commit a294aa1a6b5cc39b7800afc82d9192fcc82d9989 Author: Kurdistan Tech Ministry Date: Thu Feb 12 05:19:41 2026 +0300 Initial commit: Pezkuwi Wallet Android Security hardened release: - Code obfuscation enabled (minifyEnabled=true, shrinkResources=true) - Sensitive files excluded (google-services.json, keystores) - Branch.io key moved to BuildConfig placeholder - Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77 - Comprehensive ProGuard rules for crypto wallet - Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c795721 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# http://editorconfig.org +root = true + +[{*.kt, *.kts}] +#Ktlint configuration +max_line_length = 160 +insert_final_newline = true +indent_size = 4 +ktlint_disabled_rules = import-ordering, package-name, trailing-comma-on-call-site, trailing-comma-on-declaration-site, filename \ No newline at end of file diff --git a/.github/scripts/pr_comment_extract_data.py b/.github/scripts/pr_comment_extract_data.py new file mode 100755 index 0000000..241dfac --- /dev/null +++ b/.github/scripts/pr_comment_extract_data.py @@ -0,0 +1,72 @@ +import os +import sys +import re +from datetime import datetime, timezone +import requests + + +ALLOWED_SEVERITIES = {"Major", "Critical", "Normal"} + +# Matches: "Release severity: " (case-insensitive, flexible spaces) +SEVERITY_LINE_RE = re.compile(r"^release\s+severity\s*:\s*(.+)$", re.IGNORECASE) + + +def parse_base_params(comment_link: str) -> None: + if not comment_link: + print("COMMENT_LINK is not set. Provide a valid PR comment API URL in env var COMMENT_LINK.") + sys.exit(1) + + env_file = os.getenv("GITHUB_ENV") + if not env_file: + print("GITHUB_ENV is not set. This script expects GitHub Actions environment.") + sys.exit(1) + + try: + resp = requests.get(comment_link, timeout=10) + resp.raise_for_status() + payload = resp.json() + except requests.RequestException as e: + print(f"Failed to fetch PR comment: {e}") + sys.exit(1) + except ValueError: + print("Response is not valid JSON.") + sys.exit(1) + + body = payload.get("body") + if not isinstance(body, str) or not body.strip(): + print("PR comment body is empty. Add 'Release severity: Major | Critical | Normal'.") + sys.exit(1) + + lines = [line.strip() for line in body.splitlines()] + + severity_raw = "" + + for line in lines: + m = SEVERITY_LINE_RE.match(line) + if m: + severity_raw = m.group(1).strip() + break + + if not severity_raw: + print("Release severity is missing. Add a line 'Release severity: Major | Critical | Normal'.") + sys.exit(1) + + if severity_raw not in ALLOWED_SEVERITIES: + print(f"Invalid severity '{severity_raw}'. Allowed values: Major, Critical, Normal.") + sys.exit(1) + + severity = severity_raw + + time_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + try: + with open(env_file, "a", encoding="utf-8") as f: + f.write(f"TIME={time_iso}\n") + f.write(f"SEVERITY={severity}\n") + except OSError as e: + print(f"Failed to write to GITHUB_ENV: {e}") + sys.exit(1) + + +if __name__ == "__main__": + parse_base_params(os.getenv("COMMENT_LINK")) diff --git a/.github/scripts/run_balances_test.sh b/.github/scripts/run_balances_test.sh new file mode 100644 index 0000000..b7b8265 --- /dev/null +++ b/.github/scripts/run_balances_test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +adb devices + +# Install debug app +adb -s emulator-5554 install app/debug/app-debug.apk + +# Install instrumental tests +adb -s emulator-5554 install app/androidTest/debug/app-debug-androidTest.apk + +# Run tests +adb logcat -c && +python - < allure-results.tar + +exit $EXIT_CODE diff --git a/.github/scripts/run_instrumental_tests.sh b/.github/scripts/run_instrumental_tests.sh new file mode 100644 index 0000000..f72a82e --- /dev/null +++ b/.github/scripts/run_instrumental_tests.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +adb devices + +# Install debug app +adb -s emulator-5554 install app/debug/app-debug.apk + +# Install instrumental tests +adb -s emulator-5554 install test-app/androidTest/debug/app-debug-androidTest.apk + +# Run tests +adb logcat -c && +python - < allure-results.tar + +exit $EXIT_CODE diff --git a/.github/workflows/android_build.yml b/.github/workflows/android_build.yml new file mode 100644 index 0000000..9d3e4c2 --- /dev/null +++ b/.github/workflows/android_build.yml @@ -0,0 +1,204 @@ +name: Reusable workflow for build Android + +on: + workflow_call: + inputs: + branch: + required: true + default: main + type: string + gradlew-command: + required: false + type: string + default: "false" + run-tests: + required: false + type: boolean + default: true + keystore-file-name: + required: false + type: string + default: "false" + keystore-file-base64: + required: false + type: string + default: "false" + upload-name: + required: false + type: string + default: "apk" + build-debug-tests: + required: false + type: boolean + default: false + secrets: + # Crowdloan secrets - NOT NEEDED for Pezkuwi (own blockchain) + ACALA_PROD_AUTH_TOKEN: + required: false + ACALA_TEST_AUTH_TOKEN: + required: false + MOONBEAM_PROD_AUTH_TOKEN: + required: false + MOONBEAM_TEST_AUTH_TOKEN: + required: false + # Fiat on-ramp - OPTIONAL (future update) + MOONPAY_PRODUCTION_SECRET: + required: false + MOONPAY_TEST_SECRET: + required: false + # EVM chain support - REQUIRED for cross-chain + EHTERSCAN_API_KEY_MOONBEAM: + required: true + EHTERSCAN_API_KEY_MOONRIVER: + required: true + EHTERSCAN_API_KEY_ETHEREUM: + required: true + INFURA_API_KEY: + required: true + # RPC provider - use own nodes or Dwellir + DWELLIR_API_KEY: + required: false + # WalletConnect - REQUIRED for dApp connections + WALLET_CONNECT_PROJECT_ID: + required: true + # Google OAuth - REQUIRED for cloud backup + DEBUG_GOOGLE_OAUTH_ID: + required: true + RELEASE_GOOGLE_OAUTH_ID: + required: true + # Special secrets for signing: + CI_MARKET_KEYSTORE_PASS: + required: false + CI_MARKET_KEYSTORE_KEY_ALIAS: + required: false + CI_MARKET_KEYSTORE_KEY_PASS: + required: false + CI_MARKET_KEY_FILE: + required: false + CI_KEYSTORE_PASS: + required: false + CI_KEYSTORE_KEY_ALIAS: + required: false + CI_KEYSTORE_KEY_PASS: + required: false + CI_GITHUB_KEYSTORE_PASS: + required: false + CI_GITHUB_KEYSTORE_KEY_ALIAS: + required: false + CI_GITHUB_KEYSTORE_KEY_PASS: + required: false + # Secrets for google-services: + CI_DEVELOP_GOOGLE_SERVICES: + required: true + CI_PRODUCTION_GOOGLE_SERVICES: + required: true + +env: + ACALA_PROD_AUTH_TOKEN: ${{ secrets.ACALA_PROD_AUTH_TOKEN }} + ACALA_TEST_AUTH_TOKEN: ${{ secrets.ACALA_TEST_AUTH_TOKEN }} + MOONBEAM_PROD_AUTH_TOKEN: ${{ secrets.MOONBEAM_PROD_AUTH_TOKEN }} + MOONBEAM_TEST_AUTH_TOKEN: ${{ secrets.MOONBEAM_TEST_AUTH_TOKEN }} + MOONPAY_PRODUCTION_SECRET: ${{ secrets.MOONPAY_PRODUCTION_SECRET }} + MOONPAY_TEST_SECRET: ${{ secrets.MOONPAY_TEST_SECRET }} + MERCURYO_PRODUCTION_SECRET: ${{ secrets.MERCURYO_PRODUCTION_SECRET }} + MERCURYO_TEST_SECRET: ${{ secrets.MERCURYO_TEST_SECRET }} + EHTERSCAN_API_KEY_MOONBEAM: ${{ secrets.EHTERSCAN_API_KEY_MOONBEAM }} + EHTERSCAN_API_KEY_MOONRIVER: ${{ secrets.EHTERSCAN_API_KEY_MOONRIVER }} + EHTERSCAN_API_KEY_ETHEREUM: ${{ secrets.EHTERSCAN_API_KEY_ETHEREUM }} + INFURA_API_KEY: ${{ secrets.INFURA_API_KEY }} + DWELLIR_API_KEY: ${{ secrets.DWELLIR_API_KEY }} + WALLET_CONNECT_PROJECT_ID: ${{ secrets.WALLET_CONNECT_PROJECT_ID }} + DEBUG_GOOGLE_OAUTH_ID: ${{ secrets.DEBUG_GOOGLE_OAUTH_ID }} + RELEASE_GOOGLE_OAUTH_ID: ${{ secrets.RELEASE_GOOGLE_OAUTH_ID }} + + CI_MARKET_KEYSTORE_PASS: ${{ secrets.CI_MARKET_KEYSTORE_PASS }} + CI_MARKET_KEYSTORE_KEY_ALIAS: ${{ secrets.CI_MARKET_KEYSTORE_KEY_ALIAS }} + CI_MARKET_KEYSTORE_KEY_PASS: ${{ secrets.CI_MARKET_KEYSTORE_KEY_PASS }} + CI_MARKET_KEY_FILE: ${{ secrets.RELEASE_MARKET_KEY_FILE }} + + CI_KEYSTORE_PASS: ${{ secrets.CI_KEYSTORE_PASS }} + CI_KEYSTORE_KEY_ALIAS: ${{ secrets.CI_KEYSTORE_KEY_ALIAS }} + CI_KEYSTORE_KEY_PASS: ${{ secrets.CI_KEYSTORE_KEY_PASS }} + + CI_GITHUB_KEYSTORE_PASS: ${{ secrets.CI_GITHUB_KEYSTORE_PASS }} + CI_GITHUB_KEYSTORE_KEY_ALIAS: ${{ secrets.CI_GITHUB_KEYSTORE_KEY_ALIAS }} + CI_GITHUB_KEYSTORE_KEY_PASS: ${{ secrets.CI_GITHUB_KEYSTORE_KEY_PASS }} + CI_GITHUB_KEYSTORE_KEY_FILE: ${{ secrets.BASE64_GITHUB_KEYSTORE_FILE }} + + CI_DEVELOP_GOOGLE_SERVICES_FILE: ${{ secrets.CI_DEVELOP_GOOGLE_SERVICES }} + CI_PRODUCTION_GOOGLE_SERVICES_FILE: ${{ secrets.CI_PRODUCTION_GOOGLE_SERVICES }} + + POLKASSEMBLY_SUMMARY_API_KEY: ${{ secrets.POLKASSEMBLY_SUMMARY_API_KEY }} + + CI_BUILD_ID: ${{ github.run_number }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{inputs.upload-name}} + cancel-in-progress: true + +jobs: + build-app: + name: Build app and test + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - name: Checkout particular branch + uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch }} + + - name: 📂 Set up DEV Google Services + uses: davidSchuppa/base64Secret-toFile-action@v3 + with: + secret: ${{ env.CI_DEVELOP_GOOGLE_SERVICES_FILE }} + filename: google-services.json + destination-path: ./app/ + + - name: 📂 Set up PROD Google Services + uses: davidSchuppa/base64Secret-toFile-action@v3 + with: + secret: ${{ env.CI_PRODUCTION_GOOGLE_SERVICES_FILE }} + filename: google-services.json + destination-path: ./app/src/release/ + + - name: 🔧 Install dependencies + uses: ./.github/workflows/install/ + + - name: 🧪 Run tests + if: ${{ inputs.run-tests }} + run: ./gradlew runTest + + - name: 🔐 Getting github sign key + if: ${{ startsWith(inputs.keystore-file-name, 'github_key.jks') }} + uses: timheuer/base64-to-file@v1.1 + with: + fileName: ${{ inputs.keystore-file-name }} + fileDir: './app/' + encodedString: ${{ env.CI_GITHUB_KEYSTORE_KEY_FILE }} + + - name: 🔐 Getting market sign key + if: ${{ startsWith(inputs.keystore-file-name, 'market_key.jks') }} + uses: timheuer/base64-to-file@v1.1 + with: + fileName: ${{ inputs.keystore-file-name }} + fileDir: './app/' + encodedString: ${{ env.CI_MARKET_KEY_FILE }} + + - name: 🏗 Build app + if: ${{ !startsWith(inputs.gradlew-command, 'false') }} + run: ./gradlew ${{ inputs.gradlew-command }} + + - name: 🏗 Build debug tests + if: ${{ inputs.build-debug-tests }} + run: ./gradlew assembleDebugAndroidTest + + - name: 🧹 Delete key after building + if: ${{ !startsWith(inputs.keystore-file-name, 'false') }} + run: rm ./app/${{ inputs.keystore-file-name }} + + - name: ➡️ Upload build artifacts + if: ${{ !startsWith(inputs.gradlew-command, 'false') }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.upload-name }} + path: app/build/outputs/apk/ diff --git a/.github/workflows/appium-mobile-tests.yml b/.github/workflows/appium-mobile-tests.yml new file mode 100644 index 0000000..497fbbf --- /dev/null +++ b/.github/workflows/appium-mobile-tests.yml @@ -0,0 +1,68 @@ +name: Appium Mobile Tests + +on: + workflow_call: + inputs: + app_url: + type: string + description: URL to download the app from + required: true + test_grep: + type: string + description: Test pattern to run (pytest marker or test name) + required: false + default: "android" + allure_job_run_id: + type: string + description: ALLURE_JOB_RUN_ID service parameter. Leave blank. + required: false + default: "" + allure_username: + type: string + description: ALLURE_USERNAME service parameter. Leave blank. + required: false + default: "" + secrets: + WORKFLOW_TOKEN: + required: true + ALLURE_TOKEN: + required: false + +env: + PYTHON_VERSION: '3.9' + CI: true + ALLURE_ENDPOINT: https://pezkuwi.testops.cloud/ + ALLURE_PROJECT_ID: 103 + +jobs: + trigger-tests: + runs-on: ubuntu-latest + steps: + - name: Trigger mobile tests in test repository + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.WORKFLOW_TOKEN }} + script: | + const response = await github.rest.actions.createWorkflowDispatch({ + owner: 'pezkuwichain', + repo: 'appium-mobile-tests', + workflow_id: 'browserstack-tests.yml', + ref: 'master', + inputs: { + app_url: '${{ inputs.app_url }}', + ALLURE_JOB_RUN_ID: '${{ inputs.allure_job_run_id }}', + ALLURE_USERNAME: '${{ inputs.allure_username }}' + } + }); + + console.log('Mobile tests triggered successfully'); + console.log('App URL:', '${{ inputs.app_url }}'); + + - name: Wait for test completion (optional) + if: false + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.WORKFLOW_TOKEN }} + script: | + console.log('Waiting for test completion...'); + diff --git a/.github/workflows/balances_test.yml b/.github/workflows/balances_test.yml new file mode 100644 index 0000000..9054520 --- /dev/null +++ b/.github/workflows/balances_test.yml @@ -0,0 +1,93 @@ +name: Run balances tests + +on: + workflow_dispatch: + schedule: + - cron: '0 */8 * * *' + +jobs: + build-app: + uses: pezkuwichain/pezkuwi-wallet-android/.github/workflows/android_build.yml@main + with: + branch: ${{github.head_ref}} + gradlew-command: assembleDebug + upload-name: develop-apk + run-tests: false + build-debug-tests: true + secrets: inherit + + run-tests: + needs: [build-app] + runs-on: macos-13 + steps: + - uses: actions/checkout@v4 + + - name: Download built artifact + uses: actions/download-artifact@v4 + with: + name: develop-apk + path: app + + - name: Debug path + run: | + ls -laR app + + - name: Add permissions + run: chmod +x .github/scripts/run_balances_test.sh + + - name: Run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + disable-animations: true + profile: Nexus 6 + api-level: 29 + script: .github/scripts/run_balances_test.sh + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: anroid-results + path: ./allure-results.tar + + report: + needs: [run-tests] + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Download artifact + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Unzip results + run: | + find artifacts -name allure-results.tar -exec tar -xvf {} \; + + - name: Debug path + run: | + ls -laR + + - name: Generate report + uses: ./.github/workflows/report/ + with: + token: ${{ secrets.ACTIONS_DEPLOY_KEY }} + keep-reports-history: 30 + + telegram-notification: + needs: [report] + runs-on: ubuntu-latest + if: failure() + steps: + - name: Notify Telegram channel + uses: appleboy/telegram-action@master + with: + to: ${{ secrets.TELEGRAM_TO }} + token: ${{ secrets.TELEGRAM_TOKEN }} + format: html + message: | + 💸 Balances tests failed. + + Test Results: https://pezkuwichain.github.io/balances_test_result/${{ github.run_number }}/index.html + + Github run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/distribute_app_to_play_store.yml b/.github/workflows/distribute_app_to_play_store.yml new file mode 100644 index 0000000..03f28f6 --- /dev/null +++ b/.github/workflows/distribute_app_to_play_store.yml @@ -0,0 +1,56 @@ +name: Distribute app to Play Store + +on: + workflow_dispatch: + inputs: + app_version: + description: 'Version of application' + required: true + default: v*.*.* + branch: + description: 'From which branch the application will be built' + required: true + default: main + +jobs: + build: + uses: pezkuwichain/pezkuwi-wallet-android/.github/workflows/android_build.yml@main + with: + branch: ${{ github.event.inputs.branch }} + gradlew-command: assembleReleaseMarket + keystore-file-name: market_key.jks + secrets: inherit + + upload: + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - name: Set Environment Variables + uses: tw3lveparsecs/github-actions-setvars@v0.1 + with: + envFilePath: .github/workflows/variables/android.env + + - name: Download built artifact + uses: actions/download-artifact@v4 + with: + name: apk + path: app + + - name: Rename artifacts + run: mv app/releaseMarket/app-releaseMarket.apk app/releaseMarket/pezkuwi-wallet-android-${{ github.event.inputs.app_version }}.apk + + - name: Market publication + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} # The contents of your service-account.json + packageName: io.pezkuwichain.wallet + releaseFiles: app/releaseMarket/pezkuwi-wallet-android-${{ github.event.inputs.app_version }}.apk + track: production # One of production, beta, alpha, internalsharing, internal, or a custom track name (case sensitive) + status: draft # One of "completed", "inProgress", "halted", "draft" + inAppUpdatePriority: 2 + userFraction: 1.0 # Percentage of users who should get the staged version of the app. Defaults to 1.0 + whatsNewDirectory: distribution/whatsnew # The directory of localized "whats new" files to upload as the release notes. The files contained in the whatsNewDirectory MUST use the pattern whatsnew- where LOCALE is using the BCP 47 format + mappingFile: app/build/outputs/mapping/release/mapping.txt # The mapping.txt file used to de-obfuscate your stack traces from crash reports + debugSymbols: app/intermediates/merged_native_libs/release/out/lib diff --git a/.github/workflows/install/action.yml b/.github/workflows/install/action.yml new file mode 100644 index 0000000..a7ed92e --- /dev/null +++ b/.github/workflows/install/action.yml @@ -0,0 +1,37 @@ +name: Install dependencies for Android build +description: Contains all dependencies for Android build +runs: + using: "composite" + steps: + - name: ☕️ Install Java + uses: actions/setup-java@v4.0.0 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + cmdline-tools-version: 12266719 + + - name: Install NDK + run: echo "y" | sudo ${ANDROID_SDK_ROOT}/cmdline-tools/16.0/bin/sdkmanager --install "ndk;26.1.10909125" --sdk_root=${ANDROID_SDK_ROOT} + shell: bash + + - name: Set ndk.dir in local.properties + run: echo "ndk.dir=${{ steps.setup-ndk.outputs.ndk-path }}" >> local.properties + shell: bash + + - name: 🦀 Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Add targets + run: | + rustup target add armv7-linux-androideabi + rustup target add i686-linux-android + rustup target add x86_64-linux-android + rustup target add aarch64-linux-android + shell: bash \ No newline at end of file diff --git a/.github/workflows/manual_firebase_distribution.yml b/.github/workflows/manual_firebase_distribution.yml new file mode 100644 index 0000000..ca836bd --- /dev/null +++ b/.github/workflows/manual_firebase_distribution.yml @@ -0,0 +1,42 @@ +name: Manual Firebase distribution + +on: + workflow_dispatch: + inputs: + firebase_group: + description: 'Firebase group' + required: true + default: dev-team + branch: + description: 'From which branch the application will be built' + required: true + default: main + +jobs: + build: + uses: pezkuwichain/pezkuwi-wallet-android/.github/workflows/android_build.yml@main + with: + branch: ${{ github.event.inputs.branch }} + gradlew-command: assembleDevelop + secrets: inherit + + upload: + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - name: Download built artifact + uses: actions/download-artifact@v4 + with: + name: apk + path: app + + - name: 🗳 Upload to Firebase + uses: ./.github/workflows/upload-to-firebase + with: + appId: ${{ secrets.ANDROID_DEVELOP_FIREBASE_APP_ID }} + firebase-token: ${{ secrets.CREDENTIAL_FILE_CONTENT }} + releaseNotes: ${{ github.event.head_commit.message }} + test-groups: ${{ github.event.inputs.firebase_group }} + upload-file: app/develop/app-develop.apk diff --git a/.github/workflows/pr_workflow.yml b/.github/workflows/pr_workflow.yml new file mode 100644 index 0000000..e9cf43d --- /dev/null +++ b/.github/workflows/pr_workflow.yml @@ -0,0 +1,76 @@ +name: PR Workflow + +on: + pull_request: + branches: + - 'master' + pull_request_review_comment: + types: [created, edited, deleted] + +jobs: + checkRef: + if: github.event.pull_request.base.ref == 'master' || github.event_name == 'pull_request' + runs-on: ubuntu-latest + outputs: + is_rc: ${{ steps.check_ref.outputs.ref_contains_rc }} + + steps: + - uses: actions/checkout@v4 + - name: Check if "rc" or "hotfix" is present in github.ref + id: check_ref + run: | + echo ${{ github.head_ref || github.ref_name }} + if [[ "${{ github.head_ref || github.ref_name }}" == "rc/"* || "${{ github.head_ref || github.ref_name }}" == "hotfix/"* ]]; then + echo "ref_contains_rc=1" >> $GITHUB_OUTPUT + else + echo "ref_contains_rc=0" >> $GITHUB_OUTPUT + fi + + - name: Output check result + run: | + echo "Output: ${{ steps.check_ref.outputs.ref_contains_rc }}" + + make-or-update-pr: + runs-on: ubuntu-latest + permissions: write-all + needs: checkRef + if: needs.checkRef.outputs.is_rc == '1' + + steps: + - uses: actions/checkout@v4 + - name: Find Comment + uses: peter-evans/find-comment@v3 + id: fc + with: + issue-number: ${{ github.event.pull_request.number }} + body-includes: Release notes + + - name: Create comment link + id: create_link + run: | + echo "COMMENT_LINK=https://api.github.com/repos/${{ github.repository }}/issues/comments/${{ steps.fc.outputs.comment-id }}" >> $GITHUB_ENV + shell: bash + + - name: Extract version from branch name + id: extract_version + run: | + VERSION=${{ github.head_ref }} + VERSION=${VERSION/hotfix/rc} # Replace "hotfix" with "rc" + echo "version=${VERSION#*rc/}" >> $GITHUB_OUTPUT + + - uses: tibdex/github-app-token@v2 + id: generate-token + with: + app_id: ${{ secrets.PR_APP_ID }} + private_key: ${{ secrets.PR_APP_TOKEN }} + + - name: Run Python script + run: python .github/scripts/pr_comment_extract_data.py + + - name: Create new branch and file in pezkuwi-wallet-android-releases repo + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ steps.generate-token.outputs.token }} + repository: pezkuwichain/pezkuwi-wallet-android-releases + event-type: create-pr + client-payload: '{"version": "${{ steps.extract_version.outputs.version }}", "comment_link": "${{ env.COMMENT_LINK }}", "time": "${{ env.TIME }}", "severity": "${{ env.SEVERITY }}"}' diff --git a/.github/workflows/publish_github_release.yml b/.github/workflows/publish_github_release.yml new file mode 100644 index 0000000..443117b --- /dev/null +++ b/.github/workflows/publish_github_release.yml @@ -0,0 +1,88 @@ +name: Publish GitHub release + +on: + push: + tags: + - '*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + build: + uses: pezkuwichain/pezkuwi-wallet-android/.github/workflows/android_build.yml@main + with: + branch: master + gradlew-command: assembleReleaseGithub + keystore-file-name: github_key.jks + secrets: inherit + + create-release: + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + + - name: Download built artifact + uses: actions/download-artifact@v4 + with: + name: apk + path: app + + - name: Rename artifacts + run: mv app/releaseGithub/app-releaseGithub.apk app/releaseGithub/pezkuwi-wallet-android-${{ github.ref_name }}-github.apk + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v1 + with: + name: Release ${{ github.ref_name }} + tag_name: ${{ github.ref_name }} + generate_release_notes: true + draft: true + files: app/releaseGithub/pezkuwi-wallet-android-${{ github.ref_name }}-github.apk + + deploy-to-vps: + runs-on: ubuntu-latest + needs: build + + steps: + - name: Download built artifact + uses: actions/download-artifact@v4 + with: + name: apk + path: deploy + + - name: Prepare APK for VPS + run: | + mkdir -p ./vps-deploy + mv deploy/releaseGithub/app-releaseGithub.apk ./vps-deploy/pezkuwi-wallet.apk + + - name: Create version.json + run: | + TAG="${{ github.ref_name }}" + VERSION="${TAG#v}" + cat > ./vps-deploy/version.json << EOF + { + "version": "$VERSION", + "tag": "$TAG", + "apk": "pezkuwi-wallet.apk", + "updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } + EOF + + - name: Deploy to VPS + uses: appleboy/scp-action@v1.0.0 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + source: 'vps-deploy/*' + target: '/var/www/wallet.pezkuwichain.io' + strip_components: 1 + overwrite: true diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..6fe7dc9 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,14 @@ +name: Pull request + +on: + pull_request: + + +jobs: + test: + uses: pezkuwichain/pezkuwi-wallet-android/.github/workflows/android_build.yml@main + with: + branch: ${{github.head_ref}} + gradlew-command: assembleDevelop + build-debug-tests: false # TODO: Enable this, when debug build will be fixed for tests + secrets: inherit diff --git a/.github/workflows/push_develop.yml b/.github/workflows/push_develop.yml new file mode 100644 index 0000000..16e2fce --- /dev/null +++ b/.github/workflows/push_develop.yml @@ -0,0 +1,78 @@ +name: Build test and deploy debug apk + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + uses: pezkuwichain/pezkuwi-wallet-android/.github/workflows/android_build.yml@main + with: + branch: main + gradlew-command: assembleDebug + secrets: inherit + + upload-to-firebase: + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + - name: Download built artifact + uses: actions/download-artifact@v4 + with: + name: apk + path: app + + - name: 🗳 Upload to Firebase + uses: ./.github/workflows/upload-to-firebase + with: + appId: ${{ secrets.ANDROID_DEBUG_FIREBASE_APP_ID }} + firebase-token: ${{ secrets.CREDENTIAL_FILE_CONTENT }} + releaseNotes: ${{ github.event.head_commit.message }} + test-groups: dev-team + upload-file: app/debug/app-debug.apk + + upload-to-s3: + runs-on: ubuntu-latest + needs: build + outputs: + s3_url: ${{ steps.s3_upload.outputs.s3_url }} + env: + S3_BUCKET: s3://pezkuwi-wallet-android-app + S3_REGION: nl-ams + + steps: + - uses: actions/checkout@v4 + - name: Download built artifact + uses: actions/download-artifact@v4 + with: + name: apk + path: app + + - name: ⚙️ Upload to S3 + id: s3_upload + uses: ./.github/workflows/upload-to-s3 + with: + s3_region: ${{ env.S3_REGION }} + s3_access_key: ${{ secrets.SCW_ACCESS_KEY }} + s3_secret_key: ${{ secrets.SCW_SECRET_KEY }} + s3_bucket: ${{ env.S3_BUCKET }} + upload_file: app/debug/app-debug.apk + + - name: Show S3 URL + run: | + echo "App uploaded to: ${{ steps.s3_upload.outputs.s3_url }}" + + appium-mobile-tests: + needs: [upload-to-s3] + if: ${{ always() && needs.upload-to-s3.result == 'success' }} + uses: ./.github/workflows/appium-mobile-tests.yml + with: + app_url: ${{ needs.upload-to-s3.outputs.s3_url }} + test_grep: "android" + allure_job_run_id: "" + secrets: + WORKFLOW_TOKEN: ${{ secrets.PAT_TOKEN }} + ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} diff --git a/.github/workflows/report/action.yml b/.github/workflows/report/action.yml new file mode 100644 index 0000000..c732b9a --- /dev/null +++ b/.github/workflows/report/action.yml @@ -0,0 +1,40 @@ +name: Publish report to gh-pages +description: That workflow will publish report to the github-pages +inputs: + keep-reports-history: + description: "History storage depth, integer" + required: true + token: + description: "Github PAT" + required: true + +runs: + using: "composite" + steps: + - name: Get Allure history + uses: actions/checkout@v4 + if: always() + continue-on-error: true + with: + repository: pezkuwichain/balances_test_result + ref: gh-pages + path: gh-pages + + - name: Allure Report action + uses: simple-elf/allure-report-action@master + if: always() + with: + allure_results: allure-results + allure_history: allure-history + keep_reports: ${{ inputs.keep-reports-history }} + github_repo: balances_test_result + github_repo_owner: pezkuwichain + + - name: Deploy report to Github Pages + if: always() + uses: peaceiris/actions-gh-pages@v4 + with: + deploy_key: ${{ inputs.token }} + publish_branch: gh-pages + publish_dir: allure-history + external_repository: pezkuwichain/balances_test_result diff --git a/.github/workflows/sync-branches.yml b/.github/workflows/sync-branches.yml new file mode 100644 index 0000000..54f4786 --- /dev/null +++ b/.github/workflows/sync-branches.yml @@ -0,0 +1,34 @@ +name: Sync main and master branches + +on: + push: + branches: + - main + - master + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.PAT_TOKEN }} + + - name: Sync branches + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "main was updated, syncing master..." + git checkout master + git reset --hard origin/main + git push origin master --force + elif [ "${{ github.ref }}" = "refs/heads/master" ]; then + echo "master was updated, syncing main..." + git checkout main + git reset --hard origin/master + git push origin main --force + fi diff --git a/.github/workflows/update_tag.yml b/.github/workflows/update_tag.yml new file mode 100644 index 0000000..55edd2a --- /dev/null +++ b/.github/workflows/update_tag.yml @@ -0,0 +1,43 @@ +name: Bump app version + +on: + push: + branches: + ['master'] + +permissions: + contents: write + +jobs: + update-tag: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Version in build.gradle + run: | + versionName=$(grep "versionName" build.gradle | grep -o "'.*'") + versionName=${versionName//\'/} + echo Version in gradle file: $versionName + echo "GRADLE_APP_VERSION=$versionName" >> "$GITHUB_ENV" + + - name: Check if tag exists + id: version + run: | + if git rev-parse "v${{ env.GRADLE_APP_VERSION }}" >/dev/null 2>&1; then + echo "Tag already exists" + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "Tag does not exist" + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - uses: rickstaa/action-create-tag@v1 + if: steps.version.outputs.changed == 'true' + with: + tag: 'v${{ env.GRADLE_APP_VERSION }}' + message: Release v${{ env.GRADLE_APP_VERSION }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/upload-to-firebase/action.yml b/.github/workflows/upload-to-firebase/action.yml new file mode 100644 index 0000000..ef65425 --- /dev/null +++ b/.github/workflows/upload-to-firebase/action.yml @@ -0,0 +1,49 @@ +name: Deploy to Firebase +description: Deploy artifacts by provided path to firebase for provided groups +inputs: + appId: + description: 'Firebase AppID' + required: true + firebase-token: + description: 'Token from firebase CLI' + required: true + releaseNotes: + description: 'Notes which will attach to version' + required: true + default: 'update' + test-groups: + description: 'Groups which will receive the version' + required: true + upload-file: + description: 'File to uploading' + required: true + +runs: + using: "composite" + steps: + - name: Upload artifact to Firebase App Distribution + id: upload + continue-on-error: true + uses: wzieba/Firebase-Distribution-Github-Action@v1.7.0 + with: + appId: ${{ inputs.appId }} + serviceCredentialsFileContent: ${{ inputs.firebase-token }} + releaseNotes: ${{ inputs.releaseNotes }} + groups: ${{ inputs.test-groups }} + file: ${{ inputs.upload-file }} + + - name: Sleep for 60 seconds + uses: whatnick/wait-action@master + if: steps.upload.outcome=='failure' + with: + time: '60s' + + - name: Retry upload artifacts + if: steps.upload.outcome=='failure' + uses: wzieba/Firebase-Distribution-Github-Action@v1.7.0 + with: + appId: ${{ inputs.appId }} + serviceCredentialsFileContent: ${{ inputs.firebase-token }} + releaseNotes: ${{ inputs.releaseNotes }} + groups: ${{ inputs.test-groups }} + file: ${{ inputs.upload-file }} diff --git a/.github/workflows/upload-to-s3/action.yml b/.github/workflows/upload-to-s3/action.yml new file mode 100644 index 0000000..6328ab7 --- /dev/null +++ b/.github/workflows/upload-to-s3/action.yml @@ -0,0 +1,52 @@ +name: Upload to s3 +description: Upload artifacts to s3 +inputs: + s3_region: + description: 'S3 region' + required: true + s3_access_key: + description: 'S3 access key' + required: true + s3_secret_key: + description: 'S3 secret key' + required: true + s3_bucket: + description: 'S3 bucket' + required: true + upload_file: + description: 'File to uploading' + required: true + +outputs: + s3_url: + description: 'URL of the uploaded file' + value: ${{ steps.interact_with_storage.outputs.s3_url }} + +runs: + using: "composite" + steps: + - name: Set up S3cmd cli tool + uses: s3-actions/s3cmd@v1.6.1 + with: + provider: scaleway + region: ${{ inputs.s3_region }} + secret_key: ${{ inputs.s3_secret_key }} + access_key: ${{ inputs.s3_access_key }} + + - name: List available S3 buckets + run: s3cmd ls + shell: bash + + - name: Interact with object storage + id: interact_with_storage + run: | + file="${{ inputs.upload_file }}" + destination_s3="${{ inputs.s3_bucket }}" + filename=$(basename "$file") + s3cmd sync "$file" "${destination_s3}/${filename}" --acl-public + + bucket_name=$(echo "${{ inputs.s3_bucket }}" | sed 's|s3://||') + s3_url="https://${bucket_name}.s3.${{ inputs.s3_region }}.scw.cloud/${filename}" + echo "s3_url=${s3_url}" >> $GITHUB_OUTPUT + echo "Uploaded file URL: ${s3_url}" + shell: bash diff --git a/.github/workflows/variables/android.env b/.github/workflows/variables/android.env new file mode 100644 index 0000000..64c1477 --- /dev/null +++ b/.github/workflows/variables/android.env @@ -0,0 +1,3 @@ +# Android Build Variables for Pezkuwi Wallet +APP_NAME=Pezkuwi Wallet +PACKAGE_NAME=io.pezkuwichain.wallet diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..524e7b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +*.iml +.gradle +/local.properties +.DS_Store +/build +*/build +/captures +.externalNativeBuild +app/src/main/aidl/ +app/*.apk +/.idea/ + +# ignore jacoco coverage reports +/coverage + +*.jks +.java-version + +# ignore database schemas +/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/*.json + +# database schemas exceptions +!/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/1.json +!/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/2.json +!/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/8.json +!/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/9.json + +# Firebase config - contains sensitive API keys +google-services.json +**/google-services.json + +# Version properties +version.properties +.kotlin/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..12e3923 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "nova-wallet-dapp-js"] + path = nova-wallet-dapp-js + url = git@github.com:nova-wallet/nova-wallet-dapp-js.git +[submodule "nova-wallet-metamask-js"] + path = nova-wallet-metamask-js + url = git@github.com:nova-wallet/nova-wallet-metamask-js.git diff --git a/CHANGELOG_PEZKUWI.md b/CHANGELOG_PEZKUWI.md new file mode 100644 index 0000000..5acfdc0 --- /dev/null +++ b/CHANGELOG_PEZKUWI.md @@ -0,0 +1,380 @@ +# PezWallet Android - Pezkuwi Uyumluluk Değişiklikleri + +Bu dosya, Pezkuwi chain uyumluluğu için yapılan tüm değişiklikleri takip eder. +Context sıfırlanması durumunda referans olarak kullanılmalıdır. + +--- + +## DEBUG KODLARI (Production öncesi KALDIRILMALI) + +### 1. FeeLoaderV2Provider.kt - Hata mesajı gösterimi +**Dosya:** `feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderV2Provider.kt` +**Değişiklik:** +```kotlin +// ÖNCE: +message = resourceManager.getString(R.string.choose_amount_error_fee), + +// SONRA (DEBUG): +message = "DEBUG: $errorMsg | Runtime: $diagnostics", +``` +**Temizleme:** +- `"DEBUG: $errorMsg | Runtime: $diagnostics"` → `resourceManager.getString(R.string.choose_amount_error_fee)` olarak geri al +- `val diagnostics = try { ... }` bloğunu kaldır + +--- + +### 2. RuntimeFactory.kt - Diagnostic değişken ve log'lar +**Dosya:** `runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeFactory.kt` +**Eklenenler:** +```kotlin +// Companion object içinde: +companion object { + @Volatile + var lastDiagnostics: String = "not yet initialized" +} + +// constructRuntimeInternal içinde: +lastDiagnostics = "typesUsage=$typesUsage, ExtrinsicSig=$hasExtrinsicSignature, MultiSig=$hasMultiSignature, typeCount=${types.size}" + +// Log satırları: +Log.d("RuntimeFactory", "DEBUG: TypesUsage for chain $chainId = $typesUsage") +Log.d("RuntimeFactory", "DEBUG: Loading BASE types for $chainId") +Log.d("RuntimeFactory", "DEBUG: BASE types loaded, hash=$baseHash, typeCount=${types.size}") +Log.d("RuntimeFactory", "DEBUG: Chain $chainId - ExtrinsicSignature=$hasExtrinsicSignature, MultiSignature=$hasMultiSignature, typesUsage=$typesUsage, typeCount=${types.size}") +Log.d("RuntimeFactory", "DEBUG: BaseTypes loaded, length=${baseTypesRaw.length}, contains ExtrinsicSignature=${baseTypesRaw.contains("ExtrinsicSignature")}") +Log.e("RuntimeFactory", "DEBUG: BaseTypes NOT in cache!") +``` +**Temizleme:** +- `companion object { ... }` bloğunu kaldır +- `lastDiagnostics = ...` satırını kaldır +- Tüm `Log.d/Log.e("RuntimeFactory", "DEBUG: ...")` satırlarını kaldır + +--- + +### 3. CustomTransactionExtensions.kt - Log satırları +**Dosya:** `runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/CustomTransactionExtensions.kt` +**Temizlenecek:** Tüm `Log.d(TAG, ...)` satırları ve `private const val TAG` tanımı + +--- + +### 4. ExtrinsicBuilderFactory.kt - Log satırları +**Dosya:** `runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicBuilderFactory.kt` +**Temizlenecek:** Tüm `Log.d(TAG, ...)` satırları ve `private const val TAG` tanımı + +--- + +### 5. PezkuwiAddressConstructor.kt - Log satırları +**Dosya:** `common/src/main/java/io/novafoundation/nova/common/utils/PezkuwiAddressConstructor.kt` +**Temizlenecek:** Tüm `Log.d(TAG, ...)` satırları ve `private const val TAG` tanımı + +--- + +### 6. RealExtrinsicService.kt - Extrinsic build hata log'u +**Dosya:** `feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicService.kt` +**Eklenen:** +```kotlin +val extrinsic = try { + extrinsicBuilder.buildExtrinsic() +} catch (e: Exception) { + Log.e("RealExtrinsicService", "Failed to build extrinsic for chain ${chain.name}", e) + Log.e("RealExtrinsicService", "SigningMode: $signingMode, Chain: ${chain.id}") + throw e +} +``` +**Temizleme:** try-catch bloğunu kaldır, sadece `extrinsicBuilder.buildExtrinsic()` bırak + +--- + +## FEATURE DEĞİŞİKLİKLERİ (Kalıcı) + +### 1. PezkuwiAddressConstructor.kt - YENİ DOSYA +**Dosya:** `common/src/main/java/io/novafoundation/nova/common/utils/PezkuwiAddressConstructor.kt` +**Açıklama:** Pezkuwi chain'leri için özel address constructor. SDK'nın AddressInstanceConstructor'ı "Address" type'ını ararken, Pezkuwi "pezsp_runtime::multiaddress::MultiAddress" kullanıyor. + +--- + +### 2. RuntimeSnapshotExt.kt - Address type lookup +**Dosya:** `runtime/src/main/java/io/novafoundation/nova/runtime/util/RuntimeSnapshotExt.kt` +**Değişiklik:** Birden fazla address type ismi deneniyor: +```kotlin +val addressType = typeRegistry["Address"] + ?: typeRegistry["MultiAddress"] + ?: typeRegistry["sp_runtime::multiaddress::MultiAddress"] + ?: typeRegistry["pezsp_runtime::multiaddress::MultiAddress"] + ?: return false +``` + +--- + +### 3. Signed Extension Dosyaları - YENİ DOSYALAR +**Dosyalar:** +- `runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/AuthorizeCall.kt` +- `runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckNonZeroSender.kt` +- `runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckWeight.kt` +- `runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/WeightReclaim.kt` +- `runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckMortality.kt` + +**Açıklama:** Pezkuwi chain'leri için özel signed extension'lar + +--- + +### 3.1. PezkuwiCheckMortality.kt - YENİ DOSYA +**Dosya:** `runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckMortality.kt` +**Açıklama:** SDK'nın CheckMortality'si metadata type lookup yaparak encode ediyor ve Pezkuwi'de bu başarısız oluyordu ("failed to encode extension CheckMortality" hatası). Bu custom extension, Era ve blockHash'i doğrudan encode ediyor. +**Neden gerekli:** SDK CheckMortality, Era type'ını metadata'dan arıyor. Pezkuwi metadata'sında Era type'ı `pezsp_runtime.generic.era.Era` DictEnum olarak tanımlı ve SDK bunu handle edemiyor. + +**Kod:** +```kotlin +class PezkuwiCheckMortality( + era: Era.Mortal, + blockHash: ByteArray +) : FixedValueTransactionExtension( + name = "CheckMortality", + implicit = blockHash, // blockHash goes into signer payload + explicit = createEraEntry(era) // Era as DictEnum.Entry +) +``` + +--- + +### 4. CustomTransactionExtensions.kt - Pezkuwi extension logic +**Dosya:** `runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/CustomTransactionExtensions.kt` +**Değişiklik:** `isPezkuwiChain()` fonksiyonu eklendi, Pezkuwi için farklı extension'lar kullanılıyor + +--- + +### 5. Address encoding yaklaşımı değişikliği +**Eski yaklaşım:** `AddressInstanceConstructor` veya `PezkuwiAddressConstructor` ile type ismine göre tahmin +**Yeni yaklaşım:** `argumentType("dest").constructAccountLookupInstance(accountId)` ile metadata'dan gerçek type alınıyor + +**Güncellenen dosyalar:** +1. `feature-wallet-api/.../ExtrinsicBuilderExt.kt` - **YENİ YAKLAŞIM**: metadata'dan type alıyor +2. `feature-governance-impl/.../ExtrinsicBuilderExt.kt` - Zaten doğru yaklaşımı kullanıyordu +3. Diğer dosyalar hala PezkuwiAddressConstructor kullanıyor (gerekirse güncellenecek): + - `feature-staking-impl/.../ExtrinsicBuilderExt.kt` + - `feature-staking-impl/.../NominationPoolsCalls.kt` + - `feature-proxy-api/.../ExtrinsicBuilderExt.kt` + - `feature-wallet-impl/.../StatemineAssetTransfers.kt` + - `feature-wallet-impl/.../OrmlAssetTransfers.kt` + - `feature-wallet-impl/.../NativeAssetIssuer.kt` + - `feature-wallet-impl/.../OrmlAssetIssuer.kt` + - `feature-wallet-impl/.../StatemineAssetIssuer.kt` + - `feature-account-impl/.../ProxiedSigner.kt` + +--- + +### 6. CHAINS_URL - GitHub'a yönlendirme +**Dosya:** `runtime/build.gradle` +**Değişiklik:** +```gradle +// ÖNCE: +buildConfigField "String", "CHAINS_URL", "\"https://wallet.pezkuwichain.io/chains.json\"" + +// SONRA: +buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" +``` +**Neden:** wallet.pezkuwichain.io/chains.json Telegram miniapp için kullanılıyor ve `"types": null`. Android için ayrı chains.json gerekli. + +--- + +### 7. chains/v22/android/chains.json - Android-specific chains +**Repo:** `pezkuwi-wallet-utils` +**Dosya:** `chains/v22/android/chains.json` +**Açıklama:** Android uygulama için özel chains.json. wallet.pezkuwichain.io'dan kopyalandı ve şu değişiklikler yapıldı: +- `"types": { "overridesCommon": false }` eklendi (TypesUsage.BASE için) +- `"feeViaRuntimeCall": true` eklendi +**Etkilenen chain'ler:** +- Pezkuwi Mainnet (bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75) +- Pezkuwi Asset Hub (00d0e1d0581c3cd5c5768652d52f4520184018b44f56a2ae1e0dc9d65c00c948) +- Pezkuwi People Chain (58269e9c184f721e0309332d90cafc410df1519a5dc27a5fd9b3bf5fd2d129f8) +- Zagros Testnet (96eb58af1bb7288115b5e4ff1590422533e749293f231974536dc6672417d06f) + +--- + +### 8. default.json - MultiAddress inline tanımı +**Repo:** `pezkuwi-wallet-utils` +**Dosya:** `chains/types/default.json` +**Değişiklik:** MultiAddress artık GenericMultiAddress'e referans vermiyor, inline enum olarak tanımlı: +```json +"MultiAddress": { + "type": "enum", + "type_mapping": [ + ["Id", "AccountId"], + ["Index", "Compact"], + ["Raw", "Bytes"], + ["Address32", "H256"], + ["Address20", "H160"] + ] +} +``` +**Neden:** v14Preset() GenericMultiAddress içermiyor, bu yüzden type çözümlenemiyordu. + +--- + +### 9. PezkuwiIntegrationTest.kt - YENİ DOSYA +**Dosya:** `app/src/androidTest/java/io/novafoundation/nova/PezkuwiIntegrationTest.kt` +**Açıklama:** Pezkuwi chain'leri için integration testleri: +- Runtime type kontrolü (ExtrinsicSignature, MultiSignature, Address, MultiAddress) +- ExtrinsicBuilder oluşturma +- Transfer call yapısı kontrolü +- Signed extensions kontrolü +- Utility asset kontrolü +**Çalıştırma:** +```bash +./gradlew :app:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=io.novafoundation.nova.PezkuwiIntegrationTest +``` + +--- + +### 10. GitHub Actions - Branch senkronizasyonu +**Dosya:** `.github/workflows/sync-branches.yml` +**Açıklama:** main ve master branch'lerini otomatik senkronize eder. +- main'e push → master güncellenir +- master'a push → main güncellenir + +--- + +### 7. pezkuwi.json - Chain-specific types (ASSETS) +**Dosya:** `runtime/src/main/assets/types/pezkuwi.json` +**Açıklama:** Pezkuwi chain'leri için özel type tanımları +```json +{ + "types": { + "ExtrinsicSignature": "MultiSignature", + "Address": "pezsp_runtime::multiaddress::MultiAddress", + "LookupSource": "pezsp_runtime::multiaddress::MultiAddress" + }, + "typesAlias": { + "pezsp_runtime::multiaddress::MultiAddress": "MultiAddress", + "pezsp_runtime::MultiSignature": "MultiSignature", + "pezsp_runtime.generic.era.Era": "Era" + } +} +``` +**NOT:** Bu dosya şu anda kullanılmıyor çünkü TypesUsage.BASE kullanılıyor. TypesUsage.BOTH veya OWN için chains.json'da URL eklenebilir. + +--- + +## SORUN GEÇMİŞİ + +1. **"Network not responding"** - Fee hesaplama hatası + - Çözüm: feeViaRuntimeCall eklendi, custom signed extension'lar eklendi + +2. **"IllegalStateException: Type Address was not found"** - Address type lookup hatası + - Çözüm: RuntimeSnapshotExt.kt'de birden fazla type ismi deneniyor + +3. **"EncodeDecodeException: is not a valid instance"** - Address encoding hatası + - Çözüm: `argumentType("dest").constructAccountLookupInstance(accountId)` ile metadata'dan gerçek type alınıyor (ExtrinsicBuilderExt.kt) + +4. **"failed to encode extension CheckMortality"** - CheckMortality encoding hatası + - SDK'nın CheckMortality'si metadata type lookup yaparak Era'yı encode etmeye çalışıyor + - Pezkuwi Era type'ı `pezsp_runtime.generic.era.Era` DictEnum olarak tanımlı + - Çözüm: `PezkuwiCheckMortality` custom extension'ı oluşturuldu, Era'yı `DictEnum.Entry("MortalX", secondByte)` olarak veriyor + +5. **"IllegalStateException: Type ExtrinsicSignature was not found"** - ExtrinsicSignature type hatası ✅ ÇÖZÜLDÜ + - SDK "ExtrinsicSignature" type'ını arıyor ama Pezkuwi chain'leri `"types": null` kullanıyordu + - `TypesUsage.NONE` olduğu için base types (default.json) yüklenmiyordu + - **Çözüm:** + - `runtime/build.gradle` içinde CHAINS_URL GitHub'a yönlendirildi + - `pezkuwi-wallet-utils/chains/v22/android/chains.json` oluşturuldu (`"types": { "overridesCommon": false }`) + - Artık `TypesUsage.BASE` kullanılıyor ve default.json yükleniyor + +6. **"IllegalStateException: Type Address was not found"** - Address type hatası ✅ ÇÖZÜLDÜ + - v14Preset() `GenericMultiAddress` içermiyor + - default.json'da `"MultiAddress": "GenericMultiAddress"` tanımlıydı ama GenericMultiAddress çözümlenemiyordu + - **Çözüm:** default.json'da MultiAddress inline enum olarak tanımlandı: + ```json + "MultiAddress": { + "type": "enum", + "type_mapping": [ + ["Id", "AccountId"], + ["Index", "Compact"], + ["Raw", "Bytes"], + ["Address32", "H256"], + ["Address20", "H160"] + ] + } + ``` + +7. **"TypeReference is null"** - Transfer onaylama hatası (DEVAM EDİYOR) + - Fee hesaplama çalışıyor ✅ + - Transfer onaylama sırasında hata oluşuyor + - Muhtemelen signing sırasında bir type çözümlenemiyor + - Debug logging eklendi: `RealExtrinsicService.kt` + - Stack trace bekleniyor + +--- + +## ÇALIŞAN İMPLEMENTASYONLAR (Referans) + +### 1. pezkuwi-extension (Browser Extension) +**Konum:** `/home/mamostehp/pezkuwi-extension/` +**Nasıl çalışıyor:** +- `@pezkuwi/types` (polkadot.js fork) kullanıyor +- `TypeRegistry` ile dynamic type handling +- Custom user extensions: +```javascript +const PEZKUWI_USER_EXTENSIONS = { + AuthorizeCall: { + extrinsic: {}, + payload: {} + } +}; +``` +- `registry.setSignedExtensions(payload.signedExtensions, PEZKUWI_USER_EXTENSIONS)` ile extension'lar ekleniyor +- Metadata'dan registry oluşturuluyor: `metadataExpand(metadata, false)` + +### 2. pezkuwi-subxt (Rust) +**Konum:** `/home/mamostehp/pezkuwi-sdk/vendor/pezkuwi-subxt/` +**Nasıl çalışıyor:** +- Rust'ta compile-time type generation +- Metadata'dan otomatik type oluşturma + +### 3. Telegram Miniapp +- Web tabanlı, polkadot.js kullanıyor +- `"types": null` ile çalışıyor çünkü metadata v14+ self-contained + +--- + +## TEMİZLEME KONTROL LİSTESİ + +Production release öncesi yapılacaklar: + +- [ ] FeeLoaderV2Provider.kt - DEBUG mesajını ve diagnostics'i kaldır +- [ ] RuntimeFactory.kt - companion object ve debug log'ları kaldır +- [ ] CustomTransactionExtensions.kt - Log satırlarını kaldır +- [ ] ExtrinsicBuilderFactory.kt - Log satırlarını kaldır +- [ ] PezkuwiAddressConstructor.kt - Log satırlarını kaldır (varsa) +- [ ] RealExtrinsicService.kt - try-catch debug bloğunu kaldır +- [x] Test et: Fee hesaplama çalışıyor mu? ✅ +- [ ] Test et: Transfer işlemi çalışıyor mu? (TypeReference hatası devam ediyor) + +--- + +## TYPE LOADING AKIŞI (Referans) + +``` +chains.json + ↓ +"types": { "overridesCommon": false } → TypesUsage.BASE +"types": { "url": "...", "overridesCommon": false } → TypesUsage.BOTH +"types": { "url": "...", "overridesCommon": true } → TypesUsage.OWN +"types": null → TypesUsage.NONE + ↓ +RuntimeFactory.constructRuntime() + ↓ +TypesUsage.BASE → constructBaseTypes() → fetch from DEFAULT_TYPES_URL +TypesUsage.BOTH → constructBaseTypes() + constructOwnTypes() +TypesUsage.OWN → constructOwnTypes() only +TypesUsage.NONE → use v14Preset() only + ↓ +TypeRegistry + ↓ +RuntimeSnapshot +``` + +**DEFAULT_TYPES_URL:** `https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/types/default.json` + +--- + +*Son güncelleme: 2026-02-03 06:30 (CHAINS_URL GitHub'a yönlendirildi, MultiAddress inline tanımlandı, Integration test eklendi, TypeReference hatası araştırılıyor)* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..9d447c5 --- /dev/null +++ b/NOTICE @@ -0,0 +1,9 @@ +Nova - Polkadot, Kusama wallet + +Copyright 2022-2025 Novasama Technologies PTE. LTD. +This product includes software developed at Novasama Technologies PTE. LTD. + +Some parts of this product are derived from https://github.com/soramitsu/fearless-Android, which belongs to Soramitsu K.K. and was mostly developed by our team of developers from May 1, 2020, to October 5, 2021. +Copyright 2021, Soramitsu Helvetia AG, all rights reserved. + +License Rights transferred from Novasama Technologies PTE. LTD to Novasama Technologies GmbH starting from 1st of April 2023 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e8360cc --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# Pezkuwi Wallet Android + +Next generation mobile wallet for Pezkuwichain and the Polkadot ecosystem. + +[![](https://img.shields.io/twitter/follow/pezkuwichain?label=Follow&style=social)](https://twitter.com/pezkuwichain) + +## About + +Pezkuwi Wallet is a next-generation mobile application for the Pezkuwichain and Polkadot ecosystem. It provides a transparent, community-oriented wallet experience with convenient UX/UI, fast performance, and strong security. + +**Key Features:** +- Full Pezkuwichain support (HEZ & PEZ tokens) +- Full Polkadot ecosystem compatibility +- Staking, Governance, DeFi +- NFT support +- Cross-chain transfers (XCM) +- Hardware wallet support (Ledger, Polkadot Vault) +- WalletConnect v2 +- Push notifications + +## Native Tokens + +| Token | Network | Description | +|-------|---------|-------------| +| HEZ | Relay Chain | Native token for fees and staking | +| PEZ | Asset Hub | Governance token | + +## Build Instructions + +### Clone Repository + +```bash +git clone git@github.com:pezkuwichain/pezkuwi-wallet-android.git +``` + +### Install NDK + +Install NDK version `26.1.10909125` from SDK Manager: +Tools -> SDK Manager -> SDK Tools -> NDK (Side by Side) + +### Install Rust + +Install Rust by following [official instructions](https://www.rust-lang.org/tools/install). + +Add Android build targets: + +```bash +rustup target add armv7-linux-androideabi +rustup target add i686-linux-android +rustup target add x86_64-linux-android +rustup target add aarch64-linux-android +``` + +### Update local.properties + +Add the following lines to your `local.properties`: + +```properties +ACALA_PROD_AUTH_TOKEN=mock +ACALA_TEST_AUTH_TOKEN=mock +CI_KEYSTORE_KEY_ALIAS=mock +CI_KEYSTORE_KEY_PASS=mock +CI_KEYSTORE_PASS=mock +DEBUG_GOOGLE_OAUTH_ID=mock +RELEASE_GOOGLE_OAUTH_ID=mock +DWELLIR_API_KEY=mock +EHTERSCAN_API_KEY_ETHEREUM=mock +EHTERSCAN_API_KEY_MOONBEAM=mock +EHTERSCAN_API_KEY_MOONRIVER=mock +INFURA_API_KEY=mock +MERCURYO_PRODUCTION_SECRET=mock +MERCURYO_TEST_SECRET=mock +MOONBEAM_PROD_AUTH_TOKEN=mock +MOONBEAM_TEST_AUTH_TOKEN=mock +MOONPAY_PRODUCTION_SECRET=mock +MOONPAY_TEST_SECRET=mock +WALLET_CONNECT_PROJECT_ID=mock +``` + +**Note:** Firebase and Google-related features (Notifications, Cloud Backups) require proper configuration. + +### Build Types + +- `debug`: Uses fixed keystore for Google services +- `debugLocal`: Uses your local debug keystore +- `release`: Production build + +## Supported Languages + +- English +- Turkish (Türkçe) +- Kurdish Kurmanji (Kurmancî) +- Spanish (Español) +- French (Français) +- German (Deutsch) +- Russian (Русский) +- Japanese (日本語) +- Chinese (中文) +- Korean (한국어) +- Portuguese (Português) +- Vietnamese (Tiếng Việt) +- And more... + +## Resources + +- Website: https://pezkuwichain.io +- Documentation: https://docs.pezkuwichain.io +- Telegram: https://t.me/pezkuwichain +- Twitter: https://twitter.com/pezkuwichain +- GitHub: https://github.com/pezkuwichain + +## License + +Pezkuwi Wallet Android is available under the Apache 2.0 license. See the LICENSE file for more info. + +Based on Nova Wallet (https://novawallet.io) - © Novasama Technologies GmbH + +© Dijital Kurdistan Tech Institute 2026 diff --git a/allmodules.gradle b/allmodules.gradle new file mode 100644 index 0000000..4c96023 --- /dev/null +++ b/allmodules.gradle @@ -0,0 +1,37 @@ +def APP_MODULE = 'app' + +subprojects { + if (!file("$projectDir/build.gradle").exists()) { + return + } + + if (APP_MODULE == project.name) { + apply plugin: 'com.android.application' + } else { + apply plugin: 'com.android.library' + } + + apply plugin: 'kotlin-android' + apply plugin: "com.google.devtools.ksp" + + android { + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + freeCompilerArgs = ["-Xcontext-receivers"] + } + } +} \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..c593825 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,5 @@ +/build +/release* + +src/release*/google-services.json +!src/release/google-services.json \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..fdf22e9 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,336 @@ +apply plugin: 'com.google.gms.google-services' +apply plugin: 'kotlin-parcelize' +apply plugin: 'com.google.firebase.appdistribution' +apply plugin: "com.github.triplet.play" +apply from: "../scripts/versions.gradle" +apply from: "../scripts/secrets.gradle" + +android { + defaultConfig { + applicationId rootProject.applicationId + versionCode computeVersionCode() + versionName computeVersionName() + + // Branch.io key from local.properties or environment variable + manifestPlaceholders = [ + BRANCH_KEY: readRawSecretOrNull('BRANCH_KEY') ?: "key_test_placeholder" + ] + } + signingConfigs { + dev { + storeFile file('develop_key.jks') + storePassword readRawSecretOrNull('CI_KEYSTORE_PASS') + keyAlias readRawSecretOrNull('CI_KEYSTORE_KEY_ALIAS') + keyPassword readRawSecretOrNull('CI_KEYSTORE_KEY_PASS') + } + market { + storeFile file('market_key.jks') + storePassword readRawSecretOrNull('CI_MARKET_KEYSTORE_PASS') + keyAlias readRawSecretOrNull('CI_MARKET_KEYSTORE_KEY_ALIAS') + keyPassword readRawSecretOrNull('CI_MARKET_KEYSTORE_KEY_PASS') + } + github { + storeFile file('github_key.jks') + storePassword readRawSecretOrNull('CI_GITHUB_KEYSTORE_PASS') + keyAlias readRawSecretOrNull('CI_GITHUB_KEYSTORE_KEY_ALIAS') + keyPassword readRawSecretOrNull('CI_GITHUB_KEYSTORE_KEY_PASS') + } + } + buildTypes { + debug { + applicationIdSuffix '.debug' + versionNameSuffix '-debug' + + buildConfigField "String", "BuildType", "\"debug\"" + } + debugLocal { + initWith buildTypes.debug + matchingFallbacks = ['debug'] + signingConfig signingConfigs.debug + } + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + + buildConfigField "String", "BuildType", "\"release\"" + } + releaseTest { + initWith buildTypes.release + matchingFallbacks = ['release'] + signingConfig signingConfigs.debug + + versionNameSuffix '-releaseTest' + applicationIdSuffix '.releaseTest' + + buildConfigField "String", "BuildType", "\"releaseTest\"" + } + releaseMarket { + initWith buildTypes.release + matchingFallbacks = ['release'] + signingConfig signingConfigs.market + + versionNameSuffix "-${releaseApplicationSuffix}" + applicationIdSuffix ".${releaseApplicationSuffix}" + + buildConfigField "String", "BuildType", "\"releaseMarket\"" + } + releaseGithub { + initWith buildTypes.release + matchingFallbacks = ['release'] + signingConfig signingConfigs.github + + buildConfigField "String", "BuildType", "\"releaseGithub\"" + } + develop { + signingConfig signingConfigs.dev + matchingFallbacks = ['debug'] + versionNameSuffix '-develop' + applicationIdSuffix '.dev' + //Init firebase + def localReleaseNotes = releaseNotes() + def localFirebaseGroup = firebaseGroup() + firebaseAppDistribution { + releaseNotes = localReleaseNotes + groups = localFirebaseGroup + } + + buildConfigField "String", "BuildType", "\"develop\"" + } + instrumentialTest { + initWith buildTypes.debug + matchingFallbacks = ['debug'] + defaultConfig.testInstrumentationRunner "io.qameta.allure.android.runners.AllureAndroidJUnitRunner" + + buildConfigField "String", "BuildType", "\"instrumentalTest\"" + } + } + + sourceSets { + releaseGithub { + res.srcDirs = ['src/release/res'] + } + releaseMarket { + res.srcDirs = ['src/release/res'] + } + releaseTest { + res.srcDirs = ['src/release/res'] + } + } + + bundle { + language { + enableSplit = false + } + } + + applicationVariants.all { variant -> + String name = variant.buildType.name + if (name != "release" && name.startsWith("release")) { + createBindReleaseFileTask(variant.buildType.name) + } + } + + packagingOptions { + resources.excludes.add("META-INF/versions/9/previous-compilation-data.bin") + resources.excludes.add("META-INF/DEPENDENCIES") + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } + namespace 'io.novafoundation.nova.app' +} + +void createBindReleaseFileTask(String destination) { + String taskName = "bind${destination.capitalize()}GithubGoogleServicesToRelease" + + Task task = task(taskName, type: Copy) { + description = "Switches to RELEASE google-services.json for ${destination}" + from "src/release" + include "google-services.json" + into "src/${destination}" + } + + afterEvaluate { + def capitalizedDestination = destination.capitalize() + def dependentTasks = [ + "process${capitalizedDestination}GoogleServices".toString(), + "merge${capitalizedDestination}JniLibFolders".toString(), + "merge${capitalizedDestination}StartupProfile".toString(), + "merge${capitalizedDestination}Shaders".toString(), + "merge${capitalizedDestination}ArtProfile".toString() + ] + + dependentTasks.forEach { + tasks.getByName(it).dependsOn(task) + } + } +} + +play { + serviceAccountCredentials = file(System.env.CI_PLAY_KEY ?: "../key/fake.json") + track = "beta" + releaseStatus = "completed" +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-splash') + + implementation project(':feature-onboarding-api') + implementation project(':feature-onboarding-impl') + + implementation project(':feature-ledger-api') + implementation project(':feature-ledger-core') + implementation project(':feature-ledger-impl') + + implementation project(':feature-account-api') + implementation project(':feature-account-impl') + + implementation project(':feature-account-migration') + + implementation project(':feature-wallet-api') + implementation project(':feature-wallet-impl') + + implementation project(':runtime') + implementation project(':web3names') + + implementation project(':feature-staking-api') + implementation project(':feature-staking-impl') + + implementation project(':feature-crowdloan-api') + implementation project(':feature-crowdloan-impl') + + implementation project(':feature-dapp-api') + implementation project(':feature-dapp-impl') + + implementation project(':feature-nft-api') + implementation project(':feature-nft-impl') + + implementation project(':feature-currency-api') + implementation project(':feature-currency-impl') + + implementation project(':feature-governance-api') + implementation project(':feature-governance-impl') + + implementation project(':feature-assets') + + implementation project(':feature-vote') + + implementation project(':feature-versions-api') + implementation project(':feature-versions-impl') + + implementation project(':caip') + + implementation project(':feature-external-sign-api') + implementation project(':feature-external-sign-impl') + + implementation project(':feature-wallet-connect-api') + implementation project(':feature-wallet-connect-impl') + + implementation project(':feature-proxy-api') + implementation project(':feature-proxy-impl') + + implementation project(':feature-settings-api') + implementation project(':feature-settings-impl') + + implementation project(":feature-swap-core") + implementation project(':feature-swap-api') + implementation project(':feature-swap-impl') + + implementation project(":feature-buy-api") + implementation project(":feature-buy-impl") + + implementation project(':feature-push-notifications') + implementation project(':feature-deep-linking') + + implementation project(':feature-cloud-backup-api') + implementation project(':feature-cloud-backup-impl') + + implementation project(':feature-banners-api') + implementation project(':feature-banners-impl') + + implementation project(':feature-ahm-api') + implementation project(':feature-ahm-impl') + + implementation project(':feature-gift-api') + implementation project(':feature-gift-impl') + + implementation project(':bindings:metadata_shortener') + implementation project(':bindings:sr25519-bizinikiwi') + + implementation project(":feature-xcm:impl") + + implementation project(":feature-multisig:operations") + + implementation project(':test-shared') + + implementation kotlinDep + + implementation biometricDep + + + implementation androidDep + implementation constraintDep + + implementation zXingEmbeddedDep + + implementation navigationFragmentDep + implementation navigationUiDep + + implementation roomDep + + implementation substrateSdkDep + + implementation daggerDep + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + implementation lifeCycleKtxDep + + implementation retrofitDep + implementation gsonConvertedDep + + implementation gifDep + + compileOnly wsDep + + implementation coroutinesDep + + testImplementation project(':test-shared') + + implementation insetterDep + + implementation liveDataKtxDep + + implementation platform(firebaseBomDep) + implementation firestoreDep + implementation firebaseCloudMessagingDep + implementation firebaseAppCheck + + implementation walletConnectCoreDep, withoutTransitiveAndroidX + implementation walletConnectWalletDep, withoutTransitiveAndroidX + + kspAndroidTest daggerCompiler + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep + + androidTestImplementation allureKotlinModel + androidTestImplementation allureKotlinCommons + androidTestImplementation allureKotlinJunit4 + androidTestImplementation allureKotlinAndroid +} + +task printVersion { + doLast { + println "versionName:${computeVersionName()}" + } +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..8d9ac0a --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,170 @@ +# ============================================================ +# Pezkuwi Wallet ProGuard Rules +# ============================================================ + +# Keep line numbers for debugging crash reports +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# ============================================================ +# Kotlin +# ============================================================ +-dontwarn kotlin.** +-keep class kotlin.Metadata { *; } +-keepclassmembers class kotlin.Metadata { + public ; +} + +# Kotlin Coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} +-dontwarn kotlinx.coroutines.** + +# ============================================================ +# Retrofit & OkHttp +# ============================================================ +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn javax.annotation.** +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase +-keep class retrofit2.** { *; } +-keepattributes Signature +-keepattributes Exceptions +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} + +# ============================================================ +# Gson +# ============================================================ +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn sun.misc.** +-keep class com.google.gson.** { *; } +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# ============================================================ +# BouncyCastle Crypto +# ============================================================ +-keep class org.bouncycastle.** { *; } +-dontwarn org.bouncycastle.** + +# ============================================================ +# Native JNI Bindings (Rust) +# ============================================================ +# SR25519 signing +-keep class io.novafoundation.nova.** { native ; } +-keepclasseswithmembernames class * { + native ; +} + +# Keep all JNI related classes +-keep class io.parity.** { *; } + +# ============================================================ +# Substrate SDK +# ============================================================ +-keep class jp.co.soramitsu.** { *; } +-dontwarn jp.co.soramitsu.** + +# ============================================================ +# Firebase +# ============================================================ +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + +# ============================================================ +# Branch.io Deep Linking +# ============================================================ +-keep class io.branch.** { *; } +-dontwarn io.branch.** + +# ============================================================ +# Web3j (Ethereum) +# ============================================================ +-keep class org.web3j.** { *; } +-dontwarn org.web3j.** + +# ============================================================ +# SQLCipher +# ============================================================ +-keep class net.sqlcipher.** { *; } +-dontwarn net.sqlcipher.** + +# ============================================================ +# Room Database +# ============================================================ +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-dontwarn androidx.room.paging.** + +# ============================================================ +# Data Classes & Models (Keep for serialization) +# ============================================================ +-keep class io.novafoundation.nova.**.model.** { *; } +-keep class io.novafoundation.nova.**.response.** { *; } +-keep class io.novafoundation.nova.**.request.** { *; } +-keep class io.novafoundation.nova.**.dto.** { *; } + +# ============================================================ +# Parcelable +# ============================================================ +-keepclassmembers class * implements android.os.Parcelable { + public static final ** CREATOR; +} + +# ============================================================ +# Enums +# ============================================================ +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +# ============================================================ +# Serializable +# ============================================================ +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# ============================================================ +# Ledger USB/Bluetooth +# ============================================================ +-keep class io.novafoundation.nova.feature_ledger_impl.** { *; } + +# ============================================================ +# WalletConnect +# ============================================================ +-keep class com.walletconnect.** { *; } +-dontwarn com.walletconnect.** + +# ============================================================ +# Optimization settings +# ============================================================ +-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/* +-optimizationpasses 5 +-allowaccessmodification + +# ============================================================ +# Don't warn about missing classes that we don't use +# ============================================================ +-dontwarn org.conscrypt.** +-dontwarn org.slf4j.** +-dontwarn javax.naming.** diff --git a/app/src/androidTest/java/io/novafoundation/nova/BaseIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/BaseIntegrationTest.kt new file mode 100644 index 0000000..80c614e --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/BaseIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.withChildScope +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.runtime.di.RuntimeComponent +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +open class BaseIntegrationTest { + + protected val context: Context = ApplicationProvider.getApplicationContext() + + protected val runtimeApi = FeatureUtils.getFeature(context, RuntimeApi::class.java) + + val chainRegistry = runtimeApi.chainRegistry() + + private val externalRequirementFlow = runtimeApi.externalRequirementFlow() + + @Before + fun setup() = runBlocking { + externalRequirementFlow.emit(ChainConnection.ExternalRequirement.ALLOWED) + } + + protected fun runTest(action: suspend CoroutineScope.() -> Unit) { + runBlocking { + withChildScope { + action() + } + } + } + + protected suspend fun ChainRegistry.polkadot(): Chain { + return getChain(Chain.Geneses.POLKADOT) + } + + protected suspend fun ChainRegistry.polkadotAssetHub(): Chain { + return getChain(Chain.Geneses.POLKADOT_ASSET_HUB) + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/BlockParsingIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/BlockParsingIntegrationTest.kt new file mode 100644 index 0000000..621eb20 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/BlockParsingIntegrationTest.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova + +import android.content.Context +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.novafoundation.nova.common.data.network.runtime.binding.bindEventRecords +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.runtime.di.RuntimeComponent +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BlockParsingIntegrationTest { + + private val chainGenesis = "f1cf9022c7ebb34b162d5b5e34e705a5a740b2d0ecc1009fb89023e62a488108" // Shiden + + private val runtimeApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + RuntimeApi::class.java + ) + + private val chainRegistry = runtimeApi.chainRegistry() + private val externalRequirementFlow = runtimeApi.externalRequirementFlow() + + private val rpcCalls = runtimeApi.rpcCalls() + + private val remoteStorage = runtimeApi.remoteStorageSource() + + @Test + fun testBlockParsing() = runBlocking { + externalRequirementFlow.emit(ChainConnection.ExternalRequirement.ALLOWED) + val chain = chainRegistry.getChain(chainGenesis) + + val block = rpcCalls.getBlock(chain.id) + + val logTag = this@BlockParsingIntegrationTest.LOG_TAG + + Log.d(logTag, block.block.header.number.toString()) + + val events = remoteStorage.query( + chainId = chain.id, + keyBuilder = { it.metadata.system().storage("Events").storageKey() }, + binding = { scale, runtime -> + Log.d(logTag, scale!!) + bindEventRecords(scale) + } + ) + +// val eventsRaw = "0x0800000000000000000000000000000002000000010000000000585f8f0900000000020000" +// val type = bindEventRecords(eventsRaw, chainRegistry.getRuntime(chain.id)) + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/ChainMappingIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/ChainMappingIntegrationTest.kt new file mode 100644 index 0000000..bb0a481 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/ChainMappingIntegrationTest.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.gson.Gson +import androidx.test.platform.app.InstrumentationRegistry +import dagger.Component +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.RemoteToDomainChainMapperFacade +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainAssetToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainExplorerToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainExternalApiToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainLocalToChain +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainNodeToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapExternalApisToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapNodeSelectionPreferencesToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteAssetToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteChainToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteExplorersToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteNodesToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote +import io.novafoundation.nova.test_shared.assertAllItemsEquals +import javax.inject.Inject +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + + +@Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class + ] +) +interface MappingTestAppComponent { + + fun inject(test: ChainMappingIntegrationTest) +} + +@RunWith(AndroidJUnit4::class) +class ChainMappingIntegrationTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + private val featureContainer = context as FeatureContainer + + @Inject + lateinit var networkApiCreator: NetworkApiCreator + + @Inject + lateinit var remoteToDomainChainMapperFacade: RemoteToDomainChainMapperFacade + + lateinit var chainFetcher: ChainFetcher + + private val gson = Gson() + + @Before + fun prepare() { + val component = DaggerMappingTestAppComponent.builder() + .commonApi(featureContainer.commonApi()) + .runtimeApi(featureContainer.getFeature(RuntimeApi::class.java)) + .build() + + component.inject(this) + + chainFetcher = networkApiCreator.create(ChainFetcher::class.java) + } + + @Test + fun testChainMappingIsMatch() { + runBlocking { + val chainsRemote = chainFetcher.getChains() + + val remoteToDomain = chainsRemote.map { mapRemoteToDomain(it) } + val remoteToLocalToDomain = chainsRemote.map { mapRemoteToLocalToDomain(it) } + val domainToLocalToDomain = remoteToDomain.map { mapDomainToLocalToDomain(it) } + + assertAllItemsEquals(listOf(remoteToDomain, remoteToLocalToDomain, domainToLocalToDomain)) + } + } + + private fun mapRemoteToLocalToDomain(chainRemote: ChainRemote): Chain { + val chainLocal = mapRemoteChainToLocal(chainRemote, null, ChainLocal.Source.DEFAULT, gson) + val assetsLocal = chainRemote.assets.map { mapRemoteAssetToLocal(chainRemote, it, gson, isEnabled = true) } + val nodesLocal = mapRemoteNodesToLocal(chainRemote) + val explorersLocal = mapRemoteExplorersToLocal(chainRemote) + val externalApisLocal = mapExternalApisToLocal(chainRemote) + + return mapChainLocalToChain( + chainLocal = chainLocal, + nodesLocal = nodesLocal, + nodeSelectionPreferences = NodeSelectionPreferencesLocal(chainLocal.id, autoBalanceEnabled = true, selectedNodeUrl = null), + assetsLocal = assetsLocal, + explorersLocal = explorersLocal, + externalApisLocal = externalApisLocal, + gson = gson + ) + } + + private fun mapRemoteToDomain(chainRemote: ChainRemote): Chain { + return remoteToDomainChainMapperFacade.mapRemoteChainToDomain(chainRemote, Chain.Source.DEFAULT) + } + + private fun mapDomainToLocalToDomain(chain: Chain): Chain { + val chainLocal = mapChainToLocal(chain, gson) + val nodesLocal = chain.nodes.nodes.map { mapChainNodeToLocal(it) } + val nodeSelectionPreferencesLocal = mapNodeSelectionPreferencesToLocal(chain) + val assetsLocal = chain.assets.map { mapChainAssetToLocal(it, gson) } + val explorersLocal = chain.explorers.map { mapChainExplorerToLocal(it) } + val externalApisLocal = chain.externalApis.map { mapChainExternalApiToLocal(gson, chain.id, it) } + + return mapChainLocalToChain( + chainLocal = chainLocal, + nodesLocal = nodesLocal, + nodeSelectionPreferences = nodeSelectionPreferencesLocal, + assetsLocal = assetsLocal, + explorersLocal = explorersLocal, + externalApisLocal = externalApisLocal, + gson = gson + ) + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/ChainSyncIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/ChainSyncIntegrationTest.kt new file mode 100644 index 0000000..9127e1f --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/ChainSyncIntegrationTest.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.gson.Gson +import dagger.Component +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.core_db.AppDatabase +import io.novafoundation.nova.runtime.multiNetwork.chain.ChainSyncService +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + + +@Component( + dependencies = [ + CommonApi::class, + ] +) +interface TestAppComponent { + + fun inject(test: ChainSyncServiceIntegrationTest) +} + +@RunWith(AndroidJUnit4::class) +class ChainSyncServiceIntegrationTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + private val featureContainer = context as FeatureContainer + + @Inject + lateinit var networkApiCreator: NetworkApiCreator + + lateinit var chainSyncService: ChainSyncService + + @Before + fun setup() { + val component = DaggerTestAppComponent.builder() + .commonApi(featureContainer.commonApi()) + .build() + + component.inject(this) + + val chainDao = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .build() + .chainDao() + + chainSyncService = ChainSyncService(chainDao, networkApiCreator.create(ChainFetcher::class.java), Gson()) + } + + @Test + fun shouldFetchAndStoreRealChains() = runBlocking { + chainSyncService.syncUp() + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/CrossChainTransfersIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/CrossChainTransfersIntegrationTest.kt new file mode 100644 index 0000000..810f656 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/CrossChainTransfersIntegrationTest.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.emptySubstrateAccountId +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.domain.model.toDefaultSubstrateAddress +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.repository.getXcmChain +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.transferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.transferConfiguration +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.emptyAccountId +import io.novafoundation.nova.runtime.ext.normalizeTokenSymbol +import io.novafoundation.nova.runtime.multiNetwork.findChain +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.math.BigInteger + +class CrossChainTransfersIntegrationTest : BaseIntegrationTest() { + + private val walletApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + WalletFeatureApi::class.java + ) + + private val chainTransfersRepository = walletApi.crossChainTransfersRepository + private val crossChainWeigher = walletApi.crossChainWeigher + + private val parachainInfoRepository = runtimeApi.parachainInfoRepository + + @Test + fun testParachainToParachain() = performFeeTest( + from = "Moonriver", + what = "xcKAR", + to = "Karura" + ) + + @Test + fun testRelaychainToParachain() = performFeeTest( + from = "Kusama", + what = "KSM", + to = "Moonriver" + ) + + @Test + fun testParachainToRelaychain() = performFeeTest( + from = "Moonriver", + what = "xcKSM", + to = "Kusama" + ) + + @Test + fun testParachainToParachainNonReserve() = performFeeTest( + from = "Karura", + what = "BNC", + to = "Moonriver" + ) + + private fun performFeeTest( + from: String, + to: String, + what: String + ) { + runBlocking { + val originChain = chainRegistry.findChain { it.name == from }!! + val asssetInOrigin = originChain.assets.first { it.symbol.value == what } + + val destinationChain = chainRegistry.findChain { it.name == to }!! + val asssetInDestination = destinationChain.assets.first { normalizeTokenSymbol(it.symbol.value) == normalizeTokenSymbol(what) } + + val crossChainConfig = chainTransfersRepository.getConfiguration() + + val crossChainTransfer = crossChainConfig.transferConfiguration( + originChain = parachainInfoRepository.getXcmChain(originChain), + originAsset = asssetInOrigin, + destinationChain = parachainInfoRepository.getXcmChain(destinationChain), + )!! + + val transfer = AssetTransferBase( + recipient = originChain.addressOf(originChain.emptyAccountId()), + originChain = originChain, + originChainAsset = asssetInOrigin, + destinationChain = destinationChain, + destinationChainAsset = asssetInDestination, + feePaymentCurrency = FeePaymentCurrency.Native, + amountPlanks = BigInteger.ZERO + ) + + val crossChainFeeResult = runCatching { crossChainWeigher.estimateFee(transfer, crossChainTransfer) } + + check(crossChainFeeResult.isSuccess) + } + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/DryRunIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/DryRunIntegrationTest.kt new file mode 100644 index 0000000..f0dc57c --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/DryRunIntegrationTest.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.toResult +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.composeCall +import io.novafoundation.nova.common.utils.xcmPalletName +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.getByLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.Junctions +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId +import io.novafoundation.nova.feature_xcm_api.multiLocation.asLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.toMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance +import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm +import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import org.junit.Test +import java.math.BigDecimal +import java.math.BigInteger + +class DryRunIntegrationTest : BaseIntegrationTest() { + + private val xcmApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + XcmFeatureApi::class.java + ) + + private val dryRunApi = xcmApi.dryRunApi + + @Test + fun testDryRunXcmTeleport() = runTest { + val polkadot = chainRegistry.polkadot() + val polkadotAssetHub = chainRegistry.polkadotAssetHub() + + val polkadotRuntime = chainRegistry.getRuntime(polkadot.id) + + val polkadotLocation = MultiLocation.Interior.Here.asLocation() + val polkadotAssetHubLocation = Junctions(ParachainId(1000)).asLocation() + + val dotLocation = polkadotLocation.toRelative() + val amount = polkadot.utilityAsset.planksFromAmount(BigDecimal.ONE) + val assets = MultiAsset.from(dotLocation, amount) + + val origin = "16WWmr2Xqgy5fna35GsNHXMU7vDBM12gzHCFGibQjSmKpAN".toAccountId().intoKey() + val beneficiary = origin.toMultiLocation() + + val xcmVersion = XcmVersion.V4 + + val pahVersionedLocation = polkadotAssetHubLocation.toRelative().versionedXcm(xcmVersion) + + // Compose limited_teleport_assets call to execute on Polkadot + val call = polkadotRuntime.composeCall( + moduleName = polkadotRuntime.metadata.xcmPalletName(), + callName = "limited_teleport_assets", + arguments = mapOf( + "dest" to pahVersionedLocation.toEncodableInstance(), + "beneficiary" to beneficiary.versionedXcm(xcmVersion).toEncodableInstance(), + "assets" to MultiAssets(assets).versionedXcm(xcmVersion).toEncodableInstance(), + "fee_asset_item" to BigInteger.ZERO, + "weight_limit" to WeightLimit.Unlimited.toEncodableInstance() + ) + ) + + // Dry run call execution + val dryRunEffects = dryRunApi.dryRunCall( + originCaller = OriginCaller.System.Signed(origin), + call = call, + chainId = polkadot.id, + xcmResultsVersion = XcmVersion.V4 + ) + .getOrThrow() + .toResult().getOrThrow() + + // Find xcm forwarded to Polkadot Asset Hub + val forwardedXcm = dryRunEffects.forwardedXcms.getByLocation(pahVersionedLocation).first() + println(forwardedXcm) + + // Dry run execution of forwarded message on Polkadot Asset Hub + val xcmDryRunEffects = dryRunApi.dryRunXcm( + xcm = forwardedXcm, + originLocation = polkadotLocation.fromPointOfViewOf(polkadotAssetHubLocation).versionedXcm(xcmVersion), + chainId = polkadotAssetHub.id + ) + .getOrThrow() + .toResult().getOrThrow() + + println(xcmDryRunEffects.emittedEvents) + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/GasPriceProviderIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/GasPriceProviderIntegrationTest.kt new file mode 100644 index 0000000..c133074 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/GasPriceProviderIntegrationTest.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova + +import android.util.Log +import io.novafoundation.nova.common.utils.average +import io.novafoundation.nova.common.utils.divideToDecimal +import io.novafoundation.nova.runtime.ethereum.gas.LegacyGasPriceProvider +import io.novafoundation.nova.runtime.ethereum.gas.MaxPriorityFeeGasProvider +import io.novafoundation.nova.runtime.ext.Ids +import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import org.junit.Test +import java.math.BigInteger + +class GasPriceProviderIntegrationTest : BaseIntegrationTest() { + + @Test + fun compareLegacyAndImprovedGasPriceEstimations() = runTest { + val api = chainRegistry.getCallEthereumApiOrThrow(Chain.Ids.MOONBEAM) + + val legacy = LegacyGasPriceProvider(api) + val improved = MaxPriorityFeeGasProvider(api) + + val legacyStats = mutableSetOf() + val improvedStats = mutableSetOf() + + api.newHeadsFlow().map { + legacyStats.add(legacy.getGasPrice()) + improvedStats.add(improved.getGasPrice()) + } + .take(1000) + .collect() + + legacyStats.printStats("Legacy") + improvedStats.printStats("Improved") + } + + private fun Set.printStats(name: String) { + val min = min() + val max = max() + + Log.d("GasPriceProviderIntegrationTest", """ + Stats for $name source + Min: $min + Max: $max + Avg: ${average()} + Max/Min ratio: ${max.divideToDecimal(min)} + """) + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/GovernanceIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/GovernanceIntegrationTest.kt new file mode 100644 index 0000000..13c0b09 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/GovernanceIntegrationTest.kt @@ -0,0 +1,247 @@ +package io.novafoundation.nova + +import android.util.Log +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.firstLoaded +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumType +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumTypeFilter +import io.novafoundation.nova.feature_governance_impl.data.RealGovernanceAdditionalState +import io.novafoundation.nova.runtime.ext.externalApi +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import org.junit.Test +import java.math.BigInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + + +class GovernanceIntegrationTest : BaseIntegrationTest() { + + private val accountApi = FeatureUtils.getFeature(context, AccountFeatureApi::class.java) + private val governanceApi = FeatureUtils.getFeature(context, GovernanceFeatureApi::class.java) + + @Test + fun shouldRetrieveOnChainReferenda() = runTest { + val chain = chain() + val selectedGovernance = supportedGovernanceOption(chain, Chain.Governance.V1) + + val onChainReferendaRepository = source(selectedGovernance).referenda + + val referenda = onChainReferendaRepository.getAllOnChainReferenda(chain.id) + + Log.d(this@GovernanceIntegrationTest.LOG_TAG, referenda.toString()) + } + + @Test + fun shouldRetrieveConvictionVotes() = runTest { + val chain = chain() + val selectedGovernance = supportedGovernanceOption(chain, Chain.Governance.V1) + + val convictionVotingRepository = source(selectedGovernance).convictionVoting + + val accountId = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY".toAccountId() + + val votes = convictionVotingRepository.votingFor(accountId, chain.id) + Log.d(this@GovernanceIntegrationTest.LOG_TAG, votes.toString()) + } + + @Test + fun shouldRetrieveTrackLocks() = runTest { + val chain = chain() + val selectedGovernance = supportedGovernanceOption(chain, Chain.Governance.V1) + + val convictionVotingRepository = source(selectedGovernance).convictionVoting + + val accountId = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY".toAccountId() + + val fullChainAssetId = FullChainAssetId(chain.id, chain.utilityAsset.id) + + val trackLocks = convictionVotingRepository.trackLocksFlow(accountId, fullChainAssetId).first() + Log.d(this@GovernanceIntegrationTest.LOG_TAG, trackLocks.toString()) + } + + @Test + fun shouldRetrieveReferendaTracks() = runTest { + val chain = chain() + val selectedGovernance = supportedGovernanceOption(chain, Chain.Governance.V1) + + val onChainReferendaRepository = source(selectedGovernance).referenda + + val tracks = onChainReferendaRepository.getTracks(chain.id) + + Log.d(this@GovernanceIntegrationTest.LOG_TAG, tracks.toString()) + } + + @Test + fun shouldRetrieveDomainReferendaPreviews() = runTest { + val accountRepository = accountApi.provideAccountRepository() + val referendaListInteractor = governanceApi.referendaListInteractor + val updateSystem = governanceApi.governanceUpdateSystem + + updateSystem.start() + .inBackground() + .launchIn(this) + + val metaAccount = accountRepository.getSelectedMetaAccount() + val accountId = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY".toAccountId() + + val chain = chain() + val selectedGovernance = supportedGovernanceOption(chain, Chain.Governance.V1) + + val filterFlow: Flow = flow { + val referendaFilter = ReferendumTypeFilter(ReferendumType.ALL) + emit(referendaFilter) + } + + val referendaByGroup = referendaListInteractor.referendaListStateFlow(metaAccount, accountId, selectedGovernance, this, filterFlow).firstLoaded() + val referenda = referendaByGroup.groupedReferenda.values.flatten() + + Log.d(this@GovernanceIntegrationTest.LOG_TAG, referenda.joinToString("\n")) + } + + @Test + fun shouldRetrieveDomainReferendumDetails() = runTest { + val referendumDetailsInteractor = governanceApi.referendumDetailsInteractor + val updateSystem = governanceApi.governanceUpdateSystem + + updateSystem.start() + .inBackground() + .launchIn(this) + + val accountId = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY".toAccountId() + val referendumId = ReferendumId(BigInteger.ZERO) + val chain = chain() + val selectedGovernance = supportedGovernanceOption(chain, Chain.Governance.V1) + + val referendumDetails = referendumDetailsInteractor.referendumDetailsFlow(referendumId, selectedGovernance, accountId, CoroutineScope(Dispatchers.Main)) + .first() + + Log.d(this@GovernanceIntegrationTest.LOG_TAG, referendumDetails.toString()) + + val callDetails = referendumDetailsInteractor.detailsFor( + preImage = referendumDetails?.onChainMetadata!!.preImage!!, + chain = chain + ) + + Log.d(this@GovernanceIntegrationTest.LOG_TAG, callDetails.toString()) + } + + @Test + fun shouldRetrieveVoters() = runTest { + val interactor = governanceApi.referendumVotersInteractor + + val referendumId = ReferendumId(BigInteger.ZERO) + val referendumVoters = interactor.votersFlow(referendumId, VoteType.AYE) + .first() + + Log.d(this@GovernanceIntegrationTest.LOG_TAG, referendumVoters.toString()) + } + + @Test + fun shouldFetchDelegatesList() = runTest { + val interactor = governanceApi.delegateListInteractor + val updateSystem = governanceApi.governanceUpdateSystem + + updateSystem.start() + .inBackground() + .launchIn(this) + + val chain = kusama() + val delegates = interactor.getDelegates( + governanceOption = supportedGovernanceOption(chain, Chain.Governance.V2), + scope = this + ) + Log.d(this@GovernanceIntegrationTest.LOG_TAG, delegates.toString()) + } + + @Test + fun shouldFetchDelegateDetails() = runTest { + val delegateAccountId = "DCZyhphXsRLcW84G9WmWEXtAA8DKGtVGSFZLJYty8Ajjyfa".toAccountId() // ChaosDAO + + val interactor = governanceApi.delegateDetailsInteractor + val updateSystem = governanceApi.governanceUpdateSystem + + updateSystem.start() + .inBackground() + .launchIn(this) + + val delegate = interactor.delegateDetailsFlow(delegateAccountId) + Log.d(this@GovernanceIntegrationTest.LOG_TAG, delegate.toString()) + } + + @Test + fun shouldFetchChooseTrackData() = runTest { + val interactor = governanceApi.newDelegationChooseTrackInteractor + val updateSystem = governanceApi.governanceUpdateSystem + + updateSystem.start() + .inBackground() + .launchIn(this) + + val trackData = interactor.observeNewDelegationTrackData().first() + + Log.d( + this@GovernanceIntegrationTest.LOG_TAG, + """ + Available: ${trackData.trackPartition.available.size} + Already voted: ${trackData.trackPartition.alreadyVoted.size} + Already delegated: ${trackData.trackPartition.alreadyDelegated.size} + Presets: ${trackData.presets} + """.trimIndent() + ) + } + + @Test + fun shouldFetchDelegators() = runTest { + val delegateAddress = "DCZyhphXsRLcW84G9WmWEXtAA8DKGtVGSFZLJYty8Ajjyfa" // ChaosDAO + + val interactor = governanceApi.delegateDelegatorsInteractor + val updateSystem = governanceApi.governanceUpdateSystem + + updateSystem.start() + .inBackground() + .launchIn(this) + + val delegators = interactor.delegatorsFlow(delegateAddress.toAccountId()).first() + + Log.d(this@GovernanceIntegrationTest.LOG_TAG, delegators.toString()) + } + + private suspend fun source(supportedGovernance: SupportedGovernanceOption) = governanceApi.governanceSourceRegistry.sourceFor(supportedGovernance) + + private fun supportedGovernanceOption(chain: Chain, governance: Chain.Governance) = + SupportedGovernanceOption( + ChainWithAsset(chain, chain.utilityAsset), + RealGovernanceAdditionalState( + governance, + false + ) + ) + + private suspend fun chain(): Chain = chainRegistry.currentChains.map { chains -> + chains.find { it.governance.isNotEmpty() } + } + .filterNotNull() + .first() + + private suspend fun kusama(): Chain = chainRegistry.currentChains.mapNotNull { chains -> + chains.find { it.externalApi() != null } + }.first() +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/MetadataShortenerTest.kt b/app/src/androidTest/java/io/novafoundation/nova/MetadataShortenerTest.kt new file mode 100644 index 0000000..0e32dd9 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/MetadataShortenerTest.kt @@ -0,0 +1,182 @@ +package io.novafoundation.nova; + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.data.signer.SigningMode +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.data.signer.setSignerData +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.DefaultMetaAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.nativeTransfer +import io.novafoundation.nova.metadata_shortener.MetadataShortener +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novafoundation.nova.runtime.extrinsic.systemRemark +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode +import io.novasama.substrate_sdk_android.runtime.extrinsic.Nonce +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckNonce.Companion.setNonce +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.GeneralTransactionSigner +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.VerifySignature.Companion.setVerifySignature +import org.junit.Test +import java.math.BigInteger + +class MetadataShortenerTest : BaseIntegrationTest() { + + val extrinsicBuilderFactory = runtimeApi.provideExtrinsicBuilderFactory() + + val runtimeMetadata = + "0x01ce8f12006d6574610fe90e000c1c73705f636f72651863727970746f2c4163636f756e7449643332000004000401205b75383b2033325d0000040000032000000008000800000503000c08306672616d655f73797374656d2c4163636f756e74496e666f08144e6f6e636501102c4163636f756e74446174610114001401146e6f6e63651001144e6f6e6365000124636f6e73756d657273100120526566436f756e7400012470726f766964657273100120526566436f756e7400012c73756666696369656e7473100120526566436f756e740001106461746114012c4163636f756e74446174610000100000050500140c3c70616c6c65745f62616c616e6365731474797065732c4163636f756e7444617461041c42616c616e63650118001001106672656518011c42616c616e6365000120726573657276656418011c42616c616e636500011866726f7a656e18011c42616c616e6365000114666c6167731c01284578747261466c61677300001800000507001c0c3c70616c6c65745f62616c616e636573147479706573284578747261466c61677300000400180110753132380000200c346672616d655f737570706f7274206469737061746368405065724469737061746368436c6173730404540124000c01186e6f726d616c2401045400012c6f7065726174696f6e616c240104540001246d616e6461746f7279240104540000240c2873705f77656967687473247765696768745f76321857656967687400000801207265665f74696d6528010c75363400012870726f6f665f73697a6528010c7536340000280000062c002c000005060030083c7072696d69746976655f74797065731048323536000004000401205b75383b2033325d000034000002080038102873705f72756e74696d651c67656e65726963186469676573741844696765737400000401106c6f67733c013c5665633c4469676573744974656d3e00003c000002400040102873705f72756e74696d651c67656e6572696318646967657374284469676573744974656d0001142850726552756e74696d650800440144436f6e73656e737573456e67696e654964000034011c5665633c75383e00060024436f6e73656e7375730800440144436f6e73656e737573456e67696e654964000034011c5665633c75383e000400105365616c0800440144436f6e73656e737573456e67696e654964000034011c5665633c75383e000500144f74686572040034011c5665633c75383e0000006452756e74696d65456e7669726f6e6d656e74557064617465640008000044000003040000000800480000024c004c08306672616d655f73797374656d2c4576656e745265636f7264080445015004540130000c011470686173652108011450686173650001146576656e7450010445000118746f70696373b90101185665633c543e0000500840706f6c6b61646f745f72756e74696d653052756e74696d654576656e7400019c1853797374656d04005401706672616d655f73797374656d3a3a4576656e743c52756e74696d653e000000245363686564756c657204007c018070616c6c65745f7363686564756c65723a3a4576656e743c52756e74696d653e00010020507265696d616765040090017c70616c6c65745f707265696d6167653a3a4576656e743c52756e74696d653e000a001c496e6469636573040094017870616c6c65745f696e64696365733a3a4576656e743c52756e74696d653e0004002042616c616e636573040098017c70616c6c65745f62616c616e6365733a3a4576656e743c52756e74696d653e000500485472616e73616374696f6e5061796d656e740400a001a870616c6c65745f7472616e73616374696f6e5f7061796d656e743a3a4576656e743c52756e74696d653e0020001c5374616b696e670400a4017870616c6c65745f7374616b696e673a3a4576656e743c52756e74696d653e000700204f6666656e6365730400bc015870616c6c65745f6f6666656e6365733a3a4576656e740008001c53657373696f6e0400c4015470616c6c65745f73657373696f6e3a3a4576656e740009001c4772616e6470610400c8015470616c6c65745f6772616e6470613a3a4576656e74000b0020496d4f6e6c696e650400dc018070616c6c65745f696d5f6f6e6c696e653a3a4576656e743c52756e74696d653e000c0020547265617375727904000101017c70616c6c65745f74726561737572793a3a4576656e743c52756e74696d653e00130040436f6e76696374696f6e566f74696e670400890101a070616c6c65745f636f6e76696374696f6e5f766f74696e673a3a4576656e743c52756e74696d653e001400245265666572656e646104008d01018070616c6c65745f7265666572656e64613a3a4576656e743c52756e74696d653e0015002457686974656c69737404007d07018070616c6c65745f77686974656c6973743a3a4576656e743c52756e74696d653e00170018436c61696d73040091070158636c61696d733a3a4576656e743c52756e74696d653e0018001c56657374696e6704009507017870616c6c65745f76657374696e673a3a4576656e743c52756e74696d653e0019001c5574696c69747904009907015470616c6c65745f7574696c6974793a3a4576656e74001a00204964656e7469747904009d07017c70616c6c65745f6964656e746974793a3a4576656e743c52756e74696d653e001c001450726f78790400a107017070616c6c65745f70726f78793a3a4576656e743c52756e74696d653e001d00204d756c74697369670400a507017c70616c6c65745f6d756c74697369673a3a4576656e743c52756e74696d653e001e0020426f756e746965730400a907017c70616c6c65745f626f756e746965733a3a4576656e743c52756e74696d653e002200344368696c64426f756e746965730400ad07019470616c6c65745f6368696c645f626f756e746965733a3a4576656e743c52756e74696d653e00260068456c656374696f6e50726f76696465724d756c746950686173650400b10701d070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f70686173653a3a4576656e743c52756e74696d653e00240024566f7465724c6973740400c10701f470616c6c65745f626167735f6c6973743a3a4576656e743c52756e74696d652c2070616c6c65745f626167735f6c6973743a3a496e7374616e6365313e0025003c4e6f6d696e6174696f6e506f6f6c730400c507019c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c733a3a4576656e743c52756e74696d653e0027002c46617374556e7374616b650400c907018c70616c6c65745f666173745f756e7374616b653a3a4576656e743c52756e74696d653e0028003450617261496e636c7573696f6e0400cd07019070617261636861696e735f696e636c7573696f6e3a3a4576656e743c52756e74696d653e0035001450617261730400dd07015c70617261636861696e735f70617261733a3a4576656e740038001048726d700400e107017c70617261636861696e735f68726d703a3a4576656e743c52756e74696d653e003c0034506172617344697370757465730400e507018c70617261636861696e735f64697370757465733a3a4576656e743c52756e74696d653e003e00245265676973747261720400f107017c70617261735f7265676973747261723a3a4576656e743c52756e74696d653e00460014536c6f74730400f5070154736c6f74733a3a4576656e743c52756e74696d653e0047002041756374696f6e730400f907016061756374696f6e733a3a4576656e743c52756e74696d653e0048002443726f77646c6f616e0400fd07016463726f77646c6f616e3a3a4576656e743c52756e74696d653e004900485374617465547269654d6967726174696f6e0400010801ac70616c6c65745f73746174655f747269655f6d6967726174696f6e3a3a4576656e743c52756e74696d653e0062002458636d50616c6c657404000d08016870616c6c65745f78636d3a3a4576656e743c52756e74696d653e006300304d657373616765517565756504001508019070616c6c65745f6d6573736167655f71756575653a3a4576656e743c52756e74696d653e0064002441737365745261746504001d08018470616c6c65745f61737365745f726174653a3a4576656e743c52756e74696d653e00650000540c306672616d655f73797374656d1870616c6c6574144576656e7404045400011c4045787472696e7369635375636365737304013464697370617463685f696e666f5801304469737061746368496e666f00000490416e2065787472696e73696320636f6d706c65746564207375636365737366756c6c792e3c45787472696e7369634661696c656408013864697370617463685f6572726f7264013444697370617463684572726f7200013464697370617463685f696e666f5801304469737061746368496e666f00010450416e2065787472696e736963206661696c65642e2c436f64655570646174656400020450603a636f6465602077617320757064617465642e284e65774163636f756e7404011c6163636f756e74000130543a3a4163636f756e7449640003046841206e6577206163636f756e742077617320637265617465642e344b696c6c65644163636f756e7404011c6163636f756e74000130543a3a4163636f756e74496400040458416e206163636f756e7420776173207265617065642e2052656d61726b656408011873656e646572000130543a3a4163636f756e7449640001106861736830011c543a3a48617368000504704f6e206f6e2d636861696e2072656d61726b2068617070656e65642e4455706772616465417574686f72697a6564080124636f64655f6861736830011c543a3a48617368000134636865636b5f76657273696f6e780110626f6f6c00060468416e20757067726164652077617320617574686f72697a65642e04704576656e7420666f72207468652053797374656d2070616c6c65742e580c346672616d655f737570706f7274206469737061746368304469737061746368496e666f00000c0118776569676874240118576569676874000114636c6173735c01344469737061746368436c617373000120706179735f6665656001105061797300005c0c346672616d655f737570706f7274206469737061746368344469737061746368436c61737300010c184e6f726d616c0000002c4f7065726174696f6e616c000100244d616e6461746f727900020000600c346672616d655f737570706f727420646973706174636810506179730001080c596573000000084e6f0001000064082873705f72756e74696d653444697370617463684572726f72000138144f746865720000003043616e6e6f744c6f6f6b7570000100244261644f726967696e000200184d6f64756c65040068012c4d6f64756c654572726f7200030044436f6e73756d657252656d61696e696e670004002c4e6f50726f76696465727300050040546f6f4d616e79436f6e73756d65727300060014546f6b656e04006c0128546f6b656e4572726f720007002841726974686d65746963040070013c41726974686d657469634572726f72000800345472616e73616374696f6e616c04007401485472616e73616374696f6e616c4572726f7200090024457868617573746564000a0028436f7272757074696f6e000b002c556e617661696c61626c65000c0038526f6f744e6f74416c6c6f776564000d000068082873705f72756e74696d652c4d6f64756c654572726f720000080114696e64657808010875380001146572726f7244018c5b75383b204d41585f4d4f44554c455f4552524f525f454e434f4445445f53495a455d00006c082873705f72756e74696d6528546f6b656e4572726f720001284046756e6473556e617661696c61626c65000000304f6e6c7950726f76696465720001003042656c6f774d696e696d756d0002003043616e6e6f7443726561746500030030556e6b6e6f776e41737365740004001846726f7a656e0005002c556e737570706f727465640006004043616e6e6f74437265617465486f6c64000700344e6f74457870656e6461626c650008001c426c6f636b65640009000070083473705f61726974686d657469633c41726974686d657469634572726f7200010c24556e646572666c6f77000000204f766572666c6f77000100384469766973696f6e42795a65726f0002000074082873705f72756e74696d65485472616e73616374696f6e616c4572726f72000108304c696d6974526561636865640000001c4e6f4c61796572000100007800000500007c0c4070616c6c65745f7363686564756c65721870616c6c6574144576656e74040454000118245363686564756c65640801107768656e100144426c6f636b4e756d626572466f723c543e000114696e64657810010c753332000004505363686564756c656420736f6d65207461736b2e2043616e63656c65640801107768656e100144426c6f636b4e756d626572466f723c543e000114696e64657810010c7533320001044c43616e63656c656420736f6d65207461736b2e28446973706174636865640c01107461736b8001785461736b416464726573733c426c6f636b4e756d626572466f723c543e3e00010869648401404f7074696f6e3c5461736b4e616d653e000118726573756c748801384469737061746368526573756c74000204544469737061746368656420736f6d65207461736b2e3c43616c6c556e617661696c61626c650801107461736b8001785461736b416464726573733c426c6f636b4e756d626572466f723c543e3e00010869648401404f7074696f6e3c5461736b4e616d653e00030429015468652063616c6c20666f72207468652070726f7669646564206861736820776173206e6f7420666f756e6420736f20746865207461736b20686173206265656e2061626f727465642e38506572696f6469634661696c65640801107461736b8001785461736b416464726573733c426c6f636b4e756d626572466f723c543e3e00010869648401404f7074696f6e3c5461736b4e616d653e0004043d0154686520676976656e207461736b2077617320756e61626c6520746f2062652072656e657765642073696e636520746865206167656e64612069732066756c6c206174207468617420626c6f636b2e545065726d616e656e746c794f7665727765696768740801107461736b8001785461736b416464726573733c426c6f636b4e756d626572466f723c543e3e00010869648401404f7074696f6e3c5461736b4e616d653e000504f054686520676976656e207461736b2063616e206e657665722062652065786563757465642073696e6365206974206973206f7665727765696768742e04304576656e747320747970652e80000004081010008404184f7074696f6e04045401040108104e6f6e6500000010536f6d650400040000010000880418526573756c74080454018c044501640108084f6b04008c000000000c45727204006400000100008c0000040000900c3c70616c6c65745f707265696d6167651870616c6c6574144576656e7404045400010c144e6f7465640401106861736830011c543a3a48617368000004684120707265696d61676520686173206265656e206e6f7465642e245265717565737465640401106861736830011c543a3a48617368000104784120707265696d61676520686173206265656e207265717565737465642e1c436c65617265640401106861736830011c543a3a486173680002046c4120707265696d616765206861732062656e20636c65617265642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574940c3870616c6c65745f696e64696365731870616c6c6574144576656e7404045400010c34496e64657841737369676e656408010c77686f000130543a3a4163636f756e744964000114696e64657810013c543a3a4163636f756e74496e6465780000047441206163636f756e7420696e646578207761732061737369676e65642e28496e6465784672656564040114696e64657810013c543a3a4163636f756e74496e646578000104bc41206163636f756e7420696e64657820686173206265656e2066726565642075702028756e61737369676e6564292e2c496e64657846726f7a656e080114696e64657810013c543a3a4163636f756e74496e64657800010c77686f000130543a3a4163636f756e744964000204e841206163636f756e7420696e64657820686173206265656e2066726f7a656e20746f206974732063757272656e74206163636f756e742049442e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574980c3c70616c6c65745f62616c616e6365731870616c6c6574144576656e740804540004490001581c456e646f77656408011c6163636f756e74000130543a3a4163636f756e744964000130667265655f62616c616e6365180128543a3a42616c616e6365000004b8416e206163636f756e74207761732063726561746564207769746820736f6d6520667265652062616c616e63652e20447573744c6f737408011c6163636f756e74000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e63650001083d01416e206163636f756e74207761732072656d6f7665642077686f73652062616c616e636520776173206e6f6e2d7a65726f206275742062656c6f77204578697374656e7469616c4465706f7369742c78726573756c74696e6720696e20616e206f75747269676874206c6f73732e205472616e736665720c011066726f6d000130543a3a4163636f756e744964000108746f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e63650002044c5472616e73666572207375636365656465642e2842616c616e636553657408010c77686f000130543a3a4163636f756e74496400011066726565180128543a3a42616c616e636500030468412062616c616e6365207761732073657420627920726f6f742e20526573657276656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e6365000404e0536f6d652062616c616e63652077617320726573657276656420286d6f7665642066726f6d206672656520746f207265736572766564292e28556e726573657276656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e6365000504e8536f6d652062616c616e63652077617320756e726573657276656420286d6f7665642066726f6d20726573657276656420746f2066726565292e4852657365727665526570617472696174656410011066726f6d000130543a3a4163636f756e744964000108746f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e636500014864657374696e6174696f6e5f7374617475739c01185374617475730006084d01536f6d652062616c616e636520776173206d6f7665642066726f6d207468652072657365727665206f6620746865206669727374206163636f756e7420746f20746865207365636f6e64206163636f756e742ed846696e616c20617267756d656e7420696e64696361746573207468652064657374696e6174696f6e2062616c616e636520747970652e1c4465706f73697408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e6365000704d8536f6d6520616d6f756e7420776173206465706f73697465642028652e672e20666f72207472616e73616374696f6e2066656573292e20576974686472617708010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e63650008041d01536f6d6520616d6f756e74207761732077697468647261776e2066726f6d20746865206163636f756e742028652e672e20666f72207472616e73616374696f6e2066656573292e1c536c617368656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e63650009040101536f6d6520616d6f756e74207761732072656d6f7665642066726f6d20746865206163636f756e742028652e672e20666f72206d69736265686176696f72292e184d696e74656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e6365000a049c536f6d6520616d6f756e7420776173206d696e74656420696e746f20616e206163636f756e742e184275726e656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e6365000b049c536f6d6520616d6f756e7420776173206275726e65642066726f6d20616e206163636f756e742e2453757370656e64656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e6365000c041501536f6d6520616d6f756e74207761732073757370656e6465642066726f6d20616e206163636f756e74202869742063616e20626520726573746f726564206c61746572292e20526573746f72656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e6365000d04a4536f6d6520616d6f756e742077617320726573746f72656420696e746f20616e206163636f756e742e20557067726164656404010c77686f000130543a3a4163636f756e744964000e0460416e206163636f756e74207761732075706772616465642e18497373756564040118616d6f756e74180128543a3a42616c616e6365000f042d01546f74616c2069737375616e63652077617320696e637265617365642062792060616d6f756e74602c206372656174696e6720612063726564697420746f2062652062616c616e6365642e2452657363696e646564040118616d6f756e74180128543a3a42616c616e63650010042501546f74616c2069737375616e636520776173206465637265617365642062792060616d6f756e74602c206372656174696e672061206465627420746f2062652062616c616e6365642e184c6f636b656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e636500110460536f6d652062616c616e636520776173206c6f636b65642e20556e6c6f636b656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e636500120468536f6d652062616c616e63652077617320756e6c6f636b65642e1846726f7a656e08010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e636500130460536f6d652062616c616e6365207761732066726f7a656e2e1854686177656408010c77686f000130543a3a4163636f756e744964000118616d6f756e74180128543a3a42616c616e636500140460536f6d652062616c616e636520776173207468617765642e4c546f74616c49737375616e6365466f7263656408010c6f6c64180128543a3a42616c616e636500010c6e6577180128543a3a42616c616e6365001504ac5468652060546f74616c49737375616e6365602077617320666f72636566756c6c79206368616e6765642e047c54686520604576656e746020656e756d206f6620746869732070616c6c65749c14346672616d655f737570706f72741874726169747318746f6b656e73106d6973633442616c616e6365537461747573000108104672656500000020526573657276656400010000a00c6870616c6c65745f7472616e73616374696f6e5f7061796d656e741870616c6c6574144576656e74040454000104485472616e73616374696f6e466565506169640c010c77686f000130543a3a4163636f756e74496400012861637475616c5f66656518013042616c616e63654f663c543e00010c74697018013042616c616e63654f663c543e000008590141207472616e73616374696f6e20666565206061637475616c5f666565602c206f662077686963682060746970602077617320616464656420746f20746865206d696e696d756d20696e636c7573696f6e206665652c5c686173206265656e2070616964206279206077686f602e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574a4103870616c6c65745f7374616b696e671870616c6c65741870616c6c6574144576656e740404540001481c457261506169640c01246572615f696e646578100120457261496e64657800014076616c696461746f725f7061796f757418013042616c616e63654f663c543e00012472656d61696e64657218013042616c616e63654f663c543e000008550154686520657261207061796f757420686173206265656e207365743b207468652066697273742062616c616e6365206973207468652076616c696461746f722d7061796f75743b20746865207365636f6e64206973c07468652072656d61696e6465722066726f6d20746865206d6178696d756d20616d6f756e74206f66207265776172642e2052657761726465640c01147374617368000130543a3a4163636f756e74496400011064657374a8017c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e000118616d6f756e7418013042616c616e63654f663c543e0001040d01546865206e6f6d696e61746f7220686173206265656e207265776172646564206279207468697320616d6f756e7420746f20746869732064657374696e6174696f6e2e1c536c61736865640801187374616b6572000130543a3a4163636f756e744964000118616d6f756e7418013042616c616e63654f663c543e0002041d0141207374616b6572202876616c696461746f72206f72206e6f6d696e61746f722920686173206265656e20736c61736865642062792074686520676976656e20616d6f756e742e34536c6173685265706f727465640c012476616c696461746f72000130543a3a4163636f756e7449640001206672616374696f6eac011c50657262696c6c000124736c6173685f657261100120457261496e64657800030859014120736c61736820666f722074686520676976656e2076616c696461746f722c20666f722074686520676976656e2070657263656e74616765206f66207468656972207374616b652c2061742074686520676976656e54657261206173206265656e207265706f727465642e684f6c64536c617368696e675265706f727444697363617264656404013473657373696f6e5f696e64657810013053657373696f6e496e6465780004081901416e206f6c6420736c617368696e67207265706f72742066726f6d2061207072696f72206572612077617320646973636172646564206265636175736520697420636f756c64446e6f742062652070726f6365737365642e385374616b657273456c65637465640005048441206e657720736574206f66207374616b6572732077617320656c65637465642e18426f6e6465640801147374617368000130543a3a4163636f756e744964000118616d6f756e7418013042616c616e63654f663c543e000610d0416e206163636f756e742068617320626f6e646564207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d004d014e4f54453a2054686973206576656e74206973206f6e6c7920656d6974746564207768656e2066756e64732061726520626f6e64656420766961206120646973706174636861626c652e204e6f7461626c792c210169742077696c6c206e6f7420626520656d697474656420666f72207374616b696e672072657761726473207768656e20746865792061726520616464656420746f207374616b652e20556e626f6e6465640801147374617368000130543a3a4163636f756e744964000118616d6f756e7418013042616c616e63654f663c543e00070490416e206163636f756e742068617320756e626f6e646564207468697320616d6f756e742e2457697468647261776e0801147374617368000130543a3a4163636f756e744964000118616d6f756e7418013042616c616e63654f663c543e0008085901416e206163636f756e74206861732063616c6c6564206077697468647261775f756e626f6e6465646020616e642072656d6f76656420756e626f6e64696e67206368756e6b7320776f727468206042616c616e6365606466726f6d2074686520756e6c6f636b696e672071756575652e184b69636b65640801246e6f6d696e61746f72000130543a3a4163636f756e7449640001147374617368000130543a3a4163636f756e744964000904b441206e6f6d696e61746f7220686173206265656e206b69636b65642066726f6d20612076616c696461746f722e545374616b696e67456c656374696f6e4661696c6564000a04ac54686520656c656374696f6e206661696c65642e204e6f206e65772065726120697320706c616e6e65642e1c4368696c6c65640401147374617368000130543a3a4163636f756e744964000b042101416e206163636f756e74206861732073746f707065642070617274696369706174696e672061732065697468657220612076616c696461746f72206f72206e6f6d696e61746f722e345061796f7574537461727465640801246572615f696e646578100120457261496e64657800013c76616c696461746f725f7374617368000130543a3a4163636f756e744964000c0498546865207374616b657273272072657761726473206172652067657474696e6720706169642e4456616c696461746f7250726566735365740801147374617368000130543a3a4163636f756e7449640001147072656673b0013856616c696461746f725072656673000d0498412076616c696461746f72206861732073657420746865697220707265666572656e6365732e68536e617073686f74566f7465727353697a65457863656564656404011073697a6510010c753332000e0468566f746572732073697a65206c696d697420726561636865642e6c536e617073686f745461726765747353697a65457863656564656404011073697a6510010c753332000f046c546172676574732073697a65206c696d697420726561636865642e20466f7263654572610401106d6f6465b8011c466f7263696e670010047441206e657720666f72636520657261206d6f646520776173207365742e64436f6e74726f6c6c65724261746368446570726563617465640401206661696c7572657310010c753332001104a45265706f7274206f66206120636f6e74726f6c6c6572206261746368206465707265636174696f6e2e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574a8083870616c6c65745f7374616b696e674452657761726444657374696e6174696f6e04244163636f756e74496401000114185374616b656400000014537461736800010028436f6e74726f6c6c65720002001c4163636f756e7404000001244163636f756e744964000300104e6f6e6500040000ac0c3473705f61726974686d65746963287065725f7468696e67731c50657262696c6c0000040010010c7533320000b0083870616c6c65745f7374616b696e673856616c696461746f7250726566730000080128636f6d6d697373696f6eb4011c50657262696c6c00011c626c6f636b6564780110626f6f6c0000b4000006ac00b8083870616c6c65745f7374616b696e671c466f7263696e67000110284e6f74466f7263696e6700000020466f7263654e657700010024466f7263654e6f6e650002002c466f726365416c7761797300030000bc0c3c70616c6c65745f6f6666656e6365731870616c6c6574144576656e740001041c4f6666656e63650801106b696e64c001104b696e6400012074696d65736c6f743401384f706171756554696d65536c6f7400000c5101546865726520697320616e206f6666656e6365207265706f72746564206f662074686520676976656e20606b696e64602068617070656e656420617420746865206073657373696f6e5f696e6465786020616e643501286b696e642d7370656369666963292074696d6520736c6f742e2054686973206576656e74206973206e6f74206465706f736974656420666f72206475706c696361746520736c61736865732e4c5c5b6b696e642c2074696d65736c6f745c5d2e04304576656e747320747970652ec0000003100000000800c40c3870616c6c65745f73657373696f6e1870616c6c6574144576656e74000104284e657753657373696f6e04013473657373696f6e5f696e64657810013053657373696f6e496e64657800000839014e65772073657373696f6e206861732068617070656e65642e204e6f746520746861742074686520617267756d656e74206973207468652073657373696f6e20696e6465782c206e6f74207468659c626c6f636b206e756d626572206173207468652074797065206d6967687420737567676573742e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574c80c3870616c6c65745f6772616e6470611870616c6c6574144576656e7400010c384e6577417574686f726974696573040134617574686f726974795f736574cc0134417574686f726974794c6973740000048c4e657720617574686f726974792073657420686173206265656e206170706c6965642e185061757365640001049843757272656e7420617574686f726974792073657420686173206265656e207061757365642e1c526573756d65640002049c43757272656e7420617574686f726974792073657420686173206265656e20726573756d65642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574cc000002d000d000000408d42c00d40c5073705f636f6e73656e7375735f6772616e6470610c617070185075626c696300000400d8013c656432353531393a3a5075626c69630000d80c1c73705f636f72651c65643235353139185075626c6963000004000401205b75383b2033325d0000dc1040706f6c6b61646f745f72756e74696d654070616c6c65745f696d5f6f6e6c696e651870616c6c6574144576656e7404045400010c444865617274626561745265636569766564040130617574686f726974795f6964e0016c73757065723a3a737232353531393a3a417574686f7269747949640000001c416c6c476f6f640001002c536f6d654f66666c696e6504011c6f66666c696e65e801a073705f7374643a3a7665633a3a5665633c4964656e74696669636174696f6e5475706c653c543e3e000200047c54686520604576656e746020656e756d206f6620746869732070616c6c6574e01440706f6c6b61646f745f72756e74696d654070616c6c65745f696d5f6f6e6c696e651c737232353531392c6170705f73723235353139185075626c696300000400e4013c737232353531393a3a5075626c69630000e40c1c73705f636f72651c73723235353139185075626c6963000004000401205b75383b2033325d0000e8000002ec00ec0000040800f000f0082873705f7374616b696e67204578706f7375726508244163636f756e74496401001c42616c616e63650118000c0114746f74616cf4011c42616c616e636500010c6f776ef4011c42616c616e63650001186f7468657273f801ac5665633c496e646976696475616c4578706f737572653c4163636f756e7449642c2042616c616e63653e3e0000f40000061800f8000002fc00fc082873705f7374616b696e6748496e646976696475616c4578706f7375726508244163636f756e74496401001c42616c616e636501180008010c77686f0001244163636f756e74496400011476616c7565f4011c42616c616e6365000001010c3c70616c6c65745f74726561737572791870616c6c6574144576656e740804540004490001382050726f706f73656404013870726f706f73616c5f696e64657810013450726f706f73616c496e646578000004344e65772070726f706f73616c2e205370656e64696e670401406275646765745f72656d61696e696e6718013c42616c616e63654f663c542c20493e000104e45765206861766520656e6465642061207370656e6420706572696f6420616e642077696c6c206e6f7720616c6c6f636174652066756e64732e1c417761726465640c013870726f706f73616c5f696e64657810013450726f706f73616c496e646578000114617761726418013c42616c616e63654f663c542c20493e00011c6163636f756e74000130543a3a4163636f756e7449640002047c536f6d652066756e64732068617665206265656e20616c6c6f63617465642e2052656a656374656408013870726f706f73616c5f696e64657810013450726f706f73616c496e64657800011c736c617368656418013c42616c616e63654f663c542c20493e000304b0412070726f706f73616c207761732072656a65637465643b2066756e6473207765726520736c61736865642e144275726e7404012c6275726e745f66756e647318013c42616c616e63654f663c542c20493e00040488536f6d65206f66206f75722066756e64732068617665206265656e206275726e742e20526f6c6c6f766572040140726f6c6c6f7665725f62616c616e636518013c42616c616e63654f663c542c20493e0005042d015370656e64696e67206861732066696e69736865643b20746869732069732074686520616d6f756e74207468617420726f6c6c73206f76657220756e74696c206e657874207370656e642e1c4465706f73697404011476616c756518013c42616c616e63654f663c542c20493e0006047c536f6d652066756e64732068617665206265656e206465706f73697465642e345370656e64417070726f7665640c013870726f706f73616c5f696e64657810013450726f706f73616c496e646578000118616d6f756e7418013c42616c616e63654f663c542c20493e00012c62656e6566696369617279000130543a3a4163636f756e7449640007049c41206e6577207370656e642070726f706f73616c20686173206265656e20617070726f7665642e3c55706461746564496e61637469766508012c726561637469766174656418013c42616c616e63654f663c542c20493e00012c646561637469766174656418013c42616c616e63654f663c542c20493e000804cc54686520696e6163746976652066756e6473206f66207468652070616c6c65742068617665206265656e20757064617465642e4841737365745370656e64417070726f766564180114696e6465781001285370656e64496e64657800012861737365745f6b696e6405010130543a3a41737365744b696e64000118616d6f756e74180150417373657442616c616e63654f663c542c20493e00012c62656e656669636961727969010138543a3a42656e656669636961727900012876616c69645f66726f6d100144426c6f636b4e756d626572466f723c543e0001246578706972655f6174100144426c6f636b4e756d626572466f723c543e000904b441206e6577206173736574207370656e642070726f706f73616c20686173206265656e20617070726f7665642e4041737365745370656e64566f69646564040114696e6465781001285370656e64496e646578000a0474416e20617070726f766564207370656e642077617320766f696465642e1050616964080114696e6465781001285370656e64496e6465780001287061796d656e745f69642c01643c543a3a5061796d6173746572206173205061793e3a3a4964000b044c41207061796d656e742068617070656e65642e345061796d656e744661696c6564080114696e6465781001285370656e64496e6465780001287061796d656e745f69642c01643c543a3a5061796d6173746572206173205061793e3a3a4964000c049041207061796d656e74206661696c656420616e642063616e20626520726574726965642e385370656e6450726f636573736564040114696e6465781001285370656e64496e646578000d084d0141207370656e64207761732070726f63657373656420616e642072656d6f7665642066726f6d207468652073746f726167652e204974206d696768742068617665206265656e207375636365737366756c6c797070616964206f72206974206d6179206861766520657870697265642e047c54686520604576656e746020656e756d206f6620746869732070616c6c657405010c5c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e14696d706c735c56657273696f6e65644c6f63617461626c6541737365740001080856330801206c6f636174696f6e0901015878636d3a3a76333a3a4d756c74694c6f636174696f6e00012061737365745f69642d01014078636d3a3a76333a3a417373657449640003000856340801206c6f636174696f6e3101014478636d3a3a76343a3a4c6f636174696f6e00012061737365745f69646501014078636d3a3a76343a3a41737365744964000400000901102c73746167696e675f78636d087633346d756c74696c6f636174696f6e344d756c74694c6f636174696f6e000008011c706172656e74730801087538000120696e746572696f720d0101244a756e6374696f6e7300000d01100c78636d087633246a756e6374696f6e73244a756e6374696f6e7300012410486572650000000858310400110101204a756e6374696f6e0001000858320800110101204a756e6374696f6e0000110101204a756e6374696f6e0002000858330c00110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0003000858341000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0004000858351400110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0005000858361800110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0006000858371c00110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0007000858382000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e0000110101204a756e6374696f6e000800001101100c78636d087633206a756e6374696f6e204a756e6374696f6e0001282450617261636861696e04001501010c7533320000002c4163636f756e744964333208011c6e6574776f726b190101444f7074696f6e3c4e6574776f726b49643e00010869640401205b75383b2033325d000100384163636f756e74496e646578363408011c6e6574776f726b190101444f7074696f6e3c4e6574776f726b49643e000114696e64657828010c753634000200304163636f756e744b6579323008011c6e6574776f726b190101444f7074696f6e3c4e6574776f726b49643e00010c6b6579210101205b75383b2032305d0003003850616c6c6574496e7374616e6365040008010875380004003047656e6572616c496e6465780400f40110753132380005002847656e6572616c4b65790801186c656e6774680801087538000110646174610401205b75383b2033325d000600244f6e6c794368696c6400070024506c7572616c697479080108696425010118426f647949640001107061727429010120426f6479506172740008003c476c6f62616c436f6e73656e73757304001d0101244e6574776f726b49640009000015010000061000190104184f7074696f6e040454011d010108104e6f6e6500000010536f6d6504001d0100000100001d01100c78636d087633206a756e6374696f6e244e6574776f726b496400012c24427947656e6573697304000401205b75383b2033325d000000184279466f726b080130626c6f636b5f6e756d6265722c010c753634000128626c6f636b5f686173680401205b75383b2033325d00010020506f6c6b61646f74000200184b7573616d610003001c57657374656e6400040018526f636f636f00050018576f636f636f00060020457468657265756d040120636861696e5f696428010c7536340007002c426974636f696e436f72650008002c426974636f696e4361736800090040506f6c6b61646f7442756c6c6574696e000a000021010000031400000008002501100c78636d087633206a756e6374696f6e18426f6479496400012810556e69740000001c4d6f6e696b6572040044011c5b75383b20345d00010014496e64657804001501010c7533320002002445786563757469766500030024546563686e6963616c0004002c4c656769736c6174697665000500204a7564696369616c0006001c446566656e73650007003841646d696e697374726174696f6e000800205472656173757279000900002901100c78636d087633206a756e6374696f6e20426f64795061727400011414566f6963650000001c4d656d62657273040114636f756e741501010c753332000100204672616374696f6e08010c6e6f6d1501010c75333200011464656e6f6d1501010c7533320002004441744c6561737450726f706f7274696f6e08010c6e6f6d1501010c75333200011464656e6f6d1501010c753332000300484d6f72655468616e50726f706f7274696f6e08010c6e6f6d1501010c75333200011464656e6f6d1501010c753332000400002d01100c78636d087633286d756c746961737365741c4173736574496400010820436f6e63726574650400090101344d756c74694c6f636174696f6e00000020416273747261637404000401205b75383b2033325d000100003101102c73746167696e675f78636d087634206c6f636174696f6e204c6f636174696f6e000008011c706172656e74730801087538000120696e746572696f72350101244a756e6374696f6e7300003501102c73746167696e675f78636d087634246a756e6374696f6e73244a756e6374696f6e7300012410486572650000000858310400390101484172633c5b4a756e6374696f6e3b20315d3e0001000858320400490101484172633c5b4a756e6374696f6e3b20325d3e00020008583304004d0101484172633c5b4a756e6374696f6e3b20335d3e0003000858340400510101484172633c5b4a756e6374696f6e3b20345d3e0004000858350400550101484172633c5b4a756e6374696f6e3b20355d3e0005000858360400590101484172633c5b4a756e6374696f6e3b20365d3e00060008583704005d0101484172633c5b4a756e6374696f6e3b20375d3e0007000858380400610101484172633c5b4a756e6374696f6e3b20385d3e000800003901000003010000003d01003d01102c73746167696e675f78636d087634206a756e6374696f6e204a756e6374696f6e0001282450617261636861696e04001501010c7533320000002c4163636f756e744964333208011c6e6574776f726b410101444f7074696f6e3c4e6574776f726b49643e00010869640401205b75383b2033325d000100384163636f756e74496e646578363408011c6e6574776f726b410101444f7074696f6e3c4e6574776f726b49643e000114696e64657828010c753634000200304163636f756e744b6579323008011c6e6574776f726b410101444f7074696f6e3c4e6574776f726b49643e00010c6b6579210101205b75383b2032305d0003003850616c6c6574496e7374616e6365040008010875380004003047656e6572616c496e6465780400f40110753132380005002847656e6572616c4b65790801186c656e6774680801087538000110646174610401205b75383b2033325d000600244f6e6c794368696c6400070024506c7572616c697479080108696425010118426f647949640001107061727429010120426f6479506172740008003c476c6f62616c436f6e73656e7375730400450101244e6574776f726b496400090000410104184f7074696f6e0404540145010108104e6f6e6500000010536f6d650400450100000100004501102c73746167696e675f78636d087634206a756e6374696f6e244e6574776f726b496400012c24427947656e6573697304000401205b75383b2033325d000000184279466f726b080130626c6f636b5f6e756d6265722c010c753634000128626c6f636b5f686173680401205b75383b2033325d00010020506f6c6b61646f74000200184b7573616d610003001c57657374656e6400040018526f636f636f00050018576f636f636f00060020457468657265756d040120636861696e5f696428010c7536340007002c426974636f696e436f72650008002c426974636f696e4361736800090040506f6c6b61646f7442756c6c6574696e000a00004901000003020000003d01004d01000003030000003d01005101000003040000003d01005501000003050000003d01005901000003060000003d01005d01000003070000003d01006101000003080000003d01006501102c73746167696e675f78636d0876341461737365741c4173736574496400000400310101204c6f636174696f6e00006901080c78636d4456657273696f6e65644c6f636174696f6e00010c08563204006d01014476323a3a4d756c74694c6f636174696f6e00010008563304000901014476333a3a4d756c74694c6f636174696f6e00030008563404003101013076343a3a4c6f636174696f6e000400006d01100c78636d087632346d756c74696c6f636174696f6e344d756c74694c6f636174696f6e000008011c706172656e74730801087538000120696e746572696f72710101244a756e6374696f6e7300007101100c78636d087632346d756c74696c6f636174696f6e244a756e6374696f6e7300012410486572650000000858310400750101204a756e6374696f6e0001000858320800750101204a756e6374696f6e0000750101204a756e6374696f6e0002000858330c00750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0003000858341000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0004000858351400750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0005000858361800750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0006000858371c00750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0007000858382000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e0000750101204a756e6374696f6e000800007501100c78636d087632206a756e6374696f6e204a756e6374696f6e0001242450617261636861696e04001501010c7533320000002c4163636f756e744964333208011c6e6574776f726b790101244e6574776f726b496400010869640401205b75383b2033325d000100384163636f756e74496e646578363408011c6e6574776f726b790101244e6574776f726b4964000114696e64657828010c753634000200304163636f756e744b6579323008011c6e6574776f726b790101244e6574776f726b496400010c6b6579210101205b75383b2032305d0003003850616c6c6574496e7374616e6365040008010875380004003047656e6572616c496e6465780400f40110753132380005002847656e6572616c4b657904007d0101805765616b426f756e6465645665633c75382c20436f6e73745533323c33323e3e000600244f6e6c794368696c6400070024506c7572616c697479080108696481010118426f647949640001107061727485010120426f6479506172740008000079010c0c78636d087632244e6574776f726b49640001100c416e79000000144e616d656404007d0101805765616b426f756e6465645665633c75382c20436f6e73745533323c33323e3e00010020506f6c6b61646f74000200184b7573616d61000300007d010c4c626f756e6465645f636f6c6c656374696f6e73407765616b5f626f756e6465645f766563385765616b426f756e64656456656308045401080453000004003401185665633c543e000081010c0c78636d08763218426f6479496400012810556e6974000000144e616d656404007d0101805765616b426f756e6465645665633c75382c20436f6e73745533323c33323e3e00010014496e64657804001501010c7533320002002445786563757469766500030024546563686e6963616c0004002c4c656769736c6174697665000500204a7564696369616c0006001c446566656e73650007003841646d696e697374726174696f6e0008002054726561737572790009000085010c0c78636d08763220426f64795061727400011414566f6963650000001c4d656d62657273040114636f756e741501010c753332000100204672616374696f6e08010c6e6f6d1501010c75333200011464656e6f6d1501010c7533320002004441744c6561737450726f706f7274696f6e08010c6e6f6d1501010c75333200011464656e6f6d1501010c753332000300484d6f72655468616e50726f706f7274696f6e08010c6e6f6d1501010c75333200011464656e6f6d1501010c7533320004000089010c6070616c6c65745f636f6e76696374696f6e5f766f74696e671870616c6c6574144576656e740804540004490001082444656c6567617465640800000130543a3a4163636f756e7449640000000130543a3a4163636f756e7449640000041d01416e206163636f756e74206861732064656c65676174656420746865697220766f746520746f20616e6f74686572206163636f756e742e205c5b77686f2c207461726765745c5d2c556e64656c6567617465640400000130543a3a4163636f756e744964000104f4416e205c5b6163636f756e745c5d206861732063616e63656c6c656420612070726576696f75732064656c65676174696f6e206f7065726174696f6e2e047c54686520604576656e746020656e756d206f6620746869732070616c6c65748d010c4070616c6c65745f7265666572656e64611870616c6c6574144576656e74080454000449000140245375626d69747465640c0114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e0114747261636b9101013c547261636b49644f663c542c20493e04250154686520747261636b2028616e6420627920657874656e73696f6e2070726f706f73616c206469737061746368206f726967696e29206f662074686973207265666572656e64756d2e012070726f706f73616c9501014c426f756e64656443616c6c4f663c542c20493e04805468652070726f706f73616c20666f7220746865207265666572656e64756d2e00048041207265666572656e64756d20686173206265656e207375626d69747465642e544465636973696f6e4465706f736974506c616365640c0114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e010c77686f000130543a3a4163636f756e744964048c546865206163636f756e742077686f20706c6163656420746865206465706f7369742e0118616d6f756e7418013c42616c616e63654f663c542c20493e048454686520616d6f756e7420706c6163656420627920746865206163636f756e742e010494546865206465636973696f6e206465706f73697420686173206265656e20706c616365642e5c4465636973696f6e4465706f736974526566756e6465640c0114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e010c77686f000130543a3a4163636f756e744964048c546865206163636f756e742077686f20706c6163656420746865206465706f7369742e0118616d6f756e7418013c42616c616e63654f663c542c20493e048454686520616d6f756e7420706c6163656420627920746865206163636f756e742e02049c546865206465636973696f6e206465706f73697420686173206265656e20726566756e6465642e384465706f736974536c617368656408010c77686f000130543a3a4163636f756e744964048c546865206163636f756e742077686f20706c6163656420746865206465706f7369742e0118616d6f756e7418013c42616c616e63654f663c542c20493e048454686520616d6f756e7420706c6163656420627920746865206163636f756e742e03046c41206465706f73697420686173206265656e20736c61736865642e3c4465636973696f6e53746172746564100114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e0114747261636b9101013c547261636b49644f663c542c20493e04250154686520747261636b2028616e6420627920657874656e73696f6e2070726f706f73616c206469737061746368206f726967696e29206f662074686973207265666572656e64756d2e012070726f706f73616c9501014c426f756e64656443616c6c4f663c542c20493e04805468652070726f706f73616c20666f7220746865207265666572656e64756d2e011474616c6c7979070120543a3a54616c6c7904b85468652063757272656e742074616c6c79206f6620766f74657320696e2074686973207265666572656e64756d2e0404bc41207265666572656e64756d20686173206d6f76656420696e746f20746865206465636964696e672070686173652e38436f6e6669726d53746172746564040114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e050038436f6e6669726d41626f72746564040114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e060024436f6e6669726d6564080114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e011474616c6c7979070120543a3a54616c6c7904b05468652066696e616c2074616c6c79206f6620766f74657320696e2074686973207265666572656e64756d2e0704210141207265666572656e64756d2068617320656e6465642069747320636f6e6669726d6174696f6e20706861736520616e6420697320726561647920666f7220617070726f76616c2e20417070726f766564040114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e08040d0141207265666572656e64756d20686173206265656e20617070726f76656420616e64206974732070726f706f73616c20686173206265656e207363686564756c65642e2052656a6563746564080114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e011474616c6c7979070120543a3a54616c6c7904b05468652066696e616c2074616c6c79206f6620766f74657320696e2074686973207265666572656e64756d2e0904ac412070726f706f73616c20686173206265656e2072656a6563746564206279207265666572656e64756d2e2054696d65644f7574080114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e011474616c6c7979070120543a3a54616c6c7904b05468652066696e616c2074616c6c79206f6620766f74657320696e2074686973207265666572656e64756d2e0a04d841207265666572656e64756d20686173206265656e2074696d6564206f757420776974686f7574206265696e6720646563696465642e2443616e63656c6c6564080114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e011474616c6c7979070120543a3a54616c6c7904b05468652066696e616c2074616c6c79206f6620766f74657320696e2074686973207265666572656e64756d2e0b048041207265666572656e64756d20686173206265656e2063616e63656c6c65642e184b696c6c6564080114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e011474616c6c7979070120543a3a54616c6c7904b05468652066696e616c2074616c6c79206f6620766f74657320696e2074686973207265666572656e64756d2e0c047441207265666572656e64756d20686173206265656e206b696c6c65642e645375626d697373696f6e4465706f736974526566756e6465640c0114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e010c77686f000130543a3a4163636f756e744964048c546865206163636f756e742077686f20706c6163656420746865206465706f7369742e0118616d6f756e7418013c42616c616e63654f663c542c20493e048454686520616d6f756e7420706c6163656420627920746865206163636f756e742e0d04a4546865207375626d697373696f6e206465706f73697420686173206265656e20726566756e6465642e2c4d65746164617461536574080114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e01106861736830011c543a3a486173680438507265696d61676520686173682e0e049c4d6574616461746120666f722061207265666572656e64756d20686173206265656e207365742e3c4d65746164617461436c6561726564080114696e64657810013c5265666572656e64756d496e6465780460496e646578206f6620746865207265666572656e64756d2e01106861736830011c543a3a486173680438507265696d61676520686173682e0f04ac4d6574616461746120666f722061207265666572656e64756d20686173206265656e20636c65617265642e047c54686520604576656e746020656e756d206f6620746869732070616c6c657491010000050400950110346672616d655f737570706f72741874726169747324707265696d616765731c426f756e6465640804540199010448017107010c184c656761637904011068617368300124483a3a4f757470757400000018496e6c696e65040075070134426f756e646564496e6c696e65000100184c6f6f6b757008011068617368300124483a3a4f757470757400010c6c656e10010c7533320002000099010840706f6c6b61646f745f72756e74696d652c52756e74696d6543616c6c0001b01853797374656d04009d0101ad0173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c53797374656d2c2052756e74696d653e000000245363686564756c65720400ad0101b90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c5363686564756c65722c2052756e74696d653e00010020507265696d6167650400b50101b50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c507265696d6167652c2052756e74696d653e000a0010426162650400bd0101a50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c426162652c2052756e74696d653e0002002454696d657374616d700400e10101b90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c54696d657374616d702c2052756e74696d653e0003001c496e64696365730400e50101b10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c496e64696365732c2052756e74696d653e0004002042616c616e6365730400f10101b50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c42616c616e6365732c2052756e74696d653e0005001c5374616b696e670400fd0101b10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c5374616b696e672c2052756e74696d653e0007001c53657373696f6e0400390201b10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c53657373696f6e2c2052756e74696d653e0009001c4772616e6470610400590201b10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c4772616e6470612c2052756e74696d653e000b002054726561737572790400890201b50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c54726561737572792c2052756e74696d653e00130040436f6e76696374696f6e566f74696e670400910201d50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c436f6e76696374696f6e566f74696e672c2052756e74696d653e001400245265666572656e64610400a50201b90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c5265666572656e64612c2052756e74696d653e0015002457686974656c6973740400cd0201b90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c57686974656c6973742c2052756e74696d653e00170018436c61696d730400d10201ad0173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c436c61696d732c2052756e74696d653e0018001c56657374696e670400f10201b10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c56657374696e672c2052756e74696d653e0019001c5574696c6974790400f90201b10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c5574696c6974792c2052756e74696d653e001a00204964656e746974790400010301b50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c4964656e746974792c2052756e74696d653e001c001450726f78790400b10301a90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c50726f78792c2052756e74696d653e001d00204d756c74697369670400bd0301b50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c4d756c74697369672c2052756e74696d653e001e0020426f756e746965730400c90301b50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c426f756e746965732c2052756e74696d653e002200344368696c64426f756e746965730400cd0301c90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c4368696c64426f756e746965732c2052756e74696d653e00260068456c656374696f6e50726f76696465724d756c746950686173650400d10301fd0173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c456c656374696f6e50726f76696465724d756c746950686173652c2052756e74696d653e00240024566f7465724c6973740400c50401b90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c566f7465724c6973742c2052756e74696d653e0025003c4e6f6d696e6174696f6e506f6f6c730400c90401d10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c4e6f6d696e6174696f6e506f6f6c732c2052756e74696d653e0027002c46617374556e7374616b650400fd0401c10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c46617374556e7374616b652c2052756e74696d653e00280034436f6e66696775726174696f6e0400010501c90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c436f6e66696775726174696f6e2c2052756e74696d653e0033002c50617261735368617265640400210501c10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c50617261735368617265642c2052756e74696d653e0034003450617261496e636c7573696f6e0400250501c90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c50617261496e636c7573696f6e2c2052756e74696d653e0035003050617261496e686572656e740400290501c50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c50617261496e686572656e742c2052756e74696d653e0036001450617261730400b50501a90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c50617261732c2052756e74696d653e0038002c496e697469616c697a65720400bd0501c10173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c496e697469616c697a65722c2052756e74696d653e0039001048726d700400c10501a50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c48726d702c2052756e74696d653e003c0034506172617344697370757465730400c90501c90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c506172617344697370757465732c2052756e74696d653e003e00345061726173536c617368696e670400cd0501c90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c5061726173536c617368696e672c2052756e74696d653e003f00245265676973747261720400dd0501b90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c5265676973747261722c2052756e74696d653e00460014536c6f74730400e10501a90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c536c6f74732c2052756e74696d653e0047002041756374696f6e730400e50501b50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c41756374696f6e732c2052756e74696d653e0048002443726f77646c6f616e0400ed0501b90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c43726f77646c6f616e2c2052756e74696d653e004900485374617465547269654d6967726174696f6e0400f90501dd0173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c5374617465547269654d6967726174696f6e2c2052756e74696d653e0062002458636d50616c6c65740400110601b90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c58636d50616c6c65742c2052756e74696d653e006300304d657373616765517565756504003d0701c50173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c4d65737361676551756575652c2052756e74696d653e006400244173736574526174650400490701b90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c4173736574526174652c2052756e74696d653e0065001442656566790400510701a90173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a64697370617463680a3a3a43616c6c61626c6543616c6c466f723c42656566792c2052756e74696d653e00c800009d010c306672616d655f73797374656d1870616c6c65741043616c6c04045400012c1872656d61726b04011872656d61726b34011c5665633c75383e0000045c536565205b6050616c6c65743a3a72656d61726b605d2e387365745f686561705f706167657304011470616765732c010c7536340001047c536565205b6050616c6c65743a3a7365745f686561705f7061676573605d2e207365745f636f6465040110636f646534011c5665633c75383e00020464536565205b6050616c6c65743a3a7365745f636f6465605d2e5c7365745f636f64655f776974686f75745f636865636b73040110636f646534011c5665633c75383e000304a0536565205b6050616c6c65743a3a7365745f636f64655f776974686f75745f636865636b73605d2e2c7365745f73746f726167650401146974656d73a10101345665633c4b657956616c75653e00040470536565205b6050616c6c65743a3a7365745f73746f72616765605d2e306b696c6c5f73746f726167650401106b657973a90101205665633c4b65793e00050474536565205b6050616c6c65743a3a6b696c6c5f73746f72616765605d2e2c6b696c6c5f70726566697808011870726566697834010c4b657900011c7375626b65797310010c75333200060470536565205b6050616c6c65743a3a6b696c6c5f707265666978605d2e4472656d61726b5f776974685f6576656e7404011872656d61726b34011c5665633c75383e00070488536565205b6050616c6c65743a3a72656d61726b5f776974685f6576656e74605d2e44617574686f72697a655f75706772616465040124636f64655f6861736830011c543a3a4861736800090488536565205b6050616c6c65743a3a617574686f72697a655f75706772616465605d2e80617574686f72697a655f757067726164655f776974686f75745f636865636b73040124636f64655f6861736830011c543a3a48617368000a04c4536565205b6050616c6c65743a3a617574686f72697a655f757067726164655f776974686f75745f636865636b73605d2e606170706c795f617574686f72697a65645f75706772616465040110636f646534011c5665633c75383e000b04a4536565205b6050616c6c65743a3a6170706c795f617574686f72697a65645f75706772616465605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ea101000002a50100a50100000408343400a9010000023400ad010c4070616c6c65745f7363686564756c65721870616c6c65741043616c6c040454000118207363686564756c651001107768656e100144426c6f636b4e756d626572466f723c543e0001386d617962655f706572696f646963b10101ac4f7074696f6e3c7363686564756c653a3a506572696f643c426c6f636b4e756d626572466f723c543e3e3e0001207072696f726974790801487363686564756c653a3a5072696f7269747900011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00000464536565205b6050616c6c65743a3a7363686564756c65605d2e1863616e63656c0801107768656e100144426c6f636b4e756d626572466f723c543e000114696e64657810010c7533320001045c536565205b6050616c6c65743a3a63616e63656c605d2e387363686564756c655f6e616d656414010869640401205461736b4e616d650001107768656e100144426c6f636b4e756d626572466f723c543e0001386d617962655f706572696f646963b10101ac4f7074696f6e3c7363686564756c653a3a506572696f643c426c6f636b4e756d626572466f723c543e3e3e0001207072696f726974790801487363686564756c653a3a5072696f7269747900011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e0002047c536565205b6050616c6c65743a3a7363686564756c655f6e616d6564605d2e3063616e63656c5f6e616d656404010869640401205461736b4e616d6500030474536565205b6050616c6c65743a3a63616e63656c5f6e616d6564605d2e387363686564756c655f61667465721001146166746572100144426c6f636b4e756d626572466f723c543e0001386d617962655f706572696f646963b10101ac4f7074696f6e3c7363686564756c653a3a506572696f643c426c6f636b4e756d626572466f723c543e3e3e0001207072696f726974790801487363686564756c653a3a5072696f7269747900011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e0004047c536565205b6050616c6c65743a3a7363686564756c655f6166746572605d2e507363686564756c655f6e616d65645f616674657214010869640401205461736b4e616d650001146166746572100144426c6f636b4e756d626572466f723c543e0001386d617962655f706572696f646963b10101ac4f7074696f6e3c7363686564756c653a3a506572696f643c426c6f636b4e756d626572466f723c543e3e3e0001207072696f726974790801487363686564756c653a3a5072696f7269747900011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00050494536565205b6050616c6c65743a3a7363686564756c655f6e616d65645f6166746572605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732eb10104184f7074696f6e04045401800108104e6f6e6500000010536f6d650400800000010000b5010c3c70616c6c65745f707265696d6167651870616c6c65741043616c6c040454000114346e6f74655f707265696d616765040114627974657334011c5665633c75383e00000478536565205b6050616c6c65743a3a6e6f74655f707265696d616765605d2e3c756e6e6f74655f707265696d6167650401106861736830011c543a3a4861736800010480536565205b6050616c6c65743a3a756e6e6f74655f707265696d616765605d2e40726571756573745f707265696d6167650401106861736830011c543a3a4861736800020484536565205b6050616c6c65743a3a726571756573745f707265696d616765605d2e48756e726571756573745f707265696d6167650401106861736830011c543a3a486173680003048c536565205b6050616c6c65743a3a756e726571756573745f707265696d616765605d2e38656e737572655f75706461746564040118686173686573b90101305665633c543a3a486173683e0004047c536565205b6050616c6c65743a3a656e737572655f75706461746564605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732eb9010000023000bd010c2c70616c6c65745f626162651870616c6c65741043616c6c04045400010c4c7265706f72745f65717569766f636174696f6e08014865717569766f636174696f6e5f70726f6f66c1010190426f783c45717569766f636174696f6e50726f6f663c486561646572466f723c543e3e3e00013c6b65795f6f776e65725f70726f6f66d1010140543a3a4b65794f776e657250726f6f6600000490536565205b6050616c6c65743a3a7265706f72745f65717569766f636174696f6e605d2e707265706f72745f65717569766f636174696f6e5f756e7369676e656408014865717569766f636174696f6e5f70726f6f66c1010190426f783c45717569766f636174696f6e50726f6f663c486561646572466f723c543e3e3e00013c6b65795f6f776e65725f70726f6f66d1010140543a3a4b65794f776e657250726f6f66000104b4536565205b6050616c6c65743a3a7265706f72745f65717569766f636174696f6e5f756e7369676e6564605d2e48706c616e5f636f6e6669675f6368616e6765040118636f6e666967d50101504e657874436f6e66696744657363726970746f720002048c536565205b6050616c6c65743a3a706c616e5f636f6e6669675f6368616e6765605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ec101084873705f636f6e73656e7375735f736c6f74734445717569766f636174696f6e50726f6f66081848656164657201c50108496401c901001001206f6666656e646572c90101084964000110736c6f74cd010110536c6f7400013066697273745f686561646572c50101184865616465720001347365636f6e645f686561646572c50101184865616465720000c501102873705f72756e74696d651c67656e65726963186865616465721848656164657208184e756d62657201101048617368000014012c706172656e745f68617368300130486173683a3a4f75747075740001186e756d626572150101184e756d62657200012873746174655f726f6f74300130486173683a3a4f757470757400013c65787472696e736963735f726f6f74300130486173683a3a4f75747075740001186469676573743801184469676573740000c9010c4473705f636f6e73656e7375735f626162650c617070185075626c696300000400e4013c737232353531393a3a5075626c69630000cd01084873705f636f6e73656e7375735f736c6f747310536c6f74000004002c010c7536340000d101082873705f73657373696f6e3c4d656d6265727368697050726f6f6600000c011c73657373696f6e10013053657373696f6e496e646578000128747269655f6e6f646573a90101305665633c5665633c75383e3e00013c76616c696461746f725f636f756e7410013856616c696461746f72436f756e740000d5010c4473705f636f6e73656e7375735f626162651c64696765737473504e657874436f6e66696744657363726970746f7200010408563108010463d9010128287536342c2075363429000134616c6c6f7765645f736c6f7473dd010130416c6c6f776564536c6f747300010000d901000004082c2c00dd01084473705f636f6e73656e7375735f6261626530416c6c6f776564536c6f747300010c305072696d617279536c6f7473000000745072696d617279416e645365636f6e64617279506c61696e536c6f74730001006c5072696d617279416e645365636f6e64617279565246536c6f747300020000e1010c4070616c6c65745f74696d657374616d701870616c6c65741043616c6c0404540001040c73657404010c6e6f77280124543a3a4d6f6d656e7400000450536565205b6050616c6c65743a3a736574605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ee5010c3870616c6c65745f696e64696365731870616c6c65741043616c6c04045400011414636c61696d040114696e64657810013c543a3a4163636f756e74496e64657800000458536565205b6050616c6c65743a3a636c61696d605d2e207472616e7366657208010c6e6577e90101504163636f756e7449644c6f6f6b75704f663c543e000114696e64657810013c543a3a4163636f756e74496e64657800010464536565205b6050616c6c65743a3a7472616e73666572605d2e1066726565040114696e64657810013c543a3a4163636f756e74496e64657800020454536565205b6050616c6c65743a3a66726565605d2e38666f7263655f7472616e736665720c010c6e6577e90101504163636f756e7449644c6f6f6b75704f663c543e000114696e64657810013c543a3a4163636f756e74496e646578000118667265657a65780110626f6f6c0003047c536565205b6050616c6c65743a3a666f7263655f7472616e73666572605d2e18667265657a65040114696e64657810013c543a3a4163636f756e74496e6465780004045c536565205b6050616c6c65743a3a667265657a65605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ee9010c2873705f72756e74696d65306d756c746961646472657373304d756c74694164647265737308244163636f756e7449640100304163636f756e74496e646578018c011408496404000001244163636f756e74496400000014496e6465780400ed0101304163636f756e74496e6465780001000c526177040034011c5665633c75383e0002002441646472657373333204000401205b75383b2033325d000300244164647265737332300400210101205b75383b2032305d00040000ed010000068c00f1010c3c70616c6c65745f62616c616e6365731870616c6c65741043616c6c080454000449000120507472616e736665725f616c6c6f775f646561746808011064657374e90101504163636f756e7449644c6f6f6b75704f663c543e00011476616c7565f40128543a3a42616c616e636500000494536565205b6050616c6c65743a3a7472616e736665725f616c6c6f775f6465617468605d2e38666f7263655f7472616e736665720c0118736f75726365e90101504163636f756e7449644c6f6f6b75704f663c543e00011064657374e90101504163636f756e7449644c6f6f6b75704f663c543e00011476616c7565f40128543a3a42616c616e63650002047c536565205b6050616c6c65743a3a666f7263655f7472616e73666572605d2e4c7472616e736665725f6b6565705f616c69766508011064657374e90101504163636f756e7449644c6f6f6b75704f663c543e00011476616c7565f40128543a3a42616c616e636500030490536565205b6050616c6c65743a3a7472616e736665725f6b6565705f616c697665605d2e307472616e736665725f616c6c08011064657374e90101504163636f756e7449644c6f6f6b75704f663c543e0001286b6565705f616c697665780110626f6f6c00040474536565205b6050616c6c65743a3a7472616e736665725f616c6c605d2e3c666f7263655f756e7265736572766508010c77686fe90101504163636f756e7449644c6f6f6b75704f663c543e000118616d6f756e74180128543a3a42616c616e636500050480536565205b6050616c6c65743a3a666f7263655f756e72657365727665605d2e40757067726164655f6163636f756e747304010c77686ff50101445665633c543a3a4163636f756e7449643e00060484536565205b6050616c6c65743a3a757067726164655f6163636f756e7473605d2e44666f7263655f7365745f62616c616e636508010c77686fe90101504163636f756e7449644c6f6f6b75704f663c543e0001206e65775f66726565f40128543a3a42616c616e636500080488536565205b6050616c6c65743a3a666f7263655f7365745f62616c616e6365605d2e6c666f7263655f61646a7573745f746f74616c5f69737375616e6365080124646972656374696f6ef901014c41646a7573746d656e74446972656374696f6e00011464656c7461f40128543a3a42616c616e6365000904b0536565205b6050616c6c65743a3a666f7263655f61646a7573745f746f74616c5f69737375616e6365605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ef5010000020000f9010c3c70616c6c65745f62616c616e6365731474797065734c41646a7573746d656e74446972656374696f6e00010820496e63726561736500000020446563726561736500010000fd01103870616c6c65745f7374616b696e671870616c6c65741870616c6c65741043616c6c04045400017810626f6e6408011476616c7565f4013042616c616e63654f663c543e0001147061796565a8017c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e00000454536565205b6050616c6c65743a3a626f6e64605d2e28626f6e645f65787472610401386d61785f6164646974696f6e616cf4013042616c616e63654f663c543e0001046c536565205b6050616c6c65743a3a626f6e645f6578747261605d2e18756e626f6e6404011476616c7565f4013042616c616e63654f663c543e0002045c536565205b6050616c6c65743a3a756e626f6e64605d2e4477697468647261775f756e626f6e6465640401486e756d5f736c617368696e675f7370616e7310010c75333200030488536565205b6050616c6c65743a3a77697468647261775f756e626f6e646564605d2e2076616c69646174650401147072656673b0013856616c696461746f72507265667300040464536565205b6050616c6c65743a3a76616c6964617465605d2e206e6f6d696e61746504011c74617267657473010201645665633c4163636f756e7449644c6f6f6b75704f663c543e3e00050464536565205b6050616c6c65743a3a6e6f6d696e617465605d2e146368696c6c00060458536565205b6050616c6c65743a3a6368696c6c605d2e247365745f70617965650401147061796565a8017c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e00070468536565205b6050616c6c65743a3a7365745f7061796565605d2e387365745f636f6e74726f6c6c65720008047c536565205b6050616c6c65743a3a7365745f636f6e74726f6c6c6572605d2e4c7365745f76616c696461746f725f636f756e7404010c6e65771501010c75333200090490536565205b6050616c6c65743a3a7365745f76616c696461746f725f636f756e74605d2e60696e6372656173655f76616c696461746f725f636f756e740401286164646974696f6e616c1501010c753332000a04a4536565205b6050616c6c65743a3a696e6372656173655f76616c696461746f725f636f756e74605d2e547363616c655f76616c696461746f725f636f756e74040118666163746f720502011c50657263656e74000b0498536565205b6050616c6c65743a3a7363616c655f76616c696461746f725f636f756e74605d2e34666f7263655f6e6f5f65726173000c0478536565205b6050616c6c65743a3a666f7263655f6e6f5f65726173605d2e34666f7263655f6e65775f657261000d0478536565205b6050616c6c65743a3a666f7263655f6e65775f657261605d2e447365745f696e76756c6e657261626c6573040134696e76756c6e657261626c6573f50101445665633c543a3a4163636f756e7449643e000e0488536565205b6050616c6c65743a3a7365745f696e76756c6e657261626c6573605d2e34666f7263655f756e7374616b650801147374617368000130543a3a4163636f756e7449640001486e756d5f736c617368696e675f7370616e7310010c753332000f0478536565205b6050616c6c65743a3a666f7263655f756e7374616b65605d2e50666f7263655f6e65775f6572615f616c7761797300100494536565205b6050616c6c65743a3a666f7263655f6e65775f6572615f616c77617973605d2e5463616e63656c5f64656665727265645f736c61736808010c657261100120457261496e646578000134736c6173685f696e6469636573090201205665633c7533323e00110498536565205b6050616c6c65743a3a63616e63656c5f64656665727265645f736c617368605d2e387061796f75745f7374616b65727308013c76616c696461746f725f7374617368000130543a3a4163636f756e74496400010c657261100120457261496e6465780012047c536565205b6050616c6c65743a3a7061796f75745f7374616b657273605d2e187265626f6e6404011476616c7565f4013042616c616e63654f663c543e0013045c536565205b6050616c6c65743a3a7265626f6e64605d2e28726561705f73746173680801147374617368000130543a3a4163636f756e7449640001486e756d5f736c617368696e675f7370616e7310010c7533320014046c536565205b6050616c6c65743a3a726561705f7374617368605d2e106b69636b04010c77686f010201645665633c4163636f756e7449644c6f6f6b75704f663c543e3e00150454536565205b6050616c6c65743a3a6b69636b605d2e4c7365745f7374616b696e675f636f6e666967731801486d696e5f6e6f6d696e61746f725f626f6e640d020158436f6e6669674f703c42616c616e63654f663c543e3e0001486d696e5f76616c696461746f725f626f6e640d020158436f6e6669674f703c42616c616e63654f663c543e3e00014c6d61785f6e6f6d696e61746f725f636f756e7411020134436f6e6669674f703c7533323e00014c6d61785f76616c696461746f725f636f756e7411020134436f6e6669674f703c7533323e00013c6368696c6c5f7468726573686f6c6415020144436f6e6669674f703c50657263656e743e0001386d696e5f636f6d6d697373696f6e19020144436f6e6669674f703c50657262696c6c3e00160490536565205b6050616c6c65743a3a7365745f7374616b696e675f636f6e66696773605d2e2c6368696c6c5f6f746865720401147374617368000130543a3a4163636f756e74496400170470536565205b6050616c6c65743a3a6368696c6c5f6f74686572605d2e68666f7263655f6170706c795f6d696e5f636f6d6d697373696f6e04013c76616c696461746f725f7374617368000130543a3a4163636f756e744964001804ac536565205b6050616c6c65743a3a666f7263655f6170706c795f6d696e5f636f6d6d697373696f6e605d2e487365745f6d696e5f636f6d6d697373696f6e04010c6e6577ac011c50657262696c6c0019048c536565205b6050616c6c65743a3a7365745f6d696e5f636f6d6d697373696f6e605d2e587061796f75745f7374616b6572735f62795f706167650c013c76616c696461746f725f7374617368000130543a3a4163636f756e74496400010c657261100120457261496e6465780001107061676510011050616765001a049c536565205b6050616c6c65743a3a7061796f75745f7374616b6572735f62795f70616765605d2e307570646174655f7061796565040128636f6e74726f6c6c6572000130543a3a4163636f756e744964001b0474536565205b6050616c6c65743a3a7570646174655f7061796565605d2e686465707265636174655f636f6e74726f6c6c65725f626174636804012c636f6e74726f6c6c6572731d0201f4426f756e6465645665633c543a3a4163636f756e7449642c20543a3a4d6178436f6e74726f6c6c657273496e4465707265636174696f6e42617463683e001c04ac536565205b6050616c6c65743a3a6465707265636174655f636f6e74726f6c6c65725f6261746368605d2e38726573746f72655f6c65646765721001147374617368000130543a3a4163636f756e7449640001406d617962655f636f6e74726f6c6c6572210201504f7074696f6e3c543a3a4163636f756e7449643e00012c6d617962655f746f74616c250201504f7074696f6e3c42616c616e63654f663c543e3e00013c6d617962655f756e6c6f636b696e6729020115014f7074696f6e3c426f756e6465645665633c556e6c6f636b4368756e6b3c42616c616e63654f663c543e3e2c20543a3a4d6178556e6c6f636b696e674368756e6b730a3e3e001d047c536565205b6050616c6c65743a3a726573746f72655f6c6564676572605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e0102000002e9010005020c3473705f61726974686d65746963287065725f7468696e67731c50657263656e740000040008010875380000090200000210000d02103870616c6c65745f7374616b696e671870616c6c65741870616c6c657420436f6e6669674f700404540118010c104e6f6f700000000c5365740400180104540001001852656d6f7665000200001102103870616c6c65745f7374616b696e671870616c6c65741870616c6c657420436f6e6669674f700404540110010c104e6f6f700000000c5365740400100104540001001852656d6f7665000200001502103870616c6c65745f7374616b696e671870616c6c65741870616c6c657420436f6e6669674f70040454010502010c104e6f6f700000000c536574040005020104540001001852656d6f7665000200001902103870616c6c65745f7374616b696e671870616c6c65741870616c6c657420436f6e6669674f7004045401ac010c104e6f6f700000000c5365740400ac0104540001001852656d6f7665000200001d020c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540100045300000400f50101185665633c543e0000210204184f7074696f6e04045401000108104e6f6e6500000010536f6d650400000000010000250204184f7074696f6e04045401180108104e6f6e6500000010536f6d650400180000010000290204184f7074696f6e040454012d020108104e6f6e6500000010536f6d6504002d0200000100002d020c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e646564566563080454013102045300000400350201185665633c543e00003102083870616c6c65745f7374616b696e672c556e6c6f636b4368756e6b041c42616c616e636501180008011476616c7565f4011c42616c616e636500010c65726115010120457261496e6465780000350200000231020039020c3870616c6c65745f73657373696f6e1870616c6c65741043616c6c040454000108207365745f6b6579730801106b6579733d02011c543a3a4b65797300011470726f6f6634011c5665633c75383e00000464536565205b6050616c6c65743a3a7365745f6b657973605d2e2870757267655f6b6579730001046c536565205b6050616c6c65743a3a70757267655f6b657973605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e3d020840706f6c6b61646f745f72756e74696d652c53657373696f6e4b657973000018011c6772616e647061d401d03c4772616e647061206173202463726174653a3a426f756e64546f52756e74696d654170705075626c69633e3a3a5075626c696300011062616265c90101c43c42616265206173202463726174653a3a426f756e64546f52756e74696d654170705075626c69633e3a3a5075626c6963000138706172615f76616c696461746f72410201e03c496e697469616c697a6572206173202463726174653a3a426f756e64546f52756e74696d654170705075626c69633e3a3a5075626c696300013c706172615f61737369676e6d656e74450201f03c5061726153657373696f6e496e666f206173202463726174653a3a426f756e64546f52756e74696d654170705075626c69633e3a3a5075626c696300014c617574686f726974795f646973636f76657279490201fc3c417574686f72697479446973636f76657279206173202463726174653a3a426f756e64546f52756e74696d654170705075626c69633e3a3a5075626c696300011462656566794d0201c83c4265656679206173202463726174653a3a426f756e64546f52756e74696d654170705075626c69633e3a3a5075626c696300004102104c706f6c6b61646f745f7072696d6974697665730876363476616c696461746f725f617070185075626c696300000400e4013c737232353531393a3a5075626c696300004502104c706f6c6b61646f745f7072696d6974697665730876363861737369676e6d656e745f617070185075626c696300000400e4013c737232353531393a3a5075626c6963000049020c5873705f617574686f726974795f646973636f766572790c617070185075626c696300000400e4013c737232353531393a3a5075626c696300004d020c4873705f636f6e73656e7375735f62656566793065636473615f63727970746f185075626c6963000004005102013465636473613a3a5075626c6963000051020c1c73705f636f7265146563647361185075626c696300000400550201805b75383b205055424c49435f4b45595f53455249414c495a45445f53495a455d0000550200000321000000080059020c3870616c6c65745f6772616e6470611870616c6c65741043616c6c04045400010c4c7265706f72745f65717569766f636174696f6e08014865717569766f636174696f6e5f70726f6f665d0201c8426f783c45717569766f636174696f6e50726f6f663c543a3a486173682c20426c6f636b4e756d626572466f723c543e3e3e00013c6b65795f6f776e65725f70726f6f66d1010140543a3a4b65794f776e657250726f6f6600000490536565205b6050616c6c65743a3a7265706f72745f65717569766f636174696f6e605d2e707265706f72745f65717569766f636174696f6e5f756e7369676e656408014865717569766f636174696f6e5f70726f6f665d0201c8426f783c45717569766f636174696f6e50726f6f663c543a3a486173682c20426c6f636b4e756d626572466f723c543e3e3e00013c6b65795f6f776e65725f70726f6f66d1010140543a3a4b65794f776e657250726f6f66000104b4536565205b6050616c6c65743a3a7265706f72745f65717569766f636174696f6e5f756e7369676e6564605d2e306e6f74655f7374616c6c656408011464656c6179100144426c6f636b4e756d626572466f723c543e00016c626573745f66696e616c697a65645f626c6f636b5f6e756d626572100144426c6f636b4e756d626572466f723c543e00020474536565205b6050616c6c65743a3a6e6f74655f7374616c6c6564605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e5d02085073705f636f6e73656e7375735f6772616e6470614445717569766f636174696f6e50726f6f660804480130044e0110000801187365745f69642c0114536574496400013065717569766f636174696f6e6102014845717569766f636174696f6e3c482c204e3e00006102085073705f636f6e73656e7375735f6772616e6470613045717569766f636174696f6e0804480130044e011001081c507265766f7465040065020139016772616e6470613a3a45717569766f636174696f6e3c417574686f7269747949642c206772616e6470613a3a507265766f74653c482c204e3e2c0a417574686f726974795369676e61747572653e00000024507265636f6d6d697404007d020141016772616e6470613a3a45717569766f636174696f6e3c417574686f7269747949642c206772616e6470613a3a507265636f6d6d69743c482c204e3e2c0a417574686f726974795369676e61747572653e000100006502084066696e616c6974795f6772616e6470613045717569766f636174696f6e0c08496401d404560169020453016d0200100130726f756e645f6e756d6265722c010c7536340001206964656e74697479d40108496400011466697273747902011828562c2053290001187365636f6e647902011828562c20532900006902084066696e616c6974795f6772616e6470611c507265766f74650804480130044e01100008012c7461726765745f68617368300104480001347461726765745f6e756d6265721001044e00006d020c5073705f636f6e73656e7375735f6772616e6470610c617070245369676e61747572650000040071020148656432353531393a3a5369676e6174757265000071020c1c73705f636f72651c65643235353139245369676e617475726500000400750201205b75383b2036345d0000750200000340000000080079020000040869026d02007d02084066696e616c6974795f6772616e6470613045717569766f636174696f6e0c08496401d404560181020453016d0200100130726f756e645f6e756d6265722c010c7536340001206964656e74697479d40108496400011466697273748502011828562c2053290001187365636f6e648502011828562c20532900008102084066696e616c6974795f6772616e64706124507265636f6d6d69740804480130044e01100008012c7461726765745f68617368300104480001347461726765745f6e756d6265721001044e000085020000040881026d020089020c3c70616c6c65745f74726561737572791870616c6c65741043616c6c0804540004490001243470726f706f73655f7370656e6408011476616c7565f4013c42616c616e63654f663c542c20493e00012c62656e6566696369617279e90101504163636f756e7449644c6f6f6b75704f663c543e00000478536565205b6050616c6c65743a3a70726f706f73655f7370656e64605d2e3c72656a6563745f70726f706f73616c04012c70726f706f73616c5f69641501013450726f706f73616c496e64657800010480536565205b6050616c6c65743a3a72656a6563745f70726f706f73616c605d2e40617070726f76655f70726f706f73616c04012c70726f706f73616c5f69641501013450726f706f73616c496e64657800020484536565205b6050616c6c65743a3a617070726f76655f70726f706f73616c605d2e2c7370656e645f6c6f63616c080118616d6f756e74f4013c42616c616e63654f663c542c20493e00012c62656e6566696369617279e90101504163636f756e7449644c6f6f6b75704f663c543e00030470536565205b6050616c6c65743a3a7370656e645f6c6f63616c605d2e3c72656d6f76655f617070726f76616c04012c70726f706f73616c5f69641501013450726f706f73616c496e64657800040480536565205b6050616c6c65743a3a72656d6f76655f617070726f76616c605d2e147370656e6410012861737365745f6b696e6405010144426f783c543a3a41737365744b696e643e000118616d6f756e74f40150417373657442616c616e63654f663c542c20493e00012c62656e656669636961727969010178426f783c42656e65666963696172794c6f6f6b75704f663c542c20493e3e00012876616c69645f66726f6d8d0201644f7074696f6e3c426c6f636b4e756d626572466f723c543e3e00050458536565205b6050616c6c65743a3a7370656e64605d2e187061796f7574040114696e6465781001285370656e64496e6465780006045c536565205b6050616c6c65743a3a7061796f7574605d2e30636865636b5f737461747573040114696e6465781001285370656e64496e64657800070474536565205b6050616c6c65743a3a636865636b5f737461747573605d2e28766f69645f7370656e64040114696e6465781001285370656e64496e6465780008046c536565205b6050616c6c65743a3a766f69645f7370656e64605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e8d0204184f7074696f6e04045401100108104e6f6e6500000010536f6d65040010000001000091020c6070616c6c65745f636f6e76696374696f6e5f766f74696e671870616c6c65741043616c6c08045400044900011810766f7465080128706f6c6c5f696e64657815010144506f6c6c496e6465784f663c542c20493e000110766f7465950201704163636f756e74566f74653c42616c616e63654f663c542c20493e3e00000454536565205b6050616c6c65743a3a766f7465605d2e2064656c6567617465100114636c61737391010134436c6173734f663c542c20493e000108746fe90101504163636f756e7449644c6f6f6b75704f663c543e000128636f6e76696374696f6e9d020128436f6e76696374696f6e00011c62616c616e636518013c42616c616e63654f663c542c20493e00010464536565205b6050616c6c65743a3a64656c6567617465605d2e28756e64656c6567617465040114636c61737391010134436c6173734f663c542c20493e0002046c536565205b6050616c6c65743a3a756e64656c6567617465605d2e18756e6c6f636b080114636c61737391010134436c6173734f663c542c20493e000118746172676574e90101504163636f756e7449644c6f6f6b75704f663c543e0003045c536565205b6050616c6c65743a3a756e6c6f636b605d2e2c72656d6f76655f766f7465080114636c617373a10201544f7074696f6e3c436c6173734f663c542c20493e3e000114696e646578100144506f6c6c496e6465784f663c542c20493e00040470536565205b6050616c6c65743a3a72656d6f76655f766f7465605d2e4472656d6f76655f6f746865725f766f74650c0118746172676574e90101504163636f756e7449644c6f6f6b75704f663c543e000114636c61737391010134436c6173734f663c542c20493e000114696e646578100144506f6c6c496e6465784f663c542c20493e00050488536565205b6050616c6c65743a3a72656d6f76655f6f746865725f766f7465605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e95020c6070616c6c65745f636f6e76696374696f6e5f766f74696e6710766f74652c4163636f756e74566f7465041c42616c616e63650118010c205374616e64617264080110766f746599020110566f746500011c62616c616e636518011c42616c616e63650000001453706c697408010c61796518011c42616c616e636500010c6e617918011c42616c616e63650001003053706c69744162737461696e0c010c61796518011c42616c616e636500010c6e617918011c42616c616e636500011c6162737461696e18011c42616c616e63650002000099020c6070616c6c65745f636f6e76696374696f6e5f766f74696e6710766f746510566f746500000400080000009d020c6070616c6c65745f636f6e76696374696f6e5f766f74696e6728636f6e76696374696f6e28436f6e76696374696f6e00011c104e6f6e65000000204c6f636b65643178000100204c6f636b65643278000200204c6f636b65643378000300204c6f636b65643478000400204c6f636b65643578000500204c6f636b6564367800060000a10204184f7074696f6e0404540191010108104e6f6e6500000010536f6d65040091010000010000a5020c4070616c6c65745f7265666572656e64611870616c6c65741043616c6c080454000449000124187375626d69740c013c70726f706f73616c5f6f726967696ea902015c426f783c50616c6c6574734f726967696e4f663c543e3e00012070726f706f73616c9501014c426f756e64656443616c6c4f663c542c20493e000140656e6163746d656e745f6d6f6d656e74c502017c446973706174636854696d653c426c6f636b4e756d626572466f723c543e3e0000045c536565205b6050616c6c65743a3a7375626d6974605d2e58706c6163655f6465636973696f6e5f6465706f736974040114696e64657810013c5265666572656e64756d496e6465780001049c536565205b6050616c6c65743a3a706c6163655f6465636973696f6e5f6465706f736974605d2e5c726566756e645f6465636973696f6e5f6465706f736974040114696e64657810013c5265666572656e64756d496e646578000204a0536565205b6050616c6c65743a3a726566756e645f6465636973696f6e5f6465706f736974605d2e1863616e63656c040114696e64657810013c5265666572656e64756d496e6465780003045c536565205b6050616c6c65743a3a63616e63656c605d2e106b696c6c040114696e64657810013c5265666572656e64756d496e64657800040454536565205b6050616c6c65743a3a6b696c6c605d2e406e756467655f7265666572656e64756d040114696e64657810013c5265666572656e64756d496e64657800050484536565205b6050616c6c65743a3a6e756467655f7265666572656e64756d605d2e486f6e655f66657765725f6465636964696e67040114747261636b9101013c547261636b49644f663c542c20493e0006048c536565205b6050616c6c65743a3a6f6e655f66657765725f6465636964696e67605d2e64726566756e645f7375626d697373696f6e5f6465706f736974040114696e64657810013c5265666572656e64756d496e646578000704a8536565205b6050616c6c65743a3a726566756e645f7375626d697373696f6e5f6465706f736974605d2e307365745f6d65746164617461080114696e64657810013c5265666572656e64756d496e6465780001286d617962655f68617368c902013c4f7074696f6e3c543a3a486173683e00080474536565205b6050616c6c65743a3a7365745f6d65746164617461605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ea9020840706f6c6b61646f745f72756e74696d65304f726967696e43616c6c65720001141873797374656d0400ad0201746672616d655f73797374656d3a3a4f726967696e3c52756e74696d653e0000001c4f726967696e730400b102017470616c6c65745f637573746f6d5f6f726967696e733a3a4f726967696e0016004050617261636861696e734f726967696e0400b502016470617261636861696e735f6f726967696e3a3a4f726967696e0032002458636d50616c6c65740400bd02014870616c6c65745f78636d3a3a4f726967696e00630010566f69640400c10201410173656c663a3a73705f6170695f68696464656e5f696e636c756465735f636f6e7374727563745f72756e74696d653a3a68696464656e5f696e636c7564653a3a0a5f5f707269766174653a3a566f696400040000ad020c346672616d655f737570706f7274206469737061746368245261774f726967696e04244163636f756e7449640100010c10526f6f74000000185369676e656404000001244163636f756e744964000100104e6f6e6500020000b1021440706f6c6b61646f745f72756e74696d6528676f7665726e616e63651c6f726967696e735470616c6c65745f637573746f6d5f6f726967696e73184f726967696e00013c305374616b696e6741646d696e000000245472656173757265720001003c46656c6c6f777368697041646d696e0002003047656e6572616c41646d696e0003003041756374696f6e41646d696e000400284c6561736541646d696e0005004c5265666572656e64756d43616e63656c6c6572000600405265666572656e64756d4b696c6c65720007002c536d616c6c5469707065720008002442696754697070657200090030536d616c6c5370656e646572000a00344d656469756d5370656e646572000b00284269675370656e646572000c004457686974656c697374656443616c6c6572000d003457697368466f724368616e6765000e0000b502106c706f6c6b61646f745f72756e74696d655f70617261636861696e73186f726967696e1870616c6c6574184f726967696e0001042450617261636861696e0400b902011850617261496400000000b9020c74706f6c6b61646f745f70617261636861696e5f7072696d697469766573287072696d6974697665730849640000040010010c7533320000bd020c2870616c6c65745f78636d1870616c6c6574184f726967696e0001080c58636d0400310101204c6f636174696f6e00000020526573706f6e73650400310101204c6f636174696f6e00010000c102081c73705f636f726510566f696400010000c50210346672616d655f737570706f727418747261697473207363686564756c6530446973706174636854696d65042c426c6f636b4e756d62657201100108084174040010012c426c6f636b4e756d626572000000144166746572040010012c426c6f636b4e756d62657200010000c90204184f7074696f6e04045401300108104e6f6e6500000010536f6d650400300000010000cd020c4070616c6c65745f77686974656c6973741870616c6c65741043616c6c0404540001103877686974656c6973745f63616c6c04012463616c6c5f6861736830011c543a3a486173680000047c536565205b6050616c6c65743a3a77686974656c6973745f63616c6c605d2e5c72656d6f76655f77686974656c69737465645f63616c6c04012463616c6c5f6861736830011c543a3a48617368000104a0536565205b6050616c6c65743a3a72656d6f76655f77686974656c69737465645f63616c6c605d2e6464697370617463685f77686974656c69737465645f63616c6c0c012463616c6c5f6861736830011c543a3a4861736800014063616c6c5f656e636f6465645f6c656e10010c75333200014c63616c6c5f7765696768745f7769746e657373240118576569676874000204a8536565205b6050616c6c65743a3a64697370617463685f77686974656c69737465645f63616c6c605d2e9c64697370617463685f77686974656c69737465645f63616c6c5f776974685f707265696d61676504011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e000304e0536565205b6050616c6c65743a3a64697370617463685f77686974656c69737465645f63616c6c5f776974685f707265696d616765605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ed102105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e18636c61696d731870616c6c65741043616c6c04045400011414636c61696d08011064657374000130543a3a4163636f756e744964000148657468657265756d5f7369676e6174757265d502013845636473615369676e617475726500000458536565205b6050616c6c65743a3a636c61696d605d2e286d696e745f636c61696d10010c77686fdd02013c457468657265756d4164647265737300011476616c756518013042616c616e63654f663c543e00014076657374696e675f7363686564756c65e10201dc4f7074696f6e3c2842616c616e63654f663c543e2c2042616c616e63654f663c543e2c20426c6f636b4e756d626572466f723c543e293e00012473746174656d656e74e90201544f7074696f6e3c53746174656d656e744b696e643e0001046c536565205b6050616c6c65743a3a6d696e745f636c61696d605d2e30636c61696d5f6174746573740c011064657374000130543a3a4163636f756e744964000148657468657265756d5f7369676e6174757265d502013845636473615369676e617475726500012473746174656d656e7434011c5665633c75383e00020474536565205b6050616c6c65743a3a636c61696d5f617474657374605d2e1861747465737404012473746174656d656e7434011c5665633c75383e0003045c536565205b6050616c6c65743a3a617474657374605d2e286d6f76655f636c61696d0c010c6f6c64dd02013c457468657265756d4164647265737300010c6e6577dd02013c457468657265756d416464726573730001386d617962655f707265636c61696d210201504f7074696f6e3c543a3a4163636f756e7449643e0004046c536565205b6050616c6c65743a3a6d6f76655f636c61696d605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ed5020c5c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e18636c61696d733845636473615369676e617475726500000400d90201205b75383b2036355d0000d902000003410000000800dd020c5c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e18636c61696d733c457468657265756d4164647265737300000400210101205b75383b2032305d0000e10204184f7074696f6e04045401e5020108104e6f6e6500000010536f6d650400e5020000010000e5020000040c18181000e90204184f7074696f6e04045401ed020108104e6f6e6500000010536f6d650400ed020000010000ed020c5c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e18636c61696d733453746174656d656e744b696e640001081c526567756c6172000000105361667400010000f1020c3870616c6c65745f76657374696e671870616c6c65741043616c6c040454000118107665737400000454536565205b6050616c6c65743a3a76657374605d2e28766573745f6f74686572040118746172676574e90101504163636f756e7449644c6f6f6b75704f663c543e0001046c536565205b6050616c6c65743a3a766573745f6f74686572605d2e3c7665737465645f7472616e73666572080118746172676574e90101504163636f756e7449644c6f6f6b75704f663c543e0001207363686564756c65f50201b056657374696e67496e666f3c42616c616e63654f663c543e2c20426c6f636b4e756d626572466f723c543e3e00020480536565205b6050616c6c65743a3a7665737465645f7472616e73666572605d2e54666f7263655f7665737465645f7472616e736665720c0118736f75726365e90101504163636f756e7449644c6f6f6b75704f663c543e000118746172676574e90101504163636f756e7449644c6f6f6b75704f663c543e0001207363686564756c65f50201b056657374696e67496e666f3c42616c616e63654f663c543e2c20426c6f636b4e756d626572466f723c543e3e00030498536565205b6050616c6c65743a3a666f7263655f7665737465645f7472616e73666572605d2e3c6d657267655f7363686564756c657308013c7363686564756c65315f696e64657810010c75333200013c7363686564756c65325f696e64657810010c75333200040480536565205b6050616c6c65743a3a6d657267655f7363686564756c6573605d2e74666f7263655f72656d6f76655f76657374696e675f7363686564756c65080118746172676574e901018c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263650001387363686564756c655f696e64657810010c753332000504b8536565205b6050616c6c65743a3a666f7263655f72656d6f76655f76657374696e675f7363686564756c65605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ef5020c3870616c6c65745f76657374696e673076657374696e675f696e666f2c56657374696e67496e666f081c42616c616e636501182c426c6f636b4e756d6265720110000c01186c6f636b656418011c42616c616e63650001247065725f626c6f636b18011c42616c616e63650001387374617274696e675f626c6f636b10012c426c6f636b4e756d6265720000f9020c3870616c6c65745f7574696c6974791870616c6c65741043616c6c04045400011814626174636804011463616c6c73fd02017c5665633c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00000458536565205b6050616c6c65743a3a6261746368605d2e3461735f64657269766174697665080114696e6465789101010c75313600011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00010478536565205b6050616c6c65743a3a61735f64657269766174697665605d2e2462617463685f616c6c04011463616c6c73fd02017c5665633c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00020468536565205b6050616c6c65743a3a62617463685f616c6c605d2e2c64697370617463685f617308012461735f6f726967696ea9020154426f783c543a3a50616c6c6574734f726967696e3e00011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00030470536565205b6050616c6c65743a3a64697370617463685f6173605d2e2c666f7263655f626174636804011463616c6c73fd02017c5665633c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00040470536565205b6050616c6c65743a3a666f7263655f6261746368605d2e2c776974685f77656967687408011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00011877656967687424011857656967687400050470536565205b6050616c6c65743a3a776974685f776569676874605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732efd0200000299010001030c3c70616c6c65745f6964656e746974791870616c6c65741043616c6c040454000158346164645f72656769737472617204011c6163636f756e74e90101504163636f756e7449644c6f6f6b75704f663c543e00000478536565205b6050616c6c65743a3a6164645f726567697374726172605d2e307365745f6964656e74697479040110696e666f0503016c426f783c543a3a4964656e74697479496e666f726d6174696f6e3e00010474536565205b6050616c6c65743a3a7365745f6964656e74697479605d2e207365745f7375627304011073756273910301645665633c28543a3a4163636f756e7449642c2044617461293e00020464536565205b6050616c6c65743a3a7365745f73756273605d2e38636c6561725f6964656e746974790003047c536565205b6050616c6c65743a3a636c6561725f6964656e74697479605d2e44726571756573745f6a756467656d656e740801247265675f696e64657815010138526567697374726172496e64657800011c6d61785f666565f4013042616c616e63654f663c543e00040488536565205b6050616c6c65743a3a726571756573745f6a756467656d656e74605d2e3863616e63656c5f726571756573740401247265675f696e646578100138526567697374726172496e6465780005047c536565205b6050616c6c65743a3a63616e63656c5f72657175657374605d2e1c7365745f666565080114696e64657815010138526567697374726172496e64657800010c666565f4013042616c616e63654f663c543e00060460536565205b6050616c6c65743a3a7365745f666565605d2e387365745f6163636f756e745f6964080114696e64657815010138526567697374726172496e64657800010c6e6577e90101504163636f756e7449644c6f6f6b75704f663c543e0007047c536565205b6050616c6c65743a3a7365745f6163636f756e745f6964605d2e287365745f6669656c6473080114696e64657815010138526567697374726172496e6465780001186669656c64732c0129013c543a3a4964656e74697479496e666f726d6174696f6e206173204964656e74697479496e666f726d6174696f6e50726f76696465723e3a3a0a4669656c64734964656e7469666965720008046c536565205b6050616c6c65743a3a7365745f6669656c6473605d2e4470726f766964655f6a756467656d656e741001247265675f696e64657815010138526567697374726172496e646578000118746172676574e90101504163636f756e7449644c6f6f6b75704f663c543e0001246a756467656d656e749903015c4a756467656d656e743c42616c616e63654f663c543e3e0001206964656e7469747930011c543a3a4861736800090488536565205b6050616c6c65743a3a70726f766964655f6a756467656d656e74605d2e346b696c6c5f6964656e74697479040118746172676574e90101504163636f756e7449644c6f6f6b75704f663c543e000a0478536565205b6050616c6c65743a3a6b696c6c5f6964656e74697479605d2e1c6164645f73756208010c737562e90101504163636f756e7449644c6f6f6b75704f663c543e000110646174611103011044617461000b0460536565205b6050616c6c65743a3a6164645f737562605d2e2872656e616d655f73756208010c737562e90101504163636f756e7449644c6f6f6b75704f663c543e000110646174611103011044617461000c046c536565205b6050616c6c65743a3a72656e616d655f737562605d2e2872656d6f76655f73756204010c737562e90101504163636f756e7449644c6f6f6b75704f663c543e000d046c536565205b6050616c6c65743a3a72656d6f76655f737562605d2e20717569745f737562000e0464536565205b6050616c6c65743a3a717569745f737562605d2e586164645f757365726e616d655f617574686f726974790c0124617574686f72697479e90101504163636f756e7449644c6f6f6b75704f663c543e00011873756666697834011c5665633c75383e000128616c6c6f636174696f6e10010c753332000f049c536565205b6050616c6c65743a3a6164645f757365726e616d655f617574686f72697479605d2e6472656d6f76655f757365726e616d655f617574686f72697479040124617574686f72697479e90101504163636f756e7449644c6f6f6b75704f663c543e001004a8536565205b6050616c6c65743a3a72656d6f76655f757365726e616d655f617574686f72697479605d2e407365745f757365726e616d655f666f720c010c77686fe90101504163636f756e7449644c6f6f6b75704f663c543e000120757365726e616d6534011c5665633c75383e0001247369676e61747572659d0301704f7074696f6e3c543a3a4f6666636861696e5369676e61747572653e00110484536565205b6050616c6c65743a3a7365745f757365726e616d655f666f72605d2e3c6163636570745f757365726e616d65040120757365726e616d65ad03012c557365726e616d653c543e00120480536565205b6050616c6c65743a3a6163636570745f757365726e616d65605d2e5c72656d6f76655f657870697265645f617070726f76616c040120757365726e616d65ad03012c557365726e616d653c543e001304a0536565205b6050616c6c65743a3a72656d6f76655f657870697265645f617070726f76616c605d2e507365745f7072696d6172795f757365726e616d65040120757365726e616d65ad03012c557365726e616d653c543e00140494536565205b6050616c6c65743a3a7365745f7072696d6172795f757365726e616d65605d2e6072656d6f76655f64616e676c696e675f757365726e616d65040120757365726e616d65ad03012c557365726e616d653c543e001504a4536565205b6050616c6c65743a3a72656d6f76655f64616e676c696e675f757365726e616d65605d2e04704964656e746974792070616c6c6574206465636c61726174696f6e2e05030c3c70616c6c65745f6964656e74697479186c6567616379304964656e74697479496e666f04284669656c644c696d697400002401286164646974696f6e616c09030190426f756e6465645665633c28446174612c2044617461292c204669656c644c696d69743e00011c646973706c617911030110446174610001146c6567616c110301104461746100010c776562110301104461746100011072696f741103011044617461000114656d61696c110301104461746100013c7067705f66696e6765727072696e748d0301404f7074696f6e3c5b75383b2032305d3e000114696d616765110301104461746100011c747769747465721103011044617461000009030c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e646564566563080454010d03045300000400890301185665633c543e00000d0300000408110311030011030c3c70616c6c65745f6964656e746974791474797065731044617461000198104e6f6e650000001052617730040015030000010010526177310400190300000200105261773204001d0300000300105261773304002103000004001052617734040044000005001052617735040025030000060010526177360400290300000700105261773704002d03000008001052617738040031030000090010526177390400350300000a001452617731300400390300000b0014526177313104003d0300000c001452617731320400410300000d001452617731330400450300000e001452617731340400490300000f0014526177313504004d03000010001452617731360400c000001100145261773137040051030000120014526177313804005503000013001452617731390400590300001400145261773230040021010000150014526177323104005d030000160014526177323204006103000017001452617732330400650300001800145261773234040069030000190014526177323504006d0300001a001452617732360400710300001b001452617732370400750300001c001452617732380400790300001d0014526177323904007d0300001e001452617733300400810300001f001452617733310400850300002000145261773332040004000021002c426c616b6554776f323536040004000022001853686132353604000400002300244b656363616b323536040004000024002c53686154687265653235360400040000250000150300000300000000080019030000030100000008001d030000030200000008002103000003030000000800250300000305000000080029030000030600000008002d030000030700000008003103000003080000000800350300000309000000080039030000030a00000008003d030000030b000000080041030000030c000000080045030000030d000000080049030000030e00000008004d030000030f00000008005103000003110000000800550300000312000000080059030000031300000008005d030000031500000008006103000003160000000800650300000317000000080069030000031800000008006d0300000319000000080071030000031a000000080075030000031b000000080079030000031c00000008007d030000031d000000080081030000031e000000080085030000031f000000080089030000020d03008d0304184f7074696f6e0404540121010108104e6f6e6500000010536f6d6504002101000001000091030000029503009503000004080011030099030c3c70616c6c65745f6964656e74697479147479706573244a756467656d656e74041c42616c616e63650118011c1c556e6b6e6f776e0000001c46656550616964040018011c42616c616e636500010028526561736f6e61626c65000200244b6e6f776e476f6f64000300244f75744f6644617465000400284c6f775175616c697479000500244572726f6e656f7573000600009d0304184f7074696f6e04045401a1030108104e6f6e6500000010536f6d650400a1030000010000a103082873705f72756e74696d65384d756c74695369676e617475726500010c1c45643235353139040071020148656432353531393a3a5369676e61747572650000001c537232353531390400a5030148737232353531393a3a5369676e61747572650001001445636473610400a903014065636473613a3a5369676e617475726500020000a5030c1c73705f636f72651c73723235353139245369676e617475726500000400750201205b75383b2036345d0000a9030c1c73705f636f7265146563647361245369676e617475726500000400d902017c5b75383b205349474e41545552455f53455249414c495a45445f53495a455d0000ad030c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e0000b1030c3070616c6c65745f70726f78791870616c6c65741043616c6c0404540001281470726f78790c01107265616ce90101504163636f756e7449644c6f6f6b75704f663c543e000140666f7263655f70726f78795f74797065b50301504f7074696f6e3c543a3a50726f7879547970653e00011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00000458536565205b6050616c6c65743a3a70726f7879605d2e246164645f70726f78790c012064656c6567617465e90101504163636f756e7449644c6f6f6b75704f663c543e00012870726f78795f74797065b9030130543a3a50726f78795479706500011464656c6179100144426c6f636b4e756d626572466f723c543e00010468536565205b6050616c6c65743a3a6164645f70726f7879605d2e3072656d6f76655f70726f78790c012064656c6567617465e90101504163636f756e7449644c6f6f6b75704f663c543e00012870726f78795f74797065b9030130543a3a50726f78795479706500011464656c6179100144426c6f636b4e756d626572466f723c543e00020474536565205b6050616c6c65743a3a72656d6f76655f70726f7879605d2e3872656d6f76655f70726f786965730003047c536565205b6050616c6c65743a3a72656d6f76655f70726f78696573605d2e2c6372656174655f707572650c012870726f78795f74797065b9030130543a3a50726f78795479706500011464656c6179100144426c6f636b4e756d626572466f723c543e000114696e6465789101010c75313600040470536565205b6050616c6c65743a3a6372656174655f70757265605d2e246b696c6c5f7075726514011c737061776e6572e90101504163636f756e7449644c6f6f6b75704f663c543e00012870726f78795f74797065b9030130543a3a50726f787954797065000114696e6465789101010c75313600011868656967687415010144426c6f636b4e756d626572466f723c543e0001246578745f696e6465781501010c75333200050468536565205b6050616c6c65743a3a6b696c6c5f70757265605d2e20616e6e6f756e63650801107265616ce90101504163636f756e7449644c6f6f6b75704f663c543e00012463616c6c5f6861736830013443616c6c486173684f663c543e00060464536565205b6050616c6c65743a3a616e6e6f756e6365605d2e4c72656d6f76655f616e6e6f756e63656d656e740801107265616ce90101504163636f756e7449644c6f6f6b75704f663c543e00012463616c6c5f6861736830013443616c6c486173684f663c543e00070490536565205b6050616c6c65743a3a72656d6f76655f616e6e6f756e63656d656e74605d2e4c72656a6563745f616e6e6f756e63656d656e7408012064656c6567617465e90101504163636f756e7449644c6f6f6b75704f663c543e00012463616c6c5f6861736830013443616c6c486173684f663c543e00080490536565205b6050616c6c65743a3a72656a6563745f616e6e6f756e63656d656e74605d2e3c70726f78795f616e6e6f756e63656410012064656c6567617465e90101504163636f756e7449644c6f6f6b75704f663c543e0001107265616ce90101504163636f756e7449644c6f6f6b75704f663c543e000140666f7263655f70726f78795f74797065b50301504f7074696f6e3c543a3a50726f7879547970653e00011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00090480536565205b6050616c6c65743a3a70726f78795f616e6e6f756e636564605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732eb50304184f7074696f6e04045401b9030108104e6f6e6500000010536f6d650400b9030000010000b9030840706f6c6b61646f745f72756e74696d652450726f7879547970650001200c416e790000002c4e6f6e5472616e7366657200010028476f7665726e616e63650002001c5374616b696e67000300444964656e746974794a756467656d656e740005002c43616e63656c50726f78790006001c41756374696f6e0007003c4e6f6d696e6174696f6e506f6f6c7300080000bd030c3c70616c6c65745f6d756c74697369671870616c6c65741043616c6c0404540001105061735f6d756c74695f7468726573686f6c645f310801446f746865725f7369676e61746f72696573f50101445665633c543a3a4163636f756e7449643e00011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e00000494536565205b6050616c6c65743a3a61735f6d756c74695f7468726573686f6c645f31605d2e2061735f6d756c74691401247468726573686f6c649101010c7531360001446f746865725f7369676e61746f72696573f50101445665633c543a3a4163636f756e7449643e00013c6d617962655f74696d65706f696e74c10301904f7074696f6e3c54696d65706f696e743c426c6f636b4e756d626572466f723c543e3e3e00011063616c6c9901017c426f783c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e0001286d61785f77656967687424011857656967687400010464536565205b6050616c6c65743a3a61735f6d756c7469605d2e40617070726f76655f61735f6d756c74691401247468726573686f6c649101010c7531360001446f746865725f7369676e61746f72696573f50101445665633c543a3a4163636f756e7449643e00013c6d617962655f74696d65706f696e74c10301904f7074696f6e3c54696d65706f696e743c426c6f636b4e756d626572466f723c543e3e3e00012463616c6c5f686173680401205b75383b2033325d0001286d61785f77656967687424011857656967687400020484536565205b6050616c6c65743a3a617070726f76655f61735f6d756c7469605d2e3c63616e63656c5f61735f6d756c74691001247468726573686f6c649101010c7531360001446f746865725f7369676e61746f72696573f50101445665633c543a3a4163636f756e7449643e00012474696d65706f696e74c503017054696d65706f696e743c426c6f636b4e756d626572466f723c543e3e00012463616c6c5f686173680401205b75383b2033325d00030480536565205b6050616c6c65743a3a63616e63656c5f61735f6d756c7469605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ec10304184f7074696f6e04045401c5030108104e6f6e6500000010536f6d650400c5030000010000c503083c70616c6c65745f6d756c74697369672454696d65706f696e74042c426c6f636b4e756d62657201100008011868656967687410012c426c6f636b4e756d626572000114696e64657810010c7533320000c9030c3c70616c6c65745f626f756e746965731870616c6c65741043616c6c0804540004490001243870726f706f73655f626f756e747908011476616c7565f4013c42616c616e63654f663c542c20493e00012c6465736372697074696f6e34011c5665633c75383e0000047c536565205b6050616c6c65743a3a70726f706f73655f626f756e7479605d2e38617070726f76655f626f756e7479040124626f756e74795f69641501012c426f756e7479496e6465780001047c536565205b6050616c6c65743a3a617070726f76655f626f756e7479605d2e3c70726f706f73655f63757261746f720c0124626f756e74795f69641501012c426f756e7479496e64657800011c63757261746f72e90101504163636f756e7449644c6f6f6b75704f663c543e00010c666565f4013c42616c616e63654f663c542c20493e00020480536565205b6050616c6c65743a3a70726f706f73655f63757261746f72605d2e40756e61737369676e5f63757261746f72040124626f756e74795f69641501012c426f756e7479496e64657800030484536565205b6050616c6c65743a3a756e61737369676e5f63757261746f72605d2e386163636570745f63757261746f72040124626f756e74795f69641501012c426f756e7479496e6465780004047c536565205b6050616c6c65743a3a6163636570745f63757261746f72605d2e3061776172645f626f756e7479080124626f756e74795f69641501012c426f756e7479496e64657800012c62656e6566696369617279e90101504163636f756e7449644c6f6f6b75704f663c543e00050474536565205b6050616c6c65743a3a61776172645f626f756e7479605d2e30636c61696d5f626f756e7479040124626f756e74795f69641501012c426f756e7479496e64657800060474536565205b6050616c6c65743a3a636c61696d5f626f756e7479605d2e30636c6f73655f626f756e7479040124626f756e74795f69641501012c426f756e7479496e64657800070474536565205b6050616c6c65743a3a636c6f73655f626f756e7479605d2e50657874656e645f626f756e74795f657870697279080124626f756e74795f69641501012c426f756e7479496e64657800011872656d61726b34011c5665633c75383e00080494536565205b6050616c6c65743a3a657874656e645f626f756e74795f657870697279605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ecd030c5470616c6c65745f6368696c645f626f756e746965731870616c6c65741043616c6c04045400011c406164645f6368696c645f626f756e74790c0140706172656e745f626f756e74795f69641501012c426f756e7479496e64657800011476616c7565f4013042616c616e63654f663c543e00012c6465736372697074696f6e34011c5665633c75383e00000484536565205b6050616c6c65743a3a6164645f6368696c645f626f756e7479605d2e3c70726f706f73655f63757261746f72100140706172656e745f626f756e74795f69641501012c426f756e7479496e64657800013c6368696c645f626f756e74795f69641501012c426f756e7479496e64657800011c63757261746f72e90101504163636f756e7449644c6f6f6b75704f663c543e00010c666565f4013042616c616e63654f663c543e00010480536565205b6050616c6c65743a3a70726f706f73655f63757261746f72605d2e386163636570745f63757261746f72080140706172656e745f626f756e74795f69641501012c426f756e7479496e64657800013c6368696c645f626f756e74795f69641501012c426f756e7479496e6465780002047c536565205b6050616c6c65743a3a6163636570745f63757261746f72605d2e40756e61737369676e5f63757261746f72080140706172656e745f626f756e74795f69641501012c426f756e7479496e64657800013c6368696c645f626f756e74795f69641501012c426f756e7479496e64657800030484536565205b6050616c6c65743a3a756e61737369676e5f63757261746f72605d2e4861776172645f6368696c645f626f756e74790c0140706172656e745f626f756e74795f69641501012c426f756e7479496e64657800013c6368696c645f626f756e74795f69641501012c426f756e7479496e64657800012c62656e6566696369617279e90101504163636f756e7449644c6f6f6b75704f663c543e0004048c536565205b6050616c6c65743a3a61776172645f6368696c645f626f756e7479605d2e48636c61696d5f6368696c645f626f756e7479080140706172656e745f626f756e74795f69641501012c426f756e7479496e64657800013c6368696c645f626f756e74795f69641501012c426f756e7479496e6465780005048c536565205b6050616c6c65743a3a636c61696d5f6368696c645f626f756e7479605d2e48636c6f73655f6368696c645f626f756e7479080140706172656e745f626f756e74795f69641501012c426f756e7479496e64657800013c6368696c645f626f756e74795f69641501012c426f756e7479496e6465780006048c536565205b6050616c6c65743a3a636c6f73655f6368696c645f626f756e7479605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ed1030c9070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f70686173651870616c6c65741043616c6c0404540001143c7375626d69745f756e7369676e65640801307261775f736f6c7574696f6ed50301b0426f783c526177536f6c7574696f6e3c536f6c7574696f6e4f663c543a3a4d696e6572436f6e6669673e3e3e00011c7769746e657373a9040158536f6c7574696f6e4f72536e617073686f7453697a6500000480536565205b6050616c6c65743a3a7375626d69745f756e7369676e6564605d2e6c7365745f6d696e696d756d5f756e747275737465645f73636f72650401406d617962655f6e6578745f73636f7265ad0401544f7074696f6e3c456c656374696f6e53636f72653e000104b0536565205b6050616c6c65743a3a7365745f6d696e696d756d5f756e747275737465645f73636f7265605d2e747365745f656d657267656e63795f656c656374696f6e5f726573756c74040120737570706f727473b1040158537570706f7274733c543a3a4163636f756e7449643e000204b8536565205b6050616c6c65743a3a7365745f656d657267656e63795f656c656374696f6e5f726573756c74605d2e187375626d69740401307261775f736f6c7574696f6ed50301b0426f783c526177536f6c7574696f6e3c536f6c7574696f6e4f663c543a3a4d696e6572436f6e6669673e3e3e0003045c536565205b6050616c6c65743a3a7375626d6974605d2e4c676f7665726e616e63655f66616c6c6261636b0801406d617962655f6d61785f766f746572738d02012c4f7074696f6e3c7533323e0001446d617962655f6d61785f746172676574738d02012c4f7074696f6e3c7533323e00040490536565205b6050616c6c65743a3a676f7665726e616e63655f66616c6c6261636b605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ed503089070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f70686173652c526177536f6c7574696f6e04045301d903000c0120736f6c7574696f6ed90301045300011473636f7265a5040134456c656374696f6e53636f7265000114726f756e6410010c7533320000d9030840706f6c6b61646f745f72756e74696d65544e706f73436f6d70616374536f6c7574696f6e31360000400118766f74657331dd0300000118766f74657332e90300000118766f74657333fd0300000118766f74657334090400000118766f74657335150400000118766f74657336210400000118766f746573372d0400000118766f74657338390400000118766f7465733945040000011c766f746573313051040000011c766f74657331315d040000011c766f746573313269040000011c766f746573313375040000011c766f746573313481040000011c766f74657331358d040000011c766f74657331369904000000dd03000002e10300e103000004081501e50300e503000006910100e903000002ed0300ed030000040c1501f103e50300f10300000408e503f50300f503000006f90300f9030c3473705f61726974686d65746963287065725f7468696e677318506572553136000004009101010c7531360000fd0300000201040001040000040c15010504e50300050400000302000000f1030009040000020d04000d040000040c15011104e50300110400000303000000f10300150400000219040019040000040c15011d04e503001d0400000304000000f10300210400000225040025040000040c15012904e50300290400000305000000f103002d0400000231040031040000040c15013504e50300350400000306000000f1030039040000023d04003d040000040c15014104e50300410400000307000000f10300450400000249040049040000040c15014d04e503004d0400000308000000f10300510400000255040055040000040c15015904e50300590400000309000000f103005d0400000261040061040000040c15016504e5030065040000030a000000f1030069040000026d04006d040000040c15017104e5030071040000030b000000f10300750400000279040079040000040c15017d04e503007d040000030c000000f10300810400000285040085040000040c15018904e5030089040000030d000000f103008d0400000291040091040000040c15019504e5030095040000030e000000f1030099040000029d04009d040000040c1501a104e50300a1040000030f000000f10300a504084473705f6e706f735f656c656374696f6e7334456c656374696f6e53636f726500000c01346d696e696d616c5f7374616b6518013c457874656e64656442616c616e636500012473756d5f7374616b6518013c457874656e64656442616c616e636500014473756d5f7374616b655f7371756172656418013c457874656e64656442616c616e63650000a904089070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f706861736558536f6c7574696f6e4f72536e617073686f7453697a650000080118766f746572731501010c75333200011c746172676574731501010c7533320000ad0404184f7074696f6e04045401a5040108104e6f6e6500000010536f6d650400a5040000010000b104000002b50400b5040000040800b90400b904084473705f6e706f735f656c656374696f6e731c537570706f727404244163636f756e744964010000080114746f74616c18013c457874656e64656442616c616e6365000118766f74657273bd0401845665633c284163636f756e7449642c20457874656e64656442616c616e6365293e0000bd04000002c10400c10400000408001800c5040c4070616c6c65745f626167735f6c6973741870616c6c65741043616c6c08045400044900010c1472656261670401286469736c6f6361746564e90101504163636f756e7449644c6f6f6b75704f663c543e00000458536565205b6050616c6c65743a3a7265626167605d2e3c7075745f696e5f66726f6e745f6f6604011c6c696768746572e90101504163636f756e7449644c6f6f6b75704f663c543e00010480536565205b6050616c6c65743a3a7075745f696e5f66726f6e745f6f66605d2e547075745f696e5f66726f6e745f6f665f6f7468657208011c68656176696572e90101504163636f756e7449644c6f6f6b75704f663c543e00011c6c696768746572e90101504163636f756e7449644c6f6f6b75704f663c543e00020498536565205b6050616c6c65743a3a7075745f696e5f66726f6e745f6f665f6f74686572605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ec9040c5c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c731870616c6c65741043616c6c04045400015c106a6f696e080118616d6f756e74f4013042616c616e63654f663c543e00011c706f6f6c5f6964100118506f6f6c496400000454536565205b6050616c6c65743a3a6a6f696e605d2e28626f6e645f65787472610401146578747261cd04015c426f6e6445787472613c42616c616e63654f663c543e3e0001046c536565205b6050616c6c65743a3a626f6e645f6578747261605d2e30636c61696d5f7061796f757400020474536565205b6050616c6c65743a3a636c61696d5f7061796f7574605d2e18756e626f6e640801386d656d6265725f6163636f756e74e90101504163636f756e7449644c6f6f6b75704f663c543e000140756e626f6e64696e675f706f696e7473f4013042616c616e63654f663c543e0003045c536565205b6050616c6c65743a3a756e626f6e64605d2e58706f6f6c5f77697468647261775f756e626f6e64656408011c706f6f6c5f6964100118506f6f6c49640001486e756d5f736c617368696e675f7370616e7310010c7533320004049c536565205b6050616c6c65743a3a706f6f6c5f77697468647261775f756e626f6e646564605d2e4477697468647261775f756e626f6e6465640801386d656d6265725f6163636f756e74e90101504163636f756e7449644c6f6f6b75704f663c543e0001486e756d5f736c617368696e675f7370616e7310010c75333200050488536565205b6050616c6c65743a3a77697468647261775f756e626f6e646564605d2e18637265617465100118616d6f756e74f4013042616c616e63654f663c543e000110726f6f74e90101504163636f756e7449644c6f6f6b75704f663c543e0001246e6f6d696e61746f72e90101504163636f756e7449644c6f6f6b75704f663c543e00011c626f756e636572e90101504163636f756e7449644c6f6f6b75704f663c543e0006045c536565205b6050616c6c65743a3a637265617465605d2e4c6372656174655f776974685f706f6f6c5f6964140118616d6f756e74f4013042616c616e63654f663c543e000110726f6f74e90101504163636f756e7449644c6f6f6b75704f663c543e0001246e6f6d696e61746f72e90101504163636f756e7449644c6f6f6b75704f663c543e00011c626f756e636572e90101504163636f756e7449644c6f6f6b75704f663c543e00011c706f6f6c5f6964100118506f6f6c496400070490536565205b6050616c6c65743a3a6372656174655f776974685f706f6f6c5f6964605d2e206e6f6d696e61746508011c706f6f6c5f6964100118506f6f6c496400012876616c696461746f7273f50101445665633c543a3a4163636f756e7449643e00080464536565205b6050616c6c65743a3a6e6f6d696e617465605d2e247365745f737461746508011c706f6f6c5f6964100118506f6f6c49640001147374617465d1040124506f6f6c537461746500090468536565205b6050616c6c65743a3a7365745f7374617465605d2e307365745f6d6574616461746108011c706f6f6c5f6964100118506f6f6c49640001206d6574616461746134011c5665633c75383e000a0474536565205b6050616c6c65743a3a7365745f6d65746164617461605d2e2c7365745f636f6e666967731801346d696e5f6a6f696e5f626f6e64d5040158436f6e6669674f703c42616c616e63654f663c543e3e00013c6d696e5f6372656174655f626f6e64d5040158436f6e6669674f703c42616c616e63654f663c543e3e0001246d61785f706f6f6c73d9040134436f6e6669674f703c7533323e00012c6d61785f6d656d62657273d9040134436f6e6669674f703c7533323e0001506d61785f6d656d626572735f7065725f706f6f6cd9040134436f6e6669674f703c7533323e000154676c6f62616c5f6d61785f636f6d6d697373696f6edd040144436f6e6669674f703c50657262696c6c3e000b0470536565205b6050616c6c65743a3a7365745f636f6e66696773605d2e307570646174655f726f6c657310011c706f6f6c5f6964100118506f6f6c49640001206e65775f726f6f74e1040158436f6e6669674f703c543a3a4163636f756e7449643e0001346e65775f6e6f6d696e61746f72e1040158436f6e6669674f703c543a3a4163636f756e7449643e00012c6e65775f626f756e636572e1040158436f6e6669674f703c543a3a4163636f756e7449643e000c0474536565205b6050616c6c65743a3a7570646174655f726f6c6573605d2e146368696c6c04011c706f6f6c5f6964100118506f6f6c4964000d0458536565205b6050616c6c65743a3a6368696c6c605d2e40626f6e645f65787472615f6f746865720801186d656d626572e90101504163636f756e7449644c6f6f6b75704f663c543e0001146578747261cd04015c426f6e6445787472613c42616c616e63654f663c543e3e000e0484536565205b6050616c6c65743a3a626f6e645f65787472615f6f74686572605d2e507365745f636c61696d5f7065726d697373696f6e0401287065726d697373696f6ee504013c436c61696d5065726d697373696f6e000f0494536565205b6050616c6c65743a3a7365745f636c61696d5f7065726d697373696f6e605d2e48636c61696d5f7061796f75745f6f746865720401146f74686572000130543a3a4163636f756e7449640010048c536565205b6050616c6c65743a3a636c61696d5f7061796f75745f6f74686572605d2e387365745f636f6d6d697373696f6e08011c706f6f6c5f6964100118506f6f6c49640001386e65775f636f6d6d697373696f6ee904017c4f7074696f6e3c2850657262696c6c2c20543a3a4163636f756e744964293e0011047c536565205b6050616c6c65743a3a7365745f636f6d6d697373696f6e605d2e487365745f636f6d6d697373696f6e5f6d617808011c706f6f6c5f6964100118506f6f6c49640001386d61785f636f6d6d697373696f6eac011c50657262696c6c0012048c536565205b6050616c6c65743a3a7365745f636f6d6d697373696f6e5f6d6178605d2e687365745f636f6d6d697373696f6e5f6368616e67655f7261746508011c706f6f6c5f6964100118506f6f6c496400012c6368616e67655f72617465f104019c436f6d6d697373696f6e4368616e6765526174653c426c6f636b4e756d626572466f723c543e3e001304ac536565205b6050616c6c65743a3a7365745f636f6d6d697373696f6e5f6368616e67655f72617465605d2e40636c61696d5f636f6d6d697373696f6e04011c706f6f6c5f6964100118506f6f6c496400140484536565205b6050616c6c65743a3a636c61696d5f636f6d6d697373696f6e605d2e4c61646a7573745f706f6f6c5f6465706f73697404011c706f6f6c5f6964100118506f6f6c496400150490536565205b6050616c6c65743a3a61646a7573745f706f6f6c5f6465706f736974605d2e7c7365745f636f6d6d697373696f6e5f636c61696d5f7065726d697373696f6e08011c706f6f6c5f6964100118506f6f6c49640001287065726d697373696f6ef50401bc4f7074696f6e3c436f6d6d697373696f6e436c61696d5065726d697373696f6e3c543a3a4163636f756e7449643e3e001604c0536565205b6050616c6c65743a3a7365745f636f6d6d697373696f6e5f636c61696d5f7065726d697373696f6e605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ecd04085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7324426f6e644578747261041c42616c616e6365011801082c4672656542616c616e6365040018011c42616c616e63650000001c5265776172647300010000d104085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7324506f6f6c537461746500010c104f70656e0000001c426c6f636b65640001002844657374726f79696e6700020000d504085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7320436f6e6669674f700404540118010c104e6f6f700000000c5365740400180104540001001852656d6f766500020000d904085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7320436f6e6669674f700404540110010c104e6f6f700000000c5365740400100104540001001852656d6f766500020000dd04085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7320436f6e6669674f7004045401ac010c104e6f6f700000000c5365740400ac0104540001001852656d6f766500020000e104085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7320436f6e6669674f700404540100010c104e6f6f700000000c5365740400000104540001001852656d6f766500020000e504085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c733c436c61696d5065726d697373696f6e000110305065726d697373696f6e6564000000585065726d697373696f6e6c657373436f6d706f756e64000100585065726d697373696f6e6c6573735769746864726177000200445065726d697373696f6e6c657373416c6c00030000e90404184f7074696f6e04045401ed040108104e6f6e6500000010536f6d650400ed040000010000ed0400000408ac0000f104085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7350436f6d6d697373696f6e4368616e676552617465042c426c6f636b4e756d6265720110000801306d61785f696e637265617365ac011c50657262696c6c0001246d696e5f64656c617910012c426c6f636b4e756d6265720000f50404184f7074696f6e04045401f9040108104e6f6e6500000010536f6d650400f9040000010000f904085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7364436f6d6d697373696f6e436c61696d5065726d697373696f6e04244163636f756e74496401000108385065726d697373696f6e6c6573730000001c4163636f756e7404000001244163636f756e74496400010000fd040c4c70616c6c65745f666173745f756e7374616b651870616c6c65741043616c6c04045400010c5472656769737465725f666173745f756e7374616b6500000498536565205b6050616c6c65743a3a72656769737465725f666173745f756e7374616b65605d2e28646572656769737465720001046c536565205b6050616c6c65743a3a64657265676973746572605d2e1c636f6e74726f6c040134657261735f746f5f636865636b100120457261496e64657800020460536565205b6050616c6c65743a3a636f6e74726f6c605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e0105106c706f6c6b61646f745f72756e74696d655f70617261636861696e7334636f6e66696775726174696f6e1870616c6c65741043616c6c0404540001bc7c7365745f76616c69646174696f6e5f757067726164655f636f6f6c646f776e04010c6e6577100144426c6f636b4e756d626572466f723c543e000004c0536565205b6050616c6c65743a3a7365745f76616c69646174696f6e5f757067726164655f636f6f6c646f776e605d2e707365745f76616c69646174696f6e5f757067726164655f64656c617904010c6e6577100144426c6f636b4e756d626572466f723c543e000104b4536565205b6050616c6c65743a3a7365745f76616c69646174696f6e5f757067726164655f64656c6179605d2e647365745f636f64655f726574656e74696f6e5f706572696f6404010c6e6577100144426c6f636b4e756d626572466f723c543e000204a8536565205b6050616c6c65743a3a7365745f636f64655f726574656e74696f6e5f706572696f64605d2e447365745f6d61785f636f64655f73697a6504010c6e657710010c75333200030488536565205b6050616c6c65743a3a7365745f6d61785f636f64655f73697a65605d2e407365745f6d61785f706f765f73697a6504010c6e657710010c75333200040484536565205b6050616c6c65743a3a7365745f6d61785f706f765f73697a65605d2e587365745f6d61785f686561645f646174615f73697a6504010c6e657710010c7533320005049c536565205b6050616c6c65743a3a7365745f6d61785f686561645f646174615f73697a65605d2e487365745f636f726574696d655f636f72657304010c6e657710010c7533320006048c536565205b6050616c6c65743a3a7365745f636f726574696d655f636f726573605d2e547365745f6f6e5f64656d616e645f7265747269657304010c6e657710010c75333200070498536565205b6050616c6c65743a3a7365745f6f6e5f64656d616e645f72657472696573605d2e707365745f67726f75705f726f746174696f6e5f6672657175656e637904010c6e6577100144426c6f636b4e756d626572466f723c543e000804b4536565205b6050616c6c65743a3a7365745f67726f75705f726f746174696f6e5f6672657175656e6379605d2e747365745f70617261735f617661696c6162696c6974795f706572696f6404010c6e6577100144426c6f636b4e756d626572466f723c543e000904b8536565205b6050616c6c65743a3a7365745f70617261735f617661696c6162696c6974795f706572696f64605d2e607365745f7363686564756c696e675f6c6f6f6b616865616404010c6e657710010c753332000b04a4536565205b6050616c6c65743a3a7365745f7363686564756c696e675f6c6f6f6b6168656164605d2e6c7365745f6d61785f76616c696461746f72735f7065725f636f726504010c6e65778d02012c4f7074696f6e3c7533323e000c04b0536565205b6050616c6c65743a3a7365745f6d61785f76616c696461746f72735f7065725f636f7265605d2e487365745f6d61785f76616c696461746f727304010c6e65778d02012c4f7074696f6e3c7533323e000d048c536565205b6050616c6c65743a3a7365745f6d61785f76616c696461746f7273605d2e487365745f646973707574655f706572696f6404010c6e657710013053657373696f6e496e646578000e048c536565205b6050616c6c65743a3a7365745f646973707574655f706572696f64605d2eb47365745f646973707574655f706f73745f636f6e636c7573696f6e5f616363657074616e63655f706572696f6404010c6e6577100144426c6f636b4e756d626572466f723c543e000f04f8536565205b6050616c6c65743a3a7365745f646973707574655f706f73745f636f6e636c7573696f6e5f616363657074616e63655f706572696f64605d2e447365745f6e6f5f73686f775f736c6f747304010c6e657710010c75333200120488536565205b6050616c6c65743a3a7365745f6e6f5f73686f775f736c6f7473605d2e507365745f6e5f64656c61795f7472616e6368657304010c6e657710010c75333200130494536565205b6050616c6c65743a3a7365745f6e5f64656c61795f7472616e63686573605d2e787365745f7a65726f74685f64656c61795f7472616e6368655f776964746804010c6e657710010c753332001404bc536565205b6050616c6c65743a3a7365745f7a65726f74685f64656c61795f7472616e6368655f7769647468605d2e507365745f6e65656465645f617070726f76616c7304010c6e657710010c75333200150494536565205b6050616c6c65743a3a7365745f6e65656465645f617070726f76616c73605d2e707365745f72656c61795f7672665f6d6f64756c6f5f73616d706c657304010c6e657710010c753332001604b4536565205b6050616c6c65743a3a7365745f72656c61795f7672665f6d6f64756c6f5f73616d706c6573605d2e687365745f6d61785f7570776172645f71756575655f636f756e7404010c6e657710010c753332001704ac536565205b6050616c6c65743a3a7365745f6d61785f7570776172645f71756575655f636f756e74605d2e647365745f6d61785f7570776172645f71756575655f73697a6504010c6e657710010c753332001804a8536565205b6050616c6c65743a3a7365745f6d61785f7570776172645f71756575655f73697a65605d2e747365745f6d61785f646f776e776172645f6d6573736167655f73697a6504010c6e657710010c753332001904b8536565205b6050616c6c65743a3a7365745f6d61785f646f776e776172645f6d6573736167655f73697a65605d2e6c7365745f6d61785f7570776172645f6d6573736167655f73697a6504010c6e657710010c753332001b04b0536565205b6050616c6c65743a3a7365745f6d61785f7570776172645f6d6573736167655f73697a65605d2ea07365745f6d61785f7570776172645f6d6573736167655f6e756d5f7065725f63616e64696461746504010c6e657710010c753332001c04e4536565205b6050616c6c65743a3a7365745f6d61785f7570776172645f6d6573736167655f6e756d5f7065725f63616e646964617465605d2e647365745f68726d705f6f70656e5f726571756573745f74746c04010c6e657710010c753332001d04a8536565205b6050616c6c65743a3a7365745f68726d705f6f70656e5f726571756573745f74746c605d2e5c7365745f68726d705f73656e6465725f6465706f73697404010c6e657718011c42616c616e6365001e04a0536565205b6050616c6c65743a3a7365745f68726d705f73656e6465725f6465706f736974605d2e687365745f68726d705f726563697069656e745f6465706f73697404010c6e657718011c42616c616e6365001f04ac536565205b6050616c6c65743a3a7365745f68726d705f726563697069656e745f6465706f736974605d2e747365745f68726d705f6368616e6e656c5f6d61785f636170616369747904010c6e657710010c753332002004b8536565205b6050616c6c65743a3a7365745f68726d705f6368616e6e656c5f6d61785f6361706163697479605d2e7c7365745f68726d705f6368616e6e656c5f6d61785f746f74616c5f73697a6504010c6e657710010c753332002104c0536565205b6050616c6c65743a3a7365745f68726d705f6368616e6e656c5f6d61785f746f74616c5f73697a65605d2e9c7365745f68726d705f6d61785f70617261636861696e5f696e626f756e645f6368616e6e656c7304010c6e657710010c753332002204e0536565205b6050616c6c65743a3a7365745f68726d705f6d61785f70617261636861696e5f696e626f756e645f6368616e6e656c73605d2e847365745f68726d705f6368616e6e656c5f6d61785f6d6573736167655f73697a6504010c6e657710010c753332002404c8536565205b6050616c6c65743a3a7365745f68726d705f6368616e6e656c5f6d61785f6d6573736167655f73697a65605d2ea07365745f68726d705f6d61785f70617261636861696e5f6f7574626f756e645f6368616e6e656c7304010c6e657710010c753332002504e4536565205b6050616c6c65743a3a7365745f68726d705f6d61785f70617261636861696e5f6f7574626f756e645f6368616e6e656c73605d2e987365745f68726d705f6d61785f6d6573736167655f6e756d5f7065725f63616e64696461746504010c6e657710010c753332002704dc536565205b6050616c6c65743a3a7365745f68726d705f6d61785f6d6573736167655f6e756d5f7065725f63616e646964617465605d2e487365745f7076665f766f74696e675f74746c04010c6e657710013053657373696f6e496e646578002a048c536565205b6050616c6c65743a3a7365745f7076665f766f74696e675f74746c605d2e907365745f6d696e696d756d5f76616c69646174696f6e5f757067726164655f64656c617904010c6e6577100144426c6f636b4e756d626572466f723c543e002b04d4536565205b6050616c6c65743a3a7365745f6d696e696d756d5f76616c69646174696f6e5f757067726164655f64656c6179605d2e707365745f6279706173735f636f6e73697374656e63795f636865636b04010c6e6577780110626f6f6c002c04b4536565205b6050616c6c65743a3a7365745f6279706173735f636f6e73697374656e63795f636865636b605d2e607365745f6173796e635f6261636b696e675f706172616d7304010c6e6577050501484173796e634261636b696e67506172616d73002d04a4536565205b6050616c6c65743a3a7365745f6173796e635f6261636b696e675f706172616d73605d2e4c7365745f6578656375746f725f706172616d7304010c6e6577090501384578656375746f72506172616d73002e0490536565205b6050616c6c65743a3a7365745f6578656375746f725f706172616d73605d2e587365745f6f6e5f64656d616e645f626173655f66656504010c6e657718011c42616c616e6365002f049c536565205b6050616c6c65743a3a7365745f6f6e5f64656d616e645f626173655f666565605d2e747365745f6f6e5f64656d616e645f6665655f766172696162696c69747904010c6e6577ac011c50657262696c6c003004b8536565205b6050616c6c65743a3a7365745f6f6e5f64656d616e645f6665655f766172696162696c697479605d2e707365745f6f6e5f64656d616e645f71756575655f6d61785f73697a6504010c6e657710010c753332003104b4536565205b6050616c6c65743a3a7365745f6f6e5f64656d616e645f71756575655f6d61785f73697a65605d2e987365745f6f6e5f64656d616e645f7461726765745f71756575655f7574696c697a6174696f6e04010c6e6577ac011c50657262696c6c003204dc536565205b6050616c6c65743a3a7365745f6f6e5f64656d616e645f7461726765745f71756575655f7574696c697a6174696f6e605d2e447365745f6f6e5f64656d616e645f74746c04010c6e6577100144426c6f636b4e756d626572466f723c543e00330488536565205b6050616c6c65743a3a7365745f6f6e5f64656d616e645f74746c605d2e647365745f6d696e696d756d5f6261636b696e675f766f74657304010c6e657710010c753332003404a8536565205b6050616c6c65743a3a7365745f6d696e696d756d5f6261636b696e675f766f746573605d2e407365745f6e6f64655f66656174757265080114696e646578080108753800011476616c7565780110626f6f6c00350484536565205b6050616c6c65743a3a7365745f6e6f64655f66656174757265605d2e687365745f617070726f76616c5f766f74696e675f706172616d7304010c6e65771d050150417070726f76616c566f74696e67506172616d73003604ac536565205b6050616c6c65743a3a7365745f617070726f76616c5f766f74696e675f706172616d73605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e0505104c706f6c6b61646f745f7072696d697469766573087636346173796e635f6261636b696e67484173796e634261636b696e67506172616d73000008014c6d61785f63616e6469646174655f646570746810010c753332000150616c6c6f7765645f616e6365737472795f6c656e10010c75333200000905104c706f6c6b61646f745f7072696d6974697665730876363c6578656375746f725f706172616d73384578656375746f72506172616d73000004000d0501485665633c4578656375746f72506172616d3e00000d050000021105001105104c706f6c6b61646f745f7072696d6974697665730876363c6578656375746f725f706172616d73344578656375746f72506172616d00011c384d61784d656d6f72795061676573040010010c7533320001003c537461636b4c6f676963616c4d6178040010010c75333200020038537461636b4e61746976654d6178040010010c75333200030050507265636865636b696e674d61784d656d6f727904002c010c753634000400385076665072657054696d656f757408001505012c507666507265704b696e6400002c010c753634000500385076664578656354696d656f757408001905012c507666457865634b696e6400002c010c753634000600445761736d45787442756c6b4d656d6f72790007000015050c4c706f6c6b61646f745f7072696d6974697665730876362c507666507265704b696e6400010820507265636865636b0000001c507265706172650001000019050c4c706f6c6b61646f745f7072696d6974697665730876362c507666457865634b696e640001081c4261636b696e6700000020417070726f76616c000100001d050c4c706f6c6b61646f745f7072696d697469766573207673746167696e6750417070726f76616c566f74696e67506172616d73000004016c6d61785f617070726f76616c5f636f616c657363655f636f756e7410010c75333200002105106c706f6c6b61646f745f72756e74696d655f70617261636861696e73187368617265641870616c6c65741043616c6c040454000100040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e2505106c706f6c6b61646f745f72756e74696d655f70617261636861696e7324696e636c7573696f6e1870616c6c65741043616c6c040454000100040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e2905106c706f6c6b61646f745f72756e74696d655f70617261636861696e733870617261735f696e686572656e741870616c6c65741043616c6c04045400010414656e746572040110646174612d05019050617261636861696e73496e686572656e74446174613c486561646572466f723c543e3e00000458536565205b6050616c6c65743a3a656e746572605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e2d050c4c706f6c6b61646f745f7072696d69746976657308763630496e686572656e7444617461040c48445201c501001001246269746669656c647331050190556e636865636b65645369676e6564417661696c6162696c6974794269746669656c64730001446261636b65645f63616e646964617465734d05017c5665633c4261636b656443616e6469646174653c4844523a3a486173683e3e0001206469737075746573910501604d756c74694469737075746553746174656d656e74536574000134706172656e745f686561646572c501010c484452000031050000023505003505104c706f6c6b61646f745f7072696d697469766573087636187369676e65643c556e636865636b65645369676e6564081c5061796c6f61640139052c5265616c5061796c6f6164013905000c011c7061796c6f61643905011c5061796c6f616400013c76616c696461746f725f696e6465784505013856616c696461746f72496e6465780001247369676e61747572654905014856616c696461746f725369676e6174757265000039050c4c706f6c6b61646f745f7072696d69746976657308763650417661696c6162696c6974794269746669656c64000004003d05017c4269745665633c75382c206269747665633a3a6f726465723a3a4c7362303e00003d050000070841050041050c18626974766563146f72646572104c7362300000000045050c4c706f6c6b61646f745f7072696d6974697665730876363856616c696461746f72496e6465780000040010010c75333200004905104c706f6c6b61646f745f7072696d6974697665730876363476616c696461746f725f617070245369676e617475726500000400a5030148737232353531393a3a5369676e617475726500004d0500000251050051050c4c706f6c6b61646f745f7072696d6974697665730876363c4261636b656443616e6469646174650404480130000c012463616e64696461746555050170436f6d6d697474656443616e646964617465526563656970743c483e00013876616c69646974795f766f746573890501605665633c56616c69646974794174746573746174696f6e3e00014476616c696461746f725f696e64696365733d05017c4269745665633c75382c206269747665633a3a6f726465723a3a4c7362303e000055050c4c706f6c6b61646f745f7072696d69746976657308763664436f6d6d697474656443616e6469646174655265636569707404044801300008012864657363726970746f725905015843616e64696461746544657363726970746f723c483e00012c636f6d6d69746d656e74736905015043616e646964617465436f6d6d69746d656e7473000059050c4c706f6c6b61646f745f7072696d6974697665730876364c43616e64696461746544657363726970746f7204044801300024011c706172615f6964b9020108496400013072656c61795f706172656e7430010448000120636f6c6c61746f725d050128436f6c6c61746f7249640001787065727369737465645f76616c69646174696f6e5f646174615f6861736830011048617368000120706f765f6861736830011048617368000130657261737572655f726f6f74300110486173680001247369676e617475726561050144436f6c6c61746f725369676e6174757265000124706172615f686561643001104861736800015076616c69646174696f6e5f636f64655f686173686505014856616c69646174696f6e436f64654861736800005d05104c706f6c6b61646f745f7072696d69746976657308763630636f6c6c61746f725f617070185075626c696300000400e4013c737232353531393a3a5075626c696300006105104c706f6c6b61646f745f7072696d69746976657308763630636f6c6c61746f725f617070245369676e617475726500000400a5030148737232353531393a3a5369676e6174757265000065050c74706f6c6b61646f745f70617261636861696e5f7072696d697469766573287072696d6974697665734856616c69646174696f6e436f6465486173680000040030011048617368000069050c4c706f6c6b61646f745f7072696d6974697665730876365043616e646964617465436f6d6d69746d656e747304044e01100018013c7570776172645f6d657373616765736d0501385570776172644d6573736167657300014c686f72697a6f6e74616c5f6d6573736167657371050148486f72697a6f6e74616c4d6573736167657300014c6e65775f76616c69646174696f6e5f636f64657d0501584f7074696f6e3c56616c69646174696f6e436f64653e000124686561645f6461746185050120486561644461746100016c70726f6365737365645f646f776e776172645f6d6573736167657310010c75333200013868726d705f77617465726d61726b1001044e00006d050c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540134045300000400a90101185665633c543e000071050c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e646564566563080454017505045300000400790501185665633c543e000075050860706f6c6b61646f745f636f72655f7072696d6974697665734c4f7574626f756e6448726d704d6573736167650408496401b90200080124726563697069656e74b902010849640001106461746134015073705f7374643a3a7665633a3a5665633c75383e000079050000027505007d0504184f7074696f6e0404540181050108104e6f6e6500000010536f6d6504008105000001000081050c74706f6c6b61646f745f70617261636861696e5f7072696d697469766573287072696d6974697665733856616c69646174696f6e436f64650000040034011c5665633c75383e000085050c74706f6c6b61646f745f70617261636861696e5f7072696d697469766573287072696d6974697665732048656164446174610000040034011c5665633c75383e000089050000028d05008d050c4c706f6c6b61646f745f7072696d6974697665730876364c56616c69646974794174746573746174696f6e00010820496d706c6963697404004905014856616c696461746f725369676e6174757265000100204578706c6963697404004905014856616c696461746f725369676e617475726500020000910500000295050095050c4c706f6c6b61646f745f7072696d6974697665730876364c4469737075746553746174656d656e7453657400000c013863616e6469646174655f686173689905013443616e6469646174654861736800011c73657373696f6e10013053657373696f6e496e64657800012873746174656d656e74739d0501ec5665633c284469737075746553746174656d656e742c2056616c696461746f72496e6465782c2056616c696461746f725369676e6174757265293e000099050860706f6c6b61646f745f636f72655f7072696d6974697665733443616e64696461746548617368000004003001104861736800009d05000002a10500a1050000040ca5054505490500a5050c4c706f6c6b61646f745f7072696d697469766573087636404469737075746553746174656d656e740001081456616c69640400a905016456616c69644469737075746553746174656d656e744b696e640000001c496e76616c69640400b105016c496e76616c69644469737075746553746174656d656e744b696e6400010000a9050c4c706f6c6b61646f745f7072696d6974697665730876366456616c69644469737075746553746174656d656e744b696e64000114204578706c696369740000003c4261636b696e675365636f6e646564040030011048617368000100304261636b696e6756616c696404003001104861736800020040417070726f76616c436865636b696e6700030088417070726f76616c436865636b696e674d756c7469706c6543616e646964617465730400ad0501485665633c43616e646964617465486173683e00040000ad05000002990500b1050c4c706f6c6b61646f745f7072696d6974697665730876366c496e76616c69644469737075746553746174656d656e744b696e64000104204578706c6963697400000000b505106c706f6c6b61646f745f72756e74696d655f70617261636861696e731470617261731870616c6c65741043616c6c04045400012458666f7263655f7365745f63757272656e745f636f646508011070617261b90201185061726149640001206e65775f636f64658105013856616c69646174696f6e436f64650000049c536565205b6050616c6c65743a3a666f7263655f7365745f63757272656e745f636f6465605d2e58666f7263655f7365745f63757272656e745f6865616408011070617261b90201185061726149640001206e65775f686561648505012048656164446174610001049c536565205b6050616c6c65743a3a666f7263655f7365745f63757272656e745f68656164605d2e6c666f7263655f7363686564756c655f636f64655f757067726164650c011070617261b90201185061726149640001206e65775f636f64658105013856616c69646174696f6e436f646500014c72656c61795f706172656e745f6e756d626572100144426c6f636b4e756d626572466f723c543e000204b0536565205b6050616c6c65743a3a666f7263655f7363686564756c655f636f64655f75706772616465605d2e4c666f7263655f6e6f74655f6e65775f6865616408011070617261b90201185061726149640001206e65775f6865616485050120486561644461746100030490536565205b6050616c6c65743a3a666f7263655f6e6f74655f6e65775f68656164605d2e48666f7263655f71756575655f616374696f6e04011070617261b90201185061726149640004048c536565205b6050616c6c65743a3a666f7263655f71756575655f616374696f6e605d2e6c6164645f747275737465645f76616c69646174696f6e5f636f646504013c76616c69646174696f6e5f636f64658105013856616c69646174696f6e436f6465000504b0536565205b6050616c6c65743a3a6164645f747275737465645f76616c69646174696f6e5f636f6465605d2e6c706f6b655f756e757365645f76616c69646174696f6e5f636f646504015076616c69646174696f6e5f636f64655f686173686505014856616c69646174696f6e436f646548617368000604b0536565205b6050616c6c65743a3a706f6b655f756e757365645f76616c69646174696f6e5f636f6465605d2e6c696e636c7564655f7076665f636865636b5f73746174656d656e7408011073746d74b9050144507666436865636b53746174656d656e740001247369676e61747572654905014856616c696461746f725369676e6174757265000704b0536565205b6050616c6c65743a3a696e636c7564655f7076665f636865636b5f73746174656d656e74605d2e74666f7263655f7365745f6d6f73745f726563656e745f636f6e7465787408011070617261b902011850617261496400011c636f6e74657874100144426c6f636b4e756d626572466f723c543e000804b8536565205b6050616c6c65743a3a666f7263655f7365745f6d6f73745f726563656e745f636f6e74657874605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732eb9050c4c706f6c6b61646f745f7072696d69746976657308763644507666436865636b53746174656d656e740000100118616363657074780110626f6f6c00011c7375626a6563746505014856616c69646174696f6e436f64654861736800013473657373696f6e5f696e64657810013053657373696f6e496e64657800013c76616c696461746f725f696e6465784505013856616c696461746f72496e6465780000bd05106c706f6c6b61646f745f72756e74696d655f70617261636861696e732c696e697469616c697a65721870616c6c65741043616c6c04045400010434666f7263655f617070726f766504011475705f746f10012c426c6f636b4e756d62657200000478536565205b6050616c6c65743a3a666f7263655f617070726f7665605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ec105106c706f6c6b61646f745f72756e74696d655f70617261636861696e731068726d701870616c6c65741043616c6c0404540001285868726d705f696e69745f6f70656e5f6368616e6e656c0c0124726563697069656e74b902011850617261496400015470726f706f7365645f6d61785f636170616369747910010c75333200016470726f706f7365645f6d61785f6d6573736167655f73697a6510010c7533320000049c536565205b6050616c6c65743a3a68726d705f696e69745f6f70656e5f6368616e6e656c605d2e6068726d705f6163636570745f6f70656e5f6368616e6e656c04011873656e646572b9020118506172614964000104a4536565205b6050616c6c65743a3a68726d705f6163636570745f6f70656e5f6368616e6e656c605d2e4868726d705f636c6f73655f6368616e6e656c0401286368616e6e656c5f6964c505013448726d704368616e6e656c49640002048c536565205b6050616c6c65743a3a68726d705f636c6f73655f6368616e6e656c605d2e40666f7263655f636c65616e5f68726d700c011070617261b902011850617261496400012c6e756d5f696e626f756e6410010c7533320001306e756d5f6f7574626f756e6410010c75333200030484536565205b6050616c6c65743a3a666f7263655f636c65616e5f68726d70605d2e5c666f7263655f70726f636573735f68726d705f6f70656e0401206368616e6e656c7310010c753332000404a0536565205b6050616c6c65743a3a666f7263655f70726f636573735f68726d705f6f70656e605d2e60666f7263655f70726f636573735f68726d705f636c6f73650401206368616e6e656c7310010c753332000504a4536565205b6050616c6c65743a3a666f7263655f70726f636573735f68726d705f636c6f7365605d2e6068726d705f63616e63656c5f6f70656e5f726571756573740801286368616e6e656c5f6964c505013448726d704368616e6e656c49640001346f70656e5f726571756573747310010c753332000604a4536565205b6050616c6c65743a3a68726d705f63616e63656c5f6f70656e5f72657175657374605d2e5c666f7263655f6f70656e5f68726d705f6368616e6e656c10011873656e646572b9020118506172614964000124726563697069656e74b90201185061726149640001306d61785f636170616369747910010c7533320001406d61785f6d6573736167655f73697a6510010c753332000704a0536565205b6050616c6c65743a3a666f7263655f6f70656e5f68726d705f6368616e6e656c605d2e6065737461626c6973685f73797374656d5f6368616e6e656c08011873656e646572b9020118506172614964000124726563697069656e74b9020118506172614964000804a4536565205b6050616c6c65743a3a65737461626c6973685f73797374656d5f6368616e6e656c605d2e54706f6b655f6368616e6e656c5f6465706f7369747308011873656e646572b9020118506172614964000124726563697069656e74b902011850617261496400090498536565205b6050616c6c65743a3a706f6b655f6368616e6e656c5f6465706f73697473605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ec5050c74706f6c6b61646f745f70617261636861696e5f7072696d697469766573287072696d6974697665733448726d704368616e6e656c4964000008011873656e646572b90201084964000124726563697069656e74b902010849640000c905106c706f6c6b61646f745f72756e74696d655f70617261636861696e732064697370757465731870616c6c65741043616c6c04045400010438666f7263655f756e667265657a650000047c536565205b6050616c6c65743a3a666f7263655f756e667265657a65605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ecd05146c706f6c6b61646f745f72756e74696d655f70617261636861696e7320646973707574657320736c617368696e671870616c6c65741043616c6c040454000104707265706f72745f646973707574655f6c6f73745f756e7369676e6564080134646973707574655f70726f6f66d1050144426f783c4469737075746550726f6f663e00013c6b65795f6f776e65725f70726f6f66d1010140543a3a4b65794f776e657250726f6f66000004b4536565205b6050616c6c65743a3a7265706f72745f646973707574655f6c6f73745f756e7369676e6564605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ed105104c706f6c6b61646f745f7072696d69746976657308763620736c617368696e67304469737075746550726f6f66000010012474696d655f736c6f74d5050140446973707574657354696d65536c6f740001106b696e64d905014c536c617368696e674f6666656e63654b696e6400013c76616c696461746f725f696e6465784505013856616c696461746f72496e64657800013076616c696461746f725f69644102012c56616c696461746f7249640000d505104c706f6c6b61646f745f7072696d69746976657308763620736c617368696e6740446973707574657354696d65536c6f74000008013473657373696f6e5f696e64657810013053657373696f6e496e64657800013863616e6469646174655f686173689905013443616e646964617465486173680000d905104c706f6c6b61646f745f7072696d69746976657308763620736c617368696e674c536c617368696e674f6666656e63654b696e6400010828466f72496e76616c696400000030416761696e737456616c696400010000dd05105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e3c70617261735f7265676973747261721870616c6c65741043616c6c0404540001242072656769737465720c01086964b902011850617261496400013067656e657369735f6865616485050120486561644461746100013c76616c69646174696f6e5f636f64658105013856616c69646174696f6e436f646500000464536565205b6050616c6c65743a3a7265676973746572605d2e38666f7263655f726567697374657214010c77686f000130543a3a4163636f756e74496400011c6465706f73697418013042616c616e63654f663c543e0001086964b902011850617261496400013067656e657369735f6865616485050120486561644461746100013c76616c69646174696f6e5f636f64658105013856616c69646174696f6e436f64650001047c536565205b6050616c6c65743a3a666f7263655f7265676973746572605d2e28646572656769737465720401086964b90201185061726149640002046c536565205b6050616c6c65743a3a64657265676973746572605d2e10737761700801086964b90201185061726149640001146f74686572b902011850617261496400030454536565205b6050616c6c65743a3a73776170605d2e2c72656d6f76655f6c6f636b04011070617261b902011850617261496400040470536565205b6050616c6c65743a3a72656d6f76655f6c6f636b605d2e1c7265736572766500050460536565205b6050616c6c65743a3a72657365727665605d2e206164645f6c6f636b04011070617261b902011850617261496400060464536565205b6050616c6c65743a3a6164645f6c6f636b605d2e547363686564756c655f636f64655f7570677261646508011070617261b90201185061726149640001206e65775f636f64658105013856616c69646174696f6e436f646500070498536565205b6050616c6c65743a3a7363686564756c655f636f64655f75706772616465605d2e407365745f63757272656e745f6865616408011070617261b90201185061726149640001206e65775f6865616485050120486561644461746100080484536565205b6050616c6c65743a3a7365745f63757272656e745f68656164605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ee105105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e14736c6f74731870616c6c65741043616c6c04045400010c2c666f7263655f6c6561736514011070617261b90201185061726149640001186c6561736572000130543a3a4163636f756e744964000118616d6f756e7418013042616c616e63654f663c543e000130706572696f645f626567696e1001404c65617365506572696f644f663c543e000130706572696f645f636f756e741001404c65617365506572696f644f663c543e00000470536565205b6050616c6c65743a3a666f7263655f6c65617365605d2e40636c6561725f616c6c5f6c656173657304011070617261b902011850617261496400010484536565205b6050616c6c65743a3a636c6561725f616c6c5f6c6561736573605d2e3c747269676765725f6f6e626f61726404011070617261b902011850617261496400020480536565205b6050616c6c65743a3a747269676765725f6f6e626f617264605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ee505105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e2061756374696f6e731870616c6c65741043616c6c04045400010c2c6e65775f61756374696f6e0801206475726174696f6e15010144426c6f636b4e756d626572466f723c543e0001486c656173655f706572696f645f696e646578150101404c65617365506572696f644f663c543e00000470536565205b6050616c6c65743a3a6e65775f61756374696f6e605d2e0c62696414011070617261e905011850617261496400013461756374696f6e5f696e6465781501013041756374696f6e496e64657800012866697273745f736c6f74150101404c65617365506572696f644f663c543e0001246c6173745f736c6f74150101404c65617365506572696f644f663c543e000118616d6f756e74f4013042616c616e63654f663c543e00010450536565205b6050616c6c65743a3a626964605d2e3863616e63656c5f61756374696f6e0002047c536565205b6050616c6c65743a3a63616e63656c5f61756374696f6e605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ee905000006b90200ed05105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e2463726f77646c6f616e1870616c6c65741043616c6c04045400012418637265617465180114696e646578e905011850617261496400010c636170f4013042616c616e63654f663c543e00013066697273745f706572696f64150101404c65617365506572696f644f663c543e00012c6c6173745f706572696f64150101404c65617365506572696f644f663c543e00010c656e6415010144426c6f636b4e756d626572466f723c543e0001207665726966696572f105014c4f7074696f6e3c4d756c74695369676e65723e0000045c536565205b6050616c6c65743a3a637265617465605d2e28636f6e747269627574650c0114696e646578e905011850617261496400011476616c7565f4013042616c616e63654f663c543e0001247369676e61747572659d0301584f7074696f6e3c4d756c74695369676e61747572653e0001046c536565205b6050616c6c65743a3a636f6e74726962757465605d2e20776974686472617708010c77686f000130543a3a4163636f756e744964000114696e646578e905011850617261496400020464536565205b6050616c6c65743a3a7769746864726177605d2e18726566756e64040114696e646578e90501185061726149640003045c536565205b6050616c6c65743a3a726566756e64605d2e20646973736f6c7665040114696e646578e905011850617261496400040464536565205b6050616c6c65743a3a646973736f6c7665605d2e1065646974180114696e646578e905011850617261496400010c636170f4013042616c616e63654f663c543e00013066697273745f706572696f64150101404c65617365506572696f644f663c543e00012c6c6173745f706572696f64150101404c65617365506572696f644f663c543e00010c656e6415010144426c6f636b4e756d626572466f723c543e0001207665726966696572f105014c4f7074696f6e3c4d756c74695369676e65723e00050454536565205b6050616c6c65743a3a65646974605d2e206164645f6d656d6f080114696e646578b90201185061726149640001106d656d6f34011c5665633c75383e00060464536565205b6050616c6c65743a3a6164645f6d656d6f605d2e10706f6b65040114696e646578b902011850617261496400070454536565205b6050616c6c65743a3a706f6b65605d2e38636f6e747269627574655f616c6c080114696e646578e90501185061726149640001247369676e61747572659d0301584f7074696f6e3c4d756c74695369676e61747572653e0008047c536565205b6050616c6c65743a3a636f6e747269627574655f616c6c605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732ef10504184f7074696f6e04045401f5050108104e6f6e6500000010536f6d650400f5050000010000f505082873705f72756e74696d652c4d756c74695369676e657200010c1c456432353531390400d8013c656432353531393a3a5075626c69630000001c537232353531390400e4013c737232353531393a3a5075626c696300010014456364736104005102013465636473613a3a5075626c696300020000f9050c6c70616c6c65745f73746174655f747269655f6d6967726174696f6e1870616c6c65741043616c6c04045400011858636f6e74726f6c5f6175746f5f6d6967726174696f6e0401306d617962655f636f6e666967fd05015c4f7074696f6e3c4d6967726174696f6e4c696d6974733e0000049c536565205b6050616c6c65743a3a636f6e74726f6c5f6175746f5f6d6967726174696f6e605d2e40636f6e74696e75655f6d6967726174650c01186c696d6974730106013c4d6967726174696f6e4c696d69747300013c7265616c5f73697a655f757070657210010c7533320001307769746e6573735f7461736b050601404d6967726174696f6e5461736b3c543e00010484536565205b6050616c6c65743a3a636f6e74696e75655f6d696772617465605d2e486d6967726174655f637573746f6d5f746f700801106b657973a90101305665633c5665633c75383e3e0001307769746e6573735f73697a6510010c7533320002048c536565205b6050616c6c65743a3a6d6967726174655f637573746f6d5f746f70605d2e506d6967726174655f637573746f6d5f6368696c640c0110726f6f7434011c5665633c75383e0001286368696c645f6b657973a90101305665633c5665633c75383e3e000128746f74616c5f73697a6510010c75333200030494536565205b6050616c6c65743a3a6d6967726174655f637573746f6d5f6368696c64605d2e547365745f7369676e65645f6d61785f6c696d6974730401186c696d6974730106013c4d6967726174696f6e4c696d69747300040498536565205b6050616c6c65743a3a7365745f7369676e65645f6d61785f6c696d697473605d2e48666f7263655f7365745f70726f677265737308013070726f67726573735f746f700906013450726f67726573734f663c543e00013870726f67726573735f6368696c640906013450726f67726573734f663c543e0005048c536565205b6050616c6c65743a3a666f7263655f7365745f70726f6772657373605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732efd0504184f7074696f6e0404540101060108104e6f6e6500000010536f6d6504000106000001000001060c6c70616c6c65745f73746174655f747269655f6d6967726174696f6e1870616c6c65743c4d6967726174696f6e4c696d697473000008011073697a6510010c7533320001106974656d10010c753332000005060c6c70616c6c65745f73746174655f747269655f6d6967726174696f6e1870616c6c6574344d6967726174696f6e5461736b040454000014013070726f67726573735f746f700906013450726f67726573734f663c543e00013870726f67726573735f6368696c640906013450726f67726573734f663c543e00011073697a6510010c753332000124746f705f6974656d7310010c75333200012c6368696c645f6974656d7310010c753332000009060c6c70616c6c65745f73746174655f747269655f6d6967726174696f6e1870616c6c65742050726f677265737304244d61784b65794c656e00010c1c546f53746172740000001c4c6173744b657904000d060164426f756e6465645665633c75382c204d61784b65794c656e3e00010020436f6d706c657465000200000d060c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e000011060c2870616c6c65745f78636d1870616c6c65741043616c6c0404540001341073656e640801106465737469010158426f783c56657273696f6e65644c6f636174696f6e3e00011c6d65737361676515060154426f783c56657273696f6e656458636d3c28293e3e00000454536565205b6050616c6c65743a3a73656e64605d2e3c74656c65706f72745f6173736574731001106465737469010158426f783c56657273696f6e65644c6f636174696f6e3e00012c62656e656669636961727969010158426f783c56657273696f6e65644c6f636174696f6e3e0001186173736574730d070150426f783c56657273696f6e65644173736574733e0001386665655f61737365745f6974656d10010c75333200010480536565205b6050616c6c65743a3a74656c65706f72745f617373657473605d2e5c726573657276655f7472616e736665725f6173736574731001106465737469010158426f783c56657273696f6e65644c6f636174696f6e3e00012c62656e656669636961727969010158426f783c56657273696f6e65644c6f636174696f6e3e0001186173736574730d070150426f783c56657273696f6e65644173736574733e0001386665655f61737365745f6974656d10010c753332000204a0536565205b6050616c6c65743a3a726573657276655f7472616e736665725f617373657473605d2e1c6578656375746508011c6d657373616765110701b4426f783c56657273696f6e656458636d3c3c5420617320436f6e6669673e3a3a52756e74696d6543616c6c3e3e0001286d61785f77656967687424011857656967687400030460536565205b6050616c6c65743a3a65786563757465605d2e44666f7263655f78636d5f76657273696f6e0801206c6f636174696f6e31010134426f783c4c6f636174696f6e3e00011c76657273696f6e10012858636d56657273696f6e00040488536565205b6050616c6c65743a3a666f7263655f78636d5f76657273696f6e605d2e64666f7263655f64656661756c745f78636d5f76657273696f6e0401446d617962655f78636d5f76657273696f6e8d0201484f7074696f6e3c58636d56657273696f6e3e000504a8536565205b6050616c6c65743a3a666f7263655f64656661756c745f78636d5f76657273696f6e605d2e78666f7263655f7375627363726962655f76657273696f6e5f6e6f746966790401206c6f636174696f6e69010158426f783c56657273696f6e65644c6f636174696f6e3e000604bc536565205b6050616c6c65743a3a666f7263655f7375627363726962655f76657273696f6e5f6e6f74696679605d2e80666f7263655f756e7375627363726962655f76657273696f6e5f6e6f746966790401206c6f636174696f6e69010158426f783c56657273696f6e65644c6f636174696f6e3e000704c4536565205b6050616c6c65743a3a666f7263655f756e7375627363726962655f76657273696f6e5f6e6f74696679605d2e7c6c696d697465645f726573657276655f7472616e736665725f6173736574731401106465737469010158426f783c56657273696f6e65644c6f636174696f6e3e00012c62656e656669636961727969010158426f783c56657273696f6e65644c6f636174696f6e3e0001186173736574730d070150426f783c56657273696f6e65644173736574733e0001386665655f61737365745f6974656d10010c7533320001307765696768745f6c696d6974c106012c5765696768744c696d6974000804c0536565205b6050616c6c65743a3a6c696d697465645f726573657276655f7472616e736665725f617373657473605d2e5c6c696d697465645f74656c65706f72745f6173736574731401106465737469010158426f783c56657273696f6e65644c6f636174696f6e3e00012c62656e656669636961727969010158426f783c56657273696f6e65644c6f636174696f6e3e0001186173736574730d070150426f783c56657273696f6e65644173736574733e0001386665655f61737365745f6974656d10010c7533320001307765696768745f6c696d6974c106012c5765696768744c696d6974000904a0536565205b6050616c6c65743a3a6c696d697465645f74656c65706f72745f617373657473605d2e40666f7263655f73757370656e73696f6e04012473757370656e646564780110626f6f6c000a0484536565205b6050616c6c65743a3a666f7263655f73757370656e73696f6e605d2e3c7472616e736665725f6173736574731401106465737469010158426f783c56657273696f6e65644c6f636174696f6e3e00012c62656e656669636961727969010158426f783c56657273696f6e65644c6f636174696f6e3e0001186173736574730d070150426f783c56657273696f6e65644173736574733e0001386665655f61737365745f6974656d10010c7533320001307765696768745f6c696d6974c106012c5765696768744c696d6974000b0480536565205b6050616c6c65743a3a7472616e736665725f617373657473605d2e30636c61696d5f6173736574730801186173736574730d070150426f783c56657273696f6e65644173736574733e00012c62656e656669636961727969010158426f783c56657273696f6e65644c6f636174696f6e3e000c0474536565205b6050616c6c65743a3a636c61696d5f617373657473605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e1506080c78636d3056657273696f6e656458636d042c52756e74696d6543616c6c00010c08563204001906015076323a3a58636d3c52756e74696d6543616c6c3e00020008563304006506015076333a3a58636d3c52756e74696d6543616c6c3e0003000856340400c506015076343a3a58636d3c52756e74696d6543616c6c3e0004000019060c0c78636d0876320c58636d042c52756e74696d6543616c6c000004001d0601745665633c496e737472756374696f6e3c52756e74696d6543616c6c3e3e00001d0600000221060021060c0c78636d0876322c496e737472756374696f6e042c52756e74696d6543616c6c000170345769746864726177417373657404002506012c4d756c7469417373657473000000545265736572766541737365744465706f736974656404002506012c4d756c7469417373657473000100585265636569766554656c65706f72746564417373657404002506012c4d756c7469417373657473000200345175657279526573706f6e73650c012071756572795f696428011c51756572794964000120726573706f6e73653d060120526573706f6e73650001286d61785f77656967687428010c753634000300345472616e7366657241737365740801186173736574732506012c4d756c746941737365747300012c62656e65666963696172796d0101344d756c74694c6f636174696f6e000400505472616e736665725265736572766541737365740c01186173736574732506012c4d756c7469417373657473000110646573746d0101344d756c74694c6f636174696f6e00010c78636d1906011c58636d3c28293e000500205472616e736163740c012c6f726967696e5f747970654d0601284f726967696e4b696e64000158726571756972655f7765696768745f61745f6d6f737428010c75363400011063616c6c51060168446f75626c65456e636f6465643c52756e74696d6543616c6c3e0006006448726d704e65774368616e6e656c4f70656e526571756573740c011873656e6465721501010c7533320001406d61785f6d6573736167655f73697a651501010c7533320001306d61785f63617061636974791501010c7533320007004c48726d704368616e6e656c4163636570746564040124726563697069656e741501010c7533320008004848726d704368616e6e656c436c6f73696e670c0124696e69746961746f721501010c75333200011873656e6465721501010c753332000124726563697069656e741501010c7533320009002c436c6561724f726967696e000a003444657363656e644f726967696e040071010154496e746572696f724d756c74694c6f636174696f6e000b002c5265706f72744572726f720c012071756572795f696428011c51756572794964000110646573746d0101344d756c74694c6f636174696f6e00014c6d61785f726573706f6e73655f77656967687428010c753634000c00304465706f73697441737365740c0118617373657473550601404d756c7469417373657446696c7465720001286d61785f6173736574731501010c75333200012c62656e65666963696172796d0101344d756c74694c6f636174696f6e000d004c4465706f736974526573657276654173736574100118617373657473550601404d756c7469417373657446696c7465720001286d61785f6173736574731501010c753332000110646573746d0101344d756c74694c6f636174696f6e00010c78636d1906011c58636d3c28293e000e003445786368616e6765417373657408011067697665550601404d756c7469417373657446696c74657200011c726563656976652506012c4d756c7469417373657473000f005c496e6974696174655265736572766557697468647261770c0118617373657473550601404d756c7469417373657446696c74657200011c726573657276656d0101344d756c74694c6f636174696f6e00010c78636d1906011c58636d3c28293e00100040496e69746961746554656c65706f72740c0118617373657473550601404d756c7469417373657446696c746572000110646573746d0101344d756c74694c6f636174696f6e00010c78636d1906011c58636d3c28293e001100305175657279486f6c64696e6710012071756572795f696428011c51756572794964000110646573746d0101344d756c74694c6f636174696f6e000118617373657473550601404d756c7469417373657446696c74657200014c6d61785f726573706f6e73655f77656967687428010c75363400120030427579457865637574696f6e080110666565732d0601284d756c746941737365740001307765696768745f6c696d69746106012c5765696768744c696d697400130034526566756e64537572706c75730014003c5365744572726f7248616e646c657204001906014058636d3c52756e74696d6543616c6c3e0015002c536574417070656e64697804001906014058636d3c52756e74696d6543616c6c3e00160028436c6561724572726f7200170028436c61696d41737365740801186173736574732506012c4d756c74694173736574730001187469636b65746d0101344d756c74694c6f636174696f6e0018001054726170040028010c7536340019004053756273637269626556657273696f6e08012071756572795f696428011c5175657279496400014c6d61785f726573706f6e73655f77656967687428010c753634001a0048556e73756273637269626556657273696f6e001b00002506100c78636d087632286d756c746961737365742c4d756c7469417373657473000004002906013c5665633c4d756c746941737365743e000029060000022d06002d06100c78636d087632286d756c74696173736574284d756c74694173736574000008010869643106011c4173736574496400010c66756e3506012c46756e676962696c69747900003106100c78636d087632286d756c746961737365741c4173736574496400010820436f6e637265746504006d0101344d756c74694c6f636174696f6e000000204162737472616374040034011c5665633c75383e000100003506100c78636d087632286d756c746961737365742c46756e676962696c6974790001082046756e6769626c650400f40110753132380000002c4e6f6e46756e6769626c650400390601344173736574496e7374616e6365000100003906100c78636d087632286d756c74696173736574344173736574496e7374616e636500011c24556e646566696e656400000014496e6465780400f401107531323800010018417272617934040044011c5b75383b20345d0002001841727261793804003103011c5b75383b20385d0003001c417272617931360400c001205b75383b2031365d0004001c4172726179333204000401205b75383b2033325d00050010426c6f62040034011c5665633c75383e000600003d060c0c78636d08763220526573706f6e7365000110104e756c6c0000001841737365747304002506012c4d756c74694173736574730001003c457865637574696f6e526573756c740400410601504f7074696f6e3c287533322c204572726f72293e0002001c56657273696f6e040010013873757065723a3a56657273696f6e00030000410604184f7074696f6e0404540145060108104e6f6e6500000010536f6d65040045060000010000450600000408104906004906100c78636d08763218747261697473144572726f72000168204f766572666c6f7700000034556e696d706c656d656e74656400010060556e74727573746564526573657276654c6f636174696f6e00020064556e7472757374656454656c65706f72744c6f636174696f6e000300444d756c74694c6f636174696f6e46756c6c000400684d756c74694c6f636174696f6e4e6f74496e7665727469626c65000500244261644f726967696e0006003c496e76616c69644c6f636174696f6e0007003441737365744e6f74466f756e64000800544661696c6564546f5472616e7361637441737365740009003c4e6f74576974686472617761626c65000a00484c6f636174696f6e43616e6e6f74486f6c64000b0054457863656564734d61784d65737361676553697a65000c005844657374696e6174696f6e556e737570706f72746564000d00245472616e73706f7274000e0028556e726f757461626c65000f0030556e6b6e6f776e436c61696d001000384661696c6564546f4465636f6465001100404d6178576569676874496e76616c6964001200384e6f74486f6c64696e674665657300130030546f6f457870656e73697665001400105472617004002c010c7536340015004c556e68616e646c656458636d56657273696f6e001600485765696768744c696d69745265616368656404002c01185765696768740017001c426172726965720018004c5765696768744e6f74436f6d70757461626c65001900004d060c0c78636d087632284f726967696e4b696e64000110184e617469766500000040536f7665726569676e4163636f756e74000100245375706572757365720002000c58636d0003000051060c0c78636d38646f75626c655f656e636f64656434446f75626c65456e636f646564040454000004011c656e636f64656434011c5665633c75383e00005506100c78636d087632286d756c74696173736574404d756c7469417373657446696c74657200010820446566696e69746504002506012c4d756c74694173736574730000001057696c6404005906013857696c644d756c74694173736574000100005906100c78636d087632286d756c746961737365743857696c644d756c746941737365740001080c416c6c00000014416c6c4f6608010869643106011c4173736574496400010c66756e5d06013c57696c6446756e676962696c697479000100005d06100c78636d087632286d756c746961737365743c57696c6446756e676962696c6974790001082046756e6769626c650000002c4e6f6e46756e6769626c650001000061060c0c78636d0876322c5765696768744c696d697400010824556e6c696d697465640000001c4c696d69746564040028010c7536340001000065060c0c78636d0876330c58636d041043616c6c00000400690601585665633c496e737472756374696f6e3c43616c6c3e3e000069060000026d06006d060c0c78636d0876332c496e737472756374696f6e041043616c6c0001c0345769746864726177417373657404007106012c4d756c7469417373657473000000545265736572766541737365744465706f736974656404007106012c4d756c7469417373657473000100585265636569766554656c65706f72746564417373657404007106012c4d756c7469417373657473000200345175657279526573706f6e736510012071756572795f696428011c51756572794964000120726573706f6e736585060120526573706f6e73650001286d61785f77656967687424011857656967687400011c71756572696572ad0601544f7074696f6e3c4d756c74694c6f636174696f6e3e000300345472616e7366657241737365740801186173736574737106012c4d756c746941737365747300012c62656e6566696369617279090101344d756c74694c6f636174696f6e000400505472616e736665725265736572766541737365740c01186173736574737106012c4d756c746941737365747300011064657374090101344d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e000500205472616e736163740c012c6f726967696e5f6b696e644d0601284f726967696e4b696e64000158726571756972655f7765696768745f61745f6d6f737424011857656967687400011063616c6c5106014c446f75626c65456e636f6465643c43616c6c3e0006006448726d704e65774368616e6e656c4f70656e526571756573740c011873656e6465721501010c7533320001406d61785f6d6573736167655f73697a651501010c7533320001306d61785f63617061636974791501010c7533320007004c48726d704368616e6e656c4163636570746564040124726563697069656e741501010c7533320008004848726d704368616e6e656c436c6f73696e670c0124696e69746961746f721501010c75333200011873656e6465721501010c753332000124726563697069656e741501010c7533320009002c436c6561724f726967696e000a003444657363656e644f726967696e04000d010154496e746572696f724d756c74694c6f636174696f6e000b002c5265706f72744572726f720400b10601445175657279526573706f6e7365496e666f000c00304465706f7369744173736574080118617373657473b50601404d756c7469417373657446696c74657200012c62656e6566696369617279090101344d756c74694c6f636174696f6e000d004c4465706f7369745265736572766541737365740c0118617373657473b50601404d756c7469417373657446696c74657200011064657374090101344d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e000e003445786368616e676541737365740c011067697665b50601404d756c7469417373657446696c74657200011077616e747106012c4d756c746941737365747300011c6d6178696d616c780110626f6f6c000f005c496e6974696174655265736572766557697468647261770c0118617373657473b50601404d756c7469417373657446696c74657200011c72657365727665090101344d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e00100040496e69746961746554656c65706f72740c0118617373657473b50601404d756c7469417373657446696c74657200011064657374090101344d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e001100345265706f7274486f6c64696e67080134726573706f6e73655f696e666fb10601445175657279526573706f6e7365496e666f000118617373657473b50601404d756c7469417373657446696c74657200120030427579457865637574696f6e08011066656573790601284d756c746941737365740001307765696768745f6c696d6974c106012c5765696768744c696d697400130034526566756e64537572706c75730014003c5365744572726f7248616e646c657204006506012458636d3c43616c6c3e0015002c536574417070656e64697804006506012458636d3c43616c6c3e00160028436c6561724572726f7200170028436c61696d41737365740801186173736574737106012c4d756c74694173736574730001187469636b6574090101344d756c74694c6f636174696f6e0018001054726170040028010c7536340019004053756273637269626556657273696f6e08012071756572795f696428011c5175657279496400014c6d61785f726573706f6e73655f776569676874240118576569676874001a0048556e73756273637269626556657273696f6e001b00244275726e417373657404007106012c4d756c7469417373657473001c002c457870656374417373657404007106012c4d756c7469417373657473001d00304578706563744f726967696e0400ad0601544f7074696f6e3c4d756c74694c6f636174696f6e3e001e002c4578706563744572726f720400890601504f7074696f6e3c287533322c204572726f72293e001f00504578706563745472616e736163745374617475730400a50601384d617962654572726f72436f64650020002c517565727950616c6c657408012c6d6f64756c655f6e616d6534011c5665633c75383e000134726573706f6e73655f696e666fb10601445175657279526573706f6e7365496e666f0021003045787065637450616c6c6574140114696e6465781501010c7533320001106e616d6534011c5665633c75383e00012c6d6f64756c655f6e616d6534011c5665633c75383e00012c63726174655f6d616a6f721501010c75333200013c6d696e5f63726174655f6d696e6f721501010c753332002200505265706f72745472616e736163745374617475730400b10601445175657279526573706f6e7365496e666f0023004c436c6561725472616e736163745374617475730024003c556e6976657273616c4f726967696e0400110101204a756e6374696f6e002500344578706f72744d6573736167650c011c6e6574776f726b1d0101244e6574776f726b496400012c64657374696e6174696f6e0d010154496e746572696f724d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e002600244c6f636b41737365740801146173736574790601284d756c74694173736574000120756e6c6f636b6572090101344d756c74694c6f636174696f6e0027002c556e6c6f636b41737365740801146173736574790601284d756c74694173736574000118746172676574090101344d756c74694c6f636174696f6e002800384e6f7465556e6c6f636b61626c650801146173736574790601284d756c746941737365740001146f776e6572090101344d756c74694c6f636174696f6e0029003452657175657374556e6c6f636b0801146173736574790601284d756c746941737365740001186c6f636b6572090101344d756c74694c6f636174696f6e002a002c536574466565734d6f64650401306a69745f7769746864726177780110626f6f6c002b0020536574546f70696304000401205b75383b2033325d002c0028436c656172546f706963002d002c416c6961734f726967696e0400090101344d756c74694c6f636174696f6e002e003c556e70616964457865637574696f6e0801307765696768745f6c696d6974c106012c5765696768744c696d6974000130636865636b5f6f726967696ead0601544f7074696f6e3c4d756c74694c6f636174696f6e3e002f00007106100c78636d087633286d756c746961737365742c4d756c7469417373657473000004007506013c5665633c4d756c746941737365743e000075060000027906007906100c78636d087633286d756c74696173736574284d756c74694173736574000008010869642d01011c4173736574496400010c66756e7d06012c46756e676962696c69747900007d06100c78636d087633286d756c746961737365742c46756e676962696c6974790001082046756e6769626c650400f40110753132380000002c4e6f6e46756e6769626c650400810601344173736574496e7374616e6365000100008106100c78636d087633286d756c74696173736574344173736574496e7374616e636500011824556e646566696e656400000014496e6465780400f401107531323800010018417272617934040044011c5b75383b20345d0002001841727261793804003103011c5b75383b20385d0003001c417272617931360400c001205b75383b2031365d0004001c4172726179333204000401205b75383b2033325d0005000085060c0c78636d08763320526573706f6e7365000118104e756c6c0000001841737365747304007106012c4d756c74694173736574730001003c457865637574696f6e526573756c740400890601504f7074696f6e3c287533322c204572726f72293e0002001c56657273696f6e040010013873757065723a3a56657273696f6e0003002c50616c6c657473496e666f040095060198426f756e6465645665633c50616c6c6574496e666f2c204d617850616c6c657473496e666f3e000400384469737061746368526573756c740400a50601384d617962654572726f72436f646500050000890604184f7074696f6e040454018d060108104e6f6e6500000010536f6d6504008d0600000100008d0600000408109106009106100c78636d08763318747261697473144572726f720001a0204f766572666c6f7700000034556e696d706c656d656e74656400010060556e74727573746564526573657276654c6f636174696f6e00020064556e7472757374656454656c65706f72744c6f636174696f6e000300304c6f636174696f6e46756c6c000400544c6f636174696f6e4e6f74496e7665727469626c65000500244261644f726967696e0006003c496e76616c69644c6f636174696f6e0007003441737365744e6f74466f756e64000800544661696c6564546f5472616e7361637441737365740009003c4e6f74576974686472617761626c65000a00484c6f636174696f6e43616e6e6f74486f6c64000b0054457863656564734d61784d65737361676553697a65000c005844657374696e6174696f6e556e737570706f72746564000d00245472616e73706f7274000e0028556e726f757461626c65000f0030556e6b6e6f776e436c61696d001000384661696c6564546f4465636f6465001100404d6178576569676874496e76616c6964001200384e6f74486f6c64696e674665657300130030546f6f457870656e73697665001400105472617004002c010c753634001500404578706563746174696f6e46616c73650016003850616c6c65744e6f74466f756e64001700304e616d654d69736d617463680018004c56657273696f6e496e636f6d70617469626c6500190050486f6c64696e67576f756c644f766572666c6f77001a002c4578706f72744572726f72001b00385265616e63686f724661696c6564001c00184e6f4465616c001d0028466565734e6f744d6574001e00244c6f636b4572726f72001f00304e6f5065726d697373696f6e00200028556e616e63686f726564002100384e6f744465706f73697461626c650022004c556e68616e646c656458636d56657273696f6e002300485765696768744c696d69745265616368656404002401185765696768740024001c426172726965720025004c5765696768744e6f74436f6d70757461626c650026004445786365656473537461636b4c696d69740027000095060c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e646564566563080454019906045300000400a10601185665633c543e000099060c0c78636d0876332850616c6c6574496e666f0000180114696e6465781501010c7533320001106e616d659d060180426f756e6465645665633c75382c204d617850616c6c65744e616d654c656e3e00012c6d6f64756c655f6e616d659d060180426f756e6465645665633c75382c204d617850616c6c65744e616d654c656e3e0001146d616a6f721501010c7533320001146d696e6f721501010c75333200011470617463681501010c75333200009d060c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e0000a106000002990600a5060c0c78636d087633384d617962654572726f72436f646500010c1c53756363657373000000144572726f720400a906018c426f756e6465645665633c75382c204d617844697370617463684572726f724c656e3e000100385472756e63617465644572726f720400a906018c426f756e6465645665633c75382c204d617844697370617463684572726f724c656e3e00020000a9060c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e0000ad0604184f7074696f6e0404540109010108104e6f6e6500000010536f6d65040009010000010000b1060c0c78636d087633445175657279526573706f6e7365496e666f00000c012c64657374696e6174696f6e090101344d756c74694c6f636174696f6e00012071756572795f696428011c517565727949640001286d61785f7765696768742401185765696768740000b506100c78636d087633286d756c74696173736574404d756c7469417373657446696c74657200010820446566696e69746504007106012c4d756c74694173736574730000001057696c640400b906013857696c644d756c7469417373657400010000b906100c78636d087633286d756c746961737365743857696c644d756c746941737365740001100c416c6c00000014416c6c4f6608010869642d01011c4173736574496400010c66756ebd06013c57696c6446756e676962696c69747900010028416c6c436f756e74656404001501010c75333200020030416c6c4f66436f756e7465640c010869642d01011c4173736574496400010c66756ebd06013c57696c6446756e676962696c697479000114636f756e741501010c75333200030000bd06100c78636d087633286d756c746961737365743c57696c6446756e676962696c6974790001082046756e6769626c650000002c4e6f6e46756e6769626c6500010000c1060c0c78636d0876332c5765696768744c696d697400010824556e6c696d697465640000001c4c696d69746564040024011857656967687400010000c5060c2c73746167696e675f78636d0876340c58636d041043616c6c00000400c90601585665633c496e737472756374696f6e3c43616c6c3e3e0000c906000002cd0600cd060c2c73746167696e675f78636d0876342c496e737472756374696f6e041043616c6c0001c034576974686472617741737365740400d1060118417373657473000000545265736572766541737365744465706f73697465640400d1060118417373657473000100585265636569766554656c65706f7274656441737365740400d1060118417373657473000200345175657279526573706f6e736510012071756572795f696428011c51756572794964000120726573706f6e7365e5060120526573706f6e73650001286d61785f77656967687424011857656967687400011c71756572696572f90601404f7074696f6e3c4c6f636174696f6e3e000300345472616e736665724173736574080118617373657473d106011841737365747300012c62656e6566696369617279310101204c6f636174696f6e000400505472616e736665725265736572766541737365740c0118617373657473d106011841737365747300011064657374310101204c6f636174696f6e00010c78636dc506011c58636d3c28293e000500205472616e736163740c012c6f726967696e5f6b696e644d0601284f726967696e4b696e64000158726571756972655f7765696768745f61745f6d6f737424011857656967687400011063616c6c5106014c446f75626c65456e636f6465643c43616c6c3e0006006448726d704e65774368616e6e656c4f70656e526571756573740c011873656e6465721501010c7533320001406d61785f6d6573736167655f73697a651501010c7533320001306d61785f63617061636974791501010c7533320007004c48726d704368616e6e656c4163636570746564040124726563697069656e741501010c7533320008004848726d704368616e6e656c436c6f73696e670c0124696e69746961746f721501010c75333200011873656e6465721501010c753332000124726563697069656e741501010c7533320009002c436c6561724f726967696e000a003444657363656e644f726967696e040035010140496e746572696f724c6f636174696f6e000b002c5265706f72744572726f720400fd0601445175657279526573706f6e7365496e666f000c00304465706f73697441737365740801186173736574730107012c417373657446696c74657200012c62656e6566696369617279310101204c6f636174696f6e000d004c4465706f7369745265736572766541737365740c01186173736574730107012c417373657446696c74657200011064657374310101204c6f636174696f6e00010c78636dc506011c58636d3c28293e000e003445786368616e676541737365740c0110676976650107012c417373657446696c74657200011077616e74d106011841737365747300011c6d6178696d616c780110626f6f6c000f005c496e6974696174655265736572766557697468647261770c01186173736574730107012c417373657446696c74657200011c72657365727665310101204c6f636174696f6e00010c78636dc506011c58636d3c28293e00100040496e69746961746554656c65706f72740c01186173736574730107012c417373657446696c74657200011064657374310101204c6f636174696f6e00010c78636dc506011c58636d3c28293e001100345265706f7274486f6c64696e67080134726573706f6e73655f696e666ffd0601445175657279526573706f6e7365496e666f0001186173736574730107012c417373657446696c74657200120030427579457865637574696f6e08011066656573d906011441737365740001307765696768745f6c696d6974c106012c5765696768744c696d697400130034526566756e64537572706c75730014003c5365744572726f7248616e646c65720400c506012458636d3c43616c6c3e0015002c536574417070656e6469780400c506012458636d3c43616c6c3e00160028436c6561724572726f7200170028436c61696d4173736574080118617373657473d10601184173736574730001187469636b6574310101204c6f636174696f6e0018001054726170040028010c7536340019004053756273637269626556657273696f6e08012071756572795f696428011c5175657279496400014c6d61785f726573706f6e73655f776569676874240118576569676874001a0048556e73756273637269626556657273696f6e001b00244275726e41737365740400d1060118417373657473001c002c45787065637441737365740400d1060118417373657473001d00304578706563744f726967696e0400f90601404f7074696f6e3c4c6f636174696f6e3e001e002c4578706563744572726f720400890601504f7074696f6e3c287533322c204572726f72293e001f00504578706563745472616e736163745374617475730400a50601384d617962654572726f72436f64650020002c517565727950616c6c657408012c6d6f64756c655f6e616d6534011c5665633c75383e000134726573706f6e73655f696e666ffd0601445175657279526573706f6e7365496e666f0021003045787065637450616c6c6574140114696e6465781501010c7533320001106e616d6534011c5665633c75383e00012c6d6f64756c655f6e616d6534011c5665633c75383e00012c63726174655f6d616a6f721501010c75333200013c6d696e5f63726174655f6d696e6f721501010c753332002200505265706f72745472616e736163745374617475730400fd0601445175657279526573706f6e7365496e666f0023004c436c6561725472616e736163745374617475730024003c556e6976657273616c4f726967696e04003d0101204a756e6374696f6e002500344578706f72744d6573736167650c011c6e6574776f726b450101244e6574776f726b496400012c64657374696e6174696f6e35010140496e746572696f724c6f636174696f6e00010c78636dc506011c58636d3c28293e002600244c6f636b41737365740801146173736574d90601144173736574000120756e6c6f636b6572310101204c6f636174696f6e0027002c556e6c6f636b41737365740801146173736574d90601144173736574000118746172676574310101204c6f636174696f6e002800384e6f7465556e6c6f636b61626c650801146173736574d906011441737365740001146f776e6572310101204c6f636174696f6e0029003452657175657374556e6c6f636b0801146173736574d906011441737365740001186c6f636b6572310101204c6f636174696f6e002a002c536574466565734d6f64650401306a69745f7769746864726177780110626f6f6c002b0020536574546f70696304000401205b75383b2033325d002c0028436c656172546f706963002d002c416c6961734f726967696e0400310101204c6f636174696f6e002e003c556e70616964457865637574696f6e0801307765696768745f6c696d6974c106012c5765696768744c696d6974000130636865636b5f6f726967696ef90601404f7074696f6e3c4c6f636174696f6e3e002f0000d106102c73746167696e675f78636d0876341461737365741841737365747300000400d50601285665633c41737365743e0000d506000002d90600d906102c73746167696e675f78636d087634146173736574144173736574000008010869646501011c4173736574496400010c66756edd06012c46756e676962696c6974790000dd06102c73746167696e675f78636d0876341461737365742c46756e676962696c6974790001082046756e6769626c650400f40110753132380000002c4e6f6e46756e6769626c650400e10601344173736574496e7374616e636500010000e106102c73746167696e675f78636d087634146173736574344173736574496e7374616e636500011824556e646566696e656400000014496e6465780400f401107531323800010018417272617934040044011c5b75383b20345d0002001841727261793804003103011c5b75383b20385d0003001c417272617931360400c001205b75383b2031365d0004001c4172726179333204000401205b75383b2033325d00050000e5060c2c73746167696e675f78636d08763420526573706f6e7365000118104e756c6c000000184173736574730400d10601184173736574730001003c457865637574696f6e526573756c740400890601504f7074696f6e3c287533322c204572726f72293e0002001c56657273696f6e040010013873757065723a3a56657273696f6e0003002c50616c6c657473496e666f0400e9060198426f756e6465645665633c50616c6c6574496e666f2c204d617850616c6c657473496e666f3e000400384469737061746368526573756c740400a50601384d617962654572726f72436f646500050000e9060c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401ed06045300000400f50601185665633c543e0000ed060c2c73746167696e675f78636d0876342850616c6c6574496e666f0000180114696e6465781501010c7533320001106e616d65f1060180426f756e6465645665633c75382c204d617850616c6c65744e616d654c656e3e00012c6d6f64756c655f6e616d65f1060180426f756e6465645665633c75382c204d617850616c6c65744e616d654c656e3e0001146d616a6f721501010c7533320001146d696e6f721501010c75333200011470617463681501010c7533320000f1060c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e0000f506000002ed0600f90604184f7074696f6e0404540131010108104e6f6e6500000010536f6d65040031010000010000fd060c2c73746167696e675f78636d087634445175657279526573706f6e7365496e666f00000c012c64657374696e6174696f6e310101204c6f636174696f6e00012071756572795f696428011c517565727949640001286d61785f77656967687424011857656967687400000107102c73746167696e675f78636d0876341461737365742c417373657446696c74657200010820446566696e6974650400d10601184173736574730000001057696c6404000507012457696c644173736574000100000507102c73746167696e675f78636d0876341461737365742457696c6441737365740001100c416c6c00000014416c6c4f6608010869646501011c4173736574496400010c66756e0907013c57696c6446756e676962696c69747900010028416c6c436f756e74656404001501010c75333200020030416c6c4f66436f756e7465640c010869646501011c4173736574496400010c66756e0907013c57696c6446756e676962696c697479000114636f756e741501010c753332000300000907102c73746167696e675f78636d0876341461737365743c57696c6446756e676962696c6974790001082046756e6769626c650000002c4e6f6e46756e6769626c65000100000d07080c78636d3c56657273696f6e656441737365747300010c08563204002506013c76323a3a4d756c746941737365747300010008563304007106013c76333a3a4d756c74694173736574730003000856340400d106012876343a3a417373657473000400001107080c78636d3056657273696f6e656458636d042c52756e74696d6543616c6c00010c08563204001507015076323a3a58636d3c52756e74696d6543616c6c3e00020008563304002507015076333a3a58636d3c52756e74696d6543616c6c3e00030008563404003107015076343a3a58636d3c52756e74696d6543616c6c3e0004000015070c0c78636d0876320c58636d042c52756e74696d6543616c6c00000400190701745665633c496e737472756374696f6e3c52756e74696d6543616c6c3e3e000019070000021d07001d070c0c78636d0876322c496e737472756374696f6e042c52756e74696d6543616c6c000170345769746864726177417373657404002506012c4d756c7469417373657473000000545265736572766541737365744465706f736974656404002506012c4d756c7469417373657473000100585265636569766554656c65706f72746564417373657404002506012c4d756c7469417373657473000200345175657279526573706f6e73650c012071756572795f696428011c51756572794964000120726573706f6e73653d060120526573706f6e73650001286d61785f77656967687428010c753634000300345472616e7366657241737365740801186173736574732506012c4d756c746941737365747300012c62656e65666963696172796d0101344d756c74694c6f636174696f6e000400505472616e736665725265736572766541737365740c01186173736574732506012c4d756c7469417373657473000110646573746d0101344d756c74694c6f636174696f6e00010c78636d1906011c58636d3c28293e000500205472616e736163740c012c6f726967696e5f747970654d0601284f726967696e4b696e64000158726571756972655f7765696768745f61745f6d6f737428010c75363400011063616c6c21070168446f75626c65456e636f6465643c52756e74696d6543616c6c3e0006006448726d704e65774368616e6e656c4f70656e526571756573740c011873656e6465721501010c7533320001406d61785f6d6573736167655f73697a651501010c7533320001306d61785f63617061636974791501010c7533320007004c48726d704368616e6e656c4163636570746564040124726563697069656e741501010c7533320008004848726d704368616e6e656c436c6f73696e670c0124696e69746961746f721501010c75333200011873656e6465721501010c753332000124726563697069656e741501010c7533320009002c436c6561724f726967696e000a003444657363656e644f726967696e040071010154496e746572696f724d756c74694c6f636174696f6e000b002c5265706f72744572726f720c012071756572795f696428011c51756572794964000110646573746d0101344d756c74694c6f636174696f6e00014c6d61785f726573706f6e73655f77656967687428010c753634000c00304465706f73697441737365740c0118617373657473550601404d756c7469417373657446696c7465720001286d61785f6173736574731501010c75333200012c62656e65666963696172796d0101344d756c74694c6f636174696f6e000d004c4465706f736974526573657276654173736574100118617373657473550601404d756c7469417373657446696c7465720001286d61785f6173736574731501010c753332000110646573746d0101344d756c74694c6f636174696f6e00010c78636d1906011c58636d3c28293e000e003445786368616e6765417373657408011067697665550601404d756c7469417373657446696c74657200011c726563656976652506012c4d756c7469417373657473000f005c496e6974696174655265736572766557697468647261770c0118617373657473550601404d756c7469417373657446696c74657200011c726573657276656d0101344d756c74694c6f636174696f6e00010c78636d1906011c58636d3c28293e00100040496e69746961746554656c65706f72740c0118617373657473550601404d756c7469417373657446696c746572000110646573746d0101344d756c74694c6f636174696f6e00010c78636d1906011c58636d3c28293e001100305175657279486f6c64696e6710012071756572795f696428011c51756572794964000110646573746d0101344d756c74694c6f636174696f6e000118617373657473550601404d756c7469417373657446696c74657200014c6d61785f726573706f6e73655f77656967687428010c75363400120030427579457865637574696f6e080110666565732d0601284d756c746941737365740001307765696768745f6c696d69746106012c5765696768744c696d697400130034526566756e64537572706c75730014003c5365744572726f7248616e646c657204001507014058636d3c52756e74696d6543616c6c3e0015002c536574417070656e64697804001507014058636d3c52756e74696d6543616c6c3e00160028436c6561724572726f7200170028436c61696d41737365740801186173736574732506012c4d756c74694173736574730001187469636b65746d0101344d756c74694c6f636174696f6e0018001054726170040028010c7536340019004053756273637269626556657273696f6e08012071756572795f696428011c5175657279496400014c6d61785f726573706f6e73655f77656967687428010c753634001a0048556e73756273637269626556657273696f6e001b000021070c0c78636d38646f75626c655f656e636f64656434446f75626c65456e636f646564040454000004011c656e636f64656434011c5665633c75383e000025070c0c78636d0876330c58636d041043616c6c00000400290701585665633c496e737472756374696f6e3c43616c6c3e3e000029070000022d07002d070c0c78636d0876332c496e737472756374696f6e041043616c6c0001c0345769746864726177417373657404007106012c4d756c7469417373657473000000545265736572766541737365744465706f736974656404007106012c4d756c7469417373657473000100585265636569766554656c65706f72746564417373657404007106012c4d756c7469417373657473000200345175657279526573706f6e736510012071756572795f696428011c51756572794964000120726573706f6e736585060120526573706f6e73650001286d61785f77656967687424011857656967687400011c71756572696572ad0601544f7074696f6e3c4d756c74694c6f636174696f6e3e000300345472616e7366657241737365740801186173736574737106012c4d756c746941737365747300012c62656e6566696369617279090101344d756c74694c6f636174696f6e000400505472616e736665725265736572766541737365740c01186173736574737106012c4d756c746941737365747300011064657374090101344d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e000500205472616e736163740c012c6f726967696e5f6b696e644d0601284f726967696e4b696e64000158726571756972655f7765696768745f61745f6d6f737424011857656967687400011063616c6c2107014c446f75626c65456e636f6465643c43616c6c3e0006006448726d704e65774368616e6e656c4f70656e526571756573740c011873656e6465721501010c7533320001406d61785f6d6573736167655f73697a651501010c7533320001306d61785f63617061636974791501010c7533320007004c48726d704368616e6e656c4163636570746564040124726563697069656e741501010c7533320008004848726d704368616e6e656c436c6f73696e670c0124696e69746961746f721501010c75333200011873656e6465721501010c753332000124726563697069656e741501010c7533320009002c436c6561724f726967696e000a003444657363656e644f726967696e04000d010154496e746572696f724d756c74694c6f636174696f6e000b002c5265706f72744572726f720400b10601445175657279526573706f6e7365496e666f000c00304465706f7369744173736574080118617373657473b50601404d756c7469417373657446696c74657200012c62656e6566696369617279090101344d756c74694c6f636174696f6e000d004c4465706f7369745265736572766541737365740c0118617373657473b50601404d756c7469417373657446696c74657200011064657374090101344d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e000e003445786368616e676541737365740c011067697665b50601404d756c7469417373657446696c74657200011077616e747106012c4d756c746941737365747300011c6d6178696d616c780110626f6f6c000f005c496e6974696174655265736572766557697468647261770c0118617373657473b50601404d756c7469417373657446696c74657200011c72657365727665090101344d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e00100040496e69746961746554656c65706f72740c0118617373657473b50601404d756c7469417373657446696c74657200011064657374090101344d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e001100345265706f7274486f6c64696e67080134726573706f6e73655f696e666fb10601445175657279526573706f6e7365496e666f000118617373657473b50601404d756c7469417373657446696c74657200120030427579457865637574696f6e08011066656573790601284d756c746941737365740001307765696768745f6c696d6974c106012c5765696768744c696d697400130034526566756e64537572706c75730014003c5365744572726f7248616e646c657204002507012458636d3c43616c6c3e0015002c536574417070656e64697804002507012458636d3c43616c6c3e00160028436c6561724572726f7200170028436c61696d41737365740801186173736574737106012c4d756c74694173736574730001187469636b6574090101344d756c74694c6f636174696f6e0018001054726170040028010c7536340019004053756273637269626556657273696f6e08012071756572795f696428011c5175657279496400014c6d61785f726573706f6e73655f776569676874240118576569676874001a0048556e73756273637269626556657273696f6e001b00244275726e417373657404007106012c4d756c7469417373657473001c002c457870656374417373657404007106012c4d756c7469417373657473001d00304578706563744f726967696e0400ad0601544f7074696f6e3c4d756c74694c6f636174696f6e3e001e002c4578706563744572726f720400890601504f7074696f6e3c287533322c204572726f72293e001f00504578706563745472616e736163745374617475730400a50601384d617962654572726f72436f64650020002c517565727950616c6c657408012c6d6f64756c655f6e616d6534011c5665633c75383e000134726573706f6e73655f696e666fb10601445175657279526573706f6e7365496e666f0021003045787065637450616c6c6574140114696e6465781501010c7533320001106e616d6534011c5665633c75383e00012c6d6f64756c655f6e616d6534011c5665633c75383e00012c63726174655f6d616a6f721501010c75333200013c6d696e5f63726174655f6d696e6f721501010c753332002200505265706f72745472616e736163745374617475730400b10601445175657279526573706f6e7365496e666f0023004c436c6561725472616e736163745374617475730024003c556e6976657273616c4f726967696e0400110101204a756e6374696f6e002500344578706f72744d6573736167650c011c6e6574776f726b1d0101244e6574776f726b496400012c64657374696e6174696f6e0d010154496e746572696f724d756c74694c6f636174696f6e00010c78636d6506011c58636d3c28293e002600244c6f636b41737365740801146173736574790601284d756c74694173736574000120756e6c6f636b6572090101344d756c74694c6f636174696f6e0027002c556e6c6f636b41737365740801146173736574790601284d756c74694173736574000118746172676574090101344d756c74694c6f636174696f6e002800384e6f7465556e6c6f636b61626c650801146173736574790601284d756c746941737365740001146f776e6572090101344d756c74694c6f636174696f6e0029003452657175657374556e6c6f636b0801146173736574790601284d756c746941737365740001186c6f636b6572090101344d756c74694c6f636174696f6e002a002c536574466565734d6f64650401306a69745f7769746864726177780110626f6f6c002b0020536574546f70696304000401205b75383b2033325d002c0028436c656172546f706963002d002c416c6961734f726967696e0400090101344d756c74694c6f636174696f6e002e003c556e70616964457865637574696f6e0801307765696768745f6c696d6974c106012c5765696768744c696d6974000130636865636b5f6f726967696ead0601544f7074696f6e3c4d756c74694c6f636174696f6e3e002f000031070c2c73746167696e675f78636d0876340c58636d041043616c6c00000400350701585665633c496e737472756374696f6e3c43616c6c3e3e0000350700000239070039070c2c73746167696e675f78636d0876342c496e737472756374696f6e041043616c6c0001c034576974686472617741737365740400d1060118417373657473000000545265736572766541737365744465706f73697465640400d1060118417373657473000100585265636569766554656c65706f7274656441737365740400d1060118417373657473000200345175657279526573706f6e736510012071756572795f696428011c51756572794964000120726573706f6e7365e5060120526573706f6e73650001286d61785f77656967687424011857656967687400011c71756572696572f90601404f7074696f6e3c4c6f636174696f6e3e000300345472616e736665724173736574080118617373657473d106011841737365747300012c62656e6566696369617279310101204c6f636174696f6e000400505472616e736665725265736572766541737365740c0118617373657473d106011841737365747300011064657374310101204c6f636174696f6e00010c78636dc506011c58636d3c28293e000500205472616e736163740c012c6f726967696e5f6b696e644d0601284f726967696e4b696e64000158726571756972655f7765696768745f61745f6d6f737424011857656967687400011063616c6c2107014c446f75626c65456e636f6465643c43616c6c3e0006006448726d704e65774368616e6e656c4f70656e526571756573740c011873656e6465721501010c7533320001406d61785f6d6573736167655f73697a651501010c7533320001306d61785f63617061636974791501010c7533320007004c48726d704368616e6e656c4163636570746564040124726563697069656e741501010c7533320008004848726d704368616e6e656c436c6f73696e670c0124696e69746961746f721501010c75333200011873656e6465721501010c753332000124726563697069656e741501010c7533320009002c436c6561724f726967696e000a003444657363656e644f726967696e040035010140496e746572696f724c6f636174696f6e000b002c5265706f72744572726f720400fd0601445175657279526573706f6e7365496e666f000c00304465706f73697441737365740801186173736574730107012c417373657446696c74657200012c62656e6566696369617279310101204c6f636174696f6e000d004c4465706f7369745265736572766541737365740c01186173736574730107012c417373657446696c74657200011064657374310101204c6f636174696f6e00010c78636dc506011c58636d3c28293e000e003445786368616e676541737365740c0110676976650107012c417373657446696c74657200011077616e74d106011841737365747300011c6d6178696d616c780110626f6f6c000f005c496e6974696174655265736572766557697468647261770c01186173736574730107012c417373657446696c74657200011c72657365727665310101204c6f636174696f6e00010c78636dc506011c58636d3c28293e00100040496e69746961746554656c65706f72740c01186173736574730107012c417373657446696c74657200011064657374310101204c6f636174696f6e00010c78636dc506011c58636d3c28293e001100345265706f7274486f6c64696e67080134726573706f6e73655f696e666ffd0601445175657279526573706f6e7365496e666f0001186173736574730107012c417373657446696c74657200120030427579457865637574696f6e08011066656573d906011441737365740001307765696768745f6c696d6974c106012c5765696768744c696d697400130034526566756e64537572706c75730014003c5365744572726f7248616e646c657204003107012458636d3c43616c6c3e0015002c536574417070656e64697804003107012458636d3c43616c6c3e00160028436c6561724572726f7200170028436c61696d4173736574080118617373657473d10601184173736574730001187469636b6574310101204c6f636174696f6e0018001054726170040028010c7536340019004053756273637269626556657273696f6e08012071756572795f696428011c5175657279496400014c6d61785f726573706f6e73655f776569676874240118576569676874001a0048556e73756273637269626556657273696f6e001b00244275726e41737365740400d1060118417373657473001c002c45787065637441737365740400d1060118417373657473001d00304578706563744f726967696e0400f90601404f7074696f6e3c4c6f636174696f6e3e001e002c4578706563744572726f720400890601504f7074696f6e3c287533322c204572726f72293e001f00504578706563745472616e736163745374617475730400a50601384d617962654572726f72436f64650020002c517565727950616c6c657408012c6d6f64756c655f6e616d6534011c5665633c75383e000134726573706f6e73655f696e666ffd0601445175657279526573706f6e7365496e666f0021003045787065637450616c6c6574140114696e6465781501010c7533320001106e616d6534011c5665633c75383e00012c6d6f64756c655f6e616d6534011c5665633c75383e00012c63726174655f6d616a6f721501010c75333200013c6d696e5f63726174655f6d696e6f721501010c753332002200505265706f72745472616e736163745374617475730400fd0601445175657279526573706f6e7365496e666f0023004c436c6561725472616e736163745374617475730024003c556e6976657273616c4f726967696e04003d0101204a756e6374696f6e002500344578706f72744d6573736167650c011c6e6574776f726b450101244e6574776f726b496400012c64657374696e6174696f6e35010140496e746572696f724c6f636174696f6e00010c78636dc506011c58636d3c28293e002600244c6f636b41737365740801146173736574d90601144173736574000120756e6c6f636b6572310101204c6f636174696f6e0027002c556e6c6f636b41737365740801146173736574d90601144173736574000118746172676574310101204c6f636174696f6e002800384e6f7465556e6c6f636b61626c650801146173736574d906011441737365740001146f776e6572310101204c6f636174696f6e0029003452657175657374556e6c6f636b0801146173736574d906011441737365740001186c6f636b6572310101204c6f636174696f6e002a002c536574466565734d6f64650401306a69745f7769746864726177780110626f6f6c002b0020536574546f70696304000401205b75383b2033325d002c0028436c656172546f706963002d002c416c6961734f726967696e0400310101204c6f636174696f6e002e003c556e70616964457865637574696f6e0801307765696768745f6c696d6974c106012c5765696768744c696d6974000130636865636b5f6f726967696ef90601404f7074696f6e3c4c6f636174696f6e3e002f00003d070c5070616c6c65745f6d6573736167655f71756575651870616c6c65741043616c6c04045400010824726561705f706167650801386d6573736167655f6f726967696e410701484d6573736167654f726967696e4f663c543e000128706167655f696e64657810012450616765496e64657800000468536565205b6050616c6c65743a3a726561705f70616765605d2e48657865637574655f6f7665727765696768741001386d6573736167655f6f726967696e410701484d6573736167654f726967696e4f663c543e0001107061676510012450616765496e646578000114696e64657810011c543a3a53697a650001307765696768745f6c696d69742401185765696768740001048c536565205b6050616c6c65743a3a657865637574655f6f766572776569676874605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e41070c6c706f6c6b61646f745f72756e74696d655f70617261636861696e7324696e636c7573696f6e584167677265676174654d6573736167654f726967696e0001040c556d70040045070128556d70517565756549640000000045070c6c706f6c6b61646f745f72756e74696d655f70617261636861696e7324696e636c7573696f6e28556d705175657565496400010410506172610400b90201185061726149640000000049070c4470616c6c65745f61737365745f726174651870616c6c65741043616c6c04045400010c1863726561746508012861737365745f6b696e6405010144426f783c543a3a41737365744b696e643e000110726174654d0701244669786564553132380000045c536565205b6050616c6c65743a3a637265617465605d2e1875706461746508012861737365745f6b696e6405010144426f783c543a3a41737365744b696e643e000110726174654d0701244669786564553132380001045c536565205b6050616c6c65743a3a757064617465605d2e1872656d6f766504012861737365745f6b696e6405010144426f783c543a3a41737365744b696e643e0002045c536565205b6050616c6c65743a3a72656d6f7665605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e4d070c3473705f61726974686d657469632c66697865645f706f696e74244669786564553132380000040018011075313238000051070c3070616c6c65745f62656566791870616c6c65741043616c6c04045400010c4c7265706f72745f65717569766f636174696f6e08014865717569766f636174696f6e5f70726f6f665507018d01426f783c45717569766f636174696f6e50726f6f663c426c6f636b4e756d626572466f723c543e2c20543a3a426565667949642c3c543a3a426565667949640a61732052756e74696d654170705075626c69633e3a3a5369676e61747572652c3e2c3e00013c6b65795f6f776e65725f70726f6f66d1010140543a3a4b65794f776e657250726f6f6600000490536565205b6050616c6c65743a3a7265706f72745f65717569766f636174696f6e605d2e707265706f72745f65717569766f636174696f6e5f756e7369676e656408014865717569766f636174696f6e5f70726f6f665507018d01426f783c45717569766f636174696f6e50726f6f663c426c6f636b4e756d626572466f723c543e2c20543a3a426565667949642c3c543a3a426565667949640a61732052756e74696d654170705075626c69633e3a3a5369676e61747572652c3e2c3e00013c6b65795f6f776e65725f70726f6f66d1010140543a3a4b65794f776e657250726f6f66000104b4536565205b6050616c6c65743a3a7265706f72745f65717569766f636174696f6e5f756e7369676e6564605d2e3c7365745f6e65775f67656e6573697304013c64656c61795f696e5f626c6f636b73100144426c6f636b4e756d626572466f723c543e00020480536565205b6050616c6c65743a3a7365745f6e65775f67656e65736973605d2e040d01436f6e7461696e7320612076617269616e742070657220646973706174636861626c652065787472696e736963207468617420746869732070616c6c6574206861732e5507084873705f636f6e73656e7375735f62656566794445717569766f636174696f6e50726f6f660c184e756d6265720110084964014d02245369676e61747572650159070008011466697273745d070188566f74654d6573736167653c4e756d6265722c2049642c205369676e61747572653e0001187365636f6e645d070188566f74654d6573736167653c4e756d6265722c2049642c205369676e61747572653e000059070c4873705f636f6e73656e7375735f62656566793065636473615f63727970746f245369676e617475726500000400a903014065636473613a3a5369676e617475726500005d07084873705f636f6e73656e7375735f62656566792c566f74654d6573736167650c184e756d6265720110084964014d02245369676e6174757265015907000c0128636f6d6d69746d656e7461070148436f6d6d69746d656e743c4e756d6265723e00010869644d02010849640001247369676e6174757265590701245369676e6174757265000061070c4873705f636f6e73656e7375735f626565667928636f6d6d69746d656e7428436f6d6d69746d656e74043054426c6f636b4e756d6265720110000c011c7061796c6f61646507011c5061796c6f6164000130626c6f636b5f6e756d62657210013054426c6f636b4e756d62657200014076616c696461746f725f7365745f69642c013856616c696461746f725365744964000065070c4873705f636f6e73656e7375735f62656566791c7061796c6f61641c5061796c6f616400000400690701785665633c2842656566795061796c6f616449642c205665633c75383e293e000069070000026d07006d07000004081d03340071070c2873705f72756e74696d65187472616974732c426c616b6554776f3235360000000075070c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e000079070c6070616c6c65745f636f6e76696374696f6e5f766f74696e671474797065731454616c6c790814566f746573011814546f74616c00000c011061796573180114566f7465730001106e617973180114566f74657300011c737570706f7274180114566f74657300007d070c4070616c6c65745f77686974656c6973741870616c6c6574144576656e7404045400010c3c43616c6c57686974656c697374656404012463616c6c5f6861736830011c543a3a486173680000005857686974656c697374656443616c6c52656d6f76656404012463616c6c5f6861736830011c543a3a486173680001006457686974656c697374656443616c6c4469737061746368656408012463616c6c5f6861736830011c543a3a48617368000118726573756c74810701684469737061746368526573756c7457697468506f7374496e666f000200047c54686520604576656e746020656e756d206f6620746869732070616c6c657481070418526573756c740804540185070445018d070108084f6b04008507000000000c45727204008d07000001000085070c346672616d655f737570706f727420646973706174636840506f73744469737061746368496e666f000008013461637475616c5f776569676874890701384f7074696f6e3c5765696768743e000120706179735f666565600110506179730000890704184f7074696f6e04045401240108104e6f6e6500000010536f6d6504002400000100008d07082873705f72756e74696d656444697370617463684572726f7257697468506f7374496e666f0410496e666f01850700080124706f73745f696e666f85070110496e666f0001146572726f7264013444697370617463684572726f7200009107105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e18636c61696d731870616c6c6574144576656e740404540001041c436c61696d65640c010c77686f000130543a3a4163636f756e744964000140657468657265756d5f61646472657373dd02013c457468657265756d41646472657373000118616d6f756e7418013042616c616e63654f663c543e00000468536f6d656f6e6520636c61696d656420736f6d6520444f54732e047c54686520604576656e746020656e756d206f6620746869732070616c6c657495070c3870616c6c65745f76657374696e671870616c6c6574144576656e740404540001083856657374696e675570646174656408011c6163636f756e74000130543a3a4163636f756e744964000120756e76657374656418013042616c616e63654f663c543e000008510154686520616d6f756e742076657374656420686173206265656e20757064617465642e205468697320636f756c6420696e6469636174652061206368616e676520696e2066756e647320617661696c61626c652e25015468652062616c616e636520676976656e2069732074686520616d6f756e74207768696368206973206c65667420756e7665737465642028616e642074687573206c6f636b6564292e4056657374696e67436f6d706c6574656404011c6163636f756e74000130543a3a4163636f756e7449640001049c416e205c5b6163636f756e745c5d20686173206265636f6d652066756c6c79207665737465642e047c54686520604576656e746020656e756d206f6620746869732070616c6c657499070c3870616c6c65745f7574696c6974791870616c6c6574144576656e74000118404261746368496e746572727570746564080114696e64657810010c7533320001146572726f7264013444697370617463684572726f7200000855014261746368206f66206469737061746368657320646964206e6f7420636f6d706c6574652066756c6c792e20496e646578206f66206669727374206661696c696e6720646973706174636820676976656e2c2061734877656c6c20617320746865206572726f722e384261746368436f6d706c65746564000104c84261746368206f66206469737061746368657320636f6d706c657465642066756c6c792077697468206e6f206572726f722e604261746368436f6d706c65746564576974684572726f7273000204b44261746368206f66206469737061746368657320636f6d706c657465642062757420686173206572726f72732e344974656d436f6d706c657465640003041d01412073696e676c65206974656d2077697468696e2061204261746368206f6620646973706174636865732068617320636f6d706c657465642077697468206e6f206572726f722e284974656d4661696c65640401146572726f7264013444697370617463684572726f720004041101412073696e676c65206974656d2077697468696e2061204261746368206f6620646973706174636865732068617320636f6d706c657465642077697468206572726f722e30446973706174636865644173040118726573756c748801384469737061746368526573756c7400050458412063616c6c2077617320646973706174636865642e047c54686520604576656e746020656e756d206f6620746869732070616c6c65749d070c3c70616c6c65745f6964656e746974791870616c6c6574144576656e740404540001442c4964656e7469747953657404010c77686f000130543a3a4163636f756e744964000004ec41206e616d652077617320736574206f72207265736574202877686963682077696c6c2072656d6f766520616c6c206a756467656d656e7473292e3c4964656e74697479436c656172656408010c77686f000130543a3a4163636f756e74496400011c6465706f73697418013042616c616e63654f663c543e000104cc41206e616d652077617320636c65617265642c20616e642074686520676976656e2062616c616e63652072657475726e65642e384964656e746974794b696c6c656408010c77686f000130543a3a4163636f756e74496400011c6465706f73697418013042616c616e63654f663c543e000204c441206e616d65207761732072656d6f76656420616e642074686520676976656e2062616c616e636520736c61736865642e484a756467656d656e7452657175657374656408010c77686f000130543a3a4163636f756e74496400013c7265676973747261725f696e646578100138526567697374726172496e6465780003049c41206a756467656d656e74207761732061736b65642066726f6d2061207265676973747261722e504a756467656d656e74556e72657175657374656408010c77686f000130543a3a4163636f756e74496400013c7265676973747261725f696e646578100138526567697374726172496e6465780004048841206a756467656d656e74207265717565737420776173207265747261637465642e384a756467656d656e74476976656e080118746172676574000130543a3a4163636f756e74496400013c7265676973747261725f696e646578100138526567697374726172496e6465780005049441206a756467656d656e742077617320676976656e2062792061207265676973747261722e38526567697374726172416464656404013c7265676973747261725f696e646578100138526567697374726172496e646578000604584120726567697374726172207761732061646465642e405375624964656e7469747941646465640c010c737562000130543a3a4163636f756e7449640001106d61696e000130543a3a4163636f756e74496400011c6465706f73697418013042616c616e63654f663c543e000704f441207375622d6964656e746974792077617320616464656420746f20616e206964656e7469747920616e6420746865206465706f73697420706169642e485375624964656e7469747952656d6f7665640c010c737562000130543a3a4163636f756e7449640001106d61696e000130543a3a4163636f756e74496400011c6465706f73697418013042616c616e63654f663c543e000804090141207375622d6964656e74697479207761732072656d6f7665642066726f6d20616e206964656e7469747920616e6420746865206465706f7369742066726565642e485375624964656e746974795265766f6b65640c010c737562000130543a3a4163636f756e7449640001106d61696e000130543a3a4163636f756e74496400011c6465706f73697418013042616c616e63654f663c543e000908190141207375622d6964656e746974792077617320636c65617265642c20616e642074686520676976656e206465706f7369742072657061747269617465642066726f6d20746865c86d61696e206964656e74697479206163636f756e7420746f20746865207375622d6964656e74697479206163636f756e742e38417574686f726974794164646564040124617574686f72697479000130543a3a4163636f756e744964000a047c4120757365726e616d6520617574686f72697479207761732061646465642e40417574686f7269747952656d6f766564040124617574686f72697479000130543a3a4163636f756e744964000b04844120757365726e616d6520617574686f72697479207761732072656d6f7665642e2c557365726e616d6553657408010c77686f000130543a3a4163636f756e744964000120757365726e616d65ad03012c557365726e616d653c543e000c04744120757365726e616d65207761732073657420666f72206077686f602e38557365726e616d655175657565640c010c77686f000130543a3a4163636f756e744964000120757365726e616d65ad03012c557365726e616d653c543e00012865787069726174696f6e100144426c6f636b4e756d626572466f723c543e000d0419014120757365726e616d6520776173207175657565642c20627574206077686f60206d75737420616363657074206974207072696f7220746f206065787069726174696f6e602e48507265617070726f76616c4578706972656404011477686f7365000130543a3a4163636f756e744964000e043901412071756575656420757365726e616d6520706173736564206974732065787069726174696f6e20776974686f7574206265696e6720636c61696d656420616e64207761732072656d6f7665642e485072696d617279557365726e616d6553657408010c77686f000130543a3a4163636f756e744964000120757365726e616d65ad03012c557365726e616d653c543e000f0401014120757365726e616d6520776173207365742061732061207072696d61727920616e642063616e206265206c6f6f6b65642075702066726f6d206077686f602e5c44616e676c696e67557365726e616d6552656d6f76656408010c77686f000130543a3a4163636f756e744964000120757365726e616d65ad03012c557365726e616d653c543e0010085d01412064616e676c696e6720757365726e616d652028617320696e2c206120757365726e616d6520636f72726573706f6e64696e6720746f20616e206163636f756e742074686174206861732072656d6f766564206974736c6964656e746974792920686173206265656e2072656d6f7665642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574a1070c3070616c6c65745f70726f78791870616c6c6574144576656e740404540001143450726f78794578656375746564040118726573756c748801384469737061746368526573756c74000004bc412070726f78792077617320657865637574656420636f72726563746c792c20776974682074686520676976656e2e2c507572654372656174656410011070757265000130543a3a4163636f756e74496400010c77686f000130543a3a4163636f756e74496400012870726f78795f74797065b9030130543a3a50726f787954797065000150646973616d626967756174696f6e5f696e6465789101010c753136000108dc412070757265206163636f756e7420686173206265656e2063726561746564206279206e65772070726f7879207769746820676976656e90646973616d626967756174696f6e20696e64657820616e642070726f787920747970652e24416e6e6f756e6365640c01107265616c000130543a3a4163636f756e74496400011470726f7879000130543a3a4163636f756e74496400012463616c6c5f6861736830013443616c6c486173684f663c543e000204e0416e20616e6e6f756e63656d656e742077617320706c6163656420746f206d616b6520612063616c6c20696e20746865206675747572652e2850726f7879416464656410012464656c656761746f72000130543a3a4163636f756e74496400012464656c656761746565000130543a3a4163636f756e74496400012870726f78795f74797065b9030130543a3a50726f78795479706500011464656c6179100144426c6f636b4e756d626572466f723c543e00030448412070726f7879207761732061646465642e3050726f787952656d6f76656410012464656c656761746f72000130543a3a4163636f756e74496400012464656c656761746565000130543a3a4163636f756e74496400012870726f78795f74797065b9030130543a3a50726f78795479706500011464656c6179100144426c6f636b4e756d626572466f723c543e00040450412070726f7879207761732072656d6f7665642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574a5070c3c70616c6c65745f6d756c74697369671870616c6c6574144576656e740404540001102c4e65774d756c74697369670c0124617070726f76696e67000130543a3a4163636f756e7449640001206d756c7469736967000130543a3a4163636f756e74496400012463616c6c5f6861736804012043616c6c486173680000048c41206e6577206d756c7469736967206f7065726174696f6e2068617320626567756e2e404d756c7469736967417070726f76616c100124617070726f76696e67000130543a3a4163636f756e74496400012474696d65706f696e74c503017054696d65706f696e743c426c6f636b4e756d626572466f723c543e3e0001206d756c7469736967000130543a3a4163636f756e74496400012463616c6c5f6861736804012043616c6c48617368000104c841206d756c7469736967206f7065726174696f6e20686173206265656e20617070726f76656420627920736f6d656f6e652e404d756c74697369674578656375746564140124617070726f76696e67000130543a3a4163636f756e74496400012474696d65706f696e74c503017054696d65706f696e743c426c6f636b4e756d626572466f723c543e3e0001206d756c7469736967000130543a3a4163636f756e74496400012463616c6c5f6861736804012043616c6c48617368000118726573756c748801384469737061746368526573756c740002049c41206d756c7469736967206f7065726174696f6e20686173206265656e2065786563757465642e444d756c746973696743616e63656c6c656410012863616e63656c6c696e67000130543a3a4163636f756e74496400012474696d65706f696e74c503017054696d65706f696e743c426c6f636b4e756d626572466f723c543e3e0001206d756c7469736967000130543a3a4163636f756e74496400012463616c6c5f6861736804012043616c6c48617368000304a041206d756c7469736967206f7065726174696f6e20686173206265656e2063616e63656c6c65642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574a9070c3c70616c6c65745f626f756e746965731870616c6c6574144576656e7408045400044900012c38426f756e747950726f706f736564040114696e64657810012c426f756e7479496e646578000004504e657720626f756e74792070726f706f73616c2e38426f756e747952656a6563746564080114696e64657810012c426f756e7479496e646578000110626f6e6418013c42616c616e63654f663c542c20493e000104cc4120626f756e74792070726f706f73616c207761732072656a65637465643b2066756e6473207765726520736c61736865642e48426f756e7479426563616d65416374697665040114696e64657810012c426f756e7479496e646578000204b84120626f756e74792070726f706f73616c2069732066756e64656420616e6420626563616d65206163746976652e34426f756e747941776172646564080114696e64657810012c426f756e7479496e64657800012c62656e6566696369617279000130543a3a4163636f756e744964000304944120626f756e7479206973206177617264656420746f20612062656e65666963696172792e34426f756e7479436c61696d65640c0114696e64657810012c426f756e7479496e6465780001187061796f757418013c42616c616e63654f663c542c20493e00012c62656e6566696369617279000130543a3a4163636f756e7449640004048c4120626f756e747920697320636c61696d65642062792062656e65666963696172792e38426f756e747943616e63656c6564040114696e64657810012c426f756e7479496e646578000504584120626f756e74792069732063616e63656c6c65642e38426f756e7479457874656e646564040114696e64657810012c426f756e7479496e646578000604704120626f756e74792065787069727920697320657874656e6465642e38426f756e7479417070726f766564040114696e64657810012c426f756e7479496e646578000704544120626f756e747920697320617070726f7665642e3c43757261746f7250726f706f736564080124626f756e74795f696410012c426f756e7479496e64657800011c63757261746f72000130543a3a4163636f756e744964000804744120626f756e74792063757261746f722069732070726f706f7365642e4443757261746f72556e61737369676e6564040124626f756e74795f696410012c426f756e7479496e6465780009047c4120626f756e74792063757261746f7220697320756e61737369676e65642e3c43757261746f724163636570746564080124626f756e74795f696410012c426f756e7479496e64657800011c63757261746f72000130543a3a4163636f756e744964000a04744120626f756e74792063757261746f722069732061636365707465642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574ad070c5470616c6c65745f6368696c645f626f756e746965731870616c6c6574144576656e74040454000110144164646564080114696e64657810012c426f756e7479496e64657800012c6368696c645f696e64657810012c426f756e7479496e6465780000046041206368696c642d626f756e74792069732061646465642e1c417761726465640c0114696e64657810012c426f756e7479496e64657800012c6368696c645f696e64657810012c426f756e7479496e64657800012c62656e6566696369617279000130543a3a4163636f756e744964000104ac41206368696c642d626f756e7479206973206177617264656420746f20612062656e65666963696172792e1c436c61696d6564100114696e64657810012c426f756e7479496e64657800012c6368696c645f696e64657810012c426f756e7479496e6465780001187061796f757418013042616c616e63654f663c543e00012c62656e6566696369617279000130543a3a4163636f756e744964000204a441206368696c642d626f756e747920697320636c61696d65642062792062656e65666963696172792e2043616e63656c6564080114696e64657810012c426f756e7479496e64657800012c6368696c645f696e64657810012c426f756e7479496e6465780003047041206368696c642d626f756e74792069732063616e63656c6c65642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574b1070c9070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f70686173651870616c6c6574144576656e7404045400011838536f6c7574696f6e53746f7265640c011c636f6d70757465b507013c456c656374696f6e436f6d707574650001186f726967696e210201504f7074696f6e3c543a3a4163636f756e7449643e000130707265765f656a6563746564780110626f6f6c00001cb44120736f6c7574696f6e207761732073746f72656420776974682074686520676976656e20636f6d707574652e00510154686520606f726967696e6020696e6469636174657320746865206f726967696e206f662074686520736f6c7574696f6e2e20496620606f726967696e602069732060536f6d65284163636f756e74496429602c55017468652073746f72656420736f6c7574696f6e20776173207375626d6974656420696e20746865207369676e65642070686173652062792061206d696e657220776974682074686520604163636f756e744964602e25014f74686572776973652c2074686520736f6c7574696f6e207761732073746f7265642065697468657220647572696e672074686520756e7369676e6564207068617365206f722062794d0160543a3a466f7263654f726967696e602e205468652060626f6f6c6020697320607472756560207768656e20612070726576696f757320736f6c7574696f6e2077617320656a656374656420746f206d616b6548726f6f6d20666f722074686973206f6e652e44456c656374696f6e46696e616c697a656408011c636f6d70757465b507013c456c656374696f6e436f6d7075746500011473636f7265a5040134456c656374696f6e53636f7265000104190154686520656c656374696f6e20686173206265656e2066696e616c697a65642c20776974682074686520676976656e20636f6d7075746174696f6e20616e642073636f72652e38456c656374696f6e4661696c656400020c4c416e20656c656374696f6e206661696c65642e0001014e6f74206d7563682063616e20626520736169642061626f757420776869636820636f6d7075746573206661696c656420696e207468652070726f636573732e20526577617264656408011c6163636f756e740001983c54206173206672616d655f73797374656d3a3a436f6e6669673e3a3a4163636f756e74496400011476616c756518013042616c616e63654f663c543e0003042501416e206163636f756e7420686173206265656e20726577617264656420666f72207468656972207369676e6564207375626d697373696f6e206265696e672066696e616c697a65642e1c536c617368656408011c6163636f756e740001983c54206173206672616d655f73797374656d3a3a436f6e6669673e3a3a4163636f756e74496400011476616c756518013042616c616e63654f663c543e0004042101416e206163636f756e7420686173206265656e20736c617368656420666f72207375626d697474696e6720616e20696e76616c6964207369676e6564207375626d697373696f6e2e4450686173655472616e736974696f6e65640c011066726f6db907016050686173653c426c6f636b4e756d626572466f723c543e3e000108746fb907016050686173653c426c6f636b4e756d626572466f723c543e3e000114726f756e6410010c753332000504b85468657265207761732061207068617365207472616e736974696f6e20696e206120676976656e20726f756e642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574b507089070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f70686173653c456c656374696f6e436f6d707574650001141c4f6e436861696e000000185369676e656400010020556e7369676e65640002002046616c6c6261636b00030024456d657267656e637900040000b907089070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f70686173651450686173650408426e011001100c4f6666000000185369676e656400010020556e7369676e65640400bd07012828626f6f6c2c20426e2900020024456d657267656e637900030000bd0700000408781000c1070c4070616c6c65745f626167735f6c6973741870616c6c6574144576656e740804540004490001082052656261676765640c010c77686f000130543a3a4163636f756e74496400011066726f6d2c0120543a3a53636f7265000108746f2c0120543a3a53636f7265000004a44d6f76656420616e206163636f756e742066726f6d206f6e652062616720746f20616e6f746865722e3053636f72655570646174656408010c77686f000130543a3a4163636f756e7449640001246e65775f73636f72652c0120543a3a53636f7265000104d855706461746564207468652073636f7265206f6620736f6d65206163636f756e7420746f2074686520676976656e20616d6f756e742e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574c5070c5c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c731870616c6c6574144576656e740404540001481c437265617465640801246465706f7369746f72000130543a3a4163636f756e74496400011c706f6f6c5f6964100118506f6f6c4964000004604120706f6f6c20686173206265656e20637265617465642e18426f6e6465641001186d656d626572000130543a3a4163636f756e74496400011c706f6f6c5f6964100118506f6f6c4964000118626f6e64656418013042616c616e63654f663c543e0001186a6f696e6564780110626f6f6c0001049441206d656d6265722068617320626563616d6520626f6e64656420696e206120706f6f6c2e1c506169644f75740c01186d656d626572000130543a3a4163636f756e74496400011c706f6f6c5f6964100118506f6f6c49640001187061796f757418013042616c616e63654f663c543e0002048c41207061796f757420686173206265656e206d61646520746f2061206d656d6265722e20556e626f6e6465641401186d656d626572000130543a3a4163636f756e74496400011c706f6f6c5f6964100118506f6f6c496400011c62616c616e636518013042616c616e63654f663c543e000118706f696e747318013042616c616e63654f663c543e00010c657261100120457261496e64657800032c9841206d656d6265722068617320756e626f6e6465642066726f6d20746865697220706f6f6c2e0039012d206062616c616e6365602069732074686520636f72726573706f6e64696e672062616c616e6365206f6620746865206e756d626572206f6620706f696e7473207468617420686173206265656e5501202072657175657374656420746f20626520756e626f6e646564202874686520617267756d656e74206f66207468652060756e626f6e6460207472616e73616374696f6e292066726f6d2074686520626f6e6465641c2020706f6f6c2e45012d2060706f696e74736020697320746865206e756d626572206f6620706f696e747320746861742061726520697373756564206173206120726573756c74206f66206062616c616e636560206265696e67c0646973736f6c76656420696e746f2074686520636f72726573706f6e64696e6720756e626f6e64696e6720706f6f6c2ee42d206065726160206973207468652065726120696e207768696368207468652062616c616e63652077696c6c20626520756e626f6e6465642e5501496e2074686520616273656e6365206f6620736c617368696e672c2074686573652076616c7565732077696c6c206d617463682e20496e207468652070726573656e6365206f6620736c617368696e672c207468654d016e756d626572206f6620706f696e74732074686174206172652069737375656420696e2074686520756e626f6e64696e6720706f6f6c2077696c6c206265206c657373207468616e2074686520616d6f756e746472657175657374656420746f20626520756e626f6e6465642e2457697468647261776e1001186d656d626572000130543a3a4163636f756e74496400011c706f6f6c5f6964100118506f6f6c496400011c62616c616e636518013042616c616e63654f663c543e000118706f696e747318013042616c616e63654f663c543e0004189c41206d656d626572206861732077697468647261776e2066726f6d20746865697220706f6f6c2e00210154686520676976656e206e756d626572206f662060706f696e7473602068617665206265656e20646973736f6c76656420696e2072657475726e206f66206062616c616e6365602e00590153696d696c617220746f2060556e626f6e64656460206576656e742c20696e2074686520616273656e6365206f6620736c617368696e672c2074686520726174696f206f6620706f696e7420746f2062616c616e63652877696c6c20626520312e2444657374726f79656404011c706f6f6c5f6964100118506f6f6c4964000504684120706f6f6c20686173206265656e2064657374726f7965642e3053746174654368616e67656408011c706f6f6c5f6964100118506f6f6c49640001246e65775f7374617465d1040124506f6f6c53746174650006047c546865207374617465206f66206120706f6f6c20686173206368616e676564344d656d62657252656d6f76656408011c706f6f6c5f6964100118506f6f6c49640001186d656d626572000130543a3a4163636f756e74496400070c9841206d656d62657220686173206265656e2072656d6f7665642066726f6d206120706f6f6c2e0051015468652072656d6f76616c2063616e20626520766f6c756e74617279202877697468647261776e20616c6c20756e626f6e6465642066756e647329206f7220696e766f6c756e7461727920286b69636b6564292e30526f6c6573557064617465640c0110726f6f74210201504f7074696f6e3c543a3a4163636f756e7449643e00011c626f756e636572210201504f7074696f6e3c543a3a4163636f756e7449643e0001246e6f6d696e61746f72210201504f7074696f6e3c543a3a4163636f756e7449643e000808550154686520726f6c6573206f66206120706f6f6c2068617665206265656e207570646174656420746f2074686520676976656e206e657720726f6c65732e204e6f7465207468617420746865206465706f7369746f724463616e206e65766572206368616e67652e2c506f6f6c536c617368656408011c706f6f6c5f6964100118506f6f6c496400011c62616c616e636518013042616c616e63654f663c543e0009040d01546865206163746976652062616c616e6365206f6620706f6f6c2060706f6f6c5f69646020686173206265656e20736c617368656420746f206062616c616e6365602e50556e626f6e64696e67506f6f6c536c61736865640c011c706f6f6c5f6964100118506f6f6c496400010c657261100120457261496e64657800011c62616c616e636518013042616c616e63654f663c543e000a04250154686520756e626f6e6420706f6f6c206174206065726160206f6620706f6f6c2060706f6f6c5f69646020686173206265656e20736c617368656420746f206062616c616e6365602e54506f6f6c436f6d6d697373696f6e5570646174656408011c706f6f6c5f6964100118506f6f6c496400011c63757272656e74e904017c4f7074696f6e3c2850657262696c6c2c20543a3a4163636f756e744964293e000b04b44120706f6f6c277320636f6d6d697373696f6e2073657474696e6720686173206265656e206368616e6765642e60506f6f6c4d6178436f6d6d697373696f6e5570646174656408011c706f6f6c5f6964100118506f6f6c49640001386d61785f636f6d6d697373696f6eac011c50657262696c6c000c04d44120706f6f6c2773206d6178696d756d20636f6d6d697373696f6e2073657474696e6720686173206265656e206368616e6765642e7c506f6f6c436f6d6d697373696f6e4368616e6765526174655570646174656408011c706f6f6c5f6964100118506f6f6c496400012c6368616e67655f72617465f104019c436f6d6d697373696f6e4368616e6765526174653c426c6f636b4e756d626572466f723c543e3e000d04cc4120706f6f6c277320636f6d6d697373696f6e20606368616e67655f726174656020686173206265656e206368616e6765642e90506f6f6c436f6d6d697373696f6e436c61696d5065726d697373696f6e5570646174656408011c706f6f6c5f6964100118506f6f6c49640001287065726d697373696f6ef50401bc4f7074696f6e3c436f6d6d697373696f6e436c61696d5065726d697373696f6e3c543a3a4163636f756e7449643e3e000e04c8506f6f6c20636f6d6d697373696f6e20636c61696d207065726d697373696f6e20686173206265656e20757064617465642e54506f6f6c436f6d6d697373696f6e436c61696d656408011c706f6f6c5f6964100118506f6f6c4964000128636f6d6d697373696f6e18013042616c616e63654f663c543e000f0484506f6f6c20636f6d6d697373696f6e20686173206265656e20636c61696d65642e644d696e42616c616e63654465666963697441646a757374656408011c706f6f6c5f6964100118506f6f6c4964000118616d6f756e7418013042616c616e63654f663c543e001004c8546f70706564207570206465666963697420696e2066726f7a656e204544206f66207468652072657761726420706f6f6c2e604d696e42616c616e636545786365737341646a757374656408011c706f6f6c5f6964100118506f6f6c4964000118616d6f756e7418013042616c616e63654f663c543e001104bc436c61696d6564206578636573732066726f7a656e204544206f66206166207468652072657761726420706f6f6c2e04584576656e7473206f6620746869732070616c6c65742ec9070c4c70616c6c65745f666173745f756e7374616b651870616c6c6574144576656e7404045400011420556e7374616b65640801147374617368000130543a3a4163636f756e744964000118726573756c748801384469737061746368526573756c740000045841207374616b65722077617320756e7374616b65642e1c536c61736865640801147374617368000130543a3a4163636f756e744964000118616d6f756e7418013042616c616e63654f663c543e000104190141207374616b65722077617320736c617368656420666f722072657175657374696e6720666173742d756e7374616b65207768696c7374206265696e67206578706f7365642e304261746368436865636b656404011065726173090201345665633c457261496e6465783e00020445014120626174636820776173207061727469616c6c7920636865636b656420666f722074686520676976656e20657261732c20627574207468652070726f6365737320646964206e6f742066696e6973682e34426174636846696e697368656404011073697a6510010c7533320003109c41206261746368206f66206120676976656e2073697a6520776173207465726d696e617465642e0055015468697320697320616c7761797320666f6c6c6f77732062792061206e756d626572206f662060556e7374616b656460206f722060536c617368656460206576656e74732c206d61726b696e672074686520656e64e86f66207468652062617463682e2041206e65772062617463682077696c6c20626520637265617465642075706f6e206e65787420626c6f636b2e34496e7465726e616c4572726f72000404e8416e20696e7465726e616c206572726f722068617070656e65642e204f7065726174696f6e732077696c6c20626520706175736564206e6f772e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574cd07106c706f6c6b61646f745f72756e74696d655f70617261636861696e7324696e636c7573696f6e1870616c6c6574144576656e740404540001103c43616e6469646174654261636b65641000d107016443616e646964617465526563656970743c543a3a486173683e00008505012048656164446174610000d5070124436f7265496e6465780000d907012847726f7570496e646578000004c0412063616e64696461746520776173206261636b65642e20605b63616e6469646174652c20686561645f646174615d604443616e646964617465496e636c756465641000d107016443616e646964617465526563656970743c543a3a486173683e00008505012048656164446174610000d5070124436f7265496e6465780000d907012847726f7570496e646578000104c8412063616e6469646174652077617320696e636c756465642e20605b63616e6469646174652c20686561645f646174615d604443616e64696461746554696d65644f75740c00d107016443616e646964617465526563656970743c543a3a486173683e00008505012048656164446174610000d5070124436f7265496e646578000204bc412063616e6469646174652074696d6564206f75742e20605b63616e6469646174652c20686561645f646174615d60585570776172644d65737361676573526563656976656408011066726f6db9020118506172614964000114636f756e7410010c753332000304f8536f6d6520757077617264206d657373616765732068617665206265656e20726563656976656420616e642077696c6c2062652070726f6365737365642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574d1070c4c706f6c6b61646f745f7072696d6974697665730876364043616e6469646174655265636569707404044801300008012864657363726970746f725905015843616e64696461746544657363726970746f723c483e000140636f6d6d69746d656e74735f68617368300110486173680000d5070c4c706f6c6b61646f745f7072696d69746976657308763624436f7265496e6465780000040010010c7533320000d9070c4c706f6c6b61646f745f7072696d6974697665730876362847726f7570496e6465780000040010010c7533320000dd07106c706f6c6b61646f745f72756e74696d655f70617261636861696e731470617261731870616c6c6574144576656e740001204843757272656e74436f6465557064617465640400b9020118506172614964000004cc43757272656e7420636f646520686173206265656e207570646174656420666f72206120506172612e2060706172615f6964604843757272656e7448656164557064617465640400b9020118506172614964000104cc43757272656e74206865616420686173206265656e207570646174656420666f72206120506172612e2060706172615f69646050436f6465557067726164655363686564756c65640400b9020118506172614964000204dc4120636f6465207570677261646520686173206265656e207363686564756c656420666f72206120506172612e2060706172615f696460304e6577486561644e6f7465640400b9020118506172614964000304bc41206e6577206865616420686173206265656e206e6f74656420666f72206120506172612e2060706172615f69646030416374696f6e5175657565640800b9020118506172614964000010013053657373696f6e496e646578000404f041207061726120686173206265656e2071756575656420746f20657865637574652070656e64696e6720616374696f6e732e2060706172615f6964603c507666436865636b5374617274656408006505014856616c69646174696f6e436f6465486173680000b9020118506172614964000508550154686520676976656e20706172612065697468657220696e69746961746564206f72207375627363726962656420746f20612050564620636865636b20666f722074686520676976656e2076616c69646174696f6e6c636f64652e2060636f64655f68617368602060706172615f69646040507666436865636b416363657074656408006505014856616c69646174696f6e436f6465486173680000b9020118506172614964000608110154686520676976656e2076616c69646174696f6e20636f6465207761732061636365707465642062792074686520505646207072652d636865636b696e6720766f74652e5460636f64655f68617368602060706172615f69646040507666436865636b52656a656374656408006505014856616c69646174696f6e436f6465486173680000b9020118506172614964000708110154686520676976656e2076616c69646174696f6e20636f6465207761732072656a65637465642062792074686520505646207072652d636865636b696e6720766f74652e5460636f64655f68617368602060706172615f696460047c54686520604576656e746020656e756d206f6620746869732070616c6c6574e107106c706f6c6b61646f745f72756e74696d655f70617261636861696e731068726d701870616c6c6574144576656e7404045400011c504f70656e4368616e6e656c52657175657374656410011873656e646572b9020118506172614964000124726563697069656e74b902011850617261496400015470726f706f7365645f6d61785f636170616369747910010c75333200016470726f706f7365645f6d61785f6d6573736167655f73697a6510010c753332000004704f70656e2048524d50206368616e6e656c207265717565737465642e4c4f70656e4368616e6e656c43616e63656c656408013062795f70617261636861696eb90201185061726149640001286368616e6e656c5f6964c505013448726d704368616e6e656c49640001042901416e2048524d50206368616e6e656c20726571756573742073656e7420627920746865207265636569766572207761732063616e63656c6564206279206569746865722070617274792e4c4f70656e4368616e6e656c416363657074656408011873656e646572b9020118506172614964000124726563697069656e74b90201185061726149640002046c4f70656e2048524d50206368616e6e656c2061636365707465642e344368616e6e656c436c6f73656408013062795f70617261636861696eb90201185061726149640001286368616e6e656c5f6964c505013448726d704368616e6e656c49640003045048524d50206368616e6e656c20636c6f7365642e5848726d704368616e6e656c466f7263654f70656e656410011873656e646572b9020118506172614964000124726563697069656e74b902011850617261496400015470726f706f7365645f6d61785f636170616369747910010c75333200016470726f706f7365645f6d61785f6d6573736167655f73697a6510010c753332000404ac416e2048524d50206368616e6e656c20776173206f70656e65642076696120526f6f74206f726967696e2e5c48726d7053797374656d4368616e6e656c4f70656e656410011873656e646572b9020118506172614964000124726563697069656e74b902011850617261496400015470726f706f7365645f6d61785f636170616369747910010c75333200016470726f706f7365645f6d61785f6d6573736167655f73697a6510010c753332000504d4416e2048524d50206368616e6e656c20776173206f70656e6564206265747765656e2074776f2073797374656d20636861696e732e684f70656e4368616e6e656c4465706f736974735570646174656408011873656e646572b9020118506172614964000124726563697069656e74b9020118506172614964000604a0416e2048524d50206368616e6e656c2773206465706f73697473207765726520757064617465642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574e507106c706f6c6b61646f745f72756e74696d655f70617261636861696e732064697370757465731870616c6c6574144576656e7404045400010c4044697370757465496e6974696174656408009905013443616e646964617465486173680000e907013c446973707574654c6f636174696f6e000004090141206469737075746520686173206265656e20696e697469617465642e205c5b63616e64696461746520686173682c2064697370757465206c6f636174696f6e5c5d4044697370757465436f6e636c7564656408009905013443616e646964617465486173680000ed07013444697370757465526573756c74000108cc4120646973707574652068617320636f6e636c7564656420666f72206f7220616761696e737420612063616e6469646174652eb4605c5b706172612069642c2063616e64696461746520686173682c206469737075746520726573756c745c5d60185265766572740400100144426c6f636b4e756d626572466f723c543e000210fc4120646973707574652068617320636f6e636c7564656420776974682073757065726d616a6f7269747920616761696e737420612063616e6469646174652e0d01426c6f636b20617574686f72732073686f756c64206e6f206c6f6e676572206275696c64206f6e20746f70206f662074686973206865616420616e642073686f756c640101696e7374656164207265766572742074686520626c6f636b2061742074686520676976656e206865696768742e20546869732073686f756c6420626520746865fc6e756d626572206f6620746865206368696c64206f6620746865206c617374206b6e6f776e2076616c696420626c6f636b20696e2074686520636861696e2e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574e9070c6c706f6c6b61646f745f72756e74696d655f70617261636861696e732064697370757465733c446973707574654c6f636174696f6e000108144c6f63616c0000001852656d6f746500010000ed070c6c706f6c6b61646f745f72756e74696d655f70617261636861696e732064697370757465733444697370757465526573756c740001081456616c69640000001c496e76616c696400010000f107105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e3c70617261735f7265676973747261721870616c6c6574144576656e74040454000110285265676973746572656408011c706172615f6964b902011850617261496400011c6d616e61676572000130543a3a4163636f756e7449640000003044657265676973746572656404011c706172615f6964b902011850617261496400010020526573657276656408011c706172615f6964b902011850617261496400010c77686f000130543a3a4163636f756e7449640002001c5377617070656408011c706172615f6964b90201185061726149640001206f746865725f6964b9020118506172614964000300047c54686520604576656e746020656e756d206f6620746869732070616c6c6574f507105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e14736c6f74731870616c6c6574144576656e74040454000108384e65774c65617365506572696f640401306c656173655f706572696f641001404c65617365506572696f644f663c543e0000049041206e657720605b6c656173655f706572696f645d6020697320626567696e6e696e672e184c656173656418011c706172615f6964b90201185061726149640001186c6561736572000130543a3a4163636f756e744964000130706572696f645f626567696e1001404c65617365506572696f644f663c543e000130706572696f645f636f756e741001404c65617365506572696f644f663c543e00013865787472615f726573657276656418013042616c616e63654f663c543e000130746f74616c5f616d6f756e7418013042616c616e63654f663c543e00010c35014120706172612068617320776f6e2074686520726967687420746f206120636f6e74696e756f757320736574206f66206c6561736520706572696f647320617320612070617261636861696e2e450146697273742062616c616e636520697320616e7920657874726120616d6f756e74207265736572766564206f6e20746f70206f662074686520706172612773206578697374696e67206465706f7369742eb05365636f6e642062616c616e63652069732074686520746f74616c20616d6f756e742072657365727665642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574f907105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e2061756374696f6e731870616c6c6574144576656e7404045400011c3841756374696f6e537461727465640c013461756374696f6e5f696e64657810013041756374696f6e496e6465780001306c656173655f706572696f641001404c65617365506572696f644f663c543e000118656e64696e67100144426c6f636b4e756d626572466f723c543e0000084901416e2061756374696f6e20737461727465642e2050726f76696465732069747320696e64657820616e642074686520626c6f636b206e756d6265722077686572652069742077696c6c20626567696e20746f1501636c6f736520616e6420746865206669727374206c6561736520706572696f64206f662074686520717561647275706c657420746861742069732061756374696f6e65642e3441756374696f6e436c6f73656404013461756374696f6e5f696e64657810013041756374696f6e496e646578000104b8416e2061756374696f6e20656e6465642e20416c6c2066756e6473206265636f6d6520756e72657365727665642e2052657365727665640c0118626964646572000130543a3a4163636f756e74496400013865787472615f726573657276656418013042616c616e63654f663c543e000130746f74616c5f616d6f756e7418013042616c616e63654f663c543e000208490146756e6473207765726520726573657276656420666f7220612077696e6e696e67206269642e2046697273742062616c616e63652069732074686520657874726120616d6f756e742072657365727665642e505365636f6e642069732074686520746f74616c2e28556e7265736572766564080118626964646572000130543a3a4163636f756e744964000118616d6f756e7418013042616c616e63654f663c543e000304290146756e6473207765726520756e72657365727665642073696e636520626964646572206973206e6f206c6f6e676572206163746976652e20605b6269646465722c20616d6f756e745d604852657365727665436f6e66697363617465640c011c706172615f6964b90201185061726149640001186c6561736572000130543a3a4163636f756e744964000118616d6f756e7418013042616c616e63654f663c543e0004085501536f6d656f6e6520617474656d7074656420746f206c65617365207468652073616d6520736c6f7420747769636520666f7220612070617261636861696e2e2054686520616d6f756e742069732068656c6420696eb87265736572766520627574206e6f2070617261636861696e20736c6f7420686173206265656e206c65617365642e2c4269644163636570746564140118626964646572000130543a3a4163636f756e74496400011c706172615f6964b9020118506172614964000118616d6f756e7418013042616c616e63654f663c543e00012866697273745f736c6f741001404c65617365506572696f644f663c543e0001246c6173745f736c6f741001404c65617365506572696f644f663c543e000504c841206e65772062696420686173206265656e206163636570746564206173207468652063757272656e742077696e6e65722e3457696e6e696e674f666673657408013461756374696f6e5f696e64657810013041756374696f6e496e646578000130626c6f636b5f6e756d626572100144426c6f636b4e756d626572466f723c543e00060859015468652077696e6e696e67206f6666736574207761732063686f73656e20666f7220616e2061756374696f6e2e20546869732077696c6c206d617020696e746f20746865206057696e6e696e67602073746f72616765106d61702e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574fd07105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e2463726f77646c6f616e1870616c6c6574144576656e740404540001281c4372656174656404011c706172615f6964b90201185061726149640000048c4372656174652061206e65772063726f77646c6f616e696e672063616d706169676e2e2c436f6e74726962757465640c010c77686f000130543a3a4163636f756e74496400012866756e645f696e646578b9020118506172614964000118616d6f756e7418013042616c616e63654f663c543e00010470436f6e747269627574656420746f20612063726f77642073616c652e2057697468647265770c010c77686f000130543a3a4163636f756e74496400012866756e645f696e646578b9020118506172614964000118616d6f756e7418013042616c616e63654f663c543e0002049c57697468647265772066756c6c2062616c616e6365206f66206120636f6e7472696275746f722e445061727469616c6c79526566756e64656404011c706172615f6964b90201185061726149640003082d01546865206c6f616e7320696e20612066756e642068617665206265656e207061727469616c6c7920646973736f6c7665642c20692e652e2074686572652061726520736f6d65206c656674b46f766572206368696c64206b6579732074686174207374696c6c206e65656420746f206265206b696c6c65642e2c416c6c526566756e64656404011c706172615f6964b90201185061726149640004049c416c6c206c6f616e7320696e20612066756e642068617665206265656e20726566756e6465642e24446973736f6c76656404011c706172615f6964b90201185061726149640005044846756e6420697320646973736f6c7665642e3c48616e646c65426964526573756c7408011c706172615f6964b9020118506172614964000118726573756c748801384469737061746368526573756c74000604f454686520726573756c74206f6620747279696e6720746f207375626d69742061206e65772062696420746f2074686520536c6f74732070616c6c65742e1845646974656404011c706172615f6964b9020118506172614964000704c454686520636f6e66696775726174696f6e20746f20612063726f77646c6f616e20686173206265656e206564697465642e2c4d656d6f557064617465640c010c77686f000130543a3a4163636f756e74496400011c706172615f6964b90201185061726149640001106d656d6f34011c5665633c75383e0008046041206d656d6f20686173206265656e20757064617465642e3c4164646564546f4e6577526169736504011c706172615f6964b9020118506172614964000904a0412070617261636861696e20686173206265656e206d6f76656420746f20604e6577526169736560047c54686520604576656e746020656e756d206f6620746869732070616c6c657401080c6c70616c6c65745f73746174655f747269655f6d6967726174696f6e1870616c6c6574144576656e74040454000110204d696772617465640c010c746f7010010c7533320001146368696c6410010c75333200011c636f6d70757465050801404d6967726174696f6e436f6d707574650000083901476976656e206e756d626572206f66206028746f702c206368696c642960206b6579732077657265206d6967726174656420726573706563746976656c792c20776974682074686520676976656e2860636f6d70757465602e1c536c617368656408010c77686f000130543a3a4163636f756e744964000118616d6f756e7418013042616c616e63654f663c543e000104b4536f6d65206163636f756e7420676f7420736c61736865642062792074686520676976656e20616d6f756e742e544175746f4d6967726174696f6e46696e697368656400020484546865206175746f206d6967726174696f6e207461736b2066696e69736865642e1848616c7465640401146572726f72090801204572726f723c543e000304ec4d6967726174696f6e20676f742068616c7465642064756520746f20616e206572726f72206f72206d6973732d636f6e66696775726174696f6e2e0470496e6e6572206576656e7473206f6620746869732070616c6c65742e05080c6c70616c6c65745f73746174655f747269655f6d6967726174696f6e1870616c6c6574404d6967726174696f6e436f6d70757465000108185369676e6564000000104175746f0001000009080c6c70616c6c65745f73746174655f747269655f6d6967726174696f6e1870616c6c6574144572726f720404540001183c4d61785369676e65644c696d697473000004804d6178207369676e6564206c696d697473206e6f74207265737065637465642e284b6579546f6f4c6f6e6700011cb441206b657920776173206c6f6e676572207468616e2074686520636f6e66696775726564206d6178696d756d2e00110154686973206d65616e73207468617420746865206d6967726174696f6e2068616c746564206174207468652063757272656e74205b6050726f6772657373605d20616e64010163616e20626520726573756d656420776974682061206c6172676572205b6063726174653a3a436f6e6669673a3a4d61784b65794c656e605d2076616c75652e21015265747279696e672077697468207468652073616d65205b6063726174653a3a436f6e6669673a3a4d61784b65794c656e605d2076616c75652077696c6c206e6f7420776f726b2e45015468652076616c75652073686f756c64206f6e6c7920626520696e6372656173656420746f2061766f696420612073746f72616765206d6967726174696f6e20666f72207468652063757272656e746c799073746f726564205b6063726174653a3a50726f67726573733a3a4c6173744b6579605d2e384e6f74456e6f75676846756e6473000204947375626d697474657220646f6573206e6f74206861766520656e6f7567682066756e64732e284261645769746e65737300030468426164207769746e65737320646174612070726f76696465642e645369676e65644d6967726174696f6e4e6f74416c6c6f77656400040425015369676e6564206d6967726174696f6e206973206e6f7420616c6c6f776564206265636175736520746865206d6178696d756d206c696d6974206973206e6f7420736574207965742e304261644368696c64526f6f7400050460426164206368696c6420726f6f742070726f76696465642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e0d080c2870616c6c65745f78636d1870616c6c6574144576656e7404045400016024417474656d7074656404011c6f7574636f6d651108015078636d3a3a6c61746573743a3a4f7574636f6d65000004a8457865637574696f6e206f6620616e2058434d206d6573736167652077617320617474656d707465642e1053656e741001186f726967696e310101204c6f636174696f6e00012c64657374696e6174696f6e310101204c6f636174696f6e00011c6d657373616765c506011c58636d3c28293e0001286d6573736167655f696404011c58636d486173680001045c412058434d206d657373616765207761732073656e742e48556e6578706563746564526573706f6e73650801186f726967696e310101204c6f636174696f6e00012071756572795f69642c011c5175657279496400020c5901517565727920726573706f6e736520726563656976656420776869636820646f6573206e6f74206d61746368206120726567697374657265642071756572792e2054686973206d61792062652062656361757365206155016d61746368696e6720717565727920776173206e6576657220726567697374657265642c206974206d617920626520626563617573652069742069732061206475706c696361746520726573706f6e73652c206f727062656361757365207468652071756572792074696d6564206f75742e34526573706f6e7365526561647908012071756572795f69642c011c51756572794964000120726573706f6e7365e5060120526573706f6e73650003085d01517565727920726573706f6e736520686173206265656e20726563656976656420616e6420697320726561647920666f722074616b696e672077697468206074616b655f726573706f6e7365602e205468657265206973806e6f2072656769737465726564206e6f74696669636174696f6e2063616c6c2e204e6f7469666965640c012071756572795f69642c011c5175657279496400013070616c6c65745f696e646578080108753800012863616c6c5f696e64657808010875380004085901517565727920726573706f6e736520686173206265656e20726563656976656420616e642071756572792069732072656d6f7665642e205468652072656769737465726564206e6f74696669636174696f6e20686173a86265656e206469737061746368656420616e64206578656375746564207375636365737366756c6c792e404e6f746966794f76657277656967687414012071756572795f69642c011c5175657279496400013070616c6c65745f696e646578080108753800012863616c6c5f696e646578080108753800013461637475616c5f77656967687424011857656967687400014c6d61785f62756467657465645f77656967687424011857656967687400050c4901517565727920726573706f6e736520686173206265656e20726563656976656420616e642071756572792069732072656d6f7665642e205468652072656769737465726564206e6f74696669636174696f6e5901636f756c64206e6f742062652064697370617463686564206265636175736520746865206469737061746368207765696768742069732067726561746572207468616e20746865206d6178696d756d20776569676874e46f726967696e616c6c7920627564676574656420627920746869732072756e74696d6520666f722074686520717565727920726573756c742e4c4e6f7469667944697370617463684572726f720c012071756572795f69642c011c5175657279496400013070616c6c65745f696e646578080108753800012863616c6c5f696e64657808010875380006085501517565727920726573706f6e736520686173206265656e20726563656976656420616e642071756572792069732072656d6f7665642e2054686572652077617320612067656e6572616c206572726f722077697468886469737061746368696e6720746865206e6f74696669636174696f6e2063616c6c2e484e6f746966794465636f64654661696c65640c012071756572795f69642c011c5175657279496400013070616c6c65745f696e646578080108753800012863616c6c5f696e646578080108753800070c5101517565727920726573706f6e736520686173206265656e20726563656976656420616e642071756572792069732072656d6f7665642e205468652064697370617463682077617320756e61626c6520746f20626559016465636f64656420696e746f2061206043616c6c603b2074686973206d696768742062652064756520746f2064697370617463682066756e6374696f6e20686176696e672061207369676e6174757265207768696368946973206e6f742060286f726967696e2c20517565727949642c20526573706f6e736529602e40496e76616c6964526573706f6e6465720c01186f726967696e310101204c6f636174696f6e00012071756572795f69642c011c5175657279496400014465787065637465645f6c6f636174696f6ef90601404f7074696f6e3c4c6f636174696f6e3e00080c5901457870656374656420717565727920726573706f6e736520686173206265656e2072656365697665642062757420746865206f726967696e206c6f636174696f6e206f662074686520726573706f6e736520646f657355016e6f74206d6174636820746861742065787065637465642e205468652071756572792072656d61696e73207265676973746572656420666f722061206c617465722c2076616c69642c20726573706f6e736520746f6c626520726563656976656420616e642061637465642075706f6e2e5c496e76616c6964526573706f6e64657256657273696f6e0801186f726967696e310101204c6f636174696f6e00012071756572795f69642c011c5175657279496400091c5101457870656374656420717565727920726573706f6e736520686173206265656e2072656365697665642062757420746865206578706563746564206f726967696e206c6f636174696f6e20706c6163656420696e4d0173746f7261676520627920746869732072756e74696d652070726576696f75736c792063616e6e6f74206265206465636f6465642e205468652071756572792072656d61696e7320726567697374657265642e0041015468697320697320756e6578706563746564202873696e63652061206c6f636174696f6e20706c6163656420696e2073746f7261676520696e20612070726576696f75736c7920657865637574696e674d0172756e74696d652073686f756c64206265207265616461626c65207072696f7220746f2071756572792074696d656f75742920616e642064616e6765726f75732073696e63652074686520706f737369626c79590176616c696420726573706f6e73652077696c6c2062652064726f707065642e204d616e75616c20676f7665726e616e636520696e74657276656e74696f6e2069732070726f6261626c7920676f696e6720746f2062651c6e65656465642e34526573706f6e736554616b656e04012071756572795f69642c011c51756572794964000a04c8526563656976656420717565727920726573706f6e736520686173206265656e207265616420616e642072656d6f7665642e34417373657473547261707065640c011068617368300110483235360001186f726967696e310101204c6f636174696f6e0001186173736574730d07013c56657273696f6e6564417373657473000b04b8536f6d65206173736574732068617665206265656e20706c6163656420696e20616e20617373657420747261702e5456657273696f6e4368616e67654e6f74696669656410012c64657374696e6174696f6e310101204c6f636174696f6e000118726573756c7410012858636d56657273696f6e000110636f7374d10601184173736574730001286d6573736167655f696404011c58636d48617368000c0c2501416e2058434d2076657273696f6e206368616e6765206e6f74696669636174696f6e206d65737361676520686173206265656e20617474656d7074656420746f2062652073656e742e00e054686520636f7374206f662073656e64696e672069742028626f726e652062792074686520636861696e2920697320696e636c756465642e5c537570706f7274656456657273696f6e4368616e6765640801206c6f636174696f6e310101204c6f636174696f6e00011c76657273696f6e10012858636d56657273696f6e000d08390154686520737570706f727465642076657273696f6e206f662061206c6f636174696f6e20686173206265656e206368616e6765642e2054686973206d69676874206265207468726f75676820616ec06175746f6d61746963206e6f74696669636174696f6e206f722061206d616e75616c20696e74657276656e74696f6e2e504e6f7469667954617267657453656e644661696c0c01206c6f636174696f6e310101204c6f636174696f6e00012071756572795f69642c011c517565727949640001146572726f729106012058636d4572726f72000e0859014120676976656e206c6f636174696f6e2077686963682068616420612076657273696f6e206368616e676520737562736372697074696f6e207761732064726f70706564206f77696e6720746f20616e206572726f727c73656e64696e6720746865206e6f74696669636174696f6e20746f2069742e644e6f746966795461726765744d6967726174696f6e4661696c0801206c6f636174696f6e6901014456657273696f6e65644c6f636174696f6e00012071756572795f69642c011c51756572794964000f0859014120676976656e206c6f636174696f6e2077686963682068616420612076657273696f6e206368616e676520737562736372697074696f6e207761732064726f70706564206f77696e6720746f20616e206572726f72b46d6967726174696e6720746865206c6f636174696f6e20746f206f7572206e65772058434d20666f726d61742e54496e76616c69645175657269657256657273696f6e0801186f726967696e310101204c6f636174696f6e00012071756572795f69642c011c5175657279496400101c5501457870656374656420717565727920726573706f6e736520686173206265656e20726563656976656420627574207468652065787065637465642071756572696572206c6f636174696f6e20706c6163656420696e4d0173746f7261676520627920746869732072756e74696d652070726576696f75736c792063616e6e6f74206265206465636f6465642e205468652071756572792072656d61696e7320726567697374657265642e0041015468697320697320756e6578706563746564202873696e63652061206c6f636174696f6e20706c6163656420696e2073746f7261676520696e20612070726576696f75736c7920657865637574696e674d0172756e74696d652073686f756c64206265207265616461626c65207072696f7220746f2071756572792074696d656f75742920616e642064616e6765726f75732073696e63652074686520706f737369626c79590176616c696420726573706f6e73652077696c6c2062652064726f707065642e204d616e75616c20676f7665726e616e636520696e74657276656e74696f6e2069732070726f6261626c7920676f696e6720746f2062651c6e65656465642e38496e76616c6964517565726965721001186f726967696e310101204c6f636174696f6e00012071756572795f69642c011c5175657279496400014065787065637465645f71756572696572310101204c6f636174696f6e0001506d617962655f61637475616c5f71756572696572f90601404f7074696f6e3c4c6f636174696f6e3e00110c5d01457870656374656420717565727920726573706f6e736520686173206265656e20726563656976656420627574207468652071756572696572206c6f636174696f6e206f662074686520726573706f6e736520646f657351016e6f74206d61746368207468652065787065637465642e205468652071756572792072656d61696e73207265676973746572656420666f722061206c617465722c2076616c69642c20726573706f6e736520746f6c626520726563656976656420616e642061637465642075706f6e2e5056657273696f6e4e6f74696679537461727465640c012c64657374696e6174696f6e310101204c6f636174696f6e000110636f7374d10601184173736574730001286d6573736167655f696404011c58636d486173680012085901412072656d6f746520686173207265717565737465642058434d2076657273696f6e206368616e6765206e6f74696669636174696f6e2066726f6d20757320616e64207765206861766520686f6e6f7265642069742e1d01412076657273696f6e20696e666f726d6174696f6e206d6573736167652069732073656e7420746f207468656d20616e642069747320636f737420697320696e636c756465642e5856657273696f6e4e6f746966795265717565737465640c012c64657374696e6174696f6e310101204c6f636174696f6e000110636f7374d10601184173736574730001286d6573736167655f696404011c58636d486173680013043d015765206861766520726571756573746564207468617420612072656d6f746520636861696e2073656e642075732058434d2076657273696f6e206368616e6765206e6f74696669636174696f6e732e6056657273696f6e4e6f74696679556e7265717565737465640c012c64657374696e6174696f6e310101204c6f636174696f6e000110636f7374d10601184173736574730001286d6573736167655f696404011c58636d4861736800140825015765206861766520726571756573746564207468617420612072656d6f746520636861696e2073746f70732073656e64696e672075732058434d2076657273696f6e206368616e6765386e6f74696669636174696f6e732e204665657350616964080118706179696e67310101204c6f636174696f6e00011066656573d1060118417373657473001504310146656573207765726520706169642066726f6d2061206c6f636174696f6e20666f7220616e206f7065726174696f6e20286f6674656e20666f72207573696e67206053656e6458636d60292e34417373657473436c61696d65640c011068617368300110483235360001186f726967696e310101204c6f636174696f6e0001186173736574730d07013c56657273696f6e6564417373657473001604c0536f6d65206173736574732068617665206265656e20636c61696d65642066726f6d20616e20617373657420747261706056657273696f6e4d6967726174696f6e46696e697368656404011c76657273696f6e10012858636d56657273696f6e00170484412058434d2076657273696f6e206d6967726174696f6e2066696e69736865642e047c54686520604576656e746020656e756d206f6620746869732070616c6c65741108102c73746167696e675f78636d087634187472616974731c4f7574636f6d6500010c20436f6d706c6574650401107573656424011857656967687400000028496e636f6d706c657465080110757365642401185765696768740001146572726f72910601144572726f72000100144572726f720401146572726f72910601144572726f720002000015080c5070616c6c65745f6d6573736167655f71756575651870616c6c6574144576656e740404540001104050726f63657373696e674661696c65640c010869643001104832353604945468652060626c616b65325f323536602068617368206f6620746865206d6573736167652e01186f726967696e410701484d6573736167654f726967696e4f663c543e0464546865207175657565206f6620746865206d6573736167652e01146572726f721908014c50726f636573734d6573736167654572726f721060546865206572726f722074686174206f636375727265642e00490154686973206572726f7220697320707265747479206f70617175652e204d6f72652066696e652d677261696e6564206572726f7273206e65656420746f20626520656d6974746564206173206576656e74736862792074686520604d65737361676550726f636573736f72602e000455014d657373616765206469736361726465642064756520746f20616e206572726f7220696e2074686520604d65737361676550726f636573736f72602028757375616c6c79206120666f726d6174206572726f72292e2450726f63657373656410010869643001104832353604945468652060626c616b65325f323536602068617368206f6620746865206d6573736167652e01186f726967696e410701484d6573736167654f726967696e4f663c543e0464546865207175657565206f6620746865206d6573736167652e012c7765696768745f7573656424011857656967687404c0486f77206d7563682077656967687420776173207573656420746f2070726f6365737320746865206d6573736167652e011c73756363657373780110626f6f6c18885768657468657220746865206d657373616765207761732070726f6365737365642e0049014e6f74652074686174207468697320646f6573206e6f74206d65616e20746861742074686520756e6465726c79696e6720604d65737361676550726f636573736f72602077617320696e7465726e616c6c7935017375636365737366756c2e204974202a736f6c656c792a206d65616e73207468617420746865204d512070616c6c65742077696c6c2074726561742074686973206173206120737563636573734d01636f6e646974696f6e20616e64206469736361726420746865206d6573736167652e20416e7920696e7465726e616c206572726f72206e6565647320746f20626520656d6974746564206173206576656e74736862792074686520604d65737361676550726f636573736f72602e0104544d6573736167652069732070726f6365737365642e484f766572776569676874456e71756575656410010869640401205b75383b2033325d04945468652060626c616b65325f323536602068617368206f6620746865206d6573736167652e01186f726967696e410701484d6573736167654f726967696e4f663c543e0464546865207175657565206f6620746865206d6573736167652e0128706167655f696e64657810012450616765496e64657804605468652070616765206f6620746865206d6573736167652e01346d6573736167655f696e64657810011c543a3a53697a6504a454686520696e646578206f6620746865206d6573736167652077697468696e2074686520706167652e02048c4d65737361676520706c6163656420696e206f7665727765696768742071756575652e28506167655265617065640801186f726967696e410701484d6573736167654f726967696e4f663c543e0458546865207175657565206f662074686520706167652e0114696e64657810012450616765496e646578045854686520696e646578206f662074686520706167652e03045454686973207061676520776173207265617065642e047c54686520604576656e746020656e756d206f6620746869732070616c6c6574190810346672616d655f737570706f727418747261697473206d657373616765734c50726f636573734d6573736167654572726f7200011424426164466f726d61740000001c436f72727570740001002c556e737570706f72746564000200284f7665727765696768740400240118576569676874000300145969656c64000400001d080c4470616c6c65745f61737365745f726174651870616c6c6574144576656e7404045400010c404173736574526174654372656174656408012861737365745f6b696e6405010130543a3a41737365744b696e64000110726174654d0701244669786564553132380000004041737365745261746552656d6f76656404012861737365745f6b696e6405010130543a3a41737365744b696e6400010040417373657452617465557064617465640c012861737365745f6b696e6405010130543a3a41737365744b696e6400010c6f6c644d07012446697865645531323800010c6e65774d070124466978656455313238000200047c54686520604576656e746020656e756d206f6620746869732070616c6c6574210808306672616d655f73797374656d14506861736500010c384170706c7945787472696e736963040010010c7533320000003046696e616c697a6174696f6e00010038496e697469616c697a6174696f6e0002000025080000028000290808306672616d655f73797374656d584c61737452756e74696d6555706772616465496e666f0000080130737065635f76657273696f6e1501014c636f6465633a3a436f6d706163743c7533323e000124737065635f6e616d652d08016473705f72756e74696d653a3a52756e74696d65537472696e6700002d080000050200310808306672616d655f73797374656d60436f646555706772616465417574686f72697a6174696f6e0404540000080124636f64655f6861736830011c543a3a48617368000134636865636b5f76657273696f6e780110626f6f6c000035080c306672616d655f73797374656d186c696d69747330426c6f636b5765696768747300000c0128626173655f626c6f636b2401185765696768740001246d61785f626c6f636b2401185765696768740001247065725f636c617373390801845065724469737061746368436c6173733c57656967687473506572436c6173733e000039080c346672616d655f737570706f7274206469737061746368405065724469737061746368436c617373040454013d08000c01186e6f726d616c3d0801045400012c6f7065726174696f6e616c3d080104540001246d616e6461746f72793d0801045400003d080c306672616d655f73797374656d186c696d6974733c57656967687473506572436c6173730000100138626173655f65787472696e7369632401185765696768740001346d61785f65787472696e736963890701384f7074696f6e3c5765696768743e0001246d61785f746f74616c890701384f7074696f6e3c5765696768743e0001207265736572766564890701384f7074696f6e3c5765696768743e000041080c306672616d655f73797374656d186c696d6974732c426c6f636b4c656e677468000004010c6d6178450801545065724469737061746368436c6173733c7533323e000045080c346672616d655f737570706f7274206469737061746368405065724469737061746368436c6173730404540110000c01186e6f726d616c1001045400012c6f7065726174696f6e616c100104540001246d616e6461746f72791001045400004908082873705f776569676874733c52756e74696d6544625765696768740000080110726561642c010c75363400011477726974652c010c75363400004d08082873705f76657273696f6e3852756e74696d6556657273696f6e0000200124737065635f6e616d652d08013452756e74696d65537472696e67000124696d706c5f6e616d652d08013452756e74696d65537472696e67000144617574686f72696e675f76657273696f6e10010c753332000130737065635f76657273696f6e10010c753332000130696d706c5f76657273696f6e10010c753332000110617069735108011c4170697356656300014c7472616e73616374696f6e5f76657273696f6e10010c75333200013473746174655f76657273696f6e080108753800005108040c436f7704045401550800040055080000005508000002590800590800000408310310005d080c306672616d655f73797374656d1870616c6c6574144572726f720404540001203c496e76616c6964537065634e616d650000081101546865206e616d65206f662073706563696669636174696f6e20646f6573206e6f74206d61746368206265747765656e207468652063757272656e742072756e74696d6550616e6420746865206e65772072756e74696d652e685370656356657273696f6e4e65656473546f496e63726561736500010841015468652073706563696669636174696f6e2076657273696f6e206973206e6f7420616c6c6f77656420746f206465637265617365206265747765656e207468652063757272656e742072756e74696d6550616e6420746865206e65772072756e74696d652e744661696c6564546f4578747261637452756e74696d6556657273696f6e00020cec4661696c656420746f2065787472616374207468652072756e74696d652076657273696f6e2066726f6d20746865206e65772072756e74696d652e0009014569746865722063616c6c696e672060436f72655f76657273696f6e60206f72206465636f64696e67206052756e74696d6556657273696f6e60206661696c65642e4c4e6f6e44656661756c74436f6d706f73697465000304fc537569636964652063616c6c6564207768656e20746865206163636f756e7420686173206e6f6e2d64656661756c7420636f6d706f7369746520646174612e3c4e6f6e5a65726f526566436f756e74000404350154686572652069732061206e6f6e2d7a65726f207265666572656e636520636f756e742070726576656e74696e6720746865206163636f756e742066726f6d206265696e67207075726765642e3043616c6c46696c7465726564000504d0546865206f726967696e2066696c7465722070726576656e74207468652063616c6c20746f20626520646973706174636865642e444e6f7468696e67417574686f72697a6564000604584e6f207570677261646520617574686f72697a65642e30556e617574686f72697a656400070494546865207375626d697474656420636f6465206973206e6f7420617574686f72697a65642e046c4572726f7220666f72207468652053797374656d2070616c6c657461080c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540165080453000004006d0801185665633c543e0000650804184f7074696f6e0404540169080108104e6f6e6500000010536f6d650400690800000100006908084070616c6c65745f7363686564756c6572245363686564756c656414104e616d6501041043616c6c0195012c426c6f636b4e756d62657201103450616c6c6574734f726967696e01a902244163636f756e7449640100001401206d617962655f69648401304f7074696f6e3c4e616d653e0001207072696f726974790801487363686564756c653a3a5072696f7269747900011063616c6c9501011043616c6c0001386d617962655f706572696f646963b10101944f7074696f6e3c7363686564756c653a3a506572696f643c426c6f636b4e756d6265723e3e0001186f726967696ea902013450616c6c6574734f726967696e00006d0800000265080071080c4070616c6c65745f7363686564756c65721870616c6c6574144572726f72040454000114404661696c6564546f5363686564756c65000004644661696c656420746f207363686564756c6520612063616c6c204e6f74466f756e640001047c43616e6e6f742066696e6420746865207363686564756c65642063616c6c2e5c546172676574426c6f636b4e756d626572496e50617374000204a4476976656e2074617267657420626c6f636b206e756d62657220697320696e2074686520706173742e4852657363686564756c654e6f4368616e6765000304f052657363686564756c65206661696c6564206265636175736520697420646f6573206e6f74206368616e6765207363686564756c65642074696d652e144e616d6564000404d0417474656d707420746f207573652061206e6f6e2d6e616d65642066756e6374696f6e206f6e2061206e616d6564207461736b2e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e7508083c70616c6c65745f707265696d616765404f6c645265717565737453746174757308244163636f756e74496401001c42616c616e6365011801082c556e72657175657374656408011c6465706f736974c1040150284163636f756e7449642c2042616c616e63652900010c6c656e10010c753332000000245265717565737465640c011c6465706f736974790801704f7074696f6e3c284163636f756e7449642c2042616c616e6365293e000114636f756e7410010c75333200010c6c656e8d02012c4f7074696f6e3c7533323e00010000790804184f7074696f6e04045401c1040108104e6f6e6500000010536f6d650400c10400000100007d08083c70616c6c65745f707265696d616765345265717565737453746174757308244163636f756e7449640100185469636b657401810801082c556e7265717565737465640801187469636b65748508014c284163636f756e7449642c205469636b65742900010c6c656e10010c753332000000245265717565737465640c01306d617962655f7469636b65748908016c4f7074696f6e3c284163636f756e7449642c205469636b6574293e000114636f756e7410010c7533320001246d617962655f6c656e8d02012c4f7074696f6e3c7533323e00010000810814346672616d655f737570706f72741874726169747318746f6b656e732066756e6769626c6544486f6c64436f6e73696465726174696f6e10044100044600045200044400000400180128463a3a42616c616e6365000085080000040800810800890804184f7074696f6e0404540185080108104e6f6e6500000010536f6d650400850800000100008d080000040830100091080c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e000095080c3c70616c6c65745f707265696d6167651870616c6c6574144572726f7204045400012018546f6f426967000004a0507265696d61676520697320746f6f206c6172676520746f2073746f7265206f6e2d636861696e2e30416c72656164794e6f746564000104a4507265696d6167652068617320616c7265616479206265656e206e6f746564206f6e2d636861696e2e344e6f74417574686f72697a6564000204c85468652075736572206973206e6f7420617574686f72697a656420746f20706572666f726d207468697320616374696f6e2e204e6f744e6f746564000304fc54686520707265696d6167652063616e6e6f742062652072656d6f7665642073696e636520697420686173206e6f7420796574206265656e206e6f7465642e2452657175657374656400040409014120707265696d616765206d6179206e6f742062652072656d6f766564207768656e20746865726520617265206f75747374616e64696e672072657175657374732e304e6f745265717565737465640005042d0154686520707265696d61676520726571756573742063616e6e6f742062652072656d6f7665642073696e6365206e6f206f75747374616e64696e672072657175657374732065786973742e1c546f6f4d616e7900060455014d6f7265207468616e20604d41585f484153485f555047524144455f42554c4b5f434f554e54602068617368657320776572652072657175657374656420746f206265207570677261646564206174206f6e63652e18546f6f466577000704e4546f6f206665772068617368657320776572652072657175657374656420746f2062652075706772616465642028692e652e207a65726f292e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e99080c4c626f756e6465645f636f6c6c656374696f6e73407765616b5f626f756e6465645f766563385765616b426f756e646564566563080454019d08045300000400a10801185665633c543e00009d0800000408c9012c00a1080000029d0800a5080c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540104045300000400a90801185665633c543e0000a9080000020400ad0804184f7074696f6e04045401b1080108104e6f6e6500000010536f6d650400b1080000010000b1080c4473705f636f6e73656e7375735f626162651c646967657374732450726544696765737400010c1c5072696d6172790400b50801405072696d617279507265446967657374000100385365636f6e64617279506c61696e0400bd08015c5365636f6e64617279506c61696e507265446967657374000200305365636f6e646172795652460400c10801545365636f6e6461727956524650726544696765737400030000b5080c4473705f636f6e73656e7375735f626162651c64696765737473405072696d61727950726544696765737400000c013c617574686f726974795f696e64657810015473757065723a3a417574686f72697479496e646578000110736c6f74cd010110536c6f740001347672665f7369676e6174757265b90801305672665369676e61747572650000b908101c73705f636f72651c737232353531390c767266305672665369676e617475726500000801287072655f6f75747075740401305672665072654f757470757400011470726f6f667502012056726650726f6f660000bd080c4473705f636f6e73656e7375735f626162651c646967657374735c5365636f6e64617279506c61696e507265446967657374000008013c617574686f726974795f696e64657810015473757065723a3a417574686f72697479496e646578000110736c6f74cd010110536c6f740000c1080c4473705f636f6e73656e7375735f626162651c64696765737473545365636f6e6461727956524650726544696765737400000c013c617574686f726974795f696e64657810015473757065723a3a417574686f72697479496e646578000110736c6f74cd010110536c6f740001347672665f7369676e6174757265b90801305672665369676e61747572650000c508084473705f636f6e73656e7375735f62616265584261626545706f6368436f6e66696775726174696f6e000008010463d9010128287536342c2075363429000134616c6c6f7765645f736c6f7473dd010130416c6c6f776564536c6f74730000c9080c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401cd08045300000400d10801185665633c543e0000cd08000004082c1000d108000002cd0800d5080c2c70616c6c65745f626162651870616c6c6574144572726f7204045400011060496e76616c696445717569766f636174696f6e50726f6f660000043101416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c69644b65794f776e65727368697050726f6f66000104310141206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f727400020415014120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e50496e76616c6964436f6e66696775726174696f6e0003048c5375626d697474656420636f6e66696775726174696f6e20697320696e76616c69642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ed9080000040c00187800dd080c3870616c6c65745f696e64696365731870616c6c6574144572726f720404540001142c4e6f7441737369676e65640000048c54686520696e64657820776173206e6f7420616c72656164792061737369676e65642e204e6f744f776e6572000104a454686520696e6465782069732061737369676e656420746f20616e6f74686572206163636f756e742e14496e5573650002047054686520696e64657820776173206e6f7420617661696c61626c652e2c4e6f745472616e73666572000304c854686520736f7572636520616e642064657374696e6174696f6e206163636f756e747320617265206964656e746963616c2e245065726d616e656e74000404d054686520696e646578206973207065726d616e656e7420616e64206d6179206e6f742062652066726565642f6368616e6765642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ee1080c4c626f756e6465645f636f6c6c656374696f6e73407765616b5f626f756e6465645f766563385765616b426f756e64656456656308045401e508045300000400ed0801185665633c543e0000e5080c3c70616c6c65745f62616c616e6365731474797065732c42616c616e63654c6f636b041c42616c616e63650118000c01086964310301384c6f636b4964656e746966696572000118616d6f756e7418011c42616c616e636500011c726561736f6e73e908011c526561736f6e730000e9080c3c70616c6c65745f62616c616e6365731474797065731c526561736f6e7300010c0c466565000000104d6973630001000c416c6c00020000ed08000002e50800f1080c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401f508045300000400f90801185665633c543e0000f5080c3c70616c6c65745f62616c616e6365731474797065732c52657365727665446174610844526573657276654964656e7469666965720131031c42616c616e6365011800080108696431030144526573657276654964656e746966696572000118616d6f756e7418011c42616c616e63650000f908000002f50800fd080c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e646564566563080454010109045300000400110901185665633c543e000001090c3c70616c6c65745f62616c616e636573147479706573204964416d6f756e74080849640105091c42616c616e63650118000801086964050901084964000118616d6f756e7418011c42616c616e6365000005090840706f6c6b61646f745f72756e74696d654452756e74696d65486f6c64526561736f6e00010820507265696d61676504000909016c70616c6c65745f707265696d6167653a3a486f6c64526561736f6e000a00485374617465547269654d6967726174696f6e04000d09019c70616c6c65745f73746174655f747269655f6d6967726174696f6e3a3a486f6c64526561736f6e0062000009090c3c70616c6c65745f707265696d6167651870616c6c657428486f6c64526561736f6e00010420507265696d616765000000000d090c6c70616c6c65745f73746174655f747269655f6d6967726174696f6e1870616c6c657428486f6c64526561736f6e0001043c536c617368466f724d69677261746500000000110900000201090015090c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e646564566563080454011909045300000400250901185665633c543e000019090c3c70616c6c65745f62616c616e636573147479706573204964416d6f756e7408084964011d091c42616c616e636501180008010869641d0901084964000118616d6f756e7418011c42616c616e636500001d090840706f6c6b61646f745f72756e74696d654c52756e74696d65467265657a65526561736f6e0001043c4e6f6d696e6174696f6e506f6f6c7304002109019470616c6c65745f6e6f6d696e6174696f6e5f706f6f6c733a3a467265657a65526561736f6e0027000021090c5c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c731870616c6c657430467265657a65526561736f6e00010438506f6f6c4d696e42616c616e636500000000250900000219090029090c3c70616c6c65745f62616c616e6365731870616c6c6574144572726f720804540004490001303856657374696e6742616c616e63650000049c56657374696e672062616c616e636520746f6f206869676820746f2073656e642076616c75652e544c69717569646974795265737472696374696f6e73000104c84163636f756e74206c6971756964697479207265737472696374696f6e732070726576656e74207769746864726177616c2e4c496e73756666696369656e7442616c616e63650002047842616c616e636520746f6f206c6f7720746f2073656e642076616c75652e484578697374656e7469616c4465706f736974000304ec56616c756520746f6f206c6f7720746f20637265617465206163636f756e742064756520746f206578697374656e7469616c206465706f7369742e34457870656e646162696c697479000404905472616e736665722f7061796d656e7420776f756c64206b696c6c206163636f756e742e5c4578697374696e6756657374696e675363686564756c65000504cc412076657374696e67207363686564756c6520616c72656164792065786973747320666f722074686973206163636f756e742e2c446561644163636f756e740006048c42656e6566696369617279206163636f756e74206d757374207072652d65786973742e3c546f6f4d616e795265736572766573000704b84e756d626572206f66206e616d65642072657365727665732065786365656420604d61785265736572766573602e30546f6f4d616e79486f6c6473000804f84e756d626572206f6620686f6c647320657863656564206056617269616e74436f756e744f663c543a3a52756e74696d65486f6c64526561736f6e3e602e38546f6f4d616e79467265657a6573000904984e756d626572206f6620667265657a65732065786365656420604d6178467265657a6573602e4c49737375616e63654465616374697661746564000a0401015468652069737375616e63652063616e6e6f74206265206d6f6469666965642073696e636520697420697320616c72656164792064656163746976617465642e2444656c74615a65726f000b04645468652064656c74612063616e6e6f74206265207a65726f2e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e2d09086870616c6c65745f7472616e73616374696f6e5f7061796d656e742052656c6561736573000108245631416e6369656e74000000085632000100003109083870616c6c65745f7374616b696e67345374616b696e674c656467657204045400001401147374617368000130543a3a4163636f756e744964000114746f74616cf4013042616c616e63654f663c543e000118616374697665f4013042616c616e63654f663c543e000124756e6c6f636b696e672d0201f0426f756e6465645665633c556e6c6f636b4368756e6b3c42616c616e63654f663c543e3e2c20543a3a4d6178556e6c6f636b696e674368756e6b733e0001586c65676163795f636c61696d65645f7265776172647335090194426f756e6465645665633c457261496e6465782c20543a3a486973746f727944657074683e000035090c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540110045300000400090201185665633c543e00003909083870616c6c65745f7374616b696e672c4e6f6d696e6174696f6e7304045400000c011c746172676574733d0901b4426f756e6465645665633c543a3a4163636f756e7449642c204d61784e6f6d696e6174696f6e734f663c543e3e0001307375626d69747465645f696e100120457261496e64657800012873757070726573736564780110626f6f6c00003d090c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540100045300000400f50101185665633c543e00004109083870616c6c65745f7374616b696e6734416374697665457261496e666f0000080114696e646578100120457261496e64657800011473746172744509012c4f7074696f6e3c7536343e0000450904184f7074696f6e040454012c0108104e6f6e6500000010536f6d6504002c00000100004909000004081000004d09082873705f7374616b696e675450616765644578706f737572654d65746164617461041c42616c616e6365011800100114746f74616cf4011c42616c616e636500010c6f776ef4011c42616c616e636500013c6e6f6d696e61746f725f636f756e7410010c753332000128706167655f636f756e7410011050616765000051090000040c100010005509082873705f7374616b696e67304578706f737572655061676508244163636f756e74496401001c42616c616e6365011800080128706167655f746f74616cf4011c42616c616e63650001186f7468657273f801ac5665633c496e646976696475616c4578706f737572653c4163636f756e7449642c2042616c616e63653e3e00005909083870616c6c65745f7374616b696e673c457261526577617264506f696e747304244163636f756e744964010000080114746f74616c10012c526577617264506f696e74000128696e646976696475616c5d09018042547265654d61703c4163636f756e7449642c20526577617264506f696e743e00005d09042042547265654d617008044b0100045601100004006109000000610900000265090065090000040800100069090000026d09006d09083870616c6c65745f7374616b696e6738556e6170706c696564536c61736808244163636f756e74496401001c42616c616e636501180014012476616c696461746f720001244163636f756e74496400010c6f776e18011c42616c616e63650001186f7468657273bd0401645665633c284163636f756e7449642c2042616c616e6365293e0001247265706f7274657273f50101385665633c4163636f756e7449643e0001187061796f757418011c42616c616e63650000710900000408ac180075090c3870616c6c65745f7374616b696e6720736c617368696e6734536c617368696e675370616e7300001001287370616e5f696e6465781001245370616e496e6465780001286c6173745f7374617274100120457261496e6465780001486c6173745f6e6f6e7a65726f5f736c617368100120457261496e6465780001147072696f72090201345665633c457261496e6465783e000079090c3870616c6c65745f7374616b696e6720736c617368696e67285370616e5265636f7264041c42616c616e636501180008011c736c617368656418011c42616c616e6365000120706169645f6f757418011c42616c616e636500007d090000028109008109000004081078008509103870616c6c65745f7374616b696e671870616c6c65741870616c6c6574144572726f72040454000170344e6f74436f6e74726f6c6c6572000004644e6f74206120636f6e74726f6c6c6572206163636f756e742e204e6f745374617368000104504e6f742061207374617368206163636f756e742e34416c7265616479426f6e64656400020460537461736820697320616c726561647920626f6e6465642e34416c726561647950616972656400030474436f6e74726f6c6c657220697320616c7265616479207061697265642e30456d7074795461726765747300040460546172676574732063616e6e6f7420626520656d7074792e384475706c6963617465496e646578000504404475706c696361746520696e6465782e44496e76616c6964536c617368496e64657800060484536c617368207265636f726420696e646578206f7574206f6620626f756e64732e40496e73756666696369656e74426f6e6400070c590143616e6e6f74206861766520612076616c696461746f72206f72206e6f6d696e61746f7220726f6c652c20776974682076616c7565206c657373207468616e20746865206d696e696d756d20646566696e65642062793d01676f7665726e616e6365202873656520604d696e56616c696461746f72426f6e646020616e6420604d696e4e6f6d696e61746f72426f6e6460292e20496620756e626f6e64696e67206973207468651501696e74656e74696f6e2c20606368696c6c6020666972737420746f2072656d6f7665206f6e65277320726f6c652061732076616c696461746f722f6e6f6d696e61746f722e304e6f4d6f72654368756e6b730008049043616e206e6f74207363686564756c65206d6f726520756e6c6f636b206368756e6b732e344e6f556e6c6f636b4368756e6b000904a043616e206e6f74207265626f6e6420776974686f757420756e6c6f636b696e67206368756e6b732e3046756e646564546172676574000a04c8417474656d7074696e6720746f2074617267657420612073746173682074686174207374696c6c206861732066756e64732e48496e76616c6964457261546f526577617264000b0458496e76616c69642065726120746f207265776172642e68496e76616c69644e756d6265724f664e6f6d696e6174696f6e73000c0478496e76616c6964206e756d626572206f66206e6f6d696e6174696f6e732e484e6f74536f72746564416e64556e69717565000d04804974656d7320617265206e6f7420736f7274656420616e6420756e697175652e38416c7265616479436c61696d6564000e0409015265776172647320666f72207468697320657261206861766520616c7265616479206265656e20636c61696d656420666f7220746869732076616c696461746f722e2c496e76616c696450616765000f04844e6f206e6f6d696e61746f7273206578697374206f6e207468697320706167652e54496e636f7272656374486973746f72794465707468001004c0496e636f72726563742070726576696f757320686973746f727920646570746820696e7075742070726f76696465642e58496e636f7272656374536c617368696e675370616e73001104b0496e636f7272656374206e756d626572206f6620736c617368696e67207370616e732070726f76696465642e2042616453746174650012043901496e7465726e616c20737461746520686173206265636f6d6520736f6d65686f7720636f7272757074656420616e6420746865206f7065726174696f6e2063616e6e6f7420636f6e74696e75652e38546f6f4d616e795461726765747300130494546f6f206d616e79206e6f6d696e6174696f6e207461726765747320737570706c6965642e244261645461726765740014043d0141206e6f6d696e6174696f6e207461726765742077617320737570706c69656420746861742077617320626c6f636b6564206f72206f7468657277697365206e6f7420612076616c696461746f722e4043616e6e6f744368696c6c4f74686572001504550154686520757365722068617320656e6f75676820626f6e6420616e6420746875732063616e6e6f74206265206368696c6c656420666f72636566756c6c7920627920616e2065787465726e616c20706572736f6e2e44546f6f4d616e794e6f6d696e61746f72730016084d0154686572652061726520746f6f206d616e79206e6f6d696e61746f727320696e207468652073797374656d2e20476f7665726e616e6365206e6565647320746f2061646a75737420746865207374616b696e67b473657474696e677320746f206b656570207468696e6773207361666520666f72207468652072756e74696d652e44546f6f4d616e7956616c696461746f7273001708550154686572652061726520746f6f206d616e792076616c696461746f722063616e6469646174657320696e207468652073797374656d2e20476f7665726e616e6365206e6565647320746f2061646a75737420746865d47374616b696e672073657474696e677320746f206b656570207468696e6773207361666520666f72207468652072756e74696d652e40436f6d6d697373696f6e546f6f4c6f77001804e0436f6d6d697373696f6e20697320746f6f206c6f772e204d757374206265206174206c6561737420604d696e436f6d6d697373696f6e602e2c426f756e644e6f744d657400190458536f6d6520626f756e64206973206e6f74206d65742e50436f6e74726f6c6c657244657072656361746564001a04010155736564207768656e20617474656d7074696e6720746f20757365206465707265636174656420636f6e74726f6c6c6572206163636f756e74206c6f6769632e4c43616e6e6f74526573746f72654c6564676572001b045843616e6e6f742072657365742061206c65646765722e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e89090c2873705f7374616b696e671c6f6666656e6365384f6666656e636544657461696c7308205265706f727465720100204f6666656e64657201ec000801206f6666656e646572ec01204f6666656e6465720001247265706f7274657273f50101345665633c5265706f727465723e00008d0900000408c034009109000002950900950900000408003d02009909000004089d0934009d090c1c73705f636f72651863727970746f244b65795479706549640000040044011c5b75383b20345d0000a1090c3870616c6c65745f73657373696f6e1870616c6c6574144572726f7204045400011430496e76616c696450726f6f6600000460496e76616c6964206f776e6572736869702070726f6f662e5c4e6f4173736f63696174656456616c696461746f7249640001049c4e6f206173736f6369617465642076616c696461746f7220494420666f72206163636f756e742e344475706c6963617465644b65790002046452656769737465726564206475706c6963617465206b65792e184e6f4b657973000304a44e6f206b65797320617265206173736f63696174656420776974682074686973206163636f756e742e244e6f4163636f756e7400040419014b65792073657474696e67206163636f756e74206973206e6f74206c6976652c20736f206974277320696d706f737369626c6520746f206173736f6369617465206b6579732e04744572726f7220666f72207468652073657373696f6e2070616c6c65742ea509083870616c6c65745f6772616e6470612c53746f726564537461746504044e01100110104c6976650000003050656e64696e6750617573650801307363686564756c65645f61741001044e00011464656c61791001044e000100185061757365640002003450656e64696e67526573756d650801307363686564756c65645f61741001044e00011464656c61791001044e00030000a909083870616c6c65745f6772616e6470614c53746f72656450656e64696e674368616e676508044e0110144c696d697400001001307363686564756c65645f61741001044e00011464656c61791001044e0001406e6578745f617574686f726974696573ad09016c426f756e646564417574686f726974794c6973743c4c696d69743e000118666f726365648d0201244f7074696f6e3c4e3e0000ad090c4c626f756e6465645f636f6c6c656374696f6e73407765616b5f626f756e6465645f766563385765616b426f756e64656456656308045401d0045300000400cc01185665633c543e0000b1090c3870616c6c65745f6772616e6470611870616c6c6574144572726f7204045400011c2c50617573654661696c65640000080501417474656d707420746f207369676e616c204752414e445041207061757365207768656e2074686520617574686f72697479207365742069736e2774206c697665a42865697468657220706175736564206f7220616c72656164792070656e64696e67207061757365292e30526573756d654661696c65640001081101417474656d707420746f207369676e616c204752414e44504120726573756d65207768656e2074686520617574686f72697479207365742069736e277420706175736564a028656974686572206c697665206f7220616c72656164792070656e64696e6720726573756d65292e344368616e676550656e64696e67000204e8417474656d707420746f207369676e616c204752414e445041206368616e67652077697468206f6e6520616c72656164792070656e64696e672e1c546f6f536f6f6e000304bc43616e6e6f74207369676e616c20666f72636564206368616e676520736f20736f6f6e206166746572206c6173742e60496e76616c69644b65794f776e65727368697050726f6f66000404310141206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c696445717569766f636174696f6e50726f6f660005043101416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f727400060415014120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742eb5090c4c626f756e6465645f636f6c6c656374696f6e73407765616b5f626f756e6465645f766563385765616b426f756e646564566563080454014902045300000400b90901185665633c543e0000b909000002490200bd09083c70616c6c65745f74726561737572792050726f706f73616c08244163636f756e74496401001c42616c616e636501180010012070726f706f7365720001244163636f756e74496400011476616c756518011c42616c616e636500012c62656e65666963696172790001244163636f756e744964000110626f6e6418011c42616c616e63650000c1090c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540110045300000400090201185665633c543e0000c509083c70616c6c65745f74726561737572792c5370656e64537461747573142441737365744b696e6401050130417373657442616c616e636501182c42656e65666963696172790169012c426c6f636b4e756d6265720110245061796d656e744964012c0018012861737365745f6b696e640501012441737365744b696e64000118616d6f756e74180130417373657442616c616e636500012c62656e65666963696172796901012c42656e656669636961727900012876616c69645f66726f6d10012c426c6f636b4e756d6265720001246578706972655f617410012c426c6f636b4e756d626572000118737461747573c909015c5061796d656e7453746174653c5061796d656e7449643e0000c909083c70616c6c65745f7472656173757279305061796d656e74537461746504084964012c010c1c50656e64696e6700000024417474656d7074656404010869642c01084964000100184661696c656400020000cd090c3473705f61726974686d65746963287065725f7468696e67731c5065726d696c6c0000040010010c7533320000d10908346672616d655f737570706f72742050616c6c65744964000004003103011c5b75383b20385d0000d5090c3c70616c6c65745f74726561737572791870616c6c6574144572726f7208045400044900013070496e73756666696369656e7450726f706f7365727342616c616e63650000047850726f706f73657227732062616c616e636520697320746f6f206c6f772e30496e76616c6964496e646578000104ac4e6f2070726f706f73616c2c20626f756e7479206f72207370656e64206174207468617420696e6465782e40546f6f4d616e79417070726f76616c7300020480546f6f206d616e7920617070726f76616c7320696e207468652071756575652e58496e73756666696369656e745065726d697373696f6e0003084501546865207370656e64206f726967696e2069732076616c6964206275742074686520616d6f756e7420697420697320616c6c6f77656420746f207370656e64206973206c6f776572207468616e207468654c616d6f756e7420746f206265207370656e742e4c50726f706f73616c4e6f74417070726f7665640004047c50726f706f73616c20686173206e6f74206265656e20617070726f7665642e584661696c6564546f436f6e7665727442616c616e636500050451015468652062616c616e6365206f6620746865206173736574206b696e64206973206e6f7420636f6e7665727469626c6520746f207468652062616c616e6365206f6620746865206e61746976652061737365742e305370656e6445787069726564000604b0546865207370656e6420686173206578706972656420616e642063616e6e6f7420626520636c61696d65642e2c4561726c795061796f7574000704a4546865207370656e64206973206e6f742079657420656c696769626c6520666f72207061796f75742e40416c7265616479417474656d707465640008049c546865207061796d656e742068617320616c7265616479206265656e20617474656d707465642e2c5061796f75744572726f72000904cc54686572652077617320736f6d65206973737565207769746820746865206d656368616e69736d206f66207061796d656e742e304e6f74417474656d70746564000a04a4546865207061796f757420776173206e6f742079657420617474656d707465642f636c61696d65642e30496e636f6e636c7573697665000b04c4546865207061796d656e7420686173206e656974686572206661696c6564206e6f7220737563636565646564207965742e04784572726f7220666f72207468652074726561737572792070616c6c65742ed9090000040800910100dd090c6070616c6c65745f636f6e76696374696f6e5f766f74696e6710766f746518566f74696e67141c42616c616e63650118244163636f756e74496401002c426c6f636b4e756d626572011024506f6c6c496e6465780110204d6178566f7465730001081c43617374696e670400e10901c843617374696e673c42616c616e63652c20426c6f636b4e756d6265722c20506f6c6c496e6465782c204d6178566f7465733e0000002844656c65676174696e670400f90901ac44656c65676174696e673c42616c616e63652c204163636f756e7449642c20426c6f636b4e756d6265723e00010000e1090c6070616c6c65745f636f6e76696374696f6e5f766f74696e6710766f74651c43617374696e67101c42616c616e636501182c426c6f636b4e756d626572011024506f6c6c496e6465780110204d6178566f74657300000c0114766f746573e50901dc426f756e6465645665633c28506f6c6c496e6465782c204163636f756e74566f74653c42616c616e63653e292c204d6178566f7465733e00012c64656c65676174696f6e73f109015044656c65676174696f6e733c42616c616e63653e0001147072696f72f509017c5072696f724c6f636b3c426c6f636b4e756d6265722c2042616c616e63653e0000e5090c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401e909045300000400ed0901185665633c543e0000e9090000040810950200ed09000002e90900f1090c6070616c6c65745f636f6e76696374696f6e5f766f74696e671474797065732c44656c65676174696f6e73041c42616c616e6365011800080114766f74657318011c42616c616e636500011c6361706974616c18011c42616c616e63650000f5090c6070616c6c65745f636f6e76696374696f6e5f766f74696e6710766f7465245072696f724c6f636b082c426c6f636b4e756d62657201101c42616c616e6365011800080010012c426c6f636b4e756d626572000018011c42616c616e63650000f9090c6070616c6c65745f636f6e76696374696f6e5f766f74696e6710766f74652844656c65676174696e670c1c42616c616e63650118244163636f756e74496401002c426c6f636b4e756d62657201100014011c62616c616e636518011c42616c616e63650001187461726765740001244163636f756e744964000128636f6e76696374696f6e9d020128436f6e76696374696f6e00012c64656c65676174696f6e73f109015044656c65676174696f6e733c42616c616e63653e0001147072696f72f509017c5072696f724c6f636b3c426c6f636b4e756d6265722c2042616c616e63653e0000fd090c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401010a045300000400050a01185665633c543e0000010a0000040891011800050a000002010a00090a0c6070616c6c65745f636f6e76696374696f6e5f766f74696e671870616c6c6574144572726f72080454000449000130284e6f744f6e676f696e6700000450506f6c6c206973206e6f74206f6e676f696e672e204e6f74566f746572000104ac54686520676976656e206163636f756e7420646964206e6f7420766f7465206f6e2074686520706f6c6c2e304e6f5065726d697373696f6e000204c8546865206163746f7220686173206e6f207065726d697373696f6e20746f20636f6e647563742074686520616374696f6e2e3c4e6f5065726d697373696f6e5965740003045901546865206163746f7220686173206e6f207065726d697373696f6e20746f20636f6e647563742074686520616374696f6e207269676874206e6f77206275742077696c6c20646f20696e20746865206675747572652e44416c726561647944656c65676174696e6700040488546865206163636f756e7420697320616c72656164792064656c65676174696e672e34416c7265616479566f74696e670005085501546865206163636f756e742063757272656e746c792068617320766f74657320617474616368656420746f20697420616e6420746865206f7065726174696f6e2063616e6e6f74207375636365656420756e74696ce87468657365206172652072656d6f7665642c20656974686572207468726f7567682060756e766f746560206f722060726561705f766f7465602e44496e73756666696369656e7446756e6473000604fc546f6f206869676820612062616c616e6365207761732070726f7669646564207468617420746865206163636f756e742063616e6e6f74206166666f72642e344e6f7444656c65676174696e67000704a0546865206163636f756e74206973206e6f742063757272656e746c792064656c65676174696e672e204e6f6e73656e73650008049444656c65676174696f6e20746f206f6e6573656c66206d616b6573206e6f2073656e73652e3c4d6178566f74657352656163686564000904804d6178696d756d206e756d626572206f6620766f74657320726561636865642e2c436c6173734e6565646564000a04390154686520636c617373206d75737420626520737570706c6965642073696e6365206974206973206e6f7420656173696c792064657465726d696e61626c652066726f6d207468652073746174652e20426164436c617373000b048454686520636c61737320494420737570706c69656420697320696e76616c69642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e0d0a0c4070616c6c65745f7265666572656e6461147479706573385265666572656e64756d496e666f201c547261636b49640191013452756e74696d654f726967696e01a902184d6f6d656e7401101043616c6c0195011c42616c616e636501181454616c6c79017907244163636f756e74496401003c5363686564756c6541646472657373018001181c4f6e676f696e670400110a018d015265666572656e64756d5374617475733c547261636b49642c2052756e74696d654f726967696e2c204d6f6d656e742c2043616c6c2c2042616c616e63652c2054616c6c792c0a4163636f756e7449642c205363686564756c65416464726573732c3e00000020417070726f7665640c001001184d6f6d656e740000190a018c4f7074696f6e3c4465706f7369743c4163636f756e7449642c2042616c616e63653e3e0000190a018c4f7074696f6e3c4465706f7369743c4163636f756e7449642c2042616c616e63653e3e0001002052656a65637465640c001001184d6f6d656e740000190a018c4f7074696f6e3c4465706f7369743c4163636f756e7449642c2042616c616e63653e3e0000190a018c4f7074696f6e3c4465706f7369743c4163636f756e7449642c2042616c616e63653e3e0002002443616e63656c6c65640c001001184d6f6d656e740000190a018c4f7074696f6e3c4465706f7369743c4163636f756e7449642c2042616c616e63653e3e0000190a018c4f7074696f6e3c4465706f7369743c4163636f756e7449642c2042616c616e63653e3e0003002054696d65644f75740c001001184d6f6d656e740000190a018c4f7074696f6e3c4465706f7369743c4163636f756e7449642c2042616c616e63653e3e0000190a018c4f7074696f6e3c4465706f7369743c4163636f756e7449642c2042616c616e63653e3e000400184b696c6c656404001001184d6f6d656e7400050000110a0c4070616c6c65745f7265666572656e6461147479706573405265666572656e64756d537461747573201c547261636b49640191013452756e74696d654f726967696e01a902184d6f6d656e7401101043616c6c0195011c42616c616e636501181454616c6c79017907244163636f756e74496401003c5363686564756c65416464726573730180002c0114747261636b9101011c547261636b49640001186f726967696ea902013452756e74696d654f726967696e00012070726f706f73616c9501011043616c6c000124656e6163746d656e74c5020150446973706174636854696d653c4d6f6d656e743e0001247375626d69747465641001184d6f6d656e740001487375626d697373696f6e5f6465706f736974150a016c4465706f7369743c4163636f756e7449642c2042616c616e63653e0001406465636973696f6e5f6465706f736974190a018c4f7074696f6e3c4465706f7369743c4163636f756e7449642c2042616c616e63653e3e0001206465636964696e671d0a01784f7074696f6e3c4465636964696e675374617475733c4d6f6d656e743e3e00011474616c6c797907011454616c6c79000120696e5f7175657565780110626f6f6c000114616c61726d250a01844f7074696f6e3c284d6f6d656e742c205363686564756c6541646472657373293e0000150a0c4070616c6c65745f7265666572656e64611474797065731c4465706f73697408244163636f756e74496401001c42616c616e636501180008010c77686f0001244163636f756e744964000118616d6f756e7418011c42616c616e63650000190a04184f7074696f6e04045401150a0108104e6f6e6500000010536f6d650400150a00000100001d0a04184f7074696f6e04045401210a0108104e6f6e6500000010536f6d650400210a0000010000210a0c4070616c6c65745f7265666572656e6461147479706573384465636964696e67537461747573042c426c6f636b4e756d62657201100008011473696e636510012c426c6f636b4e756d626572000128636f6e6669726d696e678d02014c4f7074696f6e3c426c6f636b4e756d6265723e0000250a04184f7074696f6e04045401290a0108104e6f6e6500000010536f6d650400290a0000010000290a000004081080002d0a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401310a045300000400350a01185665633c543e0000310a00000408101800350a000002310a00390a0000023d0a003d0a000004089101410a00410a0c4070616c6c65745f7265666572656e646114747970657324547261636b496e666f081c42616c616e63650118184d6f6d656e740110002401106e616d652d0801302627737461746963207374720001306d61785f6465636964696e6710010c7533320001406465636973696f6e5f6465706f73697418011c42616c616e6365000138707265706172655f706572696f641001184d6f6d656e7400013c6465636973696f6e5f706572696f641001184d6f6d656e74000138636f6e6669726d5f706572696f641001184d6f6d656e740001506d696e5f656e6163746d656e745f706572696f641001184d6f6d656e740001306d696e5f617070726f76616c450a0114437572766500012c6d696e5f737570706f7274450a011443757276650000450a0c4070616c6c65745f7265666572656e646114747970657314437572766500010c404c696e65617244656372656173696e670c01186c656e677468ac011c50657262696c6c000114666c6f6f72ac011c50657262696c6c0001106365696cac011c50657262696c6c000000445374657070656444656372656173696e67100114626567696eac011c50657262696c6c00010c656e64ac011c50657262696c6c00011073746570ac011c50657262696c6c000118706572696f64ac011c50657262696c6c000100285265636970726f63616c0c0118666163746f72490a01204669786564493634000120785f6f6666736574490a01204669786564493634000120795f6f6666736574490a0120466978656449363400020000490a0c3473705f61726974686d657469632c66697865645f706f696e74204669786564493634000004004d0a010c69363400004d0a0000050c00510a0c4070616c6c65745f7265666572656e64611870616c6c6574144572726f72080454000449000134284e6f744f6e676f696e67000004685265666572656e64756d206973206e6f74206f6e676f696e672e284861734465706f736974000104b85265666572656e64756d2773206465636973696f6e206465706f73697420697320616c726561647920706169642e20426164547261636b0002049c54686520747261636b206964656e74696669657220676976656e2077617320696e76616c69642e1046756c6c000304310154686572652061726520616c726561647920612066756c6c20636f6d706c656d656e74206f66207265666572656e646120696e2070726f677265737320666f72207468697320747261636b2e285175657565456d70747900040480546865207175657565206f662074686520747261636b20697320656d7074792e344261645265666572656e64756d000504e4546865207265666572656e64756d20696e6465782070726f766964656420697320696e76616c696420696e207468697320636f6e746578742e2c4e6f7468696e67546f446f000604ac546865726520776173206e6f7468696e6720746f20646f20696e2074686520616476616e63656d656e742e1c4e6f547261636b000704a04e6f20747261636b2065786973747320666f72207468652070726f706f73616c206f726967696e2e28556e66696e69736865640008040101416e79206465706f7369742063616e6e6f7420626520726566756e64656420756e74696c20616674657220746865206465636973696f6e206973206f7665722e304e6f5065726d697373696f6e000904a8546865206465706f73697420726566756e646572206973206e6f7420746865206465706f7369746f722e244e6f4465706f736974000a04cc546865206465706f7369742063616e6e6f7420626520726566756e6465642073696e6365206e6f6e6520776173206d6164652e24426164537461747573000b04d0546865207265666572656e64756d2073746174757320697320696e76616c696420666f722074686973206f7065726174696f6e2e40507265696d6167654e6f744578697374000c047054686520707265696d61676520646f6573206e6f742065786973742e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e550a0c4070616c6c65745f77686974656c6973741870616c6c6574144572726f720404540001144c556e617661696c61626c65507265496d616765000004c854686520707265696d616765206f66207468652063616c6c206861736820636f756c64206e6f74206265206c6f616465642e3c556e6465636f6461626c6543616c6c000104785468652063616c6c20636f756c64206e6f74206265206465636f6465642e60496e76616c696443616c6c5765696768745769746e657373000204ec54686520776569676874206f6620746865206465636f6465642063616c6c2077617320686967686572207468616e20746865207769746e6573732e5043616c6c49734e6f7457686974656c6973746564000304745468652063616c6c20776173206e6f742077686974656c69737465642e5843616c6c416c726561647957686974656c6973746564000404a05468652063616c6c2077617320616c72656164792077686974656c69737465643b204e6f2d4f702e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e590a105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e18636c61696d731870616c6c6574144572726f7204045400011860496e76616c6964457468657265756d5369676e61747572650000046c496e76616c696420457468657265756d207369676e61747572652e405369676e65724861734e6f436c61696d00010478457468657265756d206164647265737320686173206e6f20636c61696d2e4053656e6465724861734e6f436c61696d000204b04163636f756e742049442073656e64696e67207472616e73616374696f6e20686173206e6f20636c61696d2e30506f74556e646572666c6f77000308490154686572652773206e6f7420656e6f75676820696e2074686520706f7420746f20706179206f757420736f6d6520756e76657374656420616d6f756e742e2047656e6572616c6c7920696d706c6965732061306c6f676963206572726f722e40496e76616c696453746174656d656e740004049041206e65656465642073746174656d656e7420776173206e6f7420696e636c756465642e4c56657374656442616c616e6365457869737473000504a4546865206163636f756e7420616c7265616479206861732061207665737465642062616c616e63652e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e5d0a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401f502045300000400610a01185665633c543e0000610a000002f50200650a083870616c6c65745f76657374696e672052656c656173657300010808563000000008563100010000690a0c3870616c6c65745f76657374696e671870616c6c6574144572726f72040454000114284e6f7456657374696e6700000484546865206163636f756e7420676976656e206973206e6f742076657374696e672e5441744d617856657374696e675363686564756c65730001082501546865206163636f756e7420616c72656164792068617320604d617856657374696e675363686564756c65736020636f756e74206f66207363686564756c657320616e642074687573510163616e6e6f742061646420616e6f74686572206f6e652e20436f6e7369646572206d657267696e67206578697374696e67207363686564756c657320696e206f7264657220746f2061646420616e6f746865722e24416d6f756e744c6f770002040501416d6f756e74206265696e67207472616e7366657272656420697320746f6f206c6f7720746f2063726561746520612076657374696e67207363686564756c652e605363686564756c65496e6465784f75744f66426f756e6473000304d0416e20696e64657820776173206f7574206f6620626f756e6473206f66207468652076657374696e67207363686564756c65732e54496e76616c69645363686564756c65506172616d730004040d014661696c656420746f206372656174652061206e6577207363686564756c65206265636175736520736f6d6520706172616d657465722077617320696e76616c69642e04744572726f7220666f72207468652076657374696e672070616c6c65742e6d0a0c3870616c6c65745f7574696c6974791870616c6c6574144572726f7204045400010430546f6f4d616e7943616c6c730000045c546f6f206d616e792063616c6c7320626174636865642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e710a00000408750a850a00750a0c3c70616c6c65745f6964656e7469747914747970657330526567697374726174696f6e0c1c42616c616e63650118344d61784a756467656d656e747300304964656e74697479496e666f010503000c01286a756467656d656e7473790a01fc426f756e6465645665633c28526567697374726172496e6465782c204a756467656d656e743c42616c616e63653e292c204d61784a756467656d656e74733e00011c6465706f73697418011c42616c616e6365000110696e666f050301304964656e74697479496e666f0000790a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e646564566563080454017d0a045300000400810a01185665633c543e00007d0a0000040810990300810a0000027d0a00850a04184f7074696f6e04045401ad030108104e6f6e6500000010536f6d650400ad030000010000890a00000408188d0a008d0a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540100045300000400f50101185665633c543e0000910a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401950a0453000004009d0a01185665633c543e0000950a04184f7074696f6e04045401990a0108104e6f6e6500000010536f6d650400990a0000010000990a0c3c70616c6c65745f6964656e7469747914747970657334526567697374726172496e666f0c1c42616c616e63650118244163636f756e74496401001c49644669656c64012c000c011c6163636f756e740001244163636f756e74496400010c66656518011c42616c616e63650001186669656c64732c011c49644669656c6400009d0a000002950a00a10a0c3c70616c6c65745f6964656e746974791474797065734c417574686f7269747950726f70657274696573041853756666697801a50a00080118737566666978a50a0118537566666978000128616c6c6f636174696f6e100128416c6c6f636174696f6e0000a50a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e0000a90a0c3c70616c6c65745f6964656e746974791870616c6c6574144572726f7204045400016848546f6f4d616e795375624163636f756e74730000045c546f6f206d616e7920737562732d6163636f756e74732e204e6f74466f756e64000104504163636f756e742069736e277420666f756e642e204e6f744e616d6564000204504163636f756e742069736e2774206e616d65642e28456d707479496e64657800030430456d70747920696e6465782e284665654368616e6765640004043c466565206973206368616e6765642e284e6f4964656e74697479000504484e6f206964656e7469747920666f756e642e3c537469636b794a756467656d656e7400060444537469636b79206a756467656d656e742e384a756467656d656e74476976656e000704404a756467656d656e7420676976656e2e40496e76616c69644a756467656d656e7400080448496e76616c6964206a756467656d656e742e30496e76616c6964496e6465780009045454686520696e64657820697320696e76616c69642e34496e76616c6964546172676574000a04585468652074617267657420697320696e76616c69642e44546f6f4d616e7952656769737472617273000b04e84d6178696d756d20616d6f756e74206f66207265676973747261727320726561636865642e2043616e6e6f742061646420616e79206d6f72652e38416c7265616479436c61696d6564000c04704163636f756e7420494420697320616c7265616479206e616d65642e184e6f74537562000d047053656e646572206973206e6f742061207375622d6163636f756e742e204e6f744f776e6564000e04885375622d6163636f756e742069736e2774206f776e65642062792073656e6465722e744a756467656d656e74466f72446966666572656e744964656e74697479000f04d05468652070726f7669646564206a756467656d656e742077617320666f72206120646966666572656e74206964656e746974792e584a756467656d656e745061796d656e744661696c6564001004f84572726f722074686174206f6363757273207768656e20746865726520697320616e20697373756520706179696e6720666f72206a756467656d656e742e34496e76616c6964537566666978001104805468652070726f76696465642073756666697820697320746f6f206c6f6e672e504e6f74557365726e616d65417574686f72697479001204e05468652073656e64657220646f6573206e6f742068617665207065726d697373696f6e20746f206973737565206120757365726e616d652e304e6f416c6c6f636174696f6e001304c454686520617574686f726974792063616e6e6f7420616c6c6f6361746520616e79206d6f726520757365726e616d65732e40496e76616c69645369676e6174757265001404a8546865207369676e6174757265206f6e206120757365726e616d6520776173206e6f742076616c69642e4452657175697265735369676e6174757265001504090153657474696e67207468697320757365726e616d652072657175697265732061207369676e61747572652c20627574206e6f6e65207761732070726f76696465642e3c496e76616c6964557365726e616d65001604b054686520757365726e616d6520646f6573206e6f74206d6565742074686520726571756972656d656e74732e34557365726e616d6554616b656e0017047854686520757365726e616d6520697320616c72656164792074616b656e2e284e6f557365726e616d65001804985468652072657175657374656420757365726e616d6520646f6573206e6f742065786973742e284e6f74457870697265640019042d0154686520757365726e616d652063616e6e6f7420626520666f72636566756c6c792072656d6f76656420626563617573652069742063616e207374696c6c2062652061636365707465642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ead0a00000408b10a1800b10a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401b50a045300000400b90a01185665633c543e0000b50a083070616c6c65745f70726f78793c50726f7879446566696e6974696f6e0c244163636f756e74496401002450726f78795479706501b9032c426c6f636b4e756d6265720110000c012064656c65676174650001244163636f756e74496400012870726f78795f74797065b903012450726f78795479706500011464656c617910012c426c6f636b4e756d6265720000b90a000002b50a00bd0a00000408c10a1800c10a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401c50a045300000400c90a01185665633c543e0000c50a083070616c6c65745f70726f787930416e6e6f756e63656d656e740c244163636f756e7449640100104861736801302c426c6f636b4e756d6265720110000c01107265616c0001244163636f756e74496400012463616c6c5f686173683001104861736800011868656967687410012c426c6f636b4e756d6265720000c90a000002c50a00cd0a0c3070616c6c65745f70726f78791870616c6c6574144572726f720404540001201c546f6f4d616e79000004210154686572652061726520746f6f206d616e792070726f786965732072656769737465726564206f7220746f6f206d616e7920616e6e6f756e63656d656e74732070656e64696e672e204e6f74466f756e640001047450726f787920726567697374726174696f6e206e6f7420666f756e642e204e6f7450726f7879000204cc53656e646572206973206e6f7420612070726f7879206f6620746865206163636f756e7420746f2062652070726f786965642e2c556e70726f787961626c650003042101412063616c6c20776869636820697320696e636f6d70617469626c652077697468207468652070726f7879207479706527732066696c7465722077617320617474656d707465642e244475706c69636174650004046c4163636f756e7420697320616c726561647920612070726f78792e304e6f5065726d697373696f6e000504150143616c6c206d6179206e6f74206265206d6164652062792070726f78792062656361757365206974206d617920657363616c617465206974732070726976696c656765732e2c556e616e6e6f756e636564000604d0416e6e6f756e63656d656e742c206966206d61646520617420616c6c2c20776173206d61646520746f6f20726563656e746c792e2c4e6f53656c6650726f78790007046443616e6e6f74206164642073656c662061732070726f78792e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ed10a00000408000400d50a083c70616c6c65745f6d756c7469736967204d756c7469736967102c426c6f636b4e756d62657201101c42616c616e63650118244163636f756e7449640100304d6178417070726f76616c7300001001107768656ec503015854696d65706f696e743c426c6f636b4e756d6265723e00011c6465706f73697418011c42616c616e63650001246465706f7369746f720001244163636f756e744964000124617070726f76616c73d90a018c426f756e6465645665633c4163636f756e7449642c204d6178417070726f76616c733e0000d90a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540100045300000400f50101185665633c543e0000dd0a0c3c70616c6c65745f6d756c74697369671870616c6c6574144572726f72040454000138404d696e696d756d5468726573686f6c640000047c5468726573686f6c64206d7573742062652032206f7220677265617465722e3c416c7265616479417070726f766564000104ac43616c6c20697320616c726561647920617070726f7665642062792074686973207369676e61746f72792e444e6f417070726f76616c734e65656465640002049c43616c6c20646f65736e2774206e65656420616e7920286d6f72652920617070726f76616c732e44546f6f4665775369676e61746f72696573000304a854686572652061726520746f6f20666577207369676e61746f7269657320696e20746865206c6973742e48546f6f4d616e795369676e61746f72696573000404ac54686572652061726520746f6f206d616e79207369676e61746f7269657320696e20746865206c6973742e545369676e61746f726965734f75744f664f726465720005040d01546865207369676e61746f7269657320776572652070726f7669646564206f7574206f66206f726465723b20746865792073686f756c64206265206f7264657265642e4c53656e646572496e5369676e61746f726965730006040d015468652073656e6465722077617320636f6e7461696e656420696e20746865206f74686572207369676e61746f726965733b2069742073686f756c646e27742062652e204e6f74466f756e64000704dc4d756c7469736967206f7065726174696f6e206e6f7420666f756e64207768656e20617474656d7074696e6720746f2063616e63656c2e204e6f744f776e65720008042d014f6e6c7920746865206163636f756e742074686174206f726967696e616c6c79206372656174656420746865206d756c74697369672069732061626c6520746f2063616e63656c2069742e2c4e6f54696d65706f696e740009041d014e6f2074696d65706f696e742077617320676976656e2c2079657420746865206d756c7469736967206f7065726174696f6e20697320616c726561647920756e6465727761792e3857726f6e6754696d65706f696e74000a042d014120646966666572656e742074696d65706f696e742077617320676976656e20746f20746865206d756c7469736967206f7065726174696f6e207468617420697320756e6465727761792e4c556e657870656374656454696d65706f696e74000b04f4412074696d65706f696e742077617320676976656e2c20796574206e6f206d756c7469736967206f7065726174696f6e20697320756e6465727761792e3c4d6178576569676874546f6f4c6f77000c04d0546865206d6178696d756d2077656967687420696e666f726d6174696f6e2070726f76696465642077617320746f6f206c6f772e34416c726561647953746f726564000d04a0546865206461746120746f2062652073746f72656420697320616c72656164792073746f7265642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ee10a083c70616c6c65745f626f756e7469657318426f756e74790c244163636f756e74496401001c42616c616e636501182c426c6f636b4e756d62657201100018012070726f706f7365720001244163636f756e74496400011476616c756518011c42616c616e636500010c66656518011c42616c616e636500013c63757261746f725f6465706f73697418011c42616c616e6365000110626f6e6418011c42616c616e6365000118737461747573e50a0190426f756e74795374617475733c4163636f756e7449642c20426c6f636b4e756d6265723e0000e50a083c70616c6c65745f626f756e7469657330426f756e747953746174757308244163636f756e74496401002c426c6f636b4e756d626572011001182050726f706f73656400000020417070726f7665640001001846756e6465640002003c43757261746f7250726f706f73656404011c63757261746f720001244163636f756e7449640003001841637469766508011c63757261746f720001244163636f756e7449640001287570646174655f64756510012c426c6f636b4e756d6265720004003450656e64696e675061796f75740c011c63757261746f720001244163636f756e74496400012c62656e65666963696172790001244163636f756e744964000124756e6c6f636b5f617410012c426c6f636b4e756d62657200050000e90a0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e0000ed0a0c3c70616c6c65745f626f756e746965731870616c6c6574144572726f7208045400044900012c70496e73756666696369656e7450726f706f7365727342616c616e63650000047850726f706f73657227732062616c616e636520697320746f6f206c6f772e30496e76616c6964496e646578000104904e6f2070726f706f73616c206f7220626f756e7479206174207468617420696e6465782e30526561736f6e546f6f4269670002048454686520726561736f6e20676976656e206973206a75737420746f6f206269672e40556e65787065637465645374617475730003048054686520626f756e74792073746174757320697320756e65787065637465642e385265717569726543757261746f720004045c5265717569726520626f756e74792063757261746f722e30496e76616c696456616c756500050454496e76616c696420626f756e74792076616c75652e28496e76616c69644665650006044c496e76616c696420626f756e7479206665652e3450656e64696e675061796f75740007086c4120626f756e7479207061796f75742069732070656e64696e672ef8546f2063616e63656c2074686520626f756e74792c20796f75206d75737420756e61737369676e20616e6420736c617368207468652063757261746f722e245072656d6174757265000804450154686520626f756e746965732063616e6e6f7420626520636c61696d65642f636c6f73656420626563617573652069742773207374696c6c20696e2074686520636f756e74646f776e20706572696f642e504861734163746976654368696c64426f756e7479000904050154686520626f756e74792063616e6e6f7420626520636c6f73656420626563617573652069742068617320616374697665206368696c6420626f756e746965732e34546f6f4d616e79517565756564000a0498546f6f206d616e7920617070726f76616c732061726520616c7265616479207175657565642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ef10a085470616c6c65745f6368696c645f626f756e746965732c4368696c64426f756e74790c244163636f756e74496401001c42616c616e636501182c426c6f636b4e756d626572011000140134706172656e745f626f756e747910012c426f756e7479496e64657800011476616c756518011c42616c616e636500010c66656518011c42616c616e636500013c63757261746f725f6465706f73697418011c42616c616e6365000118737461747573f50a01a44368696c64426f756e74795374617475733c4163636f756e7449642c20426c6f636b4e756d6265723e0000f50a085470616c6c65745f6368696c645f626f756e74696573444368696c64426f756e747953746174757308244163636f756e74496401002c426c6f636b4e756d626572011001101441646465640000003c43757261746f7250726f706f73656404011c63757261746f720001244163636f756e7449640001001841637469766504011c63757261746f720001244163636f756e7449640002003450656e64696e675061796f75740c011c63757261746f720001244163636f756e74496400012c62656e65666963696172790001244163636f756e744964000124756e6c6f636b5f617410012c426c6f636b4e756d62657200030000f90a0c5470616c6c65745f6368696c645f626f756e746965731870616c6c6574144572726f7204045400010c54506172656e74426f756e74794e6f74416374697665000004a454686520706172656e7420626f756e7479206973206e6f7420696e206163746976652073746174652e64496e73756666696369656e74426f756e747942616c616e6365000104e454686520626f756e74792062616c616e6365206973206e6f7420656e6f75676820746f20616464206e6577206368696c642d626f756e74792e50546f6f4d616e794368696c64426f756e746965730002040d014e756d626572206f66206368696c6420626f756e746965732065786365656473206c696d697420604d61784163746976654368696c64426f756e7479436f756e74602e048054686520604572726f726020656e756d206f6620746869732070616c6c65742efd0a089070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f7068617365345265616479536f6c7574696f6e08244163636f756e74496400284d617857696e6e65727300000c0120737570706f727473010b0198426f756e646564537570706f7274733c4163636f756e7449642c204d617857696e6e6572733e00011473636f7265a5040134456c656374696f6e53636f726500011c636f6d70757465b507013c456c656374696f6e436f6d707574650000010b0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401b504045300000400b10401185665633c543e0000050b089070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f706861736534526f756e64536e617073686f7408244163636f756e7449640100304461746150726f766964657201090b00080118766f746572730d0b01445665633c4461746150726f76696465723e00011c74617267657473f50101385665633c4163636f756e7449643e0000090b0000040c002c3d09000d0b000002090b00110b0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401150b045300000400190b01185665633c543e0000150b0000040ca504101000190b000002150b001d0b0c9070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f7068617365187369676e6564405369676e65645375626d697373696f6e0c244163636f756e74496401001c42616c616e6365011820536f6c7574696f6e01d9030010010c77686f0001244163636f756e74496400011c6465706f73697418011c42616c616e63650001307261775f736f6c7574696f6ed5030154526177536f6c7574696f6e3c536f6c7574696f6e3e00012063616c6c5f66656518011c42616c616e63650000210b0c9070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f70686173651870616c6c6574144572726f7204045400013c6850726544697370617463684561726c795375626d697373696f6e000004645375626d697373696f6e2077617320746f6f206561726c792e6c507265446973706174636857726f6e6757696e6e6572436f756e740001048857726f6e67206e756d626572206f662077696e6e6572732070726573656e7465642e6450726544697370617463685765616b5375626d697373696f6e000204905375626d697373696f6e2077617320746f6f207765616b2c2073636f72652d776973652e3c5369676e6564517565756546756c6c0003044901546865207175657565207761732066756c6c2c20616e642074686520736f6c7574696f6e20776173206e6f7420626574746572207468616e20616e79206f6620746865206578697374696e67206f6e65732e585369676e656443616e6e6f745061794465706f73697400040494546865206f726967696e206661696c656420746f2070617920746865206465706f7369742e505369676e6564496e76616c69645769746e657373000504a05769746e657373206461746120746f20646973706174636861626c6520697320696e76616c69642e4c5369676e6564546f6f4d756368576569676874000604b8546865207369676e6564207375626d697373696f6e20636f6e73756d657320746f6f206d756368207765696768743c4f637743616c6c57726f6e67457261000704984f4357207375626d697474656420736f6c7574696f6e20666f722077726f6e6720726f756e645c4d697373696e67536e617073686f744d65746164617461000804a8536e617073686f74206d657461646174612073686f756c6420657869737420627574206469646e27742e58496e76616c69645375626d697373696f6e496e646578000904d06053656c663a3a696e736572745f7375626d697373696f6e602072657475726e656420616e20696e76616c696420696e6465782e3843616c6c4e6f74416c6c6f776564000a04985468652063616c6c206973206e6f7420616c6c6f776564206174207468697320706f696e742e3846616c6c6261636b4661696c6564000b044c5468652066616c6c6261636b206661696c65642c426f756e644e6f744d6574000c0448536f6d6520626f756e64206e6f74206d657438546f6f4d616e7957696e6e657273000d049c5375626d697474656420736f6c7574696f6e2068617320746f6f206d616e792077696e6e657273645072654469737061746368446966666572656e74526f756e64000e04b453756d697373696f6e2077617320707265706172656420666f72206120646966666572656e7420726f756e642e040d014572726f72206f66207468652070616c6c657420746861742063616e2062652072657475726e656420696e20726573706f6e736520746f20646973706174636865732e250b0c4070616c6c65745f626167735f6c697374106c697374104e6f646508045400044900001401086964000130543a3a4163636f756e74496400011070726576210201504f7074696f6e3c543a3a4163636f756e7449643e0001106e657874210201504f7074696f6e3c543a3a4163636f756e7449643e0001246261675f75707065722c0120543a3a53636f726500011473636f72652c0120543a3a53636f72650000290b0c4070616c6c65745f626167735f6c697374106c6973740c426167080454000449000008011068656164210201504f7074696f6e3c543a3a4163636f756e7449643e0001107461696c210201504f7074696f6e3c543a3a4163636f756e7449643e00002d0b0000022c00310b0c4070616c6c65745f626167735f6c6973741870616c6c6574144572726f72080454000449000104104c6973740400350b01244c6973744572726f72000004b441206572726f7220696e20746865206c69737420696e7465726661636520696d706c656d656e746174696f6e2e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e350b0c4070616c6c65745f626167735f6c697374106c697374244c6973744572726f72000110244475706c6963617465000000284e6f7448656176696572000100304e6f74496e53616d65426167000200304e6f64654e6f74466f756e6400030000390b085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7328506f6f6c4d656d626572040454000010011c706f6f6c5f6964100118506f6f6c4964000118706f696e747318013042616c616e63654f663c543e0001706c6173745f7265636f726465645f7265776172645f636f756e7465724d070140543a3a526577617264436f756e746572000138756e626f6e64696e675f657261733d0b01e0426f756e64656442547265654d61703c457261496e6465782c2042616c616e63654f663c543e2c20543a3a4d6178556e626f6e64696e673e00003d0b0c4c626f756e6465645f636f6c6c656374696f6e7344626f756e6465645f62747265655f6d61703c426f756e64656442547265654d61700c044b011004560118045300000400410b013842547265654d61703c4b2c20563e0000410b042042547265654d617008044b011004560118000400350a000000450b085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c733c426f6e646564506f6f6c496e6e65720404540000140128636f6d6d697373696f6e490b0134436f6d6d697373696f6e3c543e0001386d656d6265725f636f756e74657210010c753332000118706f696e747318013042616c616e63654f663c543e000114726f6c6573550b015c506f6f6c526f6c65733c543a3a4163636f756e7449643e0001147374617465d1040124506f6f6c53746174650000490b085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7328436f6d6d697373696f6e040454000014011c63757272656e74e904017c4f7074696f6e3c2850657262696c6c2c20543a3a4163636f756e744964293e00010c6d61784d0b013c4f7074696f6e3c50657262696c6c3e00012c6368616e67655f72617465510b01bc4f7074696f6e3c436f6d6d697373696f6e4368616e6765526174653c426c6f636b4e756d626572466f723c543e3e3e0001347468726f74746c655f66726f6d8d0201644f7074696f6e3c426c6f636b4e756d626572466f723c543e3e000140636c61696d5f7065726d697373696f6ef50401bc4f7074696f6e3c436f6d6d697373696f6e436c61696d5065726d697373696f6e3c543a3a4163636f756e7449643e3e00004d0b04184f7074696f6e04045401ac0108104e6f6e6500000010536f6d650400ac0000010000510b04184f7074696f6e04045401f1040108104e6f6e6500000010536f6d650400f1040000010000550b085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7324506f6f6c526f6c657304244163636f756e7449640100001001246465706f7369746f720001244163636f756e744964000110726f6f74210201444f7074696f6e3c4163636f756e7449643e0001246e6f6d696e61746f72210201444f7074696f6e3c4163636f756e7449643e00011c626f756e636572210201444f7074696f6e3c4163636f756e7449643e0000590b085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7328526577617264506f6f6c04045400001401706c6173745f7265636f726465645f7265776172645f636f756e7465724d070140543a3a526577617264436f756e74657200016c6c6173745f7265636f726465645f746f74616c5f7061796f75747318013042616c616e63654f663c543e000154746f74616c5f726577617264735f636c61696d656418013042616c616e63654f663c543e000160746f74616c5f636f6d6d697373696f6e5f70656e64696e6718013042616c616e63654f663c543e000160746f74616c5f636f6d6d697373696f6e5f636c61696d656418013042616c616e63654f663c543e00005d0b085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7320537562506f6f6c7304045400000801186e6f5f657261610b0134556e626f6e64506f6f6c3c543e000120776974685f657261650b010101426f756e64656442547265654d61703c457261496e6465782c20556e626f6e64506f6f6c3c543e2c20546f74616c556e626f6e64696e67506f6f6c733c543e3e0000610b085c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c7328556e626f6e64506f6f6c0404540000080118706f696e747318013042616c616e63654f663c543e00011c62616c616e636518013042616c616e63654f663c543e0000650b0c4c626f756e6465645f636f6c6c656374696f6e7344626f756e6465645f62747265655f6d61703c426f756e64656442547265654d61700c044b0110045601610b045300000400690b013842547265654d61703c4b2c20563e0000690b042042547265654d617008044b0110045601610b0004006d0b0000006d0b000002710b00710b0000040810610b00750b0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e0000790b0c5c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c731870616c6c6574144572726f7204045400018030506f6f6c4e6f74466f756e6400000488412028626f6e6465642920706f6f6c20696420646f6573206e6f742065786973742e48506f6f6c4d656d6265724e6f74466f756e640001046c416e206163636f756e74206973206e6f742061206d656d6265722e48526577617264506f6f6c4e6f74466f756e640002042101412072657761726420706f6f6c20646f6573206e6f742065786973742e20496e20616c6c206361736573207468697320697320612073797374656d206c6f676963206572726f722e40537562506f6f6c734e6f74466f756e6400030468412073756220706f6f6c20646f6573206e6f742065786973742e644163636f756e7442656c6f6e6773546f4f74686572506f6f6c0004084d01416e206163636f756e7420697320616c72656164792064656c65676174696e6720696e20616e6f7468657220706f6f6c2e20416e206163636f756e74206d6179206f6e6c792062656c6f6e6720746f206f6e653c706f6f6c20617420612074696d652e3846756c6c79556e626f6e64696e670005083d01546865206d656d6265722069732066756c6c7920756e626f6e6465642028616e6420746875732063616e6e6f74206163636573732074686520626f6e64656420616e642072657761726420706f6f6ca8616e796d6f726520746f2c20666f72206578616d706c652c20636f6c6c6563742072657761726473292e444d6178556e626f6e64696e674c696d69740006040901546865206d656d6265722063616e6e6f7420756e626f6e642066757274686572206368756e6b732064756520746f207265616368696e6720746865206c696d69742e4443616e6e6f745769746864726177416e790007044d014e6f6e65206f66207468652066756e64732063616e2062652077697468647261776e2079657420626563617573652074686520626f6e64696e67206475726174696f6e20686173206e6f74207061737365642e444d696e696d756d426f6e644e6f744d6574000814290154686520616d6f756e7420646f6573206e6f74206d65657420746865206d696e696d756d20626f6e6420746f20656974686572206a6f696e206f7220637265617465206120706f6f6c2e005501546865206465706f7369746f722063616e206e6576657220756e626f6e6420746f20612076616c7565206c657373207468616e206050616c6c65743a3a6465706f7369746f725f6d696e5f626f6e64602e205468655d0163616c6c657220646f6573206e6f742068617665206e6f6d696e6174696e67207065726d697373696f6e7320666f722074686520706f6f6c2e204d656d626572732063616e206e6576657220756e626f6e6420746f20616876616c75652062656c6f7720604d696e4a6f696e426f6e64602e304f766572666c6f775269736b0009042101546865207472616e73616374696f6e20636f756c64206e6f742062652065786563757465642064756520746f206f766572666c6f77207269736b20666f722074686520706f6f6c2e344e6f7444657374726f79696e67000a085d014120706f6f6c206d75737420626520696e205b60506f6f6c53746174653a3a44657374726f79696e67605d20696e206f7264657220666f7220746865206465706f7369746f7220746f20756e626f6e64206f7220666f72b86f74686572206d656d6265727320746f206265207065726d697373696f6e6c6573736c7920756e626f6e6465642e304e6f744e6f6d696e61746f72000b04f45468652063616c6c657220646f6573206e6f742068617665206e6f6d696e6174696e67207065726d697373696f6e7320666f722074686520706f6f6c2e544e6f744b69636b65724f7244657374726f79696e67000c043d01456974686572206129207468652063616c6c65722063616e6e6f74206d616b6520612076616c6964206b69636b206f722062292074686520706f6f6c206973206e6f742064657374726f79696e672e1c4e6f744f70656e000d047054686520706f6f6c206973206e6f74206f70656e20746f206a6f696e204d6178506f6f6c73000e04845468652073797374656d206973206d61786564206f7574206f6e20706f6f6c732e384d6178506f6f6c4d656d62657273000f049c546f6f206d616e79206d656d6265727320696e2074686520706f6f6c206f722073797374656d2e4443616e4e6f744368616e676553746174650010048854686520706f6f6c732073746174652063616e6e6f74206265206368616e6765642e54446f65734e6f74486176655065726d697373696f6e001104b85468652063616c6c657220646f6573206e6f742068617665206164657175617465207065726d697373696f6e732e544d65746164617461457863656564734d61784c656e001204ac4d657461646174612065786365656473205b60436f6e6669673a3a4d61784d657461646174614c656e605d24446566656e7369766504007d0b0138446566656e736976654572726f720013083101536f6d65206572726f72206f6363757272656420746861742073686f756c64206e657665722068617070656e2e20546869732073686f756c64206265207265706f7274656420746f20746865306d61696e7461696e6572732e9c5061727469616c556e626f6e644e6f74416c6c6f7765645065726d697373696f6e6c6573736c79001404bc5061727469616c20756e626f6e64696e67206e6f7720616c6c6f776564207065726d697373696f6e6c6573736c792e5c4d6178436f6d6d697373696f6e526573747269637465640015041d0154686520706f6f6c2773206d617820636f6d6d697373696f6e2063616e6e6f742062652073657420686967686572207468616e20746865206578697374696e672076616c75652e60436f6d6d697373696f6e457863656564734d6178696d756d001604ec54686520737570706c69656420636f6d6d697373696f6e206578636565647320746865206d617820616c6c6f77656420636f6d6d697373696f6e2e78436f6d6d697373696f6e45786365656473476c6f62616c4d6178696d756d001704e854686520737570706c69656420636f6d6d697373696f6e206578636565647320676c6f62616c206d6178696d756d20636f6d6d697373696f6e2e64436f6d6d697373696f6e4368616e67655468726f74746c656400180409014e6f7420656e6f75676820626c6f636b732068617665207375727061737365642073696e636520746865206c61737420636f6d6d697373696f6e207570646174652e78436f6d6d697373696f6e4368616e6765526174654e6f74416c6c6f7765640019040101546865207375626d6974746564206368616e67657320746f20636f6d6d697373696f6e206368616e6765207261746520617265206e6f7420616c6c6f7765642e4c4e6f50656e64696e67436f6d6d697373696f6e001a04a05468657265206973206e6f2070656e64696e6720636f6d6d697373696f6e20746f20636c61696d2e584e6f436f6d6d697373696f6e43757272656e74536574001b048c4e6f20636f6d6d697373696f6e2063757272656e7420686173206265656e207365742e2c506f6f6c4964496e557365001c0464506f6f6c2069642063757272656e746c7920696e207573652e34496e76616c6964506f6f6c4964001d049c506f6f6c2069642070726f7669646564206973206e6f7420636f72726563742f757361626c652e4c426f6e64457874726152657374726963746564001e04fc426f6e64696e67206578747261206973207265737472696374656420746f207468652065786163742070656e64696e672072657761726420616d6f756e742e3c4e6f7468696e67546f41646a757374001f04b04e6f20696d62616c616e636520696e20746865204544206465706f73697420666f722074686520706f6f6c2e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e7d0b0c5c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c731870616c6c657438446566656e736976654572726f72000114684e6f74456e6f7567685370616365496e556e626f6e64506f6f6c00000030506f6f6c4e6f74466f756e6400010048526577617264506f6f6c4e6f74466f756e6400020040537562506f6f6c734e6f74466f756e6400030070426f6e64656453746173684b696c6c65645072656d61747572656c7900040000810b0c4c70616c6c65745f666173745f756e7374616b6514747970657338556e7374616b6552657175657374040454000008011c73746173686573850b01d8426f756e6465645665633c28543a3a4163636f756e7449642c2042616c616e63654f663c543e292c20543a3a426174636853697a653e00011c636865636b6564890b0190426f756e6465645665633c457261496e6465782c204d6178436865636b696e673c543e3e0000850b0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401c104045300000400bd0401185665633c543e0000890b0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e6465645665630804540110045300000400090201185665633c543e00008d0b0c4c70616c6c65745f666173745f756e7374616b651870616c6c6574144572726f72040454000118344e6f74436f6e74726f6c6c657200000cb85468652070726f766964656420436f6e74726f6c6c6572206163636f756e7420776173206e6f7420666f756e642e00c054686973206d65616e7320746861742074686520676976656e206163636f756e74206973206e6f7420626f6e6465642e34416c7265616479517565756564000104ac54686520626f6e646564206163636f756e742068617320616c7265616479206265656e207175657565642e384e6f7446756c6c79426f6e646564000204bc54686520626f6e646564206163636f756e74206861732061637469766520756e6c6f636b696e67206368756e6b732e244e6f74517565756564000304b45468652070726f766964656420756e2d7374616b6572206973206e6f7420696e2074686520605175657565602e2c416c72656164794865616400040405015468652070726f766964656420756e2d7374616b657220697320616c726561647920696e20486561642c20616e642063616e6e6f7420646572656769737465722e3843616c6c4e6f74416c6c6f7765640005041d015468652063616c6c206973206e6f7420616c6c6f776564206174207468697320706f696e742062656361757365207468652070616c6c6574206973206e6f74206163746976652e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e910b0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e7334636f6e66696775726174696f6e44486f7374436f6e66696775726174696f6e042c426c6f636b4e756d626572011000b401346d61785f636f64655f73697a6510010c7533320001486d61785f686561645f646174615f73697a6510010c7533320001586d61785f7570776172645f71756575655f636f756e7410010c7533320001546d61785f7570776172645f71756575655f73697a6510010c75333200015c6d61785f7570776172645f6d6573736167655f73697a6510010c7533320001906d61785f7570776172645f6d6573736167655f6e756d5f7065725f63616e64696461746510010c75333200018868726d705f6d61785f6d6573736167655f6e756d5f7065725f63616e64696461746510010c75333200016c76616c69646174696f6e5f757067726164655f636f6f6c646f776e10012c426c6f636b4e756d62657200016076616c69646174696f6e5f757067726164655f64656c617910012c426c6f636b4e756d6265720001506173796e635f6261636b696e675f706172616d73050501484173796e634261636b696e67506172616d730001306d61785f706f765f73697a6510010c7533320001646d61785f646f776e776172645f6d6573736167655f73697a6510010c75333200019068726d705f6d61785f70617261636861696e5f6f7574626f756e645f6368616e6e656c7310010c75333200014c68726d705f73656e6465725f6465706f73697418011c42616c616e636500015868726d705f726563697069656e745f6465706f73697418011c42616c616e636500016468726d705f6368616e6e656c5f6d61785f636170616369747910010c75333200016c68726d705f6368616e6e656c5f6d61785f746f74616c5f73697a6510010c75333200018c68726d705f6d61785f70617261636861696e5f696e626f756e645f6368616e6e656c7310010c75333200017468726d705f6368616e6e656c5f6d61785f6d6573736167655f73697a6510010c75333200013c6578656375746f725f706172616d73090501384578656375746f72506172616d73000154636f64655f726574656e74696f6e5f706572696f6410012c426c6f636b4e756d626572000138636f726574696d655f636f72657310010c7533320001446f6e5f64656d616e645f7265747269657310010c7533320001606f6e5f64656d616e645f71756575655f6d61785f73697a6510010c7533320001886f6e5f64656d616e645f7461726765745f71756575655f7574696c697a6174696f6eac011c50657262696c6c0001646f6e5f64656d616e645f6665655f766172696162696c697479ac011c50657262696c6c0001486f6e5f64656d616e645f626173655f66656518011c42616c616e63650001346f6e5f64656d616e645f74746c10012c426c6f636b4e756d62657200016067726f75705f726f746174696f6e5f6672657175656e637910012c426c6f636b4e756d62657200016470617261735f617661696c6162696c6974795f706572696f6410012c426c6f636b4e756d6265720001507363686564756c696e675f6c6f6f6b616865616410010c75333200015c6d61785f76616c696461746f72735f7065725f636f72658d02012c4f7074696f6e3c7533323e0001386d61785f76616c696461746f72738d02012c4f7074696f6e3c7533323e000138646973707574655f706572696f6410013053657373696f6e496e6465780001a4646973707574655f706f73745f636f6e636c7573696f6e5f616363657074616e63655f706572696f6410012c426c6f636b4e756d6265720001346e6f5f73686f775f736c6f747310010c7533320001406e5f64656c61795f7472616e6368657310010c7533320001687a65726f74685f64656c61795f7472616e6368655f776964746810010c7533320001406e65656465645f617070726f76616c7310010c75333200016072656c61795f7672665f6d6f64756c6f5f73616d706c657310010c7533320001387076665f766f74696e675f74746c10013053657373696f6e496e6465780001806d696e696d756d5f76616c69646174696f6e5f757067726164655f64656c617910012c426c6f636b4e756d6265720001546d696e696d756d5f6261636b696e675f766f74657310010c7533320001346e6f64655f66656174757265733d0501304e6f64654665617475726573000158617070726f76616c5f766f74696e675f706172616d731d050150417070726f76616c566f74696e67506172616d730000950b000002990b00990b0000040810910b009d0b106c706f6c6b61646f745f72756e74696d655f70617261636861696e7334636f6e66696775726174696f6e1870616c6c6574144572726f720404540001043c496e76616c69644e657756616c7565000004dc546865206e65772076616c756520666f72206120636f6e66696775726174696f6e20706172616d6574657220697320696e76616c69642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ea10b000002450500a50b000002410200a90b0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e731873686172656468416c6c6f77656452656c6179506172656e7473547261636b657208104861736801302c426c6f636b4e756d626572011000080118627566666572ad0b015856656344657175653c28486173682c2048617368293e0001346c61746573745f6e756d62657210012c426c6f636b4e756d6265720000ad0b000002b10b00b10b00000408303000b50b0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e7324696e636c7573696f6e68417661696c6162696c6974794269746669656c645265636f726404044e0110000801206269746669656c6439050150417661696c6162696c6974794269746669656c640001307375626d69747465645f61741001044e0000b90b0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e7324696e636c7573696f6e7043616e64696461746550656e64696e67417661696c6162696c6974790804480130044e011000200110636f7265d5070124436f7265496e646578000110686173689905013443616e6469646174654861736800012864657363726970746f725905015843616e64696461746544657363726970746f723c483e000148617661696c6162696c6974795f766f7465733d0501604269745665633c75382c204269744f726465724c7362303e00011c6261636b6572733d0501604269745665633c75382c204269744f726465724c7362303e00014c72656c61795f706172656e745f6e756d6265721001044e0001406261636b65645f696e5f6e756d6265721001044e0001346261636b696e675f67726f7570d907012847726f7570496e6465780000bd0b106c706f6c6b61646f745f72756e74696d655f70617261636861696e7324696e636c7573696f6e1870616c6c6574144572726f720404540001748c556e736f727465644f724475706c696361746556616c696461746f72496e6469636573000004e856616c696461746f7220696e646963657320617265206f7574206f66206f72646572206f7220636f6e7461696e73206475706c6963617465732e98556e736f727465644f724475706c69636174654469737075746553746174656d656e74536574000104f8446973707574652073746174656d656e74207365747320617265206f7574206f66206f72646572206f7220636f6e7461696e206475706c6963617465732e8c556e736f727465644f724475706c69636174654261636b656443616e6469646174657300020419014261636b65642063616e6469646174657320617265206f7574206f66206f726465722028636f726520696e64657829206f7220636f6e7461696e206475706c6963617465732e54556e657870656374656452656c6179506172656e7400030429014120646966666572656e742072656c617920706172656e74207761732070726f766964656420636f6d706172656420746f20746865206f6e2d636861696e2073746f726564206f6e652e4457726f6e674269746669656c6453697a65000404a8417661696c6162696c697479206269746669656c642068617320756e65787065637465642073697a652e404269746669656c64416c6c5a65726f73000504804269746669656c6420636f6e7369737473206f66207a65726f73206f6e6c792e704269746669656c644475706c69636174654f72556e6f7264657265640006044d014d756c7469706c65206269746669656c6473207375626d69747465642062792073616d652076616c696461746f72206f722076616c696461746f7273206f7574206f66206f7264657220627920696e6465782e6456616c696461746f72496e6465784f75744f66426f756e64730007047856616c696461746f7220696e646578206f7574206f6620626f756e64732e60496e76616c69644269746669656c645369676e617475726500080444496e76616c6964207369676e617475726550556e7363686564756c656443616e646964617465000904ac43616e646964617465207375626d6974746564206275742070617261206e6f74207363686564756c65642e8043616e6469646174655363686564756c65644265666f72655061726146726565000a04310143616e646964617465207363686564756c656420646573706974652070656e64696e672063616e64696461746520616c7265616479206578697374696e6720666f722074686520706172612e4c5363686564756c65644f75744f664f72646572000b04745363686564756c656420636f726573206f7574206f66206f726465722e404865616444617461546f6f4c61726765000c04a448656164206461746120657863656564732074686520636f6e66696775726564206d6178696d756d2e505072656d6174757265436f646555706772616465000d0464436f64652075706772616465207072656d61747572656c792e3c4e6577436f6465546f6f4c61726765000e04604f757470757420636f646520697320746f6f206c6172676554446973616c6c6f77656452656c6179506172656e74000f08ec5468652063616e64696461746527732072656c61792d706172656e7420776173206e6f7420616c6c6f7765642e204569746865722069742077617325016e6f7420726563656e7420656e6f756768206f72206974206469646e277420616476616e6365206261736564206f6e20746865206c6173742070617261636861696e20626c6f636b2e44496e76616c696441737369676e6d656e7400100815014661696c656420746f20636f6d707574652067726f757020696e64657820666f722074686520636f72653a206569746865722069742773206f7574206f6620626f756e6473e86f72207468652072656c617920706172656e7420646f65736e27742062656c6f6e6720746f207468652063757272656e742073657373696f6e2e44496e76616c696447726f7570496e6465780011049c496e76616c69642067726f757020696e64657820696e20636f72652061737369676e6d656e742e4c496e73756666696369656e744261636b696e6700120490496e73756666696369656e7420286e6f6e2d6d616a6f7269747929206261636b696e672e38496e76616c69644261636b696e67001304e4496e76616c69642028626164207369676e61747572652c20756e6b6e6f776e2076616c696461746f722c206574632e29206261636b696e672e444e6f74436f6c6c61746f725369676e656400140468436f6c6c61746f7220646964206e6f74207369676e20506f562e6856616c69646174696f6e44617461486173684d69736d61746368001504c45468652076616c69646174696f6e2064617461206861736820646f6573206e6f74206d617463682065787065637465642e80496e636f7272656374446f776e776172644d65737361676548616e646c696e67001604d854686520646f776e77617264206d657373616765207175657565206973206e6f742070726f63657373656420636f72726563746c792e54496e76616c69645570776172644d657373616765730017041d014174206c65617374206f6e6520757077617264206d6573736167652073656e7420646f6573206e6f7420706173732074686520616363657074616e63652063726974657269612e6048726d7057617465726d61726b4d697368616e646c696e6700180411015468652063616e646964617465206469646e277420666f6c6c6f77207468652072756c6573206f662048524d502077617465726d61726b20616476616e63656d656e742e4c496e76616c69644f7574626f756e6448726d70001904d45468652048524d50206d657373616765732073656e74206279207468652063616e646964617465206973206e6f742076616c69642e64496e76616c696456616c69646174696f6e436f646548617368001a04dc5468652076616c69646174696f6e20636f64652068617368206f66207468652063616e646964617465206973206e6f742076616c69642e4050617261486561644d69736d61746368001b0855015468652060706172615f6865616460206861736820696e207468652063616e6469646174652064657363726970746f7220646f65736e2774206d61746368207468652068617368206f66207468652061637475616c7470617261206865616420696e2074686520636f6d6d69746d656e74732e6c4269746669656c645265666572656e6365734672656564436f7265001c0ca041206269746669656c642074686174207265666572656e636573206120667265656420636f72652cb865697468657220696e74656e74696f6e616c6c79206f722061732070617274206f66206120636f6e636c7564656440696e76616c696420646973707574652e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ec10b0c4c706f6c6b61646f745f7072696d6974697665730876364c536372617065644f6e436861696e566f7465730404480130000c011c73657373696f6e10013053657373696f6e496e6465780001806261636b696e675f76616c696461746f72735f7065725f63616e646964617465c50b011d015665633c2843616e646964617465526563656970743c483e2c205665633c2856616c696461746f72496e6465782c2056616c69646974794174746573746174696f6e293e290a3e0001206469737075746573910501604d756c74694469737075746553746174656d656e745365740000c50b000002c90b00c90b00000408d107cd0b00cd0b000002d10b00d10b0000040845058d0500d50b106c706f6c6b61646f745f72756e74696d655f70617261636861696e733870617261735f696e686572656e741870616c6c6574144572726f7204045400012464546f6f4d616e79496e636c7573696f6e496e686572656e7473000004cc496e636c7573696f6e20696e686572656e742063616c6c6564206d6f7265207468616e206f6e63652070657220626c6f636b2e4c496e76616c6964506172656e7448656164657200010855015468652068617368206f6620746865207375626d697474656420706172656e742068656164657220646f65736e277420636f72726573706f6e6420746f2074686520736176656420626c6f636b2068617368206f662c74686520706172656e742e6443616e646964617465436f6e636c75646564496e76616c6964000204b844697370757465642063616e64696461746520746861742077617320636f6e636c7564656420696e76616c69642e48496e686572656e744f7665727765696768740003040901546865206461746120676976656e20746f2074686520696e686572656e742077696c6c20726573756c7420696e20616e206f76657277656967687420626c6f636b2e944469737075746553746174656d656e7473556e736f727465644f724475706c696361746573000404bc546865206f72646572696e67206f6620646973707574652073746174656d656e74732077617320696e76616c69642e3844697370757465496e76616c6964000504804120646973707574652073746174656d656e742077617320696e76616c69642e404261636b6564427944697361626c6564000604b8412063616e64696461746520776173206261636b656420627920612064697361626c65642076616c696461746f725c4261636b65644f6e556e7363686564756c6564436f72650007040101412063616e64696461746520776173206261636b6564206576656e2074686f756768207468652070617261696420776173206e6f74207363686564756c65642e50556e7363686564756c656443616e64696461746500080474546f6f206d616e792063616e6469646174657320737570706c6965642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ed90b000002a10b00dd0b000002e10b00e10b106c706f6c6b61646f745f72756e74696d655f70617261636861696e73247363686564756c65721870616c6c657430436f72654f6363757069656404044e0110010810467265650000001450617261730400e50b01345061726173456e7472793c4e3e00010000e50b106c706f6c6b61646f745f72756e74696d655f70617261636861696e73247363686564756c65721870616c6c6574285061726173456e74727904044e0110000c012861737369676e6d656e74e90b012841737369676e6d656e74000154617661696c6162696c6974795f74696d656f75747310010c75333200010c74746c1001044e0000e90b106c706f6c6b61646f745f72756e74696d655f70617261636861696e73247363686564756c657218636f6d6d6f6e2841737369676e6d656e7400010810506f6f6c08011c706172615f6964b9020118506172614964000128636f72655f696e646578d5070124436f7265496e6465780000001042756c6b0400b902011850617261496400010000ed0b042042547265654d617008044b01d507045601f10b000400f50b000000f10b000002e50b00f50b000002f90b00f90b00000408d507f10b00fd0b0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e731470617261735c507666436865636b416374697665566f74655374617465042c426c6f636b4e756d626572011000140130766f7465735f6163636570743d0501604269745665633c75382c204269744f726465724c7362303e000130766f7465735f72656a6563743d0501604269745665633c75382c204269744f726465724c7362303e00010c61676510013053657373696f6e496e646578000128637265617465645f617410012c426c6f636b4e756d626572000118636175736573010c017c5665633c507666436865636b43617573653c426c6f636b4e756d6265723e3e0000010c000002050c00050c0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e7314706172617334507666436865636b4361757365042c426c6f636b4e756d62657201100108284f6e626f617264696e670400b90201185061726149640000001c557067726164650c01086964b902011850617261496400012c696e636c756465645f617410012c426c6f636b4e756d6265720001307365745f676f5f6168656164090c0128536574476f416865616400010000090c0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e7314706172617328536574476f41686561640001080c596573000000084e6f000100000d0c000002650500110c000002b90200150c0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e7314706172617334506172614c6966656379636c6500011c284f6e626f617264696e6700000028506172617468726561640001002450617261636861696e0002004c557067726164696e675061726174687265616400030050446f776e67726164696e6750617261636861696e000400544f6666626f617264696e6750617261746872656164000500504f6666626f617264696e6750617261636861696e00060000190c00000408b90210001d0c0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e73147061726173405061726150617374436f64654d65746104044e011000080134757067726164655f74696d6573210c01605665633c5265706c6163656d656e7454696d65733c4e3e3e00012c6c6173745f7072756e65648d0201244f7074696f6e3c4e3e0000210c000002250c00250c0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e73147061726173405265706c6163656d656e7454696d657304044e01100008012c65787065637465645f61741001044e0001306163746976617465645f61741001044e0000290c000002190c002d0c0c4c706f6c6b61646f745f7072696d6974697665730876363855706772616465476f41686561640001081441626f72740000001c476f416865616400010000310c0c4c706f6c6b61646f745f7072696d69746976657308763648557067726164655265737472696374696f6e0001041c50726573656e7400000000350c0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e731470617261733c5061726147656e657369734172677300000c013067656e657369735f6865616485050120486561644461746100013c76616c69646174696f6e5f636f64658105013856616c69646174696f6e436f6465000124706172615f6b696e64780120506172614b696e640000390c106c706f6c6b61646f745f72756e74696d655f70617261636861696e731470617261731870616c6c6574144572726f72040454000130344e6f74526567697374657265640000049450617261206973206e6f74207265676973746572656420696e206f75722073797374656d2e3443616e6e6f744f6e626f6172640001041501506172612063616e6e6f74206265206f6e626f6172646564206265636175736520697420697320616c726561647920747261636b6564206279206f75722073797374656d2e3843616e6e6f744f6666626f6172640002049c506172612063616e6e6f74206265206f6666626f617264656420617420746869732074696d652e3443616e6e6f7455706772616465000304d4506172612063616e6e6f7420626520757067726164656420746f2061206c6561736520686f6c64696e672070617261636861696e2e3c43616e6e6f74446f776e6772616465000404d0506172612063616e6e6f7420626520646f776e67726164656420746f20616e206f6e2d64656d616e642070617261636861696e2e58507666436865636b53746174656d656e745374616c65000504b05468652073746174656d656e7420666f7220505646207072652d636865636b696e67206973207374616c652e5c507666436865636b53746174656d656e74467574757265000604ec5468652073746174656d656e7420666f7220505646207072652d636865636b696e6720697320666f722061206675747572652073657373696f6e2e84507666436865636b56616c696461746f72496e6465784f75744f66426f756e6473000704a4436c61696d65642076616c696461746f7220696e646578206973206f7574206f6620626f756e64732e60507666436865636b496e76616c69645369676e6174757265000804c8546865207369676e617475726520666f722074686520505646207072652d636865636b696e6720697320696e76616c69642e48507666436865636b446f75626c65566f7465000904b054686520676976656e2076616c696461746f7220616c7265616479206861732063617374206120766f74652e58507666436865636b5375626a656374496e76616c6964000a04f454686520676976656e2050564620646f6573206e6f7420657869737420617420746865206d6f6d656e74206f662070726f63657373206120766f74652e4443616e6e6f7455706772616465436f6465000b04cc50617261636861696e2063616e6e6f742063757272656e746c79207363686564756c65206120636f646520757067726164652e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e3d0c000002410c00410c0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e732c696e697469616c697a657254427566666572656453657373696f6e4368616e676500000c012876616c696461746f7273a50b01405665633c56616c696461746f7249643e000118717565756564a50b01405665633c56616c696461746f7249643e00013473657373696f6e5f696e64657810013053657373696f6e496e6465780000450c000002490c00490c0860706f6c6b61646f745f636f72655f7072696d69746976657358496e626f756e64446f776e776172644d657373616765042c426c6f636b4e756d62657201100008011c73656e745f617410012c426c6f636b4e756d62657200010c6d736734013c446f776e776172644d65737361676500004d0c0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e731068726d705848726d704f70656e4368616e6e656c526571756573740000180124636f6e6669726d6564780110626f6f6c0001105f61676510013053657373696f6e496e64657800013873656e6465725f6465706f73697418011c42616c616e63650001406d61785f6d6573736167655f73697a6510010c7533320001306d61785f636170616369747910010c7533320001386d61785f746f74616c5f73697a6510010c7533320000510c000002c50500550c0c6c706f6c6b61646f745f72756e74696d655f70617261636861696e731068726d702c48726d704368616e6e656c00002001306d61785f636170616369747910010c7533320001386d61785f746f74616c5f73697a6510010c7533320001406d61785f6d6573736167655f73697a6510010c7533320001246d73675f636f756e7410010c753332000128746f74616c5f73697a6510010c7533320001206d71635f68656164c90201304f7074696f6e3c486173683e00013873656e6465725f6465706f73697418011c42616c616e6365000144726563697069656e745f6465706f73697418011c42616c616e63650000590c0000025d0c005d0c0860706f6c6b61646f745f636f72655f7072696d69746976657348496e626f756e6448726d704d657373616765042c426c6f636b4e756d62657201100008011c73656e745f617410012c426c6f636b4e756d6265720001106461746134015073705f7374643a3a7665633a3a5665633c75383e0000610c000002650c00650c0000040810110c00690c106c706f6c6b61646f745f72756e74696d655f70617261636861696e731068726d701870616c6c6574144572726f72040454000150544f70656e48726d704368616e6e656c546f53656c66000004c45468652073656e64657220747269656420746f206f70656e2061206368616e6e656c20746f207468656d73656c7665732e7c4f70656e48726d704368616e6e656c496e76616c6964526563697069656e740001048854686520726563697069656e74206973206e6f7420612076616c696420706172612e6c4f70656e48726d704368616e6e656c5a65726f43617061636974790002047c54686520726571756573746564206361706163697479206973207a65726f2e8c4f70656e48726d704368616e6e656c4361706163697479457863656564734c696d6974000304c05468652072657175657374656420636170616369747920657863656564732074686520676c6f62616c206c696d69742e784f70656e48726d704368616e6e656c5a65726f4d65737361676553697a65000404a054686520726571756573746564206d6178696d756d206d6573736167652073697a6520697320302e984f70656e48726d704368616e6e656c4d65737361676553697a65457863656564734c696d69740005042901546865206f70656e20726571756573742072657175657374656420746865206d6573736167652073697a65207468617420657863656564732074686520676c6f62616c206c696d69742e704f70656e48726d704368616e6e656c416c726561647945786973747300060468546865206368616e6e656c20616c7265616479206578697374737c4f70656e48726d704368616e6e656c416c7265616479526571756573746564000704d0546865726520697320616c72656164792061207265717565737420746f206f70656e207468652073616d65206368616e6e656c2e704f70656e48726d704368616e6e656c4c696d697445786365656465640008041d015468652073656e64657220616c72656164792068617320746865206d6178696d756d206e756d626572206f6620616c6c6f776564206f7574626f756e64206368616e6e656c732e7041636365707448726d704368616e6e656c446f65736e744578697374000904e0546865206368616e6e656c2066726f6d207468652073656e64657220746f20746865206f726967696e20646f65736e27742065786973742e8441636365707448726d704368616e6e656c416c7265616479436f6e6669726d6564000a0484546865206368616e6e656c20697320616c726561647920636f6e6669726d65642e7841636365707448726d704368616e6e656c4c696d69744578636565646564000b04250154686520726563697069656e7420616c72656164792068617320746865206d6178696d756d206e756d626572206f6620616c6c6f77656420696e626f756e64206368616e6e656c732e70436c6f736548726d704368616e6e656c556e617574686f72697a6564000c045501546865206f726967696e20747269657320746f20636c6f73652061206368616e6e656c207768657265206974206973206e656974686572207468652073656e646572206e6f722074686520726563697069656e742e6c436c6f736548726d704368616e6e656c446f65736e744578697374000d049c546865206368616e6e656c20746f20626520636c6f73656420646f65736e27742065786973742e7c436c6f736548726d704368616e6e656c416c7265616479556e646572776179000e04bc546865206368616e6e656c20636c6f7365207265717565737420697320616c7265616479207265717565737465642e8443616e63656c48726d704f70656e4368616e6e656c556e617574686f72697a6564000f045d0143616e63656c696e6720697320726571756573746564206279206e656974686572207468652073656e646572206e6f7220726563697069656e74206f6620746865206f70656e206368616e6e656c20726571756573742e684f70656e48726d704368616e6e656c446f65736e7445786973740010047c546865206f70656e207265717565737420646f65736e27742065786973742e7c4f70656e48726d704368616e6e656c416c7265616479436f6e6669726d65640011042d0143616e6e6f742063616e63656c20616e2048524d50206f70656e206368616e6e656c2072657175657374206265636175736520697420697320616c726561647920636f6e6669726d65642e3057726f6e675769746e6573730012048c5468652070726f7669646564207769746e65737320646174612069732077726f6e672e704368616e6e656c4372656174696f6e4e6f74417574686f72697a6564001304e8546865206368616e6e656c206265747765656e2074686573652074776f20636861696e732063616e6e6f7420626520617574686f72697a65642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e6d0c000002450200710c0c4c706f6c6b61646f745f7072696d6974697665730876362c53657373696f6e496e666f00003401606163746976655f76616c696461746f725f696e6469636573a10b014c5665633c56616c696461746f72496e6465783e00012c72616e646f6d5f736565640401205b75383b2033325d000138646973707574655f706572696f6410013053657373696f6e496e64657800012876616c696461746f7273750c019c496e64657865645665633c56616c696461746f72496e6465782c2056616c696461746f7249643e000138646973636f766572795f6b657973b90901645665633c417574686f72697479446973636f7665727949643e00013c61737369676e6d656e745f6b6579736d0c01445665633c41737369676e6d656e7449643e00014076616c696461746f725f67726f757073790c01ac496e64657865645665633c47726f7570496e6465782c205665633c56616c696461746f72496e6465783e3e00011c6e5f636f72657310010c7533320001687a65726f74685f64656c61795f7472616e6368655f776964746810010c75333200016072656c61795f7672665f6d6f64756c6f5f73616d706c657310010c7533320001406e5f64656c61795f7472616e6368657310010c7533320001346e6f5f73686f775f736c6f747310010c7533320001406e65656465645f617070726f76616c7310010c7533320000750c0c4c706f6c6b61646f745f7072696d69746976657308763628496e646578656456656308044b0145050456014102000400a50b01185665633c563e0000790c0c4c706f6c6b61646f745f7072696d69746976657308763628496e646578656456656308044b01d907045601a10b000400d90b01185665633c563e00007d0c0000040810990500810c0c4c706f6c6b61646f745f7072696d6974697665730876363044697370757465537461746504044e01100010013876616c696461746f72735f666f723d05017c4269745665633c75382c206269747665633a3a6f726465723a3a4c7362303e00014876616c696461746f72735f616761696e73743d05017c4269745665633c75382c206269747665633a3a6f726465723a3a4c7362303e00011473746172741001044e000130636f6e636c756465645f61748d0201244f7074696f6e3c4e3e0000850c04204254726565536574040454014505000400a10b000000890c106c706f6c6b61646f745f72756e74696d655f70617261636861696e732064697370757465731870616c6c6574144572726f72040454000124744475706c69636174654469737075746553746174656d656e7453657473000004a84475706c696361746520646973707574652073746174656d656e7420736574732070726f76696465642e5c416e6369656e744469737075746553746174656d656e740001048c416e6369656e7420646973707574652073746174656d656e742070726f76696465642e6456616c696461746f72496e6465784f75744f66426f756e6473000204e856616c696461746f7220696e646578206f6e2073746174656d656e74206973206f7574206f6620626f756e647320666f722073657373696f6e2e40496e76616c69645369676e61747572650003047c496e76616c6964207369676e6174757265206f6e2073746174656d656e742e484475706c696361746553746174656d656e74000404cc56616c696461746f7220766f7465207375626d6974746564206d6f7265207468616e206f6e636520746f20646973707574652e4853696e676c65536964656444697370757465000504c441206469737075746520776865726520746865726520617265206f6e6c7920766f746573206f6e206f6e6520736964652e3c4d616c6963696f75734261636b65720006049c41206469737075746520766f74652066726f6d2061206d616c6963696f7573206261636b65722e4c4d697373696e674261636b696e67566f746573000704e04e6f206261636b696e6720766f74657320776572652070726f766964657320616c6f6e6720646973707574652073746174656d656e74732e48556e636f6e6669726d656444697370757465000804b0556e636f6e6669726d656420646973707574652073746174656d656e7420736574732070726f76696465642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e8d0c104c706f6c6b61646f745f7072696d69746976657308763620736c617368696e673850656e64696e67536c617368657300000801106b657973910c019442547265654d61703c56616c696461746f72496e6465782c2056616c696461746f7249643e0001106b696e64d905014c536c617368696e674f6666656e63654b696e640000910c042042547265654d617008044b0145050456014102000400950c000000950c000002990c00990c0000040845054102009d0c146c706f6c6b61646f745f72756e74696d655f70617261636861696e7320646973707574657320736c617368696e671870616c6c6574144572726f7204045400011860496e76616c69644b65794f776e65727368697050726f6f660000048c546865206b6579206f776e6572736869702070726f6f6620697320696e76616c69642e4c496e76616c696453657373696f6e496e646578000104a05468652073657373696f6e20696e64657820697320746f6f206f6c64206f7220696e76616c69642e50496e76616c696443616e64696461746548617368000204785468652063616e646964617465206861736820697320696e76616c69642e54496e76616c696456616c696461746f72496e64657800030801015468657265206973206e6f2070656e64696e6720736c61736820666f722074686520676976656e2076616c696461746f7220696e64657820616e642074696d6514736c6f742e6056616c696461746f72496e64657849644d69736d61746368000404d05468652076616c696461746f7220696e64657820646f6573206e6f74206d61746368207468652076616c696461746f722069642e5c4475706c6963617465536c617368696e675265706f72740005040d0154686520676976656e20736c617368696e67207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ea10c0c5c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e3c70617261735f7265676973747261722050617261496e666f081c4163636f756e7401001c42616c616e63650118000c011c6d616e6167657200011c4163636f756e7400011c6465706f73697418011c42616c616e63650001186c6f636b6564a50c01304f7074696f6e3c626f6f6c3e0000a50c04184f7074696f6e04045401780108104e6f6e6500000010536f6d650400780000010000a90c105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e3c70617261735f7265676973747261721870616c6c6574144572726f72040454000138344e6f745265676973746572656400000464546865204944206973206e6f7420726567697374657265642e44416c7265616479526567697374657265640001047454686520494420697320616c726561647920726567697374657265642e204e6f744f776e65720002049c5468652063616c6c6572206973206e6f7420746865206f776e6572206f6620746869732049642e30436f6465546f6f4c617267650003045c496e76616c6964207061726120636f64652073697a652e404865616444617461546f6f4c6172676500040470496e76616c69642070617261206865616420646174612073697a652e304e6f7450617261636861696e0005046050617261206973206e6f7420612050617261636861696e2e344e6f7450617261746872656164000604bc50617261206973206e6f742061205061726174687265616420286f6e2d64656d616e642070617261636861696e292e4043616e6e6f74446572656769737465720007045843616e6e6f74206465726567697374657220706172613c43616e6e6f74446f776e67726164650008042d0143616e6e6f74207363686564756c6520646f776e6772616465206f66206c6561736520686f6c64696e672070617261636861696e20746f206f6e2d64656d616e642070617261636861696e3443616e6e6f7455706772616465000904250143616e6e6f74207363686564756c652075706772616465206f66206f6e2d64656d616e642070617261636861696e20746f206c6561736520686f6c64696e672070617261636861696e28506172614c6f636b6564000a08490150617261206973206c6f636b65642066726f6d206d616e6970756c6174696f6e20627920746865206d616e616765722e204d757374207573652070617261636861696e206f722072656c617920636861696e2c676f7665726e616e63652e2c4e6f745265736572766564000b04d054686520494420676976656e20666f7220726567697374726174696f6e20686173206e6f74206265656e2072657365727665642e24456d707479436f6465000c04d45265676973746572696e672070617261636861696e207769746820656d70747920636f6465206973206e6f7420616c6c6f7765642e2843616e6e6f7453776170000d08510143616e6e6f7420706572666f726d20612070617261636861696e20736c6f74202f206c6966656379636c6520737761702e20436865636b207468617420746865207374617465206f6620626f74682070617261738461726520636f727265637420666f7220746865207377617020746f20776f726b2e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ead0c000002790800b10c105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e14736c6f74731870616c6c6574144572726f7204045400010844506172614e6f744f6e626f617264696e670000048c5468652070617261636861696e204944206973206e6f74206f6e626f617264696e672e284c656173654572726f720001048854686572652077617320616e206572726f72207769746820746865206c656173652e048054686520604572726f726020656e756d206f6620746869732070616c6c65742eb50c0000040800b90200b90c00000324000000bd0c00bd0c04184f7074696f6e04045401c10c0108104e6f6e6500000010536f6d650400c10c0000010000c10c0000040c00b9021800c50c105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e2061756374696f6e731870616c6c6574144572726f7204045400011c4441756374696f6e496e50726f677265737300000490546869732061756374696f6e20697320616c726561647920696e2070726f67726573732e444c65617365506572696f64496e5061737400010480546865206c6561736520706572696f6420697320696e2074686520706173742e44506172614e6f74526567697374657265640002045850617261206973206e6f742072656769737465726564444e6f7443757272656e7441756374696f6e000304584e6f7420612063757272656e742061756374696f6e2e284e6f7441756374696f6e0004043c4e6f7420616e2061756374696f6e2e3041756374696f6e456e6465640005046841756374696f6e2068617320616c726561647920656e6465642e40416c72656164794c65617365644f7574000604d8546865207061726120697320616c7265616479206c6561736564206f757420666f722070617274206f6620746869732072616e67652e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ec90c0c5c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e2463726f77646c6f616e2046756e64496e666f10244163636f756e74496401001c42616c616e636501182c426c6f636b4e756d62657201102c4c65617365506572696f640110002801246465706f7369746f720001244163636f756e7449640001207665726966696572f105014c4f7074696f6e3c4d756c74695369676e65723e00011c6465706f73697418011c42616c616e636500011872616973656418011c42616c616e636500010c656e6410012c426c6f636b4e756d62657200010c63617018011c42616c616e63650001446c6173745f636f6e747269627574696f6ecd0c01744c617374436f6e747269627574696f6e3c426c6f636b4e756d6265723e00013066697273745f706572696f6410012c4c65617365506572696f6400012c6c6173745f706572696f6410012c4c65617365506572696f6400012866756e645f696e64657810012446756e64496e6465780000cd0c0c5c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e2463726f77646c6f616e404c617374436f6e747269627574696f6e042c426c6f636b4e756d6265720110010c144e6576657200000024507265456e64696e67040010010c75333200010018456e64696e67040010012c426c6f636b4e756d62657200020000d10c105c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e2463726f77646c6f616e1870616c6c6574144572726f7204045400015c444669727374506572696f64496e50617374000004f45468652063757272656e74206c6561736520706572696f64206973206d6f7265207468616e20746865206669727374206c6561736520706572696f642e644669727374506572696f64546f6f466172496e4675747572650001041101546865206669727374206c6561736520706572696f64206e6565647320746f206174206c65617374206265206c657373207468616e203320606d61785f76616c7565602e6c4c617374506572696f644265666f72654669727374506572696f64000204e84c617374206c6561736520706572696f64206d7573742062652067726561746572207468616e206669727374206c6561736520706572696f642e604c617374506572696f64546f6f466172496e4675747572650003042d01546865206c617374206c6561736520706572696f642063616e6e6f74206265206d6f7265207468616e203320706572696f64732061667465722074686520666972737420706572696f642e3c43616e6e6f74456e64496e5061737400040445015468652063616d706169676e20656e6473206265666f7265207468652063757272656e7420626c6f636b206e756d6265722e2054686520656e64206d75737420626520696e20746865206675747572652e44456e64546f6f466172496e467574757265000504c054686520656e64206461746520666f7220746869732063726f77646c6f616e206973206e6f742073656e7369626c652e204f766572666c6f770006045854686572652077617320616e206f766572666c6f772e50436f6e747269627574696f6e546f6f536d616c6c000704e854686520636f6e747269627574696f6e207761732062656c6f7720746865206d696e696d756d2c20604d696e436f6e747269627574696f6e602e34496e76616c69645061726149640008044c496e76616c69642066756e6420696e6465782e2c436170457863656564656400090490436f6e747269627574696f6e7320657863656564206d6178696d756d20616d6f756e742e58436f6e747269627574696f6e506572696f644f766572000a04a854686520636f6e747269627574696f6e20706572696f642068617320616c726561647920656e6465642e34496e76616c69644f726967696e000b048c546865206f726967696e206f6620746869732063616c6c20697320696e76616c69642e304e6f7450617261636861696e000c04c8546869732063726f77646c6f616e20646f6573206e6f7420636f72726573706f6e6420746f20612070617261636861696e2e2c4c65617365416374697665000d041501546869732070617261636861696e206c65617365206973207374696c6c2061637469766520616e64207265746972656d656e742063616e6e6f742079657420626567696e2e404269644f724c65617365416374697665000e043101546869732070617261636861696e277320626964206f72206c65617365206973207374696c6c2061637469766520616e642077697468647261772063616e6e6f742079657420626567696e2e3046756e644e6f74456e646564000f04805468652063726f77646c6f616e20686173206e6f742079657420656e6465642e3c4e6f436f6e747269627574696f6e73001004d0546865726520617265206e6f20636f6e747269627574696f6e732073746f72656420696e20746869732063726f77646c6f616e2e484e6f745265616479546f446973736f6c766500110855015468652063726f77646c6f616e206973206e6f7420726561647920746f20646973736f6c76652e20506f74656e7469616c6c79207374696c6c20686173206120736c6f74206f7220696e207265746972656d656e741c706572696f642e40496e76616c69645369676e617475726500120448496e76616c6964207369676e61747572652e304d656d6f546f6f4c617267650013047c5468652070726f7669646564206d656d6f20697320746f6f206c617267652e44416c7265616479496e4e65775261697365001404845468652066756e6420697320616c726561647920696e20604e65775261697365604856726644656c6179496e50726f6772657373001504b44e6f20636f6e747269627574696f6e7320616c6c6f77656420647572696e6720746865205652462064656c6179344e6f4c65617365506572696f640016042d0141206c6561736520706572696f6420686173206e6f742073746172746564207965742c2064756520746f20616e206f666673657420696e20746865207374617274696e6720626c6f636b2e048054686520604572726f726020656e756d206f6620746869732070616c6c65742ed50c0c2870616c6c65745f78636d1870616c6c65742c5175657279537461747573042c426c6f636b4e756d6265720110010c1c50656e64696e67100124726573706f6e6465726901014456657273696f6e65644c6f636174696f6e00014c6d617962655f6d617463685f71756572696572d90c01644f7074696f6e3c56657273696f6e65644c6f636174696f6e3e0001306d617962655f6e6f74696679dd0c01404f7074696f6e3c2875382c207538293e00011c74696d656f757410012c426c6f636b4e756d6265720000003c56657273696f6e4e6f7469666965720801186f726967696e6901014456657273696f6e65644c6f636174696f6e00012469735f616374697665780110626f6f6c000100145265616479080120726573706f6e7365e50c014456657273696f6e6564526573706f6e7365000108617410012c426c6f636b4e756d62657200020000d90c04184f7074696f6e0404540169010108104e6f6e6500000010536f6d65040069010000010000dd0c04184f7074696f6e04045401e10c0108104e6f6e6500000010536f6d650400e10c0000010000e10c00000408080800e50c080c78636d4456657273696f6e6564526573706f6e736500010c08563204003d06013076323a3a526573706f6e736500020008563304008506013076333a3a526573706f6e73650003000856340400e506013076343a3a526573706f6e736500040000e90c0000040810690100ed0c0000040c2c241000f10c0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401f50c045300000400f90c01185665633c543e0000f50c0000040869011000f90c000002f50c00fd0c0c2870616c6c65745f78636d1870616c6c65745456657273696f6e4d6967726174696f6e53746167650001105c4d696772617465537570706f7274656456657273696f6e0000005c4d69677261746556657273696f6e4e6f74696669657273000100504e6f7469667943757272656e74546172676574730400010d013c4f7074696f6e3c5665633c75383e3e000200684d696772617465416e644e6f746966794f6c645461726765747300030000010d04184f7074696f6e04045401340108104e6f6e6500000010536f6d650400340000010000050d0000040c1000090d00090d080c78636d4056657273696f6e65644173736574496400010808563304002d01012c76333a3a4173736574496400030008563404006501012c76343a3a41737365744964000400000d0d0c2870616c6c65745f78636d1870616c6c65746852656d6f74654c6f636b656446756e6769626c655265636f72640848436f6e73756d65724964656e746966696572018c304d6178436f6e73756d6572730000100118616d6f756e74180110753132380001146f776e65726901014456657273696f6e65644c6f636174696f6e0001186c6f636b65726901014456657273696f6e65644c6f636174696f6e000124636f6e73756d657273110d01d0426f756e6465645665633c28436f6e73756d65724964656e7469666965722c2075313238292c204d6178436f6e73756d6572733e0000110d0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401150d045300000400190d01185665633c543e0000150d000004088c1800190d000002150d001d0d0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401210d045300000400250d01185665633c543e0000210d0000040818690100250d000002210d00290d0c2870616c6c65745f78636d1870616c6c6574144572726f720404540001642c556e726561636861626c650000085d0154686520646573697265642064657374696e6174696f6e2077617320756e726561636861626c652c2067656e6572616c6c7920626563617573652074686572652069732061206e6f20776179206f6620726f7574696e6718746f2069742e2c53656e644661696c757265000108410154686572652077617320736f6d65206f746865722069737375652028692e652e206e6f7420746f20646f207769746820726f7574696e672920696e2073656e64696e6720746865206d6573736167652ec8506572686170732061206c61636b206f6620737061636520666f7220627566666572696e6720746865206d6573736167652e2046696c74657265640002049c546865206d65737361676520657865637574696f6e206661696c73207468652066696c7465722e48556e776569676861626c654d657373616765000304b4546865206d65737361676527732077656967687420636f756c64206e6f742062652064657465726d696e65642e6044657374696e6174696f6e4e6f74496e7665727469626c65000404dc5468652064657374696e6174696f6e20604c6f636174696f6e602070726f76696465642063616e6e6f7420626520696e7665727465642e14456d707479000504805468652061737365747320746f2062652073656e742061726520656d7074792e3843616e6e6f745265616e63686f720006043501436f756c64206e6f742072652d616e63686f72207468652061737365747320746f206465636c61726520746865206665657320666f72207468652064657374696e6174696f6e20636861696e2e34546f6f4d616e79417373657473000704c4546f6f206d616e79206173736574732068617665206265656e20617474656d7074656420666f72207472616e736665722e34496e76616c69644f726967696e000804784f726967696e20697320696e76616c696420666f722073656e64696e672e2842616456657273696f6e00090421015468652076657273696f6e206f6620746865206056657273696f6e6564602076616c75652075736564206973206e6f742061626c6520746f20626520696e7465727072657465642e2c4261644c6f636174696f6e000a08410154686520676976656e206c6f636174696f6e20636f756c64206e6f7420626520757365642028652e672e20626563617573652069742063616e6e6f742062652065787072657373656420696e2074686560646573697265642076657273696f6e206f662058434d292e384e6f537562736372697074696f6e000b04bc546865207265666572656e63656420737562736372697074696f6e20636f756c64206e6f7420626520666f756e642e44416c726561647953756273637269626564000c041101546865206c6f636174696f6e20697320696e76616c69642073696e636520697420616c726561647920686173206120737562736372697074696f6e2066726f6d2075732e5843616e6e6f74436865636b4f757454656c65706f7274000d042901436f756c64206e6f7420636865636b2d6f7574207468652061737365747320666f722074656c65706f72746174696f6e20746f207468652064657374696e6174696f6e20636861696e2e284c6f7742616c616e6365000e044101546865206f776e657220646f6573206e6f74206f776e2028616c6c29206f662074686520617373657420746861742074686579207769736820746f20646f20746865206f7065726174696f6e206f6e2e30546f6f4d616e794c6f636b73000f04c0546865206173736574206f776e65722068617320746f6f206d616e79206c6f636b73206f6e207468652061737365742e4c4163636f756e744e6f74536f7665726569676e001004310154686520676976656e206163636f756e74206973206e6f7420616e206964656e7469666961626c6520736f7665726569676e206163636f756e7420666f7220616e79206c6f636174696f6e2e28466565734e6f744d65740011042901546865206f7065726174696f6e207265717569726564206665657320746f20626520706169642077686963682074686520696e69746961746f7220636f756c64206e6f74206d6565742e304c6f636b4e6f74466f756e64001204f4412072656d6f7465206c6f636b20776974682074686520636f72726573706f6e64696e67206461746120636f756c64206e6f7420626520666f756e642e14496e557365001304490154686520756e6c6f636b206f7065726174696f6e2063616e6e6f742073756363656564206265636175736520746865726520617265207374696c6c20636f6e73756d657273206f6620746865206c6f636b2e5c496e76616c696441737365744e6f74436f6e63726574650014046c496e76616c6964206e6f6e2d636f6e63726574652061737365742e68496e76616c69644173736574556e6b6e6f776e52657365727665001504f0496e76616c69642061737365742c207265736572766520636861696e20636f756c64206e6f742062652064657465726d696e656420666f722069742e78496e76616c69644173736574556e737570706f72746564526573657276650016044501496e76616c69642061737365742c20646f206e6f7420737570706f72742072656d6f7465206173736574207265736572766573207769746820646966666572656e7420666565732072657365727665732e3c546f6f4d616e7952657365727665730017044901546f6f206d616e7920617373657473207769746820646966666572656e742072657365727665206c6f636174696f6e732068617665206265656e20617474656d7074656420666f72207472616e736665722e604c6f63616c457865637574696f6e496e636f6d706c6574650018047c4c6f63616c2058434d20657865637574696f6e20696e636f6d706c6574652e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e2d0d085070616c6c65745f6d6573736167655f717565756524426f6f6b537461746504344d6573736167654f726967696e01410700180114626567696e10012450616765496e64657800010c656e6410012450616765496e646578000114636f756e7410012450616765496e64657800014072656164795f6e65696768626f757273310d01844f7074696f6e3c4e65696768626f7572733c4d6573736167654f726967696e3e3e0001346d6573736167655f636f756e742c010c75363400011073697a652c010c7536340000310d04184f7074696f6e04045401350d0108104e6f6e6500000010536f6d650400350d0000010000350d085070616c6c65745f6d6573736167655f7175657565284e65696768626f75727304344d6573736167654f726967696e0141070008011070726576410701344d6573736167654f726967696e0001106e657874410701344d6573736167654f726967696e0000390d00000408410710003d0d085070616c6c65745f6d6573736167655f71756575651050616765081053697a650110204865617053697a65000018012472656d61696e696e6710011053697a6500013872656d61696e696e675f73697a6510011053697a6500012c66697273745f696e64657810011053697a65000114666972737410011053697a650001106c61737410011053697a6500011068656170410d019c426f756e6465645665633c75382c20496e746f5533323c4865617053697a652c2053697a653e3e0000410d0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e64656456656308045401080453000004003401185665633c543e0000450d0c5070616c6c65745f6d6573736167655f71756575651870616c6c6574144572726f720404540001242c4e6f745265617061626c65000008490150616765206973206e6f74207265617061626c65206265636175736520697420686173206974656d732072656d61696e696e6720746f2062652070726f63657373656420616e64206973206e6f74206f6c641c656e6f7567682e184e6f50616765000104845061676520746f2062652072656170656420646f6573206e6f742065786973742e244e6f4d657373616765000204a8546865207265666572656e636564206d65737361676520636f756c64206e6f7420626520666f756e642e40416c726561647950726f6365737365640003040101546865206d6573736167652077617320616c72656164792070726f63657373656420616e642063616e6e6f742062652070726f63657373656420616761696e2e18517565756564000404ac546865206d6573736167652069732071756575656420666f722066757475726520657865637574696f6e2e48496e73756666696369656e74576569676874000504190154686572652069732074656d706f726172696c79206e6f7420656e6f7567682077656967687420746f20636f6e74696e756520736572766963696e67206d657373616765732e6054656d706f726172696c79556e70726f6365737361626c65000610a854686973206d6573736167652069732074656d706f726172696c7920756e70726f6365737361626c652e00590153756368206572726f7273206172652065787065637465642c20627574206e6f742067756172616e746565642c20746f207265736f6c7665207468656d73656c766573206576656e7475616c6c79207468726f756768247265747279696e672e2c517565756550617573656400070cec5468652071756575652069732070617573656420616e64206e6f206d6573736167652063616e2062652065786563757465642066726f6d2069742e001d01546869732063616e206368616e676520617420616e792074696d6520616e64206d6179207265736f6c766520696e20746865206675747572652062792072652d747279696e672e4c526563757273697665446973616c6c6f7765640008043101416e6f746865722063616c6c20697320696e2070726f677265737320616e64206e6565647320746f2066696e697368206265666f726520746869732063616c6c2063616e2068617070656e2e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e490d0c4470616c6c65745f61737365745f726174651870616c6c6574144572726f7204045400010840556e6b6e6f776e41737365744b696e640000047854686520676976656e20617373657420494420697320756e6b6e6f776e2e34416c7265616479457869737473000104510154686520676976656e20617373657420494420616c72656164792068617320616e2061737369676e656420636f6e76657273696f6e207261746520616e642063616e6e6f742062652072652d637265617465642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e4d0d0c4c626f756e6465645f636f6c6c656374696f6e732c626f756e6465645f76656328426f756e646564566563080454014d02045300000400510d01185665633c543e0000510d0000024d0200550d0c3070616c6c65745f62656566791870616c6c6574144572726f7204045400011060496e76616c69644b65794f776e65727368697050726f6f66000004310141206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c696445717569766f636174696f6e50726f6f660001043101416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f727400020415014120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e50496e76616c6964436f6e66696775726174696f6e0003048c5375626d697474656420636f6e66696775726174696f6e20697320696e76616c69642e048054686520604572726f726020656e756d206f6620746869732070616c6c65742e590d0c4873705f636f6e73656e7375735f62656566790c6d6d72444265656679417574686f726974795365740458417574686f72697479536574436f6d6d69746d656e740130000c010869642c015463726174653a3a56616c696461746f72536574496400010c6c656e10010c7533320001446b65797365745f636f6d6d69746d656e74300158417574686f72697479536574436f6d6d69746d656e7400005d0d00000424610d650d690d6d0d710d790d7d0d810d850d00610d10306672616d655f73797374656d28657874656e73696f6e7354636865636b5f6e6f6e5f7a65726f5f73656e64657248436865636b4e6f6e5a65726f53656e64657204045400000000650d10306672616d655f73797374656d28657874656e73696f6e7348636865636b5f737065635f76657273696f6e40436865636b5370656356657273696f6e04045400000000690d10306672616d655f73797374656d28657874656e73696f6e7340636865636b5f74785f76657273696f6e38436865636b547856657273696f6e040454000000006d0d10306672616d655f73797374656d28657874656e73696f6e7334636865636b5f67656e6573697330436865636b47656e6573697304045400000000710d10306672616d655f73797374656d28657874656e73696f6e733c636865636b5f6d6f7274616c69747938436865636b4d6f7274616c69747904045400000400750d010c4572610000750d102873705f72756e74696d651c67656e657269630c6572610c4572610001010420496d6d6f7274616c0000001c4d6f7274616c31040008000001001c4d6f7274616c32040008000002001c4d6f7274616c33040008000003001c4d6f7274616c34040008000004001c4d6f7274616c35040008000005001c4d6f7274616c36040008000006001c4d6f7274616c37040008000007001c4d6f7274616c38040008000008001c4d6f7274616c3904000800000900204d6f7274616c313004000800000a00204d6f7274616c313104000800000b00204d6f7274616c313204000800000c00204d6f7274616c313304000800000d00204d6f7274616c313404000800000e00204d6f7274616c313504000800000f00204d6f7274616c313604000800001000204d6f7274616c313704000800001100204d6f7274616c313804000800001200204d6f7274616c313904000800001300204d6f7274616c323004000800001400204d6f7274616c323104000800001500204d6f7274616c323204000800001600204d6f7274616c323304000800001700204d6f7274616c323404000800001800204d6f7274616c323504000800001900204d6f7274616c323604000800001a00204d6f7274616c323704000800001b00204d6f7274616c323804000800001c00204d6f7274616c323904000800001d00204d6f7274616c333004000800001e00204d6f7274616c333104000800001f00204d6f7274616c333204000800002000204d6f7274616c333304000800002100204d6f7274616c333404000800002200204d6f7274616c333504000800002300204d6f7274616c333604000800002400204d6f7274616c333704000800002500204d6f7274616c333804000800002600204d6f7274616c333904000800002700204d6f7274616c343004000800002800204d6f7274616c343104000800002900204d6f7274616c343204000800002a00204d6f7274616c343304000800002b00204d6f7274616c343404000800002c00204d6f7274616c343504000800002d00204d6f7274616c343604000800002e00204d6f7274616c343704000800002f00204d6f7274616c343804000800003000204d6f7274616c343904000800003100204d6f7274616c353004000800003200204d6f7274616c353104000800003300204d6f7274616c353204000800003400204d6f7274616c353304000800003500204d6f7274616c353404000800003600204d6f7274616c353504000800003700204d6f7274616c353604000800003800204d6f7274616c353704000800003900204d6f7274616c353804000800003a00204d6f7274616c353904000800003b00204d6f7274616c363004000800003c00204d6f7274616c363104000800003d00204d6f7274616c363204000800003e00204d6f7274616c363304000800003f00204d6f7274616c363404000800004000204d6f7274616c363504000800004100204d6f7274616c363604000800004200204d6f7274616c363704000800004300204d6f7274616c363804000800004400204d6f7274616c363904000800004500204d6f7274616c373004000800004600204d6f7274616c373104000800004700204d6f7274616c373204000800004800204d6f7274616c373304000800004900204d6f7274616c373404000800004a00204d6f7274616c373504000800004b00204d6f7274616c373604000800004c00204d6f7274616c373704000800004d00204d6f7274616c373804000800004e00204d6f7274616c373904000800004f00204d6f7274616c383004000800005000204d6f7274616c383104000800005100204d6f7274616c383204000800005200204d6f7274616c383304000800005300204d6f7274616c383404000800005400204d6f7274616c383504000800005500204d6f7274616c383604000800005600204d6f7274616c383704000800005700204d6f7274616c383804000800005800204d6f7274616c383904000800005900204d6f7274616c393004000800005a00204d6f7274616c393104000800005b00204d6f7274616c393204000800005c00204d6f7274616c393304000800005d00204d6f7274616c393404000800005e00204d6f7274616c393504000800005f00204d6f7274616c393604000800006000204d6f7274616c393704000800006100204d6f7274616c393804000800006200204d6f7274616c393904000800006300244d6f7274616c31303004000800006400244d6f7274616c31303104000800006500244d6f7274616c31303204000800006600244d6f7274616c31303304000800006700244d6f7274616c31303404000800006800244d6f7274616c31303504000800006900244d6f7274616c31303604000800006a00244d6f7274616c31303704000800006b00244d6f7274616c31303804000800006c00244d6f7274616c31303904000800006d00244d6f7274616c31313004000800006e00244d6f7274616c31313104000800006f00244d6f7274616c31313204000800007000244d6f7274616c31313304000800007100244d6f7274616c31313404000800007200244d6f7274616c31313504000800007300244d6f7274616c31313604000800007400244d6f7274616c31313704000800007500244d6f7274616c31313804000800007600244d6f7274616c31313904000800007700244d6f7274616c31323004000800007800244d6f7274616c31323104000800007900244d6f7274616c31323204000800007a00244d6f7274616c31323304000800007b00244d6f7274616c31323404000800007c00244d6f7274616c31323504000800007d00244d6f7274616c31323604000800007e00244d6f7274616c31323704000800007f00244d6f7274616c31323804000800008000244d6f7274616c31323904000800008100244d6f7274616c31333004000800008200244d6f7274616c31333104000800008300244d6f7274616c31333204000800008400244d6f7274616c31333304000800008500244d6f7274616c31333404000800008600244d6f7274616c31333504000800008700244d6f7274616c31333604000800008800244d6f7274616c31333704000800008900244d6f7274616c31333804000800008a00244d6f7274616c31333904000800008b00244d6f7274616c31343004000800008c00244d6f7274616c31343104000800008d00244d6f7274616c31343204000800008e00244d6f7274616c31343304000800008f00244d6f7274616c31343404000800009000244d6f7274616c31343504000800009100244d6f7274616c31343604000800009200244d6f7274616c31343704000800009300244d6f7274616c31343804000800009400244d6f7274616c31343904000800009500244d6f7274616c31353004000800009600244d6f7274616c31353104000800009700244d6f7274616c31353204000800009800244d6f7274616c31353304000800009900244d6f7274616c31353404000800009a00244d6f7274616c31353504000800009b00244d6f7274616c31353604000800009c00244d6f7274616c31353704000800009d00244d6f7274616c31353804000800009e00244d6f7274616c31353904000800009f00244d6f7274616c3136300400080000a000244d6f7274616c3136310400080000a100244d6f7274616c3136320400080000a200244d6f7274616c3136330400080000a300244d6f7274616c3136340400080000a400244d6f7274616c3136350400080000a500244d6f7274616c3136360400080000a600244d6f7274616c3136370400080000a700244d6f7274616c3136380400080000a800244d6f7274616c3136390400080000a900244d6f7274616c3137300400080000aa00244d6f7274616c3137310400080000ab00244d6f7274616c3137320400080000ac00244d6f7274616c3137330400080000ad00244d6f7274616c3137340400080000ae00244d6f7274616c3137350400080000af00244d6f7274616c3137360400080000b000244d6f7274616c3137370400080000b100244d6f7274616c3137380400080000b200244d6f7274616c3137390400080000b300244d6f7274616c3138300400080000b400244d6f7274616c3138310400080000b500244d6f7274616c3138320400080000b600244d6f7274616c3138330400080000b700244d6f7274616c3138340400080000b800244d6f7274616c3138350400080000b900244d6f7274616c3138360400080000ba00244d6f7274616c3138370400080000bb00244d6f7274616c3138380400080000bc00244d6f7274616c3138390400080000bd00244d6f7274616c3139300400080000be00244d6f7274616c3139310400080000bf00244d6f7274616c3139320400080000c000244d6f7274616c3139330400080000c100244d6f7274616c3139340400080000c200244d6f7274616c3139350400080000c300244d6f7274616c3139360400080000c400244d6f7274616c3139370400080000c500244d6f7274616c3139380400080000c600244d6f7274616c3139390400080000c700244d6f7274616c3230300400080000c800244d6f7274616c3230310400080000c900244d6f7274616c3230320400080000ca00244d6f7274616c3230330400080000cb00244d6f7274616c3230340400080000cc00244d6f7274616c3230350400080000cd00244d6f7274616c3230360400080000ce00244d6f7274616c3230370400080000cf00244d6f7274616c3230380400080000d000244d6f7274616c3230390400080000d100244d6f7274616c3231300400080000d200244d6f7274616c3231310400080000d300244d6f7274616c3231320400080000d400244d6f7274616c3231330400080000d500244d6f7274616c3231340400080000d600244d6f7274616c3231350400080000d700244d6f7274616c3231360400080000d800244d6f7274616c3231370400080000d900244d6f7274616c3231380400080000da00244d6f7274616c3231390400080000db00244d6f7274616c3232300400080000dc00244d6f7274616c3232310400080000dd00244d6f7274616c3232320400080000de00244d6f7274616c3232330400080000df00244d6f7274616c3232340400080000e000244d6f7274616c3232350400080000e100244d6f7274616c3232360400080000e200244d6f7274616c3232370400080000e300244d6f7274616c3232380400080000e400244d6f7274616c3232390400080000e500244d6f7274616c3233300400080000e600244d6f7274616c3233310400080000e700244d6f7274616c3233320400080000e800244d6f7274616c3233330400080000e900244d6f7274616c3233340400080000ea00244d6f7274616c3233350400080000eb00244d6f7274616c3233360400080000ec00244d6f7274616c3233370400080000ed00244d6f7274616c3233380400080000ee00244d6f7274616c3233390400080000ef00244d6f7274616c3234300400080000f000244d6f7274616c3234310400080000f100244d6f7274616c3234320400080000f200244d6f7274616c3234330400080000f300244d6f7274616c3234340400080000f400244d6f7274616c3234350400080000f500244d6f7274616c3234360400080000f600244d6f7274616c3234370400080000f700244d6f7274616c3234380400080000f800244d6f7274616c3234390400080000f900244d6f7274616c3235300400080000fa00244d6f7274616c3235310400080000fb00244d6f7274616c3235320400080000fc00244d6f7274616c3235330400080000fd00244d6f7274616c3235340400080000fe00244d6f7274616c3235350400080000ff0000790d10306672616d655f73797374656d28657874656e73696f6e732c636865636b5f6e6f6e636528436865636b4e6f6e63650404540000040015010120543a3a4e6f6e636500007d0d10306672616d655f73797374656d28657874656e73696f6e7330636865636b5f7765696768742c436865636b57656967687404045400000000810d086870616c6c65745f7472616e73616374696f6e5f7061796d656e74604368617267655472616e73616374696f6e5061796d656e7404045400000400f4013042616c616e63654f663c543e0000850d0c5c706f6c6b61646f745f72756e74696d655f636f6d6d6f6e18636c61696d734850726576616c69646174654174746573747304045400000000890d0840706f6c6b61646f745f72756e74696d651c52756e74696d65000000008d0d102873705f72756e74696d651c67656e6572696314626c6f636b14426c6f636b081848656164657201c5012445787472696e73696301910d00080118686561646572c501011848656164657200012865787472696e73696373950d01385665633c45787472696e7369633e0000910d102873705f72756e74696d651c67656e657269634c756e636865636b65645f65787472696e73696348556e636865636b656445787472696e736963101c4164647265737301e9011043616c6c019901245369676e617475726501a103144578747261015d0d00040034000000950d000002910d00990d081c73705f636f7265384f70617175654d657461646174610000040034011c5665633c75383e00009d0d04184f7074696f6e04045401990d0108104e6f6e6500000010536f6d650400990d0000010000a10d0418526573756c740804540188044501a50d0108084f6b040088000000000c4572720400a50d0000010000a50d0c2873705f72756e74696d65507472616e73616374696f6e5f76616c6964697479605472616e73616374696f6e56616c69646974794572726f720001081c496e76616c69640400a90d0148496e76616c69645472616e73616374696f6e0000001c556e6b6e6f776e0400ad0d0148556e6b6e6f776e5472616e73616374696f6e00010000a90d0c2873705f72756e74696d65507472616e73616374696f6e5f76616c696469747948496e76616c69645472616e73616374696f6e00012c1043616c6c0000001c5061796d656e7400010018467574757265000200145374616c650003002042616450726f6f6600040044416e6369656e744269727468426c6f636b0005004445786861757374735265736f757263657300060018437573746f6d04000801087538000700304261644d616e6461746f72790008004c4d616e6461746f727956616c69646174696f6e000900244261645369676e6572000a0000ad0d0c2873705f72756e74696d65507472616e73616374696f6e5f76616c696469747948556e6b6e6f776e5472616e73616374696f6e00010c3043616e6e6f744c6f6f6b75700000004c4e6f556e7369676e656456616c696461746f7200010018437573746f6d0400080108753800020000b10d083073705f696e686572656e747330496e686572656e7444617461000004011064617461b50d019442547265654d61703c496e686572656e744964656e7469666965722c205665633c75383e3e0000b50d042042547265654d617008044b01310304560134000400b90d000000b90d000002bd0d00bd0d0000040831033400c10d083073705f696e686572656e747350436865636b496e686572656e7473526573756c7400000c01106f6b6179780110626f6f6c00012c666174616c5f6572726f72780110626f6f6c0001186572726f7273b10d0130496e686572656e74446174610000c50d0c2873705f72756e74696d65507472616e73616374696f6e5f76616c6964697479445472616e73616374696f6e536f7572636500010c1c496e426c6f636b000000144c6f63616c0001002045787465726e616c00020000c90d0418526573756c7408045401cd0d044501a50d0108084f6b0400cd0d000000000c4572720400a50d0000010000cd0d0c2873705f72756e74696d65507472616e73616374696f6e5f76616c69646974794056616c69645472616e73616374696f6e00001401207072696f726974792c014c5472616e73616374696f6e5072696f726974790001207265717569726573a901014c5665633c5472616e73616374696f6e5461673e00012070726f7669646573a901014c5665633c5472616e73616374696f6e5461673e0001246c6f6e6765766974792c01505472616e73616374696f6e4c6f6e67657669747900012470726f706167617465780110626f6f6c0000d10d00000408d90bd50d00d50d0c4c706f6c6b61646f745f7072696d6974697665730876364447726f7570526f746174696f6e496e666f04044e0110000c014c73657373696f6e5f73746172745f626c6f636b1001044e00016067726f75705f726f746174696f6e5f6672657175656e63791001044e00010c6e6f771001044e0000d90d000002dd0d00dd0d0c4c706f6c6b61646f745f7072696d69746976657308763624436f726553746174650804480130044e0110010c204f636375706965640400e10d01484f63637570696564436f72653c482c204e3e000000245363686564756c65640400e90d01345363686564756c6564436f7265000100104672656500020000e10d0c4c706f6c6b61646f745f7072696d697469766573087636304f63637570696564436f72650804480130044e0110002001506e6578745f75705f6f6e5f617661696c61626c65e50d01544f7074696f6e3c5363686564756c6564436f72653e0001386f636375706965645f73696e63651001044e00012c74696d655f6f75745f61741001044e00014c6e6578745f75705f6f6e5f74696d655f6f7574e50d01544f7074696f6e3c5363686564756c6564436f72653e000130617661696c6162696c6974793d05017c4269745665633c75382c206269747665633a3a6f726465723a3a4c7362303e00014467726f75705f726573706f6e7369626c65d907012847726f7570496e64657800013863616e6469646174655f686173689905013443616e6469646174654861736800015063616e6469646174655f64657363726970746f725905015843616e64696461746544657363726970746f723c483e0000e50d04184f7074696f6e04045401e90d0108104e6f6e6500000010536f6d650400e90d0000010000e90d0c4c706f6c6b61646f745f7072696d697469766573087636345363686564756c6564436f7265000008011c706172615f6964b90201084964000120636f6c6c61746f72ed0d01484f7074696f6e3c436f6c6c61746f7249643e0000ed0d04184f7074696f6e040454015d050108104e6f6e6500000010536f6d6504005d050000010000f10d0c4c706f6c6b61646f745f7072696d697469766573087636584f63637570696564436f7265417373756d7074696f6e00010c20496e636c756465640000002054696d65644f7574000100104672656500020000f50d04184f7074696f6e04045401f90d0108104e6f6e6500000010536f6d650400f90d0000010000f90d0c4c706f6c6b61646f745f7072696d6974697665730876365c50657273697374656456616c69646174696f6e446174610804480130044e01100010012c706172656e745f6865616485050120486561644461746100014c72656c61795f706172656e745f6e756d6265721001044e00016472656c61795f706172656e745f73746f726167655f726f6f74300104480001306d61785f706f765f73697a6510010c7533320000fd0d04184f7074696f6e04045401010e0108104e6f6e6500000010536f6d650400010e0000010000010e00000408f90d650500050e04184f7074696f6e0404540155050108104e6f6e6500000010536f6d65040055050000010000090e0000020d0e000d0e0c4c706f6c6b61646f745f7072696d6974697665730876363843616e6469646174654576656e740404480130010c3c43616e6469646174654261636b65641000d107014c43616e646964617465526563656970743c483e00008505012048656164446174610000d5070124436f7265496e6465780000d907012847726f7570496e6465780000004443616e646964617465496e636c756465641000d107014c43616e646964617465526563656970743c483e00008505012048656164446174610000d5070124436f7265496e6465780000d907012847726f7570496e6465780001004443616e64696461746554696d65644f75740c00d107014c43616e646964617465526563656970743c483e00008505012048656164446174610000d5070124436f7265496e64657800020000110e042042547265654d617008044b01b902045601590c000400150e000000150e000002190e00190e00000408b902590c001d0e04184f7074696f6e04045401c10b0108104e6f6e6500000010536f6d650400c10b0000010000210e04184f7074696f6e04045401710c0108104e6f6e6500000010536f6d650400710c0000010000250e04184f7074696f6e0404540165050108104e6f6e6500000010536f6d65040065050000010000290e0000022d0e002d0e0000040c109905810c00310e04184f7074696f6e0404540109050108104e6f6e6500000010536f6d65040009050000010000350e000002390e00390e0000040c1099058d0c003d0e04184f7074696f6e04045401410e0108104e6f6e6500000010536f6d650400410e0000010000410e104c706f6c6b61646f745f7072696d69746976657308763620736c617368696e675c4f70617175654b65794f776e65727368697050726f6f660000040034011c5665633c75383e0000450e04184f7074696f6e040454018c0108104e6f6e6500000010536f6d6504008c0000010000490e04184f7074696f6e040454014d0e0108104e6f6e6500000010536f6d6504004d0e00000100004d0e104c706f6c6b61646f745f7072696d697469766573087636346173796e635f6261636b696e67304261636b696e6753746174650804480130044e01100008012c636f6e73747261696e7473510e0138436f6e73747261696e74733c4e3e00015070656e64696e675f617661696c6162696c697479710e019c5665633c43616e64696461746550656e64696e67417661696c6162696c6974793c482c204e3e3e0000510e104c706f6c6b61646f745f7072696d697469766573087636346173796e635f6261636b696e672c436f6e73747261696e747304044e01100038015c6d696e5f72656c61795f706172656e745f6e756d6265721001044e0001306d61785f706f765f73697a6510010c7533320001346d61785f636f64655f73697a6510010c753332000134756d705f72656d61696e696e6710010c75333200014c756d705f72656d61696e696e675f627974657310010c7533320001646d61785f756d705f6e756d5f7065725f63616e64696461746510010c753332000158646d705f72656d61696e696e675f6d65737361676573090201185665633c4e3e00013068726d705f696e626f756e64550e0164496e626f756e6448726d704c696d69746174696f6e733c4e3e00014468726d705f6368616e6e656c735f6f7574590e01a45665633c2849642c204f7574626f756e6448726d704368616e6e656c4c696d69746174696f6e73293e0001686d61785f68726d705f6e756d5f7065725f63616e64696461746510010c75333200013c72657175697265645f706172656e7485050120486561644461746100015076616c69646174696f6e5f636f64655f686173686505014856616c69646174696f6e436f64654861736800014c757067726164655f7265737472696374696f6e650e01684f7074696f6e3c557067726164655265737472696374696f6e3e0001586675747572655f76616c69646174696f6e5f636f6465690e017c4f7074696f6e3c284e2c2056616c69646174696f6e436f646548617368293e0000550e104c706f6c6b61646f745f7072696d697469766573087636346173796e635f6261636b696e6758496e626f756e6448726d704c696d69746174696f6e7304044e01100004014076616c69645f77617465726d61726b73090201185665633c4e3e0000590e0000025d0e005d0e00000408b902610e00610e104c706f6c6b61646f745f7072696d697469766573087636346173796e635f6261636b696e67784f7574626f756e6448726d704368616e6e656c4c696d69746174696f6e73000008013c62797465735f72656d61696e696e6710010c7533320001486d657373616765735f72656d61696e696e6710010c7533320000650e04184f7074696f6e04045401310c0108104e6f6e6500000010536f6d650400310c0000010000690e04184f7074696f6e040454016d0e0108104e6f6e6500000010536f6d6504006d0e00000100006d0e0000040810650500710e000002750e00750e104c706f6c6b61646f745f7072696d697469766573087636346173796e635f6261636b696e677043616e64696461746550656e64696e67417661696c6162696c6974790804480130044e01100014013863616e6469646174655f686173689905013443616e6469646174654861736800012864657363726970746f725905015843616e64696461746544657363726970746f723c483e00012c636f6d6d69746d656e74736905015043616e646964617465436f6d6d69746d656e747300014c72656c61795f706172656e745f6e756d6265721001044e0001306d61785f706f765f73697a6510010c7533320000790e04184f7074696f6e040454017d0e0108104e6f6e6500000010536f6d6504007d0e00000100007d0e084873705f636f6e73656e7375735f62656566793056616c696461746f72536574042c417574686f726974794964014d020008012876616c696461746f7273510d01405665633c417574686f7269747949643e00010869642c013856616c696461746f7253657449640000810e084873705f636f6e73656e7375735f62656566795c4f70617175654b65794f776e65727368697050726f6f660000040034011c5665633c75383e0000850e04184f7074696f6e04045401810e0108104e6f6e6500000010536f6d650400810e0000010000890e0418526573756c7408045401300445018d0e0108084f6b040030000000000c45727204008d0e00000100008d0e084473705f6d6d725f7072696d697469766573144572726f7200012840496e76616c69644e756d657269634f7000000010507573680001001c476574526f6f7400020018436f6d6d69740003003447656e657261746550726f6f6600040018566572696679000500304c6561664e6f74466f756e640006004450616c6c65744e6f74496e636c7564656400070040496e76616c69644c656166496e64657800080054496e76616c6964426573744b6e6f776e426c6f636b00090000910e0418526573756c74080454012c0445018d0e0108084f6b04002c000000000c45727204008d0e0000010000950e0418526573756c7408045401990e0445018d0e0108084f6b0400990e000000000c45727204008d0e0000010000990e000004089d0ea50e009d0e000002a10e00a10e084473705f6d6d725f7072696d6974697665734c456e636f6461626c654f70617175654c6561660000040034011c5665633c75383e0000a50e084473705f6d6d725f7072696d6974697665731450726f6f660410486173680130000c01306c6561665f696e64696365732d0b01385665633c4c656166496e6465783e0001286c6561665f636f756e742c01244e6f6465496e6465780001146974656d73b90101245665633c486173683e0000a90e0418526573756c74080454018c0445018d0e0108084f6b04008c000000000c45727204008d0e0000010000ad0e085073705f636f6e73656e7375735f6772616e6470615c4f70617175654b65794f776e65727368697050726f6f660000040034011c5665633c75383e0000b10e04184f7074696f6e04045401ad0e0108104e6f6e6500000010536f6d650400ad0e0000010000b50e084473705f636f6e73656e7375735f626162654442616265436f6e66696775726174696f6e0000180134736c6f745f6475726174696f6e2c010c75363400013065706f63685f6c656e6774682c010c75363400010463d9010128287536342c207536342900012c617574686f726974696573a108019c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e00012872616e646f6d6e65737304012852616e646f6d6e657373000134616c6c6f7765645f736c6f7473dd010130416c6c6f776564536c6f74730000b90e084473705f636f6e73656e7375735f626162651445706f6368000018012c65706f63685f696e6465782c010c75363400012873746172745f736c6f74cd010110536c6f740001206475726174696f6e2c010c75363400012c617574686f726974696573a108019c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e00012872616e646f6d6e65737304012852616e646f6d6e657373000118636f6e666967c50801584261626545706f6368436f6e66696775726174696f6e0000bd0e04184f7074696f6e04045401c10e0108104e6f6e6500000010536f6d650400c10e0000010000c10e084473705f636f6e73656e7375735f626162655c4f70617175654b65794f776e65727368697050726f6f660000040034011c5665633c75383e0000c50e04184f7074696f6e04045401c90e0108104e6f6e6500000010536f6d650400c90e0000010000c90e000002cd0e00cd0e00000408349d0900d10e0c6870616c6c65745f7472616e73616374696f6e5f7061796d656e741474797065734c52756e74696d654469737061746368496e666f081c42616c616e63650118185765696768740124000c0118776569676874240118576569676874000114636c6173735c01344469737061746368436c61737300012c7061727469616c5f66656518011c42616c616e63650000d50e0c6870616c6c65745f7472616e73616374696f6e5f7061796d656e741474797065732846656544657461696c73041c42616c616e6365011800080134696e636c7573696f6e5f666565d90e01744f7074696f6e3c496e636c7573696f6e4665653c42616c616e63653e3e00010c74697018011c42616c616e63650000d90e04184f7074696f6e04045401dd0e0108104e6f6e6500000010536f6d650400dd0e0000010000dd0e0c6870616c6c65745f7472616e73616374696f6e5f7061796d656e7414747970657330496e636c7573696f6e466565041c42616c616e63650118000c0120626173655f66656518011c42616c616e636500011c6c656e5f66656518011c42616c616e636500014c61646a75737465645f7765696768745f66656518011c42616c616e63650000e10e0418526573756c74080454018c0445012d080108084f6b04008c000000000c45727204002d080000010000e50e0840706f6c6b61646f745f72756e74696d653052756e74696d654572726f720001a41853797374656d04005d0801706672616d655f73797374656d3a3a4572726f723c52756e74696d653e000000245363686564756c657204007108018070616c6c65745f7363686564756c65723a3a4572726f723c52756e74696d653e00010020507265696d61676504009508017c70616c6c65745f707265696d6167653a3a4572726f723c52756e74696d653e000a0010426162650400d508016c70616c6c65745f626162653a3a4572726f723c52756e74696d653e0002001c496e64696365730400dd08017870616c6c65745f696e64696365733a3a4572726f723c52756e74696d653e0004002042616c616e63657304002909017c70616c6c65745f62616c616e6365733a3a4572726f723c52756e74696d653e0005001c5374616b696e6704008509017870616c6c65745f7374616b696e673a3a4572726f723c52756e74696d653e0007001c53657373696f6e0400a109017870616c6c65745f73657373696f6e3a3a4572726f723c52756e74696d653e0009001c4772616e6470610400b109017870616c6c65745f6772616e6470613a3a4572726f723c52756e74696d653e000b002054726561737572790400d509017c70616c6c65745f74726561737572793a3a4572726f723c52756e74696d653e00130040436f6e76696374696f6e566f74696e670400090a01a070616c6c65745f636f6e76696374696f6e5f766f74696e673a3a4572726f723c52756e74696d653e001400245265666572656e64610400510a018070616c6c65745f7265666572656e64613a3a4572726f723c52756e74696d653e0015002457686974656c6973740400550a018070616c6c65745f77686974656c6973743a3a4572726f723c52756e74696d653e00170018436c61696d730400590a0158636c61696d733a3a4572726f723c52756e74696d653e0018001c56657374696e670400690a017870616c6c65745f76657374696e673a3a4572726f723c52756e74696d653e0019001c5574696c69747904006d0a017870616c6c65745f7574696c6974793a3a4572726f723c52756e74696d653e001a00204964656e746974790400a90a017c70616c6c65745f6964656e746974793a3a4572726f723c52756e74696d653e001c001450726f78790400cd0a017070616c6c65745f70726f78793a3a4572726f723c52756e74696d653e001d00204d756c74697369670400dd0a017c70616c6c65745f6d756c74697369673a3a4572726f723c52756e74696d653e001e0020426f756e746965730400ed0a017c70616c6c65745f626f756e746965733a3a4572726f723c52756e74696d653e002200344368696c64426f756e746965730400f90a019470616c6c65745f6368696c645f626f756e746965733a3a4572726f723c52756e74696d653e00260068456c656374696f6e50726f76696465724d756c746950686173650400210b01d070616c6c65745f656c656374696f6e5f70726f76696465725f6d756c74695f70686173653a3a4572726f723c52756e74696d653e00240024566f7465724c6973740400310b01f470616c6c65745f626167735f6c6973743a3a4572726f723c52756e74696d652c2070616c6c65745f626167735f6c6973743a3a496e7374616e6365313e0025003c4e6f6d696e6174696f6e506f6f6c730400790b019c70616c6c65745f6e6f6d696e6174696f6e5f706f6f6c733a3a4572726f723c52756e74696d653e0027002c46617374556e7374616b6504008d0b018c70616c6c65745f666173745f756e7374616b653a3a4572726f723c52756e74696d653e00280034436f6e66696775726174696f6e04009d0b01a070617261636861696e735f636f6e66696775726174696f6e3a3a4572726f723c52756e74696d653e0033003450617261496e636c7573696f6e0400bd0b019070617261636861696e735f696e636c7573696f6e3a3a4572726f723c52756e74696d653e0035003050617261496e686572656e740400d50b01a470617261636861696e735f70617261735f696e686572656e743a3a4572726f723c52756e74696d653e0036001450617261730400390c018070617261636861696e735f70617261733a3a4572726f723c52756e74696d653e0038001048726d700400690c017c70617261636861696e735f68726d703a3a4572726f723c52756e74696d653e003c0034506172617344697370757465730400890c018c70617261636861696e735f64697370757465733a3a4572726f723c52756e74696d653e003e00345061726173536c617368696e6704009d0c018c70617261636861696e735f736c617368696e673a3a4572726f723c52756e74696d653e003f00245265676973747261720400a90c017c70617261735f7265676973747261723a3a4572726f723c52756e74696d653e00460014536c6f74730400b10c0154736c6f74733a3a4572726f723c52756e74696d653e0047002041756374696f6e730400c50c016061756374696f6e733a3a4572726f723c52756e74696d653e0048002443726f77646c6f616e0400d10c016463726f77646c6f616e3a3a4572726f723c52756e74696d653e004900485374617465547269654d6967726174696f6e0400090801ac70616c6c65745f73746174655f747269655f6d6967726174696f6e3a3a4572726f723c52756e74696d653e0062002458636d50616c6c65740400290d016870616c6c65745f78636d3a3a4572726f723c52756e74696d653e006300304d65737361676551756575650400450d019070616c6c65745f6d6573736167655f71756575653a3a4572726f723c52756e74696d653e006400244173736574526174650400490d018470616c6c65745f61737365745f726174653a3a4572726f723c52756e74696d653e0065001442656566790400550d017070616c6c65745f62656566793a3a4572726f723c52756e74696d653e00c80000e41853797374656d011853797374656d441c4163636f756e7401010402000c4101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008004e8205468652066756c6c206163636f756e7420696e666f726d6174696f6e20666f72206120706172746963756c6172206163636f756e742049442e3845787472696e736963436f756e74000010040004b820546f74616c2065787472696e7369637320636f756e7420666f72207468652063757272656e7420626c6f636b2e2c426c6f636b576569676874010020180000000000000488205468652063757272656e742077656967687420666f722074686520626c6f636b2e40416c6c45787472696e736963734c656e000010040004410120546f74616c206c656e6774682028696e2062797465732920666f7220616c6c2065787472696e736963732070757420746f6765746865722c20666f72207468652063757272656e7420626c6f636b2e24426c6f636b486173680101040510308000000000000000000000000000000000000000000000000000000000000000000498204d6170206f6620626c6f636b206e756d6265727320746f20626c6f636b206861736865732e3445787472696e736963446174610101040510340400043d012045787472696e73696373206461746120666f72207468652063757272656e7420626c6f636b20286d61707320616e2065787472696e736963277320696e64657820746f206974732064617461292e184e756d6265720100101000000000040901205468652063757272656e7420626c6f636b206e756d626572206265696e672070726f6365737365642e205365742062792060657865637574655f626c6f636b602e28506172656e744861736801003080000000000000000000000000000000000000000000000000000000000000000004702048617368206f66207468652070726576696f757320626c6f636b2e18446967657374010038040004f020446967657374206f66207468652063757272656e7420626c6f636b2c20616c736f2070617274206f662074686520626c6f636b206865616465722e184576656e747301004804001ca0204576656e7473206465706f736974656420666f72207468652063757272656e7420626c6f636b2e001d01204e4f54453a20546865206974656d20697320756e626f756e6420616e642073686f756c64207468657265666f7265206e657665722062652072656164206f6e20636861696e2ed020497420636f756c64206f746865727769736520696e666c6174652074686520506f562073697a65206f66206120626c6f636b2e002d01204576656e747320686176652061206c6172676520696e2d6d656d6f72792073697a652e20426f7820746865206576656e747320746f206e6f7420676f206f75742d6f662d6d656d6f7279fc206a75737420696e206361736520736f6d656f6e65207374696c6c207265616473207468656d2066726f6d2077697468696e207468652072756e74696d652e284576656e74436f756e74010010100000000004b820546865206e756d626572206f66206576656e747320696e2074686520604576656e74733c543e60206c6973742e2c4576656e74546f70696373010104023025080400282501204d617070696e67206265747765656e206120746f7069632028726570726573656e74656420627920543a3a486173682920616e64206120766563746f72206f6620696e646578657394206f66206576656e747320696e2074686520603c4576656e74733c543e3e60206c6973742e00510120416c6c20746f70696320766563746f727320686176652064657465726d696e69737469632073746f72616765206c6f636174696f6e7320646570656e64696e67206f6e2074686520746f7069632e2054686973450120616c6c6f7773206c696768742d636c69656e747320746f206c6576657261676520746865206368616e67657320747269652073746f7261676520747261636b696e67206d656368616e69736d20616e64e420696e2063617365206f66206368616e67657320666574636820746865206c697374206f66206576656e7473206f6620696e7465726573742e005901205468652076616c756520686173207468652074797065206028426c6f636b4e756d626572466f723c543e2c204576656e74496e646578296020626563617573652069662077652075736564206f6e6c79206a7573744d012074686520604576656e74496e64657860207468656e20696e20636173652069662074686520746f70696320686173207468652073616d6520636f6e74656e7473206f6e20746865206e65787420626c6f636b0101206e6f206e6f74696669636174696f6e2077696c6c20626520747269676765726564207468757320746865206576656e74206d69676874206265206c6f73742e484c61737452756e74696d65557067726164650000290804000455012053746f726573207468652060737065635f76657273696f6e6020616e642060737065635f6e616d6560206f66207768656e20746865206c6173742072756e74696d6520757067726164652068617070656e65642e545570677261646564546f553332526566436f756e740100780400044d012054727565206966207765206861766520757067726164656420736f207468617420607479706520526566436f756e74602069732060753332602e2046616c7365202864656661756c7429206966206e6f742e605570677261646564546f547269706c65526566436f756e740100780400085d012054727565206966207765206861766520757067726164656420736f2074686174204163636f756e74496e666f20636f6e7461696e73207468726565207479706573206f662060526566436f756e74602e2046616c736548202864656661756c7429206966206e6f742e38457865637574696f6e506861736500002108040004882054686520657865637574696f6e207068617365206f662074686520626c6f636b2e44417574686f72697a65645570677261646500003108040004b82060536f6d6560206966206120636f6465207570677261646520686173206265656e20617574686f72697a65642e019d0101541830426c6f636b576569676874733508010207b0bde93603000b00204aa9d10113ffffffffffffffff222d0d1e00010bb8845c8f580113a3703d0ad7a370bd010b0098f73e5d0113ffffffffffffffbf010000222d0d1e00010bb80caff9cc0113a3703d0ad7a370fd010b00204aa9d10113ffffffffffffffff01070088526a74130000000000000040222d0d1e0000000004d020426c6f636b20262065787472696e7369637320776569676874733a20626173652076616c75657320616e64206c696d6974732e2c426c6f636b4c656e67746841083000003c00000050000000500004a820546865206d6178696d756d206c656e677468206f66206120626c6f636b2028696e206279746573292e38426c6f636b48617368436f756e74101000100000045501204d6178696d756d206e756d626572206f6620626c6f636b206e756d62657220746f20626c6f636b2068617368206d617070696e677320746f206b65657020286f6c64657374207072756e6564206669727374292e20446257656967687449084038ca38010000000098aaf904000000000409012054686520776569676874206f662072756e74696d65206461746162617365206f7065726174696f6e73207468652072756e74696d652063616e20696e766f6b652e1c56657273696f6e4d083d0420706f6c6b61646f743c7061726974792d706f6c6b61646f7400000000104a0f00000000004cdf6acb689907609b0400000037e397fc7c91f5e40200000040fe3ad401f8959a0600000017a6bc0d0062aeb30100000018ef58a3b67ba77001000000d2bc9897eed08f1503000000f78b278be53f454c02000000af2c0297a23e6d3d0a00000049eaaf1b548a0cb00300000091d5df18b0d2cf58020000002a5e924655399e6001000000ed99c5acb25eedf503000000cbca25e39f14238702000000687ad44ad37f03c201000000ab3c0572291feb8b01000000bc9d89904f5b923f0100000037c8bb1350a9a2a804000000f3ff14d5ab52705903000000fbc577b9d747efd60100000019000000010484204765742074686520636861696e27732063757272656e742076657273696f6e2e2853533538507265666978910108000014a8205468652064657369676e61746564205353353820707265666978206f66207468697320636861696e2e0039012054686973207265706c6163657320746865202273733538466f726d6174222070726f7065727479206465636c6172656420696e2074686520636861696e20737065632e20526561736f6e20697331012074686174207468652072756e74696d652073686f756c64206b6e6f772061626f7574207468652070726566697820696e206f7264657220746f206d616b6520757365206f662069742061737020616e206964656e746966696572206f662074686520636861696e2e015d080000245363686564756c657201245363686564756c65720c3c496e636f6d706c65746553696e6365000010040000184167656e6461010104051061080400044d01204974656d7320746f2062652065786563757465642c20696e64657865642062792074686520626c6f636b206e756d626572207468617420746865792073686f756c64206265206578656375746564206f6e2e184c6f6f6b7570000104050480040010f8204c6f6f6b75702066726f6d2061206e616d6520746f2074686520626c6f636b206e756d62657220616e6420696e646578206f6620746865207461736b2e00590120466f72207633202d3e207634207468652070726576696f75736c7920756e626f756e646564206964656e7469746965732061726520426c616b65322d3235362068617368656420746f20666f726d2074686520763430206964656e7469746965732e01ad01017c08344d6178696d756d57656967687424400b00806e87740113cccccccccccccccc04290120546865206d6178696d756d207765696768742074686174206d6179206265207363686564756c65642070657220626c6f636b20666f7220616e7920646973706174636861626c65732e504d61785363686564756c6564506572426c6f636b101032000000141d0120546865206d6178696d756d206e756d626572206f66207363686564756c65642063616c6c7320696e2074686520717565756520666f7220612073696e676c6520626c6f636b2e0018204e4f54453a5101202b20446570656e64656e742070616c6c657473272062656e63686d61726b73206d696768742072657175697265206120686967686572206c696d697420666f72207468652073657474696e672e205365742061c420686967686572206c696d697420756e646572206072756e74696d652d62656e63686d61726b736020666561747572652e017108010020507265696d6167650120507265696d6167650c24537461747573466f72000104063075080400049020546865207265717565737420737461747573206f66206120676976656e20686173682e4052657175657374537461747573466f7200010406307d080400049020546865207265717565737420737461747573206f66206120676976656e20686173682e2c507265696d616765466f72000104068d08910804000001b5010190000195080a001042616265011042616265442845706f6368496e64657801002c20000000000000000004542043757272656e742065706f636820696e6465782e2c417574686f726974696573010099080400046c2043757272656e742065706f636820617574686f7269746965732e2c47656e65736973536c6f740100cd0120000000000000000008f82054686520736c6f74206174207768696368207468652066697273742065706f63682061637475616c6c7920737461727465642e205468697320697320309020756e74696c2074686520666972737420626c6f636b206f662074686520636861696e2e2c43757272656e74536c6f740100cd0120000000000000000004542043757272656e7420736c6f74206e756d6265722e2852616e646f6d6e65737301000480000000000000000000000000000000000000000000000000000000000000000028b8205468652065706f63682072616e646f6d6e65737320666f7220746865202a63757272656e742a2065706f63682e002c20232053656375726974790005012054686973204d555354204e4f54206265207573656420666f722067616d626c696e672c2061732069742063616e20626520696e666c75656e6365642062792061f8206d616c6963696f75732076616c696461746f7220696e207468652073686f7274207465726d2e204974204d4159206265207573656420696e206d616e7915012063727970746f677261706869632070726f746f636f6c732c20686f77657665722c20736f206c6f6e67206173206f6e652072656d656d6265727320746861742074686973150120286c696b652065766572797468696e6720656c7365206f6e2d636861696e29206974206973207075626c69632e20466f72206578616d706c652c2069742063616e206265050120757365642077686572652061206e756d626572206973206e656564656420746861742063616e6e6f742068617665206265656e2063686f73656e20627920616e0d01206164766572736172792c20666f7220707572706f7365732073756368206173207075626c69632d636f696e207a65726f2d6b6e6f776c656467652070726f6f66732e6050656e64696e6745706f6368436f6e6669674368616e67650000d50104000461012050656e64696e672065706f636820636f6e66696775726174696f6e206368616e676520746861742077696c6c206265206170706c696564207768656e20746865206e6578742065706f636820697320656e61637465642e384e65787452616e646f6d6e657373010004800000000000000000000000000000000000000000000000000000000000000000045c204e6578742065706f63682072616e646f6d6e6573732e3c4e657874417574686f7269746965730100990804000460204e6578742065706f636820617574686f7269746965732e305365676d656e74496e6465780100101000000000247c2052616e646f6d6e65737320756e64657220636f6e737472756374696f6e2e00f8205765206d616b6520612074726164652d6f6666206265747765656e2073746f7261676520616363657373657320616e64206c697374206c656e6774682e01012057652073746f72652074686520756e6465722d636f6e737472756374696f6e2072616e646f6d6e65737320696e207365676d656e7473206f6620757020746f942060554e4445525f434f4e535452554354494f4e5f5345474d454e545f4c454e475448602e00ec204f6e63652061207365676d656e7420726561636865732074686973206c656e6774682c20776520626567696e20746865206e657874206f6e652e090120576520726573657420616c6c207365676d656e747320616e642072657475726e20746f206030602061742074686520626567696e6e696e67206f662065766572791c2065706f63682e44556e646572436f6e737472756374696f6e0101040510a50804000415012054574f582d4e4f54453a20605365676d656e74496e6465786020697320616e20696e6372656173696e6720696e74656765722c20736f2074686973206973206f6b61792e2c496e697469616c697a65640000ad0804000801012054656d706f726172792076616c75652028636c656172656420617420626c6f636b2066696e616c697a6174696f6e292077686963682069732060536f6d65601d01206966207065722d626c6f636b20696e697469616c697a6174696f6e2068617320616c7265616479206265656e2063616c6c656420666f722063757272656e7420626c6f636b2e4c417574686f7256726652616e646f6d6e65737301008404001015012054686973206669656c642073686f756c6420616c7761797320626520706f70756c6174656420647572696e6720626c6f636b2070726f63657373696e6720756e6c6573731901207365636f6e6461727920706c61696e20736c6f74732061726520656e61626c65642028776869636820646f6e277420636f6e7461696e206120565246206f7574707574292e0049012049742069732073657420696e20606f6e5f66696e616c697a65602c206265666f72652069742077696c6c20636f6e7461696e207468652076616c75652066726f6d20746865206c61737420626c6f636b2e2845706f63685374617274010080200000000000000000145d012054686520626c6f636b206e756d62657273207768656e20746865206c61737420616e642063757272656e742065706f6368206861766520737461727465642c20726573706563746976656c7920604e2d316020616e641420604e602e4901204e4f54453a20576520747261636b207468697320697320696e206f7264657220746f20616e6e6f746174652074686520626c6f636b206e756d626572207768656e206120676976656e20706f6f6c206f66590120656e74726f7079207761732066697865642028692e652e20697420776173206b6e6f776e20746f20636861696e206f6273657276657273292e2053696e63652065706f6368732061726520646566696e656420696e590120736c6f74732c207768696368206d617920626520736b69707065642c2074686520626c6f636b206e756d62657273206d6179206e6f74206c696e6520757020776974682074686520736c6f74206e756d626572732e204c6174656e657373010010100000000014d820486f77206c617465207468652063757272656e7420626c6f636b20697320636f6d706172656420746f2069747320706172656e742e001501205468697320656e74727920697320706f70756c617465642061732070617274206f6620626c6f636b20657865637574696f6e20616e6420697320636c65616e65642075701101206f6e20626c6f636b2066696e616c697a6174696f6e2e205175657279696e6720746869732073746f7261676520656e747279206f757473696465206f6620626c6f636bb020657865637574696f6e20636f6e746578742073686f756c6420616c77617973207969656c64207a65726f2e2c45706f6368436f6e6669670000c50804000861012054686520636f6e66696775726174696f6e20666f72207468652063757272656e742065706f63682e2053686f756c64206e6576657220626520604e6f6e656020617320697420697320696e697469616c697a656420696e242067656e657369732e3c4e65787445706f6368436f6e6669670000c5080400082d012054686520636f6e66696775726174696f6e20666f7220746865206e6578742065706f63682c20604e6f6e65602069662074686520636f6e6669672077696c6c206e6f74206368616e6765e82028796f752063616e2066616c6c6261636b20746f206045706f6368436f6e6669676020696e737465616420696e20746861742063617365292e34536b697070656445706f6368730100c90804002029012041206c697374206f6620746865206c6173742031303020736b69707065642065706f63687320616e642074686520636f72726573706f6e64696e672073657373696f6e20696e64657870207768656e207468652065706f63682077617320736b69707065642e0031012054686973206973206f6e6c79207573656420666f722076616c69646174696e672065717569766f636174696f6e2070726f6f66732e20416e2065717569766f636174696f6e2070726f6f663501206d75737420636f6e7461696e732061206b65792d6f776e6572736869702070726f6f6620666f72206120676976656e2073657373696f6e2c207468657265666f7265207765206e656564206139012077617920746f2074696520746f6765746865722073657373696f6e7320616e642065706f636820696e64696365732c20692e652e207765206e65656420746f2076616c69646174652074686174290120612076616c696461746f722077617320746865206f776e6572206f66206120676976656e206b6579206f6e206120676976656e2073657373696f6e2c20616e64207768617420746865b0206163746976652065706f636820696e6465782077617320647572696e6720746861742073657373696f6e2e01bd0100103445706f63684475726174696f6e2c2060090000000000000cec2054686520616d6f756e74206f662074696d652c20696e20736c6f74732c207468617420656163682065706f63682073686f756c64206c6173742e1901204e4f54453a2043757272656e746c79206974206973206e6f7420706f737369626c6520746f206368616e6765207468652065706f6368206475726174696f6e20616674657221012074686520636861696e2068617320737461727465642e20417474656d7074696e6720746f20646f20736f2077696c6c20627269636b20626c6f636b2070726f64756374696f6e2e444578706563746564426c6f636b54696d652c20701700000000000014050120546865206578706563746564206176657261676520626c6f636b2074696d6520617420776869636820424142452073686f756c64206265206372656174696e67110120626c6f636b732e2053696e636520424142452069732070726f626162696c6973746963206974206973206e6f74207472697669616c20746f20666967757265206f75740501207768617420746865206578706563746564206176657261676520626c6f636b2074696d652073686f756c64206265206261736564206f6e2074686520736c6f740901206475726174696f6e20616e642074686520736563757269747920706172616d657465722060636020287768657265206031202d20636020726570726573656e7473a0207468652070726f626162696c697479206f66206120736c6f74206265696e6720656d707479292e384d6178417574686f7269746965731010a08601000488204d6178206e756d626572206f6620617574686f72697469657320616c6c6f776564344d61784e6f6d696e61746f727310100002000004d420546865206d6178696d756d206e756d626572206f66206e6f6d696e61746f727320666f7220656163682076616c696461746f722e01d50802002454696d657374616d70012454696d657374616d70080c4e6f7701002c20000000000000000004a0205468652063757272656e742074696d6520666f72207468652063757272656e7420626c6f636b2e24446964557064617465010078040010d82057686574686572207468652074696d657374616d7020686173206265656e207570646174656420696e207468697320626c6f636b2e00550120546869732076616c7565206973207570646174656420746f206074727565602075706f6e207375636365737366756c207375626d697373696f6e206f6620612074696d657374616d702062792061206e6f64652e4501204974206973207468656e20636865636b65642061742074686520656e64206f66206561636820626c6f636b20657865637574696f6e20696e2074686520606f6e5f66696e616c697a656020686f6f6b2e01e1010004344d696e696d756d506572696f642c20b80b000000000000188c20546865206d696e696d756d20706572696f64206265747765656e20626c6f636b732e004d012042652061776172652074686174207468697320697320646966666572656e7420746f20746865202a65787065637465642a20706572696f6420746861742074686520626c6f636b2070726f64756374696f6e4901206170706172617475732070726f76696465732e20596f75722063686f73656e20636f6e73656e7375732073797374656d2077696c6c2067656e6572616c6c7920776f726b2077697468207468697320746f61012064657465726d696e6520612073656e7369626c6520626c6f636b2074696d652e20466f72206578616d706c652c20696e2074686520417572612070616c6c65742069742077696c6c20626520646f75626c6520746869737020706572696f64206f6e2064656661756c742073657474696e67732e0003001c496e6469636573011c496e646963657304204163636f756e74730001040210d9080400048820546865206c6f6f6b75702066726f6d20696e64657820746f206163636f756e742e01e5010194041c4465706f736974184000e8764817000000000000000000000004ac20546865206465706f736974206e656564656420666f7220726573657276696e6720616e20696e6465782e01dd0804002042616c616e636573012042616c616e6365731c34546f74616c49737375616e6365010018400000000000000000000000000000000004982054686520746f74616c20756e6974732069737375656420696e207468652073797374656d2e40496e61637469766549737375616e636501001840000000000000000000000000000000000409012054686520746f74616c20756e697473206f66206f75747374616e64696e672064656163746976617465642062616c616e636520696e207468652073797374656d2e1c4163636f756e74010104020014010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080600901205468652042616c616e6365732070616c6c6574206578616d706c65206f662073746f72696e67207468652062616c616e6365206f6620616e206163636f756e742e00282023204578616d706c650034206060606e6f636f6d70696c65b02020696d706c2070616c6c65745f62616c616e6365733a3a436f6e66696720666f722052756e74696d65207b19022020202074797065204163636f756e7453746f7265203d2053746f726167654d61705368696d3c53656c663a3a4163636f756e743c52756e74696d653e2c206672616d655f73797374656d3a3a50726f76696465723c52756e74696d653e2c204163636f756e7449642c2053656c663a3a4163636f756e74446174613c42616c616e63653e3e0c20207d102060606000150120596f752063616e20616c736f2073746f7265207468652062616c616e6365206f6620616e206163636f756e7420696e20746865206053797374656d602070616c6c65742e00282023204578616d706c650034206060606e6f636f6d70696c65b02020696d706c2070616c6c65745f62616c616e6365733a3a436f6e66696720666f722052756e74696d65207b7420202074797065204163636f756e7453746f7265203d2053797374656d0c20207d102060606000510120427574207468697320636f6d657320776974682074726164656f6666732c2073746f72696e67206163636f756e742062616c616e63657320696e207468652073797374656d2070616c6c65742073746f7265736d0120606672616d655f73797374656d60206461746120616c6f6e677369646520746865206163636f756e74206461746120636f6e747261727920746f2073746f72696e67206163636f756e742062616c616e63657320696e207468652901206042616c616e636573602070616c6c65742c20776869636820757365732061206053746f726167654d61706020746f2073746f72652062616c616e6365732064617461206f6e6c792e4101204e4f54453a2054686973206973206f6e6c79207573656420696e207468652063617365207468617420746869732070616c6c6574206973207573656420746f2073746f72652062616c616e6365732e144c6f636b730101040200e108040008b820416e79206c6971756964697479206c6f636b73206f6e20736f6d65206163636f756e742062616c616e6365732e2501204e4f54453a2053686f756c64206f6e6c79206265206163636573736564207768656e2073657474696e672c206368616e67696e6720616e642066726565696e672061206c6f636b2e2052657365727665730101040200f108040004a4204e616d6564207265736572766573206f6e20736f6d65206163636f756e742062616c616e6365732e14486f6c64730101040200fd080400046c20486f6c6473206f6e206163636f756e742062616c616e6365732e1c467265657a6573010104020015090400048820467265657a65206c6f636b73206f6e206163636f756e742062616c616e6365732e01f101019810484578697374656e7469616c4465706f736974184000e40b5402000000000000000000000020410120546865206d696e696d756d20616d6f756e7420726571756972656420746f206b65657020616e206163636f756e74206f70656e2e204d5553542042452047524541544552205448414e205a45524f2100590120496620796f75202a7265616c6c792a206e65656420697420746f206265207a65726f2c20796f752063616e20656e61626c652074686520666561747572652060696e7365637572655f7a65726f5f65646020666f72610120746869732070616c6c65742e20486f77657665722c20796f7520646f20736f20617420796f7572206f776e207269736b3a20746869732077696c6c206f70656e2075702061206d616a6f7220446f5320766563746f722e590120496e206361736520796f752068617665206d756c7469706c6520736f7572636573206f662070726f7669646572207265666572656e6365732c20796f75206d617920616c736f2067657420756e65787065637465648c206265686176696f757220696620796f7520736574207468697320746f207a65726f2e00f020426f74746f6d206c696e653a20446f20796f757273656c662061206661766f757220616e64206d616b65206974206174206c65617374206f6e6521204d61784c6f636b7310103200000008f420546865206d6178696d756d206e756d626572206f66206c6f636b7320746861742073686f756c64206578697374206f6e20616e206163636f756e742edc204e6f74207374726963746c7920656e666f726365642c20627574207573656420666f722077656967687420657374696d6174696f6e2e2c4d61785265736572766573101032000000040d0120546865206d6178696d756d206e756d626572206f66206e616d656420726573657276657320746861742063616e206578697374206f6e20616e206163636f756e742e284d6178467265657a657310100800000004610120546865206d6178696d756d206e756d626572206f6620696e646976696475616c20667265657a65206c6f636b7320746861742063616e206578697374206f6e20616e206163636f756e7420617420616e792074696d652e0129090500485472616e73616374696f6e5061796d656e7401485472616e73616374696f6e5061796d656e7408444e6578744665654d756c7469706c69657201004d0740000064a7b3b6e00d0000000000000000003853746f7261676556657273696f6e01002d090400000001a004604f7065726174696f6e616c4665654d756c7469706c696572080405545901204120666565206d756c7469706c69657220666f7220604f7065726174696f6e616c602065787472696e7369637320746f20636f6d7075746520227669727475616c207469702220746f20626f6f73742074686569722c20607072696f726974796000510120546869732076616c7565206973206d756c7469706c69656420627920746865206066696e616c5f6665656020746f206f627461696e206120227669727475616c20746970222074686174206973206c61746572f420616464656420746f20612074697020636f6d706f6e656e7420696e20726567756c617220607072696f72697479602063616c63756c6174696f6e732e4d01204974206d65616e732074686174206120604e6f726d616c60207472616e73616374696f6e2063616e2066726f6e742d72756e20612073696d696c61726c792d73697a656420604f7065726174696f6e616c6041012065787472696e736963202877697468206e6f20746970292c20627920696e636c7564696e672061207469702076616c75652067726561746572207468616e20746865207669727475616c207469702e003c20606060727573742c69676e6f726540202f2f20466f7220604e6f726d616c608c206c6574207072696f72697479203d207072696f726974795f63616c6328746970293b0054202f2f20466f7220604f7065726174696f6e616c601101206c6574207669727475616c5f746970203d2028696e636c7573696f6e5f666565202b2074697029202a204f7065726174696f6e616c4665654d756c7469706c6965723bc4206c6574207072696f72697479203d207072696f726974795f63616c6328746970202b207669727475616c5f746970293b1020606060005101204e6f746520746861742073696e636520776520757365206066696e616c5f6665656020746865206d756c7469706c696572206170706c69657320616c736f20746f2074686520726567756c61722060746970605d012073656e74207769746820746865207472616e73616374696f6e2e20536f2c206e6f74206f6e6c7920646f657320746865207472616e73616374696f6e206765742061207072696f726974792062756d702062617365646101206f6e207468652060696e636c7573696f6e5f666565602c2062757420776520616c736f20616d706c6966792074686520696d70616374206f662074697073206170706c69656420746f20604f7065726174696f6e616c6038207472616e73616374696f6e732e00200028417574686f72736869700128417574686f72736869700418417574686f720000000400046420417574686f72206f662063757272656e7420626c6f636b2e0000000006001c5374616b696e67011c5374616b696e67a03856616c696461746f72436f756e740100101000000000049c2054686520696465616c206e756d626572206f66206163746976652076616c696461746f72732e544d696e696d756d56616c696461746f72436f756e740100101000000000044101204d696e696d756d206e756d626572206f66207374616b696e67207061727469636970616e7473206265666f726520656d657267656e637920636f6e646974696f6e732061726520696d706f7365642e34496e76756c6e657261626c65730100f50104000c590120416e792076616c696461746f72732074686174206d6179206e6576657220626520736c6173686564206f7220666f726369626c79206b69636b65642e20497427732061205665632073696e636520746865792772654d01206561737920746f20696e697469616c697a6520616e642074686520706572666f726d616e636520686974206973206d696e696d616c2028776520657870656374206e6f206d6f7265207468616e20666f7572ac20696e76756c6e657261626c65732920616e64207265737472696374656420746f20746573746e6574732e18426f6e64656400010405000004000c0101204d61702066726f6d20616c6c206c6f636b65642022737461736822206163636f756e747320746f2074686520636f6e74726f6c6c6572206163636f756e742e00d02054574f582d4e4f54453a20534146452073696e636520604163636f756e7449646020697320612073656375726520686173682e404d696e4e6f6d696e61746f72426f6e64010018400000000000000000000000000000000004210120546865206d696e696d756d2061637469766520626f6e6420746f206265636f6d6520616e64206d61696e7461696e2074686520726f6c65206f662061206e6f6d696e61746f722e404d696e56616c696461746f72426f6e64010018400000000000000000000000000000000004210120546865206d696e696d756d2061637469766520626f6e6420746f206265636f6d6520616e64206d61696e7461696e2074686520726f6c65206f6620612076616c696461746f722e484d696e696d756d4163746976655374616b65010018400000000000000000000000000000000004110120546865206d696e696d756d20616374697665206e6f6d696e61746f72207374616b65206f6620746865206c617374207375636365737366756c20656c656374696f6e2e344d696e436f6d6d697373696f6e0100ac10000000000ce820546865206d696e696d756d20616d6f756e74206f6620636f6d6d697373696f6e20746861742076616c696461746f72732063616e207365742e00802049662073657420746f206030602c206e6f206c696d6974206578697374732e184c6564676572000104020031090400104501204d61702066726f6d20616c6c2028756e6c6f636b6564292022636f6e74726f6c6c657222206163636f756e747320746f2074686520696e666f20726567617264696e6720746865207374616b696e672e007501204e6f74653a20416c6c2074686520726561647320616e64206d75746174696f6e7320746f20746869732073746f72616765202a4d5553542a20626520646f6e65207468726f75676820746865206d6574686f6473206578706f736564e8206279205b605374616b696e674c6564676572605d20746f20656e73757265206461746120616e64206c6f636b20636f6e73697374656e63792e1450617965650001040500a804000ce42057686572652074686520726577617264207061796d656e742073686f756c64206265206d6164652e204b657965642062792073746173682e00d02054574f582d4e4f54453a20534146452073696e636520604163636f756e7449646020697320612073656375726520686173682e2856616c696461746f72730101040500b00800000c450120546865206d61702066726f6d202877616e6e616265292076616c696461746f72207374617368206b657920746f2074686520707265666572656e636573206f6620746861742076616c696461746f722e00d02054574f582d4e4f54453a20534146452073696e636520604163636f756e7449646020697320612073656375726520686173682e50436f756e746572466f7256616c696461746f7273010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d6170484d617856616c696461746f7273436f756e7400001004000c310120546865206d6178696d756d2076616c696461746f7220636f756e74206265666f72652077652073746f7020616c6c6f77696e67206e65772076616c696461746f727320746f206a6f696e2e00d0205768656e20746869732076616c7565206973206e6f74207365742c206e6f206c696d6974732061726520656e666f726365642e284e6f6d696e61746f72730001040500390904004c750120546865206d61702066726f6d206e6f6d696e61746f72207374617368206b657920746f207468656972206e6f6d696e6174696f6e20707265666572656e6365732c206e616d656c79207468652076616c696461746f72732074686174582074686579207769736820746f20737570706f72742e003901204e6f7465207468617420746865206b657973206f6620746869732073746f72616765206d6170206d69676874206265636f6d65206e6f6e2d6465636f6461626c6520696e2063617365207468652d01206163636f756e742773205b604e6f6d696e6174696f6e7351756f74613a3a4d61784e6f6d696e6174696f6e73605d20636f6e66696775726174696f6e206973206465637265617365642e9020496e2074686973207261726520636173652c207468657365206e6f6d696e61746f7273650120617265207374696c6c206578697374656e7420696e2073746f726167652c207468656972206b657920697320636f727265637420616e64207265747269657661626c652028692e652e2060636f6e7461696e735f6b657960710120696e6469636174657320746861742074686579206578697374292c206275742074686569722076616c75652063616e6e6f74206265206465636f6465642e205468657265666f72652c20746865206e6f6e2d6465636f6461626c656d01206e6f6d696e61746f72732077696c6c206566666563746976656c79206e6f742d65786973742c20756e74696c20746865792072652d7375626d697420746865697220707265666572656e6365732073756368207468617420697401012069732077697468696e2074686520626f756e6473206f6620746865206e65776c79207365742060436f6e6669673a3a4d61784e6f6d696e6174696f6e73602e006101205468697320696d706c696573207468617420603a3a697465725f6b65797328292e636f756e7428296020616e6420603a3a6974657228292e636f756e74282960206d696768742072657475726e20646966666572656e746d012076616c75657320666f722074686973206d61702e204d6f72656f7665722c20746865206d61696e20603a3a636f756e7428296020697320616c69676e656420776974682074686520666f726d65722c206e616d656c79207468656c206e756d626572206f66206b65797320746861742065786973742e006d01204c6173746c792c20696620616e79206f6620746865206e6f6d696e61746f7273206265636f6d65206e6f6e2d6465636f6461626c652c20746865792063616e206265206368696c6c656420696d6d6564696174656c7920766961b8205b6043616c6c3a3a6368696c6c5f6f74686572605d20646973706174636861626c6520627920616e796f6e652e00d02054574f582d4e4f54453a20534146452073696e636520604163636f756e7449646020697320612073656375726520686173682e50436f756e746572466f724e6f6d696e61746f7273010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d6170484d61784e6f6d696e61746f7273436f756e7400001004000c310120546865206d6178696d756d206e6f6d696e61746f7220636f756e74206265666f72652077652073746f7020616c6c6f77696e67206e65772076616c696461746f727320746f206a6f696e2e00d0205768656e20746869732076616c7565206973206e6f74207365742c206e6f206c696d6974732061726520656e666f726365642e2843757272656e744572610000100400105c205468652063757272656e742065726120696e6465782e006501205468697320697320746865206c617465737420706c616e6e6564206572612c20646570656e64696e67206f6e20686f77207468652053657373696f6e2070616c6c657420717565756573207468652076616c696461746f7280207365742c206974206d6967687420626520616374697665206f72206e6f742e2441637469766545726100004109040010d820546865206163746976652065726120696e666f726d6174696f6e2c20697420686f6c647320696e64657820616e642073746172742e0059012054686520616374697665206572612069732074686520657261206265696e672063757272656e746c792072657761726465642e2056616c696461746f7220736574206f66207468697320657261206d757374206265ac20657175616c20746f205b6053657373696f6e496e746572666163653a3a76616c696461746f7273605d2e5445726173537461727453657373696f6e496e6465780001040510100400105501205468652073657373696f6e20696e646578206174207768696368207468652065726120737461727420666f7220746865206c617374205b60436f6e6669673a3a486973746f72794465707468605d20657261732e006101204e6f74653a205468697320747261636b7320746865207374617274696e672073657373696f6e2028692e652e2073657373696f6e20696e646578207768656e20657261207374617274206265696e672061637469766529f020666f7220746865206572617320696e20605b43757272656e74457261202d20484953544f52595f44455054482c2043757272656e744572615d602e2c457261735374616b65727301010805054909f00c0000002078204578706f73757265206f662076616c696461746f72206174206572612e0061012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00cc2049732069742072656d6f766564206166746572205b60436f6e6669673a3a486973746f72794465707468605d20657261732e4101204966207374616b657273206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e20656d707479206578706f737572652069732072657475726e65642e002901204e6f74653a20446570726563617465642073696e6365207631342e205573652060457261496e666f6020696e737465616420746f20776f726b2077697468206578706f73757265732e4c457261735374616b6572734f76657276696577000108050549094d09040030b82053756d6d617279206f662076616c696461746f72206578706f73757265206174206120676976656e206572612e007101205468697320636f6e7461696e732074686520746f74616c207374616b6520696e20737570706f7274206f66207468652076616c696461746f7220616e64207468656972206f776e207374616b652e20496e206164646974696f6e2c75012069742063616e20616c736f206265207573656420746f2067657420746865206e756d626572206f66206e6f6d696e61746f7273206261636b696e6720746869732076616c696461746f7220616e6420746865206e756d626572206f666901206578706f73757265207061676573207468657920617265206469766964656420696e746f2e20546865207061676520636f756e742069732075736566756c20746f2064657465726d696e6520746865206e756d626572206f66ac207061676573206f6620726577617264732074686174206e6565647320746f20626520636c61696d65642e0061012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742eac2053686f756c64206f6e6c79206265206163636573736564207468726f7567682060457261496e666f602e00cc2049732069742072656d6f766564206166746572205b60436f6e6669673a3a486973746f72794465707468605d20657261732e4101204966207374616b657273206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e20656d707479206f766572766965772069732072657475726e65642e48457261735374616b657273436c697070656401010805054909f00c000000409820436c6970706564204578706f73757265206f662076616c696461746f72206174206572612e006501204e6f74653a205468697320697320646570726563617465642c2073686f756c64206265207573656420617320726561642d6f6e6c7920616e642077696c6c2062652072656d6f76656420696e20746865206675747572652e3101204e657720604578706f737572656073206172652073746f72656420696e2061207061676564206d616e6e657220696e2060457261735374616b65727350616765646020696e73746561642e00590120546869732069732073696d696c617220746f205b60457261735374616b657273605d20627574206e756d626572206f66206e6f6d696e61746f7273206578706f736564206973207265647563656420746f20746865a82060543a3a4d61784578706f737572655061676553697a65602062696767657374207374616b6572732e1d0120284e6f74653a20746865206669656c642060746f74616c6020616e6420606f776e60206f6620746865206578706f737572652072656d61696e7320756e6368616e676564292ef42054686973206973207573656420746f206c696d69742074686520692f6f20636f737420666f7220746865206e6f6d696e61746f72207061796f75742e005d012054686973206973206b657965642066697374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00cc2049742069732072656d6f766564206166746572205b60436f6e6669673a3a486973746f72794465707468605d20657261732e4101204966207374616b657273206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e20656d707479206578706f737572652069732072657475726e65642e002901204e6f74653a20446570726563617465642073696e6365207631342e205573652060457261496e666f6020696e737465616420746f20776f726b2077697468206578706f73757265732e40457261735374616b657273506167656400010c05050551095509040018c020506167696e61746564206578706f73757265206f6620612076616c696461746f7220617420676976656e206572612e0071012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e2c207468656e207374617368206163636f756e7420616e642066696e616c6c79d42074686520706167652e2053686f756c64206f6e6c79206265206163636573736564207468726f7567682060457261496e666f602e00d4205468697320697320636c6561726564206166746572205b60436f6e6669673a3a486973746f72794465707468605d20657261732e38436c61696d656452657761726473010108050549090902040018dc20486973746f7279206f6620636c61696d656420706167656420726577617264732062792065726120616e642076616c696461746f722e0069012054686973206973206b657965642062792065726120616e642076616c696461746f72207374617368207768696368206d61707320746f2074686520736574206f66207061676520696e6465786573207768696368206861766538206265656e20636c61696d65642e00cc2049742069732072656d6f766564206166746572205b60436f6e6669673a3a486973746f72794465707468605d20657261732e484572617356616c696461746f72507265667301010805054909b00800001411012053696d696c617220746f2060457261735374616b657273602c207468697320686f6c64732074686520707265666572656e636573206f662076616c696461746f72732e0061012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00cc2049732069742072656d6f766564206166746572205b60436f6e6669673a3a486973746f72794465707468605d20657261732e4c4572617356616c696461746f7252657761726400010405101804000c2d012054686520746f74616c2076616c696461746f7220657261207061796f757420666f7220746865206c617374205b60436f6e6669673a3a486973746f72794465707468605d20657261732e0021012045726173207468617420686176656e27742066696e697368656420796574206f7220686173206265656e2072656d6f76656420646f65736e27742068617665207265776172642e4045726173526577617264506f696e74730101040510590914000000000008d0205265776172647320666f7220746865206c617374205b60436f6e6669673a3a486973746f72794465707468605d20657261732e250120496620726577617264206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e2030207265776172642069732072657475726e65642e3845726173546f74616c5374616b6501010405101840000000000000000000000000000000000811012054686520746f74616c20616d6f756e74207374616b656420666f7220746865206c617374205b60436f6e6669673a3a486973746f72794465707468605d20657261732e1d0120496620746f74616c206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e2030207374616b652069732072657475726e65642e20466f7263654572610100b804000454204d6f6465206f662065726120666f7263696e672e4c536c6173685265776172644672616374696f6e0100ac10000000000cf8205468652070657263656e74616765206f662074686520736c617368207468617420697320646973747269627574656420746f207265706f72746572732e00e4205468652072657374206f662074686520736c61736865642076616c75652069732068616e646c6564206279207468652060536c617368602e4c43616e63656c6564536c6173685061796f757401001840000000000000000000000000000000000815012054686520616d6f756e74206f662063757272656e637920676976656e20746f207265706f7274657273206f66206120736c617368206576656e7420776869636820776173ec2063616e63656c65642062792065787472616f7264696e6172792063697263756d7374616e6365732028652e672e20676f7665726e616e6365292e40556e6170706c696564536c617368657301010405106909040004c420416c6c20756e6170706c69656420736c61736865732074686174206172652071756575656420666f72206c617465722e28426f6e646564457261730100250804001025012041206d617070696e672066726f6d207374696c6c2d626f6e646564206572617320746f207468652066697273742073657373696f6e20696e646578206f662074686174206572612e00c8204d75737420636f6e7461696e7320696e666f726d6174696f6e20666f72206572617320666f72207468652072616e67653abc20605b6163746976655f657261202d20626f756e64696e675f6475726174696f6e3b206163746976655f6572615d604c56616c696461746f72536c617368496e457261000108050549097109040008450120416c6c20736c617368696e67206576656e7473206f6e2076616c696461746f72732c206d61707065642062792065726120746f20746865206869676865737420736c6173682070726f706f7274696f6e7020616e6420736c6173682076616c7565206f6620746865206572612e4c4e6f6d696e61746f72536c617368496e4572610001080505490918040004610120416c6c20736c617368696e67206576656e7473206f6e206e6f6d696e61746f72732c206d61707065642062792065726120746f20746865206869676865737420736c6173682076616c7565206f6620746865206572612e34536c617368696e675370616e73000104050075090400048c20536c617368696e67207370616e7320666f72207374617368206163636f756e74732e245370616e536c6173680101040565097909800000000000000000000000000000000000000000000000000000000000000000083d01205265636f72647320696e666f726d6174696f6e2061626f757420746865206d6178696d756d20736c617368206f6620612073746173682077697468696e206120736c617368696e67207370616e2cb82061732077656c6c20617320686f77206d7563682072657761726420686173206265656e2070616964206f75742e5443757272656e74506c616e6e656453657373696f6e01001010000000000ce820546865206c61737420706c616e6e65642073657373696f6e207363686564756c6564206279207468652073657373696f6e2070616c6c65742e0071012054686973206973206261736963616c6c7920696e2073796e632077697468207468652063616c6c20746f205b6070616c6c65745f73657373696f6e3a3a53657373696f6e4d616e616765723a3a6e65775f73657373696f6e605d2e4c4f6666656e64696e6756616c696461746f727301007d09040024690120496e6469636573206f662076616c696461746f727320746861742068617665206f6666656e64656420696e20746865206163746976652065726120616e6420776865746865722074686579206172652063757272656e746c79282064697361626c65642e00690120546869732076616c75652073686f756c642062652061207375706572736574206f662064697361626c65642076616c696461746f72732073696e6365206e6f7420616c6c206f6666656e636573206c65616420746f2074686571012076616c696461746f72206265696e672064697361626c65642028696620746865726520776173206e6f20736c617368292e2054686973206973206e656564656420746f20747261636b207468652070657263656e74616765206f6649012076616c696461746f727320746861742068617665206f6666656e64656420696e207468652063757272656e74206572612c20656e737572696e672061206e65772065726120697320666f72636564206966750120604f6666656e64696e6756616c696461746f72735468726573686f6c646020697320726561636865642e205468652076656320697320616c77617973206b65707420736f7274656420736f20746861742077652063616e2066696e6471012077686574686572206120676976656e2076616c696461746f72206861732070726576696f75736c79206f6666656e646564207573696e672062696e617279207365617263682e204974206765747320636c6561726564207768656e38207468652065726120656e64732e384368696c6c5468726573686f6c640000050204000c510120546865207468726573686f6c6420666f72207768656e2075736572732063616e2073746172742063616c6c696e6720606368696c6c5f6f746865726020666f72206f746865722076616c696461746f7273202f5901206e6f6d696e61746f72732e20546865207468726573686f6c6420697320636f6d706172656420746f207468652061637475616c206e756d626572206f662076616c696461746f7273202f206e6f6d696e61746f72732901202860436f756e74466f722a602920696e207468652073797374656d20636f6d706172656420746f2074686520636f6e66696775726564206d61782028604d61782a436f756e7460292e01fd0101a41830486973746f72794465707468101054000000508c204e756d626572206f66206572617320746f206b65657020696e20686973746f72792e00e820466f6c6c6f77696e6720696e666f726d6174696f6e206973206b65707420666f72206572617320696e20605b63757272656e745f657261202d090120486973746f727944657074682c2063757272656e745f6572615d603a2060457261735374616b657273602c2060457261735374616b657273436c6970706564602c050120604572617356616c696461746f725072656673602c20604572617356616c696461746f72526577617264602c206045726173526577617264506f696e7473602c4501206045726173546f74616c5374616b65602c206045726173537461727453657373696f6e496e646578602c2060436c61696d656452657761726473602c2060457261735374616b6572735061676564602c5c2060457261735374616b6572734f76657276696577602e00e4204d757374206265206d6f7265207468616e20746865206e756d626572206f6620657261732064656c617965642062792073657373696f6e2ef820492e652e2061637469766520657261206d75737420616c7761797320626520696e20686973746f72792e20492e652e20606163746976655f657261203ec42063757272656e745f657261202d20686973746f72795f646570746860206d7573742062652067756172616e746565642e001101204966206d6967726174696e6720616e206578697374696e672070616c6c65742066726f6d2073746f726167652076616c756520746f20636f6e6669672076616c75652cec20746869732073686f756c642062652073657420746f2073616d652076616c7565206f72206772656174657220617320696e2073746f726167652e001501204e6f74653a2060486973746f727944657074686020697320757365642061732074686520757070657220626f756e6420666f72207468652060426f756e646564566563602d01206974656d20605374616b696e674c65646765722e6c65676163795f636c61696d65645f72657761726473602e2053657474696e6720746869732076616c7565206c6f776572207468616ed820746865206578697374696e672076616c75652063616e206c65616420746f20696e636f6e73697374656e6369657320696e20746865150120605374616b696e674c65646765726020616e642077696c6c206e65656420746f2062652068616e646c65642070726f7065726c7920696e2061206d6967726174696f6e2ef020546865207465737420607265647563696e675f686973746f72795f64657074685f616272757074602073686f77732074686973206566666563742e3853657373696f6e735065724572611010060000000470204e756d626572206f662073657373696f6e7320706572206572612e3c426f6e64696e674475726174696f6e10101c00000004e4204e756d626572206f6620657261732074686174207374616b65642066756e6473206d7573742072656d61696e20626f6e64656420666f722e48536c61736844656665724475726174696f6e10101b000000100101204e756d626572206f662065726173207468617420736c6173686573206172652064656665727265642062792c20616674657220636f6d7075746174696f6e2e000d0120546869732073686f756c64206265206c657373207468616e2074686520626f6e64696e67206475726174696f6e2e2053657420746f203020696620736c617368657315012073686f756c64206265206170706c69656420696d6d6564696174656c792c20776974686f7574206f70706f7274756e69747920666f7220696e74657276656e74696f6e2e4c4d61784578706f737572655061676553697a651010000200002cb020546865206d6178696d756d2073697a65206f6620656163682060543a3a4578706f7375726550616765602e00290120416e20604578706f737572655061676560206973207765616b6c7920626f756e64656420746f2061206d6178696d756d206f6620604d61784578706f737572655061676553697a656030206e6f6d696e61746f72732e00210120466f72206f6c646572206e6f6e2d7061676564206578706f737572652c206120726577617264207061796f757420776173207265737472696374656420746f2074686520746f70210120604d61784578706f737572655061676553697a6560206e6f6d696e61746f72732e205468697320697320746f206c696d69742074686520692f6f20636f737420666f722074686548206e6f6d696e61746f72207061796f75742e005901204e6f74653a20604d61784578706f737572655061676553697a6560206973207573656420746f20626f756e642060436c61696d6564526577617264736020616e6420697320756e7361666520746f207265647563659020776974686f75742068616e646c696e6720697420696e2061206d6967726174696f6e2e484d6178556e6c6f636b696e674368756e6b7310102000000028050120546865206d6178696d756d206e756d626572206f662060756e6c6f636b696e6760206368756e6b732061205b605374616b696e674c6564676572605d2063616e090120686176652e204566666563746976656c792064657465726d696e657320686f77206d616e7920756e6971756520657261732061207374616b6572206d61792062653820756e626f6e64696e6720696e2e00f8204e6f74653a20604d6178556e6c6f636b696e674368756e6b736020697320757365642061732074686520757070657220626f756e6420666f722074686501012060426f756e64656456656360206974656d20605374616b696e674c65646765722e756e6c6f636b696e67602e2053657474696e6720746869732076616c75650501206c6f776572207468616e20746865206578697374696e672076616c75652063616e206c65616420746f20696e636f6e73697374656e6369657320696e20746865090120605374616b696e674c65646765726020616e642077696c6c206e65656420746f2062652068616e646c65642070726f7065726c7920696e20612072756e74696d650501206d6967726174696f6e2e20546865207465737420607265647563696e675f6d61785f756e6c6f636b696e675f6368756e6b735f616272757074602073686f7773342074686973206566666563742e0185090700204f6666656e63657301204f6666656e636573081c5265706f72747300010405308909040004490120546865207072696d61727920737472756374757265207468617420686f6c647320616c6c206f6666656e6365207265636f726473206b65796564206279207265706f7274206964656e746966696572732e58436f6e63757272656e745265706f727473496e64657801010805058d09b9010400042901204120766563746f72206f66207265706f727473206f66207468652073616d65206b696e6420746861742068617070656e6564206174207468652073616d652074696d6520736c6f742e0001bc0000080028486973746f726963616c0128486973746f726963616c0848486973746f726963616c53657373696f6e7300010405108d080400045d01204d617070696e672066726f6d20686973746f726963616c2073657373696f6e20696e646963657320746f2073657373696f6e2d6461746120726f6f74206861736820616e642076616c696461746f7220636f756e742e2c53746f72656452616e6765000080040004e4205468652072616e6765206f6620686973746f726963616c2073657373696f6e732077652073746f72652e205b66697273742c206c617374290000000021001c53657373696f6e011c53657373696f6e1c2856616c696461746f72730100f5010400047c205468652063757272656e7420736574206f662076616c696461746f72732e3043757272656e74496e646578010010100000000004782043757272656e7420696e646578206f66207468652073657373696f6e2e345175657565644368616e676564010078040008390120547275652069662074686520756e6465726c79696e672065636f6e6f6d6963206964656e746974696573206f7220776569676874696e6720626568696e64207468652076616c696461746f7273a420686173206368616e67656420696e20746865207175657565642076616c696461746f72207365742e285175657565644b657973010091090400083d012054686520717565756564206b65797320666f7220746865206e6578742073657373696f6e2e205768656e20746865206e6578742073657373696f6e20626567696e732c207468657365206b657973e02077696c6c206265207573656420746f2064657465726d696e65207468652076616c696461746f7227732073657373696f6e206b6579732e4844697361626c656456616c696461746f7273010009020400148020496e6469636573206f662064697361626c65642076616c696461746f72732e003d01205468652076656320697320616c77617973206b65707420736f7274656420736f20746861742077652063616e2066696e642077686574686572206120676976656e2076616c696461746f722069733d012064697361626c6564207573696e672062696e617279207365617263682e204974206765747320636c6561726564207768656e20606f6e5f73657373696f6e5f656e64696e67602072657475726e73642061206e657720736574206f66206964656e7469746965732e204e6578744b65797300010405003d020400049c20546865206e6578742073657373696f6e206b65797320666f7220612076616c696461746f722e204b65794f776e657200010405990900040004090120546865206f776e6572206f662061206b65792e20546865206b65792069732074686520604b657954797065496460202b2074686520656e636f646564206b65792e01390201c40001a10909001c4772616e647061011c4772616e6470611c1453746174650100a50904000490205374617465206f66207468652063757272656e7420617574686f72697479207365742e3450656e64696e674368616e67650000a909040004c42050656e64696e67206368616e67653a20287369676e616c65642061742c207363686564756c6564206368616e6765292e284e657874466f72636564000010040004bc206e65787420626c6f636b206e756d6265722077686572652077652063616e20666f7263652061206368616e67652e1c5374616c6c65640000800400049020607472756560206966207765206172652063757272656e746c79207374616c6c65642e3043757272656e74536574496401002c200000000000000000085d0120546865206e756d626572206f66206368616e6765732028626f746820696e207465726d73206f66206b65797320616e6420756e6465726c79696e672065636f6e6f6d696320726573706f6e736962696c697469657329c420696e20746865202273657422206f66204772616e6470612076616c696461746f72732066726f6d2067656e657369732e30536574496453657373696f6e000104052c1004002859012041206d617070696e672066726f6d206772616e6470612073657420494420746f2074686520696e646578206f6620746865202a6d6f737420726563656e742a2073657373696f6e20666f722077686963682069747368206d656d62657273207765726520726573706f6e7369626c652e0045012054686973206973206f6e6c79207573656420666f722076616c69646174696e672065717569766f636174696f6e2070726f6f66732e20416e2065717569766f636174696f6e2070726f6f66206d7573744d0120636f6e7461696e732061206b65792d6f776e6572736869702070726f6f6620666f72206120676976656e2073657373696f6e2c207468657265666f7265207765206e65656420612077617920746f20746965450120746f6765746865722073657373696f6e7320616e64204752414e44504120736574206964732c20692e652e207765206e65656420746f2076616c6964617465207468617420612076616c696461746f7241012077617320746865206f776e6572206f66206120676976656e206b6579206f6e206120676976656e2073657373696f6e2c20616e642077686174207468652061637469766520736574204944207761735420647572696e6720746861742073657373696f6e2e00b82054574f582d4e4f54453a2060536574496460206973206e6f7420756e646572207573657220636f6e74726f6c2e2c417574686f7269746965730100ad0904000484205468652063757272656e74206c697374206f6620617574686f7269746965732e01590201c80c384d6178417574686f7269746965731010a0860100045c204d617820417574686f72697469657320696e20757365344d61784e6f6d696e61746f727310100002000004d420546865206d6178696d756d206e756d626572206f66206e6f6d696e61746f727320666f7220656163682076616c696461746f722e584d6178536574496453657373696f6e456e74726965732c20a80000000000000018390120546865206d6178696d756d206e756d626572206f6620656e747269657320746f206b65657020696e207468652073657420696420746f2073657373696f6e20696e646578206d617070696e672e0031012053696e6365207468652060536574496453657373696f6e60206d6170206973206f6e6c79207573656420666f722076616c69646174696e672065717569766f636174696f6e73207468697329012076616c75652073686f756c642072656c61746520746f2074686520626f6e64696e67206475726174696f6e206f66207768617465766572207374616b696e672073797374656d2069733501206265696e6720757365642028696620616e79292e2049662065717569766f636174696f6e2068616e646c696e67206973206e6f7420656e61626c6564207468656e20746869732076616c7565342063616e206265207a65726f2e01b1090b0048417574686f72697479446973636f766572790148417574686f72697479446973636f7665727908104b6579730100b5090400048c204b657973206f66207468652063757272656e7420617574686f72697479207365742e204e6578744b6579730100b50904000480204b657973206f6620746865206e65787420617574686f72697479207365742e000000000d0020547265617375727901205472656173757279183450726f706f73616c436f756e74010010100000000004a4204e756d626572206f662070726f706f73616c7320746861742068617665206265656e206d6164652e2450726f706f73616c730001040510bd090400047c2050726f706f73616c7320746861742068617665206265656e206d6164652e2c4465616374697661746564010018400000000000000000000000000000000004f02054686520616d6f756e7420776869636820686173206265656e207265706f7274656420617320696e61637469766520746f2043757272656e63792e24417070726f76616c730100c109040004f82050726f706f73616c20696e646963657320746861742068617665206265656e20617070726f76656420627574206e6f742079657420617761726465642e285370656e64436f756e74010010100000000004a42054686520636f756e74206f66207370656e647320746861742068617665206265656e206d6164652e185370656e64730001040510c509040004d0205370656e647320746861742068617665206265656e20617070726f76656420616e64206265696e672070726f6365737365642e018902010101203050726f706f73616c426f6e64cd091050c30000085501204672616374696f6e206f6620612070726f706f73616c27732076616c756520746861742073686f756c6420626520626f6e64656420696e206f7264657220746f20706c616365207468652070726f706f73616c2e110120416e2061636365707465642070726f706f73616c2067657473207468657365206261636b2e20412072656a65637465642070726f706f73616c20646f6573206e6f742e4c50726f706f73616c426f6e644d696e696d756d18400010a5d4e80000000000000000000000044901204d696e696d756d20616d6f756e74206f662066756e647320746861742073686f756c6420626520706c6163656420696e2061206465706f73697420666f72206d616b696e6720612070726f706f73616c2e4c50726f706f73616c426f6e644d6178696d756d25024401005039278c0400000000000000000000044901204d6178696d756d20616d6f756e74206f662066756e647320746861742073686f756c6420626520706c6163656420696e2061206465706f73697420666f72206d616b696e6720612070726f706f73616c2e2c5370656e64506572696f64101000460500048820506572696f64206265747765656e2073756363657373697665207370656e64732e104275726ecd0910102700000411012050657263656e74616765206f662073706172652066756e64732028696620616e7929207468617420617265206275726e7420706572207370656e6420706572696f642e2050616c6c65744964d1092070792f74727372790419012054686520747265617375727927732070616c6c65742069642c207573656420666f72206465726976696e672069747320736f7665726569676e206163636f756e742049442e304d6178417070726f76616c731010640000000c150120546865206d6178696d756d206e756d626572206f6620617070726f76616c7320746861742063616e207761697420696e20746865207370656e64696e672071756575652e004d01204e4f54453a205468697320706172616d6574657220697320616c736f20757365642077697468696e2074686520426f756e746965732050616c6c657420657874656e73696f6e20696620656e61626c65642e305061796f7574506572696f641010809706000419012054686520706572696f6420647572696e6720776869636820616e20617070726f766564207472656173757279207370656e642068617320746f20626520636c61696d65642e01d509130040436f6e76696374696f6e566f74696e670140436f6e76696374696f6e566f74696e670824566f74696e67466f720101080505d909dd09d800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008750120416c6c20766f74696e6720666f72206120706172746963756c617220766f74657220696e206120706172746963756c617220766f74696e6720636c6173732e2057652073746f7265207468652062616c616e636520666f72207468659c206e756d626572206f6620766f74657320746861742077652068617665207265636f726465642e34436c6173734c6f636b73466f720101040500fd0904000c69012054686520766f74696e6720636c617373657320776869636820686176652061206e6f6e2d7a65726f206c6f636b20726571756972656d656e7420616e6420746865206c6f636b20616d6f756e747320776869636820746865796d0120726571756972652e205468652061637475616c20616d6f756e74206c6f636b6564206f6e20626568616c66206f6620746869732070616c6c65742073686f756c6420616c7761797320626520746865206d6178696d756d206f662c2074686973206c6973742e01910201890108204d6178566f74657310100002000010f020546865206d6178696d756d206e756d626572206f6620636f6e63757272656e7420766f74657320616e206163636f756e74206d617920686176652e00550120416c736f207573656420746f20636f6d70757465207765696768742c20616e206f7665726c79206c617267652076616c75652063616e206c65616420746f2065787472696e736963732077697468206c61726765c02077656967687420657374696d6174696f6e3a20736565206064656c65676174656020666f7220696e7374616e63652e44566f74654c6f636b696e67506572696f641010c0890100109020546865206d696e696d756d20706572696f64206f6620766f7465206c6f636b696e672e0065012049742073686f756c64206265206e6f2073686f72746572207468616e20656e6163746d656e7420706572696f6420746f20656e73757265207468617420696e207468652063617365206f6620616e20617070726f76616c2c49012074686f7365207375636365737366756c20766f7465727320617265206c6f636b656420696e746f2074686520636f6e73657175656e636573207468617420746865697220766f74657320656e7461696c2e01090a1400245265666572656e646101245265666572656e6461143c5265666572656e64756d436f756e74010010100000000004310120546865206e6578742066726565207265666572656e64756d20696e6465782c20616b6120746865206e756d626572206f66207265666572656e6461207374617274656420736f206661722e445265666572656e64756d496e666f466f7200010402100d0a040004b420496e666f726d6174696f6e20636f6e6365726e696e6720616e7920676976656e207265666572656e64756d2e28547261636b51756575650101040591012d0a0400105d012054686520736f72746564206c697374206f66207265666572656e646120726561647920746f206265206465636964656420627574206e6f7420796574206265696e6720646563696465642c206f7264657265642062797c20636f6e76696374696f6e2d776569676874656420617070726f76616c732e00410120546869732073686f756c6420626520656d70747920696620604465636964696e67436f756e7460206973206c657373207468616e2060547261636b496e666f3a3a6d61785f6465636964696e67602e344465636964696e67436f756e7401010405910110100000000004c420546865206e756d626572206f66207265666572656e6461206265696e6720646563696465642063757272656e746c792e284d657461646174614f66000104021030040018050120546865206d6574616461746120697320612067656e6572616c20696e666f726d6174696f6e20636f6e6365726e696e6720746865207265666572656e64756d2e490120546865206048617368602072656665727320746f2074686520707265696d616765206f66207468652060507265696d61676573602070726f76696465722077686963682063616e2062652061204a534f4e882064756d70206f7220495046532068617368206f662061204a534f4e2066696c652e00750120436f6e73696465722061206761726261676520636f6c6c656374696f6e20666f722061206d65746164617461206f662066696e6973686564207265666572656e64756d7320746f2060756e7265717565737460202872656d6f76652944206c6172676520707265696d616765732e01a502018d0114445375626d697373696f6e4465706f736974184000e40b5402000000000000000000000004350120546865206d696e696d756d20616d6f756e7420746f20626520757365642061732061206465706f73697420666f722061207075626c6963207265666572656e64756d2070726f706f73616c2e244d617851756575656410106400000004e4204d6178696d756d2073697a65206f6620746865207265666572656e64756d20717565756520666f7220612073696e676c6520747261636b2e44556e6465636964696e6754696d656f757410108013030008550120546865206e756d626572206f6620626c6f636b73206166746572207375626d697373696f6e20746861742061207265666572656e64756d206d75737420626567696e206265696e6720646563696465642062792ee4204f6e63652074686973207061737365732c207468656e20616e796f6e65206d61792063616e63656c20746865207265666572656e64756d2e34416c61726d496e74657276616c1010010000000c5d01205175616e74697a6174696f6e206c6576656c20666f7220746865207265666572656e64756d2077616b657570207363686564756c65722e204120686967686572206e756d6265722077696c6c20726573756c7420696e5d012066657765722073746f726167652072656164732f777269746573206e656564656420666f7220736d616c6c657220766f746572732c2062757420616c736f20726573756c7420696e2064656c61797320746f207468655501206175746f6d61746963207265666572656e64756d20737461747573206368616e6765732e204578706c6963697420736572766963696e6720696e737472756374696f6e732061726520756e61666665637465642e18547261636b73390a191740000010726f6f74010000000080c6a47e8d03000000000000000000b00400000027060040380000403800000290d73e0d000000005743de13000000005443de13000000000000ca9a3b000000000065cd1d01004877686974656c69737465645f63616c6c65726400000000407a10f35a000000000000000000002c01000000270600640000006400000002ec972510000000007b573c170000000042392f1200000000020e00840000000000d6e61f0100000000396279020000000002003c776973685f666f725f6368616e67650a0000000080f420e6b500000000000000000000b00400000027060040380000640000000290d73e0d000000005743de13000000005443de13000000000000ca9a3b000000000065cd1d0a00347374616b696e675f61646d696e0a00000000203d88792d00000000000000000000b004000000270600080700006400000000c94330240065cd1d00ca9a3b025d6f780000000000e82eed00000000008c6889ffffffffff0b00247472656173757265720a00000000a0724e180900000000000000000000b004000000270600c0890100403800000290d73e0d000000005743de13000000005443de13000000000000ca9a3b000000000065cd1d0c002c6c656173655f61646d696e0a00000000203d88792d00000000000000000000b004000000270600080700006400000000c94330240065cd1d00ca9a3b025d6f780000000000e82eed00000000008c6889ffffffffff0d004066656c6c6f77736869705f61646d696e0a00000000203d88792d00000000000000000000b004000000270600080700006400000000c94330240065cd1d00ca9a3b025d6f780000000000e82eed00000000008c6889ffffffffff0e003467656e6572616c5f61646d696e0a00000000203d88792d00000000000000000000b00400000027060008070000640000000290d73e0d000000005743de13000000005443de13000000000259a2f40200000000a3296b05000000002e6b4afdffffffff0f003461756374696f6e5f61646d696e0a00000000203d88792d00000000000000000000b00400000027060008070000640000000290d73e0d000000005743de13000000005443de13000000000259a2f40200000000a3296b05000000002e6b4afdffffffff1400507265666572656e64756d5f63616e63656c6c6572e803000000407a10f35a00000000000000000000b0040000c0890100080700006400000000c94330240065cd1d00ca9a3b025d6f780000000000e82eed00000000008c6889ffffffffff1500447265666572656e64756d5f6b696c6c6572e803000000406352bfc601000000000000000000b004000000270600080700006400000000c94330240065cd1d00ca9a3b025d6f780000000000e82eed00000000008c6889ffffffffff1e0030736d616c6c5f746970706572c800000000e40b540200000000000000000000000a000000c0890100640000000a00000000499149150065cd1d00ca9a3b02f9ba1800000000002a4d3100000000006b59e7ffffffffff1f00286269675f7469707065726400000000e8764817000000000000000000000064000000c0890100580200006400000000499149150065cd1d00ca9a3b02694f3f000000000035967d0000000000e534c1ffffffffff200034736d616c6c5f7370656e646572320000000010a5d4e800000000000000000000006009000000270600807000004038000000c94330240065cd1d00ca9a3b025d6f780000000000e82eed00000000008c6889ffffffffff2100386d656469756d5f7370656e6465723200000000204aa9d10100000000000000000000600900000027060000e1000040380000005b01f6300065cd1d00ca9a3b021161db0000000000bfd1aa010000000020972affffffffff22002c6269675f7370656e6465723200000000409452a303000000000000000000006009000000270600c0890100403800000000ca9a3b0065cd1d00ca9a3b02413cb00100000000755d34030000000045d165feffffffff04e020496e666f726d6174696f6e20636f6e6365726e696e672074686520646966666572656e74207265666572656e64756d20747261636b732e01510a15001c4f726967696e73000000000016002457686974656c697374012457686974656c697374043c57686974656c697374656443616c6c00010405308c04000001cd02017d070001550a170018436c61696d730118436c61696d731418436c61696d7300010406dd021804000014546f74616c0100184000000000000000000000000000000000001c56657374696e6700010406dd02e502040010782056657374696e67207363686564756c6520666f72206120636c61696d2e0d012046697273742062616c616e63652069732074686520746f74616c20616d6f756e7420746861742073686f756c642062652068656c6420666f722076657374696e672ee4205365636f6e642062616c616e636520697320686f77206d7563682073686f756c6420626520756e6c6f636b65642070657220626c6f636b2ecc2054686520626c6f636b206e756d626572206973207768656e207468652076657374696e672073686f756c642073746172742e1c5369676e696e6700010406dd02ed02040004c0205468652073746174656d656e74206b696e642074686174206d757374206265207369676e65642c20696620616e792e24507265636c61696d730001040600dd020400042d01205072652d636c61696d656420457468657265756d206163636f756e74732c20627920746865204163636f756e74204944207468617420746865792061726520636c61696d656420746f2e01d102019107041850726566697834888450617920444f547320746f2074686520506f6c6b61646f74206163636f756e743a0001590a18001c56657374696e67011c56657374696e67081c56657374696e6700010402005d0a040004d820496e666f726d6174696f6e20726567617264696e67207468652076657374696e67206f66206120676976656e206163636f756e742e3853746f7261676556657273696f6e0100650a04000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e003101204e6577206e6574776f726b732073746172742077697468206c61746573742076657273696f6e2c2061732064657465726d696e6564206279207468652067656e65736973206275696c642e01f10201950708444d696e5665737465645472616e73666572184000e40b5402000000000000000000000004e820546865206d696e696d756d20616d6f756e74207472616e7366657272656420746f2063616c6c20607665737465645f7472616e73666572602e4c4d617856657374696e675363686564756c657310101c0000000001690a19001c5574696c6974790001f902019907044c626174636865645f63616c6c735f6c696d69741010aa2a000004a820546865206c696d6974206f6e20746865206e756d626572206f6620626174636865642063616c6c732e016d0a1a00204964656e7469747901204964656e746974791c284964656e746974794f660001040500710a040010690120496e666f726d6174696f6e20746861742069732070657274696e656e7420746f206964656e746966792074686520656e7469747920626568696e6420616e206163636f756e742e204669727374206974656d20697320746865e020726567697374726174696f6e2c207365636f6e6420697320746865206163636f756e742773207072696d61727920757365726e616d652e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e1c53757065724f66000104020095030400086101205468652073757065722d6964656e74697479206f6620616e20616c7465726e6174697665202273756222206964656e7469747920746f676574686572207769746820697473206e616d652c2077697468696e2074686174510120636f6e746578742e20496620746865206163636f756e74206973206e6f7420736f6d65206f74686572206163636f756e742773207375622d6964656e746974792c207468656e206a75737420604e6f6e65602e18537562734f660101040500890a44000000000000000000000000000000000014b820416c7465726e6174697665202273756222206964656e746974696573206f662074686973206163636f756e742e001d0120546865206669727374206974656d20697320746865206465706f7369742c20746865207365636f6e64206973206120766563746f72206f6620746865206163636f756e74732e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e28526567697374726172730100910a0400104d012054686520736574206f6620726567697374726172732e204e6f7420657870656374656420746f206765742076657279206269672061732063616e206f6e6c79206265206164646564207468726f7567682061a8207370656369616c206f726967696e20286c696b656c79206120636f756e63696c206d6f74696f6e292e0029012054686520696e64657820696e746f20746869732063616e206265206361737420746f2060526567697374726172496e6465786020746f2067657420612076616c69642076616c75652e4c557365726e616d65417574686f7269746965730001040500a10a040004f42041206d6170206f6620746865206163636f756e74732077686f2061726520617574686f72697a656420746f206772616e7420757365726e616d65732e444163636f756e744f66557365726e616d6500010402ad03000400146d012052657665727365206c6f6f6b75702066726f6d2060757365726e616d656020746f2074686520604163636f756e7449646020746861742068617320726567697374657265642069742e205468652076616c75652073686f756c6465012062652061206b657920696e2074686520604964656e746974794f6660206d61702c20627574206974206d6179206e6f742069662074686520757365722068617320636c6561726564207468656972206964656e746974792e006901204d756c7469706c6520757365726e616d6573206d6179206d617020746f207468652073616d6520604163636f756e744964602c2062757420604964656e746974794f66602077696c6c206f6e6c79206d617020746f206f6e6548207072696d61727920757365726e616d652e4050656e64696e67557365726e616d657300010402ad0365090400186d0120557365726e616d6573207468617420616e20617574686f7269747920686173206772616e7465642c20627574207468617420746865206163636f756e7420636f6e74726f6c6c657220686173206e6f7420636f6e6669726d65647101207468617420746865792077616e742069742e2055736564207072696d6172696c7920696e2063617365732077686572652074686520604163636f756e744964602063616e6e6f742070726f766964652061207369676e61747572655d012062656361757365207468657920617265206120707572652070726f78792c206d756c74697369672c206574632e20496e206f7264657220746f20636f6e6669726d2069742c20746865792073686f756c642063616c6c6c205b6043616c6c3a3a6163636570745f757365726e616d65605d2e001d01204669727374207475706c65206974656d20697320746865206163636f756e7420616e64207365636f6e642069732074686520616363657074616e636520646561646c696e652e010103019d07203042617369634465706f7369741840007db52a2f000000000000000000000004d82054686520616d6f756e742068656c64206f6e206465706f73697420666f7220612072656769737465726564206964656e746974792e2c427974654465706f736974184080969800000000000000000000000000041d012054686520616d6f756e742068656c64206f6e206465706f7369742070657220656e636f646564206279746520666f7220612072656769737465726564206964656e746974792e445375624163636f756e744465706f736974184080f884b02e00000000000000000000000c65012054686520616d6f756e742068656c64206f6e206465706f73697420666f7220612072656769737465726564207375626163636f756e742e20546869732073686f756c64206163636f756e7420666f7220746865206661637465012074686174206f6e652073746f72616765206974656d27732076616c75652077696c6c20696e637265617365206279207468652073697a65206f6620616e206163636f756e742049442c20616e642074686572652077696c6c350120626520616e6f746865722074726965206974656d2077686f73652076616c7565206973207468652073697a65206f6620616e206163636f756e7420494420706c75732033322062797465732e384d61785375624163636f756e7473101064000000040d0120546865206d6178696d756d206e756d626572206f66207375622d6163636f756e747320616c6c6f77656420706572206964656e746966696564206163636f756e742e344d617852656769737472617273101014000000085101204d61786d696d756d206e756d626572206f66207265676973747261727320616c6c6f77656420696e207468652073797374656d2e204e656564656420746f20626f756e642074686520636f6d706c65786974797c206f662c20652e672e2c207570646174696e67206a756467656d656e74732e6450656e64696e67557365726e616d6545787069726174696f6e1010c089010004150120546865206e756d626572206f6620626c6f636b732077697468696e207768696368206120757365726e616d65206772616e74206d7573742062652061636365707465642e3c4d61785375666669784c656e677468101007000000048020546865206d6178696d756d206c656e677468206f662061207375666669782e444d6178557365726e616d654c656e67746810102000000004610120546865206d6178696d756d206c656e677468206f66206120757365726e616d652c20696e636c7564696e67206974732073756666697820616e6420616e792073797374656d2d61646465642064656c696d69746572732e01a90a1c001450726f7879011450726f7879081c50726f786965730101040500ad0a4400000000000000000000000000000000000845012054686520736574206f66206163636f756e742070726f786965732e204d61707320746865206163636f756e74207768696368206861732064656c65676174656420746f20746865206163636f756e7473210120776869636820617265206265696e672064656c65676174656420746f2c20746f67657468657220776974682074686520616d6f756e742068656c64206f6e206465706f7369742e34416e6e6f756e63656d656e74730101040500bd0a44000000000000000000000000000000000004ac2054686520616e6e6f756e63656d656e7473206d616465206279207468652070726f787920286b6579292e01b10301a107184050726f78794465706f7369744261736518400084b2952e000000000000000000000010110120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720612070726f78792e00010120546869732069732068656c6420666f7220616e206164646974696f6e616c2073746f72616765206974656d2077686f73652076616c75652073697a652069732501206073697a656f662842616c616e6365296020627974657320616e642077686f7365206b65792073697a65206973206073697a656f66284163636f756e74496429602062797465732e4850726f78794465706f736974466163746f7218408066ab1300000000000000000000000014bc2054686520616d6f756e74206f662063757272656e6379206e6565646564207065722070726f78792061646465642e00350120546869732069732068656c6420666f7220616464696e6720333220627974657320706c757320616e20696e7374616e6365206f66206050726f78795479706560206d6f726520696e746f20616101207072652d6578697374696e672073746f726167652076616c75652e20546875732c207768656e20636f6e6669677572696e67206050726f78794465706f736974466163746f7260206f6e652073686f756c642074616b65f420696e746f206163636f756e7420603332202b2070726f78795f747970652e656e636f646528292e6c656e282960206279746573206f6620646174612e284d617850726f7869657310102000000004f020546865206d6178696d756d20616d6f756e74206f662070726f7869657320616c6c6f77656420666f7220612073696e676c65206163636f756e742e284d617850656e64696e6710102000000004450120546865206d6178696d756d20616d6f756e74206f662074696d652d64656c6179656420616e6e6f756e63656d656e747320746861742061726520616c6c6f77656420746f2062652070656e64696e672e5c416e6e6f756e63656d656e744465706f7369744261736518400084b2952e000000000000000000000010310120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720616e20616e6e6f756e63656d656e742e00490120546869732069732068656c64207768656e2061206e65772073746f72616765206974656d20686f6c64696e672061206042616c616e636560206973206372656174656420287479706963616c6c7920313620206279746573292e64416e6e6f756e63656d656e744465706f736974466163746f72184000cd562700000000000000000000000010d42054686520616d6f756e74206f662063757272656e6379206e65656465642070657220616e6e6f756e63656d656e74206d6164652e00590120546869732069732068656c6420666f7220616464696e6720616e20604163636f756e744964602c2060486173686020616e642060426c6f636b4e756d6265726020287479706963616c6c79203638206279746573298c20696e746f2061207072652d6578697374696e672073746f726167652076616c75652e01cd0a1d00204d756c746973696701204d756c746973696704244d756c7469736967730001080502d10ad50a040004942054686520736574206f66206f70656e206d756c7469736967206f7065726174696f6e732e01bd0301a5070c2c4465706f736974426173651840008c61c52e000000000000000000000018590120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e672061206d756c746973696720657865637574696f6e206f7220746f842073746f726520612064697370617463682063616c6c20666f72206c617465722e00010120546869732069732068656c6420666f7220616e206164646974696f6e616c2073746f72616765206974656d2077686f73652076616c75652073697a652069733101206034202b2073697a656f662828426c6f636b4e756d6265722c2042616c616e63652c204163636f756e74496429296020627974657320616e642077686f7365206b65792073697a652069738020603332202b2073697a656f66284163636f756e74496429602062797465732e344465706f736974466163746f72184000d012130000000000000000000000000c55012054686520616d6f756e74206f662063757272656e6379206e65656465642070657220756e6974207468726573686f6c64207768656e206372656174696e672061206d756c746973696720657865637574696f6e2e00250120546869732069732068656c6420666f7220616464696e67203332206279746573206d6f726520696e746f2061207072652d6578697374696e672073746f726167652076616c75652e384d61785369676e61746f7269657310106400000004ec20546865206d6178696d756d20616d6f756e74206f66207369676e61746f7269657320616c6c6f77656420696e20746865206d756c74697369672e01dd0a1e0020426f756e746965730120426f756e74696573102c426f756e7479436f756e74010010100000000004c0204e756d626572206f6620626f756e74792070726f706f73616c7320746861742068617665206265656e206d6164652e20426f756e746965730001040510e10a0400047820426f756e7469657320746861742068617665206265656e206d6164652e48426f756e74794465736372697074696f6e730001040510e90a0400048020546865206465736372697074696f6e206f66206561636820626f756e74792e3c426f756e7479417070726f76616c730100c109040004ec20426f756e747920696e646963657320746861742068617665206265656e20617070726f76656420627574206e6f74207965742066756e6465642e01c90301a9072444426f756e74794465706f73697442617365184000e40b5402000000000000000000000004e82054686520616d6f756e742068656c64206f6e206465706f73697420666f7220706c6163696e67206120626f756e74792070726f706f73616c2e60426f756e74794465706f7369745061796f757444656c6179101000c20100045901205468652064656c617920706572696f6420666f72207768696368206120626f756e74792062656e6566696369617279206e65656420746f2077616974206265666f726520636c61696d20746865207061796f75742e48426f756e7479557064617465506572696f64101080c61300046c20426f756e7479206475726174696f6e20696e20626c6f636b732e6043757261746f724465706f7369744d756c7469706c696572cd091020a10700101901205468652063757261746f72206465706f7369742069732063616c63756c6174656420617320612070657263656e74616765206f66207468652063757261746f72206665652e0039012054686973206465706f73697420686173206f7074696f6e616c20757070657220616e64206c6f77657220626f756e64732077697468206043757261746f724465706f7369744d61786020616e6454206043757261746f724465706f7369744d696e602e4443757261746f724465706f7369744d61782502440100204aa9d10100000000000000000000044901204d6178696d756d20616d6f756e74206f662066756e647320746861742073686f756c6420626520706c6163656420696e2061206465706f73697420666f72206d616b696e6720612070726f706f73616c2e4443757261746f724465706f7369744d696e2502440100e87648170000000000000000000000044901204d696e696d756d20616d6f756e74206f662066756e647320746861742073686f756c6420626520706c6163656420696e2061206465706f73697420666f72206d616b696e6720612070726f706f73616c2e48426f756e747956616c75654d696e696d756d184000e876481700000000000000000000000470204d696e696d756d2076616c756520666f72206120626f756e74792e48446174614465706f73697450657242797465184000e1f5050000000000000000000000000461012054686520616d6f756e742068656c64206f6e206465706f7369742070657220627974652077697468696e2074686520746970207265706f727420726561736f6e206f7220626f756e7479206465736372697074696f6e2e4c4d6178696d756d526561736f6e4c656e6774681010004000000c88204d6178696d756d2061636365707461626c6520726561736f6e206c656e6774682e0065012042656e63686d61726b7320646570656e64206f6e20746869732076616c75652c206265207375726520746f2075706461746520776569676874732066696c65207768656e206368616e67696e6720746869732076616c756501ed0a2200344368696c64426f756e7469657301344368696c64426f756e7469657314404368696c64426f756e7479436f756e7401001010000000000480204e756d626572206f6620746f74616c206368696c6420626f756e746965732e4c506172656e744368696c64426f756e74696573010104051010100000000008b0204e756d626572206f66206368696c6420626f756e746965732070657220706172656e7420626f756e74792ee0204d6170206f6620706172656e7420626f756e747920696e64657820746f206e756d626572206f66206368696c6420626f756e746965732e344368696c64426f756e74696573000108050580f10a04000494204368696c6420626f756e7469657320746861742068617665206265656e2061646465642e5c4368696c64426f756e74794465736372697074696f6e730001040510e90a0400049820546865206465736372697074696f6e206f662065616368206368696c642d626f756e74792e4c4368696c6472656e43757261746f72466565730101040510184000000000000000000000000000000000040101205468652063756d756c6174697665206368696c642d626f756e74792063757261746f722066656520666f72206561636820706172656e7420626f756e74792e01cd0301ad0708644d61784163746976654368696c64426f756e7479436f756e74101064000000041d01204d6178696d756d206e756d626572206f66206368696c6420626f756e7469657320746861742063616e20626520616464656420746f206120706172656e7420626f756e74792e5c4368696c64426f756e747956616c75654d696e696d756d184000e40b540200000000000000000000000488204d696e696d756d2076616c756520666f722061206368696c642d626f756e74792e01f90a260068456c656374696f6e50726f76696465724d756c746950686173650168456c656374696f6e50726f76696465724d756c746950686173652814526f756e64010010100100000018ac20496e7465726e616c20636f756e74657220666f7220746865206e756d626572206f6620726f756e64732e00550120546869732069732075736566756c20666f722064652d6475706c69636174696f6e206f66207472616e73616374696f6e73207375626d697474656420746f2074686520706f6f6c2c20616e642067656e6572616c6c20646961676e6f7374696373206f66207468652070616c6c65742e004d012054686973206973206d6572656c7920696e6372656d656e746564206f6e6365207065722065766572792074696d65207468617420616e20757073747265616d2060656c656374602069732063616c6c65642e3043757272656e7450686173650100b9070400043c2043757272656e742070686173652e38517565756564536f6c7574696f6e0000fd0a04000c3d012043757272656e74206265737420736f6c7574696f6e2c207369676e6564206f7220756e7369676e65642c2071756575656420746f2062652072657475726e65642075706f6e2060656c656374602e006020416c7761797320736f727465642062792073636f72652e20536e617073686f740000050b0400107020536e617073686f742064617461206f662074686520726f756e642e005d01205468697320697320637265617465642061742074686520626567696e6e696e67206f6620746865207369676e656420706861736520616e6420636c65617265642075706f6e2063616c6c696e672060656c656374602e2901204e6f74653a20546869732073746f726167652074797065206d757374206f6e6c79206265206d757461746564207468726f756768205b60536e617073686f7457726170706572605d2e384465736972656454617267657473000010040010cc2044657369726564206e756d626572206f66207461726765747320746f20656c65637420666f72207468697320726f756e642e00a8204f6e6c7920657869737473207768656e205b60536e617073686f74605d2069732070726573656e742e2901204e6f74653a20546869732073746f726167652074797065206d757374206f6e6c79206265206d757461746564207468726f756768205b60536e617073686f7457726170706572605d2e40536e617073686f744d657461646174610000a9040400109820546865206d65746164617461206f6620746865205b60526f756e64536e617073686f74605d00a8204f6e6c7920657869737473207768656e205b60536e617073686f74605d2069732070726573656e742e2901204e6f74653a20546869732073746f726167652074797065206d757374206f6e6c79206265206d757461746564207468726f756768205b60536e617073686f7457726170706572605d2e645369676e65645375626d697373696f6e4e657874496e646578010010100000000024010120546865206e65787420696e64657820746f2062652061737369676e656420746f20616e20696e636f6d696e67207369676e6564207375626d697373696f6e2e007501204576657279206163636570746564207375626d697373696f6e2069732061737369676e6564206120756e6971756520696e6465783b207468617420696e64657820697320626f756e6420746f207468617420706172746963756c61726501207375626d697373696f6e20666f7220746865206475726174696f6e206f662074686520656c656374696f6e2e204f6e20656c656374696f6e2066696e616c697a6174696f6e2c20746865206e65787420696e6465782069733020726573657420746f20302e0069012057652063616e2774206a7573742075736520605369676e65645375626d697373696f6e496e64696365732e6c656e2829602c206265636175736520746861742773206120626f756e646564207365743b20706173742069747359012063617061636974792c2069742077696c6c2073696d706c792073617475726174652e2057652063616e2774206a7573742069746572617465206f76657220605369676e65645375626d697373696f6e734d6170602cf4206265636175736520697465726174696f6e20697320736c6f772e20496e73746561642c2077652073746f7265207468652076616c756520686572652e5c5369676e65645375626d697373696f6e496e64696365730100110b0400186d01204120736f727465642c20626f756e64656420766563746f72206f6620602873636f72652c20626c6f636b5f6e756d6265722c20696e64657829602c20776865726520656163682060696e6465786020706f696e747320746f2061782076616c756520696e20605369676e65645375626d697373696f6e73602e007101205765206e65766572206e65656420746f2070726f63657373206d6f7265207468616e20612073696e676c65207369676e6564207375626d697373696f6e20617420612074696d652e205369676e6564207375626d697373696f6e7375012063616e206265207175697465206c617267652c20736f2077652772652077696c6c696e6720746f207061792074686520636f7374206f66206d756c7469706c6520646174616261736520616363657373657320746f206163636573732101207468656d206f6e6520617420612074696d6520696e7374656164206f662072656164696e6720616e64206465636f64696e6720616c6c206f66207468656d206174206f6e63652e505369676e65645375626d697373696f6e734d617000010405101d0b04001c7420556e636865636b65642c207369676e656420736f6c7574696f6e732e00690120546f676574686572207769746820605375626d697373696f6e496e6469636573602c20746869732073746f726573206120626f756e64656420736574206f6620605369676e65645375626d697373696f6e7360207768696c65ec20616c6c6f77696e6720757320746f206b656570206f6e6c7920612073696e676c65206f6e6520696e206d656d6f727920617420612074696d652e0069012054776f78206e6f74653a20746865206b6579206f6620746865206d617020697320616e206175746f2d696e6372656d656e74696e6720696e6465782077686963682075736572732063616e6e6f7420696e7370656374206f72f4206166666563743b2077652073686f756c646e2774206e65656420612063727970746f67726170686963616c6c7920736563757265206861736865722e544d696e696d756d556e7472757374656453636f72650000a5040400105d0120546865206d696e696d756d2073636f7265207468617420656163682027756e747275737465642720736f6c7574696f6e206d7573742061747461696e20696e206f7264657220746f20626520636f6e7369646572656428206665617369626c652e00b82043616e206265207365742076696120607365745f6d696e696d756d5f756e747275737465645f73636f7265602e01d10301b1074034556e7369676e656450686173651010580200000480204475726174696f6e206f662074686520756e7369676e65642070686173652e2c5369676e656450686173651010580200000478204475726174696f6e206f6620746865207369676e65642070686173652e544265747465725369676e65645468726573686f6c64ac1000000000084d0120546865206d696e696d756d20616d6f756e74206f6620696d70726f76656d656e7420746f2074686520736f6c7574696f6e2073636f7265207468617420646566696e6573206120736f6c7574696f6e2061737820226265747465722220696e20746865205369676e65642070686173652e384f6666636861696e52657065617410101200000010b42054686520726570656174207468726573686f6c64206f6620746865206f6666636861696e20776f726b65722e00610120466f72206578616d706c652c20696620697420697320352c2074686174206d65616e732074686174206174206c65617374203520626c6f636b732077696c6c20656c61707365206265747765656e20617474656d7074738420746f207375626d69742074686520776f726b6572277320736f6c7574696f6e2e3c4d696e657254785072696f726974792c2065666666666666e604250120546865207072696f72697479206f662074686520756e7369676e6564207472616e73616374696f6e207375626d697474656420696e2074686520756e7369676e65642d7068617365505369676e65644d61785375626d697373696f6e731010100000001ce4204d6178696d756d206e756d626572206f66207369676e6564207375626d697373696f6e7320746861742063616e206265207175657565642e005501204974206973206265737420746f2061766f69642061646a757374696e67207468697320647572696e6720616e20656c656374696f6e2c20617320697420696d706163747320646f776e73747265616d2064617461650120737472756374757265732e20496e20706172746963756c61722c20605369676e65645375626d697373696f6e496e64696365733c543e6020697320626f756e646564206f6e20746869732076616c75652e20496620796f75f42075706461746520746869732076616c756520647572696e6720616e20656c656374696f6e2c20796f75205f6d7573745f20656e7375726520746861744d0120605369676e65645375626d697373696f6e496e64696365732e6c656e282960206973206c657373207468616e206f7220657175616c20746f20746865206e65772076616c75652e204f74686572776973652cf020617474656d70747320746f207375626d6974206e657720736f6c7574696f6e73206d617920636175736520612072756e74696d652070616e69632e3c5369676e65644d617857656967687424400b08c77258550113a3703d0ad7a370bd1494204d6178696d756d20776569676874206f662061207369676e656420736f6c7574696f6e2e005d01204966205b60436f6e6669673a3a4d696e6572436f6e666967605d206973206265696e6720696d706c656d656e74656420746f207375626d6974207369676e656420736f6c7574696f6e7320286f757473696465206f663d0120746869732070616c6c6574292c207468656e205b604d696e6572436f6e6669673a3a736f6c7574696f6e5f776569676874605d206973207573656420746f20636f6d7061726520616761696e73743020746869732076616c75652e405369676e65644d6178526566756e647310100400000004190120546865206d6178696d756d20616d6f756e74206f6620756e636865636b656420736f6c7574696f6e7320746f20726566756e64207468652063616c6c2066656520666f722e405369676e656452657761726442617365184000e40b54020000000000000000000000048820426173652072657761726420666f722061207369676e656420736f6c7574696f6e445369676e65644465706f736974427974651840787d010000000000000000000000000004a0205065722d62797465206465706f73697420666f722061207369676e656420736f6c7574696f6e2e4c5369676e65644465706f73697457656967687418400000000000000000000000000000000004a8205065722d776569676874206465706f73697420666f722061207369676e656420736f6c7574696f6e2e284d617857696e6e6572731010b004000010350120546865206d6178696d756d206e756d626572206f662077696e6e65727320746861742063616e20626520656c656374656420627920746869732060456c656374696f6e50726f7669646572604020696d706c656d656e746174696f6e2e005101204e6f74653a2054686973206d75737420616c776179732062652067726561746572206f7220657175616c20746f2060543a3a4461746150726f76696465723a3a646573697265645f746172676574732829602e384d696e65724d61784c656e67746810100000360000384d696e65724d617857656967687424400b08c77258550113a3703d0ad7a370bd00544d696e65724d6178566f746573506572566f746572101010000000003c4d696e65724d617857696e6e6572731010b00400000001210b240024566f7465724c6973740124566f7465724c6973740c244c6973744e6f6465730001040500250b04000c8020412073696e676c65206e6f64652c2077697468696e20736f6d65206261672e000501204e6f6465732073746f7265206c696e6b7320666f727761726420616e64206261636b2077697468696e207468656972207265737065637469766520626167732e4c436f756e746572466f724c6973744e6f646573010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d6170204c69737442616773000104052c290b04000c642041206261672073746f72656420696e2073746f726167652e0019012053746f7265732061206042616760207374727563742c2077686963682073746f726573206865616420616e64207461696c20706f696e7465727320746f20697473656c662e01c50401c10704344261675468726573686f6c64732d0b0919210300e40b5402000000f39e809702000000a8b197e20200000094492e3603000000279c3a930300000003bccefa0300000042c01b6e040000001b4775ee04000000385e557d0500000046dc601c0600000089386ccd06000000b6ee809207000000fe7ee36d08000000e81b1a6209000000b019f4710a000000103592a00b000000cfc96ff10c00000041146d680e000000e79bda0910000000cee885da1100000028a9c7df13000000bb70931f160000008e4089a018000000810a096a1b000000366a48841e0000005bd36af821000000807c9cd025000000c95530182a000000bd63c1db2e00000071e0572934000000689092103a000000edc4d4a240000000699379f3470000008fd80c18500000004baf8a28590000006a16a63f630000000995177b6e00000078c5f4fb7a00000062c811e78800000051bf6d6598000000048eaba4a9000000544698d7bc00000091cac036d2000000175f1801ea000000bd15b27c0401000043358ff721010000b8fc84c84201000099673c506701000007e44efa8f010000b341833ebd010000027f2ea2ef0100009883bcb927020000164d652a66020000b49513acab0200002d8e820bf9020000a1e6982c4f030000a616080daf030000cc9d37c719040000a0d584959004000042e7e0d514050000028cd70da80500000f750aef4b060000ea8d2e5c02070000c3cb996ecd070000b1e5717caf080000aa2b8e1fab090000b5c1203dc30a000026d03d0efb0b000070c75929560d0000ebadda8cd80e0000f797dbaa86100000cff04476651200001f2660717a14000009a611becb1600001dfbe82f60190000943a3c603f1c00008afe89c4711f0000ced963c70023000003a92ae4f6260000fe72eec55f2b000036c9cc6948300000dae33245bf350000062a7470d43b00007c9732d69942000084a32468234a0000571ad45987520000e7f10262de5b00000db8760344660000ae0401ded67100007d9eb308b97e00001e044a76108d00003a1df064079d0000e04fafdaccae00005679f02f95c2000095c3aaa99ad80000967c05251ef10000177a66d6670c010028cb1f1ec82a0100fa282f75984c0100d57dc8743c7201007dc4b3fb229c0100365cde74c7ca01009eb8e142b3fe01000c31ae547f3802005fe101e8d57802006373da7e74c0020051d1a60d2e100300c7e9a468ed68030061c091f7b7cb0300bf27a1b7b03904007b1499941bb404008523ed22613c050069a5d4c512d40500ec8c934def7c0600f5aa901be83807008cbe5ddb260a080002978ce113f30800fae314435df60900ddf12dbafe160b002ebadc6f4a580c000c5518c4f2bd0d00f0bb5431154c0f00498e866b46071100b2c153de9ff41200278a2fb2ce191500b2399f84247d1700e199e704aa251a00ba13f5ab331b1d00264785cc7866200088bf803f2d1124001c9823f81d262800ccc422d450b12c00f088820528c03100367c6d7e896137006e9329d30aa63d008cbc6c1322a044000070f32a5c644c00b43b84699909550080b4abe450a95e00a0cda979db5f69004cc27f4cc74c7500d0ac0eba34938200483e0ccf3d5a910068c68e7469cda100281e6fa52b1db40098a92326747fc800f09a74634d30df0080cdfc4b8d72f8009014602d9a901401f0b413d945dd330120973596c1b4560150dcfbaead7d7d01e01198b947aaa80130c7ee16bbb9d801206e488697390e02a0fa4b1d72c74902c0117170b5128c02808a1643a6ded502c0f823b1a204280380af5970a2768303c06f2d87ff41e90340937fac8f925a040091097117b6d804400fdf5b212065050049c149446e0106008ebca6e56caf0600595686851c71078068aa34a4b7480880a1e29e52b9380900bdabe880e4430a002a72b4204c6d0b80f1c013335cb80c00a03ccbdce3280e80b8629a9e20c30f00de5693d2ca8b11005d7f4c93238813001a87df3504be1500a7ce4b84ef3318000110fbea24f11a00802ae5d1b5fd1d0022a134609d62210044216bf0da2925000261f1828f5e29006620cf851e0d2e008410195252433300a0c18fca8410390026ad1493cc853f00d0cd24662fb646009ce19a1cdab64e0058ccc20c5f9f5700200a7578fb89610030bbbbd6e4936c0060cba7dc9edd7800b83bc0425b8b8600b886236164c59500f8f15fdc93b8a600206a91c0d696b900d8efe28fc097ce0068299bf52ef9e5ffffffffffffffffacd020546865206c697374206f66207468726573686f6c64732073657061726174696e672074686520766172696f757320626167732e00490120496473206172652073657061726174656420696e746f20756e736f727465642062616773206163636f7264696e6720746f2074686569722073636f72652e205468697320737065636966696573207468656101207468726573686f6c64732073657061726174696e672074686520626167732e20416e20696427732062616720697320746865206c6172676573742062616720666f722077686963682074686520696427732073636f7265b8206973206c657373207468616e206f7220657175616c20746f20697473207570706572207468726573686f6c642e006501205768656e20696473206172652069746572617465642c2068696768657220626167732061726520697465726174656420636f6d706c6574656c79206265666f7265206c6f77657220626167732e2054686973206d65616e735901207468617420697465726174696f6e206973205f73656d692d736f727465645f3a20696473206f66206869676865722073636f72652074656e6420746f20636f6d65206265666f726520696473206f66206c6f7765722d012073636f72652c206275742070656572206964732077697468696e206120706172746963756c6172206261672061726520736f7274656420696e20696e73657274696f6e206f726465722e006820232045787072657373696e672074686520636f6e7374616e74004d01205468697320636f6e7374616e74206d75737420626520736f7274656420696e207374726963746c7920696e6372656173696e67206f726465722e204475706c6963617465206974656d7320617265206e6f742c207065726d69747465642e00410120546865726520697320616e20696d706c696564207570706572206c696d6974206f66206053636f72653a3a4d4158603b20746861742076616c756520646f6573206e6f74206e65656420746f2062652101207370656369666965642077697468696e20746865206261672e20466f7220616e792074776f207468726573686f6c64206c697374732c206966206f6e6520656e647320776974683101206053636f72653a3a4d4158602c20746865206f74686572206f6e6520646f6573206e6f742c20616e64207468657920617265206f746865727769736520657175616c2c207468652074776f7c206c697374732077696c6c20626568617665206964656e746963616c6c792e003820232043616c63756c6174696f6e005501204974206973207265636f6d6d656e64656420746f2067656e65726174652074686520736574206f66207468726573686f6c647320696e20612067656f6d6574726963207365726965732c2073756368207468617441012074686572652065786973747320736f6d6520636f6e7374616e7420726174696f2073756368207468617420607468726573686f6c645b6b202b20315d203d3d20287468726573686f6c645b6b5d202ad020636f6e7374616e745f726174696f292e6d6178287468726573686f6c645b6b5d202b2031296020666f7220616c6c20606b602e005901205468652068656c7065727320696e2074686520602f7574696c732f6672616d652f67656e65726174652d6261677360206d6f64756c652063616e2073696d706c69667920746869732063616c63756c6174696f6e2e002c2023204578616d706c6573005101202d20496620604261675468726573686f6c64733a3a67657428292e69735f656d7074792829602c207468656e20616c6c20696473206172652070757420696e746f207468652073616d65206261672c20616e64b0202020697465726174696f6e206973207374726963746c7920696e20696e73657274696f6e206f726465722e6101202d20496620604261675468726573686f6c64733a3a67657428292e6c656e2829203d3d203634602c20616e6420746865207468726573686f6c6473206172652064657465726d696e6564206163636f7264696e6720746f11012020207468652070726f63656475726520676976656e2061626f76652c207468656e2074686520636f6e7374616e7420726174696f20697320657175616c20746f20322e6501202d20496620604261675468726573686f6c64733a3a67657428292e6c656e2829203d3d20323030602c20616e6420746865207468726573686f6c6473206172652064657465726d696e6564206163636f7264696e6720746f59012020207468652070726f63656475726520676976656e2061626f76652c207468656e2074686520636f6e7374616e7420726174696f20697320617070726f78696d6174656c7920657175616c20746f20312e3234382e6101202d20496620746865207468726573686f6c64206c69737420626567696e7320605b312c20322c20332c202e2e2e5d602c207468656e20616e20696420776974682073636f72652030206f7220312077696c6c2066616c6cf0202020696e746f2062616720302c20616e20696420776974682073636f726520322077696c6c2066616c6c20696e746f2062616720312c206574632e00302023204d6967726174696f6e00610120496e20746865206576656e7420746861742074686973206c6973742065766572206368616e6765732c206120636f7079206f6620746865206f6c642062616773206c697374206d7573742062652072657461696e65642e5d012057697468207468617420604c6973743a3a6d696772617465602063616e2062652063616c6c65642c2077686963682077696c6c20706572666f726d2074686520617070726f707269617465206d6967726174696f6e2e01310b25003c4e6f6d696e6174696f6e506f6f6c73013c4e6f6d696e6174696f6e506f6f6c735440546f74616c56616c75654c6f636b65640100184000000000000000000000000000000000148c205468652073756d206f662066756e6473206163726f737320616c6c20706f6f6c732e0071012054686973206d69676874206265206c6f77657220627574206e6576657220686967686572207468616e207468652073756d206f662060746f74616c5f62616c616e636560206f6620616c6c205b60506f6f6c4d656d62657273605d590120626563617573652063616c6c696e672060706f6f6c5f77697468647261775f756e626f6e64656460206d696768742064656372656173652074686520746f74616c207374616b65206f662074686520706f6f6c277329012060626f6e6465645f6163636f756e746020776974686f75742061646a757374696e67207468652070616c6c65742d696e7465726e616c2060556e626f6e64696e67506f6f6c6027732e2c4d696e4a6f696e426f6e640100184000000000000000000000000000000000049c204d696e696d756d20616d6f756e7420746f20626f6e6420746f206a6f696e206120706f6f6c2e344d696e437265617465426f6e6401001840000000000000000000000000000000001ca0204d696e696d756d20626f6e6420726571756972656420746f20637265617465206120706f6f6c2e00650120546869732069732074686520616d6f756e74207468617420746865206465706f7369746f72206d7573742070757420617320746865697220696e697469616c207374616b6520696e2074686520706f6f6c2c20617320616e8820696e6469636174696f6e206f662022736b696e20696e207468652067616d65222e0069012054686973206973207468652076616c756520746861742077696c6c20616c7761797320657869737420696e20746865207374616b696e67206c6564676572206f662074686520706f6f6c20626f6e646564206163636f756e7480207768696c6520616c6c206f74686572206163636f756e7473206c656176652e204d6178506f6f6c730000100400086901204d6178696d756d206e756d626572206f66206e6f6d696e6174696f6e20706f6f6c7320746861742063616e2065786973742e20496620604e6f6e65602c207468656e20616e20756e626f756e646564206e756d626572206f664420706f6f6c732063616e2065786973742e384d6178506f6f6c4d656d626572730000100400084901204d6178696d756d206e756d626572206f66206d656d6265727320746861742063616e20657869737420696e207468652073797374656d2e20496620604e6f6e65602c207468656e2074686520636f756e74b8206d656d6265727320617265206e6f7420626f756e64206f6e20612073797374656d20776964652062617369732e544d6178506f6f6c4d656d62657273506572506f6f6c0000100400084101204d6178696d756d206e756d626572206f66206d656d626572732074686174206d61792062656c6f6e6720746f20706f6f6c2e20496620604e6f6e65602c207468656e2074686520636f756e74206f66a8206d656d62657273206973206e6f7420626f756e64206f6e20612070657220706f6f6c2062617369732e4c476c6f62616c4d6178436f6d6d697373696f6e0000ac04000c690120546865206d6178696d756d20636f6d6d697373696f6e20746861742063616e2062652063686172676564206279206120706f6f6c2e2055736564206f6e20636f6d6d697373696f6e207061796f75747320746f20626f756e64250120706f6f6c20636f6d6d697373696f6e73207468617420617265203e2060476c6f62616c4d6178436f6d6d697373696f6e602c206e65636573736172792069662061206675747572650d012060476c6f62616c4d6178436f6d6d697373696f6e60206973206c6f776572207468616e20736f6d652063757272656e7420706f6f6c20636f6d6d697373696f6e732e2c506f6f6c4d656d626572730001040500390b04000c4020416374697665206d656d626572732e00d02054574f582d4e4f54453a20534146452073696e636520604163636f756e7449646020697320612073656375726520686173682e54436f756e746572466f72506f6f6c4d656d62657273010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d61702c426f6e646564506f6f6c730001040510450b040004682053746f7261676520666f7220626f6e64656420706f6f6c732e54436f756e746572466f72426f6e646564506f6f6c73010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d61702c526577617264506f6f6c730001040510590b04000875012052657761726420706f6f6c732e2054686973206973207768657265207468657265207265776172647320666f72206561636820706f6f6c20616363756d756c6174652e205768656e2061206d656d62657273207061796f7574206973590120636c61696d65642c207468652062616c616e636520636f6d6573206f757420666f207468652072657761726420706f6f6c2e204b657965642062792074686520626f6e64656420706f6f6c73206163636f756e742e54436f756e746572466f72526577617264506f6f6c73010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d61703c537562506f6f6c7353746f7261676500010405105d0b04000819012047726f757073206f6620756e626f6e64696e6720706f6f6c732e20456163682067726f7570206f6620756e626f6e64696e6720706f6f6c732062656c6f6e677320746f2061290120626f6e64656420706f6f6c2c2068656e636520746865206e616d65207375622d706f6f6c732e204b657965642062792074686520626f6e64656420706f6f6c73206163636f756e742e64436f756e746572466f72537562506f6f6c7353746f72616765010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d6170204d657461646174610101040510750b0400045c204d6574616461746120666f722074686520706f6f6c2e48436f756e746572466f724d65746164617461010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d6170284c617374506f6f6c4964010010100000000004d0204576657220696e6372656173696e67206e756d626572206f6620616c6c20706f6f6c73206372656174656420736f206661722e4c52657665727365506f6f6c49644c6f6f6b7570000104050010040010dc20412072657665727365206c6f6f6b75702066726f6d2074686520706f6f6c2773206163636f756e7420696420746f206974732069642e0055012054686973206973206f6e6c79207573656420666f7220736c617368696e672e20496e20616c6c206f7468657220696e7374616e6365732c2074686520706f6f6c20696420697320757365642c20616e6420746865c0206163636f756e7473206172652064657465726d696e6973746963616c6c7920646572697665642066726f6d2069742e74436f756e746572466f7252657665727365506f6f6c49644c6f6f6b7570010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d617040436c61696d5065726d697373696f6e730101040500e5040400040101204d61702066726f6d206120706f6f6c206d656d626572206163636f756e7420746f207468656972206f7074656420636c61696d207065726d697373696f6e2e01c90401c5070c2050616c6c65744964d1092070792f6e6f706c73048420546865206e6f6d696e6174696f6e20706f6f6c27732070616c6c65742069642e484d6178506f696e7473546f42616c616e636508040a301d0120546865206d6178696d756d20706f6f6c20706f696e74732d746f2d62616c616e636520726174696f207468617420616e20606f70656e6020706f6f6c2063616e20686176652e005501205468697320697320696d706f7274616e7420696e20746865206576656e7420736c617368696e672074616b657320706c61636520616e642074686520706f6f6c277320706f696e74732d746f2d62616c616e63657c20726174696f206265636f6d65732064697370726f706f7274696f6e616c2e006501204d6f72656f7665722c20746869732072656c6174657320746f207468652060526577617264436f756e7465726020747970652061732077656c6c2c206173207468652061726974686d65746963206f7065726174696f6e7355012061726520612066756e6374696f6e206f66206e756d626572206f6620706f696e74732c20616e642062792073657474696e6720746869732076616c756520746f20652e672e2031302c20796f7520656e73757265650120746861742074686520746f74616c206e756d626572206f6620706f696e747320696e207468652073797374656d20617265206174206d6f73742031302074696d65732074686520746f74616c5f69737375616e6365206f669c2074686520636861696e2c20696e20746865206162736f6c75746520776f72736520636173652e00490120466f7220612076616c7565206f662031302c20746865207468726573686f6c6420776f756c64206265206120706f6f6c20706f696e74732d746f2d62616c616e636520726174696f206f662031303a312e310120537563682061207363656e6172696f20776f756c6420616c736f20626520746865206571756976616c656e74206f662074686520706f6f6c206265696e672039302520736c61736865642e304d6178556e626f6e64696e67101020000000043d0120546865206d6178696d756d206e756d626572206f662073696d756c74616e656f757320756e626f6e64696e67206368756e6b7320746861742063616e20657869737420706572206d656d6265722e01790b27002c46617374556e7374616b65012c46617374556e7374616b651010486561640000810b04000cc0205468652063757272656e74202268656164206f662074686520717565756522206265696e6720756e7374616b65642e00290120546865206865616420696e20697473656c662063616e2062652061206261746368206f6620757020746f205b60436f6e6669673a3a426174636853697a65605d207374616b6572732e14517565756500010405001804000cc020546865206d6170206f6620616c6c206163636f756e74732077697368696e6720746f20626520756e7374616b65642e003901204b6565707320747261636b206f6620604163636f756e744964602077697368696e6720746f20756e7374616b6520616e64206974277320636f72726573706f6e64696e67206465706f7369742e3c436f756e746572466f725175657565010010100000000004ac436f756e74657220666f72207468652072656c6174656420636f756e7465642073746f72616765206d61704c45726173546f436865636b506572426c6f636b0100101000000000208c204e756d626572206f66206572617320746f20636865636b2070657220626c6f636b2e0035012049662073657420746f20302c20746869732070616c6c657420646f6573206162736f6c7574656c79206e6f7468696e672e2043616e6e6f742062652073657420746f206d6f7265207468616e90205b60436f6e6669673a3a4d617845726173546f436865636b506572426c6f636b605d2e006501204261736564206f6e2074686520616d6f756e74206f662077656967687420617661696c61626c65206174205b6050616c6c65743a3a6f6e5f69646c65605d2c20757020746f2074686973206d616e792065726173206172655d0120636865636b65642e2054686520636865636b696e6720697320726570726573656e746564206279207570646174696e67205b60556e7374616b65526571756573743a3a636865636b6564605d2c207768696368206973502073746f72656420696e205b6048656164605d2e01fd0401c907041c4465706f736974184000e40b54020000000000000000000000086501204465706f73697420746f2074616b6520666f7220756e7374616b696e672c20746f206d616b6520737572652077652772652061626c6520746f20736c6173682074686520697420696e206f7264657220746f20636f766572c02074686520636f737473206f66207265736f7572636573206f6e20756e7375636365737366756c20756e7374616b652e018d0b28004050617261636861696e734f726967696e000000000032107901205468657265206973206e6f2077617920746f20726567697374657220616e206f726967696e207479706520696e2060636f6e7374727563745f72756e74696d656020776974686f757420612070616c6c657420746865206f726967696e302062656c6f6e677320746f2e0075012054686973206d6f64756c652066756c66696c6c73206f6e6c79207468652073696e676c6520707572706f7365206f6620686f7573696e672074686520604f726967696e6020696e2060636f6e7374727563745f72756e74696d65602e34436f6e66696775726174696f6e0134436f6e66696775726174696f6e0c30416374697665436f6e6669670100910b41030000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001027000080b2e60e80c3c9018096980000000000000000000000000005000000010000000100000001000000000006000000640000000100000000000000000000000000000000000000020000000200000002000000000100000004c8205468652061637469766520636f6e66696775726174696f6e20666f72207468652063757272656e742073657373696f6e2e3850656e64696e67436f6e666967730100950b04001c7c2050656e64696e6720636f6e66696775726174696f6e206368616e6765732e00590120546869732069732061206c697374206f6620636f6e66696775726174696f6e206368616e6765732c2065616368207769746820612073657373696f6e20696e6465782061742077686963682069742073686f756c6430206265206170706c6965642e00610120546865206c69737420697320736f7274656420617363656e64696e672062792073657373696f6e20696e6465782e20416c736f2c2074686973206c6973742063616e206f6e6c7920636f6e7461696e206174206d6f7374fc2032206974656d733a20666f7220746865206e6578742073657373696f6e20616e6420666f722074686520607363686564756c65645f73657373696f6e602e58427970617373436f6e73697374656e6379436865636b01007804000861012049662074686973206973207365742c207468656e2074686520636f6e66696775726174696f6e20736574746572732077696c6c206279706173732074686520636f6e73697374656e637920636865636b732e2054686973b4206973206d65616e7420746f2062652075736564206f6e6c7920617320746865206c617374207265736f72742e0101050000019d0b33002c5061726173536861726564012c5061726173536861726564104c43757272656e7453657373696f6e496e6465780100101000000000046c205468652063757272656e742073657373696f6e20696e6465782e5841637469766556616c696461746f72496e64696365730100a10b040008090120416c6c207468652076616c696461746f7273206163746976656c792070617274696369706174696e6720696e2070617261636861696e20636f6e73656e7375732eb020496e64696365732061726520696e746f207468652062726f616465722076616c696461746f72207365742e4c41637469766556616c696461746f724b6579730100a50b0400085501205468652070617261636861696e206174746573746174696f6e206b657973206f66207468652076616c696461746f7273206163746976656c792070617274696369706174696e6720696e2070617261636861696e1d0120636f6e73656e7375732e20546869732073686f756c64206265207468652073616d65206c656e677468206173206041637469766556616c696461746f72496e6469636573602e4c416c6c6f77656452656c6179506172656e74730100a90b140000000000046c20416c6c20616c6c6f7765642072656c61792d706172656e74732e01210500000034003450617261496e636c7573696f6e013450617261496e636c7573696f6e0c54417661696c6162696c6974794269746669656c6473000104054505b50b040004650120546865206c6174657374206269746669656c6420666f7220656163682076616c696461746f722c20726566657272656420746f20627920746865697220696e64657820696e207468652076616c696461746f72207365742e4c50656e64696e67417661696c6162696c69747900010405b902b90b040004b42043616e646964617465732070656e64696e6720617661696c6162696c6974792062792060506172614964602e7850656e64696e67417661696c6162696c697479436f6d6d69746d656e747300010405b902690504000405012054686520636f6d6d69746d656e7473206f662063616e646964617465732070656e64696e6720617661696c6162696c6974792c2062792060506172614964602e01250501cd070001bd0b35003050617261496e686572656e74013050617261496e686572656e740820496e636c7564656400008c040018ec20576865746865722074686520706172617320696e686572656e742077617320696e636c756465642077697468696e207468697320626c6f636b2e0069012054686520604f7074696f6e3c28293e60206973206566666563746976656c7920612060626f6f6c602c20627574206974206e6576657220686974732073746f7261676520696e2074686520604e6f6e65602076617269616e74bc2064756520746f207468652067756172616e74656573206f66204652414d4527732073746f7261676520415049732e004901204966207468697320697320604e6f6e65602061742074686520656e64206f662074686520626c6f636b2c2077652070616e696320616e642072656e6465722074686520626c6f636b20696e76616c69642e304f6e436861696e566f7465730000c10b04000445012053637261706564206f6e20636861696e206461746120666f722065787472616374696e67207265736f6c7665642064697370757465732061732077656c6c206173206261636b696e6720766f7465732e012905000001d50b360034506172615363686564756c65720134506172615363686564756c6572103c56616c696461746f7247726f7570730100d90b04001c6d0120416c6c207468652076616c696461746f722067726f7570732e204f6e6520666f72206561636820636f72652e20496e64696365732061726520696e746f206041637469766556616c696461746f727360202d206e6f74207468656d012062726f6164657220736574206f6620506f6c6b61646f742076616c696461746f72732c2062757420696e7374656164206a7573742074686520737562736574207573656420666f722070617261636861696e7320647572696e673820746869732073657373696f6e2e00490120426f756e643a20546865206e756d626572206f6620636f726573206973207468652073756d206f6620746865206e756d62657273206f662070617261636861696e7320616e6420706172617468726561646901206d756c7469706c65786572732e20526561736f6e61626c792c203130302d313030302e2054686520646f6d696e616e7420666163746f7220697320746865206e756d626572206f662076616c696461746f72733a20736166655020757070657220626f756e642061742031306b2e44417661696c6162696c697479436f7265730100dd0b0400205901204f6e6520656e74727920666f72206561636820617661696c6162696c69747920636f72652e20456e74726965732061726520604e6f6e65602069662074686520636f7265206973206e6f742063757272656e746c790d01206f636375706965642e2043616e2062652074656d706f726172696c792060536f6d6560206966207363686564756c656420627574206e6f74206f636375706965642e41012054686520692774682070617261636861696e2062656c6f6e677320746f20746865206927746820636f72652c2077697468207468652072656d61696e696e6720636f72657320616c6c206265696e676420706172617468726561642d6d756c7469706c65786572732e00d820426f756e64656420627920746865206d6178696d756d206f6620656974686572206f662074686573652074776f2076616c7565733ae42020202a20546865206e756d626572206f662070617261636861696e7320616e642070617261746872656164206d756c7469706c657865727345012020202a20546865206e756d626572206f662076616c696461746f727320646976696465642062792060636f6e66696775726174696f6e2e6d61785f76616c696461746f72735f7065725f636f7265602e4453657373696f6e5374617274426c6f636b01001010000000001c69012054686520626c6f636b206e756d626572207768657265207468652073657373696f6e207374617274206f636375727265642e205573656420746f20747261636b20686f77206d616e792067726f757020726f746174696f6e733c2068617665206f636375727265642e005501204e6f7465207468617420696e2074686520636f6e74657874206f662070617261636861696e73206d6f64756c6573207468652073657373696f6e206368616e6765206973207369676e616c656420647572696e6761012074686520626c6f636b20616e6420656e61637465642061742074686520656e64206f662074686520626c6f636b20286174207468652066696e616c697a6174696f6e2073746167652c20746f206265206578616374292e5901205468757320666f7220616c6c20696e74656e747320616e6420707572706f7365732074686520656666656374206f66207468652073657373696f6e206368616e6765206973206f6273657276656420617420746865650120626c6f636b20666f6c6c6f77696e67207468652073657373696f6e206368616e67652c20626c6f636b206e756d626572206f66207768696368207765207361766520696e20746869732073746f726167652076616c75652e28436c61696d51756575650100ed0b0400145901204f6e6520656e74727920666f72206561636820617661696c6162696c69747920636f72652e20546865206056656344657175656020726570726573656e7473207468652061737369676e6d656e747320746f2062656d01207363686564756c6564206f6e207468617420636f72652e20604e6f6e6560206973207573656420746f207369676e616c20746f206e6f74207363686564756c6520746865206e6578742070617261206f662074686520636f72655501206173207468657265206973206f6e652063757272656e746c79206265696e67207363686564756c65642e204e6f74207573696e6720604e6f6e6560206865726520776f756c64206f76657277726974652074686571012060436f726553746174656020696e207468652072756e74696d65204150492e205468652076616c756520636f6e7461696e656420686572652077696c6c206e6f742062652076616c69642061667465722074686520656e64206f666d01206120626c6f636b2e2052756e74696d6520415049732073686f756c64206265207573656420746f2064657465726d696e65207363686564756c656420636f7265732f20666f7220746865207570636f6d696e6720626c6f636b2e000000003700145061726173011450617261735040507666416374697665566f74654d6170000104056505fd0b040010b420416c6c2063757272656e746c792061637469766520505646207072652d636865636b696e6720766f7465732e002c20496e76617269616e743a7501202d20546865726520617265206e6f20505646207072652d636865636b696e6720766f74657320746861742065786973747320696e206c69737420627574206e6f7420696e207468652073657420616e6420766963652076657273612e44507666416374697665566f74654c69737401000d0c040004350120546865206c697374206f6620616c6c2063757272656e746c79206163746976652050564620766f7465732e20417578696c6961727920746f2060507666416374697665566f74654d6170602e2850617261636861696e730100110c040010690120416c6c206c6561736520686f6c64696e672070617261636861696e732e204f72646572656420617363656e64696e672062792060506172614964602e204f6e2064656d616e642070617261636861696e7320617265206e6f742820696e636c756465642e00e820436f6e7369646572207573696e6720746865205b6050617261636861696e734361636865605d2074797065206f66206d6f64696679696e672e38506172614c6966656379636c657300010405b902150c040004bc205468652063757272656e74206c6966656379636c65206f66206120616c6c206b6e6f776e2050617261204944732e14486561647300010405b9028505040004a02054686520686561642d64617461206f66206576657279207265676973746572656420706172612e444d6f7374526563656e74436f6e7465787400010405b9021004000429012054686520636f6e74657874202872656c61792d636861696e20626c6f636b206e756d62657229206f6620746865206d6f737420726563656e742070617261636861696e20686561642e3c43757272656e74436f64654861736800010405b902650504000cb4205468652076616c69646174696f6e20636f64652068617368206f66206576657279206c69766520706172612e00e420436f72726573706f6e64696e6720636f64652063616e206265207265747269657665642077697468205b60436f6465427948617368605d2e3050617374436f64654861736800010405190c650504001061012041637475616c207061737420636f646520686173682c20696e646963617465642062792074686520706172612069642061732077656c6c2061732074686520626c6f636b206e756d6265722061742077686963682069744420626563616d65206f757464617465642e00e420436f72726573706f6e64696e6720636f64652063616e206265207265747269657665642077697468205b60436f6465427948617368605d2e3050617374436f64654d65746101010405b9021d0c0800000c4901205061737420636f6465206f662070617261636861696e732e205468652070617261636861696e73207468656d73656c766573206d6179206e6f74206265207265676973746572656420616e796d6f72652c49012062757420776520616c736f206b65657020746865697220636f6465206f6e2d636861696e20666f72207468652073616d6520616d6f756e74206f662074696d65206173206f7574646174656420636f6465b020746f206b65657020697420617661696c61626c6520666f7220617070726f76616c20636865636b6572732e3c50617374436f64655072756e696e670100290c04001869012057686963682070617261732068617665207061737420636f64652074686174206e65656473207072756e696e6720616e64207468652072656c61792d636861696e20626c6f636b2061742077686963682074686520636f6465690120776173207265706c616365642e204e6f746520746861742074686973206973207468652061637475616c20686569676874206f662074686520696e636c7564656420626c6f636b2c206e6f74207468652065787065637465643d01206865696768742061742077686963682074686520636f6465207570677261646520776f756c64206265206170706c6965642c20616c74686f7567682074686579206d617920626520657175616c2e6d01205468697320697320746f20656e737572652074686520656e7469726520616363657074616e636520706572696f6420697320636f76657265642c206e6f7420616e206f666673657420616363657074616e636520706572696f646d01207374617274696e672066726f6d207468652074696d65206174207768696368207468652070617261636861696e20706572636569766573206120636f6465207570677261646520617320686176696e67206f636375727265642e5501204d756c7469706c6520656e747269657320666f7220612073696e676c65207061726120617265207065726d69747465642e204f72646572656420617363656e64696e6720627920626c6f636b206e756d6265722e48467574757265436f6465557067726164657300010405b9021004000c29012054686520626c6f636b206e756d6265722061742077686963682074686520706c616e6e656420636f6465206368616e676520697320657870656374656420666f72206120706172612e650120546865206368616e67652077696c6c206265206170706c696564206166746572207468652066697273742070617261626c6f636b20666f72207468697320494420696e636c75646564207768696368206578656375746573190120696e2074686520636f6e74657874206f6620612072656c617920636861696e20626c6f636b20776974682061206e756d626572203e3d206065787065637465645f6174602e38467574757265436f64654861736800010405b902650504000c9c205468652061637475616c2066757475726520636f64652068617368206f66206120706172612e00e420436f72726573706f6e64696e6720636f64652063616e206265207265747269657665642077697468205b60436f6465427948617368605d2e5055706772616465476f41686561645369676e616c00010405b9022d0c040028750120546869732069732075736564206279207468652072656c61792d636861696e20746f20636f6d6d756e696361746520746f20612070617261636861696e206120676f2d6168656164207769746820696e2074686520757067726164652c2070726f6365647572652e00750120546869732076616c756520697320616273656e74207768656e20746865726520617265206e6f207570677261646573207363686564756c6564206f7220647572696e67207468652074696d65207468652072656c617920636861696e550120706572666f726d732074686520636865636b732e20497420697320736574206174207468652066697273742072656c61792d636861696e20626c6f636b207768656e2074686520636f72726573706f6e64696e6775012070617261636861696e2063616e207377697463682069747320757067726164652066756e6374696f6e2e20417320736f6f6e206173207468652070617261636861696e277320626c6f636b20697320696e636c756465642c20746865702076616c7565206765747320726573657420746f20604e6f6e65602e006501204e4f544520746861742074686973206669656c6420697320757365642062792070617261636861696e7320766961206d65726b6c652073746f726167652070726f6f66732c207468657265666f7265206368616e67696e67c42074686520666f726d61742077696c6c2072657175697265206d6967726174696f6e206f662070617261636861696e732e60557067726164655265737472696374696f6e5369676e616c00010405b902310c040024690120546869732069732075736564206279207468652072656c61792d636861696e20746f20636f6d6d756e6963617465207468617420746865726520617265207265737472696374696f6e7320666f7220706572666f726d696e677c20616e207570677261646520666f7220746869732070617261636861696e2e0059012054686973206d617920626520612062656361757365207468652070617261636861696e20776169747320666f7220746865207570677261646520636f6f6c646f776e20746f206578706972652e20416e6f746865726d0120706f74656e7469616c207573652063617365206973207768656e2077652077616e7420746f20706572666f726d20736f6d65206d61696e74656e616e63652028737563682061732073746f72616765206d6967726174696f6e29e020776520636f756c6420726573747269637420757067726164657320746f206d616b65207468652070726f636573732073696d706c65722e006501204e4f544520746861742074686973206669656c6420697320757365642062792070617261636861696e7320766961206d65726b6c652073746f726167652070726f6f66732c207468657265666f7265206368616e67696e67c42074686520666f726d61742077696c6c2072657175697265206d6967726174696f6e206f662070617261636861696e732e4055706772616465436f6f6c646f776e730100290c04000c510120546865206c697374206f662070617261636861696e73207468617420617265206177616974696e6720666f722074686569722075706772616465207265737472696374696f6e20746f20636f6f6c646f776e2e008c204f72646572656420617363656e64696e6720627920626c6f636b206e756d6265722e405570636f6d696e6755706772616465730100290c040010590120546865206c697374206f66207570636f6d696e6720636f64652075706772616465732e2045616368206974656d20697320612070616972206f66207768696368207061726120706572666f726d73206120636f6465e8207570677261646520616e642061742077686963682072656c61792d636861696e20626c6f636b2069742069732065787065637465642061742e008c204f72646572656420617363656e64696e6720627920626c6f636b206e756d6265722e30416374696f6e7351756575650101040510110c04000415012054686520616374696f6e7320746f20706572666f726d20647572696e6720746865207374617274206f6620612073706563696669632073657373696f6e20696e6465782e505570636f6d696e67506172617347656e6573697300010405b902350c040010a0205570636f6d696e6720706172617320696e7374616e74696174696f6e20617267756d656e74732e006501204e4f5445207468617420616674657220505646207072652d636865636b696e6720697320656e61626c65642074686520706172612067656e65736973206172672077696c6c2068617665206974277320636f646520736574610120746f20656d7074792e20496e73746561642c2074686520636f64652077696c6c20626520736176656420696e746f207468652073746f726167652072696768742061776179207669612060436f6465427948617368602e38436f64654279486173685265667301010406650510100000000004290120546865206e756d626572206f66207265666572656e6365206f6e207468652076616c69646174696f6e20636f646520696e205b60436f6465427948617368605d2073746f726167652e28436f64654279486173680001040665058105040010902056616c69646174696f6e20636f64652073746f7265642062792069747320686173682e00310120546869732073746f7261676520697320636f6e73697374656e742077697468205b60467574757265436f646548617368605d2c205b6043757272656e74436f646548617368605d20616e6448205b6050617374436f646548617368605d2e01b50501dd070440556e7369676e65645072696f726974792c20ffffffffffffffff0001390c38002c496e697469616c697a6572012c496e697469616c697a65720838486173496e697469616c697a656400008c04002021012057686574686572207468652070617261636861696e73206d6f64756c65732068617665206265656e20696e697469616c697a65642077697468696e207468697320626c6f636b2e0025012053656d616e746963616c6c7920612060626f6f6c602c2062757420746869732067756172616e746565732069742073686f756c64206e65766572206869742074686520747269652c6901206173207468697320697320636c656172656420696e20606f6e5f66696e616c697a656020616e64204672616d65206f7074696d697a657320604e6f6e65602076616c75657320746f20626520656d7074792076616c7565732e00710120417320612060626f6f6c602c20607365742866616c7365296020616e64206072656d6f766528296020626f7468206c65616420746f20746865206e6578742060676574282960206265696e672066616c73652c20627574206f6e657501206f66207468656d2077726974657320746f20746865207472696520616e64206f6e6520646f6573206e6f742e205468697320636f6e667573696f6e206d616b657320604f7074696f6e3c28293e60206d6f7265207375697461626c659020666f72207468652073656d616e74696373206f662074686973207661726961626c652e58427566666572656453657373696f6e4368616e67657301003d0c04001c59012042756666657265642073657373696f6e206368616e67657320616c6f6e6720776974682074686520626c6f636b206e756d62657220617420776869636820746865792073686f756c64206265206170706c6965642e005d01205479706963616c6c7920746869732077696c6c20626520656d707479206f72206f6e6520656c656d656e74206c6f6e672e2041706172742066726f6d20746861742074686973206974656d206e65766572206869747334207468652073746f726167652e00690120486f776576657220746869732069732061206056656360207265676172646c65737320746f2068616e646c6520766172696f757320656467652063617365732074686174206d6179206f636375722061742072756e74696d65c0207570677261646520626f756e646172696573206f7220696620676f7665726e616e636520696e74657276656e65732e01bd0500000039000c446d70010c446d700c54446f776e776172644d65737361676551756575657301010405b902450c040004d02054686520646f776e77617264206d657373616765732061646472657373656420666f722061206365727461696e20706172612e64446f776e776172644d6573736167655175657565486561647301010405b902308000000000000000000000000000000000000000000000000000000000000000001c25012041206d617070696e6720746861742073746f7265732074686520646f776e77617264206d657373616765207175657565204d5143206865616420666f72206561636820706172612e00902045616368206c696e6b20696e207468697320636861696e20686173206120666f726d3a78206028707265765f686561642c20422c2048284d2929602c207768657265e8202d2060707265765f68656164603a206973207468652070726576696f757320686561642068617368206f72207a65726f206966206e6f6e652e2101202d206042603a206973207468652072656c61792d636861696e20626c6f636b206e756d62657220696e2077686963682061206d6573736167652077617320617070656e6465642ed4202d206048284d29603a206973207468652068617368206f6620746865206d657373616765206265696e6720617070656e6465642e4444656c6976657279466565466163746f7201010405b9024d0740000064a7b3b6e00d000000000000000004c42054686520666163746f7220746f206d756c7469706c792074686520626173652064656c6976657279206665652062792e000000003a001048726d70011048726d70305c48726d704f70656e4368616e6e656c526571756573747300010405c5054d0c040018bc2054686520736574206f662070656e64696e672048524d50206f70656e206368616e6e656c2072657175657374732e00c02054686520736574206973206163636f6d70616e6965642062792061206c69737420666f7220697465726174696f6e2e002c20496e76617269616e743a3d01202d20546865726520617265206e6f206368616e6e656c7320746861742065786973747320696e206c69737420627574206e6f7420696e207468652073657420616e6420766963652076657273612e6c48726d704f70656e4368616e6e656c52657175657374734c6973740100510c0400006c48726d704f70656e4368616e6e656c52657175657374436f756e7401010405b9021010000000000c65012054686973206d617070696e6720747261636b7320686f77206d616e79206f70656e206368616e6e656c2072657175657374732061726520696e69746961746564206279206120676976656e2073656e64657220706172612e590120496e76617269616e743a206048726d704f70656e4368616e6e656c5265717565737473602073686f756c6420636f6e7461696e207468652073616d65206e756d626572206f66206974656d732074686174206861730501206028582c205f296020617320746865206e756d626572206f66206048726d704f70656e4368616e6e656c52657175657374436f756e746020666f72206058602e7c48726d7041636365707465644368616e6e656c52657175657374436f756e7401010405b9021010000000000c71012054686973206d617070696e6720747261636b7320686f77206d616e79206f70656e206368616e6e656c2072657175657374732077657265206163636570746564206279206120676976656e20726563697069656e7420706172612e6d0120496e76617269616e743a206048726d704f70656e4368616e6e656c5265717565737473602073686f756c6420636f6e7461696e207468652073616d65206e756d626572206f66206974656d732060285f2c20582960207769746855012060636f6e6669726d6564602073657420746f20747275652c20617320746865206e756d626572206f66206048726d7041636365707465644368616e6e656c52657175657374436f756e746020666f72206058602e6048726d70436c6f73654368616e6e656c526571756573747300010405c5058c04001c7101204120736574206f662070656e64696e672048524d5020636c6f7365206368616e6e656c20726571756573747320746861742061726520676f696e6720746f20626520636c6f73656420647572696e67207468652073657373696f6e2101206368616e67652e205573656420666f7220636865636b696e67206966206120676976656e206368616e6e656c206973207265676973746572656420666f7220636c6f737572652e00c02054686520736574206973206163636f6d70616e6965642062792061206c69737420666f7220697465726174696f6e2e002c20496e76617269616e743a3d01202d20546865726520617265206e6f206368616e6e656c7320746861742065786973747320696e206c69737420627574206e6f7420696e207468652073657420616e6420766963652076657273612e7048726d70436c6f73654368616e6e656c52657175657374734c6973740100510c0400003848726d7057617465726d61726b7300010405b90210040010b8205468652048524d502077617465726d61726b206173736f6369617465642077697468206561636820706172612e2c20496e76617269616e743a5501202d2065616368207061726120605060207573656420686572652061732061206b65792073686f756c642073617469736679206050617261733a3a69735f76616c69645f70617261285029602077697468696e20612c20202073657373696f6e2e3048726d704368616e6e656c7300010405c505550c04000cb42048524d50206368616e6e656c2064617461206173736f6369617465642077697468206561636820706172612e2c20496e76617269616e743a7501202d2065616368207061727469636970616e7420696e20746865206368616e6e656c2073686f756c642073617469736679206050617261733a3a69735f76616c69645f70617261285029602077697468696e20612073657373696f6e2e6048726d70496e67726573734368616e6e656c73496e64657801010405b902110c040034710120496e67726573732f65677265737320696e646578657320616c6c6f7720746f2066696e6420616c6c207468652073656e6465727320616e642072656365697665727320676976656e20746865206f70706f7369746520736964652e1420492e652e0021012028612920696e677265737320696e64657820616c6c6f777320746f2066696e6420616c6c207468652073656e6465727320666f72206120676976656e20726563697069656e742e1d01202862292065677265737320696e64657820616c6c6f777320746f2066696e6420616c6c2074686520726563697069656e747320666f72206120676976656e2073656e6465722e003020496e76617269616e74733a5101202d20666f72206561636820696e677265737320696e64657820656e74727920666f72206050602065616368206974656d2060496020696e2074686520696e6465782073686f756c642070726573656e7420696e782020206048726d704368616e6e656c7360206173206028492c205029602e4d01202d20666f7220656163682065677265737320696e64657820656e74727920666f72206050602065616368206974656d2060456020696e2074686520696e6465782073686f756c642070726573656e7420696e782020206048726d704368616e6e656c7360206173206028502c204529602e0101202d2074686572652073686f756c64206265206e6f206f746865722064616e676c696e67206368616e6e656c7320696e206048726d704368616e6e656c73602e68202d2074686520766563746f72732061726520736f727465642e5c48726d704567726573734368616e6e656c73496e64657801010405b902110c0400004c48726d704368616e6e656c436f6e74656e747301010405c505590c040008ac2053746f7261676520666f7220746865206d6573736167657320666f722065616368206368616e6e656c2e650120496e76617269616e743a2063616e6e6f74206265206e6f6e2d656d7074792069662074686520636f72726573706f6e64696e67206368616e6e656c20696e206048726d704368616e6e656c736020697320604e6f6e65602e4848726d704368616e6e656c4469676573747301010405b902610c0400186901204d61696e7461696e732061206d617070696e6720746861742063616e206265207573656420746f20616e7377657220746865207175657374696f6e3a20576861742070617261732073656e742061206d657373616765206174e42074686520676976656e20626c6f636b206e756d62657220666f72206120676976656e2072656365697665722e20496e76617269616e74733aa8202d2054686520696e6e657220605665633c5061726149643e60206973206e6576657220656d7074792ee8202d2054686520696e6e657220605665633c5061726149643e602063616e6e6f742073746f72652074776f2073616d652060506172614964602e6d01202d20546865206f7574657220766563746f7220697320736f7274656420617363656e64696e6720627920626c6f636b206e756d62657220616e642063616e6e6f742073746f72652074776f206974656d732077697468207468655420202073616d6520626c6f636b206e756d6265722e01c10501e1070001690c3c003c5061726153657373696f6e496e666f013c5061726153657373696f6e496e666f145041737369676e6d656e744b657973556e7361666501006d0c04000ca42041737369676e6d656e74206b65797320666f72207468652063757272656e742073657373696f6e2e6d01204e6f7465207468617420746869732041504920697320707269766174652064756520746f206974206265696e672070726f6e6520746f20276f66662d62792d6f6e65272061742073657373696f6e20626f756e6461726965732eac205768656e20696e20646f7562742c20757365206053657373696f6e73602041504920696e73746561642e544561726c6965737453746f72656453657373696f6e010010100000000004010120546865206561726c696573742073657373696f6e20666f722077686963682070726576696f75732073657373696f6e20696e666f2069732073746f7265642e2053657373696f6e730001040610710c04000ca42053657373696f6e20696e666f726d6174696f6e20696e206120726f6c6c696e672077696e646f772e35012053686f756c64206861766520616e20656e74727920696e2072616e676520604561726c6965737453746f72656453657373696f6e2e2e3d43757272656e7453657373696f6e496e646578602e750120446f6573206e6f74206861766520616e7920656e7472696573206265666f7265207468652073657373696f6e20696e64657820696e207468652066697273742073657373696f6e206368616e6765206e6f74696669636174696f6e2e2c4163636f756e744b6579730001040610f5010400047101205468652076616c696461746f72206163636f756e74206b657973206f66207468652076616c696461746f7273206163746976656c792070617274696369706174696e6720696e2070617261636861696e20636f6e73656e7375732e5453657373696f6e4578656375746f72506172616d7300010406100905040004c4204578656375746f7220706172616d657465722073657420666f72206120676976656e2073657373696f6e20696e646578000000003d00345061726173446973707574657301345061726173446973707574657314444c6173745072756e656453657373696f6e000010040008010120546865206c617374207072756e65642073657373696f6e2c20696620616e792e20416c6c20646174612073746f7265642062792074686973206d6f64756c6554207265666572656e6365732073657373696f6e732e20446973707574657300010805027d0c810c040004050120416c6c206f6e676f696e67206f7220636f6e636c7564656420646973707574657320666f7220746865206c617374207365766572616c2073657373696f6e732e444261636b6572734f6e446973707574657300010805027d0c850c0400089c204261636b696e6720766f7465732073746f72656420666f72206561636820646973707574652e8c20546869732073746f72616765206973207573656420666f7220736c617368696e672e20496e636c7564656400010805027d0c10040008450120416c6c20696e636c7564656420626c6f636b73206f6e2074686520636861696e2c2061732077656c6c2061732074686520626c6f636b206e756d62657220696e207468697320636861696e207468617459012073686f756c64206265207265766572746564206261636b20746f206966207468652063616e64696461746520697320646973707574656420616e642064657465726d696e656420746f20626520696e76616c69642e1846726f7a656e01008d02040010110120576865746865722074686520636861696e2069732066726f7a656e2e2053746172747320617320604e6f6e65602e205768656e20746869732069732060536f6d65602c35012074686520636861696e2077696c6c206e6f742061636365707420616e79206e65772070617261636861696e20626c6f636b7320666f72206261636b696e67206f7220696e636c7573696f6e2c090120616e64206974732076616c756520696e6469636174657320746865206c6173742076616c696420626c6f636b206e756d62657220696e2074686520636861696e2ef82049742063616e206f6e6c7920626520736574206261636b20746f20604e6f6e656020627920676f7665726e616e636520696e74657276656e74696f6e2e01c90501e5070001890c3e00345061726173536c617368696e6701345061726173536c617368696e670840556e6170706c696564536c617368657300010805027d0c8d0c040004902056616c696461746f72732070656e64696e67206469737075746520736c61736865732e4856616c696461746f72536574436f756e747300010405101004000484206056616c696461746f72536574436f756e7460207065722073657373696f6e2e01cd050000019d0c3f00585061726141737369676e6d656e7450726f7669646572000000000040002452656769737472617201245265676973747261720c2c50656e64696e675377617000010405b902b902040004642050656e64696e672073776170206f7065726174696f6e732e14506172617300010405b902a10c040010050120416d6f756e742068656c64206f6e206465706f73697420666f722065616368207061726120616e6420746865206f726967696e616c206465706f7369746f722e0071012054686520676976656e206163636f756e7420494420697320726573706f6e7369626c6520666f72207265676973746572696e672074686520636f646520616e6420696e697469616c206865616420646174612c20627574206d61795501206f6e6c7920646f20736f2069662069742069736e27742079657420726567697374657265642e2028416674657220746861742c206974277320757020746f20676f7665726e616e636520746f20646f20736f2e29384e657874467265655061726149640100b9021000000000046020546865206e65787420667265652060506172614964602e01dd0501f107082c506172614465706f73697418400010a5d4e8000000000000000000000008d420546865206465706f73697420746f206265207061696420746f2072756e2061206f6e2d64656d616e642070617261636861696e2e3d0120546869732073686f756c6420696e636c7564652074686520636f737420666f722073746f72696e67207468652067656e65736973206865616420616e642076616c69646174696f6e20636f64652e48446174614465706f7369745065724279746518408096980000000000000000000000000004c420546865206465706f73697420746f20626520706169642070657220627974652073746f726564206f6e20636861696e2e01a90c460014536c6f74730114536c6f747304184c656173657301010405b902ad0c040040150120416d6f756e74732068656c64206f6e206465706f73697420666f7220656163682028706f737369626c792066757475726529206c65617365642070617261636861696e2e006101205468652061637475616c20616d6f756e74206c6f636b6564206f6e2069747320626568616c6620627920616e79206163636f756e7420617420616e792074696d6520697320746865206d6178696d756d206f66207468652901207365636f6e642076616c756573206f6620746865206974656d7320696e2074686973206c6973742077686f73652066697273742076616c756520697320746865206163636f756e742e00610120546865206669727374206974656d20696e20746865206c6973742069732074686520616d6f756e74206c6f636b656420666f72207468652063757272656e74204c6561736520506572696f642e20466f6c6c6f77696e67b0206974656d732061726520666f72207468652073756273657175656e74206c6561736520706572696f64732e006101205468652064656661756c742076616c75652028616e20656d707479206c6973742920696d706c6965732074686174207468652070617261636861696e206e6f206c6f6e6765722065786973747320286f72206e65766572b42065786973746564292061732066617220617320746869732070616c6c657420697320636f6e6365726e65642e00510120496620612070617261636861696e20646f65736e2774206578697374202a7965742a20627574206973207363686564756c656420746f20657869737420696e20746865206675747572652c207468656e20697461012077696c6c206265206c6566742d7061646465642077697468206f6e65206f72206d6f726520604e6f6e65607320746f2064656e6f74652074686520666163742074686174206e6f7468696e672069732068656c64206f6e5d01206465706f73697420666f7220746865206e6f6e2d6578697374656e7420636861696e2063757272656e746c792c206275742069732068656c6420617420736f6d6520706f696e7420696e20746865206675747572652e00dc20497420697320696c6c6567616c20666f72206120604e6f6e65602076616c756520746f20747261696c20696e20746865206c6973742e01e10501f507082c4c65617365506572696f6410100075120004dc20546865206e756d626572206f6620626c6f636b73206f76657220776869636820612073696e676c6520706572696f64206c617374732e2c4c656173654f6666736574101000100e0004d420546865206e756d626572206f6620626c6f636b7320746f206f66667365742065616368206c6561736520706572696f642062792e01b10c47002041756374696f6e73012041756374696f6e73103841756374696f6e436f756e7465720100101000000000048c204e756d626572206f662061756374696f6e73207374617274656420736f206661722e2c41756374696f6e496e666f000080040014f820496e666f726d6174696f6e2072656c6174696e6720746f207468652063757272656e742061756374696f6e2c206966207468657265206973206f6e652e00450120546865206669727374206974656d20696e20746865207475706c6520697320746865206c6561736520706572696f6420696e646578207468617420746865206669727374206f662074686520666f7572510120636f6e746967756f7573206c6561736520706572696f6473206f6e2061756374696f6e20697320666f722e20546865207365636f6e642069732074686520626c6f636b206e756d626572207768656e207468655d012061756374696f6e2077696c6c2022626567696e20746f20656e64222c20692e652e2074686520666972737420626c6f636b206f662074686520456e64696e6720506572696f64206f66207468652061756374696f6e2e3c5265736572766564416d6f756e747300010405b50c18040008310120416d6f756e74732063757272656e746c7920726573657276656420696e20746865206163636f756e7473206f662074686520626964646572732063757272656e746c792077696e6e696e673820287375622d2972616e6765732e1c57696e6e696e670001040510b90c04000c6101205468652077696e6e696e67206269647320666f722065616368206f66207468652031302072616e67657320617420656163682073616d706c6520696e207468652066696e616c20456e64696e6720506572696f64206f664901207468652063757272656e742061756374696f6e2e20546865206d61702773206b65792069732074686520302d626173656420696e64657820696e746f207468652053616d706c652053697a652e205468651d012066697273742073616d706c65206f662074686520656e64696e6720706572696f6420697320303b20746865206c617374206973206053616d706c652053697a65202d2031602e01e50501f9071030456e64696e67506572696f64101040190100041d0120546865206e756d626572206f6620626c6f636b73206f76657220776869636820616e2061756374696f6e206d617920626520726574726f6163746976656c7920656e6465642e3053616d706c654c656e6774681010140000000cf020546865206c656e677468206f6620656163682073616d706c6520746f2074616b6520647572696e672074686520656e64696e6720706572696f642e00d42060456e64696e67506572696f6460202f206053616d706c654c656e67746860203d20546f74616c2023206f662053616d706c657338536c6f7452616e6765436f756e74101024000000004c4c65617365506572696f6473506572536c6f741010080000000001c50c48002443726f77646c6f616e012443726f77646c6f616e101446756e647300010405b902c90c0400046820496e666f206f6e20616c6c206f66207468652066756e64732e204e657752616973650100110c0400085501205468652066756e64732074686174206861766520686164206164646974696f6e616c20636f6e747269627574696f6e7320647572696e6720746865206c61737420626c6f636b2e20546869732069732075736564150120696e206f7264657220746f2064657465726d696e652077686963682066756e64732073686f756c64207375626d6974206e6577206f72207570646174656420626964732e30456e64696e6773436f756e74010010100000000004290120546865206e756d626572206f662061756374696f6e732074686174206861766520656e746572656420696e746f20746865697220656e64696e6720706572696f6420736f206661722e344e65787446756e64496e646578010010100000000004a820547261636b657220666f7220746865206e65787420617661696c61626c652066756e6420696e64657801ed0501fd070c2050616c6c65744964d1092070792f6366756e64080d01206050616c6c657449646020666f72207468652063726f77646c6f616e2070616c6c65742e20416e20617070726f7072696174652076616c756520636f756c6420626564206050616c6c65744964282a622270792f6366756e642229603c4d696e436f6e747269627574696f6e184000743ba40b000000000000000000000008610120546865206d696e696d756d20616d6f756e742074686174206d617920626520636f6e747269627574656420696e746f20612063726f77646c6f616e2e2053686f756c6420616c6d6f7374206365727461696e6c792062657c206174206c6561737420604578697374656e7469616c4465706f736974602e3c52656d6f76654b6579734c696d69741010e803000004e4204d6178206e756d626572206f662073746f72616765206b65797320746f2072656d6f7665207065722065787472696e7369632063616c6c2e01d10c4900485374617465547269654d6967726174696f6e01485374617465547269654d6967726174696f6e0c404d6967726174696f6e50726f63657373010005063800000000000000000000000000001050204d6967726174696f6e2070726f67726573732e005d0120546869732073746f7265732074686520736e617073686f74206f6620746865206c617374206d69677261746564206b6579732e2049742063616e2062652073657420696e746f206d6f74696f6e20616e64206d6f7665d420666f727761726420627920616e79206f6620746865206d65616e732070726f766964656420627920746869732070616c6c65742e284175746f4c696d6974730100fd0504000cd420546865206c696d69747320746861742061726520696d706f736564206f6e206175746f6d61746963206d6967726174696f6e732e00d42049662073657420746f204e6f6e652c207468656e206e6f206175746f6d61746963206d6967726174696f6e2068617070656e732e605369676e65644d6967726174696f6e4d61784c696d6974730000010604000ce020546865206d6178696d756d206c696d697473207468617420746865207369676e6564206d6967726174696f6e20636f756c64207573652e00b4204966206e6f74207365742c206e6f207369676e6564207375626d697373696f6e20697320616c6c6f7765642e01f90501010804244d61784b65794c656e10100002000054b4204d6178696d616c206e756d626572206f6620627974657320746861742061206b65792063616e20686176652e00b0204652414d4520697473656c6620646f6573206e6f74206c696d697420746865206b6579206c656e6774682e01012054686520636f6e63726574652076616c7565206d757374207468657265666f726520646570656e64206f6e20796f75722073746f726167652075736167652e59012041205b606672616d655f737570706f72743a3a73746f726167653a3a53746f726167654e4d6170605d20666f72206578616d706c652063616e206861766520616e20617262697472617279206e756d626572206f664501206b65797320776869636820617265207468656e2068617368656420616e6420636f6e636174656e617465642c20726573756c74696e6720696e206172626974726172696c79206c6f6e67206b6579732e0041012055736520746865202a7374617465206d6967726174696f6e205250432a20746f20726574726965766520746865206c656e677468206f6620746865206c6f6e67657374206b657920696e20796f757201012073746f726167653a203c68747470733a2f2f6769746875622e636f6d2f706172697479746563682f7375627374726174652f6973737565732f31313634323e00290120546865206d6967726174696f6e2077696c6c2068616c7420776974682061206048616c74656460206576656e7420696620746869732076616c756520697320746f6f20736d616c6c2e49012053696e6365207468657265206973206e6f207265616c2070656e616c74792066726f6d206f7665722d657374696d6174696e672c206974206973206164766973656420746f207573652061206c61726765802076616c75652e205468652064656661756c742069732035313220627974652e008020536f6d65206b6579206c656e6774687320666f72207265666572656e63653ad0202d205b606672616d655f737570706f72743a3a73746f726167653a3a53746f7261676556616c7565605d3a2033322062797465c8202d205b606672616d655f737570706f72743a3a73746f726167653a3a53746f726167654d6170605d3a2036342062797465e0202d205b606672616d655f737570706f72743a3a73746f726167653a3a53746f72616765446f75626c654d6170605d3a2039362062797465004820466f72206d6f726520696e666f207365654901203c68747470733a2f2f7777772e736861776e74616272697a692e636f6d2f626c6f672f7375627374726174652f7175657279696e672d7375627374726174652d73746f726167652d7669612d7270632f3e01090862002458636d50616c6c6574012458636d50616c6c657430305175657279436f756e74657201002c200000000000000000048820546865206c617465737420617661696c61626c6520717565727920696e6465782e1c51756572696573000104022cd50c0400045420546865206f6e676f696e6720717565726965732e28417373657454726170730101040630101000000000106820546865206578697374696e672061737365742074726170732e006101204b65792069732074686520626c616b6532203235362068617368206f6620286f726967696e2c2076657273696f6e65642060417373657473602920706169722e2056616c756520697320746865206e756d626572206f661d012074696d65732074686973207061697220686173206265656e20747261707065642028757375616c6c79206a75737420312069662069742065786973747320617420616c6c292e385361666558636d56657273696f6e00001004000861012044656661756c742076657273696f6e20746f20656e636f64652058434d207768656e206c61746573742076657273696f6e206f662064657374696e6174696f6e20697320756e6b6e6f776e2e20496620604e6f6e65602c3d01207468656e207468652064657374696e6174696f6e732077686f73652058434d2076657273696f6e20697320756e6b6e6f776e2061726520636f6e7369646572656420756e726561636861626c652e40537570706f7274656456657273696f6e0001080502e90c10040004f020546865204c61746573742076657273696f6e732074686174207765206b6e6f7720766172696f7573206c6f636174696f6e7320737570706f72742e4056657273696f6e4e6f746966696572730001080502e90c2c040004050120416c6c206c6f636174696f6e7320746861742077652068617665207265717565737465642076657273696f6e206e6f74696669636174696f6e732066726f6d2e5056657273696f6e4e6f74696679546172676574730001080502e90ced0c04000871012054686520746172676574206c6f636174696f6e73207468617420617265207375627363726962656420746f206f75722076657273696f6e206368616e6765732c2061732077656c6c20617320746865206d6f737420726563656e7494206f66206f75722076657273696f6e7320776520696e666f726d6564207468656d206f662e5456657273696f6e446973636f7665727951756575650100f10c04000c65012044657374696e6174696f6e732077686f7365206c61746573742058434d2076657273696f6e20776520776f756c64206c696b6520746f206b6e6f772e204475706c696361746573206e6f7420616c6c6f7765642c20616e6471012074686520607533326020636f756e74657220697320746865206e756d626572206f662074696d6573207468617420612073656e6420746f207468652064657374696e6174696f6e20686173206265656e20617474656d707465642c8c20776869636820697320757365642061732061207072696f726974697a6174696f6e2e4043757272656e744d6967726174696f6e0000fd0c0400049c205468652063757272656e74206d6967726174696f6e27732073746167652c20696620616e792e5452656d6f74654c6f636b656446756e6769626c657300010c050202050d0d0d040004f02046756e6769626c6520617373657473207768696368207765206b6e6f7720617265206c6f636b6564206f6e20612072656d6f746520636861696e2e3c4c6f636b656446756e6769626c657300010402001d0d040004e02046756e6769626c6520617373657473207768696368207765206b6e6f7720617265206c6f636b6564206f6e207468697320636861696e2e5458636d457865637574696f6e53757370656e646564010078040004b420476c6f62616c2073757370656e73696f6e207374617465206f66207468652058434d206578656375746f722e011106010d080001290d6300304d657373616765517565756501304d65737361676551756575650c30426f6f6b5374617465466f720101040541072d0d74000000000000000000000000000000000000000000000000000000000004cc2054686520696e646578206f662074686520666972737420616e64206c61737420286e6f6e2d656d707479292070616765732e2c536572766963654865616400004107040004bc20546865206f726967696e2061742077686963682077652073686f756c6420626567696e20736572766963696e672e1450616765730001080505390d3d0d0400048820546865206d6170206f66207061676520696e646963657320746f2070616765732e013d070115080c204865617053697a65101000000100143d01205468652073697a65206f662074686520706167653b207468697320696d706c69657320746865206d6178696d756d206d6573736167652073697a652077686963682063616e2062652073656e742e005901204120676f6f642076616c756520646570656e6473206f6e20746865206578706563746564206d6573736167652073697a65732c20746865697220776569676874732c207468652077656967687420746861742069735d0120617661696c61626c6520666f722070726f63657373696e67207468656d20616e6420746865206d6178696d616c206e6565646564206d6573736167652073697a652e20546865206d6178696d616c206d65737361676511012073697a6520697320736c696768746c79206c6f776572207468616e207468697320617320646566696e6564206279205b604d61784d6573736167654c656e4f66605d2e204d61785374616c651010080000000c5d0120546865206d6178696d756d206e756d626572206f66207374616c652070616765732028692e652e206f66206f766572776569676874206d657373616765732920616c6c6f776564206265666f72652063756c6c696e6751012063616e2068617070656e2e204f6e636520746865726520617265206d6f7265207374616c65207061676573207468616e20746869732c207468656e20686973746f726963616c207061676573206d6179206265fc2064726f707065642c206576656e206966207468657920636f6e7461696e20756e70726f636573736564206f766572776569676874206d657373616765732e3453657276696365576569676874890740010700a0db215d1333333333333333331441012054686520616d6f756e74206f66207765696768742028696620616e79292077686963682073686f756c642062652070726f766964656420746f20746865206d65737361676520717565756520666f726820736572766963696e6720656e717565756564206974656d732e00fc2054686973206d6179206265206c65676974696d6174656c7920604e6f6e656020696e207468652063617365207468617420796f752077696c6c2063616c6ca82060536572766963655175657565733a3a736572766963655f71756575657360206d616e75616c6c792e01450d64002441737365745261746501244173736574526174650458436f6e76657273696f6e52617465546f4e61746976650001040205014d0704000c1d01204d61707320616e20617373657420746f2069747320666978656420706f696e7420726570726573656e746174696f6e20696e20746865206e61746976652062616c616e63652e004d0120452e672e20606e61746976655f616d6f756e74203d2061737365745f616d6f756e74202a20436f6e76657273696f6e52617465546f4e61746976653a3a3c543e3a3a6765742861737365745f6b696e642960014907011d080001490d650014426565667901144265656679142c417574686f72697469657301004d0d04000470205468652063757272656e7420617574686f726974696573207365743856616c696461746f72536574496401002c2000000000000000000474205468652063757272656e742076616c696461746f72207365742069643c4e657874417574686f72697469657301004d0d040004ec20417574686f72697469657320736574207363686564756c656420746f2062652075736564207769746820746865206e6578742073657373696f6e30536574496453657373696f6e000104052c1004002851012041206d617070696e672066726f6d2042454546592073657420494420746f2074686520696e646578206f6620746865202a6d6f737420726563656e742a2073657373696f6e20666f722077686963682069747368206d656d62657273207765726520726573706f6e7369626c652e0045012054686973206973206f6e6c79207573656420666f722076616c69646174696e672065717569766f636174696f6e2070726f6f66732e20416e2065717569766f636174696f6e2070726f6f66206d7573744d0120636f6e7461696e732061206b65792d6f776e6572736869702070726f6f6620666f72206120676976656e2073657373696f6e2c207468657265666f7265207765206e65656420612077617920746f207469653d0120746f6765746865722073657373696f6e7320616e6420424545465920736574206964732c20692e652e207765206e65656420746f2076616c6964617465207468617420612076616c696461746f7241012077617320746865206f776e6572206f66206120676976656e206b6579206f6e206120676976656e2073657373696f6e2c20616e642077686174207468652061637469766520736574204944207761735420647572696e6720746861742073657373696f6e2e00dc2054574f582d4e4f54453a206056616c696461746f72536574496460206973206e6f7420756e646572207573657220636f6e74726f6c2e3047656e65736973426c6f636b01008d0204000cdc20426c6f636b206e756d62657220776865726520424545465920636f6e73656e73757320697320656e61626c65642f737461727465642e6901204279206368616e67696e67207468697320287468726f7567682070726976696c6567656420607365745f6e65775f67656e65736973282960292c20424545465920636f6e73656e737573206973206566666563746976656c79ac207265737461727465642066726f6d20746865206e65776c792073657420626c6f636b206e756d6265722e015107000c384d6178417574686f7269746965731010a086010004d420546865206d6178696d756d206e756d626572206f6620617574686f72697469657320746861742063616e2062652061646465642e344d61784e6f6d696e61746f727310100002000004d420546865206d6178696d756d206e756d626572206f66206e6f6d696e61746f727320666f7220656163682076616c696461746f722e584d6178536574496453657373696f6e456e74726965732c20a80000000000000018390120546865206d6178696d756d206e756d626572206f6620656e747269657320746f206b65657020696e207468652073657420696420746f2073657373696f6e20696e646578206d617070696e672e0031012053696e6365207468652060536574496453657373696f6e60206d6170206973206f6e6c79207573656420666f722076616c69646174696e672065717569766f636174696f6e73207468697329012076616c75652073686f756c642072656c61746520746f2074686520626f6e64696e67206475726174696f6e206f66207768617465766572207374616b696e672073797374656d2069733501206265696e6720757365642028696620616e79292e2049662065717569766f636174696f6e2068616e646c696e67206973206e6f7420656e61626c6564207468656e20746869732076616c7565342063616e206265207a65726f2e01550dc8000c4d6d72010c4d6d720c20526f6f74486173680100308000000000000000000000000000000000000000000000000000000000000000000458204c6174657374204d4d5220526f6f7420686173682e384e756d6265724f664c656176657301002c20000000000000000004b02043757272656e742073697a65206f6620746865204d4d5220286e756d626572206f66206c6561766573292e144e6f646573000104062c300400108020486173686573206f6620746865206e6f64657320696e20746865204d4d522e002d01204e6f7465207468697320636f6c6c656374696f6e206f6e6c7920636f6e7461696e73204d4d52207065616b732c2074686520696e6e6572206e6f6465732028616e64206c656176657329bc20617265207072756e656420616e64206f6e6c792073746f72656420696e20746865204f6666636861696e2044422e00000000c9003042656566794d6d724c656166013042656566794d6d724c65616608404265656679417574686f7269746965730100590db0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004a02044657461696c73206f662063757272656e7420424545465920617574686f72697479207365742e5042656566794e657874417574686f7269746965730100590db000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c942044657461696c73206f66206e65787420424545465920617574686f72697479207365742e00510120546869732073746f7261676520656e747279206973207573656420617320636163686520666f722063616c6c7320746f20607570646174655f62656566795f6e6578745f617574686f726974795f736574602e00000000ca0004e9019901a1035d0d2448436865636b4e6f6e5a65726f53656e646572610d8c40436865636b5370656356657273696f6e650d1038436865636b547856657273696f6e690d1030436865636b47656e657369736d0d3038436865636b4d6f7274616c697479710d3028436865636b4e6f6e6365790d8c2c436865636b5765696768747d0d8c604368617267655472616e73616374696f6e5061796d656e74810d8c4850726576616c696461746541747465737473850d8c890d4c10436f72650c1c76657273696f6e004d0804902052657475726e73207468652076657273696f6e206f66207468652072756e74696d652e34657865637574655f626c6f636b0414626c6f636b8d0d8c046420457865637574652074686520676976656e20626c6f636b2e40696e697469616c697a655f626c6f636b0418686561646572c5018c04a820496e697469616c697a65206120626c6f636b20776974682074686520676976656e206865616465722e042101205468652060436f7265602072756e74696d65206170692074686174206576657279205375627374726174652072756e74696d65206e6565647320746f20696d706c656d656e742e204d657461646174610c206d6574616461746100990d048c2052657475726e7320746865206d65746164617461206f6620612072756e74696d652e4c6d657461646174615f61745f76657273696f6e041c76657273696f6e109d0d10a42052657475726e7320746865206d65746164617461206174206120676976656e2076657273696f6e2e0005012049662074686520676976656e206076657273696f6e602069736e277420737570706f727465642c20746869732077696c6c2072657475726e20604e6f6e65602e750120557365205b6053656c663a3a6d657461646174615f76657273696f6e73605d20746f2066696e64206f75742061626f757420737570706f72746564206d657461646174612076657273696f6e206f66207468652072756e74696d652e446d657461646174615f76657273696f6e730009020ca42052657475726e732074686520737570706f72746564206d657461646174612076657273696f6e732e00c020546869732063616e206265207573656420746f2063616c6c20606d657461646174615f61745f76657273696f6e602e0401012054686520604d65746164617461602061706920747261697420746861742072657475726e73206d6574616461746120666f72207468652072756e74696d652e30426c6f636b4275696c646572103c6170706c795f65787472696e736963042465787472696e736963910da10d106c204170706c792074686520676976656e2065787472696e7369632e0039012052657475726e7320616e20696e636c7573696f6e206f7574636f6d652077686963682073706563696669657320696620746869732065787472696e73696320697320696e636c7564656420696e4c207468697320626c6f636b206f72206e6f742e3866696e616c697a655f626c6f636b00c50104682046696e697368207468652063757272656e7420626c6f636b2e4c696e686572656e745f65787472696e736963730420696e686572656e74b10d950d043d012047656e657261746520696e686572656e742065787472696e736963732e2054686520696e686572656e7420646174612077696c6c20766172792066726f6d20636861696e20746f20636861696e2e3c636865636b5f696e686572656e74730814626c6f636b8d0d1064617461b10dc10d04550120436865636b20746861742074686520696e686572656e7473206172652076616c69642e2054686520696e686572656e7420646174612077696c6c20766172792066726f6d20636861696e20746f20636861696e2e047101205468652060426c6f636b4275696c646572602061706920747261697420746861742070726f7669646573207468652072657175697265642066756e6374696f6e616c69747920666f72206275696c64696e67206120626c6f636b2e484e6f6d696e6174696f6e506f6f6c734170690c3c70656e64696e675f72657761726473040c77686f00180435012052657475726e73207468652070656e64696e67207265776172647320666f7220746865206d656d626572207468617420746865204163636f756e7449642077617320676976656e20666f722e44706f696e74735f746f5f62616c616e6365081c706f6f6c5f69641018706f696e7473181804f42052657475726e7320746865206571756976616c656e742062616c616e6365206f662060706f696e74736020666f72206120676976656e20706f6f6c2e4462616c616e63655f746f5f706f696e7473081c706f6f6c5f696410246e65775f66756e6473181804fc2052657475726e7320746865206571756976616c656e7420706f696e7473206f6620606e65775f66756e64736020666f72206120676976656e20706f6f6c2e04f82052756e74696d652061706920666f7220616363657373696e6720696e666f726d6174696f6e2061626f7574206e6f6d696e6174696f6e20706f6f6c732e285374616b696e6741706908446e6f6d696e6174696f6e735f71756f7461041c62616c616e636518100411012052657475726e7320746865206e6f6d696e6174696f6e732071756f746120666f722061206e6f6d696e61746f722077697468206120676976656e2062616c616e63652e5c657261735f7374616b6572735f706167655f636f756e74080c657261101c6163636f756e7400100411012052657475726e7320746865207061676520636f756e74206f66206578706f737572657320666f7220612076616c696461746f7220696e206120676976656e206572612e00585461676765645472616e73616374696f6e5175657565045076616c69646174655f7472616e73616374696f6e0c18736f75726365c50d087478910d28626c6f636b5f6861736830c90d24682056616c696461746520746865207472616e73616374696f6e2e0065012054686973206d6574686f6420697320696e766f6b656420627920746865207472616e73616374696f6e20706f6f6c20746f206c6561726e2064657461696c732061626f757420676976656e207472616e73616374696f6e2e45012054686520696d706c656d656e746174696f6e2073686f756c64206d616b65207375726520746f207665726966792074686520636f72726563746e657373206f6620746865207472616e73616374696f6e4d0120616761696e73742063757272656e742073746174652e2054686520676976656e2060626c6f636b5f686173686020636f72726573706f6e647320746f207468652068617368206f662074686520626c6f636b7c207468617420697320757365642061732063757272656e742073746174652e004501204e6f7465207468617420746869732063616c6c206d617920626520706572666f726d65642062792074686520706f6f6c206d756c7469706c652074696d657320616e64207472616e73616374696f6e73a4206d6967687420626520766572696669656420696e20616e7920706f737369626c65206f726465722e044d012054686520605461676765645472616e73616374696f6e5175657565602061706920747261697420666f7220696e746572666572696e67207769746820746865207472616e73616374696f6e2071756575652e444f6666636861696e576f726b6572417069043c6f6666636861696e5f776f726b65720418686561646572c5018c04c82053746172747320746865206f66662d636861696e207461736b20666f7220676976656e20626c6f636b206865616465722e046420546865206f6666636861696e20776f726b6572206170692e3450617261636861696e486f7374742876616c696461746f727300a50b047020476574207468652063757272656e742076616c696461746f72732e4076616c696461746f725f67726f75707300d10d0c65012052657475726e73207468652076616c696461746f722067726f75707320616e6420726f746174696f6e20696e666f206c6f63616c697a6564206261736564206f6e20746865206879706f746865746963616c206368696c64610120206f66206120626c6f636b2077686f736520737461746520207468697320697320696e766f6b6564206f6e2e204e6f7465207468617420606e6f776020696e20746865206047726f7570526f746174696f6e496e666f60d02073686f756c642062652074686520737563636573736f72206f6620746865206e756d626572206f662074686520626c6f636b2e48617661696c6162696c6974795f636f72657300d90d083501205969656c647320696e666f726d6174696f6e206f6e20616c6c20617661696c6162696c69747920636f7265732061732072656c6576616e7420746f20746865206368696c6420626c6f636b2e3d0120436f72657320617265206569746865722066726565206f72206f636375706965642e204672656520636f7265732063616e20686176652070617261732061737369676e656420746f207468656d2e647065727369737465645f76616c69646174696f6e5f64617461081c706172615f6964b90228617373756d7074696f6ef10df50d146901205969656c647320746865207065727369737465642076616c69646174696f6e206461746120666f722074686520676976656e20605061726149646020616c6f6e67207769746820616e20617373756d7074696f6e2074686174d82073686f756c6420626520757365642069662074686520706172612063757272656e746c79206f63637570696573206120636f72652e0045012052657475726e7320604e6f6e656020696620656974686572207468652070617261206973206e6f742072656769737465726564206f722074686520617373756d7074696f6e20697320604672656564609820616e6420746865207061726120616c7265616479206f63637570696573206120636f72652e5c617373756d65645f76616c69646174696f6e5f64617461081c706172615f6964b9029c65787065637465645f7065727369737465645f76616c69646174696f6e5f646174615f6861736830fd0d0c69012052657475726e7320746865207065727369737465642076616c69646174696f6e206461746120666f722074686520676976656e20605061726149646020616c6f6e6720776974682074686520636f72726573706f6e64696e6775012076616c69646174696f6e20636f646520686173682e20496e7374656164206f6620616363657074696e6720617373756d7074696f6e2061626f75742074686520706172612c206d617463686573207468652076616c69646174696f6e29012064617461206861736820616761696e737420616e206578706563746564206f6e6520616e64207969656c647320604e6f6e65602069662074686579277265206e6f7420657175616c2e60636865636b5f76616c69646174696f6e5f6f757470757473081c706172615f6964b9021c6f75747075747369057804150120436865636b732069662074686520676976656e2076616c69646174696f6e206f75747075747320706173732074686520616363657074616e63652063726974657269612e5c73657373696f6e5f696e6465785f666f725f6368696c6400100cf02052657475726e73207468652073657373696f6e20696e6465782065787065637465642061742061206368696c64206f662074686520626c6f636b2e00d020546869732063616e206265207573656420746f20696e7374616e7469617465206120605369676e696e67436f6e74657874602e3c76616c69646174696f6e5f636f6465081c706172615f6964b90228617373756d7074696f6ef10d7d05105501204665746368207468652076616c69646174696f6e20636f64652075736564206279206120706172612c206d616b696e672074686520676976656e20604f63637570696564436f7265417373756d7074696f6e602e0045012052657475726e7320604e6f6e656020696620656974686572207468652070617261206973206e6f742072656769737465726564206f722074686520617373756d7074696f6e20697320604672656564609820616e6420746865207061726120616c7265616479206f63637570696573206120636f72652e7863616e6469646174655f70656e64696e675f617661696c6162696c697479041c706172615f6964b902050e085d0120476574207468652072656365697074206f6620612063616e6469646174652070656e64696e6720617661696c6162696c6974792e20546869732072657475726e732060536f6d656020666f7220616e7920706172617325012061737369676e656420746f206f6363757069656420636f72657320696e2060617661696c6162696c6974795f636f7265736020616e6420604e6f6e6560206f74686572776973652e4063616e6469646174655f6576656e747300090e042d0120476574206120766563746f72206f66206576656e747320636f6e6365726e696e672063616e646964617465732074686174206f636375727265642077697468696e206120626c6f636b2e30646d715f636f6e74656e74730424726563697069656e74b902450c043d012047657420616c6c207468652070656e64696e6720696e626f756e64206d6573736167657320696e2074686520646f776e77617264206d65737361676520717565756520666f72206120706172612e78696e626f756e645f68726d705f6368616e6e656c735f636f6e74656e74730424726563697069656e74b902110e086501204765742074686520636f6e74656e7473206f6620616c6c206368616e6e656c732061646472657373656420746f2074686520676976656e20726563697069656e742e204368616e6e656c7320746861742068617665206e6f90206d6573736167657320696e207468656d2061726520616c736f20696e636c756465642e5c76616c69646174696f6e5f636f64655f62795f6861736804106861736865057d05049c20476574207468652076616c69646174696f6e20636f64652066726f6d2069747320686173682e386f6e5f636861696e5f766f746573001d0e0431012053637261706520646973707574652072656c6576616e742066726f6d206f6e2d636861696e2c206261636b696e6720766f74657320616e64207265736f6c7665642064697370757465732e3073657373696f6e5f696e666f0414696e64657810210e0cdc20476574207468652073657373696f6e20696e666f20666f722074686520676976656e2073657373696f6e2c2069662073746f7265642e001901204e4f54453a20546869732066756e6374696f6e206973206f6e6c7920617661696c61626c652073696e63652070617261636861696e20686f73742076657273696f6e20322e687375626d69745f7076665f636865636b5f73746174656d656e74081073746d74b905247369676e617475726549058c0c0101205375626d697473206120505646207072652d636865636b696e672073746174656d656e7420696e746f20746865207472616e73616374696f6e20706f6f6c2e001901204e4f54453a20546869732066756e6374696f6e206973206f6e6c7920617661696c61626c652073696e63652070617261636861696e20686f73742076657273696f6e20322e54707666735f726571756972655f707265636865636b000d0c0c5d012052657475726e7320636f646520686173686573206f66205056467320746861742072657175697265207072652d636865636b696e672062792076616c696461746f727320696e2074686520616374697665207365742e001901204e4f54453a20546869732066756e6374696f6e206973206f6e6c7920617661696c61626c652073696e63652070617261636861696e20686f73742076657273696f6e20322e5076616c69646174696f6e5f636f64655f68617368081c706172615f6964b90228617373756d7074696f6ef10d250e0c8501204665746368207468652068617368206f66207468652076616c69646174696f6e20636f64652075736564206279206120706172612c206d616b696e672074686520676976656e20604f63637570696564436f7265417373756d7074696f6e602e001901204e4f54453a20546869732066756e6374696f6e206973206f6e6c7920617661696c61626c652073696e63652070617261636861696e20686f73742076657273696f6e20322e20646973707574657300290e04782052657475726e7320616c6c206f6e636861696e2064697370757465732e5c73657373696f6e5f6578656375746f725f706172616d73043473657373696f6e5f696e64657810310e04b82052657475726e7320657865637574696f6e20706172616d657465727320666f72207468652073657373696f6e2e44756e6170706c6965645f736c617368657300350e0859012052657475726e732061206c697374206f662076616c696461746f72732074686174206c6f7374206120706173742073657373696f6e206469737075746520616e64206e65656420746f20626520736c61736865642e1901204e4f54453a20546869732066756e6374696f6e206973206f6e6c7920617661696c61626c652073696e63652070617261636861696e20686f73742076657273696f6e20352e4c6b65795f6f776e6572736869705f70726f6f66043076616c696461746f725f696441023d0e08cc2052657475726e732061206d65726b6c652070726f6f66206f6620612076616c696461746f722073657373696f6e206b65792e1901204e4f54453a20546869732066756e6374696f6e206973206f6e6c7920617661696c61626c652073696e63652070617261636861696e20686f73742076657273696f6e20352e687375626d69745f7265706f72745f646973707574655f6c6f73740834646973707574655f70726f6f66d1054c6b65795f6f776e6572736869705f70726f6f66410e450e0c2901205375626d697420616e20756e7369676e65642065787472696e73696320746f20736c6173682076616c696461746f72732077686f206c6f7374206120646973707574652061626f75747c20612063616e646964617465206f66206120706173742073657373696f6e2e1901204e4f54453a20546869732066756e6374696f6e206973206f6e6c7920617661696c61626c652073696e63652070617261636861696e20686f73742076657273696f6e20352e546d696e696d756d5f6261636b696e675f766f7465730010080d012047657420746865206d696e696d756d206e756d626572206f66206261636b696e6720766f74657320666f7220612070617261636861696e2063616e6469646174652ef4205468697320697320612073746167696e67206d6574686f642120446f206e6f7420757365206f6e2070726f64756374696f6e2072756e74696d65732148706172615f6261636b696e675f737461746504045fb902490e04e42052657475726e7320746865207374617465206f662070617261636861696e206261636b696e6720666f72206120676976656e20706172612e506173796e635f6261636b696e675f706172616d730005050461012052657475726e732063616e646964617465277320616363657074616e6365206c696d69746174696f6e7320666f72206173796e6368726f6e6f7573206261636b696e6720666f7220612072656c617920706172656e742e4c64697361626c65645f76616c696461746f727300a10b04f82052657475726e732061206c697374206f6620616c6c2064697361626c65642076616c696461746f72732061742074686520676976656e20626c6f636b2e346e6f64655f6665617475726573003d05084c20476574206e6f64652066656174757265732ef4205468697320697320612073746167696e67206d6574686f642120446f206e6f7420757365206f6e2070726f64756374696f6e2072756e74696d65732158617070726f76616c5f766f74696e675f706172616d73001d0504a420417070726f76616c20766f74696e6720636f6e66696775726174696f6e20706172616d657465727304dc205468652041504920666f72207175657279696e6720746865207374617465206f662070617261636861696e73206f6e2d636861696e2e204265656679417069103462656566795f67656e65736973008d020405012052657475726e2074686520626c6f636b206e756d62657220776865726520424545465920636f6e73656e73757320697320656e61626c65642f737461727465643476616c696461746f725f73657400790e04b82052657475726e207468652063757272656e74206163746976652042454546592076616c696461746f7220736574b47375626d69745f7265706f72745f65717569766f636174696f6e5f756e7369676e65645f65787472696e736963084865717569766f636174696f6e5f70726f6f6655073c6b65795f6f776e65725f70726f6f66810e450e201101205375626d69747320616e20756e7369676e65642065787472696e73696320746f207265706f727420616e2065717569766f636174696f6e2e205468652063616c6c6572f8206d7573742070726f76696465207468652065717569766f636174696f6e2070726f6f6620616e642061206b6579206f776e6572736869702070726f6f66fc202873686f756c64206265206f627461696e6564207573696e67206067656e65726174655f6b65795f6f776e6572736869705f70726f6f6660292e2054686505012065787472696e7369632077696c6c20626520756e7369676e656420616e642073686f756c64206f6e6c7920626520616363657074656420666f72206c6f63616c150120617574686f727368697020286e6f7420746f2062652062726f61646361737420746f20746865206e6574776f726b292e2054686973206d6574686f642072657475726e73090120604e6f6e6560207768656e206372656174696f6e206f66207468652065787472696e736963206661696c732c20652e672e2069662065717569766f636174696f6e0501207265706f7274696e672069732064697361626c656420666f722074686520676976656e2072756e74696d652028692e652e2074686973206d6574686f6420697305012068617264636f64656420746f2072657475726e20604e6f6e6560292e204f6e6c792075736566756c20696e20616e206f6666636861696e20636f6e746578742e7067656e65726174655f6b65795f6f776e6572736869705f70726f6f6608187365745f69642c30617574686f726974795f69644d02850e2c09012047656e65726174657320612070726f6f66206f66206b6579206f776e65727368697020666f722074686520676976656e20617574686f7269747920696e20746865fc20676976656e207365742e20416e206578616d706c65207573616765206f662074686973206d6f64756c6520697320636f75706c656420776974682074686505012073657373696f6e20686973746f726963616c206d6f64756c6520746f2070726f76652074686174206120676976656e20617574686f72697479206b65792069730d01207469656420746f206120676976656e207374616b696e67206964656e7469747920647572696e6720612073706563696669632073657373696f6e2e2050726f6f66731101206f66206b6579206f776e65727368697020617265206e656365737361727920666f72207375626d697474696e672065717569766f636174696f6e207265706f7274732e1101204e4f54453a206576656e2074686f75676820746865204150492074616b6573206120607365745f69646020617320706172616d65746572207468652063757272656e74090120696d706c656d656e746174696f6e732069676e6f726573207468697320706172616d6574657220616e6420696e73746561642072656c696573206f6e20746869730d01206d6574686f64206265696e672063616c6c65642061742074686520636f727265637420626c6f636b206865696768742c20692e652e20616e7920706f696e7420617415012077686963682074686520676976656e20736574206964206973206c697665206f6e2d636861696e2e2046757475726520696d706c656d656e746174696f6e732077696c6c0d0120696e73746561642075736520696e64657865642064617461207468726f75676820616e206f6666636861696e20776f726b65722c206e6f7420726571756972696e6778206f6c6465722073746174657320746f20626520617661696c61626c652e048020415049206e656365737361727920666f7220424545465920766f746572732e184d6d7241706914206d6d725f726f6f7400890e048c2052657475726e20746865206f6e2d636861696e204d4d5220726f6f7420686173682e386d6d725f6c6561665f636f756e7400910e04b82052657475726e20746865206e756d626572206f66204d4d5220626c6f636b7320696e2074686520636861696e2e3867656e65726174655f70726f6f660834626c6f636b5f6e756d6265727309025c626573745f6b6e6f776e5f626c6f636b5f6e756d6265728d02950e0869012047656e6572617465204d4d522070726f6f6620666f72206120736572696573206f6620626c6f636b206e756d626572732e2049662060626573745f6b6e6f776e5f626c6f636b5f6e756d626572203d20536f6d65286e29602c45012075736520686973746f726963616c204d4d5220737461746520617420676976656e20626c6f636b2068656967687420606e602e20456c73652c207573652063757272656e74204d4d522073746174652e307665726966795f70726f6f6608186c65617665739d0e1470726f6f66a50ea90e14f420566572696679204d4d522070726f6f6620616761696e7374206f6e2d636861696e204d4d5220666f722061206261746368206f66206c65617665732e007101204e6f746520746869732066756e6374696f6e2077696c6c20757365206f6e2d636861696e204d4d5220726f6f74206861736820616e6420636865636b206966207468652070726f6f66206d6174636865732074686520686173682e6d01204e6f74652c20746865206c65617665732073686f756c6420626520736f727465642073756368207468617420636f72726573706f6e64696e67206c656176657320616e64206c65616620696e646963657320686176652074686585012073616d6520706f736974696f6e20696e20626f74682074686520606c65617665736020766563746f7220616e642074686520606c6561665f696e64696365736020766563746f7220636f6e7461696e656420696e20746865205b50726f6f665d587665726966795f70726f6f665f73746174656c6573730c10726f6f7430186c65617665739d0e1470726f6f66a50ea90e1c010120566572696679204d4d522070726f6f6620616761696e737420676976656e20726f6f74206861736820666f722061206261746368206f66206c65617665732e00fc204e6f746520746869732066756e6374696f6e20646f6573206e6f74207265717569726520616e79206f6e2d636861696e2073746f72616765202d20746865bc2070726f6f6620697320766572696669656420616761696e737420676976656e204d4d5220726f6f7420686173682e006d01204e6f74652c20746865206c65617665732073686f756c6420626520736f727465642073756368207468617420636f72726573706f6e64696e67206c656176657320616e64206c65616620696e646963657320686176652074686585012073616d6520706f736974696f6e20696e20626f74682074686520606c65617665736020766563746f7220616e642074686520606c6561665f696e64696365736020766563746f7220636f6e7461696e656420696e20746865205b50726f6f665d04842041504920746f20696e7465726163742077697468204d4d522070616c6c65742e2c42656566794d6d72417069084c617574686f726974795f7365745f70726f6f6600590d04dc2052657475726e207468652063757272656e746c792061637469766520424545465920617574686f72697479207365742070726f6f662e606e6578745f617574686f726974795f7365745f70726f6f6600590d04c82052657475726e20746865206e6578742f71756575656420424545465920617574686f72697479207365742070726f6f662e0490204150492075736566756c20666f72204245454659206c6967687420636c69656e74732e284772616e647061417069104c6772616e6470615f617574686f72697469657300cc183d0120476574207468652063757272656e74204752414e44504120617574686f72697469657320616e6420776569676874732e20546869732073686f756c64206e6f74206368616e6765206578636570741d0120666f72207768656e206368616e67657320617265207363686564756c656420616e642074686520636f72726573706f6e64696e672064656c617920686173207061737365642e003501205768656e2063616c6c656420617420626c6f636b20422c2069742077696c6c2072657475726e2074686520736574206f6620617574686f72697469657320746861742073686f756c642062653d01207573656420746f2066696e616c697a652064657363656e64616e7473206f66207468697320626c6f636b2028422b312c20422b322c202e2e2e292e2054686520626c6f636b204220697473656c66c02069732066696e616c697a65642062792074686520617574686f7269746965732066726f6d20626c6f636b20422d312eb47375626d69745f7265706f72745f65717569766f636174696f6e5f756e7369676e65645f65787472696e736963084865717569766f636174696f6e5f70726f6f665d023c6b65795f6f776e65725f70726f6f66ad0e450e201101205375626d69747320616e20756e7369676e65642065787472696e73696320746f207265706f727420616e2065717569766f636174696f6e2e205468652063616c6c6572f8206d7573742070726f76696465207468652065717569766f636174696f6e2070726f6f6620616e642061206b6579206f776e6572736869702070726f6f66fc202873686f756c64206265206f627461696e6564207573696e67206067656e65726174655f6b65795f6f776e6572736869705f70726f6f6660292e2054686505012065787472696e7369632077696c6c20626520756e7369676e656420616e642073686f756c64206f6e6c7920626520616363657074656420666f72206c6f63616c150120617574686f727368697020286e6f7420746f2062652062726f61646361737420746f20746865206e6574776f726b292e2054686973206d6574686f642072657475726e73090120604e6f6e6560207768656e206372656174696f6e206f66207468652065787472696e736963206661696c732c20652e672e2069662065717569766f636174696f6e0501207265706f7274696e672069732064697361626c656420666f722074686520676976656e2072756e74696d652028692e652e2074686973206d6574686f6420697305012068617264636f64656420746f2072657475726e20604e6f6e6560292e204f6e6c792075736566756c20696e20616e206f6666636861696e20636f6e746578742e7067656e65726174655f6b65795f6f776e6572736869705f70726f6f6608187365745f69642c30617574686f726974795f6964d4b10e2c09012047656e65726174657320612070726f6f66206f66206b6579206f776e65727368697020666f722074686520676976656e20617574686f7269747920696e20746865fc20676976656e207365742e20416e206578616d706c65207573616765206f662074686973206d6f64756c6520697320636f75706c656420776974682074686505012073657373696f6e20686973746f726963616c206d6f64756c6520746f2070726f76652074686174206120676976656e20617574686f72697479206b65792069730d01207469656420746f206120676976656e207374616b696e67206964656e7469747920647572696e6720612073706563696669632073657373696f6e2e2050726f6f66731101206f66206b6579206f776e65727368697020617265206e656365737361727920666f72207375626d697474696e672065717569766f636174696f6e207265706f7274732e1101204e4f54453a206576656e2074686f75676820746865204150492074616b6573206120607365745f69646020617320706172616d65746572207468652063757272656e74fc20696d706c656d656e746174696f6e732069676e6f7265207468697320706172616d6574657220616e6420696e73746561642072656c79206f6e20746869730d01206d6574686f64206265696e672063616c6c65642061742074686520636f727265637420626c6f636b206865696768742c20692e652e20616e7920706f696e7420617415012077686963682074686520676976656e20736574206964206973206c697665206f6e2d636861696e2e2046757475726520696d706c656d656e746174696f6e732077696c6c0d0120696e73746561642075736520696e64657865642064617461207468726f75676820616e206f6666636861696e20776f726b65722c206e6f7420726571756972696e6778206f6c6465722073746174657320746f20626520617661696c61626c652e3863757272656e745f7365745f6964002c0498204765742063757272656e74204752414e44504120617574686f72697479207365742069642e240101204150497320666f7220696e746567726174696e6720746865204752414e4450412066696e616c6974792067616467657420696e746f2072756e74696d65732ec020546869732073686f756c6420626520696d706c656d656e746564206f6e207468652072756e74696d6520736964652e0015012054686973206973207072696d6172696c79207573656420666f72206e65676f74696174696e6720617574686f726974792d736574206368616e67657320666f72207468650d01206761646765742e204752414e44504120757365732061207369676e616c696e67206d6f64656c206f66206368616e67696e6720617574686f7269747920736574733a3101206368616e6765732073686f756c64206265207369676e616c6564207769746820612064656c6179206f66204e20626c6f636b732c20616e64207468656e206175746f6d61746963616c6c79e4206170706c69656420696e207468652072756e74696d652061667465722074686f7365204e20626c6f636b732068617665207061737365642e00fc2054686520636f6e73656e7375732070726f746f636f6c2077696c6c20636f6f7264696e617465207468652068616e646f66662065787465726e616c6c792e1c426162654170691834636f6e66696775726174696f6e00b50e048c2052657475726e2074686520636f6e66696775726174696f6e20666f7220424142452e4c63757272656e745f65706f63685f737461727400cd0104c42052657475726e732074686520736c6f7420746861742073746172746564207468652063757272656e742065706f63682e3463757272656e745f65706f636800b90e04c42052657475726e7320696e666f726d6174696f6e20726567617264696e67207468652063757272656e742065706f63682e286e6578745f65706f636800b90e0801012052657475726e7320696e666f726d6174696f6e20726567617264696e6720746865206e6578742065706f6368202877686963682077617320616c72656164795c2070726576696f75736c7920616e6e6f756e636564292e7067656e65726174655f6b65795f6f776e6572736869705f70726f6f660810736c6f74cd0130617574686f726974795f6964c901bd0e2c09012047656e65726174657320612070726f6f66206f66206b6579206f776e65727368697020666f722074686520676976656e20617574686f7269747920696e207468650d012063757272656e742065706f63682e20416e206578616d706c65207573616765206f662074686973206d6f64756c6520697320636f75706c656420776974682074686505012073657373696f6e20686973746f726963616c206d6f64756c6520746f2070726f76652074686174206120676976656e20617574686f72697479206b65792069730d01207469656420746f206120676976656e207374616b696e67206964656e7469747920647572696e6720612073706563696669632073657373696f6e2e2050726f6f66731101206f66206b6579206f776e65727368697020617265206e656365737361727920666f72207375626d697474696e672065717569766f636174696f6e207265706f7274732e0901204e4f54453a206576656e2074686f75676820746865204150492074616b657320612060736c6f746020617320706172616d65746572207468652063757272656e74090120696d706c656d656e746174696f6e732069676e6f726573207468697320706172616d6574657220616e6420696e73746561642072656c696573206f6e20746869730d01206d6574686f64206265696e672063616c6c65642061742074686520636f727265637420626c6f636b206865696768742c20692e652e20616e7920706f696e74206174f0207768696368207468652065706f636820666f722074686520676976656e20736c6f74206973206c697665206f6e2d636861696e2e20467574757265090120696d706c656d656e746174696f6e732077696c6c20696e73746561642075736520696e64657865642064617461207468726f75676820616e206f6666636861696ed020776f726b65722c206e6f7420726571756972696e67206f6c6465722073746174657320746f20626520617661696c61626c652eb47375626d69745f7265706f72745f65717569766f636174696f6e5f756e7369676e65645f65787472696e736963084865717569766f636174696f6e5f70726f6f66c1013c6b65795f6f776e65725f70726f6f66c10e450e201101205375626d69747320616e20756e7369676e65642065787472696e73696320746f207265706f727420616e2065717569766f636174696f6e2e205468652063616c6c6572f8206d7573742070726f76696465207468652065717569766f636174696f6e2070726f6f6620616e642061206b6579206f776e6572736869702070726f6f66fc202873686f756c64206265206f627461696e6564207573696e67206067656e65726174655f6b65795f6f776e6572736869705f70726f6f6660292e2054686505012065787472696e7369632077696c6c20626520756e7369676e656420616e642073686f756c64206f6e6c7920626520616363657074656420666f72206c6f63616c150120617574686f727368697020286e6f7420746f2062652062726f61646361737420746f20746865206e6574776f726b292e2054686973206d6574686f642072657475726e73090120604e6f6e6560207768656e206372656174696f6e206f66207468652065787472696e736963206661696c732c20652e672e2069662065717569766f636174696f6e0501207265706f7274696e672069732064697361626c656420666f722074686520676976656e2072756e74696d652028692e652e2074686973206d6574686f6420697305012068617264636f64656420746f2072657475726e20604e6f6e6560292e204f6e6c792075736566756c20696e20616e206f6666636861696e20636f6e746578742e04b820415049206e656365737361727920666f7220626c6f636b20617574686f7273686970207769746820424142452e54417574686f72697479446973636f76657279417069042c617574686f72697469657300b90904190120526574726965766520617574686f72697479206964656e74696669657273206f66207468652063757272656e7420616e64206e65787420617574686f72697479207365742e10742054686520617574686f7269747920646973636f76657279206170692e0051012054686973206170692069732075736564206279207468652060636c69656e742f617574686f726974792d646973636f7665727960206d6f64756c6520746f207265747269657665206964656e746966696572739c206f66207468652063757272656e7420616e64206e65787420617574686f72697479207365742e2c53657373696f6e4b657973085467656e65726174655f73657373696f6e5f6b657973041073656564010d341c15012047656e6572617465206120736574206f662073657373696f6e206b6579732077697468206f7074696f6e616c6c79207573696e672074686520676976656e20736565642e090120546865206b6579732073686f756c642062652073746f7265642077697468696e20746865206b657973746f7265206578706f736564207669612072756e74696d653c2065787465726e616c69746965732e00b0205468652073656564206e6565647320746f20626520612076616c69642060757466386020737472696e672e00d02052657475726e732074686520636f6e636174656e61746564205343414c4520656e636f646564207075626c6963206b6579732e4c6465636f64655f73657373696f6e5f6b657973041c656e636f64656434c50e0c98204465636f64652074686520676976656e207075626c69632073657373696f6e206b6579732e00dc2052657475726e7320746865206c697374206f66207075626c696320726177207075626c6963206b657973202b206b657920747970652e04682053657373696f6e206b6579732072756e74696d65206170692e3c4163636f756e744e6f6e636541706904346163636f756e745f6e6f6e6365041c6163636f756e74001004c0204765742063757272656e74206163636f756e74206e6f6e6365206f6620676976656e20604163636f756e744964602e0480205468652041504920746f207175657279206163636f756e74206e6f6e63652e545472616e73616374696f6e5061796d656e74417069102871756572795f696e666f080c757874910d0c6c656e10d10e004471756572795f6665655f64657461696c73080c757874910d0c6c656e10d50e004c71756572795f7765696768745f746f5f66656504187765696768742418004c71756572795f6c656e6774685f746f5f66656504186c656e67746810180000645472616e73616374696f6e5061796d656e7443616c6c417069103c71756572795f63616c6c5f696e666f081063616c6c99010c6c656e10d10e04490120517565727920696e666f726d6174696f6e206f66206120646973706174636820636c6173732c207765696768742c20616e6420666565206f66206120676976656e20656e636f646564206043616c6c602e5871756572795f63616c6c5f6665655f64657461696c73081063616c6c99010c6c656e10d50e04b4205175657279206665652064657461696c73206f66206120676976656e20656e636f646564206043616c6c602e4c71756572795f7765696768745f746f5f6665650418776569676874241804010120517565727920746865206f7574707574206f66207468652063757272656e742060576569676874546f4665656020676976656e20736f6d6520696e7075742e4c71756572795f6c656e6774685f746f5f66656504186c656e677468101804010120517565727920746865206f7574707574206f66207468652063757272656e7420604c656e677468546f4665656020676976656e20736f6d6520696e7075742e003847656e657369734275696c64657208546372656174655f64656661756c745f636f6e6669670034100d012043726561746573207468652064656661756c74206047656e65736973436f6e6669676020616e642072657475726e732069742061732061204a534f4e20626c6f622e00b10120546869732066756e6374696f6e20696e7374616e746961746573207468652064656661756c74206047656e65736973436f6e666967602073747275637420666f72207468652072756e74696d6520616e642073657269616c697a657320697420696e746f2061204a534f4e810120626c6f622e2049742072657475726e73206120605665633c75383e6020636f6e7461696e696e6720746865204a534f4e20726570726573656e746174696f6e206f66207468652064656661756c74206047656e65736973436f6e666967602e306275696c645f636f6e66696704106a736f6e34e10e1c6d01204275696c64206047656e65736973436f6e666967602066726f6d2061204a534f4e20626c6f62206e6f74207573696e6720616e792064656661756c747320616e642073746f726520697420696e207468652073746f726167652e00ad0120546869732066756e6374696f6e20646573657269616c697a6573207468652066756c6c206047656e65736973436f6e666967602066726f6d2074686520676976656e204a534f4e20626c6f6220616e64207075747320697420696e746f207468652073746f726167652ea501204966207468652070726f7669646564204a534f4e20626c6f6220697320696e636f7272656374206f7220696e636f6d706c657465206f722074686520646573657269616c697a6174696f6e206661696c732c20616e206572726f722069732072657475726e65642e1101204974206973207265636f6d6d656e64656420746f206c6f6720616e79206572726f727320656e636f756e746572656420647572696e67207468652070726f636573732e009d0120506c65617365206e6f746520746861742070726f7669646564206a736f6e20626c6f62206d75737420636f6e7461696e20616c6c206047656e65736973436f6e66696760206669656c64732c206e6f2064656661756c74732077696c6c20626520757365642e04cc2041504920746f20696e74657261637420776974682047656e65736973436f6e66696720666f72207468652072756e74696d65990150e50e00" + + @Test + fun shouldGenerateProofForExtrinsic() = runTest { + val polkadot = chainRegistry.getChain(Chain.Geneses.POLKADOT) + val runtimeMetadataBytes = runtimeMetadata.fromHex() + + val dot = polkadot.utilityAsset + + val specVersion = 1_002_000 + val specName = "polkadot" + + val signer = TestSigner { payload -> + val signedExtras = payload.encodedExplicits() + val additionalSigned = payload.encodedImplicits() + + Log.d("MetadataShortenerTest", "Signed extras: ${signedExtras.toHexString()}") + Log.d("MetadataShortenerTest", "Additional signed: ${additionalSigned.toHexString()}") + Log.d("MetadataShortenerTest", "Call: ${payload.encodedCall().toHexString()}") + + val proof = MetadataShortener.generate_extrinsic_proof( + payload.encodedCall(), + signedExtras, + additionalSigned, + runtimeMetadataBytes, + specVersion, + specName, + polkadot.addressPrefix, + dot.precision.value, + dot.symbol.value + ) + + Log.d("MetadataShortenerTest", "Generated proof of size ${proof.size / 1024.0} KB: ${proof.toHexString()}") + } + + val extrinsicBuilder = extrinsicBuilderFactory.create( + chain = polkadot, + options = ExtrinsicBuilderFactory.Options(BatchMode.BATCH) + ) + + extrinsicBuilder.nativeTransfer(accountId = signer.accountId, amount = BigInteger.ONE) + extrinsicBuilder.systemRemark(remark = byteArrayOf(1, 2, 3)) + + with(extrinsicBuilder) { + signer.setSignerData(TestSigningContext(polkadot), SigningMode.SUBMISSION) + } + + extrinsicBuilder.buildExtrinsic() + } + + @Test + fun shouldGenerateMetadataDigest() = runTest { + val polkadot = chainRegistry.getChain(Chain.Geneses.POLKADOT) + val dot = polkadot.utilityAsset + + val specVersion = 1_002_000 + val specName = "polkadot" + + val digest = MetadataShortener.generate_metadata_digest( + runtimeMetadata.fromHex(), + specVersion, + specName, + polkadot.addressPrefix, + dot.precision.value, + dot.symbol.value + ) + + Log.d("MetadataShortenerTest", "Metadata digest: ${digest.toHexString()}") + } + + private inner class TestSigner( + private val testPayload: (InheritedImplication) -> Unit + ) : NovaSigner, GeneralTransactionSigner { + + val accountId = ByteArray(32) { 1 } + + override suspend fun callExecutionType(): CallExecutionType { + return CallExecutionType.IMMEDIATE + } + + override val metaAccount: MetaAccount = DefaultMetaAccount( + id = 0, + globallyUniqueId = "0", + substrateAccountId = accountId, + substrateCryptoType = null, + substratePublicKey = null, + ethereumAddress = null, + ethereumPublicKey = null, + isSelected = true, + name = "test", + type = LightMetaAccount.Type.SECRETS, + chainAccounts = emptyMap(), + status = LightMetaAccount.Status.ACTIVE, + parentMetaId = null + ) + + override suspend fun getSigningHierarchy(): SubmissionHierarchy { + return SubmissionHierarchy(metaAccount, CallExecutionType.IMMEDIATE) + } + + override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + error("Not implemented") + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForSubmission(context: SigningContext) { + setNonce(BigInteger.ZERO) + setVerifySignature(this, accountId) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForFee(context: SigningContext) { + setSignerDataForSubmission(context) + } + + override suspend fun submissionSignerAccountId(chain: Chain): AccountId { + return accountId + } + + override suspend fun maxCallsPerTransaction(): Int? { + return null + } + + override suspend fun signInheritedImplication( + inheritedImplication: InheritedImplication, + accountId: AccountId + ): SignatureWrapper { + testPayload(inheritedImplication) + + return SignatureWrapper.Sr25519(ByteArray(64)) + } + } + + private class TestSigningContext(override val chain: Chain) : SigningContext { + override suspend fun getNonce(accountId: AccountIdKey): Nonce { + return Nonce.ZERO + } + } +} + + diff --git a/app/src/androidTest/java/io/novafoundation/nova/NftFullSyncIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/NftFullSyncIntegrationTest.kt new file mode 100644 index 0000000..4d025d9 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/NftFullSyncIntegrationTest.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_nft_api.NftFeatureApi +import io.novafoundation.nova.feature_nft_api.data.model.isFullySynced +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.runtime.di.RuntimeComponent +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class NftFullSyncIntegrationTest { + + private val nftApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + NftFeatureApi::class.java + ) + + private val accountApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + AccountFeatureApi::class.java + ) + + private val runtimeApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + RuntimeApi::class.java + ) + + private val externalRequirementFlow = runtimeApi.externalRequirementFlow() + + @Test + fun testFullSyncIntegration(): Unit = runBlocking { + externalRequirementFlow.emit(ChainConnection.ExternalRequirement.ALLOWED) + + val metaAccount = accountApi.accountUseCase().getSelectedMetaAccount() + + val nftRepository = nftApi.nftRepository + + nftRepository.initialNftSync(metaAccount, true) + + nftRepository.allNftFlow(metaAccount) + .map { nfts -> nfts.filter { !it.isFullySynced } } + .takeWhile { it.isNotEmpty() } + .onEach { unsyncedNfts -> + unsyncedNfts.forEach { nftRepository.fullNftSync(it) } + } + .onCompletion { + print("Full sync done") + } + .launchIn(this) + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/NftUniquesIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/NftUniquesIntegrationTest.kt new file mode 100644 index 0000000..6313f23 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/NftUniquesIntegrationTest.kt @@ -0,0 +1,181 @@ +package io.novafoundation.nova + +import android.content.Context +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindBoolean +import io.novafoundation.nova.common.data.network.runtime.binding.bindString +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.uniques +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.runtime.di.RuntimeComponent +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import io.novafoundation.nova.runtime.storage.source.multi.MultiQueryBuilder +import io.novafoundation.nova.runtime.storage.source.query.multi +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.math.BigInteger + +data class UniquesClass( + val id: BigInteger, + val metadata: Metadata?, + val details: Details +) { + data class Metadata( + val deposit: BigInteger, + val data: String, + ) + + data class Details( + val instances: BigInteger, + val frozen: Boolean + ) +} + +data class UniquesInstance( + val collection: UniquesClass, + val id: BigInteger, + val metadata: Metadata?, + val details: Details +) { + + data class Metadata( + val data: String, + ) + + data class Details( + val owner: String, + val frozen: Boolean, + ) +} + +class NftUniquesIntegrationTest { + + private val chainGenesis = "48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a" + + private val runtimeApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + RuntimeApi::class.java + ) + + private val chainRegistry = runtimeApi.chainRegistry() + private val externalRequirementFlow = runtimeApi.externalRequirementFlow() + + private val storageRemoteSource = runtimeApi.remoteStorageSource() + + @Test + fun testUniquesIntegration(): Unit = runBlocking { + chainRegistry.currentChains.first() // wait till chains are ready + externalRequirementFlow.emit(ChainConnection.ExternalRequirement.ALLOWED) + + val chain = chainRegistry.getChain(chainGenesis) + + val accountId = "JGKSibhyZgzY7jEe5a9gdybDEbqNNRSxYyJJmeeycbCbQ5v".toAccountId() + + val instances = storageRemoteSource.query(chainGenesis) { + val classesWithInstances = runtime.metadata.uniques().storage("Account").keys(accountId) + .map { (_: AccountId, collection: BigInteger, instance: BigInteger) -> + listOf(collection, instance) + } + + val classesIds = classesWithInstances.map { (collection, _) -> collection }.distinct() + + val classDetailsDescriptor: MultiQueryBuilder.Descriptor + val classMetadatasDescriptor: MultiQueryBuilder.Descriptor + val instancesDetailsDescriptor: MultiQueryBuilder.Descriptor, UniquesInstance.Details> + val instancesMetadataDescriptor: MultiQueryBuilder.Descriptor, UniquesInstance.Metadata?> + + val multiQueryResults = multi { + classDetailsDescriptor = runtime.metadata.uniques().storage("Class").querySingleArgKeys( + keysArgs = classesIds, + keyExtractor = { it.component1() }, + binding = { parsedValue -> + val classDetailsStruct = parsedValue.cast() + + UniquesClass.Details( + instances = classDetailsStruct.getTyped("instances"), + frozen = classDetailsStruct.getTyped("isFrozen") + ) + } + ) + + classMetadatasDescriptor = runtime.metadata.uniques().storage("ClassMetadataOf").querySingleArgKeys( + keysArgs = classesIds, + keyExtractor = { it.component1() }, + binding = { parsedValue -> + parsedValue?.cast()?.let { classMetadataStruct -> + UniquesClass.Metadata( + deposit = classMetadataStruct.getTyped("deposit"), + data = bindString(classMetadataStruct["data"]) + ) + } + } + ) + + instancesDetailsDescriptor = runtime.metadata.uniques().storage("Asset").queryKeys( + keysArgs = classesWithInstances, + keyExtractor = { it.component1() to it.component2() }, + binding = { parsedValue -> + val instanceDetailsStruct = parsedValue.cast() + + UniquesInstance.Details( + owner = chain.addressOf(bindAccountId(instanceDetailsStruct["owner"])), + frozen = bindBoolean(instanceDetailsStruct["isFrozen"]) + ) + } + ) + + instancesMetadataDescriptor = runtime.metadata.uniques().storage("InstanceMetadataOf").queryKeys( + keysArgs = classesWithInstances, + keyExtractor = { it.component1() to it.component2() }, + binding = { parsedValue -> + parsedValue?.cast()?.let { + UniquesInstance.Metadata( + data = bindString(it["data"]) + ) + } + } + ) + } + + val classDetails = multiQueryResults[classDetailsDescriptor] + + val classMetadatas = multiQueryResults[classMetadatasDescriptor] + + val instancesDetails = multiQueryResults[instancesDetailsDescriptor] + + val instancesMetadatas = multiQueryResults[instancesMetadataDescriptor] + + val classes = classesIds.associateWith { classId -> + UniquesClass( + id = classId, + metadata = classMetadatas[classId], + details = classDetails.getValue(classId) + ) + } + + classesWithInstances.map { (collectionId, instanceId) -> + val instanceKey = collectionId to instanceId + + UniquesInstance( + collection = classes.getValue(collectionId), + id = instanceId, + metadata = instancesMetadatas[instanceKey], + details = instancesDetails.getValue(instanceKey) + ) + } + } + + Log.d(LOG_TAG, instances.toString()) + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/NominationPoolsRewardCalculatorIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/NominationPoolsRewardCalculatorIntegrationTest.kt new file mode 100644 index 0000000..7ddc432 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/NominationPoolsRewardCalculatorIntegrationTest.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova + +import android.util.Log +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Perbill +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState.OptionAdditionalData +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.rewards.NominationPoolRewardCalculator +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType +import kotlinx.coroutines.flow.launchIn +import org.junit.Test + +class NominationPoolsRewardCalculatorIntegrationTest : BaseIntegrationTest() { + + private val stakingFeatureComponent = FeatureUtils.getFeature(context, StakingFeatureApi::class.java) + + private val nominationPoolRewardCalculatorFactory = stakingFeatureComponent.nominationPoolRewardCalculatorFactory + private val stakingUpdateSystem = stakingFeatureComponent.stakingUpdateSystem + private val stakingSharedState = stakingFeatureComponent.stakingSharedState + + @Test + fun testRewardCalculator() = runTest { + val polkadot = chainRegistry.polkadot() + val stakingOption = StakingOption( + assetWithChain = ChainWithAsset(polkadot, polkadot.utilityAsset), + additional = OptionAdditionalData(StakingType.NOMINATION_POOLS) + ) + + stakingSharedState.setSelectedOption(stakingOption) + + stakingUpdateSystem.start() + .launchIn(this) + + val rewardCalculator = nominationPoolRewardCalculatorFactory.create(stakingOption, sharedComputationScope = this) + + Log.d("NominationPoolsRewardCalculatorIntegrationTest", "Max APY: ${rewardCalculator.maxAPY}") + Log.d("NominationPoolsRewardCalculatorIntegrationTest", "APY for Nova Pool: ${rewardCalculator.apyFor(54)}") + } + + private fun NominationPoolRewardCalculator.apyFor(poolId: Int): Fraction? { + return apyFor(PoolId(poolId)) + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/PezkuwiIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/PezkuwiIntegrationTest.kt new file mode 100644 index 0000000..c241aeb --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/PezkuwiIntegrationTest.kt @@ -0,0 +1,324 @@ +package io.novafoundation.nova + +import android.content.Context +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.data.signer.SigningMode +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.data.signer.setSignerData +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.DefaultMetaAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.nativeTransfer +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode +import io.novasama.substrate_sdk_android.runtime.extrinsic.Nonce +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckNonce.Companion.setNonce +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.GeneralTransactionSigner +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.VerifySignature.Companion.setVerifySignature +import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.moduleOrNull +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.math.BigInteger + +/** + * End-to-end integration tests for Pezkuwi chain compatibility. + * These tests verify that: + * 1. Runtime loads correctly with proper types + * 2. Extrinsics can be built + * 3. Fee calculation works + * 4. Transfer extrinsics can be created + * + * Run with: ./gradlew :app:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=io.novafoundation.nova.PezkuwiIntegrationTest + */ +class PezkuwiIntegrationTest : BaseIntegrationTest() { + + private val walletApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + WalletFeatureApi::class.java + ) + + private val extrinsicBuilderFactory = runtimeApi.provideExtrinsicBuilderFactory() + private val rpcCalls = runtimeApi.rpcCalls() + + /** + * Test 1: Verify Pezkuwi Mainnet runtime loads with required types + */ + @Test + fun testPezkuwiMainnetRuntimeTypes() = runTest { + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + val runtime = chainRegistry.getRuntime(chain.id) + + // Verify critical types exist + val extrinsicSignature = runtime.typeRegistry["ExtrinsicSignature"] + assertNotNull("ExtrinsicSignature type should exist", extrinsicSignature) + + val multiSignature = runtime.typeRegistry["MultiSignature"] + assertNotNull("MultiSignature type should exist", multiSignature) + + val multiAddress = runtime.typeRegistry["MultiAddress"] + assertNotNull("MultiAddress type should exist", multiAddress) + + val address = runtime.typeRegistry["Address"] + assertNotNull("Address type should exist", address) + + println("Pezkuwi Mainnet: All required types present") + } + + /** + * Test 2: Verify Pezkuwi Asset Hub runtime loads with required types + */ + @Test + fun testPezkuwiAssetHubRuntimeTypes() = runTest { + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI_ASSET_HUB) + val runtime = chainRegistry.getRuntime(chain.id) + + val extrinsicSignature = runtime.typeRegistry["ExtrinsicSignature"] + assertNotNull("ExtrinsicSignature type should exist", extrinsicSignature) + + val address = runtime.typeRegistry["Address"] + assertNotNull("Address type should exist", address) + + println("Pezkuwi Asset Hub: All required types present") + } + + /** + * Test 3: Verify extrinsic builder can be created for Pezkuwi + */ + @Test + fun testPezkuwiExtrinsicBuilderCreation() = runTest { + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + + val builder = extrinsicBuilderFactory.create( + chain = chain, + options = io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory.Options( + batchMode = BatchMode.BATCH_ALL + ) + ) + + assertNotNull("ExtrinsicBuilder should be created", builder) + println("Pezkuwi ExtrinsicBuilder created successfully") + } + + /** + * Test 4: Verify transfer call can be constructed + */ + @Test + fun testPezkuwiTransferCallConstruction() = runTest { + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + val runtime = chainRegistry.getRuntime(chain.id) + + // Check if balances module exists + val balancesModule = runtime.metadata.moduleOrNull("Balances") + assertNotNull("Balances module should exist", balancesModule) + + // Check transfer call exists + val hasTransferKeepAlive = balancesModule?.callOrNull("transfer_keep_alive") != null + val hasTransferAllowDeath = balancesModule?.callOrNull("transfer_allow_death") != null || + balancesModule?.callOrNull("transfer") != null + + assertTrue("Transfer call should exist", hasTransferKeepAlive || hasTransferAllowDeath) + + println("Pezkuwi transfer call found: transfer_keep_alive=$hasTransferKeepAlive, transfer_allow_death=$hasTransferAllowDeath") + } + + /** + * Test 5: Verify signed extensions are properly handled + */ + @Test + fun testPezkuwiSignedExtensions() = runTest { + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + val runtime = chainRegistry.getRuntime(chain.id) + + val signedExtensions = runtime.metadata.extrinsic.signedExtensions.map { it.id } + println("Pezkuwi signed extensions: $signedExtensions") + + // Verify Pezkuwi-specific extensions + val hasAuthorizeCall = signedExtensions.contains("AuthorizeCall") + println("Has AuthorizeCall extension: $hasAuthorizeCall") + + // Standard extensions should also be present + val hasCheckMortality = signedExtensions.contains("CheckMortality") + val hasCheckNonce = signedExtensions.contains("CheckNonce") + + assertTrue("CheckMortality should exist", hasCheckMortality) + assertTrue("CheckNonce should exist", hasCheckNonce) + } + + /** + * Test 6: Verify utility asset is properly configured + */ + @Test + fun testPezkuwiUtilityAsset() = runTest { + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + + val utilityAsset = chain.utilityAsset + assertNotNull("Utility asset should exist", utilityAsset) + + println("Pezkuwi utility asset: ${utilityAsset.symbol}, precision: ${utilityAsset.precision}") + } + + /** + * Test 7: Build and sign a transfer extrinsic (THIS IS THE CRITICAL TEST) + * This test will catch "TypeReference is null" errors during signing + */ + @Test + fun testPezkuwiBuildSignedTransferExtrinsic() = runTest { + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + val signer = TestSigner() + + val builder = extrinsicBuilderFactory.create( + chain = chain, + options = ExtrinsicBuilderFactory.Options(BatchMode.BATCH) + ) + + // Add transfer call + val recipientAccountId = ByteArray(32) { 2 } + builder.nativeTransfer(accountId = recipientAccountId, amount = BigInteger.ONE) + + // Set signer data (this is where TypeReference errors can occur) + try { + with(builder) { + signer.setSignerData(TestSigningContext(chain), SigningMode.SUBMISSION) + } + Log.d("PezkuwiTest", "Signer data set successfully") + } catch (e: Exception) { + Log.e("PezkuwiTest", "Failed to set signer data", e) + fail("Failed to set signer data: ${e.message}") + } + + // Build the extrinsic (this is where TypeReference errors can also occur) + try { + val extrinsic = builder.buildExtrinsic() + assertNotNull("Built extrinsic should not be null", extrinsic) + Log.d("PezkuwiTest", "Extrinsic built successfully: ${extrinsic.extrinsicHex}") + println("Pezkuwi: Transfer extrinsic built and signed successfully!") + } catch (e: Exception) { + Log.e("PezkuwiTest", "Failed to build extrinsic", e) + fail("Failed to build extrinsic: ${e.message}\nCause: ${e.cause?.message}") + } + } + + /** + * Test 8: Build extrinsic for fee calculation (uses fake signature) + */ + @Test + fun testPezkuwiBuildFeeExtrinsic() = runTest { + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + val signer = TestSigner() + + val builder = extrinsicBuilderFactory.create( + chain = chain, + options = ExtrinsicBuilderFactory.Options(BatchMode.BATCH) + ) + + val recipientAccountId = ByteArray(32) { 2 } + builder.nativeTransfer(accountId = recipientAccountId, amount = BigInteger.ONE) + + // Set signer data for FEE mode (uses fake signature) + try { + with(builder) { + signer.setSignerData(TestSigningContext(chain), SigningMode.FEE) + } + val extrinsic = builder.buildExtrinsic() + assertNotNull("Fee extrinsic should not be null", extrinsic) + println("Pezkuwi: Fee extrinsic built successfully!") + } catch (e: Exception) { + Log.e("PezkuwiTest", "Failed to build fee extrinsic", e) + fail("Failed to build fee extrinsic: ${e.message}") + } + } + + // Helper extension + private suspend fun ChainRegistry.pezkuwiMainnet(): Chain { + return getChain(Chain.Geneses.PEZKUWI) + } + + // Test signer for building extrinsics without real keys + private inner class TestSigner : NovaSigner, GeneralTransactionSigner { + + val accountId = ByteArray(32) { 1 } + + override suspend fun callExecutionType(): CallExecutionType { + return CallExecutionType.IMMEDIATE + } + + override val metaAccount: MetaAccount = DefaultMetaAccount( + id = 0, + globallyUniqueId = "0", + substrateAccountId = accountId, + substrateCryptoType = null, + substratePublicKey = null, + ethereumAddress = null, + ethereumPublicKey = null, + isSelected = true, + name = "test", + type = LightMetaAccount.Type.SECRETS, + chainAccounts = emptyMap(), + status = LightMetaAccount.Status.ACTIVE, + parentMetaId = null + ) + + override suspend fun getSigningHierarchy(): SubmissionHierarchy { + return SubmissionHierarchy(metaAccount, CallExecutionType.IMMEDIATE) + } + + override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + error("Not implemented") + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForSubmission(context: SigningContext) { + setNonce(BigInteger.ZERO) + setVerifySignature(this@TestSigner, accountId) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForFee(context: SigningContext) { + setSignerDataForSubmission(context) + } + + override suspend fun submissionSignerAccountId(chain: Chain): AccountId { + return accountId + } + + override suspend fun maxCallsPerTransaction(): Int? { + return null + } + + override suspend fun signInheritedImplication( + inheritedImplication: InheritedImplication, + accountId: AccountId + ): SignatureWrapper { + // Return a fake Sr25519 signature for testing + return SignatureWrapper.Sr25519(ByteArray(64)) + } + } + + private class TestSigningContext(override val chain: Chain) : SigningContext { + override suspend fun getNonce(accountId: AccountIdKey): Nonce { + return Nonce.ZERO + } + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/PezkuwiLiveTransferTest.kt b/app/src/androidTest/java/io/novafoundation/nova/PezkuwiLiveTransferTest.kt new file mode 100644 index 0000000..b79b568 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/PezkuwiLiveTransferTest.kt @@ -0,0 +1,430 @@ +package io.novafoundation.nova + +import android.content.Context +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.deriveSeed32 +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.data.signer.SigningMode +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.data.signer.setSignerData +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.DefaultMetaAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.TransferMode +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.nativeTransfer +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.ext.requireGenesisHash +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novasama.substrate_sdk_android.encrypt.EncryptionType +import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.SubstrateKeypairFactory +import io.novasama.substrate_sdk_android.encrypt.seed.substrate.SubstrateSeedFactory +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode +import io.novasama.substrate_sdk_android.runtime.extrinsic.Nonce +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.KeyPairSigner +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckNonce.Companion.setNonce +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.GeneralTransactionSigner +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.VerifySignature.Companion.setVerifySignature +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.signingPayload +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import io.novafoundation.nova.sr25519.BizinikiwSr25519 +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Test +import java.math.BigInteger + +/** + * LIVE TRANSFER TEST - Transfers real HEZ tokens on Pezkuwi mainnet! + * + * Sender: 5DXv3Dc5xELckTgcYa2dm1TSZPgqDPxVDW3Cid4ALWpVjY3w + * Recipient: 5HdY6U2UQF8wPwczP3SoQz28kQu1WJSBqxKGePUKG4M5QYdV + * Amount: 5 HEZ + * + * Run with: ./gradlew :app:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=io.novafoundation.nova.PezkuwiLiveTransferTest + */ +class PezkuwiLiveTransferTest : BaseIntegrationTest() { + + companion object { + // Test wallet mnemonic + private const val TEST_MNEMONIC = "crucial surge north silly divert throw habit fury zebra fabric tank output" + + // Sender address (derived from mnemonic) + private const val SENDER_ADDRESS = "5DXv3Dc5xELckTgcYa2dm1TSZPgqDPxVDW3Cid4ALWpVjY3w" + + // Recipient address + private const val RECIPIENT_ADDRESS = "5HdY6U2UQF8wPwczP3SoQz28kQu1WJSBqxKGePUKG4M5QYdV" + + // Amount: 5 HEZ (with 12 decimals) + private val TRANSFER_AMOUNT = BigInteger("5000000000000") // 5 * 10^12 + } + + private val walletApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + WalletFeatureApi::class.java + ) + + private val extrinsicBuilderFactory = runtimeApi.provideExtrinsicBuilderFactory() + private val rpcCalls = runtimeApi.rpcCalls() + + /** + * LIVE TEST: Build and submit a real transfer on Pezkuwi mainnet + */ + @Test(timeout = 120000) // 2 minute timeout + fun testLiveTransfer5HEZ() = runTest { + Log.d("LiveTransferTest", "=== STARTING LIVE TRANSFER TEST ===") + Log.d("LiveTransferTest", "Sender: $SENDER_ADDRESS") + Log.d("LiveTransferTest", "Recipient: $RECIPIENT_ADDRESS") + Log.d("LiveTransferTest", "Amount: 5 HEZ") + + // Request full sync for Pezkuwi chain specifically + Log.d("LiveTransferTest", "Requesting full sync for Pezkuwi chain...") + chainRegistry.enableFullSync(Chain.Geneses.PEZKUWI) + + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + Log.d("LiveTransferTest", "Chain: ${chain.name}") + + // Create keypair from mnemonic + val keypair = createKeypairFromMnemonic(TEST_MNEMONIC) + Log.d("LiveTransferTest", "Keypair created, public key: ${keypair.publicKey.toHexString()}") + + // Create signer + val signer = RealSigner(keypair, chain) + Log.d("LiveTransferTest", "Signer created") + + // Get recipient account ID + val recipientAccountId = RECIPIENT_ADDRESS.toAccountId() + Log.d("LiveTransferTest", "Recipient AccountId: ${recipientAccountId.toHexString()}") + + // Get current nonce using sender's SS58 address + val nonce = try { + rpcCalls.getNonce(chain.id, SENDER_ADDRESS) + } catch (e: Exception) { + Log.e("LiveTransferTest", "Failed to get nonce, using 0", e) + BigInteger.ZERO + } + Log.d("LiveTransferTest", "Current nonce: $nonce") + + // Create extrinsic builder + val builder = extrinsicBuilderFactory.create( + chain = chain, + options = ExtrinsicBuilderFactory.Options(BatchMode.BATCH) + ) + Log.d("LiveTransferTest", "ExtrinsicBuilder created") + + // Use default MORTAL era (same as @pezkuwi/api) + Log.d("LiveTransferTest", "Using MORTAL era (default, same as @pezkuwi/api)") + + // Add transfer call with KEEP_ALIVE mode (same as @pezkuwi/api uses) + builder.nativeTransfer(accountId = recipientAccountId, amount = TRANSFER_AMOUNT, mode = TransferMode.KEEP_ALIVE) + Log.d("LiveTransferTest", "Transfer call added") + + // Set signer data for SUBMISSION (this is where TypeReference errors occur!) + try { + with(builder) { + signer.setSignerData(RealSigningContext(chain, nonce), SigningMode.SUBMISSION) + } + Log.d("LiveTransferTest", "Signer data set successfully") + } catch (e: Exception) { + Log.e("LiveTransferTest", "FAILED to set signer data!", e) + fail("Failed to set signer data: ${e.message}\nCause: ${e.cause?.message}\nStack: ${e.stackTraceToString()}") + return@runTest + } + + // Build the extrinsic + val extrinsic = try { + builder.buildExtrinsic() + } catch (e: Exception) { + Log.e("LiveTransferTest", "FAILED to build extrinsic!", e) + fail("Failed to build extrinsic: ${e.message}\nCause: ${e.cause?.message}\nStack: ${e.stackTraceToString()}") + return@runTest + } + + assertNotNull("Extrinsic should not be null", extrinsic) + Log.d("LiveTransferTest", "Extrinsic built: ${extrinsic.extrinsicHex}") + + // Submit the extrinsic + Log.d("LiveTransferTest", "Submitting extrinsic to network...") + try { + val hash = rpcCalls.submitExtrinsic(chain.id, extrinsic) + Log.d("LiveTransferTest", "=== TRANSFER SUBMITTED SUCCESSFULLY ===") + Log.d("LiveTransferTest", "Transaction hash: $hash") + println("LIVE TRANSFER SUCCESS! TX Hash: $hash") + } catch (e: Exception) { + Log.e("LiveTransferTest", "FAILED to submit extrinsic!", e) + fail("Failed to submit extrinsic: ${e.message}") + } + } + + /** + * Test to check type resolution in the runtime + */ + @Test(timeout = 120000) + fun testTypeResolution() = runTest { + Log.d("LiveTransferTest", "=== TESTING TYPE RESOLUTION ===") + + // Request full sync for Pezkuwi chain + chainRegistry.enableFullSync(Chain.Geneses.PEZKUWI) + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + val runtime = chainRegistry.getRuntime(chain.id) + + // Check critical types for extrinsic encoding + val typesToCheck = listOf( + "Address", + "MultiAddress", + "GenericMultiAddress", + "ExtrinsicSignature", + "MultiSignature", + "pezsp_runtime::multiaddress::MultiAddress", + "pezsp_runtime::MultiSignature", + "pezsp_runtime.multiaddress.MultiAddress", + "pezsp_runtime.MultiSignature", + "GenericExtrinsic", + "Extrinsic" + ) + + val results = mutableListOf() + for (typeName in typesToCheck) { + val type = runtime.typeRegistry[typeName] + val resolved = type?.let { + try { + // Try to get the actual type, not just alias + it.toString() + } catch (e: Exception) { + "ERROR: ${e.message}" + } + } + val status = if (type != null) "FOUND: $resolved" else "MISSING" + results.add(" $typeName: $status") + Log.d("LiveTransferTest", "$typeName: $status") + } + + // Check if extrinsic signature type is defined in metadata + val extrinsicMeta = runtime.metadata.extrinsic + Log.d("LiveTransferTest", "Extrinsic version: ${extrinsicMeta.version}") + Log.d("LiveTransferTest", "Signed extensions: ${extrinsicMeta.signedExtensions.map { it.id }}") + + // Log signed extension IDs + for (ext in extrinsicMeta.signedExtensions) { + Log.d("LiveTransferTest", "Extension: ${ext.id}") + } + + // Just log the extension names - type access might be restricted + Log.d("LiveTransferTest", "Signed extensions count: ${extrinsicMeta.signedExtensions.size}") + + // Log the extrinsic address type if available + Log.d("LiveTransferTest", "RuntimeFactory diagnostics: ${io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFactory.lastDiagnostics}") + + println("Type resolution results:\n${results.joinToString("\n")}") + } + + /** + * Test fee calculation (doesn't submit, just builds for fee estimation) + */ + @Test(timeout = 120000) + fun testFeeCalculation() = runTest { + Log.d("LiveTransferTest", "=== TESTING FEE CALCULATION ===") + + // Request full sync for Pezkuwi chain + chainRegistry.enableFullSync(Chain.Geneses.PEZKUWI) + val chain = chainRegistry.getChain(Chain.Geneses.PEZKUWI) + + // First, log type registry state + val runtime = chainRegistry.getRuntime(chain.id) + Log.d("LiveTransferTest", "TypeRegistry has ExtrinsicSignature: ${runtime.typeRegistry["ExtrinsicSignature"] != null}") + Log.d("LiveTransferTest", "TypeRegistry has MultiSignature: ${runtime.typeRegistry["MultiSignature"] != null}") + Log.d("LiveTransferTest", "TypeRegistry has Address: ${runtime.typeRegistry["Address"] != null}") + Log.d("LiveTransferTest", "TypeRegistry has MultiAddress: ${runtime.typeRegistry["MultiAddress"] != null}") + + val keypair = createKeypairFromMnemonic(TEST_MNEMONIC) + val signer = RealSigner(keypair, chain) + val recipientAccountId = RECIPIENT_ADDRESS.toAccountId() + + val builder = extrinsicBuilderFactory.create( + chain = chain, + options = ExtrinsicBuilderFactory.Options(BatchMode.BATCH) + ) + + builder.nativeTransfer(accountId = recipientAccountId, amount = TRANSFER_AMOUNT) + + // Set signer data for FEE mode + try { + with(builder) { + signer.setSignerData(RealSigningContext(chain, BigInteger.ZERO), SigningMode.FEE) + } + Log.d("LiveTransferTest", "Signer data set, building extrinsic...") + val extrinsic = builder.buildExtrinsic() + assertNotNull("Fee extrinsic should not be null", extrinsic) + Log.d("LiveTransferTest", "Extrinsic built, getting hex...") + + // The error happens when accessing extrinsicHex + try { + val hex = extrinsic.extrinsicHex + Log.d("LiveTransferTest", "Fee extrinsic built: $hex") + println("Fee calculation test PASSED!") + } catch (e: Exception) { + Log.e("LiveTransferTest", "FAILED accessing extrinsicHex!", e) + fail("Failed to get extrinsic hex: ${e.message}\nCause: ${e.cause?.message}\nStack: ${e.stackTraceToString()}") + } + } catch (e: Exception) { + Log.e("LiveTransferTest", "Fee calculation FAILED!", e) + fail("Fee calculation failed: ${e.message}\nCause: ${e.cause?.message}") + } + } + + // Helper to create keypair from mnemonic + private fun createKeypairFromMnemonic(mnemonic: String): Keypair { + val seedResult = SubstrateSeedFactory.deriveSeed32(mnemonic, password = null) + return SubstrateKeypairFactory.generate(EncryptionType.SR25519, seedResult.seed) + } + + // Real signer using actual keypair with bizinikiwi context + private inner class RealSigner( + private val keypair: Keypair, + private val chain: Chain + ) : NovaSigner, GeneralTransactionSigner { + + val accountId: ByteArray = keypair.publicKey + + // Generate proper 96-byte keypair using BizinikiwSr25519 native library + // This gives us the correct 64-byte secret key format for signing + private val bizinikiwKeypair: ByteArray by lazy { + val seedResult = SubstrateSeedFactory.deriveSeed32(TEST_MNEMONIC, password = null) + BizinikiwSr25519.keypairFromSeed(seedResult.seed) + } + + // Extract 64-byte secret key (32-byte scalar + 32-byte nonce) + private val bizinikiwSecretKey: ByteArray by lazy { + BizinikiwSr25519.secretKeyFromKeypair(bizinikiwKeypair) + } + + // Extract 32-byte public key + private val bizinikiwPublicKey: ByteArray by lazy { + BizinikiwSr25519.publicKeyFromKeypair(bizinikiwKeypair) + } + + private val keyPairSigner = KeyPairSigner( + keypair, + MultiChainEncryption.Substrate(EncryptionType.SR25519) + ) + + override suspend fun callExecutionType(): CallExecutionType { + return CallExecutionType.IMMEDIATE + } + + override val metaAccount: MetaAccount = DefaultMetaAccount( + id = 0, + globallyUniqueId = "test-wallet", + substrateAccountId = accountId, + substrateCryptoType = null, + substratePublicKey = keypair.publicKey, + ethereumAddress = null, + ethereumPublicKey = null, + isSelected = true, + name = "Test Wallet", + type = LightMetaAccount.Type.SECRETS, + chainAccounts = emptyMap(), + status = LightMetaAccount.Status.ACTIVE, + parentMetaId = null + ) + + override suspend fun getSigningHierarchy(): SubmissionHierarchy { + return SubmissionHierarchy(metaAccount, CallExecutionType.IMMEDIATE) + } + + override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + return keyPairSigner.signRaw(payload) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForSubmission(context: SigningContext) { + val nonce = context.getNonce(AccountIdKey(accountId)) + setNonce(nonce) + setVerifySignature(this@RealSigner, accountId) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForFee(context: SigningContext) { + setSignerDataForSubmission(context) + } + + override suspend fun submissionSignerAccountId(chain: Chain): AccountId { + return accountId + } + + override suspend fun maxCallsPerTransaction(): Int? { + return null + } + + override suspend fun signInheritedImplication( + inheritedImplication: InheritedImplication, + accountId: AccountId + ): SignatureWrapper { + // Get the SDK's signing payload (SCALE format - same as @pezkuwi/api) + val sdkPayloadBytes = inheritedImplication.signingPayload() + + Log.d("LiveTransferTest", "=== SIGNING PAYLOAD (SDK - SCALE) ===") + Log.d("LiveTransferTest", "SDK Payload hex: ${sdkPayloadBytes.toHexString()}") + Log.d("LiveTransferTest", "SDK Payload length: ${sdkPayloadBytes.size} bytes") + + // Debug: show first bytes to verify format + if (sdkPayloadBytes.size >= 42) { + val callData = sdkPayloadBytes.copyOfRange(0, 42) + val extensions = sdkPayloadBytes.copyOfRange(42, sdkPayloadBytes.size) + Log.d("LiveTransferTest", "Call data (42 bytes): ${callData.toHexString()}") + Log.d("LiveTransferTest", "Extensions (${extensions.size} bytes): ${extensions.toHexString()}") + } + + // Use BizinikiwSr25519 native library with "bizinikiwi" signing context + Log.d("LiveTransferTest", "=== USING BIZINIKIWI CONTEXT ===") + Log.d("LiveTransferTest", "Bizinikiwi public key: ${bizinikiwPublicKey.toHexString()}") + Log.d("LiveTransferTest", "Bizinikiwi secret key size: ${bizinikiwSecretKey.size} bytes") + + val signatureBytes = BizinikiwSr25519.sign( + publicKey = bizinikiwPublicKey, + secretKey = bizinikiwSecretKey, + message = sdkPayloadBytes + ) + + Log.d("LiveTransferTest", "=== SIGNATURE PRODUCED ===") + Log.d("LiveTransferTest", "Signature bytes: ${signatureBytes.toHexString()}") + Log.d("LiveTransferTest", "Signature length: ${signatureBytes.size} bytes") + + // Verify the signature locally before sending + val verifyResult = BizinikiwSr25519.verify(signatureBytes, sdkPayloadBytes, bizinikiwPublicKey) + Log.d("LiveTransferTest", "Local verification: $verifyResult") + + return SignatureWrapper.Sr25519(signatureBytes) + } + } + + private class RealSigningContext( + override val chain: Chain, + private val nonceValue: BigInteger + ) : SigningContext { + override suspend fun getNonce(accountId: AccountIdKey): Nonce { + return Nonce.ZERO + nonceValue + } + } + + private fun ByteArray.toHexString(): String { + return joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/StakingDashboardIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/StakingDashboardIntegrationTest.kt new file mode 100644 index 0000000..6140597 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/StakingDashboardIntegrationTest.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova + +import android.util.Log +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingDashboard +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.isSyncing +import kotlinx.coroutines.flow.launchIn +import org.junit.Test +import java.lang.reflect.Type + +class StakingDashboardIntegrationTest: BaseIntegrationTest() { + + private val stakingApi = FeatureUtils.getFeature(context, StakingFeatureApi::class.java) + + private val interactor = stakingApi.dashboardInteractor + + private val updateSystem = stakingApi.dashboardUpdateSystem + + private val gson = GsonBuilder() + .registerTypeHierarchyAdapter(AggregatedStakingDashboardOption::class.java, AggregatedStakingDashboardOptionDesirializer()) + .create() + + @Test + fun syncStakingDashboard() = runTest { + updateSystem.start() + .inBackground() + .launchIn(this) + + interactor.stakingDashboardFlow() + .inBackground() + .collect(::logDashboard) + } + + private fun logDashboard(dashboard: ExtendedLoadingState) { + if (dashboard !is ExtendedLoadingState.Loaded) return + + val serialized = gson.toJson(dashboard) + + val message = """ + Dashboard state: + Syncing items: ${dashboard.data.syncingItemsCount()} + $serialized + """.trimIndent() + + Log.d("StakingDashboardIntegrationTest", message) + } + + private class AggregatedStakingDashboardOptionDesirializer : JsonSerializer> { + override fun serialize(src: AggregatedStakingDashboardOption<*>, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + return JsonObject().apply { + add("chain", JsonPrimitive(src.chain.name)) + add("stakingState", context.serialize(src.stakingState)) + add("syncing", context.serialize(src.syncingStage)) + } + } + } + + private fun StakingDashboard.syncingItemsCount(): Int { + return withoutStake.count { it.syncingStage.isSyncing() } + hasStake.count { it.syncingStage.isSyncing() } + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/TuringAutomationIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/TuringAutomationIntegrationTest.kt new file mode 100644 index 0000000..7c7b4ba --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/TuringAutomationIntegrationTest.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova + +import android.util.Log +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.AutomationAction +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.OptimalAutomationRequest +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.findChain +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.math.BigInteger + +class TuringAutomationIntegrationTest : BaseIntegrationTest() { + + private val stakingApi = FeatureUtils.getFeature(context, StakingFeatureApi::class.java) + private val automationTasksRepository = stakingApi.turingAutomationRepository + + @Test + fun calculateOptimalAutoCompounding(){ + runBlocking { + val chain = chainRegistry.findTuringChain() + val request = OptimalAutomationRequest( + collator = "6AEG2WKRVvZteWWT3aMkk2ZE21FvURqiJkYpXimukub8Zb9C", + amount = BigInteger("1000000000000") + ) + + val response = automationTasksRepository.calculateOptimalAutomation(chain.id, request) + + Log.d(LOG_TAG, response.toString()) + } + } + + @Test + fun calculateAutoCompoundExecutionFees(){ + runBlocking { + val chain = chainRegistry.findTuringChain() + val fees = automationTasksRepository.getTimeAutomationFees(chain.id, AutomationAction.AUTO_COMPOUND_DELEGATED_STAKE, executions = 1) + + Log.d(LOG_TAG, fees.toString()) + } + } + + private suspend fun ChainRegistry.findTuringChain() = findChain { it.name == "Turing" }!! +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/Web3jServiceIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/Web3jServiceIntegrationTest.kt new file mode 100644 index 0000000..49d3c71 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/Web3jServiceIntegrationTest.kt @@ -0,0 +1,137 @@ +package io.novafoundation.nova + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.second +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.core.ethereum.log.Topic +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ethereum.contract.base.querySingle +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Queries +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import io.novafoundation.nova.runtime.multiNetwork.getEthereumApiOrThrow +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.web3j.abi.EventEncoder +import org.web3j.abi.TypeEncoder +import org.web3j.abi.datatypes.Address +import org.web3j.protocol.core.DefaultBlockParameterName +import java.math.BigInteger + +class Erc20Transfer( + val txHash: String, + val blockNumber: String, + val from: String, + val to: String, + val contract: String, + val amount: BigInteger, +) + +class Web3jServiceIntegrationTest : BaseIntegrationTest() { + + @Test + fun shouldFetchBalance(): Unit = runBlocking { + val web3j = moonbeamWeb3j() + val balance = web3j.ethGetBalance("0xf977814e90da44bfa03b6295a0616a897441acec", DefaultBlockParameterName.LATEST).sendSuspend() + Log.d(LOG_TAG, balance.balance.toString()) + } + + @Test + fun shouldFetchComplexStructure(): Unit = runBlocking { + val web3j = moonbeamWeb3j() + val block = web3j.ethGetBlockByNumber(DefaultBlockParameterName.LATEST, true).sendSuspend() + Log.d(LOG_TAG, block.block.hash) + } + + @Test + fun shouldSubscribeToNewHeadEvents(): Unit = runBlocking { + val web3j = moonbeamWeb3j() + val newHead = web3j.newHeadsNotifications().asFlow().first() + + Log.d(LOG_TAG, "New head appended to chain: ${newHead.params.result.hash}") + } + + @Test + fun shouldSubscribeBalances(): Unit = runBlocking { + val web3j = moonbeamWeb3j() + val accountAddress = "0x4A43C16107591AE5Ec904e584ed4Bb05386F98f7" + val moonbeamUsdc = "0x818ec0a7fe18ff94269904fced6ae3dae6d6dc0b" + + val balanceUpdates = web3j.erc20BalanceFlow(accountAddress, moonbeamUsdc).take(2).toList() + + error("Initial balance: ${balanceUpdates.first()}, new balance: ${balanceUpdates.second()}") + } + + private fun Web3Api.erc20BalanceFlow(account: String, contract: String): Flow { + return flow { + val erc20 = Erc20Standard().querySingle(contract, web3j = this@erc20BalanceFlow) + val initialBalance = erc20.balanceOfAsync(account).await() + + emit(initialBalance) + + val changes = accountErcTransfersFlow(account).map { + erc20.balanceOfAsync(account).await() + } + + emitAll(changes) + } + } + + + private fun Web3Api.accountErcTransfersFlow(address: String): Flow { + val addressTopic = TypeEncoder.encode(Address(address)) + + val transferEvent = Erc20Queries.TRANSFER_EVENT + val transferEventSignature = EventEncoder.encode(transferEvent) + val contractAddresses = emptyList() // everything + + val erc20SendTopic = listOf( + Topic.Single(transferEventSignature), // zero-th topic is event signature + Topic.AnyOf(addressTopic), // our account as `from` + ) + + val erc20ReceiveTopic = listOf( + Topic.Single(transferEventSignature), // zero-th topic is event signature + Topic.Any, // anyone is `from` + Topic.AnyOf(addressTopic) // out account as `to` + ) + + val receiveTransferNotifications = logsNotifications(contractAddresses, erc20ReceiveTopic) + val sendTransferNotifications = logsNotifications(contractAddresses, erc20SendTopic) + + val transferNotifications = merge(receiveTransferNotifications, sendTransferNotifications) + + return transferNotifications.map { logNotification -> + val log = logNotification.params.result + + val contract = log.address + val event = Erc20Queries.parseTransferEvent(log) + + Erc20Transfer( + txHash = log.transactionHash, + blockNumber = log.blockNumber, + from = event.from.value, + to = event.to.value, + contract = contract, + amount = event.amount.value, + ) + } + } + + private suspend fun moonbeamWeb3j(): Web3Api { + val moonbeamChainId = "fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d" + + return chainRegistry.getEthereumApiOrThrow(moonbeamChainId, Chain.Node.ConnectionType.WSS) + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/BalancesIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/BalancesIntegrationTest.kt new file mode 100644 index 0000000..ca9ea0d --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/BalancesIntegrationTest.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.balances + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.gson.Gson +import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountInfo +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.common.utils.hasModule +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.domain.account.model.DefaultMetaAccount +import io.novafoundation.nova.runtime.BuildConfig.TEST_CHAINS_URL +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.runtime.di.RuntimeComponent +import io.novafoundation.nova.runtime.extrinsic.systemRemark +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import io.novafoundation.nova.runtime.multiNetwork.getSocket +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.wsrpc.networkStateFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import java.math.BigInteger +import java.math.BigInteger.ZERO +import java.net.URL +import kotlin.time.Duration.Companion.seconds + +@RunWith(Parameterized::class) +class BalancesIntegrationTest( + private val testChainId: String, + private val testChainName: String, + private val testAccount: String +) { + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{1}") + fun data(): List> { + val arrayOfNetworks: Array = Gson().fromJson(URL(TEST_CHAINS_URL).readText()) + return arrayOfNetworks.map { arrayOf(it.chainId, it.name, it.account) } + } + + class TestData( + val chainId: String, + val name: String, + val account: String? + ) + } + + private val maxAmount = BigInteger.valueOf(10).pow(30) + + private val runtimeApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + RuntimeApi::class.java + ) + + private val accountApi = FeatureUtils.getFeature( + ApplicationProvider.getApplicationContext(), + AccountFeatureApi::class.java + ) + + private val chainRegistry = runtimeApi.chainRegistry() + private val externalRequirementFlow = runtimeApi.externalRequirementFlow() + + private val remoteStorage = runtimeApi.remoteStorageSource() + + private val extrinsicService = accountApi.extrinsicService() + + @Before + fun before() = runBlocking { + externalRequirementFlow.emit(ChainConnection.ExternalRequirement.ALLOWED) + } + + @Test + fun testBalancesLoading() = runBlocking(Dispatchers.Default) { + val chains = chainRegistry.getChain(testChainId) + + val freeBalance = testBalancesInChainAsync(chains, testAccount)?.data?.free ?: error("Balance was null") + + assertTrue("Free balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) + assertTrue("Free balance: $freeBalance is greater than 0", ZERO < freeBalance) + } + + @Test + fun testFeeLoading() = runBlocking(Dispatchers.Default) { + val chains = chainRegistry.getChain(testChainId) + + testFeeLoadingAsync(chains) + + Unit + } + + private suspend fun testBalancesInChainAsync(chain: Chain, currentAccount: String): AccountInfo? { + return coroutineScope { + try { + withTimeout(80.seconds) { + remoteStorage.query( + chainId = chain.id, + keyBuilder = { it.metadata.system().storage("Account").storageKey(it, currentAccount.fromHex()) }, + binding = { scale, runtime -> scale?.let { bindAccountInfo(scale, runtime) } } + ) + } + } catch (e: Exception) { + throw Exception("Socket state: ${chainRegistry.getSocket(chain.id).networkStateFlow().first()}, error: ${e.message}", e) + } + } + } + + private suspend fun testFeeLoadingAsync(chain: Chain) { + return coroutineScope { + withTimeout(80.seconds) { + extrinsicService.estimateFee(chain, testTransactionOrigin()) { + systemRemark(byteArrayOf(0)) + + val haveBatch = runtime.metadata.hasModule("Utility") + if (haveBatch) { + systemRemark(byteArrayOf(0)) + } + } + } + } + } + + private fun testTransactionOrigin(): TransactionOrigin = TransactionOrigin.Wallet( + createTestMetaAccount() + ) + + private fun createTestMetaAccount(): MetaAccount { + val metaAccount = DefaultMetaAccount( + id = 0, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + substratePublicKey = testAccount.fromHex(), + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = testAccount.fromHex(), + ethereumAddress = testAccount.fromHex(), + ethereumPublicKey = testAccount.fromHex(), + isSelected = true, + name = "Test", + type = LightMetaAccount.Type.WATCH_ONLY, + status = LightMetaAccount.Status.ACTIVE, + chainAccounts = emptyMap(), + parentMetaId = null + ) + return metaAccount + } +} diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..8231c46 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + [Debug] Pezkuwi + \ No newline at end of file diff --git a/app/src/develop/res/values/strings.xml b/app/src/develop/res/values/strings.xml new file mode 100644 index 0000000..5f8e831 --- /dev/null +++ b/app/src/develop/res/values/strings.xml @@ -0,0 +1,4 @@ + + + [Dev] Pezkuwi + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..efd4320 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/io/novafoundation/nova/app/App.kt b/app/src/main/java/io/novafoundation/nova/app/App.kt new file mode 100644 index 0000000..48060ea --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/App.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.app + +import android.app.Application +import android.content.Context +import android.content.res.Configuration +import com.walletconnect.android.Core +import com.walletconnect.android.CoreClient +import com.walletconnect.android.relay.ConnectionType +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Web3Wallet +import io.novafoundation.nova.app.di.app.AppComponent +import io.novafoundation.nova.app.di.deps.FeatureHolderManager +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.LanguagesHolder +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo.BranchIOLinkHandler +import io.novafoundation.nova.feature_wallet_connect_impl.BuildConfig +import javax.inject.Inject + +private const val WC_REDIRECT_URL = "pezkuwiwallet://request" + +open class App : Application(), FeatureContainer { + + @Inject + lateinit var featureHolderManager: FeatureHolderManager + + private lateinit var appComponent: AppComponent + + private val languagesHolder: LanguagesHolder = LanguagesHolder() + + // App global scope using for processes that should work while app is alive + private val rootScope = RootScope() + + override fun attachBaseContext(base: Context) { + val contextManager = ContextManager.getInstanceOrInit(base, languagesHolder) + super.attachBaseContext(contextManager.setLocale(base)) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + val contextManager = ContextManager.getInstanceOrInit(this, languagesHolder) + contextManager.setLocale(this) + } + + override fun onCreate() { + super.onCreate() + val contextManger = ContextManager.getInstanceOrInit(this, languagesHolder) + + appComponent = io.novafoundation.nova.app.di.app.DaggerAppComponent + .builder() + .application(this) + .contextManager(contextManger) + .rootScope(rootScope) + .build() + + appComponent.inject(this) + + BranchIOLinkHandler.Initializer.init(this) + + initializeWalletConnect() + } + + override fun getFeature(key: Class<*>): T { + return featureHolderManager.getFeature(key)!! + } + + override fun releaseFeature(key: Class<*>) { + featureHolderManager.releaseFeature(key) + } + + override fun commonApi(): CommonApi { + return appComponent + } + + private fun initializeWalletConnect() { + val projectId = BuildConfig.WALLET_CONNECT_PROJECT_ID + val relayUrl = "relay.walletconnect.com" + val serverUrl = "wss://$relayUrl?projectId=$projectId" + val connectionType = ConnectionType.MANUAL + val appMetaData = Core.Model.AppMetaData( + name = "Pezkuwi Wallet", + description = "Next-gen wallet for Pezkuwichain and Polkadot ecosystem", + url = "https://pezkuwichain.io/", + icons = listOf("https://raw.githubusercontent.com/pezkuwichain/branding/master/logos/Pezkuwi_Wallet_Sun_Color.png"), + redirect = WC_REDIRECT_URL + ) + + CoreClient.initialize(relayServerUrl = serverUrl, connectionType = connectionType, application = this, metaData = appMetaData) { error -> + // TODO maybe re-initialize client + } + + val initParams = Wallet.Params.Init(core = CoreClient) + + Web3Wallet.initialize(initParams) { error -> + // TODO maybe re-initialize client + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/AppComponent.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/AppComponent.kt new file mode 100644 index 0000000..f5e2f28 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/AppComponent.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.app.di.app + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.app.App +import io.novafoundation.nova.app.di.app.navigation.NavigationModule +import io.novafoundation.nova.app.di.deps.ComponentHolderModule +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.modules.CommonModule +import io.novafoundation.nova.common.di.modules.NetworkModule +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.utils.coroutines.RootScope + +@ApplicationScope +@Component( + modules = [ + AppModule::class, + CommonModule::class, + NetworkModule::class, + NavigationModule::class, + ComponentHolderModule::class, + FeatureManagerModule::class + ] +) +interface AppComponent : CommonApi { + + @Component.Builder + interface Builder { + + @BindsInstance + fun application(application: App): Builder + + @BindsInstance + fun contextManager(contextManager: ContextManager): Builder + + @BindsInstance + fun rootScope(rootScope: RootScope): Builder + + fun build(): AppComponent + } + + fun inject(app: App) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/AppModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/AppModule.kt new file mode 100644 index 0000000..1249831 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/AppModule.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.app.di.app + +import android.content.Context +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.App +import io.novafoundation.nova.app.root.presentation.common.RealBuildTypeProvider +import io.novafoundation.nova.app.root.presentation.common.RootActivityIntentProvider +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.interfaces.BuildTypeProvider + +@Module +class AppModule { + + @ApplicationScope + @Provides + fun provideContext(application: App): Context { + return application + } + + @Provides + @ApplicationScope + fun provideRootActivityIntentProvider(context: Context): ActivityIntentProvider = RootActivityIntentProvider(context) + + @Provides + @ApplicationScope + fun provideBuildTypeProvider(): BuildTypeProvider = RealBuildTypeProvider() +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/FeatureManagerModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/FeatureManagerModule.kt new file mode 100644 index 0000000..40a7497 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/FeatureManagerModule.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.app.di.app + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.di.deps.FeatureHolderManager +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.scope.ApplicationScope + +@Module +class FeatureManagerModule { + + @ApplicationScope + @Provides + fun provideFeatureHolderManager(featureApiHolderMap: @JvmSuppressWildcards Map, FeatureApiHolder>): FeatureHolderManager { + return FeatureHolderManager(featureApiHolderMap) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/AccountMigrationNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/AccountMigrationNavigationModule.kt new file mode 100644 index 0000000..4209863 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/AccountMigrationNavigationModule.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.accountmigration.AccountMigrationNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter + +@Module +class AccountMigrationNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter( + navigationHoldersRegistry: NavigationHoldersRegistry + ): AccountMigrationRouter = AccountMigrationNavigator( + navigationHoldersRegistry = navigationHoldersRegistry + ) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/AccountNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/AccountNavigationModule.kt new file mode 100644 index 0000000..8c8bd1f --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/AccountNavigationModule.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.account.PolkadotVaultVariantSignCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.account.ScanSeedCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.account.SelectAddressCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.account.SelectMultipleWalletsCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.account.SelectSingleWalletCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.account.SelectWalletCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.cloudBackup.ChangeBackupPasswordCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.cloudBackup.RestoreBackupPasswordCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.cloudBackup.SyncWalletsBackupPasswordCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.pincode.PinCodeTwoFactorVerificationCommunicatorImpl +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedCommunicator +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter + +@Module +class AccountNavigationModule { + + @Provides + @ApplicationScope + fun providePinCodeTwoFactorVerificationCommunicator( + navigationHoldersRegistry: NavigationHoldersRegistry + ): PinCodeTwoFactorVerificationCommunicator = PinCodeTwoFactorVerificationCommunicatorImpl(navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun provideSelectWalletCommunicator( + navigationHoldersRegistry: NavigationHoldersRegistry + ): SelectWalletCommunicator = SelectWalletCommunicatorImpl(navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun provideParitySignerCommunicator( + navigationHoldersRegistry: NavigationHoldersRegistry + ): PolkadotVaultVariantSignCommunicator = PolkadotVaultVariantSignCommunicatorImpl(navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun provideSelectAddressCommunicator( + router: AssetsRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): SelectAddressCommunicator = SelectAddressCommunicatorImpl(router, navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun provideScanSeedCommunicator( + navigationHoldersRegistry: NavigationHoldersRegistry + ): ScanSeedCommunicator = ScanSeedCommunicatorImpl(navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun provideSelectSingleWalletCommunicator( + router: AssetsRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): SelectSingleWalletCommunicator = SelectSingleWalletCommunicatorImpl(router) + + @Provides + @ApplicationScope + fun provideSelectMultipleWalletsCommunicator( + router: AssetsRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): SelectMultipleWalletsCommunicator = SelectMultipleWalletsCommunicatorImpl(router, navigationHoldersRegistry) + + @ApplicationScope + @Provides + fun provideAccountRouter(navigator: Navigator): AccountRouter = navigator + + @Provides + @ApplicationScope + fun providePushGovernanceSettingsCommunicator( + router: AccountRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): SyncWalletsBackupPasswordCommunicator = SyncWalletsBackupPasswordCommunicatorImpl(router, navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun provideChangeBackupPasswordCommunicator( + router: AccountRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): ChangeBackupPasswordCommunicator = ChangeBackupPasswordCommunicatorImpl(router, navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun provideRestoreBackupPasswordCommunicator( + router: AccountRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): RestoreBackupPasswordCommunicator = RestoreBackupPasswordCommunicatorImpl(router, navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/AssetNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/AssetNavigationModule.kt new file mode 100644 index 0000000..a155eb4 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/AssetNavigationModule.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.topup.TopUpAddressCommunicatorImpl +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator + +@Module +class AssetNavigationModule { + + @ApplicationScope + @Provides + fun provideTopUpAddressCommunicator(navigationHoldersRegistry: NavigationHoldersRegistry): TopUpAddressCommunicator { + return TopUpAddressCommunicatorImpl(navigationHoldersRegistry) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/BuyNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/BuyNavigationModule.kt new file mode 100644 index 0000000..dfdd0d3 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/BuyNavigationModule.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.buy.BuyNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_buy_impl.presentation.BuyRouter + +@Module +class BuyNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter(navigationHoldersRegistry: NavigationHoldersRegistry): BuyRouter = + BuyNavigator(navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/ChainMigrationNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/ChainMigrationNavigationModule.kt new file mode 100644 index 0000000..0488186 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/ChainMigrationNavigationModule.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.chainMigration.ChainMigrationNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_ahm_impl.presentation.ChainMigrationRouter + +@Module +class ChainMigrationNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter(navigationHoldersRegistry: NavigationHoldersRegistry): ChainMigrationRouter = + ChainMigrationNavigator(navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/CloudBackupNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/CloudBackupNavigationModule.kt new file mode 100644 index 0000000..1653b39 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/CloudBackupNavigationModule.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.cloudBackup.CloudBackupNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_cloud_backup_impl.presentation.CloudBackupRouter + +@Module +class CloudBackupNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter(navigationHoldersRegistry: NavigationHoldersRegistry): CloudBackupRouter = + CloudBackupNavigator(navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/CurrencyNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/CurrencyNavigationModule.kt new file mode 100644 index 0000000..d4d6044 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/CurrencyNavigationModule.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.wallet.CurrencyNavigator +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_currency_api.presentation.CurrencyRouter + +@Module +class CurrencyNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter( + rootRouter: RootRouter, + navigationHoldersRegistry: NavigationHoldersRegistry, + ): CurrencyRouter = CurrencyNavigator(rootRouter, navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/DAppNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/DAppNavigationModule.kt new file mode 100644 index 0000000..f446d89 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/DAppNavigationModule.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.dApp.DAppNavigator +import io.novafoundation.nova.app.root.navigation.navigators.dApp.DAppSearchCommunicatorImpl +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator + +@Module +class DAppNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter( + navigationHoldersRegistry: NavigationHoldersRegistry + ): DAppRouter = DAppNavigator(navigationHoldersRegistry) + + @ApplicationScope + @Provides + fun provideSearchDappCommunicator(navigationHoldersRegistry: NavigationHoldersRegistry): DAppSearchCommunicator { + return DAppSearchCommunicatorImpl(navigationHoldersRegistry) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/ExternalSignNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/ExternalSignNavigationModule.kt new file mode 100644 index 0000000..4d3848e --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/ExternalSignNavigationModule.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.externalSign.ExternalSignCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.externalSign.ExternalSignNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter + +@Module +class ExternalSignNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter(navigationHoldersRegistry: NavigationHoldersRegistry): ExternalSignRouter = + ExternalSignNavigator(navigationHoldersRegistry) + + @ApplicationScope + @Provides + fun provideSignExtrinsicCommunicator( + navigationHoldersRegistry: NavigationHoldersRegistry, + automaticInteractionGate: AutomaticInteractionGate, + ): ExternalSignCommunicator { + return ExternalSignCommunicatorImpl(navigationHoldersRegistry, automaticInteractionGate) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/GiftNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/GiftNavigationModule.kt new file mode 100644 index 0000000..2cc5a85 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/GiftNavigationModule.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.gift.GiftNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter + +@Module +class GiftNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter(commonDelegate: Navigator, navigationHoldersRegistry: NavigationHoldersRegistry): GiftRouter = + GiftNavigator(commonDelegate, navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/GovernanceNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/GovernanceNavigationModule.kt new file mode 100644 index 0000000..6baa546 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/GovernanceNavigationModule.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.governance.GovernanceNavigator +import io.novafoundation.nova.app.root.navigation.navigators.governance.SelectTracksCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.governance.TinderGovVoteCommunicatorImpl +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksCommunicator +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.TinderGovVoteCommunicator + +@Module +class GovernanceNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter( + navigationHoldersRegistry: NavigationHoldersRegistry, + commonNavigator: Navigator, + contextManager: ContextManager, + dAppRouter: DAppRouter + ): GovernanceRouter = GovernanceNavigator(navigationHoldersRegistry, commonNavigator, contextManager, dAppRouter) + + @Provides + @ApplicationScope + fun provideSelectTracksCommunicator( + router: GovernanceRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): SelectTracksCommunicator = SelectTracksCommunicatorImpl(router, navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun provideTinderGovVoteCommunicator( + router: GovernanceRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): TinderGovVoteCommunicator = TinderGovVoteCommunicatorImpl(router, navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/LedgerNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/LedgerNavigationModule.kt new file mode 100644 index 0000000..9879c7b --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/LedgerNavigationModule.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.ledger.LedgerNavigator +import io.novafoundation.nova.app.root.navigation.navigators.ledger.LedgerSignCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.ledger.SelectLedgerAddressCommunicatorImpl +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_account_api.presenatation.sign.LedgerSignCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.SelectLedgerAddressInterScreenCommunicator + +@Module +class LedgerNavigationModule { + + @ApplicationScope + @Provides + fun provideSelectLedgerAddressCommunicator(navigationHoldersRegistry: NavigationHoldersRegistry): SelectLedgerAddressInterScreenCommunicator { + return SelectLedgerAddressCommunicatorImpl(navigationHoldersRegistry) + } + + @Provides + @ApplicationScope + fun provideLedgerSignerCommunicator( + navigationHoldersRegistry: NavigationHoldersRegistry + ): LedgerSignCommunicator = LedgerSignCommunicatorImpl(navigationHoldersRegistry) + + @ApplicationScope + @Provides + fun provideRouter(router: AccountRouter, navigationHoldersRegistry: NavigationHoldersRegistry): LedgerRouter = + LedgerNavigator(router, navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/MultisigNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/MultisigNavigationModule.kt new file mode 100644 index 0000000..c79b340 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/MultisigNavigationModule.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.multisig.MultisigOperationsNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter + +@Module +class MultisigNavigationModule { + + @ApplicationScope + @Provides + fun provideOperationsRouter( + navigationHoldersRegistry: NavigationHoldersRegistry, + commonDelegate: Navigator + ): MultisigOperationsRouter = MultisigOperationsNavigator(navigationHoldersRegistry, commonDelegate) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/NavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/NavigationModule.kt new file mode 100644 index 0000000..ec7637b --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/NavigationModule.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.di.app.navigation.staking.StakingNavigationModule +import io.novafoundation.nova.app.root.navigation.holders.RootNavigationHolder +import io.novafoundation.nova.app.root.navigation.holders.SplitScreenNavigationHolder +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.navigation.DelayedNavigationRouter +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.splash.SplashRouter + +@Module( + includes = [ + AccountNavigationModule::class, + AssetNavigationModule::class, + DAppNavigationModule::class, + NftNavigationModule::class, + StakingNavigationModule::class, + LedgerNavigationModule::class, + CurrencyNavigationModule::class, + GovernanceNavigationModule::class, + WalletConnectNavigationModule::class, + VoteNavigationModule::class, + VersionsNavigationModule::class, + ExternalSignNavigationModule::class, + SettingsNavigationModule::class, + SwapNavigationModule::class, + BuyNavigationModule::class, + PushNotificationsNavigationModule::class, + CloudBackupNavigationModule::class, + AssetNavigationModule::class, + AccountMigrationNavigationModule::class, + MultisigNavigationModule::class, + ChainMigrationNavigationModule::class, + WalletNavigationModule::class, + GiftNavigationModule::class + ] +) +class NavigationModule { + + @ApplicationScope + @Provides + fun provideMainNavigatorHolder( + contextManager: ContextManager + ): SplitScreenNavigationHolder = SplitScreenNavigationHolder(contextManager) + + @ApplicationScope + @Provides + fun provideDappNavigatorHolder( + contextManager: ContextManager + ): RootNavigationHolder = RootNavigationHolder(contextManager) + + @ApplicationScope + @Provides + fun provideNavigationHoldersRegistry( + rootNavigatorHolder: RootNavigationHolder, + splitScreenNavigationHolder: SplitScreenNavigationHolder, + ): NavigationHoldersRegistry { + return NavigationHoldersRegistry(splitScreenNavigationHolder, rootNavigatorHolder) + } + + @ApplicationScope + @Provides + fun provideNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + walletConnectRouter: WalletConnectRouter + ): Navigator = Navigator(navigationHoldersRegistry, walletConnectRouter) + + @Provides + @ApplicationScope + fun provideRootRouter(navigator: Navigator): RootRouter = navigator + + @ApplicationScope + @Provides + fun provideSplashRouter(navigator: Navigator): SplashRouter = navigator + + @ApplicationScope + @Provides + fun provideOnboardingRouter(navigator: Navigator): OnboardingRouter = navigator + + @ApplicationScope + @Provides + fun provideAssetsRouter(navigator: Navigator): AssetsRouter = navigator + + @ApplicationScope + @Provides + fun provideCrowdloanRouter(navigator: Navigator): CrowdloanRouter = navigator + + @ApplicationScope + @Provides + fun provideDelayedNavigationRouter(navigator: Navigator): DelayedNavigationRouter = navigator +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/NftNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/NftNavigationModule.kt new file mode 100644 index 0000000..1cadfbc --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/NftNavigationModule.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.nft.NftNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_nft_impl.NftRouter + +@Module +class NftNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter(navigationHoldersRegistry: NavigationHoldersRegistry): NftRouter = + NftNavigator(navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/PushNotificationsNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/PushNotificationsNavigationModule.kt new file mode 100644 index 0000000..c368234 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/PushNotificationsNavigationModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.push.PushGovernanceSettingsCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.push.PushMultisigSettingsCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.push.PushNotificationsNavigator +import io.novafoundation.nova.app.root.navigation.navigators.push.PushStakingSettingsCommunicatorImpl +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsCommunicator + +@Module +class PushNotificationsNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter(navigationHoldersRegistry: NavigationHoldersRegistry): PushNotificationsRouter = + PushNotificationsNavigator(navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun providePushGovernanceSettingsCommunicator( + router: PushNotificationsRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): PushGovernanceSettingsCommunicator = PushGovernanceSettingsCommunicatorImpl(router, navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun providePushStakingSettingsCommunicator( + router: PushNotificationsRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): PushStakingSettingsCommunicator = PushStakingSettingsCommunicatorImpl(router, navigationHoldersRegistry) + + @Provides + @ApplicationScope + fun providePushMultisigSettingsCommunicator( + router: PushNotificationsRouter, + navigationHoldersRegistry: NavigationHoldersRegistry + ): PushMultisigSettingsCommunicator = PushMultisigSettingsCommunicatorImpl(router, navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/SettingsNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/SettingsNavigationModule.kt new file mode 100644 index 0000000..676ccb9 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/SettingsNavigationModule.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.settings.SettingsNavigator +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter + +@Module +class SettingsNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter( + rootRouter: RootRouter, + navigationHoldersRegistry: NavigationHoldersRegistry, + walletConnectRouter: WalletConnectRouter, + navigator: Navigator, + ): SettingsRouter = SettingsNavigator(navigationHoldersRegistry, rootRouter, walletConnectRouter, navigator) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/SwapNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/SwapNavigationModule.kt new file mode 100644 index 0000000..813ff4d --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/SwapNavigationModule.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.swap.SwapNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter + +@Module +class SwapNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter( + navigationHoldersRegistry: NavigationHoldersRegistry, + commonDelegate: Navigator + ): SwapRouter = SwapNavigator(navigationHoldersRegistry, commonDelegate) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/VersionsNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/VersionsNavigationModule.kt new file mode 100644 index 0000000..fd4135c --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/VersionsNavigationModule.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.versions.VersionsNavigator +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter + +@Module +class VersionsNavigationModule { + + @Provides + @ApplicationScope + fun provideRouter( + navigationHoldersRegistry: NavigationHoldersRegistry, + contextManager: ContextManager, + appLinksProvider: AppLinksProvider + ): VersionsRouter = VersionsNavigator(navigationHoldersRegistry, contextManager, appLinksProvider.storeUrl) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/VoteNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/VoteNavigationModule.kt new file mode 100644 index 0000000..dd2248e --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/VoteNavigationModule.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.vote.VoteNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_vote.presentation.VoteRouter + +@Module +class VoteNavigationModule { + + @Provides + @ApplicationScope + fun provideVoteRouter(navigator: Navigator): VoteRouter = VoteNavigator(navigator) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/WalletConnectNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/WalletConnectNavigationModule.kt new file mode 100644 index 0000000..7c3b7fe --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/WalletConnectNavigationModule.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.walletConnect.ApproveSessionCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.walletConnect.WalletConnectNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator + +@Module +class WalletConnectNavigationModule { + + @Provides + @ApplicationScope + fun provideApproveSessionCommunicator( + navigationHoldersRegistry: NavigationHoldersRegistry, + automaticInteractionGate: AutomaticInteractionGate, + ): ApproveSessionCommunicator = ApproveSessionCommunicatorImpl(navigationHoldersRegistry, automaticInteractionGate) + + @ApplicationScope + @Provides + fun provideRouter(navigationHoldersRegistry: NavigationHoldersRegistry): WalletConnectRouter = + WalletConnectNavigator(navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/WalletNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/WalletNavigationModule.kt new file mode 100644 index 0000000..06f42e6 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/WalletNavigationModule.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.di.app.navigation + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.wallet.WalletNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_wallet_impl.presentation.WalletRouter + +@Module +class WalletNavigationModule { + + @ApplicationScope + @Provides + fun provideRouter( + navigationHoldersRegistry: NavigationHoldersRegistry, + commonDelegate: Navigator + ): WalletRouter = WalletNavigator(commonDelegate, navigationHoldersRegistry) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/MythosStakingNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/MythosStakingNavigationModule.kt new file mode 100644 index 0000000..8205692 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/MythosStakingNavigationModule.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.app.di.app.navigation.staking + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.staking.mythos.MythosStakingNavigator +import io.novafoundation.nova.app.root.navigation.navigators.staking.mythos.SelectMythCollatorSettingsInterScreenCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.staking.mythos.SelectMythosCollatorInterScreenCommunicatorImpl +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsInterScreenCommunicator + +@Module +class MythosStakingNavigationModule { + + @Provides + @ApplicationScope + fun provideMythosStakingRouter( + navigationHoldersRegistry: NavigationHoldersRegistry, + stakingDashboardRouter: StakingDashboardRouter, + ): MythosStakingRouter { + return MythosStakingNavigator(navigationHoldersRegistry, stakingDashboardRouter) + } + + @Provides + @ApplicationScope + fun provideSelectCollatorCommunicator(navigationHoldersRegistry: NavigationHoldersRegistry): SelectMythosInterScreenCommunicator { + return SelectMythosCollatorInterScreenCommunicatorImpl(navigationHoldersRegistry) + } + + @Provides + @ApplicationScope + fun provideSelectSettingsCollatorCommunicator(navigationHoldersRegistry: NavigationHoldersRegistry): SelectMythCollatorSettingsInterScreenCommunicator { + return SelectMythCollatorSettingsInterScreenCommunicatorImpl(navigationHoldersRegistry) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/NominationPoolsStakingNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/NominationPoolsStakingNavigationModule.kt new file mode 100644 index 0000000..d9a8173 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/NominationPoolsStakingNavigationModule.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.app.di.app.navigation.staking + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.staking.nominationPools.NominationPoolsStakingNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter + +@Module +class NominationPoolsStakingNavigationModule { + + @Provides + @ApplicationScope + fun provideRouter(navigationHoldersRegistry: NavigationHoldersRegistry, navigator: Navigator): NominationPoolsRouter { + return NominationPoolsStakingNavigator(navigationHoldersRegistry, navigator) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/ParachainStakingNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/ParachainStakingNavigationModule.kt new file mode 100644 index 0000000..bb7bb63 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/ParachainStakingNavigationModule.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.app.di.app.navigation.staking + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.staking.parachain.ParachainStakingNavigator +import io.novafoundation.nova.app.root.navigation.navigators.staking.parachain.SelectCollatorInterScreenCommunicatorImpl +import io.novafoundation.nova.app.root.navigation.navigators.staking.parachain.SelectCollatorSettingsInterScreenCommunicatorImpl +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator + +@Module +class ParachainStakingNavigationModule { + + @Provides + @ApplicationScope + fun provideParachainStakingRouter( + navigationHoldersRegistry: NavigationHoldersRegistry, + navigator: Navigator + ): ParachainStakingRouter { + return ParachainStakingNavigator(navigationHoldersRegistry, navigator) + } + + @Provides + @ApplicationScope + fun provideSelectCollatorCommunicator(navigationHoldersRegistry: NavigationHoldersRegistry): SelectCollatorInterScreenCommunicator { + return SelectCollatorInterScreenCommunicatorImpl(navigationHoldersRegistry) + } + + @Provides + @ApplicationScope + fun provideSelectCollatorSettingsCommunicator(navigationHoldersRegistry: NavigationHoldersRegistry): SelectCollatorSettingsInterScreenCommunicator { + return SelectCollatorSettingsInterScreenCommunicatorImpl(navigationHoldersRegistry) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/RelayStakingNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/RelayStakingNavigationModule.kt new file mode 100644 index 0000000..9f3d598 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/RelayStakingNavigationModule.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.app.di.app.navigation.staking + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.staking.relaychain.RelayStakingNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter + +@Module +class RelayStakingNavigationModule { + + @Provides + @ApplicationScope + fun provideRelayStakingRouter( + navigationHoldersRegistry: NavigationHoldersRegistry, + navigator: Navigator, + dashboardRouter: StakingDashboardRouter, + dAppRouter: DAppRouter + ): StakingRouter { + return RelayStakingNavigator(navigationHoldersRegistry, navigator, dashboardRouter, dAppRouter) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/StakingNavigationModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/StakingNavigationModule.kt new file mode 100644 index 0000000..ae63b56 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/app/navigation/staking/StakingNavigationModule.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.app.di.app.navigation.staking + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.staking.StakingDashboardNavigator +import io.novafoundation.nova.app.root.navigation.navigators.staking.StartMultiStakingNavigator +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter + +@Module( + includes = [ + ParachainStakingNavigationModule::class, + RelayStakingNavigationModule::class, + NominationPoolsStakingNavigationModule::class, + MythosStakingNavigationModule::class + ] +) +class StakingNavigationModule { + + @Provides + @ApplicationScope + fun provideStakingDashboardNavigator(navigationHoldersRegistry: NavigationHoldersRegistry): StakingDashboardNavigator { + return StakingDashboardNavigator(navigationHoldersRegistry) + } + + @Provides + @ApplicationScope + fun provideStakingDashboardRouter(relayStakingNavigator: StakingDashboardNavigator): StakingDashboardRouter = relayStakingNavigator + + @Provides + @ApplicationScope + fun provideStartMultiStakingRouter( + navigationHoldersRegistry: NavigationHoldersRegistry, + dashboardRouter: StakingDashboardRouter, + commonNavigator: Navigator + ): StartMultiStakingRouter { + return StartMultiStakingNavigator(navigationHoldersRegistry, dashboardRouter, commonNavigator) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/deps/ComponentHolderModule.kt b/app/src/main/java/io/novafoundation/nova/app/di/deps/ComponentHolderModule.kt new file mode 100644 index 0000000..54bf2d7 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/deps/ComponentHolderModule.kt @@ -0,0 +1,306 @@ +package io.novafoundation.nova.app.di.deps + +import dagger.Binds +import dagger.Module +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import io.novafoundation.nova.app.App +import io.novafoundation.nova.app.root.di.RootApi +import io.novafoundation.nova.app.root.di.RootFeatureHolder +import io.novafoundation.nova.caip.di.CaipApi +import io.novafoundation.nova.caip.di.CaipFeatureHolder +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.core_db.di.DbHolder +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureHolder +import io.novafoundation.nova.feature_account_migration.di.AccountMigrationFeatureApi +import io.novafoundation.nova.feature_account_migration.di.AccountMigrationFeatureHolder +import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi +import io.novafoundation.nova.feature_ahm_impl.di.ChainMigrationFeatureHolder +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureHolder +import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi +import io.novafoundation.nova.feature_banners_impl.di.BannersFeatureHolder +import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi +import io.novafoundation.nova.feature_buy_impl.di.BuyFeatureHolder +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi +import io.novafoundation.nova.feature_cloud_backup_impl.di.CloudBackupFeatureHolder +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.di.CrowdloanFeatureHolder +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_currency_impl.di.CurrencyFeatureHolder +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureHolder +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureHolder +import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi +import io.novafoundation.nova.feature_external_sign_impl.di.ExternalSignFeatureHolder +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureHolder +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureHolder +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_core.LedgerCoreHolder +import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureHolder +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureHolder +import io.novafoundation.nova.feature_nft_api.NftFeatureApi +import io.novafoundation.nova.feature_nft_impl.di.NftFeatureHolder +import io.novafoundation.nova.feature_onboarding_api.di.OnboardingFeatureApi +import io.novafoundation.nova.feature_onboarding_impl.di.OnboardingFeatureHolder +import io.novafoundation.nova.feature_proxy_api.di.ProxyFeatureApi +import io.novafoundation.nova.feature_proxy_impl.di.ProxyFeatureHolder +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureHolder +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureHolder +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureHolder +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_swap_core.di.SwapCoreHolder +import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi +import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureHolder +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.feature_versions_impl.di.VersionsFeatureHolder +import io.novafoundation.nova.feature_vote.di.VoteFeatureApi +import io.novafoundation.nova.feature_vote.di.VoteFeatureHolder +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.feature_wallet_connect_impl.di.WalletConnectFeatureHolder +import io.novafoundation.nova.feature_wallet_impl.di.WalletFeatureHolder +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.feature_xcm_impl.di.XcmFeatureHolder +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.runtime.di.RuntimeHolder +import io.novafoundation.nova.splash.di.SplashFeatureApi +import io.novafoundation.nova.splash.di.SplashFeatureHolder +import io.novafoundation.nova.web3names.di.Web3NamesApi +import io.novafoundation.nova.web3names.di.Web3NamesHolder + +@Module +interface ComponentHolderModule { + + @ApplicationScope + @Binds + fun provideFeatureContainer(application: App): FeatureContainer + + @ApplicationScope + @Binds + @ClassKey(SplashFeatureApi::class) + @IntoMap + fun provideSplashFeatureHolder(splashFeatureHolder: SplashFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(DbApi::class) + @IntoMap + fun provideDbFeature(dbHolder: DbHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(OnboardingFeatureApi::class) + @IntoMap + fun provideOnboardingFeature(onboardingFeatureHolder: OnboardingFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(DAppFeatureApi::class) + @IntoMap + fun provideDAppFeature(dAppFeatureHolder: DAppFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(LedgerFeatureApi::class) + @IntoMap + fun provideLedgerFeature(accountFeatureHolder: LedgerFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(LedgerCoreApi::class) + @IntoMap + fun provideLedgerCore(accountFeatureHolder: LedgerCoreHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(GovernanceFeatureApi::class) + @IntoMap + fun provideGovernanceFeature(accountFeatureHolder: GovernanceFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(AccountFeatureApi::class) + @IntoMap + fun provideAccountFeature(accountFeatureHolder: AccountFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(AssetsFeatureApi::class) + @IntoMap + fun provideAssetsFeature(holder: AssetsFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(VoteFeatureApi::class) + @IntoMap + fun provideVoteFeature(holder: VoteFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(WalletFeatureApi::class) + @IntoMap + fun provideWalletFeature(walletFeatureHolder: WalletFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(CurrencyFeatureApi::class) + @IntoMap + fun provideCurrencyFeature(currencyFeatureHolder: CurrencyFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(RootApi::class) + @IntoMap + fun provideMainFeature(accountFeatureHolder: RootFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(StakingFeatureApi::class) + @IntoMap + fun provideStakingFeature(holder: StakingFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(RuntimeApi::class) + @IntoMap + fun provideRuntimeFeature(runtimeHolder: RuntimeHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(Web3NamesApi::class) + @IntoMap + fun provideWeb3Names(web3NamesHolder: Web3NamesHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(CrowdloanFeatureApi::class) + @IntoMap + fun provideCrowdloanFeature(holder: CrowdloanFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(NftFeatureApi::class) + @IntoMap + fun provideNftFeature(holder: NftFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(VersionsFeatureApi::class) + @IntoMap + fun provideVersionsFeature(holder: VersionsFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(CaipApi::class) + @IntoMap + fun provideCaipFeature(holder: CaipFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(ExternalSignFeatureApi::class) + @IntoMap + fun provideExternalSignFeature(holder: ExternalSignFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(WalletConnectFeatureApi::class) + @IntoMap + fun provideWalletConnectFeature(holder: WalletConnectFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(SettingsFeatureApi::class) + @IntoMap + fun provideSettingsFeature(holder: SettingsFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(SwapFeatureApi::class) + @IntoMap + fun provideSwapFeature(holder: SwapFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(BuyFeatureApi::class) + @IntoMap + fun provideBuyFeature(holder: BuyFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(PushNotificationsFeatureApi::class) + @IntoMap + fun providePushNotificationsFeature(holder: PushNotificationsFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(ProxyFeatureApi::class) + @IntoMap + fun provideProxyFeature(holder: ProxyFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(DeepLinkingFeatureApi::class) + @IntoMap + fun provideDeepLinkingFeatureHolder(holder: DeepLinkingFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(CloudBackupFeatureApi::class) + @IntoMap + fun provideCloudBackupFeatureHolder(holder: CloudBackupFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(SwapCoreApi::class) + @IntoMap + fun provideSwapCoreFeatureHolder(holder: SwapCoreHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(BannersFeatureApi::class) + @IntoMap + fun provideBannersFeatureApi(holder: BannersFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(XcmFeatureApi::class) + @IntoMap + fun provideXcmFeatureHolder(holder: XcmFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(MultisigOperationsFeatureApi::class) + @IntoMap + fun provideMultisigOperationsFeatureHolder(holder: MultisigOperationsFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(AccountMigrationFeatureApi::class) + @IntoMap + fun provideAccountMigrationFeatureHolder(holder: AccountMigrationFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(ChainMigrationFeatureApi::class) + @IntoMap + fun provideChainMigrationFeatureHolder(holder: ChainMigrationFeatureHolder): FeatureApiHolder + + @ApplicationScope + @Binds + @ClassKey(GiftFeatureApi::class) + @IntoMap + fun provideGiftFeature(giftFeatureHolder: GiftFeatureHolder): FeatureApiHolder +} diff --git a/app/src/main/java/io/novafoundation/nova/app/di/deps/FeatureHolderManager.kt b/app/src/main/java/io/novafoundation/nova/app/di/deps/FeatureHolderManager.kt new file mode 100644 index 0000000..37ac357 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/di/deps/FeatureHolderManager.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.app.di.deps + +import io.novafoundation.nova.common.di.FeatureApiHolder + +class FeatureHolderManager( + private val mFeatureHolders: Map, FeatureApiHolder> +) { + + fun getFeature(key: Class<*>): T? { + val featureApiHolder = mFeatureHolders[key] ?: throw IllegalStateException() + return featureApiHolder.getFeatureApi() + } + + fun releaseFeature(key: Class<*>) { + val featureApiHolder = mFeatureHolders[key] ?: throw IllegalStateException() + featureApiHolder.releaseFeatureApi() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/di/ExternalServiceInitializersModule.kt b/app/src/main/java/io/novafoundation/nova/app/root/di/ExternalServiceInitializersModule.kt new file mode 100644 index 0000000..41880a3 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/di/ExternalServiceInitializersModule.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.app.root.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.app.root.presentation.common.FirebaseServiceInitializer +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.interfaces.CompoundExternalServiceInitializer +import io.novafoundation.nova.common.interfaces.ExternalServiceInitializer + +@Module +class ExternalServiceInitializersModule { + + @Provides + @IntoSet + fun provideFirebaseServiceInitializer( + context: Context + ): ExternalServiceInitializer { + return FirebaseServiceInitializer(context) + } + + @Provides + @FeatureScope + fun provideCompoundExternalServiceInitializer( + initializers: Set<@JvmSuppressWildcards ExternalServiceInitializer> + ): ExternalServiceInitializer { + return CompoundExternalServiceInitializer(initializers) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/di/RootApi.kt b/app/src/main/java/io/novafoundation/nova/app/root/di/RootApi.kt new file mode 100644 index 0000000..0fa1746 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/di/RootApi.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.app.root.di + +interface RootApi diff --git a/app/src/main/java/io/novafoundation/nova/app/root/di/RootComponent.kt b/app/src/main/java/io/novafoundation/nova/app/root/di/RootComponent.kt new file mode 100644 index 0000000..399945c --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/di/RootComponent.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.app.root.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.app.root.navigation.holders.RootNavigationHolder +import io.novafoundation.nova.app.root.navigation.holders.SplitScreenNavigationHolder +import io.novafoundation.nova.app.root.navigation.navigators.staking.StakingDashboardNavigator +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.app.root.presentation.di.RootActivityComponent +import io.novafoundation.nova.app.root.presentation.main.di.MainFragmentComponent +import io.novafoundation.nova.app.root.presentation.splitScreen.di.SplitScreenFragmentComponent +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.navigation.DelayedNavigationRouter +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_migration.di.AccountMigrationFeatureApi +import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + RootDependencies::class + ], + modules = [ + RootFeatureModule::class + ] +) +@FeatureScope +interface RootComponent { + + fun mainActivityComponentFactory(): RootActivityComponent.Factory + + fun splitScreenFragmentComponentFactory(): SplitScreenFragmentComponent.Factory + + fun mainFragmentComponentFactory(): MainFragmentComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance splitScreenNavigationHolder: SplitScreenNavigationHolder, + @BindsInstance rootNavigationHolder: RootNavigationHolder, + @BindsInstance rootRouter: RootRouter, + @BindsInstance governanceRouter: GovernanceRouter, + @BindsInstance dAppRouter: DAppRouter, + @BindsInstance assetsRouter: AssetsRouter, + @BindsInstance accountRouter: AccountRouter, + @BindsInstance stakingRouter: StakingRouter, + @BindsInstance stakingDashboardNavigator: StakingDashboardNavigator, + @BindsInstance delayedNavigationRouter: DelayedNavigationRouter, + deps: RootDependencies + ): RootComponent + } + + @Component( + dependencies = [ + AccountFeatureApi::class, + WalletFeatureApi::class, + StakingFeatureApi::class, + CrowdloanFeatureApi::class, + AssetsFeatureApi::class, + CurrencyFeatureApi::class, + GovernanceFeatureApi::class, + DAppFeatureApi::class, + DbApi::class, + CommonApi::class, + RuntimeApi::class, + VersionsFeatureApi::class, + WalletConnectFeatureApi::class, + PushNotificationsFeatureApi::class, + DeepLinkingFeatureApi::class, + LedgerFeatureApi::class, + BuyFeatureApi::class, + DeepLinkingFeatureApi::class, + AccountMigrationFeatureApi::class, + MultisigOperationsFeatureApi::class, + ChainMigrationFeatureApi::class, + GiftFeatureApi::class + ] + ) + interface RootFeatureDependenciesComponent : RootDependencies +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/di/RootDependencies.kt b/app/src/main/java/io/novafoundation/nova/app/root/di/RootDependencies.kt new file mode 100644 index 0000000..ec5522e --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/di/RootDependencies.kt @@ -0,0 +1,192 @@ +package io.novafoundation.nova.app.root.di + +import android.content.Context +import coil.ImageLoader +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.NetworkStateMixin +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.SafeModeService +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.ToastMessageManager +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.network.DeviceNetworkStateObserver +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.core_db.dao.BrowserTabsDao +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxyExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.di.deeplinks.AccountDeepLinks +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.cloudBackup.ApplyLocalSnapshotToCloudBackupUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_migration.di.deeplinks.AccountMigrationDeepLinks +import io.novafoundation.nova.feature_ahm_api.data.repository.ChainMigrationRepository +import io.novafoundation.nova.feature_ahm_api.data.repository.MigrationInfoRepository +import io.novafoundation.nova.feature_ahm_api.di.deeplinks.ChainMigrationDeepLinks +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationDetailsSelectToShowUseCase +import io.novafoundation.nova.feature_assets.data.network.BalancesUpdateSystem +import io.novafoundation.nova.feature_assets.di.modules.deeplinks.AssetDeepLinks +import io.novafoundation.nova.feature_buy_api.di.deeplinks.BuyDeepLinks +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserTabExternalRepository +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_api.di.deeplinks.DAppDeepLinks +import io.novafoundation.nova.feature_deep_linking.presentation.handling.PendingDeepLinkProvider +import io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo.BranchIoLinkConverter +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkingPreferences +import io.novafoundation.nova.feature_gift_api.di.GiftDeepLinks +import io.novafoundation.nova.feature_governance_api.data.MutableGovernanceState +import io.novafoundation.nova.feature_governance_api.di.deeplinks.GovernanceDeepLinks +import io.novafoundation.nova.feature_multisig_operations.di.deeplink.MultisigDeepLinks +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.WelcomePushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning.MultisigPushNotificationsAlertMixinFactory +import io.novafoundation.nova.feature_staking_api.di.deeplinks.StakingDeepLinks +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.MultisigExtrinsicValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.ProxyHaveEnoughFeeValidationFactory +import io.novafoundation.nova.feature_wallet_connect_api.di.deeplinks.WalletConnectDeepLinks +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import kotlinx.coroutines.flow.MutableStateFlow + +interface RootDependencies { + + val stakingDeepLinks: StakingDeepLinks + + val accountDeepLinks: AccountDeepLinks + + val dAppDeepLinks: DAppDeepLinks + + val governanceDeepLinks: GovernanceDeepLinks + + val buyDeepLinks: BuyDeepLinks + + val assetDeepLinks: AssetDeepLinks + + val giftDeepLinks: GiftDeepLinks + + val chainMigrationDeepLinks: ChainMigrationDeepLinks + + val walletConnectDeepLinks: WalletConnectDeepLinks + + val systemCallExecutor: SystemCallExecutor + + val contextManager: ContextManager + + val walletConnectService: WalletConnectService + + val imageLoader: ImageLoader + + val automaticInteractionGate: AutomaticInteractionGate + + val walletConnectSessionsUseCase: WalletConnectSessionsUseCase + + val pushNotificationsInteractor: PushNotificationsInteractor + + val welcomePushNotificationsInteractor: WelcomePushNotificationsInteractor + + val applyLocalSnapshotToCloudBackupUseCase: ApplyLocalSnapshotToCloudBackupUseCase + + val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory + + val tabsDao: BrowserTabsDao + + val balancesUpdateSystem: BalancesUpdateSystem + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val browserTabExternalRepository: BrowserTabExternalRepository + + val externalAccountsSyncService: ExternalAccountsSyncService + + val multisigPendingOperationsService: MultisigPendingOperationsService + + val accountMigrationDeepLinks: AccountMigrationDeepLinks + + val multisigDeepLinks: MultisigDeepLinks + + val deepLinkingPreferences: DeepLinkingPreferences + + val branchIoLinkConverter: BranchIoLinkConverter + + val pendingDeepLinkProvider: PendingDeepLinkProvider + + val multisigExtrinsicValidationRequestBus: MultisigExtrinsicValidationRequestBus + + val multisigExtrinsicValidationFactory: MultisigExtrinsicValidationFactory + + val actionBottomSheetLauncher: ActionBottomSheetLauncher + + val multisigPushNotificationsAlertMixinFactory: MultisigPushNotificationsAlertMixinFactory + + val chainMigrationDetailsSelectToShowUseCase: ChainMigrationDetailsSelectToShowUseCase + + val deviceNetworkStateObserver: DeviceNetworkStateObserver + + fun updateNotificationsInteractor(): UpdateNotificationsInteractor + + fun contributionsInteractor(): ContributionsInteractor + + fun crowdloanRepository(): CrowdloanRepository + + fun networkStateMixin(): NetworkStateMixin + + fun externalRequirementsFlow(): MutableStateFlow + + fun accountRepository(): AccountRepository + + fun walletRepository(): WalletRepository + + fun appLinksProvider(): AppLinksProvider + + fun resourceManager(): ResourceManager + + fun currencyInteractor(): CurrencyInteractor + + fun stakingRepository(): StakingRepository + + fun chainRegistry(): ChainRegistry + + fun backgroundAccessObserver(): BackgroundAccessObserver + + fun safeModeService(): SafeModeService + + fun rootScope(): RootScope + + fun governanceStateUpdater(): MutableGovernanceState + + fun dappMetadataRepository(): DAppMetadataRepository + + fun encryptionDefaults(): EncryptionDefaults + + fun proxyExtrinsicValidationRequestBus(): ProxyExtrinsicValidationRequestBus + + fun metaAccountChangesRequestBus(): MetaAccountChangesEventBus + + fun proxyHaveEnoughFeeValidationFactory(): ProxyHaveEnoughFeeValidationFactory + + fun context(): Context + + fun toastMessageManager(): ToastMessageManager + + fun dialogMessageManager(): DialogMessageManager + + fun chainMigrationRepository(): ChainMigrationRepository + + fun migrationInfoRepository(): MigrationInfoRepository +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/di/RootFeatureHolder.kt b/app/src/main/java/io/novafoundation/nova/app/root/di/RootFeatureHolder.kt new file mode 100644 index 0000000..c257933 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/di/RootFeatureHolder.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.app.root.di + +import io.novafoundation.nova.app.root.navigation.holders.RootNavigationHolder +import io.novafoundation.nova.app.root.navigation.holders.SplitScreenNavigationHolder +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.navigators.staking.StakingDashboardNavigator +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.navigation.DelayedNavigationRouter +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_migration.di.AccountMigrationFeatureApi +import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class RootFeatureHolder @Inject constructor( + private val splitScreenNavigationHolder: SplitScreenNavigationHolder, + private val rootNavigationHolder: RootNavigationHolder, + private val navigator: Navigator, + private val governanceRouter: GovernanceRouter, + private val dAppRouter: DAppRouter, + private val accountRouter: AccountRouter, + private val assetsRouter: AssetsRouter, + private val stakingRouter: StakingRouter, + private val stakingDashboardNavigator: StakingDashboardNavigator, + private val delayedNavRouter: DelayedNavigationRouter, + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val rootFeatureDependencies = DaggerRootComponent_RootFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .stakingFeatureApi(getFeature(StakingFeatureApi::class.java)) + .assetsFeatureApi(getFeature(AssetsFeatureApi::class.java)) + .currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java)) + .crowdloanFeatureApi(getFeature(CrowdloanFeatureApi::class.java)) + .governanceFeatureApi(getFeature(GovernanceFeatureApi::class.java)) + .dAppFeatureApi(getFeature(DAppFeatureApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .versionsFeatureApi(getFeature(VersionsFeatureApi::class.java)) + .walletConnectFeatureApi(getFeature(WalletConnectFeatureApi::class.java)) + .pushNotificationsFeatureApi(getFeature(PushNotificationsFeatureApi::class.java)) + .deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java)) + .ledgerFeatureApi(getFeature(LedgerFeatureApi::class.java)) + .buyFeatureApi(getFeature(BuyFeatureApi::class.java)) + .deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java)) + .accountMigrationFeatureApi(getFeature(AccountMigrationFeatureApi::class.java)) + .multisigOperationsFeatureApi(getFeature(MultisigOperationsFeatureApi::class.java)) + .chainMigrationFeatureApi(getFeature(ChainMigrationFeatureApi::class.java)) + .giftFeatureApi(getFeature(GiftFeatureApi::class.java)) + .build() + + return DaggerRootComponent.factory() + .create( + splitScreenNavigationHolder, + rootNavigationHolder, + navigator, + governanceRouter, + dAppRouter, + assetsRouter, + accountRouter, + stakingRouter, + stakingDashboardNavigator, + delayedNavRouter, + rootFeatureDependencies + ) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/di/RootFeatureModule.kt b/app/src/main/java/io/novafoundation/nova/app/root/di/RootFeatureModule.kt new file mode 100644 index 0000000..b9e1fef --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/di/RootFeatureModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.app.root.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.app.root.di.busHandler.RequestBusHandlerModule +import io.novafoundation.nova.app.root.di.deeplink.DeepLinksModule +import io.novafoundation.nova.app.root.domain.RootInteractor +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_ahm_api.data.repository.ChainMigrationRepository +import io.novafoundation.nova.feature_ahm_api.data.repository.MigrationInfoRepository +import io.novafoundation.nova.feature_assets.data.network.BalancesUpdateSystem +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository + +@Module( + includes = [ + RequestBusHandlerModule::class, + ExternalServiceInitializersModule::class, + DeepLinksModule::class + ] +) +class RootFeatureModule { + + @Provides + @FeatureScope + fun provideRootInteractor( + walletRepository: WalletRepository, + accountRepository: AccountRepository, + balancesUpdateSystem: BalancesUpdateSystem, + multisigPendingOperationsService: MultisigPendingOperationsService, + externalAccountsSyncService: ExternalAccountsSyncService, + chainMigrationRepository: ChainMigrationRepository, + migrationInfoRepository: MigrationInfoRepository + ): RootInteractor { + return RootInteractor( + updateSystem = balancesUpdateSystem, + walletRepository = walletRepository, + accountRepository = accountRepository, + multisigPendingOperationsService = multisigPendingOperationsService, + externalAccountsSyncService = externalAccountsSyncService, + chainMigrationRepository = chainMigrationRepository, + migrationInfoRepository = migrationInfoRepository + ) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/di/busHandler/RequestBusHandlerModule.kt b/app/src/main/java/io/novafoundation/nova/app/root/di/busHandler/RequestBusHandlerModule.kt new file mode 100644 index 0000000..a902224 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/di/busHandler/RequestBusHandlerModule.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.app.root.di.busHandler + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.app.root.presentation.requestBusHandler.CloudBackupSyncRequestBusHandler +import io.novafoundation.nova.app.root.presentation.requestBusHandler.CompoundRequestBusHandler +import io.novafoundation.nova.app.root.presentation.requestBusHandler.MultisigExtrinsicValidationRequestBusHandler +import io.novafoundation.nova.app.root.presentation.requestBusHandler.ProxyExtrinsicValidationRequestBusHandler +import io.novafoundation.nova.app.root.presentation.requestBusHandler.PushSettingsSyncRequestBusHandler +import io.novafoundation.nova.app.root.presentation.requestBusHandler.RequestBusHandler +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxyExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.domain.cloudBackup.ApplyLocalSnapshotToCloudBackupUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_wallet_api.domain.validation.MultisigExtrinsicValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.ProxyHaveEnoughFeeValidationFactory + +@Module +class RequestBusHandlerModule { + + @Provides + @FeatureScope + @IntoSet + fun providePushSettingsSyncRequestBusHandler( + metaAccountChangesEventBus: MetaAccountChangesEventBus, + pushNotificationsInteractor: PushNotificationsInteractor + ): RequestBusHandler { + return PushSettingsSyncRequestBusHandler( + metaAccountChangesEventBus, + pushNotificationsInteractor + ) + } + + @Provides + @FeatureScope + @IntoSet + fun provideProxyExtrinsicValidationRequestBusHandler( + proxyProxyExtrinsicValidationRequestBus: ProxyExtrinsicValidationRequestBus, + proxyHaveEnoughFeeValidationFactory: ProxyHaveEnoughFeeValidationFactory + ): RequestBusHandler { + return ProxyExtrinsicValidationRequestBusHandler( + proxyProxyExtrinsicValidationRequestBus, + proxyHaveEnoughFeeValidationFactory + ) + } + + @Provides + @FeatureScope + @IntoSet + fun provideMultisigExtrinsicValidationRequestBusHandler( + multisigExtrinsicValidationRequestBus: MultisigExtrinsicValidationRequestBus, + multisigExtrinsicValidationFactory: MultisigExtrinsicValidationFactory + ): RequestBusHandler { + return MultisigExtrinsicValidationRequestBusHandler( + multisigExtrinsicValidationRequestBus, + multisigExtrinsicValidationFactory + ) + } + + @Provides + @FeatureScope + @IntoSet + fun provideCloudBackupSyncRequestBusHandler( + rootRouter: RootRouter, + resourceManager: ResourceManager, + metaAccountChangesEventBus: MetaAccountChangesEventBus, + applyLocalSnapshotToCloudBackupUseCase: ApplyLocalSnapshotToCloudBackupUseCase, + accountRepository: AccountRepository, + actionBottomSheetLauncher: ActionBottomSheetLauncher, + automaticInteractionGate: AutomaticInteractionGate + ): RequestBusHandler { + return CloudBackupSyncRequestBusHandler( + rootRouter, + resourceManager, + metaAccountChangesEventBus, + applyLocalSnapshotToCloudBackupUseCase, + accountRepository, + actionBottomSheetLauncher, + automaticInteractionGate + ) + } + + @Provides + @FeatureScope + fun provideCompoundRequestBusHandler( + handlers: Set<@JvmSuppressWildcards RequestBusHandler> + ): CompoundRequestBusHandler { + return CompoundRequestBusHandler(handlers) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/di/deeplink/DeepLinksModule.kt b/app/src/main/java/io/novafoundation/nova/app/root/di/deeplink/DeepLinksModule.kt new file mode 100644 index 0000000..6ce2e98 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/di/deeplink/DeepLinksModule.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.app.root.di.deeplink + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.di.deeplinks.AccountDeepLinks +import io.novafoundation.nova.feature_account_migration.di.deeplinks.AccountMigrationDeepLinks +import io.novafoundation.nova.feature_ahm_api.di.deeplinks.ChainMigrationDeepLinks +import io.novafoundation.nova.feature_assets.di.modules.deeplinks.AssetDeepLinks +import io.novafoundation.nova.feature_buy_api.di.deeplinks.BuyDeepLinks +import io.novafoundation.nova.feature_dapp_api.di.deeplinks.DAppDeepLinks +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import io.novafoundation.nova.feature_deep_linking.presentation.handling.PendingDeepLinkProvider +import io.novafoundation.nova.feature_deep_linking.presentation.handling.RootDeepLinkHandler +import io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo.BranchIOLinkHandler +import io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo.BranchIoLinkConverter +import io.novafoundation.nova.feature_gift_api.di.GiftDeepLinks +import io.novafoundation.nova.feature_governance_api.di.deeplinks.GovernanceDeepLinks +import io.novafoundation.nova.feature_multisig_operations.di.deeplink.MultisigDeepLinks +import io.novafoundation.nova.feature_staking_api.di.deeplinks.StakingDeepLinks +import io.novafoundation.nova.feature_wallet_connect_api.di.deeplinks.WalletConnectDeepLinks + +@Module +class DeepLinksModule { + + @Provides + @FeatureScope + fun provideDeepLinkHandlers( + stakingDeepLinks: StakingDeepLinks, + accountDeepLinks: AccountDeepLinks, + dAppDeepLinks: DAppDeepLinks, + governanceDeepLinks: GovernanceDeepLinks, + buyDeepLinks: BuyDeepLinks, + assetDeepLinks: AssetDeepLinks, + walletConnectDeepLinks: WalletConnectDeepLinks, + accountMigrationDeepLinks: AccountMigrationDeepLinks, + multisigDeepLinks: MultisigDeepLinks, + chainMigrationDeepLinks: ChainMigrationDeepLinks, + giftDeepLinks: GiftDeepLinks + ): List<@JvmWildcard DeepLinkHandler> { + return buildList { + addAll(stakingDeepLinks.deepLinkHandlers) + addAll(accountDeepLinks.deepLinkHandlers) + addAll(dAppDeepLinks.deepLinkHandlers) + addAll(governanceDeepLinks.deepLinkHandlers) + addAll(buyDeepLinks.deepLinkHandlers) + addAll(assetDeepLinks.deepLinkHandlers) + addAll(walletConnectDeepLinks.deepLinkHandlers) + addAll(accountMigrationDeepLinks.deepLinkHandlers) + addAll(multisigDeepLinks.deepLinkHandlers) + addAll(chainMigrationDeepLinks.deepLinkHandlers) + addAll(giftDeepLinks.deepLinkHandlers) + } + } + + @Provides + @FeatureScope + fun provideRootDeepLinkHandler( + pendingDeepLinkProvider: PendingDeepLinkProvider, + nestedHandlers: @JvmWildcard List + ): RootDeepLinkHandler { + return RootDeepLinkHandler( + pendingDeepLinkProvider, + nestedHandlers + ) + } + + @Provides + @FeatureScope + fun provideBranchIOLinkHandler( + branchIoLinkConverter: BranchIoLinkConverter + ): BranchIOLinkHandler { + return BranchIOLinkHandler(branchIoLinkConverter) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/domain/RootInteractor.kt b/app/src/main/java/io/novafoundation/nova/app/root/domain/RootInteractor.kt new file mode 100644 index 0000000..d96fc96 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/domain/RootInteractor.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.app.root.domain + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_ahm_api.data.repository.ChainMigrationRepository +import io.novafoundation.nova.feature_ahm_api.data.repository.MigrationInfoRepository +import io.novafoundation.nova.feature_assets.data.network.BalancesUpdateSystem +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import kotlinx.coroutines.flow.Flow + +class RootInteractor( + private val updateSystem: BalancesUpdateSystem, + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, + private val multisigPendingOperationsService: MultisigPendingOperationsService, + private val externalAccountsSyncService: ExternalAccountsSyncService, + private val chainMigrationRepository: ChainMigrationRepository, + private val migrationInfoRepository: MigrationInfoRepository +) { + + fun runBalancesUpdate(): Flow = updateSystem.start() + + suspend fun updatePhishingAddresses() { + runCatching { + walletRepository.updatePhishingAddresses() + } + } + + suspend fun isAccountSelected(): Boolean { + return accountRepository.isAccountSelected() + } + + suspend fun isPinCodeSet(): Boolean { + return accountRepository.isCodeSet() + } + + fun syncExternalAccounts() { + externalAccountsSyncService.sync() + } + + context(ComputationalScope) + fun syncPendingMultisigOperations(): Flow { + return multisigPendingOperationsService.performMultisigOperationsSync() + } + + suspend fun cacheBalancesForChainMigrationDetection() { + chainMigrationRepository.cacheBalancesForChainMigrationDetection() + } + + suspend fun loadMigrationDetailsConfigs() { + migrationInfoRepository.loadConfigs() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/domain/SplitScreenInteractor.kt b/app/src/main/java/io/novafoundation/nova/app/root/domain/SplitScreenInteractor.kt new file mode 100644 index 0000000..ebd1c84 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/domain/SplitScreenInteractor.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.app.root.domain + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_dapp_api.data.model.SimpleTabModel +import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserTabExternalRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +class SplitScreenInteractor( + private val repository: BrowserTabExternalRepository, + private val accountRepository: AccountRepository +) { + + fun observeTabNamesById(): Flow> { + return accountRepository.selectedMetaAccountFlow() + .flatMapLatest { repository.observeTabsWithNames(it.id) } + } + + suspend fun removeAllTabs() { + val metaAccount = accountRepository.getSelectedMetaAccount() + repository.removeTabsForMetaAccount(metaAccount.id) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/Ext.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/Ext.kt new file mode 100644 index 0000000..a6d4806 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/Ext.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.app.root.navigation + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.annotation.IdRes +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import androidx.navigation.NavGraph +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.delayedNavigation.NavComponentDelayedNavigation +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.presentation.splitScreen.SplitScreenFragment +import io.novafoundation.nova.app.root.presentation.splitScreen.SplitScreenPayload + +@SuppressLint("RestrictedApi") +fun NavController.getBackStackEntryBefore(@IdRes id: Int): NavBackStackEntry { + val initial = getBackStackEntry(id) + val backStack = backStack.toList() + + val initialIndex = backStack.indexOf(initial) + + var previousIndex = initialIndex - 1 + + // ignore nav graphs + while (previousIndex > 0 && backStack[previousIndex].destination is NavGraph) { + previousIndex-- + } + + return backStack[previousIndex] +} + +fun BaseNavigator.openSplitScreenWithInstantAction(actionId: Int, nestedActionExtras: Bundle? = null) { + val delayedNavigation = NavComponentDelayedNavigation(actionId, nestedActionExtras) + + val splitScreenPayload = SplitScreenPayload.InstantNavigationOnAttach(delayedNavigation) + navigationBuilder().action(R.id.action_open_split_screen) + .setArgs(SplitScreenFragment.createPayload(splitScreenPayload)) + .navigateInRoot() +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/FlowInterScreenCommunicator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/FlowInterScreenCommunicator.kt new file mode 100644 index 0000000..bf6e367 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/FlowInterScreenCommunicator.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.app.root.navigation + +import io.novafoundation.nova.common.navigation.InterScreenCommunicator +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch + +abstract class FlowInterScreenCommunicator : + InterScreenCommunicator, + CoroutineScope by CoroutineScope(Dispatchers.Main) { + + private var response: O? = null + + override val responseFlow = singleReplaySharedFlow() + + private var _request: I? = null + + override val latestResponse: O? + get() = response + + override val lastState: O? + get() = latestResponse + + override val lastInput: I? + get() = _request + + abstract fun dispatchRequest(request: I) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun openRequest(request: I) { + _request = request + response = null + responseFlow.resetReplayCache() + + dispatchRequest(request) + } + + override fun respond(response: O) { + launch { + this@FlowInterScreenCommunicator.response = response + responseFlow.emit(response) + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/NavStackInterScreenCommunicator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/NavStackInterScreenCommunicator.kt new file mode 100644 index 0000000..b32b782 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/NavStackInterScreenCommunicator.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.app.root.navigation + +import android.os.Parcelable +import androidx.annotation.CallSuper +import androidx.lifecycle.asFlow +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.builder.NavigationBuilderRegistry +import io.novafoundation.nova.app.root.navigation.navigators.navigationBuilder +import io.novafoundation.nova.common.navigation.InterScreenCommunicator +import kotlinx.coroutines.flow.Flow +import java.util.UUID + +abstract class NavStackInterScreenCommunicator( + private val navigationHoldersRegistry: NavigationHoldersRegistry +) : InterScreenCommunicator { + + private val responseKey = UUID.randomUUID().toString() + private val requestKey = UUID.randomUUID().toString() + + protected val navController: NavController + get() = navigationHoldersRegistry.firstAttachedNavController!! + + // from requester - retrieve from current entry + override val latestResponse: O? + get() = navController.currentBackStackEntry!!.savedStateHandle + .get(responseKey) + + // from responder - retrieve from previous (requester) entry + override val lastState: O? + get() = navController.previousBackStackEntry!!.savedStateHandle + .get(responseKey) + + override val responseFlow: Flow + get() = createResponseFlow() + + // from responder - retrieve from previous (requester) entry + override val lastInput: I? + get() = navController.previousBackStackEntry!!.savedStateHandle + .get(requestKey) + + @CallSuper + override fun openRequest(request: I) { + saveRequest(request) + } + + fun clearedResponseFlow(): Flow { + navController.currentBackStackEntry!!.savedStateHandle.apply { + remove(requestKey) + remove(responseKey) + } + return createResponseFlow() + } + + override fun respond(response: O) { + // previousBackStackEntry since we want to report to previous screen + saveResultTo(navController.previousBackStackEntry!!, response) + } + + protected fun saveResultTo(backStackEntry: NavBackStackEntry, response: O) { + backStackEntry.savedStateHandle.set(responseKey, response) + } + + private fun saveRequest(request: I) { + navController.currentBackStackEntry!!.savedStateHandle.set(requestKey, request) + } + + private fun createResponseFlow(): Flow { + return navController.currentBackStackEntry!!.savedStateHandle + .getLiveData(responseKey) + .asFlow() + } + + protected fun navigationBuilder(): NavigationBuilderRegistry { + return navigationHoldersRegistry.navigationBuilder() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/delayedNavigation/BackDelayedNavigation.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/delayedNavigation/BackDelayedNavigation.kt new file mode 100644 index 0000000..0c2686d --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/delayedNavigation/BackDelayedNavigation.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.app.root.navigation.delayedNavigation + +import io.novafoundation.nova.common.navigation.DelayedNavigation +import kotlinx.parcelize.Parcelize + +@Parcelize +object BackDelayedNavigation : DelayedNavigation diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/delayedNavigation/NavComponentDelayedNavigation.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/delayedNavigation/NavComponentDelayedNavigation.kt new file mode 100644 index 0000000..5eedeb6 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/delayedNavigation/NavComponentDelayedNavigation.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.app.root.navigation.delayedNavigation + +import android.os.Bundle +import io.novafoundation.nova.common.navigation.DelayedNavigation +import kotlinx.parcelize.Parcelize + +@Parcelize +class NavComponentDelayedNavigation(val globalActionId: Int, val extras: Bundle? = null) : DelayedNavigation diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/holders/NavigationHolder.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/holders/NavigationHolder.kt new file mode 100644 index 0000000..83598d3 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/holders/NavigationHolder.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.app.root.navigation.holders + +import androidx.navigation.NavController +import io.novafoundation.nova.common.resources.ContextManager + +abstract class NavigationHolder(val contextManager: ContextManager) { + + var navController: NavController? = null + private set + + fun isControllerAttached(): Boolean { + return navController != null + } + + fun attach(navController: NavController) { + this.navController = navController + } + + /** + * Detaches the current navController only if it matches the one provided. + * This check ensures that if a new screen with a navController is attached, + * it doesn't lose its navController when the previous screen calls detach. + * By verifying equality, we prevent unintended detachment. + */ + fun detachNavController(navController: NavController) { + if (this.navController == navController) { + this.navController = null + } + } + + fun detach() { + navController = null + } + + fun finishApp() { + contextManager.getActivity()?.finish() + } + + fun executeBack() { + val popped = navController!!.popBackStack() + + if (!popped) { + contextManager.getActivity()!!.finish() + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/holders/RootNavigationHolder.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/holders/RootNavigationHolder.kt new file mode 100644 index 0000000..b2d6130 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/holders/RootNavigationHolder.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.app.root.navigation.holders + +import io.novafoundation.nova.common.resources.ContextManager + +class RootNavigationHolder(contextManager: ContextManager) : NavigationHolder(contextManager) diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/holders/SplitScreenNavigationHolder.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/holders/SplitScreenNavigationHolder.kt new file mode 100644 index 0000000..cd8c568 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/holders/SplitScreenNavigationHolder.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.app.root.navigation.holders + +import io.novafoundation.nova.common.resources.ContextManager + +class SplitScreenNavigationHolder(contextManager: ContextManager) : NavigationHolder(contextManager) diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigationFragment/MainNavHostFragment.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigationFragment/MainNavHostFragment.kt new file mode 100644 index 0000000..fa65b14 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigationFragment/MainNavHostFragment.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.app.root.navigation.navigationFragment + +import io.novafoundation.nova.app.R + +class MainNavHostFragment : NovaNavHostFragment() { + + override val containerId: Int = R.id.mainNavHost +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigationFragment/NovaNavHostFragment.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigationFragment/NovaNavHostFragment.kt new file mode 100644 index 0000000..8712bce --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigationFragment/NovaNavHostFragment.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.root.navigation.navigationFragment + +import android.annotation.SuppressLint +import androidx.navigation.NavController +import androidx.navigation.fragment.DialogFragmentNavigator +import androidx.navigation.fragment.NavHostFragment +import io.novafoundation.nova.app.root.navigation.navigators.AddFragmentNavigator + +abstract class NovaNavHostFragment : NavHostFragment() { + + abstract val containerId: Int + + @SuppressLint("MissingSuperCall") + override fun onCreateNavController(navController: NavController) { + navController.navigatorProvider.addNavigator(DialogFragmentNavigator(requireContext(), childFragmentManager)) + val addFragmentNavigator = AddFragmentNavigator(requireContext(), childFragmentManager, containerId) + + navController.navigatorProvider.addNavigator(addFragmentNavigator) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigationFragment/RootNavHostFragment.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigationFragment/RootNavHostFragment.kt new file mode 100644 index 0000000..de609c5 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigationFragment/RootNavHostFragment.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.app.root.navigation.navigationFragment + +import io.novafoundation.nova.app.R + +class RootNavHostFragment : NovaNavHostFragment() { + + override val containerId: Int = R.id.rootNavHost +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/AddFragmentNavigator.java b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/AddFragmentNavigator.java new file mode 100644 index 0000000..456e5dc --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/AddFragmentNavigator.java @@ -0,0 +1,352 @@ +package io.novafoundation.nova.app.root.navigation.navigators; + + +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Bundle; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; + +import androidx.annotation.CallSuper; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.navigation.NavDestination; +import androidx.navigation.NavOptions; +import androidx.navigation.Navigator; +import androidx.navigation.NavigatorProvider; +import androidx.navigation.fragment.FragmentNavigator; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; + +import io.novafoundation.nova.app.R; + +/** + * This is an improved version (aka copy-paste with fixes) of + * {@link androidx.navigation.fragment.FragmentNavigator} which allows not only to replace old + * fragment with new one, but also add new and hide old one. + * The difference with original implementation from google library is in navigate() method ( + * if (destination.shouldUseAdd) ... )and in modified Destination subclass + * which includes shouldUseAdd flag + */ +@Navigator.Name("fragment") +public class AddFragmentNavigator extends Navigator { + private static final String TAG = "FragmentNavigator"; + private static final String KEY_BACK_STACK_IDS = "androidx-nav-fragment:navigator:backStackIds"; + + private final Context mContext; + private final FragmentManager mFragmentManager; + private final int mContainerId; + private ArrayDeque mBackStack = new ArrayDeque<>(); + + public AddFragmentNavigator(@NonNull Context context, @NonNull FragmentManager manager, + int containerId) { + mContext = context; + mFragmentManager = manager; + mContainerId = containerId; + } + + /** + * {@inheritDoc} + *

+ * This method must call + * {@link FragmentTransaction#setPrimaryNavigationFragment(Fragment)} + * if the pop succeeded so that the newly visible Fragment can be retrieved with + * {@link FragmentManager#getPrimaryNavigationFragment()}. + *

+ * Note that the default implementation pops the Fragment + * asynchronously, so the newly visible Fragment from the back stack + * is not instantly available after this call completes. + */ + @Override + public boolean popBackStack() { + if (mBackStack.isEmpty()) { + return false; + } + if (mFragmentManager.isStateSaved()) { + Log.i(TAG, "Ignoring popBackStack() call: FragmentManager has already" + + " saved its state"); + return false; + } + mFragmentManager.popBackStack( + generateBackStackName(mBackStack.size(), mBackStack.peekLast()), + FragmentManager.POP_BACK_STACK_INCLUSIVE); + mBackStack.removeLast(); + return true; + } + + @NonNull + @Override + public AddFragmentNavigator.Destination createDestination() { + return new AddFragmentNavigator.Destination(this); + } + + /** + * Instantiates the Fragment via the FragmentManager's + * {@link androidx.fragment.app.FragmentFactory}. + * Note that this method is not responsible for calling + * {@link Fragment#setArguments(Bundle)} on the returned Fragment instance. + * + * @param context Context providing the correct {@link ClassLoader} + * @param fragmentManager FragmentManager the Fragment will be added to + * @param className The Fragment to instantiate + * @param args The Fragment's arguments, if any + * @return A new fragment instance. + * @deprecated Set a custom {@link androidx.fragment.app.FragmentFactory} via + * {@link FragmentManager#setFragmentFactory(androidx.fragment.app.FragmentFactory)} to control + * instantiation of Fragments. + */ + @SuppressWarnings("DeprecatedIsStillUsed") // needed to maintain forward compatibility + @Deprecated + @NonNull + public Fragment instantiateFragment(@NonNull Context context, + @NonNull FragmentManager fragmentManager, + @NonNull String className, @SuppressWarnings("unused") @Nullable Bundle args) { + return fragmentManager.getFragmentFactory().instantiate( + context.getClassLoader(), className); + } + + /** + * {@inheritDoc} + *

+ * This method should always call + * {@link FragmentTransaction#setPrimaryNavigationFragment(Fragment)} + * so that the Fragment associated with the new destination can be retrieved with + * {@link FragmentManager#getPrimaryNavigationFragment()}. + *

+ * Note that the default implementation commits the new Fragment + * asynchronously, so the new Fragment is not instantly available + * after this call completes. + */ + @SuppressWarnings("deprecation") /* Using instantiateFragment for forward compatibility */ + @Nullable + @Override + public NavDestination navigate(@NonNull Destination destination, @Nullable Bundle args, + @Nullable NavOptions navOptions, @Nullable Navigator.Extras navigatorExtras) { + if (mFragmentManager.isStateSaved()) { + Log.i(TAG, "Ignoring navigate() call: FragmentManager has already" + + " saved its state"); + return null; + } + String className = destination.getClassName(); + if (className.charAt(0) == '.') { + className = mContext.getPackageName() + className; + } + final Fragment frag = instantiateFragment(mContext, mFragmentManager, + className, args); + frag.setArguments(args); + final FragmentTransaction ft = mFragmentManager.beginTransaction(); + + int enterAnim = navOptions != null ? navOptions.getEnterAnim() : -1; + int exitAnim = navOptions != null ? navOptions.getExitAnim() : -1; + int popEnterAnim = navOptions != null ? navOptions.getPopEnterAnim() : -1; + int popExitAnim = navOptions != null ? navOptions.getPopExitAnim() : -1; + if (enterAnim != -1 || exitAnim != -1 || popEnterAnim != -1 || popExitAnim != -1) { + enterAnim = enterAnim != -1 ? enterAnim : 0; + exitAnim = exitAnim != -1 ? exitAnim : 0; + popEnterAnim = popEnterAnim != -1 ? popEnterAnim : 0; + popExitAnim = popExitAnim != -1 ? popExitAnim : 0; + ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim); + } + + if (destination.shouldUseAdd) { + List fragments = mFragmentManager.getFragments(); + for (Fragment fragment : fragments) { + if (fragment.isHidden()) continue; + + ft.hide(fragment); + } + ft.add(mContainerId, frag); + } else { + ft.replace(mContainerId, frag); + } + ft.setPrimaryNavigationFragment(frag); + + final @IdRes int destId = destination.getId(); + final boolean initialNavigation = mBackStack.isEmpty(); + // TODO Build first class singleTop behavior for fragments + final boolean isSingleTopReplacement = navOptions != null && !initialNavigation + && navOptions.shouldLaunchSingleTop() + && mBackStack.peekLast() == destId; + + boolean isAdded; + if (initialNavigation) { + isAdded = true; + } else if (isSingleTopReplacement) { + // Single Top means we only want one instance on the back stack + if (mBackStack.size() > 1) { + // If the Fragment to be replaced is on the FragmentManager's + // back stack, a simple replace() isn't enough so we + // remove it from the back stack and put our replacement + // on the back stack in its place + mFragmentManager.popBackStack( + generateBackStackName(mBackStack.size(), mBackStack.peekLast()), + FragmentManager.POP_BACK_STACK_INCLUSIVE); + ft.addToBackStack(generateBackStackName(mBackStack.size(), destId)); + } + isAdded = false; + } else { + ft.addToBackStack(generateBackStackName(mBackStack.size() + 1, destId)); + isAdded = true; + } + if (navigatorExtras instanceof FragmentNavigator.Extras) { + FragmentNavigator.Extras extras = (FragmentNavigator.Extras) navigatorExtras; + for (Map.Entry sharedElement : extras.getSharedElements().entrySet()) { + ft.addSharedElement(sharedElement.getKey(), sharedElement.getValue()); + } + } + ft.setReorderingAllowed(true); + ft.commit(); + // The commit succeeded, update our view of the world + if (isAdded) { + mBackStack.add(destId); + return destination; + } else { + return null; + } + } + + @Override + @Nullable + public Bundle onSaveState() { + Bundle b = new Bundle(); + int[] backStack = new int[mBackStack.size()]; + int index = 0; + for (Integer id : mBackStack) { + backStack[index++] = id; + } + b.putIntArray(KEY_BACK_STACK_IDS, backStack); + return b; + } + + @Override + public void onRestoreState(@Nullable Bundle savedState) { + if (savedState != null) { + int[] backStack = savedState.getIntArray(KEY_BACK_STACK_IDS); + if (backStack != null) { + mBackStack.clear(); + for (int destId : backStack) { + mBackStack.add(destId); + } + } + } + } + + @NonNull + private String generateBackStackName(int backStackIndex, int destId) { + return backStackIndex + "-" + destId; + } + + @NavDestination.ClassType(Fragment.class) + public static class Destination extends NavDestination { + + private String mClassName; + private boolean shouldUseAdd; + + /** + * Construct a new fragment destination. This destination is not valid until you set the + * Fragment via {@link #setClassName(String)}. + * + * @param navigatorProvider The {@link androidx.navigation.NavController} which this destination + * will be associated with. + */ + public Destination(@NonNull NavigatorProvider navigatorProvider) { + this(navigatorProvider.getNavigator(AddFragmentNavigator.class)); + } + + /** + * Construct a new fragment destination. This destination is not valid until you set the + * Fragment via {@link #setClassName(String)}. + * + * @param fragmentNavigator The {@link FragmentNavigator} which this destination + * will be associated with. Generally retrieved via a + * {@link androidx.navigation.NavController}'s + * {@link NavigatorProvider#getNavigator(Class)} method. + */ + public Destination(@NonNull Navigator fragmentNavigator) { + super(fragmentNavigator); + } + + @CallSuper + @Override + public void onInflate(@NonNull Context context, @NonNull AttributeSet attrs) { + super.onInflate(context, attrs); + TypedArray fragmentNavigatorArray = context.getResources().obtainAttributes(attrs, + R.styleable.FragmentNavigator); + String className = fragmentNavigatorArray.getString(R.styleable.FragmentNavigator_android_name); + if (className != null) { + setClassName(className); + } + fragmentNavigatorArray.recycle(); + + TypedArray novaNavigatorArray = context.getResources().obtainAttributes(attrs, + R.styleable.AddFragmentNavigator); + + Boolean useAdd = novaNavigatorArray.getBoolean(R.styleable.AddFragmentNavigator_useAdd, false); + setUseAdd(useAdd); + novaNavigatorArray.recycle(); + } + + @NonNull + public final Destination setUseAdd(@NonNull Boolean useAdd) { + this.shouldUseAdd = useAdd; + return this; + } + + /** + * Gets the Fragment's class name associated with this destination + * + * @throws IllegalStateException when no Fragment class was set. + */ + @NonNull + public final boolean shouldUseAdd() { + return shouldUseAdd; + } + + /** + * Set the Fragment class name associated with this destination + * + * @param className The class name of the Fragment to show when you navigate to this + * destination + * @return this {@link androidx.navigation.fragment.FragmentNavigator.Destination} + */ + @NonNull + public final Destination setClassName(@NonNull String className) { + mClassName = className; + return this; + } + + /** + * Gets the Fragment's class name associated with this destination + * + * @throws IllegalStateException when no Fragment class was set. + */ + @NonNull + public final String getClassName() { + if (mClassName == null) { + throw new IllegalStateException("Fragment class was not set"); + } + return mClassName; + } + + @NonNull + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(super.toString()); + sb.append(" class="); + if (mClassName == null) { + sb.append("null"); + } else { + sb.append(mClassName); + } + return sb.toString(); + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/BaseNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/BaseNavigator.kt new file mode 100644 index 0000000..068b57b --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/BaseNavigator.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.app.root.navigation.navigators + +import io.novafoundation.nova.app.root.navigation.navigators.builder.NavigationBuilderRegistry +import io.novafoundation.nova.common.navigation.ReturnableRouter + +abstract class BaseNavigator( + private val navigationHoldersRegistry: NavigationHoldersRegistry +) : ReturnableRouter { + + val currentBackStackEntry + get() = navigationHoldersRegistry.firstAttachedNavController + ?.currentBackStackEntry + + val previousBackStackEntry + get() = navigationHoldersRegistry.firstAttachedNavController + ?.previousBackStackEntry + + val currentDestination + get() = navigationHoldersRegistry.firstAttachedNavController + ?.currentDestination + + override fun back() { + navigationHoldersRegistry.firstAttachedHolder.executeBack() + } + + fun finishApp() { + navigationHoldersRegistry.firstAttachedHolder.finishApp() + } + + fun navigationBuilder(): NavigationBuilderRegistry { + return navigationHoldersRegistry.navigationBuilder() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/NavigationHoldersRegistry.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/NavigationHoldersRegistry.kt new file mode 100644 index 0000000..9abf305 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/NavigationHoldersRegistry.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.app.root.navigation.navigators + +import androidx.navigation.NavController +import io.novafoundation.nova.app.root.navigation.holders.NavigationHolder +import io.novafoundation.nova.app.root.navigation.holders.RootNavigationHolder +import io.novafoundation.nova.app.root.navigation.holders.SplitScreenNavigationHolder +import io.novafoundation.nova.app.root.navigation.navigators.builder.NavigationBuilderRegistry + +class NavigationHoldersRegistry( + val splitScreenNavigationHolder: SplitScreenNavigationHolder, + val rootNavigationHolder: RootNavigationHolder +) { + + private val holders = listOf(splitScreenNavigationHolder, rootNavigationHolder) + + val firstAttachedHolder: NavigationHolder + get() = holders.first { it.isControllerAttached() } + + val firstAttachedNavController: NavController? + get() = firstAttachedHolder.navController +} + +fun NavigationHoldersRegistry.navigationBuilder(): NavigationBuilderRegistry { + return NavigationBuilderRegistry(this) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt new file mode 100644 index 0000000..99735fe --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt @@ -0,0 +1,980 @@ +package io.novafoundation.nova.app.root.navigation.navigators + +import android.os.Bundle +import androidx.lifecycle.asFlow +import androidx.navigation.NavOptions +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.delayedNavigation.BackDelayedNavigation +import io.novafoundation.nova.app.root.navigation.delayedNavigation.NavComponentDelayedNavigation +import io.novafoundation.nova.app.root.navigation.openSplitScreenWithInstantAction +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.common.navigation.DelayedNavigation +import io.novafoundation.nova.common.navigation.DelayedNavigationRouter +import io.novafoundation.nova.common.utils.getParcelableCompat +import io.novafoundation.nova.common.utils.postToUiThread +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionFragment +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionModePayload +import io.novafoundation.nova.feature_account_impl.presentation.account.details.WalletDetailsFragment +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet.CreateBackupPasswordPayload +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet.CreateWalletBackupPasswordFragment +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.exporting.json.ExportJsonFragment +import io.novafoundation.nova.feature_account_impl.presentation.exporting.seed.ExportSeedFragment +import io.novafoundation.nova.feature_account_impl.presentation.importing.ImportAccountFragment +import io.novafoundation.nova.feature_account_impl.presentation.legacyAddress.ChainAddressSelectorFragment +import io.novafoundation.nova.feature_account_impl.presentation.legacyAddress.ChainAddressSelectorPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.ManualBackupSelectAccountFragment +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.ManualBackupSelectAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.advanced.ManualBackupAdvancedSecretsFragment +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.main.ManualBackupSecretsFragment +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.warning.ManualBackupWarningFragment +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup.BackupMnemonicFragment +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup.BackupMnemonicPayload +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicFragment +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicPayload +import io.novafoundation.nova.feature_account_impl.presentation.node.details.NodeDetailsFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.finish.FinishImportParitySignerFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.preview.PreviewImportParitySignerFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.scan.ScanImportParitySignerFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.start.StartImportParitySignerFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.ScanSignParitySignerFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model.ScanSignParitySignerPayload +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PinCodeAction +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PincodeFragment +import io.novafoundation.nova.feature_account_impl.presentation.pincode.ToolbarConfiguration +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletFragment +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletPayload +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletPayload.FlowType +import io.novafoundation.nova.feature_account_impl.presentation.watchOnly.change.ChangeWatchAccountFragment +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.ChainMigrationDetailsFragment +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.ChainMigrationDetailsPayload +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.detail.BalanceDetailFragment +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowFragment +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.receive.ReceiveFragment +import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft +import io.novafoundation.nova.feature_assets.presentation.send.amount.SelectSendFragment +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload +import io.novafoundation.nova.feature_assets.presentation.send.confirm.ConfirmSendFragment +import io.novafoundation.nova.feature_assets.presentation.swap.asset.AssetSwapFlowFragment +import io.novafoundation.nova.feature_assets.presentation.swap.asset.SwapFlowPayload +import io.novafoundation.nova.feature_assets.presentation.swap.network.NetworkSwapFlowFragment +import io.novafoundation.nova.feature_assets.presentation.swap.network.NetworkSwapFlowPayload +import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.AddTokenEnterInfoFragment +import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.AddTokenEnterInfoPayload +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensFragment +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensPayload +import io.novafoundation.nova.feature_assets.presentation.trade.common.TradeProviderFlowType +import io.novafoundation.nova.feature_assets.presentation.trade.provider.TradeProviderListFragment +import io.novafoundation.nova.feature_assets.presentation.trade.provider.TradeProviderListPayload +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.OnSuccessfulTradeStrategyType +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebFragment +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebPayload +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.extrinsic.ExtrinsicDetailFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.direct.RewardDetailFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.pool.PoolRewardDetailFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.swap.SwapDetailFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.transfer.TransferDetailFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.ConfirmContributeFragment +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.parcel.ConfirmContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeFragment +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.terms.MoonbeamCrowdloanTermsFragment +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.CrowdloanContributeFragment +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload +import io.novafoundation.nova.feature_gift_impl.presentation.amount.SelectGiftAmountFragment +import io.novafoundation.nova.feature_gift_impl.presentation.amount.SelectGiftAmountPayload +import io.novafoundation.nova.feature_gift_impl.presentation.gifts.GiftsFragment +import io.novafoundation.nova.feature_gift_impl.presentation.gifts.GiftsPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger.AddEvmAccountSelectGenericLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger.AddEvmAccountSelectGenericLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger.AddChainAccountSelectLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger.AddChainAccountSelectLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedBottomSheet +import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedPayload +import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter +import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.WelcomeFragment +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_swap_impl.presentation.main.SwapMainSettingsFragment +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsPayload +import io.novafoundation.nova.splash.SplashRouter +import kotlinx.coroutines.flow.Flow + +class Navigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val walletConnectDelegate: WalletConnectRouter +) : BaseNavigator(navigationHoldersRegistry), + SplashRouter, + OnboardingRouter, + AccountRouter, + AssetsRouter, + RootRouter, + CrowdloanRouter, + DelayedNavigationRouter { + + override fun openWelcomeScreen() { + navigationBuilder().cases() + .addCase(R.id.accountsFragment, R.id.action_walletManagment_to_welcome) + .addCase(R.id.splashFragment, R.id.action_splash_to_onboarding) + .setArgs(WelcomeFragment.bundle(false)) + .navigateInFirstAttachedContext() + } + + override fun openInitialCheckPincode() { + val action = PinCodeAction.Check(NavComponentDelayedNavigation(R.id.action_open_split_screen), ToolbarConfiguration()) + + navigationBuilder().action(R.id.action_splash_to_pin) + .setArgs(PincodeFragment.getPinCodeBundle(action)) + .navigateInRoot() + } + + override fun openCreateFirstWallet() { + navigationBuilder().action(R.id.action_welcomeFragment_to_startCreateWallet) + .setArgs(StartCreateWalletFragment.bundle(StartCreateWalletPayload(FlowType.FIRST_WALLET))) + .navigateInFirstAttachedContext() + } + + override fun openMain() { + navigationBuilder().action(R.id.action_open_split_screen) + .navigateInRoot() + } + + override fun openAfterPinCode(delayedNavigation: DelayedNavigation) { + when (delayedNavigation) { + is NavComponentDelayedNavigation -> { + val navOptions = NavOptions.Builder() + .setPopUpTo(R.id.pincodeFragment, true) + .setEnterAnim(R.anim.fragment_open_enter) + .setExitAnim(R.anim.fragment_open_exit) + .setPopEnterAnim(R.anim.fragment_close_enter) + .setPopExitAnim(R.anim.fragment_close_exit) + .build() + + navigationBuilder().action(delayedNavigation.globalActionId) + .setArgs(delayedNavigation.extras) + .setNavOptions(navOptions) + .navigateInFirstAttachedContext() + } + + is BackDelayedNavigation -> back() + } + } + + override fun openCreatePincode() { + val args = buildCreatePinBundle() + + navigationBuilder().cases() + .addCase(R.id.splashFragment, R.id.action_splash_to_pin) + .addCase(R.id.importAccountFragment, R.id.action_importAccountFragment_to_pincodeFragment) + .addCase(R.id.confirmMnemonicFragment, R.id.action_confirmMnemonicFragment_to_pincodeFragment) + .addCase(R.id.createWatchWalletFragment, R.id.action_watchWalletFragment_to_pincodeFragment) + .addCase(R.id.finishImportParitySignerFragment, R.id.action_finishImportParitySignerFragment_to_pincodeFragment) + .addCase(R.id.finishImportLedgerFragment, R.id.action_finishImportLedgerFragment_to_pincodeFragment) + .addCase(R.id.createCloudBackupPasswordFragment, R.id.action_createCloudBackupPasswordFragment_to_pincodeFragment) + .addCase(R.id.restoreCloudBackupFragment, R.id.action_restoreCloudBackupFragment_to_pincodeFragment) + .addCase(R.id.finishImportGenericLedgerFragment, R.id.action_finishImportGenericLedgerFragment_to_pincodeFragment) + .setArgs(args) + .navigateInRoot() + } + + override fun openAdvancedSettings(payload: AdvancedEncryptionModePayload) { + navigationBuilder().action(R.id.action_open_advancedEncryptionFragment) + .setArgs(AdvancedEncryptionFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openConfirmMnemonicOnCreate(confirmMnemonicPayload: ConfirmMnemonicPayload) { + navigationBuilder().action(R.id.action_backupMnemonicFragment_to_confirmMnemonicFragment) + .setArgs(ConfirmMnemonicFragment.getBundle(confirmMnemonicPayload)) + .navigateInFirstAttachedContext() + } + + override fun openImportAccountScreen(payload: ImportAccountPayload) { + navigationBuilder().cases() + .addCase(R.id.splashFragment, R.id.action_splashFragment_to_import_nav_graph) + .setFallbackCase(R.id.action_import_nav_graph) + .setArgs(ImportAccountFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openMnemonicScreen(accountName: String?, addAccountPayload: AddAccountPayload) { + val payload = BackupMnemonicPayload.Create(accountName, addAccountPayload) + + navigationBuilder().cases() + .addCase(R.id.welcomeFragment, R.id.action_welcomeFragment_to_mnemonic_nav_graph) + .addCase(R.id.startCreateWalletFragment, R.id.action_startCreateWalletFragment_to_mnemonic_nav_graph) + .addCase(R.id.walletDetailsFragment, R.id.action_accountDetailsFragment_to_mnemonic_nav_graph) + .setArgs(BackupMnemonicFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openContribute(payload: ContributePayload) { + val bundle = CrowdloanContributeFragment.getBundle(payload) + + navigationBuilder().cases() + .addCase(R.id.mainFragment, R.id.action_mainFragment_to_crowdloanContributeFragment) + .addCase(R.id.moonbeamCrowdloanTermsFragment, R.id.action_moonbeamCrowdloanTermsFragment_to_crowdloanContributeFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + @Deprecated("TODO: Use communicator api instead") + override val customBonusFlow: Flow + get() = currentBackStackEntry!!.savedStateHandle + .getLiveData(CrowdloanContributeFragment.KEY_BONUS_LIVE_DATA) + .asFlow() + + @Deprecated("TODO: Use communicator api instead") + override val latestCustomBonus: BonusPayload? + get() = currentBackStackEntry!!.savedStateHandle + .get(CrowdloanContributeFragment.KEY_BONUS_LIVE_DATA) + + override fun openCustomContribute(payload: CustomContributePayload) { + navigationBuilder().action(R.id.action_crowdloanContributeFragment_to_customContributeFragment) + .setArgs(CustomContributeFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + @Deprecated("TODO: Use communicator api instead") + override fun setCustomBonus(payload: BonusPayload) { + previousBackStackEntry!!.savedStateHandle.set(CrowdloanContributeFragment.KEY_BONUS_LIVE_DATA, payload) + } + + override fun openConfirmContribute(payload: ConfirmContributePayload) { + navigationBuilder().action(R.id.action_crowdloanContributeFragment_to_confirmContributeFragment) + .setArgs(ConfirmContributeFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun returnToMain() { + navigationBuilder().action(R.id.back_to_main) + .navigateInFirstAttachedContext() + } + + override fun openMoonbeamFlow(payload: ContributePayload) { + navigationBuilder().action(R.id.action_mainFragment_to_moonbeamCrowdloanTermsFragment) + .setArgs(MoonbeamCrowdloanTermsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openAddAccount(payload: AddAccountPayload) { + navigationBuilder().action(R.id.action_open_onboarding) + .setArgs(WelcomeFragment.bundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openFilter(payload: TransactionHistoryFilterPayload) { + navigationBuilder().action(R.id.action_mainFragment_to_filterFragment) + .setArgs(TransactionHistoryFilterFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSend(payload: SendPayload, initialRecipientAddress: String?, initialAmount: Double?) { + val extras = SelectSendFragment.getBundle(payload, initialRecipientAddress, initialAmount) + + navigationBuilder().cases() + .addCase(R.id.sendFlowFragment, R.id.action_sendFlow_to_send) + .addCase(R.id.sendFlowNetworkFragment, R.id.action_sendFlowNetwork_to_send) + .setFallbackCase(R.id.action_open_send) + .setArgs(extras) + .navigateInFirstAttachedContext() + } + + override fun openConfirmTransfer(transferDraft: TransferDraft) { + val bundle = ConfirmSendFragment.getBundle(transferDraft) + + navigationBuilder().action(R.id.action_chooseAmountFragment_to_confirmTransferFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openTransferDetail(transaction: OperationParcelizeModel.Transfer) { + val bundle = TransferDetailFragment.getBundle(transaction) + + navigationBuilder().action(R.id.open_transfer_detail) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openRewardDetail(reward: OperationParcelizeModel.Reward) { + val bundle = RewardDetailFragment.getBundle(reward) + + navigationBuilder().action(R.id.open_reward_detail) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openPoolRewardDetail(reward: OperationParcelizeModel.PoolReward) { + val bundle = PoolRewardDetailFragment.getBundle(reward) + + navigationBuilder().action(R.id.open_pool_reward_detail) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openSwapDetail(swap: OperationParcelizeModel.Swap) { + val bundle = SwapDetailFragment.getBundle(swap) + + navigationBuilder().action(R.id.open_swap_detail) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openExtrinsicDetail(extrinsic: OperationParcelizeModel.Extrinsic) { + navigationBuilder().action(R.id.open_extrinsic_detail) + .setArgs(ExtrinsicDetailFragment.getBundle(extrinsic)) + .navigateInFirstAttachedContext() + } + + override fun openWallets() { + navigationBuilder().action(R.id.action_open_accounts) + .navigateInFirstAttachedContext() + } + + override fun openSwitchWallet() { + navigationBuilder().action(R.id.action_open_switch_wallet) + .navigateInFirstAttachedContext() + } + + override fun openDelegatedAccountsUpdates() { + navigationBuilder().action(R.id.action_switchWalletFragment_to_delegatedAccountUpdates) + .navigateInFirstAttachedContext() + } + + override fun openSelectAddress(arguments: Bundle) { + navigationBuilder().action(R.id.action_open_select_address) + .setArgs(arguments) + .navigateInFirstAttachedContext() + } + + override fun openSelectSingleWallet(arguments: Bundle) { + navigationBuilder().action(R.id.action_open_select_single_wallet) + .setArgs(arguments) + .navigateInFirstAttachedContext() + } + + override fun openSelectMultipleWallets(arguments: Bundle) { + navigationBuilder().action(R.id.action_open_select_multiple_wallets) + .setArgs(arguments) + .navigateInFirstAttachedContext() + } + + override fun openNodes() { + navigationBuilder().action(R.id.action_mainFragment_to_nodesFragment) + .navigateInFirstAttachedContext() + } + + override fun openReceive(assetPayload: AssetPayload) { + navigationBuilder().cases() + .addCase(R.id.receiveFlowFragment, R.id.action_receiveFlow_to_receive) + .addCase(R.id.receiveFlowNetworkFragment, R.id.action_receiveFlowNetwork_to_receive) + .setFallbackCase(R.id.action_open_receive) + .setArgs(ReceiveFragment.getBundle(assetPayload)) + .navigateInFirstAttachedContext() + } + + override fun openAssetSearch() { + navigationBuilder().action(R.id.action_mainFragment_to_assetSearchFragment) + .navigateInFirstAttachedContext() + } + + override fun openManageTokens() { + navigationBuilder().action(R.id.action_mainFragment_to_manageTokensGraph) + .navigateInFirstAttachedContext() + } + + override fun openManageChainTokens(payload: ManageChainTokensPayload) { + val args = ManageChainTokensFragment.getBundle(payload) + navigationBuilder().action(R.id.action_manageTokensFragment_to_manageChainTokensFragment) + .setArgs(args) + .navigateInFirstAttachedContext() + } + + override fun openAddTokenSelectChain() { + navigationBuilder().action(R.id.action_manageTokensFragment_to_addTokenSelectChainFragment) + .navigateInFirstAttachedContext() + } + + override fun openSendFlow() { + navigationBuilder().cases() + .addCase(R.id.mainFragment, R.id.action_mainFragment_to_sendFlow) + .addCase(R.id.bridgeFragment, R.id.action_bridge_to_sendFlow) + .navigateInFirstAttachedContext() + } + + override fun openReceiveFlow() { + navigationBuilder().action(R.id.action_mainFragment_to_receiveFlow) + .navigateInFirstAttachedContext() + } + + override fun openBuyFlow() { + navigationBuilder().action(R.id.action_mainFragment_to_buyFlow) + .navigateInFirstAttachedContext() + } + + override fun openSellFlow() { + navigationBuilder().action(R.id.action_mainFragment_to_sellFlow) + .navigateInFirstAttachedContext() + } + + override fun openBridgeFlow() { + navigationBuilder().action(R.id.action_mainFragment_to_bridgeFlow) + .navigateInFirstAttachedContext() + } + + override fun openSelectGiftAmount(assetPayload: AssetPayload) { + navigationBuilder().action(R.id.action_selectGiftAmount) + .setArgs(SelectGiftAmountFragment.createPayload(SelectGiftAmountPayload(assetPayload))) + .navigateInFirstAttachedContext() + } + + override fun openBuyFlowFromSendFlow() { + navigationBuilder().action(R.id.action_sendFlow_to_buyFlow) + .navigateInFirstAttachedContext() + } + + override fun openAddTokenEnterInfo(payload: AddTokenEnterInfoPayload) { + val args = AddTokenEnterInfoFragment.getBundle(payload) + navigationBuilder().action(R.id.action_addTokenSelectChainFragment_to_addTokenEnterInfoFragment) + .setArgs(args) + .navigateInFirstAttachedContext() + } + + override fun finishAddTokenFlow() { + navigationBuilder().action(R.id.finish_add_token_flow) + .navigateInFirstAttachedContext() + } + + override fun openWalletConnectSessions(metaId: Long) { + walletConnectDelegate.openWalletConnectSessions(WalletConnectSessionsPayload(metaId = metaId)) + } + + override fun openWalletConnectScan() { + walletConnectDelegate.openScanPairingQrCode() + } + + override fun closeSendFlow() { + navigationBuilder().action(R.id.action_close_send_flow) + .navigateInFirstAttachedContext() + } + + override fun openNovaCard() { + navigationBuilder().action(R.id.action_open_novaCard) + .navigateInFirstAttachedContext() + } + + override fun openAwaitingCardCreation() { + navigationBuilder().action(R.id.action_open_awaiting_card_creation) + .navigateInFirstAttachedContext() + } + + override fun closeNovaCard() { + navigationBuilder().action(R.id.action_close_nova_card_from_waiting_dialog) + .navigateInFirstAttachedContext() + } + + override fun openSendNetworks(payload: NetworkFlowPayload) { + navigationBuilder().action(R.id.action_sendFlow_to_sendFlowNetwork) + .setArgs(NetworkFlowFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openReceiveNetworks(payload: NetworkFlowPayload) { + navigationBuilder().action(R.id.action_receiveFlow_to_receiveFlowNetwork) + .setArgs(NetworkFlowFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSwapNetworks(payload: NetworkSwapFlowPayload) { + navigationBuilder().action(R.id.action_selectAssetSwapFlowFragment_to_swapFlowNetworkFragment) + .setArgs(NetworkSwapFlowFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openBuyNetworks(payload: NetworkFlowPayload) { + navigationBuilder().action(R.id.action_buyFlow_to_buyFlowNetwork) + .setArgs(NetworkFlowFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSellNetworks(payload: NetworkFlowPayload) { + navigationBuilder().action(R.id.action_sellFlow_to_sellFlowNetwork) + .setArgs(NetworkFlowFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openGiftsNetworks(payload: NetworkFlowPayload) { + navigationBuilder().action(R.id.action_giftsFlow_to_giftsFlowNetwork) + .setArgs(NetworkFlowFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openBuyProviders( + chainId: String, + chainAssetId: Int + ) { + val payload = TradeProviderListPayload(chainId, chainAssetId, TradeProviderFlowType.BUY, OnSuccessfulTradeStrategyType.OPEN_ASSET) + navigationBuilder().cases() + .addCase(R.id.buyFlowFragment, R.id.action_buyFlow_to_tradeProvidersFragment) + .addCase(R.id.buyFlowNetworkFragment, R.id.action_buyFlowNetworks_to_tradeProvidersFragment) + .setFallbackCase(R.id.action_tradeProvidersFragment) + .setArgs(TradeProviderListFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSellProviders( + chainId: String, + chainAssetId: Int + ) { + val payload = TradeProviderListPayload(chainId, chainAssetId, TradeProviderFlowType.SELL, OnSuccessfulTradeStrategyType.OPEN_ASSET) + navigationBuilder().cases() + .addCase(R.id.sellFlowFragment, R.id.action_sellFlow_to_tradeProvidersFragment) + .addCase(R.id.sellFlowNetworkFragment, R.id.action_sellFlowNetworks_to_tradeProvidersFragment) + .setFallbackCase(R.id.action_tradeProvidersFragment) + .setArgs(TradeProviderListFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openTradeWebInterface(payload: TradeWebPayload) { + navigationBuilder().action(R.id.action_tradeWebFragment) + .setArgs(TradeWebFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openChainAddressSelector(chainId: String, accountId: ByteArray) { + val payload = ChainAddressSelectorPayload(chainId, accountId) + + navigationBuilder().action(R.id.action_openUnifiedAddressDialog) + .setArgs(ChainAddressSelectorFragment.getBundle(payload)) + .navigateInRoot() + } + + override fun closeChainAddressesSelector() { + navigationBuilder().action(R.id.action_closeChainAddressesFragment) + .navigateInRoot() + } + + override fun openAddGenericEvmAddressSelectLedger(metaId: Long) { + val payload = AddEvmAccountSelectGenericLedgerPayload(metaId) + + navigationBuilder().action(R.id.action_accountDetailsFragment_to_addEvmAccountGenericLedgerGraph) + .setArgs(AddEvmAccountSelectGenericLedgerFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun returnToMainSwapScreen() { + navigationBuilder().action(R.id.action_return_to_swap_settings) + .navigateInFirstAttachedContext() + } + + override fun openSwapFlow() { + val payload = SwapFlowPayload.InitialSelecting + navigationBuilder().action(R.id.action_mainFragment_to_swapFlow) + .setArgs(AssetSwapFlowFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSwapSetupAmount(swapSettingsPayload: SwapSettingsPayload) { + navigationBuilder().action(R.id.action_open_swapSetupAmount) + .setArgs(SwapMainSettingsFragment.getBundle(swapSettingsPayload)) + .navigateInFirstAttachedContext() + } + + override fun returnToMainScreen() { + navigationBuilder().action(R.id.action_returnToMainScreen) + .navigateInFirstAttachedContext() + } + + override fun finishSelectAndOpenSwapSetupAmount(swapSettingsPayload: SwapSettingsPayload) { + navigationBuilder().action(R.id.action_finish_and_open_swap_settings) + .setArgs(SwapMainSettingsFragment.getBundle(swapSettingsPayload)) + .navigateInFirstAttachedContext() + } + + override fun openNfts() { + navigationBuilder().action(R.id.action_mainFragment_to_nfts_nav_graph) + .navigateInFirstAttachedContext() + } + + override fun nonCancellableVerify() { + if (currentDestination?.id == R.id.splashFragment) { + return + } + + val action = PinCodeAction.CheckAfterInactivity(BackDelayedNavigation, ToolbarConfiguration()) + val bundle = PincodeFragment.getPinCodeBundle(action) + + if (currentDestination?.id == R.id.pincodeFragment) { + val arguments = currentBackStackEntry!!.arguments!!.getParcelableCompat(PincodeFragment.KEY_PINCODE_ACTION) + if (arguments is PinCodeAction.Change) { + navigationBuilder().action(R.id.action_pin_code_access_recovery) + .setArgs(bundle) + .navigateInRoot() + } + } else { + navigationBuilder().action(R.id.action_pin_code_access_recovery) + .setArgs(bundle) + .navigateInRoot() + } + } + + override fun openUpdateNotifications() { + navigationBuilder().action(R.id.action_open_update_notifications) + .navigateInRoot() + } + + override fun openPushWelcome() { + navigationBuilder().action(R.id.action_open_pushNotificationsWelcome) + .navigateInFirstAttachedContext() + } + + override fun openCloudBackupSettings() { + navigationBuilder().action(R.id.action_open_cloudBackupSettings) + .navigateInFirstAttachedContext() + } + + override fun openChainMigrationDetails(chainId: String) { + navigationBuilder().action(R.id.action_open_chain_migration_details) + .setArgs(ChainMigrationDetailsFragment.createPayload(ChainMigrationDetailsPayload(chainId))) + .navigateInRoot() + } + + override fun returnToWallet() { + // to achieve smooth animation + postToUiThread { + navigationBuilder().action(R.id.action_return_to_wallet) + .navigateInFirstAttachedContext() + } + } + + override fun openWalletDetails(metaId: Long) { + val extras = WalletDetailsFragment.getBundle(metaId) + navigationBuilder().action(R.id.action_open_account_details) + .setArgs(extras) + .navigateInFirstAttachedContext() + } + + override fun openClaimContribution() { + navigationBuilder() + .action(R.id.action_userContributionsFragment_to_claimContributionFragment) + .navigateInFirstAttachedContext() + } + + override fun openNodeDetails(nodeId: Int) { + val extras = NodeDetailsFragment.getBundle(nodeId) + navigationBuilder().action(R.id.action_nodesFragment_to_nodeDetailsFragment) + .setArgs(extras) + .navigateInFirstAttachedContext() + } + + override fun openAssetDetails(assetPayload: AssetPayload) { + val bundle = BalanceDetailFragment.getBundle(assetPayload) + + navigationBuilder().cases() + .addCase(R.id.mainFragment, R.id.action_mainFragment_to_balanceDetailFragment) + .addCase(R.id.assetSearchFragment, R.id.action_assetSearchFragment_to_balanceDetailFragment) + .addCase(R.id.confirmTransferFragment, R.id.action_confirmTransferFragment_to_balanceDetailFragment) + .addCase(R.id.tradeWebFragment, R.id.action_tradeWebFragment_to_balanceDetailFragment) + .addCase(R.id.balanceDetailFragment, R.id.action_balanceDetailFragment_to_balanceDetailFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openAssetDetailsFromDeepLink(payload: AssetPayload) { + openSplitScreenWithInstantAction(R.id.action_mainFragment_to_balanceDetailFragment, BalanceDetailFragment.getBundle(payload)) + } + + override fun openGifts() { + navigationBuilder().action(R.id.action_open_gifts) + .setArgs(GiftsFragment.createPayload(GiftsPayload.AllAssets)) + .navigateInFirstAttachedContext() + } + + override fun openGiftsByAsset(assetPayload: AssetPayload) { + navigationBuilder().action(R.id.action_open_gifts) + .setArgs(GiftsFragment.createPayload(GiftsPayload.ByAsset(assetPayload))) + .navigateInFirstAttachedContext() + } + + override fun finishTradeOperation() { + navigationBuilder().action(R.id.action_finishTradeOperation) + .navigateInFirstAttachedContext() + } + + override fun openAddNode() { + navigationBuilder().action(R.id.action_nodesFragment_to_addNodeFragment) + .navigateInFirstAttachedContext() + } + + override fun openChangeWatchAccount(payload: AddAccountPayload.ChainAccount) { + val bundle = ChangeWatchAccountFragment.getBundle(payload) + + navigationBuilder().action(R.id.action_accountDetailsFragment_to_changeWatchAccountFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openCreateWallet(payload: StartCreateWalletPayload) { + navigationBuilder().action(R.id.action_open_create_new_wallet) + .setArgs(StartCreateWalletFragment.bundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openUserContributions() { + navigationBuilder().action(R.id.action_mainFragment_to_userContributionsGraph) + .navigateInFirstAttachedContext() + } + + override fun getExportMnemonicDelayedNavigation(exportPayload: ExportPayload.ChainAccount): DelayedNavigation { + val payload = BackupMnemonicPayload.Confirm(exportPayload.chainId, exportPayload.metaId) + val extras = BackupMnemonicFragment.getBundle(payload) + + return NavComponentDelayedNavigation(R.id.action_open_mnemonic_nav_graph, extras) + } + + override fun getExportSeedDelayedNavigation(exportPayload: ExportPayload.ChainAccount): DelayedNavigation { + val extras = ExportSeedFragment.getBundle(exportPayload) + + return NavComponentDelayedNavigation(R.id.action_export_seed, extras) + } + + override fun getExportJsonDelayedNavigation(exportPayload: ExportPayload): DelayedNavigation { + val extras = ExportJsonFragment.getBundle(exportPayload) + + return NavComponentDelayedNavigation(R.id.action_export_json, extras) + } + + override fun exportJsonAction(exportPayload: ExportPayload) { + val extras = ExportJsonFragment.getBundle(exportPayload) + + navigationBuilder().action(R.id.action_export_json) + .setArgs(extras) + .navigateInFirstAttachedContext() + } + + override fun finishExportFlow() { + navigationBuilder().action(R.id.finish_export_flow) + .navigateInFirstAttachedContext() + } + + override fun openScanImportParitySigner(payload: ParitySignerStartPayload) { + val args = ScanImportParitySignerFragment.getBundle(payload) + + navigationBuilder().action(R.id.action_startImportParitySignerFragment_to_scanImportParitySignerFragment) + .setArgs(args) + .navigateInFirstAttachedContext() + } + + override fun openPreviewImportParitySigner(payload: ParitySignerAccountPayload) { + val bundle = PreviewImportParitySignerFragment.getBundle(payload) + + navigationBuilder().action(R.id.action_scanImportParitySignerFragment_to_previewImportParitySignerFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openFinishImportParitySigner(payload: ParitySignerAccountPayload) { + val bundle = FinishImportParitySignerFragment.getBundle(payload) + + navigationBuilder().action(R.id.action_previewImportParitySignerFragment_to_finishImportParitySignerFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openScanParitySignerSignature(payload: ScanSignParitySignerPayload) { + val bundle = ScanSignParitySignerFragment.getBundle(payload) + + navigationBuilder().action(R.id.action_showSignParitySignerFragment_to_scanSignParitySignerFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun finishParitySignerFlow() { + navigationBuilder().action(R.id.action_finish_parity_signer_flow) + .navigateInFirstAttachedContext() + } + + override fun openAddLedgerChainAccountFlow(addAccountPayload: AddAccountPayload.ChainAccount) { + val payload = AddChainAccountSelectLedgerPayload(addAccountPayload, SelectLedgerPayload.ConnectionMode.ALL) + val bundle = AddChainAccountSelectLedgerFragment.getBundle(payload) + + navigationBuilder().action(R.id.action_accountDetailsFragment_to_addLedgerAccountGraph) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openCreateCloudBackupPassword(walletName: String) { + val bundle = CreateWalletBackupPasswordFragment.getBundle(CreateBackupPasswordPayload(walletName)) + + navigationBuilder().action(R.id.action_startCreateWalletFragment_to_createCloudBackupPasswordFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun restoreCloudBackup() { + navigationBuilder().cases() + .addCase(R.id.importWalletOptionsFragment, R.id.action_importWalletOptionsFragment_to_restoreCloudBackup) + .addCase(R.id.startCreateWalletFragment, R.id.action_startCreateWalletFragment_to_resotreCloudBackupFragment) + .navigateInFirstAttachedContext() + } + + override fun openSyncWalletsBackupPassword() { + navigationBuilder().action(R.id.action_cloudBackupSettings_to_syncWalletsBackupPasswordFragment) + .navigateInFirstAttachedContext() + } + + override fun openChangeBackupPasswordFlow() { + navigationBuilder().action(R.id.action_cloudBackupSettings_to_checkCloudBackupPasswordFragment) + .navigateInFirstAttachedContext() + } + + override fun openRestoreBackupPassword() { + navigationBuilder().action(R.id.action_cloudBackupSettings_to_restoreCloudBackupPasswordFragment) + .navigateInFirstAttachedContext() + } + + override fun openChangeBackupPassword() { + navigationBuilder().action(R.id.action_checkCloudBackupPasswordFragment_to_changeBackupPasswordFragment) + .navigateInFirstAttachedContext() + } + + override fun openManualBackupSelectAccount(metaId: Long) { + val bundle = ManualBackupSelectAccountFragment.bundle(ManualBackupSelectAccountPayload(metaId)) + + navigationBuilder().action(R.id.action_manualBackupSelectWalletFragment_to_manualBackupSelectAccountFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openManualBackupConditions(payload: ManualBackupCommonPayload) { + val bundle = ManualBackupWarningFragment.bundle(payload) + + val pinCodePayload = PinCodeAction.Check( + NavComponentDelayedNavigation(R.id.action_manualBackupPincodeFragment_to_manualBackupWarning, bundle), + ToolbarConfiguration() + ) + val pinCodeBundle = PincodeFragment.getPinCodeBundle(pinCodePayload) + + navigationBuilder().cases() + .addCase(R.id.manualBackupSelectWallet, R.id.action_manualBackupSelectWallet_to_pincode_check) + .addCase(R.id.manualBackupSelectAccount, R.id.action_manualBackupSelectAccount_to_pincode_check) + .setArgs(pinCodeBundle) + .navigateInFirstAttachedContext() + } + + override fun openManualBackupSecrets(payload: ManualBackupCommonPayload) { + val bundle = ManualBackupSecretsFragment.bundle(payload) + navigationBuilder().action(R.id.action_manualBackupWarning_to_manualBackupSecrets) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openManualBackupAdvancedSecrets(payload: ManualBackupCommonPayload) { + val bundle = ManualBackupAdvancedSecretsFragment.bundle(payload) + navigationBuilder().action(R.id.action_manualBackupSecrets_to_manualBackupAdvancedSecrets) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openCreateWatchWallet() { + navigationBuilder().action(R.id.action_importWalletOptionsFragment_to_createWatchWalletFragment) + .navigateInFirstAttachedContext() + } + + override fun openStartImportParitySigner() { + openStartImportPolkadotVault(PolkadotVaultVariant.PARITY_SIGNER) + } + + override fun openStartImportPolkadotVault() { + openStartImportPolkadotVault(PolkadotVaultVariant.POLKADOT_VAULT) + } + + override fun openImportOptionsScreen() { + navigationBuilder().cases() + .addCase(R.id.welcomeFragment, R.id.action_welcomeFragment_to_importWalletOptionsFragment) + .setFallbackCase(R.id.action_importWalletOptionsFragment) + .navigateInFirstAttachedContext() + } + + override fun openStartImportLegacyLedger() { + navigationBuilder().action(R.id.action_importWalletOptionsFragment_to_import_legacy_ledger_graph) + .navigateInFirstAttachedContext() + } + + override fun openStartImportGenericLedger() { + navigationBuilder().action(R.id.action_importWalletOptionsFragment_to_import_generic_ledger_graph) + .navigateInFirstAttachedContext() + } + + override fun withPinCodeCheckRequired( + delayedNavigation: DelayedNavigation, + createMode: Boolean, + pinCodeTitleRes: Int?, + ) { + val action = if (createMode) { + PinCodeAction.Create(delayedNavigation) + } else { + PinCodeAction.Check(delayedNavigation, ToolbarConfiguration(pinCodeTitleRes, true)) + } + + navigationBuilder().action(R.id.open_pincode_check) + .setArgs(PincodeFragment.getPinCodeBundle(action)) + .navigateInFirstAttachedContext() + } + + private fun openStartImportPolkadotVault(variant: PolkadotVaultVariant) { + val args = StartImportParitySignerFragment.getBundle(ParitySignerStartPayload(variant)) + + navigationBuilder().action(R.id.action_importWalletOptionsFragment_to_import_parity_signer_graph) + .setArgs(args) + .navigateInFirstAttachedContext() + } + + private fun buildCreatePinBundle(): Bundle { + val delayedNavigation = NavComponentDelayedNavigation(R.id.action_open_split_screen) + val action = PinCodeAction.Create(delayedNavigation) + return PincodeFragment.getPinCodeBundle(action) + } + + override fun runDelayedNavigation(delayedNavigation: DelayedNavigation) { + when (delayedNavigation) { + BackDelayedNavigation -> back() + is NavComponentDelayedNavigation -> { + navigationBuilder().action(delayedNavigation.globalActionId) + .setArgs(delayedNavigation.extras) + .navigateInFirstAttachedContext() + } + } + } + + override fun finishTopUp() { + navigationBuilder().action(R.id.action_finishTopUpFlow) + .navigateInFirstAttachedContext() + } + + override fun openPendingMultisigOperations() { + navigationBuilder().action(R.id.action_mainFragment_to_multisigPendingOperationsFlow) + .navigateInFirstAttachedContext() + } + + override fun openMainWithFinishMultisigTransaction(accountWasSwitched: Boolean) { + val payload = MultisigCreatedBottomSheet.createPayload(MultisigCreatedPayload(accountWasSwitched)) + openSplitScreenWithInstantAction(R.id.action_open_multisigCreatedDialog, nestedActionExtras = payload) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/PolkadotVaultVariantSignCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/PolkadotVaultVariantSignCommunicatorImpl.kt new file mode 100644 index 0000000..aade76b --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/PolkadotVaultVariantSignCommunicatorImpl.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.app.root.navigation.navigators.account + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.getBackStackEntryBefore +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator.Request +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator.Response +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show.ShowSignParitySignerFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show.ShowSignParitySignerPayload + +class PolkadotVaultVariantSignCommunicatorImpl( + navigationHoldersRegistry: NavigationHoldersRegistry +) : NavStackInterScreenCommunicator(navigationHoldersRegistry), PolkadotVaultVariantSignCommunicator { + + private var usedPolkadotVaultVariant: PolkadotVaultVariant? = null + + override fun respond(response: Response) { + val requester = navController.getBackStackEntryBefore(R.id.showSignParitySignerFragment) + + saveResultTo(requester, response) + } + + override fun setUsedVariant(variant: PolkadotVaultVariant) { + usedPolkadotVaultVariant = variant + } + + override fun openRequest(request: Request) { + super.openRequest(request) + + val payload = ShowSignParitySignerPayload(request, requireNotNull(usedPolkadotVaultVariant)) + val bundle = ShowSignParitySignerFragment.getBundle(payload) + navController.navigate(R.id.action_open_sign_parity_signer, bundle) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/ScanSeedCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/ScanSeedCommunicatorImpl.kt new file mode 100644 index 0000000..de9c109 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/ScanSeedCommunicatorImpl.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.root.navigation.navigators.account + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.navigationBuilder +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedCommunicator + +class ScanSeedCommunicatorImpl( + private val navigationHoldersRegistry: NavigationHoldersRegistry +) : NavStackInterScreenCommunicator(navigationHoldersRegistry), + ScanSeedCommunicator { + + override fun openRequest(request: ScanSeedCommunicator.Request) { + super.openRequest(request) + + navigationHoldersRegistry.navigationBuilder().action(R.id.action_scan_seed) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectAddressCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectAddressCommunicatorImpl.kt new file mode 100644 index 0000000..b142547 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectAddressCommunicatorImpl.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.root.navigation.navigators.account + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressRequester +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressResponder +import io.novafoundation.nova.feature_account_impl.presentation.account.list.selectAddress.SelectAddressBottomSheet +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter + +class SelectAddressCommunicatorImpl(private val router: AssetsRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + SelectAddressCommunicator { + + override fun openRequest(request: SelectAddressRequester.Request) { + super.openRequest(request) + + router.openSelectAddress(SelectAddressBottomSheet.getBundle(request)) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectMultipleWalletsCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectMultipleWalletsCommunicatorImpl.kt new file mode 100644 index 0000000..3a2bb73 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectMultipleWalletsCommunicatorImpl.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.root.navigation.navigators.account + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsRequester +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsResponder +import io.novafoundation.nova.feature_account_impl.presentation.account.list.multipleSelecting.SelectMultipleWalletsFragment +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter + +class SelectMultipleWalletsCommunicatorImpl(private val router: AssetsRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + SelectMultipleWalletsCommunicator { + + override fun openRequest(request: SelectMultipleWalletsRequester.Request) { + super.openRequest(request) + + router.openSelectMultipleWallets(SelectMultipleWalletsFragment.getBundle(request)) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectSingleWalletCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectSingleWalletCommunicatorImpl.kt new file mode 100644 index 0000000..9b55812 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectSingleWalletCommunicatorImpl.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.app.root.navigation.navigators.account + +import io.novafoundation.nova.app.root.navigation.FlowInterScreenCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletRequester +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletResponder +import io.novafoundation.nova.feature_account_impl.presentation.account.list.singleSelecting.SelectSingleWalletFragment +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter + +class SelectSingleWalletCommunicatorImpl(private val router: AssetsRouter) : + FlowInterScreenCommunicator(), + SelectSingleWalletCommunicator { + + override fun dispatchRequest(request: SelectSingleWalletRequester.Request) { + router.openSelectSingleWallet(SelectSingleWalletFragment.createPayload(request)) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectWalletCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectWalletCommunicatorImpl.kt new file mode 100644 index 0000000..e53f25b --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/account/SelectWalletCommunicatorImpl.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.app.root.navigation.navigators.account + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator.Payload +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator.Response + +class SelectWalletCommunicatorImpl( + private val navigationHoldersRegistry: NavigationHoldersRegistry +) : NavStackInterScreenCommunicator(navigationHoldersRegistry), SelectWalletCommunicator { + + override fun openRequest(request: Payload) { + super.openRequest(request) + + navController.navigate(R.id.action_open_select_wallet) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/accountmigration/AccountMigrationNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/accountmigration/AccountMigrationNavigator.kt new file mode 100644 index 0000000..ebbd6ff --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/accountmigration/AccountMigrationNavigator.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.app.root.navigation.navigators.accountmigration + +import android.os.Bundle +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.delayedNavigation.NavComponentDelayedNavigation +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PinCodeAction +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PincodeFragment +import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter +import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingFragment +import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingPayload + +class AccountMigrationNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry +) : BaseNavigator(navigationHoldersRegistry), AccountMigrationRouter { + + override fun openAccountMigrationPairing(scheme: String) { + val payload = AccountMigrationPairingPayload(scheme) + navigationBuilder().action(R.id.action_open_accountMigrationPairing) + .setArgs(AccountMigrationPairingFragment.Companion.createPayload(payload)) + .navigateInRoot() + } + + override fun finishMigrationFlow() { + navigationBuilder().action(R.id.action_open_split_screen) + .navigateInRoot() + } + + override fun openPinCodeSet() { + val args = buildCreatePinBundle() + + navigationBuilder().action(R.id.action_migration_to_pin) + .setArgs(args) + .navigateInRoot() + } + + private fun buildCreatePinBundle(): Bundle { + val delayedNavigation = NavComponentDelayedNavigation(R.id.action_open_split_screen) + val action = PinCodeAction.Create(delayedNavigation) + return PincodeFragment.getPinCodeBundle(action) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/ActionNavigationBuilder.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/ActionNavigationBuilder.kt new file mode 100644 index 0000000..59de6ae --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/ActionNavigationBuilder.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.app.root.navigation.navigators.builder + +import io.novafoundation.nova.app.root.navigation.holders.NavigationHolder +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry + +class ActionNavigationBuilder( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val actionId: Int +) : NavigationBuilder(navigationHoldersRegistry) { + + override fun performInternal(navigationHolder: NavigationHolder) { + performAction(navigationHolder, actionId) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/CasesNavigationBuilder.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/CasesNavigationBuilder.kt new file mode 100644 index 0000000..aa936d5 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/CasesNavigationBuilder.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.app.root.navigation.navigators.builder + +import androidx.navigation.NavDestination +import io.novafoundation.nova.app.root.navigation.holders.NavigationHolder +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry + +class CasesNavigationBuilder( + navigationHoldersRegistry: NavigationHoldersRegistry +) : NavigationBuilder(navigationHoldersRegistry) { + + private class Case(val destination: Int, val actionId: Int) + + private var cases = mutableListOf() + + private var fallbackCaseActionId: Int? = null + + fun addCase(currentDestination: Int, actionId: Int): CasesNavigationBuilder { + cases.add(Case(currentDestination, actionId)) + return this + } + + fun setFallbackCase(actionId: Int): CasesNavigationBuilder { + fallbackCaseActionId = actionId + return this + } + + override fun performInternal(navigationHolder: NavigationHolder) { + val navController = navigationHolder.navController ?: return + val currentDestination = navController.currentDestination ?: return + + val caseActionId = cases.find { case -> case.destination == currentDestination.id } + ?.actionId + ?: fallbackCaseActionId + ?: throw IllegalArgumentException("Unknown case for ${currentDestination.label}") + + performAction(navigationHolder, caseActionId) + } + + private fun NavDestination.hasAction(actionId: Int): Boolean { + return this.getAction(actionId) != null + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/NavigationBuilder.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/NavigationBuilder.kt new file mode 100644 index 0000000..0a6c587 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/NavigationBuilder.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.app.root.navigation.navigators.builder + +import android.os.Bundle +import androidx.navigation.NavDestination +import androidx.navigation.NavOptions +import androidx.navigation.fragment.FragmentNavigator +import io.novafoundation.nova.app.root.navigation.holders.NavigationHolder +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry + +/** + * Class for building navigation. + * Currently, it has 2 navigators: split_screen and root + * - root-navigator - a navigator that opens fragments on top of others and is designed for fragments that do not require a split screen (For example, DAppBrowser needs to be opened in the root navigator) + * - split_screen-navigator - the main navigator of the application. All fragments opened in it will be opened in a split screen mode + * + * Let's look at the scenarios for building navigation: + * - In the normal case, you need to add a navigation node only to the split_screen_nav_graph, be it a dialog or a fragment. + * This can be used when you are sure that the fragment or dialog should not be launched in the browser or on top of a split screen. + * Use [navigateInFirstAttachedContext] and the fragment will be automatically attached to the SplitScreenNavigationHolder. + * - If you expect that the fragment can also be launched from the browser, you need to add it to both the root_navigation_graph and the split_screen_navigation_graph. + * Keep in mind that the actionId must be the same in both graphs. + * Use [navigateInFirstAttachedContext] and the fragment will be automatically attached to the desired holder. + * - In case of adding dialogs, you can add it only to the root_navigation_graph if you think that the dialog can be launched both from the browser and from the remote part of the application. + * To attach the dialog to the RootNavigationHolder, call [navigateInRoot] + * - In the latter case, we may need to add a screen that is strictly required to be opened on top of the split screen or only in the browser flow. (Such screens as entering a pin code). + * In this case, we need to add an action only to the root_navigation_graph + * To attach the fragment to the RootNavigationHolder, call [navigateInRoot] + **/ +abstract class NavigationBuilder( + private val navigationHoldersRegistry: NavigationHoldersRegistry +) { + + protected var navOptions: NavOptions? = null + protected var args: Bundle? = null + protected var extras: FragmentNavigator.Extras? = null + + fun setArgs(args: Bundle?): NavigationBuilder { + this.args = args + return this + } + + fun setNavOptions(navOptions: NavOptions): NavigationBuilder { + this.navOptions = navOptions + return this + } + + fun setExtras(extras: FragmentNavigator.Extras?): NavigationBuilder { + this.extras = extras + return this + } + + /** + * Opens a fragment in the first attached navigation holder (split_screen or root). + * If it is assumed that the fragment can be opened both in root and in split_screen, then it is necessary to add a navigation node both to split_screen_navigation_graph and to root_navigation_graph + */ + fun navigateInFirstAttachedContext() { + performInternal(navigationHoldersRegistry.firstAttachedHolder) + } + + /** + * Always open fragment in root navigation holder. + * In this case, the node must be added to root_navigation_graph + **/ + fun navigateInRoot() { + performInternal(navigationHoldersRegistry.rootNavigationHolder) + } + + protected fun NavigationBuilder.performAction(navigationHolder: NavigationHolder, actionId: Int) { + val navController = navigationHolder.navController ?: return + val currentDestination = navController.currentDestination ?: return + + if (currentDestination.hasAction(actionId)) { + navigationHolder.navController?.navigate(actionId, args, navOptions, extras) + } + } + + protected abstract fun performInternal(navigationHolder: NavigationHolder) +} + +private fun NavDestination.hasAction(actionId: Int): Boolean { + return this.getAction(actionId) != null +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/NavigationBuilderRegistry.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/NavigationBuilderRegistry.kt new file mode 100644 index 0000000..0a8e3ec --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/NavigationBuilderRegistry.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.app.root.navigation.navigators.builder + +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry + +class NavigationBuilderRegistry(private val registry: NavigationHoldersRegistry) { + + fun action(actionId: Int) = ActionNavigationBuilder(registry, actionId) + + fun cases() = CasesNavigationBuilder(registry) + + fun graph(graphId: Int) = OpenGraphNavigationBuilder(registry, graphId) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/OpenGraphNavigationBuilder.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/OpenGraphNavigationBuilder.kt new file mode 100644 index 0000000..1f8534f --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/builder/OpenGraphNavigationBuilder.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.app.root.navigation.navigators.builder + +import io.novafoundation.nova.app.root.navigation.holders.NavigationHolder +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry + +class OpenGraphNavigationBuilder( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val graphId: Int +) : NavigationBuilder(navigationHoldersRegistry) { + + override fun performInternal(navigationHolder: NavigationHolder) { + navigationHolder.navController?.navigate(graphId, args, navOptions, extras) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/buy/BuyNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/buy/BuyNavigator.kt new file mode 100644 index 0000000..9e2a6c9 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/buy/BuyNavigator.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.app.root.navigation.navigators.buy + +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_buy_impl.presentation.BuyRouter + +class BuyNavigator(navigationHoldersRegistry: NavigationHoldersRegistry) : BuyRouter, + BaseNavigator(navigationHoldersRegistry) diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/chainMigration/ChainMigrationNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/chainMigration/ChainMigrationNavigator.kt new file mode 100644 index 0000000..d0eea3f --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/chainMigration/ChainMigrationNavigator.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.app.root.navigation.navigators.chainMigration + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_ahm_impl.presentation.ChainMigrationRouter +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.ChainMigrationDetailsFragment +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.ChainMigrationDetailsPayload + +class ChainMigrationNavigator(navigationHoldersRegistry: NavigationHoldersRegistry) : ChainMigrationRouter, BaseNavigator(navigationHoldersRegistry) { + + override fun openChainMigrationDetails(chainId: String) { + navigationBuilder().action(R.id.action_open_chain_migration_details) + .setArgs(ChainMigrationDetailsFragment.createPayload(ChainMigrationDetailsPayload(chainId))) + .navigateInRoot() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/ChangeBackupPasswordCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/ChangeBackupPasswordCommunicatorImpl.kt new file mode 100644 index 0000000..0aed81d --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/ChangeBackupPasswordCommunicatorImpl.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.app.root.navigation.navigators.cloudBackup + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordRequester +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordResponder +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter + +class ChangeBackupPasswordCommunicatorImpl(private val router: AccountRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + ChangeBackupPasswordCommunicator { + + override fun openRequest(request: ChangeBackupPasswordRequester.EmptyRequest) { + super.openRequest(request) + + router.openChangeBackupPasswordFlow() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/CloudBackupNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/CloudBackupNavigator.kt new file mode 100644 index 0000000..bcf1833 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/CloudBackupNavigator.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.app.root.navigation.navigators.cloudBackup + +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_cloud_backup_impl.presentation.CloudBackupRouter + +class CloudBackupNavigator(navigationHoldersRegistry: NavigationHoldersRegistry) : CloudBackupRouter, + BaseNavigator(navigationHoldersRegistry) diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/RestoreBackupPasswordCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/RestoreBackupPasswordCommunicatorImpl.kt new file mode 100644 index 0000000..3c75b03 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/RestoreBackupPasswordCommunicatorImpl.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.app.root.navigation.navigators.cloudBackup + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordRequester +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordResponder +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter + +class RestoreBackupPasswordCommunicatorImpl(private val router: AccountRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + RestoreBackupPasswordCommunicator { + + override fun openRequest(request: RestoreBackupPasswordRequester.EmptyRequest) { + super.openRequest(request) + + router.openRestoreBackupPassword() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/SyncWalletsBackupPasswordCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/SyncWalletsBackupPasswordCommunicatorImpl.kt new file mode 100644 index 0000000..081fc37 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/cloudBackup/SyncWalletsBackupPasswordCommunicatorImpl.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.app.root.navigation.navigators.cloudBackup + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordRequester +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordResponder + +class SyncWalletsBackupPasswordCommunicatorImpl(private val router: AccountRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + SyncWalletsBackupPasswordCommunicator { + + override fun openRequest(request: SyncWalletsBackupPasswordRequester.EmptyRequest) { + super.openRequest(request) + + router.openSyncWalletsBackupPassword() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/dApp/DAppNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/dApp/DAppNavigator.kt new file mode 100644 index 0000000..5f1b5c9 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/dApp/DAppNavigator.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.app.root.navigation.navigators.dApp + +import androidx.navigation.NavOptions +import androidx.navigation.fragment.FragmentNavigator +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.builder.NavigationBuilder +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.AddToFavouritesFragment +import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DAppBrowserFragment +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DappSearchFragment +import io.novafoundation.nova.feature_dapp_impl.presentation.search.SearchPayload + +class DAppNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, +) : BaseNavigator(navigationHoldersRegistry), DAppRouter { + + override fun openChangeAccount() { + navigationBuilder().action(R.id.action_open_switch_wallet) + .navigateInFirstAttachedContext() + } + + override fun openDAppBrowser(payload: DAppBrowserPayload, extras: FragmentNavigator.Extras?) { + // Close dapp browser if it is already opened + // TODO it's better to provide new url to existing browser + navigationBuilder().graph(R.id.dapp_browser_graph) + .setDappAnimations() + .setExtras(extras) + .setArgs(DAppBrowserFragment.getBundle(payload)) + .navigateInRoot() + } + + override fun openDappSearch() { + openDappSearchWithCategory(categoryId = null) + } + + override fun openDappSearchWithCategory(categoryId: String?) { + navigationBuilder().graph(R.id.dapp_search_graph) + .setDappAnimations() + .setArgs(DappSearchFragment.getBundle(SearchPayload(initialUrl = null, SearchPayload.Request.OPEN_NEW_URL, preselectedCategoryId = categoryId))) + .navigateInRoot() + } + + override fun finishDappSearch() { + navigationBuilder().action(R.id.action_finish_dapp_search) + .navigateInRoot() + } + + override fun openAddToFavourites(payload: AddToFavouritesPayload) { + navigationBuilder().action(R.id.action_DAppBrowserFragment_to_addToFavouritesFragment) + .setArgs(AddToFavouritesFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openAuthorizedDApps() { + navigationBuilder().action(R.id.action_mainFragment_to_authorizedDAppsFragment) + .navigateInFirstAttachedContext() + } + + override fun openTabs() { + navigationBuilder().graph(R.id.dapp_tabs_graph) + .setDappAnimations() + .navigateInRoot() + } + + override fun closeTabsScreen() { + navigationBuilder().action(R.id.action_finish_tabs_fragment) + .navigateInRoot() + } + + override fun openDAppFavorites() { + navigationBuilder().action(R.id.action_open_dapp_favorites) + .navigateInFirstAttachedContext() + } + + private fun NavigationBuilder.setDappAnimations(): NavigationBuilder { + val currentDestinationId = currentDestination?.id + + // For this currentDestinations we will use default animation. And for other - slide_in, slide_out + val dappDestinations = listOf( + R.id.dappSearchFragment, + R.id.dappBrowserFragment, + R.id.dappTabsFragment + ) + + val navOptionsBuilder = if (currentDestinationId in dappDestinations) { + // Only slide out animation + NavOptions.Builder() + .setEnterAnim(R.anim.fragment_open_enter) + .setExitAnim(R.anim.fragment_open_exit) + .setPopEnterAnim(R.anim.fragment_close_enter) + .setPopExitAnim(R.anim.fragment_slide_out) + .setPopUpTo(R.id.splitScreenFragment, false) + } else { + // Slide in/out animations + NavOptions.Builder() + .setEnterAnim(R.anim.fragment_slide_in) + .setExitAnim(R.anim.fragment_open_exit) + .setPopEnterAnim(R.anim.fragment_close_enter) + .setPopExitAnim(R.anim.fragment_slide_out) + .setPopUpTo(R.id.splitScreenFragment, false) + } + + return setNavOptions(navOptionsBuilder.build()) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/dApp/DAppSearchCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/dApp/DAppSearchCommunicatorImpl.kt new file mode 100644 index 0000000..5834e89 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/dApp/DAppSearchCommunicatorImpl.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.app.root.navigation.navigators.dApp + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator.Response +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DappSearchFragment +import io.novafoundation.nova.feature_dapp_impl.presentation.search.SearchPayload + +class DAppSearchCommunicatorImpl(navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + DAppSearchCommunicator { + + override fun openRequest(request: SearchPayload) { + super.openRequest(request) + + navigationBuilder().action(R.id.action_open_dappSearch_from_browser) + .setArgs(DappSearchFragment.getBundle(request)) + .navigateInRoot() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/externalSign/ExternalSignCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/externalSign/ExternalSignCommunicatorImpl.kt new file mode 100644 index 0000000..c8d97b0 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/externalSign/ExternalSignCommunicatorImpl.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.app.root.navigation.navigators.externalSign + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.FlowInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.navigationBuilder +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload +import io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.ExternalSignFragment +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ExternalSignCommunicatorImpl( + private val navigationHoldersRegistry: NavigationHoldersRegistry, + private val automaticInteractionGate: AutomaticInteractionGate, +) : CoroutineScope by CoroutineScope(Dispatchers.Main), + FlowInterScreenCommunicator(), + ExternalSignCommunicator { + + override fun dispatchRequest(request: ExternalSignPayload) { + launch { + automaticInteractionGate.awaitInteractionAllowed() + + navigationHoldersRegistry.navigationBuilder().action(R.id.action_open_externalSignGraph) + .setArgs(ExternalSignFragment.getBundle(request)) + .navigateInRoot() + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/externalSign/ExternalSignNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/externalSign/ExternalSignNavigator.kt new file mode 100644 index 0000000..8115db5 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/externalSign/ExternalSignNavigator.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.app.root.navigation.navigators.externalSign + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter +import io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.ExternalExtrinsicDetailsFragment + +class ExternalSignNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry +) : BaseNavigator(navigationHoldersRegistry), ExternalSignRouter { + + override fun openExtrinsicDetails(extrinsicContent: String) { + navigationBuilder().action(R.id.action_ConfirmSignExtrinsicFragment_to_extrinsicDetailsFragment) + .setArgs(ExternalExtrinsicDetailsFragment.getBundle(extrinsicContent)) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/gift/GiftNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/gift/GiftNavigator.kt new file mode 100644 index 0000000..32d8db8 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/gift/GiftNavigator.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.app.root.navigation.navigators.gift + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.feature_gift_impl.domain.GiftId +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftFragment +import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftPayload +import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmFragment +import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmPayload +import io.novafoundation.nova.feature_gift_impl.presentation.share.ShareGiftFragment +import io.novafoundation.nova.feature_gift_impl.presentation.share.ShareGiftPayload +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload + +class GiftNavigator( + private val commonDelegate: Navigator, + navigationHoldersRegistry: NavigationHoldersRegistry +) : GiftRouter, BaseNavigator(navigationHoldersRegistry) { + + override fun finishCreateGift() { + navigationBuilder().action(R.id.action_finishCreateGift) + .navigateInFirstAttachedContext() + } + + override fun openGiftsFlow() { + navigationBuilder().action(R.id.action_giftsFragment_to_giftsFlow) + .navigateInFirstAttachedContext() + } + + override fun openSelectGiftAmount(assetPayload: AssetPayload) { + commonDelegate.openSelectGiftAmount(assetPayload) + } + + override fun openConfirmCreateGift(payload: CreateGiftConfirmPayload) { + navigationBuilder().action(R.id.action_openConfirmCreateGift) + .setArgs(CreateGiftConfirmFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openGiftSharing(giftId: GiftId, isSecondOpen: Boolean) { + navigationBuilder().action(R.id.action_openShareGiftFragment) + .setArgs(ShareGiftFragment.createPayload(ShareGiftPayload(giftId, isSecondOpen))) + .navigateInFirstAttachedContext() + } + + override fun openMainScreen() { + commonDelegate.openMain() + } + + override fun openClaimGift(payload: ClaimGiftPayload) { + navigationBuilder().action(R.id.action_openClaimGiftFragment) + .setArgs(ClaimGiftFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openManageWallets() { + commonDelegate.openWallets() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/governance/GovernanceNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/governance/GovernanceNavigator.kt new file mode 100644 index 0000000..dd43a09 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/governance/GovernanceNavigator.kt @@ -0,0 +1,268 @@ +package io.novafoundation.nova.app.root.navigation.navigators.governance + +import android.os.Bundle +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.navigation.openSplitScreenWithInstantAction +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.utils.showBrowser +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_governance_impl.BuildConfig +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.DescriptionFragment +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.DescriptionPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.DelegateDelegatorsFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.DelegateDelegatorsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.VotedReferendaFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.VotedReferendaPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.NewDelegationChooseTracksFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.NewDelegationChooseTracksPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.NewDelegationConfirmFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.NewDelegationConfirmPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.RemoveVotesFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.RemoveVotesPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.RevokeDelegationChooseTracksFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.RevokeDelegationChooseTracksPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.RevokeDelegationConfirmFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.RevokeDelegationConfirmPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.ReferendumDetailsFragment +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.ReferendumInfoFragment +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.ReferendumInfoPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumFullDetailsFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumFullDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.ConfirmReferendumVoteFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.ConfirmVoteReferendumPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVoteFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.ReferendumVotersFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.ReferendumVotersPayload + +class GovernanceNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val commonNavigator: Navigator, + private val contextManager: ContextManager, + private val dAppRouter: DAppRouter +) : BaseNavigator(navigationHoldersRegistry), GovernanceRouter { + + override fun openReferendum(payload: ReferendumDetailsPayload) { + navigationBuilder().cases() + .addCase(R.id.referendumDetailsFragment, R.id.action_referendumDetailsFragment_to_referendumDetailsFragment) + .addCase(R.id.referendaSearchFragment, R.id.action_open_referendum_details_from_referenda_search) + .setFallbackCase(R.id.action_open_referendum_details) + .setArgs(ReferendumDetailsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openReferendumFullDetails(payload: ReferendumFullDetailsPayload) { + navigationBuilder().action(R.id.action_referendumDetailsFragment_to_referendumFullDetailsFragment) + .setArgs(ReferendumFullDetailsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openReferendumVoters(payload: ReferendumVotersPayload) { + navigationBuilder().action(R.id.action_referendumDetailsFragment_to_referendumVotersFragment) + .setArgs(ReferendumVotersFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSetupReferendumVote(payload: SetupVotePayload) { + navigationBuilder().action(R.id.action_referendumDetailsFragment_to_setupVoteReferendumFragment) + .setArgs(SetupVoteFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSetupTinderGovVote(payload: SetupVotePayload) { + navigationBuilder().action(R.id.action_tinderGovCards_to_setupTinderGovVoteFragment) + .setArgs(SetupVoteFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun backToReferendumDetails() { + navigationBuilder().action(R.id.action_confirmReferendumVote_to_referendumDetailsFragment) + .navigateInFirstAttachedContext() + } + + override fun finishUnlockFlow(shouldCloseLocksScreen: Boolean) { + if (shouldCloseLocksScreen) { + navigationBuilder().action(R.id.action_confirmReferendumVote_to_mainFragment) + .navigateInFirstAttachedContext() + } else { + back() + } + } + + override fun openWalletDetails(id: Long) { + commonNavigator.openWalletDetails(id) + } + + override fun openAddDelegation() { + navigationBuilder().cases() + .addCase(R.id.mainFragment, R.id.action_mainFragment_to_delegation) + .addCase(R.id.yourDelegationsFragment, R.id.action_yourDelegations_to_delegationList) + .navigateInFirstAttachedContext() + } + + override fun openYourDelegations() { + navigationBuilder().action(R.id.action_mainFragment_to_your_delegation) + .navigateInFirstAttachedContext() + } + + override fun openBecomingDelegateTutorial() { + contextManager.getActivity()?.showBrowser(BuildConfig.DELEGATION_TUTORIAL_URL) + } + + override fun backToYourDelegations() { + navigationBuilder().action(R.id.action_back_to_your_delegations) + .navigateInFirstAttachedContext() + } + + override fun openRevokeDelegationChooseTracks(payload: RevokeDelegationChooseTracksPayload) { + navigationBuilder().action(R.id.action_delegateDetailsFragment_to_revokeDelegationChooseTracksFragment) + .setArgs(RevokeDelegationChooseTracksFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openRevokeDelegationsConfirm(payload: RevokeDelegationConfirmPayload) { + navigationBuilder().action(R.id.action_revokeDelegationChooseTracksFragment_to_revokeDelegationConfirmFragment) + .setArgs(RevokeDelegationConfirmFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openDelegateSearch() { + navigationBuilder().action(R.id.action_delegateListFragment_to_delegateSearchFragment) + .navigateInFirstAttachedContext() + } + + override fun openSelectGovernanceTracks(bundle: Bundle) { + navigationBuilder().action(R.id.action_open_select_governance_tracks) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openTinderGovCards() { + navigationBuilder().action(R.id.action_openTinderGovCards) + .navigateInFirstAttachedContext() + } + + override fun openTinderGovBasket() { + navigationBuilder().action(R.id.action_tinderGovCards_to_tinderGovBasket) + .navigateInFirstAttachedContext() + } + + override fun openConfirmTinderGovVote() { + navigationBuilder().action(R.id.action_setupTinderGovBasket_to_confirmTinderGovVote) + .navigateInFirstAttachedContext() + } + + override fun backToTinderGovCards() { + navigationBuilder().action(R.id.action_confirmTinderGovVote_to_tinderGovCards) + .navigateInFirstAttachedContext() + } + + override fun openReferendumInfo(payload: ReferendumInfoPayload) { + navigationBuilder().cases() + .addCase(R.id.tinderGovCards, R.id.action_tinderGovCards_to_referendumInfo) + .addCase(R.id.setupTinderGovBasketFragment, R.id.action_setupTinderGovBasket_to_referendumInfo) + .setArgs(ReferendumInfoFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openReferendumFromDeepLink(payload: ReferendumDetailsPayload) { + openSplitScreenWithInstantAction(R.id.action_open_referendum_details, ReferendumDetailsFragment.getBundle(payload)) + } + + override fun openReferendaSearch() { + navigationBuilder().action(R.id.action_open_referenda_search) + .navigateInFirstAttachedContext() + } + + override fun openReferendaFilters() { + navigationBuilder().action(R.id.action_open_referenda_filters) + .navigateInFirstAttachedContext() + } + + override fun openRemoveVotes(payload: RemoveVotesPayload) { + navigationBuilder().action(R.id.action_open_remove_votes) + .setArgs(RemoveVotesFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openDelegateDelegators(payload: DelegateDelegatorsPayload) { + navigationBuilder().action(R.id.action_delegateDetailsFragment_to_delegateDelegatorsFragment) + .setArgs(DelegateDelegatorsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openDelegateDetails(payload: DelegateDetailsPayload) { + navigationBuilder().cases() + .addCase(R.id.delegateListFragment, R.id.action_delegateListFragment_to_delegateDetailsFragment) + .addCase(R.id.yourDelegationsFragment, R.id.action_yourDelegations_to_delegationDetails) + .addCase(R.id.delegateSearchFragment, R.id.action_delegateSearchFragment_to_delegateDetailsFragment) + .setArgs(DelegateDetailsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openNewDelegationChooseTracks(payload: NewDelegationChooseTracksPayload) { + navigationBuilder().action(R.id.action_delegateDetailsFragment_to_selectDelegationTracks) + .setArgs(NewDelegationChooseTracksFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openNewDelegationChooseAmount(payload: NewDelegationChooseAmountPayload) { + navigationBuilder().action(R.id.action_selectDelegationTracks_to_newDelegationChooseAmountFragment) + .setArgs(NewDelegationChooseAmountFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openNewDelegationConfirm(payload: NewDelegationConfirmPayload) { + navigationBuilder().action(R.id.action_newDelegationChooseAmountFragment_to_newDelegationConfirmFragment) + .setArgs(NewDelegationConfirmFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openVotedReferenda(payload: VotedReferendaPayload) { + navigationBuilder().action(R.id.action_delegateDetailsFragment_to_votedReferendaFragment) + .setArgs(VotedReferendaFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openDelegateFullDescription(payload: DescriptionPayload) { + navigationBuilder().action(R.id.action_delegateDetailsFragment_to_delegateFullDescription) + .setArgs(DescriptionFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openDAppBrowser(url: String) { + dAppRouter.openDAppBrowser(DAppBrowserPayload.Address(url)) + } + + override fun openReferendumDescription(payload: DescriptionPayload) { + navigationBuilder().action(R.id.action_referendumDetailsFragment_to_referendumDescription) + .setArgs(DescriptionFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openConfirmVoteReferendum(payload: ConfirmVoteReferendumPayload) { + navigationBuilder().action(R.id.action_setupVoteReferendumFragment_to_confirmReferendumVote) + .setArgs(ConfirmReferendumVoteFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openGovernanceLocksOverview() { + navigationBuilder().action(R.id.action_mainFragment_to_governanceLocksOverview) + .navigateInFirstAttachedContext() + } + + override fun openConfirmGovernanceUnlock() { + navigationBuilder().action(R.id.action_governanceLocksOverview_to_confirmGovernanceUnlock) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/governance/SelectTracksCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/governance/SelectTracksCommunicatorImpl.kt new file mode 100644 index 0000000..4240ff1 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/governance/SelectTracksCommunicatorImpl.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.root.navigation.navigators.governance + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksRequester +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksResponder +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.governanceTracks.SelectGovernanceTracksFragment + +class SelectTracksCommunicatorImpl(private val router: GovernanceRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + SelectTracksCommunicator { + + override fun openRequest(request: SelectTracksRequester.Request) { + super.openRequest(request) + + router.openSelectGovernanceTracks(SelectGovernanceTracksFragment.getBundle(request)) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/governance/TinderGovVoteCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/governance/TinderGovVoteCommunicatorImpl.kt new file mode 100644 index 0000000..dacdd81 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/governance/TinderGovVoteCommunicatorImpl.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.app.root.navigation.navigators.governance + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.TinderGovVoteCommunicator +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.TinderGovVoteRequester +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.TinderGovVoteResponder +import kotlinx.coroutines.flow.Flow + +class TinderGovVoteCommunicatorImpl(private val router: GovernanceRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + TinderGovVoteCommunicator { + + override val responseFlow: Flow + get() = clearedResponseFlow() + + override fun openRequest(request: TinderGovVoteRequester.Request) { + super.openRequest(request) + + router.openSetupTinderGovVote(SetupVotePayload(request.referendumId)) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/ledger/LedgerNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/ledger/LedgerNavigator.kt new file mode 100644 index 0000000..87c7bbe --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/ledger/LedgerNavigator.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.app.root.navigation.navigators.ledger + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.AddEvmGenericLedgerAccountSelectAddressFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.AddEvmGenericLedgerAccountSelectAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.AddLedgerChainAccountSelectAddressFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.AddLedgerChainAccountSelectAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectAddressLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.FinishImportGenericLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.FinishImportGenericLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.PreviewImportGenericLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.PreviewImportGenericLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.SelectLedgerGenericImportFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.SelectLedgerGenericPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.FillWalletImportLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.FillWalletImportLedgerLegacyPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.FinishImportLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.FinishImportLedgerPayload + +class LedgerNavigator( + private val accountRouter: AccountRouter, + navigationHoldersRegistry: NavigationHoldersRegistry +) : BaseNavigator(navigationHoldersRegistry), LedgerRouter { + + override fun openImportFillWallet(payload: FillWalletImportLedgerLegacyPayload) { + navigationBuilder().action(R.id.action_startImportLedgerFragment_to_fillWalletImportLedgerFragment) + .setArgs(FillWalletImportLedgerFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun returnToImportFillWallet() { + navigationBuilder().action(R.id.action_selectAddressImportLedgerFragment_to_fillWalletImportLedgerFragment) + .navigateInFirstAttachedContext() + } + + override fun openSelectImportAddress(payload: SelectLedgerAddressPayload) { + navigationBuilder().action(R.id.action_selectLedgerImportFragment_to_selectAddressImportLedgerFragment) + .setArgs(SelectAddressLedgerFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openCreatePincode() { + accountRouter.openCreatePincode() + } + + override fun openMain() { + accountRouter.openMain() + } + + override fun openFinishImportLedger(payload: FinishImportLedgerPayload) { + navigationBuilder().action(R.id.action_fillWalletImportLedgerFragment_to_finishImportLedgerFragment) + .setArgs(FinishImportLedgerFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun finishSignFlow() { + back() + } + + override fun openAddChainAccountSelectAddress(payload: AddLedgerChainAccountSelectAddressPayload) { + navigationBuilder().action(R.id.action_addChainAccountSelectLedgerFragment_to_addChainAccountSelectAddressLedgerFragment) + .setArgs(AddLedgerChainAccountSelectAddressFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSelectLedgerGeneric(payload: SelectLedgerGenericPayload) { + navigationBuilder().action(R.id.action_startImportGenericLedgerFragment_to_selectLedgerGenericImportFragment) + .setArgs(SelectLedgerGenericImportFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSelectAddressGenericLedger(payload: SelectLedgerAddressPayload) { + navigationBuilder().action(R.id.action_selectLedgerGenericImportFragment_to_selectAddressImportGenericLedgerFragment) + .setArgs(SelectAddressLedgerFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openPreviewLedgerAccountsGeneric(payload: PreviewImportGenericLedgerPayload) { + navigationBuilder().action(R.id.action_selectAddressImportGenericLedgerFragment_to_previewImportGenericLedgerFragment) + .setArgs(PreviewImportGenericLedgerFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openFinishImportLedgerGeneric(payload: FinishImportGenericLedgerPayload) { + navigationBuilder().action(R.id.action_previewImportGenericLedgerFragment_to_finishImportGenericLedgerFragment) + .setArgs(FinishImportGenericLedgerFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openAddGenericEvmAddressSelectAddress(payload: AddEvmGenericLedgerAccountSelectAddressPayload) { + navigationBuilder().action(R.id.action_addEvmAccountSelectGenericLedgerFragment_to_addEvmGenericLedgerAccountSelectAddressFragment) + .setArgs(AddEvmGenericLedgerAccountSelectAddressFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/ledger/LedgerSignCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/ledger/LedgerSignCommunicatorImpl.kt new file mode 100644 index 0000000..7f3c42f --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/ledger/LedgerSignCommunicatorImpl.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.app.root.navigation.navigators.ledger + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.getBackStackEntryBefore +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.presenatation.sign.LedgerSignCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator.Request +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator.Response +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.sign.SignLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.sign.SignLedgerPayload + +class LedgerSignCommunicatorImpl(navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), LedgerSignCommunicator { + + private var usedVariant: LedgerVariant? = null + + override fun respond(response: Response) { + val requester = navController.getBackStackEntryBefore(R.id.signLedgerFragment) + + saveResultTo(requester, response) + } + + override fun setUsedVariant(variant: LedgerVariant) { + usedVariant = variant + } + + override fun openRequest(request: Request) { + super.openRequest(request) + + val payload = SignLedgerPayload(request, requireNotNull(usedVariant), SelectLedgerPayload.ConnectionMode.ALL) + val bundle = SignLedgerFragment.getBundle(payload) + navController.navigate(R.id.action_open_sign_ledger, bundle) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/ledger/SelectLedgerAddressCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/ledger/SelectLedgerAddressCommunicatorImpl.kt new file mode 100644 index 0000000..204d76c --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/ledger/SelectLedgerAddressCommunicatorImpl.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.app.root.navigation.navigators.ledger + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.SelectLedgerLegacyPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.LedgerChainAccount +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.SelectLedgerAddressInterScreenCommunicator +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.SelectLedgerLegacyImportFragment + +class SelectLedgerAddressCommunicatorImpl(navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + SelectLedgerAddressInterScreenCommunicator { + + override fun openRequest(request: SelectLedgerLegacyPayload) { + super.openRequest(request) + + val args = SelectLedgerLegacyImportFragment.getBundle(request) + navController.navigate(R.id.action_fillWalletImportLedgerFragment_to_selectLedgerImportFragment, args) + } + + override fun respond(response: LedgerChainAccount) { + val responseEntry = navController.getBackStackEntry(R.id.fillWalletImportLedgerFragment) + + saveResultTo(responseEntry, response) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/multisig/MultisigOperationsNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/multisig/MultisigOperationsNavigator.kt new file mode 100644 index 0000000..dba908d --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/multisig/MultisigOperationsNavigator.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.app.root.navigation.navigators.multisig + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsFragment +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.details.full.MultisigOperationFullDetailsFragment +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.MultisigOperationEnterCallFragment + +class MultisigOperationsNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val commonDelegate: Navigator, +) : BaseNavigator(navigationHoldersRegistry), MultisigOperationsRouter { + + override fun openPendingOperations() { + navigationBuilder().action(R.id.action_multisigCreatedDialog_to_multisigPendingOperationsFlow) + .navigateInFirstAttachedContext() + } + + override fun openMain() { + commonDelegate.openMain() + } + + override fun openMultisigOperationDetails(payload: MultisigOperationDetailsPayload) { + navigationBuilder().cases() + .addCase(R.id.multisigPendingOperationsFragment, R.id.action_multisigPendingOperationsFragment_to_multisigOperationDetailsFragment) + .setFallbackCase(R.id.action_multisigOperationDetailsFragment) + .setArgs(MultisigOperationDetailsFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openMultisigFullDetails(payload: MultisigOperationPayload) { + navigationBuilder().action(R.id.action_multisigOperationDetailsFragment_to_externalExtrinsicDetailsFragment) + .setArgs(MultisigOperationFullDetailsFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } + + override fun openEnterCallDetails(payload: MultisigOperationPayload) { + navigationBuilder().action(R.id.action_multisigOperationDetailsFragment_to_enterCallDetails) + .setArgs(MultisigOperationEnterCallFragment.createPayload(payload)) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/nft/NftNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/nft/NftNavigator.kt new file mode 100644 index 0000000..4c9ec99 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/nft/NftNavigator.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.app.root.navigation.navigators.nft + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_nft_impl.NftRouter +import io.novafoundation.nova.feature_nft_impl.presentation.nft.details.NftDetailsFragment + +class NftNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry +) : BaseNavigator(navigationHoldersRegistry), NftRouter { + + override fun openNftDetails(nftId: String) { + navigationBuilder().action(R.id.action_nftListFragment_to_nftDetailsFragment) + .setArgs(NftDetailsFragment.getBundle(nftId)) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/pincode/PinCodeTwoFactorVerificationCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/pincode/PinCodeTwoFactorVerificationCommunicatorImpl.kt new file mode 100644 index 0000000..aa38c78 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/pincode/PinCodeTwoFactorVerificationCommunicatorImpl.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.app.root.navigation.navigators.pincode + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.FlowInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.navigationBuilder +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationCommunicator +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationRequester.Request +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationResponder.Response +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PinCodeAction +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PincodeFragment + +class PinCodeTwoFactorVerificationCommunicatorImpl( + private val navigationHoldersRegistry: NavigationHoldersRegistry +) : FlowInterScreenCommunicator(), PinCodeTwoFactorVerificationCommunicator { + + override fun dispatchRequest(request: Request) { + val action = PinCodeAction.TwoFactorVerification(request.useBiometryIfEnabled) + val bundle = PincodeFragment.getPinCodeBundle(action) + + navigationHoldersRegistry.navigationBuilder().action(R.id.action_pin_code_two_factor_verification) + .setArgs(bundle) + .navigateInRoot() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushGovernanceSettingsCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushGovernanceSettingsCommunicatorImpl.kt new file mode 100644 index 0000000..517b949 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushGovernanceSettingsCommunicatorImpl.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.root.navigation.navigators.push + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsFragment +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsRequester +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsResponder + +class PushGovernanceSettingsCommunicatorImpl(private val router: PushNotificationsRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + PushGovernanceSettingsCommunicator { + + override fun openRequest(request: PushGovernanceSettingsRequester.Request) { + super.openRequest(request) + + router.openPushGovernanceSettings(PushGovernanceSettingsFragment.getBundle(request)) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushMultisigSettingsCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushMultisigSettingsCommunicatorImpl.kt new file mode 100644 index 0000000..c512b52 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushMultisigSettingsCommunicatorImpl.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.root.navigation.navigators.push + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsFragment +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsRequester +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsResponder + +class PushMultisigSettingsCommunicatorImpl(private val router: PushNotificationsRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + PushMultisigSettingsCommunicator { + + override fun openRequest(request: PushMultisigSettingsRequester.Request) { + super.openRequest(request) + + router.openPushMultisigsSettings(PushMultisigSettingsFragment.createPayload(request)) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushNotificationsNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushNotificationsNavigator.kt new file mode 100644 index 0000000..30b8925 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushNotificationsNavigator.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.app.root.navigation.navigators.push + +import android.os.Bundle +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.presentation.settings.PushSettingsFragment +import io.novafoundation.nova.feature_push_notifications.presentation.settings.PushSettingsPayload +import io.novafoundation.nova.feature_push_notifications.presentation.settings.withWalletSelection + +class PushNotificationsNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry +) : BaseNavigator(navigationHoldersRegistry), PushNotificationsRouter { + + override fun openPushSettingsWithAccounts() { + navigationBuilder().action(R.id.action_open_pushNotificationsSettings) + .setArgs(PushSettingsFragment.createPayload(PushSettingsPayload.withWalletSelection(enableSwitcherOnStart = true))) + .navigateInFirstAttachedContext() + } + + override fun openPushMultisigsSettings(args: Bundle) { + navigationBuilder().action(R.id.action_pushSettings_to_multisigSettings) + .setArgs(args) + .navigateInFirstAttachedContext() + } + + override fun openPushGovernanceSettings(args: Bundle) { + navigationBuilder().action(R.id.action_pushSettings_to_governanceSettings) + .setArgs(args) + .navigateInFirstAttachedContext() + } + + override fun openPushStakingSettings(args: Bundle) { + navigationBuilder().action(R.id.action_pushSettings_to_stakingSettings) + .setArgs(args) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushStakingSettingsCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushStakingSettingsCommunicatorImpl.kt new file mode 100644 index 0000000..9f642ed --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/push/PushStakingSettingsCommunicatorImpl.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.root.navigation.navigators.push + +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsFragment +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsRequester +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsResponder + +class PushStakingSettingsCommunicatorImpl(private val router: PushNotificationsRouter, navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + PushStakingSettingsCommunicator { + + override fun openRequest(request: PushStakingSettingsRequester.Request) { + super.openRequest(request) + + router.openPushStakingSettings(PushStakingSettingsFragment.getBundle(request)) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/settings/SettingsNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/settings/SettingsNavigator.kt new file mode 100644 index 0000000..0492747 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/settings/SettingsNavigator.kt @@ -0,0 +1,137 @@ +package io.novafoundation.nova.app.root.navigation.navigators.settings + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PinCodeAction +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PincodeFragment +import io.novafoundation.nova.feature_push_notifications.presentation.settings.PushSettingsFragment +import io.novafoundation.nova.feature_push_notifications.presentation.settings.PushSettingsPayload +import io.novafoundation.nova.feature_push_notifications.presentation.settings.default +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkMainFragment +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementFragment +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.NetworkManagementListFragment +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodeFragment +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodePayload +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsPayload + +class SettingsNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val rootRouter: RootRouter, + private val walletConnectDelegate: WalletConnectRouter, + private val delegate: Navigator +) : BaseNavigator(navigationHoldersRegistry), + SettingsRouter { + + override fun returnToWallet() { + rootRouter.returnToWallet() + } + + override fun openWallets() { + delegate.openWallets() + } + + override fun openNetworks() { + navigationBuilder().action(R.id.action_open_networkManagement) + .navigateInFirstAttachedContext() + } + + override fun openNetworkDetails(payload: ChainNetworkManagementPayload) { + navigationBuilder().action(R.id.action_open_networkManagementDetails) + .setArgs(ChainNetworkManagementFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openCustomNode(payload: CustomNodePayload) { + navigationBuilder().action(R.id.action_open_customNode) + .setArgs(CustomNodeFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun addNetwork() { + navigationBuilder().action(R.id.action_open_preConfiguredNetworks) + .navigateInFirstAttachedContext() + } + + override fun openCreateNetworkFlow() { + navigationBuilder().action(R.id.action_open_addNetworkFragment) + .navigateInFirstAttachedContext() + } + + override fun openCreateNetworkFlow(payload: AddNetworkPayload.Mode.Add) { + navigationBuilder().action(R.id.action_open_addNetworkFragment) + .setArgs(AddNetworkMainFragment.getBundle(AddNetworkPayload(payload))) + .navigateInFirstAttachedContext() + } + + override fun finishCreateNetworkFlow() { + navigationBuilder().action(R.id.action_finishCreateNetworkFlow) + .setArgs(NetworkManagementListFragment.getBundle(openAddedTab = true)) + .navigateInFirstAttachedContext() + } + + override fun openEditNetwork(payload: AddNetworkPayload.Mode.Edit) { + navigationBuilder().action(R.id.action_open_editNetwork) + .setArgs(AddNetworkMainFragment.getBundle(AddNetworkPayload(payload))) + .navigateInFirstAttachedContext() + } + + override fun openPushNotificationSettings() { + navigationBuilder().action(R.id.action_open_pushNotificationsSettings) + .setArgs(PushSettingsFragment.createPayload(PushSettingsPayload.default())) + .navigateInFirstAttachedContext() + } + + override fun openCurrencies() { + navigationBuilder().action(R.id.action_mainFragment_to_currenciesFragment) + .navigateInFirstAttachedContext() + } + + override fun openLanguages() { + navigationBuilder().action(R.id.action_mainFragment_to_languagesFragment) + .navigateInFirstAttachedContext() + } + + override fun openAppearance() { + navigationBuilder().action(R.id.action_mainFragment_to_appearanceFragment) + .navigateInFirstAttachedContext() + } + + override fun openChangePinCode() { + navigationBuilder().action(R.id.action_change_pin_code) + .setArgs(PincodeFragment.getPinCodeBundle(PinCodeAction.Change)) + .navigateInFirstAttachedContext() + } + + override fun openWalletDetails(metaId: Long) { + delegate.openWalletDetails(metaId) + } + + override fun openSwitchWallet() { + delegate.openSwitchWallet() + } + + override fun openWalletConnectScan() { + walletConnectDelegate.openScanPairingQrCode() + } + + override fun openWalletConnectSessions() { + walletConnectDelegate.openWalletConnectSessions(WalletConnectSessionsPayload(metaId = null)) + } + + override fun openCloudBackupSettings() { + navigationBuilder().action(R.id.action_open_cloudBackupSettings) + .navigateInFirstAttachedContext() + } + + override fun openManualBackup() { + navigationBuilder().action(R.id.action_open_manualBackupSelectWallet) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/StakingDashboardNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/StakingDashboardNavigator.kt new file mode 100644 index 0000000..dcccd4b --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/StakingDashboardNavigator.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking + +import androidx.lifecycle.MutableLiveData +import androidx.navigation.NavController +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter + +class StakingDashboardNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry +) : BaseNavigator(navigationHoldersRegistry), StakingDashboardRouter { + + private var stakingTabNavController: NavController? = null + private var pendingAction: Int? = null + + override val scrollToDashboardTopEvent = MutableLiveData>() + + fun setStakingTabNavController(navController: NavController) { + stakingTabNavController = navController + + if (pendingAction != null) { + navController.navigate(pendingAction!!) + pendingAction = null + } + } + + fun clearStakingTabNavController() { + stakingTabNavController = null + } + + override fun openMoreStakingOptions() { + stakingTabNavController?.navigate(R.id.action_stakingDashboardFragment_to_moreStakingOptionsFragment) + } + + override fun backInStakingTab() { + stakingTabNavController?.popBackStack() + } + + override fun returnToStakingDashboard() { + navigationBuilder() + .action(R.id.back_to_main) + .navigateInFirstAttachedContext() + + returnToStakingTabRoot() + scrollToDashboardTopEvent.value = Unit.event() + } + override fun openStakingDashboard() { + stakingTabNavController.performNavigationOrDelay(R.id.action_open_staking) + } + + private fun returnToStakingTabRoot() { + stakingTabNavController.performNavigationOrDelay(R.id.return_to_staking_dashboard) + } + + private fun NavController?.performNavigationOrDelay(actionId: Int) { + val controller = this + + if (controller != null) { + controller.navigate(actionId) + } else { + pendingAction = actionId + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/StartMultiStakingNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/StartMultiStakingNavigator.kt new file mode 100644 index 0000000..1fc4714 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/StartMultiStakingNavigator.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.StartParachainStakingMode +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.StartParachainStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.StartParachainStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.ConfirmMultiStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.ConfirmMultiStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.StartStakingLandingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StartStakingLandingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.SetupAmountMultiStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.SetupAmountMultiStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.SetupStakingTypeFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.SetupStakingTypePayload + +class StartMultiStakingNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val stakingDashboardRouter: StakingDashboardRouter, + private val commonNavigationHolder: Navigator, +) : BaseNavigator(navigationHoldersRegistry), StartMultiStakingRouter { + + override fun openStartStakingLanding(payload: StartStakingLandingPayload) { + navigationBuilder().action(R.id.action_mainFragment_to_startStackingLanding) + .setArgs(StartStakingLandingFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openStartParachainStaking() { + navigationBuilder().action(R.id.action_startStakingLandingFragment_to_staking_parachain_start_graph) + .setArgs(StartParachainStakingFragment.getBundle(StartParachainStakingPayload(StartParachainStakingMode.START))) + .navigateInFirstAttachedContext() + } + + override fun openStartMythosStaking() { + navigationBuilder().action(R.id.action_startStakingLandingFragment_to_staking_mythos_start_graph) + .navigateInFirstAttachedContext() + } + + override fun openStartMultiStaking(payload: SetupAmountMultiStakingPayload) { + navigationBuilder().action(R.id.action_startStakingLandingFragment_to_start_multi_staking_nav_graph) + .setArgs(SetupAmountMultiStakingFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSetupStakingType(payload: SetupStakingTypePayload) { + navigationBuilder().action(R.id.action_setupAmountMultiStakingFragment_to_setupStakingType) + .setArgs(SetupStakingTypeFragment.getArguments(payload)) + .navigateInFirstAttachedContext() + } + + override fun openConfirm(payload: ConfirmMultiStakingPayload) { + navigationBuilder().action(R.id.action_setupAmountMultiStakingFragment_to_confirmMultiStakingFragment) + .setArgs(ConfirmMultiStakingFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSelectedValidators() { + navigationBuilder().action(R.id.action_confirmMultiStakingFragment_to_confirmNominationsFragment) + .navigateInFirstAttachedContext() + } + + override fun returnToStakingDashboard() { + stakingDashboardRouter.returnToStakingDashboard() + } + + override fun goToWalletDetails(metaId: Long) { + commonNavigationHolder.openWalletDetails(metaId) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/mythos/MythosStakingNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/mythos/MythosStakingNavigator.kt new file mode 100644 index 0000000..f3b88c4 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/mythos/MythosStakingNavigator.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking.mythos + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemConsequences +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.ConfirmStartMythosStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.ConfirmStartMythosStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.ConfirmUnbondMythosFragment +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.ConfirmUnbondMythosPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.ValidatorDetailsFragment + +class MythosStakingNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val stakingDashboardRouter: StakingDashboardRouter, +) : BaseNavigator(navigationHoldersRegistry), MythosStakingRouter { + + override fun openCollatorDetails(payload: StakeTargetDetailsPayload) { + navigationBuilder() + .action(R.id.open_validator_details) + .setArgs(ValidatorDetailsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openConfirmStartStaking(payload: ConfirmStartMythosStakingPayload) { + navigationBuilder() + .action(R.id.action_startMythosStakingFragment_to_confirmStartMythosStakingFragment) + .setArgs(ConfirmStartMythosStakingFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openClaimRewards() { + navigationBuilder() + .action(R.id.action_open_mythos_claim_rewards) + .navigateInFirstAttachedContext() + } + + override fun openBondMore() { + navigationBuilder() + .action(R.id.action_open_MythosBondMoreGraph) + .navigateInFirstAttachedContext() + } + + override fun openUnbond() { + navigationBuilder() + .action(R.id.action_open_stakingMythosUnbondGraph) + .navigateInFirstAttachedContext() + } + + override fun openUnbondConfirm(payload: ConfirmUnbondMythosPayload) { + navigationBuilder() + .action(R.id.action_setupUnbondMythosFragment_to_confirmUnbondMythosFragment) + .setArgs(ConfirmUnbondMythosFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openRedeem() { + navigationBuilder() + .action(R.id.action_stakingFragment_to_mythosRedeemFragment) + .navigateInFirstAttachedContext() + } + + override fun finishRedeemFlow(redeemConsequences: RedeemConsequences) { + if (redeemConsequences.willKillStash) { + stakingDashboardRouter.returnToStakingDashboard() + } else { + returnToStakingMain() + } + } + + override fun openStakedCollators() { + navigationBuilder() + .action(R.id.action_stakingFragment_to_mythosCurrentCollatorsFragment) + .navigateInFirstAttachedContext() + } + + override fun returnToStartStaking() { + navigationBuilder() + .action(R.id.action_return_to_start_staking) + .navigateInFirstAttachedContext() + } + + override fun returnToStakingMain() { + navigationBuilder() + .action(R.id.back_to_staking_main) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/mythos/SelectMythCollatorSettingsInterScreenCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/mythos/SelectMythCollatorSettingsInterScreenCommunicatorImpl.kt new file mode 100644 index 0000000..380f742 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/mythos/SelectMythCollatorSettingsInterScreenCommunicatorImpl.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking.mythos + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.MythCollatorRecommendationConfigParcel + +class SelectMythCollatorSettingsInterScreenCommunicatorImpl(navigationHoldersRegistry: NavigationHoldersRegistry) : + SelectMythCollatorSettingsInterScreenCommunicator, + NavStackInterScreenCommunicator(navigationHoldersRegistry) { + + override fun openRequest(request: MythCollatorRecommendationConfigParcel) { + super.openRequest(request) + + val bundle = SelectMythCollatorSettingsFragment.getBundle(request) + navController.navigate(R.id.action_selectMythosCollatorFragment_to_selectMythCollatorSettingsFragment, bundle) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/mythos/SelectMythosCollatorInterScreenCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/mythos/SelectMythosCollatorInterScreenCommunicatorImpl.kt new file mode 100644 index 0000000..9564424 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/mythos/SelectMythosCollatorInterScreenCommunicatorImpl.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking.mythos + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenCommunicator.Request +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.MythosCollatorParcel + +class SelectMythosCollatorInterScreenCommunicatorImpl(navigationHoldersRegistry: NavigationHoldersRegistry) : + SelectMythosInterScreenCommunicator, + NavStackInterScreenCommunicator(navigationHoldersRegistry) { + + override fun respond(response: MythosCollatorParcel) { + val responseEntry = navController.getBackStackEntry(R.id.startMythosStakingFragment) + saveResultTo(responseEntry, response) + } + + override fun openRequest(request: Request) { + super.openRequest(request) + + navController.navigate(R.id.action_startMythosStakingFragment_to_selectMythosCollatorFragment) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/nominationPools/NominationPoolsStakingNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/nominationPools/NominationPoolsStakingNavigator.kt new file mode 100644 index 0000000..1865b8e --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/nominationPools/NominationPoolsStakingNavigator.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking.nominationPools + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.NominationPoolsConfirmBondMoreFragment +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.NominationPoolsConfirmBondMorePayload +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.NominationPoolsConfirmUnbondFragment +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.NominationPoolsConfirmUnbondPayload + +class NominationPoolsStakingNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val commonNavigator: Navigator, +) : BaseNavigator(navigationHoldersRegistry), NominationPoolsRouter { + + override fun openSetupBondMore() { + navigationBuilder().action(R.id.action_stakingFragment_to_PoolsBondMoreGraph).navigateInFirstAttachedContext() + } + + override fun openConfirmBondMore(payload: NominationPoolsConfirmBondMorePayload) { + navigationBuilder().action(R.id.action_nominationPoolsSetupBondMoreFragment_to_nominationPoolsConfirmBondMoreFragment) + .setArgs(NominationPoolsConfirmBondMoreFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openConfirmUnbond(payload: NominationPoolsConfirmUnbondPayload) { + navigationBuilder().action(R.id.action_nominationPoolsSetupUnbondFragment_to_nominationPoolsConfirmUnbondFragment) + .setArgs(NominationPoolsConfirmUnbondFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openRedeem() { + navigationBuilder().action(R.id.action_stakingFragment_to_PoolsRedeemFragment).navigateInFirstAttachedContext() + } + + override fun openClaimRewards() { + navigationBuilder().action(R.id.action_stakingFragment_to_PoolsClaimRewardsFragment).navigateInFirstAttachedContext() + } + + override fun openSetupUnbond() { + navigationBuilder().action(R.id.action_stakingFragment_to_PoolsUnbondGraph).navigateInFirstAttachedContext() + } + + override fun returnToStakingMain() { + navigationBuilder().action(R.id.back_to_staking_main).navigateInFirstAttachedContext() + } + + override fun returnToMain() { + commonNavigator.returnToMain() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/parachain/ParachainStakingNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/parachain/ParachainStakingNavigator.kt new file mode 100644 index 0000000..a0ed671 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/parachain/ParachainStakingNavigator.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking.parachain + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.ParachainStakingRebondFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.model.ParachainStakingRebondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.ConfirmStartParachainStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.model.ConfirmStartParachainStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.StartParachainStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.StartParachainStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.ParachainStakingUnbondConfirmFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.model.ParachainStakingUnbondConfirmPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.YieldBoostConfirmFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfirmPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.ValidatorDetailsFragment + +class ParachainStakingNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val commonNavigator: Navigator, +) : BaseNavigator(navigationHoldersRegistry), ParachainStakingRouter { + + override fun openStartStaking(payload: StartParachainStakingPayload) { + navigationBuilder().action(R.id.action_open_startParachainStakingGraph) + .setArgs(StartParachainStakingFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openConfirmStartStaking(payload: ConfirmStartParachainStakingPayload) { + navigationBuilder().action(R.id.action_startParachainStakingFragment_to_confirmStartParachainStakingFragment) + .setArgs(ConfirmStartParachainStakingFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSearchCollator() { + navigationBuilder().action(R.id.action_selectCollatorFragment_to_searchCollatorFragment) + .navigateInFirstAttachedContext() + } + + override fun openCollatorDetails(payload: StakeTargetDetailsPayload) { + navigationBuilder().action(R.id.open_validator_details) + .setArgs(ValidatorDetailsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openWalletDetails(metaId: Long) { + commonNavigator.openWalletDetails(metaId) + } + + override fun returnToStakingMain() { + navigationBuilder().action(R.id.back_to_staking_main).navigateInFirstAttachedContext() + } + + override fun returnToStartStaking() { + navigationBuilder().action(R.id.action_return_to_start_staking).navigateInFirstAttachedContext() + } + + override fun openCurrentCollators() { + navigationBuilder().action(R.id.action_stakingFragment_to_currentCollatorsFragment).navigateInFirstAttachedContext() + } + + override fun openUnbond() { + navigationBuilder().action(R.id.action_open_parachainUnbondGraph).navigateInFirstAttachedContext() + } + + override fun openConfirmUnbond(payload: ParachainStakingUnbondConfirmPayload) { + navigationBuilder().action(R.id.action_parachainStakingUnbondFragment_to_parachainStakingUnbondConfirmFragment) + .setArgs(ParachainStakingUnbondConfirmFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openRedeem() { + navigationBuilder().action(R.id.action_stakingFragment_to_parachainStakingRedeemFragment).navigateInFirstAttachedContext() + } + + override fun openRebond(payload: ParachainStakingRebondPayload) { + navigationBuilder().action(R.id.action_stakingFragment_to_parachainStakingRebondFragment) + .setArgs(ParachainStakingRebondFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSetupYieldBoost() { + navigationBuilder().action(R.id.action_stakingFragment_to_yieldBoostGraph).navigateInFirstAttachedContext() + } + + override fun openConfirmYieldBoost(payload: YieldBoostConfirmPayload) { + navigationBuilder().action(R.id.action_setupYieldBoostFragment_to_yieldBoostConfirmFragment) + .setArgs(YieldBoostConfirmFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openAddStakingProxy() { + navigationBuilder().action(R.id.action_open_addStakingProxyFragment).navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/parachain/SelectCollatorInterScreenCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/parachain/SelectCollatorInterScreenCommunicatorImpl.kt new file mode 100644 index 0000000..6554ac7 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/parachain/SelectCollatorInterScreenCommunicatorImpl.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking.parachain + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator.Request +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator.Response + +class SelectCollatorInterScreenCommunicatorImpl(navigationHoldersRegistry: NavigationHoldersRegistry) : + SelectCollatorInterScreenCommunicator, + NavStackInterScreenCommunicator(navigationHoldersRegistry) { + + override fun respond(response: Response) { + val responseEntry = navController.getBackStackEntry(R.id.startParachainStakingFragment) + + saveResultTo(responseEntry, response) + } + + override fun openRequest(request: Request) { + super.openRequest(request) + + navController.navigate(R.id.action_startParachainStakingFragment_to_selectCollatorFragment) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/parachain/SelectCollatorSettingsInterScreenCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/parachain/SelectCollatorSettingsInterScreenCommunicatorImpl.kt new file mode 100644 index 0000000..73cf8c7 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/parachain/SelectCollatorSettingsInterScreenCommunicatorImpl.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking.parachain + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator.Request +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator.Response + +class SelectCollatorSettingsInterScreenCommunicatorImpl(navigationHoldersRegistry: NavigationHoldersRegistry) : + SelectCollatorSettingsInterScreenCommunicator, + NavStackInterScreenCommunicator(navigationHoldersRegistry) { + + override fun openRequest(request: Request) { + super.openRequest(request) + + val bundle = SelectCollatorSettingsFragment.getBundle(request.currentConfig) + navController.navigate(R.id.action_selectCollatorFragment_to_selectCollatorSettingsFragment, bundle) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/relaychain/RelayStakingNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/relaychain/RelayStakingNavigator.kt new file mode 100644 index 0000000..1165780 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/staking/relaychain/RelayStakingNavigator.kt @@ -0,0 +1,293 @@ +package io.novafoundation.nova.app.root.navigation.navigators.staking.relaychain + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemConsequences +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.ConfirmPayoutFragment +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.model.ConfirmPayoutPayload +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.detail.PayoutDetailsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.model.PendingPayoutParcelable +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import io.novafoundation.nova.feature_staking_impl.presentation.pools.searchPool.SearchPoolFragment +import io.novafoundation.nova.feature_staking_impl.presentation.pools.selectPool.SelectPoolFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.ConfirmBondMoreFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.ConfirmBondMorePayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select.SelectBondMoreFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select.SelectBondMorePayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.ConfirmSetControllerFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.ConfirmSetControllerPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.ConfirmAddStakingProxyFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.ConfirmAddStakingProxyPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.ConfirmRemoveStakingProxyFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.ConfirmRemoveStakingProxyPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.ConfirmRebondFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.ConfirmRebondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem.RedeemFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem.RedeemPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.ConfirmRewardDestinationFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel.ConfirmRewardDestinationPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.ConfirmUnbondFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.ConfirmUnbondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload.FlowType +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.ReviewCustomValidatorsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select.SelectCustomValidatorsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.ValidatorDetailsFragment + +class RelayStakingNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val commonNavigator: Navigator, + private val stakingDashboardRouter: StakingDashboardRouter, + private val dAppRouter: DAppRouter +) : BaseNavigator(navigationHoldersRegistry), StakingRouter { + + override fun returnToStakingMain() { + navigationBuilder().action(R.id.back_to_staking_main) + .navigateInFirstAttachedContext() + } + + override fun openSwitchWallet() = commonNavigator.openSwitchWallet() + + override fun openWalletDetails(metaAccountId: Long) = commonNavigator.openWalletDetails(metaAccountId) + + override fun openCustomRebond() { + navigationBuilder().action(R.id.action_stakingFragment_to_customRebondFragment) + .navigateInFirstAttachedContext() + } + + override fun openCurrentValidators() { + navigationBuilder().action(R.id.action_stakingFragment_to_currentValidatorsFragment) + .navigateInFirstAttachedContext() + } + + override fun returnToCurrentValidators() { + navigationBuilder().action(R.id.action_confirmStakingFragment_back_to_currentValidatorsFragment) + .navigateInFirstAttachedContext() + } + + override fun openChangeRewardDestination() { + navigationBuilder().action(R.id.action_stakingFragment_to_selectRewardDestinationFragment) + .navigateInFirstAttachedContext() + } + + override fun openConfirmRewardDestination(payload: ConfirmRewardDestinationPayload) { + navigationBuilder().action(R.id.action_selectRewardDestinationFragment_to_confirmRewardDestinationFragment) + .setArgs(ConfirmRewardDestinationFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openControllerAccount() { + navigationBuilder().action(R.id.action_stakingBalanceFragment_to_setControllerAccountFragment) + .navigateInFirstAttachedContext() + } + + override fun openConfirmSetController(payload: ConfirmSetControllerPayload) { + navigationBuilder().action(R.id.action_stakingSetControllerAccountFragment_to_confirmSetControllerAccountFragment) + .setArgs(ConfirmSetControllerFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openRecommendedValidators() { + navigationBuilder().action(R.id.action_startChangeValidatorsFragment_to_recommendedValidatorsFragment) + .navigateInFirstAttachedContext() + } + + override fun openSelectCustomValidators() { + val flowType = when (currentDestination?.id) { + R.id.setupStakingType -> FlowType.SETUP_STAKING_VALIDATORS + else -> FlowType.CHANGE_STAKING_VALIDATORS + } + val payload = CustomValidatorsPayload(flowType) + + navigationBuilder().cases() + .addCase(R.id.setupStakingType, R.id.action_setupStakingType_to_selectCustomValidatorsFragment) + .addCase(R.id.startChangeValidatorsFragment, R.id.action_startChangeValidatorsFragment_to_selectCustomValidatorsFragment) + .setArgs(SelectCustomValidatorsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openCustomValidatorsSettings() { + navigationBuilder().action(R.id.action_selectCustomValidatorsFragment_to_settingsCustomValidatorsFragment) + .navigateInFirstAttachedContext() + } + + override fun openSearchCustomValidators() { + navigationBuilder().action(R.id.action_selectCustomValidatorsFragment_to_searchCustomValidatorsFragment) + .navigateInFirstAttachedContext() + } + + override fun openReviewCustomValidators(payload: CustomValidatorsPayload) { + navigationBuilder().action(R.id.action_selectCustomValidatorsFragment_to_reviewCustomValidatorsFragment) + .setArgs(ReviewCustomValidatorsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openConfirmStaking() { + navigationBuilder().action(R.id.openConfirmStakingFragment) + .navigateInFirstAttachedContext() + } + + override fun openConfirmNominations() { + navigationBuilder().action(R.id.action_confirmStakingFragment_to_confirmNominationsFragment) + .navigateInFirstAttachedContext() + } + + override fun openChainStakingMain() { + navigationBuilder().action(R.id.action_mainFragment_to_stakingGraph) + .navigateInFirstAttachedContext() + } + + override fun openStartChangeValidators() { + navigationBuilder().action(R.id.openStartChangeValidatorsFragment) + .navigateInFirstAttachedContext() + } + + override fun openPayouts() { + navigationBuilder().action(R.id.action_stakingFragment_to_payoutsListFragment) + .navigateInFirstAttachedContext() + } + + override fun openPayoutDetails(payout: PendingPayoutParcelable) { + navigationBuilder().action(R.id.action_payoutsListFragment_to_payoutDetailsFragment) + .setArgs(PayoutDetailsFragment.getBundle(payout)) + .navigateInFirstAttachedContext() + } + + override fun openConfirmPayout(payload: ConfirmPayoutPayload) { + navigationBuilder().action(R.id.action_open_confirm_payout) + .setArgs(ConfirmPayoutFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openBondMore() { + navigationBuilder().action(R.id.action_open_selectBondMoreFragment) + .setArgs(SelectBondMoreFragment.getBundle(SelectBondMorePayload())) + .navigateInFirstAttachedContext() + } + + override fun openConfirmBondMore(payload: ConfirmBondMorePayload) { + navigationBuilder().action(R.id.action_selectBondMoreFragment_to_confirmBondMoreFragment) + .setArgs(ConfirmBondMoreFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openSelectUnbond() { + navigationBuilder().action(R.id.action_stakingFragment_to_selectUnbondFragment) + .navigateInFirstAttachedContext() + } + + override fun openConfirmUnbond(payload: ConfirmUnbondPayload) { + navigationBuilder().action(R.id.action_selectUnbondFragment_to_confirmUnbondFragment) + .setArgs(ConfirmUnbondFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openRedeem() { + navigationBuilder().action(R.id.action_open_redeemFragment) + .setArgs(RedeemFragment.getBundle(RedeemPayload())) + .navigateInFirstAttachedContext() + } + + override fun openConfirmRebond(payload: ConfirmRebondPayload) { + navigationBuilder().action(R.id.action_open_confirm_rebond) + .setArgs(ConfirmRebondFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openValidatorDetails(payload: StakeTargetDetailsPayload) { + navigationBuilder().action(R.id.open_validator_details) + .setArgs(ValidatorDetailsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openRebag() { + navigationBuilder().action(R.id.action_stakingFragment_to_rebag) + .navigateInFirstAttachedContext() + } + + override fun openStakingPeriods() { + navigationBuilder().action(R.id.action_stakingFragment_to_staking_periods) + .navigateInFirstAttachedContext() + } + + override fun openSetupStakingType() { + navigationBuilder().action(R.id.action_setupAmountMultiStakingFragment_to_setupStakingType) + .navigateInFirstAttachedContext() + } + + override fun openSelectPool(payload: SelectingPoolPayload) { + val arguments = SelectPoolFragment.getBundle(payload) + navigationBuilder().action(R.id.action_setupStakingType_to_selectCustomPoolFragment) + .setArgs(arguments) + .navigateInFirstAttachedContext() + } + + override fun openSearchPool(payload: SelectingPoolPayload) { + val arguments = SearchPoolFragment.getBundle(payload) + navigationBuilder().action(R.id.action_selectPool_to_searchPoolFragment) + .setArgs(arguments) + .navigateInFirstAttachedContext() + } + + override fun finishSetupValidatorsFlow() { + navigationBuilder().action(R.id.action_back_to_setupAmountMultiStakingFragment) + .navigateInFirstAttachedContext() + } + + override fun finishSetupPoolFlow() { + navigationBuilder().cases() + .addCase(R.id.searchPoolFragment, R.id.action_searchPool_to_setupAmountMultiStakingFragment) + .addCase(R.id.selectPoolFragment, R.id.action_selectPool_to_setupAmountMultiStakingFragment) + .navigateInFirstAttachedContext() + } + + override fun finishRedeemFlow(redeemConsequences: RedeemConsequences) { + if (redeemConsequences.willKillStash) { + stakingDashboardRouter.returnToStakingDashboard() + } else { + returnToStakingMain() + } + } + + override fun openAddStakingProxy() { + navigationBuilder().action(R.id.action_open_addStakingProxyFragment) + .navigateInFirstAttachedContext() + } + + override fun openConfirmAddStakingProxy(payload: ConfirmAddStakingProxyPayload) { + navigationBuilder().action(R.id.action_addStakingProxyFragment_to_confirmAddStakingProxyFragment) + .setArgs(ConfirmAddStakingProxyFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openStakingProxyList() { + navigationBuilder().action(R.id.action_open_stakingProxyList) + .navigateInFirstAttachedContext() + } + + override fun openConfirmRemoveStakingProxy(payload: ConfirmRemoveStakingProxyPayload) { + navigationBuilder().action(R.id.action_open_confirmRemoveStakingProxyFragment) + .setArgs(ConfirmRemoveStakingProxyFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openDAppBrowser(url: String) { + dAppRouter.openDAppBrowser(DAppBrowserPayload.Address(url)) + } + + override fun openStakingDashboard() { + if (currentDestination?.id != R.id.mainFragment) { + navigationBuilder().action(R.id.action_open_split_screen) + .navigateInFirstAttachedContext() + } + + stakingDashboardRouter.openStakingDashboard() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/swap/SwapNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/swap/SwapNavigator.kt new file mode 100644 index 0000000..79208c4 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/swap/SwapNavigator.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.app.root.navigation.navigators.swap + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.feature_assets.presentation.balance.detail.BalanceDetailFragment +import io.novafoundation.nova.feature_assets.presentation.swap.asset.AssetSwapFlowFragment +import io.novafoundation.nova.feature_assets.presentation.swap.asset.SwapFlowPayload +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.main.SwapMainSettingsFragment +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload + +class SwapNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val commonDelegate: Navigator +) : BaseNavigator(navigationHoldersRegistry), SwapRouter { + + override fun openSwapRoute() { + navigationBuilder().action(R.id.action_open_swapRouteFragment) + .navigateInFirstAttachedContext() + } + + override fun openSwapFee() { + navigationBuilder().action(R.id.action_open_swapFeeFragment) + .navigateInFirstAttachedContext() + } + + override fun openSwapExecution() { + navigationBuilder().action(R.id.action_swapConfirmationFragment_to_swapExecutionFragment) + .navigateInFirstAttachedContext() + } + + override fun openSwapConfirmation() { + navigationBuilder().action(R.id.action_swapMainSettingsFragment_to_swapConfirmationFragment) + .navigateInFirstAttachedContext() + } + + override fun openSwapOptions() { + navigationBuilder().action(R.id.action_swapMainSettingsFragment_to_swapOptionsFragment) + .navigateInFirstAttachedContext() + } + + override fun openRetrySwap(payload: SwapSettingsPayload) { + navigationBuilder().action(R.id.action_swapExecutionFragment_to_swapSettingsFragment) + .setArgs(SwapMainSettingsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openBalanceDetails(assetPayload: AssetPayload) { + val bundle = BalanceDetailFragment.getBundle(assetPayload) + + navigationBuilder().action(R.id.action_swapExecutionFragment_to_assetDetails) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun openMain() { + commonDelegate.openMain() + } + + override fun selectAssetIn(selectedAsset: AssetPayload?) { + val payload = SwapFlowPayload.ReselectAssetIn(selectedAsset) + val bundle = AssetSwapFlowFragment.getBundle(payload) + + navigationBuilder().action(R.id.action_swapSettingsFragment_to_select_swap_token_graph) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } + + override fun selectAssetOut(selectedAsset: AssetPayload?) { + val payload = SwapFlowPayload.ReselectAssetOut(selectedAsset) + val bundle = AssetSwapFlowFragment.getBundle(payload) + + navigationBuilder().action(R.id.action_swapSettingsFragment_to_select_swap_token_graph) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/topup/TopUpAddressCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/topup/TopUpAddressCommunicatorImpl.kt new file mode 100644 index 0000000..0e102f4 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/topup/TopUpAddressCommunicatorImpl.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.app.root.navigation.navigators.topup + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.NavStackInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressFragment +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressPayload +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressResponder + +class TopUpAddressCommunicatorImpl(navigationHoldersRegistry: NavigationHoldersRegistry) : + NavStackInterScreenCommunicator(navigationHoldersRegistry), + TopUpAddressCommunicator { + + override fun openRequest(request: TopUpAddressPayload) { + super.openRequest(request) + + navigationBuilder().action(R.id.action_open_topUpAddress) + .setArgs(TopUpAddressFragment.createPayload(request)) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/versions/VersionsNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/versions/VersionsNavigator.kt new file mode 100644 index 0000000..9fe787b --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/versions/VersionsNavigator.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.app.root.navigation.navigators.versions + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.utils.showBrowser +import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter + +class VersionsNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry, + private val contextManager: ContextManager, + private val updateSourceLink: String +) : BaseNavigator(navigationHoldersRegistry), VersionsRouter { + + override fun openAppUpdater() { + contextManager.getActivity()?.showBrowser(updateSourceLink, R.string.common_cannot_find_app) + } + + override fun closeUpdateNotifications() { + navigationBuilder().action(R.id.action_close_update_notifications) + .navigateInRoot() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/vote/VoteNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/vote/VoteNavigator.kt new file mode 100644 index 0000000..8f6f157 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/vote/VoteNavigator.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.app.root.navigation.navigators.vote + +import androidx.fragment.app.Fragment +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.feature_crowdloan_impl.presentation.main.CrowdloanFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.ReferendaListFragment +import io.novafoundation.nova.feature_vote.presentation.VoteRouter + +class VoteNavigator( + private val commonNavigator: Navigator, +) : VoteRouter { + override fun getDemocracyFragment(): Fragment { + return ReferendaListFragment() + } + + override fun getCrowdloansFragment(): Fragment { + return CrowdloanFragment() + } + + override fun openSwitchWallet() { + commonNavigator.openSwitchWallet() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/wallet/CurrencyNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/wallet/CurrencyNavigator.kt new file mode 100644 index 0000000..107cd8e --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/wallet/CurrencyNavigator.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.app.root.navigation.navigators.wallet + +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.feature_currency_api.presentation.CurrencyRouter + +class CurrencyNavigator( + val rootRouter: RootRouter, + navigationHoldersRegistry: NavigationHoldersRegistry +) : BaseNavigator(navigationHoldersRegistry), CurrencyRouter { + + override fun returnToWallet() { + rootRouter.returnToWallet() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/wallet/WalletNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/wallet/WalletNavigator.kt new file mode 100644 index 0000000..64c6cf4 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/wallet/WalletNavigator.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.app.root.navigation.navigators.wallet + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.Navigator +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload +import io.novafoundation.nova.feature_assets.presentation.trade.common.TradeProviderFlowType +import io.novafoundation.nova.feature_assets.presentation.trade.provider.TradeProviderListFragment +import io.novafoundation.nova.feature_assets.presentation.trade.provider.TradeProviderListPayload +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.OnSuccessfulTradeStrategyType +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_impl.presentation.WalletRouter + +class WalletNavigator( + private val commonDelegate: Navigator, + navigationHoldersRegistry: NavigationHoldersRegistry +) : BaseNavigator(navigationHoldersRegistry), WalletRouter { + + override fun openSendCrossChain(destination: AssetPayload, recipientAddress: String?) { + val payload = SendPayload.SpecifiedDestination(destination) + + commonDelegate.openSend(payload, recipientAddress) + } + + override fun openReceive(assetPayload: AssetPayload) { + commonDelegate.openReceive(assetPayload) + } + + override fun openBuyToken(chainId: String, assetId: Int) { + val bundle = TradeProviderListFragment.createPayload( + TradeProviderListPayload( + chainId, + assetId, + TradeProviderFlowType.BUY, + OnSuccessfulTradeStrategyType.RETURN_BACK + ) + ) + + navigationBuilder().action(R.id.action_tradeProvidersFragment) + .setArgs(bundle) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/walletConnect/ApproveSessionCommunicatorImpl.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/walletConnect/ApproveSessionCommunicatorImpl.kt new file mode 100644 index 0000000..1c35992 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/walletConnect/ApproveSessionCommunicatorImpl.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.app.root.navigation.navigators.walletConnect + +import com.walletconnect.web3.wallet.client.Wallet +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.FlowInterScreenCommunicator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.app.root.navigation.navigators.navigationBuilder +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator +import kotlinx.coroutines.launch + +class ApproveSessionCommunicatorImpl( + private val navigationHoldersRegistry: NavigationHoldersRegistry, + private val automaticInteractionGate: AutomaticInteractionGate, +) : FlowInterScreenCommunicator(), + ApproveSessionCommunicator { + + override fun dispatchRequest(request: Wallet.Model.SessionProposal) { + launch { + automaticInteractionGate.awaitInteractionAllowed() + + navigationHoldersRegistry.navigationBuilder().action(R.id.action_open_approve_wallet_connect_session) + .navigateInFirstAttachedContext() + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/walletConnect/WalletConnectNavigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/walletConnect/WalletConnectNavigator.kt new file mode 100644 index 0000000..a797ef1 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/walletConnect/WalletConnectNavigator.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.app.root.navigation.navigators.walletConnect + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.navigation.navigators.BaseNavigator +import io.novafoundation.nova.app.root.navigation.navigators.NavigationHoldersRegistry +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsFragment +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsPayload +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsFragment +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsPayload + +class WalletConnectNavigator( + navigationHoldersRegistry: NavigationHoldersRegistry +) : BaseNavigator(navigationHoldersRegistry), WalletConnectRouter { + + override fun openSessionDetails(payload: WalletConnectSessionDetailsPayload) { + navigationBuilder().action(R.id.action_walletConnectSessionsFragment_to_walletConnectSessionDetailsFragment) + .setArgs(WalletConnectSessionDetailsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } + + override fun openScanPairingQrCode() { + navigationBuilder().action(R.id.action_open_scanWalletConnect) + .navigateInFirstAttachedContext() + } + + override fun backToSettings() { + navigationBuilder().action(R.id.walletConnectSessionDetailsFragment_to_settings) + .navigateInFirstAttachedContext() + } + + override fun openWalletConnectSessions(payload: WalletConnectSessionsPayload) { + navigationBuilder().action(R.id.action_mainFragment_to_walletConnectGraph) + .setArgs(WalletConnectSessionsFragment.getBundle(payload)) + .navigateInFirstAttachedContext() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootActivity.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootActivity.kt new file mode 100644 index 0000000..252ac95 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootActivity.kt @@ -0,0 +1,150 @@ +package io.novafoundation.nova.app.root.presentation + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.databinding.ActivityRootBinding +import io.novafoundation.nova.app.root.di.RootApi +import io.novafoundation.nova.app.root.di.RootComponent +import io.novafoundation.nova.app.root.navigation.holders.RootNavigationHolder +import io.novafoundation.nova.common.base.BaseActivity +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.showToast +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.view.bottomSheet.action.observeActionBottomSheet +import io.novafoundation.nova.common.view.dialog.dialog +import io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo.BranchIOLinkHandler +import io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning.observeEnableMultisigPushesAlert +import io.novafoundation.nova.splash.presentation.SplashBackgroundHolder + +import javax.inject.Inject + +class RootActivity : BaseActivity(), SplashBackgroundHolder { + + @Inject + lateinit var rootNavigationHolder: RootNavigationHolder + + @Inject + lateinit var systemCallExecutor: SystemCallExecutor + + @Inject + lateinit var contextManager: ContextManager + + @Inject + lateinit var branchIOLinkHandler: BranchIOLinkHandler + + override fun createBinding(): ActivityRootBinding { + return ActivityRootBinding.inflate(LayoutInflater.from(this)) + } + + override fun inject() { + FeatureUtils.getFeature(this, RootApi::class.java) + .mainActivityComponentFactory() + .create(this) + .inject(this) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + removeSplashBackground() + + viewModel.restoredAfterConfigChange() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (!systemCallExecutor.onActivityResult(requestCode, resultCode, data)) { + super.onActivityResult(requestCode, resultCode, data) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + rootNavigationHolder.attach(rootNavController) + + contextManager.attachActivity(this) + + binder.rootNetworkBar.setOnApplyWindowInsetsListener { view, insets -> + view.updatePadding(top = insets.systemWindowInsetTop) + + insets + } + + intent?.let(::processIntent) + + viewModel.applySafeModeIfEnabled() + } + + override fun onDestroy() { + super.onDestroy() + + contextManager.detachActivity() + rootNavigationHolder.detach() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + + branchIOLinkHandler.onActivityNewIntent(this, intent) + processIntent(intent) + } + + override fun initViews() { + } + + override fun onStop() { + super.onStop() + + viewModel.noticeInBackground() + } + + override fun onStart() { + super.onStart() + + branchIOLinkHandler.onActivityStart(this, viewModel::handleDeepLink) + + viewModel.noticeInForeground() + } + + override fun subscribe(viewModel: RootViewModel) { + observeActionBottomSheet(viewModel) + observeEnableMultisigPushesAlert(viewModel.multisigPushNotificationsAlertMixin) + + viewModel.showConnectingBarLiveData.observe(this) { show -> + binder.rootNetworkBar.setVisible(show) + } + + viewModel.toastMessagesEvents.observeEvent { showToast(it) } + + viewModel.dialogMessageEvents.observeEvent { dialog(this, decorator = it) } + + viewModel.walletConnectErrorsLiveData.observeEvent { it?.let { showError(it) } } + } + + override fun removeSplashBackground() { + window.setBackgroundDrawableResource(R.color.secondary_screen_background) + } + + override fun changeLanguage() { + viewModel.noticeLanguageLanguage() + + recreate() + } + + private fun processIntent(intent: Intent) { + intent.data?.let { viewModel.handleDeepLink(it) } + } + + private val rootNavController: NavController by lazy { + val navHostFragment = supportFragmentManager.findFragmentById(R.id.rootNavHost) as NavHostFragment + + navHostFragment.navController + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootRouter.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootRouter.kt new file mode 100644 index 0000000..3b44f71 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootRouter.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.app.root.presentation + +interface RootRouter { + + fun returnToWallet() + + fun nonCancellableVerify() + + fun openUpdateNotifications() + + fun openPushWelcome() + + fun openCloudBackupSettings() + + fun openChainMigrationDetails(chainId: String) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootViewModel.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootViewModel.kt new file mode 100644 index 0000000..6a6d3c9 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/RootViewModel.kt @@ -0,0 +1,246 @@ +package io.novafoundation.nova.app.root.presentation + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.app.root.domain.RootInteractor +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.formatDeepLinkHandlingException +import io.novafoundation.nova.app.root.presentation.requestBusHandler.CompoundRequestBusHandler +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.interfaces.ExternalServiceInitializer +import io.novafoundation.nova.common.mixin.api.NetworkStateMixin +import io.novafoundation.nova.common.mixin.api.NetworkStateUi +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.SafeModeService +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.ToastMessageManager +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.mapEvent +import io.novafoundation.nova.common.utils.network.DeviceNetworkStateObserver +import io.novafoundation.nova.common.utils.onFailureInstance +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.RootDeepLinkHandler +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning.MultisigPushNotificationsAlertMixinFactory +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection.ExternalRequirement +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class RootViewModel( + private val interactor: RootInteractor, + private val currencyInteractor: CurrencyInteractor, + private val rootRouter: RootRouter, + private val externalConnectionRequirementFlow: MutableStateFlow, + private val resourceManager: ResourceManager, + private val networkStateMixin: NetworkStateMixin, + private val contributionsInteractor: ContributionsInteractor, + private val backgroundAccessObserver: BackgroundAccessObserver, + private val safeModeService: SafeModeService, + private val updateNotificationsInteractor: UpdateNotificationsInteractor, + private val walletConnectService: WalletConnectService, + private val walletConnectSessionsUseCase: WalletConnectSessionsUseCase, + private val deepLinkHandler: RootDeepLinkHandler, + private val rootScope: RootScope, + private val compoundRequestBusHandler: CompoundRequestBusHandler, + private val pushNotificationsInteractor: PushNotificationsInteractor, + private val externalServiceInitializer: ExternalServiceInitializer, + private val actionBottomSheetLauncher: ActionBottomSheetLauncher, + private val toastMessageManager: ToastMessageManager, + private val dialogMessageManager: DialogMessageManager, + private val multisigPushNotificationsAlertMixinFactory: MultisigPushNotificationsAlertMixinFactory, + private val deviceNetworkStateObserver: DeviceNetworkStateObserver +) : BaseViewModel(), + NetworkStateUi by networkStateMixin, + ActionBottomSheetLauncher by actionBottomSheetLauncher { + + val toastMessagesEvents = toastMessageManager.toastMessagesEvents + + val dialogMessageEvents = dialogMessageManager.dialogMessagesEvents + + val walletConnectErrorsLiveData = walletConnectService.onPairErrorLiveData + .mapEvent { it.message } + + val multisigPushNotificationsAlertMixin = multisigPushNotificationsAlertMixinFactory.create(this) + + private var willBeClearedForLanguageChange = false + + init { + contributionsInteractor.runUpdate() + .launchIn(this) + + launch { + // Cache balances before balances sync to detect migration + interactor.cacheBalancesForChainMigrationDetection() + + interactor.runBalancesUpdate() + .onEach { handleUpdatesSideEffect(it) } + .launchIn(viewModelScope) + } + + backgroundAccessObserver.requestAccessFlow + .onEach { verifyUserIfNeed() } + .launchIn(this) + + checkForUpdates() + + syncDelegatedAccounts() + + syncCurrencies() + + syncWalletConnectSessions() + + updatePhishingAddresses() + + observeBusEvents() + + walletConnectService.onPairErrorLiveData.observeForever { + showError(it.peekContent()) + } + + subscribeDeepLinkCallback() + + syncPushSettingsIfNeeded() + + handlePendingDeepLink() + + externalServiceInitializer.initialize() + + multisigPushNotificationsAlertMixin.subscribeToShowAlert() + + deviceNetworkStateObserver.observeIsNetworkAvailable() + .onEach { interactor.loadMigrationDetailsConfigs() } + .launchIn(this) + } + + private fun observeBusEvents() { + compoundRequestBusHandler.observe() + .launchIn(this) + } + + private fun subscribeDeepLinkCallback() { + deepLinkHandler.callbackFlow + .onEach { handleDeepLinkCallbackEvent(it) } + .launchIn(this) + } + + private fun handleDeepLinkCallbackEvent(event: CallbackEvent) { + when (event) { + is CallbackEvent.Message -> { + showToast(event.message) + } + } + } + + private fun syncWalletConnectSessions() = launch { + walletConnectSessionsUseCase.syncActiveSessions() + } + + private fun checkForUpdates() { + launch { + updateNotificationsInteractor.loadVersions() + updateNotificationsInteractor.waitPermissionToUpdate() + if (updateNotificationsInteractor.hasImportantUpdates()) { + rootRouter.openUpdateNotifications() + } + } + } + + private fun syncCurrencies() { + launch { currencyInteractor.syncCurrencies() } + } + + private fun syncDelegatedAccounts() { + interactor.syncExternalAccounts() + + interactor.syncPendingMultisigOperations() + .inBackground() + .launchIn(rootScope) + } + + private fun handleUpdatesSideEffect(sideEffect: Updater.SideEffect) { + // pass + } + + private fun updatePhishingAddresses() { + viewModelScope.launch { + interactor.updatePhishingAddresses() + } + } + + private fun syncPushSettingsIfNeeded() { + launch { + pushNotificationsInteractor.initialSyncSettings() + } + } + + fun noticeInBackground() { + if (!willBeClearedForLanguageChange) { + externalConnectionRequirementFlow.value = ExternalRequirement.STOPPED + + walletConnectService.disconnect() + } + } + + fun noticeInForeground() { + walletConnectService.connect() + + externalConnectionRequirementFlow.value = ExternalRequirement.ALLOWED + } + + fun noticeLanguageLanguage() { + willBeClearedForLanguageChange = true + } + + fun restoredAfterConfigChange() { + if (willBeClearedForLanguageChange) { + rootRouter.returnToWallet() + + willBeClearedForLanguageChange = false + } + } + + private fun verifyUserIfNeed() { + launch { + if (interactor.isAccountSelected() && interactor.isPinCodeSet()) { + rootRouter.nonCancellableVerify() + } else { + backgroundAccessObserver.checkPassed() + } + } + } + + fun applySafeModeIfEnabled() { + safeModeService.applySafeModeIfEnabled() + } + + fun handleDeepLink(data: Uri) { + launch { + deepLinkHandler.handleDeepLink(data) + .onFailureInstance { + val errorMessage = formatDeepLinkHandlingException(resourceManager, it) + showError(errorMessage) + } + } + } + + private fun handlePendingDeepLink() { + launch { + deepLinkHandler.checkAndHandlePendingDeepLink() + .onFailureInstance { + val errorMessage = formatDeepLinkHandlingException(resourceManager, it) + showError(errorMessage) + } + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/common/FirebaseServiceInitializer.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/common/FirebaseServiceInitializer.kt new file mode 100644 index 0000000..b1bcf5a --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/common/FirebaseServiceInitializer.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.app.root.presentation.common + +import android.content.Context +import com.google.firebase.Firebase +import com.google.firebase.appcheck.appCheck +import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory +import com.google.firebase.initialize +import io.novafoundation.nova.common.interfaces.ExternalServiceInitializer + +class FirebaseServiceInitializer(private val context: Context) : ExternalServiceInitializer { + + override fun initialize() { + Firebase.initialize(context = context) + Firebase.appCheck.installAppCheckProviderFactory( + PlayIntegrityAppCheckProviderFactory.getInstance(), + ) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/common/RealBuildTypeProvider.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/common/RealBuildTypeProvider.kt new file mode 100644 index 0000000..40955f8 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/common/RealBuildTypeProvider.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.app.root.presentation.common + +import io.novafoundation.nova.app.BuildConfig +import io.novafoundation.nova.common.interfaces.BuildType +import io.novafoundation.nova.common.interfaces.BuildTypeProvider + +class RealBuildTypeProvider : BuildTypeProvider { + + override fun isDebug(): Boolean { + return BuildConfig.DEBUG + } + + override fun getBuildType(): BuildType? { + return when (BuildConfig.BUILD_TYPE) { + "debug" -> BuildType.DEBUG + "develop" -> BuildType.DEVELOP + "instrumentalTest" -> BuildType.INSTRUMENTAL_TEST + "release" -> BuildType.RELEASE + "releaseTest" -> BuildType.RELEASE_TEST + "releaseMarket" -> BuildType.RELEASE_MARKET + "releaseGithub" -> BuildType.RELEASE_GITHUB + else -> null + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/common/RootActivityIntentProvider.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/common/RootActivityIntentProvider.kt new file mode 100644 index 0000000..ca85de3 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/common/RootActivityIntentProvider.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.app.root.presentation.common + +import android.content.Context +import android.content.Intent +import io.novafoundation.nova.app.root.presentation.RootActivity +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider + +class RootActivityIntentProvider(private val context: Context) : ActivityIntentProvider { + + override fun getIntent(): Intent { + return Intent(context, RootActivity::class.java) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/di/RootActivityComponent.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/di/RootActivityComponent.kt new file mode 100644 index 0000000..5e4239b --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/di/RootActivityComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.app.root.presentation.di + +import androidx.appcompat.app.AppCompatActivity +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.app.root.presentation.RootActivity +import io.novafoundation.nova.common.di.scope.ScreenScope + +@Subcomponent( + modules = [ + RootActivityModule::class + ] +) +@ScreenScope +interface RootActivityComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance activity: AppCompatActivity + ): RootActivityComponent + } + + fun inject(rootActivity: RootActivity) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/di/RootActivityModule.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/di/RootActivityModule.kt new file mode 100644 index 0000000..d2779aa --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/di/RootActivityModule.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.app.root.presentation.di + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.app.root.domain.RootInteractor +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.app.root.presentation.RootViewModel +import io.novafoundation.nova.app.root.presentation.requestBusHandler.CompoundRequestBusHandler +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.interfaces.ExternalServiceInitializer +import io.novafoundation.nova.common.mixin.api.NetworkStateMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.SafeModeService +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.ToastMessageManager +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.network.DeviceNetworkStateObserver +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_deep_linking.presentation.handling.RootDeepLinkHandler +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning.MultisigPushNotificationsAlertMixinFactory +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import kotlinx.coroutines.flow.MutableStateFlow + +@Module( + includes = [ + ViewModelModule::class + ] +) +class RootActivityModule { + + @Provides + @IntoMap + @ViewModelKey(RootViewModel::class) + fun provideViewModel( + interactor: RootInteractor, + currencyInteractor: CurrencyInteractor, + rootRouter: RootRouter, + resourceManager: ResourceManager, + networkStateMixin: NetworkStateMixin, + externalRequirementsFlow: MutableStateFlow, + contributionsInteractor: ContributionsInteractor, + backgroundAccessObserver: BackgroundAccessObserver, + safeModeService: SafeModeService, + updateNotificationsInteractor: UpdateNotificationsInteractor, + walletConnectService: WalletConnectService, + walletConnectSessionsUseCase: WalletConnectSessionsUseCase, + deepLinkHandler: RootDeepLinkHandler, + rootScope: RootScope, + compoundRequestBusHandler: CompoundRequestBusHandler, + pushNotificationsInteractor: PushNotificationsInteractor, + externalServiceInitializer: ExternalServiceInitializer, + actionBottomSheetLauncher: ActionBottomSheetLauncher, + toastMessageManager: ToastMessageManager, + dialogMessageManager: DialogMessageManager, + multisigPushNotificationsAlertMixinFactory: MultisigPushNotificationsAlertMixinFactory, + deviceNetworkStateObserver: DeviceNetworkStateObserver + ): ViewModel { + return RootViewModel( + interactor, + currencyInteractor, + rootRouter, + externalRequirementsFlow, + resourceManager, + networkStateMixin, + contributionsInteractor, + backgroundAccessObserver, + safeModeService, + updateNotificationsInteractor, + walletConnectService, + walletConnectSessionsUseCase, + deepLinkHandler, + rootScope, + compoundRequestBusHandler, + pushNotificationsInteractor, + externalServiceInitializer, + actionBottomSheetLauncher, + toastMessageManager, + dialogMessageManager, + multisigPushNotificationsAlertMixinFactory, + deviceNetworkStateObserver + ) + } + + @Provides + fun provideViewModelCreator( + activity: AppCompatActivity, + viewModelFactory: ViewModelProvider.Factory + ): RootViewModel { + return ViewModelProvider(activity, viewModelFactory).get(RootViewModel::class.java) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/MainFragment.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/MainFragment.kt new file mode 100644 index 0000000..59740a0 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/MainFragment.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.app.root.presentation.main + +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.databinding.FragmentMainBinding +import io.novafoundation.nova.app.root.di.RootApi +import io.novafoundation.nova.app.root.di.RootComponent +import io.novafoundation.nova.app.root.navigation.navigators.staking.StakingDashboardNavigator +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils + +import javax.inject.Inject + +class MainFragment : BaseFragment() { + + override fun createBinding() = FragmentMainBinding.inflate(layoutInflater) + + @Inject + lateinit var stakingDashboardNavigator: StakingDashboardNavigator + + private var navController: NavController? = null + + private val backCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + isEnabled = navController!!.navigateUp() + } + } + + override fun onDestroyView() { + super.onDestroyView() + + backCallback.isEnabled = false + stakingDashboardNavigator.clearStakingTabNavController() + } + + override fun applyInsets(rootView: View) { + // Bottom Navigation View apply insets by itself so we override it to do nothing + } + + override fun initViews() { + val nestedNavHostFragment = childFragmentManager.findFragmentById(R.id.bottomNavHost) as NavHostFragment + + navController = nestedNavHostFragment.navController + stakingDashboardNavigator.setStakingTabNavController(navController!!) + + binder.bottomNavigationView.setupWithNavController(navController!!) + binder.bottomNavigationView.itemIconTintList = null + + requireActivity().onBackPressedDispatcher.addCallback(backCallback) + + navController!!.addOnDestinationChangedListener { _, destination, _ -> + backCallback.isEnabled = !isAtHomeTab(destination) + } + } + + override fun inject() { + FeatureUtils.getFeature(this, RootApi::class.java) + .mainFragmentComponentFactory() + .create(requireActivity()) + .inject(this) + } + + override fun subscribe(viewModel: MainViewModel) {} + + private fun isAtHomeTab(destination: NavDestination) = + destination.id == navController!!.graph.startDestination +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/MainViewModel.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/MainViewModel.kt new file mode 100644 index 0000000..5df31b6 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/MainViewModel.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.app.root.presentation.main + +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationDetailsSelectToShowUseCase +import io.novafoundation.nova.feature_push_notifications.domain.interactor.WelcomePushNotificationsInteractor +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor +import kotlinx.coroutines.launch + +class MainViewModel( + updateNotificationsInteractor: UpdateNotificationsInteractor, + private val automaticInteractionGate: AutomaticInteractionGate, + private val welcomePushNotificationsInteractor: WelcomePushNotificationsInteractor, + private val rootRouter: RootRouter, + private val chainMigrationDetailsSelectToShowUseCase: ChainMigrationDetailsSelectToShowUseCase +) : BaseViewModel() { + + init { + updateNotificationsInteractor.allowInAppUpdateCheck() + automaticInteractionGate.initialPinPassed() + + if (welcomePushNotificationsInteractor.needToShowWelcomeScreen()) { + rootRouter.openPushWelcome() + } + + launch { + val chainIdsToShowMigrationDetails = chainMigrationDetailsSelectToShowUseCase.getChainIdsToShowMigrationDetails() + chainIdsToShowMigrationDetails.forEach { + rootRouter.openChainMigrationDetails(it) + } + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/di/MainFragmentComponent.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/di/MainFragmentComponent.kt new file mode 100644 index 0000000..4f73a27 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/di/MainFragmentComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.app.root.presentation.main.di + +import androidx.fragment.app.FragmentActivity +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.app.root.presentation.main.MainFragment +import io.novafoundation.nova.common.di.scope.ScreenScope + +@Subcomponent( + modules = [ + MainFragmentModule::class + ] +) +@ScreenScope +interface MainFragmentComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance activity: FragmentActivity + ): MainFragmentComponent + } + + fun inject(fragment: MainFragment) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/di/MainFragmentModule.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/di/MainFragmentModule.kt new file mode 100644 index 0000000..be9f79d --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/main/di/MainFragmentModule.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.app.root.presentation.main.di + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.app.root.presentation.main.MainViewModel +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationDetailsSelectToShowUseCase +import io.novafoundation.nova.feature_push_notifications.domain.interactor.WelcomePushNotificationsInteractor +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor + +@Module( + includes = [ + ViewModelModule::class + ] +) +class MainFragmentModule { + + @Provides + @IntoMap + @ViewModelKey(MainViewModel::class) + fun provideViewModel( + updateNotificationsInteractor: UpdateNotificationsInteractor, + automaticInteractionGate: AutomaticInteractionGate, + welcomePushNotificationsInteractor: WelcomePushNotificationsInteractor, + chainMigrationDetailsSelectToShowUseCase: ChainMigrationDetailsSelectToShowUseCase, + rootRouter: RootRouter + ): ViewModel { + return MainViewModel( + updateNotificationsInteractor, + automaticInteractionGate, + welcomePushNotificationsInteractor, + rootRouter, + chainMigrationDetailsSelectToShowUseCase + ) + } + + @Provides + fun provideViewModelCreator( + activity: FragmentActivity, + viewModelFactory: ViewModelProvider.Factory + ): MainViewModel { + return ViewModelProvider(activity, viewModelFactory).get(MainViewModel::class.java) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/CloudBackupSyncRequestBusHandler.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/CloudBackupSyncRequestBusHandler.kt new file mode 100644 index 0000000..cfd110f --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/CloudBackupSyncRequestBusHandler.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.app.root.presentation.requestBusHandler + +import io.novafoundation.nova.app.root.presentation.RootRouter +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bus.EventBus +import io.novafoundation.nova.common.utils.onEachLatest +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.data.cloudBackup.CLOUD_BACKUP_APPLY_SOURCE +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.events.collect +import io.novafoundation.nova.feature_account_api.domain.cloudBackup.ApplyLocalSnapshotToCloudBackupUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_impl.data.cloudBackup.isBackupable +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchCloudBackupDestructiveChangesNotApplied +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchCloudBackupDestructiveChangesNotAppliedWithoutRouting +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter + +class CloudBackupSyncRequestBusHandler( + private val rootRouter: RootRouter, + private val resourceManager: ResourceManager, + private val metaAccountChangesEventBus: MetaAccountChangesEventBus, + private val applyLocalSnapshotToCloudBackupUseCase: ApplyLocalSnapshotToCloudBackupUseCase, + private val accountRepository: AccountRepository, + private val actionBottomSheetLauncher: ActionBottomSheetLauncher, + private val automaticInteractionGate: AutomaticInteractionGate, +) : RequestBusHandler { + + override fun observe(): Flow<*> { + return metaAccountChangesEventBus.observeEvent() + .filter { it.shouldTriggerBackupSync() } + .onEachLatest { + applyLocalSnapshotToCloudBackupUseCase.applyLocalSnapshotToCloudBackupIfSyncEnabled() + .onFailure { showDestructiveChangesNotAppliedDialog() } + } + } + + private fun EventBus.SourceEvent.shouldTriggerBackupSync(): Boolean { + if (source == CLOUD_BACKUP_APPLY_SOURCE) return false + + val potentialTriggers = event.collect( + onAdd = { it.metaAccountType }, + onStructureChanged = { it.metaAccountType }, + onRemoved = { it.metaAccountType }, + onNameChanged = { it.metaAccountType } + ) + + return potentialTriggers.any { it.isBackupable() } + } + + private suspend fun showDestructiveChangesNotAppliedDialog() { + automaticInteractionGate.awaitInteractionAllowed() + + if (accountRepository.hasActiveMetaAccounts()) { + actionBottomSheetLauncher.launchCloudBackupDestructiveChangesNotApplied( + resourceManager = resourceManager, + onReviewClicked = ::onReviewIssueClicked + ) + } else { + actionBottomSheetLauncher.launchCloudBackupDestructiveChangesNotAppliedWithoutRouting(resourceManager) + } + } + + private fun onReviewIssueClicked() { + rootRouter.openCloudBackupSettings() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/CompoundRequestBusHandler.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/CompoundRequestBusHandler.kt new file mode 100644 index 0000000..273b207 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/CompoundRequestBusHandler.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.app.root.presentation.requestBusHandler + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.merge + +class CompoundRequestBusHandler( + private val handlers: Set +) : RequestBusHandler { + override fun observe(): Flow<*> { + return handlers.toList() + .map { it.observe() } + .merge() + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/MultisigExtrinsicValidationRequestBusHandler.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/MultisigExtrinsicValidationRequestBusHandler.kt new file mode 100644 index 0000000..fae8a20 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/MultisigExtrinsicValidationRequestBusHandler.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.app.root.presentation.requestBusHandler + +import io.novafoundation.nova.common.utils.bus.observeBusEvent +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationRequestBus.ValidationResponse +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.MultisigExtrinsicValidationFactory +import kotlinx.coroutines.flow.Flow + +class MultisigExtrinsicValidationRequestBusHandler( + private val multisigExtrinsicValidationRequestBus: MultisigExtrinsicValidationRequestBus, + private val multisigExtrinsicValidationFactory: MultisigExtrinsicValidationFactory +) : RequestBusHandler { + + override fun observe(): Flow<*> { + return multisigExtrinsicValidationRequestBus.observeEvent() + .observeBusEvent { request -> + val validationResult = createValidationSystem() + .validate(request.validationPayload) + + ValidationResponse(validationResult) + } + } + + private fun createValidationSystem(): MultisigExtrinsicValidationSystem { + return ValidationSystem { + multisigExtrinsicValidationFactory.multisigSignatoryHasEnoughBalance() + multisigExtrinsicValidationFactory.noPendingMultisigWithSameCallData() + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/ProxyExtrinsicValidationRequestBusHandler.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/ProxyExtrinsicValidationRequestBusHandler.kt new file mode 100644 index 0000000..da5e751 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/ProxyExtrinsicValidationRequestBusHandler.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.app.root.presentation.requestBusHandler + +import io.novafoundation.nova.common.utils.bus.observeBusEvent +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxiedExtrinsicValidationFailure +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxiedExtrinsicValidationPayload +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxyExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxyExtrinsicValidationRequestBus.ValidationResponse +import io.novafoundation.nova.feature_account_api.data.proxy.validation.proxyAccountId +import io.novafoundation.nova.feature_wallet_api.domain.validation.ProxyHaveEnoughFeeValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.proxyHasEnoughFeeValidation +import kotlinx.coroutines.flow.Flow + +class ProxyExtrinsicValidationRequestBusHandler( + private val proxyProxyExtrinsicValidationRequestBus: ProxyExtrinsicValidationRequestBus, + private val proxyHaveEnoughFeeValidationFactory: ProxyHaveEnoughFeeValidationFactory +) : RequestBusHandler { + + override fun observe(): Flow<*> { + return proxyProxyExtrinsicValidationRequestBus.observeEvent() + .observeBusEvent { request -> + val validationResult = createValidationSystem() + .validate(request.validationPayload) + ValidationResponse(validationResult) + } + } + + private fun createValidationSystem(): ValidationSystem { + return ValidationSystem { + proxyHasEnoughFee() + } + } + + private fun ValidationSystemBuilder.proxyHasEnoughFee() { + proxyHasEnoughFeeValidation( + factory = proxyHaveEnoughFeeValidationFactory, + proxiedMetaAccount = { it.proxiedMetaAccount }, + proxyAccountId = { it.proxyAccountId }, + proxiedCall = { it.proxiedCall }, + chainWithAsset = { it.chainWithAsset }, + proxyNotEnoughFee = { payload, availableBalance, fee -> + val asset = payload.chainWithAsset.asset + ProxiedExtrinsicValidationFailure.ProxyNotEnoughFee( + proxy = payload.proxyMetaAccount, + asset = asset, + fee = fee.amount, + availableBalance = availableBalance + ) + } + ) + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/PushSettingsSyncRequestBusHandler.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/PushSettingsSyncRequestBusHandler.kt new file mode 100644 index 0000000..044c366 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/PushSettingsSyncRequestBusHandler.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.app.root.presentation.requestBusHandler + +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.events.collect +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach + +class PushSettingsSyncRequestBusHandler( + private val metaAccountChangesEventBus: MetaAccountChangesEventBus, + private val pushNotificationsInteractor: PushNotificationsInteractor +) : RequestBusHandler { + + override fun observe(): Flow<*> { + return metaAccountChangesEventBus.observeEvent() + .onEach { sourceEvent -> + val changed = sourceEvent.event.collect( + onStructureChanged = { it.metaId } + ) + val removed = sourceEvent.event.collect( + onRemoved = { it.metaId } + ) + + pushNotificationsInteractor.onMetaAccountChange(changed = changed, deleted = removed) + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/RequestBusHandler.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/RequestBusHandler.kt new file mode 100644 index 0000000..123d1f1 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/requestBusHandler/RequestBusHandler.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.app.root.presentation.requestBusHandler + +import kotlinx.coroutines.flow.Flow + +interface RequestBusHandler { + fun observe(): Flow<*> +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/SplitScreenFragment.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/SplitScreenFragment.kt new file mode 100644 index 0000000..40b037e --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/SplitScreenFragment.kt @@ -0,0 +1,138 @@ +package io.novafoundation.nova.app.root.presentation.splitScreen + +import android.graphics.Rect +import android.os.Bundle +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type +import androidx.core.view.isVisible +import androidx.core.view.marginTop +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import coil.ImageLoader +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.databinding.FragmentSplitScreenBinding +import io.novafoundation.nova.app.root.di.RootApi +import io.novafoundation.nova.app.root.di.RootComponent +import io.novafoundation.nova.app.root.navigation.holders.SplitScreenNavigationHolder +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.RoundCornersOutlineProvider +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.payloadOrElse +import io.novafoundation.nova.feature_dapp_impl.presentation.tab.setupCloseAllDappTabsDialogue +import javax.inject.Inject + +class SplitScreenFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + @Inject + lateinit var splitScreenNavigationHolder: SplitScreenNavigationHolder + + @Inject + lateinit var imageLoader: ImageLoader + + private val mainNavController: NavController by lazy { + val navHostFragment = childFragmentManager.findFragmentById(R.id.mainNavHost) as NavHostFragment + + navHostFragment.navController + } + + override fun applyInsets(rootView: View) { + // Implemented to not consume insets for nested fragments + } + + override fun createBinding() = FragmentSplitScreenBinding.inflate(layoutInflater) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + splitScreenNavigationHolder.attach(mainNavController) + + viewModel.onNavigationAttached() + } + + override fun onDestroyView() { + splitScreenNavigationHolder.detachNavController(mainNavController) + + super.onDestroyView() + } + + override fun inject() { + FeatureUtils.getFeature(this, RootApi::class.java) + .splitScreenFragmentComponentFactory() + .create(this, payloadOrElse { SplitScreenPayload.NoNavigation }) + .inject(this) + } + + override fun initViews() { + val outlineMargin = Rect(0, (-12).dp, 0, 0) // To avoid round corners at top + binder.mainNavHost.outlineProvider = RoundCornersOutlineProvider(12.dpF, margin = outlineMargin) + + binder.dappEntryPoint.setOnClickListener { viewModel.onTabsClicked() } + binder.dappEntryPointClose.setOnClickListener { viewModel.onTabsCloseClicked() } + } + + override fun subscribe(viewModel: SplitScreenViewModel) { + setupCloseAllDappTabsDialogue(viewModel.closeAllTabsConfirmation) + + viewModel.dappTabsVisible.observe { shouldBeVisible -> + binder.mainNavHost.clipToOutline = shouldBeVisible + binder.dappEntryPoint.isVisible = shouldBeVisible + } + + viewModel.tabsTitle.observe { model -> + binder.dappEntryPointIcon.letOrHide(model.icon) { + binder.dappEntryPointIcon.setIcon(it, imageLoader) + } + binder.dappEntryPointText.text = model.title + } + manageInsets() + } + + /** + * Since we have a dAppEntryPoint we must change ime insets for main container and its children + * to avoid extra bottom space when keyboard is shown + */ + private fun manageInsets() { + binder.dappEntryPoint.applyNavigationBarInsets() + + var dappEntryPointShown = false + var dappEntryPointHeight = 0 + + // Inset listener that provides a custom insets to its children + ViewCompat.setOnApplyWindowInsetsListener(binder.mainNavHost) { _, insets -> + val insetsBuilder = WindowInsetsCompat.Builder(insets) + // We need to remove height from ime inset to don't show dapp entry point when keyboard is shown + insetsBuilder.setInsets(Type.ime(), insets.getInsets(Type.ime()).decreaseBottom(dappEntryPointHeight)) + + // We also remove other navigation ans gestures insets since dappEntryPoint must use them instead of any nested fragment + insetsBuilder.setInsets(Type.navigationBars(), insets.getInsets(Type.navigationBars()).removeBottom(dappEntryPointShown)) + insetsBuilder.build() + } + + // Subscribe to change insets when dappEntryPoint changes its visibility + viewModel.dappTabsVisible.observe { + // Change this instantly to avoid delays until dappEntryPoint will be measured + dappEntryPointShown = it + ViewCompat.requestApplyInsets(binder.mainNavHost) + + // Change this only after dappEntryPoint will be measured to setup a keyboard insets + binder.dappEntryPoint.post { + dappEntryPointHeight = if (binder.dappEntryPoint.isVisible) { + binder.dappEntryPoint.height + binder.dappEntryPoint.marginTop + } else { + 0 + } + + ViewCompat.requestApplyInsets(binder.mainNavHost) + } + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/SplitScreenPayload.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/SplitScreenPayload.kt new file mode 100644 index 0000000..9a8e857 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/SplitScreenPayload.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.app.root.presentation.splitScreen + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.DelayedNavigation +import kotlinx.android.parcel.Parcelize + +sealed interface SplitScreenPayload : Parcelable { + + @Parcelize + object NoNavigation : SplitScreenPayload + + @Parcelize + class InstantNavigationOnAttach( + val delayedNavigation: DelayedNavigation + ) : SplitScreenPayload +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/SplitScreenViewModel.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/SplitScreenViewModel.kt new file mode 100644 index 0000000..db43a89 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/SplitScreenViewModel.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.app.root.presentation.splitScreen + +import io.novafoundation.nova.app.R +import io.novafoundation.nova.app.root.domain.SplitScreenInteractor +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.awaitAction +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.navigation.DelayedNavigationRouter +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Consumer +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.asFileIcon +import io.novafoundation.nova.common.utils.images.asUrlIcon +import io.novafoundation.nova.feature_dapp_api.data.model.SimpleTabModel +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +data class TabsTitleModel( + val title: String, + val icon: Icon? +) + +class SplitScreenViewModel( + private val interactor: SplitScreenInteractor, + private val router: DAppRouter, + private val delayedNavigationRouter: DelayedNavigationRouter, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val resourceManager: ResourceManager, + private val payload: SplitScreenPayload +) : BaseViewModel() { + + private val consumablePayload = Consumer(payload) + + val closeAllTabsConfirmation = actionAwaitableMixinFactory.confirmingAction() + + private val tabsFlow = interactor.observeTabNamesById() + .shareInBackground() + + val tabsTitle = tabsFlow.map { tabs -> + if (tabs.size == 1) { + singleTabTitle(tabs.single()) + } else { + tabSizeTitle(tabs.size) + } + }.distinctUntilChanged() + + val dappTabsVisible = tabsFlow.map { it.isNotEmpty() } + .distinctUntilChanged() + + fun onTabsClicked() = launch { + val tabs = tabsFlow.first() + + if (tabs.size == 1) { + val payload = DAppBrowserPayload.Tab(tabs.single().tabId) + router.openDAppBrowser(payload) + } else { + router.openTabs() + } + } + + fun onTabsCloseClicked() = launch { + closeAllTabsConfirmation.awaitAction() + + interactor.removeAllTabs() + } + + private fun singleTabTitle(tab: SimpleTabModel): TabsTitleModel { + return tab.title?.let { + TabsTitleModel(it, tab.knownDAppIconUrl?.asUrlIcon() ?: tab.faviconPath?.asFileIcon()) + } ?: tabSizeTitle(1) + } + + private fun tabSizeTitle(size: Int): TabsTitleModel { + return TabsTitleModel( + resourceManager.getString(R.string.dapp_entry_point_title, size), + null + ) + } + + fun onNavigationAttached() { + consumablePayload.useOnce { + when (it) { + is SplitScreenPayload.InstantNavigationOnAttach -> { + delayedNavigationRouter.runDelayedNavigation(it.delayedNavigation) + } + + SplitScreenPayload.NoNavigation, + null -> { + } // Do nothing + } + } + } +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/Utils.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/Utils.kt new file mode 100644 index 0000000..85682c0 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/Utils.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.app.root.presentation.splitScreen + +import androidx.core.graphics.Insets + +fun Insets.decreaseBottom(bottomInset: Int): Insets { + return Insets.of( + left, + top, + right, + (bottom - bottomInset).coerceAtLeast(0) + ) +} + +fun Insets.removeBottom(removeBottom: Boolean): Insets { + return Insets.of( + left, + top, + right, + if (removeBottom) 0 else bottom + ) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/di/SplitScreenFragmentComponent.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/di/SplitScreenFragmentComponent.kt new file mode 100644 index 0000000..c7f490c --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/di/SplitScreenFragmentComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.app.root.presentation.splitScreen.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.app.root.presentation.splitScreen.SplitScreenFragment +import io.novafoundation.nova.app.root.presentation.splitScreen.SplitScreenPayload +import io.novafoundation.nova.common.di.scope.ScreenScope + +@Subcomponent( + modules = [ + SplitScreenFragmentModule::class + ] +) +@ScreenScope +interface SplitScreenFragmentComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SplitScreenPayload + ): SplitScreenFragmentComponent + } + + fun inject(fragment: SplitScreenFragment) +} diff --git a/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/di/SplitScreenFragmentModule.kt b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/di/SplitScreenFragmentModule.kt new file mode 100644 index 0000000..e44dd96 --- /dev/null +++ b/app/src/main/java/io/novafoundation/nova/app/root/presentation/splitScreen/di/SplitScreenFragmentModule.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.app.root.presentation.splitScreen.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.app.root.domain.SplitScreenInteractor +import io.novafoundation.nova.app.root.presentation.splitScreen.SplitScreenPayload +import io.novafoundation.nova.app.root.presentation.splitScreen.SplitScreenViewModel +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.navigation.DelayedNavigationRouter +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserTabExternalRepository +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter + +@Module( + includes = [ + ViewModelModule::class + ] +) +class SplitScreenFragmentModule { + + @Provides + fun provideInteractor( + repository: BrowserTabExternalRepository, + accountRepository: AccountRepository + ): SplitScreenInteractor { + return SplitScreenInteractor(repository, accountRepository) + } + + @Provides + @IntoMap + @ViewModelKey(SplitScreenViewModel::class) + fun provideViewModel( + interactor: SplitScreenInteractor, + dAppRouter: DAppRouter, + delayedNavigationRouter: DelayedNavigationRouter, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + resourceManager: ResourceManager, + payload: SplitScreenPayload + ): ViewModel { + return SplitScreenViewModel(interactor, dAppRouter, delayedNavigationRouter, actionAwaitableMixinFactory, resourceManager, payload) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SplitScreenViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SplitScreenViewModel::class.java) + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_nevroz_fire.png b/app/src/main/res/drawable-hdpi/ic_nevroz_fire.png new file mode 100644 index 0000000..5465d0b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_nevroz_fire.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_nevroz_fire.png b/app/src/main/res/drawable-ldpi/ic_nevroz_fire.png new file mode 100644 index 0000000..4e78306 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_nevroz_fire.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_nevroz_fire.png b/app/src/main/res/drawable-mdpi/ic_nevroz_fire.png new file mode 100644 index 0000000..15faee7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_nevroz_fire.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_nevroz_fire.png b/app/src/main/res/drawable-xhdpi/ic_nevroz_fire.png new file mode 100644 index 0000000..14b093a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_nevroz_fire.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_nevroz_fire.png b/app/src/main/res/drawable-xxhdpi/ic_nevroz_fire.png new file mode 100644 index 0000000..d974dfa Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_nevroz_fire.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_nevroz_fire.png b/app/src/main/res/drawable-xxxhdpi/ic_nevroz_fire.png new file mode 100644 index 0000000..12e24d8 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_nevroz_fire.png differ diff --git a/app/src/main/res/drawable/bottom_assets.xml b/app/src/main/res/drawable/bottom_assets.xml new file mode 100644 index 0000000..d9b2331 --- /dev/null +++ b/app/src/main/res/drawable/bottom_assets.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bottom_dapps.xml b/app/src/main/res/drawable/bottom_dapps.xml new file mode 100644 index 0000000..9dce6b9 --- /dev/null +++ b/app/src/main/res/drawable/bottom_dapps.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bottom_settings.xml b/app/src/main/res/drawable/bottom_settings.xml new file mode 100644 index 0000000..b0eab16 --- /dev/null +++ b/app/src/main/res/drawable/bottom_settings.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bottom_staking.xml b/app/src/main/res/drawable/bottom_staking.xml new file mode 100644 index 0000000..ed6bfa2 --- /dev/null +++ b/app/src/main/res/drawable/bottom_staking.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bottom_vote.xml b/app/src/main/res/drawable/bottom_vote.xml new file mode 100644 index 0000000..f585926 --- /dev/null +++ b/app/src/main/res/drawable/bottom_vote.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_root.xml b/app/src/main/res/layout/activity_root.xml new file mode 100644 index 0000000..fa0d12f --- /dev/null +++ b/app/src/main/res/layout/activity_root.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml new file mode 100644 index 0000000..b799e78 --- /dev/null +++ b/app/src/main/res/layout/fragment_main.xml @@ -0,0 +1,38 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_split_screen.xml b/app/src/main/res/layout/fragment_split_screen.xml new file mode 100644 index 0000000..dc181a5 --- /dev/null +++ b/app/src/main/res/layout/fragment_split_screen.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/bottom_navigations.xml b/app/src/main/res/menu/bottom_navigations.xml new file mode 100644 index 0000000..391be41 --- /dev/null +++ b/app/src/main/res/menu/bottom_navigations.xml @@ -0,0 +1,39 @@ + +

+ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..4ae7d12 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..37b3ea1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..b7639b1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ed29748 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..4422f40 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..e76dd94 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..fa29528 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d943d6b Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..55cfa92 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..5eb02a4 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..ab41bfe Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6673e9a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..f507302 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..4f4e24a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..63264e1 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a999cd3 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..01a3d12 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..08bf3d3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..c78818b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f2c016a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8bb05c3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/navigation/add_evm_account_generic_ledger_graph.xml b/app/src/main/res/navigation/add_evm_account_generic_ledger_graph.xml new file mode 100644 index 0000000..33cb8a6 --- /dev/null +++ b/app/src/main/res/navigation/add_evm_account_generic_ledger_graph.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/add_ledger_chain_account_graph.xml b/app/src/main/res/navigation/add_ledger_chain_account_graph.xml new file mode 100644 index 0000000..a0c5a1e --- /dev/null +++ b/app/src/main/res/navigation/add_ledger_chain_account_graph.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/bottom_nav_graph.xml b/app/src/main/res/navigation/bottom_nav_graph.xml new file mode 100644 index 0000000..5accd69 --- /dev/null +++ b/app/src/main/res/navigation/bottom_nav_graph.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/cloud_backup_settings_graph.xml b/app/src/main/res/navigation/cloud_backup_settings_graph.xml new file mode 100644 index 0000000..8e48f78 --- /dev/null +++ b/app/src/main/res/navigation/cloud_backup_settings_graph.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/create_wallet_nav_graph.xml b/app/src/main/res/navigation/create_wallet_nav_graph.xml new file mode 100644 index 0000000..f91cc00 --- /dev/null +++ b/app/src/main/res/navigation/create_wallet_nav_graph.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/crowdloan_contributions_graph.xml b/app/src/main/res/navigation/crowdloan_contributions_graph.xml new file mode 100644 index 0000000..8f6800b --- /dev/null +++ b/app/src/main/res/navigation/crowdloan_contributions_graph.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/dapp_browser_graph.xml b/app/src/main/res/navigation/dapp_browser_graph.xml new file mode 100644 index 0000000..e24f460 --- /dev/null +++ b/app/src/main/res/navigation/dapp_browser_graph.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/dapp_search_graph.xml b/app/src/main/res/navigation/dapp_search_graph.xml new file mode 100644 index 0000000..4204e48 --- /dev/null +++ b/app/src/main/res/navigation/dapp_search_graph.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/dapp_tabs_graph.xml b/app/src/main/res/navigation/dapp_tabs_graph.xml new file mode 100644 index 0000000..e568a33 --- /dev/null +++ b/app/src/main/res/navigation/dapp_tabs_graph.xml @@ -0,0 +1,19 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/delegation_details_nav_graph.xml b/app/src/main/res/navigation/delegation_details_nav_graph.xml new file mode 100644 index 0000000..dbe61b8 --- /dev/null +++ b/app/src/main/res/navigation/delegation_details_nav_graph.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/delegation_nav_graph.xml b/app/src/main/res/navigation/delegation_nav_graph.xml new file mode 100644 index 0000000..6c52266 --- /dev/null +++ b/app/src/main/res/navigation/delegation_nav_graph.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/external_sign_graph.xml b/app/src/main/res/navigation/external_sign_graph.xml new file mode 100644 index 0000000..086b853 --- /dev/null +++ b/app/src/main/res/navigation/external_sign_graph.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/gifts_nav_graph.xml b/app/src/main/res/navigation/gifts_nav_graph.xml new file mode 100644 index 0000000..c5098db --- /dev/null +++ b/app/src/main/res/navigation/gifts_nav_graph.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/import_generic_ledger_graph.xml b/app/src/main/res/navigation/import_generic_ledger_graph.xml new file mode 100644 index 0000000..f8f453c --- /dev/null +++ b/app/src/main/res/navigation/import_generic_ledger_graph.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/import_legacy_ledger_graph.xml b/app/src/main/res/navigation/import_legacy_ledger_graph.xml new file mode 100644 index 0000000..eb03102 --- /dev/null +++ b/app/src/main/res/navigation/import_legacy_ledger_graph.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/import_nav_graph.xml b/app/src/main/res/navigation/import_nav_graph.xml new file mode 100644 index 0000000..e6072a6 --- /dev/null +++ b/app/src/main/res/navigation/import_nav_graph.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/import_parity_signer_graph.xml b/app/src/main/res/navigation/import_parity_signer_graph.xml new file mode 100644 index 0000000..ca3661c --- /dev/null +++ b/app/src/main/res/navigation/import_parity_signer_graph.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/import_wallet_options_nav_graph.xml b/app/src/main/res/navigation/import_wallet_options_nav_graph.xml new file mode 100644 index 0000000..dc86bae --- /dev/null +++ b/app/src/main/res/navigation/import_wallet_options_nav_graph.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/manage_tokens_graph.xml b/app/src/main/res/navigation/manage_tokens_graph.xml new file mode 100644 index 0000000..a15d089 --- /dev/null +++ b/app/src/main/res/navigation/manage_tokens_graph.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/manual_backup_graph.xml b/app/src/main/res/navigation/manual_backup_graph.xml new file mode 100644 index 0000000..f5a669d --- /dev/null +++ b/app/src/main/res/navigation/manual_backup_graph.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/mnemonic_nav_graph.xml b/app/src/main/res/navigation/mnemonic_nav_graph.xml new file mode 100644 index 0000000..53c448b --- /dev/null +++ b/app/src/main/res/navigation/mnemonic_nav_graph.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/multisig_operation_details_graph.xml b/app/src/main/res/navigation/multisig_operation_details_graph.xml new file mode 100644 index 0000000..cbabac9 --- /dev/null +++ b/app/src/main/res/navigation/multisig_operation_details_graph.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/multisig_pending_operations_graph.xml b/app/src/main/res/navigation/multisig_pending_operations_graph.xml new file mode 100644 index 0000000..967760b --- /dev/null +++ b/app/src/main/res/navigation/multisig_pending_operations_graph.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/network_management_graph.xml b/app/src/main/res/navigation/network_management_graph.xml new file mode 100644 index 0000000..8a98c2f --- /dev/null +++ b/app/src/main/res/navigation/network_management_graph.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nfts_nav_graph.xml b/app/src/main/res/navigation/nfts_nav_graph.xml new file mode 100644 index 0000000..ded4dab --- /dev/null +++ b/app/src/main/res/navigation/nfts_nav_graph.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nomination_pools_bond_more_graph.xml b/app/src/main/res/navigation/nomination_pools_bond_more_graph.xml new file mode 100644 index 0000000..f8df6d2 --- /dev/null +++ b/app/src/main/res/navigation/nomination_pools_bond_more_graph.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nomination_pools_unbond_graph.xml b/app/src/main/res/navigation/nomination_pools_unbond_graph.xml new file mode 100644 index 0000000..d7f413e --- /dev/null +++ b/app/src/main/res/navigation/nomination_pools_unbond_graph.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nova_card_graph.xml b/app/src/main/res/navigation/nova_card_graph.xml new file mode 100644 index 0000000..11667b6 --- /dev/null +++ b/app/src/main/res/navigation/nova_card_graph.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/onboarding_nav_graph.xml b/app/src/main/res/navigation/onboarding_nav_graph.xml new file mode 100644 index 0000000..ed49678 --- /dev/null +++ b/app/src/main/res/navigation/onboarding_nav_graph.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/push_settings_graph.xml b/app/src/main/res/navigation/push_settings_graph.xml new file mode 100644 index 0000000..2f66e11 --- /dev/null +++ b/app/src/main/res/navigation/push_settings_graph.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/referenda_search_graph.xml b/app/src/main/res/navigation/referenda_search_graph.xml new file mode 100644 index 0000000..b26ba04 --- /dev/null +++ b/app/src/main/res/navigation/referenda_search_graph.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/referendum_details_graph.xml b/app/src/main/res/navigation/referendum_details_graph.xml new file mode 100644 index 0000000..03d3d74 --- /dev/null +++ b/app/src/main/res/navigation/referendum_details_graph.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/referendum_unlock_graph.xml b/app/src/main/res/navigation/referendum_unlock_graph.xml new file mode 100644 index 0000000..5a6d71f --- /dev/null +++ b/app/src/main/res/navigation/referendum_unlock_graph.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/restore_cloud_backup_nav_graph.xml b/app/src/main/res/navigation/restore_cloud_backup_nav_graph.xml new file mode 100644 index 0000000..04adcf9 --- /dev/null +++ b/app/src/main/res/navigation/restore_cloud_backup_nav_graph.xml @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/root_nav_graph.xml b/app/src/main/res/navigation/root_nav_graph.xml new file mode 100644 index 0000000..dddbd85 --- /dev/null +++ b/app/src/main/res/navigation/root_nav_graph.xml @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/select_swap_token_nav_graph.xml b/app/src/main/res/navigation/select_swap_token_nav_graph.xml new file mode 100644 index 0000000..4c8d304 --- /dev/null +++ b/app/src/main/res/navigation/select_swap_token_nav_graph.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/setup_staking_validators_graph.xml b/app/src/main/res/navigation/setup_staking_validators_graph.xml new file mode 100644 index 0000000..68742b8 --- /dev/null +++ b/app/src/main/res/navigation/setup_staking_validators_graph.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/sign_ledger_nav_graph.xml b/app/src/main/res/navigation/sign_ledger_nav_graph.xml new file mode 100644 index 0000000..aaf53e0 --- /dev/null +++ b/app/src/main/res/navigation/sign_ledger_nav_graph.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/sign_parity_signer_graph.xml b/app/src/main/res/navigation/sign_parity_signer_graph.xml new file mode 100644 index 0000000..02fc87d --- /dev/null +++ b/app/src/main/res/navigation/sign_parity_signer_graph.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/split_screen_nav_graph.xml b/app/src/main/res/navigation/split_screen_nav_graph.xml new file mode 100644 index 0000000..7b65289 --- /dev/null +++ b/app/src/main/res/navigation/split_screen_nav_graph.xml @@ -0,0 +1,1434 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/staking_dashboard_graph.xml b/app/src/main/res/navigation/staking_dashboard_graph.xml new file mode 100644 index 0000000..4f42fce --- /dev/null +++ b/app/src/main/res/navigation/staking_dashboard_graph.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/staking_main_graph.xml b/app/src/main/res/navigation/staking_main_graph.xml new file mode 100644 index 0000000..0e313a5 --- /dev/null +++ b/app/src/main/res/navigation/staking_main_graph.xml @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/staking_mythos_start_graph.xml b/app/src/main/res/navigation/staking_mythos_start_graph.xml new file mode 100644 index 0000000..f8b6a78 --- /dev/null +++ b/app/src/main/res/navigation/staking_mythos_start_graph.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/staking_mythos_unbond_graph.xml b/app/src/main/res/navigation/staking_mythos_unbond_graph.xml new file mode 100644 index 0000000..accbd40 --- /dev/null +++ b/app/src/main/res/navigation/staking_mythos_unbond_graph.xml @@ -0,0 +1,24 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/staking_parachain_start_graph.xml b/app/src/main/res/navigation/staking_parachain_start_graph.xml new file mode 100644 index 0000000..3c2c186 --- /dev/null +++ b/app/src/main/res/navigation/staking_parachain_start_graph.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/staking_parachain_unbond.xml b/app/src/main/res/navigation/staking_parachain_unbond.xml new file mode 100644 index 0000000..386beb4 --- /dev/null +++ b/app/src/main/res/navigation/staking_parachain_unbond.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/staking_parachain_yield_boost.xml b/app/src/main/res/navigation/staking_parachain_yield_boost.xml new file mode 100644 index 0000000..5a099f6 --- /dev/null +++ b/app/src/main/res/navigation/staking_parachain_yield_boost.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/start_multi_staking_nav_graph.xml b/app/src/main/res/navigation/start_multi_staking_nav_graph.xml new file mode 100644 index 0000000..a8921ab --- /dev/null +++ b/app/src/main/res/navigation/start_multi_staking_nav_graph.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/start_staking_nav_graph.xml b/app/src/main/res/navigation/start_staking_nav_graph.xml new file mode 100644 index 0000000..5168c16 --- /dev/null +++ b/app/src/main/res/navigation/start_staking_nav_graph.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/start_swap_nav_graph.xml b/app/src/main/res/navigation/start_swap_nav_graph.xml new file mode 100644 index 0000000..bda06dd --- /dev/null +++ b/app/src/main/res/navigation/start_swap_nav_graph.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/tinder_gov_graph.xml b/app/src/main/res/navigation/tinder_gov_graph.xml new file mode 100644 index 0000000..dd92904 --- /dev/null +++ b/app/src/main/res/navigation/tinder_gov_graph.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/wallet_connect_nav_graph.xml b/app/src/main/res/navigation/wallet_connect_nav_graph.xml new file mode 100644 index 0000000..8dd9799 --- /dev/null +++ b/app/src/main/res/navigation/wallet_connect_nav_graph.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/your_delegations_nav_graph.xml b/app/src/main/res/navigation/your_delegations_nav_graph.xml new file mode 100644 index 0000000..905da45 --- /dev/null +++ b/app/src/main/res/navigation/your_delegations_nav_graph.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..a2ca1e8 --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..18130d8 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Pezkuwi Wallet + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..7da4b02 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..9fb0cc5 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + + 10.0.2.2 + + \ No newline at end of file diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..fbec41b --- /dev/null +++ b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/src/release/res/drawable/ic_launcher_foreground.xml b/app/src/release/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..9cd6ca3 --- /dev/null +++ b/app/src/release/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/release/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/release/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f2acb4 --- /dev/null +++ b/app/src/release/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/release/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/release/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f2acb4 --- /dev/null +++ b/app/src/release/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/release/res/mipmap-hdpi/ic_launcher.png b/app/src/release/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..ed34208 Binary files /dev/null and b/app/src/release/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/release/res/mipmap-hdpi/ic_launcher_background.png b/app/src/release/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..c6a55f2 Binary files /dev/null and b/app/src/release/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/app/src/release/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/release/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..235ee15 Binary files /dev/null and b/app/src/release/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/release/res/mipmap-hdpi/ic_launcher_round.png b/app/src/release/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..ed34208 Binary files /dev/null and b/app/src/release/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/release/res/mipmap-mdpi/ic_launcher.png b/app/src/release/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..4a4320d Binary files /dev/null and b/app/src/release/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/release/res/mipmap-mdpi/ic_launcher_background.png b/app/src/release/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..740770a Binary files /dev/null and b/app/src/release/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/app/src/release/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/release/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..256a91d Binary files /dev/null and b/app/src/release/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/release/res/mipmap-mdpi/ic_launcher_round.png b/app/src/release/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..4a4320d Binary files /dev/null and b/app/src/release/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/release/res/mipmap-xhdpi/ic_launcher.png b/app/src/release/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..d70dba2 Binary files /dev/null and b/app/src/release/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/release/res/mipmap-xhdpi/ic_launcher_background.png b/app/src/release/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..3c1d23f Binary files /dev/null and b/app/src/release/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/app/src/release/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/release/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7f7df60 Binary files /dev/null and b/app/src/release/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/release/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/release/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d70dba2 Binary files /dev/null and b/app/src/release/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/release/res/mipmap-xxhdpi/ic_launcher.png b/app/src/release/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d1b7188 Binary files /dev/null and b/app/src/release/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/release/res/mipmap-xxhdpi/ic_launcher_background.png b/app/src/release/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..8db3f72 Binary files /dev/null and b/app/src/release/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/app/src/release/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/release/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1091fc0 Binary files /dev/null and b/app/src/release/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/release/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/release/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d1b7188 Binary files /dev/null and b/app/src/release/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/release/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/release/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..160dafe Binary files /dev/null and b/app/src/release/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/release/res/mipmap-xxxhdpi/ic_launcher_background.png b/app/src/release/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..ff6bc52 Binary files /dev/null and b/app/src/release/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/app/src/release/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/release/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..0e1ee16 Binary files /dev/null and b/app/src/release/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/release/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/release/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..160dafe Binary files /dev/null and b/app/src/release/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/release/res/xml/network_security_config.xml b/app/src/release/res/xml/network_security_config.xml new file mode 100644 index 0000000..56bc38b --- /dev/null +++ b/app/src/release/res/xml/network_security_config.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/test/java/io/novafoundation/nova/feature_cloud_backup_impl/data/encryption/ScryptCloudBackupEncryptionTest.kt b/app/src/test/java/io/novafoundation/nova/feature_cloud_backup_impl/data/encryption/ScryptCloudBackupEncryptionTest.kt new file mode 100644 index 0000000..91f9790 --- /dev/null +++ b/app/src/test/java/io/novafoundation/nova/feature_cloud_backup_impl/data/encryption/ScryptCloudBackupEncryptionTest.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.data.encryption + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError +import io.novafoundation.nova.feature_cloud_backup_impl.data.UnencryptedPrivateData +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class ScryptCloudBackupEncryptionTest { + + private val encryption = ScryptCloudBackupEncryption() + + @Test + fun shouldEncryptAndDecryptToTheSameValue() = runBlocking { + val plaintext = "Test" + val password = "12345" + + val encrypted = encryption.encryptBackup(UnencryptedPrivateData(plaintext), password) + assert(encrypted.isSuccess) + val decrypted = encryption.decryptBackup(encrypted.getOrThrow(), password) + assert(decrypted.isSuccess) + assertEquals(plaintext, decrypted.getOrThrow().unencryptedData) + } + + @Test(expected = InvalidBackupPasswordError::class) + fun shouldFailOnWrongPassword() { + runBlocking { + val plaintext = "Test" + val password = "12345" + val wrongPassword = "1234" + + val encrypted = encryption.encryptBackup(UnencryptedPrivateData(plaintext), password) + val decrypted = encryption.decryptBackup(encrypted.getOrThrow(), wrongPassword) + + decrypted.getOrThrow() + } + } +} diff --git a/app/src/test/java/io/novafoundation/nova/feature_dapp_impl/data/repository/PhishingSitesRepositoryImplTest.kt b/app/src/test/java/io/novafoundation/nova/feature_dapp_impl/data/repository/PhishingSitesRepositoryImplTest.kt new file mode 100644 index 0000000..ebe3e85 --- /dev/null +++ b/app/src/test/java/io/novafoundation/nova/feature_dapp_impl/data/repository/PhishingSitesRepositoryImplTest.kt @@ -0,0 +1,112 @@ +package io.novafoundation.nova.feature_dapp_impl.data.repository + +import io.novafoundation.nova.core_db.dao.PhishingSitesDao +import io.novafoundation.nova.feature_dapp_impl.data.network.phishing.PhishingSitesApi +import io.novafoundation.nova.feature_dapp_impl.data.phisning.BlackListPhishingDetectingService +import io.novafoundation.nova.feature_dapp_impl.data.phisning.CompoundPhishingDetectingService +import io.novafoundation.nova.feature_dapp_impl.data.phisning.DomainListPhishingDetectingService +import io.novafoundation.nova.feature_dapp_impl.data.phisning.PhishingDetectingService +import io.novafoundation.nova.test_shared.any +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.BDDMockito.given +import org.mockito.BDDMockito.reset +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class PhishingSitesRepositoryImplTest { + + @Mock + lateinit var phishingDao: PhishingSitesDao + + @Mock + lateinit var phishingSitesApi: PhishingSitesApi + + private val phishingDetectingService: PhishingDetectingService by lazy { + CompoundPhishingDetectingService( + listOf( + BlackListPhishingDetectingService(phishingDao), + DomainListPhishingDetectingService(listOf("top")) + ) + ) + } + + private val phishingSiteRepository by lazy { + PhishingSitesRepositoryImpl(phishingDao, phishingSitesApi, phishingDetectingService) + } + + + @Test + fun isPhishing() { + runBlocking { + // exact match + runTest( + dbItems = listOf("host.com"), + checkingUrl = "http://host.com", + expectedResult = true + ) + + // subdomain + runTest( + dbItems = listOf("host.com"), + checkingUrl = "http://sub.host.com", + expectedResult = true + ) + + // sub-subdomain + runTest( + dbItems = listOf("host.com"), + checkingUrl = "http://sub2.sub1.host.com", + expectedResult = true + ) + + // ignore path and other url elements + runTest( + dbItems = listOf("host.com"), + checkingUrl = "http://host.com/path?arg=1", + expectedResult = true + ) + + // no prefix trigger + runTest( + dbItems = listOf("host.com"), + checkingUrl = "http://prefixed-host.com", + expectedResult = false + ) + + // ignore host in query + runTest( + dbItems = listOf("host.com"), + checkingUrl = "http://valid.com?redirectUrl=host.com", + expectedResult = false + ) + + // top url is always phishing + runTest( + dbItems = listOf(), + checkingUrl = "http://invalid.host.top", + expectedResult = true + ) + } + } + + private suspend fun runTest( + dbItems: List, + checkingUrl: String, + expectedResult: Boolean + ) { + reset(phishingDao) + given(phishingDao.isPhishing(any())).willAnswer { mock -> + val hostSuffixes = mock.getArgument>(0).toSet() + + dbItems.any { it in hostSuffixes } + } + + val isPhishing = phishingSiteRepository.isPhishing(checkingUrl) + + assertEquals(expectedResult, isPhishing) + } +} diff --git a/bindings/hydra-dx-math/.gitignore b/bindings/hydra-dx-math/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/bindings/hydra-dx-math/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/bindings/hydra-dx-math/build.gradle b/bindings/hydra-dx-math/build.gradle new file mode 100644 index 0000000..30980e1 --- /dev/null +++ b/bindings/hydra-dx-math/build.gradle @@ -0,0 +1,39 @@ +apply plugin: 'org.mozilla.rust-android-gradle.rust-android' + +android { + + ndkVersion "26.1.10909125" + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.hydra_dx_math' +} + +dependencies { + implementation kotlinDep + implementation project(':common') + + testImplementation jUnitDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} + +cargo { + module = "rust/" + libname = "hydra_dx_math_java" + targets = ["arm", "arm64", "x86", "x86_64"] + profile = "release" + pythonCommand = "python3" +} + +tasks.matching { it.name.matches(/merge.*JniLibFolders/) }.configureEach { + it.inputs.dir(new File(buildDir, "rustJniLibs/android")) + it.dependsOn("cargoBuild") +} diff --git a/bindings/hydra-dx-math/rust/.gitignore b/bindings/hydra-dx-math/rust/.gitignore new file mode 100644 index 0000000..ec376bb --- /dev/null +++ b/bindings/hydra-dx-math/rust/.gitignore @@ -0,0 +1,2 @@ +.idea +target \ No newline at end of file diff --git a/bindings/hydra-dx-math/rust/Cargo.lock b/bindings/hydra-dx-math/rust/Cargo.lock new file mode 100644 index 0000000..5115e38 --- /dev/null +++ b/bindings/hydra-dx-math/rust/Cargo.lock @@ -0,0 +1,2916 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "ark-bls12-377" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb00293ba84f51ce3bd026bd0de55899c4e68f0a39a5728cebae3a73ffdc0a4f" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-bls12-381" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools 0.10.5", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "array-bytes" +version = "6.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5dde061bd34119e902bbb2d9b90c5692635cf59fb91d582c2b68043f1b8293" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "az" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals", + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bounded-collections" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ad8a0bed7827f0b07a5d23cec2e58cc02038a99e4ca81616cb2bb2025f804d" +dependencies = [ + "log", + "parity-scale-codec", + "scale-info", + "serde", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "bytemuck" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "common-path" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2382f75942f4b3be3690fe4f86365e9c853c1587d6ee58212cebf6e2a9ccd101" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126f97965c8ad46d6d9163268ff28432e8f6a1196a55578867832e3049df63dd" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive-syn-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "docify" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a772b62b1837c8f060432ddcc10b17aae1453ef17617a99bc07789252d2a5896" +dependencies = [ + "docify_macros", +] + +[[package]] +name = "docify_macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e6be249b0a462a14784a99b19bf35a667bb5e09de611738bb7362fa4c95ff7" +dependencies = [ + "common-path", + "derive-syn-parse", + "once_cell", + "proc-macro2", + "quote", + "regex", + "syn 2.0.106", + "termcolor", + "toml", + "walkdir", +] + +[[package]] +name = "dyn-clonable" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a36efbb9bfd58e1723780aa04b61aba95ace6a05d9ffabfdb0b43672552f0805" +dependencies = [ + "dyn-clonable-impl", + "dyn-clone", +] + +[[package]] +name = "dyn-clonable-impl" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8671d54058979a37a26f3511fbf8d198ba1aa35ffb202c42587d918d77213a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-zebra" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0017d969298eec91e3db7a2985a8cab4df6341d86e6f3a6f5878b13fb7846bc9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "hashbrown 0.15.5", + "pkcs8", + "rand_core", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "environmental" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48c92028aaa870e83d51c64e5d4e0b6981b360c522198c23959f219a4e1b15b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[package]] +name = "expander" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c470c71d91ecbd179935b24170459e926382eaaa86b590b78814e180d8a8e2" +dependencies = [ + "blake2", + "file-guard", + "fs-err", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "file-guard" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fixed" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a65312835c1097a0c926ff3702df965285fadc33d948b87397ff8961bad881" +dependencies = [ + "az", + "bytemuck", + "half", + "num-traits", + "typenum", +] + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", + "num_cpus", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom_or_panic" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" +dependencies = [ + "rand", + "rand_core", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hash-db" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e7d7786361d7425ae2fe4f9e407eb0efaa0840f5212d109cc018c40c35c6ab4" + +[[package]] +name = "hash256-std-hasher" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c171d55b98633f4ed3860808f004099b36c1cc29c42cfc53aa8591b21efcf2" +dependencies = [ + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hydra-dx-math" +version = "10.3.0" +source = "git+https://github.com/galacticcouncil/HydraDX-node#09f399af6fd3477fb41c10d0067f6d0e6016d804" +dependencies = [ + "fixed", + "num-traits", + "parity-scale-codec", + "primitive-types", + "scale-info", + "serde", + "sp-arithmetic", + "sp-core", + "sp-std", +] + +[[package]] +name = "hydra-dx-math-java" +version = "0.1.0" +dependencies = [ + "hydra-dx-math", + "jni", + "serde", + "serde-aux", + "serde_json", + "sp-arithmetic", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-serde" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc88fc67028ae3db0c853baa36269d398d5f45b6982f95549ff5def78c935cd" +dependencies = [ + "serde", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", +] + +[[package]] +name = "integer-sqrt" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770" +dependencies = [ + "num-traits", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bcc950632e48b86da402c5c077590583da5ac0d480103611d5374e7c967a3c" +dependencies = [ + "cesu8", + "combine", + "error-chain", + "jni-sys", + "log", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2 0.10.9", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64", + "digest 0.9.0", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand", + "serde", + "sha2 0.9.9", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core", + "zeroize", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parity-bip39" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e69bf016dc406eff7d53a7d3f7cf1c2e72c82b9088aac1118591e36dd2cd3e9" +dependencies = [ + "bitcoin_hashes", + "rand", + "rand_core", + "serde", + "unicode-normalization", +] + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "bytes", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "password-hash", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polkavm-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9428a5cfcc85c5d7b9fc4b6a18c4b802d0173d768182a51cc7751640f08b92" + +[[package]] +name = "polkavm-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8c4bea6f3e11cd89bb18bcdddac10bd9a24015399bd1c485ad68a985a19606" +dependencies = [ + "polkavm-derive-impl-macro", +] + +[[package]] +name = "polkavm-derive-impl" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fdfc49717fb9a196e74a5d28e0bc764eb394a2c803eb11133a31ac996c60c" +dependencies = [ + "polkavm-common", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "polkavm-derive-impl-macro" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ba81f7b5faac81e528eb6158a6f3c9e0bb1008e0ffa19653bc8dea925ecb429" +dependencies = [ + "polkavm-derive-impl", + "syn 2.0.106", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.106", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "impl-serde", + "scale-info", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.9.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scale-info" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" +dependencies = [ + "bitvec", + "cfg-if", + "derive_more", + "parity-scale-codec", + "scale-info-derive", + "serde", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" +dependencies = [ + "aead", + "arrayref", + "arrayvec", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" +dependencies = [ + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" +dependencies = [ + "cc", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-aux" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a" +dependencies = [ + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "sp-arithmetic" +version = "26.0.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "docify", + "integer-sqrt", + "num-traits", + "parity-scale-codec", + "scale-info", + "serde", + "static_assertions", +] + +[[package]] +name = "sp-core" +version = "34.0.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "array-bytes", + "bitflags 1.3.2", + "blake2", + "bounded-collections", + "bs58", + "dyn-clonable", + "ed25519-zebra", + "futures", + "hash-db", + "hash256-std-hasher", + "impl-serde", + "itertools 0.11.0", + "k256", + "libsecp256k1", + "log", + "merlin", + "parity-bip39", + "parity-scale-codec", + "parking_lot", + "paste", + "primitive-types", + "rand", + "scale-info", + "schnorrkel", + "secp256k1", + "secrecy", + "serde", + "sp-crypto-hashing", + "sp-debug-derive", + "sp-externalities", + "sp-runtime-interface", + "sp-std", + "sp-storage", + "ss58-registry", + "substrate-bip39", + "thiserror", + "tracing", + "w3f-bls", + "zeroize", +] + +[[package]] +name = "sp-crypto-hashing" +version = "0.1.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "blake2b_simd", + "byteorder", + "digest 0.10.7", + "sha2 0.10.9", + "sha3", + "twox-hash", +] + +[[package]] +name = "sp-debug-derive" +version = "14.0.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sp-externalities" +version = "0.29.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "environmental", + "parity-scale-codec", + "sp-storage", +] + +[[package]] +name = "sp-runtime-interface" +version = "28.0.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "bytes", + "impl-trait-for-tuples", + "parity-scale-codec", + "polkavm-derive", + "primitive-types", + "sp-externalities", + "sp-runtime-interface-proc-macro", + "sp-std", + "sp-storage", + "sp-tracing", + "sp-wasm-interface", + "static_assertions", +] + +[[package]] +name = "sp-runtime-interface-proc-macro" +version = "18.0.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "Inflector", + "expander", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sp-std" +version = "14.0.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" + +[[package]] +name = "sp-storage" +version = "21.0.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "impl-serde", + "parity-scale-codec", + "ref-cast", + "serde", + "sp-debug-derive", +] + +[[package]] +name = "sp-tracing" +version = "17.0.1" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "parity-scale-codec", + "tracing", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sp-wasm-interface" +version = "21.0.1" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "anyhow", + "impl-trait-for-tuples", + "log", + "parity-scale-codec", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ss58-registry" +version = "1.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19409f13998e55816d1c728395af0b52ec066206341d939e22e7766df9b494b8" +dependencies = [ + "Inflector", + "num-format", + "proc-macro2", + "quote", + "serde", + "serde_json", + "unicode-xid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "substrate-bip39" +version = "0.6.0" +source = "git+https://github.com/galacticcouncil/polkadot-sdk?branch=stable2409-patch6#1ed662757b03c4f17dc2429c1df84e975d98513a" +dependencies = [ + "hmac", + "pbkdf2", + "schnorrkel", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "time", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "digest 0.10.7", + "rand", + "static_assertions", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "w3f-bls" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6bfb937b3d12077654a9e43e32a4e9c20177dd9fea0f3aba673e7840bb54f32" +dependencies = [ + "ark-bls12-377", + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-serialize-derive", + "arrayref", + "digest 0.10.7", + "rand", + "rand_chacha", + "rand_core", + "sha2 0.10.9", + "sha3", + "zeroize", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/bindings/hydra-dx-math/rust/Cargo.toml b/bindings/hydra-dx-math/rust/Cargo.toml new file mode 100644 index 0000000..0be8353 --- /dev/null +++ b/bindings/hydra-dx-math/rust/Cargo.toml @@ -0,0 +1,29 @@ +[package] +authors = ['Novasama Technologies'] +edition = '2021' +license = 'Apache 2.0' +name = "hydra-dx-math-java" +repository = 'https://github.com/nova-wallet/nova-wallet-android' +version = "0.1.0" + +[dependencies] +serde = { version = "1.0.169", features = ["derive"] } +serde_json = "1.0.100" +serde-aux = "4.2.0" +sp-arithmetic = { git = "https://github.com/galacticcouncil/polkadot-sdk", branch = "stable2409-patch6", default-features = false } +hydra-dx-math = { git = "https://github.com/galacticcouncil/HydraDX-node", version="10.3.0"} +jni = { version = "0.17.0", default-features = false } + +[profile.release] +strip = true +lto = true +opt-level = "s" + +[lib] +name = "hydra_dx_math_java" +crate_type = ["cdylib"] + +[features] +default = ["std"] +std = ["sp-arithmetic/std"] +stableswap = [] \ No newline at end of file diff --git a/bindings/hydra-dx-math/rust/src/lib.rs b/bindings/hydra-dx-math/rust/src/lib.rs new file mode 100644 index 0000000..f789ede --- /dev/null +++ b/bindings/hydra-dx-math/rust/src/lib.rs @@ -0,0 +1,732 @@ +#![allow(non_snake_case)] + +extern crate core; +extern crate hydra_dx_math; +extern crate jni; +extern crate serde; +extern crate sp_arithmetic; + +use std::collections::HashMap; + +use hydra_dx_math::stableswap::types::AssetReserve; +use jni::objects::{JClass, JString}; +use jni::sys::jint; +use jni::JNIEnv; +use serde::Deserialize; +use sp_arithmetic::per_things::Permill; +use serde_aux::prelude::*; + +fn error() -> String { + "-1".to_string() +} + +macro_rules! parse_into { + ($x:ty, $y:expr) => {{ + let r = if let Some(x) = $y.parse::<$x>().ok() { + x + } else { + println!("Parse failed"); + return error(); + }; + r + }}; +} + +const D_ITERATIONS: u8 = 128; +const Y_ITERATIONS: u8 = 64; + +#[derive(Deserialize, Copy, Clone, Debug)] +pub struct AssetBalance { + asset_id: u32, + #[serde(deserialize_with = "deserialize_number_from_string")] + amount: u128, + decimals: u8, +} + +impl From<&AssetBalance> for AssetReserve { + fn from(value: &AssetBalance) -> Self { + Self { + amount: value.amount, + decimals: value.decimals, + } + } +} + +#[derive(Deserialize, Copy, Clone, Debug)] +pub struct AssetAmount { + asset_id: u32, + #[serde(deserialize_with = "deserialize_number_from_string")] + amount: u128, +} + +// Tuple struct to apply per-field deserializers on u128s +#[derive(Deserialize, Copy, Clone, Debug)] +struct U128Pair( + #[serde(deserialize_with = "deserialize_number_from_string")] u128, + #[serde(deserialize_with = "deserialize_number_from_string")] u128, +); + +// Parse JSON like: [["0","0"],["1000000000000","500000000000"],["42","1337"]] +fn parse_pairs(json: &str) -> Option> { + let v: serde_json::Result> = serde_json::from_str(json); + match v { + Ok(vecp) => Some(vecp.into_iter().map(|p| (p.0, p.1)).collect()), + Err(_) => None, + } +} + +fn get_str<'a>(jni: &'a JNIEnv<'a>, string: JString<'a>) -> String { + jni.get_string(string).unwrap().to_str().unwrap().to_string() +} + +/* ---------------- STABLESWAP ---------------- */ + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_stableswap_StableSwapMathBridge_calculate_1out_1given_1in<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + reserves: JString, + asset_in: jint, + asset_out: jint, + amount_in: JString, + amplification: JString, + fee: JString, + pegs: JString, +) -> JString<'a> { + let reserves = get_str(&jni_env, reserves); + let asset_in = asset_in as u32; + let asset_out = asset_out as u32; + let amount_in = get_str(&jni_env, amount_in); + let amplification = get_str(&jni_env, amplification); + let fee = get_str(&jni_env, fee); + let pegs = get_str(&jni_env, pegs); + + let out = calculate_out_given_in( + reserves, + asset_in, + asset_out, + amount_in, + amplification, + fee, + pegs, + ); + + jni_env.new_string(out).unwrap() +} + +fn calculate_out_given_in( + reserves: String, + asset_in: u32, + asset_out: u32, + amount_in: String, + amplification: String, + fee: String, + pegs: String, +) -> String { + let reserves: serde_json::Result> = serde_json::from_str(&reserves); + if reserves.is_err() { + return error(); + } + let mut reserves = reserves.unwrap(); + reserves.sort_by_key(|v| v.asset_id); + + let idx_in = reserves.iter().position(|v| v.asset_id == asset_in); + let idx_out = reserves.iter().position(|v| v.asset_id == asset_out); + + if idx_in.is_none() || idx_out.is_none() { + return error(); + } + + let amount_in = parse_into!(u128, amount_in); + let amplification = parse_into!(u128, amplification); + let fee = Permill::from_float(parse_into!(f64, fee)); + let balances: Vec = reserves.iter().map(|v| v.into()).collect(); + + // Parse 7th param + let pairs = match parse_pairs(&pegs) { + Some(p) => p, + None => return error(), + }; + + let result = hydra_dx_math::stableswap::calculate_out_given_in_with_fee::( + &balances, + idx_in.unwrap(), + idx_out.unwrap(), + amount_in, + amplification, + fee, + &pairs, + ); + + if let Some(r) = result { + r.0.to_string() + } else { + error() + } +} + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_stableswap_StableSwapMathBridge_calculate_1in_1given_1out<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + reserves: JString, + asset_in: jint, + asset_out: jint, + amount_in: JString, + amplification: JString, + fee: JString, + pegs: JString, +) -> JString<'a> { + let reserves = get_str(&jni_env, reserves); + let asset_in = asset_in as u32; + let asset_out = asset_out as u32; + let amount_in = get_str(&jni_env, amount_in); + let amplification = get_str(&jni_env, amplification); + let fee = get_str(&jni_env, fee); + let pegs = get_str(&jni_env, pegs); + + let result = calculate_in_given_out( + reserves, + asset_in, + asset_out, + amount_in, + amplification, + fee, + pegs, + ); + + jni_env.new_string(result).unwrap() +} + +fn calculate_in_given_out( + reserves: String, + asset_in: u32, + asset_out: u32, + amount_out: String, + amplification: String, + fee: String, + pegs: String, +) -> String { + let reserves: serde_json::Result> = serde_json::from_str(&reserves); + if reserves.is_err() { + return error(); + } + let mut reserves = reserves.unwrap(); + reserves.sort_by_key(|v| v.asset_id); + + let idx_in = reserves.iter().position(|v| v.asset_id == asset_in); + let idx_out = reserves.iter().position(|v| v.asset_id == asset_out); + + if idx_in.is_none() || idx_out.is_none() { + return error(); + } + + let amount_out = parse_into!(u128, amount_out); + let amplification = parse_into!(u128, amplification); + let fee = Permill::from_float(parse_into!(f64, fee)); + + let balances: Vec = reserves.iter().map(|v| v.into()).collect(); + + // Parse 7th param + let pairs = match parse_pairs(&pegs) { + Some(p) => p, + None => return error(), + }; + + let result = + hydra_dx_math::stableswap::calculate_in_given_out_with_fee::( + &balances, + idx_in.unwrap(), + idx_out.unwrap(), + amount_out, + amplification, + fee, + &pairs, + ); + + if let Some(r) = result { + r.0.to_string() + } else { + error() + } +} + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_stableswap_StableSwapMathBridge_calculate_1amplification<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + initial_amplification: JString, + final_amplification: JString, + initial_block: JString, + final_block: JString, + current_block: JString, +) -> JString<'a> { + let initial_amplification = get_str(&jni_env, initial_amplification); + let final_amplification = get_str(&jni_env, final_amplification); + let initial_block = get_str(&jni_env, initial_block); + let final_block = get_str(&jni_env, final_block); + let current_block = get_str(&jni_env, current_block); + + let result = calculate_amplification( + initial_amplification, + final_amplification, + initial_block, + final_block, + current_block, + ); + + jni_env.new_string(result).unwrap() +} + +fn calculate_amplification( + initial_amplification: String, + final_amplification: String, + initial_block: String, + final_block: String, + current_block: String, +) -> String { + let initial_amplification = parse_into!(u128, initial_amplification); + let final_amplification = parse_into!(u128, final_amplification); + let initial_block = parse_into!(u128, initial_block); + let final_block = parse_into!(u128, final_block); + let current_block = parse_into!(u128, current_block); + + hydra_dx_math::stableswap::calculate_amplification( + initial_amplification, + final_amplification, + initial_block, + final_block, + current_block, + ) + .to_string() +} + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_stableswap_StableSwapMathBridge_calculate_1shares<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + reserves: JString, + assets: JString, + amplification: JString, + share_issuance: JString, + fee: JString, + pegs: JString, +) -> JString<'a> { + let reserves = get_str(&jni_env, reserves); + let assets = get_str(&jni_env, assets); + let amplification = get_str(&jni_env, amplification); + let share_issuance = get_str(&jni_env, share_issuance); + let fee = get_str(&jni_env, fee); + let pegs = get_str(&jni_env, pegs); + + let result = calculate_shares(reserves, assets, amplification, share_issuance, fee, pegs); + + jni_env.new_string(result).unwrap() +} + +fn calculate_shares( + reserves: String, + assets: String, + amplification: String, + share_issuance: String, + fee: String, + pegs: String, +) -> String { + let reserves: serde_json::Result> = serde_json::from_str(&reserves); + if reserves.is_err() { + return error(); + } + let mut reserves = reserves.unwrap(); + reserves.sort_by_key(|v| v.asset_id); + + let assets: serde_json::Result> = serde_json::from_str(&assets); + if assets.is_err() { + return error(); + } + let assets = assets.unwrap(); + if assets.len() > reserves.len() { + return error(); + } + + let mut updated_reserves = reserves.clone(); + + let mut liquidity: HashMap = HashMap::new(); + for a in assets.iter() { + let r = liquidity.insert(a.asset_id, a.amount); + if r.is_some() { + return error(); + } + } + for reserve in updated_reserves.iter_mut() { + if let Some(v) = liquidity.get(&reserve.asset_id) { + reserve.amount += v; + } + } + let balances: Vec = reserves.iter().map(|v| v.into()).collect(); + let updated_balances: Vec = updated_reserves.iter().map(|v| v.into()).collect(); + let amplification = parse_into!(u128, amplification); + let issuance = parse_into!(u128, share_issuance); + let fee = Permill::from_float(parse_into!(f64, fee)); + + let pairs = match parse_pairs(&pegs) { + Some(p) => p, + None => return error(), + }; + + let result = hydra_dx_math::stableswap::calculate_shares::( + &balances, + &updated_balances, + amplification, + issuance, + fee, + &pairs, + ); + + if let Some(r) = result { + r.0.to_string() + } else { + error() + } +} + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_stableswap_StableSwapMathBridge_calculate_1shares_1for_1amount<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + reserves: JString, + asset_in: jint, + amount: JString, + amplification: JString, + share_issuance: JString, + fee: JString, + pegs: JString, +) -> JString<'a> { + let reserves = get_str(&jni_env, reserves); + let asset_in = asset_in as u32; + let amount = get_str(&jni_env, amount); + let amplification = get_str(&jni_env, amplification); + let share_issuance = get_str(&jni_env, share_issuance); + let fee = get_str(&jni_env, fee); + let pegs = get_str(&jni_env, pegs); + + let result = calculate_shares_for_amount( + reserves, + asset_in, + amount, + amplification, + share_issuance, + fee, + pegs, + ); + + jni_env.new_string(result).unwrap() +} + +fn calculate_shares_for_amount( + reserves: String, + asset_in: u32, + amount: String, + amplification: String, + share_issuance: String, + fee: String, + pegs: String, +) -> String { + let reserves: serde_json::Result> = serde_json::from_str(&reserves); + if reserves.is_err() { + return error(); + } + let mut reserves = reserves.unwrap(); + reserves.sort_by_key(|v| v.asset_id); + let idx_in = reserves.iter().position(|v| v.asset_id == asset_in); + if idx_in.is_none() { + return error(); + } + let amount_in = parse_into!(u128, amount); + let balances: Vec = reserves.iter().map(|v| v.into()).collect(); + let amplification = parse_into!(u128, amplification); + let issuance = parse_into!(u128, share_issuance); + let fee = Permill::from_float(parse_into!(f64, fee)); + + // Parse 7th param + let pairs = match parse_pairs(&pegs) { + Some(p) => p, + None => return error(), + }; + + let result = hydra_dx_math::stableswap::calculate_shares_for_amount::( + &balances, + idx_in.unwrap(), + amount_in, + amplification, + issuance, + fee, + &pairs, + ); + + if let Some(r) = result { + r.0.to_string() + } else { + error() + } +} + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_stableswap_StableSwapMathBridge_calculate_1add_1one_1asset<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + reserves: JString, + shares: JString, + asset_in: jint, + amplification: JString, + share_issuance: JString, + fee: JString, + pegs: JString, +) -> JString<'a> { + let reserves = get_str(&jni_env, reserves); + let shares = get_str(&jni_env, shares); + let asset_in = asset_in as u32; + let amplification = get_str(&jni_env, amplification); + let share_issuance = get_str(&jni_env, share_issuance); + let fee = get_str(&jni_env, fee); + let pegs = get_str(&jni_env, pegs); + + let result = calculate_add_one_asset( + reserves, + shares, + asset_in, + amplification, + share_issuance, + fee, + pegs, + ); + + jni_env.new_string(result).unwrap() +} + +fn calculate_add_one_asset( + reserves: String, + shares: String, + asset_in: u32, + amplification: String, + share_issuance: String, + fee: String, + pegs: String, +) -> String { + let reserves: serde_json::Result> = serde_json::from_str(&reserves); + if reserves.is_err() { + return error(); + } + let mut reserves = reserves.unwrap(); + reserves.sort_by_key(|v| v.asset_id); + let idx_in = reserves.iter().position(|v| v.asset_id == asset_in); + if idx_in.is_none() { + return error(); + } + + let balances: Vec = reserves.iter().map(|v| v.into()).collect(); + let shares = parse_into!(u128, shares); + let amplification = parse_into!(u128, amplification); + let issuance = parse_into!(u128, share_issuance); + let fee = Permill::from_float(parse_into!(f64, fee)); + + let pairs = match parse_pairs(&pegs) { + Some(p) => p, + None => return error(), + }; + + let result = hydra_dx_math::stableswap::calculate_add_one_asset::( + &balances, + shares, + idx_in.unwrap(), + issuance, + amplification, + fee, + &pairs, + ); + + if let Some(r) = result { + r.0.to_string() + } else { + error() + } +} + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_stableswap_StableSwapMathBridge_calculate_1liquidity_1out_1one_1asset<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + reserves: JString, + shares: JString, + asset_out: jint, + amplification: JString, + share_issuance: JString, + withdraw_fee: JString, + pegs: JString, +) -> JString<'a> { + let reserves = get_str(&jni_env, reserves); + let shares = get_str(&jni_env, shares); + let asset_out = asset_out as u32; + let amplification = get_str(&jni_env, amplification); + let share_issuance = get_str(&jni_env, share_issuance); + let withdraw_fee = get_str(&jni_env, withdraw_fee); + let pegs = get_str(&jni_env, pegs); + + let result = calculate_liquidity_out_one_asset( + reserves, + shares, + asset_out, + amplification, + share_issuance, + withdraw_fee, + pegs, + ); + + jni_env.new_string(result).unwrap() +} + +fn calculate_liquidity_out_one_asset( + reserves: String, + shares: String, + asset_out: u32, + amplification: String, + share_issuance: String, + withdraw_fee: String, + pegs: String, +) -> String { + let reserves: serde_json::Result> = serde_json::from_str(&reserves); + if reserves.is_err() { + return error(); + } + let mut reserves = reserves.unwrap(); + reserves.sort_by_key(|v| v.asset_id); + + let idx_out = reserves.iter().position(|v| v.asset_id == asset_out); + if idx_out.is_none() { + println!("idx_out error"); + return error(); + } + + let shares_out = parse_into!(u128, shares); + let amplification = parse_into!(u128, amplification); + let issuance = parse_into!(u128, share_issuance); + let fee = Permill::from_float(parse_into!(f64, withdraw_fee)); + + let balances: Vec = reserves.iter().map(|v| v.into()).collect(); + + let pairs = match parse_pairs(&pegs) { + Some(p) => p, + None => { println!("parse_pairs error"); return error(); }, + }; + + let result = hydra_dx_math::stableswap::calculate_withdraw_one_asset::( + &balances, + shares_out, + idx_out.unwrap(), + issuance, + amplification, + fee, + &pairs, + ); + + if let Some(r) = result { + r.0.to_string() + } else { + println!("final result error"); + error() + } +} + +/* ---------------- XYK ---------------------- */ + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_xyk_HYKSwapMathBridge_calculate_1out_1given_1in<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + balance_in: JString, + balance_out: JString, + amount_in: JString, +) -> JString<'a> { + let balance_in: String = get_str(&jni_env, balance_in); + let balance_out: String = get_str(&jni_env, balance_out); + let amount_in: String = get_str(&jni_env, amount_in); + + let out = xyk_calculate_out_given_in(balance_in, balance_out, amount_in); + + jni_env.new_string(out).unwrap() +} + +fn xyk_calculate_out_given_in(balance_in: String, balance_out: String, amount_in: String) -> String { + let balance_in = parse_into!(u128, balance_in); + let balance_out = parse_into!(u128, balance_out); + let amount_in = parse_into!(u128, amount_in); + + let result = hydra_dx_math::xyk::calculate_out_given_in(balance_in, balance_out, amount_in); + + if let Ok(r) = result { + r.to_string() + } else { + error() + } +} + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_xyk_HYKSwapMathBridge_calculate_1in_1given_1out<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + balance_in: JString, + balance_out: JString, + amount_in: JString, +) -> JString<'a> { + let balance_in: String = get_str(&jni_env, balance_in); + let balance_out: String = get_str(&jni_env, balance_out); + let amount_in: String = get_str(&jni_env, amount_in); + + let out = xyk_calculate_in_given_out(balance_in, balance_out, amount_in); + + jni_env.new_string(out).unwrap() +} + +fn xyk_calculate_in_given_out(balance_in: String, balance_out: String, amount_out: String) -> String { + let balance_in = parse_into!(u128, balance_in); + let balance_out = parse_into!(u128, balance_out); + let amount_out = parse_into!(u128, amount_out); + + let result = hydra_dx_math::xyk::calculate_in_given_out(balance_out, balance_in, amount_out); + + if let Ok(r) = result { + r.to_string() + } else { + error() + } +} + +#[no_mangle] +pub fn Java_io_novafoundation_nova_hydra_1dx_1math_xyk_HYKSwapMathBridge_calculate_1pool_1trade_1fee<'a>( + jni_env: JNIEnv<'a>, + _: JClass, + amount: JString, + fee_nominator: JString, + fee_denominator: JString, +) -> JString<'a> { + let amount: String = get_str(&jni_env, amount); + let fee_nominator: String = get_str(&jni_env, fee_nominator); + let fee_denominator: String = get_str(&jni_env, fee_denominator); + + let out = calculate_pool_trade_fee(amount, fee_nominator, fee_denominator); + + jni_env.new_string(out).unwrap() +} + +fn calculate_pool_trade_fee(amount: String, fee_nominator: String, fee_denominator: String) -> String { + let amount = parse_into!(u128, amount); + let fee_nominator = parse_into!(u32, fee_nominator); + let fee_denominator = parse_into!(u32, fee_denominator); + + let result = hydra_dx_math::fee::calculate_pool_trade_fee(amount, (fee_nominator, fee_denominator)); + + if let Some(r) = result { + r.to_string() + } else { + error() + } +} \ No newline at end of file diff --git a/bindings/hydra-dx-math/src/androidTest/java/io/novafoundation/nova/hydra_dx_math/stableswap/StableSwapTest.kt b/bindings/hydra-dx-math/src/androidTest/java/io/novafoundation/nova/hydra_dx_math/stableswap/StableSwapTest.kt new file mode 100644 index 0000000..ffdcd71 --- /dev/null +++ b/bindings/hydra-dx-math/src/androidTest/java/io/novafoundation/nova/hydra_dx_math/stableswap/StableSwapTest.kt @@ -0,0 +1,279 @@ +package io.novafoundation.nova.hydra_dx_math.stableswap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Ignore +import org.junit.Test + +class StableSwapTest { + + @Test + fun shouldCalculateOutGivenIn() { + val data = """ + [{ + "asset_id": 1, + "amount": "1000000000000", + "decimals": 12 + }, + { + "asset_id": 0, + "amount": "1000000000000", + "decimals": 12 + } + ] + """ + + val result = StableSwapMathBridge.calculate_out_given_in( + data, + 0, + 1, + "1000000000", + "1", + "0", + "" + ) + + assertEquals("999500248", result) + } + + @Test + fun shouldCalculateInGiveOut() { + val data = """ + [{ + "asset_id": 1, + "amount": "1000000000000", + "decimals": 12 + }, + { + "asset_id": 0, + "amount": "1000000000000", + "decimals": 12 + } + ] + """ + + val result = StableSwapMathBridge.calculate_in_given_out( + data, + 0, + 1, + "1000000000", + "1", + "0", + "" + ) + + assertNotEquals("-1", result) + } + + @Test + fun shouldCalculateAmplification() { + val result = StableSwapMathBridge.calculate_amplification("10", "10", "0", "100", "50") + + assertEquals("10", result) + } + + @Test + fun shouldCalculateShares() { + val data = """ + [{ + "asset_id": 0, + "amount":"90000000000", + "decimals": 12 + }, + { + "asset_id": 1, + "amount": "5000000000000000000000", + "decimals": 12 + } + ] + """ + + val assets = """ + [{"asset_id":1,"amount":"43000000000000000000"}] + """ + + val result = StableSwapMathBridge.calculate_shares( + data, + assets, + "1000", + "64839594451719860", + "0", + "" + ) + + assertEquals("371541351762585", result.toString()) + } + + @Test + fun shouldCalculateSharesForAmount() { + val data = """ + [ + { + "asset_id": 0, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 1, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 2, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 3, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 4, + "amount": "10000000000000000", + "decimals": 12 + } +] + """ + + val result = StableSwapMathBridge.calculate_shares_for_amount( + data, + 0, + "100000000000000", + "100", + "20000000000000000000000", + "0", + "" + ) + + assertEquals("40001593768209443008", result.toString()) + } + + @Test + @Ignore("The test fails with last digit being 0 instead of 1. We need to check why it happens later") + fun shouldCalculateAddOneAsset() { + val data = """ + [ + { + "asset_id": 0, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 1, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 2, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 3, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 4, + "amount": "10000000000000000", + "decimals": 12 + } +] + """ + + val result = StableSwapMathBridge.calculate_add_one_asset( + data, + "399850144492663029649", + 2, + "100", + "20000000000000000000000", + "0", + "" + ) + + assertEquals("1000000000000001", result.toString()) + } + + @Test + fun shouldcalculateLiquidityOutOneAsset() { + val data = """ + [ + { + "asset_id": 0, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 1, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 2, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 3, + "amount": "10000000000000000", + "decimals": 12 + }, + { + "asset_id": 4, + "amount": "10000000000000000", + "decimals": 12 + } +] + """ + + val result = StableSwapMathBridge.calculate_liquidity_out_one_asset( + data, + "40001593768209443008", + 0, + "100", + "20000000000000000000000", + "0", + "" + ) + + assertEquals("99999999999999", result.toString()) + } + + @Test + fun failingCase() { + val data = """ + [{"amount":"2246975221087","decimals":6,"asset_id":10},{"amount":"2256486088023","decimals":6,"asset_id":22}] + """ + + val result = StableSwapMathBridge.calculate_liquidity_out_one_asset( + data, + "1000000000", + 10, + "100", + "4502091550542833181457210", + "0.00040", + "" + ) + + assertEquals("99999999999999", result.toString()) + } + + @Test + fun failingCase2() { + val data = """ +[{"amount":"505342304916","decimals":6,"asset_id":10},{"amount":"368030436758902944990436","decimals":18,"asset_id":18},{"amount":"410374848833","decimals":6,"asset_id":21},{"amount":"0","decimals":6,"asset_id":23}] """ + + val result = StableSwapMathBridge.calculate_shares_for_amount( + data, + 10, + "10", + "320", + "1662219218861236418723363", + "0.00040", + "" + ) + + assertEquals("99999999999999", result.toString()) + } +} diff --git a/bindings/hydra-dx-math/src/main/AndroidManifest.xml b/bindings/hydra-dx-math/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/bindings/hydra-dx-math/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/bindings/hydra-dx-math/src/main/java/io/novafoundation/nova/hydra_dx_math/HydraDxMathConversions.kt b/bindings/hydra-dx-math/src/main/java/io/novafoundation/nova/hydra_dx_math/HydraDxMathConversions.kt new file mode 100644 index 0000000..a2bc6c1 --- /dev/null +++ b/bindings/hydra-dx-math/src/main/java/io/novafoundation/nova/hydra_dx_math/HydraDxMathConversions.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.hydra_dx_math + +import io.novafoundation.nova.common.utils.atLeastZero +import java.math.BigInteger + +object HydraDxMathConversions { + + fun String.fromBridgeResultToBalance(): BigInteger? { + return if (this == "-1") null else toBigInteger().atLeastZero() + } +} diff --git a/bindings/hydra-dx-math/src/main/java/io/novafoundation/nova/hydra_dx_math/stableswap/StableSwapMathBridge.java b/bindings/hydra-dx-math/src/main/java/io/novafoundation/nova/hydra_dx_math/stableswap/StableSwapMathBridge.java new file mode 100644 index 0000000..d8dacc3 --- /dev/null +++ b/bindings/hydra-dx-math/src/main/java/io/novafoundation/nova/hydra_dx_math/stableswap/StableSwapMathBridge.java @@ -0,0 +1,75 @@ +package io.novafoundation.nova.hydra_dx_math.stableswap; + +public class StableSwapMathBridge { + + static { + System.loadLibrary("hydra_dx_math_java"); + } + + public static native String calculate_out_given_in( + String reserves, + int asset_in, + int asset_out, + String amount_in, + String amplification, + String fee, + String pegs + ); + + public static native String calculate_in_given_out( + String reserves, + int asset_in, + int asset_out, + String amount_out, + String amplification, + String fee, + String pegs + ); + + public static native String calculate_amplification( + String initial_amplification, + String final_amplification, + String initial_block, + String final_block, + String current_block + ); + + public static native String calculate_shares( + String reserves, + String assets, + String amplification, + String share_issuance, + String fee, + String pegs + ); + + public static native String calculate_shares_for_amount( + String reserves, + int asset_in, + String amount, + String amplification, + String share_issuance, + String fee, + String pegs + ); + + public static native String calculate_add_one_asset( + String reserves, + String shares, + int asset_in, + String amplification, + String share_issuance, + String fee, + String pegs + ); + + public static native String calculate_liquidity_out_one_asset( + String reserves, + String shares, + int asset_out, + String amplification, + String share_issuance, + String withdraw_fee, + String pegs + ); +} diff --git a/bindings/hydra-dx-math/src/main/java/io/novafoundation/nova/hydra_dx_math/xyk/HYKSwapMathBridge.java b/bindings/hydra-dx-math/src/main/java/io/novafoundation/nova/hydra_dx_math/xyk/HYKSwapMathBridge.java new file mode 100644 index 0000000..f9adec6 --- /dev/null +++ b/bindings/hydra-dx-math/src/main/java/io/novafoundation/nova/hydra_dx_math/xyk/HYKSwapMathBridge.java @@ -0,0 +1,26 @@ +package io.novafoundation.nova.hydra_dx_math.xyk; + +public class HYKSwapMathBridge { + + static { + System.loadLibrary("hydra_dx_math_java"); + } + + public static native String calculate_out_given_in( + String balanceIn, + String balanceOut, + String amountIn + ); + + public static native String calculate_in_given_out( + String balanceIn, + String balanceOut, + String amountOut + ); + + public static native String calculate_pool_trade_fee( + String amount, + String feeNumerator, + String feeDenominator + ); +} \ No newline at end of file diff --git a/bindings/metadata_shortener/.gitignore b/bindings/metadata_shortener/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/bindings/metadata_shortener/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/bindings/metadata_shortener/build.gradle b/bindings/metadata_shortener/build.gradle new file mode 100644 index 0000000..8cddfd1 --- /dev/null +++ b/bindings/metadata_shortener/build.gradle @@ -0,0 +1,38 @@ +apply plugin: 'org.mozilla.rust-android-gradle.rust-android' + +android { + namespace 'io.novafoundation.nova.metadata_shortener' + + ndkVersion "26.1.10909125" + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation kotlinDep + + testImplementation jUnitDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} + +cargo { + module = "rust/" + libname = "metadata_shortener_java" + targets = ["arm", "arm64", "x86", "x86_64"] + profile = "release" + pythonCommand = "python3" +} + +tasks.matching { it.name.matches(/merge.*JniLibFolders/) }.configureEach { + it.inputs.dir(new File(buildDir, "rustJniLibs/android")) + it.dependsOn("cargoBuild") +} diff --git a/bindings/metadata_shortener/consumer-rules.pro b/bindings/metadata_shortener/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/bindings/metadata_shortener/proguard-rules.pro b/bindings/metadata_shortener/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/bindings/metadata_shortener/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/bindings/metadata_shortener/rust/.gitignore b/bindings/metadata_shortener/rust/.gitignore new file mode 100644 index 0000000..ec376bb --- /dev/null +++ b/bindings/metadata_shortener/rust/.gitignore @@ -0,0 +1,2 @@ +.idea +target \ No newline at end of file diff --git a/bindings/metadata_shortener/rust/Cargo.lock b/bindings/metadata_shortener/rust/Cargo.lock new file mode 100644 index 0000000..d4e1b9b --- /dev/null +++ b/bindings/metadata_shortener/rust/Cargo.lock @@ -0,0 +1,535 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "array-bytes" +version = "6.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5dde061bd34119e902bbb2d9b90c5692635cf59fb91d582c2b68043f1b8293" + +[[package]] +name = "arrayref" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" + +[[package]] +name = "arrayvec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cca6d3674597c30ddf2c587bf8d9d65c9a84d2326d941cc79c9842dfe0ef52" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", +] + +[[package]] +name = "byte-slice-cast" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3ac9f8b63eca6fd385229b3675f6cc0dc5c8a5c8a54a59d4f52ffd670d87b0c" + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" + +[[package]] +name = "cc" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c51067fd44124faa7f870b4b1c969379ad32b2ba805aa959430ceaa384f695" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[package]] +name = "frame-metadata" +version = "16.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cf1549fba25a6fcac22785b61698317d958e96cac72a59102ea45b9ae64692" +dependencies = [ + "cfg-if", + "parity-scale-codec", + "scale-info", + "serde", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d7a9f6330b71fea57921c9b61c47ee6e84f72d394754eff6163ae67e7395eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "jni" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bcc950632e48b86da402c5c077590583da5ac0d480103611d5374e7c967a3c" +dependencies = [ + "cesu8", + "combine", + "error-chain", + "jni-sys", + "log", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "memchr" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0d8b92cd8358e8d229c11df9358decae64d137c5be540952c5ca7b25aea768" + +[[package]] +name = "merkleized-metadata" +version = "0.1.0" +source = "git+https://github.com/Zondax/merkleized-metadata?rev=cd1363a2c4702abf34fcc461055f0059b3c32bec#cd1363a2c4702abf34fcc461055f0059b3c32bec" +dependencies = [ + "array-bytes", + "blake3", + "frame-metadata", + "parity-scale-codec", + "scale-decode", + "scale-info", +] + +[[package]] +name = "metadata-shortener-java" +version = "0.1.0" +dependencies = [ + "array-bytes", + "frame-metadata", + "jni", + "merkleized-metadata", + "parity-scale-codec", +] + +[[package]] +name = "parity-scale-codec" +version = "3.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "306800abfa29c7f16596b5970a588435e3d5b3149683d00c12b699cc19f895ee" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d830939c76d294956402033aee57a6da7b438f2294eb94864c37b0569053a42c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "proc-macro-crate" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scale-bits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57b1e7f6b65ed1f04e79a85a57d755ad56d76fdf1e9bddcc9ae14f71fcdcf54" +dependencies = [ + "parity-scale-codec", + "scale-type-resolver", +] + +[[package]] +name = "scale-decode" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e10e0d345101a013ca3af62c2d44d20213d543cc64d96080c68d931e54360c5" +dependencies = [ + "derive_more", + "parity-scale-codec", + "scale-bits", + "scale-type-resolver", + "smallvec", +] + +[[package]] +name = "scale-info" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca070c12893629e2cc820a9761bedf6ce1dcddc9852984d1dc734b8bd9bd024" +dependencies = [ + "bitvec", + "cfg-if", + "derive_more", + "parity-scale-codec", + "scale-info-derive", + "serde", +] + +[[package]] +name = "scale-info-derive" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d35494501194174bda522a32605929eefc9ecf7e0a326c26db1fdd85881eb62" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "scale-type-resolver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0cded6518aa0bd6c1be2b88ac81bf7044992f0f154bfbabd5ad34f43512abcb" + +[[package]] +name = "serde" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "toml_datetime" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" + +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] diff --git a/bindings/metadata_shortener/rust/Cargo.toml b/bindings/metadata_shortener/rust/Cargo.toml new file mode 100644 index 0000000..af00aad --- /dev/null +++ b/bindings/metadata_shortener/rust/Cargo.toml @@ -0,0 +1,23 @@ +[package] +authors = ['Novasama Technologies'] +edition = '2021' +license = 'Apache 2.0' +name = "metadata-shortener-java" +repository = 'https://github.com/nova-wallet/nova-wallet-android' +version = "0.1.0" + +[dependencies] +array-bytes = "6.2.2" +merkleized-metadata = { git = "https://github.com/Zondax/merkleized-metadata", default-features = false, rev = "cd1363a2c4702abf34fcc461055f0059b3c32bec" } +jni = { version = "0.17.0", default-features = false } +frame-metadata = { version = "16.0.0", features = [ "current" ] } +codec = { package = "parity-scale-codec", version = "3.6.9", features = [ "derive" ] } + +[profile.release] +strip = true +lto = true +opt-level = "s" + +[lib] +name = "metadata_shortener_java" +crate_type = ["cdylib"] \ No newline at end of file diff --git a/bindings/metadata_shortener/rust/src/lib.rs b/bindings/metadata_shortener/rust/src/lib.rs new file mode 100644 index 0000000..86fa06f --- /dev/null +++ b/bindings/metadata_shortener/rust/src/lib.rs @@ -0,0 +1,214 @@ +#![allow(non_snake_case)] + +extern crate core; +extern crate jni; +extern crate merkleized_metadata; + +use codec::{Decode, Encode}; +use frame_metadata::{RuntimeMetadataPrefixed, OpaqueMetadata, RuntimeMetadata}; +use jni::{errors::Result as JniResult, sys::jint}; +use jni::objects::{JClass, JString}; +use jni::sys::jbyteArray; +use jni::JNIEnv; +use merkleized_metadata::{generate_metadata_digest, generate_proof_for_extrinsic_parts, ExtraInfo, SignedExtrinsicData, FrameMetadataPrepared, Proof, ExtrinsicMetadata}; +use std::ptr; + +#[derive(Encode)] +pub struct MetadataProof { + proof: Proof, + extrinsic: ExtrinsicMetadata, + extra_info: ExtraInfo, +} + +macro_rules! r#try_or_throw { + ($jni_env: ident, $expr:expr, $ret: expr) => { + match $expr { + JniResult::Ok(val) => val, + JniResult::Err(err) => { + $jni_env + .throw_new("java/lang/Exception", err.description()) + .unwrap(); + return $ret; + } + } + }; + ($expr:expr,) => { + $crate::r#try!($expr) + }; +} + +macro_rules! r#try_or_throw_null { + ($jni_env: ident, $expr:expr) => { + try_or_throw!($jni_env, $expr, ptr::null_mut()) + }; +} + +#[no_mangle] +fn Java_io_novafoundation_nova_metadata_1shortener_MetadataShortener_generate_1extrinsic_1proof( + jni_env: JNIEnv, + _: JClass, + call: jbyteArray, + signed_extras: jbyteArray, + additional_signed: jbyteArray, + metadata: jbyteArray, + spec_version: jint, + spec_name: JString, + base58_prefix: jint, + decimals: jint, + token_symbol: JString, +) -> jbyteArray { + let Some(metadata) = decode_metadata(&jni_env, metadata) else { + return ptr::null_mut(); + }; + + let included_in_extrinsic = + try_or_throw_null!(jni_env, jni_env.convert_byte_array(signed_extras)); + let included_in_signed_data = + try_or_throw_null!(jni_env, jni_env.convert_byte_array(additional_signed)); + + let call_vec = try_or_throw_null!(jni_env, jni_env.convert_byte_array(call)); + + let signed_ext_data = SignedExtrinsicData { + included_in_extrinsic: included_in_extrinsic.as_slice(), + included_in_signed_data: included_in_signed_data.as_slice(), + }; + + let spec_version = spec_version as u32; + let spec_name = try_or_throw_null!(jni_env, jni_env.get_string(spec_name)); + let base58_prefix = base58_prefix as u16; + let decimals = decimals as u8; + let token_symbol = try_or_throw_null!(jni_env, jni_env.get_string(token_symbol)); + + let extra_info = ExtraInfo { + spec_version: spec_version, + spec_name: spec_name.into(), + base58_prefix: base58_prefix, + decimals: decimals, + token_symbol: token_symbol.into(), + }; + + let extrinsic_metadata = FrameMetadataPrepared::prepare(&metadata) + .unwrap() + .as_type_information() + .unwrap() + .extrinsic_metadata; + + let Ok(registry_proof) = + generate_proof_for_extrinsic_parts(call_vec.as_slice(), Some(signed_ext_data), &metadata) + else { + jni_env + .throw_new("java/lang/Exception", "Failed to construct proof") + .unwrap(); + return ptr::null_mut(); + }; + + let meta_proof = MetadataProof { + proof: registry_proof, + extrinsic: extrinsic_metadata, + extra_info: extra_info, + }; + + let prood_encoded = meta_proof.encode(); + + try_or_throw_null!( + jni_env, + jni_env.byte_array_from_slice(prood_encoded.as_slice().as_ref()) + ) +} + +#[no_mangle] +fn Java_io_novafoundation_nova_metadata_1shortener_MetadataShortener_generate_1metadata_1digest( + jni_env: JNIEnv, + _: JClass, + metadata: jbyteArray, + spec_version: jint, + spec_name: JString, + base58_prefix: jint, + decimals: jint, + token_symbol: JString, +) -> jbyteArray { + let Some(metadata) = decode_metadata(&jni_env, metadata) else { + return ptr::null_mut(); + }; + + let spec_version = spec_version as u32; + let spec_name = try_or_throw_null!(jni_env, jni_env.get_string(spec_name)); + let base58_prefix = base58_prefix as u16; + let decimals = decimals as u8; + let token_symbol = try_or_throw_null!(jni_env, jni_env.get_string(token_symbol)); + + let extra_info = ExtraInfo { + spec_version: spec_version, + spec_name: spec_name.into(), + base58_prefix: base58_prefix, + decimals: decimals, + token_symbol: token_symbol.into(), + }; + + let Ok(digest) = generate_metadata_digest(&metadata, extra_info) else { + jni_env + .throw_new("java/lang/Exception", "Failed to generate digest") + .unwrap(); + return ptr::null_mut(); + }; + + let digest_hash = digest.hash(); + + try_or_throw_null!( + jni_env, + jni_env.byte_array_from_slice(digest_hash.as_slice().as_ref()) + ) +} + +fn decode_metadata(jni_env: &JNIEnv, metadata: jbyteArray) -> Option { + let metadata = try_or_throw!(jni_env, jni_env.convert_byte_array(metadata), None); + + let Some(metadata) = Option::::decode(&mut &metadata[..]) + .ok() + .flatten() else { + jni_env + .throw_new("java/lang/Exception", "Failed to decode opaque metadata") + .unwrap(); + + return None; + }; + let metadata = metadata.0; + + let Ok(metadata) = RuntimeMetadataPrefixed::decode(&mut &metadata[..]) else { + jni_env + .throw_new("java/lang/Exception", "Failed to decode metadata") + .unwrap(); + + return None + }; + + Some(metadata.1) +} + +// fn main() { +// let signed_extras = "15000000"; +// let additional_signed = "104a0f001900000091b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c36c9f8deedd0c7f1aae4d1900c88456f23d9c224934e6b8c57f750c8b146997c1"; +// let call = "050000010101010101010101010101010101010101010101010101010101010101010104"; +// let metadata = "Metadata goes here"; + +// let signed_extras = hex2bytes(signed_extras).expect("Cant decode signed extras"); +// let additional_signed = hex2bytes(additional_signed).expect("Cant decode additional signed"); +// let call = hex2bytes(call).expect("Cant decode call"); + + +// let metadata = hex2bytes(metadata).expect("Cant decode metadata"); +// let metadata = Option::::decode(&mut &metadata[..]) +// .expect("Failed to decode opaque metadata") +// .expect("Metadata V15 support is required.") +// .0; +// let metadata = RuntimeMetadataPrefixed::decode(&mut &metadata[..]).expect("Failed to decode metadata"); + + +// let signed_ext_data = SignedExtrinsicData { +// included_in_extrinsic: signed_extras.as_slice(), +// included_in_signed_data: additional_signed.as_slice(), +// }; + +// let proof = generate_proof_for_extrinsic_parts(call.as_slice(), Some(signed_ext_data), &metadata.1) +// .expect("Failed to generate proof"); +// } \ No newline at end of file diff --git a/bindings/metadata_shortener/src/main/AndroidManifest.xml b/bindings/metadata_shortener/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/bindings/metadata_shortener/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/bindings/metadata_shortener/src/main/java/io/novafoundation/nova/metadata_shortener/MetadataShortener.java b/bindings/metadata_shortener/src/main/java/io/novafoundation/nova/metadata_shortener/MetadataShortener.java new file mode 100644 index 0000000..27e15cc --- /dev/null +++ b/bindings/metadata_shortener/src/main/java/io/novafoundation/nova/metadata_shortener/MetadataShortener.java @@ -0,0 +1,30 @@ +package io.novafoundation.nova.metadata_shortener; + +public class MetadataShortener { + + static { + System.loadLibrary("metadata_shortener_java"); + } + + public static native byte[] generate_extrinsic_proof( + byte[] call, + byte[] signed_extras, + byte[] additional_signed, + byte[] metadata, + + int spec_version, + String spec_name, + int base58_prefix, + int decimals, + String token_symbol + ); + + public static native byte[] generate_metadata_digest( + byte[] metadata, + int spec_version, + String spec_name, + int base58_prefix, + int decimals, + String token_symbol + ); +} diff --git a/bindings/sr25519-bizinikiwi/.gitignore b/bindings/sr25519-bizinikiwi/.gitignore new file mode 100644 index 0000000..c42713e --- /dev/null +++ b/bindings/sr25519-bizinikiwi/.gitignore @@ -0,0 +1,2 @@ +/build +/rust/target/ diff --git a/bindings/sr25519-bizinikiwi/build.gradle b/bindings/sr25519-bizinikiwi/build.gradle new file mode 100644 index 0000000..6d1b0ae --- /dev/null +++ b/bindings/sr25519-bizinikiwi/build.gradle @@ -0,0 +1,37 @@ +apply plugin: 'org.mozilla.rust-android-gradle.rust-android' + +android { + namespace 'io.novafoundation.nova.sr25519_bizinikiwi' + + ndkVersion "26.1.10909125" + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation kotlinDep + + testImplementation jUnitDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} + +cargo { + module = "rust/" + libname = "sr25519_bizinikiwi_java" + targets = ["arm", "arm64", "x86", "x86_64"] + profile = "release" + pythonCommand = "python3" +} + +tasks.matching { it.name.matches(/merge.*JniLibFolders/) }.configureEach { + it.inputs.dir(new File(buildDir, "rustJniLibs/android")) + it.dependsOn("cargoBuild") +} diff --git a/bindings/sr25519-bizinikiwi/consumer-rules.pro b/bindings/sr25519-bizinikiwi/consumer-rules.pro new file mode 100644 index 0000000..c282afb --- /dev/null +++ b/bindings/sr25519-bizinikiwi/consumer-rules.pro @@ -0,0 +1,2 @@ +# Keep BizinikiwSr25519 native methods +-keep class io.novafoundation.nova.sr25519.BizinikiwSr25519 { *; } diff --git a/bindings/sr25519-bizinikiwi/proguard-rules.pro b/bindings/sr25519-bizinikiwi/proguard-rules.pro new file mode 100644 index 0000000..fb87038 --- /dev/null +++ b/bindings/sr25519-bizinikiwi/proguard-rules.pro @@ -0,0 +1,7 @@ +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} + +# Keep BizinikiwSr25519 object +-keep class io.novafoundation.nova.sr25519.BizinikiwSr25519 { *; } diff --git a/bindings/sr25519-bizinikiwi/rust/Cargo.lock b/bindings/sr25519-bizinikiwi/rust/Cargo.lock new file mode 100644 index 0000000..0285c4a --- /dev/null +++ b/bindings/sr25519-bizinikiwi/rust/Cargo.lock @@ -0,0 +1,583 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom_or_panic" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" +dependencies = [ + "rand", + "rand_core", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core", + "zeroize", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schnorrkel" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9fcb6c2e176e86ec703e22560d99d65a5ee9056ae45a08e13e84ebf796296f" +dependencies = [ + "aead", + "arrayref", + "arrayvec", + "curve25519-dalek", + "getrandom_or_panic", + "merlin", + "rand_core", + "serde_bytes", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sr25519-bizinikiwi-java" +version = "0.1.0" +dependencies = [ + "jni", + "schnorrkel", + "zeroize", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/bindings/sr25519-bizinikiwi/rust/Cargo.toml b/bindings/sr25519-bizinikiwi/rust/Cargo.toml new file mode 100644 index 0000000..7022857 --- /dev/null +++ b/bindings/sr25519-bizinikiwi/rust/Cargo.toml @@ -0,0 +1,20 @@ +[package] +authors = ['PezkuwiChain'] +edition = '2021' +license = 'Apache 2.0' +name = "sr25519-bizinikiwi-java" +version = "0.1.0" + +[dependencies] +schnorrkel = "0.11.4" +jni = { version = "0.21", default-features = false } +zeroize = "1.7" + +[profile.release] +strip = true +lto = true +opt-level = "s" + +[lib] +name = "sr25519_bizinikiwi_java" +crate_type = ["cdylib"] diff --git a/bindings/sr25519-bizinikiwi/rust/src/lib.rs b/bindings/sr25519-bizinikiwi/rust/src/lib.rs new file mode 100644 index 0000000..c777027 --- /dev/null +++ b/bindings/sr25519-bizinikiwi/rust/src/lib.rs @@ -0,0 +1,131 @@ +use jni::JNIEnv; +use jni::objects::{JByteArray, JClass}; +use jni::sys::jbyteArray; +use schnorrkel::{ExpansionMode, Keypair, MiniSecretKey, PublicKey, SecretKey, Signature}; +use schnorrkel::context::signing_context; + +/// Pezkuwi signing context - different from standard "substrate" +const BIZINIKIWI_CTX: &[u8] = b"bizinikiwi"; + +const KEYPAIR_LENGTH: usize = 96; +const SECRET_KEY_LENGTH: usize = 64; +const PUBLIC_KEY_LENGTH: usize = 32; +const SIGNATURE_LENGTH: usize = 64; +const SEED_LENGTH: usize = 32; + +fn create_from_seed(seed: &[u8]) -> Keypair { + match MiniSecretKey::from_bytes(seed) { + Ok(mini) => mini.expand_to_keypair(ExpansionMode::Ed25519), + Err(_) => panic!("Invalid seed provided"), + } +} + +fn create_from_pair(pair: &[u8]) -> Keypair { + match Keypair::from_bytes(pair) { + Ok(kp) => kp, + Err(_) => panic!("Invalid keypair provided"), + } +} + +/// Sign a message using bizinikiwi context +#[no_mangle] +pub extern "system" fn Java_io_novafoundation_nova_sr25519_BizinikiwSr25519_sign<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + public_key: JByteArray<'local>, + secret_key: JByteArray<'local>, + message: JByteArray<'local>, +) -> jbyteArray { + let public_vec = env.convert_byte_array(&public_key).expect("Invalid public key"); + let secret_vec = env.convert_byte_array(&secret_key).expect("Invalid secret key"); + let message_vec = env.convert_byte_array(&message).expect("Invalid message"); + + let secret = SecretKey::from_bytes(&secret_vec).expect("Invalid secret key bytes"); + let public = PublicKey::from_bytes(&public_vec).expect("Invalid public key bytes"); + + let context = signing_context(BIZINIKIWI_CTX); + let signature = secret.sign(context.bytes(&message_vec), &public); + + let output = env.byte_array_from_slice(signature.to_bytes().as_ref()) + .expect("Failed to create signature array"); + + output.into_raw() +} + +/// Verify a signature using bizinikiwi context +#[no_mangle] +pub extern "system" fn Java_io_novafoundation_nova_sr25519_BizinikiwSr25519_verify<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + signature: JByteArray<'local>, + message: JByteArray<'local>, + public_key: JByteArray<'local>, +) -> bool { + let sig_vec = env.convert_byte_array(&signature).expect("Invalid signature"); + let msg_vec = env.convert_byte_array(&message).expect("Invalid message"); + let pub_vec = env.convert_byte_array(&public_key).expect("Invalid public key"); + + let sig = match Signature::from_bytes(&sig_vec) { + Ok(s) => s, + Err(_) => return false, + }; + + let public = match PublicKey::from_bytes(&pub_vec) { + Ok(p) => p, + Err(_) => return false, + }; + + let context = signing_context(BIZINIKIWI_CTX); + public.verify(context.bytes(&msg_vec), &sig).is_ok() +} + +/// Generate keypair from seed +#[no_mangle] +pub extern "system" fn Java_io_novafoundation_nova_sr25519_BizinikiwSr25519_keypairFromSeed<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + seed: JByteArray<'local>, +) -> jbyteArray { + let seed_vec = env.convert_byte_array(&seed).expect("Invalid seed"); + + let keypair = create_from_seed(&seed_vec); + + let output = env.byte_array_from_slice(&keypair.to_bytes()) + .expect("Failed to create keypair array"); + + output.into_raw() +} + +/// Get public key from keypair +#[no_mangle] +pub extern "system" fn Java_io_novafoundation_nova_sr25519_BizinikiwSr25519_publicKeyFromKeypair<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + keypair: JByteArray<'local>, +) -> jbyteArray { + let keypair_vec = env.convert_byte_array(&keypair).expect("Invalid keypair"); + + let kp = create_from_pair(&keypair_vec); + + let output = env.byte_array_from_slice(kp.public.to_bytes().as_ref()) + .expect("Failed to create public key array"); + + output.into_raw() +} + +/// Get secret key from keypair (64 bytes: 32 key + 32 nonce) +#[no_mangle] +pub extern "system" fn Java_io_novafoundation_nova_sr25519_BizinikiwSr25519_secretKeyFromKeypair<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + keypair: JByteArray<'local>, +) -> jbyteArray { + let keypair_vec = env.convert_byte_array(&keypair).expect("Invalid keypair"); + + let kp = create_from_pair(&keypair_vec); + + let output = env.byte_array_from_slice(&kp.secret.to_bytes()) + .expect("Failed to create secret key array"); + + output.into_raw() +} diff --git a/bindings/sr25519-bizinikiwi/src/main/AndroidManifest.xml b/bindings/sr25519-bizinikiwi/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/bindings/sr25519-bizinikiwi/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/bindings/sr25519-bizinikiwi/src/main/java/io/novafoundation/nova/sr25519/BizinikiwSr25519.kt b/bindings/sr25519-bizinikiwi/src/main/java/io/novafoundation/nova/sr25519/BizinikiwSr25519.kt new file mode 100644 index 0000000..84328c1 --- /dev/null +++ b/bindings/sr25519-bizinikiwi/src/main/java/io/novafoundation/nova/sr25519/BizinikiwSr25519.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.sr25519 + +/** + * SR25519 signing implementation for PezkuwiChain using "bizinikiwi" signing context. + * + * Standard Substrate chains use "substrate" context, but Pezkuwi ecosystem uses "bizinikiwi". + * This native library provides signing compatible with @pezkuwi/scure-sr25519. + */ +object BizinikiwSr25519 { + + init { + System.loadLibrary("sr25519_bizinikiwi_java") + } + + /** + * Sign a message using SR25519 with bizinikiwi context. + * + * @param publicKey 32-byte public key + * @param secretKey 64-byte secret key (32-byte scalar + 32-byte nonce) + * @param message Message bytes to sign + * @return 64-byte signature + */ + external fun sign(publicKey: ByteArray, secretKey: ByteArray, message: ByteArray): ByteArray + + /** + * Verify a signature using bizinikiwi context. + * + * @param signature 64-byte signature + * @param message Original message bytes + * @param publicKey 32-byte public key + * @return true if signature is valid + */ + external fun verify(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean + + /** + * Generate a keypair from a 32-byte seed. + * + * @param seed 32-byte seed (mini secret key) + * @return 96-byte keypair (32 key + 32 nonce + 32 public) + */ + external fun keypairFromSeed(seed: ByteArray): ByteArray + + /** + * Extract public key from keypair. + * + * @param keypair 96-byte keypair + * @return 32-byte public key + */ + external fun publicKeyFromKeypair(keypair: ByteArray): ByteArray + + /** + * Extract secret key from keypair. + * + * @param keypair 96-byte keypair + * @return 64-byte secret key + */ + external fun secretKeyFromKeypair(keypair: ByteArray): ByteArray +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..5370672 --- /dev/null +++ b/build.gradle @@ -0,0 +1,303 @@ +buildscript { + ext { + // App version + versionName = '1.0.0' + versionCode = 1 + + applicationId = "io.pezkuwichain.wallet" + releaseApplicationSuffix = "market" + + // SDK and tools + compileSdkVersion = 36 + minSdkVersion = 24 + targetSdkVersion = 36 + + kotlinVersion = '2.1.0' + + ktlintPinterestVersion = '0.47.0' + + recyclerVersion = "1.2.1" + supportVersion = '1.1.0' + cardViewVersion = '1.0.0' + constraintVersion = '2.1.4' + + coroutinesVersion = '1.8.1' + + biometricVersion = '1.0.1' + + progressButtonsVersion = '2.1.0' + + daggerVersion = '2.52' + javaxInjectVersion = '1' + + architectureComponentVersion = '2.7.0' + + multibaseVersion = '1.1.1' + + retrofitVersion = '2.9.0' + okhttpVersion = '4.12.0' + gsonVersion = '2.10.1' + + zXingVersion = '3.5.0' + + navControllerVersion = '2.7.7' + + junitVersion = '4.13.2' + mockitoVersion = '5.12.0' + robolectricVersion = '4.1' + allureVersion = '2.4.0' + + bouncyCastleVersion = '1.77' + + web3jVersion = '4.9.5' + + substrateSdkVersion = '2.11.0' + + gifVersion = '1.2.19' + + zXingVersion = '3.4.0' + zXingEmbeddedVersion = '3.5.0@aar' + + biometricDep = "androidx.biometric:biometric:$biometricVersion" + + firebaseAppDistrVersion = '2.1.1' + playPublisherVersion = '2.5.0' + + wsVersion = "2.10" + + permissionsVersion = '1.1.2' + + insetterVersion = "0.5.0" + + shimmerVersion = '0.5.0' + + coilVersion = '1.2.1' + + flexBoxVersion = "3.0.0" + + bleVersion = '2.5.1' + + roomVersion = '2.6.1' + + markwonVersion = '4.6.2' + + firebaseBomVersion = '32.7.1' + + walletConnectCoreVersion = "1.27.2" + walletConnectWalletVersion = "1.20.2" + + playServicesAuthVersion = "20.0.0" + googleApiClientVersion = "1.32.1" + googleDriveVersion = "v3-rev20210919-1.32.1" + + cardStackVersion = "2.3.4" + + withoutTransitiveAndroidX = { + exclude group: "androidx.appcompat", module: "appcompat" + exclude group: "androidx.fragment", module: "fragment-ktx" + } + + coilDep = "io.coil-kt:coil:$coilVersion" + coilSvg = "io.coil-kt:coil-svg:$coilVersion" + + kotlinDep = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + + insetterDep = "dev.chrisbanes.insetter:insetter-widgets:$insetterVersion" + + playServicesAuthDep = "com.google.android.gms:play-services-auth:$playServicesAuthVersion" + googleApiClientDep = "com.google.api-client:google-api-client-android:$googleApiClientVersion" + googleDriveDep = "com.google.apis:google-api-services-drive:$googleDriveVersion" + + androidDep = "androidx.appcompat:appcompat:$supportVersion" + cardViewDep = "androidx.cardview:cardview:$cardViewVersion" + recyclerViewDep = "androidx.recyclerview:recyclerview:$recyclerVersion" + constraintDep = "androidx.constraintlayout:constraintlayout:$constraintVersion" + materialDep = "com.google.android.material:material:$supportVersion" + + flexBoxDep = "com.google.android.flexbox:flexbox:$flexBoxVersion" + + daggerDep = "com.google.dagger:dagger:$daggerVersion" + daggerCompiler = "com.google.dagger:dagger-compiler:$daggerVersion" + + lifecycleDep = "androidx.lifecycle:lifecycle-extensions:$architectureComponentVersion" + lifecycleCompiler = "androidx.lifecycle:lifecycle-compiler:$architectureComponentVersion" + lifecycleRuntime = "androidx.lifecycle:lifecycle-runtime-android:$architectureComponentVersion" + + coroutinesDep = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" + coroutinesAndroidDep = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" + coroutinesTestDep = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" + coroutinesFutureDep = "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion" + coroutinesRxDep = "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$coroutinesVersion" + + + web3jDep = "org.web3j:core:$web3jVersion" + + // viewModelScope + viewModelKtxDep = "androidx.lifecycle:lifecycle-viewmodel-ktx:$architectureComponentVersion" + + // liveData builder + liveDataKtxDep = "androidx.lifecycle:lifecycle-livedata-ktx:$architectureComponentVersion" + + // lifecycle scopes + lifeCycleKtxDep = "androidx.lifecycle:lifecycle-runtime-ktx:$architectureComponentVersion" + + permissionsDep = "com.github.florent37:RuntimePermission:$permissionsVersion" + + roomDep = "androidx.room:room-runtime:$roomVersion" + roomKtxDep = "androidx.room:room-ktx:$roomVersion" + roomCompiler = "androidx.room:room-compiler:$roomVersion" + + navigationFragmentDep = "androidx.navigation:navigation-fragment-ktx:$navControllerVersion" + navigationUiDep = "androidx.navigation:navigation-ui-ktx:$navControllerVersion" + + bouncyCastleDep = "org.bouncycastle:bcprov-jdk18on:$bouncyCastleVersion" + + retrofitDep = "com.squareup.retrofit2:retrofit:$retrofitVersion" + interceptorVersion = "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" + gsonConvertedDep = "com.squareup.retrofit2:converter-gson:$retrofitVersion" + scalarsConverterDep = "com.squareup.retrofit2:converter-scalars:$retrofitVersion" + + gsonDep = "com.google.code.gson:gson:$gsonVersion" + + zXingCoreDep = "com.google.zxing:core:$zXingVersion" + zXingEmbeddedDep = "com.journeyapps:zxing-android-embedded:$zXingEmbeddedVersion" + + substrateSdkDep = "io.github.nova-wallet.substrate-sdk-android:core:$substrateSdkVersion" + substrateSdkSerializationDep = "io.github.nova-wallet.substrate-sdk-android:kotlinx-serialization-scale:$substrateSdkVersion" + + gifDep = "pl.droidsonroids.gif:android-gif-drawable:$gifVersion" + + wsDep = "com.neovisionaries:nv-websocket-client:$wsVersion" + + shimmerDep = "com.facebook.shimmer:shimmer:$shimmerVersion" + + jUnitDep = "junit:junit:$junitVersion" + mockitoDep = "org.mockito:mockito-core:$mockitoVersion" + robolectricDep = "org.robolectric:robolectric:$robolectricVersion" + archCoreTestDep = "androidx.arch.core:core-testing:$architectureComponentVersion" + + progressButtonDep = "com.github.razir.progressbutton:progressbutton:$progressButtonsVersion" + + bleDep = "no.nordicsemi.android:ble:$bleVersion" + bleKotlinDep = "no.nordicsemi.android:ble-ktx:$bleVersion" + + androidTestRunnerDep = 'androidx.test:runner:1.4.0' + androidTestRulesDep = 'androidx.test:rules:1.4.0' + androidJunitDep = 'androidx.test.ext:junit:1.1.3' + + allureKotlinModel = "io.qameta.allure:allure-kotlin-model:$allureVersion" + allureKotlinCommons = "io.qameta.allure:allure-kotlin-commons:$allureVersion" + allureKotlinJunit4 = "io.qameta.allure:allure-kotlin-junit4:$allureVersion" + allureKotlinAndroid = "io.qameta.allure:allure-kotlin-android:$allureVersion" + + roomTestsDep = "androidx.room:room-testing:$architectureComponentVersion" + + markwonDep = "io.noties.markwon:core:$markwonVersion" + markwonImage = "io.noties.markwon:image-coil:$markwonVersion" + markwonTables = "io.noties.markwon:ext-tables:$markwonVersion" + markwonLinkify = "io.noties.markwon:linkify:$markwonVersion" + markwonStrikethrough = "io.noties.markwon:ext-strikethrough:$markwonVersion" + markwonHtml = "io.noties.markwon:html:$markwonVersion" + + multibaseDep = "com.github.multiformats:java-multibase:$multibaseVersion" + + walletConnectCoreDep = "com.walletconnect:android-core:$walletConnectCoreVersion" + walletConnectWalletDep = "com.walletconnect:web3wallet:$walletConnectWalletVersion" + + canonizationJsonDep = "io.github.erdtman:java-json-canonicalization:1.1" + + firebaseBomDep = "com.google.firebase:firebase-bom:$firebaseBomVersion" + firestoreDep = "com.google.firebase:firebase-firestore" + firebaseCloudMessagingDep = "com.google.firebase:firebase-messaging" + firebaseAppCheck = "com.google.firebase:firebase-appcheck-playintegrity" + + kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" + + cardStackView = "com.github.yuyakaido:cardstackview:$cardStackVersion" + + chartsDep = "com.github.PhilJay:MPAndroidChart:v3.1.0" + + swipeRefershLayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01" + + branchIo = "io.branch.sdk.android:library:5.18.0" + + playServiceIdentifier = "com.google.android.gms:play-services-ads-identifier:18.2.0" + + androidxWebKit = "androidx.webkit:webkit:1.14.0" + + playIntegrity = "com.google.android.play:integrity:1.4.0" + + lottie = "com.airbnb.android:lottie:6.6.6" + } + + repositories { + google() + mavenCentral() + jcenter() + maven { url "https://plugins.gradle.org/m2/" } + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + classpath 'com.google.gms:google-services:4.3.14' + classpath 'org.mozilla.rust-android-gradle:plugin:0.9.6' + classpath "com.google.firebase:firebase-appdistribution-gradle:$firebaseAppDistrVersion" + classpath "com.github.triplet.gradle:play-publisher:$playPublisherVersion" + classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:2.1.0-1.0.29" + } +} + +allprojects { + repositories { + google() + mavenCentral() + jcenter() + maven { url "https://jitpack.io" } + maven { url "https://nexus.iroha.tech/repository/maven-soramitsu/" } + mavenLocal() + flatDir { dirs "$rootDir/common/libs" } + } +} + +configurations { + ktlint +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + + +dependencies { + ktlint("com.pinterest:ktlint:$ktlintPinterestVersion") { + attributes { + attribute(Bundling.BUNDLING_ATTRIBUTE, getObjects().named(Bundling, Bundling.EXTERNAL)) + } + } +} + +task ktlint(type: JavaExec, group: "verification") { + description = "Check Kotlin code style." + classpath = configurations.ktlint + main = "com.pinterest.ktlint.Main" + args "--reporter=plain" + args "--android" + args "--reporter=checkstyle,output=${project.buildDir}/reports/checkstyle/ktlint.xml" + args "$project.rootDir/**/src/main/**/*.kt" +} + +task ktlintFormat(type: JavaExec, group: "formatting") { + description = "Fix Kotlin code style deviations." + classpath = configurations.ktlint + main = "com.pinterest.ktlint.Main" + jvmArgs "--add-opens=java.base/java.lang=ALL-UNNAMED" + args "-F", "$project.rootDir/**/src/main/**/*.kt" + args "--android" + jvmArgs "--add-opens=java.base/java.lang=ALL-UNNAMED" +} + +task runTest(type: GradleBuild) { + tasks = ['clean', 'ktlint', 'testDebugUnitTest'] +} + +apply from: "allmodules.gradle" diff --git a/caip/.gitignore b/caip/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/caip/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/caip/build.gradle b/caip/build.gradle new file mode 100644 index 0000000..7604b5a --- /dev/null +++ b/caip/build.gradle @@ -0,0 +1,43 @@ + +android { + namespace 'io.novafoundation.nova.caip' + + defaultConfig { + + + buildConfigField "String", "SLIP_44_COINS_BASE_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/slip44.json\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation project(":common") + implementation project(":core-db") + implementation project(":runtime") + implementation project(":core-api") + + implementation kotlinDep + + implementation coroutinesDep + + implementation retrofitDep + + implementation daggerDep + ksp daggerCompiler + + implementation multibaseDep + + testImplementation project(':test-shared') + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} \ No newline at end of file diff --git a/caip/consumer-rules.pro b/caip/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/caip/proguard-rules.pro b/caip/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/caip/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/caip/src/main/AndroidManifest.xml b/caip/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/caip/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/Caip19MatcherFactory.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/Caip19MatcherFactory.kt new file mode 100644 index 0000000..aea74a5 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/Caip19MatcherFactory.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.caip.caip19 + +import io.novafoundation.nova.caip.caip19.matchers.Caip19Matcher +import io.novafoundation.nova.caip.caip19.matchers.asset.AssetMatcher +import io.novafoundation.nova.caip.caip19.matchers.asset.Erc20AssetMatcher +import io.novafoundation.nova.caip.caip19.matchers.asset.Slip44AssetMatcher +import io.novafoundation.nova.caip.caip19.matchers.asset.UnsupportedAssetMatcher +import io.novafoundation.nova.caip.caip2.Caip2MatcherFactory +import io.novafoundation.nova.caip.slip44.Slip44CoinRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type.EvmErc20 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type.Unsupported + +interface Caip19MatcherFactory { + + suspend fun getCaip19Matcher(chain: Chain, chainAsset: Chain.Asset): Caip19Matcher +} + +class RealCaip19MatcherFactory( + private val slip44CoinRepository: Slip44CoinRepository, + private val caip2MatcherFactory: Caip2MatcherFactory, +) : Caip19MatcherFactory { + + override suspend fun getCaip19Matcher(chain: Chain, chainAsset: Chain.Asset): Caip19Matcher { + val caip2Matcher = caip2MatcherFactory.getCaip2Matcher(chain) + val assetNamespaceMatcher = getAssetNamespaceMatcher(chainAsset) + + return Caip19Matcher(caip2Matcher, assetNamespaceMatcher) + } + + private suspend fun getAssetNamespaceMatcher(chainAsset: Chain.Asset): AssetMatcher { + return when (val assetType = chainAsset.type) { + is EvmErc20 -> Erc20AssetMatcher(assetType.contractAddress) + + Unsupported -> UnsupportedAssetMatcher() + + else -> slip44CoinRepository.getCoinCode(chainAsset)?.let(::Slip44AssetMatcher) + ?: UnsupportedAssetMatcher() + } + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/Caip19Parser.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/Caip19Parser.kt new file mode 100644 index 0000000..4e9248c --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/Caip19Parser.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.caip.caip19 + +import io.novafoundation.nova.caip.caip19.identifiers.AssetIdentifier +import io.novafoundation.nova.caip.caip19.identifiers.Caip19Identifier +import io.novafoundation.nova.caip.caip19.identifiers.ERC20 +import io.novafoundation.nova.caip.caip19.identifiers.NotSupportedIdentifierException +import io.novafoundation.nova.caip.caip19.identifiers.SLIP44 +import io.novafoundation.nova.caip.caip2.Caip2Parser +import io.novafoundation.nova.caip.common.toNamespaceAndReference + +interface Caip19Parser { + + fun parseCaip19(caip19Identifier: String): Result +} + +internal class RealCaip19Parser( + private val caip2Parser: Caip2Parser, +) : Caip19Parser { + + override fun parseCaip19(caip19Identifier: String): Result = runCatching { + val (chain, asset) = caip19Identifier.splitToNamespaces() + + Caip19Identifier(caip2Parser.parseCaip2(chain).getOrThrow(), parseAsset(asset)) + } + + private fun parseAsset(asset: String): AssetIdentifier { + val (assetNamespace, assetIdentifier) = asset.toNamespaceAndReference() + + return when (assetNamespace) { + SLIP44 -> AssetIdentifier.Slip44(assetIdentifier.toInt()) + ERC20 -> AssetIdentifier.Erc20(assetIdentifier) + else -> throw NotSupportedIdentifierException() + } + } + + private fun String.splitToNamespaces(): Pair { + val (chainNamespace, tokenNamespace) = split("/") + return Pair(chainNamespace, tokenNamespace) + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/identifiers/AssetIdentifier.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/identifiers/AssetIdentifier.kt new file mode 100644 index 0000000..2c4c934 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/identifiers/AssetIdentifier.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.caip.caip19.identifiers + +const val SLIP44 = "slip44" +const val ERC20 = "erc20" + +sealed interface AssetIdentifier { + class Slip44(val slip44CoinCode: Int) : AssetIdentifier + + class Erc20(val contractAddress: String) : AssetIdentifier +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/identifiers/Caip19Identifier.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/identifiers/Caip19Identifier.kt new file mode 100644 index 0000000..204194f --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/identifiers/Caip19Identifier.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.caip.caip19.identifiers + +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier + +class Caip19Identifier(val caip2Identifier: Caip2Identifier, val assetIdentifier: AssetIdentifier) diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/identifiers/NotSupportedIdentifierException.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/identifiers/NotSupportedIdentifierException.kt new file mode 100644 index 0000000..d5f5c6f --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/identifiers/NotSupportedIdentifierException.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.caip.caip19.identifiers + +class NotSupportedIdentifierException : Exception() diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/Caip19Matcher.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/Caip19Matcher.kt new file mode 100644 index 0000000..6c41629 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/Caip19Matcher.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.caip.caip19.matchers + +import io.novafoundation.nova.caip.caip19.identifiers.Caip19Identifier +import io.novafoundation.nova.caip.caip19.matchers.asset.AssetMatcher +import io.novafoundation.nova.caip.caip19.matchers.asset.UnsupportedAssetMatcher +import io.novafoundation.nova.caip.caip2.matchers.Caip2Matcher + +class Caip19Matcher( + private val caip2Matcher: Caip2Matcher, + private val assetMatcher: AssetMatcher +) { + + fun match(caip19Identifier: Caip19Identifier): Boolean { + return caip2Matcher.match(caip19Identifier.caip2Identifier) && + assetMatcher.match(caip19Identifier.assetIdentifier) + } + + fun isUnsupported(): Boolean { + return assetMatcher is UnsupportedAssetMatcher + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/AssetMatcher.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/AssetMatcher.kt new file mode 100644 index 0000000..38016f6 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/AssetMatcher.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.caip.caip19.matchers.asset + +import io.novafoundation.nova.caip.caip19.identifiers.AssetIdentifier + +interface AssetMatcher { + + fun match(assetIdentifier: AssetIdentifier): Boolean +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/Erc20AssetMatcher.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/Erc20AssetMatcher.kt new file mode 100644 index 0000000..e585990 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/Erc20AssetMatcher.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.caip.caip19.matchers.asset + +import io.novafoundation.nova.caip.caip19.identifiers.AssetIdentifier +import io.novafoundation.nova.caip.caip19.identifiers.AssetIdentifier.Erc20 + +class Erc20AssetMatcher(private val contractAddress: String) : AssetMatcher { + + override fun match(assetIdentifier: AssetIdentifier): Boolean { + return assetIdentifier is Erc20 && assetIdentifier.contractAddress == contractAddress + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/Slip44AssetMatcher.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/Slip44AssetMatcher.kt new file mode 100644 index 0000000..7fd6911 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/Slip44AssetMatcher.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.caip.caip19.matchers.asset + +import io.novafoundation.nova.caip.caip19.identifiers.AssetIdentifier + +class Slip44AssetMatcher( + private val assetSlip44CoinCode: Int, +) : AssetMatcher { + + override fun match(assetIdentifier: AssetIdentifier): Boolean { + return assetIdentifier is AssetIdentifier.Slip44 && + assetSlip44CoinCode == assetIdentifier.slip44CoinCode + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/UnsupportedAssetMatcher.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/UnsupportedAssetMatcher.kt new file mode 100644 index 0000000..e9483b0 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip19/matchers/asset/UnsupportedAssetMatcher.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.caip.caip19.matchers.asset + +import io.novafoundation.nova.caip.caip19.identifiers.AssetIdentifier + +class UnsupportedAssetMatcher : AssetMatcher { + + override fun match(assetIdentifier: AssetIdentifier): Boolean { + return false + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip2/Caip2MatcherFactory.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip2/Caip2MatcherFactory.kt new file mode 100644 index 0000000..9d7c139 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip2/Caip2MatcherFactory.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.caip.caip2 + +import io.novafoundation.nova.caip.caip2.matchers.Caip2Matcher +import io.novafoundation.nova.caip.caip2.matchers.Caip2MatcherList +import io.novafoundation.nova.caip.caip2.matchers.Eip155Matcher +import io.novafoundation.nova.caip.caip2.matchers.SubstrateCaip2Matcher +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface Caip2MatcherFactory { + + suspend fun getCaip2Matcher(chain: Chain): Caip2Matcher +} + +internal class RealCaip2MatcherFactory : Caip2MatcherFactory { + + override suspend fun getCaip2Matcher(chain: Chain): Caip2Matcher { + val matchers = buildList { + add(SubstrateCaip2Matcher(chain)) + + if (chain.isEthereumBased) { + add(Eip155Matcher(chain)) + } + } + return Caip2MatcherList(matchers) + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip2/Caip2Parser.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip2/Caip2Parser.kt new file mode 100644 index 0000000..e5aaf24 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip2/Caip2Parser.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.caip.caip2 + +import io.novafoundation.nova.caip.caip19.identifiers.NotSupportedIdentifierException +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier +import io.novafoundation.nova.caip.caip2.identifier.Caip2Namespace +import io.novafoundation.nova.caip.common.toNamespaceAndReference + +interface Caip2Parser { + + fun parseCaip2(caip2Identifier: String): Result +} + +fun Caip2Parser.isValidCaip2(caip2Identifier: String): Boolean = parseCaip2(caip2Identifier).isSuccess + +fun Caip2Parser.parseCaip2OrThrow(caip2Identifier: String): Caip2Identifier = parseCaip2(caip2Identifier).getOrThrow() + +internal class RealCaip2Parser : Caip2Parser { + + override fun parseCaip2(caip2Identifier: String): Result = runCatching { + val (chainNamespace, chainIdentifier) = caip2Identifier.toNamespaceAndReference() + + when (chainNamespace) { + Caip2Namespace.EIP155.namespaceName -> Caip2Identifier.Eip155(chainIdentifier.toBigInteger()) + Caip2Namespace.POLKADOT.namespaceName -> Caip2Identifier.Polkadot(chainIdentifier) + else -> throw NotSupportedIdentifierException() + } + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip2/Caip2Resolver.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip2/Caip2Resolver.kt new file mode 100644 index 0000000..9da52d7 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip2/Caip2Resolver.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.caip.caip2 + +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier +import io.novafoundation.nova.caip.caip2.identifier.Caip2Namespace +import io.novafoundation.nova.common.utils.associateByMultiple +import io.novafoundation.nova.runtime.ext.genesisHash +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.enabledChains + +interface Caip2Resolver { + + fun caip2Of(chain: Chain, preferredNamespace: Caip2Namespace): Caip2Identifier? + + fun allCaip2Of(chain: Chain): List + + suspend fun chainsByCaip2(): Map +} + +internal class RealCaip2Resolver( + private val chainRegistry: ChainRegistry, +) : Caip2Resolver { + + override fun caip2Of(chain: Chain, preferredNamespace: Caip2Namespace): Caip2Identifier? { + val allCaips = allCaip2Of(chain) + + return allCaips.find { it.namespace == preferredNamespace } ?: allCaips.firstOrNull() + } + + override fun allCaip2Of(chain: Chain): List { + return buildList { + if (chain.hasSubstrateRuntime) add(polkadotChain(requireNotNull(chain.genesisHash))) + if (chain.isEthereumBased) add(eipChain(chain.addressPrefix)) + } + } + + override suspend fun chainsByCaip2(): Map { + val allChains = chainRegistry.enabledChains() + + return allChains.associateByMultiple { chain -> allCaip2Of(chain).map { it.namespaceWitId } } + } + + private fun polkadotChain(genesisHash: String): Caip2Identifier { + return Caip2Identifier.Polkadot(genesisHash) + } + + private fun eipChain(eipChainId: Int): Caip2Identifier { + return Caip2Identifier.Eip155(eipChainId.toBigInteger()) + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip2/identifier/Caip2Identifier.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip2/identifier/Caip2Identifier.kt new file mode 100644 index 0000000..a12ccec --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip2/identifier/Caip2Identifier.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.caip.caip2.identifier + +import io.novafoundation.nova.common.utils.removeHexPrefix +import java.math.BigInteger + +enum class Caip2Namespace(val namespaceName: String) { + EIP155("eip155"), + POLKADOT("polkadot"); + + companion object { + + fun find(namespaceName: String): Caip2Namespace? { + return values().find { it.namespaceName == namespaceName } + } + } +} + +sealed class Caip2Identifier { + + abstract val namespaceWitId: String + + abstract val namespace: Caip2Namespace + + override operator fun equals(other: Any?): Boolean = other is Caip2Identifier && namespaceWitId == other.namespaceWitId + + override fun hashCode(): Int = namespaceWitId.hashCode() + + class Eip155(val chainId: BigInteger) : Caip2Identifier() { + + override val namespace = Caip2Namespace.EIP155 + + override val namespaceWitId: String = formatCaip2(Caip2Namespace.EIP155, chainId) + } + + class Polkadot(val genesisHash: String) : Caip2Identifier() { + override val namespace = Caip2Namespace.POLKADOT + + override val namespaceWitId: String = formatCaip2(Caip2Namespace.POLKADOT, genesisHash.removeHexPrefix().take(32)) + } +} + +private fun formatCaip2(namespace: Caip2Namespace, reference: Any): String { + return "${namespace.namespaceName}:$reference" +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/Caip2Matcher.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/Caip2Matcher.kt new file mode 100644 index 0000000..d6193e4 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/Caip2Matcher.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.caip.caip2.matchers + +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier + +interface Caip2Matcher { + + fun match(caip2Identifier: Caip2Identifier): Boolean +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/Caip2MatcherList.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/Caip2MatcherList.kt new file mode 100644 index 0000000..bddea4d --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/Caip2MatcherList.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.caip.caip2.matchers + +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier + +class Caip2MatcherList(private val matchers: List) : Caip2Matcher { + + override fun match(caip2Identifier: Caip2Identifier): Boolean { + return matchers.any { it.match(caip2Identifier) } + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/Eip155Matcher.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/Eip155Matcher.kt new file mode 100644 index 0000000..ad4e583 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/Eip155Matcher.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.caip.caip2.matchers + +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class Eip155Matcher(private val chain: Chain) : Caip2Matcher { + + override fun match(caip2Identifier: Caip2Identifier): Boolean { + return caip2Identifier is Caip2Identifier.Eip155 && + caip2Identifier.chainId == chain.addressPrefix.toBigInteger() + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/SubstrateCaip2Matcher.kt b/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/SubstrateCaip2Matcher.kt new file mode 100644 index 0000000..6b69f8b --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/caip2/matchers/SubstrateCaip2Matcher.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.caip.caip2.matchers + +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier + +class SubstrateCaip2Matcher(private val chain: Chain) : Caip2Matcher { + + override fun match(caip2Identifier: Caip2Identifier): Boolean { + return caip2Identifier is Caip2Identifier.Polkadot && + chain.id.removeHexPrefix().startsWith(caip2Identifier.genesisHash.removeHexPrefix()) + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/common/ParsingExt.kt b/caip/src/main/java/io/novafoundation/nova/caip/common/ParsingExt.kt new file mode 100644 index 0000000..858e357 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/common/ParsingExt.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.caip.common + +internal fun String.toNamespaceAndReference(): Pair { + val (namespaceName, namespaceReference) = split(":") + return Pair(namespaceName, namespaceReference) +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/di/CaipApi.kt b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipApi.kt new file mode 100644 index 0000000..4d61ee3 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipApi.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.caip.di + +import io.novafoundation.nova.caip.caip19.Caip19MatcherFactory +import io.novafoundation.nova.caip.caip19.Caip19Parser +import io.novafoundation.nova.caip.caip2.Caip2MatcherFactory +import io.novafoundation.nova.caip.caip2.Caip2Parser +import io.novafoundation.nova.caip.caip2.Caip2Resolver + +interface CaipApi { + + val caip2Parser: Caip2Parser + + val caip2Resolver: Caip2Resolver + + val caip2MatcherFactory: Caip2MatcherFactory + + val caip19Parser: Caip19Parser + + val caip19MatcherFactory: Caip19MatcherFactory +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/di/CaipDependencies.kt b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipDependencies.kt new file mode 100644 index 0000000..86afe63 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipDependencies.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.caip.di + +import com.google.gson.Gson +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +interface CaipDependencies { + + val networkApiCreator: NetworkApiCreator + + val gson: Gson + + val chainRegistry: ChainRegistry +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/di/CaipFeatureComponent.kt b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipFeatureComponent.kt new file mode 100644 index 0000000..3a1ad89 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipFeatureComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.caip.di + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + modules = [ + CaipModule::class + ], + dependencies = [ + CaipDependencies::class + ] +) +@FeatureScope +abstract class CaipFeatureComponent : CaipApi { + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + ] + ) + interface CaipDependenciesComponent : CaipDependencies +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/di/CaipFeatureHolder.kt b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipFeatureHolder.kt new file mode 100644 index 0000000..758b6b3 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipFeatureHolder.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.caip.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class CaipFeatureHolder @Inject constructor( + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dbDependencies = DaggerCaipFeatureComponent_CaipDependenciesComponent.builder() + .commonApi(commonApi()) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .build() + + return DaggerCaipFeatureComponent.builder() + .caipDependencies(dbDependencies) + .build() + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/di/CaipModule.kt b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipModule.kt new file mode 100644 index 0000000..a5251ae --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/di/CaipModule.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.caip.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.caip.caip19.Caip19MatcherFactory +import io.novafoundation.nova.caip.caip19.Caip19Parser +import io.novafoundation.nova.caip.caip19.RealCaip19MatcherFactory +import io.novafoundation.nova.caip.caip19.RealCaip19Parser +import io.novafoundation.nova.caip.caip2.Caip2MatcherFactory +import io.novafoundation.nova.caip.caip2.Caip2Parser +import io.novafoundation.nova.caip.caip2.Caip2Resolver +import io.novafoundation.nova.caip.caip2.RealCaip2MatcherFactory +import io.novafoundation.nova.caip.caip2.RealCaip2Parser +import io.novafoundation.nova.caip.caip2.RealCaip2Resolver +import io.novafoundation.nova.caip.slip44.RealSlip44CoinRepository +import io.novafoundation.nova.caip.slip44.Slip44CoinRepository +import io.novafoundation.nova.caip.slip44.endpoint.Slip44CoinApi +import io.novafoundation.nova.caip.BuildConfig +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class CaipModule { + + @Provides + @FeatureScope + fun provideSlip44CoinApi( + networkApiCreator: NetworkApiCreator + ): Slip44CoinApi { + return networkApiCreator.create(Slip44CoinApi::class.java) + } + + @Provides + @FeatureScope + fun provideSlip44CoinRepository(slip44Api: Slip44CoinApi): Slip44CoinRepository { + return RealSlip44CoinRepository( + slip44Api = slip44Api, + slip44CoinsUrl = BuildConfig.SLIP_44_COINS_BASE_URL + ) + } + + @Provides + @FeatureScope + fun provideCaip2MatcherFactory(): Caip2MatcherFactory = RealCaip2MatcherFactory() + + @Provides + @FeatureScope + fun provideCaip2Parser(): Caip2Parser = RealCaip2Parser() + + @Provides + @FeatureScope + fun provideCaip2Resolver(chainRegistry: ChainRegistry): Caip2Resolver = RealCaip2Resolver(chainRegistry) + + @Provides + @FeatureScope + fun provideCaip19MatcherFactory( + slip44CoinRepository: Slip44CoinRepository, + caip2MatcherFactory: Caip2MatcherFactory, + ): Caip19MatcherFactory { + return RealCaip19MatcherFactory(slip44CoinRepository, caip2MatcherFactory) + } + + @Provides + @FeatureScope + fun provideCaip19Parser(caip2Parser: Caip2Parser): Caip19Parser = RealCaip19Parser(caip2Parser) +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/slip44/Slip44CoinRepository.kt b/caip/src/main/java/io/novafoundation/nova/caip/slip44/Slip44CoinRepository.kt new file mode 100644 index 0000000..f53b2a8 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/slip44/Slip44CoinRepository.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.caip.slip44 + +import io.novafoundation.nova.caip.slip44.endpoint.Slip44CoinApi +import io.novafoundation.nova.caip.slip44.endpoint.Slip44CoinRemote +import io.novafoundation.nova.runtime.ext.normalizeSymbol +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface Slip44CoinRepository { + + suspend fun getCoinCode(chainAsset: Chain.Asset): Int? +} + +internal class RealSlip44CoinRepository( + private val slip44Api: Slip44CoinApi, + private val slip44CoinsUrl: String +) : Slip44CoinRepository { + + private var slip44Coins: Map = emptyMap() + private val mutex = Mutex() + + override suspend fun getCoinCode(chainAsset: Chain.Asset): Int? = mutex.withLock { + if (slip44Coins.isEmpty()) { + slip44Coins = slip44Api.getSlip44Coins(slip44CoinsUrl) + .associateBy { it.symbol } + } + + return slip44Coins[chainAsset.normalizeSymbol()] + ?.index + ?.toIntOrNull() + } +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/slip44/endpoint/Slip44CoinApi.kt b/caip/src/main/java/io/novafoundation/nova/caip/slip44/endpoint/Slip44CoinApi.kt new file mode 100644 index 0000000..1dcf598 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/slip44/endpoint/Slip44CoinApi.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.caip.slip44.endpoint + +import retrofit2.http.GET +import retrofit2.http.Url + +interface Slip44CoinApi { + + @GET + suspend fun getSlip44Coins(@Url url: String): List +} diff --git a/caip/src/main/java/io/novafoundation/nova/caip/slip44/endpoint/Slip44CoinRemote.kt b/caip/src/main/java/io/novafoundation/nova/caip/slip44/endpoint/Slip44CoinRemote.kt new file mode 100644 index 0000000..cee8970 --- /dev/null +++ b/caip/src/main/java/io/novafoundation/nova/caip/slip44/endpoint/Slip44CoinRemote.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.caip.slip44.endpoint + +class Slip44CoinRemote( + val index: String, + val symbol: String +) diff --git a/caip/src/test/java/io/novafoundation/nova/caip/Caip19MatcherTest.kt b/caip/src/test/java/io/novafoundation/nova/caip/Caip19MatcherTest.kt new file mode 100644 index 0000000..f72c670 --- /dev/null +++ b/caip/src/test/java/io/novafoundation/nova/caip/Caip19MatcherTest.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.caip + +import io.novafoundation.nova.caip.caip19.RealCaip19MatcherFactory +import io.novafoundation.nova.caip.caip19.RealCaip19Parser +import io.novafoundation.nova.caip.caip19.identifiers.Caip19Identifier +import io.novafoundation.nova.caip.caip2.RealCaip2MatcherFactory +import io.novafoundation.nova.caip.caip2.RealCaip2Parser +import io.novafoundation.nova.caip.slip44.Slip44CoinRepository +import io.novafoundation.nova.common.utils.requireValue +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class Caip19MatcherTest { + + private val caip19MatcherFactory = RealCaip19MatcherFactory(getSlip44CoinRepository(), RealCaip2MatcherFactory()) + private val parser = RealCaip19Parser(RealCaip2Parser()) + + private val cointType = 1 + + private val substrateChainId = "0x0" + private val ethereumChainId = "eip155:1" + private val eip155ChainId = 1 + + @Mock + private lateinit var chain: Chain + + @Mock + private lateinit var chainAsset: Chain.Asset + + @Test + fun `polkadot slip44 should match`() = runBlocking { + mockChain(chainId = substrateChainId, isEthereumBased = false) + mockAsset(type = Chain.Asset.Type.Native) + val identifier = getIdentifier("polkadot:$substrateChainId/slip44:$cointType") + val matcher = caip19MatcherFactory.getCaip19Matcher(chain, chainAsset) + assertTrue(matcher.match(identifier)) + } + + @Test + fun `polkadot slip44 should not match`() = runBlocking { + mockChain(chainId = "eip155:1", isEthereumBased = true) + mockAsset(type = Chain.Asset.Type.EvmErc20("0x0")) + val identifier = getIdentifier("polkadot:${substrateChainId}/slip44:$cointType") + val matcher = caip19MatcherFactory.getCaip19Matcher(chain, chainAsset) + assertFalse(matcher.match(identifier)) + } + + @Test + fun `eip155 erc20 should match`() = runBlocking { + mockChain(chainId = ethereumChainId, isEthereumBased = true, addressPrefix = eip155ChainId) + mockAsset(type = Chain.Asset.Type.EvmErc20("0x0")) + val identifier = getIdentifier("$ethereumChainId/erc20:0x0") + val matcher = caip19MatcherFactory.getCaip19Matcher(chain, chainAsset) + assertTrue(matcher.match(identifier)) + } + + @Test + fun `substrate-chainId ethereum-based erc20 should match`() = runBlocking { + mockChain(chainId = substrateChainId, isEthereumBased = true, addressPrefix = eip155ChainId) + mockAsset(type = Chain.Asset.Type.EvmErc20("0x0")) + val identifier = getIdentifier("$ethereumChainId/erc20:0x0") + val matcher = caip19MatcherFactory.getCaip19Matcher(chain, chainAsset) + assertTrue(matcher.match(identifier)) + } + + @Test + fun `eip155 erc20 wrong coinType`() = runBlocking { + mockChain(chainId = ethereumChainId, isEthereumBased = true) + mockAsset(type = Chain.Asset.Type.EvmErc20("0x0")) + val identifier = getIdentifier("eip155:3/erc20:0x0") + val matcher = caip19MatcherFactory.getCaip19Matcher(chain, chainAsset) + assertFalse(matcher.match(identifier)) + } + + @Test + fun `eip155 erc20 should not match`() = runBlocking { + mockChain(chainId = substrateChainId, isEthereumBased = false) + mockAsset(type = Chain.Asset.Type.Native) + val identifier = getIdentifier("eip155:$cointType/erc20:0x0") + val matcher = caip19MatcherFactory.getCaip19Matcher(chain, chainAsset) + assertFalse(matcher.match(identifier)) + } + + private fun getSlip44CoinRepository(): Slip44CoinRepository { + return object : Slip44CoinRepository { + override suspend fun getCoinCode(chainAsset: Chain.Asset): Int { + return cointType + } + } + } + + private fun getIdentifier(raw: String): Caip19Identifier { + return parser.parseCaip19(raw).requireValue() + } + + private fun mockChain(chainId: String, isEthereumBased: Boolean, addressPrefix: Int? = null) { + `when`(chain.id).thenReturn(chainId) + `when`(chain.isEthereumBased).thenReturn(isEthereumBased) + + addressPrefix?.let { + `when`(chain.addressPrefix).thenReturn(addressPrefix) + } + } + + private fun mockAsset(type: Chain.Asset.Type) { + `when`(chainAsset.type).thenReturn(type) + } +} diff --git a/caip/src/test/java/io/novafoundation/nova/caip/Caip19Parser.kt b/caip/src/test/java/io/novafoundation/nova/caip/Caip19Parser.kt new file mode 100644 index 0000000..b8edcaa --- /dev/null +++ b/caip/src/test/java/io/novafoundation/nova/caip/Caip19Parser.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.caip + +import io.novafoundation.nova.caip.caip19.RealCaip19Parser +import io.novafoundation.nova.caip.caip19.identifiers.AssetIdentifier +import io.novafoundation.nova.caip.caip19.identifiers.Caip19Identifier +import io.novafoundation.nova.caip.caip2.RealCaip2Parser +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier +import io.novafoundation.nova.common.utils.requireValue +import org.junit.Assert.assertTrue +import org.junit.Test + +class Caip19ParserTest { + + private val caip2Parser = RealCaip2Parser() + private val caip19Parser = RealCaip19Parser(caip2Parser) + + @Test + fun `test substrate chain namespace`() { + val identifier = getIdentifier("polkadot:0x0/slip44:10") + assertInstance(identifier.caip2Identifier) + } + + @Test + fun `test ethereum chain namespace`() { + val identifier = getIdentifier("eip155:1/slip44:10") + assertInstance(identifier.caip2Identifier) + } + + @Test + fun `test slip44 asset namespace`() { + val identifier = getIdentifier("polkadot:0x0/slip44:10") + assertInstance(identifier.assetIdentifier) + } + + @Test + fun `test erc20 asset namespace`() { + val identifier = getIdentifier("polkadot:0x0/erc20:10") + assertInstance(identifier.assetIdentifier) + } + + private inline fun assertInstance(value: Any?) { + assertTrue(value is T) + } + + private fun getIdentifier(raw: String): Caip19Identifier { + return caip19Parser.parseCaip19(raw).requireValue() + } +} diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/common/.gitignore @@ -0,0 +1 @@ +/build diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000..9dfca97 --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,157 @@ +apply plugin: 'kotlin-parcelize' + +android { + + defaultConfig { + + buildConfigField "String", "WEBSITE_URL", "\"https://pezkuwichain.io\"" + buildConfigField "String", "PRIVACY_URL", "\"https://pezkuwichain.io/privacy\"" + buildConfigField "String", "TERMS_URL", "\"https://pezkuwichain.io/terms\"" + buildConfigField "String", "GITHUB_URL", "\"https://github.com/pezkuwichain\"" + buildConfigField "String", "TELEGRAM_URL", "\"https://t.me/pezkuwichain\"" + buildConfigField "String", "TWITTER_URL", "\"https://twitter.com/pezkuwichain\"" + buildConfigField "String", "RATE_URL", "\"market://details?id=${rootProject.applicationId}.${releaseApplicationSuffix}\"" + buildConfigField "String", "EMAIL", "\"support@pezkuwichain.io\"" + buildConfigField "String", "YOUTUBE_URL", "\"https://www.youtube.com/@SatoshiQazi\"" + + buildConfigField "String", "TWITTER_ACCOUNT_TEMPLATE", "\"https://twitter.com/%s\"" + buildConfigField "String", "RECOMMENDED_VALIDATORS_LEARN_MORE", "\"https://docs.pezkuwichain.io/wallet-wiki/staking/staking-faq#q-how-does-pezkuwi-wallet-select-validators-collators\"" + + buildConfigField "String", "PAYOUTS_LEARN_MORE", "\"https://docs.pezkuwichain.io/wallet-wiki/staking/staking-faq#q-what-is-the-difference-between-restake-rewards-and-transferable-rewards\"" + + buildConfigField "String", "SET_CONTROLLER_LEARN_MORE", "\"https://docs.pezkuwichain.io/wallet-wiki/staking/staking-faq#q-what-are-stash-and-controller-accounts\"" + + buildConfigField "String", "SET_CONTROLLER_DEPRECATED_LEARN_MORE", "\"https://docs.pezkuwichain.io/wallet-wiki/staking/controller-account-deprecation\"" + + buildConfigField "String", "PARITY_SIGNER_TROUBLESHOOTING", "\"https://docs.pezkuwichain.io/wallet-wiki/hardware-wallets/parity-signer/troubleshooting\"" + buildConfigField "String", "POLKADOT_VAULT_TROUBLESHOOTING", "\"https://docs.pezkuwichain.io/wallet-wiki/hardware-wallets/polkadot-vault/troubleshooting\"" + buildConfigField "String", "PEZKUWI_WALLET_WIKI_BASE", "\"https://docs.pezkuwichain.io/wallet-wiki/about-pezkuwi-wallet\"" + buildConfigField "String", "PEZKUWI_WALLET_WIKI_PROXY", "\"https://docs.pezkuwichain.io/wallet-wiki/wallet-management/delegated-authorities-proxies\"" + buildConfigField "String", "PEZKUWI_WALLET_WIKI_INTEGRATE_NETWORK", "\"https://docs.pezkuwichain.io/wallet-wiki/misc/developer-documentation/integrate-network\"" + + buildConfigField "String", "LEDGER_MIGRATION_ARTICLE", "\"https://docs.pezkuwichain.io/wallet-wiki/wallet-management/hardware-wallets/ledger-nano-x/ledger-app-migration\"" + + buildConfigField "String", "LEDGER_CONNECTION_GUIDE", "\"https://docs.pezkuwichain.io/wallet-wiki/wallet-management/hardware-wallets/ledger-devices\"" + + buildConfigField "String", "APP_UPDATE_SOURCE_LINK", "\"https://wallet.pezkuwichain.io\"" + + buildConfigField "String", "PEZKUWI_CARD_WIDGET_URL", "\"https://exchange.mercuryo.io\"" + + buildConfigField "String", "ASSET_COLORED_ICON_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/main/icons/tokens/colored\"" + buildConfigField "String", "ASSET_WHITE_ICON_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/main/icons/tokens/white\"" + + buildConfigField "String", "UNIFIED_ADDRESS_ARTICLE", "\"https://docs.pezkuwichain.io/wallet-wiki/asset-management/how-to-receive-tokens#unified-and-legacy-addresses\"" + + buildConfigField "String", "MULTISIGS_WIKI_URL", "\"https://docs.pezkuwichain.io/wallet-wiki/wallet-management/multisig-wallets\"" + + buildConfigField "String", "GIFTS_WIKI_URL", "\"https://docs.pezkuwichain.io/wallet-wiki/asset-management/gifting-tokens\"" + + buildConfigField "long", "CLOUD_PROJECT_NUMBER", "171267697857L" + + buildConfigField "String", "GLOBAL_CONFIG_URL", "\"https://wallet.pezkuwichain.io/config/global_config_dev.json\"" + } + + buildTypes { + debug { + + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + buildConfigField "long", "CLOUD_PROJECT_NUMBER", "802342409053L" + + buildConfigField "String", "GLOBAL_CONFIG_URL", "\"https://wallet.pezkuwichain.io/config/global_config.json\"" + } + + releaseGithub { + initWith buildTypes.release + matchingFallbacks = ['release'] + buildConfigField "String", "APP_UPDATE_SOURCE_LINK", "\"https://github.com/pezkuwichain/pezWallet/releases\"" + } + } + + namespace 'io.novafoundation.nova.common' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation(name: 'renderscript-toolkit', ext: 'aar') + + api project(":core-api") + + implementation kotlinDep + + implementation androidDep + implementation cardViewDep + implementation recyclerViewDep + implementation materialDep + implementation constraintDep + + implementation biometricDep + + implementation bouncyCastleDep + + api substrateSdkDep + + implementation coroutinesDep + api liveDataKtxDep + implementation lifeCycleKtxDep + + implementation viewModelKtxDep + + implementation daggerDep + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + implementation retrofitDep + api gsonConvertedDep + implementation scalarsConverterDep + implementation interceptorVersion + + implementation zXingCoreDep + implementation zXingEmbeddedDep + + implementation progressButtonDep + + implementation wsDep + + api insetterDep + + api coilDep + api coilSvg + + api web3jDep + api coroutinesFutureDep + api coroutinesRxDep + + implementation shimmerDep + + implementation playIntegrity + + testImplementation jUnitDep + testImplementation mockitoDep + testImplementation project(':test-shared') + + implementation permissionsDep + + implementation flexBoxDep + + implementation markwonDep + implementation markwonImage + implementation markwonTables + implementation markwonLinkify + implementation markwonStrikethrough + implementation markwonHtml + implementation kotlinReflect + + implementation playServicesAuthDep + +} \ No newline at end of file diff --git a/common/libs/renderscript-toolkit.aar b/common/libs/renderscript-toolkit.aar new file mode 100644 index 0000000..77670e5 Binary files /dev/null and b/common/libs/renderscript-toolkit.aar differ diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4713b75 --- /dev/null +++ b/common/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/common/src/main/java/io/novafoundation/nova/common/address/AccountId.kt b/common/src/main/java/io/novafoundation/nova/common/address/AccountId.kt new file mode 100644 index 0000000..4274775 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/AccountId.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.common.address + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novafoundation.nova.common.utils.HexString +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId + +class AccountIdKey(val value: AccountId) { + + companion object; + + override fun equals(other: Any?): Boolean { + return this === other || other is AccountIdKey && this.value contentEquals other.value + } + + override fun hashCode(): Int = value.contentHashCode() + + override fun toString(): String = value.contentToString() +} + +fun AccountId.intoKey() = AccountIdKey(this) + +fun AccountIdKey.toHex(): HexString { + return value.toHexString() +} + +fun AccountIdKey.toHexWithPrefix(): HexString { + return value.toHexString(withPrefix = true) +} + +fun AccountIdKey.Companion.fromHex(src: HexString): Result { + return runCatching { src.fromHex().intoKey() } +} + +fun AccountIdKey.Companion.fromHexOrNull(src: HexString): AccountIdKey? { + return fromHex(src).getOrNull() +} + +fun AccountIdKey.Companion.fromHexOrThrow(src: HexString): AccountIdKey { + return fromHex(src).getOrThrow() +} + +operator fun Map.get(key: AccountId) = get(AccountIdKey(key)) +fun Map.getValue(key: AccountId) = getValue(AccountIdKey(key)) + +interface WithAccountId { + + val accountId: AccountIdKey +} diff --git a/common/src/main/java/io/novafoundation/nova/common/address/AccountIdParcel.kt b/common/src/main/java/io/novafoundation/nova/common/address/AccountIdParcel.kt new file mode 100644 index 0000000..6a56aea --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/AccountIdParcel.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.common.address + +import android.os.Parcelable +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.android.parcel.Parcelize + +@Parcelize +class AccountIdParcel(private val value: AccountId) : Parcelable { + + companion object { + + fun fromHex(hexAccountId: String): AccountIdParcel { + return AccountIdParcel(hexAccountId.fromHex()) + } + } + + val accountId: AccountIdKey + get() = value.intoKey() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/address/AccountIdSerialization.kt b/common/src/main/java/io/novafoundation/nova/common/address/AccountIdSerialization.kt new file mode 100644 index 0000000..fabac1d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/AccountIdSerialization.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.common.address + +import com.google.gson.JsonArray +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import java.lang.reflect.Type + +class AccountIdSerializer : JsonSerializer, JsonDeserializer { + override fun serialize(src: AccountIdKey, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + return JsonPrimitive(src.toHex()) + } + + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): AccountIdKey { + return AccountIdKey.fromHex(json.asString).getOrThrow() + } +} + +class AccountIdKeyListAdapter : JsonSerializer>, JsonDeserializer> { + private val delegate = AccountIdSerializer() + + override fun serialize( + src: List, + typeOfSrc: Type, + context: JsonSerializationContext + ): JsonElement { + val jsonArray = JsonArray() + src.forEach { jsonArray.add(delegate.serialize(it, AccountIdKey::class.java, context)) } + return jsonArray + } + + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): List { + return json.asJsonArray.map { + delegate.deserialize(it, AccountIdKey::class.java, context) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/address/AddressIconGenerator.kt b/common/src/main/java/io/novafoundation/nova/common/address/AddressIconGenerator.kt new file mode 100644 index 0000000..e207634 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/AddressIconGenerator.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.common.address + +import android.graphics.drawable.Drawable +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.resources.ResourceManager +import io.novasama.substrate_sdk_android.exceptions.AddressFormatException +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.icon.IconGenerator +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +// TODO ethereum address icon generation +interface AddressIconGenerator { + + companion object { + + const val SIZE_SMALL = 18 + const val SIZE_MEDIUM = 24 + const val SIZE_BIG = 32 + + val BACKGROUND_LIGHT = R.color.address_icon_background + val BACKGROUND_TRANSPARENT = android.R.color.transparent + + val BACKGROUND_DEFAULT = BACKGROUND_LIGHT + } + + suspend fun createAddressIcon( + accountId: AccountId, + sizeInDp: Int, + @ColorRes backgroundColorRes: Int = BACKGROUND_DEFAULT + ): Drawable +} + +@Throws(AddressFormatException::class) +suspend fun AddressIconGenerator.createSubstrateAddressModel( + accountAddress: String, + sizeInDp: Int, + accountName: String? = null, + @ColorRes background: Int = AddressIconGenerator.BACKGROUND_DEFAULT +): AddressModel { + val icon = createSubstrateAddressIcon(accountAddress, sizeInDp, background) + + return AddressModel(accountAddress, icon, accountName) +} + +@Throws(AddressFormatException::class) +suspend fun AddressIconGenerator.createSubstrateAddressIcon( + accountAddress: String, + sizeInDp: Int, + @ColorRes background: Int = AddressIconGenerator.BACKGROUND_DEFAULT +) = withContext(Dispatchers.Default) { + val addressId = accountAddress.toAccountId() + + createAddressIcon(addressId, sizeInDp, background) +} + +class CachingAddressIconGenerator( + private val delegate: AddressIconGenerator +) : AddressIconGenerator { + + val cache = ConcurrentHashMap() + + override suspend fun createAddressIcon( + accountId: AccountId, + sizeInDp: Int, + @ColorRes backgroundColorRes: Int + ): Drawable = withContext(Dispatchers.Default) { + val key = "${accountId.toHexString()}:$sizeInDp:$backgroundColorRes" + + cache.getOrPut(key) { + delegate.createAddressIcon(accountId, sizeInDp, backgroundColorRes) + } + } +} + +class StatelessAddressIconGenerator( + private val iconGenerator: IconGenerator, + private val resourceManager: ResourceManager +) : AddressIconGenerator { + + override suspend fun createAddressIcon( + accountId: AccountId, + sizeInDp: Int, + @ColorRes backgroundColorRes: Int + ) = withContext(Dispatchers.Default) { + val sizeInPx = resourceManager.measureInPx(sizeInDp) + val backgroundColor = resourceManager.getColor(backgroundColorRes) + + val drawable = iconGenerator.getSvgImage(accountId, sizeInPx, backgroundColor = backgroundColor) + drawable.setBounds(0, 0, sizeInPx, sizeInPx) + drawable + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/address/AddressModel.kt b/common/src/main/java/io/novafoundation/nova/common/address/AddressModel.kt new file mode 100644 index 0000000..bb13c04 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/AddressModel.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.address + +import android.graphics.drawable.Drawable + +open class AddressModel( + val address: String, + val image: Drawable, + val name: String? = null +) { + val nameOrAddress = name ?: address +} + +class OptionalAddressModel( + val address: String, + val image: Drawable?, + val name: String? = null +) { + val nameOrAddress = name ?: address +} diff --git a/common/src/main/java/io/novafoundation/nova/common/address/format/AddressFormat.kt b/common/src/main/java/io/novafoundation/nova/common/address/format/AddressFormat.kt new file mode 100644 index 0000000..d374de5 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/format/AddressFormat.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.common.address.format + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.UNIFIED_ADDRESS_PREFIX +import io.novasama.substrate_sdk_android.ss58.SS58Encoder + +interface AddressFormat { + + val scheme: AddressScheme + + companion object { + + fun evm(): AddressFormat { + return EthereumAddressFormat() + } + + fun defaultForScheme(scheme: AddressScheme, substrateAddressPrefix: Short = SS58Encoder.UNIFIED_ADDRESS_PREFIX): AddressFormat { + return when (scheme) { + AddressScheme.EVM -> EthereumAddressFormat() + AddressScheme.SUBSTRATE -> SubstrateAddressFormat.forSS58rPrefix(substrateAddressPrefix) + } + } + } + + @JvmInline + value class PublicKey(val value: ByteArray) + + @JvmInline + value class AccountId(val value: ByteArray) + + @JvmInline + value class Address(val value: String) + + fun addressOf(accountId: AccountId): Address + + fun accountIdOf(address: Address): AccountId + + fun accountIdOf(publicKey: PublicKey): AccountId + + fun isValidAddress(address: Address): Boolean +} + +fun ByteArray.asPublicKey() = AddressFormat.PublicKey(this) +fun ByteArray.asAccountId() = AddressFormat.AccountId(this) +fun String.asAddress() = AddressFormat.Address(this) + +fun AddressFormat.addressOf(accountIdKey: AccountIdKey): AddressFormat.Address { + return addressOf(accountIdKey.value.asAccountId()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/address/format/AddressScheme.kt b/common/src/main/java/io/novafoundation/nova/common/address/format/AddressScheme.kt new file mode 100644 index 0000000..d2f0530 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/format/AddressScheme.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.common.address.format + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novasama.substrate_sdk_android.runtime.AccountId + +enum class AddressScheme { + + /** + * 20-byte address, Ethereum-like address encoding + */ + EVM, + + /** + * 32-byte address, ss58 address encoding + */ + SUBSTRATE; + + companion object { + fun findFromAccountId(accountId: AccountId): AddressScheme? { + return when (accountId.size) { + 32 -> SUBSTRATE + 20 -> EVM + else -> null + } + } + } +} + +fun AccountIdKey.getAddressScheme(): AddressScheme? { + return AddressScheme.findFromAccountId(value) +} + +fun AccountIdKey.getAddressSchemeOrThrow(): AddressScheme { + return requireNotNull(getAddressScheme()) { + "Could not detect address scheme from account id of length ${value.size}" + } +} + +val AddressScheme.defaultOrdering + get() = when (this) { + AddressScheme.SUBSTRATE -> 0 + AddressScheme.EVM -> 1 + } + +fun AddressScheme.isSubstrate(): Boolean { + return this == AddressScheme.SUBSTRATE +} + +fun AddressScheme.isEvm(): Boolean { + return this == AddressScheme.EVM +} diff --git a/common/src/main/java/io/novafoundation/nova/common/address/format/AddressSchemeFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/address/format/AddressSchemeFormatter.kt new file mode 100644 index 0000000..bd2f5d8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/format/AddressSchemeFormatter.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.common.address.format + +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.resources.ResourceManager +import javax.inject.Inject + +interface AddressSchemeFormatter { + + fun addressLabel(addressScheme: AddressScheme): String + + fun accountsLabel(addressScheme: AddressScheme): String +} + +@ApplicationScope +internal class RealAddressSchemeFormatter @Inject constructor( + private val resourceManager: ResourceManager +) : AddressSchemeFormatter { + + override fun addressLabel(addressScheme: AddressScheme): String { + return when (addressScheme) { + AddressScheme.SUBSTRATE -> resourceManager.getString(R.string.common_substrate_address) + AddressScheme.EVM -> resourceManager.getString(R.string.common_evm_address) + } + } + + override fun accountsLabel(addressScheme: AddressScheme): String { + return when (addressScheme) { + AddressScheme.SUBSTRATE -> resourceManager.getString(R.string.account_substrate_accounts) + AddressScheme.EVM -> resourceManager.getString(R.string.account_evm_accounts) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/address/format/EthereumAddressFormat.kt b/common/src/main/java/io/novafoundation/nova/common/address/format/EthereumAddressFormat.kt new file mode 100644 index 0000000..d66df67 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/format/EthereumAddressFormat.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.address.format + +import io.novasama.substrate_sdk_android.extensions.asEthereumAccountId +import io.novasama.substrate_sdk_android.extensions.asEthereumAddress +import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey +import io.novasama.substrate_sdk_android.extensions.isValid +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.extensions.toAddress + +class EthereumAddressFormat : AddressFormat { + + override val scheme: AddressScheme = AddressScheme.EVM + + override fun addressOf(accountId: AddressFormat.AccountId): AddressFormat.Address { + return accountId.value.asEthereumAccountId() + .toAddress().value.asAddress() + } + + override fun accountIdOf(address: AddressFormat.Address): AddressFormat.AccountId { + return address.value.asEthereumAddress() + .toAccountId().value.asAccountId() + } + + override fun accountIdOf(publicKey: AddressFormat.PublicKey): AddressFormat.AccountId { + return publicKey.value.asEthereumPublicKey() + .toAccountId().value.asAccountId() + } + + override fun isValidAddress(address: AddressFormat.Address): Boolean { + return address.value.asEthereumAddress().isValid() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/address/format/SubstrateAddressFormat.kt b/common/src/main/java/io/novafoundation/nova/common/address/format/SubstrateAddressFormat.kt new file mode 100644 index 0000000..efda36c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/address/format/SubstrateAddressFormat.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.common.address.format + +import io.novafoundation.nova.common.utils.GENERIC_ADDRESS_PREFIX +import io.novafoundation.nova.common.utils.substrateAccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.addressPrefix +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAddress + +class SubstrateAddressFormat private constructor( + private val addressPrefix: Short? +) : AddressFormat { + + override val scheme: AddressScheme = AddressScheme.SUBSTRATE + + companion object { + + fun forSS58rPrefix(prefix: Short): SubstrateAddressFormat { + return SubstrateAddressFormat(prefix) + } + } + + override fun addressOf(accountId: AddressFormat.AccountId): AddressFormat.Address { + val addressPrefixOrDefault = addressPrefix ?: SS58Encoder.GENERIC_ADDRESS_PREFIX + return accountId.value.toAddress(addressPrefixOrDefault).asAddress() + } + + override fun accountIdOf(address: AddressFormat.Address): AddressFormat.AccountId { + val accountId = address.value.toAccountId() + + addressPrefix?.let { + require(addressPrefix == address.value.addressPrefix()) { + "Address prefix mismatch. Expected: $addressPrefix, Got: ${address.value}" + } + } + + return accountId.asAccountId() + } + + override fun accountIdOf(publicKey: AddressFormat.PublicKey): AddressFormat.AccountId { + return publicKey.value.substrateAccountId().asAccountId() + } + + override fun isValidAddress(address: AddressFormat.Address): Boolean { + return kotlin.runCatching { accountIdOf(address) }.isSuccess + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/BaseActivity.kt b/common/src/main/java/io/novafoundation/nova/common/base/BaseActivity.kt new file mode 100644 index 0000000..ec08b58 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/BaseActivity.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.common.base + +import android.content.Context +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.utils.showToast +import javax.inject.Inject + +abstract class BaseActivity : + AppCompatActivity(), BaseScreenMixin { + + override val providedContext: Context + get() = this + + override val lifecycleOwner: LifecycleOwner + get() = this + + protected lateinit var binder: B + private set + + @Inject + override lateinit var viewModel: T + + protected abstract fun createBinding(): B + + override fun attachBaseContext(base: Context) { + val commonApi = (base.applicationContext as FeatureContainer).commonApi() + val contextManager = commonApi.contextManager() + applyOverrideConfiguration(contextManager.setLocale(base).resources.configuration) + super.attachBaseContext(contextManager.setLocale(base)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + binder = createBinding() + + setContentView(binder.root) + + inject() + initViews() + subscribe(viewModel) + + viewModel.errorLiveData.observeEvent(::showError) + + viewModel.toastLiveData.observeEvent { showToast(it) } + } + + abstract fun changeLanguage() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/BaseBottomSheetFragment.kt b/common/src/main/java/io/novafoundation/nova/common/base/BaseBottomSheetFragment.kt new file mode 100644 index 0000000..50899ad --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/BaseBottomSheetFragment.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.common.base + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import javax.inject.Inject + +abstract class BaseBottomSheetFragment : BottomSheetDialogFragment(), BaseFragmentMixin { + + @Inject + override lateinit var viewModel: T + + protected lateinit var binder: B + private set + + override val fragment: Fragment + get() = this + + private val delegate by lazy(LazyThreadSafetyMode.NONE) { BaseFragmentDelegate(this) } + + protected abstract fun createBinding(): B + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binder = createBinding() + return binder.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + delegate.onViewCreated(view, savedInstanceState) + } + + protected fun getBehaviour(): BottomSheetBehavior<*> { + return (dialog as BottomSheetDialog).behavior + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/BaseException.kt b/common/src/main/java/io/novafoundation/nova/common/base/BaseException.kt new file mode 100644 index 0000000..099961e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/BaseException.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.base + +import java.io.IOException + +class BaseException( + val kind: Kind, + message: String, + exception: Throwable? = null +) : RuntimeException(message, exception) { + + enum class Kind { + BUSINESS, + NETWORK, + HTTP, + UNEXPECTED + } + + companion object { + + fun businessError(message: String): BaseException { + return BaseException(Kind.BUSINESS, message) + } + + fun httpError(errorCode: Int, message: String): BaseException { + return BaseException(Kind.HTTP, message) + } + + fun networkError(message: String, exception: IOException): BaseException { + return BaseException(Kind.NETWORK, message, exception) + } + + fun unexpectedError(exception: Throwable): BaseException { + return BaseException(Kind.UNEXPECTED, exception.message ?: "", exception) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/BaseFragment.kt b/common/src/main/java/io/novafoundation/nova/common/base/BaseFragment.kt new file mode 100644 index 0000000..ef4f0c6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/BaseFragment.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.common.base + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.children +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import javax.inject.Inject + +abstract class BaseFragment : Fragment(), BaseFragmentMixin { + + @Inject + override lateinit var viewModel: T + + protected lateinit var binder: B + private set + + override val fragment: Fragment + get() = this + + private val delegate by lazy(LazyThreadSafetyMode.NONE) { BaseFragmentDelegate(this) } + + protected abstract fun createBinding(): B + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binder = createBinding() + return binder.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + applyInsetsToChildrenLegacy() + applyInsets(view) + + delegate.onViewCreated(view, savedInstanceState) + } + + open fun applyInsets(rootView: View) { + rootView.applySystemBarInsets() + } + + /** + * Fix insets for android 7-10. + * For some reason Fragments doesn't send insets to their children after root view so we push them forcibly + * TODO: I haven't found the reason of this issue so I think this fix is temporary until we found the reason + */ + private fun applyInsetsToChildrenLegacy() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) { + ViewCompat.setOnApplyWindowInsetsListener(binder.root) { view, insets -> + val viewGroup = (view as? ViewGroup) ?: return@setOnApplyWindowInsetsListener insets + viewGroup.children.forEach { + ViewCompat.dispatchApplyWindowInsets(it, insets) + } + insets + } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/BaseFragmentMixin.kt b/common/src/main/java/io/novafoundation/nova/common/base/BaseFragmentMixin.kt new file mode 100644 index 0000000..e49c692 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/BaseFragmentMixin.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.common.base + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.widget.EditText +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.showToast +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +interface BaseFragmentMixin : BaseScreenMixin { + + val fragment: Fragment + + override val providedContext: Context + get() = fragment.requireContext() + + override val lifecycleOwner: LifecycleOwner + get() = fragment.viewLifecycleOwner + + fun onBackPressed(action: () -> Unit) { + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + action() + } + } + + fragment.requireActivity().onBackPressedDispatcher.addCallback(fragment.viewLifecycleOwner, callback) + } + + fun Flow.observe(collector: suspend (V) -> Unit) { + fragment.lifecycleScope.launchWhenResumed { + collect(collector) + } + } + + fun Flow.observeWhenCreated(collector: suspend (V) -> Unit) { + fragment.lifecycleScope.launchWhenCreated { + collect(collector) + } + } + + fun Flow.observeFirst(collector: suspend (V) -> Unit) { + fragment.lifecycleScope.launchWhenCreated { + collector(first()) + } + } + + fun Flow.observeWhenVisible(collector: suspend (V) -> Unit) { + fragment.viewLifecycleOwner.lifecycleScope.launchWhenResumed { + collect(collector) + } + } + + fun LiveData.observe(observer: (V) -> Unit) { + observe(fragment.viewLifecycleOwner, observer) + } + + fun EditText.bindTo(liveData: MutableLiveData) = bindTo(liveData, fragment.viewLifecycleOwner) + + @Suppress("UNCHECKED_CAST") + fun argument(key: String): A = fragment.arguments!![key] as A + + @Suppress("UNCHECKED_CAST") + fun argumentOrNull(key: String): A? = fragment.arguments?.get(key) as? A +} + +class BaseFragmentDelegate( + private val mixin: BaseFragmentMixin +) { + + fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(mixin) { + inject() + initViews() + subscribe(viewModel) + + viewModel.errorLiveData.observeEvent(::showError) + + viewModel.errorWithTitleLiveData.observeEvent { + showErrorWithTitle(it.first, it.second) + } + + viewModel.toastLiveData.observeEvent { view.context.showToast(it) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/BaseFragmentMixinExt.kt b/common/src/main/java/io/novafoundation/nova/common/base/BaseFragmentMixinExt.kt new file mode 100644 index 0000000..c095988 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/BaseFragmentMixinExt.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.base + +fun BaseFragmentMixin<*>.blockBackPressing() = onBackPressed { + // do nothing +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/BaseScreenMixin.kt b/common/src/main/java/io/novafoundation/nova/common/base/BaseScreenMixin.kt new file mode 100644 index 0000000..f3d6a31 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/BaseScreenMixin.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.common.base + +import android.widget.Toast +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.WithLifecycleExtensions +import io.novafoundation.nova.common.view.dialog.dialog + +interface BaseScreenMixin : WithContextExtensions, WithLifecycleExtensions { + + val viewModel: T + + fun initViews() + + fun inject() + + fun subscribe(viewModel: T) + + fun showError(errorMessage: String) { + dialog(providedContext) { + setTitle(providedContext.getString(R.string.common_error_general_title)) + setMessage(errorMessage) + setPositiveButton(R.string.common_ok) { _, _ -> } + } + } + + fun showErrorWithTitle(title: String, errorMessage: CharSequence?) { + dialog(providedContext) { + setTitle(title) + setMessage(errorMessage) + setPositiveButton(R.string.common_ok) { _, _ -> } + } + } + + fun showMessage(text: String) { + Toast.makeText(providedContext, text, Toast.LENGTH_SHORT) + .show() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/BaseViewModel.kt b/common/src/main/java/io/novafoundation/nova/common/base/BaseViewModel.kt new file mode 100644 index 0000000..17a06ec --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/BaseViewModel.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.common.base + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.errors.shouldIgnore +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.validation.ProgressConsumer +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystem +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.CoroutineContext + +typealias TitleAndMessage = Pair + +open class BaseViewModel : + ViewModel(), + CoroutineScope, + ComputationalScope, + WithCoroutineScopeExtensions { + + private val _errorLiveData = MutableLiveData>() + val errorLiveData: LiveData> = _errorLiveData + + private val _errorWithTitleLiveData = MutableLiveData>() + val errorWithTitleLiveData: LiveData> = _errorWithTitleLiveData + + private val _toastLiveData = MutableLiveData>() + val toastLiveData: LiveData> = _toastLiveData + + fun showToast(text: String) { + _toastLiveData.postValue(Event(text)) + } + + fun showError(title: String, text: CharSequence) { + _errorWithTitleLiveData.postValue(Event(title to text)) + } + + fun showError(text: String) { + _errorLiveData.postValue(Event(text)) + } + + fun showError(throwable: Throwable) { + if (!shouldIgnore(throwable)) { + throwable.printStackTrace() + + throwable.message?.let(this::showError) + } + } + + override val coroutineContext: CoroutineContext + get() = viewModelScope.coroutineContext + + override val coroutineScope: CoroutineScope + get() = this + + suspend fun ValidationExecutor.requireValid( + validationSystem: ValidationSystem, + payload: P, + validationFailureTransformer: (S) -> TitleAndMessage, + progressConsumer: ProgressConsumer? = null, + autoFixPayload: (original: P, failureStatus: S) -> P = { original, _ -> original }, + block: (P) -> Unit, + ) = requireValid( + validationSystem = validationSystem, + payload = payload, + errorDisplayer = { showError(it) }, + validationFailureTransformerDefault = validationFailureTransformer, + progressConsumer = progressConsumer, + autoFixPayload = autoFixPayload, + block = block, + scope = viewModelScope + ) + + suspend fun ValidationExecutor.requireValid( + validationSystem: ValidationSystem, + payload: P, + validationFailureTransformerCustom: (ValidationStatus.NotValid, ValidationFlowActions

) -> TransformedFailure?, + autoFixPayload: (original: P, failureStatus: S) -> P = { original, _ -> original }, + progressConsumer: ProgressConsumer? = null, + block: (P) -> Unit, + ) = requireValid( + validationSystem = validationSystem, + payload = payload, + errorDisplayer = ::showError, + validationFailureTransformerCustom = validationFailureTransformerCustom, + progressConsumer = progressConsumer, + autoFixPayload = autoFixPayload, + block = block, + scope = viewModelScope + ) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/BaseViewModelExt.kt b/common/src/main/java/io/novafoundation/nova/common/base/BaseViewModelExt.kt new file mode 100644 index 0000000..9cd1dec --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/BaseViewModelExt.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.base + +fun BaseViewModel.showError(model: TitleAndMessage) { + if (model.second != null) { + showError(model.first, model.second!!) + } else { + showError(model.first) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/errors/CompoundException.kt b/common/src/main/java/io/novafoundation/nova/common/base/errors/CompoundException.kt new file mode 100644 index 0000000..5c407e3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/errors/CompoundException.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.common.base.errors + +class CompoundException(val nested: List) : Exception() diff --git a/common/src/main/java/io/novafoundation/nova/common/base/errors/NovaException.kt b/common/src/main/java/io/novafoundation/nova/common/base/errors/NovaException.kt new file mode 100644 index 0000000..f99b228 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/errors/NovaException.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.common.base.errors + +import io.novafoundation.nova.common.resources.ResourceManager + +class NovaException( + val kind: Kind, + message: String?, + exception: Throwable? = null, +) : RuntimeException(message, exception) { + + enum class Kind { + NETWORK, + UNEXPECTED + } + + companion object { + + fun networkError(resourceManager: ResourceManager, throwable: Throwable): NovaException { + return NovaException(Kind.NETWORK, "", throwable) // TODO: add common error text to resources + } + + fun unexpectedError(exception: Throwable): NovaException { + return NovaException(Kind.UNEXPECTED, exception.message ?: "", exception) // TODO: add common error text to resources + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/base/errors/SigningCancelledException.kt b/common/src/main/java/io/novafoundation/nova/common/base/errors/SigningCancelledException.kt new file mode 100644 index 0000000..f72b7af --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/errors/SigningCancelledException.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.common.base.errors + +class SigningCancelledException : Exception() diff --git a/common/src/main/java/io/novafoundation/nova/common/base/errors/Ui.kt b/common/src/main/java/io/novafoundation/nova/common/base/errors/Ui.kt new file mode 100644 index 0000000..1c2d6cb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/base/errors/Ui.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.common.base.errors + +fun shouldIgnore(exception: Throwable) = exception is SigningCancelledException diff --git a/common/src/main/java/io/novafoundation/nova/common/data/FileProviderImpl.kt b/common/src/main/java/io/novafoundation/nova/common/data/FileProviderImpl.kt new file mode 100644 index 0000000..20198c3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/FileProviderImpl.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.common.data + +import android.content.Context +import android.net.Uri +import io.novafoundation.nova.common.interfaces.FileProvider +import java.io.File +import java.util.UUID +import androidx.core.content.FileProvider as AndroidFileProvider + +class FileProviderImpl( + private val context: Context +) : FileProvider { + + override fun getFileInExternalCacheStorage(fileName: String): File { + val cacheDir = context.externalCacheDir?.absolutePath ?: directoryNotAvailable() + + return File(cacheDir, fileName) + } + + override fun getFileInInternalCacheStorage(fileName: String): File { + val cacheDir = context.cacheDir?.absolutePath ?: directoryNotAvailable() + + return File(cacheDir, fileName) + } + + override fun generateTempFile(fixedName: String?): File { + val name = fixedName ?: UUID.randomUUID().toString() + + return getFileInExternalCacheStorage(name) + } + + override fun uriOf(file: File): Uri { + return AndroidFileProvider.getUriForFile(context, "${context.packageName}.provider", file) + } + + private fun directoryNotAvailable(): Nothing { + throw IllegalStateException("Cache directory is unavailable") + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/GoogleApiAvailabilityProvider.kt b/common/src/main/java/io/novafoundation/nova/common/data/GoogleApiAvailabilityProvider.kt new file mode 100644 index 0000000..49f27e3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/GoogleApiAvailabilityProvider.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.common.data + +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability + +interface GoogleApiAvailabilityProvider { + + fun isAvailable(): Boolean +} + +internal class RealGoogleApiAvailabilityProvider( + val context: Context +) : GoogleApiAvailabilityProvider { + + override fun isAvailable(): Boolean { + val googleApiAvailability = GoogleApiAvailability.getInstance() + val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(context) + return resultCode == ConnectionResult.SUCCESS + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/config/GlobalConfigApi.kt b/common/src/main/java/io/novafoundation/nova/common/data/config/GlobalConfigApi.kt new file mode 100644 index 0000000..69d60e3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/config/GlobalConfigApi.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.data.config + +import io.novafoundation.nova.common.BuildConfig +import retrofit2.http.GET + +interface GlobalConfigApi { + + @GET(BuildConfig.GLOBAL_CONFIG_URL) + suspend fun getGlobalConfig(): GlobalConfigRemote +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/config/GlobalConfigDataSource.kt b/common/src/main/java/io/novafoundation/nova/common/data/config/GlobalConfigDataSource.kt new file mode 100644 index 0000000..98e1f73 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/config/GlobalConfigDataSource.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.common.data.config + +import io.novafoundation.nova.common.domain.config.GlobalConfig +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface GlobalConfigDataSource { + + suspend fun getGlobalConfig(): GlobalConfig +} + +class RealGlobalConfigDataSource( + private val api: GlobalConfigApi +) : GlobalConfigDataSource { + + private var globalConfig: GlobalConfig? = null + private val mutex = Mutex() + + override suspend fun getGlobalConfig(): GlobalConfig { + if (globalConfig != null) return globalConfig!! + + mutex.withLock { + if (globalConfig != null) return globalConfig!! + + val remoteConfig = api.getGlobalConfig() + globalConfig = remoteConfig.toDomain() + } + + return globalConfig!! + } + + private fun GlobalConfigRemote.toDomain() = GlobalConfig( + multisigsApiUrl = multisigsApiUrl, + proxyApiUrl = proxyApiUrl, + multiStakingApiUrl = multiStakingApiUrl + ) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/config/GlobalConfigRemote.kt b/common/src/main/java/io/novafoundation/nova/common/data/config/GlobalConfigRemote.kt new file mode 100644 index 0000000..5c07cfb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/config/GlobalConfigRemote.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.common.data.config + +class GlobalConfigRemote( + val multisigsApiUrl: String, + val proxyApiUrl: String, + val multiStakingApiUrl: String +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/holders/ChainIdHolder.kt b/common/src/main/java/io/novafoundation/nova/common/data/holders/ChainIdHolder.kt new file mode 100644 index 0000000..7c541f4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/holders/ChainIdHolder.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.common.data.holders + +interface ChainIdHolder { + + suspend fun chainId(): String +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/holders/RuntimeHolder.kt b/common/src/main/java/io/novafoundation/nova/common/data/holders/RuntimeHolder.kt new file mode 100644 index 0000000..9a7d3ee --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/holders/RuntimeHolder.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.data.holders + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +interface RuntimeHolder { + + suspend fun runtime(): RuntimeSnapshot +} + +suspend inline fun RuntimeHolder.useRuntime(block: (RuntimeSnapshot) -> T) = block(runtime()) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/mappers/EncryptionTypeMappers.kt b/common/src/main/java/io/novafoundation/nova/common/data/mappers/EncryptionTypeMappers.kt new file mode 100644 index 0000000..357382c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/mappers/EncryptionTypeMappers.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.common.data.mappers + +import io.novafoundation.nova.core.model.CryptoType +import io.novasama.substrate_sdk_android.encrypt.EncryptionType + +fun mapCryptoTypeToEncryption(cryptoType: CryptoType): EncryptionType { + return when (cryptoType) { + CryptoType.SR25519 -> EncryptionType.SR25519 + CryptoType.ED25519 -> EncryptionType.ED25519 + CryptoType.ECDSA -> EncryptionType.ECDSA + } +} + +fun mapEncryptionToCryptoType(cryptoType: EncryptionType): CryptoType { + return when (cryptoType) { + EncryptionType.SR25519 -> CryptoType.SR25519 + EncryptionType.ED25519 -> CryptoType.ED25519 + EncryptionType.ECDSA -> CryptoType.ECDSA + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/memory/ComputationalCache.kt b/common/src/main/java/io/novafoundation/nova/common/data/memory/ComputationalCache.kt new file mode 100644 index 0000000..690bdc3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/memory/ComputationalCache.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.common.data.memory + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface ComputationalCache { + + /** + * Caches [computation] between calls until all supplied [scope]s have been cancelled + */ + suspend fun useCache( + key: String, + scope: CoroutineScope, + computation: suspend CoroutineScope.() -> T + ): T + + fun useSharedFlow( + key: String, + scope: CoroutineScope, + flowLazy: suspend CoroutineScope.() -> Flow + ): Flow +} + +context(ComputationalScope) +suspend fun ComputationalCache.useCache( + key: String, + computation: suspend CoroutineScope.() -> T +): T = useCache(key, this@ComputationalScope, computation) + +context(ComputationalScope) +fun ComputationalCache.useSharedFlow( + key: String, + flowLazy: suspend CoroutineScope.() -> Flow +): Flow = useSharedFlow(key, this@ComputationalScope, flowLazy) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/memory/ComputationalScope.kt b/common/src/main/java/io/novafoundation/nova/common/data/memory/ComputationalScope.kt new file mode 100644 index 0000000..f6110fa --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/memory/ComputationalScope.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.data.memory + +import kotlinx.coroutines.CoroutineScope + +/** + * A specialization of `CoroutineScope` to avoid context receiver pollution when used as `context(ComputationalScope)` + */ +interface ComputationalScope : CoroutineScope + +fun ComputationalScope(scope: CoroutineScope): ComputationalScope = InlineComputationalScope(scope) + +@JvmInline +private value class InlineComputationalScope(val scope: CoroutineScope) : ComputationalScope, CoroutineScope by scope diff --git a/common/src/main/java/io/novafoundation/nova/common/data/memory/LazyAsyncCache.kt b/common/src/main/java/io/novafoundation/nova/common/data/memory/LazyAsyncCache.kt new file mode 100644 index 0000000..aeb30e9 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/memory/LazyAsyncCache.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.common.data.memory + +import io.novafoundation.nova.common.utils.invokeOnCompletion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface LazyAsyncCache { + + suspend fun getOrCompute(key: K): V +} + +/** + * In-memory cache primitive that caches asynchronously computed value + * Lifetime of the cache itself is determine by supplied [CoroutineScope] + */ +fun LazyAsyncCache(coroutineScope: CoroutineScope, compute: AsyncCacheCompute): LazyAsyncCache { + return RealLazyAsyncCache(coroutineScope, compute) +} + +/** + * Specialization of [LazyAsyncCache] that's cached value is a [SharedFlow] shared in the supplied [coroutineScope] + */ +inline fun SharedFlowCache( + coroutineScope: CoroutineScope, + crossinline compute: suspend (key: K) -> Flow +): LazyAsyncCache> { + return LazyAsyncCache(coroutineScope) { key -> + compute(key).shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) + } +} + +typealias AsyncCacheCompute = suspend (key: K) -> V + +private class RealLazyAsyncCache( + private val lifetime: CoroutineScope, + private val compute: AsyncCacheCompute, +) : LazyAsyncCache { + + private val mutex = Mutex() + private val cache = mutableMapOf() + + override suspend fun getOrCompute(key: K): V { + mutex.withLock { + if (key in cache) return cache.getValue(key) + + return compute(key).also { + cache[key] = it + } + } + } + + init { + lifetime.invokeOnCompletion { + clearCache() + } + } + + // GlobalScope job is fine here since it just for clearing the map + @OptIn(DelicateCoroutinesApi::class) + private fun clearCache() = GlobalScope.launch { + mutex.withLock { cache.clear() } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/memory/LazyAsyncMultiCache.kt b/common/src/main/java/io/novafoundation/nova/common/data/memory/LazyAsyncMultiCache.kt new file mode 100644 index 0000000..238137b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/memory/LazyAsyncMultiCache.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.common.data.memory + +import android.util.Log +import io.novafoundation.nova.common.utils.invokeOnCompletion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.Collections + +interface LazyAsyncMultiCache { + + suspend fun getOrCompute(keys: Collection): Map + + suspend fun put(key: K, value: V) + + suspend fun putAll(map: Map) +} + +/** + * In-memory cache primitive that caches asynchronously computed values + * This is a generalization of [LazyAsyncCache] that can request batch of elements at the same time + * Lifetime of the cache itself is determine by supplied [CoroutineScope] + */ +fun LazyAsyncMultiCache( + coroutineScope: CoroutineScope, + debugLabel: String = "LazyAsyncMultiCache", + compute: AsyncMultiCacheCompute +): LazyAsyncMultiCache { + return RealLazyAsyncMultiCache(coroutineScope, debugLabel, compute) +} + +typealias AsyncMultiCacheCompute = suspend (keys: List) -> Map + +private class RealLazyAsyncMultiCache( + lifetime: CoroutineScope, + private val debugLabel: String, + private val compute: AsyncMultiCacheCompute, +) : LazyAsyncMultiCache { + + private val mutex = Mutex() + private val cache = mutableMapOf() + + override suspend fun getOrCompute(keys: Collection): Map { + mutex.withLock { + Log.d(debugLabel, "Requested to fetch ${keys.size} keys") + + val missingKeys = keys - cache.keys + + if (missingKeys.isNotEmpty()) { + Log.d(debugLabel, "Missing ${keys.size} keys") + + val newKeys = compute(missingKeys) + require(newKeys.size == missingKeys.size) { + "compute() returned less keys than was requested. Make sure you return values for all requested keys" + } + cache.putAll(newKeys) + } else { + Log.d(debugLabel, "All keys are already in cache") + } + + // Return the view of the whole cache to avoid extra allocations of the map + return Collections.unmodifiableMap(cache) + } + } + + override suspend fun put(key: K, value: V) { + mutex.withLock { + cache[key] = value + } + } + + override suspend fun putAll(map: Map) { + mutex.withLock { + cache.putAll(map) + } + } + + init { + lifetime.invokeOnCompletion { + clearCache() + } + } + + // GlobalScope job is fine here since it just for clearing the map + @OptIn(DelicateCoroutinesApi::class) + private fun clearCache() = GlobalScope.launch { + mutex.withLock { cache.clear() } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/memory/RealComputationalCache.kt b/common/src/main/java/io/novafoundation/nova/common/data/memory/RealComputationalCache.kt new file mode 100644 index 0000000..12f4b11 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/memory/RealComputationalCache.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.common.data.memory + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invokeOnCompletion +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +private typealias Awaitable = suspend () -> T +private typealias AwaitableConstructor = suspend CoroutineScope.() -> Awaitable + +internal class RealComputationalCache : ComputationalCache, CoroutineScope by CoroutineScope(Dispatchers.Default) { + + private class Entry( + val dependents: MutableSet, + val aggregateScope: CoroutineScope, + val awaitable: Awaitable + ) + + private val memory = mutableMapOf() + private val mutex = Mutex() + + override suspend fun useCache( + key: String, + scope: CoroutineScope, + computation: suspend CoroutineScope.() -> T + ): T = withContext(Dispatchers.Default) { + useCacheInternal(key, scope) { + val deferred = async { this@useCacheInternal.computation() } + + return@useCacheInternal { deferred.await() } + } + } + + override fun useSharedFlow( + key: String, + scope: CoroutineScope, + flowLazy: suspend CoroutineScope.() -> Flow + ): Flow { + return flowOfAll { + useCacheInternal(key, scope) { + val inner = singleReplaySharedFlow() + + launch { + flowLazy(this@useCacheInternal) + .onEach { inner.emit(it) } + .inBackground() + .launchIn(this@useCacheInternal) + } + + return@useCacheInternal { inner } + } + } + } + + @Suppress("UNCHECKED_CAST") + private suspend fun useCacheInternal( + key: String, + scope: CoroutineScope, + cachedAction: AwaitableConstructor + ): T { + val awaitable = mutex.withLock { + if (key in memory) { + Log.d(LOG_TAG, "Key $key requested - already present") + + val entry = memory.getValue(key) + + entry.dependents += scope + + entry.awaitable + } else { + Log.d(LOG_TAG, "Key $key requested - creating new operation") + + val aggregateScope = CoroutineScope(Dispatchers.Default) + val awaitable = cachedAction(aggregateScope) + + memory[key] = Entry(dependents = mutableSetOf(scope), aggregateScope, awaitable) + + awaitable + } + } + + scope.invokeOnCompletion { + this@RealComputationalCache.launch { + mutex.withLock { + memory[key]?.let { entry -> + entry.dependents -= scope + + if (entry.dependents.isEmpty()) { + Log.d(this@RealComputationalCache.LOG_TAG, "Key $key - last scope cancelled") + + memory.remove(key) + + entry.aggregateScope.cancel() + } else { + Log.d(this@RealComputationalCache.LOG_TAG, "Key $key - scope cancelled, ${entry.dependents.size} remaining") + } + } + } + } + } + + return awaitable() as T + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/memory/SharedComputation.kt b/common/src/main/java/io/novafoundation/nova/common/data/memory/SharedComputation.kt new file mode 100644 index 0000000..c3e45f3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/memory/SharedComputation.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.common.data.memory + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +abstract class SharedComputation( + private val computationalCache: ComputationalCache +) { + + context(ComputationalScope) + protected fun cachedFlow( + vararg keyArgs: String, + flowLazy: suspend CoroutineScope.() -> Flow + ): Flow { + val key = keyArgs.joinToString(separator = ".") + + return computationalCache.useSharedFlow(key, flowLazy) + } + + context(ComputationalScope) + protected suspend fun cachedValue( + vararg keyArgs: String, + valueLazy: suspend CoroutineScope.() -> T + ): T { + val key = keyArgs.joinToString(separator = ".") + + return computationalCache.useCache(key, valueLazy) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/memory/SingleValueCache.kt b/common/src/main/java/io/novafoundation/nova/common/data/memory/SingleValueCache.kt new file mode 100644 index 0000000..d602237 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/memory/SingleValueCache.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.data.memory + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface SingleValueCache { + + suspend operator fun invoke(): T +} + +typealias SingleValueCacheCompute = suspend () -> T + +fun SingleValueCache(compute: SingleValueCacheCompute): SingleValueCache { + return RealSingleValueCache(compute) +} + +private class RealSingleValueCache( + private val compute: SingleValueCacheCompute, +) : SingleValueCache { + + private val mutex = Mutex() + private var cache: Any? = NULL + + @Suppress("UNCHECKED_CAST") + override suspend operator fun invoke(): T { + mutex.withLock { + if (cache === NULL) { + cache = compute() + } + + return cache as T + } + } + + private object NULL +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/model/AssetIconMode.kt b/common/src/main/java/io/novafoundation/nova/common/data/model/AssetIconMode.kt new file mode 100644 index 0000000..76fda50 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/model/AssetIconMode.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.data.model + +enum class AssetIconMode { + COLORED, WHITE +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/model/AssetViewMode.kt b/common/src/main/java/io/novafoundation/nova/common/data/model/AssetViewMode.kt new file mode 100644 index 0000000..04a0870 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/model/AssetViewMode.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.data.model + +enum class AssetViewMode { + TOKENS, NETWORKS +} + +fun AssetViewMode.switch(): AssetViewMode { + return when (this) { + AssetViewMode.TOKENS -> AssetViewMode.NETWORKS + AssetViewMode.NETWORKS -> AssetViewMode.TOKENS + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/model/DataPage.kt b/common/src/main/java/io/novafoundation/nova/common/data/model/DataPage.kt new file mode 100644 index 0000000..f485492 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/model/DataPage.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.common.data.model + +import io.novafoundation.nova.common.utils.castOrNull + +data class DataPage( + val nextOffset: PageOffset, + val items: List +) : List by items { + + companion object { + + fun empty(): DataPage = DataPage(nextOffset = PageOffset.FullData, items = emptyList()) + } +} + +sealed class PageOffset { + + companion object; + + sealed class Loadable : PageOffset() { + data class Cursor(val value: String) : Loadable() + + data class PageNumber(val page: Int) : Loadable() + + object FirstPage : Loadable() + } + + object FullData : PageOffset() +} + +fun PageOffset.Companion.CursorOrFull(value: String?): PageOffset = if (value != null) { + PageOffset.Loadable.Cursor(value) +} else { + PageOffset.FullData +} + +fun PageOffset.asCursorOrNull(): PageOffset.Loadable.Cursor? { + return castOrNull() +} + +fun PageOffset.requirePageNumber(): PageOffset.Loadable.PageNumber { + require(this is PageOffset.Loadable.PageNumber) + + return this +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/model/MaskingMode.kt b/common/src/main/java/io/novafoundation/nova/common/data/model/MaskingMode.kt new file mode 100644 index 0000000..d390566 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/model/MaskingMode.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.data.model + +enum class MaskingMode { + ENABLED, DISABLED +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/AndroidLogger.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/AndroidLogger.kt new file mode 100644 index 0000000..c79f68e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/AndroidLogger.kt @@ -0,0 +1,23 @@ + +package io.novafoundation.nova.common.data.network + +import android.util.Log +import io.novasama.substrate_sdk_android.wsrpc.logging.Logger + +const val TAG = "AndroidLogger" + +class AndroidLogger( + private val debug: Boolean +) : Logger { + override fun log(message: String?) { + if (debug) { + Log.d(TAG, message.toString()) + } + } + + override fun log(throwable: Throwable?) { + if (debug) { + throwable?.printStackTrace() + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/AppLinksProvider.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/AppLinksProvider.kt new file mode 100644 index 0000000..87f8444 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/AppLinksProvider.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.data.network + +class AppLinksProvider( + val termsUrl: String, + val privacyUrl: String, + val telegram: String, + val twitter: String, + val rateApp: String, + val website: String, + val github: String, + val email: String, + val youtube: String, + + val payoutsLearnMore: String, + val recommendedValidatorsLearnMore: String, + val twitterAccountTemplate: String, + val setControllerLearnMore: String, + val setControllerDeprecatedLeanMore: String, + + val paritySignerTroubleShooting: String, + val polkadotVaultTroubleShooting: String, + val ledgerConnectionGuide: String, + val wikiBase: String, + val wikiProxy: String, + val integrateNetwork: String, + val storeUrl: String, + + val ledgerMigrationArticle: String, + + val pezkuwiCardWidgetUrl: String, + val unifiedAddressArticle: String, + val multisigsWikiUrl: String, + + val giftsWikiUrl: String, +) { + + fun getTwitterAccountUrl( + accountName: String + ): String = twitterAccountTemplate.format(accountName) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/HttpExceptionHandler.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/HttpExceptionHandler.kt new file mode 100644 index 0000000..cab249c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/HttpExceptionHandler.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.common.data.network + +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.base.BaseException +import io.novafoundation.nova.common.resources.ResourceManager +import retrofit2.HttpException +import java.io.IOException + +class HttpExceptionHandler( + private val resourceManager: ResourceManager +) { + suspend fun wrap(block: suspend () -> T): T { + return try { + block() + } catch (e: Throwable) { + throw transformException(e) + } + } + + fun transformException(exception: Throwable): BaseException { + return when (exception) { + is HttpException -> { + val response = exception.response()!! + + val errorCode = response.code() + response.errorBody()?.close() + + BaseException.httpError(errorCode, resourceManager.getString(R.string.common_undefined_error_message)) + } + is IOException -> BaseException.networkError(resourceManager.getString(R.string.connection_error_message_v2_2_0), exception) + else -> BaseException.unexpectedError(exception) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/NetworkApiCreator.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/NetworkApiCreator.kt new file mode 100644 index 0000000..8e18ef9 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/NetworkApiCreator.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.common.data.network + +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.scalars.ScalarsConverterFactory + +class NetworkApiCreator( + private val okHttpClient: OkHttpClient, + private val baseUrl: String +) { + + fun create( + service: Class, + customBaseUrl: String = baseUrl + ): T { + val retrofit = Retrofit.Builder() + .client(okHttpClient) + .baseUrl(customBaseUrl) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + return retrofit.create(service) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/TimeHeaderInterceptor.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/TimeHeaderInterceptor.kt new file mode 100644 index 0000000..7e759a5 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/TimeHeaderInterceptor.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.common.data.network + +import okhttp3.Interceptor +import okhttp3.Response +import java.util.concurrent.TimeUnit + +class TimeHeaderInterceptor : Interceptor { + + companion object { + private const val CONNECT_TIMEOUT = "CONNECT_TIMEOUT" + private const val READ_TIMEOUT = "READ_TIMEOUT" + private const val WRITE_TIMEOUT = "WRITE_TIMEOUT" + + private const val LONG_REQUEST_DURATION = 60_000 // 60 sec + + const val LONG_CONNECT = "$CONNECT_TIMEOUT: $LONG_REQUEST_DURATION" + const val LONG_READ = "$READ_TIMEOUT: $LONG_REQUEST_DURATION" + const val LONG_WRITE = "$WRITE_TIMEOUT: $LONG_REQUEST_DURATION" + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + var connectTimeout = chain.connectTimeoutMillis() + var readTimeout = chain.readTimeoutMillis() + var writeTimeout = chain.writeTimeoutMillis() + + val builder = request.newBuilder() + + request.header(CONNECT_TIMEOUT)?.also { + connectTimeout = it.toInt() + builder.removeHeader(CONNECT_TIMEOUT) + } + + request.header(READ_TIMEOUT)?.also { + readTimeout = it.toInt() + builder.removeHeader(READ_TIMEOUT) + } + + request.header(WRITE_TIMEOUT)?.also { + writeTimeout = it.toInt() + builder.removeHeader(WRITE_TIMEOUT) + } + + return chain + .withConnectTimeout(connectTimeout, TimeUnit.MILLISECONDS) + .withReadTimeout(readTimeout, TimeUnit.MILLISECONDS) + .withWriteTimeout(writeTimeout, TimeUnit.MILLISECONDS) + .proceed(builder.build()) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/UserAgent.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/UserAgent.kt new file mode 100644 index 0000000..32a59f4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/UserAgent.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.data.network + +object UserAgent { + + const val PEZKUWI = "User-Agent: Pezkuwi Wallet (Android)" + + @Deprecated("Use PEZKUWI instead", replaceWith = ReplaceWith("PEZKUWI")) + const val NOVA = PEZKUWI +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/coingecko/CoinGeckoLinkParser.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/coingecko/CoinGeckoLinkParser.kt new file mode 100644 index 0000000..9fc9b56 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/coingecko/CoinGeckoLinkParser.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.common.data.network.coingecko + +import android.net.Uri +import io.novafoundation.nova.common.utils.Urls + +private const val COINGECKO_HOST = "www.coingecko.com" +private const val COINGECKO_PATH_LANGUAGE = "en" +private const val COINGECKO_PATH_SEGMENT = "coins" + +class CoinGeckoLinkParser { + + class Content(val priceId: String) + + fun parse(input: String): Result = runCatching { + val parsedUri = parseToUri(input) + + require(parsedUri.host == COINGECKO_HOST) + val (language, coinSegment, priceId) = parsedUri.pathSegments + require(coinSegment == COINGECKO_PATH_SEGMENT) + + Content(priceId) + } + + fun format(priceId: String): String { + return Uri.Builder() + .scheme("https") + .authority(COINGECKO_HOST) + .appendPath(COINGECKO_PATH_LANGUAGE) + .appendPath(COINGECKO_PATH_SEGMENT) + .appendPath(priceId) + .build() + .toString() + } + + private fun parseToUri(input: String): Uri { + val withProtocol = Urls.ensureHttpsProtocol(input) + return Uri.parse(withProtocol) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/coingecko/PriceInfo.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/coingecko/PriceInfo.kt new file mode 100644 index 0000000..727f408 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/coingecko/PriceInfo.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.data.network.coingecko + +import java.math.BigDecimal + +class PriceInfo( + val price: BigDecimal?, + val rateChange: BigDecimal? +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/ext/AccountInfoExt.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/ext/AccountInfoExt.kt new file mode 100644 index 0000000..5560292 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/ext/AccountInfoExt.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.data.network.ext + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo +import io.novafoundation.nova.common.domain.balance.TransferableMode +import io.novafoundation.nova.common.domain.balance.calculateTransferable +import java.math.BigInteger + +fun AccountInfo.transferableBalance(): BigInteger { + return transferableMode.calculateTransferable(data) +} + +val AccountInfo.transferableMode: TransferableMode + get() = if (data.flags.holdsAndFreezesEnabled()) { + TransferableMode.HOLDS_AND_FREEZES + } else { + TransferableMode.REGULAR + } diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/http/CacheControl.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/http/CacheControl.kt new file mode 100644 index 0000000..147499e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/http/CacheControl.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.common.data.network.http + +object CacheControl { + + const val NO_CACHE = "Cache-control: no-cache" +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/BulkRetriever.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/BulkRetriever.kt new file mode 100644 index 0000000..50d094d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/BulkRetriever.kt @@ -0,0 +1,146 @@ +package io.novafoundation.nova.common.data.network.rpc + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.executeAsync +import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull +import io.novasama.substrate_sdk_android.wsrpc.mappers.pojoList +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext + +class GetKeysPagedRequest( + keyPrefix: String, + pageSize: Int, + fullKeyOffset: String?, +) : RuntimeRequest( + method = "state_getKeysPaged", + params = listOfNotNull( + keyPrefix, + pageSize, + fullKeyOffset, + ) +) + +class GetKeys( + keyPrefix: String, + at: BlockHash? +) : RuntimeRequest( + method = "state_getKeys", + params = listOfNotNull( + keyPrefix, + at + ) +) + +class QueryStorageAtRequest( + keys: List, + at: String? +) : RuntimeRequest( + method = "state_queryStorageAt", + params = listOfNotNull( + keys, + at + ) +) + +class QueryStorageAtResponse( + val block: String, + val changes: List> +) { + fun changesAsMap(): Map { + return changes.map { it[0]!! to it[1] }.toMap() + } +} + +class BulkRetriever(private val pageSize: Int) { + + /** + * Retrieves all keys starting with [keyPrefix] from [at] block + * Returns only first [defaultPageSize] elements in case historical querying is used ([at] is not null) + */ + suspend fun retrieveAllKeys( + socketService: SocketService, + keyPrefix: String, + at: BlockHash? = null + ): List = withContext(Dispatchers.IO) { + if (at != null) { + queryKeysByPrefixHistorical(socketService, keyPrefix, at) + } else { + queryKeysByPrefixCurrent(socketService, keyPrefix) + } + } + + suspend fun queryKeys( + socketService: SocketService, + keys: List, + at: BlockHash? = null + ): Map = withContext(Dispatchers.IO) { + val chunks = keys.chunked(pageSize) + + chunks.fold(mutableMapOf()) { acc, chunk -> + ensureActive() + + val request = QueryStorageAtRequest(chunk, at) + + val chunkValues = socketService.executeAsync(request, mapper = pojoList().nonNull()) + .first().changesAsMap() + + acc.putAll(chunkValues) + + acc + } + } + + /** + * Note: the amount of keys returned by this method is limited by [defaultPageSize] + * So it is should not be used for storages with big amount of entries + */ + private suspend fun queryKeysByPrefixHistorical( + socketService: SocketService, + prefix: String, + at: BlockHash + ): List { + // We use `state_getKeys` for historical prefix queries instead of `state_getKeysPaged` + // since most of the chains always return empty list when the same is requested via `state_getKeysPaged` + // Thus, we can only request up to 1000 first historical keys + val request = GetKeys(prefix, at) + + return socketService.executeAsync(request, mapper = pojoList().nonNull()) + } + + private suspend fun queryKeysByPrefixCurrent( + socketService: SocketService, + prefix: String + ): List { + val result = mutableListOf() + + var currentOffset: String? = null + + while (true) { + coroutineContext.ensureActive() + + val request = GetKeysPagedRequest(prefix, pageSize, currentOffset) + + val page = socketService.executeAsync(request, mapper = pojoList().nonNull()) + + result += page + + if (isLastPage(page)) break + + currentOffset = page.last() + } + + return result + } + + private fun isLastPage(page: List) = page.size < pageSize +} + +suspend fun BulkRetriever.queryKey( + socketService: SocketService, + key: String, + at: BlockHash? = null +): String? = queryKeys(socketService, listOf(key), at).values.first() diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/BulkRetrieverExt.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/BulkRetrieverExt.kt new file mode 100644 index 0000000..39a7038 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/BulkRetrieverExt.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.data.network.rpc + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novasama.substrate_sdk_android.wsrpc.SocketService + +suspend fun BulkRetriever.retrieveAllValues(socketService: SocketService, keyPrefix: String, at: BlockHash? = null): Map { + val allKeys = retrieveAllKeys(socketService, keyPrefix, at) + + return queryKeys(socketService, allKeys, at) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/ChildState.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/ChildState.kt new file mode 100644 index 0000000..ef46dce --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/ChildState.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.common.data.network.rpc + +import io.novasama.substrate_sdk_android.extensions.toHexString +import java.io.ByteArrayOutputStream + +private const val CHILD_KEY_DEFAULT = ":child_storage:default:" + +suspend fun childStateKey( + builder: suspend ByteArrayOutputStream.() -> Unit +): String { + val buffer = ByteArrayOutputStream().apply { + write(CHILD_KEY_DEFAULT.encodeToByteArray()) + + builder() + } + + return buffer.toByteArray().toHexString(withPrefix = true) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/SocketSingleRequestExecutor.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/SocketSingleRequestExecutor.kt new file mode 100644 index 0000000..9b15a56 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/rpc/SocketSingleRequestExecutor.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.common.data.network.rpc + +import com.google.gson.Gson +import com.neovisionaries.ws.client.WebSocket +import com.neovisionaries.ws.client.WebSocketAdapter +import com.neovisionaries.ws.client.WebSocketException +import com.neovisionaries.ws.client.WebSocketFactory +import io.novafoundation.nova.common.base.errors.NovaException +import io.novafoundation.nova.common.resources.ResourceManager +import io.novasama.substrate_sdk_android.wsrpc.logging.Logger +import io.novasama.substrate_sdk_android.wsrpc.mappers.ResponseMapper +import io.novasama.substrate_sdk_android.wsrpc.request.base.RpcRequest +import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Suppress("EXPERIMENTAL_API_USAGE") +class SocketSingleRequestExecutor( + private val jsonMapper: Gson, + private val logger: Logger, + private val wsFactory: WebSocketFactory, + private val resourceManager: ResourceManager +) { + + suspend fun executeRequest( + request: RpcRequest, + url: String, + mapper: ResponseMapper + ): R { + val response = executeRequest(request, url) + + return withContext(Dispatchers.Default) { + mapper.map(response, jsonMapper) + } + } + + suspend fun executeRequest( + request: RpcRequest, + url: String + ): RpcResponse = withContext(Dispatchers.IO) { + try { + executeRequestInternal(request, url) + } catch (e: Exception) { + throw NovaException.networkError(resourceManager, e) + } + } + + private suspend fun executeRequestInternal( + request: RpcRequest, + url: String + ): RpcResponse = suspendCancellableCoroutine { cont -> + + val webSocket: WebSocket = wsFactory.createSocket(url) + + cont.invokeOnCancellation { + webSocket.clearListeners() + webSocket.disconnect() + } + + webSocket.addListener(object : WebSocketAdapter() { + override fun onTextMessage(websocket: WebSocket, text: String) { + logger.log("[RECEIVED] $text") + + val response = jsonMapper.fromJson(text, RpcResponse::class.java) + + cont.resume(response) + + webSocket.disconnect() + } + + override fun onError(websocket: WebSocket, cause: WebSocketException) { + cont.resumeWithException(cause) + } + }) + + webSocket.connect() + + webSocket.sendText(jsonMapper.toJson(request)) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/AccountId.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/AccountId.kt new file mode 100644 index 0000000..2a20574 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/AccountId.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novasama.substrate_sdk_android.runtime.AccountId + +@HelperBinding +fun bindAccountId(dynamicInstance: Any?): AccountId = dynamicInstance.cast() + +fun bindAccountIdKey(dynamicInstance: Any?): AccountIdKey = bindAccountId(dynamicInstance).intoKey() + +fun bindNullableAccountId(dynamicInstance: Any?): AccountId? = dynamicInstance.nullableCast() diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/AccountInfo.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/AccountInfo.kt new file mode 100644 index 0000000..a3a3b0f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/AccountInfo.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novafoundation.nova.common.domain.balance.EDCountingMode +import io.novafoundation.nova.common.domain.balance.TransferableMode +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.system +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import java.math.BigInteger + +open class AccountBalance( + val free: BigInteger, + val reserved: BigInteger, + val frozen: BigInteger +) { + + companion object { + + fun empty(): AccountBalance { + return AccountBalance( + free = BigInteger.ZERO, + reserved = BigInteger.ZERO, + frozen = BigInteger.ZERO, + ) + } + } +} + +fun AccountBalance?.orEmpty(): AccountBalance = this ?: AccountBalance.empty() + +class AccountData( + free: BigInteger, + reserved: BigInteger, + frozen: BigInteger, + val flags: AccountDataFlags, +) : AccountBalance(free, reserved, frozen) + +@JvmInline +value class AccountDataFlags(val value: BigInteger) { + + companion object { + + fun default() = AccountDataFlags(BigInteger.ZERO) + + private val HOLD_AND_FREEZES_ENABLED_MASK: BigInteger = BigInteger("80000000000000000000000000000000", 16) + } + + fun holdsAndFreezesEnabled(): Boolean { + return flagEnabled(HOLD_AND_FREEZES_ENABLED_MASK) + } + + @Suppress("SameParameterValue") + private fun flagEnabled(flag: BigInteger) = value and flag == flag +} + +fun AccountDataFlags.transferableMode(): TransferableMode { + return if (holdsAndFreezesEnabled()) { + TransferableMode.HOLDS_AND_FREEZES + } else { + TransferableMode.REGULAR + } +} + +fun AccountDataFlags.edCountingMode(): EDCountingMode { + return if (holdsAndFreezesEnabled()) { + EDCountingMode.FREE + } else { + EDCountingMode.TOTAL + } +} + +class AccountInfo( + val consumers: BigInteger, + val providers: BigInteger, + val sufficients: BigInteger, + val data: AccountData +) { + + companion object { + fun empty() = AccountInfo( + consumers = BigInteger.ZERO, + providers = BigInteger.ZERO, + sufficients = BigInteger.ZERO, + data = AccountData( + free = BigInteger.ZERO, + reserved = BigInteger.ZERO, + frozen = BigInteger.ZERO, + flags = AccountDataFlags.default(), + ) + ) + } +} + +@HelperBinding +fun bindAccountData(dynamicInstance: Struct.Instance): AccountData { + val frozen = if (hasSplitFrozen(dynamicInstance)) { + val miscFrozen = bindNumber(dynamicInstance["miscFrozen"]) + val feeFrozen = bindNumber(dynamicInstance["feeFrozen"]) + + miscFrozen.max(feeFrozen) + } else { + bindNumber(dynamicInstance["frozen"]) + } + + return AccountData( + free = bindNumber(dynamicInstance["free"]), + reserved = bindNumber(dynamicInstance["reserved"]), + frozen = frozen, + flags = bindAccountDataFlags(dynamicInstance["flags"]) + ) +} + +private fun hasSplitFrozen(accountInfo: Struct.Instance): Boolean { + return "miscFrozen" in accountInfo.mapping +} + +private fun bindAccountDataFlags(instance: Any?): AccountDataFlags { + return if (instance != null) { + AccountDataFlags(bindNumber(instance)) + } else { + AccountDataFlags.default() + } +} + +@HelperBinding +fun bindNonce(dynamicInstance: Any?): BigInteger { + return bindNumber(dynamicInstance) +} + +@UseCaseBinding +fun bindAccountInfo(scale: String, runtime: RuntimeSnapshot): AccountInfo { + val type = runtime.metadata.system().storage("Account").returnType() + + val dynamicInstance = type.fromHexOrNull(runtime, scale) + + return bindAccountInfo(dynamicInstance) +} + +fun bindAccountInfo(decoded: Any?): AccountInfo { + val dynamicInstance = decoded.cast() + + return AccountInfo( + consumers = dynamicInstance.getTyped("consumers").orZero(), + providers = dynamicInstance.getTyped("providers").orZero(), + sufficients = dynamicInstance.getTyped("sufficients").orZero(), + data = bindAccountData(dynamicInstance.getTyped("data")) + ) +} + +fun bindOrmlAccountBalanceOrEmpty(decoded: Any?): AccountBalance { + return decoded?.let { bindOrmlAccountData(decoded) } ?: AccountBalance.empty() +} + +fun bindOrmlAccountData(decoded: Any?): AccountBalance { + val dynamicInstance = decoded.cast() + + return AccountBalance( + free = bindNumber(dynamicInstance["free"]), + reserved = bindNumber(dynamicInstance["reserved"]), + frozen = bindNumber(dynamicInstance["frozen"]), + ) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/ActiveEra.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/ActiveEra.kt new file mode 100644 index 0000000..ca4b0cb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/ActiveEra.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import java.math.BigInteger + +/* +"ActiveEraInfo": { + "type": "struct", + "type_mapping": [ + [ + "index", + "EraIndex" + ], + [ + "start", + "Option" + ] + ] +} + */ +@UseCaseBinding +fun bindActiveEraIndex( + scale: String, + runtime: RuntimeSnapshot +): BigInteger { + val returnType = runtime.metadata.storageReturnType("Staking", "ActiveEra") + val decoded = returnType.fromHex(runtime, scale) as? Struct.Instance ?: incompatible() + + return decoded.get("index") ?: incompatible() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/BalanceOf.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/BalanceOf.kt new file mode 100644 index 0000000..a9f4943 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/BalanceOf.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import java.math.BigInteger + +typealias BalanceOf = BigInteger diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/BindingHelpers.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/BindingHelpers.kt new file mode 100644 index 0000000..4fc6058 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/BindingHelpers.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.RuntimeType +import io.novasama.substrate_sdk_android.runtime.definitions.types.Type +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +annotation class UseCaseBinding + +annotation class HelperBinding + +fun incompatible(): Nothing = throw IllegalStateException("Binding is incompatible") + +typealias Binder = (scale: String?, RuntimeSnapshot) -> T +typealias NonNullBinder = (scale: String, RuntimeSnapshot) -> T +typealias NonNullBinderWithType = (scale: String, RuntimeSnapshot, Type<*>) -> T +typealias BinderWithType = (scale: String?, RuntimeSnapshot, Type<*>) -> T + +@OptIn(ExperimentalContracts::class) +inline fun requireType(dynamicInstance: Any?): T { + contract { + returns() implies (dynamicInstance is T) + } + + return dynamicInstance as? T ?: incompatible() +} + +inline fun Any?.cast(): T { + return this as? T ?: incompatible() +} + +inline fun Any?.nullableCast(): T? { + if (this == null) return null + + return this as? T ?: incompatible() +} + +inline fun Any?.castOrNull(): T? { + return this as? T +} + +@OptIn(ExperimentalContracts::class) +fun Any?.castToStruct(): Struct.Instance { + contract { + returns() implies (this@castToStruct is Struct.Instance) + } + + return cast() +} + +@OptIn(ExperimentalContracts::class) +fun Any?.castToDictEnum(): DictEnum.Entry<*> { + contract { + returns() implies (this@castToDictEnum is DictEnum.Entry<*>) + } + + return cast() +} + +fun Any?.castToStructOrNull(): Struct.Instance? { + return castOrNull() +} + +fun Any?.castToList(): List<*> { + return cast() +} + +inline fun Struct.Instance.getTyped(key: String) = get(key) ?: incompatible() + +fun Struct.Instance.getList(key: String) = get>(key) ?: incompatible() +fun Struct.Instance.getStruct(key: String) = get(key) ?: incompatible() + +inline fun bindOrNull(binder: () -> T): T? = runCatching(binder).getOrNull() + +fun StorageEntry.returnType() = type.value ?: incompatible() + +fun RuntimeMetadata.storageReturnType(moduleName: String, storageName: String): Type<*> { + return module(moduleName).storage(storageName).returnType() +} + +fun RuntimeType<*, D>.fromHexOrIncompatible(scale: String, runtime: RuntimeSnapshot): D = successOrIncompatible { + fromHex(runtime, scale) +} + +fun RuntimeType<*, D>.fromByteArrayOrIncompatible(scale: ByteArray, runtime: RuntimeSnapshot): D = successOrIncompatible { + fromByteArray(runtime, scale) +} + +private fun successOrIncompatible(block: () -> T): T = runCatching { + block() +}.getOrElse { incompatible() } diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Block.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Block.kt new file mode 100644 index 0000000..4e938da --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Block.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novafoundation.nova.common.utils.system +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import java.math.BigInteger + +typealias BlockNumber = BigInteger + +typealias BlockHash = String + +fun bindBlockNumber(scale: String, runtime: RuntimeSnapshot): BlockNumber { + val type = runtime.metadata.system().storage("Number").returnType() + + val dynamicInstance = type.fromHexOrIncompatible(scale, runtime) + + return bindNumber(dynamicInstance) +} + +fun bindBlockNumber(dynamic: Any?) = bindNumber(dynamic) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Collections.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Collections.kt new file mode 100644 index 0000000..ec0d93e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Collections.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novafoundation.nova.common.utils.mapToSet +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum + +fun bindList(dynamicInstance: Any?, itemBinder: (Any?) -> T): List { + if (dynamicInstance == null) return emptyList() + + return dynamicInstance.cast>().map { + itemBinder(it) + } +} + +fun bindSet(dynamicInstance: Any?, itemBinder: (Any?) -> T): Set { + if (dynamicInstance == null) return emptySet() + + return dynamicInstance.cast>().mapToSet { itemBinder(it) } +} + +inline fun bindPair( + dynamicInstance: Any, + firstComponent: (Any?) -> T1, + secondComponent: (Any?) -> T2 +): Pair { + val (first, second) = dynamicInstance.cast>() + + return firstComponent(first) to secondComponent(second) +} + +// Maps are encoded as List> +fun bindMap(dynamicInstance: Any?, keyBinder: (Any?) -> K, valueBinder: (Any?) -> V): Map { + if (dynamicInstance == null) return emptyMap() + + return dynamicInstance.cast>().associateBy( + keySelector = { + val (keyRaw, _) = it.cast>() + + keyBinder(keyRaw) + }, + valueTransform = { + val (_, valueRaw) = it.cast>() + + valueBinder(valueRaw) + } + ) +} + +inline fun > bindCollectionEnum( + dynamicInstance: Any?, + enumValueFromName: (String) -> T = ::enumValueOf +): T { + return when (dynamicInstance) { + is String -> enumValueFromName(dynamicInstance) // collection enum + is DictEnum.Entry<*> -> enumValueFromName(dynamicInstance.name) // dict enum with empty values + else -> incompatible() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Constants.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Constants.kt new file mode 100644 index 0000000..cb52983 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Constants.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromByteArrayOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.module.Constant +import java.math.BigInteger + +@HelperBinding +fun bindNumberConstant( + constant: Constant, + runtime: RuntimeSnapshot +): BigInteger = bindNullableNumberConstant(constant, runtime) ?: incompatible() + +@HelperBinding +fun bindNullableNumberConstant( + constant: Constant, + runtime: RuntimeSnapshot +): BigInteger? { + val decoded = constant.type?.fromByteArrayOrNull(runtime, constant.value) ?: incompatible() + + return decoded as BigInteger? +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Data.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Data.kt new file mode 100644 index 0000000..cab33dd --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Data.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Data as DataType + +sealed class Data { + abstract fun asString(): String? + + object None : Data() { + override fun asString(): String? = null + } + + class Raw(val value: ByteArray) : Data() { + + override fun asString() = String(value) + } + + class Hash(val value: ByteArray, val type: Type) : Data() { + + enum class Type { + BLAKE_2B_256, SHA_256, KECCAK_256, SHA_3_256 + } + + override fun asString() = value.toHexString(withPrefix = true) + } +} + +@HelperBinding +fun bindData(dynamicInstance: Any?): Data { + requireType>(dynamicInstance) + + return when (dynamicInstance.name) { + DataType.NONE -> Data.None + DataType.RAW -> Data.Raw(dynamicInstance.value.cast()) + DataType.BLAKE_2B_256 -> Data.Hash(dynamicInstance.value.cast(), Data.Hash.Type.BLAKE_2B_256) + DataType.SHA_256 -> Data.Hash(dynamicInstance.value.cast(), Data.Hash.Type.SHA_256) + DataType.KECCAK_256 -> Data.Hash(dynamicInstance.value.cast(), Data.Hash.Type.KECCAK_256) + DataType.SHA_3_256 -> Data.Hash(dynamicInstance.value.cast(), Data.Hash.Type.SHA_3_256) + else -> incompatible() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/DispatchError.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/DispatchError.kt new file mode 100644 index 0000000..880d76a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/DispatchError.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.metadata +import io.novasama.substrate_sdk_android.runtime.metadata.error +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.module.ErrorMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module as RuntimeModule + +sealed class DispatchError : Throwable() { + + data class Module(val module: RuntimeModule, val error: ErrorMetadata) : DispatchError() { + + override val message: String + get() = toString() + + override fun toString(): String { + return "${module.name}.${error.name}" + } + } + + object Token : DispatchError() { + + override val message: String + get() = toString() + + override fun toString(): String { + return "Not enough tokens" + } + } + + object Unknown : DispatchError() +} + +context(RuntimeContext) +fun bindDispatchError(decoded: Any?): DispatchError { + val asDictEnum = decoded.castToDictEnum() + + return when (asDictEnum.name) { + "Module" -> { + val moduleErrorStruct = asDictEnum.value.castToStruct() + + val moduleIndex = bindInt(moduleErrorStruct["index"]) + val errorIndex = bindModuleError(moduleErrorStruct["error"]) + + val module = metadata.module(moduleIndex) + val error = module.error(errorIndex) + + DispatchError.Module(module, error) + } + + "Token" -> DispatchError.Token + + else -> DispatchError.Unknown + } +} + +private fun bindModuleError(errorEncoded: ByteArray?): Int { + requireNotNull(errorEncoded) { + "Error should exist" + } + + return errorEncoded[0].toInt() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/DispatchTime.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/DispatchTime.kt new file mode 100644 index 0000000..977fb64 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/DispatchTime.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import java.math.BigInteger + +sealed class DispatchTime { + + class At(val block: BlockNumber) : DispatchTime() + + class After(val block: BlockNumber) : DispatchTime() +} + +fun bindDispatchTime(decoded: DictEnum.Entry<*>): DispatchTime { + return when (decoded.name) { + "At" -> DispatchTime.At(block = bindBlockNumber(decoded.value)) + "After" -> DispatchTime.After(block = bindBlockNumber(decoded.value)) + else -> incompatible() + } +} + +val DispatchTime.minimumRequiredBlock + get() = when (this) { + is DispatchTime.After -> block + BigInteger.ONE + is DispatchTime.At -> block + } diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Events.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Events.kt new file mode 100644 index 0000000..e1268c3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Events.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import java.math.BigInteger + +class EventRecord(val phase: Phase, val event: GenericEvent.Instance) + +sealed class Phase { + + class ApplyExtrinsic(val extrinsicId: BigInteger) : Phase() + + object Finalization : Phase() + + object Initialization : Phase() +} + +fun bindEventRecords(decoded: Any?): List { + return bindList(decoded, ::bindEventRecord) +} + +fun bindEvent(decoded: Any?): GenericEvent.Instance { + return decoded.cast() +} + +private fun bindEventRecord(dynamicInstance: Any?): EventRecord { + requireType(dynamicInstance) + + val phaseDynamic = dynamicInstance.getTyped>("phase") + + val phase = when (phaseDynamic.name) { + "ApplyExtrinsic" -> Phase.ApplyExtrinsic(bindNumber(phaseDynamic.value)) + "Finalization" -> Phase.Finalization + "Initialization" -> Phase.Initialization + else -> incompatible() + } + + val dynamicEvent = dynamicInstance.getTyped("event") + + return EventRecord(phase, dynamicEvent) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Floats.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Floats.kt new file mode 100644 index 0000000..d09f875 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Floats.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.percents +import java.math.BigDecimal +import java.math.BigInteger +import io.novafoundation.nova.common.utils.Perbill as PerbillTyped + +typealias Perbill = BigDecimal +typealias FixedI64 = BigDecimal + +const val PERBILL_MANTISSA_SIZE = 9 +const val PERMILL_MANTISSA_SIZE = 6 +const val PERQUINTILL_MANTISSA_SIZE = 18 + +@HelperBinding +fun bindPerbillNumber(value: BigInteger, mantissa: Int = PERBILL_MANTISSA_SIZE): Perbill { + return value.toBigDecimal(scale = mantissa) +} + +fun bindPerbill(dynamic: Any?, mantissa: Int = PERBILL_MANTISSA_SIZE): Perbill { + return bindPerbillNumber(dynamic.cast(), mantissa) +} + +fun bindPercentFraction(dynamic: Any?): Fraction { + return bindNumber(dynamic).percents +} + +fun bindFixedI64Number(value: BigInteger): FixedI64 { + return bindPerbillNumber(value) +} + +fun bindFixedI64(dynamic: Any?): FixedI64 { + return bindPerbill(dynamic) +} + +fun bindPerbillTyped(dynamic: Any?, mantissa: Int = PERBILL_MANTISSA_SIZE): PerbillTyped { + return PerbillTyped(bindPerbill(dynamic, mantissa).toDouble()) +} + +fun bindPermill(dynamic: Any?): PerbillTyped { + return bindPerbillTyped(dynamic, mantissa = PERMILL_MANTISSA_SIZE) +} + +fun BigInteger.asPerQuintill(): PerbillTyped { + return PerbillTyped(toBigDecimal(scale = PERQUINTILL_MANTISSA_SIZE).toDouble()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/GenericCall.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/GenericCall.kt new file mode 100644 index 0000000..2591f82 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/GenericCall.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +fun bindGenericCall(decoded: Any?): GenericCall.Instance { + return decoded.cast() +} + +fun bindGenericCallList(decoded: Any?): List { + return bindList(decoded, ::bindGenericCall) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/MultiAddress.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/MultiAddress.kt new file mode 100644 index 0000000..714231f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/MultiAddress.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import java.math.BigInteger + +sealed class MultiAddress { + + companion object { + const val TYPE_ID = "Id" + const val TYPE_INDEX = "Index" + const val TYPE_RAW = "Raw" + const val TYPE_ADDRESS32 = "Address32" + const val TYPE_ADDRESS20 = "Address20" + } + + class Id(val value: ByteArray) : MultiAddress() + + class Index(val value: BigInteger) : MultiAddress() + + class Raw(val value: ByteArray) : MultiAddress() + + class Address32(val value: ByteArray) : MultiAddress() { + init { + require(value.size == 32) { + "Address32 should be 32 bytes long" + } + } + } + class Address20(val value: ByteArray) : MultiAddress() { + init { + require(value.size == 20) { + "Address20 should be 20 bytes long" + } + } + } +} + +fun bindMultiAddress(multiAddress: MultiAddress): DictEnum.Entry<*> { + return when (multiAddress) { + is MultiAddress.Id -> DictEnum.Entry(MultiAddress.TYPE_ID, multiAddress.value) + is MultiAddress.Index -> DictEnum.Entry(MultiAddress.TYPE_INDEX, multiAddress.value) + is MultiAddress.Raw -> DictEnum.Entry(MultiAddress.TYPE_RAW, multiAddress.value) + is MultiAddress.Address32 -> DictEnum.Entry(MultiAddress.TYPE_ADDRESS32, multiAddress.value) + is MultiAddress.Address20 -> DictEnum.Entry(MultiAddress.TYPE_ADDRESS20, multiAddress.value) + } +} + +fun bindMultiAddress(dynamicInstance: DictEnum.Entry<*>): MultiAddress { + return when (dynamicInstance.name) { + MultiAddress.TYPE_ID -> MultiAddress.Id(dynamicInstance.value.cast()) + MultiAddress.TYPE_INDEX -> MultiAddress.Index(dynamicInstance.value.cast()) + MultiAddress.TYPE_RAW -> MultiAddress.Raw(dynamicInstance.value.cast()) + MultiAddress.TYPE_ADDRESS32 -> MultiAddress.Address32(dynamicInstance.value.cast()) + MultiAddress.TYPE_ADDRESS20 -> MultiAddress.Address20(dynamicInstance.value.cast()) + else -> incompatible() + } +} + +fun bindAccountIdentifier(dynamicInstance: Any?) = when (dynamicInstance) { + // MultiAddress + is DictEnum.Entry<*> -> (bindMultiAddress(dynamicInstance) as MultiAddress.Id).value + // GenericAccountId or EthereumAddress + is ByteArray -> dynamicInstance + else -> incompatible() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/ParaId.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/ParaId.kt new file mode 100644 index 0000000..6722e21 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/ParaId.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import java.math.BigInteger + +typealias ParaId = BigInteger diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Primitive.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Primitive.kt new file mode 100644 index 0000000..03ef10b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/Primitive.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novafoundation.nova.common.utils.orZero +import java.math.BigInteger + +@HelperBinding +fun bindNumber(dynamicInstance: Any?): BigInteger = dynamicInstance.cast() + +fun bindNumberOrNull(dynamicInstance: Any?): BigInteger? = dynamicInstance?.cast() + +fun bindInt(dynamicInstance: Any?): Int = bindNumber(dynamicInstance).toInt() + +@HelperBinding +fun bindNumberOrZero(dynamicInstance: Any?): BigInteger = dynamicInstance?.let(::bindNumber).orZero() + +@HelperBinding +fun bindString(dynamicInstance: Any?): String = dynamicInstance.cast().decodeToString() + +@HelperBinding +fun bindBoolean(dynamicInstance: Any?): Boolean = dynamicInstance.cast() + +@HelperBinding +fun bindByteArray(dynamicInstance: Any?): ByteArray = dynamicInstance.cast() diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/ScaleResult.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/ScaleResult.kt new file mode 100644 index 0000000..68fa546 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/ScaleResult.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +sealed class ScaleResult { + + class Ok(val value: T) : ScaleResult() + + class Error(val error: E) : ScaleResult() + + companion object { + + fun bind( + dynamicInstance: Any?, + bindOk: (Any?) -> T, + bindError: (Any?) -> E + ): ScaleResult { + val asEnum = dynamicInstance.castToDictEnum() + + return when (asEnum.name) { + "Ok" -> Ok(bindOk(asEnum.value)) + "Err" -> Error(bindError(asEnum.value)) + else -> error("Unknown Result variant: ${asEnum.name}") + } + } + } +} + +class ScaleResultError(val content: Any?) : Throwable() + +fun ScaleResult.toResult(): Result { + return when (this) { + is ScaleResult.Error -> Result.failure(ScaleResultError(error)) + is ScaleResult.Ok -> Result.success(value) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/WeightV2.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/WeightV2.kt new file mode 100644 index 0000000..5218b3c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/binding/WeightV2.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.common.data.network.runtime.binding + +import io.novafoundation.nova.common.utils.Min +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.scale.ToDynamicScaleInstance +import io.novafoundation.nova.common.utils.structOf +import io.novafoundation.nova.common.utils.times +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import java.math.BigInteger + +typealias Weight = BigInteger + +data class WeightV2(val refTime: BigInteger, val proofSize: BigInteger) : ToDynamicScaleInstance, Min { + + companion object { + + val MAX_DIMENSION = "184467440737090".toBigInteger() + + fun max(): WeightV2 { + return WeightV2(MAX_DIMENSION, MAX_DIMENSION) + } + + fun fromV1(refTime: BigInteger): WeightV2 { + return WeightV2(refTime, proofSize = BigInteger.ZERO) + } + + fun zero(): WeightV2 { + return WeightV2(BigInteger.ZERO, BigInteger.ZERO) + } + } + + operator fun times(multiplier: Double): WeightV2 { + return WeightV2(refTime = refTime.times(multiplier), proofSize = proofSize.times(multiplier)) + } + + operator fun plus(other: WeightV2): WeightV2 { + return WeightV2(refTime + other.refTime, proofSize + other.proofSize) + } + + operator fun minus(other: WeightV2): WeightV2 { + return WeightV2( + refTime = (refTime - other.refTime).atLeastZero(), + proofSize = (proofSize - other.proofSize).atLeastZero() + ) + } + + override fun toEncodableInstance(): Struct.Instance { + return structOf("refTime" to refTime, "proofSize" to proofSize) + } + + override fun min(other: WeightV2): WeightV2 { + return WeightV2( + refTime = refTime.min(other.refTime), + proofSize = proofSize.min(other.proofSize) + ) + } +} + +fun WeightV2.fitsIn(limit: WeightV2): Boolean { + return refTime <= limit.refTime && proofSize <= limit.proofSize +} + +fun bindWeight(decoded: Any?): Weight { + return when (decoded) { + // weight v1 + is BalanceOf -> decoded + + // weight v2 + is Struct.Instance -> bindWeightV2(decoded).refTime + + else -> incompatible() + } +} + +fun bindWeightV2(decoded: Any?): WeightV2 { + return when (decoded) { + is BalanceOf -> WeightV2.fromV1(decoded) + + is Struct.Instance -> WeightV2( + refTime = bindNumber(decoded["refTime"]), + proofSize = bindNumber(decoded["proofSize"]) + ) + + else -> incompatible() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/FeeCalculationRequest.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/FeeCalculationRequest.kt new file mode 100644 index 0000000..55ee3dd --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/FeeCalculationRequest.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +class FeeCalculationRequest(extrinsicInHex: String) : RuntimeRequest( + method = "payment_queryInfo", + params = listOf(extrinsicInHex) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetBlockHashRequest.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetBlockHashRequest.kt new file mode 100644 index 0000000..89a893a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetBlockHashRequest.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +class GetBlockHashRequest(blockNumber: BlockNumber?) : RuntimeRequest( + method = "chain_getBlockHash", + params = listOfNotNull( + blockNumber + ) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetBlockRequest.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetBlockRequest.kt new file mode 100644 index 0000000..53e8985 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetBlockRequest.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +class GetBlockRequest(blockHash: String? = null) : RuntimeRequest( + method = "chain_getBlock", + params = listOfNotNull( + blockHash + ) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetChainRequest.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetChainRequest.kt new file mode 100644 index 0000000..c19b626 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetChainRequest.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +class GetChainRequest : RuntimeRequest( + method = "system_chain", + params = emptyList() +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetChildStateRequest.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetChildStateRequest.kt new file mode 100644 index 0000000..450064c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetChildStateRequest.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +class GetChildStateRequest( + storageKey: String, + childKey: String +) : RuntimeRequest( + method = "childstate_getStorage", + params = listOf(childKey, storageKey) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetFinalizedHeadRequest.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetFinalizedHeadRequest.kt new file mode 100644 index 0000000..36888a0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetFinalizedHeadRequest.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +object GetFinalizedHeadRequest : RuntimeRequest( + method = "chain_getFinalizedHead", + params = emptyList() +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetHeaderRequest.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetHeaderRequest.kt new file mode 100644 index 0000000..fbb2a23 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetHeaderRequest.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +class GetHeaderRequest(blockHash: String? = null) : RuntimeRequest( + method = "chain_getHeader", + params = listOfNotNull( + blockHash + ) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetStorageSize.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetStorageSize.kt new file mode 100644 index 0000000..5c067e7 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetStorageSize.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +class GetStorageSize(key: String) : RuntimeRequest( + method = "state_getStorageSize", + params = listOfNotNull(key) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetSystemPropertiesRequest.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetSystemPropertiesRequest.kt new file mode 100644 index 0000000..7b5ab35 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/GetSystemPropertiesRequest.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +class GetSystemPropertiesRequest : RuntimeRequest( + method = "system_properties", + params = emptyList() +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/NextAccountIndexRequest.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/NextAccountIndexRequest.kt new file mode 100644 index 0000000..201c88b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/calls/NextAccountIndexRequest.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.data.network.runtime.calls + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +class NextAccountIndexRequest(accountAddress: String) : RuntimeRequest( + method = "system_accountNextIndex", + params = listOf( + accountAddress + ) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/model/FeeResponse.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/model/FeeResponse.kt new file mode 100644 index 0000000..6ae9fe7 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/model/FeeResponse.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.common.data.network.runtime.model + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import com.google.gson.annotations.JsonAdapter +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import java.lang.reflect.Type +import java.math.BigInteger + +class FeeResponse( + val partialFee: BigInteger, + + @JsonAdapter(WeightDeserizalier::class) + val weight: WeightV2 +) + +class WeightDeserizalier : JsonDeserializer { + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): WeightV2 { + return when { + // weight v1 + json is JsonPrimitive -> WeightV2.fromV1(json.asLong.toBigInteger()) + // weight v2 + json is JsonObject -> WeightV2( + refTime = json["ref_time"].asLong.toBigInteger(), + proofSize = json["proof_size"].asLong.toBigInteger() + ) + + else -> error("Unsupported weight type") + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/model/SignedBlock.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/model/SignedBlock.kt new file mode 100644 index 0000000..c886daa --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/model/SignedBlock.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.common.data.network.runtime.model + +import com.google.gson.annotations.SerializedName +import io.novafoundation.nova.common.utils.removeHexPrefix + +class SignedBlock(val block: Block, val justification: Any?) { + class Block(val extrinsics: List, val header: Header) { + class Header(@SerializedName("number") private val numberRaw: String, val parentHash: String?) { + val number: Int + get() { + return numberRaw.removeHexPrefix().toInt(radix = 16) + } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/model/SystemProperties.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/model/SystemProperties.kt new file mode 100644 index 0000000..ae801b6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/runtime/model/SystemProperties.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.data.network.runtime.model + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.annotations.JsonAdapter +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +class SystemProperties( + val ss58Format: Int?, + val SS58Prefix: Int?, + @JsonAdapter(WrapToListSerializer::class) + val tokenDecimals: List, + @JsonAdapter(WrapToListSerializer::class) + val tokenSymbol: List +) + +private class WrapToListSerializer : JsonDeserializer> { + + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): List<*> { + val valueType = (typeOfT as ParameterizedType).actualTypeArguments[0] + + if (json.isJsonPrimitive) { + return listOf(context.deserialize(json, valueType)) + } + + return json.asJsonArray.map { + context.deserialize(it, valueType) + } + } +} + +fun SystemProperties.firstTokenDecimals() = tokenDecimals.first() + +fun SystemProperties.firstTokenSymbol() = tokenSymbol.first() diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/EraValidatorInfoQueryResponse.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/EraValidatorInfoQueryResponse.kt new file mode 100644 index 0000000..1f853f5 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/EraValidatorInfoQueryResponse.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.data.network.subquery + +import java.math.BigInteger + +class EraValidatorInfoQueryResponse(val eraValidatorInfos: SubQueryNodes?) { + class EraValidatorInfo( + val id: String, + val address: String, + val era: BigInteger, + val total: String, + val own: String, + ) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/SubQueryResponse.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/SubQueryResponse.kt new file mode 100644 index 0000000..781d02d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/SubQueryResponse.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.common.data.network.subquery + +class SubQueryResponse( + val data: T +) + +class SubQueryNodes(val nodes: List) + +class SubQueryTotalCount(val totalCount: Int) + +class SubQueryGroupedAggregates(val groupedAggregates: List) + +sealed class GroupedAggregate(val keys: List) { + + class Sum(val sum: T, keys: List) : GroupedAggregate(keys) +} + +fun SubQueryGroupedAggregates>.firstSum(): T? { + return groupedAggregates.firstOrNull()?.sum +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/SubqueryExpressions.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/SubqueryExpressions.kt new file mode 100644 index 0000000..af06f46 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/SubqueryExpressions.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.common.data.network.subquery + +object SubqueryExpressions { + + fun or(vararg innerExpressions: String): String { + return compoundExpression("or", *innerExpressions) + } + + fun or(innerExpressions: Collection) = or(*innerExpressions.toTypedArray()) + + infix fun String.or(another: String): String { + return or(this, another) + } + + fun anyOf(innerExpressions: Collection) = or(innerExpressions) + fun anyOf(vararg innerExpressions: String) = or(*innerExpressions) + + fun allOf(vararg innerExpressions: String) = and(*innerExpressions) + + fun and(vararg innerExpressions: String): String { + return compoundExpression("and", *innerExpressions) + } + + fun presentIn(vararg values: String): String { + return compoundExpression("in", *values) + } + + fun presentIn(values: List): String { + return presentIn(*values.toTypedArray()) + } + + infix fun String.and(another: String): String { + return and(this, another) + } + + fun and(innerExpressions: Collection) = and(*innerExpressions.toTypedArray()) + + fun not(expression: String): String { + return "not: {$expression}" + } + + private fun compoundExpression(name: String, vararg innerExpressions: String): String { + if (innerExpressions.isEmpty()) { + return "" + } + + if (innerExpressions.size == 1) { + return innerExpressions.first() + } + + return innerExpressions.joinToString( + prefix = "$name: [", + postfix = "]", + separator = "," + ) { + "{$it}" + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/SubqueryFilters.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/SubqueryFilters.kt new file mode 100644 index 0000000..d0187dd --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/subquery/SubqueryFilters.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.data.network.subquery + +import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.or + +interface SubQueryFilters { + + companion object : SubQueryFilters + + infix fun String.equalTo(value: String) = "$this: { equalTo: \"$value\" }" + + infix fun String.equalTo(value: Boolean) = "$this: { equalTo: $value }" + + infix fun String.equalTo(value: Int) = "$this: { equalTo: $value }" + + infix fun String.equalToEnum(value: String) = "$this: { equalTo: $value }" + + fun queryParams( + filter: String + ): String { + if (filter.isEmpty()) { + return "" + } + + return "(filter: { $filter })" + } + + infix fun String.presentIn(values: List): String { + val queryValues = values.joinToString(separator = ",") { "\"${it}\"" } + return "$this: { in: [$queryValues] }" + } + + fun String.containsFilter(field: String, value: String?): String { + return if (value != null) { + "$this: { contains: { $field: \"$value\" } }" + } else { + or( + "$this: { contains: { $field: null } }", + "not: { $this: { containsKey: \"$field\"} }" + ) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/providers/deviceid/AndroidDeviceIdProvider.kt b/common/src/main/java/io/novafoundation/nova/common/data/providers/deviceid/AndroidDeviceIdProvider.kt new file mode 100644 index 0000000..2839b41 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/providers/deviceid/AndroidDeviceIdProvider.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.common.data.providers.deviceid + +import android.annotation.SuppressLint +import android.content.Context +import android.provider.Settings.Secure + +class AndroidDeviceIdProvider( + private val context: Context +) : DeviceIdProvider { + + @SuppressLint("HardwareIds") + override fun getDeviceId(): String { + return Secure.getString(context.contentResolver, Secure.ANDROID_ID) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/providers/deviceid/DeviceIdProvider.kt b/common/src/main/java/io/novafoundation/nova/common/data/providers/deviceid/DeviceIdProvider.kt new file mode 100644 index 0000000..5f6f698 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/providers/deviceid/DeviceIdProvider.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.data.providers.deviceid + +interface DeviceIdProvider { + fun getDeviceId(): String +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/repository/AssetsIconModeRepository.kt b/common/src/main/java/io/novafoundation/nova/common/data/repository/AssetsIconModeRepository.kt new file mode 100644 index 0000000..9d8073d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/repository/AssetsIconModeRepository.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.common.data.repository + +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.data.storage.Preferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface AssetsIconModeRepository { + + fun assetsIconModeFlow(): Flow + + fun setAssetsIconMode(assetsViewMode: AssetIconMode) + + fun getIconMode(): AssetIconMode +} + +private const val PREFS_ASSETS_ICON_MODE = "PREFS_ASSETS_ICON_MODE" +private val ASSET_ICON_MODE_DEFAULT = AssetIconMode.COLORED + +class RealAssetsIconModeRepository( + private val preferences: Preferences +) : AssetsIconModeRepository { + + override fun assetsIconModeFlow(): Flow { + return preferences.stringFlow(PREFS_ASSETS_ICON_MODE) + .map { + it?.fromPrefsValue() ?: ASSET_ICON_MODE_DEFAULT + } + } + + override fun setAssetsIconMode(assetsViewMode: AssetIconMode) { + preferences.putString(PREFS_ASSETS_ICON_MODE, assetsViewMode.toPrefsValue()) + } + + override fun getIconMode(): AssetIconMode { + return preferences.getString(PREFS_ASSETS_ICON_MODE)?.fromPrefsValue() ?: ASSET_ICON_MODE_DEFAULT + } + + private fun AssetIconMode.toPrefsValue(): String { + return when (this) { + AssetIconMode.COLORED -> "colored" + AssetIconMode.WHITE -> "white" + } + } + + private fun String.fromPrefsValue(): AssetIconMode? { + return when (this) { + "colored" -> AssetIconMode.COLORED + "white" -> AssetIconMode.WHITE + else -> null + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/repository/AssetsViewModeRepository.kt b/common/src/main/java/io/novafoundation/nova/common/data/repository/AssetsViewModeRepository.kt new file mode 100644 index 0000000..072c9cb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/repository/AssetsViewModeRepository.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.common.data.repository + +import io.novafoundation.nova.common.data.model.AssetViewMode +import io.novafoundation.nova.common.data.storage.Preferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +interface AssetsViewModeRepository { + + fun getAssetViewMode(): AssetViewMode + + fun assetsViewModeFlow(): Flow + + suspend fun setAssetsViewMode(assetsViewMode: AssetViewMode) +} + +private const val PREFS_ASSETS_VIEW_MODE = "PREFS_ASSETS_VIEW_MODE" +private val ASSET_VIEW_MODE_DEFAULT = AssetViewMode.TOKENS + +class RealAssetsViewModeRepository( + private val preferences: Preferences +) : AssetsViewModeRepository { + + override fun getAssetViewMode(): AssetViewMode { + return preferences.getString(PREFS_ASSETS_VIEW_MODE)?.fromPrefsValue() ?: ASSET_VIEW_MODE_DEFAULT + } + + override fun assetsViewModeFlow(): Flow { + return preferences.stringFlow(PREFS_ASSETS_VIEW_MODE) + .map { + it?.fromPrefsValue() ?: ASSET_VIEW_MODE_DEFAULT + } + } + + override suspend fun setAssetsViewMode(assetsViewMode: AssetViewMode) = withContext(Dispatchers.IO) { + preferences.putString(PREFS_ASSETS_VIEW_MODE, assetsViewMode.toPrefsValue()) + } + + private fun AssetViewMode.toPrefsValue(): String { + return when (this) { + AssetViewMode.NETWORKS -> "networks" + AssetViewMode.TOKENS -> "tokens" + } + } + + private fun String.fromPrefsValue(): AssetViewMode? { + return when (this) { + "networks" -> AssetViewMode.NETWORKS + "tokens" -> AssetViewMode.TOKENS + else -> null + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/repository/BannerVisibilityRepository.kt b/common/src/main/java/io/novafoundation/nova/common/data/repository/BannerVisibilityRepository.kt new file mode 100644 index 0000000..89de741 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/repository/BannerVisibilityRepository.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.data.repository + +import io.novafoundation.nova.common.data.storage.Preferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +typealias BannerTag = String + +interface BannerVisibilityRepository { + + fun shouldShowBannerFlow(tag: BannerTag): Flow + + suspend fun hideBanner(tag: BannerTag) + + suspend fun showBanner(tag: BannerTag) +} + +private const val SHOW_BANNER_DEFAULT = true + +internal class RealBannerVisibilityRepository( + private val preferences: Preferences +) : BannerVisibilityRepository { + + override fun shouldShowBannerFlow(tag: BannerTag): Flow { + return preferences.booleanFlow(prefsTag(tag), defaultValue = SHOW_BANNER_DEFAULT) + } + + override suspend fun hideBanner(tag: BannerTag) = withContext(Dispatchers.IO) { + preferences.putBoolean(prefsTag(tag), false) + } + + override suspend fun showBanner(tag: BannerTag) = withContext(Dispatchers.IO) { + preferences.putBoolean(prefsTag(tag), true) + } + + private fun prefsTag(bannerTag: BannerTag): String { + return "BannerVisibilityRepository.$bannerTag" + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/repository/ToggleFeatureRepository.kt b/common/src/main/java/io/novafoundation/nova/common/data/repository/ToggleFeatureRepository.kt new file mode 100644 index 0000000..086c479 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/repository/ToggleFeatureRepository.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.common.data.repository + +import io.novafoundation.nova.common.data.storage.Preferences +import kotlinx.coroutines.flow.Flow + +interface ToggleFeatureRepository { + fun get(key: String, default: Boolean = false): Boolean + + fun set(key: String, value: Boolean) + + fun observe(key: String, default: Boolean = false): Flow +} + +class RealToggleFeatureRepository( + private val preferences: Preferences +) : ToggleFeatureRepository { + + override fun get(key: String, default: Boolean): Boolean { + return preferences.getBoolean(key, default) + } + + override fun set(key: String, value: Boolean) { + preferences.putBoolean(key, value) + } + + override fun observe(key: String, default: Boolean): Flow { + return preferences.booleanFlow(key, default) + } +} + +fun ToggleFeatureRepository.toggle(key: String): Boolean { + val toggled = !get(key) + set(key, toggled) + return toggled +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v1/Ext.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v1/Ext.kt new file mode 100644 index 0000000..2470f06 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v1/Ext.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.common.data.secrets.v1 + +import io.novasama.substrate_sdk_android.encrypt.keypair.BaseKeypair +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.Sr25519Keypair + +/** + * Creates [Sr25519Keypair] if [nonce] is not null + * Creates [BaseKeypair] otherwise + */ +fun Keypair( + publicKey: ByteArray, + privateKey: ByteArray, + nonce: ByteArray? = null +) = if (nonce != null) { + Sr25519Keypair( + publicKey = publicKey, + privateKey = privateKey, + nonce = nonce + ) +} else { + BaseKeypair( + privateKey = privateKey, + publicKey = publicKey + ) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v1/SecretStoreV1.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v1/SecretStoreV1.kt new file mode 100644 index 0000000..984dbc2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v1/SecretStoreV1.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.common.data.secrets.v1 + +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.core.model.SecuritySource +import io.novafoundation.nova.core.model.WithDerivationPath +import io.novafoundation.nova.core.model.WithMnemonic +import io.novafoundation.nova.core.model.WithSeed +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.Sr25519Keypair +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface SecretStoreV1 { + + suspend fun saveSecuritySource(accountAddress: String, source: SecuritySource) + + suspend fun getSecuritySource(accountAddress: String): SecuritySource? +} + +private const val PREFS_SECURITY_SOURCE_MASK = "security_source_%s" + +class SecretStoreV1Impl( + private val encryptedPreferences: EncryptedPreferences +) : SecretStoreV1 { + + override suspend fun saveSecuritySource(accountAddress: String, source: SecuritySource) = withContext(Dispatchers.Default) { + val key = PREFS_SECURITY_SOURCE_MASK.format(accountAddress) + + val keypair = source.keypair + val seed = (source as? WithSeed)?.seed + val mnemonic = (source as? WithMnemonic)?.mnemonic + val derivationPath = (source as? WithDerivationPath)?.derivationPath + + val toSave = SourceInternal { + it[Type] = getSourceType(source).name + + it[PrivateKey] = keypair.privateKey + it[PublicKey] = keypair.publicKey + it[Nonce] = (keypair as? Sr25519Keypair)?.nonce + + it[Seed] = seed + it[Mnemonic] = mnemonic + it[DerivationPath] = derivationPath + } + + val raw = SourceInternal.toHexString(toSave) + + encryptedPreferences.putEncryptedString(key, raw) + } + + override suspend fun getSecuritySource(accountAddress: String): SecuritySource? = withContext(Dispatchers.Default) { + val key = PREFS_SECURITY_SOURCE_MASK.format(accountAddress) + + val raw = encryptedPreferences.getDecryptedString(key) ?: return@withContext null + val internalSource = SourceInternal.read(raw) + + val keypair = Keypair( + publicKey = internalSource[SourceInternal.PublicKey], + privateKey = internalSource[SourceInternal.PrivateKey], + nonce = internalSource[SourceInternal.Nonce] + ) + + val seed = internalSource[SourceInternal.Seed] + val mnemonic = internalSource[SourceInternal.Mnemonic] + val derivationPath = internalSource[SourceInternal.DerivationPath] + + when (SourceType.valueOf(internalSource[SourceInternal.Type])) { + SourceType.CREATE -> SecuritySource.Specified.Create(seed, keypair, mnemonic!!, derivationPath) + SourceType.SEED -> SecuritySource.Specified.Seed(seed, keypair, derivationPath) + SourceType.JSON -> SecuritySource.Specified.Json(seed, keypair) + SourceType.MNEMONIC -> SecuritySource.Specified.Mnemonic(seed, keypair, mnemonic!!, derivationPath) + SourceType.UNSPECIFIED -> SecuritySource.Unspecified(keypair) + } + } + + private fun getSourceType(securitySource: SecuritySource): SourceType { + return when (securitySource) { + is SecuritySource.Specified.Create -> SourceType.CREATE + is SecuritySource.Specified.Mnemonic -> SourceType.MNEMONIC + is SecuritySource.Specified.Json -> SourceType.JSON + is SecuritySource.Specified.Seed -> SourceType.SEED + else -> SourceType.UNSPECIFIED + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v1/SourceInternal.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v1/SourceInternal.kt new file mode 100644 index 0000000..5b88777 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v1/SourceInternal.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.data.secrets.v1 + +import io.novasama.substrate_sdk_android.scale.Schema +import io.novasama.substrate_sdk_android.scale.byteArray +import io.novasama.substrate_sdk_android.scale.string + +internal enum class SourceType { + CREATE, SEED, MNEMONIC, JSON, UNSPECIFIED +} + +internal object SourceInternal : Schema() { + val Type by string() + + val PrivateKey by byteArray() + val PublicKey by byteArray() + + val Nonce by byteArray().optional() + + val Seed by byteArray().optional() + val Mnemonic by string().optional() + + val DerivationPath by string().optional() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt new file mode 100644 index 0000000..cae900e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.common.data.secrets.v2 + +import io.novafoundation.nova.common.utils.invoke +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.Sr25519Keypair +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import io.novasama.substrate_sdk_android.scale.Schema +import io.novasama.substrate_sdk_android.scale.byteArray +import io.novasama.substrate_sdk_android.scale.schema +import io.novasama.substrate_sdk_android.scale.string + +object KeyPairSchema : Schema() { + val PrivateKey by byteArray() + val PublicKey by byteArray() + + val Nonce by byteArray().optional() +} + +object MetaAccountSecrets : Schema() { + val Entropy by byteArray().optional() + val SubstrateSeed by byteArray().optional() + + val SubstrateKeypair by schema(KeyPairSchema) + val SubstrateDerivationPath by string().optional() + + val EthereumKeypair by schema(KeyPairSchema).optional() + val EthereumDerivationPath by string().optional() +} + +object ChainAccountSecrets : Schema() { + val Entropy by byteArray().optional() + val Seed by byteArray().optional() + + val Keypair by schema(KeyPairSchema) + val DerivationPath by string().optional() +} + +fun MetaAccountSecrets( + substrateKeyPair: Keypair, + entropy: ByteArray? = null, + substrateSeed: ByteArray? = null, + substrateDerivationPath: String? = null, + ethereumKeypair: Keypair? = null, + ethereumDerivationPath: String? = null, +): EncodableStruct = MetaAccountSecrets { secrets -> + secrets[Entropy] = entropy + secrets[SubstrateSeed] = substrateSeed + + secrets[SubstrateKeypair] = KeyPairSchema { keypair -> + keypair[PublicKey] = substrateKeyPair.publicKey + keypair[PrivateKey] = substrateKeyPair.privateKey + keypair[Nonce] = (substrateKeyPair as? Sr25519Keypair)?.nonce + } + secrets[SubstrateDerivationPath] = substrateDerivationPath + + secrets[EthereumKeypair] = ethereumKeypair?.let { + KeyPairSchema { keypair -> + keypair[PublicKey] = it.publicKey + keypair[PrivateKey] = it.privateKey + keypair[Nonce] = null // ethereum does not support Sr25519 so nonce is always null + } + } + secrets[EthereumDerivationPath] = ethereumDerivationPath +} + +fun ChainAccountSecrets( + keyPair: Keypair, + entropy: ByteArray? = null, + seed: ByteArray? = null, + derivationPath: String? = null, +): EncodableStruct = ChainAccountSecrets { secrets -> + secrets[Entropy] = entropy + secrets[Seed] = seed + + secrets[Keypair] = KeyPairSchema { keypair -> + keypair[PublicKey] = keyPair.publicKey + keypair[PrivateKey] = keyPair.privateKey + keypair[Nonce] = (keyPair as? Sr25519Keypair)?.nonce + } + secrets[DerivationPath] = derivationPath +} + +val EncodableStruct.substrateDerivationPath + get() = get(MetaAccountSecrets.SubstrateDerivationPath) + +val EncodableStruct.ethereumDerivationPath + get() = get(MetaAccountSecrets.EthereumDerivationPath) + +val EncodableStruct.entropy + get() = get(MetaAccountSecrets.Entropy) + +val EncodableStruct.seed + get() = get(MetaAccountSecrets.SubstrateSeed) + +val EncodableStruct.substrateKeypair + get() = get(MetaAccountSecrets.SubstrateKeypair) + +val EncodableStruct.ethereumKeypair + get() = get(MetaAccountSecrets.EthereumKeypair) + +val EncodableStruct.derivationPath + get() = get(ChainAccountSecrets.DerivationPath) + +@get:JvmName("chainAccountEntropy") +val EncodableStruct.entropy + get() = get(ChainAccountSecrets.Entropy) + +@get:JvmName("chainAccountSeed") +val EncodableStruct.seed + get() = get(ChainAccountSecrets.Seed) + +val EncodableStruct.keypair + get() = get(ChainAccountSecrets.Keypair) +val EncodableStruct.privateKey + get() = get(KeyPairSchema.PrivateKey) + +val EncodableStruct.publicKey + get() = get(KeyPairSchema.PublicKey) + +val EncodableStruct.nonce + get() = get(KeyPairSchema.Nonce) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt new file mode 100644 index 0000000..b62f364 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt @@ -0,0 +1,207 @@ +package io.novafoundation.nova.common.data.secrets.v2 + +import io.novafoundation.nova.common.data.secrets.v1.Keypair +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.utils.Union +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.fold +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import io.novasama.substrate_sdk_android.scale.toHexString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val ACCESS_SECRETS = "ACCESS_SECRETS" +private const val ADDITIONAL_KNOWN_KEYS = "ADDITIONAL_KNOWN_KEYS" +private const val ADDITIONAL_KNOWN_KEYS_DELIMITER = "," + +typealias AccountSecrets = Union, EncodableStruct> + +class SecretStoreV2( + private val encryptedPreferences: EncryptedPreferences, +) { + + suspend fun putMetaAccountSecrets(metaId: Long, secrets: EncodableStruct) = withContext(Dispatchers.IO) { + encryptedPreferences.putEncryptedString(metaAccountKey(metaId, ACCESS_SECRETS), secrets.toHexString()) + } + + suspend fun getMetaAccountSecrets(metaId: Long): EncodableStruct? = withContext(Dispatchers.IO) { + encryptedPreferences.getDecryptedString(metaAccountKey(metaId, ACCESS_SECRETS))?.let(MetaAccountSecrets::read) + } + + suspend fun putChainAccountSecrets(metaId: Long, accountId: ByteArray, secrets: EncodableStruct) = withContext(Dispatchers.IO) { + encryptedPreferences.putEncryptedString(chainAccountKey(metaId, accountId, ACCESS_SECRETS), secrets.toHexString()) + } + + suspend fun getChainAccountSecrets(metaId: Long, accountId: ByteArray): EncodableStruct? = withContext(Dispatchers.IO) { + encryptedPreferences.getDecryptedString(chainAccountKey(metaId, accountId, ACCESS_SECRETS))?.let(ChainAccountSecrets::read) + } + suspend fun hasChainSecrets(metaId: Long, accountId: ByteArray) = withContext(Dispatchers.IO) { + encryptedPreferences.hasKey(chainAccountKey(metaId, accountId, ACCESS_SECRETS)) + } + + suspend fun getAdditionalMetaAccountSecret(metaId: Long, secretName: String) = withContext(Dispatchers.IO) { + encryptedPreferences.getDecryptedString(metaAccountAdditionalKey(metaId, secretName)) + } + + suspend fun putAdditionalMetaAccountSecret(metaId: Long, secretName: String, value: String) = withContext(Dispatchers.IO) { + val key = metaAccountAdditionalKey(metaId, secretName) + + encryptedPreferences.putEncryptedString(key, value) + putAdditionalSecretKeyToKnown(metaId, secretName) + } + + suspend fun clearMetaAccountSecrets(metaId: Long, chainAccountIds: List) = withContext(Dispatchers.IO) { + chainAccountIds.map { chainAccountKey(metaId, it, ACCESS_SECRETS) } + .onEach(encryptedPreferences::removeKey) + + encryptedPreferences.removeKey(metaAccountKey(metaId, ACCESS_SECRETS)) + clearAdditionalSecrets(metaId) + } + + suspend fun clearChainAccountsSecrets(metaId: Long, chainAccountIds: List) = withContext(Dispatchers.IO) { + chainAccountIds.map { chainAccountKey(metaId, it, ACCESS_SECRETS) } + .onEach(encryptedPreferences::removeKey) + } + + suspend fun allKnownAdditionalSecrets(metaId: Long): Map = withContext(Dispatchers.IO) { + allKnownAdditionalSecretKeys(metaId).associateWith { secretKey -> + getAdditionalMetaAccountSecret(metaId, secretKey) + }.filterNotNull() + } + + suspend fun allKnownAdditionalSecretKeys(metaId: Long): Set = withContext(Dispatchers.IO) { + val metaAccountAdditionalKnownKey = metaAccountAdditionalKnownKey(metaId) + + encryptedPreferences.getDecryptedString(metaAccountAdditionalKnownKey) + ?.split(ADDITIONAL_KNOWN_KEYS_DELIMITER)?.toSet() + ?: emptySet() + } + + private suspend fun clearAdditionalSecrets(metaId: Long) { + val allKnown = allKnownAdditionalSecretKeys(metaId) + + allKnown.forEach { secretName -> + encryptedPreferences.removeKey(metaAccountAdditionalKey(metaId, secretName)) + } + + encryptedPreferences.removeKey(metaAccountAdditionalKnownKey(metaId)) + } + + private suspend fun putAdditionalSecretKeyToKnown(metaId: Long, secretName: String) { + require(validAdditionalKeyName(secretName)) + + val currentKnownKeys = allKnownAdditionalSecretKeys(metaId) + + val updatedKnownKeys = currentKnownKeys + secretName + val encodedKnownKeys = updatedKnownKeys.joinToString(ADDITIONAL_KNOWN_KEYS_DELIMITER) + + encryptedPreferences.putEncryptedString(metaAccountAdditionalKnownKey(metaId), encodedKnownKeys) + } + + private fun chainAccountKey(metaId: Long, accountId: ByteArray, secretName: String) = "$metaId:${accountId.toHexString()}:$secretName" + + private fun metaAccountKey(metaId: Long, secretName: String) = "$metaId:$secretName" + + private fun metaAccountAdditionalKnownKey(metaId: Long) = "$metaId:$ADDITIONAL_KNOWN_KEYS" + private fun metaAccountAdditionalKey(metaId: Long, secretName: String) = "$metaId:$ADDITIONAL_KNOWN_KEYS:$secretName" + + private fun validAdditionalKeyName(secretName: String) = ADDITIONAL_KNOWN_KEYS_DELIMITER !in secretName +} + +suspend fun SecretStoreV2.getAccountSecrets( + metaId: Long, + accountId: ByteArray, +): AccountSecrets { + return if (hasChainSecrets(metaId, accountId)) { + Union.right( + getChainAccountSecrets(metaId, accountId) ?: noChainSecrets(metaId, accountId) + ) + } else { + Union.left( + getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId) + ) + } +} + +fun AccountSecrets.seed(): ByteArray? = fold( + left = { it[MetaAccountSecrets.SubstrateSeed] }, + right = { it[ChainAccountSecrets.Seed] } +) + +fun AccountSecrets.entropy(): ByteArray? = fold( + left = { it[MetaAccountSecrets.Entropy] }, + right = { it[ChainAccountSecrets.Entropy] } +) + +suspend fun SecretStoreV2.getChainAccountKeypair( + metaId: Long, + accountId: ByteArray, +): Keypair = withContext(Dispatchers.Default) { + val secrets = getChainAccountSecrets(metaId, accountId) ?: noChainSecrets(metaId, accountId) + + val keypairStruct = secrets[ChainAccountSecrets.Keypair] + + mapKeypairStructToKeypair(keypairStruct) +} + +val AccountSecrets.isMetaAccountSecrets + get() = isLeft + +val AccountSecrets.isChainAccountSecrets + get() = isRight + +suspend fun SecretStoreV2.getMetaAccountKeypair( + metaId: Long, + isEthereum: Boolean, +): Keypair = withContext(Dispatchers.Default) { + val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId) + + mapMetaAccountSecretsToKeypair(secrets, isEthereum) +} + +fun mapMetaAccountSecretsToKeypair( + secrets: EncodableStruct, + ethereum: Boolean, +): Keypair { + val keypairStruct = if (ethereum) { + secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() + } else { + secrets[MetaAccountSecrets.SubstrateKeypair] + } + + return mapKeypairStructToKeypair(keypairStruct) +} + +fun mapMetaAccountSecretsToDerivationPath( + secrets: EncodableStruct, + ethereum: Boolean, +): String? { + return if (ethereum) { + secrets[MetaAccountSecrets.EthereumDerivationPath] + } else { + secrets[MetaAccountSecrets.SubstrateDerivationPath] + } +} + +fun mapChainAccountSecretsToKeypair( + secrets: EncodableStruct +) = mapKeypairStructToKeypair(secrets[ChainAccountSecrets.Keypair]) + +private fun noMetaSecrets(metaId: Long): Nothing = error("No secrets found for meta account $metaId") + +private fun noChainSecrets(metaId: Long, accountId: ByteArray): Nothing { + error("No secrets found for meta account $metaId for account ${accountId.toHexString()}") +} + +private fun noEthereumSecret(): Nothing = error("No ethereum keypair found") + +fun mapKeypairStructToKeypair(struct: EncodableStruct): Keypair { + return Keypair( + publicKey = struct[KeyPairSchema.PublicKey], + privateKey = struct[KeyPairSchema.PrivateKey], + nonce = struct[KeyPairSchema.Nonce] + ) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/Preferences.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/Preferences.kt new file mode 100644 index 0000000..4f9dbee --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/Preferences.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.common.data.storage + +import io.novafoundation.nova.core.model.Language +import kotlinx.coroutines.flow.Flow + +typealias InitialValueProducer = suspend () -> T + +interface Preferences { + + fun contains(field: String): Boolean + + fun putString(field: String, value: String?) + + fun getString(field: String, defaultValue: String): String + + fun getString(field: String): String? + + fun putBoolean(field: String, value: Boolean) + + fun getBoolean(field: String, defaultValue: Boolean): Boolean + + fun putInt(field: String, value: Int) + + fun putStringSet(field: String, value: Set?) + + fun getInt(field: String, defaultValue: Int): Int + + fun putLong(field: String, value: Long) + + fun getLong(field: String, defaultValue: Long): Long + + fun getStringSet(field: String): Set + + fun getCurrentLanguage(): Language? + + fun saveCurrentLanguage(languageIsoCode: String) + + fun removeField(field: String) + + fun stringFlow( + field: String, + initialValueProducer: InitialValueProducer? = null + ): Flow + + fun booleanFlow( + field: String, + defaultValue: Boolean + ): Flow + + fun stringSetFlow( + field: String, + initialValueProducer: InitialValueProducer>? = null + ): Flow?> + + fun keyFlow(key: String): Flow + + fun keysFlow(vararg keys: String): Flow> + + fun edit(): Editor +} + +interface Editor { + + fun putString(field: String, value: String?) + + fun putBoolean(field: String, value: Boolean) + + fun putInt(field: String, value: Int) + + fun putLong(field: String, value: Long) + + fun remove(field: String) + + fun apply() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/PreferencesImpl.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/PreferencesImpl.kt new file mode 100644 index 0000000..584d16f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/PreferencesImpl.kt @@ -0,0 +1,146 @@ +package io.novafoundation.nova.common.data.storage + +import android.content.SharedPreferences +import io.novafoundation.nova.core.model.Language +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map + +class PreferencesImpl( + private val sharedPreferences: SharedPreferences +) : Preferences { + + /* + SharedPreferencesImpl stores listeners in a WeakHashMap, + meaning listener is subject to GC if it is not kept anywhere else. + This is not a problem until a stringFlow() call is followed later by shareIn() or stateIn(), + which cause listener to be GC-ed (TODO - research why). + To avoid that, store strong references to listeners until corresponding flow is closed. + */ + private val listeners = mutableSetOf() + + companion object { + + private const val PREFS_SELECTED_LANGUAGE = "selected_language" + } + + override fun contains(field: String) = sharedPreferences.contains(field) + + override fun putString(field: String, value: String?) { + sharedPreferences.edit().putString(field, value).apply() + } + + override fun getString(field: String, defaultValue: String): String { + return sharedPreferences.getString(field, defaultValue) ?: defaultValue + } + + override fun getString(field: String): String? { + return sharedPreferences.getString(field, null) + } + + override fun putBoolean(field: String, value: Boolean) { + sharedPreferences.edit().putBoolean(field, value).apply() + } + + override fun getBoolean(field: String, defaultValue: Boolean): Boolean { + return sharedPreferences.getBoolean(field, defaultValue) + } + + override fun putInt(field: String, value: Int) { + sharedPreferences.edit().putInt(field, value).apply() + } + + override fun getInt(field: String, defaultValue: Int): Int { + return sharedPreferences.getInt(field, defaultValue) + } + + override fun putLong(field: String, value: Long) { + sharedPreferences.edit().putLong(field, value).apply() + } + + override fun putStringSet(field: String, value: Set?) { + sharedPreferences.edit().putStringSet(field, value).apply() + } + + override fun getLong(field: String, defaultValue: Long): Long { + return sharedPreferences.getLong(field, defaultValue) + } + + override fun getStringSet(field: String): Set { + return sharedPreferences.getStringSet(field, emptySet()) ?: emptySet() + } + + override fun getCurrentLanguage(): Language? { + return if (sharedPreferences.contains(PREFS_SELECTED_LANGUAGE)) { + Language(iso639Code = sharedPreferences.getString(PREFS_SELECTED_LANGUAGE, "")!!) + } else { + null + } + } + + override fun saveCurrentLanguage(languageIsoCode: String) { + sharedPreferences.edit().putString(PREFS_SELECTED_LANGUAGE, languageIsoCode).commit() + } + + override fun removeField(field: String) { + sharedPreferences.edit().remove(field).apply() + } + + override fun stringFlow( + field: String, + initialValueProducer: (suspend () -> String)? + ): Flow = keyFlow(field) + .map { + if (contains(field)) { + getString(field) + } else { + val initialValue = initialValueProducer?.invoke() + putString(field, initialValue) + initialValue + } + } + + override fun booleanFlow(field: String, defaultValue: Boolean): Flow { + return keyFlow(field).map { + getBoolean(field, defaultValue) + } + } + + override fun stringSetFlow(field: String, initialValueProducer: InitialValueProducer>?): Flow?> { + return keyFlow(field).map { + if (contains(field)) { + getStringSet(field) + } else { + val initialValue = initialValueProducer?.invoke() + putStringSet(field, initialValue) + initialValue + } + } + } + + override fun keyFlow(key: String): Flow = keysFlow(key) + .map { it.first() } + + override fun keysFlow(vararg keys: String): Flow> = callbackFlow { + send(keys.toList()) + + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key in keys) { + trySend(listOfNotNull(key)) + } + } + + listeners.add(listener) + sharedPreferences.registerOnSharedPreferenceChangeListener(listener) + + awaitClose { + listeners.remove(listener) + sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) + } + } + + override fun edit(): Editor { + return SharedPreferenceEditor(sharedPreferences) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/SharedPreferenceEditor.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/SharedPreferenceEditor.kt new file mode 100644 index 0000000..6c982e9 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/SharedPreferenceEditor.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.data.storage + +import android.content.SharedPreferences + +class SharedPreferenceEditor(private val sharedPreferences: SharedPreferences) : Editor { + + private val editor = sharedPreferences.edit() + + override fun putString(field: String, value: String?) { + editor.putString(field, value) + } + + override fun putBoolean(field: String, value: Boolean) { + editor.putBoolean(field, value) + } + + override fun putInt(field: String, value: Int) { + editor.putInt(field, value) + } + + override fun putLong(field: String, value: Long) { + editor.putLong(field, value) + } + + override fun remove(field: String) { + editor.remove(field) + } + + override fun apply() { + editor.apply() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferences.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferences.kt new file mode 100644 index 0000000..c624b42 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferences.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.data.storage.encrypt + +interface EncryptedPreferences { + + fun putEncryptedString(field: String, value: String) + + fun getDecryptedString(field: String): String? + + fun hasKey(field: String): Boolean + + fun removeKey(field: String) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImpl.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImpl.kt new file mode 100644 index 0000000..e6c7074 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImpl.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.common.data.storage.encrypt + +import io.novafoundation.nova.common.data.storage.Preferences + +class EncryptedPreferencesImpl( + private val preferences: Preferences, + private val encryptionUtil: EncryptionUtil +) : EncryptedPreferences { + + override fun putEncryptedString(field: String, value: String) { + preferences.putString(field, encryptionUtil.encrypt(value)) + } + + override fun getDecryptedString(field: String): String? { + val encryptedString = preferences.getString(field) + return encryptedString?.let { encryptionUtil.decrypt(it) } + } + + override fun hasKey(field: String): Boolean { + return preferences.contains(field) + } + + override fun removeKey(field: String) { + preferences.removeField(field) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptionUtil.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptionUtil.kt new file mode 100644 index 0000000..c17889f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptionUtil.kt @@ -0,0 +1,246 @@ +package io.novafoundation.nova.common.data.storage.encrypt + +import android.content.Context +import android.os.Build +import android.security.KeyPairGeneratorSpec +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import org.bouncycastle.util.Arrays +import org.bouncycastle.util.encoders.Base64 +import java.math.BigInteger +import java.security.InvalidAlgorithmParameterException +import java.security.InvalidKeyException +import java.security.Key +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.NoSuchAlgorithmException +import java.security.PrivateKey +import java.security.PublicKey +import java.security.SecureRandom +import java.security.spec.AlgorithmParameterSpec +import java.util.Calendar +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.IllegalBlockSizeException +import javax.crypto.KeyGenerator +import javax.crypto.NoSuchPaddingException +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.inject.Inject +import javax.security.auth.x500.X500Principal + +class EncryptionUtil @Inject constructor( + private val context: Context +) { + + companion object { + private const val RSA = "RSA" + private const val AES = "AES" + private const val KEY_STORE_PROVIDER = "AndroidKeyStore" + private const val TRANSFORMATION = "RSA/ECB/PKCS1Padding" + private const val KEY_ALIAS = "key_alias" + private const val BLOCK_SIZE = 16 + private const val AES_KEY_LENGTH = 256 + private var second = false + + private var privateKey: PrivateKey? = null + private var publicKey: PublicKey? = null + + private const val SECRET_KEY = "secret_key" + private val secureRandom = SecureRandom() + private var keyStore: KeyStore? = null + } + + init { + initKeystore() + } + + fun getPrerenceAesKey(): Key { + val secretKey: SecretKey + val encryptedKey = context.getSharedPreferences(KEY_ALIAS, Context.MODE_PRIVATE).getString(SECRET_KEY, "") + if (encryptedKey!!.isEmpty()) { + val keyGenerator = KeyGenerator.getInstance(AES) + keyGenerator.init(AES_KEY_LENGTH, secureRandom) + secretKey = keyGenerator.generateKey() + context.getSharedPreferences(KEY_ALIAS, Context.MODE_PRIVATE).edit().putString(SECRET_KEY, encryptRsa(secretKey.encoded)).apply() + } else { + val key = decryptRsa(encryptedKey) + secretKey = SecretKeySpec(key, 0, key!!.size, AES) + } + return secretKey + } + + private fun initKeystore() { + try { + keyStore = KeyStore.getInstance(KEY_STORE_PROVIDER) + keyStore!!.load(null) + + if (keyStore!!.getKey(KEY_ALIAS, null) == null) { + createKeys() + } + + privateKey = keyStore!!.getKey(KEY_ALIAS, null) as PrivateKey + publicKey = keyStore!!.getCertificate(KEY_ALIAS).publicKey + } catch (e: Exception) { + if (!second) { + second = true + initKeystore() + } + e.printStackTrace() + } + } + + private fun createKeys() { + val startDate = Calendar.getInstance() + val endDate = Calendar.getInstance() + endDate.add(Calendar.YEAR, 25) + + val spec: AlgorithmParameterSpec + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + spec = KeyPairGeneratorSpec.Builder(context) + .setAlias(KEY_ALIAS) + .setSubject(X500Principal("CN=Sora")) + .setSerialNumber(BigInteger.ONE) + .setStartDate(startDate.time) + .setEndDate(endDate.time) + .build() + } else { + spec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setCertificateSubject(X500Principal("CN=Sora")) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1) + .setCertificateSerialNumber(BigInteger.ONE) + .setCertificateNotBefore(startDate.time) + .setCertificateNotAfter(endDate.time) + .build() + } + val keyPairGenerator = KeyPairGenerator.getInstance(RSA, KEY_STORE_PROVIDER) + keyPairGenerator.initialize(spec) + keyPairGenerator.generateKeyPair() + } + + fun encrypt(cleartext: String?): String { + if (cleartext != null && cleartext.isNotEmpty()) { + try { + return encrypt(getPrerenceAesKey().encoded, cleartext) + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + } + } + return "" + } + + fun encrypt(key: ByteArray, cleartext: String): String { + try { + val result = encrypt(key, cleartext.toByteArray()) + return Base64.toBase64String(result) + } catch (e: Exception) { + e.printStackTrace() + } + return "" + } + + fun decrypt(encryptedBase64: String): String { + try { + return decrypt(getPrerenceAesKey().encoded, encryptedBase64) + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + } + + return "" + } + + fun decrypt(key: ByteArray, encryptedBase64: String): String { + try { + val encrypted = Base64.decode(encryptedBase64) + val result = decrypt(key, encrypted) + return String(result) + } catch (e: Exception) { + e.printStackTrace() + } + + return "" + } + + @Throws(Exception::class) + private fun encrypt(key: ByteArray, clear: ByteArray): ByteArray { + val skeySpec = SecretKeySpec(key, "AES") + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, skeySpec, IvParameterSpec(generateIVBytes()), secureRandom) + return Arrays.concatenate(cipher.iv, cipher.doFinal(clear)) + } + + @Throws( + NoSuchPaddingException::class, + NoSuchAlgorithmException::class, + InvalidAlgorithmParameterException::class, + InvalidKeyException::class, + BadPaddingException::class, + IllegalBlockSizeException::class + ) + private fun decrypt(key: ByteArray, encrypted: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(key, "AES"), + IvParameterSpec(Arrays.copyOfRange(encrypted, 0, BLOCK_SIZE)), + secureRandom + ) + return cipher.doFinal(Arrays.copyOfRange(encrypted, BLOCK_SIZE, encrypted.size)) + } + + private fun encryptRsa(input: ByteArray): String { + val cipher: Cipher + + try { + cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + return Base64.toBase64String(cipher.doFinal(input)) + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + } catch (e: NoSuchPaddingException) { + e.printStackTrace() + } catch (e: InvalidKeyException) { + e.printStackTrace() + } catch (e: BadPaddingException) { + e.printStackTrace() + } catch (e: IllegalBlockSizeException) { + e.printStackTrace() + } + + return "" + } + + private fun decryptRsa(encrypted: String): ByteArray? { + val cipher: Cipher + + try { + cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, privateKey) + return cipher.doFinal(Base64.decode(encrypted)) + } catch (e: NoSuchAlgorithmException) { + e.printStackTrace() + } catch (e: NoSuchPaddingException) { + e.printStackTrace() + } catch (e: InvalidKeyException) { + e.printStackTrace() + } catch (e: BadPaddingException) { + e.printStackTrace() + } catch (e: IllegalBlockSizeException) { + e.printStackTrace() + } + + return null + } + + private fun generateIVBytes(): ByteArray { + val ivBytes = ByteArray(BLOCK_SIZE) + secureRandom.nextBytes(ivBytes) + return ivBytes + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt b/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt new file mode 100644 index 0000000..60d5d0f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/CommonApi.kt @@ -0,0 +1,255 @@ +package io.novafoundation.nova.common.di + +import android.content.ContentResolver +import android.content.Context +import android.content.SharedPreferences +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.address.format.EthereumAddressFormat +import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider +import io.novafoundation.nova.common.data.config.GlobalConfigDataSource +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.data.network.rpc.SocketSingleRequestExecutor +import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository +import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository +import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository +import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1 +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.di.modules.Caching +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.interfaces.BuildTypeProvider +import io.novafoundation.nova.common.interfaces.FileCache +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.NetworkStateMixin +import io.novafoundation.nova.common.mixin.condition.ConditionMixinFactory +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.LanguagesHolder +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.SafeModeService +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationExecutor +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService +import io.novafoundation.nova.common.utils.CopyValueMixin +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.IntegrityService +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.common.utils.ToastMessageManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.ip.IpAddressReceiver +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.utils.network.DeviceNetworkStateObserver +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import io.novafoundation.nova.common.utils.splash.SplashPassedObserver +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClientFactory +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.vibration.DeviceVibrator +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.common.view.input.chooser.ListChooserMixin +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.common.view.parallaxCard.BackingParallaxCardLruCache +import io.novasama.substrate_sdk_android.encrypt.Signer +import io.novasama.substrate_sdk_android.icon.IconGenerator +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.logging.Logger +import okhttp3.OkHttpClient +import java.util.Random + +interface CommonApi { + + val maskableValueFormatterFactory: MaskableValueFormatterFactory + + val amountFormatterProvider: MaskableValueFormatterProvider + + val maskingModeUseCase: MaskingModeUseCase + + val systemCallExecutor: SystemCallExecutor + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val resourcesHintsMixinFactory: ResourcesHintsMixinFactory + + val okHttpClient: OkHttpClient + + val fileCache: FileCache + + val permissionsAskerFactory: PermissionsAskerFactory + + val bluetoothManager: BluetoothManager + + val locationManager: LocationManager + + val listChooserMixinFactory: ListChooserMixin.Factory + + val partialRetriableMixinFactory: PartialRetriableMixin.Factory + + val automaticInteractionGate: AutomaticInteractionGate + + val bannerVisibilityRepository: BannerVisibilityRepository + + val provideActivityIntentProvider: ActivityIntentProvider + + val googleApiAvailabilityProvider: GoogleApiAvailabilityProvider + + val coinGeckoLinkParser: CoinGeckoLinkParser + + val webViewPermissionAskerFactory: WebViewPermissionAskerFactory + + val webViewFileChooserFactory: WebViewFileChooserFactory + + val interceptingWebViewClientFactory: InterceptingWebViewClientFactory + + val addressSchemeFormatter: AddressSchemeFormatter + + val splashPassedObserver: SplashPassedObserver + + val integrityService: IntegrityService + + val toggleFeatureRepository: ToggleFeatureRepository + + val ipAddressReceiver: IpAddressReceiver + + val actionBottomSheetLauncher: ActionBottomSheetLauncher + + val deviceNetworkStateObserver: DeviceNetworkStateObserver + + val deviceIdProvider: DeviceIdProvider + + fun copyTextMixin(): CopyTextLauncher.Presentation + + fun computationalCache(): ComputationalCache + + fun imageLoader(): ImageLoader + + fun context(): Context + + fun provideResourceManager(): ResourceManager + + fun provideNetworkApiCreator(): NetworkApiCreator + + fun provideAppLinksProvider(): AppLinksProvider + + fun providePreferences(): Preferences + + fun backgroundAccessObserver(): BackgroundAccessObserver + + fun provideEncryptedPreferences(): EncryptedPreferences + + fun provideIconGenerator(): IconGenerator + + fun provideClipboardManager(): ClipboardManager + + fun provideDeviceVibrator(): DeviceVibrator + + fun signer(): Signer + + fun logger(): Logger + + fun contextManager(): ContextManager + + fun languagesHolder(): LanguagesHolder + + fun provideJsonMapper(): Gson + + fun socketServiceCreator(): SocketService + + fun provideSocketSingleRequestExecutor(): SocketSingleRequestExecutor + + fun addressIconGenerator(): AddressIconGenerator + + @Caching + fun cachingAddressIconGenerator(): AddressIconGenerator + + fun networkStateMixin(): NetworkStateMixin + + fun qrCodeGenerator(): QrCodeGenerator + + fun fileProvider(): FileProvider + + fun random(): Random + + fun contentResolver(): ContentResolver + + fun httpExceptionHandler(): HttpExceptionHandler + + fun validationExecutor(): ValidationExecutor + + fun secretStoreV1(): SecretStoreV1 + + fun secretStoreV2(): SecretStoreV2 + + fun customDialogDisplayer(): CustomDialogDisplayer.Presentation + + fun appVersionsProvider(): AppVersionProvider + + fun ethereumAddressFormat(): EthereumAddressFormat + + fun sharedPreferences(): SharedPreferences + + fun safeModeService(): SafeModeService + + fun twoFactorVerificationService(): TwoFactorVerificationService + + fun twoFactorVerificationExecutor(): TwoFactorVerificationExecutor + + fun rootScope(): RootScope + + fun bakingParallaxCardCache(): BackingParallaxCardLruCache + + fun descriptionBottomSheetLauncher(): DescriptionBottomSheetLauncher + + fun provideActionBottomSheetLauncherFactory(): ActionBottomSheetLauncherFactory + + fun progressDialogMixinFactory(): ProgressDialogMixinFactory + + fun provideListSelectorMixinFactory(): ListSelectorMixin.Factory + + fun provideConditionMixinFactory(): ConditionMixinFactory + + fun buildTypeProvider(): BuildTypeProvider + + fun assetsViewModeRepository(): AssetsViewModeRepository + + fun assetsIconModeService(): AssetsIconModeRepository + + fun assetIconProvider(): AssetIconProvider + + fun assetViewModeInteractor(): AssetViewModeInteractor + + fun toastMessageManager(): ToastMessageManager + + fun dialogMessageManager(): DialogMessageManager + + fun copyValueMixin(): CopyValueMixin + + fun globalConfigDataSource(): GlobalConfigDataSource +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/FeatureApiHolder.kt b/common/src/main/java/io/novafoundation/nova/common/di/FeatureApiHolder.kt new file mode 100644 index 0000000..e30a7bf --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/FeatureApiHolder.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.common.di + +import java.util.concurrent.locks.ReentrantLock + +abstract class FeatureApiHolder( + private val mFeatureContainer: FeatureContainer +) { + private val mFeatureLocker = ReentrantLock() + + private var mFeatureApi: Any? = null + + fun getFeatureApi(): T { + mFeatureLocker.lock() + if (mFeatureApi == null) { + mFeatureApi = initializeDependencies() + } + mFeatureLocker.unlock() + return mFeatureApi as T + } + + fun releaseFeatureApi() { + mFeatureLocker.lock() + mFeatureApi = null + destroyDependencies() + mFeatureLocker.unlock() + } + + fun commonApi(): CommonApi { + return mFeatureContainer.commonApi() + } + + protected fun getFeature(key: Class): T { + return mFeatureContainer.getFeature(key) ?: throw RuntimeException() + } + + protected fun releaseFeature(key: Class<*>) { + mFeatureContainer.releaseFeature(key) + } + + protected abstract fun initializeDependencies(): Any + + protected fun destroyDependencies() { + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/FeatureContainer.kt b/common/src/main/java/io/novafoundation/nova/common/di/FeatureContainer.kt new file mode 100644 index 0000000..20eb4e0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/FeatureContainer.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.di + +interface FeatureContainer { + + fun getFeature(key: Class<*>): T + + fun releaseFeature(key: Class<*>) + + fun commonApi(): CommonApi +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/FeatureUtils.kt b/common/src/main/java/io/novafoundation/nova/common/di/FeatureUtils.kt new file mode 100644 index 0000000..f08b152 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/FeatureUtils.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.di + +import android.app.Activity +import android.content.Context +import androidx.fragment.app.Fragment + +object FeatureUtils { + + fun getFeature(context: Context, key: Class<*>): T { + return getHolder(context).getFeature(key) + } + + fun getCommonApi(context: Context): CommonApi { + return getHolder(context).commonApi() + } + + fun getFeature(activity: Activity, key: Class<*>): T { + return getHolder(activity.applicationContext).getFeature(key) + } + + fun getFeature(fragment: Fragment, key: Class<*>): T { + return getHolder(fragment.context!!).getFeature(key) + } + + fun releaseFeature(context: Context, key: Class<*>) { + getHolder(context).releaseFeature(key) + } + + fun releaseFeature(context: Activity, key: Class<*>) { + getHolder(context.applicationContext).releaseFeature(key) + } + + fun releaseFeature(fragment: Fragment, key: Class<*>) { + getHolder(fragment.context!!).releaseFeature(key) + } + + private fun getHolder(context: Context): FeatureContainer { + return context.applicationContext as FeatureContainer + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonBindsModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonBindsModule.kt new file mode 100644 index 0000000..6551275 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonBindsModule.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.di.modules + +import dagger.Binds +import dagger.Module +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.address.format.RealAddressSchemeFormatter + +@Module +internal interface CommonBindsModule { + + @Binds + fun bindAddressSchemeFormatter(real: RealAddressSchemeFormatter): AddressSchemeFormatter +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt new file mode 100644 index 0000000..083b72b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt @@ -0,0 +1,509 @@ +package io.novafoundation.nova.common.di.modules + +import android.content.ContentResolver +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Color +import coil.ImageLoader +import coil.decode.SvgDecoder +import com.google.android.play.core.integrity.IntegrityManagerFactory +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.BuildConfig +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.CachingAddressIconGenerator +import io.novafoundation.nova.common.address.StatelessAddressIconGenerator +import io.novafoundation.nova.common.address.format.EthereumAddressFormat +import io.novafoundation.nova.common.data.FileProviderImpl +import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider +import io.novafoundation.nova.common.data.RealGoogleApiAvailabilityProvider +import io.novafoundation.nova.common.data.config.GlobalConfigApi +import io.novafoundation.nova.common.data.config.GlobalConfigDataSource +import io.novafoundation.nova.common.data.config.RealGlobalConfigDataSource +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.memory.RealComputationalCache +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.data.providers.deviceid.AndroidDeviceIdProvider +import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository +import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository +import io.novafoundation.nova.common.data.repository.RealAssetsIconModeRepository +import io.novafoundation.nova.common.data.repository.RealAssetsViewModeRepository +import io.novafoundation.nova.common.data.repository.RealBannerVisibilityRepository +import io.novafoundation.nova.common.data.repository.RealToggleFeatureRepository +import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository +import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1 +import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1Impl +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.PreferencesImpl +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferencesImpl +import io.novafoundation.nova.common.data.storage.encrypt.EncryptionUtil +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import io.novafoundation.nova.common.domain.interactor.RealAssetViewModeInteractor +import io.novafoundation.nova.common.domain.usecase.RealMaskingModeUseCase +import io.novafoundation.nova.common.interfaces.FileCache +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.interfaces.InternalFileSystemCache +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableProvider +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.condition.ConditionMixinFactory +import io.novafoundation.nova.common.mixin.condition.RealConditionMixinFactory +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher +import io.novafoundation.nova.common.mixin.copy.RealCopyTextLauncher +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.mixin.impl.CustomDialogProvider +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.RealAssetIconProvider +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.LanguagesHolder +import io.novafoundation.nova.common.resources.OSAppVersionProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.resources.ResourceManagerImpl +import io.novafoundation.nova.common.sequrity.RealSafeModeService +import io.novafoundation.nova.common.sequrity.RealTwoFactorVerificationService +import io.novafoundation.nova.common.sequrity.SafeModeService +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationExecutor +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationCommunicator +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationExecutor +import io.novafoundation.nova.common.utils.CopyValueMixin +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.IntegrityService +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.common.utils.RealCopyValueMixin +import io.novafoundation.nova.common.utils.RealDialogMessageManager +import io.novafoundation.nova.common.utils.RealToastMessageManager +import io.novafoundation.nova.common.utils.ToastMessageManager +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.ip.IpAddressReceiver +import io.novafoundation.nova.common.utils.ip.PublicIpAddressReceiver +import io.novafoundation.nova.common.utils.ip.PublicIpReceiverApi +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.utils.multiResult.RealPartialRetriableMixinFactory +import io.novafoundation.nova.common.utils.network.DeviceNetworkStateObserver +import io.novafoundation.nova.common.utils.network.RealDeviceNetworkStateObserver +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import io.novafoundation.nova.common.utils.sequrity.RealAutomaticInteractionGate +import io.novafoundation.nova.common.utils.splash.RealSplashPassedObserver +import io.novafoundation.nova.common.utils.splash.SplashPassedObserver +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.vibration.DeviceVibrator +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.bottomSheet.action.RealActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.description.RealDescriptionBottomSheetLauncher +import io.novafoundation.nova.common.view.input.chooser.ListChooserMixin +import io.novafoundation.nova.common.view.input.chooser.RealListChooserMixinFactory +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.common.view.input.selector.RealListSelectorMixinFactory +import io.novasama.substrate_sdk_android.encrypt.Signer +import io.novasama.substrate_sdk_android.icon.IconGenerator +import java.security.SecureRandom +import java.util.Random +import javax.inject.Qualifier + +const val SHARED_PREFERENCES_FILE = "fearless_prefs" + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Caching + +@Module(includes = [ParallaxCardModule::class, WebViewModule::class, CommonBindsModule::class]) +class CommonModule { + + @Provides + @ApplicationScope + fun provideGoogleApiAvailabilityProvider( + context: Context + ): GoogleApiAvailabilityProvider { + return RealGoogleApiAvailabilityProvider(context) + } + + @Provides + @ApplicationScope + fun provideComputationalCache(): ComputationalCache = RealComputationalCache() + + @Provides + @ApplicationScope + fun imageLoader(context: Context) = ImageLoader.Builder(context) + .componentRegistry { + add(SvgDecoder(context)) + } + .build() + + @Provides + @ApplicationScope + fun provideResourceManager(contextManager: ContextManager): ResourceManager { + return ResourceManagerImpl(contextManager) + } + + @Provides + @ApplicationScope + fun provideSharedPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(SHARED_PREFERENCES_FILE, Context.MODE_PRIVATE) + } + + @Provides + @ApplicationScope + fun providePreferences(sharedPreferences: SharedPreferences): Preferences { + return PreferencesImpl(sharedPreferences) + } + + @Provides + @ApplicationScope + fun provideInteractionGate(): AutomaticInteractionGate = RealAutomaticInteractionGate() + + @Provides + @ApplicationScope + fun provideBackgroundAccessObserver( + preferences: Preferences, + automaticInteractionGate: AutomaticInteractionGate + ): BackgroundAccessObserver { + return BackgroundAccessObserver(preferences, automaticInteractionGate) + } + + @Provides + @ApplicationScope + fun provideSplashPassedObserver(): SplashPassedObserver = RealSplashPassedObserver() + + @Provides + @ApplicationScope + fun provideEncryptionUtil(context: Context): EncryptionUtil { + return EncryptionUtil(context) + } + + @Provides + @ApplicationScope + fun provideEncryptedPreferences( + preferences: Preferences, + encryptionUtil: EncryptionUtil, + ): EncryptedPreferences { + return EncryptedPreferencesImpl(preferences, encryptionUtil) + } + + @Provides + @ApplicationScope + fun provideSigner(): Signer { + return Signer + } + + @Provides + @ApplicationScope + fun provideIconGenerator(): IconGenerator { + return IconGenerator() + } + + @Provides + @ApplicationScope + fun provideClipboardManager(context: Context): ClipboardManager { + return ClipboardManager(context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager) + } + + @Provides + @ApplicationScope + fun provideDeviceVibrator(context: Context): DeviceVibrator { + return DeviceVibrator(context) + } + + @Provides + @ApplicationScope + fun provideLanguagesHolder(): LanguagesHolder { + return LanguagesHolder() + } + + @Provides + @ApplicationScope + fun provideAddressModelCreator( + resourceManager: ResourceManager, + iconGenerator: IconGenerator, + ): AddressIconGenerator = StatelessAddressIconGenerator(iconGenerator, resourceManager) + + @Provides + @Caching + fun provideCachingAddressModelCreator( + delegate: AddressIconGenerator, + ): AddressIconGenerator = CachingAddressIconGenerator(delegate) + + @Provides + @ApplicationScope + fun provideQrCodeGenerator(): QrCodeGenerator { + return QrCodeGenerator(Color.BLACK, Color.WHITE) + } + + @Provides + @ApplicationScope + fun provideFileProvider(contextManager: ContextManager): FileProvider { + return FileProviderImpl(contextManager.getApplicationContext()) + } + + @Provides + @ApplicationScope + fun provideRandom(): Random = SecureRandom() + + @Provides + @ApplicationScope + fun provideContentResolver( + context: Context, + ): ContentResolver { + return context.contentResolver + } + + @Provides + @ApplicationScope + fun provideValidationExecutor(): ValidationExecutor { + return ValidationExecutor() + } + + @Provides + @ApplicationScope + fun provideSecretStoreV1( + encryptedPreferences: EncryptedPreferences, + ): SecretStoreV1 = SecretStoreV1Impl(encryptedPreferences) + + @Provides + @ApplicationScope + fun provideSecretStoreV2( + encryptedPreferences: EncryptedPreferences, + ) = SecretStoreV2(encryptedPreferences) + + @Provides + @ApplicationScope + fun provideCustomDialogDisplayer(): CustomDialogDisplayer.Presentation = CustomDialogProvider() + + @Provides + @ApplicationScope + fun provideAppVersionsProvider(context: Context): AppVersionProvider { + return OSAppVersionProvider(context) + } + + @Provides + @ApplicationScope + fun provideSystemCallExecutor( + contextManager: ContextManager + ): SystemCallExecutor = SystemCallExecutor(contextManager) + + @Provides + @ApplicationScope + fun actionAwaitableMixinFactory(): ActionAwaitableMixin.Factory = ActionAwaitableProvider + + @Provides + @ApplicationScope + fun resourcesHintsMixinFactory( + resourceManager: ResourceManager, + ) = ResourcesHintsMixinFactory(resourceManager) + + @Provides + @ApplicationScope + fun provideFileCache(fileProvider: FileProvider): FileCache = InternalFileSystemCache(fileProvider) + + @Provides + @ApplicationScope + fun providePermissionAskerFactory( + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + ) = PermissionsAskerFactory(actionAwaitableMixinFactory) + + @Provides + @ApplicationScope + fun provideEthereumAddressFormat() = EthereumAddressFormat() + + @Provides + @ApplicationScope + fun provideListChooserMixinFactory( + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + ): ListChooserMixin.Factory = RealListChooserMixinFactory(actionAwaitableMixinFactory) + + @Provides + @ApplicationScope + fun provideSafeModeService( + contextManager: ContextManager, + preferences: Preferences + ): SafeModeService { + return RealSafeModeService(contextManager, preferences) + } + + @Provides + @ApplicationScope + fun providePartialRetriableMixinFactory( + resourceManager: ResourceManager + ): PartialRetriableMixin.Factory = RealPartialRetriableMixinFactory(resourceManager) + + @Provides + @ApplicationScope + fun provideTwoFactorVerificationExecutor( + twoFactorVerificationExecutor: PinCodeTwoFactorVerificationCommunicator + ): TwoFactorVerificationExecutor = PinCodeTwoFactorVerificationExecutor(twoFactorVerificationExecutor) + + @Provides + @ApplicationScope + fun provideTwoFactorVerificationService( + preferences: Preferences, + twoFactorVerificationExecutor: TwoFactorVerificationExecutor + ): TwoFactorVerificationService = RealTwoFactorVerificationService(preferences, twoFactorVerificationExecutor) + + @Provides + @ApplicationScope + fun provideBannerVisibilityRepository( + preferences: Preferences + ): BannerVisibilityRepository = RealBannerVisibilityRepository(preferences) + + @Provides + @ApplicationScope + fun provideDescriptionBottomSheetLauncher(): DescriptionBottomSheetLauncher = RealDescriptionBottomSheetLauncher() + + @Provides + @ApplicationScope + fun provideProgressDialogMixinFactory(): ProgressDialogMixinFactory = ProgressDialogMixinFactory() + + @Provides + @ApplicationScope + fun provideActionBottomSheetLauncher(): ActionBottomSheetLauncherFactory = RealActionBottomSheetLauncherFactory() + + @Provides + @ApplicationScope + fun provideListSelectorMixinFactory(): ListSelectorMixin.Factory = RealListSelectorMixinFactory() + + @Provides + @ApplicationScope + fun provideConditionMixinFactory(resourceManager: ResourceManager): ConditionMixinFactory { + return RealConditionMixinFactory(resourceManager) + } + + @Provides + @ApplicationScope + fun provideCoinGeckoLinkParser(): CoinGeckoLinkParser { + return CoinGeckoLinkParser() + } + + @Provides + @ApplicationScope + fun provideAssetsViewModeRepository(preferences: Preferences): AssetsViewModeRepository = RealAssetsViewModeRepository(preferences) + + @Provides + @ApplicationScope + fun provideAssetViewModeInteractor(repository: AssetsViewModeRepository): AssetViewModeInteractor { + return RealAssetViewModeInteractor(repository) + } + + @Provides + @ApplicationScope + fun provideAssetsIconModeRepository(preferences: Preferences): AssetsIconModeRepository = RealAssetsIconModeRepository(preferences) + + @Provides + @ApplicationScope + fun provideAssetIconProvider(repository: AssetsIconModeRepository): AssetIconProvider { + return RealAssetIconProvider( + repository, + BuildConfig.ASSET_COLORED_ICON_URL, + BuildConfig.ASSET_WHITE_ICON_URL, + ) + } + + @Provides + @ApplicationScope + fun provideToastMessageManager(): ToastMessageManager { + return RealToastMessageManager() + } + + @Provides + @ApplicationScope + fun provideDialogMessageManager(): DialogMessageManager { + return RealDialogMessageManager() + } + + @Provides + @ApplicationScope + fun provideIntegrityService(context: Context, rootScope: RootScope): IntegrityService { + val integrityManager = IntegrityManagerFactory.createStandard(context) + return IntegrityService(BuildConfig.CLOUD_PROJECT_NUMBER, integrityManager, rootScope) + } + + @Provides + @ApplicationScope + fun provideCopyValueMixin( + clipboardManager: ClipboardManager, + toastMessageManager: ToastMessageManager, + resourceManager: ResourceManager + ): CopyValueMixin = RealCopyValueMixin( + clipboardManager, + toastMessageManager, + resourceManager + ) + + @Provides + @ApplicationScope + fun provideToggleFeatureRepository(preferences: Preferences): ToggleFeatureRepository = RealToggleFeatureRepository(preferences) + + @Provides + @ApplicationScope + fun provideCopyTextMixin(): CopyTextLauncher.Presentation = RealCopyTextLauncher() + + @Provides + @ApplicationScope + fun provideIpReceiver( + networkApiCreator: NetworkApiCreator + ): IpAddressReceiver = PublicIpAddressReceiver(networkApiCreator.create(PublicIpReceiverApi::class.java)) + + @Provides + @ApplicationScope + fun actionBottomSheetLauncher( + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory + ): ActionBottomSheetLauncher = actionBottomSheetLauncherFactory.create() + + @Provides + @ApplicationScope + fun maskingModeUseCase(toggleFeatureRepository: ToggleFeatureRepository): MaskingModeUseCase { + return RealMaskingModeUseCase(toggleFeatureRepository) + } + + @Provides + @ApplicationScope + fun provideMaskableAmountFormatterFactory(): MaskableValueFormatterFactory { + return MaskableValueFormatterFactory() + } + + @Provides + @ApplicationScope + fun provideMaskableAmountFormatterProvider( + maskableValueFormatterFactory: MaskableValueFormatterFactory, + maskingModeUseCase: MaskingModeUseCase + ): MaskableValueFormatterProvider { + return MaskableValueFormatterProvider(maskableValueFormatterFactory, maskingModeUseCase) + } + + @Provides + @ApplicationScope + fun provideGlobalConfigDataSource( + networkApiCreator: NetworkApiCreator + ): GlobalConfigDataSource { + val api = networkApiCreator.create(GlobalConfigApi::class.java) + return RealGlobalConfigDataSource(api) + } + + @Provides + @ApplicationScope + fun provideDeviceNetworkManager(context: Context): DeviceNetworkStateObserver { + return RealDeviceNetworkStateObserver(context) + } + + @Provides + @ApplicationScope + fun provideDeviceIdProvider(context: Context): DeviceIdProvider { + return AndroidDeviceIdProvider(context) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/NetworkModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/NetworkModule.kt new file mode 100644 index 0000000..4896f02 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/NetworkModule.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.common.di.modules + +import android.content.Context +import com.google.gson.Gson +import com.neovisionaries.ws.client.WebSocketFactory +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.BuildConfig +import io.novafoundation.nova.common.data.network.AndroidLogger +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.network.TimeHeaderInterceptor +import io.novafoundation.nova.common.data.network.rpc.SocketSingleRequestExecutor +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.mixin.api.NetworkStateMixin +import io.novafoundation.nova.common.mixin.impl.NetworkStateProvider +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.bluetooth.RealBluetoothManager +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.location.RealLocationManager +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.logging.Logger +import io.novasama.substrate_sdk_android.wsrpc.recovery.Reconnector +import io.novasama.substrate_sdk_android.wsrpc.request.RequestExecutor +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import java.io.File +import java.util.concurrent.TimeUnit + +private const val HTTP_CACHE = "http_cache" +private const val CACHE_SIZE = 50L * 1024L * 1024L // 50 MiB +private const val TIMEOUT_SECONDS = 20L + +private const val SOCKET_CONNECTION_TIMEOUT = 5_000 + +@Module +class NetworkModule { + + @Provides + @ApplicationScope + fun provideAppLinksProvider(): AppLinksProvider { + return AppLinksProvider( + termsUrl = BuildConfig.TERMS_URL, + privacyUrl = BuildConfig.PRIVACY_URL, + payoutsLearnMore = BuildConfig.PAYOUTS_LEARN_MORE, + twitterAccountTemplate = BuildConfig.TWITTER_ACCOUNT_TEMPLATE, + setControllerLearnMore = BuildConfig.SET_CONTROLLER_LEARN_MORE, + setControllerDeprecatedLeanMore = BuildConfig.SET_CONTROLLER_DEPRECATED_LEARN_MORE, + recommendedValidatorsLearnMore = BuildConfig.RECOMMENDED_VALIDATORS_LEARN_MORE, + paritySignerTroubleShooting = BuildConfig.PARITY_SIGNER_TROUBLESHOOTING, + polkadotVaultTroubleShooting = BuildConfig.POLKADOT_VAULT_TROUBLESHOOTING, + ledgerConnectionGuide = BuildConfig.LEDGER_CONNECTION_GUIDE, + telegram = BuildConfig.TELEGRAM_URL, + twitter = BuildConfig.TWITTER_URL, + rateApp = BuildConfig.RATE_URL, + website = BuildConfig.WEBSITE_URL, + wikiBase = BuildConfig.PEZKUWI_WALLET_WIKI_BASE, + wikiProxy = BuildConfig.PEZKUWI_WALLET_WIKI_PROXY, + integrateNetwork = BuildConfig.PEZKUWI_WALLET_WIKI_INTEGRATE_NETWORK, + github = BuildConfig.GITHUB_URL, + email = BuildConfig.EMAIL, + youtube = BuildConfig.YOUTUBE_URL, + storeUrl = BuildConfig.APP_UPDATE_SOURCE_LINK, + ledgerMigrationArticle = BuildConfig.LEDGER_MIGRATION_ARTICLE, + pezkuwiCardWidgetUrl = BuildConfig.PEZKUWI_CARD_WIDGET_URL, + unifiedAddressArticle = BuildConfig.UNIFIED_ADDRESS_ARTICLE, + multisigsWikiUrl = BuildConfig.MULTISIGS_WIKI_URL, + giftsWikiUrl = BuildConfig.GIFTS_WIKI_URL + ) + } + + @Provides + @ApplicationScope + fun provideOkHttpClient( + context: Context + ): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .cache(Cache(File(context.cacheDir, HTTP_CACHE), CACHE_SIZE)) + .retryOnConnectionFailure(true) + .addInterceptor(TimeHeaderInterceptor()) + + if (BuildConfig.DEBUG) { + builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + } + + return builder.build() + } + + @Provides + @ApplicationScope + fun provideLogger(): Logger = AndroidLogger(debug = BuildConfig.DEBUG) + + @Provides + @ApplicationScope + fun provideApiCreator( + okHttpClient: OkHttpClient + ): NetworkApiCreator { + return NetworkApiCreator(okHttpClient, "https://placeholder.com") + } + + @Provides + @ApplicationScope + fun httpExceptionHandler( + resourceManager: ResourceManager + ): HttpExceptionHandler = HttpExceptionHandler(resourceManager) + + @Provides + @ApplicationScope + fun provideSocketFactory() = WebSocketFactory().apply { + connectionTimeout = SOCKET_CONNECTION_TIMEOUT + } + + @Provides + fun provideReconnector() = Reconnector() + + @Provides + fun provideRequestExecutor() = RequestExecutor() + + @Provides + fun provideSocketService( + mapper: Gson, + socketFactory: WebSocketFactory, + logger: Logger, + reconnector: Reconnector, + requestExecutor: RequestExecutor + ): SocketService = SocketService(mapper, logger, socketFactory, reconnector, requestExecutor) + + @Provides + @ApplicationScope + fun provideSocketSingleRequestExecutor( + mapper: Gson, + logger: Logger, + socketFactory: WebSocketFactory, + resourceManager: ResourceManager + ) = SocketSingleRequestExecutor(mapper, logger, socketFactory, resourceManager) + + @Provides + fun provideNetworkStateMixin(): NetworkStateMixin = NetworkStateProvider() + + @Provides + @ApplicationScope + fun provideJsonMapper() = Gson() + + @Provides + @ApplicationScope + fun provideBluetoothManager( + contextManager: ContextManager, + systemCallExecutor: SystemCallExecutor + ): BluetoothManager = RealBluetoothManager(contextManager, systemCallExecutor) + + @Provides + @ApplicationScope + fun provideLocationManager( + contextManager: ContextManager + ): LocationManager = RealLocationManager(contextManager) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/ParallaxCardModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/ParallaxCardModule.kt new file mode 100644 index 0000000..fcc1c9c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/ParallaxCardModule.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.view.parallaxCard.BackingParallaxCardLruCache + +@Module() +class ParallaxCardModule { + + @Provides + @ApplicationScope + fun provideBackingParallaxCardLruCache() = BackingParallaxCardLruCache(8) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/WebViewModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/WebViewModule.kt new file mode 100644 index 0000000..2a4fb35 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/WebViewModule.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.utils.systemCall.WebViewFilePickerSystemCallFactory +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClientFactory + +@Module +class WebViewModule { + + @Provides + @ApplicationScope + fun provideWebViewPermissionAskerFactory( + permissionsAsker: PermissionsAskerFactory + ) = WebViewPermissionAskerFactory(permissionsAsker) + + @Provides + @ApplicationScope + fun provideWebViewFilePickerSystemCallFactory() = WebViewFilePickerSystemCallFactory() + + @Provides + @ApplicationScope + fun provideWebViewFileChooserFactory( + systemCallExecutor: SystemCallExecutor, + webViewFilePickerSystemCallFactory: WebViewFilePickerSystemCallFactory + ) = WebViewFileChooserFactory(systemCallExecutor, webViewFilePickerSystemCallFactory) + + @Provides + @ApplicationScope + fun provideInterceptingWebViewClientFactory() = InterceptingWebViewClientFactory() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/shared/MarkdownFullModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/shared/MarkdownFullModule.kt new file mode 100644 index 0000000..021e90d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/shared/MarkdownFullModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.di.modules.shared + +import android.content.Context +import android.text.util.Linkify +import coil.ImageLoader +import dagger.Module +import dagger.Provides +import io.noties.markwon.Markwon +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin +import io.noties.markwon.ext.tables.TablePlugin +import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.image.coil.CoilImagesPlugin +import io.noties.markwon.linkify.LinkifyPlugin +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.utils.markdown.LinkStylePlugin + +@Module +class MarkdownFullModule { + + @Provides + @ScreenScope + fun provideMarkwon(context: Context, imageLoader: ImageLoader): Markwon { + return Markwon.builder(context) + .usePlugin(LinkifyPlugin.create(Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS)) + .usePlugin(LinkStylePlugin(context)) + .usePlugin(CoilImagesPlugin.create(context, imageLoader)) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(TablePlugin.create(context)) + .usePlugin(HtmlPlugin.create()) + .build() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/shared/MarkdownShortModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/shared/MarkdownShortModule.kt new file mode 100644 index 0000000..8b7c461 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/shared/MarkdownShortModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.di.modules.shared + +import android.content.Context +import android.text.util.Linkify +import dagger.Module +import dagger.Provides +import io.noties.markwon.Markwon +import io.noties.markwon.ext.strikethrough.StrikethroughPlugin +import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.linkify.LinkifyPlugin +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.utils.markdown.RemoveHtmlTagsPlugin +import io.novafoundation.nova.common.utils.markdown.LinkStylePlugin + +private const val IMG_HTML_TAG = "img" +private const val TABLE_HTML_TAG = "table" + +@Module +class MarkdownShortModule { + + @Provides + @ScreenScope + fun provideMarkwon(context: Context): Markwon { + return Markwon.builder(context) + .usePlugin(RemoveHtmlTagsPlugin(IMG_HTML_TAG, TABLE_HTML_TAG)) + .usePlugin(LinkifyPlugin.create(Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS)) + .usePlugin(LinkStylePlugin(context)) + .usePlugin(StrikethroughPlugin.create()) + .usePlugin(HtmlPlugin.create()) + .build() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/shared/PermissionAskerForFragmentModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/shared/PermissionAskerForFragmentModule.kt new file mode 100644 index 0000000..779d90b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/shared/PermissionAskerForFragmentModule.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.common.di.modules.shared + +import androidx.fragment.app.Fragment +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory + +@Module +class PermissionAskerForFragmentModule { + + @Provides + @ScreenScope + fun providePermissionAsker( + permissionsAskerFactory: PermissionsAskerFactory, + fragment: Fragment + ) = permissionsAskerFactory.create(fragment) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/scope/ApplicationScope.kt b/common/src/main/java/io/novafoundation/nova/common/di/scope/ApplicationScope.kt new file mode 100644 index 0000000..69fe36b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/scope/ApplicationScope.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.common.di.scope + +import javax.inject.Scope + +@Scope +annotation class ApplicationScope diff --git a/common/src/main/java/io/novafoundation/nova/common/di/scope/FeatureScope.kt b/common/src/main/java/io/novafoundation/nova/common/di/scope/FeatureScope.kt new file mode 100644 index 0000000..8848203 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/scope/FeatureScope.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.common.di.scope + +import javax.inject.Scope + +@Scope +annotation class FeatureScope diff --git a/common/src/main/java/io/novafoundation/nova/common/di/scope/ScreenScope.kt b/common/src/main/java/io/novafoundation/nova/common/di/scope/ScreenScope.kt new file mode 100644 index 0000000..2f7576b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/scope/ScreenScope.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.common.di.scope + +import javax.inject.Scope + +@Scope +annotation class ScreenScope diff --git a/common/src/main/java/io/novafoundation/nova/common/di/viewmodel/ViewModelKey.kt b/common/src/main/java/io/novafoundation/nova/common/di/viewmodel/ViewModelKey.kt new file mode 100644 index 0000000..099b5c0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/viewmodel/ViewModelKey.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.di.viewmodel + +import androidx.lifecycle.ViewModel +import dagger.MapKey +import kotlin.reflect.KClass + +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER +) +@MapKey +annotation class ViewModelKey(val value: KClass) diff --git a/common/src/main/java/io/novafoundation/nova/common/di/viewmodel/ViewModelModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/viewmodel/ViewModelModule.kt new file mode 100644 index 0000000..75f6bd6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/viewmodel/ViewModelModule.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.di.viewmodel + +import androidx.lifecycle.ViewModelProvider +import dagger.Binds +import dagger.Module + +@Module +abstract class ViewModelModule { + + @Binds + abstract fun bindViewModelFactory(factory: ViewModelProviderFactory): ViewModelProvider.Factory +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/viewmodel/ViewModelProviderFactory.kt b/common/src/main/java/io/novafoundation/nova/common/di/viewmodel/ViewModelProviderFactory.kt new file mode 100644 index 0000000..6423871 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/di/viewmodel/ViewModelProviderFactory.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.di.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import javax.inject.Inject +import javax.inject.Provider + +class ViewModelProviderFactory @Inject constructor( + private val creators: @JvmSuppressWildcards Map, Provider> +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + val found = creators.entries.find { modelClass.isAssignableFrom(it.key) } + val creator = found?.value + ?: throw IllegalArgumentException("unknown model class $modelClass") + try { + @Suppress("UNCHECKED_CAST") + return creator.get() as T + } catch (e: Exception) { + throw RuntimeException(e) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/domain/ExtendedLoadingState.kt b/common/src/main/java/io/novafoundation/nova/common/domain/ExtendedLoadingState.kt new file mode 100644 index 0000000..6229569 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/domain/ExtendedLoadingState.kt @@ -0,0 +1,139 @@ +package io.novafoundation.nova.common.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transform + +sealed class ExtendedLoadingState { + + companion object; + + object Loading : ExtendedLoadingState() + + data class Error(val exception: Throwable) : ExtendedLoadingState() + + data class Loaded(val data: T) : ExtendedLoadingState() +} + +inline fun Flow>.mapLoading(crossinline mapper: suspend (T) -> V): Flow> { + return map { loadingState -> loadingState.map { mapper(it) } } +} + +inline fun Flow>.onError(crossinline block: suspend (Throwable) -> Unit): Flow> { + return onEach { loadingState -> + if (loadingState is ExtendedLoadingState.Error) { + block(loadingState.exception) + } + } +} + +fun Flow>.filterLoaded(): Flow { + return transform { loadingState -> + if (loadingState is ExtendedLoadingState.Loaded) { + emit(loadingState.data) + } + } +} + +inline fun ExtendedLoadingState.map(mapper: (T) -> R): ExtendedLoadingState { + return when (this) { + is ExtendedLoadingState.Loading -> this + is ExtendedLoadingState.Error -> this + is ExtendedLoadingState.Loaded -> ExtendedLoadingState.Loaded(mapper(data)) + } +} + +val ExtendedLoadingState.dataOrNull: T? + get() = when (this) { + is ExtendedLoadingState.Loaded -> this.data + else -> null + } + +@get:JvmName("isErrorProp") +val ExtendedLoadingState<*>.isError: Boolean + get() = this is ExtendedLoadingState.Error + +fun ExtendedLoadingState.loadedAndEmpty(): Boolean = when (this) { + is ExtendedLoadingState.Loaded -> data == null + else -> false +} + +fun loadedNothing(): ExtendedLoadingState { + return ExtendedLoadingState.Loaded(null) +} + +fun ExtendedLoadingState<*>.isLoading(): Boolean { + return this is ExtendedLoadingState.Loading +} + +@get:JvmName("isLoadingProp") +val ExtendedLoadingState<*>.isLoading: Boolean + get() = isLoading() + +fun ExtendedLoadingState<*>.isError(): Boolean { + return this is ExtendedLoadingState.Error +} + +fun ExtendedLoadingState<*>.isLoadingOrError(): Boolean { + return isLoading() || isError() +} + +fun ExtendedLoadingState<*>.isLoaded(): Boolean { + return this is ExtendedLoadingState.Loaded +} + +suspend fun FlowCollector>.emitLoaded(value: T) { + emit(ExtendedLoadingState.Loaded(value)) +} + +suspend fun FlowCollector>.emitLoading() { + emit(ExtendedLoadingState.Loading) +} + +suspend fun FlowCollector>.emitError(throwable: Throwable) { + emit(ExtendedLoadingState.Error(throwable)) +} + +fun ExtendedLoadingState.Companion.fromOption(value: T?): ExtendedLoadingState { + return if (value != null) { + ExtendedLoadingState.Loaded(value) + } else { + ExtendedLoadingState.Loading + } +} + +fun Throwable.asLoadingError(): ExtendedLoadingState.Error = ExtendedLoadingState.Error(this) + +fun T.asLoaded(): ExtendedLoadingState.Loaded = ExtendedLoadingState.Loaded(this) + +inline fun ExtendedLoadingState.onLoaded(action: (T) -> Unit): ExtendedLoadingState { + if (this is ExtendedLoadingState.Loaded) { + action(data) + } + + return this +} + +inline fun ExtendedLoadingState.onNotLoaded(action: () -> Unit): ExtendedLoadingState { + if (this !is ExtendedLoadingState.Loaded) { + action() + } + + return this +} + +inline fun ExtendedLoadingState.onError(action: (Throwable) -> Unit): ExtendedLoadingState { + if (this is ExtendedLoadingState.Error) { + action(exception) + } + + return this +} + +fun ExtendedLoadingState?.orLoading(): ExtendedLoadingState { + if (this == null) return ExtendedLoadingState.Loading + + return this +} diff --git a/common/src/main/java/io/novafoundation/nova/common/domain/balance/CalculateBalanceUtils.kt b/common/src/main/java/io/novafoundation/nova/common/domain/balance/CalculateBalanceUtils.kt new file mode 100644 index 0000000..e1e5f33 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/domain/balance/CalculateBalanceUtils.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.common.domain.balance + +import io.novafoundation.nova.common.utils.atLeastZero +import java.math.BigInteger + +fun legacyTransferable(free: BigInteger, frozen: BigInteger): BigInteger { + return (free - frozen).atLeastZero() +} + +fun holdAndFreezesTransferable(free: BigInteger, frozen: BigInteger, reserved: BigInteger): BigInteger { + val freeCannotDropBelow = (frozen - reserved).atLeastZero() + + return (free - freeCannotDropBelow).atLeastZero() +} + +fun totalBalance(free: BigInteger, reserved: BigInteger): BigInteger { + return free + reserved +} + +// https://github.com/paritytech/polkadot-sdk/blob/b9fbf243c57939ecadc89b82ed42249703203874/substrate/frame/balances/src/impl_currency.rs#L522 +// free - amount >= max(ed, frozen) => max_amount = free - max(ed, frozen) +fun legacyReservable(free: BigInteger, frozen: BigInteger, ed: BigInteger): BigInteger { + return free - ed.max(frozen) +} + +// reducible_balance (https://github.com/paritytech/polkadot-sdk/blob/b9fbf243c57939ecadc89b82ed42249703203874/substrate/frame/balances/src/impl_fungible.rs#L47) +// is called with Force and Protect args (https://github.com/paritytech/polkadot-sdk/blob/b9fbf243c57939ecadc89b82ed42249703203874/substrate/frame/support/src/traits/tokens/fungibles/hold.rs#L101) +fun holdsAndFreezesReservable(free: BigInteger, ed: BigInteger): BigInteger { + return free - ed +} diff --git a/common/src/main/java/io/novafoundation/nova/common/domain/balance/EDCountingMode.kt b/common/src/main/java/io/novafoundation/nova/common/domain/balance/EDCountingMode.kt new file mode 100644 index 0000000..3c0939e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/domain/balance/EDCountingMode.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.common.domain.balance + +import io.novasama.substrate_sdk_android.hash.isPositive +import java.math.BigInteger + +enum class EDCountingMode { + TOTAL, FREE +} + +fun EDCountingMode.calculateBalanceCountedTowardsEd(free: BigInteger, reserved: BigInteger): BigInteger { + return when (this) { + EDCountingMode.TOTAL -> totalBalance(free, reserved) + EDCountingMode.FREE -> free + } +} + +fun EDCountingMode.reservedPreventsDusting(reserved: BigInteger): Boolean { + return when (this) { + EDCountingMode.TOTAL -> false + EDCountingMode.FREE -> reserved.isPositive() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/domain/balance/TransferableMode.kt b/common/src/main/java/io/novafoundation/nova/common/domain/balance/TransferableMode.kt new file mode 100644 index 0000000..f0e4455 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/domain/balance/TransferableMode.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.common.domain.balance + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance +import java.math.BigInteger + +enum class TransferableMode { + REGULAR, HOLDS_AND_FREEZES +} + +fun TransferableMode.calculateTransferable(free: BigInteger, frozen: BigInteger, reserved: BigInteger): BigInteger { + return when (this) { + TransferableMode.REGULAR -> legacyTransferable(free, frozen) + TransferableMode.HOLDS_AND_FREEZES -> holdAndFreezesTransferable(free, frozen, reserved) + } +} + +fun TransferableMode.calculateReservable(free: BigInteger, frozen: BigInteger, ed: BigInteger): BigInteger { + return when (this) { + TransferableMode.REGULAR -> legacyReservable(free, frozen, ed) + TransferableMode.HOLDS_AND_FREEZES -> holdsAndFreezesReservable(free, ed) + } +} + +fun TransferableMode.calculateTransferable(accountBalance: AccountBalance): BigInteger { + return calculateTransferable(accountBalance.free, accountBalance.frozen, accountBalance.reserved) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/domain/config/GlobalConfig.kt b/common/src/main/java/io/novafoundation/nova/common/domain/config/GlobalConfig.kt new file mode 100644 index 0000000..2d45168 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/domain/config/GlobalConfig.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.common.domain.config + +class GlobalConfig( + val multisigsApiUrl: String, + val proxyApiUrl: String, + val multiStakingApiUrl: String +) diff --git a/common/src/main/java/io/novafoundation/nova/common/domain/interactor/AssetViewModeInteractor.kt b/common/src/main/java/io/novafoundation/nova/common/domain/interactor/AssetViewModeInteractor.kt new file mode 100644 index 0000000..f9a7d15 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/domain/interactor/AssetViewModeInteractor.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.common.domain.interactor + +import io.novafoundation.nova.common.data.model.AssetViewMode +import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository +import kotlinx.coroutines.flow.Flow + +interface AssetViewModeInteractor { + + fun getAssetViewMode(): AssetViewMode + + fun assetsViewModeFlow(): Flow + + suspend fun setAssetsViewMode(assetsViewMode: AssetViewMode) +} + +class RealAssetViewModeInteractor( + private val assetsViewModeRepository: AssetsViewModeRepository +) : AssetViewModeInteractor { + override fun getAssetViewMode(): AssetViewMode { + return assetsViewModeRepository.getAssetViewMode() + } + + override fun assetsViewModeFlow(): Flow { + return assetsViewModeRepository.assetsViewModeFlow() + } + + override suspend fun setAssetsViewMode(assetsViewMode: AssetViewMode) { + assetsViewModeRepository.setAssetsViewMode(assetsViewMode) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/domain/usecase/MaskingModeUseCase.kt b/common/src/main/java/io/novafoundation/nova/common/domain/usecase/MaskingModeUseCase.kt new file mode 100644 index 0000000..1ad328a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/domain/usecase/MaskingModeUseCase.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.common.domain.usecase + +import io.novafoundation.nova.common.data.model.MaskingMode +import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository +import io.novafoundation.nova.common.data.repository.toggle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +interface MaskingModeUseCase { + + fun observeMaskingMode(): Flow + + fun toggleMaskingMode() + + fun toggleHideBalancesOnLaunch() + + fun observeHideBalancesOnLaunchEnabled(): Flow +} + +private const val MASKING_MODE_FEATURE = "MASKING_MODE_FEATURE" +private const val MASKING_MODE_ON_LAUNCH_FEATURE = "MASKING_MODE_ON_LAUNCH_FEATURE" + +class RealMaskingModeUseCase( + private val toggleFeatureRepository: ToggleFeatureRepository +) : MaskingModeUseCase { + + init { + toggleFeatureRepository.set(MASKING_MODE_FEATURE, initialMaskingModeState()) + } + + override fun observeMaskingMode(): Flow { + return observeMaskingModeEnabled() + .map { toMaskingMode(it) } + .distinctUntilChanged() + } + + override fun toggleMaskingMode() { + toggleFeatureRepository.toggle(MASKING_MODE_FEATURE) + } + + override fun toggleHideBalancesOnLaunch() { + val isHideOnLaunchEnabled = toggleFeatureRepository.toggle(MASKING_MODE_ON_LAUNCH_FEATURE) + toggleFeatureRepository.set(MASKING_MODE_FEATURE, isHideOnLaunchEnabled) + } + + override fun observeHideBalancesOnLaunchEnabled(): Flow { + return toggleFeatureRepository.observe(MASKING_MODE_ON_LAUNCH_FEATURE, false) + } + + private fun observeMaskingModeEnabled() = toggleFeatureRepository.observe(MASKING_MODE_FEATURE) + + private fun initialMaskingModeState(): Boolean { + return toggleFeatureRepository.get(MASKING_MODE_ON_LAUNCH_FEATURE, false) || + toggleFeatureRepository.get(MASKING_MODE_FEATURE, false) + } +} + +private fun toMaskingMode(enabled: Boolean): MaskingMode = when (enabled) { + true -> MaskingMode.ENABLED + false -> MaskingMode.DISABLED +} diff --git a/common/src/main/java/io/novafoundation/nova/common/interfaces/ActivityIntentProvider.kt b/common/src/main/java/io/novafoundation/nova/common/interfaces/ActivityIntentProvider.kt new file mode 100644 index 0000000..7cf8a4d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/interfaces/ActivityIntentProvider.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.common.interfaces + +import android.content.Intent + +interface ActivityIntentProvider { + fun getIntent(): Intent +} diff --git a/common/src/main/java/io/novafoundation/nova/common/interfaces/BuildTypeProvider.kt b/common/src/main/java/io/novafoundation/nova/common/interfaces/BuildTypeProvider.kt new file mode 100644 index 0000000..9215222 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/interfaces/BuildTypeProvider.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.common.interfaces + +interface BuildTypeProvider { + + fun isDebug(): Boolean + + fun getBuildType(): BuildType? +} + +enum class BuildType { + DEBUG, + DEVELOP, + INSTRUMENTAL_TEST, + RELEASE, + RELEASE_TEST, + RELEASE_MARKET, + RELEASE_GITHUB, +} + +fun BuildTypeProvider.isMarketRelease(): Boolean { + return getBuildType() == BuildType.RELEASE_MARKET +} diff --git a/common/src/main/java/io/novafoundation/nova/common/interfaces/ExternalServiceInitializer.kt b/common/src/main/java/io/novafoundation/nova/common/interfaces/ExternalServiceInitializer.kt new file mode 100644 index 0000000..d09902f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/interfaces/ExternalServiceInitializer.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.interfaces + +interface ExternalServiceInitializer { + fun initialize() +} + +class CompoundExternalServiceInitializer( + private val initializers: Set +) : ExternalServiceInitializer { + + override fun initialize() { + initializers.forEach { it.initialize() } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/interfaces/FileCache.kt b/common/src/main/java/io/novafoundation/nova/common/interfaces/FileCache.kt new file mode 100644 index 0000000..d528eeb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/interfaces/FileCache.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.common.interfaces + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +interface FileCache { + + suspend fun updateCache(fileName: String, value: String) + + fun observeCachedValue(fileName: String): Flow +} + +private typealias OnCacheValueChanged = (String) -> Unit + +internal class InternalFileSystemCache( + private val fileProvider: FileProvider +) : FileCache { + + private val callbacks: MutableMap> = mutableMapOf() + private val fileMutex: Mutex = Mutex() + + override suspend fun updateCache(fileName: String, value: String) { + fileProvider.writeCache(fileName, value) + + notifyCallbacks(fileName, value) + } + + override fun observeCachedValue(fileName: String): Flow { + return callbackFlow { + val callback: OnCacheValueChanged = { + trySend(it) + } + + putCallback(fileName, callback) + + awaitClose { removeCallback(fileName, callback) } + } + .onStart { emit(fileProvider.readCache(fileName)) } + .filterNotNull() + } + + private fun putCallback(fileName: String, callback: OnCacheValueChanged) = synchronized(this) { + val callbacksForFile = callbacks.getOrPut(fileName) { mutableListOf() } + + callbacksForFile.add(callback) + } + + private fun removeCallback(fileName: String, callback: OnCacheValueChanged) = synchronized(this) { + val callbacksForFile = callbacks[fileName] ?: return + + callbacksForFile.remove(callback) + + if (callbacksForFile.isEmpty()) { + callbacks.remove(fileName) + } + } + + private fun notifyCallbacks(fileName: String, value: String) { + val callbacks = synchronized(this) { callbacks[fileName]?.toMutableList() } + + callbacks?.forEach { it.invoke(value) } + } + + private suspend fun FileProvider.readCache(fileName: String): String? = withContext(Dispatchers.IO) { + fileMutex.withLock { + val file = getFileInInternalCacheStorage(fileName) + + if (file.exists()) { + file.readText() + } else { + null + } + } + } + + private suspend fun FileProvider.writeCache(fileName: String, value: String) = withContext(Dispatchers.IO) { + fileMutex.withLock { + val file = getFileInInternalCacheStorage(fileName) + + file.writeText(value) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/interfaces/FileProvider.kt b/common/src/main/java/io/novafoundation/nova/common/interfaces/FileProvider.kt new file mode 100644 index 0000000..2b8b390 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/interfaces/FileProvider.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.common.interfaces + +import android.net.Uri +import java.io.File + +interface FileProvider { + + fun getFileInExternalCacheStorage(fileName: String): File + + fun getFileInInternalCacheStorage(fileName: String): File + + fun generateTempFile(fixedName: String? = null): File + + fun uriOf(file: File): Uri +} diff --git a/common/src/main/java/io/novafoundation/nova/common/io/MainThreadExecutor.kt b/common/src/main/java/io/novafoundation/nova/common/io/MainThreadExecutor.kt new file mode 100644 index 0000000..87d354f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/io/MainThreadExecutor.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.common.io + +import android.os.Handler +import android.os.Looper +import androidx.annotation.NonNull +import java.util.concurrent.Executor + +class MainThreadExecutor : Executor { + + private val mainThreadHandler = Handler(Looper.getMainLooper()) + + override fun execute(@NonNull command: Runnable) { + mainThreadHandler.post(command) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/BaseListAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/list/BaseListAdapter.kt new file mode 100644 index 0000000..4ae331a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/BaseListAdapter.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.common.list + +import android.view.View +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.extensions.LayoutContainer + +abstract class BaseListAdapter(diffCallback: DiffUtil.ItemCallback) : ListAdapter(diffCallback) { + + override fun onViewRecycled(holder: VH) { + holder.unbind() + } +} + +abstract class BaseViewHolder(override val containerView: View) : RecyclerView.ViewHolder(containerView), LayoutContainer { + + val context + get() = containerView.context + + open fun unbind() {} +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/CustomPlaceholderAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/list/CustomPlaceholderAdapter.kt new file mode 100644 index 0000000..2d5691d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/CustomPlaceholderAdapter.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.common.list + +import android.view.View +import android.view.ViewGroup +import androidx.annotation.LayoutRes +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflateChild + +class CustomPlaceholderAdapter(@LayoutRes val layoutId: Int) : SingleItemAdapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StubHolder { + return StubHolder(parent.inflateChild(layoutId)) + } + + override fun onBindViewHolder(holder: StubHolder, position: Int) {} + + override fun getItemViewType(position: Int): Int { + return layoutId + } +} + +class StubHolder(view: View) : RecyclerView.ViewHolder(view) diff --git a/common/src/main/java/io/novafoundation/nova/common/list/EditablePlaceholderAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/list/EditablePlaceholderAdapter.kt new file mode 100644 index 0000000..43e5ed5 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/EditablePlaceholderAdapter.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.common.list + +import android.view.View.OnClickListener +import android.view.ViewGroup +import io.novafoundation.nova.common.databinding.ItemPlaceholderBinding +import io.novafoundation.nova.common.utils.ViewSpace +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.view.PlaceholderModel + +class EditablePlaceholderAdapter( + private var model: PlaceholderModel? = null, + private var padding: ViewSpace? = null, + private var clickListener: OnClickListener? = null +) : SingleItemAdapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EditableStubHolder { + return EditableStubHolder(ItemPlaceholderBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: EditableStubHolder, position: Int) { + holder.bind(model, padding, clickListener) + } + + fun setPadding(padding: ViewSpace?) { + this.padding = padding + if (showItem) { + notifyItemChanged(0) + } + } + + fun setPlaceholderData(model: PlaceholderModel) { + this.model = model + if (showItem) { + notifyItemChanged(0) + } + } + + fun setButtonClickListener(listener: OnClickListener?) { + clickListener = listener + if (showItem) { + notifyItemChanged(0) + } + } +} + +class EditableStubHolder(private val binder: ItemPlaceholderBinding) : BaseViewHolder(binder.root) { + + fun bind(model: PlaceholderModel?, padding: ViewSpace?, clickListener: OnClickListener?) { + model?.let { binder.itemPlaceholder.setModel(model) } + binder.itemPlaceholder.setButtonClickListener(clickListener) + padding?.let { binder.root.updatePadding(it) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/GroupedList.kt b/common/src/main/java/io/novafoundation/nova/common/list/GroupedList.kt new file mode 100644 index 0000000..0bbecb8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/GroupedList.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.common.list + +typealias GroupedList = Map> + +fun emptyGroupedList() = emptyMap() + +fun GroupedList.toListWithHeaders(): List = flatMap { (groupKey, values) -> + listOf(groupKey) + values +} + +inline fun GroupedList.toListWithHeaders( + keyMapper: (K1, List) -> K2?, + valueMapper: (V1) -> V2 +) = flatMap { (key, values) -> + val mappedKey = keyMapper(key, values) + val mappedValues = values.map(valueMapper) + + if (mappedKey != null) { + listOf(mappedKey) + mappedValues + } else { + mappedValues + } +} + +fun GroupedList.toValueList(): List = flatMap { (_, values) -> values } diff --git a/common/src/main/java/io/novafoundation/nova/common/list/GroupedListAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/list/GroupedListAdapter.kt new file mode 100644 index 0000000..30d192f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/GroupedListAdapter.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.common.list + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView + +abstract class GroupedListAdapter(private val diffCallback: BaseGroupedDiffCallback) : + ListAdapter(diffCallback) { + + companion object { + const val TYPE_GROUP = 1 + const val TYPE_CHILD = 2 + } + + abstract fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder + + abstract fun createChildViewHolder(parent: ViewGroup): GroupedListHolder + + abstract fun bindGroup(holder: GroupedListHolder, group: GROUP) + abstract fun bindChild(holder: GroupedListHolder, child: CHILD) + + protected open fun bindGroup( + holder: GroupedListHolder, + position: Int, + group: GROUP, + payloads: List + ) { + bindGroup(holder, group) + } + + protected open fun bindChild( + holder: GroupedListHolder, + position: Int, + child: CHILD, + payloads: List + ) { + bindChild(holder, child) + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + + return if (diffCallback.isGroup(item)) TYPE_GROUP else TYPE_CHILD + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): GroupedListHolder { + return if (viewType == TYPE_GROUP) { + createGroupViewHolder(parent) + } else { + createChildViewHolder(parent) + } + } + + @Suppress("UNCHECKED_CAST") + override fun onBindViewHolder(holder: GroupedListHolder, position: Int) { + val item = getItem(position) + + if (getItemViewType(position) == TYPE_GROUP) { + bindGroup(holder, item as GROUP) + } else { + bindChild(holder, item as CHILD) + } + } + + @Suppress("UNCHECKED_CAST") + override fun onBindViewHolder(holder: GroupedListHolder, position: Int, payloads: List) { + val item = getItem(position) + + if (getItemViewType(position) == TYPE_GROUP) { + bindGroup(holder, position, item as GROUP, payloads) + } else { + bindChild(holder, position, item as CHILD, payloads) + } + } + + override fun onViewRecycled(holder: GroupedListHolder) { + holder.unbind() + } + + protected inline fun findIndexOfElement(crossinline condition: (T) -> Boolean): Int { + return currentList.indexOfFirst { it is T && condition(it) } + } +} + +@Suppress("UNCHECKED_CAST") +abstract class BaseGroupedDiffCallback(private val groupClass: Class) : + DiffUtil.ItemCallback() { + abstract fun areGroupItemsTheSame(oldItem: GROUP, newItem: GROUP): Boolean + abstract fun areGroupContentsTheSame(oldItem: GROUP, newItem: GROUP): Boolean + + protected open fun getGroupChangePayload(oldItem: GROUP, newItem: GROUP): Any? = null + + abstract fun areChildItemsTheSame(oldItem: CHILD, newItem: CHILD): Boolean + abstract fun areChildContentsTheSame(oldItem: CHILD, newItem: CHILD): Boolean + + protected open fun getChildChangePayload(oldItem: CHILD, newItem: CHILD): Any? = null + + override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean { + if (oldItem::class != newItem::class) return false + + return if (isGroup(oldItem)) { + areGroupItemsTheSame(oldItem as GROUP, newItem as GROUP) + } else { + areChildItemsTheSame(oldItem as CHILD, newItem as CHILD) + } + } + + override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { + if (oldItem::class != newItem::class) return false + + return if (isGroup(oldItem)) { + areGroupContentsTheSame(oldItem as GROUP, newItem as GROUP) + } else { + areChildContentsTheSame(oldItem as CHILD, newItem as CHILD) + } + } + + override fun getChangePayload(oldItem: Any, newItem: Any): Any? { + return if (isGroup(oldItem)) { + getGroupChangePayload(oldItem as GROUP, newItem as GROUP) + } else { + getChildChangePayload(oldItem as CHILD, newItem as CHILD) + } + } + + internal fun isGroup(item: Any) = item::class.java == groupClass +} + +// TODO containerView can be removed +abstract class GroupedListHolder(open val containerView: View) : + RecyclerView.ViewHolder(containerView) { + + open fun unbind() { + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/GroupedListSpacingDecoration.kt b/common/src/main/java/io/novafoundation/nova/common/list/GroupedListSpacingDecoration.kt new file mode 100644 index 0000000..232d6db --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/GroupedListSpacingDecoration.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.common.list + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.headers.TextHeaderHolder +import io.novafoundation.nova.common.utils.dp + +class GroupedListSpacingDecoration( + private val groupTopSpacing: Int, + private val groupBottomSpacing: Int, + private val firstItemTopSpacing: Int, + private val middleItemTopSpacing: Int, + private val itemBottomSpacing: Int, +) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val viewHolder = parent.getChildViewHolder(view) + + val isFirst = viewHolder.absoluteAdapterPosition == 0 + val isGroup = viewHolder is TextHeaderHolder + + val context = view.context + + val topDp: Int + val bottomDp: Int + + when { + isGroup -> { + topDp = groupTopSpacing; bottomDp = groupBottomSpacing + } + isFirst -> { + topDp = firstItemTopSpacing; bottomDp = itemBottomSpacing + } + else -> { + topDp = middleItemTopSpacing; bottomDp = itemBottomSpacing + } + } + + outRect.set(0, topDp.dp(context), 0, bottomDp.dp(context)) + } +} + +fun RecyclerView.setGroupedListSpacings( + groupTopSpacing: Int = 0, + groupBottomSpacing: Int = 0, + firstItemTopSpacing: Int = 0, + middleItemTopSpacing: Int = 0, + itemBottomSpacing: Int = 0, +) = addItemDecoration( + GroupedListSpacingDecoration( + groupTopSpacing = groupTopSpacing, + groupBottomSpacing = groupBottomSpacing, + firstItemTopSpacing = firstItemTopSpacing, + middleItemTopSpacing = middleItemTopSpacing, + itemBottomSpacing = itemBottomSpacing + ) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/list/NestedAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/list/NestedAdapter.kt new file mode 100644 index 0000000..7b1966e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/NestedAdapter.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.common.list + +import android.graphics.Rect +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Orientation +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.databinding.ItemNestedListBinding +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater + +class NestedAdapter( + private val nestedAdapter: ListAdapter, + @Orientation private val orientation: Int, + private val paddingInDp: Rect? = null, + private val disableItemAnimations: Boolean = false, +) : RecyclerView.Adapter>() { + + private var showNestedList = true + private var nestedList: List = listOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NestedListViewHolder { + return NestedListViewHolder( + ItemNestedListBinding.inflate(parent.inflater(), parent, false), + nestedAdapter, + orientation, + paddingInDp, + disableItemAnimations + ) + } + + override fun onBindViewHolder(holder: NestedListViewHolder, position: Int) { + holder.bind(nestedList) + } + + override fun getItemCount(): Int { + return if (showNestedList) 1 else 0 + } + + fun submitList(items: List) { + nestedList = items + if (showNestedList) { + notifyItemChanged(0, true) + } + } + + fun show(show: Boolean) { + if (showNestedList != show) { + showNestedList = show + if (show) { + notifyItemInserted(0) + } else { + notifyItemRemoved(0) + } + } + } +} + +class NestedListViewHolder( + binder: ItemNestedListBinding, + private val nestedAdapter: ListAdapter, + @Orientation orientation: Int, + padding: Rect?, + disableItemAnimations: Boolean, +) : BaseViewHolder(binder.root) { + + init { + binder.itemNestedList.adapter = nestedAdapter + binder.itemNestedList.layoutManager = LinearLayoutManager(binder.root.context, orientation, false) + if (disableItemAnimations) binder.itemNestedList.itemAnimator = null + + padding?.let { + binder.itemNestedList.setPadding( + it.left.dp(binder.root.context), + it.top.dp(binder.root.context), + it.right.dp(binder.root.context), + it.bottom.dp(binder.root.context) + ) + } + } + + fun bind(nestedList: List) { + nestedAdapter.submitList(nestedList) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/PayloadGenerator.kt b/common/src/main/java/io/novafoundation/nova/common/list/PayloadGenerator.kt new file mode 100644 index 0000000..07dcd38 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/PayloadGenerator.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.common.list + +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView + +typealias DiffCheck = (T) -> V + +open class PayloadGenerator(private vararg val checks: DiffCheck) { + + fun diff(first: T, second: T): List>? { + val foundMatches = checks.filter { check -> check(first) != check(second) } + + return if (foundMatches.isEmpty()) { + null + } else { + foundMatches + } + } +} + +typealias UnknownPayloadHandler = (Any?) -> Unit + +@Suppress("UNCHECKED_CAST") +fun ListAdapter.resolvePayload( + holder: VH, + position: Int, + payloads: List, + onUnknownPayload: UnknownPayloadHandler? = null, + onDiffCheck: (DiffCheck) -> Unit, +) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + } else { + when (val payload = payloads.first()) { + is List<*> -> { + val diffChecks = payload as List> + + diffChecks.forEach(onDiffCheck) + } + else -> onUnknownPayload?.invoke(payload) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/SingleItemAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/list/SingleItemAdapter.kt new file mode 100644 index 0000000..e070f08 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/SingleItemAdapter.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.common.list + +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +abstract class SingleItemAdapter() : RecyclerView.Adapter() { + + protected var showItem = false + private set + + constructor(isShownByDefault: Boolean) : this() { + showItem = isShownByDefault + } + + override fun getItemCount(): Int { + return if (showItem) 1 else 0 + } + + fun show(show: Boolean) { + if (showItem != show) { + showItem = show + if (show) { + notifyItemInserted(0) + } else { + notifyItemRemoved(0) + } + } + } + + fun notifyChangedIfShown() { + if (showItem) { + notifyItemChanged(0) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/decoration/BackgroundItemDecoration.kt b/common/src/main/java/io/novafoundation/nova/common/list/decoration/BackgroundItemDecoration.kt new file mode 100644 index 0000000..20a7d71 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/decoration/BackgroundItemDecoration.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.common.list.decoration + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.view.View +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import kotlin.math.roundToInt + +open class BackgroundItemDecoration( + context: Context, + private val background: Drawable = context.getRoundedCornerDrawable(fillColorRes = R.color.block_background), + outerHorizontalMarginDp: Int, + innerVerticalPaddingDp: Int, +) : RecyclerView.ItemDecoration() { + + private val innerVerticalPadding = innerVerticalPaddingDp.dp(context) + private val outerHorizontalMargin = outerHorizontalMarginDp.dp(context) + + open fun shouldApplyDecoration(holder: RecyclerView.ViewHolder): Boolean { + return true + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + if (!parent.shouldApplyDecoration(view)) return + + outRect.set(outerHorizontalMargin, 0, outerHorizontalMargin, 0) + } + + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val childrenSections = filterChildren(parent) + childrenSections.forEach { + val topChild: View = it.mostTop() ?: return + val bottomChild: View = it.mostBottom() ?: return + + background.setBounds( + topChild.left, + topChild.top + topChild.translationY.roundToInt() - innerVerticalPadding, + topChild.right, + bottomChild.bottom + bottomChild.translationY.roundToInt() + innerVerticalPadding + ) + + background.draw(canvas) + } + } + + private fun filterChildren(parent: RecyclerView): List> { + val sections = mutableListOf(mutableListOf()) + parent.children.forEach { child -> + if (parent.shouldApplyDecoration(child)) { + sections.last().add(child) + } else { + if (sections.last().isNotEmpty()) { + sections.add(mutableListOf()) + } + } + } + + return sections + } + + private fun RecyclerView.shouldApplyDecoration(view: View): Boolean { + val viewHolder = getChildViewHolder(view) + return shouldApplyDecoration(viewHolder) + } + + private fun List.mostTop(): View? { + return minByOrNull { it.top + it.translationY } + } + + private fun List.mostBottom(): View? { + return maxByOrNull { it.bottom + it.translationY } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/decoration/DividerItemDecoration.kt b/common/src/main/java/io/novafoundation/nova/common/list/decoration/DividerItemDecoration.kt new file mode 100644 index 0000000..78fd014 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/decoration/DividerItemDecoration.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.common.list.decoration + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.view.View +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF + +open class DividerItemDecoration( + context: Context, + private val dividerColorRes: Int = R.color.divider, + dividerWidthDp: Int = 1, + dividerMarginDp: Int = 0 +) : RecyclerView.ItemDecoration() { + + private val dividerMargin = dividerMarginDp.dp(context) + + private val paint = Paint().apply { + color = context.getColor(dividerColorRes) + style = Paint.Style.FILL + strokeWidth = dividerWidthDp.dpF(context) + } + + open fun shouldApplyDecorationBetween(top: RecyclerView.ViewHolder, bottom: RecyclerView.ViewHolder): Boolean { + return true + } + + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + filterChildren(parent) + .forEach { + canvas.drawLine( + dividerMargin.toFloat(), + it.bottom.toFloat(), + parent.width.toFloat() - dividerMargin, + it.bottom.toFloat(), + paint + ) + } + } + + /** + * Returns children that should have divider under them. + */ + private fun filterChildren(parent: RecyclerView): List { + return parent.children + .zipWithNext() + .filter { (top, bottom) -> parent.shouldApplyDecoration(top, bottom) } + .map { it.first } + .toList() + } + + private fun RecyclerView.shouldApplyDecoration(top: View, bottom: View): Boolean { + val topViewHolder = getChildViewHolder(top) + val bottomViewHolder = getChildViewHolder(bottom) + return shouldApplyDecorationBetween(topViewHolder, bottomViewHolder) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/decoration/ExtraSpaceItemDecoration.kt b/common/src/main/java/io/novafoundation/nova/common/list/decoration/ExtraSpaceItemDecoration.kt new file mode 100644 index 0000000..e2cb939 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/decoration/ExtraSpaceItemDecoration.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.common.list.decoration + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +/** + * Implement it in your ViewHolder to set extra space around it. + */ +interface ExtraSpaceViewHolder { + + fun getExtraSpace(topViewHolder: ViewHolder?, bottomViewHolder: ViewHolder?): Rect? +} + +/** + * ItemDecoration that looking for ExtraSpaceViewHolder implementations and set extra space around them. + */ +class ExtraSpaceItemDecoration : RecyclerView.ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val itemPosition = parent.getChildAdapterPosition(view) + val viewHolder = parent.findViewHolderForAdapterPosition(itemPosition) + if (viewHolder is ExtraSpaceViewHolder) { + val topViewHolder = parent.findViewHolderForAdapterPosition(itemPosition - 1) + val bottomViewHolder = parent.findViewHolderForAdapterPosition(itemPosition + 1) + val extraSpace = viewHolder.getExtraSpace(topViewHolder, bottomViewHolder) ?: return + outRect.set(extraSpace) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/headers/TextHeader.kt b/common/src/main/java/io/novafoundation/nova/common/list/headers/TextHeader.kt new file mode 100644 index 0000000..2639795 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/headers/TextHeader.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.common.list.headers + +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.databinding.ItemTextHeaderBinding +import io.novafoundation.nova.common.list.GroupedListHolder + +class TextHeader(val content: String) { + + companion object { + + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean { + return oldItem.content == newItem.content + } + + override fun areContentsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean { + return true + } + } + } +} + +class TextHeaderHolder(private val binder: ItemTextHeaderBinding) : GroupedListHolder(binder.root) { + + fun bind(item: TextHeader) { + binder.textHeader.text = item.content + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/instruction/InstructionAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/list/instruction/InstructionAdapter.kt new file mode 100644 index 0000000..995f1ef --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/instruction/InstructionAdapter.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.common.list.instruction + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.databinding.ItemInstructionBinding +import io.novafoundation.nova.common.databinding.ItemInstructionImageBinding +import io.novafoundation.nova.common.utils.inflater + +private const val STEP_VIEW_TYPE = 0 +private const val IMAGE_VIEW_TYPE = 1 + +class InstructionAdapter : ListAdapter(DiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return when { + viewType == STEP_VIEW_TYPE -> InstructionStepViewHolder(ItemInstructionBinding.inflate(parent.inflater(), parent, false)) + viewType == IMAGE_VIEW_TYPE -> InstructionImageViewHolder(ItemInstructionImageBinding.inflate(parent.inflater(), parent, false)) + else -> error("Unknown view type") + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + when (holder) { + is InstructionStepViewHolder -> holder.bind(getItem(position) as InstructionItem.Step) + is InstructionImageViewHolder -> holder.bind(getItem(position) as InstructionItem.Image) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is InstructionItem.Image -> IMAGE_VIEW_TYPE + is InstructionItem.Step -> STEP_VIEW_TYPE + } + } +} + +private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: InstructionItem, newItem: InstructionItem): Boolean { + return false + } + + override fun areContentsTheSame(oldItem: InstructionItem, newItem: InstructionItem): Boolean { + return false + } +} + +class InstructionStepViewHolder(private val binder: ItemInstructionBinding) : ViewHolder(binder.root) { + fun bind(instruction: InstructionItem.Step) { + binder.instructionStep.setStepNumber(instruction.number) + binder.instructionStep.setStepText(instruction.text) + } +} + +class InstructionImageViewHolder(private val binder: ItemInstructionImageBinding) : ViewHolder(binder.root) { + fun bind(instruction: InstructionItem.Image) { + binder.instructionImage.setModel(instruction.image, instruction.label) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/list/instruction/InstructionItem.kt b/common/src/main/java/io/novafoundation/nova/common/list/instruction/InstructionItem.kt new file mode 100644 index 0000000..122adf8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/list/instruction/InstructionItem.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.common.list.instruction + +import androidx.annotation.DrawableRes + +sealed interface InstructionItem { + class Step( + val number: Int, + val text: CharSequence + ) : InstructionItem + + class Image( + @DrawableRes val image: Int, + val label: String? + ) : InstructionItem +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/MixinFactory.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/MixinFactory.kt new file mode 100644 index 0000000..37f81c6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/MixinFactory.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.mixin + +import kotlinx.coroutines.CoroutineScope + +interface MixinFactory { + + fun create(scope: CoroutineScope): M +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/actionAwaitable/ActionAwaitableMixin.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/actionAwaitable/ActionAwaitableMixin.kt new file mode 100644 index 0000000..12ddfff --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/actionAwaitable/ActionAwaitableMixin.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.common.mixin.actionAwaitable + +import androidx.lifecycle.LiveData +import androidx.lifecycle.asFlow +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin.Action +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull + +typealias ChooseOneOfManyAwaitable = ActionAwaitableMixin, E> +typealias ConfirmationAwaitable

= ActionAwaitableMixin.Presentation + +typealias ConfirmOrDenyAwaitable

= ActionAwaitableMixin.Presentation + +typealias ChooseOneOfAwaitableAction = Action, E> +typealias ChooseOneOfManyAwaitableAction = Action, E> + +interface ActionAwaitableMixin { + + class Action( + val payload: P, + val onSuccess: (R) -> Unit, + val onCancel: () -> Unit, + ) + + val awaitableActionLiveData: LiveData>> + + interface Presentation : ActionAwaitableMixin { + + suspend fun awaitAction(payload: P): R + } + + interface Factory { + + fun create(): Presentation + } +} + +val ActionAwaitableMixin.awaitableActionFlow: Flow> + get() = awaitableActionLiveData.asFlow() + .mapNotNull { it.getContentIfNotHandled() } + +fun ActionAwaitableMixin.Factory.selectingOneOf() = create, T>() +fun

ActionAwaitableMixin.Factory.confirmingAction(): ConfirmationAwaitable

= create() +fun

ActionAwaitableMixin.Factory.confirmingOrDenyingAction(): ConfirmOrDenyAwaitable

= create() + +fun ActionAwaitableMixin.Factory.fixedSelectionOf() = create() + +suspend fun ActionAwaitableMixin.Presentation.awaitAction() = awaitAction(Unit) + +fun ActionAwaitableMixin<*, *>.hasAlreadyTriggered(): Boolean = awaitableActionLiveData.value != null diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/actionAwaitable/ActionAwaitableProvider.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/actionAwaitable/ActionAwaitableProvider.kt new file mode 100644 index 0000000..f20d5c0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/actionAwaitable/ActionAwaitableProvider.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.common.mixin.actionAwaitable + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +internal class ActionAwaitableProvider : ActionAwaitableMixin.Presentation { + + companion object : ActionAwaitableMixin.Factory { + + override fun create(): ActionAwaitableMixin.Presentation { + return ActionAwaitableProvider() + } + } + + override val awaitableActionLiveData = MutableLiveData>>() + + override suspend fun awaitAction(payload: P): R = suspendCancellableCoroutine { continuation -> + val action = ActionAwaitableMixin.Action( + payload = payload, + onSuccess = { continuation.resume(it) }, + onCancel = { continuation.cancel() } + ) + + awaitableActionLiveData.postValue(action.event()) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/actionAwaitable/ConfirmationDialog.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/actionAwaitable/ConfirmationDialog.kt new file mode 100644 index 0000000..5453b32 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/actionAwaitable/ConfirmationDialog.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.common.mixin.actionAwaitable + +import androidx.annotation.StyleRes +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.dialog.dialog + +class ConfirmationDialogInfo(val title: String, val message: String?, val positiveButton: String, val negativeButton: String?) { + + companion object; +} + +fun ConfirmationDialogInfo.Companion.fromRes( + resourceManager: ResourceManager, + title: Int, + message: Int?, + positiveButton: Int, + negativeButton: Int? +) = ConfirmationDialogInfo( + resourceManager.getString(title), + message?.let { resourceManager.getString(message) }, + resourceManager.getString(positiveButton), + negativeButton?.let { resourceManager.getString(it) } +) + +fun ConfirmationDialogInfo.Companion.titleAndButton( + resourceManager: ResourceManager, + title: Int, + button: Int +) = fromRes(resourceManager, title, null, button, null) + +fun BaseFragmentMixin<*>.setupConfirmationDialog( + @StyleRes style: Int, + awaitableMixin: ConfirmationAwaitable +) { + awaitableMixin.awaitableActionLiveData.observeEvent { action -> + dialog(providedContext, style) { + val payload = action.payload + setTitle(payload.title) + payload.message?.let { setMessage(payload.message) } + setPositiveButton(payload.positiveButton) { _, _ -> action.onSuccess(Unit) } + + if (payload.negativeButton != null) { + setNegativeButton(payload.negativeButton) { _, _ -> action.onCancel() } + } + + setOnCancelListener { action.onCancel() } + } + } +} + +fun BaseFragment<*, *>.setupConfirmationOrDenyDialog(@StyleRes style: Int, awaitableMixin: ConfirmOrDenyAwaitable) { + awaitableMixin.awaitableActionLiveData.observeEvent { action -> + dialog(requireContext(), style) { + val payload = action.payload + setTitle(payload.title) + payload.message?.let { setMessage(payload.message) } + setPositiveButton(payload.positiveButton) { _, _ -> action.onSuccess(true) } + + if (payload.negativeButton != null) { + setNegativeButton(payload.negativeButton) { _, _ -> action.onSuccess(false) } + } + + setOnCancelListener { action.onCancel() } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/api/Browserable.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/api/Browserable.kt new file mode 100644 index 0000000..5b91bc3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/api/Browserable.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.common.mixin.api + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event + +interface Browserable { + val openBrowserEvent: LiveData> + + interface Presentation : Browserable { + companion object // extensions + + fun showBrowser(url: String) + } +} + +fun Browserable.Presentation.Companion.of(liveData: MutableLiveData>) = object : Browserable.Presentation { + + override fun showBrowser(url: String) { + liveData.value = Event(url) + } + + override val openBrowserEvent = liveData +} + +fun Browserable(): Browserable.Presentation = Browserable.Presentation.of(MutableLiveData()) diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/api/CustomDialogDisplayer.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/api/CustomDialogDisplayer.kt new file mode 100644 index 0000000..d330614 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/api/CustomDialogDisplayer.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.common.mixin.api + +import androidx.annotation.StyleRes +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer.Payload.DialogAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event + +interface CustomDialogDisplayer { + + val showCustomDialog: LiveData> + + class Payload( + val title: String, + val message: CharSequence?, + val okAction: DialogAction?, + val cancelAction: DialogAction? = null, + @StyleRes val customStyle: Int? = null, + ) { + + class DialogAction( + val title: String, + val action: () -> Unit, + ) { + + companion object { + + fun noOp(title: String) = DialogAction(title = title, action = {}) + } + } + } + + interface Presentation : CustomDialogDisplayer { + + fun displayDialog(payload: Payload) + } +} + +fun CustomDialogDisplayer.Presentation.displayError( + resourceManager: ResourceManager, + error: Throwable, +) { + error.message?.let { + displayDialog( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.common_error_general_title), + message = it, + okAction = DialogAction.noOp(resourceManager.getString(R.string.common_ok)), + cancelAction = null + ), + ) + } +} + +fun TitleAndMessage.toCustomDialogPayload(resourceManager: ResourceManager): CustomDialogDisplayer.Payload { + return CustomDialogDisplayer.Payload( + title = first, + message = second, + okAction = DialogAction.noOp(resourceManager.getString(R.string.common_ok)), + ) +} + +fun CustomDialogDisplayer.Presentation.displayDialogOrNothing(payload: CustomDialogDisplayer.Payload?) { + payload?.let { + displayDialog(it) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/api/NetworkStateMixin.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/api/NetworkStateMixin.kt new file mode 100644 index 0000000..853fdd0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/api/NetworkStateMixin.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.mixin.api + +import androidx.lifecycle.LiveData + +interface NetworkStateMixin : NetworkStateUi + +interface NetworkStateUi { + val showConnectingBarLiveData: LiveData +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/api/Retriable.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/api/Retriable.kt new file mode 100644 index 0000000..40d4b53 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/api/Retriable.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.common.mixin.api + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event + +typealias Action = () -> Unit + +class RetryPayload( + val title: String, + val message: String, + val onRetry: Action, + val onCancel: Action? = null +) + +interface Retriable { + + val retryEvent: MutableLiveData> +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/api/Validatable.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/api/Validatable.kt new file mode 100644 index 0000000..2400757 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/api/Validatable.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.common.mixin.api + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.validation.ValidationStatus + +sealed class ValidationFailureUi { + + class Default( + val level: ValidationStatus.NotValid.Level, + val title: String, + val message: CharSequence?, + val confirmWarning: Action, + ) : ValidationFailureUi() + + class Custom(val payload: CustomDialogDisplayer.Payload) : ValidationFailureUi() +} + +interface Validatable { + val validationFailureEvent: LiveData> +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/condition/ConditionMixin.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/condition/ConditionMixin.kt new file mode 100644 index 0000000..ab45512 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/condition/ConditionMixin.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.common.mixin.condition + +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface ConditionMixinFactory { + + fun createConditionMixin( + coroutineScope: CoroutineScope, + conditionsCount: Int + ): ConditionMixin +} + +interface ConditionMixin { + + val allConditionsSatisfied: Flow + + fun checkCondition(index: Int, isChecked: Boolean) +} + +fun ConditionMixin.buttonState(enabledState: String, disabledState: String): Flow { + return allConditionsSatisfied.map { satisfied -> + when (satisfied) { + true -> DescriptiveButtonState.Enabled(enabledState) + false -> DescriptiveButtonState.Disabled(disabledState) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/condition/ConditionMixinUI.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/condition/ConditionMixinUI.kt new file mode 100644 index 0000000..7196898 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/condition/ConditionMixinUI.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.common.mixin.condition + +import io.novafoundation.nova.common.utils.CheckableListener + +fun ConditionMixin.setupConditions(vararg conditionInputs: CheckableListener) { + conditionInputs.forEachIndexed { index, conditionInput -> + conditionInput.setOnCheckedChangeListener { _, isChecked -> + checkCondition(index, isChecked) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/condition/RealConditionMixin.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/condition/RealConditionMixin.kt new file mode 100644 index 0000000..d1cefcc --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/condition/RealConditionMixin.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.mixin.condition + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.common.utils.updateValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class RealConditionMixinFactory( + private val resourceManager: ResourceManager, +) : ConditionMixinFactory { + + override fun createConditionMixin( + coroutineScope: CoroutineScope, + conditionsCount: Int + ): ConditionMixin { + return RealConditionMixin( + coroutineScope, + resourceManager, + conditionsCount + ) + } +} + +class RealConditionMixin( + private val coroutineScope: CoroutineScope, + private val resourceManager: ResourceManager, + private val conditionsCount: Int +) : ConditionMixin, CoroutineScope by coroutineScope { + + private val conditionsState = MutableStateFlow(mapOf()) + + override val allConditionsSatisfied: Flow = conditionsState.map { conditions -> + conditions.values.size == conditionsCount && conditions.values.all { it } + }.shareInBackground() + + override fun checkCondition(index: Int, isChecked: Boolean) { + conditionsState.updateValue { it + (index to isChecked) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/copy/CopyTextLauncher.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/copy/CopyTextLauncher.kt new file mode 100644 index 0000000..d2afbf6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/copy/CopyTextLauncher.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.common.mixin.copy + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher.Payload +import io.novafoundation.nova.common.utils.Event + +interface CopyTextLauncher { + + class Payload(val title: String, val textToCopy: String, val copyButtonName: String, val shareButtonName: String) + + val showCopyTextDialog: LiveData> + + interface Presentation : CopyTextLauncher { + + suspend fun showCopyTextDialog(payload: Payload) + } +} + +class RealCopyTextLauncher : CopyTextLauncher.Presentation { + + override val showCopyTextDialog = MutableLiveData>() + + override suspend fun showCopyTextDialog(payload: Payload) { + showCopyTextDialog.value = Event(payload) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/copy/Ext.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/copy/Ext.kt new file mode 100644 index 0000000..64991eb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/copy/Ext.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.common.mixin.copy + +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.resources.ResourceManager + +suspend fun CopyTextLauncher.Presentation.showCopyCallHash( + resourceManager: ResourceManager, + value: String +) { + showCopyTextDialog( + CopyTextLauncher.Payload( + title = value, + textToCopy = value, + resourceManager.getString(R.string.common_copy_hash), + resourceManager.getString(R.string.common_share_hash) + ) + ) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/copy/UI.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/copy/UI.kt new file mode 100644 index 0000000..96f8bdf --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/copy/UI.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.mixin.copy + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.CopierBottomSheet + +fun BaseFragment.setupCopyText(viewModel: T) where T : BaseViewModel, T : CopyTextLauncher { + viewModel.showCopyTextDialog.observeEvent { + CopierBottomSheet( + requireContext(), + payload = it + ).show() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/hints/ConstantHintsMixin.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/ConstantHintsMixin.kt new file mode 100644 index 0000000..4b4fad7 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/ConstantHintsMixin.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.common.mixin.hints + +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +abstract class ConstantHintsMixin( + coroutineScope: CoroutineScope +) : HintsMixin, + CoroutineScope by coroutineScope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + abstract suspend fun getHints(): List + + override val hintsFlow: Flow> = flowOf { + getHints() + } + .inBackground() + .shareLazily() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/hints/HintsMixin.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/HintsMixin.kt new file mode 100644 index 0000000..5e3febc --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/HintsMixin.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.mixin.hints + +import kotlinx.coroutines.flow.Flow + +interface HintsMixin { + + val hintsFlow: Flow> +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/hints/HintsUi.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/HintsUi.kt new file mode 100644 index 0000000..4c85c09 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/HintsUi.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.common.mixin.hints + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.TextView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.utils.useAttributes + +class HintModel( + val iconRes: Int, + val text: CharSequence +) + +class HintsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + init { + orientation = VERTICAL + + attrs?.let(::applyAttributes) + } + + fun setHints(hints: List) { + removeAllViews() + + hints.mapIndexed { index, hint -> + TextView(context).apply { + setTextAppearance(R.style.TextAppearance_NovaFoundation_Regular_Caption1) + + setTextColorRes(R.color.text_secondary) + setDrawableStart(hint.iconRes, widthInDp = 16, paddingInDp = 8, tint = R.color.icon_secondary) + + text = hint.text + + if (index > 0) { + updatePadding(top = 12.dp) + } + } + }.forEach(::addView) + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.HintsView) { + val singleHint = it.getText(R.styleable.HintsView_HintsView_singleHint) + singleHint?.let(::setSingleHint) + } +} + +fun HintsView.setSingleHint(hint: CharSequence) { + setHints(listOf(hint)) +} + +fun BaseFragment<*, *>.observeHints(mixin: HintsMixin, view: HintsView) { + mixin.hintsFlow.observe(view::setHints) +} + +fun HintsView.setHints(hints: List) { + val novaIconHints = hints.map { HintModel(R.drawable.ic_pezkuwi, it) } + setHints(novaIconHints) +} + +fun HintsView.setHints(vararg hints: HintModel) { + setHints(hints.toList()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/hints/NoHintsMixin.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/NoHintsMixin.kt new file mode 100644 index 0000000..55afa7e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/NoHintsMixin.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.mixin.hints + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class NoHintsMixin : HintsMixin { + + override val hintsFlow: Flow> = flowOf(emptyList()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/hints/ResourcesHintsMixin.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/ResourcesHintsMixin.kt new file mode 100644 index 0000000..77048ab --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/hints/ResourcesHintsMixin.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.common.mixin.hints + +import io.novafoundation.nova.common.resources.ResourceManager +import kotlinx.coroutines.CoroutineScope + +class ResourcesHintsMixinFactory( + private val resourceManager: ResourceManager +) { + + fun create(coroutineScope: CoroutineScope, hintsRes: List): HintsMixin { + return ResourcesHintsMixin(coroutineScope, resourceManager, hintsRes) + } +} + +private class ResourcesHintsMixin( + coroutineScope: CoroutineScope, + private val resourceManager: ResourceManager, + private val hintsRes: List +) : ConstantHintsMixin(coroutineScope) { + + override suspend fun getHints() = hintsRes.map(resourceManager::getString) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/impl/BrowserableUi.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/BrowserableUi.kt new file mode 100644 index 0000000..d389cde --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/BrowserableUi.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.mixin.impl + +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.showBrowser + +fun BaseFragmentMixin<*>.observeBrowserEvents(mixin: Browserable) { + mixin.openBrowserEvent.observeEvent(providedContext::showBrowser) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/impl/CustomDialogProvider.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/CustomDialogProvider.kt new file mode 100644 index 0000000..baa4c09 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/CustomDialogProvider.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.common.mixin.impl + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.view.dialog.dialog + +class CustomDialogProvider : CustomDialogDisplayer.Presentation { + + override val showCustomDialog = MutableLiveData>() + + override fun displayDialog(payload: CustomDialogDisplayer.Payload) { + showCustomDialog.postValue(Event(payload)) + } +} + +fun BaseFragmentMixin.setupCustomDialogDisplayer( + viewModel: V, +) where V : BaseViewModel, V : CustomDialogDisplayer { + viewModel.showCustomDialog.observeEvent { + displayDialogFor(it) + } +} + +fun BaseFragmentMixin<*>.displayDialogFor(payload: CustomDialogDisplayer.Payload) { + dialog(providedContext, customStyle = payload.customStyle) { + setTitle(payload.title) + setMessage(payload.message) + + payload.okAction?.let { okAction -> + setPositiveButton(okAction.title) { _, _ -> + okAction.action() + } + } + + payload.cancelAction?.let { negativeAction -> + setNegativeButton(negativeAction.title) { _, _ -> + negativeAction.action() + } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/impl/NetworkStateProvider.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/NetworkStateProvider.kt new file mode 100644 index 0000000..2f138d2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/NetworkStateProvider.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.common.mixin.impl + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.mixin.api.NetworkStateMixin +import io.novasama.substrate_sdk_android.wsrpc.state.SocketStateMachine.State + +private const val ATTEMPT_THRESHOLD = 1 + +// TODO connection status +class NetworkStateProvider : NetworkStateMixin { + + override val showConnectingBarLiveData = /* observe().flatMapLatest(SocketService::networkStateFlow) + .map { state -> + val attempts = stateAsAttempting(state) + + attempts != null && attempts > ATTEMPT_THRESHOLD + } + .distinctUntilChanged() + .asLiveData()*/ MutableLiveData( + false + ) + + private fun stateAsAttempting(state: State): Int? { + return when (state) { + is State.Connecting -> state.attempt + is State.WaitingForReconnect -> state.attempt + else -> null + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/impl/RetriableUi.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/RetriableUi.kt new file mode 100644 index 0000000..11ff5ee --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/RetriableUi.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.mixin.impl + +import android.content.Context +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.view.dialog.retryDialog + +fun BaseFragmentMixin<*>.observeRetries( + retriable: Retriable, + context: Context = fragment.requireContext(), +) { + with(retriable) { + retryEvent.observeEvent { + retryDialog( + context = context, + onRetry = it.onRetry, + onCancel = it.onCancel + ) { + setTitle(it.title) + setMessage(it.message) + } + } + } +} + +fun BaseFragmentMixin.observeRetries( + viewModel: T, + context: Context = fragment.requireContext(), +) where T : BaseViewModel, T : Retriable { + observeRetries(retriable = viewModel, context) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/impl/ValidatableUi.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/ValidatableUi.kt new file mode 100644 index 0000000..639fc50 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/impl/ValidatableUi.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.common.mixin.impl + +import android.content.Context +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.api.ValidationFailureUi +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.view.dialog.errorDialog +import io.novafoundation.nova.common.view.dialog.warningDialog + +fun BaseFragmentMixin<*>.observeValidations( + viewModel: Validatable, + dialogContext: Context = providedContext +) { + viewModel.validationFailureEvent.observeEvent { + when (it) { + is ValidationFailureUi.Default -> { + val level = it.level + + when { + level >= DefaultFailureLevel.ERROR -> errorDialog(dialogContext) { + setTitle(it.title) + setMessage(it.message) + } + level >= DefaultFailureLevel.WARNING -> warningDialog( + context = dialogContext, + onPositiveClick = it.confirmWarning + ) { + setTitle(it.title) + setMessage(it.message) + } + } + } + is ValidationFailureUi.Custom -> displayDialogFor(it.payload) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/mixin/restrictions/RestrictionCheckMixin.kt b/common/src/main/java/io/novafoundation/nova/common/mixin/restrictions/RestrictionCheckMixin.kt new file mode 100644 index 0000000..972753a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/mixin/restrictions/RestrictionCheckMixin.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.mixin.restrictions + +interface RestrictionCheckMixin { + + // TODO: potentially may add a payload + suspend fun isRestricted(): Boolean + + // TODO: potentially may add a payload + suspend fun checkRestrictionAndDo(action: () -> Unit) +} + +suspend fun RestrictionCheckMixin.isAllowed() = !isRestricted() diff --git a/common/src/main/java/io/novafoundation/nova/common/navigation/DelayedNavigation.kt b/common/src/main/java/io/novafoundation/nova/common/navigation/DelayedNavigation.kt new file mode 100644 index 0000000..5a36928 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/navigation/DelayedNavigation.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.navigation + +import android.os.Parcelable + +interface DelayedNavigation : Parcelable diff --git a/common/src/main/java/io/novafoundation/nova/common/navigation/DelayedNavigationRouter.kt b/common/src/main/java/io/novafoundation/nova/common/navigation/DelayedNavigationRouter.kt new file mode 100644 index 0000000..062ff07 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/navigation/DelayedNavigationRouter.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.common.navigation + +interface DelayedNavigationRouter { + + fun runDelayedNavigation(delayedNavigation: DelayedNavigation) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/navigation/InterScreenCommunicator.kt b/common/src/main/java/io/novafoundation/nova/common/navigation/InterScreenCommunicator.kt new file mode 100644 index 0000000..2bc745c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/navigation/InterScreenCommunicator.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.navigation + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +interface InterScreenCommunicator : InterScreenRequester, InterScreenResponder + +interface InterScreenRequester { + + val latestResponse: O? + + val responseFlow: Flow + + fun openRequest(request: I) +} + +interface InterScreenResponder { + + val lastInput: I? + + val lastState: O? + + fun respond(response: O) +} + +fun InterScreenResponder.requireLastInput(): I { + return requireNotNull(lastInput) { + "No input is set" + } +} + +fun InterScreenRequester.openRequest() = openRequest(Unit) + +fun InterScreenResponder<*, Unit>.respond() = respond(Unit) + +suspend fun InterScreenRequester.awaitResponse(request: I): O { + val responseFlow = responseFlow + + openRequest(request) + + return responseFlow.first() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/navigation/PendingNavigationAction.kt b/common/src/main/java/io/novafoundation/nova/common/navigation/PendingNavigationAction.kt new file mode 100644 index 0000000..2fb91c2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/navigation/PendingNavigationAction.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.common.navigation + +typealias PendingNavigationAction = (ROUTER) -> Unit diff --git a/common/src/main/java/io/novafoundation/nova/common/navigation/ReturnableRouter.kt b/common/src/main/java/io/novafoundation/nova/common/navigation/ReturnableRouter.kt new file mode 100644 index 0000000..3e068f3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/navigation/ReturnableRouter.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.common.navigation + +interface ReturnableRouter { + + fun back() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/navigation/SecureRouter.kt b/common/src/main/java/io/novafoundation/nova/common/navigation/SecureRouter.kt new file mode 100644 index 0000000..29e38f0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/navigation/SecureRouter.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.common.navigation + +@Retention(AnnotationRetention.SOURCE) +annotation class PinRequired + +interface SecureRouter { + + fun withPinCodeCheckRequired( + delayedNavigation: DelayedNavigation, + createMode: Boolean = false, + pinCodeTitleRes: Int? = null + ) + + fun openAfterPinCode(delayedNavigation: DelayedNavigation) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/AddressExt.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/AddressExt.kt new file mode 100644 index 0000000..7f88758 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/AddressExt.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.common.presentation + +import io.novafoundation.nova.common.utils.ellipsizeMiddle + +fun String.ellipsizeAddress(): String { + return ellipsizeMiddle(6).toString() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/AssetIconProvider.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/AssetIconProvider.kt new file mode 100644 index 0000000..478a8ef --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/AssetIconProvider.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.common.presentation + +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.asIcon +import io.novafoundation.nova.common.utils.images.asUrlIcon + +interface AssetIconProvider { + + companion object; + + fun getAssetIcon(iconName: String): Icon + + fun getAssetIcon(iconName: String, iconMode: AssetIconMode): Icon +} + +class RealAssetIconProvider( + private val assetsIconModeRepository: AssetsIconModeRepository, + private val coloredBaseUrl: String, + private val whiteBaseUrl: String +) : AssetIconProvider { + + override fun getAssetIcon(iconName: String): Icon { + return getAssetIcon(iconName, assetsIconModeRepository.getIconMode()) + } + + override fun getAssetIcon(iconName: String, iconMode: AssetIconMode): Icon { + // If iconName is already a full URL, use it directly + val iconUrl = if (iconName.startsWith("http://") || iconName.startsWith("https://")) { + iconName + } else { + when (iconMode) { + AssetIconMode.COLORED -> "$coloredBaseUrl/$iconName" + AssetIconMode.WHITE -> "$whiteBaseUrl/$iconName" + } + } + + return iconUrl.asUrlIcon() + } +} + +val AssetIconProvider.Companion.fallbackIcon: Icon + get() = R.drawable.ic_pezkuwi.asIcon() + +fun AssetIconProvider.getAssetIconOrFallback( + iconName: String?, + fallback: Icon = AssetIconProvider.fallbackIcon +): Icon { + return iconName?.let { getAssetIcon(it) } ?: fallback +} + +fun AssetIconProvider.getAssetIconOrFallback( + iconName: String?, + iconMode: AssetIconMode, + fallback: Icon = AssetIconProvider.fallbackIcon +): Icon { + return iconName?.let { getAssetIcon(it, iconMode) } ?: fallback +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/ColoredDrawable.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/ColoredDrawable.kt new file mode 100644 index 0000000..3e55bb1 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/ColoredDrawable.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.presentation + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes + +data class ColoredDrawable( + @DrawableRes val drawableRes: Int, + @ColorRes val iconColor: Int +) diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/ColoredText.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/ColoredText.kt new file mode 100644 index 0000000..89dc088 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/ColoredText.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.common.presentation + +import android.widget.TextView +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setTextColorRes + +data class ColoredText( + val text: CharSequence, + @ColorRes val colorRes: Int, +) + +fun TextView.setColoredText(coloredText: ColoredText) { + text = coloredText.text + setTextColorRes(coloredText.colorRes) +} + +fun TextView.setColoredTextOrHide(coloredText: ColoredText?) = letOrHide(coloredText, ::setColoredText) diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/CopierBottomSheet.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/CopierBottomSheet.kt new file mode 100644 index 0000000..7ba06f4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/CopierBottomSheet.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.common.presentation + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.widget.Toast +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.BottomSheeetCopierBinding +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.utils.shareText +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem + +class CopierBottomSheet( + context: Context, + private val payload: CopyTextLauncher.Payload +) : FixedListBottomSheet( + context, + viewConfiguration = ViewConfiguration( + configurationBinder = BottomSheeetCopierBinding.inflate(LayoutInflater.from(context)), + title = { configurationBinder.copierValue }, + container = { configurationBinder.copierContainer } + ) +) { + + val clipboardManager: ClipboardManager by lazy { + FeatureUtils.getCommonApi(context).provideClipboardManager() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTitle(payload.title) + textItem(R.drawable.ic_copy_outline, payload.copyButtonName, false) { + // TODO: We'd like to have it in mixin or view model + clipboardManager.addToClipboard(payload.textToCopy) + Toast.makeText(context, context.getString(R.string.common_copied), Toast.LENGTH_LONG) + .show() + } + textItem(R.drawable.ic_share_outline, payload.shareButtonName, false) { + // TODO: We'd like to have it in mixin or view model + context.shareText(payload.textToCopy) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/DescriptiveButtonState.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/DescriptiveButtonState.kt new file mode 100644 index 0000000..dcf258a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/DescriptiveButtonState.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.common.presentation + +sealed class DescriptiveButtonState { + + data class Enabled(val action: String) : DescriptiveButtonState() + + data class Disabled(val reason: String) : DescriptiveButtonState() + + object Loading : DescriptiveButtonState() + + object Gone : DescriptiveButtonState() + + object Invisible : DescriptiveButtonState() +} + +fun DescriptiveButtonState.textOrNull(): String? { + return when (this) { + is DescriptiveButtonState.Enabled -> action + is DescriptiveButtonState.Disabled -> reason + DescriptiveButtonState.Gone, + DescriptiveButtonState.Invisible, + DescriptiveButtonState.Loading -> null + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/LoadingState.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/LoadingState.kt new file mode 100644 index 0000000..cb834f3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/LoadingState.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.common.presentation + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +@Deprecated("Use ExtendedLoadingState instead since it offers support for errors too") +sealed class LoadingState { + + class Loading : LoadingState() + + data class Loaded(val data: T) : LoadingState() +} + +interface LoadingView { + + fun showData(data: T) + + fun showLoading() +} + +fun LoadingView.showLoadingState(loadingState: LoadingState) { + when (loadingState) { + is LoadingState.Loaded -> showData(loadingState.data) + is LoadingState.Loading -> showLoading() + } +} + +fun LoadingView.showLoadingState(loadingState: ExtendedLoadingState) { + when (loadingState) { + is ExtendedLoadingState.Loaded -> showData(loadingState.data) + is ExtendedLoadingState.Loading, is ExtendedLoadingState.Error -> showLoading() + } +} + +@Suppress("UNCHECKED_CAST") +inline fun LoadingState.map(mapper: (T) -> R): LoadingState { + return when (this) { + is LoadingState.Loading<*> -> this as LoadingState.Loading + is LoadingState.Loaded -> LoadingState.Loaded(mapper(data)) + } +} + +@Suppress("UNCHECKED_CAST") +inline fun LoadingState.flatMap(mapper: (T) -> LoadingState): LoadingState { + return when (this) { + is LoadingState.Loading<*> -> this as LoadingState.Loading + is LoadingState.Loaded -> mapper(data) + } +} + +@Suppress("UNCHECKED_CAST") +inline fun Flow>.mapLoading(crossinline mapper: suspend (T) -> V): Flow> { + return map { loadingState -> loadingState.map { mapper(it) } } +} + +fun T?.toLoadingState(): LoadingState = if (this == null) LoadingState.Loading() else LoadingState.Loaded(this) + +val LoadingState.dataOrNull: T? + get() = when (this) { + is LoadingState.Loaded -> this.data + else -> null + } + +val LoadingState<*>.isLoading: Boolean + get() = this is LoadingState.Loading + +suspend inline fun Flow>.firstLoaded(): T = first { it.dataOrNull != null }.dataOrNull as T diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/SearchState.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/SearchState.kt new file mode 100644 index 0000000..b2f7c22 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/SearchState.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.presentation + +sealed class SearchState { + + object NoInput : SearchState() + + object Loading : SearchState() + + object NoResults : SearchState() + + class Success(val data: List, val headerTitle: String) : SearchState() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/masking/MaskableDrawable.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/MaskableDrawable.kt new file mode 100644 index 0000000..28c9368 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/MaskableDrawable.kt @@ -0,0 +1,150 @@ +package io.novafoundation.nova.common.presentation.masking + +import android.content.res.Resources +import android.content.res.Resources.Theme +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.util.TypedValue +import androidx.annotation.ColorInt +import io.novafoundation.nova.common.R + +class MaskableDrawable : Drawable() { + + companion object { + private const val DEFAULT_DOT_SIZE_DP = 6f + private const val DEFAULT_DOT_SPACING_DP = 4f + + private fun dpToPx(dp: Float, res: Resources): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, res.displayMetrics) + } + + enum class Gravity(val v: Int) { + START(0), CENTER(1), END(2); + + companion object { + fun fromInt(v: Int) = when (v) { + 0 -> START; 1 -> CENTER; else -> END + } + } + } + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + + private var dotSizePx: Float = dpToPx(DEFAULT_DOT_SIZE_DP, Resources.getSystem()) + private var dotSpacingPx: Float = dpToPx(DEFAULT_DOT_SPACING_DP, Resources.getSystem()) + private var dotCount: Int = 4 + private var gravity: Gravity = Gravity.CENTER + + @ColorInt + private var dotColorParam: Int = Color.BLACK + set(value) { + field = value + paint.color = value + invalidateSelf() + } + + fun setDotSizePx(sizePx: Float) { + if (sizePx != dotSizePx) { + dotSizePx = sizePx; invalidateSelf() + } + } + + fun setDotSpacingPx(spacingPx: Float) { + if (spacingPx != dotSpacingPx) { + dotSpacingPx = spacingPx; invalidateSelf() + } + } + + fun setDotCount(count: Int) { + val c = count.coerceAtLeast(1); if (c != dotCount) { + dotCount = c; invalidateSelf() + } + } + + fun setDotColor(@ColorInt color: Int) { + dotColorParam = color + } + + fun setGravity(g: Gravity) { + if (g != gravity) { + gravity = g; invalidateSelf() + } + } + + fun setDotSizeDp(dp: Float, res: Resources) = setDotSizePx(dpToPx(dp, res)) + fun setDotSpacingDp(dp: Float, res: Resources) = setDotSpacingPx(dpToPx(dp, res)) + + override fun draw(canvas: Canvas) { + if (dotCount <= 0 || dotSizePx <= 0f) return + + val b = bounds + val radius = dotSizePx * 0.5f + val contentWidth = dotCount * dotSizePx + (dotCount - 1) * dotSpacingPx + + val startX = when (gravity) { + Gravity.START -> b.left.toFloat() + radius + Gravity.CENTER -> b.left + (b.width() - contentWidth) * 0.5f + radius + Gravity.END -> b.right - contentWidth + radius + } + + val cy = b.exactCenterY() + var cx = startX + for (i in 0 until dotCount) { + canvas.drawCircle(cx, cy, radius, paint) + cx += dotSizePx + dotSpacingPx + } + } + + override fun setAlpha(alpha: Int) { + val a = alpha.coerceIn(0, 255) + if (paint.alpha != a) { + paint.alpha = a + invalidateSelf() + } + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + invalidateSelf() + } + + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun getIntrinsicHeight(): Int = dotSizePx.toInt() + override fun getIntrinsicWidth(): Int = (dotCount * dotSizePx + (dotCount - 1) * dotSpacingPx).toInt() + + override fun inflate(r: Resources, parser: org.xmlpull.v1.XmlPullParser, attrs: AttributeSet, theme: Theme?) { + super.inflate(r, parser, attrs, theme) + val a = theme?.obtainStyledAttributes(attrs, R.styleable.MaskableDrawable, 0, 0) + ?: r.obtainAttributes(attrs, R.styleable.MaskableDrawable) + try { + dotSizePx = a.getDimension( + R.styleable.MaskableDrawable_md_dotSize, + dpToPx(DEFAULT_DOT_SIZE_DP, r) + ) + dotSpacingPx = a.getDimension( + R.styleable.MaskableDrawable_md_dotSpacing, + dpToPx(DEFAULT_DOT_SPACING_DP, r) + ) + dotColorParam = a.getColor( + R.styleable.MaskableDrawable_md_dotColor, + Color.BLACK + ) + dotCount = a.getInt(R.styleable.MaskableDrawable_md_dotCount, 4).coerceAtLeast(1) + gravity = Gravity.fromInt(a.getInt(R.styleable.MaskableDrawable_md_gravity, Gravity.CENTER.v)) + setBounds(0, 0, dotSizePx.toInt() * dotCount + (dotCount - 1) * dotSpacingPx.toInt(), dotSizePx.toInt()) + } finally { + a.recycle() + } + paint.color = dotColorParam + } + + override fun setTint(color: Int) { + setDotColor(color) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/masking/MaskableModel.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/MaskableModel.kt new file mode 100644 index 0000000..f9e2a87 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/MaskableModel.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.common.presentation.masking + +import io.novafoundation.nova.common.data.model.MaskingMode + +sealed interface MaskableModel { + data object Hidden : MaskableModel + data class Unmasked(val value: T) : MaskableModel +} + +inline fun MaskingMode.toMaskableModel(valueReceiver: () -> T): MaskableModel { + return when (this) { + MaskingMode.ENABLED -> MaskableModel.Hidden + MaskingMode.DISABLED -> MaskableModel.Unmasked(valueReceiver()) + } +} + +fun MaskableModel.map(mapper: (T) -> R): MaskableModel = when (this) { + is MaskableModel.Hidden -> MaskableModel.Hidden + is MaskableModel.Unmasked -> MaskableModel.Unmasked(mapper(value)) +} + +fun MaskableModel.getUnmaskedOrElse(mapper: () -> T): T = when (this) { + is MaskableModel.Hidden -> mapper() + is MaskableModel.Unmasked -> value +} + +fun MaskableModel.dataOrNull(): T? = when (this) { + is MaskableModel.Hidden -> null + is MaskableModel.Unmasked -> value +} + +fun MaskableModel.onHidden(onHidden: () -> Unit): MaskableModel { + if (this is MaskableModel.Hidden) { + onHidden() + } + return this +} + +fun MaskableModel.onUnmasked(onUnmasked: (T) -> Unit): MaskableModel { + if (this is MaskableModel.Unmasked) { + onUnmasked(this.value) + } + return this +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/masking/MaskingExt.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/MaskingExt.kt new file mode 100644 index 0000000..f634bc8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/MaskingExt.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.presentation.masking + +import android.graphics.drawable.Drawable +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.drawableText +import io.novafoundation.nova.common.utils.removeCompoundDrawables +import io.novafoundation.nova.common.utils.setCompoundDrawables + +private class MaskingCache(val drawables: Array) + +fun TextView.setMaskableText( + maskableText: MaskableModel, + @DrawableRes maskDrawableRes: Int = R.drawable.mask_dots_small +) { + maskableText.onHidden { + val drawable = ContextCompat.getDrawable(context, maskDrawableRes)!! + text = drawableText(drawable, extendToLineHeight = true) + + // Save some state to restore later + setTag(R.id.tag_mask_cache, MaskingCache(compoundDrawables)) + removeCompoundDrawables() + }.onUnmasked { + text = it + + // Restore drawables state + val maskingCache = getTag(R.id.tag_mask_cache) as? MaskingCache + maskingCache?.let { setCompoundDrawables(maskingCache.drawables) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/masking/formatter/MaskableValueFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/formatter/MaskableValueFormatter.kt new file mode 100644 index 0000000..bc81663 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/formatter/MaskableValueFormatter.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.common.presentation.masking.formatter + +import io.novafoundation.nova.common.data.model.MaskingMode +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.presentation.masking.toMaskableModel + +interface MaskableValueFormatter { + + // TODO: valueReceiver should be suspend to support suspendable logic + fun format(valueReceiver: () -> T): MaskableModel +} + +class MaskableValueFormatterFactory { + fun create(maskingMode: MaskingMode): MaskableValueFormatter { + return RealMaskableValueFormatter(maskingMode) + } +} + +private class RealMaskableValueFormatter(private val maskingMode: MaskingMode) : MaskableValueFormatter { + + override fun format(valueReceiver: () -> T): MaskableModel { + return maskingMode.toMaskableModel(valueReceiver) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/masking/formatter/MaskableValueFormatterProvider.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/formatter/MaskableValueFormatterProvider.kt new file mode 100644 index 0000000..bceebec --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/masking/formatter/MaskableValueFormatterProvider.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.presentation.masking.formatter + +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class MaskableValueFormatterProvider( + private val maskableValueFormatterFactory: MaskableValueFormatterFactory, + private val maskingModeUseCase: MaskingModeUseCase +) { + + fun provideFormatter(): Flow { + return maskingModeUseCase.observeMaskingMode().map { + maskableValueFormatterFactory.create(it) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/scan/AlternatingDecoder.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/AlternatingDecoder.kt new file mode 100644 index 0000000..c639742 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/AlternatingDecoder.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.common.presentation.scan + +import com.google.zxing.BinaryBitmap +import com.google.zxing.LuminanceSource +import com.google.zxing.Reader +import com.google.zxing.common.HybridBinarizer +import com.journeyapps.barcodescanner.Decoder +import java.util.concurrent.atomic.AtomicInteger + +class AlternatingDecoder(reader: Reader) : Decoder(reader) { + + private var counter = AtomicInteger(0) + + override fun toBitmap(source: LuminanceSource): BinaryBitmap { + val nextCounter = counter.getAndIncrement() + + val updatedSource = if (nextCounter % 2 == 0) { + source + } else { + source.invert() + } + + return BinaryBitmap(HybridBinarizer(updatedSource)) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/scan/AlternatingDecoderFactory.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/AlternatingDecoderFactory.kt new file mode 100644 index 0000000..c01e9b2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/AlternatingDecoderFactory.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.common.presentation.scan + +import com.google.zxing.BarcodeFormat +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.journeyapps.barcodescanner.Decoder +import com.journeyapps.barcodescanner.DecoderFactory +import java.util.EnumMap + +class AlternatingDecoderFactory( + private val decodeFormats: Collection? = null, + private val hints: Map? = null, + private val characterSet: String? = null, +) : DecoderFactory { + + override fun createDecoder(baseHints: Map): Decoder { + val allHints: MutableMap = EnumMap(DecodeHintType::class.java) + + allHints.putAll(baseHints) + + hints?.let(allHints::putAll) + + decodeFormats?.let { + allHints[DecodeHintType.POSSIBLE_FORMATS] = decodeFormats + } + + characterSet?.let { + allHints[DecodeHintType.CHARACTER_SET] = characterSet + } + + val reader = MultiFormatReader().apply { setHints(allHints) } + + return AlternatingDecoder(reader) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/scan/ScanQrFragment.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/ScanQrFragment.kt new file mode 100644 index 0000000..addda6e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/ScanQrFragment.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.common.presentation.scan + +import android.view.WindowManager +import androidx.annotation.CallSuper +import androidx.viewbinding.ViewBinding +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.permissions.setupPermissionAsker + +abstract class ScanQrFragment : BaseFragment() { + + abstract val scanView: ScanView + + @CallSuper + override fun initViews() { + startScanning() + } + + @CallSuper + override fun subscribe(viewModel: V) { + viewModel.scanningAvailable.observe { scanningAvailable -> + if (scanningAvailable) { + scanView.resume() + } else { + scanView.pause() + } + } + + viewModel.resetScanningEvent.observeEvent { + startScanning() + } + + setupPermissionAsker(viewModel) + } + + override fun onStart() { + super.onStart() + + viewModel.onStart() + } + + override fun onResume() { + super.onResume() + + if (viewModel.scanningAvailable.value) { + scanView.resume() + } + + requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + override fun onPause() { + super.onPause() + + scanView.pause() + + requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + private fun startScanning() { + scanView.startDecoding { viewModel.onScanned(it) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/scan/ScanQrViewModel.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/ScanQrViewModel.kt new file mode 100644 index 0000000..e0381c4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/ScanQrViewModel.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.common.presentation.scan + +import android.Manifest +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.sendEvent +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +abstract class ScanQrViewModel( + private val permissionsAsker: PermissionsAsker.Presentation, +) : BaseViewModel(), PermissionsAsker by permissionsAsker { + + val scanningAvailable = MutableStateFlow(false) + + private val _resetScanningEvent = MutableLiveData>() + val resetScanningEvent: LiveData> = _resetScanningEvent + + protected abstract suspend fun scanned(result: String) + + fun onScanned(result: String) { + launch { + scanned(result) + } + } + + fun onStart() { + requirePermissions() + } + + // wait a bit until re-enabling scanner otherwise user might experience a lot of error messages shown due to fast scanning + protected suspend fun resetScanningThrottled() { + delay(1000) + + resetScanning() + } + + protected fun resetScanning() { + _resetScanningEvent.sendEvent() + } + + private fun requirePermissions() = launch { + val granted = permissionsAsker.requirePermissions(Manifest.permission.CAMERA) + + scanningAvailable.value = granted + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/presentation/scan/ScanView.kt b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/ScanView.kt new file mode 100644 index 0000000..a16bb37 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/presentation/scan/ScanView.kt @@ -0,0 +1,130 @@ +package io.novafoundation.nova.common.presentation.scan + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView +import com.google.zxing.BarcodeFormat +import com.google.zxing.ResultPoint +import com.journeyapps.barcodescanner.BarcodeCallback +import com.journeyapps.barcodescanner.BarcodeResult +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewScanBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.useAttributes + +class ScanView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + val binder = ViewScanBinding.inflate(inflater(), this) + + init { + setupDecoder() + + binder.viewScanViewFinder.setCameraPreview(binder.viewScanScanner) + + binder.viewScanViewFinder.onFinderRectChanges { + positionLabels(it) + } + + attrs?.let(::applyAttributes) + } + + val subtitle: TextView + get() = binder.viewScanSubtitle + + fun resume() { + binder.viewScanScanner.resume() + } + + fun pause() { + binder.viewScanScanner.pause() + } + + inline fun startDecoding(crossinline onScanned: (String) -> Unit) { + binder.viewScanScanner.decodeSingle(object : BarcodeCallback { + override fun barcodeResult(result: BarcodeResult) { + onScanned(result.toString()) + } + + override fun possibleResultPoints(resultPoints: MutableList?) {} + }) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + if (!changed) return + + binder.viewScanViewFinder.framingRect?.let { + positionLabels(it) + } + } + + fun setTitle(title: String) { + binder.viewScanTitle.text = title + } + + fun setSubtitle(subtitle: String) { + binder.viewScanSubtitle.text = subtitle + } + + private fun setupDecoder() { + binder.viewScanScanner.decoderFactory = AlternatingDecoderFactory( + decodeFormats = listOf(BarcodeFormat.QR_CODE), + hints = null, + characterSet = null, + ) + } + + private fun positionLabels(finderRect: Rect) { + binder.viewScanTitle.doIfHasText { positionTitle(finderRect) } + binder.viewScanSubtitle.doIfHasText { positionSubTitle(finderRect) } + } + + private inline fun TextView.doIfHasText(action: () -> Unit) { + if (text.isNotEmpty()) action() + } + + private fun positionTitle(finderRect: Rect) { + val rectTop = finderRect.top + + // how much finderRect offsets from center of the screen + half of textView height since it is originally centered itself + val requiredBottomMargin = height / 2 - rectTop + binder.viewScanTitle.height / 2 + + binder.viewScanTitle.layoutParams = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + gravity = Gravity.CENTER + setMargins(16.dp, 0, 16.dp, requiredBottomMargin + 24.dp) + } + binder.viewScanTitle.makeVisible() + } + + private fun positionSubTitle(finderRect: Rect) { + val rectBottom = finderRect.bottom + + // how much finderRect offsets from center of the screen + half of textView height since it is originally centered itself + val requiredTopMargin = rectBottom - height / 2 + binder.viewScanSubtitle.height / 2 + + binder.viewScanSubtitle.layoutParams = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + gravity = Gravity.CENTER + setMargins(16.dp, requiredTopMargin + 24.dp, 16.dp, 0) + } + binder.viewScanSubtitle.makeVisible() + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.ScanView) { typedArray -> + val title = typedArray.getString(R.styleable.ScanView_title) + title?.let(::setTitle) + + val subTitle = typedArray.getString(R.styleable.ScanView_subTitle) + subTitle?.let(::setSubtitle) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/resources/AppVersionProvider.kt b/common/src/main/java/io/novafoundation/nova/common/resources/AppVersionProvider.kt new file mode 100644 index 0000000..d7db778 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/resources/AppVersionProvider.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.common.resources + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build + +interface AppVersionProvider { + + val versionName: String +} + +internal class OSAppVersionProvider( + private val appContext: Context, +) : AppVersionProvider { + + override val versionName: String + get() { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.packageManager.getPackageInfo(appContext.packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + appContext.packageManager.getPackageInfo(appContext.packageName, 0) + } + return packageInfo.versionName!! + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/resources/ClipboardManager.kt b/common/src/main/java/io/novafoundation/nova/common/resources/ClipboardManager.kt new file mode 100644 index 0000000..bc1cb80 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/resources/ClipboardManager.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.common.resources + +import android.content.ClipData +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import android.content.ClipboardManager as NativeClipboardManager + +private const val DEFAULT_LABEL = "nova" + +class ClipboardManager( + private val clipboardManager: NativeClipboardManager +) { + + fun observePrimaryClip(): Flow = callbackFlow { + send(getTextOrNull()) + + val listener = NativeClipboardManager.OnPrimaryClipChangedListener { + trySend(getTextOrNull()) + } + + clipboardManager.addPrimaryClipChangedListener(listener) + + awaitClose { + clipboardManager.removePrimaryClipChangedListener(listener) + } + } + + fun getTextOrNull(): String? { + return with(clipboardManager) { + if (!hasPrimaryClip()) { + null + } else { + val item: ClipData.Item = primaryClip!!.getItemAt(0) + + item.text?.toString() + } + } + } + + fun addToClipboard(text: String, label: String = DEFAULT_LABEL) { + val clip = ClipData.newPlainText(label, text) + clipboardManager.setPrimaryClip(clip) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/resources/ContextManager.kt b/common/src/main/java/io/novafoundation/nova/common/resources/ContextManager.kt new file mode 100644 index 0000000..01aeccf --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/resources/ContextManager.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.common.resources + +import android.app.Activity +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import io.novafoundation.nova.common.data.storage.PreferencesImpl +import io.novafoundation.nova.common.di.modules.SHARED_PREFERENCES_FILE +import io.novafoundation.nova.common.utils.SingletonHolder +import java.util.Locale +import javax.inject.Singleton + +@Singleton +class ContextManager private constructor( + private var context: Context, + private val languagesHolder: LanguagesHolder +) { + + private val LANGUAGE_PART_INDEX = 0 + private val COUNTRY_PART_INDEX = 1 + + private var activity: AppCompatActivity? = null + + companion object : SingletonHolder(::ContextManager) + + fun getApplicationContext(): Context { + return context + } + + fun getActivity(): AppCompatActivity? { + return activity + } + + fun attachActivity(activity: AppCompatActivity) { + this.activity = activity + } + + fun detachActivity() { + this.activity = null + } + + fun setLocale(context: Context): Context { + return updateResources(context) + } + + fun getLocale(): Locale { + return if (Locale.getDefault().displayLanguage != "ba") Locale.getDefault() else Locale("ru") + } + + private fun updateResources(context: Context): Context { + val prefs = PreferencesImpl(context.getSharedPreferences(SHARED_PREFERENCES_FILE, Context.MODE_PRIVATE)) + + val currentLanguage = prefs.getCurrentLanguage() + val currentLanguageCode = if (currentLanguage == null) { + val currentLocale = Locale.getDefault() + if (languagesHolder.getLanguages().map { it.iso639Code }.contains(currentLocale.language)) { + currentLocale.language + } else { + languagesHolder.getDefaultLanguage().iso639Code + } + } else { + currentLanguage.iso639Code + } + + prefs.saveCurrentLanguage(currentLanguageCode) + + val locale = mapLanguageToLocale(currentLanguageCode) + Locale.setDefault(locale) + + val configuration = context.resources.configuration + configuration.setLocale(locale) + configuration.setLayoutDirection(locale) + + this.context = context.createConfigurationContext(configuration) + + return this.context + } + + private fun mapLanguageToLocale(language: String): Locale { + val codes = language.split("_") + + return if (hasCountryCode(codes)) { + Locale(codes[LANGUAGE_PART_INDEX], codes[COUNTRY_PART_INDEX]) + } else { + Locale(language) + } + } + + private fun hasCountryCode(codes: List) = codes.size != 1 +} + +fun ContextManager.requireActivity(): Activity { + return requireNotNull(getActivity()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/resources/LanguagesHolder.kt b/common/src/main/java/io/novafoundation/nova/common/resources/LanguagesHolder.kt new file mode 100644 index 0000000..de3644d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/resources/LanguagesHolder.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.common.resources + +import io.novafoundation.nova.core.model.Language +import javax.inject.Singleton + +@Singleton +class LanguagesHolder { + + companion object { + + private val KURDISH = Language("ku", "KURDISH") + private val ENGLISH = Language("en", "ENGLISH") + private val CHINESE = Language("zh", "CHINESE") + private val ITALIAN = Language("it", "ITALIAN") + private val PORTUGUESE = Language("pt", "PORTUGUESE") + private val RUSSIAN = Language("ru", "RUSSIAN") + private val SPANISH = Language("es", "SPANISH") + private val TURKISH = Language("tr", "TURKISH") + private val FRENCH = Language("fr", "FRENCH") + private val INDONESIAN = Language("in", "INDONESIAN") + private val POLISH = Language("pl", "POLISH") + private val JAPANESE = Language("ja", "JAPANESE") + private val VIETNAMESE = Language("vi", "VIETNAMESE") + private val KOREAN = Language("ko", "KOREAN") + private val HUNGARIAN = Language("hu", "HUNGARIAN") + } + + fun getDefaultLanguage(): Language { + return ENGLISH + } + + fun getLanguages(): List { + val defaultLanguage = listOf(getDefaultLanguage()) + val kurdish = listOf(KURDISH) + val otherLanguages = listOf( + CHINESE, + FRENCH, + HUNGARIAN, + INDONESIAN, + ITALIAN, + JAPANESE, + KOREAN, + POLISH, + PORTUGUESE, + RUSSIAN, + SPANISH, + TURKISH, + VIETNAMESE + ) + + return defaultLanguage + kurdish + otherLanguages.sortedBy { it.name } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/resources/ResourceManager.kt b/common/src/main/java/io/novafoundation/nova/common/resources/ResourceManager.kt new file mode 100644 index 0000000..540e4a4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/resources/ResourceManager.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.common.resources + +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.annotation.FontRes +import androidx.annotation.RawRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.formatting.format +import kotlin.time.Duration + +interface ResourceManager { + + fun loadRawString(@RawRes res: Int): String + + fun getString(res: Int): String + + fun getString(res: Int, vararg arguments: Any): String + + fun getText(res: Int): CharSequence + + fun getColor(res: Int): Int + + fun getQuantityString(id: Int, quantity: Int): String + fun getQuantityString(id: Int, quantity: Int, vararg arguments: Any): String + + fun measureInPx(dp: Int): Int + + fun formatDateTime(timestamp: Long): String + fun formatDate(timestamp: Long): String + fun formatDuration(elapsedTime: Long): String + + fun formatDuration(duration: Duration, estimated: Boolean = true): String + + fun formatTime(timestamp: Long): String + + fun getDrawable(@DrawableRes id: Int): Drawable + + fun getDimensionPixelSize(id: Int): Int + + fun getFont(@FontRes fontRes: Int): Typeface? +} + +fun ResourceManager.formatTimeLeft(elapsedTimeInMillis: Long): String { + val durationFormatted = formatDuration(elapsedTimeInMillis) + + return getString(R.string.common_left, durationFormatted) +} + +fun ResourceManager.formatListPreview( + elements: List, + maxPreviewItems: Int = 1, + @StringRes zeroLabel: Int? = R.string.common_none, +): String { + return when { + elements.isEmpty() -> zeroLabel?.let(::getString).orEmpty() + elements.size <= maxPreviewItems -> elements.joinPreviewItems(maxPreviewItems) + else -> { + val previewItems = elements.joinPreviewItems(maxPreviewItems) + val remainingCount = elements.size - maxPreviewItems + + getString(R.string.common_element_and_more_format, previewItems, remainingCount.format()) + } + } +} + +fun ResourceManager.formatBooleanToState(isEnabled: Boolean): String { + return if (isEnabled) { + getString(R.string.common_on) + } else { + getString(R.string.common_off) + } +} + +private fun List.joinPreviewItems(previewItemsCount: Int): String = take(previewItemsCount).joinToString(separator = ", ") diff --git a/common/src/main/java/io/novafoundation/nova/common/resources/ResourceManagerImpl.kt b/common/src/main/java/io/novafoundation/nova/common/resources/ResourceManagerImpl.kt new file mode 100644 index 0000000..261caf9 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/resources/ResourceManagerImpl.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.common.resources + +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.text.format.DateUtils +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.utils.daysFromMillis +import io.novafoundation.nova.common.utils.formatting.baseDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.EstimatedDurationFormatter +import io.novafoundation.nova.common.utils.formatting.formatDateTime +import io.novafoundation.nova.common.utils.getDrawableCompat +import io.novafoundation.nova.common.utils.readText +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@ApplicationScope +class ResourceManagerImpl( + private val contextManager: ContextManager +) : ResourceManager { + + private val defaultDurationFormatter by lazy { baseDurationFormatter(contextManager.getApplicationContext()) } + + private val estimatedDurationFormatter by lazy { EstimatedDurationFormatter(defaultDurationFormatter) } + + override fun loadRawString(res: Int): String { + return contextManager.getApplicationContext().resources + .openRawResource(res) + .readText() + } + + override fun getString(res: Int): String { + return contextManager.getApplicationContext().getString(res) + } + + override fun getString(res: Int, vararg arguments: Any): String { + return contextManager.getApplicationContext().getString(res, *arguments) + } + + override fun getText(res: Int): CharSequence { + return contextManager.getApplicationContext().getText(res) + } + + override fun getColor(res: Int): Int { + contextManager.getApplicationContext().resources + return ContextCompat.getColor(contextManager.getApplicationContext(), res) + } + + override fun getQuantityString(id: Int, quantity: Int): String { + return contextManager.getApplicationContext().resources.getQuantityString(id, quantity) + } + + override fun getQuantityString(id: Int, quantity: Int, vararg arguments: Any): String { + return contextManager.getApplicationContext().resources.getQuantityString(id, quantity, *arguments) + } + + override fun measureInPx(dp: Int): Int { + val px = contextManager.getApplicationContext().resources.displayMetrics.density * dp + + return px.toInt() + } + + override fun formatDateTime(timestamp: Long): String { + return timestamp.formatDateTime().toString() + } + + override fun formatDate(timestamp: Long): String { + return DateUtils.formatDateTime( + contextManager.getApplicationContext(), + timestamp, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR or DateUtils.FORMAT_ABBREV_MONTH + ) + } + + override fun formatTime(timestamp: Long): String { + return DateUtils.formatDateTime(contextManager.getApplicationContext(), timestamp, DateUtils.FORMAT_SHOW_TIME) + } + + override fun formatDuration(elapsedTime: Long): String { + val inDays = elapsedTime.daysFromMillis().toInt() + + return when { + inDays > 0 -> getQuantityString(R.plurals.staking_main_lockup_period_value, inDays, inDays) + else -> { + val inSeconds = elapsedTime.milliseconds.inWholeSeconds + + DateUtils.formatElapsedTime(inSeconds) + } + } + } + + override fun formatDuration(duration: Duration, estimated: Boolean): String { + return if (estimated) { + estimatedDurationFormatter.format(duration) + } else { + defaultDurationFormatter.format(duration) + } + } + + override fun getDrawable(id: Int): Drawable { + return contextManager.getApplicationContext().getDrawableCompat(id) + } + + override fun getDimensionPixelSize(id: Int): Int { + return contextManager.getApplicationContext().resources.getDimensionPixelSize(id) + } + + override fun getFont(fontRes: Int): Typeface? { + return ResourcesCompat.getFont(contextManager.getApplicationContext(), fontRes) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/sequrity/SafeModeService.kt b/common/src/main/java/io/novafoundation/nova/common/sequrity/SafeModeService.kt new file mode 100644 index 0000000..8626802 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/sequrity/SafeModeService.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.common.sequrity + +import android.view.WindowManager +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.resources.ContextManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +interface SafeModeService { + fun isSafeModeEnabled(): Boolean + + fun safeModeStatusFlow(): Flow + + fun applySafeModeIfEnabled() + + fun setSafeMode(enable: Boolean) + + fun toggleSafeMode() +} + +class RealSafeModeService( + private val contextManager: ContextManager, + private val preferences: Preferences +) : SafeModeService { + + companion object { + private const val PREF_SAFE_MODE_STATUS = "safe_mode_status" + } + + private val safeModeStatus = MutableStateFlow(getSafeModeStatus()) + + override fun isSafeModeEnabled(): Boolean { + return safeModeStatus.value + } + + override fun safeModeStatusFlow(): Flow { + return safeModeStatus + } + + override fun applySafeModeIfEnabled() { + if (safeModeStatus.value) { + contextManager.getActivity()?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + override fun setSafeMode(enable: Boolean) { + preferences.putBoolean(PREF_SAFE_MODE_STATUS, enable) + + if (enable) { + contextManager.getActivity()?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + contextManager.getActivity()?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + + safeModeStatus.value = enable + } + + override fun toggleSafeMode() { + setSafeMode(!safeModeStatus.value) + } + + private fun getSafeModeStatus(): Boolean { + return preferences.getBoolean(PREF_SAFE_MODE_STATUS, false) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricMessageMapper.kt b/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricMessageMapper.kt new file mode 100644 index 0000000..79df5b4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricMessageMapper.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.sequrity.biometry + +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.resources.ResourceManager + +fun mapBiometricErrors(resourceManager: ResourceManager, biometricResponse: BiometricResponse): String? { + return when (biometricResponse) { + is BiometricResponse.Fail -> resourceManager.getString(R.string.pincode_biometric_error) + is BiometricResponse.Error -> if (!biometricResponse.cancelledByUser) biometricResponse.message else null + else -> null + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricPromptFactory.kt b/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricPromptFactory.kt new file mode 100644 index 0000000..a0bb58a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricPromptFactory.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.sequrity.biometry + +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.Fragment +import java.util.concurrent.Executor + +class BiometricPromptFactory(private val fragment: Fragment, private val executor: Executor) { + + fun create( + callback: BiometricPrompt.AuthenticationCallback + ): BiometricPrompt { + return BiometricPrompt(fragment, executor, callback) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricService.kt b/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricService.kt new file mode 100644 index 0000000..efd17d4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricService.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.common.sequrity.biometry + +import kotlinx.coroutines.flow.Flow + +sealed interface BiometricResponse { + + object Success : BiometricResponse + + object Fail : BiometricResponse + + object NotReady : BiometricResponse + + class Error(val cancelledByUser: Boolean, val message: String) : BiometricResponse +} + +interface BiometricService { + + val biometryServiceResponseFlow: Flow + + fun isEnabled(): Boolean + + fun isEnabledFlow(): Flow + + suspend fun toggle() + + fun requestBiometric() + + fun isBiometricReady(): Boolean + + fun cancel() + + fun enableBiometry(enable: Boolean) + + fun refreshBiometryState() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricServiceFactory.kt b/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricServiceFactory.kt new file mode 100644 index 0000000..d54d6c7 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/sequrity/biometry/BiometricServiceFactory.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.sequrity.biometry + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt + +interface BiometricServiceFactory { + + fun create( + biometricManager: BiometricManager, + biometricPromptFactory: BiometricPromptFactory, + promptInfo: BiometricPrompt.PromptInfo + ): BiometricService +} diff --git a/common/src/main/java/io/novafoundation/nova/common/sequrity/verification/PinCodeTwoFactorVerificationCommunicator.kt b/common/src/main/java/io/novafoundation/nova/common/sequrity/verification/PinCodeTwoFactorVerificationCommunicator.kt new file mode 100644 index 0000000..492186f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/sequrity/verification/PinCodeTwoFactorVerificationCommunicator.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.sequrity.verification + +import io.novafoundation.nova.common.navigation.InterScreenRequester +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationResult +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationRequester.Request +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationResponder.Response +import kotlinx.parcelize.Parcelize + +interface PinCodeTwoFactorVerificationRequester : InterScreenRequester { + + @Parcelize + class Request(val useBiometryIfEnabled: Boolean) : Parcelable +} + +interface PinCodeTwoFactorVerificationResponder : InterScreenResponder { + + @Parcelize + class Response(val result: TwoFactorVerificationResult) : Parcelable +} + +interface PinCodeTwoFactorVerificationCommunicator : PinCodeTwoFactorVerificationRequester, PinCodeTwoFactorVerificationResponder diff --git a/common/src/main/java/io/novafoundation/nova/common/sequrity/verification/PinCodeTwoFactorVerificationExecutor.kt b/common/src/main/java/io/novafoundation/nova/common/sequrity/verification/PinCodeTwoFactorVerificationExecutor.kt new file mode 100644 index 0000000..f746045 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/sequrity/verification/PinCodeTwoFactorVerificationExecutor.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.common.sequrity.verification + +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationResult +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationExecutor +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationRequester.Request +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationResponder.Response +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +class PinCodeTwoFactorVerificationExecutor( + private val interScreenCommunicator: PinCodeTwoFactorVerificationCommunicator +) : TwoFactorVerificationExecutor { + + override fun cancel() { + interScreenCommunicator.respond(Response(TwoFactorVerificationResult.CANCELED)) + } + + override fun confirm() { + interScreenCommunicator.respond(Response(TwoFactorVerificationResult.CONFIRMED)) + } + + override suspend fun runConfirmation(useBiometry: Boolean): TwoFactorVerificationResult = withContext(Dispatchers.Main) { + val responseFlow = interScreenCommunicator.responseFlow + interScreenCommunicator.openRequest(Request(useBiometry)) + return@withContext responseFlow.first().result + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/sequrity/verification/TwoFactorVerificationService.kt b/common/src/main/java/io/novafoundation/nova/common/sequrity/verification/TwoFactorVerificationService.kt new file mode 100644 index 0000000..9d85adb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/sequrity/verification/TwoFactorVerificationService.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.common.sequrity + +import android.os.Parcelable +import io.novafoundation.nova.common.data.storage.Preferences +import kotlinx.parcelize.Parcelize +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +@Parcelize +enum class TwoFactorVerificationResult : Parcelable { + CONFIRMED, CANCELED +} + +interface TwoFactorVerificationService { + + fun isEnabled(): Boolean + + fun isEnabledFlow(): Flow + + suspend fun toggle() + + suspend fun requestConfirmationIfEnabled(): TwoFactorVerificationResult + + suspend fun requestConfirmation(useBiometry: Boolean): TwoFactorVerificationResult +} + +interface TwoFactorVerificationExecutor { + + fun cancel() + + fun confirm() + + suspend fun runConfirmation(useBiometry: Boolean): TwoFactorVerificationResult +} + +class RealTwoFactorVerificationService( + private val preferences: Preferences, + private val twoFactorVerificationExecutor: TwoFactorVerificationExecutor +) : TwoFactorVerificationService { + + companion object { + private const val PREF_TWO_FACTOR_CONFIRMATION_STATE = "two_factor_confirmation_statE" + } + + private val state = MutableStateFlow(isEnabled()) + + override fun isEnabled(): Boolean { + return preferences.getBoolean(PREF_TWO_FACTOR_CONFIRMATION_STATE, false) + } + + override fun isEnabledFlow(): Flow = state + + override suspend fun toggle() { + val isEnabled = isEnabled() + + if (isEnabled) { + val confirmationResult = requestConfirmationIfEnabled() + if (confirmationResult != TwoFactorVerificationResult.CONFIRMED) { + return + } + } + + setEnable(!isEnabled) + } + + override suspend fun requestConfirmationIfEnabled(): TwoFactorVerificationResult { + if (isEnabled()) { + return requestConfirmation(true) + } + + return TwoFactorVerificationResult.CONFIRMED + } + + override suspend fun requestConfirmation(useBiometry: Boolean): TwoFactorVerificationResult { + return twoFactorVerificationExecutor.runConfirmation(useBiometry) + } + + private fun setEnable(enable: Boolean) { + preferences.putBoolean(PREF_TWO_FACTOR_CONFIRMATION_STATE, enable) + state.value = enable + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/AlphaColorFilter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/AlphaColorFilter.kt new file mode 100644 index 0000000..06a5748 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/AlphaColorFilter.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.utils + +import android.graphics.ColorMatrixColorFilter + +class AlphaColorFilter(val alpha: Float) : ColorMatrixColorFilter( + floatArrayOf( + 1f, 0f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 1f, 0f, 0f, + 0f, 0f, 0f, alpha, 0f + ) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/AlphaDrawable.kt b/common/src/main/java/io/novafoundation/nova/common/utils/AlphaDrawable.kt new file mode 100644 index 0000000..f34c63b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/AlphaDrawable.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.common.utils + +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.drawable.Drawable +import androidx.core.graphics.minus +import androidx.core.graphics.toRectF + +/** + * Note: this implementation is very expensive (see [Canvas.saveLayerAlpha]). + * This is usefull for drawables without implemented alpha and color filter support, such as PictureDrawable. + * In other cases it's recommended to use [Drawable.setAlpha] or [Drawable.setColorFilter] instead of this class. + */ +class AlphaDrawable(private val nestedDrawable: Drawable, private var alpha: Float) : Drawable() { + + private val layerBounds: RectF = RectF() + + init { + bounds = Rect(nestedDrawable.bounds) + layerBounds.set(bounds.toRectF()) + } + + override fun draw(canvas: Canvas) { + canvas.saveLayerAlpha(layerBounds, (alpha * 255).toInt()) + nestedDrawable.draw(canvas) + canvas.restore() + } + + override fun setAlpha(alpha: Int) { + this.alpha = alpha.toFloat() / 255f + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + nestedDrawable.colorFilter = colorFilter + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int { + return nestedDrawable.opacity + } + + override fun getIntrinsicWidth(): Int { + return nestedDrawable.intrinsicWidth + } + + override fun getIntrinsicHeight(): Int { + return nestedDrawable.intrinsicHeight + } +} + +fun Drawable.withAlphaDrawable(alpha: Float): Drawable { + return AlphaDrawable(this, alpha) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/AndroidExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/AndroidExt.kt new file mode 100644 index 0000000..3d79b1c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/AndroidExt.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.utils + +import android.database.Cursor + +inline fun Cursor.map(iteration: Cursor.() -> T): List { + val result = mutableListOf() + + while (moveToNext()) { + result.add(iteration()) + } + + return result +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/BigRational.kt b/common/src/main/java/io/novafoundation/nova/common/utils/BigRational.kt new file mode 100644 index 0000000..a6c92b4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/BigRational.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.utils + +import java.math.BigDecimal +import java.math.BigInteger + +class BigRational(numerator: BigInteger, denominator: BigInteger) { + + val quotient: BigDecimal = numerator.toBigDecimal().divide(denominator.toBigDecimal()) + + val integralQuotient: BigInteger = quotient.toBigInteger() + + companion object +} + +fun BigRational.Companion.fixedU128(value: BigInteger): BigRational { + return BigRational(value, BigInteger.TEN.pow(18)) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/BundleExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/BundleExt.kt new file mode 100644 index 0000000..08a5e14 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/BundleExt.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.utils + +import android.os.Build +import android.os.Bundle +import android.os.Parcelable + +inline fun Bundle.getParcelableCompat(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelable(key, T::class.java) + } else { + getParcelable(key) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/CallbackLruCache.kt b/common/src/main/java/io/novafoundation/nova/common/utils/CallbackLruCache.kt new file mode 100644 index 0000000..3eea1ba --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/CallbackLruCache.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.common.utils + +import android.util.LruCache + +class CallbackLruCache(maxSize: Int) : LruCache(maxSize) { + + private var entryRemovedCallback: ((V) -> Unit)? = null + + fun setOnEntryRemovedCallback(callback: (V) -> Unit) { + this.entryRemovedCallback = callback + } + + fun removeAll() { + trimToSize(0) + } + + override fun entryRemoved(evicted: Boolean, key: K, oldValue: V, newValue: V) { + entryRemovedCallback?.invoke(oldValue) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/CheckableListener.kt b/common/src/main/java/io/novafoundation/nova/common/utils/CheckableListener.kt new file mode 100644 index 0000000..e634896 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/CheckableListener.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.utils + +import android.widget.Checkable +import android.widget.CompoundButton + +interface CheckableListener : Checkable { + fun setOnCheckedChangeListener(listener: CompoundButton.OnCheckedChangeListener) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/CollectionDiffer.kt b/common/src/main/java/io/novafoundation/nova/common/utils/CollectionDiffer.kt new file mode 100644 index 0000000..8c23504 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/CollectionDiffer.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.common.utils + +interface Identifiable { + + val identifier: String +} + +fun Iterable.findById(other: Identifiable?): T? = find { it.identifier == other?.identifier } +fun Iterable.findById(id: String): T? = find { it.identifier == id } + +fun Iterable.firstById(id: String): T = first { it.identifier == id } + +fun CollectionDiffer.Diff<*>.hasDifference() = newOrUpdated.isNotEmpty() || removed.isNotEmpty() + +fun CollectionDiffer.Diff<*>.hasUpdated() = updated.isNotEmpty() + +object CollectionDiffer { + + data class Diff( + val added: List, + val updated: List, + val removed: List, + val all: List + ) { + val newOrUpdated by lazy { updated + added } + + companion object { + fun empty() = CollectionDiffer.Diff( + emptyList(), + emptyList(), + emptyList(), + emptyList() + ) + + fun added(list: List) = CollectionDiffer.Diff( + added = list, + emptyList(), + emptyList(), + emptyList() + ) + } + } + + fun findDiff( + newItems: List, + oldItems: List, + forceUseNewItems: Boolean + ): Diff { + val newKeys: Set = newItems.mapTo(mutableSetOf()) { it.identifier } + val oldMapping = oldItems.associateBy { it.identifier } + + val added = newItems.mapNotNull { new -> + val old = oldMapping[new.identifier] + + new.takeIf { old == null } + } + + val updated = newItems.mapNotNull { new -> + val old = oldMapping[new.identifier] + + // old exists and it is different from new (or we're forced to use new) + new.takeIf { old != null && (old != new || forceUseNewItems) } + } + + val removed = oldItems.filter { it.identifier !in newKeys } + + return Diff(added = added, updated = updated, removed = removed, all = newItems) + } + + fun findDiff( + newItems: Map, + oldItems: Map, + forceUseNewItems: Boolean + ): Diff> { + val added = mutableListOf>() + val updated = mutableListOf>() + + newItems.forEach { newEntry -> + val (key, newValue) = newEntry + val oldValue = oldItems[key] + + when { + key !in oldItems -> added.add(newEntry) + oldValue != newValue || forceUseNewItems -> updated.add(newEntry) + } + } + + val removed = oldItems.mapNotNull { entry -> entry.takeIf { entry.key !in newItems } } + + return Diff(added = added, updated = updated, removed = removed, all = newItems.entries.toList()) + } +} + +fun CollectionDiffer.Diff.map(mapper: (T) -> R) = CollectionDiffer.Diff( + added = added.map(mapper), + updated = updated.map(mapper), + removed = removed.map(mapper), + all = all.map(mapper) +) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ComponentHolder.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ComponentHolder.kt new file mode 100644 index 0000000..98686a2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ComponentHolder.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.utils + +@JvmInline +value class ComponentHolder(val values: List<*>) { + inline operator fun component1() = values[0] as T + inline operator fun component2() = values[1] as T + inline operator fun component3() = values[2] as T + inline operator fun component4() = values[3] as T + inline operator fun component5() = values[4] as T +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Consumer.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Consumer.kt new file mode 100644 index 0000000..2083a1b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Consumer.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.utils + +class Consumer(private var target: T?) { + + fun getOnce(): T? { + return target.also { + target = null + } + } + + fun useOnce(block: (T) -> Unit) { + getOnce()?.let(block) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ContextExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ContextExt.kt new file mode 100644 index 0000000..5c9a628 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ContextExt.kt @@ -0,0 +1,198 @@ +package io.novafoundation.nova.common.utils + +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.os.CombinedVibration +import android.os.Handler +import android.os.Looper +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.Log +import android.util.TypedValue +import android.view.ContextThemeWrapper +import android.view.View +import androidx.annotation.AttrRes +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StyleRes +import androidx.core.content.ContextCompat +import androidx.viewbinding.ViewBinding +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getMaskedRipple +import io.novafoundation.nova.common.view.shape.getRippleMask +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import kotlin.math.roundToInt + +fun Context.getDrawableCompat(@DrawableRes drawableRes: Int) = + ContextCompat.getDrawable(this, drawableRes)!! + +fun Context.shareText(text: String) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + + putExtra(Intent.EXTRA_TEXT, text) + } + + startActivity(Intent.createChooser(intent, null)) +} + +inline fun postToUiThread(crossinline action: () -> Unit) { + Handler(Looper.getMainLooper()).post { + action.invoke() + } +} + +fun Int.dp(context: Context): Int { + return dpF(context).toInt() +} + +fun Int.dpF(context: Context): Float { + return toFloat().dpF(context) +} + +fun Float.dp(context: Context): Int { + return dpF(context).toInt() +} + +fun Float.dpF(context: Context): Float { + return context.resources.displayMetrics.density * this +} + +fun Float.px(context: Context): Int { + return (this / context.resources.displayMetrics.density).roundToInt() +} + +fun Context.readAssetFile(name: String) = assets.open(name).readText() + +@ColorInt +fun Context.getColorFromAttr( + @AttrRes attrColor: Int, + typedValue: TypedValue = TypedValue(), + resolveRefs: Boolean = true +): Int { + theme.resolveAttribute(attrColor, typedValue, resolveRefs) + return typedValue.data +} + +@ColorInt +fun Context.getPrimaryColor() = getColorFromAttr(R.attr.colorPrimary) + +@ColorInt +fun Context.getAccentColor() = getColorFromAttr(R.attr.colorAccent) + +@ColorRes +fun Context.getColorResFromAttr( + @AttrRes attrColor: Int, + typedValue: TypedValue = TypedValue(), + resolveRefs: Boolean = true +): Int { + theme.resolveAttribute(attrColor, typedValue, resolveRefs) + return typedValue.resourceId +} + +@ColorRes +fun Context.getAccentColorRes() = getColorResFromAttr(R.attr.colorAccent) + +fun Context.themed(@StyleRes themeId: Int): Context = ContextThemeWrapper(this, themeId) + +interface WithContextExtensions { + + val providedContext: Context + + val Int.dp: Int + get() = dp(providedContext) + + val Float.dp: Int + get() = dp(providedContext) + + val Int.dpF: Float + get() = dpF(providedContext) + + val Float.dpF: Float + get() = dpF(providedContext) + + fun getRippleDrawable(cornerSizeInDp: Int) = providedContext.getMaskedRipple(cornerSizeInDp) + + fun addRipple(to: Drawable, mask: Drawable? = getRippleMask()) = providedContext.addRipple(to, mask) + + fun Drawable.withRippleMask(mask: Drawable = getRippleMask()) = addRipple(this, mask) + + fun getRoundedCornerDrawable( + @ColorRes fillColorRes: Int = R.color.secondary_screen_background, + @ColorRes strokeColorRes: Int? = null, + cornerSizeDp: Int = 12, + ) = providedContext.getRoundedCornerDrawable(fillColorRes, strokeColorRes, cornerSizeDp) + + fun getRippleMask( + cornerSizeDp: Int = 12, + ) = providedContext.getRippleMask(cornerSizeDp) +} + +fun WithContextExtensions(context: Context) = object : WithContextExtensions { + override val providedContext: Context = context +} + +context(View) +fun getRoundedCornerDrawable( + @ColorRes fillColorRes: Int = R.color.secondary_screen_background, + @ColorRes strokeColorRes: Int? = null, + cornerSizeDp: Int = 12, +) = context.getRoundedCornerDrawable(fillColorRes, strokeColorRes, cornerSizeDp) + +context(View) +fun getRippleMask( + cornerSizeDp: Int = 12, +) = context.getRippleMask(cornerSizeDp) + +context(View) +fun addRipple(to: Drawable, mask: Drawable? = getRippleMask()) = context.addRipple(to, mask) + +fun Context.launchDeepLink(url: String) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .addFlags(FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } catch (e: Exception) { + Log.e(LOG_TAG, "Error while running an activity", e) + } +} + +context(View) +fun Drawable.withRippleMask(mask: Drawable = getRippleMask()) = context.addRipple(this, mask) + +context(View) +val Int.dp: Int + get() = dp(this@View.context) + +context(ViewBinding) +val Int.dp: Int + get() = dp(this@ViewBinding.root.context) + +context(View) +val Int.dpF: Float + get() = dpF(this@View.context) + +fun Context.vibrate(duration: Long) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibrator = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + val vibrationEffect = VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator.vibrate(CombinedVibration.createParallel(vibrationEffect)) + } else { + val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + if (vibrator.hasVibrator()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(duration) + } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/CopyValueMixin.kt b/common/src/main/java/io/novafoundation/nova/common/utils/CopyValueMixin.kt new file mode 100644 index 0000000..b7d3a6e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/CopyValueMixin.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.common.utils + +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ResourceManager + +interface CopyValueMixin { + fun copyValue(value: String) +} + +internal class RealCopyValueMixin( + private val clipboardManager: ClipboardManager, + private val toastMessageManager: ToastMessageManager, + private val resourceManager: ResourceManager +) : CopyValueMixin { + override fun copyValue(value: String) { + clipboardManager.addToClipboard(value) + + toastMessageManager.showToast(resourceManager.getString(R.string.common_copied)) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/CryptoUtils.kt b/common/src/main/java/io/novafoundation/nova/common/utils/CryptoUtils.kt new file mode 100644 index 0000000..2a434b5 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/CryptoUtils.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.common.utils + +import android.util.Base64 +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 +import org.bouncycastle.jcajce.provider.digest.SHA256 +import org.bouncycastle.jcajce.provider.digest.SHA512 +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +fun String.hmacSHA256(secret: String): ByteArray { + val chiper: Mac = Mac.getInstance("HmacSHA256") + val secretKeySpec = SecretKeySpec(secret.toByteArray(), "HmacSHA256") + chiper.init(secretKeySpec) + + return chiper.doFinal(this.toByteArray()) +} + +fun ByteArray.substrateAccountId(): ByteArray { + return if (size > 32) { + this.blake2b256() + } else { + this + } +} + +fun ByteArray.sha512(): ByteArray { + val digits = SHA512.Digest() + return digits.digest(this) +} + +fun ByteArray.sha256(): ByteArray { + val digest = SHA256.Digest() + + return digest.digest(this) +} + +fun String.md5(): String { + val hasher = MessageDigest.getInstance("MD5") + + return hasher.digest(encodeToByteArray()).decodeToString() +} + +fun ByteArray.md5(): String { + val hasher = MessageDigest.getInstance("MD5") + + return hasher.digest(this).decodeToString() +} + +fun ByteArray.toBase64() = Base64.encodeToString(this, Base64.NO_WRAP) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/CursorExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/CursorExt.kt new file mode 100644 index 0000000..add24ba --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/CursorExt.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils + +import android.database.Cursor + +fun Cursor.collectAll(collectItem: (Cursor) -> R): List { + if (!moveToFirst()) return emptyList() + + val items = mutableListOf() + + do { + val item = collectItem(this) + items.add(item) + } while (moveToNext()) + + return items +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/DateExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/DateExt.kt new file mode 100644 index 0000000..33033b4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/DateExt.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.utils + +import java.util.Date + +fun Date.timestamp(): Long = time / 1000 diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/DialogExtensions.kt b/common/src/main/java/io/novafoundation/nova/common/utils/DialogExtensions.kt new file mode 100644 index 0000000..f093b8d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/DialogExtensions.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.common.utils + +import android.content.DialogInterface +import android.view.View + +// TODO waiting for multiple receivers feature, probably in Kotlin 1.7 +interface DialogExtensions { + + val dialogInterface: DialogInterface + + fun View.setDismissingClickListener(listener: (View) -> Unit) { + setOnClickListener { + listener.invoke(it) + + dialogInterface.dismiss() + } + } + + fun View.dismissOnClick() { + setOnClickListener { dialogInterface.dismiss() } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/DialogMessageManager.kt b/common/src/main/java/io/novafoundation/nova/common/utils/DialogMessageManager.kt new file mode 100644 index 0000000..a872f70 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/DialogMessageManager.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.common.utils + +import androidx.annotation.StyleRes +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +typealias DialogBuilder = AlertDialog.Builder.() -> Unit + +interface DialogMessageManager { + + val dialogMessagesEvents: LiveData> + + fun showDialog( + @StyleRes customStyle: Int? = null, + decorator: DialogBuilder + ) +} + +/** + * TODO: Unite this approach with [io.novafoundation.nova.common.view.dialog.dialog]. Create a common interface for example + */ +class RealDialogMessageManager : DialogMessageManager { + + override val dialogMessagesEvents = MutableLiveData>() + + override fun showDialog( + @StyleRes customStyle: Int?, + decorator: DialogBuilder + ) { + dialogMessagesEvents.value = Event(decorator) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/DrawableExtension.kt b/common/src/main/java/io/novafoundation/nova/common/utils/DrawableExtension.kt new file mode 100644 index 0000000..27b6777 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/DrawableExtension.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.common.utils + +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Rect +import android.graphics.drawable.Drawable + +class DrawableExtension( + private val contentDrawable: Drawable, + private val extensionOffset: Rect +) : Drawable() { + + override fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + super.setBounds( + left - extensionOffset.left, + top - extensionOffset.top, + right + extensionOffset.right, + bottom + extensionOffset.bottom + ) + + contentDrawable.bounds = bounds + } + + override fun draw(canvas: Canvas) { + contentDrawable.draw(canvas) + } + + override fun setAlpha(alpha: Int) = Unit + + override fun setColorFilter(colorFilter: ColorFilter?) { + contentDrawable.colorFilter = colorFilter + } + + override fun setTint(tintColor: Int) { + contentDrawable.setTint(tintColor) + } + + override fun setTintList(tint: ColorStateList?) { + contentDrawable.setTintList(tint) + } + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int { + return contentDrawable.opacity + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/DurationExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/DurationExt.kt new file mode 100644 index 0000000..2257ea7 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/DurationExt.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.common.utils + +import kotlin.time.Duration + +inline val Duration.lastDays: Long + get() = this.inWholeDays + +val Duration.lastHours: Int + get() = this.toComponents { _, hours, _, _, _ -> hours } + +val Duration.lastMinutes: Int + get() = this.toComponents { _, _, minutes, _, _ -> minutes } + +val Duration.lastSeconds: Int + get() = this.toComponents { _, _, _, seconds, _ -> seconds } diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Event.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Event.kt new file mode 100644 index 0000000..8e9bc5e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Event.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.common.utils + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer + +class Event(private val content: T) { + + var hasBeenHandled = false + private set + + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + fun peekContent(): T = content +} + +class EventObserver(private val onEventUnhandledContent: (T) -> Unit) : Observer> { + override fun onChanged(event: Event?) { + event?.getContentIfNotHandled()?.let { value -> + onEventUnhandledContent(value) + } + } +} + +fun T.event(): Event = Event(this) + +fun LiveData>.mapEvent(mapper: (T) -> R): LiveData> = this.map { Event(mapper(it.peekContent())) } diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Executors.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Executors.kt new file mode 100644 index 0000000..c3e8784 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Executors.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.utils + +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +private const val CORE_POOL_SIZE = 1 +private const val KEEP_ALIVE_TIME = 60L + +fun newLimitedThreadPoolExecutor(maxThreads: Int): ThreadPoolExecutor { + return ThreadPoolExecutor(CORE_POOL_SIZE, maxThreads, KEEP_ALIVE_TIME, TimeUnit.SECONDS, LinkedBlockingQueue()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Ext.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Ext.kt new file mode 100644 index 0000000..8e112ac --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Ext.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.common.utils + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.view.View +import android.widget.Toast +import androidx.annotation.ColorInt +import androidx.fragment.app.Fragment +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import java.net.URLEncoder + +fun Context.showToast(msg: String, duration: Int = Toast.LENGTH_LONG) { + Toast.makeText(this, msg, duration).show() +} + +fun MutableLiveData.setValueIfNew(newValue: T) { + if (this.value != newValue) value = newValue +} + +fun View.makeVisible() { + this.visibility = View.VISIBLE +} + +fun View.makeInvisible() { + this.visibility = View.INVISIBLE +} + +fun View.makeGone() { + this.visibility = View.GONE +} + +fun Fragment.hideKeyboard() { + requireActivity().currentFocus?.hideSoftKeyboard() +} + +fun Fragment.showBrowser(link: String) = requireContext().showBrowser(link) + +fun Context.showBrowser(link: String, errorMessageRes: Int = R.string.common_cannot_open_link) { + val intent = Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(link) } + + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(this, errorMessageRes, Toast.LENGTH_SHORT) + .show() + } +} + +fun Context.sendEmailIntent( + targetEmail: String, + title: String = getString(R.string.common_email_chooser_title) +) { + try { + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + putExtra(Intent.EXTRA_EMAIL, targetEmail) + type = "message/rfc822" + data = Uri.parse("mailto:$targetEmail") + } + startActivity(Intent.createChooser(emailIntent, title)) + } catch (e: Exception) { + Toast.makeText(this, R.string.common_something_went_wrong_title, Toast.LENGTH_SHORT) + } +} + +fun @receiver:ColorInt Int.toHexColor(): String { + val withoutAlpha = 0xFFFFFF and this + + return "#%06X".format(withoutAlpha) +} + +fun String.urlEncoded() = URLEncoder.encode(this, Charsets.UTF_8.displayName()) + +fun CoroutineScope.childScope(supervised: Boolean = true): CoroutineScope { + val parentJob = coroutineContext[Job] + + val job = if (supervised) SupervisorJob(parent = parentJob) else Job(parent = parentJob) + + return CoroutineScope(coroutineContext + job) +} + +fun Int.asBoolean() = this != 0 + +fun Boolean?.orFalse() = this ?: false + +fun Boolean?.orTrue() = this ?: true + +fun T.doIf(isTrue: Boolean, block: T.() -> Unit): T { + if (isTrue) block() + return this +} + +fun T.mapIf(isTrue: Boolean, block: T.() -> T): T { + return if (isTrue) { + block() + } else { + this + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/FileExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/FileExt.kt new file mode 100644 index 0000000..5d8ee04 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/FileExt.kt @@ -0,0 +1,21 @@ +@file:Suppress("BlockingMethodInNonBlockingContext") + +package io.novafoundation.nova.common.utils + +import android.graphics.Bitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +/** + * @param quality - integer between 1 and 100 + */ +suspend fun File.write(bitmap: Bitmap, quality: Int = 100) { + withContext(Dispatchers.IO) { + val outputStream = FileOutputStream(this@write) + + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) + outputStream.close() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Filters.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Filters.kt new file mode 100644 index 0000000..3626d9f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Filters.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +interface Filter { + fun shouldInclude(model: T): Boolean +} + +interface RuntimeDependent { + + fun availableIn(runtime: RuntimeSnapshot): Boolean +} + +interface PalletBasedFilter : Filter, RuntimeDependent { + + override fun availableIn(runtime: RuntimeSnapshot) = true +} + +interface NamedFilter : Filter { + + val name: String +} + +interface OptionsFilter : Filter { + + val options: List +} + +class EverythingFilter : Filter { + + override fun shouldInclude(model: T) = true +} + +fun List.applyFilters(filters: List>): List { + return filter { item -> filters.all { filter -> filter.shouldInclude(item) } } +} + +fun List.applyFilter(filter: Filter): List { + return filter { item -> filter.shouldInclude(item) } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/FlowExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/FlowExt.kt new file mode 100644 index 0000000..a06606b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/FlowExt.kt @@ -0,0 +1,749 @@ +package io.novafoundation.nova.common.utils + +import android.widget.CompoundButton +import android.widget.EditText +import android.widget.RadioGroup +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.lifecycleScope +import com.google.android.material.tabs.TabLayout +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.input.Input +import io.novafoundation.nova.common.utils.input.isModifiable +import io.novafoundation.nova.common.utils.input.modifyInput +import io.novafoundation.nova.common.utils.input.valueOrNull +import io.novafoundation.nova.common.view.InsertableInputField +import io.novafoundation.nova.common.view.input.seekbar.Seekbar +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.coroutineContext +import kotlin.experimental.ExperimentalTypeInference +import kotlin.time.Duration + +inline fun Flow>.filterList(crossinline handler: suspend (T) -> Boolean) = map { list -> + list.filter { item -> handler(item) } +} + +inline fun Flow>.filterSet(crossinline handler: suspend (T) -> Boolean) = map { set -> + set.filter { item -> handler(item) }.toSet() +} + +inline fun Flow>.mapList(crossinline mapper: suspend (T) -> R) = map { list -> + list.map { item -> mapper(item) } +} + +inline fun Flow>.mapResult(crossinline mapper: suspend (T) -> R) = map { result -> + result.map { item -> mapper(item) } +} + +/** + * Maps nullable values by transforming non-null values and propagating null to downstream + */ +inline fun Flow.mapOptional(crossinline mapper: suspend (T) -> R?): Flow = map { result -> + result?.let { mapper(it) } +} + +inline fun Flow>.mapListNotNull(crossinline mapper: suspend (T) -> R?) = map { list -> + list.mapNotNull { item -> mapper(item) } +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow.onEachLatest(action: suspend (T) -> Unit): Flow = transformLatest { value -> + action(value) + return@transformLatest emit(value) +} + +/** + * Modifies flow so that it firstly emits [LoadingState.Loading] state. + * Then emits each element from upstream wrapped into [LoadingState.Loaded] state. + */ +fun Flow.withLoading(): Flow> { + return map> { LoadingState.Loaded(it) } + .onStart { emit(LoadingState.Loading()) } +} + +fun MutableStateFlow.setter(): (T) -> Unit { + return { value = it } +} + +fun MutableStateFlow.updateValue(updater: (T) -> T) { + value = updater(value) +} + +fun Flow.withItemScope(parentScope: CoroutineScope): Flow> { + var currentScope: CoroutineScope? = null + + return map { + currentScope?.cancel() + currentScope = parentScope.childScope(supervised = true) + it to requireNotNull(currentScope) + } +} + +/** + * Modifies flow so that it firstly emits [ExtendedLoadingState.Loading] state. + * Then emits each element from upstream wrapped into [ExtendedLoadingState.Loaded] state. + * If exception occurs, emits [ExtendedLoadingState.Error] state. + */ +fun Flow.withSafeLoading(): Flow> { + return map> { ExtendedLoadingState.Loaded(it) } + .onStart { emit(ExtendedLoadingState.Loading) } + .catch { emit(ExtendedLoadingState.Error(it)) } +} + +suspend fun Flow>.firstOnLoad(): T = transform { + collect { + if (it is LoadingState.Loaded) { + emit(it.data) + } + } +}.first() + +fun List>.mergeIfMultiple(): Flow = when (size) { + 0 -> emptyFlow() + 1 -> first() + else -> merge() +} + +inline fun withFlowScope(crossinline block: suspend (scope: CoroutineScope) -> Flow): Flow { + return flowOfAll { + val flowScope = CoroutineScope(coroutineContext) + + block(flowScope) + } +} + +inline fun parentCancellableFlowScope(crossinline block: suspend (scope: CoroutineScope) -> T): Flow { + return flow { + val flowScope = CoroutineScope(coroutineContext) + emit(block(flowScope)) + + awaitCancellation() + } +} + +fun combineToPair(flow1: Flow, flow2: Flow): Flow> = combine(flow1, flow2, ::Pair) + +fun combineToTriple(flow1: Flow, flow2: Flow, flow3: Flow): Flow> = combine(flow1, flow2, flow3, ::Triple) + +fun combineToTuple4( + flow1: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow +): Flow> = combine(flow1, flow2, flow3, flow4, ::Tuple4) + +/** + * Modifies flow so that it firstly emits [LoadingState.Loading] state for each element from upstream. + * Then, it constructs new source via [sourceSupplier] and emits all of its items wrapped into [LoadingState.Loaded] state + * Old suppliers are discarded as per [Flow.transformLatest] behavior + */ +fun Flow.withLoading(sourceSupplier: suspend (T) -> Flow): Flow> { + return transformLatest { item -> + emit(LoadingState.Loading()) + + val newSource = sourceSupplier(item).map { LoadingState.Loaded(it) } + + emitAll(newSource) + } +} + +private enum class InnerState { + INITIAL_START, SECONDARY_START, IN_PROGRESS +} + +/** + * Modifies flow so that it firstly emits [LoadingState.Loading] state for each element from upstream. + * Then, it constructs new source via [sourceSupplier] and emits all of its items wrapped into [LoadingState.Loaded] state + * Old suppliers are discarded as per [Flow.transformLatest] behavior + * + * NOTE: This is a modified version of [withLoading] that is intended to be used ONLY with [SharingStarted.WhileSubscribed]. + * In particular, it does not emit loading state on second and subsequent re-subscriptions + */ +fun Flow.withLoadingShared(sourceSupplier: suspend (T) -> Flow): Flow> { + var state: InnerState = InnerState.INITIAL_START + + return transformLatest { item -> + if (state != InnerState.SECONDARY_START) { + emit(ExtendedLoadingState.Loading) + } + state = InnerState.IN_PROGRESS + + val newSource = sourceSupplier(item).map { ExtendedLoadingState.Loaded(it) } + + emitAll(newSource) + } + .catch { emit(ExtendedLoadingState.Error(it)) } + .onCompletion { state = InnerState.SECONDARY_START } +} + +fun Flow.zipWithLastNonNull(): Flow> = flow { + var lastNonNull: T? = null + + collect { + emit(lastNonNull to it) + + if (it != null) { + lastNonNull = it + } + } +} + +suspend inline fun Flow>.firstLoaded(): T = first { it.dataOrNull != null }.dataOrNull as T + +suspend fun Flow>.firstIfLoaded(): T? = first().dataOrNull + +/** + * Modifies flow so that it firstly emits [LoadingState.Loading] state. + * Then emits each element from upstream wrapped into [LoadingState.Loaded] state. + * + * NOTE: This is a modified version of [withLoading] that is intended to be used ONLY with [SharingStarted.WhileSubscribed]. + * In particular, it does not emit loading state on second and subsequent re-subscriptions + */ +fun Flow.withLoadingShared(): Flow> { + var state: InnerState = InnerState.INITIAL_START + + return map> { ExtendedLoadingState.Loaded(it) } + .onStart { + if (state != InnerState.SECONDARY_START) { + emit(ExtendedLoadingState.Loading) + } + state = InnerState.IN_PROGRESS + } + .catch { emit(ExtendedLoadingState.Error(it)) } + .onCompletion { state = InnerState.SECONDARY_START } +} + +/** + * Similar to [Flow.takeWhile] but emits last element too + */ +fun Flow.takeWhileInclusive(predicate: suspend (T) -> Boolean) = transformWhile { + emit(it) + + predicate(it) +} + +inline fun Flow.mapNullable(crossinline mapper: suspend (T) -> R): Flow { + return map { it?.let { mapper(it) } } +} + +/** + * Modifies flow so that it firstly emits [LoadingState.Loading] state for each element from upstream. + * Then, it constructs new source via [sourceSupplier] and emits all of its items wrapped into [LoadingState.Loaded] state + * Old suppliers are discarded as per [Flow.transformLatest] behavior + */ +fun Flow.withLoadingSingle(sourceSupplier: suspend (T) -> R): Flow> { + return transformLatest { item -> + emit(LoadingState.Loading()) + + val newSource = LoadingState.Loaded(sourceSupplier(item)) + + emit(newSource) + } +} + +fun Flow.wrapInResult(): Flow> { + return map { Result.success(it) } + .catch { emit(Result.failure(it)) } +} + +@Suppress("UNCHECKED_CAST") +@OptIn(ExperimentalTypeInference::class) +inline fun Flow>.transformResult( + @BuilderInference crossinline transform: suspend FlowCollector.(value: T) -> Unit +): Flow> { + return transform { upstream -> + upstream.onFailure { + emit(upstream as Result) + }.onSuccess { + val innerCollector = FlowCollector { + emit(Result.success(it)) + } + + runCatching { + transform(innerCollector, it) + }.onFailure { + if (it is CancellationException) { + throw it + } + + emit(Result.failure(it)) + } + } + } +} + +fun Flow.withLoadingResult(source: suspend (T) -> Result): Flow> { + return transformLatest { item -> + emit(ExtendedLoadingState.Loading) + + source(item) + .onSuccess { emit(ExtendedLoadingState.Loaded(it)) } + .onFailure { emit(ExtendedLoadingState.Error(it)) } + } +} + +fun Flow.asLiveData(scope: CoroutineScope): LiveData { + val liveData = MutableLiveData() + + onEach { + liveData.value = it + }.launchIn(scope) + + return liveData +} + +fun Flow>.diffed(): Flow> { + return zipWithPrevious().map { (previous, new) -> + CollectionDiffer.findDiff(newItems = new, oldItems = previous.orEmpty(), forceUseNewItems = false) + } +} + +suspend inline fun Flow.awaitTrue() { + first { it } +} + +fun Flow.zipWithPrevious(): Flow> = flow { + var current: T? = null + + collect { + emit(current to it) + + current = it + } +} + +fun Flow.onEachWithPrevious(action: suspend (T?, T) -> Unit): Flow = flow { + var current: T? = null + + collect { + action(current, it) + emit(it) + current = it + } +} + +private fun MutableMap.removeAndCancel(key: K) { + remove(key)?.also(CoroutineScope::cancel) +} + +fun Flow>.transformLatestDiffed(transform: suspend FlowCollector.(value: T) -> Unit): Flow = channelFlow { + val parentScope = CoroutineScope(coroutineContext) + val itemScopes = mutableMapOf() + + diffed().onEach { diff -> + diff.removed.forEach { removedItem -> + itemScopes.removeAndCancel(removedItem.identifier) + } + + diff.newOrUpdated.forEach { newOrUpdatedItem -> + itemScopes.removeAndCancel(newOrUpdatedItem.identifier) + + val chainScope = parentScope.childScope(supervised = false) + itemScopes[newOrUpdatedItem.identifier] = chainScope + + chainScope.launch { + transform(SendingCollector(this@channelFlow), newOrUpdatedItem) + } + } + }.launchIn(parentScope) +} + +private class SendingCollector( + private val channel: SendChannel +) : FlowCollector { + + override suspend fun emit(value: T): Unit = channel.send(value) +} + +fun singleReplaySharedFlow() = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + +fun Flow.inBackground() = flowOn(Dispatchers.Default) + +fun Flow.nullOnStart(): Flow { + return onStart { emit(null) } +} + +fun InsertableInputField.bindTo(flow: MutableSharedFlow, scope: CoroutineScope) { + content.bindTo(flow, scope) +} + +fun EditText.bindTo( + flow: MutableSharedFlow, + scope: CoroutineScope, + moveSelectionToEndOnInsertion: Boolean = false, +) { + bindTo(flow, scope, moveSelectionToEndOnInsertion, toT = { it }, fromT = { it }) +} + +inline fun EditText.bindTo( + flow: MutableSharedFlow, + scope: CoroutineScope, + moveSelectionToEndOnInsertion: Boolean = false, + crossinline toT: suspend (String) -> T, + crossinline fromT: suspend (T) -> String?, +) { + val textWatcher = onTextChanged { + scope.launch { + flow.emit(toT(it)) + } + } + + scope.launch { + flow.collect { input -> + val inputString = fromT(input) + if (inputString != null && text.toString() != inputString) { + removeTextChangedListener(textWatcher) + setText(inputString) + if (moveSelectionToEndOnInsertion) { + moveSelectionToTheEnd() + } + addTextChangedListener(textWatcher) + } + } + } +} + +fun EditText.moveSelectionToTheEnd() { + if (hasFocus()) { + setSelection(text.length) + } +} + +context(BaseFragment<*, *>) +infix fun TabLayout.bindTo(pageIndexFlow: MutableSharedFlow) = bindTo(pageIndexFlow, this@BaseFragment.lifecycleScope) + +fun TabLayout.bindTo( + pageIndexFlow: MutableSharedFlow, + scope: LifecycleCoroutineScope +) { + var currentTabPosition = -1 + + addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + val newIndex = tab.position + if (currentTabPosition != newIndex) { + currentTabPosition = newIndex + scope.launch { + pageIndexFlow.emit(newIndex) + } + } + } + + override fun onTabUnselected(tab: TabLayout.Tab?) {} + override fun onTabReselected(tab: TabLayout.Tab?) {} + }) + + pageIndexFlow.observe(scope) { index -> + if (index != currentTabPosition && index in 0 until tabCount) { + currentTabPosition = index + this@bindTo.getTabAt(index)?.select() + } + } +} + +inline fun MutableStateFlow.withFlagSet(action: () -> R): R { + value = true + + val result = action() + + value = false + + return result +} + +fun CompoundButton.bindTo(flow: Flow, scope: CoroutineScope, callback: (Boolean) -> Unit) { + var oldValue = isChecked + + scope.launch { + flow.collect { newValue -> + if (isChecked != newValue) { + oldValue = newValue + isChecked = newValue + } + } + } + + setOnCheckedChangeListener { _, newValue -> + if (oldValue != newValue) { + oldValue = newValue + callback(newValue) + } + } +} + +fun CompoundButton.bindTo(flow: MutableStateFlow, scope: CoroutineScope) { + scope.launch { + flow.collect { newValue -> + if (isChecked != newValue) { + isChecked = newValue + } + } + } + + setOnCheckedChangeListener { _, newValue -> + if (flow.value != newValue) { + flow.value = newValue + } + } +} + +@JvmName("bindToInput") +fun CompoundButton.bindTo(flow: MutableStateFlow>, scope: CoroutineScope) { + scope.launch { + flow.collect { newValue -> + when (newValue) { + Input.Disabled -> makeGone() + + is Input.Enabled -> { + if (isChecked != newValue.value) { + isChecked = newValue.value + } + + makeVisible() + isEnabled = newValue.isModifiable + } + } + } + } + + setOnCheckedChangeListener { _, newValue -> + if (flow.value.valueOrNull != newValue) { + flow.modifyInput(newValue) + } + } +} + +fun > RadioGroup.bindTo(flow: MutableStateFlow, scope: LifecycleCoroutineScope, valueToViewId: Map) { + val viewIdToValue = valueToViewId.reversed() + + setOnCheckedChangeListener { _, checkedId -> + val newValue = viewIdToValue.getValue(checkedId) + + if (flow.value != newValue) { + flow.value = newValue + } + } + + scope.launchWhenResumed { + flow.collect { + val newCheckedId = valueToViewId.getValue(it) + + if (newCheckedId != checkedRadioButtonId) { + check(newCheckedId) + } + } + } +} + +fun RadioGroup.bindTo(flow: MutableStateFlow, scope: LifecycleCoroutineScope) { + setOnCheckedChangeListener { _, checkedId -> + if (flow.value != checkedId) { + flow.value = checkedId + } + } + + scope.launchWhenResumed { + flow.collect { + if (it != checkedRadioButtonId) { + check(it) + } + } + } +} + +fun Seekbar.bindTo(flow: MutableStateFlow, scope: LifecycleCoroutineScope) { + setOnProgressChangedListener { progress -> + if (flow.value != progress) { + flow.value = progress + } + } + + scope.launchWhenResumed { + flow.collect { + if (it != progress) { + progress = it + } + } + } +} + +fun Flow.observe( + scope: LifecycleCoroutineScope, + collector: FlowCollector, +) { + scope.launchWhenResumed { + collect(collector) + } +} + +fun MutableStateFlow.toggle() { + value = !value +} + +fun flowOf(producer: suspend () -> T) = flow { + emit(producer()) +} + +inline fun flowOfAll(crossinline producer: suspend () -> Flow): Flow = flow { + emitAll(producer()) +} + +inline fun Iterable>.combine(): Flow> { + return combineIdentity(this) +} + +inline fun combineIdentity(flows: Iterable>): Flow> { + return combine(flows) { it.toList() } +} + +fun Collection>.accumulate(): Flow> { + return accumulate(*this.toTypedArray()) +} + +fun accumulate(vararg flows: Flow): Flow> { + val flowsList = flows.mapIndexed { index, flow -> flow.map { index to flow } } + val resultOfFlows = MutableList(flowsList.size) { null } + val lock = Mutex() + + return flowsList + .merge() + .map { + lock.withLock { resultOfFlows[it.first] = it.second.first() } + resultOfFlows.filterNotNull().toList() + } +} + +fun accumulateFlatten(vararg flows: Flow>): Flow> { + return accumulate(*flows).map { it.flatten() } +} + +fun unite(flowA: Flow, flowB: Flow, transform: suspend (A?, B?) -> R): Flow { + var aResult: A? = null + var bResult: B? = null + + return merge( + flowA.onEach { aResult = it }, + flowB.onEach { bResult = it }, + ).map { transform(aResult, bResult) } +} + +fun unite(flowA: Flow, flowB: Flow, flowC: Flow, transform: (A?, B?, C?) -> R): Flow { + var aResult: A? = null + var bResult: B? = null + var cResult: C? = null + + return merge( + flowA.onEach { aResult = it }, + flowB.onEach { bResult = it }, + flowC.onEach { cResult = it }, + ).map { transform(aResult, bResult, cResult) } +} + +fun unite(flowA: Flow, flowB: Flow, flowC: Flow, flowD: Flow, transform: (A?, B?, C?, D?) -> R): Flow { + var aResult: A? = null + var bResult: B? = null + var cResult: C? = null + var dResult: D? = null + + return merge( + flowA.onEach { aResult = it }, + flowB.onEach { bResult = it }, + flowC.onEach { cResult = it }, + flowD.onEach { dResult = it } + ).map { transform(aResult, bResult, cResult, dResult) } +} + +fun firstNonEmpty( + vararg sources: Flow> +): Flow> = accumulate(*sources) + .transform { collected -> + val isAllLoaded = collected.size == sources.size + val flattenResult: List = collected.flatten() + + if (isAllLoaded || flattenResult.isNotEmpty()) { + emit(flattenResult) + } + } + +fun Flow.observeInLifecycle( + lifecycleCoroutineScope: LifecycleCoroutineScope, + observer: FlowCollector, +) { + lifecycleCoroutineScope.launchWhenResumed { + collect(observer) + } +} + +fun Flow.skipFirst(): Flow { + return drop(1) +} + +fun Map>.checkEnabled(key: T) = get(key)?.value ?: false + +suspend inline fun Flow.firstNotNull(): T = first { it != null } as T + +inline fun Flow>.mapLatestIndexed(crossinline transform: suspend (T) -> R): Flow> { + return mapLatest { IndexedValue(it.index, transform(it.value)) } +} + +/** + * Emits first element from upstream and then emits last element emitted by upstream during specified time window + * + * ``` + * flow { + * for (num in 1..15) { + * emit(num) + * delay(25) + * } + * }.throttleLast(100) + * .onEach { println(it) } + * .collect() // Prints 1, 5, 9, 13, 15 + * + * ``` + */ +fun Flow.throttleLast(delay: Duration): Flow = this + .conflate() + .transform { + emit(it) + delay(delay) + } diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Fraction.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Fraction.kt new file mode 100644 index 0000000..6441d9d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Fraction.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.common.utils + +import java.math.BigDecimal +import java.math.BigInteger + +@JvmInline +value class Fraction private constructor(private val value: Double) : Comparable { + + companion object { + + val ZERO: Fraction = Fraction(0.0) + + fun Double.toFraction(unit: FractionUnit): Fraction { + return Fraction(unit.convertToFraction(this)) + } + + val Double.percents: Fraction + get() = toFraction(FractionUnit.PERCENT) + + val BigDecimal.percents: Fraction + get() = toDouble().percents + + val BigInteger.percents: Fraction + get() = toDouble().percents + + val BigDecimal.fractions: Fraction + get() = toDouble().fractions + + val Double.fractions: Fraction + get() = toFraction(FractionUnit.FRACTION) + + val Float.fractions: Fraction + get() = toDouble().toFraction(FractionUnit.FRACTION) + + val Int.percents: Fraction + get() = toDouble().toFraction(FractionUnit.PERCENT) + } + + val inPercents: Double + get() = FractionUnit.PERCENT.convertFromFraction(value) + + val inFraction: Double + get() = FractionUnit.FRACTION.convertFromFraction(value) + + val inWholePercents: Int + get() = FractionUnit.PERCENT.convertFromFractionWhole(value) + + override fun compareTo(other: Fraction): Int { + return value.compareTo(other.value) + } +} + +enum class FractionUnit { + + /** + * Default range: 0..1 + */ + FRACTION, + + /** + * Default range: 0..100 + */ + PERCENT +} + +fun Fraction?.orZero(): Fraction = this ?: Fraction.ZERO + +val Fraction.isZero: Boolean + get() = this == Fraction.ZERO + +private fun FractionUnit.convertToFraction(value: Double): Double { + return when (this) { + FractionUnit.FRACTION -> value + FractionUnit.PERCENT -> value / 100 + } +} + +private fun FractionUnit.convertFromFraction(value: Double): Double { + return when (this) { + FractionUnit.FRACTION -> value + FractionUnit.PERCENT -> value * 100 + } +} + +private fun FractionUnit.convertFromFractionWhole(value: Double): Int { + return convertFromFraction(value).toInt() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/GridSpacingItemDecoration.kt b/common/src/main/java/io/novafoundation/nova/common/utils/GridSpacingItemDecoration.kt new file mode 100644 index 0000000..5b23a77 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/GridSpacingItemDecoration.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.common.utils + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class GridSpacingItemDecoration( + gridLayoutManager: GridLayoutManager, + private val spacing: Int, +) : RecyclerView.ItemDecoration() { + + private val spanCount = gridLayoutManager.spanCount + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val position = parent.getChildAdapterPosition(view) + val column = position % spanCount + + outRect.left = column * spacing / spanCount + outRect.right = spacing - (column + 1) * spacing / spanCount + if (position >= spanCount) { + outRect.top = spacing + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Hex.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Hex.kt new file mode 100644 index 0000000..1e701aa --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Hex.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.common.utils + +typealias HexString = String diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Http.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Http.kt new file mode 100644 index 0000000..4f8e794 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Http.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.common.utils + +fun Iterable.asQueryParam() = joinToString(separator = ",") diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/IdentifiableSet.kt b/common/src/main/java/io/novafoundation/nova/common/utils/IdentifiableSet.kt new file mode 100644 index 0000000..fb53a06 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/IdentifiableSet.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.common.utils + +class SetItem(val value: T) { + + override fun equals(other: Any?): Boolean { + if (other !is SetItem<*>) return false + if (value.javaClass != other.value.javaClass) return false + + return value.identifier == other.value.identifier + } + + override fun hashCode(): Int { + return value.identifier.hashCode() + } +} + +fun T.asSetItem(): SetItem { + return SetItem(this) +} + +fun Collection.asSetItems(): Set> { + val setItemList = this.map { SetItem(it) } + return setOf(*setItemList.toTypedArray()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ImageLoaderExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ImageLoaderExt.kt new file mode 100644 index 0000000..3c9989d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ImageLoaderExt.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.common.utils + +import android.widget.ImageView +import coil.ImageLoader +import coil.imageLoader +import coil.loadAny +import coil.request.ImageRequest + +fun ImageView.loadOrHide( + any: Any?, + imageLoader: ImageLoader = context.imageLoader, + builder: ImageRequest.Builder.() -> Unit = {} +) { + loadAny(any, imageLoader) { + listener( + onSuccess = { _, _ -> makeVisible() }, + onError = { _, _ -> makeGone() } + ) + builder() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ImageMonitor.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ImageMonitor.kt new file mode 100644 index 0000000..d4e8575 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ImageMonitor.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.common.utils + +import android.os.FileObserver +import android.widget.ImageView +import coil.ImageLoader +import coil.load +import java.io.File + +class ImageMonitor( + private val imageView: ImageView, + private val imageLoader: ImageLoader +) { + + private var fileObserver: FileObserver? = null + + fun startMonitoring(filePath: String) { + val file = File(filePath) + if (!file.exists()) { + return + } + + // Stop watching previous file + stopMonitoring() + + // Initialize FileObserver to monitor changes to the file + fileObserver = object : FileObserver(filePath, MODIFY) { + override fun onEvent(event: Int, path: String?) { + if (event == MODIFY) { + // When file is updated, invalidate the cache and reload + reloadImage(filePath) + } + } + } + + fileObserver?.startWatching() + + // Load the initial image + reloadImage(filePath) + } + + fun stopMonitoring() { + fileObserver?.stopWatching() + fileObserver = null + } + + private fun reloadImage(filePath: String) { + imageView.load(File(filePath), imageLoader) + } +} + +fun ImageMonitor.setPathOrStopWatching(filePath: String?) { + if (filePath == null) { + stopMonitoring() + } else { + startMonitoring(filePath) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/InformationSize.kt b/common/src/main/java/io/novafoundation/nova/common/utils/InformationSize.kt new file mode 100644 index 0000000..dcdee0a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/InformationSize.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.common.utils + +@JvmInline +value class InformationSize(private val sizeInBytes: Long) : Comparable { + + companion object { + + val Int.bytes: InformationSize get() = toInformationSize(InformationSizeUnit.BYTES) + + val Long.bytes: InformationSize get() = toInformationSize(InformationSizeUnit.BYTES) + + val Int.kilobytes: InformationSize get() = toInformationSize(InformationSizeUnit.KILOBYTES) + + val Long.kilobytes: InformationSize get() = toInformationSize(InformationSizeUnit.KILOBYTES) + + val Int.megabytes: InformationSize get() = toInformationSize(InformationSizeUnit.MEGABYTES) + + val Long.megabytes: InformationSize get() = toInformationSize(InformationSizeUnit.MEGABYTES) + } + + val inWholeBytes + get() = sizeInBytes + + override fun compareTo(other: InformationSize): Int { + return sizeInBytes.compareTo(other.sizeInBytes) + } + + operator fun plus(other: InformationSize): InformationSize { + return InformationSize(sizeInBytes + other.sizeInBytes) + } + + operator fun minus(other: InformationSize): InformationSize { + return InformationSize(sizeInBytes - other.sizeInBytes) + } +} + +enum class InformationSizeUnit { + + BYTES, + + KILOBYTES, + + MEGABYTES +} + +fun Int.toInformationSize(unit: InformationSizeUnit): InformationSize { + return toLong().toInformationSize(unit) +} + +fun Long.toInformationSize(unit: InformationSizeUnit): InformationSize { + return InformationSize(unit.convertToBytes(this)) +} + +private fun InformationSizeUnit.convertToBytes(value: Long): Long { + return when (this) { + InformationSizeUnit.BYTES -> value + InformationSizeUnit.KILOBYTES -> value * 1024 + InformationSizeUnit.MEGABYTES -> value * 1024 * 1024 + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/IntegrityService.kt b/common/src/main/java/io/novafoundation/nova/common/utils/IntegrityService.kt new file mode 100644 index 0000000..c74f22a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/IntegrityService.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.common.utils + +import com.google.android.play.core.integrity.StandardIntegrityException +import com.google.android.play.core.integrity.StandardIntegrityManager +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest +import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID +import io.novafoundation.nova.common.utils.coroutines.RootScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +private const val RECEIVING_PROVIDER_MAX_RETRY = 3 + +private sealed interface ProviderState { + data object Preparing : ProviderState + data object ReceivingError : ProviderState + class Ready(val provider: StandardIntegrityTokenProvider) : ProviderState +} + +class IntegrityService( + private val cloudProjectNumber: Long, + private var standardIntegrityManager: StandardIntegrityManager, + private val rootScope: RootScope +) { + + private var providerState: ProviderState = ProviderState.Preparing + private val prepareProviderMutex = Mutex() + + init { + rootScope.launch { ensureProviderIsReady() } + } + + suspend fun getIntegrityToken(requestHash: String): String { + ensureProviderIsReady() + + return try { + requestIntegrityToken(requestHash) + } catch (e: StandardIntegrityException) { + if (e.statusCode == INTEGRITY_TOKEN_PROVIDER_INVALID) { + resetProviderState() + ensureProviderIsReady() + requestIntegrityToken(requestHash) + } else { + throw e + } + } + } + + private suspend fun ensureProviderIsReady() { + if (providerState is ProviderState.Ready) return + + prepareProviderMutex.withLock { + if (providerState is ProviderState.Ready) return@withLock + + repeat(RECEIVING_PROVIDER_MAX_RETRY) { + providerState = prepareTokenProvider() + if (providerState is ProviderState.Ready) return@repeat + } + } + } + + private fun resetProviderState() { + providerState = ProviderState.Preparing + } + + private suspend fun prepareTokenProvider() = suspendCoroutine { continuation -> + standardIntegrityManager.prepareIntegrityToken(providerRequest()) + .addOnSuccessListener { continuation.resume(ProviderState.Ready(it)) } + .addOnFailureListener { continuation.resume(ProviderState.ReceivingError) } + .addOnCanceledListener { continuation.resume(ProviderState.ReceivingError) } + } + + private fun getIntegrityTokenProvider() = (providerState as? ProviderState.Ready)?.provider + + private suspend fun requestIntegrityToken(requestHash: String): String { + val provider = getIntegrityTokenProvider() ?: throw IllegalStateException("Token provider is not initialized") + return suspendCoroutine { continuation -> + provider.request(integrityTokenRequest(requestHash)) + .addOnSuccessListener { continuation.resume(it.token()) } + .addOnFailureListener { continuation.resumeWithException(it) } + } + } + + private fun providerRequest() = + StandardIntegrityManager.PrepareIntegrityTokenRequest.builder() + .setCloudProjectNumber(cloudProjectNumber) + .build() + + private fun integrityTokenRequest(requestHash: String) = + StandardIntegrityTokenRequest.builder() + .setRequestHash(requestHash) + .build() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/JsonExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/JsonExt.kt new file mode 100644 index 0000000..8fc1567 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/JsonExt.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.common.utils + +import com.google.gson.Gson +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import com.google.gson.reflect.TypeToken +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import java.lang.reflect.Type +import java.math.BigInteger + +class ByteArrayHexAdapter : JsonSerializer, JsonDeserializer { + + override fun serialize(src: ByteArray, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + return JsonPrimitive(src.toHexString(withPrefix = true)) + } + + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): ByteArray { + return json.asString.fromHex() + } +} + +fun Any?.asGsonParsedNumberOrNull(): BigInteger? = when (this) { + // gson parses integers as double when type is not specified + is Double -> toLong().toBigInteger() + is Long -> toBigInteger() + is Int -> toBigInteger() + is String -> toBigIntegerOrNull() + else -> null +} + +fun Any?.asGsonParsedLongOrNull(): Long? = when (this) { + is Number -> toLong() + is String -> toLongOrNull() + else -> null +} + +fun Any?.asGsonParsedIntOrNull(): Int? = when (this) { + is Number -> toInt() + is String -> toIntOrNull() + else -> null +} + +fun Any?.asGsonParsedNumber(): BigInteger = asGsonParsedNumberOrNull() + ?: throw IllegalArgumentException("Failed to convert gson-parsed object to number") + +fun Gson.parseArbitraryObject(src: String): Map? { + val typeToken = object : TypeToken>() {} + + return fromJson(src, typeToken.type) +} + +fun Gson.fromParsedHierarchy(src: Any?, clazz: Class): T = fromJson(toJsonTree(src), clazz) +inline fun Gson.fromParsedHierarchy(src: Any?): T = fromParsedHierarchy(src, T::class.java) + +inline fun Gson.fromJson(src: String): T = fromJson(src, object : TypeToken() {}.type) + +inline fun Gson.fromJsonOrNull(src: String): T? = runCatching { fromJson(src) }.getOrNull() diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/KeyMutex.kt b/common/src/main/java/io/novafoundation/nova/common/utils/KeyMutex.kt new file mode 100644 index 0000000..e4ab92b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/KeyMutex.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.common.utils + +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class KeyMutex { + + private val mutexesByKey = ConcurrentHashMap() + + suspend inline fun withKeyLock(key: Any, crossinline block: suspend () -> T): T { + val mutex = getMutexForKey(key) + return mutex.withLock { + try { + block() + } finally { + removeMutex(key) + } + } + } + + fun getMutexForKey(key: Any): Mutex { + return mutexesByKey.getOrPut(key) { Mutex() } + } + + fun removeMutex(key: Any) { + mutexesByKey.remove(key) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/KotlinExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/KotlinExt.kt new file mode 100644 index 0000000..96f5bb4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/KotlinExt.kt @@ -0,0 +1,782 @@ +package io.novafoundation.nova.common.utils + +import android.net.Uri +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.launch +import org.web3j.utils.Numeric +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.math.BigDecimal +import java.math.BigInteger +import java.math.MathContext +import java.math.RoundingMode +import java.util.Calendar +import java.util.Collections +import java.util.Date +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.math.sqrt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.measureTimedValue + +private val PERCENTAGE_MULTIPLIER = 100.toBigDecimal() + +fun BigDecimal.fractionToPercentage() = this * PERCENTAGE_MULTIPLIER + +fun Double.percentageToFraction() = this / PERCENTAGE_MULTIPLIER.toDouble() +fun BigDecimal.percentageToFraction() = this.divide(PERCENTAGE_MULTIPLIER, MathContext.DECIMAL64) + +infix fun Int.floorMod(divisor: Int) = Math.floorMod(this, divisor) + +fun Double.ceil(): Double = kotlin.math.ceil(this) + +fun BigInteger.toDuration() = toLong().milliseconds + +@Suppress("UNCHECKED_CAST") +inline fun Result.flatMap(transform: (T) -> Result): Result { + return fold( + onSuccess = { transform(it) }, + onFailure = { this as Result } + ) +} + +inline fun Result.onFailureInstance(action: (E) -> Unit): Result { + return onFailure { + if (it is E) { + action(it) + } + } +} + +inline fun Result.finally(transform: () -> Unit): Result { + transform() + return this +} + +@OptIn(ExperimentalContracts::class) +inline fun Result.mapError(transform: (throwable: Throwable) -> Throwable): Result { + contract { + callsInPlace(transform, InvocationKind.AT_MOST_ONCE) + } + return when (val exception = this.exceptionOrNull()) { + null -> this + else -> Result.failure(transform(exception)) + } +} + +fun Result<*>.coerceToUnit(): Result = map { } + +inline fun measureExecution(label: String, function: () -> R): R { + val (value, time) = measureTimedValue(function) + Log.d("Performance", "$label took $time") + + return value +} + +inline fun Result.mapErrorNotInstance(transform: (throwable: Throwable) -> Throwable): Result { + return mapError { throwable -> + if (throwable !is E) { + transform(throwable) + } else { + throwable + } + } +} + +fun List>>.toMultiSubscription(expectedSize: Int): Flow> { + return mergeIfMultiple() + .runningFold(emptyMap()) { accumulator, tokenIdWithBalance -> + accumulator + tokenIdWithBalance + } + .filter { it.size == expectedSize } +} + +inline fun > enumValueOfOrNull(raw: String): E? = runCatching { enumValueOf(raw) }.getOrNull() + +inline fun List.associateByMultiple(keysExtractor: (V) -> Iterable): Map { + val destination = LinkedHashMap() + + for (element in this) { + val keys = keysExtractor(element) + + for (key in keys) { + destination[key] = element + } + } + + return destination +} + +fun List.safeSubList(fromIndex: Int, toIndex: Int): List { + return subList(fromIndex.coerceIn(0, size), toIndex.coerceIn(0, size)) +} + +suspend fun Iterable.onEachAsync(operation: suspend (T) -> R) { + coroutineScope { + map { async { operation(it) } } + }.awaitAll() +} + +suspend fun Iterable.mapAsync(operation: suspend (T) -> R): List { + return coroutineScope { + map { async { operation(it) } } + }.awaitAll() +} + +suspend fun Iterable.flatMapAsync(operation: suspend (T) -> Collection): List { + return coroutineScope { + map { async { operation(it) } } + }.awaitAll().flatten() +} + +suspend fun Iterable.forEachAsync(operation: suspend (T) -> R) { + mapAsync(operation) +} + +fun ByteArray.startsWith(prefix: ByteArray): Boolean { + if (prefix.size > size) return false + + prefix.forEachIndexed { index, byte -> + if (get(index) != byte) return false + } + + return true +} + +fun ByteArray.endsWith(suffix: ByteArray): Boolean { + if (suffix.size > size) return false + + val offset = size - suffix.size + + suffix.forEachIndexed { index, byte -> + if (get(offset + index) != byte) return false + } + + return true +} + +fun ByteArray.windowed(windowSize: Int): List { + require(windowSize > 0) { + "Window size should be positive" + } + + val result = mutableListOf() + + var i = 0 + + while (i < size) { + val copyStart = i + val copyEnd = (i + windowSize).coerceAtMost(size) + + result.add(copyOfRange(copyStart, copyEnd)) + + i += windowSize + } + + return result +} + +/** + * Compares two BigDecimals taking into account only values but not scale unlike `==` operator + */ +infix fun BigDecimal.hasTheSaveValueAs(another: BigDecimal) = compareTo(another) == 0 + +fun BigInteger.intSqrt() = sqrt(toDouble()).toLong().toBigInteger() + +fun unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE, initializer) + +fun MutableSet.toImmutable(): Set = Collections.unmodifiableSet(this) + +operator fun BigInteger.times(double: Double): BigInteger = toBigDecimal().multiply(double.toBigDecimal()).toBigInteger() + +operator fun BigInteger.times(int: Int): BigInteger = multiply(int.toBigInteger()) + +val BigDecimal.isZero: Boolean + get() = signum() == 0 + +val BigDecimal.isPositive: Boolean + get() = signum() > 0 + +val BigDecimal.isNegative: Boolean + get() = signum() < 0 + +val BigDecimal.isNonNegative: Boolean + get() = signum() >= 0 + +val BigInteger.isNonPositive: Boolean + get() = signum() <= 0 + +val BigInteger.isNonNegative: Boolean + get() = signum() >= 0 + +val BigInteger.isZero: Boolean + get() = signum() == 0 + +fun BigInteger?.orZero(): BigInteger = this ?: BigInteger.ZERO +fun BigDecimal?.orZero(): BigDecimal = this ?: 0.toBigDecimal() + +fun Double?.orZero(): Double = this ?: 0.0 + +fun Int?.orZero(): Int = this ?: 0 + +fun BigInteger.divideToDecimal(divisor: BigInteger, mathContext: MathContext = MathContext.DECIMAL64): BigDecimal { + return toBigDecimal().divide(divisor.toBigDecimal(), mathContext) +} + +fun BigInteger.atLeastZero() = coerceAtLeast(BigInteger.ZERO) + +fun BigDecimal.atLeastZero() = coerceAtLeast(BigDecimal.ZERO) + +fun Int.atLeastZero() = coerceAtLeast(0) + +fun BigDecimal.lessEpsilon(): BigDecimal = when { + this.isZero -> this + else -> this.subtract(BigInteger.ONE.toBigDecimal(scale = MathContext.DECIMAL64.precision)) +} + +fun BigDecimal.divideOrNull(value: BigDecimal): BigDecimal? = try { + this.divide(value) +} catch (e: ArithmeticException) { + null +} + +fun BigDecimal.divideOrNull(value: BigDecimal, mathContext: MathContext): BigDecimal? = try { + this.divide(value, mathContext) +} catch (e: ArithmeticException) { + null +} + +fun BigDecimal.divideOrNull(value: BigDecimal, roundingMode: RoundingMode): BigDecimal? = try { + this.divide(value, roundingMode) +} catch (e: ArithmeticException) { + null +} + +fun BigDecimal.coerceInOrNull(from: BigDecimal, to: BigDecimal): BigDecimal? = if (this >= from && this <= to) { + this +} else { + null +} + +fun Long.daysFromMillis() = TimeUnit.MILLISECONDS.toDays(this) + +@Deprecated( + message = "Use sumOf from stdlib instead", + replaceWith = ReplaceWith( + expression = "this.sumOf(extractor)", + ) +) +inline fun Collection.sumByBigInteger(extractor: (T) -> BigInteger) = sumOf { extractor(it) } + +fun Iterable.sum() = sumOf { it } + +fun String.decodeEvmQuantity(): BigInteger { + return Numeric.decodeQuantity(this) +} + +suspend operator fun Deferred.invoke() = await() + +inline fun Iterable.sumByBigDecimal(extractor: (T) -> BigDecimal) = fold(BigDecimal.ZERO) { acc, element -> + acc + extractor(element) +} + +inline fun Iterable.groupByIntoSet(keySelector: (T) -> K): Map> { + return groupByInto(valueCollectionCreator = { mutableSetOf() }, keySelector = keySelector) +} + +inline fun > Iterable.groupByInto( + valueCollectionCreator: () -> C, + keySelector: (T) -> K +): Map { + val result = mutableMapOf() + + for (element in this) { + val key = keySelector(element) + val collection = result.getOrPut(key) { valueCollectionCreator() } + collection.add(element) + } + return result +} + +inline fun Any?.castOrNull(): T? { + return this as? T +} + +fun ByteArray.padEnd(expectedSize: Int, padding: Byte = 0): ByteArray { + if (size >= expectedSize) return this + + val padded = ByteArray(expectedSize) { padding } + return copyInto(padded) +} + +fun Map.reversed() = HashMap().also { newMap -> + entries.forEach { newMap[it.value] = it.key } +} + +fun Map>.flattenKeys(keyTransform: (K1, K2) -> KR): Map { + return flatMap { (key1, innerMap) -> + innerMap.map { (key2, value) -> + val key = keyTransform(key1, key2) + key to value + } + }.toMap() +} + +fun Iterable.isAscending(comparator: Comparator) = zipWithNext().all { (first, second) -> comparator.compare(first, second) < 0 } + +fun Result.requireException() = exceptionOrNull()!! + +fun Result.requireValue() = getOrThrow()!! + +fun Result.requireInnerNotNull(): Result { + return mapCatching { requireNotNull(it) } +} + +/** + * Given a list finds a partition point in O(log2(N)) given that there is only a single partition point present. + * That is, there is only a single place in the whole array where the value of [partition] changes from false to true + * + * @return index of the first element where invocation of [partition] returns true. Returns null in case there is no such elements in the list + */ +inline fun List.findPartitionPoint(partition: (T) -> Boolean): Int? { + if (isEmpty()) return null + + var lowIdx = 0 + var highIdx = size - 1 + + while (highIdx - lowIdx > 1) { + val midIdx = (lowIdx + highIdx) / 2 + + val midValue = get(midIdx) + val isPartitionTrue = partition(midValue) + + if (isPartitionTrue) { + highIdx = midIdx + } else { + lowIdx = midIdx + 1 + } + } + + val isLowTrue = partition(get(lowIdx)) + if (isLowTrue) return lowIdx + + val isHighTrue = partition(get(highIdx)) + if (isHighTrue) return highIdx + + return null +} + +/** + * @see [findPartitionPoint] + */ +fun List.findPartitionPoint(): Int? { + return findPartitionPoint { it } +} + +fun Result.mapFailure(transform: (Throwable) -> Throwable): Result { + return when { + isFailure -> Result.failure(transform(requireException())) + else -> this + } +} + +fun InputStream.readText() = bufferedReader().use { it.readText() } + +fun List.second() = get(1) + +fun > Collection>.anyIs(value: E) = any { it == value } + +fun Int.quantize(factor: Int) = this - this % factor + +@Suppress("UNCHECKED_CAST") +inline fun Map.mapValuesNotNull(crossinline mapper: (Map.Entry) -> R?): Map { + return mapValues(mapper) + .filterNotNull() +} + +@Suppress("UNCHECKED_CAST") +inline fun Map.filterNotNull(): Map { + return filterValues { it != null } as Map +} + +inline fun Map, V>.dropSecondKey(): Map { + return mapKeys { (keys, _) -> keys.first } +} + +inline fun Array.tryFindNonNull(transform: (T) -> R?): R? { + for (item in this) { + val transformed = transform(item) + + if (transformed != null) return transformed + } + + return null +} + +fun String.bigIntegerFromHex() = removeHexPrefix().toBigInteger(16) +fun String.intFromHex() = removeHexPrefix().toInt(16) + +/** + * Complexity: O(n * log(n)) + */ +// TODO possible to optimize +fun List.median(): Double = sorted().let { + val middleRight = it[it.size / 2] + val middleLeft = it[(it.size - 1) / 2] // will be same as middleRight if list size is odd + + (middleLeft + middleRight) / 2 +} + +fun Collection.average(): BigInteger { + if (isEmpty()) throw NoSuchFieldException("Collection is empty") + + return sum() / size.toBigInteger() +} + +fun generateLinearSequence(initial: Int, step: Int) = generateSequence(initial) { it + step } + +fun Set.toggle(item: T): Set = if (item in this) { + this - item +} else { + this + item +} + +fun List.cycle(): Sequence { + if (isEmpty()) return emptySequence() + + var i = 0 + + return generateSequence { this[i++ % this.size] } +} + +fun List.cycleMultiple(): Sequence { + if (isEmpty()) return emptySequence() + if (size == 1) return sequenceOf(single()) + + var i = 0 + + return generateSequence { this[i++ % this.size] } +} + +inline fun List<*>.findIsInstanceOrNull(): R? { + return find { it is R } as? R +} + +inline fun CoroutineScope.lazyAsync(context: CoroutineContext = EmptyCoroutineContext, crossinline producer: suspend () -> T) = lazy { + async(context) { producer() } +} + +inline fun CoroutineScope.invokeOnCompletion(crossinline action: () -> Unit) { + coroutineContext[Job]?.invokeOnCompletion { action() } +} + +inline fun Iterable.filterToSet(predicate: (T) -> Boolean): Set = filterTo(mutableSetOf(), predicate) + +fun String?.nullIfEmpty(): String? = if (isNullOrEmpty()) null else this + +fun String?.nullIfBlank(): String? = if (isNullOrBlank()) null else this + +fun String.ensureSuffix(suffix: String) = if (endsWith(suffix)) this else this + suffix + +private val NAMED_PATTERN_REGEX = "\\{([a-zA-z]+)\\}".toRegex() + +fun String.formatNamed(vararg values: Pair) = formatNamed(values.toMap()) + +fun String.capitalize() = this.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + +private const val CAMEL_CASE_REGEX_STRING = "(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])" // https://stackoverflow.com/a/7599674 +private val CAMEL_CASE_REGEX = CAMEL_CASE_REGEX_STRING.toRegex() + +private const val SNAKE_CASE_REGEX_STRING = "_" +fun String.splitCamelCase() = CAMEL_CASE_REGEX.split(this) + +fun String.splitSnakeCase() = split(SNAKE_CASE_REGEX_STRING) + +fun String.splitSnakeOrCamelCase() = if (contains(SNAKE_CASE_REGEX_STRING)) { + splitSnakeCase() +} else { + splitCamelCase() +} + +fun String.splitAndCapitalizeWords(): String { + val split = splitSnakeOrCamelCase() + + return split.joinToString(separator = " ") { it.capitalize() } +} + +/** + * Replaces all parts in form of '{name}' to the corresponding value from values using 'name' as a key. + * + * @return formatted string + */ +fun String.formatNamed(values: Map): String { + return formatNamed(values, onUnknownSecret = { "null" }) +} + +fun String.formatNamedOrThrow(values: Map): String { + return formatNamed(values, onUnknownSecret = { throw IllegalArgumentException("Unknown secret: $it") }) +} + +fun String.formatNamed(values: Map, onUnknownSecret: (secretName: String) -> String): String { + return NAMED_PATTERN_REGEX.replace(this) { matchResult -> + val argumentName = matchResult.groupValues.second() + + values[argumentName] ?: onUnknownSecret(argumentName) + } +} + +inline fun T?.defaultOnNull(lazyProducer: () -> T): T { + return this ?: lazyProducer() +} + +fun List.modified(modification: T, condition: (T) -> Boolean): List { + return modified(indexOfFirst(condition), modification) +} + +fun List.removed(condition: (T) -> Boolean): List { + return toMutableList().apply { removeAll(condition) } +} + +fun List.added(toAdd: T): List { + return toMutableList().apply { add(toAdd) } +} + +fun MutableList.removeFirstOrNull(condition: (T) -> Boolean): T? { + val index = indexOfFirstOrNull(condition) ?: return null + return removeAt(index) +} + +fun List.prepended(toPrepend: T): List { + return toMutableList().apply { add(0, toPrepend) } +} + +fun List.modified(index: Int, modification: T): List { + val newList = this.toMutableList() + + newList[index] = modification + + return newList +} + +fun MutableMap.put(entry: Pair) { + put(entry.first, entry.second) +} + +fun Set.added(toAdd: T): Set { + return toMutableSet().apply { add(toAdd) } +} + +fun Map.inserted(key: K, value: V): Map { + return toMutableMap().apply { put(key, value) } +} + +inline fun Iterable.mapToSet(mapper: (T) -> R): Set = mapTo(mutableSetOf(), mapper) + +inline fun Iterable.flatMapToSet(mapper: (T) -> Iterable): Set = flatMapTo(mutableSetOf(), mapper) + +inline fun Iterable.foldToSet(mapper: (T) -> Iterable): Set = fold(mutableSetOf()) { acc, value -> + acc += mapper(value) + acc +} + +inline fun Iterable.groupByToSet(keySelector: (T) -> K): MultiMap { + val destination = mutableMultiMapOf() + + for (element in this) { + val key = keySelector(element) + destination.put(key, element) + } + + return destination +} + +inline fun Iterable.mapNotNullToSet(mapper: (T) -> R?): Set = mapNotNullTo(mutableSetOf(), mapper) + +fun Collection.indexOfFirstOrNull(predicate: (T) -> Boolean) = indexOfFirst(predicate).takeIf { it >= 0 } + +fun Collection.indexOfOrNull(value: T) = indexOf(value).takeIf { it >= 0 } + +@Suppress("IfThenToElvis") +fun ByteArray?.optionalContentEquals(other: ByteArray?): Boolean { + return if (this == null) { + other == null + } else { + this.contentEquals(other) + } +} + +fun Uri.Builder.appendNullableQueryParameter(name: String, value: String?) = apply { + value?.let { appendQueryParameter(name, value) } +} + +fun ByteArray.dropBytes(count: Int) = copyOfRange(count, size) +fun ByteArray.dropBytesLast(count: Int) = copyOfRange(0, size - count) + +fun ByteArray.chunked(count: Int): List = toList().chunked(count).map { it.toByteArray() } + +fun buildByteArray(block: (ByteArrayOutputStream) -> Unit): ByteArray = ByteArrayOutputStream().apply { + block(this) +}.toByteArray() + +fun String.toUuid() = UUID.fromString(this) + +val Int.kilobytes: BigInteger + get() = this.toBigInteger() * 1024.toBigInteger() + +fun ByteArray.compareTo(other: ByteArray, unsigned: Boolean): Int { + if (size != other.size) { + return size - other.size + } + + for (i in indices) { + val result = if (unsigned) { + this[i].toUByte().compareTo(other[i].toUByte()) + } else { + this[i].compareTo(other[i]) + } + + if (result != 0) { + return result + } + } + + return 0 +} + +fun ByteArrayComparator() = Comparator { a, b -> a.compareTo(b, unsigned = false) } + +inline fun CoroutineScope.withChildScope(action: CoroutineScope.() -> Unit) { + val childScope = childScope() + + action(childScope) + + childScope.cancel() +} + +fun List.associateWithIndex() = withIndex().associateBy(keySelector = { it.value }, valueTransform = { it.index }) + +/** + * @return true if action returned true at least once, false otherwise + */ +inline fun repeatUntil(maxTimes: Int?, action: () -> Boolean): Boolean { + var times = 0 + + while (maxTimes == null || times < maxTimes) { + if (action()) return true + + times++ + } + + return false +} + +fun Date.atTheBeginningOfTheDay(): Date { + val calendar = Calendar.getInstance().apply { + time = this@atTheBeginningOfTheDay + resetDay() + } + + return calendar.toDate() +} + +fun Date.atTheEndOfTheDay(): Date { + val calendar = Calendar.getInstance().apply { + time = this@atTheEndOfTheDay + set(Calendar.HOUR_OF_DAY, 23) + set(Calendar.MINUTE, 59) + set(Calendar.SECOND, 59) + set(Calendar.MILLISECOND, 999) + } + + return calendar.toDate() +} + +fun Date.atTheNextDay(): Date { + val calendar = Calendar.getInstance().apply { + time = this@atTheNextDay + add(Calendar.DAY_OF_MONTH, 1) + resetDay() + } + + return calendar.toDate() +} + +fun Float.signum() = when { + this < 0f -> -1f + this > 0f -> 1f + else -> 0f +} + +fun Calendar.toDate(): Date = Date(time.time) + +fun Calendar.resetDay() { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) +} + +inline fun CoroutineScope.launchUnit( + context: CoroutineContext = EmptyCoroutineContext, + crossinline block: suspend CoroutineScope.() -> Unit +) { + launch(context) { block() } +} + +fun Iterable.sum(): Duration = fold(Duration.ZERO) { acc, duration -> acc + duration } + +suspend fun scopeAsync( + context: CoroutineContext = Dispatchers.Default, + block: suspend CoroutineScope.() -> T +): Deferred { + return coroutineScope { + async(context, block = block) + } +} + +fun Int.collectionIndexOrNull(): Int? { + return takeIf { it >= 0 } +} + +fun Set.hasIntersectionWith(other: Set): Boolean { + return this.any { it in other } +} + +@Suppress("UNCHECKED_CAST") +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R +): Flow = kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6 + ) +} + +typealias LazyGet = () -> T diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/LazyAsync.kt b/common/src/main/java/io/novafoundation/nova/common/utils/LazyAsync.kt new file mode 100644 index 0000000..013271b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/LazyAsync.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.common.utils + +fun interface LazyAsync { + + suspend fun get(): T +} + +fun lazyAsync( + mode: AsyncLazyThreadSafetyMode = AsyncLazyThreadSafetyMode.NONE, + initializer: suspend () -> T +): LazyAsync { + return when (mode) { + AsyncLazyThreadSafetyMode.NONE -> UnsafeLazyAsync(initializer) + } +} + +/** + * @see [LazyThreadSafetyMode] + */ +enum class AsyncLazyThreadSafetyMode { + + NONE, +} + +private object UNINITIALIZED_VALUE + +private class UnsafeLazyAsync( + initializer: suspend () -> T +) : LazyAsync { + + private var initializer: (suspend () -> T)? = initializer + private var value: Any? = UNINITIALIZED_VALUE + + override suspend fun get(): T { + if (value === UNINITIALIZED_VALUE) { + value = initializer!!() + initializer = null + } + + @Suppress("UNCHECKED_CAST") + return value as T + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/LifecycleEx.kt b/common/src/main/java/io/novafoundation/nova/common/utils/LifecycleEx.kt new file mode 100644 index 0000000..934b27a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/LifecycleEx.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.common.utils + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner + +fun Lifecycle.onDestroy(action: () -> Unit) { + addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + action() + + removeObserver(this) + } + }) +} + +fun Lifecycle.whenStarted(action: () -> Unit) { + if (currentState.isAtLeast(Lifecycle.State.STARTED)) { + action() + } else { + addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + action() + + removeObserver(this) + } + }) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ListExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ListExt.kt new file mode 100644 index 0000000..79a23a1 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ListExt.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.common.utils + +fun List.isSubsetOf(list: List): Boolean { + return list.containsAll(this) +} + +fun Collection.associateMutableBy(keyExtractor: (T) -> K): MutableMap { + val map = mutableMapOf() + onEach { map[keyExtractor(it)] = it } + return map +} + +fun Collection.isAllEquals(value: (T) -> Any): Boolean { + if (isEmpty()) return false + + val first = value(first()) + return all { value(it) == first } +} + +fun Collection.isLast(value: T): Boolean { + return lastOrNull() == value +} + +fun Collection.isNotLast(value: T): Boolean { + return lastOrNull() != null && !isLast(value) +} + +fun List.getFromTheEndOrNull(index: Int): T? { + return getOrNull(lastIndex - index) +} + +inline fun List.binarySearchFloor(fromIndex: Int = 0, toIndex: Int = size, comparison: (T) -> Int): Int { + rangeCheck(fromIndex, toIndex) + + if (this.isEmpty()) return -1 + + var low = fromIndex + var high = toIndex - 1 + + while (low <= high) { + val mid = (low + high).ushr(1) + val midVal = get(mid) + val cmp = comparison(midVal) + + if (cmp < 0) { + low = mid + 1 + } else if (cmp > 0) { + high = mid - 1 + } else { + return mid // key found + } + } + + // key not found. Takes floor key + return if (low <= 0) { + 0 + } else if (low >= size) { + size - 1 + } else { + low - 1 + } +} + +fun List.rangeCheck(fromIndex: Int, toIndex: Int) { + when { + fromIndex > toIndex -> throw IllegalArgumentException("fromIndex ($fromIndex) is greater than toIndex ($toIndex).") + fromIndex < 0 -> throw IndexOutOfBoundsException("fromIndex ($fromIndex) is less than zero.") + toIndex > size -> throw IndexOutOfBoundsException("toIndex ($toIndex) is greater than size ($size).") + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/LiveDatExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/LiveDatExt.kt new file mode 100644 index 0000000..1915751 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/LiveDatExt.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.common.utils + +import android.widget.EditText +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.LiveDataScope +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.Transformations +import kotlinx.coroutines.flow.Flow + +fun MutableLiveData>.sendEvent() { + this.value = Event(Unit) +} + +fun LiveData.map(mapper: (FROM) -> TO): LiveData { + return map(null, mapper) +} + +fun LiveData.map(initial: TO?, mapper: (FROM) -> TO): LiveData { + return MediatorLiveData().apply { + addSource(this@map) { + value = mapper.invoke(it) + } + + initial?.let(::setValue) + } +} + +fun mediatorLiveData(mediatorBuilder: MediatorLiveData.() -> Unit): MediatorLiveData { + val liveData = MediatorLiveData() + + mediatorBuilder.invoke(liveData) + + return liveData +} + +fun multipleSourceLiveData(vararg sources: LiveData): MediatorLiveData = mediatorLiveData { + for (source in sources) { + updateFrom(source) + } +} + +fun MediatorLiveData.updateFrom(other: LiveData) = addSource(other) { + value = it +} + +/** + * Supports up to N sources, where N is last componentN() in ComponentHolder + * @see ComponentHolder + */ +fun combine( + vararg sources: LiveData<*>, + combiner: (ComponentHolder) -> R +): LiveData { + return MediatorLiveData().apply { + for (source in sources) { + addSource(source) { + val values = sources.map { it.value } + + val nonNull = values.filterNotNull() + + if (nonNull.size == values.size) { + value = combiner.invoke(ComponentHolder(nonNull)) + } + } + } + } +} + +fun LiveData.combine( + another: LiveData, + initial: RESULT? = null, + zipper: (FIRST, SECOND) -> RESULT +): LiveData { + return MediatorLiveData().apply { + addSource(this@combine) { first -> + val second = another.value + + if (first != null && second != null) { + value = zipper.invoke(first, second) + } + } + + addSource(another) { second -> + val first = this@combine.value + + if (first != null && second != null) { + value = zipper.invoke(first, second) + } + } + + initial?.let { value = it } + } +} + +fun LiveData.switchMap( + mapper: (FROM) -> LiveData +) = switchMap(mapper, true) + +fun LiveData.switchMap( + mapper: (FROM) -> LiveData, + triggerOnSwitch: Boolean +): LiveData { + val result: MediatorLiveData = MediatorLiveData() + + result.addSource( + this, + object : Observer { + var mSource: LiveData? = null + + override fun onChanged(x: FROM) { + val newLiveData: LiveData = mapper.invoke(x) + + if (mSource === newLiveData) { + return + } + if (mSource != null) { + result.removeSource(mSource!!) + } + + mSource = newLiveData + + if (mSource != null) { + result.addSource(mSource!!) { y -> result.setValue(y) } + + if (triggerOnSwitch && mSource!!.value != null) { + mSource!!.notifyObservers() + } + } + } + } + ) + + return result +} + +fun LiveData.distinctUntilChanged() = Transformations.distinctUntilChanged(this) + +fun EditText.bindTo(liveData: MutableLiveData, lifecycleOwner: LifecycleOwner) { + onTextChanged { + if (liveData.value != it) { + liveData.value = it + } + } + + liveData.observe( + lifecycleOwner, + Observer { + if (it != text.toString()) { + setText(it) + } + } + ) +} + +fun LiveData.isNotEmpty() = !value.isNullOrEmpty() + +fun LiveData.notifyObservers() { + (this as MutableLiveData).value = value +} + +suspend fun LiveDataScope.emitAll(flow: Flow) = flow.collect { emit(it) } diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Logging.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Logging.kt new file mode 100644 index 0000000..0c30758 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Logging.kt @@ -0,0 +1,4 @@ +package io.novafoundation.nova.common.utils + +val Any.LOG_TAG + get() = this::class.simpleName diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/MapExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/MapExt.kt new file mode 100644 index 0000000..b778f35 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/MapExt.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.utils + +@Suppress("UNCHECKED_CAST") +inline fun Map.filterValuesIsInstance(): Map { + return filterValues { value -> value is R } as Map +} + +fun mapOfNotNullValues(vararg pairs: Pair): Map { + return mapOf(*pairs).filterNotNull() +} + +inline fun Map>.filterValueList(action: (V) -> Boolean): Map> { + return mapValues { (_, list) -> list.filter { action(it) } } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/MaterialCalendarExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/MaterialCalendarExt.kt new file mode 100644 index 0000000..262325e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/MaterialCalendarExt.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils + +import com.google.android.material.datepicker.CalendarConstraints.DateValidator +import kotlinx.parcelize.Parcelize + +@Parcelize +class RangeDateValidator(private val start: Long?, private val end: Long?) : DateValidator { + + override fun isValid(date: Long): Boolean { + if (start == null && end == null) return true + if (start != null && date < start) return false + if (end != null && date > end) return false + + return true + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Min.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Min.kt new file mode 100644 index 0000000..0afa958 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Min.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.utils + +/** + * Interface that can be used to get access to `min` and `max` functions on collections + * when `Comparable` is not implementable for a given type. + * + * In particular, `Comparable` implies presence of the total ordering for a type. + * However, even types with partial ordering, do, in principle, support `Min` and `Max` operations + */ +interface Min> { + + /** + * Find minimum between self and [other] + * Should be commutative, i.e. `a.min(b) = b.min(a)` + */ + fun min(other: T): T +} + +fun > min(first: T, vararg rest: T): T { + return rest.fold(first) { acc, item -> + acc.min(item) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/MultiMap.kt b/common/src/main/java/io/novafoundation/nova/common/utils/MultiMap.kt new file mode 100644 index 0000000..e3d2648 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/MultiMap.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.common.utils + +typealias MutableMultiMap = MutableMap> +typealias MutableMultiMapList = MutableMap> +typealias MultiMap = Map> +typealias MultiMapList = Map> + +fun Map>.toMutableMultiMapList(): MutableMultiMapList { + val mutableMultiMap = mutableMultiListMapOf() + onEach { (key, value) -> + mutableMultiMap.put(key, value) + } + return mutableMultiMap +} + +fun mutableMultiMapOf(): MutableMultiMap = mutableMapOf() + +inline fun buildMultiMap(builder: MutableMultiMap.() -> Unit): MultiMap = mutableMultiMapOf() + .apply(builder) + +fun mutableMultiListMapOf(): MutableMultiMapList = mutableMapOf() + +fun MutableMultiMap.put(key: K, value: V) { + getOrPut(key, ::mutableSetOf).add(value) +} + +fun MutableMultiMap.putAll(key: K, values: Collection) { + getOrPut(key, ::mutableSetOf).addAll(values) +} + +fun MutableMultiMap.putAll(other: MultiMap) { + other.forEach { (k, v) -> + putAll(k, v) + } +} + +@JvmName("putIntoList") +fun MutableMultiMapList.put(key: K, value: V) { + getOrPut(key, ::mutableListOf).add(value) +} + +fun MutableMultiMapList.put(key: K, values: List) { + getOrPut(key, ::mutableListOf).addAll(values) +} + +inline fun buildMultiMapList(builder: MutableMultiMapList.() -> Unit): MultiMapList { + return mutableMultiListMapOf().apply(builder) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/PayloadCreator.kt b/common/src/main/java/io/novafoundation/nova/common/utils/PayloadCreator.kt new file mode 100644 index 0000000..4d67c8b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/PayloadCreator.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.common.utils + +import android.os.Bundle +import android.os.Parcelable +import androidx.fragment.app.Fragment + +const val KEY_PAYLOAD = "KEY_PAYLOAD" + +interface PayloadCreator { + + fun createPayload(payload: T): Bundle +} + +class FragmentPayloadCreator : PayloadCreator { + + override fun createPayload(payload: T): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } +} + +fun Fragment.payload(): T { + return requireArguments().getParcelable(KEY_PAYLOAD)!! +} + +fun Fragment.payloadOrNull(): T? { + return arguments?.getParcelable(KEY_PAYLOAD) as? T +} + +fun Fragment.payloadOrElse(fallback: () -> T): T { + return payloadOrNull() ?: fallback() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Percent.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Percent.kt new file mode 100644 index 0000000..6247c55 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Percent.kt @@ -0,0 +1,60 @@ +@file:Suppress("NOTHING_TO_INLINE", "DeprecatedCallableAddReplaceWith") + +package io.novafoundation.nova.common.utils + +import java.math.BigDecimal + +/** + * Type that represents [Percent] / 100 + * Thus, 0.1 will represent equivalent to 10% + */ +@JvmInline +@Deprecated("Use Fraction which offers much easier and understandable abstraction over fractions") +value class Perbill(val value: Double) : Comparable { + + companion object { + + fun zero() = Perbill(0.0) + } + + override fun compareTo(other: Perbill): Int { + return value.compareTo(other.value) + } +} + +/** + * Type that represents percentages + * E.g. Percent(10) represents value of 10% + */ +@JvmInline +@Deprecated("Use Fraction which offers much easier and understandable abstraction over fractions") +value class Percent(val value: Double) : Comparable { + + companion object { + + fun zero(): Percent = Percent(0.0) + } + + override fun compareTo(other: Percent): Int { + return value.compareTo(other.value) + } + + operator fun div(divisor: Int): Percent { + return Percent(value / divisor) + } +} + +@Deprecated("Use Fraction instead") +inline fun BigDecimal.asPerbill(): Perbill = Perbill(this.toDouble()) + +@Deprecated("Use Fraction instead") +inline fun Double.asPerbill(): Perbill = Perbill(this) + +@Deprecated("Use Fraction instead") +inline fun Double.asPercent(): Percent = Percent(this) + +@Deprecated("Use Fraction instead") +inline fun Perbill.toPercent(): Percent = Percent(value * 100) + +@Deprecated("Use Fraction instead") +inline fun Perbill?.orZero(): Perbill = this ?: Perbill(0.0) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Percentage.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Percentage.kt new file mode 100644 index 0000000..cc8c908 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Percentage.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.common.utils + +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +fun percentage(scale: Int, values: List): List { + val total = values.sumOf { it } + if (total.isZero) { + return values.map { BigDecimal.ZERO } + } + + val accumulatedPercentage = values.map { it / total * BigDecimal.valueOf(100.0) } + .runningReduce { accumulated, next -> accumulated + next } + .map { it.setScale(scale, RoundingMode.HALF_UP) } + + val baseLine = accumulatedPercentage.mapIndexed { index, value -> + if (index == 0) { + BigDecimal.ZERO + } else { + accumulatedPercentage[index - 1] + } + } + + return accumulatedPercentage.mapIndexed { index, value -> + value - baseLine[index] + } +} + +fun percentage(scale: Int, vararg values: BigDecimal): List { + return percentage(scale, values.toList()) +} + +/** + * Splits this BigInteger "total" into parts according to the supplied [weights]. + * + * Returns: + * - An empty list if [weights] is empty. + * - All zeroes if [this] (the total) is negative or any of [weights] are negative. + * - All zeroes if the sum of [weights] is zero. + * - Otherwise, parts distributed as proportionally as possible, with leftover + * integer units allocated to the parts with the largest fractional remainders. + */ +fun BigInteger.splitByWeights(weights: List): List { + if (weights.isEmpty()) { + return emptyList() + } + + if (this < BigInteger.ZERO || weights.any { it < BigInteger.ZERO }) { + return List(weights.size) { BigInteger.ZERO } + } + + val sumOfWeights = weights.sum() + if (sumOfWeights == BigInteger.ZERO) { + return List(weights.size) { BigInteger.ZERO } + } + + val weightedTotals = weights.map { w -> w * this } + val baseParts = weightedTotals.mapTo(mutableListOf()) { wt -> wt.divide(sumOfWeights) } + + val remainders = weightedTotals.map { wt -> wt.mod(sumOfWeights) } + + val sumOfBaseParts = baseParts.sum() + var leftover = this - sumOfBaseParts + + // Distribute leftover among those with largest remainder first + val indicesByRemainder = remainders.indices.sortedByDescending { remainders[it] } + for (i in indicesByRemainder) { + if (leftover <= BigInteger.ZERO) break + baseParts[i] = baseParts[i] + BigInteger.ONE + leftover -= BigInteger.ONE + } + + return baseParts +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/PezkuwiAddressConstructor.kt b/common/src/main/java/io/novafoundation/nova/common/utils/PezkuwiAddressConstructor.kt new file mode 100644 index 0000000..9cab718 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/PezkuwiAddressConstructor.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.common.utils + +import android.util.Log +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.registry.TypeRegistry +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.MULTI_ADDRESS_ID +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.FixedByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.skipAliases + +private const val TAG = "PezkuwiAddressConstructor" + +/** + * Custom address constructor that handles Pezkuwi chains which use different type names. + * Pezkuwi uses "pezsp_runtime::multiaddress::MultiAddress" instead of standard "Address". + */ +object PezkuwiAddressConstructor { + + private val ADDRESS_TYPE_NAMES = listOf( + "Address", + "MultiAddress", + "sp_runtime::multiaddress::MultiAddress", + "pezsp_runtime::multiaddress::MultiAddress" + ) + + /** + * Constructs an address instance compatible with both standard Substrate and Pezkuwi chains. + * Checks the actual type structure to determine the correct encoding format. + */ + fun constructInstance(typeRegistry: TypeRegistry, accountId: AccountId): Any { + // Try to find the address type + var foundTypeName: String? = null + val addressType = ADDRESS_TYPE_NAMES.firstNotNullOfOrNull { name -> + typeRegistry[name]?.also { foundTypeName = name } + } + + Log.d(TAG, "Found address type: $foundTypeName, type class: ${addressType?.javaClass?.simpleName}") + + // If no address type found, return the raw accountId (for chains with simple AccountId) + if (addressType == null) { + Log.d(TAG, "No address type found, returning raw accountId") + return accountId + } + + val resolvedType = addressType.skipAliases() + Log.d(TAG, "Resolved type after skipAliases: ${resolvedType?.javaClass?.simpleName}, name: ${resolvedType?.name}") + + // Check the actual type structure + return when (resolvedType) { + is DictEnum -> { + // Use the actual variant name from the type + // Standard chains use "Id", but Pezkuwi uses numeric variants like "0" + val variantNames = resolvedType.elements.values.map { it.name } + Log.d(TAG, "Type is DictEnum with variants: $variantNames") + + // Use "Id" if available, otherwise use the first variant (index 0) + val idVariantName = if (variantNames.contains(MULTI_ADDRESS_ID)) { + MULTI_ADDRESS_ID + } else { + resolvedType.elements[0]?.name ?: MULTI_ADDRESS_ID + } + Log.d(TAG, "Using variant name: $idVariantName") + DictEnum.Entry(idVariantName, accountId) + } + is FixedByteArray -> { + Log.d(TAG, "Type is FixedByteArray with length: ${resolvedType.length}, returning raw accountId") + // GenericAccountId or similar - return raw + accountId + } + null -> { + Log.d(TAG, "Resolved type is null for type: $foundTypeName") + // If this is a MultiAddress type that couldn't resolve, use variant "0" + if (foundTypeName?.contains("MultiAddress") == true || foundTypeName?.contains("multiaddress") == true) { + Log.d(TAG, "Type appears to be MultiAddress, using variant 0") + DictEnum.Entry("0", accountId) + } else { + Log.d(TAG, "Returning raw accountId") + accountId + } + } + else -> { + Log.d(TAG, "Unknown type: ${resolvedType.javaClass.simpleName}, returning raw accountId") + // Unknown type, try raw accountId instead of DictEnum + accountId + } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Precision.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Precision.kt new file mode 100644 index 0000000..5330005 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Precision.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils + +import android.os.Parcelable +import java.math.BigDecimal +import java.math.BigInteger +import kotlinx.parcelize.Parcelize + +@JvmInline +@Parcelize +value class Precision(val value: Int) : Parcelable + +fun Int.asPrecision() = Precision(this) + +fun BigDecimal.planksFromAmount(precision: Precision) = scaleByPowerOfTen(precision.value).toBigInteger() + +fun BigInteger.amountFromPlanks(precision: Precision) = toBigDecimal(scale = precision.value) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/QrCodeGenerator.kt b/common/src/main/java/io/novafoundation/nova/common/utils/QrCodeGenerator.kt new file mode 100644 index 0000000..31fbfd5 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/QrCodeGenerator.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.common.utils + +import android.graphics.Bitmap +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import com.google.zxing.qrcode.encoder.Encoder +import com.google.zxing.qrcode.encoder.QRCode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class QrCodeGenerator( + private val firstColor: Int, + private val secondColor: Int +) { + + companion object { + private const val RECEIVE_QR_SCALE_SIZE = 1024 + private const val PADDING_SIZE = 2 + + // Max binary payload length with ErrorCorrectionLevel.H is 1273 bytes however nginx still fails with this amount + // With 1000 it works well + // See https://stackoverflow.com/a/11065449 + const val MAX_PAYLOAD_LENGTH = 512 + } + + fun generateQrCode(input: String): QRCode { + val hints = HashMap() + return Encoder.encode(input, ErrorCorrectionLevel.H, hints) + } + + suspend fun generateQrBitmap(input: String): Bitmap { + return withContext(Dispatchers.Default) { + val qrCode = generateQrCode(input) + val byteMatrix = qrCode.matrix + val width = byteMatrix.width + PADDING_SIZE + val height = byteMatrix.height + PADDING_SIZE + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + for (y in 0 until height) { + for (x in 0 until width) { + if (y == 0 || y > byteMatrix.height || x == 0 || x > byteMatrix.width) { + bitmap.setPixel(x, y, secondColor) + } else { + bitmap.setPixel(x, y, if (byteMatrix.get(x - PADDING_SIZE / 2, y - PADDING_SIZE / 2).toInt() == 1) firstColor else secondColor) + } + } + } + Bitmap.createScaledBitmap(bitmap, RECEIVE_QR_SCALE_SIZE, RECEIVE_QR_SCALE_SIZE, false) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/RecyclerViewAdapterExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/RecyclerViewAdapterExt.kt new file mode 100644 index 0000000..86f1109 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/RecyclerViewAdapterExt.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.utils + +import androidx.recyclerview.widget.RecyclerView + +fun RecyclerView.ViewHolder.doIfPositionValid(block: (position: Int) -> Unit) { + val position = bindingAdapterPosition + if (bindingAdapterPosition != RecyclerView.NO_POSITION) { + block(position) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Retries.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Retries.kt new file mode 100644 index 0000000..89818bf --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Retries.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.common.utils + +import android.util.Log +import io.novasama.substrate_sdk_android.wsrpc.recovery.LinearReconnectStrategy +import io.novasama.substrate_sdk_android.wsrpc.recovery.ReconnectStrategy +import kotlinx.coroutines.delay + +suspend inline fun retryUntilDone( + retryStrategy: ReconnectStrategy = LinearReconnectStrategy(step = 500L), + block: () -> T, +): T { + var attempt = 0 + + while (true) { + val blockResult = runCatching { block() } + + if (blockResult.isSuccess) { + return blockResult.requireValue() + } else { + Log.e("RetryUntilDone", "Failed to execute retriable operation:", blockResult.requireException()) + + attempt++ + + delay(retryStrategy.getTimeForReconnect(attempt)) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/RoundCornersOutlineProvider.kt b/common/src/main/java/io/novafoundation/nova/common/utils/RoundCornersOutlineProvider.kt new file mode 100644 index 0000000..9af6f45 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/RoundCornersOutlineProvider.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.common.utils + +import android.graphics.Outline +import android.graphics.Rect +import android.view.View +import android.view.ViewOutlineProvider + +class RoundCornersOutlineProvider( + private val cornerRadius: Float, + private var margin: Rect = Rect() +) : ViewOutlineProvider() { + + fun setMargin(margin: Rect) { + this.margin = margin + } + + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect( + 0 + margin.left, + 0 + margin.top, + view.width - margin.right, + view.height - margin.bottom, + cornerRadius + ) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/RuntimeContext.kt b/common/src/main/java/io/novafoundation/nova/common/utils/RuntimeContext.kt new file mode 100644 index 0000000..2951b2b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/RuntimeContext.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata + +interface RuntimeContext { + + val runtime: RuntimeSnapshot +} + +val RuntimeContext.metadata: RuntimeMetadata + get() = runtime.metadata + +fun RuntimeContext(runtime: RuntimeSnapshot): RuntimeContext { + return InlineRuntimeContext(runtime) +} + +inline fun RuntimeSnapshot.provideContext(action: RuntimeContext.() -> R): R { + return with(RuntimeContext(this)) { + action() + } +} + +@JvmInline +private value class InlineRuntimeContext(override val runtime: RuntimeSnapshot) : RuntimeContext diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/SemiUnboundedRange.kt b/common/src/main/java/io/novafoundation/nova/common/utils/SemiUnboundedRange.kt new file mode 100644 index 0000000..d71a048 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/SemiUnboundedRange.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.common.utils + +interface SemiUnboundedRange> { + + val start: T + + val endInclusive: T? +} + +infix operator fun > T.rangeTo(another: T?): SemiUnboundedRange { + return ComparableSemiUnboundedRange(this, another) +} + +inline fun , R : Comparable> SemiUnboundedRange.map(mapper: (T) -> R): SemiUnboundedRange { + return mapper(start)..endInclusive?.let(mapper) +} + +class ComparableSemiUnboundedRange>(override val start: T, override val endInclusive: T?) : SemiUnboundedRange diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/SharedState.kt b/common/src/main/java/io/novafoundation/nova/common/utils/SharedState.kt new file mode 100644 index 0000000..d401eb4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/SharedState.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.utils + +interface SharedState { + + fun getOrNull(): T? +} + +fun SharedState.getOrThrow(): T = getOrNull() ?: throw IllegalStateException("State is null") + +interface MutableSharedState : SharedState { + + fun set(value: T) + + fun reset() +} + +class DefaultMutableSharedState : MutableSharedState { + + @Volatile + private var value: T? = null + + @Synchronized + override fun getOrNull(): T? { + return value + } + + @Synchronized + override fun set(value: T) { + this.value = value + } + + @Synchronized + override fun reset() { + value = null + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/SingletonDialogHolder.kt b/common/src/main/java/io/novafoundation/nova/common/utils/SingletonDialogHolder.kt new file mode 100644 index 0000000..d898537 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/SingletonDialogHolder.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.common.utils + +import android.app.Dialog + +class SingletonDialogHolder { + + private var dialog: T? = null + + fun isDialogAlreadyOpened(): Boolean { + return dialog != null && dialog?.isShowing == true + } + + fun getDialog(): T? { + return dialog + } + + fun showNewDialogOrSkip(creator: () -> T) { + if (isDialogAlreadyOpened()) return + + dialog = creator() + dialog?.setOnDismissListener { + dialog = null + } + + dialog?.show() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/SingletonHolder.kt b/common/src/main/java/io/novafoundation/nova/common/utils/SingletonHolder.kt new file mode 100644 index 0000000..6663939 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/SingletonHolder.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.common.utils + +open class SingletonHolder(creator: (A, B) -> T) { + + private var creator: ((A, B) -> T)? = creator + + @Volatile private var instance: T? = null + + fun getInstanceOrInit(arg1: A, arg2: B): T { + val localInstance = instance + if (localInstance != null) { + return localInstance + } + + return synchronized(this) { + val i = instance + if (i != null) { + i + } else { + val created = creator!!(arg1, arg2) + instance = created + creator = null + created + } + } + } + + fun getInstance(): T? { + return instance + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/SpannableDSL.kt b/common/src/main/java/io/novafoundation/nova/common/utils/SpannableDSL.kt new file mode 100644 index 0000000..2636f88 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/SpannableDSL.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.common.utils + +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.resources.ResourceManager + +class SpannableStyler(val content: String) { + + private val buildingSpannable = SpannableString(content) + + fun clickable( + text: String, + @ColorInt color: Int? = null, + onClick: () -> Unit + ) { + val startIndex = content.indexOf(text) + + if (startIndex == -1) { + return + } + + val endIndex = startIndex + text.length + + buildingSpannable.setSpan(clickableSpan(onClick), startIndex, endIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + color?.let { + buildingSpannable.setSpan(colorSpan(color), startIndex, endIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + fun build() = buildingSpannable +} + +fun styleText(content: String, block: SpannableStyler.() -> Unit): Spannable { + val builder = SpannableStyler(content) + + builder.block() + + return builder.build() +} + +class SpannableBuilder(private val resourceManager: ResourceManager) { + + private val builder = SpannableStringBuilder() + + fun appendColored(text: CharSequence, @ColorRes color: Int) { + val span = ForegroundColorSpan(resourceManager.getColor(color)) + + append(text, span) + } + + fun appendColored(@StringRes textRes: Int, @ColorRes color: Int) { + val text = resourceManager.getString(textRes) + + return appendColored(text, color) + } + + fun append(text: CharSequence) { + builder.append(text) + } + + fun setFullSpan(span: Any) { + builder.setSpan(span, 0, builder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + fun appendSpan(span: Any) { + builder.append(" ") + builder.setSpan(span, builder.length - 1, builder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + fun build(): SpannableString = SpannableString(builder) + + private fun append(text: CharSequence, span: Any): SpannableBuilder { + builder.append(text, span, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + + return this + } +} + +fun buildSpannable(resourceManager: ResourceManager, block: SpannableBuilder.() -> Unit): Spannable { + val builder = SpannableBuilder(resourceManager).apply(block) + + return builder.build() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/SpannableExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/SpannableExt.kt new file mode 100644 index 0000000..9ba80d8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/SpannableExt.kt @@ -0,0 +1,125 @@ +package io.novafoundation.nova.common.utils + +import android.annotation.TargetApi +import android.content.Context +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.os.Build +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.SpannedString +import android.text.TextPaint +import android.text.style.CharacterStyle +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.ImageSpan +import android.text.style.MetricAffectingSpan +import android.text.style.StyleSpan +import android.text.style.TypefaceSpan +import android.view.View +import androidx.annotation.FontRes +import androidx.core.content.res.ResourcesCompat +import androidx.core.text.toSpannable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.spannable.LineHeightDrawableSpan + +fun CharSequence.toSpannable(span: Any): Spannable { + return this.toSpannable().setFullSpan(span) +} + +fun CharSequence.bold(): Spannable { + return toSpannable(boldSpan()) +} + +fun Spannable.setFullSpan(span: Any): Spannable { + setSpan(span, 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + return this +} + +// This method is nice for ImageSpan +fun Spannable.setEndSpan(span: Any): Spannable { + return SpannableStringBuilder(this) + .appendEnd(span) +} + +fun SpannableStringBuilder.appendSpace(): SpannableStringBuilder { + append(" ") + return this +} + +fun SpannableStringBuilder.append(text: CharSequence?, span: Any): SpannableStringBuilder { + val startSpan = length + append(text) + .setSpan(span, startSpan, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + return this +} + +fun SpannableStringBuilder.appendEnd(span: Any): SpannableStringBuilder { + appendSpace() + .setSpan(span, length - 1, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + return this +} + +fun clickableSpan(onClick: () -> Unit) = object : ClickableSpan() { + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } + + override fun onClick(widget: View) { + onClick() + } +} + +fun colorSpan(color: Int) = ForegroundColorSpan(color) + +fun fontSpan(resourceManager: ResourceManager, @FontRes fontRes: Int) = fontSpan(resourceManager.getFont(fontRes)) + +fun fontSpan(context: Context, @FontRes fontRes: Int) = fontSpan(ResourcesCompat.getFont(context, fontRes)) + +fun fontSpan(typeface: Typeface?): CharacterStyle { + return when { + typeface == null -> NoOpSpan() + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> typefaceSpanCompatV28(typeface) + + else -> CustomTypefaceSpan(typeface) + } +} + +fun boldSpan() = StyleSpan(Typeface.BOLD) + +fun drawableText(drawable: Drawable, extendToLineHeight: Boolean = false): Spannable = SpannableStringBuilder().appendEnd( + drawableSpan(drawable, extendToLineHeight) +) + +fun drawableSpan(drawable: Drawable, extendToLineHeight: Boolean = false) = when (extendToLineHeight) { + true -> LineHeightDrawableSpan(drawable) + false -> ImageSpan(drawable) +} + +fun CharSequence.formatAsSpannable(vararg args: Any): SpannedString { + return SpannableFormatter.format(this, *args) +} + +@TargetApi(Build.VERSION_CODES.P) +private fun typefaceSpanCompatV28(typeface: Typeface) = + TypefaceSpan(typeface) + +private class CustomTypefaceSpan(private val typeface: Typeface?) : MetricAffectingSpan() { + override fun updateDrawState(paint: TextPaint) { + paint.typeface = typeface + } + + override fun updateMeasureState(paint: TextPaint) { + paint.typeface = typeface + } +} + +private class NoOpSpan : CharacterStyle() { + override fun updateDrawState(tp: TextPaint?) {} +} + +fun CharSequence.addColor(color: Int): Spannable { + return this.toSpannable(colorSpan(color)) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/StringExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/StringExt.kt new file mode 100644 index 0000000..883f50c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/StringExt.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.utils + +fun String.removeSpacing(): String { + return replace(" ", "") +} + +fun CharSequence.ellipsizeMiddle(shownSymbols: Int): CharSequence { + if (length < shownSymbols * 2) return this + + return StringBuilder(take(shownSymbols)) + .append("...") + .append(takeLast(shownSymbols)) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/SubstrateSdkExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/SubstrateSdkExt.kt new file mode 100644 index 0000000..c756602 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/SubstrateSdkExt.kt @@ -0,0 +1,683 @@ +package io.novafoundation.nova.common.utils + +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import io.emeraldpay.polkaj.scale.ScaleCodecWriter +import io.novafoundation.nova.common.data.network.runtime.binding.bindNullableNumberConstant +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumberConstant +import io.novafoundation.nova.common.data.network.runtime.binding.fromByteArrayOrIncompatible +import io.novafoundation.nova.common.data.network.runtime.binding.fromHexOrIncompatible +import io.novafoundation.nova.core.model.Node +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.seed.SeedFactory +import io.novasama.substrate_sdk_android.encrypt.vByte +import io.novasama.substrate_sdk_android.extensions.asEthereumAccountId +import io.novasama.substrate_sdk_android.extensions.asEthereumAddress +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.extensions.toAddress +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.RuntimeType +import io.novasama.substrate_sdk_android.runtime.definitions.types.Type +import io.novasama.substrate_sdk_android.runtime.definitions.types.bytesOrNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromByteArrayOrNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.DefaultSignedExtensions +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Extrinsic +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.findExplicitOrNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.signer +import io.novasama.substrate_sdk_android.runtime.definitions.types.skipAliases +import io.novasama.substrate_sdk_android.runtime.definitions.types.skipAliasesOrNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.toByteArray +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.getGenesisHashOrThrow +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.getGenesisHashOrThrow +import io.novasama.substrate_sdk_android.runtime.metadata.ExtrinsicMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.TransactionExtensionId +import io.novasama.substrate_sdk_android.runtime.metadata.TransactionExtensionMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.call +import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.fullName +import io.novasama.substrate_sdk_android.runtime.metadata.method +import io.novasama.substrate_sdk_android.runtime.metadata.methodOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.module.Constant +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event +import io.novasama.substrate_sdk_android.runtime.metadata.module.FunctionArgument +import io.novasama.substrate_sdk_android.runtime.metadata.module.MetadataFunction +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import io.novasama.substrate_sdk_android.runtime.metadata.moduleOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.runtimeApiOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.splitKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import io.novasama.substrate_sdk_android.scale.Schema +import io.novasama.substrate_sdk_android.scale.dataType.DataType +import io.novasama.substrate_sdk_android.scale.utils.toUnsignedBytes +import io.novasama.substrate_sdk_android.ss58.SS58Encoder +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.addressPrefix +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAddress +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.networkStateFlow +import io.novasama.substrate_sdk_android.wsrpc.state.SocketStateMachine +import kotlinx.coroutines.flow.first +import org.web3j.crypto.Sign +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.nio.ByteBuffer +import java.nio.ByteOrder + +typealias PalletName = String + +val BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH: String + get() = "//44//60//0/0/0" + +val SS58Encoder.DEFAULT_PREFIX: Short + get() = 42.toShort() + +val SS58Encoder.UNIFIED_ADDRESS_PREFIX: Short + get() = 0.toShort() + +val SS58Encoder.GENERIC_ADDRESS_PREFIX: Short + get() = DEFAULT_PREFIX + +fun BIP32JunctionDecoder.default() = decode(DEFAULT_DERIVATION_PATH) + +fun StorageEntry.defaultInHex() = default.toHexString(withPrefix = true) + +fun DictEnum.getOrNull(name: String): Type<*>? { + val element = elements.values.find { it.name == name } ?: return null + return element.value.skipAliasesOrNull()?.value +} + +fun ByteArray.toAddress(networkType: Node.NetworkType) = toAddress(networkType.runtimeConfiguration.addressByte) + +fun String.isValidSS58Address() = runCatching { toAccountId() }.isSuccess + +fun String.removeHexPrefix() = removePrefix("0x") + +fun MetadataFunction.argument(name: String): FunctionArgument = arguments.first { it.name == name } + +fun MetadataFunction.argumentType(name: String): RuntimeType<*, *> = requireNotNull(argument(name).type) + +fun FunctionArgument.requireActualType() = type?.skipAliases()!! + +fun Short.toByteArray(byteOrder: ByteOrder = ByteOrder.BIG_ENDIAN): ByteArray { + val buffer = ByteBuffer.allocate(2) + buffer.order(byteOrder) + buffer.putShort(this) + return buffer.array() +} + +val Short.bigEndianBytes + get() = toByteArray(ByteOrder.BIG_ENDIAN) + +val Short.littleEndianBytes + get() = toByteArray(ByteOrder.LITTLE_ENDIAN) + +fun ByteArray.toBigEndianShort(): Short = ByteBuffer.wrap(this).order(ByteOrder.BIG_ENDIAN).short +fun ByteArray.toBigEndianU16(): UShort = toBigEndianShort().toUShort() + +fun BigInteger.toUnsignedLittleEndian(): ByteArray { + return toUnsignedBytes().reversedArray() +} + +fun BigInteger.takeUnlessZero(): BigInteger? { + return takeUnless { isZero } +} + +fun ByteArray.toBigEndianU32(): UInt = ByteBuffer.wrap(this).order(ByteOrder.BIG_ENDIAN).int.toUInt() + +fun DataType.fromHex(hex: String): T { + val codecReader = ScaleCodecReader(hex.fromHex()) + + return read(codecReader) +} + +fun DataType.toHex(value: T): String { + return toByteArray(value).toHexString(withPrefix = true) +} + +fun DataType.toByteArray(value: T): ByteArray { + val stream = ByteArrayOutputStream() + val writer = ScaleCodecWriter(stream) + + write(writer, value) + + return stream.toByteArray() +} + +fun RuntimeType<*, *>.toHexUntypedOrNull(runtime: RuntimeSnapshot, value: Any?) = + bytesOrNull(runtime, value)?.toHexString(withPrefix = true) + +fun RuntimeSnapshot.isParachain() = metadata.hasModule(Modules.PARACHAIN_SYSTEM) + +fun RuntimeSnapshot.composeCall( + moduleName: String, + callName: String, + arguments: Map +): GenericCall.Instance { + val module = metadata.module(moduleName) + val call = module.call(callName) + + return GenericCall.Instance(module, call, arguments) +} + +context(RuntimeContext) +fun composeCall( + moduleName: String, + callName: String, + arguments: Map +): GenericCall.Instance { + return runtime.composeCall(moduleName, callName, arguments) +} + +typealias StructBuilderWithContext = S.(EncodableStruct) -> Unit + +operator fun > S.invoke(block: StructBuilderWithContext? = null): EncodableStruct { + val struct = EncodableStruct(this) + + block?.invoke(this, struct) + + return struct +} + +fun > EncodableStruct.hash(): String { + return schema.toByteArray(this).blake2b256().toHexString(withPrefix = true) +} + +fun ExtrinsicMetadata.hasSignedExtension(id: String): Boolean { + return signedExtensions.any { it.id == id } +} + +fun String.extrinsicHash(): String { + return fromHex().blake2b256().toHexString(withPrefix = true) +} + +fun StorageEntry.decodeValue(value: String?, runtimeSnapshot: RuntimeSnapshot) = value?.let { + val type = type.value ?: throw IllegalStateException("Unknown value type for storage ${this.fullName}") + + type.fromHexOrIncompatible(it, runtimeSnapshot) +} + +fun Constant.decodedValue(runtimeSnapshot: RuntimeSnapshot): Any? { + val type = type ?: throw IllegalStateException("Unknown value type for constant ${this.name}") + + return type.fromByteArrayOrIncompatible(value, runtimeSnapshot) +} + +context(RuntimeContext) +fun Constant.getAs(binding: (dynamicInstance: Any?) -> V): V { + val rawValue = decodedValue(runtime) + return binding(rawValue) +} + +fun String.toHexAccountId(): String = toAccountId().toHexString() + +fun Extrinsic.Instance.tip(): BigInteger? = findExplicitOrNull(DefaultSignedExtensions.CHECK_TX_PAYMENT) as? BigInteger + +fun Module.constant(name: String) = constantOrNull(name) ?: throw NoSuchElementException() + +fun Module.numberConstant(name: String, runtimeSnapshot: RuntimeSnapshot) = bindNumberConstant(constant(name), runtimeSnapshot) + +fun Module.numberConstantOrNull(name: String, runtimeSnapshot: RuntimeSnapshot) = constantOrNull(name)?.let { + bindNumberConstant(it, runtimeSnapshot) +} + +context(RuntimeContext) +fun Module.numberConstant(name: String) = numberConstant(name, runtime) + +context(RuntimeContext) +fun RuntimeType<*, D>.fromHex(value: HexString): D { + return fromHex(runtime, value) +} + +fun Module.optionalNumberConstant(name: String, runtimeSnapshot: RuntimeSnapshot) = bindNullableNumberConstant(constant(name), runtimeSnapshot) + +fun Constant.asNumber(runtimeSnapshot: RuntimeSnapshot) = bindNumberConstant(this, runtimeSnapshot) + +fun Constant.decoded(runtimeSnapshot: RuntimeSnapshot): Any? { + return type?.fromByteArrayOrNull(runtimeSnapshot, value) +} + +fun Module.constantOrNull(name: String) = constants[name] + +fun RuntimeMetadata.staking() = module(Modules.STAKING) + +fun RuntimeMetadata.voterListOrNull() = firstExistingModuleOrNull(Modules.VOTER_LIST, Modules.BAG_LIST) +fun RuntimeMetadata.voterListName(): String = requireNotNull(voterListOrNull()).name + +fun RuntimeMetadata.system() = module(Modules.SYSTEM) + +fun RuntimeMetadata.multisig() = module(Modules.MULTISIG) + +fun RuntimeMetadata.balances() = module(Modules.BALANCES) + +fun RuntimeMetadata.eqBalances() = module(Modules.EQ_BALANCES) + +fun RuntimeMetadata.tokens() = module(Modules.TOKENS) + +fun RuntimeMetadata.assetRegistry() = module(Modules.ASSET_REGISTRY) + +fun RuntimeMetadata.currencies() = module(Modules.CURRENCIES) +fun RuntimeMetadata.currenciesOrNull() = moduleOrNull(Modules.CURRENCIES) +fun RuntimeMetadata.crowdloan() = module(Modules.CROWDLOAN) +fun RuntimeMetadata.uniques() = module(Modules.UNIQUES) + +fun RuntimeMetadata.babe() = module(Modules.BABE) +fun RuntimeMetadata.elections() = module(Modules.ELECTIONS) + +fun RuntimeMetadata.electionsOrNull() = moduleOrNull(Modules.ELECTIONS) + +fun RuntimeMetadata.committeeManagementOrNull() = moduleOrNull("CommitteeManagement") + +fun RuntimeMetadata.babeOrNull() = moduleOrNull(Modules.BABE) + +fun RuntimeMetadata.timestampOrNull() = moduleOrNull(Modules.TIMESTAMP) +fun RuntimeMetadata.timestamp() = module(Modules.TIMESTAMP) + +fun RuntimeMetadata.slots() = module(Modules.SLOTS) + +fun RuntimeMetadata.session() = module(Modules.SESSION) + +fun RuntimeMetadata.parachainStaking() = module(Modules.PARACHAIN_STAKING) + +fun RuntimeMetadata.vesting() = module(Modules.VESTING) + +fun RuntimeMetadata.identity() = module(Modules.IDENTITY) + +fun RuntimeMetadata.automationTime() = module(Modules.AUTOMATION_TIME) + +fun RuntimeMetadata.parachainInfoOrNull() = firstExistingModuleOrNull(Modules.PARACHAIN_INFO, Modules.TEYRCHAIN_INFO) +fun RuntimeMetadata.parasOrNull() = moduleOrNull(Modules.PARAS) + +fun RuntimeMetadata.referenda() = module(Modules.REFERENDA) + +fun RuntimeMetadata.convictionVoting() = module(Modules.CONVICTION_VOTING) + +fun RuntimeMetadata.democracy() = module(Modules.DEMOCRACY) + +fun RuntimeMetadata.scheduler() = module(Modules.SCHEDULER) + +fun RuntimeMetadata.treasury() = module(Modules.TREASURY) + +fun RuntimeMetadata.electionProviderMultiPhaseOrNull() = moduleOrNull(Modules.ELECTION_PROVIDER_MULTI_PHASE) + +fun RuntimeMetadata.preImage() = module(Modules.PREIMAGE) + +fun RuntimeMetadata.nominationPools() = module(Modules.NOMINATION_POOLS) + +fun RuntimeMetadata.delegatedStakingOrNull() = moduleOrNull(Modules.DELEGATED_STAKING) + +fun RuntimeMetadata.delegatedStaking() = module(Modules.DELEGATED_STAKING) + +fun RuntimeMetadata.nominationPoolsOrNull() = moduleOrNull(Modules.NOMINATION_POOLS) + +fun RuntimeMetadata.assetConversionOrNull() = moduleOrNull(Modules.ASSET_CONVERSION) + +fun RuntimeMetadata.omnipoolOrNull() = moduleOrNull(Modules.OMNIPOOL) + +fun RuntimeMetadata.omnipool() = module(Modules.OMNIPOOL) + +fun RuntimeMetadata.stableSwapOrNull() = moduleOrNull(Modules.STABLE_SWAP) + +fun RuntimeMetadata.xykOrNull() = moduleOrNull(Modules.XYK) + +fun RuntimeMetadata.xyk() = module(Modules.XYK) + +fun RuntimeMetadata.stableSwap() = module(Modules.STABLE_SWAP) + +fun RuntimeMetadata.dynamicFeesOrNull() = moduleOrNull(Modules.DYNAMIC_FEES) + +fun RuntimeMetadata.dynamicFees() = module(Modules.DYNAMIC_FEES) + +fun RuntimeMetadata.multiTransactionPayment() = module(Modules.MULTI_TRANSACTION_PAYMENT) + +fun RuntimeMetadata.referralsOrNull() = moduleOrNull(Modules.REFERRALS) + +fun RuntimeMetadata.assetConversion() = module(Modules.ASSET_CONVERSION) + +fun RuntimeMetadata.proxyOrNull() = moduleOrNull(Modules.PROXY) + +fun RuntimeMetadata.proxy() = module(Modules.PROXY) + +fun RuntimeMetadata.utility() = module(Modules.UTILITY) + +fun RuntimeMetadata.collatorStaking() = module(Modules.COLLATOR_STAKING) + +fun RuntimeMetadata.firstExistingModuleName(vararg options: String): String { + return options.first(::hasModule) +} + +fun RuntimeMetadata.firstExistingCall(vararg options: Pair): MetadataFunction { + val result = options.tryFindNonNull { (moduleName, functionName) -> + moduleOrNull(moduleName)?.callOrNull(functionName) + } + + return requireNotNull(result) +} + +fun RuntimeMetadata.firstExistingCall(options: List>): MetadataFunction { + return firstExistingCall(*options.toTypedArray()) +} + +fun RuntimeMetadata.firstExistingModuleOrNull(vararg options: String): Module? { + return options.tryFindNonNull { moduleOrNull(it) } +} + +fun Module.firstExistingCallName(vararg options: String): String { + return options.first(::hasCall) +} + +fun RuntimeMetadata.xcmPalletName() = firstExistingModuleName("XcmPallet", "PolkadotXcm", "PezkuwiXcm") + +fun RuntimeMetadata.xcmPalletNameOrNull(): String? = firstExistingModuleOrNull("XcmPallet", "PolkadotXcm", "PezkuwiXcm")?.name + +fun RuntimeMetadata.xTokensName() = firstExistingModuleName("XTokens", "Xtokens") + +fun StorageEntry.splitKeyToComponents(runtime: RuntimeSnapshot, key: String): ComponentHolder { + return ComponentHolder(splitKey(runtime, key)) +} + +fun String.networkType() = Node.NetworkType.findByAddressByte(addressPrefix())!! + +fun RuntimeMetadata.hasModule(name: String) = moduleOrNull(name) != null +fun RuntimeMetadata.hasConstant(module: String, constant: String) = moduleOrNull(module)?.constantOrNull(constant) != null + +fun Module.hasCall(name: String) = callOrNull(name) != null + +fun Module.hasStorage(storage: String) = storageOrNull(storage) != null + +context(RuntimeContext) +fun StorageEntry.createStorageKey(vararg keyArguments: Any?): String { + return if (keyArguments.isEmpty()) { + storageKey() + } else { + storageKey(runtime, *keyArguments) + } +} + +fun SeedFactory.deriveSeed32(mnemonicWords: String, password: String?) = cropSeedTo32Bytes(deriveSeed(mnemonicWords, password)) + +private fun cropSeedTo32Bytes(seedResult: SeedFactory.Result): SeedFactory.Result { + return SeedFactory.Result(seed = seedResult.seed.copyOfRange(0, 32), seedResult.mnemonic) +} + +fun GenericCall.Instance.oneOf(vararg functionCandidates: MetadataFunction?): Boolean { + return functionCandidates.any { candidate -> candidate != null && function == candidate } +} + +fun Extrinsic.Instance.isSigned(): Boolean { + return signer() != null +} + +fun GenericCall.Instance.instanceOf(functionCandidate: MetadataFunction): Boolean = function == functionCandidate + +fun GenericCall.Instance.instanceOf(moduleName: String, callName: String): Boolean = moduleName == module.name && callName == function.name + +fun GenericCall.Instance.instanceOf(moduleName: String, vararg callNames: String): Boolean = moduleName == module.name && function.name in callNames + +fun GenericEvent.Instance.instanceOf(moduleName: String, eventName: String): Boolean = moduleName == module.name && eventName == event.name + +fun GenericEvent.Instance.instanceOf(moduleName: String, vararg eventNames: String): Boolean = moduleName == module.name && event.name in eventNames + +fun GenericEvent.Instance.instanceOf(event: Event): Boolean = event.index == this.event.index + +fun RuntimeMetadata.assetConversionAssetIdType(): RuntimeType<*, *>? { + val runtimeApi = runtimeApiOrNull("AssetConversionApi") ?: return null + + return runtimeApi.method("quote_price_tokens_for_exact_tokens") + .inputs.first().type +} + +fun ExtrinsicMetadata.transactionExtensionOrNull(id: TransactionExtensionId): TransactionExtensionMetadata? { + return signedExtensions.find { it.id == id } +} + +fun structOf(vararg pairs: Pair) = Struct.Instance(mapOf(*pairs)) + +fun SignedRaw.toEcdsaSignatureData(): Sign.SignatureData { + return signatureWrapper.run { + require(this is SignatureWrapper.Ecdsa) + Sign.SignatureData(v, r, s) + } +} + +fun SignedRaw.asHexString() = signatureWrapper.asHexString() + +fun SignatureWrapper.asHexString() = signature.toHexString(withPrefix = true) + +fun String.ethereumAddressToAccountId() = asEthereumAddress().toAccountId().value +fun AccountId.ethereumAccountIdToAddress(withChecksum: Boolean = true) = asEthereumAccountId().toAddress(withChecksum).value + +// We do not use all-zeros account here or for emptySubstrateAccountId since most substrate chains forbid transfers from this account +fun emptyEthereumAccountId() = ByteArray(20) { 1 } + +fun emptySubstrateAccountId() = ByteArray(32) { 1 } + +fun emptyEthereumAddress() = emptyEthereumAccountId().ethereumAccountIdToAddress(withChecksum = false) + +val InheritedImplication.chainId: String + get() = getGenesisHashOrThrow().toHexString() + +fun ExtrinsicBuilder.getChainIdOrThrow(): String { + return getGenesisHashOrThrow().toHexString() +} + +fun RuntimeMetadata.moduleOrFallback(name: String, vararg fallbacks: String): Module = modules[name] + ?: fallbacks.firstOrNull { modules[it] != null } + ?.let { modules[it] } ?: throw NoSuchElementException() + +fun Module.storageOrFallback(name: String, vararg fallbacks: String): StorageEntry = storage?.get(name) + ?: fallbacks.firstOrNull { storage?.get(it) != null } + ?.let { storage?.get(it) } ?: throw NoSuchElementException() + +suspend fun SocketService.awaitConnected() { + networkStateFlow().first { it is SocketStateMachine.State.Connected } +} + +fun String.hexBytesSize(): Int { + val contentLength = if (startsWith("0x")) { + length - 2 + } else { + length + } + + return contentLength / 2 +} + +fun RuntimeMetadata.hasRuntimeApisMetadata(): Boolean { + return apis != null +} + +fun RuntimeMetadata.hasDetectedRuntimeApi(section: String, method: String): Boolean { + if (!hasRuntimeApisMetadata()) return false + + return runtimeApiOrNull(section)?.methodOrNull(method) != null +} + +fun GenericCall.Instance.toHex(runtimeSnapshot: RuntimeSnapshot): String { + return toByteArray(runtimeSnapshot).toHexString(withPrefix = true) +} + +fun GenericCall.Instance.toByteArray(runtimeSnapshot: RuntimeSnapshot): ByteArray { + return GenericCall.toByteArray(runtimeSnapshot, this) +} + +fun GenericCall.Instance.callHash(runtimeSnapshot: RuntimeSnapshot): ByteArray { + return toByteArray(runtimeSnapshot).blake2b256() +} + +fun String.callHash(): ByteArray { + return fromHex().blake2b256() +} + +fun String.callHashString(): String { + return callHash().toHexString(withPrefix = true) +} + +fun SignatureWrapperEcdsa(signature: ByteArray): SignatureWrapper.Ecdsa { + require(signature.size == 65) + + val r = signature.copyOfRange(0, 32) + val s = signature.copyOfRange(32, 64) + val v = signature[64].convertToWeb3jCompatibleVByte() + + return SignatureWrapper.Ecdsa(v = byteArrayOf(v), r = r, s = s) +} + +/** + * Ensure this signature is compatible with external signers and verifiers (dapps) + * We need to do that due to differences in ECDSA v-byte format + */ +fun SignatureWrapper.convertToExternalCompatibleFormat(): SignatureWrapper { + return when (this) { + is SignatureWrapper.Ecdsa -> convertToExternalCompatibleVByteFormat() + is SignatureWrapper.Sr25519, is SignatureWrapper.Ed25519 -> this + } +} + +private fun SignatureWrapper.Ecdsa.convertToExternalCompatibleVByteFormat(): SignatureWrapper.Ecdsa { + return SignatureWrapper.Ecdsa( + v = byteArrayOf(vByte.convertToExternalCompatibleVByte()), + r = r, + s = s + ) +} + +/** + * @see convertToWeb3jCompatibleVByte + */ +private fun Byte.convertToExternalCompatibleVByte(): Byte { + if (this in 0..7) { + return this + } + + if (this in 27..34) { + return (this - 27).toByte() + } + + throw IllegalArgumentException("Invalid vByte: $this") +} + +// Web3j supports only one format - when vByte is between [27..34] +// However, there is a second format - when vByte is between [0..7] - e.g. Ledger and Parity Signer +private fun Byte.convertToWeb3jCompatibleVByte(): Byte { + if (this in 27..34) { + return this + } + + if (this in 0..7) { + return (this + 27).toByte() + } + + throw IllegalArgumentException("Invalid vByte: $this") +} + +object Modules { + + const val VESTING: String = "Vesting" + const val STAKING = "Staking" + const val BALANCES = "Balances" + const val EQ_BALANCES = "EqBalances" + const val SYSTEM = "System" + const val CROWDLOAN = "Crowdloan" + const val BABE = "Babe" + const val ELECTIONS = "Elections" + const val TIMESTAMP = "Timestamp" + const val SLOTS = "Slots" + const val SESSION = "Session" + + const val ASSETS = "Assets" + const val TOKENS = "Tokens" + const val CURRENCIES = "Currencies" + + const val UNIQUES = "Uniques" + + const val PARACHAIN_STAKING = "ParachainStaking" + + const val PARACHAIN_SYSTEM = "ParachainSystem" + + const val IDENTITY = "Identity" + + const val PARACHAIN_INFO = "ParachainInfo" + const val TEYRCHAIN_INFO = "TeyrchainInfo" + const val PARAS = "Paras" + + const val AUTOMATION_TIME = "AutomationTime" + + const val REFERENDA = "Referenda" + const val CONVICTION_VOTING = "ConvictionVoting" + + const val SCHEDULER = "Scheduler" + + const val TREASURY = "Treasury" + + const val PREIMAGE = "Preimage" + + const val DEMOCRACY = "Democracy" + + const val VOTER_LIST = "VoterList" + const val BAG_LIST = "BagsList" + + const val ELECTION_PROVIDER_MULTI_PHASE = "ElectionProviderMultiPhase" + + const val NOMINATION_POOLS = "NominationPools" + + const val DELEGATED_STAKING = "DelegatedStaking" + + const val ASSET_CONVERSION = "AssetConversion" + + const val TRANSACTION_PAYMENT = "TransactionPayment" + const val ASSET_TX_PAYMENT = "AssetTxPayment" + + const val UTILITY = "Utility" + + const val PROXY = "Proxy" + + const val AUCTIONS = "auctions" + + const val INDICES = "Indices" + const val GRANDPA = "Grandpa" + const val IM_ONLINE = "ImOnline" + const val BOUNTIES = "Bounties" + const val CHILD_BOUNTIES = "ChildBounties" + const val WHITELIST = "Whitelist" + const val CLAIMS = "Claims" + const val MULTISIG = "Multisig" + const val REGISTRAR = "Registrar" + const val FAST_UNSTAKE = "FastUnstake" + + const val OMNIPOOL = "Omnipool" + + const val DYNAMIC_FEES = "DynamicFees" + + const val MULTI_TRANSACTION_PAYMENT = "MultiTransactionPayment" + + const val REFERRALS = "Referrals" + + const val ROUTER = "Router" + + const val STABLE_SWAP = "Stableswap" + + const val XYK = "XYK" + + const val ASSET_REGISTRY = "AssetRegistry" + + const val COLLATOR_STAKING = "CollatorStaking" + + const val EVM = "EVM" +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/SuspendableProperty.kt b/common/src/main/java/io/novafoundation/nova/common/utils/SuspendableProperty.kt new file mode 100644 index 0000000..26fbaec --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/SuspendableProperty.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.common.utils + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first + +class SuspendableProperty { + private val value = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + fun invalidate() { + value.resetReplayCache() + } + + fun set(new: T) { + value.tryEmit(new) // always successful, since BufferOverflow.DROP_OLDEST is used + } + + suspend fun get(): T = value.first() + + fun observe(): Flow = value +} + +suspend inline fun SuspendableProperty.useValue(action: (T) -> R): R = action(get()) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ToastMessageManager.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ToastMessageManager.kt new file mode 100644 index 0000000..863419c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ToastMessageManager.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.common.utils + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +interface ToastMessageManager { + + val toastMessagesEvents: LiveData> + + fun showToast(message: String) +} + +class RealToastMessageManager : ToastMessageManager { + + override val toastMessagesEvents = MutableLiveData>() + + override fun showToast(message: String) { + toastMessagesEvents.value = Event(message) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/TokenSymbol.kt b/common/src/main/java/io/novafoundation/nova/common/utils/TokenSymbol.kt new file mode 100644 index 0000000..b014942 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/TokenSymbol.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.utils + +import android.os.Parcelable +import io.novafoundation.nova.common.utils.formatting.format +import java.math.BigDecimal +import java.math.RoundingMode +import kotlinx.parcelize.Parcelize + +@JvmInline +@Parcelize +value class TokenSymbol(val value: String) : Parcelable { + + companion object; // extensions + + override fun toString() = value +} + +fun String.asTokenSymbol() = TokenSymbol(this) + +@Deprecated("Use TokenFormatter instead") +fun BigDecimal.formatTokenAmount(roundingMode: RoundingMode = RoundingMode.FLOOR): String { + return format(roundingMode) +} + +@Deprecated("Use TokenFormatter instead") +fun BigDecimal.formatTokenAmount(tokenSymbol: TokenSymbol, roundingMode: RoundingMode = RoundingMode.FLOOR): String { + return format(roundingMode).withTokenSymbol(tokenSymbol) +} + +fun String.withTokenSymbol(tokenSymbol: TokenSymbol): String { + return "$this ${tokenSymbol.value}" +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Tuple4.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Tuple4.kt new file mode 100644 index 0000000..19ef818 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Tuple4.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.utils + +data class Tuple4( + val first: A, + val second: B, + val third: C, + val fourth: D +) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Union.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Union.kt new file mode 100644 index 0000000..0ecd9e0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Union.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.common.utils + +class Union private constructor( + @PublishedApi + internal val value: Any?, + val isLeft: Boolean +) { + + val isRight get() = !isLeft + + companion object { + + fun left(value: L): Union = Union(value, isLeft = true) + + fun right(value: R): Union = Union(value, isLeft = false) + } + + fun leftOrNull() = if (isLeft) { + value as L + } else { + null + } + + fun rightOrNull() = if (isRight) { + value as R + } else { + null + } +} + +fun Union.leftOrThrow() = leftOrNull() ?: error("Not a left value") +fun Union.rightOrThrow() = rightOrNull() ?: error("Not a right value") + +inline fun Union.fold( + left: (L) -> T, + right: (R) -> T +): T { + return if (isLeft) { + left(leftOrThrow()) + } else { + right(rightOrThrow()) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/UriExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/UriExt.kt new file mode 100644 index 0000000..f3938db --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/UriExt.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.utils + +import android.net.Uri + +fun Uri.hasQuery(key: String): Boolean { + return getQueryParameter(key) != null +} + +fun Uri.Builder.appendPathOrSkip(path: String?): Uri.Builder { + if (path != null) { + appendPath(path) + } + + return this +} + +fun Uri.Builder.appendQueries(queries: Map): Uri.Builder { + queries.forEach { (key, value) -> + appendQueryParameter(key, value) + } + + return this +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Urls.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Urls.kt new file mode 100644 index 0000000..28171a1 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Urls.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.common.utils + +import android.util.Patterns +import java.net.URI +import java.net.URL + +object Urls { + + const val HTTP_PREFIX = "http://" + const val HTTPS_PREFIX = "https://" + + /** + * @return normalized url in a form of protocol://host + */ + fun normalizeUrl(url: String): String { + val parsedUrl = URL(url) + + return "${parsedUrl.protocol}://${parsedUrl.host}" + } + + fun normalizePath(url: String): String { + return url.removeSuffix("/").let { + URI.create(it).normalize().toString() + } + } + + fun hostOf(url: String): String { + return URL(url).host + } + + fun domainOf(url: String): String { + return URL(url).authority + } + + fun isValidWebUrl(url: String): Boolean { + return Patterns.WEB_URL.matcher(url).matches() + } + + fun ensureHttpsProtocol(url: String): String { + return when { + url.startsWith(HTTPS_PREFIX) -> url + url.startsWith(HTTP_PREFIX) -> url.replace(HTTP_PREFIX, HTTPS_PREFIX) + else -> "$HTTPS_PREFIX$url" + } + } +} + +val URL.isSecure + get() = protocol == "https" diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ViewClickGestureDetector.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ViewClickGestureDetector.kt new file mode 100644 index 0000000..f73519c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ViewClickGestureDetector.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.common.utils + +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View + +class ViewClickGestureDetector(view: View) : GestureDetector(view.context, ViewClickGestureDetectorListener(view)) + +private class ViewClickGestureDetectorListener(private val view: View) : GestureDetector.OnGestureListener { + + override fun onDown(e: MotionEvent): Boolean { + return false + } + + override fun onShowPress(e: MotionEvent) { + } + + override fun onSingleTapUp(e: MotionEvent): Boolean { + view.performClick() + return true + } + + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean { + return false + } + + override fun onLongPress(e: MotionEvent) { + } + + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + return false + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ViewExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ViewExt.kt new file mode 100644 index 0000000..82f124f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ViewExt.kt @@ -0,0 +1,429 @@ +package io.novafoundation.nova.common.utils + +import android.content.Context +import android.content.res.ColorStateList +import android.content.res.TypedArray +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.view.ViewTreeObserver +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.ImageView +import android.widget.ScrollView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.LayoutRes +import androidx.annotation.StyleableRes +import androidx.core.content.ContextCompat +import androidx.core.content.res.getColorOrThrow +import androidx.core.widget.TextViewCompat +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.facebook.shimmer.ShimmerFrameLayout +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import io.novafoundation.nova.common.presentation.ColoredDrawable +import io.novafoundation.nova.common.utils.input.Input +import io.novafoundation.nova.common.utils.input.valueOrNull + +fun View.inflater(): LayoutInflater { + return LayoutInflater.from(context) +} + +fun View.updatePadding( + top: Int = paddingTop, + bottom: Int = paddingBottom, + start: Int = paddingStart, + end: Int = paddingEnd, +) { + setPadding(start, top, end, bottom) +} + +inline fun EditText.onTextChanged(crossinline listener: (String) -> Unit): TextWatcher { + val textWatcher = object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + listener.invoke(s.toString()) + } + } + + addTextChangedListener(textWatcher) + + return textWatcher +} + +inline fun EditText.onDoneClicked(crossinline listener: () -> Unit) { + setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + listener.invoke() + + true + } + + false + } +} + +fun EditText.setSelectionEnd() { + setSelection(text.length) +} + +fun ViewGroup.inflateChild(@LayoutRes id: Int, attachToRoot: Boolean = false): View { + return LayoutInflater.from(context).run { + inflate(id, this@inflateChild, attachToRoot) + } +} + +fun TextView.setTextColorRes(@ColorRes colorRes: Int) = setTextColor(ContextCompat.getColor(context, colorRes)) + +fun View.updateTopMargin(newMargin: Int) { + (layoutParams as? MarginLayoutParams)?.let { + it.setMargins(it.leftMargin, newMargin, it.rightMargin, it.bottomMargin) + } +} + +inline fun View.letOrHide(value: T?, setup: (T) -> Unit) { + if (value == null) { + makeGone() + return + } + + makeVisible() + setup(value) +} + +fun ShimmerFrameLayout.setShimmerVisible(visible: Boolean) { + if (visible) startShimmer() else stopShimmer() + + setVisible(visible) +} + +fun TextView.setCompoundDrawables(drawables: Array?) { + setCompoundDrawables(drawables?.getOrNull(0), drawables?.getOrNull(1), drawables?.getOrNull(2), drawables?.getOrNull(3)) +} + +private fun TextView.setCompoundDrawable( + @DrawableRes drawableRes: Int?, + widthInDp: Int?, + heightInDp: Int?, + @ColorRes tint: Int?, + paddingInDp: Int = 0, + applier: TextView.(Drawable) -> Unit, +) { + if (drawableRes == null) { + setCompoundDrawablesWithIntrinsicBounds(null, null, null, null) + return + } + + val drawable = context.getDrawableCompat(drawableRes) + + tint?.let { drawable.mutate().setTint(context.getColor(it)) } + + drawable.updateDimensions(context, widthInDp, heightInDp) + + applier(drawable) + + val paddingInPx = paddingInDp.dp(context) + compoundDrawablePadding = paddingInPx +} + +fun TextView.removeCompoundDrawables() = setCompoundDrawablesRelative(null, null, null, null) + +fun TextView.setDrawableTop( + @DrawableRes drawableRes: Int? = null, + widthInDp: Int? = null, + heightInDp: Int? = widthInDp, + paddingInDp: Int = 0, + @ColorRes tint: Int? = null, +) { + val (start, _, end, bottom) = compoundDrawablesRelative + + setCompoundDrawable(drawableRes, widthInDp, heightInDp, tint, paddingInDp) { + setCompoundDrawablesRelative(start, it, end, bottom) + } +} + +fun TextView.setDrawableEnd( + @DrawableRes drawableRes: Int? = null, + widthInDp: Int? = null, + heightInDp: Int? = widthInDp, + paddingInDp: Int = 0, + @ColorRes tint: Int? = null, +) { + val (start, top, _, bottom) = compoundDrawablesRelative + + setCompoundDrawable(drawableRes, widthInDp, heightInDp, tint, paddingInDp) { + setCompoundDrawablesRelative(start, top, it, bottom) + } +} + +fun TextView.removeDrawableEnd() { + setDrawableEnd(null as Int?) +} + +fun TextView.setDrawableEnd( + icon: ColoredDrawable?, + widthInDp: Int? = null, + heightInDp: Int? = widthInDp, + paddingInDp: Int = 0, +) = setDrawableEnd(icon?.drawableRes, widthInDp, heightInDp, paddingInDp, icon?.iconColor) + +fun TextView.setDrawableStart( + @DrawableRes drawableRes: Int? = null, + widthInDp: Int? = null, + heightInDp: Int? = widthInDp, + paddingInDp: Int = 0, + @ColorRes tint: Int? = null, +) { + val (_, top, end, bottom) = compoundDrawablesRelative + + setCompoundDrawable(drawableRes, widthInDp, heightInDp, tint, paddingInDp) { + setCompoundDrawablesRelative(it, top, end, bottom) + } +} + +private fun Drawable.updateDimensions( + context: Context, + widthInDp: Int?, + heightInDp: Int? +) { + val widthInPx = widthInDp?.dp(context) ?: intrinsicWidth + val heightInPx = heightInDp?.dp(context) ?: intrinsicHeight + + setBounds(0, 0, widthInPx, heightInPx) +} + +fun ImageView.setImageTintRes(@ColorRes tintRes: Int?) { + imageTintList = tintRes?.let { ColorStateList.valueOf(context.getColor(tintRes)) } +} + +fun ImageView.setImageTint(@ColorInt tint: Int?) { + imageTintList = tint?.let { ColorStateList.valueOf(it) } +} + +inline fun View.doOnGlobalLayout(crossinline action: () -> Unit) { + viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + viewTreeObserver.removeOnGlobalLayoutListener(this) + + action() + } + }) +} + +fun View.setVisible(visible: Boolean, falseState: Int = View.GONE) { + visibility = if (visible) View.VISIBLE else falseState +} + +fun ViewGroup.addAfter(anchor: View, newViews: List) { + val index = indexOfChild(anchor) + + newViews.forEachIndexed { offset, view -> + addView(view, index + offset + 1) + } +} + +fun ViewGroup.addAfter(anchor: View, child: View) { + val index = indexOfChild(anchor) + + addView(child, index + 1) +} + +fun RecyclerView.scrollToTopWhenItemsShuffled(lifecycleOwner: LifecycleOwner) { + val adapterDataObserver = object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + scrollToPosition(0) + } + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + scrollToPosition(0) + } + } + + adapter?.registerAdapterDataObserver(adapterDataObserver) + + lifecycleOwner.lifecycle.onDestroy { adapter?.unregisterAdapterDataObserver(adapterDataObserver) } +} + +fun RecyclerView.enableShowingNewlyAddedTopElements(): RecyclerView.AdapterDataObserver { + val adapterDataObserver = object : RecyclerView.AdapterDataObserver() { + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (positionStart == 0 && wasAtBeginningBeforeInsertion(itemCount)) { + scrollToPosition(0) + } + } + } + + adapter?.registerAdapterDataObserver(adapterDataObserver) + + return adapterDataObserver +} + +private fun RecyclerView.wasAtBeginningBeforeInsertion(insertedCount: Int) = + findFirstVisiblePosition() < insertedCount && insertedCount != adapter!!.itemCount + +fun RecyclerView.findFirstVisiblePosition(): Int { + return (layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() +} + +fun RecyclerView.findLastVisiblePosition(): Int { + return (layoutManager as LinearLayoutManager).findLastVisibleItemPosition() +} + +fun ScrollView.scrollOnFocusTo(vararg focusableTargets: View) { + val listener = View.OnFocusChangeListener { view, hasFocus -> + if (hasFocus) { + postToSelf { scrollTo(view.left, view.top) } + } + } + + focusableTargets.forEach { it.onFocusChangeListener = listener } +} + +fun TextView.setCompoundDrawableTintRes(@ColorRes tintRes: Int?) { + setCompoundDrawableTint(tintRes?.let { context.getColor(it) }) +} + +fun TextView.setCompoundDrawableTint(@ColorInt tint: Int?) { + val colorStateList = tint?.let { ColorStateList.valueOf(it) } + + TextViewCompat.setCompoundDrawableTintList(this, colorStateList) +} + +fun TextView.setTextOrHide(newText: CharSequence?) { + if (newText != null) { + text = newText + setVisible(true) + } else { + setVisible(false) + } +} + +inline fun T.postToSelf(crossinline action: T.() -> Unit) = with(this) { post { action() } } + +inline fun > TypedArray.getEnum(index: Int, default: T) = + getInt(index, /*defValue*/-1).let { + if (it >= 0) enumValues()[it] else default + } + +inline fun Context.useAttributes( + attributeSet: AttributeSet, + @StyleableRes styleable: IntArray, + block: (TypedArray) -> Unit, +) { + val typedArray = obtainStyledAttributes(attributeSet, styleable) + + block(typedArray) + + typedArray.recycle() +} + +fun TypedArray.getResourceIdOrNull(@StyleableRes index: Int) = getResourceId(index, 0).takeIf { it != 0 } + +fun TypedArray.getColorOrNull(@StyleableRes index: Int) = runCatching { getColorOrThrow(index) }.getOrNull() + +fun View.setBackgroundColorRes(@ColorRes colorRes: Int) = setBackgroundColor(context.getColor(colorRes)) + +fun View.setBackgroundTintRes(@ColorRes colorRes: Int) { + backgroundTintList = ColorStateList.valueOf(context.getColor(colorRes)) +} + +fun View.useInputValue(input: Input, onValue: (I) -> Unit) { + setVisible(input is Input.Enabled) + isEnabled = input is Input.Enabled.Modifiable + + input.valueOrNull?.let(onValue) +} + +fun ListAdapter.submitListPreservingViewPoint( + data: List, + into: RecyclerView, + extraDiffCompletedCallback: (() -> Unit)? = null +) { + val recyclerViewState = into.layoutManager!!.onSaveInstanceState() + + submitList(data) { + into.layoutManager!!.onRestoreInstanceState(recyclerViewState) + + extraDiffCompletedCallback?.invoke() + } +} + +fun ImageView.setImageResource(@DrawableRes imageRes: Int?) = if (imageRes == null) { + setImageDrawable(null) +} else { + setImageResource(imageRes) +} + +fun ImageView.setImageResourceOrHide(@DrawableRes imageRes: Int?) = if (imageRes == null) { + makeGone() +} else { + makeVisible() + setImageResource(imageRes) +} + +fun EditText.moveCursorToTheEnd() = setSelection(length()) + +fun ShimmerFrameLayout.setShimmerShown(shown: Boolean) { + if (shown) { + showShimmer(true) + } else { + hideShimmer() + } +} + +fun EditText.switchPasswordInputType(isPasswordVisible: Boolean) { + val selection = selectionEnd + inputType = if (isPasswordVisible) { + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + } else { + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + + setSelection(selection) +} + +fun TabLayout.setupWithViewPager2(viewPager: ViewPager2, tabText: (Int) -> CharSequence) { + TabLayoutMediator(this, viewPager) { tab, position -> + tab.text = tabText(position) + }.attach() +} + +fun View.bounds(): Rect { + return Rect(0, 0, width, height) +} + +fun TabLayout.setTabSelectedListener(callback: (TabLayout.Tab) -> Unit) { + addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + callback(tab) + } + + override fun onTabUnselected(tab: TabLayout.Tab) {} + + override fun onTabReselected(tab: TabLayout.Tab) {} + }) +} + +fun View.setForegroundRes(@DrawableRes drawableRes: Int) { + val drawable = context.getDrawable(drawableRes) + foreground = drawable +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ViewSpace.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ViewSpace.kt new file mode 100644 index 0000000..9dae584 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ViewSpace.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.common.utils + +import android.content.Context +import android.view.View +import android.view.ViewGroup + +class ViewSpace( + val start: Int? = null, + val top: Int? = null, + val end: Int? = null, + val bottom: Int? = null +) + +operator fun ViewSpace.times(value: Float): ViewSpace { + return ViewSpace( + start?.times(value)?.toInt(), + top?.times(value)?.toInt(), + end?.times(value)?.toInt(), + bottom?.times(value)?.toInt() + ) +} + +fun ViewSpace.dp(context: Context): ViewSpace { + return ViewSpace( + start?.dp(context), + top?.dp(context), + end?.dp(context), + bottom?.dp(context) + ) +} + +fun View.updatePadding(space: ViewSpace) { + setPadding( + space.start ?: paddingStart, + space.top ?: paddingTop, + space.end ?: paddingEnd, + space.bottom ?: paddingBottom + ) +} + +fun View.updateMargin(space: ViewSpace) { + (layoutParams as? ViewGroup.MarginLayoutParams)?.let { + it.setMargins( + space.start ?: it.marginStart, + space.top ?: it.topMargin, + space.end ?: it.marginEnd, + space.bottom ?: it.bottomMargin + ) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ViewSwitcherUtils.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ViewSwitcherUtils.kt new file mode 100644 index 0000000..659f666 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ViewSwitcherUtils.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.common.utils + +import android.widget.TextSwitcher +import android.widget.TextView + +fun TextSwitcher.setText(text: String, colorRes: Int) { + nextTextView.setTextColorRes(colorRes) + setText(text) +} + +fun TextSwitcher.setCurrentText(text: String, colorRes: Int) { + currentTextView.setTextColorRes(colorRes) + setCurrentText(text) +} + +val TextSwitcher.currentTextView: TextView + get() = currentView as TextView + +val TextSwitcher.nextTextView: TextView + get() = nextView as TextView diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ViewUtils.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ViewUtils.kt new file mode 100644 index 0000000..bac6600 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ViewUtils.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.common.utils + +import android.view.View + +fun makeVisibleViews(vararg views: View) { + views.forEach { it.visibility = View.VISIBLE } +} + +fun makeGoneViews(vararg views: View) { + views.forEach { it.visibility = View.GONE } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/WithCoroutineScopeExtensions.kt b/common/src/main/java/io/novafoundation/nova/common/utils/WithCoroutineScopeExtensions.kt new file mode 100644 index 0000000..c5c1753 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/WithCoroutineScopeExtensions.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.common.utils + +import androidx.lifecycle.LiveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.shareIn + +// TODO waiting for multiple receivers feature, probably in Kotlin 1.7 +interface WithCoroutineScopeExtensions { + + val coroutineScope: CoroutineScope + + fun Flow.share( + started: SharingStarted = SharingStarted.Eagerly + ) = shareIn(coroutineScope, started = started, replay = 1) + + fun Flow.shareLazily() = shareIn(coroutineScope, started = SharingStarted.Lazily, replay = 1) + + fun Flow.shareInBackground( + started: SharingStarted = SharingStarted.Eagerly + ) = inBackground().share(started) + + fun Flow.shareWhileSubscribed() = share(SharingStarted.WhileSubscribed()) + + fun Flow.asLiveData(): LiveData { + return asLiveData(coroutineScope) + } +} + +fun WithCoroutineScopeExtensions(coroutineScope: CoroutineScope) = object : WithCoroutineScopeExtensions { + override val coroutineScope: CoroutineScope = coroutineScope +} + +context(CoroutineScope) +fun Flow.share(started: SharingStarted = SharingStarted.Eagerly): SharedFlow { + return shareIn(this@CoroutineScope, started = started, replay = 1) +} + +context(CoroutineScope) +fun Flow.shareInBackground(started: SharingStarted = SharingStarted.Eagerly): SharedFlow { + return inBackground().share(started) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/WithLifecycleExtensions.kt b/common/src/main/java/io/novafoundation/nova/common/utils/WithLifecycleExtensions.kt new file mode 100644 index 0000000..8ff52f8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/WithLifecycleExtensions.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.utils + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData + +interface WithLifecycleExtensions { + + val lifecycleOwner: LifecycleOwner + + fun LiveData>.observeEvent(observer: (V) -> Unit) { + observeEvent(lifecycleOwner, observer) + } +} + +fun LiveData>.observeEvent(lifecycleOwner: LifecycleOwner, observer: (V) -> Unit) { + observe(lifecycleOwner, EventObserver(observer)) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/bluetooth/BluetoothManager.kt b/common/src/main/java/io/novafoundation/nova/common/utils/bluetooth/BluetoothManager.kt new file mode 100644 index 0000000..086adb0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/bluetooth/BluetoothManager.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.common.utils.bluetooth + +import android.annotation.SuppressLint +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanSettings +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.utils.systemCall.EnableBluetoothSystemCall +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.utils.whenStarted +import android.bluetooth.BluetoothManager as NativeBluetoothManager + +interface BluetoothManager { + + fun startBleScan(filters: List, settings: ScanSettings, callback: ScanCallback) + + fun stopBleScan(callback: ScanCallback) + + fun enableBluetooth() + + fun isBluetoothEnabled(): Boolean + + suspend fun enableBluetoothAndAwait(): Boolean +} + +@SuppressLint("MissingPermission") +internal class RealBluetoothManager( + private val contextManager: ContextManager, + private val systemCallExecutor: SystemCallExecutor +) : BluetoothManager { + + private val nativeBluetoothManager = contextManager.getApplicationContext().getSystemService(Activity.BLUETOOTH_SERVICE) as NativeBluetoothManager + + private val bluetoothAdapter: BluetoothAdapter + get() = nativeBluetoothManager.adapter + + override fun startBleScan(filters: List, settings: ScanSettings, callback: ScanCallback) { + bluetoothAdapter.bluetoothLeScanner?.startScan(filters, settings, callback) + } + + override fun stopBleScan(callback: ScanCallback) { + bluetoothAdapter.bluetoothLeScanner?.stopScan(callback) + } + + override fun enableBluetooth() { + if (isBluetoothEnabled()) return + + val activity = contextManager.getActivity()!! + + activity.lifecycle.whenStarted { + systemCallExecutor.executeSystemCallNotBlocking(EnableBluetoothSystemCall()) + } + } + + override suspend fun enableBluetoothAndAwait(): Boolean { + if (isBluetoothEnabled()) return true + + return systemCallExecutor.executeSystemCall(EnableBluetoothSystemCall()).getOrNull() ?: false + } + + override fun isBluetoothEnabled(): Boolean { + return bluetoothAdapter.isEnabled + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/blur/SweetBlur.kt b/common/src/main/java/io/novafoundation/nova/common/utils/blur/SweetBlur.kt new file mode 100644 index 0000000..a1858b1 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/blur/SweetBlur.kt @@ -0,0 +1,235 @@ +package io.novafoundation.nova.common.utils.blur + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Matrix +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.RectF +import android.graphics.drawable.BitmapDrawable +import android.view.View +import android.view.ViewTreeObserver +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import androidx.annotation.ColorInt +import androidx.core.graphics.times +import androidx.core.graphics.toRect +import com.google.android.renderscript.Toolkit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext +import kotlin.math.roundToInt + +class SweetBlur( + private val targetView: View, + private val captureFromView: View, + private val extraSpace: RectF?, + private val cutSpace: RectF?, + private val blurColor: Int?, + private val onException: ((Exception) -> Unit)?, + fakeRadius: Int, +) : ViewTreeObserver.OnDrawListener, CoroutineScope { + + class ViewBackgroundBuilder { + + private var targetView: View? = null + private var captureFromView: View? = null + private var extraSpace: RectF? = null + private var cutSpace: RectF? = null + private var radius: Int? = null + private var blurColor: Int? = null + private var onException: ((Exception) -> Unit)? = null + + fun toTarget(targetView: View) = apply { this.targetView = targetView } + + fun captureFrom(captureFromView: View) = apply { this.captureFromView = captureFromView } + + fun captureExtraSpace(extraSpace: RectF) = apply { this.extraSpace = extraSpace } + + fun cutSpace(cutSpace: RectF) = apply { this.cutSpace = cutSpace } + + fun radius(radius: Int) = apply { this.radius = radius } + + fun blurColor(@ColorInt blurColor: Int) = apply { this.blurColor = blurColor } + + fun catchException(action: (Exception) -> Unit) = apply { onException = action } + + fun build(): SweetBlur { + return SweetBlur( + targetView!!, + captureFromView!!, + extraSpace, + cutSpace, + blurColor, + onException, + radius!!, + ) + } + } + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + + var started: Boolean = false + val radius: Int + private val downscaleFactor: Float + private val bitmapColorFilter: ColorFilter? + + init { + val boundedFakeRadius = minOf(maxOf(fakeRadius, 1), 250) + val radiusRange = 249f + val trueRadiusRange = 24f + val compressionRange = 9f + val decelerate = DecelerateInterpolator(3f) + val accelerate = AccelerateInterpolator(0.5f) + val compression = 1f + accelerate.getInterpolation((boundedFakeRadius - 1) / radiusRange) * compressionRange + val trueRadius = 1f + decelerate.getInterpolation((boundedFakeRadius - 1) / radiusRange) * trueRadiusRange + downscaleFactor = 1f / compression + radius = trueRadius.roundToInt() + + bitmapColorFilter = if (blurColor == null) null else PorterDuffColorFilter(blurColor, PorterDuff.Mode.SRC_ATOP) + } + + fun start() { + if (!started) { + started = true + captureFromView.post { + captureFromView.viewTreeObserver.addOnDrawListener(this) + } + } + } + + fun stop() { + if (started) { + started = false + captureFromView.post { + captureFromView.viewTreeObserver.removeOnDrawListener(this) + } + } + } + + override fun onDraw() { + if (!started) return + + targetView.post { + try { + makeBlurBackground() + } catch (e: Exception) { + stop() + onException?.invoke(e) ?: throw e + } + } + } + + private fun makeBlurBackground() { + val capturedBitmap = captureBitmap() ?: return + launch { + try { + val bitmapDrawable = withContext(Dispatchers.Default) { + val blurBitmap = blurBitmap(capturedBitmap) + val cutSpaceBlur = applyCutSpace(blurBitmap) + createBlurDrawable(cutSpaceBlur) + } + targetView.background = bitmapDrawable + } catch (e: Exception) { + stop() + onException?.invoke(e) ?: throw e + } + } + } + + private fun applyCutSpace(bitmap: Bitmap): Bitmap { + return if (cutSpace != null) { + val blurRect = bitmap.rect() + .inset(cutSpace * downscaleFactor) + return createBitmap( + bitmap, + blurRect + ) + } else { + bitmap + } + } + + private fun getViewClip(): RectF { + var clip = targetView.getClip() + + if (extraSpace != null) { + clip = clip.extra(extraSpace) + } + + return clip + } + + private fun getTargetSizeBitmap(viewClip: RectF): Bitmap? { + val width = (viewClip.width() * downscaleFactor).toInt() + val height = (viewClip.height() * downscaleFactor).toInt() + if (width <= 0 || height <= 0) return null + + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + } + + fun blurBitmap(src: Bitmap): Bitmap { + return Toolkit.blur(src, radius) + } + + private fun captureBitmap(): Bitmap? { + val viewClip = getViewClip() + + val targetBitmap = getTargetSizeBitmap(viewClip) ?: return null + targetBitmap.eraseColor(Color.BLACK) + val canvas = SweetBlurCanvas(targetBitmap) + canvas.clipRect(0f, 0f, viewClip.width() * downscaleFactor, viewClip.height() * downscaleFactor) + val matrix = Matrix() + matrix.setTranslate(0f, -viewClip.top * downscaleFactor) + matrix.preScale(downscaleFactor, downscaleFactor) + canvas.setMatrix(matrix) + captureFromView.draw(canvas) + return targetBitmap + } + + private fun createBitmap(source: Bitmap, clipF: RectF): Bitmap { + val clip = clipF.toRect() + return Bitmap.createBitmap(source, clip.left, clip.top, clip.width(), clip.height()) + } + + private fun Bitmap.rect(): RectF { + return RectF(0f, 0f, width.toFloat(), height.toFloat()) + } + + private fun RectF.extra(other: RectF): RectF { + return RectF( + left - other.left, + top - other.top, + right + other.right, + bottom + other.bottom + ) + } + + private fun RectF.inset(other: RectF): RectF { + return RectF( + left + other.left, + top + other.top, + right - other.right, + bottom - other.bottom + ) + } + + private fun View.getClip(): RectF { + return RectF( + left.toFloat(), + top.toFloat(), + right.toFloat(), + bottom.toFloat() + ) + } + + private fun createBlurDrawable(bitmap: Bitmap): BitmapDrawable { + return BitmapDrawable(targetView.context.resources, bitmap).apply { + colorFilter = bitmapColorFilter + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/blur/SweetBlurCanvas.kt b/common/src/main/java/io/novafoundation/nova/common/utils/blur/SweetBlurCanvas.kt new file mode 100644 index 0000000..8827040 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/blur/SweetBlurCanvas.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.common.utils.blur + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.os.Build + +class SweetBlurCanvas(bitmap: Bitmap) : Canvas(bitmap) { + override fun drawBitmap(bitmap: Bitmap, src: Rect?, dst: RectF, paint: Paint?) { + super.drawBitmap(makeSoftwareIfHardware(bitmap), src, dst, paint) + } + + override fun drawBitmap(bitmap: Bitmap, src: Rect?, dst: Rect, paint: Paint?) { + super.drawBitmap(makeSoftwareIfHardware(bitmap), src, dst, paint) + } + + override fun drawBitmap(bitmap: Bitmap, matrix: Matrix, paint: Paint?) { + super.drawBitmap(makeSoftwareIfHardware(bitmap), matrix, paint) + } + + override fun drawBitmap(bitmap: Bitmap, left: Float, top: Float, paint: Paint?) { + super.drawBitmap(makeSoftwareIfHardware(bitmap), left, top, paint) + } + + private fun makeSoftwareIfHardware(bitmap: Bitmap): Bitmap { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) { + bitmap.copy(Bitmap.Config.ARGB_8888, bitmap.isMutable) + } else { + bitmap + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/browser/fileChoosing/RealWebViewFileChooser.kt b/common/src/main/java/io/novafoundation/nova/common/utils/browser/fileChoosing/RealWebViewFileChooser.kt new file mode 100644 index 0000000..64541bb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/browser/fileChoosing/RealWebViewFileChooser.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.common.utils.browser.fileChoosing + +import android.net.Uri +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.widget.Toast +import androidx.fragment.app.Fragment +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.systemCall.WebViewFilePickerSystemCallFactory +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor + +class WebViewFileChooserFactory( + private val systemCallExecutor: SystemCallExecutor, + private val webViewFilePickerSystemCallFactory: WebViewFilePickerSystemCallFactory +) { + fun create(fragment: Fragment): WebViewFileChooser { + return RealWebViewFileChooser(systemCallExecutor, webViewFilePickerSystemCallFactory, fragment) + } +} + +class RealWebViewFileChooser( + private val systemCallExecutor: SystemCallExecutor, + private val webViewFilePickerSystemCallFactory: WebViewFilePickerSystemCallFactory, + private val fragment: Fragment +) : WebViewFileChooser { + + override fun onShowFileChooser(filePathCallback: ValueCallback>?, fileChooserParams: WebChromeClient.FileChooserParams?): Boolean { + val systemCall = webViewFilePickerSystemCallFactory.create(fileChooserParams) + + val isHandled = systemCallExecutor.executeSystemCallNotBlocking(systemCall) { + it.onSuccess { uri -> + filePathCallback?.onReceiveValue(arrayOf(uri)) + }.onFailure { + filePathCallback?.onReceiveValue(null) + } + } + + if (!isHandled) { + Toast.makeText(fragment.context, R.string.common_no_app_to_handle_import_intent, Toast.LENGTH_LONG) + .show() + } + + return isHandled + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/browser/fileChoosing/WebViewFileChooser.kt b/common/src/main/java/io/novafoundation/nova/common/utils/browser/fileChoosing/WebViewFileChooser.kt new file mode 100644 index 0000000..219a52b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/browser/fileChoosing/WebViewFileChooser.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.utils.browser.fileChoosing + +import android.net.Uri +import android.webkit.ValueCallback +import android.webkit.WebChromeClient + +interface WebViewFileChooser { + + fun onShowFileChooser(filePathCallback: ValueCallback>?, fileChooserParams: WebChromeClient.FileChooserParams?): Boolean +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/browser/permissions/RealWebViewPermissionAsker.kt b/common/src/main/java/io/novafoundation/nova/common/utils/browser/permissions/RealWebViewPermissionAsker.kt new file mode 100644 index 0000000..ce72de8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/browser/permissions/RealWebViewPermissionAsker.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.common.utils.browser.permissions + +import android.Manifest +import android.webkit.PermissionRequest +import androidx.fragment.app.Fragment +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class WebViewPermissionAskerFactory(private val permissionsAskerFactory: PermissionsAskerFactory) { + + fun create(fragment: Fragment): WebViewPermissionAsker { + return RealWebViewPermissionAsker(permissionsAskerFactory.create(fragment)) + } +} + +class RealWebViewPermissionAsker( + private val permissionsAsker: PermissionsAsker.Presentation +) : WebViewPermissionAsker { + + override fun requestPermission(coroutineScope: CoroutineScope, request: PermissionRequest) { + coroutineScope.launch { + val permissions = mapPermissionRequest(request) + + if (permissions.isNotEmpty()) { + val result = permissionsAsker.requirePermissions(*permissions) + + if (result) { + request.grant(request.resources) + } else { + request.deny() + } + } else { + request.deny() + } + } + } + + private fun mapPermissionRequest(request: PermissionRequest) = request.resources.flatMap { resource -> + when (resource) { + PermissionRequest.RESOURCE_VIDEO_CAPTURE -> listOf(Manifest.permission.CAMERA) + else -> emptyList() + } + }.toTypedArray() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/browser/permissions/WebViewPermissionAsker.kt b/common/src/main/java/io/novafoundation/nova/common/utils/browser/permissions/WebViewPermissionAsker.kt new file mode 100644 index 0000000..a7c2417 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/browser/permissions/WebViewPermissionAsker.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.utils.browser.permissions + +import android.webkit.PermissionRequest +import kotlinx.coroutines.CoroutineScope + +interface WebViewPermissionAsker { + + fun requestPermission(coroutineScope: CoroutineScope, request: PermissionRequest) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/bus/BaseEventBus.kt b/common/src/main/java/io/novafoundation/nova/common/utils/bus/BaseEventBus.kt new file mode 100644 index 0000000..8625561 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/bus/BaseEventBus.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.utils.bus + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +abstract class BaseEventBus : EventBus { + + private val eventFlow = MutableSharedFlow>() + + override suspend fun notify(event: T, source: String?) { + eventFlow.emit(EventBus.SourceEvent(event, source)) + } + + override fun observeEvent(): Flow> { + return eventFlow + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/bus/BaseRequestBus.kt b/common/src/main/java/io/novafoundation/nova/common/utils/bus/BaseRequestBus.kt new file mode 100644 index 0000000..607d8ad --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/bus/BaseRequestBus.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.common.utils.bus + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlin.coroutines.Continuation +import kotlin.coroutines.suspendCoroutine + +abstract class BaseRequestBus : RequestBus { + + private val eventFlow = MutableSharedFlow, T>>() + + override suspend fun handle(request: T): R = withContext(Dispatchers.Default) { + suspendCoroutine { continuation -> + runBlocking { + eventFlow.emit(continuation to request) + } + } + } + + override fun observeEvent(): Flow, T>> { + return eventFlow + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/bus/EventBus.kt b/common/src/main/java/io/novafoundation/nova/common/utils/bus/EventBus.kt new file mode 100644 index 0000000..8d6097f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/bus/EventBus.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.utils.bus + +import kotlinx.coroutines.flow.Flow + +interface EventBus { + + interface Event + + class SourceEvent(val event: T, val source: String?) : Event + + suspend fun notify(event: T, source: String?) + + fun observeEvent(): Flow> +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/bus/RequestBus.kt b/common/src/main/java/io/novafoundation/nova/common/utils/bus/RequestBus.kt new file mode 100644 index 0000000..b6f1965 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/bus/RequestBus.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.common.utils.bus + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +interface RequestBus { + + interface Request + + interface Response + + suspend fun handle(request: T): R + + fun observeEvent(): Flow, T>> +} + +fun Flow, T>>.observeBusEvent( + action: suspend (T) -> R +): Flow, T>> { + return this.onEach { (continuation, request) -> + val response = action(request) + continuation.resume(response) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/coroutines/RootScope.kt b/common/src/main/java/io/novafoundation/nova/common/utils/coroutines/RootScope.kt new file mode 100644 index 0000000..ad4ef02 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/coroutines/RootScope.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.utils.coroutines + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope + +@RequiresOptIn( + message = """ + Using RootScope might have unintended side effects. + In case when we use RootScope in RootViewModel and its is re-created - RootScope jobs wont be cancelled that may give us duplicated jobs + """, + level = RequiresOptIn.Level.WARNING +) +@Retention(AnnotationRetention.BINARY) +annotation class DangerousScope + +@DangerousScope +class RootScope : CoroutineScope by MainScope() diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/AmountWithFraction.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/AmountWithFraction.kt new file mode 100644 index 0000000..280dba0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/AmountWithFraction.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.common.utils.formatting + +class AmountWithFraction(val amount: String, val fraction: String?, val separator: String) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/CompoundNumberFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/CompoundNumberFormatter.kt new file mode 100644 index 0000000..3ceb363 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/CompoundNumberFormatter.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.utils.formatting + +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode + +class CompoundNumberFormatter( + val abbreviations: List, +) : NumberFormatter { + + init { + require(abbreviations.isNotEmpty()) { + "Cannot create compound formatter with empty abbreviations" + } + + require( + abbreviations.zipWithNext().all { (current, next) -> + current.threshold <= next.threshold + } + ) { + "Abbreviations should go in non-descending order w.r.t. threshold" + } + } + + override fun format(number: BigDecimal, roundingMode: RoundingMode): String { + val lastAbbreviationMatching = abbreviations.lastOrNull { number >= it.threshold } ?: abbreviations.first() + + val scaled = number.divide(lastAbbreviationMatching.divisor, MathContext.UNLIMITED) + + return lastAbbreviationMatching.formatter.format(scaled, roundingMode) + lastAbbreviationMatching.suffix + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/DynamicPrecisionFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/DynamicPrecisionFormatter.kt new file mode 100644 index 0000000..5cfb1a8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/DynamicPrecisionFormatter.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.common.utils.formatting + +import java.lang.Integer.max +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat +import kotlin.math.min + +class DynamicPrecisionFormatter( + private val minScale: Int, + private val minPrecision: Int, +) : NumberFormatter { + + private val patternCache = mutableMapOf() + + override fun format(number: BigDecimal, roundingMode: RoundingMode): String { + // scale() - total amount of digits after 0., + // precision() - amount of non-zero digits in decimal part + val zeroPrecision = number.scale() - number.precision() + val requiredPrecision = zeroPrecision + min(number.precision(), minPrecision) + + val formattingPrecision = max(minScale, requiredPrecision) + + val formatter = patternCache.getOrPut(formattingPrecision) { decimalFormatterFor(patternWith(formattingPrecision)) } + if (formatter.roundingMode != roundingMode) { + formatter.roundingMode = roundingMode + } + + return formatter.format(number) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/FixedPrecisionFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/FixedPrecisionFormatter.kt new file mode 100644 index 0000000..c03638b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/FixedPrecisionFormatter.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.utils.formatting + +import java.math.BigDecimal +import java.math.RoundingMode + +class FixedPrecisionFormatter(private val precision: Int) : NumberFormatter { + + private val delegate = decimalFormatterFor(patternWith(precision)) + + override fun format(number: BigDecimal, roundingMode: RoundingMode): String { + if (delegate.roundingMode != roundingMode) { + delegate.roundingMode = roundingMode + } + + return delegate.format(number) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/Formatable.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/Formatable.kt new file mode 100644 index 0000000..5af2d0d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/Formatable.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.utils.formatting + +import io.novafoundation.nova.common.resources.ResourceManager + +interface Formatable { + + fun format(resourceManager: ResourceManager): String? +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/NumberAbbreviation.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/NumberAbbreviation.kt new file mode 100644 index 0000000..9f814ff --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/NumberAbbreviation.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.common.utils.formatting + +import java.math.BigDecimal + +class NumberAbbreviation( + val threshold: BigDecimal, + val divisor: BigDecimal, + val suffix: String, + val formatter: NumberFormatter, +) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/NumberFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/NumberFormatter.kt new file mode 100644 index 0000000..bddd7de --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/NumberFormatter.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.utils.formatting + +import java.math.BigDecimal +import java.math.RoundingMode + +interface NumberFormatter { + + fun format(number: BigDecimal, roundingMode: RoundingMode = RoundingMode.FLOOR): String +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/NumberFormatters.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/NumberFormatters.kt new file mode 100644 index 0000000..6d68303 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/NumberFormatters.kt @@ -0,0 +1,304 @@ +package io.novafoundation.nova.common.utils.formatting + +import android.content.Context +import android.text.format.DateUtils +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Perbill +import io.novafoundation.nova.common.utils.Percent +import io.novafoundation.nova.common.utils.daysFromMillis +import io.novafoundation.nova.common.utils.formatting.duration.BoundedDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.CompoundDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.DayAndHourDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.DayDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.DurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.HoursDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.MinutesDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.RoundMinutesDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.SecondsDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.ZeroDurationFormatter +import io.novafoundation.nova.common.utils.fractionToPercentage +import io.novafoundation.nova.common.utils.isNonNegative +import io.novafoundation.nova.common.utils.toPercent +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode +import java.text.DecimalFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Calendar +import java.util.Locale +import java.util.concurrent.TimeUnit + +const val DATE_ISO_8601_FULL = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" +const val DATE_ISO_8601_NO_MS = "yyyy-MM-dd'T'HH:mm:ss'Z'" + +private const val DECIMAL_PATTERN_BASE = "###,###." + +private const val GROUPING_SEPARATOR = ',' +private const val DECIMAL_SEPARATOR = '.' + +private const val MIN_SCALE = 5 +private const val PRICE_MIN_PRECISION = 3 +private const val TOKEN_MIN_PRECISION = 1 +const val ABBREVIATED_SCALE = 2 + +private val dateTimeFormat = SimpleDateFormat.getDateTimeInstance() +private val dateTimeFormatISO_8601 by lazy { SimpleDateFormat(DATE_ISO_8601_FULL, Locale.getDefault()) } +private val dateTimeFormatISO_8601_NoMs by lazy { SimpleDateFormat(DATE_ISO_8601_NO_MS, Locale.getDefault()) } + +private val defaultAbbreviationFormatter = FixedPrecisionFormatter(ABBREVIATED_SCALE) +private val defaultFullFormatter = FixedPrecisionFormatter(MIN_SCALE) + +private val zeroAbbreviation = NumberAbbreviation( + threshold = BigDecimal.ZERO, + divisor = BigDecimal.ONE, + suffix = "", + formatter = DynamicPrecisionFormatter(minScale = MIN_SCALE, minPrecision = TOKEN_MIN_PRECISION) +) + +private val oneAbbreviation = NumberAbbreviation( + threshold = BigDecimal.ONE, + divisor = BigDecimal.ONE, + suffix = "", + formatter = defaultFullFormatter +) + +private val thousandAbbreviation = NumberAbbreviation( + threshold = BigDecimal("1E+3"), + divisor = BigDecimal.ONE, + suffix = "", + formatter = defaultAbbreviationFormatter +) + +private val millionAbbreviation = NumberAbbreviation( + threshold = BigDecimal("1E+6"), + divisor = BigDecimal("1E+6"), + suffix = "M", + formatter = defaultAbbreviationFormatter +) + +private val billionAbbreviation = NumberAbbreviation( + threshold = BigDecimal("1E+9"), + divisor = BigDecimal("1E+9"), + suffix = "B", + formatter = defaultAbbreviationFormatter +) + +private val trillionAbbreviation = NumberAbbreviation( + threshold = BigDecimal("1E+12"), + divisor = BigDecimal("1E+12"), + suffix = "T", + formatter = defaultAbbreviationFormatter +) + +private val defaultNumberFormatter = defaultNumberFormatter() +private val fullAmountAbbreviationFormatter = fullAmountAbbreviationFormatter() + +fun BigDecimal.toStripTrailingZerosString(): String { + return stripTrailingZeros().toPlainString() +} + +fun BigDecimal.format(roundingMode: RoundingMode = RoundingMode.FLOOR): String { + return defaultNumberFormatter.format(this, roundingMode) +} + +@Deprecated("Use TokenFormatter instead") +fun BigDecimal.formatWithFullAmount(): String { + return fullAmountAbbreviationFormatter.format(this) +} + +fun Int.format(): String { + return defaultNumberFormatter.format(BigDecimal(this)) +} + +fun BigDecimal.toAmountInput(): String { + return toDouble().toString() +} + +fun BigInteger.format(): String { + return defaultNumberFormatter.format(BigDecimal(this)) +} + +fun BigDecimal.formatAsChange(): String { + val prefix = if (isNonNegative) "+" else "" + + return prefix + formatAsPercentage() +} + +fun BigDecimal.formatAsPercentage(includeSymbol: Boolean = true): String { + return defaultAbbreviationFormatter.format(this) + if (includeSymbol) "%" else "" +} + +fun Percent.format(): String { + return value.toBigDecimal().formatAsPercentage() +} + +fun Fraction.formatPercents(): String { + return inPercents.toBigDecimal().formatAsPercentage() +} + +fun Perbill.format(): String { + return toPercent().format() +} + +fun Fraction.formatPercents(includeSymbol: Boolean = true): String { + return inPercents.toBigDecimal().formatAsPercentage(includeSymbol) +} + +fun BigDecimal.formatFractionAsPercentage(): String { + return fractionToPercentage().formatAsPercentage() +} + +fun Date.formatDateSinceEpoch(resourceManager: ResourceManager): String { + val currentDays = System.currentTimeMillis().daysFromMillis() + val diff = currentDays - time.daysFromMillis() + + return when (diff) { + 0L -> resourceManager.getString(R.string.today) + 1L -> resourceManager.getString(R.string.yesterday) + else -> { + resourceManager.formatDate(time) + } + } +} + +fun Date.isThisYear(): Boolean { + val calendar = Calendar.getInstance() + calendar.timeInMillis = time + return calendar.get(Calendar.YEAR) == Calendar.getInstance().get(Calendar.YEAR) +} + +fun ResourceManager.formatTime(data: Date): String { + return formatTime(data.time) +} + +fun Long.formatDaysSinceEpoch(context: Context): String? { + val currentDays = System.currentTimeMillis().daysFromMillis() + val diff = currentDays - this + + return when (diff) { + 0L -> context.getString(R.string.today) + 1L -> context.getString(R.string.yesterday) + else -> { + val inMillis = TimeUnit.DAYS.toMillis(this) + DateUtils.formatDateTime(context, inMillis, 0) + } + } +} + +fun Long.formatDateTime() = dateTimeFormat.format(Date(this)) + +fun parseDateISO_8601(value: String): Date? { + return runCatching { dateTimeFormatISO_8601.parse(value) }.getOrNull() +} + +fun parseDateISO_8601_NoMs(value: String): Date? { + return runCatching { dateTimeFormatISO_8601_NoMs.parse(value) }.getOrNull() +} + +fun formatDateISO_8601_NoMs(date: Date): String { + return dateTimeFormatISO_8601_NoMs.format(date) +} + +fun decimalFormatterFor(pattern: String): DecimalFormat { + return DecimalFormat(pattern).apply { + val symbols = decimalFormatSymbols + + symbols.groupingSeparator = GROUPING_SEPARATOR + symbols.decimalSeparator = DECIMAL_SEPARATOR + + decimalFormatSymbols = symbols + + decimalFormatSymbols = decimalFormatSymbols + } +} + +fun CharSequence.toAmountWithFraction(): AmountWithFraction { + val amountAndFraction = this.split(DECIMAL_SEPARATOR) + val amount = amountAndFraction[0] + val fraction = amountAndFraction.getOrNull(1) + return AmountWithFraction(amount, fraction, DECIMAL_SEPARATOR.toString()) +} + +fun patternWith(precision: Int) = "$DECIMAL_PATTERN_BASE${"#".repeat(precision)}" + +fun fullAmountAbbreviationFormatter() = CompoundNumberFormatter( + abbreviations = listOf( + zeroAbbreviation, + oneAbbreviation, + thousandAbbreviation + ) +) + +fun defaultNumberFormatter() = CompoundNumberFormatter( + abbreviations = listOf( + zeroAbbreviation, + oneAbbreviation, + thousandAbbreviation, + millionAbbreviation, + billionAbbreviation, + trillionAbbreviation + ) +) + +fun currencyFormatter() = CompoundNumberFormatter( + abbreviations = listOf( + NumberAbbreviation( + threshold = BigDecimal.ZERO, + divisor = BigDecimal.ONE, + suffix = "", + formatter = DynamicPrecisionFormatter(minScale = ABBREVIATED_SCALE, minPrecision = PRICE_MIN_PRECISION) + ), + NumberAbbreviation( + threshold = BigDecimal.ONE, + divisor = BigDecimal.ONE, + suffix = "", + formatter = defaultAbbreviationFormatter + ), + thousandAbbreviation, + millionAbbreviation, + billionAbbreviation, + trillionAbbreviation + ) +) + +fun simpleCurrencyFormatter() = CompoundNumberFormatter( + abbreviations = listOf( + NumberAbbreviation( + threshold = BigDecimal.ZERO, + divisor = BigDecimal.ONE, + suffix = "", + formatter = DynamicPrecisionFormatter(minScale = ABBREVIATED_SCALE, minPrecision = PRICE_MIN_PRECISION) + ), + NumberAbbreviation( + threshold = BigDecimal.ONE, + divisor = BigDecimal.ONE, + suffix = "", + formatter = defaultAbbreviationFormatter + ) + ) +) + +fun baseDurationFormatter( + context: Context, + dayDurationFormatter: BoundedDurationFormatter = DayAndHourDurationFormatter( + DayDurationFormatter(context), + HoursDurationFormatter(context) + ), + hoursDurationFormatter: BoundedDurationFormatter = HoursDurationFormatter(context), + minutesDurationFormatter: BoundedDurationFormatter = MinutesDurationFormatter(context), + secondsDurationFormatter: BoundedDurationFormatter = SecondsDurationFormatter(context), + zeroDurationFormatter: BoundedDurationFormatter = ZeroDurationFormatter(DayDurationFormatter(context)) +): DurationFormatter { + val compoundFormatter = CompoundDurationFormatter( + dayDurationFormatter, + hoursDurationFormatter, + minutesDurationFormatter, + secondsDurationFormatter, + zeroDurationFormatter + ) + + return RoundMinutesDurationFormatter(compoundFormatter) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/TermsAndPrivacyFormatting.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/TermsAndPrivacyFormatting.kt new file mode 100644 index 0000000..eca3db0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/TermsAndPrivacyFormatting.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.common.utils.formatting + +import android.graphics.Color +import android.text.method.LinkMovementMethod +import android.widget.TextView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.clickableSpan +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.setFullSpan +import io.novafoundation.nova.common.utils.toSpannable + +fun TextView.applyTermsAndPrivacyPolicy( + containerResId: Int, + termsResId: Int, + privacyResId: Int, + termsClicked: () -> Unit, + privacyClicked: () -> Unit +) { + movementMethod = LinkMovementMethod.getInstance() + highlightColor = Color.TRANSPARENT + val linkColor = context.getColor(R.color.text_primary) + + text = SpannableFormatter.format( + context.getString(containerResId), + context.getString(termsResId) + .toSpannable(clickableSpan(termsClicked)) + .setFullSpan(colorSpan(linkColor)), + context.getString(privacyResId) + .toSpannable(clickableSpan(privacyClicked)) + .setFullSpan(colorSpan(linkColor)) + ) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/TimerValue.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/TimerValue.kt new file mode 100644 index 0000000..619592b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/TimerValue.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.common.utils.formatting + +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +class TimerValue( + val millis: Long, + val millisCalculatedAt: Long, // used to offset timer value if timer is rerun, e.g. in the RecyclerView +) { + companion object { + + fun fromCurrentTime(millis: Long): TimerValue { + return TimerValue(millis, System.currentTimeMillis()) + } + } + + override fun toString(): String { + return millis.toDuration(DurationUnit.MILLISECONDS).toString() + } +} + +fun Duration.toTimerValue() = TimerValue.fromCurrentTime(millis = inWholeMilliseconds) + +fun TimerValue.remainingTime(): Long { + val currentTimer = System.currentTimeMillis() + val passedTime = currentTimer - millisCalculatedAt + val remainingTime = millis - passedTime + + return remainingTime.coerceAtLeast(0) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/CompoundDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/CompoundDurationFormatter.kt new file mode 100644 index 0000000..e11000e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/CompoundDurationFormatter.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import kotlin.time.Duration + +class CompoundDurationFormatter( + val formatters: List +) : DurationFormatter { + + constructor(vararg formatters: BoundedDurationFormatter) : this(formatters.toList()) + + override fun format(duration: Duration): String { + val formatter = formatters.firstOrNull { it.threshold <= duration } ?: formatters.last() + + return formatter.format(duration) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/DayAndHourDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/DayAndHourDurationFormatter.kt new file mode 100644 index 0000000..b246858 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/DayAndHourDurationFormatter.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import io.novafoundation.nova.common.utils.lastHours +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days + +class DayAndHourDurationFormatter( + private val dayFormatter: DayDurationFormatter, + private val hoursFormatter: HoursDurationFormatter, + private val format: String? = null +) : BoundedDurationFormatter { + + override val threshold: Duration = 1.days + + override fun format(duration: Duration): String { + if (duration.lastHours > 0) { + return formatDaysAndHours(duration) + } else { + return dayFormatter.format(duration) + } + } + + private fun formatDaysAndHours(duration: Duration): String { + if (format == null) { + return dayFormatter.format(duration) + " " + hoursFormatter.format(duration) + } else { + return format.format( + dayFormatter.format(duration), + hoursFormatter.format(duration) + ) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/DayDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/DayDurationFormatter.kt new file mode 100644 index 0000000..0b77b30 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/DayDurationFormatter.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import android.content.Context +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.lastDays +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days + +class DayDurationFormatter( + private val context: Context +) : BoundedDurationFormatter { + + override val threshold: Duration = 1.days + + override fun format(duration: Duration): String { + val days = duration.lastDays + return context.resources.getQuantityString(R.plurals.staking_main_lockup_period_value, days.toInt(), days) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/DurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/DurationFormatter.kt new file mode 100644 index 0000000..73bf8b8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/DurationFormatter.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import kotlin.time.Duration + +interface DurationFormatter { + + fun format(duration: Duration): String +} + +interface BoundedDurationFormatter : DurationFormatter { + + val threshold: Duration +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/EstimatedDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/EstimatedDurationFormatter.kt new file mode 100644 index 0000000..704f711 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/EstimatedDurationFormatter.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import kotlin.time.Duration + +class EstimatedDurationFormatter( + private val durationFormatter: DurationFormatter +) : DurationFormatter { + + override fun format(duration: Duration): String { + return "~" + durationFormatter.format(duration) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/HoursDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/HoursDurationFormatter.kt new file mode 100644 index 0000000..aa1babf --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/HoursDurationFormatter.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import android.content.Context +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.lastHours +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +class HoursDurationFormatter( + private val context: Context +) : BoundedDurationFormatter { + + override val threshold: Duration = 1.hours + + override fun format(duration: Duration): String { + val hours = duration.lastHours + return context.resources.getQuantityString(R.plurals.common_hours_format, hours, hours) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/MinutesDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/MinutesDurationFormatter.kt new file mode 100644 index 0000000..7043f9d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/MinutesDurationFormatter.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import android.content.Context +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.lastMinutes +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +class MinutesDurationFormatter( + private val context: Context +) : BoundedDurationFormatter { + + override val threshold: Duration = 1.minutes + + override fun format(duration: Duration): String { + val minutes = duration.lastMinutes + return context.resources.getQuantityString(R.plurals.common_minutes_format, minutes, minutes) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/RoundMinutesDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/RoundMinutesDurationFormatter.kt new file mode 100644 index 0000000..07e8eb0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/RoundMinutesDurationFormatter.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import io.novafoundation.nova.common.utils.lastMinutes +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +class RoundMinutesDurationFormatter( + private val nestedDurationFormatter: DurationFormatter, + private val roundMinutesThreshold: Duration = 1.hours +) : DurationFormatter { + + override fun format(duration: Duration): String { + val roundedDuration = if (duration > roundMinutesThreshold) { + roundMinutes(duration) + } else { + duration + } + return nestedDurationFormatter.format(roundedDuration) + } + + private fun roundMinutes(duration: Duration): Duration { + val lastMinutes = duration.lastMinutes + val wholeMinutes = if (lastMinutes >= 30) { + duration.inWholeMinutes + (60 - lastMinutes) + } else { + duration.inWholeMinutes - lastMinutes + } + + return wholeMinutes.minutes + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/SecondsDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/SecondsDurationFormatter.kt new file mode 100644 index 0000000..0a0a2ae --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/SecondsDurationFormatter.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import android.content.Context +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.lastSeconds +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class SecondsDurationFormatter( + private val context: Context +) : BoundedDurationFormatter { + + override val threshold: Duration = 1.seconds + + override fun format(duration: Duration): String { + val seconds = duration.lastSeconds + return context.resources.getQuantityString(R.plurals.common_seconds_format, seconds, seconds) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/ShortcutDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/ShortcutDurationFormatter.kt new file mode 100644 index 0000000..28684aa --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/ShortcutDurationFormatter.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +class ShortcutDurationFormatter( + private val shortcuts: List, + private val nestedFormatter: BoundedDurationFormatter +) : BoundedDurationFormatter { + + override val threshold: Duration = nestedFormatter.threshold + + override fun format(duration: Duration): String { + val formatter = shortcuts.firstOrNull { it.invokeCondition(duration) } + ?.formatter + ?: nestedFormatter + + return formatter.format(duration) + } +} + +open class DurationShortcut(val formatter: DurationFormatter, val invokeCondition: (Duration) -> Boolean) { + + constructor(formatTo: String, condition: (Duration) -> Boolean) : this(StringDurationFormatter(formatTo), condition) +} + +class DayDurationShortcut(shortcut: String) : DurationShortcut( + formatTo = shortcut, + condition = { it > 23.hours && it <= 1.days } +) diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/StringDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/StringDurationFormatter.kt new file mode 100644 index 0000000..49be117 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/StringDurationFormatter.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import kotlin.time.Duration + +class StringDurationFormatter( + private val string: String +) : DurationFormatter { + + override fun format(duration: Duration): String { + return string + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/TimeDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/TimeDurationFormatter.kt new file mode 100644 index 0000000..5429af2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/TimeDurationFormatter.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +class TimeDurationFormatter( + private val useHours: Boolean = true, + private val useMinutes: Boolean = true, + private val useSeconds: Boolean = true +) : BoundedDurationFormatter { + + override val threshold: Duration = 1.minutes + + init { + if (!useHours && !useMinutes && !useSeconds) throw IllegalArgumentException("At least one of the flags should be true") + } + + override fun format(duration: Duration): String { + return duration.toComponents { _, hours, minutes, seconds, _ -> + val args = listOfNotNull( + hours.takeIf { useHours }, + minutes.takeIf { useMinutes }, + seconds.takeIf { useSeconds } + ) + + formatTime(*args.toTypedArray()) + } + } + + private fun formatTime(vararg args: Any): String { + val formatString = args.joinToString(separator = ":") { "%02d" } // Get string like %02d:%02d:%02d that depends on the number of args + + return formatString.format(*args) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/WrapDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/WrapDurationFormatter.kt new file mode 100644 index 0000000..cd65e5c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/WrapDurationFormatter.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import android.content.Context +import androidx.annotation.StringRes +import kotlin.time.Duration + +class WrapDurationFormatter( + private val context: Context, + @StringRes private val resId: Int, + private val nestedFormatter: BoundedDurationFormatter +) : BoundedDurationFormatter { + + override val threshold: Duration = nestedFormatter.threshold + + override fun format(duration: Duration): String { + val nestedFormatterString = nestedFormatter.format(duration) + return context.getString(resId, nestedFormatterString) + } +} + +fun BoundedDurationFormatter.wrapInto(context: Context, @StringRes prefixRes: Int): WrapDurationFormatter { + return WrapDurationFormatter(context, prefixRes, this) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/ZeroDurationFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/ZeroDurationFormatter.kt new file mode 100644 index 0000000..daef9ce --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/duration/ZeroDurationFormatter.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.utils.formatting.duration + +import kotlin.time.Duration + +class ZeroDurationFormatter( + private val nestedFormatter: DurationFormatter +) : BoundedDurationFormatter { + + override val threshold: Duration = Duration.ZERO + + override fun format(duration: Duration): String { + return nestedFormatter.format(duration) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/spannable/SpannableFormatter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/spannable/SpannableFormatter.kt new file mode 100644 index 0000000..87aa135 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/spannable/SpannableFormatter.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.common.utils.formatting.spannable + +import android.text.SpannedString +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter.fill +import java.util.regex.Pattern + +/** + * The simple version of formatter. + * Supports formatting only next types: %s, %1$s. + */ +object SpannableFormatter { + + // Add other formatting types in next patterns such as 'd' in [] to extend functionality + private val FORMAT_SEQUENCE: Pattern = Pattern.compile("%\\d*\\\$?[s]") + private val INDEX_PATTERN: Pattern = Pattern.compile("(?<=^%)(\\d+)(?=\\\$[s]\$)") // search index in %1$s. + + fun format(format: CharSequence, vararg args: Any): SpannedString { + val formattedResult = fill(SpannableFormatterBuilder(format), *args) + return SpannedString(formattedResult) + } + + /** + * Not throw format exceptions if format is incorrect. + * In case when format is incorrect will return dirty string with format types. + */ + fun fill(builder: Builder, vararg args: Any): CharSequence { + val matcher = FORMAT_SEQUENCE.matcher(builder.format) + var index = 0 + var offset = 0 + while (matcher.find()) { + matcher.group() + val argNumber = parseArgNumber(matcher.group()) ?: index + if (argNumber >= args.size) { + continue + } + val arg = args[argNumber] + val start = matcher.start() - offset + val end = matcher.end() - offset + if (arg is CharSequence) { + builder.replace(start, end, arg) + } else { + builder.replace(start, end, arg.toString()) + } + index++ + offset += end - start - arg.toString().length + } + return builder.result() + } + + private fun parseArgNumber(argNumberString: String): Int? { + val matcher = INDEX_PATTERN.matcher(argNumberString) + if (matcher.find()) { + return matcher.group().toInt() - 1 + } + + return null + } + + interface Builder { + + val format: CharSequence + + fun replace(start: Int, end: Int, text: CharSequence) + + fun result(): CharSequence + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/spannable/SpannableFormatterBuilder.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/spannable/SpannableFormatterBuilder.kt new file mode 100644 index 0000000..d36150a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/spannable/SpannableFormatterBuilder.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils.formatting.spannable + +import android.text.SpannableStringBuilder + +class SpannableFormatterBuilder(override val format: CharSequence) : SpannableFormatter.Builder { + + private val spannableStringBuilder = SpannableStringBuilder(format) + + override fun replace(start: Int, end: Int, text: CharSequence) { + spannableStringBuilder.replace(start, end, text) + } + + override fun result(): CharSequence { + return spannableStringBuilder + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/formatting/spannable/SpannableFormatterExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/spannable/SpannableFormatterExt.kt new file mode 100644 index 0000000..71740af --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/formatting/spannable/SpannableFormatterExt.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.common.utils.formatting.spannable + +import android.content.Context +import android.text.SpannedString +import androidx.annotation.StringRes +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.toSpannable + +fun CharSequence.spannableFormatting(vararg args: Any): CharSequence { + return SpannableFormatter.format(this, *args) +} + +fun SpannableFormatter.format(resourceManager: ResourceManager, @StringRes resId: Int, vararg args: Any): SpannedString { + val format = resourceManager.getString(resId) + return format(format, *args) +} + +fun SpannableFormatter.format(context: Context, @StringRes resId: Int, vararg args: Any): SpannedString { + val format = context.getString(resId) + return format(format, *args) +} + +fun Context.highlightedText(mainRes: Int, vararg highlightedRes: Int): SpannedString { + val highlighted = highlightedRes.map { + getString(it).toSpannable(colorSpan(getColor(R.color.text_primary))) + }.toTypedArray() + + return SpannableFormatter.format(this, mainRes, *highlighted) +} + +fun ResourceManager.highlightedText(mainRes: Int, vararg highlightedRes: Int): SpannedString { + val highlighted = highlightedRes.map { + getString(it).toSpannable(colorSpan(getColor(R.color.text_primary))) + }.toTypedArray() + + return SpannableFormatter.format(this, mainRes, *highlighted) +} + +fun ResourceManager.highlightedText(mainRes: Int, vararg highlightedString: String): SpannedString { + val highlighted = highlightedString.map { + it.toSpannable(colorSpan(getColor(R.color.text_primary))) + }.toTypedArray() + + return SpannableFormatter.format(this, mainRes, *highlighted) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/graph/Graph.kt b/common/src/main/java/io/novafoundation/nova/common/utils/graph/Graph.kt new file mode 100644 index 0000000..6917e63 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/graph/Graph.kt @@ -0,0 +1,150 @@ +package io.novafoundation.nova.common.utils.graph + +import io.novafoundation.nova.common.utils.MultiMapList +import java.util.PriorityQueue + +interface Edge { + + val from: N + + val to: N +} + +interface WeightedEdge : Edge { + + fun weightForAppendingTo(path: Path>): Int +} + +class Graph>( + val adjacencyList: MultiMapList +) { + + companion object; +} + +typealias Path = List + +fun Graph.vertices(): Set { + return adjacencyList.keys +} + +fun Graph<*, *>.numberOfEdges(): Int { + return adjacencyList.values.sumOf { it.size } +} + +fun > Graph.allEdges(): List { + return adjacencyList.values.flatten() +} + +fun interface EdgeVisitFilter> { + + suspend fun shouldVisit(edge: E, pathPredecessor: E?): Boolean +} + +/** + * Finds all nodes reachable from [origin] + * + * Works for both directed and undirected graphs + * + * Complexity: O(V + E) + */ +suspend fun > Graph.findAllPossibleDestinations( + origin: N, + nodeVisitFilter: EdgeVisitFilter? = null +): Set { + val actualNodeListFilter = nodeVisitFilter ?: EdgeVisitFilter { _, _ -> true } + + val reachableNodes = reachabilityDfs(origin, adjacencyList, actualNodeListFilter, predecessor = null) + reachableNodes.removeAt(reachableNodes.indexOf(origin)) + + return reachableNodes.toSet() +} + +fun > Graph.hasOutcomingDirections(origin: N): Boolean { + val vertices = adjacencyList[origin] ?: return false + return vertices.isNotEmpty() +} + +suspend fun > Graph.findDijkstraPathsBetween( + from: N, + to: N, + limit: Int, + nodeVisitFilter: EdgeVisitFilter? = null +): List> { + val actualNodeListFilter = nodeVisitFilter ?: EdgeVisitFilter { _, _ -> true } + + data class QueueElement(val currentPath: Path, val score: Int) : Comparable { + + override fun compareTo(other: QueueElement): Int { + return score - other.score + } + + fun lastNode(): N { + return if (currentPath.isNotEmpty()) currentPath.last().to else from + } + + operator fun contains(node: N): Boolean { + return currentPath.any { it.from == node || it.to == node } + } + } + + val paths = mutableListOf>() + + val count = mutableMapOf() + + val heap = PriorityQueue() + heap.add(QueueElement(currentPath = emptyList(), score = 0)) + + while (heap.isNotEmpty() && paths.size < limit) { + val minimumQueueElement = heap.poll()!! + val lastNode = minimumQueueElement.lastNode() + + val newCount = count.getOrElse(lastNode) { 0 } + 1 + count[lastNode] = newCount + + val predecessor = minimumQueueElement.currentPath.lastOrNull() + + if (lastNode == to) { + paths.add(minimumQueueElement.currentPath) + continue + } + + if (newCount <= limit) { + val edges = adjacencyList[lastNode].orEmpty() + edges.forEach { edge -> + if (edge.to in minimumQueueElement || !actualNodeListFilter.shouldVisit(edge, predecessor)) return@forEach + + val newElement = QueueElement( + currentPath = minimumQueueElement.currentPath + edge, + score = minimumQueueElement.score + edge.weightForAppendingTo(minimumQueueElement.currentPath) + ) + + heap.add(newElement) + } + } + } + + return paths +} + +private suspend fun > reachabilityDfs( + node: N, + adjacencyList: Map>, + nodeVisitFilter: EdgeVisitFilter, + predecessor: E?, + visited: MutableSet = mutableSetOf(), + connectedComponentState: MutableList = mutableListOf() +): MutableList { + visited.add(node) + connectedComponentState.add(node) + + val edges = adjacencyList[node].orEmpty() + + for (edge in edges) { + if (edge.to !in visited && nodeVisitFilter.shouldVisit(edge, predecessor)) { + reachabilityDfs(edge.to, adjacencyList, nodeVisitFilter, predecessor = edge, visited, connectedComponentState) + } + } + + return connectedComponentState +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/graph/GraphBuilder.kt b/common/src/main/java/io/novafoundation/nova/common/utils/graph/GraphBuilder.kt new file mode 100644 index 0000000..2511bd6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/graph/GraphBuilder.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.common.utils.graph + +import io.novafoundation.nova.common.utils.MultiMapList + +class GraphBuilder> { + + private val adjacencyList: MutableMap> = mutableMapOf() + + fun addEdge(edge: E) { + val fromEdges = adjacencyList.getOrPut(edge.from) { mutableListOf() } + initializeVertex(edge.to) + fromEdges.add(edge) + } + + fun addEdges(from: N, to: List) { + val fromEdges = adjacencyList.getOrPut(from) { mutableListOf() } + to.onEach { initializeVertex(it.to) } + + fromEdges.addAll(to) + } + + fun build(): Graph { + return Graph(adjacencyList) + } + + private fun initializeVertex(v: N) { + adjacencyList.computeIfAbsent(v) { mutableListOf() } + } +} + +fun > GraphBuilder.addEdges(map: MultiMapList) { + map.forEach { (fromNode, toNodes) -> + addEdges(fromNode, toNodes) + } +} + +fun > Graph.Companion.create(vararg multiMaps: MultiMapList): Graph { + return create(multiMaps.toList()) +} + +fun > Graph.Companion.create(vararg adjacencyPairs: Pair>): Graph { + return create(adjacencyPairs.toMap()) +} + +fun > Graph.Companion.create(multiMaps: List>): Graph { + return GraphBuilder().apply { + multiMaps.forEach(::addEdges) + }.build() +} + +@JvmName("createFromEdges") +fun > Graph.Companion.create(edges: List): Graph { + return build { + edges.forEach { + addEdge(it) + } + } +} + +inline fun > Graph.Companion.build(building: GraphBuilder.() -> Unit): Graph { + return GraphBuilder().apply(building).build() +} + +inline fun > Graph.Companion.buildAdjacencyList(building: GraphBuilder.() -> Unit): MultiMapList { + return build(building).adjacencyList +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/graph/SimpleGraph.kt b/common/src/main/java/io/novafoundation/nova/common/utils/graph/SimpleGraph.kt new file mode 100644 index 0000000..71cc1c4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/graph/SimpleGraph.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.utils.graph + +import io.novafoundation.nova.common.utils.MultiMapList + +data class SimpleEdge(override val from: N, override val to: N) : WeightedEdge { + + override fun weightForAppendingTo(path: Path>): Int { + return 1 + } +} + +typealias SimpleGraph = Graph> + +fun Graph.Companion.createSimple(vararg multiMaps: MultiMapList): SimpleGraph { + return createSimple(multiMaps.toList()) +} + +fun Graph.Companion.createSimple(vararg adjacencyPairs: Pair>): SimpleGraph { + return createSimple(adjacencyPairs.toMap()) +} + +fun Graph.Companion.createSimple(multiMaps: List>): SimpleGraph { + return GraphBuilder>().apply { + multiMaps.forEach(::addSimpleEdges) + }.build() +} + +fun GraphBuilder>.addSimpleEdges(map: MultiMapList) { + map.forEach { (fromNode, toNodes) -> + addSimpleEdges(fromNode, toNodes) + } +} + +fun GraphBuilder>.addSimpleEdges(from: N, to: List) { + addEdges(from, to.map { SimpleEdge(from, it) }) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/gson/SealedTypeAdapterFactory.kt b/common/src/main/java/io/novafoundation/nova/common/utils/gson/SealedTypeAdapterFactory.kt new file mode 100644 index 0000000..c3cc7b9 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/gson/SealedTypeAdapterFactory.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.common.utils.gson + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonPrimitive +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import kotlin.reflect.KClass + +class SealedTypeAdapterFactory private constructor( + private val baseType: KClass, + private val typeFieldName: String +) : TypeAdapterFactory { + + private val subclasses = baseType.sealedSubclasses + private val nameToSubclass = subclasses.associateBy { it.simpleName!! } + + init { + if (!baseType.isSealed) throw IllegalArgumentException("$baseType is not a sealed class") + } + + override fun create(gson: Gson, type: TypeToken?): TypeAdapter? { + if (type == null || subclasses.isEmpty() || subclasses.none { type.rawType.isAssignableFrom(it.java) }) return null + + val elementTypeAdapter = gson.getAdapter(JsonElement::class.java) + val subclassToDelegate: Map, TypeAdapter<*>> = subclasses.associateWith { + gson.getDelegateAdapter(this, TypeToken.get(it.java)) + } + + return object : TypeAdapter() { + override fun write(writer: JsonWriter, value: R) { + val srcType = value::class + val label = srcType.simpleName!! + + @Suppress("UNCHECKED_CAST") + val delegate = subclassToDelegate[srcType] as TypeAdapter + val jsonObject = delegate.toJsonTree(value).asJsonObject + + if (jsonObject.has(typeFieldName)) { + throw JsonParseException("cannot serialize $label because it already defines a field named $typeFieldName") + } + val clone = JsonObject() + clone.add(typeFieldName, JsonPrimitive(label)) + jsonObject.entrySet().forEach { + clone.add(it.key, it.value) + } + elementTypeAdapter.write(writer, clone) + } + + override fun read(reader: JsonReader): R { + val element = elementTypeAdapter.read(reader) + val labelElement = element.asJsonObject.remove(typeFieldName) ?: throw JsonParseException( + "cannot deserialize $baseType because it does not define a field named $typeFieldName" + ) + val name = labelElement.asString + val subclass = nameToSubclass[name] ?: throw JsonParseException("cannot find $name subclass of $baseType") + @Suppress("UNCHECKED_CAST") + return (subclass.objectInstance as? R) ?: (subclassToDelegate[subclass]!!.fromJsonTree(element) as R) + } + } + } + + companion object { + fun of(clz: KClass) = SealedTypeAdapterFactory(clz, "type") + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/images/Icon.kt b/common/src/main/java/io/novafoundation/nova/common/utils/images/Icon.kt new file mode 100644 index 0000000..6ec1874 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/images/Icon.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.common.utils.images + +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.annotation.DrawableRes +import coil.ImageLoader +import coil.load +import coil.request.ImageRequest +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import java.io.File + +sealed class Icon { + + data class FromLink(val data: String) : Icon() + + data class FromFile(val data: File) : Icon() + + data class FromDrawable(val data: Drawable) : Icon() + + data class FromDrawableRes(@DrawableRes val res: Int) : Icon() +} + +typealias ExtraImageRequestBuilding = ImageRequest.Builder.() -> Unit + +fun ImageView.setIcon(icon: Icon, imageLoader: ImageLoader, builder: ExtraImageRequestBuilding = {}) { + when (icon) { + is Icon.FromDrawable -> load(icon.data, imageLoader, builder) + is Icon.FromLink -> load(icon.data, imageLoader, builder) + is Icon.FromDrawableRes -> load(icon.res, imageLoader, builder) + is Icon.FromFile -> load(icon.data, imageLoader, builder) + } +} + +fun ImageView.setIconOrMakeGone(icon: Icon?, imageLoader: ImageLoader, builder: ExtraImageRequestBuilding = {}) { + if (icon == null) { + this.makeGone() + } else { + this.makeVisible() + setIcon(icon, imageLoader, builder) + } +} + +fun Drawable.asIcon() = Icon.FromDrawable(this) +fun @receiver:DrawableRes Int.asIcon() = Icon.FromDrawableRes(this) +fun String.asUrlIcon() = Icon.FromLink(this) +fun String.asFileIcon() = Icon.FromFile(File(this)) +fun File.asIcon() = Icon.FromFile(this) + +fun ImageLoader.Companion.formatIcon(icon: Icon): Any = when (icon) { + is Icon.FromDrawable -> icon.data + is Icon.FromDrawableRes -> icon.res + is Icon.FromLink -> icon.data + is Icon.FromFile -> icon.data +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/input/Input.kt b/common/src/main/java/io/novafoundation/nova/common/utils/input/Input.kt new file mode 100644 index 0000000..1998f87 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/input/Input.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.common.utils.input + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first + +sealed class Input { + + sealed class Enabled(val value: I) : Input() { + class Modifiable(value: I) : Enabled(value) + + class UnModifiable(value: I) : Enabled(value) + } + + object Disabled : Input() +} + +fun T.modifiableInput(): Input = Input.Enabled.Modifiable(this) +fun T.unmodifiableInput(): Input = Input.Enabled.UnModifiable(this) +fun disabledInput(): Input = Input.Disabled + +fun Input.map(modification: (I) -> R): Input = when (this) { + is Input.Enabled.Modifiable -> Input.Enabled.Modifiable(modification(value)) + is Input.Enabled.UnModifiable -> Input.Enabled.UnModifiable(modification(value)) + Input.Disabled -> Input.Disabled +} + +fun Input.modify(new: I): Input = when (this) { + is Input.Enabled.Modifiable -> Input.Enabled.Modifiable(new) + is Input.Enabled.UnModifiable -> this + Input.Disabled -> this +} + +fun Input.modifyIfNotNull(new: I?): Input = new?.let { modify(it) } ?: this + +fun Input.fold( + ifEnabled: (I) -> R, + ifDisabled: R +): R = when (this) { + is Input.Enabled -> ifEnabled(value) + Input.Disabled -> ifDisabled +} + +inline fun Input.ifModifiable(action: (I) -> Unit) { + (this as? Input.Enabled.Modifiable)?.let { action(it.value) } +} + +val Input.valueOrNull + get() = (this as? Input.Enabled)?.value + +val Input<*>.isModifiable + get() = this is Input.Enabled.Modifiable + +suspend fun MutableSharedFlow>.modifyInput(newValue: I) { + emit(first().modify(newValue)) +} + +fun MutableStateFlow>.modifyInput(newValue: I) { + value = value.modify(newValue) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/insets/ImeInsetsState.kt b/common/src/main/java/io/novafoundation/nova/common/utils/insets/ImeInsetsState.kt new file mode 100644 index 0000000..0804cf9 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/insets/ImeInsetsState.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils.insets + +import android.os.Build + +enum class ImeInsetsState(val enabled: Boolean) { + DISABLE(false), + + /** + * Android 10 and lower doesn't support ime insets so we may have wrong insets in case when dappEntryPoint is shown + * So this state is useful to prevent use ime insets only for supported APIs + * See: [SplitScreenFragment.manageInsets]. + */ + ENABLE_IF_SUPPORTED(isImeInsetsSupported()) +} + +private fun isImeInsetsSupported() = Build.VERSION.SDK_INT > Build.VERSION_CODES.Q diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/insets/InsetsExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/insets/InsetsExt.kt new file mode 100644 index 0000000..1488e87 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/insets/InsetsExt.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.common.utils.insets + +import android.view.View +import dev.chrisbanes.insetter.applyInsetter + +fun View.applyBarMargin() = applyInsetter { + type(statusBars = true) { + margin() + } +} + +fun View.applyStatusBarInsets(consume: Boolean = true) = applyInsetter { + type(statusBars = true) { + padding() + } + + consume(consume) +} + +fun View.applyNavigationBarInsets(consume: Boolean = true, imeInsets: ImeInsetsState = ImeInsetsState.DISABLE) = applyInsetter { + type(navigationBars = true, ime = imeInsets.enabled) { + padding(bottom = true) + } + + consume(consume) +} + +fun View.applySystemBarInsets(consume: Boolean = true, imeInsets: ImeInsetsState = ImeInsetsState.DISABLE) = applyInsetter { + type(statusBars = true, navigationBars = true, ime = imeInsets.enabled) { + padding(top = true, bottom = true) + } + + consume(consume) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ip/IpAddressReceiver.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ip/IpAddressReceiver.kt new file mode 100644 index 0000000..cb6980b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ip/IpAddressReceiver.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.common.utils.ip + +interface IpAddressReceiver { + suspend fun get(): String +} + +suspend fun IpAddressReceiver.getOrNull() = runCatching { get() }.getOrNull() diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/ip/PublicIpAddressReceiver.kt b/common/src/main/java/io/novafoundation/nova/common/utils/ip/PublicIpAddressReceiver.kt new file mode 100644 index 0000000..d1ab2a2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/ip/PublicIpAddressReceiver.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils.ip + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.http.GET + +interface PublicIpReceiverApi { + @GET("https://api.ipify.org//") + suspend fun get(): String +} + +class PublicIpAddressReceiver( + private val api: PublicIpReceiverApi +) : IpAddressReceiver { + override suspend fun get(): String = withContext(Dispatchers.IO) { api.get() } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/keyboard/KeyboardExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/keyboard/KeyboardExt.kt new file mode 100644 index 0000000..362f125 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/keyboard/KeyboardExt.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.common.utils.keyboard + +import android.app.Activity +import android.content.Context +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import io.novafoundation.nova.common.utils.onDestroy + +fun View.hideSoftKeyboard() { + val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(windowToken, 0) +} + +fun Activity.hideSoftKeyboard() { + currentFocus?.hideSoftKeyboard() +} + +fun View.showSoftKeyboard() { + requestFocus() + + val inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + + inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) +} + +/** + * Make sure that the insets are not consumed by the layer above for this method to work correctly + */ +fun Lifecycle.setKeyboardVisibilityListener(view: View, callback: KeyboardVisibilityCallback?) { + if (callback == null) { + ViewCompat.setOnApplyWindowInsetsListener(view, null) + return + } + + onDestroy { + ViewCompat.setOnApplyWindowInsetsListener(view, null) + } + + var wasShownBefore: Boolean = view.isKeyboardVisible() + + ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> + val isShown = isImeInsetsVisible(insets) + if (isShown != wasShownBefore) { + wasShownBefore = isShown + callback.onKeyboardEvent(isShown) + } + insets + } + + ViewCompat.requestApplyInsets(view) +} + +fun Fragment.isKeyboardVisible(): Boolean { + return requireView().isKeyboardVisible() +} + +fun View.isKeyboardVisible(): Boolean { + val rootInsets = getRootInsets() ?: return false + return isImeInsetsVisible(rootInsets) +} + +private fun View.getRootInsets(): WindowInsetsCompat? { + return ViewCompat.getRootWindowInsets(this) +} + +private fun isImeInsetsVisible(insets: WindowInsetsCompat): Boolean { + return insets.isVisible(WindowInsetsCompat.Type.ime()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/keyboard/KeyboardVisibilityCallback.kt b/common/src/main/java/io/novafoundation/nova/common/utils/keyboard/KeyboardVisibilityCallback.kt new file mode 100644 index 0000000..9d5117d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/keyboard/KeyboardVisibilityCallback.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.utils.keyboard + +fun interface KeyboardVisibilityCallback { + fun onKeyboardEvent(isShown: Boolean) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/location/LocationManager.kt b/common/src/main/java/io/novafoundation/nova/common/utils/location/LocationManager.kt new file mode 100644 index 0000000..0270249 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/location/LocationManager.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.utils.location + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.utils.whenStarted +import android.location.LocationManager as NativeLocationManager + +interface LocationManager { + + fun enableLocation() + + fun isLocationEnabled(): Boolean +} + +class RealLocationManager(private val contextManager: ContextManager) : LocationManager { + + private val locationManager = contextManager.getApplicationContext().getSystemService(Context.LOCATION_SERVICE) as NativeLocationManager + + override fun enableLocation() { + val activity = contextManager.getActivity()!! + activity.lifecycle.whenStarted { + if (!locationManager.isProviderEnabled(NativeLocationManager.GPS_PROVIDER)) { + val enableLocationIntent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + contextManager.getActivity() + ?.startActivityForResult(enableLocationIntent, 0) + } + } + } + + override fun isLocationEnabled(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + locationManager.isLocationEnabled + } else { + locationManager.isProviderEnabled(NativeLocationManager.GPS_PROVIDER) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/markdown/BoldStylePlugin.kt b/common/src/main/java/io/novafoundation/nova/common/utils/markdown/BoldStylePlugin.kt new file mode 100644 index 0000000..175aa58 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/markdown/BoldStylePlugin.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.utils.markdown + +import android.content.Context +import androidx.annotation.ColorRes +import androidx.annotation.FontRes +import androidx.core.content.res.ResourcesCompat +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.MarkwonConfiguration +import io.noties.markwon.MarkwonSpansFactory +import io.noties.markwon.RenderProps +import io.noties.markwon.SpanFactory +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.fontSpan +import org.commonmark.node.StrongEmphasis + +class BoldStylePlugin( + private val context: Context, + @FontRes private val typefaceRes: Int, + @ColorRes private val colorRes: Int +) : AbstractMarkwonPlugin() { + + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder.setFactory(StrongEmphasis::class.java, BoldSpanFactory(context, typefaceRes, colorRes)) + } +} + +private class BoldSpanFactory(private val context: Context, private val typefaceRes: Int, private val colorRes: Int) : SpanFactory { + + override fun getSpans(configuration: MarkwonConfiguration, props: RenderProps): Any { + val font = ResourcesCompat.getFont(context, typefaceRes) + return arrayOf( + fontSpan(font), + colorSpan(context.getColor(colorRes)) + ) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/markdown/LinkStylePlugin.kt b/common/src/main/java/io/novafoundation/nova/common/utils/markdown/LinkStylePlugin.kt new file mode 100644 index 0000000..20dd2af --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/markdown/LinkStylePlugin.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.utils.markdown + +import android.content.Context +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.core.MarkwonTheme +import io.novafoundation.nova.common.R + +class LinkStylePlugin(private val context: Context) : AbstractMarkwonPlugin() { + + override fun configureTheme(builder: MarkwonTheme.Builder) { + builder.isLinkUnderlined(false) + .linkColor(context.getColor(R.color.button_background_primary)) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/markdown/RemoveHtmlTagsPlugin.kt b/common/src/main/java/io/novafoundation/nova/common/utils/markdown/RemoveHtmlTagsPlugin.kt new file mode 100644 index 0000000..216bc6f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/markdown/RemoveHtmlTagsPlugin.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils.markdown + +import io.noties.markwon.AbstractMarkwonPlugin + +class RemoveHtmlTagsPlugin(vararg tagNames: String) : AbstractMarkwonPlugin() { + + private val typeNamesRegex = tagNames.map { "<$it(\\s[^>]*)?>.*?|<$it(\\s[^>]*)?>".toRegex() } + + override fun processMarkdown(markdown: String): String { + var result = markdown + typeNamesRegex.forEach { + result = result.replace(it, "") + } + return result + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/multiResult/PartialRetriableMixin.kt b/common/src/main/java/io/novafoundation/nova/common/utils/multiResult/PartialRetriableMixin.kt new file mode 100644 index 0000000..8c326aa --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/multiResult/PartialRetriableMixin.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.utils.multiResult + +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.validation.ProgressConsumer +import kotlinx.coroutines.CoroutineScope + +interface PartialRetriableMixin : Retriable { + + interface Factory { + + fun create(scope: CoroutineScope): Presentation + } + + interface Presentation : PartialRetriableMixin { + + suspend fun handleMultiResult( + multiResult: RetriableMultiResult, + onSuccess: suspend (List) -> Unit, + progressConsumer: ProgressConsumer? = null, + onRetryCancelled: () -> Unit + ) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/multiResult/PartialRetriableMixinImpl.kt b/common/src/main/java/io/novafoundation/nova/common/utils/multiResult/PartialRetriableMixinImpl.kt new file mode 100644 index 0000000..1ba875f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/multiResult/PartialRetriableMixinImpl.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.common.utils.multiResult + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.base.errors.shouldIgnore +import io.novafoundation.nova.common.mixin.api.RetryPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.validation.ProgressConsumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RealPartialRetriableMixinFactory( + private val resourceManager: ResourceManager +) : PartialRetriableMixin.Factory { + + override fun create(scope: CoroutineScope): PartialRetriableMixin.Presentation { + return PartialRetriableMixinImpl(resourceManager, scope) + } +} + +private class PartialRetriableMixinImpl( + private val resourceManager: ResourceManager, + coroutineScope: CoroutineScope +) : PartialRetriableMixin.Presentation, CoroutineScope by coroutineScope { + + override suspend fun handleMultiResult( + multiResult: RetriableMultiResult, + onSuccess: suspend (List) -> Unit, + progressConsumer: ProgressConsumer?, + onRetryCancelled: () -> Unit + ) { + multiResult + .onAnyFailure { submissionFailed(it, onSuccess, progressConsumer, onRetryCancelled) } + .onFullSuccess(onSuccess) + + progressConsumer?.invoke(false) + } + + override val retryEvent = MutableLiveData>() + + private fun submissionFailed( + failure: RetriableMultiResult.RetriableFailure, + onSuccess: suspend (List) -> Unit, + progressConsumer: ProgressConsumer?, + onRetryCancelled: () -> Unit, + ) { + if (shouldIgnore(failure.error)) return + + val onRetry = { retrySubmission(failure, onSuccess, progressConsumer, onRetryCancelled) } + + retryEvent.value = RetryPayload( + title = resourceManager.getString(R.string.common_some_tx_failed_title), + message = resourceManager.getString(R.string.common_some_tx_failed_message), + onRetry = onRetry, + onCancel = onRetryCancelled + ).event() + } + + private fun retrySubmission( + failure: RetriableMultiResult.RetriableFailure, + onSuccess: suspend (List) -> Unit, + progressConsumer: ProgressConsumer?, + onRetryCancelled: () -> Unit + ) { + progressConsumer?.invoke(true) + + launch { + val newResult = withContext(Dispatchers.Default) { failure.retry() } + handleMultiResult(newResult, onSuccess, progressConsumer, onRetryCancelled) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/multiResult/RetriableMultiResult.kt b/common/src/main/java/io/novafoundation/nova/common/utils/multiResult/RetriableMultiResult.kt new file mode 100644 index 0000000..3083f98 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/multiResult/RetriableMultiResult.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.common.utils.multiResult + +import android.util.Log +import io.novafoundation.nova.common.base.errors.CompoundException +import io.novafoundation.nova.common.utils.multiResult.RetriableMultiResult.RetriableFailure +import io.novafoundation.nova.common.utils.requireException +import io.novafoundation.nova.common.utils.requireValue +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +class RetriableMultiResult(val succeeded: List, val failed: RetriableFailure?) { + + companion object + + class RetriableFailure(val retry: suspend () -> RetriableMultiResult, val error: Throwable) +} + +fun RetriableMultiResult.Companion.allFailed(failed: RetriableFailure) = RetriableMultiResult(emptyList(), failed) + +suspend inline fun RetriableMultiResult.onFullSuccess(action: suspend (successResults: List) -> Unit): RetriableMultiResult { + if (failed == null) { + action(succeeded) + } + + return this +} + +inline fun RetriableMultiResult.onAnyFailure(action: (failed: RetriableFailure) -> Unit): RetriableMultiResult { + if (failed != null) { + action(failed) + } + + return this +} + +suspend fun runMultiCatching( + intermediateListLoading: suspend () -> List, + listProcessing: suspend (I) -> T +): RetriableMultiResult = coroutineScope { + val intermediateListResult = runCatching { intermediateListLoading() } + .onFailure { Log.w("RetriableMultiResult", "Failed to construct multi result list", it) } + + if (intermediateListResult.isFailure) { + val retry = suspend { runMultiCatching(intermediateListLoading, listProcessing) } + + return@coroutineScope RetriableMultiResult.allFailed(RetriableFailure(retry, intermediateListResult.requireException())) + } + + val intermediateList = intermediateListResult.requireValue() + + val (succeeded, failed) = intermediateList.map { item -> + val asyncProcess = async { + runCatching { listProcessing(item) } + .onFailure { Log.w("RetriableMultiResult", "Failed to construct multi result for item $item", it) } + } + + asyncProcess to item + } + .partition { (itemResult, _) -> itemResult.await().isSuccess } + + val retryFailure = if (failed.isNotEmpty()) { + val failedItems = failed.map { it.second } + val exception = CompoundException(failed.map { it.first.await().requireException() }) + val retry = suspend { runMultiCatching(intermediateListLoading = { failedItems }, listProcessing) } + + RetriableFailure(retry, exception) + } else { + null + } + + val successResults = succeeded.map { it.first.await().getOrThrow() } + + RetriableMultiResult(successResults, retryFailure) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/network/DeviceNetworkStateObserver.kt b/common/src/main/java/io/novafoundation/nova/common/utils/network/DeviceNetworkStateObserver.kt new file mode 100644 index 0000000..cd1262e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/network/DeviceNetworkStateObserver.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.common.utils.network + +import kotlinx.coroutines.flow.Flow + +interface DeviceNetworkStateObserver { + fun observeIsNetworkAvailable(): Flow +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/network/RealDeviceNetworkStateObserver.kt b/common/src/main/java/io/novafoundation/nova/common/utils/network/RealDeviceNetworkStateObserver.kt new file mode 100644 index 0000000..1818a95 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/network/RealDeviceNetworkStateObserver.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.common.utils.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +class RealDeviceNetworkStateObserver( + private val context: Context +) : DeviceNetworkStateObserver { + + override fun observeIsNetworkAvailable(): Flow = callbackFlow { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + + val initialNetwork = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(initialNetwork) + val isConnected = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + trySend(isConnected) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + }.distinctUntilChanged() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/permissions/PermissionsAsker.kt b/common/src/main/java/io/novafoundation/nova/common/utils/permissions/PermissionsAsker.kt new file mode 100644 index 0000000..233e16e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/permissions/PermissionsAsker.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.common.utils.permissions + +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin + +typealias Permission = String + +interface PermissionsAsker { + enum class PermissionDeniedAction { + RETRY, CANCEL + } + + enum class PermissionDeniedLevel { + CAN_ASK_AGAIN, REQUIRE_SETTINGS_CHANGE + } + + val showPermissionsDenied: ActionAwaitableMixin + + interface Presentation : PermissionsAsker { + suspend fun requirePermissions(vararg permissions: Permission): Boolean + + fun checkPermissions(vararg permissions: Permission): Boolean + } +} + +fun PermissionsAsker.Presentation.checkPermissions(permissions: List): Boolean { + return checkPermissions(*permissions.toTypedArray()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/permissions/ReturnablePermissionsAsker.kt b/common/src/main/java/io/novafoundation/nova/common/utils/permissions/ReturnablePermissionsAsker.kt new file mode 100644 index 0000000..920bdcf --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/permissions/ReturnablePermissionsAsker.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.common.utils.permissions + +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import com.github.florent37.runtimepermission.kotlin.PermissionException +import com.github.florent37.runtimepermission.kotlin.coroutines.experimental.askPermission +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker.PermissionDeniedAction +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker.PermissionDeniedLevel +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker.Presentation + +class PermissionsAskerFactory( + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory +) { + + fun createReturnable( + fragment: Fragment, + router: ReturnableRouter + ): Presentation = ReturnablePermissionsAsker(actionAwaitableMixinFactory, fragment, router) + + fun create( + fragment: Fragment + ): Presentation = BasePermissionsAsker(actionAwaitableMixinFactory, fragment) +} + +private open class BasePermissionsAsker( + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + protected val fragment: Fragment +) : Presentation { + + override val showPermissionsDenied = actionAwaitableMixinFactory.create() + + override suspend fun requirePermissions(vararg permissions: Permission): Boolean { + try { + fragment.askPermission(*permissions) + + return true + } catch (e: PermissionException) { + val level = if (e.hasForeverDenied()) { + PermissionDeniedLevel.REQUIRE_SETTINGS_CHANGE + } else { + PermissionDeniedLevel.CAN_ASK_AGAIN + } + + when (showPermissionsDenied.awaitAction(level)) { + PermissionDeniedAction.RETRY -> return onRetry(e, *permissions) + + PermissionDeniedAction.CANCEL -> return onCancel() + } + } + } + + override fun checkPermissions(vararg permissions: Permission): Boolean { + return permissions.all { + ContextCompat.checkSelfPermission(fragment.requireContext(), it) == PackageManager.PERMISSION_GRANTED + } + } + + protected suspend fun onRetry(e: PermissionException, vararg permissions: Permission): Boolean { + if (e.hasDenied()) { + return requirePermissions(*permissions) + } + + if (e.hasForeverDenied()) { + e.goToSettings() + + return false + } + + return true + } + + protected open suspend fun onCancel(): Boolean { + return false + } +} + +private class ReturnablePermissionsAsker( + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + fragment: Fragment, + private val returnableRouter: ReturnableRouter +) : BasePermissionsAsker(actionAwaitableMixinFactory, fragment) { + + override suspend fun onCancel(): Boolean { + returnableRouter.back() + + return false + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/permissions/Ui.kt b/common/src/main/java/io/novafoundation/nova/common/utils/permissions/Ui.kt new file mode 100644 index 0000000..627526b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/permissions/Ui.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.common.utils.permissions + +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker.PermissionDeniedAction +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker.PermissionDeniedLevel.CAN_ASK_AGAIN +import io.novafoundation.nova.common.view.dialog.warningDialog + +fun BaseFragment<*, *>.setupPermissionAsker(component: PermissionsAsker) { + component.showPermissionsDenied.awaitableActionLiveData.observeEvent { + val level = it.payload + + warningDialog( + context = requireContext(), + onPositiveClick = { it.onSuccess(PermissionDeniedAction.RETRY) }, + onNegativeClick = { it.onSuccess(PermissionDeniedAction.CANCEL) }, + positiveTextRes = if (level == CAN_ASK_AGAIN) R.string.common_ask_again else R.string.common_to_settings + ) { + if (level == CAN_ASK_AGAIN) { + setTitle(R.string.common_permission_permissions_needed_title) + setMessage(R.string.common_permission_permissions_needed_message) + } else { + setTitle(R.string.common_permission_permissions_denied_title) + setMessage(R.string.common_permission_permissions_denied_message) + } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/progress/ProgressDialog.kt b/common/src/main/java/io/novafoundation/nova/common/utils/progress/ProgressDialog.kt new file mode 100644 index 0000000..b879d3c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/progress/ProgressDialog.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.utils.progress + +import android.app.Dialog +import android.content.Context +import android.widget.TextView +import androidx.annotation.StringRes +import io.novafoundation.nova.common.R + +class ProgressDialog(context: Context) : Dialog(context) { + + init { + setContentView(R.layout.dialog_progress) + setCancelable(false) + } + + fun setText(@StringRes textRes: Int) { + findViewById(R.id.progressText).setText(textRes) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/progress/ProgressDialogExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/progress/ProgressDialogExt.kt new file mode 100644 index 0000000..7b4bba0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/progress/ProgressDialogExt.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.utils.progress + +import android.app.Dialog +import io.novafoundation.nova.common.base.BaseFragmentMixin + +fun BaseFragmentMixin<*>.observeProgressDialog(progressDialogMixin: ProgressDialogMixin): Dialog { + val progressDialog = ProgressDialog(providedContext) + progressDialogMixin.showProgressLiveData.observeEvent { + when (it) { + is ProgressState.Show -> { + progressDialog.setText(it.textRes) + progressDialog.show() + } + + is ProgressState.Hide -> progressDialog.dismiss() + } + } + return progressDialog +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/progress/ProgressDialogMixin.kt b/common/src/main/java/io/novafoundation/nova/common/utils/progress/ProgressDialogMixin.kt new file mode 100644 index 0000000..5e2b363 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/progress/ProgressDialogMixin.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.utils.progress + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event + +class ProgressDialogMixinFactory { + + fun create(): ProgressDialogMixin = RealProgressDialogMixin() +} + +interface ProgressDialogMixin { + + val showProgressLiveData: LiveData> + + fun setProgressState(state: ProgressState) +} + +class RealProgressDialogMixin : ProgressDialogMixin { + + private val _showProgressLiveData = MutableLiveData>() + + override val showProgressLiveData: LiveData> = _showProgressLiveData + + override fun setProgressState(state: ProgressState) { + _showProgressLiveData.value = state.event() + } +} + +sealed interface ProgressState { + + class Show(val textRes: Int) : ProgressState + + object Hide : ProgressState +} + +suspend fun ProgressDialogMixin.startProgress(progressTextRes: Int, action: suspend () -> Unit) { + setProgressState(ProgressState.Show(progressTextRes)) + action() + setProgressState(ProgressState.Hide) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/WithViewType.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/WithViewType.kt new file mode 100644 index 0000000..ebb50e7 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/WithViewType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.utils.recyclerView + +interface WithViewType { + val viewType: Int +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/Extensions.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/Extensions.kt new file mode 100644 index 0000000..dc3c325 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/Extensions.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils.recyclerView.dragging + +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.View +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +@SuppressLint("ClickableViewAccessibility") +fun View.prepareForDragging(viewHolder: ViewHolder, startDragListener: StartDragListener) { + this.setOnTouchListener { _, event -> + if (event.action == MotionEvent.ACTION_DOWN) { + startDragListener.requestDrag(viewHolder) + } + false + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/OnItemDragCallback.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/OnItemDragCallback.kt new file mode 100644 index 0000000..b28b946 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/OnItemDragCallback.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.utils.recyclerView.dragging + +interface OnItemDragCallback { + fun onItemMove(fromPosition: Int, toPosition: Int) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/SimpleItemDragHelperCallback.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/SimpleItemDragHelperCallback.kt new file mode 100644 index 0000000..e592a4a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/SimpleItemDragHelperCallback.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.common.utils.recyclerView.dragging + +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView + +class SimpleItemDragHelperCallback( + private val onItemDragCallback: OnItemDragCallback +) : ItemTouchHelper.Callback() { + + override fun isLongPressDragEnabled(): Boolean { + return true + } + + override fun isItemViewSwipeEnabled(): Boolean { + return false + } + + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN + return makeMovementFlags(dragFlags, 0) + } + + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + onItemDragCallback.onItemMove(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/StartDragListener.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/StartDragListener.kt new file mode 100644 index 0000000..b5532ff --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/dragging/StartDragListener.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.common.utils.recyclerView.dragging + +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +interface StartDragListener { + fun requestDrag(viewHolder: ViewHolder) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableAdapter.kt new file mode 100644 index 0000000..2ea0f99 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableAdapter.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable + +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableBaseItem + +interface ExpandableAdapter { + + fun getItems(): List +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableAnimationExt.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableAnimationExt.kt new file mode 100644 index 0000000..a54d6a3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableAnimationExt.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable + +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimationItemState + +fun ExpandableAnimationItemState.flippedFraction(): Float { + return 1f - animationFraction +} + +fun Float.flippedFraction(): Float { + return 1f - this +} + +fun ExpandableAnimationItemState.expandingFraction(): Float { + return when (animationType) { + ExpandableAnimationItemState.Type.EXPANDING -> animationFraction + ExpandableAnimationItemState.Type.COLLAPSING -> flippedFraction() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableAnimationSettings.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableAnimationSettings.kt new file mode 100644 index 0000000..2d001e0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableAnimationSettings.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable + +import android.view.animation.Interpolator + +class ExpandableAnimationSettings(val duration: Long, val interpolator: Interpolator) { + companion object; +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableItemAnimator.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableItemAnimator.kt new file mode 100644 index 0000000..2c7a806 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableItemAnimator.kt @@ -0,0 +1,299 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.view.ViewPropertyAnimator +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import androidx.recyclerview.widget.SimpleItemAnimator +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimationItemState +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimator + +/** + * Potential problems: + * - If in one time we will run add and move animation or remove and move animation - one of an animation will be cancelled + */ +abstract class ExpandableItemAnimator( + private val settings: ExpandableAnimationSettings, + private val expandableAnimator: ExpandableAnimator +) : SimpleItemAnimator() { + + private var preparedForAnimation = false + + private val addAnimations = mutableMapOf>() // Parent item to children + private val removeAnimations = mutableMapOf>() // Parent item to children + private val moveAnimations = mutableListOf() + + private val pendingAddAnimations = mutableSetOf() + private val pendingRemoveAnimations = mutableSetOf() + private val pendingMoveAnimations = mutableSetOf() + + init { + addDuration = settings.duration + removeDuration = settings.duration + moveDuration = settings.duration + + supportsChangeAnimations = false + } + + /** + * Use this method before adapter.submitList() to prepare items for animation. + * Item animations will be skipped otherwise + */ + fun prepareForAnimation() { + preparedForAnimation = true + } + + override fun animateAdd(holder: ViewHolder): Boolean { + val notPreparedForAnimation = !preparedForAnimation + val notExpandableChildItem = holder !is ExpandableChildViewHolder || holder.expandableItem == null + if (notPreparedForAnimation || notExpandableChildItem) { + dispatchAddFinished(holder) + return false + } + + val item = (holder as ExpandableChildViewHolder).expandableItem!! + + // Reset move state helps clear translationY when animation is being to be canceled + if (pendingMoveAnimations.contains(holder)) { + holder.itemView.animate().cancel() + + resetMoveState(holder) + } + + if (pendingRemoveAnimations.contains(holder)) { + holder.itemView.animate().cancel() + } else { + preAddImpl(holder) + } + + if (item.groupId !in addAnimations) addAnimations[item.groupId] = mutableListOf() + addAnimations[item.groupId]?.add(holder) + + expandableAnimator.prepareAnimationToState(item.groupId, ExpandableAnimationItemState.Type.EXPANDING) + + return true + } + + override fun animateRemove(holder: ViewHolder): Boolean { + val notPreparedForAnimation = !preparedForAnimation + val notExpandableChildItem = holder !is ExpandableChildViewHolder || holder.expandableItem == null + if (notPreparedForAnimation || notExpandableChildItem) { + dispatchRemoveFinished(holder) + return false + } + + val item = (holder as ExpandableChildViewHolder).expandableItem!! + + // Reset move state helps clear translationY when animation is being to be canceled + if (pendingMoveAnimations.contains(holder)) { + holder.itemView.animate().cancel() + + resetMoveState(holder) + } + + if (pendingAddAnimations.contains(holder)) { + holder.itemView.animate().cancel() + } else { + preRemoveImpl(holder) + } + + if (item.groupId !in removeAnimations) removeAnimations[item.groupId] = mutableListOf() + removeAnimations[item.groupId]?.add(holder) + + expandableAnimator.prepareAnimationToState(item.groupId, ExpandableAnimationItemState.Type.COLLAPSING) + + return true + } + + override fun animateMove(holder: ViewHolder, fromX: Int, fromY: Int, toX: Int, toY: Int): Boolean { + val notPreparedForAnimation = !preparedForAnimation + if (notPreparedForAnimation || holder !is ExpandableBaseViewHolder<*>) { + dispatchMoveFinished(holder) + return false + } + + // Reset add state helps clear alpha and scale when animation is being to be canceled + if (pendingAddAnimations.contains(holder)) { + holder.itemView.animate().cancel() + resetAddState(holder) + } + + // Reset remove state helps clear alpha and scale when animation is being to be canceled + if (pendingRemoveAnimations.contains(holder)) { + holder.itemView.animate().cancel() + } + + if (pendingMoveAnimations.contains(holder)) { + holder.itemView.animate().cancel() + } + + preMoveImpl(holder, fromY, toY) + moveAnimations.add(holder) + return true + } + + override fun animateChange(oldHolder: ViewHolder?, newHolder: ViewHolder?, fromLeft: Int, fromTop: Int, toLeft: Int, toTop: Int): Boolean { + if (oldHolder == newHolder) { + dispatchChangeFinished(newHolder, false) + } else { + dispatchChangeFinished(oldHolder, true) + dispatchChangeFinished(newHolder, false) + } + return false + } + + override fun runPendingAnimations() { + // Add animation + expand items + runExpandableAnimationFor(addAnimations, pendingAddAnimations) { animateAddImpl(it) } + + // Remove animation + collapse items + runExpandableAnimationFor(removeAnimations, pendingRemoveAnimations) { animateRemoveImpl(it) } + + // Move animation + val animatingViewHolders = moveAnimations.toList() + moveAnimations.clear() + + for (holder in animatingViewHolders) { + animateMoveImpl(holder) + } + + pendingMoveAnimations.addAll(animatingViewHolders) + + // Set prepare for animation = false to return to skipping animations + if (pendingAddAnimations.isNotEmpty() || pendingRemoveAnimations.isNotEmpty() || pendingMoveAnimations.isNotEmpty()) { + preparedForAnimation = false + } + } + + private fun runExpandableAnimationFor( + animationGroup: MutableMap>, + pendingAnimations: MutableSet, + runAnimation: (ViewHolder) -> Unit + ) { + val parentItemIds = animationGroup.keys.toList() + val animatingViewHolders = animationGroup.flatMap { (_, viewHolders) -> viewHolders } + animationGroup.clear() + + parentItemIds.forEach { expandableAnimator.runAnimationFor(it) } + for (holder in animatingViewHolders) { + runAnimation(holder) + } + + pendingAnimations.addAll(animatingViewHolders) + } + + abstract fun preAddImpl(holder: ViewHolder) + + abstract fun getAddAnimator(holder: ViewHolder): ViewPropertyAnimator + + abstract fun preRemoveImpl(holder: ViewHolder) + + abstract fun getRemoveAnimator(holder: ViewHolder): ViewPropertyAnimator + + abstract fun preMoveImpl(holder: ViewHolder, fromY: Int, toY: Int) + + abstract fun getMoveAnimator(holder: ViewHolder): ViewPropertyAnimator + + abstract fun resetAddState(holder: ViewHolder) + + abstract fun resetRemoveState(holder: ViewHolder) + + abstract fun resetMoveState(holder: ViewHolder) + + private fun animateAddImpl(holder: ViewHolder) { + val animation = getAddAnimator(holder) + animation.setInterpolator(settings.interpolator) + .setDuration(settings.duration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animator: Animator) { + addFinished(holder) + animation.setListener(null) + } + }).start() + } + + private fun animateRemoveImpl(holder: ViewHolder) { + val animation = getRemoveAnimator(holder) + animation.setInterpolator(settings.interpolator) + .setDuration(settings.duration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animator: Animator) { + resetRemoveState(holder) + removeFinished(holder) + animation.setListener(null) + } + }).start() + } + + private fun animateMoveImpl(holder: ViewHolder) { + val animation = getMoveAnimator(holder) + animation.setInterpolator(settings.interpolator) + .setDuration(settings.duration) + .setListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animator: Animator) { + moveFinished(holder) + animation.setListener(null) + } + }).start() + } + + override fun endAnimation(viewHolder: ViewHolder) { + viewHolder.itemView.animate().cancel() + } + + override fun endAnimations() { + pendingAddAnimations.iterator().forEach { it.itemView.animate().cancel() } + pendingAddAnimations.clear() + + pendingRemoveAnimations.iterator().forEach { it.itemView.animate().cancel() } + pendingRemoveAnimations.clear() + + pendingMoveAnimations.iterator().forEach { it.itemView.animate().cancel() } + pendingMoveAnimations.clear() + + addAnimations.clear() + removeAnimations.clear() + moveAnimations.clear() + + expandableAnimator.cancelAnimations() + } + + override fun isRunning(): Boolean { + return addAnimations.isNotEmpty() || + removeAnimations.isNotEmpty() || + moveAnimations.isNotEmpty() || + pendingAddAnimations.isNotEmpty() || + pendingRemoveAnimations.isNotEmpty() || + pendingMoveAnimations.isNotEmpty() + } + + private fun addFinished(holder: ViewHolder) { + pendingAddAnimations.remove(holder) + internalDispatchAnimationFinished(holder) + } + + private fun removeFinished(holder: ViewHolder) { + pendingRemoveAnimations.remove(holder) + internalDispatchAnimationFinished(holder) + } + + private fun moveFinished(holder: ViewHolder) { + pendingMoveAnimations.remove(holder) + internalDispatchAnimationFinished(holder) + } + + private fun internalDispatchAnimationFinished(holder: ViewHolder) { + if (holder in pendingAddAnimations) return + if (holder in pendingRemoveAnimations) return + if (holder in pendingMoveAnimations) return + + dispatchAnimationFinished(holder) + dispatchFinishedWhenDone() + } + + private fun dispatchFinishedWhenDone() { + if (!isRunning) { + dispatchAnimationsFinished() + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableItemDecoration.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableItemDecoration.kt new file mode 100644 index 0000000..ec30ca0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableItemDecoration.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable + +import android.graphics.Canvas +import android.graphics.Rect +import android.view.View +import androidx.core.view.children +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Adapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.indexOfFirstOrNull +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimator +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimationItemState +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableBaseItem +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableChildItem +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableParentItem + +private data class ItemWithViewHolder(val position: Int, val item: ExpandableBaseItem, val viewHolder: ViewHolder?) + +abstract class ExpandableItemDecoration( + private val adapter: ExpandableAdapter, + private val animator: ExpandableAnimator +) : RecyclerView.ItemDecoration() { + + abstract fun onDrawGroup( + canvas: Canvas, + animationState: ExpandableAnimationItemState, + recyclerView: RecyclerView, + parentItem: ExpandableParentItem, + parent: ViewHolder?, + children: List + ) + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + } + + override fun onDraw(canvas: Canvas, recyclerView: RecyclerView, state: RecyclerView.State) { + val items = getParentAndChildren(recyclerView) + for ((parentItem, children) in items) { + val animationState = animator.getStateForPosition(parentItem.position) ?: continue + val childViewHolders = children.mapNotNull { it.viewHolder } + onDrawGroup(canvas, animationState, recyclerView, parentItem.item as ExpandableParentItem, parentItem.viewHolder, childViewHolders) + } + } + + private fun getParentAndChildren(recyclerView: RecyclerView): Map> { + // Searching all view holders in recycler view and match them with adapter items + val items = recyclerView.children.toList() + .mapNotNull { + val viewHolder = recyclerView.getChildViewHolder(it) + val expandableViewHolder = viewHolder as? ExpandableBaseViewHolder<*> ?: return@mapNotNull null + val item = expandableViewHolder.expandableItem ?: return@mapNotNull null + ItemWithViewHolder(viewHolder.bindingAdapterPosition, item, viewHolder) + } + + // Grouping view holders by parents + val parentsWithChildren = mutableMapOf>() + + val parents = items.filter { it.item is ExpandableParentItem }.associateBy { it.item.getId() } + val children = items.filter { it.item is ExpandableChildItem } + parents.values.forEach { parentsWithChildren[it] = mutableListOf() } + + children.forEach { child -> + val item = child.item as ExpandableChildItem + val parent = parents[item.groupId] ?: getParentForItem(recyclerView, item) ?: return@forEach + val parentChildren = parentsWithChildren[parent] ?: mutableListOf() + parentChildren.add(child) + parentsWithChildren[parent] = parentChildren + } + + return parentsWithChildren + } + + private fun getParentForItem(recyclerView: RecyclerView, item: ExpandableChildItem): ItemWithViewHolder? { + val positionInAdapter = adapter.getItems().indexOfFirstOrNull { it.getId() == item.groupId } ?: return null + val parentItem = adapter.getItems()[positionInAdapter] + val globalAdapterPosition = positionInAdapter.convertToGlobalAdapterPosition(recyclerView, adapter as Adapter<*>) + val viewHolder = recyclerView.findViewHolderForAdapterPosition(globalAdapterPosition) + return ItemWithViewHolder(positionInAdapter, parentItem as ExpandableParentItem, viewHolder) + } + + // Useful to find global position if ConcatAdapter is used + private fun Int.convertToGlobalAdapterPosition(recyclerView: RecyclerView, localAdapter: Adapter<*>): Int { + val globalAdapter = recyclerView.adapter + return if (globalAdapter is ConcatAdapter) { + val localAdapterIndex = globalAdapter.adapters.indexOf(localAdapter) + if (localAdapterIndex > 0) { + val adaptersBeforeTarget = globalAdapter.adapters.subList(0, localAdapterIndex - 1) + val offset = adaptersBeforeTarget.sumOf { it.itemCount } + this + offset + } else { + this + } + } else { + this + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableParentViewHolder.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableParentViewHolder.kt new file mode 100644 index 0000000..6b3ba22 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/ExpandableParentViewHolder.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable + +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableBaseItem +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableChildItem +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableParentItem + +interface ExpandableBaseViewHolder { + var expandableItem: T? + + fun updateExpandableItem(item: T) { + expandableItem = item + } +} + +/** + * The view holder that may show ExpandableChildItem's + * It's used to check the type of viewHolder in [ExpandableItemDecoration] + */ +interface ExpandableParentViewHolder : ExpandableBaseViewHolder + +/** + * The view holder that is shown as an ExpandableChildItem + * It's used to check the type of viewHolder in [ExpandableItemDecoration] + */ +interface ExpandableChildViewHolder : ExpandableBaseViewHolder diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/animator/ExpandableAnimator.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/animator/ExpandableAnimator.kt new file mode 100644 index 0000000..eb54259 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/animator/ExpandableAnimator.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable.animator + +import android.animation.Animator +import android.animation.ValueAnimator +import androidx.core.animation.addListener +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAdapter +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAnimationSettings +import io.novafoundation.nova.common.utils.recyclerView.expandable.flippedFraction +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableBaseItem +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableParentItem + +/** + * EXPANDING: animationFraction = 0f - fully collapsed and animationFraction = 1f - fully expanded + * COLLAPSING: animationFraction = 0f - fully expanded and animationFraction = 1f - fully collapsed + * So animationFraction is always move from 0f to 1f + */ +class ExpandableAnimationItemState(val animationType: Type, animationFraction: Float) { + + var animationFraction: Float = animationFraction + internal set(value) { + field = value.coerceIn(0f, 1f) + } + + enum class Type { + EXPANDING, COLLAPSING + } +} + +private class RunningAnimation(val currentState: ExpandableAnimationItemState, val animator: Animator) + +class ExpandableAnimator( + private val recyclerView: RecyclerView, + private val animationSettings: ExpandableAnimationSettings, + private val expandableAdapter: ExpandableAdapter +) { + + // It contains only items that is animating right now + private val runningAnimations = mutableMapOf() + + // Return current animation state for parent position or calculate state in [getExpandableItemState] if it isn't animating now + fun getStateForPosition(position: Int): ExpandableAnimationItemState? { + val items = expandableAdapter.getItems() + val item = items.getOrNull(position) ?: return null + if (item !is ExpandableParentItem) return null + + return runningAnimations[item.getId()]?.currentState ?: getExpandableItemState(position, items) + } + + // Just prepare an animation without running + fun prepareAnimationToState(parentId: String, type: ExpandableAnimationItemState.Type) { + val existingSettings = runningAnimations[parentId] + + // No need to run animation if animation state is running and current type is the same + if (existingSettings == null) { + val state = ExpandableAnimationItemState(type, 0f) + setAnimationFor(parentId, state) + } else { + // No need to update animation state if it's the same and already running + if (existingSettings.currentState.animationType == type) { + return + } + + // Toggle animation state and flipping fraction to continue the animation but to another side + val state = ExpandableAnimationItemState(type, existingSettings.currentState.flippedFraction()) + setAnimationFor(parentId, state) + } + } + + fun runAnimationFor(parentId: String) { + val existingSettings = runningAnimations[parentId] + existingSettings?.animator?.start() + } + + private fun setAnimationFor(parentId: String, state: ExpandableAnimationItemState) { + runningAnimations[parentId]?.animator?.cancel() // Cancel previous animation if it's exist + + val animator = ValueAnimator.ofFloat(state.animationFraction, 1f) + .setDuration(animationSettings.duration) + + animator.interpolator = animationSettings.interpolator + animator.addUpdateListener { + state.animationFraction = it.animatedValue as Float + recyclerView.invalidate() + } // Invalidate recycler view to trigger onDraw in Item Decoration + animator.addListener(onEnd = { runningAnimations.remove(parentId) }) + + runningAnimations[parentId] = RunningAnimation(state, animator) + } + + fun cancelAnimations() { + runningAnimations.values + .toList() // Copy list to avoid ConcurrentModificationException + .forEach { it.animator.cancel() } + } + + private fun getExpandableItemState(position: Int, items: List): ExpandableAnimationItemState { + val nextItem = items.getOrNull(position + 1) + + // If next item is not a parent item it means current item is fully expanded + return if (nextItem == null || nextItem is ExpandableParentItem) { + ExpandableAnimationItemState(ExpandableAnimationItemState.Type.COLLAPSING, 1f) + } else { + ExpandableAnimationItemState(ExpandableAnimationItemState.Type.EXPANDING, 1f) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/items/ExpandableBaseItem.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/items/ExpandableBaseItem.kt new file mode 100644 index 0000000..2bcd719 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/items/ExpandableBaseItem.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable.items + +/** + * The item that may show ExpandingItem's + */ +interface ExpandableBaseItem { + fun getId(): String +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/items/ExpandableChildItem.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/items/ExpandableChildItem.kt new file mode 100644 index 0000000..902d8a1 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/items/ExpandableChildItem.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable.items + +/** + * The item that may be shown or hidden From ExpandableItem + */ +interface ExpandableChildItem : ExpandableBaseItem { + + val groupId: String +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/items/ExpandableParentItem.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/items/ExpandableParentItem.kt new file mode 100644 index 0000000..dee85b8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/expandable/items/ExpandableParentItem.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.common.utils.recyclerView.expandable.items + +/** + * The item that may show ExpandingItem's + */ +interface ExpandableParentItem : ExpandableBaseItem diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/RecyclerViewItemSpace.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/RecyclerViewItemSpace.kt new file mode 100644 index 0000000..58d1566 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/RecyclerViewItemSpace.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.utils.recyclerView.space + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +interface RecyclerViewItemSpace { + fun handleSpace(outRect: Rect, view: View, parent: RecyclerView): Boolean +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/SpaceBetween.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/SpaceBetween.kt new file mode 100644 index 0000000..b835c4c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/SpaceBetween.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.utils.recyclerView.space + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.recyclerView.WithViewType + +class SpaceBetween( + upperViewTypeProvider: WithViewType, + lowerViewTypeProvider: WithViewType, + private val spaceDp: Int +) : RecyclerViewItemSpace { + + private val upperViewType = upperViewTypeProvider.viewType + private val lowerViewType = lowerViewTypeProvider.viewType + + constructor(singleViewTypeProvider: WithViewType, spaceDp: Int) : this(singleViewTypeProvider, singleViewTypeProvider, spaceDp) + + override fun handleSpace(outRect: Rect, view: View, parent: RecyclerView): Boolean { + if (shouldSetSpaceForItems(parent, view)) { + setSpaceBetweenItems(outRect, view) + return true + } + + return false + } + + private fun shouldSetSpaceForItems( + parent: RecyclerView, + view: View + ): Boolean { + if (parent.getViewType(view) != upperViewType) return false + if (parent.getNextViewType(view) != lowerViewType) return false + + return true + } + + private fun setSpaceBetweenItems(outRect: Rect, view: View) { + outRect.set(0, 0, 0, spaceDp.dp(view.context)) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/SpaceItemDecoration.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/SpaceItemDecoration.kt new file mode 100644 index 0000000..894f291 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/SpaceItemDecoration.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.common.utils.recyclerView.space + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ItemDecoration + +class SpaceItemDecoration(private val spaces: List) : ItemDecoration() { + + class Builder { + private val spaces = mutableListOf() + + fun add(space: RecyclerViewItemSpace) { + spaces.add(space) + } + + fun build(): SpaceItemDecoration { + return SpaceItemDecoration(spaces) + } + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + handleFirstMatchedSpaceFor(outRect, view, parent) + } + + private fun handleFirstMatchedSpaceFor(outRect: Rect, view: View, parent: RecyclerView) { + spaces.firstOrNull { it.handleSpace(outRect, view, parent) } + } +} + +fun RecyclerView.addSpaceItemDecoration(setup: SpaceItemDecoration.Builder.() -> Unit) { + val builder = SpaceItemDecoration.Builder() + builder.setup() + addItemDecoration(builder.build()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/Utils.kt b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/Utils.kt new file mode 100644 index 0000000..b132a1c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/recyclerView/space/Utils.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.common.utils.recyclerView.space + +import android.view.View +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.RecyclerView + +fun RecyclerView.getAdapterPosition(view: View): Int? { + val adapterPosition = getChildAdapterPosition(view) + if (adapterPosition == RecyclerView.NO_POSITION) return null + return adapterPosition +} + +fun RecyclerView.getViewType(view: View): Int? { + val adapterPosition = getAdapterPosition(view) ?: return null + + return getViewTypeForPosition(adapterPosition) +} + +fun RecyclerView.getNextViewType(view: View): Int? { + val adapterPosition = getAdapterPosition(view) ?: return null + + return getViewTypeForPosition(adapterPosition + 1) +} + +fun RecyclerView.getViewTypeForPosition(position: Int): Int? { + if (position == RecyclerView.NO_POSITION) return null + + return adapter?.getViewTypeInNestedAdapters(position) +} + +private fun RecyclerView.Adapter<*>.getViewTypeInNestedAdapters(position: Int): Int? { + if (position >= itemCount) return null + + return when (this) { + is ConcatAdapter -> this.getTrueViewTypeFromPosition(position) + + else -> this.getItemViewType(position) + } +} + +/* +ConcatAdapter may change view types of nested adapters, so this method returns true view type of a position + */ +private fun ConcatAdapter.getTrueViewTypeFromPosition(position: Int): Int? { + var localPosition = position + adapters.forEach { + if (localPosition < it.itemCount) { + return it.getViewTypeInNestedAdapters(localPosition) + } else { + localPosition -= it.itemCount + } + } + + return null +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/scale/ToDynamicScaleInstance.kt b/common/src/main/java/io/novafoundation/nova/common/utils/scale/ToDynamicScaleInstance.kt new file mode 100644 index 0000000..73d451e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/scale/ToDynamicScaleInstance.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.common.utils.scale + +interface ToDynamicScaleInstance { + + fun toEncodableInstance(): Any? +} + +@JvmInline +value class DynamicScaleInstance(val value: Any?) : ToDynamicScaleInstance { + + override fun toEncodableInstance(): Any? { + return value + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/search/PhraseSearch.kt b/common/src/main/java/io/novafoundation/nova/common/utils/search/PhraseSearch.kt new file mode 100644 index 0000000..65d394f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/search/PhraseSearch.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.utils.search + +interface PhraseSearch { + fun isPhraseQuery(): Boolean + + fun matchedWith(raw: String): Boolean +} + +class CachedPhraseSearch(query: String) : PhraseSearch { + + private val isPhrase = query.trim().contains(' ') + private val phraseRegex = query.getPhraseRegex() + private val searchCache = mutableMapOf() + + override fun isPhraseQuery(): Boolean { + return isPhrase + } + + override fun matchedWith(raw: String): Boolean { + return if (searchCache.containsKey(raw)) { + searchCache.getValue(raw) + } else { + val matchingResult = phraseRegex.containsMatchIn(raw) + searchCache[raw] = matchingResult + matchingResult + } + } +} + +private fun String.getPhraseRegex(): Regex { + if (isEmpty()) return "\$.".toRegex() // Regex that doesn't match anything + + return this.trim() + .replace(" ", ".*") + .toRegex() // Matching words in order: first.*second.*third +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/search/SearchComparator.kt b/common/src/main/java/io/novafoundation/nova/common/utils/search/SearchComparator.kt new file mode 100644 index 0000000..0e3eaac --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/search/SearchComparator.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.common.utils.search + +class SearchComparator( + private val query: String, + private val phraseSearch: PhraseSearch?, + private val extractors: List<(T) -> String?> +) : Comparator { + + class Builder(private val query: String, extractor: (T) -> String?) { + + private val extractors = mutableListOf(extractor) + private var phraseSearch: PhraseSearch? = null + + fun addPhraseSearch(phraseSearch: PhraseSearch): Builder { + this.phraseSearch = phraseSearch + return this + } + + fun and(extractor: (T) -> String?): Builder { + extractors += extractor + return this + } + + fun build(): SearchComparator { + return SearchComparator(query, phraseSearch, extractors) + } + } + + override fun compare(o1: T, o2: T): Int { + val o1Scores = extractors.sumOf { it(o1).getMatchingScore() } + val o2Scores = extractors.sumOf { it(o2).getMatchingScore() } + return o1Scores.compareTo(o2Scores) + } + + private fun String?.getMatchingScore(): Int { + if (this == null) return 0 + + var score = 0 + if (this == query) { + score = 1000 + } else if (query in this) { + score = 1 + } else if (phraseSearch != null && phraseSearch.isPhraseQuery()) { + query.getSeparatedMatchingScore() + } + + return score + } + + private fun String.getSeparatedMatchingScore(): Int { + return if (phraseSearch!!.matchedWith(this)) { + 1 + } else { + 0 + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/search/SearchFilter.kt b/common/src/main/java/io/novafoundation/nova/common/utils/search/SearchFilter.kt new file mode 100644 index 0000000..59765f0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/search/SearchFilter.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.utils.search + +class SearchFilter( + private val query: String, + private val phraseSearch: PhraseSearch?, + private val extractors: List<(T) -> String?> +) { + + class Builder(private val query: String, extractor: (T) -> String?) { + + private val extractors = mutableListOf(extractor) + private var phraseSearch: PhraseSearch? = null + + fun addPhraseSearch(phraseSearch: PhraseSearch): Builder { + this.phraseSearch = phraseSearch + return this + } + + fun or(extractor: (T) -> String?): Builder { + extractors += extractor + return this + } + + fun build(): SearchFilter { + return SearchFilter(query, phraseSearch, extractors) + } + } + + fun filter(value: T): Boolean { + return extractors.any { + val extractedValue = it(value) ?: return@any false + + if (phraseSearch != null && phraseSearch.isPhraseQuery()) { + phraseSearch.matchedWith(extractedValue) + } else { + extractedValue.contains(query) + } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/search/SearchUtils.kt b/common/src/main/java/io/novafoundation/nova/common/utils/search/SearchUtils.kt new file mode 100644 index 0000000..2727a63 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/search/SearchUtils.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.utils.search + +fun Iterable.filterWith(searchFilter: SearchFilter): List { + return filter { searchFilter.filter(it) } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/selectionStore/ComputationalCacheSelectionStoreProvider.kt b/common/src/main/java/io/novafoundation/nova/common/utils/selectionStore/ComputationalCacheSelectionStoreProvider.kt new file mode 100644 index 0000000..aff798b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/selectionStore/ComputationalCacheSelectionStoreProvider.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.common.utils.selectionStore + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import kotlinx.coroutines.CoroutineScope + +abstract class ComputationalCacheSelectionStoreProvider>( + private val computationalCache: ComputationalCache, + private val key: String +) : SelectionStoreProvider { + + override suspend fun getSelectionStore(scope: CoroutineScope): T { + return computationalCache.useCache(key, scope) { + initSelectionStore() + } + } + + protected abstract fun initSelectionStore(): T +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/selectionStore/MutableSelectionStore.kt b/common/src/main/java/io/novafoundation/nova/common/utils/selectionStore/MutableSelectionStore.kt new file mode 100644 index 0000000..64df841 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/selectionStore/MutableSelectionStore.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils.selectionStore + +import kotlinx.coroutines.flow.MutableStateFlow + +abstract class MutableSelectionStore : SelectionStore { + + override val currentSelectionFlow = MutableStateFlow(null) + + override fun getCurrentSelection(): T? { + return currentSelectionFlow.value + } + + fun updateSelection(selection: T) { + currentSelectionFlow.value = selection + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/selectionStore/SelectionStore.kt b/common/src/main/java/io/novafoundation/nova/common/utils/selectionStore/SelectionStore.kt new file mode 100644 index 0000000..b0fe8cb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/selectionStore/SelectionStore.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.common.utils.selectionStore + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface SelectionStore { + + val currentSelectionFlow: Flow + + fun getCurrentSelection(): T? +} + +interface SelectionStoreProvider> { + + suspend fun getSelectionStore(scope: CoroutineScope): T +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/sequrity/AutomaticInteractionGate.kt b/common/src/main/java/io/novafoundation/nova/common/utils/sequrity/AutomaticInteractionGate.kt new file mode 100644 index 0000000..03365cd --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/sequrity/AutomaticInteractionGate.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.common.utils.sequrity + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn + +interface AutomaticInteractionGate { + + val isInteractionAllowedFlow: Flow + + fun initialPinPassed() + + fun wentToBackground() + + fun foregroundCheckPassed() +} + +@Suppress("SimplifyBooleanWithConstants") +suspend fun AutomaticInteractionGate.awaitInteractionAllowed() { + isInteractionAllowedFlow.first { allowed -> allowed == true } +} + +internal class RealAutomaticInteractionGate : AutomaticInteractionGate, CoroutineScope by CoroutineScope(Dispatchers.Default) { + + private val initialPinPassed = MutableStateFlow(false) + private val backgroundCheckPassed = MutableStateFlow(false) + + override val isInteractionAllowedFlow = combine(initialPinPassed, backgroundCheckPassed) { initialCheck, backgroundCheck -> + initialCheck && backgroundCheck + } + .stateIn(this, SharingStarted.Eagerly, initialValue = false) + + override fun initialPinPassed() { + initialPinPassed.value = true + } + + override fun wentToBackground() { + backgroundCheckPassed.value = false + } + + override fun foregroundCheckPassed() { + backgroundCheckPassed.value = true + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/sequrity/BackgroundAccessObserver.kt b/common/src/main/java/io/novafoundation/nova/common/utils/sequrity/BackgroundAccessObserver.kt new file mode 100644 index 0000000..7782c02 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/sequrity/BackgroundAccessObserver.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.common.utils.sequrity + +import android.os.SystemClock +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import io.novafoundation.nova.common.data.storage.Preferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext + +class BackgroundAccessObserver( + private val preferences: Preferences, + private val automaticInteractionGate: AutomaticInteractionGate, + private val accessTimeInBackground: Long = DEFAULT_ACCESS_TIME, +) : DefaultLifecycleObserver, CoroutineScope { + + companion object { + val DEFAULT_ACCESS_TIME = TimeUnit.MINUTES.toMillis(5L) + + private const val PREFS_ON_PAUSE_TIME = "ON_PAUSE_TIME" + } + + enum class State { + REQUEST_ACCESS, NOTHING + } + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + + private var currentState = State.NOTHING + + private val _stateFlow = MutableSharedFlow() + + val requestAccessFlow: Flow = _stateFlow + .filter { it == State.REQUEST_ACCESS } + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + fun checkPassed() { + changeState(State.NOTHING) + preferences.removeField(PREFS_ON_PAUSE_TIME) + automaticInteractionGate.foregroundCheckPassed() + } + + override fun onCreate(owner: LifecycleOwner) { + preferences.removeField(PREFS_ON_PAUSE_TIME) + } + + override fun onStop(owner: LifecycleOwner) { + val elapsedTime = SystemClock.elapsedRealtime() + preferences.putLong(PREFS_ON_PAUSE_TIME, elapsedTime) + automaticInteractionGate.wentToBackground() + } + + override fun onStart(owner: LifecycleOwner) { + val elapsedTime = SystemClock.elapsedRealtime() + val onPauseTime = preferences.getLong(PREFS_ON_PAUSE_TIME, -1) + val difference = elapsedTime - onPauseTime + if (onPauseTime >= 0 && difference > accessTimeInBackground) { + changeState(State.REQUEST_ACCESS) + } else { + automaticInteractionGate.foregroundCheckPassed() + } + } + + private fun changeState(state: State) { + launch { + currentState = state + _stateFlow.emit(currentState) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/share/ShareUtils.kt b/common/src/main/java/io/novafoundation/nova/common/utils/share/ShareUtils.kt new file mode 100644 index 0000000..324ba96 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/share/ShareUtils.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.common.utils.share + +import android.content.Intent +import android.net.Uri +import io.novafoundation.nova.common.base.BaseFragment + +data class ImageWithTextSharing( + val fileUri: Uri, + val shareMessage: String +) + +fun BaseFragment<*, *>.shareImageWithText(sharingData: ImageWithTextSharing, chooserTitle: String?) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "image/*" + putExtra(Intent.EXTRA_STREAM, sharingData.fileUri) + putExtra(Intent.EXTRA_TEXT, sharingData.shareMessage) + } + + startActivity(Intent.createChooser(intent, chooserTitle)) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/spannable/LineHeightDrawableSpan.kt b/common/src/main/java/io/novafoundation/nova/common/utils/spannable/LineHeightDrawableSpan.kt new file mode 100644 index 0000000..2dc1e43 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/spannable/LineHeightDrawableSpan.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.common.utils.spannable + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.drawable.Drawable +import android.text.style.ReplacementSpan +import androidx.core.graphics.drawable.updateBounds +import kotlin.math.roundToInt + +/** + * Extends drawable height to line height without keeping aspect ratio + */ +class LineHeightDrawableSpan( + private val drawable: Drawable +) : ReplacementSpan() { + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + outputFontMetrics: Paint.FontMetricsInt? + ): Int { + val paintFontMetrics = paint.fontMetricsInt + outputFontMetrics?.apply { + ascent = paintFontMetrics.ascent + descent = paintFontMetrics.descent + top = paintFontMetrics.top + bottom = paintFontMetrics.bottom + } + + return drawable.intrinsicWidth + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val lineHeight = (bottom - top).coerceAtLeast(1) + val dH = drawable.intrinsicHeight.coerceAtLeast(1) + + val scale = lineHeight.toFloat() / dH + val drawH = (dH * scale).roundToInt() + + drawable.updateBounds(bottom = drawH) + + val save = canvas.save() + val transY = bottom - drawH + canvas.translate(x, transY.toFloat()) + drawable.draw(canvas) + canvas.restoreToCount(save) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/splash/SplashPassedObserver.kt b/common/src/main/java/io/novafoundation/nova/common/utils/splash/SplashPassedObserver.kt new file mode 100644 index 0000000..bbdeb38 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/splash/SplashPassedObserver.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.common.utils.splash + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first + +interface SplashPassedObserver { + + val isSplashPassed: Flow + + fun setSplashPassed() +} + +suspend fun SplashPassedObserver.awaitSplashPassed() { + isSplashPassed.first { allowed -> allowed == true } +} + +internal class RealSplashPassedObserver : SplashPassedObserver, CoroutineScope by CoroutineScope(Dispatchers.Default) { + + override val isSplashPassed = MutableStateFlow(false) + + override fun setSplashPassed() { + isSplashPassed.value = true + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/stateMachine/StateMachine.kt b/common/src/main/java/io/novafoundation/nova/common/utils/stateMachine/StateMachine.kt new file mode 100644 index 0000000..33d4caf --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/stateMachine/StateMachine.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.common.utils.stateMachine + +import android.util.Log +import io.novafoundation.nova.common.utils.awaitTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface StateMachine, SIDE_EFFECT, EVENT> { + + val state: StateFlow + + val sideEffects: ReceiveChannel + + fun onEvent(event: EVENT) + + interface State, SIDE_EFFECT, EVENT> { + + context(Transition) + suspend fun performTransition(event: EVENT) + + /** + * Called when this state is state-machine's initial state + */ + context(Transition) + suspend fun bootstrap() {} + } + + interface Transition, SIDE_EFFECT> { + + suspend fun emitState(newState: STATE) + + suspend fun emitSideEffect(sideEffect: SIDE_EFFECT) + } +} + +fun , SIDE_EFFECT, EVENT> StateMachine( + initialState: STATE, + coroutineScope: CoroutineScope +): StateMachine = StateMachineImpl(initialState, coroutineScope) + +private class StateMachineImpl, SIDE_EFFECT, EVENT>( + private val initialState: STATE, + coroutineScope: CoroutineScope +) : StateMachine, CoroutineScope by coroutineScope { + + private val mutex = Mutex() + + override val state = MutableStateFlow(initialState) + + override val sideEffects = Channel(capacity = Channel.UNLIMITED) + + private val bootstrapped = MutableStateFlow(false) + + init { + bootstrap() + } + + override fun onEvent(event: EVENT) { + Log.d("StateMachineTAG", "onEvent: $event") + launch { + bootstrapped.awaitTrue() + + mutex.withLock { + with(TransitionImpl()) { + state.value.performTransition(event) + } + } + } + } + + private fun bootstrap() { + launch { + Log.d("StateMachineTAG", "initial state: $initialState") + Log.d("StateMachineTAG", "bootstrap started") + mutex.withLock { + with(TransitionImpl()) { + state.value.bootstrap() + } + } + bootstrapped.value = true + Log.d("StateMachineTAG", "bootstrap finished") + } + } + + private inner class TransitionImpl : StateMachine.Transition { + + override suspend fun emitState(newState: STATE) { + Log.d("StateMachineTAG", "state: $newState") + state.value = newState + } + + override suspend fun emitSideEffect(sideEffect: SIDE_EFFECT) { + Log.d("StateMachineTAG", "sideEffect: $sideEffect") + sideEffects.send(sideEffect) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/EnableBluetoothSystemCall.kt b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/EnableBluetoothSystemCall.kt new file mode 100644 index 0000000..7b469b6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/EnableBluetoothSystemCall.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.common.utils.systemCall + +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity + +private const val REQUEST_CODE = 733 + +class EnableBluetoothSystemCall : SystemCall { + + override fun createRequest(activity: AppCompatActivity): SystemCall.Request { + val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + + return SystemCall.Request( + intent = intent, + requestCode = REQUEST_CODE + ) + } + + override fun parseResult(requestCode: Int, resultCode: Int, intent: Intent?): Result { + return when { + resultCode == Activity.RESULT_OK -> Result.success(true) + else -> Result.success(false) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/ScanQrCodeCall.kt b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/ScanQrCodeCall.kt new file mode 100644 index 0000000..58702c2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/ScanQrCodeCall.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.utils.systemCall + +import android.app.Activity +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import com.google.zxing.integration.android.IntentIntegrator + +class ScanQrCodeCall : SystemCall { + + override fun createRequest(activity: AppCompatActivity): SystemCall.Request { + val integrator = IntentIntegrator(activity).apply { + setDesiredBarcodeFormats(IntentIntegrator.QR_CODE_TYPES) + setPrompt("") + setBeepEnabled(false) + } + + return SystemCall.Request( + intent = integrator.createScanIntent(), + requestCode = IntentIntegrator.REQUEST_CODE + ) + } + + override fun parseResult(requestCode: Int, resultCode: Int, intent: Intent?): Result { + val qrContent = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent)?.contents + + return when { + resultCode == Activity.RESULT_CANCELED -> Result.failure(SystemCall.Failure.Cancelled()) + qrContent == null -> Result.failure(SystemCall.Failure.Unknown()) + else -> Result.success(qrContent) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/SystemCall.kt b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/SystemCall.kt new file mode 100644 index 0000000..9560d69 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/SystemCall.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.common.utils.systemCall + +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity + +interface SystemCall { + + sealed class Failure : Throwable() { + + class Unknown : Failure() + + class Cancelled : Failure() + } + + class Request( + val intent: Intent, + val requestCode: Int, + ) + + fun createRequest(activity: AppCompatActivity): Request + + fun parseResult(requestCode: Int, resultCode: Int, intent: Intent?): Result +} + +inline fun Result.onSystemCallFailure(onFailure: (failure: Throwable) -> Unit) { + onFailure { + if (it !is SystemCall.Failure.Cancelled) { + onFailure(it) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/SystemCallExecutor.kt b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/SystemCallExecutor.kt new file mode 100644 index 0000000..d80cb76 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/SystemCallExecutor.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.common.utils.systemCall + +import android.content.Intent +import io.novafoundation.nova.common.resources.ContextManager +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class SystemCallExecutor( + private val contextManager: ContextManager +) { + + private class PendingRequest( + val callback: (Result) -> Unit, + val systemCall: SystemCall + ) + + private val ongoingRequests = ConcurrentHashMap>() + + @Suppress("UNCHECKED_CAST") // type-safety is guaranteed by PendingRequest + suspend fun executeSystemCall(systemCall: SystemCall) = suspendCoroutine> { continuation -> + try { + val request = handleRequest(systemCall) + + ongoingRequests[request.requestCode] = PendingRequest( + callback = { continuation.resume(it as Result) }, + systemCall = systemCall as SystemCall + ) + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + + @Suppress("UNCHECKED_CAST") // type-safety is guaranteed by PendingRequest + fun executeSystemCallNotBlocking(systemCall: SystemCall, onResult: (Result) -> Unit = {}): Boolean { + try { + val request = handleRequest(systemCall) + + ongoingRequests[request.requestCode] = PendingRequest( + callback = onResult as (Result) -> Unit, + systemCall = systemCall as SystemCall + ) + return true + } catch (e: Exception) { + return false + } + } + + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + val removed = ongoingRequests.remove(requestCode)?.let { systemCallRequest -> + val parsedResult = systemCallRequest.systemCall.parseResult(requestCode, resultCode, data) + + systemCallRequest.callback(parsedResult) + } + + return removed != null + } + + private fun handleRequest(systemCall: SystemCall): SystemCall.Request { + val activity = contextManager.getActivity()!! + val request = systemCall.createRequest(activity) + activity.startActivityForResult(request.intent, request.requestCode) + return request + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/WebViewFilePickerSystemCall.kt b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/WebViewFilePickerSystemCall.kt new file mode 100644 index 0000000..f34b503 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/systemCall/WebViewFilePickerSystemCall.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.common.utils.systemCall + +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.net.Uri +import android.webkit.WebChromeClient +import androidx.appcompat.app.AppCompatActivity + +class WebViewFilePickerSystemCallFactory { + fun create(fileChooserParams: WebChromeClient.FileChooserParams?): WebViewFilePickerSystemCall { + return WebViewFilePickerSystemCall(fileChooserParams) + } +} + +class WebViewFilePickerSystemCall( + private val fileChooserParams: WebChromeClient.FileChooserParams? +) : SystemCall { + + companion object { + + private const val REQUEST_CODE = 301 + } + + override fun createRequest(activity: AppCompatActivity): SystemCall.Request { + val chooserIntent = if (fileChooserParams == null) { + val selectionIntent = Intent(Intent.ACTION_GET_CONTENT) + selectionIntent.addCategory(Intent.CATEGORY_OPENABLE) + selectionIntent.type = "*/*" + Intent.createChooser(selectionIntent, null) + } else { + fileChooserParams.createIntent() + } + + return SystemCall.Request(chooserIntent, REQUEST_CODE) + } + + override fun parseResult(requestCode: Int, resultCode: Int, intent: Intent?): Result { + if (resultCode != RESULT_OK) return Result.failure(UnsupportedOperationException()) + + val data = intent?.data + + return if (data != null) { + Result.success(data) + } else { + Result.failure(UnsupportedOperationException()) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/textwatchers/NonTranslucentEmojisTextWatcher.kt b/common/src/main/java/io/novafoundation/nova/common/utils/textwatchers/NonTranslucentEmojisTextWatcher.kt new file mode 100644 index 0000000..0cb9774 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/textwatchers/NonTranslucentEmojisTextWatcher.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.common.utils.textwatchers + +import android.graphics.Color +import android.text.Editable +import android.text.Spanned +import android.text.TextWatcher +import android.text.style.ForegroundColorSpan + +class NonTranslucentEmojisTextWatcher : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(editable: Editable?) { + if (editable == null) return + + handleEmojis(editable.toString()) { + editable.setSpan(ForegroundColorSpan(Color.WHITE), it.first, it.second + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + + private fun handleEmojis(string: String, handler: (Pair) -> Unit) { + for (i in string.indices) { + val char = string[i] + if (Character.isSurrogate(char)) { + if (i + 1 < string.length) { + val nextCharIndex = i + 1 + val nextChar = string[nextCharIndex] + if (Character.isSurrogatePair(char, nextChar)) { + handler(i to nextCharIndex) + } + } + } else if (isEmoji(char)) { + handler(i to i) + } + } + } + + private fun isEmoji(c: Char): Boolean { + return c.code in 0x2600..0x27BF || + c.code in 0x1F300..0x1F5FF || + c.code in 0x1F600..0x1F64F || + c.code in 0x1F680..0x1F6FF || + c.code in 0x1F900..0x1F9FF + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/webView/BaseWebChromeClient.kt b/common/src/main/java/io/novafoundation/nova/common/utils/webView/BaseWebChromeClient.kt new file mode 100644 index 0000000..6cc7822 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/webView/BaseWebChromeClient.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.utils.webView + +import android.net.Uri +import android.webkit.PermissionRequest +import android.webkit.ValueCallback +import android.webkit.WebChromeClient +import android.webkit.WebView +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooser +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAsker +import kotlinx.coroutines.CoroutineScope + +class BaseWebChromeClientFactory( + private val permissionsAsker: WebViewPermissionAsker, + private val webViewFileChooser: WebViewFileChooser +) { + fun create(coroutineScope: CoroutineScope) = BaseWebChromeClient( + permissionsAsker = permissionsAsker, + webViewFileChooser = webViewFileChooser, + coroutineScope = coroutineScope + ) +} + +open class BaseWebChromeClient( + private val permissionsAsker: WebViewPermissionAsker, + private val webViewFileChooser: WebViewFileChooser, + private val coroutineScope: CoroutineScope +) : WebChromeClient() { + + override fun onPermissionRequest(request: PermissionRequest) { + permissionsAsker.requestPermission(coroutineScope, request) + } + + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams + ): Boolean { + webViewFileChooser.onShowFileChooser(filePathCallback, fileChooserParams) + + return true + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/webView/InterceptingWebViewClient.kt b/common/src/main/java/io/novafoundation/nova/common/utils/webView/InterceptingWebViewClient.kt new file mode 100644 index 0000000..0b89608 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/webView/InterceptingWebViewClient.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.utils.webView + +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient + +class InterceptingWebViewClientFactory { + fun create(interceptors: List) = InterceptingWebViewClient(interceptors) +} + +class InterceptingWebViewClient(private val interceptors: List) : WebViewClient() { + + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { + interceptors.firstOrNull { it.intercept(request) } + + return super.shouldInterceptRequest(view, request) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/webView/WebViewRequestInterceptor.kt b/common/src/main/java/io/novafoundation/nova/common/utils/webView/WebViewRequestInterceptor.kt new file mode 100644 index 0000000..900f391 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/webView/WebViewRequestInterceptor.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.common.utils.webView + +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +interface WebViewRequestInterceptor { + + /** + * @return Intercept a request and return WebResourceResponse if the true was intercepted otherwise false + */ + fun intercept(request: WebResourceRequest): Boolean +} + +fun OkHttpClient.makeRequestBlocking(requestBuilder: Request.Builder): Response { + val okHttpResponse = this.newCall(requestBuilder.build()).execute() + + if (okHttpResponse.isSuccessful) { + return okHttpResponse + } + + throw RuntimeException("Request failed with ${okHttpResponse.code} code: ${okHttpResponse.networkResponse?.body}") +} + +fun WebResourceRequest.toOkHttpRequestBuilder(): Request.Builder { + val url = url.toString() + val okHttpRequestBuilder = Request.Builder().url(url) + + okHttpRequestBuilder.get() + + for ((key, value) in requestHeaders) { + okHttpRequestBuilder.addHeader(key, value) + } + + val cookieManager = CookieManager.getInstance() + val cookies = cookieManager.getCookie(url) + if (cookies != null) { + okHttpRequestBuilder.addHeader("Cookie", cookies) + } + + return okHttpRequestBuilder +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/webView/interceptors/CompoundWebViewRequestInterceptor.kt b/common/src/main/java/io/novafoundation/nova/common/utils/webView/interceptors/CompoundWebViewRequestInterceptor.kt new file mode 100644 index 0000000..d6ee299 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/webView/interceptors/CompoundWebViewRequestInterceptor.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.common.utils.webView.interceptors + +import android.webkit.WebResourceRequest +import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor + +class CompoundWebViewRequestInterceptor( + private val interceptors: List +) : WebViewRequestInterceptor { + + constructor(vararg interceptors: WebViewRequestInterceptor) : this(interceptors.toList()) + + override fun intercept(request: WebResourceRequest): Boolean { + return interceptors.any { it.intercept(request) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/validation/Builder.kt b/common/src/main/java/io/novafoundation/nova/common/validation/Builder.kt new file mode 100644 index 0000000..3535740 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/validation/Builder.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.common.validation + +interface ValidationSystemBuilder { + + fun validate(validation: Validation) + + fun build(): ValidationSystem +} + +private class Builder : ValidationSystemBuilder { + + private val validations = mutableListOf>() + + override fun validate(validation: Validation) { + validations += validation + } + + override fun build(): ValidationSystem { + return ValidationSystem.from(validations) + } +} + +fun ValidationSystem(builderBlock: ValidationSystemBuilder.() -> Unit): ValidationSystem { + val builder = Builder() + + builder.builderBlock() + + return builder.build() +} + +fun EmptyValidationSystem(): ValidationSystem = ValidationSystem.from(emptyList()) diff --git a/common/src/main/java/io/novafoundation/nova/common/validation/FieldValidator.kt b/common/src/main/java/io/novafoundation/nova/common/validation/FieldValidator.kt new file mode 100644 index 0000000..3c5f221 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/validation/FieldValidator.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.common.validation + +import io.novafoundation.nova.common.utils.combine +import io.novafoundation.nova.common.view.ValidatableInputField +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +interface FieldValidator { + + fun observe(inputStream: Flow): Flow + + companion object +} + +abstract class MapFieldValidator : FieldValidator { + + abstract suspend fun validate(input: String): FieldValidationResult + + override fun observe(inputStream: Flow) = inputStream.map(::validate) +} + +class CompoundFieldValidator( + private val validators: List +) : FieldValidator { + + constructor(vararg validators: FieldValidator) : this(validators.toList()) + + override fun observe(inputStream: Flow): Flow { + return validators.map { it.observe(inputStream) } + .combine() + .map { + it.firstOrNull { it is FieldValidationResult.Error } + ?: FieldValidationResult.Ok + } + } +} + +sealed class FieldValidationResult { + + object Ok : FieldValidationResult() + + class Error( + /** + * User-friendly error message to be displayed in UI + */ + val reason: String, + + /** + * The optional tag that other components may use to determine which validator originated this error + */ + val tag: String? = null + ) : FieldValidationResult() +} + +fun FieldValidationResult.getReasonOrNull(): String? { + return when (this) { + is FieldValidationResult.Error -> reason + else -> null + } +} + +fun FieldValidationResult.isErrorWithTag(tag: String): Boolean { + return this is FieldValidationResult.Error && this.tag == tag +} + +fun ValidatableInputField.observeErrors( + flow: Flow, + scope: CoroutineScope, +) { + scope.launch { + flow.collect { validationResult -> + when (validationResult) { + is FieldValidationResult.Ok -> { + hideError() + } + + is FieldValidationResult.Error -> { + showError(validationResult.reason) + } + } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/validation/Validation.kt b/common/src/main/java/io/novafoundation/nova/common/validation/Validation.kt new file mode 100644 index 0000000..99573ac --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/validation/Validation.kt @@ -0,0 +1,165 @@ +package io.novafoundation.nova.common.validation + +import io.novafoundation.nova.common.utils.requireException +import io.novafoundation.nova.common.utils.requireValue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface Validation { + + suspend fun validate(value: T): ValidationStatus +} + +fun validOrWarning( + condition: Boolean, + lazyReason: () -> S, +): ValidationStatus = if (condition) { + ValidationStatus.Valid() +} else { + ValidationStatus.NotValid(DefaultFailureLevel.WARNING, lazyReason()) +} + +inline fun validOrError( + isValid: Boolean, + lazyReason: () -> S, +): ValidationStatus = if (isValid) { + ValidationStatus.Valid() +} else { + ValidationStatus.NotValid(DefaultFailureLevel.ERROR, lazyReason()) +} + +fun validationError(reason: S) = ValidationStatus.NotValid(DefaultFailureLevel.ERROR, reason) +fun validationWarning(reason: S) = ValidationStatus.NotValid(DefaultFailureLevel.WARNING, reason) +fun valid() = ValidationStatus.Valid() + +inline infix fun Boolean.isTrueOrError(error: () -> E) = validOrError(this, error) +inline infix fun Boolean.isFalseOrError(error: () -> E) = this.not().isTrueOrError(error) + +infix fun Boolean.isTrueOrWarning(warning: () -> E) = validOrWarning(this, warning) +infix fun Boolean.isFalseOrWarning(warning: () -> E) = this.not().isTrueOrWarning(warning) + +sealed class ValidationStatus { + + class Valid : ValidationStatus() + + class NotValid(val level: Level, val reason: S) : ValidationStatus() { + + interface Level { + val value: Int + + operator fun compareTo(other: Level): Int = value - other.value + } + } +} + +@JvmName("validationErrorReceiver") +fun T.validationError(): ValidationStatus.NotValid = ValidationStatus.NotValid(DefaultFailureLevel.ERROR, this) + +@JvmName("validationWarningReceiver") +fun T.validationWarning(): ValidationStatus.NotValid = ValidationStatus.NotValid(DefaultFailureLevel.WARNING, this) + +fun ValidationStatus.NotValid<*>.isError() = this.level == DefaultFailureLevel.ERROR + +fun ValidationStatus.NotValid<*>.isWarning() = this.level == DefaultFailureLevel.WARNING + +fun ValidationStatus.notValidOrNull(): ValidationStatus.NotValid? { + if (this is ValidationStatus.NotValid) { + return this + } + + return null +} + +enum class DefaultFailureLevel(override val value: Int) : ValidationStatus.NotValid.Level { + WARNING(1), ERROR(2) +} + +class CompositeValidation( + val validations: Collection>, +) : Validation { + + /** + * Finds the most serious failure across supplied validations + * If exception occurred during any validation it will be ignored if there is any validation that reported [ValidationStatus.NotValid] state + * If all validations either failed to complete or were valid, then the first exception will be rethrown + * + * That is we achieve the following behavior: + * User does not see exception until he really has to + */ + override suspend fun validate(value: T): ValidationStatus { + val validationStatuses = validations.map { runCatching { it.validate(value) } } + + val failureStatuses = validationStatuses.filter { it.isSuccess } // Result.isSuccess -> validation completed w/o exception + .map { it.requireValue() } + .filterIsInstance>() + + val mostSeriousReason = failureStatuses.maxByOrNull { it.level.value } + + return if (mostSeriousReason != null) { // there is at least one NotValid validation + mostSeriousReason + } else { + val firstFailure = validationStatuses.firstOrNull { it.isFailure } + + // rethrow exception if any + firstFailure?.let { throw it.requireException() } + + ValidationStatus.Valid() + } + } +} + +class ValidationSystem( + private val validation: Validation +) { + + companion object; + + suspend fun validate( + value: T, + ignoreUntil: ValidationStatus.NotValid.Level? = null + ): Result> = runCatching { + withContext(Dispatchers.Default) { + when (val status = validation.validate(value)) { + is ValidationStatus.Valid -> status + + is ValidationStatus.NotValid -> { + if (ignoreUntil != null && status.level.value <= ignoreUntil.value) { + ValidationStatus.Valid() + } else { + status + } + } + } + } + } + + fun copyTo(validationSystemBuilder: ValidationSystemBuilder) { + validationSystemBuilder.validate(validation) + } +} + +context (ValidationSystemBuilder) +fun ValidationSystem.copyIntoCurrent() = copyTo(this@ValidationSystemBuilder) + +fun ValidationSystem.Companion.from(validations: Collection>): ValidationSystem { + return ValidationSystem(CompositeValidation(validations)) +} + +suspend fun ValidationSystem.validate( + ignoreUntil: ValidationStatus.NotValid.Level? = null +) = validate(Unit, ignoreUntil) + +fun Result>.unwrap( + onValid: () -> Unit, + onInvalid: (ValidationStatus.NotValid) -> Unit, + onFailure: (Throwable) -> Unit +) { + if (isSuccess) { + when (val status = getOrThrow()) { + is ValidationStatus.Valid<*> -> onValid() + is ValidationStatus.NotValid -> onInvalid(status) + } + } else { + onFailure(requireException()) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/validation/ValidationExecutor.kt b/common/src/main/java/io/novafoundation/nova/common/validation/ValidationExecutor.kt new file mode 100644 index 0000000..68f2d9b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/validation/ValidationExecutor.kt @@ -0,0 +1,154 @@ +package io.novafoundation.nova.common.validation + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.api.ValidationFailureUi +import io.novafoundation.nova.common.utils.Event +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +typealias ProgressConsumer = (Boolean) -> Unit + +fun MutableLiveData.progressConsumer(): ProgressConsumer = { value = it } + +fun MutableStateFlow.progressConsumer(): ProgressConsumer = { value = it } + +sealed class TransformedFailure { + + class Default(val titleAndMessage: TitleAndMessage) : TransformedFailure() + + class Custom(val dialogPayload: CustomDialogDisplayer.Payload) : TransformedFailure() +} + +interface ValidationFlowActions

{ + + fun resumeFlow(modifyPayload: ((P) -> P)? = null) + + fun revalidate(modifyPayload: ((P) -> P)? = null) +} + +class ValidationExecutor : Validatable { + + suspend fun requireValid( + validationSystem: ValidationSystem, + payload: P, + errorDisplayer: (Throwable) -> Unit, + validationFailureTransformerCustom: (ValidationStatus.NotValid, ValidationFlowActions

) -> TransformedFailure?, + progressConsumer: ProgressConsumer? = null, + autoFixPayload: (original: P, failureStatus: S) -> P = { original, _ -> original }, + scope: CoroutineScope, + block: (P) -> Unit, + ) { + progressConsumer?.invoke(true) + + validationSystem.validate(payload) + .unwrap( + onValid = { block(payload) }, + onFailure = { + progressConsumer?.invoke(false) + + errorDisplayer(it) + }, + onInvalid = { + progressConsumer?.invoke(false) + + val validationFlowActions = createFlowActions( + payload = payload, + autoFixPayload = autoFixPayload, + notValidStatus = it, + progressConsumer = progressConsumer, + revalidate = { newPayload -> + scope.launch { + requireValid( + validationSystem = validationSystem, + payload = newPayload, + errorDisplayer = errorDisplayer, + validationFailureTransformerCustom = validationFailureTransformerCustom, + progressConsumer = progressConsumer, + autoFixPayload = autoFixPayload, + block = block, + scope = scope + ) + } + }, + successBlock = block + ) + + val eventPayload = when (val transformedFailure = validationFailureTransformerCustom(it, validationFlowActions)) { + is TransformedFailure.Custom -> ValidationFailureUi.Custom(transformedFailure.dialogPayload) + + is TransformedFailure.Default -> { + val (title, message) = transformedFailure.titleAndMessage + + ValidationFailureUi.Default( + level = it.level, + title = title, + message = message, + confirmWarning = validationFlowActions::resumeFlow + ) + } + + null -> null + } + + eventPayload?.let { + validationFailureEvent.value = Event(eventPayload) + } + } + ) + } + + private fun createFlowActions( + payload: P, + progressConsumer: ProgressConsumer?, + autoFixPayload: (original: P, failureStatus: S) -> P, + notValidStatus: ValidationStatus.NotValid, + revalidate: (newPayload: P) -> Unit, + successBlock: (newPayload: P) -> Unit, + ) = object : ValidationFlowActions

{ + + override fun resumeFlow(modifyPayload: ((P) -> P)?) { + progressConsumer?.invoke(true) + successBlock(transformPayload(modifyPayload)) + } + + override fun revalidate(modifyPayload: ((P) -> P)?) { + progressConsumer?.invoke(true) + revalidate(transformPayload(modifyPayload)) + } + + private fun transformPayload(modifyPayload: ((P) -> P)?): P { + val payloadToAutoFix = modifyPayload?.invoke(payload) ?: payload + + // we do not remove autoFixPayload functionality for backward compatibility, with passing `modifiedPayload` becoming the preferred way + return autoFixPayload(payloadToAutoFix, notValidStatus.reason) + } + } + + suspend fun requireValid( + validationSystem: ValidationSystem, + payload: P, + errorDisplayer: (Throwable) -> Unit, + validationFailureTransformerDefault: (S) -> TitleAndMessage, + autoFixPayload: (original: P, failureStatus: S) -> P = { original, _ -> original }, + progressConsumer: ProgressConsumer? = null, + scope: CoroutineScope, + block: (P) -> Unit, + ) = requireValid( + validationSystem = validationSystem, + payload = payload, + errorDisplayer = errorDisplayer, + validationFailureTransformerCustom = { it, _ -> TransformedFailure.Default(validationFailureTransformerDefault(it.reason)) }, + progressConsumer = progressConsumer, + autoFixPayload = autoFixPayload, + block = block, + scope = scope + ) + + override val validationFailureEvent = MutableLiveData>() +} + +fun TitleAndMessage.asDefault() = TransformedFailure.Default(this) diff --git a/common/src/main/java/io/novafoundation/nova/common/vibration/DeviceVibrator.kt b/common/src/main/java/io/novafoundation/nova/common/vibration/DeviceVibrator.kt new file mode 100644 index 0000000..d0ad42b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/vibration/DeviceVibrator.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.vibration + +import android.content.Context +import io.novafoundation.nova.common.utils.vibrate + +class DeviceVibrator( + private val context: Context +) { + + companion object { + private const val SHORT_VIBRATION_DURATION = 200L + } + + fun makeShortVibration() { + context.vibrate(SHORT_VIBRATION_DURATION) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/AccentActionView.kt b/common/src/main/java/io/novafoundation/nova/common/view/AccentActionView.kt new file mode 100644 index 0000000..5968c92 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/AccentActionView.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewAccentActionBinding +import io.novafoundation.nova.common.utils.inflater + +class AccentActionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binder = ViewAccentActionBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + + setBackgroundResource(R.drawable.bg_primary_list_item) + } + + fun setText(@StringRes textRes: Int) { + binder.accentActionText.setText(textRes) + } + + fun setIcon(@DrawableRes iconRes: Int) { + binder.accentActionIcon.setImageResource(iconRes) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/AccountInfoView.kt b/common/src/main/java/io/novafoundation/nova/common/view/AccountInfoView.kt new file mode 100644 index 0000000..4c67a8b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/AccountInfoView.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import androidx.annotation.ColorInt +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewAccountInfoBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.getColorOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setImageTint + +class AccountInfoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewAccountInfoBinding.inflate(inflater(), this) + + init { + background = getRoundedCornerDrawable(fillColorRes = R.color.block_background).withRippleMask() + + isFocusable = true + isClickable = true + + applyAttributes(attrs) + } + + private fun applyAttributes(attrs: AttributeSet?) { + attrs?.let { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.AccountInfoView) + + val actionIcon = typedArray.getDrawable(R.styleable.AccountInfoView_accountActionIcon) + actionIcon?.let(::setActionIcon) + + val actionIconTint = typedArray.getColorOrNull(R.styleable.AccountInfoView_accountActionIconTint) + setActionIconTint(actionIconTint) + + val textVisible = typedArray.getBoolean(R.styleable.AccountInfoView_textVisible, true) + binder.accountAddressText.visibility = if (textVisible) View.VISIBLE else View.GONE + + typedArray.recycle() + } + } + + fun setActionIcon(icon: Drawable) { + binder.accountAction.setImageDrawable(icon) + } + + fun setActionIconTint(@ColorInt color: Int?) { + binder.accountAction.setImageTint(color) + } + + fun setActionListener(clickListener: (View) -> Unit) { + binder.accountAction.setOnClickListener(clickListener) + } + + fun setWholeClickListener(listener: (View) -> Unit) { + setOnClickListener(listener) + + setActionListener(listener) + } + + fun setTitle(accountName: String) { + binder.accountTitle.text = accountName + } + + fun setText(address: String) { + binder.accountAddressText.text = address + } + + fun setAccountIcon(icon: Drawable) { + binder.accountIcon.setImageDrawable(icon) + } + + fun hideBody() { + binder.accountAddressText.makeGone() + } + + fun showBody() { + binder.accountAddressText.makeVisible() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/AddressView.kt b/common/src/main/java/io/novafoundation/nova/common/view/AddressView.kt new file mode 100644 index 0000000..89f18d3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/AddressView.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.databinding.ViewAddressBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.removeDrawableEnd +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setTextColorRes + +class AddressView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : FrameLayout(context, attrs, defStyle), WithContextExtensions { + + override val providedContext: Context = context + + private val binder = ViewAddressBinding.inflate(inflater(), this) + + init { + setEndIcon(R.drawable.ic_info) + attrs?.let { applyStyleAttrs(it) } + } + + private fun applyStyleAttrs(attrs: AttributeSet) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.AddressView) + + val textColorRes = typedArray.getResourceId(R.styleable.AddressView_android_textColor, R.color.text_secondary) + binder.addressValue.setTextColorRes(textColorRes) + + typedArray.recycle() + } + + fun setAddress(icon: Drawable, address: String) { + binder.addressImage.setImageDrawable(icon) + binder.addressValue.text = address + } + + fun setEndIcon(@DrawableRes iconRes: Int?) { + if (iconRes == null) { + binder.addressValue.removeDrawableEnd() + } else { + binder.addressValue.setDrawableEnd(iconRes, widthInDp = 16, paddingInDp = 6) + } + } +} + +fun AddressView.setAddressModel(addressModel: AddressModel) { + setAddress(addressModel.image, addressModel.nameOrAddress) +} + +fun AddressView.setAddressOrHide(addressModel: AddressModel?) { + if (addressModel == null) { + makeGone() + return + } + + makeVisible() + + setAddress(addressModel.image, addressModel.nameOrAddress) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/AdvertisementCard.kt b/common/src/main/java/io/novafoundation/nova/common/view/AdvertisementCard.kt new file mode 100644 index 0000000..6ea4286 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/AdvertisementCard.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.annotation.DrawableRes +import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import com.google.android.material.card.MaterialCardView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewAdvertisementCardBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.utils.useAttributes + +class AdvertisementCard @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : MaterialCardView(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewAdvertisementCardBinding.inflate(inflater(), this) + + val action: PrimaryButton + get() = binder.advertisementCardButton + + init { + cardElevation = 0f + radius = 12f.dpF(context) + strokeWidth = 1.dp(context) + strokeColor = context.getColor(R.color.container_border) + + attrs?.let(::applyAttrs) + + updatePadding(bottom = 16.dp) + } + + fun setupAction(lifecycleOwner: LifecycleOwner, onClicked: (View) -> Unit) { + action.prepareForProgress(lifecycleOwner) + action.setOnClickListener(onClicked) + } + + fun setOnLearnMoreClickedListener(onClicked: (View) -> Unit) { + binder.advertisementCardLearnMoreArrow.setOnClickListener(onClicked) + binder.advertisementCardLearnMoreContent.setOnClickListener(onClicked) + } + + fun setOnCloseClickListener(listener: OnClickListener?) { + binder.advertisementCardClose.setOnClickListener(listener) + } + + fun setModel(model: AdvertisementCardModel) { + binder.advertisementCardTitle.text = model.title + binder.advertisementCardSubTitle.text = model.subtitle + binder.advertisementCardImage.setImageResource(model.imageRes) + binder.advertisementCardBackground.setBackgroundResource(model.bannerBackgroundRes) + } + + private fun applyAttrs(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.AdvertisementCard) { + val actionLabel = it.getString(R.styleable.AdvertisementCard_action) + action.setTextOrHide(actionLabel) + + val learnMore = it.getString(R.styleable.AdvertisementCard_learnMore) + binder.advertisementCardLearnMoreGroup.setVisible(learnMore != null) + binder.advertisementCardLearnMoreContent.text = learnMore + + val title = it.getString(R.styleable.AdvertisementCard_title) + binder.advertisementCardTitle.text = title + + val subtitle = it.getString(R.styleable.AdvertisementCard_subtitle) + binder.advertisementCardSubTitle.text = subtitle + + val image = it.getDrawable(R.styleable.AdvertisementCard_image) + binder.advertisementCardImage.setImageDrawable(image) + + val bannerBackground = it.getDrawable(R.styleable.AdvertisementCard_advertisementCardBackground) + binder.advertisementCardBackground.background = bannerBackground + + val showClose = it.getBoolean(R.styleable.AdvertisementCard_showClose, false) + binder.advertisementCardClose.isVisible = showClose + } +} + +class AdvertisementCardModel( + val title: String, + val subtitle: String, + @DrawableRes val imageRes: Int, + @DrawableRes val bannerBackgroundRes: Int, +) diff --git a/common/src/main/java/io/novafoundation/nova/common/view/AlertView.kt b/common/src/main/java/io/novafoundation/nova/common/view/AlertView.kt new file mode 100644 index 0000000..9db9ef0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/AlertView.kt @@ -0,0 +1,201 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.text.method.LinkMovementMethod +import android.util.AttributeSet +import android.view.Gravity +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.updateLayoutParams +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewAlertBinding +import io.novafoundation.nova.common.databinding.ViewAlertMessageBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.getEnum +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.utils.useAttributes + +typealias SimpleAlertModel = String + +class AlertModel( + val style: AlertView.Style, + val message: String, + val subMessages: List, + val linkAction: ActionModel? = null, + val buttonAction: ActionModel? = null +) { + + constructor( + style: AlertView.Style, + message: String, + subMessage: CharSequence? = null, + linkAction: ActionModel? = null, + buttonAction: ActionModel? = null, + ) : this(style, message, subMessages = listOfNotNull(subMessage), linkAction, buttonAction) + + class ActionModel(val text: String, val listener: () -> Unit) +} + +class AlertView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + enum class StylePreset { + WARNING, ERROR, INFO + } + + data class Style( + @DrawableRes val iconRes: Int, + @ColorRes val backgroundColorRes: Int, + @ColorRes val iconTintRes: Int? = null, + val iconGravity: Int = Gravity.TOP + ) { + + companion object { + + fun fromPreset(preset: StylePreset, iconGravity: Int = Gravity.TOP) = when (preset) { + StylePreset.WARNING -> Style(R.drawable.ic_warning_filled, R.color.warning_block_background, iconGravity = iconGravity) + StylePreset.ERROR -> Style(R.drawable.ic_slash, R.color.error_block_background, iconGravity = iconGravity) + StylePreset.INFO -> Style(R.drawable.ic_info_accent, R.color.individual_chip_background, iconGravity = iconGravity) + } + } + } + + private val binder = ViewAlertBinding.inflate(inflater(), this) + + init { + updatePadding(bottom = 10.dp) + + attrs?.let(::applyAttrs) + } + + fun setStyle(style: Style) { + setStyleBackground(style.backgroundColorRes) + setStyleIcon(style.iconRes, style.iconTintRes, style.iconGravity) + } + + fun setStylePreset(preset: StylePreset) { + setStyle(Style.fromPreset(preset)) + } + + fun setMessage(text: String) { + binder.alertMessage.text = text + } + + fun setMessage(@StringRes textRes: Int) { + binder.alertMessage.setText(textRes) + } + + fun setSubMessage(text: CharSequence?) { + setSubMessages(listOfNotNull(text)) + } + + fun setSubMessages(subMessages: List) { + binder.alertSubMessageContainer.removeAllViews() + subMessages.forEach { createSubMessageView(it) } + } + + fun setActionText(actionText: String?) { + binder.alertActionGroup.letOrHide(actionText) { text -> + binder.alertActionContent.text = text + } + } + + fun setOnLinkClickedListener(listener: () -> Unit) { + binder.alertActionContent.setOnClickListener { listener() } + binder.alertActionArrow.setOnClickListener { listener() } + } + + fun setButtonText(text: String) { + binder.alertButton.setTextOrHide(text) + } + + fun setOnButtonClickedListener(listener: () -> Unit) { + binder.alertButton.setOnClickListener { listener() } + } + + fun setOnCloseClickListener(listener: (() -> Unit)?) { + binder.alertCloseButton.letOrHide(listener) { + binder.alertCloseButton.setOnClickListener { it() } + } + } + + fun setModel(maybeModel: SimpleAlertModel?) = letOrHide(maybeModel) { model -> + setMessage(model) + } + + private fun setStyleBackground(@ColorRes colorRes: Int) { + background = getRoundedCornerDrawable(fillColorRes = colorRes) + } + + private fun setStyleIcon(@DrawableRes iconRes: Int, iconTintRes: Int? = null, iconGravity: Int) { + binder.alertIcon.setImageResource(iconRes) + binder.alertIcon.setImageTintRes(iconTintRes) + binder.alertIcon.updateLayoutParams { gravity = iconGravity } + } + + private fun createSubMessageView(text: CharSequence): TextView { + return ViewAlertMessageBinding.inflate(inflater(), binder.alertSubMessageContainer, true) + .alertSubMessage + .apply { + this.text = text + this.movementMethod = LinkMovementMethod.getInstance() + } + } + + private fun applyAttrs(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.AlertView) { + val stylePreset = it.getEnum(R.styleable.AlertView_alertMode, StylePreset.WARNING) + val styleFromPreset = Style.fromPreset(stylePreset) + + val backgroundColorRes = it.getResourceId(R.styleable.AlertView_styleBackgroundColor, styleFromPreset.backgroundColorRes) + val iconRes = it.getResourceId(R.styleable.AlertView_styleIcon, styleFromPreset.iconRes) + val iconTintRes = it.getResourceIdOrNull(R.styleable.AlertView_styleIconTint) + + setStyle(Style(iconRes, backgroundColorRes, iconTintRes)) + + val text = it.getString(R.styleable.AlertView_android_text) + text?.let(::setMessage) + + val description = it.getString(R.styleable.AlertView_AlertView_description) + setSubMessage(description) + + val action = it.getString(R.styleable.AlertView_AlertView_action) + setActionText(action) + } +} + +fun AlertView.setModel(model: AlertModel) { + setMessage(model.message) + setSubMessages(model.subMessages) + + if (model.linkAction != null) { + setActionText(model.linkAction.text) + setOnLinkClickedListener(model.linkAction.listener) + } + + if (model.buttonAction != null) { + setButtonText(model.buttonAction.text) + setOnButtonClickedListener(model.buttonAction.listener) + } + + setStyle(model.style) +} + +fun AlertView.setModelOrHide(maybeModel: AlertModel?) = letOrHide(maybeModel, ::setModel) + +fun AlertView.setMessageOrHide(text: String?) = letOrHide(text, ::setMessage) + +fun AlertView.StylePreset.asStyle(): AlertView.Style { + return AlertView.Style.fromPreset(this) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/AmountView.kt b/common/src/main/java/io/novafoundation/nova/common/view/AmountView.kt new file mode 100644 index 0000000..58e1912 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/AmountView.kt @@ -0,0 +1,102 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.text.InputType +import android.util.AttributeSet +import android.widget.EditText +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewChooseAmountOldBinding +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.common.view.shape.getCornersStateDrawable + +class AmountView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + private val binder = ViewChooseAmountOldBinding.inflate(inflater(), this) + + val amountInput: EditText + get() = binder.stakingAmountInput + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + setBackground() + + applyAttributes(attrs) + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + amountInput.isEnabled = enabled + amountInput.inputType = if (enabled) InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL else InputType.TYPE_NULL + } +/* + override fun childDrawableStateChanged(child: View?) { + refreshDrawableState() + }*/ + + // Make this view be aware of amountInput state changes (i.e. state_focused) + override fun onCreateDrawableState(extraSpace: Int): IntArray { + val fieldState: IntArray? = amountInput.drawableState + + val need = fieldState?.size ?: 0 + + val selfState = super.onCreateDrawableState(extraSpace + need) + + return mergeDrawableStates(selfState, fieldState) + } + + private fun setBackground() { + background = context.getCornersStateDrawable( + focusedDrawable = context.getBlockDrawable( + strokeColorRes = R.color.active_border + ), + idleDrawable = context.getBlockDrawable() + ) + } + + private fun applyAttributes(attributeSet: AttributeSet?) { + attributeSet?.let { + val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.AmountView) + + val enabled = typedArray.getBoolean(R.styleable.AmountView_android_enabled, true) + isEnabled = enabled + + typedArray.recycle() + } + } + + fun setAssetImage(image: Drawable) { + binder.stakingAssetImage.setImageDrawable(image) + } + + fun loadAssetImage(icon: Icon) { + binder.stakingAssetImage.setIcon(icon, imageLoader) + } + + fun setAssetName(name: String) { + binder.stakingAssetToken.text = name + } + + fun setAssetBalance(balance: String) { + binder.stakingAssetBalance.text = balance + } + + fun setFiatAmount(priceAmount: String?) { + binder.stakingAssetPriceAmount.setTextOrHide(priceAmount) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/BannerView.kt b/common/src/main/java/io/novafoundation/nova/common/view/BannerView.kt new file mode 100644 index 0000000..078cf71 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/BannerView.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.core.view.isVisible +import com.google.android.material.card.MaterialCardView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewBannerBinding +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.getEnum +import io.novafoundation.nova.common.utils.inflater + +class BannerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : MaterialCardView(context, attrs, defStyle) { + + private val binder = ViewBannerBinding.inflate(inflater(), this) + + init { + cardElevation = 0f + radius = 12f.dpF(context) + strokeWidth = 1.dp(context) + strokeColor = context.getColor(R.color.container_border) + + applyAttributes(attrs) + } + + fun setOnCloseClickListener(listener: OnClickListener?) { + binder.bannerClose.setOnClickListener(listener) + } + + private fun applyAttributes(attrs: AttributeSet?) { + attrs?.let { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BannerView) + + val image = typedArray.getDrawable(R.styleable.BannerView_android_src) + binder.bannerImage.setImageDrawable(image) + + val background = typedArray.getDrawable(R.styleable.BannerView_bannerBackground) + binder.bannerBackground.background = background + + val closeIcon = typedArray.getDrawable(R.styleable.BannerView_closeIcon) + if (closeIcon != null) { + binder.bannerClose.setImageDrawable(closeIcon) + } + + val showClose = typedArray.getBoolean(R.styleable.BannerView_showClose, false) + binder.bannerClose.isVisible = showClose + + val style = typedArray.getEnum(R.styleable.BannerView_android_scaleType, ImageView.ScaleType.CENTER) + setImageScaleType(style) + + typedArray.recycle() + } + } + + override fun addView(child: View, params: ViewGroup.LayoutParams?) { + if (child.id == R.id.bannerBackground) { + super.addView(child, params) + } else { + binder.bannerContent.addView(child, params) + } + } + + fun setBannerBackground(@DrawableRes backgroundRes: Int) { + binder.bannerBackground.setBackgroundResource(backgroundRes) + } + + fun setImage(@DrawableRes imageRes: Int) { + binder.bannerImage.setImageResource(imageRes) + } + + fun setImageScaleType(scaleType: ImageView.ScaleType) { + binder.bannerImage.scaleType = scaleType + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/ButtonLarge.kt b/common/src/main/java/io/novafoundation/nova/common/view/ButtonLarge.kt new file mode 100644 index 0000000..7b5b8cd --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/ButtonLarge.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ButtonLargeBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.getColorFromAttr +import io.novafoundation.nova.common.utils.getEnum +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawableFromColors + +class ButtonLarge @kotlin.jvm.JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ButtonLargeBinding.inflate(inflater(), this) + + init { + minHeight = 52.dp + + attrs?.let(::applyAttributes) + } + + enum class Style { + PRIMARY, + SECONDARY, + } + + fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.ButtonLarge) { + val style = it.getEnum(R.styleable.ButtonLarge_buttonLargeStyle, Style.PRIMARY) + setStyle(style) + + val icon = it.getDrawable(R.styleable.ButtonLarge_icon) + setIcon(icon) + + val title = it.getString(R.styleable.ButtonLarge_title) + setTitle(title) + + val subtitle = it.getString(R.styleable.ButtonLarge_subTitle) + setSubtitle(subtitle) + } + + private fun setTitle(title: String?) { + binder.buttonLargeTitle.text = title + } + + private fun setSubtitle(subtitle: String?) { + binder.buttonLargeSubtitle.setTextOrHide(subtitle) + } + + private fun setIcon(icon: Drawable?) { + binder.buttonLargeIcon.setImageDrawable(icon) + } + + private fun setStyle(style: Style) = with(context) { + val backgroundColor = when (style) { + Style.PRIMARY -> context.getColor(R.color.button_background_primary) + Style.SECONDARY -> context.getColor(R.color.button_background_secondary) + } + + val rippleColor = getColorFromAttr(R.attr.colorControlHighlight) + val baseBackground = context.getRoundedCornerDrawableFromColors(backgroundColor) + + background = addRipple(baseBackground, mask = null, rippleColor = rippleColor) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/ChipActionsList.kt b/common/src/main/java/io/novafoundation/nova/common/view/ChipActionsList.kt new file mode 100644 index 0000000..0b45bd0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/ChipActionsList.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.common.view + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.databinding.ItemChipActionBinding +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.inflater + +class ChipActionsModel( + val action: String +) + +class ChipActionsAdapter( + private val handler: Handler +) : BaseListAdapter(DiffCallback()) { + + fun interface Handler { + + fun chipActionClicked(index: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChipActionsViewHolder { + return ChipActionsViewHolder(ItemChipActionBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: ChipActionsViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +class ChipActionsViewHolder( + private val binder: ItemChipActionBinding, + handler: ChipActionsAdapter.Handler +) : BaseViewHolder(binder.root) { + + init { + binder.root.setOnClickListener { handler.chipActionClicked(bindingAdapterPosition) } + } + + fun bind(item: ChipActionsModel) { + binder.itemChipAction.text = item.action + } + + override fun unbind() {} +} + +private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ChipActionsModel, newItem: ChipActionsModel): Boolean { + return oldItem.action == newItem.action + } + + override fun areContentsTheSame(oldItem: ChipActionsModel, newItem: ChipActionsModel): Boolean { + return true + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/ChipLabelView.kt b/common/src/main/java/io/novafoundation/nova/common/view/ChipLabelView.kt new file mode 100644 index 0000000..a5c905b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/ChipLabelView.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.ContextThemeWrapper +import android.view.Gravity +import androidx.annotation.DrawableRes +import androidx.appcompat.widget.AppCompatTextView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.px +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable + +private const val BASE_ICON_PADDING_DP = 6 + +data class ChipLabelModel( + val title: String, + val icon: TintedIcon? = null +) + +data class TintedIcon(val canApplyOwnTint: Boolean, @DrawableRes val icon: Int) { + + companion object { + + fun @receiver:DrawableRes Int.asTintedIcon(canApplyOwnTint: Boolean): TintedIcon { + return TintedIcon(canApplyOwnTint, this) + } + } +} + +class ChipLabelView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatTextView(ContextThemeWrapper(context, R.style.Widget_Nova_ChipLabel), attrs, defStyleAttr) { + + private var startIconTint: Int = R.color.chip_icon + private var iconStartPadding: Float = 0f + private var endIconTint: Int = R.color.chip_icon + private var iconEndPadding: Float = 0f + + init { + background = context.getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 8) + gravity = Gravity.CENTER_VERTICAL + + applyAttrs(context, attrs) + } + + fun setModel(model: ChipLabelModel) { + val tintColor = R.color.icon_secondary.takeIf { model.icon?.canApplyOwnTint ?: false } + setDrawableStart(model.icon?.icon, widthInDp = 16, paddingInDp = 6, tint = tintColor) + + text = model.title + } + + private fun applyAttrs(context: Context, attrs: AttributeSet?) { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ChipLabelView) + + iconStartPadding = typedArray.getDimension(R.styleable.ChipLabelView_iconStartPadding, BASE_ICON_PADDING_DP.dpF(context)) + startIconTint = typedArray.getResourceId(R.styleable.ChipLabelView_iconStartTint, R.color.chip_icon) + typedArray.getResourceIdOrNull(R.styleable.ChipLabelView_iconStart)?.let { + setDrawableStart(it, widthInDp = 16, paddingInDp = iconStartPadding.px(context), tint = startIconTint) + } + + iconEndPadding = typedArray.getDimension(R.styleable.ChipLabelView_iconEndPadding, BASE_ICON_PADDING_DP.dpF(context)) + endIconTint = typedArray.getResourceId(R.styleable.ChipLabelView_iconEndTint, R.color.chip_icon) + typedArray.getResourceIdOrNull(R.styleable.ChipLabelView_iconEnd)?.let { + setDrawableEnd(it, widthInDp = 16, paddingInDp = iconEndPadding.px(context), tint = endIconTint) + } + + typedArray.recycle() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/CounterView.kt b/common/src/main/java/io/novafoundation/nova/common/view/CounterView.kt new file mode 100644 index 0000000..7b66230 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/CounterView.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.ContextThemeWrapper +import androidx.appcompat.widget.AppCompatTextView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable + +class CounterView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatTextView(ContextThemeWrapper(context, R.style.Widget_Nova_Counter), attrs, defStyleAttr) { + + init { + background = context.getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 8) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/ExpandableView.kt b/common/src/main/java/io/novafoundation/nova/common/view/ExpandableView.kt new file mode 100644 index 0000000..ce68807 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/ExpandableView.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.common.view + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.animation.doOnEnd +import androidx.core.animation.doOnStart +import androidx.core.view.isVisible +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible + +enum class ExpandableViewState { + COLLAPSED, + EXPANDED +} + +class ExpandableView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + fun interface Callback { + fun onStateChanged(state: ExpandableViewState) + } + + private var supportAnimation: Boolean = true + private var collapsedByDefault: Boolean = false + private var chevronResId: Int? = null + private var expandablePartResId: Int? = null + + private val expandCollapseAnimator = ValueAnimator() + + private val chevron: View? by lazy { findViewByIdOrNull(chevronResId) } + private val expandablePart: View? by lazy { findViewByIdOrNull(expandablePartResId) } + + private var isExpandable: Boolean = true + + private var callback: Callback? = null + + init { + applyAttributes(attrs) + setOnClickListener { toggle() } + + expandCollapseAnimator.interpolator = AccelerateDecelerateInterpolator() + expandCollapseAnimator.duration = 300L + expandCollapseAnimator.addUpdateListener { animator -> + val animatedValue = animator.animatedValue as Float + + expandablePart?.let { + val offset = animatedValue * it.height + it.translationY = offset + it.clipBounds = Rect(0, -offset.toInt(), it.width, it.height) + } + + chevron?.let { + it.rotation = 180 * animatedValue + } + } + } + + override fun onFinishInflate() { + super.onFinishInflate() + + if (collapsedByDefault) { + collapseImmediate() + } else { + expandImmediate() + } + } + + fun currentState(): ExpandableViewState { + return when (isExpanded()) { + true -> ExpandableViewState.EXPANDED + false -> ExpandableViewState.COLLAPSED + } + } + + fun setCallback(callback: Callback) { + this.callback = callback + } + + fun setState(state: ExpandableViewState) { + when (state) { + ExpandableViewState.COLLAPSED -> collapse() + ExpandableViewState.EXPANDED -> expand() + } + } + + fun collapseImmediate() { + callback?.onStateChanged(ExpandableViewState.COLLAPSED) + expandablePart?.makeGone() + chevron?.rotation = -180f + } + + fun expandImmediate() { + callback?.onStateChanged(ExpandableViewState.EXPANDED) + expandablePart?.makeVisible() + chevron?.rotation = 0f + } + + fun setExpandable(isExpandable: Boolean) { + this.isExpandable = isExpandable + collapseImmediate() + chevron?.isVisible = isExpandable + } + + private fun applyAttributes(attrs: AttributeSet?) { + attrs?.let { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ExpandableView) + + supportAnimation = typedArray.getBoolean(R.styleable.ExpandableView_supportAnimation, true) + collapsedByDefault = typedArray.getBoolean(R.styleable.ExpandableView_collapsedByDefault, false) + chevronResId = typedArray.getResourceIdOrNull(R.styleable.ExpandableView_chevronId) + expandablePartResId = typedArray.getResourceIdOrNull(R.styleable.ExpandableView_expandableId) + + typedArray.recycle() + } + } + + private fun isExpanded() = expandablePart?.isVisible == true + + private fun toggle() { + if (!isExpandable) return + + if (isExpanded()) { + collapse() + } else { + expand() + } + } + + private fun collapse() { + if (supportAnimation) { + callback?.onStateChanged(ExpandableViewState.COLLAPSED) + expandCollapseAnimator.removeAllListeners() + expandCollapseAnimator.setFloatValues(0f, -1f) + expandCollapseAnimator.doOnEnd { expandablePart?.makeGone() } + expandCollapseAnimator.start() + } else { + collapseImmediate() + } + } + + private fun expand() { + if (supportAnimation) { + callback?.onStateChanged(ExpandableViewState.EXPANDED) + expandCollapseAnimator.removeAllListeners() + expandCollapseAnimator.setFloatValues(-1f, 0f) + expandCollapseAnimator.doOnStart { expandablePart?.makeVisible() } + expandCollapseAnimator.start() + } else { + expandImmediate() + } + } + + private fun findViewByIdOrNull(id: Int?): View? = id?.let { findViewById(it) } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/ExpandableViewExt.kt b/common/src/main/java/io/novafoundation/nova/common/view/ExpandableViewExt.kt new file mode 100644 index 0000000..2265855 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/ExpandableViewExt.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.common.view + +import android.widget.TextView +import io.novafoundation.nova.common.R + +fun ExpandableView.bindWithHideShowButton(hideShowButton: TextView) { + hideShowButton.setHideShowButtonState(currentState()) + setCallback { hideShowButton.setHideShowButtonState(it) } +} + +private fun TextView.setHideShowButtonState(state: ExpandableViewState) { + when (state) { + ExpandableViewState.COLLAPSED -> { + text = context.getString(R.string.common_show) + } + + ExpandableViewState.EXPANDED -> { + text = context.getString(R.string.common_hide) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/Extensions.kt b/common/src/main/java/io/novafoundation/nova/common/view/Extensions.kt new file mode 100644 index 0000000..722a8b8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/Extensions.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.os.CountDownTimer +import android.widget.CompoundButton +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleCoroutineScope +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.common.utils.formatting.duration.CompoundDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.DayAndHourDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.DayDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.DurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.HoursDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.RoundMinutesDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.TimeDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.ZeroDurationFormatter +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.onDestroy +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds + +private val TIMER_TAG = R.string.common_time_left + +fun TextView.startTimer( + value: TimerValue, + durationFormatter: DurationFormatter? = null, + @StringRes customMessageFormat: Int? = null, + lifecycle: Lifecycle? = null, + onTick: ((view: TextView, millisUntilFinished: Long) -> Unit)? = null, + onFinish: ((view: TextView) -> Unit)? = null +) = startTimer(value.millis, value.millisCalculatedAt, durationFormatter, lifecycle, customMessageFormat, onTick, onFinish) + +fun TextView.startTimer( + millis: Long, + millisCalculatedAt: Long? = null, + durationFormatter: DurationFormatter? = null, + lifecycle: Lifecycle? = null, + @StringRes customMessageFormat: Int? = null, + onTick: ((view: TextView, millisUntilFinished: Long) -> Unit)? = null, + onFinish: ((view: TextView) -> Unit)? = null +) { + val actualDurationFormatter = durationFormatter ?: getTimerDurationFormatter(context) + + val timePassedSinceCalculation = if (millisCalculatedAt != null) System.currentTimeMillis() - millisCalculatedAt else 0L + + val currentTimer = getTag(TIMER_TAG) + + if (currentTimer is CountDownTimer) { + currentTimer.cancel() + } + + val newTimer = object : CountDownTimer(millis - timePassedSinceCalculation, 1000) { + override fun onTick(millisUntilFinished: Long) { + setNewValue(actualDurationFormatter, millisUntilFinished, customMessageFormat) + + onTick?.invoke(this@startTimer, millisUntilFinished) + } + + override fun onFinish() { + if (onFinish != null) { + onFinish(this@startTimer) + } else { + this@startTimer.text = actualDurationFormatter.format(0L.milliseconds) + } + + cancel() + + setTag(TIMER_TAG, null) + } + } + + lifecycle?.onDestroy { + newTimer.cancel() + } + + setNewValue(actualDurationFormatter, millis - timePassedSinceCalculation, customMessageFormat) + newTimer.start() + + setTag(TIMER_TAG, newTimer) +} + +private fun getTimerDurationFormatter(context: Context): DurationFormatter { + val timeDurationFormatter = TimeDurationFormatter() + val compoundFormatter = CompoundDurationFormatter( + DayAndHourDurationFormatter( + dayFormatter = DayDurationFormatter(context), + hoursFormatter = HoursDurationFormatter(context) + ), + timeDurationFormatter, + ZeroDurationFormatter(timeDurationFormatter) + ) + + return RoundMinutesDurationFormatter(compoundFormatter, roundMinutesThreshold = 1.days) +} + +private fun TextView.setNewValue(durationFormatter: DurationFormatter, mills: Long, timeFormatRes: Int?) { + val formattedTime = durationFormatter.format(mills.milliseconds) + + val message = timeFormatRes?.let { + resources.getString(timeFormatRes, formattedTime) + } ?: formattedTime + + this.text = message +} + +fun TextView.stopTimer() { + val currentTimer = getTag(TIMER_TAG) + + if (currentTimer is CountDownTimer) { + currentTimer.cancel() + setTag(TIMER_TAG, null) + } +} + +fun CompoundButton.bindFromMap(key: K, map: Map>, lifecycleScope: LifecycleCoroutineScope) { + val source = map[key] + + if (source == null) { + makeGone() + return + } + + bindTo(source, lifecycleScope) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/GenericTableCellView.kt b/common/src/main/java/io/novafoundation/nova/common/view/GenericTableCellView.kt new file mode 100644 index 0000000..be09505 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/GenericTableCellView.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewGenericTableCellBinding +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.useAttributes + +open class GenericTableCellView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + defStyleRes: Int = 0, +) : ConstraintLayout(context, attrs, defStyle, defStyleRes), TableItem { + + private val binder = ViewGenericTableCellBinding.inflate(inflater(), this) + + protected lateinit var valueView: V + + companion object { + + private val SELF_IDS = listOf(R.id.genericTableCellTitle, R.id.genericTableCellValueProgress) + } + + init { + minHeight = 44.dp + + setBackgroundResource(R.drawable.bg_primary_list_item) + + attrs?.let(::applyAttributes) + } + + override fun onFinishInflate() { + super.onFinishInflate() + + findAndPositionValueView() + } + + @Suppress("UNCHECKED_CAST") + private fun findAndPositionValueView() { + children.forEach { + if (it.id !in SELF_IDS) { + valueView = it as V + valueView.layoutParams = createValueViewLayoutParams() + requestLayout() + } + } + } + + override fun addView(child: View) { + if (child.id in SELF_IDS) { + super.addView(child) + } else { + addValueView(child) + } + } + + fun showProgress(showProgress: Boolean) { + binder.genericTableCellValueProgress.setVisible(showProgress) + valueView.setVisible(!showProgress) + } + + fun setTitle(title: String?) { + binder.genericTableCellTitle.text = title + } + + fun setTitle(@StringRes titleRes: Int) { + binder.genericTableCellTitle.setText(titleRes) + } + + fun setTitleIconEnd(@DrawableRes icon: Int?) { + binder.genericTableCellTitle.setDrawableEnd(icon, widthInDp = 16, paddingInDp = 4) + } + + @JvmName("setValueContentView") + protected fun setValueView(view: V) { + addValueView(view) + } + + @Suppress("UNCHECKED_CAST") + private fun addValueView(child: View) { + valueView = child as V + super.addView(child, createValueViewLayoutParams()) + } + + private fun createValueViewLayoutParams() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + endToEnd = LayoutParams.PARENT_ID + startToEnd = R.id.genericTableCellTitle + marginStart = 16.dp + topToTop = LayoutParams.PARENT_ID + bottomToBottom = LayoutParams.PARENT_ID + horizontalBias = 1.0f + constrainedWidth = true + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.GenericTableCellView) { typedArray -> + val titleText = typedArray.getString(R.styleable.GenericTableCellView_title) + setTitle(titleText) + + val titleIconEnd = typedArray.getResourceIdOrNull(R.styleable.GenericTableCellView_titleIcon) + titleIconEnd?.let(::setTitleIconEnd) + } + + override fun disableOwnDividers() {} + + override fun shouldDrawDivider(): Boolean { + return true + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/GoNextView.kt b/common/src/main/java/io/novafoundation/nova/common/view/GoNextView.kt new file mode 100644 index 0000000..870b9be --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/GoNextView.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import coil.load +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewGoNextBinding +import io.novafoundation.nova.common.utils.getColorOrNull +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible + +class GoNextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binder = ViewGoNextBinding.inflate(inflater(), this) + + init { + attrs?.let(this::applyAttributes) + } + + val icon: ImageView + get() = binder.goNextIcon + + val title: TextView + get() = binder.goNextTitle + + fun setInProgress(inProgress: Boolean) { + isEnabled = !inProgress + + binder.goNextActionImage.setVisible(!inProgress) + binder.goNextProgress.setVisible(inProgress) + } + + fun setDividerVisible(visible: Boolean) { + binder.goNextDivider.setVisible(visible) + } + + fun setBadgeText(badgeText: String?) { + binder.goNextBadgeText.setTextOrHide(badgeText) + } + + fun loadIcon(iconLink: String, imageLoader: ImageLoader) { + icon.load(iconLink, imageLoader) + icon.setVisible(true) + } + + fun setProgressTint(@ColorRes tintColor: Int) { + binder.goNextProgress.indeterminateTintList = ColorStateList.valueOf(context.getColor(tintColor)) + } + + fun setIcon(drawable: Drawable?) { + icon.setImageDrawable(drawable) + icon.setVisible(drawable != null) + } + + fun setIconTint(colorStateList: ColorStateList?) { + icon.imageTintList = colorStateList + } + + fun setActionTint(@ColorInt color: Int) { + binder.goNextActionImage.imageTintList = ColorStateList.valueOf(color) + binder.goNextBadgeText.setTextColor(color) + } + + private fun applyAttributes(attributeSet: AttributeSet?) { + val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.GoNextView) + + val titleDisplay = typedArray.getString(R.styleable.GoNextView_android_text) + title.text = titleDisplay + + val inProgress = typedArray.getBoolean(R.styleable.GoNextView_inProgress, false) + setInProgress(inProgress) + + val iconDrawable = typedArray.getDrawable(R.styleable.GoNextView_icon) + setIcon(iconDrawable) + + val iconTint = typedArray.getColorStateList(R.styleable.GoNextView_iconTint) + setIconTint(iconTint) + + val actionIconDrawable = typedArray.getDrawable(R.styleable.GoNextView_actionIcon) + binder.goNextActionImage.setImageDrawable(actionIconDrawable) + + val dividerVisible = typedArray.getBoolean(R.styleable.GoNextView_dividerVisible, true) + setDividerVisible(dividerVisible) + + val backgroundDrawable = typedArray.getDrawable(R.styleable.GoNextView_android_background) + if (backgroundDrawable != null) background = backgroundDrawable else setBackgroundResource(R.drawable.bg_primary_list_item) + + val textAppearance = typedArray.getResourceIdOrNull(R.styleable.GoNextView_android_textAppearance) + textAppearance?.let(title::setTextAppearance) + + val titleColor = typedArray.getColorOrNull(R.styleable.GoNextView_android_textColor) + titleColor?.let { title.setTextColor(it) } + + val actionTint = typedArray.getColor(R.styleable.GoNextView_actionTint, context.getColor(R.color.icon_primary)) + setActionTint(actionTint) + + typedArray.recycle() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/HasDivider.kt b/common/src/main/java/io/novafoundation/nova/common/view/HasDivider.kt new file mode 100644 index 0000000..4262c4b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/HasDivider.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.common.view + +interface HasDivider { + + fun setDividerVisible(visible: Boolean) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/IconButton.kt b/common/src/main/java/io/novafoundation/nova/common/view/IconButton.kt new file mode 100644 index 0000000..3a73e6b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/IconButton.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.getMaskedRipple + +class IconButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : AppCompatImageView(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + init { + updatePadding(top = 6.dp, bottom = 6.dp, start = 12.dp, end = 12.dp) + + background = context.getMaskedRipple(cornerSizeInDp = 10) + + attrs?.let(::applyAttributes) + } + + fun setIcon(icon: Drawable) { + setImageDrawable(icon) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val height = MeasureSpec.makeMeasureSpec(32.dp, MeasureSpec.EXACTLY) + val width = MeasureSpec.makeMeasureSpec(44.dp, MeasureSpec.EXACTLY) + + super.onMeasure(width, height) + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.IconButton) { typedArray -> + val icon = typedArray.getDrawable(R.styleable.IconButton_android_src) + icon?.let(::setIcon) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/InputField.kt b/common/src/main/java/io/novafoundation/nova/common/view/InputField.kt new file mode 100644 index 0000000..4d55149 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/InputField.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.text.InputType +import android.text.method.ScrollingMovementMethod +import android.util.AttributeSet +import android.widget.EditText +import com.google.android.material.textfield.TextInputLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewInputFieldBinding +import io.novafoundation.nova.common.utils.getEnum +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.getCornersStateDrawable +import io.novafoundation.nova.common.view.shape.getInputBackground +import kotlin.math.roundToInt + +enum class BackgroundMode { + INPUT_STATE, + SOLID, + CUSTOM, +} + +class InputField @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = R.style.Widget_Nova_Input_Primary_External, +) : TextInputLayout(context, attrs, defStyle) { + + private val binder = ViewInputFieldBinding.inflate(inflater(), this) + + val content: EditText + get() = editText!! + + init { + content.setHintTextColor(context.getColor(R.color.hint_text)) + content.background = context.getCornersStateDrawable() + + attrs?.let(::applyAttributes) + } + + private fun applyAttributes(attributeSet: AttributeSet) { + val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.InputField) + + val inputType = typedArray.getInt(R.styleable.InputField_android_inputType, InputType.TYPE_CLASS_TEXT) + content.inputType = inputType + + val text = typedArray.getString(R.styleable.InputField_android_text) + content.setText(text) + + content.minHeight = typedArray.getDimension(R.styleable.InputField_editTextMinHeight, content.minHeight.toFloat()).roundToInt() + + content.setPadding( + typedArray.getDimension(R.styleable.InputField_editTextPaddingStart, content.paddingLeft.toFloat()).roundToInt(), + typedArray.getDimension(R.styleable.InputField_editTextPaddingTop, content.paddingTop.toFloat()).roundToInt(), + typedArray.getDimension(R.styleable.InputField_editTextPaddingEnd, content.paddingRight.toFloat()).roundToInt(), + typedArray.getDimension(R.styleable.InputField_editTextPaddingBottom, content.paddingBottom.toFloat()).roundToInt() + ) + + val contentHint = typedArray.getString(R.styleable.InputField_editTextHint) + if (contentHint != null) { + hint = null + content.hint = contentHint + } + + val hintColor = typedArray.getColor(R.styleable.InputField_editTextHintColor, context.getColor(R.color.hint_text)) + content.setHintTextColor(hintColor) + + val backgroundMode = typedArray.getEnum(R.styleable.InputField_backgroundMode, BackgroundMode.INPUT_STATE) + when (backgroundMode) { + BackgroundMode.INPUT_STATE -> content.background = context.getCornersStateDrawable() + BackgroundMode.SOLID -> content.background = context.getInputBackground() + BackgroundMode.CUSTOM -> {} + } + + val textAppearanceRes = typedArray.getResourceIdOrNull(R.styleable.InputField_android_textAppearance) + if (textAppearanceRes != null) { + content.setTextAppearance(textAppearanceRes) + } + + val maxLines = typedArray.getInt(R.styleable.InputField_android_maxLines, -1) + if (maxLines != -1) { + content.maxLines = maxLines + content.isVerticalScrollBarEnabled = true + content.movementMethod = ScrollingMovementMethod.getInstance() + } + + val gravity = typedArray.getInt(R.styleable.InputField_android_gravity, -1) + + if (gravity != -1) { + content.gravity = gravity + } + + typedArray.recycle() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/InsertableInputField.kt b/common/src/main/java/io/novafoundation/nova/common/view/InsertableInputField.kt new file mode 100644 index 0000000..270e7d3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/InsertableInputField.kt @@ -0,0 +1,127 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import android.widget.EditText +import android.widget.LinearLayout +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewInsertableInputFieldBinding +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getInputBackground +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable + +class InsertableInputField @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewInsertableInputFieldBinding.inflate(inflater(), this) + + private var clipboardManager: ClipboardManager? = getClipboardManager() + + var supportScan: Boolean = false + var supportInsertion: Boolean = true + + val content: EditText + get() = binder.actionInputField + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = 48.dp + + setAddStatesFromChildren(true) + + setBackgrounds() + + attrs?.let(::applyAttributes) + + content.addTextChangedListener { + updateButtonsVisibility(it) + } + + binder.actionInputFieldAction.setOnClickListener { + paste() + } + + binder.actionInputFieldAction.setOnClickListener { + paste() + } + + binder.actionInputFieldClear.setOnClickListener { content.text = null } + + updateButtonsVisibility(content.text) + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + if (enabled) { + updateButtonsVisibility(content.text) + } else { + binder.actionInputFieldAction.makeGone() + binder.actionInputFieldClear.makeGone() + binder.actionInputFieldScan.makeGone() + } + + content.isEnabled = enabled + } + + fun onScanClicked(onClickListener: OnClickListener?) { + binder.actionInputFieldScan.setOnClickListener(onClickListener) + } + + private fun updateButtonsVisibility(text: CharSequence?) { + val clipboardValue = clipboardManager?.getTextOrNull() + val clipboardIsNotEmpty = !TextUtils.isEmpty(clipboardValue) + val textIsEmpty = TextUtils.isEmpty(text) + + binder.actionInputFieldClear.isGone = textIsEmpty + binder.actionInputFieldAction.isVisible = textIsEmpty && clipboardIsNotEmpty && supportInsertion + binder.actionInputFieldScan.isVisible = textIsEmpty && supportScan + } + + private fun setBackgrounds() = with(context) { + background = context.getInputBackground() + + binder.actionInputFieldAction.background = buttonBackground() + binder.actionInputFieldScan.background = buttonBackground() + } + + private fun paste() { + val clipboard = clipboardManager?.getTextOrNull() + content.setText(clipboard) + } + + private fun Context.buttonBackground() = addRipple(getRoundedCornerDrawable(R.color.button_background_secondary, cornerSizeInDp = 10)) + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.ActionInputField) { + val hint = it.getString(R.styleable.ActionInputField_android_hint) + + supportScan = it.getBoolean(R.styleable.ActionInputField_supportScan, supportScan) + supportInsertion = it.getBoolean(R.styleable.ActionInputField_supportInsertion, supportInsertion) + content.isSaveEnabled = it.getBoolean(R.styleable.ActionInputField_fieldSaveEnabled, true) + + hint?.let { content.hint = hint } + } + + private fun getClipboardManager(): ClipboardManager? { + return if (isInEditMode) { + null + } else { + FeatureUtils.getCommonApi(context) + .provideClipboardManager() + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/InstructionImageView.kt b/common/src/main/java/io/novafoundation/nova/common/view/InstructionImageView.kt new file mode 100644 index 0000000..8260638 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/InstructionImageView.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.databinding.ViewInstructionImageBinding +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide + +class InstructionImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewInstructionImageBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + } + + fun setModel(@DrawableRes imageRes: Int, label: String?) { + binder.viewInstructionImage.setImageResource(imageRes) + binder.viewInstructionImageLabel.setTextOrHide(label) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/InstructionStepView.kt b/common/src/main/java/io/novafoundation/nova/common/view/InstructionStepView.kt new file mode 100644 index 0000000..d2652c6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/InstructionStepView.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewInstructionStepBinding +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes + +class InstructionStepView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewInstructionStepBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + + attrs?.let(::applyAttributes) + } + + fun setStepNumber(stepNumber: Int) { + binder.instructionStepIndicator.text = stepNumber.toString() + } + + fun setStepText(stepText: CharSequence) { + binder.instructionStepText.text = stepText + } + + private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.InstructionStepView) { + val stepNumber = it.getString(R.styleable.InstructionStepView_stepNumber) + binder.instructionStepIndicator.text = stepNumber + + // use getResourceId() instead of getString() since resources might contain spans which will be lost if getString() is used + val stepText = it.getResourceIdOrNull(R.styleable.InstructionStepView_stepText) + stepText?.let { binder.instructionStepText.setText(it) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/LabeledTextView.kt b/common/src/main/java/io/novafoundation/nova/common/view/LabeledTextView.kt new file mode 100644 index 0000000..3e8a286 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/LabeledTextView.kt @@ -0,0 +1,136 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewLabeledTextBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.getDrawableCompat +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getCornersStateDrawable + +class LabeledTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewLabeledTextBinding.inflate(inflater(), this) + + init { + minHeight = 48.dp + setPadding(0, 8.dp, 0, 8.dp) + + applyAttributes(attrs) + + if (background == null) { + background = context.addRipple(context.getCornersStateDrawable()) + } + } + + private var singleLine: Boolean = true + + val textIconView: ImageView + get() = binder.labeledTextIcon + + val primaryIcon: ImageView + get() = binder.labeledTextPrimaryIcon + + private fun applyAttributes(attrs: AttributeSet?) { + attrs?.let { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.LabeledTextView) + + val label = typedArray.getString(R.styleable.LabeledTextView_label) + setLabelOrHide(label) + + val message = typedArray.getString(R.styleable.LabeledTextView_message) + message?.let(::setMessage) + + val messageColor = typedArray.getColor(R.styleable.LabeledTextView_messageColor, context.getColor(R.color.text_primary)) + setMessageColor(messageColor) + + val messageStyle = typedArray.getResourceIdOrNull(R.styleable.LabeledTextView_messageStyle) + messageStyle?.let(binder.labeledTextText::setTextAppearance) + + val labelStyle = typedArray.getResourceIdOrNull(R.styleable.LabeledTextView_labelStyle) + labelStyle?.let(binder.labeledTextLabel::setTextAppearance) + + val textIcon = typedArray.getDrawable(R.styleable.LabeledTextView_textIcon) + textIcon?.let(::setTextIcon) + + val enabled = typedArray.getBoolean(R.styleable.LabeledTextView_enabled, true) + isEnabled = enabled + + val actionIcon = typedArray.getDrawable(R.styleable.LabeledTextView_actionIcon) + setActionIcon(actionIcon) + + singleLine = typedArray.getBoolean(R.styleable.LabeledTextView_android_singleLine, true) + binder.labeledTextText.isSingleLine = singleLine + + typedArray.recycle() + } + } + + private fun setMessageColor(messageColor: Int) { + binder.labeledTextText.setTextColor(messageColor) + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + binder.labeledTextAction.setVisible(enabled) + } + + fun setLabel(label: String) { + setLabelOrHide(label) + } + + fun setLabelOrHide(label: String?) { + binder.labeledTextLabel.setTextOrHide(label) + } + + fun setActionIcon(icon: Drawable?) { + binder.labeledTextAction.setImageDrawable(icon) + + binder.labeledTextAction.setVisible(icon != null) + } + + fun setMessage(@StringRes messageRes: Int) = setMessage(context.getString(messageRes)) + + fun setMessage(text: String?) { + binder.labeledTextText.text = text + } + + fun setTextIcon(@DrawableRes iconRes: Int) = setTextIcon(context.getDrawableCompat(iconRes)) + + fun setTextIcon(icon: Drawable) { + binder.labeledTextIcon.makeVisible() + binder.labeledTextIcon.setImageDrawable(icon) + } + + fun setPrimaryIcon(icon: Drawable) { + primaryIcon.makeVisible() + primaryIcon.setImageDrawable(icon) + } + + fun setActionClickListener(listener: (View) -> Unit) { + binder.labeledTextAction.setOnClickListener(listener) + } + + fun setWholeClickListener(listener: (View) -> Unit) { + setOnClickListener(listener) + + setActionClickListener(listener) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/LabeledTextViewExt.kt b/common/src/main/java/io/novafoundation/nova/common/view/LabeledTextViewExt.kt new file mode 100644 index 0000000..0564669 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/LabeledTextViewExt.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.view + +import io.novafoundation.nova.common.address.AddressModel + +fun LabeledTextView.setAddress(addressModel: AddressModel) { + setTextIcon(addressModel.image) + setMessage(addressModel.nameOrAddress) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/LinkView.kt b/common/src/main/java/io/novafoundation/nova/common/view/LinkView.kt new file mode 100644 index 0000000..a4c367b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/LinkView.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewLinkBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes + +class LinkView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewLinkBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + + attrs?.let(::applyAttributes) + } + + private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.LinkView) { + val linkText = it.getString(R.styleable.LinkView_linkText) + binder.viewLinkText.text = linkText + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/NovaConnectView.kt b/common/src/main/java/io/novafoundation/nova/common/view/NovaConnectView.kt new file mode 100644 index 0000000..af0e3b8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/NovaConnectView.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewNovaConnectBinding +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes + +class NovaConnectView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewNovaConnectBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + + attrs?.let(::applyAttributes) + } + + fun setTargetImage(@DrawableRes targetImageRes: Int) { + binder.viewNovaConnectTargetIcon.setImageResource(targetImageRes) + } + + private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.NovaConnectView) { + val targetImage = it.getDrawable(R.styleable.NovaConnectView_targetImage) + binder.viewNovaConnectTargetIcon.setImageDrawable(targetImage) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/PlaceholderView.kt b/common/src/main/java/io/novafoundation/nova/common/view/PlaceholderView.kt new file mode 100644 index 0000000..0697e07 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/PlaceholderView.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewPlaceholderBinding +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.getColorOrNull +import io.novafoundation.nova.common.utils.getEnum +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setImageTint +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable + +class PlaceholderView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + enum class Style(val showBackground: Boolean, val backgroundColorRes: Int?, val textColorRes: Int) { + BACKGROUND_PRIMARY(true, R.color.block_background, R.color.text_secondary), + BACKGROUND_SECONDARY(true, R.color.block_background, R.color.text_secondary), + NO_BACKGROUND(false, null, R.color.text_secondary) + } + + private val binder = ViewPlaceholderBinding.inflate(inflater(), this) + + init { + setPadding(16.dp(context), 16.dp(context), 16.dp(context), 32.dp(context)) + + orientation = VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + + attrs?.let(::applyAttributes) + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.PlaceholderView) { typedArray -> + val text = typedArray.getString(R.styleable.PlaceholderView_android_text) + text?.let(::setText) + + val backgroundStyle = typedArray.getEnum(R.styleable.PlaceholderView_placeholderBackgroundStyle, Style.BACKGROUND_PRIMARY) + setStyle(backgroundStyle) + + val image = typedArray.getResourceIdOrNull(R.styleable.PlaceholderView_image) + image?.let(::setImage) + + val imageTint = typedArray.getColorOrNull(R.styleable.PlaceholderView_imageTint) + imageTint?.let(::setImageTint) + + val showButton = typedArray.getBoolean(R.styleable.PlaceholderView_showButton, true) + binder.viewPlaceholderButton.isVisible = showButton + } + + fun setStyle(style: Style) { + background = if (style.showBackground) { + context.getRoundedCornerDrawable(style.backgroundColorRes!!, cornerSizeInDp = 12) + } else { + null + } + binder.viewPlaceholderText.setTextColorRes(style.textColorRes) + } + + fun setImage(@DrawableRes image: Int) { + binder.viewPlaceholderImage.setImageResource(image) + } + + fun setImageTint(@ColorInt tint: Int?) { + binder.viewPlaceholderImage.setImageTint(tint) + } + + fun setText(text: String) { + binder.viewPlaceholderText.text = text + } + + fun setText(@StringRes textRes: Int) { + binder.viewPlaceholderText.setText(textRes) + } + + fun setButtonText(text: String?) { + binder.viewPlaceholderButton.setTextOrHide(text) + } + + fun setButtonText(@StringRes textRes: Int) { + setButtonText(context.getString(textRes)) + } + + fun setModel(model: PlaceholderModel) { + setText(model.text) + setImage(model.imageRes) + setButtonText(model.buttonText) + setImageTint(model.imageTint) + model.style?.let { setStyle(it) } + } + + fun setButtonClickListener(listener: OnClickListener?) { + binder.viewPlaceholderButton.setOnClickListener(listener) + } +} + +class PlaceholderModel( + val text: String, + @DrawableRes val imageRes: Int, + val buttonText: String? = null, + val style: PlaceholderView.Style? = null, + @ColorInt val imageTint: Int? = null +) + +fun PlaceholderView.setModelOrHide(model: PlaceholderModel?) { + model?.let { setModel(it) } + isVisible = model != null +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/PrimaryButton.kt b/common/src/main/java/io/novafoundation/nova/common/view/PrimaryButton.kt new file mode 100644 index 0000000..23c0dc2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/PrimaryButton.kt @@ -0,0 +1,296 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.ContextThemeWrapper +import android.view.View +import androidx.annotation.ColorInt +import androidx.annotation.StyleRes +import androidx.appcompat.widget.AppCompatTextView +import androidx.lifecycle.LifecycleOwner +import com.github.razir.progressbutton.bindProgressButton +import com.github.razir.progressbutton.hideProgress +import com.github.razir.progressbutton.isProgressActive +import com.github.razir.progressbutton.showProgress +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.getColorFromAttr +import io.novafoundation.nova.common.utils.getEnum +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getCornersStateDrawable +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawableFromColors + +enum class ButtonState { + NORMAL, + DISABLED, + PROGRESS, + GONE, + INVISIBLE +} + +private const val ICON_SIZE_DP_DEFAULT = 24 +private const val ICON_PADDING_DP_DEFAULT = 8 + +class PrimaryButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : AppCompatTextView(ContextThemeWrapper(context, R.style.Widget_Nova_Button), attrs, defStyle) { + + enum class Appearance { + + PRIMARY { + override fun disabledColor(context: Context) = context.getColor(R.color.button_background_inactive) + + override fun enabledColor(context: Context) = context.getColor(R.color.button_background_primary) + }, + PRIMARY_TRANSPARENT { + override fun disabledColor(context: Context) = context.getColor(R.color.button_background_inactive_on_gradient) + + override fun enabledColor(context: Context) = context.getColor(R.color.button_background_primary) + }, + SECONDARY { + override fun disabledColor(context: Context) = context.getColor(R.color.button_background_inactive) + + override fun enabledColor(context: Context) = context.getColor(R.color.button_background_secondary) + }, + SECONDARY_TRANSPARENT { + override fun disabledColor(context: Context) = context.getColor(R.color.button_background_inactive_on_gradient) + + override fun enabledColor(context: Context) = context.getColor(R.color.button_background_secondary) + }, + + PRIMARY_POSITIVE { + override fun disabledColor(context: Context) = context.getColor(R.color.button_background_inactive) + + override fun enabledColor(context: Context) = context.getColor(R.color.button_background_approve) + }, + + PRIMARY_NEGATIVE { + override fun disabledColor(context: Context) = context.getColor(R.color.button_background_inactive) + + override fun enabledColor(context: Context) = context.getColor(R.color.button_background_reject) + }, + + ACCENT_SECONDARY_TRANSPARENT { + override fun enabledColor(context: Context): Int = context.getColor(R.color.button_background_secondary) + + override fun disabledColor(context: Context): Int = context.getColor(R.color.button_background_inactive_on_gradient) + + override fun textColor(context: Context): ColorStateList = context.getColorStateList(R.color.button_accent_text_colors) + }, + + CLOUD_BACKUP { + override fun enabledColor(context: Context): Int = context.getColor(R.color.cloud_backup_button_background) + + override fun disabledColor(context: Context): Int = context.getColor(R.color.button_background_inactive) + + override fun textColor(context: Context): ColorStateList = context.getColorStateList(R.color.text_on_cloud_backup_button) + }; + + @ColorInt + abstract fun disabledColor(context: Context): Int + + @ColorInt + abstract fun enabledColor(context: Context): Int + + open fun textColor(context: Context): ColorStateList = context.getColorStateList(R.color.button_text_colors) + } + + enum class Size(val heightDp: Int, val cornerSizeDp: Int, @StyleRes val textAppearance: Int) { + LARGE(52, 12, R.style.TextAppearance_NovaFoundation_SemiBold_SubHeadline), + SMALL(44, 10, R.style.TextAppearance_NovaFoundation_SemiBold_SubHeadline), + EXTRA_SMALL(32, 10, R.style.TextAppearance_NovaFoundation_SemiBold_Footnote); + } + + private var cachedText: String? = null + + private lateinit var size: Size + + private var preparedForProgress = false + + private var icon: Bitmap? = null + private var iconPaint: Paint? = null + private var iconSrcRect: Rect? = null + private var iconDestRect: Rect? = null + private var iconPadding = 0 + private var iconSize = 0 + + init { + attrs?.let(this::applyAttrs) + } + + fun prepareForProgress(lifecycleOwner: LifecycleOwner) { + lifecycleOwner.bindProgressButton(this) + + preparedForProgress = true + } + + override fun onDraw(canvas: Canvas) { + val icon = icon + + if (icon != null && !isProgressActive()) { + val shift: Int = (iconSize + iconPadding) / 2 + + canvas.save() + canvas.translate(shift.toFloat(), 0f) + + super.onDraw(canvas) + + val textWidth = paint.measureText(text.toString()) + val left = (width / 2f - textWidth / 2f - iconSize - iconPadding).toInt() + val top: Int = height / 2 - iconSize / 2 + + iconDestRect!!.set(left, top, left + iconSize, top + iconSize) + canvas.drawBitmap(icon, iconSrcRect, iconDestRect!!, iconPaint) + + canvas.restore() + } else { + super.onDraw(canvas) + } + } + + fun showProgress(show: Boolean) { + isEnabled = !show + + if (show) { + checkPreparedForProgress() + + showProgress() + } else { + hideProgress() + } + } + + fun setState(state: ButtonState) { + isEnabled = state == ButtonState.NORMAL + + visibility = when (state) { + ButtonState.GONE -> View.GONE + ButtonState.INVISIBLE -> View.INVISIBLE + else -> View.VISIBLE + } + + if (state == ButtonState.PROGRESS) { + checkPreparedForProgress() + + showProgress() + } else { + hideProgress() + } + } + + private fun applyAttrs(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.PrimaryButton) { typedArray -> + size = typedArray.getEnum(R.styleable.PrimaryButton_size, Size.LARGE) + setTextAppearance(size.textAppearance) + + minimumHeight = size.heightDp.dp(context) + + typedArray.getDrawable(R.styleable.PrimaryButton_iconSrc)?.let { icon = drawableToBitmap(it) } + icon?.let { icon -> + iconPadding = typedArray.getDimensionPixelSize(R.styleable.PrimaryButton_iconPadding, ICON_PADDING_DP_DEFAULT.dp(context)) + iconSize = typedArray.getDimensionPixelSize(R.styleable.PrimaryButton_iconSize, ICON_SIZE_DP_DEFAULT.dp(context)) + iconPaint = Paint() + iconSrcRect = Rect(0, 0, icon.width, icon.height) + iconDestRect = Rect() + } + + val appearance = typedArray.getEnum(R.styleable.PrimaryButton_appearance, Appearance.PRIMARY) + setAppearance(appearance) + } + + fun setAppearance(appearance: Appearance) { + setAppearance(appearance, cornerSizeDp = size.cornerSizeDp) + } + + private fun setAppearance(appearance: Appearance, cornerSizeDp: Int) = with(context) { + val activeState = getRoundedCornerDrawableFromColors(appearance.enabledColor(this), cornerSizeInDp = cornerSizeDp) + val baseBackground = getCornersStateDrawable( + disabledDrawable = getRoundedCornerDrawableFromColors(appearance.disabledColor(this), cornerSizeInDp = cornerSizeDp), + focusedDrawable = activeState, + idleDrawable = activeState + ) + + val rippleColor = getColorFromAttr(R.attr.colorControlHighlight) + val background = addRipple(baseBackground, mask = null, rippleColor = rippleColor) + + setBackground(background) + setTextColor(appearance.textColor(this)) + } + + private fun checkPreparedForProgress() { + if (!preparedForProgress) { + throw IllegalArgumentException("You must call prepareForProgress() first!") + } + } + + private fun hideProgress() { + if (isProgressActive()) { + hideProgress(cachedText) + } + } + + private fun showProgress() { + if (isProgressActive()) return + + cachedText = text.toString() + + showProgress { + progressColor = currentTextColor + } + } + + private fun drawableToBitmap(drawable: Drawable): Bitmap? { + if (drawable is BitmapDrawable) { + return drawable.bitmap + } + val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap + } + + fun setButtonColor(color: Int) { + background.setTint(color) + } +} + +fun PrimaryButton.setProgressState(show: Boolean) { + setState(if (show) ButtonState.PROGRESS else ButtonState.NORMAL) +} + +fun PrimaryButton.setState(descriptiveButtonState: DescriptiveButtonState) { + when (descriptiveButtonState) { + is DescriptiveButtonState.Disabled -> { + setState(ButtonState.DISABLED) + text = descriptiveButtonState.reason + } + + is DescriptiveButtonState.Enabled -> { + setState(ButtonState.NORMAL) + text = descriptiveButtonState.action + } + + DescriptiveButtonState.Loading -> { + setState(ButtonState.PROGRESS) + } + + DescriptiveButtonState.Gone -> { + setState(ButtonState.GONE) + } + + DescriptiveButtonState.Invisible -> { + setState(ButtonState.INVISIBLE) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/PrimaryButtonV2.kt b/common/src/main/java/io/novafoundation/nova/common/view/PrimaryButtonV2.kt new file mode 100644 index 0000000..9df51f7 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/PrimaryButtonV2.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import androidx.lifecycle.LifecycleOwner +import com.github.razir.progressbutton.DrawableButton +import com.github.razir.progressbutton.bindProgressButton +import com.github.razir.progressbutton.hideProgress +import com.github.razir.progressbutton.isProgressActive +import com.github.razir.progressbutton.showProgress +import com.google.android.material.button.MaterialButton +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.presentation.textOrNull + +class PrimaryButtonV2 @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : MaterialButton(context, attrs, defStyle) { + + private var cachedIcon: Drawable? = null + private var cachedText: String? = null + + private var preparedForProgress = false + + fun prepareForProgress(lifecycleOwner: LifecycleOwner) { + lifecycleOwner.bindProgressButton(this) + + preparedForProgress = true + } + + fun checkPreparedForProgress() { + if (!preparedForProgress) { + throw IllegalArgumentException("You must call prepareForProgress() first!") + } + } + + fun showProgress(show: Boolean) { + isEnabled = !show + + if (show) { + checkPreparedForProgress() + + showButtonProgress() + } else { + hideProgress() + } + } + + fun hideButtonProgress() { + if (isProgressActive()) { + icon = cachedIcon + hideProgress(cachedText) + } + } + + fun showButtonProgress() { + if (isProgressActive()) return + + cachedIcon = icon + cachedText = text.toString() + + icon = null + showProgress { + progressColor = currentTextColor + gravity = DrawableButton.GRAVITY_CENTER + } + } +} + +fun PrimaryButtonV2.setState(state: DescriptiveButtonState) { + isEnabled = state is DescriptiveButtonState.Enabled + + visibility = when (state) { + DescriptiveButtonState.Gone -> View.GONE + DescriptiveButtonState.Invisible -> View.INVISIBLE + else -> View.VISIBLE + } + + if (state == DescriptiveButtonState.Loading) { + checkPreparedForProgress() + + showButtonProgress() + } else { + text = state.textOrNull() + + hideButtonProgress() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/PromoBannerView.kt b/common/src/main/java/io/novafoundation/nova/common/view/PromoBannerView.kt new file mode 100644 index 0000000..43b6efb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/PromoBannerView.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewPromoBannerBinding +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes + +class PromoBannerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + private val binder = ViewPromoBannerBinding.inflate(inflater(), this) + + init { + attrs?.let(::applyAttributes) + } + + fun setOnCloseClickListener(listener: OnClickListener?) { + binder.promoBannerClose.setOnClickListener(listener) + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.PromoBannerView) { typedArray -> + val image = typedArray.getDrawable(R.styleable.PromoBannerView_promoBanner_image) + binder.promoBannerImage.setImageDrawable(image) + + val background = typedArray.getDrawable(R.styleable.PromoBannerView_promoBanner_background) + setBackground(background) + + val title = typedArray.getString(R.styleable.PromoBannerView_promoBanner_title) + binder.promoBannerTitle.text = title + + val description = typedArray.getString(R.styleable.PromoBannerView_promoBanner_description) + binder.promoBannerDescription.text = description + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/QrCodeView.kt b/common/src/main/java/io/novafoundation/nova/common/view/QrCodeView.kt new file mode 100644 index 0000000..af48b0b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/QrCodeView.kt @@ -0,0 +1,189 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import androidx.core.graphics.toRect +import coil.ImageLoader +import coil.request.ImageRequest +import com.google.zxing.qrcode.encoder.QRCode +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.formatIcon + +class QrCodeModel( + val qrCode: QRCode, + val overlayBackground: Drawable?, + val overlayPaddingInDp: Int, + val centerOverlay: Icon, +) + +class QrCodeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val qrColor = context.getColor(R.color.qr_code_content) + private val backgroundColor = context.getColor(R.color.qr_code_background) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + private var data: QRCode? = null + + private var overlayPadding: Int = 0 + private var centerOverlay: Drawable? = null + private var overlayBackground: Drawable? = null + + private val overlaySize = 64.dp + private val overlayQuiteZone = 0 + private val qrPadding = 16.dpF + + private val centerRect: RectF = RectF() + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + if (isInEditMode) { + ImageLoader.invoke(context) + } else { + FeatureUtils.getCommonApi(context).imageLoader() + } + } + + fun setQrModel(model: QrCodeModel) { + this.data = model.qrCode + this.overlayBackground = model.overlayBackground + this.overlayPadding = model.overlayPaddingInDp.dp(context) + + if (model.centerOverlay != null) { + val centerOverlayRequest = getCenterOverlayImageRequest(model.centerOverlay) { + this.centerOverlay = it + invalidate() + } + + imageLoader.enqueue(centerOverlayRequest) + } + invalidate() + } + + private fun getCenterOverlayImageRequest(icon: Icon, target: (Drawable?) -> Unit): ImageRequest { + return ImageRequest.Builder(context) + .data(ImageLoader.formatIcon(icon)) + .target(onSuccess = target) + .size(overlaySize, overlaySize) + .build() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + + setMeasuredDimension(widthSize, heightSize) + + centerRect.left = (measuredWidth / 2 - overlaySize / 2).toFloat() + centerRect.top = (measuredHeight / 2 - overlaySize / 2).toFloat() + centerRect.right = (measuredWidth / 2 + overlaySize / 2).toFloat() + centerRect.bottom = (measuredHeight / 2 + overlaySize / 2).toFloat() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + canvas.save() + + paint.color = qrColor + canvas.drawColor(backgroundColor) + data?.let { renderQRCodeImage(canvas, it) } + + canvas.restore() + } + + private fun renderQRCodeImage(canvas: Canvas, data: QRCode) { + renderQRImage(canvas, data, width, height) + } + + private fun renderQRImage(canvas: Canvas, code: QRCode, width: Int, height: Int) { + paint.color = qrColor + val input = code.matrix ?: throw IllegalStateException() + val inputWidth = input.width + val inputHeight = input.height + val outputWidth = Math.max(width, inputWidth) - qrPadding * 2 + val outputHeight = Math.max(height, inputHeight) - qrPadding * 2 + val multiple = Math.min(outputWidth / inputWidth, outputHeight / inputHeight) + val leftPadding = qrPadding + val topPadding = qrPadding + val FINDER_PATTERN_SIZE = 7f + val CIRCLE_SCALE_DOWN_FACTOR = 0.7f + val circleSize = (multiple * CIRCLE_SCALE_DOWN_FACTOR) + val circleRadius = circleSize / 2 + + var inputY = 0 + var outputY = topPadding + + while (inputY < inputHeight) { + var inputX = 0 + var outputX = leftPadding + while (inputX < inputWidth) { + if (input[inputX, inputY].toInt() == 1) { + val overlaysFinder = inputX <= FINDER_PATTERN_SIZE && inputY <= FINDER_PATTERN_SIZE || + inputX >= inputWidth - FINDER_PATTERN_SIZE && inputY <= FINDER_PATTERN_SIZE || + inputX <= FINDER_PATTERN_SIZE && inputY >= inputHeight - FINDER_PATTERN_SIZE + + val overlaysCenter = (this.overlay != null) && ( + this.centerRect.intersects( + outputX - overlayQuiteZone, + outputY - overlayQuiteZone, + outputX + circleSize + overlayQuiteZone, + outputY + circleRadius + overlayQuiteZone + ) + ) + + if (!overlaysCenter && !overlaysFinder) { + canvas.drawCircle(paddingStart + outputX + circleRadius, paddingStart + outputY + circleRadius, circleRadius, paint) + } + } + inputX++ + outputX += multiple + } + inputY++ + outputY += multiple + } + val cornerCircleDiameter = multiple * FINDER_PATTERN_SIZE + drawFinderPatternCircleStyle(canvas, leftPadding, topPadding, cornerCircleDiameter) + drawFinderPatternCircleStyle(canvas, leftPadding + (inputWidth - FINDER_PATTERN_SIZE) * multiple, topPadding, cornerCircleDiameter) + drawFinderPatternCircleStyle(canvas, leftPadding, topPadding + (inputHeight - FINDER_PATTERN_SIZE) * multiple, cornerCircleDiameter) + + drawCenterOverlay(canvas) + } + + private fun drawFinderPatternCircleStyle(canvas: Canvas, x: Float, y: Float, circleDiameter: Float) { + val radius = circleDiameter / 2 + val WHITE_CIRCLE_RADIUS = circleDiameter * 5 / 7 / 2 + val WHITE_CIRCLE_OFFSET = circleDiameter / 7 + WHITE_CIRCLE_RADIUS + val MIDDLE_DOT_RADIUS = circleDiameter * 3 / 7 / 2 + val MIDDLE_DOT_OFFSET = circleDiameter * 2 / 7 + MIDDLE_DOT_RADIUS + paint.color = qrColor + canvas.drawCircle(x + radius, y + radius, radius, paint) + paint.color = backgroundColor + canvas.drawCircle(x + WHITE_CIRCLE_OFFSET, y + WHITE_CIRCLE_OFFSET, WHITE_CIRCLE_RADIUS, paint) + paint.color = qrColor + canvas.drawCircle(x + MIDDLE_DOT_OFFSET, y + MIDDLE_DOT_OFFSET, MIDDLE_DOT_RADIUS, paint) + } + + private fun drawCenterOverlay(canvas: Canvas) { + val overlayBackgroundWithInsets = centerRect.toRect().apply { + inset(overlayPadding, overlayPadding) + } + + overlayBackground?.bounds = overlayBackgroundWithInsets + centerOverlay?.bounds = overlayBackgroundWithInsets + + overlayBackground?.draw(canvas) + centerOverlay?.draw(canvas) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/ReadMoreView.kt b/common/src/main/java/io/novafoundation/nova/common/view/ReadMoreView.kt new file mode 100644 index 0000000..9c9ceec --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/ReadMoreView.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.ContextThemeWrapper +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.defaultOnNull +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.utils.useAttributes + +class ReadMoreView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatTextView(ContextThemeWrapper(context, R.style.TextAppearance_NovaFoundation_Regular_SubHeadline), attrs, defStyleAttr), + WithContextExtensions by WithContextExtensions(context) { + + init { + gravity = Gravity.CENTER_VERTICAL + updatePadding(top = 8.dp, bottom = 8.dp) + setTextColorRes(R.color.button_text_accent) + setDrawableEnd(R.drawable.ic_chevron_right, widthInDp = 16, tint = R.color.icon_accent) + includeFontPadding = false + + attrs?.let { applyAttrs(attrs) } + } + + private fun applyAttrs(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.ReadMoreView) { typedArray -> + text = typedArray.getString(R.styleable.ReadMoreView_android_text) + .defaultOnNull { context.getString(R.string.common_read_more) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/SearchToolbar.kt b/common/src/main/java/io/novafoundation/nova/common/view/SearchToolbar.kt new file mode 100644 index 0000000..be3ac17 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/SearchToolbar.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewSearchToolbarBinding +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes + +class SearchToolbar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binder = ViewSearchToolbarBinding.inflate(inflater(), this) + + val searchInput + get() = binder.searchToolbarSearch + + val cancel + get() = binder.searchToolbarCancel + + init { + orientation = HORIZONTAL + setBackgroundResource(R.color.blur_navigation_background) + + attrs?.let(::applyAttributes) + } + + fun setHint(hint: String) { + searchInput.setHint(hint) + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.SearchToolbar) { typedArray -> + val hint = typedArray.getString(R.styleable.SearchToolbar_android_hint) + hint?.let(::setHint) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/SearchView.kt b/common/src/main/java/io/novafoundation/nova/common/view/SearchView.kt new file mode 100644 index 0000000..721530c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/SearchView.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.EditText +import android.widget.LinearLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewSearchBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.onTextChanged +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.useAttributes + +class SearchView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions { + + private val binder = ViewSearchBinding.inflate(inflater(), this) + + override val providedContext: Context + get() = context + + val content: EditText + get() = binder.searchContent + + init { + background = getRoundedCornerDrawable(fillColorRes = R.color.input_background, cornerSizeDp = 10) + + orientation = HORIZONTAL + + content.onTextChanged { + binder.searchClear.setVisible(it.isNotEmpty()) + } + binder.searchClear.setOnClickListener { + content.text.clear() + } + + attrs?.let(::applyAttrs) + } + + fun setHint(hint: String?) { + content.hint = hint + } + + private fun applyAttrs(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.SearchView) { + val hint = it.getString(R.styleable.SearchView_android_hint) + setHint(hint) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/SegmentedTabLayout.kt b/common/src/main/java/io/novafoundation/nova/common/view/SegmentedTabLayout.kt new file mode 100644 index 0000000..de031e5 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/SegmentedTabLayout.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Path +import android.graphics.drawable.InsetDrawable +import android.util.AttributeSet +import com.google.android.material.tabs.TabLayout +import io.novafoundation.nova.common.R +import kotlin.math.roundToInt + +class SegmentedTabLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TabLayout( + context, + attrs, + defStyleAttr, +) { + + private var backgroundCornerRadius: Float = 0f + + val clipPath = Path() + + init { + setBackgroundResource(R.color.segmented_background_on_black) + + applyAttrs(attrs) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + + clipPath.addRoundRect( + 0f, + 0f, + measuredWidth.toFloat(), + measuredHeight.toFloat(), + backgroundCornerRadius, + backgroundCornerRadius, + Path.Direction.CW + ) + + tabSelectedIndicator?.setBounds( + tabSelectedIndicator?.bounds?.left ?: 0, + 0, + 100, + measuredHeight + ) + } + + override fun draw(canvas: Canvas) { + canvas.clipPath(clipPath) + super.draw(canvas) + } + + private fun applyAttrs(attrs: AttributeSet?) { + val a = context.obtainStyledAttributes(attrs, R.styleable.SegmentedTabLayout) + val tabIndicatorMargin = a.getDimension(R.styleable.SegmentedTabLayout_tabIndicatorMargin, 0f).roundToInt() + backgroundCornerRadius = a.getDimension(R.styleable.SegmentedTabLayout_backgroundCornerRadius, 0f) + a.recycle() + + val newTabIndicator = InsetDrawable(tabSelectedIndicator, tabIndicatorMargin) + setSelectedTabIndicator(newTabIndicator) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/SlideShowView.kt b/common/src/main/java/io/novafoundation/nova/common/view/SlideShowView.kt new file mode 100644 index 0000000..74de880 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/SlideShowView.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.Bitmap +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.coroutineScope +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.useAttributes +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private const val DEFAULT_DELAY_MILLIS = 100 + +class SlideShowView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : AppCompatImageView(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context), LifecycleOwner { + + private val slideVisibilityLyfeCycle = LifecycleRegistry(this) + + private var currentDelay: Duration = DEFAULT_DELAY_MILLIS.milliseconds + + private var currentIterator: Iterator? = null + + private var slideUpdateJob: Job? = null + + init { + attrs?.let { applyAttrs(it) } + + setupSlideShow() + } + + fun setDelay(delay: Duration) { + this.currentDelay = delay + } + + fun setIterator(iterator: Iterator) { + currentIterator = iterator + setupSlideShow() + } + + fun pauseSlideShow() { + slideUpdateJob?.cancel() + slideUpdateJob = null + } + + fun resumeSlideShow() { + setupSlideShow() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + slideVisibilityLyfeCycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + slideVisibilityLyfeCycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } + + override fun getLifecycle(): Lifecycle { + return slideVisibilityLyfeCycle + } + + private fun setupSlideShow() { + slideUpdateJob?.cancel() + + slideUpdateJob = slideVisibilityLyfeCycle.coroutineScope.launchWhenCreated { + val iterator = currentIterator + + while (iterator != null && iterator.hasNext()) { + val nextFrame = iterator.next() + showFrame(nextFrame) + + delay(currentDelay) + } + } + } + + private fun showFrame(frame: Bitmap) { + setImageBitmap(frame) + } + + private fun applyAttrs(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.SlideShowView) { typedArray -> + val delayMillis = typedArray.getInt(R.styleable.SlideShowView_SlideShowView_slideDelayMillis, DEFAULT_DELAY_MILLIS) + setDelay(delayMillis.milliseconds) + } +} + +fun SlideShowView.setSequence(sequence: Sequence) = setIterator(sequence.iterator()) diff --git a/common/src/main/java/io/novafoundation/nova/common/view/Switch.kt b/common/src/main/java/io/novafoundation/nova/common/view/Switch.kt new file mode 100644 index 0000000..b35105f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/Switch.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.CompoundButton +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewSwitchBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.useAttributes + +private const val DIVIDER_VISIBLE_DEFAULT = false + +class Switch @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewSwitchBinding.inflate(inflater(), this) + + val field: CompoundButton + get() = binder.viewSwitchField + + init { + attrs?.let(::applyAttributes) + } + + fun setTitle(title: String) { + binder.viewSwitchTitle.text = title + } + + fun setSubtitle(subtitle: String?) { + binder.viewSwitchSubtitle.setTextOrHide(subtitle) + } + + fun setDividerVisible(dividerVisible: Boolean) { + if (dividerVisible) { + setBackgroundResource(R.drawable.divider_drawable) + } else { + background = null + } + } + + private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.Switch) { + val title = it.getString(R.styleable.Switch_title) + title?.let(::setTitle) + + val subtitle = it.getString(R.styleable.Switch_subtitle) + setSubtitle(subtitle) + + val dividerVisible = it.getBoolean(R.styleable.Switch_dividerVisible, DIVIDER_VISIBLE_DEFAULT) + setDividerVisible(dividerVisible) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/TableCellView.kt b/common/src/main/java/io/novafoundation/nova/common/view/TableCellView.kt new file mode 100644 index 0000000..fe3505b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/TableCellView.kt @@ -0,0 +1,338 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.constraintlayout.widget.Group +import coil.ImageLoader +import coil.load +import coil.transform.RoundedCornersTransformation +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewTableCellBinding +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.getAccentColor +import io.novafoundation.nova.common.utils.getEnum +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.images.ExtraImageRequestBuilding +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.postToSelf +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.useAttributes + +private const val DRAW_DIVIDER_DEFAULT = true + +open class TableCellView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + defStyleRes: Int = 0, +) : ConstraintLayout(context, attrs, defStyle, defStyleRes), TableItem { + + enum class FieldStyle { + PRIMARY, SECONDARY, LINK, POSITIVE + } + + companion object { + + fun createTableCellView(context: Context): TableCellView { + return TableCellView(context).apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + + valueSecondary.setTextColorRes(R.color.text_secondary) + title.setTextColorRes(R.color.text_secondary) + } + } + } + + private val binder = ViewTableCellBinding.inflate(inflater(), this) + + val title: TextView + get() = binder.tableCellTitle + + val valuePrimary: TextView + get() = binder.tableCellValuePrimary + + val valueSecondary: TextView + get() = binder.tableCellValueSecondary + + val image: ImageView + get() = binder.tableCellImage + + private val valueProgress: ProgressBar + get() = binder.tableCellValueProgress + + private val contentGroup: Group + get() = binder.tableCellContent + + private var shouldDrawDivider: Boolean = DRAW_DIVIDER_DEFAULT + + val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + setBackgroundResource(R.drawable.bg_primary_list_item) + minHeight = 44.dp(context) + + attrs?.let { applyAttributes(it) } + } + + override fun disableOwnDividers() { + setOwnDividerVisible(false) + } + + override fun shouldDrawDivider(): Boolean { + return shouldDrawDivider + } + + fun setTitle(titleRes: Int) { + binder.tableCellTitle.setText(titleRes) + } + + fun setTitle(title: String?) { + binder.tableCellTitle.text = title + } + + fun setImage(src: Drawable) { + image.setImageDrawable(src) + image.makeVisible() + } + + fun setImage(@DrawableRes src: Int, sizeDp: Int) { + image.load(src) { + size(sizeDp.dp(context)) + } + image.makeVisible() + } + + fun setOnValueClickListener(onClick: View.OnClickListener?) { + valuePrimary.setOnClickListener(onClick) + valueSecondary.setOnClickListener(onClick) + } + + fun loadImage( + url: String?, + @DrawableRes placeholderRes: Int? = null, + roundedCornersDp: Int? = 10 + ) { + loadImage(icon = url?.let(Icon::FromLink)) { + roundedCornersDp?.let { + transformations(RoundedCornersTransformation(roundedCornersDp.dpF(context))) + } + + placeholderRes?.let { + placeholder(it) + error(it) + } + } + } + + fun loadImage( + icon: Icon?, + extraBuilder: ExtraImageRequestBuilding = { } + ) { + if (icon == null) return + + image.makeVisible() + image.setIcon(icon, imageLoader, extraBuilder) + } + + fun showProgress() { + makeVisible() + + contentGroup.makeGone() + valueProgress.makeVisible() + } + + @Deprecated( + """ + TableCellView's own divider is deprecated and will be removed in the future. + To show dividers between multiple TableCellViews put them into TableView + """ + ) + fun setOwnDividerVisible(visible: Boolean) { + binder.tableCellValueDivider.setVisible(visible && shouldDrawDivider) + } + + fun setPrimaryValueEndIcon(@DrawableRes icon: Int?, @ColorRes tint: Int? = null) { + binder.tableCellValuePrimary.setDrawableEnd(icon, widthInDp = 16, paddingInDp = 8, tint = tint) + } + + fun setPrimaryValueStartIcon(@DrawableRes icon: Int?, @ColorRes tint: Int? = null) { + binder.tableCellValuePrimary.setDrawableStart(icon, widthInDp = 16, paddingInDp = 8, tint = tint) + } + + fun setPrimaryValueStyle(style: FieldStyle) { + when (style) { + FieldStyle.PRIMARY -> { + valuePrimary.setTextColorRes(R.color.text_primary) + } + + FieldStyle.LINK -> { + valuePrimary.setTextColor(context.getAccentColor()) + } + + FieldStyle.POSITIVE -> { + valuePrimary.setTextColorRes(R.color.text_positive) + } + + FieldStyle.SECONDARY -> { + valuePrimary.setTextColorRes(R.color.text_secondary) + } + } + } + + fun setTitleIconEnd(@DrawableRes icon: Int?, tintRes: Int?) { + binder.tableCellTitle.setDrawableEnd(icon, widthInDp = 16, paddingInDp = 4, tint = tintRes) + } + + fun setTitleIconStart(@DrawableRes icon: Int?, tintRes: Int?) { + binder.tableCellTitle.setDrawableStart(icon, widthInDp = 16, paddingInDp = 4, tint = tintRes) + } + + fun showValue(primary: CharSequence, secondary: CharSequence? = null) { + postToSelf { + contentGroup.makeVisible() + + valuePrimary.text = primary + valueSecondary.setTextOrHide(secondary) + + valueProgress.makeGone() + } + } + + fun setTitleEllipsisable(ellipsisable: Boolean) { + val constraintSet = ConstraintSet() + constraintSet.clone(this) + + if (ellipsisable) { + constraintSet.connect(binder.tableCellTitle.id, ConstraintSet.END, binder.barrier.id, ConstraintSet.START, 16.dp(context)) + constraintSet.clear(binder.tableCellValuePrimary.id, ConstraintSet.START) + constraintSet.constrainedWidth(binder.tableCellTitle.id, true) + constraintSet.setHorizontalBias(binder.tableCellTitle.id, 0f) + } else { + constraintSet.clear(binder.tableCellTitle.id, ConstraintSet.END) + constraintSet.connect(binder.tableCellValuePrimary.id, ConstraintSet.START, binder.tableCellTitle.id, ConstraintSet.END, 16.dp(context)) + constraintSet.constrainedWidth(binder.tableCellTitle.id, false) + } + constraintSet.applyTo(this) + } + + fun setShouldDrawDivider(shouldDrawDivider: Boolean) { + this.shouldDrawDivider = shouldDrawDivider + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.TableCellView) { typedArray -> + val titleText = typedArray.getString(R.styleable.TableCellView_title) + setTitle(titleText) + + val primaryValueText = typedArray.getString(R.styleable.TableCellView_primaryValue) + primaryValueText?.let { showValue(it) } + + val dividerVisible = typedArray.getBoolean(R.styleable.TableCellView_dividerVisible, true) + setOwnDividerVisible(dividerVisible) + + val primaryValueEndIcon = typedArray.getResourceIdOrNull(R.styleable.TableCellView_primaryValueEndIcon) + primaryValueEndIcon?.let { + val primaryValueIconTint = typedArray.getResourceIdOrNull(R.styleable.TableCellView_primaryValueIconTint) + + setPrimaryValueEndIcon(primaryValueEndIcon, primaryValueIconTint) + } + + val primaryValueStartIcon = typedArray.getResourceIdOrNull(R.styleable.TableCellView_primaryValueStartIcon) + primaryValueStartIcon?.let { + val primaryValueIconTint = typedArray.getResourceIdOrNull(R.styleable.TableCellView_primaryValueIconTint) + + setPrimaryValueStartIcon(primaryValueStartIcon, primaryValueIconTint) + } + + val primaryValueStyle = typedArray.getEnum(R.styleable.TableCellView_primaryValueStyle, default = FieldStyle.PRIMARY) + setPrimaryValueStyle(primaryValueStyle) + + val titleIconEnd = typedArray.getResourceIdOrNull(R.styleable.TableCellView_titleIcon) + titleIconEnd?.let { + val titleIconTint = typedArray.getResourceIdOrNull(R.styleable.TableCellView_titleIconTint) + + setTitleIconEnd(titleIconEnd, titleIconTint) + } + + val titleIconStart = typedArray.getResourceIdOrNull(R.styleable.TableCellView_titleIconStart) + titleIconStart?.let { + val titleIconStartTint = typedArray.getResourceIdOrNull(R.styleable.TableCellView_titleIconStartTint) + + setTitleIconStart(titleIconStart, titleIconStartTint) + } + + val titleTextAppearance = typedArray.getResourceIdOrNull(R.styleable.TableCellView_titleValueTextAppearance) + titleTextAppearance?.let(title::setTextAppearance) + + val primaryValueTextAppearance = typedArray.getResourceIdOrNull(R.styleable.TableCellView_primaryValueTextAppearance) + primaryValueTextAppearance?.let(valuePrimary::setTextAppearance) + + val secondaryValueTextAppearance = typedArray.getResourceIdOrNull(R.styleable.TableCellView_secondaryValueTextAppearance) + secondaryValueTextAppearance?.let(valueSecondary::setTextAppearance) + + val titleEllipsisable = typedArray.getBoolean(R.styleable.TableCellView_titleEllipsisable, false) + setTitleEllipsisable(titleEllipsisable) + + val shouldDrawDivider = typedArray.getBoolean(R.styleable.TableCellView_shouldDrawDivider, DRAW_DIVIDER_DEFAULT) + setShouldDrawDivider(shouldDrawDivider) + } +} + +fun TableCellView.showValueOrHide(primary: CharSequence?, secondary: CharSequence? = null) { + if (primary != null) { + showValue(primary, secondary) + } + + setVisible(primary != null) +} + +@Suppress("LiftReturnOrAssignment") +fun TableCellView.setExtraInfoAvailable(available: Boolean) { + if (available) { + setPrimaryValueEndIcon(R.drawable.ic_info) + isEnabled = true + } else { + setPrimaryValueEndIcon(null) + isEnabled = false + } +} + +fun TableCellView.showLoadingState(state: ExtendedLoadingState, showData: (T) -> Unit) { + when (state) { + is ExtendedLoadingState.Error -> showValue(context.getString(R.string.common_error_general_title)) + + is ExtendedLoadingState.Loaded -> if (state.data != null) { + showData(state.data) + } else { + makeGone() + } + + ExtendedLoadingState.Loading -> showProgress() + } +} + +fun TableCellView.showLoadingValue(state: ExtendedLoadingState) { + showLoadingState(state, ::showValue) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/TableItem.kt b/common/src/main/java/io/novafoundation/nova/common/view/TableItem.kt new file mode 100644 index 0000000..b436e35 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/TableItem.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.common.view + +interface TableItem { + + fun disableOwnDividers() + + // TODO this is only needed until TableView has its own divider + fun shouldDrawDivider(): Boolean +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/TableView.kt b/common/src/main/java/io/novafoundation/nova/common/view/TableView.kt new file mode 100644 index 0000000..94bedc5 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/TableView.kt @@ -0,0 +1,194 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.view.updateMarginsRelative +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setBackgroundColorRes +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.utils.useAttributes +import kotlin.math.roundToInt + +private const val SHOW_BACKGROUND_DEAULT = true +private const val DRAW_DIVIDERS_DEAULT = false + +private const val PADDING_HORIZONTAL_DP = 16 +private const val PADDING_VERTICAL_DP = 4 + +open class TableView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions { + + override val providedContext: Context = context + + val titleView: TextView = addTitleView() + + private val childHorizontalPadding = 16.dpF(context) + private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val dividerPath = Path() + + private var showBackground: Boolean = SHOW_BACKGROUND_DEAULT + + private var drawDividers: Boolean = DRAW_DIVIDERS_DEAULT + + private var childrenPadding = Rect() + + init { + orientation = VERTICAL + + if (attrs != null) { + attrs.let(::applyAttributes) + } else { + noAttrsInit() + } + + if (showBackground) { + background = getRoundedCornerDrawable(R.color.block_background) + } else { + setBackgroundColorRes(android.R.color.transparent) + } + + clipToOutline = true + + dividerPaint.apply { + color = context.getColor(R.color.divider) + style = Paint.Style.STROKE + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + setupTableChildrenAppearance() + } + + /* + We use setupTableChildrenAppearance here to support case when child view makes gone programmatically. + The we recalculate dividers + */ + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + + setupTableChildrenAppearance() + + dividerPath.reset() + children.forEachIndexed { idx, child -> + val isVisible = child.isVisible + val allowsToDrawDividers = shouldDrawDivider(child) + val hasNext = idx < childCount - 1 + + if (isVisible && allowsToDrawDividers && hasNext) { + dividerPath.moveTo(childHorizontalPadding, child.bottom.toFloat()) + dividerPath.lineTo(measuredWidth - childHorizontalPadding, child.bottom.toFloat()) + } + } + } + + private fun shouldDrawDivider(child: View) = when { + child is TableItem && !child.shouldDrawDivider() -> false + child is TableItem && child.shouldDrawDivider() -> true + else -> drawDividers + } + + fun invalidateChildrenVisibility() { + setupTableChildrenAppearance() + } + + fun setTitle(title: String?) { + titleView.setTextOrHide(title) + } + + private fun addTitleView(): TextView = TextView(context).also { title -> + title.setTextAppearance(R.style.TextAppearance_NovaFoundation_Regular_SubHeadline) + title.setTextColorRes(R.color.text_primary) + title.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).also { params -> + params.updateMarginsRelative(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp) + } + + addView(title, 0) + } + + private fun noAttrsInit() { + setTitle(null) + + childrenPadding.set( + PADDING_HORIZONTAL_DP.dp, + PADDING_VERTICAL_DP.dp, + PADDING_HORIZONTAL_DP.dp, + PADDING_VERTICAL_DP.dp, + ) + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.TableView) { + val title = it.getString(R.styleable.TableView_title) + setTitle(title) + + showBackground = it.getBoolean(R.styleable.TableView_showBackground, SHOW_BACKGROUND_DEAULT) + drawDividers = it.getBoolean(R.styleable.TableView_drawDividers, DRAW_DIVIDERS_DEAULT) + + childrenPadding.set( + it.getDimension(R.styleable.TableView_childrenPaddingStart, PADDING_HORIZONTAL_DP.dpF).roundToInt(), + it.getDimension(R.styleable.TableView_childrenPaddingTop, PADDING_VERTICAL_DP.dpF).roundToInt(), + it.getDimension(R.styleable.TableView_childrenPaddingEnd, PADDING_HORIZONTAL_DP.dpF).roundToInt(), + it.getDimension(R.styleable.TableView_childrenPaddingBottom, PADDING_VERTICAL_DP.dpF).roundToInt(), + ) + } + + private fun setupTableChildrenAppearance() { + val tableChildren = children.filter { it != titleView } + .filter { it.isVisible } + .toList() + + if (tableChildren.isEmpty()) { + makeGone() + return + } else { + makeVisible() + } + + tableChildren.forEach { + if (it is TableItem) { + it.disableOwnDividers() + } + + it.updatePadding(start = childrenPadding.left, end = childrenPadding.right) + } + + tableChildren.first().apply { + updatePadding(top = childrenPadding.top) + } + tableChildren.last().apply { + updatePadding(bottom = childrenPadding.bottom) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawPath(dividerPath, dividerPaint) + } + + private fun List.withoutLast(): List { + return if (size <= 1) { + listOf() + } else { + subList(0, size - 1) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/TapToViewContainer.kt b/common/src/main/java/io/novafoundation/nova/common/view/TapToViewContainer.kt new file mode 100644 index 0000000..61b9881 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/TapToViewContainer.kt @@ -0,0 +1,176 @@ +package io.novafoundation.nova.common.view + +import android.animation.LayoutTransition +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.os.Bundle +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.DrawableRes +import androidx.core.view.isVisible +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewTapToViewContainerBinding +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.getParcelableCompat +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.useAttributes + +import kotlin.math.roundToInt + +private const val SUPER_STATE = "super_state" +private const val REVEAL_CONTAINER_VISIBILITY = "reveal_container_visibility" + +open class TapToViewContainer @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val binder = ViewTapToViewContainerBinding.inflate(inflater(), this) + + private var onShowContentClicked: OnClickListener? = null + + private var isContentVisible = false + + private var cornerRadius: Float = 0f + private val clipPath = Path() + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = context.getColor(R.color.container_border) + strokeWidth = 2.dpF(context) // Actually it will be drawn as 1dp, since half of the stroke will be clipped by path + } + + init { + layoutTransition = LayoutTransition() // To animate this layout size change and children visibility + + binder.tapToViewContainer.setOnClickListener { + isContentVisible = true + onShowContentClicked?.onClick(this) + binder.tapToViewContainer.visibility = GONE + requestLayout() + } + + attrs?.let(::applyAttrs) + + setWillNotDraw(false) + } + + /** + * Support two states to expand hidden content when it becomes visible + */ + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (isContentVisible) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } else { + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = measureTapToViewContainerBackground(width) + + val heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + + super.onMeasure(widthMeasureSpec, heightSpec) + } + } + + /** + * Measure the height of the tap to view container background based on the its background aspect ratio + */ + private fun measureTapToViewContainerBackground(width: Int): Int { + val tapToViewBackgroundHeight = binder.tapToViewContainer.background?.intrinsicHeight ?: 0 + val tabToViewBackgroundWidth = binder.tapToViewContainer.background?.intrinsicWidth ?: 0 + val height = tapToViewBackgroundHeight * (width.toFloat() / tabToViewBackgroundWidth.toFloat()) + return height.roundToInt() + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + clipPath.reset() + clipPath.addRoundRect(0f, 0f, width.toFloat(), height.toFloat(), cornerRadius, cornerRadius, Path.Direction.CW) + } + + /** + * To add views to the container that we want to hide + */ + override fun addView(child: View, params: ViewGroup.LayoutParams) { + if (child.id == R.id.tapToViewContainer || child.id == R.id.tapToViewHiddenContent) { + super.addView(child, params) + } else { + binder.tapToViewHiddenContent.addView(child, params) + } + } + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + + return Bundle().apply { + putParcelable(SUPER_STATE, superState) + putBoolean(REVEAL_CONTAINER_VISIBILITY, isContentVisible) + } + } + + override fun onRestoreInstanceState(state: Parcelable) { + if (state is Bundle) { + super.onRestoreInstanceState(state.getParcelableCompat(SUPER_STATE)) + + isContentVisible = state.getBoolean(REVEAL_CONTAINER_VISIBILITY) + binder.tapToViewContainer.isVisible = !isContentVisible + } else { + super.onRestoreInstanceState(state) + } + } + + override fun draw(canvas: Canvas) { + canvas.clipPath(clipPath) + super.draw(canvas) + canvas.drawPath(clipPath, strokePaint) + } + + fun setTitleOrHide(title: String?) { + binder.tapToViewTitle.setTextOrHide(title) + } + + fun setSubtitleOrHide(subtitle: String?) { + binder.tapToViewSbutitle.setTextOrHide(subtitle) + } + + fun setTapToViewBackground(@DrawableRes background: Int) { + binder.tapToViewContainer.setBackgroundResource(background) + requestLayout() + } + + fun setCardCornerRadius(radius: Float) { + cornerRadius = radius + requestLayout() + } + + fun showContent(show: Boolean) { + isContentVisible = show + binder.tapToViewContainer.isVisible = !show + requestLayout() + } + + fun onContentShownListener(listener: OnClickListener) { + onShowContentClicked = listener + } + + private fun applyAttrs(attributeSet: AttributeSet) = context.useAttributes( + attributeSet, + R.styleable.TapToViewContainer + ) { + val title = it.getString(R.styleable.TapToViewContainer_title) + val subtitle = it.getString(R.styleable.TapToViewContainer_subtitle) + val tapToRevealBackground = it.getResourceIdOrNull(R.styleable.TapToViewContainer_tapToViewBackground) ?: android.R.color.black + cornerRadius = it.getDimension(R.styleable.TapToViewContainer_cornerRadius, 0f) + + setTitleOrHide(title) + setSubtitleOrHide(subtitle) + setTapToViewBackground(tapToRevealBackground) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/TipsInputField.kt b/common/src/main/java/io/novafoundation/nova/common/view/TipsInputField.kt new file mode 100644 index 0000000..2baa9a6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/TipsInputField.kt @@ -0,0 +1,166 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PointF +import android.text.method.DigitsKeyListener +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.view.isVisible +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewTipsInputBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.onTextChanged +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.getInputBackground +import io.novafoundation.nova.common.view.shape.getInputBackgroundError + +class TipsInputField @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context), ValidatableInputField { + + private val binder = ViewTipsInputBinding.inflate(inflater(), this) + + private var postfix: String? = null + private var postfixPadding = 4.dp + private var postfixPosition: PointF? = null + + private val textPaint: Paint + + val content: EditText + get() = binder.tipsInputField + + init { + orientation = VERTICAL + binder.tipsInputFieldContainer.setAddStatesFromChildren(true) + binder.tipsInputFieldContainer.background = context.getInputBackground() + + content.onTextChanged { + binder.tipsInputClear.isVisible = it.isNotEmpty() + binder.tipsInputContainer.isVisible = it.isEmpty() + measurePostfix(it) + invalidate() + } + + textPaint = Paint(content.paint).apply { + color = content.currentTextColor + } + + binder.tipsInputClear.setOnClickListener { content.text = null } + + attrs?.let(::applyAttributes) + setWillNotDraw(false) + } + + private fun measurePostfix(text: String) { + if (text.isEmpty() || postfix == null) { + postfixPosition = null + return + } + + val textWidth = content.paint.measureText(text, 0, text.length) + val textLeft = content.paddingLeft.toFloat() + postfixPosition = PointF( + content.x + textLeft + textWidth, + content.y + content.baseline.toFloat() + ) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (postfix != null && postfixPosition != null) { + canvas.drawText(postfix!!, postfixPosition!!.x + postfixPadding, postfixPosition!!.y, textPaint) + } + } + + fun setHint(hint: String) { + content.hint = hint + } + + fun clearTips() { + binder.tipsInputContainer.removeAllViews() + } + + fun addIconTip(@DrawableRes iconRes: Int, @ColorRes tintRes: Int? = null, onClick: OnClickListener): View { + val view = ImageView(context) + prepareCommonOptions(view, onClick) + view.setImageResource(iconRes) + view.setPadding(8.dp, 0.dp, 8.dp, 0.dp) + view.setImageTintRes(tintRes) + view.scaleType = ImageView.ScaleType.CENTER_INSIDE + binder.tipsInputContainer.addView(view) + return view + } + + fun addTextTip(text: String, @ColorRes tintRes: Int? = null, onClick: OnClickListener): View { + val view = TextView(context) + prepareCommonOptions(view, onClick) + view.text = text + view.setTextAppearance(R.style.TextAppearance_NovaFoundation_SemiBold_Footnote) + view.setPadding(12.dp, 0.dp, 12.dp, 0.dp) + tintRes?.let { view.setTextColor(context.getColor(it)) } + view.gravity = Gravity.CENTER + binder.tipsInputContainer.addView(view) + return view + } + + private fun prepareCommonOptions(view: View, onClick: OnClickListener) { + view.background = buttonBackground() + view.layoutParams = MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT).apply { + marginStart = 4.dp + marginEnd = 4.dp + } + view.setOnClickListener(onClick) + } + + override fun showError(error: String) { + binder.tipsInputError.makeVisible() + binder.tipsInputError.text = error + val color = context.getColor(R.color.text_negative) + content.setTextColor(color) + textPaint.color = color + binder.tipsInputFieldContainer.background = context.getInputBackgroundError() + invalidate() + } + + override fun hideError() { + binder.tipsInputError.makeGone() + val color = context.getColor(R.color.text_primary) + content.setTextColor(color) + textPaint.color = color + binder.tipsInputFieldContainer.background = context.getInputBackground() + invalidate() + } + + private fun buttonBackground() = addRipple(getRoundedCornerDrawable(R.color.button_background_secondary)) + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.TipsInputField) { + val hint = it.getString(R.styleable.TipsInputField_android_hint) + hint?.let { content.hint = hint } + + postfix = it.getString(R.styleable.TipsInputField_postfix) + + val digits = it.getString(R.styleable.TipsInputField_android_digits) + digits?.let { + content.keyListener = DigitsKeyListener.getInstance(it) + } + + val inputType = it.getInt(R.styleable.TipsInputField_android_inputType, EditorInfo.TYPE_NULL) + content.inputType = inputType + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/TitledSearchToolbar.kt b/common/src/main/java/io/novafoundation/nova/common/view/TitledSearchToolbar.kt new file mode 100644 index 0000000..17cdcb4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/TitledSearchToolbar.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewTitledSearchToolbarBinding +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setBackgroundColorRes + +class TitledSearchToolbar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binder = ViewTitledSearchToolbarBinding.inflate(inflater(), this) + + val toolbar: Toolbar + get() = binder.titledSearchToolbar + + val searchField: SearchView + get() = binder.titledSearchToolbarField + + init { + applyAttributes(attrs) + + setBackgroundColorRes(R.color.solid_navigation_background) + } + + private fun applyAttributes(attrs: AttributeSet?) { + attrs?.let { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.TitledSearchToolbar) + + val title = typedArray.getString(R.styleable.TitledSearchToolbar_titleText) + toolbar.setTitle(title) + + val hint = typedArray.getString(R.styleable.TitledSearchToolbar_android_hint) + searchField.setHint(hint) + typedArray.recycle() + } + } + + fun setTitle(@StringRes titleRes: Int) { + toolbar.setTitle(titleRes) + } + + fun setHomeButtonListener(listener: (View) -> Unit) { + toolbar.setHomeButtonListener(listener) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/Toolbar.kt b/common/src/main/java/io/novafoundation/nova/common/view/Toolbar.kt new file mode 100644 index 0000000..6a81cc7 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/Toolbar.kt @@ -0,0 +1,195 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewToolbarBinding +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setVisible + +class Toolbar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val binder = ViewToolbarBinding.inflate(inflater(), this, true) + + val rightActionText: TextView + get() = binder.rightText + + val titleView: TextView + get() = binder.titleTv + + init { + applyAttributes(attrs) + } + + private fun applyAttributes(attrs: AttributeSet?) { + attrs?.let { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.Toolbar) + + val title = typedArray.getString(R.styleable.Toolbar_titleText) + setTitle(title) + + val rightIcon = typedArray.getDrawable(R.styleable.Toolbar_iconRight) + rightIcon?.let { setRightIconDrawable(it) } + + val action = typedArray.getString(R.styleable.Toolbar_textRight) + action?.let { setTextRight(it) } + + val textRightVisible = typedArray.getBoolean(R.styleable.Toolbar_textRightVisible, true) + setRightTextVisible(textRightVisible) + + val homeButtonIcon = typedArray.getDrawable(R.styleable.Toolbar_homeButtonIcon) + homeButtonIcon?.let { setHomeButtonIcon(it) } + + val homeButtonVisible = typedArray.getBoolean(R.styleable.Toolbar_homeButtonVisible, true) + setHomeButtonVisibility(homeButtonVisible) + + val dividerVisible = typedArray.getBoolean(R.styleable.Toolbar_dividerVisible, true) + binder.toolbarDivider.setVisible(dividerVisible) + + val backgroundAttrDrawable = typedArray.getDrawable(R.styleable.Toolbar_contentBackground) ?: ColorDrawable( + context.getColor(R.color.secondary_screen_background) + ) + binder.toolbarContainer.background = backgroundAttrDrawable + + val textAppearance = typedArray.getResourceIdOrNull(R.styleable.Toolbar_titleTextAppearance) + textAppearance?.let(binder.titleTv::setTextAppearance) + + typedArray.recycle() + } + } + + fun setHomeButtonIcon(@DrawableRes iconRes: Int) { + binder.backImg.setImageResource(iconRes) + } + + fun setHomeButtonIcon(icon: Drawable) { + binder.backImg.setImageDrawable(icon) + } + + fun setTextRight(action: String) { + binder.rightImg.makeGone() + + binder.rightText.makeVisible() + binder.rightText.text = action + } + + fun setRightIconVisible(visible: Boolean) { + binder.rightImg.setVisible(visible) + } + + fun setRightTextVisible(visible: Boolean) { + binder.rightText.setVisible(visible) + } + + fun showProgress(visible: Boolean) { + binder.toolbarProgress.setVisible(visible) + binder.rightActionContainer.setVisible(!visible) + } + + fun setTitleIcon(drawable: Drawable?) { + binder.titleTv.compoundDrawablePadding = 8.dp(context) + binder.titleTv.setCompoundDrawables(drawable, null, null, null) + } + + fun setTitle(title: CharSequence?) { + binder.titleTv.text = title + } + + fun setTitle(@StringRes titleRes: Int) { + binder.titleTv.setText(titleRes) + } + + fun showHomeButton() { + binder.backImg.makeVisible() + } + + fun hideHomeButton() { + binder.backImg.makeGone() + } + + fun setHomeButtonListener(listener: (View) -> Unit) { + binder.backImg.setOnClickListener(listener) + } + + fun hideRightAction() { + binder.rightImg.makeGone() + binder.rightText.makeGone() + } + + fun setRightActionTint(@ColorRes colorRes: Int) { + binder.rightImg.setImageTintRes(colorRes) + binder.rightText.setTextColorRes(colorRes) + } + + fun setRightIconRes(@DrawableRes iconRes: Int) { + val drawable = ContextCompat.getDrawable(context, iconRes) + drawable?.let { setRightIconDrawable(it) } + } + + fun setRightIconDrawable(assetIconDrawable: Drawable) { + binder.rightText.makeGone() + + binder.rightImg.makeVisible() + binder.rightImg.setImageDrawable(assetIconDrawable) + } + + fun setRightActionClickListener(listener: (View) -> Unit) { + binder.rightImg.setOnClickListener(listener) + binder.rightText.setOnClickListener(listener) + } + + fun setHomeButtonVisibility(visible: Boolean) { + binder.backImg.visibility = if (visible) View.VISIBLE else View.GONE + } + + fun addCustomAction(@DrawableRes icon: Int, onClick: OnClickListener): ImageView { + val actionView = ImageView(context).apply { + setImageResource(icon) + + layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + val verticalMargin = 16.dp(context) + + val endMarginDp = if (binder.toolbarCustomActions.childCount == 0) 16 else 10 + val endMargin = endMarginDp.dp(context) + + val startMargin = 10.dp(context) + + setMargins(startMargin, verticalMargin, endMargin, verticalMargin) + } + + setOnClickListener(onClick) + } + + binder.toolbarCustomActions.makeVisible() + binder.toolbarCustomActions.addView(actionView, 0) + + return actionView + } + + fun setRightActionEnabled(enabled: Boolean) { + binder.rightImg.isEnabled = enabled + binder.rightText.isEnabled = enabled + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/TopCropImageView.kt b/common/src/main/java/io/novafoundation/nova/common/view/TopCropImageView.kt new file mode 100644 index 0000000..4c58c0a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/TopCropImageView.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView + +class TopCropImageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : AppCompatImageView(context, attrs, defStyle) { + + init { + scaleType = ScaleType.MATRIX + } + + override fun setImageDrawable(drawable: Drawable?) { + super.setImageDrawable(drawable) + updateMatrix(drawable) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + drawable?.let { updateMatrix(it) } + } + + private fun updateMatrix(drawable: Drawable?) { + if (drawable == null) return + + val dwidth = drawable.intrinsicWidth + val dheight = drawable.intrinsicHeight + val vwidth = width + val vheight = height + + if (vwidth == 0 || vheight == 0) return // Skip if size is not ready + + val matrix = imageMatrix + val scale: Float + var dx = 0f + val dy = 0f + + if (dwidth * vheight > vwidth * dheight) { + scale = vheight.toFloat() / dheight.toFloat() + dx = (vwidth - dwidth * scale) * 0.5f + } else { + scale = vwidth.toFloat() / dwidth.toFloat() + } + + matrix.setScale(scale, scale) + matrix.postTranslate(dx, dy) + + imageMatrix = matrix + invalidate() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/ValidatableInputField.kt b/common/src/main/java/io/novafoundation/nova/common/view/ValidatableInputField.kt new file mode 100644 index 0000000..bfbec9c --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/ValidatableInputField.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.view + +interface ValidatableInputField { + + fun showError(error: String) + + fun hideError() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/WarningCheckBox.kt b/common/src/main/java/io/novafoundation/nova/common/view/WarningCheckBox.kt new file mode 100644 index 0000000..74f4271 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/WarningCheckBox.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.CompoundButton +import android.widget.LinearLayout +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewWarningCheckboxBinding +import io.novafoundation.nova.common.utils.CheckableListener +import io.novafoundation.nova.common.utils.getColorOrNull +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setImageResourceOrHide +import io.novafoundation.nova.common.utils.setImageTint +import io.novafoundation.nova.common.utils.useAttributes + +class WarningCheckBox @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), CheckableListener { + + private val binder = ViewWarningCheckboxBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + setBackgroundResource(R.drawable.secondary_container_ripple_background) + addStatesFromChildren() + + attrs?.let(::applyAttributes) + } + + fun setText(text: CharSequence?) { + binder.warningCheckBoxCheckBox.text = text + } + + fun setIconTintColor(color: Int) { + binder.warningCheckBoxIcon.setImageTint(color) + } + + fun setIcon(@DrawableRes iconRes: Int?) { + binder.warningCheckBoxIcon.setImageResourceOrHide(iconRes) + } + + override fun setOnCheckedChangeListener(listener: CompoundButton.OnCheckedChangeListener) { + binder.warningCheckBoxCheckBox.setOnCheckedChangeListener(listener) + } + + private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.WarningCheckBox) { + val iconRes = it.getResourceIdOrNull(R.styleable.WarningCheckBox_android_icon) + val iconTint = it.getColorOrNull(R.styleable.WarningCheckBox_iconTint) + val text = it.getString(R.styleable.WarningCheckBox_android_text) + + setIcon(iconRes) + iconTint?.let(::setIconTintColor) + setText(text) + } + + override fun setChecked(checked: Boolean) { + binder.warningCheckBoxCheckBox.isChecked = checked + } + + override fun isChecked(): Boolean { + return binder.warningCheckBoxCheckBox.isChecked + } + + override fun toggle() { + binder.warningCheckBoxCheckBox.toggle() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/YourWalletsView.kt b/common/src/main/java/io/novafoundation/nova/common/view/YourWalletsView.kt new file mode 100644 index 0000000..d363802 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/YourWalletsView.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.common.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import androidx.core.view.setPadding +import io.novafoundation.nova.common.databinding.ViewYourWalletsBinding +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.getRippleMask +import io.novafoundation.nova.common.utils.getRoundedCornerDrawable +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.withRippleMask + +class YourWalletsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binder = ViewYourWalletsBinding.inflate(inflater(), this) + + init { + setPadding(4.dp) + background = getRoundedCornerDrawable(cornerSizeDp = 8).withRippleMask(getRippleMask(cornerSizeDp = 8)) + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/ActionNotAllowedBottomSheet.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/ActionNotAllowedBottomSheet.kt new file mode 100644 index 0000000..5aeb4d4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/ActionNotAllowedBottomSheet.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.common.view.bottomSheet + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.view.setPadding +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.BottomSheetActionNotAllowedBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.view.PrimaryButton + +open class ActionNotAllowedBottomSheet( + context: Context, + private val onSuccess: () -> Unit, +) : BaseBottomSheet(context, R.style.BottomSheetDialog), WithContextExtensions by WithContextExtensions(context) { + + override val binder = BottomSheetActionNotAllowedBinding.inflate(LayoutInflater.from(context)) + + val iconView: ImageView + get() = binder.actionNotAllowedImage + + val titleView: TextView + get() = binder.actionNotAllowedTitle + + val subtitleView: TextView + get() = binder.actionNotAllowedSubtitle + + val buttonView: PrimaryButton + get() = binder.actionNotAllowedOk + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setOnDismissListener { onSuccess() } + + binder.actionNotAllowedOk.setOnClickListener { + dismiss() + } + } + + // make it final so we will be able to call it from constructor without the risk of initialization conflict + final override fun setContentView(layoutResId: Int) { + super.setContentView(layoutResId) + } + + protected fun applySolidIconStyle(@DrawableRes src: Int, @ColorRes tint: Int? = R.color.icon_primary) = with(iconView) { + setPadding(12.dp) + setImageTintRes(tint) + setBackgroundResource(R.drawable.bg_icon_big) + setImageResource(src) + } + + protected fun applyDashedIconStyle(@DrawableRes src: Int, @ColorRes tint: Int? = R.color.icon_primary) = with(iconView) { + setPadding(12.dp) + setImageTintRes(tint) + setBackgroundResource(R.drawable.bg_icon_big_dashed) + setImageResource(src) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/BaseBottomSheet.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/BaseBottomSheet.kt new file mode 100644 index 0000000..aebb2ab --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/BaseBottomSheet.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.common.view.bottomSheet + +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetDialog +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.DialogExtensions +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import androidx.viewbinding.ViewBinding + +abstract class BaseBottomSheet( + context: Context, + style: Int = R.style.BottomSheetDialog, + private val onCancel: (() -> Unit)? = null, +) : BottomSheetDialog(context, style), + DialogExtensions, + CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Main) { + + protected abstract val binder: B + + private val backgroundAccessObserver: BackgroundAccessObserver + + final override val dialogInterface: DialogInterface + get() = this + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binder.root) + + window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + window?.decorView + ?.findViewById(R.id.touch_outside) + ?.isFocusable = false + + onCancel?.let { + setOnCancelListener { onCancel.invoke() } + } + } + + init { + backgroundAccessObserver = FeatureUtils.getCommonApi(context) + .backgroundAccessObserver() + + backgroundAccessObserver.requestAccessFlow + .onEach { dismiss() } + .launchIn(this) + } + + override fun dismiss() { + coroutineContext.cancel() + super.dismiss() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/LockBottomSheetBehavior.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/LockBottomSheetBehavior.kt new file mode 100644 index 0000000..6127cdf --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/LockBottomSheetBehavior.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.common.view.bottomSheet + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior + +class LockBottomSheetBehavior @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : BottomSheetBehavior(context, attrs) { + + companion object { + fun fromView(view: V): LockBottomSheetBehavior { + val params = view.layoutParams + if (params !is CoordinatorLayout.LayoutParams) { + throw IllegalArgumentException("The view is not a child of CoordinatorLayout") + } else { + val behavior = params.behavior + return behavior as? LockBottomSheetBehavior + ?: throw IllegalArgumentException("The view is not associated with BottomSheetBehavior") + } + } + } + + var isDraggable = true + + override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean { + return if (isDraggable) super.onInterceptTouchEvent(parent, child, event) else false + } + + override fun onTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean { + return if (isDraggable) super.onTouchEvent(parent, child, event) else false + } + + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + directTargetChild: View, + target: View, + axes: Int, + type: Int + ): Boolean { + return if (isDraggable) { + super.onStartNestedScroll( + coordinatorLayout, + child, + directTargetChild, + target, + axes, + type + ) + } else { + false + } + } + + override fun onNestedPreFling( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + velocityX: Float, + velocityY: Float + ): Boolean { + return if (isDraggable) super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY) else false + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/ActionBottomSheet.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/ActionBottomSheet.kt new file mode 100644 index 0000000..db75aab --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/ActionBottomSheet.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.view.bottomSheet.action + +import android.content.Context +import android.view.LayoutInflater +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.BottomSheetActionBinding +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet + +class ActionBottomSheet( + context: Context, + payload: ActionBottomSheetPayload +) : BaseBottomSheet(context, R.style.BottomSheetDialog) { + + override val binder: BottomSheetActionBinding = BottomSheetActionBinding.inflate(LayoutInflater.from(context)) + + init { + binder.setupView( + payload, + onPositiveButtonClicked = ::dismiss, // We handle click using payload.actionButtonPreferences.onClick + onNeutralButtonClicked = ::dismiss + ) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/ActionBottomSheetLauncher.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/ActionBottomSheetLauncher.kt new file mode 100644 index 0000000..eddf2ed --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/ActionBottomSheetLauncher.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.view.bottomSheet.action + +import androidx.annotation.DrawableRes +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.base.BaseScreenMixin +import io.novafoundation.nova.common.utils.Event + +interface ActionBottomSheetLauncherFactory { + + fun create(): ActionBottomSheetLauncher +} + +interface ActionBottomSheetLauncher { + + val showActionEvent: LiveData> + + fun launchBottomSheet( + @DrawableRes imageRes: Int, + title: CharSequence, + subtitle: CharSequence, + actionButtonPreferences: ButtonPreferences, + neutralButtonPreferences: ButtonPreferences? = null, + checkBoxPreferences: CheckBoxPreferences? = null + ) +} + +fun BaseScreenMixin<*>.observeActionBottomSheet(launcher: ActionBottomSheetLauncher) { + launcher.showActionEvent.observeEvent { payload -> + val dialog = ActionBottomSheet( + context = providedContext, + payload = payload + ) + + dialog.show() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/ActionBottomSheetPayload.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/ActionBottomSheetPayload.kt new file mode 100644 index 0000000..89e59b3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/ActionBottomSheetPayload.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.common.view.bottomSheet.action + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.PrimaryButton + +class ActionBottomSheetPayload( + @DrawableRes val imageRes: Int, + val title: CharSequence, + val subtitle: CharSequence, + val actionButtonPreferences: ButtonPreferences, + val neutralButtonPreferences: ButtonPreferences? = null, + val alertModel: AlertModel? = null, + val checkBoxPreferences: CheckBoxPreferences? = null +) + +class CheckBoxPreferences( + val text: CharSequence, + val onCheckChanged: ((Boolean) -> Unit)? = null +) { + companion object +} + +class ButtonPreferences( + val text: CharSequence, + val style: PrimaryButton.Appearance, + val onClick: (() -> Unit)? = null +) { + companion object +} + +fun ButtonPreferences.Companion.primary(text: CharSequence, onClick: (() -> Unit)? = null) = + ButtonPreferences(text, PrimaryButton.Appearance.PRIMARY, onClick) + +fun ButtonPreferences.Companion.secondary(text: CharSequence, onClick: (() -> Unit)? = null) = + ButtonPreferences(text, PrimaryButton.Appearance.SECONDARY, onClick) + +fun ButtonPreferences.Companion.negative(text: CharSequence, onClick: (() -> Unit)? = null) = + ButtonPreferences(text, PrimaryButton.Appearance.PRIMARY_NEGATIVE, onClick) diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/RealActionBottomSheetLauncher.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/RealActionBottomSheetLauncher.kt new file mode 100644 index 0000000..a9ca870 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/RealActionBottomSheetLauncher.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.view.bottomSheet.action + +import androidx.annotation.DrawableRes +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event + +class RealActionBottomSheetLauncherFactory : ActionBottomSheetLauncherFactory { + + override fun create(): ActionBottomSheetLauncher { + return RealActionBottomSheetLauncher() + } +} + +class RealActionBottomSheetLauncher : ActionBottomSheetLauncher { + + override val showActionEvent = MutableLiveData>() + + override fun launchBottomSheet( + @DrawableRes imageRes: Int, + title: CharSequence, + subtitle: CharSequence, + actionButtonPreferences: ButtonPreferences, + neutralButtonPreferences: ButtonPreferences?, + checkBoxPreferences: CheckBoxPreferences? + ) { + showActionEvent.value = ActionBottomSheetPayload( + imageRes = imageRes, + title = title, + subtitle = subtitle, + actionButtonPreferences = actionButtonPreferences, + neutralButtonPreferences = neutralButtonPreferences, + checkBoxPreferences = checkBoxPreferences + ).event() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/UtilsExt.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/UtilsExt.kt new file mode 100644 index 0000000..0dadbdc --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/UtilsExt.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.common.view.bottomSheet.action + +import android.widget.CheckBox +import android.widget.ImageView +import android.widget.TextView +import io.novafoundation.nova.common.databinding.BottomSheetActionBinding +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.common.view.PrimaryButton + +fun BottomSheetActionBinding.setupView( + payload: ActionBottomSheetPayload, + onPositiveButtonClicked: (() -> Unit)?, + onNeutralButtonClicked: (() -> Unit)? +) { + val iconView: ImageView = actionBottomSheetImage + val titleView: TextView = actionBottomSheetTitle + val subtitleView: TextView = actionBottomSheetSubtitle + val neutralButton: PrimaryButton = actionBottomSheetNeutralBtn + val actionButton: PrimaryButton = actionBottomSheetPositiveBtn + val alert: AlertView = actionBottomSheetAlert + val checkBox: CheckBox = actionBottomSheetCheckBox + + iconView.setImageResource(payload.imageRes) + titleView.text = payload.title + subtitleView.text = payload.subtitle + + actionButton.apply { + text = payload.actionButtonPreferences.text + setAppearance(payload.actionButtonPreferences.style) + setOnClickListener { + payload.actionButtonPreferences.onClick?.invoke() + onPositiveButtonClicked?.invoke() + } + } + + payload.neutralButtonPreferences?.let { preferences -> + neutralButton.apply { + text = preferences.text + setAppearance(preferences.style) + setOnClickListener { + preferences.onClick?.invoke() + onNeutralButtonClicked?.invoke() + } + } + } ?: neutralButton.makeGone() + + payload.alertModel?.let { + alert.apply { + makeVisible() + setStyle(it.style) + setMessage(it.message) + } + } ?: alert.makeGone() + + payload.checkBoxPreferences?.let { preferences -> + checkBox.text = preferences.text + checkBox.setOnCheckedChangeListener { _, isChecked -> + preferences.onCheckChanged?.invoke(isChecked) + } + } ?: checkBox.makeGone() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/fragment/ActionBottomSheetDialogFragment.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/fragment/ActionBottomSheetDialogFragment.kt new file mode 100644 index 0000000..ec03041 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/fragment/ActionBottomSheetDialogFragment.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.common.view.bottomSheet.action.fragment + +import android.view.LayoutInflater +import io.novafoundation.nova.common.base.BaseBottomSheetFragment +import io.novafoundation.nova.common.databinding.BottomSheetActionBinding +import io.novafoundation.nova.common.view.bottomSheet.action.setupView + +abstract class ActionBottomSheetDialogFragment : BaseBottomSheetFragment() { + + override fun createBinding() = BottomSheetActionBinding.inflate(LayoutInflater.from(context)) + + override fun initViews() { + binder.setupView( + viewModel.getPayload(), + onPositiveButtonClicked = viewModel::onActionClicked, + onNeutralButtonClicked = ::dismiss + ) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/fragment/ActionBottomSheetViewModel.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/fragment/ActionBottomSheetViewModel.kt new file mode 100644 index 0000000..e825b01 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/action/fragment/ActionBottomSheetViewModel.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.common.view.bottomSheet.action.fragment + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetPayload + +abstract class ActionBottomSheetViewModel() : BaseViewModel() { + + abstract fun getPayload(): ActionBottomSheetPayload + + abstract fun onActionClicked() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/description/DescriptionBottomSheet.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/description/DescriptionBottomSheet.kt new file mode 100644 index 0000000..bc79dd0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/description/DescriptionBottomSheet.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.common.view.bottomSheet.description + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.BottomSheetDescriptionBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet + +class DescriptionBottomSheet( + context: Context, + val titleRes: Int, + val descriptionRes: Int +) : BaseBottomSheet(context, R.style.BottomSheetDialog), WithContextExtensions by WithContextExtensions(context) { + + override val binder: BottomSheetDescriptionBinding = BottomSheetDescriptionBinding.inflate(LayoutInflater.from(context)) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binder.sheetDescriptionTitle.setText(titleRes) + binder.sheetDescriptionDetails.setText(descriptionRes) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/description/DescriptionBottomSheetLauncher.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/description/DescriptionBottomSheetLauncher.kt new file mode 100644 index 0000000..d6a1844 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/description/DescriptionBottomSheetLauncher.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.view.bottomSheet.description + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event + +interface DescriptionBottomSheetLauncher { + + val showDescriptionEvent: LiveData> + + fun launchDescriptionBottomSheet(titleRes: Int, descriptionRes: Int) +} + +fun DescriptionBottomSheetLauncher.launchNetworkFeeDescription() { + launchDescriptionBottomSheet(R.string.network_fee, R.string.swap_network_fee_description) +} + +class RealDescriptionBottomSheetLauncher : DescriptionBottomSheetLauncher { + + override val showDescriptionEvent = MutableLiveData>() + + override fun launchDescriptionBottomSheet(titleRes: Int, descriptionRes: Int) { + showDescriptionEvent.value = DescriptionModel(titleRes, descriptionRes).event() + } +} + +fun BaseFragment<*, *>.observeDescription(launcher: DescriptionBottomSheetLauncher) { + launcher.showDescriptionEvent.observeEvent { event -> + val dialog = DescriptionBottomSheet( + context = requireContext(), + titleRes = event.titleRes, + descriptionRes = event.descriptionRes + ) + + dialog.show() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/description/DescriptionModel.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/description/DescriptionModel.kt new file mode 100644 index 0000000..7f89bcd --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/description/DescriptionModel.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.common.view.bottomSheet.description + +class DescriptionModel(val titleRes: Int, val descriptionRes: Int) diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/list/dynamic/DynamicListBottomSheet.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/list/dynamic/DynamicListBottomSheet.kt new file mode 100644 index 0000000..0c24464 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/list/dynamic/DynamicListBottomSheet.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.common.view.bottomSheet.list.dynamic + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import androidx.annotation.CallSuper +import androidx.annotation.DrawableRes +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.BottomSheetDynamicListBinding +import io.novafoundation.nova.common.utils.DialogExtensions +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet + +typealias ClickHandler = (BaseDynamicListBottomSheet, T) -> Unit + +class ReferentialEqualityDiffCallBack : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { + return oldItem === newItem + } + + override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { + return true + } +} + +abstract class BaseDynamicListBottomSheet(context: Context) : + BaseBottomSheet(context, R.style.BottomSheetDialog), + WithContextExtensions by WithContextExtensions(context), + DialogExtensions { + + override val binder: BottomSheetDynamicListBinding = BottomSheetDynamicListBinding.inflate(LayoutInflater.from(context)) + + protected val container: LinearLayout + get() = binder.dynamicListSheetItemContainer + + protected val headerView: View + get() = binder.dynamicListSheetHeader + + protected val recyclerView: RecyclerView + get() = binder.dynamicListSheetContent + + final override fun setTitle(title: CharSequence?) { + binder.dynamicListSheetTitle.text = title + } + + fun setSubtitle(subtitle: CharSequence?) { + binder.dynamicListSheetSubtitle.setTextOrHide(subtitle) + } + + final override fun setTitle(titleId: Int) { + binder.dynamicListSheetTitle.setText(titleId) + } + + fun hideTitle() { + binder.dynamicListSheetTitle.setVisible(false) + } + + fun setupRightAction( + @DrawableRes drawableRes: Int, + onClickListener: View.OnClickListener + ) { + binder.dynamicListSheetRightAction.setImageResource(drawableRes) + binder.dynamicListSheetRightAction.setVisible(true) + binder.dynamicListSheetRightAction.setOnClickListener(onClickListener) + } +} + +abstract class DynamicListBottomSheet( + context: Context, + private val payload: Payload, + private val diffCallback: DiffUtil.ItemCallback, + private val onClicked: ClickHandler?, + private val onCancel: (() -> Unit)? = null, + private val dismissOnClick: Boolean = true +) : BaseDynamicListBottomSheet(context), DynamicListSheetAdapter.Handler { + + open class Payload(val data: List, val selected: T? = null) + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binder.dynamicListSheetContent.setHasFixedSize(true) + + val adapter = DynamicListSheetAdapter(payload.selected, this, diffCallback, holderCreator()) + binder.dynamicListSheetContent.adapter = adapter + + adapter.submitList(payload.data) + + setOnCancelListener { onCancel?.invoke() } + } + + abstract fun holderCreator(): HolderCreator + + override fun itemClicked(item: T) { + onClicked?.invoke(this, item) + + if (dismissOnClick) { + dismiss() + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/list/dynamic/DynamicListSheetAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/list/dynamic/DynamicListSheetAdapter.kt new file mode 100644 index 0000000..36625d0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/list/dynamic/DynamicListSheetAdapter.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.common.view.bottomSheet.list.dynamic + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import kotlinx.android.extensions.LayoutContainer + +typealias HolderCreator = (parentView: ViewGroup) -> DynamicListSheetAdapter.Holder + +class DynamicListSheetAdapter( + private val selected: T?, + private val handler: DynamicListBottomSheet, + private val diffCallback: DiffUtil.ItemCallback, + private val holderCreator: HolderCreator +) : ListAdapter>(diffCallback) { + + interface Handler { + fun itemClicked(item: T) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return holderCreator(parent) + } + + override fun onBindViewHolder(holder: Holder, position: Int) { + val item = getItem(position) + val isSelected = selected?.let { diffCallback.areItemsTheSame(it, item) } ?: false + + holder.bind(item, isSelected, handler) + } + + abstract class Holder(override val containerView: View) : RecyclerView.ViewHolder(containerView), LayoutContainer { + + open fun bind(item: T, isSelected: Boolean, handler: Handler) { + itemView.setOnClickListener { handler.itemClicked(item) } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/list/fixed/FixedListBottomSheet.kt b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/list/fixed/FixedListBottomSheet.kt new file mode 100644 index 0000000..7def87d --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/bottomSheet/list/fixed/FixedListBottomSheet.kt @@ -0,0 +1,192 @@ +package io.novafoundation.nova.common.view.bottomSheet.list.fixed + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.viewbinding.ViewBinding +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.databinding.ItemSheetDescriptiveActionBinding +import io.novafoundation.nova.common.databinding.ItemSheetIconicLabelBinding +import io.novafoundation.nova.common.databinding.ItemSheetSwitcherBinding +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet + +typealias ViewGetter = FixedListBottomSheet.ViewConfiguration.() -> V + +abstract class FixedListBottomSheet( + context: Context, + private val viewConfiguration: ViewConfiguration, + onCancel: (() -> Unit)? = null +) : BaseBottomSheet(context, onCancel = onCancel) { + + class ViewConfiguration( + val configurationBinder: B, + val container: ViewGetter, + val title: ViewGetter, + ) { + companion object { + fun default(context: Context) = ViewConfiguration( + configurationBinder = BottomSheeetFixedListBinding.inflate(LayoutInflater.from(context)), + container = { configurationBinder.fixedListSheetItemContainer }, + title = { configurationBinder.fixedListSheetTitle }, + ) + } + } + + override val binder: B = viewConfiguration.configurationBinder + + final override fun setContentView(layoutResId: Int) { + super.setContentView(layoutResId) + } + + override fun setTitle(@StringRes titleRes: Int) { + viewConfiguration.title(viewConfiguration).setText(titleRes) + } + + override fun setTitle(title: CharSequence?) { + viewConfiguration.title(viewConfiguration).setTextOrHide(title?.toString()) + } + + fun item(binder: IB, builder: (IB) -> Unit) { + val container = viewConfiguration.container(viewConfiguration) + + builder.invoke(binder) + + container.addView(binder.root) + } + + fun addItem(view: View) { + val container = viewConfiguration.container(viewConfiguration) + container.addView(view) + } + + fun item(view: T, builder: (T) -> Unit) { + builder.invoke(view) + + viewConfiguration.container(viewConfiguration).addView(view) + } + + fun getCommonPadding(): Int { + return 16.dp(context) + } +} + +fun FixedListBottomSheet<*>.textItem( + @DrawableRes iconRes: Int, + title: String, + showArrow: Boolean = false, + applyIconTint: Boolean = true, + onClick: (View) -> Unit, +) { + item(ItemSheetIconicLabelBinding.inflate(LayoutInflater.from(context))) { itemBinder -> + itemBinder.itemExternalActionContent.text = title + + val paddingInDp = 12 + + itemBinder.itemExternalActionContent.setDrawableStart( + drawableRes = iconRes, + widthInDp = 24, + tint = R.color.icon_primary.takeIf { applyIconTint }, + paddingInDp = 12 + ) + + if (showArrow) { + itemBinder.itemExternalActionContent.setDrawableEnd( + drawableRes = R.drawable.ic_chevron_right, + widthInDp = 24, + tint = R.color.icon_secondary, + paddingInDp = paddingInDp + ) + } + + itemBinder.root.setDismissingClickListener(onClick) + } +} + +fun FixedListBottomSheet<*>.textWithDescriptionItem( + title: String, + description: String, + @DrawableRes iconRes: Int, + enabled: Boolean = true, + showArrowWhenEnabled: Boolean = false, + onClick: (View) -> Unit, +) { + item(ItemSheetDescriptiveActionBinding.inflate(LayoutInflater.from(context))) { itemBinder -> + itemBinder.itemSheetDescriptiveActionTitle.text = title + itemBinder.itemSheetDescriptiveActionSubtitle.text = description + + itemBinder.root.isEnabled = enabled + + itemBinder.itemSheetDescriptiveActionIcon.setImageResource(iconRes) + + itemBinder.itemSheetDescriptiveActionArrow.setVisible(enabled && showArrowWhenEnabled) + + if (enabled) { + itemBinder.root.setDismissingClickListener(onClick) + } + } +} + +fun FixedListBottomSheet<*>.textWithDescriptionItem( + @StringRes titleRes: Int, + @StringRes descriptionRes: Int, + @DrawableRes iconRes: Int, + enabled: Boolean = true, + showArrowWhenEnabled: Boolean = false, + onClick: (View) -> Unit, +) { + textWithDescriptionItem( + title = context.getString(titleRes), + description = context.getString(descriptionRes), + iconRes = iconRes, + enabled = enabled, + showArrowWhenEnabled = showArrowWhenEnabled, + onClick = onClick + ) +} + +fun FixedListBottomSheet<*>.textItem( + @DrawableRes iconRes: Int, + @StringRes titleRes: Int, + showArrow: Boolean = false, + applyIconTint: Boolean = true, + onClick: (View) -> Unit +) { + textItem( + iconRes = iconRes, + title = context.getString(titleRes), + showArrow = showArrow, + applyIconTint = applyIconTint, + onClick = onClick + ) +} + +fun FixedListBottomSheet<*>.switcherItem( + @DrawableRes iconRes: Int, + @StringRes titleRes: Int, + initialState: Boolean, + onClick: (View) -> Unit +) { + item(ItemSheetSwitcherBinding.inflate(LayoutInflater.from(context))) { itemBinder -> + itemBinder.itemSheetSwitcher.setText(titleRes) + itemBinder.itemSheetSwitcher.isChecked = initialState + + itemBinder.itemSheetSwitcher.setDrawableStart( + drawableRes = iconRes, + widthInDp = 24, + tint = R.color.icon_primary, + paddingInDp = 12 + ) + + itemBinder.root.setDismissingClickListener(onClick) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/dialog/BaseAlertDialogBuilder.kt b/common/src/main/java/io/novafoundation/nova/common/view/dialog/BaseAlertDialogBuilder.kt new file mode 100644 index 0000000..d508d7e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/dialog/BaseAlertDialogBuilder.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.common.view.dialog + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class BaseAlertDialogBuilder(context: Context) : + AlertDialog.Builder(context), + CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Main) { + + private val backgroundAccessObserver: BackgroundAccessObserver + + init { + backgroundAccessObserver = FeatureUtils.getCommonApi(context).backgroundAccessObserver() + } + + override fun create(): AlertDialog { + val dialog = super.create() + backgroundAccessObserver.requestAccessFlow + .onEach { dialog.dismiss() } + .launchIn(this) + dialog.dismiss() + dialog.setOnDismissListener { coroutineContext.cancel() } + return dialog + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/dialog/DialogDecorators.kt b/common/src/main/java/io/novafoundation/nova/common/view/dialog/DialogDecorators.kt new file mode 100644 index 0000000..e4f3e88 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/dialog/DialogDecorators.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.common.view.dialog + +import android.content.Context +import android.view.ContextThemeWrapper +import androidx.annotation.StringRes +import androidx.annotation.StyleRes +import androidx.appcompat.app.AlertDialog +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.themed + +typealias DialogClickHandler = () -> Unit + +typealias DialogDecorator = AlertDialog.Builder.() -> Unit + +inline fun dialog( + context: Context, + @StyleRes customStyle: Int? = null, + decorator: DialogDecorator +) { + val styleOrDefault = customStyle ?: R.style.BlueDarkOverlay + val builder = BaseAlertDialogBuilder(ContextThemeWrapper(context, styleOrDefault)) + .setCancelable(false) + + builder.decorator() + + builder.show() +} + +fun infoDialog( + context: Context, + decorator: DialogDecorator +) { + dialog(context) { + setPositiveButton(R.string.common_ok, null) + + decorator() + } +} + +fun warningDialog( + context: Context, + onPositiveClick: DialogClickHandler, + @StringRes positiveTextRes: Int = R.string.common_continue, + @StringRes negativeTextRes: Int = R.string.common_cancel, + onNegativeClick: DialogClickHandler? = null, + @StyleRes styleRes: Int = R.style.AccentNegativeAlertDialogTheme_Reversed, + decorator: DialogDecorator? = null +) { + dialog(context.themed(styleRes)) { + setPositiveButton(positiveTextRes) { _, _ -> onPositiveClick() } + setNegativeButton(negativeTextRes) { _, _ -> onNegativeClick?.invoke() } + + decorator?.invoke(this) + } +} + +fun errorDialog( + context: Context, + onConfirm: DialogClickHandler? = null, + @StringRes confirmTextRes: Int = R.string.common_ok, + decorator: DialogDecorator? = null +) { + dialog(context) { + setTitle(R.string.common_error_general_title) + setPositiveButton(confirmTextRes) { _, _ -> onConfirm?.invoke() } + + decorator?.invoke(this) + } +} + +fun retryDialog( + context: Context, + onRetry: DialogClickHandler? = null, + onCancel: DialogClickHandler? = null, + decorator: DialogDecorator? = null +) { + dialog(context) { + setTitle(R.string.common_error_general_title) + setPositiveButton(R.string.common_retry) { _, _ -> onRetry?.invoke() } + setNegativeButton(R.string.common_ok) { _, _ -> onCancel?.invoke() } + + decorator?.invoke(this) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/TextInputView.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/TextInputView.kt new file mode 100644 index 0000000..be59bdd --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/TextInputView.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.common.view.input + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatEditText +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.view.shape.getInputBackground + +class TextInputView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.defaultTextInputStyle, +) : AppCompatEditText(context, attrs, defStyleAttr) { + + init { + background = context.getInputBackground() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/ListChooserBottomSheet.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/ListChooserBottomSheet.kt new file mode 100644 index 0000000..b2f1ef7 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/ListChooserBottomSheet.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.common.view.input.chooser + +import android.content.Context +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.databinding.ItemListChooserBinding +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ClickHandler +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.common.view.input.chooser.ListChooserMixin.Model + +class ListChooserBottomSheet( + context: Context, + private val payload: Payload>, + onClicked: ClickHandler>, + onCancel: (() -> Unit)? = null, +) : DynamicListBottomSheet>( + context = context, + payload = payload, + diffCallback = DiffCallback(), + onClicked = onClicked, + onCancel = onCancel +) { + + class Payload( + data: List, + selected: T? = null, + @StringRes val titleRes: Int, + ) : DynamicListBottomSheet.Payload(data, selected) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(payload.titleRes) + } + + override fun holderCreator(): HolderCreator> = { parentView -> + ListChooserViewHolder(ItemListChooserBinding.inflate(parentView.inflater(), parentView, false)) + } +} + +private class ListChooserViewHolder(private val binder: ItemListChooserBinding) : DynamicListSheetAdapter.Holder>(binder.root) { + + override fun bind( + item: Model, + isSelected: Boolean, + handler: DynamicListSheetAdapter.Handler> + ) { + super.bind(item, isSelected, handler) + + with(containerView) { + binder.itemListChooserLabel.text = item.display + binder.itemListChooserCheck.isChecked = isSelected + } + } +} + +private class DiffCallback : DiffUtil.ItemCallback>() { + override fun areItemsTheSame(oldItem: Model, newItem: Model): Boolean { + return oldItem.display == newItem.display + } + + override fun areContentsTheSame(oldItem: Model, newItem: Model): Boolean { + return true + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/ListChooserMixin.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/ListChooserMixin.kt new file mode 100644 index 0000000..ffe883b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/ListChooserMixin.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.common.view.input.chooser + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +typealias ListChooserDataProvider = suspend () -> ListChooserMixin.Data +typealias ListChooserAwaitable = ActionAwaitableMixin, E> + +interface ListChooserMixin { + + interface Factory { + + fun create( + coroutineScope: CoroutineScope, + dataProvider: ListChooserDataProvider, + @StringRes selectorTitleRes: Int, + ): ListChooserMixin + } + + class Data(val all: List>, val initial: Model) + + class Model(val value: T, val display: String) + + val chooseNewOption: ListChooserAwaitable> + + val selectedOption: Flow> + + fun selectorClicked() +} + +val ListChooserMixin.selectedValue: Flow + get() = selectedOption.map { it.value } + +inline fun > ListChooserMixin.Factory.createFromEnum( + coroutineScope: CoroutineScope, + noinline displayOf: suspend (E) -> String, + initial: E, + selectorTitleRes: Int +): ListChooserMixin { + val provider = suspend { + val all = enumValues().map { enumValue -> + ListChooserMixin.Model(enumValue, displayOf(enumValue)) + } + val initialModel = all.first { it.value == initial } + + ListChooserMixin.Data(all, initialModel) + } + + return create(coroutineScope, provider, selectorTitleRes) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/ListChooserView.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/ListChooserView.kt new file mode 100644 index 0000000..9bce26a --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/ListChooserView.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.common.view.input.chooser + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewListChooserBinding +import io.novafoundation.nova.common.utils.ensureSuffix +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes + +class ListChooserView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binder = ViewListChooserBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + + attrs?.let(::applyAttributes) + } + + fun setLabel(label: String) { + val suffixedLabel = label.ensureSuffix(":") + + binder.viewListChooserLabel.text = suffixedLabel + } + + fun setValueDisplay(value: String?) { + binder.viewListChooserValue.text = value + } + + private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.ListChooserView) { + val label = it.getString(R.styleable.ListChooserView_listChooserView_label) + label?.let(::setLabel) + + val initialValueDisplay = it.getString(R.styleable.ListChooserView_listChooserView_value) + initialValueDisplay?.let(::setValueDisplay) + } +} + +fun ListChooserView.setModel(model: ListChooserMixin.Model<*>) { + setValueDisplay(model.display) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/RealListChooserMixin.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/RealListChooserMixin.kt new file mode 100644 index 0000000..bec6f50 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/RealListChooserMixin.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.common.view.input.chooser + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.view.input.chooser.ListChooserMixin.Model +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +internal class RealListChooserMixinFactory( + private val actionAwaitableMixin: ActionAwaitableMixin.Factory +) : ListChooserMixin.Factory { + + override fun create( + coroutineScope: CoroutineScope, + dataProvider: ListChooserDataProvider, + selectorTitleRes: Int, + ): ListChooserMixin { + return RealListChooserMixin(coroutineScope, actionAwaitableMixin, dataProvider, selectorTitleRes) + } +} + +private class RealListChooserMixin( + coroutineScope: CoroutineScope, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val dataProvider: ListChooserDataProvider, + @StringRes private val selectorTitleRes: Int, +) : ListChooserMixin, CoroutineScope by coroutineScope { + + override val chooseNewOption = actionAwaitableMixinFactory.create>, Model>() + + override val selectedOption = singleReplaySharedFlow>() + + private val data = async(Dispatchers.Default) { dataProvider() } + + init { + launch { + updateSelectedOption(data.await().initial) + } + } + + override fun selectorClicked() { + launch { + val allOptions = data.await().all + val currentlySelected = selectedOption.first() + + val payload = ListChooserBottomSheet.Payload(allOptions, currentlySelected, selectorTitleRes) + + val newSelectedOption = chooseNewOption.awaitAction(payload) + updateSelectedOption(newSelectedOption) + } + } + + suspend fun updateSelectedOption(model: Model) { + selectedOption.emit(model) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/Ui.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/Ui.kt new file mode 100644 index 0000000..6889ead --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/chooser/Ui.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.common.view.input.chooser + +import io.novafoundation.nova.common.base.BaseFragmentMixin + +fun BaseFragmentMixin<*>.setupListChooserMixin( + listChooserMixin: ListChooserMixin, + view: ListChooserView +) { + listChooserMixin.selectedOption.observe(view::setModel) + + listChooserMixin.chooseNewOption.awaitableActionLiveData.observeEvent { action -> + ListChooserBottomSheet( + context = fragment.requireContext(), + payload = action.payload, + onCancel = action.onCancel, + onClicked = { _, item -> action.onSuccess(item) }, + ).show() + } + + view.setOnClickListener { + listChooserMixin.selectorClicked() + } +} + +fun BaseFragmentMixin<*>.setupListChooserMixinBottomSheet( + listChooserMixin: ListChooserMixin +) { + listChooserMixin.chooseNewOption.awaitableActionLiveData.observeEvent { action -> + ListChooserBottomSheet( + context = fragment.requireContext(), + payload = action.payload, + onCancel = action.onCancel, + onClicked = { _, item -> action.onSuccess(item) }, + ).show() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/seekbar/Seekbar.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/seekbar/Seekbar.kt new file mode 100644 index 0000000..647339b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/seekbar/Seekbar.kt @@ -0,0 +1,104 @@ +package io.novafoundation.nova.common.view.input.seekbar + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import android.widget.TextView +import androidx.core.view.children +import com.google.android.flexbox.FlexboxLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewSeekbarBinding +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes + +class Seekbar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binder = ViewSeekbarBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + } + + var progress: Int + get() = binder.seekbarInner.progress + set(value) { + binder.seekbarInner.progress = value + } + + init { + addTickLabelOnLayoutListener() + } + + fun setValues(values: SeekbarValues<*>) { + binder.seekbarInner.max = values.max + + binder.seekbarTickLabelsContainer.removeAllViews() + + values.values.forEach { seekbarValue -> + val tickLabel = tickLabelView(seekbarValue) + binder.seekbarTickLabelsContainer.addView(tickLabel) + } + } + + fun setOnProgressChangedListener(listener: (Int) -> Unit) { + binder.seekbarInner.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + listener(progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) {} + + override fun onStopTrackingTouch(seekBar: SeekBar) {} + }) + } + + private fun tickLabelView(value: SeekbarValue<*>): View { + return TextView(context).apply { + id = View.generateViewId() + setTextAppearance(R.style.TextAppearance_NovaFoundation_Regular_Caption1) + gravity = Gravity.CENTER_HORIZONTAL + + layoutParams = FlexboxLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + + text = value.label + setTextColorRes(value.labelColorRes) + } + } + + private fun addTickLabelOnLayoutListener() { + binder.seekbarTickLabelsContainer.addOnLayoutChangeListener { viewGroup, _, _, _, _, _, _, _, _ -> + require(viewGroup is ViewGroup) + + val seekbarSteps = binder.seekbarInner.max + val seekbarStartPadding = binder.seekbarInner.paddingStart + val seekbarEndPadding = binder.seekbarInner.paddingEnd + val slideZoneWidth = getParentMeasuredWidth() - (seekbarStartPadding + seekbarEndPadding) + val seekbarStepWidth = slideZoneWidth / seekbarSteps + + viewGroup.children.toList() + .forEachIndexed { index, view -> + val halfViewWidth = view.measuredWidth / 2 + val stepOffset = seekbarStepWidth * index + val left = seekbarStartPadding - halfViewWidth + stepOffset + val top = 0 + val right = seekbarStartPadding + halfViewWidth + stepOffset + val bottom = viewGroup.measuredHeight + + view.layout(left, top, right, bottom) + } + } + } + + private fun getParentMeasuredWidth(): Int { + return measuredWidth + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/seekbar/SeekbarValues.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/seekbar/SeekbarValues.kt new file mode 100644 index 0000000..2056e92 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/seekbar/SeekbarValues.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.view.input.seekbar + +class SeekbarValues(val values: List>) { + + fun valueAt(index: Int): T? { + return values.getOrNull(index)?.value + } + + val max: Int + get() = values.size - 1 +} + +class SeekbarValue(val value: T, val label: String, val labelColorRes: Int) diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/selector/DynamicSelectorBottomSheet.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/selector/DynamicSelectorBottomSheet.kt new file mode 100644 index 0000000..28fb893 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/selector/DynamicSelectorBottomSheet.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.common.view.input.selector + +import android.content.Context +import android.os.Bundle +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.databinding.ItemListSelectorBinding +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ClickHandler +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator + +class DynamicSelectorBottomSheet( + context: Context, + private val payload: Payload, + onClicked: ClickHandler, + onCancel: (() -> Unit)? = null, +) : DynamicListBottomSheet( + context = context, + payload = payload, + diffCallback = SelectorDiffCallback(), + onClicked = onClicked, + onCancel = onCancel +) { + + class Payload( + val titleRes: Int?, + val subtitle: String?, + data: List + ) : DynamicListBottomSheet.Payload(data) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (payload.titleRes == null) { + hideTitle() + } else { + setTitle(payload.titleRes) + } + + binder.dynamicListSheetSubtitle.setTextOrHide(payload.subtitle) + } + + override fun holderCreator(): HolderCreator = { parentView -> + SelectorViewHolder(ItemListSelectorBinding.inflate(parentView.inflater(), parentView, false)) + } +} + +private class SelectorViewHolder(private val binder: ItemListSelectorBinding) : DynamicListSheetAdapter.Holder(binder.root) { + + override fun bind( + item: ListSelectorMixin.Item, + isSelected: Boolean, + handler: DynamicListSheetAdapter.Handler + ) { + super.bind(item, isSelected, handler) + + with(containerView) { + binder.itemSelectorIcon.setImageResource(item.iconRes) + binder.itemSelectorIcon.setImageTintRes(item.iconTintRes) + binder.itemSelectorTitle.setText(item.titleRes) + binder.itemSelectorTitle.setTextColorRes(item.titleColorRes) + } + } +} + +private class SelectorDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ListSelectorMixin.Item, newItem: ListSelectorMixin.Item): Boolean { + return oldItem.titleRes == newItem.titleRes + } + + override fun areContentsTheSame(oldItem: ListSelectorMixin.Item, newItem: ListSelectorMixin.Item): Boolean { + return true + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/selector/ListSelectorMixin.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/selector/ListSelectorMixin.kt new file mode 100644 index 0000000..d13c096 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/selector/ListSelectorMixin.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.common.view.input.selector + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event +import kotlinx.coroutines.CoroutineScope + +interface ListSelectorMixin { + + interface Factory { + + fun create( + coroutineScope: CoroutineScope + ): ListSelectorMixin + } + + class Payload(@StringRes val titleRes: Int, val subtitle: String?, val items: List) + + class Item( + @DrawableRes val iconRes: Int, + @ColorRes val iconTintRes: Int, + @StringRes val titleRes: Int, + @ColorRes val titleColorRes: Int, + val onClick: () -> Unit + ) + + val actionLiveData: LiveData> + + fun showSelector(@StringRes titleRes: Int, items: List) + + fun showSelector(@StringRes titleRes: Int, subtitle: String?, items: List) +} + +class RealListSelectorMixinFactory : ListSelectorMixin.Factory { + + override fun create( + coroutineScope: CoroutineScope + ): RealListSelectorMixin { + return RealListSelectorMixin(coroutineScope) + } +} + +class RealListSelectorMixin( + coroutineScope: CoroutineScope +) : ListSelectorMixin, CoroutineScope by coroutineScope { + + override val actionLiveData: MutableLiveData> = MutableLiveData() + + override fun showSelector(titleRes: Int, items: List) { + showSelector(titleRes, subtitle = null, items) + } + + override fun showSelector(titleRes: Int, subtitle: String?, items: List) { + actionLiveData.value = Event(ListSelectorMixin.Payload(titleRes, subtitle, items)) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/input/selector/Ui.kt b/common/src/main/java/io/novafoundation/nova/common/view/input/selector/Ui.kt new file mode 100644 index 0000000..20bc738 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/input/selector/Ui.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.view.input.selector + +import io.novafoundation.nova.common.base.BaseFragmentMixin + +fun BaseFragmentMixin<*>.setupListSelectorMixin(listChooserMixin: ListSelectorMixin) { + listChooserMixin.actionLiveData.observeEvent { action -> + DynamicSelectorBottomSheet( + context = fragment.requireContext(), + payload = DynamicSelectorBottomSheet.Payload( + titleRes = action.titleRes, + subtitle = action.subtitle, + data = action.items + ), + onClicked = { _, item -> item.onClick() }, + ).show() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/BackingParallaxCardLruCache.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/BackingParallaxCardLruCache.kt new file mode 100644 index 0000000..451658e --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/BackingParallaxCardLruCache.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.common.view.parallaxCard + +import android.graphics.Bitmap +import android.util.LruCache + +private const val DISK_CACHE_SIZE = 1024 * 1024 * 5 // 5MB + +class BackingParallaxCardLruCache(cacheSizeInMb: Int) : LruCache(cacheSizeInMb * DISK_CACHE_SIZE) { + + override fun sizeOf(key: String, bitmap: Bitmap): Int { + return bitmap.byteCount + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/BitmapShaderHelper.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/BitmapShaderHelper.kt new file mode 100644 index 0000000..a116078 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/BitmapShaderHelper.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.common.view.parallaxCard + +import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Shader + +class BitmapShaderHelper(val bitmap: Bitmap) { + + val shader = BitmapShader( + bitmap, + Shader.TileMode.CLAMP, + Shader.TileMode.CLAMP + ) + val matrix = Matrix() + + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + init { + paint.shader = shader + paint.style = Paint.Style.FILL + setAlpha(1f) + } + + fun setAlpha(alpha: Float) { + paint.alpha = (alpha * 255).toInt() + } + + fun withHighlighting(withHighlighting: Boolean) { + paint.shader = if (withHighlighting) { + shader + } else { + null + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/BitmapWithRect.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/BitmapWithRect.kt new file mode 100644 index 0000000..5c8f328 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/BitmapWithRect.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.view.parallaxCard + +import android.graphics.Bitmap +import android.graphics.RectF + +class BitmapWithRect(val bitmap: Bitmap) { + val rect: RectF = RectF() +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/FrostedGlassLayer.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/FrostedGlassLayer.kt new file mode 100644 index 0000000..1c44c0b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/FrostedGlassLayer.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.common.view.parallaxCard + +import android.graphics.Paint +import android.graphics.Path +import android.view.View + +class FrostedGlassLayer { + val layers: MutableList = mutableListOf() +} + +class ViewWithLayoutParams(val view: View, layoutParams: ParallaxCardView.LayoutParams) { + val cardRadius = layoutParams.cardRadius + + val drawBorder = layoutParams.drawBorder + + val borderPath = Path() + + val cardPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = layoutParams.cardBackgroundColor!! + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxCardResourceManager.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxCardResourceManager.kt new file mode 100644 index 0000000..65d99e3 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxCardResourceManager.kt @@ -0,0 +1,135 @@ +package io.novafoundation.nova.common.view.parallaxCard + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Paint +import android.util.Log +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.data.network.TAG +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.forEachAsync +import io.novafoundation.nova.common.utils.mapIf +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ParallaxCardResourceManager( + context: Context, + val parallaxLayers: List, + val lruCache: BackingParallaxCardLruCache +) { + + private var callback: OnBakingPreparedCallback? = null + + private val dispatcher = Executors.newSingleThreadExecutor() + .asCoroutineDispatcher() + SupervisorJob() + + val coroutineScope = CoroutineScope(dispatcher) + + val cardBackgroundBitmap = BitmapFactory.decodeResource(context.resources, R.drawable.ic_parallax_card_background) + val cardHighlightBitmap: Bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.ic_card_background_highlight) + val cardBorderHighlightBitmap: Bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.ic_card_border_highlight) + val nestedViewBorderHighlightBitmap: Bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.ic_frosted_glass_highlight) + val parallaxHighlightBitmap: Bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.ic_pattern_highlight) + + // Shaders + var cardHighlightShader: BitmapShaderHelper = cardHighlightBitmap.toBitmapShaderHelper() + var cardBorderHighlightShader: BitmapShaderHelper = cardBorderHighlightBitmap.toBitmapShaderHelper() + .apply { + paint.strokeWidth = 2.dpF(context) + paint.style = Paint.Style.STROKE + } + var parallaxHighlightShader: BitmapShaderHelper = parallaxHighlightBitmap.toBitmapShaderHelper() + var nestedViewBorderHighlightShader: BitmapShaderHelper = nestedViewBorderHighlightBitmap.toBitmapShaderHelper() + .apply { + paint.strokeWidth = 2.dpF(context) + paint.style = Paint.Style.STROKE + } + + var isPrepared: Boolean = false + + init { + parallaxLayers.forEach { + val layer = getBitmapFromCache(it.id) + val blurredLayer = getBitmapFromCache(blurredKey(it.id)) + if (layer != null && blurredLayer != null) { + it.onReady(layer, blurredLayer) + } + } + + val layersToBake = parallaxLayers.filter { it.isNotReady() } + + if (layersToBake.isEmpty()) { + onPrepared() + } else { + coroutineScope.launch { + bakeParallaxLayers(context, layersToBake) + } + } + } + + private suspend fun bakeParallaxLayers(context: Context, layersToBake: List) = runCatching { + withContext(Dispatchers.Default) { + layersToBake.filter { it.isNotReady() } + .forEachAsync { + val layer = getBitmapFromCache(it.id) { + BitmapFactory.decodeResource(context.resources, it.bitmapId) + .mapIf(it.blurRadius > 0) { blurBitmap(it.blurRadius) } + .mapIf(it.withHighlighting) { convertToAlphaMask() } + } + val layerBlurred = getBitmapFromCache(blurredKey(it.id)) { + layer.downscale(0.25f) + .blurBitmap(2.dp(context)) + .mapIf(it.withHighlighting) { convertToAlphaMask() } + } + + it.onReady(layer, layerBlurred) + } + } + + onPrepared() + }.onFailure { + Log.d(TAG, it.message, it) + } + + fun onViewRemove() { + coroutineScope.cancel() + callback = null + } + + fun setBakingPreparedCallback(callback: OnBakingPreparedCallback) { + this.callback = callback + } + + private fun onPrepared() { + isPrepared = true + callback?.onBakingPrepared() + } + + private fun getBitmapFromCache(key: String): Bitmap? { + return lruCache.get(key) + } + + private fun getBitmapFromCache(key: String, bake: () -> Bitmap): Bitmap { + var bitmap = lruCache.get(key) + if (bitmap == null) { + bitmap = bake() + lruCache.put(key, bitmap) + } + + return bitmap + } + + private fun blurredKey(key: String) = "${key}_blurred" + + interface OnBakingPreparedCallback { + fun onBakingPrepared() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxCardUtils.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxCardUtils.kt new file mode 100644 index 0000000..2f8e9d9 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxCardUtils.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.common.view.parallaxCard + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Path +import android.graphics.RectF +import android.view.View +import com.google.android.renderscript.Toolkit + +fun Path.applyRoundRect(rectF: RectF, radius: Float) { + reset() + addRoundRect(rectF, radius, radius, Path.Direction.CW) + close() +} + +fun Path.applyRoundRect(view: View, radius: Float) { + reset() + addRoundRect( + view.left.toFloat() + view.translationX, + view.top.toFloat(), + view.right.toFloat() + view.translationX, + view.bottom.toFloat(), + radius, + radius, + Path.Direction.CW + ) + close() +} + +fun RectF.setCardBounds(view: View) { + set( + 0f, + 0f, + view.width.toFloat(), + view.height.toFloat() + ) +} + +fun Bitmap.convertToAlphaMask(): Bitmap { + val alphaMask = Bitmap.createBitmap( + width, + height, + Bitmap.Config.ALPHA_8 + ) + val canvas = Canvas(alphaMask) + canvas.drawBitmap(this, 0.0f, 0.0f, null) + return alphaMask +} + +fun Bitmap.blurBitmap(size: Int): Bitmap { + if (size == 0) return this + return Toolkit.blur(this, size) +} + +fun Bitmap.downscale(factor: Float): Bitmap { + val newBitmap = Bitmap.createBitmap( + (width * factor).toInt(), + (height * factor).toInt(), + config!! + ) + val canvas = Canvas(newBitmap) + canvas.clipRect(0f, 0f, width * factor, height * factor) + val matrix = Matrix() + matrix.preScale(factor, factor) + canvas.setMatrix(matrix) + canvas.drawBitmap(this, 0f, 0f, null) + return newBitmap +} + +fun Bitmap.toBitmapShaderHelper(): BitmapShaderHelper { + return BitmapShaderHelper(this) +} + +fun Bitmap.toBitmapWithRect(): BitmapWithRect { + return BitmapWithRect(this) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxCardView.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxCardView.kt new file mode 100644 index 0000000..e28a75b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxCardView.kt @@ -0,0 +1,392 @@ +package io.novafoundation.nova.common.view.parallaxCard + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Outline +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.graphics.withSave +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.getColorOrNull +import io.novafoundation.nova.common.view.parallaxCard.gyroscope.CardGyroscopeListener + +private const val DEVICE_ROTATION_ANGLE_RADIUS = 16f + +open class ParallaxCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), ParallaxCardResourceManager.OnBakingPreparedCallback { + + private val frostedGlassLayer: FrostedGlassLayer = FrostedGlassLayer() + private val cardRect = RectF() + private val cardPath = Path() + private val cardRadius = 12.dpF(context) + private var travelOffset = TravelVector(0f, 0f) + + // We use padding to support vertical highlight animation. + private var highlightPadding = 100.dpF(context) + private var verticalParallaxHighlightPadding = 100.dpF(context) + + private var parallaxHighlihtMaxTravel = TravelVector(0f, 0f) + private var cardHighlightMaxTravel = TravelVector(0f, 0f) + private val cardBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = context.getColor(R.color.container_card_actions_border) + strokeWidth = 2.dpF(context) + style = Paint.Style.STROKE + } + + private val parallaxLayers: List + + private val lruCache: BackingParallaxCardLruCache = FeatureUtils.getCommonApi(context).bakingParallaxCardCache() + + private val helper: ParallaxCardResourceManager + + private var gyroscopeListenerCallback: ((TravelVector) -> Unit)? = { rotation: TravelVector -> + travelOffset = rotation + + if (helper.isPrepared) { + updateHighlights() + updateFrostedGlassLayer() + } + + invalidate() + } + + private var gyroscopeListener: CardGyroscopeListener? = CardGyroscopeListener( + context, + TravelVector(DEVICE_ROTATION_ANGLE_RADIUS, DEVICE_ROTATION_ANGLE_RADIUS), + gyroscopeListenerCallback + ) + + init { + clipToPadding = false + + // Implement native shadow + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, width, height, cardRadius) + } + } + + parallaxLayers = listOf( + ParallaxLayer( + id = "parallax_1", + bitmapId = R.drawable.ic_big_star, + alpha = 1f, + withHighlighting = true, + blurRadius = 0, + travelVector = TravelVector((-7).dpF(context), (-3).dpF(context)) + ), + ParallaxLayer( + id = "parallax_2", + bitmapId = R.drawable.ic_middle_star, + alpha = 1f, + withHighlighting = true, + blurRadius = 2.dp(context), + travelVector = TravelVector((15).dpF(context), (8).dpF(context)) + ), + ParallaxLayer( + id = "parallax_3", + bitmapId = R.drawable.ic_small_star, + alpha = 1f, + withHighlighting = true, + blurRadius = 3.dp(context), + travelVector = TravelVector((25).dpF(context), (19).dpF(context)) + ) + ) + + helper = ParallaxCardResourceManager(context, parallaxLayers, lruCache) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + helper.setBakingPreparedCallback(this) + + setWillNotDraw(false) + + postDelayed({ + gyroscopeListener?.start() + }, 300) // Added small delay to avoid wrong parallax initial position + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + helper.onViewRemove() + gyroscopeListener?.cancel() + gyroscopeListener = null + gyroscopeListenerCallback = null + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + cardHighlightMaxTravel.set(-width.toFloat() / 2, -highlightPadding) + parallaxHighlihtMaxTravel.set(-width.toFloat() / 2, -height.toFloat() / 2) + + cardRect.setCardBounds(this) + cardPath.applyRoundRect(cardRect, cardRadius) + if (helper.isPrepared) { + updateLayers() + } + } + + override fun onBakingPrepared() { + startFadeAnimation() + updateLayers() + } + + private fun updateLayers() { + updateHighlights() + updateParallaxBitmapBounds() + updateFrostedGlassLayer() + } + + private fun startFadeAnimation() { + if (handler == null) return + + handler.post { + helper.cardHighlightShader.setAlpha(0f) + helper.cardBorderHighlightShader.setAlpha(0f) + helper.parallaxHighlightShader.setAlpha(0f) + helper.nestedViewBorderHighlightShader.setAlpha(0f) + + val fadeAnimator = ValueAnimator.ofFloat(0f, 1f).setDuration(1000L) + + fadeAnimator.addUpdateListener { + helper.cardHighlightShader.setAlpha(it.animatedFraction) + helper.cardBorderHighlightShader.setAlpha(it.animatedFraction) + helper.parallaxHighlightShader.setAlpha(it.animatedFraction) + helper.nestedViewBorderHighlightShader.setAlpha(it.animatedFraction) + invalidate() + } + + fadeAnimator.start() + } + } + + override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) { + if (params is LayoutParams && params.cardBackgroundColor != null) { + frostedGlassLayer.layers.add(ViewWithLayoutParams(child, params)) + } + + super.addView(child, index, params) + } + + override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams { + return LayoutParams(context, attrs) + } + + override fun generateDefaultLayoutParams(): ConstraintLayout.LayoutParams { + return LayoutParams( + ConstraintLayout.LayoutParams.WRAP_CONTENT, + ConstraintLayout.LayoutParams.WRAP_CONTENT + ) + } + + override fun generateLayoutParams(p: ViewGroup.LayoutParams): ViewGroup.LayoutParams { + return LayoutParams(p) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.clipPath(cardPath) + drawCard(canvas) + drawParallax(canvas) + drawFrostedGlassLayer(canvas) + } + + private fun drawCard(canvas: Canvas) { + // Card background and border + canvas.drawBitmap(helper.cardBackgroundBitmap, null, cardRect, null) + canvas.drawPath(cardPath, cardBorderPaint) + + // Highlights + if (helper.isPrepared) { + canvas.drawPath(cardPath, helper.cardBorderHighlightShader.paint) + canvas.drawRect(cardRect, helper.cardHighlightShader.paint) + } + } + + private fun drawParallax(canvas: Canvas) { + if (!helper.isPrepared) return + + helper.parallaxLayers.forEach { + it.layerBitmap?.drawParallaxLayerByRange(canvas, it) + } + } + + private fun drawBlurredParallax(canvas: Canvas) { + if (!helper.isPrepared) return + + helper.parallaxLayers.forEach { + it.blurredLayerBitmap?.drawParallaxLayerByRange(canvas, it) + } + } + + private fun drawFrostedGlassLayer(canvas: Canvas) { + frostedGlassLayer.layers.forEach { + canvas.withSave { + canvas.clipPath(it.borderPath) + + // Blurred parallax + if (helper.isPrepared) { + canvas.drawBitmap(helper.cardBackgroundBitmap, null, cardRect, null) + drawBlurredParallax(canvas) + } + + // Nested card background and border + canvas.drawPath(it.borderPath, it.cardPaint) + if (it.drawBorder) { + canvas.drawPath(it.borderPath, cardBorderPaint) + } + + // Highlight for border + if (helper.isPrepared && it.drawBorder) { + canvas.drawPath(it.borderPath, helper.nestedViewBorderHighlightShader.paint) + } + } + } + } + + private fun BitmapWithRect.drawParallaxLayerByRange(canvas: Canvas, parallaxLayer: ParallaxLayer) { + canvas.withSave { + travelOffsetInRange(parallaxLayer.travelVector) + helper.parallaxHighlightShader.setAlpha(parallaxLayer.alpha) + helper.parallaxHighlightShader.withHighlighting(parallaxLayer.withHighlighting) + canvas.drawBitmap(bitmap, null, rect, helper.parallaxHighlightShader.paint) + } + } + + private fun Canvas.travelOffsetInRange(travelVector: TravelVector) { + val pixelOffset = getTravelOffsetInRange(travelVector) + translate(pixelOffset.x, pixelOffset.y) + } + + private fun getTravelOffsetInRange(rangeRadius: TravelVector): TravelVector { + return travelOffset * rangeRadius + } + + private fun updateHighlights() { + val cardHighlightOffset = getTravelOffsetInRange(cardHighlightMaxTravel) + val parallaxHighlightOffset = getTravelOffsetInRange(parallaxHighlihtMaxTravel) + helper.cardHighlightShader.normalizeMatrix(ScaleType.CENTER_INSIDE, cardHighlightOffset, -highlightPadding, -highlightPadding) + helper.cardBorderHighlightShader.normalizeMatrix(ScaleType.CENTER_INSIDE, cardHighlightOffset, -highlightPadding, -highlightPadding) + helper.nestedViewBorderHighlightShader.normalizeMatrix(ScaleType.CENTER_INSIDE, cardHighlightOffset, -highlightPadding, -highlightPadding) + helper.parallaxHighlightShader.normalizeMatrix(ScaleType.CENTER, parallaxHighlightOffset, -verticalParallaxHighlightPadding) + } + + private fun updateParallaxBitmapBounds() { + val paddingOffset = 19.dpF(context) + helper.parallaxLayers.forEach { + it.layerBitmap?.normalizeBounds(ScaleType.CENTER, paddingVertical = -paddingOffset, paddingHorizontal = 0f) + it.blurredLayerBitmap?.normalizeBounds(ScaleType.CENTER, paddingVertical = -paddingOffset, paddingHorizontal = 0f) + } + } + + private fun updateFrostedGlassLayer() { + frostedGlassLayer.layers.forEach { + it.borderPath.applyRoundRect(it.view, it.cardRadius) + } + } + + private fun BitmapShaderHelper.normalizeMatrix( + scaleType: ScaleType, + shaderOffset: TravelVector, + paddingVertical: Float = 0f, + paddingHorizontal: Float = 0f + ) { + val scale = bitmap.calculateScale(scaleType, cardRect.width(), cardRect.height(), paddingVertical, paddingHorizontal) + + matrix.setScale(scale, scale) + matrix.postTranslate( + cardRect.left + (cardRect.width() - bitmap.width * scale) / 2f + shaderOffset.x, + cardRect.top + (cardRect.height() - bitmap.height * scale) / 2f + shaderOffset.y + ) + + shader.setLocalMatrix(matrix) + } + + private fun BitmapWithRect.normalizeBounds( + scaleType: ScaleType, + paddingVertical: Float = 0f, + paddingHorizontal: Float = 0f + ) { + val scale = bitmap.calculateScale(scaleType, cardRect.width(), cardRect.height(), paddingVertical, paddingHorizontal) + + rect.set( + 0f, + 0f, + bitmap.width * scale, + bitmap.height * scale + ) + rect.offset((cardRect.width() - rect.width()) / 2, (cardRect.height() - rect.height()) / 2) + } + + private fun Bitmap.calculateScale( + scaleType: ScaleType, + targetWidth: Float, + targetHeight: Float, + paddingVertical: Float, + paddingHorizontal: Float + ): Float { + val wScale = (targetWidth - paddingHorizontal * 2) / this@calculateScale.width + val hScale = (targetHeight - paddingVertical * 2) / this@calculateScale.height + return if (scaleType == ScaleType.CENTER) { + wScale.coerceAtLeast(hScale) + } else { + wScale.coerceAtMost(hScale) + } + } + + class LayoutParams : ConstraintLayout.LayoutParams { + val cardBackgroundColor: Int? + val cardBorderColor: Int? + val cardRadius: Float + val drawBorder: Boolean + + constructor(source: ViewGroup.LayoutParams) : super(source) { + cardBackgroundColor = null + cardBorderColor = null + cardRadius = 0f + drawBorder = false + } + + constructor(source: LayoutParams) : super(source) { + cardBackgroundColor = source.cardBackgroundColor + cardBorderColor = source.cardBorderColor + cardRadius = source.cardRadius + drawBorder = source.drawBorder + } + + constructor(width: Int, height: Int) : super(width, height) { + cardBackgroundColor = null + cardBorderColor = null + cardRadius = 0f + drawBorder = true + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + val a = context.obtainStyledAttributes(attrs, R.styleable.ParallaxCardView_Layout) + cardBackgroundColor = a.getColorOrNull( + R.styleable.ParallaxCardView_Layout_layout_cardBackgroundColor + ) + cardBorderColor = a.getColorOrNull(R.styleable.ParallaxCardView_Layout_layout_cardBorderColor) + cardRadius = a.getDimension(R.styleable.ParallaxCardView_Layout_layout_cardRadius, 0f) + drawBorder = a.getBoolean(R.styleable.ParallaxCardView_Layout_layout_drawBorder, true) + a.recycle() + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxLayer.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxLayer.kt new file mode 100644 index 0000000..8204486 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ParallaxLayer.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.common.view.parallaxCard + +import android.graphics.Bitmap +import androidx.annotation.DrawableRes + +class ParallaxLayer( + val id: String, + @DrawableRes val bitmapId: Int, + val alpha: Float, + val withHighlighting: Boolean, + val blurRadius: Int, + val travelVector: TravelVector +) { + var layerBitmap: BitmapWithRect? = null + var blurredLayerBitmap: BitmapWithRect? = null + + fun onReady( + bitmapWithRect: Bitmap, + blurredBitmapWithRect: Bitmap + ) { + this.layerBitmap = bitmapWithRect.toBitmapWithRect() + this.blurredLayerBitmap = blurredBitmapWithRect.toBitmapWithRect() + } + + fun isNotReady(): Boolean { + return layerBitmap == null || blurredLayerBitmap == null + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ScaleType.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ScaleType.kt new file mode 100644 index 0000000..59a5889 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/ScaleType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.common.view.parallaxCard + +enum class ScaleType { + CENTER_INSIDE, CENTER +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/TravelVector.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/TravelVector.kt new file mode 100644 index 0000000..0254cbf --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/TravelVector.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.common.view.parallaxCard + +class TravelVector(var x: Float, var y: Float) { + + fun set(x: Float, y: Float) { + this.x = x + this.y = y + } + + fun coerceIn(min: TravelVector, max: TravelVector): TravelVector { + return TravelVector(x.coerceIn(min.x, max.x), y.coerceIn(min.y, max.y)) + } + + operator fun plus(other: TravelVector): TravelVector { + return TravelVector(x + other.x, y + other.y) + } + + operator fun minus(other: TravelVector): TravelVector { + return TravelVector(x - other.x, y - other.y) + } + + operator fun div(other: TravelVector): TravelVector { + return TravelVector(x / other.x, y / other.y) + } + + operator fun times(other: Float): TravelVector { + return TravelVector(x * other, y * other) + } + + operator fun times(other: TravelVector): TravelVector { + return TravelVector(x * other.x, y * other.y) + } + + operator fun unaryMinus(): TravelVector { + return TravelVector(-x, -y) + } +} + +fun TravelVector.isZero(): Boolean { + return x == 0f && y == 0f +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/gyroscope/CardGyroscopeListener.kt b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/gyroscope/CardGyroscopeListener.kt new file mode 100644 index 0000000..a81c656 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/parallaxCard/gyroscope/CardGyroscopeListener.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.common.view.parallaxCard.gyroscope + +import android.animation.TimeAnimator +import android.animation.TimeAnimator.TimeListener +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import androidx.core.content.ContextCompat +import io.novafoundation.nova.common.view.parallaxCard.TravelVector +import kotlin.time.Duration.Companion.seconds + +private const val SECOND_IN_MILLIS = 1000f +private const val SENSOR_X_INDEX = 0 +private const val INTERPOLATION_VELOCITY = 0.3f +private const val SENSOR_Y_INDEX = 1 +private val SENSOR_FREQUENCY_MICROSECONDS = (1.seconds.inWholeMicroseconds / 60).toInt() + +class CardGyroscopeListener( + context: Context, + private val deviceRotationAngle: TravelVector, + private var callback: ((rotation: TravelVector) -> Unit)? +) { + + private val sensorManager = ContextCompat.getSystemService(context, SensorManager::class.java) + private val timeAnimator = TimeAnimator() + private var interpolatedRotation = TravelVector(0f, 0f) + private var deviceRotation = TravelVector(0f, 0f) + private var previousEventMillis: Long = 0 + + fun start() { + if (sensorManager != null) { + val gyroscopeSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + if (gyroscopeSensor != null) { + previousEventMillis = System.currentTimeMillis() + sensorManager.registerListener(sensorListener, gyroscopeSensor, SENSOR_FREQUENCY_MICROSECONDS) + timeAnimator.setTimeListener(timeListener) + timeAnimator.start() + } + } + } + + var sensorListener: SensorEventListener? = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + val currentMillis = System.currentTimeMillis() + val dT = (currentMillis - previousEventMillis) / SECOND_IN_MILLIS + val yRadians = event.values[SENSOR_Y_INDEX] * dT.toDouble() + val xRadians = event.values[SENSOR_X_INDEX] * dT.toDouble() + + // y and x are inverted due to the device orientation + deviceRotation += TravelVector( + x = Math.toDegrees(yRadians).toFloat(), + y = Math.toDegrees(xRadians).toFloat() + ) + + deviceRotation = deviceRotation.coerceIn(-deviceRotationAngle, deviceRotationAngle) + + previousEventMillis = currentMillis + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + } + + var timeListener: TimeListener? = object : TimeListener { + + override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) { + interpolatedRotation += (deviceRotation - interpolatedRotation) * INTERPOLATION_VELOCITY + callback?.invoke(interpolatedRotation / deviceRotationAngle) + } + } + + fun cancel() { + if (sensorManager != null) { + previousEventMillis = System.currentTimeMillis() + sensorManager.unregisterListener(sensorListener) + timeAnimator.setTimeListener(null) + timeAnimator.cancel() + + sensorListener = null + timeListener = null + callback = null + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/qr/QrViewFinderView.kt b/common/src/main/java/io/novafoundation/nova/common/view/qr/QrViewFinderView.kt new file mode 100644 index 0000000..1bc9563 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/qr/QrViewFinderView.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.common.view.qr + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import androidx.core.graphics.toRectF +import com.journeyapps.barcodescanner.CameraPreview +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.getDrawableCompat + +typealias OnFramingRectChangeListener = (Rect) -> Unit + +class QrViewFinderView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val maskColor = context.getColor(R.color.dim_background) + private val whiteColor = context.getColor(R.color.text_primary) + + // Cache the framingRect so that we can still draw it after the preview + // stopped. + var framingRect: Rect? = null + private set + + private val framingPath = Path() + + private var cameraPreview: CameraPreview? = null + + private val finderDrawable: Drawable = context.getDrawableCompat(R.drawable.ic_view_finder) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val cornerRadius = 25.dpF(context) + + private var frameChangeListener: OnFramingRectChangeListener? = null + + fun onFinderRectChanges(listener: (Rect) -> Unit) { + frameChangeListener = listener + } + + fun setCameraPreview(cameraPreview: CameraPreview) { + this.cameraPreview = cameraPreview + + cameraPreview.addStateListener(object : CameraPreview.StateListener { + override fun previewSized() { + refreshSizes() + invalidate() + } + + override fun previewStarted() {} + override fun previewStopped() {} + override fun cameraError(error: Exception?) {} + override fun cameraClosed() {} + }) + } + + override fun onDraw(canvas: Canvas) { + if (framingRect == null) { + return + } + + paint.color = maskColor + canvas.drawPath(framingPath, paint) + + paint.color = whiteColor + finderDrawable.bounds = framingRect!! + finderDrawable.draw(canvas) + } + + private fun refreshSizes() { + if (cameraPreview == null) { + return + } + + val framingRect = cameraPreview!!.framingRect + + if (framingRect != null) { + this.framingRect = framingRect + + frameChangeListener?.invoke(framingRect) + + framingPath.reset() + framingPath.addRoundRect(framingRect.toRectF(), cornerRadius, cornerRadius, Path.Direction.CW) + framingPath.fillType = Path.FillType.INVERSE_EVEN_ODD + framingPath.close() + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/recyclerview/LinearLayoutManagerFixed.kt b/common/src/main/java/io/novafoundation/nova/common/view/recyclerview/LinearLayoutManagerFixed.kt new file mode 100644 index 0000000..901e341 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/recyclerview/LinearLayoutManagerFixed.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.common.view.recyclerview + +import android.content.Context +import android.util.AttributeSet +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class LinearLayoutManagerFixed : LinearLayoutManager { + constructor( + context: Context + ) : super(context) + + constructor( + context: Context, + attrs: AttributeSet, + defStyleAttr: Int, + defStyleRes: Int + ) : super(context, attrs, defStyleAttr, defStyleRes) + + constructor( + context: Context, + @RecyclerView.Orientation orientation: Int, + reverseLayout: Boolean + ) : super(context, orientation, reverseLayout) + + override fun supportsPredictiveItemAnimations() = false +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/recyclerview/adapter/text/TextAdapter.kt b/common/src/main/java/io/novafoundation/nova/common/view/recyclerview/adapter/text/TextAdapter.kt new file mode 100644 index 0000000..a8a4aa1 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/recyclerview/adapter/text/TextAdapter.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.common.view.recyclerview.adapter.text + +import android.view.ViewGroup +import androidx.annotation.ColorRes +import androidx.annotation.StyleRes +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ItemTextBinding +import io.novafoundation.nova.common.utils.ViewSpace +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.updatePadding + +class TextAdapter( + private var text: String? = null, + @StyleRes private val styleRes: Int = R.style.TextAppearance_NovaFoundation_Bold_Title2, + @ColorRes private val textColor: Int? = R.color.text_primary, + private val paddingInDp: ViewSpace? = null, + private var isShown: Boolean = true +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TextViewHolder { + return TextViewHolder(ItemTextBinding.inflate(parent.inflater(), parent, false)) + } + + override fun getItemCount(): Int { + return 1 + } + + override fun onBindViewHolder(holder: TextViewHolder, position: Int) { + holder.bind(text, styleRes, textColor, paddingInDp, isShown) + } + + fun setText(text: String) { + this.text = text + notifyItemChanged(0) + } + + fun show(show: Boolean) { + isShown = show + notifyItemChanged(0) + } +} + +class TextViewHolder(private val binder: ItemTextBinding) : ViewHolder(binder.root) { + + fun bind(text: String?, styleRes: Int, textColor: Int?, paddingInDp: ViewSpace?, isShown: Boolean) { + binder.itemText.isVisible = isShown + binder.itemText.text = text + binder.itemText.setTextAppearance(styleRes) + textColor?.let { binder.itemText.setTextColor(itemView.context.getColor(it)) } + paddingInDp?.let { itemView.updatePadding(it.dp(itemView.context)) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/recyclerview/item/OperationListItem.kt b/common/src/main/java/io/novafoundation/nova/common/view/recyclerview/item/OperationListItem.kt new file mode 100644 index 0000000..5133387 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/recyclerview/item/OperationListItem.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.common.view.recyclerview.item + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.setPadding +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ItemOperationListItemBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setImageTint +import io.novafoundation.nova.common.utils.setImageTintRes + +class OperationListItem @kotlin.jvm.JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + enum class IconStyle { + BORDERED_CIRCLE, DEFAULT + } + + private val binder = ItemOperationListItemBinding.inflate(inflater(), this) + + val icon: ImageView + get() = binder.itemOperationIcon + + val header: TextView + get() = binder.itemOperationHeader + + val subHeader: TextView + get() = binder.itemOperationSubHeader + + val valuePrimary: TextView + get() = binder.itemOperationValuePrimary + + val valueSecondary: TextView + get() = binder.itemOperationValueSecondary + + val status: ImageView + get() = binder.itemOperationValueStatus + + init { + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + + setBackgroundResource(R.drawable.bg_primary_list_item) + } + + fun setIconStyle(iconStyle: IconStyle) { + when (iconStyle) { + IconStyle.BORDERED_CIRCLE -> { + icon.setBackgroundResource(R.drawable.bg_icon_container_on_color) + icon.setImageTintRes(R.color.icon_secondary) + } + IconStyle.DEFAULT -> { + icon.setPadding(0) + icon.background = null + icon.setImageTint(null) + } + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/section/SectionView.kt b/common/src/main/java/io/novafoundation/nova/common/view/section/SectionView.kt new file mode 100644 index 0000000..c75a071 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/section/SectionView.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.common.view.section + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable + +abstract class SectionView( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions { + + override val providedContext: Context = context + + init { + background = with(context) { + addRipple(getRoundedCornerDrawable(R.color.block_background)) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsGroupHeaderView.kt b/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsGroupHeaderView.kt new file mode 100644 index 0000000..56750b6 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsGroupHeaderView.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.common.view.settings + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import io.novafoundation.nova.common.R + +class SettingsGroupHeaderView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : TextView(context, attrs, defStyleAttr, R.style.Widget_Nova_Text_SettingsHeader) diff --git a/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsGroupView.kt b/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsGroupView.kt new file mode 100644 index 0000000..a90dbfa --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsGroupView.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.common.view.settings + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewOutlineProvider +import android.widget.LinearLayout +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.getDrawableCompat +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable + +class SettingsGroupView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + init { + outlineProvider = ViewOutlineProvider.BACKGROUND + clipToOutline = true + + orientation = VERTICAL + + background = context.getRoundedCornerDrawable(fillColorRes = R.color.block_background) + + dividerDrawable = context.getDrawableCompat(R.drawable.divider_decoration) + showDividers = SHOW_DIVIDER_MIDDLE + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsItemView.kt b/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsItemView.kt new file mode 100644 index 0000000..177abf8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsItemView.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.common.view.settings + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.core.view.isVisible +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewSettingsItemBinding +import io.novafoundation.nova.common.utils.getDrawableCompat +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes + +class SettingsItemView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binder = ViewSettingsItemBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + background = context.getDrawableCompat(R.drawable.bg_primary_list_item) + + attrs?.let(::applyAttributes) + } + + fun setTitle(title: String?) { + binder.settingsItemTitle.text = title + } + + fun setValue(value: String?) { + binder.settingsItemValue.text = value + } + + fun setIcon(icon: Drawable?) { + binder.settingsItemIcon.isVisible = icon != null + binder.settingsItemIcon.setImageDrawable(icon) + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.SettingsItemView) { + val title = it.getString(R.styleable.SettingsItemView_title) + setTitle(title) + + val value = it.getString(R.styleable.SettingsItemView_settingValue) + setValue(value) + + val icon = it.getDrawable(R.styleable.SettingsItemView_icon) + setIcon(icon) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsSwitcherView.kt b/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsSwitcherView.kt new file mode 100644 index 0000000..505f29f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/settings/SettingsSwitcherView.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.common.view.settings + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.databinding.ViewSettingsSwitcherBinding +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setCompoundDrawableTintRes +import io.novafoundation.nova.common.utils.useAttributes + +class SettingsSwitcherView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr) { + + private val binder = ViewSettingsSwitcherBinding.inflate(inflater(), this) + + init { + attrs?.let(::applyAttributes) + } + + fun setTitle(title: String?) { + binder.settingsSwitcher.text = title + } + + fun setIconTintColor(@ColorRes tintRes: Int?) { + binder.settingsSwitcher.setCompoundDrawableTintRes(tintRes) + } + + fun setIcon(icon: Drawable?) { + // Set icon size 24 dp + val iconSize = 24.dp + icon?.setBounds(0, 0, iconSize, iconSize) + binder.settingsSwitcher.setCompoundDrawables(icon, null, null, null) + } + + fun setChecked(checked: Boolean) { + binder.settingsSwitcher.isChecked = checked + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + binder.settingsSwitcher.isEnabled = enabled + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.SettingsSwitcherView) { + val title = it.getString(R.styleable.SettingsSwitcherView_title) + setTitle(title) + + val icon = it.getDrawable(R.styleable.SettingsSwitcherView_icon) + setIcon(icon) + + val textColorStateList = it.getColorStateList(R.styleable.SettingsSwitcherView_switcherTextColor) + textColorStateList?.let { binder.settingsSwitcher.setTextColor(it) } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/shape/Gradient.kt b/common/src/main/java/io/novafoundation/nova/common/view/shape/Gradient.kt new file mode 100644 index 0000000..f06ec13 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/shape/Gradient.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.common.view.shape + +import android.content.Context +import android.graphics.LinearGradient +import android.graphics.Shader +import android.graphics.drawable.Drawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.RoundRectShape +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.quantize + +fun Context.gradientDrawable( + colors: IntArray, + offsets: FloatArray, + angle: Int, + cornerRadiusDp: Int +): Drawable { + val gradientFactory: ShapeDrawable.ShaderFactory = object : ShapeDrawable.ShaderFactory() { + override fun resize(width: Int, height: Int): Shader { + val (x0, y0, x1, y1) = gradientDirectionCoordinates(angle, width.toFloat(), height.toFloat()) + + return LinearGradient(x0, y0, x1, y1, colors, offsets, Shader.TileMode.CLAMP) + } + } + val roundCorners = FloatArray(8) { cornerRadiusDp.dpF(this) } + + return ShapeDrawable(RoundRectShape(roundCorners, null, null)).apply { + shaderFactory = gradientFactory + } +} + +private fun gradientDirectionCoordinates( + angle: Int, + width: Float, + height: Float +): List { + val x0: Float + val x1: Float + val y0: Float + val y1: Float + + // Adopted from GradientDrawable since it does not allow to supply positions on pre-Q (<29) devices + when (angle.quantize(45)) { + 270 -> { + x0 = 0f + y0 = 0f + x1 = x0 + y1 = height + } + 225 -> { + x0 = width + y0 = 0f + x1 = 0f + y1 = height + } + 180 -> { + x0 = width + y0 = 0f + x1 = 0f + y1 = y0 + } + 135 -> { + x0 = width + y0 = height + x1 = 0f + y1 = 0f + } + 90 -> { + x0 = 0f + y0 = height + x1 = x0 + y1 = 0f + } + 45 -> { + x0 = 0f + y0 = height + x1 = width + y1 = 0f + } + 0 -> { + x0 = 0f + y0 = 0f + x1 = width + y1 = y0 + } + else -> { + x0 = 0f + y0 = 0f + x1 = width + y1 = height + } + } + + return listOf(x0, y0, x1, y1) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/shape/ShapeProvider.kt b/common/src/main/java/io/novafoundation/nova/common/view/shape/ShapeProvider.kt new file mode 100644 index 0000000..d1544d0 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/shape/ShapeProvider.kt @@ -0,0 +1,231 @@ +package io.novafoundation.nova.common.view.shape + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.StateListDrawable +import android.util.StateSet +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import io.novafoundation.nova.common.R + +const val DEFAULT_CORNER_RADIUS = 12 + +fun Int.toColorStateList() = ColorStateList.valueOf(this) + +fun Context.getRippleMask(cornerSizeDp: Int = DEFAULT_CORNER_RADIUS): Drawable { + return getRoundedCornerDrawableFromColors(Color.WHITE, null, cornerSizeDp) +} + +fun Context.getMaskedRipple( + cornerSizeInDp: Int, + @ColorInt rippleColor: Int = getColor(R.color.cell_background_pressed) +): Drawable { + return RippleDrawable(rippleColor.toColorStateList(), null, getRippleMask(cornerSizeInDp)) +} + +fun Context.addRipple( + drawable: Drawable? = null, + mask: Drawable? = getRippleMask(), + @ColorInt rippleColor: Int = getColor(R.color.cell_background_pressed) +): Drawable { + return RippleDrawable(rippleColor.toColorStateList(), drawable, mask) +} + +fun Context.getCornersStateDrawable( + disabledDrawable: Drawable = getDisabledDrawable(), + focusedDrawable: Drawable = getFocusedDrawable(), + idleDrawable: Drawable = getIdleDrawable(), +): Drawable { + return StateListDrawable().apply { + addState(intArrayOf(-android.R.attr.state_enabled), disabledDrawable) + addState(intArrayOf(android.R.attr.state_focused), focusedDrawable) + addState(StateSet.WILD_CARD, idleDrawable) + } +} + +fun Context.getCornersCheckableDrawable( + checked: Drawable, + unchecked: Drawable +): Drawable { + return StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_checked), checked) + addState(StateSet.WILD_CARD, unchecked) + } +} + +fun Context.getInputBackground() = getCornersStateDrawable( + focusedDrawable = getRoundedCornerDrawableFromColors( + fillColor = getColor(R.color.input_background), + strokeColor = getColor(R.color.active_border), + strokeSizeInDp = 1f + ), + idleDrawable = getRoundedCornerDrawable(R.color.input_background), + disabledDrawable = getRoundedCornerDrawableFromColors( + fillColor = getColor(R.color.input_background), + strokeColor = null, + strokeSizeInDp = 0f + ) +) + +fun Context.getInputBackgroundError(): Drawable { + val background = getRoundedCornerDrawable( + fillColorRes = R.color.input_background, + strokeColorRes = R.color.error_border, + strokeSizeInDp = 1f + ) + + return getCornersStateDrawable( + focusedDrawable = background, + idleDrawable = background + ) +} + +fun Context.getFocusedDrawable(): Drawable = getRoundedCornerDrawable(strokeColorRes = R.color.active_border) +fun Context.getDisabledDrawable(): Drawable = getRoundedCornerDrawable(fillColorRes = R.color.input_background) +fun Context.getIdleDrawable(): Drawable = getRoundedCornerDrawable(strokeColorRes = R.color.container_border) +fun Context.getBlockDrawable(@ColorRes strokeColorRes: Int? = null): Drawable { + return getRoundedCornerDrawable(fillColorRes = R.color.block_background, strokeColorRes = strokeColorRes) +} + +fun Context.getRoundedCornerDrawableWithRipple( + @ColorRes fillColorRes: Int? = R.color.secondary_screen_background, + @ColorRes strokeColorRes: Int? = null, + cornerSizeInDp: Int = DEFAULT_CORNER_RADIUS, + strokeSizeInDp: Float = 1.0f, + @ColorInt rippleColor: Int = getColor(R.color.cell_background_pressed) +): Drawable { + val ripple = getRippleMask(cornerSizeInDp) + val drawable = getRoundedCornerDrawable(fillColorRes, strokeColorRes, cornerSizeInDp, strokeSizeInDp) + + return addRipple(drawable, ripple, rippleColor) +} + +fun Context.getRoundedCornerDrawable( + @ColorRes fillColorRes: Int? = R.color.secondary_screen_background, + @ColorRes strokeColorRes: Int? = null, + cornerSizeInDp: Int = DEFAULT_CORNER_RADIUS, + strokeSizeInDp: Float = 1.0f, +): Drawable { + val fillColor = fillColorRes?.let(this::getColor) + val strokeColor = strokeColorRes?.let(this::getColor) + + return getRoundedCornerDrawableFromColors(fillColor, strokeColor, cornerSizeInDp, strokeSizeInDp) +} + +fun Context.getTopRoundedCornerDrawable( + @ColorRes fillColorRes: Int = R.color.secondary_screen_background, + @ColorRes strokeColorRes: Int? = null, + cornerSizeInDp: Int = DEFAULT_CORNER_RADIUS, + strokeSizeInDp: Float = 1.0f, +): Drawable { + val fillColor = getColor(fillColorRes) + val strokeColor = strokeColorRes?.let(this::getColor) + + return getTopRoundedCornerDrawableFromColors(fillColor, strokeColor, cornerSizeInDp, strokeSizeInDp) +} + +fun Context.getBottomRoundedCornerDrawable( + @ColorRes fillColorRes: Int = R.color.secondary_screen_background, + @ColorRes strokeColorRes: Int? = null, + cornerSizeInDp: Int = DEFAULT_CORNER_RADIUS, + strokeSizeInDp: Float = 1.0f, +): Drawable { + val fillColor = getColor(fillColorRes) + val strokeColor = strokeColorRes?.let(this::getColor) + + return getBottomRoundedCornerDrawableFromColors(fillColor, strokeColor, cornerSizeInDp, strokeSizeInDp) +} + +fun Context.getTopRoundedCornerDrawableFromColors( + @ColorInt fillColor: Int = getColor(R.color.secondary_screen_background), + @ColorInt strokeColor: Int? = null, + cornerSizeInDp: Int = DEFAULT_CORNER_RADIUS, + strokeSizeInDp: Float = 1.0f, +): Drawable { + return cornerDrawableFromColors( + fillColor = fillColor, + strokeColor = strokeColor, + cornerSizeInDp = cornerSizeInDp, + strokeSizeInDp = strokeSizeInDp, + shapeBuilder = { cornerSizePx -> + ShapeAppearanceModel.Builder() + .setTopLeftCorner(CornerFamily.ROUNDED, cornerSizePx) + .setTopRightCorner(CornerFamily.ROUNDED, cornerSizePx) + .build() + } + ) +} + +fun Context.getBottomRoundedCornerDrawableFromColors( + @ColorInt fillColor: Int = getColor(R.color.secondary_screen_background), + @ColorInt strokeColor: Int? = null, + cornerSizeInDp: Int = DEFAULT_CORNER_RADIUS, + strokeSizeInDp: Float = 1.0f, +): Drawable { + return cornerDrawableFromColors( + fillColor = fillColor, + strokeColor = strokeColor, + cornerSizeInDp = cornerSizeInDp, + strokeSizeInDp = strokeSizeInDp, + shapeBuilder = { cornerSizePx -> + ShapeAppearanceModel.Builder() + .setBottomRightCorner(CornerFamily.ROUNDED, cornerSizePx) + .setBottomLeftCorner(CornerFamily.ROUNDED, cornerSizePx) + .build() + } + ) +} + +fun Context.getRoundedCornerDrawableFromColors( + @ColorInt fillColor: Int? = getColor(R.color.secondary_screen_background), + @ColorInt strokeColor: Int? = null, + cornerSizeInDp: Int = DEFAULT_CORNER_RADIUS, + strokeSizeInDp: Float = 1.0f, +): Drawable { + return cornerDrawableFromColors( + fillColor = fillColor, + strokeColor = strokeColor, + cornerSizeInDp = cornerSizeInDp, + strokeSizeInDp = strokeSizeInDp, + shapeBuilder = { cornerSizePx -> + ShapeAppearanceModel.Builder() + .setAllCorners(CornerFamily.ROUNDED, cornerSizePx) + .build() + } + ) +} + +private fun Context.cornerDrawableFromColors( + @ColorInt fillColor: Int?, + @ColorInt strokeColor: Int?, + cornerSizeInDp: Int, + strokeSizeInDp: Float, + shapeBuilder: (cornerSize: Float) -> ShapeAppearanceModel +): Drawable { + val density = resources.displayMetrics.density + + val cornerSizePx = density * cornerSizeInDp + val strokeSizePx = density * strokeSizeInDp + + return MaterialShapeDrawable(shapeBuilder(cornerSizePx)).apply { + setFillColor(ColorStateList.valueOf(fillColor ?: Color.TRANSPARENT)) + + strokeColor?.let { + setStroke(strokeSizePx, it) + } + } +} + +fun ovalDrawable(@ColorInt fillColor: Int): Drawable { + return ShapeDrawable().apply { + paint.color = fillColor + shape = android.graphics.drawable.shapes.OvalShape() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/tabs/TabItem.kt b/common/src/main/java/io/novafoundation/nova/common/view/tabs/TabItem.kt new file mode 100644 index 0000000..9eba9fb --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/tabs/TabItem.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.common.view.tabs + +import android.content.Context +import android.util.AttributeSet +import android.view.ContextThemeWrapper +import android.widget.CompoundButton +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getCornersCheckableDrawable +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable + +class TabItem @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : CompoundButton(ContextThemeWrapper(context, R.style.Widget_Nova_TabItem), attrs, defStyleAttr), + WithContextExtensions by WithContextExtensions(context) { + + init { + background = with(context) { + getCornersCheckableDrawable( + checked = addRipple(getRoundedCornerDrawable(fillColorRes = R.color.segmented_tab_active, cornerSizeInDp = 10), mask = getRippleMask(10)), + unchecked = addRipple( + drawable = getRoundedCornerDrawable(fillColorRes = android.R.color.transparent, cornerSizeInDp = 10), + mask = getRippleMask(10) + ) + ) + } + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/view/tabs/TabsView.kt b/common/src/main/java/io/novafoundation/nova/common/view/tabs/TabsView.kt new file mode 100644 index 0000000..da368d8 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/view/tabs/TabsView.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.common.view.tabs + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams.MATCH_PARENT +import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT +import androidx.annotation.StringRes +import androidx.core.view.children +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable + +typealias OnTabSelected = (index: Int) -> Unit + +private val DEFAULT_BACKGROUND_TINT = R.color.segmented_background + +class TabsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private var activeTab: Int? = null + private var onTabSelected: OnTabSelected? = null + + init { + updatePadding(top = 4.dp, bottom = 4.dp, start = 4.dp) + + attrs?.let(::applyAttributes) + } + + fun addTab(title: String) { + val tab = TabItem(context).apply { + text = title + layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + weight = 1.0f + marginEnd = 4.dp + } + + setOnClickListener { clickedView -> + setCheckedTab(indexOfChild(clickedView), triggerListener = true) + } + } + + addView(tab) + } + + fun setCheckedTab(newActiveTab: Int, triggerListener: Boolean) { + val previousTab = activeTab + + activeTab = newActiveTab + + if (previousTab != newActiveTab && triggerListener) { + onTabSelected?.invoke(newActiveTab) + } + + // we need to update checked states even if the same button was clicked since + // CompoundButton (which is parent for TabItem) toggles state internally on every click + children.filterIsInstance() + .forEachIndexed { index, tabItem -> + tabItem.isChecked = index == activeTab + } + } + + fun onTabSelected(listener: OnTabSelected) { + onTabSelected = listener + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val height = MeasureSpec.makeMeasureSpec(40.dp, MeasureSpec.EXACTLY) + + super.onMeasure(widthMeasureSpec, height) + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.TabsView) { + val backgroundTint = it.getResourceId(R.styleable.TabsView_TabsView_backgroundTint, DEFAULT_BACKGROUND_TINT) + background = context.getRoundedCornerDrawable(fillColorRes = backgroundTint) + } +} + +fun TabsView.addTab(@StringRes titleRes: Int) { + addTab(context.getString(titleRes)) +} diff --git a/common/src/main/res/anim/asset_mode_fade_in.xml b/common/src/main/res/anim/asset_mode_fade_in.xml new file mode 100644 index 0000000..33a1e91 --- /dev/null +++ b/common/src/main/res/anim/asset_mode_fade_in.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/asset_mode_fade_out.xml b/common/src/main/res/anim/asset_mode_fade_out.xml new file mode 100644 index 0000000..a7dccf1 --- /dev/null +++ b/common/src/main/res/anim/asset_mode_fade_out.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/asset_mode_slide_bottom_in.xml b/common/src/main/res/anim/asset_mode_slide_bottom_in.xml new file mode 100644 index 0000000..42fca22 --- /dev/null +++ b/common/src/main/res/anim/asset_mode_slide_bottom_in.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/asset_mode_slide_bottom_out.xml b/common/src/main/res/anim/asset_mode_slide_bottom_out.xml new file mode 100644 index 0000000..d6e7826 --- /dev/null +++ b/common/src/main/res/anim/asset_mode_slide_bottom_out.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/asset_mode_slide_top_in.xml b/common/src/main/res/anim/asset_mode_slide_top_in.xml new file mode 100644 index 0000000..f17339b --- /dev/null +++ b/common/src/main/res/anim/asset_mode_slide_top_in.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/asset_mode_slide_top_out.xml b/common/src/main/res/anim/asset_mode_slide_top_out.xml new file mode 100644 index 0000000..702bd58 --- /dev/null +++ b/common/src/main/res/anim/asset_mode_slide_top_out.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fade_scale_in.xml b/common/src/main/res/anim/fade_scale_in.xml new file mode 100644 index 0000000..f604a7f --- /dev/null +++ b/common/src/main/res/anim/fade_scale_in.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fade_scale_out.xml b/common/src/main/res/anim/fade_scale_out.xml new file mode 100644 index 0000000..e8ecbe7 --- /dev/null +++ b/common/src/main/res/anim/fade_scale_out.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fade_slide_bottom_in.xml b/common/src/main/res/anim/fade_slide_bottom_in.xml new file mode 100644 index 0000000..1caa6ef --- /dev/null +++ b/common/src/main/res/anim/fade_slide_bottom_in.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fade_slide_bottom_out.xml b/common/src/main/res/anim/fade_slide_bottom_out.xml new file mode 100644 index 0000000..6a5aee2 --- /dev/null +++ b/common/src/main/res/anim/fade_slide_bottom_out.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fragment_close_enter.xml b/common/src/main/res/anim/fragment_close_enter.xml new file mode 100644 index 0000000..d57480b --- /dev/null +++ b/common/src/main/res/anim/fragment_close_enter.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fragment_close_exit.xml b/common/src/main/res/anim/fragment_close_exit.xml new file mode 100644 index 0000000..3429a1b --- /dev/null +++ b/common/src/main/res/anim/fragment_close_exit.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fragment_open_enter.xml b/common/src/main/res/anim/fragment_open_enter.xml new file mode 100644 index 0000000..acd6c7c --- /dev/null +++ b/common/src/main/res/anim/fragment_open_enter.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fragment_open_exit.xml b/common/src/main/res/anim/fragment_open_exit.xml new file mode 100644 index 0000000..6daecec --- /dev/null +++ b/common/src/main/res/anim/fragment_open_exit.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fragment_slide_in.xml b/common/src/main/res/anim/fragment_slide_in.xml new file mode 100644 index 0000000..fb1717e --- /dev/null +++ b/common/src/main/res/anim/fragment_slide_in.xml @@ -0,0 +1,24 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/fragment_slide_out.xml b/common/src/main/res/anim/fragment_slide_out.xml new file mode 100644 index 0000000..a84cb65 --- /dev/null +++ b/common/src/main/res/anim/fragment_slide_out.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/anim/progress_rotate.xml b/common/src/main/res/anim/progress_rotate.xml new file mode 100644 index 0000000..d69f5a4 --- /dev/null +++ b/common/src/main/res/anim/progress_rotate.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/common/src/main/res/anim/shake.xml b/common/src/main/res/anim/shake.xml new file mode 100644 index 0000000..4478cd2 --- /dev/null +++ b/common/src/main/res/anim/shake.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/common/src/main/res/color/actions_color.xml b/common/src/main/res/color/actions_color.xml new file mode 100644 index 0000000..318ce1d --- /dev/null +++ b/common/src/main/res/color/actions_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/appearance_selectable_text.xml b/common/src/main/res/color/appearance_selectable_text.xml new file mode 100644 index 0000000..0eff979 --- /dev/null +++ b/common/src/main/res/color/appearance_selectable_text.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/color/button_accent_text_colors.xml b/common/src/main/res/color/button_accent_text_colors.xml new file mode 100644 index 0000000..222a824 --- /dev/null +++ b/common/src/main/res/color/button_accent_text_colors.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/button_secondary_text_colors.xml b/common/src/main/res/color/button_secondary_text_colors.xml new file mode 100644 index 0000000..e7e4c60 --- /dev/null +++ b/common/src/main/res/color/button_secondary_text_colors.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/button_text_colors.xml b/common/src/main/res/color/button_text_colors.xml new file mode 100644 index 0000000..e9ac4db --- /dev/null +++ b/common/src/main/res/color/button_text_colors.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/icon_accent_state_colors.xml b/common/src/main/res/color/icon_accent_state_colors.xml new file mode 100644 index 0000000..7c12abc --- /dev/null +++ b/common/src/main/res/color/icon_accent_state_colors.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/icon_primary_state_colors.xml b/common/src/main/res/color/icon_primary_state_colors.xml new file mode 100644 index 0000000..9d64dba --- /dev/null +++ b/common/src/main/res/color/icon_primary_state_colors.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/icon_secondary_state_colors.xml b/common/src/main/res/color/icon_secondary_state_colors.xml new file mode 100644 index 0000000..7a4296f --- /dev/null +++ b/common/src/main/res/color/icon_secondary_state_colors.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/input_monocrhome_icon_tint.xml b/common/src/main/res/color/input_monocrhome_icon_tint.xml new file mode 100644 index 0000000..99d91c3 --- /dev/null +++ b/common/src/main/res/color/input_monocrhome_icon_tint.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/input_text_color.xml b/common/src/main/res/color/input_text_color.xml new file mode 100644 index 0000000..9368533 --- /dev/null +++ b/common/src/main/res/color/input_text_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/selector_button_background_negative.xml b/common/src/main/res/color/selector_button_background_negative.xml new file mode 100644 index 0000000..4d1561e --- /dev/null +++ b/common/src/main/res/color/selector_button_background_negative.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/selector_button_background_positive.xml b/common/src/main/res/color/selector_button_background_positive.xml new file mode 100644 index 0000000..e08e9b3 --- /dev/null +++ b/common/src/main/res/color/selector_button_background_positive.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/selector_button_background_primary.xml b/common/src/main/res/color/selector_button_background_primary.xml new file mode 100644 index 0000000..196990e --- /dev/null +++ b/common/src/main/res/color/selector_button_background_primary.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/selector_button_background_secondary.xml b/common/src/main/res/color/selector_button_background_secondary.xml new file mode 100644 index 0000000..0c64a6d --- /dev/null +++ b/common/src/main/res/color/selector_button_background_secondary.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/selector_button_background_secondary_transparent.xml b/common/src/main/res/color/selector_button_background_secondary_transparent.xml new file mode 100644 index 0000000..58c2972 --- /dev/null +++ b/common/src/main/res/color/selector_button_background_secondary_transparent.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/selector_button_text.xml b/common/src/main/res/color/selector_button_text.xml new file mode 100644 index 0000000..e9ac4db --- /dev/null +++ b/common/src/main/res/color/selector_button_text.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/tab_item_text_color.xml b/common/src/main/res/color/tab_item_text_color.xml new file mode 100644 index 0000000..d623121 --- /dev/null +++ b/common/src/main/res/color/tab_item_text_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/text_action_colors.xml b/common/src/main/res/color/text_action_colors.xml new file mode 100644 index 0000000..222a824 --- /dev/null +++ b/common/src/main/res/color/text_action_colors.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/color/tint_radio_button.xml b/common/src/main/res/color/tint_radio_button.xml new file mode 100644 index 0000000..abb5ecd --- /dev/null +++ b/common/src/main/res/color/tint_radio_button.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable-hdpi/advertisement_calendar.png b/common/src/main/res/drawable-hdpi/advertisement_calendar.png new file mode 100644 index 0000000..355a272 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/advertisement_calendar.png differ diff --git a/common/src/main/res/drawable-hdpi/advertisement_star.png b/common/src/main/res/drawable-hdpi/advertisement_star.png new file mode 100644 index 0000000..7f36f01 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/advertisement_star.png differ diff --git a/common/src/main/res/drawable-hdpi/crowdloan_banner_image.png b/common/src/main/res/drawable-hdpi/crowdloan_banner_image.png new file mode 100644 index 0000000..a135cb0 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/crowdloan_banner_image.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_banner_blue_gradient.png b/common/src/main/res/drawable-hdpi/ic_banner_blue_gradient.png new file mode 100644 index 0000000..319e166 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_banner_blue_gradient.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_banner_delegate_image.webp b/common/src/main/res/drawable-hdpi/ic_banner_delegate_image.webp new file mode 100644 index 0000000..908d8bf Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_banner_delegate_image.webp differ diff --git a/common/src/main/res/drawable-hdpi/ic_banner_grey_gradient.png b/common/src/main/res/drawable-hdpi/ic_banner_grey_gradient.png new file mode 100644 index 0000000..e539b7b Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_banner_grey_gradient.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_banner_turquoise_gradient.png b/common/src/main/res/drawable-hdpi/ic_banner_turquoise_gradient.png new file mode 100644 index 0000000..2e52ccf Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_banner_turquoise_gradient.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_banner_warning.png b/common/src/main/res/drawable-hdpi/ic_banner_warning.png new file mode 100644 index 0000000..865374c Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_banner_warning.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_banner_yellow_gradient.png b/common/src/main/res/drawable-hdpi/ic_banner_yellow_gradient.png new file mode 100644 index 0000000..2952756 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_banner_yellow_gradient.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_banxa_provider_logo.png b/common/src/main/res/drawable-hdpi/ic_banxa_provider_logo.png new file mode 100644 index 0000000..9c81f4b Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_banxa_provider_logo.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_bell.png b/common/src/main/res/drawable-hdpi/ic_bell.png new file mode 100644 index 0000000..a17030f Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_bell.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_big_star.png b/common/src/main/res/drawable-hdpi/ic_big_star.png new file mode 100644 index 0000000..98168fc Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_big_star.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_blue_siri.png b/common/src/main/res/drawable-hdpi/ic_blue_siri.png new file mode 100644 index 0000000..32e0994 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_blue_siri.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_cloud_backup_add.png b/common/src/main/res/drawable-hdpi/ic_cloud_backup_add.png new file mode 100644 index 0000000..d1951e2 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_cloud_backup_add.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_cloud_backup_delete.png b/common/src/main/res/drawable-hdpi/ic_cloud_backup_delete.png new file mode 100644 index 0000000..b37a3f5 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_cloud_backup_delete.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_cloud_backup_error.png b/common/src/main/res/drawable-hdpi/ic_cloud_backup_error.png new file mode 100644 index 0000000..aeb679e Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_cloud_backup_error.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_cloud_backup_lock.png b/common/src/main/res/drawable-hdpi/ic_cloud_backup_lock.png new file mode 100644 index 0000000..4cba24d Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_cloud_backup_lock.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_cloud_backup_password.png b/common/src/main/res/drawable-hdpi/ic_cloud_backup_password.png new file mode 100644 index 0000000..88e6567 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_cloud_backup_password.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_cloud_backup_sync.png b/common/src/main/res/drawable-hdpi/ic_cloud_backup_sync.png new file mode 100644 index 0000000..04ca0f5 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_cloud_backup_sync.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_cloud_backup_warning.png b/common/src/main/res/drawable-hdpi/ic_cloud_backup_warning.png new file mode 100644 index 0000000..53ced03 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_cloud_backup_warning.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_container_timer_animated.png b/common/src/main/res/drawable-hdpi/ic_container_timer_animated.png new file mode 100644 index 0000000..ae2eafe Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_container_timer_animated.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_create_wallet_background.png b/common/src/main/res/drawable-hdpi/ic_create_wallet_background.png new file mode 100644 index 0000000..6d169a1 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_create_wallet_background.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_critical_update.png b/common/src/main/res/drawable-hdpi/ic_critical_update.png new file mode 100644 index 0000000..dd6226e Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_critical_update.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_cycle.png b/common/src/main/res/drawable-hdpi/ic_cycle.png new file mode 100644 index 0000000..c28e5e0 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_cycle.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_direct_staking_banner_picture.png b/common/src/main/res/drawable-hdpi/ic_direct_staking_banner_picture.png new file mode 100644 index 0000000..1082e9f Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_direct_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_gift_packed.png b/common/src/main/res/drawable-hdpi/ic_gift_packed.png new file mode 100644 index 0000000..f465f71 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_gift_packed.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_gift_unpacked.png b/common/src/main/res/drawable-hdpi/ic_gift_unpacked.png new file mode 100644 index 0000000..1bcee47 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_gift_unpacked.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_gifts_placeholder.png b/common/src/main/res/drawable-hdpi/ic_gifts_placeholder.png new file mode 100644 index 0000000..fd8942f Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_gifts_placeholder.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_import_option_cloud.png b/common/src/main/res/drawable-hdpi/ic_import_option_cloud.png new file mode 100644 index 0000000..cceee86 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_import_option_cloud.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_import_option_hardware.png b/common/src/main/res/drawable-hdpi/ic_import_option_hardware.png new file mode 100644 index 0000000..9df359f Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_import_option_hardware.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_import_option_passphrase.png b/common/src/main/res/drawable-hdpi/ic_import_option_passphrase.png new file mode 100644 index 0000000..5e82314 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_import_option_passphrase.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_import_option_trust_wallet.png b/common/src/main/res/drawable-hdpi/ic_import_option_trust_wallet.png new file mode 100644 index 0000000..d03117c Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_import_option_trust_wallet.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_import_option_watch_only.png b/common/src/main/res/drawable-hdpi/ic_import_option_watch_only.png new file mode 100644 index 0000000..f718fcd Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_import_option_watch_only.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_flex_approve.png b/common/src/main/res/drawable-hdpi/ic_ledger_flex_approve.png new file mode 100644 index 0000000..e1922b0 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_flex_approve.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_flex_error.png b/common/src/main/res/drawable-hdpi/ic_ledger_flex_error.png new file mode 100644 index 0000000..54b9428 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_flex_error.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_flex_sign.png b/common/src/main/res/drawable-hdpi/ic_ledger_flex_sign.png new file mode 100644 index 0000000..54c48a7 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_flex_sign.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_nano_gen5_approve.png b/common/src/main/res/drawable-hdpi/ic_ledger_nano_gen5_approve.png new file mode 100644 index 0000000..718708b Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_nano_gen5_approve.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_nano_gen5_error.png b/common/src/main/res/drawable-hdpi/ic_ledger_nano_gen5_error.png new file mode 100644 index 0000000..6e46d6f Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_nano_gen5_error.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_nano_gen5_sign.png b/common/src/main/res/drawable-hdpi/ic_ledger_nano_gen5_sign.png new file mode 100644 index 0000000..95cfe04 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_nano_gen5_sign.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_nano_s_approve.png b/common/src/main/res/drawable-hdpi/ic_ledger_nano_s_approve.png new file mode 100644 index 0000000..2a56b37 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_nano_s_approve.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_nano_s_error.png b/common/src/main/res/drawable-hdpi/ic_ledger_nano_s_error.png new file mode 100644 index 0000000..c5d81cb Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_nano_s_error.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_nano_x_approve.png b/common/src/main/res/drawable-hdpi/ic_ledger_nano_x_approve.png new file mode 100644 index 0000000..4fbf967 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_nano_x_approve.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_nano_x_error.png b/common/src/main/res/drawable-hdpi/ic_ledger_nano_x_error.png new file mode 100644 index 0000000..94d5dd8 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_nano_x_error.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_stax_approve.png b/common/src/main/res/drawable-hdpi/ic_ledger_stax_approve.png new file mode 100644 index 0000000..10e9f29 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_stax_approve.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_stax_error.png b/common/src/main/res/drawable-hdpi/ic_ledger_stax_error.png new file mode 100644 index 0000000..e91e901 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_stax_error.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_ledger_stax_sign.png b/common/src/main/res/drawable-hdpi/ic_ledger_stax_sign.png new file mode 100644 index 0000000..f85bf96 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_ledger_stax_sign.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_loading_screen_developed_by.png b/common/src/main/res/drawable-hdpi/ic_loading_screen_developed_by.png new file mode 100644 index 0000000..c6af8db Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_loading_screen_developed_by.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_loading_screen_logo.webp b/common/src/main/res/drawable-hdpi/ic_loading_screen_logo.webp new file mode 100644 index 0000000..ebfefc2 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_loading_screen_logo.webp differ diff --git a/common/src/main/res/drawable-hdpi/ic_main_banner_background.png b/common/src/main/res/drawable-hdpi/ic_main_banner_background.png new file mode 100644 index 0000000..8e1508b Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_main_banner_background.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_major_update.png b/common/src/main/res/drawable-hdpi/ic_major_update.png new file mode 100644 index 0000000..febd7c4 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_major_update.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_mercurio_provider_logo.png b/common/src/main/res/drawable-hdpi/ic_mercurio_provider_logo.png new file mode 100644 index 0000000..95e5ab1 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_mercurio_provider_logo.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_middle_star.png b/common/src/main/res/drawable-hdpi/ic_middle_star.png new file mode 100644 index 0000000..e2b1aab Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_middle_star.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_migration_image.png b/common/src/main/res/drawable-hdpi/ic_migration_image.png new file mode 100644 index 0000000..51bc8c9 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_migration_image.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_mnemonic_card_blur.png b/common/src/main/res/drawable-hdpi/ic_mnemonic_card_blur.png new file mode 100644 index 0000000..3d0b25f Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_mnemonic_card_blur.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_monitor_your_stake.png b/common/src/main/res/drawable-hdpi/ic_monitor_your_stake.png new file mode 100644 index 0000000..145d985 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_monitor_your_stake.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_networks_banner_image.png b/common/src/main/res/drawable-hdpi/ic_networks_banner_image.png new file mode 100644 index 0000000..1d96182 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_networks_banner_image.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_no_added_networks.png b/common/src/main/res/drawable-hdpi/ic_no_added_networks.png new file mode 100644 index 0000000..9e77d54 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_no_added_networks.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_participate_in_governance.png b/common/src/main/res/drawable-hdpi/ic_participate_in_governance.png new file mode 100644 index 0000000..96675c2 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_participate_in_governance.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_pezkuwi_card_logo.png b/common/src/main/res/drawable-hdpi/ic_pezkuwi_card_logo.png new file mode 100644 index 0000000..013c1ff Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_pezkuwi_card_logo.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_pezkuwi_logo.png b/common/src/main/res/drawable-hdpi/ic_pezkuwi_logo.png new file mode 100644 index 0000000..f36906b Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_pezkuwi_logo.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_pink_siri.png b/common/src/main/res/drawable-hdpi/ic_pink_siri.png new file mode 100644 index 0000000..c474486 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_pink_siri.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_planet.png b/common/src/main/res/drawable-hdpi/ic_planet.png new file mode 100644 index 0000000..bdb6073 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_planet.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_pool_staking_banner_picture.png b/common/src/main/res/drawable-hdpi/ic_pool_staking_banner_picture.png new file mode 100644 index 0000000..996f8f2 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_pool_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_powered_by_oak.png b/common/src/main/res/drawable-hdpi/ic_powered_by_oak.png new file mode 100644 index 0000000..b8caa72 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_powered_by_oak.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_raw_seed_blur.png b/common/src/main/res/drawable-hdpi/ic_raw_seed_blur.png new file mode 100644 index 0000000..8ac3cdd Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_raw_seed_blur.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_rewards.png b/common/src/main/res/drawable-hdpi/ic_rewards.png new file mode 100644 index 0000000..2c4c328 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_rewards.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_small_star.png b/common/src/main/res/drawable-hdpi/ic_small_star.png new file mode 100644 index 0000000..8c703d6 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_small_star.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_splash_background.webp b/common/src/main/res/drawable-hdpi/ic_splash_background.webp new file mode 100644 index 0000000..db5c098 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_splash_background.webp differ diff --git a/common/src/main/res/drawable-hdpi/ic_stake_anytime.png b/common/src/main/res/drawable-hdpi/ic_stake_anytime.png new file mode 100644 index 0000000..0406635 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_stake_anytime.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_swap_asset_default_background.png b/common/src/main/res/drawable-hdpi/ic_swap_asset_default_background.png new file mode 100644 index 0000000..f4c0a7a Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_swap_asset_default_background.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_test_network.png b/common/src/main/res/drawable-hdpi/ic_test_network.png new file mode 100644 index 0000000..8333a90 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_test_network.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_timer_card.png b/common/src/main/res/drawable-hdpi/ic_timer_card.png new file mode 100644 index 0000000..877f796 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_timer_card.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_tinder_gov_empty_state.png b/common/src/main/res/drawable-hdpi/ic_tinder_gov_empty_state.png new file mode 100644 index 0000000..8305bbf Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_tinder_gov_empty_state.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_transak_provider_logo.png b/common/src/main/res/drawable-hdpi/ic_transak_provider_logo.png new file mode 100644 index 0000000..8ffbfe3 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_transak_provider_logo.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_unstake_anytime.png b/common/src/main/res/drawable-hdpi/ic_unstake_anytime.png new file mode 100644 index 0000000..5bd2a7e Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_unstake_anytime.png differ diff --git a/common/src/main/res/drawable-hdpi/ic_yellow_siri.png b/common/src/main/res/drawable-hdpi/ic_yellow_siri.png new file mode 100644 index 0000000..716d21d Binary files /dev/null and b/common/src/main/res/drawable-hdpi/ic_yellow_siri.png differ diff --git a/common/src/main/res/drawable-hdpi/my_parity_signer.png b/common/src/main/res/drawable-hdpi/my_parity_signer.png new file mode 100644 index 0000000..a6a2f37 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/my_parity_signer.png differ diff --git a/common/src/main/res/drawable-hdpi/polkadot_vault_account.png b/common/src/main/res/drawable-hdpi/polkadot_vault_account.png new file mode 100644 index 0000000..6e43a87 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/polkadot_vault_account.png differ diff --git a/common/src/main/res/drawable-hdpi/shield.png b/common/src/main/res/drawable-hdpi/shield.png new file mode 100644 index 0000000..c7b6ee6 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/shield.png differ diff --git a/common/src/main/res/drawable-hdpi/tinder_gov.png b/common/src/main/res/drawable-hdpi/tinder_gov.png new file mode 100644 index 0000000..b5d5d62 Binary files /dev/null and b/common/src/main/res/drawable-hdpi/tinder_gov.png differ diff --git a/common/src/main/res/drawable-ldpi/advertisement_calendar.png b/common/src/main/res/drawable-ldpi/advertisement_calendar.png new file mode 100644 index 0000000..7fb19f8 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/advertisement_calendar.png differ diff --git a/common/src/main/res/drawable-ldpi/advertisement_star.png b/common/src/main/res/drawable-ldpi/advertisement_star.png new file mode 100644 index 0000000..156fe32 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/advertisement_star.png differ diff --git a/common/src/main/res/drawable-ldpi/crowdloan_banner_image.png b/common/src/main/res/drawable-ldpi/crowdloan_banner_image.png new file mode 100644 index 0000000..01208dc Binary files /dev/null and b/common/src/main/res/drawable-ldpi/crowdloan_banner_image.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_banner_blue_gradient.png b/common/src/main/res/drawable-ldpi/ic_banner_blue_gradient.png new file mode 100644 index 0000000..43193d4 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_banner_blue_gradient.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_banner_delegate_image.webp b/common/src/main/res/drawable-ldpi/ic_banner_delegate_image.webp new file mode 100644 index 0000000..15cd17c Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_banner_delegate_image.webp differ diff --git a/common/src/main/res/drawable-ldpi/ic_banner_grey_gradient.png b/common/src/main/res/drawable-ldpi/ic_banner_grey_gradient.png new file mode 100644 index 0000000..3637b7d Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_banner_grey_gradient.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_banner_turquoise_gradient.png b/common/src/main/res/drawable-ldpi/ic_banner_turquoise_gradient.png new file mode 100644 index 0000000..33e9d9e Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_banner_turquoise_gradient.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_banner_warning.png b/common/src/main/res/drawable-ldpi/ic_banner_warning.png new file mode 100644 index 0000000..519ff28 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_banner_warning.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_banner_yellow_gradient.png b/common/src/main/res/drawable-ldpi/ic_banner_yellow_gradient.png new file mode 100644 index 0000000..20e279f Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_banner_yellow_gradient.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_banxa_provider_logo.png b/common/src/main/res/drawable-ldpi/ic_banxa_provider_logo.png new file mode 100644 index 0000000..7e6625d Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_banxa_provider_logo.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_bell.png b/common/src/main/res/drawable-ldpi/ic_bell.png new file mode 100644 index 0000000..264cf09 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_bell.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_blue_siri.png b/common/src/main/res/drawable-ldpi/ic_blue_siri.png new file mode 100644 index 0000000..c247b7c Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_blue_siri.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_cloud_backup_add.png b/common/src/main/res/drawable-ldpi/ic_cloud_backup_add.png new file mode 100644 index 0000000..c467758 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_cloud_backup_add.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_cloud_backup_delete.png b/common/src/main/res/drawable-ldpi/ic_cloud_backup_delete.png new file mode 100644 index 0000000..e5f666a Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_cloud_backup_delete.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_cloud_backup_error.png b/common/src/main/res/drawable-ldpi/ic_cloud_backup_error.png new file mode 100644 index 0000000..1322da6 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_cloud_backup_error.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_cloud_backup_lock.png b/common/src/main/res/drawable-ldpi/ic_cloud_backup_lock.png new file mode 100644 index 0000000..7569545 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_cloud_backup_lock.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_cloud_backup_password.png b/common/src/main/res/drawable-ldpi/ic_cloud_backup_password.png new file mode 100644 index 0000000..5ad2688 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_cloud_backup_password.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_cloud_backup_sync.png b/common/src/main/res/drawable-ldpi/ic_cloud_backup_sync.png new file mode 100644 index 0000000..7cec22d Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_cloud_backup_sync.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_cloud_backup_warning.png b/common/src/main/res/drawable-ldpi/ic_cloud_backup_warning.png new file mode 100644 index 0000000..9ecfc3d Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_cloud_backup_warning.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_container_timer_animated.png b/common/src/main/res/drawable-ldpi/ic_container_timer_animated.png new file mode 100644 index 0000000..caeb391 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_container_timer_animated.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_create_wallet_background.png b/common/src/main/res/drawable-ldpi/ic_create_wallet_background.png new file mode 100644 index 0000000..6cfb511 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_create_wallet_background.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_critical_update.png b/common/src/main/res/drawable-ldpi/ic_critical_update.png new file mode 100644 index 0000000..9b1236a Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_critical_update.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_cycle.png b/common/src/main/res/drawable-ldpi/ic_cycle.png new file mode 100644 index 0000000..11bc28a Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_cycle.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_direct_staking_banner_picture.png b/common/src/main/res/drawable-ldpi/ic_direct_staking_banner_picture.png new file mode 100644 index 0000000..24d7c4c Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_direct_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_gift_packed.png b/common/src/main/res/drawable-ldpi/ic_gift_packed.png new file mode 100644 index 0000000..5278a7c Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_gift_packed.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_gift_unpacked.png b/common/src/main/res/drawable-ldpi/ic_gift_unpacked.png new file mode 100644 index 0000000..a32bd08 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_gift_unpacked.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_gifts_placeholder.png b/common/src/main/res/drawable-ldpi/ic_gifts_placeholder.png new file mode 100644 index 0000000..45c7d88 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_gifts_placeholder.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_import_option_hardware.png b/common/src/main/res/drawable-ldpi/ic_import_option_hardware.png new file mode 100644 index 0000000..adb194f Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_import_option_hardware.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_import_option_passphrase.png b/common/src/main/res/drawable-ldpi/ic_import_option_passphrase.png new file mode 100644 index 0000000..557edd4 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_import_option_passphrase.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_import_option_watch_only.png b/common/src/main/res/drawable-ldpi/ic_import_option_watch_only.png new file mode 100644 index 0000000..76460f0 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_import_option_watch_only.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_flex_approve.png b/common/src/main/res/drawable-ldpi/ic_ledger_flex_approve.png new file mode 100644 index 0000000..28e122f Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_flex_approve.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_flex_error.png b/common/src/main/res/drawable-ldpi/ic_ledger_flex_error.png new file mode 100644 index 0000000..0e8b4aa Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_flex_error.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_flex_sign.png b/common/src/main/res/drawable-ldpi/ic_ledger_flex_sign.png new file mode 100644 index 0000000..81e82db Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_flex_sign.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_nano_gen5_approve.png b/common/src/main/res/drawable-ldpi/ic_ledger_nano_gen5_approve.png new file mode 100644 index 0000000..bd03799 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_nano_gen5_approve.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_nano_gen5_error.png b/common/src/main/res/drawable-ldpi/ic_ledger_nano_gen5_error.png new file mode 100644 index 0000000..832967e Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_nano_gen5_error.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_nano_gen5_sign.png b/common/src/main/res/drawable-ldpi/ic_ledger_nano_gen5_sign.png new file mode 100644 index 0000000..a2e8c25 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_nano_gen5_sign.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_nano_s_approve.png b/common/src/main/res/drawable-ldpi/ic_ledger_nano_s_approve.png new file mode 100644 index 0000000..69838f7 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_nano_s_approve.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_nano_s_error.png b/common/src/main/res/drawable-ldpi/ic_ledger_nano_s_error.png new file mode 100644 index 0000000..a7fc99a Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_nano_s_error.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_nano_x_approve.png b/common/src/main/res/drawable-ldpi/ic_ledger_nano_x_approve.png new file mode 100644 index 0000000..f85cbc1 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_nano_x_approve.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_nano_x_error.png b/common/src/main/res/drawable-ldpi/ic_ledger_nano_x_error.png new file mode 100644 index 0000000..779c418 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_nano_x_error.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_stax_approve.png b/common/src/main/res/drawable-ldpi/ic_ledger_stax_approve.png new file mode 100644 index 0000000..f309e27 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_stax_approve.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_stax_error.png b/common/src/main/res/drawable-ldpi/ic_ledger_stax_error.png new file mode 100644 index 0000000..03deb1b Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_stax_error.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_ledger_stax_sign.png b/common/src/main/res/drawable-ldpi/ic_ledger_stax_sign.png new file mode 100644 index 0000000..c4e74ea Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_ledger_stax_sign.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_loading_screen_developed_by.png b/common/src/main/res/drawable-ldpi/ic_loading_screen_developed_by.png new file mode 100644 index 0000000..0f9f1fc Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_loading_screen_developed_by.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_loading_screen_logo.webp b/common/src/main/res/drawable-ldpi/ic_loading_screen_logo.webp new file mode 100644 index 0000000..47a5855 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_loading_screen_logo.webp differ diff --git a/common/src/main/res/drawable-ldpi/ic_main_banner_background.png b/common/src/main/res/drawable-ldpi/ic_main_banner_background.png new file mode 100644 index 0000000..107c2e0 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_main_banner_background.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_major_update.png b/common/src/main/res/drawable-ldpi/ic_major_update.png new file mode 100644 index 0000000..95c0e15 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_major_update.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_mercurio_provider_logo.png b/common/src/main/res/drawable-ldpi/ic_mercurio_provider_logo.png new file mode 100644 index 0000000..7943a8e Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_mercurio_provider_logo.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_migration_image.png b/common/src/main/res/drawable-ldpi/ic_migration_image.png new file mode 100644 index 0000000..83a8699 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_migration_image.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_mnemonic_card_blur.png b/common/src/main/res/drawable-ldpi/ic_mnemonic_card_blur.png new file mode 100644 index 0000000..46810dc Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_mnemonic_card_blur.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_monitor_your_stake.png b/common/src/main/res/drawable-ldpi/ic_monitor_your_stake.png new file mode 100644 index 0000000..995626c Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_monitor_your_stake.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_networks_banner_image.png b/common/src/main/res/drawable-ldpi/ic_networks_banner_image.png new file mode 100644 index 0000000..7c277ce Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_networks_banner_image.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_no_added_networks.png b/common/src/main/res/drawable-ldpi/ic_no_added_networks.png new file mode 100644 index 0000000..e093fa2 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_no_added_networks.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_participate_in_governance.png b/common/src/main/res/drawable-ldpi/ic_participate_in_governance.png new file mode 100644 index 0000000..285c250 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_participate_in_governance.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_pezkuwi_card_logo.png b/common/src/main/res/drawable-ldpi/ic_pezkuwi_card_logo.png new file mode 100644 index 0000000..c155551 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_pezkuwi_card_logo.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_pezkuwi_logo.png b/common/src/main/res/drawable-ldpi/ic_pezkuwi_logo.png new file mode 100644 index 0000000..35e260c Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_pezkuwi_logo.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_pink_siri.png b/common/src/main/res/drawable-ldpi/ic_pink_siri.png new file mode 100644 index 0000000..9040236 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_pink_siri.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_planet.png b/common/src/main/res/drawable-ldpi/ic_planet.png new file mode 100644 index 0000000..e18fcd1 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_planet.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_pool_staking_banner_picture.png b/common/src/main/res/drawable-ldpi/ic_pool_staking_banner_picture.png new file mode 100644 index 0000000..4bdaf54 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_pool_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_powered_by_oak.png b/common/src/main/res/drawable-ldpi/ic_powered_by_oak.png new file mode 100644 index 0000000..535275e Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_powered_by_oak.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_raw_seed_blur.png b/common/src/main/res/drawable-ldpi/ic_raw_seed_blur.png new file mode 100644 index 0000000..4a07922 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_raw_seed_blur.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_rewards.png b/common/src/main/res/drawable-ldpi/ic_rewards.png new file mode 100644 index 0000000..5db27b9 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_rewards.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_splash_background.webp b/common/src/main/res/drawable-ldpi/ic_splash_background.webp new file mode 100644 index 0000000..22d3626 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_splash_background.webp differ diff --git a/common/src/main/res/drawable-ldpi/ic_stake_anytime.png b/common/src/main/res/drawable-ldpi/ic_stake_anytime.png new file mode 100644 index 0000000..dab2f63 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_stake_anytime.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_swap_asset_default_background.png b/common/src/main/res/drawable-ldpi/ic_swap_asset_default_background.png new file mode 100644 index 0000000..927179d Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_swap_asset_default_background.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_test_network.png b/common/src/main/res/drawable-ldpi/ic_test_network.png new file mode 100644 index 0000000..630dc7b Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_test_network.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_timer_card.png b/common/src/main/res/drawable-ldpi/ic_timer_card.png new file mode 100644 index 0000000..a0eb8af Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_timer_card.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_tinder_gov_empty_state.png b/common/src/main/res/drawable-ldpi/ic_tinder_gov_empty_state.png new file mode 100644 index 0000000..833318f Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_tinder_gov_empty_state.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_transak_provider_logo.png b/common/src/main/res/drawable-ldpi/ic_transak_provider_logo.png new file mode 100644 index 0000000..812cae8 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_transak_provider_logo.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_unstake_anytime.png b/common/src/main/res/drawable-ldpi/ic_unstake_anytime.png new file mode 100644 index 0000000..382cdbf Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_unstake_anytime.png differ diff --git a/common/src/main/res/drawable-ldpi/ic_yellow_siri.png b/common/src/main/res/drawable-ldpi/ic_yellow_siri.png new file mode 100644 index 0000000..a5a80f3 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/ic_yellow_siri.png differ diff --git a/common/src/main/res/drawable-ldpi/my_parity_signer.png b/common/src/main/res/drawable-ldpi/my_parity_signer.png new file mode 100644 index 0000000..99c0736 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/my_parity_signer.png differ diff --git a/common/src/main/res/drawable-ldpi/polkadot_vault_account.png b/common/src/main/res/drawable-ldpi/polkadot_vault_account.png new file mode 100644 index 0000000..66ed27d Binary files /dev/null and b/common/src/main/res/drawable-ldpi/polkadot_vault_account.png differ diff --git a/common/src/main/res/drawable-ldpi/shield.png b/common/src/main/res/drawable-ldpi/shield.png new file mode 100644 index 0000000..c927bf2 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/shield.png differ diff --git a/common/src/main/res/drawable-ldpi/tinder_gov.png b/common/src/main/res/drawable-ldpi/tinder_gov.png new file mode 100644 index 0000000..7acb6a1 Binary files /dev/null and b/common/src/main/res/drawable-ldpi/tinder_gov.png differ diff --git a/common/src/main/res/drawable-mdpi/advertisement_calendar.png b/common/src/main/res/drawable-mdpi/advertisement_calendar.png new file mode 100644 index 0000000..f5dfb70 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/advertisement_calendar.png differ diff --git a/common/src/main/res/drawable-mdpi/advertisement_star.png b/common/src/main/res/drawable-mdpi/advertisement_star.png new file mode 100644 index 0000000..ededb47 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/advertisement_star.png differ diff --git a/common/src/main/res/drawable-mdpi/crowdloan_banner_image.png b/common/src/main/res/drawable-mdpi/crowdloan_banner_image.png new file mode 100644 index 0000000..efe9348 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/crowdloan_banner_image.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_banner_blue_gradient.png b/common/src/main/res/drawable-mdpi/ic_banner_blue_gradient.png new file mode 100644 index 0000000..4527075 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_banner_blue_gradient.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_banner_delegate_image.webp b/common/src/main/res/drawable-mdpi/ic_banner_delegate_image.webp new file mode 100644 index 0000000..0877ba9 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_banner_delegate_image.webp differ diff --git a/common/src/main/res/drawable-mdpi/ic_banner_grey_gradient.png b/common/src/main/res/drawable-mdpi/ic_banner_grey_gradient.png new file mode 100644 index 0000000..e647c95 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_banner_grey_gradient.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_banner_turquoise_gradient.png b/common/src/main/res/drawable-mdpi/ic_banner_turquoise_gradient.png new file mode 100644 index 0000000..69cec58 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_banner_turquoise_gradient.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_banner_warning.png b/common/src/main/res/drawable-mdpi/ic_banner_warning.png new file mode 100644 index 0000000..388b59d Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_banner_warning.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_banner_yellow_gradient.png b/common/src/main/res/drawable-mdpi/ic_banner_yellow_gradient.png new file mode 100644 index 0000000..9d21fd8 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_banner_yellow_gradient.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_banxa_provider_logo.png b/common/src/main/res/drawable-mdpi/ic_banxa_provider_logo.png new file mode 100644 index 0000000..4d390ab Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_banxa_provider_logo.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_bell.png b/common/src/main/res/drawable-mdpi/ic_bell.png new file mode 100644 index 0000000..58a8131 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_bell.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_big_star.png b/common/src/main/res/drawable-mdpi/ic_big_star.png new file mode 100644 index 0000000..46ccab1 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_big_star.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_blue_siri.png b/common/src/main/res/drawable-mdpi/ic_blue_siri.png new file mode 100644 index 0000000..0ab06d7 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_blue_siri.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_cloud_backup_add.png b/common/src/main/res/drawable-mdpi/ic_cloud_backup_add.png new file mode 100644 index 0000000..fd47991 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_cloud_backup_add.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_cloud_backup_delete.png b/common/src/main/res/drawable-mdpi/ic_cloud_backup_delete.png new file mode 100644 index 0000000..b9672db Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_cloud_backup_delete.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_cloud_backup_error.png b/common/src/main/res/drawable-mdpi/ic_cloud_backup_error.png new file mode 100644 index 0000000..479b2c2 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_cloud_backup_error.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_cloud_backup_lock.png b/common/src/main/res/drawable-mdpi/ic_cloud_backup_lock.png new file mode 100644 index 0000000..95c89bc Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_cloud_backup_lock.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_cloud_backup_password.png b/common/src/main/res/drawable-mdpi/ic_cloud_backup_password.png new file mode 100644 index 0000000..3298a20 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_cloud_backup_password.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_cloud_backup_sync.png b/common/src/main/res/drawable-mdpi/ic_cloud_backup_sync.png new file mode 100644 index 0000000..ab10e24 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_cloud_backup_sync.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_cloud_backup_warning.png b/common/src/main/res/drawable-mdpi/ic_cloud_backup_warning.png new file mode 100644 index 0000000..ff5d1f8 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_cloud_backup_warning.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_container_timer_animated.png b/common/src/main/res/drawable-mdpi/ic_container_timer_animated.png new file mode 100644 index 0000000..6f2267f Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_container_timer_animated.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_create_wallet_background.png b/common/src/main/res/drawable-mdpi/ic_create_wallet_background.png new file mode 100644 index 0000000..8c382fd Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_create_wallet_background.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_critical_update.png b/common/src/main/res/drawable-mdpi/ic_critical_update.png new file mode 100644 index 0000000..d5b246b Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_critical_update.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_cycle.png b/common/src/main/res/drawable-mdpi/ic_cycle.png new file mode 100644 index 0000000..ba238da Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_cycle.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_direct_staking_banner_picture.png b/common/src/main/res/drawable-mdpi/ic_direct_staking_banner_picture.png new file mode 100644 index 0000000..0889909 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_direct_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_gift_packed.png b/common/src/main/res/drawable-mdpi/ic_gift_packed.png new file mode 100644 index 0000000..f04c1cc Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_gift_packed.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_gift_unpacked.png b/common/src/main/res/drawable-mdpi/ic_gift_unpacked.png new file mode 100644 index 0000000..a02a3bc Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_gift_unpacked.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_gifts_placeholder.png b/common/src/main/res/drawable-mdpi/ic_gifts_placeholder.png new file mode 100644 index 0000000..f5d7a16 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_gifts_placeholder.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_import_option_cloud.png b/common/src/main/res/drawable-mdpi/ic_import_option_cloud.png new file mode 100644 index 0000000..fc8df43 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_import_option_cloud.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_import_option_hardware.png b/common/src/main/res/drawable-mdpi/ic_import_option_hardware.png new file mode 100644 index 0000000..7429b53 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_import_option_hardware.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_import_option_passphrase.png b/common/src/main/res/drawable-mdpi/ic_import_option_passphrase.png new file mode 100644 index 0000000..b3f8599 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_import_option_passphrase.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_import_option_trust_wallet.png b/common/src/main/res/drawable-mdpi/ic_import_option_trust_wallet.png new file mode 100644 index 0000000..e689967 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_import_option_trust_wallet.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_import_option_watch_only.png b/common/src/main/res/drawable-mdpi/ic_import_option_watch_only.png new file mode 100644 index 0000000..ba8113d Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_import_option_watch_only.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_flex_approve.png b/common/src/main/res/drawable-mdpi/ic_ledger_flex_approve.png new file mode 100644 index 0000000..4a9eb72 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_flex_approve.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_flex_error.png b/common/src/main/res/drawable-mdpi/ic_ledger_flex_error.png new file mode 100644 index 0000000..4ec8a3e Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_flex_error.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_flex_sign.png b/common/src/main/res/drawable-mdpi/ic_ledger_flex_sign.png new file mode 100644 index 0000000..b099429 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_flex_sign.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_nano_gen5_approve.png b/common/src/main/res/drawable-mdpi/ic_ledger_nano_gen5_approve.png new file mode 100644 index 0000000..66e4c50 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_nano_gen5_approve.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_nano_gen5_error.png b/common/src/main/res/drawable-mdpi/ic_ledger_nano_gen5_error.png new file mode 100644 index 0000000..4518784 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_nano_gen5_error.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_nano_gen5_sign.png b/common/src/main/res/drawable-mdpi/ic_ledger_nano_gen5_sign.png new file mode 100644 index 0000000..a6550a8 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_nano_gen5_sign.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_nano_s_approve.png b/common/src/main/res/drawable-mdpi/ic_ledger_nano_s_approve.png new file mode 100644 index 0000000..5a87bce Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_nano_s_approve.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_nano_s_error.png b/common/src/main/res/drawable-mdpi/ic_ledger_nano_s_error.png new file mode 100644 index 0000000..67bf2ae Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_nano_s_error.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_nano_x_approve.png b/common/src/main/res/drawable-mdpi/ic_ledger_nano_x_approve.png new file mode 100644 index 0000000..e3365d3 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_nano_x_approve.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_nano_x_error.png b/common/src/main/res/drawable-mdpi/ic_ledger_nano_x_error.png new file mode 100644 index 0000000..0e447ef Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_nano_x_error.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_stax_approve.png b/common/src/main/res/drawable-mdpi/ic_ledger_stax_approve.png new file mode 100644 index 0000000..875f148 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_stax_approve.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_stax_error.png b/common/src/main/res/drawable-mdpi/ic_ledger_stax_error.png new file mode 100644 index 0000000..7e36a1f Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_stax_error.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_ledger_stax_sign.png b/common/src/main/res/drawable-mdpi/ic_ledger_stax_sign.png new file mode 100644 index 0000000..b875aac Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_ledger_stax_sign.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_loading_screen_developed_by.png b/common/src/main/res/drawable-mdpi/ic_loading_screen_developed_by.png new file mode 100644 index 0000000..ac91b9a Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_loading_screen_developed_by.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_loading_screen_logo.webp b/common/src/main/res/drawable-mdpi/ic_loading_screen_logo.webp new file mode 100644 index 0000000..d337b22 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_loading_screen_logo.webp differ diff --git a/common/src/main/res/drawable-mdpi/ic_main_banner_background.png b/common/src/main/res/drawable-mdpi/ic_main_banner_background.png new file mode 100644 index 0000000..fa7e8b4 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_main_banner_background.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_major_update.png b/common/src/main/res/drawable-mdpi/ic_major_update.png new file mode 100644 index 0000000..2ac1456 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_major_update.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_mercurio_provider_logo.png b/common/src/main/res/drawable-mdpi/ic_mercurio_provider_logo.png new file mode 100644 index 0000000..c8a50c3 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_mercurio_provider_logo.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_middle_star.png b/common/src/main/res/drawable-mdpi/ic_middle_star.png new file mode 100644 index 0000000..d1cdd00 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_middle_star.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_migration_image.png b/common/src/main/res/drawable-mdpi/ic_migration_image.png new file mode 100644 index 0000000..31cde31 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_migration_image.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_mnemonic_card_blur.png b/common/src/main/res/drawable-mdpi/ic_mnemonic_card_blur.png new file mode 100644 index 0000000..43b3510 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_mnemonic_card_blur.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_monitor_your_stake.png b/common/src/main/res/drawable-mdpi/ic_monitor_your_stake.png new file mode 100644 index 0000000..e0e3ac1 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_monitor_your_stake.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_networks_banner_image.png b/common/src/main/res/drawable-mdpi/ic_networks_banner_image.png new file mode 100644 index 0000000..7ce3566 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_networks_banner_image.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_no_added_networks.png b/common/src/main/res/drawable-mdpi/ic_no_added_networks.png new file mode 100644 index 0000000..465f316 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_no_added_networks.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_participate_in_governance.png b/common/src/main/res/drawable-mdpi/ic_participate_in_governance.png new file mode 100644 index 0000000..26daa8c Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_participate_in_governance.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_pezkuwi_card_logo.png b/common/src/main/res/drawable-mdpi/ic_pezkuwi_card_logo.png new file mode 100644 index 0000000..44fd293 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_pezkuwi_card_logo.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_pezkuwi_logo.png b/common/src/main/res/drawable-mdpi/ic_pezkuwi_logo.png new file mode 100644 index 0000000..5305480 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_pezkuwi_logo.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_pink_siri.png b/common/src/main/res/drawable-mdpi/ic_pink_siri.png new file mode 100644 index 0000000..6a76d3d Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_pink_siri.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_planet.png b/common/src/main/res/drawable-mdpi/ic_planet.png new file mode 100644 index 0000000..12595d4 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_planet.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_pool_staking_banner_picture.png b/common/src/main/res/drawable-mdpi/ic_pool_staking_banner_picture.png new file mode 100644 index 0000000..019a2fc Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_pool_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_powered_by_oak.png b/common/src/main/res/drawable-mdpi/ic_powered_by_oak.png new file mode 100644 index 0000000..d3a4b99 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_powered_by_oak.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_raw_seed_blur.png b/common/src/main/res/drawable-mdpi/ic_raw_seed_blur.png new file mode 100644 index 0000000..cf303d8 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_raw_seed_blur.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_rewards.png b/common/src/main/res/drawable-mdpi/ic_rewards.png new file mode 100644 index 0000000..a091bfd Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_rewards.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_small_star.png b/common/src/main/res/drawable-mdpi/ic_small_star.png new file mode 100644 index 0000000..5c2c54d Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_small_star.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_splash_background.webp b/common/src/main/res/drawable-mdpi/ic_splash_background.webp new file mode 100644 index 0000000..b700fa4 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_splash_background.webp differ diff --git a/common/src/main/res/drawable-mdpi/ic_stake_anytime.png b/common/src/main/res/drawable-mdpi/ic_stake_anytime.png new file mode 100644 index 0000000..ab6af3e Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_stake_anytime.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_swap_asset_default_background.png b/common/src/main/res/drawable-mdpi/ic_swap_asset_default_background.png new file mode 100644 index 0000000..72ff928 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_swap_asset_default_background.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_test_network.png b/common/src/main/res/drawable-mdpi/ic_test_network.png new file mode 100644 index 0000000..549e12b Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_test_network.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_timer_card.png b/common/src/main/res/drawable-mdpi/ic_timer_card.png new file mode 100644 index 0000000..7f6374d Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_timer_card.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_tinder_gov_empty_state.png b/common/src/main/res/drawable-mdpi/ic_tinder_gov_empty_state.png new file mode 100644 index 0000000..0759908 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_tinder_gov_empty_state.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_transak_provider_logo.png b/common/src/main/res/drawable-mdpi/ic_transak_provider_logo.png new file mode 100644 index 0000000..fb0d734 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_transak_provider_logo.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_unstake_anytime.png b/common/src/main/res/drawable-mdpi/ic_unstake_anytime.png new file mode 100644 index 0000000..5b3bedd Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_unstake_anytime.png differ diff --git a/common/src/main/res/drawable-mdpi/ic_yellow_siri.png b/common/src/main/res/drawable-mdpi/ic_yellow_siri.png new file mode 100644 index 0000000..ad41c3d Binary files /dev/null and b/common/src/main/res/drawable-mdpi/ic_yellow_siri.png differ diff --git a/common/src/main/res/drawable-mdpi/my_parity_signer.png b/common/src/main/res/drawable-mdpi/my_parity_signer.png new file mode 100644 index 0000000..e0209e3 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/my_parity_signer.png differ diff --git a/common/src/main/res/drawable-mdpi/polkadot_vault_account.png b/common/src/main/res/drawable-mdpi/polkadot_vault_account.png new file mode 100644 index 0000000..2d97db1 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/polkadot_vault_account.png differ diff --git a/common/src/main/res/drawable-mdpi/shield.png b/common/src/main/res/drawable-mdpi/shield.png new file mode 100644 index 0000000..dc695f0 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/shield.png differ diff --git a/common/src/main/res/drawable-mdpi/tinder_gov.png b/common/src/main/res/drawable-mdpi/tinder_gov.png new file mode 100644 index 0000000..afadf98 Binary files /dev/null and b/common/src/main/res/drawable-mdpi/tinder_gov.png differ diff --git a/common/src/main/res/drawable-xhdpi/advertisement_calendar.png b/common/src/main/res/drawable-xhdpi/advertisement_calendar.png new file mode 100644 index 0000000..0bd075f Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/advertisement_calendar.png differ diff --git a/common/src/main/res/drawable-xhdpi/advertisement_star.png b/common/src/main/res/drawable-xhdpi/advertisement_star.png new file mode 100644 index 0000000..caeb902 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/advertisement_star.png differ diff --git a/common/src/main/res/drawable-xhdpi/crowdloan_banner_image.png b/common/src/main/res/drawable-xhdpi/crowdloan_banner_image.png new file mode 100644 index 0000000..7b74e23 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/crowdloan_banner_image.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_banner_blue_gradient.png b/common/src/main/res/drawable-xhdpi/ic_banner_blue_gradient.png new file mode 100644 index 0000000..3858820 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_banner_blue_gradient.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_banner_delegate_image.webp b/common/src/main/res/drawable-xhdpi/ic_banner_delegate_image.webp new file mode 100644 index 0000000..a658c85 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_banner_delegate_image.webp differ diff --git a/common/src/main/res/drawable-xhdpi/ic_banner_grey_gradient.png b/common/src/main/res/drawable-xhdpi/ic_banner_grey_gradient.png new file mode 100644 index 0000000..973d077 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_banner_grey_gradient.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_banner_turquoise_gradient.png b/common/src/main/res/drawable-xhdpi/ic_banner_turquoise_gradient.png new file mode 100644 index 0000000..8845722 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_banner_turquoise_gradient.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_banner_warning.png b/common/src/main/res/drawable-xhdpi/ic_banner_warning.png new file mode 100644 index 0000000..abf0d17 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_banner_warning.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_banner_yellow_gradient.png b/common/src/main/res/drawable-xhdpi/ic_banner_yellow_gradient.png new file mode 100644 index 0000000..368689d Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_banner_yellow_gradient.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_banxa_provider_logo.png b/common/src/main/res/drawable-xhdpi/ic_banxa_provider_logo.png new file mode 100644 index 0000000..da85db2 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_banxa_provider_logo.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_bell.png b/common/src/main/res/drawable-xhdpi/ic_bell.png new file mode 100644 index 0000000..eba1fb1 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_bell.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_big_star.png b/common/src/main/res/drawable-xhdpi/ic_big_star.png new file mode 100644 index 0000000..eda6e5f Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_big_star.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_blue_siri.png b/common/src/main/res/drawable-xhdpi/ic_blue_siri.png new file mode 100644 index 0000000..09f20e0 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_blue_siri.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_cloud_backup_add.png b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_add.png new file mode 100644 index 0000000..fe9a66d Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_add.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_cloud_backup_delete.png b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_delete.png new file mode 100644 index 0000000..51f0306 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_delete.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_cloud_backup_error.png b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_error.png new file mode 100644 index 0000000..93f0b2d Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_error.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_cloud_backup_lock.png b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_lock.png new file mode 100644 index 0000000..139472f Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_lock.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_cloud_backup_password.png b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_password.png new file mode 100644 index 0000000..92980d0 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_password.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_cloud_backup_sync.png b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_sync.png new file mode 100644 index 0000000..a77f808 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_sync.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_cloud_backup_warning.png b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_warning.png new file mode 100644 index 0000000..7113166 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_cloud_backup_warning.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_container_timer_animated.png b/common/src/main/res/drawable-xhdpi/ic_container_timer_animated.png new file mode 100644 index 0000000..4b064e5 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_container_timer_animated.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_create_wallet_background.png b/common/src/main/res/drawable-xhdpi/ic_create_wallet_background.png new file mode 100644 index 0000000..dee51b1 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_create_wallet_background.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_critical_update.png b/common/src/main/res/drawable-xhdpi/ic_critical_update.png new file mode 100644 index 0000000..3aac974 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_critical_update.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_cycle.png b/common/src/main/res/drawable-xhdpi/ic_cycle.png new file mode 100644 index 0000000..8f3b709 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_cycle.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_direct_staking_banner_picture.png b/common/src/main/res/drawable-xhdpi/ic_direct_staking_banner_picture.png new file mode 100644 index 0000000..36a451b Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_direct_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_gift_packed.png b/common/src/main/res/drawable-xhdpi/ic_gift_packed.png new file mode 100644 index 0000000..8163b98 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_gift_packed.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_gift_unpacked.png b/common/src/main/res/drawable-xhdpi/ic_gift_unpacked.png new file mode 100644 index 0000000..7037b57 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_gift_unpacked.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_gifts_placeholder.png b/common/src/main/res/drawable-xhdpi/ic_gifts_placeholder.png new file mode 100644 index 0000000..0ee60ca Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_gifts_placeholder.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_import_option_cloud.png b/common/src/main/res/drawable-xhdpi/ic_import_option_cloud.png new file mode 100644 index 0000000..107d5cb Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_import_option_cloud.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_import_option_hardware.png b/common/src/main/res/drawable-xhdpi/ic_import_option_hardware.png new file mode 100644 index 0000000..86848fc Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_import_option_hardware.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_import_option_passphrase.png b/common/src/main/res/drawable-xhdpi/ic_import_option_passphrase.png new file mode 100644 index 0000000..55806b7 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_import_option_passphrase.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_import_option_trust_wallet.png b/common/src/main/res/drawable-xhdpi/ic_import_option_trust_wallet.png new file mode 100644 index 0000000..62164de Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_import_option_trust_wallet.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_import_option_watch_only.png b/common/src/main/res/drawable-xhdpi/ic_import_option_watch_only.png new file mode 100644 index 0000000..7e93de2 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_import_option_watch_only.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_flex_approve.png b/common/src/main/res/drawable-xhdpi/ic_ledger_flex_approve.png new file mode 100644 index 0000000..79d3b77 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_flex_approve.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_flex_error.png b/common/src/main/res/drawable-xhdpi/ic_ledger_flex_error.png new file mode 100644 index 0000000..bda52de Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_flex_error.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_flex_sign.png b/common/src/main/res/drawable-xhdpi/ic_ledger_flex_sign.png new file mode 100644 index 0000000..7747b4e Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_flex_sign.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_nano_gen5_approve.png b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_gen5_approve.png new file mode 100644 index 0000000..269589f Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_gen5_approve.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_nano_gen5_error.png b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_gen5_error.png new file mode 100644 index 0000000..81b3db9 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_gen5_error.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_nano_gen5_sign.png b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_gen5_sign.png new file mode 100644 index 0000000..5729805 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_gen5_sign.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_nano_s_approve.png b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_s_approve.png new file mode 100644 index 0000000..d4ea193 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_s_approve.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_nano_s_error.png b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_s_error.png new file mode 100644 index 0000000..5d93ba7 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_s_error.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_nano_x_approve.png b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_x_approve.png new file mode 100644 index 0000000..1e0c30c Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_x_approve.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_nano_x_error.png b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_x_error.png new file mode 100644 index 0000000..be049b2 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_nano_x_error.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_stax_approve.png b/common/src/main/res/drawable-xhdpi/ic_ledger_stax_approve.png new file mode 100644 index 0000000..337cc0c Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_stax_approve.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_stax_error.png b/common/src/main/res/drawable-xhdpi/ic_ledger_stax_error.png new file mode 100644 index 0000000..576ef83 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_stax_error.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_ledger_stax_sign.png b/common/src/main/res/drawable-xhdpi/ic_ledger_stax_sign.png new file mode 100644 index 0000000..5810df9 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_ledger_stax_sign.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_loading_screen_developed_by.png b/common/src/main/res/drawable-xhdpi/ic_loading_screen_developed_by.png new file mode 100644 index 0000000..fc5fdc3 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_loading_screen_developed_by.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_loading_screen_logo.webp b/common/src/main/res/drawable-xhdpi/ic_loading_screen_logo.webp new file mode 100644 index 0000000..5e859fa Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_loading_screen_logo.webp differ diff --git a/common/src/main/res/drawable-xhdpi/ic_main_banner_background.png b/common/src/main/res/drawable-xhdpi/ic_main_banner_background.png new file mode 100644 index 0000000..1af9cc8 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_main_banner_background.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_major_update.png b/common/src/main/res/drawable-xhdpi/ic_major_update.png new file mode 100644 index 0000000..9352d62 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_major_update.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_mercurio_provider_logo.png b/common/src/main/res/drawable-xhdpi/ic_mercurio_provider_logo.png new file mode 100644 index 0000000..8ee0ddc Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_mercurio_provider_logo.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_middle_star.png b/common/src/main/res/drawable-xhdpi/ic_middle_star.png new file mode 100644 index 0000000..5a16792 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_middle_star.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_migration_image.png b/common/src/main/res/drawable-xhdpi/ic_migration_image.png new file mode 100644 index 0000000..da77404 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_migration_image.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_mnemonic_card_blur.png b/common/src/main/res/drawable-xhdpi/ic_mnemonic_card_blur.png new file mode 100644 index 0000000..0065263 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_mnemonic_card_blur.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_monitor_your_stake.png b/common/src/main/res/drawable-xhdpi/ic_monitor_your_stake.png new file mode 100644 index 0000000..df4966e Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_monitor_your_stake.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_networks_banner_image.png b/common/src/main/res/drawable-xhdpi/ic_networks_banner_image.png new file mode 100644 index 0000000..6674a1c Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_networks_banner_image.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_no_added_networks.png b/common/src/main/res/drawable-xhdpi/ic_no_added_networks.png new file mode 100644 index 0000000..d190120 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_no_added_networks.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_participate_in_governance.png b/common/src/main/res/drawable-xhdpi/ic_participate_in_governance.png new file mode 100644 index 0000000..09658ef Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_participate_in_governance.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_pezkuwi_card_logo.png b/common/src/main/res/drawable-xhdpi/ic_pezkuwi_card_logo.png new file mode 100644 index 0000000..c75af3e Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_pezkuwi_card_logo.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_pezkuwi_logo.png b/common/src/main/res/drawable-xhdpi/ic_pezkuwi_logo.png new file mode 100644 index 0000000..1b2f8f1 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_pezkuwi_logo.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_pink_siri.png b/common/src/main/res/drawable-xhdpi/ic_pink_siri.png new file mode 100644 index 0000000..419fe78 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_pink_siri.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_planet.png b/common/src/main/res/drawable-xhdpi/ic_planet.png new file mode 100644 index 0000000..72f4cf5 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_planet.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_pool_staking_banner_picture.png b/common/src/main/res/drawable-xhdpi/ic_pool_staking_banner_picture.png new file mode 100644 index 0000000..42a9e60 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_pool_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_powered_by_oak.png b/common/src/main/res/drawable-xhdpi/ic_powered_by_oak.png new file mode 100644 index 0000000..049b9bd Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_powered_by_oak.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_raw_seed_blur.png b/common/src/main/res/drawable-xhdpi/ic_raw_seed_blur.png new file mode 100644 index 0000000..57aed86 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_raw_seed_blur.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_rewards.png b/common/src/main/res/drawable-xhdpi/ic_rewards.png new file mode 100644 index 0000000..fbd32b8 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_rewards.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_small_star.png b/common/src/main/res/drawable-xhdpi/ic_small_star.png new file mode 100644 index 0000000..0a413dc Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_small_star.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_splash_background.webp b/common/src/main/res/drawable-xhdpi/ic_splash_background.webp new file mode 100644 index 0000000..dd7cdb3 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_splash_background.webp differ diff --git a/common/src/main/res/drawable-xhdpi/ic_stake_anytime.png b/common/src/main/res/drawable-xhdpi/ic_stake_anytime.png new file mode 100644 index 0000000..bbb2a56 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_stake_anytime.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_swap_asset_default_background.png b/common/src/main/res/drawable-xhdpi/ic_swap_asset_default_background.png new file mode 100644 index 0000000..5aac780 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_swap_asset_default_background.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_test_network.png b/common/src/main/res/drawable-xhdpi/ic_test_network.png new file mode 100644 index 0000000..96f2ba1 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_test_network.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_timer_card.png b/common/src/main/res/drawable-xhdpi/ic_timer_card.png new file mode 100644 index 0000000..4627de8 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_timer_card.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_tinder_gov_empty_state.png b/common/src/main/res/drawable-xhdpi/ic_tinder_gov_empty_state.png new file mode 100644 index 0000000..628e64c Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_tinder_gov_empty_state.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_transak_provider_logo.png b/common/src/main/res/drawable-xhdpi/ic_transak_provider_logo.png new file mode 100644 index 0000000..a42dbac Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_transak_provider_logo.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_unstake_anytime.png b/common/src/main/res/drawable-xhdpi/ic_unstake_anytime.png new file mode 100644 index 0000000..4254602 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_unstake_anytime.png differ diff --git a/common/src/main/res/drawable-xhdpi/ic_yellow_siri.png b/common/src/main/res/drawable-xhdpi/ic_yellow_siri.png new file mode 100644 index 0000000..a5bda56 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/ic_yellow_siri.png differ diff --git a/common/src/main/res/drawable-xhdpi/my_parity_signer.png b/common/src/main/res/drawable-xhdpi/my_parity_signer.png new file mode 100644 index 0000000..97aff8c Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/my_parity_signer.png differ diff --git a/common/src/main/res/drawable-xhdpi/polkadot_vault_account.png b/common/src/main/res/drawable-xhdpi/polkadot_vault_account.png new file mode 100644 index 0000000..1e8889f Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/polkadot_vault_account.png differ diff --git a/common/src/main/res/drawable-xhdpi/shield.png b/common/src/main/res/drawable-xhdpi/shield.png new file mode 100644 index 0000000..3b48e79 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/shield.png differ diff --git a/common/src/main/res/drawable-xhdpi/tinder_gov.png b/common/src/main/res/drawable-xhdpi/tinder_gov.png new file mode 100644 index 0000000..7a0ca18 Binary files /dev/null and b/common/src/main/res/drawable-xhdpi/tinder_gov.png differ diff --git a/common/src/main/res/drawable-xxhdpi/advertisement_calendar.png b/common/src/main/res/drawable-xxhdpi/advertisement_calendar.png new file mode 100644 index 0000000..0fd7a3d Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/advertisement_calendar.png differ diff --git a/common/src/main/res/drawable-xxhdpi/advertisement_star.png b/common/src/main/res/drawable-xxhdpi/advertisement_star.png new file mode 100644 index 0000000..620a981 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/advertisement_star.png differ diff --git a/common/src/main/res/drawable-xxhdpi/crowdloan_banner_image.png b/common/src/main/res/drawable-xxhdpi/crowdloan_banner_image.png new file mode 100644 index 0000000..683434f Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/crowdloan_banner_image.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_banner_blue_gradient.png b/common/src/main/res/drawable-xxhdpi/ic_banner_blue_gradient.png new file mode 100644 index 0000000..d9e1206 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_banner_blue_gradient.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_banner_delegate_image.webp b/common/src/main/res/drawable-xxhdpi/ic_banner_delegate_image.webp new file mode 100644 index 0000000..2506c55 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_banner_delegate_image.webp differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_banner_grey_gradient.png b/common/src/main/res/drawable-xxhdpi/ic_banner_grey_gradient.png new file mode 100644 index 0000000..a2b7ee3 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_banner_grey_gradient.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_banner_turquoise_gradient.png b/common/src/main/res/drawable-xxhdpi/ic_banner_turquoise_gradient.png new file mode 100644 index 0000000..e7d5ebc Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_banner_turquoise_gradient.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_banner_warning.png b/common/src/main/res/drawable-xxhdpi/ic_banner_warning.png new file mode 100644 index 0000000..ada6919 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_banner_warning.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_banner_yellow_gradient.png b/common/src/main/res/drawable-xxhdpi/ic_banner_yellow_gradient.png new file mode 100644 index 0000000..e18e239 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_banner_yellow_gradient.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_banxa_provider_logo.png b/common/src/main/res/drawable-xxhdpi/ic_banxa_provider_logo.png new file mode 100644 index 0000000..a8f63c0 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_banxa_provider_logo.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_bell.png b/common/src/main/res/drawable-xxhdpi/ic_bell.png new file mode 100644 index 0000000..23504fd Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_bell.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_big_star.png b/common/src/main/res/drawable-xxhdpi/ic_big_star.png new file mode 100644 index 0000000..8200d07 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_big_star.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_blue_siri.png b/common/src/main/res/drawable-xxhdpi/ic_blue_siri.png new file mode 100644 index 0000000..48390f0 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_blue_siri.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_add.png b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_add.png new file mode 100644 index 0000000..b1d6b2a Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_add.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_delete.png b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_delete.png new file mode 100644 index 0000000..7785a5c Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_delete.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_error.png b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_error.png new file mode 100644 index 0000000..245b00c Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_error.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_lock.png b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_lock.png new file mode 100644 index 0000000..7d1efb7 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_lock.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_password.png b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_password.png new file mode 100644 index 0000000..933e4be Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_password.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_sync.png b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_sync.png new file mode 100644 index 0000000..52c63c7 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_sync.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_warning.png b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_warning.png new file mode 100644 index 0000000..ad7ef37 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_cloud_backup_warning.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_container_timer_animated.png b/common/src/main/res/drawable-xxhdpi/ic_container_timer_animated.png new file mode 100644 index 0000000..c626294 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_container_timer_animated.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_create_wallet_background.png b/common/src/main/res/drawable-xxhdpi/ic_create_wallet_background.png new file mode 100644 index 0000000..d88cc92 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_create_wallet_background.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_critical_update.png b/common/src/main/res/drawable-xxhdpi/ic_critical_update.png new file mode 100644 index 0000000..bfbff40 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_critical_update.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_cycle.png b/common/src/main/res/drawable-xxhdpi/ic_cycle.png new file mode 100644 index 0000000..35d294a Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_cycle.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_direct_staking_banner_picture.png b/common/src/main/res/drawable-xxhdpi/ic_direct_staking_banner_picture.png new file mode 100644 index 0000000..6baf0c2 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_direct_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_gift_packed.png b/common/src/main/res/drawable-xxhdpi/ic_gift_packed.png new file mode 100644 index 0000000..55cad0c Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_gift_packed.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_gift_unpacked.png b/common/src/main/res/drawable-xxhdpi/ic_gift_unpacked.png new file mode 100644 index 0000000..7b423bc Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_gift_unpacked.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_gifts_placeholder.png b/common/src/main/res/drawable-xxhdpi/ic_gifts_placeholder.png new file mode 100644 index 0000000..6105e8d Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_gifts_placeholder.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_import_option_cloud.png b/common/src/main/res/drawable-xxhdpi/ic_import_option_cloud.png new file mode 100644 index 0000000..968c4ae Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_import_option_cloud.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_import_option_hardware.png b/common/src/main/res/drawable-xxhdpi/ic_import_option_hardware.png new file mode 100644 index 0000000..ec40caa Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_import_option_hardware.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_import_option_passphrase.png b/common/src/main/res/drawable-xxhdpi/ic_import_option_passphrase.png new file mode 100644 index 0000000..fd5c8cd Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_import_option_passphrase.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_import_option_trust_wallet.png b/common/src/main/res/drawable-xxhdpi/ic_import_option_trust_wallet.png new file mode 100644 index 0000000..d91635e Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_import_option_trust_wallet.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_import_option_watch_only.png b/common/src/main/res/drawable-xxhdpi/ic_import_option_watch_only.png new file mode 100644 index 0000000..9c8492b Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_import_option_watch_only.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_flex_approve.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_flex_approve.png new file mode 100644 index 0000000..8f28193 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_flex_approve.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_flex_error.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_flex_error.png new file mode 100644 index 0000000..2d11c2e Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_flex_error.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_flex_sign.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_flex_sign.png new file mode 100644 index 0000000..cc80786 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_flex_sign.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_gen5_approve.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_gen5_approve.png new file mode 100644 index 0000000..4b65639 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_gen5_approve.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_gen5_error.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_gen5_error.png new file mode 100644 index 0000000..ea20a61 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_gen5_error.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_gen5_sign.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_gen5_sign.png new file mode 100644 index 0000000..745e92e Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_gen5_sign.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_s_approve.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_s_approve.png new file mode 100644 index 0000000..1001fef Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_s_approve.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_s_error.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_s_error.png new file mode 100644 index 0000000..292d5ec Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_s_error.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_x_approve.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_x_approve.png new file mode 100644 index 0000000..bf327fb Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_x_approve.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_x_error.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_x_error.png new file mode 100644 index 0000000..7abed0b Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_nano_x_error.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_stax_approve.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_stax_approve.png new file mode 100644 index 0000000..4cdc043 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_stax_approve.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_stax_error.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_stax_error.png new file mode 100644 index 0000000..14c100a Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_stax_error.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_ledger_stax_sign.png b/common/src/main/res/drawable-xxhdpi/ic_ledger_stax_sign.png new file mode 100644 index 0000000..47a4d5c Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_ledger_stax_sign.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_loading_screen_developed_by.png b/common/src/main/res/drawable-xxhdpi/ic_loading_screen_developed_by.png new file mode 100644 index 0000000..164cd90 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_loading_screen_developed_by.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_loading_screen_logo.webp b/common/src/main/res/drawable-xxhdpi/ic_loading_screen_logo.webp new file mode 100644 index 0000000..808ab0a Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_loading_screen_logo.webp differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_main_banner_background.png b/common/src/main/res/drawable-xxhdpi/ic_main_banner_background.png new file mode 100644 index 0000000..ceb52da Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_main_banner_background.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_major_update.png b/common/src/main/res/drawable-xxhdpi/ic_major_update.png new file mode 100644 index 0000000..22c81e3 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_major_update.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_mercurio_provider_logo.png b/common/src/main/res/drawable-xxhdpi/ic_mercurio_provider_logo.png new file mode 100644 index 0000000..355ea1b Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_mercurio_provider_logo.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_middle_star.png b/common/src/main/res/drawable-xxhdpi/ic_middle_star.png new file mode 100644 index 0000000..440bd3b Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_middle_star.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_migration_image.png b/common/src/main/res/drawable-xxhdpi/ic_migration_image.png new file mode 100644 index 0000000..5ad7b71 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_migration_image.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_mnemonic_card_blur.png b/common/src/main/res/drawable-xxhdpi/ic_mnemonic_card_blur.png new file mode 100644 index 0000000..f6e6e1b Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_mnemonic_card_blur.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_monitor_your_stake.png b/common/src/main/res/drawable-xxhdpi/ic_monitor_your_stake.png new file mode 100644 index 0000000..511eec5 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_monitor_your_stake.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_networks_banner_image.png b/common/src/main/res/drawable-xxhdpi/ic_networks_banner_image.png new file mode 100644 index 0000000..b9fd47d Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_networks_banner_image.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_no_added_networks.png b/common/src/main/res/drawable-xxhdpi/ic_no_added_networks.png new file mode 100644 index 0000000..d30010e Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_no_added_networks.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_participate_in_governance.png b/common/src/main/res/drawable-xxhdpi/ic_participate_in_governance.png new file mode 100644 index 0000000..44f349f Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_participate_in_governance.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_pezkuwi_card_logo.png b/common/src/main/res/drawable-xxhdpi/ic_pezkuwi_card_logo.png new file mode 100644 index 0000000..246f028 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_pezkuwi_card_logo.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_pezkuwi_logo.png b/common/src/main/res/drawable-xxhdpi/ic_pezkuwi_logo.png new file mode 100644 index 0000000..968feb8 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_pezkuwi_logo.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_pink_siri.png b/common/src/main/res/drawable-xxhdpi/ic_pink_siri.png new file mode 100644 index 0000000..f22ee4a Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_pink_siri.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_planet.png b/common/src/main/res/drawable-xxhdpi/ic_planet.png new file mode 100644 index 0000000..21a3b82 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_planet.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_pool_staking_banner_picture.png b/common/src/main/res/drawable-xxhdpi/ic_pool_staking_banner_picture.png new file mode 100644 index 0000000..9cbeb1d Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_pool_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_powered_by_oak.png b/common/src/main/res/drawable-xxhdpi/ic_powered_by_oak.png new file mode 100644 index 0000000..136e3ee Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_powered_by_oak.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_raw_seed_blur.png b/common/src/main/res/drawable-xxhdpi/ic_raw_seed_blur.png new file mode 100644 index 0000000..518e56a Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_raw_seed_blur.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_rewards.png b/common/src/main/res/drawable-xxhdpi/ic_rewards.png new file mode 100644 index 0000000..e8538e3 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_rewards.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_small_star.png b/common/src/main/res/drawable-xxhdpi/ic_small_star.png new file mode 100644 index 0000000..d6ac345 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_small_star.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_splash_background.webp b/common/src/main/res/drawable-xxhdpi/ic_splash_background.webp new file mode 100644 index 0000000..fd57671 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_splash_background.webp differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_stake_anytime.png b/common/src/main/res/drawable-xxhdpi/ic_stake_anytime.png new file mode 100644 index 0000000..26158f9 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_stake_anytime.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_swap_asset_default_background.png b/common/src/main/res/drawable-xxhdpi/ic_swap_asset_default_background.png new file mode 100644 index 0000000..0399912 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_swap_asset_default_background.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_test_network.png b/common/src/main/res/drawable-xxhdpi/ic_test_network.png new file mode 100644 index 0000000..d4923f4 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_test_network.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_timer_card.png b/common/src/main/res/drawable-xxhdpi/ic_timer_card.png new file mode 100644 index 0000000..b438a9f Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_timer_card.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_tinder_gov_empty_state.png b/common/src/main/res/drawable-xxhdpi/ic_tinder_gov_empty_state.png new file mode 100644 index 0000000..04a4897 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_tinder_gov_empty_state.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_transak_provider_logo.png b/common/src/main/res/drawable-xxhdpi/ic_transak_provider_logo.png new file mode 100644 index 0000000..5522877 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_transak_provider_logo.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_unstake_anytime.png b/common/src/main/res/drawable-xxhdpi/ic_unstake_anytime.png new file mode 100644 index 0000000..630e759 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_unstake_anytime.png differ diff --git a/common/src/main/res/drawable-xxhdpi/ic_yellow_siri.png b/common/src/main/res/drawable-xxhdpi/ic_yellow_siri.png new file mode 100644 index 0000000..ce925d4 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/ic_yellow_siri.png differ diff --git a/common/src/main/res/drawable-xxhdpi/my_parity_signer.png b/common/src/main/res/drawable-xxhdpi/my_parity_signer.png new file mode 100644 index 0000000..8fca975 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/my_parity_signer.png differ diff --git a/common/src/main/res/drawable-xxhdpi/polkadot_vault_account.png b/common/src/main/res/drawable-xxhdpi/polkadot_vault_account.png new file mode 100644 index 0000000..cf2ede3 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/polkadot_vault_account.png differ diff --git a/common/src/main/res/drawable-xxhdpi/shield.png b/common/src/main/res/drawable-xxhdpi/shield.png new file mode 100644 index 0000000..8f33d9b Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/shield.png differ diff --git a/common/src/main/res/drawable-xxhdpi/tinder_gov.png b/common/src/main/res/drawable-xxhdpi/tinder_gov.png new file mode 100644 index 0000000..00ca4e1 Binary files /dev/null and b/common/src/main/res/drawable-xxhdpi/tinder_gov.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/advertisement_calendar.png b/common/src/main/res/drawable-xxxhdpi/advertisement_calendar.png new file mode 100644 index 0000000..6a8ca56 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/advertisement_calendar.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/advertisement_star.png b/common/src/main/res/drawable-xxxhdpi/advertisement_star.png new file mode 100644 index 0000000..e31cfe5 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/advertisement_star.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/crowdloan_banner_image.png b/common/src/main/res/drawable-xxxhdpi/crowdloan_banner_image.png new file mode 100644 index 0000000..828683a Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/crowdloan_banner_image.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_banner_blue_gradient.png b/common/src/main/res/drawable-xxxhdpi/ic_banner_blue_gradient.png new file mode 100644 index 0000000..76fff92 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_banner_blue_gradient.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_banner_delegate_image.webp b/common/src/main/res/drawable-xxxhdpi/ic_banner_delegate_image.webp new file mode 100644 index 0000000..5ae9a06 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_banner_delegate_image.webp differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_banner_grey_gradient.png b/common/src/main/res/drawable-xxxhdpi/ic_banner_grey_gradient.png new file mode 100644 index 0000000..b365237 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_banner_grey_gradient.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_banner_turquoise_gradient.png b/common/src/main/res/drawable-xxxhdpi/ic_banner_turquoise_gradient.png new file mode 100644 index 0000000..f23a4ad Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_banner_turquoise_gradient.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_banner_warning.png b/common/src/main/res/drawable-xxxhdpi/ic_banner_warning.png new file mode 100644 index 0000000..6f2b9a2 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_banner_warning.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_banner_yellow_gradient.png b/common/src/main/res/drawable-xxxhdpi/ic_banner_yellow_gradient.png new file mode 100644 index 0000000..da0e575 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_banner_yellow_gradient.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_banxa_provider_logo.png b/common/src/main/res/drawable-xxxhdpi/ic_banxa_provider_logo.png new file mode 100644 index 0000000..16b5c59 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_banxa_provider_logo.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_bell.png b/common/src/main/res/drawable-xxxhdpi/ic_bell.png new file mode 100644 index 0000000..5898b59 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_bell.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_big_star.png b/common/src/main/res/drawable-xxxhdpi/ic_big_star.png new file mode 100644 index 0000000..81914c5 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_big_star.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_blue_siri.png b/common/src/main/res/drawable-xxxhdpi/ic_blue_siri.png new file mode 100644 index 0000000..da815b0 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_blue_siri.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_add.png b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_add.png new file mode 100644 index 0000000..49a4024 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_add.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_delete.png b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_delete.png new file mode 100644 index 0000000..2f40b2f Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_delete.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_error.png b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_error.png new file mode 100644 index 0000000..a8430d8 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_error.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_lock.png b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_lock.png new file mode 100644 index 0000000..3fae511 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_lock.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_password.png b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_password.png new file mode 100644 index 0000000..3b8f6d9 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_password.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_sync.png b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_sync.png new file mode 100644 index 0000000..54aa24d Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_sync.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_warning.png b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_warning.png new file mode 100644 index 0000000..9d71cbb Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_cloud_backup_warning.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_container_timer_animated.png b/common/src/main/res/drawable-xxxhdpi/ic_container_timer_animated.png new file mode 100644 index 0000000..ac7e967 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_container_timer_animated.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_create_wallet_background.png b/common/src/main/res/drawable-xxxhdpi/ic_create_wallet_background.png new file mode 100644 index 0000000..9632fd9 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_create_wallet_background.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_critical_update.png b/common/src/main/res/drawable-xxxhdpi/ic_critical_update.png new file mode 100644 index 0000000..44c861a Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_critical_update.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_cycle.png b/common/src/main/res/drawable-xxxhdpi/ic_cycle.png new file mode 100644 index 0000000..d80323e Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_cycle.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_direct_staking_banner_picture.png b/common/src/main/res/drawable-xxxhdpi/ic_direct_staking_banner_picture.png new file mode 100644 index 0000000..db16571 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_direct_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_gift_packed.png b/common/src/main/res/drawable-xxxhdpi/ic_gift_packed.png new file mode 100644 index 0000000..f882da6 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_gift_packed.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_gift_unpacked.png b/common/src/main/res/drawable-xxxhdpi/ic_gift_unpacked.png new file mode 100644 index 0000000..d3e271b Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_gift_unpacked.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_gifts_placeholder.png b/common/src/main/res/drawable-xxxhdpi/ic_gifts_placeholder.png new file mode 100644 index 0000000..5b7fff1 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_gifts_placeholder.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_import_option_cloud.png b/common/src/main/res/drawable-xxxhdpi/ic_import_option_cloud.png new file mode 100644 index 0000000..27c3a95 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_import_option_cloud.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_import_option_hardware.png b/common/src/main/res/drawable-xxxhdpi/ic_import_option_hardware.png new file mode 100644 index 0000000..5bd5c43 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_import_option_hardware.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_import_option_passphrase.png b/common/src/main/res/drawable-xxxhdpi/ic_import_option_passphrase.png new file mode 100644 index 0000000..73c8a2a Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_import_option_passphrase.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_import_option_trust_wallet.png b/common/src/main/res/drawable-xxxhdpi/ic_import_option_trust_wallet.png new file mode 100644 index 0000000..59362a0 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_import_option_trust_wallet.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_import_option_watch_only.png b/common/src/main/res/drawable-xxxhdpi/ic_import_option_watch_only.png new file mode 100644 index 0000000..2bf3811 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_import_option_watch_only.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_flex_approve.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_flex_approve.png new file mode 100644 index 0000000..5e36e00 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_flex_approve.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_flex_error.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_flex_error.png new file mode 100644 index 0000000..9c6256d Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_flex_error.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_flex_sign.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_flex_sign.png new file mode 100644 index 0000000..4bcfb47 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_flex_sign.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_gen5_approve.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_gen5_approve.png new file mode 100644 index 0000000..97078c5 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_gen5_approve.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_gen5_error.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_gen5_error.png new file mode 100644 index 0000000..84326ff Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_gen5_error.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_gen5_sign.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_gen5_sign.png new file mode 100644 index 0000000..7abf0b5 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_gen5_sign.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_s_approve.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_s_approve.png new file mode 100644 index 0000000..08d48be Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_s_approve.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_s_error.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_s_error.png new file mode 100644 index 0000000..bcc7afb Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_s_error.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_x_approve.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_x_approve.png new file mode 100644 index 0000000..992d8c6 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_x_approve.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_x_error.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_x_error.png new file mode 100644 index 0000000..d885682 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_nano_x_error.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_stax_approve.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_stax_approve.png new file mode 100644 index 0000000..5d0407d Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_stax_approve.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_stax_error.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_stax_error.png new file mode 100644 index 0000000..1ec878b Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_stax_error.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_ledger_stax_sign.png b/common/src/main/res/drawable-xxxhdpi/ic_ledger_stax_sign.png new file mode 100644 index 0000000..f2e3fd3 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_ledger_stax_sign.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_loading_screen_developed_by.png b/common/src/main/res/drawable-xxxhdpi/ic_loading_screen_developed_by.png new file mode 100644 index 0000000..5b581fa Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_loading_screen_developed_by.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_loading_screen_logo.webp b/common/src/main/res/drawable-xxxhdpi/ic_loading_screen_logo.webp new file mode 100644 index 0000000..a4d92a5 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_loading_screen_logo.webp differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_main_banner_background.png b/common/src/main/res/drawable-xxxhdpi/ic_main_banner_background.png new file mode 100644 index 0000000..014d8e2 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_main_banner_background.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_major_update.png b/common/src/main/res/drawable-xxxhdpi/ic_major_update.png new file mode 100644 index 0000000..26e7ba3 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_major_update.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_mercurio_provider_logo.png b/common/src/main/res/drawable-xxxhdpi/ic_mercurio_provider_logo.png new file mode 100644 index 0000000..12d4e4a Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_mercurio_provider_logo.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_middle_star.png b/common/src/main/res/drawable-xxxhdpi/ic_middle_star.png new file mode 100644 index 0000000..1498f03 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_middle_star.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_migration_image.png b/common/src/main/res/drawable-xxxhdpi/ic_migration_image.png new file mode 100644 index 0000000..1d01eba Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_migration_image.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_mnemonic_card_blur.png b/common/src/main/res/drawable-xxxhdpi/ic_mnemonic_card_blur.png new file mode 100644 index 0000000..448f6ce Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_mnemonic_card_blur.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_monitor_your_stake.png b/common/src/main/res/drawable-xxxhdpi/ic_monitor_your_stake.png new file mode 100644 index 0000000..534d50b Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_monitor_your_stake.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_networks_banner_image.png b/common/src/main/res/drawable-xxxhdpi/ic_networks_banner_image.png new file mode 100644 index 0000000..ee5f8e0 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_networks_banner_image.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_no_added_networks.png b/common/src/main/res/drawable-xxxhdpi/ic_no_added_networks.png new file mode 100644 index 0000000..c0f4ee0 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_no_added_networks.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_participate_in_governance.png b/common/src/main/res/drawable-xxxhdpi/ic_participate_in_governance.png new file mode 100644 index 0000000..f2fe9eb Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_participate_in_governance.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_pezkuwi_card_logo.png b/common/src/main/res/drawable-xxxhdpi/ic_pezkuwi_card_logo.png new file mode 100644 index 0000000..1124d9f Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_pezkuwi_card_logo.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_pezkuwi_logo.png b/common/src/main/res/drawable-xxxhdpi/ic_pezkuwi_logo.png new file mode 100644 index 0000000..f861c18 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_pezkuwi_logo.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_pink_siri.png b/common/src/main/res/drawable-xxxhdpi/ic_pink_siri.png new file mode 100644 index 0000000..ceb763b Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_pink_siri.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_planet.png b/common/src/main/res/drawable-xxxhdpi/ic_planet.png new file mode 100644 index 0000000..714f45c Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_planet.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_pool_staking_banner_picture.png b/common/src/main/res/drawable-xxxhdpi/ic_pool_staking_banner_picture.png new file mode 100644 index 0000000..6427e93 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_pool_staking_banner_picture.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_powered_by_oak.png b/common/src/main/res/drawable-xxxhdpi/ic_powered_by_oak.png new file mode 100644 index 0000000..214b067 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_powered_by_oak.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_raw_seed_blur.png b/common/src/main/res/drawable-xxxhdpi/ic_raw_seed_blur.png new file mode 100644 index 0000000..7b8d2fc Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_raw_seed_blur.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_rewards.png b/common/src/main/res/drawable-xxxhdpi/ic_rewards.png new file mode 100644 index 0000000..b7db4aa Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_rewards.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_small_star.png b/common/src/main/res/drawable-xxxhdpi/ic_small_star.png new file mode 100644 index 0000000..3d48c2c Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_small_star.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_splash_background.webp b/common/src/main/res/drawable-xxxhdpi/ic_splash_background.webp new file mode 100644 index 0000000..5c9d834 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_splash_background.webp differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_stake_anytime.png b/common/src/main/res/drawable-xxxhdpi/ic_stake_anytime.png new file mode 100644 index 0000000..35063dd Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_stake_anytime.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_swap_asset_default_background.png b/common/src/main/res/drawable-xxxhdpi/ic_swap_asset_default_background.png new file mode 100644 index 0000000..0e7c8a6 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_swap_asset_default_background.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_test_network.png b/common/src/main/res/drawable-xxxhdpi/ic_test_network.png new file mode 100644 index 0000000..4813793 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_test_network.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_timer_card.png b/common/src/main/res/drawable-xxxhdpi/ic_timer_card.png new file mode 100644 index 0000000..7d7b1a1 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_timer_card.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_tinder_gov_empty_state.png b/common/src/main/res/drawable-xxxhdpi/ic_tinder_gov_empty_state.png new file mode 100644 index 0000000..2db5601 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_tinder_gov_empty_state.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_transak_provider_logo.png b/common/src/main/res/drawable-xxxhdpi/ic_transak_provider_logo.png new file mode 100644 index 0000000..c6808ba Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_transak_provider_logo.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_unstake_anytime.png b/common/src/main/res/drawable-xxxhdpi/ic_unstake_anytime.png new file mode 100644 index 0000000..2fccb64 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_unstake_anytime.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/ic_yellow_siri.png b/common/src/main/res/drawable-xxxhdpi/ic_yellow_siri.png new file mode 100644 index 0000000..8d4031a Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/ic_yellow_siri.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/my_parity_signer.png b/common/src/main/res/drawable-xxxhdpi/my_parity_signer.png new file mode 100644 index 0000000..3784f25 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/my_parity_signer.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/polkadot_vault_account.png b/common/src/main/res/drawable-xxxhdpi/polkadot_vault_account.png new file mode 100644 index 0000000..40324b6 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/polkadot_vault_account.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/shield.png b/common/src/main/res/drawable-xxxhdpi/shield.png new file mode 100644 index 0000000..ce586c9 Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/shield.png differ diff --git a/common/src/main/res/drawable-xxxhdpi/tinder_gov.png b/common/src/main/res/drawable-xxxhdpi/tinder_gov.png new file mode 100644 index 0000000..471ec5f Binary files /dev/null and b/common/src/main/res/drawable-xxxhdpi/tinder_gov.png differ diff --git a/common/src/main/res/drawable/account_migration_background.png b/common/src/main/res/drawable/account_migration_background.png new file mode 100644 index 0000000..77d4ead Binary files /dev/null and b/common/src/main/res/drawable/account_migration_background.png differ diff --git a/common/src/main/res/drawable/approve_with_pin.xml b/common/src/main/res/drawable/approve_with_pin.xml new file mode 100644 index 0000000..959bfab --- /dev/null +++ b/common/src/main/res/drawable/approve_with_pin.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/common/src/main/res/drawable/bg_add_custom_network.xml b/common/src/main/res/drawable/bg_add_custom_network.xml new file mode 100644 index 0000000..e37e897 --- /dev/null +++ b/common/src/main/res/drawable/bg_add_custom_network.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/bg_appearance_container.xml b/common/src/main/res/drawable/bg_appearance_container.xml new file mode 100644 index 0000000..35e891d --- /dev/null +++ b/common/src/main/res/drawable/bg_appearance_container.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_big_input_shape.xml b/common/src/main/res/drawable/bg_big_input_shape.xml new file mode 100644 index 0000000..71d597b --- /dev/null +++ b/common/src/main/res/drawable/bg_big_input_shape.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/bg_big_input_shape_focused.xml b/common/src/main/res/drawable/bg_big_input_shape_focused.xml new file mode 100644 index 0000000..2f0d931 --- /dev/null +++ b/common/src/main/res/drawable/bg_big_input_shape_focused.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/bg_big_input_shape_selector.xml b/common/src/main/res/drawable/bg_big_input_shape_selector.xml new file mode 100644 index 0000000..e26222d --- /dev/null +++ b/common/src/main/res/drawable/bg_big_input_shape_selector.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_block_12.xml b/common/src/main/res/drawable/bg_block_12.xml new file mode 100644 index 0000000..f8787d9 --- /dev/null +++ b/common/src/main/res/drawable/bg_block_12.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_block_16.xml b/common/src/main/res/drawable/bg_block_16.xml new file mode 100644 index 0000000..e3f407f --- /dev/null +++ b/common/src/main/res/drawable/bg_block_16.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_block_container.xml b/common/src/main/res/drawable/bg_block_container.xml new file mode 100644 index 0000000..f8787d9 --- /dev/null +++ b/common/src/main/res/drawable/bg_block_container.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_bottom_button_container.xml b/common/src/main/res/drawable/bg_bottom_button_container.xml new file mode 100644 index 0000000..ce43b1c --- /dev/null +++ b/common/src/main/res/drawable/bg_bottom_button_container.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_browser_tabs_outline.xml b/common/src/main/res/drawable/bg_browser_tabs_outline.xml new file mode 100644 index 0000000..c535992 --- /dev/null +++ b/common/src/main/res/drawable/bg_browser_tabs_outline.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/common/src/main/res/drawable/bg_button_outline.xml b/common/src/main/res/drawable/bg_button_outline.xml new file mode 100644 index 0000000..cf68fa1 --- /dev/null +++ b/common/src/main/res/drawable/bg_button_outline.xml @@ -0,0 +1,17 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_button_outline_selector.xml b/common/src/main/res/drawable/bg_button_outline_selector.xml new file mode 100644 index 0000000..5d72ae8 --- /dev/null +++ b/common/src/main/res/drawable/bg_button_outline_selector.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_button_primary.xml b/common/src/main/res/drawable/bg_button_primary.xml new file mode 100644 index 0000000..45e48f6 --- /dev/null +++ b/common/src/main/res/drawable/bg_button_primary.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_button_primary_disabled.xml b/common/src/main/res/drawable/bg_button_primary_disabled.xml new file mode 100644 index 0000000..d9aac16 --- /dev/null +++ b/common/src/main/res/drawable/bg_button_primary_disabled.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_button_primary_selector.xml b/common/src/main/res/drawable/bg_button_primary_selector.xml new file mode 100644 index 0000000..4bac3b3 --- /dev/null +++ b/common/src/main/res/drawable/bg_button_primary_selector.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_chain_placeholder.xml b/common/src/main/res/drawable/bg_chain_placeholder.xml new file mode 100644 index 0000000..86a3fa5 --- /dev/null +++ b/common/src/main/res/drawable/bg_chain_placeholder.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/common/src/main/res/drawable/bg_chain_wallet.xml b/common/src/main/res/drawable/bg_chain_wallet.xml new file mode 100644 index 0000000..42d8f9f --- /dev/null +++ b/common/src/main/res/drawable/bg_chain_wallet.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_chip_6.xml b/common/src/main/res/drawable/bg_chip_6.xml new file mode 100644 index 0000000..844514f --- /dev/null +++ b/common/src/main/res/drawable/bg_chip_6.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_chip_8.xml b/common/src/main/res/drawable/bg_chip_8.xml new file mode 100644 index 0000000..273dd2a --- /dev/null +++ b/common/src/main/res/drawable/bg_chip_8.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_chip_oval.xml b/common/src/main/res/drawable/bg_chip_oval.xml new file mode 100644 index 0000000..ceb1fa3 --- /dev/null +++ b/common/src/main/res/drawable/bg_chip_oval.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_circle_button_ripple_background.xml b/common/src/main/res/drawable/bg_circle_button_ripple_background.xml new file mode 100644 index 0000000..1a28906 --- /dev/null +++ b/common/src/main/res/drawable/bg_circle_button_ripple_background.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/bg_cloud_backup_progress.xml b/common/src/main/res/drawable/bg_cloud_backup_progress.xml new file mode 100644 index 0000000..bb1a34a --- /dev/null +++ b/common/src/main/res/drawable/bg_cloud_backup_progress.xml @@ -0,0 +1,4 @@ + + + diff --git a/common/src/main/res/drawable/bg_common_circle.xml b/common/src/main/res/drawable/bg_common_circle.xml new file mode 100644 index 0000000..982cd68 --- /dev/null +++ b/common/src/main/res/drawable/bg_common_circle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/common/src/main/res/drawable/bg_container_with_border_circle.xml b/common/src/main/res/drawable/bg_container_with_border_circle.xml new file mode 100644 index 0000000..2267d8a --- /dev/null +++ b/common/src/main/res/drawable/bg_container_with_border_circle.xml @@ -0,0 +1,5 @@ + + + + diff --git a/common/src/main/res/drawable/bg_crowdloan_banner.xml b/common/src/main/res/drawable/bg_crowdloan_banner.xml new file mode 100644 index 0000000..5eb1818 --- /dev/null +++ b/common/src/main/res/drawable/bg_crowdloan_banner.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/common/src/main/res/drawable/bg_currency.xml b/common/src/main/res/drawable/bg_currency.xml new file mode 100644 index 0000000..bdf03ad --- /dev/null +++ b/common/src/main/res/drawable/bg_currency.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_dapp_entry_point.xml b/common/src/main/res/drawable/bg_dapp_entry_point.xml new file mode 100644 index 0000000..3caefb9 --- /dev/null +++ b/common/src/main/res/drawable/bg_dapp_entry_point.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_delegate_organisation.xml b/common/src/main/res/drawable/bg_delegate_organisation.xml new file mode 100644 index 0000000..57cbea1 --- /dev/null +++ b/common/src/main/res/drawable/bg_delegate_organisation.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_icon_big.xml b/common/src/main/res/drawable/bg_icon_big.xml new file mode 100644 index 0000000..9bb8470 --- /dev/null +++ b/common/src/main/res/drawable/bg_icon_big.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_icon_big_dashed.xml b/common/src/main/res/drawable/bg_icon_big_dashed.xml new file mode 100644 index 0000000..a3493f0 --- /dev/null +++ b/common/src/main/res/drawable/bg_icon_big_dashed.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_icon_container_on_color.xml b/common/src/main/res/drawable/bg_icon_container_on_color.xml new file mode 100644 index 0000000..720413b --- /dev/null +++ b/common/src/main/res/drawable/bg_icon_container_on_color.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_induvidual_circle_border.xml b/common/src/main/res/drawable/bg_induvidual_circle_border.xml new file mode 100644 index 0000000..0196d5d --- /dev/null +++ b/common/src/main/res/drawable/bg_induvidual_circle_border.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_info_card_back.xml b/common/src/main/res/drawable/bg_info_card_back.xml new file mode 100644 index 0000000..4e6e42d --- /dev/null +++ b/common/src/main/res/drawable/bg_info_card_back.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_networks_banner.xml b/common/src/main/res/drawable/bg_networks_banner.xml new file mode 100644 index 0000000..c186026 --- /dev/null +++ b/common/src/main/res/drawable/bg_networks_banner.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/common/src/main/res/drawable/bg_primary_list_item.xml b/common/src/main/res/drawable/bg_primary_list_item.xml new file mode 100644 index 0000000..2c0af2f --- /dev/null +++ b/common/src/main/res/drawable/bg_primary_list_item.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_primary_list_item_corner_12.xml b/common/src/main/res/drawable/bg_primary_list_item_corner_12.xml new file mode 100644 index 0000000..70b4025 --- /dev/null +++ b/common/src/main/res/drawable/bg_primary_list_item_corner_12.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_primary_list_item_corner_12_solid.xml b/common/src/main/res/drawable/bg_primary_list_item_corner_12_solid.xml new file mode 100644 index 0000000..ca2c3db --- /dev/null +++ b/common/src/main/res/drawable/bg_primary_list_item_corner_12_solid.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_progress_bar.xml b/common/src/main/res/drawable/bg_progress_bar.xml new file mode 100644 index 0000000..752e52d --- /dev/null +++ b/common/src/main/res/drawable/bg_progress_bar.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_puller.xml b/common/src/main/res/drawable/bg_puller.xml new file mode 100644 index 0000000..485b80b --- /dev/null +++ b/common/src/main/res/drawable/bg_puller.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_round_number.xml b/common/src/main/res/drawable/bg_round_number.xml new file mode 100644 index 0000000..9e54890 --- /dev/null +++ b/common/src/main/res/drawable/bg_round_number.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_secondary_background_circle.xml b/common/src/main/res/drawable/bg_secondary_background_circle.xml new file mode 100644 index 0000000..5777ba2 --- /dev/null +++ b/common/src/main/res/drawable/bg_secondary_background_circle.xml @@ -0,0 +1,4 @@ + + + diff --git a/common/src/main/res/drawable/bg_secrets_input_disabled.xml b/common/src/main/res/drawable/bg_secrets_input_disabled.xml new file mode 100644 index 0000000..99de245 --- /dev/null +++ b/common/src/main/res/drawable/bg_secrets_input_disabled.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_seekbar_active.xml b/common/src/main/res/drawable/bg_seekbar_active.xml new file mode 100644 index 0000000..2e08629 --- /dev/null +++ b/common/src/main/res/drawable/bg_seekbar_active.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/bg_seekbar_inactive.xml b/common/src/main/res/drawable/bg_seekbar_inactive.xml new file mode 100644 index 0000000..716e79f --- /dev/null +++ b/common/src/main/res/drawable/bg_seekbar_inactive.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/bg_seekbar_progress.xml b/common/src/main/res/drawable/bg_seekbar_progress.xml new file mode 100644 index 0000000..4e446cb --- /dev/null +++ b/common/src/main/res/drawable/bg_seekbar_progress.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_seekbar_thumb.xml b/common/src/main/res/drawable/bg_seekbar_thumb.xml new file mode 100644 index 0000000..da18758 --- /dev/null +++ b/common/src/main/res/drawable/bg_seekbar_thumb.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/bg_seekbar_tick.xml b/common/src/main/res/drawable/bg_seekbar_tick.xml new file mode 100644 index 0000000..995de27 --- /dev/null +++ b/common/src/main/res/drawable/bg_seekbar_tick.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/bg_shimerring_6.xml b/common/src/main/res/drawable/bg_shimerring_6.xml new file mode 100644 index 0000000..6e632bd --- /dev/null +++ b/common/src/main/res/drawable/bg_shimerring_6.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_shimerring_8.xml b/common/src/main/res/drawable/bg_shimerring_8.xml new file mode 100644 index 0000000..94295d1 --- /dev/null +++ b/common/src/main/res/drawable/bg_shimerring_8.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_shimmering.xml b/common/src/main/res/drawable/bg_shimmering.xml new file mode 100644 index 0000000..e7ae310 --- /dev/null +++ b/common/src/main/res/drawable/bg_shimmering.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_shimmering_circle.xml b/common/src/main/res/drawable/bg_shimmering_circle.xml new file mode 100644 index 0000000..a7dab00 --- /dev/null +++ b/common/src/main/res/drawable/bg_shimmering_circle.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_shimmering_container.xml b/common/src/main/res/drawable/bg_shimmering_container.xml new file mode 100644 index 0000000..6d8f074 --- /dev/null +++ b/common/src/main/res/drawable/bg_shimmering_container.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_splash.xml b/common/src/main/res/drawable/bg_splash.xml new file mode 100644 index 0000000..87d65c1 --- /dev/null +++ b/common/src/main/res/drawable/bg_splash.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_text_button_ripple.xml b/common/src/main/res/drawable/bg_text_button_ripple.xml new file mode 100644 index 0000000..74bb9b7 --- /dev/null +++ b/common/src/main/res/drawable/bg_text_button_ripple.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_tinder_gov_basket_button.xml b/common/src/main/res/drawable/bg_tinder_gov_basket_button.xml new file mode 100644 index 0000000..0e88ac4 --- /dev/null +++ b/common/src/main/res/drawable/bg_tinder_gov_basket_button.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/bg_tinder_gov_cards_toolbar_gradient.xml b/common/src/main/res/drawable/bg_tinder_gov_cards_toolbar_gradient.xml new file mode 100644 index 0000000..8d629c1 --- /dev/null +++ b/common/src/main/res/drawable/bg_tinder_gov_cards_toolbar_gradient.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_tinder_gov_counter.xml b/common/src/main/res/drawable/bg_tinder_gov_counter.xml new file mode 100644 index 0000000..dbe11df --- /dev/null +++ b/common/src/main/res/drawable/bg_tinder_gov_counter.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_token_container.xml b/common/src/main/res/drawable/bg_token_container.xml new file mode 100644 index 0000000..8953dac --- /dev/null +++ b/common/src/main/res/drawable/bg_token_container.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/bg_total_card_chip.xml b/common/src/main/res/drawable/bg_total_card_chip.xml new file mode 100644 index 0000000..77812b2 --- /dev/null +++ b/common/src/main/res/drawable/bg_total_card_chip.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_voting_status_chip.xml b/common/src/main/res/drawable/bg_voting_status_chip.xml new file mode 100644 index 0000000..273dd2a --- /dev/null +++ b/common/src/main/res/drawable/bg_voting_status_chip.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_warning.xml b/common/src/main/res/drawable/bg_warning.xml new file mode 100644 index 0000000..10b8348 --- /dev/null +++ b/common/src/main/res/drawable/bg_warning.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/bg_welcome.xml b/common/src/main/res/drawable/bg_welcome.xml new file mode 100644 index 0000000..ffc07d8 --- /dev/null +++ b/common/src/main/res/drawable/bg_welcome.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/common/src/main/res/drawable/bottom_navigation_tint_color_selector.xml b/common/src/main/res/drawable/bottom_navigation_tint_color_selector.xml new file mode 100644 index 0000000..4453ef1 --- /dev/null +++ b/common/src/main/res/drawable/bottom_navigation_tint_color_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/chips_background_2.xml b/common/src/main/res/drawable/chips_background_2.xml new file mode 100644 index 0000000..f536b07 --- /dev/null +++ b/common/src/main/res/drawable/chips_background_2.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/divider_decoration.xml b/common/src/main/res/drawable/divider_decoration.xml new file mode 100644 index 0000000..fb349c5 --- /dev/null +++ b/common/src/main/res/drawable/divider_decoration.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/divider_drawable.xml b/common/src/main/res/drawable/divider_drawable.xml new file mode 100644 index 0000000..43c4cbb --- /dev/null +++ b/common/src/main/res/drawable/divider_drawable.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/drawable_background_image.png b/common/src/main/res/drawable/drawable_background_image.png new file mode 100644 index 0000000..313de5f Binary files /dev/null and b/common/src/main/res/drawable/drawable_background_image.png differ diff --git a/common/src/main/res/drawable/extrinsic_details_background.xml b/common/src/main/res/drawable/extrinsic_details_background.xml new file mode 100644 index 0000000..2ee8e8d --- /dev/null +++ b/common/src/main/res/drawable/extrinsic_details_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_abstain_vote.xml b/common/src/main/res/drawable/ic_abstain_vote.xml new file mode 100644 index 0000000..9fcd8e5 --- /dev/null +++ b/common/src/main/res/drawable/ic_abstain_vote.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_add.xml b/common/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..86d9d7b --- /dev/null +++ b/common/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_add_circle.xml b/common/src/main/res/drawable/ic_add_circle.xml new file mode 100644 index 0000000..71f6788 --- /dev/null +++ b/common/src/main/res/drawable/ic_add_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_add_circle_outline.xml b/common/src/main/res/drawable/ic_add_circle_outline.xml new file mode 100644 index 0000000..0de4064 --- /dev/null +++ b/common/src/main/res/drawable/ic_add_circle_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_android_nav_bar_dapps_active.xml b/common/src/main/res/drawable/ic_android_nav_bar_dapps_active.xml new file mode 100644 index 0000000..825aac0 --- /dev/null +++ b/common/src/main/res/drawable/ic_android_nav_bar_dapps_active.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_android_nav_bar_dapps_inactive.xml b/common/src/main/res/drawable/ic_android_nav_bar_dapps_inactive.xml new file mode 100644 index 0000000..4413efd --- /dev/null +++ b/common/src/main/res/drawable/ic_android_nav_bar_dapps_inactive.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_apple_pay.xml b/common/src/main/res/drawable/ic_apple_pay.xml new file mode 100644 index 0000000..4ad4913 --- /dev/null +++ b/common/src/main/res/drawable/ic_apple_pay.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/common/src/main/res/drawable/ic_approve_with_pin.xml b/common/src/main/res/drawable/ic_approve_with_pin.xml new file mode 100644 index 0000000..b86e235 --- /dev/null +++ b/common/src/main/res/drawable/ic_approve_with_pin.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/common/src/main/res/drawable/ic_arrow_back.xml b/common/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..807061a --- /dev/null +++ b/common/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_arrow_down.xml b/common/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 0000000..f9c2d10 --- /dev/null +++ b/common/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_arrow_right.xml b/common/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000..693ef1f --- /dev/null +++ b/common/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_arrow_up.xml b/common/src/main/res/drawable/ic_arrow_up.xml new file mode 100644 index 0000000..bf8b5bf --- /dev/null +++ b/common/src/main/res/drawable/ic_arrow_up.xml @@ -0,0 +1,5 @@ + + + diff --git a/common/src/main/res/drawable/ic_asset_view_networks.xml b/common/src/main/res/drawable/ic_asset_view_networks.xml new file mode 100644 index 0000000..d96811e --- /dev/null +++ b/common/src/main/res/drawable/ic_asset_view_networks.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_asset_view_tokens.xml b/common/src/main/res/drawable/ic_asset_view_tokens.xml new file mode 100644 index 0000000..b4a235b --- /dev/null +++ b/common/src/main/res/drawable/ic_asset_view_tokens.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_authentication.xml b/common/src/main/res/drawable/ic_authentication.xml new file mode 100644 index 0000000..b8c1bfb --- /dev/null +++ b/common/src/main/res/drawable/ic_authentication.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/common/src/main/res/drawable/ic_bank.xml b/common/src/main/res/drawable/ic_bank.xml new file mode 100644 index 0000000..acb2540 --- /dev/null +++ b/common/src/main/res/drawable/ic_bank.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_bidirectonal.xml b/common/src/main/res/drawable/ic_bidirectonal.xml new file mode 100644 index 0000000..7d4c48f --- /dev/null +++ b/common/src/main/res/drawable/ic_bidirectonal.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_block.xml b/common/src/main/res/drawable/ic_block.xml new file mode 100644 index 0000000..1afb000 --- /dev/null +++ b/common/src/main/res/drawable/ic_block.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_bottom_sheet_pin.xml b/common/src/main/res/drawable/ic_bottom_sheet_pin.xml new file mode 100644 index 0000000..cb225b3 --- /dev/null +++ b/common/src/main/res/drawable/ic_bottom_sheet_pin.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_bridge.xml b/common/src/main/res/drawable/ic_bridge.xml new file mode 100644 index 0000000..10f425f --- /dev/null +++ b/common/src/main/res/drawable/ic_bridge.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_browser_outline.xml b/common/src/main/res/drawable/ic_browser_outline.xml new file mode 100644 index 0000000..88d315d --- /dev/null +++ b/common/src/main/res/drawable/ic_browser_outline.xml @@ -0,0 +1,40 @@ + + + + + + + + diff --git a/common/src/main/res/drawable/ic_bullet_list.xml b/common/src/main/res/drawable/ic_bullet_list.xml new file mode 100644 index 0000000..bcb6718 --- /dev/null +++ b/common/src/main/res/drawable/ic_bullet_list.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/common/src/main/res/drawable/ic_buy.xml b/common/src/main/res/drawable/ic_buy.xml new file mode 100644 index 0000000..8462066 --- /dev/null +++ b/common/src/main/res/drawable/ic_buy.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_buy_outline.xml b/common/src/main/res/drawable/ic_buy_outline.xml new file mode 100644 index 0000000..3775dde --- /dev/null +++ b/common/src/main/res/drawable/ic_buy_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_buy_tokens.xml b/common/src/main/res/drawable/ic_buy_tokens.xml new file mode 100644 index 0000000..bc78d89 --- /dev/null +++ b/common/src/main/res/drawable/ic_buy_tokens.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_card_background_highlight.png b/common/src/main/res/drawable/ic_card_background_highlight.png new file mode 100644 index 0000000..64189e0 Binary files /dev/null and b/common/src/main/res/drawable/ic_card_background_highlight.png differ diff --git a/common/src/main/res/drawable/ic_card_border_highlight.png b/common/src/main/res/drawable/ic_card_border_highlight.png new file mode 100644 index 0000000..69e4501 Binary files /dev/null and b/common/src/main/res/drawable/ic_card_border_highlight.png differ diff --git a/common/src/main/res/drawable/ic_checkmark.xml b/common/src/main/res/drawable/ic_checkmark.xml new file mode 100644 index 0000000..66b450c --- /dev/null +++ b/common/src/main/res/drawable/ic_checkmark.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_checkmark_16.xml b/common/src/main/res/drawable/ic_checkmark_16.xml new file mode 100644 index 0000000..dccd335 --- /dev/null +++ b/common/src/main/res/drawable/ic_checkmark_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_checkmark_circle_16.xml b/common/src/main/res/drawable/ic_checkmark_circle_16.xml new file mode 100644 index 0000000..d36a73e --- /dev/null +++ b/common/src/main/res/drawable/ic_checkmark_circle_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_checkmark_filled.xml b/common/src/main/res/drawable/ic_checkmark_filled.xml new file mode 100644 index 0000000..d4f034d --- /dev/null +++ b/common/src/main/res/drawable/ic_checkmark_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_chevron_down.xml b/common/src/main/res/drawable/ic_chevron_down.xml new file mode 100644 index 0000000..23c447e --- /dev/null +++ b/common/src/main/res/drawable/ic_chevron_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_chevron_left.xml b/common/src/main/res/drawable/ic_chevron_left.xml new file mode 100644 index 0000000..a4f9f08 --- /dev/null +++ b/common/src/main/res/drawable/ic_chevron_left.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_chevron_right.xml b/common/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..f90cf3a --- /dev/null +++ b/common/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_chevron_right_16.xml b/common/src/main/res/drawable/ic_chevron_right_16.xml new file mode 100644 index 0000000..a06a00f --- /dev/null +++ b/common/src/main/res/drawable/ic_chevron_right_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_chevron_up.xml b/common/src/main/res/drawable/ic_chevron_up.xml new file mode 100644 index 0000000..2c9066a --- /dev/null +++ b/common/src/main/res/drawable/ic_chevron_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_chevron_up_circle_outline.xml b/common/src/main/res/drawable/ic_chevron_up_circle_outline.xml new file mode 100644 index 0000000..e233f4e --- /dev/null +++ b/common/src/main/res/drawable/ic_chevron_up_circle_outline.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_chip_filter.xml b/common/src/main/res/drawable/ic_chip_filter.xml new file mode 100644 index 0000000..330f786 --- /dev/null +++ b/common/src/main/res/drawable/ic_chip_filter.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_chip_filter_indicator.xml b/common/src/main/res/drawable/ic_chip_filter_indicator.xml new file mode 100644 index 0000000..a760890 --- /dev/null +++ b/common/src/main/res/drawable/ic_chip_filter_indicator.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_clear_pin_code_outline.xml b/common/src/main/res/drawable/ic_clear_pin_code_outline.xml new file mode 100644 index 0000000..afd4d21 --- /dev/null +++ b/common/src/main/res/drawable/ic_clear_pin_code_outline.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_close.xml b/common/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..ce835a5 --- /dev/null +++ b/common/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_close_circle.xml b/common/src/main/res/drawable/ic_close_circle.xml new file mode 100644 index 0000000..7e20fa2 --- /dev/null +++ b/common/src/main/res/drawable/ic_close_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_close_circle_translucent.xml b/common/src/main/res/drawable/ic_close_circle_translucent.xml new file mode 100644 index 0000000..4544f00 --- /dev/null +++ b/common/src/main/res/drawable/ic_close_circle_translucent.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_close_circle_translucent_contrast.xml b/common/src/main/res/drawable/ic_close_circle_translucent_contrast.xml new file mode 100644 index 0000000..0ad604a --- /dev/null +++ b/common/src/main/res/drawable/ic_close_circle_translucent_contrast.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_cloud_android.xml b/common/src/main/res/drawable/ic_cloud_android.xml new file mode 100644 index 0000000..2613e2d --- /dev/null +++ b/common/src/main/res/drawable/ic_cloud_android.xml @@ -0,0 +1,12 @@ + + + + diff --git a/common/src/main/res/drawable/ic_cloud_backup.xml b/common/src/main/res/drawable/ic_cloud_backup.xml new file mode 100644 index 0000000..0788fa9 --- /dev/null +++ b/common/src/main/res/drawable/ic_cloud_backup.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_cloud_backup_status_active.xml b/common/src/main/res/drawable/ic_cloud_backup_status_active.xml new file mode 100644 index 0000000..ef55c20 --- /dev/null +++ b/common/src/main/res/drawable/ic_cloud_backup_status_active.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_cloud_backup_status_disabled.xml b/common/src/main/res/drawable/ic_cloud_backup_status_disabled.xml new file mode 100644 index 0000000..17830ef --- /dev/null +++ b/common/src/main/res/drawable/ic_cloud_backup_status_disabled.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_cloud_backup_status_warning.xml b/common/src/main/res/drawable/ic_cloud_backup_status_warning.xml new file mode 100644 index 0000000..67b8a00 --- /dev/null +++ b/common/src/main/res/drawable/ic_cloud_backup_status_warning.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_code.xml b/common/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000..d5506b4 --- /dev/null +++ b/common/src/main/res/drawable/ic_code.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_connection_status_average.xml b/common/src/main/res/drawable/ic_connection_status_average.xml new file mode 100644 index 0000000..f37d7e4 --- /dev/null +++ b/common/src/main/res/drawable/ic_connection_status_average.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_connection_status_bad.xml b/common/src/main/res/drawable/ic_connection_status_bad.xml new file mode 100644 index 0000000..73663d2 --- /dev/null +++ b/common/src/main/res/drawable/ic_connection_status_bad.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_connection_status_connecting.xml b/common/src/main/res/drawable/ic_connection_status_connecting.xml new file mode 100644 index 0000000..670be80 --- /dev/null +++ b/common/src/main/res/drawable/ic_connection_status_connecting.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_connection_status_good.xml b/common/src/main/res/drawable/ic_connection_status_good.xml new file mode 100644 index 0000000..d640733 --- /dev/null +++ b/common/src/main/res/drawable/ic_connection_status_good.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_connections.xml b/common/src/main/res/drawable/ic_connections.xml new file mode 100644 index 0000000..48d4998 --- /dev/null +++ b/common/src/main/res/drawable/ic_connections.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/common/src/main/res/drawable/ic_copy_outline.xml b/common/src/main/res/drawable/ic_copy_outline.xml new file mode 100644 index 0000000..fc044ab --- /dev/null +++ b/common/src/main/res/drawable/ic_copy_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_cross_chain.xml b/common/src/main/res/drawable/ic_cross_chain.xml new file mode 100644 index 0000000..161efdf --- /dev/null +++ b/common/src/main/res/drawable/ic_cross_chain.xml @@ -0,0 +1,12 @@ + + + + diff --git a/common/src/main/res/drawable/ic_currency.xml b/common/src/main/res/drawable/ic_currency.xml new file mode 100644 index 0000000..461f7d6 --- /dev/null +++ b/common/src/main/res/drawable/ic_currency.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_delegate_outline.xml b/common/src/main/res/drawable/ic_delegate_outline.xml new file mode 100644 index 0000000..84df3bd --- /dev/null +++ b/common/src/main/res/drawable/ic_delegate_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_delete.xml b/common/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..59a04a8 --- /dev/null +++ b/common/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_delete_symbol.xml b/common/src/main/res/drawable/ic_delete_symbol.xml new file mode 100644 index 0000000..9001ea8 --- /dev/null +++ b/common/src/main/res/drawable/ic_delete_symbol.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_desktop.xml b/common/src/main/res/drawable/ic_desktop.xml new file mode 100644 index 0000000..fa3b55a --- /dev/null +++ b/common/src/main/res/drawable/ic_desktop.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_double_chevron_down.xml b/common/src/main/res/drawable/ic_double_chevron_down.xml new file mode 100644 index 0000000..6e7318e --- /dev/null +++ b/common/src/main/res/drawable/ic_double_chevron_down.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_double_chevron_up.xml b/common/src/main/res/drawable/ic_double_chevron_up.xml new file mode 100644 index 0000000..b0ca70f --- /dev/null +++ b/common/src/main/res/drawable/ic_double_chevron_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_earth.xml b/common/src/main/res/drawable/ic_earth.xml new file mode 100644 index 0000000..9e6c4b0 --- /dev/null +++ b/common/src/main/res/drawable/ic_earth.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_execution_result_error.xml b/common/src/main/res/drawable/ic_execution_result_error.xml new file mode 100644 index 0000000..3b03e0d --- /dev/null +++ b/common/src/main/res/drawable/ic_execution_result_error.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_execution_result_success.xml b/common/src/main/res/drawable/ic_execution_result_success.xml new file mode 100644 index 0000000..747867f --- /dev/null +++ b/common/src/main/res/drawable/ic_execution_result_success.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_eye_filled_15dp.xml b/common/src/main/res/drawable/ic_eye_filled_15dp.xml new file mode 100644 index 0000000..298ac94 --- /dev/null +++ b/common/src/main/res/drawable/ic_eye_filled_15dp.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_eye_hide.xml b/common/src/main/res/drawable/ic_eye_hide.xml new file mode 100644 index 0000000..9feb406 --- /dev/null +++ b/common/src/main/res/drawable/ic_eye_hide.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_eye_outline_hide.xml b/common/src/main/res/drawable/ic_eye_outline_hide.xml new file mode 100644 index 0000000..5f5a8b5 --- /dev/null +++ b/common/src/main/res/drawable/ic_eye_outline_hide.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_eye_show.xml b/common/src/main/res/drawable/ic_eye_show.xml new file mode 100644 index 0000000..627be65 --- /dev/null +++ b/common/src/main/res/drawable/ic_eye_show.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_fallback_network_icon.xml b/common/src/main/res/drawable/ic_fallback_network_icon.xml new file mode 100644 index 0000000..d2a6991 --- /dev/null +++ b/common/src/main/res/drawable/ic_fallback_network_icon.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_favorite_heart_filled.xml b/common/src/main/res/drawable/ic_favorite_heart_filled.xml new file mode 100644 index 0000000..674d63a --- /dev/null +++ b/common/src/main/res/drawable/ic_favorite_heart_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_favorite_heart_filled_20.xml b/common/src/main/res/drawable/ic_favorite_heart_filled_20.xml new file mode 100644 index 0000000..f6c6e29 --- /dev/null +++ b/common/src/main/res/drawable/ic_favorite_heart_filled_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_favorite_heart_outline.xml b/common/src/main/res/drawable/ic_favorite_heart_outline.xml new file mode 100644 index 0000000..2ff9b2f --- /dev/null +++ b/common/src/main/res/drawable/ic_favorite_heart_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_favorite_heart_outline_20.xml b/common/src/main/res/drawable/ic_favorite_heart_outline_20.xml new file mode 100644 index 0000000..c6281aa --- /dev/null +++ b/common/src/main/res/drawable/ic_favorite_heart_outline_20.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_file_outline.xml b/common/src/main/res/drawable/ic_file_outline.xml new file mode 100644 index 0000000..79aa46f --- /dev/null +++ b/common/src/main/res/drawable/ic_file_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_filter.xml b/common/src/main/res/drawable/ic_filter.xml new file mode 100644 index 0000000..39454ea --- /dev/null +++ b/common/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_filter_indicator.xml b/common/src/main/res/drawable/ic_filter_indicator.xml new file mode 100644 index 0000000..6f3ac12 --- /dev/null +++ b/common/src/main/res/drawable/ic_filter_indicator.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_fire.xml b/common/src/main/res/drawable/ic_fire.xml new file mode 100644 index 0000000..8b5d913 --- /dev/null +++ b/common/src/main/res/drawable/ic_fire.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_flip_swap.xml b/common/src/main/res/drawable/ic_flip_swap.xml new file mode 100644 index 0000000..5a054a5 --- /dev/null +++ b/common/src/main/res/drawable/ic_flip_swap.xml @@ -0,0 +1,12 @@ + + + + diff --git a/common/src/main/res/drawable/ic_frosted_glass_highlight.png b/common/src/main/res/drawable/ic_frosted_glass_highlight.png new file mode 100644 index 0000000..5e4aedc Binary files /dev/null and b/common/src/main/res/drawable/ic_frosted_glass_highlight.png differ diff --git a/common/src/main/res/drawable/ic_gem.xml b/common/src/main/res/drawable/ic_gem.xml new file mode 100644 index 0000000..fa31276 --- /dev/null +++ b/common/src/main/res/drawable/ic_gem.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_gift_card.xml b/common/src/main/res/drawable/ic_gift_card.xml new file mode 100644 index 0000000..111d157 --- /dev/null +++ b/common/src/main/res/drawable/ic_gift_card.xml @@ -0,0 +1,14 @@ + + + diff --git a/common/src/main/res/drawable/ic_github.xml b/common/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000..589d16b --- /dev/null +++ b/common/src/main/res/drawable/ic_github.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_google_logo.xml b/common/src/main/res/drawable/ic_google_logo.xml new file mode 100644 index 0000000..8a9a6dc --- /dev/null +++ b/common/src/main/res/drawable/ic_google_logo.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_google_pay.xml b/common/src/main/res/drawable/ic_google_pay.xml new file mode 100644 index 0000000..23bb5e2 --- /dev/null +++ b/common/src/main/res/drawable/ic_google_pay.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_governance_check_to_slot.xml b/common/src/main/res/drawable/ic_governance_check_to_slot.xml new file mode 100644 index 0000000..c2f1877 --- /dev/null +++ b/common/src/main/res/drawable/ic_governance_check_to_slot.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_grid_filled.xml b/common/src/main/res/drawable/ic_grid_filled.xml new file mode 100644 index 0000000..59bac8b --- /dev/null +++ b/common/src/main/res/drawable/ic_grid_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_grid_outline.xml b/common/src/main/res/drawable/ic_grid_outline.xml new file mode 100644 index 0000000..4615961 --- /dev/null +++ b/common/src/main/res/drawable/ic_grid_outline.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_hardware.xml b/common/src/main/res/drawable/ic_hardware.xml new file mode 100644 index 0000000..544067f --- /dev/null +++ b/common/src/main/res/drawable/ic_hardware.xml @@ -0,0 +1,12 @@ + + + + diff --git a/common/src/main/res/drawable/ic_identicon_placeholder.xml b/common/src/main/res/drawable/ic_identicon_placeholder.xml new file mode 100644 index 0000000..72cd7be --- /dev/null +++ b/common/src/main/res/drawable/ic_identicon_placeholder.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_identicon_placeholder_with_background.xml b/common/src/main/res/drawable/ic_identicon_placeholder_with_background.xml new file mode 100644 index 0000000..399a1a3 --- /dev/null +++ b/common/src/main/res/drawable/ic_identicon_placeholder_with_background.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_image_outline.xml b/common/src/main/res/drawable/ic_image_outline.xml new file mode 100644 index 0000000..dcc6ef7 --- /dev/null +++ b/common/src/main/res/drawable/ic_image_outline.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_import.xml b/common/src/main/res/drawable/ic_import.xml new file mode 100644 index 0000000..36c518d --- /dev/null +++ b/common/src/main/res/drawable/ic_import.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_indicator_inactive_pulse.xml b/common/src/main/res/drawable/ic_indicator_inactive_pulse.xml new file mode 100644 index 0000000..9216b17 --- /dev/null +++ b/common/src/main/res/drawable/ic_indicator_inactive_pulse.xml @@ -0,0 +1,12 @@ + + + + diff --git a/common/src/main/res/drawable/ic_indicator_negative_pulse.xml b/common/src/main/res/drawable/ic_indicator_negative_pulse.xml new file mode 100644 index 0000000..f84128c --- /dev/null +++ b/common/src/main/res/drawable/ic_indicator_negative_pulse.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_indicator_positive_pulse.xml b/common/src/main/res/drawable/ic_indicator_positive_pulse.xml new file mode 100644 index 0000000..c3fd437 --- /dev/null +++ b/common/src/main/res/drawable/ic_indicator_positive_pulse.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_individual.xml b/common/src/main/res/drawable/ic_individual.xml new file mode 100644 index 0000000..aa6866a --- /dev/null +++ b/common/src/main/res/drawable/ic_individual.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_info.xml b/common/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..2fc2edf --- /dev/null +++ b/common/src/main/res/drawable/ic_info.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/common/src/main/res/drawable/ic_info_24.xml b/common/src/main/res/drawable/ic_info_24.xml new file mode 100644 index 0000000..41df1a2 --- /dev/null +++ b/common/src/main/res/drawable/ic_info_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_info_accent.xml b/common/src/main/res/drawable/ic_info_accent.xml new file mode 100644 index 0000000..5947aeb --- /dev/null +++ b/common/src/main/res/drawable/ic_info_accent.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_json_file_upload_outline.xml b/common/src/main/res/drawable/ic_json_file_upload_outline.xml new file mode 100644 index 0000000..b0eb35a --- /dev/null +++ b/common/src/main/res/drawable/ic_json_file_upload_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_key.xml b/common/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000..3315bbc --- /dev/null +++ b/common/src/main/res/drawable/ic_key.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_key_missing.xml b/common/src/main/res/drawable/ic_key_missing.xml new file mode 100644 index 0000000..b6fb8df --- /dev/null +++ b/common/src/main/res/drawable/ic_key_missing.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_ksm_24.xml b/common/src/main/res/drawable/ic_ksm_24.xml new file mode 100644 index 0000000..074e6ed --- /dev/null +++ b/common/src/main/res/drawable/ic_ksm_24.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_language.xml b/common/src/main/res/drawable/ic_language.xml new file mode 100644 index 0000000..23ee762 --- /dev/null +++ b/common/src/main/res/drawable/ic_language.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_ledger.xml b/common/src/main/res/drawable/ic_ledger.xml new file mode 100644 index 0000000..ccdbbc4 --- /dev/null +++ b/common/src/main/res/drawable/ic_ledger.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_ledger_legacy.xml b/common/src/main/res/drawable/ic_ledger_legacy.xml new file mode 100644 index 0000000..15e4f6c --- /dev/null +++ b/common/src/main/res/drawable/ic_ledger_legacy.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_list_drag.xml b/common/src/main/res/drawable/ic_list_drag.xml new file mode 100644 index 0000000..07501b6 --- /dev/null +++ b/common/src/main/res/drawable/ic_list_drag.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_loading.xml b/common/src/main/res/drawable/ic_loading.xml new file mode 100644 index 0000000..3e07e5a --- /dev/null +++ b/common/src/main/res/drawable/ic_loading.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_lock.xml b/common/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..e9acfa5 --- /dev/null +++ b/common/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_lock_closed_outline.xml b/common/src/main/res/drawable/ic_lock_closed_outline.xml new file mode 100644 index 0000000..153e2e2 --- /dev/null +++ b/common/src/main/res/drawable/ic_lock_closed_outline.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_mail_outline.xml b/common/src/main/res/drawable/ic_mail_outline.xml new file mode 100644 index 0000000..1a4c71b --- /dev/null +++ b/common/src/main/res/drawable/ic_mail_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_manual_backup_eye.xml b/common/src/main/res/drawable/ic_manual_backup_eye.xml new file mode 100644 index 0000000..86b7ae1 --- /dev/null +++ b/common/src/main/res/drawable/ic_manual_backup_eye.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_manual_backup_funds.xml b/common/src/main/res/drawable/ic_manual_backup_funds.xml new file mode 100644 index 0000000..f800914 --- /dev/null +++ b/common/src/main/res/drawable/ic_manual_backup_funds.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_manual_backup_person.xml b/common/src/main/res/drawable/ic_manual_backup_person.xml new file mode 100644 index 0000000..c3707f3 --- /dev/null +++ b/common/src/main/res/drawable/ic_manual_backup_person.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_mastercard.xml b/common/src/main/res/drawable/ic_mastercard.xml new file mode 100644 index 0000000..33ddc25 --- /dev/null +++ b/common/src/main/res/drawable/ic_mastercard.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_megaphone.xml b/common/src/main/res/drawable/ic_megaphone.xml new file mode 100644 index 0000000..929f04a --- /dev/null +++ b/common/src/main/res/drawable/ic_megaphone.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_megaphone_outline.xml b/common/src/main/res/drawable/ic_megaphone_outline.xml new file mode 100644 index 0000000..1eb9593 --- /dev/null +++ b/common/src/main/res/drawable/ic_megaphone_outline.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_memo_android.xml b/common/src/main/res/drawable/ic_memo_android.xml new file mode 100644 index 0000000..545b5f1 --- /dev/null +++ b/common/src/main/res/drawable/ic_memo_android.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_minus_circle_outline.xml b/common/src/main/res/drawable/ic_minus_circle_outline.xml new file mode 100644 index 0000000..e1badc6 --- /dev/null +++ b/common/src/main/res/drawable/ic_minus_circle_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_mnemonic_background.png b/common/src/main/res/drawable/ic_mnemonic_background.png new file mode 100644 index 0000000..8b3c381 Binary files /dev/null and b/common/src/main/res/drawable/ic_mnemonic_background.png differ diff --git a/common/src/main/res/drawable/ic_mnemonic_phrase.xml b/common/src/main/res/drawable/ic_mnemonic_phrase.xml new file mode 100644 index 0000000..a0a7f23 --- /dev/null +++ b/common/src/main/res/drawable/ic_mnemonic_phrase.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_more_horizontal.xml b/common/src/main/res/drawable/ic_more_horizontal.xml new file mode 100644 index 0000000..f45705e --- /dev/null +++ b/common/src/main/res/drawable/ic_more_horizontal.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_multisig.xml b/common/src/main/res/drawable/ic_multisig.xml new file mode 100644 index 0000000..57a4739 --- /dev/null +++ b/common/src/main/res/drawable/ic_multisig.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_nav_bar_assets_active.xml b/common/src/main/res/drawable/ic_nav_bar_assets_active.xml new file mode 100644 index 0000000..b8ebad5 --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_bar_assets_active.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_nav_bar_assets_inactive.xml b/common/src/main/res/drawable/ic_nav_bar_assets_inactive.xml new file mode 100644 index 0000000..782f9ac --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_bar_assets_inactive.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_nav_bar_settings_active.xml b/common/src/main/res/drawable/ic_nav_bar_settings_active.xml new file mode 100644 index 0000000..e96003e --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_bar_settings_active.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_nav_bar_settings_inactive.xml b/common/src/main/res/drawable/ic_nav_bar_settings_inactive.xml new file mode 100644 index 0000000..4b5c05d --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_bar_settings_inactive.xml @@ -0,0 +1,14 @@ + + + diff --git a/common/src/main/res/drawable/ic_nav_bar_staking_active.xml b/common/src/main/res/drawable/ic_nav_bar_staking_active.xml new file mode 100644 index 0000000..4a1b3a0 --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_bar_staking_active.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_nav_bar_staking_inactive.xml b/common/src/main/res/drawable/ic_nav_bar_staking_inactive.xml new file mode 100644 index 0000000..9085c7f --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_bar_staking_inactive.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_nav_bar_vote_active.xml b/common/src/main/res/drawable/ic_nav_bar_vote_active.xml new file mode 100644 index 0000000..61ed0b8 --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_bar_vote_active.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_nav_bar_vote_inactive.xml b/common/src/main/res/drawable/ic_nav_bar_vote_inactive.xml new file mode 100644 index 0000000..f75ef36 --- /dev/null +++ b/common/src/main/res/drawable/ic_nav_bar_vote_inactive.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_networks.xml b/common/src/main/res/drawable/ic_networks.xml new file mode 100644 index 0000000..c2843a6 --- /dev/null +++ b/common/src/main/res/drawable/ic_networks.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_networks_24.xml b/common/src/main/res/drawable/ic_networks_24.xml new file mode 100644 index 0000000..4752740 --- /dev/null +++ b/common/src/main/res/drawable/ic_networks_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_no_reward_24.xml b/common/src/main/res/drawable/ic_no_reward_24.xml new file mode 100644 index 0000000..f803ce8 --- /dev/null +++ b/common/src/main/res/drawable/ic_no_reward_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_no_search_results.xml b/common/src/main/res/drawable/ic_no_search_results.xml new file mode 100644 index 0000000..5266cba --- /dev/null +++ b/common/src/main/res/drawable/ic_no_search_results.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_nomination_pool.xml b/common/src/main/res/drawable/ic_nomination_pool.xml new file mode 100644 index 0000000..38443db --- /dev/null +++ b/common/src/main/res/drawable/ic_nomination_pool.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_nominator.xml b/common/src/main/res/drawable/ic_nominator.xml new file mode 100644 index 0000000..fe8b18d --- /dev/null +++ b/common/src/main/res/drawable/ic_nominator.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_notifications_outline.xml b/common/src/main/res/drawable/ic_notifications_outline.xml new file mode 100644 index 0000000..923a9de --- /dev/null +++ b/common/src/main/res/drawable/ic_notifications_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_nova.xml b/common/src/main/res/drawable/ic_nova.xml new file mode 100644 index 0000000..3509569 --- /dev/null +++ b/common/src/main/res/drawable/ic_nova.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_nova_bank_card.xml b/common/src/main/res/drawable/ic_nova_bank_card.xml new file mode 100644 index 0000000..cbd4fe4 --- /dev/null +++ b/common/src/main/res/drawable/ic_nova_bank_card.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_nova_wiki.xml b/common/src/main/res/drawable/ic_nova_wiki.xml new file mode 100644 index 0000000..23ad3a6 --- /dev/null +++ b/common/src/main/res/drawable/ic_nova_wiki.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_options.xml b/common/src/main/res/drawable/ic_options.xml new file mode 100644 index 0000000..40d04d4 --- /dev/null +++ b/common/src/main/res/drawable/ic_options.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_organization.xml b/common/src/main/res/drawable/ic_organization.xml new file mode 100644 index 0000000..9fa5de7 --- /dev/null +++ b/common/src/main/res/drawable/ic_organization.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/common/src/main/res/drawable/ic_parallax_card_background.png b/common/src/main/res/drawable/ic_parallax_card_background.png new file mode 100644 index 0000000..8b3c381 Binary files /dev/null and b/common/src/main/res/drawable/ic_parallax_card_background.png differ diff --git a/common/src/main/res/drawable/ic_parity_signer.xml b/common/src/main/res/drawable/ic_parity_signer.xml new file mode 100644 index 0000000..bd8c273 --- /dev/null +++ b/common/src/main/res/drawable/ic_parity_signer.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_parity_signer_legacy.xml b/common/src/main/res/drawable/ic_parity_signer_legacy.xml new file mode 100644 index 0000000..514162a --- /dev/null +++ b/common/src/main/res/drawable/ic_parity_signer_legacy.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_pattern_highlight.png b/common/src/main/res/drawable/ic_pattern_highlight.png new file mode 100644 index 0000000..cdfd285 Binary files /dev/null and b/common/src/main/res/drawable/ic_pattern_highlight.png differ diff --git a/common/src/main/res/drawable/ic_pencil_edit.xml b/common/src/main/res/drawable/ic_pencil_edit.xml new file mode 100644 index 0000000..6bdef9a --- /dev/null +++ b/common/src/main/res/drawable/ic_pencil_edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_people_outline.xml b/common/src/main/res/drawable/ic_people_outline.xml new file mode 100644 index 0000000..58e1f57 --- /dev/null +++ b/common/src/main/res/drawable/ic_people_outline.xml @@ -0,0 +1,31 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_pezkuwi.xml b/common/src/main/res/drawable/ic_pezkuwi.xml new file mode 100644 index 0000000..1ff2b8c --- /dev/null +++ b/common/src/main/res/drawable/ic_pezkuwi.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_pezkuwi_bank_card.xml b/common/src/main/res/drawable/ic_pezkuwi_bank_card.xml new file mode 100644 index 0000000..cbd4fe4 --- /dev/null +++ b/common/src/main/res/drawable/ic_pezkuwi_bank_card.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_pezkuwi_gift.webp b/common/src/main/res/drawable/ic_pezkuwi_gift.webp new file mode 100644 index 0000000..289f826 Binary files /dev/null and b/common/src/main/res/drawable/ic_pezkuwi_gift.webp differ diff --git a/common/src/main/res/drawable/ic_pezkuwi_wiki.xml b/common/src/main/res/drawable/ic_pezkuwi_wiki.xml new file mode 100644 index 0000000..23ad3a6 --- /dev/null +++ b/common/src/main/res/drawable/ic_pezkuwi_wiki.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_pin.xml b/common/src/main/res/drawable/ic_pin.xml new file mode 100644 index 0000000..c9dba1a --- /dev/null +++ b/common/src/main/res/drawable/ic_pin.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_pin_white_24.xml b/common/src/main/res/drawable/ic_pin_white_24.xml new file mode 100644 index 0000000..3ba405f --- /dev/null +++ b/common/src/main/res/drawable/ic_pin_white_24.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_placeholder.xml b/common/src/main/res/drawable/ic_placeholder.xml new file mode 100644 index 0000000..c30f680 --- /dev/null +++ b/common/src/main/res/drawable/ic_placeholder.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_planet_outline.xml b/common/src/main/res/drawable/ic_planet_outline.xml new file mode 100644 index 0000000..7a615e2 --- /dev/null +++ b/common/src/main/res/drawable/ic_planet_outline.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_plus_accent_24.xml b/common/src/main/res/drawable/ic_plus_accent_24.xml new file mode 100644 index 0000000..607c9d4 --- /dev/null +++ b/common/src/main/res/drawable/ic_plus_accent_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_polkadot_24.xml b/common/src/main/res/drawable/ic_polkadot_24.xml new file mode 100644 index 0000000..0c68743 --- /dev/null +++ b/common/src/main/res/drawable/ic_polkadot_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_polkadot_vault.xml b/common/src/main/res/drawable/ic_polkadot_vault.xml new file mode 100644 index 0000000..9562751 --- /dev/null +++ b/common/src/main/res/drawable/ic_polkadot_vault.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_proxy.xml b/common/src/main/res/drawable/ic_proxy.xml new file mode 100644 index 0000000..078d22f --- /dev/null +++ b/common/src/main/res/drawable/ic_proxy.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_qr_scan.xml b/common/src/main/res/drawable/ic_qr_scan.xml new file mode 100644 index 0000000..93732fc --- /dev/null +++ b/common/src/main/res/drawable/ic_qr_scan.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_rate_us.xml b/common/src/main/res/drawable/ic_rate_us.xml new file mode 100644 index 0000000..22788b3 --- /dev/null +++ b/common/src/main/res/drawable/ic_rate_us.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_raw_seed.xml b/common/src/main/res/drawable/ic_raw_seed.xml new file mode 100644 index 0000000..be2fd0b --- /dev/null +++ b/common/src/main/res/drawable/ic_raw_seed.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_receive_history.xml b/common/src/main/res/drawable/ic_receive_history.xml new file mode 100644 index 0000000..6523824 --- /dev/null +++ b/common/src/main/res/drawable/ic_receive_history.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_receive_tokens.xml b/common/src/main/res/drawable/ic_receive_tokens.xml new file mode 100644 index 0000000..acfe469 --- /dev/null +++ b/common/src/main/res/drawable/ic_receive_tokens.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_recent_history.xml b/common/src/main/res/drawable/ic_recent_history.xml new file mode 100644 index 0000000..14e5a3f --- /dev/null +++ b/common/src/main/res/drawable/ic_recent_history.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_red_cross.xml b/common/src/main/res/drawable/ic_red_cross.xml new file mode 100644 index 0000000..d675243 --- /dev/null +++ b/common/src/main/res/drawable/ic_red_cross.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_redeem.xml b/common/src/main/res/drawable/ic_redeem.xml new file mode 100644 index 0000000..4e93ff8 --- /dev/null +++ b/common/src/main/res/drawable/ic_redeem.xml @@ -0,0 +1,14 @@ + + + + diff --git a/common/src/main/res/drawable/ic_refresh.xml b/common/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..4cfbb9a --- /dev/null +++ b/common/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_reward_24.xml b/common/src/main/res/drawable/ic_reward_24.xml new file mode 100644 index 0000000..794f3b7 --- /dev/null +++ b/common/src/main/res/drawable/ic_reward_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_rocket.xml b/common/src/main/res/drawable/ic_rocket.xml new file mode 100644 index 0000000..82ea458 --- /dev/null +++ b/common/src/main/res/drawable/ic_rocket.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_scan.xml b/common/src/main/res/drawable/ic_scan.xml new file mode 100644 index 0000000..23f81b7 --- /dev/null +++ b/common/src/main/res/drawable/ic_scan.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_search.xml b/common/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..f4b2e49 --- /dev/null +++ b/common/src/main/res/drawable/ic_search.xml @@ -0,0 +1,11 @@ + + + + diff --git a/common/src/main/res/drawable/ic_search_field.xml b/common/src/main/res/drawable/ic_search_field.xml new file mode 100644 index 0000000..fec5488 --- /dev/null +++ b/common/src/main/res/drawable/ic_search_field.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_sell_tokens.xml b/common/src/main/res/drawable/ic_sell_tokens.xml new file mode 100644 index 0000000..884f81b --- /dev/null +++ b/common/src/main/res/drawable/ic_sell_tokens.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_send_history.xml b/common/src/main/res/drawable/ic_send_history.xml new file mode 100644 index 0000000..255dc6d --- /dev/null +++ b/common/src/main/res/drawable/ic_send_history.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_send_tokens.xml b/common/src/main/res/drawable/ic_send_tokens.xml new file mode 100644 index 0000000..c2cfcc5 --- /dev/null +++ b/common/src/main/res/drawable/ic_send_tokens.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_sepa.xml b/common/src/main/res/drawable/ic_sepa.xml new file mode 100644 index 0000000..28cb71c --- /dev/null +++ b/common/src/main/res/drawable/ic_sepa.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/common/src/main/res/drawable/ic_settings_appearance.xml b/common/src/main/res/drawable/ic_settings_appearance.xml new file mode 100644 index 0000000..e9ff47a --- /dev/null +++ b/common/src/main/res/drawable/ic_settings_appearance.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/common/src/main/res/drawable/ic_settings_filled.xml b/common/src/main/res/drawable/ic_settings_filled.xml new file mode 100644 index 0000000..6f61a96 --- /dev/null +++ b/common/src/main/res/drawable/ic_settings_filled.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_settings_outline.xml b/common/src/main/res/drawable/ic_settings_outline.xml new file mode 100644 index 0000000..c0085b0 --- /dev/null +++ b/common/src/main/res/drawable/ic_settings_outline.xml @@ -0,0 +1,13 @@ + + + diff --git a/common/src/main/res/drawable/ic_settings_outline_semitransparent.xml b/common/src/main/res/drawable/ic_settings_outline_semitransparent.xml new file mode 100644 index 0000000..46cf94a --- /dev/null +++ b/common/src/main/res/drawable/ic_settings_outline_semitransparent.xml @@ -0,0 +1,14 @@ + + + diff --git a/common/src/main/res/drawable/ic_share_outline.xml b/common/src/main/res/drawable/ic_share_outline.xml new file mode 100644 index 0000000..4e0aee8 --- /dev/null +++ b/common/src/main/res/drawable/ic_share_outline.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_siri_paw.xml b/common/src/main/res/drawable/ic_siri_paw.xml new file mode 100644 index 0000000..1331cb1 --- /dev/null +++ b/common/src/main/res/drawable/ic_siri_paw.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_slash.xml b/common/src/main/res/drawable/ic_slash.xml new file mode 100644 index 0000000..1736236 --- /dev/null +++ b/common/src/main/res/drawable/ic_slash.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_staking.xml b/common/src/main/res/drawable/ic_staking.xml new file mode 100644 index 0000000..4aa73a0 --- /dev/null +++ b/common/src/main/res/drawable/ic_staking.xml @@ -0,0 +1,4 @@ + + + diff --git a/common/src/main/res/drawable/ic_staking_filled.xml b/common/src/main/res/drawable/ic_staking_filled.xml new file mode 100644 index 0000000..66c830b --- /dev/null +++ b/common/src/main/res/drawable/ic_staking_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_staking_history.xml b/common/src/main/res/drawable/ic_staking_history.xml new file mode 100644 index 0000000..e9874f8 --- /dev/null +++ b/common/src/main/res/drawable/ic_staking_history.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_staking_operations.xml b/common/src/main/res/drawable/ic_staking_operations.xml new file mode 100644 index 0000000..d982025 --- /dev/null +++ b/common/src/main/res/drawable/ic_staking_operations.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_staking_outline.xml b/common/src/main/res/drawable/ic_staking_outline.xml new file mode 100644 index 0000000..0a0a9b4 --- /dev/null +++ b/common/src/main/res/drawable/ic_staking_outline.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_star_filled.xml b/common/src/main/res/drawable/ic_star_filled.xml new file mode 100644 index 0000000..5034bf4 --- /dev/null +++ b/common/src/main/res/drawable/ic_star_filled.xml @@ -0,0 +1,12 @@ + + + diff --git a/common/src/main/res/drawable/ic_star_outline.xml b/common/src/main/res/drawable/ic_star_outline.xml new file mode 100644 index 0000000..a5b2fc4 --- /dev/null +++ b/common/src/main/res/drawable/ic_star_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_status_indicator.xml b/common/src/main/res/drawable/ic_status_indicator.xml new file mode 100644 index 0000000..4d6d94f --- /dev/null +++ b/common/src/main/res/drawable/ic_status_indicator.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_swap.xml b/common/src/main/res/drawable/ic_swap.xml new file mode 100644 index 0000000..6b4d785 --- /dev/null +++ b/common/src/main/res/drawable/ic_swap.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_swap_history.xml b/common/src/main/res/drawable/ic_swap_history.xml new file mode 100644 index 0000000..405a548 --- /dev/null +++ b/common/src/main/res/drawable/ic_swap_history.xml @@ -0,0 +1,12 @@ + + + + diff --git a/common/src/main/res/drawable/ic_tab_close.xml b/common/src/main/res/drawable/ic_tab_close.xml new file mode 100644 index 0000000..885526b --- /dev/null +++ b/common/src/main/res/drawable/ic_tab_close.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_tg.xml b/common/src/main/res/drawable/ic_tg.xml new file mode 100644 index 0000000..486272d --- /dev/null +++ b/common/src/main/res/drawable/ic_tg.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_three_dots.xml b/common/src/main/res/drawable/ic_three_dots.xml new file mode 100644 index 0000000..59f25ca --- /dev/null +++ b/common/src/main/res/drawable/ic_three_dots.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_thumbs_down_filled.xml b/common/src/main/res/drawable/ic_thumbs_down_filled.xml new file mode 100644 index 0000000..e970e58 --- /dev/null +++ b/common/src/main/res/drawable/ic_thumbs_down_filled.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_thumbs_up_filled.xml b/common/src/main/res/drawable/ic_thumbs_up_filled.xml new file mode 100644 index 0000000..93d829d --- /dev/null +++ b/common/src/main/res/drawable/ic_thumbs_up_filled.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_time_16.xml b/common/src/main/res/drawable/ic_time_16.xml new file mode 100644 index 0000000..171b056 --- /dev/null +++ b/common/src/main/res/drawable/ic_time_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_time_24.xml b/common/src/main/res/drawable/ic_time_24.xml new file mode 100644 index 0000000..28e127d --- /dev/null +++ b/common/src/main/res/drawable/ic_time_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_time_filled.xml b/common/src/main/res/drawable/ic_time_filled.xml new file mode 100644 index 0000000..2323976 --- /dev/null +++ b/common/src/main/res/drawable/ic_time_filled.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/common/src/main/res/drawable/ic_tinder_gov_entry_banner_background.png b/common/src/main/res/drawable/ic_tinder_gov_entry_banner_background.png new file mode 100644 index 0000000..ada601c Binary files /dev/null and b/common/src/main/res/drawable/ic_tinder_gov_entry_banner_background.png differ diff --git a/common/src/main/res/drawable/ic_token_dot_colored.xml b/common/src/main/res/drawable/ic_token_dot_colored.xml new file mode 100644 index 0000000..6bf4dc4 --- /dev/null +++ b/common/src/main/res/drawable/ic_token_dot_colored.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_token_dot_white.xml b/common/src/main/res/drawable/ic_token_dot_white.xml new file mode 100644 index 0000000..d968154 --- /dev/null +++ b/common/src/main/res/drawable/ic_token_dot_white.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/common/src/main/res/drawable/ic_token_ksm.xml b/common/src/main/res/drawable/ic_token_ksm.xml new file mode 100644 index 0000000..d316aad --- /dev/null +++ b/common/src/main/res/drawable/ic_token_ksm.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_transferable.xml b/common/src/main/res/drawable/ic_transferable.xml new file mode 100644 index 0000000..59721ca --- /dev/null +++ b/common/src/main/res/drawable/ic_transferable.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/common/src/main/res/drawable/ic_twitter.xml b/common/src/main/res/drawable/ic_twitter.xml new file mode 100644 index 0000000..0a315b7 --- /dev/null +++ b/common/src/main/res/drawable/ic_twitter.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_unfavorite_heart_outline.xml b/common/src/main/res/drawable/ic_unfavorite_heart_outline.xml new file mode 100644 index 0000000..cb7e1c1 --- /dev/null +++ b/common/src/main/res/drawable/ic_unfavorite_heart_outline.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_unknown_operation.xml b/common/src/main/res/drawable/ic_unknown_operation.xml new file mode 100644 index 0000000..755f333 --- /dev/null +++ b/common/src/main/res/drawable/ic_unknown_operation.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_unpaid_rewards.xml b/common/src/main/res/drawable/ic_unpaid_rewards.xml new file mode 100644 index 0000000..973ec9a --- /dev/null +++ b/common/src/main/res/drawable/ic_unpaid_rewards.xml @@ -0,0 +1,16 @@ + + + + diff --git a/common/src/main/res/drawable/ic_users.xml b/common/src/main/res/drawable/ic_users.xml new file mode 100644 index 0000000..3d3bef1 --- /dev/null +++ b/common/src/main/res/drawable/ic_users.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_validators_outline.xml b/common/src/main/res/drawable/ic_validators_outline.xml new file mode 100644 index 0000000..e2e2a84 --- /dev/null +++ b/common/src/main/res/drawable/ic_validators_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_view_finder.xml b/common/src/main/res/drawable/ic_view_finder.xml new file mode 100644 index 0000000..2b28e36 --- /dev/null +++ b/common/src/main/res/drawable/ic_view_finder.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/common/src/main/res/drawable/ic_visa.xml b/common/src/main/res/drawable/ic_visa.xml new file mode 100644 index 0000000..75d5163 --- /dev/null +++ b/common/src/main/res/drawable/ic_visa.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/common/src/main/res/drawable/ic_wallet_connect.xml b/common/src/main/res/drawable/ic_wallet_connect.xml new file mode 100644 index 0000000..bfa6e3c --- /dev/null +++ b/common/src/main/res/drawable/ic_wallet_connect.xml @@ -0,0 +1,9 @@ + + + diff --git a/common/src/main/res/drawable/ic_wallet_outline.xml b/common/src/main/res/drawable/ic_wallet_outline.xml new file mode 100644 index 0000000..d4e1188 --- /dev/null +++ b/common/src/main/res/drawable/ic_wallet_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_warning_filled.xml b/common/src/main/res/drawable/ic_warning_filled.xml new file mode 100644 index 0000000..0a40cc8 --- /dev/null +++ b/common/src/main/res/drawable/ic_warning_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_watch_only_filled.xml b/common/src/main/res/drawable/ic_watch_only_filled.xml new file mode 100644 index 0000000..b081e6c --- /dev/null +++ b/common/src/main/res/drawable/ic_watch_only_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/ic_web3_alert_icon.xml b/common/src/main/res/drawable/ic_web3_alert_icon.xml new file mode 100644 index 0000000..a43355a --- /dev/null +++ b/common/src/main/res/drawable/ic_web3_alert_icon.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/ic_website.xml b/common/src/main/res/drawable/ic_website.xml new file mode 100644 index 0000000..2adb62f --- /dev/null +++ b/common/src/main/res/drawable/ic_website.xml @@ -0,0 +1,13 @@ + + + + diff --git a/common/src/main/res/drawable/ic_westend_24.xml b/common/src/main/res/drawable/ic_westend_24.xml new file mode 100644 index 0000000..c73d236 --- /dev/null +++ b/common/src/main/res/drawable/ic_westend_24.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_x_clear_filled.xml b/common/src/main/res/drawable/ic_x_clear_filled.xml new file mode 100644 index 0000000..6f40430 --- /dev/null +++ b/common/src/main/res/drawable/ic_x_clear_filled.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/drawable/ic_youtube.xml b/common/src/main/res/drawable/ic_youtube.xml new file mode 100644 index 0000000..f66bebf --- /dev/null +++ b/common/src/main/res/drawable/ic_youtube.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/mask_dots_big.xml b/common/src/main/res/drawable/mask_dots_big.xml new file mode 100644 index 0000000..07c5db1 --- /dev/null +++ b/common/src/main/res/drawable/mask_dots_big.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/common/src/main/res/drawable/mask_dots_small.xml b/common/src/main/res/drawable/mask_dots_small.xml new file mode 100644 index 0000000..f971fbe --- /dev/null +++ b/common/src/main/res/drawable/mask_dots_small.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/common/src/main/res/drawable/primary_chip_background.xml b/common/src/main/res/drawable/primary_chip_background.xml new file mode 100644 index 0000000..3a61cd8 --- /dev/null +++ b/common/src/main/res/drawable/primary_chip_background.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/secondary_chip_background.xml b/common/src/main/res/drawable/secondary_chip_background.xml new file mode 100644 index 0000000..70cadaf --- /dev/null +++ b/common/src/main/res/drawable/secondary_chip_background.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/secondary_container_ripple_background.xml b/common/src/main/res/drawable/secondary_container_ripple_background.xml new file mode 100644 index 0000000..e196697 --- /dev/null +++ b/common/src/main/res/drawable/secondary_container_ripple_background.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/drawable/segmented_tab_indicator.xml b/common/src/main/res/drawable/segmented_tab_indicator.xml new file mode 100644 index 0000000..a6f73a5 --- /dev/null +++ b/common/src/main/res/drawable/segmented_tab_indicator.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/common/src/main/res/drawable/shape_account_updated_indicator.xml b/common/src/main/res/drawable/shape_account_updated_indicator.xml new file mode 100644 index 0000000..4991300 --- /dev/null +++ b/common/src/main/res/drawable/shape_account_updated_indicator.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/shape_updates_indicator.xml b/common/src/main/res/drawable/shape_updates_indicator.xml new file mode 100644 index 0000000..73f40bd --- /dev/null +++ b/common/src/main/res/drawable/shape_updates_indicator.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/shield_checkmark_outline.xml b/common/src/main/res/drawable/shield_checkmark_outline.xml new file mode 100644 index 0000000..7af4a7d --- /dev/null +++ b/common/src/main/res/drawable/shield_checkmark_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/common/src/main/res/drawable/tab_text_state_colors.xml b/common/src/main/res/drawable/tab_text_state_colors.xml new file mode 100644 index 0000000..7499bfe --- /dev/null +++ b/common/src/main/res/drawable/tab_text_state_colors.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/tinder_gov_card_background_0.png b/common/src/main/res/drawable/tinder_gov_card_background_0.png new file mode 100644 index 0000000..8d8eb44 Binary files /dev/null and b/common/src/main/res/drawable/tinder_gov_card_background_0.png differ diff --git a/common/src/main/res/drawable/tinder_gov_card_background_1.png b/common/src/main/res/drawable/tinder_gov_card_background_1.png new file mode 100644 index 0000000..ada601c Binary files /dev/null and b/common/src/main/res/drawable/tinder_gov_card_background_1.png differ diff --git a/common/src/main/res/drawable/tinder_gov_card_background_2.png b/common/src/main/res/drawable/tinder_gov_card_background_2.png new file mode 100644 index 0000000..93c3beb Binary files /dev/null and b/common/src/main/res/drawable/tinder_gov_card_background_2.png differ diff --git a/common/src/main/res/drawable/tinder_gov_card_background_3.png b/common/src/main/res/drawable/tinder_gov_card_background_3.png new file mode 100644 index 0000000..201357a Binary files /dev/null and b/common/src/main/res/drawable/tinder_gov_card_background_3.png differ diff --git a/common/src/main/res/drawable/tinder_gov_card_background_4.png b/common/src/main/res/drawable/tinder_gov_card_background_4.png new file mode 100644 index 0000000..e910c8b Binary files /dev/null and b/common/src/main/res/drawable/tinder_gov_card_background_4.png differ diff --git a/common/src/main/res/drawable/tinder_gov_card_background_5.png b/common/src/main/res/drawable/tinder_gov_card_background_5.png new file mode 100644 index 0000000..ab15b83 Binary files /dev/null and b/common/src/main/res/drawable/tinder_gov_card_background_5.png differ diff --git a/common/src/main/res/drawable/tinder_gov_card_background_6.png b/common/src/main/res/drawable/tinder_gov_card_background_6.png new file mode 100644 index 0000000..d8526c5 Binary files /dev/null and b/common/src/main/res/drawable/tinder_gov_card_background_6.png differ diff --git a/common/src/main/res/font/public_sans.xml b/common/src/main/res/font/public_sans.xml new file mode 100644 index 0000000..91587ef --- /dev/null +++ b/common/src/main/res/font/public_sans.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/font/public_sans_bold.ttf b/common/src/main/res/font/public_sans_bold.ttf new file mode 100644 index 0000000..27da08d Binary files /dev/null and b/common/src/main/res/font/public_sans_bold.ttf differ diff --git a/common/src/main/res/font/public_sans_extra_bold.ttf b/common/src/main/res/font/public_sans_extra_bold.ttf new file mode 100644 index 0000000..b4211c9 Binary files /dev/null and b/common/src/main/res/font/public_sans_extra_bold.ttf differ diff --git a/common/src/main/res/font/public_sans_extra_light.ttf b/common/src/main/res/font/public_sans_extra_light.ttf new file mode 100644 index 0000000..9156276 Binary files /dev/null and b/common/src/main/res/font/public_sans_extra_light.ttf differ diff --git a/common/src/main/res/font/public_sans_regular.ttf b/common/src/main/res/font/public_sans_regular.ttf new file mode 100644 index 0000000..0543bdc Binary files /dev/null and b/common/src/main/res/font/public_sans_regular.ttf differ diff --git a/common/src/main/res/font/public_sans_semi_bold.ttf b/common/src/main/res/font/public_sans_semi_bold.ttf new file mode 100644 index 0000000..0141e39 Binary files /dev/null and b/common/src/main/res/font/public_sans_semi_bold.ttf differ diff --git a/common/src/main/res/layout/bottom_sheeet_copier.xml b/common/src/main/res/layout/bottom_sheeet_copier.xml new file mode 100644 index 0000000..680a97e --- /dev/null +++ b/common/src/main/res/layout/bottom_sheeet_copier.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/bottom_sheeet_fixed_list.xml b/common/src/main/res/layout/bottom_sheeet_fixed_list.xml new file mode 100644 index 0000000..6e57991 --- /dev/null +++ b/common/src/main/res/layout/bottom_sheeet_fixed_list.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/bottom_sheet_action.xml b/common/src/main/res/layout/bottom_sheet_action.xml new file mode 100644 index 0000000..7820e80 --- /dev/null +++ b/common/src/main/res/layout/bottom_sheet_action.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/bottom_sheet_action_not_allowed.xml b/common/src/main/res/layout/bottom_sheet_action_not_allowed.xml new file mode 100644 index 0000000..3b2a761 --- /dev/null +++ b/common/src/main/res/layout/bottom_sheet_action_not_allowed.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/bottom_sheet_description.xml b/common/src/main/res/layout/bottom_sheet_description.xml new file mode 100644 index 0000000..1aadeb2 --- /dev/null +++ b/common/src/main/res/layout/bottom_sheet_description.xml @@ -0,0 +1,33 @@ + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/bottom_sheet_dynamic_list.xml b/common/src/main/res/layout/bottom_sheet_dynamic_list.xml new file mode 100644 index 0000000..0ff5925 --- /dev/null +++ b/common/src/main/res/layout/bottom_sheet_dynamic_list.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/button_large.xml b/common/src/main/res/layout/button_large.xml new file mode 100644 index 0000000..c2ad60e --- /dev/null +++ b/common/src/main/res/layout/button_large.xml @@ -0,0 +1,49 @@ + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/dialog_progress.xml b/common/src/main/res/layout/dialog_progress.xml new file mode 100644 index 0000000..bdc85f4 --- /dev/null +++ b/common/src/main/res/layout/dialog_progress.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_chip_action.xml b/common/src/main/res/layout/item_chip_action.xml new file mode 100644 index 0000000..8b1f6a3 --- /dev/null +++ b/common/src/main/res/layout/item_chip_action.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_instruction.xml b/common/src/main/res/layout/item_instruction.xml new file mode 100644 index 0000000..82ce310 --- /dev/null +++ b/common/src/main/res/layout/item_instruction.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_instruction_image.xml b/common/src/main/res/layout/item_instruction_image.xml new file mode 100644 index 0000000..431cf74 --- /dev/null +++ b/common/src/main/res/layout/item_instruction_image.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_list_chooser.xml b/common/src/main/res/layout/item_list_chooser.xml new file mode 100644 index 0000000..efb480d --- /dev/null +++ b/common/src/main/res/layout/item_list_chooser.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_list_selector.xml b/common/src/main/res/layout/item_list_selector.xml new file mode 100644 index 0000000..c6d0545 --- /dev/null +++ b/common/src/main/res/layout/item_list_selector.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_nested_list.xml b/common/src/main/res/layout/item_nested_list.xml new file mode 100644 index 0000000..ee11d25 --- /dev/null +++ b/common/src/main/res/layout/item_nested_list.xml @@ -0,0 +1,8 @@ + + diff --git a/common/src/main/res/layout/item_operation_list_item.xml b/common/src/main/res/layout/item_operation_list_item.xml new file mode 100644 index 0000000..11dcd20 --- /dev/null +++ b/common/src/main/res/layout/item_operation_list_item.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_placeholder.xml b/common/src/main/res/layout/item_placeholder.xml new file mode 100644 index 0000000..b59b92d --- /dev/null +++ b/common/src/main/res/layout/item_placeholder.xml @@ -0,0 +1,13 @@ + + diff --git a/common/src/main/res/layout/item_sheet_descriptive_action.xml b/common/src/main/res/layout/item_sheet_descriptive_action.xml new file mode 100644 index 0000000..129bd35 --- /dev/null +++ b/common/src/main/res/layout/item_sheet_descriptive_action.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_sheet_iconic_label.xml b/common/src/main/res/layout/item_sheet_iconic_label.xml new file mode 100644 index 0000000..49efbef --- /dev/null +++ b/common/src/main/res/layout/item_sheet_iconic_label.xml @@ -0,0 +1,40 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_sheet_switcher.xml b/common/src/main/res/layout/item_sheet_switcher.xml new file mode 100644 index 0000000..c50f5bd --- /dev/null +++ b/common/src/main/res/layout/item_sheet_switcher.xml @@ -0,0 +1,16 @@ + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_text.xml b/common/src/main/res/layout/item_text.xml new file mode 100644 index 0000000..490a206 --- /dev/null +++ b/common/src/main/res/layout/item_text.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/common/src/main/res/layout/item_text_header.xml b/common/src/main/res/layout/item_text_header.xml new file mode 100644 index 0000000..36dfa74 --- /dev/null +++ b/common/src/main/res/layout/item_text_header.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/layout_puller.xml b/common/src/main/res/layout/layout_puller.xml new file mode 100644 index 0000000..2e71bb5 --- /dev/null +++ b/common/src/main/res/layout/layout_puller.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/common/src/main/res/layout/section_title_image_content.xml b/common/src/main/res/layout/section_title_image_content.xml new file mode 100644 index 0000000..083980b --- /dev/null +++ b/common/src/main/res/layout/section_title_image_content.xml @@ -0,0 +1,50 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_accent_action.xml b/common/src/main/res/layout/view_accent_action.xml new file mode 100644 index 0000000..9c4f28c --- /dev/null +++ b/common/src/main/res/layout/view_accent_action.xml @@ -0,0 +1,29 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_account_info.xml b/common/src/main/res/layout/view_account_info.xml new file mode 100644 index 0000000..be6efd8 --- /dev/null +++ b/common/src/main/res/layout/view_account_info.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_address.xml b/common/src/main/res/layout/view_address.xml new file mode 100644 index 0000000..3d74a1d --- /dev/null +++ b/common/src/main/res/layout/view_address.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_advertisement_card.xml b/common/src/main/res/layout/view_advertisement_card.xml new file mode 100644 index 0000000..8c89bb5 --- /dev/null +++ b/common/src/main/res/layout/view_advertisement_card.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_alert.xml b/common/src/main/res/layout/view_alert.xml new file mode 100644 index 0000000..1d5ef0e --- /dev/null +++ b/common/src/main/res/layout/view_alert.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_alert_message.xml b/common/src/main/res/layout/view_alert_message.xml new file mode 100644 index 0000000..a4f577b --- /dev/null +++ b/common/src/main/res/layout/view_alert_message.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_banner.xml b/common/src/main/res/layout/view_banner.xml new file mode 100644 index 0000000..c272330 --- /dev/null +++ b/common/src/main/res/layout/view_banner.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_choose_amount_old.xml b/common/src/main/res/layout/view_choose_amount_old.xml new file mode 100644 index 0000000..5334677 --- /dev/null +++ b/common/src/main/res/layout/view_choose_amount_old.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_generic_table_cell.xml b/common/src/main/res/layout/view_generic_table_cell.xml new file mode 100644 index 0000000..4b95542 --- /dev/null +++ b/common/src/main/res/layout/view_generic_table_cell.xml @@ -0,0 +1,38 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_go_next.xml b/common/src/main/res/layout/view_go_next.xml new file mode 100644 index 0000000..2773d50 --- /dev/null +++ b/common/src/main/res/layout/view_go_next.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_input_field.xml b/common/src/main/res/layout/view_input_field.xml new file mode 100644 index 0000000..b74ba90 --- /dev/null +++ b/common/src/main/res/layout/view_input_field.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_insertable_input_field.xml b/common/src/main/res/layout/view_insertable_input_field.xml new file mode 100644 index 0000000..3b1f612 --- /dev/null +++ b/common/src/main/res/layout/view_insertable_input_field.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_instruction_image.xml b/common/src/main/res/layout/view_instruction_image.xml new file mode 100644 index 0000000..c3d03d4 --- /dev/null +++ b/common/src/main/res/layout/view_instruction_image.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_instruction_step.xml b/common/src/main/res/layout/view_instruction_step.xml new file mode 100644 index 0000000..b7417eb --- /dev/null +++ b/common/src/main/res/layout/view_instruction_step.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_labeled_text.xml b/common/src/main/res/layout/view_labeled_text.xml new file mode 100644 index 0000000..8124388 --- /dev/null +++ b/common/src/main/res/layout/view_labeled_text.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_link.xml b/common/src/main/res/layout/view_link.xml new file mode 100644 index 0000000..c52cda4 --- /dev/null +++ b/common/src/main/res/layout/view_link.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_list_chooser.xml b/common/src/main/res/layout/view_list_chooser.xml new file mode 100644 index 0000000..c29b8d9 --- /dev/null +++ b/common/src/main/res/layout/view_list_chooser.xml @@ -0,0 +1,37 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_nova_connect.xml b/common/src/main/res/layout/view_nova_connect.xml new file mode 100644 index 0000000..913cdf2 --- /dev/null +++ b/common/src/main/res/layout/view_nova_connect.xml @@ -0,0 +1,27 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_placeholder.xml b/common/src/main/res/layout/view_placeholder.xml new file mode 100644 index 0000000..9a3937b --- /dev/null +++ b/common/src/main/res/layout/view_placeholder.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_promo_banner.xml b/common/src/main/res/layout/view_promo_banner.xml new file mode 100644 index 0000000..6b95abf --- /dev/null +++ b/common/src/main/res/layout/view_promo_banner.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_scan.xml b/common/src/main/res/layout/view_scan.xml new file mode 100644 index 0000000..6019a3e --- /dev/null +++ b/common/src/main/res/layout/view_scan.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_search.xml b/common/src/main/res/layout/view_search.xml new file mode 100644 index 0000000..3518291 --- /dev/null +++ b/common/src/main/res/layout/view_search.xml @@ -0,0 +1,43 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_search_toolbar.xml b/common/src/main/res/layout/view_search_toolbar.xml new file mode 100644 index 0000000..c014915 --- /dev/null +++ b/common/src/main/res/layout/view_search_toolbar.xml @@ -0,0 +1,33 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_seekbar.xml b/common/src/main/res/layout/view_seekbar.xml new file mode 100644 index 0000000..1a3092c --- /dev/null +++ b/common/src/main/res/layout/view_seekbar.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_settings_item.xml b/common/src/main/res/layout/view_settings_item.xml new file mode 100644 index 0000000..d9b3bed --- /dev/null +++ b/common/src/main/res/layout/view_settings_item.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_settings_switcher.xml b/common/src/main/res/layout/view_settings_switcher.xml new file mode 100644 index 0000000..2dc2002 --- /dev/null +++ b/common/src/main/res/layout/view_settings_switcher.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_switch.xml b/common/src/main/res/layout/view_switch.xml new file mode 100644 index 0000000..c7ff833 --- /dev/null +++ b/common/src/main/res/layout/view_switch.xml @@ -0,0 +1,52 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_table_cell.xml b/common/src/main/res/layout/view_table_cell.xml new file mode 100644 index 0000000..c491067 --- /dev/null +++ b/common/src/main/res/layout/view_table_cell.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_tap_to_view_container.xml b/common/src/main/res/layout/view_tap_to_view_container.xml new file mode 100644 index 0000000..5798829 --- /dev/null +++ b/common/src/main/res/layout/view_tap_to_view_container.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_tips_input.xml b/common/src/main/res/layout/view_tips_input.xml new file mode 100644 index 0000000..91be861 --- /dev/null +++ b/common/src/main/res/layout/view_tips_input.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_titled_search_toolbar.xml b/common/src/main/res/layout/view_titled_search_toolbar.xml new file mode 100644 index 0000000..83aabcd --- /dev/null +++ b/common/src/main/res/layout/view_titled_search_toolbar.xml @@ -0,0 +1,37 @@ + + + + + + + + + + diff --git a/common/src/main/res/layout/view_toolbar.xml b/common/src/main/res/layout/view_toolbar.xml new file mode 100644 index 0000000..bfeea37 --- /dev/null +++ b/common/src/main/res/layout/view_toolbar.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_warning_checkbox.xml b/common/src/main/res/layout/view_warning_checkbox.xml new file mode 100644 index 0000000..d0e275e --- /dev/null +++ b/common/src/main/res/layout/view_warning_checkbox.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/layout/view_your_wallets.xml b/common/src/main/res/layout/view_your_wallets.xml new file mode 100644 index 0000000..9382efe --- /dev/null +++ b/common/src/main/res/layout/view_your_wallets.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/raw/astr_packing.json b/common/src/main/res/raw/astr_packing.json new file mode 100644 index 0000000..8ddd342 --- /dev/null +++ b/common/src/main/res/raw/astr_packing.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":300,"w":512,"h":512,"assets":[{"id":"comp_0","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[12.6]},{"t":102,"s":[96]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":82,"s":[116,96,0],"to":[86,26,0],"ti":[-1.25,-87.67,0]},{"t":102,"s":[290.5,297,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[100,84,100]},{"t":97,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-94.1,-1.62],[-91.1,3.82],[-88.1,-1.62]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-98.42,7.01],[-94.48,6.07],[-97.85,0.07],[-98.23,0.07],[-98.6,3.63],[-103.1,3.82],[-103.85,0.45],[-98.42,-4.43],[-95.98,-12.87],[-105.07,0.45]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-94.47,-5.72],[-87.53,-5.53],[-87.54,-5.93],[-90.34,-10.03],[-84.73,-10.8],[-83.23,-3.87],[-77.23,2.7],[-84.16,-11.91],[-91.47,-11.72]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[-89.6,14.13],[-80.6,8.32],[-84.35,0.26],[-87.92,6.26],[-87.54,6.45],[-84.35,4.95],[-81.92,8.13],[-85.48,11.13],[-90.22,9.68],[-91.29,9.07],[-91.83,9.32],[-100.1,10.95]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[-73.1,0.26],[-91.1,18.26],[-109.1,0.26],[-91.1,-17.74]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":100,"s":[5]},{"t":106,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":82,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":92,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":82,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[110,134]},{"t":92,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":101,"s":[28]},{"t":121,"s":[-3.75]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":101,"s":[104,122,0],"to":[54,7,0],"ti":[-19.25,-84.67,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[243.5,267,0],"to":[0.06,-3.71,0],"ti":[0,-3.54,0]},{"t":127,"s":[243.5,267,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[85,85,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":121,"s":[95.56,94.94,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":124,"s":[102.56,40.29,100]},{"t":127,"s":[103.56,52.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-2.64,-2.21],[0.36,3.23],[3.36,-2.21]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-6.95,6.42],[-3.01,5.48],[-6.38,-0.52],[-6.76,-0.52],[-7.13,3.04],[-11.63,3.23],[-12.38,-0.14],[-6.95,-5.02],[-4.51,-13.46],[-13.6,-0.14]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3,-6.31],[3.94,-6.12],[3.93,-6.52],[1.12,-10.62],[6.74,-11.39],[8.24,-4.46],[14.24,2.11],[7.31,-12.5],[0,-12.31]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.87,13.54],[10.87,7.73],[7.12,-0.33],[3.55,5.67],[3.93,5.85],[7.12,4.35],[9.55,7.54],[5.99,10.54],[1.25,9.09],[0.18,8.48],[-0.36,8.73],[-8.63,10.35]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18.36,-0.33],[0.37,17.67],[-17.64,-0.33],[0.37,-18.33]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[5]},{"t":131,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[0,0],"to":[0,-0.75],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":124,"s":[0,11],"to":[0,0],"ti":[0,0]},{"t":127,"s":[0,7]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":121,"s":[120,120]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[102.4,116]},{"t":127,"s":[102.4,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[31.25]},{"t":120,"s":[14]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":96,"s":[367,78,0],"to":[-18,29,0],"ti":[6.75,-76.67,0]},{"t":116,"s":[325.5,270,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[85,85,100]},{"t":116,"s":[97,43.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-3.56,-2.48],[-0.56,2.96],[2.44,-2.48]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.87,6.15],[-3.93,5.21],[-7.31,-0.79],[-7.68,-0.79],[-8.06,2.77],[-12.56,2.96],[-13.31,-0.42],[-7.87,-5.29],[-5.43,-13.73],[-14.52,-0.42]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.92,-6.58],[3.02,-6.4],[3.01,-6.79],[0.2,-10.89],[5.82,-11.67],[7.32,-4.73],[13.32,1.83],[6.39,-12.77],[-0.92,-12.58]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[0.94,13.27],[9.94,7.46],[6.19,-0.6],[2.63,5.4],[3.01,5.58],[6.19,4.08],[8.63,7.27],[5.07,10.27],[0.32,8.82],[-0.74,8.21],[-1.28,8.46],[-9.56,10.08]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[17.44,-0.6],[-0.56,17.4],[-18.56,-0.6],[-0.56,-18.6]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":116,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":106,"s":[116,116]},{"t":116,"s":[102.4,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[81.55]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":112,"s":[70.2]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[-10]},{"t":119,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":92,"s":[125,74,0],"to":[34,35,0],"ti":[-5.25,-79.67,0]},{"t":112,"s":[210.5,282,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[42.6,26.6,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[71,71,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":112,"s":[100,34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,54,100]},{"t":119,"s":[100,39,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-2.98,1.11],[0.02,6.55],[3.02,1.11]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.29,9.73],[-3.35,8.8],[-6.72,2.8],[-7.1,2.8],[-7.47,6.36],[-11.97,6.55],[-12.72,3.17],[-7.29,-1.7],[-4.85,-10.14],[-13.94,3.17]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.34,-2.99],[3.6,-2.81],[3.59,-3.2],[0.78,-7.31],[6.4,-8.08],[7.9,-1.14],[13.9,5.42],[6.97,-9.18],[-0.34,-8.99]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.53,16.86],[10.53,11.05],[6.78,2.98],[3.21,8.98],[3.59,9.17],[6.78,7.67],[9.21,10.86],[5.65,13.86],[0.91,12.41],[-0.16,11.8],[-0.7,12.05],[-8.97,13.67]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18.02,2.98],[0.03,20.98],[-17.98,2.98],[0.03,-15.02]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[5]},{"t":131,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[0,9],"to":[0,-0.15],"ti":[0,2.44]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,-8.41],"to":[0,-2.01],"ti":[0,-0.17]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":97,"s":[0,0],"to":[0,0.62],"ti":[0,0]},{"t":112,"s":[0,12]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":92,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[104.15,129.8]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":97,"s":[104.25,104]},{"t":112,"s":[105,130]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[-23]},{"t":101,"s":[-8]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":75,"s":[433,92,0],"to":[-77,4,0],"ti":[-1.25,-87.67,0]},{"t":97,"s":[238.5,296,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,100,100]},{"t":93,"s":[100,32,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-3.25,-0.96],[-0.25,4.47],[2.75,-0.96]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.56,7.66],[-3.62,6.72],[-7,0.72],[-7.37,0.72],[-7.75,4.29],[-12.25,4.47],[-13,1.1],[-7.56,-3.78],[-5.12,-12.21],[-14.22,1.1]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.61,-5.07],[3.32,-4.88],[3.31,-5.27],[0.51,-9.38],[6.13,-10.15],[7.63,-3.21],[13.63,3.35],[6.7,-11.25],[-0.61,-11.07]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.25,14.79],[10.25,8.97],[6.5,0.91],[2.94,6.91],[3.31,7.1],[6.5,5.6],[8.94,8.79],[5.38,11.79],[0.63,10.34],[-0.44,9.72],[-0.97,9.98],[-9.25,11.6]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[17.75,0.91],[-0.25,18.91],[-18.25,0.91],[-0.25,-17.09]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[5]},{"t":131,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":93,"s":[0,19]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[116,116]},{"t":93,"s":[105,125]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":100,"s":[-22.75]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":105,"s":[-22]},{"t":109,"s":[-9]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":85,"s":[367,78,0],"to":[-34,23,0],"ti":[6.75,-76.67,0]},{"t":105,"s":[264.5,279,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[77,77,100]},{"t":105,"s":[77,34.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-3.81,0.95],[-0.81,6.38],[2.19,0.95]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-8.12,9.57],[-4.18,8.63],[-7.56,2.63],[-7.93,2.63],[-8.31,6.2],[-12.81,6.38],[-13.56,3.01],[-8.12,-1.87],[-5.68,-10.3],[-14.77,3.01]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-4.17,-3.16],[2.77,-2.97],[2.76,-3.37],[-0.05,-7.47],[5.57,-8.24],[7.07,-1.3],[13.07,5.26],[6.14,-9.35],[-1.17,-9.16]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[0.69,16.7],[9.69,10.88],[5.94,2.82],[2.38,8.82],[2.76,9.01],[5.94,7.51],[8.38,10.7],[4.82,13.7],[0.07,12.24],[-0.99,11.63],[-1.53,11.88],[-9.81,13.51]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[17.19,2.82],[-0.81,20.82],[-18.81,2.82],[-0.81,-15.18]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[5]},{"t":131,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":105,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":85,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[116,116]},{"t":105,"s":[109,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[12.6]},{"t":90,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":70,"s":[427,116,0],"to":[-67,4,0],"ti":[-1.25,-87.67,0]},{"t":90,"s":[264.5,338,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[71,38.34,100]},{"t":80,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-5.33,-2.04],[-2.33,3.4],[0.67,-2.04]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-9.64,6.59],[-5.7,5.65],[-9.08,-0.35],[-9.45,-0.35],[-9.83,3.21],[-14.33,3.4],[-15.08,0.03],[-9.64,-4.85],[-7.2,-13.29],[-16.3,0.03]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-5.69,-6.14],[1.24,-5.95],[1.23,-6.35],[-1.57,-10.45],[4.05,-11.22],[5.55,-4.29],[11.55,2.28],[4.62,-12.33],[-2.69,-12.14]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[-0.83,13.71],[8.17,7.9],[4.42,-0.16],[0.86,5.84],[1.23,6.02],[4.42,4.52],[6.86,7.71],[3.3,10.71],[-1.45,9.26],[-2.52,8.65],[-3.05,8.9],[-11.33,10.52]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[15.67,-0.16],[-2.33,17.84],[-20.33,-0.16],[-2.33,-18.16]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[258.5,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[5]},{"t":131,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":80,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":70,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[110,134]},{"t":80,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[12.6]},{"t":80,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":60,"s":[75,92,0],"to":[105,30,0],"ti":[-1.25,-87.67,0]},{"t":80,"s":[242.5,340,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[100,84,100]},{"t":75,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-2.83,-2.05],[0.17,3.39],[3.17,-2.05]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.14,6.57],[-3.2,5.64],[-6.58,-0.36],[-6.95,-0.36],[-7.33,3.2],[-11.83,3.39],[-12.58,0.01],[-7.14,-4.86],[-4.7,-13.3],[-13.8,0.01]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.19,-6.15],[3.74,-5.97],[3.73,-6.36],[0.93,-10.47],[6.55,-11.24],[8.05,-4.3],[14.05,2.26],[7.12,-12.34],[-0.19,-12.15]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.67,13.7],[10.67,7.89],[6.92,-0.18],[3.36,5.82],[3.73,6.01],[6.92,4.51],[9.36,7.7],[5.8,10.7],[1.05,9.25],[-0.02,8.64],[-0.55,8.89],[-8.83,10.51]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18.17,-0.18],[0.17,17.82],[-17.83,-0.18],[0.17,-18.18]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[5]},{"t":131,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":70,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":60,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[110,134]},{"t":70,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":275,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":300,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":263,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":275,"s":[100,100]},{"t":300,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":263,"op":300,"st":206},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[344.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":255,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":280,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":243,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":255,"s":[100,100]},{"t":280,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":243,"op":281,"st":186},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":236,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":259,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":228,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":236,"s":[100,100]},{"t":259,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":228,"op":260,"st":171},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":222,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":233,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":214,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":222,"s":[100,100]},{"t":233,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":214,"op":234,"st":157},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":208,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":219,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":200,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":208,"s":[100,100]},{"t":219,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":200,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":219,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":200,"op":220,"st":143},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":76,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":51,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":59,"s":[100,100]},{"t":76,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":51,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":76,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":51,"op":77,"st":-6},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[200.44,241.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":55,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":36,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":44,"s":[100,100]},{"t":55,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":36,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":55,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":36,"op":56,"st":-21},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[343.44,238.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":35,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":46,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":27,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":35,"s":[100,100]},{"t":46,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":27,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":46,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":27,"op":47,"st":-30},{"ind":9,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50.66,-184.2,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[11,2],[0,0],[-2.75,-7.75],[20.5,-7]],"o":[[-10,8.5],[0,0],[1.12,3.14],[40,-13]],"v":[[42.08,-17.98],[22.38,-6.82],[31.83,10.77],[14.08,31.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":171,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":172,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[46.06,-12.83],[18.2,-29.66],[-8.71,1.58],[30.67,-18.97]],"o":[[-16.15,4.5],[10.3,-11.21],[35.76,-6.47],[63.53,-17.79]],"v":[[0.89,-20.37],[-50.62,19.68],[-18.36,2.16],[13.89,45.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-36.51],[-50.62,19.68],[-19.83,-11.75],[12.42,32]],"c":true}]},{"t":209,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":170,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-29.64,-5.98],[-0.31,4.52],[-26.64,18.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":169,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-11.09],[26.17,26.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-25],[24.7,12.25],[-56.19,5.91]],"c":true}]},{"t":209,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.06,-13],[-19.29,-28.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-35.04,-33.48],[-5.61,-46.46],[47.19,27.25]],"c":true}]},{"t":209,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":168,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":163,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":166,"s":[{"i":[[2.59,-1.44],[13.98,27.91],[-8.75,-2.33],[-23.7,-7.47]],"o":[[-20.01,-7.24],[-0.97,-1.93],[2.46,22.32],[-1.7,0.73]],"v":[[44,48.14],[-36.97,-2.51],[-7.45,-15.43],[72.59,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.38,-2.11],[-22.87,-27.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-41.54,-17.57],[-10.85,-47.87],[71.01,35.39]],"c":true}]},{"t":209,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":10,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[49.33,-79.33,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":154,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[16.25,-17.11],[15.33,-34.84],[44.12,-24.39],[45.75,-6.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":159,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.25,-17.11],[15.83,-16.84],[46.12,-6.39],[45.75,-6.61]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":154,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":158,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":160,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":144,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":142,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":151,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":154,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":141,"s":[0],"h":1},{"t":142,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":157,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.21,-18.11],[15.79,-17.84],[46.08,-7.39],[45.71,-7.61]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":153,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":11,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":120,"s":[50.27,-201.25,0],"to":[0,6.23,0],"ti":[0,-15.32,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":134,"s":[50.27,-284.64,0],"to":[0,21.19,0],"ti":[0,-8.61,0]},{"t":146,"s":[50.27,-152.25,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":120,"s":[0,0,100]},{"t":144,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.4,91.93],[131.21,19.33],[0.36,-48.29],[-131.32,19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":130,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-52.85,28.76],[95.73,14.81],[62.88,1.73],[-97.23,18.68]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[83.8,18.9],[83.9,18.9],[83.72,18.4],[-85.86,18.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":132,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[73.69,-14.15],[90.29,-13.04],[73.61,-14.65],[-91.27,-13.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.04,-13.5],[132.06,-4.67],[-0.12,-14],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-85.27,-14.13],[83.47,-13.5],[83.29,-15.26],[-86.29,-13.83]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.28,0.28,0.87,1],"h":1},{"t":131,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[66.17,-103.68],[-66.06,-33.47],[-66,-17],[66.77,-83.74]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.9,-48.82],[-66,-40.33],[-66,-17],[66,-15.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":131,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":134,"s":[0.4,0.42,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":135,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[0.4,0.42,1,1]},{"t":146,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-65.25,-84.24],[-65.69,-104.18],[65.97,-33.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,-15.67],[-65.97,-49.33],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.4,0.42,1,1],"h":1},{"t":132,"s":[0.47,0.58,0.99,1],"h":1},{"t":135,"s":[0.4,0.42,1,1],"h":1},{"t":140,"s":[0.47,0.58,0.99,1],"h":1},{"t":146,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":132,"s":[0],"h":1},{"t":146,"s":[35],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"t":119,"s":[100,100],"h":1},{"t":123,"s":[-100,100],"h":1},{"t":127,"s":[100,100],"h":1},{"t":131,"s":[-100,100],"h":1},{"t":135,"s":[100,100],"h":1},{"t":140,"s":[-100,100],"h":1},{"t":146,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":12,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.85},"o":{"x":0.17,"y":0.17},"t":0,"s":[50,-98.75,0],"to":[0,1.68,0],"ti":[0,-4.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":13,"s":[50,-184.14,0],"to":[0,5.71,0],"ti":[0,-2.32,0]},{"t":26,"s":[50,-78.75,0]}],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":0,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":34,"s":[97,97,100]},{"t":50,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-62.26,55.67],[-62.73,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-61.96,76.67],[-61.81,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-57.72,99.67],[-57.69,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[-9.59,4.33],[14.9,-23.89],[0,0],[0,0]],"o":[[14.5,18.78],[-0.1,-0.56],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,33.56],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[-13.68,5.7],[-6.74,-51.36],[0,0],[0,0]],"o":[[-4.79,47.52],[0.48,-0.45],[0,0],[0,0]],"v":[[61.73,-99.67],[61.46,45.58],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":7,"s":[0.47,0.58,0.99,1],"h":1},{"t":10,"s":[0.4,0.42,1,1],"h":1},{"t":14,"s":[0.47,0.58,0.99,1],"h":1},{"t":17,"s":[0.4,0.42,1,1],"h":1},{"t":24,"s":[0.47,0.58,0.99,1],"h":1},{"t":31,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.4,55.67],[60.91,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.7,76.67],[61.84,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[65.94,99.67],[65.95,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[10.47,2.67],[-18.22,-33.33],[0,0],[0,0]],"o":[[-10.44,12.11],[-0.04,-0.56],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.88,33.56],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[10.32,2.06],[8.37,-56.82],[0,0],[0,0]],"o":[[5.88,46.61],[0.04,-0.45],[0,0],[0,0]],"v":[[-61.94,-99.67],[-62.21,45.58],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":7,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":9,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":10,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":13,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":16,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":17,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":23,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":30,"s":[0.42,0.47,1,1]},{"t":31,"s":[0.47,0.58,0.99,1]}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":13,"ty":0,"refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":103,"s":[209.44,138.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":131,"s":[209.44,289.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[71,71,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":103,"op":141,"st":46},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.76,"y":0},"t":94,"s":[317.44,178.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":120,"s":[317.44,338.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[102,102,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":102,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":133,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":94,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":102,"s":[100,100]},{"t":133,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":94,"op":134,"st":37},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":70,"s":[202.44,133.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":97,"s":[202.44,349.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[127,127,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":105,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":70,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":78,"s":[100,100]},{"t":105,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":106,"st":13},{"ind":19,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.01,-33.33],[123.51,-1.58],[-0.61,-33.67],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.93,-0.08],[123.51,-1.58],[0.31,-0.42],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.67,-51.08],[123.51,-1.58],[4.43,51.08],[-123.51,-1.25]],"c":true}]},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":151,"st":0},{"ind":21,"ty":3,"ks":{"o":{"k":0},"r":{"k":0},"p":{"k":[257,451,0],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":18,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":26,"s":[103,97,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[97,103,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":43,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":94,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":98,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":122,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":126,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":145,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":149,"s":[102,98,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":155,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.85,1.14,-0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":168,"s":[99,102,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,4.33]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":180,"s":[110,90,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.75,0.75,0.75],"y":[0,0,0]},"t":195,"s":[90,110,100]},{"t":208,"s":[100,100,100]}],"l":2}},"ip":0,"op":264,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/astr_unpacking.json b/common/src/main/res/raw/astr_unpacking.json new file mode 100644 index 0000000..7705853 --- /dev/null +++ b/common/src/main/res/raw/astr_unpacking.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":360,"w":512,"h":512,"assets":[{"id":"comp_0","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[248.14,284.55,0],"l":2},"a":{"k":[14,-1,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-72,39],[-152,111]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.86,45],[3,185]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[61,31],[156,113]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"t":69,"s":[73]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[0]},{"t":67,"s":[73]}]},"o":{"k":0},"m":1}],"ip":60,"op":177,"st":-3},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":-208.79},"p":{"k":[90.94,257.49,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[73.08,73.08,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.59,-21.92],[27.98,-23.86],[-36.09,-3.9],[3.75,11.83],[-1.96,-59.37],[-44.64,5.65],[18.27,13.23]],"o":[[1.21,44.71],[-24.04,20.51],[7.95,0.86],[-8.39,-26.51],[1.32,39.94],[41.51,-5.26],[-13.06,-9.45]],"v":[[-134.97,-34.32],[-81.98,23.04],[-72.15,98.43],[-50.97,80.06],[-134.14,144.99],[-59.15,210.78],[-26.48,151.25]],"c":false}}},{"ty":"st","c":{"k":[0.949019667682,0.870865167356,0,1]},"o":{"k":100},"w":{"k":10},"lc":1,"lj":1,"ml":4},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"t":122,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[25.52]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[90]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":177,"st":-3},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":-322},"p":{"k":[191.66,150.67,0],"l":2},"a":{"k":[127.48,-143.62,0],"l":2},"s":{"k":[-190,190,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-4.76,4.16],[-5.58,-2.96],[0.86,-2.32],[1.98,1.6],[-0.17,2.54],[-4.87,2.42],[-4.55,-2.99],[0.82,-1.61],[1.23,1.05],[0.03,1.62],[-5.55,1.61],[-2.45,-2.91]],"o":[[1.22,-6.2],[4.76,-4.16],[2.19,1.16],[-0.89,2.39],[-1.98,-1.6],[0.36,-5.43],[4.88,-2.42],[1.51,1],[-0.73,1.44],[-1.23,-1.05],[-0.1,-5.78],[3.66,-1.06],[0,0]],"v":[[96.85,-121.52],[105.41,-138.05],[123,-140.94],[126.33,-135.01],[119.89,-133.82],[117.49,-140.76],[126.14,-153.74],[141.72,-152.8],[143.88,-148.35],[139.69,-147.87],[138.04,-152.28],[147.62,-165.26],[158.1,-162.64]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[10]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[38]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[63]},{"t":100,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[99]},{"t":100,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0,0.602906589882,0.949019607843,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[244.14,221.55,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[56,56,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[13.57,11.19],[-31.1,23.53],[44.5,-0.5],[0.21,-20],[-89,21]],"o":[[-12.84,-10.6],[37,-28],[-34.23,0.39],[-1,97],[35.17,-8.3]],"v":[[0.84,68.6],[14,13],[-11.5,-83.5],[-43,64],[-132,56]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[23]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[39]},{"t":90,"s":[49]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[40]},{"t":85,"s":[49]}]},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[171]},{"t":90,"s":[179]}]},"m":1},{"ty":"st","c":{"k":[0,0.949019667682,0.334948072246,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[8]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":74,"s":[18]},{"t":90,"s":[5]}]},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,20]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":61,"op":169,"st":-11},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":-370},"p":{"k":[336.32,194.77,0],"l":2},"a":{"k":[-147.98,-152.87,0],"l":2},"s":{"k":[-211,211,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[3.94,4.85],[5.94,-1.95],[-0.45,-2.41],[-2.2,1.23],[-0.26,2.51],[4.35,3.18],[4.93,-2.16],[-0.52,-1.71],[-1.37,0.82],[-0.3,1.57],[5.15,2.5],[2.88,-2.43]],"o":[[-0.16,-6.25],[-3.94,-4.85],[-2.33,0.77],[0.47,2.48],[2.2,-1.23],[0.56,-5.36],[-4.35,-3.18],[-1.64,0.72],[0.47,1.53],[1.37,-0.82],[1.06,-5.62],[-3.39,-1.64],[0,0]],"v":[[-121.55,-126.72],[-127.13,-144.27],[-143.8,-150.03],[-148.04,-144.81],[-141.97,-142.56],[-138.46,-148.94],[-144.73,-163.04],[-160.08,-164.73],[-162.93,-160.75],[-158.93,-159.58],[-156.58,-163.6],[-163.75,-177.86],[-174.41,-177.06]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[13]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[45]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[63]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[99]},{"t":97,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.964705882353,0,0.037585939146,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[169]},{"t":121,"s":[-67]}]},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.86},"o":{"x":0.05,"y":0.01},"t":68,"s":[201,236.25,0],"to":[-0.3,-1.62,0],"ti":[89.46,-52.28,0]},{"i":{"x":0.52,"y":0.9},"o":{"x":0.16,"y":0.13},"t":77,"s":[117.46,105.72,0],"to":[-89.46,52.28,0],"ti":[54.73,-96.02,0]},{"t":127,"s":[83.5,392.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":79,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":84,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":88,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":99,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[80,80,100]},{"t":120,"s":[75,-75,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,-2.72],[-1.69,-6.82],[5.28,-3.42],[-3.74,4.29]],"o":[[-1.96,5],[-8.39,-4.89],[-3.96,-11.6],[3,-5.59]],"v":[[15,-15],[15,15],[-15,15],[-15,-15]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":69,"s":[0,0.34,0.91,1],"h":1},{"t":72,"s":[0.89,0.89,0.89,1],"h":1},{"t":77,"s":[0,0.34,0.91,1],"h":1},{"t":82,"s":[0.89,0.89,0.89,1],"h":1},{"t":86,"s":[0,0.34,0.91,1],"h":1},{"t":91,"s":[0.89,0.89,0.89,1],"h":1},{"t":96,"s":[0,0.34,0.91,1],"h":1},{"t":103,"s":[0.89,0.89,0.89,1],"h":1},{"t":110,"s":[0,0.34,0.91,1],"h":1},{"t":117,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":180,"st":37},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-90]},{"t":109,"s":[301.64]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[198.75,244.5,0],"to":[-1.88,-52.9,0],"ti":[28.8,18.42,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[144.71,133.7,0],"to":[-28.64,-18.32,0],"ti":[23.57,-30.86,0]},{"t":109,"s":[58.5,148.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":68,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"t":109,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[29,30]},{"t":107,"s":[18,18.62]}]},"p":{"k":[0,0]},"r":{"k":211}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[0.96,0.81,0,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0.96,0.81,0,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0.96,0.81,0,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0.96,0.81,0,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0.96,0.81,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":4},"sa":{"k":0}}]}],"ip":65,"op":110,"st":34},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[-163]},{"t":120,"s":[-73.39]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[198.75,232.5,0],"to":[2.25,-107.5,0],"ti":[45.25,-88,0]},{"t":120,"s":[43.75,100,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":100,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":107,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,45,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":113,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[132,-64,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":119,"s":[130,-32,100]},{"t":120,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[22,22]},{"t":116,"s":[15,15]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":64,"s":[0.67,0,0.27,1],"h":1},{"t":67,"s":[0.89,0.89,0.89,1],"h":1},{"t":71,"s":[0.67,0,0.27,1],"h":1},{"t":76,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[0.67,0,0.27,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":90,"s":[0.67,0,0.27,1],"h":1},{"t":97,"s":[0.89,0.89,0.89,1],"h":1},{"t":104,"s":[0.67,0,0.27,1],"h":1},{"t":111,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[0.67,0,0.27,1],"h":1},{"t":118,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":64,"op":121,"st":-53},{"ind":9,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[-29]},{"t":131,"s":[306]}]},"p":{"a":1,"k":[{"i":{"x":0.43,"y":0.94},"o":{"x":0,"y":0},"t":70,"s":[315.75,212.5,0],"to":[84.25,-131.5,0],"ti":[9.5,-151.25,0]},{"t":133,"s":[444.5,412.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":71,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":108,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"t":123,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":133,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[7.5,-7.5],[7.5,7.5],[0.09,7.5],[-7.5,7.5],[-7.5,-7.5],[-1.31,-7.5]],"c":true}]},{"t":140,"s":[{"i":[[-1.04,1.29],[-0.3,-3.48],[1.31,-2.19],[0,0],[0,0],[-1.41,1.63]],"o":[[0.26,3.49],[0.29,3.4],[-1.99,3.33],[0,0],[0,0],[1.17,-1.35]],"v":[[-2.19,-14.3],[-0.07,-2.07],[-0.67,5.04],[-7.5,7.5],[-7.5,-7.5],[-4.66,-10.34]],"c":true}]}]}},{"ty":"fl","c":{"a":1,"k":[{"t":71,"s":[1,0.61,0,1],"h":1},{"t":74,"s":[0.89,0.89,0.89,1],"h":1},{"t":79,"s":[1,0.61,0,1],"h":1},{"t":84,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0.61,0,1],"h":1},{"t":93,"s":[0.89,0.89,0.89,1],"h":1},{"t":98,"s":[1,0.61,0,1],"h":1},{"t":105,"s":[0.89,0.89,0.89,1],"h":1},{"t":112,"s":[1,0.61,0,1],"h":1},{"t":119,"s":[0.89,0.89,0.89,1],"h":1},{"t":124,"s":[1,0.61,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":70,"op":219,"st":39},{"ind":10,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-29]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[186]},{"t":130,"s":[188]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[312.75,231.5,0],"to":[-0.58,-8.79,0],"ti":[-27.32,0.19,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[337,137.9,0],"to":[27.32,-0.19,0],"ti":[5.65,-84.54,0]},{"t":130,"s":[365.5,416.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":103,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,-100,100]},{"t":130,"s":[100,40,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"k":[22,22]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":66,"s":[0,0.86,0.06,1],"h":1},{"t":69,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0,0.86,0.06,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0,0.86,0.06,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0,0.86,0.06,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0,0.86,0.06,1],"h":1},{"t":114,"s":[0.89,0.89,0.89,1],"h":1},{"t":121,"s":[0,0.86,0.06,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":65,"op":214,"st":34},{"ind":11,"ty":4,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[100]},{"t":124,"s":[0]}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[126]},{"t":124,"s":[349]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":0.68},"o":{"x":0,"y":0},"t":68,"s":[274,232.25,0],"to":[14,-95.25,0],"ti":[-3.5,70.25,0]},{"t":124,"s":[416.5,52.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":116,"s":[100,100,100]},{"t":124,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[20,20]},{"t":124,"s":[14,14]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":72,"s":[1,0.92,0,1],"h":1},{"t":75,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[1,0.92,0,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":89,"s":[1,0.92,0,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":99,"s":[1,0.92,0,1],"h":1},{"t":106,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[1,0.92,0,1],"h":1},{"t":120,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":217,"st":37},{"ind":12,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[54]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":76,"s":[-18]},{"t":126,"s":[18]}]},"p":{"a":1,"k":[{"i":{"x":0.33,"y":0.63},"o":{"x":0.02,"y":0},"t":63,"s":[199,223.25,0],"to":[-1.41,-14.49,0],"ti":[30.23,18.1,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.19,"y":0.18},"t":69,"s":[164.23,151.1,0],"to":[-30.23,-18.1,0],"ti":[-19.69,-81.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83.75,"s":[108.31,217.87,0],"to":[19.69,81.13,0],"ti":[-18.51,-96.26,0]},{"t":126,"s":[36.5,421.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":67,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":105,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[-100,100,100]},{"t":126,"s":[-100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":63,"s":[22,22]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[32,32]},{"t":126,"s":[26,9.75]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[1,0,0.86,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":75,"s":[1,0,0.86,1],"h":1},{"t":80,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0,0.86,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":101,"s":[1,0,0.86,1],"h":1},{"t":110,"s":[0.89,0.89,0.89,1],"h":1},{"t":120,"s":[1,0,0.86,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":63,"op":212,"st":32},{"ind":13,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[50]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[-6]},{"t":103,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[250.5,249,0],"to":[-2.6,-22.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":69,"s":[190.72,157.91,0],"to":[-69.01,-1.59,0],"ti":[-1.05,-109.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[121.5,435.5,0],"to":[-0.24,-0.22,0],"ti":[-0.07,-2.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[121.37,423.98,0],"to":[0.05,2.14,0],"ti":[0,0,0]},{"t":99,"s":[121.5,438,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-3,-1.87],[0,3.56],[3,-1.87]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.31,6.75],[-3.37,5.81],[-6.75,-0.19],[-7.12,-0.19],[-7.5,3.38],[-12,3.56],[-12.75,0.19],[-7.31,-4.69],[-4.87,-13.12],[-13.97,0.19]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.37,-5.98],[3.57,-5.79],[3.56,-6.19],[0.76,-10.29],[6.38,-11.06],[7.88,-4.12],[13.88,2.44],[6.95,-12.16],[-0.37,-11.98]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.5,13.88],[10.5,8.06],[6.75,0],[3.19,6],[3.56,6.19],[6.75,4.69],[9.19,7.88],[5.63,10.88],[0.88,9.42],[-0.19,8.81],[-0.72,9.06],[-9,10.69]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18,0],[0,18],[-18,0],[0,-18]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[346,93.33],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":90,"s":[346,87.33],"to":[0,0],"ti":[0,0]},{"t":99,"s":[346,93.33]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":99,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,2]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"t":99,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[115]},{"i":{"x":[0.83],"y":[0.79]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[459]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":112,"s":[353]},{"t":122,"s":[359]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[291.5,240,0],"to":[-2.6,-19.52,0],"ti":[-37.7,-0.28,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":71,"s":[329.72,135.91,0],"to":[54.99,0.41,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[386.5,440,0],"to":[-7,-25,0],"ti":[17,0.5,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[345.5,440,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[335.5,440,0],"to":[6.75,0.38,0],"ti":[0,0,0]},{"t":122,"s":[350.5,455,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[-100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-3,-1.87],[0,3.56],[3,-1.87]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.31,6.75],[-3.37,5.81],[-6.75,-0.19],[-7.12,-0.19],[-7.5,3.38],[-12,3.56],[-12.75,0.19],[-7.31,-4.69],[-4.87,-13.12],[-13.97,0.19]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.37,-5.98],[3.57,-5.79],[3.56,-6.19],[0.76,-10.29],[6.38,-11.06],[7.88,-4.12],[13.88,2.44],[6.95,-12.16],[-0.37,-11.98]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.5,13.88],[10.5,8.06],[6.75,0],[3.19,6],[3.56,6.19],[6.75,4.69],[9.19,7.88],[5.63,10.88],[0.88,9.42],[-0.19,8.81],[-0.72,9.06],[-9,10.69]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18,0],[0,18],[-18,0],[0,-18]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":122,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":15,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-59]},{"i":{"x":[0.49],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":69,"s":[-30]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[12]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-14]},{"t":96,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[290.5,195,0],"to":[0.4,-33.52,0],"ti":[-40.98,-0.41,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[356.72,126.91,0],"to":[40.99,0.41,0],"ti":[0.95,-126.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[420.5,419,0],"to":[-0.41,-0.39,0],"ti":[-0.05,-33.51,0]},{"t":96,"s":[420.5,419,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[0,0,100]},{"t":65,"s":[90,90,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-3,-1.87],[0,3.56],[3,-1.87]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.31,6.75],[-3.37,5.81],[-6.75,-0.19],[-7.12,-0.19],[-7.5,3.38],[-12,3.56],[-12.75,0.19],[-7.31,-4.69],[-4.87,-13.12],[-13.97,0.19]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.37,-5.98],[3.57,-5.79],[3.56,-6.19],[0.76,-10.29],[6.38,-11.06],[7.88,-4.12],[13.88,2.44],[6.95,-12.16],[-0.37,-11.98]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.5,13.88],[10.5,8.06],[6.75,0],[3.19,6],[3.56,6.19],[6.75,4.69],[9.19,7.88],[5.63,10.88],[0.88,9.42],[-0.19,8.81],[-0.72,9.06],[-9,10.69]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18,0],[0,18],[-18,0],[0,-18]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,40]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,20]},{"t":97,"s":[100,40]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,3]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,42]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,26]},{"t":97,"s":[100,42]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[346.44,208.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[79,56.3,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":180,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":149,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":161,"s":[100,100]},{"t":180,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":149,"op":180,"st":92},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":141,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":166,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":129,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":141,"s":[100,100]},{"t":166,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":129,"op":166,"st":72},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,184.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":122,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":145,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":114,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":122,"s":[100,100]},{"t":145,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":114,"op":146,"st":57},{"ind":19,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":99,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":136,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":99,"op":137,"st":42},{"ind":20,"ty":4,"ks":{"o":{"a":1,"k":[{"t":45,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"r":{"k":0},"p":{"k":[284.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":82,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":45,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":57,"s":[100,100]},{"t":82,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":45,"op":83,"st":-12},{"ind":21,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":56,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":25,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":33,"s":[100,100]},{"t":56,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":25,"op":57,"st":-32},{"ind":22,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":12,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":37,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":0,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":12,"s":[100,100]},{"t":37,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":38,"st":-57},{"ind":23,"ty":4,"parent":27,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[257.66,216.8,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[257.66,195.11,0],"to":[0,0,0],"ti":[0,0,0]},{"t":74,"s":[257.66,196.8,0]}],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"t":66,"s":[100,50,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.13,-28.48],[-50.62,19.68],[-18.12,-3.73],[14.13,40.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"t":63,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":65,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.75,-16.98],[26.41,20.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-32.08,-18.25],[-14.49,-16.96],[-15.39,-18.55],[-52.77,-20.68]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":69,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-42.74,-31.07],[-14.97,-46.8],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"t":68,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.06,-20.19],[-18.55,-45.47],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[2.59,-1.44],[37.94,1.18],[-6.25,14.1],[-24.19,4.01]],"o":[[-19.07,-0.37],[-2.16,-0.07],[10.87,19.21],[-1.7,0.73]],"v":[[48.4,19.5],[-17.41,53.75],[-6.96,-10.32],[71.12,18.18]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[1.94,-1.08],[6.24,-1.19],[15.02,17.72],[-16.78,-17.45]],"o":[[-3.39,20.66],[-1.59,0.3],[8.15,14.41],[-1.28,0.54]],"v":[[47.38,-11.46],[33.39,27.91],[-7.43,15.2],[48.16,-10.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[{"i":[[1.3,-0.72],[4.59,9.5],[7.91,-2.82],[-5.41,1.07]],"o":[[-0.4,-8.63],[-0.47,-0.97],[10.84,16.61],[-0.85,0.36]],"v":[[54.18,-18.16],[38.44,-59.82],[16.53,-34.2],[42.57,-26.83]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":71,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":24,"ty":4,"parent":27,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":68,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[256.46,249.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[256.46,225.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[256.46,209.72,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[256.46,206.72,0]}],"l":2},"a":{"k":[256.46,249.72,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,36,100]},{"t":67,"s":[100,14,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[1.75,-10.16],[-45.92,7.16],[-17.13,19.11],[30,-1.05]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-56.04,12.3],[-19.24,23.32],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.1,11.25],[-18.36,20.12],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[0.71,-11.86],[-45.71,6.16],[-16.92,18.11],[34.21,-0.66]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":25,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.96,347.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":26,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[100],"h":1},{"t":81,"s":[0],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-3,-1.87],[0,3.56],[3,-1.87]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.31,6.75],[-3.37,5.81],[-6.75,-0.19],[-7.12,-0.19],[-7.5,3.38],[-12,3.56],[-12.75,0.19],[-7.31,-4.69],[-4.87,-13.12],[-13.97,0.19]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.37,-5.98],[3.57,-5.79],[3.56,-6.19],[0.76,-10.29],[6.38,-11.06],[7.88,-4.12],[13.88,2.44],[6.95,-12.16],[-0.37,-11.98]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.5,13.88],[10.5,8.06],[6.75,0],[3.19,6],[3.56,6.19],[6.75,4.69],[9.19,7.88],[5.63,10.88],[0.88,9.42],[-0.19,8.81],[-0.72,9.06],[-9,10.69]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18,0],[0,18],[-18,0],[0,-18]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":27,"ty":4,"parent":28,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":72,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":72,"s":[257.27,128.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1,-61.13],[131.24,-49.8],[-1.2,-40.13],[-132.88,-50.13]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"t":72,"s":[81,81]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":28,"ty":4,"parent":36,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-74.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":29,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[-198]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-174]},{"t":97,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[220.5,269,0],"to":[-2.6,-19.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[157.72,193.91,0],"to":[-69.01,-1.59,0],"ti":[2.95,-91.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[83.5,374,0],"to":[-0.41,-0.39,0],"ti":[-3.5,-7,0]},{"t":97,"s":[87.5,374,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[84,84,100]},{"t":88,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-3,-1.87],[0,3.56],[3,-1.87]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.31,6.75],[-3.37,5.81],[-6.75,-0.19],[-7.12,-0.19],[-7.5,3.38],[-12,3.56],[-12.75,0.19],[-7.31,-4.69],[-4.87,-13.12],[-13.97,0.19]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.37,-5.98],[3.57,-5.79],[3.56,-6.19],[0.76,-10.29],[6.38,-11.06],[7.88,-4.12],[13.88,2.44],[6.95,-12.16],[-0.37,-11.98]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.5,13.88],[10.5,8.06],[6.75,0],[3.19,6],[3.56,6.19],[6.75,4.69],[9.19,7.88],[5.63,10.88],[0.88,9.42],[-0.19,8.81],[-0.72,9.06],[-9,10.69]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18,0],[0,18],[-18,0],[0,-18]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[100,24]},{"t":97,"s":[100,12]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[0,-3],"to":[0,0],"ti":[0,0]},{"t":97,"s":[0,-2.5]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[91,28]},{"t":97,"s":[91,22]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":30,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[-180]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[-207]},{"t":93,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[296.5,278,0],"to":[2.4,-15.52,0],"ti":[-54.47,3.14,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[348.72,155.91,0],"to":[56.66,-3.26,0],"ti":[-0.05,-138.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[428.5,358,0],"to":[-4.91,-22.89,0],"ti":[0.5,-14.75,0]},{"t":93,"s":[402.5,376,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[100,100,100]},{"t":85,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-2.49,0.61],[0.51,6.05],[3.51,0.61]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-6.8,9.24],[-2.86,8.3],[-6.24,2.3],[-6.61,2.3],[-6.99,5.86],[-11.49,6.05],[-12.24,2.67],[-6.8,-2.2],[-4.36,-10.64],[-13.45,2.67]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-2.85,-3.49],[4.09,-3.31],[4.08,-3.7],[1.27,-7.8],[6.89,-8.58],[8.39,-1.64],[14.39,4.92],[7.46,-9.68],[0.15,-9.49]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[2.01,16.36],[11.01,10.55],[7.26,2.49],[3.7,8.49],[4.08,8.67],[7.26,7.17],[9.7,10.36],[6.14,13.36],[1.39,11.91],[0.33,11.3],[-0.21,11.55],[-8.49,13.17]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18.51,2.49],[0.51,20.49],[-17.49,2.49],[0.51,-15.51]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[345.49,90.85]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,-100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[100,0]},{"t":93,"s":[100,-24]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":91,"s":[0,-4]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[91,4]},{"t":93,"s":[100,31]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":31,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.48,312.85,0],"l":2},"a":{"k":[266.48,312.85,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[177.2,-0.1],[180.2,5.34],[183.2,-0.1]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[172.88,8.53],[176.82,7.59],[173.45,1.59],[173.07,1.59],[172.7,5.15],[168.2,5.34],[167.45,1.97],[172.88,-2.91],[175.32,-11.35],[166.23,1.97]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[176.83,-4.2],[183.77,-4.01],[183.76,-4.41],[180.96,-8.51],[186.57,-9.28],[188.07,-2.35],[194.07,4.22],[187.14,-10.39],[179.83,-10.2]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[181.7,15.65],[190.7,9.84],[186.95,1.78],[183.38,7.78],[183.76,7.97],[186.95,6.47],[189.38,9.65],[185.82,12.65],[181.08,11.2],[180.01,10.59],[179.47,10.84],[171.2,12.47]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[198.2,1.78],[180.2,19.78],[162.2,1.78],[180.2,-16.22]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-15.3,90.48]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":91,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":113,"s":[5]},{"t":119,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[231.5,294],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[231.5,283],"to":[0,0],"ti":[0,0]},{"t":74,"s":[231.5,294]}]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":96},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[177.2,-0.1],[180.2,5.34],[183.2,-0.1]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[172.88,8.53],[176.82,7.59],[173.45,1.59],[173.07,1.59],[172.7,5.15],[168.2,5.34],[167.45,1.97],[172.88,-2.91],[175.32,-11.35],[166.23,1.97]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[176.83,-4.2],[183.77,-4.01],[183.76,-4.41],[180.96,-8.51],[186.57,-9.28],[188.07,-2.35],[194.07,4.22],[187.14,-10.39],[179.83,-10.2]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[181.7,15.65],[190.7,9.84],[186.95,1.78],[183.38,7.78],[183.76,7.97],[186.95,6.47],[189.38,9.65],[185.82,12.65],[181.08,11.2],[180.01,10.59],[179.47,10.84],[171.2,12.47]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[198.2,1.78],[180.2,19.78],[162.2,1.78],[180.2,-16.22]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-15.98,87.15]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":91,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":113,"s":[5]},{"t":119,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[189,270.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[189,263.5],"to":[0,0],"ti":[0,0]},{"t":74,"s":[189,270.5]}]},"a":{"k":[166,92]},"s":{"k":[103.56,52.65]},"r":{"k":-3.75},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[177.2,-0.1],[180.2,5.34],[183.2,-0.1]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[172.88,8.53],[176.82,7.59],[173.45,1.59],[173.07,1.59],[172.7,5.15],[168.2,5.34],[167.45,1.97],[172.88,-2.91],[175.32,-11.35],[166.23,1.97]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[176.83,-4.2],[183.77,-4.01],[183.76,-4.41],[180.96,-8.51],[186.57,-9.28],[188.07,-2.35],[194.07,4.22],[187.14,-10.39],[179.83,-10.2]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[181.7,15.65],[190.7,9.84],[186.95,1.78],[183.38,7.78],[183.76,7.97],[186.95,6.47],[189.38,9.65],[185.82,12.65],[181.08,11.2],[180.01,10.59],[179.47,10.84],[171.2,12.47]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[198.2,1.78],[180.2,19.78],[162.2,1.78],[180.2,-16.22]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-14.22,96.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":91,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":113,"s":[5]},{"t":119,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,10]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":91,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":113,"s":[5]},{"t":119,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[329.5,274],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[329.5,262],"to":[0,0],"ti":[0,0]},{"t":75,"s":[329.5,274]}]},"a":{"k":[166,92]},"s":{"k":[97,43.65]},"r":{"k":14},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-2.06,-0.1],[0.95,5.34],[3.95,-0.1]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-6.37,8.53],[-2.43,7.59],[-5.8,1.59],[-6.18,1.59],[-6.55,5.15],[-11.05,5.34],[-11.8,1.97],[-6.37,-2.91],[-3.93,-11.35],[-13.02,1.97]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-2.42,-4.2],[4.52,-4.01],[4.51,-4.41],[1.71,-8.51],[7.32,-9.28],[8.82,-2.35],[14.82,4.22],[7.89,-10.39],[0.58,-10.2]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[2.45,15.65],[11.45,9.84],[7.7,1.78],[4.13,7.78],[4.51,7.97],[7.7,6.47],[10.13,9.65],[6.57,12.65],[1.83,11.2],[0.76,10.59],[0.22,10.84],[-8.05,12.47]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18.95,1.78],[0.95,19.78],[-17.05,1.78],[0.95,-16.22]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":91,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":113,"s":[5]},{"t":119,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":91,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":113,"s":[5]},{"t":119,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[238.5,264.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[238.5,254.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[238.5,264.5]}]},"a":{"k":[166,92]},"s":{"k":[100,39]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-2.06,0.07],[0.94,5.51],[3.94,0.07]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-6.37,8.7],[-2.43,7.76],[-5.81,1.76],[-6.18,1.76],[-6.56,5.32],[-11.06,5.51],[-11.81,2.13],[-6.37,-2.74],[-3.93,-11.18],[-13.02,2.13]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-2.42,-4.03],[4.52,-3.84],[4.51,-4.24],[1.7,-8.34],[7.32,-9.11],[8.82,-2.18],[14.82,4.39],[7.89,-10.22],[0.58,-10.03]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[2.44,15.82],[11.44,10.01],[7.69,1.95],[4.13,7.95],[4.51,8.13],[7.69,6.63],[10.13,9.82],[6.57,12.82],[1.82,11.37],[0.76,10.76],[0.22,11.01],[-8.06,12.63]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18.94,1.95],[0.94,19.95],[-17.06,1.95],[0.94,-16.05]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.313725501299,0.68235296011,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,12]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[285.5,268.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[285.5,258.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[285.5,268.5]}]},"a":{"k":[166,92]},"s":{"k":[100,32]},"r":{"k":-8},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-1.64,-0.78],[1.36,4.66],[4.36,-0.78]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-5.95,7.85],[-2.02,6.91],[-5.39,0.91],[-5.77,0.91],[-6.14,4.47],[-10.64,4.66],[-11.39,1.29],[-5.95,-3.59],[-3.52,-12.03],[-12.61,1.29]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-2.01,-4.88],[4.93,-4.69],[4.92,-5.09],[2.12,-9.19],[7.73,-9.96],[9.23,-3.03],[15.23,3.54],[8.31,-11.07],[0.99,-10.88]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[2.86,14.97],[11.86,9.16],[8.11,1.1],[4.55,7.1],[4.92,7.29],[8.11,5.79],[10.55,8.97],[6.98,11.97],[2.24,10.52],[1.17,9.91],[0.63,10.16],[-7.64,11.79]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[19.36,1.1],[1.36,19.1],[-16.64,1.1],[1.36,-16.9]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,7]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[271.5,254],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[271.5,245],"to":[0,0],"ti":[0,0]},{"t":71,"s":[271.5,254]}]},"a":{"k":[166,92]},"s":{"k":[77,28.36]},"r":{"k":3.04},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-2.49,-0.59],[0.51,4.85],[3.51,-0.59]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-6.81,8.04],[-2.87,7.1],[-6.24,1.1],[-6.62,1.1],[-6.99,4.66],[-11.49,4.85],[-12.24,1.48],[-6.81,-3.4],[-4.37,-11.84],[-13.46,1.48]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-2.86,-4.69],[4.08,-4.5],[4.07,-4.9],[1.26,-9],[6.88,-9.77],[8.38,-2.84],[14.38,3.73],[7.45,-10.88],[0.14,-10.69]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[2.01,15.16],[11.01,9.35],[7.26,1.29],[3.69,7.29],[4.07,7.47],[7.26,5.97],[9.69,9.16],[6.13,12.16],[1.39,10.71],[0.32,10.1],[-0.22,10.35],[-8.49,11.97]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18.51,1.29],[0.51,19.29],[-17.49,1.29],[0.51,-16.71]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":107,"s":[5]},{"t":113,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[284.5,298]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":100},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":32,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":33,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[0],"h":1},{"t":81,"s":[100],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[2.25,-0.56],[-1.31,-1.31],[0,1.5]],"o":[[0.19,1.69],[1.31,-1.31],[-1.96,-0.56]],"v":[[-3,-1.87],[0,3.56],[3,-1.87]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[-5.06,0],[-1.12,0.56],[0.38,1.13],[0,0],[1.04,-1.21],[1.69,1.5],[-0.37,1.31],[-3,1.31],[-1.69,2.63],[-0.09,-5.81]],"o":[[1.13,0],[-1.31,-1.12],[0,0],[0.38,0.75],[-0.8,0.94],[-0.75,-0.67],[0.38,-1.5],[0,-3.75],[-5.25,1.69],[0.05,2.81]],"v":[[-7.31,6.75],[-3.37,5.81],[-6.75,-0.19],[-7.12,-0.19],[-7.5,3.38],[-12,3.56],[-12.75,0.19],[-7.31,-4.69],[-4.87,-13.12],[-13.97,0.19]],"c":true}}},{"ind":2,"ty":"sh","ks":{"k":{"i":[[0,-1.52],[-1.7,-0.58],[0,0],[-0.67,2.26],[-2.18,-2.18],[0,-3.19],[-1.87,-3.94],[4.12,2.6],[0.72,-0.56]],"o":[[1.68,-0.4],[0,0],[-1.87,-0.37],[0.55,-1.87],[1.19,1.19],[1.12,0.54],[0.75,-7.69],[-3.76,-2.38],[-1.88,1.48]],"v":[[-3.37,-5.98],[3.57,-5.79],[3.56,-6.19],[0.76,-10.29],[6.38,-11.06],[7.88,-4.12],[13.88,2.44],[6.95,-12.16],[-0.37,-11.98]],"c":true}}},{"ind":3,"ty":"sh","ks":{"k":{"i":[[-5.25,0.19],[-0.8,3.19],[2.44,1.31],[1.5,-1.69],[0,0],[-2.06,0],[0,-2.06],[3.31,0],[1.42,0.83],[0.39,0.21],[0.22,-0.11],[4.32,0.32]],"o":[[3.38,-0.12],[0.75,-3],[-0.19,1.5],[0,0],[0.56,-0.75],[0.94,0],[0,0.56],[-2.28,0],[-0.33,-0.19],[-0.14,0.06],[-1.28,0.63],[2.44,1.88]],"v":[[1.5,13.88],[10.5,8.06],[6.75,0],[3.19,6],[3.56,6.19],[6.75,4.69],[9.19,7.88],[5.63,10.88],[0.88,9.42],[-0.19,8.81],[-0.72,9.06],[-9,10.69]],"c":true}}},{"ind":4,"ty":"sh","ks":{"k":{"i":[[0,-9.94],[9.94,0],[0,9.94],[-9.94,0]],"o":[[0,9.94],[-9.94,0],[0,-9.94],[9.94,0]],"v":[[18,0],[0,18],[-18,0],[0,-18]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.012389359996,0.312909245491,0.683088243008,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":83,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":90,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":112,"s":[5]},{"t":118,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.007843137719,0.546325206757,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":34,"ty":4,"parent":28,"ks":{"o":{"a":1,"k":[{"t":56,"s":[0],"h":1},{"t":72,"s":[100],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.34,"y":0},"t":72,"s":[257.27,128.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":91,"s":[257.27,342.53,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.48,-54.5],[131.76,-43.17],[-0.67,-33.5],[-132.36,-43.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.77,33.72],[131.48,-1.28],[-0.92,-32.28],[-132.64,-1.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.76,5.8],[131.57,4.14],[-0.9,5.8],[-132.55,3.8]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1.15,-23.5],[131.16,-19.17],[-1.28,-10.75],[-132.97,-19.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.05,-64.7],[132.03,-18.36],[-0.09,37.3],[-132.09,-18.7]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,-99.54],[132.16,0.65],[-0.09,95.58],[-131.96,0.98]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1},{"t":79,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[64.68,-94.47],[-66.93,-61.2],[-66.95,-36.54],[64.92,-50.71]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.53,-53.52],[-66.43,-54.53],[-66.43,-31.2],[65.4,-28.87]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-53.02],[-66.03,-54.03],[-66.03,-30.7],[65.97,26.63]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,61.22],[-65.96,-33.37],[-66.01,-34.68],[66.04,60.58]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65,-36.37],[-67.1,-51.21],[-67.19,-94.97],[65.09,-60.63]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.52,-31.03],[-66.61,-29.37],[-66.33,-54.02],[65.59,-53.95]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.92,-30.53],[-66.04,26.13],[-65.93,-53.52],[65.99,-53.45]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-35.85],[-65.98,60.07],[-65.91,60.71],[66.05,-34.94]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":72,"s":[81,81]},{"t":83,"s":[73,73]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":36,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":29,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":43,"s":[1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":46,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1]},{"t":58,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":55,"s":[257,451,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[257,439,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[257,451,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.9,1.09,0]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":55,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":58,"s":[110,89,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[91,108,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[103,97,100]},{"t":73,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]},{"id":"comp_1","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":181,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":144,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":156,"s":[100,100]},{"t":181,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":144,"op":181,"st":87},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[336.44,166.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":136,"s":[100,100]},{"t":161,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":124,"op":162,"st":67},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[190.44,214.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":117,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":109,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":117,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":109,"op":141,"st":52},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":103,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":114,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[100,100]},{"t":114,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":95,"op":115,"st":38},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":100,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":81,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":89,"s":[100,100]},{"t":100,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":81,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":100,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":81,"op":101,"st":24},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":90,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":73,"s":[100,100]},{"t":90,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":90,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":65,"op":91,"st":8},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,221.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":69,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":50,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":58,"s":[100,100]},{"t":69,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":50,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":69,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":50,"op":70,"st":-7},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[323.44,178.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":49,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":60,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":41,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":49,"s":[100,100]},{"t":60,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":41,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":60,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":41,"op":61,"st":-16},{"ind":9,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257.66,216.8,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-43.5],[-50.62,19.68],[-19.83,-18.75],[12.42,25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.08,-16.07],[17.57,-27.27],[-8.47,2.39],[30.67,-18.15]],"o":[[-15.8,5.37],[10.67,-14.43],[36.88,-10.55],[63.53,-17.02]],"v":[[1.03,-25.49],[-50.62,19.68],[-18.22,-2.26],[14.03,39.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"t":83,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-32],[24.7,5.25],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.13],[15.01,-7.06],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,17.55],[-23.68,11.14],[4.47,0.56]],"v":[[-34.14,-8.48],[52.65,-15.65],[26.31,20],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"t":83,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-25.54,-39.48],[3.89,-52.46],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-14.47],[-8.77,1.66],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.23],[26.18,-4.96],[-7.91,6.92]],"v":[[28.2,44.02],[-47.21,-19.87],[-19.44,-35.04],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"t":83,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-32.04,-23.56],[-1.35,-53.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,23.79],[-20,4.13],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.37],[-16,22.94],[-7,3.5]],"v":[[44.51,48.89],[-46.54,-9.48],[-23.02,-33.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"t":83,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":10,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.76,93.8],[14.58,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.58,93.8],[14.76,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":11,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[257.27,248.75,0],"to":[0,14.83,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[257.27,218.64,0],"to":[0,0,0],"ti":[0,-8.61,0]},{"t":34,"s":[257.27,248.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":35},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":12,"ty":4,"parent":14,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-28.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":13,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":32,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[-1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":66,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[-1.5]},{"t":81,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.17,"y":0.17},"t":0,"s":[257,739,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.13},"t":20,"s":[257,379,0],"to":[0,0,0],"ti":[0,0,0]},{"t":32,"s":[257,405,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":9,"s":[97,105,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":36,"s":[104,96,100]},{"t":43,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":0,"cl":"Present3","refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":180,"op":360,"st":180},{"ind":2,"ty":0,"cl":"Present2","refId":"comp_1","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/azero_packing.json b/common/src/main/res/raw/azero_packing.json new file mode 100644 index 0000000..4f2c6c8 --- /dev/null +++ b/common/src/main/res/raw/azero_packing.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":300,"w":512,"h":512,"assets":[{"id":"comp_0","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[12.6]},{"t":102,"s":[96]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":82,"s":[116,96,0],"to":[86,26,0],"ti":[-1.25,-87.67,0]},{"t":102,"s":[290.5,297,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[100,84,100]},{"t":97,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[-80.88,1.3],[-76.44,1.3],[-76.16,1.18],[-76.04,0.91],[-76.04,-2.29],[-76.16,-2.57],[-76.44,-2.69],[-82.66,-2.69],[-87.56,-13.71],[-87.75,-13.94],[-88.04,-14.03],[-92.04,-14.03],[-92.33,-13.94],[-92.53,-13.71],[-97.42,-2.7],[-103.64,-2.7],[-103.8,-2.67],[-103.93,-2.58],[-104.01,-2.45],[-104.04,-2.29],[-104.04,0.91],[-103.92,1.19],[-103.64,1.31],[-99.2,1.31],[-104,12.1],[-104.03,12.29],[-103.97,12.47],[-103.83,12.6],[-103.64,12.65],[-100.07,12.65],[-99.78,12.56],[-99.58,12.33],[-90.04,-9.11],[-80.5,12.33],[-80.3,12.56],[-80.01,12.65],[-76.44,12.65],[-76.25,12.6],[-76.11,12.47],[-76.04,12.28],[-76.08,12.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":132,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":154,"s":[5]},{"t":160,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":82,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":92,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":82,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[110,134]},{"t":92,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":101,"s":[28]},{"t":121,"s":[-3.75]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":101,"s":[104,122,0],"to":[54,7,0],"ti":[-19.25,-84.67,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[243.5,267,0],"to":[0.06,-3.71,0],"ti":[0,-3.54,0]},{"t":127,"s":[243.5,267,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[85,85,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":121,"s":[95.56,94.94,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":124,"s":[102.56,40.29,100]},{"t":127,"s":[103.56,52.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.98,2.04],[13.41,2.04],[13.69,1.93],[13.81,1.65],[13.81,-1.55],[13.7,-1.83],[13.41,-1.95],[7.2,-1.95],[2.3,-12.97],[2.1,-13.2],[1.81,-13.29],[-2.19,-13.29],[-2.47,-13.2],[-2.67,-12.97],[-7.57,-1.96],[-13.78,-1.96],[-13.94,-1.93],[-14.07,-1.84],[-14.16,-1.71],[-14.18,-1.55],[-14.18,1.65],[-14.07,1.93],[-13.78,2.05],[-9.35,2.05],[-14.15,12.84],[-14.17,13.03],[-14.11,13.21],[-13.97,13.34],[-13.78,13.39],[-10.21,13.39],[-9.92,13.3],[-9.73,13.07],[-0.19,-8.37],[9.36,13.07],[9.55,13.3],[9.84,13.39],[13.41,13.39],[13.61,13.34],[13.75,13.21],[13.81,13.02],[13.78,12.83]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.023529414088,0.643137276173,0.549019634724,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[0,0],"to":[0,-0.75],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":124,"s":[0,11],"to":[0,0],"ti":[0,0]},{"t":127,"s":[0,7]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":115,"s":[109.64,109.64]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":121,"s":[120,120]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[102.4,116]},{"t":127,"s":[102.4,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[31.25]},{"t":120,"s":[14]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":96,"s":[367,78,0],"to":[-18,29,0],"ti":[6.75,-76.67,0]},{"t":116,"s":[325.5,270,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[85,85,100]},{"t":116,"s":[97,43.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.12,3.26],[12.56,3.26],[12.84,3.15],[12.96,2.88],[12.96,-0.32],[12.84,-0.61],[12.56,-0.72],[6.34,-0.72],[1.44,-11.74],[1.25,-11.97],[0.96,-12.06],[-3.04,-12.06],[-3.33,-11.97],[-3.52,-11.74],[-8.42,-0.74],[-14.64,-0.74],[-14.8,-0.71],[-14.93,-0.62],[-15.01,-0.48],[-15.04,-0.32],[-15.04,2.88],[-14.92,3.16],[-14.64,3.28],[-10.2,3.28],[-15,14.07],[-15.03,14.26],[-14.96,14.44],[-14.82,14.57],[-14.64,14.62],[-11.07,14.62],[-10.78,14.53],[-10.58,14.3],[-1.04,-7.14],[8.5,14.3],[8.7,14.53],[8.99,14.62],[12.56,14.62],[12.75,14.57],[12.9,14.43],[12.96,14.25],[12.93,14.05]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.023529414088,0.643137276173,0.549019634724,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":116,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":106,"s":[116,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[102.4,116]},{"t":125,"s":[114.47,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[81.55]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":112,"s":[70.2]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[-10]},{"t":119,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":92,"s":[125,74,0],"to":[34,35,0],"ti":[-5.25,-79.67,0]},{"t":112,"s":[210.5,282,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[42.6,26.6,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[71,71,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":112,"s":[100,34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,54,100]},{"t":119,"s":[100,39,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.63,1.38],[13.07,1.38],[13.35,1.27],[13.47,0.99],[13.47,-2.21],[13.35,-2.49],[13.07,-2.61],[6.85,-2.61],[1.95,-13.63],[1.76,-13.86],[1.47,-13.95],[-2.53,-13.95],[-2.82,-13.86],[-3.01,-13.63],[-7.91,-2.62],[-14.13,-2.62],[-14.28,-2.59],[-14.42,-2.5],[-14.5,-2.37],[-14.53,-2.21],[-14.53,0.99],[-14.41,1.28],[-14.13,1.39],[-9.69,1.39],[-14.49,12.18],[-14.52,12.37],[-14.45,12.55],[-14.31,12.68],[-14.13,12.73],[-10.56,12.73],[-10.27,12.65],[-10.07,12.41],[-0.53,-9.02],[9.01,12.41],[9.21,12.65],[9.5,12.73],[13.07,12.73],[13.26,12.68],[13.41,12.55],[13.47,12.36],[13.44,12.17]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.023529414088,0.643137276173,0.549019634724,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[0,9],"to":[0,-0.15],"ti":[0,2.44]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,-8.41],"to":[0,-2.01],"ti":[0,-0.17]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":97,"s":[0,0],"to":[0,0.62],"ti":[0,0]},{"t":112,"s":[0,12]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":92,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[104.15,129.8]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":97,"s":[104.25,104]},{"t":112,"s":[105,130]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[-23]},{"t":101,"s":[-8]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":75,"s":[433,92,0],"to":[-77,4,0],"ti":[-1.25,-87.67,0]},{"t":97,"s":[238.5,296,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,100,100]},{"t":93,"s":[100,32,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[10.43,5.19],[14.86,5.19],[15.14,5.08],[15.26,4.8],[15.26,1.6],[15.15,1.32],[14.86,1.2],[8.65,1.2],[3.75,-9.82],[3.55,-10.05],[3.26,-10.14],[-0.74,-10.14],[-1.02,-10.05],[-1.22,-9.82],[-6.12,1.19],[-12.33,1.19],[-12.49,1.22],[-12.62,1.31],[-12.71,1.44],[-12.73,1.6],[-12.73,4.8],[-12.62,5.09],[-12.33,5.2],[-7.9,5.2],[-12.7,15.99],[-12.72,16.18],[-12.66,16.36],[-12.52,16.49],[-12.33,16.54],[-8.76,16.54],[-8.47,16.45],[-8.28,16.22],[1.26,-5.21],[10.81,16.22],[11,16.45],[11.29,16.54],[14.86,16.54],[15.06,16.49],[15.2,16.36],[15.26,16.17],[15.23,15.98]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.023529414088,0.643137276173,0.549019634724,0.35686275363]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":93,"s":[0,19]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[116,116]},{"t":93,"s":[105,125]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":100,"s":[-22.75]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":105,"s":[-22]},{"t":109,"s":[-9]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":85,"s":[367,78,0],"to":[-34,23,0],"ti":[6.75,-76.67,0]},{"t":105,"s":[264.5,279,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[77,77,100]},{"t":105,"s":[77,34.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[10.04,1.98],[14.48,1.98],[14.76,1.87],[14.88,1.6],[14.88,-1.61],[14.76,-1.89],[14.48,-2.01],[8.26,-2.01],[3.36,-13.03],[3.17,-13.26],[2.88,-13.34],[-1.12,-13.34],[-1.41,-13.26],[-1.61,-13.03],[-6.5,-2.02],[-12.72,-2.02],[-12.88,-1.99],[-13.01,-1.9],[-13.1,-1.76],[-13.12,-1.61],[-13.12,1.6],[-13,1.88],[-12.72,2],[-8.28,2],[-13.08,12.78],[-13.11,12.97],[-13.05,13.15],[-12.91,13.28],[-12.72,13.33],[-9.15,13.33],[-8.86,13.25],[-8.66,13.02],[0.88,-8.42],[10.42,13.02],[10.62,13.25],[10.91,13.33],[14.48,13.33],[14.67,13.28],[14.81,13.15],[14.88,12.96],[14.84,12.77]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.023529414088,0.643137276173,0.549019634724,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":105,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":85,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[116,116]},{"t":105,"s":[109,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[12.6]},{"t":90,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":70,"s":[427,116,0],"to":[-67,4,0],"ti":[-1.25,-87.67,0]},{"t":90,"s":[264.5,338,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[71,38.34,100]},{"t":80,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[6.9,2.44],[11.34,2.44],[11.61,2.32],[11.74,2.05],[11.74,-1.15],[11.62,-1.43],[11.34,-1.55],[5.12,-1.55],[0.22,-12.57],[0.02,-12.8],[-0.26,-12.89],[-4.26,-12.89],[-4.55,-12.8],[-4.75,-12.57],[-9.65,-1.56],[-15.86,-1.56],[-16.02,-1.53],[-16.15,-1.44],[-16.24,-1.31],[-16.26,-1.15],[-16.26,2.05],[-16.15,2.33],[-15.86,2.45],[-11.43,2.45],[-16.23,13.24],[-16.25,13.43],[-16.19,13.61],[-16.05,13.74],[-15.86,13.79],[-12.29,13.79],[-12,13.7],[-11.81,13.47],[-2.26,-7.97],[7.28,13.47],[7.47,13.7],[7.77,13.79],[11.34,13.79],[11.53,13.74],[11.67,13.61],[11.73,13.42],[11.7,13.22]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[258.5,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[12.6]},{"t":80,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":60,"s":[75,92,0],"to":[105,30,0],"ti":[-1.25,-87.67,0]},{"t":80,"s":[242.5,340,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[100,84,100]},{"t":75,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.79,2.61],[13.23,2.61],[13.51,2.49],[13.63,2.22],[13.63,-0.98],[13.51,-1.26],[13.23,-1.38],[7.01,-1.38],[2.11,-12.4],[1.92,-12.63],[1.63,-12.72],[-2.37,-12.72],[-2.66,-12.63],[-2.85,-12.4],[-7.75,-1.39],[-13.97,-1.39],[-14.13,-1.36],[-14.26,-1.27],[-14.34,-1.14],[-14.37,-0.98],[-14.37,2.22],[-14.25,2.5],[-13.97,2.62],[-9.53,2.62],[-14.33,13.41],[-14.36,13.6],[-14.29,13.78],[-14.15,13.91],[-13.97,13.96],[-10.4,13.96],[-10.11,13.87],[-9.91,13.64],[-0.37,-7.8],[9.17,13.64],[9.37,13.87],[9.66,13.96],[13.23,13.96],[13.42,13.91],[13.57,13.78],[13.63,13.59],[13.6,13.39]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":275,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":300,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":263,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":275,"s":[100,100]},{"t":300,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":263,"op":300,"st":206},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[344.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":255,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":280,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":243,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":255,"s":[100,100]},{"t":280,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":243,"op":281,"st":186},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":236,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":259,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":228,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":236,"s":[100,100]},{"t":259,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":228,"op":260,"st":171},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":222,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":233,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":214,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":222,"s":[100,100]},{"t":233,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":214,"op":234,"st":157},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":208,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":219,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":200,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":208,"s":[100,100]},{"t":219,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":200,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":219,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":200,"op":220,"st":143},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":76,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":51,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":59,"s":[100,100]},{"t":76,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":51,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":76,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":51,"op":77,"st":-6},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[200.44,241.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":55,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":36,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":44,"s":[100,100]},{"t":55,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":36,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":55,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":36,"op":56,"st":-21},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[343.44,238.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":35,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":46,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":27,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":35,"s":[100,100]},{"t":46,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":27,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":46,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":27,"op":47,"st":-30},{"ind":9,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50.66,-184.2,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[11,2],[0,0],[-2.75,-7.75],[20.5,-7]],"o":[[-10,8.5],[0,0],[1.12,3.14],[40,-13]],"v":[[42.08,-17.98],[22.38,-6.82],[31.83,10.77],[14.08,31.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":171,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":172,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[46.06,-12.83],[18.2,-29.66],[-8.71,1.58],[30.67,-18.97]],"o":[[-16.15,4.5],[10.3,-11.21],[35.76,-6.47],[63.53,-17.79]],"v":[[0.89,-20.37],[-50.62,19.68],[-18.36,2.16],[13.89,45.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-36.51],[-50.62,19.68],[-19.83,-11.75],[12.42,32]],"c":true}]},{"t":209,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":170,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-29.64,-5.98],[-0.31,4.52],[-26.64,18.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":169,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-11.09],[26.17,26.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-25],[24.7,12.25],[-56.19,5.91]],"c":true}]},{"t":209,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.06,-13],[-19.29,-28.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-35.04,-33.48],[-5.61,-46.46],[47.19,27.25]],"c":true}]},{"t":209,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":168,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":163,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":166,"s":[{"i":[[2.59,-1.44],[13.98,27.91],[-8.75,-2.33],[-23.7,-7.47]],"o":[[-20.01,-7.24],[-0.97,-1.93],[2.46,22.32],[-1.7,0.73]],"v":[[44,48.14],[-36.97,-2.51],[-7.45,-15.43],[72.59,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.38,-2.11],[-22.87,-27.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-41.54,-17.57],[-10.85,-47.87],[71.01,35.39]],"c":true}]},{"t":209,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":10,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[49.33,-79.33,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":154,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[16.25,-17.11],[15.33,-34.84],[44.12,-24.39],[45.75,-6.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":159,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.25,-17.11],[15.83,-16.84],[46.12,-6.39],[45.75,-6.61]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":154,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":158,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":160,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":144,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":142,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":151,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":154,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":141,"s":[0],"h":1},{"t":142,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":157,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.21,-18.11],[15.79,-17.84],[46.08,-7.39],[45.71,-7.61]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":153,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":11,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":120,"s":[50.27,-201.25,0],"to":[0,6.23,0],"ti":[0,-15.32,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":134,"s":[50.27,-284.64,0],"to":[0,21.19,0],"ti":[0,-8.61,0]},{"t":146,"s":[50.27,-152.25,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":120,"s":[0,0,100]},{"t":144,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.4,91.93],[131.21,19.33],[0.36,-48.29],[-131.32,19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":130,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-52.85,28.76],[95.73,14.81],[62.88,1.73],[-97.23,18.68]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[83.8,18.9],[83.9,18.9],[83.72,18.4],[-85.86,18.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":132,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[73.69,-14.15],[90.29,-13.04],[73.61,-14.65],[-91.27,-13.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.04,-13.5],[132.06,-4.67],[-0.12,-14],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-85.27,-14.13],[83.47,-13.5],[83.29,-15.26],[-86.29,-13.83]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.28,0.28,0.87,1],"h":1},{"t":131,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[66.17,-103.68],[-66.06,-33.47],[-66,-17],[66.77,-83.74]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.9,-48.82],[-66,-40.33],[-66,-17],[66,-15.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":131,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":134,"s":[0.4,0.42,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":135,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[0.4,0.42,1,1]},{"t":146,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-65.25,-84.24],[-65.69,-104.18],[65.97,-33.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,-15.67],[-65.97,-49.33],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.4,0.42,1,1],"h":1},{"t":132,"s":[0.47,0.58,0.99,1],"h":1},{"t":135,"s":[0.4,0.42,1,1],"h":1},{"t":140,"s":[0.47,0.58,0.99,1],"h":1},{"t":146,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":132,"s":[0],"h":1},{"t":146,"s":[35],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"t":119,"s":[100,100],"h":1},{"t":123,"s":[-100,100],"h":1},{"t":127,"s":[100,100],"h":1},{"t":131,"s":[-100,100],"h":1},{"t":135,"s":[100,100],"h":1},{"t":140,"s":[-100,100],"h":1},{"t":146,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":12,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.85},"o":{"x":0.17,"y":0.17},"t":0,"s":[50,-98.75,0],"to":[0,1.68,0],"ti":[0,-4.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":13,"s":[50,-184.14,0],"to":[0,5.71,0],"ti":[0,-2.32,0]},{"t":26,"s":[50,-78.75,0]}],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":0,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":34,"s":[97,97,100]},{"t":50,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-62.26,55.67],[-62.73,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-61.96,76.67],[-61.81,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-57.72,99.67],[-57.69,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[-9.59,4.33],[14.9,-23.89],[0,0],[0,0]],"o":[[14.5,18.78],[-0.1,-0.56],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,33.56],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[-13.68,5.7],[-6.74,-51.36],[0,0],[0,0]],"o":[[-4.79,47.52],[0.48,-0.45],[0,0],[0,0]],"v":[[61.73,-99.67],[61.46,45.58],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":7,"s":[0.47,0.58,0.99,1],"h":1},{"t":10,"s":[0.4,0.42,1,1],"h":1},{"t":14,"s":[0.47,0.58,0.99,1],"h":1},{"t":17,"s":[0.4,0.42,1,1],"h":1},{"t":24,"s":[0.47,0.58,0.99,1],"h":1},{"t":31,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.4,55.67],[60.91,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.7,76.67],[61.84,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[65.94,99.67],[65.95,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[10.47,2.67],[-18.22,-33.33],[0,0],[0,0]],"o":[[-10.44,12.11],[-0.04,-0.56],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.88,33.56],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[10.32,2.06],[8.37,-56.82],[0,0],[0,0]],"o":[[5.88,46.61],[0.04,-0.45],[0,0],[0,0]],"v":[[-61.94,-99.67],[-62.21,45.58],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":7,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":9,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":10,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":13,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":16,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":17,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":23,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":30,"s":[0.42,0.47,1,1]},{"t":31,"s":[0.47,0.58,0.99,1]}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":13,"ty":0,"refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":103,"s":[209.44,138.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":131,"s":[209.44,289.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[71,71,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":103,"op":141,"st":46},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.76,"y":0},"t":94,"s":[317.44,178.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":120,"s":[317.44,338.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[102,102,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":102,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":133,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":94,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":102,"s":[100,100]},{"t":133,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":94,"op":134,"st":37},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":70,"s":[202.44,133.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":97,"s":[202.44,349.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[127,127,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":105,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":70,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":78,"s":[100,100]},{"t":105,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":106,"st":13},{"ind":19,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.01,-33.33],[123.51,-1.58],[-0.61,-33.67],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.93,-0.08],[123.51,-1.58],[0.31,-0.42],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.67,-51.08],[123.51,-1.58],[4.43,51.08],[-123.51,-1.25]],"c":true}]},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":151,"st":0},{"ind":21,"ty":3,"ks":{"o":{"k":0},"r":{"k":0},"p":{"k":[257,451,0],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":18,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":26,"s":[103,97,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[97,103,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":43,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":94,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":98,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":122,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":126,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":145,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":149,"s":[102,98,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":155,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.85,1.14,-0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":168,"s":[99,102,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,4.33]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":180,"s":[110,90,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.75,0.75,0.75],"y":[0,0,0]},"t":195,"s":[90,110,100]},{"t":208,"s":[100,100,100]}],"l":2}},"ip":0,"op":264,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/azero_unpacking.json b/common/src/main/res/raw/azero_unpacking.json new file mode 100644 index 0000000..2c79e26 --- /dev/null +++ b/common/src/main/res/raw/azero_unpacking.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":360,"w":512,"h":512,"assets":[{"id":"comp_0","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[248.14,284.55,0],"l":2},"a":{"k":[14,-1,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-72,39],[-152,111]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.86,45],[3,185]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[61,31],[156,113]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"t":69,"s":[73]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[0]},{"t":67,"s":[73]}]},"o":{"k":0},"m":1}],"ip":60,"op":177,"st":-3},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":-208.79},"p":{"k":[90.94,257.49,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[73.08,73.08,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.59,-21.92],[27.98,-23.86],[-36.09,-3.9],[3.75,11.83],[-1.96,-59.37],[-44.64,5.65],[18.27,13.23]],"o":[[1.21,44.71],[-24.04,20.51],[7.95,0.86],[-8.39,-26.51],[1.32,39.94],[41.51,-5.26],[-13.06,-9.45]],"v":[[-134.97,-34.32],[-81.98,23.04],[-72.15,98.43],[-50.97,80.06],[-134.14,144.99],[-59.15,210.78],[-26.48,151.25]],"c":false}}},{"ty":"st","c":{"k":[0.949019667682,0.870865167356,0,1]},"o":{"k":100},"w":{"k":10},"lc":1,"lj":1,"ml":4},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"t":122,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[25.52]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[90]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":177,"st":-3},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":-322},"p":{"k":[191.66,150.67,0],"l":2},"a":{"k":[127.48,-143.62,0],"l":2},"s":{"k":[-190,190,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-4.76,4.16],[-5.58,-2.96],[0.86,-2.32],[1.98,1.6],[-0.17,2.54],[-4.87,2.42],[-4.55,-2.99],[0.82,-1.61],[1.23,1.05],[0.03,1.62],[-5.55,1.61],[-2.45,-2.91]],"o":[[1.22,-6.2],[4.76,-4.16],[2.19,1.16],[-0.89,2.39],[-1.98,-1.6],[0.36,-5.43],[4.88,-2.42],[1.51,1],[-0.73,1.44],[-1.23,-1.05],[-0.1,-5.78],[3.66,-1.06],[0,0]],"v":[[96.85,-121.52],[105.41,-138.05],[123,-140.94],[126.33,-135.01],[119.89,-133.82],[117.49,-140.76],[126.14,-153.74],[141.72,-152.8],[143.88,-148.35],[139.69,-147.87],[138.04,-152.28],[147.62,-165.26],[158.1,-162.64]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[10]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[38]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[63]},{"t":100,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[99]},{"t":100,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0,0.602906589882,0.949019607843,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[244.14,221.55,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[56,56,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[13.57,11.19],[-31.1,23.53],[44.5,-0.5],[0.21,-20],[-89,21]],"o":[[-12.84,-10.6],[37,-28],[-34.23,0.39],[-1,97],[35.17,-8.3]],"v":[[0.84,68.6],[14,13],[-11.5,-83.5],[-43,64],[-132,56]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[23]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[39]},{"t":90,"s":[49]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[40]},{"t":85,"s":[49]}]},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[171]},{"t":90,"s":[179]}]},"m":1},{"ty":"st","c":{"k":[0,0.949019667682,0.334948072246,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[8]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":74,"s":[18]},{"t":90,"s":[5]}]},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,20]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":61,"op":169,"st":-11},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":-370},"p":{"k":[336.32,194.77,0],"l":2},"a":{"k":[-147.98,-152.87,0],"l":2},"s":{"k":[-211,211,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[3.94,4.85],[5.94,-1.95],[-0.45,-2.41],[-2.2,1.23],[-0.26,2.51],[4.35,3.18],[4.93,-2.16],[-0.52,-1.71],[-1.37,0.82],[-0.3,1.57],[5.15,2.5],[2.88,-2.43]],"o":[[-0.16,-6.25],[-3.94,-4.85],[-2.33,0.77],[0.47,2.48],[2.2,-1.23],[0.56,-5.36],[-4.35,-3.18],[-1.64,0.72],[0.47,1.53],[1.37,-0.82],[1.06,-5.62],[-3.39,-1.64],[0,0]],"v":[[-121.55,-126.72],[-127.13,-144.27],[-143.8,-150.03],[-148.04,-144.81],[-141.97,-142.56],[-138.46,-148.94],[-144.73,-163.04],[-160.08,-164.73],[-162.93,-160.75],[-158.93,-159.58],[-156.58,-163.6],[-163.75,-177.86],[-174.41,-177.06]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[13]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[45]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[63]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[99]},{"t":97,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.964705882353,0,0.037585939146,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[169]},{"t":121,"s":[-67]}]},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.86},"o":{"x":0.05,"y":0.01},"t":68,"s":[201,236.25,0],"to":[-0.3,-1.62,0],"ti":[89.46,-52.28,0]},{"i":{"x":0.52,"y":0.9},"o":{"x":0.16,"y":0.13},"t":77,"s":[117.46,105.72,0],"to":[-89.46,52.28,0],"ti":[54.73,-96.02,0]},{"t":127,"s":[83.5,392.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":79,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":84,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":88,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":99,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[80,80,100]},{"t":120,"s":[75,-75,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,-2.72],[-1.69,-6.82],[5.28,-3.42],[-3.74,4.29]],"o":[[-1.96,5],[-8.39,-4.89],[-3.96,-11.6],[3,-5.59]],"v":[[15,-15],[15,15],[-15,15],[-15,-15]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":69,"s":[0,0.34,0.91,1],"h":1},{"t":72,"s":[0.89,0.89,0.89,1],"h":1},{"t":77,"s":[0,0.34,0.91,1],"h":1},{"t":82,"s":[0.89,0.89,0.89,1],"h":1},{"t":86,"s":[0,0.34,0.91,1],"h":1},{"t":91,"s":[0.89,0.89,0.89,1],"h":1},{"t":96,"s":[0,0.34,0.91,1],"h":1},{"t":103,"s":[0.89,0.89,0.89,1],"h":1},{"t":110,"s":[0,0.34,0.91,1],"h":1},{"t":117,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":180,"st":37},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-90]},{"t":109,"s":[301.64]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[198.75,244.5,0],"to":[-1.88,-52.9,0],"ti":[28.8,18.42,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[144.71,133.7,0],"to":[-28.64,-18.32,0],"ti":[23.57,-30.86,0]},{"t":109,"s":[58.5,148.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":68,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"t":109,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[29,30]},{"t":107,"s":[18,18.62]}]},"p":{"k":[0,0]},"r":{"k":211}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[0.96,0.81,0,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0.96,0.81,0,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0.96,0.81,0,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0.96,0.81,0,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0.96,0.81,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":4},"sa":{"k":0}}]}],"ip":65,"op":110,"st":34},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[-163]},{"t":120,"s":[-73.39]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[198.75,232.5,0],"to":[2.25,-107.5,0],"ti":[45.25,-88,0]},{"t":120,"s":[43.75,100,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":100,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":107,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,45,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":113,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[132,-64,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":119,"s":[130,-32,100]},{"t":120,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[22,22]},{"t":116,"s":[15,15]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":64,"s":[0.67,0,0.27,1],"h":1},{"t":67,"s":[0.89,0.89,0.89,1],"h":1},{"t":71,"s":[0.67,0,0.27,1],"h":1},{"t":76,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[0.67,0,0.27,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":90,"s":[0.67,0,0.27,1],"h":1},{"t":97,"s":[0.89,0.89,0.89,1],"h":1},{"t":104,"s":[0.67,0,0.27,1],"h":1},{"t":111,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[0.67,0,0.27,1],"h":1},{"t":118,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":64,"op":121,"st":-53},{"ind":9,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[-29]},{"t":131,"s":[306]}]},"p":{"a":1,"k":[{"i":{"x":0.43,"y":0.94},"o":{"x":0,"y":0},"t":70,"s":[315.75,212.5,0],"to":[84.25,-131.5,0],"ti":[9.5,-151.25,0]},{"t":133,"s":[444.5,412.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":71,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":108,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"t":123,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":133,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[7.5,-7.5],[7.5,7.5],[0.09,7.5],[-7.5,7.5],[-7.5,-7.5],[-1.31,-7.5]],"c":true}]},{"t":140,"s":[{"i":[[-1.04,1.29],[-0.3,-3.48],[1.31,-2.19],[0,0],[0,0],[-1.41,1.63]],"o":[[0.26,3.49],[0.29,3.4],[-1.99,3.33],[0,0],[0,0],[1.17,-1.35]],"v":[[-2.19,-14.3],[-0.07,-2.07],[-0.67,5.04],[-7.5,7.5],[-7.5,-7.5],[-4.66,-10.34]],"c":true}]}]}},{"ty":"fl","c":{"a":1,"k":[{"t":71,"s":[1,0.61,0,1],"h":1},{"t":74,"s":[0.89,0.89,0.89,1],"h":1},{"t":79,"s":[1,0.61,0,1],"h":1},{"t":84,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0.61,0,1],"h":1},{"t":93,"s":[0.89,0.89,0.89,1],"h":1},{"t":98,"s":[1,0.61,0,1],"h":1},{"t":105,"s":[0.89,0.89,0.89,1],"h":1},{"t":112,"s":[1,0.61,0,1],"h":1},{"t":119,"s":[0.89,0.89,0.89,1],"h":1},{"t":124,"s":[1,0.61,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":70,"op":219,"st":39},{"ind":10,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-29]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[186]},{"t":130,"s":[188]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[312.75,231.5,0],"to":[-0.58,-8.79,0],"ti":[-27.32,0.19,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[337,137.9,0],"to":[27.32,-0.19,0],"ti":[5.65,-84.54,0]},{"t":130,"s":[365.5,416.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":103,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,-100,100]},{"t":130,"s":[100,40,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"k":[22,22]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":66,"s":[0,0.86,0.06,1],"h":1},{"t":69,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0,0.86,0.06,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0,0.86,0.06,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0,0.86,0.06,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0,0.86,0.06,1],"h":1},{"t":114,"s":[0.89,0.89,0.89,1],"h":1},{"t":121,"s":[0,0.86,0.06,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":65,"op":214,"st":34},{"ind":11,"ty":4,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[100]},{"t":124,"s":[0]}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[126]},{"t":124,"s":[349]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":0.68},"o":{"x":0,"y":0},"t":68,"s":[274,232.25,0],"to":[14,-95.25,0],"ti":[-3.5,70.25,0]},{"t":124,"s":[416.5,52.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":116,"s":[100,100,100]},{"t":124,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[20,20]},{"t":124,"s":[14,14]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":72,"s":[1,0.92,0,1],"h":1},{"t":75,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[1,0.92,0,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":89,"s":[1,0.92,0,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":99,"s":[1,0.92,0,1],"h":1},{"t":106,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[1,0.92,0,1],"h":1},{"t":120,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":217,"st":37},{"ind":12,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[54]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":76,"s":[-18]},{"t":126,"s":[18]}]},"p":{"a":1,"k":[{"i":{"x":0.33,"y":0.63},"o":{"x":0.02,"y":0},"t":63,"s":[199,223.25,0],"to":[-1.41,-14.49,0],"ti":[30.23,18.1,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.19,"y":0.18},"t":69,"s":[164.23,151.1,0],"to":[-30.23,-18.1,0],"ti":[-19.69,-81.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83.75,"s":[108.31,217.87,0],"to":[19.69,81.13,0],"ti":[-18.51,-96.26,0]},{"t":126,"s":[36.5,421.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":67,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":105,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[-100,100,100]},{"t":126,"s":[-100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":63,"s":[22,22]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[32,32]},{"t":126,"s":[26,9.75]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[1,0,0.86,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":75,"s":[1,0,0.86,1],"h":1},{"t":80,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0,0.86,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":101,"s":[1,0,0.86,1],"h":1},{"t":110,"s":[0.89,0.89,0.89,1],"h":1},{"t":120,"s":[1,0,0.86,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":63,"op":212,"st":32},{"ind":13,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[50]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[-6]},{"t":103,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[250.5,249,0],"to":[-2.6,-22.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":69,"s":[190.72,157.91,0],"to":[-69.01,-1.59,0],"ti":[-1.05,-109.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[121.5,435.5,0],"to":[-0.24,-0.22,0],"ti":[-0.07,-2.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[121.37,423.98,0],"to":[0.05,2.14,0],"ti":[0,0,0]},{"t":99,"s":[121.5,438,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.66,-1.01],[13.1,-1.01],[13.38,-1.12],[13.5,-1.39],[13.5,-4.6],[13.38,-4.88],[13.1,-5],[6.88,-5],[1.98,-16.02],[1.79,-16.25],[1.5,-16.33],[-2.5,-16.33],[-2.79,-16.25],[-2.98,-16.02],[-7.88,-5.01],[-14.1,-5.01],[-14.26,-4.98],[-14.39,-4.89],[-14.47,-4.75],[-14.5,-4.6],[-14.5,-1.39],[-14.38,-1.11],[-14.1,-0.99],[-9.66,-0.99],[-14.46,9.79],[-14.49,9.98],[-14.42,10.16],[-14.28,10.29],[-14.1,10.34],[-10.53,10.34],[-10.24,10.26],[-10.04,10.03],[-0.5,-11.41],[9.04,10.03],[9.24,10.26],[9.53,10.34],[13.1,10.34],[13.29,10.29],[13.44,10.16],[13.5,9.97],[13.47,9.78]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":99,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,2]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"t":99,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[115]},{"i":{"x":[0.83],"y":[0.79]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[459]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":112,"s":[353]},{"t":122,"s":[359]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[291.5,240,0],"to":[-2.6,-19.52,0],"ti":[-37.7,-0.28,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":71,"s":[329.72,135.91,0],"to":[54.99,0.41,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[386.5,440,0],"to":[-7,-25,0],"ti":[17,0.5,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[345.5,440,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[335.5,440,0],"to":[6.75,0.38,0],"ti":[0,0,0]},{"t":122,"s":[350.5,455,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[-100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[-322.84,-78.01],[-318.4,-78.01],[-318.12,-78.12],[-318,-78.39],[-318,-81.6],[-318.12,-81.88],[-318.4,-82],[-324.62,-82],[-329.52,-93.02],[-329.71,-93.25],[-330,-93.33],[-334,-93.33],[-334.29,-93.25],[-334.48,-93.02],[-339.38,-82.01],[-345.6,-82.01],[-345.76,-81.98],[-345.89,-81.89],[-345.97,-81.75],[-346,-81.6],[-346,-78.39],[-345.88,-78.11],[-345.6,-77.99],[-341.16,-77.99],[-345.96,-67.21],[-345.99,-67.02],[-345.92,-66.84],[-345.78,-66.71],[-345.6,-66.66],[-342.03,-66.66],[-341.74,-66.74],[-341.54,-66.97],[-332,-88.41],[-322.46,-66.97],[-322.26,-66.74],[-321.97,-66.66],[-318.4,-66.66],[-318.21,-66.71],[-318.06,-66.84],[-318,-67.03],[-318.03,-67.22]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":112,"s":[677.5,170.33],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":116,"s":[677.5,164.83],"to":[0,0],"ti":[0,0]},{"t":122,"s":[677.5,130.33]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":122,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":15,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-59]},{"i":{"x":[0.49],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":69,"s":[-30]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[12]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-14]},{"t":96,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[290.5,195,0],"to":[0.4,-33.52,0],"ti":[-40.98,-0.41,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[356.72,126.91,0],"to":[40.99,0.41,0],"ti":[0.95,-126.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[420.5,419,0],"to":[-0.41,-0.39,0],"ti":[-0.05,-33.51,0]},{"t":96,"s":[420.5,419,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[0,0,100]},{"t":65,"s":[90,90,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.66,-1.01],[13.1,-1.01],[13.38,-1.12],[13.5,-1.39],[13.5,-4.6],[13.38,-4.88],[13.1,-5],[6.88,-5],[1.98,-16.02],[1.79,-16.25],[1.5,-16.33],[-2.5,-16.33],[-2.79,-16.25],[-2.98,-16.02],[-7.88,-5.01],[-14.1,-5.01],[-14.26,-4.98],[-14.39,-4.89],[-14.47,-4.75],[-14.5,-4.6],[-14.5,-1.39],[-14.38,-1.11],[-14.1,-0.99],[-9.66,-0.99],[-14.46,9.79],[-14.49,9.98],[-14.42,10.16],[-14.28,10.29],[-14.1,10.34],[-10.53,10.34],[-10.24,10.26],[-10.04,10.03],[-0.5,-11.41],[9.04,10.03],[9.24,10.26],[9.53,10.34],[13.1,10.34],[13.29,10.29],[13.44,10.16],[13.5,9.97],[13.47,9.78]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,95.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,40]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,20]},{"t":97,"s":[100,40]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,3]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,42]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,26]},{"t":97,"s":[100,42]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[346.44,208.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[79,56.3,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":180,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":149,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":161,"s":[100,100]},{"t":180,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":149,"op":180,"st":92},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":141,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":166,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":129,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":141,"s":[100,100]},{"t":166,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":129,"op":166,"st":72},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,184.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":122,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":145,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":114,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":122,"s":[100,100]},{"t":145,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":114,"op":146,"st":57},{"ind":19,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":99,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":136,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":99,"op":137,"st":42},{"ind":20,"ty":4,"ks":{"o":{"a":1,"k":[{"t":45,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"r":{"k":0},"p":{"k":[284.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":82,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":45,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":57,"s":[100,100]},{"t":82,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":45,"op":83,"st":-12},{"ind":21,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":56,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":25,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":33,"s":[100,100]},{"t":56,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":25,"op":57,"st":-32},{"ind":22,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":12,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":37,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":0,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":12,"s":[100,100]},{"t":37,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":38,"st":-57},{"ind":23,"ty":4,"parent":27,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[257.66,216.8,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[257.66,195.11,0],"to":[0,0,0],"ti":[0,0,0]},{"t":74,"s":[257.66,196.8,0]}],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"t":66,"s":[100,50,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.13,-28.48],[-50.62,19.68],[-18.12,-3.73],[14.13,40.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"t":63,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":65,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.75,-16.98],[26.41,20.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-32.08,-18.25],[-14.49,-16.96],[-15.39,-18.55],[-52.77,-20.68]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":69,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-42.74,-31.07],[-14.97,-46.8],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"t":68,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.06,-20.19],[-18.55,-45.47],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[2.59,-1.44],[37.94,1.18],[-6.25,14.1],[-24.19,4.01]],"o":[[-19.07,-0.37],[-2.16,-0.07],[10.87,19.21],[-1.7,0.73]],"v":[[48.4,19.5],[-17.41,53.75],[-6.96,-10.32],[71.12,18.18]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[1.94,-1.08],[6.24,-1.19],[15.02,17.72],[-16.78,-17.45]],"o":[[-3.39,20.66],[-1.59,0.3],[8.15,14.41],[-1.28,0.54]],"v":[[47.38,-11.46],[33.39,27.91],[-7.43,15.2],[48.16,-10.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[{"i":[[1.3,-0.72],[4.59,9.5],[7.91,-2.82],[-5.41,1.07]],"o":[[-0.4,-8.63],[-0.47,-0.97],[10.84,16.61],[-0.85,0.36]],"v":[[54.18,-18.16],[38.44,-59.82],[16.53,-34.2],[42.57,-26.83]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":71,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":24,"ty":4,"parent":27,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":68,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[256.46,249.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[256.46,225.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[256.46,209.72,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[256.46,206.72,0]}],"l":2},"a":{"k":[256.46,249.72,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,36,100]},{"t":67,"s":[100,14,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[1.75,-10.16],[-45.92,7.16],[-17.13,19.11],[30,-1.05]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-56.04,12.3],[-19.24,23.32],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.1,11.25],[-18.36,20.12],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[0.71,-11.86],[-45.71,6.16],[-16.92,18.11],[34.21,-0.66]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":25,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.96,347.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":26,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[100],"h":1},{"t":81,"s":[0],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.66,-1.01],[13.1,-1.01],[13.38,-1.12],[13.5,-1.39],[13.5,-4.6],[13.38,-4.88],[13.1,-5],[6.88,-5],[1.98,-16.02],[1.79,-16.25],[1.5,-16.33],[-2.5,-16.33],[-2.79,-16.25],[-2.98,-16.02],[-7.88,-5.01],[-14.1,-5.01],[-14.26,-4.98],[-14.39,-4.89],[-14.47,-4.75],[-14.5,-4.6],[-14.5,-1.39],[-14.38,-1.11],[-14.1,-0.99],[-9.66,-0.99],[-14.46,9.79],[-14.49,9.98],[-14.42,10.16],[-14.28,10.29],[-14.1,10.34],[-10.53,10.34],[-10.24,10.26],[-10.04,10.03],[-0.5,-11.41],[9.04,10.03],[9.24,10.26],[9.53,10.34],[13.1,10.34],[13.29,10.29],[13.44,10.16],[13.5,9.97],[13.47,9.78]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":27,"ty":4,"parent":28,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":72,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":72,"s":[257.27,128.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1,-61.13],[131.24,-49.8],[-1.2,-40.13],[-132.88,-50.13]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"t":72,"s":[81,81]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":28,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":29,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[-198]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-174]},{"t":97,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[220.5,269,0],"to":[-2.6,-19.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[157.72,193.91,0],"to":[-69.01,-1.59,0],"ti":[2.95,-91.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[83.5,374,0],"to":[-0.41,-0.39,0],"ti":[-3.5,-7,0]},{"t":97,"s":[87.5,374,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[84,84,100]},{"t":88,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.66,-1.01],[13.1,-1.01],[13.38,-1.12],[13.5,-1.39],[13.5,-4.6],[13.38,-4.88],[13.1,-5],[6.88,-5],[1.98,-16.02],[1.79,-16.25],[1.5,-16.33],[-2.5,-16.33],[-2.79,-16.25],[-2.98,-16.02],[-7.88,-5.01],[-14.1,-5.01],[-14.26,-4.98],[-14.39,-4.89],[-14.47,-4.75],[-14.5,-4.6],[-14.5,-1.39],[-14.38,-1.11],[-14.1,-0.99],[-9.66,-0.99],[-14.46,9.79],[-14.49,9.98],[-14.42,10.16],[-14.28,10.29],[-14.1,10.34],[-10.53,10.34],[-10.24,10.26],[-10.04,10.03],[-0.5,-11.41],[9.04,10.03],[9.24,10.26],[9.53,10.34],[13.1,10.34],[13.29,10.29],[13.44,10.16],[13.5,9.97],[13.47,9.78]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346.5,89.83]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[100,24]},{"t":97,"s":[100,12]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[0,-3],"to":[0,0],"ti":[0,0]},{"t":97,"s":[0,-2.5]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[91,28]},{"t":97,"s":[91,22]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":30,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[-180]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[-207]},{"t":93,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[296.5,278,0],"to":[2.4,-15.52,0],"ti":[-54.47,3.14,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[348.72,155.91,0],"to":[56.66,-3.26,0],"ti":[-0.05,-138.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[428.5,358,0],"to":[-4.91,-22.89,0],"ti":[0.5,-14.75,0]},{"t":93,"s":[402.5,376,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[100,100,100]},{"t":85,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[9.18,1.48],[13.61,1.48],[13.89,1.37],[14.01,1.09],[14.01,-2.11],[13.9,-2.39],[13.61,-2.51],[7.4,-2.51],[2.5,-13.53],[2.3,-13.76],[2.01,-13.85],[-1.99,-13.85],[-2.27,-13.76],[-2.47,-13.53],[-7.37,-2.52],[-13.58,-2.52],[-13.74,-2.49],[-13.87,-2.4],[-13.96,-2.27],[-13.98,-2.11],[-13.98,1.09],[-13.87,1.37],[-13.58,1.49],[-9.15,1.49],[-13.95,12.28],[-13.97,12.47],[-13.91,12.65],[-13.77,12.78],[-13.58,12.83],[-10.01,12.83],[-9.72,12.74],[-9.53,12.51],[0.01,-8.93],[9.56,12.51],[9.75,12.74],[10.04,12.83],[13.61,12.83],[13.81,12.78],[13.95,12.65],[14.01,12.46],[13.98,12.27]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[345.49,90.85]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,-100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[100,0]},{"t":93,"s":[100,-24]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":91,"s":[0,-4]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[91,4]},{"t":93,"s":[100,31]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":31,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.48,312.85,0],"l":2},"a":{"k":[266.48,312.85,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[369.96,1.85],[374.4,1.85],[374.68,1.73],[374.8,1.46],[374.8,-1.74],[374.68,-2.02],[374.4,-2.14],[368.18,-2.14],[363.28,-13.16],[363.09,-13.39],[362.8,-13.48],[358.8,-13.48],[358.51,-13.39],[358.32,-13.16],[353.42,-2.15],[347.2,-2.15],[347.04,-2.12],[346.91,-2.03],[346.83,-1.9],[346.8,-1.74],[346.8,1.46],[346.92,1.74],[347.2,1.86],[351.64,1.86],[346.84,12.65],[346.81,12.84],[346.88,13.02],[347.02,13.15],[347.2,13.2],[350.77,13.2],[351.06,13.11],[351.26,12.88],[360.8,-8.56],[370.34,12.88],[370.54,13.11],[370.83,13.2],[374.4,13.2],[374.59,13.15],[374.74,13.02],[374.8,12.83],[374.77,12.63]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-196.4,89.4]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[231.5,294],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[231.5,283],"to":[0,0],"ti":[0,0]},{"t":74,"s":[231.5,294]}]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":96},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[370.65,5.17],[375.08,5.17],[375.36,5.06],[375.48,4.79],[375.48,1.58],[375.37,1.3],[375.08,1.18],[368.87,1.18],[363.97,-9.84],[363.77,-10.07],[363.48,-10.15],[359.48,-10.15],[359.19,-10.07],[359,-9.84],[354.1,1.17],[347.88,1.17],[347.73,1.2],[347.6,1.29],[347.51,1.43],[347.48,1.58],[347.48,4.79],[347.6,5.07],[347.88,5.19],[352.32,5.19],[347.52,15.97],[347.49,16.16],[347.56,16.34],[347.7,16.47],[347.88,16.52],[351.45,16.52],[351.75,16.44],[351.94,16.21],[361.48,-5.23],[371.03,16.21],[371.22,16.44],[371.51,16.52],[375.08,16.52],[375.27,16.47],[375.42,16.34],[375.48,16.15],[375.45,15.96]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-194.88,83.13]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[189,270.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[189,263.5],"to":[0,0],"ti":[0,0]},{"t":74,"s":[189,270.5]}]},"a":{"k":[166,92]},"s":{"k":[103.56,52.65]},"r":{"k":-3.75},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[368.89,-4.53],[373.32,-4.53],[373.6,-4.64],[373.72,-4.92],[373.72,-8.12],[373.61,-8.4],[373.32,-8.52],[367.11,-8.52],[362.21,-19.54],[362.01,-19.77],[361.72,-19.86],[357.72,-19.86],[357.43,-19.77],[357.24,-19.54],[352.34,-8.53],[346.12,-8.53],[345.97,-8.5],[345.84,-8.41],[345.75,-8.28],[345.72,-8.12],[345.72,-4.92],[345.84,-4.64],[346.12,-4.52],[350.56,-4.52],[345.76,6.27],[345.73,6.46],[345.8,6.64],[345.94,6.77],[346.12,6.82],[349.69,6.82],[349.99,6.73],[350.18,6.5],[359.72,-14.94],[369.27,6.5],[369.46,6.73],[369.75,6.82],[373.32,6.82],[373.51,6.77],[373.66,6.64],[373.72,6.45],[373.69,6.26]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-194.75,97.72]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,10]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[329.5,274],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[329.5,262],"to":[0,0],"ti":[0,0]},{"t":75,"s":[329.5,274]}]},"a":{"k":[166,92]},"s":{"k":[97,43.65]},"r":{"k":14},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[188.86,0.77],[193.3,0.77],[193.57,0.66],[193.7,0.38],[193.7,-2.82],[193.58,-3.1],[193.3,-3.22],[187.08,-3.22],[182.18,-14.24],[181.98,-14.47],[181.7,-14.55],[177.7,-14.55],[177.41,-14.47],[177.21,-14.24],[172.31,-3.23],[166.1,-3.23],[165.94,-3.2],[165.81,-3.11],[165.72,-2.97],[165.7,-2.82],[165.7,0.38],[165.81,0.67],[166.1,0.78],[170.53,0.78],[165.73,11.57],[165.71,11.76],[165.77,11.94],[165.91,12.07],[166.1,12.12],[169.67,12.12],[169.96,12.04],[170.15,11.81],[179.7,-9.63],[189.24,11.81],[189.43,12.04],[189.73,12.12],[193.3,12.12],[193.49,12.07],[193.63,11.94],[193.69,11.75],[193.66,11.56]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-12.7,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[238.5,264.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[238.5,254.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[238.5,264.5]}]},"a":{"k":[166,92]},"s":{"k":[100,39]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[9.61,0.94],[14.04,0.94],[14.32,0.83],[14.44,0.55],[14.44,-2.65],[14.33,-2.93],[14.04,-3.05],[7.83,-3.05],[2.93,-14.07],[2.73,-14.3],[2.44,-14.39],[-1.56,-14.39],[-1.85,-14.3],[-2.04,-14.07],[-6.94,-3.06],[-13.16,-3.06],[-13.31,-3.03],[-13.44,-2.94],[-13.53,-2.81],[-13.56,-2.65],[-13.56,0.55],[-13.44,0.83],[-13.16,0.95],[-8.72,0.95],[-13.52,11.74],[-13.55,11.93],[-13.48,12.11],[-13.34,12.24],[-13.16,12.29],[-9.59,12.29],[-9.29,12.2],[-9.1,11.97],[0.44,-9.47],[9.99,11.97],[10.18,12.2],[10.47,12.29],[14.04,12.29],[14.23,12.24],[14.38,12.11],[14.44,11.92],[14.41,11.73]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,12]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[285.5,268.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[285.5,258.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[285.5,268.5]}]},"a":{"k":[166,92]},"s":{"k":[100,32]},"r":{"k":-8},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[10.4,2.68],[14.84,2.68],[15.11,2.57],[15.24,2.3],[15.24,-0.9],[15.12,-1.19],[14.84,-1.3],[8.62,-1.3],[3.72,-12.32],[3.52,-12.55],[3.24,-12.64],[-0.76,-12.64],[-1.05,-12.56],[-1.25,-12.32],[-6.15,-1.32],[-12.36,-1.32],[-12.52,-1.29],[-12.65,-1.2],[-12.74,-1.06],[-12.76,-0.9],[-12.76,2.3],[-12.65,2.58],[-12.36,2.7],[-7.93,2.7],[-12.73,13.48],[-12.75,13.67],[-12.69,13.85],[-12.55,13.98],[-12.36,14.03],[-8.79,14.03],[-8.5,13.95],[-8.31,13.72],[1.24,-7.72],[10.78,13.72],[10.97,13.95],[11.27,14.03],[14.84,14.03],[15.03,13.99],[15.17,13.85],[15.23,13.67],[15.2,13.47]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,7]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[271.5,254],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[271.5,245],"to":[0,0],"ti":[0,0]},{"t":71,"s":[271.5,254]}]},"a":{"k":[166,92]},"s":{"k":[77,28.36]},"r":{"k":3.04},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.8,1.8],[13.24,1.8],[13.52,1.69],[13.64,1.42],[13.64,-1.78],[13.52,-2.07],[13.24,-2.18],[7.02,-2.18],[2.12,-13.2],[1.93,-13.43],[1.64,-13.52],[-2.36,-13.52],[-2.65,-13.44],[-2.85,-13.2],[-7.74,-2.2],[-13.96,-2.2],[-14.12,-2.17],[-14.25,-2.08],[-14.33,-1.94],[-14.36,-1.78],[-14.36,1.42],[-14.24,1.7],[-13.96,1.82],[-9.52,1.82],[-14.32,12.6],[-14.35,12.79],[-14.29,12.97],[-14.14,13.1],[-13.96,13.15],[-10.39,13.15],[-10.1,13.07],[-9.9,12.84],[-0.36,-8.6],[9.18,12.84],[9.38,13.07],[9.67,13.15],[13.24,13.15],[13.43,13.11],[13.58,12.97],[13.64,12.79],[13.61,12.59]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[284.5,298]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":100},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":32,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":33,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[0],"h":1},{"t":81,"s":[100],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-0.07,0.07],[0,0.1],[0,0],[0.08,0.08],[0.11,0],[0,0],[0,0],[0.09,0.06],[0.1,0],[0,0],[0.09,-0.06],[0.04,-0.09],[0,0],[0,0],[0.05,-0.02],[0.04,-0.04],[0.02,-0.05],[0,-0.05],[0,0],[-0.07,-0.07],[-0.11,0],[0,0],[0,0],[-0.01,-0.06],[-0.04,-0.05],[-0.06,-0.03],[-0.06,0],[0,0],[-0.09,0.06],[-0.04,0.09],[0,0],[0,0],[-0.09,-0.06],[-0.1,0],[0,0],[-0.06,0.03],[-0.04,0.06],[0,0.07],[0.03,0.06]],"o":[[0,0],[0.1,0],[0.07,-0.07],[0,0],[0,-0.11],[-0.07,-0.07],[0,0],[0,0],[-0.04,-0.09],[-0.08,-0.06],[0,0],[-0.1,0],[-0.09,0.06],[0,0],[0,0],[-0.05,0],[-0.05,0.02],[-0.04,0.04],[-0.02,0.05],[0,0],[0,0.11],[0.08,0.08],[0,0],[0,0],[-0.02,0.06],[0.01,0.06],[0.04,0.05],[0.06,0.03],[0,0],[0.1,0],[0.09,-0.06],[0,0],[0,0],[0.04,0.09],[0.09,0.06],[0,0],[0.07,0],[0.06,-0.03],[0.04,-0.06],[0.01,-0.07],[0,0]],"v":[[8.66,-1.01],[13.1,-1.01],[13.38,-1.12],[13.5,-1.39],[13.5,-4.6],[13.38,-4.88],[13.1,-5],[6.88,-5],[1.98,-16.02],[1.79,-16.25],[1.5,-16.33],[-2.5,-16.33],[-2.79,-16.25],[-2.98,-16.02],[-7.88,-5.01],[-14.1,-5.01],[-14.26,-4.98],[-14.39,-4.89],[-14.47,-4.75],[-14.5,-4.6],[-14.5,-1.39],[-14.38,-1.11],[-14.1,-0.99],[-9.66,-0.99],[-14.46,9.79],[-14.49,9.98],[-14.42,10.16],[-14.28,10.29],[-14.1,10.34],[-10.53,10.34],[-10.24,10.26],[-10.04,10.03],[-0.5,-11.41],[9.04,10.03],[9.24,10.26],[9.53,10.34],[13.1,10.34],[13.29,10.29],[13.44,10.16],[13.5,9.97],[13.47,9.78]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.917647063732,0.780392169952,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":34,"ty":4,"parent":28,"ks":{"o":{"a":1,"k":[{"t":56,"s":[0],"h":1},{"t":72,"s":[100],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.34,"y":0},"t":72,"s":[257.27,128.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":91,"s":[257.27,342.53,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.48,-54.5],[131.76,-43.17],[-0.67,-33.5],[-132.36,-43.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.77,33.72],[131.48,-1.28],[-0.92,-32.28],[-132.64,-1.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.76,5.8],[131.57,4.14],[-0.9,5.8],[-132.55,3.8]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1.15,-23.5],[131.16,-19.17],[-1.28,-10.75],[-132.97,-19.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.05,-64.7],[132.03,-18.36],[-0.09,37.3],[-132.09,-18.7]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,-99.54],[132.16,0.65],[-0.09,95.58],[-131.96,0.98]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1},{"t":79,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[64.68,-94.47],[-66.93,-61.2],[-66.95,-36.54],[64.92,-50.71]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.53,-53.52],[-66.43,-54.53],[-66.43,-31.2],[65.4,-28.87]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-53.02],[-66.03,-54.03],[-66.03,-30.7],[65.97,26.63]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,61.22],[-65.96,-33.37],[-66.01,-34.68],[66.04,60.58]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65,-36.37],[-67.1,-51.21],[-67.19,-94.97],[65.09,-60.63]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.52,-31.03],[-66.61,-29.37],[-66.33,-54.02],[65.59,-53.95]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.92,-30.53],[-66.04,26.13],[-65.93,-53.52],[65.99,-53.45]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-35.85],[-65.98,60.07],[-65.91,60.71],[66.05,-34.94]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":72,"s":[81,81]},{"t":83,"s":[73,73]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0}]},{"id":"comp_1","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":181,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":144,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":156,"s":[100,100]},{"t":181,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":144,"op":181,"st":87},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[336.44,166.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":136,"s":[100,100]},{"t":161,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":124,"op":162,"st":67},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[190.44,214.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":117,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":109,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":117,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":109,"op":141,"st":52},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":103,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":114,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[100,100]},{"t":114,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":95,"op":115,"st":38},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":100,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":81,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":89,"s":[100,100]},{"t":100,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":81,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":100,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":81,"op":101,"st":24},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":90,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":73,"s":[100,100]},{"t":90,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":90,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":65,"op":91,"st":8},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,221.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":69,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":50,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":58,"s":[100,100]},{"t":69,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":50,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":69,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":50,"op":70,"st":-7},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[323.44,178.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":49,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":60,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":41,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":49,"s":[100,100]},{"t":60,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":41,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":60,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":41,"op":61,"st":-16},{"ind":9,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257.66,216.8,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-43.5],[-50.62,19.68],[-19.83,-18.75],[12.42,25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.08,-16.07],[17.57,-27.27],[-8.47,2.39],[30.67,-18.15]],"o":[[-15.8,5.37],[10.67,-14.43],[36.88,-10.55],[63.53,-17.02]],"v":[[1.03,-25.49],[-50.62,19.68],[-18.22,-2.26],[14.03,39.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"t":83,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-32],[24.7,5.25],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.13],[15.01,-7.06],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,17.55],[-23.68,11.14],[4.47,0.56]],"v":[[-34.14,-8.48],[52.65,-15.65],[26.31,20],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"t":83,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-25.54,-39.48],[3.89,-52.46],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-14.47],[-8.77,1.66],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.23],[26.18,-4.96],[-7.91,6.92]],"v":[[28.2,44.02],[-47.21,-19.87],[-19.44,-35.04],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"t":83,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-32.04,-23.56],[-1.35,-53.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,23.79],[-20,4.13],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.37],[-16,22.94],[-7,3.5]],"v":[[44.51,48.89],[-46.54,-9.48],[-23.02,-33.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"t":83,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":10,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.76,93.8],[14.58,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.58,93.8],[14.76,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":11,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[257.27,248.75,0],"to":[0,14.83,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[257.27,218.64,0],"to":[0,0,0],"ti":[0,-8.61,0]},{"t":34,"s":[257.27,248.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":35},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":12,"ty":4,"parent":14,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-28.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":13,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":32,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[-1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":66,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[-1.5]},{"t":81,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.17,"y":0.17},"t":0,"s":[257,739,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.13},"t":20,"s":[257,379,0],"to":[0,0,0],"ti":[0,0,0]},{"t":32,"s":[257,405,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":9,"s":[97,105,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":36,"s":[104,96,100]},{"t":43,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":0,"cl":"Present3","refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":180,"op":360,"st":180},{"ind":2,"ty":0,"cl":"Present2","refId":"comp_1","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/default_packing.json b/common/src/main/res/raw/default_packing.json new file mode 100644 index 0000000..5331b33 --- /dev/null +++ b/common/src/main/res/raw/default_packing.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":300,"w":512,"h":512,"assets":[{"id":"comp_0","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[12.6]},{"t":102,"s":[96]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":82,"s":[116,96,0],"to":[86,26,0],"ti":[-1.25,-87.67,0]},{"t":102,"s":[290.5,297,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[100,84,100]},{"t":97,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[-547.75,-146.75],[-547.75,-146.27],[-560.1,-143.53],[-562.52,-141.13],[-565.27,-128.75],[-566.22,-128.75],[-568.98,-141.13],[-571.4,-143.53],[-583.75,-146.27],[-583.75,-147.22],[-571.4,-149.97],[-568.98,-152.37],[-566.22,-164.75],[-565.27,-164.75],[-562.52,-152.37],[-560.1,-149.97],[-547.75,-147.22]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-2,0]},"a":{"k":[-823,-239]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":147,"s":[5]},{"t":153,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":82,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":92,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":82,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[110,134]},{"t":92,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":101,"s":[28]},{"t":121,"s":[-3.75]}]},"p":{"a":1,"k":[{"i":{"x":0.71,"y":0.56},"o":{"x":0.31,"y":0},"t":101,"s":[104,122,0],"to":[38.75,5.02,0],"ti":[-29.47,-47.19,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.43,"y":0.45},"t":116,"s":[217.28,203.1,0],"to":[11.6,18.58,0],"ti":[-5.44,-23.91,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[243.5,267,0],"to":[0.06,-3.71,0],"ti":[0,-3.54,0]},{"t":127,"s":[243.5,267,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[85,85,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":121,"s":[95.56,94.94,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":124,"s":[102.56,40.29,100]},{"t":127,"s":[103.56,52.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.25,-0.25],[17.25,0.23],[4.9,2.97],[2.48,5.37],[-0.28,17.75],[-1.23,17.75],[-3.98,5.37],[-6.4,2.97],[-18.75,0.23],[-18.75,-0.72],[-6.4,-3.47],[-3.98,-5.87],[-1.23,-18.25],[-0.28,-18.25],[2.48,-5.87],[4.9,-3.47],[17.25,-0.72]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92.5]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":147,"s":[5]},{"t":153,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[0,0],"to":[0,-0.75],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":124,"s":[0,11],"to":[0,0],"ti":[0,0]},{"t":127,"s":[0,7]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":115,"s":[109.64,109.64]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":121,"s":[120,120]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[102.4,116]},{"t":127,"s":[102.4,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[31.25]},{"t":120,"s":[14]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":96,"s":[367,78,0],"to":[-18,29,0],"ti":[6.75,-76.67,0]},{"t":116,"s":[325.5,270,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[85,85,100]},{"t":116,"s":[97,43.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.25,0.25],[17.25,0.73],[4.9,3.47],[2.48,5.87],[-0.28,18.25],[-1.23,18.25],[-3.98,5.87],[-6.4,3.47],[-18.75,0.73],[-18.75,-0.22],[-6.4,-2.97],[-3.98,-5.37],[-1.23,-17.75],[-0.28,-17.75],[2.48,-5.37],[4.9,-2.97],[17.25,-0.22]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":147,"s":[5]},{"t":153,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":116,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":106,"s":[116,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[102.4,116]},{"t":125,"s":[114.47,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[81.55]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":112,"s":[70.2]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[-10]},{"t":119,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":92,"s":[125,74,0],"to":[34,35,0],"ti":[-5.25,-79.67,0]},{"t":112,"s":[210.5,282,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[42.6,26.6,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[71,71,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":112,"s":[100,34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,54,100]},{"t":119,"s":[100,39,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.25,0.25],[17.25,0.73],[4.9,3.47],[2.48,5.87],[-0.28,18.25],[-1.23,18.25],[-3.98,5.87],[-6.4,3.47],[-18.75,0.73],[-18.75,-0.22],[-6.4,-2.97],[-3.98,-5.37],[-1.23,-17.75],[-0.28,-17.75],[2.48,-5.37],[4.9,-2.97],[17.25,-0.22]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":147,"s":[5]},{"t":153,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[0,9],"to":[0,-0.15],"ti":[0,2.44]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,-8.41],"to":[0,-2.01],"ti":[0,-0.17]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":97,"s":[0,0],"to":[0,0.62],"ti":[0,0]},{"t":112,"s":[0,12]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":92,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[104.15,129.8]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":97,"s":[104.25,104]},{"t":112,"s":[105,130]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[-23]},{"t":101,"s":[-8]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":75,"s":[433,92,0],"to":[-77,4,0],"ti":[-1.25,-87.67,0]},{"t":97,"s":[238.5,296,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,100,100]},{"t":93,"s":[100,32,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.25,0.25],[17.25,0.73],[4.9,3.47],[2.48,5.87],[-0.28,18.25],[-1.23,18.25],[-3.98,5.87],[-6.4,3.47],[-18.75,0.73],[-18.75,-0.22],[-6.4,-2.97],[-3.98,-5.37],[-1.23,-17.75],[-0.28,-17.75],[2.48,-5.37],[4.9,-2.97],[17.25,-0.22]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":147,"s":[5]},{"t":153,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":93,"s":[0,19]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[116,116]},{"t":93,"s":[105,125]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":100,"s":[-22.75]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":105,"s":[-22]},{"t":109,"s":[-9]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":85,"s":[367,78,0],"to":[-34,23,0],"ti":[6.75,-76.67,0]},{"t":105,"s":[264.5,279,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[77,77,100]},{"t":105,"s":[77,34.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.25,0.25],[17.25,0.73],[4.9,3.47],[2.48,5.87],[-0.28,18.25],[-1.23,18.25],[-3.98,5.87],[-6.4,3.47],[-18.75,0.73],[-18.75,-0.22],[-6.4,-2.97],[-3.98,-5.37],[-1.23,-17.75],[-0.28,-17.75],[2.48,-5.37],[4.9,-2.97],[17.25,-0.22]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":147,"s":[5]},{"t":153,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":105,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":85,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[116,116]},{"t":105,"s":[109,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[12.6]},{"t":90,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":70,"s":[427,116,0],"to":[-67,4,0],"ti":[-1.25,-87.67,0]},{"t":90,"s":[264.5,338,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[71,38.34,100]},{"t":80,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[14.75,0.25],[14.75,0.73],[2.4,3.47],[-0.02,5.87],[-2.77,18.25],[-3.72,18.25],[-6.48,5.87],[-8.9,3.47],[-21.25,0.73],[-21.25,-0.22],[-8.9,-2.97],[-6.48,-5.37],[-3.72,-17.75],[-2.77,-17.75],[-0.02,-5.37],[2.4,-2.97],[14.75,-0.22]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[258.5,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":125,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":147,"s":[5]},{"t":153,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[12.6]},{"t":80,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":60,"s":[75,92,0],"to":[105,30,0],"ti":[-1.25,-87.67,0]},{"t":80,"s":[242.5,340,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[100,84,100]},{"t":75,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.25,0.25],[17.25,0.73],[4.9,3.47],[2.48,5.87],[-0.28,18.25],[-1.23,18.25],[-3.98,5.87],[-6.4,3.47],[-18.75,0.73],[-18.75,-0.22],[-6.4,-2.97],[-3.98,-5.37],[-1.23,-17.75],[-0.28,-17.75],[2.48,-5.37],[4.9,-2.97],[17.25,-0.22]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":275,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":300,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":263,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":275,"s":[100,100]},{"t":300,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":263,"op":300,"st":206},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[344.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":255,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":280,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":243,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":255,"s":[100,100]},{"t":280,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":243,"op":281,"st":186},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":236,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":259,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":228,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":236,"s":[100,100]},{"t":259,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":228,"op":260,"st":171},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":222,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":233,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":214,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":222,"s":[100,100]},{"t":233,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":214,"op":234,"st":157},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":208,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":219,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":200,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":208,"s":[100,100]},{"t":219,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":200,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":219,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":200,"op":220,"st":143},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":76,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":51,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":59,"s":[100,100]},{"t":76,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":51,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":76,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":51,"op":77,"st":-6},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[200.44,241.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":55,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":36,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":44,"s":[100,100]},{"t":55,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":36,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":55,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":36,"op":56,"st":-21},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[343.44,238.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":35,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":46,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":27,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":35,"s":[100,100]},{"t":46,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":27,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":46,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":27,"op":47,"st":-30},{"ind":9,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50.66,-184.2,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[11,2],[0,0],[-2.75,-7.75],[20.5,-7]],"o":[[-10,8.5],[0,0],[1.12,3.14],[40,-13]],"v":[[42.08,-17.98],[22.38,-6.82],[31.83,10.77],[14.08,31.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":171,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":172,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[46.06,-12.83],[18.2,-29.66],[-8.71,1.58],[30.67,-18.97]],"o":[[-16.15,4.5],[10.3,-11.21],[35.76,-6.47],[63.53,-17.79]],"v":[[0.89,-20.37],[-50.62,19.68],[-18.36,2.16],[13.89,45.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-36.51],[-50.62,19.68],[-19.83,-11.75],[12.42,32]],"c":true}]},{"t":209,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":170,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-29.64,-5.98],[-0.31,4.52],[-26.64,18.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":169,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-11.09],[26.17,26.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-25],[24.7,12.25],[-56.19,5.91]],"c":true}]},{"t":209,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.06,-13],[-19.29,-28.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-35.04,-33.48],[-5.61,-46.46],[47.19,27.25]],"c":true}]},{"t":209,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":168,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":163,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":166,"s":[{"i":[[2.59,-1.44],[13.98,27.91],[-8.75,-2.33],[-23.7,-7.47]],"o":[[-20.01,-7.24],[-0.97,-1.93],[2.46,22.32],[-1.7,0.73]],"v":[[44,48.14],[-36.97,-2.51],[-7.45,-15.43],[72.59,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.38,-2.11],[-22.87,-27.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-41.54,-17.57],[-10.85,-47.87],[71.01,35.39]],"c":true}]},{"t":209,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":10,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[49.33,-79.33,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":154,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[16.25,-17.11],[15.33,-34.84],[44.12,-24.39],[45.75,-6.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":159,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.25,-17.11],[15.83,-16.84],[46.12,-6.39],[45.75,-6.61]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":154,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":158,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":160,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":144,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":142,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":151,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":154,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":141,"s":[0],"h":1},{"t":142,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":157,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.21,-18.11],[15.79,-17.84],[46.08,-7.39],[45.71,-7.61]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":153,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":11,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":120,"s":[50.27,-201.25,0],"to":[0,6.23,0],"ti":[0,-15.32,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":134,"s":[50.27,-284.64,0],"to":[0,21.19,0],"ti":[0,-8.61,0]},{"t":146,"s":[50.27,-152.25,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":120,"s":[0,0,100]},{"t":144,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.4,91.93],[131.21,19.33],[0.36,-48.29],[-131.32,19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":130,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-52.85,28.76],[95.73,14.81],[62.88,1.73],[-97.23,18.68]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[83.8,18.9],[83.9,18.9],[83.72,18.4],[-85.86,18.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":132,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[73.69,-14.15],[90.29,-13.04],[73.61,-14.65],[-91.27,-13.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.04,-13.5],[132.06,-4.67],[-0.12,-14],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-85.27,-14.13],[83.47,-13.5],[83.29,-15.26],[-86.29,-13.83]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.28,0.28,0.87,1],"h":1},{"t":131,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[66.17,-103.68],[-66.06,-33.47],[-66,-17],[66.77,-83.74]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.9,-48.82],[-66,-40.33],[-66,-17],[66,-15.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":131,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":134,"s":[0.4,0.42,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":135,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[0.4,0.42,1,1]},{"t":146,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-65.25,-84.24],[-65.69,-104.18],[65.97,-33.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,-15.67],[-65.97,-49.33],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.4,0.42,1,1],"h":1},{"t":132,"s":[0.47,0.58,0.99,1],"h":1},{"t":135,"s":[0.4,0.42,1,1],"h":1},{"t":140,"s":[0.47,0.58,0.99,1],"h":1},{"t":146,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":132,"s":[0],"h":1},{"t":146,"s":[35],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"t":119,"s":[100,100],"h":1},{"t":123,"s":[-100,100],"h":1},{"t":127,"s":[100,100],"h":1},{"t":131,"s":[-100,100],"h":1},{"t":135,"s":[100,100],"h":1},{"t":140,"s":[-100,100],"h":1},{"t":146,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":12,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.85},"o":{"x":0.17,"y":0.17},"t":0,"s":[50,-98.75,0],"to":[0,1.68,0],"ti":[0,-4.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":13,"s":[50,-184.14,0],"to":[0,5.71,0],"ti":[0,-2.32,0]},{"t":26,"s":[50,-78.75,0]}],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":0,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":34,"s":[97,97,100]},{"t":50,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-62.26,55.67],[-62.73,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-61.96,76.67],[-61.81,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-57.72,99.67],[-57.69,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[-9.59,4.33],[14.9,-23.89],[0,0],[0,0]],"o":[[14.5,18.78],[-0.1,-0.56],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,33.56],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[-13.68,5.7],[-6.74,-51.36],[0,0],[0,0]],"o":[[-4.79,47.52],[0.48,-0.45],[0,0],[0,0]],"v":[[61.73,-99.67],[61.46,45.58],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":7,"s":[0.47,0.58,0.99,1],"h":1},{"t":10,"s":[0.4,0.42,1,1],"h":1},{"t":14,"s":[0.47,0.58,0.99,1],"h":1},{"t":17,"s":[0.4,0.42,1,1],"h":1},{"t":24,"s":[0.47,0.58,0.99,1],"h":1},{"t":31,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.4,55.67],[60.91,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.7,76.67],[61.84,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[65.94,99.67],[65.95,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[10.47,2.67],[-18.22,-33.33],[0,0],[0,0]],"o":[[-10.44,12.11],[-0.04,-0.56],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.88,33.56],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[10.32,2.06],[8.37,-56.82],[0,0],[0,0]],"o":[[5.88,46.61],[0.04,-0.45],[0,0],[0,0]],"v":[[-61.94,-99.67],[-62.21,45.58],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":7,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":9,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":10,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":13,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":16,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":17,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":23,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":30,"s":[0.42,0.47,1,1]},{"t":31,"s":[0.47,0.58,0.99,1]}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":13,"ty":0,"refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":103,"s":[209.44,138.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":131,"s":[209.44,289.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[71,71,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":103,"op":141,"st":46},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.76,"y":0},"t":94,"s":[317.44,178.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":120,"s":[317.44,338.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[102,102,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":102,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":133,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":94,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":102,"s":[100,100]},{"t":133,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":94,"op":134,"st":37},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":70,"s":[202.44,133.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":97,"s":[202.44,349.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[127,127,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":105,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":70,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":78,"s":[100,100]},{"t":105,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":106,"st":13},{"ind":19,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.01,-33.33],[123.51,-1.58],[-0.61,-33.67],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.93,-0.08],[123.51,-1.58],[0.31,-0.42],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.67,-51.08],[123.51,-1.58],[4.43,51.08],[-123.51,-1.25]],"c":true}]},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":151,"st":0},{"ind":21,"ty":3,"ks":{"o":{"k":0},"r":{"k":0},"p":{"k":[257,451,0],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":18,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":26,"s":[103,97,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[97,103,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":43,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":94,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":98,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":122,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":126,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":145,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":149,"s":[102,98,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":155,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.85,1.14,-0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":168,"s":[99,102,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,4.33]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":180,"s":[110,90,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.75,0.75,0.75],"y":[0,0,0]},"t":195,"s":[90,110,100]},{"t":208,"s":[100,100,100]}],"l":2}},"ip":0,"op":264,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/default_unpacking.json b/common/src/main/res/raw/default_unpacking.json new file mode 100644 index 0000000..bde4d5a --- /dev/null +++ b/common/src/main/res/raw/default_unpacking.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":360,"w":512,"h":512,"assets":[{"id":"comp_0","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[248.14,284.55,0],"l":2},"a":{"k":[14,-1,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-72,39],[-152,111]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.86,45],[3,185]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[61,31],[156,113]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"t":69,"s":[73]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[0]},{"t":67,"s":[73]}]},"o":{"k":0},"m":1}],"ip":60,"op":177,"st":-3},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":-208.79},"p":{"k":[90.94,257.49,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[73.08,73.08,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.59,-21.92],[27.98,-23.86],[-36.09,-3.9],[3.75,11.83],[-1.96,-59.37],[-44.64,5.65],[18.27,13.23]],"o":[[1.21,44.71],[-24.04,20.51],[7.95,0.86],[-8.39,-26.51],[1.32,39.94],[41.51,-5.26],[-13.06,-9.45]],"v":[[-134.97,-34.32],[-81.98,23.04],[-72.15,98.43],[-50.97,80.06],[-134.14,144.99],[-59.15,210.78],[-26.48,151.25]],"c":false}}},{"ty":"st","c":{"k":[0.949019667682,0.870865167356,0,1]},"o":{"k":100},"w":{"k":10},"lc":1,"lj":1,"ml":4},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"t":122,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[25.52]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[90]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":177,"st":-3},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":-322},"p":{"k":[191.66,150.67,0],"l":2},"a":{"k":[127.48,-143.62,0],"l":2},"s":{"k":[-190,190,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-4.76,4.16],[-5.58,-2.96],[0.86,-2.32],[1.98,1.6],[-0.17,2.54],[-4.87,2.42],[-4.55,-2.99],[0.82,-1.61],[1.23,1.05],[0.03,1.62],[-5.55,1.61],[-2.45,-2.91]],"o":[[1.22,-6.2],[4.76,-4.16],[2.19,1.16],[-0.89,2.39],[-1.98,-1.6],[0.36,-5.43],[4.88,-2.42],[1.51,1],[-0.73,1.44],[-1.23,-1.05],[-0.1,-5.78],[3.66,-1.06],[0,0]],"v":[[96.85,-121.52],[105.41,-138.05],[123,-140.94],[126.33,-135.01],[119.89,-133.82],[117.49,-140.76],[126.14,-153.74],[141.72,-152.8],[143.88,-148.35],[139.69,-147.87],[138.04,-152.28],[147.62,-165.26],[158.1,-162.64]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[10]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[38]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[63]},{"t":100,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[99]},{"t":100,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0,0.602906589882,0.949019607843,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[244.14,221.55,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[56,56,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[13.57,11.19],[-31.1,23.53],[44.5,-0.5],[0.21,-20],[-89,21]],"o":[[-12.84,-10.6],[37,-28],[-34.23,0.39],[-1,97],[35.17,-8.3]],"v":[[0.84,68.6],[14,13],[-11.5,-83.5],[-43,64],[-132,56]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[23]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[39]},{"t":90,"s":[49]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[40]},{"t":85,"s":[49]}]},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[171]},{"t":90,"s":[179]}]},"m":1},{"ty":"st","c":{"k":[0,0.949019667682,0.334948072246,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[8]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":74,"s":[18]},{"t":90,"s":[5]}]},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,20]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":61,"op":169,"st":-11},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":-370},"p":{"k":[336.32,194.77,0],"l":2},"a":{"k":[-147.98,-152.87,0],"l":2},"s":{"k":[-211,211,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[3.94,4.85],[5.94,-1.95],[-0.45,-2.41],[-2.2,1.23],[-0.26,2.51],[4.35,3.18],[4.93,-2.16],[-0.52,-1.71],[-1.37,0.82],[-0.3,1.57],[5.15,2.5],[2.88,-2.43]],"o":[[-0.16,-6.25],[-3.94,-4.85],[-2.33,0.77],[0.47,2.48],[2.2,-1.23],[0.56,-5.36],[-4.35,-3.18],[-1.64,0.72],[0.47,1.53],[1.37,-0.82],[1.06,-5.62],[-3.39,-1.64],[0,0]],"v":[[-121.55,-126.72],[-127.13,-144.27],[-143.8,-150.03],[-148.04,-144.81],[-141.97,-142.56],[-138.46,-148.94],[-144.73,-163.04],[-160.08,-164.73],[-162.93,-160.75],[-158.93,-159.58],[-156.58,-163.6],[-163.75,-177.86],[-174.41,-177.06]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[13]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[45]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[63]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[99]},{"t":97,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.964705882353,0,0.037585939146,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[169]},{"t":121,"s":[-67]}]},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.86},"o":{"x":0.05,"y":0.01},"t":68,"s":[201,236.25,0],"to":[-0.3,-1.62,0],"ti":[89.46,-52.28,0]},{"i":{"x":0.52,"y":0.9},"o":{"x":0.16,"y":0.13},"t":77,"s":[117.46,105.72,0],"to":[-89.46,52.28,0],"ti":[54.73,-96.02,0]},{"t":127,"s":[83.5,392.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":79,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":84,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":88,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":99,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[80,80,100]},{"t":120,"s":[75,-75,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,-2.72],[-1.69,-6.82],[5.28,-3.42],[-3.74,4.29]],"o":[[-1.96,5],[-8.39,-4.89],[-3.96,-11.6],[3,-5.59]],"v":[[15,-15],[15,15],[-15,15],[-15,-15]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":69,"s":[0,0.34,0.91,1],"h":1},{"t":72,"s":[0.89,0.89,0.89,1],"h":1},{"t":77,"s":[0,0.34,0.91,1],"h":1},{"t":82,"s":[0.89,0.89,0.89,1],"h":1},{"t":86,"s":[0,0.34,0.91,1],"h":1},{"t":91,"s":[0.89,0.89,0.89,1],"h":1},{"t":96,"s":[0,0.34,0.91,1],"h":1},{"t":103,"s":[0.89,0.89,0.89,1],"h":1},{"t":110,"s":[0,0.34,0.91,1],"h":1},{"t":117,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":180,"st":37},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-90]},{"t":109,"s":[301.64]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[198.75,244.5,0],"to":[-1.88,-52.9,0],"ti":[28.8,18.42,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[144.71,133.7,0],"to":[-28.64,-18.32,0],"ti":[23.57,-30.86,0]},{"t":109,"s":[58.5,148.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":68,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"t":109,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[29,30]},{"t":107,"s":[18,18.62]}]},"p":{"k":[0,0]},"r":{"k":211}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[0.96,0.81,0,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0.96,0.81,0,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0.96,0.81,0,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0.96,0.81,0,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0.96,0.81,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":4},"sa":{"k":0}}]}],"ip":65,"op":110,"st":34},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[-163]},{"t":120,"s":[-73.39]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[198.75,232.5,0],"to":[2.25,-107.5,0],"ti":[45.25,-88,0]},{"t":120,"s":[43.75,100,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":100,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":107,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,45,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":113,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[132,-64,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":119,"s":[130,-32,100]},{"t":120,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[22,22]},{"t":116,"s":[15,15]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":64,"s":[0.67,0,0.27,1],"h":1},{"t":67,"s":[0.89,0.89,0.89,1],"h":1},{"t":71,"s":[0.67,0,0.27,1],"h":1},{"t":76,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[0.67,0,0.27,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":90,"s":[0.67,0,0.27,1],"h":1},{"t":97,"s":[0.89,0.89,0.89,1],"h":1},{"t":104,"s":[0.67,0,0.27,1],"h":1},{"t":111,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[0.67,0,0.27,1],"h":1},{"t":118,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":64,"op":121,"st":-53},{"ind":9,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[-29]},{"t":131,"s":[306]}]},"p":{"a":1,"k":[{"i":{"x":0.43,"y":0.94},"o":{"x":0,"y":0},"t":70,"s":[315.75,212.5,0],"to":[84.25,-131.5,0],"ti":[9.5,-151.25,0]},{"t":133,"s":[444.5,412.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":71,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":108,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"t":123,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":133,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[7.5,-7.5],[7.5,7.5],[0.09,7.5],[-7.5,7.5],[-7.5,-7.5],[-1.31,-7.5]],"c":true}]},{"t":140,"s":[{"i":[[-1.04,1.29],[-0.3,-3.48],[1.31,-2.19],[0,0],[0,0],[-1.41,1.63]],"o":[[0.26,3.49],[0.29,3.4],[-1.99,3.33],[0,0],[0,0],[1.17,-1.35]],"v":[[-2.19,-14.3],[-0.07,-2.07],[-0.67,5.04],[-7.5,7.5],[-7.5,-7.5],[-4.66,-10.34]],"c":true}]}]}},{"ty":"fl","c":{"a":1,"k":[{"t":71,"s":[1,0.61,0,1],"h":1},{"t":74,"s":[0.89,0.89,0.89,1],"h":1},{"t":79,"s":[1,0.61,0,1],"h":1},{"t":84,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0.61,0,1],"h":1},{"t":93,"s":[0.89,0.89,0.89,1],"h":1},{"t":98,"s":[1,0.61,0,1],"h":1},{"t":105,"s":[0.89,0.89,0.89,1],"h":1},{"t":112,"s":[1,0.61,0,1],"h":1},{"t":119,"s":[0.89,0.89,0.89,1],"h":1},{"t":124,"s":[1,0.61,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":70,"op":219,"st":39},{"ind":10,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-29]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[186]},{"t":130,"s":[188]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[312.75,231.5,0],"to":[-0.58,-8.79,0],"ti":[-27.32,0.19,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[337,137.9,0],"to":[27.32,-0.19,0],"ti":[5.65,-84.54,0]},{"t":130,"s":[365.5,416.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":103,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,-100,100]},{"t":130,"s":[100,40,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"k":[22,22]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":66,"s":[0,0.86,0.06,1],"h":1},{"t":69,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0,0.86,0.06,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0,0.86,0.06,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0,0.86,0.06,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0,0.86,0.06,1],"h":1},{"t":114,"s":[0.89,0.89,0.89,1],"h":1},{"t":121,"s":[0,0.86,0.06,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":65,"op":214,"st":34},{"ind":11,"ty":4,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[100]},{"t":124,"s":[0]}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[126]},{"t":124,"s":[349]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":0.68},"o":{"x":0,"y":0},"t":68,"s":[274,232.25,0],"to":[14,-95.25,0],"ti":[-3.5,70.25,0]},{"t":124,"s":[416.5,52.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":116,"s":[100,100,100]},{"t":124,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[20,20]},{"t":124,"s":[14,14]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":72,"s":[1,0.92,0,1],"h":1},{"t":75,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[1,0.92,0,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":89,"s":[1,0.92,0,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":99,"s":[1,0.92,0,1],"h":1},{"t":106,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[1,0.92,0,1],"h":1},{"t":120,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":217,"st":37},{"ind":12,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[54]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":76,"s":[-18]},{"t":126,"s":[18]}]},"p":{"a":1,"k":[{"i":{"x":0.33,"y":0.63},"o":{"x":0.02,"y":0},"t":63,"s":[199,223.25,0],"to":[-1.41,-14.49,0],"ti":[30.23,18.1,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.19,"y":0.18},"t":69,"s":[164.23,151.1,0],"to":[-30.23,-18.1,0],"ti":[-19.69,-81.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83.75,"s":[108.31,217.87,0],"to":[19.69,81.13,0],"ti":[-18.51,-96.26,0]},{"t":126,"s":[36.5,421.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":67,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":105,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[-100,100,100]},{"t":126,"s":[-100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":63,"s":[22,22]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[32,32]},{"t":126,"s":[26,9.75]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[1,0,0.86,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":75,"s":[1,0,0.86,1],"h":1},{"t":80,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0,0.86,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":101,"s":[1,0,0.86,1],"h":1},{"t":110,"s":[0.89,0.89,0.89,1],"h":1},{"t":120,"s":[1,0,0.86,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":63,"op":212,"st":32},{"ind":13,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[50]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[-6]},{"t":103,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[250.5,249,0],"to":[-2.6,-22.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":69,"s":[190.72,157.91,0],"to":[-69.01,-1.59,0],"ti":[-1.05,-109.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[121.5,435.5,0],"to":[-0.24,-0.22,0],"ti":[-0.07,-2.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[121.37,423.98,0],"to":[0.05,2.14,0],"ti":[0,0,0]},{"t":99,"s":[121.5,438,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[-162.75,-5.08],[-162.75,-4.61],[-175.1,-1.86],[-177.52,0.53],[-180.27,12.92],[-181.22,12.92],[-183.98,0.53],[-186.4,-1.86],[-198.75,-4.61],[-198.75,-5.56],[-186.4,-8.3],[-183.98,-10.7],[-181.22,-23.08],[-180.27,-23.08],[-177.52,-10.7],[-175.1,-8.3],[-162.75,-5.56]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[-181,-1]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":99,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,2]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[115]},{"i":{"x":[0.83],"y":[0.79]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[459]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":112,"s":[353]},{"t":122,"s":[359]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[291.5,240,0],"to":[-2.6,-19.52,0],"ti":[-37.7,-0.28,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":71,"s":[329.72,135.91,0],"to":[54.99,0.41,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[386.5,440,0],"to":[-7,-25,0],"ti":[17,0.5,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[345.5,440,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[335.5,440,0],"to":[6.75,0.38,0],"ti":[0,0,0]},{"t":122,"s":[350.5,455,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[-100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[-313.25,-76.58],[-313.25,-76.11],[-325.6,-73.36],[-328.02,-70.97],[-330.77,-58.58],[-331.72,-58.58],[-334.48,-70.97],[-336.9,-73.36],[-349.25,-76.11],[-349.25,-77.06],[-336.9,-79.8],[-334.48,-82.2],[-331.72,-94.58],[-330.77,-94.58],[-328.02,-82.2],[-325.6,-79.8],[-313.25,-77.06]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":112,"s":[677.5,165.83],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":117,"s":[677.5,160.33],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":122,"s":[677.5,128.83],"to":[0,0],"ti":[0,0]},{"t":137,"s":[677.5,128.83]}]},"a":{"k":[0,-3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":122,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":15,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-59]},{"i":{"x":[0.49],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":69,"s":[-30]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[12]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-14]},{"t":96,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[290.5,195,0],"to":[0.4,-33.52,0],"ti":[-40.98,-0.41,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[356.72,126.91,0],"to":[40.99,0.41,0],"ti":[0.95,-126.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[420.5,419,0],"to":[-0.41,-0.39,0],"ti":[-0.05,-33.51,0]},{"t":96,"s":[420.5,419,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[0,0,100]},{"t":65,"s":[90,90,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[18.25,-6.08],[18.25,-5.61],[5.9,-2.86],[3.48,-0.47],[0.73,11.92],[-0.22,11.92],[-2.98,-0.47],[-5.4,-2.86],[-17.75,-5.61],[-17.75,-6.56],[-5.4,-9.3],[-2.98,-11.7],[-0.22,-24.08],[0.73,-24.08],[3.48,-11.7],[5.9,-9.3],[18.25,-6.56]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,95.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,40]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,20]},{"t":97,"s":[100,40]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,3]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,42]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,26]},{"t":97,"s":[100,42]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[346.44,208.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[79,56.3,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":180,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":149,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":161,"s":[100,100]},{"t":180,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":149,"op":180,"st":92},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":141,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":166,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":129,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":141,"s":[100,100]},{"t":166,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":129,"op":166,"st":72},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,184.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":122,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":145,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":114,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":122,"s":[100,100]},{"t":145,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":114,"op":146,"st":57},{"ind":19,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":99,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":136,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":99,"op":137,"st":42},{"ind":20,"ty":4,"ks":{"o":{"a":1,"k":[{"t":45,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"r":{"k":0},"p":{"k":[284.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":82,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":45,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":57,"s":[100,100]},{"t":82,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":45,"op":83,"st":-12},{"ind":21,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":56,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":25,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":33,"s":[100,100]},{"t":56,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":25,"op":57,"st":-32},{"ind":22,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":12,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":37,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":0,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":12,"s":[100,100]},{"t":37,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":38,"st":-57},{"ind":23,"ty":4,"parent":27,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[257.66,216.8,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[257.66,195.11,0],"to":[0,0,0],"ti":[0,0,0]},{"t":74,"s":[257.66,196.8,0]}],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"t":66,"s":[100,50,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.13,-28.48],[-50.62,19.68],[-18.12,-3.73],[14.13,40.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"t":63,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":65,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.75,-16.98],[26.41,20.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-32.08,-18.25],[-14.49,-16.96],[-15.39,-18.55],[-52.77,-20.68]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":69,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-42.74,-31.07],[-14.97,-46.8],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"t":68,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.06,-20.19],[-18.55,-45.47],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[2.59,-1.44],[37.94,1.18],[-6.25,14.1],[-24.19,4.01]],"o":[[-19.07,-0.37],[-2.16,-0.07],[10.87,19.21],[-1.7,0.73]],"v":[[48.4,19.5],[-17.41,53.75],[-6.96,-10.32],[71.12,18.18]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[1.94,-1.08],[6.24,-1.19],[15.02,17.72],[-16.78,-17.45]],"o":[[-3.39,20.66],[-1.59,0.3],[8.15,14.41],[-1.28,0.54]],"v":[[47.38,-11.46],[33.39,27.91],[-7.43,15.2],[48.16,-10.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[{"i":[[1.3,-0.72],[4.59,9.5],[7.91,-2.82],[-5.41,1.07]],"o":[[-0.4,-8.63],[-0.47,-0.97],[10.84,16.61],[-0.85,0.36]],"v":[[54.18,-18.16],[38.44,-59.82],[16.53,-34.2],[42.57,-26.83]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":71,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":24,"ty":4,"parent":27,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":68,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[256.46,249.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[256.46,225.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[256.46,209.72,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[256.46,206.72,0]}],"l":2},"a":{"k":[256.46,249.72,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,36,100]},{"t":67,"s":[100,14,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[1.75,-10.16],[-45.92,7.16],[-17.13,19.11],[30,-1.05]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-56.04,12.3],[-19.24,23.32],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.1,11.25],[-18.36,20.12],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[0.71,-11.86],[-45.71,6.16],[-16.92,18.11],[34.21,-0.66]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":25,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.96,347.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":26,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[100],"h":1},{"t":81,"s":[0],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[18.25,-4.08],[18.25,-3.61],[5.9,-0.86],[3.48,1.53],[0.73,13.92],[-0.22,13.92],[-2.98,1.53],[-5.4,-0.86],[-17.75,-3.61],[-17.75,-4.56],[-5.4,-7.3],[-2.98,-9.7],[-0.22,-22.08],[0.73,-22.08],[3.48,-9.7],[5.9,-7.3],[18.25,-4.56]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":27,"ty":4,"parent":28,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":72,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":72,"s":[257.27,128.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1,-61.13],[131.24,-49.8],[-1.2,-40.13],[-132.88,-50.13]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"t":72,"s":[81,81]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":28,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":29,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[-198]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-174]},{"t":97,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[220.5,269,0],"to":[-2.6,-19.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[157.72,193.91,0],"to":[-69.01,-1.59,0],"ti":[2.95,-91.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[83.5,374,0],"to":[-0.41,-0.39,0],"ti":[-3.5,-7,0]},{"t":97,"s":[87.5,374,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[84,84,100]},{"t":88,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.75,-0.58],[17.75,-0.11],[5.4,2.64],[2.98,5.03],[0.23,17.42],[-0.72,17.42],[-3.48,5.03],[-5.9,2.64],[-18.25,-0.11],[-18.25,-1.06],[-5.9,-3.8],[-3.48,-6.2],[-0.72,-18.58],[0.23,-18.58],[2.98,-6.2],[5.4,-3.8],[17.75,-1.06]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346.5,89.83]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[100,24]},{"t":97,"s":[100,12]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[0,-3],"to":[0,0],"ti":[0,0]},{"t":97,"s":[0,-2.5]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[91,28]},{"t":97,"s":[91,22]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":30,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[-180]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[-207]},{"t":93,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[296.5,278,0],"to":[2.4,-15.52,0],"ti":[-54.47,3.14,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[348.72,155.91,0],"to":[56.66,-3.26,0],"ti":[-0.05,-138.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[428.5,358,0],"to":[-4.91,-22.89,0],"ti":[0.5,-14.75,0]},{"t":93,"s":[402.5,376,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[100,100,100]},{"t":85,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[18.76,0.4],[18.76,0.88],[6.42,3.62],[4,6.02],[1.24,18.4],[0.29,18.4],[-2.47,6.02],[-4.89,3.62],[-17.24,0.88],[-17.24,-0.07],[-4.89,-2.82],[-2.47,-5.21],[0.29,-17.6],[1.24,-17.6],[4,-5.21],[6.42,-2.82],[18.76,-0.07]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[345.49,88.85]},"a":{"k":[0,3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,-100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[100,0]},{"t":93,"s":[100,-24]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":91,"s":[0,-4]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[91,4]},{"t":93,"s":[100,31]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":31,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.48,312.85,0],"l":2},"a":{"k":[266.48,312.85,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[741.39,-2.56],[741.39,-2.08],[729.05,0.66],[726.63,3.06],[723.87,15.44],[722.92,15.44],[720.16,3.06],[717.74,0.66],[705.39,-2.08],[705.39,-3.03],[717.74,-5.78],[720.16,-8.18],[722.92,-20.56],[723.87,-20.56],[726.63,-8.18],[729.05,-5.78],[741.39,-3.03]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-557.72,95.71]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[231.5,294],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[231.5,283],"to":[0,0],"ti":[0,0]},{"t":74,"s":[231.5,294]}]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":96},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[378.55,10.02],[378.55,10.5],[366.2,13.24],[363.78,15.64],[361.02,28.02],[360.07,28.02],[357.32,15.64],[354.9,13.24],[342.55,10.5],[342.55,9.55],[354.9,6.8],[357.32,4.41],[360.07,-7.98],[361.02,-7.98],[363.78,4.41],[366.2,6.8],[378.55,9.55]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-194.88,83.13]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[189,270.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[189,263.5],"to":[0,0],"ti":[0,0]},{"t":74,"s":[189,270.5]}]},"a":{"k":[166,92]},"s":{"k":[103.56,52.65]},"r":{"k":-3.75},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[378.42,-4.57],[378.42,-4.09],[366.07,-1.35],[363.65,1.05],[360.89,13.43],[359.94,13.43],[357.19,1.05],[354.77,-1.35],[342.42,-4.09],[342.42,-5.04],[354.77,-7.79],[357.19,-10.18],[359.94,-22.57],[360.89,-22.57],[363.65,-10.18],[366.07,-7.79],[378.42,-5.04]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-194.75,97.72]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,10]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"fl","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[329.5,274],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[329.5,262],"to":[0,0],"ti":[0,0]},{"t":75,"s":[329.5,274]}]},"a":{"k":[166,92]},"s":{"k":[97,43.65]},"r":{"k":14},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[196.36,1.59],[196.36,2.07],[184.02,4.81],[181.6,7.21],[178.84,19.59],[177.89,19.59],[175.13,7.21],[172.71,4.81],[160.36,2.07],[160.36,1.12],[172.71,-1.63],[175.13,-4.02],[177.89,-16.41],[178.84,-16.41],[181.6,-4.02],[184.02,-1.63],[196.36,1.12]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-12.7,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"fl","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[238.5,264.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[238.5,254.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[238.5,264.5]}]},"a":{"k":[166,92]},"s":{"k":[100,39]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.86,1.59],[17.86,2.07],[5.52,4.81],[3.1,7.21],[0.34,19.59],[-0.61,19.59],[-3.37,7.21],[-5.79,4.81],[-18.14,2.07],[-18.14,1.12],[-5.79,-1.63],[-3.37,-4.02],[-0.61,-16.41],[0.34,-16.41],[3.1,-4.02],[5.52,-1.63],[17.86,1.12]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,12]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[285.5,268.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[285.5,258.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[285.5,268.5]}]},"a":{"k":[166,92]},"s":{"k":[100,32]},"r":{"k":-8},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.86,1.59],[17.86,2.07],[5.52,4.81],[3.1,7.21],[0.34,19.59],[-0.61,19.59],[-3.37,7.21],[-5.79,4.81],[-18.14,2.07],[-18.14,1.12],[-5.79,-1.63],[-3.37,-4.02],[-0.61,-16.41],[0.34,-16.41],[3.1,-4.02],[5.52,-1.63],[17.86,1.12]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,7]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[271.5,254],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[271.5,245],"to":[0,0],"ti":[0,0]},{"t":71,"s":[271.5,254]}]},"a":{"k":[166,92]},"s":{"k":[77,28.36]},"r":{"k":3.04},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[17.86,1.59],[17.86,2.07],[5.52,4.81],[3.1,7.21],[0.34,19.59],[-0.61,19.59],[-3.37,7.21],[-5.79,4.81],[-18.14,2.07],[-18.14,1.12],[-5.79,-1.63],[-3.37,-4.02],[-0.61,-16.41],[0.34,-16.41],[3.1,-4.02],[5.52,-1.63],[17.86,1.12]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[284.5,298]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":100},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":32,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":33,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[0],"h":1},{"t":81,"s":[100],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[3.1,-1.19],[0.43,-1.1],[0.47,-2.95],[0,0],[1.19,3.09],[1.1,0.42],[2.94,0.47],[0,0],[-3.1,1.19],[-0.43,1.1],[-0.47,2.95],[0,0],[-1.19,-3.09],[-1.1,-0.42],[-2.94,-0.47]],"o":[[0,0],[-2.94,0.47],[-1.11,0.43],[-1.19,3.09],[0,0],[-0.47,-2.95],[-0.43,-1.1],[-3.1,-1.19],[0,0],[2.94,-0.47],[1.11,-0.42],[1.19,-3.09],[0,0],[0.47,2.95],[0.43,1.1],[3.1,1.19],[0,0]],"v":[[-162.33,-0.19],[-162.33,0.29],[-174.68,3.03],[-177.1,5.43],[-179.86,17.81],[-180.81,17.81],[-183.56,5.43],[-185.98,3.03],[-198.33,0.29],[-198.33,-0.66],[-185.98,-3.4],[-183.56,-5.8],[-180.81,-18.19],[-179.86,-18.19],[-177.1,-5.8],[-174.68,-3.4],[-162.33,-0.66]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[-181,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.09019607843137255,0.3607843137254902,0.7647058823529411,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[5]},{"t":117,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.12156862745098039,0.47058823529411764,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":34,"ty":4,"parent":28,"ks":{"o":{"a":1,"k":[{"t":56,"s":[0],"h":1},{"t":72,"s":[100],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.34,"y":0},"t":72,"s":[257.27,128.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":91,"s":[257.27,342.53,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.48,-54.5],[131.76,-43.17],[-0.67,-33.5],[-132.36,-43.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.77,33.72],[131.48,-1.28],[-0.92,-32.28],[-132.64,-1.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.76,5.8],[131.57,4.14],[-0.9,5.8],[-132.55,3.8]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1.15,-23.5],[131.16,-19.17],[-1.28,-10.75],[-132.97,-19.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.05,-64.7],[132.03,-18.36],[-0.09,37.3],[-132.09,-18.7]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,-99.54],[132.16,0.65],[-0.09,95.58],[-131.96,0.98]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1},{"t":79,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[64.68,-94.47],[-66.93,-61.2],[-66.95,-36.54],[64.92,-50.71]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.53,-53.52],[-66.43,-54.53],[-66.43,-31.2],[65.4,-28.87]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-53.02],[-66.03,-54.03],[-66.03,-30.7],[65.97,26.63]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,61.22],[-65.96,-33.37],[-66.01,-34.68],[66.04,60.58]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65,-36.37],[-67.1,-51.21],[-67.19,-94.97],[65.09,-60.63]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.52,-31.03],[-66.61,-29.37],[-66.33,-54.02],[65.59,-53.95]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.92,-30.53],[-66.04,26.13],[-65.93,-53.52],[65.99,-53.45]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-35.85],[-65.98,60.07],[-65.91,60.71],[66.05,-34.94]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":72,"s":[81,81]},{"t":83,"s":[73,73]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0}]},{"id":"comp_1","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":181,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":144,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":156,"s":[100,100]},{"t":181,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":144,"op":181,"st":87},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[336.44,166.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":136,"s":[100,100]},{"t":161,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":124,"op":162,"st":67},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[190.44,214.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":117,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":109,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":117,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":109,"op":141,"st":52},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":103,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":114,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[100,100]},{"t":114,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":95,"op":115,"st":38},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":100,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":81,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":89,"s":[100,100]},{"t":100,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":81,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":100,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":81,"op":101,"st":24},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":90,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":73,"s":[100,100]},{"t":90,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":90,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":65,"op":91,"st":8},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,221.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":69,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":50,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":58,"s":[100,100]},{"t":69,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":50,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":69,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":50,"op":70,"st":-7},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[323.44,178.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":49,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":60,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":41,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":49,"s":[100,100]},{"t":60,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":41,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":60,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":41,"op":61,"st":-16},{"ind":9,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257.66,216.8,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-43.5],[-50.62,19.68],[-19.83,-18.75],[12.42,25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.08,-16.07],[17.57,-27.27],[-8.47,2.39],[30.67,-18.15]],"o":[[-15.8,5.37],[10.67,-14.43],[36.88,-10.55],[63.53,-17.02]],"v":[[1.03,-25.49],[-50.62,19.68],[-18.22,-2.26],[14.03,39.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"t":83,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-32],[24.7,5.25],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.13],[15.01,-7.06],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,17.55],[-23.68,11.14],[4.47,0.56]],"v":[[-34.14,-8.48],[52.65,-15.65],[26.31,20],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"t":83,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-25.54,-39.48],[3.89,-52.46],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-14.47],[-8.77,1.66],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.23],[26.18,-4.96],[-7.91,6.92]],"v":[[28.2,44.02],[-47.21,-19.87],[-19.44,-35.04],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"t":83,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-32.04,-23.56],[-1.35,-53.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,23.79],[-20,4.13],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.37],[-16,22.94],[-7,3.5]],"v":[[44.51,48.89],[-46.54,-9.48],[-23.02,-33.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"t":83,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":10,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.76,93.8],[14.58,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.58,93.8],[14.76,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":11,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[257.27,248.75,0],"to":[0,14.83,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[257.27,218.64,0],"to":[0,0,0],"ti":[0,-8.61,0]},{"t":34,"s":[257.27,248.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":35},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":12,"ty":4,"parent":14,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-28.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":13,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":32,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[-1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":66,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[-1.5]},{"t":81,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.17,"y":0.17},"t":0,"s":[257,739,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.13},"t":20,"s":[257,379,0],"to":[0,0,0],"ti":[0,0,0]},{"t":32,"s":[257,405,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":9,"s":[97,105,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":36,"s":[104,96,100]},{"t":43,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":0,"cl":"Present3","refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":180,"op":360,"st":180},{"ind":2,"ty":0,"cl":"Present2","refId":"comp_1","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/dot_packing.json b/common/src/main/res/raw/dot_packing.json new file mode 100644 index 0000000..5c1dede --- /dev/null +++ b/common/src/main/res/raw/dot_packing.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":300,"w":512,"h":512,"assets":[],"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":275,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":300,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":263,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":275,"s":[100,100]},{"t":300,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":263,"op":300,"st":206},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[344.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":255,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":280,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":243,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":255,"s":[100,100]},{"t":280,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":243,"op":281,"st":186},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":236,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":259,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":228,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":236,"s":[100,100]},{"t":259,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":228,"op":260,"st":171},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":222,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":233,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":214,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":222,"s":[100,100]},{"t":233,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":214,"op":234,"st":157},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":208,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":219,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":200,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":208,"s":[100,100]},{"t":219,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":200,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":219,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":200,"op":220,"st":143},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":76,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":51,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":59,"s":[100,100]},{"t":76,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":51,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":76,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":51,"op":77,"st":-6},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[200.44,241.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":55,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":36,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":44,"s":[100,100]},{"t":55,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":36,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":55,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":36,"op":56,"st":-21},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[343.44,238.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":35,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":46,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":27,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":35,"s":[100,100]},{"t":46,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":27,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":46,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":27,"op":47,"st":-30},{"ind":9,"ty":4,"parent":41,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50.66,-184.2,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[11,2],[0,0],[-2.75,-7.75],[20.5,-7]],"o":[[-10,8.5],[0,0],[1.12,3.14],[40,-13]],"v":[[42.08,-17.98],[22.38,-6.82],[31.83,10.77],[14.08,31.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":171,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":172,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[46.06,-12.83],[18.2,-29.66],[-8.71,1.58],[30.67,-18.97]],"o":[[-16.15,4.5],[10.3,-11.21],[35.76,-6.47],[63.53,-17.79]],"v":[[0.89,-20.37],[-50.62,19.68],[-18.36,2.16],[13.89,45.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-36.51],[-50.62,19.68],[-19.83,-11.75],[12.42,32]],"c":true}]},{"t":209,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":170,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-29.64,-5.98],[-0.31,4.52],[-26.64,18.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":169,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-11.09],[26.17,26.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-25],[24.7,12.25],[-56.19,5.91]],"c":true}]},{"t":209,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.06,-13],[-19.29,-28.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-35.04,-33.48],[-5.61,-46.46],[47.19,27.25]],"c":true}]},{"t":209,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":168,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":163,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":166,"s":[{"i":[[2.59,-1.44],[13.98,27.91],[-8.75,-2.33],[-23.7,-7.47]],"o":[[-20.01,-7.24],[-0.97,-1.93],[2.46,22.32],[-1.7,0.73]],"v":[[44,48.14],[-36.97,-2.51],[-7.45,-15.43],[72.59,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.38,-2.11],[-22.87,-27.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-41.54,-17.57],[-10.85,-47.87],[71.01,35.39]],"c":true}]},{"t":209,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":10,"ty":4,"parent":41,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[49.33,-79.33,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":154,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[16.25,-17.11],[15.33,-34.84],[44.12,-24.39],[45.75,-6.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":159,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.25,-17.11],[15.83,-16.84],[46.12,-6.39],[45.75,-6.61]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":154,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":158,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":160,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":144,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":142,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":151,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":154,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":141,"s":[0],"h":1},{"t":142,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":157,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.21,-18.11],[15.79,-17.84],[46.08,-7.39],[45.71,-7.61]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":153,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":11,"ty":4,"parent":41,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":120,"s":[50.27,-201.25,0],"to":[0,6.23,0],"ti":[0,-15.32,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":134,"s":[50.27,-284.64,0],"to":[0,21.19,0],"ti":[0,-8.61,0]},{"t":146,"s":[50.27,-152.25,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":120,"s":[0,0,100]},{"t":144,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.4,91.93],[131.21,19.33],[0.36,-48.29],[-131.32,19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":130,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-52.85,28.76],[95.73,14.81],[62.88,1.73],[-97.23,18.68]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[83.8,18.9],[83.9,18.9],[83.72,18.4],[-85.86,18.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":132,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[73.69,-14.15],[90.29,-13.04],[73.61,-14.65],[-91.27,-13.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.04,-13.5],[132.06,-4.67],[-0.12,-14],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-85.27,-14.13],[83.47,-13.5],[83.29,-15.26],[-86.29,-13.83]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.28,0.28,0.87,1],"h":1},{"t":131,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[66.17,-103.68],[-66.06,-33.47],[-66,-17],[66.77,-83.74]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.9,-48.82],[-66,-40.33],[-66,-17],[66,-15.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":131,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":134,"s":[0.4,0.42,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":135,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[0.4,0.42,1,1]},{"t":146,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-65.25,-84.24],[-65.69,-104.18],[65.97,-33.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,-15.67],[-65.97,-49.33],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.4,0.42,1,1],"h":1},{"t":132,"s":[0.47,0.58,0.99,1],"h":1},{"t":135,"s":[0.4,0.42,1,1],"h":1},{"t":140,"s":[0.47,0.58,0.99,1],"h":1},{"t":146,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":132,"s":[0],"h":1},{"t":146,"s":[35],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"t":119,"s":[100,100],"h":1},{"t":123,"s":[-100,100],"h":1},{"t":127,"s":[100,100],"h":1},{"t":131,"s":[-100,100],"h":1},{"t":135,"s":[100,100],"h":1},{"t":140,"s":[-100,100],"h":1},{"t":146,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":12,"ty":4,"parent":41,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.85},"o":{"x":0.17,"y":0.17},"t":0,"s":[50,-98.75,0],"to":[0,1.68,0],"ti":[0,-4.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":13,"s":[50,-184.14,0],"to":[0,5.71,0],"ti":[0,-2.32,0]},{"t":26,"s":[50,-78.75,0]}],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":0,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":34,"s":[97,97,100]},{"t":50,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-62.26,55.67],[-62.73,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-61.96,76.67],[-61.81,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-57.72,99.67],[-57.69,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[-9.59,4.33],[14.9,-23.89],[0,0],[0,0]],"o":[[14.5,18.78],[-0.1,-0.56],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,33.56],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[-13.68,5.7],[-6.74,-51.36],[0,0],[0,0]],"o":[[-4.79,47.52],[0.48,-0.45],[0,0],[0,0]],"v":[[61.73,-99.67],[61.46,45.58],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":7,"s":[0.47,0.58,0.99,1],"h":1},{"t":10,"s":[0.4,0.42,1,1],"h":1},{"t":14,"s":[0.47,0.58,0.99,1],"h":1},{"t":17,"s":[0.4,0.42,1,1],"h":1},{"t":24,"s":[0.47,0.58,0.99,1],"h":1},{"t":31,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.4,55.67],[60.91,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.7,76.67],[61.84,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[65.94,99.67],[65.95,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[10.47,2.67],[-18.22,-33.33],[0,0],[0,0]],"o":[[-10.44,12.11],[-0.04,-0.56],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.88,33.56],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[10.32,2.06],[8.37,-56.82],[0,0],[0,0]],"o":[[5.88,46.61],[0.04,-0.45],[0,0],[0,0]],"v":[[-61.94,-99.67],[-62.21,45.58],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":7,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":9,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":10,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":13,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":16,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":17,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":23,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":30,"s":[0.42,0.47,1,1]},{"t":31,"s":[0.47,0.58,0.99,1]}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":29,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[12.6]},{"t":102,"s":[96]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":82,"s":[116,96,0],"to":[86,26,0],"ti":[-1.25,-87.67,0]},{"t":102,"s":[290.5,297,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[100,84,100]},{"t":97,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":82,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":92,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":82,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[110,134]},{"t":92,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":30,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":101,"s":[28]},{"t":121,"s":[-3.75]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":101,"s":[104,122,0],"to":[54,7,0],"ti":[-19.25,-84.67,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[243.5,267,0],"to":[0.06,-3.71,0],"ti":[0,-3.54,0]},{"t":127,"s":[243.5,267,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[85,85,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":121,"s":[95.56,94.94,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":124,"s":[102.56,40.29,100]},{"t":127,"s":[103.56,52.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[0,0],"to":[0,-0.75],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":124,"s":[0,11],"to":[0,0],"ti":[0,0]},{"t":127,"s":[0,7]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":121,"s":[120,120]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[102.4,116]},{"t":127,"s":[102.4,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":31,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[31.25]},{"t":120,"s":[14]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":96,"s":[367,78,0],"to":[-18,29,0],"ti":[6.75,-76.67,0]},{"t":116,"s":[325.5,270,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[85,85,100]},{"t":116,"s":[97,43.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":116,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":106,"s":[116,116]},{"t":116,"s":[102.4,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":32,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[81.55]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":112,"s":[70.2]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[-10]},{"t":119,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":92,"s":[125,74,0],"to":[34,35,0],"ti":[-5.25,-79.67,0]},{"t":112,"s":[210.5,282,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[42.6,26.6,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[71,71,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":112,"s":[100,34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,54,100]},{"t":119,"s":[100,39,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[0,9],"to":[0,-0.15],"ti":[0,2.44]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,-8.41],"to":[0,-2.01],"ti":[0,-0.17]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":97,"s":[0,0],"to":[0,0.62],"ti":[0,0]},{"t":112,"s":[0,12]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":92,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[104.15,129.8]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":97,"s":[104.25,104]},{"t":112,"s":[105,130]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":33,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[-23]},{"t":101,"s":[-8]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":75,"s":[433,92,0],"to":[-77,4,0],"ti":[-1.25,-87.67,0]},{"t":97,"s":[238.5,296,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,100,100]},{"t":93,"s":[100,32,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":93,"s":[0,19]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[116,116]},{"t":93,"s":[105,125]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":34,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":100,"s":[-22.75]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":105,"s":[-22]},{"t":109,"s":[-9]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":85,"s":[367,78,0],"to":[-34,23,0],"ti":[6.75,-76.67,0]},{"t":105,"s":[264.5,279,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[77,77,100]},{"t":105,"s":[77,34.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":105,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":85,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[116,116]},{"t":105,"s":[109,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":35,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[12.6]},{"t":90,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":70,"s":[427,116,0],"to":[-67,4,0],"ti":[-1.25,-87.67,0]},{"t":90,"s":[264.5,338,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[71,38.34,100]},{"t":80,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":80,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":70,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[110,134]},{"t":80,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":36,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[12.6]},{"t":80,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":60,"s":[75,92,0],"to":[105,30,0],"ti":[-1.25,-87.67,0]},{"t":80,"s":[242.5,340,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[100,84,100]},{"t":75,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":70,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":60,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[110,134]},{"t":70,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":37,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":103,"s":[209.44,138.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":131,"s":[209.44,289.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[71,71,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":103,"op":141,"st":46},{"ind":38,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.76,"y":0},"t":94,"s":[317.44,178.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":120,"s":[317.44,338.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[102,102,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":102,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":133,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":94,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":102,"s":[100,100]},{"t":133,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":94,"op":134,"st":37},{"ind":39,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":70,"s":[202.44,133.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":97,"s":[202.44,349.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[127,127,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":105,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":70,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":78,"s":[100,100]},{"t":105,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":106,"st":13},{"ind":40,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.01,-33.33],[123.51,-1.58],[-0.61,-33.67],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.93,-0.08],[123.51,-1.58],[0.31,-0.42],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.67,-51.08],[123.51,-1.58],[4.43,51.08],[-123.51,-1.25]],"c":true}]},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":151,"st":0},{"ind":41,"ty":3,"ks":{"o":{"k":0},"r":{"k":0},"p":{"k":[257,451,0],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":18,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":26,"s":[103,97,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[97,103,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":43,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":94,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":98,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":122,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":126,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":145,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":149,"s":[102,98,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":155,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.85,1.14,-0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":168,"s":[99,102,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,4.33]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":180,"s":[110,90,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.75,0.75,0.75],"y":[0,0,0]},"t":195,"s":[90,110,100]},{"t":208,"s":[100,100,100]}],"l":2}},"ip":0,"op":264,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/dot_upacking.json b/common/src/main/res/raw/dot_upacking.json new file mode 100644 index 0000000..59a536f --- /dev/null +++ b/common/src/main/res/raw/dot_upacking.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":360,"w":512,"h":512,"assets":[{"id":"comp_0","layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[248.14,284.55,0],"l":2},"a":{"k":[14,-1,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-72,39],[-152,111]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.86,45],[3,185]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[61,31],[156,113]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"t":69,"s":[73]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[0]},{"t":67,"s":[73]}]},"o":{"k":0},"m":1}],"ip":60,"op":177,"st":-3},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":-208.79},"p":{"k":[90.94,257.49,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[73.08,73.08,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.59,-21.92],[27.98,-23.86],[-36.09,-3.9],[3.75,11.83],[-1.96,-59.37],[-44.64,5.65],[18.27,13.23]],"o":[[1.21,44.71],[-24.04,20.51],[7.95,0.86],[-8.39,-26.51],[1.32,39.94],[41.51,-5.26],[-13.06,-9.45]],"v":[[-134.97,-34.32],[-81.98,23.04],[-72.15,98.43],[-50.97,80.06],[-134.14,144.99],[-59.15,210.78],[-26.48,151.25]],"c":false}}},{"ty":"st","c":{"k":[0.949019667682,0.870865167356,0,1]},"o":{"k":100},"w":{"k":10},"lc":1,"lj":1,"ml":4},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"t":122,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[25.52]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[90]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":177,"st":-3},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":-322},"p":{"k":[191.66,150.67,0],"l":2},"a":{"k":[127.48,-143.62,0],"l":2},"s":{"k":[-190,190,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-4.76,4.16],[-5.58,-2.96],[0.86,-2.32],[1.98,1.6],[-0.17,2.54],[-4.87,2.42],[-4.55,-2.99],[0.82,-1.61],[1.23,1.05],[0.03,1.62],[-5.55,1.61],[-2.45,-2.91]],"o":[[1.22,-6.2],[4.76,-4.16],[2.19,1.16],[-0.89,2.39],[-1.98,-1.6],[0.36,-5.43],[4.88,-2.42],[1.51,1],[-0.73,1.44],[-1.23,-1.05],[-0.1,-5.78],[3.66,-1.06],[0,0]],"v":[[96.85,-121.52],[105.41,-138.05],[123,-140.94],[126.33,-135.01],[119.89,-133.82],[117.49,-140.76],[126.14,-153.74],[141.72,-152.8],[143.88,-148.35],[139.69,-147.87],[138.04,-152.28],[147.62,-165.26],[158.1,-162.64]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[10]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[38]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[63]},{"t":100,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[99]},{"t":100,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0,0.602906589882,0.949019607843,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[244.14,221.55,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[56,56,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[13.57,11.19],[-31.1,23.53],[44.5,-0.5],[0.21,-20],[-89,21]],"o":[[-12.84,-10.6],[37,-28],[-34.23,0.39],[-1,97],[35.17,-8.3]],"v":[[0.84,68.6],[14,13],[-11.5,-83.5],[-43,64],[-132,56]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[23]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[39]},{"t":90,"s":[49]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[40]},{"t":85,"s":[49]}]},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[171]},{"t":90,"s":[179]}]},"m":1},{"ty":"st","c":{"k":[0,0.949019667682,0.334948072246,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[8]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":74,"s":[18]},{"t":90,"s":[5]}]},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,20]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":61,"op":169,"st":-11},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":-370},"p":{"k":[336.32,194.77,0],"l":2},"a":{"k":[-147.98,-152.87,0],"l":2},"s":{"k":[-211,211,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[3.94,4.85],[5.94,-1.95],[-0.45,-2.41],[-2.2,1.23],[-0.26,2.51],[4.35,3.18],[4.93,-2.16],[-0.52,-1.71],[-1.37,0.82],[-0.3,1.57],[5.15,2.5],[2.88,-2.43]],"o":[[-0.16,-6.25],[-3.94,-4.85],[-2.33,0.77],[0.47,2.48],[2.2,-1.23],[0.56,-5.36],[-4.35,-3.18],[-1.64,0.72],[0.47,1.53],[1.37,-0.82],[1.06,-5.62],[-3.39,-1.64],[0,0]],"v":[[-121.55,-126.72],[-127.13,-144.27],[-143.8,-150.03],[-148.04,-144.81],[-141.97,-142.56],[-138.46,-148.94],[-144.73,-163.04],[-160.08,-164.73],[-162.93,-160.75],[-158.93,-159.58],[-156.58,-163.6],[-163.75,-177.86],[-174.41,-177.06]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[13]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[45]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[63]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[99]},{"t":97,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.964705882353,0,0.037585939146,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[169]},{"t":121,"s":[-67]}]},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.86},"o":{"x":0.05,"y":0.01},"t":68,"s":[201,236.25,0],"to":[-0.3,-1.62,0],"ti":[89.46,-52.28,0]},{"i":{"x":0.52,"y":0.9},"o":{"x":0.16,"y":0.13},"t":77,"s":[117.46,105.72,0],"to":[-89.46,52.28,0],"ti":[54.73,-96.02,0]},{"t":127,"s":[83.5,392.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":79,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":84,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":88,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":99,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[80,80,100]},{"t":120,"s":[75,-75,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,-2.72],[-1.69,-6.82],[5.28,-3.42],[-3.74,4.29]],"o":[[-1.96,5],[-8.39,-4.89],[-3.96,-11.6],[3,-5.59]],"v":[[15,-15],[15,15],[-15,15],[-15,-15]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":69,"s":[0,0.34,0.91,1],"h":1},{"t":72,"s":[0.89,0.89,0.89,1],"h":1},{"t":77,"s":[0,0.34,0.91,1],"h":1},{"t":82,"s":[0.89,0.89,0.89,1],"h":1},{"t":86,"s":[0,0.34,0.91,1],"h":1},{"t":91,"s":[0.89,0.89,0.89,1],"h":1},{"t":96,"s":[0,0.34,0.91,1],"h":1},{"t":103,"s":[0.89,0.89,0.89,1],"h":1},{"t":110,"s":[0,0.34,0.91,1],"h":1},{"t":117,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":180,"st":37},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-90]},{"t":109,"s":[301.64]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[198.75,244.5,0],"to":[-1.88,-52.9,0],"ti":[28.8,18.42,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[144.71,133.7,0],"to":[-28.64,-18.32,0],"ti":[23.57,-30.86,0]},{"t":109,"s":[58.5,148.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":68,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"t":109,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[29,30]},{"t":107,"s":[18,18.62]}]},"p":{"k":[0,0]},"r":{"k":211}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[0.96,0.81,0,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0.96,0.81,0,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0.96,0.81,0,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0.96,0.81,0,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0.96,0.81,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":4},"sa":{"k":0}}]}],"ip":65,"op":110,"st":34},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[-163]},{"t":120,"s":[-73.39]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[198.75,232.5,0],"to":[2.25,-107.5,0],"ti":[45.25,-88,0]},{"t":120,"s":[43.75,100,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":100,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":107,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,45,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":113,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[132,-64,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":119,"s":[130,-32,100]},{"t":120,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[22,22]},{"t":116,"s":[15,15]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":64,"s":[0.67,0,0.27,1],"h":1},{"t":67,"s":[0.89,0.89,0.89,1],"h":1},{"t":71,"s":[0.67,0,0.27,1],"h":1},{"t":76,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[0.67,0,0.27,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":90,"s":[0.67,0,0.27,1],"h":1},{"t":97,"s":[0.89,0.89,0.89,1],"h":1},{"t":104,"s":[0.67,0,0.27,1],"h":1},{"t":111,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[0.67,0,0.27,1],"h":1},{"t":118,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":64,"op":121,"st":-53},{"ind":9,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[-29]},{"t":131,"s":[306]}]},"p":{"a":1,"k":[{"i":{"x":0.43,"y":0.94},"o":{"x":0,"y":0},"t":70,"s":[315.75,212.5,0],"to":[84.25,-131.5,0],"ti":[9.5,-151.25,0]},{"t":133,"s":[444.5,412.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":71,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":108,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"t":123,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":133,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[7.5,-7.5],[7.5,7.5],[0.09,7.5],[-7.5,7.5],[-7.5,-7.5],[-1.31,-7.5]],"c":true}]},{"t":140,"s":[{"i":[[-1.04,1.29],[-0.3,-3.48],[1.31,-2.19],[0,0],[0,0],[-1.41,1.63]],"o":[[0.26,3.49],[0.29,3.4],[-1.99,3.33],[0,0],[0,0],[1.17,-1.35]],"v":[[-2.19,-14.3],[-0.07,-2.07],[-0.67,5.04],[-7.5,7.5],[-7.5,-7.5],[-4.66,-10.34]],"c":true}]}]}},{"ty":"fl","c":{"a":1,"k":[{"t":71,"s":[1,0.61,0,1],"h":1},{"t":74,"s":[0.89,0.89,0.89,1],"h":1},{"t":79,"s":[1,0.61,0,1],"h":1},{"t":84,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0.61,0,1],"h":1},{"t":93,"s":[0.89,0.89,0.89,1],"h":1},{"t":98,"s":[1,0.61,0,1],"h":1},{"t":105,"s":[0.89,0.89,0.89,1],"h":1},{"t":112,"s":[1,0.61,0,1],"h":1},{"t":119,"s":[0.89,0.89,0.89,1],"h":1},{"t":124,"s":[1,0.61,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":70,"op":219,"st":39},{"ind":10,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-29]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[186]},{"t":130,"s":[188]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[312.75,231.5,0],"to":[-0.58,-8.79,0],"ti":[-27.32,0.19,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[337,137.9,0],"to":[27.32,-0.19,0],"ti":[5.65,-84.54,0]},{"t":130,"s":[365.5,416.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":103,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,-100,100]},{"t":130,"s":[100,40,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"k":[22,22]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":66,"s":[0,0.86,0.06,1],"h":1},{"t":69,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0,0.86,0.06,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0,0.86,0.06,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0,0.86,0.06,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0,0.86,0.06,1],"h":1},{"t":114,"s":[0.89,0.89,0.89,1],"h":1},{"t":121,"s":[0,0.86,0.06,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":65,"op":214,"st":34},{"ind":11,"ty":4,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[100]},{"t":124,"s":[0]}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[126]},{"t":124,"s":[349]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":0.68},"o":{"x":0,"y":0},"t":68,"s":[274,232.25,0],"to":[14,-95.25,0],"ti":[-3.5,70.25,0]},{"t":124,"s":[416.5,52.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":116,"s":[100,100,100]},{"t":124,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[20,20]},{"t":124,"s":[14,14]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":72,"s":[1,0.92,0,1],"h":1},{"t":75,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[1,0.92,0,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":89,"s":[1,0.92,0,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":99,"s":[1,0.92,0,1],"h":1},{"t":106,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[1,0.92,0,1],"h":1},{"t":120,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":217,"st":37},{"ind":12,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[54]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":76,"s":[-18]},{"t":126,"s":[18]}]},"p":{"a":1,"k":[{"i":{"x":0.33,"y":0.63},"o":{"x":0.02,"y":0},"t":63,"s":[199,223.25,0],"to":[-1.41,-14.49,0],"ti":[30.23,18.1,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.19,"y":0.18},"t":69,"s":[164.23,151.1,0],"to":[-30.23,-18.1,0],"ti":[-19.69,-81.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83.75,"s":[108.31,217.87,0],"to":[19.69,81.13,0],"ti":[-18.51,-96.26,0]},{"t":126,"s":[36.5,421.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":67,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":105,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[-100,100,100]},{"t":126,"s":[-100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":63,"s":[22,22]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[32,32]},{"t":126,"s":[26,9.75]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[1,0,0.86,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":75,"s":[1,0,0.86,1],"h":1},{"t":80,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0,0.86,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":101,"s":[1,0,0.86,1],"h":1},{"t":110,"s":[0.89,0.89,0.89,1],"h":1},{"t":120,"s":[1,0,0.86,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":63,"op":212,"st":32},{"ind":19,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[50]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[-6]},{"t":103,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[250.5,249,0],"to":[-2.6,-22.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":69,"s":[190.72,157.91,0],"to":[-69.01,-1.59,0],"ti":[-1.05,-109.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[121.5,435.5,0],"to":[-0.24,-0.22,0],"ti":[-0.07,-2.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[121.37,423.98,0],"to":[0.05,2.14,0],"ti":[0,0,0]},{"t":99,"s":[121.5,438,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":99,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,2]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"t":99,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":20,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[115]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[292]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":112,"s":[158]},{"t":122,"s":[180]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[291.5,240,0],"to":[-2.6,-19.52,0],"ti":[-37.7,-0.28,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":71,"s":[329.72,135.91,0],"to":[54.99,0.41,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[386.5,440,0],"to":[-7,-25,0],"ti":[17,0.5,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[345.5,440,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[335.5,440,0],"to":[6.75,0.38,0],"ti":[0,0,0]},{"t":122,"s":[350.5,455,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":122,"s":[0,-3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":112,"s":[5]},{"t":122,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":21,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-59]},{"i":{"x":[0.49],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":69,"s":[-30]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[12]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-14]},{"t":96,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[290.5,195,0],"to":[0.4,-33.52,0],"ti":[-40.98,-0.41,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[356.72,126.91,0],"to":[40.99,0.41,0],"ti":[0.95,-126.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[420.5,419,0],"to":[-0.41,-0.39,0],"ti":[-0.05,-33.51,0]},{"t":96,"s":[420.5,419,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[0,0,100]},{"t":65,"s":[90,90,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,40]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,20]},{"t":97,"s":[100,40]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,3]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,42]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,26]},{"t":97,"s":[100,42]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"w":{"k":6},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":22,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[346.44,208.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[79,56.3,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":180,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":149,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":161,"s":[100,100]},{"t":180,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":149,"op":180,"st":92},{"ind":23,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":141,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":166,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":129,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":141,"s":[100,100]},{"t":166,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":129,"op":166,"st":72},{"ind":24,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,184.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":122,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":145,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":114,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":122,"s":[100,100]},{"t":145,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":114,"op":146,"st":57},{"ind":25,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":99,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":136,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":99,"op":137,"st":42},{"ind":26,"ty":4,"ks":{"o":{"a":1,"k":[{"t":45,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"r":{"k":0},"p":{"k":[284.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":82,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":45,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":57,"s":[100,100]},{"t":82,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":45,"op":83,"st":-12},{"ind":27,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":56,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":25,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":33,"s":[100,100]},{"t":56,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":25,"op":57,"st":-32},{"ind":28,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":12,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":37,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":0,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":12,"s":[100,100]},{"t":37,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":38,"st":-57},{"ind":29,"ty":4,"parent":35,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[257.66,216.8,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[257.66,195.11,0],"to":[0,0,0],"ti":[0,0,0]},{"t":74,"s":[257.66,196.8,0]}],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"t":66,"s":[100,50,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.13,-28.48],[-50.62,19.68],[-18.12,-3.73],[14.13,40.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"t":63,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":65,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.75,-16.98],[26.41,20.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-32.08,-18.25],[-14.49,-16.96],[-15.39,-18.55],[-52.77,-20.68]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":69,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-42.74,-31.07],[-14.97,-46.8],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"t":68,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.06,-20.19],[-18.55,-45.47],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[2.59,-1.44],[37.94,1.18],[-6.25,14.1],[-24.19,4.01]],"o":[[-19.07,-0.37],[-2.16,-0.07],[10.87,19.21],[-1.7,0.73]],"v":[[48.4,19.5],[-17.41,53.75],[-6.96,-10.32],[71.12,18.18]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[1.94,-1.08],[6.24,-1.19],[15.02,17.72],[-16.78,-17.45]],"o":[[-3.39,20.66],[-1.59,0.3],[8.15,14.41],[-1.28,0.54]],"v":[[47.38,-11.46],[33.39,27.91],[-7.43,15.2],[48.16,-10.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[{"i":[[1.3,-0.72],[4.59,9.5],[7.91,-2.82],[-5.41,1.07]],"o":[[-0.4,-8.63],[-0.47,-0.97],[10.84,16.61],[-0.85,0.36]],"v":[[54.18,-18.16],[38.44,-59.82],[16.53,-34.2],[42.57,-26.83]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":71,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":30,"ty":4,"parent":35,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":68,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[256.46,249.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[256.46,225.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[256.46,209.72,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[256.46,206.72,0]}],"l":2},"a":{"k":[256.46,249.72,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,36,100]},{"t":67,"s":[100,14,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[1.75,-10.16],[-45.92,7.16],[-17.13,19.11],[30,-1.05]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-56.04,12.3],[-19.24,23.32],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.1,11.25],[-18.36,20.12],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[0.71,-11.86],[-45.71,6.16],[-16.92,18.11],[34.21,-0.66]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":31,"ty":4,"parent":36,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.96,347.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":34,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[100],"h":1},{"t":81,"s":[0],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[9]},{"t":81,"s":[5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":35,"ty":4,"parent":36,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":72,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":72,"s":[257.27,128.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1,-61.13],[131.24,-49.8],[-1.2,-40.13],[-132.88,-50.13]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"t":72,"s":[81,81]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":36,"ty":4,"parent":52,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-74.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":41,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[-198]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-174]},{"t":97,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[220.5,269,0],"to":[-2.6,-19.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[157.72,193.91,0],"to":[-69.01,-1.59,0],"ti":[2.95,-91.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[83.5,374,0],"to":[-0.41,-0.39,0],"ti":[-3.5,-7,0]},{"t":97,"s":[87.5,374,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[84,84,100]},{"t":88,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[100,24]},{"t":97,"s":[100,12]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[0,-3],"to":[0,0],"ti":[0,0]},{"t":97,"s":[0,-2.5]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[91,28]},{"t":97,"s":[91,22]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[9]},{"t":88,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":42,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[-180]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[-207]},{"t":93,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[296.5,278,0],"to":[2.4,-15.52,0],"ti":[-54.47,3.14,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[348.72,155.91,0],"to":[56.66,-3.26,0],"ti":[-0.05,-138.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[428.5,358,0],"to":[-4.91,-22.89,0],"ti":[0.5,-14.75,0]},{"t":93,"s":[402.5,376,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[100,100,100]},{"t":85,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[100,0]},{"t":93,"s":[100,24]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":91,"s":[0,-4]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[91,4]},{"t":93,"s":[100,31]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[1],"y":[0]},"t":68,"s":[5]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[9]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[1],"y":[0]},"t":83,"s":[5]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":91,"s":[9]},{"t":93,"s":[6]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":45,"ty":4,"parent":36,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.48,312.85,0],"l":2},"a":{"k":[266.48,312.85,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[231.5,294],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[231.5,283],"to":[0,0],"ti":[0,0]},{"t":74,"s":[231.5,294]}]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":96},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[189,270.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[189,263.5],"to":[0,0],"ti":[0,0]},{"t":74,"s":[189,270.5]}]},"a":{"k":[166,92]},"s":{"k":[103.56,52.65]},"r":{"k":-3.75},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,10]},"a":{"k":[0,0]},"s":{"k":[102.4,116]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[329.5,274],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[329.5,262],"to":[0,0],"ti":[0,0]},{"t":75,"s":[329.5,274]}]},"a":{"k":[166,92]},"s":{"k":[97,43.65]},"r":{"k":14},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,12]},"a":{"k":[0,0]},"s":{"k":[105,130]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[238.5,264.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[238.5,254.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[238.5,264.5]}]},"a":{"k":[166,92]},"s":{"k":[100,39]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,19]},"a":{"k":[0,0]},"s":{"k":[105,125]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[285.5,268.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[285.5,258.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[285.5,268.5]}]},"a":{"k":[166,92]},"s":{"k":[100,32]},"r":{"k":-8},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,10]},"a":{"k":[0,0]},"s":{"k":[109,116]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[271.5,254],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[271.5,245],"to":[0,0],"ti":[0,0]},{"t":71,"s":[271.5,254]}]},"a":{"k":[166,92]},"s":{"k":[77,28.36]},"r":{"k":3.04},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[116,116]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[284.5,298]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":100},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":46,"ty":4,"parent":36,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":49,"ty":4,"ks":{"o":{"a":1,"k":[{"t":67,"s":[0],"h":1},{"t":81,"s":[100],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[166,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[4.73,2.83],[-5.51,-3.83]],"o":[[-4.87,-2.91],[4.24,2.95]],"v":[[-3.77,6.51],[4.11,-6.25]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.96,99.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.98,3.89],[4.63,-3.74]],"o":[[4.36,-3.4],[-4.45,3.6]],"v":[[-3.9,-6.28],[4.05,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[179.88,84]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.17,-6.08],[0.09,5.79]],"o":[[0.17,6.12],[-0.08,-5.29]],"v":[[7.38,-0.12],[-7.38,-0.08]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.93,76.07]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0.17,5.98],[-0.05,-5.84]],"o":[[-0.14,-4.83],[0.05,6.45]],"v":[[-7.34,-0.42],[7.34,-0.24]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.92,107.96]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,1.63],[4.42,-2.9]],"o":[[5.47,-1.5],[-5.55,3.65]],"v":[[-3.34,-6.74],[3.99,6.31]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.15,99.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.81,-1.83],[5.18,3.72]],"o":[[5.36,2.04],[-4.69,-3.37]],"v":[[3.51,-6.61],[-3.83,6.21]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[152.3,84.06]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[165.8,91.56]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.866666674614,0,0.352941185236,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[9]},{"t":81,"s":[5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.996078431606,0,0.478431373835,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":52,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":29,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":43,"s":[1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":46,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1]},{"t":58,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":55,"s":[257,451,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[257,439,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[257,451,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.9,1.09,0]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":55,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":58,"s":[110,89,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[91,108,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[103,97,100]},{"t":73,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]},{"id":"comp_1","layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":181,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":144,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":156,"s":[100,100]},{"t":181,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":144,"op":181,"st":87},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[336.44,166.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":136,"s":[100,100]},{"t":161,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":124,"op":162,"st":67},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[190.44,214.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":117,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":109,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":117,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":109,"op":141,"st":52},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":103,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":114,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[100,100]},{"t":114,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":95,"op":115,"st":38},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":100,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":81,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":89,"s":[100,100]},{"t":100,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":81,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":100,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":81,"op":101,"st":24},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":90,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":73,"s":[100,100]},{"t":90,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":90,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":65,"op":91,"st":8},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,221.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":69,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":50,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":58,"s":[100,100]},{"t":69,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":50,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":69,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":50,"op":70,"st":-7},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[323.44,178.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":49,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":60,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":41,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":49,"s":[100,100]},{"t":60,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":41,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":60,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":41,"op":61,"st":-16},{"ind":9,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257.66,216.8,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-43.5],[-50.62,19.68],[-19.83,-18.75],[12.42,25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.08,-16.07],[17.57,-27.27],[-8.47,2.39],[30.67,-18.15]],"o":[[-15.8,5.37],[10.67,-14.43],[36.88,-10.55],[63.53,-17.02]],"v":[[1.03,-25.49],[-50.62,19.68],[-18.22,-2.26],[14.03,39.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"t":83,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-32],[24.7,5.25],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.13],[15.01,-7.06],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,17.55],[-23.68,11.14],[4.47,0.56]],"v":[[-34.14,-8.48],[52.65,-15.65],[26.31,20],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"t":83,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-25.54,-39.48],[3.89,-52.46],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-14.47],[-8.77,1.66],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.23],[26.18,-4.96],[-7.91,6.92]],"v":[[28.2,44.02],[-47.21,-19.87],[-19.44,-35.04],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"t":83,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-32.04,-23.56],[-1.35,-53.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,23.79],[-20,4.13],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.37],[-16,22.94],[-7,3.5]],"v":[[44.51,48.89],[-46.54,-9.48],[-23.02,-33.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"t":83,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":10,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.76,93.8],[14.58,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.58,93.8],[14.76,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":11,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[257.27,248.75,0],"to":[0,14.83,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[257.27,218.64,0],"to":[0,0,0],"ti":[0,-8.61,0]},{"t":34,"s":[257.27,248.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":35},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":12,"ty":4,"parent":14,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-28.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":13,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":32,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[-1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":66,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[-1.5]},{"t":81,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.17,"y":0.17},"t":0,"s":[257,739,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.13},"t":20,"s":[257,379,0],"to":[0,0,0],"ti":[0,0,0]},{"t":32,"s":[257,405,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":9,"s":[97,105,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":36,"s":[104,96,100]},{"t":43,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":0,"cl":"Present3","refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":180,"op":360,"st":180},{"ind":2,"ty":0,"cl":"Present2","refId":"comp_1","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/hdx_packing.json b/common/src/main/res/raw/hdx_packing.json new file mode 100644 index 0000000..b19038c --- /dev/null +++ b/common/src/main/res/raw/hdx_packing.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":300,"w":512,"h":512,"assets":[{"id":"comp_0","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[12.6]},{"t":102,"s":[96]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":82,"s":[116,96,0],"to":[86,26,0],"ti":[-1.25,-87.67,0]},{"t":102,"s":[290.5,297,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[100,84,100]},{"t":97,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[-313.1,-71.55],[-311.63,-73.02],[-311.63,-77.27],[-312.88,-78.51],[-329.34,-75.83],[-318.67,-77.46],[-317.81,-83.44],[-325.16,-90.77],[-331.1,-90.77],[-339.01,-82.86],[-323.3,-79.79],[-343.15,-76.45],[-344.62,-74.98],[-344.62,-70.73],[-343.38,-69.49],[-326.91,-72.17],[-337.58,-70.54],[-338.44,-64.56],[-331.1,-57.23],[-325.16,-57.23],[-317.24,-65.14],[-332.96,-68.21],[-313.1,-71.55]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[-584,-165]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":123,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":127,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[5]},{"t":151,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":82,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":92,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":82,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[110,134]},{"t":92,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":101,"s":[28]},{"t":121,"s":[-3.75]}]},"p":{"a":1,"k":[{"i":{"x":0.71,"y":0.56},"o":{"x":0.31,"y":0},"t":101,"s":[104,122,0],"to":[38.75,5.02,0],"ti":[-29.47,-47.19,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.43,"y":0.45},"t":116,"s":[217.28,203.1,0],"to":[11.6,18.58,0],"ti":[-5.44,-23.91,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[243.5,267,0],"to":[0.06,-3.71,0],"ti":[0,-3.54,0]},{"t":127,"s":[243.5,267,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[85,85,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":121,"s":[95.56,94.94,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":124,"s":[102.56,40.29,100]},{"t":127,"s":[103.56,52.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.9,1.45],[16.36,-0.02],[16.36,-4.27],[15.12,-5.51],[-1.34,-2.83],[9.33,-4.46],[10.19,-10.44],[2.84,-17.77],[-3.1,-17.77],[-11.01,-9.86],[4.7,-6.79],[-15.15,-3.45],[-16.62,-1.98],[-16.62,2.27],[-15.38,3.51],[1.09,0.83],[-9.58,2.46],[-10.44,8.44],[-3.1,15.77],[2.84,15.77],[10.76,7.86],[-4.96,4.79],[14.9,1.45]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92.5]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":123,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":127,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[5]},{"t":151,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[0,0],"to":[0,-0.75],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":124,"s":[0,11],"to":[0,0],"ti":[0,0]},{"t":127,"s":[0,7]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":115,"s":[109.64,109.64]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":121,"s":[120,120]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[102.4,116]},{"t":127,"s":[102.4,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[31.25]},{"t":120,"s":[14]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":96,"s":[367,78,0],"to":[-18,29,0],"ti":[6.75,-76.67,0]},{"t":116,"s":[325.5,270,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[85,85,100]},{"t":116,"s":[97,43.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.9,1.95],[16.36,0.48],[16.36,-3.77],[15.12,-5.01],[-1.34,-2.33],[9.33,-3.96],[10.19,-9.94],[2.84,-17.27],[-3.1,-17.27],[-11.01,-9.36],[4.7,-6.29],[-15.15,-2.95],[-16.62,-1.48],[-16.62,2.77],[-15.38,4.01],[1.09,1.33],[-9.58,2.96],[-10.44,8.94],[-3.1,16.27],[2.84,16.27],[10.76,8.36],[-4.96,5.29],[14.9,1.95]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":123,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":127,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[5]},{"t":151,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":116,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":106,"s":[116,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[102.4,116]},{"t":125,"s":[114.47,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[81.55]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":112,"s":[70.2]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[-10]},{"t":119,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":92,"s":[125,74,0],"to":[34,35,0],"ti":[-5.25,-79.67,0]},{"t":112,"s":[210.5,282,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[42.6,26.6,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[71,71,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":112,"s":[100,34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,54,100]},{"t":119,"s":[100,39,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.9,1.95],[16.36,0.48],[16.36,-3.77],[15.12,-5.01],[-1.34,-2.33],[9.33,-3.96],[10.19,-9.94],[2.84,-17.27],[-3.1,-17.27],[-11.01,-9.36],[4.7,-6.29],[-15.15,-2.95],[-16.62,-1.48],[-16.62,2.77],[-15.38,4.01],[1.09,1.33],[-9.58,2.96],[-10.44,8.94],[-3.1,16.27],[2.84,16.27],[10.76,8.36],[-4.96,5.29],[14.9,1.95]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":123,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":127,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[5]},{"t":151,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[0,9],"to":[0,-0.15],"ti":[0,2.44]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,-8.41],"to":[0,-2.01],"ti":[0,-0.17]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":97,"s":[0,0],"to":[0,0.62],"ti":[0,0]},{"t":112,"s":[0,12]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":92,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[104.15,129.8]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":97,"s":[104.25,104]},{"t":112,"s":[105,130]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[-23]},{"t":101,"s":[-8]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":75,"s":[433,92,0],"to":[-77,4,0],"ti":[-1.25,-87.67,0]},{"t":97,"s":[238.5,296,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,100,100]},{"t":93,"s":[100,32,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.9,1.95],[16.36,0.48],[16.36,-3.77],[15.12,-5.01],[-1.34,-2.33],[9.33,-3.96],[10.19,-9.94],[2.84,-17.27],[-3.1,-17.27],[-11.01,-9.36],[4.7,-6.29],[-15.15,-2.95],[-16.62,-1.48],[-16.62,2.77],[-15.38,4.01],[1.09,1.33],[-9.58,2.96],[-10.44,8.94],[-3.1,16.27],[2.84,16.27],[10.76,8.36],[-4.96,5.29],[14.9,1.95]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":123,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":127,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[5]},{"t":151,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":93,"s":[0,19]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[116,116]},{"t":93,"s":[105,125]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":100,"s":[-22.75]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":105,"s":[-22]},{"t":109,"s":[-9]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":85,"s":[367,78,0],"to":[-34,23,0],"ti":[6.75,-76.67,0]},{"t":105,"s":[264.5,279,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[77,77,100]},{"t":105,"s":[77,34.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.9,1.95],[16.36,0.48],[16.36,-3.77],[15.12,-5.01],[-1.34,-2.33],[9.33,-3.96],[10.19,-9.94],[2.84,-17.27],[-3.1,-17.27],[-11.01,-9.36],[4.7,-6.29],[-15.15,-2.95],[-16.62,-1.48],[-16.62,2.77],[-15.38,4.01],[1.09,1.33],[-9.58,2.96],[-10.44,8.94],[-3.1,16.27],[2.84,16.27],[10.76,8.36],[-4.96,5.29],[14.9,1.95]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":123,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":127,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[5]},{"t":151,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":105,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":85,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[116,116]},{"t":105,"s":[109,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[12.6]},{"t":90,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":70,"s":[427,116,0],"to":[-67,4,0],"ti":[-1.25,-87.67,0]},{"t":90,"s":[264.5,338,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[71,38.34,100]},{"t":80,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[12.4,1.95],[13.87,0.48],[13.87,-3.77],[12.62,-5.01],[-3.84,-2.33],[6.83,-3.96],[7.69,-9.94],[0.34,-17.27],[-5.6,-17.27],[-13.51,-9.36],[2.2,-6.29],[-17.65,-2.95],[-19.12,-1.48],[-19.12,2.77],[-17.88,4.01],[-1.41,1.33],[-12.08,2.96],[-12.94,8.94],[-5.6,16.27],[0.34,16.27],[8.26,8.36],[-7.46,5.29],[12.4,1.95]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[258.5,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":123,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":127,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[5]},{"t":151,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[12.6]},{"t":80,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":60,"s":[75,92,0],"to":[105,30,0],"ti":[-1.25,-87.67,0]},{"t":80,"s":[242.5,340,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[100,84,100]},{"t":75,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.9,1.95],[16.36,0.48],[16.36,-3.77],[15.12,-5.01],[-1.34,-2.33],[9.33,-3.96],[10.19,-9.94],[2.84,-17.27],[-3.1,-17.27],[-11.01,-9.36],[4.7,-6.29],[-15.15,-2.95],[-16.62,-1.48],[-16.62,2.77],[-15.38,4.01],[1.09,1.33],[-9.58,2.96],[-10.44,8.94],[-3.1,16.27],[2.84,16.27],[10.76,8.36],[-4.96,5.29],[14.9,1.95]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":126,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[5]},{"t":150,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":275,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":300,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":263,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":275,"s":[100,100]},{"t":300,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":263,"op":300,"st":206},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[344.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":255,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":280,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":243,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":255,"s":[100,100]},{"t":280,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":243,"op":281,"st":186},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":236,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":259,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":228,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":236,"s":[100,100]},{"t":259,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":228,"op":260,"st":171},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":222,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":233,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":214,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":222,"s":[100,100]},{"t":233,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":214,"op":234,"st":157},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":208,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":219,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":200,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":208,"s":[100,100]},{"t":219,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":200,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":219,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":200,"op":220,"st":143},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":76,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":51,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":59,"s":[100,100]},{"t":76,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":51,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":76,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":51,"op":77,"st":-6},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[200.44,241.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":55,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":36,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":44,"s":[100,100]},{"t":55,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":36,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":55,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":36,"op":56,"st":-21},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[343.44,238.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":35,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":46,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":27,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":35,"s":[100,100]},{"t":46,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":27,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":46,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":27,"op":47,"st":-30},{"ind":9,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50.66,-184.2,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[11,2],[0,0],[-2.75,-7.75],[20.5,-7]],"o":[[-10,8.5],[0,0],[1.12,3.14],[40,-13]],"v":[[42.08,-17.98],[22.38,-6.82],[31.83,10.77],[14.08,31.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":171,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":172,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[46.06,-12.83],[18.2,-29.66],[-8.71,1.58],[30.67,-18.97]],"o":[[-16.15,4.5],[10.3,-11.21],[35.76,-6.47],[63.53,-17.79]],"v":[[0.89,-20.37],[-50.62,19.68],[-18.36,2.16],[13.89,45.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-36.51],[-50.62,19.68],[-19.83,-11.75],[12.42,32]],"c":true}]},{"t":209,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":170,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-29.64,-5.98],[-0.31,4.52],[-26.64,18.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":169,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-11.09],[26.17,26.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-25],[24.7,12.25],[-56.19,5.91]],"c":true}]},{"t":209,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.06,-13],[-19.29,-28.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-35.04,-33.48],[-5.61,-46.46],[47.19,27.25]],"c":true}]},{"t":209,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":168,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":163,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":166,"s":[{"i":[[2.59,-1.44],[13.98,27.91],[-8.75,-2.33],[-23.7,-7.47]],"o":[[-20.01,-7.24],[-0.97,-1.93],[2.46,22.32],[-1.7,0.73]],"v":[[44,48.14],[-36.97,-2.51],[-7.45,-15.43],[72.59,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.38,-2.11],[-22.87,-27.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-41.54,-17.57],[-10.85,-47.87],[71.01,35.39]],"c":true}]},{"t":209,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":10,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[49.33,-79.33,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":154,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[16.25,-17.11],[15.33,-34.84],[44.12,-24.39],[45.75,-6.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":159,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.25,-17.11],[15.83,-16.84],[46.12,-6.39],[45.75,-6.61]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":154,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":158,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":160,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":144,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":142,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":151,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":154,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":141,"s":[0],"h":1},{"t":142,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":157,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.21,-18.11],[15.79,-17.84],[46.08,-7.39],[45.71,-7.61]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":153,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":11,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":120,"s":[50.27,-201.25,0],"to":[0,6.23,0],"ti":[0,-15.32,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":134,"s":[50.27,-284.64,0],"to":[0,21.19,0],"ti":[0,-8.61,0]},{"t":146,"s":[50.27,-152.25,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":120,"s":[0,0,100]},{"t":144,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.4,91.93],[131.21,19.33],[0.36,-48.29],[-131.32,19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":130,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-52.85,28.76],[95.73,14.81],[62.88,1.73],[-97.23,18.68]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[83.8,18.9],[83.9,18.9],[83.72,18.4],[-85.86,18.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":132,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[73.69,-14.15],[90.29,-13.04],[73.61,-14.65],[-91.27,-13.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.04,-13.5],[132.06,-4.67],[-0.12,-14],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-85.27,-14.13],[83.47,-13.5],[83.29,-15.26],[-86.29,-13.83]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.28,0.28,0.87,1],"h":1},{"t":131,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[66.17,-103.68],[-66.06,-33.47],[-66,-17],[66.77,-83.74]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.9,-48.82],[-66,-40.33],[-66,-17],[66,-15.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":131,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":134,"s":[0.4,0.42,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":135,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[0.4,0.42,1,1]},{"t":146,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-65.25,-84.24],[-65.69,-104.18],[65.97,-33.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,-15.67],[-65.97,-49.33],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.4,0.42,1,1],"h":1},{"t":132,"s":[0.47,0.58,0.99,1],"h":1},{"t":135,"s":[0.4,0.42,1,1],"h":1},{"t":140,"s":[0.47,0.58,0.99,1],"h":1},{"t":146,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":132,"s":[0],"h":1},{"t":146,"s":[35],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"t":119,"s":[100,100],"h":1},{"t":123,"s":[-100,100],"h":1},{"t":127,"s":[100,100],"h":1},{"t":131,"s":[-100,100],"h":1},{"t":135,"s":[100,100],"h":1},{"t":140,"s":[-100,100],"h":1},{"t":146,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":12,"ty":4,"parent":21,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.85},"o":{"x":0.17,"y":0.17},"t":0,"s":[50,-98.75,0],"to":[0,1.68,0],"ti":[0,-4.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":13,"s":[50,-184.14,0],"to":[0,5.71,0],"ti":[0,-2.32,0]},{"t":26,"s":[50,-78.75,0]}],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":0,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":34,"s":[97,97,100]},{"t":50,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-62.26,55.67],[-62.73,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-61.96,76.67],[-61.81,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-57.72,99.67],[-57.69,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[-9.59,4.33],[14.9,-23.89],[0,0],[0,0]],"o":[[14.5,18.78],[-0.1,-0.56],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,33.56],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[-13.68,5.7],[-6.74,-51.36],[0,0],[0,0]],"o":[[-4.79,47.52],[0.48,-0.45],[0,0],[0,0]],"v":[[61.73,-99.67],[61.46,45.58],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":7,"s":[0.47,0.58,0.99,1],"h":1},{"t":10,"s":[0.4,0.42,1,1],"h":1},{"t":14,"s":[0.47,0.58,0.99,1],"h":1},{"t":17,"s":[0.4,0.42,1,1],"h":1},{"t":24,"s":[0.47,0.58,0.99,1],"h":1},{"t":31,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.4,55.67],[60.91,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.7,76.67],[61.84,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[65.94,99.67],[65.95,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[10.47,2.67],[-18.22,-33.33],[0,0],[0,0]],"o":[[-10.44,12.11],[-0.04,-0.56],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.88,33.56],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[10.32,2.06],[8.37,-56.82],[0,0],[0,0]],"o":[[5.88,46.61],[0.04,-0.45],[0,0],[0,0]],"v":[[-61.94,-99.67],[-62.21,45.58],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":7,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":9,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":10,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":13,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":16,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":17,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":23,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":30,"s":[0.42,0.47,1,1]},{"t":31,"s":[0.47,0.58,0.99,1]}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":13,"ty":0,"refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":103,"s":[209.44,138.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":131,"s":[209.44,289.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[71,71,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":103,"op":141,"st":46},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.76,"y":0},"t":94,"s":[317.44,178.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":120,"s":[317.44,338.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[102,102,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":102,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":133,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":94,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":102,"s":[100,100]},{"t":133,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":94,"op":134,"st":37},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":70,"s":[202.44,133.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":97,"s":[202.44,349.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[127,127,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":105,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":70,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":78,"s":[100,100]},{"t":105,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":106,"st":13},{"ind":19,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.01,-33.33],[123.51,-1.58],[-0.61,-33.67],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.93,-0.08],[123.51,-1.58],[0.31,-0.42],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.67,-51.08],[123.51,-1.58],[4.43,51.08],[-123.51,-1.25]],"c":true}]},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":151,"st":0},{"ind":21,"ty":3,"ks":{"o":{"k":0},"r":{"k":0},"p":{"k":[257,451,0],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":18,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":26,"s":[103,97,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[97,103,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":43,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":94,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":98,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":122,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":126,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":145,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":149,"s":[102,98,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":155,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.85,1.14,-0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":168,"s":[99,102,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,4.33]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":180,"s":[110,90,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.75,0.75,0.75],"y":[0,0,0]},"t":195,"s":[90,110,100]},{"t":208,"s":[100,100,100]}],"l":2}},"ip":0,"op":264,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/hdx_unpacking.json b/common/src/main/res/raw/hdx_unpacking.json new file mode 100644 index 0000000..3ca7636 --- /dev/null +++ b/common/src/main/res/raw/hdx_unpacking.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":360,"w":512,"h":512,"assets":[{"id":"comp_0","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[248.14,284.55,0],"l":2},"a":{"k":[14,-1,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-72,39],[-152,111]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.86,45],[3,185]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[61,31],[156,113]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"t":69,"s":[73]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[0]},{"t":67,"s":[73]}]},"o":{"k":0},"m":1}],"ip":60,"op":177,"st":-3},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":-208.79},"p":{"k":[90.94,257.49,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[73.08,73.08,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.59,-21.92],[27.98,-23.86],[-36.09,-3.9],[3.75,11.83],[-1.96,-59.37],[-44.64,5.65],[18.27,13.23]],"o":[[1.21,44.71],[-24.04,20.51],[7.95,0.86],[-8.39,-26.51],[1.32,39.94],[41.51,-5.26],[-13.06,-9.45]],"v":[[-134.97,-34.32],[-81.98,23.04],[-72.15,98.43],[-50.97,80.06],[-134.14,144.99],[-59.15,210.78],[-26.48,151.25]],"c":false}}},{"ty":"st","c":{"k":[0.949019667682,0.870865167356,0,1]},"o":{"k":100},"w":{"k":10},"lc":1,"lj":1,"ml":4},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"t":122,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[25.52]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[90]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":177,"st":-3},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":-322},"p":{"k":[191.66,150.67,0],"l":2},"a":{"k":[127.48,-143.62,0],"l":2},"s":{"k":[-190,190,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-4.76,4.16],[-5.58,-2.96],[0.86,-2.32],[1.98,1.6],[-0.17,2.54],[-4.87,2.42],[-4.55,-2.99],[0.82,-1.61],[1.23,1.05],[0.03,1.62],[-5.55,1.61],[-2.45,-2.91]],"o":[[1.22,-6.2],[4.76,-4.16],[2.19,1.16],[-0.89,2.39],[-1.98,-1.6],[0.36,-5.43],[4.88,-2.42],[1.51,1],[-0.73,1.44],[-1.23,-1.05],[-0.1,-5.78],[3.66,-1.06],[0,0]],"v":[[96.85,-121.52],[105.41,-138.05],[123,-140.94],[126.33,-135.01],[119.89,-133.82],[117.49,-140.76],[126.14,-153.74],[141.72,-152.8],[143.88,-148.35],[139.69,-147.87],[138.04,-152.28],[147.62,-165.26],[158.1,-162.64]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[10]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[38]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[63]},{"t":100,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[99]},{"t":100,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0,0.602906589882,0.949019607843,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[244.14,221.55,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[56,56,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[13.57,11.19],[-31.1,23.53],[44.5,-0.5],[0.21,-20],[-89,21]],"o":[[-12.84,-10.6],[37,-28],[-34.23,0.39],[-1,97],[35.17,-8.3]],"v":[[0.84,68.6],[14,13],[-11.5,-83.5],[-43,64],[-132,56]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[23]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[39]},{"t":90,"s":[49]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[40]},{"t":85,"s":[49]}]},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[171]},{"t":90,"s":[179]}]},"m":1},{"ty":"st","c":{"k":[0,0.949019667682,0.334948072246,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[8]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":74,"s":[18]},{"t":90,"s":[5]}]},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,20]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":61,"op":169,"st":-11},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":-370},"p":{"k":[336.32,194.77,0],"l":2},"a":{"k":[-147.98,-152.87,0],"l":2},"s":{"k":[-211,211,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[3.94,4.85],[5.94,-1.95],[-0.45,-2.41],[-2.2,1.23],[-0.26,2.51],[4.35,3.18],[4.93,-2.16],[-0.52,-1.71],[-1.37,0.82],[-0.3,1.57],[5.15,2.5],[2.88,-2.43]],"o":[[-0.16,-6.25],[-3.94,-4.85],[-2.33,0.77],[0.47,2.48],[2.2,-1.23],[0.56,-5.36],[-4.35,-3.18],[-1.64,0.72],[0.47,1.53],[1.37,-0.82],[1.06,-5.62],[-3.39,-1.64],[0,0]],"v":[[-121.55,-126.72],[-127.13,-144.27],[-143.8,-150.03],[-148.04,-144.81],[-141.97,-142.56],[-138.46,-148.94],[-144.73,-163.04],[-160.08,-164.73],[-162.93,-160.75],[-158.93,-159.58],[-156.58,-163.6],[-163.75,-177.86],[-174.41,-177.06]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[13]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[45]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[63]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[99]},{"t":97,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.964705882353,0,0.037585939146,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[169]},{"t":121,"s":[-67]}]},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.86},"o":{"x":0.05,"y":0.01},"t":68,"s":[201,236.25,0],"to":[-0.3,-1.62,0],"ti":[89.46,-52.28,0]},{"i":{"x":0.52,"y":0.9},"o":{"x":0.16,"y":0.13},"t":77,"s":[117.46,105.72,0],"to":[-89.46,52.28,0],"ti":[54.73,-96.02,0]},{"t":127,"s":[83.5,392.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":79,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":84,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":88,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":99,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[80,80,100]},{"t":120,"s":[75,-75,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,-2.72],[-1.69,-6.82],[5.28,-3.42],[-3.74,4.29]],"o":[[-1.96,5],[-8.39,-4.89],[-3.96,-11.6],[3,-5.59]],"v":[[15,-15],[15,15],[-15,15],[-15,-15]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":69,"s":[0,0.34,0.91,1],"h":1},{"t":72,"s":[0.89,0.89,0.89,1],"h":1},{"t":77,"s":[0,0.34,0.91,1],"h":1},{"t":82,"s":[0.89,0.89,0.89,1],"h":1},{"t":86,"s":[0,0.34,0.91,1],"h":1},{"t":91,"s":[0.89,0.89,0.89,1],"h":1},{"t":96,"s":[0,0.34,0.91,1],"h":1},{"t":103,"s":[0.89,0.89,0.89,1],"h":1},{"t":110,"s":[0,0.34,0.91,1],"h":1},{"t":117,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":180,"st":37},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-90]},{"t":109,"s":[301.64]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[198.75,244.5,0],"to":[-1.88,-52.9,0],"ti":[28.8,18.42,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[144.71,133.7,0],"to":[-28.64,-18.32,0],"ti":[23.57,-30.86,0]},{"t":109,"s":[58.5,148.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":68,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"t":109,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[29,30]},{"t":107,"s":[18,18.62]}]},"p":{"k":[0,0]},"r":{"k":211}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[0.96,0.81,0,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0.96,0.81,0,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0.96,0.81,0,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0.96,0.81,0,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0.96,0.81,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":4},"sa":{"k":0}}]}],"ip":65,"op":110,"st":34},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[-163]},{"t":120,"s":[-73.39]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[198.75,232.5,0],"to":[2.25,-107.5,0],"ti":[45.25,-88,0]},{"t":120,"s":[43.75,100,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":100,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":107,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,45,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":113,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[132,-64,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":119,"s":[130,-32,100]},{"t":120,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[22,22]},{"t":116,"s":[15,15]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":64,"s":[0.67,0,0.27,1],"h":1},{"t":67,"s":[0.89,0.89,0.89,1],"h":1},{"t":71,"s":[0.67,0,0.27,1],"h":1},{"t":76,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[0.67,0,0.27,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":90,"s":[0.67,0,0.27,1],"h":1},{"t":97,"s":[0.89,0.89,0.89,1],"h":1},{"t":104,"s":[0.67,0,0.27,1],"h":1},{"t":111,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[0.67,0,0.27,1],"h":1},{"t":118,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":64,"op":121,"st":-53},{"ind":9,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[-29]},{"t":131,"s":[306]}]},"p":{"a":1,"k":[{"i":{"x":0.43,"y":0.94},"o":{"x":0,"y":0},"t":70,"s":[315.75,212.5,0],"to":[84.25,-131.5,0],"ti":[9.5,-151.25,0]},{"t":133,"s":[444.5,412.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":71,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":108,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"t":123,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":133,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[7.5,-7.5],[7.5,7.5],[0.09,7.5],[-7.5,7.5],[-7.5,-7.5],[-1.31,-7.5]],"c":true}]},{"t":140,"s":[{"i":[[-1.04,1.29],[-0.3,-3.48],[1.31,-2.19],[0,0],[0,0],[-1.41,1.63]],"o":[[0.26,3.49],[0.29,3.4],[-1.99,3.33],[0,0],[0,0],[1.17,-1.35]],"v":[[-2.19,-14.3],[-0.07,-2.07],[-0.67,5.04],[-7.5,7.5],[-7.5,-7.5],[-4.66,-10.34]],"c":true}]}]}},{"ty":"fl","c":{"a":1,"k":[{"t":71,"s":[1,0.61,0,1],"h":1},{"t":74,"s":[0.89,0.89,0.89,1],"h":1},{"t":79,"s":[1,0.61,0,1],"h":1},{"t":84,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0.61,0,1],"h":1},{"t":93,"s":[0.89,0.89,0.89,1],"h":1},{"t":98,"s":[1,0.61,0,1],"h":1},{"t":105,"s":[0.89,0.89,0.89,1],"h":1},{"t":112,"s":[1,0.61,0,1],"h":1},{"t":119,"s":[0.89,0.89,0.89,1],"h":1},{"t":124,"s":[1,0.61,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":70,"op":219,"st":39},{"ind":10,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-29]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[186]},{"t":130,"s":[188]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[312.75,231.5,0],"to":[-0.58,-8.79,0],"ti":[-27.32,0.19,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[337,137.9,0],"to":[27.32,-0.19,0],"ti":[5.65,-84.54,0]},{"t":130,"s":[365.5,416.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":103,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,-100,100]},{"t":130,"s":[100,40,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"k":[22,22]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":66,"s":[0,0.86,0.06,1],"h":1},{"t":69,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0,0.86,0.06,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0,0.86,0.06,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0,0.86,0.06,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0,0.86,0.06,1],"h":1},{"t":114,"s":[0.89,0.89,0.89,1],"h":1},{"t":121,"s":[0,0.86,0.06,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":65,"op":214,"st":34},{"ind":11,"ty":4,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[100]},{"t":124,"s":[0]}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[126]},{"t":124,"s":[349]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":0.68},"o":{"x":0,"y":0},"t":68,"s":[274,232.25,0],"to":[14,-95.25,0],"ti":[-3.5,70.25,0]},{"t":124,"s":[416.5,52.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":116,"s":[100,100,100]},{"t":124,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[20,20]},{"t":124,"s":[14,14]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":72,"s":[1,0.92,0,1],"h":1},{"t":75,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[1,0.92,0,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":89,"s":[1,0.92,0,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":99,"s":[1,0.92,0,1],"h":1},{"t":106,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[1,0.92,0,1],"h":1},{"t":120,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":217,"st":37},{"ind":12,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[54]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":76,"s":[-18]},{"t":126,"s":[18]}]},"p":{"a":1,"k":[{"i":{"x":0.33,"y":0.63},"o":{"x":0.02,"y":0},"t":63,"s":[199,223.25,0],"to":[-1.41,-14.49,0],"ti":[30.23,18.1,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.19,"y":0.18},"t":69,"s":[164.23,151.1,0],"to":[-30.23,-18.1,0],"ti":[-19.69,-81.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83.75,"s":[108.31,217.87,0],"to":[19.69,81.13,0],"ti":[-18.51,-96.26,0]},{"t":126,"s":[36.5,421.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":67,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":105,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[-100,100,100]},{"t":126,"s":[-100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":63,"s":[22,22]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[32,32]},{"t":126,"s":[26,9.75]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[1,0,0.86,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":75,"s":[1,0,0.86,1],"h":1},{"t":80,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0,0.86,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":101,"s":[1,0,0.86,1],"h":1},{"t":110,"s":[0.89,0.89,0.89,1],"h":1},{"t":120,"s":[1,0,0.86,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":63,"op":212,"st":32},{"ind":13,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[50]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[-6]},{"t":103,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[250.5,249,0],"to":[-2.6,-22.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":69,"s":[190.72,157.91,0],"to":[-69.01,-1.59,0],"ti":[-1.05,-109.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[121.5,435.5,0],"to":[-0.24,-0.22,0],"ti":[-0.07,-2.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[121.37,423.98,0],"to":[0.05,2.14,0],"ti":[0,0,0]},{"t":99,"s":[121.5,438,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[-75.1,0.61],[-73.64,-0.85],[-73.64,-5.1],[-74.88,-6.34],[-91.34,-3.66],[-80.67,-5.29],[-79.81,-11.27],[-87.16,-18.6],[-93.1,-18.6],[-101.01,-10.7],[-85.3,-7.62],[-105.15,-4.28],[-106.62,-2.81],[-106.62,1.43],[-105.38,2.68],[-88.91,-0.01],[-99.58,1.63],[-100.44,7.6],[-93.1,14.94],[-87.16,14.94],[-79.24,7.03],[-94.96,3.95],[-75.1,0.61]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[-90,-4]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":99,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,2]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[115]},{"i":{"x":[0.83],"y":[0.79]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[459]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":112,"s":[353]},{"t":122,"s":[359]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[291.5,240,0],"to":[-2.6,-19.52,0],"ti":[-37.7,-0.28,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":71,"s":[329.72,135.91,0],"to":[54.99,0.41,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[386.5,440,0],"to":[-7,-25,0],"ti":[17,0.5,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[345.5,440,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[335.5,440,0],"to":[6.75,0.38,0],"ti":[0,0,0]},{"t":122,"s":[350.5,455,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[-100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[-316.6,-72.39],[-315.13,-73.85],[-315.13,-78.1],[-316.38,-79.34],[-332.84,-76.66],[-322.17,-78.29],[-321.31,-84.27],[-328.66,-91.6],[-334.6,-91.6],[-342.51,-83.7],[-326.8,-80.62],[-346.65,-77.28],[-348.12,-75.81],[-348.12,-71.57],[-346.88,-70.32],[-330.41,-73.01],[-341.08,-71.37],[-341.94,-65.4],[-334.6,-58.06],[-328.66,-58.06],[-320.74,-65.97],[-336.46,-69.05],[-316.6,-72.39]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":112,"s":[677.5,165.83],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":117,"s":[677.5,160.33],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":122,"s":[677.5,128.83],"to":[0,0],"ti":[0,0]},{"t":137,"s":[677.5,128.83]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":122,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":15,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-59]},{"i":{"x":[0.49],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":69,"s":[-30]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[12]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-14]},{"t":96,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[290.5,195,0],"to":[0.4,-33.52,0],"ti":[-40.98,-0.41,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[356.72,126.91,0],"to":[40.99,0.41,0],"ti":[0.95,-126.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[420.5,419,0],"to":[-0.41,-0.39,0],"ti":[-0.05,-33.51,0]},{"t":96,"s":[420.5,419,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[0,0,100]},{"t":65,"s":[90,90,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.9,-1.89],[16.36,-3.35],[16.36,-7.6],[15.12,-8.84],[-1.34,-6.16],[9.33,-7.79],[10.19,-13.77],[2.84,-21.1],[-3.1,-21.1],[-11.01,-13.2],[4.7,-10.12],[-15.15,-6.78],[-16.62,-5.31],[-16.62,-1.07],[-15.38,0.18],[1.09,-2.51],[-9.58,-0.87],[-10.44,5.1],[-3.1,12.44],[2.84,12.44],[10.76,4.53],[-4.96,1.45],[14.9,-1.89]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,95.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,40]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,20]},{"t":97,"s":[100,40]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,3]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,42]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,26]},{"t":97,"s":[100,42]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[346.44,208.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[79,56.3,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":180,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":149,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":161,"s":[100,100]},{"t":180,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":149,"op":180,"st":92},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":141,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":166,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":129,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":141,"s":[100,100]},{"t":166,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":129,"op":166,"st":72},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,184.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":122,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":145,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":114,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":122,"s":[100,100]},{"t":145,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":114,"op":146,"st":57},{"ind":19,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":99,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":136,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":99,"op":137,"st":42},{"ind":20,"ty":4,"ks":{"o":{"a":1,"k":[{"t":45,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"r":{"k":0},"p":{"k":[284.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":82,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":45,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":57,"s":[100,100]},{"t":82,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":45,"op":83,"st":-12},{"ind":21,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":56,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":25,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":33,"s":[100,100]},{"t":56,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":25,"op":57,"st":-32},{"ind":22,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":12,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":37,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":0,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":12,"s":[100,100]},{"t":37,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":38,"st":-57},{"ind":23,"ty":4,"parent":27,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[257.66,216.8,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[257.66,195.11,0],"to":[0,0,0],"ti":[0,0,0]},{"t":74,"s":[257.66,196.8,0]}],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"t":66,"s":[100,50,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.13,-28.48],[-50.62,19.68],[-18.12,-3.73],[14.13,40.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"t":63,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":65,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.75,-16.98],[26.41,20.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-32.08,-18.25],[-14.49,-16.96],[-15.39,-18.55],[-52.77,-20.68]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":69,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-42.74,-31.07],[-14.97,-46.8],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"t":68,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.06,-20.19],[-18.55,-45.47],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[2.59,-1.44],[37.94,1.18],[-6.25,14.1],[-24.19,4.01]],"o":[[-19.07,-0.37],[-2.16,-0.07],[10.87,19.21],[-1.7,0.73]],"v":[[48.4,19.5],[-17.41,53.75],[-6.96,-10.32],[71.12,18.18]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[1.94,-1.08],[6.24,-1.19],[15.02,17.72],[-16.78,-17.45]],"o":[[-3.39,20.66],[-1.59,0.3],[8.15,14.41],[-1.28,0.54]],"v":[[47.38,-11.46],[33.39,27.91],[-7.43,15.2],[48.16,-10.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[{"i":[[1.3,-0.72],[4.59,9.5],[7.91,-2.82],[-5.41,1.07]],"o":[[-0.4,-8.63],[-0.47,-0.97],[10.84,16.61],[-0.85,0.36]],"v":[[54.18,-18.16],[38.44,-59.82],[16.53,-34.2],[42.57,-26.83]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":71,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":24,"ty":4,"parent":27,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":68,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[256.46,249.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[256.46,225.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[256.46,209.72,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[256.46,206.72,0]}],"l":2},"a":{"k":[256.46,249.72,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,36,100]},{"t":67,"s":[100,14,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[1.75,-10.16],[-45.92,7.16],[-17.13,19.11],[30,-1.05]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-56.04,12.3],[-19.24,23.32],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.1,11.25],[-18.36,20.12],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[0.71,-11.86],[-45.71,6.16],[-16.92,18.11],[34.21,-0.66]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":25,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.96,347.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":26,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[100],"h":1},{"t":81,"s":[0],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.9,0.11],[16.36,-1.35],[16.36,-5.6],[15.12,-6.84],[-1.34,-4.16],[9.33,-5.79],[10.19,-11.77],[2.84,-19.1],[-3.1,-19.1],[-11.01,-11.2],[4.7,-8.12],[-15.15,-4.78],[-16.62,-3.31],[-16.62,0.93],[-15.38,2.17],[1.09,-0.51],[-9.58,1.13],[-10.44,7.1],[-3.1,14.44],[2.84,14.44],[10.76,6.53],[-4.96,3.45],[14.9,0.11]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":27,"ty":4,"parent":28,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":72,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":72,"s":[257.27,128.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1,-61.13],[131.24,-49.8],[-1.2,-40.13],[-132.88,-50.13]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"t":72,"s":[81,81]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":28,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":29,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[-198]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-174]},{"t":97,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[220.5,269,0],"to":[-2.6,-19.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[157.72,193.91,0],"to":[-69.01,-1.59,0],"ti":[2.95,-91.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[83.5,374,0],"to":[-0.41,-0.39,0],"ti":[-3.5,-7,0]},{"t":97,"s":[87.5,374,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[84,84,100]},{"t":88,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.4,3.61],[15.87,2.15],[15.87,-2.1],[14.62,-3.34],[-1.84,-0.66],[8.83,-2.29],[9.69,-8.27],[2.34,-15.6],[-3.6,-15.6],[-11.51,-7.7],[4.2,-4.62],[-15.65,-1.28],[-17.12,0.19],[-17.12,4.43],[-15.88,5.68],[0.59,2.99],[-10.08,4.63],[-10.94,10.6],[-3.6,17.94],[2.34,17.94],[10.26,10.03],[-5.46,6.95],[14.4,3.61]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346.5,89.83]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[100,24]},{"t":97,"s":[100,12]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[0,-3],"to":[0,0],"ti":[0,0]},{"t":97,"s":[0,-2.5]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[91,28]},{"t":97,"s":[91,22]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":30,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[-180]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[-207]},{"t":93,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[296.5,278,0],"to":[2.4,-15.52,0],"ti":[-54.47,3.14,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[348.72,155.91,0],"to":[56.66,-3.26,0],"ti":[-0.05,-138.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[428.5,358,0],"to":[-4.91,-22.89,0],"ti":[0.5,-14.75,0]},{"t":93,"s":[402.5,376,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[100,100,100]},{"t":85,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[15.41,2.6],[16.88,1.13],[16.88,-3.11],[15.63,-4.36],[-0.83,-1.67],[9.84,-3.31],[10.7,-9.28],[3.36,-16.62],[-2.58,-16.62],[-10.5,-8.71],[5.22,-5.64],[-14.64,-2.3],[-16.11,-0.83],[-16.11,3.42],[-14.86,4.66],[1.6,1.98],[-9.07,3.61],[-9.93,9.59],[-2.59,16.92],[3.36,16.92],[11.27,9.02],[-4.44,5.94],[15.41,2.6]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[345.49,88.85]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,-100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[100,0]},{"t":93,"s":[100,-24]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":91,"s":[0,-4]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[91,4]},{"t":93,"s":[100,31]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":31,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.48,312.85,0],"l":2},"a":{"k":[266.48,312.85,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[557.3,4.04],[558.77,2.58],[558.77,-1.67],[557.53,-2.91],[541.06,-0.23],[551.73,-1.86],[552.59,-7.84],[545.25,-15.17],[539.31,-15.17],[531.39,-7.27],[547.11,-4.19],[527.25,-0.85],[525.78,0.62],[525.78,4.86],[527.03,6.1],[543.49,3.42],[532.82,5.06],[531.96,11.03],[539.3,18.37],[545.25,18.37],[553.16,10.46],[537.45,7.38],[557.3,4.04]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-377.14,91.81]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[231.5,294],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[231.5,283],"to":[0,0],"ti":[0,0]},{"t":74,"s":[231.5,294]}]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":96},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[375.04,12.73],[376.51,11.26],[376.51,7.01],[375.26,5.77],[358.8,8.45],[369.47,6.82],[370.33,0.84],[362.99,-6.49],[357.04,-6.49],[349.13,1.42],[364.84,4.49],[344.99,7.83],[343.52,9.3],[343.52,13.55],[344.77,14.79],[361.23,12.11],[350.56,13.74],[349.7,19.72],[357.04,27.05],[362.99,27.05],[370.9,19.14],[355.19,16.07],[375.04,12.73]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-194.88,83.13]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[189,270.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[189,263.5],"to":[0,0],"ti":[0,0]},{"t":74,"s":[189,270.5]}]},"a":{"k":[166,92]},"s":{"k":[103.56,52.65]},"r":{"k":-3.75},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[374.91,-1.86],[376.38,-3.33],[376.38,-7.58],[375.13,-8.82],[358.67,-6.14],[369.34,-7.77],[370.2,-13.75],[362.86,-21.08],[356.91,-21.08],[349,-13.17],[364.71,-10.1],[344.86,-6.76],[343.39,-5.29],[343.39,-1.04],[344.64,0.2],[361.1,-2.48],[350.43,-0.85],[349.57,5.13],[356.91,12.46],[362.85,12.46],[370.77,4.55],[355.05,1.48],[374.91,-1.86]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-194.75,97.72]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,10]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[329.5,274],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[329.5,262],"to":[0,0],"ti":[0,0]},{"t":75,"s":[329.5,274]}]},"a":{"k":[166,92]},"s":{"k":[97,43.65]},"r":{"k":14},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[192.85,4.3],[194.32,2.83],[194.32,-1.41],[193.08,-2.66],[176.62,0.03],[187.29,-1.61],[188.15,-7.58],[180.8,-14.92],[174.86,-14.92],[166.94,-7.01],[182.66,-3.94],[162.81,-0.6],[161.34,0.87],[161.34,5.12],[162.58,6.36],[179.04,3.68],[168.37,5.31],[167.51,11.29],[174.86,18.62],[180.8,18.62],[188.72,10.72],[173,7.64],[192.85,4.3]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-12.7,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[238.5,264.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[238.5,254.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[238.5,264.5]}]},"a":{"k":[166,92]},"s":{"k":[100,39]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.35,4.3],[15.82,2.83],[15.82,-1.41],[14.58,-2.66],[-1.88,0.03],[8.79,-1.61],[9.65,-7.58],[2.3,-14.92],[-3.64,-14.92],[-11.56,-7.01],[4.16,-3.94],[-15.69,-0.6],[-17.16,0.87],[-17.16,5.12],[-15.92,6.36],[0.54,3.68],[-10.13,5.31],[-10.99,11.29],[-3.64,18.62],[2.3,18.62],[10.22,10.72],[-5.5,7.64],[14.35,4.3]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.666666686535,0.023529414088,0.239215701818,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,12]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[285.5,268.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[285.5,258.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[285.5,268.5]}]},"a":{"k":[166,92]},"s":{"k":[100,32]},"r":{"k":-8},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.35,4.3],[15.82,2.83],[15.82,-1.41],[14.58,-2.66],[-1.88,0.03],[8.79,-1.61],[9.65,-7.58],[2.3,-14.92],[-3.64,-14.92],[-11.56,-7.01],[4.16,-3.94],[-15.69,-0.6],[-17.16,0.87],[-17.16,5.12],[-15.92,6.36],[0.54,3.68],[-10.13,5.31],[-10.99,11.29],[-3.64,18.62],[2.3,18.62],[10.22,10.72],[-5.5,7.64],[14.35,4.3]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,7]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.024944704026,0.642616450787,0.550229668617,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[271.5,254],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[271.5,245],"to":[0,0],"ti":[0,0]},{"t":71,"s":[271.5,254]}]},"a":{"k":[166,92]},"s":{"k":[77,28.36]},"r":{"k":3.04},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.35,4.3],[15.82,2.83],[15.82,-1.41],[14.58,-2.66],[-1.88,0.03],[8.79,-1.61],[9.65,-7.58],[2.3,-14.92],[-3.64,-14.92],[-11.56,-7.01],[4.16,-3.94],[-15.69,-0.6],[-17.16,0.87],[-17.16,5.12],[-15.92,6.36],[0.54,3.68],[-10.13,5.31],[-10.99,11.29],[-3.64,18.62],[2.3,18.62],[10.22,10.72],[-5.5,7.64],[14.35,4.3]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":108,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":119,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":137,"s":[5]},{"t":143,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[284.5,298]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":100},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":32,"ty":4,"parent":28,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":33,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[0],"h":1},{"t":81,"s":[100],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-0.53,0.53],[1.18,1.17],[0,0],[5.34,2.66],[-3.32,1.78],[1.78,1.78],[0,0],[1.64,-1.64],[0,0],[-4.24,-4.26],[5.37,-5.36],[0.53,-0.53],[-1.17,-1.17],[0,0],[-5.34,-2.66],[3.32,-1.78],[-1.78,-1.78],[0,0],[-1.64,1.64],[0,0],[4.24,4.26],[-5.37,5.36]],"o":[[0.41,-0.41],[1.17,-1.17],[0,0],[-4.45,4.45],[3.61,0.67],[2.22,-1.19],[0,0],[-1.64,-1.64],[0,0],[5.21,-2.21],[-6.47,-3.14],[-0.41,0.41],[-1.17,1.17],[0,0],[4.45,-4.45],[-3.61,-0.67],[-2.22,1.19],[0,0],[1.64,1.64],[0,0],[-5.21,2.21],[6.47,3.14],[0,0]],"v":[[14.78,-0.63],[16.25,-2.1],[16.25,-6.35],[15.01,-7.59],[-1.46,-4.91],[9.22,-6.54],[10.07,-12.52],[2.73,-19.85],[-3.21,-19.85],[-11.13,-11.94],[4.59,-8.87],[-15.26,-5.53],[-16.73,-4.06],[-16.73,0.19],[-15.49,1.43],[0.97,-1.25],[-9.7,0.38],[-10.56,6.35],[-3.21,13.69],[2.73,13.69],[10.65,5.78],[-5.07,2.71],[14.78,-0.63]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346,93.33]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.666452229023,0.022664288059,0.238545268774,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[5]},{"t":116,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.898039221764,0.243137255311,0.46274510026,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":34,"ty":4,"parent":28,"ks":{"o":{"a":1,"k":[{"t":56,"s":[0],"h":1},{"t":72,"s":[100],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.34,"y":0},"t":72,"s":[257.27,128.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":91,"s":[257.27,342.53,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.48,-54.5],[131.76,-43.17],[-0.67,-33.5],[-132.36,-43.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.77,33.72],[131.48,-1.28],[-0.92,-32.28],[-132.64,-1.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.76,5.8],[131.57,4.14],[-0.9,5.8],[-132.55,3.8]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1.15,-23.5],[131.16,-19.17],[-1.28,-10.75],[-132.97,-19.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.05,-64.7],[132.03,-18.36],[-0.09,37.3],[-132.09,-18.7]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,-99.54],[132.16,0.65],[-0.09,95.58],[-131.96,0.98]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1},{"t":79,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[64.68,-94.47],[-66.93,-61.2],[-66.95,-36.54],[64.92,-50.71]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.53,-53.52],[-66.43,-54.53],[-66.43,-31.2],[65.4,-28.87]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-53.02],[-66.03,-54.03],[-66.03,-30.7],[65.97,26.63]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,61.22],[-65.96,-33.37],[-66.01,-34.68],[66.04,60.58]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65,-36.37],[-67.1,-51.21],[-67.19,-94.97],[65.09,-60.63]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.52,-31.03],[-66.61,-29.37],[-66.33,-54.02],[65.59,-53.95]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.92,-30.53],[-66.04,26.13],[-65.93,-53.52],[65.99,-53.45]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-35.85],[-65.98,60.07],[-65.91,60.71],[66.05,-34.94]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":72,"s":[81,81]},{"t":83,"s":[73,73]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0}]},{"id":"comp_1","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":181,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":144,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":156,"s":[100,100]},{"t":181,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":144,"op":181,"st":87},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[336.44,166.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":136,"s":[100,100]},{"t":161,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":124,"op":162,"st":67},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[190.44,214.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":117,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":109,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":117,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":109,"op":141,"st":52},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":103,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":114,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[100,100]},{"t":114,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":95,"op":115,"st":38},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":100,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":81,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":89,"s":[100,100]},{"t":100,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":81,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":100,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":81,"op":101,"st":24},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":90,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":73,"s":[100,100]},{"t":90,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":90,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":65,"op":91,"st":8},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,221.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":69,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":50,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":58,"s":[100,100]},{"t":69,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":50,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":69,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":50,"op":70,"st":-7},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[323.44,178.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":49,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":60,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":41,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":49,"s":[100,100]},{"t":60,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":41,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":60,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":41,"op":61,"st":-16},{"ind":9,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257.66,216.8,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-43.5],[-50.62,19.68],[-19.83,-18.75],[12.42,25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.08,-16.07],[17.57,-27.27],[-8.47,2.39],[30.67,-18.15]],"o":[[-15.8,5.37],[10.67,-14.43],[36.88,-10.55],[63.53,-17.02]],"v":[[1.03,-25.49],[-50.62,19.68],[-18.22,-2.26],[14.03,39.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"t":83,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-32],[24.7,5.25],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.13],[15.01,-7.06],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,17.55],[-23.68,11.14],[4.47,0.56]],"v":[[-34.14,-8.48],[52.65,-15.65],[26.31,20],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"t":83,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-25.54,-39.48],[3.89,-52.46],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-14.47],[-8.77,1.66],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.23],[26.18,-4.96],[-7.91,6.92]],"v":[[28.2,44.02],[-47.21,-19.87],[-19.44,-35.04],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"t":83,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-32.04,-23.56],[-1.35,-53.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,23.79],[-20,4.13],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.37],[-16,22.94],[-7,3.5]],"v":[[44.51,48.89],[-46.54,-9.48],[-23.02,-33.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"t":83,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":10,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.76,93.8],[14.58,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.58,93.8],[14.76,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":11,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[257.27,248.75,0],"to":[0,14.83,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[257.27,218.64,0],"to":[0,0,0],"ti":[0,-8.61,0]},{"t":34,"s":[257.27,248.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":35},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":12,"ty":4,"parent":14,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-28.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":13,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":32,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[-1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":66,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[-1.5]},{"t":81,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.17,"y":0.17},"t":0,"s":[257,739,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.13},"t":20,"s":[257,379,0],"to":[0,0,0],"ti":[0,0,0]},{"t":32,"s":[257,405,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":9,"s":[97,105,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":36,"s":[104,96,100]},{"t":43,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":0,"cl":"Present3","refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":180,"op":360,"st":180},{"ind":2,"ty":0,"cl":"Present2","refId":"comp_1","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/ksm_packing.json b/common/src/main/res/raw/ksm_packing.json new file mode 100644 index 0000000..488ef4b --- /dev/null +++ b/common/src/main/res/raw/ksm_packing.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":300,"w":512,"h":512,"assets":[],"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":275,"s":[80]},{"t":297,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":263,"s":[18]},{"t":275,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":275,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":300,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":263,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":275,"s":[100,100]},{"t":300,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":263,"op":300,"st":206},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[344.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":255,"s":[80]},{"t":277,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":243,"s":[18]},{"t":255,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":255,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":280,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":243,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":255,"s":[100,100]},{"t":280,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":243,"op":281,"st":186},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":236,"s":[80]},{"t":256,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":228,"s":[18]},{"t":236,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":236,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":259,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":228,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":236,"s":[100,100]},{"t":259,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":228,"op":260,"st":171},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":222,"s":[80]},{"t":230,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":214,"s":[18]},{"t":222,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":222,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":233,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":214,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":222,"s":[100,100]},{"t":233,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":214,"op":234,"st":157},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":208,"s":[80]},{"t":216,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":200,"s":[18]},{"t":208,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":208,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":219,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":200,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":208,"s":[100,100]},{"t":219,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":200,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":219,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":200,"op":220,"st":143},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":59,"s":[80]},{"t":73,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":51,"s":[18]},{"t":59,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":76,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":51,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":59,"s":[100,100]},{"t":76,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":51,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":76,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":51,"op":77,"st":-6},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[200.44,241.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":44,"s":[80]},{"t":52,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":36,"s":[18]},{"t":44,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":55,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":36,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":44,"s":[100,100]},{"t":55,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":36,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":55,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":36,"op":56,"st":-21},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[343.44,238.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[80]},{"t":43,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":27,"s":[18]},{"t":35,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":35,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":46,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":27,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":35,"s":[100,100]},{"t":46,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":27,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":46,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":27,"op":47,"st":-30},{"ind":9,"ty":4,"parent":41,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50.66,-184.2,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[11,2],[0,0],[-2.75,-7.75],[20.5,-7]],"o":[[-10,8.5],[0,0],[1.12,3.14],[40,-13]],"v":[[42.08,-17.98],[22.38,-6.82],[31.83,10.77],[14.08,31.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":171,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":172,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[46.06,-12.83],[18.2,-29.66],[-8.71,1.58],[30.67,-18.97]],"o":[[-16.15,4.5],[10.3,-11.21],[35.76,-6.47],[63.53,-17.79]],"v":[[0.89,-20.37],[-50.62,19.68],[-18.36,2.16],[13.89,45.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-36.51],[-50.62,19.68],[-19.83,-11.75],[12.42,32]],"c":true}]},{"t":209,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":170,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-29.64,-5.98],[-0.31,4.52],[-26.64,18.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":169,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":174,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-11.09],[26.17,26.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-25],[24.7,12.25],[-56.19,5.91]],"c":true}]},{"t":209,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":155,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.06,-13],[-19.29,-28.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-35.04,-33.48],[-5.61,-46.46],[47.19,27.25]],"c":true}]},{"t":209,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":168,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":163,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":166,"s":[{"i":[[2.59,-1.44],[13.98,27.91],[-8.75,-2.33],[-23.7,-7.47]],"o":[[-20.01,-7.24],[-0.97,-1.93],[2.46,22.32],[-1.7,0.73]],"v":[[44,48.14],[-36.97,-2.51],[-7.45,-15.43],[72.59,35.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":170,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":173,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":180,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.38,-2.11],[-22.87,-27.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.73,"y":0},"t":190,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":199,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-41.54,-17.57],[-10.85,-47.87],[71.01,35.39]],"c":true}]},{"t":209,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":154,"s":[0],"h":1},{"t":161,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":10,"ty":4,"parent":41,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[49.33,-79.33,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":154,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[16.25,-17.11],[15.33,-34.84],[44.12,-24.39],[45.75,-6.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":159,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.25,-17.11],[15.83,-16.84],[46.12,-6.39],[45.75,-6.61]],"c":true}]},{"t":162,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":154,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":158,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":160,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":145,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":149,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":155,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":144,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":142,"s":[{"i":[[0.17,-5.08],[-6.42,-1.19],[-6.29,0.03],[2.05,0.73]],"o":[[6.42,4.17],[-0.42,-3.19],[-4.29,-3.22],[-8.45,2.23]],"v":[[-58.62,163.48],[-29.54,175.84],[-19.42,163.37],[-47.5,149.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[-5.25,-1.97],[3.75,0.62]],"o":[[0,0],[0,0],[-4.29,-3.22],[-6.16,-1.02]],"v":[[-52.16,150.73],[-26.08,162.59],[-19.46,158.87],[-46.54,145.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.12,130.98],[-29.04,142.84],[-18.92,130.37],[-47,118.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[-11.79,1.03],[3.8,-0.02]],"o":[[0,0],[0,0],[-4.29,-3.22],[-9.57,0.04]],"v":[[-55.04,19.98],[-28.96,31.84],[-18.84,19.37],[-46.92,7.92]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":151,"s":[{"i":[[2.08,-0.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[14.75,-2.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[-4.96,-0.08],[-33.95,-12.43]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[42.08,-15.83],[0.25,-0.44],[5.25,4.73],[5,1.83]],"o":[[0.58,0.17],[56,-19.44],[-3.25,-2.02],[10.5,18.58]],"v":[[-48.29,6.73],[-19.21,19.84],[23.79,-15.58],[-5.2,-27.93]],"c":true}]},{"t":154,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":143,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,60.44],[-14.67,64.29],[14.67,78.56],[14.83,74.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":144,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.5,35.44],[-14.67,64.29],[14.67,78.56],[14.92,45.19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":150,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[2.79,1.56],[-7.67,-20.74],[-1.09,-1.13],[-10.64,16.33]],"o":[[-13.58,24.89],[0.06,-0.74],[-5.64,-12.24],[0.73,-1.44]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[3.44,1.3],[4.11,-24.73],[-0.56,-0.82],[3,49.67]],"o":[[6.22,30.39],[3,2.08],[3.89,-27.64],[0.22,0.12]],"v":[[-14.67,-78.56],[-14.78,67.93],[14.33,80.84],[14.67,-65.56]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":141,"s":[0],"h":1},{"t":142,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[-7.95,1.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[9.05,1.48],[-10.25,5.03],[0,0]],"v":[[16.21,-18.11],[22.79,-27.34],[50.08,-19.39],[45.71,-7.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":157,"s":[{"i":[[0,0],[-8.95,11.48],[-5.75,-1.97],[0,0]],"o":[[0,0],[8.05,2.48],[-8.75,11.53],[0,0]],"v":[[-13.79,-5.11],[-14.71,-22.84],[14.08,-12.39],[15.71,5.39]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":153,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.21,-18.11],[15.79,-17.84],[46.08,-7.39],[45.71,-7.61]],"c":true}]},{"t":161,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":139,"s":[0],"h":1},{"t":153,"s":[100],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":11,"ty":4,"parent":41,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":120,"s":[50.27,-201.25,0],"to":[0,6.23,0],"ti":[0,-15.32,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":134,"s":[50.27,-284.64,0],"to":[0,21.19,0],"ti":[0,-8.61,0]},{"t":146,"s":[50.27,-152.25,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":120,"s":[0,0,100]},{"t":144,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.4,91.93],[131.21,19.33],[0.36,-48.29],[-131.32,19]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":130,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-52.85,28.76],[95.73,14.81],[62.88,1.73],[-97.23,18.68]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[83.8,18.9],[83.9,18.9],[83.72,18.4],[-85.86,18.57]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":132,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[73.69,-14.15],[90.29,-13.04],[73.61,-14.65],[-91.27,-13.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.04,-13.5],[132.06,-4.67],[-0.12,-14],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-85.27,-14.13],[83.47,-13.5],[83.29,-15.26],[-86.29,-13.83]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.28,0.28,0.87,1],"h":1},{"t":131,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[66.17,-103.68],[-66.06,-33.47],[-66,-17],[66.77,-83.74]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.9,-48.82],[-66,-40.33],[-66,-17],[66,-15.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[149.85,-50.24],[-19.68,-50.58],[-19.5,-17],[150,-16.96]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":131,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":134,"s":[0.4,0.42,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":135,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":145,"s":[0.4,0.42,1,1]},{"t":146,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":127,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-65.25,-84.24],[-65.69,-104.18],[65.97,-33.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":131,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":135,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,-15.67],[-65.97,-49.33],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":140,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.82,-23.84],[17.98,-17.47],[17.99,-50.75],[17.97,-50.51]],"c":true}]},{"t":146,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":127,"s":[0.4,0.42,1,1],"h":1},{"t":132,"s":[0.47,0.58,0.99,1],"h":1},{"t":135,"s":[0.4,0.42,1,1],"h":1},{"t":140,"s":[0.47,0.58,0.99,1],"h":1},{"t":146,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":132,"s":[0],"h":1},{"t":146,"s":[35],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"t":119,"s":[100,100],"h":1},{"t":123,"s":[-100,100],"h":1},{"t":127,"s":[100,100],"h":1},{"t":131,"s":[-100,100],"h":1},{"t":135,"s":[100,100],"h":1},{"t":140,"s":[-100,100],"h":1},{"t":146,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":12,"ty":4,"parent":41,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.85},"o":{"x":0.17,"y":0.17},"t":0,"s":[50,-98.75,0],"to":[0,1.68,0],"ti":[0,-4.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":13,"s":[50,-184.14,0],"to":[0,5.71,0],"ti":[0,-2.32,0]},{"t":26,"s":[50,-78.75,0]}],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":0,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":34,"s":[97,97,100]},{"t":50,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-62.26,55.67],[-62.73,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,47.17],[-61.96,76.67],[-61.81,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[37.23,-101.67],[37.73,22.67],[37.77,65.17],[37.3,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-57.72,99.67],[-57.69,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[-9.59,4.33],[14.9,-23.89],[0,0],[0,0]],"o":[[14.5,18.78],[-0.1,-0.56],[0,0],[0,0]],"v":[[61.73,-99.67],[61.79,33.56],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[-13.68,5.7],[-6.74,-51.36],[0,0],[0,0]],"o":[[-4.79,47.52],[0.48,-0.45],[0,0],[0,0]],"v":[[61.73,-99.67],[61.46,45.58],[-61.73,99.67],[-61.7,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":7,"s":[0.47,0.58,0.99,1],"h":1},{"t":10,"s":[0.4,0.42,1,1],"h":1},{"t":14,"s":[0.47,0.58,0.99,1],"h":1},{"t":17,"s":[0.4,0.42,1,1],"h":1},{"t":24,"s":[0.47,0.58,0.99,1],"h":1},{"t":31,"s":[0.4,0.42,1,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.4,55.67],[60.91,-132.42]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.89,47.17],[61.7,76.67],[61.84,-99.17]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-38.94,-102.17],[-39.44,65.67],[161.43,65.17],[160.94,-102.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[65.94,99.67],[65.95,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":168,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":180,"s":[{"i":[[10.47,2.67],[-18.22,-33.33],[0,0],[0,0]],"o":[[-10.44,12.11],[-0.04,-0.56],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.88,33.56],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":195,"s":[{"i":[[10.32,2.06],[8.37,-56.82],[0,0],[0,0]],"o":[[5.88,46.61],[0.04,-0.45],[0,0],[0,0]],"v":[[-61.94,-99.67],[-62.21,45.58],[61.93,99.67],[61.94,-47.67]],"c":true}]},{"t":208,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":7,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":9,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":10,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":13,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":16,"s":[0.42,0.47,1,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":17,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":23,"s":[0.47,0.58,0.99,1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":30,"s":[0.42,0.47,1,1]},{"t":31,"s":[0.47,0.58,0.99,1]}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":301,"st":0},{"ind":13,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":82,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[12.6]},{"t":102,"s":[96]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":82,"s":[116,96,0],"to":[86,26,0],"ti":[-1.25,-87.67,0]},{"t":102,"s":[290.5,297,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[100,84,100]},{"t":97,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":82,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":92,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":82,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[110,134]},{"t":92,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":101,"s":[28]},{"t":121,"s":[-3.75]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":101,"s":[104,122,0],"to":[54,7,0],"ti":[-19.25,-84.67,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[243.5,267,0],"to":[0.06,-3.71,0],"ti":[0,-3.54,0]},{"t":127,"s":[243.5,267,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[85,85,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":121,"s":[95.56,94.94,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":124,"s":[102.56,40.29,100]},{"t":127,"s":[103.56,52.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":121,"s":[0,0],"to":[0,-0.75],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":124,"s":[0,11],"to":[0,0],"ti":[0,0]},{"t":127,"s":[0,7]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":121,"s":[120,120]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[102.4,116]},{"t":127,"s":[102.4,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":15,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":96,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":116,"s":[31.25]},{"t":120,"s":[14]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":96,"s":[367,78,0],"to":[-18,29,0],"ti":[6.75,-76.67,0]},{"t":116,"s":[325.5,270,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[85,85,100]},{"t":116,"s":[97,43.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":116,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":106,"s":[116,116]},{"t":116,"s":[102.4,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[81.55]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":112,"s":[70.2]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[-10]},{"t":119,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":92,"s":[125,74,0],"to":[34,35,0],"ti":[-5.25,-79.67,0]},{"t":112,"s":[210.5,282,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":92,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[42.6,26.6,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[71,71,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":112,"s":[100,34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,54,100]},{"t":119,"s":[100,39,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[0,9],"to":[0,-0.15],"ti":[0,2.44]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,-8.41],"to":[0,-2.01],"ti":[0,-0.17]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":97,"s":[0,0],"to":[0,0.62],"ti":[0,0]},{"t":112,"s":[0,12]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":92,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[104.15,129.8]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":97,"s":[104.25,104]},{"t":112,"s":[105,130]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":97,"s":[-23]},{"t":101,"s":[-8]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":75,"s":[433,92,0],"to":[-77,4,0],"ti":[-1.25,-87.67,0]},{"t":97,"s":[238.5,296,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,100,100]},{"t":93,"s":[100,32,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":87,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":93,"s":[0,19]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":87,"s":[116,116]},{"t":93,"s":[105,125]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":100,"s":[-22.75]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":105,"s":[-22]},{"t":109,"s":[-9]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":85,"s":[367,78,0],"to":[-34,23,0],"ti":[6.75,-76.67,0]},{"t":105,"s":[264.5,279,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[77,77,100]},{"t":105,"s":[77,34.65,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[0,9],"to":[0,-0.75],"ti":[0,0.75]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":95,"s":[0,0],"to":[0,-1.5],"ti":[0,0]},{"t":105,"s":[0,10]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":85,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[116,116]},{"t":105,"s":[109,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":19,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[12.6]},{"t":90,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":70,"s":[427,116,0],"to":[-67,4,0],"ti":[-1.25,-87.67,0]},{"t":90,"s":[264.5,338,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[71,38.34,100]},{"t":80,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":80,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":70,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[110,134]},{"t":80,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":20,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[28]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[12.6]},{"t":80,"s":[100]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.35,"y":0},"t":60,"s":[75,92,0],"to":[105,30,0],"ti":[-1.25,-87.67,0]},{"t":80,"s":[242.5,340,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[71,38.34,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":70,"s":[100,84,100]},{"t":75,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[0,9],"to":[0,-0.75],"ti":[0,1.88]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[0,14.5],"to":[0,-1.87],"ti":[0,0.75]},{"t":70,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":60,"s":[104,116]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[110,134]},{"t":70,"s":[116,116]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":37,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":103,"s":[209.44,138.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":131,"s":[209.44,289.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[71,71,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":103,"op":141,"st":46},{"ind":38,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.76,"y":0},"t":94,"s":[317.44,178.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":120,"s":[317.44,338.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[102,102,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":102,"s":[80]},{"t":130,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":94,"s":[18]},{"t":102,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":102,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":133,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":94,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":102,"s":[100,100]},{"t":133,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":94,"op":134,"st":37},{"ind":39,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":70,"s":[202.44,133.19,0],"to":[0,0,0],"ti":[0,0,0]},{"t":97,"s":[202.44,349.19,0]}],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[127,127,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":78,"s":[80]},{"t":102,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[18]},{"t":78,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":105,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.992156922583,0.803921628466,0.035294117647,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156862745,0.803921568627,0.035294117647,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":70,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":78,"s":[100,100]},{"t":105,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":106,"st":13},{"ind":40,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":7,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":10,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.01,-33.33],[123.51,-1.58],[-0.61,-33.67],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":13,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":17,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.93,-0.08],[123.51,-1.58],[0.31,-0.42],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":23,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-64.67,-3.08],[99.01,-3.58],[99.42,-3.92],[-100.51,-3.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":38,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-3.67,-51.08],[123.51,-1.58],[4.43,51.08],[-123.51,-1.25]],"c":true}]},{"t":48,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"a":1,"k":[{"t":0,"s":[100,100],"h":1},{"t":7,"s":[-100,100],"h":1},{"t":10,"s":[100,100],"h":1},{"t":13,"s":[-100,100],"h":1},{"t":17,"s":[100,100],"h":1},{"t":23,"s":[-100,100],"h":1},{"t":31,"s":[100,100],"h":1}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":151,"st":0},{"ind":41,"ty":3,"ks":{"o":{"k":0},"r":{"k":0},"p":{"k":[257,451,0],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":18,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":26,"s":[103,97,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[97,103,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":43,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":94,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":98,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":122,"s":[101,99,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":126,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":145,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.5,0.5,0.5],"y":[0,0,0]},"t":149,"s":[102,98,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":155,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.85,1.14,-0.67]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":168,"s":[99,102,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,1.17,4.33]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":180,"s":[110,90,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[0.75,0.75,0.75],"y":[0,0,0]},"t":195,"s":[90,110,100]},{"t":208,"s":[100,100,100]}],"l":2}},"ip":0,"op":264,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/ksm_unpacking.json b/common/src/main/res/raw/ksm_unpacking.json new file mode 100644 index 0000000..c030c8c --- /dev/null +++ b/common/src/main/res/raw/ksm_unpacking.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":360,"w":512,"h":512,"assets":[{"id":"comp_0","layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[248.14,284.55,0],"l":2},"a":{"k":[14,-1,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-72,39],[-152,111]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.86,45],[3,185]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[61,31],[156,113]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"t":69,"s":[73]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[0]},{"t":67,"s":[73]}]},"o":{"k":0},"m":1}],"ip":60,"op":177,"st":-3},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":-208.79},"p":{"k":[90.94,257.49,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[73.08,73.08,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.59,-21.92],[27.98,-23.86],[-36.09,-3.9],[3.75,11.83],[-1.96,-59.37],[-44.64,5.65],[18.27,13.23]],"o":[[1.21,44.71],[-24.04,20.51],[7.95,0.86],[-8.39,-26.51],[1.32,39.94],[41.51,-5.26],[-13.06,-9.45]],"v":[[-134.97,-34.32],[-81.98,23.04],[-72.15,98.43],[-50.97,80.06],[-134.14,144.99],[-59.15,210.78],[-26.48,151.25]],"c":false}}},{"ty":"st","c":{"k":[0.949019667682,0.870865167356,0,1]},"o":{"k":100},"w":{"k":10},"lc":1,"lj":1,"ml":4},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"t":122,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[25.52]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[90]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":177,"st":-3},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":-322},"p":{"k":[191.66,150.67,0],"l":2},"a":{"k":[127.48,-143.62,0],"l":2},"s":{"k":[-190,190,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-4.76,4.16],[-5.58,-2.96],[0.86,-2.32],[1.98,1.6],[-0.17,2.54],[-4.87,2.42],[-4.55,-2.99],[0.82,-1.61],[1.23,1.05],[0.03,1.62],[-5.55,1.61],[-2.45,-2.91]],"o":[[1.22,-6.2],[4.76,-4.16],[2.19,1.16],[-0.89,2.39],[-1.98,-1.6],[0.36,-5.43],[4.88,-2.42],[1.51,1],[-0.73,1.44],[-1.23,-1.05],[-0.1,-5.78],[3.66,-1.06],[0,0]],"v":[[96.85,-121.52],[105.41,-138.05],[123,-140.94],[126.33,-135.01],[119.89,-133.82],[117.49,-140.76],[126.14,-153.74],[141.72,-152.8],[143.88,-148.35],[139.69,-147.87],[138.04,-152.28],[147.62,-165.26],[158.1,-162.64]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[10]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[38]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[63]},{"t":100,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[99]},{"t":100,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0,0.602906589882,0.949019607843,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[244.14,221.55,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[56,56,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[13.57,11.19],[-31.1,23.53],[44.5,-0.5],[0.21,-20],[-89,21]],"o":[[-12.84,-10.6],[37,-28],[-34.23,0.39],[-1,97],[35.17,-8.3]],"v":[[0.84,68.6],[14,13],[-11.5,-83.5],[-43,64],[-132,56]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[23]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[39]},{"t":90,"s":[49]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[40]},{"t":85,"s":[49]}]},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[171]},{"t":90,"s":[179]}]},"m":1},{"ty":"st","c":{"k":[0,0.949019667682,0.334948072246,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[8]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":74,"s":[18]},{"t":90,"s":[5]}]},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,20]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":61,"op":169,"st":-11},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":-370},"p":{"k":[336.32,194.77,0],"l":2},"a":{"k":[-147.98,-152.87,0],"l":2},"s":{"k":[-211,211,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[3.94,4.85],[5.94,-1.95],[-0.45,-2.41],[-2.2,1.23],[-0.26,2.51],[4.35,3.18],[4.93,-2.16],[-0.52,-1.71],[-1.37,0.82],[-0.3,1.57],[5.15,2.5],[2.88,-2.43]],"o":[[-0.16,-6.25],[-3.94,-4.85],[-2.33,0.77],[0.47,2.48],[2.2,-1.23],[0.56,-5.36],[-4.35,-3.18],[-1.64,0.72],[0.47,1.53],[1.37,-0.82],[1.06,-5.62],[-3.39,-1.64],[0,0]],"v":[[-121.55,-126.72],[-127.13,-144.27],[-143.8,-150.03],[-148.04,-144.81],[-141.97,-142.56],[-138.46,-148.94],[-144.73,-163.04],[-160.08,-164.73],[-162.93,-160.75],[-158.93,-159.58],[-156.58,-163.6],[-163.75,-177.86],[-174.41,-177.06]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[13]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[45]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[63]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[99]},{"t":97,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.964705882353,0,0.037585939146,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[169]},{"t":121,"s":[-67]}]},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.86},"o":{"x":0.05,"y":0.01},"t":68,"s":[201,236.25,0],"to":[-0.3,-1.62,0],"ti":[89.46,-52.28,0]},{"i":{"x":0.52,"y":0.9},"o":{"x":0.16,"y":0.13},"t":77,"s":[117.46,105.72,0],"to":[-89.46,52.28,0],"ti":[54.73,-96.02,0]},{"t":127,"s":[83.5,392.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":79,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":84,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":88,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":99,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[80,80,100]},{"t":120,"s":[75,-75,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,-2.72],[-1.69,-6.82],[5.28,-3.42],[-3.74,4.29]],"o":[[-1.96,5],[-8.39,-4.89],[-3.96,-11.6],[3,-5.59]],"v":[[15,-15],[15,15],[-15,15],[-15,-15]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":69,"s":[0,0.34,0.91,1],"h":1},{"t":72,"s":[0.89,0.89,0.89,1],"h":1},{"t":77,"s":[0,0.34,0.91,1],"h":1},{"t":82,"s":[0.89,0.89,0.89,1],"h":1},{"t":86,"s":[0,0.34,0.91,1],"h":1},{"t":91,"s":[0.89,0.89,0.89,1],"h":1},{"t":96,"s":[0,0.34,0.91,1],"h":1},{"t":103,"s":[0.89,0.89,0.89,1],"h":1},{"t":110,"s":[0,0.34,0.91,1],"h":1},{"t":117,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":180,"st":37},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-90]},{"t":109,"s":[301.64]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[198.75,244.5,0],"to":[-1.88,-52.9,0],"ti":[28.8,18.42,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[144.71,133.7,0],"to":[-28.64,-18.32,0],"ti":[23.57,-30.86,0]},{"t":109,"s":[58.5,148.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":68,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"t":109,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[29,30]},{"t":107,"s":[18,18.62]}]},"p":{"k":[0,0]},"r":{"k":211}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[0.96,0.81,0,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0.96,0.81,0,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0.96,0.81,0,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0.96,0.81,0,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0.96,0.81,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":4},"sa":{"k":0}}]}],"ip":65,"op":110,"st":34},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[-163]},{"t":120,"s":[-73.39]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[198.75,232.5,0],"to":[2.25,-107.5,0],"ti":[45.25,-88,0]},{"t":120,"s":[43.75,100,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":100,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":107,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,45,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":113,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[132,-64,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":119,"s":[130,-32,100]},{"t":120,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[22,22]},{"t":116,"s":[15,15]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":64,"s":[0.67,0,0.27,1],"h":1},{"t":67,"s":[0.89,0.89,0.89,1],"h":1},{"t":71,"s":[0.67,0,0.27,1],"h":1},{"t":76,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[0.67,0,0.27,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":90,"s":[0.67,0,0.27,1],"h":1},{"t":97,"s":[0.89,0.89,0.89,1],"h":1},{"t":104,"s":[0.67,0,0.27,1],"h":1},{"t":111,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[0.67,0,0.27,1],"h":1},{"t":118,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":64,"op":121,"st":-53},{"ind":9,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[-29]},{"t":131,"s":[306]}]},"p":{"a":1,"k":[{"i":{"x":0.43,"y":0.94},"o":{"x":0,"y":0},"t":70,"s":[315.75,212.5,0],"to":[84.25,-131.5,0],"ti":[9.5,-151.25,0]},{"t":133,"s":[444.5,412.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":71,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":108,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"t":123,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":133,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[7.5,-7.5],[7.5,7.5],[0.09,7.5],[-7.5,7.5],[-7.5,-7.5],[-1.31,-7.5]],"c":true}]},{"t":140,"s":[{"i":[[-1.04,1.29],[-0.3,-3.48],[1.31,-2.19],[0,0],[0,0],[-1.41,1.63]],"o":[[0.26,3.49],[0.29,3.4],[-1.99,3.33],[0,0],[0,0],[1.17,-1.35]],"v":[[-2.19,-14.3],[-0.07,-2.07],[-0.67,5.04],[-7.5,7.5],[-7.5,-7.5],[-4.66,-10.34]],"c":true}]}]}},{"ty":"fl","c":{"a":1,"k":[{"t":71,"s":[1,0.61,0,1],"h":1},{"t":74,"s":[0.89,0.89,0.89,1],"h":1},{"t":79,"s":[1,0.61,0,1],"h":1},{"t":84,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0.61,0,1],"h":1},{"t":93,"s":[0.89,0.89,0.89,1],"h":1},{"t":98,"s":[1,0.61,0,1],"h":1},{"t":105,"s":[0.89,0.89,0.89,1],"h":1},{"t":112,"s":[1,0.61,0,1],"h":1},{"t":119,"s":[0.89,0.89,0.89,1],"h":1},{"t":124,"s":[1,0.61,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":70,"op":219,"st":39},{"ind":10,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-29]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[186]},{"t":130,"s":[188]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[312.75,231.5,0],"to":[-0.58,-8.79,0],"ti":[-27.32,0.19,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[337,137.9,0],"to":[27.32,-0.19,0],"ti":[5.65,-84.54,0]},{"t":130,"s":[365.5,416.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":103,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,-100,100]},{"t":130,"s":[100,40,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"k":[22,22]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":66,"s":[0,0.86,0.06,1],"h":1},{"t":69,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0,0.86,0.06,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0,0.86,0.06,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0,0.86,0.06,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0,0.86,0.06,1],"h":1},{"t":114,"s":[0.89,0.89,0.89,1],"h":1},{"t":121,"s":[0,0.86,0.06,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":65,"op":214,"st":34},{"ind":11,"ty":4,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[100]},{"t":124,"s":[0]}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[126]},{"t":124,"s":[349]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":0.68},"o":{"x":0,"y":0},"t":68,"s":[274,232.25,0],"to":[14,-95.25,0],"ti":[-3.5,70.25,0]},{"t":124,"s":[416.5,52.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":116,"s":[100,100,100]},{"t":124,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[20,20]},{"t":124,"s":[14,14]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":72,"s":[1,0.92,0,1],"h":1},{"t":75,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[1,0.92,0,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":89,"s":[1,0.92,0,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":99,"s":[1,0.92,0,1],"h":1},{"t":106,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[1,0.92,0,1],"h":1},{"t":120,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":217,"st":37},{"ind":12,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[54]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":76,"s":[-18]},{"t":126,"s":[18]}]},"p":{"a":1,"k":[{"i":{"x":0.33,"y":0.63},"o":{"x":0.02,"y":0},"t":63,"s":[199,223.25,0],"to":[-1.41,-14.49,0],"ti":[30.23,18.1,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.19,"y":0.18},"t":69,"s":[164.23,151.1,0],"to":[-30.23,-18.1,0],"ti":[-19.69,-81.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83.75,"s":[108.31,217.87,0],"to":[19.69,81.13,0],"ti":[-18.51,-96.26,0]},{"t":126,"s":[36.5,421.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":67,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":105,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[-100,100,100]},{"t":126,"s":[-100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":63,"s":[22,22]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[32,32]},{"t":126,"s":[26,9.75]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[1,0,0.86,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":75,"s":[1,0,0.86,1],"h":1},{"t":80,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0,0.86,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":101,"s":[1,0,0.86,1],"h":1},{"t":110,"s":[0.89,0.89,0.89,1],"h":1},{"t":120,"s":[1,0,0.86,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":63,"op":212,"st":32},{"ind":16,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[50]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[-6]},{"t":103,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[250.5,249,0],"to":[-2.6,-22.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":69,"s":[190.72,157.91,0],"to":[-69.01,-1.59,0],"ti":[-1.05,-109.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[121.5,435.5,0],"to":[-0.24,-0.22,0],"ti":[-0.07,-2.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[121.37,423.98,0],"to":[0.05,2.14,0],"ti":[0,0,0]},{"t":99,"s":[121.5,438,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":99,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,2]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"t":99,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":17,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[115]},{"i":{"x":[0.83],"y":[0.79]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[459]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":112,"s":[353]},{"t":122,"s":[359]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[291.5,240,0],"to":[-2.6,-19.52,0],"ti":[-37.7,-0.28,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":71,"s":[329.72,135.91,0],"to":[54.99,0.41,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[386.5,440,0],"to":[-7,-25,0],"ti":[17,0.5,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[345.5,440,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[335.5,440,0],"to":[6.75,0.38,0],"ti":[0,0,0]},{"t":122,"s":[350.5,455,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[-100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,45]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":122,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":112,"s":[5]},{"t":122,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":18,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-59]},{"i":{"x":[0.49],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":69,"s":[-30]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[12]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-14]},{"t":96,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[290.5,195,0],"to":[0.4,-33.52,0],"ti":[-40.98,-0.41,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[356.72,126.91,0],"to":[40.99,0.41,0],"ti":[0.95,-126.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[420.5,419,0],"to":[-0.41,-0.39,0],"ti":[-0.05,-33.51,0]},{"t":96,"s":[420.5,419,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[0,0,100]},{"t":65,"s":[90,90,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,40]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,20]},{"t":97,"s":[100,40]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,3]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,42]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,26]},{"t":97,"s":[100,42]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":6},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":22,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[346.44,208.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[79,56.3,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":180,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":149,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":161,"s":[100,100]},{"t":180,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":149,"op":180,"st":92},{"ind":23,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":141,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":166,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":129,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":141,"s":[100,100]},{"t":166,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":129,"op":166,"st":72},{"ind":24,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,184.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":122,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":145,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":114,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":122,"s":[100,100]},{"t":145,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":114,"op":146,"st":57},{"ind":25,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":99,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":136,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":99,"op":137,"st":42},{"ind":26,"ty":4,"ks":{"o":{"a":1,"k":[{"t":45,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"r":{"k":0},"p":{"k":[284.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":82,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":45,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":57,"s":[100,100]},{"t":82,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":45,"op":83,"st":-12},{"ind":27,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":56,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":25,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":33,"s":[100,100]},{"t":56,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":25,"op":57,"st":-32},{"ind":28,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":12,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":37,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":0,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":12,"s":[100,100]},{"t":37,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":38,"st":-57},{"ind":29,"ty":4,"parent":35,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[257.66,216.8,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[257.66,195.11,0],"to":[0,0,0],"ti":[0,0,0]},{"t":74,"s":[257.66,196.8,0]}],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"t":66,"s":[100,50,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.13,-28.48],[-50.62,19.68],[-18.12,-3.73],[14.13,40.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"t":63,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":65,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.75,-16.98],[26.41,20.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-32.08,-18.25],[-14.49,-16.96],[-15.39,-18.55],[-52.77,-20.68]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":69,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-42.74,-31.07],[-14.97,-46.8],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"t":68,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.06,-20.19],[-18.55,-45.47],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[2.59,-1.44],[37.94,1.18],[-6.25,14.1],[-24.19,4.01]],"o":[[-19.07,-0.37],[-2.16,-0.07],[10.87,19.21],[-1.7,0.73]],"v":[[48.4,19.5],[-17.41,53.75],[-6.96,-10.32],[71.12,18.18]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[1.94,-1.08],[6.24,-1.19],[15.02,17.72],[-16.78,-17.45]],"o":[[-3.39,20.66],[-1.59,0.3],[8.15,14.41],[-1.28,0.54]],"v":[[47.38,-11.46],[33.39,27.91],[-7.43,15.2],[48.16,-10.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[{"i":[[1.3,-0.72],[4.59,9.5],[7.91,-2.82],[-5.41,1.07]],"o":[[-0.4,-8.63],[-0.47,-0.97],[10.84,16.61],[-0.85,0.36]],"v":[[54.18,-18.16],[38.44,-59.82],[16.53,-34.2],[42.57,-26.83]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":71,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":30,"ty":4,"parent":35,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":68,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[256.46,249.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[256.46,225.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[256.46,209.72,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[256.46,206.72,0]}],"l":2},"a":{"k":[256.46,249.72,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,36,100]},{"t":67,"s":[100,14,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[1.75,-10.16],[-45.92,7.16],[-17.13,19.11],[30,-1.05]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-56.04,12.3],[-19.24,23.32],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.1,11.25],[-18.36,20.12],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[0.71,-11.86],[-45.71,6.16],[-16.92,18.11],[34.21,-0.66]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":31,"ty":4,"parent":36,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.96,347.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":33,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[100],"h":1},{"t":81,"s":[0],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[9]},{"t":81,"s":[5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":35,"ty":4,"parent":36,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":72,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":72,"s":[257.27,128.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1,-61.13],[131.24,-49.8],[-1.2,-40.13],[-132.88,-50.13]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"t":72,"s":[81,81]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":36,"ty":4,"parent":52,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-74.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":39,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[-198]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-174]},{"t":97,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[220.5,269,0],"to":[-2.6,-19.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[157.72,193.91,0],"to":[-69.01,-1.59,0],"ti":[2.95,-91.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[83.5,374,0],"to":[-0.41,-0.39,0],"ti":[-3.5,-7,0]},{"t":97,"s":[87.5,374,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[84,84,100]},{"t":88,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[100,24]},{"t":97,"s":[100,12]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[0,-3],"to":[0,0],"ti":[0,0]},{"t":97,"s":[0,-2.5]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[91,28]},{"t":97,"s":[91,22]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[9]},{"t":88,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":40,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[-180]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[-207]},{"t":93,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[296.5,278,0],"to":[2.4,-15.52,0],"ti":[-54.47,3.14,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[348.72,155.91,0],"to":[56.66,-3.26,0],"ti":[-0.05,-138.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[428.5,358,0],"to":[-4.91,-22.89,0],"ti":[0.5,-14.75,0]},{"t":93,"s":[402.5,376,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[100,100,100]},{"t":85,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[100,0]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[100,0]},{"t":93,"s":[100,24]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":91,"s":[0,-4]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[91,4]},{"t":93,"s":[100,31]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[1],"y":[0]},"t":68,"s":[5]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[9]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[1],"y":[0]},"t":83,"s":[5]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":91,"s":[9]},{"t":93,"s":[6]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":44,"ty":4,"parent":36,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.48,312.85,0],"l":2},"a":{"k":[266.48,312.85,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[231.5,294],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[231.5,283],"to":[0,0],"ti":[0,0]},{"t":74,"s":[231.5,294]}]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":96},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[189,270.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[189,263.5],"to":[0,0],"ti":[0,0]},{"t":74,"s":[189,270.5]}]},"a":{"k":[166,92]},"s":{"k":[103.56,52.65]},"r":{"k":-3.75},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[329.5,274],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[329.5,262],"to":[0,0],"ti":[0,0]},{"t":75,"s":[329.5,274]}]},"a":{"k":[166,92]},"s":{"k":[97,43.65]},"r":{"k":14},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[238.5,264.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[238.5,254.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[238.5,264.5]}]},"a":{"k":[166,92]},"s":{"k":[100,39]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[285.5,268.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[285.5,258.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[285.5,268.5]}]},"a":{"k":[166,92]},"s":{"k":[100,32]},"r":{"k":-8},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[271.5,254],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[271.5,245],"to":[0,0],"ti":[0,0]},{"t":71,"s":[271.5,254]}]},"a":{"k":[166,92]},"s":{"k":[77,28.36]},"r":{"k":3.04},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.8,91.56]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[284.5,298]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":100},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":46,"ty":4,"parent":36,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":48,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[0],"h":1},{"t":81,"s":[100],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[256,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-1.62,-1.25],[1.57,0],[0.58,1.35],[1.1,0.14],[-0.41,0.26],[1.64,-0.46],[0.89,0],[-7.64,3.94],[0.91,0.09],[-6.6,5.14],[-4.12,-5.08],[0.81,-2.58],[3.08,-1.9]],"o":[[-1.48,0],[0.95,-1.09],[-0.84,-0.11],[0.42,-0.27],[-3.5,0],[-0.64,0.18],[1.97,-2.8],[-0.76,-0.07],[18.01,-11.61],[1.87,-1.45],[-3.5,0.52],[-1.3,4.13],[-3.81,2.36]],"v":[[8.47,11.33],[3.95,11.33],[1.52,6.67],[-1.26,6.31],[-0.29,5.7],[-16.54,9.96],[-18.8,9.98],[-7.27,2.87],[-9.44,2.66],[10.25,-9.72],[18.8,-8.04],[13.58,-2.26],[5.38,4.68]],"c":true}}},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,0]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0,0,0,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[9]},{"t":81,"s":[5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.011764706112,0.078431375325,0.129411771894,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":52,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":29,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":43,"s":[1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":46,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1]},{"t":58,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":55,"s":[257,451,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[257,439,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[257,451,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.9,1.09,0]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":55,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":58,"s":[110,89,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[91,108,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[103,97,100]},{"t":73,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]},{"id":"comp_1","layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":181,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":144,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":156,"s":[100,100]},{"t":181,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":144,"op":181,"st":87},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[336.44,166.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":136,"s":[100,100]},{"t":161,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":124,"op":162,"st":67},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[190.44,214.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":117,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":109,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":117,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":109,"op":141,"st":52},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":103,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":114,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[100,100]},{"t":114,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":95,"op":115,"st":38},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":100,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":81,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":89,"s":[100,100]},{"t":100,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":81,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":100,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":81,"op":101,"st":24},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":90,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":73,"s":[100,100]},{"t":90,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":90,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":65,"op":91,"st":8},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,221.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":69,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":50,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":58,"s":[100,100]},{"t":69,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":50,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":69,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":50,"op":70,"st":-7},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[323.44,178.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":49,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":60,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":41,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":49,"s":[100,100]},{"t":60,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":41,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":60,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":41,"op":61,"st":-16},{"ind":9,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257.66,216.8,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-43.5],[-50.62,19.68],[-19.83,-18.75],[12.42,25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.08,-16.07],[17.57,-27.27],[-8.47,2.39],[30.67,-18.15]],"o":[[-15.8,5.37],[10.67,-14.43],[36.88,-10.55],[63.53,-17.02]],"v":[[1.03,-25.49],[-50.62,19.68],[-18.22,-2.26],[14.03,39.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"t":83,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-32],[24.7,5.25],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.13],[15.01,-7.06],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,17.55],[-23.68,11.14],[4.47,0.56]],"v":[[-34.14,-8.48],[52.65,-15.65],[26.31,20],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"t":83,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-25.54,-39.48],[3.89,-52.46],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-14.47],[-8.77,1.66],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.23],[26.18,-4.96],[-7.91,6.92]],"v":[[28.2,44.02],[-47.21,-19.87],[-19.44,-35.04],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"t":83,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-32.04,-23.56],[-1.35,-53.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,23.79],[-20,4.13],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.37],[-16,22.94],[-7,3.5]],"v":[[44.51,48.89],[-46.54,-9.48],[-23.02,-33.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"t":83,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":10,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.76,93.8],[14.58,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.58,93.8],[14.76,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":11,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[257.27,248.75,0],"to":[0,14.83,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[257.27,218.64,0],"to":[0,0,0],"ti":[0,-8.61,0]},{"t":34,"s":[257.27,248.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":35},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":12,"ty":4,"parent":14,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-28.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":13,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":32,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[-1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":66,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[-1.5]},{"t":81,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.17,"y":0.17},"t":0,"s":[257,739,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.13},"t":20,"s":[257,379,0],"to":[0,0,0],"ti":[0,0,0]},{"t":32,"s":[257,405,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":9,"s":[97,105,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":36,"s":[104,96,100]},{"t":43,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":0,"cl":"Present3","refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":180,"op":360,"st":180},{"ind":2,"ty":0,"cl":"Present2","refId":"comp_1","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/usdt_packing.json b/common/src/main/res/raw/usdt_packing.json new file mode 100644 index 0000000..d9ee84a --- /dev/null +++ b/common/src/main/res/raw/usdt_packing.json @@ -0,0 +1 @@ +{"v": "5.9.0", "fr": 60, "ip": 0, "op": 300, "w": 512, "h": 512, "assets": [{"id": "comp_0", "fr": 60, "layers": [{"ind": 1, "ty": 0, "refId": "comp_1", "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [256, 256, 0], "l": 2}, "a": {"k": [256, 256, 0], "l": 2}, "s": {"k": [100, 100, 100], "l": 2}}, "w": 512, "h": 512, "ip": 0, "op": 180, "st": 0}, {"ind": 3, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 82, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 93, "s": [12.6]}, {"t": 102, "s": [96]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 82, "s": [116, 96, 0], "to": [86, 26, 0], "ti": [-1.25, -87.67, 0]}, {"t": 102, "s": [290.5, 297, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 82, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 87, "s": [71, 38.34, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 92, "s": [100, 84, 100]}, {"t": 97, "s": [100, 100, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0.03, -0.15], [9.07, -1.87], [1.99, -3.54], [1.99, -1.46], [0.03, -1.41], [-1.93, -1.46], [-1.93, -3.54], [-9, -1.87]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.9, -9.92], [7.9, -7.06], [1.99, -7.06], [1.99, -5.08], [9.28, -2.96], [9.28, -0.78], [1.99, 1.34], [1.99, 6.2], [-1.93, 6.2], [-1.93, 1.34], [-9.21, -0.78], [-9.21, -2.96], [-1.93, -5.08], [-1.93, -7.06], [-7.83, -7.06], [-7.83, -9.92]], "c": true}}}, {"ind": 2, "ty": "sh", "ks": {"k": {"i": [[-0.48, 0], [0, 0], [-0.25, -0.41], [0, 0], [0.45, -0.43], [0, 0], [0.54, 0.52], [0, 0], [-0.34, 0.53], [0, 0]], "o": [[0, 0], [0.5, 0], [0, 0], [0.31, 0.53], [0, 0], [-0.54, 0.52], [0, 0], [-0.46, -0.44], [0, 0], [0.25, -0.4]], "v": [[-10.16, -14.46], [10.58, -14.46], [11.78, -13.79], [17.82, -3.61], [17.59, -1.99], [0.96, 13.95], [-0.98, 13.95], [-17.59, -1.96], [-17.79, -3.62], [-11.34, -13.83]], "c": true}}}, {"ty": "mm", "mm": 5}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.39, 94.13]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180019110441, 0.56936275959, 0.56936275959, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 82, "s": [0, 9], "to": [0, -0.75], "ti": [0, 1.88]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 87, "s": [0, 14.5], "to": [0, -1.87], "ti": [0, 0.75]}, {"t": 92, "s": [0, 0]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 82, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 87, "s": [110, 134]}, {"t": 92, "s": [116, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 4, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 101, "s": [28]}, {"t": 121, "s": [-3.75]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 101, "s": [104, 122, 0], "to": [54, 7, 0], "ti": [-19.25, -84.67, 0]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 121, "s": [243.5, 267, 0], "to": [0.06, -3.71, 0], "ti": [0, -3.54, 0]}, {"t": 127, "s": [243.5, 267, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 101, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 111, "s": [85, 85, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 121, "s": [95.56, 94.94, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 124, "s": [102.56, 40.29, 100]}, {"t": 127, "s": [103.56, 52.65, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0.03, -0.15], [9.07, -1.87], [1.99, -3.54], [1.99, -1.46], [0.03, -1.42], [-1.93, -1.46], [-1.93, -3.54], [-9, -1.87]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.9, -9.92], [7.9, -7.06], [1.99, -7.06], [1.99, -5.08], [9.28, -2.96], [9.28, -0.78], [1.99, 1.34], [1.99, 6.2], [-1.93, 6.2], [-1.93, 1.34], [-9.21, -0.78], [-9.21, -2.96], [-1.93, -5.08], [-1.93, -7.06], [-7.83, -7.06], [-7.83, -9.92]], "c": true}}}, {"ind": 2, "ty": "sh", "ks": {"k": {"i": [[-0.48, 0], [0, 0], [-0.25, -0.41], [0, 0], [0.45, -0.43], [0, 0], [0.54, 0.52], [0, 0], [-0.34, 0.53], [0, 0]], "o": [[0, 0], [0.5, 0], [0, 0], [0.31, 0.53], [0, 0], [-0.54, 0.52], [0, 0], [-0.46, -0.44], [0, 0], [0.25, -0.4]], "v": [[-10.16, -14.46], [10.58, -14.46], [11.78, -13.79], [17.82, -3.61], [17.59, -1.99], [0.96, 13.95], [-0.98, 13.95], [-17.59, -1.96], [-17.79, -3.62], [-11.34, -13.83]], "c": true}}}, {"ty": "mm", "mm": 5}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.39, 94.13]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 121, "s": [0, 0], "to": [0, -0.75], "ti": [0, 0]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 124, "s": [0, 11], "to": [0, 0], "ti": [0, 0]}, {"t": 127, "s": [0, 7]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 121, "s": [120, 120]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 124, "s": [102.4, 116]}, {"t": 127, "s": [102.4, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 5, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 96, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 116, "s": [31.25]}, {"t": 120, "s": [14]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 96, "s": [367, 78, 0], "to": [-18, 29, 0], "ti": [6.75, -76.67, 0]}, {"t": 116, "s": [325.5, 270, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 96, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 106, "s": [85, 85, 100]}, {"t": 116, "s": [97, 43.65, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0.03, -0.15], [9.07, -1.87], [1.99, -3.54], [1.99, -1.46], [0.03, -1.42], [-1.93, -1.46], [-1.93, -3.54], [-9, -1.87]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.9, -9.92], [7.9, -7.06], [1.99, -7.06], [1.99, -5.08], [9.28, -2.96], [9.28, -0.78], [1.99, 1.34], [1.99, 6.2], [-1.93, 6.2], [-1.93, 1.34], [-9.21, -0.78], [-9.21, -2.96], [-1.93, -5.08], [-1.93, -7.06], [-7.83, -7.06], [-7.83, -9.92]], "c": true}}}, {"ind": 2, "ty": "sh", "ks": {"k": {"i": [[-0.48, 0], [0, 0], [-0.25, -0.41], [0, 0], [0.45, -0.43], [0, 0], [0.54, 0.52], [0, 0], [-0.34, 0.53], [0, 0]], "o": [[0, 0], [0.5, 0], [0, 0], [0.31, 0.53], [0, 0], [-0.54, 0.52], [0, 0], [-0.46, -0.44], [0, 0], [0.25, -0.4]], "v": [[-10.16, -14.46], [10.58, -14.46], [11.78, -13.79], [17.82, -3.61], [17.59, -1.99], [0.96, 13.95], [-0.98, 13.95], [-17.59, -1.96], [-17.79, -3.62], [-11.34, -13.83]], "c": true}}}, {"ty": "mm", "mm": 5}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.39, 94.13]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 96, "s": [0, 9], "to": [0, -0.75], "ti": [0, 0.75]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 106, "s": [0, 0], "to": [0, -1.5], "ti": [0, 0]}, {"t": 116, "s": [0, 10]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 96, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 106, "s": [116, 116]}, {"t": 116, "s": [102.4, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 6, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 92, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 97, "s": [81.55]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 112, "s": [70.2]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 115, "s": [-10]}, {"t": 119, "s": [0]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 92, "s": [125, 74, 0], "to": [34, 35, 0], "ti": [-5.25, -79.67, 0]}, {"t": 112, "s": [210.5, 282, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 92, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 95, "s": [42.6, 26.6, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 97, "s": [71, 71, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 112, "s": [100, 34, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 115, "s": [100, 54, 100]}, {"t": 119, "s": [100, 39, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0.03, -0.15], [9.07, -1.87], [1.99, -3.54], [1.99, -1.46], [0.03, -1.42], [-1.93, -1.46], [-1.93, -3.54], [-9, -1.87]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.9, -9.92], [7.9, -7.06], [1.99, -7.06], [1.99, -5.08], [9.28, -2.96], [9.28, -0.78], [1.99, 1.34], [1.99, 6.2], [-1.93, 6.2], [-1.93, 1.34], [-9.21, -0.78], [-9.21, -2.96], [-1.93, -5.08], [-1.93, -7.06], [-7.83, -7.06], [-7.83, -9.92]], "c": true}}}, {"ind": 2, "ty": "sh", "ks": {"k": {"i": [[-0.48, 0], [0, 0], [-0.25, -0.41], [0, 0], [0.45, -0.43], [0, 0], [0.54, 0.52], [0, 0], [-0.34, 0.53], [0, 0]], "o": [[0, 0], [0.5, 0], [0, 0], [0.31, 0.53], [0, 0], [-0.54, 0.52], [0, 0], [-0.46, -0.44], [0, 0], [0.25, -0.4]], "v": [[-10.16, -14.46], [10.58, -14.46], [11.78, -13.79], [17.82, -3.61], [17.59, -1.99], [0.96, 13.95], [-0.98, 13.95], [-17.59, -1.96], [-17.79, -3.62], [-11.34, -13.83]], "c": true}}}, {"ty": "mm", "mm": 5}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.39, 94.13]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 92, "s": [0, 9], "to": [0, -0.15], "ti": [0, 2.44]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 95, "s": [0, -8.41], "to": [0, -2.01], "ti": [0, -0.17]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 97, "s": [0, 0], "to": [0, 0.62], "ti": [0, 0]}, {"t": 112, "s": [0, 12]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 92, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 95, "s": [104.15, 129.8]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 97, "s": [104.25, 104]}, {"t": 112, "s": [105, 130]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 7, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 75, "s": [-20]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 88, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 97, "s": [-23]}, {"t": 101, "s": [-8]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 75, "s": [433, 92, 0], "to": [-77, 4, 0], "ti": [-1.25, -87.67, 0]}, {"t": 97, "s": [238.5, 296, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 75, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 87, "s": [100, 100, 100]}, {"t": 93, "s": [100, 32, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0.03, -0.15], [9.07, -1.87], [1.99, -3.54], [1.99, -1.46], [0.03, -1.42], [-1.93, -1.46], [-1.93, -3.54], [-9, -1.87]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.9, -9.92], [7.9, -7.06], [1.99, -7.06], [1.99, -5.08], [9.28, -2.96], [9.28, -0.78], [1.99, 1.34], [1.99, 6.2], [-1.93, 6.2], [-1.93, 1.34], [-9.21, -0.78], [-9.21, -2.96], [-1.93, -5.08], [-1.93, -7.06], [-7.83, -7.06], [-7.83, -9.92]], "c": true}}}, {"ind": 2, "ty": "sh", "ks": {"k": {"i": [[-0.48, 0], [0, 0], [-0.25, -0.41], [0, 0], [0.45, -0.43], [0, 0], [0.54, 0.52], [0, 0], [-0.34, 0.53], [0, 0]], "o": [[0, 0], [0.5, 0], [0, 0], [0.31, 0.53], [0, 0], [-0.54, 0.52], [0, 0], [-0.46, -0.44], [0, 0], [0.25, -0.4]], "v": [[-10.16, -14.46], [10.58, -14.46], [11.78, -13.79], [17.82, -3.61], [17.59, -1.99], [0.96, 13.95], [-0.98, 13.95], [-17.59, -1.96], [-17.79, -3.62], [-11.34, -13.83]], "c": true}}}, {"ty": "mm", "mm": 5}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.39, 94.13]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 87, "s": [0, 0], "to": [0, -1.5], "ti": [0, 0]}, {"t": 93, "s": [0, 19]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 87, "s": [116, 116]}, {"t": 93, "s": [105, 125]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 8, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 85, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 100, "s": [-22.75]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 105, "s": [-22]}, {"t": 109, "s": [-9]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 85, "s": [367, 78, 0], "to": [-34, 23, 0], "ti": [6.75, -76.67, 0]}, {"t": 105, "s": [264.5, 279, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 85, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 95, "s": [77, 77, 100]}, {"t": 105, "s": [77, 34.65, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0.03, -0.15], [9.07, -1.87], [1.99, -3.54], [1.99, -1.46], [0.03, -1.42], [-1.93, -1.46], [-1.93, -3.54], [-9, -1.87]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.9, -9.92], [7.9, -7.06], [1.99, -7.06], [1.99, -5.08], [9.28, -2.96], [9.28, -0.78], [1.99, 1.34], [1.99, 6.2], [-1.93, 6.2], [-1.93, 1.34], [-9.21, -0.78], [-9.21, -2.96], [-1.93, -5.08], [-1.93, -7.06], [-7.83, -7.06], [-7.83, -9.92]], "c": true}}}, {"ind": 2, "ty": "sh", "ks": {"k": {"i": [[-0.48, 0], [0, 0], [-0.25, -0.41], [0, 0], [0.45, -0.43], [0, 0], [0.54, 0.52], [0, 0], [-0.34, 0.53], [0, 0]], "o": [[0, 0], [0.5, 0], [0, 0], [0.31, 0.53], [0, 0], [-0.54, 0.52], [0, 0], [-0.46, -0.44], [0, 0], [0.25, -0.4]], "v": [[-10.16, -14.46], [10.58, -14.46], [11.78, -13.79], [17.82, -3.61], [17.59, -1.99], [0.96, 13.95], [-0.98, 13.95], [-17.59, -1.96], [-17.79, -3.62], [-11.34, -13.83]], "c": true}}}, {"ty": "mm", "mm": 5}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.39, 94.13]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 85, "s": [0, 9], "to": [0, -0.75], "ti": [0, 0.75]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 95, "s": [0, 0], "to": [0, -1.5], "ti": [0, 0]}, {"t": 105, "s": [0, 10]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 85, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 95, "s": [116, 116]}, {"t": 105, "s": [109, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 10, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 60, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 71, "s": [12.6]}, {"t": 80, "s": [100]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 60, "s": [75, 92, 0], "to": [105, 30, 0], "ti": [-1.25, -87.67, 0]}, {"t": 80, "s": [242.5, 340, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 60, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 65, "s": [71, 38.34, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 70, "s": [100, 84, 100]}, {"t": 75, "s": [100, 100, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0.03, -0.15], [9.07, -1.87], [1.99, -3.54], [1.99, -1.46], [0.03, -1.42], [-1.93, -1.46], [-1.93, -3.54], [-9, -1.87]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.9, -9.92], [7.9, -7.06], [1.99, -7.06], [1.99, -5.08], [9.28, -2.96], [9.28, -0.78], [1.99, 1.34], [1.99, 6.2], [-1.93, 6.2], [-1.93, 1.34], [-9.21, -0.78], [-9.21, -2.96], [-1.93, -5.08], [-1.93, -7.06], [-7.83, -7.06], [-7.83, -9.92]], "c": true}}}, {"ind": 2, "ty": "sh", "ks": {"k": {"i": [[-0.48, 0], [0, 0], [-0.25, -0.41], [0, 0], [0.45, -0.43], [0, 0], [0.54, 0.52], [0, 0], [-0.34, 0.53], [0, 0]], "o": [[0, 0], [0.5, 0], [0, 0], [0.31, 0.53], [0, 0], [-0.54, 0.52], [0, 0], [-0.46, -0.44], [0, 0], [0.25, -0.4]], "v": [[-10.16, -14.46], [10.58, -14.46], [11.78, -13.79], [17.82, -3.61], [17.59, -1.99], [0.96, 13.95], [-0.98, 13.95], [-17.59, -1.96], [-17.79, -3.62], [-11.34, -13.83]], "c": true}}}, {"ty": "mm", "mm": 5}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.39, 94.13]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 60, "s": [0, 9], "to": [0, -0.75], "ti": [0, 1.88]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 65, "s": [0, 14.5], "to": [0, -1.87], "ti": [0, 0.75]}, {"t": 70, "s": [0, 0]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 60, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 65, "s": [110, 134]}, {"t": 70, "s": [116, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}]}, {"id": "comp_1", "fr": 60, "layers": [{"ind": 1, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 82, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 93, "s": [12.6]}, {"t": 102, "s": [96]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 82, "s": [116, 96, 0], "to": [86, 26, 0], "ti": [-1.25, -87.67, 0]}, {"t": 102, "s": [290.5, 297, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 82, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 87, "s": [71, 38.34, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 92, "s": [100, 84, 100]}, {"t": 97, "s": [100, 100, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0, 1.71], [9.03, -0.01], [1.96, -1.68], [1.96, 0.4], [0, 0.44], [-1.96, 0.4], [-1.96, -1.68], [-9.03, -0.01]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.87, -8.06], [7.87, -5.2], [1.96, -5.2], [1.96, -3.22], [9.24, -1.1], [9.24, 1.07], [1.96, 3.2], [1.96, 8.06], [-1.96, 8.06], [-1.96, 3.2], [-9.24, 1.07], [-9.24, -1.1], [-1.96, -3.22], [-1.96, -5.2], [-7.87, -5.2], [-7.87, -8.06]], "c": true}}}, {"ty": "mm", "mm": 1}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.45, 93.87]}, "a": {"k": [0, 0]}, "s": {"k": [192.31, 192.31]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180019110441, 0.56936275959, 0.56936275959, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 82, "s": [0, 9], "to": [0, -0.75], "ti": [0, 1.88]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 87, "s": [0, 14.5], "to": [0, -1.87], "ti": [0, 0.75]}, {"t": 92, "s": [0, 0]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 82, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 87, "s": [110, 134]}, {"t": 92, "s": [116, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 2, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 101, "s": [28]}, {"t": 121, "s": [-3.75]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 101, "s": [104, 122, 0], "to": [54, 7, 0], "ti": [-19.25, -84.67, 0]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 121, "s": [243.5, 267, 0], "to": [0.06, -3.71, 0], "ti": [0, -3.54, 0]}, {"t": 127, "s": [243.5, 267, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 101, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 111, "s": [85, 85, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 121, "s": [95.56, 94.94, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 124, "s": [102.56, 40.29, 100]}, {"t": 127, "s": [103.56, 52.65, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0, 1.71], [9.03, -0.01], [1.96, -1.68], [1.96, 0.4], [0, 0.44], [-1.96, 0.4], [-1.96, -1.68], [-9.03, -0.01]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.87, -8.06], [7.87, -5.2], [1.96, -5.2], [1.96, -3.22], [9.24, -1.1], [9.24, 1.08], [1.96, 3.2], [1.96, 8.06], [-1.96, 8.06], [-1.96, 3.2], [-9.24, 1.08], [-9.24, -1.1], [-1.96, -3.22], [-1.96, -5.2], [-7.87, -5.2], [-7.87, -8.06]], "c": true}}}, {"ty": "mm", "mm": 1}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.45, 93.87]}, "a": {"k": [0, 0]}, "s": {"k": [192.31, 192.31]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 121, "s": [0, 0], "to": [0, -0.75], "ti": [0, 0]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 124, "s": [0, 11], "to": [0, 0], "ti": [0, 0]}, {"t": 127, "s": [0, 7]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 121, "s": [120, 120]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 124, "s": [102.4, 116]}, {"t": 127, "s": [102.4, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 3, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 96, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 116, "s": [31.25]}, {"t": 120, "s": [14]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 96, "s": [367, 78, 0], "to": [-18, 29, 0], "ti": [6.75, -76.67, 0]}, {"t": 116, "s": [325.5, 270, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 96, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 106, "s": [85, 85, 100]}, {"t": 116, "s": [97, 43.65, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0, 1.71], [9.03, -0.01], [1.96, -1.68], [1.96, 0.4], [0, 0.44], [-1.96, 0.4], [-1.96, -1.68], [-9.03, -0.01]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.87, -8.06], [7.87, -5.2], [1.96, -5.2], [1.96, -3.22], [9.24, -1.1], [9.24, 1.08], [1.96, 3.2], [1.96, 8.06], [-1.96, 8.06], [-1.96, 3.2], [-9.24, 1.08], [-9.24, -1.1], [-1.96, -3.22], [-1.96, -5.2], [-7.87, -5.2], [-7.87, -8.06]], "c": true}}}, {"ty": "mm", "mm": 1}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.45, 93.87]}, "a": {"k": [0, 0]}, "s": {"k": [192.31, 192.31]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 96, "s": [0, 9], "to": [0, -0.75], "ti": [0, 0.75]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 106, "s": [0, 0], "to": [0, -1.5], "ti": [0, 0]}, {"t": 116, "s": [0, 10]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 96, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 106, "s": [116, 116]}, {"t": 116, "s": [102.4, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 4, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 92, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 97, "s": [81.55]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 112, "s": [70.2]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 115, "s": [-10]}, {"t": 119, "s": [0]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 92, "s": [125, 74, 0], "to": [34, 35, 0], "ti": [-5.25, -79.67, 0]}, {"t": 112, "s": [210.5, 282, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 92, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 95, "s": [42.6, 26.6, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 97, "s": [71, 71, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 112, "s": [100, 34, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 115, "s": [100, 54, 100]}, {"t": 119, "s": [100, 39, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0, 1.71], [9.03, -0.01], [1.96, -1.68], [1.96, 0.4], [0, 0.44], [-1.96, 0.4], [-1.96, -1.68], [-9.03, -0.01]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.87, -8.06], [7.87, -5.2], [1.96, -5.2], [1.96, -3.22], [9.24, -1.1], [9.24, 1.08], [1.96, 3.2], [1.96, 8.06], [-1.96, 8.06], [-1.96, 3.2], [-9.24, 1.08], [-9.24, -1.1], [-1.96, -3.22], [-1.96, -5.2], [-7.87, -5.2], [-7.87, -8.06]], "c": true}}}, {"ty": "mm", "mm": 1}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.45, 93.87]}, "a": {"k": [0, 0]}, "s": {"k": [192.31, 192.31]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 92, "s": [0, 9], "to": [0, -0.15], "ti": [0, 2.44]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 95, "s": [0, -8.41], "to": [0, -2.01], "ti": [0, -0.17]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 97, "s": [0, 0], "to": [0, 0.62], "ti": [0, 0]}, {"t": 112, "s": [0, 12]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 92, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 95, "s": [104.15, 129.8]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 97, "s": [104.25, 104]}, {"t": 112, "s": [105, 130]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 5, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 75, "s": [-20]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 88, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 97, "s": [-23]}, {"t": 101, "s": [-8]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 75, "s": [433, 92, 0], "to": [-77, 4, 0], "ti": [-1.25, -87.67, 0]}, {"t": 97, "s": [238.5, 296, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 75, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 87, "s": [100, 100, 100]}, {"t": 93, "s": [100, 32, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0, 1.71], [9.03, -0.01], [1.96, -1.68], [1.96, 0.4], [0, 0.44], [-1.96, 0.4], [-1.96, -1.68], [-9.03, -0.01]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.87, -8.06], [7.87, -5.2], [1.96, -5.2], [1.96, -3.22], [9.24, -1.1], [9.24, 1.08], [1.96, 3.2], [1.96, 8.06], [-1.96, 8.06], [-1.96, 3.2], [-9.24, 1.08], [-9.24, -1.1], [-1.96, -3.22], [-1.96, -5.2], [-7.87, -5.2], [-7.87, -8.06]], "c": true}}}, {"ty": "mm", "mm": 1}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.45, 93.87]}, "a": {"k": [0, 0]}, "s": {"k": [192.31, 192.31]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 87, "s": [0, 0], "to": [0, -1.5], "ti": [0, 0]}, {"t": 93, "s": [0, 19]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 87, "s": [116, 116]}, {"t": 93, "s": [105, 125]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 6, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 85, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 100, "s": [-22.75]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 105, "s": [-22]}, {"t": 109, "s": [-9]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 85, "s": [367, 78, 0], "to": [-34, 23, 0], "ti": [6.75, -76.67, 0]}, {"t": 105, "s": [264.5, 279, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 85, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 95, "s": [77, 77, 100]}, {"t": 105, "s": [77, 34.65, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0, 1.71], [9.03, -0.01], [1.96, -1.68], [1.96, 0.4], [0, 0.44], [-1.96, 0.4], [-1.96, -1.68], [-9.03, -0.01]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.87, -8.06], [7.87, -5.2], [1.96, -5.2], [1.96, -3.22], [9.24, -1.1], [9.24, 1.08], [1.96, 3.2], [1.96, 8.06], [-1.96, 8.06], [-1.96, 3.2], [-9.24, 1.08], [-9.24, -1.1], [-1.96, -3.22], [-1.96, -5.2], [-7.87, -5.2], [-7.87, -8.06]], "c": true}}}, {"ty": "mm", "mm": 1}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.45, 93.87]}, "a": {"k": [0, 0]}, "s": {"k": [192.31, 192.31]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 85, "s": [0, 9], "to": [0, -0.75], "ti": [0, 0.75]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 95, "s": [0, 0], "to": [0, -1.5], "ti": [0, 0]}, {"t": 105, "s": [0, 10]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 85, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 95, "s": [116, 116]}, {"t": 105, "s": [109, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 7, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 70, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 81, "s": [12.6]}, {"t": 90, "s": [100]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 70, "s": [427, 116, 0], "to": [-67, 4, 0], "ti": [-1.25, -87.67, 0]}, {"t": 90, "s": [264.5, 338, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 70, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 75, "s": [71, 38.34, 100]}, {"t": 80, "s": [100, 100, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0, 1.71], [9.03, -0.01], [1.96, -1.68], [1.96, 0.4], [0, 0.44], [-1.96, 0.4], [-1.96, -1.68], [-9.03, -0.01]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.87, -8.06], [7.87, -5.2], [1.96, -5.2], [1.96, -3.22], [9.24, -1.1], [9.24, 1.08], [1.96, 3.2], [1.96, 8.06], [-1.96, 8.06], [-1.96, 3.2], [-9.24, 1.08], [-9.24, -1.1], [-1.96, -3.22], [-1.96, -5.2], [-7.87, -5.2], [-7.87, -8.06]], "c": true}}}, {"ty": "mm", "mm": 1}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.45, 93.87]}, "a": {"k": [0, 0]}, "s": {"k": [192.31, 192.31]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 70, "s": [0, 9], "to": [0, -0.75], "ti": [0, 1.88]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 75, "s": [0, 14.5], "to": [0, -1.87], "ti": [0, 0.75]}, {"t": 80, "s": [0, 0]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 70, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 75, "s": [110, 134]}, {"t": 80, "s": [116, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}, {"ind": 8, "ty": 4, "ks": {"o": {"k": 100}, "r": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 60, "s": [28]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 71, "s": [12.6]}, {"t": 80, "s": [100]}]}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.35, "y": 0}, "t": 60, "s": [75, 92, 0], "to": [105, 30, 0], "ti": [-1.25, -87.67, 0]}, {"t": 80, "s": [242.5, 340, 0]}], "l": 2}, "a": {"k": [346, 92, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 60, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 65, "s": [71, 38.34, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 70, "s": [100, 84, 100]}, {"t": 75, "s": [100, 100, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[-4.43, 0], [-0.9, 0.98], [3.53, 0.18], [0, 0], [0.67, 0], [0.63, 0.03], [0, 0], [0.77, -0.83]], "o": [[4.43, 0], [-0.77, -0.83], [0, 0], [-0.63, 0.03], [-0.67, 0], [0, 0], [-3.53, 0.18], [0.91, 0.98]], "v": [[0, 1.71], [9.03, -0.01], [1.96, -1.68], [1.96, 0.4], [0, 0.44], [-1.96, 0.4], [-1.96, -1.68], [-9.03, -0.01]], "c": true}}}, {"ind": 1, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [-0.02, -1.04], [0, 0], [4.15, -0.21], [0, 0], [0, 0], [0, 0], [0.02, 1.04], [0, 0], [-4.15, 0.21], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [4.15, 0.21], [0, 0], [-0.02, 1.04], [0, 0], [0, 0], [0, 0], [-4.15, -0.21], [0, 0], [0.02, -1.04], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[7.87, -8.06], [7.87, -5.2], [1.96, -5.2], [1.96, -3.22], [9.24, -1.1], [9.24, 1.08], [1.96, 3.2], [1.96, 8.06], [-1.96, 8.06], [-1.96, 3.2], [-9.24, 1.08], [-9.24, -1.1], [-1.96, -3.22], [-1.96, -5.2], [-7.87, -5.2], [-7.87, -8.06]], "c": true}}}, {"ty": "mm", "mm": 1}, {"ty": "fl", "c": {"k": [1, 1, 1, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [346.45, 93.87]}, "a": {"k": [0, 0]}, "s": {"k": [192.31, 192.31]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0, 0.72549021244, 0.72549021244, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, -16.38], [16.38, 0], [0, 16.38], [-16.38, 0]], "o": [[0, 16.38], [-16.38, 0], [0, -16.38], [16.38, 0]], "v": [[29.67, 0], [0, 29.67], [-29.67, 0], [0, -29.67]], "c": true}}}, {"ty": "fl", "c": {"k": [0.180392161012, 0.568627476692, 0.568627476692, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 60, "s": [0, 9], "to": [0, -0.75], "ti": [0, 1.88]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 65, "s": [0, 14.5], "to": [0, -1.87], "ti": [0, 0.75]}, {"t": 70, "s": [0, 0]}]}, "a": {"k": [0, 0]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 60, "s": [104, 116]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 65, "s": [110, 134]}, {"t": 70, "s": [116, 116]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [346, 92]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 180, "st": 0}]}], "layers": [{"ind": 1, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [246.44, 128.19, 0], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [106, 75.54, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 263, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 275, "s": [80]}, {"t": 297, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 263, "s": [18]}, {"t": 275, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 263, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 275, "s": [80]}, {"t": 297, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 263, "s": [18]}, {"t": 275, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 263, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 275, "s": [80]}, {"t": 297, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 263, "s": [18]}, {"t": 275, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 263, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 275, "s": [80]}, {"t": 297, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 263, "s": [18]}, {"t": 275, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 275, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 300, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 263, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 275, "s": [100, 100]}, {"t": 300, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [-57.5, -55.5]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 263, "op": 300, "st": 206}, {"ind": 2, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [344.44, 194.19, 0], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [110, 110, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 243, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 255, "s": [80]}, {"t": 277, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 243, "s": [18]}, {"t": 255, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 243, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 255, "s": [80]}, {"t": 277, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 243, "s": [18]}, {"t": 255, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 243, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 255, "s": [80]}, {"t": 277, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 243, "s": [18]}, {"t": 255, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 243, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 255, "s": [80]}, {"t": 277, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 243, "s": [18]}, {"t": 255, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 255, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 280, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 243, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 255, "s": [100, 100]}, {"t": 280, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [-57.5, -55.5]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 243, "op": 281, "st": 186}, {"ind": 3, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [160.44, 224.19, 0], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [106, 75.54, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 228, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 236, "s": [80]}, {"t": 256, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 228, "s": [18]}, {"t": 236, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 228, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 236, "s": [80]}, {"t": 256, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 228, "s": [18]}, {"t": 236, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 228, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 236, "s": [80]}, {"t": 256, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 228, "s": [18]}, {"t": 236, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 228, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 236, "s": [80]}, {"t": 256, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 228, "s": [18]}, {"t": 236, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 236, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 259, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 228, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 236, "s": [100, 100]}, {"t": 259, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [-57.5, -55.5]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 228, "op": 260, "st": 171}, {"ind": 4, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [315.44, 162.19, 0], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [94, 94, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 214, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 222, "s": [80]}, {"t": 230, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 214, "s": [18]}, {"t": 222, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 214, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 222, "s": [80]}, {"t": 230, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 214, "s": [18]}, {"t": 222, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 214, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 222, "s": [80]}, {"t": 230, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 214, "s": [18]}, {"t": 222, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 214, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 222, "s": [80]}, {"t": 230, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 214, "s": [18]}, {"t": 222, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 222, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 233, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 214, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 222, "s": [100, 100]}, {"t": 233, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [-57.5, -55.5]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 214, "op": 234, "st": 157}, {"ind": 5, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [231.44, 139.19, 0], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [73, 73, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 200, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 208, "s": [80]}, {"t": 216, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 200, "s": [18]}, {"t": 208, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 200, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 208, "s": [80]}, {"t": 216, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 200, "s": [18]}, {"t": 208, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 200, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 208, "s": [80]}, {"t": 216, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 200, "s": [18]}, {"t": 208, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 200, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 208, "s": [80]}, {"t": 216, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 200, "s": [18]}, {"t": 208, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 208, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 219, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 200, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 208, "s": [100, 100]}, {"t": 219, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.5, "y": 1}, "o": {"x": 0.17, "y": 0.17}, "t": 200, "s": [-57.5, -55.5], "to": [0, -3.33], "ti": [0, 3.33]}, {"t": 219, "s": [-57.5, -75.5]}]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 200, "op": 220, "st": 143}, {"ind": 6, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [270.44, 256.19, 0], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [111, 111, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 51, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 59, "s": [80]}, {"t": 73, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 51, "s": [18]}, {"t": 59, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 51, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 59, "s": [80]}, {"t": 73, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 51, "s": [18]}, {"t": 59, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 51, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 59, "s": [80]}, {"t": 73, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 51, "s": [18]}, {"t": 59, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 51, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 59, "s": [80]}, {"t": 73, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 51, "s": [18]}, {"t": 59, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 59, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 76, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 51, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 59, "s": [100, 100]}, {"t": 76, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.5, "y": 1}, "o": {"x": 0.17, "y": 0.17}, "t": 51, "s": [-57.5, -55.5], "to": [0, -3.33], "ti": [0, 3.33]}, {"t": 76, "s": [-57.5, -75.5]}]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 51, "op": 77, "st": -6}, {"ind": 7, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [200.44, 241.19, 0], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [118, 118, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 36, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 44, "s": [80]}, {"t": 52, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 36, "s": [18]}, {"t": 44, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 36, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 44, "s": [80]}, {"t": 52, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 36, "s": [18]}, {"t": 44, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 36, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 44, "s": [80]}, {"t": 52, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 36, "s": [18]}, {"t": 44, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 36, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 44, "s": [80]}, {"t": 52, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 36, "s": [18]}, {"t": 44, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 44, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 55, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 36, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 44, "s": [100, 100]}, {"t": 55, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.5, "y": 1}, "o": {"x": 0.17, "y": 0.17}, "t": 36, "s": [-57.5, -55.5], "to": [0, -3.33], "ti": [0, 3.33]}, {"t": 55, "s": [-57.5, -75.5]}]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 36, "op": 56, "st": -21}, {"ind": 8, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [343.44, 238.19, 0], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [82, 82, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 27, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 35, "s": [80]}, {"t": 43, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 27, "s": [18]}, {"t": 35, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 27, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 35, "s": [80]}, {"t": 43, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 27, "s": [18]}, {"t": 35, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 27, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 35, "s": [80]}, {"t": 43, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 27, "s": [18]}, {"t": 35, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 27, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 35, "s": [80]}, {"t": 43, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 27, "s": [18]}, {"t": 35, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 35, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 46, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 27, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 35, "s": [100, 100]}, {"t": 46, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"a": 1, "k": [{"i": {"x": 0.5, "y": 1}, "o": {"x": 0.17, "y": 0.17}, "t": 27, "s": [-57.5, -55.5], "to": [0, -3.33], "ti": [0, 3.33]}, {"t": 46, "s": [-57.5, -75.5]}]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 27, "op": 47, "st": -30}, {"ind": 9, "ty": 4, "parent": 21, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [50.66, -184.2, 0], "l": 2}, "a": {"k": [257.66, 216.8, 0], "l": 2}, "s": {"k": [100, 100, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 170, "s": [{"i": [[11, 2], [0, 0], [-2.75, -7.75], [20.5, -7]], "o": [[-10, 8.5], [0, 0], [1.12, 3.14], [40, -13]], "v": [[42.08, -17.98], [22.38, -6.82], [31.83, 10.77], [14.08, 31.02]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 171, "s": [{"i": [[39.4, 1.3], [4.31, -6.56], [-8.66, -3.76], [24.57, -11.79]], "o": [[-13.77, 8.37], [11.81, -0.56], [10.25, 4.45], [49.41, -14.91]], "v": [[12.68, -31.28], [-10.72, -13.92], [17.83, -6.93], [14.08, 34.02]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 172, "s": [{"i": [[44.75, -11.49], [11.41, -10.46], [-2.46, -0.63], [28.63, -16.58]], "o": [[-5.87, 2.43], [10.91, -2.96], [28.75, 7.32], [57, -22.5]], "v": [[-0.05, -31.41], [-22.82, -15.02], [-0.17, -14.3], [13.58, 35.52]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 174, "s": [{"i": [[47.43, -17.89], [18.2, -29.66], [-8.37, 2.88], [30.67, -18.97]], "o": [[-15.68, 5.92], [11.21, -17.41], [37.34, -12.82], [63.53, -17.79]], "v": [[1.08, -31.48], [-50.62, 19.68], [-18.17, -6.73], [14.08, 37.02]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 180, "s": [{"i": [[46.06, -12.83], [18.2, -29.66], [-8.71, 1.58], [30.67, -18.97]], "o": [[-16.15, 4.5], [10.3, -11.21], [35.76, -6.47], [63.53, -17.79]], "v": [[0.89, -20.37], [-50.62, 19.68], [-18.36, 2.16], [13.89, 45.91]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.73, "y": 0}, "t": 190, "s": [{"i": [[46.06, -10.63], [15.68, -20.1], [-8.76, 0.92], [30.67, -15.71]], "o": [[-16.15, 3.73], [9.05, -5.5], [35.52, -3.75], [63.53, -14.73]], "v": [[0.89, -7.51], [-50.62, 19.68], [-18.36, 11.15], [13.89, 47.38]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 199, "s": [{"i": [[47.43, -17.89], [20.56, -17.22], [-6.93, 6.91], [30.67, -18.97]], "o": [[-15.68, 5.92], [17.25, -13.56], [27.96, -27.87], [63.53, -17.79]], "v": [[-0.58, -36.51], [-50.62, 19.68], [-19.83, -11.75], [12.42, 32]], "c": true}]}, {"t": 209, "s": [{"i": [[47.43, -17.89], [18.2, -29.66], [-8.37, 2.88], [30.67, -18.97]], "o": [[-15.68, 5.92], [11.21, -17.41], [37.34, -12.82], [63.53, -17.79]], "v": [[1.08, -31.48], [-50.62, 19.68], [-18.17, -6.73], [14.08, 37.02]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156863213, 0.803921580315, 0.035294119269, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [301.42, 231.98]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"a": 1, "k": [{"t": 155, "s": [0], "h": 1}, {"t": 170, "s": [100], "h": 1}]}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 161, "s": [{"i": [[0, 0], [0, 0], [0, 0], [20.55, 8.44]], "o": [[0, 0], [0, 0], [0, 0], [4.47, 0.56]], "v": [[-29.64, -5.98], [-0.31, 4.52], [-26.64, 18.28], [-56.19, 5.91]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 169, "s": [{"i": [[-3.74, 4.54], [-13.15, 5.12], [10.69, -4.36], [39.55, 15.94]], "o": [[30.03, 10.74], [-2.65, 10.12], [-14.81, 6.04], [4.47, 0.56]], "v": [[-32.17, -7.38], [35.01, -10.26], [16.17, 12.22], [-56.19, 5.91]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 174, "s": [{"i": [[-6.65, 8.07], [-9.03, 17.9], [15.01, -7.37], [17.55, 9.94]], "o": [[39.5, 16.83], [-2.67, 18.33], [-23.68, 11.64], [4.47, 0.56]], "v": [[-34.14, -8.48], [52.69, -19.98], [26.36, 17.28], [-56.19, 5.91]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 180, "s": [{"i": [[-6.65, 8.07], [-9.03, 17.9], [15.01, -7.37], [17.55, 9.94]], "o": [[39.5, 16.83], [-2.67, 18.33], [-23.68, 11.64], [4.47, 0.56]], "v": [[-34.14, -8.48], [52.5, -11.09], [26.17, 26.17], [-56.19, 5.91]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.73, "y": 0}, "t": 190, "s": [{"i": [[-6.65, 8.07], [-9.03, 14.83], [15.01, -6.11], [17.55, 9.94]], "o": [[39.5, 16.83], [-2.67, 15.18], [-23.68, 9.64], [4.47, 0.56]], "v": [[-34.14, -8.48], [52.5, -2.69], [26.17, 28.17], [-56.19, 5.91]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 199, "s": [{"i": [[-6.65, 8.07], [-9.03, 17.9], [15.01, -7.37], [17.55, 9.94]], "o": [[39.5, 16.83], [-2.67, 18.33], [-23.68, 11.64], [4.47, 0.56]], "v": [[-34.14, -8.48], [51.04, -25], [24.7, 12.25], [-56.19, 5.91]], "c": true}]}, {"t": 209, "s": [{"i": [[-6.65, 8.07], [-9.03, 17.9], [15.01, -7.37], [17.55, 9.94]], "o": [[39.5, 16.83], [-2.67, 18.33], [-23.68, 11.64], [4.47, 0.56]], "v": [[-34.14, -8.48], [52.69, -19.98], [26.36, 17.28], [-56.19, 5.91]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.976470589638, 0.607843160629, 0.019607843831, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [299.64, 248.64]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"a": 1, "k": [{"t": 155, "s": [0], "h": 1}, {"t": 161, "s": [100], "h": 1}]}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [299.64, 248.64]}, "a": {"k": [299.64, 248.64]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 161, "s": [{"i": [[8.94, -5.53], [-8, -6], [-8.77, 1.72], [-3.25, -14.54]], "o": [[-13.97, -26.85], [5.5, -7.5], [26.18, -5.14], [-7.91, 6.92]], "v": [[-37.8, -58.98], [-41.19, -30], [-10.92, -40.22], [18.69, -52.75]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 168, "s": [{"i": [[5.86, -3.17], [5.15, -4.4], [-6.82, 1.02], [-2.27, -4.65]], "o": [[-13.14, -7.17], [-1.35, -11.4], [11.18, 0.02], [-6.27, 1.35]], "v": [[-6.05, -39.83], [-31.84, -41.1], [-6.37, -60.52], [20.09, -51.85]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 170, "s": [{"i": [[8.61, -5.02], [4.5, -25.5], [-26.77, 0.72], [-2.87, -6.75]], "o": [[-10.89, -17.52], [0.5, -16], [16.91, -0.46], [-6.37, 2.25]], "v": [[11.7, -25.48], [-33.69, -29], [2.58, -50.22], [32.69, -36.75]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 173, "s": [{"i": [[8.94, -5.53], [19.5, -15], [-8.77, 1.72], [-3.25, -14.54]], "o": [[-13.97, -26.85], [5.5, -7.5], [26.18, -5.14], [-7.91, 6.92]], "v": [[28.2, 44.02], [-47.19, -28], [-19.42, -43.72], [47.19, 27.25]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 180, "s": [{"i": [[8.94, -5.53], [19.5, -15], [-8.77, 1.72], [-3.25, -14.54]], "o": [[-13.97, -26.85], [5.5, -7.5], [26.18, -5.14], [-7.91, 6.92]], "v": [[28.2, 44.02], [-47.06, -13], [-19.29, -28.72], [47.19, 27.25]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.73, "y": 0}, "t": 190, "s": [{"i": [[8.94, -5.53], [19.5, -12.87], [-8.77, 1.48], [-3.25, -14.54]], "o": [[-13.97, -26.85], [5.5, -6.43], [26.18, -4.41], [-7.91, 6.92]], "v": [[28.2, 44.02], [-47.29, 4.5], [-19.52, -8.99], [47.19, 27.25]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 199, "s": [{"i": [[8.94, -5.53], [19.5, -15], [-8.89, -0.97], [-19.47, -10.03]], "o": [[-28.64, -15.84], [5.5, -7.5], [26.71, 2.92], [-7.91, 6.92]], "v": [[28.2, 44.02], [-35.04, -33.48], [-5.61, -46.46], [47.19, 27.25]], "c": true}]}, {"t": 209, "s": [{"i": [[8.94, -5.53], [19.5, -15], [-8.77, 1.72], [-3.25, -14.54]], "o": [[-13.97, -26.85], [5.5, -7.5], [26.18, -5.14], [-7.91, 6.92]], "v": [[28.2, 44.02], [-47.19, -28], [-19.42, -43.72], [47.19, 27.25]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156863213, 0.803921580315, 0.035294119269, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [217.69, 209]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"a": 1, "k": [{"t": 154, "s": [0], "h": 1}, {"t": 168, "s": [100], "h": 1}]}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 161, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[34.51, 43.89], [12.99, 34.89], [38.01, 23.11], [57.51, 30.39]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 163, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[44.32, 48.24], [-33.62, 16.37], [-3.01, 5.13], [72.47, 35.57]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 166, "s": [{"i": [[2.59, -1.44], [13.98, 27.91], [-8.75, -2.33], [-23.7, -7.47]], "o": [[-20.01, -7.24], [-0.97, -1.93], [2.46, 22.32], [-1.7, 0.73]], "v": [[44, 48.14], [-36.97, -2.51], [-7.45, -15.43], [72.59, 35.57]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 168, "s": [{"i": [[5.18, -2.88], [-1.16, 45.79], [-17.5, -4.67], [-41.7, -10.19]], "o": [[-40.02, -14.48], [-0.61, -4.23], [-16.6, 27.88], [-3.41, 1.45]], "v": [[43.69, 48.04], [-27.33, -43.9], [3.11, -46.49], [72.71, 35.58]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 170, "s": [{"i": [[6.48, -3.6], [-2.49, 27.73], [-21.88, -5.84], [-73.26, -19.69]], "o": [[-63.02, -24.1], [0.48, -5.37], [-16.88, 19.16], [-4.26, 1.81]], "v": [[43.54, 47.99], [-30, -31.84], [-2.11, -47.27], [72.77, 35.58]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 173, "s": [{"i": [[7.5, -5], [-0.01, 24.67], [-20, 4.29], [-40, -11.5]], "o": [[-25, -9], [0.01, -9.71], [-16, 23.79], [-7, 3.5]], "v": [[44.51, 48.89], [-46.51, -17.11], [-22.99, -42.39], [71.01, 35.39]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 180, "s": [{"i": [[7.5, -5], [-0.01, 24.67], [-20, 4.29], [-40, -11.5]], "o": [[-25, -9], [0.01, -9.71], [-16, 23.79], [-7, 3.5]], "v": [[44.51, 48.89], [-46.38, -2.11], [-22.87, -27.39], [71.01, 35.39]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.73, "y": 0}, "t": 190, "s": [{"i": [[7.5, -5], [-0.01, 21.16], [-20, 3.68], [-40, -11.5]], "o": [[-25, -9], [0.01, -8.33], [-16, 20.4], [-7, 3.5]], "v": [[44.51, 48.89], [-46.61, 13.39], [-23.09, -8.3], [71.01, 35.39]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 199, "s": [{"i": [[7.5, -5], [-0.01, 24.67], [-20, 4.29], [-74.98, -10.78]], "o": [[-49.59, -15.59], [0.01, -9.71], [-16, 23.79], [-7, 3.5]], "v": [[44.51, 48.89], [-41.54, -17.57], [-10.85, -47.87], [71.01, 35.39]], "c": true}]}, {"t": 209, "s": [{"i": [[7.5, -5], [-0.01, 24.67], [-20, 4.29], [-40, -11.5]], "o": [[-25, -9], [0.01, -9.71], [-16, 23.79], [-7, 3.5]], "v": [[44.51, 48.89], [-46.51, -17.11], [-22.99, -42.39], [71.01, 35.39]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.976470589638, 0.607843160629, 0.019607843831, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [213.49, 212.11]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"a": 1, "k": [{"t": 154, "s": [0], "h": 1}, {"t": 161, "s": [100], "h": 1}]}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [213.49, 212.11]}, "a": {"k": [213.49, 212.11]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 301, "st": 0}, {"ind": 10, "ty": 4, "parent": 21, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [49.33, -79.33, 0], "l": 2}, "a": {"k": [256.33, 321.67, 0], "l": 2}, "s": {"k": [100, 100, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 154, "s": [{"i": [[0, 0], [-7.95, 1.48], [-5.75, -1.97], [0, 0]], "o": [[0, 0], [9.05, 1.48], [-10.25, 5.03], [0, 0]], "v": [[16.21, -18.11], [22.79, -27.34], [50.08, -19.39], [45.71, -7.61]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 155, "s": [{"i": [[0, 0], [-8.95, 11.48], [-5.75, -1.97], [0, 0]], "o": [[0, 0], [8.05, 2.48], [-8.75, 11.53], [0, 0]], "v": [[16.25, -17.11], [15.33, -34.84], [44.12, -24.39], [45.75, -6.61]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 159, "s": [{"i": [[0, 0], [-8.95, 11.48], [-5.75, -1.97], [0, 0]], "o": [[0, 0], [8.05, 2.48], [-8.75, 11.53], [0, 0]], "v": [[-13.79, -5.11], [-14.71, -22.84], [14.08, -12.39], [15.71, 5.39]], "c": true}]}, {"t": 162, "s": [{"i": [[0, 0], [0, 0], [-9.33, -4.2], [0, 0]], "o": [[0, 0], [9.63, 4.24], [0, 0], [0, 0]], "v": [[-45.5, 7.39], [-45.92, 7.16], [-17.13, 19.11], [-18, 19.39]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156863213, 0.803921580315, 0.035294119269, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [288.17, 235.86]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 155, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[16.25, -17.11], [15.83, -16.84], [46.12, -6.39], [45.75, -6.61]], "c": true}]}, {"t": 162, "s": [{"i": [[0, 0], [0, 0], [-9.33, -4.2], [0, 0]], "o": [[0, 0], [9.63, 4.24], [0, 0], [0, 0]], "v": [[16, -17.11], [-45.92, 7.16], [-17.13, 19.11], [45.5, -6.61]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156863213, 0.803921580315, 0.035294119269, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [288.17, 235.86]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [225.67, 234.86]}, "a": {"k": [288.17, 235.86]}, "s": {"k": [-100, 100]}, "r": {"k": 0}, "o": {"a": 1, "k": [{"t": 139, "s": [0], "h": 1}, {"t": 154, "s": [100], "h": 1}]}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 144, "s": [{"i": [[0.17, -5.08], [-6.42, -1.19], [-6.29, 0.03], [2.05, 0.73]], "o": [[6.42, 4.17], [-0.42, -3.19], [-4.29, -3.22], [-8.45, 2.23]], "v": [[-58.62, 163.48], [-29.54, 175.84], [-19.42, 163.37], [-47.5, 149.42]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 145, "s": [{"i": [[0, 0], [0, 0], [-5.25, -1.97], [3.75, 0.62]], "o": [[0, 0], [0, 0], [-4.29, -3.22], [-6.16, -1.02]], "v": [[-52.16, 150.73], [-26.08, 162.59], [-19.46, 158.87], [-46.54, 145.42]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 149, "s": [{"i": [[0, 0], [0, 0], [-11.79, 1.03], [3.8, -0.02]], "o": [[0, 0], [0, 0], [-4.29, -3.22], [-9.57, 0.04]], "v": [[-55.12, 130.98], [-29.04, 142.84], [-18.92, 130.37], [-47, 118.92]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 155, "s": [{"i": [[0, 0], [0, 0], [-11.79, 1.03], [3.8, -0.02]], "o": [[0, 0], [0, 0], [-4.29, -3.22], [-9.57, 0.04]], "v": [[-55.04, 19.98], [-28.96, 31.84], [-18.84, 19.37], [-46.92, 7.92]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 156, "s": [{"i": [[2.08, -0.83], [0.25, -0.44], [5.25, 4.73], [5, 1.83]], "o": [[0.58, 0.17], [14.75, -2.44], [-3.25, -2.02], [10.5, 18.58]], "v": [[-48.29, 6.73], [-19.21, 19.84], [-4.96, -0.08], [-33.95, -12.43]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 158, "s": [{"i": [[42.08, -15.83], [0.25, -0.44], [5.25, 4.73], [5, 1.83]], "o": [[0.58, 0.17], [56, -19.44], [-3.25, -2.02], [10.5, 18.58]], "v": [[-48.29, 6.73], [-19.21, 19.84], [23.79, -15.58], [-5.2, -27.93]], "c": true}]}, {"t": 160, "s": [{"i": [[0, 0], [0, 0], [0, 0], [9.63, 4.24]], "o": [[0, 0], [0, 0], [-9.33, -4.2], [0, 0]], "v": [[-48.29, 6.73], [-19.21, 19.84], [48.29, -7.88], [19.5, -19.83]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156863213, 0.803921580315, 0.035294119269, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [222.96, 261.85]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 145, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.5, 60.44], [-14.67, 64.29], [14.67, 78.56], [14.83, 74.19]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 149, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.5, 35.44], [-14.67, 64.29], [14.67, 78.56], [14.92, 45.19]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 155, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.67, -78.56], [-14.67, 64.29], [14.67, 78.56], [14.67, -65.56]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 168, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.67, -78.56], [-14.67, 64.29], [14.67, 78.56], [14.67, -65.56]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 1, "y": 0}, "t": 180, "s": [{"i": [[2.79, 1.56], [-7.67, -20.74], [-1.09, -1.13], [-10.64, 16.33]], "o": [[-13.58, 24.89], [0.06, -0.74], [-5.64, -12.24], [0.73, -1.44]], "v": [[-14.67, -78.56], [-14.67, 64.29], [14.67, 78.56], [14.67, -65.56]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.75, "y": 0}, "t": 195, "s": [{"i": [[3.44, 1.3], [4.11, -24.73], [-0.56, -0.82], [3, 49.67]], "o": [[6.22, 30.39], [3, 2.08], [3.89, -27.64], [0.22, 0.12]], "v": [[-14.67, -78.56], [-14.78, 67.93], [14.33, 80.84], [14.67, -65.56]], "c": true}]}, {"t": 208, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.67, -78.56], [-14.67, 64.29], [14.67, 78.56], [14.67, -65.56]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.976470649242, 0.607843160629, 0.019607843831, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [189.33, 346.9]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [289.96, 261.85]}, "a": {"k": [222.96, 261.85]}, "s": {"k": [-100, 100]}, "r": {"k": 0}, "o": {"a": 1, "k": [{"t": 139, "s": [0], "h": 1}, {"t": 144, "s": [100], "h": 1}]}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 142, "s": [{"i": [[0.17, -5.08], [-6.42, -1.19], [-6.29, 0.03], [2.05, 0.73]], "o": [[6.42, 4.17], [-0.42, -3.19], [-4.29, -3.22], [-8.45, 2.23]], "v": [[-58.62, 163.48], [-29.54, 175.84], [-19.42, 163.37], [-47.5, 149.42]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 143, "s": [{"i": [[0, 0], [0, 0], [-5.25, -1.97], [3.75, 0.62]], "o": [[0, 0], [0, 0], [-4.29, -3.22], [-6.16, -1.02]], "v": [[-52.16, 150.73], [-26.08, 162.59], [-19.46, 158.87], [-46.54, 145.42]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 144, "s": [{"i": [[0, 0], [0, 0], [-11.79, 1.03], [3.8, -0.02]], "o": [[0, 0], [0, 0], [-4.29, -3.22], [-9.57, 0.04]], "v": [[-55.12, 130.98], [-29.04, 142.84], [-18.92, 130.37], [-47, 118.92]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 150, "s": [{"i": [[0, 0], [0, 0], [-11.79, 1.03], [3.8, -0.02]], "o": [[0, 0], [0, 0], [-4.29, -3.22], [-9.57, 0.04]], "v": [[-55.04, 19.98], [-28.96, 31.84], [-18.84, 19.37], [-46.92, 7.92]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 151, "s": [{"i": [[2.08, -0.83], [0.25, -0.44], [5.25, 4.73], [5, 1.83]], "o": [[0.58, 0.17], [14.75, -2.44], [-3.25, -2.02], [10.5, 18.58]], "v": [[-48.29, 6.73], [-19.21, 19.84], [-4.96, -0.08], [-33.95, -12.43]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 153, "s": [{"i": [[42.08, -15.83], [0.25, -0.44], [5.25, 4.73], [5, 1.83]], "o": [[0.58, 0.17], [56, -19.44], [-3.25, -2.02], [10.5, 18.58]], "v": [[-48.29, 6.73], [-19.21, 19.84], [23.79, -15.58], [-5.2, -27.93]], "c": true}]}, {"t": 154, "s": [{"i": [[0, 0], [0, 0], [0, 0], [9.63, 4.24]], "o": [[0, 0], [0, 0], [-9.33, -4.2], [0, 0]], "v": [[-48.29, 6.73], [-19.21, 19.84], [48.29, -7.88], [19.5, -19.83]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156863213, 0.803921580315, 0.035294119269, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [222.96, 261.85]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 143, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.5, 60.44], [-14.67, 64.29], [14.67, 78.56], [14.83, 74.19]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 144, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.5, 35.44], [-14.67, 64.29], [14.67, 78.56], [14.92, 45.19]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 150, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.67, -78.56], [-14.67, 64.29], [14.67, 78.56], [14.67, -65.56]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 168, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.67, -78.56], [-14.67, 64.29], [14.67, 78.56], [14.67, -65.56]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 1, "y": 0}, "t": 180, "s": [{"i": [[2.79, 1.56], [-7.67, -20.74], [-1.09, -1.13], [-10.64, 16.33]], "o": [[-13.58, 24.89], [0.06, -0.74], [-5.64, -12.24], [0.73, -1.44]], "v": [[-14.67, -78.56], [-14.67, 64.29], [14.67, 78.56], [14.67, -65.56]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.75, "y": 0}, "t": 195, "s": [{"i": [[3.44, 1.3], [4.11, -24.73], [-0.56, -0.82], [3, 49.67]], "o": [[6.22, 30.39], [3, 2.08], [3.89, -27.64], [0.22, 0.12]], "v": [[-14.67, -78.56], [-14.78, 67.93], [14.33, 80.84], [14.67, -65.56]], "c": true}]}, {"t": 208, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-14.67, -78.56], [-14.67, 64.29], [14.67, 78.56], [14.67, -65.56]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156863213, 0.803921580315, 0.035294119269, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [189.33, 346.9]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [222.96, 261.85]}, "a": {"k": [222.96, 261.85]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"a": 1, "k": [{"t": 141, "s": [0], "h": 1}, {"t": 142, "s": [100], "h": 1}]}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 153, "s": [{"i": [[0, 0], [-7.95, 1.48], [-5.75, -1.97], [0, 0]], "o": [[0, 0], [9.05, 1.48], [-10.25, 5.03], [0, 0]], "v": [[16.21, -18.11], [22.79, -27.34], [50.08, -19.39], [45.71, -7.61]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 157, "s": [{"i": [[0, 0], [-8.95, 11.48], [-5.75, -1.97], [0, 0]], "o": [[0, 0], [8.05, 2.48], [-8.75, 11.53], [0, 0]], "v": [[-13.79, -5.11], [-14.71, -22.84], [14.08, -12.39], [15.71, 5.39]], "c": true}]}, {"t": 161, "s": [{"i": [[0, 0], [0, 0], [-9.33, -4.2], [0, 0]], "o": [[0, 0], [9.63, 4.24], [0, 0], [0, 0]], "v": [[-45.29, 6.39], [-45.71, 6.16], [-16.92, 18.11], [-17.79, 18.39]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156863213, 0.803921580315, 0.035294119269, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [288.17, 235.86]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 153, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[16.21, -18.11], [15.79, -17.84], [46.08, -7.39], [45.71, -7.61]], "c": true}]}, {"t": 161, "s": [{"i": [[0, 0], [0, 0], [-9.33, -4.2], [0, 0]], "o": [[0, 0], [9.63, 4.24], [0, 0], [0, 0]], "v": [[16.21, -18.11], [-45.71, 6.16], [-16.92, 18.11], [45.71, -7.61]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.949019610882, 0.474509805441, 0.152941182256, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156863213, 0.803921580315, 0.035294119269, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [288.17, 235.86]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [288.17, 235.86]}, "a": {"k": [288.17, 235.86]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"a": 1, "k": [{"t": 139, "s": [0], "h": 1}, {"t": 153, "s": [100], "h": 1}]}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 301, "st": 0}, {"ind": 11, "ty": 4, "parent": 21, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"a": 1, "k": [{"i": {"x": 0.5, "y": 1}, "o": {"x": 0.17, "y": 0.17}, "t": 120, "s": [50.27, -201.25, 0], "to": [0, 6.23, 0], "ti": [0, -15.32, 0]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 1, "y": 0}, "t": 134, "s": [50.27, -284.64, 0], "to": [0, 21.19, 0], "ti": [0, -8.61, 0]}, {"t": 146, "s": [50.27, -152.25, 0]}], "l": 2}, "a": {"k": [257.27, 248.75, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 120, "s": [0, 0, 100]}, {"t": 144, "s": [100, 100, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 127, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[0.4, 91.93], [131.21, 19.33], [0.36, -48.29], [-131.32, 19]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 130, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-52.85, 28.76], [95.73, 14.81], [62.88, 1.73], [-97.23, 18.68]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 131, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[83.8, 18.9], [83.9, 18.9], [83.72, 18.4], [-85.86, 18.57]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 132, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[73.69, -14.15], [90.29, -13.04], [73.61, -14.65], [-91.27, -13.38]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 135, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-0.04, -13.5], [132.06, -4.67], [-0.12, -14], [-132.06, -5]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 140, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-85.27, -14.13], [83.47, -13.5], [83.29, -15.26], [-86.29, -13.83]], "c": true}]}, {"t": 146, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-0.02, -51], [132.06, -4.67], [-0.06, 51], [-132.06, -5]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.262745112181, 0.250980407, 0.800000011921, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"a": 1, "k": [{"t": 127, "s": [0.28, 0.28, 0.87, 1], "h": 1}, {"t": 131, "s": [0.47, 0.58, 0.99, 1], "h": 1}]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [257.27, 252.67]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 127, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[66.17, -103.68], [-66.06, -33.47], [-66, -17], [66.77, -83.74]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 131, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[149.85, -50.24], [-19.68, -50.58], [-19.5, -17], [150, -16.96]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 135, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[65.9, -48.82], [-66, -40.33], [-66, -17], [66, -15.17]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 140, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[149.85, -50.24], [-19.68, -50.58], [-19.5, -17], [150, -16.96]], "c": true}]}, {"t": 146, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[65.96, -39.32], [-66, -40.33], [-66, -17], [66, 40.33]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.262745112181, 0.250980407, 0.800000011921, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 131, "s": [0.47, 0.58, 0.99, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 134, "s": [0.4, 0.42, 1, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 135, "s": [0.47, 0.58, 0.99, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 141, "s": [0.47, 0.58, 0.99, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 145, "s": [0.4, 0.42, 1, 1]}, {"t": 146, "s": [0.47, 0.58, 0.99, 1], "h": 1}]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [191.21, 288]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 127, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[65.95, -16.84], [-65.25, -84.24], [-65.69, -104.18], [65.97, -33.76]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 131, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[17.82, -23.84], [17.98, -17.47], [17.99, -50.75], [17.97, -50.51]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 135, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[65.95, -16.84], [-66.02, -15.67], [-65.97, -49.33], [66.02, -39.76]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 140, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[17.82, -23.84], [17.98, -17.47], [17.99, -50.75], [17.97, -50.51]], "c": true}]}, {"t": 146, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[65.95, -16.84], [-66.02, 39.83], [-65.9, -39.83], [66.02, -39.76]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.262745112181, 0.250980407, 0.800000011921, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"a": 1, "k": [{"t": 127, "s": [0.4, 0.42, 1, 1], "h": 1}, {"t": 132, "s": [0.47, 0.58, 0.99, 1], "h": 1}, {"t": 135, "s": [0.4, 0.42, 1, 1], "h": 1}, {"t": 140, "s": [0.47, 0.58, 0.99, 1], "h": 1}, {"t": 146, "s": [0.4, 0.42, 1, 1], "h": 1}]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [323.23, 288.51]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [323.23, 288.51]}, "a": {"k": [323.23, 288.51]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]], "v": [[123.67, -33.19], [123.67, -19.7], [-0.33, 33.53], [-123.66, -20.03], [-123.66, -33.53]], "c": true}}}, {"ty": "fl", "c": {"k": [0.262745112181, 0.250980407, 0.800000011921, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [257.54, 308.3]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"a": 1, "k": [{"t": 132, "s": [0], "h": 1}, {"t": 146, "s": [35], "h": 1}]}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [256.66, 255.42]}, "a": {"k": [256.66, 255.42]}, "s": {"a": 1, "k": [{"t": 119, "s": [100, 100], "h": 1}, {"t": 123, "s": [-100, 100], "h": 1}, {"t": 127, "s": [100, 100], "h": 1}, {"t": 131, "s": [-100, 100], "h": 1}, {"t": 135, "s": [100, 100], "h": 1}, {"t": 140, "s": [-100, 100], "h": 1}, {"t": 146, "s": [100, 100], "h": 1}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 301, "st": 0}, {"ind": 12, "ty": 4, "parent": 21, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.85}, "o": {"x": 0.17, "y": 0.17}, "t": 0, "s": [50, -98.75, 0], "to": [0, 1.68, 0], "ti": [0, -4.13, 0]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 1, "y": 0}, "t": 13, "s": [50, -184.14, 0], "to": [0, 5.71, 0], "ti": [0, -2.32, 0]}, {"t": 26, "s": [50, -78.75, 0]}], "l": 2}, "a": {"k": [257, 326.25, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 0, "s": [0, 0, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 34, "s": [97, 97, 100]}, {"t": 50, "s": [100, 100, 100]}], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 7, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[37.23, -101.67], [37.73, 22.67], [37.77, 65.17], [37.3, -102.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 10, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[61.73, -99.67], [61.79, 47.17], [-62.26, 55.67], [-62.73, -132.42]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 13, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[37.23, -101.67], [37.73, 22.67], [37.77, 65.17], [37.3, -102.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 17, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[61.73, -99.67], [61.79, 47.17], [-61.96, 76.67], [-61.81, -99.17]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 23, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[37.23, -101.67], [37.73, 22.67], [37.77, 65.17], [37.3, -102.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 31, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[61.73, -99.67], [61.73, 39.67], [-61.73, 99.67], [-61.7, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 38, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[61.73, -99.67], [61.73, 39.67], [-57.72, 99.67], [-57.69, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 58, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[61.73, -99.67], [61.73, 39.67], [-61.73, 99.67], [-61.7, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 168, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[61.73, -99.67], [61.73, 39.67], [-61.73, 99.67], [-61.7, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 1, "y": 0}, "t": 180, "s": [{"i": [[-9.59, 4.33], [14.9, -23.89], [0, 0], [0, 0]], "o": [[14.5, 18.78], [-0.1, -0.56], [0, 0], [0, 0]], "v": [[61.73, -99.67], [61.79, 33.56], [-61.73, 99.67], [-61.7, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.75, "y": 0}, "t": 195, "s": [{"i": [[-13.68, 5.7], [-6.74, -51.36], [0, 0], [0, 0]], "o": [[-4.79, 47.52], [0.48, -0.45], [0, 0], [0, 0]], "v": [[61.73, -99.67], [61.46, 45.58], [-61.73, 99.67], [-61.7, -47.67]], "c": true}]}, {"t": 208, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[61.73, -99.67], [61.73, 39.67], [-61.73, 99.67], [-61.7, -47.67]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.262745112181, 0.250980407, 0.800000011921, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"a": 1, "k": [{"t": 7, "s": [0.47, 0.58, 0.99, 1], "h": 1}, {"t": 10, "s": [0.4, 0.42, 1, 1], "h": 1}, {"t": 14, "s": [0.47, 0.58, 0.99, 1], "h": 1}, {"t": 17, "s": [0.4, 0.42, 1, 1], "h": 1}, {"t": 24, "s": [0.47, 0.58, 0.99, 1], "h": 1}, {"t": 31, "s": [0.4, 0.42, 1, 1], "h": 1}]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [318.95, 351.33]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 7, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-38.94, -102.17], [-39.44, 65.67], [161.43, 65.17], [160.94, -102.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 10, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-61.94, -99.67], [-61.89, 47.17], [61.4, 55.67], [60.91, -132.42]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 13, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-38.94, -102.17], [-39.44, 65.67], [161.43, 65.17], [160.94, -102.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 17, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-61.94, -99.67], [-61.89, 47.17], [61.7, 76.67], [61.84, -99.17]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 23, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-38.94, -102.17], [-39.44, 65.67], [161.43, 65.17], [160.94, -102.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 31, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-61.94, -99.67], [-61.94, 39.67], [61.93, 99.67], [61.94, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 38, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-61.94, -99.67], [-61.94, 39.67], [65.94, 99.67], [65.95, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 58, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-61.94, -99.67], [-61.94, 39.67], [61.93, 99.67], [61.94, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 168, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-61.94, -99.67], [-61.94, 39.67], [61.93, 99.67], [61.94, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 1, "y": 0}, "t": 180, "s": [{"i": [[10.47, 2.67], [-18.22, -33.33], [0, 0], [0, 0]], "o": [[-10.44, 12.11], [-0.04, -0.56], [0, 0], [0, 0]], "v": [[-61.94, -99.67], [-61.88, 33.56], [61.93, 99.67], [61.94, -47.67]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.75, "y": 0}, "t": 195, "s": [{"i": [[10.32, 2.06], [8.37, -56.82], [0, 0], [0, 0]], "o": [[5.88, 46.61], [0.04, -0.45], [0, 0], [0, 0]], "v": [[-61.94, -99.67], [-62.21, 45.58], [61.93, 99.67], [61.94, -47.67]], "c": true}]}, {"t": 208, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-61.94, -99.67], [-61.94, 39.67], [61.93, 99.67], [61.94, -47.67]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.262745112181, 0.250980407, 0.800000011921, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 7, "s": [0.47, 0.58, 0.99, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 9, "s": [0.42, 0.47, 1, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 10, "s": [0.47, 0.58, 0.99, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 13, "s": [0.47, 0.58, 0.99, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 16, "s": [0.42, 0.47, 1, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 17, "s": [0.47, 0.58, 0.99, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 23, "s": [0.47, 0.58, 0.99, 1]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 30, "s": [0.42, 0.47, 1, 1]}, {"t": 31, "s": [0.47, 0.58, 0.99, 1]}]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [195.29, 351.33]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [256.29, 332.83]}, "a": {"k": [256.29, 332.83]}, "s": {"a": 1, "k": [{"t": 0, "s": [100, 100], "h": 1}, {"t": 7, "s": [-100, 100], "h": 1}, {"t": 10, "s": [100, 100], "h": 1}, {"t": 13, "s": [-100, 100], "h": 1}, {"t": 17, "s": [100, 100], "h": 1}, {"t": 23, "s": [-100, 100], "h": 1}, {"t": 31, "s": [100, 100], "h": 1}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 301, "st": 0}, {"ind": 14, "ty": 0, "refId": "comp_0", "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [256, 256, 0], "l": 2}, "a": {"k": [256, 256, 0], "l": 2}, "s": {"k": [100, 100, 100], "l": 2}}, "w": 512, "h": 512, "ip": 0, "op": 180, "st": 0}, {"ind": 16, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.75, "y": 0}, "t": 103, "s": [209.44, 138.19, 0], "to": [0, 0, 0], "ti": [0, 0, 0]}, {"t": 131, "s": [209.44, 289.19, 0]}], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [71, 71, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 103, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 111, "s": [80]}, {"t": 137, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 103, "s": [18]}, {"t": 111, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 103, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 111, "s": [80]}, {"t": 137, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 103, "s": [18]}, {"t": 111, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 103, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 111, "s": [80]}, {"t": 137, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 103, "s": [18]}, {"t": 111, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 103, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 111, "s": [80]}, {"t": 137, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 103, "s": [18]}, {"t": 111, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 111, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 140, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 103, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 111, "s": [100, 100]}, {"t": 140, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [-57.5, -55.5]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 103, "op": 141, "st": 46}, {"ind": 17, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.76, "y": 0}, "t": 94, "s": [317.44, 178.19, 0], "to": [0, 0, 0], "ti": [0, 0, 0]}, {"t": 120, "s": [317.44, 338.19, 0]}], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [102, 102, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 94, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 102, "s": [80]}, {"t": 130, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 94, "s": [18]}, {"t": 102, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 94, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 102, "s": [80]}, {"t": 130, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 94, "s": [18]}, {"t": 102, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 94, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 102, "s": [80]}, {"t": 130, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 94, "s": [18]}, {"t": 102, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 94, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 102, "s": [80]}, {"t": 130, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 94, "s": [18]}, {"t": 102, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 102, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 133, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 94, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 102, "s": [100, 100]}, {"t": 133, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [-57.5, -55.5]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 94, "op": 134, "st": 37}, {"ind": 18, "ty": 4, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.75, "y": 0}, "t": 70, "s": [202.44, 133.19, 0], "to": [0, 0, 0], "ti": [0, 0, 0]}, {"t": 97, "s": [202.44, 349.19, 0]}], "l": 2}, "a": {"k": [-57.56, -55.81, 0], "l": 2}, "s": {"k": [127, 127, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.51, -57.4], [-57.5, -94.25]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 70, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 78, "s": [80]}, {"t": 102, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 70, "s": [18]}, {"t": 78, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.52, -57.04], [-84.75, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 70, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 78, "s": [80]}, {"t": 102, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 70, "s": [18]}, {"t": 78, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.71, -56.98], [-57.75, -16.75]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 70, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 78, "s": [80]}, {"t": 102, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 70, "s": [18]}, {"t": 78, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 1, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"k": {"i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-57.48, -57.04], [-30.25, -57]], "c": false}}}, {"ty": "tm", "s": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 70, "s": [0]}, {"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 78, "s": [80]}, {"t": 102, "s": [100]}]}, "e": {"a": 1, "k": [{"i": {"x": [0.83], "y": [0.83]}, "o": {"x": [0.17], "y": [0.17]}, "t": 70, "s": [18]}, {"t": 78, "s": [100]}]}, "o": {"k": 0}, "m": 1}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 1, "ml": 4}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [0, 0]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 78, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [27.28, -6.81], [48.5, 0.12], [27.28, 7.06], [21.32, 40], [15.35, 7.06], [-5.87, 0.12], [15.35, -6.81]], "c": true}]}, {"t": 105, "s": [{"i": [[0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9], [-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91]], "o": [[-0.11, 0], [5.94, 6.91], [0, -0.12], [-5.94, 6.91], [0.11, 0], [-5.94, -6.9], [0, 0.12], [5.94, -6.9]], "v": [[21.32, -37.87], [33.66, -14.19], [48.5, 0.12], [35, 12.76], [21.32, 40], [7.97, 13.43], [-5.87, 0.12], [8.64, -13.18]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.992156922583, 0.803921628466, 0.035294117647, 1]}, "o": {"k": 100}, "w": {"k": 2}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.992156862745, 0.803921568627, 0.035294117647, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [-57.66, -57.3]}, "a": {"k": [21.34, -0.3]}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 70, "s": [18, 18]}, {"i": {"x": [0.83, 0.83], "y": [0.83, 0.83]}, "o": {"x": [0.17, 0.17], "y": [0.17, 0.17]}, "t": 78, "s": [100, 100]}, {"t": 105, "s": [0, 0]}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [-57.5, -55.5]}, "a": {"k": [-57.5, -55.5]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 70, "op": 106, "st": 13}, {"ind": 19, "ty": 4, "parent": 12, "ks": {"o": {"k": 100}, "r": {"k": 0}, "p": {"k": [257, 326.25, 0], "l": 2}, "a": {"k": [257, 326.25, 0], "l": 2}, "s": {"k": [100, 100, 100], "l": 2}}, "shapes": [{"ty": "gr", "it": [{"ty": "gr", "it": [{"ind": 0, "ty": "sh", "ks": {"a": 1, "k": [{"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 7, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-64.67, -3.08], [99.01, -3.58], [99.42, -3.92], [-100.51, -3.75]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 10, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[0.01, -33.33], [123.51, -1.58], [-0.61, -33.67], [-123.51, -1.25]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 13, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-64.67, -3.08], [99.01, -3.58], [99.42, -3.92], [-100.51, -3.75]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 17, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[0.93, -0.08], [123.51, -1.58], [0.31, -0.42], [-123.51, -1.25]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 23, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-64.67, -3.08], [99.01, -3.58], [99.42, -3.92], [-100.51, -3.75]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 31, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[0.33, -51.08], [123.51, -1.58], [0.42, 51.08], [-123.51, -1.25]], "c": true}]}, {"i": {"x": 0.83, "y": 0.83}, "o": {"x": 0.17, "y": 0.17}, "t": 38, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[-3.67, -51.08], [123.51, -1.58], [4.43, 51.08], [-123.51, -1.25]], "c": true}]}, {"t": 48, "s": [{"i": [[0, 0], [0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0], [0, 0]], "v": [[0.33, -51.08], [123.51, -1.58], [0.42, 51.08], [-123.51, -1.25]], "c": true}]}]}}, {"ty": "st", "c": {"k": [0.262745112181, 0.250980407, 0.800000011921, 1]}, "o": {"k": 100}, "w": {"k": 5}, "lc": 2, "lj": 2}, {"ty": "fl", "c": {"k": [0.278431385756, 0.278431385756, 0.866666674614, 1]}, "o": {"k": 100}, "r": 1}, {"ty": "tr", "p": {"k": [256.83, 252.58]}, "a": {"k": [0, 0]}, "s": {"k": [100, 100]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}, {"ty": "tr", "p": {"k": [256.29, 332.83]}, "a": {"k": [256.29, 332.83]}, "s": {"a": 1, "k": [{"t": 0, "s": [100, 100], "h": 1}, {"t": 7, "s": [-100, 100], "h": 1}, {"t": 10, "s": [100, 100], "h": 1}, {"t": 13, "s": [-100, 100], "h": 1}, {"t": 17, "s": [100, 100], "h": 1}, {"t": 23, "s": [-100, 100], "h": 1}, {"t": 31, "s": [100, 100], "h": 1}]}, "r": {"k": 0}, "o": {"k": 100}, "sk": {"k": 0}, "sa": {"k": 0}}]}], "ip": 0, "op": 151, "st": 0}, {"ind": 21, "ty": 3, "ks": {"o": {"k": 0}, "r": {"k": 0}, "p": {"k": [257, 451, 0], "l": 2}, "a": {"k": [50, 50, 0], "l": 2}, "s": {"a": 1, "k": [{"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 18, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 26, "s": [103, 97, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 32, "s": [97, 103, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 43, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 78, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 82, "s": [101, 99, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 86, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 90, "s": [101, 99, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 94, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 98, "s": [101, 99, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 102, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 106, "s": [101, 99, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 110, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 114, "s": [101, 99, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 118, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 122, "s": [101, 99, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 126, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 1.17, 0.67]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0]}, "t": 145, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 1]}, "o": {"x": [0.5, 0.5, 0.5], "y": [0, 0, 0]}, "t": 149, "s": [102, 98, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 0.83]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0.17]}, "t": 155, "s": [100, 100, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.85, 1.14, -0.67]}, "o": {"x": [0.17, 0.17, 0.17], "y": [0.17, 0.17, 0]}, "t": 168, "s": [99, 102, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 1.17, 4.33]}, "o": {"x": [1, 1, 1], "y": [0, 0, 0]}, "t": 180, "s": [110, 90, 100]}, {"i": {"x": [0.83, 0.83, 0.83], "y": [0.83, 0.83, 1]}, "o": {"x": [0.75, 0.75, 0.75], "y": [0, 0, 0]}, "t": 195, "s": [90, 110, 100]}, {"t": 208, "s": [100, 100, 100]}], "l": 2}}, "ip": 0, "op": 264, "st": 0}]} \ No newline at end of file diff --git a/common/src/main/res/raw/usdt_unpacking.json b/common/src/main/res/raw/usdt_unpacking.json new file mode 100644 index 0000000..c23eaec --- /dev/null +++ b/common/src/main/res/raw/usdt_unpacking.json @@ -0,0 +1 @@ +{"v":"5.9.0","fr":60,"ip":0,"op":360,"w":512,"h":512,"assets":[{"id":"comp_0","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[248.14,284.55,0],"l":2},"a":{"k":[14,-1,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-72,39],[-152,111]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.86,45],[3,185]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[61,31],[156,113]],"c":false}}},{"ty":"st","c":{"k":[0.988973460478,1,0.062745094299,1]},"o":{"k":100},"w":{"k":8},"lc":2,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"t":69,"s":[73]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[0]},{"t":67,"s":[73]}]},"o":{"k":0},"m":1}],"ip":60,"op":177,"st":-3},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":-208.79},"p":{"k":[90.94,257.49,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[73.08,73.08,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-0.59,-21.92],[27.98,-23.86],[-36.09,-3.9],[3.75,11.83],[-1.96,-59.37],[-44.64,5.65],[18.27,13.23]],"o":[[1.21,44.71],[-24.04,20.51],[7.95,0.86],[-8.39,-26.51],[1.32,39.94],[41.51,-5.26],[-13.06,-9.45]],"v":[[-134.97,-34.32],[-81.98,23.04],[-72.15,98.43],[-50.97,80.06],[-134.14,144.99],[-59.15,210.78],[-26.48,151.25]],"c":false}}},{"ty":"st","c":{"k":[0.949019667682,0.870865167356,0,1]},"o":{"k":100},"w":{"k":10},"lc":1,"lj":1,"ml":4},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"t":122,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[25.52]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":110,"s":[90]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":70,"op":177,"st":-3},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":-322},"p":{"k":[191.66,150.67,0],"l":2},"a":{"k":[127.48,-143.62,0],"l":2},"s":{"k":[-190,190,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[-4.76,4.16],[-5.58,-2.96],[0.86,-2.32],[1.98,1.6],[-0.17,2.54],[-4.87,2.42],[-4.55,-2.99],[0.82,-1.61],[1.23,1.05],[0.03,1.62],[-5.55,1.61],[-2.45,-2.91]],"o":[[1.22,-6.2],[4.76,-4.16],[2.19,1.16],[-0.89,2.39],[-1.98,-1.6],[0.36,-5.43],[4.88,-2.42],[1.51,1],[-0.73,1.44],[-1.23,-1.05],[-0.1,-5.78],[3.66,-1.06],[0,0]],"v":[[96.85,-121.52],[105.41,-138.05],[123,-140.94],[126.33,-135.01],[119.89,-133.82],[117.49,-140.76],[126.14,-153.74],[141.72,-152.8],[143.88,-148.35],[139.69,-147.87],[138.04,-152.28],[147.62,-165.26],[158.1,-162.64]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[10]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[38]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[63]},{"t":100,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":84,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[99]},{"t":100,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0,0.602906589882,0.949019607843,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":-180},"p":{"k":[244.14,221.55,0],"l":2},"a":{"k":[0,0,0],"l":2},"s":{"k":[56,56,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[13.57,11.19],[-31.1,23.53],[44.5,-0.5],[0.21,-20],[-89,21]],"o":[[-12.84,-10.6],[37,-28],[-34.23,0.39],[-1,97],[35.17,-8.3]],"v":[[0.84,68.6],[14,13],[-11.5,-83.5],[-43,64],[-132,56]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[23]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[39]},{"t":90,"s":[49]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[40]},{"t":85,"s":[49]}]},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[171]},{"t":90,"s":[179]}]},"m":1},{"ty":"st","c":{"k":[0,0.949019667682,0.334948072246,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[8]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":74,"s":[18]},{"t":90,"s":[5]}]},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"k":[0,20]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":61,"op":169,"st":-11},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":-370},"p":{"k":[336.32,194.77,0],"l":2},"a":{"k":[-147.98,-152.87,0],"l":2},"s":{"k":[-211,211,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[3.94,4.85],[5.94,-1.95],[-0.45,-2.41],[-2.2,1.23],[-0.26,2.51],[4.35,3.18],[4.93,-2.16],[-0.52,-1.71],[-1.37,0.82],[-0.3,1.57],[5.15,2.5],[2.88,-2.43]],"o":[[-0.16,-6.25],[-3.94,-4.85],[-2.33,0.77],[0.47,2.48],[2.2,-1.23],[0.56,-5.36],[-4.35,-3.18],[-1.64,0.72],[0.47,1.53],[1.37,-0.82],[1.06,-5.62],[-3.39,-1.64],[0,0]],"v":[[-121.55,-126.72],[-127.13,-144.27],[-143.8,-150.03],[-148.04,-144.81],[-141.97,-142.56],[-138.46,-148.94],[-144.73,-163.04],[-160.08,-164.73],[-162.93,-160.75],[-158.93,-159.58],[-156.58,-163.6],[-163.75,-177.86],[-174.41,-177.06]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[13]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[45]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[63]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[20]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[66]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[94]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[99]},{"t":97,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.964705882353,0,0.037585939146,1]},"o":{"k":100},"w":{"k":4},"lc":1,"lj":2},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":63,"op":130,"st":-50},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[169]},{"t":121,"s":[-67]}]},"p":{"a":1,"k":[{"i":{"x":0.4,"y":0.86},"o":{"x":0.05,"y":0.01},"t":68,"s":[201,236.25,0],"to":[-0.3,-1.62,0],"ti":[89.46,-52.28,0]},{"i":{"x":0.52,"y":0.9},"o":{"x":0.16,"y":0.13},"t":77,"s":[117.46,105.72,0],"to":[-89.46,52.28,0],"ti":[54.73,-96.02,0]},{"t":127,"s":[83.5,392.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":75,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":79,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":84,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":88,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":99,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":114,"s":[80,80,100]},{"t":120,"s":[75,-75,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-5.94,-2.72],[-1.69,-6.82],[5.28,-3.42],[-3.74,4.29]],"o":[[-1.96,5],[-8.39,-4.89],[-3.96,-11.6],[3,-5.59]],"v":[[15,-15],[15,15],[-15,15],[-15,-15]],"c":true}}},{"ty":"fl","c":{"a":1,"k":[{"t":69,"s":[0,0.34,0.91,1],"h":1},{"t":72,"s":[0.89,0.89,0.89,1],"h":1},{"t":77,"s":[0,0.34,0.91,1],"h":1},{"t":82,"s":[0.89,0.89,0.89,1],"h":1},{"t":86,"s":[0,0.34,0.91,1],"h":1},{"t":91,"s":[0.89,0.89,0.89,1],"h":1},{"t":96,"s":[0,0.34,0.91,1],"h":1},{"t":103,"s":[0.89,0.89,0.89,1],"h":1},{"t":110,"s":[0,0.34,0.91,1],"h":1},{"t":117,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":180,"st":37},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-90]},{"t":109,"s":[301.64]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[198.75,244.5,0],"to":[-1.88,-52.9,0],"ti":[28.8,18.42,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[144.71,133.7,0],"to":[-28.64,-18.32,0],"ti":[23.57,-30.86,0]},{"t":109,"s":[58.5,148.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":68,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":106,"s":[100,-100,100]},{"t":109,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[29,30]},{"t":107,"s":[18,18.62]}]},"p":{"k":[0,0]},"r":{"k":211}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[0.96,0.81,0,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0.96,0.81,0,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0.96,0.81,0,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0.96,0.81,0,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0.96,0.81,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":4},"sa":{"k":0}}]}],"ip":65,"op":110,"st":34},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[-163]},{"t":120,"s":[-73.39]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[198.75,232.5,0],"to":[2.25,-107.5,0],"ti":[45.25,-88,0]},{"t":120,"s":[43.75,100,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":93,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":100,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":107,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,45,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":111,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":113,"s":[100,10,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[132,-64,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":119,"s":[130,-32,100]},{"t":120,"s":[0,0,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[22,22]},{"t":116,"s":[15,15]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":64,"s":[0.67,0,0.27,1],"h":1},{"t":67,"s":[0.89,0.89,0.89,1],"h":1},{"t":71,"s":[0.67,0,0.27,1],"h":1},{"t":76,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[0.67,0,0.27,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":90,"s":[0.67,0,0.27,1],"h":1},{"t":97,"s":[0.89,0.89,0.89,1],"h":1},{"t":104,"s":[0.67,0,0.27,1],"h":1},{"t":111,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[0.67,0,0.27,1],"h":1},{"t":118,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":64,"op":121,"st":-53},{"ind":9,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":70,"s":[-29]},{"t":131,"s":[306]}]},"p":{"a":1,"k":[{"i":{"x":0.43,"y":0.94},"o":{"x":0,"y":0},"t":70,"s":[315.75,212.5,0],"to":[84.25,-131.5,0],"ti":[9.5,-151.25,0]},{"t":133,"s":[444.5,412.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":71,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":95,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":101,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":108,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[100,100,100]},{"t":123,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":133,"s":[{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[7.5,-7.5],[7.5,7.5],[0.09,7.5],[-7.5,7.5],[-7.5,-7.5],[-1.31,-7.5]],"c":true}]},{"t":140,"s":[{"i":[[-1.04,1.29],[-0.3,-3.48],[1.31,-2.19],[0,0],[0,0],[-1.41,1.63]],"o":[[0.26,3.49],[0.29,3.4],[-1.99,3.33],[0,0],[0,0],[1.17,-1.35]],"v":[[-2.19,-14.3],[-0.07,-2.07],[-0.67,5.04],[-7.5,7.5],[-7.5,-7.5],[-4.66,-10.34]],"c":true}]}]}},{"ty":"fl","c":{"a":1,"k":[{"t":71,"s":[1,0.61,0,1],"h":1},{"t":74,"s":[0.89,0.89,0.89,1],"h":1},{"t":79,"s":[1,0.61,0,1],"h":1},{"t":84,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0.61,0,1],"h":1},{"t":93,"s":[0.89,0.89,0.89,1],"h":1},{"t":98,"s":[1,0.61,0,1],"h":1},{"t":105,"s":[0.89,0.89,0.89,1],"h":1},{"t":112,"s":[1,0.61,0,1],"h":1},{"t":119,"s":[0.89,0.89,0.89,1],"h":1},{"t":124,"s":[1,0.61,0,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":70,"op":219,"st":39},{"ind":10,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-29]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":118,"s":[186]},{"t":130,"s":[188]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[312.75,231.5,0],"to":[-0.58,-8.79,0],"ti":[-27.32,0.19,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[337,137.9,0],"to":[27.32,-0.19,0],"ti":[5.65,-84.54,0]},{"t":130,"s":[365.5,416.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":76,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":81,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":85,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":90,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":103,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":110,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":118,"s":[100,-100,100]},{"t":130,"s":[100,40,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"k":[22,22]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":66,"s":[0,0.86,0.06,1],"h":1},{"t":69,"s":[0.89,0.89,0.89,1],"h":1},{"t":74,"s":[0,0.86,0.06,1],"h":1},{"t":79,"s":[0.89,0.89,0.89,1],"h":1},{"t":83,"s":[0,0.86,0.06,1],"h":1},{"t":88,"s":[0.89,0.89,0.89,1],"h":1},{"t":93,"s":[0,0.86,0.06,1],"h":1},{"t":100,"s":[0.89,0.89,0.89,1],"h":1},{"t":107,"s":[0,0.86,0.06,1],"h":1},{"t":114,"s":[0.89,0.89,0.89,1],"h":1},{"t":121,"s":[0,0.86,0.06,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":65,"op":214,"st":34},{"ind":11,"ty":4,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":115,"s":[100]},{"t":124,"s":[0]}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[126]},{"t":124,"s":[349]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":0.68},"o":{"x":0,"y":0},"t":68,"s":[274,232.25,0],"to":[14,-95.25,0],"ti":[-3.5,70.25,0]},{"t":124,"s":[416.5,52.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":72,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":78,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":87,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":96,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":102,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":109,"s":[100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":116,"s":[100,100,100]},{"t":124,"s":[100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[20,20]},{"t":124,"s":[14,14]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":72,"s":[1,0.92,0,1],"h":1},{"t":75,"s":[0.89,0.89,0.89,1],"h":1},{"t":80,"s":[1,0.92,0,1],"h":1},{"t":85,"s":[0.89,0.89,0.89,1],"h":1},{"t":89,"s":[1,0.92,0,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":99,"s":[1,0.92,0,1],"h":1},{"t":106,"s":[0.89,0.89,0.89,1],"h":1},{"t":113,"s":[1,0.92,0,1],"h":1},{"t":120,"s":[0.89,0.89,0.89,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":68,"op":217,"st":37},{"ind":12,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":63,"s":[54]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":76,"s":[-18]},{"t":126,"s":[18]}]},"p":{"a":1,"k":[{"i":{"x":0.33,"y":0.63},"o":{"x":0.02,"y":0},"t":63,"s":[199,223.25,0],"to":[-1.41,-14.49,0],"ti":[30.23,18.1,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.19,"y":0.18},"t":69,"s":[164.23,151.1,0],"to":[-30.23,-18.1,0],"ti":[-19.69,-81.13,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83.75,"s":[108.31,217.87,0],"to":[19.69,81.13,0],"ti":[-18.51,-96.26,0]},{"t":126,"s":[36.5,421.75,0]}],"l":2},"a":{"k":[-16.5,-199.75,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":67,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":73,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":77,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":82,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":86,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":91,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":97,"s":[-100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":105,"s":[-100,-100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":115,"s":[-100,100,100]},{"t":126,"s":[-100,-100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":63,"s":[22,22]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":116,"s":[32,32]},{"t":126,"s":[26,9.75]}]},"p":{"k":[0,0]},"r":{"k":0}},{"ty":"fl","c":{"a":1,"k":[{"t":67,"s":[1,0,0.86,1],"h":1},{"t":70,"s":[0.89,0.89,0.89,1],"h":1},{"t":75,"s":[1,0,0.86,1],"h":1},{"t":80,"s":[0.89,0.89,0.89,1],"h":1},{"t":88,"s":[1,0,0.86,1],"h":1},{"t":94,"s":[0.89,0.89,0.89,1],"h":1},{"t":101,"s":[1,0,0.86,1],"h":1},{"t":110,"s":[0.89,0.89,0.89,1],"h":1},{"t":120,"s":[1,0,0.86,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-16.5,-200]},"a":{"k":[0,0]},"s":{"k":[109,100]},"r":{"k":-16},"o":{"k":100},"sk":{"k":24},"sa":{"k":0}}]}],"ip":63,"op":212,"st":32},{"ind":19,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[346.44,208.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[79,56.3,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":161,"s":[80]},{"t":177,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":149,"s":[18]},{"t":161,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":180,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":149,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":161,"s":[100,100]},{"t":180,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":149,"op":180,"st":92},{"ind":20,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":141,"s":[80]},{"t":163,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":129,"s":[18]},{"t":141,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":141,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":166,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":129,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":141,"s":[100,100]},{"t":166,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":129,"op":166,"st":72},{"ind":21,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,184.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":122,"s":[80]},{"t":142,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":114,"s":[18]},{"t":122,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":122,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":145,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":114,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":122,"s":[100,100]},{"t":145,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":114,"op":146,"st":57},{"ind":22,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,194.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":111,"s":[80]},{"t":133,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[18]},{"t":111,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":111,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":99,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":111,"s":[100,100]},{"t":136,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":99,"op":137,"st":42},{"ind":23,"ty":4,"ks":{"o":{"a":1,"k":[{"t":45,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"r":{"k":0},"p":{"k":[284.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":57,"s":[80]},{"t":79,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":45,"s":[18]},{"t":57,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":82,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":45,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":57,"s":[100,100]},{"t":82,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":45,"op":83,"st":-12},{"ind":24,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[180.44,224.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":33,"s":[80]},{"t":53,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":25,"s":[18]},{"t":33,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":56,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":25,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":33,"s":[100,100]},{"t":56,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":25,"op":57,"st":-32},{"ind":25,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[304.44,174.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":12,"s":[80]},{"t":34,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":0,"s":[18]},{"t":12,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":12,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":37,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":0,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":12,"s":[100,100]},{"t":37,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":38,"st":-57},{"ind":26,"ty":4,"parent":36,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[257.66,216.8,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[257.66,195.11,0],"to":[0,0,0],"ti":[0,0,0]},{"t":74,"s":[257.66,196.8,0]}],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"t":66,"s":[100,50,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-2.38,-34.04],[-50.62,19.68],[-21.63,-9.29],[10.62,34.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.13,-28.48],[-50.62,19.68],[-18.12,-3.73],[14.13,40.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[44.75,-11.49],[11.41,-10.46],[-2.46,-0.63],[28.63,-16.58]],"o":[[-5.87,2.43],[10.91,-2.96],[28.75,7.32],[57,-22.5]],"v":[[-0.05,-31.41],[-22.82,-15.02],[-0.17,-14.3],[13.58,35.52]],"c":true}]},{"t":63,"s":[{"i":[[39.4,1.3],[4.31,-6.56],[-8.66,-3.76],[24.57,-11.79]],"o":[[-13.77,8.37],[11.81,-0.56],[10.25,4.45],[49.41,-14.91]],"v":[[12.68,-31.28],[-10.72,-13.92],[17.83,-6.93],[14.08,34.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":65,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[49.24,-22.54],[22.9,14.72],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.75,-16.98],[26.41,20.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":59,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[-3.74,4.54],[-13.15,5.12],[10.69,-4.36],[39.55,15.94]],"o":[[30.03,10.74],[-2.65,10.12],[-14.81,6.04],[4.47,0.56]],"v":[[-32.17,-7.38],[35.01,-10.26],[16.17,12.22],[-56.19,5.91]],"c":true}]},{"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[20.55,8.44]],"o":[[0,0],[0,0],[0,0],[4.47,0.56]],"v":[[-32.08,-18.25],[-14.49,-16.96],[-15.39,-18.55],[-52.77,-20.68]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":69,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.83,-20.01],[-20.05,-35.73],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-42.74,-31.07],[-14.97,-46.8],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[8.61,-5.02],[4.5,-25.5],[-26.77,0.72],[-2.87,-6.75]],"o":[[-10.89,-17.52],[0.5,-16],[16.91,-0.46],[-6.37,2.25]],"v":[[11.7,-25.48],[-33.69,-29],[2.58,-50.22],[32.69,-36.75]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.86,-3.17],[5.15,-4.4],[-6.82,1.02],[-2.27,-4.65]],"o":[[-13.14,-7.17],[-1.35,-11.4],[11.18,0.02],[-6.27,1.35]],"v":[[-6.05,-39.83],[-31.84,-41.1],[-6.37,-60.52],[20.09,-51.85]],"c":true}]},{"t":68,"s":[{"i":[[8.94,-5.53],[-8,-6],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[-37.8,-58.98],[-41.19,-30],[-10.92,-40.22],[18.69,-52.75]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":66,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":31,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":44,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":47,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":50,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":53,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-47.15,-9.12],[-23.63,-34.4],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":56,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.06,-20.19],[-18.55,-45.47],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":61,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[{"i":[[6.48,-3.6],[-2.49,27.73],[-21.88,-5.84],[-73.26,-19.69]],"o":[[-63.02,-24.1],[0.48,-5.37],[-16.88,19.16],[-4.26,1.81]],"v":[[43.54,47.99],[-30,-31.84],[-2.11,-47.27],[72.77,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[{"i":[[5.18,-2.88],[-1.16,45.79],[-17.5,-4.67],[-41.7,-10.19]],"o":[[-40.02,-14.48],[-0.61,-4.23],[-16.6,27.88],[-3.41,1.45]],"v":[[43.69,48.04],[-27.33,-43.9],[3.11,-46.49],[72.71,35.58]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[2.59,-1.44],[37.94,1.18],[-6.25,14.1],[-24.19,4.01]],"o":[[-19.07,-0.37],[-2.16,-0.07],[10.87,19.21],[-1.7,0.73]],"v":[[48.4,19.5],[-17.41,53.75],[-6.96,-10.32],[71.12,18.18]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[1.94,-1.08],[6.24,-1.19],[15.02,17.72],[-16.78,-17.45]],"o":[[-3.39,20.66],[-1.59,0.3],[8.15,14.41],[-1.28,0.54]],"v":[[47.38,-11.46],[33.39,27.91],[-7.43,15.2],[48.16,-10.46]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[{"i":[[1.3,-0.72],[4.59,9.5],[7.91,-2.82],[-5.41,1.07]],"o":[[-0.4,-8.63],[-0.47,-0.97],[10.84,16.61],[-0.85,0.36]],"v":[[54.18,-18.16],[38.44,-59.82],[16.53,-34.2],[42.57,-26.83]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[44.32,48.24],[-33.62,16.37],[-3.01,5.13],[72.47,35.57]],"c":true}]},{"t":74,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[34.51,43.89],[12.99,34.89],[38.01,23.11],[57.51,30.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"t":0,"s":[100],"h":1},{"t":71,"s":[0],"h":1}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":27,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[50]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":99,"s":[-6]},{"t":103,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[250.5,249,0],"to":[-2.6,-22.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":69,"s":[190.72,157.91,0],"to":[-69.01,-1.59,0],"ti":[-1.05,-109.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[121.5,435.5,0],"to":[-0.24,-0.22,0],"ti":[-0.07,-2.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[121.37,423.98,0],"to":[0.05,2.14,0],"ti":[0,0,0]},{"t":99,"s":[121.5,438,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346.5,94.96]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[192.31,192.31]},{"t":99,"s":[192.31,104.99]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":75,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":93,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":96,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":99,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[100,2]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,90]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":96,"s":[100,100]},{"t":99,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":71,"s":[6]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":93,"s":[5]},{"t":99,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":28,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":68,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[115]},{"i":{"x":[0.83],"y":[0.79]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[459]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":112,"s":[353]},{"t":122,"s":[359]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[291.5,240,0],"to":[-2.6,-19.52,0],"ti":[-37.7,-0.28,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":71,"s":[329.72,135.91,0],"to":[54.99,0.41,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":92,"s":[386.5,440,0],"to":[-7,-25,0],"ti":[17,0.5,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":106,"s":[345.5,440,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[335.5,440,0],"to":[6.75,0.38,0],"ti":[0,0,0]},{"t":122,"s":[350.5,455,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"t":64,"s":[-100,100,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346.25,95.86]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":112,"s":[192.31,192.31]},{"t":122,"s":[192.31,111.57]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.75,"y":0},"t":112,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":122,"s":[0,3]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[1,0.83]},"o":{"x":[0.75,0.75],"y":[0,0]},"t":112,"s":[100,100]},{"t":122,"s":[100,48]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":112,"s":[5]},{"t":122,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":29,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[-59]},{"i":{"x":[0.49],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":69,"s":[-30]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[12]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-14]},{"t":96,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[290.5,195,0],"to":[0.4,-33.52,0],"ti":[-40.98,-0.41,0]},{"i":{"x":0.7,"y":0.41},"o":{"x":0.37,"y":0},"t":72,"s":[356.72,126.91,0],"to":[13.16,0.13,0],"ti":[-9.36,-27.36,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.47,"y":0.32},"t":79,"s":[390.62,172.02,0],"to":[19.79,57.87,0],"ti":[0.65,-85.9,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[420.5,419,0],"to":[-0.41,-0.39,0],"ti":[-0.05,-33.51,0]},{"t":96,"s":[420.5,419,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[0,0,100]},{"t":65,"s":[90,90,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346.5,94.49]},"a":{"k":[0,0]},"s":{"k":[192.31,70.64]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,3]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":90,"s":[100,42]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":93,"s":[100,26]},{"t":97,"s":[100,42]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":6},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":30,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.5],"y":[0]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":88,"s":[-198]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":92,"s":[-174]},{"t":97,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[220.5,269,0],"to":[-2.6,-19.52,0],"ti":[37.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[157.72,193.91,0],"to":[-69.01,-1.59,0],"ti":[2.95,-91.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[83.5,374,0],"to":[-0.41,-0.39,0],"ti":[-3.5,-7,0]},{"t":97,"s":[87.5,374,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[84,84,100]},{"t":88,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346.5,90.61]},"a":{"k":[0,0]},"s":{"k":[192.31,59.73]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":88,"s":[0,-3],"to":[0,0],"ti":[0,0]},{"t":97,"s":[0,-2.5]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":88,"s":[91,28]},{"t":97,"s":[91,22]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[9]},{"t":88,"s":[5.5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":31,"ty":4,"parent":36,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":68,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[256.46,249.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":63,"s":[256.46,225.72,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[256.46,209.72,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[256.46,206.72,0]}],"l":2},"a":{"k":[256.46,249.72,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":60,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":66,"s":[100,36,100]},{"t":67,"s":[100,14,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[1.75,-10.16],[-45.92,7.16],[-17.13,19.11],[30,-1.05]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-56.04,12.3],[-19.24,23.32],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.1,11.25],[-18.36,20.12],[48.29,-7.88],[19.5,-19.83]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}]},{"t":66,"s":[{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[0.71,-11.86],[-45.71,6.16],[-16.92,18.11],[34.21,-0.66]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":32,"ty":4,"parent":37,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.96,347.85]},"a":{"k":[0,0]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":35,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[100],"h":1},{"t":81,"s":[0],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346.5,92.86]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[192.31,192.31]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[192.31,10.41]},{"t":80,"s":[192.31,195.41]}]},"r":{"k":-0.23},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[9]},{"t":81,"s":[5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":36,"ty":4,"parent":37,"ks":{"o":{"a":1,"k":[{"t":60,"s":[100],"h":1},{"t":72,"s":[0],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":72,"s":[257.27,128.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1,-61.13],[131.24,-49.8],[-1.2,-40.13],[-132.88,-50.13]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"t":77,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"t":72,"s":[81,81]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":37,"ty":4,"parent":52,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-74.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":38,"ty":4,"ks":{"o":{"a":1,"k":[{"t":64,"s":[0],"h":1},{"t":81,"s":[100],"h":1}]},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[-89]},{"t":90,"s":[-89]}]},"p":{"a":1,"k":[{"i":{"x":0.49,"y":1},"o":{"x":0.17,"y":0.17},"t":64,"s":[246.5,247,0],"to":[-2.6,-19.52,0],"ti":[21.69,0.87,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.5,"y":0},"t":72,"s":[224.72,147.91,0],"to":[-21.69,-0.87,0],"ti":[2.95,-91.51,0]},{"t":90,"s":[211.5,336,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":63,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":64,"s":[84,84,100]},{"t":81,"s":[71.96,71.96,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[346.5,92.86]},"a":{"k":[0,0]},"s":{"k":[192.31,192.31]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[0,4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"t":81,"s":[0,0]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":64,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":75,"s":[100,4]},{"t":81,"s":[100,100]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":64,"s":[5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":75,"s":[9]},{"t":81,"s":[5]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":39,"ty":4,"ks":{"o":{"k":100},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":62,"s":[100]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[-27]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":85,"s":[-180]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[-207]},{"t":93,"s":[-180]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[296.5,278,0],"to":[2.4,-15.52,0],"ti":[-54.47,3.14,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[348.72,155.91,0],"to":[56.66,-3.26,0],"ti":[-0.05,-138.51,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":85,"s":[428.5,358,0],"to":[-4.91,-22.89,0],"ti":[0.5,-14.75,0]},{"t":93,"s":[402.5,376,0]}],"l":2},"a":{"k":[346,92,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":61,"s":[0,0,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[100,100,100]},{"t":85,"s":[87,87,100]}],"l":2}},"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[345.87,89.72]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[198.22,195.35]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":71,"s":[198.22,138.46]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[198.22,28.24]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[198.22,198.22]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":92,"s":[100,0]},{"t":93,"s":[209,62]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[0,0],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":77,"s":[0,3],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[0,-4],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[0,0],"to":[0,0],"ti":[0,0]},{"t":91,"s":[0,-4]}]},"a":{"k":[0,0]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":68,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":77,"s":[91,4]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":83,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":91,"s":[91,4]},{"t":93,"s":[100,31]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[1],"y":[0]},"t":68,"s":[5]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":77,"s":[9]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[1],"y":[0]},"t":83,"s":[5]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.17],"y":[0.17]},"t":91,"s":[9]},{"t":93,"s":[6]}]},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[346,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":41,"ty":4,"parent":37,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[266.48,312.85,0],"l":2},"a":{"k":[266.48,312.85,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.62,93.66]},"a":{"k":[0,0]},"s":{"k":[192.31,192.31]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[231.5,294],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[231.5,283],"to":[0,0],"ti":[0,0]},{"t":74,"s":[231.5,294]}]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":96},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.62,93.66]},"a":{"k":[0,0]},"s":{"k":[192.31,192.31]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":65,"s":[189,270.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[189,263.5],"to":[0,0],"ti":[0,0]},{"t":74,"s":[189,270.5]}]},"a":{"k":[166,92]},"s":{"k":[103.56,52.65]},"r":{"k":-3.75},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.62,93.66]},"a":{"k":[0,0]},"s":{"k":[192.31,192.31]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,10]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[329.5,274],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":70,"s":[329.5,262],"to":[0,0],"ti":[0,0]},{"t":75,"s":[329.5,274]}]},"a":{"k":[166,92]},"s":{"k":[97,43.65]},"r":{"k":14},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.62,93.66]},"a":{"k":[0,0]},"s":{"k":[192.31,192.31]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[238.5,264.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[238.5,254.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[238.5,264.5]}]},"a":{"k":[166,92]},"s":{"k":[100,39]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.62,93.66]},"a":{"k":[0,0]},"s":{"k":[192.31,192.31]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,12]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":64,"s":[285.5,268.5],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[285.5,258.5],"to":[0,0],"ti":[0,0]},{"t":73,"s":[285.5,268.5]}]},"a":{"k":[166,92]},"s":{"k":[100,32]},"r":{"k":-8},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.62,93.66]},"a":{"k":[0,0]},"s":{"k":[192.31,192.31]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,7]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[271.5,254],"to":[0,0],"ti":[0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":66,"s":[271.5,245],"to":[0,0],"ti":[0,0]},{"t":71,"s":[271.5,254]}]},"a":{"k":[166,92]},"s":{"k":[77,28.36]},"r":{"k":3.04},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[-4.43,0],[-0.9,0.98],[3.53,0.18],[0,0],[0.67,0],[0.63,0.03],[0,0],[0.77,-0.83]],"o":[[4.43,0],[-0.77,-0.83],[0,0],[-0.63,0.03],[-0.67,0],[0,0],[-3.53,0.18],[0.91,0.98]],"v":[[0,1.71],[9.03,-0.01],[1.96,-1.68],[1.96,0.4],[0,0.44],[-1.96,0.4],[-1.96,-1.68],[-9.03,-0.01]],"c":true}}},{"ind":1,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[-0.02,-1.04],[0,0],[4.15,-0.21],[0,0],[0,0],[0,0],[0.02,1.04],[0,0],[-4.15,0.21],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[4.15,0.21],[0,0],[-0.02,1.04],[0,0],[0,0],[0,0],[-4.15,-0.21],[0,0],[0.02,-1.04],[0,0],[0,0],[0,0],[0,0]],"v":[[7.87,-8.06],[7.87,-5.2],[1.96,-5.2],[1.96,-3.22],[9.24,-1.1],[9.24,1.08],[1.96,3.2],[1.96,8.06],[-1.96,8.06],[-1.96,3.2],[-9.24,1.08],[-9.24,-1.1],[-1.96,-3.22],[-1.96,-5.2],[-7.87,-5.2],[-7.87,-8.06]],"c":true}}},{"ty":"mm","mm":1},{"ty":"fl","c":{"k":[1,1,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[165.62,93.66]},"a":{"k":[0,0]},"s":{"k":[192.31,192.31]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,-16.38],[16.38,0],[0,16.38],[-16.38,0]],"o":[[0,16.38],[-16.38,0],[0,-16.38],[16.38,0]],"v":[[29.67,0],[0,29.67],[-29.67,0],[0,-29.67]],"c":true}}},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"st","c":{"k":[0.180392161012,0.568627476692,0.568627476692,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0,0.72549021244,0.72549021244,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[166,92]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[284.5,298]},"a":{"k":[166,92]},"s":{"k":[100,100]},"r":{"k":100},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":47,"ty":4,"parent":37,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":50,"ty":4,"parent":37,"ks":{"o":{"a":1,"k":[{"t":56,"s":[0],"h":1},{"t":72,"s":[100],"h":1}]},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.25,"y":1},"o":{"x":0.17,"y":0.17},"t":60,"s":[257.27,248.75,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.34,"y":0},"t":72,"s":[257.27,128.75,0],"to":[0,0,0],"ti":[0,0,0]},{"t":91,"s":[257.27,342.53,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"a":1,"k":[{"t":60,"s":[100,100,100],"h":1},{"t":73,"s":[100,-100,100],"h":1}],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":67,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.48,-54.5],[131.76,-43.17],[-0.67,-33.5],[-132.36,-43.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.67,-30],[131.66,-31.67],[-0.81,-30],[-132.46,-32]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,98],[132.16,-3.67],[-0.09,-100],[-131.96,-4]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.77,33.72],[131.48,-1.28],[-0.92,-32.28],[-132.64,-1.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.76,5.8],[131.57,4.14],[-0.9,5.8],[-132.55,3.8]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":79,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-1.15,-23.5],[131.16,-19.17],[-1.28,-10.75],[-132.97,-19.5]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.05,-64.7],[132.03,-18.36],[-0.09,37.3],[-132.09,-18.7]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.07,-99.54],[132.16,0.65],[-0.09,95.58],[-131.96,0.98]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"a":1,"k":[{"t":60,"s":[0.47,0.58,0.99,1],"h":1},{"t":69,"s":[0.28,0.28,0.87,1],"h":1},{"t":79,"s":[0.47,0.58,0.99,1],"h":1}]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.62,-89.32],[-66.34,-90.33],[-66.34,-67],[65.5,-64.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.66,-109.32],[-66.12,-87.16],[-66.15,-62.5],[65.72,-76.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-136.32],[-65.96,-40.33],[-66.01,-39],[66.04,-135.67]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[64.68,-94.47],[-66.93,-61.2],[-66.95,-36.54],[64.92,-50.71]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.53,-53.52],[-66.43,-54.53],[-66.43,-31.2],[65.4,-28.87]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-53.02],[-66.03,-54.03],[-66.03,-30.7],[65.97,26.63]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,61.22],[-65.96,-33.37],[-66.01,-34.68],[66.04,60.58]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":60,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.61,-66.84],[-66.52,-65.17],[-66.24,-89.83],[65.68,-89.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":69,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.8,-62.34],[-66.3,-77.17],[-66.21,-109.83],[65.89,-86.59]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":72,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-38.84],[-65.98,-136.17],[-65.91,-136.83],[66.05,-39.76]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":76,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65,-36.37],[-67.1,-51.21],[-67.19,-94.97],[65.09,-60.63]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":78,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.52,-31.03],[-66.61,-29.37],[-66.33,-54.02],[65.59,-53.95]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":83,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.92,-30.53],[-66.04,26.13],[-65.93,-53.52],[65.99,-53.45]],"c":true}]},{"t":86,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.93,-35.85],[-65.98,60.07],[-65.91,60.71],[66.05,-34.94]],"c":true}]}]}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[35]},{"t":61,"s":[0]}]},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":62,"s":[100,100]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":72,"s":[81,81]},{"t":83,"s":[73,73]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":52,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":29,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":35,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":43,"s":[1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":46,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":52,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1]},{"t":58,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":1,"y":0},"t":55,"s":[257,451,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[257,439,0],"to":[0,0,0],"ti":[0,0,0]},{"t":67,"s":[257,451,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.9,1.09,0]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0]},"t":55,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,1]},"o":{"x":[1,1,1],"y":[0,0,0]},"t":58,"s":[110,89,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":62,"s":[91,108,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":65,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":69,"s":[103,97,100]},{"t":73,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]},{"id":"comp_1","fr":60,"layers":[{"ind":1,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[246.44,128.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":156,"s":[80]},{"t":178,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":144,"s":[18]},{"t":156,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":156,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":181,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":144,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":156,"s":[100,100]},{"t":181,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":144,"op":181,"st":87},{"ind":2,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[336.44,166.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[110,110,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":136,"s":[80]},{"t":158,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":124,"s":[18]},{"t":136,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":136,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":161,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":124,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":136,"s":[100,100]},{"t":161,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":124,"op":162,"st":67},{"ind":3,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[190.44,214.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[106,75.54,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":117,"s":[80]},{"t":137,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":109,"s":[18]},{"t":117,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":117,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":140,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":109,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":117,"s":[100,100]},{"t":140,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":109,"op":141,"st":52},{"ind":4,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[315.44,162.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[94,94,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":103,"s":[80]},{"t":111,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":95,"s":[18]},{"t":103,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":103,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":114,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":95,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":103,"s":[100,100]},{"t":114,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[-57.5,-55.5]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":95,"op":115,"st":38},{"ind":5,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[231.44,139.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[73,73,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":89,"s":[80]},{"t":97,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":81,"s":[18]},{"t":89,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":89,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":100,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":81,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":89,"s":[100,100]},{"t":100,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":81,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":100,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":81,"op":101,"st":24},{"ind":6,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[270.44,256.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[111,111,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":73,"s":[80]},{"t":87,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":65,"s":[18]},{"t":73,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":73,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":90,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":65,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":73,"s":[100,100]},{"t":90,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":65,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":90,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":65,"op":91,"st":8},{"ind":7,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[160.44,221.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[118,118,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":58,"s":[80]},{"t":66,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[18]},{"t":58,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":58,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":69,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":50,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":58,"s":[100,100]},{"t":69,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":50,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":69,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":50,"op":70,"st":-7},{"ind":8,"ty":4,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[323.44,178.19,0],"l":2},"a":{"k":[-57.56,-55.81,0],"l":2},"s":{"k":[82,82,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.51,-57.4],[-57.5,-94.25]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.52,-57.04],[-84.75,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.71,-56.98],[-57.75,-16.75]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-57.48,-57.04],[-30.25,-57]],"c":false}}},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":49,"s":[80]},{"t":57,"s":[100]}]},"e":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":41,"s":[18]},{"t":49,"s":[100]}]},"o":{"k":0},"m":1},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":1,"ml":4},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[0,0]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":49,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[27.28,-6.81],[48.5,0.12],[27.28,7.06],[21.32,40],[15.35,7.06],[-5.87,0.12],[15.35,-6.81]],"c":true}]},{"t":60,"s":[{"i":[[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9],[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91]],"o":[[-0.11,0],[5.94,6.91],[0,-0.12],[-5.94,6.91],[0.11,0],[-5.94,-6.9],[0,0.12],[5.94,-6.9]],"v":[[21.32,-37.87],[33.66,-14.19],[48.5,0.12],[35,12.76],[21.32,40],[7.97,13.43],[-5.87,0.12],[8.64,-13.18]],"c":true}]}]}},{"ty":"st","c":{"k":[0.988235294118,0.929411764706,0,1]},"o":{"k":100},"w":{"k":2},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.988235294118,0.930103855507,0,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[-57.66,-57.3]},"a":{"k":[21.34,-0.3]},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":41,"s":[18,18]},{"i":{"x":[0.83,0.83],"y":[0.83,0.83]},"o":{"x":[0.17,0.17],"y":[0.17,0.17]},"t":49,"s":[100,100]},{"t":60,"s":[0,0]}]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.5,"y":1},"o":{"x":0.17,"y":0.17},"t":41,"s":[-57.5,-55.5],"to":[0,-3.33],"ti":[0,3.33]},{"t":60,"s":[-57.5,-75.5]}]},"a":{"k":[-57.5,-55.5]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":41,"op":61,"st":-16},{"ind":9,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257.66,216.8,0],"l":2},"a":{"k":[257.66,216.8,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[46.06,-10.63],[15.68,-20.1],[-8.76,0.92],[30.67,-15.71]],"o":[[-16.15,3.73],[9.05,-5.5],[35.52,-3.75],[63.53,-14.73]],"v":[[0.89,-7.51],[-50.62,19.68],[-18.36,11.15],[13.89,47.38]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[47.43,-17.89],[20.56,-17.22],[-6.93,6.91],[30.67,-18.97]],"o":[[-15.68,5.92],[17.25,-13.56],[27.96,-27.87],[63.53,-17.79]],"v":[[-0.58,-43.5],[-50.62,19.68],[-19.83,-18.75],[12.42,25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[47.08,-16.07],[17.57,-27.27],[-8.47,2.39],[30.67,-18.15]],"o":[[-15.8,5.37],[10.67,-14.43],[36.88,-10.55],[63.53,-17.02]],"v":[[1.03,-25.49],[-50.62,19.68],[-18.22,-2.26],[14.03,39.61]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[2.1,-29.49],[-50.62,19.68],[-17.15,-4.74],[15.1,39.01]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[-0.85,-35.51],[-50.62,19.68],[-20.1,-10.76],[12.15,32.99]],"c":true}]},{"t":83,"s":[{"i":[[47.43,-17.89],[18.2,-29.66],[-8.37,2.88],[30.67,-18.97]],"o":[[-15.68,5.92],[11.21,-17.41],[37.34,-12.82],[63.53,-17.79]],"v":[[1.08,-31.48],[-50.62,19.68],[-18.17,-6.73],[14.08,37.02]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[301.42,231.98]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[-6.65,8.07],[-9.03,14.83],[15.01,-6.11],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,15.18],[-23.68,9.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.5,-2.69],[26.17,28.17],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[51.04,-32],[24.7,5.25],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[-6.65,8.07],[-9.03,17.13],[15.01,-7.06],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,17.55],[-23.68,11.14],[4.47,0.56]],"v":[[-34.14,-8.48],[52.65,-15.65],[26.31,20],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[53.72,-17.99],[27.38,19.27],[-56.19,5.91]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[50.76,-24.01],[24.43,13.24],[-56.19,5.91]],"c":true}]},{"t":83,"s":[{"i":[[-6.65,8.07],[-9.03,17.9],[15.01,-7.37],[17.55,9.94]],"o":[[39.5,16.83],[-2.67,18.33],[-23.68,11.64],[4.47,0.56]],"v":[[-34.14,-8.48],[52.69,-19.98],[26.36,17.28],[-56.19,5.91]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[299.64,248.64]},"a":{"k":[299.64,248.64]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[8.94,-5.53],[19.5,-12.87],[-8.77,1.48],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-6.43],[26.18,-4.41],[-7.91,6.92]],"v":[[28.2,44.02],[-47.29,4.5],[-19.52,-8.99],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.89,-0.97],[-19.47,-10.03]],"o":[[-28.64,-15.84],[5.5,-7.5],[26.71,2.92],[-7.91,6.92]],"v":[[28.2,44.02],[-25.54,-39.48],[3.89,-52.46],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[8.94,-5.53],[19.5,-14.47],[-8.77,1.66],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.23],[26.18,-4.96],[-7.91,6.92]],"v":[[28.2,44.02],[-47.21,-19.87],[-19.44,-35.04],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-1.33,-17.83]],"o":[[-8.79,-29.32],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-43.18,-27.54],[-15.41,-43.26],[47.19,27.25]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-49.27,-23.03],[-21.5,-38.76],[47.19,27.25]],"c":true}]},{"t":83,"s":[{"i":[[8.94,-5.53],[19.5,-15],[-8.77,1.72],[-3.25,-14.54]],"o":[[-13.97,-26.85],[5.5,-7.5],[26.18,-5.14],[-7.91,6.92]],"v":[[28.2,44.02],[-47.19,-28],[-19.42,-43.72],[47.19,27.25]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[217.69,209]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":0,"s":[{"i":[[7.5,-5],[-0.01,21.16],[-20,3.68],[-40,-11.5]],"o":[[-25,-9],[0.01,-8.33],[-16,20.4],[-7,3.5]],"v":[[44.51,48.89],[-46.61,13.39],[-23.09,-8.3],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":19,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-74.98,-10.78]],"o":[[-49.59,-15.59],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-32.04,-23.56],[-1.35,-53.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":33,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":37,"s":[{"i":[[7.5,-5],[-0.01,23.79],[-20,4.13],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.37],[-16,22.94],[-7,3.5]],"v":[[44.51,48.89],[-46.54,-9.48],[-23.02,-33.87],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":45,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":52,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":57,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":62,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":68,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-47.28,-12.8]],"o":[[-39.15,-13.11],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-42.51,-16.65],[-18.99,-41.93],[71.01,35.39]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":74,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-48.6,-12.14],[-25.08,-37.43],[71.01,35.39]],"c":true}]},{"t":83,"s":[{"i":[[7.5,-5],[-0.01,24.67],[-20,4.29],[-40,-11.5]],"o":[[-25,-9],[0.01,-9.71],[-16,23.79],[-7,3.5]],"v":[[44.51,48.89],[-46.51,-17.11],[-22.99,-42.39],[71.01,35.39]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470589638,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[213.49,212.11]},"a":{"k":[213.49,212.11]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":10,"ty":4,"parent":11,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256.33,321.67,0],"l":2},"a":{"k":[256.33,321.67,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.5,7.39],[-45.92,7.16],[-17.13,19.11],[-18,19.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16,-17.11],[-45.92,7.16],[-17.13,19.11],[45.5,-6.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[225.67,234.86]},"a":{"k":[288.17,235.86]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.76,93.8],[14.58,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.976470649242,0.607843160629,0.019607843831,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[289.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[-100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[9.63,4.24]],"o":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"v":[[-48.29,6.73],[-19.21,19.84],[48.29,-7.88],[19.5,-19.83]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.58,93.8],[14.76,108.07],[14.67,-65.56]],"c":true}]},{"t":34,"s":[{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-14.67,-78.56],[-14.67,64.29],[14.67,78.56],[14.67,-65.56]],"c":true}]}]}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[189.33,346.9]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[222.96,261.85]},"a":{"k":[222.96,261.85]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[-45.29,6.39],[-45.71,6.16],[-16.92,18.11],[-17.79,18.39]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[-9.33,-4.2],[0,0]],"o":[[0,0],[9.63,4.24],[0,0],[0,0]],"v":[[16.21,-18.11],[-45.71,6.16],[-16.92,18.11],[45.71,-7.61]],"c":true}}},{"ty":"st","c":{"k":[0.949019610882,0.474509805441,0.152941182256,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.992156863213,0.803921580315,0.035294119269,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[288.17,235.86]},"a":{"k":[288.17,235.86]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":11,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"a":1,"k":[{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":4,"s":[257.27,248.75,0],"to":[0,14.83,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.17},"t":24,"s":[257.27,218.64,0],"to":[0,0,0],"ti":[0,-8.61,0]},{"t":34,"s":[257.27,248.75,0]}],"l":2},"a":{"k":[257.27,248.75,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.02,-51],[132.06,-4.67],[-0.06,51],[-132.06,-5]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.27,252.67]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.96,-39.32],[-66,-40.33],[-66,-17],[66,40.33]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[191.21,288]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[65.95,-16.84],[-66.02,39.83],[-65.9,-39.83],[66.02,-39.76]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[323.23,288.51]},"a":{"k":[323.23,288.51]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[123.67,-33.19],[123.67,-19.7],[-0.33,33.53],[-123.66,-20.03],[-123.66,-33.53]],"c":true}}},{"ty":"fl","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[257.54,308.3]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":35},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.66,255.42]},"a":{"k":[256.66,255.42]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":12,"ty":4,"parent":14,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[50,-28.75,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[61.73,-99.67],[61.73,39.67],[-61.73,99.67],[-61.7,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.40000000596,0.415686279535,1,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[318.95,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-61.94,-99.67],[-61.94,39.67],[61.93,99.67],[61.94,-47.67]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.466666668653,0.580392181873,0.988235294819,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[195.29,351.33]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":13,"ty":4,"parent":12,"ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[257,326.25,0],"l":2},"a":{"k":[257,326.25,0],"l":2},"s":{"k":[100,100,100],"l":2}},"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[0.33,-51.08],[123.51,-1.58],[0.42,51.08],[-123.51,-1.25]],"c":true}}},{"ty":"st","c":{"k":[0.262745112181,0.250980407,0.800000011921,1]},"o":{"k":100},"w":{"k":5},"lc":2,"lj":2},{"ty":"fl","c":{"k":[0.278431385756,0.278431385756,0.866666674614,1]},"o":{"k":100},"r":1},{"ty":"tr","p":{"k":[256.83,252.58]},"a":{"k":[0,0]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]},{"ty":"tr","p":{"k":[256.29,332.83]},"a":{"k":[256.29,332.83]},"s":{"k":[100,100]},"r":{"k":0},"o":{"k":100},"sk":{"k":0},"sa":{"k":0}}]}],"ip":0,"op":180,"st":0},{"ind":14,"ty":3,"ks":{"o":{"k":0},"r":{"a":1,"k":[{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.75],"y":[0]},"t":32,"s":[0]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":50,"s":[-1]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":55,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":60,"s":[-1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":66,"s":[1.5]},{"i":{"x":[0.83],"y":[0.83]},"o":{"x":[0.17],"y":[0.17]},"t":72,"s":[-1.5]},{"t":81,"s":[0]}]},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.17,"y":0.17},"t":0,"s":[257,739,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.83,"y":0.83},"o":{"x":0.17,"y":0.13},"t":20,"s":[257,379,0],"to":[0,0,0],"ti":[0,0,0]},{"t":32,"s":[257,405,0]}],"l":2},"a":{"k":[50,50,0],"l":2},"s":{"a":1,"k":[{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":9,"s":[97,105,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":32,"s":[100,100,100]},{"i":{"x":[0.83,0.83,0.83],"y":[0.83,0.83,0.83]},"o":{"x":[0.17,0.17,0.17],"y":[0.17,0.17,0.17]},"t":36,"s":[104,96,100]},{"t":43,"s":[100,100,100]}],"l":2}},"ip":0,"op":180,"st":0}]}],"layers":[{"ind":1,"ty":0,"cl":"Present3","refId":"comp_0","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":180,"op":360,"st":180},{"ind":2,"ty":0,"cl":"Present2","refId":"comp_1","ks":{"o":{"k":100},"r":{"k":0},"p":{"k":[256,256,0],"l":2},"a":{"k":[256,256,0],"l":2},"s":{"k":[100,100,100],"l":2}},"w":512,"h":512,"ip":0,"op":180,"st":0}]} \ No newline at end of file diff --git a/common/src/main/res/values-es/strings.xml b/common/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..f808b89 --- /dev/null +++ b/common/src/main/res/values-es/strings.xml @@ -0,0 +1,2047 @@ + + + Contáctanos + Github + Política de privacidad + Califícanos + Telegram + Términos y Condiciones + Términos & Condiciones + Acerca de + Versión de la aplicación + Sitio Web + Introduce una dirección %s válida... + La dirección EVM debe ser válida o estar vacía... + Introduce una dirección de sustrato válida... + Agregar cuenta + Agregar dirección + La cuenta ya existe. Por favor, prueba con otra. + Seguir cualquier cartera por su dirección + Agregar cartera solo-lectura + Escribe tu Frase De Paso + Por favor, asegúrate de anotar tu frase correctamente y de forma legible. + dirección %s + No hay cuenta en %s + Confirmar mnemónica + Verifiquemos de nuevo + Elige las palabras en el orden correcto + Crear una cuenta nueva + No uses el portapapeles o capturas de pantalla en tu dispositivo móvil, intenta encontrar métodos seguros para el respaldo (por ejemplo, papel) + El nombre se usará solo localmente en esta aplicación. Puedes editarlo más tarde + Crear nombre de billetera + Respaldar mnemónico + Crear una nueva billetera + El mnemónico se usa para recuperar acceso a la cuenta. Anótalo, ¡no podremos recuperar tu cuenta sin él! + Cuentas con un secreto cambiado + Olvidar + Asegúrate de haber exportado tu billetera antes de continuar. + ¿Olvidar billetera? + Ruta de derivación Ethereum inválida + Ruta de derivación Substrate inválida + Esta billetera está emparejada con %1$s. Pezkuwi te ayudará a formar cualquier operación que desees, y se te solicitará que las firmes usando %1$s + No soportado por %s + Esta es una billetera solo de visualización, Pezkuwi puede mostrarte saldos y otra información, pero no puedes realizar ninguna transacción con esta billetera + Ingresa el apodo de la billetera... + Por favor, prueba con otra. + Tipo de cripto par de claves Ethereum + Ruta de derivación secreta de Ethereum + Cuentas EVM + Exportar cuenta + Exportar + Importar existente + Esto es necesario para cifrar los datos y guardar el archivo JSON. + Establecer una nueva contraseña + Guarda tu secreto y almacénalo en un lugar seguro + Escribe tu secreto y guárdalo en un lugar seguro + JSON de restauración inválido. Por favor, asegúrate de que la entrada contenga un JSON válido. + El seed es inválido. Por favor, asegúrate de que tu entrada contenga 64 símbolos hexadecimales. + El JSON no contiene información de la red. Por favor, especifícalo a continuación. + Proporciona tu JSON de restauración + Típicamente es una frase de 12 palabras (pero puede ser de 15, 18, 21 o 24) + Escribe las palabras separadas por un espacio, sin comas u otros signos + Introduce las palabras en el orden correcto + Contraseña + Importar clave privada + 0xAB + Introduce tu seed bruto + Pezkuwi es compatible con todas las aplicaciones + Importar cartera + Cuenta + Tu ruta de derivación contiene símbolos no compatibles o tiene una estructura incorrecta + Ruta de derivación inválida + Archivo JSON + Asegúrate de que %s en tu dispositivo Ledger usando la aplicación Ledger Live + Polkadot app esté instalada + %s en tu dispositivo Ledger + Abre la aplicación Polkadot + Flex, Stax, Nano X, Nano S Plus + Ledger (Aplicación Genérica Polkadot) + Asegúrate de que la aplicación de Red está instalada en tu dispositivo Ledger usando la aplicación Ledger Live. Abre la aplicación de red en tu dispositivo Ledger. + Agrega al menos una cuenta + Añade cuentas a tu cartera + Guía de Conexión de Ledger + Asegúrate de que %s en tu dispositivo Ledger usando la aplicación Ledger Live + la aplicación de la Network está instalada + %s en su dispositivo Ledger + Abra la aplicación de la Red + Permite a Pezkuwi Wallet el %s + acceso a Bluetooth + %s para agregarla a la billetera + Seleccionar cuenta + %s en la configuración de tu teléfono + Habilitar OTG + Conectar Ledger + Para firmar operaciones y migrar tus cuentas a la nueva aplicación Generic Ledger, instala y abre la aplicación Migration. Las aplicaciones Legacy Old y Migration Ledger no serán compatibles en el futuro. + Se ha lanzado una nueva aplicación Ledger + Polkadot Migration + La aplicación Migration no estará disponible en un futuro cercano. Úsala para migrar tus cuentas a la nueva aplicación Ledger para evitar la pérdida de tus fondos. + Polkadot + Ledger Nano X + Ledger Legacy + Si usas un Ledger vía Bluetooth, activa Bluetooth en ambos dispositivos y concede permisos de Bluetooth y ubicación a Pezkuwi. Para USB, habilita OTG en la configuración de tu teléfono. + Por favor, activa el Bluetooth en la configuración de tu teléfono y en el dispositivo Ledger. Desbloquea tu dispositivo Ledger y abre la aplicación %s. + Selecciona tu dispositivo Ledger + Por favor, habilita el dispositivo Ledger. Desbloquea tu dispositivo Ledger y abre la aplicación %s. + ¡Ya casi llegas! 🎉\n Solo toca abajo para completar la configuración y comenzar a usar tus cuentas sin problemas tanto en la Polkadot App como en Pezkuwi Wallet + ¡Bienvenido a Pezkuwi! + frase de 12, 15, 18, 21 o 24 palabras + Multifirma + Control compartido (multifirma) + No tienes una cuenta para esta red, puedes crear o importar una cuenta. + Se necesita cuenta + No se encontró ninguna cuenta + Emparejar clave pública + Parity Signer + %s no soporta %s + Las siguientes cuentas han sido leídas exitosamente de %s + Aquí están tus cuentas + Código QR inválido, por favor asegúrate de que estás escaneando el código QR de %s + Asegúrate de seleccionar el primero de la lista + %s en tu smartphone + Abrir Parity Signer + %s que te gustaría agregar a Pezkuwi Wallet + Ve a la pestaña “Claves”. Selecciona semilla, luego la cuenta + Parity Signer te proporcionará un %s + código QR para escanear + Agrega una billetera desde %s + %s no soporta la firma de mensajes arbitrarios — solo transacciones + La firma no es compatible + Escanea el código QR desde %s + Legado + Nuevo (Vault v7+) + Tengo un error en %s + El código QR ha expirado + Por razones de seguridad, las operaciones generadas son válidas solo por %s.\nPor favor, genera un nuevo código QR y fírmalo con %s + El código QR es válido por %s + Por favor, asegúrese de estar escaneando el código QR para la operación de firma actual + Firmar con %s + Polkadot Vault + Preste atención, el nombre de la ruta de derivación debe estar vacío + %s en tu smartphone + Abrir Polkadot Vault + %s que te gustaría agregar a Pezkuwi Wallet + Toca en Derived Key + Polkadot Vault le proporcionará un %s + código QR para escanear + Toca el ícono en la esquina superior derecha y selecciona %s + Exportar clave privada + Clave privada + Delegado + Delegado a usted (Proxied) + Cualquier acción + Subasta + Cancelar Proxy + Gobernanza + Juicio de Identidad + Piscinas de Nominación + No Transferible + Staking + Ya tengo una cuenta + secretos + 64 símbolos hexadecimales + Seleccionar monedero de hardware + Seleccione su tipo de secreto + Seleccionar monedero + Cuentas con un secreto compartido + Cuentas Substrate + Tipo de criptografía de par de claves Substrate + Ruta de derivación del secreto Substrate + Nombre de la billetera + Apodo del monedero + Moonbeam, Moonriver y otras redes + Dirección EVM (Opcional) + Monederos preestablecidos + Polkadot, Kusama, Karura, KILT y más de 50 redes + Rastrea la actividad de cualquier monedero sin introducir tu clave privada en Pezkuwi Wallet + Tu monedero es solo para ver, lo que significa que no puedes realizar ninguna operación con él + ¡Ups! Falta la clave + solo visualización + Usar Polkadot Vault, Ledger o Parity Signer + Conectar monedero de hardware + Usa tus cuentas de Trust Wallet en Pezkuwi + Trust Wallet + Agregar cuenta %s + Agregar monedero + Cambiar cuenta %s + Cambiar cuenta + Ledger (Legacy) + Delegado a ti + Control compartido + Agregar nodo personalizado + Necesitas agregar una cuenta %s al monedero para poder delegar + Ingrese detalles de la red + Delegar a + Cuenta de delegación + Monedero de delegación + Tipo de acceso concedido + El depósito permanece reservado en tu cuenta hasta que se remueva el proxy. + Has alcanzado el límite de %s proxies añadidos en %s. Elimina proxies para añadir nuevos. + Se ha alcanzado el número máximo de proxies + Las redes personalizadas agregadas\naquí aparecerán + +%d + Pezkuwi ha cambiado automáticamente a tu billetera multisig para que puedas ver las transacciones pendientes. + Coloreado + Apariencia + iconos de token + Blanco + La dirección de contrato ingresada ya está presente en Pezkuwi como un token %s. + La dirección de contrato ingresada ya está presente en Pezkuwi como un token %s. ¿Estás seguro de que quieres modificarlo? + Este token ya existe + Por favor, asegúrate de que la url suministrada tenga la siguiente forma: www.coingecko.com/en/coins/tether. + Enlace de CoinGecko inválido + La dirección de contrato ingresada no es un contrato %s ERC-20. + Dirección de contrato inválida + Los decimales deben ser al menos 0, y no superar 36. + Valor de decimales inválido + Ingrese la dirección del contrato + Ingrese los decimales + Ingrese el símbolo + Ir a %s + A partir de %1$s, tu balance de %2$s, Staking y Gobernanza estarán en %3$s — con mejor rendimiento y costos más bajos. + Tus tokens de %s ahora en %s + Redes + Tokens + Agregar token + Dirección del contrato + Decimales + Símbolo + Ingrese los detalles del token ERC-20 + Seleccione la red para agregar el token ERC-20 + Crowdloans + Governance v1 + OpenGov + Elecciones + Staking + Vesting + Comprar tokens + ¿Recibió su DOT de vuelta de los crowdloans? ¡Comience a hacer staking de su DOT hoy para obtener las máximas recompensas posibles! + Potencie su DOT 🚀 + Filtrar tokens + No tienes tokens para regalar.\nCompra o deposita tokens en tu cuenta. + Todas las redes + Gestionar tokens + No transfiera %s a la cuenta controlada por Ledger ya que Ledger no admite el envío de %s, por lo que los activos quedarán bloqueados en esta cuenta + Ledger no admite este token + Buscar por red o token + No se encontraron redes o tokens con\n el nombre ingresado + Buscar por token + Sus carteras + No tiene tokens para enviar.\nCompre o reciba tokens en su\ncuenta. + Token para pagar + Token para recibir + ¡Antes de proceder con los cambios, %s para billeteras modificadas y eliminadas! + asegúrate de haber guardado las Frases De Paso + ¿Aplicar actualizaciones de respaldo? + ¡Prepárate para guardar tu billetera! + Esta frase de paso te da acceso total y permanente a todas las billeteras conectadas y los fondos dentro de ellas.\n%s + NO LA COMPARTAS. + No ingreses tu Frase de Paso en ningún formulario o sitio web.\n%s + LOS FONDOS PUEDEN PERDERSE PARA SIEMPRE. + El soporte o los administradores nunca solicitarán tu Frase de Paso bajo ninguna circunstancia.\n%s + CUIDADO CON LOS USURPADORES. + Revisar y Aceptar para Continuar + Eliminar respaldo + Puedes respaldar manualmente tu frase secreta para asegurar el acceso a los fondos de tu monedero si pierdes acceso a este dispositivo + Respaldar manualmente + Manual + No has agregado ninguna billetera con una frase de paso. + No hay billeteras para respaldar + Puedes habilitar copias de seguridad en Google Drive para almacenar copias cifradas de todos tus monederos, protegidas por una contraseña que establezcas. + Respaldar en Google Drive + Google Drive + Respaldar + Respaldar + Proceso de KYC directo y eficiente + Autenticación biométrica + Cerrar todo + ¡Compra iniciada! Por favor, espere hasta 60 minutos. Puede seguir el estado en el correo electrónico. + Seleccionar red para comprar %s + ¡Compra iniciada! Por favor, espere hasta 60 minutos. Puede seguir el estado en el correo electrónico. + Ninguno de nuestros proveedores actualmente soporta la compra de este token. Por favor elija un token diferente, una red diferente, o vuelva a verificar más tarde. + Este token no es compatible con la función de compra + Capacidad de pagar comisiones en cualquier token + La migración se realiza automáticamente, no se necesita acción + El historial de transacciones antiguo permanece en %s + A partir de %1$s tu saldo de %2$s, Staking y Gobernanza están en %3$s. Es posible que estas funciones no estén disponibles por hasta 24 horas. + Comisiones de transacción %1$sx más bajas\n(de %2$s a %3$s) + Reducción del balance mínimo %1$sx\n(de %2$s a %3$s) + ¿Qué hace que Asset Hub sea increíble? + A partir de %1$s tu %2$s saldo, el Staking y la Gobernanza están en %3$s + Más tokens soportados: %s, y otros tokens del ecosistema + Acceso unificado a %s, activos, staking y gobernanza + Equilibrar nodos automáticamente + Habilitar conexión + Por favor, ingresa una contraseña que se usará para recuperar tus billeteras desde el respaldo en la nube. Esta contraseña no podrá ser recuperada en el futuro, ¡así que asegúrate de recordarla! + Actualizar contraseña de respaldo + Token + Saldo disponible + Lo siento, la solicitud de verificación del saldo falló. Por favor, intente de nuevo más tarde. + Lo sentimos, no pudimos contactar al proveedor de transferencia. Por favor, intente de nuevo más tarde. + Lo sentimos, no tiene fondos suficientes para gastar la cantidad especificada + Tasa de transferencia + La red no responde + A + Vaya, el regalo ya ha sido reclamado + El regalo no puede ser recibido + Reclama el regalo + Puede haber un problema con el servidor. Por favor, inténtalo de nuevo más tarde. + Vaya, algo salió mal + Usa otra billetera, crea una nueva o añade una cuenta %s a esta billetera en Configuración. + Has reclamado exitosamente un regalo. Los tokens aparecerán en tu balance en breve. + ¡Tienes un regalo cripto! + Crea una nueva billetera o importa una existente para reclamar el regalo + No puedes recibir un regalo con una billetera %s + Todas las pestañas abiertas en el navegador DApp se cerrarán. + ¿Cerrar todas las DApps? + %s y recuerda siempre mantenerlos fuera de línea para restaurarlos en cualquier momento. Puedes hacer esto en la Configuración de Respaldo. + Por favor, escribe todas las Frases de Contraseña de tu billetera antes de continuar + El respaldo será eliminado de Google Drive + ¡El respaldo actual con tus billeteras será eliminado permanentemente! + ¿Estás seguro de que deseas eliminar el Respaldo en la Nube? + Eliminar Respaldo + En este momento tu respaldo no está sincronizado. Por favor, revisa estas actualizaciones. + Se encontraron cambios en el Respaldo en la Nube + Revisar Actualizaciones + Si no has escrito manualmente tu Frase de Contraseña para las billeteras que serán eliminadas, entonces esas billeteras y todos sus activos se perderán permanentemente e irremediablemente. + ¿Estás seguro de que deseas aplicar estos cambios? + Revisar el Problema + En este momento tu respaldo no está sincronizado. Por favor, revisa el problema. + No se pudieron aplicar los cambios de la billetera en el Respaldo en la Nube + Por favor, asegúrate de estar conectado a tu cuenta de Google con las credenciales correctas y haber otorgado acceso a Pezkuwi Wallet a Google Drive + Falló la autenticación de Google Drive + No tienes suficiente espacio de almacenamiento disponible en Google Drive. + No hay suficiente almacenamiento + Desafortunadamente, Google Drive no funciona sin los servicios de Google Play, que faltan en tu dispositivo. Intenta obtener los servicios de Google Play. + Servicios de Google Play no encontrados + No se puede hacer una copia de seguridad de tus billeteras en Google Drive. Asegúrate de haber habilitado Pezkuwi Wallet para usar tu Google Drive y tener suficiente espacio de almacenamiento disponible, luego intenta nuevamente. + Error de Google Drive + Por favor, verifica la corrección de la contraseña e intenta nuevamente. + La contraseña no es válida + Desafortunadamente, no hemos encontrado una copia de seguridad para restaurar las billeteras. + No se encontraron copias de seguridad + Asegúrate de haber guardado la frase de contraseña para la billetera antes de continuar. + La billetera se eliminará en la Copia de Seguridad en la Nube + Revisar Error de Copia de Seguridad + Revisar Actualizaciones de Copia de Seguridad + Ingresar Contraseña de Copia de Seguridad + Habilita para respaldar billeteras en tu Google Drive + Última sincronización: %s a las %s + Iniciar sesión en Google Drive + Revisar Problema de Google Drive + Copia de Seguridad Deshabilitada + Copia de seguridad sincronizada + Sincronización de la copia de seguridad... + Copia de seguridad no sincronizada + Nuevas billeteras se añaden automáticamente a la copia de seguridad en la nube. Puedes desactivar la copia de seguridad en la nube en Configuración. + Los cambios de la billetera se actualizarán en la copia de seguridad en la nube + Aceptar términos... + Cuenta + Dirección de cuenta + Activo + Agregar + Agregar delegación + Agregar red + Dirección + Avanzado + Todo + Permitir + Monto + El monto es demasiado bajo + El monto es demasiado grande + Aplicado + Aplicar + Preguntar de nuevo + ¡Atención! + Disponible: %s + Promedio + Saldo + Bono + Llamar + Datos de la llamada + Hash de la llamada + Cancelar + ¿Estás seguro de que quieres cancelar esta operación? + Lo siento, no tienes la aplicación correcta para procesar esta solicitud + No se puede abrir este enlace + Puedes usar hasta %s ya que necesitas pagar\n%s por la tarifa de red. + Cadena + Cambiar + Cambiar contraseña + Continuar automáticamente en el futuro + Elegir red + Limpiar + Cerrar + ¿Estás seguro de que quieres cerrar esta pantalla?\nTus cambios no se aplicarán. + Copia de seguridad en la nube + Completado + Completado (%s) + Confirmar + Confirmación + ¿Estás seguro? + Confirmado + %d ms + conectando... + Por favor, revisa tu conexión o intenta nuevamente más tarde + Fallo de conexión + Continuar + Copiado al portapapeles + Copiar dirección + Copiar datos de la llamada + Copiar hash + Copiar id + Tipo de cripto de par de llaves + Fecha + %s y %s + Eliminar + Depositante + Detalles + Deshabilitado + Desconectar + ¡No cierres la aplicación! + Hecho + Pezkuwi simula la transacción de antemano para evitar errores. Esta simulación no tuvo éxito. Inténtalo de nuevo más tarde o con una cantidad mayor. Si el problema persiste, por favor contacta al Soporte de Pezkuwi Wallet en Configuración. + Fallo en la simulación de transacción + Editar + %s (+%s más) + Selecciona la aplicación de correo + Habilitar + Introducir dirección… + Introducir cantidad... + Ingrese detalles + Introduce otra cantidad + Ingresar contraseña + Error + Tokens insuficientes + Evento + EVM + Dirección EVM + Tu cuenta será eliminada de la cadena de bloques después de esta operación ya que hace que el saldo total sea menor que el mínimo + La operación eliminará la cuenta + Expirado + Explorar + Fallido + La tarifa de red estimada %s es mucho mayor que la tarifa de red predeterminada (%s). Esto podría deberse a la congestión temporal de la red. Puedes actualizar para esperar una tarifa de red más baja. + La tarifa de red es demasiado alta + Tarifa: %s + Ordenar por: + Filtros + Descubrir más + ¿Olvidaste tu contraseña? + + todos los días + cada %s días + + diariamente + todos los días + Detalles completos + Obtener %s + Regalo + Entendido + Gobernanza + Cadena hexadecimal + Ocultar + + %d hora + %d horas + + Cómo funciona + Entiendo + Información + El código QR no es válido + Código QR inválido + Aprender más + Descubrir más sobre + Ledger + %s restantes + Administrar Billeteras + Máximo + %s máximo + + %d minuto + %d minutos + + Falta la cuenta %s + Modificar + Módulo + Nombre + Red + Ethereum + %s no es compatible + Polkadot + Redes + + Red + Redes + + Siguiente + No + No se encontró aplicación para importar archivo en el dispositivo. Por favor, instálela y vuelva a intentarlo + No se encontró aplicación adecuada en el dispositivo para procesar esta acción + Sin cambios + Vamos a mostrar su mnemónico. Asegúrese de que nadie pueda ver su pantalla y no tome capturas de pantalla - ellas pueden ser recogidas por malware de terceros + Ninguno + No disponible + Lo sentimos, no tiene fondos suficientes para pagar la tarifa de red. + Saldo insuficiente + No tienes suficiente saldo para pagar la tarifa de red de %s. El saldo actual es %s + No ahora + Apagado + Aceptar + De acuerdo, volver + Encendido + En curso + Opcional + Seleccione una opción + Frase de recuperación + Pegar + / año + %s / año + por año + %% + Los permisos solicitados son necesarios para usar esta pantalla. Debería habilitarlos en Ajustes. + Permisos denegados + Los permisos solicitados son necesarios para usar esta pantalla. + Permisos necesarios + Precio + Política de Privacidad + Proceder + Depósito de proxy + Revocar acceso + Notificaciones Push + Leer más + Recomendado + Actualizar comisión + Rechazar + Eliminar + Requerido + Restablecer + Reintentar + Algo salió mal. Por favor, intenta nuevamente + Revocar + Guardar + Escanear el código QR + Buscar + Los resultados de la búsqueda se mostrarán aquí + Resultados de la búsqueda: %d + seg + + %d segundo + %d segundos + + Ruta de derivación secreta + Ver todo + Seleccionar token + Configuraciones + Compartir + Compartir datos de la llamada + Compartir hash + Mostrar + Iniciar sesión + Solicitud de Firma + Firmante + La firma es inválida + Saltar + Saltar proceso + Ocurrió un error al enviar algunas transacciones. ¿Quieres intentar de nuevo? + No se pudo enviar algunas transacciones + Algo salió mal + Ordenar por + Estado + Substrate + Dirección Substrate + Toca para revelar + Términos y Condiciones + Red de prueba + Tiempo restante + Título + Abrir configuración + Tu saldo es demasiado pequeño + Total + Tarifa total + ID de transacción + Transacción enviada + Intentar de nuevo + Tipo + Por favor, intenta de nuevo con otra entrada. Si el error aparece de nuevo, por favor, contacta al soporte. + Desconocido + + %s no soportado + %s no soportados + + Ilimitado + Actualizar + Usar + Usar máximo + El destinatario debe ser una dirección %s válida + Destinatario inválido + Ver + Esperando + Cartera + Advertencia + + Tu Regalo + La cantidad debe ser positiva + Por favor, ingresa la contraseña que creaste durante el proceso de copia de seguridad + Ingresa la contraseña actual de la copia de seguridad + Confirmar transferirá tokens desde tu cuenta + Selecciona las palabras... + Frase de recuperación inválida, por favor verifica nuevamente el orden de las palabras + Referéndum + Votar + Rastrear + El nodo ya ha sido agregado previamente. Por favor, prueba con otro nodo. + No se puede establecer conexión con el nodo. Por favor, intenta con otro. + Desafortunadamente, la red no es compatible. Por favor, intenta con una de las siguientes: %s. + Confirma la eliminación de %s. + ¿Eliminar red? + Por favor, verifica tu conexión o intenta de nuevo más tarde + Personalizado + Predeterminado + Redes + Agregar conexión + Escanear código QR + Se ha identificado un problema con tu copia de seguridad. Tienes la opción de eliminar la copia de seguridad actual y crear una nueva. %s antes de proceder. + Asegúrate de haber guardado las frases de recuperación para todas las billeteras + Copia de seguridad encontrada pero vacía o dañada + En el futuro, sin la contraseña de respaldo no es posible restaurar tus carteras desde la Copia de Seguridad en la Nube.\n%s + Esta contraseña no se puede recuperar. + Recuerda la Contraseña de Respaldo + Confirma la contraseña + Contraseña de respaldo + Letras + Mín. 8 caracteres + Números + Las contraseñas coinciden + Por favor, introduce una contraseña para acceder a tu respaldo en cualquier momento. La contraseña no se puede recuperar, ¡asegúrate de recordarla! + Crea tu contraseña de respaldo + El ID de cadena ingresado no coincide con la red en la URL de RPC. + ID de cadena inválido + Los crowdloans privados aún no están soportados. + Crowdloan privado + Acerca de los crowdloans + Directo + Aprende más sobre los diferentes aportes a Acala + Aporte líquido + Activo (%s) + Acepto los Términos y Condiciones + Bono de Pezkuwi Wallet (%s) + El código de referido de Astar debe ser una dirección Polkadot válida + No se puede contribuir con la cantidad elegida ya que el monto recaudado resultante superará el límite del crowdloan. La contribución máxima permitida es %s. + No se puede contribuir al crowdloan seleccionado ya que su límite ya ha sido alcanzado. + Límite del crowdloan superado + Contribuir al crowdloan + Contribución + Has contribuido: %s + Líquido + Paralelo + Tus contribuciones\n aparecerán aquí + Se devuelve en %s + Será devuelto por la parachain + %s (vía %s) + Crowdloans + Obtén un bono especial + Los crowdloans se mostrarán aquí + No se puede contribuir al crowdloan seleccionado ya que ya ha finalizado. + El crowdloan ha finalizado + Ingresa tu código de referido + Información del crowdloan + Sobre el crowdloan de %s + Sitio web del crowdloan de %s + Período de arrendamiento + Elige parachains para contribuir con tus %s. Recuperarás tus tokens contribuidos, y si la parachain gana un slot, recibirás recompensas después del final de la subasta. + Necesitas agregar una cuenta %s a la billetera para poder contribuir + Aplicar bono + Si no tienes un código de referido, puedes aplicar el código de referido de Pezkuwi para recibir un bono por tu contribución + No has aplicado ningún bono + El crowdloan de Moonbeam solo soporta cuentas de tipo criptográfico SR25519 o ED25519. Por favor, considera usar otra cuenta para contribuir + No se puede contribuir con esta cuenta + Debes agregar una cuenta de Moonbeam a la billetera para participar en el crowdloan de Moonbeam + Falta la cuenta de Moonbeam + Este crowdloan no está disponible en tu ubicación. + Tu región no es compatible + Destino de la recompensa %s + Enviar acuerdo + Debe enviar un acuerdo con los Términos y Condiciones en la blockchain para proceder. Esto se requiere hacer solo una vez para todas las contribuciones a Moonbeam siguientes. + He leído y estoy de acuerdo con los Términos y Condiciones + Recaudado + Código de referido + El código de referido no es válido. Por favor, intente con otro. + Términos y Condiciones de %s + La cantidad mínima permitida para contribuir es %s. + La cantidad de contribución es demasiado pequeña + Sus tokens %s serán devueltos después del periodo de leasing. + Sus contribuciones + Recaudado: %s de %s + La URL de RPC ingresada está presente en Pezkuwi como una red personalizada de %s. ¿Está seguro de que desea modificarla? + https://networkscan.io + URL del explorador de bloques (Opcional) + 012345 + ID de cadena + TOKEN + Símbolo de moneda + Nombre de la red + Agregar nodo + Agregar nodo personalizado para + Ingrese detalles + Guardar + Editar nodo personalizado para + Nombre + Nombre del nodo + wss:// + URL del nodo + URL de RPC + DApps a los que permitió acceder para ver su dirección cuando los utiliza + La DApp “%s” será eliminada de Autorizados + ¿Eliminar de Autorizados? + DApps Autorizadas + Catálogo + Apruebe esta solicitud si confía en la aplicación + ¿Permitir a “%s” acceder a las direcciones de su cuenta? + Apruebe esta solicitud si confía en la aplicación.\nVerifique los detalles de la transacción. + DApp + DApps + %d DApps + Favoritos + Favoritos + Agregar a favoritos + La DApp “%s” será eliminada de Favoritos + ¿Eliminar de Favoritos? + La lista de DApps aparecerá aquí + Agregar a Favoritos + Modo escritorio + Eliminar de Favoritos + Configuraciones de página + Ok, llévame de vuelta + Pezkuwi Wallet cree que este sitio web podría comprometer la seguridad de sus cuentas y sus tokens + Phishing detectado + Buscar por nombre o ingresar URL + Cadena no soportada con hash de génesis %s + Asegúrese de que la operación sea correcta + No se pudo firmar la operación solicitada + Abrir de todos modos + Las DApps maliciosas pueden retirar todos tus fondos. Siempre realiza tu propia investigación antes de usar una DApp, otorgar permiso o enviar fondos.\n\nSi alguien te está presionando para que visites esta DApp, es probable que sea una estafa. En caso de duda, por favor contacta con el soporte de Pezkuwi Wallet: %s. + ¡Advertencia! La DApp es desconocida + Cadena no encontrada + El dominio del enlace %s no está permitido + Tipo de gobernanza no especificado + Tipo de gobernanza no soportado + Tipo de criptografía inválido + Ruta de derivación inválida + Mnemónico no válido + URL inválida + La URL de RPC ingresada está presente en Pezkuwi como una red de %s. + Canal de Notificación Predeterminado + +%d + Buscar por dirección o nombre + Formato de dirección inválido. Asegúrese de que la dirección pertenezca a la red correcta + resultados de búsqueda: %d + Cuentas Proxy y Multifirma detectadas automáticamente y organizadas para ti. Administra en cualquier momento en Configuración. + La lista de billeteras ha sido actualizada + Votado por todo el tiempo + Delegar + Todas las cuentas + Individuos + Organizaciones + El período de anulación de delegación comenzará después de que revoque una delegación + Sus votos votarán automáticamente junto con el voto de sus delegados + Información del delegado + Individuo + Organización + Votos delegados + Delegaciones + Editar delegación + No puede delegar en sí mismo, por favor elija otra dirección + No se puede delegar a sí mismo + Cuéntenos más sobre usted para que los usuarios de Pezkuwi lo conozcan mejor + ¿Eres un Delegado? + Describirse + En %s pistas + Votado último %s + Sus votos a través de %s + Sus votos: %s a través de %s + Eliminar votos + Revocar delegación + Después de que expire el período de anulación de delegación, necesitará desbloquear sus tokens. + Votos a los delegados + A las delegaciones + Votos recientes %s + Pistas + Seleccionar todo + Seleccione al menos 1 pista... + No hay pistas disponibles para delegar + Fellowship + Gobierno + Tesorería + Período de anulación de delegación + Ya no es válido + Su delegación + Sus delegaciones + Mostrar + Eliminando una copia de respaldo... + Tu contraseña de respaldo fue actualizada anteriormente. Para continuar usando la Copia de Seguridad en la Nube, %s + por favor, introduce la nueva contraseña de respaldo. + La contraseña de respaldo fue cambiada + No puede firmar transacciones de redes deshabilitadas. Habilite %s en la configuración y vuelva a intentarlo + %s está deshabilitado + Ya está delegando a esta cuenta: %s + La delegación ya existe + (Compatible con BTC/ETH) + ECDSA + ed25519 (alternativa) + Edwards + Contraseña de respaldo + Introducir datos de la llamada + No hay suficientes tokens para pagar la comisión + Contrato + Llamada de contrato + Función + Recuperar Carteras + %s Todas tus carteras estarán seguras en Google Drive. + ¿Quieres recuperar tus carteras? + Se encontró una Copia de Seguridad en la Nube existente + Descargar JSON de Restauración + Confirmar contraseña + Las contraseñas no coinciden + Establecer contraseña + Red: %s\nMnemotécnico: %s\nRuta de derivación: %s + Red: %s\nMnemotécnico: %s + Por favor, espere hasta que se calcule la comisión + El cálculo de la comisión está en progreso + Administrar Tarjeta de Débito + Vender token %s + Agregar delegación para el staking de %s + Detalles del intercambio + Máx: + Usted paga + Usted recibe + Seleccione un token + Recargar tarjeta con %s + Por favor, contacta a support@pezkuwichain.io. Incluye la dirección de correo electrónico que usaste para emitir la tarjeta. + Contactar soporte + Reclamado + Creado: %s + Introduce cantidad + El regalo mínimo es %s + Reclamado + Selecciona un token para regalar + Tarifa de red al reclamar + Crear Regalo + Envía regalos rápida, fácil y seguramente en Pezkuwi + Comparte Regalos Cripto con Cualquiera, en Cualquier Lugar + Regalos que creaste + Selecciona red para el regalo de %s + Introduce el monto de tu regalo + %s como un enlace e invita a cualquiera a Pezkuwi + Comparte el regalo directamente + %s, y puedes devolver los regalos no reclamados en cualquier momento desde este dispositivo + El regalo está disponible al instante + Canal de Notificación de Gobernanza + + Necesitas seleccionar al menos %d pista + Necesitas seleccionar al menos %d pistas + + Desbloquear + Ejecutar este intercambio resultará en un deslizamiento significativo y pérdidas financieras. Considere reducir el tamaño de su transacción o dividir su operación en múltiples transacciones. + Impacto de precio alto detectado (%s) + Historia + Correo electrónico + Nombre legal + Nombre en Element + Identidad + Web + El archivo JSON suministrado fue creado para una red diferente. + Por favor, asegúrese de que su entrada contenga un json válido. + El JSON de restauración es inválido + Por favor, verifique la corrección de la contraseña e inténtelo de nuevo. + Fallo en la descifrado del almacén de claves + Pegar json + Tipo de encriptación no soportado + No se puede importar una cuenta con secreto de Substrate en la red con cifrado de Ethereum + No se puede importar una cuenta con secreto de Ethereum en la red con cifrado de Substrate + Su mnemónico es inválido + Por favor, asegúrese de que su entrada contiene 64 símbolos hexadecimales. + Semilla inválida + Desafortunadamente, no se ha encontrado una Copia de Seguridad con tus carteras. + Copia de Seguridad No Encontrada + Recuperar billeteras desde Google Drive + Usa tu frase de 12, 15, 18, 21 o 24 palabras + Elige cómo te gustaría importar tu cartera + Watch-only + Integre todas las funciones de la red que está construyendo en Pezkuwi Wallet, haciéndola accesible para todos. + Integre su red + ¿Construyendo para Polkadot? + Los datos de la llamada que proporcionaste son inválidos o tienen el formato incorrecto. Por favor asegúrate de que sean correctos e inténtalo de nuevo. + Estos datos de la llamada son para otra operación con hash de llamada %s + Datos de la llamada no válidos + La dirección proxy debe ser una dirección %s válida + Dirección proxy inválida + El símbolo de moneda ingresado (%1$s) no coincide con la red (%2$s). ¿Desea usar el símbolo de moneda correcto? + Símbolo de moneda inválido + No se puede decodificar el QR + Código QR + Subir desde la galería + Exportar archivo JSON + Idioma + Ledger no soporta %s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + La operación fue cancelada por el dispositivo. Asegúrese de haber desbloqueado su Ledger. + Operación cancelada + Abra la aplicación %s en su dispositivo Ledger + Aplicación %s no iniciada + La operación completada con error en el dispositivo. Por favor, intente nuevamente más tarde. + Fallo en la operación de Ledger + Mantén presionado el botón de confirmar en tu %s para aprobar la transacción + Cargar más cuentas + Revisar y Aprobar + Cuenta %s + Cuenta no encontrada + Tu dispositivo Ledger está usando una aplicación genérica desactualizada que no soporta direcciones EVM. Actualízala a través de Ledger Live. + Actualizar la aplicación genérica de Ledger + Pulse ambos botones en su %s para aprobar la transacción + Por favor, actualice %s usando la aplicación Ledger Live + Metadatos desactualizados + Ledger no soporta la firma de mensajes arbitrarios — solo transacciones + Por favor, asegúrese de que ha seleccionado el dispositivo Ledger correcto para la operación actualmente en aprobación + Por razones de seguridad, las operaciones generadas son válidas solo por %s. Por favor, intente de nuevo y apruébelo con Ledger + La transacción ha expirado + La transacción es válida por %s + Ledger no soporta esta transacción. + Transacción no soportada + Presiona ambos botones en tu %s para aprobar la dirección + Presiona el botón de confirmar en tu %s para aprobar la dirección + Presiona ambos botones en tu %s para aprobar las direcciones + Presiona el botón de confirmar en tu %s para aprobar las direcciones + Esta cartera está emparejada con Ledger. Pezkuwi te ayudará a formar cualquier operación que desees, y se te solicitará firmarlas usando un dispositivo Ledger + Selecciona una cuenta para agregar a la cartera + Cargando información de la red... + Cargando detalles de la transacción… + Buscando tu copia de seguridad... + Transacción de pago enviada + Agregar token + Gestionar copia de seguridad + Eliminar red + Editar red + Gestionar red agregada + No podrá ver los saldos de sus tokens en esa red en la pantalla de Activos + ¿Eliminar red? + Eliminar nodo + Editar nodo + Gestionar nodo agregado + Nodo \"%s\" será eliminado + ¿Eliminar nodo? + Clave personalizada + Clave por defecto + No compartas esta información con nadie. Si lo haces, perderás todos tus activos de manera permanente e irrecuperable. + Cuentas con clave personalizada + Cuentas por defecto + %s, +%d otros + Cuentas con clave por defecto + Selecciona la clave para respaldar + Selecciona una cartera para respaldar + Por favor, lee lo siguiente atentamente antes de ver tu copia de seguridad + ¡No compartas tu frase de paso! + Mejor precio con tarifas de hasta 3.95%% + Asegúrate de que nadie pueda ver tu pantalla\ny no hagas capturas de pantalla + Por favor %s nadie + no compartas con + Por favor, intenta con otra. + Frase mnemotécnica inválida, por favor verifica una vez más el orden de las palabras + No puedes seleccionar más de %d carteras + + Selecciona al menos %d cartera + Selecciona al menos %d carteras + + Esta transacción ya ha sido rechazada o ejecutada. + No se puede realizar esta transacción + %s ya ha iniciado la misma operación y actualmente está esperando ser firmada por otros signatarios. + La operación ya existe + Para gestionar tu tarjeta de débito, por favor cambia a un tipo diferente de billetera. + La tarjeta de débito no es compatible para Multisig + Depósito multisig + El depósito permanece bloqueado en la cuenta del depositante hasta que la operación multisig sea ejecutada o rechazada. + Asegúrate de que la operación es correcta + Para crear o gestionar regalos, por favor cambia a un tipo diferente de monedero. + Los regalos no son compatibles con las billeteras Multisig + Firmado y ejecutado por %s. + ✅ Transacción multisig ejecutada + %s en %s. + ✍🏻 Tu firma solicitada + Iniciado por %s. + Billetera: %s + Firmado por %s + %d de %d firmas recogidas. + En nombre de %s. + Rechazado por %s. + ❌ Transacción multisig rechazada + En nombre de + %s: %s + Aprobar + Aprobar y Ejecutar + Ingresa datos de la llamada para ver detalles + La transacción ya ha sido ejecutada o rechazada. + El firmado ha terminado + Rechazar + Firmantes (%d de %d) + Batch All (revierte en caso de error) + Batch (ejecuta hasta el error) + Force Batch (ignora errores) + Creado por ti + Aún no hay transacciones.\nLas solicitudes de firma aparecerán aquí + Firmando (%s de %s) + Firmado por ti + Operación desconocida + Transacciones a firmar + En nombre de: + Firmado por signatario + Transacción multisig ejecutada + Tu firma solicitada + Transacción multisig rechazada + Para vender cripto por fiat, por favor cambia a un tipo diferente de billetera. + La venta no es compatible para Multisig + Firmante: + %s no tiene suficiente saldo para colocar un depósito multisig de %s. Necesitas añadir %s más a tu saldo. + %s no tiene suficiente saldo para pagar la tarifa de red de %s y colocar un depósito multisig de %s. Necesitas añadir %s más a tu saldo. + %s necesita al menos %s para pagar esta tarifa de transacción y mantenerse por encima del saldo mínimo de red. El saldo actual es: %s + %s no tiene suficiente saldo para pagar la tarifa de red de %s. Necesitas añadir %s más a tu saldo. + Las carteras multisig no soportan la firma de mensajes arbitrarios — solo transacciones + La transacción será rechazada. El depósito multisig será devuelto a %s. + La transacción multisig será iniciada por %s. El iniciador paga la tarifa de red y reserva un depósito multisig, que será liberado una vez que la transacción sea ejecutada. + Transacción multifirma + Otros firmantes ahora pueden confirmar la transacción.\nPuedes rastrear su estado en el %s. + Transacciones a firmar + Transacción multifirma creada + Ver detalles + Transacción sin información inicial en cadena (datos de la llamada) fue rechazada + %s en %s.\nNo se requieren más acciones de tu parte. + Transacción Multisig Ejecutada + %1$s → %2$s + %s en %s.\nRechazado por: %s.\nNo se requieren más acciones de tu parte. + Transacción Multisig Rechazada + Las transacciones de esta billetera requieren aprobación de múltiples firmantes. Tu cuenta es uno de los firmantes: + Otros firmantes: + Umbral %d de %d + Canal de Notificaciones de Transacciones Multisig + Activar en Configuración + Recibe notificaciones sobre solicitudes de firma, nuevas firmas y transacciones completadas, para que siempre tengas el control. Adminístralo en cualquier momento desde Configuración. + ¡Notificaciones push multisig están aquí! + Este Nodo ya existe + Comisión de red + Dirección del nodo + Información del Nodo + Agregado + nodos personalizados + nodos predeterminados + Predeterminado + Conectando… + Colección + Creado por + %s por %s + %s unidades de %s + #%s Edición de %s + Serie ilimitada + Propiedad de + Sin precio + Tus NFTs + No has añadido o seleccionado ninguna billetera multisig en Pezkuwi. Añade y selecciona al menos una para recibir notificaciones push multisig. + No hay billeteras multisig + La URL que ingresaste ya existe como el Nodo \"%s\". + Este Nodo ya existe + La URL del Nodo que ingresaste no está respondiendo o contiene un formato incorrecto. El formato de la URL debe comenzar con \"wss://\". + Error de Nodo + La URL que ingresaste no corresponde al Nodo para %1$s.\nPor favor ingresa la URL del nodo %1$s válido. + Red incorrecta + Reclamar recompensas + Tus tokens se agregarán de nuevo al stake + Directo + Información de staking en el pool + Tus recompensas (%s) también serán reclamadas y añadidas a tu saldo disponible + Pool + No se puede realizar la operación especificada ya que el pool está en estado de destrucción. Se cerrará pronto. + El pool se está cerrando + Actualmente no hay lugares libres en la cola de desvinculación para tu pool. Por favor, intenta de nuevo en %s + Demasiadas personas están desvinculando de tu pool + Tu pool + Tu pool (#%s) + Crear cuenta + Crear una nueva cartera + Política de Privacidad + Importar cuenta + Ya tengo una cartera + Al continuar, aceptas nuestros\n%1$s y %2$s + Términos y Condiciones + Intercambiar + Uno de tus collators no está generando recompensas + Uno de tus collators no ha sido seleccionado en la ronda actual + Tu período de desvinculación para %s ha pasado. No olvides redimir tus tokens + No se puede hacer staking con este collator + Cambiar collator + No se puede añadir stake a este collator + Gestionar collators + El collator seleccionado mostró intención de dejar de participar en el staking + No puedes añadir stake al collator para el cual estás desvinculando todos los tokens. + Tu stake será menor que el stake mínimo (%s) para este collator. + El saldo de staking restante caerá por debajo del valor mínimo de la red (%s) y también se agregará a la cantidad de desvinculación + No estás autorizado. Por favor, intenta de nuevo. + Usar biometría para autorizar + Pezkuwi Wallet utiliza autenticación biométrica para restringir el acceso de usuarios no autorizados a la aplicación. + Biometría + El código PIN ha sido cambiado exitosamente + Confirma tu código PIN + Crear código PIN + Introduce el código PIN + Establece tu código PIN + No puedes unirte al pool ya que ha alcanzado el número máximo de miembros + El pool está lleno + No puedes unirte a un pool que no está abierto. Por favor, contacta al propietario del pool. + El pool no está abierto + Ya no puedes usar tanto Staking Directo como Pool Staking desde la misma cuenta. Para gestionar tu Pool Staking, primero necesitas unstakear tus tokens del Staking Directo. + Las operaciones de Pool no están disponibles + Populares + Agregar red manualmente + Cargando lista de redes... + Buscar por nombre de la red + Agregar red + %s a las %s + 1D + Todo + 1M + %s (%s) + Precio de %s + 1S + 1A + Todo el tiempo + Último mes + Hoy + Última semana + Último año + Cuentas + Carteras + Idioma + Cambiar código PIN + Abrir la aplicación con el saldo oculto + Aprobar con PIN + Modo seguro + Configuración + Para administrar tu Tarjeta de Débito, por favor cambia a una billetera diferente con la red Polkadot. + La Tarjeta de Débito no es compatible con esta billetera Proxied + Esta cuenta otorgó acceso para realizar transacciones a la siguiente cuenta: + Operaciones de staking + La cuenta delegada %s no tiene suficiente saldo para pagar la tarifa de red de %s. Saldo disponible para pagar la tarifa: %s + Las carteras delegadas no soportan la firma de mensajes arbitrarios, solo transacciones + %1$s no ha delegado %2$s + %1$s ha delegado %2$s solo para %3$s + ¡Ups! No hay suficiente permiso + La transacción será iniciada por %s como una cuenta delegada. La tarifa de red será pagada por la cuenta delegada. + Esta es una cuenta Delegada (Proxied) + %s proxy + El delegado ha votado + Nuevo Referéndum + Actualización de Referéndum + %s Referéndum #%s está en vivo ahora! + 🗳️ Nuevo referéndum + Descarga Pezkuwi Wallet v%s para obtener todas las nuevas funciones! + ¡Un nuevo update de Pezkuwi Wallet está disponible! + %s referéndum #%s ha finalizado y sido aprobado 🎉 + ✅ Referéndum aprobado! + %s Referéndum #%s cambio de status de %s a %s + %s referéndum #%s ha terminado y sido rechazado! + ❌ Referéndum rechazado! + 🗳️ Estado del Referendum cambiado + %s Referéndum #%s cambió su estado a %s + Anuncios de Pezkuwi + Balances + Activar notificaciones + Transacciones de multisig + No recibirás notificaciones sobre Actividades de Carteras (Balances, Staking) porque no has seleccionado ninguna cartera + Otros + Tokens recibidos + Tokens enviados + Recompensas de Staking + Carteras + ⭐️ Nueva recompensa %s + Recibido %s de staking %s + ⭐️ Nueva recompensa + Pezkuwi Wallet • ahora + Recibido +0.6068 KSM ($20.35) de staking Kusama + Recibido %s en %s + ⬇️ Recibido + ⬇️ Recibido %s + Enviado %s a %s en %s + 💸 Enviado + 💸 Enviado %s + Selecciona hasta %d billeteras para ser notificado cuando la billetera tenga actividad + Habilitar notificaciones push + Reciba notificaciones sobre operaciones de Wallet, actualizaciones de Gobernanza, actividad de Staking y Seguridad, para que siempre esté informado + Al habilitar las notificaciones push, acepta nuestros %s y %s + Intente de nuevo más tarde accediendo a los ajustes de notificación desde la pestaña de Configuración + ¡No te pierdas de nada! + Seleccionar red para recibir %s + Copiar dirección + Si reclamas este regalo, el enlace compartido será desactivado y los tokens serán devueltos a tu billetera.\n¿Quieres continuar? + ¿Reclamar el regalo de %s? + Has reclamado exitosamente tu regalo + Pega el json o sube un archivo… + Subir archivo + Restaurar JSON + Frase de contraseña mnemotécnica + Semilla cruda + Tipo de fuente + Todos los referendos + Mostrar: + Sin votar + Votado + No hay referendos con los filtros aplicados + La información de los referendos aparecerá aquí cuando comiencen + No se encontraron referendos con el título o ID entrado + Buscar por título de referendo o ID + %d referendos + Desliza para votar en referendos con resúmenes por IA. ¡Rápido y fácil! + Referendos + Referendo no encontrado + Los votos de Abstención solo se pueden realizar con una convicción de 0.1x. ¿Votar con una convicción de 0.1x? + Actualización de la convicción + Votos de Abstención + A favor: %s + Usar el navegador Pezkuwi DApp + Solo el proponente puede editar esta descripción y el título. Si posees la cuenta del proponente, visita Polkassembly y completa la información sobre tu propuesta + Cantidad solicitada + Cronología + Tu voto: + Curva de aprobación + Beneficiario + Depósito + Electorado + Demasiado largo para previsualización + Parámetros JSON + Propositor + Curva de soporte + Participación + Umbral de votación + Posición: %s de %s + Necesitas agregar una cuenta de %s a la cartera para poder votar + Referéndum %s + En contra: %s + Votos en contra + %s votos por %s + Votos a favor + Staking + Aprobado + Cancelado + Decidiendo + Decidiendo en %s + Ejecutado + En cola + En cola (%s de %s) + Eliminado + No pasa + Pasando + Preparando + Rechazado + Aprobar en %s + Ejecutar en %s + Tiempo agotado en %s + Rechazar en %s + Tiempo agotado + Esperando depósito + Umbral: %s de %s + Votado: Aprobado + Cancelado + Creado + Votando: Decidiendo + Ejecutado + Votando: En cola + Eliminado + Votando: No pasa + Votando: Pasando + Votando: Preparando + Votado: Rechazado + Tiempo agotado + Votando: Esperando depósito + Para pasar: %s + Crowdloans + Tesorería: gran gasto + Tesorería: grandes propinas + Fellowship: administración + Gobernanza: registrador + Gobernanza: arrendamiento + Tesorería: gasto medio + Gobernanza: cancelador + Gobernanza: eliminador + Agenda principal + Tesorería: pequeño gasto + Tesorería: pequeñas propinas + Tesorería: cualquier + permanece bloqueado en %s + Desbloqueable + Abstenerse + A favor + Reutilizar todos los bloqueos: %s + Reutilizar bloqueo de gobernanza: %s + Bloqueo de gobernanza + Período de bloqueo + En contra + Multiplica votos aumentando el periodo de bloqueo + Votar por %s + Después del período de bloqueo no olvides desbloquear tus tokens + Referendos votados + %s votos + %s × %sx + La lista de votantes aparecerá aquí + %s votos + Cofradía: lista blanca + Tu voto: %s votos + El referéndum se ha completado y la votación ha terminado + Referéndum completado + Estás delegando votos para la pista de referéndum seleccionada. Por favor, pide a tu delegado que vote o elimina la delegación para poder votar directamente. + Ya delegando votos + Has alcanzado el máximo de %s votos para la pista + Número máximo de votos alcanzado + No tienes suficientes tokens disponibles para votar. Disponible para votar: %s. + Revocar tipo de acceso + Revocar por + Eliminar votos + + Anteriormente has votado en referendos en la pista %d. Para hacer esta pista disponible para delegación, necesitas eliminar tus votos existentes. + Anteriormente has votado en referendos en las pistas %d. Para hacer estas pistas disponibles para delegación, necesitas eliminar tus votos existentes. + + ¿Eliminar el historial de tus votos? + %s Es exclusivamente tuyo, almacenado de forma segura e inaccesible para otros. Sin la contraseña de respaldo, restaurar las carteras desde Google Drive es imposible. Si se pierde, elimina la copia de seguridad actual para crear una nueva con una contraseña nueva. %s + Desafortunadamente, tu contraseña no puede ser recuperada. + Alternativamente, use la frase de recuperación para la restauración. + ¿Has perdido tu contraseña? + Tu contraseña de respaldo se actualizó previamente. Para seguir usando la copia de seguridad en la nube, por favor ingresa la nueva contraseña de respaldo. + Por favor ingrese la contraseña que creó durante el proceso de respaldo + Ingrese la contraseña de la copia de seguridad + Error al actualizar la información sobre el tiempo de ejecución del blockchain. Algunas funcionalidades pueden no trabajar. + Fallo en la actualización del tiempo de ejecución + Contactos + mis cuentas + No se encontró ningún pool con el nombre o ID ingresado. Asegúrate de haber ingresado los datos correctos + Dirección de cuenta o nombre de cuenta + Aquí se mostrarán los resultados de la búsqueda + Resultados de la búsqueda + piscinas activas: %d + miembros + Seleccionar pool + Seleccionar pistas para añadir delegación + Pistas disponibles + Por favor, selecciona las pistas en las que te gustaría delegar tu poder de voto. + Seleccionar pistas para editar delegación + Seleccionar pistas para revocar tu delegación + Pistas no disponibles + Enviar regalo en + Habilitar Bluetooth y Conceder Permisos + Pezkuwi necesita que la ubicación esté habilitada para poder realizar el escaneo bluetooth y encontrar tu dispositivo Ledger + Por favor, habilita la geolocalización en las configuraciones del dispositivo + Seleccionar red + Seleccionar token para votar + Seleccione pistas para + %d de %d + Seleccione la red para vender %s + ¡Venta iniciada! Por favor, espere hasta 60 minutos. Puede seguir el estado en el correo electrónico. + Ninguno de nuestros proveedores actualmente soporta la venta de este token. Por favor elija un token diferente, una red diferente, o vuelva a verificar más tarde. + Este token no es compatible con la función de venta + Dirección o w3n + Seleccionar red para enviar %s + El destinatario es una cuenta del sistema. No está controlado por ninguna empresa o individuo.\n¿Estás seguro de que aún quieres realizar esta transferencia? + Los tokens se perderán + Otorgar autoridad a + Por favor, asegúrate de que la biometría está activada en los Ajustes + Biometría desactivada en los Ajustes + Comunidad + Obtenga ayuda por Email + General + Cada operación de firma en monederos con par de claves (creados en nova wallet o importados) debería requerir verificación PIN antes de construir la firma + Solicitar autenticación para firmar operaciones + Preferencias + Las notificaciones push están disponibles solo para la versión de Pezkuwi Wallet descargada desde Google Play. + Las notificaciones push solo están disponibles para dispositivos con servicios de Google. + La grabación de pantalla y las capturas de pantalla no estarán disponibles. La aplicación minimizada no mostrará el contenido + Modo seguro + Seguridad + Soporte & Retroalimentación + Twitter + Wiki y centro de ayuda + Youtube + La convicción se establecerá en 0.1x cuando se Abstenga + No puedes hacer stake con Staking Directo y Pools de Nominación al mismo tiempo + Ya estás en staking + Gestión avanzada de staking + El tipo de staking no puede ser cambiado + Ya tienes Staking directo + Staking directo + Has especificado menos que el stake mínimo de %s requerido para ganar recompensas con %s. Deberías considerar usar el Staking en pool para ganar recompensas. + Reutilizar tokens en Gobernanza + Stake mínimo: %s + Recompensas: Pagadas automáticamente + Recompensas: Reclamar manualmente + Ya estás haciendo staking en un pool + Staking en pool + Tu stake es menos que el mínimo para ganar recompensas + Tipo de staking no soportado + Compartir enlace de regalo + Reclamar + ¡Hola! ¡Tienes un regalo %s esperándote!\n\nInstala la app Pezkuwi Wallet, configura tu billetera y reclámalo mediante este enlace especial:\n%s + El Regalo Ha Sido Preparado.\n¡Compártelo Ahora! + sr25519 (recomendado) + Schnorrkel + La cuenta seleccionada ya está siendo utilizada como controladora. + Añadir autoridad delegada (Proxy) + Tus delegaciones + Delegadores activos + Agrega la cuenta del controlador %s a la aplicación para realizar esta acción. + Añadir delegación + Tu stake es menos del mínimo de %s.\nTener un stake menor que el mínimo aumenta las posibilidades de que el staking no genere recompensas + Stake más tokens + Cambie sus validadores. + Todo está bien ahora. Aquí aparecerán alertas. + Tener una posición desactualizada en la cola de asignación de stake a un validador puede suspender tus recompensas + Mejoras de staking + Canjear tokens sin stake. + Por favor, espera a que comience la próxima era. + Alertas + Ya controlador + Ya tienes staking en %s + Balance de staking + Balance + Stake más + No estás nominando ni validando + Cambiar controlador + Cambiar validadores + %s de %s + Validadores seleccionados + Controlador + Cuenta controladora + Descubrimos que esta cuenta no tiene tokens libres, ¿estás seguro de que quieres cambiar el controlador? + El controlador puede deshacer stake, canjear, volver a hacer stake, cambiar el destino de las recompensas y los validadores. + El controlador se utiliza para: deshacer stake, canjear, volver a hacer stake, cambiar validadores y establecer el destino de las recompensas + Controlador cambiado + Este validador está bloqueado y no puede ser seleccionado en este momento. Por favor, inténtalo de nuevo en la próxima era. + Limpiar filtros + Deseleccionar todo + Llenar el resto con recomendados + Validadores: %d de %d + Seleccionar validadores (máx. %d) + Mostrar seleccionados: %d (máx. %d) + Seleccionar validadores + Recompensas estimadas (%% APR) + Recompensas estimadas (%% APY) + Actualizar tu lista + Staking a través del navegador DApp Pezkuwi + Más opciones de staking + Haz staking y obtén recompensas + El Staking de %1$s está activo en %2$s a partir de %3$s + Recompensas estimadas + era #%s + Ganancias estimadas + Ganancias estimadas %s + Stake propio del validador + Stake propio del validador (%s) + Los tokens en período de deshacer stake no generan recompensas. + Los tokens no generan recompensas durante el período de deshacer stake + Después del período de deshacer stake necesitarás canjear tus tokens. + No olvides canjear tus tokens después del período de deshacer stake + Tus recompensas se incrementarán a partir de la próxima era. + Obtendrás recompensas aumentadas a partir de la próxima era + Los tokens en stake generan recompensas cada era (%s). + Los tokens en stake producen recompensas cada era (%s) + Pezkuwi wallet cambiará el destino de las recompensas\na tu cuenta para evitar restos en staking. + Si quieres deshacer el staking de tokens, tendrás que esperar el período de desestaking (%s). + Para deshacer el staking de tokens tendrás que esperar el período de desestaking (%s) + Información de staking + Nominadores activos + + %d día + %d días + + Stake mínimo + Red %s + En staking + Por favor, cambia tu wallet a %s para configurar un proxy + Selecciona una cuenta stash para configurar proxy + Gestionar + %s (máx. %s) + Se ha alcanzado el número máximo de nominadores. Intenta más tarde + No se puede comenzar el staking + Stake mín. + Necesitas agregar una cuenta %s a tu wallet para comenzar el staking + Mensual + Agrega tu cuenta de controlador en el dispositivo. + Sin acceso a la cuenta de controlador + Nominado: + %s recompensados + Uno de tus validadores ha sido elegido por la red. + Estado activo + Estado inactivo + Tu cantidad en staking es menor que el stake mínimo para recibir una recompensa. + Ninguno de tus validadores ha sido elegido por la red. + Tu staking empezará en la próxima era. + Inactivo + Esperando la próxima Era + esperando la próxima era (%s) + No tienes saldo suficiente para el depósito de proxy de %s. Saldo disponible: %s + Canal de Notificaciones de Staking + Colador + El stake mínimo del colador es mayor que tu delegación. No recibirás recompensas de este colador. + Información sobre el colador + Stake propio del colador + Coladores: %s + Uno o más de tus coladores han sido elegidos por la red. + Delegadores + Has alcanzado el número máximo de delegaciones de %d coladores + No puedes seleccionar un nuevo colador + Nuevo colador + esperando el próximo ronda (%s) + Tienes solicitudes de desestaking pendientes para todos tus coladores. + No hay coladores disponibles para desestaking + Los tokens devueltos se contarán a partir de la próxima ronda + Los tokens en staking producen recompensas cada ronda (%s) + Selecciona un colador + Selecciona un colador... + Obtendrás recompensas aumentadas a partir de la próxima ronda + No recibirás recompensas por esta ronda ya que ninguna de tus delegaciones está activa. + Ya estás desvinculando tokens de este collator. Solo puedes tener una desvinculación pendiente por collator + No puedes desvincularte de este collator + Tu participación debe ser mayor que la participación mínima (%s) para este collator. + No recibirás recompensas + Algunos de tus collators o no han sido elegidos o tienen una participación mínima mayor que tu cantidad participada. No recibirás una recompensa en esta ronda al participar con ellos. + Tus collators + Tu participación está asignada a los próximos collators + Collators activos sin producir recompensas + Collators sin suficiente participación para ser elegidos + Collators que se activarán en la próxima ronda + En espera (%s) + Pago + Pago expirado + + %d día restante + %d días restantes + + Puedes pagarlas por ti mismo, cuando estén cerca de expirar, pero pagarás la tarifa + Las recompensas se pagan cada 2–3 días por los validadores + Todos + Todo el tiempo + La fecha de fin siempre es hoy + Periodo personalizado + %dD + Selecciona la fecha de fin + Finaliza + Últimos 6 meses (6M) + 6M + Últimos 30 días (30D) + 30D + Últimos 3 meses (3M) + 3M + Selecciona la fecha + Selecciona la fecha de inicio + Inicia + Mostrar recompensas de participación por + Últimos 7 días (7D) + 7D + Último año (1Y) + 1Y + Tu saldo disponible es %s, necesitas dejar %s como saldo mínimo y pagar la tarifa de la red de %s. Puedes apostar no más de %s. + Autoridades delegadas (proxy) + Ranura actual en cola + Nueva ranura en cola + Regresar al stake + Todo des-stake + Los tokens devueltos se contarán desde la próxima era + Cantidad personalizada + La cantidad que desea regresar al stake es mayor que el saldo des-stake + Último des-staked + Más rentable + No sobresuscrito + Con identidad en cadena + No penalizado + Límite de 2 validadores por identidad + con al menos un contacto de identidad + Validadores recomendados + Validadores + Recompensa estimada (APY) + Canjear + Canjeable: %s + Recompensa + Destino de la recompensa + Recompensas transferibles + Era + Detalles de la recompensa + Validador + Ganancias con reinversión + Ganancias sin reinversión + ¡Perfecto! Todas las recompensas están pagadas. + ¡Asombroso! No tienes recompensas sin pagar + Pagar todo (%s) + Recompensas pendientes + Recompensas sin pagar + %s recompensas + Acerca de las recompensas + Recompensas (APY) + Destino de las recompensas + Seleccionar por uno mismo + Seleccionar la cuenta de pago + Seleccionar recomendado + seleccionados %d (máx. %d) + Validadores (%d) + Actualizar Controlador a Stash + Usar Proxies para delegar operaciones de Staking a otra cuenta + Las Cuentas Controladoras Están Siendo Descontinuadas + Seleccionar otra cuenta como controladora para delegarle operaciones de gestión de staking + Mejorar la seguridad del staking + Establecer validadores + Los validadores no están seleccionados + Seleccionar validadores para comenzar el staking + El stake mínimo recomendado para recibir consistentemente recompensas es %s. + No puedes hacer staking con menos del valor mínimo de la red (%s) + La apuesta mínima debe ser mayor que %s + Reinvertir + Reinvertir recompensas + ¿Cómo usar tus recompensas? + Selecciona tu tipo de recompensas + Cuenta de pago + Recorte + Apostar %s + Apostar máximo + Período de apuesta + Tipo de apuesta + Debes confiar en que tus nominaciones actúen de manera competente y honesta, basar tu decisión puramente en su rentabilidad actual podría llevar a ganancias reducidas o incluso pérdida de fondos. + Elige cuidadosamente a tus validadores, ya que deben actuar de forma competente y honesta. Basar tu decisión puramente en la rentabilidad podría llevar a recompensas reducidas o incluso pérdida de la apuesta + Apostar con tus validadores + Pezkuwi Wallet seleccionará a los mejores validadores basándose en criterios de seguridad y rentabilidad + Apostar con validadores recomendados + Comenzar a apostar + Reserva + La reserva puede apostar más y establecer el controlador. + Reserva se utiliza para: apostar más y establecer el controlador + La cuenta de reserva %s no está disponible para actualizar la configuración de apuesta + El nominador obtiene ingresos pasivos al bloquear sus tokens para asegurar la red. Para lograrlo, el nominador debe seleccionar varios validadores para apoyar. El nominador debe ser cuidadoso al seleccionar a los validadores. Si el validador seleccionado no se comporta adecuadamente, se aplicarán penalizaciones de recorte a ambos, dependiendo de la gravedad del incidente. + Pezkuwi Wallet ayuda a los nominadores seleccionando validadores. La aplicación móvil obtiene datos de la blockchain y compone una lista de validadores, que tienen: mayores ganancias, identidad con información de contacto, no han sido recortados y están disponibles para recibir nominaciones. Pezkuwi Wallet también se preocupa por la descentralización, por lo que si una persona o una empresa opera varios nodos validadores, solo se mostrarán hasta 2 nodos de ellos en la lista recomendada. + ¿Quién es un nominador? + Las recompensas por staking están disponibles para ser pagadas al final de cada era (6 horas en Kusama y 24 horas en Polkadot). La red almacena las recompensas pendientes durante 84 eras y, en la mayoría de los casos, los validadores pagan las recompensas para todos. Sin embargo, los validadores podrían olvidar o podría sucederles algo, por lo tanto, los nominadores pueden pagar sus recompensas por sí mismos. + Aunque las recompensas suelen ser distribuidas por los validadores, Pezkuwi Wallet ayuda alertando si hay alguna recompensa sin pagar que esté cerca de expirar. Recibirás alertas sobre esto y otras actividades en la pantalla de staking. + Recibiendo recompensas + El staking es una opción para obtener ingresos pasivos al bloquear tus tokens en la red. Las recompensas de staking se asignan cada era (6 horas en Kusama y 24 horas en Polkadot). Puedes hacer staking tanto tiempo como desees, y para deshacer el staking de tus tokens necesitas esperar a que termine el periodo de desbloqueo, haciendo tus tokens disponibles para ser redimidos. + El staking es una parte importante de la seguridad y fiabilidad de la red. Cualquier persona puede ejecutar nodos validadores, pero solo aquellos que tengan suficientes tokens apostados serán elegidos por la red para participar en la composición de nuevos bloques y recibir las recompensas. Los validadores a menudo no tienen suficientes tokens por sí mismos, por lo que los nominadores los ayudan bloqueando sus tokens para ellos para alcanzar la cantidad de apuesta requerida. + ¿Qué es el staking? + El validador ejecuta un nodo de blockchain 24/7 y se requiere que tenga suficiente stake bloqueado (tanto propio como proporcionado por los nominadores) para ser elegido por la red. Los validadores deben mantener el rendimiento y la fiabilidad de sus nodos para ser recompensados. Ser un validador es casi un trabajo de tiempo completo, hay empresas que se centran en ser validadores en las redes blockchain. + Cualquiera puede ser un validador y ejecutar un nodo de blockchain, pero eso requiere un cierto nivel de habilidades técnicas y responsabilidad. Las redes de Polkadot y Kusama tienen un programa llamado Programa de Mil Validadores para proporcionar apoyo a los principiantes. Además, la red misma siempre recompensará más a los validadores que tienen menos participación (pero suficiente para ser elegidos) para mejorar la descentralización. + ¿Quién es un validador? + Cambia tu cuenta a stash para configurar el controlador. + Staking + %s staking + Recompensado + Total apostado + Límite de impulso + Para mi collator + sin Yield Boost + con Yield Boost + para apostar automáticamente %s todos mis tokens transferibles por encima de + para apostar automáticamente %s (antes: %s) todos mis tokens transferibles por encima de + Quiero apostar + Yield Boost + Tipo de Staking + Estás desapostando todos tus tokens y no puedes apostar más. + Incapaz de apostar más + Al desvincular parcialmente, deberías dejar al menos %s en la apuesta. ¿Quieres realizar la desvinculación completa desapostando el %s restante también? + Cantidad demasiado pequeña permanece en la apuesta + La cantidad que deseas desapostar es mayor que el saldo apostado. + Desvincular + Las transacciones de desvinculación aparecerán aquí + Las transacciones de desvinculación se mostrarán aquí + Desvinculando: %s + Tus tokens estarán disponibles para canjear después del período de desvinculación. + Has alcanzado el límite de solicitudes de desvinculación (%d solicitudes activas). + Límite de solicitudes de desvinculación alcanzado + Período de desvinculación + Desapostar todo + ¿Desapostar todo? + Recompensa estimada (%% APY) + Recompensa estimada + Información del validador + Sobre suscrito. No recibirás recompensas del validador en esta era. + Nominadores + Sobre suscrito. Solo los nominadores con mayor apuesta son recompensados. + Propio + No hay resultados de búsqueda.\nAsegúrate de haber escrito la dirección completa de la cuenta + El validador es sancionado por comportamientos indebidos (por ejemplo, estar desconectado, atacar la red o usar software modificado) en la red. + Apuesta total + Apuesta total (%s) + La recompensa es menor que la comisión de la red. + Anual + Tu apuesta está asignada a los siguientes validadores. + Tu apuesta está asignada a los próximos validadores + Elegidos (%s) + Validadores que no fueron elegidos en esta era. + Validadores sin suficiente apuesta para ser elegidos + Otros, que están activos sin tu asignación de apuesta. + Validadores activos sin tu asignación de apuesta + No elegidos (%s) + Tus tokens están asignados a los validadores sobresuscritos. No recibirás recompensas en esta era. + Recompensas + Tu apuesta + Tus validadores + Tus validadores cambiarán en la próxima era. + Ahora vamos a hacer una copia de seguridad de tu monedero. Esto asegura que tus fondos están seguros y protegidos. Las copias de seguridad te permiten restaurar tu monedero en cualquier momento. + Continuar con Google + Ingrese el nombre del monedero + Mi nuevo monedero + Continuar con la copia de seguridad manual + Da un nombre a tu monedero + Esto solo será visible para ti y podrás editarlo más tarde. + Tu monedero está listo + Bluetooth + USB + Has bloqueado tokens en tu saldo debido a %s. Para continuar debes ingresar menos de %s o más de %s. Para apostar otra cantidad debes quitar tus bloqueos de %s. + No puedes apostar la cantidad especificada + Seleccionados: %d (máx %d) + Saldo disponible: %1$s (%2$s) + %s con tus tokens en apuesta + Participa en la gobernanza + Apuesta más de %1$s y %2$s con tus tokens en apuesta + participa en la gobernanza + Apuesta en cualquier momento con tan solo %1$s. Tu apuesta generará activamente recompensas %2$s + en %s + Apuesta en cualquier momento. Tu apuesta generará activamente recompensas %s + Encuentra más información sobre\n%1$s staking en %2$s + Pezkuwi Wiki + Las recompensas se acumulan %1$s. Apuesta más de %2$s para una distribución automática de recompensas, de lo contrario necesitas reclamar recompensas manualmente + cada %s + Las recompensas se acumulan %s + Las recompensas se acumulan %s. Necesitas reclamar recompensas manualmente + Las recompensas se acumulan %s y se añaden al saldo transferible + Las recompensas se acumulan %s y se añaden de nuevo a la apuesta + Las recompensas y el estado de la apuesta varían con el tiempo. %s de vez en cuando + Supervisa tu apuesta + Comenzar Apuesta + Ver %s + Términos de Uso + %1$s es una %2$s con %3$s + sin valor de token + red de prueba + %1$s\nen tus tokens %2$s\npor año + Gana hasta %s + Desanclar en cualquier momento y canjear tus fondos %s. No se ganan recompensas mientras se está desancando + después de %s + El pool que has seleccionado está inactivo debido a que no se han seleccionado validadores o su participación es inferior al mínimo.\n¿Estás seguro de que deseas continuar con el Pool seleccionado? + Se ha alcanzado el número máximo de nominadores. Intente de nuevo más tarde + %s actualmente no está disponible + Validadores: %d (máx %d) + Modificado + Nuevo + Eliminado + Token para pagar la tasa de red + La tasa de red se agrega al monto ingresado + La simulación del paso de intercambio falló + Este par no es compatible + Fallido en la operación #%s (%s) + Intercambio %s a %s en %s + Transferencia de %s de %s a %s + + %s operación + %s operaciones + + %s de %s operaciones + Intercambiando %s a %s en %s + Transfiriendo %s a %s + Tiempo de ejecución + Deberías mantener al menos %s después de pagar %s de tasa de red ya que estás sosteniendo tokens insuficientes + Debes mantener al menos %s para recibir el token %s + Debes tener al menos %s en %s para recibir %s token + Puedes intercambiar hasta %1$s ya que necesitas pagar %2$s por la tasa de red. + Puedes intercambiar hasta %1$s ya que necesitas pagar %2$s de tasa de red y también convertir %3$s a %4$s para cumplir con el saldo mínimo de %5$s. + Intercambiar máximo + Intercambiar mínimo + Deberías dejar al menos %1$s en tu saldo. ¿Quieres realizar el intercambio completo agregando también el %2$s restante? + El monto restante en tu balance es demasiado pequeño + Deberías mantener al menos %1$s después de pagar %2$s de tasa de red y convertir %3$s a %4$s para cumplir con el saldo mínimo de %5$s.\n\n¿Quieres realizar el intercambio completo agregando también el %6$s restante? + Pagar + Recibir + Selecciona un token + No hay suficientes tokens para intercambiar + No hay suficiente liquidez + No puedes recibir menos de %s + Compra instantánea de %s con tarjeta de crédito + Transferir %s desde otra red + Recibir %s con QR o tu dirección + Obtener %s usando + Durante la ejecución del intercambio, la cantidad intermedia recibida es %s, lo cual es menos que el saldo mínimo de %s. Intenta especificar una cantidad de intercambio mayor. + El deslizamiento debe estar especificado entre %s y %s + Deslizamiento inválido + Selecciona un token para pagar + Selecciona un token para recibir + Ingresar monto + Ingresa otro monto + Para pagar la comisión de red con %s, Pezkuwi intercambiará automáticamente %s por %s para mantener el balance mínimo de %s en tu cuenta. + Una comisión de red cobrada por la cadena de bloques para procesar y validar cualquier transacción. Puede variar dependiendo de las condiciones de la red o la velocidad de la transacción. + Seleccionar red para intercambiar %s + El fondo no tiene suficiente liquidez para intercambiar + La diferencia de precio se refiere a la diferencia en el precio entre dos activos diferentes. Al realizar un intercambio en cripto, la diferencia de precio es generalmente la diferencia entre el precio del activo por el que estás intercambiando y el precio del activo con el que estás intercambiando. + Diferencia de precio + %s ≈ %s + Tasa de cambio entre dos criptomonedas diferentes. Representa cuánto de una criptomoneda puedes obtener a cambio de una cierta cantidad de otra criptomoneda. + Tasa + Tasa antigua: %1$s ≈ %2$s.\nNueva tasa: %1$s ≈ %3$s + La tasa de intercambio fue actualizada + Repetir la operación + Ruta + La manera en que tu token pasará a través de diferentes redes para obtener el token deseado. + Intercambio + Transferencia + Configuraciones de intercambio + Deslizamiento + El deslizamiento de intercambio es una ocurrencia común en el comercio descentralizado donde el precio final de una transacción de intercambio podría diferir ligeramente del precio esperado, debido a las cambiantes condiciones del mercado. + Ingrese otro valor + Ingrese un valor entre %s y %s + Deslizamiento + La transacción podría ser adelantada debido al alto deslizamiento. + La transacción podría ser revertida debido a la baja tolerancia al deslizamiento. + La cantidad de %s es menor que el balance mínimo de %s + Estás intentando intercambiar una cantidad demasiado pequeña + Abstenerse: %s + A favor: %s + Siempre puedes votar por este referendo más tarde + ¿Eliminar el referendo %s de la lista de votos? + Algunos de los referendos ya no están disponibles para votar o puede que no tengas suficientes tokens disponibles para votar. Disponible para votar: %s. + Algunos de los referendos fueron excluidos de la lista de votos + No se pudieron cargar los datos del referendo + No se recuperaron datos + Ya has votado por todos los referendos disponibles o no hay referendos para votar en este momento. Vuelve más tarde. + Ya has votado por todos los referendos disponibles + Solicitado: + Lista de votos + %d restantes + Confirmar votos + No hay referendos para votar + Confirma tus votos + Sin votos + Has votado exitosamente por %d referendos + No tienes suficiente saldo para votar con el poder de voto actual %s (%sx). Por favor, cambia el poder de voto o añade más fondos a tu cartera. + Saldo insuficiente para votar + En contra: %s + SwipeGov + Votar por %d referendos + La votación se establecerá para futuros votos en SwipeGov + Poder de voto + Staking + Cartera + Hoy + Enlace de Coingecko para información de precio (Opcional) + Seleccione un proveedor para comprar el token %s + Los métodos de pago, tarifas y límites difieren según el proveedor.\nCompare sus cotizaciones para encontrar la mejor opción para usted. + Seleccione un proveedor para vender el token %s + Para continuar la compra será redirigido desde la aplicación Pezkuwi Wallet a %s + ¿Continuar en el navegador? + Ninguno de nuestros proveedores actualmente soporta la compra o venta de este token. Por favor elija un token diferente, una red diferente, o vuelva a verificar más tarde. + Este token no es compatible con la función de compra/venta + Copiar hash + Comisión + Desde + Hash Extrinsic + Detalles de la transacción + Ver en %s + Ver en Polkascan + Ver en Subscan + %s a las %s + Tu historial de transacciones anterior de %s todavía está disponible en %s + Completado + Fallido + Pendiente + Canal de Notificaciones de Transacciones + Compra de criptomonedas a partir de solo $5 + Venta de criptomonedas a partir de solo $10 + De: %s + Para: %s + Transferencia + Las transferencias entrantes y salientes aparecerán aquí + Tus operaciones se mostrarán aquí + Eliminar votos para delegar en estas pistas + Pistas a las que ya has delegado votos + Pistas no disponibles + Pistas en las que tienes votos existentes + No mostrar esto nuevamente.\nPuedes encontrar la dirección legacy en Recibir. + Formato legacy + Nuevo formato + Algunos intercambios aún pueden requerir el formato legacy\npara operaciones mientras actualizan. + Nueva Dirección Unificada + Instalar + Versión %s + Actualización disponible + Para evitar problemas y mejorar tu experiencia de usuario, te recomendamos encarecidamente que instales las actualizaciones recientes lo antes posible + Actualización crítica + Último + ¡Muchas nuevas características increíbles están disponibles para Pezkuwi Wallet! Asegúrate de actualizar tu aplicación para acceder a ellas + Actualización importante + Crítica + Importante + Ver todas las actualizaciones disponibles + Nombre + Nombre de la cartera + Este nombre se mostrará solo para ti y se almacenará localmente en tu dispositivo móvil. + Esta cuenta no ha sido elegida por la red para participar en la era actual + Volver a votar + Votar + Estado de la votación + ¡Tu tarjeta está siendo financiada! + ¡Tu tarjeta está siendo emitida! + Puede tardar hasta 5 minutos.\nEsta ventana se cerrará automáticamente. + Estimado %s + Comprar + Comp./Vend. + Comprar tokens + Comprar con + Recibir + Recibir %s + Enviar solo el token %1$s y tokens en la red %2$s a esta dirección, o podrías perder tus fondos + Vender + Vender tokens + Enviar + Cambiar + Activos + Tus activos aparecerán aquí.\nAsegúrate de que el filtro \"Ocultar saldos en cero\"\nesté desactivado + Valor de los activos + Disponible + Delegado + Detalles del saldo + Saldo total + Total después de la transferencia + Congelado + Bloqueado + Redimible + Reservado + Transferible + Desvinculando + Cartera + Nueva conexión + + %s cuenta no encontrada. Agrega la cuenta a la cartera en Configuraciones + %s cuentas no encontradas. Agrega las cuentas a la cartera en Configuraciones + + Algunas de las redes requeridas solicitadas por \"%s\" no son compatibles en Pezkuwi Wallet + Las sesiones de Wallet Connect aparecerán aquí + WalletConnect + dApp desconocida + + %s red no soportada está oculta + %s redes no soportadas están ocultas + + WalletConnect v2 + Transferencia entre cadenas + Criptomonedas + Monedas fiduciarias + Monedas fiduciarias populares + Moneda + Detalles de la extrínseca + Ocultar activos con saldos en cero + Otras transacciones + Mostrar + Recompensas y Penalizaciones + Intercambios + Filtros + Transferencias + Gestionar activos + ¿Cómo añadir un monedero? + ¿Cómo añadir un monedero? + ¿Cómo añadir un monedero? + Ejemplos de nombre: Cuenta principal, Mi validador, Crowdloans de Dotsama, etc. + Comparte este QR con el emisor + Permite que el emisor escanee este código QR + Mi dirección %s para recibir %s: + Compartir código QR + Destinatario + Asegúrate de que la dirección sea\nde la red correcta + El formato de la dirección es inválido.\nAsegúrate de que la dirección\npertenezca a la red correcta + Saldo mínimo + Debido a las restricciones entre cadenas, puedes transferir no más de %s + No tienes suficiente saldo para pagar la tarifa de cadena cruzada de %s.\nSaldo restante después de la transferencia: %s + La tarifa de cadena cruzada se agrega al monto ingresado. El destinatario puede recibir parte de la tarifa de cadena cruzada + Confirmar transferencia + Cadena cruzada + Tarifa de cadena cruzada + Tu transferencia fallará ya que la cuenta de destino no tiene suficiente %s para aceptar transferencias de otros tokens + El destinatario no puede aceptar la transferencia + Tu transferencia fallará ya que el monto final en la cuenta de destino será menor que el saldo mínimo. Por favor, intenta aumentar el monto. + Tu transferencia eliminará la cuenta del almacenamiento de bloques ya que hará que el saldo total sea menor que el saldo mínimo. + Tu cuenta será eliminada de la blockchain después de la transferencia ya que hace que el saldo total sea menor que el mínimo + La transferencia eliminará la cuenta + Tu cuenta será eliminada de la blockchain después de la transferencia ya que hace que el saldo total sea menor que el mínimo. El saldo restante también será transferido al destinatario. + Desde la red + Necesitas tener al menos %s para pagar esta tarifa de transacción y permanecer por encima del saldo mínimo de la red. Tu saldo actual es: %s. Necesitas agregar %s a tu saldo para realizar esta operación. + A mí mismo + En cadena + La siguiente dirección: %s es conocida por ser utilizada en actividades de phishing, por lo tanto, no recomendamos enviar tokens a esa dirección. ¿Te gustaría proceder de todos modos? + Alerta de estafa + El destinatario ha sido bloqueado por el propietario del token y actualmente no puede aceptar transferencias entrantes + El destinatario no puede aceptar la transferencia + Red del destinatario + A la red + Enviar %s desde + Enviar %s en + a + Remitente + Tokens + Enviar a este contacto + Detalles de la transferencia + %s (%s) + Direcciones %s para %s + Pezkuwi detectó problemas con la integridad de la información sobre direcciones %1$s. Por favor, contacta al propietario de %1$s para resolver los problemas de integridad. + La verificación de integridad falló + Destinatario inválido + No se encontró una dirección válida para %s en la red %s + %s no encontrado + Los servicios w3n %1$s no están disponibles. Inténtalo de nuevo más tarde o introduce la dirección %1$s manualmente + Error al resolver w3n + Pezkuwi no puede resolver el código para el token %s + El token %s aún no es compatible + Ayer + Yield Boost se desactivará para los collators actuales. Nuevo collator: %s + ¿Cambiar el Collator con Yield Boost? + No tienes saldo suficiente para pagar la comisión de red de %s y la comisión de ejecución de Yield Boost de %s.\nSaldo disponible para pagar la comisión: %s + No hay suficientes tokens para pagar la primera comisión de ejecución + No tienes suficiente saldo para pagar la comisión de red de %s y no caer por debajo del umbral %s.\nSaldo disponible para pagar la comisión: %s + No hay suficientes tokens para permanecer por encima del umbral + Tiempo de aumento de stake + Yield Boost apostará automáticamente %s todos mis tokens transferibles por encima de %s + Con Yield Boost + + + Puente DOT ↔ HEZ + Puente DOT ↔ HEZ + Envías + Recibes (estimado) + Tipo de cambio + Comisión del puente + Mínimo + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Intercambiar + Los intercambios HEZ→DOT se procesan cuando hay suficiente liquidez de DOT. + Saldo insuficiente + Cantidad por debajo del mínimo + Ingresa la cantidad + Los intercambios HEZ→DOT pueden tener disponibilidad limitada según la liquidez. + Los intercambios HEZ→DOT no están disponibles temporalmente. Inténtalo de nuevo cuando haya suficiente liquidez de DOT. + diff --git a/common/src/main/res/values-fr-rFR/strings.xml b/common/src/main/res/values-fr-rFR/strings.xml new file mode 100644 index 0000000..064d306 --- /dev/null +++ b/common/src/main/res/values-fr-rFR/strings.xml @@ -0,0 +1,2047 @@ + + + Contactez-nous + Github + Politique de confidentialité + Évaluez-nous + Telegram + Conditions générales + Conditions générales d\'utilisation + À propos + Version de l\'application + Site web + Entrez une adresse %s valide... + L\'adresse EVM doit être valide ou vide... + Entrez une adresse Substrate valide... + Ajouter un compte + Ajouter une adresse + Le compte existe déjà. Veuillez en essayer un autre. + Observer un portefeuille via son adresse + Observer un portefeuille + Notez la phrase et conservez-la en lieu sûr + Veillez à écrire votre phrase correctement et lisiblement. + Adresse %s + Aucun compte sur %s + Confirmer la phrase de récupération + Vérifiez à nouveau + Choisissez les mots dans le bon ordre + Créer un nouveau compte + N\'utilisez pas le presse-papiers ou les captures d\'écran sur votre appareil mobile, essayez de trouver des méthodes de sauvegarde sûres (par exemple, sur papier) + Le nom ne sera utilisé que localement dans cette application. Vous pourrez le modifier ultérieurement + Créer un nom de portefeuille + Sauvegarder la mnémonique + Créer un nouveau portefeuille + La mnémonique est utilisée pour récupérer l\'accès au compte. Notez-le, nous ne pourrons pas récupérer votre compte sans lui ! + Comptes dont le secret a été modifié + Oublier + Assurez-vous d\'avoir exporté votre portefeuille avant de poursuivre. + Oublier le portefeuille? + Chemin de dérivation Ethereum non valide + Chemin de dérivation Substrate non valide + Ce portefeuille est associé à %1$s. Pezkuwi vous aidera à former toutes les opérations que vous souhaitez, et il vous sera demandé de les signer à l\'aide de %1$s + Non pris en charge par %s + Il s\'agit d\'un portefeuille seulement observé, Pezkuwi peut vous montrer les soldes et d\'autres informations, mais vous ne pouvez pas effectuer de transactions avec ce portefeuille. + Entrez le surnom du portefeuille... + Veuillez en essayez un autre. + Type de cryptographie de la paire de clés Ethereum + Chemin de dérivation du secret Ethereum + Comptes EVM + Exporter le compte + Exporter + Importer existant + Cela est nécessaire pour chiffrer les données et enregistrer le fichier JSON. + Définir un nouveau mot de passe + Sauvegardez votre secret et mettez-le en lieu sûr + Notez votre secret et conservez-le en lieu sûr + Fichier JSON de restauration invalide. Veuillez vous assurer que l\'entrée contient un JSON valide. + La clé n\'est pas valide. Veuillez vous assurer que votre entrée contient 64 symboles hex. + Le JSON ne contient pas d\'informations sur le réseau. Veuillez les spécifier ci-dessous. + Fournissez votre JSON de restauration + Phrase généralement de 12 mots (mais peut être 15, 18, 21 ou 24) + Écrivez les mots séparément avec un espace, sans virgule ni autres signes + Entrez les mots dans le bon ordre + Mot de passe + Importer une clé privée + 0xAB + Entrez votre clé de récupération + Pezkuwi est compatible avec toutes les applications + Importer un portefeuille + Compte + Votre chemin de dérivation contient des symboles non pris en charge ou a une structure incorrecte. + Chemin de dérivation non valide + Fichier JSON + Assurez-vous que %s sur votre appareil Ledger en utilisant l\'application Ledger Live + L\'application Polkadot est installée + %s sur votre appareil Ledger + Ouvrez l\'application Polkadot + Flex, Stax, Nano X, Nano S Plus + Ledger (Application Polkadot Générique) + Assurez-vous que l\'application réseau est installée sur votre Ledger à l\'aide de l\'application Ledger Live. Puis ouvrez l\'application réseau sur votre Ledger. + Ajoutez au moins un compte + Ajouter des comptes à votre portefeuille + Guide de connexion Ledger + Assurez-vous que %s sur votre appareil Ledger à l\'aide de l\'application Ledger Live + l\'application Network est installée + %s sur votre appareil Ledger + Ouvrez l\'application réseau + Autorisez Pezkuwi Wallet à %s + accès Bluetooth + %s à ajouter au portefeuille + Sélectionnez un compte + %s dans les paramètres de votre téléphone + Activer OTG + Connecter Ledger + Pour signer des opérations et migrer vos comptes vers la nouvelle application Generic Ledger, installez et ouvrez l\'application Migration. Les applications Ledger Legacy Old et Migration ne seront plus supportées à l\'avenir. + Une nouvelle application Ledger a été publiée + Polkadot Migration + L\'application Migration sera bientôt indisponible. Utilisez-la pour migrer vos comptes vers la nouvelle application Ledger afin d\'éviter de perdre vos fonds. + Polkadot + Ledger Nano X + Ledger Legacy + Si vous utilisez un Ledger via Bluetooth, activez le Bluetooth sur les deux appareils et accordez à Pezkuwi les permissions Bluetooth et de localisation. Pour l\'USB, activez OTG dans les paramètres de votre téléphone. + Veuillez activer le Bluetooth dans les paramètres de votre téléphone et de votre appareil Ledger. Déverrouillez votre Ledger et ouvrez l\'application %s. + Sélectionnez votre appareil Ledger + Veuillez activer l\'appareil Ledger. Déverrouillez votre appareil Ledger et ouvrez l\'application %s. + Vous y êtes presque ! 🎉\n Appuyez simplement ci-dessous pour terminer la configuration et commencer à utiliser vos comptes sans effort à la fois dans l\'application Polkadot et le Pezkuwi Wallet + Bienvenue sur Pezkuwi ! + Phrase de 12, 15, 18, 21 ou 24 mots + Multisig + Contrôle partagé (multisig) + Vous n\'avez pas de compte pour ce réseau, vous pouvez créer ou importer un compte. + Compte nécessaire + Aucun compte trouvé + Associer une clé publique + Parity Signer + %s ne prend pas en charge %s + Les comptes suivants ont été lus avec succès à partir de %s + Voici vos comptes + Code QR invalide, vérifiez que vous êtes bien en train de scanner le code QR de %s + Assurez-vous de sélectionner le premier + %s sur votre smartphone + Ouvrez Parity Signer + %s que vous souhaitez ajouter au portefeuille Pezkuwi + Allez dans l’onglet «Clés». Sélectionnez la clé, puis le compte + Parity Signer vous fournira un %s + code QR à scanner + Ajouter un portefeuille à partir de %s + %s ne prend pas en charge la signature de messages arbitraires, uniquement les transactions + La signature n’est pas prise en charge + Scannez le code QR à partir de %s + Héritage + Nouveau (Vault v7+) + J’ai une erreur dans %s + Le code QR a expiré + Pour des raisons de sécurité, les opérations générées ne sont valables que pour %s. Veuillez générer un nouveau code QR et le signer avec %s. + Le code QR est valable pour %s + Veuillez vous assurer que vous scannez le code QR de l\'opération de signature en cours + Signer avec %s + Polkadot Vault + Faites attention, le nom du chemin de dérivation doit être vide + %s sur votre smartphone + Ouvrir Polkadot Vault + %s que vous souhaitez ajouter à Pezkuwi Wallet + Appuyez sur Derived Key + Polkadot Vault vous fournira un %s + code QR à scanner + Appuyez sur l\'icône dans le coin supérieur droit et sélectionnez %s + Exporter la clé privée + Clé privée + Délégué + Délégué à vous (Proxied) + N\'importe quel + Enchère + Annuler le proxy + Gouvernance + Jugement d\'identité + Pools de nomination + Non transférable + Staking + J\'ai déjà un compte + secrets + 64 symboles hex + Sélectionnez le portefeuille physique + Sélectionnez votre type de secret + Sélectionner un portefeuille + Comptes à secret partagé + Comptes Substrate + Type de cryptographie de la paire de clés Substrate + Chemin de dérivation du secret Substrate + Nom du portefeuille + Surnom du portefeuille + Moonbeam, Moonriver et autres réseaux + Adresse EVM (facultatif) + Portefeuilles prédéfinis + Polkadot, Kusama, Karura, KILT et plus de 50 réseaux + Suivez l’activité depuis Pezkuwi de n’importe quel portefeuille sans injecter de clé privée + Votre portefeuille est seulement observé, ce qui signifie que vous ne pouvez effectuer aucune opération + Oups ! La clé est manquante + Observer seulement + Utiliser Polkadot Vault, Ledger ou Parity Signer + Connecter le portefeuille physique + Utilisez vos comptes Trust Wallet dans Pezkuwi + Trust Wallet + Ajouter un compte %s + Ajouter un portefeuille + Modifier le compte %s + Changer de compte + Ledger (Legacy) + Délégué à vous + Contrôle partagé + Ajouter un nœud personnalisé + Vous devez ajouter le compte %s au portefeuille afin de déléguer + Entrez les détails du réseau + Déléguer à + Compte déléguant + Portefeuille déléguant + Type d\'accès accordé + Le dépôt reste réservé sur votre compte jusqu\'à ce que le proxy soit supprimé. + Vous avez atteint la limite de %s proxies ajoutés dans %s. Supprimez des proxies pour en ajouter de nouveaux. + Le nombre maximum de proxies a été atteint + Les réseaux personnalisés ajoutés\napparaîtront ici + +%d + Pezkuwi a automatiquement basculé vers votre portefeuille multisig pour que vous puissiez voir les transactions en attente. + Coloré + Apparence + icônes des tokens + Blanc + L\'adresse du contrat saisie est présente dans Pezkuwi sous la forme d\'un jeton %s. + L\'adresse du contrat saisie est présente dans Pezkuwi comme un token %s. Êtes-vous sûr de vouloir le modifier ? + Ce jeton existe déjà + Veuillez vous assurer que l\'URL fournie contient le lien suivant: www.coingecko.com/fr/coins/tether. + Lien CoinGecko invalide + L’adresse du contrat saisie n’est pas un contrat ERC-20 %s. + Adresse du contrat invalide + Les décimales doivent être au moins égales à 0 et ne pas dépasser 36. + Valeur décimale non valide + Entrez l\'adresse du contrat + Entrez les décimales + Entrez le symbole + Aller à %s + À partir de %1$s, votre solde de %2$s, Staking et Gouvernance seront sur %3$s — avec des performances améliorées et des coûts réduits. + Vos tokens %s maintenant sur %s + Réseaux + Tokens + Ajouter un jeton + Adresse du contrat + Décimales + Symbole + Entrez les détails du jeton ERC-20 + Sélectionnez le réseau pour ajouter un jeton ERC-20 + Prêts participatifs + Gouvernance v1 + OpenGov + Élections + Staking + Acquisition + Acheter des tokens + Vous avez récupéré vos DOT des crowdloans ? Commencez à staker vos DOT aujourd\'hui pour obtenir les récompenses maximales ! + Boostez vos DOT 🚀 + Filtrer les actifs + Vous n’avez pas de tokens à offrir.\nAchetez ou déposez des tokens sur votre compte. + Tous les réseaux + Gérer les actifs + Ne pas transférer %s sur le compte contrôlé par la Ledger car Ledger ne prend pas en charge l\'envoi de %s, les actifs seront bloqués sur ce compte + Ledger ne prend pas en charge cet actif + Recherche par réseau ou actif + Aucun réseau ou jeton avec\nnom saisi n\'a été trouvé + Rechercher par token + Vos portefeuilles + Vous n\'avez pas de tokens à envoyer.\nAchetez ou recevez des tokens sur votre\ncompte. + Token à payer + Token à recevoir + Avant de procéder aux modifications, %s pour les portefeuilles modifiés et supprimés ! + assurez-vous d\'avoir sauvegardé les phrases secrètes + Appliquer les mises à jour de la sauvegarde ? + Préparez-vous à sauvegarder votre portefeuille ! + Cette phrase secrète vous donne un accès total et permanent à tous les portefeuilles connectés et aux fonds qu\'ils contiennent.\n%s + NE LA PARTAGEZ PAS. + N\'entrez pas votre phrase secrète dans un formulaire ou un site web.\n%s + LES FONDS PEUVENT ÊTRE PERDUS À JAMAIS. + Le support ou les administrateurs ne demanderont jamais votre phrase secrète sous aucune circonstance.\n%s + MÉFIEZ-VOUS DES IMPOSTEURS. + Examiner et accepter pour continuer + Supprimer la sauvegarde + Vous pouvez sauvegarder manuellement votre phrase secrète pour garantir l\'accès aux fonds de votre portefeuille si vous perdez l\'accès à cet appareil + Sauvegarder manuellement + Manuel + Vous n\'avez ajouté aucun portefeuille avec une phrase secrète. + Aucun portefeuille à sauvegarder + Vous pouvez activer les sauvegardes Google Drive pour stocker des copies chiffrées de tous vos portefeuilles, sécurisées par un mot de passe que vous définissez. + Sauvegarder sur Google Drive + Google Drive + Sauvegarde + Sauvegarde + Processus KYC simple et efficace + Auth biométrique + Fermer tout + L\'achat est lancé ! Veuillez patienter jusqu\'à 60 minutes. Vous pouvez suivre l\'état d\'avancement sur l\'e-mail. + Sélectionner le réseau pour acheter %s + Achat initié ! Veuillez patienter jusqu\'à 60 minutes. Vous pouvez suivre le statut par email. + Aucun de nos fournisseurs ne supporte actuellement l\'achat de ce jeton. Veuillez choisir un jeton différent, un réseau différent, ou revenir plus tard. + Ce jeton n\'est pas supporté par la fonction d\'achat + Possibilité de payer les frais avec n\'importe quel token + La migration se fait automatiquement, aucune action nécessaire + L’historique des anciennes transactions reste sur %s + À partir de %1$s votre %2$s solde, le Staking et la Gouvernance sont sur %3$s. Ces fonctionnalités peuvent être indisponibles jusqu’à 24 heures. + %1$sx frais de transaction plus bas\n(de %2$s à %3$s) + %1$sx réduction du solde minimal\n(de %2$s à %3$s) + Qu\'est-ce qui rend Asset Hub extraordinaire ? + À partir de %1$s votre solde %2$s, le Staking et la Gouvernance sont sur %3$s + Plus de tokens supportés : %s, et autres tokens de l\'écosystème + Accès unifié à %s, actifs, staking et gouvernance + Équilibrage automatique des nœuds + Activer la connexion + Veuillez entrer un mot de passe qui sera utilisé pour récupérer vos portefeuilles à partir de la sauvegarde cloud. Ce mot de passe ne pourra pas être récupéré à l\'avenir, alors assurez-vous de vous en souvenir ! + Mettre à jour le mot de passe de sauvegarde + Actif + Solde disponible + Désolé, la demande de vérification du solde a échoué. Veuillez réessayer plus tard. + Désolé, nous n’avons pas pu contacter le fournisseur de transfert. Veuillez réessayer plus tard. + Désolé, vous n\'avez pas assez de fonds pour dépenser le montant spécifié + Frais de transfert + Le réseau ne répond pas + À + Oups, le cadeau a déjà été réclamé + Le cadeau ne peut pas être reçu + Réclamer le cadeau + Il peut y avoir un problème avec le serveur. Veuillez réessayer plus tard. + Oups, quelque chose a mal tourné + Utilisez un autre portefeuille, créez-en un nouveau ou ajoutez un compte %s à ce portefeuille dans les Paramètres. + Vous avez bien réclamé un cadeau. Les tokens apparaîtront dans votre solde sous peu. + Vous avez un cadeau crypto ! + Créez un nouveau portefeuille ou importez-en un existant pour réclamer le cadeau + Vous ne pouvez pas recevoir de cadeau avec un portefeuille %s + Toutes les onglets ouverts dans le navigateur DApp seront fermés. + Fermer toutes les DApps ? + %s et n\'oubliez pas de toujours les conserver hors ligne pour les restaurer à tout moment. Vous pouvez le faire dans les Réglages de sauvegarde. + Veuillez noter toutes les phrases de passe de votre portefeuille avant de continuer + La sauvegarde sera supprimée de Google Drive + La sauvegarde actuelle de vos portefeuilles sera supprimée définitivement ! + Êtes-vous sûr de vouloir supprimer la Cloud Backup ? + Supprimer la sauvegarde + Pour le moment, votre sauvegarde n\'est pas synchronisée. Veuillez examiner ces mises à jour. + Modifications de la Cloud Backup trouvées + Examiner les mises à jour + Si vous n\'avez pas manuellement noté votre phrase de passe pour les portefeuilles qui seront supprimés, ces portefeuilles et tous leurs actifs seront perdus à jamais et de manière irréversible. + Êtes-vous sûr de vouloir appliquer ces modifications ? + Examiner le problème + Pour le moment, votre sauvegarde n\'est pas synchronisée. Veuillez examiner le problème. + Échec de la mise à jour des modifications des portefeuilles dans la Cloud Backup + Veuillez vous assurer que vous êtes connecté à votre compte Google avec les bonnes informations d\'identification et que vous avez accordé à Pezkuwi Wallet l\'accès à Google Drive + Échec de l\'authentification sur Google Drive + Vous n\'avez pas suffisamment d\'espace disponible sur Google Drive. + Espace insuffisant + Malheureusement, Google Drive ne fonctionne pas sans les services Google Play, qui manquent sur votre appareil. Essayez d\'obtenir les services Google Play + Services Google Play non trouvés + Impossible de sauvegarder vos portefeuilles sur Google Drive. Veuillez vous assurer que vous avez autorisé Pezkuwi Wallet à utiliser votre Google Drive et que vous avez suffisamment d\'espace de stockage disponible, puis réessayez. + Erreur Google Drive + Veuillez vérifier la validité du mot de passe et réessayer. + Mot de passe invalide + Malheureusement, nous n\'avons pas trouvé de sauvegarde pour restaurer les portefeuilles + Aucune sauvegarde trouvée + Assurez-vous d\'avoir sauvegardé la phrase secrète pour le portefeuille avant de continuer. + Le portefeuille sera supprimé dans la sauvegarde cloud + Vérifier l\'erreur de sauvegarde + Vérifier les mises à jour de la sauvegarde + Entrer le mot de passe de sauvegarde + Activez pour sauvegarder les portefeuilles sur votre Google Drive + Dernière synchronisation : %s à %s + Se connecter à Google Drive + Vérifier le problème Google Drive + Sauvegarde désactivée + Sauvegarde synchronisée + Synchronisation de la sauvegarde... + Sauvegarde non synchronisée + Les nouveaux portefeuilles sont automatiquement ajoutés à la sauvegarde Cloud. Vous pouvez désactiver la sauvegarde Cloud dans les paramètres. + Les modifications apportées au portefeuille seront mises à jour dans la sauvegarde Cloud + Accepter les conditions... + Compte + Adresse du compte + Active + Ajouter + Ajouter une délégation + Ajouter un réseau + Adresse + Avancé + Tous + Autoriser + Montant + Montant trop faible + Montant trop élevé + Appliqué + Appliquer + Demandez à nouveau + Attention ! + Disponible : %s + Moyenne + Solde + Bonus + Appeler + Données d\'appel + Hash d\'appel + Annuler + Voulez-vous vraiment annuler cette opération ? + Désolé, vous n\'avez pas l\'application adéquate pour traiter cette demande. + Impossible d’ouvrir ce lien + Vous pouvez utiliser jusqu\'à %s puisque vous devez payer \n%s pour les frais de réseau. + Chaîne + Changer + Changer le mot de passe + Continuer automatiquement à l\'avenir + Choisir le réseau + Effacer + Fermer + Êtes-vous sûr de vouloir fermer cet écran ?\n Vos modifications ne seront pas appliquées. + Sauvegarde Cloud + Terminé + Terminé (%s) + Confirmer + Confirmation + Êtes-vous sûr ? + Confirmé + %d ms + connexion... + Veuillez vérifier votre connexion ou réessayer plus tard + Échec de la connexion + Continuer + Copié dans le presse-papiers + Copier l\'adresse + Copier les données d\'appel + Copier le hachage + Copier l\'identifiant + Type de cryptographie de la paire de clés + Date + %s et %s + Supprimer + Déposant + Détails + Désactivé + Déconnecter + Ne fermez pas l\'application! + Terminé + Pezkuwi simule la transaction au préalable pour éviter les erreurs. Cette simulation n’a pas réussi. Réessayez plus tard ou avec un montant plus élevé. Si le problème persiste, veuillez contacter le support Pezkuwi Wallet dans les Paramètres. + Échec de la simulation de transaction + Modifier + %s (+%s supplémentaires) + Sélectionnez l’application de messagerie + Activer + Entrez l\'adresse… + Saisissez un montant... + Entrez les détails + Entrez un autre montant + Entrez le mot de passe + Erreur + Pas assez de tokens + Événement + EVM + Adresse EVM + Votre compte sera supprimé de la blockchain après cette opération car il rend le solde total inférieur au minimum + L\'opération supprimera le compte + Expiré + Explorer + Échoué + Le coût estimé du réseau %s est beaucoup plus élevé que le coût par défaut (%s). Cela pourrait être dû à une congestion temporaire du réseau. Vous pouvez actualiser pour attendre un coût réseau plus bas. + Le coût du réseau est trop élevé + Frais: %s + Trier par : + Filtres + En savoir plus + Mot de passe oublié ? + + tous les jours + tous les %s jours + + quotidiennement + tous les jours + Tous les détails + Obtenez %s + Cadeau + Compris + Gouvernance + Chaîne hexadécimale + Cacher + + %d heure + %d heures + + Comment cela fonctionne-t-il ? + Je comprends + Info + Le code QR n\'est pas valide + Code QR invalide + En savoir plus + En savoir plus + Ledger + %s restant + Gérer les portefeuilles + Maximum + %s maximum + + %d minute + %d minutes + + Le compte %s est manquant + Modifier + Module + Nom + Réseau + Ethereum + %s n\'est pas pris en charge + Polkadot + Réseaux + + Réseau + Réseaux + + Suivant + Non + L\'application d\'importation de fichiers n\'a pas été trouvée sur l\'appareil. Veuillez l\'installer et réessayer + Aucune application appropriée trouvée sur l’appareil pour gérer cette intention + Aucun changement + Votre phase mnémonique sera affichée. Assurez-vous que personne ne peut voir votre écran et ne faites pas de captures d\'écran - elles peuvent être récupérées par des logiciels malveillants tiers. + Aucune + Indisponible + Désolé, vous n’avez pas assez de fonds pour payer les frais de réseau. + Solde insuffisant + Vous n\'avez pas assez de solde pour payer les frais de réseau de %s. Le solde actuel est de %s + Pas maintenant + Désactivé + D\'ACCORD + Retour + Activé + En cours + Facultative + Sélectionner une option + Phrase secrète + Coller + / an + %s / an + par an + %% + Les autorisations demandées sont requises pour utiliser cet écran. Vous devez les activer dans Paramètres. + Autorisations refusées + Les autorisations demandées sont nécessaires pour utiliser cet écran. + Autorisations requises + Prix + Politique de confidentialité + Procéder + Dépôt par procuration + Révoquer l\'accès + Notifications push + En savoir plus + Recommandé + Actualiser le coût + Rejeter + Retirer + Requis + Réinitialiser + Réessayer + Quelque chose a mal tourné. Veuillez réessayer + Révoquer + Enregistrer + Scannez le code QR + Rechercher + Le résultat de la recherche sera affiché ici + Résultats de la recherche : %d + sec + + %d seconde + %d secondes + + Chemin de dérivation du secret + Voir tout + Sélectionner un jeton + Paramètres + Partager + Partager les données d\'appel + Partager le hash + Afficher + Se connecter + Demande de signature + Signataire + La signature n\'est pas valide + Ignorer + Ignorer la procédure + Une erreur s\'est produite lors de l\'envoi de certaines transactions. Voulez-vous réessayer ? + Certaines transactions n\'ont pas été soumises + Quelque chose a mal tourné + Trier par + Statut + Substrate + Adresse Substrate + Appuyer pour révéler + Conditions générales + Testnet + Temps restant + Titre + Ouvrir les paramètres + Votre solde est trop faible + Total + Frais totaux + ID de la transaction + Envoi de la transaction + Réessayez + Type + Veuillez réessayer avec une autre entrée. Si l\'erreur réapparaît, veuillez contacter l\'assistance. + Inconnu + + %s non pris en charge + %s non pris en charge + + Illimitée + Mise à jour + Utiliser + Utiliser la quantité max + Le bénéficiaire doit être une adresse %s valide + Bénéficiaire invalide + Vue + En attente + Portefeuille + Avertissement + Oui + Votre cadeau + Le montant doit être positif + Veuillez entrer le mot de passe créé lors du processus de sauvegarde + Entrez le mot de passe de la sauvegarde actuelle + La confirmation transférera des tokens depuis votre compte + Sélectionnez les mots... + Passphrase invalide, veuillez vérifier à nouveau l\'ordre des mots + Référendum + Voter + Chemin + Le nœud a déjà été ajouté précédemment. Veuillez essayer un autre nœud. + Impossible d\'établir une connexion avec le nœud. Veuillez en essayer un autre. + Malheureusement, le réseau n\'est pas pris en charge. Veuillez essayer l\'un des suivants: %s. + Confirmer la suppression de %s. + Supprimer le réseau ? + Veuillez vérifier votre connexion ou réessayer plus tard + Personnalisé + Défaut + Réseaux + Ajouter une connexion + Scannez le code QR + Un problème a été identifié avec votre sauvegarde. Vous avez la possibilité de supprimer la sauvegarde actuelle et d\'en créer une nouvelle. %s avant de continuer. + Assurez-vous d\'avoir sauvegardé les phrases secrètes pour tous les portefeuilles + Sauvegarde trouvée mais vide ou corrompue + À l\'avenir, sans le mot de passe de sauvegarde, il est impossible de restaurer vos portefeuilles à partir de la Sauvegarde Cloud.\n%s + Ce mot de passe ne peut pas être récupéré. + Souvenez-vous du mot de passe de sauvegarde + Confirmer le mot de passe + Mot de passe de sauvegarde + Lettres + Min. 8 caractères + Nombres + Les mots de passe correspondent + Veuillez entrer un mot de passe pour accéder à votre sauvegarde à tout moment. Le mot de passe ne peut pas être récupéré, assurez-vous de vous en souvenir! + Créez votre mot de passe de sauvegarde + L\'ID de chaîne entré ne correspond pas au réseau dans l\'URL RPC. + ID de chaîne invalide + Les prêts participatifs privés ne sont pas encore pris en charge. + Prêt participatif privé + À propos des prêts participatifs + Direct + En savoir plus sur les différentes contributions à Acala + Liquide + Actif (%s) + Accepter les conditions générales + Bonus Pezkuwi (%s) + Le code de parrainage Astar doit être une adresse Polkadot valide. + Il n\'est pas possible de contribuer le montant choisi car le montant levé dépassera le plafond du participatif. La contribution maximale autorisée est de %s. + Impossible de contribuer au prêt participatif sélectionné car son plafond est déjà atteint. + Plafond du prêt participatif dépassé + Contribuer au prêt participatif + Contribution + Vous avez contribué : %s + Liquide + Parallèle + Vos contributions\n apparaîtront ici + Retours dans %s + Retour des actifs par la parachain + %s (via %s) + Prêts participatifs + Obtenez un bonus spécial + Les prêts participatifs seront affichés ici + Impossible de contribuer au prêt participatif sélectionné puisqu\'il est déjà terminé. + Prêt participatif terminé + Saisissez votre code de parrainage + Informations sur le prêt participatif + Découvrir le prêt participatif de %s + Le site web du prêt participatif de %s. + Période de location + Contribuez vos %s pour aider les parachains de votre choix à remporter un créneau. Les actifs contribués vous seront rendus, et en cas de succès, vous recevrez des récompenses à la fin de l\'enchère + Vous devez ajouter un compte %s au portefeuille afin de contribuer. + Appliquer le bonus + Si vous n\'avez pas de code de parrainage, vous pouvez appliquer le code de parrainage Pezkuwi pour recevoir un bonus pour votre contribution + Vous n’avez pas appliqué de bonus + Le crowdloan de Moonbeam ne supporte que les comptes de type crypto SR25519 ou ED25519. Veuillez envisager d\'utiliser un autre compte pour votre contribution + Impossible de contribuer avec ce compte + Vous devez ajouter un compte Moonbeam au portefeuille afin de participer au crowdloan Moonbeam + Le compte Moonbeam est manquant + Ce prêt participatif n\'est pas disponible dans votre région. + Votre région n\'est pas prise en charge + %s destination de la récompense + Soumettre l’accord + Vous devez soumettre votre accord avec les termes et conditions sur la blockchain pour continuer. Cette opération ne doit être effectuée qu\'une seule fois pour toutes les prochaines contributions Moonbeam + J\'ai lu et j\'accepte les conditions générales + Levés + Code de parrainage + Le code de parrainage n’est pas valide. S’il vous plaît, essayez-en un autre + Les conditions générales de %s + Le montant de contribution minimum est de %s + Le montant de la contribution est trop faible + Vos actifs %s seront retournés après la période de location. + Vos contributions + Levé : %s sur %s + L\'URL RPC entrée est présente dans Pezkuwi en tant que réseau personnalisé %s. Êtes-vous sûr de vouloir le modifier ? + https://networkscan.io + URL de l\'explorateur de blocs (Optionnel) + 012345 + ID de chaîne + TOKEN + Symbole de la monnaie + Nom du réseau + Ajouter un nœud + Ajouter un nœud personnalisé pour + Entrez les détails + Sauvegarder + Modifier le nœud personnalisé pour + Nom + Nom du nœud + wss:// + URL du nœud + URL RPC + Les dApps auxquelles vous avez autorisé l\'accès peuvent voir votre adresse lorsque vous les utilisez + La dApp “%s” sera retirée de la liste des dApp autorisées + Annuler l\'autorisation ? + DApps autorisées + Catalogue + Approuver cette demande si vous avez confiance en l\'application + Autorisez «%s» à accéder aux adresses de votre compte ? + Approuvez cette demande si vous avez confiance en l\'application.\nVérifiez les détails de la transaction. + DApp + DApps + %d DApps + Favoris + Favoris + Ajouter aux favoris + La dApp «%s» sera retirée des favoris + Retirer des favoris ? + La liste des DApps apparaîtra ici + Ajouter aux favoris + Mode bureau + Supprimer des favoris + Paramètres de la page + Retour + Pezkuwi estime que ce site web pourrait compromettre la sécurité de vos comptes et de vos actifs + Hameçonnage détecté + Rechercher par nom ou entrer l’URL + Chaîne non prise en charge avec le hachage de genèse %s + Assurez-vous que l’opération est correcte + Échec de la signature de l’opération demandée + Ouvrir quand même + Les DApps malveillantes peuvent retirer tous vos fonds. Faites toujours vos propres recherches avant d\'utiliser une DApp, d\'accorder des permissions ou d\'envoyer des fonds.\n\nSi quelqu\'un vous pousse à visiter cette DApp, il est probable que ce soit une escroquerie. En cas de doute, veuillez contacter le support de Pezkuwi Wallet : %s. + Attention ! DApp inconnue + Chaîne non trouvée + Le domaine du lien %s n\'est pas autorisé + Le type de gouvernance n\'est pas spécifié + Le type de gouvernance n\'est pas pris en charge + Type de cryptographie invalide + Chemin de dérivation invalide + Mnémonique non valide + URL invalide + L\'URL RPC entrée est présente dans Pezkuwi en tant que réseau %s. + Canal de notification par défaut + +%d + Recherche par adresse ou nom + Le format de l\'adresse n\'est pas valide. Assurez-vous que l\'adresse appartient au bon réseau + résultats de la recherche : %d + Les comptes Proxy et Multisig sont automatiquement détectés et organisés pour vous. Gérez-les à tout moment dans les Paramètres. + La liste des portefeuilles a été mise à jour + Historique + Déléguer + Tous les comptes + Individus + Organisations + La période de non-délégation commencera après la révocation d\'une délégation + Vos votes s\'ajouteront automatiquement à ceux de vos délégués + Informations sur le délégué + Individu + Organisation + Voix déléguées + Délégations + Modifier la délégation + Vous ne pouvez pas vous déléguer à vous-même, veuillez choisir une autre adresse. + Impossible de se déléguer + Parlez-nous un peu de vous pour que les utilisateurs de Pezkuwi puissent mieux vous connaître + Êtes-vous un délégué? + Décrivez-vous + Sur %s chemins + Votes récents (%s) + Vos votes via %s + Vos votes : %s via %s + Retirer les votes + Révoquer la délégation + Après l\'expiration de la période de non-délégation, vous devrez débloquer vos actifs. + Voix déléguées + Délégations + Votes récents (%s) + Chemins + Tout sélectionner + Sélectionnez au moins 1 chemin... + Aucun chemin n’est disponible pour la délégation + Fellowship + Gouvernance + Trésorerie + Période de non-délégation + N\'est plus valide + Votre délégation + Vos délégations + Afficher + Suppression d\'une sauvegarde... + Votre mot de passe de sauvegarde a été mis à jour précédemment. Pour continuer à utiliser la Sauvegarde Cloud, %s + veuillez entrer le nouveau mot de passe de sauvegarde. + Le mot de passe de sauvegarde a été changé + Vous ne pouvez pas signer les transactions des réseaux désactivés. Activez %s dans les paramètres et réessayez + %s est désactivé + Vous déléguez déjà à ce compte : %s + La délégation existe déjà + (compatible BTC/ETH) + ECDSA + ed25519 (alternative) + Edwards + Mot de passe de sauvegarde + Entrer les données d\'appel + Pas assez de tokens pour payer les frais + Adresse du contrat + Appel de contrat + Fonction + Récupérer les portefeuilles + %s Tous vos portefeuilles seront sauvegardés en toute sécurité sur Google Drive. + Voulez-vous récupérer vos portefeuilles ? + Sauvegarde Cloud existante trouvée + Télécharger le JSON de restauration + Confirmer le mot de passe + Les mots de passe ne correspondent pas + Définir le mot de passe + Réseau : %s\nMnémonique : %s\nChemin de dérivation : %s + Réseau : %s\nMnémonique : %s + Veuillez patienter jusqu\'à ce que les frais soient calculés + Le calcul des frais est en cours + Gérer la carte de débit + Vendre le jeton %s + Ajouter une délégation pour le staking %s + Détails de l\'échange + Max : + Vous payez + Vous recevez + Sélectionnez un token + Recharger la carte avec %s + Veuillez contacter support@pezkuwichain.io. Incluez l\'adresse email que vous avez utilisée pour émettre la carte. + Contacter le support + Réclamé + Créé : %s + Entrez le montant + Le cadeau minimum est de %s + Récupéré + Sélectionnez un token à offrir + Frais de réseau lors de la réclamation + Créer un cadeau + Envoyez des cadeaux rapidement, facilement et en toute sécurité avec Pezkuwi + Partagez des cadeaux crypto avec n\'importe qui, n\'importe où + Cadeaux que vous avez créés + Sélectionner le réseau pour le cadeau %s + Entrez le montant de votre cadeau + %s comme un lien et invitez quiconque à Pezkuwi + Partagez le cadeau directement + %s, et vous pouvez retourner les cadeaux non réclamés à tout moment depuis cet appareil + Le cadeau est disponible instantanément + Canal de notification de gouvernance + + Vous devez sélectionner au moins %d piste + Vous devez sélectionner au moins %d pistes + + Débloquer + Exécuter cet échange entraînera un glissement significatif et des pertes financières. Envisagez de réduire la taille de votre transaction ou de diviser votre transaction en plusieurs transactions. + Impact de prix élevé détecté (%s) + Historique + E-mail + Nom légal + Element ID + Identité + Site web + Le fichier JSON fourni a été créé pour un réseau différent. + Veillez à ce que votre entrée contient un fichier JSON valide. + Le JSON de restauration n\'est pas valide + Veuillez vérifier l\'exactitude du mot de passe et réessayer. + Échec du décryptage de la base de données + Coller le fichier JSON + Type de chiffrement non pris en charge + Impossible d\'importer un compte avec un secret Substrate vers le réseau chiffré avec Ethereum + Impossible d\'importer un compte avec un secret Ethereum vers le réseau chiffré avec Substrate + Votre mnémonique n\'est pas valide + Veuillez vous assurer que votre entrée contient 64 symboles hexadécimaux. + La clé n’est pas valide + Malheureusement, la sauvegarde de vos portefeuilles n\'a pas été trouvée. + Sauvegarde non trouvée + Récupérer les portefeuilles depuis Google Drive + Utilisez votre phrase de récupération de 12, 15, 18, 21 ou 24 mots + Choisissez comment vous souhaitez importer votre portefeuille + Watch-only + Intégrez toutes les fonctionnalités du réseau que vous construisez dans le portefeuille Pezkuwi, le rendant accessible à tous. + Intégrez votre réseau + Vous construisez pour Polkadot ? + Les données d\'appel que vous avez fournies sont invalides ou ont le mauvais format. Veuillez vous assurer qu\'elles sont correctes et réessayer. + Ces données d\'appel pour une autre opération avec le hash d\'appel %s + Données d\'appel invalides + L\'adresse proxy doit être une adresse %s valide + Adresse proxy invalide + Le symbole de monnaie entré (%1$s) ne correspond pas au réseau (%2$s). Voulez-vous utiliser le symbole de monnaie correct ? + Symbole de monnaie invalide + Le QR ne peut pas être décodé + Code QR + Télécharger à partir de la galerie + Exporter le fichier JSON + Langue + Ledger ne prend pas en charge %s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + L\'opération a été annulée par l\'appareil. Assurez-vous d\'avoir déverrouillé votre Ledger. + Opération annulée + Ouvrez l\'application %s sur votre appareil Ledger + L\'application %s n\'a pas été lancée + L\'opération s\'est terminée par une erreur sur l\'appareil. Veuillez réessayer plus tard. + Échec de l\'opération Ledger + Maintenez le bouton de confirmation sur votre %s pour approuver la transaction + Charger d\'autres comptes + Examiner et approuver + Compte %s + Compte non trouvé + Votre appareil Ledger utilise une application Générique obsolète qui ne prend pas en charge les adresses EVM. Mettez-la à jour via Ledger Live. + Mettre à jour l\'application Générique Ledger + Appuyez sur les deux boutons de votre %s pour approuver la transaction. + Veuillez mettre à jour %s à l’aide de l’application Ledger Live + Les métadonnées sont obsolètes + Ledger ne prend pas en charge la signature de messages arbitraires - uniquement les transactions + Veuillez vous assurer que vous avez sélectionné la bonne Ledger pour l\'opération d\'approbation en cours. + Pour des raisons de sécurité, les opérations générées ne sont valables que pour %s. Veuillez réessayer et l’approuver avec Ledger + La transaction a expiré + La transaction est valable pour %s + Ledger ne prend pas en charge cette transaction. + La transaction n\'est pas prise en charge + Appuyez sur les deux boutons de votre %s pour approuver l\'adresse + Appuyez sur le bouton de confirmation de votre %s pour approuver l\'adresse + Appuyez sur les deux boutons de votre %s pour approuver les adresses + Appuyez sur le bouton de confirmation de votre %s pour approuver les adresses + Ce portefeuille est associé à Ledger. Pezkuwi vous aidera à former toutes les opérations que vous souhaitez, et il vous sera demandé de les signer en utilisant Ledger + Sélectionner le compte à ajouter au portefeuille + Chargement des informations du réseau... + Chargement des détails de la transaction… + Recherche de votre sauvegarde... + Transaction de paiement envoyée + Ajouter un jeton + Gérer la sauvegarde + Supprimer le réseau + Modifier le réseau + Gérer le réseau ajouté + Vous ne pourrez pas voir vos soldes de tokens sur ce réseau dans l\'écran des actifs + Supprimer le réseau ? + Supprimer le nœud + Modifier le nœud + Gérer le nœud ajouté + Le nœud \\"%s\\" sera supprimé + Supprimer le nœud ? + Clé personnalisée + Clé par défaut + Ne partagez aucune de ces informations avec quiconque - Si vous le faites, vous perdrez de manière permanente et irrécupérable tous vos actifs + Comptes avec clé personnalisée + Comptes par défaut + %s, +%d autres + Comptes avec clé par défaut + Sélectionnez la clé à sauvegarder + Sélectionnez un portefeuille à sauvegarder + Veuillez lire attentivement ce qui suit avant de visualiser votre sauvegarde + Ne partagez pas votre phrase secrète! + Meilleur prix avec des frais jusqu\'à 3,95 % + Assurez-vous que personne ne peut voir votre écran\net ne prenez pas de captures d\'écran + Veuillez %s personne + ne partagez pas avec + Veuillez en essayez un autre. + Phrase de passe mnémonique non valide, veuillez vérifier une nouvelle fois l\'ordre des mots + Vous ne pouvez pas sélectionner plus de %d portefeuilles + + Sélectionnez au moins %d portefeuille + Sélectionnez au moins %d portefeuilles + + Cette transaction a déjà été rejetée ou exécutée. + Impossible d\'effectuer cette transaction + %s a déjà initié la même opération et elle est actuellement en attente de signature par d\'autres signataires. + Opération déjà existante + Pour gérer votre carte de débit, veuillez passer à un autre type de portefeuille. + La carte de débit n\'est pas prise en charge pour Multisig + Dépôt multisig + Le dépôt reste bloqué sur le compte du déposant jusqu\'à ce que l\'opération multisig soit exécutée ou rejetée. + Assurez-vous que l\'opération est correcte + Pour créer ou gérer des cadeaux, veuillez passer à un autre type de portefeuille. + Les cadeaux ne sont pas pris en charge pour les portefeuilles Multisig + Signée et exécutée par %s. + ✅ Transaction multisig exécutée + %s sur %s. + ✍🏻 Votre signature demandée + Initié par %s. + Portefeuille : %s + Signée par %s + %d de %d signatures collectées. + Au nom de %s. + Rejetée par %s. + ❌ Transaction multisig rejetée + Au nom de + %s : %s + Approuver + Approuver & Exécuter + Entrez les données d\'appel pour voir les détails + La transaction a déjà été exécutée ou rejetée. + La signature est terminée + Rejeter + Signataires (%d sur %d) + Batch All (annule en cas d\'erreur) + Batch (exécute jusqu\'à erreur) + Force Batch (ignore les erreurs) + Créé par vous + Pas encore de transactions.\nLes demandes de signature s\'afficheront ici + Signature (%s sur %s) + Signé par vous + Opération inconnue + Transactions à signer + Au nom de : + Signée par le signataire + Transaction multisig exécutée + Votre signature demandée + Transaction multisig rejetée + Pour vendre des cryptos contre des fiat, veuillez passer à un autre type de portefeuille. + La vente n\'est pas prise en charge pour Multisig + Signataire : + %s n\'a pas assez de solde pour placer un dépôt multisig de %s. Vous devez ajouter %s à votre solde + %s n\'a pas assez de solde pour payer les frais de réseau de %s et placer un dépôt multisig de %s. Vous devez ajouter %s à votre solde + %s doit avoir au moins %s pour payer les frais de cette transaction et rester au-dessus du solde minimum du réseau. Le solde actuel est : %s + %s n\'a pas assez de solde pour payer les frais de réseau de %s. Vous devez ajouter %s à votre solde + Les portefeuilles multisig ne prennent pas en charge la signature de messages arbitraires — uniquement les transactions + La transaction sera rejetée. Le dépôt multisig sera retourné à %s. + La transaction multisig sera initiée par %s. L\'initiateur paie les frais de réseau et réserve un dépôt multisig, qui sera libéré une fois la transaction exécutée. + Transaction multisig + Les autres signataires peuvent maintenant confirmer la transaction.\nVous pouvez suivre son statut dans le %s. + Transactions à signer + Transaction multisig créée + Voir les détails + Transaction sans informations initiales sur la chaîne (données d\'appel) a été rejetée + %s sur %s.\nAucune action supplémentaire n\'est requise de votre part. + Transaction Multisig Exécutée + %1$s → %2$s + %s sur %s.\nRejetée par : %s.\nAucune action supplémentaire n\'est requise de votre part. + Transaction Multisig Rejetée + Les transactions de ce portefeuille nécessitent l\'approbation de plusieurs signataires. Votre compte est l\'un des signataires : + Autres signataires : + Seuil %d sur %d + Canal de Notifications des Transactions Multisig + Activer dans les Paramètres + Recevez des notifications concernant les demandes de signature, les nouvelles signatures et les transactions terminées — pour garder le contrôle à tout moment. Gérez-les à tout moment dans les paramètres. + Les notifications push multisig sont là ! + Ce nœud existe déjà + Frais de réseau + Adresse du nœud + Informations sur le nœud + Ajouté + nœuds personnalisés + nœuds par défaut + Défaut + Connexion... + Collection + Créé par + %s pour %s + %s unités de %s + #%s Édition de %s + Série illimitée + Propriété de + Non répertorié + Vos NFT + Vous n\'avez pas ajouté ou sélectionné de portefeuilles multisig dans Pezkuwi. Ajoutez et sélectionnez au moins un pour recevoir les notifications push multisig. + Aucun portefeuille multisig + L\'URL que vous avez saisie existe déjà en tant que nœud \"%s\". + Ce nœud existe déjà + L\'URL du nœud que vous avez saisie ne répond pas ou contient un format incorrect. Le format de l\'URL doit commencer par \"wss://\". + Erreur de nœud + L\'URL que vous avez saisie ne correspond pas au nœud pour %1$s.\nVeuillez entrer l\'URL du nœud valide de %1$s. + Mauvais réseau + Réclamer des récompenses + Vos tokens seront ajoutés au Stake + Direct + Infos sur le Stake en pool + Vos récompenses (%s) seront également réclamées et ajoutées à votre solde disponible + Pool + Impossible d\'exécuter l\'opération spécifiée car le pool est en cours de fermeture. Il sera bientôt fermé. + Le pool se ferme + Il n\'y a actuellement pas de places disponibles dans la file d\'attente d\'Unstaking pour votre pool. Veuillez réessayer dans %s + Trop de personnes Unstake de votre pool + Votre pool + Votre pool (#%s) + Créer un compte + Créer un nouveau portefeuille + Politique de confidentialité + Importer un compte + J\'ai déjà un portefeuille + En continuant, vous acceptez nos\n%1$s et %2$s + Conditions générales + Échanger + L\'un de vos collateurs ne génère pas de récompenses + L’un de vos collateurs n’est pas sélectionné dans le tour en cours + Votre période de retrait pour %s est passée. N’oubliez pas d’encaisser vos actifs + Impossible de staker avec ce collateur + Changer de collateur + Impossible d\'ajouter une mise à ce collateur + Gérer les collateurs + Le collateur sélectionné a manifesté son intention de ne plus participer au staking. + Vous ne pouvez pas ajouter de mise à un collateur pour lequel vous retirez tous les actifs + Votre mise sera inférieure à la mise minimale (%s) pour ce collateur. + Le solde restant du staking passera sous la valeur minimale du réseau (%s) et sera également ajouté au montant en retrait. + Vous n\'êtes pas autorisé. Réessayez, s\'il vous plaît. + Utiliser la biométrie pour autoriser + Pezkuwi Wallet utilise l\'authentification biométrique pour restreindre l\'accès des utilisateurs non autorisés à l\'application. + Biométrie + Le code pin a été modifié avec succès + Confirmez votre code pin + Créer un code pin + Saisissez le code + Définissez votre code + Vous ne pouvez pas rejoindre le pool car il a atteint le nombre maximal de membres + Le pool est complet + Vous ne pouvez pas rejoindre un pool qui n\'est pas ouvert. Veuillez contacter le propriétaire du pool. + Le pool n\'est pas ouvert + Vous ne pouvez plus utiliser à la fois le Stake direct et le Pool Staking depuis le même compte. Pour gérer votre Pool Staking, vous devez d\'abord unstake vos tokens du Stake direct. + Les opérations de Pool ne sont pas disponibles + Populaires + Ajouter un réseau manuellement + Chargement de la liste des réseaux... + Rechercher par nom de réseau + Ajouter un réseau + %s à %s + 1J + Tout + 1M + %s (%s) + prix de %s + 1S + 1A + Depuis le début + Le mois dernier + Aujourd’hui + La semaine dernière + L\'année dernière + Comptes + Portefeuilles + Langue + Modifier le code pin + Ouvrir l’application avec le solde masqué + Approuver avec PIN + Mode sans échec + Paramètres + Pour gérer votre carte de débit, veuillez passer à un autre portefeuille avec le réseau Polkadot. + La carte de débit n\'est pas prise en charge pour ce portefeuille Proxied + Ce compte a accordé l\'accès pour effectuer des transactions au compte suivant: + Opérations de Staking + Le compte délégué %s n\'a pas un solde suffisant pour payer les frais de réseau de %s. Solde disponible pour payer les frais : %s + Les portefeuilles proxifiés ne prennent pas en charge la signature de messages arbitraires - seulement des transactions + %1$s n\'a pas délégué %2$s + %1$s a délégué %2$s seulement pour %3$s + Oups! Permission insuffisante + La transaction sera initiée par %s en tant que compte délégué. Les frais de réseau seront payés par le compte délégué. + Ceci est un compte de délégation (Proxied) + %s proxy + Le délégué a voté + Nouveau référendum + Mise à jour du référendum + %s Référendum #%s est maintenant en ligne ! + 🗳️ Nouveau référendum + Téléchargez Pezkuwi Wallet v%s pour obtenir toutes les nouvelles fonctionnalités ! + Une nouvelle mise à jour de Pezkuwi Wallet est disponible ! + %s référendum #%s est terminé et a été approuvé 🎉 + ✅ Référendum approuvé ! + %s Référendum #%s a changé de statut de %s à %s + %s référendum #%s est terminé et a été rejeté ! + ❌ Référendum rejeté ! + 🗳️ Statut du référendum modifié + %s Référendum #%s a changé de statut en %s + Annonces Pezkuwi + Soldes + Activer les notifications + Transactions multisig + Vous ne recevrez pas de notifications concernant les activités du portefeuille (soldes, Staking) car vous n\'avez sélectionné aucun portefeuille + Autres + Jetons reçus + Jetons envoyés + Récompenses de Staking + Portefeuilles + ⭐🆕 Nouvelle récompense %s + Reçu %s de %s Staking + ⭐🆕 Nouvelle récompense + Pezkuwi Wallet • maintenant + Reçu +0.6068 KSM (20,35 $) de Kusama Staking + Reçu %s sur %s + ⬇️ Reçu + ⬇️ Reçu %s + Envoyé %s à %s sur %s + 💸 Envoyé + 💸 Envoyé %s + Sélectionnez jusqu\'à %d portefeuilles pour être notifié lorsque le portefeuille a une activité + Activer les notifications push + Soyez informé des opérations du portefeuille, des mises à jour de la gouvernance, des activités de Staking et de la sécurité, pour rester toujours informé + En activant les notifications push, vous acceptez nos %s et %s + Veuillez réessayer plus tard en accédant aux paramètres de notification depuis l\'onglet Paramètres + Ne manquez rien ! + Sélectionner le réseau pour recevoir %s + Copier l\'adresse + Si vous récupérez ce cadeau, le lien partagé sera désactivé et les tokens seront remboursés sur votre portefeuille.\nVoulez-vous continuer ? + Récupérer le cadeau %s ? + Vous avez récupéré votre cadeau avec succès + Collez le fichier JSON ou téléchargez le fichier... + Télécharger le fichier + Restaurer JSON + Phrase de récupération + Clé de récupération + Type de source + Tous les référendums + Afficher: + Non votés + Votés + Il n\'y a pas de référendums avec les filtres appliqués + Les informations sur les référendums apparaîtront ici lorsqu\'ils commenceront + Aucun référendum avec le titre ou ID entré\nn\'a été trouvé + Recherche par titre de référendum ou ID + %d référendums + Balayez pour voter sur les référendums avec des résumés par IA. Rapide et facile! + Référendums + Référendum non trouvé + Les votes d\'abstention ne peuvent se faire qu\'avec une conviction de 0,1x. Voter avec une conviction de 0,1x ? + Mise à jour de la conviction + Votes d\'abstention + Oui : %s + Utiliser le navigateur Pezkuwi DApp + Seul le proposant peut modifier cette description et le titre. Si vous possédez le compte du proposant, visitez Polkassembly et remplissez les informations relatives à votre proposition. + Montant demandé + Chronologie + Votre vote : + Courbe d’approbation + Bénéficiaire + Dépôt + Électorat + Trop long pour l\'aperçu + Paramètres JSON + Proposant + Courbe d\'appui + Taux de participation + Seuil de vote + Position : %s sur %s + Vous devez ajouter le compte %s au portefeuille pour pouvoir voter. + Référendum %s + Non: %s + Votes négatifs + %s votes par %s + Votes favorables + Staking + Approuvé + Annulé + Décision + Décisif dans %s + Exécuté + En file d\'attente + Dans la file d\'attente (%s sur %s) + Neutralisé + Rejet + Approbation + Préparation + Rejeté + Adopté dans %s + Exécuté dans %s + Expire dans %s + Rejeté dans %s + Délai expiré + En attente de dépôt + Seuil : %s sur %s + Voté : Approuvé + Annulé + Créé + Vote : Décision + Exécuté + Scrutin : En attente + Tué + Scrutin : Rejet + Scrutin : Approbation + Scrutin : Préparation + Voté : Rejeté + Délai expiré + Scrutin : En attente de dépôt + Pour passer : %s + Prêts participatifs + Trésorerie : grosses dépenses + Trésorerie : gros pourboires + Fellowship: administrateur + Gouvernance : registraire + Gouvernance : Lease + Trésorerie : dépenses moyennes + Gouvernance : annulateur + Gouvernance : Tueur + Agenda principal + Trésorerie : petites dépenses + Trésorerie : petits pourboires + Trésorerie : toute + reste bloqué en %s + Déblocable + Abstention + Oui + Réutiliser tous les bloqués : %s + Réutiliser le blocage en gouvernance : %s + Verrou de gouvernance + Période de blocage + Non + Multiplier les votes en augmentant la période de blocage + Vote pour %s + Après la période de blocage, n\'oubliez pas de débloquer vos actifs. + Référendums votés + %s votes + %s × %sx + La liste des votants apparaîtra ici + %s votes + Fellowship : liste blanche + Votre vote : %s votes + Le référendum est achevé et le vote est terminé + Le référendum est terminé + Vous déléguez des votes pour le chemin du référendum sélectionné. Veuillez soit demander à votre délégué de voter, soit retirer la délégation pour pouvoir voter directement. + Délégue déjà des votes + Vous avez atteint un maximum de %s votes pour le chemin + Nombre maximum de votes atteint + Vous n\'avez pas assez d´actifs disponibles pour voter. Disponible pour voter : %s. + Révoquer le type d\'accès + Révoquer pour + Retirer les votes + + Vous avez déjà voté lors de référendums sur le chemin %d. Afin de rendre ce chemin disponible pour la délégation, vous devez retirer vos votes en cours. + Vous avez déjà voté lors de référendums sur les chemins %d . Afin de rendre ces chemins disponibles pour la délégation, vous devez retirer les votes en cours. + + Effacer l\'historique de vos votes ? + %s Il vous appartient exclusivement, stocké en toute sécurité, inaccessible aux autres. Sans le mot de passe de sauvegarde, restaurer les portefeuilles depuis Google Drive est impossible. En cas de perte, supprimez la sauvegarde actuelle pour en créer une nouvelle avec un nouveau mot de passe. %s + Malheureusement, votre mot de passe ne peut pas être récupéré. + Sinon, utilisez la phrase de passe pour la restauration. + Avez-vous perdu votre mot de passe ? + Votre mot de passe de sauvegarde a été mis à jour précédemment. Pour continuer à utiliser la sauvegarde Cloud, veuillez entrer le nouveau mot de passe de sauvegarde. + Veuillez entrer le mot de passe que vous avez créé lors du processus de sauvegarde + Entrez le mot de passe de sauvegarde + Échec de la mise à jour des informations sur l\'exécution de la blockchain. Certaines fonctionnalités peuvent ne pas fonctionner. + Échec de la mise à jour de l\'exécution + Contacts + mes comptes + Aucun pool avec le nom ou ID entré\nn\'a été trouvé. Assurez-vous que vous\navez entré les bonnes données + Adresse ou nom du compte + Les résultats de la recherche seront affichés ici + Résultats de la recherche + pools actifs : %d + membres + Sélectionner un pool + Sélectionner des chemins pour ajouter une délégation + Chemins disponibles + Veuillez sélectionner les chemins dans lesquelles vous souhaitez déléguer votre droit de vote. + Sélectionnez des chemins pour modifier une délégation + Sélectionnez des chemins pour révoquer votre délégation + Chemins non disponibles + Envoyer le cadeau sur + Activer Bluetooth & Accorder les Permissions + Pezkuwi a besoin que la localisation soit activée pour pouvoir effectuer un balayage Bluetooth afin de trouver votre appareil Ledger + Veuillez activer la géolocalisation dans les paramètres de l\'appareil + Sélectionner un réseau + Choisissez le jeton pour voter + Sélectionner des pistes pour + %d sur %d + Sélectionnez le réseau pour vendre %s + Vente initiée ! Veuillez patienter jusqu\'à 60 minutes. Vous pouvez suivre le statut par email. + Aucun de nos fournisseurs ne supporte actuellement la vente de ce jeton. Veuillez choisir un jeton différent, un réseau différent, ou revenir plus tard. + Ce jeton n\'est pas supporté par la fonction de vente + Adresse ou w3n + Sélectionner le réseau pour envoyer %s + Le destinataire est un compte système. Il n\'est contrôlé par aucune entreprise ou individu.\nÊtes-vous sûr de vouloir effectuer ce transfert ? + Les tokens seront perdus + Donner l\'autorité à + Veuillez vous assurer que la biométrie est activée dans les paramètres + La biométrie est désactivée dans les paramètres + Communauté + Obtenez de l\'aide par Email + Général + Chaque opération de signature sur les portefeuilles avec paire de clés (créée dans Pezkuwi Wallet ou importée) doit nécessiter une vérification PIN avant de construire la signature + Demander une authentification pour signer les opérations + Préférences + Les notifications push sont disponibles uniquement pour la version de Pezkuwi Wallet téléchargée depuis Google Play. + Les notifications push sont disponibles uniquement pour les appareils avec les services Google. + L\'enregistrement et les captures d\'écran ne seront pas disponibles. L\'application réduite n\'affichera pas le contenu + Mode sans échec + Sécurité + Assistance et commentaires + Twitter + Wiki et centre d\'aide + YouTube + La conviction sera fixée à 0,1x lorsque vous vous abstenez + Vous ne pouvez pas staker avec le Stake direct et les Pools de Nomination en même temps + Déjà staké + Gestion avancée du staking + Le type de staking ne peut pas être modifié + Vous avez déjà un staking Direct + Staking direct + Vous avez spécifié moins que le minimum de stake de %s requis pour gagner des récompenses avec %s. Vous devriez envisager d\'utiliser le Pool staking pour gagner des récompenses. + Réutiliser les tokens dans la Gouvernance + Stake minimum : %s + Récompenses : Payées automatiquement + Récompenses : À récupérer manuellement + Vous stakiez déjà dans un pool + Staking en pool + Votre stake est inférieur au minimum pour gagner des récompenses + Type de staking non supporté + Partager le lien du cadeau + Récupérer + Bonjour ! Vous avez un cadeau %s qui vous attend !\n\nInstallez l\'application Pezkuwi Wallet, configurez votre portefeuille et réclamez-le via ce lien spécial :\n%s + Le cadeau a été préparé.\nPartagez-le maintenant ! + sr25519 (recommandé) + Schnorrkel + Le compte sélectionné est déjà utilisé en tant que contrôleur + Ajouter une autorité déléguée (Proxy) + Vos délégations + Délégants actifs + Ajouter le compte de contrôleur %s à l\'application pour effectuer cette action. + Ajouter une délégation + Votre mise est inférieure au minimum de %s.\nLe fait d\'avoir une mise inférieure au minimum augmente les chances que la mise ne génère pas de récompenses. + Staker plus d\'actifs + Changez vos validateurs. + Tout va bien maintenant. Les alertes apparaîtront ici. + Être mal positionné dans la file d’attente de l’attribution de mise à un validateur peut suspendre vos récompenses + Amélioration du staking + Encaisser des actifs non misés. + Veuillez attendre que la prochaine ère commence. + Alertes + Déjà contrôleur + Vous avez déjà un staking dans %s + Solde en staking + Solde + Miser plus + Vous ne nommez ni ne validez + Changer de contrôleur + Changer les validateurs + %s sur %s + Validateurs sélectionnés + Contrôleur + Compte contrôleur + Nous avons constaté que ce compte n\'a pas d\'actifs disponibles. Êtes-vous sûr de vouloir changer de contrôleur ? + Le contrôleur peut désengager, encaisser, retourner à l\'enjeu, changer de valiateurs et de destination de récompenses. + Le contrôleur est utilisé pour: retirer sa mise, encaisser, retourner au staking, changer de validateurs et définir la destination des récompenses + Le contrôleur est changé + Ce validateur est bloqué et ne peut être sélectionné pour le moment. Veuillez réessayer dans la prochaine ère. + Effacer les filtres + Désélectionner tout + Remplir le reste avec ce qui est recommandé + Validateurs : %d sur %d + Sélectionnez les validateurs (max %d) + Afficher la sélection : %d (max %d) + Sélectionner des validateurs + Récompenses estimées (%% APR) + Récompenses estimées (%% APY) + Mettre à jour votre liste + Staking via le navigateur DApp Pezkuwi + Plus d\'options de staking + Stake et gagnez des récompenses + Le Staking %1$s est en ligne sur %2$s à partir de %3$s + Récompenses estimées + #%s ère + Estimation des revenus + Estimation des revenus (%s) + Participation du validateur + Participation propre du validateur (%s) + Les jetons en période de retrait ne génèrent aucune récompense. + Pendant la période de retrait, les actifs ne produisent aucune récompense + Après la période de retrait, vous devrez récupérer vos actifs. + Après la période de désengagement, n\'oubliez pas d\'encaisser vos actifs + Vos récompenses seront augmentées à partir de l\'ère suivante. + Vous obtiendrez de meilleurs récompenses à partir de la prochaine ère + Les jetons mis en jeu génèrent des récompenses à chaque ère (%s). + Les actifs mis en jeu produisent des récompenses à chaque ère (%s) + Le portefeuille Pezkuwi changera la destination des récompenses\nvers votre compte pour éviter une mise restante. + Si vous souhaitez retirer des jetons, vous devrez attendre la période de retrait (%s). + Pour désengager des actifs, vous devrez attendre la période de retrait (%s) + À propos du staking + Nominateurs actifs + + %d jour + %d jours + + Mise minimale + Réseau %s + Mis en jeu + Veuillez changer votre portefeuille sur %s pour configurer un proxy + Sélectionnez le compte stash pour configurer le proxy + Gérer + %s (max %s) + Le nombre maximum de nominateurs a été atteint + Impossible de démarrer le staking + Mise min. + Vous devez ajouter le compte %s au portefeuille afin de commencer à staker + Mensuel + Ajoutez votre compte contrôleur dans l\'appareil. + Pas d\'accès au compte contrôleur + Nommé : + %s récompensés + Un de vos validateurs a été élu par le réseau. + Statut actif + Statut inactif + Votre mise est inférieure à la mise minimale pour obtenir une récompense. + Aucun de vos validateurs n\'a été élu par le réseau. + Votre staking commencera dans l\'ère suivante. + Inactif + En attente de la prochaine ère + en attente de la prochaine ère (%s) + Vous n\'avez pas suffisamment de solde pour le dépôt proxy de %s. Solde disponible : %s + Canal de notifications de staking + Collateur + La mise minimale du collateur est supérieure à votre délégation. Vous ne recevrez pas de récompenses de la part du collateur. + Informations sur le collateur + Participation propre de collateur + Collateurs : %s + Un ou plusieurs de vos collateurs ont été élus par le réseau. + Délégants + Vous avez atteint le nombre maximum de délégations de %d collateurs + Vous ne pouvez pas sélectionner un nouveau collateur + Nouveau collateur + en attente du prochain tour (%s) + Vous avez des demandes de retrait en attente pour tous vos collateurs. + Aucun collateur disponible pour retirer + Les actifs retournés seront comptabilisés à partir du prochain tour + Les jetons misés produisent des récompenses à chaque tour (%s) + Sélectionner le collateur + Sélectionnez un collateur... + Vous obtiendrez des récompenses plus importantes à partir du prochain tour + Vous ne recevrez pas de récompenses pour ce tour car aucune de vos délégations n\'est active. + Vous êtes déjà en train de retirer des jetons de ce collateur. Vous ne pouvez avoir qu’un retrait en attente par collateur + Vous ne pouvez pas vous détacher de ce collateur + Votre mise doit être supérieure à la mise minimale (%s) pour ce collateur. + Vous ne recevrez pas de récompenses + Certains de vos collateur ne sont pas élus ou ont une mise minimale plus élevée que votre montant misé. Vous ne recevrez donc pas de récompense de leur part dans ce tour de staking. + Vos collateurs + Votre mise est attribuée aux prochains collateurs + Collateurs actifs sans produire de récompenses + Collateurs sans assez de participation pour être élus + Collateurs qui agiront lors du prochain tour + En attente (%s) + Paiement + Paiement expiré + + %d jour restant + %d jours restants + + Vous pouvez les verser vous-même, quand ils sont sur le point d’expirer, mais vous paierez les frais + Les récompenses sont payées tous les 2 ou 3 jours par les validateurs + Tout + Tout le temps + La date de fin est toujours aujourd\'hui + Période personnalisée + %dJ + Sélectionnez la date de fin + Fin + Les 6 derniers mois (6M) + 6M + Les 30 derniers jours (30J) + 30J + Les 3 derniers mois (3M) + 3M + Sélectionnez une date + Sélectionnez la date de début + Début + Afficher les récompenses de staking pour + Les 7 derniers jours (7J) + 7J + L\'année dernière (1A) + 1A + Votre solde disponible est de %s, vous devez laisser %s comme solde minimal et payer les frais de réseau de %s. Vous pouvez staker pas plus de %s. + Pouvoirs délégués (proxy) + Position dans la file d\'attente + Nouvelle position + Retour à l’enjeu + Tous les unstaking + Les actifs retournés seront comptabilisés à partir de la prochaine ère + Montant personnalisé + Le montant que vous souhaitez remettre en jeu est supérieur au solde désengagé + Derniers retraits + Le plus rentable + Non sursouscrit + Ayant une identité onchain + Non sabré + Limite de 2 validateurs par identité + avec au moins une info d\'identité + Validateurs recommandés + Validateurs + Récompense estimée (APY) + Réclamer + Encaissable : %s + Récompense + Destination des récompenses + Récompenses transférables + Ère + Détails de la récompense + Validateur + Gains avec restake + Gains sans réengagement + Parfait ! Toutes les récompenses sont payées. + Super ! Vous n’avez pas de récompenses impayées + Payer tous (%s) + Récompenses en attente + Récompenses impayées + %s récompenses + À propos des récompenses + Récompenses (APY) + Destination des récompenses + Sélectionner par vous-même + Sélectionner le compte de paiement + Sélectionner les recommandations + sélectionné %d (max %d) + Validateurs (%d) + Mettre à jour le contrôleur vers Stash + Utilisez des proxys pour déléguer les opérations de Staking à un autre compte + Les comptes de contrôleur sont obsolètes + Sélectionnez un autre compte en tant que contrôleur pour lui déléguer les opérations de gestion du staking + Améliorer la sécurité du staking + Définir les validateurs + Les validateurs ne sont pas sélectionnés + Sélectionnez des validateurs pour commencer le staking + La mise minimale recommandée pour recevoir régulièrement des récompenses est de %s. + Vous ne pouvez pas staker moins que la valeur minimale du réseau (%s) + La mise minimale doit être supérieure à %s + Remettre en jeu + Récompenses réengagées + Comment utiliser vos récompenses ? + Sélectionnez votre type de récompense + Compte de paiement + Sabrage + Staker %s + Stake max + Période de staking + Type de staking + Vous devez faire confiance à vos nominations pour agir de manière compétente et honnête. Baser votre décision uniquement sur leur rentabilité actuelle pourrait conduire à une réduction des bénéfices, voire à une perte de fonds. + Choisissez vos validateurs avec soin, car ils doivent agir avec compétence et honnêteté. Si vous basez votre décision uniquement sur la rentabilité, vous risquez de voir vos récompenses réduites, voire de perdre votre mise. + Staker avec vos validateurs + Pezkuwi sélectionnera les meilleurs validateurs en fonction de critères de sécurité et de rentabilité + Staker avec les validateurs recommandés + Commencer à staker + Réserve + La réserve peut lier plus et définir le contrôleur. + La réserve est utilisée pour : staker davantage et définir le contrôleur + Le compte réserve %s n\'est pas disponible pour mettre à jour la configuration du staking. + Le nominateur gagne un revenu passif en bloquant ses actifs pour sécuriser le réseau. Pour ce faire, le nominateur doit sélectionner un certain nombre de validateurs à soutenir. Le nominateur doit être prudent lors de la sélection des validateurs. Si le validateur sélectionné ne se comporte pas correctement, des pénalités seront appliquées à tous les deux, en fonction de la gravité de l\'incident. + Pezkuwi apporte son soutien aux nominateurs en les aidant à sélectionner leurs validateurs. Pezkuwi récupère les données de la blockchain et compose une liste de validateurs, qui ont : le plus de profits, une identité avec des informations de contact, qui ne sont pas sabrés et qui sont disponibles pour recevoir des nominations. Pezkuwi se soucie également de la décentralisation, de sorte que si une personne ou une entreprise gère plusieurs nœuds de validation, seuls deux nœuds au maximum seront affichés dans la liste recommandée. + Qu\'est-ce qu\'un nominateur ? + Les récompenses pour les mises sont disponibles à la fin de chaque ère (6 heures à Kusama et 24 heures à Polkadot). Le réseau stocke les récompenses en attente pendant 84 ères et, dans la plupart des cas, les validateurs versent les récompenses pour tout le monde. Cependant, il peut arriver que les validateurs oublient ou qu\'il leur arrive quelque chose, les nominateurs peuvent donc payer leurs récompenses eux-mêmes. + Bien que les récompenses soient généralement distribuées par les validateurs, Pezkuwi vous aide en vous alertant si des récompenses non payées sont sur le point d\'expirer. Vous recevrez des alertes à ce sujet et d\'autres activités sur l\'écran de staking. + Reçoit des récompenses + Le staking est une option permettant de gagner un revenu passif en bloquant vos tokens dans le réseau. Les récompenses sont attribuées chaque jour (6 heures sur Kusama et 24 heures sur Polkadot). Vous pouvez miser aussi longtemps que vous le souhaitez, et pour désengager vos jetons, vous devez attendre la fin de la période de désengagement, enfin vos actifs seront disponibles. + Le staking est un élément important de la sécurité et de la fiabilité du réseau. Tout le monde peut gérer des nœuds de validation, mais seuls ceux qui ont suffisamment de jetons mis en jeu seront élus par le réseau pour participer à la composition de nouveaux blocs et recevoir les récompenses. Les validateurs n\'ont souvent pas assez de jetons, c\'est pourquoi les nominateurs les aident en bloquant leurs jetons pour qu\'ils atteignent le montant de mise requis. + Qu\'est-ce que le staking ? + Le validateur gère un nœud de la blockchain 24 heures sur 24 et 7 jours sur 7 et doit avoir suffisamment de stake bloqués (détenus et fournis par les nominateurs) pour être élu par le réseau. Les validateurs doivent maintenir la performance et la fiabilité de leurs nœuds pour être récompensés. Être un validateur est presque un travail à temps plein, il y a des entreprises qui deviennent des validateurs pour les réseaux de blockchain. + Tout le monde peut être validateur et gérer un nœud de blockchain, mais cela nécessite un certain niveau de compétences techniques et de responsabilité. Les réseaux Polkadot et Kusama ont mis en place un programme, appelé Thousand Validators Programme, pour aider les débutants. En outre, le réseau lui-même récompensera toujours davantage de validateurs, qui ont moins de stake (mais suffisamment pour être élus), afin d\'améliorer la décentralisation. + Qu\'est-ce qu\'un validateur ? + Basculez votre compte vers la réserve pour configurer le contrôleur. + Staking + %s staking + Récompensé + Total mis en jeu + Seuil de boost + Pour mon collateur + sans Yield Boost + avec Yield Boost + pour staker automatiquement %s tous mes actifs transférables ci-dessus + pour staker automatiquement %s (avant : %s) tous mes actifs transférables ci-dessus + Je veux staker + Yield Boost + Type de staking + Vous retirez tous vos tokens et ne pouvez pas en stake davantage. + Impossible de stake plus + Lors d\'un retrait partiel, vous devez laisser au moins %s en jeu. Voulez-vous effectuer un retrait complet en libérant également %s restants? + Montant trop faible reste en stake + Le montant que vous souhaitez désengager est supérieur au solde mis en jeu + Retirer + Les transactions de désengagement apparaîtront ici + Les transactions de désengagement seront affichées ici + Retrait : %s + Vos actifs seront disponibles après la période de retrait. + Vous avez atteint la limite de demandes d\'unstaking ( %d demandes actives). + Limite de demandes d\'unstaking atteinte + Période de désengagement + Tout unstake + Tout retirer ? + Récompense estimée (%% APY) + Récompense estimée + Informations sur le validateur + Sursouscrit. Vous ne recevrez pas de récompenses de la part du validateur dans cette période. + Nominateurs + Sursouscrit. Seuls les nominateurs les mieux placés reçoivent des récompenses. + Propre au validateur + Aucun résultat de recherche.\nAssurez-vous d\'avoir tapé l\'adresse complète du compte + Le validateur est sabré s\'il se comporte mal (par exemple, s\'il est déconnecté, s\'il attaque le réseau ou s\'il exécute un logiciel modifié) dans le réseau. + Total en staking + Participation totale (%s) + La récompense est inférieure aux frais de réseau. + Annuel + Votre mise est attribuée aux validateurs suivants. + Votre mise est attribuée aux prochains validateurs + Élu (%s) + Les validateurs qui n\'ont pas été élus à cette ère. + Valideurs n\'ayant pas suffisamment de stake pour être élus + D\'autres, qui sont actifs sans que vous leur attribuiez de participation. + Validateurs actifs sans affecter votre mise + Non élu (%s) + Vos actifs sont attribués à des validateurs sursouscrits. Vous ne recevrez pas de récompenses durant cette ère. + Récompenses + Votre enjeu + Vos validateurs + Vos validateurs changeront lors la prochaine ère. + Maintenant, sauvegardons votre portefeuille. Cela garantit que vos fonds sont en sécurité. Les sauvegardes vous permettent de restaurer votre portefeuille à tout moment. + Continuer avec Google + Entrez le nom du portefeuille + Mon nouveau portefeuille + Continuer avec la sauvegarde manuelle + Donnez un nom à votre portefeuille + Cela ne sera visible que par vous et vous pourrez le modifier plus tard. + Votre portefeuille est prêt + Bluetooth + USB + Vous avez des tokens verrouillés sur votre solde en raison de %s. Pour continuer, vous devez entrer moins de %s ou plus de %s. Pour stake un autre montant, vous devez supprimer vos verrous %s. + Vous ne pouvez pas stake le montant spécifié + Sélectionné : %d (max %d) + Solde disponible : %1$s (%2$s) + %s avec vos tokens staked + Participer à la gouvernance + Stake plus de %1$s et %2$s avec vos tokens staked + participer à la gouvernance + Stake à tout moment avec aussi peu que %1$s. Votre stake gagnera activement des récompenses %2$s + dans %s + Stake à tout moment. Votre stake gagnera activement des récompenses %s + Découvrez plus d\'informations sur\n%1$s staking sur le %2$s + Pezkuwi Wiki + Les récompenses s\'accumulent %1$s. Stake plus de %2$s pour le versement automatique des récompenses, sinon vous devez les réclamer manuellement + tous les %s + Les récompenses s\'accumulent %s + Les récompenses s\'accumulent %s. Vous devez réclamer les récompenses manuellement + Les récompenses s\'accumulent %s et sont ajoutées au solde transférable + Les récompenses s\'accumulent %s et sont ajoutées de nouveau au stake + Les récompenses et le statut du staking varient avec le temps. %s de temps en temps + Surveillez votre stake + Commencer le Staking + Voir %s + Conditions d\'utilisation + %1$s est un %2$s avec %3$s + aucune valeur de jeton + réseau de test + %1$s\nsur vos jetons %2$s\npar an + Gagnez jusqu\'à %s + Désaccumulez à tout moment et récupérez vos fonds %s. Aucune récompense n\'est gagnée pendant le désaccumulation + après %s + La piscine que vous avez sélectionnée est inactive en raison de l\'absence de validateurs sélectionnés ou de son stake étant inférieur au minimum.\nÊtes-vous sûr de vouloir continuer avec la piscine sélectionnée? + Le nombre maximum de nominateurs a été atteint. Réessayez plus tard + %s est actuellement indisponible + Validateurs: %d (max %d) + Modifié + Nouveau + Retiré + Token pour le paiement des frais de réseau + Les frais de réseau sont ajoutés en plus du montant saisi + Échec de la simulation de l\'étape de swap + Cette paire n\'est pas prise en charge + Échec de l\'opération #%s (%s) + Échange de %s à %s sur %s + Transfert de %s de %s à %s + + %s opération + %s opérations + + %s sur %s opérations + Échange de %s à %s sur %s + Transfert de %s à %s + Temps d\'exécution + Vous devez conserver au moins %s après avoir payé %s de frais de réseau car vous détenez des tokens insuffisants + Vous devez conserver au moins %s pour recevoir des tokens %s + Vous devez avoir au moins %s sur %s pour recevoir %s jeton + Vous pouvez échanger jusqu\'à %1$s car vous devez payer %2$s pour les frais de réseau. + Vous pouvez échanger jusqu\'à %1$s car vous devez payer %2$s pour les frais de réseau et également convertir %3$s en %4$s pour atteindre le solde minimum de %5$s. + Échanger max + Utiliser le minimum + Vous devez laisser au moins %1$s sur votre solde. Voulez-vous effectuer l\'échange total en ajoutant également le restant %2$s? + Montant restant trop faible sur votre solde + Vous devez garder au moins %1$s après avoir payé les frais de réseau de %2$s et converti %3$s en %4$s pour atteindre le solde minimum de %5$s.\n\nSouhaitez-vous effectuer l\'échange total en ajoutant également le restant %6$s? + Payer + Recevoir + Sélectionnez un token + Pas assez de tokens pour échanger + Pas assez de liquidité + Vous ne pouvez pas recevoir moins de %s + Acheter instantanément %s avec une carte de crédit + Transférer %s depuis un autre réseau + Recevoir %s avec un QR ou votre adresse + Obtenir %s en utilisant + Pendant l\'exécution de l\'échange, le montant intermédiaire reçu est de %s, ce qui est inférieur au solde minimum de %s. Essayez de spécifier un montant d\'échange plus important. + La glissement doit être spécifiée entre %s et %s + Glissement non valide + Sélectionnez un token pour payer + Sélectionnez un token pour recevoir + Entrez le montant + Entrez un autre montant + Pour payer les frais de réseau avec %s, Pezkuwi échangera automatiquement %s contre %s pour maintenir le solde minimum de %s sur votre compte. + Des frais de réseau facturés par la blockchain pour traiter et valider toutes les transactions. Ils peuvent varier en fonction des conditions du réseau ou de la rapidité de la transaction. + Sélectionner le réseau pour échanger %s + Le pool n\'a pas assez de liquidité pour échanger + La différence de prix fait référence à la différence de prix entre deux actifs différents. Lors de l\'échange en crypto, la différence de prix est généralement la différence entre le prix de l\'actif que vous échangez et le prix de l\'actif avec lequel vous échangez. + Différence de prix + %s ≈ %s + Taux de change entre deux crypto-monnaies différentes. Il représente combien de crypto-monnaie vous pouvez obtenir en échange d\'un certain montant d\'une autre crypto-monnaie. + Taux + Ancien taux : %1$st∻%2$s.\nNouveau taux : %1$st∻%3$s + Le taux d\'échange a été mis à jour + Répétez l\'opération + Itinéraire + La voie que prendra votre jeton à travers différents réseaux pour obtenir le jeton souhaité. + Échange + Transfert + Paramètres d\'échange + Slippage + Le slippage est un phénomène courant dans le trading décentralisé où le prix final d\'une transaction d\'échange peut légèrement différer du prix attendu, en raison des conditions changeantes du marché. + Entrez une autre valeur + Entrez une valeur comprise entre %s et %s + Slippage + La transaction pourrait être anticipée en raison d\'un slippage élevé. + La transaction pourrait être annulée en raison d\'une faible tolérance au slippage. + Le montant de %s est inférieur au solde minimum de %s + Vous essayez d\'échanger un montant trop faible + Abstention: %s + Oui : %s + Vous pouvez toujours voter pour ce référendum plus tard + Retirer le référendum %s de la liste de vote? + Certains référendums ne sont plus disponibles pour voter ou vous n\'avez peut-être pas assez de jetons disponibles pour voter. Disponible pour voter: %s. + Certains référendums ont été exclus de la liste de vote + Les données du référendum n\'ont pas pu être chargées + Aucune donnée récupérée + Vous avez déjà voté pour tous les référendums disponibles ou il n\'y a pas de référendums pour voter en ce moment. Revenez plus tard. + Vous avez déjà voté pour tous les référendums disponibles + Demandé : + Liste de vote + %d restant + Confirmer les votes + Aucun référendum à voter + Confirmez vos votes + Aucun vote + Vous avez voté avec succès pour %d référendums + Vous n\'avez pas assez de solde pour voter avec la puissance de vote actuelle %s (%sx). Veuillez changer la puissance de vote ou ajouter plus de fonds à votre portefeuille. + Solde insuffisant pour voter + Non : %s + SwipeGov + Vote pour %d référendums + Le vote sera configuré pour les futurs votes dans SwipeGov + Puissance de vote + Staking + Portefeuille + Aujourd’hui + Lien Coingecko pour les informations sur les prix (facultatif) + Sélectionnez un fournisseur pour acheter le jeton %s + Les méthodes de paiement, frais et limites diffèrent selon le fournisseur.\nComparez leurs devis pour trouver la meilleure option pour vous. + Sélectionnez un fournisseur pour vendre le jeton %s + Pour poursuivre l\'achat, vous serez redirigé de l\'application Pezkuwi Wallet vers %s + Continuer dans le navigateur? + Aucun de nos fournisseurs ne supporte actuellement l\'achat ou la vente de ce jeton. Veuillez choisir un jeton différent, un réseau différent, ou revenir plus tard. + Ce jeton n\'est pas supporté par la fonction d\'achat/vente + Copier le hash + Frais + De + Hash extrinsèque + Détails de la transaction + Voir dans %s + Voir sur Polkascan + Voir sur Subscan + %s à %s + Votre ancien historique de transactions %s est toujours disponible sur %s + Terminé + Échec + En attente + Canal de notifications de transactions + Achetez des cryptos à partir de seulement 5 $ + Vendez des cryptos à partir de seulement 10 $ + De : %s + À : %s + Transfert + Les transferts entrants et sortants\napparaîtront ici + Vos opérations seront affichées ici + Retirer les votes afin de déléguer dans ces chemins + Chemins auxquels vous avez déjà délégué des votes + Chemins indisponibles + Chemins dans lesquels vous avez des votes en cours + Ne plus afficher cela.\nVous pouvez trouver l\'adresse legacy dans Recevoir. + Format legacy + Nouveau format + Certaines plateformes peuvent encore nécessiter le format legacy\npour des opérations pendant qu\'elles mettent à jour. + Nouvelle adresse unifiée + Installer + Version %s + Mise à jour disponible + Pour éviter tout problème et améliorer votre expérience d\'utilisateur, nous vous recommandons vivement d\'installer les mises à jour récentes dès que possible. + Mise à jour critique + Dernière + Des nouvelles fonctionnalités étonnantes sont disponibles pour le portefeuille Pezkuwi ! Assurez-vous de mettre à jour votre application pour y accéder + Mise à jour importante + Critique + Majeur + Voir toutes les mises à jour disponibles + Nom + Nom du portefeuille + Ce nom ne sera affiché que pour vous et sera stocké localement sur votre appareil mobile. + Ce compte n’est pas élu par le réseau pour participer à l’ère actuelle + Revoter + Voter + Statut du scrutin + Votre carte est en cours de financement ! + Votre carte est en cours d\'émission ! + Cela peut prendre jusqu\'à 5 minutes.\nCette fenêtre se fermera automatiquement. + Estimé %s + Acheter + Acheter/Vendre + Acheter des jetons + Acheter avec + Recevoir + Recevoir %s + Envoyez uniquement le token %1$s et les tokens dans le réseau %2$s à cette adresse, ou vous pourriez perdre vos fonds + Vendre + Vendre des jetons + Envoyer + Échanger + Actifs + Vos actifs apparaîtront ici. \n Assurez-vous que le filtre \"Masquer les soldes nuls\" est désactivé + Valeur des actifs + Disponible + Mis en jeu + Détails du solde + Solde total + Total après transfert + Gelé + Verrouillé + Remboursable + Réservé + Transférable + Désengagement + Portefeuille + Nouvelle connexion + + Compte %s manquant. Ajoutez le compte au portefeuille dans les paramètres + %s comptes manquants. Ajoutez les comptes au portefeuille dans les paramètres + + Certains des réseaux requis demandés par \\"%s\\" ne sont pas pris en charge dans Pezkuwi Wallet + Les sessions Wallet Connect apparaîtront ici + WalletConnect + dApp inconnu + + %s réseau non pris en charge est masqué + %s réseaux non pris en charge sont masqués + + WalletConnect v2 + Transfert inter-chaînes + Cryptomonnaies + Monnaies fiduciaires + Monnaies fiduciaires populaires + Monnaie + Détails extrinsèques + Masquer les actifs dont le solde est nul + Autres transactions + Afficher + Récompenses et sabrages + Échanges + Filtres + Transferts + Gérer les actifs + Comment ajouter un portefeuille? + Comment ajouter un portefeuille? + Comment ajouter un portefeuille ? + Exemples de noms : Compte principal, Mon validateur, Dotsama crowdloans, etc. + Partager ce QR avec l\'expéditeur + Laissez l’expéditeur scanner ce code QR + Mon adresse %s pour recevoir %s : + Partager le code QR + Bénéficiaire + Assurez-vous que l\'adresse provient\ndu bon réseau + Le format de l\'adresse n\'est pas valide.\nAssurez-vous que l\'adresse\nappartient au bon réseau + Solde minimal + En raison des restrictions inter-chaînes, vous ne pouvez pas transférer plus de %s + Votre solde n\'est pas suffisant pour payer les frais d\'inter-chaînes de %s.\nSolde restant après le transfert : %s + Des frais inter-chaînes sont ajoutés au montant saisi. Le destinataire peut recevoir une partie des frais inter-chaînes + Confirmer le transfert + Inter-chaînes + Frais inter-chaînes + Votre transfert échouera car le compte bénéficiaire n\'a pas assez de %s pour accepter d\'autres transferts d\'actifs + Le bénéficiaire n\'est pas en mesure d\'accepter le transfert + Votre transfert échouera car le montant final sur le compte de destination sera inférieur au solde minimal. Veuillez essayer d\'augmenter le montant. + Votre transfert retirera le compte du blockstore puisque le solde total sera inférieur au solde minimal. + Votre compte sera supprimé de la blockchain après le transfert car il rend le solde total inférieur au minimum. + Le transfert supprimera le compte + Votre compte sera supprimé de la blockchain après le transfert car le solde total sera inférieur au minimum. Le solde restant sera également transféré au bénéficiaire. + Depuis le réseau + Vous devez avoir au moins %s pour payer ces frais de transaction et rester au-dessus du solde minimum du réseau. Votre solde actuel est : %s. Vous devez ajouter %s à votre solde pour effectuer cette opération. + Moi-même + Sur la chaîne + L\'adresse suivante : %s est connue pour être utilisée dans des activités de phishing, nous ne recommandons donc pas d\'envoyer des jetons à cette adresse. Souhaitez-vous quand même procéder à l\'envoi ? + Alerte à l\'escroquerie + Le destinataire a été bloqué par le propriétaire du jeton et ne peut actuellement pas accepter de transferts entrants + Le destinataire ne peut pas accepter le transfert + Réseau destinataire + Vers le réseau + Envoyer %s depuis + Envoyer %s sur + vers + Expéditeur + Actifs + Envoyer à ce contact + Détails du transfert + %s (%s) + %s adresses pour %s + Pezkuwi a détecté des problèmes d\'intégrité des informations concernant %1$s adresses. Veuillez contacter le propriétaire de %1$s pour résoudre les problèmes d\'intégrité. + Le contrôle d\'intégrité a échoué + Récipient invalide + Aucune adresse valide n\'a été trouvée pour %s sur le réseau %s + %s introuvable + Les services w3n %1$s sont indisponibles. Réessayez plus tard ou entrez manuellement l\'adresse %1$s + Erreur lors de la résolution de w3n + Pezkuwi ne peut pas résoudre le code pour le jeton %s + Le jeton %s n\'est pas encore pris en charge + Hier + La fonction Yield Boost sera désactivée pour les collateurs actuels. Nouveau collateur : %s + Changer le collateur Yield Boosted ? + Votre solde est insuffisant pour payer les frais de réseau de %s et les frais d\'exécution de l\'augmentation de rendement de %s.\nSolde disponible pour payer les frais : %s + Pas assez d\'actifs pour payer les frais de première exécution + Vous ne disposez pas d\'un solde suffisant pour payer les frais de réseau de %s sans passer sous le seuil de %s.\nSolde disponible pour payer les frais : %s + Pas assez d´actifs pour rester au-dessus du seuil + La mise augmente avec le temps + Yield Boost misera automatiquement %s tous mes actifs transférables au-dessus de %s + Rendement boosté + + + Pont DOT ↔ HEZ + Pont DOT ↔ HEZ + Vous envoyez + Vous recevez (estimé) + Taux de change + Frais du pont + Minimum + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Échanger + Les échanges HEZ→DOT sont traités lorsque la liquidité DOT est suffisante. + Solde insuffisant + Montant inférieur au minimum + Entrez le montant + Les échanges HEZ→DOT peuvent avoir une disponibilité limitée selon la liquidité. + Les échanges HEZ→DOT sont temporairement indisponibles. Réessayez lorsque la liquidité DOT sera suffisante. + diff --git a/common/src/main/res/values-hu/strings.xml b/common/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..3690a5c --- /dev/null +++ b/common/src/main/res/values-hu/strings.xml @@ -0,0 +1,2047 @@ + + + Kapcsolat + Github + Adatvédelmi irányelvek + Értékelj minket + Telegram + Általános Szerződési Feltételek + Általános Szerződési Feltételek + Rólunk + Alkalmazás verzió + Weboldal + Érvényes %s cím megadása... + Evm címnek érvényesnek vagy üresnek kell lennie... + Valós Substrate cím megadása... + Fiók hozzáadása + Cím hozzáadása + A fiók már létezik. Kérlek, próbálj meg egy másikat. + Bármely tárca követése cím alapján + Csak nézhető tárca hozzáadása + Írd le a titkos kulcsod és tedd biztonságos helyre + Kérlek, ügyelj arra, hogy a mondatot helyesen és olvashatóan írd le. + %s cím + Nincs fiók a(z) %s láncon. + Mnemonika megerősítése + Ellenőrizd még egyszer + Válaszd ki a szavakat a megfelelő sorrendben + Új fiók létrehozása + Ne használd a vágólapot vagy a képernyőképeket a mobileszközödön, próbálj meg egy biztonságosabb megoldást találni (pl. papír) + A nevet kizárólag az alkalmazásban használjuk. A későbbiekben megváltoztatható. + Tárca nevének megadása + Mnemonika mentése + Hozzon létre egy új tárcát + A mnemonicát a fiókhoz való hozzáférés helyreállítására használják. Jegyezd le, nélküle nem tudjuk helyreállítani a fiókodat! + Fiókok megváltoztatott titkos kulccsal + Elfelejt + A folytatás előtt győződj meg arról, hogy tárcádat már exportáltad. + Tárca felejtése? + Érvénytelen Ethereum származtatási útvonal + Érvénytelen Substrate származtatási útvonal + Ez a tárca %1$s által van párosítva. Bármilyen művelet létrehozható majd Pezkuwi-ban, de %1$s szükséges majd azok alárírásához. + %s nem támogatja + Ez csak egy nézhető tárca, aminek egyenlegét és egyéb információit láthatod Pezkuwi-ban, de semmilyen tranzakciót nem hajthatsz végre róla + Tárca becenév megadása... + Kérlek, próbálj meg egy másikat. + Ethereum kulcspár titkosítási típusa + Ethereum titkos származtatási útvonal + EVM Fiókok + Fiók exportálása + Exportálás + Létező importálása + Ez az adatok titkosításához és a JSON-fájl mentéséhez szükséges. + Új jelszó beállítása + Mentsd le a titkos kulcsod és tedd biztonságos helyre + Írd le a titkos kulcsod és tedd biztonságos helyre + Érvénytelen visszaállítási JSON. Kérlek, győződj meg arról,hogy a JSON fájl évényes adatokat tartalmaz. + Érvénytelen seed. Kérlek, győződj meg arról, hogy a bevitel 64 hexadecimális szimbólumot tartalmaz. + A JSON nem tartalmaz hálózati információkat. Kérlek, add meg az alábbiakban. + Add meg a visszaállítási JSON-t + Általában 12 szavas kifejezés (de lehet 15, 18, 21 vagy 24) + A szavakat külön-külön, egy szóközzel, vessző vagy egyéb jelek nélkül kell beírni. + Írd be a szavakat a megfelelő sorrendben + Jelszó + Privát kulcs importálása + 0xAB + Add meg a nyers seed-et + A Pezkuwi minden alkalmazással kompatibilis + Tárca importálása + Fiók + A származtatási útvonal nem támogatott szimbólumokat tartalmaz vagy nem megfelelő a felépítése + Érvénytelen származtatási útvonal + JSON fájl + Bizonyosodjon meg róla, hogy %s a Ledger eszközére a Ledger Live alkalmazás használatával + Polkadot alkalmazás telepítve van + %s az Ön Ledger eszközén + Nyissa meg a Polkadot alkalmazást + Flex, Stax, Nano X, Nano S Plus + Ledger (Általános Polkadot alkalmazás) + Győzödj meg róla, hogy a hálózati alkalmazás telepítve van azon a Ledger eszközödön, amelyik a Ledger Live-ot használja. Nyisd meg a hálózati alkalmazást a Ledger eszközödön. + Legalább egy fiókot hozzá kell adni + Fiókok hozzáadása a tárcához + Ledger csatlakozási útmutató + Győzödj meg róla, hogy a hálózati alkalmazás telepítve van azon a Ledger eszközödön, amelyik a Ledger Live-ot használja + Hálózati alkalmazás telepítve van + Nyisd meg a hálózati alkalmazást a Ledger eszközödön + Nyissa meg a hálózati alkalmazást + Engedélyezed a Bluetooth-t és helymeghatározást a Pezkuwi Wallet számára + Bluetooth hozzáférést + Válaszd ki a fiókot a hozzáadáshoz + Fiók kiválasztása + %s a telefon beállításaiban + OTG engedélyezése + Ledger csatlakoztatása + Az új, Általános Ledger alkalmazásra történő aláírás és a fiókok migrálása érdekében telepítse és nyissa meg a Migration alkalmazást. A legújabb és Migration Ledger alkalmazásokat nem támogatják a jövőben. + Új Ledger alkalmazás kiadva + Polkadot Migráció + A közeljövőben a Migration alkalmazás nem lesz elérhető. Használja azt fiókjainak az új Ledger alkalmazásra történő migrálásához, hogy elkerülje az alapok elvesztését. + Polkadot + Ledger Nano X + Ledger Legacy + Ha Ledgert használ Bluetooth-on keresztül, engedélyezze a Bluetooth-t mindkét eszközön, és adja meg a Pezkuwi Bluetooth- és helyengedélyeket. USB esetén engedélyezze az OTG-t a telefon beállításaiban. + Kérlek, kapcsold be a Bluetooth-t a telefonodon és a Ledger eszközön. Old fel a Ledger eszközt és nyisd meg a(z) %s alkalmazást. + Válaszd ki Ledger eszközöd + Kérem, engedélyezze a Ledger eszközt. Oldja fel a Ledger eszköz zárolását, és nyissa meg a %s alkalmazást. + Már majdnem kész vagy! 🎉\n Csak koppints az alábbi gombra, hogy befejezd a beállítást, és zökkenőmentesen kezdd el használni a fiókjaidat mind a Polkadot App-ban, mind a Pezkuwi Wallet ben + Üdvözlünk a Pezkuwi-ban! + 12, 15, 18, 21 vagy 24 szavas kifejezés + Többaláírás + Megosztott irányítás (többaláírás) + Nem rendelkezel fiókkal ezen a hálózaton, hozzáadhatsz vagy importálhatsz egyet. + Fiók szükséges + A fiók nem található + Párosítás nyilvános kulccsal + Parity Signer + %s nem támogatja a következő: %s + A következő fiókok sikeresen be lettek olvasva innen: %s + Itt vannak a fiókjaid + Érvénytelen QR-kód. Kérlek, győződj meg arról, hogy a QR-kódot a következőből olvasod be: %s + Ügyelj arra, hogy a legfelsőt válaszd + Nyisd meg a Parity Signer alkalmazást okostelefonodon + Nyissa meg a Parity Signer-t + Menj a \"Keys\" fülre. Válaszd a seed-et, majd a fiókot amit hozzá szeretnél adni a Pezkuwi Wallet-hez. + Navigáljon a „Kulcsok” lapra. Válassza ki a magot, majd a fiókot + A Parity Signer egy QR-kódot fog adni a beolvasáshoz + QR-kód a beolvasáshoz + %s tárca hozzáadása + A(z) %s nem támogat tetszőleges üzenetek aláírását - csak a tranzakciókét. + Az aláírás nem támogatott + QR-kód beolvasása innen: %s + Régi + Új (Vault v7+) + A(z) %s hibát mutat + A QR-kód lejárt + Biztonsági okokból a generált művelet %s-ig érvényes. Kérlek, generálj egy új QR-kódot és írd alá a(z) %s segítségével + A QR-kód %s-ig érvényes + Kérlek, ellenőrizd, hogy a QR-kód az aktuális aláírási művelethez tartozik-e + %s aláírás + Polkadot Vault + Ügyelj arra, hogy a származtatási útvonal nevének üresnek kell lennie + Nyisd meg a Polkadot Vault alkalmazást okostelefonodon + Nyisd meg a Polkadot Vault-ot + %s szeretnéd hozzáadni a Pezkuwi Wallet hez + Érints rá a Származtatott kulcs elemre + A Polkadot Vault egy %s + QR-kód a beolvasáshoz + Érintsd meg az ikont a jobb felső sarokban és válaszd a %s lehetőséget + Privát kulcs exportálása + Privát kulcs + Meghatalmazott + Hozzád delegálva (Proxied) + Bármilyen + Aukció + Proxy megszakító + Kormányzás + Identitás megítélő + Nomináló Pool-ok + Nem átutaló + Stake-elés + Már rendelkezem fiókkal + titkok + 64 hexadecimális szimbólum + Válaszd ki a fizikai tárcát + Válaszd ki a titkos kulcs típusát + Tárca kiválasztása + Fiókok közös titkos kulccsal + Substrate Fiókok + Substrate kulcspár titkosítási típusa + Substrate titkos származtatási útvonal + Tárca neve + Tárca beceneve + Moonbeam, Moonriver és egyéb hálózatok + EVM-cím (opcionális) + Előre beállított tárcák + Polkadot, Kusama, Karura, KILT és még 50+ hálózat + Kövess nyomon bármilyen tárcát anélkül, hogy a megadnád a privát kulcsodat Pezkuwi Wallet-be + A tárcád csak egy nézhető tárca, ami azt jelenti, hogy nem kezdeményezhetsz rajta semmilyen műveletet + Hoppá! Hiányzik a kulcs + csak nézhető + Polkadot Vault, Ledger vagy Parity Signer használata + Hardver tárca csatlakoztatása + Használd a Trust Wallet fiókjaidat a Novában + Trust Wallet + %s fiók hozzáadása + Tárca hozzáadása + %s fiók módosítása + Fiók módosítása + Ledger (Örökség) + Önnek delegált + Megosztott irányítás + Egyéni csomópont hozzáadása + Hozzá kell adj egy %s fiókot a tárcádhoz, ahhoz hogy delegálhass + Adja meg a hálózati adatokat + Delegálás ide + Delegáló fiók + Delegáló tárca + Hozzáférés típusa + A letét a számládon marad a proxy eltávolításáig. + Elérted a felvehető proxy-k számát, ami %s a %s láncon. Törölj proxy-kat, hogy újakat vehess fel. + A proxy-k száma elérte a maximumot + Itt jelennek meg\na hozzáadott egyéni hálózatok + +%d + A Pezkuwi automatikusan átváltotta a multiszignós tárcájára, így megtekintheti a függőben lévő tranzakciókat. + Színes + Megjelenés + token ikonjai + Fehér + A megadott szerződési cím már %s tokenként szerepel a Novában. + A megadott szerződési cím már %s tokenként fel van véve Pezkuwi-ba. Biztosan módosítani szeretnéd? + A token már hozzá van adva + Győzödj meg róla, hogy a megadott URL formátum a következő: www.coingecko.com/en/coins/tether. + Érvénytelen CoinGecko hivatkozás + A megadott szerződési cím nem egy %s ERC-20-as szerződés. + Érvénytelen szerződési cím + A tizedeseknek legalább 0-nak kell lenniük, és nem lehetnek többek, mint 36. + Érvénytelen tizedesjegy + Szerződési cím megadása + Tizedesjegyek megadása + Szimbólum megadása + Ugrás a(z) %s + Kezdve %1$s, a %2$s egyenleged, Staking és Kormányzás a(z) %3$s-n lesz — javított teljesítménnyel és alacsonyabb költségekkel. + A(z) %s tokened most a(z) %s-n + Hálózatok + Tokenek + Token hozzáadása + Szerződés cím + Tizedesjegyek + Szimbólum + ERC-20 token részleteinek megadása + Válaszd ki a hálózatot az ERC-20 token hozzáadásához + Közösségi hitelek + Kormányzás v1 + OpenGov + Választások + Stake-elés + Vesting + Tokenek vásárlása + Visszakaptad DOT-odat a közösségi hitelekből? Kezd el stake-elni még ma, hogy megkapd a maximális jutalmakat. + Maximalizálja DOT jutalmait 🚀 + Tokenek szűrése + Nincsenek tokenjeid ajándékozni.\nVásárolj vagy helyezz el tokeneket a számládon. + Minden hálózat + Tokenek kezelése + Ne küldj %s-ot a Ledger által vezérelt fiókra, mivel az nem támogatja a(z) %s küldését. emiatt ezen a fiókon fognak ragadni + A Ledger nem támogatja ezt a tokent + Keresés hálózat vagy token alapján + Nem található hálózat vagy token\na megadott névvel + Keresés token alapján + Tárcáid + Nincs elegendő tokened a küldéshez.\nVásárolj vagy Fogadj tokent\nfiókodra. + Token a fizetéshez + Fogadandó token + A változtatások végrehajtása előtt %s a módosított és eltávolított pénztárcákhoz! + biztosítsd, hogy megmentetted a titkos kifejezéseket + Alkalmazza a Biztonsági Másolat Frissítéseit? + Készülj fel a pénztárcád mentésére! + Ez a titkos kifejezés teljes és állandó hozzáférést biztosít minden összekapcsolt pénztárcához és az azokban lévő pénzhez.\n%s + NE OSZD MEG. + Ne írd be a titkos kifejezésedet semmilyen formába vagy weboldalra.\n%s + A PÉNZEK ÖRÖKRE ELVESZHETNEK. + Az ügyfélszolgálat vagy az adminok soha, semmilyen körülmények között nem fogják kérni a titkos kifejezésedet.\n%s + VIGYÁZZ AZ IMITÁTOROKRA. + Áttekintés és elfogadás a folytatáshoz + Biztonsági mentés törlése + Kézzel is készíthet biztonsági másolatot a jelmondatáról, hogy biztosítsa a hozzáférést a pénztárcájának pénzeszközeihez, ha elveszíti a hozzáférést ehhez az eszközhöz. + Biztonsági mentés kézzel + Kézi + Nem adott hozzá pénztárcát jelmondattal. + Nincs pénztárca a biztonsági mentéshez + Beállíthatja a Google Drive biztonsági mentéseket, hogy titkosított másolatokat tároljon az összes pénztárcájáról, amelyeket egy jelszó véd, amit Ön állít be. + Biztonsági mentés a Google Drive-ra + Google Drive + Biztonsági mentés + Biztonsági mentés + Közvetlen és hatékony KYC folyamat + Biometrikus hitelesítés + Összes bezárása + Kérlek várj, a vásárlás megkezdődött, ami eltarthat akár 60 percig is. Az állapotot nyomon követheted az e-mailben. + Válasszon hálózatot a vásárláshoz %s + Vásárlás elindítva! Kérjük, várjon legfeljebb 60 percig. A státuszt nyomon követheti az e-mailben. + Egyik szolgáltatónk sem támogatja jelenleg ennek a tokennek a vásárlását. Kérjük, válasszon másik tokent, másik hálózatot, vagy térjen vissza később. + Ez a token nem támogatott a vásárlás funkció által + Díjak kifizetése bármilyen tokennel lehetséges + A migráció automatikusan történik, nincs szükség műveletekre + A régi tranzakcióelőzmények megmaradnak a(z) %s rendszeren + A %1$s %2$s egyenlegtől kezdve a Stakelés és a Kormányzás %3$s érhetők el. Ezek a funkciók akár 24 óráig is elérhetetlenek lehetnek. + %1$sx alacsonyabb tranzakciós költségek\n(%2$s-ról %3$s-ra) + %1$sx csökkenés a minimális egyenlegben\n(%2$s-ról %3$s-ra) + Miért nagyszerű az Asset Hub? + A %1$s kezdő %2$s egyenlegetekkel, a Stakelés és a Kormányzás a(z) %3$s-n van + További token támogatás: %s, és más ökoszisztéma tokenek + Egységes hozzáférés %s-hoz, eszközökhöz, staking-hez és kormányzáshoz + Automatikus csomópontok egyensúlyozása + Kapcsolat engedélyezése + Kérjük, adjon meg egy jelszót, amely a pénztárcái felhőből történő visszaállításához lesz használva. Ezt a jelszót a jövőben nem lehet visszaállítani, ezért feltétlenül jegyezze meg! + Biztonsági mentés jelszavának frissítése + Eszköz + Elérhető egyenleg + Sajnáljuk, de az egyenleget nem tudtuk leellenőrizni. Kérlek, próbáld meg újra később. + Sajnáljuk, de nem tudunk kapcsolatba lépni az átutalási szolgáltatóval. Kérlek, próbáld meg újra később. + Sajnáljuk, de egyenleged nem elegendő a megadott összeg küldéséhez. + Átutalási díj + A hálózat nem elérhető + Ide + Hoppá, az ajándék már be lett igényelve + Az ajándék nem fogadható el + Igényeld az ajándékot + Lehet, hogy problémát észleltünk a szerverrel. Kérlek, próbáld újra később. + Hoppá, valami baj történt + Használj másik pénztárcát, hozz létre egy újat, vagy adj hozzá egy %s fiókot ehhez a pénztárcához a Beállításokban. + Sikeresen igényeltél egy ajándékot. A tokenek hamarosan megjelennek az egyenlegeden. + Kaptál egy kripto ajándékot! + Hozz létre egy új pénztárcát, vagy importálj egy meglévőt az ajándék igényléséhez + Nem fogadhatsz ajándékot egy %s pénztárcával + Az összes megnyitott fül a DApp böngészőben be lesz zárva. + Összes DApp bezárása? + %s és ne felejtse el mindig offline módban tartani őket, hogy bármikor visszaállíthassa őket. Ezt megteheti a Biztonsági Mentés Beállításokban. + Kérjük, írja le az összes pénztárca jelszót, mielőtt folytatná + A biztonsági mentés törölve lesz a Google Drive-ból + Az aktuális biztonsági mentés a pénztárcáival véglegesen törölve lesz! + Biztosan törölni szeretné az Felhőalapú Biztonsági Mentést? + Biztonsági mentés törlése + Jelenleg a biztonsági mentése nincs szinkronizálva. Kérjük, tekintse meg ezeket a frissítéseket. + Felhőbiztonsági mentés változásai észlelve + Frissítések áttekintése + Ha nem jegyezte fel manuálisan a pénztárcák jelszavát, amelyeket el fognak távolítani, akkor ezek a pénztárcák és minden eszközük véglegesen és visszavonhatatlanul el fognak veszni. + Biztosan alkalmazni akarja ezeket a változásokat? + Probléma áttekintése + Jelenleg a biztonsági mentése nincs szinkronizálva. Kérjük, tekintse meg a problémát. + A pénztárca változásai nem frissültek a felhőbiztonsági mentésben + Kérjük, győződjön meg róla, hogy be van jelentkezve a Google-fiókjába a megfelelő hitelesítő adatokkal, és engedélyezte a Pezkuwi Wallet számára a Google Drive elérését. + Google Drive hitelesítés sikertelen + Nincs elegendő szabad Google Drive tárhelye. + Nincs elegendő tárhely + Sajnos a Google Drive nem működik a Google Play szolgáltatások nélkül, amelyek hiányoznak az eszközéről. Próbáljon meg hozzáférést szerezni a Google Play szolgáltatásokhoz. + Google Play szolgáltatások nem találhatók + Nem sikerült biztonsági mentést készíteni a pénztárcáiról a Google Drive-ra. Kérjük, ellenőrizze, hogy engedélyezte-e a Pezkuwi Wallet számára a Google Drive használatát, és hogy van-e elegendő szabad tárhelye, majd próbálja újra. + Google Drive hiba + Kérjük, ellenőrizze a jelszó helyességét, és próbálja újra. + Érvénytelen jelszó + Sajnálattal közöljük, hogy nem találtunk biztonsági mentést a pénztárcák visszaállításához + Nincs talált biztonsági mentés + Győződjön meg arról, hogy elmentette a pénztárca előkészítő kódját, mielőtt folytatná. + A pénztárca törölve lesz a felhőalapú biztonsági mentésből + Biztonsági mentés hiba átnézése + Biztonsági mentés frissítés átnézése + Biztonsági mentés jelszó beírása + Engedélyezze a pénztárcák biztonsági mentését a Google Drive-ra + Utolsó szinkronizálás: %s ekkor: %s + Jelentkezzen be a Google Drive-ra + Google Drive probléma átnézése + Biztonsági mentés letiltva + Biztonsági mentés szinkronizálva + Biztonsági mentés szinkronizálása... + Biztonsági mentés nincs szinkronizálva + Új pénztárcák automatikusan hozzáadódnak a felhőalapú biztonsági mentéshez. Kikapcsolhatja a felhőalapú biztonsági mentést a beállításokban. + A pénztárcák változásai frissítve lesznek a felhőalapú biztonsági mentésben + Feltételek elfogadása... + Fiók + Fiók címe + Aktív + Hozzáadás + Delegálás hozzáadása + Hálózat hozzáadása + Cím + Haladó + Mind + Engedélyez + Összeg + Az összeg túl kevés + Az összeg túl nagy + Alkalmazott + Alkalmaz + Újra kérdezés + Figyelem! + Elérhető: %s + Átlag + Egyenleg + Bónusz + Hívás + Hívás adatok + Hívás hash + Mégsem + Biztosan megszakítja ezt a műveletet? + Sajnáljuk, de az alkalmaás nem megfelelő a kérés végrehajtásához. + A hivatkozás nem nyitható meg + Legfeljebb %s használható fel, mivel ki kell fizetned %s hálózati díjat. + Lánc + Változtatás + Jelszó megváltoztatása + Automatikus folytatás a jövőben + Hálózat kiválasztása + Törlés + Bezárás + Biztos, hogy be akarod záni a képernyőt?\nA módosításaid el fognak veszni. + Felhő biztonsági mentés + Befejezett + Befejezett ( %s ) + Megerősít + Megerősítés + Biztos vagy benne? + Megerősítve + %d ms + csatlakozás... + Kérjük, ellenőrizze a kapcsolódását vagy próbálkozzon később + Kapcsolat sikertelen + Folytatás + Vágólapra másolva + Cím másolása + Hívás adatok másolása + Hash másolása + Id másolása + Kulcspár titkosítási típusa + Dátum + %s és %s + Törlés + Letétbe helyező + Részletek + Letiltva + Kapcsolat bontása + Ne zárja be az alkalmazást! + Kész + A Pezkuwi előre szimulálja a tranzakciót a hibák megelőzése érdekében. Ez a szimuláció nem sikerült. Próbálja újra később vagy nagyobb összeggel. Ha a probléma továbbra is fennáll, kérjük, vegye fel a kapcsolatot a Pezkuwi Wallet Támogatással a Beállításokban. + A tranzakciós szimuláció nem sikerült + Szerkeszt + %s (további +%s) + Válaszd ki az e-mail alkalmazást + Engedélyez + Cím megadása... + Összeg megadása... + Adja meg az adatokat + Adj meg másik összeget + Adja meg a jelszót + Hiba + Nincs elegendő token + Esemény + EVM + EVM cím + A fiókod el lesz távolítva a blokkláncról a művelet után, mivel a fennmaradó egyenleg kevesebb lesz, mint a minimum + A művelet el fogja távolítani a fiókot + Lejárt + Felfedezés + Sikertelen + A becsült hálózati díj %s, ami jóval magasabb, mint az alapértelmezett hálózati díj (%s). Ennek oka lehet az ideiglenes hálózati túlterheltség. Frissítheted, hogy egy alacsonyabb hálózati díjat kaphass. + A hálózati díj túl magas + Díj: %s + Rendezés: + Szűrők + További információ + Elfelejtette a jelszót? + + naponta + %s naponta + + naponta + minden nap + Minden részlet + %s beszerzése + Ajándék + Értem + Kormányzás + Hexadecimális szöveg + Elrejt + + %d óra + %d óra + + Hogyan működik + Megértettem + Információ + A QR-kód érvénytelen + Érvénytelen QR-kód + Tudj meg többet + További információ + Ledger + %s van vissza + Pénztárcák kezelése + Maximum + %s maximum + + perc + perc + + A(z) %s fiók hiányzik + Módosít + Modul + Név + Hálózat + Ethereum + %s nem támogatott + Polkadot + Hálózatok + + Hálózat + Hálózat + + Következő + Nem + A fájlimportáló alkalmazás nem található az eszközön. Kérlek, telepítés után próbáld újra + Nem található megfelelő alkalmazás a szándék kezelésére az eszközön + Nincs változás + Meg fogjuk mutatni a mnemonikus jelmondatodat. Győződj meg róla, hogy senki sem láthatja a képernyődet és ne készíts róla képernyőképet — ezeket harmadik féltől származó rosszindulatú programok begyűjthetik. + Egyik sem + Nem elérhető + Sajnáljuk, de egyenleged nem elegendő a hálózati díj kifizetéséhez. + Fedezethiány + Nincs elegendő egyenlege a hálózati díj %s fizetésére. Aktuális egyenleg %s + Most nem + Ki + OK + Oké, vissza + Be + Folyamatban lévő + Opcionális + Lehetőségek választása + Jelszó kifejezés + Beillesztés + / év + %s / év + évente + %% + A képernyő használatához szükség van a kért engedélyekre. A beállításokban be tudod őket kapcsolni. + Hozzáférés megtagadva + A képernyő használatához szükség van a kért engedélyekre. + Szükséges engedélyek + Ár + Adatvédelmi irányelvek + Folytatás + Proxy letét + Hozzáférés visszavonása + Push értesítések + Bővebben + Ajánlott + Frissítési díj + Elutasít + Eltávolít + Kötelező + Visszaállítás + Újra + Hiba történt. Kérlek, próbáld meg újra. + Visszavon + Mentés + QR-kód beolvasása + Keresés + Itt fog megjelenni a keresés eredménye + Keresési eredmények: %d + mp + + %d másodperc + %d másodpercek + + Titkos származtatási útvonal + Összes megtekintése + Válassz tokent + Beállítások + Megosztás + Hívás adatok megosztása + Hash megosztása + Megjelenít + Bejelentkezés + Aláírási kérelem + Aláíró + Érvénytelen aláírás + Kihagyás + Folyamat kihagyása + Hiba történt néhány tranzakció elküldésekor. Szeretnéd újra megpróbálni? + Néhány tranzakció elküldése nem sikerült + Hiba történt + Rendezés + Állapot + Substrate + Substrate cím + Koppintson a megjelenítéshez + Általános Szerződési Feltételek + Teszt hálózat + Hátralévő idő + Cím + Beállítások megnyitása + Az egyenleged túl kevés + Összes + Teljes díj + Tranzakció ID + Tranzakció elküldve + Próbálja újra + Típus + Kérlek, próbálkozz újra. Amennyiben a hiba ismét megjelenik, fordulj ügyfélszolgálatunkhoz. + Ismeretlen + + %s nem támogatott + %s nem támogatott + + Korlátlan + Frissítés + Használ + Használja a maximumot + A címzettnek érvényes %s címnek kell lennie + Érvénytelen címzett + Nézet + Várakozik + Tárca + Figyelmeztetés + Igen + Az ajándékod + Az összegnek pozitívnak kell lennie + Kérjük, adja meg a jelszót, amit a biztonsági mentés során létrehozott + Írja be az aktuális biztonsági mentés jelszavát + A megerősítés áthelyezi a tokeneket a számládról + Válassza ki a szavakat... + Érvénytelen titkos kifejezés, kérjük ellenőrizze még egyszer a szavak sorrendjét + Referendum + Szavazás + Kategória + Ez a csomópont már létezik. Kérlek, próbálj meg egy másikat. + A csomópont nem elérhető. Kérlek, próbálj meg egy másikat. + Sajnos a hálózat nem támogatott. Kérlek, próbáld meg a következők egyikét: %s. + A(z) %s törlésének megerősítése. + Hálózat törlése? + Kérlek, ellenőrizd a kapcsolatot, vagy próbáld meg újra később + Egyéni + Alapértelmezett + Hálózatok + Kapcsolat hozzáadása + QR-kód beolvasása + Gond merült fel a biztonsági mentésével. Lehetősége van törölni a jelenlegi biztonsági mentést, és létrehozni egy újat. %s mielőtt folytatná. + Győződjön meg róla, hogy minden pénztárcához elmentette a titkos kifejezéseket + Biztonsági mentés található, de üres vagy sérült + A jövőben, a biztonsági mentés jelszava nélkül nem lehet helyreállítani a pénztárcákat a felhő alapú mentésből.\n%s + Ez a jelszó nem helyreállítható. + Emlékezz a Biztonsági Mentés Jelszavára + Jelszó megerősítése + Biztonsági mentés jelszava + Betűk + Min. 8 karakter + Számok + Jelszavak egyeznek + Kérjük, adjon meg egy jelszót a biztonsági mentés bárhogyan történő eléréséhez. A jelszó nem állítható helyre, ezért biztosan emlékezzen rá! + Hozza létre a biztonsági mentés jelszavát + A bevitt láncazonosító nem egyezik az RPC URL hálózatával. + Érvénytelen Láncazonosító + A privát közösségi hitelek még nem támogatottak. + Privát közösségi hitel + A közösségi hitelekről + Közvetlen + Tudj meg többet az Acala-nak nyújtott különféle hozzájárulásokról + Likvid + Aktív ( %s ) + Elfogadom az Általános Szerződési Feltételeket + Pezkuwi Wallet bónusz (%s) + Az Astar ajánlói kódnak egy valós Polkadot címnek kell lennie. + Nem tudod a kiválasztott összeget hozzáadni, mert azzal a közösségi hitel túllépné a felső határát. A maximum megengedett keret: %s. + Nem tudsz hozzájárulni a kiválasztott közösségi hitelhez, mivel az már elérte a felső határt. + A közösségi hitel túllépte a felső határt + Hozzájárulás a közösségi hitelekhez + Hozzájárulás + Hozzájárulásai: %s + Likvid + Parallel + Itt fognak megjelenni\n a hozzájárulásaid + Vissza kerül %s múlva + A parachain által visszaküldendő + %s (%s-on) + Közösségi hitelek + Szerezz különleges jutalmat. + Itt fognak megjelenni a közösségi hitelek + Nem tudsz hozzájárulni a kiválasztott közösségi hitelhez, mivel már véget ért. + A közösségi hitel véget ért + Add meg az ajánlói kódodat + Közösségi hitel információ + Ismerd meg a(z) %s közösségi hitelt + %s közösségi hitel weboldala + Lízing időszak + Válassz parachain-t, hogy hozzájárulj %s tokeneddel. Felajánlott tokenjeidet vissza fogod kapni, illetve, ha a parachain elnyeri a helyet, az aukció végén további jutalmakat is kapni fogsz. + A hozzájáruláshoz hozzá kell adnod egy %s fiókot a tárcádhoz + Bónusz alkalmazása + Amennyiben nem rendelkezel ajánlói kóddal, használd a Pezkuwi ajánlókódot, hogy bónuszt kaphass hozzájárulásodért + Nem adtál hozzá bónuszt. + A Moonbeam közösségi hitel csak a SR25519 vagy ED25519 kriptotípusú számlákat támogatja. Kérlek, egy másik számlád használj ha hozzá szeretné járulásni. + Ezzel a fiókkal nem lehet hozzájárulni + Add hozzá Moonbeam fiókodat tárcádhoz, hogy részt vehess a Moonbeam közösségi hitelekben. + A Moonbeam fiók hiányzik + Ez a közösségi hitel nem elérhető a tartózkodási helyeden. + A régiód nem támogatott + %s jutalom célhelye + Megállapodás benyújtása + A folytatáshoz a blokkláncon kell benyújtanod az Általános Szerződési Feltételekhez való hozzájárulást. Ezt csak egyszer kell megtenned az összes további Moonbeam hozzájárulás esetében. + Elolvastam és elfogadom az Általános Szerződési Feltételeket + Összegyűlt + Ajánlói kód + Hibás ajánlói kód. Kérlek, próbálj meg egy másikat. + %s Általános Szerződési Feltételei + A hozzájárulás minimálisan megengedett összege %s. + A hozzájárulás összege túl alacsony + %s tokenjeid a lízingidőszak lejárta után kerülnek vissza. + Hozzájárulásaid + Összegyűlt: %s / %s + A megadott RPC URL már szerepel a Pezkuwi-ban mint %s egyéni hálózat. Biztosan módosítani szeretné? + https://networkscan.io + Blokk böngésző URL (Opcionális) + 012345 + Láncazonosító + TOKEN + Pénznem szimbóluma + Hálózat neve + Csomópont hozzáadása + Felhasználói csomópont hozzáadása a + Adataok megadása + Mentés + Felhasználói csomópont szerkesztése a + Név + Csomópont neve + wss:// + Csomópont URL + RPC URL + DApp-ok, amelyeknek hozzáférést biztosítottál a címed megtekintéséhez, amikor használod őket + \"%s\" el lesz távolítva az engedélyezett DApp-ok közül + Eltávolítod az engedélyezettek közül? + Engedélyezett DApp-ok + Katalógus + Engedélyezd a kérést, ha megbízol ebben az alkalmazásban + Engedélyezed a(z) \"%s\" hozzáférjen a fiók címeidhez? + Csak akkor engedélyezd a kérést, ha megbízol ebben az alkalmazásban.\nEllenőrizd a tranzakció részleteit. + DApp + DApps + %d DApp + Kedvencek + Kedvencek + Hozzáadás a kedvencekhez + A „%s” DApp el lesz távolítva a Kedvencek közül + Eltávolítod a kedvencek közül? + Itt fog megjelenni a DApp-ok listája + Hozzáadás a kedvencekhez + Asztali mód + Eltávolítás a kedvencekből + Oldalbeállítások + Oké, hagyjuk + A Pezkuwi Wallet úgy véli, hogy ez a weboldal veszélyt jelenthet fiókodra és tokenjeidre. + Adathalászat észlelve + Keresés névvel vagy URL-vel + Nem támogatott lánc, %s genesis hash-el + Győződj meg arról, hogy a művelet helyes + Nem sikerült aláírni a kért műveletet + Mégis megnyitás + A rosszindulatú DAppok ki tudják vonni az összes pénzét. Mindig végezzen saját kutatást, mielőtt DAppot használ, engedélyt ad, vagy pénzt küld el.\n\nHa valaki sürgeti, hogy látogassa meg ezt a DAppot, valószínűleg átverés. Ha kétségei vannak, kérjük, vegye fel a kapcsolatot a Pezkuwi Wallet támogatással: %s. + Figyelem! A DApp ismeretlen + A lánc nem található + %s hivatkozásról származó domain nem engedélyezett + Nem definiált kormányzási típus + Nem támogatott kormányzási típus + Érvénytelen kriptotípus + Érvénytelen származtatási útvonal + Érvénytelen mnemonika + Érvénytelen URL + A megadott RPC URL már szerepel a Pezkuwi-ban mint %s hálózat. + Alapértelmezett Értesítési Csatorna + +%d + Keresés cím vagy név alapján + Nem megfelelő cím formátum. Győzödj meg róla, hogy a cím a megfelelő hálózathoz tartozik. + keresési eredmények: %d + Meghatalmazott és Többaláírásos fiókok automatikusan észlelve és rendszerezve Önnek. Bármikor kezelheti a Beállításokban. + A pénztárca listája frissült + Összes szavazat + Delegálás + Minden fiók + Egyének + Szervezetek + A delegálás visszavonásának időszaka, a delegálás visszavonása után kezdődik + Szavazataid automatikusan a delegáltak szavazataival együtt fognak szavazni + Delegált információ + Egyéni + Szervezet + Delegált szavazatok + Delegációk + Delegálás szerkesztése + Nem delegálhatsz saját magadnak, kérlek, válassz egy másik címet + Nem delegálhatsz saját magadnak + Mesélj magadról, hogy a Pezkuwi felhasználók jobban megismerhessenek. + Delegált vagy? + Mutasd be magad + %s kategórián át + Utolsó %s szavazatai + Szavazataid a(z) %s által + Szavazataid: %s a(z) %s által + Szavazatok eltávolítása + Delegálás visszavonása + A delegálás feloldási időszak lejárata után fel kell oldanod a tokenjeidet. + Delegált szavazatok + Delegációk + Utolsó %s szavazatai + Kategóriák + Mindet kiválaszt + Legalább 1 kategóriát kell kiválasztani... + Nincsenek delegálható kategóriák + Fellowship + Kormányzás + Kincstár + Delegálás visszavonási időszakasz + Már nem érvényes + Delegálásod + Delegálásaid + Mutat + Biztonsági másolat törlése... + A biztonsági másolat jelszava korábban frissült. A Felhőbiztonsági mentés folytatása érdekében, %s + kérjük, adja meg az új biztonsági mentési jelszót. + A biztonsági másolat jelszava megváltozott + Nem tudsz tranzakciókat aláírni letiltott hálózatok esetén. Engedélyezd a %s beállításokban, és próbálkozz újra + %s le van tiltva + Már delegálsz a következő fiókhoz: %s + A delegálás már létezik + (BTC/ETH kompatibilis) + ECDSA + ed25519 (alternatív) + Edwards + Biztonsági mentés jelszó + Adja meg a hívás adatokat + Nincs elegendő token a díj megfizetéséhez + Szerződés + Szerződés hívás + Funkció + Tárcák helyreállítása + %s Az összes tárcád biztonságosan el lesz mentve a Google Drive-ra. + Szeretnéd visszaállítani a tárcáidat? + Meglévő felhőmentés található + Restore JSON letöltése + Jelszó megerősítése + A jelszavak nem egyeznek + Jelszó beállítása + Hálózat: %s\nMnemonikus: %s\nSzármaztatott útvonal: %s + Hálózat: %s\nMnemonikus: %s + Kérlek, várj a díj kiszámításáig + A díjszámítás folyamatban van + Bankkártya kezelése + %s token eladása + Delegálás megadása %s stake-eléshez + Váltás részletei + Max: + Fizetendő + Fogadandó + Token kiválasztása + Töltsd fel a kártyát %s-val/vel + Kérjük, vedd fel a kapcsolatot a support@pezkuwichain.io címen. Add meg az e-mail címed, amelyet használtál a kártya kiállításához. + Lépj kapcsolatba a támogatással + Igényelve + Létrehozva: %s + Adja meg az összeget + A minimum ajándékérték %s + Visszakövetelve + Válassz egy tokent ajándékként + Hálózati díj igényléskor + Ajándék Létrehozása + Küldj ajándékokat gyorsan, egyszerűen és biztonságosan a Pezkuwi segítségével + Küldj Kripto Ajándékot Bárkinek, Bárhol + Létrehozott ajándékaid + Válassz hálózatot a(z) %s ajándékhoz + Adja meg ajándékának összegét + %s linkként és hívd meg bárkit a Novához + Oszd meg közvetlenül az ajándékot + %s, és bármikor visszakérheted a fel nem használt ajándékokat erről az eszközről + Az ajándék azonnal elérhető + Kormányzási Értesítési Csatorna + + Válassz legalább %d kategóriát + Válassz legalább %d kategóriát + + Felold + Ennek a csereügyletnek a végrehajtása jelentős csúszást és pénzügyi veszteségeket eredményez. Fontolja meg a kereskedés méretének csökkentését vagy a kereskedés több tranzakcióra osztását. + Magas árhatás észlelve (%s) + Előzmények + E-mail + Hivatalos név + Elem neve + Identitás + Web + A megadott JSON fájlt egy másik hálozaton hozták létre. + Kérlek, győződj meg arról, hogy a megadott egy érvényes JSON fájl. + A visszaállítási JSON érvénytelen + Kérlek, ellenőrizd a jelszó helyességét, és próbáld meg újra. + Sikertelen kulcstár visszafejtés + JSON beillesztése + Nem támogatott titkosítási típus + A Substrate titkos kulccsal rendelkező fiókot nem lehet Ethereum titkosítással importálni a hálózatba + Az Ethereum titkos kulccsal rendelkező fiókot nem lehet Substrate titkosítással importálni a hálózatba + A mnemonikád érvénytelen + Kérlek, győződj meg arról, hogy a megadott szöveg 64 hexadecimális szimbólumot tartalmaz. + A seed érvénytelen + Sajnáljuk, a tárcáidat tartalmazó biztonsági mentés nincs meg. + Biztonsági mentés nem található + Tárcák helyreállítása a Google Drive-ról + Használd a 12, 15, 18, 21 vagy 24 szóból álló kifejezésedet + Válaszd ki, hogyan szeretnéd importálni a tárcádat + Csak megfigyelés + Integráld a fejlesztés alatt álló hálózat összes funkcióját a Pezkuwi Wallet be, hogy mindenki számára elérhető legyen. + Integráld a hálózatod + Fejlesztés Polkadot számára? + A megadott hívás adatai érvénytelenek vagy hibás formátumúak. Kérjük, ellenőrizze, hogy helyesek-e, és próbálja újra. + Ez a hívás adatkészlet egy másik művelethez, hívás hash: %s + Érvénytelen hívás adatok + A proxy címnek érvényes %s címnek kell lennie + Érvénytelen proxy cím + A megadott pénznem szimbóluma (%1$s) nem egyezik meg a hálózattal (%2$s). Használni szeretnéd a helyes pénznem szimbólumot? + Érvénytelen pénznemszimbólum + A QR-kód nem dekódolható + QR-kód + Feltöltés a galériából + JSON fájl exportálása + Nyelv + A Ledger nem támogatja a következőt: %s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + A műveletet az eszköz megszakította. Győzödj meg róla, hogy feloldottad a Ledger eszközöd. + Művelet megszakítva + Nyísd meg a(z) %s alkalmazást a Ledger eszközödön + A %s alkalmazás nem indult el + A művelet hibával végződött. Kérlek, próbáld meg újra később. + Ledger műveleti hiba + Tartsa lenyomva a megerősítő gombot a %s készülékén a tranzakció jóváhagyásához + További fiókok betöltése + Ellenőrzés és jóváhagyás + Fiók %s + Fiók nem található + A Ledger eszközöd régi általános alkalmazást használ, amely nem támogatja az EVM címeket. Frissítsd a Ledger Live-on keresztül. + Ledger General App frissítése + Nyomd le mindkét gombot a %s eszközön a tranzakció jóváhagyásához + Kérlek, frissítsd a %s-ot a Ledger Live-val + A metaadatok elavultak + A Ledger nem támogat tetszőleges üzenetek aláírását - csak a tranzakciókét. + Kérlek, ellenőrizd, hogy a megfelelő Ledger eszközt választottad az aktuális művelethez + Biztonsági okokból a generált művelet %s-ig érvényes. Kérlek, próbáld újra és hagyd jóvá Ledger-vel + A tranzakció lejárt + A tranzakció érvényes: %s + A Ledger nem támogatja ezt a tranzakciót. + A tranzakció nem támogatott + Nyomja meg mindkét gombot a %s készülékén a cím jóváhagyásához + Nyomja meg a megerősítő gombot a %s készülékén a cím jóváhagyásához + Nyomd meg mindkét gombot a %s eszközödön a címek jóváhagyásához + Nyomd meg a megerősítés gombot a %s eszközödön a címek jóváhagyásához + Ez a tárca a Ledger-rel van párosítva. A Pezkuwi segít bármilyen művelet létrehozásában és azok aláírása a Ledger segítségével történik majd. + Válaszd ki a fiókot a hozzáadáshoz + Hálózati információ betöltése... + Tranzakció részleteinek betöltése… + Biztonsági mentés keresése... + Kifizetési tranzakció elküldve + Token hozzáadása + Biztonsági mentés kezelése + Hálózat törlése + Hálózat szerkesztése + Hozzáadott hálózat kezelése + Nem fogja tudni megtekinteni token egyenlegeit azon a hálózaton az Eszközök képernyőn + Hálózat törlése? + Csomópont törlése + Csomópont szerkesztése + Hozzáadott csomópont kezelése + Csomópont \"%s\" törölve lesz + Csomópont törlése? + Egyedi kulcs + Alapértelmezett kulcs + Ne osszon meg senkivel semmilyen információt - Ha ezt megteszi, véglegesen és visszavonhatatlanul elveszíti minden eszközét + Fiókok egyedi kulccsal + Alapértelmezett fiókok + %s, +%d egyéb + Fiókok alapértelmezett kulccsal + Válassza ki a mentendő kulcsot + Válasszon ki egy pénztárcát a mentéshez + Kérjük, olvassa el figyelmesen a következőket, mielőtt megtekinti a biztonsági mentését + Ne ossza meg jelszavát! + Legjobb ár akár 3,95% díjakkal + Győződjön meg róla, hogy senki sem látja a képernyőjét,\nés ne készítsen képernyőképeket + Kérjük %s bárki + ne ossza meg senkivel + Kérlek, próbálj ki egy másikat. + Érvénytelen mnemonikus jelszó, kérlek, ellenőrizd a szavak sorrendjét. + Nem választhatsz %d tárcánál többet + + Legalább %d tárcát kell választani + Legalább %d tárcát kell választani + + Ez a tranzakció már elutasítva vagy végrehajtva lett. + Ezt a tranzakciót nem lehet végrehajtani + %s már kezdeményezett egy ugyanilyen műveletet, amely jelenleg aláírásra vár a többi aláíró részéről. + Művelet már létezik + A bankkártya kezeléséhez kérjük, váltson más típusú tárcára. + A bankkártya nem támogatott a Multisig számára + Multisig letét + A letét zárolva marad a letétbe helyező számláján, amíg a multisig művelet végrehajtásra nem kerül, vagy el nem utasítják. + Győződjön meg róla, hogy a művelet helyes + Ajándékok létrehozásához vagy kezeléséhez kérjük váltson egy másik típusú tárcára. + Ajándékozás nem támogatott többaláírásos tárcákhoz + Által aláírva és végrehajtva: %s. + ✅ Multiszignós tranzakció végrehajtva + %s a %s-on. + ✍🏻 Az ön aláírása szükséges + Kezdeményezte: %s. + Tárca: %s + Által aláírva: %s + %d a %d aláírás közül összegyűjtve. + %s nevében. + Elutasította: %s. + ❌ Multiszignós tranzakció elutasítva + Nevében + %s: %s + Jóváhagyás + Jóváhagyás és végrehajtás + Adja meg a hívás adatokat a részletek megtekintéséhez + A tranzakció már végre lett hajtva vagy el lett utasítva. + Az aláírás befejeződött + Elutasítás + Aláírók (%d a %d-ből) + Batch All (hibánál visszavonja) + Batch (hibáig hajtja végre) + Force Batch (hibafigyelmen kívül hagyás) + Ön által létrehozva + Még nincsenek tranzakciók.\nA beérkező aláírási kérelmek itt jelennek meg + Aláírás (%s a %s-ből) + Ön által aláírva + Ismeretlen művelet + Aláírásra váró tranzakciók + Nevében: + Által aláírva + Multiszignós tranzakció végrehajtva + Az ön aláírása szükséges + Multiszignós tranzakció elutasítva + Kriptopénz eladása fiat pénzért, kérjük, váltson más típusú tárcára. + Az eladás nem támogatott a Multisig számára + Aláíró: + %s nem rendelkezik elegendő egyenleggel a multisig letét %s elhelyezéséhez. Még %s forintot kell hozzáadnia az egyenlegéhez. + %s nem rendelkezik elegendő egyenleggel a hálózati díj %s és a multisig letét %s elhelyezéséhez. Még %s forintot kell hozzáadnia az egyenlegéhez. + %s-nak legalább %s-ra van szüksége a tranzakciós díj megfizetéséhez, és hogy a minimális hálózati egyenleg fölött maradjon. Jelenlegi egyenleg: %s + %s nem rendelkezik elegendő egyenleggel a hálózati díj %s megfizetéséhez. Még %s forintot kell hozzáadnia az egyenlegéhez. + A multisig pénztárcák nem támogatják tetszőleges üzenetek aláírását — csak a tranzakciókat + A tranzakció elutasításra kerül. A multisig letét visszakerül %s-hoz. + A multisig tranzakciót %s kezdeményezi. A kezdeményező fizeti a hálózati díjat és lefoglal egy multisig letétet, amely felszabadul a tranzakció végrehajtásakor. + Többaláírásos tranzakció + Más aláírók most megerősíthetik a tranzakciót.\nKövetheti státuszát a %s-ban. + Aláírásra váró tranzakciók + Többaláírásos tranzakció létrehozva + Részletek megtekintése + Láncon kívüli információ (hívás adat) nélküli tranzakció elutasítva + %s a %s-on.\nÖntől nincs szükség további lépésekre. + Multiszignós tranzakció végrehajtva + %1$s → %2$s + %s a %s-on.\nElutasította: %s.\nÖntől nincs szükség további lépésekre. + Multiszignós tranzakció elutasítva + Ennél a fióknál a tranzakciók több aláíró jóváhagyását igénylik. Az Ön fiókja egyike az aláíróknak: + Más aláírók: + Küszöb %d a %d-ből + Multiszignós tranzakciók értesítési csatorna + Engedélyezés a beállításokban + Értesüljön az aláírási kérelmekről, az új aláírásokról és a végrehajtott tranzakciókról — így mindig kézben tarthatja az irányítást. Kezelje bármikor a beállításokban. + Itt vannak a multiszignós értesítések! + Ez a node már létezik + Hálózati díj + Csomópont címe + Csomópont információ + Hozzáadva + egyéni node-ok + alapértelmezett node-ok + Alapértelmezett + Csatlakozás… + Gyűjtemény + Készítette + %s / %s + %s / %s egység + #%s / %s kiadás + Korlátlan sorozat + Tulajdonos + Nem listázott + Saját NFT-k + Nem adott hozzá vagy nem választott ki multiszignós tárcát a Novában. Adjon hozzá és válasszon ki legalább egyet a multiszignós értesítések fogadásához. + Nincsenek multiszignós tárcák + Az Ön által megadott URL már létezik \"%s\" csomópontként. + Ez a csomópont már létezik + Az Ön által megadott csomópont URL-je vagy nem válaszol, vagy hibás formátumú. Az URL formátumának \"wss://\"-kel kell kezdődnie. + Csomópont hiba + Az Ön által megadott URL nem felel meg a %1$s csomópontnak.\nKérjük, adja meg az érvényes %1$s csomópont URL-jét. + Hibás hálózat + Jutalmak igénylése + Tokeneid vissza fognak kerülni a stake-be + Direkt + Pool stake-elési információ + Jutalmaid (%s) ki lesznek kérve, majd hozzá lesznek adva az egyenlegedhez + Pool + A műveletet nem lehet végrehajtani, mert a pool megsemmisülés alatt áll. Hamarosan be for zárni. + A pool megsemmisül + Jeleneg nincs szabad hely a pool-od unstake-elési sorában. Kérlek, próbáld újra %s múlva + Túl sokan unstake-elnek a pool-odból + A pool-od + A pool-od (#%s) + Fiók létrehozása + Új tárca létrehozása + Adatvédelmi irányelvek + Fiók importálása + Már rendelkezem tárcával + A folytatással elfogadod a %1$s-et és az %2$s-et + Felhasználási feltételek + Váltás + Egy a kollátoraid közül nem generál jutalmakat + Egy a kollátoraid közül nem lett kiválaszta a jelenlegi körben + A(z) %s unstake-elési időszak véget ért. Ne felejtsd el kiváltani tokenjeid + Nem lehet stake-elni ezzel a kollátorral + Kollátor módosítása + Nem lehet stake-et hozzáadni ehhez a kollátorhoz + Kollátorok kezelése + A kiválasztott kollátor úgy néz ki, hogy nem fog részt venni a stake-elésben. + Nem adhatsz stake-et ahhoz a kollátorhoz, melynél unstake-eled az összes tokened. + A stake-ed (%s) kevesebb lesz, mint a minimális stake ennél a kollátornál. + A fennmaradó stake-elési egyenleg a minimális hálózati érték alá fog esni (%s) és az unstake-elési összeghez fog hozzáadódni + Nincs jogosultságod. Kérlek próbáld újra. + Biometrika használata engedélyezéshez + A Pezkuwi Wallet biometrikus hitelesítéssel korlátozza a jogosulatlan felhasználók hozzáférését az alkalmazáshoz. + Biometria + A PIN-kód sikeresen megváltozott + Erősítsd meg a PIN-kódodat + PIN-kód létrehozása + PIN-kód megadása + Add meg PIN-kódodat + Nem csatlakozhatsz a pool-hoz, mert az elérte a tagok maximális számát + A pool megtelt + Nem csatlakozhatsz olyan pool-hoz, amelyik nincs nyitva. Kérlek, vedd fel a kapcsolatot a pool tulajdonosával. + A pool nincs nyitva + Már nem használhatja egy fióknál egyszerre a közvetlen stakelést és a pool stakelést. Ahhoz, hogy a pool stakelést kezelje, előbb ki kell vonnia tokenjeit a közvetlen stakelésből. + Pool működés nem elérhető + Népszerűek + Hálózat hozzáadása manuálisan + Hálózatok listájának betöltése... + Keresés hálózati név alapján + Hálózat hozzáadása + %s -kor %s + 1N + Összes + 1H + %s (%s) + %s ár + 1H + + Teljes idő + Elmúlt hónap + Ma + Elmúlt hét + Elmúlt év + Fiókok + Tárcák + Nyelv + PIN-kód módosítása + Alkalmazás megnyitása rejtett egyenleggel + Jóváhagyás PIN-kóddal + Biztonságos mód + Beállítások + A Bankkártyájának kezeléséhez váltson át egy másik tárcára a Polkadot hálózattal. + Ez a proxyált tárca nem támogatja a bankkártyát + Ez a fiók tranzakciók végrehajtásához adott hozzáférést a következőnek: + Stake-elési műveletek + A delegált fiók %s nem rendelkezik elegendő egyenleggel a hálózati díj megfizetéséhez, ami %s. Az elérhető egyenleg: %s + A proxy tárcák csak a tranzakciók aláírását támogatják, egyéb üzenetekét nem. + %1$s nem delegálta a(z) %2$s fiókot + %1$s delegálta a(z) %2$s, de csak %3$s joggal + Hoppá! Nincs megfelelő jogosultság + A tranzakciót a(z) %s delegált fiók fogja kezdeményezni. A hálózati díjat szintén a delegált fiók fogja megfizetni. + Ez egy Delegáló (Proxied) fiók + %s proxy + A delegált szavazott + Új Referendum + Referendum frissítés + %s Referendum #%s szavazása elindult! + 🗳️ Új Referendum + Töltsd le a Pezkuwi Wallet v%s verzióját, hogy elérd az összes új funkciót! + Új frissítés érhető el a Pezkuwi Wallet-hez! + %s Referendum #%s szavazása lezárult és el lett fogadva 🎉 + ✅ Referendum elfogadva! + %s Referendum #%s státusza megváltozott: \"%s\" helyett \"%s\" + %s Referendum #%s szavazása lezárult és el lett utasítva! + ❌ Referendum elutasítva! + 🗳️ A referendum státusza megváltozott + %s Referendum #%s státusza megváltozott: %s + Pezkuwi közlemények + Egyenlegek + Értesítések engedélyezése + Többaláírásos tranzakciók + Nem fogsz értesítést kapni tárca tevékenységekről (Egyenlegek, Stake-elés), mivel nem választottál ki egy tárcát sem. + Egyebek + Érkezett tokenek + Elküldött tokenek + Stake-elési jutalmak + Tárcák + ⭐️ Új jutalom %s + %s érkezett %s stake-elésből + ⭐️ Új jutalom + Pezkuwi Wallet • most + Érkezett +0.6068 KSM ($20.35) a Kusama stake-elésből. + %s érkezett %s láncra + ⬇️ Érkezett + ⬇️ Érkezett %s + %s elküldve %s címre, %s láncon + 💸 Elküldve + 💸 Elküldve %s + Válasszon ki legfeljebb %d tárcát, hogy értesítést kapjon, amikor a tárca aktivitást mutat + Push értesítések engedélyezése + Értesítéseket kaphatsz a tárcában történő műveletekről, Kormányzási frissítésekről, Stake-elés aktivitásról és Biztonsági frissítésekről, így mindig napra kész lehetsz. + A push értesítések engedélyezésével elfogadod a %s-et és az %s-et + Kérlek, próbáld meg bekapcsolni később a Beállítások fül alatt, az értesítéseknél. + Ne maradj le semmiről! + Válassza ki a hálózatot a %s fogadásához + Cím másolása + Ha visszakéred ezt az ajándékot, akkor a megosztott link le lesz tiltva, és a tokenek visszatérnek a pénztárcádba.\nTovább szeretnél lépni? + Visszakérni a(z) %s Ajándékot? + Sikeresen visszakaptad az ajándékodat + JSON beillesztése vagy fájl feltöltése… + Fájl feltöltése + JSON visszaállítása + Mnemonikus jelmondat + Nyers seed + Forrás típus + Minden referendum + Mutat: + Nem szavaztak + Szavazott + A megadott szűrőkhöz nincs megfelelő referendum + Itt fog megjelenni információ a referendumról, ahogy elkezdődik + Nem található referendum a megadott címmel\nvagy azonosítóval + Keresés a referendum címével vagy azonosítóval + %d népszavazás + Csúsztassa el, hogy a népszavazásokra AI összefoglalók segítségével szavazzon. Gyors és könnyű! + Referendum + A referendum nem található + A tartózkodó szavazatok csak 0.1x meggyőződés mellett lehetségesek. Szavazás 0.1x meggyőződéssel? + Meggyőződés frissítése + Tartózkodó szavazatok + Igen: %s + Pezkuwi DApp böngésző használata + A címet és leírást csak a javaslattevő szerkesztheti. Amennyiben az ajánlattevő fiók a sajátod, látogass el a Polkassembly oldalra és töltsd fel információkkal a javaslatot. + Igényelt összeg + Idővonal + Szavazatod: + Jóváhagyási görbe + Kedvezményezett + Letét + Választók + Túl hosszú az előnézethez + JSON paraméterek + Javaslattevő + Támogatási görbe + Eredmény + Szavazatküszöb + Pozíció: %s / %s + A szavazáshoz hozzá kell adnod a következő fiókot a tárcádhoz: %s + Referendum %s + Nem: %s + Nem szavazatok + %s szavazat %s által + Igen szavazatok + Stake-elés + Jóváhagyott + Megszakított + Döntés alatt + Döntés %s múlva + Végrehajtott + Várakozás alatt + Várakozás alatt (%s / %s) + Elpusztított + Elutasítás alatt + Elfogadás + Előkészítés alatt + Elutasított + Jóváhagyás %s múlva + Végrehajtás %s múlva + Kifut az időből %s múlva + Elutasítás %s múlva + Kifutott az időből + Letétre várva + Küszöbérték: %s / %s + Szavazás eredménye: Jóváhagyott + Megszakított + Létrehozott + Szavazás: Döntés alatt + Végrehajtott + Szavazás: Várkozás alatt + Elpusztított + Szavazás: Elutasítás alatt + Szavazás: Elfogadás alatt + Szavazás: Felkészülés alatt + Szavazás eredménye: Elutasított + Kifutott az időből + Szavazás: Várakozás a letétre + Elfogadáshoz: %s + Közösségi hitelek + Kincstár: nagy kiadás + Kincstár: nagy borravaló + Fellowship: admin + Kormányzás: regisztrátor + Kormányzás: bérlet + Kincstár: közepes kiadás + Kormányzás: visszavonó + Kormányzás: pusztító + Fő napirend + Kincstár: kicsi kiadás + Kincstár: kicsi borravaló + Kincstár: bármi + zárolva marad itt: %s + Feloldható + Tartózkodik + Igen + Minden lekötött újrafelhasználása: %s + Kormányzásba zártak újrafelhasználása: %s + Kormányzati zár + Zárolási időszak + Nem + Szavazatok szorzása a zárolási időszak növelésével + Szavazás erre: %s + A zárolási időszak után ne felejtsd el feloldani a tokeneket + Szavazott referendum + %s szavazat + %s × %s x + Itt fog megjelenni a szavazók listája + %s szavazat + Fellowship: fehérlista + Szavazatod: %s szavazat + A referendum befejeződött és a szavazás véget ért + A referendum befejeződött + Szavataidat már delegálod a kiválasztott referendum kategóriákba. Kérlek kérd meg a delegáltad, hogy szavazzon vagy vond vissza delegálásod, hogy közvetlenül szavazhass. + A szavazatok már delegálás alatt vannak + Elérted a maximum %s szavazatot ehhez a kategóriához + Maximum ennyi szavazat lehet + Nem rendelkezel elegendő tokennel, ahhoz hogy szavazhass. Szavazáshoz elérhető: %s + Hozzáférés típusának visszavonása + Delegálás visszavonása + Szavazatok eltávolítása + + %d kategóriában már szavaztál referendumokra. Ahhoz, hogy az adott kategória elérhető legyen delegáláshoz, törölnöd kell a meglévő szavazataidat. + %d kategóriában már szavaztál referendumokra. Ahhoz, hogy az adott kategória elérhető legyen delegáláshoz, törölnöd kell a meglévő szavazataidat. + + Eltávolítod a szavazataid előzményeid? + %s Ez kizárólag a tiéd, biztonságosan tárolva, mások számára nem hozzáférhető. A biztonsági mentés jelszava nélkül a pénztárcák helyreállítása a Google Drive-ról lehetetlen. Ha elveszett, töröld a jelenlegi biztonsági mentést, hogy új jelszóval újat hozz létre. %s + Sajnálatos módon a jelszavad nem állítható vissza. + Egyébként használj titkos kifejezést a helyreállításhoz. + Elvesztetted a jelszavad? + Biztonsági mentés jelszava korábban frissítve lett. A Cloud Backup használatához kérlek írd be az új biztonsági mentés jelszavát. + Kérlek add meg a biztonsági mentés során létrehozott jelszót + Add meg a biztonsági mentés jelszavát + Nem sikerült frissíteni a blokklánc információt. Előfordulhat, hogy néhány funkció nem fog működni. + Runtime frissítési hiba + Kapcsolatok + fiókjaim + Nem található pool a megadott névvel\nvagy azonosítóval. Győzödj meg róla,\nhogy a megadott adat helyes + Fiókcím vagy fióknév + Itt fognak megjelenni a keresési eredmények + Keresési eredmények + Aktív pool-ok: %d + tagok + Pool kiválasztása + Kategóriák választása delegáláshoz + Elérhető kategóriák + Kérlek, válaszd ki azokat a kategóriákat, amelyekre szavazati jogot kívánsz adni. + Kategóriák választása delegálás szerkesztéséhez + Kategóriák választána delegálás visszavonásához + Nem elérhető kategóriák + Ajándék küldése + Bluetooth engedélyezése és engedélyek megadása + A Pezkuwi-nak szüksége van a helymeghatározáshoz, hogy megtalálhassa a Ledger eszköt bluetooth-on keresztül + Kérlek, engedélyezd a helymeghatározást a készülék beállításaiban + Hálózat választása + Válassz tokent a szavazáshoz + Kategóriák választása: + %d / %d + Válasszon hálózatot a(z) %s eladásához + Eladás elindítva! Kérjük, várjon legfeljebb 60 percig. A státuszt nyomon követheti az e-mailben. + Egyik szolgáltatónk sem támogatja jelenleg ennek a tokennek az eladását. Kérjük, válasszon másik tokent, másik hálózatot, vagy térjen vissza később. + Ez a token nem támogatott az eladási funkció által + Cím vagy w3n + Válassz hálózatot az elküldéshez %s + A címzett egy rendszerfiók. Nem áll vállalat vagy magánszemély irányítása alatt.\nBiztos, hogy még mindig el akarod indítani ezt az utalást? + A tokenek el fognak veszni + Meghatalmazott + Kérlek, ellenőrizd, hogy a biometrikus adatok engedélyezve vannak-e a Beállításokban + A biometrikus adatok le vannak tiltva a Beállításokban + Közösség + E-mail + Általános + Minden aláírási művelethez, mely a tárcában kulcspárral rendelkezik, (Pezkuwi Wallet-ben készített vagy importált) PIN-kódos jóvahagyás szükséges, még az aláírási folyamat előtt. + Hitelesítés kérése a műveletek aláírásához + Tulajdonságok + A push értesítések csak a Google Play-ről letöltött Pezkuwi Wallet érhetőek el. + A push értesítések csak a Google-szolgáltatásokkal rendelkező eszközökön érhetőek el. + A képernyőfelvétel és a képernyőképek nem lesznek elérhetőek. A minimalizált alkalmazás nem fog tartalmat megjeleníti + Biztonságos mód + Biztonság + Segítség és visszajelzés + Twitter + Wiki & Súgóközpont + Youtube + A megítélés 0.1x-re lesz állítva, ha tartózkodsz. + Nem lehet közvetlenül és jelölési poolokkal egyidejűleg lekötni + Már le van kötve + Fejlett stake-elés menedzsment + A stake-elési típus nem módosítható + Már directben stake-elsz + Direkt stake-elés + Kevesebbet adtál meg, mint %s, ami a minimum ahhoz, hogy %s alatt jutalmakat szerezhess. Jutalmak szerzéséhez érdemes lenne megfontolnod a Pool stake-elést. + Tokenek újrafelhasználása a Kormányzásban + Minimális stake: %s + Jutalmak: Automatikus kifizetés + Jutalmak: Manuális igénylés + Már stake-elsz egy pool-ban + Pool stake-elés + A stake-ed kevesebb, mint a jutalmak megszerzéséhez szükséges minimum + Nem támogatott stake-elési típus + Ajándék link megosztása + Visszakövetelés + Helló! Van egy %s ajándékod, ami rád vár!\n\nTelepítsd a Pezkuwi Wallet alkalmazást, állítsd be a pénztárcád, és igényeld ezt a különleges linken keresztül:\n%s + Ajándék Előkészítve.\nOszd Meg Most! + sr25519 (ajánlott) + Schnorrkel + A kiválasztott fiók már használatban van, mint vezérlő + Delegált hatáskör hozzáadása (Proxy) + Delegációid + Aktív delegálók + A művelet végrehajtásához vegyél fel egy vezérlő fiókot %s az alkalmazásba. + Delegálás hozzáadása + A stake-ed kevesebb, mint %s, ami a minimum.\nA minimálisnál kevesebb stake, nagy eséllyel nem fog jutalmat generálni. + Stake-elj még több tokent + Változtasd meg a validátoraidat. + Jelenleg minden rendben van. Itt fognak majd megjelenni a figyelmeztetések. + A validátorhoz rendelt elavult stake-elési pozíció felfüggesztheti a jutalmakat. + Stake-elési fejlesztések + Kiváltható tokenek igénylése + Kérlek, várd meg a következő korszak kezdetét + Figyelmeztetések + Ez már egy vezérlő fiók + Itt rendelkezel már stake-el: %s + Stake-elési egyenleg + Egyenleg + Stake növelése + Nem nominálsz és nem is validálsz + Vezérlő módosítása + Validátorok módosítása + %s / %s + Kiválasztott validátorok + Vezérlő + Vezérlő fiók + Ezen a fiókon nincsenek szabad tokenek, biztos, hogy meg akarod változtatni a vezérlőt? + A vezérlő megszüntetheti a stake-et, kiválthatja, visszaküldheti a stake-be, megváltoztathatja a jutalmak érkezési helyét és a validátorokat. + A vezérlő általában unstake-elni, kiváltani, vissza stake-elni, validátorokat változtatni és a jutalmak érkezési helyének beállítására képesek + A vezérlő megváltozott + Ez a validátor blokkolva van, emiatt nem lehet kiválasztani. Kérlek, próbáld meg a következő korszakban. + Szűrők törlése + Összes kijelölés törlése + A maradék kijelölése az ajánlottak közül + Validátorok: %d / %d + Validátorok kiválasztása (max. %d) + Kijelöltek mutatása: %d (max %d ) + Validátorok kiválasztása + Becsült jutalmak (%% APR) + Becsült jutalom (%% APY) + Lista frissítése + Stake-elés a Pezkuwi DApp böngészőn keresztül + További stake-elési lehetőségek + Stake és jutalom szerzés + A(z) %1$s staking él a(z) %2$s-n, kezdve %3$s + Becsült jutalmak + korszak #%s + Becsült bevétel + Becsült %s bevétel + Validátor saját stake-je + Validátor saját stake-je (%s) + Az unstake-elési időszak alatt a tokenek nem generálnak jutalmat + Az unstake-elési időszak alatt a tokenek nem hoznak jutalmakat + A stake-elési időszak után ki kell váltanod a tokenjeid. + Az unstake-elési időszak után ne felejtsd el kiváltani tokenjeidet + A jutalmaid meg fognak emelkedni a következő korszaktól + A jutalmaid meg fognak emelkedni a következő korszaktól + A stake-elt tokenjeid minden korszakban jutalmakat generálnak (%s). + Stake-elt tokenek minden korszakban jutalmat generálnak (%s) + A fennmaradó stake elkerülése érdekében, a Pezkuwi Wallet\nmeg fogja változtatni a jutalmak érkezési helyét a címedre. + Amennyiben megszüntetnéd stake-elésed, meg kell várnod a megszüntetési időszakot (%s) + Tokenek unstake-elésénél, meg kell várnod az unstake-elési időszakot (%s) + Stake-elési információ + Aktív nominátorok + + nap + nap + + Minimális stake + %s hálózat + Stake-elt + Proxy beállításához válts át a(z) %s tárcára + Stash fiók kiválasztása proxy beállításához + Kezelés + %s (max %s ) + A nominátorok száma elérte a maximumot. Próbáld újra később + A stake-et nem lehet elkezdeni + Min. stake + Fel kell vegyél egy %s fiókot a tárcádba, ahhoz hogy elkezdhess stake-elni + Havi + Vezérlő fiók hozzáadása az eszközön. + Nincs hozzáférés a vezérlő fiókhoz + Nominált: + %s jutalmazott + A hálózat nem választotta ki az egyik validátorodat. + Aktív állapot + Inaktív állapot + A stake-elt összeg kevesebb, mint a jutalom megszerzéséhez szükséges minimum. + A hálózat egyik validátorodat sem választotta ki. + A stake-elés a következő korszakban for kezdődni. + Inaktív + Várakozás a következő korszakra + Várakozás a következő korszakra (%s) + Az egyenleged nem elegendő a(z) %s proxy letéthez. Elérhető egyenleg: %s + Stake-elési Értesítési Csatorna + Kollátor + A kollátor minimális stake-je nagyobb, mint amennyit delegálsz. Nem fogsz jutalmakat kapni ettől a kollátortól. + Kollátor információ + Kollátor saját stake-je + Kollátorok: %s + Egy vagy több kollátorod ki lett választva a hálózat által. + Delegálók + Elérted a maximum delegálást a(z) %s kollátornál + Nem választhatsz új kollátort + Új kollátor + várakozás a következő körre (%s) + Függőben lévő unstake kérelmeid vannak az összes kollátorodnál. + Egy kollátor sem elérhető unstake-hez + A visszaküldött tokenek a következő körtől lesznek számolva + Stake-elt tokenek minden körben jutalmat generálnak (%s) + Kollátor kiválasztása + Kollátor kiválasztása... + A jutalmaid meg fognak emelkedni a következő körtől + Nem fogsz jutalmakat kapni ebben a körben, mivel a delegálásod nem aktív. + Már unstake-elsz ennél a kollátortól. Csak egy függőben lévő unstake lehet kollátoronként + Nem tudsz unstake-elni ettől a kollátortól + Stake-ednek nagyobbnak kell lennie, mint a minimális stake (%s) ennél a kollátornál. + Nem fogsz jutalmat kapni + Néhány kollátorod vagy nem lett kiválasztva vagy a stake-elt összegnél magasabb a minimum stake limitje. Nem fogsz jutalmat kapni a jelenlegi körben ettől a kollátortól. + Kollátoraid + Stake-ed a következő kollátorokhoz lett rendelve + Aktív kollátorok, melyek nem termelnek jutalmakat + Kollátorok, melyek nem rendelkeznek elegendő stake-el a megválasztáshoz + Kollátorok, melyek a következő körben lesznek beiktatva + Függőben (%s) + Kifizetés + A kifizetés lejárt + + %d nap van vissza + %d nap van vissza + + A kifizetést saját kezűleg is igényelheted a hálózati díj megfizetése mellett, amikor a jutalmak közel vannak a lejárathoz. + A validátorok a jutalmakat 2-3 naponta fizetik ki. + Mind + Mind + A befejezés dátuma mindig a mai nap + Egyéni időszak + %dN + Befejező dátum kiválasztása + Véget ér + Az elmúlt 6 hónap (6H) + 6H + Az elmúlt 30 nap (30N) + 30N + Az elmúlt 3 hónap (3H) + 3H + Dátum kiválasztása + Kezdő dátum kiválasztása + Kezdődik + Stake-elési jutalmak mutatása + Az elmúlt 7 nap (7N) + 7N + Az elmúlt év (1É) + + Az elérhető egyenleged %s, Nem stake-elhetsz többet, mint %s + Delegált hatáskörök (proxy) + Jelenlegi pozíció a sorban + Új pozíció a sorban + Visza a stake-be + Minden unstake-elés alatti + A visszaküldött tokenek a következő korszaktól lesznek számolva + Egyéni összeg + A vissza stake-elni kívánt összeg nagyobb, mint az unstake-elési egyenleg + Utoljára unstake-elt + Legjövedelmezőbb + Nincs túljelentkezés + Rendelkezik láncon tárolt azonosítóval + Nem büntetett + Identitásonként legfeljebb 2 validátor + legalább egy kapcsolattartóval + Ajánlott validátorok + Validátorok + Becsült jutalom (APY) + Kiváltás + Kiváltható: %s + Jutalom + Jutalom érkezési helye + Átutalható jutalmak + Korszak + Jutalom részletek + Validátor + Bevétel újra stake-el + Jutalmak újra stake-elés nélkül + Tökéletes! Minden jutalom kifizetésre került. + Fantasztikus! Nincsenek kifizetetlen jutalmaid + Az összes kifizetése (%s) + Függőben lévő jutalmak + Kifizetetlen jutalmak + %s jutalmak + A jutalmakról + Jutalmak (APY) + Jutalmak érkezési helye + Válassz saját kezűleg + Válaszd ki a kifizetési fiókot + Ajánlottak választása + %d kiválasztva (max. %d) + Validátorok (%d) + Vezérlő frissítése Stash-re + Proxy-k használata javasolt ahhoz, hogy stake-elési műveletek delegálhass egy másik fiókhoz + A vezérlő fiókok hamarosan elavulnak + Válassz egy másik fiókot, mint vezérlő, hogy a stake-eléssel kapcsolatos műveleteket átruházd + A stake-elés biztonságának javítása + Validátorok beállítása + Nincsenek kiválasztva validátorok + Válassz validátorokat a stake-elés megkezdéséhez + A jutalmak következetes megszerzéséhez ajánlott minimális stake %s. + A hálózati minimális értéknél (%s) nem lehet kevesebb a stake. + A minimális stake-nek nagyobbnak kell lennie mint %s + Újra stake-elés + Újra stake-elési jutalmak + Hogyan használd fel a jutalmaidat? + Válaszd ki a jutalom típusát + Kifizetési fiók + Büntetés + Stake %s + Stake max. + Stake-elési időszak + Stake-elési típus + Bíznod kell a nomináltjaidban, hogy azok szakszerűen és őszintén járnak el. Kizárólag az aktuális jövedelmezőségre alapozni döntésed, a jutalmak csökkenésével vagy akár elvesztésével végződhet. + Gondosan válaszd ki validátoraidat, mert azoknak szakszerűen és őszintén kell eljárniuk. Kizárólag a jövedelmezőségre alapozni döntésed, a jutalmak csökkenésével vagy akár elvesztésével végződhet. + Stake-elés validátorokkal + A Pezkuwi Wallet biztonsági és jövedelmezőségi kritériumok alapján választja ki a legjobb validátorokat + Stake-elés ajánlott validátorokkal + Stake-elés indítása + Stash + A stash képes többet lekötni és vezérlőt beállítani + A stash fiók képes növelni a stake-et és beállítani a vezérlőt + A(z) %s stash fiók nem elérhető, a stake beállításainak frissítéséhez + A nomináló passzív jövedelemre tesz szert, ha zárolja tokenjeit a hálózat biztosítására. Ennek elérése érdekében a nominálónak ki kell választania a támogatandó validátorokat. A nominálónak óvatosnak kell lennie a validátorok kiválasztásakor. Amennyiben a kiválasztott validátor nem fog megfelelően viselkedik, az incidens súlyosságától függően mindkettőre súlyos büntetések vonatkozhatnak. + A Pezkuwi Wallet segít kiválasztani a validátorokat a nominálók számára. A mobilalkalmazás lekéri az adatokat a blokkláncról, majd összeállít egy listát a validátorokról, a következőket szempontok alapján: a legmagasabb nyereségel rendelkezők, rendelkezik identitással és elérhetőségi adatokkal, nem büntetett és van szabad kapacitása a nominálások fogadására. A Pezkuwi Wallet a decentralizációval is törődik, így ha egy személy vagy egy cég több validátor csomópontot futtat, akkor az ajánlott listában legfeljebb 2 ilyen csomópontot jelenit meg. + Ki az a nominátor? + A stake-elésért járó jutalmak minden korszak végén kifizethetők (6 óra Kusama-án és 24 óra Polkadot-on). A hálózat 84 korszakig tárolja a jutalmakat és legtöbb esetben a validátorok mindenkinek automatikusan kifizetik azokat. Az validátorok, bármilyen okból kifolyólag vagy akár csak feledékenyséből elmulaszthatják a kifzetést, emiatt a nominálók maguk is igényelhetik azt. + Habár a jutalmakat a validátorok általában kifizetik, Pezkuwi Wallet figyelmeztetésekkel segít, ha vannak olyan kifizetetlen jutalmak, amelyek a lejárathoz közelednek. A stake-elési képernyőn ilyen és egyéb figyelmeztetések is megjelennek. + Jutalmak átvétele + A stake-elés egy lehetőség passzív jövedelemszerzésre a tokenek hálózatban való zárolásával. A stake-elési jutalmak minden korszakban kiosztásra kerülnek (6 óra a Kusama-án és 24 óra a Polkadot-on). Bármennyi ideig lehet stake-elni, de a tokenek unstake-eléséhez meg kell várnod, amíg a stake-elési időszak véget ér, csak ez után lesznek kiválthatóak. + A stake-elés a hálózat biztonságának és megbízhatóságának fontos része. Bárki futtathat validátor csomópontokat, de a hálózat csak azokat választja meg és jutalmazza, melyeknek elegendő tokenje van ahhoz, hogy részt vegyenek az új blokkok összeállításában. A validátorok gyakran nem rendelkeznek elegendő saját tokennel, ezért a nominálók a szükséges stake mennyiség eléréséhez lekötik tokenjeiket a validároknál. + Mi az a stake-elés? + A validátor a hét minden napján, egész nap egy blokklánc-csomópontot futtat. Elegendő stake-elt tokennel kell rendelkeznie (mind saját, mind a nominálók által biztosított) ahhoz, hogy a hálózat megválaszthassa. Az validátoroknak meg kell őrizniük csomópontjaik teljesítményét és megbízhatóságát, hogy jutalmat kaphassanak. Validátornak lenni szinte egy teljes munkaidős folyamat, vannak olyan cégek, amelyek kizárólag blokklánc validátorok üzemeltetéssel foglalkoznak. + Bárki lehet validátor és futtathat egy blokklánc csomópontot, de ehhez bizonyos szintű technikai készségekre és felelősségre van szükség. A Polkadot és a Kusama hálózatoknak van egy Thousand Validators Program nevű programjuk, amely támogatást nyújt a kezdőknek. Sőt, maga a hálózat is mindig több és több validátort fog jutalmazni, akiknek alacsonyabb a stake-je (de elegendő a megválasztáshoz) a decentralizáció érdekében. + Ki az a validátor? + A vezérlő fiók beállításához válts át a stash fiókodra. + Stake-elés + %s stake-elés + Jutalmazott + Összes stake-elt + Boost küszöbérték + A kollátoromnál + Yield Boost nélkül + Yield Boost-val + automatikusan %s után az átutalható tokenjeimmel, ha több van, mint + automatikusan %s után (korábban mint: %s) az átutalható tokenjeimmel, ha több van, mint + Stake-elni akarok + Yield Boost + Stake-elési típus + Az összes tokened unstake-elés alatt áll, emiatt nem tudsz többet stake-elni. + Nem tudsz többet stake-elni + Amikor részletekben unstake-elsz, a stake-ben kell maradjon legalább %s. Szeretnéd az összesed unstake-elni, beleértve a(z) %s maradékot is? + Túl kis összeg marad a stake-ben + Az unstake-elni kívánt összeg nagyobb, mint a stake-elt egyenleg + Unstake + Itt fognak megjelenni az unstake-elési tranzakciók + Itt fognak megjelenni az unstake-elési tranzakciók + Unstake-elés: %s + A tokenek kiválthatóak az unstake-elési időszak után. + Elérted az unstake-elési kérelmek limitjét (%d aktív kérelem) + Az unstake-elési kérelmek limitje elérve + Unstake-elési időszak + Unstake mind + Unstake mindent? + Becsült jutalom (%% APY) + Becsült jutalom + Validator információ + Túljelentkezett. Nem fogsz jutalmat kapni ebben a korszakban ettől a validátortól. + Nominátorok + Túljelentkezett. Csak a legnagyobb stake-el rendelkező nominálók kapnak jutalmat. + Saját + Nincs találat.\nGyőződj meg róla, hogy a teljes fiókcímet írtad be + A validátor helytelen viselkedés miatt büntetve lett a hálózaton (pl. offline állapotba ment, megtámadta a hálozatot vagy módosított szoftvert futtatott). + Összes stake + Teljes stake (%s) + A jutalom kevesebb, mint a hálózati díj. + Éves + A stake-ed a következő validátorohoz van rendelve. + Stake-ed a következő validátorhoz lett rendelve + Megválasztott ( %s ) + Validátorok, akiket ebben a korszakban nem választottak meg. + Validátorok, melyek nem rendelkeznek elegendő stake-el a megválasztáshoz + Mások, akik a stake-ed nélkül aktívak. + Aktív validátorok, melyeknél nem stake-elsz + Nem választották meg ( %s ) + Tokenjeid túljelentkezett validátorhoz lettek szétosztva. Ebben a korszakban nem fogsz jutalmat kapni. + Jutalmak + Stake-ed + Validátoraid + A validátoraid a következő korszakban fognak megváltozni. + Most készítsünk biztonsági mentést a pénztárcádról. Ez biztosítja, hogy a forrásaid biztonságban legyenek. A biztonsági mentések lehetővé teszik a pénztárcád bármikori visszaállítását. + Folytatás a Google-lal + Írja be a pénztárca nevét + Az új pénztárcám + Folytatás kézi biztonsági mentéssel + Adjon egy nevet a pénztárcájának + Ez csak ön számára lesz látható, és később módosíthatja. + A pénztárca készen áll + Bluetooth + USB + Zárolt tokenek vannak egyenlegeden a(z) %s miatt. A folytatáshoz adj meg kevesebbet, mint %s vagy többet, mint %s. Egyéb összeg stake-eléséhez a(z) %s törlése szükséges. + Nem tudod stake-elni a megadott összeget + Kiválasztott: %d (max. %d) + Elérhető egyenleg: %1$s (%2$s) + %s a stake-elt tokenjeiddel + Vegyél részt a kormányzásban + Stake-elj többet, mint %1$s és %2$s a stake-elt tokenjeiddel + vegyél részt a kormányzásban + Stake-elj bármikor, mindössze %1$s-ért. Jutalmakat %2$s múlva kaphatsz. + %s múlva + Stake-elj bármikor. Jutalmakat %s múlva kaphatsz. + Tudj meg többet a\n%1$s stake-elésről a %2$s oldalon + Pezkuwi Használati útmutató + A jutalmak %1$s érkeznek. Automatikus kifizetéshez stake-elj többet, mint %2$s, ellenkező esetben manuálsan kell igényeled + minden %s + A jutalmak %s érkeznek + A jutalmak %s összegződnek. Manuálsan kell igényelned. + A jutalmak %s érkeznek és hozzáadódnak az utalható egyenleghez + A jutalmak %s érkeznek és visszakerülnek a stake-be + A jutalmak és stake-elés állapota időről időre változhat. %s rendszeresen + Ellenőrizd a stake-ed állapotát + Stake-elés indítása + Lásd %s + Felhasználási feltételek + %1$s egy %2$s ahol %3$s + a tokennek nincs értéke + teszt hálózat + %1$s\n%2$s tokenjeiden évente + Keress akár %s-ot + Unstake-elj bármikor és pénzedet kiválthatod %s. Unstake-elés alatt nem kapsz jutalmat + %s múlva + A pool amit választottál nem aktív, mert nincsenek kiválaszva a validátorai vagy a stake-je kevesebb, mint a minimum. Biztos vagy benne, hogy ezt a pool-t választod? + A nominátorok száma elérte a maximumot. Próbáld újra később + %s jelenleg nem elérhető + Validátorok: %d (max. %d) + Módosítva + Új + Eltávolítva + Token a hálózati díj fizetéséhez + A hálózati díj hozzáadódik a megadott összeghez + Az átváltási lépés szimulációja nem sikerült + Ez a pár nem támogatott + A(z) #%s (%s) művelet nem sikerült + %s csere %s-ra %s-on + %s átutalás %s-ból %s-ba + + %s művelet + %s műveletek + + %s a %s műveletekből + Cseréljük %s-t %s-ra %s-on + Átutalása %s-ra %s + Végrehajtási idő + Nincs elegendő tokent a számládon. Legalább %s kell, hogy maradjon a(z) %s hálozati díj megfizetése után + Legalább %s meg kell maradjon, ahhoz hogy %s tokent fogadhass + Legalább %s szükséges a %s hálózaton a %s token fogadásához + Legfeljebb %1$s értékben válthatsz, mivel fizetned kell %2$s hálózati díjat is. + Legfeljebb %1$s értékben válthatsz, mivel fizetned kell %2$s hálózati díjat és konverziót is végre kell hajts %3$s és %4$s között, hogy megmaradjon a(z) %5$s minimális egyenleged. + Max. váltás + Min. váltás + Legalább %1$s kell, hogy legyen az egyenlegeden. Szeretnél egy teljes cserét végezni a maradék %2$s hozzáadásával? + Túl kevés összeg marad az egyenlegen + Legalább %1$s kell, hogy maradjon a %2$s hálózati díj megfizetése után továbbá a(z) %3$s és %4$s konverzió után, hogy megmaradjon a minimum egyenleg, ami %5$s. Szeretnél egy teljes cserét végezni a maradék %6$s hozzáadásával? + Fizetés + Fogadás + Token kiválasztása + Nincs elegendő token a váltáshoz + Nem elegendő likviditás + Nem fogadhatsz kevesebbet, mint %s + %s azonnali vásárlása hitelkártyával + %s átutalása egy másik hálózatról + %s fogadása QR kóddal vagy a címeddel + %s beszerzése a következővel + A csere végrehajtása közben a közbenső beérkező összeg %s, ami kevesebb, mint a minimális egyenleg %s. Próbáljon meg nagyobb csereösszeget megadni. + A csúszást %s és %s között kell megadni + Érvénytelen csúszás + Token kiválasztása fizetéshez + Token kiválasztása fogadáshoz + Összeg megadása + Egyéb összeg megadása + A %s tokennel történő hálózati díj fizetéséhez, Pezkuwi automatikus %s és %s konverziót fog végrehajtani, ahhoz, hogy fenntartsa a számla minimális egyenlegét, ami %s. + A hálózati díjakat a blokklánc számítja fel a tranzakciók feldolgozásáért és hitelesítéséért. A díj összege változhat a hálózattól és a tranzakció sebességétől. + Válassza ki a hálózatot %s cseréjekor + Az pool-nak nincs elegendő likviditása a cseréhez + Az árkülönbözet két eltérő eszköz közötti árkülönbségre utal. Egy kriptovaluta váltás során az árkülönbözet általában a váltani kívánt eszköz ára és a váltásra váró eszköz ára közötti különbség. + Árkülönbség + %s ≈ %s + Az árfolyam két különböző kriptovaluta között azt jelenti, hogy egy kriptovalutából mennyit kaphatsz cserébe egy bizonyos mennyiségű másik kriptovalutáért. + Mérték + Régi árfolyam: %1$s ≈ %2$s.\nÚj árfolyam: %1$s ≈ %3$s + Frissült a csere árfolyam + Művelet megismétlése + Útvonal + Az út, amelyen a tokenje különböző hálózatokon megy át, hogy megkapja a kívánt tokent. + Csere + Átutalás + Csere beállítások + Csúszás + A decentralizált cserekereskedésben gyakori jelenség a csúsztatás. A változó piaci feltételek miatt a végső tranzakció ára eltérhet a várt piaci ártól. + Másik érték megadása + Az érték %s és %s között kell legyen + Csúszás + A tranzakció a magas csúszás miatt lehet, hogy hamarabb for lefutni. + A tranzakció az alacsony csúszás miatt lehet, hogy vissza lesz fordítva. + A(z) %s mennyisége kevesebb, mint a(z) %s minimális egyenlege + Túl alacsony összeget próbálsz átváltani + Tartózkodik: %s + Igen: %s + Mindig szavazhat erre a referendumra később + Eltávolítás a szavazási listáról a referendum %s? + Néhány referendum már nem elérhető szavazásra, vagy lehet, hogy nincs elegendő token szavazáshoz. Elérhető szavazásra: %s. + Néhány referendum kizárva a szavazási listáról + A referendum adatai nem tölthetők be + Nincs adat lekérve + Ön már az összes elérhető referendumra szavazott, vagy jelenleg nincs szavazásra rendelkezésre álló referendum. Térjen vissza később. + Ön már az összes elérhető referendumra szavazott + Kért: + Szavazási lista + %d hátra + Szavazatok megerősítése + Nincs szavazásra váró referendum + Erősítse meg szavazatait + Nincsenek szavazatok + Sikeresen szavazott %d referendumra + Nincs elegendő egyenlege ahhoz, hogy szavazzon a jelenlegi szavazati erővel %s (%sx). Kérjük, változtassa meg a szavazati erőt vagy adjon hozzá több pénzt a pénztárcájához. + Nincs elegendő egyenleg a szavazáshoz + Ellene: %s + SwipeGov + Szavazzon %d népszavazásra + A szavazás a jövőbeni szavazásokra lesz beállítva a SwipeGovban + Szavazati erő + Stake-elés + Tárca + Ma + Coingecko hivatkozás az árakhoz (opcionális) + Válasszon szolgáltatót a(z) %s token vásárlásához + A fizetési módok, díjak és korlátok szolgáltatónként eltérőek.\nHasonlítsa össze az ajánlatukat, hogy megtalálja a legjobb lehetőséget. + Válasszon szolgáltatót a(z) %s token eladásához + A vásárlás folytatásához átirányítunk a Pezkuwi Wallet alkalmazásból ide: %s + Folytatás a böngészőben? + Egyik szolgáltatónk sem támogatja jelenleg ennek a tokennek a vásárlását vagy eladását. Kérjük, válasszon másik tokent, másik hálózatot, vagy térjen vissza később. + Ez a token nem támogatott a vásárlás/eladás funkció által + Hash másolása + Díj + Innen + Extrinsic Hash + Átutalás részletei + Megtekintés itt: %s + Megtekintés Polkascan-en + Megtekintés Subscan-en + %s - %s-kor + A korábbi %s tranzakció történeted még mindig elérhető a(z) %s-n + Befejezett + Sikertelen + Függőben + Tranzakciók Értesítési Csatornája + Kriptovásárlás már $5-tól + Kripto eladás már $10-től + Feladó: %s + Címzett: %s + Átutalás + Itt fognak megjelenni a\nbejövő és kimenő utalások + Itt fognak megjelenni a műveleteid + Szavazatok eltávolítása delegáláshoz a következő kategóriákban + Kategóriák melyekben már delegáltál szavazatokat + Nem elérhető kategóriák + Kategóriák melyekben már szavaztál + Ne mutassa újra.\nAz örökölt címet a Befogadásnál találhatja meg. + Örökölt formátum + Új formátum + Egyes cseréknek még szükségük lehet az örökölt formátumra,\namíg frissítik a rendszert. + Új egységes cím + Telepítés + Verzió %s + Frissítés érhető el + A problémák elkerülése és a felhasználói élmény javítása érdekében erősen ajánljuk, hogy a legújabb frissítéseket mielőbb telepítsd + Kritikus frissítés + Legújabb + A Pezkuwi Wallet számos izgalmas új funkciót kínál! Ne felejtsd el frissíteni az alkalmazását, hogy hozzáférj ezekhez + Jelentős frissítés + Kritikus + Jelentős + Az összes elérhető frissítés megtekintése + Név + Tárca neve + A név kizárólag csak számodra lesz látható és csakis ezen eszközön lesz tárolva. + Ezt a fiókot a hálózat nem választotta meg a jelenlegi korszakban való részvételre. + Újraszavaz + Szavazás + Szavazás állapota + A kártyád feltöltése folyamatban van! + A kártyád kiállítása folyamatban van! + Eltarthat akár 5 percig.\nEz az ablak automatikusan bezáródik. + Becsült %s + Vétel + Vétel/Eladás + Token vásárlás + Vásárlás a következővel + Fogadás + %s fogadása + Csak %1$s tokent és a %2$s hálózatban lévő tokeneket küldjön erre a címre, különben elveszítheti a pénzét + Elad + Token eladás + Küldés + Váltás + Eszközök + Itt fognak megjelenni eszközeid.\nGyőződj meg róla, hogy a \"Nulla egyenlegűek elrejtése\"\n szűrő ki van kapcsolva. + Eszközök értéke + Elérhető + Stake-elt + Egyenleg részletei + Teljes egyenleg + Összesen az átutalás után + Lefagyasztott + Zárolt + Kiváltható + Fenntartott + Átutalható + Unstake-elés + Tárca + Új kapcsolat + + %s fiók hiányzik. Add hozzá a tárca beállításaiban + %s fiókok hiányoznak. Add hozzá őket a tárca beállításaiban + + A \\" %s \\" által kért egyes hálózatok nem támogatottak a Pezkuwi Wallet ben + Itt fognak megjelenni a WalletConnect munkamenetek + WalletConnect + Ismeretlen DApp + + %s nem támogatott hálózat el van rejtve + %s nem támogatott hálózat el van rejtve + + WalletConnect v2 + Láncok közötti átutalás + Kriptovaluták + Fiat valuták + Népszerű fiat valuták + Valuta + Extrinsic részletei + Nulla egyenlegűek elrejtése + Egyéb tranzakciók + Mutat + Jutalmak és Büntetések + Átváltások + Szűrők + Átutalások + Eszközök kezelése + Hogyan lehet pénztárcát hozzáadni? + Hogyan lehet pénztárcát hozzáadni? + Hogyan lehet pénztárcát hozzáadni? + Példák névre: Fő számla, Saját validátor, Dotsama közösségi hitelek stb. + QR-kód megosztása a küldővel + A QR-kódot a feladó beolvashatja + A(z) %s címem %s fogadásához + QR-kód megosztása + Címzett + Győződj meg róla, hogy a cím\na megfelelő hálózatból származik + A cín formátuma érvénytelen.\nGyőzödj meg róla, hogy a cím\na megfelelő hálózatból származik + Minimális egyenleg + A láncok közötti korlátozások miatt nem lehet többet átutalni, mint %s + Az egyenleged nem elegendő a(z) %s láncok közötti díj kifizetéséhez. \nFennmaradó egyenleged a díj megfizetése után: %s + A beírt összegen felül a láncok közötti díj is felszámolásra kerül. A címzett megkaphatja a láncok közötti díj egy részét + Átutalás megerősítése + Láncok között + Láncok közötti díj + Utalásod sikertelen lesz, mivel a célfiók nem rendelkezik elegendő %s tokennel, ahhoz hogy egyéb tokeneket fogadhasson + A címzett nem tudja elfogadni az átutalást + Az átutalás sikertelen lesz, mivel a célszámlán lévő végösszeg kisebb lesz, mint a minimális egyenleg. Kérlek, próbáld meg növelni az összeget. + Az utalás eltávolítja a fiókot a blokkláncról, mivel a teljes egyenleg kevesebb lesz a minimálisan szükségenél. + A fiókod el lesz távolítva a blokkláncról az utalás után, mivel a fennmaradó egyenleg kevesebb lesz, mint a minimum + Az utalás el fogja távolítani a fiókot + A fiókod el lesz távolítva a blokkláncról az utalás után, mivel a fennmaradó egyenleg kevesebb lesz, mint a minimum. A fennmaradó egyenleget szintén a címzett fogja megkapni. + Hálózatról + Legalább %s kell ahhoz, hogy kifizesd a tranzakciós díjat és, hogy a hálozat által megszabott minimális egyenleg felett maradj. A jelenlegi egyenleged: %s. A művelet végrehajtásához kell még %s. + Magamnak + Láncon + A következő cím: %s köztudottan adathalász tevékenységekhez használatos, ezért nem javasoljuk tokenek küldését erre a címre. Mindenképpen folytatni szeretnéd? + Átverési figyelmeztetés + A címzettet a token tulajdonosa letiltotta, jelenleg nem tud bejövő utalásokat fogadni + A címzett nem tud átutalást fogadni + Célhálózat + Hálózatra + %s küldése innen: + %s küldése ezen: + ide: + Feladó + Tokenek + Küldés ennek a kapcsolatnak + Átutalás részletei + %s (%s) + %s címek a következőhöz: %s + A Pezkuwi problémákat észlet bizonyos %1$s címek integritásával kapcsolatban. Kérlek, vedd fel a kapcsolat a(z) %1$s tulajdonosával az integritási problémák kiküszöbölése érdekében. + Az integritás ellenőrzése sikertelen + Érvénytelen címzett + Nem található érvényes cím a(z) %s névhez a(z) %s hálózaton + A(z) %s nem található + A %1$s w3n szolgáltatások nem elérhetőek. Próbáld meg később vagy add meg a(z) %1$s címet manuálisan. + Sikertelen w3n keresés + A %s token kód nem értelmezhető Pezkuwi számára + %s token jelenleg nem támogatott + Tegnap + A Yield Boost ki lesz kapcsolva a jelenlegi kollátornál. Új kollátor: %s + Lecseréljük a Yield Boost kollátort? + Az egyenleged nem elegendő a(z) %s hálózati díj kifizetéséhez és a(z) %s Yield Boost indítási díjához. \nElérhető egyenleged a díj megfizetéséhez: %s + Nincs elegendő token az első végrehajtási díj megfizetéséhez + Az egyenleged nem elegendő a(z) %s hálózati díj kifizetéséhez és hogy ne essen a(z) %s küszöb alá. \nElérhető egyenleged a díj megfizetéséhez: %s + Nincs elegendő token a küszöb felett maradáshoz + Stake növekedési idő + A Yield Boost %s automatikusan stake-elni fogja a %s feletti utalható tokeneket + Yield Boost-olt + + + DOT ↔ HEZ híd + DOT ↔ HEZ híd + Küldöd + Kapod (becsült) + Átváltási árfolyam + Híd díja + Minimum + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Csere + A HEZ→DOT cserék akkor kerülnek feldolgozásra, amikor elegendő DOT likviditás áll rendelkezésre. + Elégtelen egyenleg + Az összeg a minimum alatt van + Írd be az összeget + A HEZ→DOT cserék korlátozott elérhetőségűek lehetnek a likviditástól függően. + A HEZ→DOT cserék ideiglenesen nem elérhetők. Próbáld újra, amikor elegendő DOT likviditás áll rendelkezésre. + diff --git a/common/src/main/res/values-in/strings.xml b/common/src/main/res/values-in/strings.xml new file mode 100644 index 0000000..0034f48 --- /dev/null +++ b/common/src/main/res/values-in/strings.xml @@ -0,0 +1,2033 @@ + + + Hubungi Kami + Github + Kebijakan Privasi + Beri peringkat kami + Telegram + Syarat dan Ketentuan + Syarat & Ketentuan + Tentang + Versi Aplikasi + Website + Masukkan alamat %s yang valid... + Alamat EVM harus valid atau kosong... + Masukkan alamat substrate yang valid... + Tambahkan akun + Tambah alamat + Akun sudah ada. Silakan coba yang lain. + Lacak dompet apa pun dengan alamatnya + Tambahkan dompet hanya-sontak + Tuliskan Frasa Rahasiamu + Pastikan untuk menulis frasa Anda dengan benar dan rapi. + Alamat %s + Tidak ada akun di %s + Konfirmasi mnemonik + Mari kita memeriksanya sekali lagi + Pilih kata-kata dalam urutan yang benar + Buat akun baru + Jangan gunakan clipboard atau tangkapan layar di perangkat seluler Anda, cari metode yang aman untuk cadangan (mis., kertas) + Nama hanya akan digunakan secara lokal di aplikasi ini. Anda dapat mengeditnya nanti + Buat nama dompet + Cadangkan frasa mnemonik + Buat dompet baru + Mnemonik digunakan untuk memulihkan akses ke akun. Tulislah, kami tidak akan dapat memulihkan akun Anda tanpa itu! + Akun dengan sandi yang diubah + Lupakan + Pastikan Anda telah mengekspor dompet Anda sebelum melanjutkan. + Lupakan dompet? + Jalur derivasi Ethereum tidak valid + Jalur derivasi Substrate tidak valid + Dompet ini dipasangkan dengan %1$s. Pezkuwi akan membantu Anda melakukan operasi apa pun yang Anda inginkan, dan Anda akan diminta untuk menandatanganinya menggunakan %1$s + Tidak didukung oleh %s + Ini adalah dompet hanya untuk ditonton, Pezkuwi dapat menunjukkan saldo dan informasi lainnya, tetapi Anda tidak dapat melakukan transaksi apa pun dengan dompet ini + Masukkan nama panggilan dompet... + Silakan, coba yang lain. + Jenis kripto pasangan kunci Ethereum + Jalur derivasi rahasia Ethereum + Akun EVM + Ekspor akun + Ekspor + Impor yang sudah ada + Kata sandi ini diperlukan untuk mengenkripsi akun Anda dan digunakan bersama file JSON ini untuk memulihkan dompet Anda. + Atur kata sandi untuk file JSON Anda + Simpan rahasia Anda dan simpan di tempat aman + Catat rahasia Anda dan simpan di tempat aman + JSON pemulihan tidak valid. Harap pastikan bahwa input mengandung JSON yang valid. + Seed tidak valid. Harap pastikan bahwa input Anda mengandung 64 simbol hex. + JSON tidak mengandung informasi jaringan. Harap tentukan di bawah ini. + Berikan Restore JSON Anda + Biasanya frasa berisi 12 kata (tetapi mungkin 15, 18, 21, atau 24) + Tulis kata-kata secara terpisah dengan satu spasi, tanpa koma atau tanda lainnya + Masukkan kata-kata dalam urutan yang benar + Kata Sandi + Impor kunci pribadi + 0xAB + Masukkan seed mentah Anda + Pezkuwi kompatibel dengan semua aplikasi + Impor dompet + Akun + Jalur derivasi Anda mengandung simbol yang tidak didukung atau memiliki struktur yang salah + Jalur derivasi tidak valid + File JSON + Pastikan %s di perangkat Ledger Anda menggunakan aplikasi Ledger Live + Aplikasi Polkadot sudah terinstal + %s di perangkat Ledger Anda + Buka aplikasi Polkadot + Flex, Stax, Nano X, Nano S Plus + Ledger (Aplikasi Polkadot Generik) + Pastikan aplikasi Jaringan terinstal di perangkat Ledger Anda menggunakan aplikasi Ledger Live. Buka aplikasi jaringan di perangkat Ledger Anda. + Tambahkan setidaknya satu akun + Tambahkan akun ke dompet Anda + Panduan Koneksi Ledger + Pastikan %s di perangkat Ledger Anda menggunakan aplikasi Ledger Live + aplikasi Network terpasang + %s pada perangkat Ledger Anda + Buka aplikasi jaringan + Izinkan Pezkuwi Wallet untuk %s + akses Bluetooth + %s untuk ditambahkan ke dompet + Pilih akun + %s di pengaturan ponsel Anda + Aktifkan OTG + Hubungkan Ledger + Untuk menandatangani operasi dan memigrasikan akun Anda ke aplikasi Ledger Generik yang baru, instal dan buka aplikasi Migrasi. Aplikasi Ledger Lama dan Ledger Migrasi tidak akan didukung di masa depan. + Aplikasi Ledger Baru Telah Dirilis + Polkadot Migration + Aplikasi Migrasi akan tidak tersedia dalam waktu dekat. Gunakan untuk memigrasikan akun Anda ke aplikasi Ledger baru untuk menghindari kehilangan dana Anda. + Polkadot + Ledger Nano X + Ledger Legacy + Jika menggunakan Ledger via Bluetooth, aktifkan Bluetooth pada kedua perangkat dan berikan izin Bluetooth dan lokasi ke Pezkuwi. Untuk USB, aktifkan OTG di pengaturan ponsel Anda. + Harap, aktifkan Bluetooth dalam pengaturan ponsel Anda dan perangkat Ledger. Buka perangkat Ledger Anda dan buka aplikasi %s. + Pilih Perangkat Ledger Anda + Silakan, aktifkan perangkat Ledger. Buka kunci perangkat Ledger Anda dan buka aplikasi %s. + Anda hampir selesai! 🎉\n Cukup ketuk di bawah untuk menyelesaikan pengaturan dan mulai menggunakan akun Anda dengan lancar di Aplikasi Polkadot dan Pezkuwi Wallet + Selamat datang di Pezkuwi! + Frasa 12, 15, 18, 21 atau 24 kata + Multisig + Kontrol bersama (multisig) + Anda tidak memiliki akun untuk jaringan ini, Anda dapat membuat atau mengimpor akun. + Akun Diperlukan + Tidak ditemukan akun + Pasangkan kunci publik + Parity Signer + %s tidak mendukung %s + Akun-akun berikut telah berhasil dibaca dari %s + Berikut adalah akun Anda + Kode QR tidak valid, pastikan Anda memindai kode QR dari %s + Pastikan untuk memilih yang paling atas + Aplikasi %s di smartphone Anda + buka Parity Signer + %s yang ingin Anda tambahkan ke Pezkuwi Wallet + Pergi ke tab Keys. Pilih seed, kemudian akun + Parity Signer akan menyediakan Anda %s + kode QR untuk dipindai + Tambahkan dompet dari %s + %s tidak mendukung penandatanganan pesan sembarangan — hanya transaksi + Penandatanganan tidak didukung + Pindai kode QR dari %s + Legacy + Baru (Vault v7+) + Saya memiliki kesalahan di %s + Kode QR telah kedaluwarsa + Untuk alasan keamanan, operasi yang dihasilkan hanya berlaku untuk %s.\nSilakan hasilkan kode QR baru dan tandai dengan %s + Kode QR valid selama %s + Pastikan Anda memindai kode QR untuk operasi yang sedang ditandatangani saat ini + Tanda tangani dengan %s + Polkadot Vault + Perhatikan, nama jalur derivasi harus kosong + Aplikasi %s di smartphone Anda + Buka Polkadot Vault + %s yang ingin Anda tambahkan ke Pezkuwi Wallet + Ketuk pada Kunci Turunan + Polkadot Vault akan memberikan Anda %s + kode QR untuk dipindai + Ketuk ikon di pojok kanan atas dan pilih %s + Ekspor Kunci Pribadi + Kunci privat + Diwakilkan + Didelegasikan kepada Anda (Proxied) + Sembarang + Lelang + Batal Proxy + Pemerintahan + Identitas Penilaian + Kolam Nominasi + Non-transfer + Staking + Sudah memiliki akun + rahasia + 64 simbol hexadesimal + Pilih dompet hardware + Pilih jenis rahasia Anda + Pilih dompet + Akun dengan rahasia bersama + Akun Substrate + Tipe kripto pasangan kunci Substrate + Jalur pemutusan rahasia Substrate + Nama dompet + Nama panggilan dompet + Moonbeam, Moonriver dan jaringan lainnya + Alamat EVM (Opsional) + Dompet preset + Polkadot, Kusama, Karura, KILT dan 50+ jaringan + Lacak aktivitas dompet apa pun tanpa menyuntikkan kunci pribadi Anda ke Pezkuwi Wallet + Dompet Anda hanya dapat dilihat, artinya Anda tidak dapat melakukan operasi apa pun dengannya + Ups! Kunci hilang + hanya untuk ditonton + Polkadot Vault, Parity Signer, atau Ledger + Dompet hardware + Gunakan akun Trust Wallet Anda di Pezkuwi + Trust Wallet + Tambah akun %s + Tambah dompet + Ubah akun %s + Ubah akun + Ledger (Legacy) + Didelegasikan kepada Anda + Kontrol bersama + Tambahkan node kustom + Anda perlu menambahkan akun %s ke dompet untuk mendelagasikan + Masukkan detail jaringan + Mendelagasi ke + Akun yang didelagasi + Dompet yang didelagasi + Berikan tipe akses + Deposit tetap direservasi di akun Anda sampai proxy dihapus. + Anda telah mencapai batas %s proxy yang ditambahkan dalam %s. Hapus proxy untuk menambahkan yang baru. + Jumlah maksimum proxy telah tercapai + Jaringan kustom yang ditambahkan\nakan muncul di sini + +%d + Pezkuwi telah secara otomatis beralih ke dompet multisig Anda sehingga Anda dapat melihat transaksi yang tertunda. + Berwarna + Penampilan + ikon token + Putih + Alamat kontrak yang dimasukkan ada dalam Pezkuwi sebagai token %s. + Alamat kontrak yang dimasukkan ada dalam Pezkuwi sebagai token %s. Apakah Anda yakin ingin memodifikasinya? + Token ini sudah ada + Pastikan URL yang disediakan memiliki bentuk berikut: www.coingecko.com/id/coins/tether. + Tautan CoinGecko Tidak Valid + Alamat kontrak yang dimasukkan bukan kontrak ERC-20 %s. + Alamat Kontrak Tidak Valid + Desimal harus minimal 0, dan tidak melebihi 36. + Nilai Desimal Tidak Valid + Masukkan alamat kontrak + Masukkan desimal + Masukkan simbol + Pergi ke %s + Mulai %1$s, saldo %2$s Anda, Staking, dan Tata Kelola akan ada di %3$s — dengan kinerja yang lebih baik dan biaya yang lebih rendah. + Token %s Anda sekarang di %s + Jaringan + Token + Tambahkan token + Alamat kontrak + Desimal + Simbol + Masukkan detail token ERC-20 + Pilih jaringan untuk menambahkan token ERC-20 + Crowdloans + Governance v1 + OpenGov + Pemilihan + Staking + Penguncian + Beli token + Menerima DOT Anda kembali dari crowdloans? Mulai melakukan staking DOT Anda hari ini untuk mendapatkan imbalan maksimal yang mungkin! + Tingkatkan DOT Anda 🚀 + Filter token + Anda tidak memiliki token untuk diberikan.\nBeli atau Setor token ke akun Anda. + Semua jaringan + Kelola token + Jangan transfer %s ke akun yang dikendalikan Ledger karena Ledger tidak mendukung pengiriman %s, sehingga aset akan terjebak di akun ini + Ledger tidak mendukung token ini + Cari berdasarkan jaringan atau token + Tidak ada jaringan atau token dengan nama yang dimasukkan ditemukan + Cari berdasarkan token + Dompet Anda + Anda tidak memiliki token untuk dikirim. Beli atau Terima token ke akun Anda. + Token untuk membayar + Token untuk menerima + Sebelum melanjutkan dengan perubahan,%s untuk dompet yang dimodifikasi dan dihapus! + pastikan Anda telah menyimpan Frasa Sandi + Terapkan Pembaruan Cadangan? + Siapkan untuk menyimpan dompet Anda! + Frasa sandi ini memberi Anda akses total dan permanen ke semua dompet yang terhubung dan dana di dalamnya. + JANGAN BAGIKAN ITU. + Jangan masukkan Frasa Sandi Anda ke dalam formulir atau situs web. + DANA DAPAT HILANG SELAMANYA. + Dukungan atau admin tidak akan pernah meminta Frasa Sandi Anda dalam keadaan apa pun.\n%s + AWAS PENIPU. + Tinjau & Terima untuk Melanjutkan + Hapus cadangan + Anda dapat mencadangkan Frasa Sandi Anda secara manual untuk memastikan akses ke dana dompet Anda jika Anda kehilangan akses ke perangkat ini + Cadangkan secara manual + Manual + Anda belum menambahkan dompet mana pun dengan Frasa Sandi. + Tidak ada dompet untuk dicadangkan + Anda dapat mengaktifkan cadangan Google Drive untuk menyimpan salinan terenkripsi dari semua dompet Anda, diamankan dengan kata sandi yang Anda tentukan. + Cadangkan ke Google Drive + Google Drive + Cadangan + Cadangan + Proses KYC yang sederhana dan efisien + Otentikasi Biometrik + Tutup Semua + Pembelian dimulai! Harap tunggu hingga 60 menit. Anda dapat melacak statusnya melalui email. + Pilih jaringan untuk membeli %s + Pembelian dimulai! Harap tunggu hingga 60 menit. Anda dapat melacak statusnya di email. + Tidak ada penyedia kami yang saat ini mendukung pembelian token ini. Silakan pilih token lain, jaringan lain, atau periksa kembali nanti. + Token ini tidak didukung oleh fitur beli + Kemampuan untuk membayar biaya dalam token apa pun + Migrasi terjadi secara otomatis, tidak perlu tindakan + Riwayat transaksi lama tetap ada di %s + Mulai dengan %1$s saldo %2$s Anda, Staking dan Tata Kelola ada di %3$s. Fitur-fitur ini mungkin tidak tersedia hingga 24 jam. + %1$sx biaya transaksi lebih rendah\n(dari %2$s ke %3$s) + %1$sx pengurangan saldo minimal\n(dari %2$s ke %3$s) + Apa yang membuat Asset Hub hebat? + Memulai %1$s saldo %2$s Anda, Staking dan Governance berada di %3$s + Lebih banyak token didukung: %s, dan token ekosistem lainnya + Akses terpadu ke %s, aset, staking, dan tata kelola + Node saldo otomatis + Aktifkan koneksi + Silakan masukkan kata sandi yang akan digunakan untuk memulihkan dompet Anda dari cadangan cloud. Kata sandi ini tidak dapat dipulihkan di masa depan, jadi pastikan untuk mengingatnya! + Perbarui kata sandi cadangan + Aset + Saldo tersedia + Maaf, permintaan pengecekan saldo gagal. Silakan coba lagi nanti. + Maaf, kami tidak dapat menghubungi penyedia transfer. Silakan coba lagi nanti. + Maaf, Anda tidak memiliki dana yang cukup untuk menghabiskan jumlah yang ditentukan + Biaya transfer + Jaringan tidak merespons + Ke + Ups, hadiah tersebut sudah diklaim + Hadiah tidak dapat diterima + Klaim hadiah + Mungkin ada masalah dengan server. Silakan coba lagi nanti. + Ups, sesuatu tidak beres + Gunakan dompet lain, buat yang baru, atau tambahkan akun %s ke dompet ini di Pengaturan. + Anda berhasil mengklaim hadiah. Token akan segera muncul di saldo Anda. + Anda mendapatkan hadiah kripto! + Buat dompet baru atau impor yang sudah ada untuk mengklaim hadiah + Anda tidak dapat menerima hadiah dengan dompet %s + Semua tab yang terbuka di browser DApp akan ditutup. + Tutup Semua DApps? + Lakukan %s dan selalu simpan offline untuk memulihkannya kapan saja. Anda dapat melakukannya di Pengaturan Cadangan. + Harap catat semua Frasa Sandi dompet Anda sebelum melanjutkan + Cadangan akan dihapus dari Google Drive + Cadangan saat ini dengan dompet Anda akan dihapus secara permanen! + Apakah Anda yakin ingin menghapus Cadangan Cloud? + Hapus Cadangan + Saat ini cadangan Anda tidak disinkronkan. Harap tinjau pembaruan ini. + Perubahan Cadangan Cloud ditemukan + Tinjau Pembaruan + Jika Anda tidak secara manual mencatat Frasa Sandi dompet untuk dompet yang akan dihapus, maka dompet-domepet tersebut dan semua asetnya akan hilang selamanya dan tidak dapat dipulihkan lagi. + Apakah Anda yakin ingin menerapkan perubahan ini? + Tinjau Masalah + Saat ini cadangan Anda tidak disinkronkan. Harap tinjau masalah tersebut. + Perubahan dompet gagal diperbarui di Cloud Backup + Pastikan Anda masuk ke akun Google dengan kredensial yang benar dan telah memberikan akses Pezkuwi Wallet ke Google Drive + Otentikasi Google Drive gagal + Anda tidak memiliki cukup ruang penyimpanan Google Drive yang tersedia. + Tidak Cukup Penyimpanan + Sayangnya, Google Drive tidak berfungsi tanpa layanan Google Play, yang tidak ada di perangkat Anda. Coba dapatkan layanan Google Play + Layanan Google Play tidak ditemukan + Tidak dapat mencadangkan dompet Anda ke Google Drive. Pastikan Anda telah mengaktifkan Pezkuwi Wallet untuk menggunakan Google Drive Anda dan memiliki ruang penyimpanan yang cukup, kemudian coba lagi. + Kesalahan Google Drive + Silakan periksa kebenaran kata sandi dan coba lagi. + Kata Sandi Tidak Valid + Sayangnya, kami tidak menemukan cadangan untuk mengembalikan dompet + Tidak Ada Cadangan Ditemukan + Pastikan Anda telah menyimpan Frasa Sandi untuk dompet sebelum melanjutkan. + Dompet akan dihapus dalam Cadangan Awan + Ulasan Kesalahan Cadangan + Ulasan Pembaruan Cadangan + Masukkan Kata Sandi Cadangan + Aktifkan untuk mencadangkan dompet ke Google Drive Anda + Sinkronisasi Terakhir: %s pada %s + Masuk ke Google Drive + Ulasan Masalah Google Drive + Cadangan Dinonaktifkan + Cadangan Disinkronkan + Menyinkronkan Cadangan... + Cadangan Belum Disinkronkan + Dompet baru secara otomatis ditambahkan ke Cadangan Awan. Anda dapat menonaktifkan Cadangan Awan di Pengaturan. + Perubahan dompet akan diperbarui dalam Cadangan Awan + Terima syarat dan ketentuan... + Akun + Alamat akun + Aktif + Tambah + Tambahkan delegasi + Tambahkan jaringan + Alamat + Tingkat lanjut + Semua + Izinkan + Jumlah + Jumlah terlalu rendah + Jumlah terlalu besar + Diterapkan + Terapkan + Tanyakan lagi + Siapkan untuk menyimpan dompet Anda! + Tersedia: %s + Rata-rata + Saldo + Bonus + Panggil + Data pemanggilan + Hash panggilan + Batal + Apakah Anda yakin ingin membatalkan operasi ini? + Maaf, Anda tidak memiliki aplikasi yang tepat untuk memproses permintaan ini + Tidak dapat membuka tautan ini + Anda bisa menggunakan hingga %s karena Anda perlu membayar\n%s untuk biaya jaringan. + Rantai + Ubah + Ubah kata sandi + Lanjutkan secara otomatis di masa depan + Pilih jaringan + Bersihkan + Tutup + Apakah Anda yakin ingin menutup layar ini?\nPerubahan Anda tidak akan diterapkan. + Cadangan awan + Selesai + Selesai (%s) + Konfirmasi + Konfirmasi + Apakah Anda yakin? + Dikonfirmasi + %d ms + menghubungkan... + Silakan periksa koneksi Anda atau coba lagi nanti + Gagal terhubung + Lanjutkan + Disalin ke clipboard + Salin alamat + Salin data pemanggilan + Salin hash + Salin id + Tipe kripto kepair + Tanggal + %s dan %s + Hapus + Penyetor + Detail + Nonaktif + Putus + Jangan tutup aplikasinya! + Selesai + Pezkuwi mensimulasikan transaksi sebelumnya untuk mencegah kesalahan. Simulasi ini tidak berhasil. Coba lagi nanti atau dengan jumlah yang lebih tinggi. Jika masalah berlanjut, silakan hubungi Dukungan Pezkuwi Wallet di Pengaturan. + Simulasi transaksi gagal + Edit + %s (+%s lainnya) + Pilih aplikasi email + Aktifkan + Masukkan alamat... + Masukkan jumlah... + Masukkan detail + Masukkan jumlah lain + Masukkan kata sandi + Kesalahan + Token tidak mencukupi + Acara + EVM + Alamat EVM + Akun Anda akan dihapus dari blockchain setelah operasi ini karena saldo totalnya lebih rendah dari minimal + Operasi akan menghapus akun + Kedaluwarsa + Jelajahi + Gagal + Biaya jaringan yang diperkirakan %s jauh lebih tinggi dari biaya jaringan default (%s). Hal ini mungkin disebabkan oleh kemacetan jaringan sementara. Anda dapat menyegarkan untuk menunggu biaya jaringan yang lebih rendah. + Biaya jaringan terlalu tinggi + Biaya: %s + Urutkan berdasarkan: + Penyaring + Temukan lebih lanjut + Lupa kata sandi? + + setiap %s hari + + setiap hari + setiap hari + Detail lengkap + Dapatkan %s + Hadiah + Dimengerti + Tata Kelola + String heksadesimal + Sembunyikan + + %d jam + + Bagaimana cara kerjanya + Saya mengerti + Informasi + Kode QR tidak valid + Kode QR tidak valid + Pelajari lebih lanjut + Temukan lebih banyak tentang + Ledger + %s tersisa + Kelola Dompet + Maksimum + %s maksimum + + %d menit + + Akun %s tidak ditemukan + Ubah + Modul + Nama + Jaringan + Ethereum + %s tidak didukung + Polkadot + Jaringan-jaringan + + Jaringan-jaringan + + Berikutnya + Tidak + Aplikasi impor file tidak ditemukan di perangkat. Silakan instal aplikasinya dan coba lagi + Tidak ada aplikasi yang cocok ditemukan di perangkat untuk menangani tujuan ini + Tidak ada perubahan + Kami akan menampilkan frasa sandi Anda. Pastikan tidak ada yang bisa melihat layar Anda dan jangan mengambil tangkapan layar - mereka dapat dikumpulkan oleh malware pihak ketiga + Tidak ada + Tidak tersedia + Maaf, Anda tidak memiliki cukup dana untuk membayar biaya jaringan. + Saldo tidak mencukupi + Saldo Anda tidak cukup untuk membayar biaya jaringan sebesar %s. Saldo saat ini adalah %s + Tidak sekarang + Mati + OK + Baiklah, kembali + Hidup + Sedang berlangsung + Opsional + Pilih opsi + Frasa sandi + Tempel + / tahun + %s / tahun + per tahun + %% + Izin yang diminta diperlukan untuk menggunakan layar ini. Anda harus mengaktifkannya di Pengaturan. + Izin ditolak + Izin yang diminta diperlukan untuk menggunakan layar ini. + Izin diperlukan + Harga + Kebijakan Privasi + Lanjutkan + Deposit Proxy + Batalkan Akses + Notifikasi Dorong + Baca lebih lanjut + Disarankan + Segarkan biaya + Tolak + Hapus + Dibutuhkan + Atur ulang + Coba lagi + Ada yang salah. Silakan coba lagi + Cabut + Simpan + Pindai kode QR + Cari + Hasil pencarian akan ditampilkan di sini + Hasil pencarian: %d + detik + + %d detik + + Jalur derivasi rahasia + Lihat Semua + Pilih token + Pengaturan + Bagikan + Bagikan data pemanggilan + Bagikan hash + Tampilkan + Masuk + Permintaan Tanda Tangan + Penandatangan + Tanda tangan tidak valid + Lewati + Lewati proses + Terjadi kesalahan saat mengirim beberapa transaksi. Apakah Anda ingin mencoba lagi? + Gagal mengirim beberapa transaksi + Ada yang salah + Urutkan berdasarkan + Status + Substrate + Alamat Substrate + Ketuk untuk mengungkap + Syarat dan Ketentuan + Testnet + Waktu tersisa + Judul + Buka Pengaturan + Saldo Anda terlalu kecil + Total + Biaya total + ID Transaksi + Transaksi dikirim + Coba lagi + Jenis + Silakan coba lagi dengan input lain. Jika kesalahan terjadi lagi, silakan hubungi dukungan. + Tidak diketahui + + %s tidak didukung + + Tidak terbatas + Perbarui + Gunakan + Gunakan maksimum + Penerima harus merupakan alamat %s yang valid + Penerima tidak valid + Lihat + Menunggu + Dompet + Peringatan + Ya + Hadiah Anda + Jumlah harus positif + Silakan masukkan kata sandi yang Anda buat selama proses pencadangan + Masukkan kata sandi cadangan saat ini + Mengonfirmasi akan mentransfer token dari akun Anda + Pilih kata-kata... + Passphrase tidak valid, silakan periksa kembali urutan kata-kata + Referendum + Voting + Lacak + Node sudah ditambahkan sebelumnya. Silakan coba node lain. + Tidak dapat menjalin koneksi dengan node. Silakan coba yang lain. + Sayangnya, jaringan tidak didukung. Silakan coba salah satu yang berikut: %s. + Konfirmasi penghapusan %s. + Hapus jaringan? + Mohon periksa koneksi Anda atau coba lagi nanti + Kustom + Default + Jaringan + Tambahkan koneksi + Pindai kode QR + Sebuah masalah telah teridentifikasi dengan cadangan Anda. Anda memiliki opsi untuk menghapus cadangan saat ini dan membuat yang baru. %s sebelum melanjutkan. + Pastikan Anda telah menyimpan Frasa Sandi untuk semua dompet + Cadangan ditemukan tetapi kosong atau rusak + Di masa depan, tanpa kata sandi cadangan tidak mungkin untuk mengembalikan dompet Anda dari Cloud Backup.\n%s + Kata sandi ini tidak dapat dipulihkan. + Ingat Kata Sandi Cadangan + Konfirmasi kata sandi + Kata sandi cadangan + Huruf + Min. 8 karakter + Angka + Kata sandi cocok + Harap masukkan kata sandi untuk mengakses cadangan Anda kapan saja. Kata sandi tidak dapat dipulihkan, pastikan untuk mengingatnya! + Buat kata sandi cadangan Anda + Chain ID yang dimasukkan tidak cocok dengan jaringan di URL RPC. + Chain ID tidak valid + Crowdloans pribadi belum didukung. + Crowdloan pribadi + Tentang crowdloans + Langsung + Pelajari lebih lanjut tentang berbagai kontribusi ke Acala + Cair + Aktif (%s) + Setuju dengan Syarat dan Ketentuan + Bonus Pezkuwi Wallet (%s) + Kode referral Astar harus merupakan alamat Polkadot yang valid + Tidak dapat berkontribusi dengan jumlah yang dipilih karena jumlah yang terkumpul akan melebihi batas crowdloan. Kontribusi maksimum yang diizinkan adalah %s. + Tidak dapat berkontribusi ke crowdloan yang dipilih karena batasnya telah tercapai. + Batas Cap Crowdloan Terlampaui + Berpartisipasi dalam crowdloan + Kontribusi + Anda sudah berkontribusi: %s + Liquid + Parallel + Kontribusi Anda\n akan muncul di sini + Akan dikembalikan dalam %s + Akan dikembalikan oleh parachain + %s (via %s) + Crowdloans + Dapatkan Bonus Spesial + Crowdloans akan ditampilkan di sini + Tidak dapat berkontribusi ke crowdloan yang dipilih karena sudah selesai + Crowdloan telah berakhir + Masukkan kode referral Anda + Informasi Crowdloan + Pelajari crowdloan %s + Situs crowdloan %s + Periode Sewa + Pilih parachains untuk berkontribusi %s Anda. Anda akan mendapatkan kembali token yang Anda kontribusikan, dan jika parachain menang, Anda akan menerima imbalan setelah akhir lelang + Anda perlu menambahkan akun %s ke dompet untuk berkontribusi + Terapkan bonus + Jika Anda tidak memiliki kode referral, Anda dapat menggunakan kode referral Pezkuwi untuk menerima bonus untuk kontribusi Anda + Anda belum menerapkan bonus + Crowdloan Moonbeam hanya mendukung akun tipe kripto SR25519 atau ED25519. Pertimbangkan untuk menggunakan akun lain untuk kontribusi + Tidak dapat berkontribusi dengan akun ini + Anda harus menambahkan akun Moonbeam ke dompet untuk berpartisipasi dalam crowdloan Moonbeam + Akun Moonbeam tidak ada + Crowdloan ini tidak tersedia di lokasi Anda. + Wilayah Anda tidak didukung + %s tujuan imbalan + Kirim persetujuan + Anda perlu mengirim persetujuan dengan Syarat & Ketentuan di blockchain untuk melanjutkan. Ini hanya perlu dilakukan sekali untuk semua kontribusi Moonbeam berikutnya + Saya telah membaca dan menyetujui Syarat dan Ketentuan + Terkumpul + Kode referral + Kode referral tidak valid. Silakan coba yang lain + Syarat dan Ketentuan %s + Jumlah minimum yang diizinkan untuk berkontribusi adalah %s. + Jumlah sumbangan terlalu kecil + Token %s Anda akan dikembalikan setelah periode penyewaan berakhir. + Kontribusi Anda + Terkumpul: %s dari %s + URL RPC yang dimasukkan sudah ada di Pezkuwi sebagai jaringan kustom %s. Apakah Anda yakin ingin mengubahnya? + https://networkscan.io + URL Block explorer (Opsional) + 012345 + ID Chain + TOKEN + Simbol Mata Uang + Nama Jaringan + Tambahkan node + Tambahkan node kustom untuk + Masukkan detail + Simpan + Sunting node kustom untuk + Nama + Nama node + wss:// + URL node + URL RPC + DApps di mana Anda mengizinkan akses untuk melihat alamat Anda saat Anda menggunakannya + DApp \'%s\' akan dihapus dari DApp yang Diotorisasi + Hapus dari Diotorisasi? + DApps yang Diotorisasi + Katalog + Setujui permintaan ini jika Anda mempercayai aplikasi tersebut + Mengizinkan ``%s\'\' untuk mengakses alamat akun Anda? + Setujui permintaan ini jika Anda mempercayai aplikasi ini.\nPeriksa detail transaksi. + DApp + DApps + %d DApps + Favorit + Favorit + Tambahkan ke favorit + DApp \"%s\" akan dihapus dari Favorit + Hapus dari Favorit? + Daftar DApps akan muncul di sini + Tambahkan ke Favorit + Mode Desktop + Hapus dari Favorit + Pengaturan Halaman + Oke, kembalikan saya + Pezkuwi Wallet percaya bahwa situs web ini dapat mengancam keamanan akun dan token Anda + Phishing terdeteksi + Cari berdasarkan nama atau masukkan URL + Rantai tidak didukung dengan hash genesis %s + Pastikan operasi sudah benar + Gagal menandatangani operasi yang diminta + Buka saja + Aplikasi desentralisasi yang berbahaya dapat menarik semua dana Anda. Selalu lakukan penelitian sendiri sebelum menggunakan aplikasi desentralisasi, memberikan izin, atau mengirim dana keluar.\n\nJika seseorang mendesak Anda untuk mengunjungi aplikasi desentralisasi ini, kemungkinan besar itu adalah penipuan. Jika ragu, harap hubungi dukungan Pezkuwi Wallet: %s. + Peringatan! Aplikasi desentralisasi tidak dikenal + Rantai tidak ditemukan + Domain dari tautan %s tidak diizinkan + Tipe tata kelola tidak ditentukan + Tipe tata kelola tidak didukung + Tipe kripto tidak valid + Format derivasi tidak valid + Mnemonic tidak valid + URL tidak valid + URL RPC yang dimasukkan sudah ada di Pezkuwi sebagai jaringan %s. + Saluran Notifikasi Default + +%d + Cari berdasarkan alamat atau nama + Format alamat tidak valid. Pastikan alamat sesuai dengan jaringan yang benar + hasil pencarian: %d + Akun Proxy dan Multisig terdeteksi otomatis dan diorganisir untuk anda. Kelola kapan saja di Pengaturan. + Daftar dompet telah diperbarui + Menggunakan suara sepanjang waktu + Delegasi + Semua akun + Perorangan + Organisasi + Periode pembatalan delegasi akan dimulai setelah Anda mencabut delegasi + Suara Anda akan secara otomatis menggunakan suara bersamaan dengan suara delegasi Anda + Info delegasi + Perorangan + Organisasi + Suara yang telah didelegasikan + Delegasi + Edit delegasi + Anda tidak dapat mendelagasikan kepada diri sendiri, harap pilih alamat yang berbeda + Tidak dapat melakukan delegasi kepada diri sendiri + Beritahu lebih banyak tentang diri Anda agar pengguna Pezkuwi lebih mengenal Anda + Apakah Anda seorang Delegasi? + Deskripsikan dirimu + Di seluruh %s trek + Memilih terakhir %s + Suara Anda melalui %s + Suara Anda: %s melalui %s + Hapus suara + Batalkan delegasi + Setelah periode pembatalan delegasi berakhir, Anda perlu membuka token Anda. + Suara yang Didelegasikan + Delegasi + Memilih terakhir %s + Trek + Pilih semua + Pilih setidaknya 1 trek... + Tidak ada trek yang tersedia untuk didelegasikan + Fellowship + Pemerintahan + Kekayaan + Periode Pembatalan Delegasi + Tidak lagi valid + Delegasi Anda + Delegasi Anda + Tampilkan + Menghapus cadangan... + Kata sandi cadangan Anda sebelumnya diperbarui. Untuk terus menggunakan Cloud Backup, %s + silakan masukkan kata sandi cadangan baru. + Kata sandi cadangan telah diubah + Anda tidak dapat menandatangani transaksi jaringan yang dinonaktifkan. Aktifkan %s di pengaturan dan coba lagi + %s dinonaktifkan + Anda sudah melakukan delegasi ke akun ini: %s + Delegasi sudah ada + (Kompatibel dengan BTC/ETH) + ECDSA + ed25519 (alternatif) + Edwards + Kata sandi cadangan + Masukkan data pemanggilan + Tidak cukup token untuk membayar biaya + Kontrak + Panggilan Kontrak + Fungsi + Pulihkan Dompet + %s Semua dompet Anda akan aman disimpan di Google Drive. + Apakah Anda ingin memulihkan dompet Anda? + Temukan Cadangan Cloud yang Ada + Unduh Kembalikan JSON + Konfirmasi kata sandi + Kata sandi tidak cocok + Atur kata sandi + Jaringan: %s\nMnemonic: %s\nJalur derivasi: %s + Jaringan: %s\nMnemonic: %s + Harap tunggu hingga biaya dihitung + Perhitungan biaya sedang berlangsung + Kelola Kartu Debit + Jual token %s + Tambahkan delegasi untuk staking %s + Detail Swap + Maks: + Anda membayar + Anda menerima + Pilih token + Isi ulang kartu dengan %s + Silakan hubungi support@pezkuwichain.io. Sertakan alamat email yang Anda gunakan untuk menerbitkan kartu. + Hubungi dukungan + Diambil + Dibuat: %s + Masukkan jumlah + Hadiah minimum adalah %s + Diklaim kembali + Pilih token untuk hadiah + Biaya jaringan pada klaim + Buat Hadiah + Kirim hadiah dengan cepat, mudah, dan aman di Pezkuwi + Bagikan Hadiah Kripto kepada Siapa Saja, Di Mana Saja + Hadiah yang Anda buat + Pilih jaringan untuk hadiah %s + Masukkan jumlah hadiah Anda + %s sebagai link dan undang siapa saja ke Pezkuwi + Bagikan hadiah secara langsung + %s, dan Anda dapat mengembalikan hadiah yang tidak diklaim kapan saja dari perangkat ini + Hadiah tersedia seketika + Saluran Pemberitahuan Pemerintahan + + Anda perlu memilih setidaknya %d trek + + Buka kunci + Melakukan tukar ini akan mengakibatkan slippage signifikan dan kerugian finansial. Pertimbangkan untuk mengurangi ukuran perdagangan Anda atau membagi perdagangan Anda ke dalam beberapa transaksi. + Dampak harga tinggi terdeteksi (%s) + Riwayat + Email + Nama Legal + Nama Element + Identitas + Situs + File JSON yang disediakan dibuat untuk jaringan yang berbeda. + Harap pastikan bahwa input Anda berisi json yang valid. + JSON Restore tidak valid. + Harap periksa kebenaran kata sandi dan coba lagi. + Gagal dekripsi keystore. + Tempelkan json + Tipe enkripsi tidak didukung. + Tidak bisa mengimpor akun dengan rahasia Substrate ke jaringan dengan enkripsi Ethereum. + Tidak bisa mengimpor akun dengan rahasia Ethereum ke jaringan dengan enkripsi Substrate. + Mnemonik Anda tidak valid. + Harap pastikan bahwa input Anda mengandung 64 simbol hex. + Seed tidak valid. + Sayangnya, Backup dengan dompet Anda tidak ditemukan. + Backup Tidak Ditemukan. + Pulihkan dompet dari Google Drive. + Gunakan frasa kata 12, 15, 18, 21, atau 24 kata Anda. + Pilih cara Anda ingin mengimpor dompet Anda. + Hanya Menonton. + Integrasikan semua fitur jaringan yang Anda bangun ke Pezkuwi Wallet, membuatnya dapat diakses oleh semua orang. + Integrasikan jaringan Anda + Membangun untuk Polkadot? + Data panggilan yang Anda berikan tidak valid atau memiliki format yang salah. Pastikan itu benar dan coba lagi. + Data pemanggilan ini untuk operasi lain dengan hash panggilan %s + Data pemanggilan tidak valid + Alamat proksi harus berupa alamat %s yang valid. + Alamat proksi tidak valid. + Simbol Mata Uang yang dimasukkan (%1$s) tidak cocok dengan jaringan (%2$s). Apakah Anda ingin menggunakan simbol mata uang yang benar? + Simbol Mata Uang tidak valid + QR tidak dapat didekripsi. + Kode QR. + Unggah dari galeri. + Ekspor file JSON. + Bahasa + Ledger tidak mendukung %s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + Operasi dibatalkan oleh perangkat. Pastikan Anda membuka kunci Ledger Anda. + Operasi dibatalkan + Buka aplikasi %s pada perangkat Ledger Anda + Aplikasi %s tidak diluncurkan + Operasi selesai dengan kesalahan pada perangkat. Harap, coba lagi nanti. + Operasi Ledger gagal + Tahan tombol konfirmasi di %s Anda untuk menyetujui transaksi + Muat lebih banyak akun + Tinjau dan Setujui + Akun %s + Akun tidak ditemukan + Perangkat Ledger Anda menggunakan aplikasi Generic usang yang tidak mendukung alamat EVM. Perbarui melalui Ledger Live. + Perbarui Aplikasi Ledger Generic + Tekan kedua tombol pada %s Anda untuk menyetujui transaksi + Silakan, perbarui %s menggunakan aplikasi Ledger Live + Metadata kadaluarsa + Ledger tidak mendukung penandatanganan pesan sembarang \ndash hanya transaksi + Pastikan Anda telah memilih perangkat Ledger yang tepat untuk menyetujui operasi saat ini + Demi alasan keamanan, operasi yang dihasilkan hanya berlaku untuk %s. Silakan coba lagi dan setuju untuk melakukannya dengan Ledger + Transaksi telah kedaluwarsa + Transaksi valid selama %s + Ledger tidak mendukung transaksi ini. + Transaksi tidak didukung + Tekan kedua tombol di %s Anda untuk menyetujui alamat + Tekan tombol konfirmasi di %s Anda untuk menyetujui alamat + Tekan kedua tombol pada %s Anda untuk menyetujui alamat + Tekan tombol konfirmasi pada %s Anda untuk menyetujui alamat + Dompet ini dipasangkan dengan Ledger. Pezkuwi akan membantu Anda melakukan operasi apa pun yang Anda inginkan, dan Anda akan diminta untuk menandatanganinya menggunakan Ledger + Pilih akun untuk ditambahkan ke dompet + Memuat info jaringan... + Memuat detail transaksi… + Mencari cadangan Anda... + Transaksi pembayaran telah dikirim + Tambahkan token + Kelola cadangan + Hapus jaringan + Sunting jaringan + Kelola jaringan yang ditambahkan + Anda tidak akan dapat melihat saldo token Anda di jaringan tersebut pada layar Aset + Hapus jaringan? + Hapus node + Sunting node + Kelola node yang ditambahkan + Node \"%s\" akan dihapus + Hapus node? + Kunci Kustom + Kunci Default + Jangan bagikan informasi ini kepada siapa pun - Jika Anda melakukannya, Anda akan kehilangan semua aset Anda secara permanen dan tidak dapat dikembalikan + Akun dengan kunci kustom + Akun default + %s, +%d lainnya + Akun dengan kunci default + Pilih kunci untuk dicadangkan + Pilih dompet untuk dicadangkan + Mohon baca dengan seksama sebelum melihat cadangan Anda + Jangan bagikan frasa sandi Anda! + Harga terbaik dengan biaya hingga 3,95% + Pastikan tidak ada yang dapat melihat layar Anda\n dan jangan mengambil tangkapan layar + Harap %s siapa pun + jangan bagi dengan + Silakan, coba yang lain. + Frasa sandi mnemonic tidak valid, harap periksa sekali lagi urutan kata-kata + Anda tidak dapat memilih lebih dari %d dompet + + Pilih setidaknya %d dompet + + Transaksi ini telah ditolak atau dieksekusi. + Tidak dapat melakukan transaksi ini + %s sudah memulai operasi yang sama dan saat ini menunggu untuk ditandatangani oleh penandatangan lainnya. + Operasi sudah ada + Untuk mengelola Kartu Debit Anda, silakan beralih ke jenis dompet yang berbeda. + Kartu Debit tidak didukung untuk Multisig + Deposit multisig + Deposit tetap terkunci di akun penyetor hingga operasi multisig dieksekusi atau ditolak. + Pastikan operasi ini benar + Untuk membuat atau mengelola hadiah, silakan beralih ke jenis dompet yang berbeda. + Pemberian hadiah tidak didukung untuk dompet Multisig + Ditandatangani dan dieksekusi oleh %s. + ✅ Transaksi multisig dieksekusi + %s pada %s. + ✍🏻 Tanda tangan Anda diminta + Diprakarsai oleh %s. + Dompet: %s + Ditandatangani oleh %s + %d dari %d tanda tangan terkumpul. + Atas nama %s. + Ditolak oleh %s. + ❌ Transaksi multisig ditolak + Atas nama + %s: %s + Setujui + Setujui & Eksekusi + Masukkan data pemanggilan untuk melihat detail + Transaksi sudah dieksekusi atau ditolak. + Penandatanganan telah berakhir + Tolak + Penandatangan (%d dari %d) + Batch Semua (mengembalikan ke awal pada kesalahan) + Batch (mengeksekusi hingga kesalahan) + Batch Paksa (mengabaikan kesalahan) + Dibuat oleh Anda + Belum ada transaksi.\nPermintaan tanda tangan akan muncul di sini + Menandatangani (%s dari %s) + Ditandatangani oleh Anda + Operasi tidak dikenal + Transaksi untuk ditandatangani + Atas nama: + Ditandatangani oleh penandatangan + Transaksi multisig dieksekusi + Tanda tangan Anda diminta + Transaksi multisig ditolak + Untuk menjual crypto untuk fiat, silakan beralih ke jenis dompet yang berbeda. + Penjualan tidak didukung untuk Multisig + Penandatangan: + %s tidak memiliki saldo yang cukup untuk menempatkan deposit multisig sebesar %s. Anda perlu menambah %s lagi ke saldo Anda + %s tidak memiliki saldo yang cukup untuk membayar biaya jaringan sebesar %s dan menempatkan deposit multisig sebesar %s. Anda perlu menambah %s lagi ke saldo Anda + %s memerlukan setidaknya %s untuk membayar biaya transaksi ini dan tetap berada di atas saldo minimum jaringan. Saldo saat ini adalah: %s + %s tidak memiliki saldo yang cukup untuk membayar biaya jaringan sebesar %s. Anda perlu menambah %s lagi ke saldo Anda + Dompet multisig tidak mendukung penandatanganan pesan sembarangan — hanya transaksi saja + Transaksi akan ditolak. Deposit multisig akan dikembalikan ke %s. + Transaksi multisig akan dimulai oleh %s. Pemulai membayar biaya jaringan dan menyisihkan deposit multisig, yang akan dikembalikan saat transaksi dieksekusi. + Transaksi Multisig + Penandatangan lainnya sekarang dapat mengkonfirmasi transaksi.\nAnda dapat melacak statusnya di %s. + Transaksi untuk ditandatangani + Transaksi Multisig dibuat + Lihat rincian + Transaksi tanpa informasi awal di rantai (data panggilan) ditolak + %s pada %s.\nTidak ada tindakan lebih lanjut yang diperlukan dari Anda. + Transaksi Multisig Dieksekusi + %1$s → %2$s + %s pada %s.\nDitolak oleh: %s.\nTidak ada tindakan lebih lanjut yang diperlukan dari Anda. + Transaksi Multisig Ditolak + Transaksi dari dompet ini memerlukan persetujuan dari beberapa penandatangan. Akun anda adalah salah satu penandatangan: + Penandatangan lainnya: + Ambang batas %d dari %d + Saluran Pemberitahuan Transaksi Multisig + Aktifkan di Pengaturan + Dapatkan pemberitahuan tentang permintaan penandatanganan, tanda tangan baru, dan transaksi yang selesai — sehingga Anda selalu dalam kendali. Kelola kapan saja di Pengaturan. + Pemberitahuan dorong multisig ada di sini! + Node ini sudah ada + Biaya jaringan + Alamat node + Info Node + Ditambahkan + node kustom + node default + Default + Menghubungkan... + Koleksi + Dibuat oleh + %s untuk %s + %s unit dari %s + #%s Edisi dari %s + Seri tak terbatas + Dimiliki oleh + Tidak terdaftar + NFT Anda + Anda belum menambahkan atau memilih dompet multisig di Pezkuwi. Tambahkan dan pilih setidaknya satu untuk menerima pemberitahuan dorong multisig. + Tidak ada dompet multisig + URL yang Anda masukkan sudah ada sebagai Node \"%s\". + Node ini sudah ada + URL Node yang Anda masukkan tidak merespons atau formatnya salah. Format URL harus dimulai dengan \"wss://\". + Kesalahan Node + URL yang Anda masukkan tidak sesuai dengan Node untuk %1$s.\nHarap masukkan URL Node %1$s yang valid. + Jaringan salah + Klaim hadiah + Token Anda akan ditambahkan kembali ke staking + Langsung + Informasi staking pool + Hadiah Anda (%s) juga akan diklaim dan ditambahkan ke saldo bebas Anda + Pool + Tidak dapat melakukan operasi yang ditentukan karena pool sedang dalam proses penghancuran. Ini akan segera ditutup. + Pool sedang dihancurkan + Saat ini tidak ada ruang kosong dalam antrian penarikan dana untuk pool Anda. Harap coba lagi dalam %s + Terlalu banyak orang mencabut staking dari pool Anda + Pool Anda + Pool Anda (#%s) + Buat akun + Buat dompet baru + Kebijakan Privasi + Impor akun + Sudah memiliki dompet + Dengan melanjutkan, Anda setuju dengan kami\n%1$s dan %2$s + Syarat dan Ketentuan + Tukar + Salah satu collator Anda tidak menghasilkan imbalan + Salah satu collator Anda tidak dipilih dalam putaran saat ini + Periode pemblokiran Anda untuk %s telah berakhir. Jangan lupa menebus token Anda + Tidak dapat menyetel dengan collator ini + Ganti collator + Tidak dapat menambahkan staking ke collator ini + Atur collator + Collator terpilih menunjukkan niat untuk berhenti berpartisipasi dalam staking. + Anda tidak dapat menambahkan staking ke collator untuk yang Anda sedang menarik semua token. + Staking Anda akan kurang dari staking minimum (%s) untuk collator ini. + Saldo staking yang tersisa akan turun di bawah nilai jaringan minimum (%s) dan juga akan ditambahkan ke jumlah penarikan kembali + Anda tidak diotorisasi. Coba lagi, silakan. + Gunakan biometrik untuk mengotorisasi + Pezkuwi Wallet menggunakan otentikasi biometrik untuk membatasi pengguna yang tidak diotorisasi mengakses aplikasi. + Biometrik + PIN code berhasil diubah + Konfirmasi kode pin Anda + Buat kode pin + Masukkan kode PIN + Atur kode pin Anda + Anda tidak dapat bergabung dengan pool karena telah mencapai jumlah anggota maksimum + Pool penuh + Anda tidak dapat bergabung dengan pool yang tidak terbuka. Silakan hubungi pemilik pool. + Pool tidak terbuka + Anda tidak dapat lagi menggunakan Staking Langsung dan Staking Pool dari akun yang sama. Untuk mengelola Staking Pool Anda, pertama-tama Anda perlu melepaskan token Anda dari Staking Langsung. + Operasi pool tidak tersedia + Populer + Tambahkan jaringan secara manual + Memuat daftar jaringan... + Cari berdasarkan nama jaringan + Tambahkan jaringan + %s pada %s + 1H + Semua + 1B + %s (%s) + harga %s + 1M + 1T + Sepanjang Masa + Bulan Lalu + Hari ini + Minggu Lalu + Tahun Lalu + Akun + Dompet + Bahasa + Ubah kode pin + Buka aplikasi dengan saldo tersembunyi + Setujui dengan PIN + Mode Aman + Pengaturan + Untuk mengelola Kartu Debit Anda, silakan beralih ke dompet lain dengan jaringan Polkadot. + Kartu Debit tidak didukung untuk dompet Proxied ini + Akun ini diberi akses untuk melakukan transaksi ke akun berikut: + Operasi Staking + Akun yang didelegasikan %s tidak memiliki cukup saldo untuk membayar biaya jaringan sebesar %s. Saldo yang tersedia untuk membayar biaya: %s + Dompet yang diproses tidak mendukung penandatanganan pesan sembarang - hanya transaksi + %1$s tidak mendapatkan delegasi %2$s + %1$s delegasikan %2$s hanya untuk %3$s + Ups! Tidak cukup izin + Transaksi akan diinisiasi oleh %s sebagai akun yang telah didelegasikan. Biaya jaringan akan dibayar oleh akun yang telah didelegasikan. + Ini adalah akun Delegasi (Proxy) + %s proxy + Delegasi telah memberikan suara + Referendum Baru + Pembaruan Referendum + %s Referendum #%s sekarang live! + 🗳️ Referendum baru + Unduh Pezkuwi Wallet v%s untuk mendapatkan semua fitur baru! + Pembaruan baru untuk Pezkuwi Wallet tersedia! + %s Referendum #%s telah berakhir dan disetujui + ✅ Referendum disetujui! + %s Status Referendum #%s berubah dari %s menjadi %s + %s Referendum #%s telah berakhir dan ditolak! + ❌ Referendum ditolak! + 🗳️ Status Referendum berubah + %s Status Referendum #%s berubah menjadi %s + Pengumuman Pezkuwi + Saldo + Aktifkan pemberitahuan + Transaksi multisig + Anda tidak akan menerima pemberitahuan tentang Aktivitas Dompet (Saldo, Staking) karena Anda belum memilih dompet mana pun + Lainnya + Token yang Diterima + Token yang Dikirim + Imbalan Staking + Dompet + ⭐\nImbalan baru %s + Diterima %s dari staking %s + ⭐\nImbalan baru + Pezkuwi Wallet \nsekarang + Diterima +0.6068 KSM ($20.35) dari Kusama staking + Diterima %s di %s + ⬇️ Diterima + ⬇️ Diterima %s + Dikirim %s ke %s di %s + 💸 Dikirim + 💸 Dikirim %s + Pilih hingga %d dompet untuk diberi tahu ketika dompet memiliki aktivitas + Aktifkan pemberitahuan push + Dapatkan pemberitahuan tentang operasi Dompet, pembaruan Tata Pemerintahan, aktivitas Staking, dan Keamanan, sehingga Anda selalu paham + Dengan mengaktifkan pemberitahuan push, Anda menyetujui %s dan %s kami + Harap coba lagi nanti dengan mengakses pengaturan pemberitahuan dari tab Pengaturan + Jangan lewatkan hal apa pun! + Pilih jaringan untuk menerima %s + Salin Alamat + Jika Anda mengambil kembali hadiah ini, tautan yang dibagikan akan dinonaktifkan, dan token akan dikembalikan ke dompet Anda.\nApakah Anda ingin melanjutkan? + Klaim kembali Hadiah %s? + Anda berhasil mengambil kembali hadiah Anda + Tempelkan json atau unggah file... + Unggah file + Pulihkan JSON + Frase Mnemonic + Biji Mentah + Tipe Sumber + Semua referenda + Tampilkan: + Belum memilih + Sudah memilih + Tidak ada referenda dengan filter yang diterapkan + Informasi Referenda akan muncul di sini ketika mereka dimulai + Tidak ada referenda dengan judul atau ID yang dimasukkan ditemukan + Cari berdasarkan judul referendum atau ID + %d referendum + Gesek untuk memberikan suara pada referendum dengan ringkasan AI. Cepat & mudah! + Referenda + Referendum tidak ditemukan + Suara Abstain hanya dapat dilakukan dengan keyakinan 0,1x. Pilih dengan keyakinan 0,1x? + Pembaruan keyakinan + Suara abstain + Ya: %s + Gunakan browser Pezkuwi DApp + Hanya penyarankan yang dapat mengedit deskripsi dan judul ini. Jika Anda memiliki akun penyarankan, kunjungi Polkassembly dan isi informasi tentang proposal Anda + Jumlah yang diminta + Jadwal waktu + Suara Anda: + Kurva persetujuan + Penerima manfaat + Deposit + Pemilih + Terlalu panjang untuk ditampilkan + Parameter JSON + Penyarankan + Kurva dukungan + Partisipasi + Ambang suara + Posisi: %s dari %s + Anda perlu menambahkan akun %s ke dompet untuk bisa memberikan suara + Referendum %s + Menolak: %s + Suara menolak + %s suara oleh %s + Suara setuju + Staking + Disetujui + Dibatalkan + Menentukan + Menentukan dalam %s + Tergunakan + Dalam antrian + Dalam antrian (%s dari %s) + Dihentikan + Tidak lulus + Lulus + Persiapan + Ditolak + Disetujui dalam %s + Tergunakan dalam %s + Waktu habis dalam %s + Ditolak dalam %s + Waktu habis + Menunggu deposit + Ambang: %s dari %s + Disetujui + Dibatalkan + Dibuat + Memutuskan + Dieksekusi + Dalam antrian + Dimatikan + Tidak Lulus + Lulus + Persiapan + Ditolak + Waktu Habis + Menunggu Deposit + Untuk Lulus: %s + Pelelangan + Kas Besar + Tips Besar + Pengelola Persaudaraan + Pendaftaran Tata Pemerintahan + Sewa Tata Pemerintahan + Sedang Mengeluarkan + Pembatalan Tata Pemerintahan + Penghapusan Tata Pemerintahan + Agenda Utama + Sedikit Mengeluarkan + Tips Kecil + Kas Belanja + bersisa terkunci dalam %s + Dapat Dibuka + Menyatakan Tidak Memilih + Setuju + Gunakan kunci semua: %s + Gunakan kunci Tata Pemerintahan: %s + Kunci Tata Pemerintahan + Periode Penguncian + Menolak + Melipatgandakan suara dengan meningkatkan periode penguncian + Memilih %s + Setelah periode penguncian jangan lupa untuk membuka kunci token Anda + Referendum yang Sudah Diputuskan + %s suara + %s ika %sx + Daftar pemilih akan muncul di sini + %s suara + Fellowship: daftar putih + Suara Anda: %s suara + Referendum telah selesai dan pemungutan suara sudah selesai + Referendum telah selesai + Anda sedang mendelekasikan suara untuk trek referendum yang dipilih. Mohon minta delegasi Anda untuk memberikan suara atau batalkan delegasi untuk dapat memberikan suara langsung. + Sudah mendelekasikan suara + Anda telah mencapai maksimum %s suara untuk trek + Jumlah suara maksimum tercapai + Anda tidak memiliki token yang cukup untuk memberikan suara. Tersedia untuk memberikan suara: %s. + Batalkan tipe akses + Batalkan untuk + Hapus suara + + Anda telah memberikan suara sebelumnya dalam referendum di trek %d. Untuk membuat trek ini tersedia untuk delegasi, Anda perlu menghapus suara yang sudah ada. + + Hapus riwayat suara Anda? + Ini milik eksklusif Anda, disimpan dengan aman, tidak dapat diakses oleh orang lain. Tanpa kata sandi cadangan, tidak mungkin untuk memulihkan dompet dari Google Drive. Jika hilang, hapus cadangan saat ini untuk membuat yang baru dengan kata sandi baru. + Sayangnya, kata sandi Anda tidak dapat dikembalikan. + Sebagai alternatif, gunakan Frasa Sandi untuk restorasi. + Apakah Anda kehilangan kata sandi Anda? + Kata sandi cadangan Anda sebelumnya diperbarui. Untuk melanjutkan menggunakan Cadangan Cloud, harap masukkan kata sandi cadangan baru. + Harap masukkan kata sandi yang Anda buat selama proses pencadangan + Masukkan kata sandi cadangan + Gagal memperbarui informasi tentang runtime blockchain. Beberapa fungsi mungkin tidak berfungsi. + Gagal Memperbarui Runtime + Kontak + akun saya + Tidak ada pool dengan nama yang dimasukkan atau ID pool yang ditemukan. Pastikan Anda memasukkan data yang benar + Alamat akun atau nama akun + Hasil pencarian akan ditampilkan di sini + Hasil Pencarian + active pools: %d + members + Select pool + Select tracks to add delegation + Available tracks + Please select the tracks in which you would like to delegate your voting power. + Select tracks to edit delegation + Select tracks to revoke your delegation + Unavailable tracks + Kirim hadiah pada + Aktifkan Bluetooth & Berikan Izin + Pezkuwi needs location to be enabled to be able to perform bluetooth scanning to find your Ledger device + Please enable geo-location in device settings + Pilih jaringan + Pilih token untuk memberikan suara + Select tracks for + %d of %d + Pilih jaringan untuk menjual %s + Penjualan dimulai! Harap tunggu hingga 60 menit. Anda dapat melacak statusnya di email. + Tidak ada penyedia kami yang saat ini mendukung penjualan token ini. Silakan pilih token lain, jaringan lain, atau periksa kembali nanti. + Token ini tidak didukung oleh fitur jual + Address or w3n + Pilih jaringan untuk mengirim %s + Recipient is a system account. It is not controlled by any company or individual.\nAre you sure you still want to perform this transfer? + Tokens will be lost + Give authority to + Pastikan biometrik diaktifkan dalam Pengaturan + Biometrik dinonaktifkan dalam Pengaturan + Komunitas + Dapatkan dukungan melalui Email + Umum + Setiap operasi tanda tangan pada dompet dengan pasangan kunci (dibuat di dompet nova atau diimpor) harus memerlukan verifikasi PIN sebelum membuat tanda tangan + Minta autentikasi untuk penandatanganan operasi + Preferensi + Notifikasi push hanya tersedia untuk versi Pezkuwi Wallet yang diunduh dari Google Play. + Notifikasi push hanya tersedia untuk perangkat dengan layanan Google. + Rekaman layar dan tangkapan layar tidak akan tersedia. Aplikasi yang diminimalkan tidak akan menampilkan kontennya + Mode aman + Keamanan + Dukungan & Masukan + Twitter + Wiki & Pusat Bantuan + Youtube + Keyakinan akan diatur ke 0,1x ketika Abstain + Anda tidak dapat melakukan staking dengan Staking Langsung dan Pool Nominasi pada saat yang sama + Sudah melakukan staking + Pengelolaan Staking lanjutan + Jenis Staking tidak dapat diubah + Anda sudah memiliki Direct Staking + Staking Langsung + Anda telah menentukan jumlah kurang dari jumlah staking minimum %s yang diperlukan untuk mendapatkan imbalan dengan %s. Anda sebaiknya mempertimbangkan menggunakan staking Kolam untuk mendapatkan imbalan. + Menggunakan token kembali dalam Tata Kelola + Staking Minimum: %s + Imbalan: Dibayarkan secara otomatis + Imbalan: Klaim secara manual + Anda sudah melakukan staking dalam sebuah kolam + Staking Kolam + Staking Anda kurang dari minimum untuk mendapatkan imbalan + Jenis staking tidak didukung + Bagikan tautan hadiah + Klaim kembali + Halo! Anda memiliki hadiah %s yang menunggu Anda!\n\nPasang aplikasi Pezkuwi Wallet, atur dompet Anda, dan klaim melalui tautan khusus ini:\n%s + Hadiah Telah Disiapkan.\nBagikan Sekarang! + sr25519 (direkomendasikan) + Schnorrkel + Akun yang dipilih sudah digunakan sebagai controller + Tambahkan otoritas terdelegasi (Proxy) + Delegasi Anda + Delegator aktif + Tambahkan akun controller %s ke aplikasi untuk melakukan tindakan ini. + Tambahkan delegasi + Stake Anda kurang dari minimum %s.\nMemiliki stake di bawah minimum meningkatkan kemungkinan staking tidak menghasilkan hadiah + Stake lebih banyak token + Ganti validator Anda. + Semuanya baik-baik saja sekarang. Peringatan akan muncul di sini. + Memiliki posisi usang dalam antrian penugasan staking ke validator dapat menangguhkan hadiah Anda + Peningkatan Staking + Tarik token yang tidak disetorkan. + Silakan tunggu era berikutnya untuk dimulai. + Peringatan + Sudah menjadi pengendali + Anda sudah memiliki staking di %s + Saldo Staking + Saldo + Stake lebih + Anda tidak melakukan nominasi atau validasi + Ganti pengendali + Ganti validator + %s dari %s + Validator yang Dipilih + Pengendali + Akun Pengendali + Kami menemukan bahwa akun ini tidak memiliki token gratis, apakah Anda yakin ingin mengganti pengendali? + Pengendali dapat menarik, menebus, kembali ke staking, mengganti tujuan hadiah, dan validator. + Pengendali digunakan untuk: menarik, menebus, kembali ke staking, mengganti validator, dan menetapkan tujuan hadiah + Pengendali telah berubah + Validator ini diblokir dan tidak dapat dipilih saat ini. Silakan coba lagi di era berikutnya. + Bersihkan filter + Batalkan semua + Isi sisanya dengan rekomendasi + Validator: %d dari %d + Pilih validator (maks %d) + Tampilkan yang dipilih: %d (maks %d) + Pilih validator + Perkiraan hadiah (%% APR) + Perkiraan hadiah (%% APY) + Perbarui daftar Anda + Staking melalui browser Pezkuwi DApp + Lebih banyak pilihan staking + Stake dan dapatkan hadiah + Staking %1$s aktif di %2$s mulai %3$s + Perkiraan hadiah + era #%s + Perkiraan pendapatan + Perkiraan pendapatan %s + Stake sendiri validator + Stake sendiri validator (%s) + Token dalam periode penarikan tidak menghasilkan hadiah. + Selama periode penarikan, token tidak menghasilkan hadiah + Setelah periode penarikan, Anda perlu menebus token Anda. + Setelah periode penarikan, jangan lupa menebus token Anda + Hadiah Anda akan meningkat mulai dari era berikutnya. + Anda akan mendapatkan hadiah yang lebih besar mulai dari era berikutnya + Token yang dipertaruhkan menghasilkan hadiah setiap era (%s). + Token yang di-stake menghasilkan reward setiap era (%s) + Dompet Pezkuwi akan mengubah tujuan reward\nsaat ke akun Anda untuk menghindari sisa stake. + Jika Anda ingin melepas token, Anda harus menunggu periode melepas (%s). + Untuk melepas token, Anda harus menunggu periode melepas (%s) + Informasi Staking + Nominator Aktif + + %d hari + + Stake Minimum + Jaringan %s + Tersedak + Silakan pindahkan dompet Anda ke %s untuk mengatur proxy + Pilih akun stash untuk mengatur proxy + Mengatur + %s (maks %s) + Jumlah maksimum nominator telah tercapai. Coba lagi nanti + Tidak dapat memulai staking + Min. stake + Anda perlu menambahkan akun %s ke dompet Anda untuk memulai staking + Bulanan + Tambahkan akun pengontrol Anda di perangkat. + Tidak ada akses ke akun pengontrol + Dinominasikan: + %s mendapat reward + Salah satu validator Anda telah terpilih oleh jaringan. + Status Aktif + Status Tidak Aktif + Jumlah staked Anda kurang dari minimum staking untuk mendapatkan reward. + Tidak ada validator Anda yang terpilih oleh jaringan. + Staking Anda akan dimulai pada era berikutnya. + Tidak Aktif + Menunggu Era Selanjutnya + menunggu era selanjutnya (%s) + Saldo Anda tidak mencukupi untuk deposit proxy sebesar %s. Saldo yang tersedia: %s + Saluran Pemberitahuan Staking + Kolator + Minimum staking kolator lebih tinggi dari delegasi Anda. Anda tidak akan menerima reward dari kolator. + Informasi Kolator + Stake Kolator Sendiri + Kolator: %s + Satu atau lebih kolator Anda telah terpilih oleh jaringan. + Delegator + Anda telah mencapai jumlah delegasi maksimum dari %d kolator. + Anda tidak bisa memilih kolator baru + Kolator Baru + menunggu ronde berikutnya (%s) + Anda memiliki permintaan unstake tertunda untuk semua kolator Anda. + Tidak ada kolator yang tersedia untuk unstake + Token yang dikembalikan akan dihitung mulai dari ronde berikutnya. + Token yang distake menghasilkan reward setiap putaran (%s) + Pilih collator + Pilih collator... + Anda akan mendapatkan reward yang lebih besar mulai dari putaran berikutnya + Anda tidak akan menerima reward untuk putaran ini karena tidak ada delegasi Anda yang aktif. + Anda sudah menarik token dari collator ini. Anda hanya bisa memiliki satu permintaan tarik tunai tertunda per collator + Anda tidak dapat menarik stake dari collator ini + Stake Anda harus lebih besar dari stake minimum (%s) untuk collator ini. + Anda tidak akan menerima reward + Beberapa collator Anda entah tidak terpilih atau memiliki stake minimum yang lebih tinggi dari jumlah stake Anda. Anda tidak akan menerima reward dalam putaran ini saat staking bersama mereka. + Collator Anda + Stake Anda dialokasikan ke collator berikutnya + Collator aktif tanpa menghasilkan reward + Collator tanpa cukup stake untuk terpilih + Collator yang akan berlaku dalam putaran berikutnya + Tertunda (%s) + Pembayaran + Pembayaran kedaluwarsa + + %d hari tersisa + + Anda bisa membayarnya sendiri ketika mendekati masa berakhir, namun Anda akan membayar biaya + Hadiah dibayarkan setiap 2-3 hari oleh validator + Semua + Sepanjang waktu + Tanggal akhir selalu hari ini + Periode kustom + %dH + Pilih tanggal akhir + Berakhir + 6 bulan terakhir (6B) + 6B + 30 hari terakhir (30H) + 30H + 3 bulan terakhir (3B) + 3B + Pilih tanggal + Pilih tanggal mulai + Dimulai + Tampilkan hadiah staking untuk + 7 hari terakhir (7H) + 7H + Tahun terakhir (1T) + 1T + Saldo tersedia Anda adalah %s, Anda perlu menyisakan %s sebagai saldo minimal dan membayar biaya jaringan sebesar %s. Anda dapat melakukan staking tidak lebih dari %s. + Otoritas Delegasi (proxy) + Slot antrian saat ini + Slot antrian baru + Kembalikan ke staking + Semua tidak dicairkan + Token yang dikembalikan akan dihitung dari era berikutnya + Jumlah kustom + Jumlah yang ingin Anda kembalikan ke staking lebih besar dari saldo tidak dicairkan + Terakhir tidak dicairkan + Paling menguntungkan + Tidak terlalu banyak peminat + Memiliki identitas onchain + Tidak dipotong + Batas 2 validator per identitas + dengan setidaknya satu kontak identitas + Validator yang direkomendasikan + Validator + Penghargaan yang diestimasi (APY) + Tebus + Dapat ditebus: %s + Hadiah + Tujuan hadiah + Hadiah yang dapat ditransfer + Era + Detail hadiah + Validator + Pendapatan dengan restake + Pendapatan tanpa restake + Sempurna! Semua imbalan sudah dibayarkan. + Luar biasa! Anda tidak memiliki imbalan yang belum dibayar. + Bayar semua (%s) + Imbalan yang tertunda + Imbalan yang belum dibayar + %s Imbalan + Tentang imbalan + Imbalan (APY) + Tujuan imbalan + Pilih sendiri + Pilih akun pembayaran + Pilih rekomendasi + terpilih %d (maks %d) + Validator (%d) + Perbarui Kontroler ke Stash + Gunakan Proksinya untuk mendelelasikan operasi Staking ke akun lain + Akun Kontroler Sedang Dihentikan Penggunaannya + Pilih akun lain sebagai kontroler untuk mendelelasikan operasi pengelolaan staking kepadanya + Perbaiki keamanan staking + Atur validator + Validator tidak dipilih + Pilih validator untuk memulai staking + Stake minimum yang direkomendasikan untuk menerima imbalan secara konsisten adalah %s. + Anda tidak dapat melakukan staking kurang dari nilai minimum jaringan (%s) + Stake minimum harus lebih besar dari %s + Ulang staking + Imbalan ulang staking + Bagaimana cara menggunakan imbalan Anda? + Pilih tipe imbalan Anda + Akun pembayaran imbalan + Potong + Stake %s + Stake maksimum + Periode staking + Tipe staking + Anda harus percaya pada nominasi Anda untuk bertindak secara kompeten dan jujur, keputusan Anda yang hanya berdasarkan profitabilitas saat ini bisa mengakibatkan penurunan keuntungan atau bahkan kerugian dana. + Pilih validator Anda dengan hati-hati, karena mereka harus bertindak dengan cakap dan jujur. Mengambil keputusan hanya berdasarkan profitabilitas bisa mengakibatkan pengurangan imbalan atau bahkan kehilangan stake. + Stake dengan validator Anda + Pezkuwi Wallet akan memilih validator teratas berdasarkan kriteria keamanan dan profitabilitas + Stake dengan validator yang direkomendasikan + Mulai staking + Stash + Stash bisa diikat lebih banyak dan mengatur pengontrol. + Stash digunakan untuk: staking lebih banyak dan mengatur pengontrol + Akun Stash %s tidak dapat diakses untuk memperbarui pengaturan staking. + Nominator mendapatkan pendapatan pasif dengan mengunci tokennya untuk mengamankan jaringan. Untuk mencapai hal itu, nominator harus memilih sejumlah validator untuk mendukung. Nominator harus hati-hati saat memilih validator. Jika validator yang dipilih tidak bertindak dengan benar, hukuman slashing akan dikenakan pada keduanya, berdasarkan tingkat keparahan insiden. + Pezkuwi Wallet menyediakan dukungan bagi para nominator dengan membantu mereka memilih validator. Aplikasi seluler mengambil data dari blockchain dan membuat daftar validator, yang memiliki: keuntungan tertinggi, identitas dengan info kontak, tidak disangsi, dan tersedia untuk menerima nominasi. Pezkuwi Wallet juga peduli tentang desentralisasi, jadi jika satu orang atau perusahaan menjalankan beberapa node validator, hanya maksimal 2 node dari mereka yang akan ditampilkan dalam daftar rekomendasi. + Siapa yang merupakan nominator? + Imbalan untuk staking dapat dicairkan pada akhir setiap era (6 jam di Kusama dan 24 jam di Polkadot). Jaringan menyimpan imbalan tertunda selama 84 era dan dalam kebanyakan kasus validator membayar imbalan untuk semua orang. Namun, validator mungkin lupa atau sesuatu mungkin terjadi dengan mereka, sehingga nominator dapat mencairkan imbalan mereka sendiri. + Meskipun imbalan biasanya didistribusikan oleh validator, Pezkuwi Wallet membantu dengan memberi peringatan jika terdapat imbalan belum dibayar yang mendekati jatuh tempo. Anda akan menerima peringatan tentang ini dan aktivitas lainnya di layar staking. + Menerima hadiah + Staking adalah opsi untuk mendapatkan pendapatan pasif dengan mengunci token Anda di jaringan. Hadiah staking dialokasikan setiap era (6 jam di Kusama dan 24 jam di Polkadot). Anda dapat melakukan staking sesuai keinginan, dan untuk melepaskan token Anda, Anda perlu menunggu periode pelepasan berakhir, membuat token Anda tersedia untuk ditebus. + Staking adalah bagian penting dari keamanan dan keandalan jaringan. Siapa pun bisa menjalankan node validator, tetapi hanya mereka yang memiliki cukup token ter-staking yang akan dipilih oleh jaringan untuk berpartisipasi dalam menyusun blok baru dan menerima hadiah. Validator seringkali tidak memiliki cukup token sendiri, jadi nominator membantu mereka dengan mengunci token mereka agar mereka mencapai jumlah staking yang diperlukan. + Apa itu Staking? + Validator menjalankan node blockchain 24/7 dan harus memiliki cukup staking terkunci (baik dimiliki maupun disediakan oleh nominator) untuk terpilih oleh jaringan. Validator harus menjaga kinerja dan keandalan node mereka untuk mendapatkan imbalan. Menjadi validator hampir seperti pekerjaan penuh waktu, ada perusahaan yang berfokus menjadi validator pada jaringan blockchain. + Semua orang bisa menjadi validator dan menjalankan node blockchain, tetapi itu memerlukan tingkat keahlian teknis dan tanggung jawab tertentu. Jaringan Polkadot dan Kusama memiliki program, bernama Thousand Validators Programme, untuk memberikan dukungan bagi pemula. Selain itu, jaringan itu sendiri akan selalu memberi imbalan lebih kepada validator yang memiliki staking lebih sedikit (tapi cukup untuk terpilih) untuk meningkatkan desentralisasi. + Siapa itu validator? + Alihkan akun Anda ke stash untuk mengatur pengontrol. + Staking + %s staking + Diberi Imbalan + Total staked + Ambang Boost + Untuk kolator saya + tanpa Yield Boost + dengan Yield Boost + otomatis menyetor %s semua token yang dapat ditransfer di atas + otomatis menyetor %s (sebelumnya: %s) semua token yang dapat ditransfer di atas + Saya ingin menyetor + Peningkatan Hasil + Jenis Penyetoran + Anda menarik semua token Anda dan tidak dapat menyetor lebih. + Tidak dapat menyetor lebih + Saikat sebagian, Anda harus meninggalkan setidaknya %s dalam penyetoran. Apakah Anda ingin melakukan penarikan penuh dengan menarik sisa %s juga? + Jumlah yang tersisa dalam penyetoran terlalu sedikit + Jumlah yang ingin Anda tarik lebih besar dari saldo yang disetorkan + Tarik + Transaksi Penarikan akan muncul di sini + Transaksi Penarikan akan ditampilkan di sini + Penarikan: %s + Token Anda akan tersedia untuk ditukar setelah periode penarikan selesai. + Anda telah mencapai batas permintaan penarikan (%d permintaan aktif). + Batas permintaan penarikan telah dicapai + Periode Penarikan + Tarik Semua + Tarik Semua? + Imbalan Estimasi (%% APY) + Perkiraan imbalan + Info validator + Oversubscribed. Anda tidak akan menerima imbalan dari validator ini dalam era ini. + Nominator + Oversubscribed. Hanya nominator teratas yang bertaruh yang dibayar imbalan. + Milik + Tidak ada hasil pencarian.\nPastikan Anda mengetik alamat akun lengkap + Validator di-hukum karena perilaku buruk (misalnya, offline, menyerang jaringan, atau menjalankan perangkat lunak yang dimodifikasi) dalam jaringan. + Total taruhan + Total taruhan (%s) + Imbalannya kurang dari biaya jaringan. + Setiap tahun + Stake Anda dialokasikan ke validator berikut. + Stake Anda ditugaskan ke validator berikut + Terpilih (%s) + Validator yang tidak terpilih dalam era ini. + Validator tanpa cukup taruhan untuk dipilih + Yang lain, yang aktif tanpa alokasi taruhan Anda. + Validator aktif tanpa penugasan taruhan Anda + Tidak terpilih (%s) + Token anda dialokasikan kepada validator yang oversubscribed. Anda tidak akan menerima hadiah pada era ini. + Hadiah + Stake Anda + Validator Anda + Validator Anda akan berubah pada era berikutnya. + Sekarang mari kita cadangkan dompet Anda. Ini memastikan bahwa dana Anda aman dan terjamin. Cadangan memungkinkan Anda mengembalikan dompet Anda kapan saja. + Lanjutkan dengan Google + Masukkan nama dompet + Dompet baru saya + Lanjutkan dengan cadangan manual + Berikan nama pada dompet Anda + Ini hanya akan terlihat oleh Anda dan Anda dapat mengeditnya nanti. + Dompet Anda sudah siap + Bluetooth + USB + Anda telah mengunci token pada saldo Anda karena %s. Untuk melanjutkan, Anda harus memasukkan kurang dari %s atau lebih dari %s. Untuk melakukan stake pada jumlah lain, Anda harus menghapus penguncian %s Anda. + Anda tidak dapat melakukan stake pada jumlah yang ditentukan + Dipilih: %d (maks %d) + Saldo tersedia: %1$s (%2$s) + %s dengan token yang Anda staked + Berpartisipasi dalam tata kelola + Stake lebih dari %1$s dan %2$s dengan token yang Anda staked + Berpartisipasi dalam tata kelola + Stake kapan saja dengan setidaknya %1$s. Stake Anda akan aktif menghasilkan imbalan %2$s + dalam %s + Stake kapan saja. Stake Anda akan aktif menghasilkan imbalan %s + Temukan informasi lebih lanjut tentang\n%1$s staking di %2$s + Pezkuwi Wiki + Imbalan bertambah %1$s. Stake di atas %2$s untuk pembayaran imbalan otomatis, jika tidak Anda perlu menuntut imbalan secara manual + setiap %s + Imbalan bertambah %s + Imbalan bertambah %s. Anda perlu menuntut imbalan secara manual + Imbalan bertambah %s dan ditambahkan ke saldo yang dapat ditransfer + Imbalan bertambah %s dan ditambahkan kembali ke stake + Imbalan dan status staking bervariasi dari waktu ke waktu. %s dari waktu ke waktu + Pantau stake Anda + Mulai Staking + Lihat %s + Ketentuan Penggunaan + %1$s adalah %2$s dengan %3$s + tidak ada nilai token + jaringan uji + %1$s\ndi %2$s token\nper tahun + Dapatkan hingga %s + Batalkan kapan saja, dan tebus dana Anda %s. Tidak ada imbalan yang diperoleh saat batalkan + setelah %s + Kolam yang Anda pilih tidak aktif karena tidak ada validator yang dipilih atau jumlah stakenya kurang dari minimum.\nApakah Anda yakin ingin melanjutkan dengan Kolam yang dipilih? + Jumlah maksimum nominator telah tercapai. Coba lagi nanti + %s saat ini tidak tersedia + Validator: %d (maks %d) + Diubah + Baru + Dihapus + Token untuk membayar biaya jaringan + Biaya jaringan ditambahkan di atas jumlah yang dimasukkan + Simulasi langkah swap gagal + Pasangan ini tidak didukung + Gagal dalam operasi #%s (%s) + %s ke %s swap pada %s + Transfer %s dari %s ke %s + + %s operasi + + %s dari %s operasi + Menukarkan %s ke %s pada %s + Mentransfer %s ke %s + Waktu eksekusi + Anda harus menyisihkan setidaknya %s setelah membayar biaya jaringan %s karena Anda memegang token yang tidak mencukupi + Anda harus menyisihkan setidaknya %s untuk menerima token %s + Anda harus memiliki setidaknya %s di %s untuk menerima %s token + Anda dapat menukar hingga %1$s karena Anda perlu membayar %2$s untuk biaya jaringan. + Anda dapat menukar hingga %1$s karena Anda perlu membayar %2$s untuk biaya jaringan dan juga mengonversi %3$s ke %4$s untuk mencapai saldo minimum %5$s. + Tukar maksimum + Tukar minimum + Anda harus meninggalkan setidaknya %1$s pada saldo Anda. Apakah Anda ingin melakukan tukar penuh dengan menambahkan %2$s yang tersisa juga? + Jumlah yang tersisa di saldo Anda terlalu kecil + Anda harus tetap setidaknya %1$s setelah membayar biaya jaringan %2$s dan mengonversi %3$s ke %4$s untuk mencapai saldo minimum %5$s.\n\nApakah Anda ingin menukar penuh dengan menambahkan %6$s yang tersisa juga? + Bayar + Terima + Pilih sebuah token + Tidak cukup token untuk ditukar + Tidak cukup likuiditas + Anda tidak dapat menerima kurang dari %s + Beli %s secara instan dengan kartu kredit + Transfer %s dari jaringan lain + Terima %s dengan QR atau alamat Anda + Dapatkan %s menggunakan + Selama eksekusi swap, jumlah terima sementara adalah %s yang lebih rendah dari saldo minimum %s. Coba tentukan jumlah swap yang lebih besar. + Prosentase pergeseran harus ditentukan antara %s dan %s + Pergeseran tidak valid + Pilih token untuk membayar + Pilih token untuk menerima + Masukkan jumlah + Masukkan jumlah lain + Untuk membayar biaya jaringan dengan %s, Pezkuwi secara otomatis akan menukar %s dengan %s untuk mempertahankan saldo minimum %s akun Anda. + Biaya jaringan yang dibebankan oleh blockchain untuk memproses dan memvalidasi transaksi. Bisa bervariasi tergantung pada kondisi jaringan atau kecepatan transaksi. + Pilih jaringan untuk menukar %s + Pool tidak memiliki likuiditas yang cukup untuk swap + Perbedaan harga merujuk pada perbedaan harga antara dua aset yang berbeda. Ketika melakukan swap dalam kripto, perbedaan harga biasanya adalah perbedaan antara harga aset yang Anda tukarkan dengan harga aset yang Anda tukar. + Perbedaan harga + %s ≈ %s + Kurs pertukaran antara dua cryptocurrency yang berbeda. Ini menggambarkan seberapa banyak cryptocurrency itu yang bisa Anda dapatkan dalam pertukaran dengan sejumlah tertentu cryptocurrency lain. + Kurs + Kurs lama: %1$s ≈ %2$s.\nKurs baru: %1$s ≈ %3$s + Kurs pertukaran telah diperbarui + Ulangi operasi + Rute + Jalur yang akan diambil token Anda melalui berbagai jaringan untuk mendapatkan token yang diinginkan. + Tukar + Transfer + Pengaturan swap + Slippage + Slippage adalah kejadian umum dalam perdagangan terdesentralisasi di mana harga akhir dari transaksi swap mungkin sedikit berbeda dari harga yang diharapkan, karena kondisi pasar yang berubah. + Masukkan nilai lain + Masukkan nilai antara %s dan %s + Selip + Transaksi mungkin diambil oleh pihak lain karena slippage yang tinggi. + Transaksi mungkin dibalikkan karena toleransi slippage yang rendah. + Jumlah %s kurang dari saldo minimum %s + Anda mencoba untuk menukar jumlah yang terlalu kecil + Abstain: %s + Aye: %s + Anda selalu bisa memberikan suara untuk referendum ini nanti + Hapus referendum %s dari daftar suara? + Beberapa referendum tidak lagi tersedia untuk pemungutan suara atau Anda mungkin tidak memiliki cukup token yang tersedia untuk memberikan suara. Tersedia untuk memberikan suara: %s. + Beberapa referendum dikeluarkan dari daftar suara + Data referendum tidak dapat dimuat + Tidak ada data yang ditemukan + Anda telah memberikan suara untuk semua referendum yang tersedia atau tidak ada referendum untuk memberikan suara saat ini. Kembali lagi nanti. + Anda telah memberikan suara untuk semua referendum yang tersedia + Diminta: + Daftar suara + %d tersisa + Konfirmasi suara + Tidak ada referendum untuk memberikan suara + Konfirmasi suara Anda + Tidak ada suara + Anda berhasil memberikan suara untuk %d referendum + Anda tidak memiliki saldo yang cukup untuk memberikan suara dengan kekuatan suara saat ini %s (%sx). Silakan ubah kekuatan suara atau tambahkan lebih banyak dana ke dompet Anda. + Saldo tidak mencukupi untuk memberikan suara + Nay: %s + SwipeGov + Berikan suara untuk %d referendum + Pemungutan suara akan diatur untuk suara di masa depan di SwipeGov + Kekuatan Suara + Staking + Dompet + Hari ini + Tautan Coingecko untuk info harga (Opsional) + Pilih penyedia untuk membeli token %s + Metode pembayaran, biaya, dan batasan berbeda-beda tergantung penyedia.\nBandingkan kutipan mereka untuk menemukan pilihan terbaik untuk Anda. + Pilih penyedia untuk menjual token %s + Untuk melanjutkan pembelian, Anda akan diarahkan dari aplikasi Pezkuwi Wallet ke %s + Lanjut di browser? + Tidak ada penyedia kami yang saat ini mendukung pembelian atau penjualan token ini. Silakan pilih token lain, jaringan lain, atau periksa kembali nanti. + Token ini tidak didukung oleh fitur beli/jual + Salin hash + Biaya + Dari + Hash Ekstrinsik + Rincian transaksi + Lihat di %s + Lihat di Polkascan + Lihat di Subscan + %s di %s + Riwayat transaksi %s Anda sebelumnya masih tersedia di %s + Selesai + Gagal + Menunggu + Kanal Notifikasi Transaksi + Beli kripto mulai dari $5 + Jual kripto mulai dari $10 + Dari: %s + Ke: %s + Transfer + Transfer masuk dan keluar\nakan muncul di sini + Operasi Anda akan ditampilkan di sini + Hapus suara untuk mend delegasikan di jalur ini + Track yang sudah Anda delegasikan suara + Track tidak tersedia + Track di mana Anda sudah memberikan suara + Jangan tampilkan ini lagi.\nAnda dapat menemukan alamat warisan di Terima. + Format warisan + Format baru + Beberapa pertukaran mungkin masih memerlukan format warisan\nuntuk operasi saat mereka memperbarui. + Alamat Baru Terpadu + Pasang + Versi %s + Pembaruan tersedia + Untuk menghindari masalah apapun, dan meningkatkan pengalaman pengguna Anda, kami sangat menyarankan Anda untuk segera menginstal pembaruan terbaru secepat mungkin + Pembaruan penting + Terbaru + Banyak fitur baru yang menakjubkan tersedia untuk Pezkuwi Wallet! Pastikan untuk memperbarui aplikasi Anda untuk mengaksesnya + Pembaruan besar + Kritis + Besar + Lihat semua pembaruan yang tersedia + Nama + Nama dompet + Nama ini akan ditampilkan hanya untuk Anda dan disimpan secara lokal di perangkat seluler Anda. + Akun ini tidak dipilih oleh jaringan untuk berpartisipasi dalam era saat ini + Perbarui suara + Beri suara + Status pemungutan suara + Kartu Anda sedang dibiayai! + Kartu Anda sedang diterbitkan! + Ini dapat memakan waktu hingga 5 menit.\nJendela ini akan ditutup secara otomatis. + Perkiraan %s + Beli + Beli/Jual + Beli token + Beli dengan + Terima + Terima %s + Hanya kirim token %1$s dan token di jaringan %2$s ke alamat ini, atau Anda bisa kehilangan dana Anda + Jual + Jual token + Kirim + Tukar + Aset + Aset Anda akan muncul di sini. Pastikan filter \'Sembunyikan saldo nol\' dimatikan + Nilai aset + Tersedia + Dijamin + Detail saldo + Total saldo + Total setelah transfer + Terbekukan + Terkunci + Dapat ditukarkan + Dipesan + Dapat ditransfer + Membatalkan jaminan + Dompet + Koneksi baru + + %s akun hilang. Tambahkan akun ke dompet di Pengaturan + + Beberapa jaringan yang diminta oleh \"%s\" tidak didukung di Pezkuwi Wallet + Sesi Wallet Connect akan muncul di sini + WalletConnect + Aplikasi dApp tidak dikenal + + %s jaringan tidak didukung disembunyikan + + WalletConnect v2 + Transfer lintas rantai + Kriptocurrency + Mata uang fiat + Mata uang fiat populer + Mata Uang + Detail Extrinsic + Sembunyikan aset dengan saldo nol + Transaksi Lain + Tampilkan + Hadiah dan Potongan + Pertukaran + Filter + Transfer + Kelola aset + Bagaimana cara menambahkan dompet? + Bagaimana cara menambahkan dompet? + Bagaimana cara menambahkan dompet? + Contoh Nama: Akun Utama, Validator Saya, Pinjaman Crowds Dotsama, dll. + Bagikan kode QR ini ke pengirim + Biarkan pengirim memindai kode QR ini + Alamat %s saya untuk menerima %s: + Bagikan kode QR + Penerima + Pastikan bahwa alamat\ndari jaringan yang benar + Format alamat tidak valid.\nPastikan alamat\ntermasuk dalam jaringan yang benar + Saldo Minimal + Karena pembatasan lintas-rantai, Anda tidak dapat mentransfer lebih dari %s + Anda tidak memiliki cukup saldo untuk membayar biaya lintas-rantai sebesar %s.\nSaldo tersisa setelah transfer: %s + Biaya lintas rantai ditambahkan di atas jumlah yang dimasukkan. Penerima mungkin menerima bagian dari biaya lintas rantai + Konfirmasi transfer + Lintas-rantai + Biaya lintas rantai + Transfer Anda akan gagal karena akun tujuan tidak memiliki cukup %s untuk menerima transfer token lain + Penerima tidak dapat menerima transfer + Transfer Anda akan gagal karena jumlah akhir di akun tujuan akan kurang dari saldo minimal. Silakan coba untuk meningkatkan jumlahnya + Transfer Anda akan menghapus akun dari blockstore karena akan membuat total saldo lebih rendah dari saldo minimal + Akun Anda akan dihapus dari blockchain setelah transfer karena membuat total saldo lebih rendah dari minimal + Transfer akan menghapus akun + Akun Anda akan dihapus dari blockchain setelah transfer karena membuat total saldo lebih rendah dari minimal. Sisa saldo akan ditransfer ke penerima juga + Dari jaringan + Anda perlu memiliki setidaknya %s untuk membayar biaya transaksi ini dan tetap di atas saldo jaringan minimum. Saldo Anda saat ini: %s. Anda perlu menambahkan %s ke saldo Anda untuk melakukan operasi ini. + Sendiri + Di rantai + Alamat berikut: %s diketahui digunakan dalam aktivitas phishing, oleh karena itu kami tidak merekomendasikan mengirimkan token ke alamat tersebut. Apakah Anda ingin melanjutkan? + Peringatan Penipuan + Penerima telah diblokir oleh pemilik token dan saat ini tidak dapat menerima transfer masuk + Penerima tidak dapat menerima transfer + Jaringan Penerima + Ke jaringan + Kirim %s dari + Kirim %s di + ke + Pengirim + Token + Kirim ke kontak ini + Rincian Transfer + %s (%s) + %s alamat untuk %s + Pezkuwi mendeteksi masalah dengan integritas informasi tentang alamat %1$s. Harap hubungi pemilik %1$s untuk menyelesaikan masalah integritas. + Pemeriksaan Integritas Gagal + Penerima Tidak Valid + Tidak ada alamat yang valid ditemukan untuk %s di jaringan %s + Penerima %s tidak ditemukan + Layanan %1$s w3n tidak tersedia. Coba lagi nanti atau masukkan alamat %1$s secara manual + Kesalahan dalam memecahkan w3n + Pezkuwi tidak dapat menyelesaikan kode untuk token %s + Token %s belum didukung + Kemarin + Yield Boost akan dimatikan untuk collator saat ini. Collator baru: %s + Ubah Collator Yield Boosted? + Anda tidak memiliki saldo yang cukup untuk membayar biaya jaringan sebesar %s dan biaya eksekusi yield boost sebesar %s.\nSaldo yang tersedia untuk membayar biaya: %s + Tidak cukup token untuk membayar biaya eksekusi pertama + Anda tidak memiliki saldo yang cukup untuk membayar biaya jaringan sebesar %s dan tidak turun di bawah ambang %s.\nSaldo yang tersedia untuk membayar biaya: %s + Tidak cukup token untuk tetap di atas ambang + Waktu peningkatan staking + Yield Boost akan secara otomatis melakukan staking %s semua token yang dapat ditransfer di atas %s + Yield Boosted + + + Jembatan DOT ↔ HEZ + Jembatan DOT ↔ HEZ + Anda kirim + Anda terima (perkiraan) + Nilai tukar + Biaya jembatan + Minimum + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Tukar + Penukaran HEZ→DOT diproses saat likuiditas DOT mencukupi. + Saldo tidak cukup + Jumlah di bawah minimum + Masukkan jumlah + Penukaran HEZ→DOT mungkin terbatas tergantung pada likuiditas. + Penukaran HEZ→DOT sementara tidak tersedia. Coba lagi saat likuiditas DOT mencukupi. + diff --git a/common/src/main/res/values-it/strings.xml b/common/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..75f1087 --- /dev/null +++ b/common/src/main/res/values-it/strings.xml @@ -0,0 +1,2047 @@ + + + Contattaci + Github + Informativa sulla privacy + Valutaci + Telegram + Termini e condizioni + Termini e condizioni + Informazioni sull\'applicazione + Versione dell\'applicazione + Sito Web + Inserisci un indirizzo %s valido... + L\'indirizzo Evm deve essere valido o vuoto... + Inserisci un indirizzo substrate valido... + Aggiungi account + Aggiungi indirizzo + Account già esistente. Si prega di provare un altro. + Traccia qualsiasi portafoglio tramite il suo indirizzo + Aggiungi un portafoglio solo visione + Scrivi la tua Passphrase + Assicurati di scrivere correttamente e in modo leggibile la tua frase. + %s indirizzo + Nessun account su %s + Conferma mnemonica + Controlliamolo ancora una volta + Scegli le parole nell\'ordine corretto + Crea un nuovo account + Non utilizzare il clipboard o gli screenshot sul tuo dispositivo mobile, cerca metodi sicuri per il backup (ad esempio, carta) + Il nome verrà utilizzato solo in questa applicazione. Puoi modificarlo in seguito. + Crea nome portafoglio + Backup mnemonico + Crea un nuovo portafoglio + Il mnemonico è utilizzato per recuperare l\'accesso all\'account. Scrivilo, non saremo in grado di recuperare il tuo account senza di esso! + Account con un segreto modificato + Dimenticare + Assicurati di aver esportato il tuo portafoglio prima di procedere. + Dimenticare il portafoglio? + Percorso di derivazione Ethereum non valido + Percorso di derivazione Substrate non valido + Questo portafoglio è accoppiato con %1$s. Pezkuwi ti aiuterà a eseguire qualsiasi operazione che desideri, e ti verrà richiesto di firmarle utilizzando %1$s + Non supportato da %s + Questo è un portafoglio di sola osservazione, Pezkuwi può mostrarti i saldi e altre informazioni, ma non puoi effettuare transazioni con questo portafoglio + Inserisci soprannome del portafoglio... + Per favore, prova un altro. + Tipo di crittografia per coppia chiave Ethereum + Percorso di derivazione segreto Ethereum + Account EVM + Esporta account + Esporta + Importa esistente + Questo è necessario per crittografare i dati e salvare il file JSON. + Imposta una nuova password + Salva il tuo segreto e conservalo in un posto sicuro + Scrivi il tuo segreto e conservalo in un posto sicuro + Json di ripristino non valido. Assicurati che l\'input contenga un json valido. + Il seed non è valido. Assicurati che l\'input contenga 64 simboli esadecimali. + Il JSON non contiene informazioni sulla rete. Si prega di specificarle di seguito. + Fornisci il tuo JSON di ripristino + Tipicamente una frase di 12 parole (ma potrebbe essere 15, 18, 21 o 24) + Scrivi le parole separatamente con uno spazio, senza virgole o altri segni + Inserisci le parole nell\'ordine corretto + Password + Importa la chiave privata + 0xAB + Inserisci il tuo seed grezzo + Pezkuwi è compatibile con tutte le app + Importa portafoglio + Account + Il tuo percorso di derivazione contiene simboli non supportati o ha una struttura non corretta + Percorso di derivazione non valido + File JSON + Assicurati che %s sul tuo dispositivo Ledger utilizzando l\'app Ledger Live + l\'app Polkadot sia installata + %s sul tuo dispositivo Ledger + Apri l\'app Polkadot + Flex, Stax, Nano X, Nano S Plus + Ledger (App Polkadot Generico) + Assicurati che l\'app di rete sia installata sul tuo dispositivo Ledger utilizzando l\'app Ledger Live. Apri l\'app di rete sul tuo dispositivo Ledger. + Aggiungi almeno un account + Aggiungi account al tuo portafoglio + Guida alla connessione Ledger + Assicurati che l\'app di %s sul tuo dispositivo Ledger utilizzando l\'app Ledger Live + l\'app Network è installata + %s sul tuo dispositivo Ledger + Apri l\'app network + Consenti a Pezkuwi Wallet di %s + accesso Bluetooth + %s da aggiungere al portafoglio + Seleziona account + %s nelle impostazioni del tuo telefono + Abilita OTG + Collega Ledger + Per firmare operazioni e migrare i tuoi account alla nuova app Generic Ledger, installa e apri l\'app Migration. Le app Ledger Legacy e Migration non saranno supportate in futuro. + È stata rilasciata una nuova app Ledger + Polkadot Migration + L\'app Migration sarà non più disponibile nel prossimo futuro. Usala per migrare i tuoi account alla nuova app Ledger per evitare di perdere i tuoi fondi. + Polkadot + Ledger Nano X + Ledger Legacy + Se utilizzi un Ledger via Bluetooth, abilita il Bluetooth su entrambi i dispositivi e concedi i permessi di Bluetooth e posizione a Pezkuwi. Per l\'USB, abilita OTG nelle impostazioni del tuo telefono. + Si prega di abilitare Bluetooth nelle impostazioni del telefono e sul dispositivo Ledger. Sblocca il tuo dispositivo Ledger e apri l\'app %s. + Seleziona il tuo dispositivo Ledger + Per favore, abilita il dispositivo Ledger. Sblocca il tuo dispositivo Ledger e apri l\'app %s. + Ci sei quasi! 🎉\n Basta toccare qui sotto per completare la configurazione e iniziare a utilizzare i tuoi account senza problemi sia nell\'app Polkadot che in Pezkuwi Wallet + Benvenuto in Pezkuwi! + Frase di 12, 15, 18, 21 o 24 parole + Multisig + Controllo condiviso (multisig) + Non hai un account per questa rete, puoi creare o importare un account. + Account necessario + Nessun account trovato + Associa la chiave pubblica + Parity Signer + %s non supporta %s + Gli account seguenti sono stati letti con successo da %s + Ecco i tuoi account + Codice QR non valido, assicurati di stare scansionando il codice QR da %s + Assicurati di selezionare il primo in alto + %s sul tuo smartphone + Apri Parity Signer + %s che desideri aggiungere a Pezkuwi Wallet + Vai alla scheda \"Chiavi\". Seleziona il seed, quindi l\'account + Parity Signer ti fornirà il %s + codice QR da scansionare + Aggiungi portafoglio da %s + %s non supporta la firma di messaggi arbitrari — solo transazioni + La firma non è supportata + Scansiona il codice QR da %s + Legacy + Nuovo (Vault v7+) + Ho un errore in %s + Il codice QR è scaduto + Per motivi di sicurezza le operazioni generate sono valide solo per %s.\nGenera un nuovo codice QR e firmalo con %s + Il codice QR è valido per %s + Assicurati di scansionare il codice QR per l\'operazione di firma attuale + Firma con %s + Cofre di Polkadot + Fai attenzione, il nome del percorso di derivazione dovrebbe essere vuoto + %s sul tuo smartphone + Apri Polkadot Vault + %s che desideri aggiungere a Pezkuwi Wallet + Tocca su Derived Key + Il Cofre di Polkadot ti fornirà il %s + codice QR da scansionare + Tocca l\'icona nell\'angolo in alto a destra e seleziona %s + Esporta Chiave Privata + Chiave privata + Proxy + Delegato a te (Proxied) + Qualsiasi + Asta + Annulla Procura + Governance + Giudizio di identità + Pool di nomina + Non Trasferire + Staking + Ho già un account + segreti + 64 simboli esadecimali + Seleziona il portafoglio hardware + Seleziona il tipo di segreto + Seleziona il portafoglio + Account con un segreto condiviso + Account Substrate + Tipo di crittografia della coppia di chiavi Substrate + Percorso di derivazione segreto Substrate + Nome del portafoglio + Soprannome del portafoglio + Moonbeam, Moonriver e altre reti + Indirizzo EVM (Opzionale) + Portafogli predefiniti + Polkadot, Kusama, Karura, KILT e altre 50 reti + Traccia l\'attività di qualsiasi portafoglio senza inserire la tua chiave privata in Pezkuwi Wallet + Il tuo portafoglio è solo per la visualizzazione, il che significa che non puoi eseguire alcuna operazione con esso + Ops! La chiave manca + solo per la visualizzazione + Usa Polkadot Vault, Ledger o Parity Signer + Collega un portafoglio hardware + Usa i tuoi account Trust Wallet in Pezkuwi + Trust Wallet + Aggiungi un account %s + Aggiungi portafoglio + Cambia l\'account %s + Cambia account + Ledger (Legacy) + Delegato a te + Controllo condiviso + Aggiungi nodo personalizzato + Devi aggiungere un account %s al portafoglio per delegare + Inserisci i dettagli della rete + Delega a + Account che delega + Portafoglio che delega + Conferisce tipo di accesso + Il deposito rimane riservato sul tuo account finché il proxy non viene rimosso + Hai raggiunto il limite di %s proxy aggiunti in %s. Rimuovi i proxy per aggiungerne di nuovi + Limite massimo di proxy raggiunto + Le reti personalizzate aggiunte\nappariranno qui + +%d + Pezkuwi ha automaticamente commutato al tuo portafoglio multisig per permetterti di visualizzare le transazioni in sospeso. + Colorato + Aspetto + icone dei token + Bianco + L\'indirizzo del contratto inserito è presente in Pezkuwi come token %s. + L\'indirizzo del contratto inserito è presente in Pezkuwi come token %s. Sei sicuro di volerlo modificare? + Questo token esiste già + Assicurati che l\'URL fornito abbia la seguente forma: www.coingecko.com/en/coins/tether. + Link CoinGecko non valido + L\'indirizzo del contratto inserito non è un contratto ERC-20 %s. + Indirizzo del contratto non valido + I decimali devono essere almeno 0 e non superiori a 36. + Valore dei decimali non valido + Inserisci l\'indirizzo del contratto + Inserisci i decimali + Inserisci il simbolo + Vai a %s + A partire da %1$s, il tuo saldo %2$s, Staking e Governance saranno su %3$s — con prestazioni migliorate e costi inferiori. + I tuoi token %s ora su %s + Reti + Token + Aggiungi token + Indirizzo del contratto + Decimali + Simbolo + Inserisci i dettagli del token ERC-20 + Seleziona la rete per aggiungere il token ERC-20 + Crowdloans + Governance v1 + OpenGov + Elezioni + Staking + Vesting + Compra token + Hai ricevuto i tuoi DOT dai crowdloan? Inizia a fare staking dei tuoi DOT oggi per ottenere il massimo delle ricompense possibili! + Potenzia i tuoi DOT \ndei DOT 🚀 + Filtra i token + Non hai token da regalare.\nAcquista o Deposita token nel tuo account. + Tutte le reti + Gestisci i token + Non trasferire %s sul conto controllato da Ledger poiché Ledger non supporta l\'invio di %s, quindi gli asset rimarranno bloccati su questo conto + Ledger non supporta questo token + Cerca per rete o token + Nessuna rete o token con il nome inserito è stata trovata + Cerca per token + I tuoi portafogli + Non hai token da inviare. \nCompra o ricevi token sul tuo \nconto. + Token da pagare + Token da ricevere + Prima di procedere con le modifiche, %s per i portafogli modificati e rimossi! + assicura che hai salvato le Passphrase + Applicare aggiornamenti di backup? + Prepara a salvare il tuo portafoglio! + Questa passphrase ti dà accesso totale e permanente a tutti i portafogli connessi e ai fondi al loro interno.\n%s + NON CONDIVIDERLA. + Non inserire la tua Passphrase in nessun modulo o sito web.\n%s + I FONDI POTREBBERO ANDARE PERSI PER SEMPRE. + Il supporto o gli amministratori non richiederanno mai la tua Passphrase in nessun caso.\n%s + ATTENZIONE AI TRUFFATORI. + Rivedi e Accetta per Continuare + Elimina backup + Puoi eseguire manualmente il backup della tua frase segreta per garantire l\'accesso ai fondi del tuo portafoglio in caso di perdita dell\'accesso a questo dispositivo + Effettua il backup manualmente + Manuale + Non hai aggiunto alcun portafoglio con una passphrase. + Nessun portafoglio da fare il backup + Puoi abilitare i backup su Google Drive per memorizzare copie criptate di tutti i tuoi portafogli, protetti da una password impostata da te. + Esegui il backup su Google Drive + Google Drive + Backup + Backup + Processo KYC semplice ed efficiente + Autenticazione biometrica + Chiudi tutto + Acquisto iniziato! Attendi fino a 60 minuti. Puoi controllare lo stato sulla email. + Seleziona la rete per l\'acquisto di %s + Acquisto avviato! Attendere fino a 60 minuti. Puoi monitorare lo stato tramite email. + Nessuno dei nostri fornitori supporta attualmente l\'acquisto di questo token. Scegli un token diverso, una rete diversa o riprova più tardi. + Questo token non è supportato dalla funzionalità di acquisto + Possibilità di pagare le commissioni in qualsiasi token + La migrazione avviene automaticamente, nessuna azione necessaria + La cronologia delle transazioni precedenti rimane su %s + A partire dal bilancio di %1$s il tuo %2$s, Staking e Governance sono su %3$s. Queste funzionalità potrebbero non essere disponibili per un massimo di 24 ore. + %1$sx commissioni di transazione ridotte\n(da %2$s a %3$s) + %1$sx riduzione del saldo minimo\n(da %2$s a %3$s) + Cosa rende Asset Hub fantastico? + A partire da %1$s il tuo saldo %2$s, Staking e Governance sono su %3$s + Più token supportati: %s, e altri token dell\'ecosistema + Accesso unificato a %s, asset, staking e governance + Bilanciamento automatico dei nodi + Abilita connessione + Inserisci una password che verrà utilizzata per recuperare i tuoi portafogli dal backup su cloud. Questa password non può essere recuperata in futuro, quindi assicurati di ricordarla! + Aggiorna la password di backup + Asset + Bilancio disponibile + Spiacente, la richiesta di controllo del bilancio non è riuscita. Si prega di riprovare più tardi. + Spiacente, non siamo riusciti a contattare il fornitore di trasferimento. Si prega di riprovare più tardi. + Spiacente, non hai abbastanza fondi per spendere l\'importo specificato + Commissione di trasferimento + Rete non risponde + A + Ops, il regalo è già stato riscattato + Il regalo non può essere ricevuto + Richiedi il regalo + Potrebbe esserci un problema con il server. Per favore, riprova più tardi. + Oops, qualcosa è andato storto + Usa un altro wallet, creane uno nuovo, o aggiungi un account %s a questo wallet nelle Impostazioni. + Hai rivendicato con successo un regalo. I token appariranno presto nel tuo saldo. + Hai ricevuto un regalo crypto! + Crea un nuovo wallet o importa uno esistente per rivendicare il regalo + Non puoi ricevere un regalo con un wallet %s + Tutte le schede aperte nel browser DApp saranno chiuse. + Chiudere tutte le DApp? + %s e ricorda di tenerle sempre offline per poterle ripristinare in qualsiasi momento. Puoi fare questo nelle Impostazioni di Backup. + Per favore scrivi tutte le Passphrase del tuo portafoglio prima di procedere. + Il backup sarà eliminato da Google Drive + Il backup attuale con i tuoi portafogli sarà eliminato definitivamente! + Sei sicuro di voler eliminare il Backup Cloud? + Elimina Backup + Al momento il tuo backup non è sincronizzato. Per favore, esamina questi aggiornamenti. + Trovate modifiche al Backup Cloud + Rivedi Aggiornamenti + Se non hai scritto manualmente la Passphrase per i portafogli che verranno rimossi, quei portafogli e tutti i loro asset saranno persi permanentemente e irreversibilmente. + Sei sicuro di voler applicare queste modifiche? + Rivedi il Problema + Al momento il tuo backup non è sincronizzato. Per favore, esamina il problema. + Impossibile applicare le modifiche al portafoglio nel Backup Cloud + Assicurati di essere collegato al tuo account Google con le credenziali corrette e di aver concesso a Pezkuwi Wallet l\'accesso a Google Drive + Autenticazione di Google Drive fallita + Non hai abbastanza spazio disponibile su Google Drive. + Spazio insufficiente + Purtroppo, Google Drive non funziona senza i servizi Google Play, che mancano sul tuo dispositivo. Prova a ottenere i servizi Google Play. + Servizi Google Play non trovati + Impossibile eseguire il backup dei tuoi portafogli su Google Drive. Assicurati di aver abilitato Pezkuwi Wallet per utilizzare il tuo Google Drive e di avere sufficiente spazio di archiviazione disponibile, quindi riprova. + Errore di Google Drive + Per favore, verifica la correttezza della password e riprova. + Password non valida + Purtroppo, non abbiamo trovato un backup per ripristinare i portafogli + Nessun backup trovato + Assicurati di aver salvato la frase di recupero per il portafoglio prima di procedere. + Il portafoglio sarà rimosso nel Backup sul Cloud + Rivedi l\'Errore di Backup + Rivedi gli Aggiornamenti del Backup + Inserisci la Password di Backup + Abilita per eseguire il backup dei portafogli su Google Drive + Ultima sincronizzazione: %s alle %s + Accedi a Google Drive + Rivedi Problema di Google Drive + Backup Disabilitato + Backup sincronizzato + Sincronizzazione del backup... + Backup non sincronizzato + I nuovi wallet vengono aggiunti automaticamente al Cloud Backup. Puoi disabilitare il Cloud Backup nelle impostazioni. + Le modifiche al wallet saranno aggiornate nel Cloud Backup + Accetta i termini... + Account + Indirizzo dell\'account + Attivo + Aggiungi + Aggiungi delegazione + Aggiungi rete + Indirizzo + Avanzato + Tutti + Consenti + Importo + La somma è troppo bassa + La somma è troppo grande + Applicato + Applica + Richiedi di nuovo + Attenzione! + Disponibile: %s + Media + Saldo + Bonus + Chiama + Dati della chiamata + Hash chiamata + Annulla + Sei sicuro di voler annullare questa operazione? + Spiacenti, non hai l\'appropriata app per elaborare questa richiesta + Impossibile aprire questo link + Puoi utilizzare fino a %s poiché è necessario pagare %s per la commissione di rete. + Catena + Modifica + Cambia password + Continua automaticamente in futuro + Scegli la rete + Cancella + Chiudi + Sei sicuro di voler chiudere questa schermata?\nLe tue modifiche non verranno applicate. + Backup cloud + Completato + Completato (%s) + Conferma + Conferma + Sei sicuro? + Confermato + %d ms + connessione in corso... + Per favore, controlla la tua connessione o riprova più tardi + Connessione fallita + Continua + Copiato negli appunti + Copia indirizzo + Copia i dati della chiamata + Copia hash + Copia ID + Tipo di crittografia della coppia di chiavi + Data + %s e %s + Elimina + Depositatore + Dettagli + Disabilitato + Disconnetti + Non chiudere l\'app! + Fatto + Pezkuwi simula la transazione in anticipo per prevenire errori. Questa simulazione non è riuscita. Riprova più tardi o con un importo maggiore. Se il problema persiste, contatta il Supporto di Pezkuwi Wallet nelle Impostazioni. + Simulazione della transazione fallita + Modifica + %s (+%s altro) + Seleziona app email + Abilita + Inserisci l\'indirizzo... + Inserisci l\'importo... + Inserisci i dettagli + Inserisci un altro importo + Inserisci la password + Errore + Token insufficienti + Evento + EVM + Indirizzo EVM + Il tuo account verrà rimosso dalla blockchain dopo questa operazione perché renderà il saldo totale inferiore al minimo. + L\'operazione rimuoverà l\'account + Scaduto + Esplora + Fallito + La commissione di rete stimata %s è molto più alta rispetto alla commissione predefinita (%s). Ciò potrebbe essere dovuto a congestione temporanea della rete. Puoi aggiornare per attendere una commissione di rete più bassa. + Commissione di rete troppo alta + Tariffa: %s + Ordina per: + Filtri + Scopri di più + Password dimenticata? + + ogni giorno + ogni %s giorni + + quotidiano + quotidiano + Dettagli completi + Ottieni %s + Regalo + Capito + Governance + Stringa esadecimale + Nascondi + + %d ora + %d ore + + Come funziona + Ho capito + Informazioni + Il codice QR non è valido + Codice QR non valido + Per saperne di più + Scopri di più su + Ledger + %s rimasti + Gestisci Wallet + Massimo + %s massimo + + %d minuto + %d minuti + + L\'account %s manca + Modifica + Modulo + Nome + Rete + Ethereum + %s non è supportato + Polkadot + Reti + + Rete + Reti + + Avanti + No + Applicazione di importazione file non trovata sul dispositivo. Si prega di installarla e riprovare. + Nessuna app adatta trovata sul dispositivo per gestire questo intento + Nessuna modifica + Stiamo per mostrare il tuo mnemonico. Assicurati che nessuno possa vedere il tuo schermo e non fare screenshot; potrebbero essere raccolti da malware di terze parti + Nessuno + Non disponibile + Spiacenti, non hai abbastanza fondi per pagare la tassa di rete. + Saldo insufficiente + Non hai abbastanza saldo per pagare la commissione di rete di %s. Il saldo attuale è %s + Non adesso + Spento + OK + Va bene, indietro + Acceso + In corso + Facoltativo + Seleziona un\'opzione + Frase di sicurezza + Incolla + / anno + %s / anno + all\'anno + %% + Le autorizzazioni richieste sono necessarie per utilizzare questa schermata. Dovresti abilitarle nelle Impostazioni. + Autorizzazioni negate + Le autorizzazioni richieste sono necessarie per utilizzare questa schermata. + Autorizzazioni necessarie + Prezzo + Politica sulla privacy + Procedi + Deposito proxy + Revoca accesso + Notifiche push + Leggi di più + Consigliato + Aggiorna commissioni + Rifiuta + Rimuovi + Obbligatorio + Ripristina + Riprova + Qualcosa è andato storto. Per favore, riprova + Revoca + Salva + Scansiona il codice QR + Cerca + Qui verranno visualizzati i risultati della ricerca + Risultati della ricerca: %d + sec + + %d secondo + %d secondi + + Percorso di derivazione segreto + Vedi tutto + Seleziona token + Impostazioni + Condividi + Condividi i dati della chiamata + Condividi hash + Mostra + Accedi + Firma la richiesta + Firmatario + Firma non valida + Salta + Salta il processo + Si è verificato un errore durante l\'invio di alcune transazioni. Vuoi riprovare? + Impossibile inviare alcune transazioni + Qualcosa è andato storto + Ordina per + Stato + Substrate + Indirizzo Substrate + Tocca per rivelare + Termini e condizioni + Testnet + Tempo rimasto + Titolo + Apri Impostazioni + Il tuo saldo è troppo piccolo + Totale + Commissione totale + ID della transazione + Transazione inviata + Prova di nuovo + Tipo + Per favore, riprova con un altro input. Se l\'errore persiste, contatta il supporto. + Sconosciuto + + %s non supportata + %s non supportate + + Illimitato + Aggiorna + Usa + Usa massimo + Il destinatario dovrebbe essere un indirizzo %s valido + Destinatario non valido + Visualizza + In attesa + Portafoglio + Avvertimento + + Il tuo Regalo + L\'importo deve essere positivo + Inserisci la password che hai creato durante il processo di backup + Inserisci la password attuale del backup + Confermare trasferirà token dal tuo account + Seleziona le parole... + Frase di sicurezza non valida, per favore controlla di nuovo l\'ordine delle parole + Referendum + Vota + Traccia + Il nodo è già stato aggiunto in precedenza. Per favore, prova un altro nodo. + Impossibile stabilire una connessione con il nodo. Per favore, prova un altro. + Purtroppo, la rete non è supportata. Per favore, prova una delle seguenti: %s. + Conferma eliminazione di %s. + Eliminare la rete? + Si prega di controllare la connessione o riprovare più tardi + Personalizzato + Predefinito + Reti + Aggiungi connessione + Scansione codice QR + È stato identificato un problema con il tuo backup. Hai la possibilità di eliminare il backup attuale e crearne uno nuovo. %s prima di procedere. + Assicurati di aver salvato le Frasi di sicurezza per tutti i wallet + Backup trovato ma vuoto o danneggiato + In futuro, senza la password di backup non sarà possibile ripristinare i tuoi portafogli dal Cloud Backup.\n%s + Questa password non può essere recuperata. + Ricorda la password di backup + Conferma password + Password di backup + Lettere + Min. 8 caratteri + Numeri + Le password corrispondono + Per favore inserisci una password per accedere al tuo backup in qualsiasi momento. La password non può essere recuperata, assicurati di ricordarla! + Crea la tua password di backup + L\'ID della catena inserito non corrisponde alla rete nell\'URL RPC. + ID Catena non valido + Le crowdloan private non sono ancora supportate + Crowdloan privata + Informazioni sulle crowdloan + Diretto + Ulteriori informazioni sui diversi contributi ad Acala + Liquid + Attive (%s) + Accetta i Termini e le Condizioni + Bonus del portafoglio Pezkuwi (%s) + Il codice di riferimento Astar dovrebbe essere un indirizzo Polkadot valido + Impossibile contribuire all\'importo scelto poiché l\'importo raccolto risultante supererà il limite del crowdloan. Il contributo massimo consentito è %s. + Impossibile contribuire al crowdloan selezionato poiché il suo limite è già stato raggiunto. + Superato limite crowdloan + Contribuisci al crowdloan + Contributo + Hai contribuito: %s + Liquid + Parallelo + I tuoi contributi\n appariranno qui + Ritorna in %s + Da restituire dal parachain + %s (tramite %s) + Crowdloans + Ottieni un bonus speciale + I crowdloan verranno visualizzati qui + Impossibile contribuire al crowdloan selezionato poiché è già terminato. + Il crowdloan è concluso + Inserisci il tuo codice di riferimento + Informazioni sui crowdloan + Scopri il crowdloan di %s + Sito web del crowdloan di %s + Periodo di leasing + Scegli le parachain a cui contribuire con il tuo %s. Riceverai indietro i token che hai contribuito e, se la parachain vince uno slot, riceverai le ricompense alla fine dell\'asta + Devi aggiungere un account %s al portafoglio per poter contribuire + Applica bonus + Se non hai un codice di riferimento, puoi applicare il codice di riferimento Pezkuwi per ricevere un bonus per il tuo contributo + Non hai applicato il bonus + Il crowdloan di Moonbeam supporta solo account di tipo crittografico SR25519 o ED25519. Si prega di considerare l\'uso di un altro account per contribuire + Impossibile contribuire con questo account + Dovresti aggiungere un account Moonbeam al portafoglio per partecipare al crowdloan di Moonbeam + Manca l\'account di Moonbeam + Questo crowdloan non è disponibile nella tua località + Il tuo paese non è supportato + %s destinazione ricompensa + Invia l\'accordo + Devi inviare l\'accordo con i Termini e le Condizioni sulla blockchain per procedere. Questo deve essere fatto solo una volta per tutti i contributi successivi a Moonbeam + Ho letto e accetto i Termini e Condizioni + Raccolto + Codice di riferimento + Il codice di riferimento non è valido. Si prega di provare un altro + %s Termini e condizioni + L\'importo minimo consentito per contribuire è %s. + L\'importo del contributo è troppo piccolo + I tuoi token %s saranno restituiti dopo il periodo di leasing. + I tuoi contributi + Raccolto: %s di %s + L\'URL RPC inserito è presente in Pezkuwi come rete personalizzata %s. Sei sicuro di volerla modificare? + https://networkscan.io + URL del Block explorer (Opzionale) + 012345 + ID Catena + TOKEN + Simbolo della Moneta + Nome della rete + Aggiungi nodo + Aggiungi nodo personalizzato per + Inserisci i dettagli + Salva + Modifica nodo personalizzato per + Nome + Nome del nodo + wss:// + URL del nodo + URL RPC + Le DApp alle quali hai permesso l\'accesso per vedere il tuo indirizzo quando le utilizzi + L\'applicazione \ + Rimuovi da Autorizzati? + DApp Autorizzate + Catalogo + Approva questa richiesta se ti fidi dell\'applicazione + Consenti a \"%s\" di accedere agli indirizzi del tuo account? + Approva questa richiesta se ti fidi dell\'applicazione.\nControlla i dettagli della transazione. + DApp + DApp + %d DApp + Preferiti + Preferiti + Aggiungi ai preferiti + \"%s\" DApp verrà rimosso dai Preferiti + Rimuovi dai Preferiti? + L\'elenco delle DApp apparirà qui + Aggiungi ai Preferiti + Modalità Desktop + Rimuovi dai Preferiti + Impostazioni Pagina + Ok, riportami indietro + Pezkuwi Wallet ritiene che questo sito web possa compromettere la sicurezza dei tuoi account e dei tuoi token + Rilevato Phishing + Cerca per nome o inserisci l\'URL + Catena non supportata con hash di genesi %s + Assicurati che l\'operazione sia corretta + Impossibile firmare l\'operazione richiesta + Apri comunque + Le DApp dannose possono prelevare tutti i tuoi fondi. Esegui sempre una tua ricerca prima di utilizzare una DApp, di concedere autorizzazioni o di inviare fondi.\n\nSe qualcuno ti spinge a visitare questa DApp, si tratta probabilmente di una truffa. In caso di dubbio, contatta il supporto di Pezkuwi Wallet: %s. + Attenzione! DApp sconosciuta + Identificativo della rete non trovato + Il dominio dal link %s non è permesso + Il tipo di governance non è specificato + Il tipo di governance non è supportato + Tipo di crittografia non valido + Percorso di derivazione non valido + La mnemonica non è valida + URL non valido + L\'URL RPC inserito è presente in Pezkuwi come rete %s. + Canale di notifica predefinito + +%d + Cerca per indirizzo o nome + Formato dell\'indirizzo non valido. Assicurati che l\'indirizzo appartenga alla rete corretta + risultati della ricerca: %d + Account Proxy e Multisig rilevati automaticamente e organizzati per te. Gestisci in qualsiasi momento nelle Impostazioni. + La lista dei portafogli è stata aggiornata + Votato per sempre + Delega + Tutti gli account + Individui + Organizzazioni + Il periodo di sblocco inizierà dopo la revoca di una delega + I tuoi voti voteranno automaticamente insieme al voto dei tuoi delegati + Informazioni sulla delega + Individuo + Organizzazione + Voti delegati + Deleghe + Modifica delega + Non puoi delegare a te stesso, per favore scegli un altro indirizzo + Non puoi delegare a te stesso + Raccontaci di più su di te in modo che gli utenti di Pezkuwi possano conoscerti meglio + Sei un delegato? + Descriviti + Attraverso %s percorsi + Ultimo voto %s + I tuoi voti tramite %s + I tuoi voti: %s tramite %s + Rimuovi voti + Revoca delega + Dopo che il periodo di undelegating è scaduto, dovrai sbloccare i tuoi token. + Voti delegati + Deleghe + Ultimo voto %s + Percorsi + Seleziona tutto + Seleziona almeno 1 percorso... + Nessun percorso disponibile per delegare + Fellowship + Governance + Tesoreria + Periodo di undelegating + Non più valido + La tua delega + Le tue deleghe + Mostra + Eliminazione del backup in corso... + La tua password di backup è stata aggiornata in precedenza. Per continuare a usare il Cloud Backup, %s + per favore inserisci la nuova password di backup. + La password di backup è stata modificata + Non puoi firmare transazioni di reti disabilitate. Abilita %s nelle impostazioni e riprova + %s è disabilitata + Stai già delegando a questo account: %s + La delega esiste già + (compatibile BTC/ETH) + ECDSA + ed25519 (alternativa) + Edwards + Password di backup + Inserisci i dati della chiamata + Non hai abbastanza token per pagare la tariffa + Contratto + Chiamata contratto + Funzione + Ripristina portafogli + %s Tutti i tuoi portafogli saranno salvati in modo sicuro su Google Drive. + Vuoi ripristinare i tuoi portafogli? + Trovato backup cloud esistente + Scarica il JSON di ripristino + Conferma password + Le password non corrispondono + Imposta password + Rete: %s\nMnemonica: %s\nPercorso di derivazione: %s + Rete: %s\nMnemonica: %s + Attendere fino al calcolo della commissione + Il calcolo della commissione è in corso + Gestisci Carta di Debito + Vendi token %s + Aggiungi delega per lo staking di %s + Dettagli dello swap + Massimo: + Tu paghi + Tu ricevi + Seleziona un token + Ricarica la carta con %s + Si prega di contattare support@pezkuwichain.io. Includere l\'indirizzo email che hai usato per emettere la carta. + Contatta il supporto + Rivendicato + Creato: %s + Inserisci importo + Il regalo minimo è %s + Revocato + Seleziona un token da regalare + Commissione di rete per il riscatto + Crea Regalo + Invia regali velocemente, facilmente e in sicurezza su Pezkuwi + Condividi Regali Crypto con Chiunque, Ovunque + Regali che hai creato + Seleziona la rete per il regalo %s + Inserisci l\'importo del tuo regalo + %s come un link e invita chiunque su Pezkuwi + Condividi il regalo direttamente + %s, e puoi restituire i regali non rivendicati in qualsiasi momento da questo dispositivo + Il regalo è disponibile istantaneamente + Canale di notifica della governance + + È necessario selezionare almeno %d traccia + È necessario selezionare almeno %d tracce + + Sblocca + Eseguire questo swap comporterà uno slippage significativo e perdite finanziarie. Considera di ridurre la dimensione del tuo scambio o di dividere il tuo scambio in più transazioni. + Alto impatto sul prezzo rilevato (%s) + Cronologia + Email + Nome legale + Nome Element + Identità + Web + Il file JSON fornito è stato creato per una rete diversa. + Assicurati che il tuo input contenga un JSON valido. + Il ripristino JSON non è valido + Controlla la correttezza della password e riprova. + Decifratura del keystore fallita + Incolla json + Tipo di crittografia non supportato + Impossibile importare l\'account con segreto Substrate nella rete con crittografia Ethereum + Impossibile importare l\'account con segreto Ethereum nella rete con crittografia Substrate + La tua mnemonica non è valida + Assicurati che il tuo input contenga 64 simboli esadecimali. + Seed non valido + Purtroppo, non è stato trovato nessun backup con i tuoi portafogli. + Backup non trovato + Recupera portafogli da Google Drive + Usa la tua frase da 12, 15, 18, 21 o 24 parole + Scegli come desideri importare il tuo portafoglio + Solo visualizzazione + Integra tutte le funzionalità della rete che stai costruendo in Pezkuwi Wallet, rendendola accessibile a tutti. + Integra la tua rete + Stai costruendo per Polkadot? + I dati della chiamata che hai fornito sono non validi o hanno un formato sbagliato. Assicurati che siano corretti e riprova. + Questi dati di chiamata sono per un\'altra operazione con hash di chiamata %s + Dati della chiamata non validi + L\'indirizzo del proxy dovrebbe essere un indirizzo valido %s + Indirizzo proxy non valido + Il Simbolo della Moneta inserito (%1$s) non corrisponde alla rete (%2$s). Vuoi utilizzare il simbolo corretto? + Simbolo della Moneta non valido + Impossibile decodificare il QR + Codice QR + Carica dalla galleria + Esporta file JSON + Lingua + Ledger non supporta %s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + L\'operazione è stata annullata dal dispositivo. Assicurati di aver sbloccato il tuo Ledger. + Operazione annullata + Apri l\'applicazione %s sul tuo dispositivo Ledger + Applicazione %s non avviata + Operazione completata con errore sul dispositivo. Per favore, riprova più tardi. + Operazione Ledger fallita + Tieni premuto il pulsante di conferma sul tuo %s per approvare la transazione + Carica più account + Revisione e Approvazione + Account %s + Account non trovato + Il tuo dispositivo Ledger sta utilizzando un\'app operativa generica obsoleta che non supporta indirizzi EVM. Aggiornala tramite Ledger Live. + Aggiorna App Generica Ledger + Premi entrambi i pulsanti sul tuo %s per approvare la transazione + Per favore, aggiorna %s usando l\'applicazione Ledger Live + Metadati obsoleti + Ledger non supporta la firma di messaggi arbitrari - solo transazioni + Assicurati di aver selezionato il dispositivo Ledger corretto per l\'operazione attualmente in fase di approvazione + Per motivi di sicurezza, le operazioni generate sono valide solo per %s. Per favore, riprova e approvata con Ledger + Transazione scaduta + La transazione è valida per %s + Ledger non supporta questa transazione. + La transazione non è supportata + Premi entrambi i pulsanti sul tuo %s per approvare l\'indirizzo + Premi il pulsante di conferma sul tuo %s per approvare l\'indirizzo + Premi entrambi i pulsanti sul tuo %s per approvare gli indirizzi + Premi il pulsante di conferma sul tuo %s per approvare gli indirizzi + Questo portafoglio è abbinato a Ledger. Pezkuwi ti aiuterà a effettuare qualsiasi operazione desideri e ti verrà richiesto di firmarle usando Ledger + Seleziona l\'account da aggiungere al portafoglio + Caricamento informazioni della rete... + Caricamento dei dettagli della transazione. + Cerco il tuo backup... + Transazione di pagamento inviata + Aggiungi token + Gestisci backup + Elimina rete + Modifica rete + Gestisci rete aggiunta + Non sarai in grado di vedere i saldi dei tuoi token su quella rete nella schermata Assets + Eliminare la rete? + Elimina nodo + Modifica nodo + Gestisci nodo aggiunto + Il nodo \\"%s\\" sarà eliminato + Eliminare il nodo? + Chiave personalizzata + Chiave predefinita + Non condividere queste informazioni con nessuno - Se lo fai, perderai permanentemente e irreversibilmente tutti i tuoi asset + Account con chiave personalizzata + Account predefiniti + %s, +%d altri + Account con chiave predefinita + Seleziona la chiave per il backup + Seleziona un portafoglio per il backup + Si prega di leggere attentamente quanto segue prima di visualizzare il backup + Non condividere la tua frase segreta! + Miglior prezzo con commissioni fino al 3,95% + Assicurati che nessuno possa vedere il tuo schermo\ne non fare screenshot + Per favore %s nessuno + non condividere con + Per favore, prova un\'altra + Frase mnemonica non valida, controlla ancora una volta l\'ordine delle parole + Non è possibile selezionare più di %d portafogli + + Seleziona almeno %d portafoglio + Seleziona almeno %d portafogli + + Questa transazione è già stata rifiutata o eseguita. + Non è possibile eseguire questa transazione + %s ha già avviato la stessa operazione ed è attualmente in attesa di essere firmata da altri firmatari. + Operazione già esistente + Per gestire la tua carta di debito, passa a un diverso tipo di portafoglio. + La carta di debito non è supportata per Multisig + Deposito multisig + Il deposito rimane bloccato sul conto del depositante fino a quando l\'operazione multisig non è eseguita o rifiutata. + Assicurati che l\'operazione sia corretta + Per creare o gestire regali, per favore cambia tipo di portafoglio. + I regali non sono supportati per i wallet Multisig + Firmato ed eseguito da %s. + ✅ Transazione multisig eseguita + %s su %s. + ✍🏻 Richiesta la tua firma + Iniziata da %s. + Portafoglio: %s + Firmato da %s + %d di %d firme raccolte. + Per conto di %s. + Rifiutato da %s. + ❌ Transazione multisig rifiutata + Per conto di + %s: %s + Approva + Approva & Esegui + Inserisci i dati della chiamata per visualizzare i dettagli + Transazione già eseguita o rifiutata. + Firma conclusa + Rifiuta + Firmatari (%d di %d) + Batch Tout (annulla in caso di errore) + Batch (esegue fino all\'errore) + Force Batch (ignora errori) + Creato da te + Nessuna transazione ancora.\nLe richieste di firma appariranno qui + Firma (%s di %s) + Firmato da te + Operazione sconosciuta + Transazioni da firmare + Per conto di: + Firmato dal firmatario + Transazione multisig eseguita + Richiesta la tua firma + Transazione multisig rifiutata + Per vendere criptovaluta per fiat, passa a un diverso tipo di portafoglio. + La vendita non è supportata per Multisig + Firmatario: + %s non ha saldo sufficiente per collocare un deposito multisig di %s. Devi aggiungere %s al tuo saldo + %s non ha saldo sufficiente per pagare la tariffa di rete di %s e collocare un deposito multisig di %s. Devi aggiungere %s al tuo saldo + %s ha bisogno di almeno %s per pagare la commissione di transazione e rimanere sopra il saldo minimo di rete. Saldo attuale: %s + %s non ha saldo sufficiente per pagare la tariffa di rete di %s. Devi aggiungere %s al tuo saldo + I wallet multisig non supportano la firma di messaggi arbitrari, ma solo delle transazioni. + La transazione sarà respinta. Il deposito multisig sarà restituito a %s. + La transazione multisig sarà avviata da %s. L\'iniziatore paga la commissione di rete e riserva un deposito multisig, che verrà liberato una volta eseguita la transazione. + Transazione multisig + Altri firmatari possono ora confermare la transazione.\nPuoi monitorarne lo stato in %s. + Transazioni da firmare + Transazione multisig creata + Visualizza dettagli + Transazione senza informazioni iniziali on-chain (dati call) è stata rifiutata + %s su %s.\nNon sono richieste ulteriori azioni da parte tua. + Transazione Multisig Eseguita + %1$s    + %s su %s.\nRifiutato da: %s.\nNon sono richieste ulteriori azioni da parte tua. + Transazione Multisig Rifiutata + Le transazioni da questo portafoglio richiedono l\'approvazione di più firmatari. Il tuo account è uno dei firmatari: + Altri firmatari: + Soglia %d su %d + Canale di Notifica Transazioni Multisig + Attiva in Impostazioni + Ricevi notifiche sulle richieste di firma, nuove firme e transazioni completate    così sarai sempre in controllo. Gestisci in qualsiasi momento nelle Impostazioni. + Le notifiche push multisig sono qui! + Questo Nodo esiste già + Commissione di rete + Indirizzo del nodo + Informazioni sul nodo + Aggiunto + nodi personalizzati + nodi predefiniti + Predefinito + Connessione in corso... + Collezione + Creato da + %s per %s + %s unità di %s + #%s Edizione di %s + Serie illimitata + Di proprietà di + Non quotato + I tuoi NFT + Non hai aggiunto né selezionato alcun portafoglio multisig in Pezkuwi. Aggiungi e seleziona almeno uno per ricevere notifiche push multisig. + Nessun portafoglio multisig + L\'URL che hai inserito esiste già come Nodo \"%s\". + Questo Nodo esiste già + L\'URL del nodo che hai inserito non risponde o contiene un formato errato. Il formato dell\'URL dovrebbe iniziare con \"wss://\". + Errore nodo + L\'URL che hai inserito non corrisponde al nodo per %1$s.\nPer favore inserisci l\'URL di un nodo %1$s valido. + Rete sbagliata + Richiedi ricompense + I tuoi token saranno aggiunti di nuovo allo stake + Diretto + Informazioni sullo staking nel pool + Le tue ricompense (%s) saranno anche richieste e aggiunte al tuo saldo libero + Pool + Impossibile eseguire l\'operazione specificata poiché il pool è nello stato di chiusura. Verrà chiuso presto. + Il pool sta chiudendo + Attualmente non ci sono posti liberi nella coda di uscita per il tuo pool. Riprova tra %s + Troppe persone stanno cancellando dal tuo pool + Il tuo pool + Il tuo pool (#%s) + Crea account + Crea un nuovo portafoglio + Informativa sulla privacy + Importa account + Ho già un portafoglio + Continuando, accetti i nostri\n%1$s e %2$s + Termini e Condizioni + Scambia + Uno dei tuoi collatori non sta generando ricompense + Uno dei tuoi collatori non è stato selezionato nel round attuale + È passato il tuo periodo di unstake per %s. Non dimenticare di riscattare i tuoi token + Impossibile fare stake con questo collatore + Cambia collatore + Impossibile aggiungere stake a questo collatore + Gestisci collatori + Il collatore selezionato ha mostrato l\'intenzione di smettere di partecipare allo staking. + Non puoi aggiungere stake al collatore per il quale stai rimuovendo tutti i token. + Il tuo stake sarà inferiore al minimo necessario (%s) per questo collatore. + Il saldo rimanente dello staking scenderà sotto il valore minimo di rete (%s) e sarà aggiunto all\'importo di unstake + Non sei autorizzato. Riprova, per favore. + Utilizza il riconoscimento biometrico per autorizzare + Pezkuwi Wallet utilizza l\'autenticazione biometrica per limitare l\'accesso non autorizzato all\'app. + Biometria + Il PIN è stato cambiato con successo + Conferma il tuo PIN + Crea un codice PIN + Inserisci il codice PIN + Imposta il tuo codice PIN + Non puoi unirti al pool poiché ha raggiunto il numero massimo di membri + Il pool è pieno + Non puoi unirti a un pool che non è aperto. Per favore, contatta il proprietario del pool. + Il pool non è aperto + Non puoi più utilizzare sia il Direct Staking che il Pool Staking dallo stesso account. Per gestire il tuo Pool Staking devi prima disattivare lo staking dei tuoi token dal Direct Staking. + Operazioni del Pool non disponibili + Popolari + Aggiungi rete manualmente + Caricamento della lista delle reti... + Cerca per nome rete + Aggiungi rete + %s alle %s + 1G + Tutto + 1M + %s (%s) + Prezzo di %s + 1S + 1A + Tutti i Tempi + Ultimo Mese + Oggi + Ultima Settimana + Ultimo Anno + Account + Portafogli + Lingua + Cambia il codice PIN + Apri l\'app con saldo nascosto + Approvazione con PIN + Modalità sicura + Impostazioni + Per gestire la tua Carta di Debito, cambia a un portafoglio diverso con la rete Polkadot. + La Carta di Debito non è supportata per questo portafoglio Proxiato + Questo account ha concesso l\'accesso per eseguire transazioni al seguente account: + Operazioni di staking + L\'account delegato %s non ha abbastanza saldo per pagare la commissione di rete di %s. Saldo disponibile per pagare la commissione: %s + I portafogli proxy non supportano la firma di messaggi arbitrari, solo transazioni + %1$s non ha delegato %2$s + %1$s ha delegato %2$s solo per %3$s + Ops! Permesso insufficiente + La transazione sarà avviata da %s come account delegato. La commissione di rete sarà pagata dall\'account delegato. + Questo è un account Delegante (Proxy) + %s proxy + Il delegato ha votato + Nuovo Referendum + Aggiornamento Referendum + %s Referendum #%s è ora attivo! + 🏛️ Nuovo referendum + Scarica Pezkuwi Wallet v%s per ottenere tutte le nuove funzionalità! + Un nuovo aggiornamento di Pezkuwi Wallet è disponibile! + %s referendum #%s si è concluso ed è stato approvato 🎉 + ✅ Referendum approvato! + %s Referendum #%s stato cambiato da %s a %s + %s referendum #%s si è concluso ed è stato rifiutato! + ❌ Referendum rifiutato! + 🏛️ Stato del referendum cambiato + %s Referendum #%s stato cambiato in %s + Annunci Pezkuwi + Saldi + Abilita notifiche + Transazioni multisig + Non riceverai notifiche sulle Attività del Portafoglio (Saldi, Staking) perché non hai selezionato nessun portafoglio + Altro + Token ricevuti + Token inviati + Ricompense di staking + Portafogli + ✨ Nuova ricompensa %s + Ricevuto %s da staking %s + ✨ Nuova ricompensa + Pezkuwi Wallet • ora + Ricevuti +0.6068 KSM ($20.35) da staking Kusama + Ricevuti %s su %s + ⬇️ Ricevuto + ⬇️ Ricevuto %s + Inviati %s a %s su %s + 💸 Inviato + 💸 Inviato %s + Seleziona fino a %d portafogli per ricevere notifiche quando il portafoglio ha attività + Abilita notifiche push + Ricevi notifiche su operazioni del Wallet, aggiornamenti della Governance, attività di Staking e Sicurezza, così sarai sempre informato + Abilitando le notifiche push, accetti i nostri %s e %s + Si prega di riprovare più tardi accedendo alle impostazioni delle notifiche dalla scheda Impostazioni + Non perdere nulla! + Seleziona la rete per ricevere %s + Copia indirizzo + Se ritiri questo regalo, il link condiviso sarà disabilitato e i token verranno rimborsati al tuo wallet.\nVuoi continuare? + Revocare il Regalo %s? + Hai riconquistato il tuo regalo con successo + Incolla json o carica il file... + Carica file + Ripristina JSON per il recupero + Frase mnemonica + Seed grezzo + Tipo di origine + Tutti i referendum + Mostra: + Non votati + Votati + Non ci sono referendum con filtri applicati + Le informazioni sui referendum appariranno qui quando inizieranno + Nessun referendum con titolo o ID inserito è stato trovato + Cerca per titolo o ID del referendum + %d referendum + Scorri per votare sui referendum con riassunti AI. Veloce & facile! + Referendum + Referendum non trovato + I voti di astensione possono essere effettuati solo con una convinzione di 0.1x. Votare con una convinzione di 0.1x? + Aggiornamento della convinzione + Voti di astensione + Affermativo: %s + Usa il browser Pezkuwi DApp + Solo il proponente può modificare questa descrizione e il titolo. Se possiedi l\'account del proponente, visita Polkassembly e inserisci informazioni sulla tua proposta + Importo richiesto + Cronologia + Il tuo voto: + Curva di approvazione + Beneficiario + Deposito + Elettorato + Troppo lungo per l\'anteprima + Parametri JSON + Proponente + Curva di supporto + Partecipazione + Soglia di voto + Posizione: %s di %s + È necessario aggiungere un account %s al portafoglio per poter votare + Referendum %s + Contro: %s + Voti contrari + %s voti da %s + Voti favorevoli + Staking + Approvato + Annullato + In decisione + Decisione in %s + Eseguito + In coda + In coda (%s di %s) + Annullato + Non superato + Superato + In preparazione + Respinto + Approvazione in %s + Esecuzione in %s + Scadenza in %s + Rifiuto in %s + Scaduto + In attesa del deposito + Soglia: %s di %s + Votato: Approvato + Annullato + Creato + Votazione: Decisione + Eseguito + Votazione: In coda + Annullato + Votazione: Non superato + Votazione: Superato + Votazione: In preparazione + Votato: Respinto + Scaduto + Votazione: In attesa del deposito + Per superare: %s + Crowdloans + Cassa: grandi spese + Cassa: grandi mance + Fellowship: amministrazione + Governance: registratore + Governance: affitto + Cassa: spese medie + Governance: annullatore + Governance: killer + Main agenda + Treasury: small spend + Treasury: small tips + Treasury: any + rimane bloccato in %s + Sbloccabile + Astenersi + + Riutilizzare tutti i blocchi: %s + Riutilizzo del blocco di governance: %s + Blocco di governance + Periodo di blocco + No + Moltiplica i voti aumentando il periodo di blocco + Vota per %s + Dopo il periodo di blocco non dimenticare di sbloccare i tuoi token + Referendum votati + %s voti + %s imes %sx + L\'elenco degli elettori apparirà qui + %s voti + Fellowship: whitelist + Il tuo voto: %s voti + Il referendum è completato e la votazione è terminata + Il referendum è completato + Stai delegando i voti per il track del referendum selezionato. Per favore chiedi al tuo delegato di votare o rimuovi la delega per poter votare direttamente. + Delega già attiva + Hai raggiunto il massimo di %s voti per il track + Raggiunto il numero massimo di voti + Non hai abbastanza token disponibili per votare. Disponibili per il voto: %s. + Revoca tipo di accesso + Revoca per + Rimuovi voti + + Hai votato precedentemente nei referendum nel %d percorso. Per rendere questo percorso disponibile per la delega, è necessario rimuovere i tuoi voti esistenti. + Hai votato precedentemente nei referendum in %d percorsi. Per rendere questi percorsi disponibili per la delega, è necessario rimuovere i tuoi voti esistenti. + + Rimuovere la cronologia dei tuoi voti? + %s È esclusivamente tuo, conservato in modo sicuro e inaccessibile agli altri. Senza la password del backup, è impossibile ripristinare i portafogli da Google Drive. Se perso, elimina il backup corrente per crearne uno nuovo con una nuova password. %s + Sfortunatamente, la tua password non può essere recuperata. + In alternativa, usa Passphrase per il ripristino. + Hai perso la tua password? + La password del backup è stata aggiornata. Per continuare a utilizzare il Cloud Backup, inserisci la nuova password di backup. + Inserisci la password che hai creato durante il processo di backup + Inserisci la password di backup + Impossibile aggiornare le informazioni sul runtime della blockchain. Alcune funzionalità potrebbero non funzionare. + Aggiornamento del runtime fallito + Contatti + i miei account + Nessun pool con nome inserito o ID pool è stato trovato. Assicurati di avere inserito i dati corretti + Indirizzo dell\'account o nome dell\'account + I risultati della ricerca verranno visualizzati qui + Risultati della ricerca + pul piscine attive: %d + membri + Seleziona pool + Seleziona percorsi per aggiungere la delega + Percorsi disponibili + Seleziona le tracce in cui desideri delegare il tuo potere di voto. + Seleziona le tracce da modificare per la delega + Seleziona le tracce per revocare la tua delega + Tracce non disponibili + Invia regalo su + Abilita Bluetooth & Concedi Permessi + Pezkuwi ha bisogno che la posizione sia attivata per poter effettuare la scansione Bluetooth per trovare il tuo dispositivo Ledger + Si prega di abilitare la geolocalizzazione nelle impostazioni del dispositivo + Seleziona rete + Seleziona il token con cui votare + Seleziona brani per + %d di %d + Seleziona la rete per vendere %s + Vendita avviata! Attendi fino a 60 minuti. Puoi monitorare lo stato tramite email. + Nessuno dei nostri fornitori supporta attualmente la vendita di questo token. Scegli un token diverso, una rete diversa o riprova più tardi. + Questo token non è supportato dalla funzionalità di vendita + Indirizzo o w3n + Seleziona la rete per inviare %s + Il destinatario è un conto di sistema. Non è controllato da alcuna azienda o individuo.\nSei sicuro di voler comunque effettuare questo trasferimento? + I token verranno persi + Conferire autorità a + Si prega, assicurarsi che la biometria sia abilitata nelle impostazioni + Biometria disabilitata nelle impostazioni + Comunità + Ottieni supporto via Email + Generale + Ogni operazione di firma sui portafogli con coppia di chiavi (creata in un portafoglio di nova o importata) dovrebbe richiedere la verifica del PIN prima di costruire la firma + Richiedi autenticazione per la firma operazioni + Preferenze + Le notifiche push sono disponibili solo per la versione di Pezkuwi Wallet scaricata da Google Play. + Le notifiche push sono disponibili solo per dispositivi con servizi Google. + La registrazione dello schermo e gli screenshot non saranno disponibili. L\'app minimizzata non visualizzerà il contenuto + Modalità sicura + Sicurezza + Supporto e Feedback + Twitter + Wiki e Centro assistenza + Youtube + La convinzione sarà impostata a 0.1x quando Abstain + Non puoi fare staking contemporaneamente con Direct Staking e con Nomination Pools + Già in staking + Gestione avanzata dello staking + Il tipo di staking non può essere modificato + Hai già lo staking diretto + Staking diretto + Hai specificato meno della puntata minima del %s richiesta per guadagnare premi con %s. Dovresti considerare l\'uso dello staking in pool per guadagnare premi. + Riutilizza i token nella Governance + Puntata minima: %s + Ricompense: Pagate automaticamente + Ricompense: Richiesta manuale + Sei già staking in un pool + Staking del pool + Il tuo staking è inferiore al minimo per guadagnare ricompense + Tipo di staking non supportato + Condividi il link del regalo + Revoca + Ciao! Hai un regalo %s che ti sta aspettando!\n\nInstalla l\'app Pezkuwi Wallet, configura il tuo wallet e richiedilo attraverso questo link speciale:\n%s + Il Regalo è Pronto.\nCondividilo Ora! + sr25519 (raccomandato) + Schnorrkel + L\'account selezionato è già in uso come controller + Aggiungi autorità delegata (Proxy) + Le tue deleghe + Deleganti attivi + Aggiungi l\'account del controller %s all\'applicazione per eseguire questa azione. + Aggiungi delega + Il tuo staking è inferiore al minimo di %s. Avere uno staking inferiore al minimo aumenta le possibilità che lo staking non generi premi + Stake più token + Cambia i tuoi validatori. + Tutto è a posto ora. Gli avvisi compariranno qui. + Avere una posizione superata nella coda dell\'assegnazione dello staking a un validatore può sospendere i tuoi premi + Miglioramenti dello staking + Riscatta i token non staked. + Attendere l\'inizio della prossima era. + Avvisi + Già controller + Hai già staking in %s + Saldo staking + Saldo + Punta di più + Né nominante né validante + Cambia controller + Cambia validatori + %s di %s + Validatori selezionati + Controller + Account controller + Abbiamo riscontrato che questo account non ha token liberi, sei sicuro di voler cambiare il controller? + Il controller può fare unstake, riscattare, ritornare a stake, cambiare destinazione ricompense e validatori. + Il controller viene utilizzato per: unstake, riscattare, ritornare a stake, cambiare validatori e impostare destinazione ricompense + Il controller è stato cambiato + Questo validatore è bloccato e al momento non può essere selezionato. Per favore, riprova nella prossima era. + Cancella filtri + Deseleziona tutto + Riempi il resto con consigliati + Validatori: %d di %d + Seleziona validatori (max %d) + Mostra selezionati: %d (max %d) + Seleziona validatori + Ricompense stimate (%% APR) + Ricompense stimate (%% APY) + Aggiorna la tua lista + Staking tramite browser DApp Pezkuwi + Più opzioni di staking + Staka e guadagna ricompense + Lo staking di %1$s è attivo su %2$s a partire da %3$s + Ricompense stimate + era #%s + Guadagni stimati + Ricompense %s stimate + Stake del validatore + Stake del validatore (%s) + I token in periodo di unstaking non generano ricompense. + Durante il periodo di unstaking i token non producono ricompense + Dopo il periodo di unstaking dovrai riscattare i tuoi token. + Dopo il periodo di unstaking non dimenticare di riscattare i tuoi token + Le tue ricompense aumenteranno a partire dalla prossima era. + Le tue ricompense aumenteranno a partire dalla prossima era + I token staked generano ricompense ad ogni era (%s). + I token in stake producono ricompense ad ogni era (%s) + Il portafoglio Pezkuwi cambierà la destinazione delle ricompense al tuo account per evitare il rimanente stake. + Se vuoi unstake dei token, dovrai attendere il periodo di unstaking (%s). + Per unstake dei token dovrai attendere il periodo di unstaking (%s) + Informazioni sullo staking + Nominatori attivi + + %d giorno + %d giorni + + Importo minimo + %s rete + Staked + Si prega di passare il portafoglio a %s per configurare un proxy + Seleziona l\'account stash per configurare il proxy + Gestisci + %s (max %s) + Il numero massimo di nominati è stato raggiunto. Riprova più tardi + Impossibile iniziare lo staking + Stake minimo + È necessario aggiungere un account %s al tuo portafoglio per iniziare lo staking + Mensile + Aggiungi il tuo account controller nel dispositivo. + Nessun accesso all\'account controller + Nominato: + %s premiato + Uno dei tuoi validatori è stato eletto dalla rete. + Stato attivo + Stato inattivo + La tua quantità staccata è inferiore allo stake minimo per ottenere un premio. + Nessuno dei tuoi validatori è stato eletto dalla rete. + Il tuo stake inizierà nella prossima era. + Inattivo + In attesa della prossima era + in attesa della prossima era (%s) + Non hai abbastanza saldo per il deposito del proxy di %s. Saldo disponibile: %s + Canale di Notifica Staking + Collatore + Il deposito minimo del collator è superiore alla tua delega. Non riceverai ricompense dal collator. + Informazioni sul collator + Deposito proprio del collator + Collator: %s + Uno o più dei tuoi collatori sono stati eletti dalla rete. + Delegatori + Hai raggiunto il numero massimo di deleghe di %d collatori + Non puoi selezionare un nuovo collator + Nuovo collator + in attesa del prossimo round (%s) + Hai richieste di rimozione pendenti per tutti i tuoi collatori. + Nessun collator disponibile per la rimozione + I token restituiti saranno conteggiati dal prossimo round + I token in stake producono ricompense ad ogni round (%s) + Seleziona collator + Seleziona collator... + Otterrai ricompense maggiori a partire dal prossimo round + Non riceverai ricompense per questo round poiché nessuna delle tue deleghe è attiva. + Stai già rimuovendo i token da questo collator. Puoi avere solo una rimozione pendente per collator + Non puoi staccarti da questo collatore + Il tuo stacco deve essere maggiore del minimo stacco (%s) per questo collatore. + Non riceverai ricompense + Alcuni dei tuoi collatori non sono stati eletti o hanno uno stacco minimo maggiore rispetto all\'importo staccato da te. Non riceverai ricompensa in questo round staccando con loro. + I tuoi collatori + Il tuo stacco è assegnato ai prossimi collatori + Collatori attivi senza produrre ricompense + Collatori senza abbastanza stacco per essere eletti + Collatori che entreranno in azione nel prossimo round + In sospeso (%s) + Pagamento + Pagamento scaduto + + %d giorno rimasto + %d giorni rimasti + + Puoi pagare da solo, quando sono vicini alla scadenza, ma pagherai la commissione + Le ricompense vengono pagate ogni 2-3 giorni dai validatori + Tutto + Sempre + La data di fine è sempre oggi + Periodo personalizzato + %dD + Seleziona la data di fine + Finisce + Ultimi 6 mesi (6M) + 6M + Ultimi 30 giorni (30D) + 30D + Ultimi 3 mesi (3M) + 3M + Seleziona la data + Seleziona la data di inizio + Inizia + Mostra ricompense di staking per + Ultimi 7 giorni (7D) + 7D + Ultimo anno (1Y) + 1Y + Il tuo saldo disponibile è %s, devi lasciare %s come saldo minimo e pagare una commissione di rete di %s. Puoi fare staking non più di %s. + Autorità delegate (proxy) + Slot attuale nella coda + Nuovo slot nella coda + Ritorna a stake + Tutto il ritiro + I token restituiti saranno conteggiati dalla prossima era + Importo personalizzato + L\'importo che vuoi ritornare allo staking è maggiore del saldo di staking + Ultimo staking ritirato + Più redditizio + Non sovrasottoscritto + Con identità onchain + Non penalizzato + Limite di 2 validatori per identità + con almeno un contatto di identità + Validatori consigliati + Validatori + Rendimento stimato (APY) + Riscatta + Riscattabile: %s + Ricompensa + Destinazione delle ricompense + Ricompense trasferibili + Era + Dettagli della ricompensa + Validatore + Guadagni con reinvestimento + Guadagni senza reinvestimento + Perfetto! Tutte le ricompense sono state pagate. + Fantastico! Non hai ricompense non pagate + Paga tutto (%s) + Ricompense in sospeso + Ricompense non pagate + %s ricompense + Informazioni sulle ricompense + Ricompense (APY) + Destinazione ricompense + Seleziona da solo + Seleziona l\'account di pagamento + Seleziona consigliato + selezionati %d (max %d) + Validatori (%d) + Aggiorna Controller a Stash + Utilizzare i Proxies per delegare le operazioni di Staking a un altro account + I Controller degli Account stanno diventando obsoleti + Seleziona un altro account come controller per delegare le operazioni di gestione dello staking ad esso + Migliorare la sicurezza dello staking + Imposta i validatori + I validatori non sono selezionati + Seleziona i validatori per iniziare lo staking + Il numero minimo consigliato per ricevere costantemente ricompense è %s. + Non puoi fare staking con un importo inferiore al valore minimo di rete (%s) + Il deposito minimo deve essere maggiore di %s + Ri-staking + Ri-staking ricompense + Come utilizzare le tue ricompense? + Seleziona il tipo di ricompensa + Account di pagamento delle ricompense + Taglio + Stake %s + Stake massimo + Periodo di staking + Tipo di staking + Dovresti affidarti ai tuoi nominati per agire con competenza e onestà, basando la tua decisione esclusivamente sulla loro attuale redditività potrebbe portare a una riduzione dei profitti o addirittura alla perdita di fondi. + Scegli attentamente i tuoi validatori, in quanto dovrebbero agire in modo competente e onesto. Basare la tua decisione esclusivamente sulla redditività potrebbe portare a ricompense ridotte o addirittura alla perdita dello staking + Stake con i tuoi validatori + Pezkuwi Wallet selezionerà i top validatori in base ai criteri di sicurezza e redditività + Stake con validatori raccomandati + Inizia lo staking + Stash + Lo stash può collegare di più e impostare il controller. + Stash viene utilizzato per: fare staking in modo più ampio e impostare il controller + L\'account stash %s non è disponibile per aggiornare l\'impostazione dello staking. + Il nominatore guadagna reddito passivo bloccando i suoi token per garantire la sicurezza della rete. Per fare ciò, il nominatore deve selezionare un numero di validatori da supportare. Il nominatore deve fare attenzione nella selezione dei validatori. Se il validatore selezionato non si comporterà correttamente, verranno applicate penalità di taglio ad entrambi, in base alla gravità dell\'incidente. + Pezkuwi Wallet fornisce supporto ai nominatori aiutandoli a selezionare i validatori. L\'applicazione mobile recupera i dati dalla blockchain e compone una lista di validatori che hanno: maggiori profitti, identità con informazioni di contatto, non tagliati e disponibili a ricevere nomination. Pezkuwi Wallet si preoccupa anche della decentralizzazione, quindi se una persona o un\'azienda gestisce diversi nodi validatori, solo fino a 2 nodi verranno mostrati nella lista consigliata. + Chi è un nominatore? + Le ricompense per lo staking sono disponibili per il pagamento alla fine di ogni era (6 ore in Kusama e 24 ore in Polkadot). La rete conserva le ricompense in sospeso durante 84 ere e nella maggior parte dei casi i validatori pagano le ricompense per tutti. Tuttavia, i validatori potrebbero dimenticarsi o potrebbe accadergli qualcosa, quindi i nominati possono incassare le loro ricompense da soli. + Anche se le ricompense sono di solito distribuite dai validatori, Pezkuwi Wallet aiuta avvertendo se ci sono ricompense non pagate che stanno per scadere. Riceverai avvisi su questo e su altre attività nella schermata dello staking. + Ricezione delle ricompense + Lo staking è un\'opzione per guadagnare reddito passivo bloccando i tuoi token nella rete. Le ricompense per lo staking vengono allocate ad ogni era (6 ore su Kusama e 24 ore su Polkadot). Puoi fare staking per tutto il tempo che desideri e per fare il unstaking dei tuoi token devi aspettare che il periodo di unstaking finisca, rendendo i tuoi token disponibili per il riscatto. + Lo staking è una parte importante della sicurezza e della affidabilità della rete. Chiunque può eseguire dei nodi validatori, ma solo coloro che hanno abbastanza token bloccati saranno eletti dalla rete per partecipare alla composizione di nuovi blocchi e ricevere le ricompense. I validatori spesso non possiedono abbastanza token da soli, perciò i nominatori li aiutano bloccando i loro token per consentire loro di raggiungere la quantità di staked richiesta. + Che cos\'è lo staking? + Il validatore gestisce un nodo blockchain 24/7 ed è tenuto a bloccare abbastanza stake (posseduto sia da lui che fornito dai nominatori) per essere eletto dalla rete. I validatori dovrebbero mantenere le prestazioni e l\'affidabilità dei loro nodi per essere ricompensati. Essere un validatore è quasi un lavoro a tempo pieno, ci sono aziende specializzate nel essere validatori nelle reti blockchain. + Tutti possono essere validatori e gestire un nodo blockchain, ma ciò richiede un certo livello di competenze tecniche e responsabilità. Le reti Polkadot e Kusama dispongono di un programma, chiamato Thousand Validators Programme, per fornire supporto ai principianti. Inoltre, la stessa rete premierà sempre più validatori con meno giacenza (ma sufficiente per essere eletti) per migliorare la decentralizzazione. + Chi è un validatore? + Passa il tuo account a stash per impostare il controller. + Staking + %s staking + Ricompensato + Totale staked + Soglia Boost + Per il mio collator + senza Yield Boost + con Yield Boost + per scommettere automaticamente %s tutti i miei token trasferibili sopra + per scommettere automaticamente %s (prima: %s) tutti i miei token trasferibili sopra + Voglio scommettere + Yield Boost + Tipo di staking + Stai ritirando tutti i tuoi token e non puoi scommetterne di più. + Impossibile scommettere di più + Quando esegui il disimpegno parziale, dovresti lasciare almeno %s in gioco. Vuoi eseguire il disimpegno completo disimpegnando anche i restanti %s? + Resta una quantità troppo piccola in gioco + L\'importo che vuoi disimpegnare è maggiore del saldo staked + Disimpegnare + Le transazioni di disimpegno appariranno qui + Le transazioni di disimpegno verranno visualizzate qui + Disimpegno: %s + I tuoi token saranno disponibili per il riscatto dopo il periodo di disimpegno + Hai raggiunto il limite delle richieste di disimpegno (%d richieste attive) + Limite delle richieste di disimpegno raggiunto + Periodo di disimpegno + Disimpegnare tutto + Disimpegnare tutto? + Rendimento stimato (%% APY) + Rendimento stimato + Informazioni sul validatore + Sovrabbondato. Non riceverai premi dal validatore in questa era + Nominatori + Sovrabbondato. Solo i nominatori con il massimo staked ricevono i premi + Proprio + Nessun risultato di ricerca.\nAssicurati di aver digitato l\'indirizzo completo dell\'account + Il validatore è stato punito per comportamenti scorretti (ad ecempio, va offline, attacca la rete, o esegue software modificati) nella rete. + Stake totale + Stake totale (%s) + La ricompensa è inferiore alla tassa di rete. + Annuale + Il tuo stake è assegnato ai seguenti validatori. + Il tuo stake è assegnato ai successivi validatori + Eletto (%s) + I validatori che non sono stati eletti in questa era. + Validatori senza abbastanza stake per essere eletti + Altri, che sono attivi senza la tua assegnazione di stake. + Validatori attivi senza la tua assegnazione di stake + Non eletti (%s) + I tuoi token sono assegnati ai validatori sovrasottoscritti. Non riceverai ricompense in questa era. + Ricompense + Il tuo stake + I tuoi validatori + I tuoi validatori cambieranno nella prossima era. + Ora procediamo con il backup del tuo wallet. Questo garantisce che i tuoi fondi siano sicuri e protetti. I backup ti permettono di ripristinare il tuo wallet in qualsiasi momento. + Continua con Google + Inserisci il nome del wallet + Il mio nuovo wallet + Continua con il backup manuale + Dai un nome al tuo wallet + Questo sarà visibile solo a te e potrai modificarlo in seguito. + Il tuo wallet è pronto + Bluetooth + USB + Hai bloccato token sul tuo saldo a causa di %s. Per continuare, dovresti inserire meno di %s o più di %s. Per bloccare un\'altra quantità, dovresti rimuovere i tuoi blocchi %s. + Non puoi bloccare l\'importo specificato + Selezionato: %d (massimo %d) + Saldo disponibile: %1$s (%2$s) + %s con i tuoi token bloccati in staking + Partecipa alla governance + Blocca oltre %1$s e %2$s con i tuoi token bloccati in staking + partecipa alla governance + Blocca in qualsiasi momento con almeno %1$s. Il tuo blocco guadagnerà attivamente ricompense %2$s + in %s + Blocca in qualsiasi momento. Il tuo blocco guadagnerà attivamente ricompense %s + Scopri maggiori informazioni su\n%1$s bloccando su %2$s + Pezkuwi Wiki + Le ricompense si accumulano %1$s. Blocca oltre %2$s per il pagamento automatico delle ricompense, altrimenti devi richiedere manualmente le ricompense + ogni %s + Le ricompense si accumulano %s + Le ricompense si accumulano %s. Devi richiedere manualmente le ricompense + Le ricompense si accumulano %s e vengono aggiunte al saldo trasferibile + Le ricompense si accumulano %s e vengono reinvestite nello stake + I premi e lo stato dello staking variano nel tempo. %s di tanto in tanto + Monitora il tuo stake + Inizia lo staking + Vedi %s + Termini di Utilizzo + %1$s è una %2$s con %3$s + nessun valore di token + rete di test + %1$s\nai tuoi token %2$s\nall\'anno + Guadagna fino a %s + Ritira in qualsiasi momento e riscatta i tuoi fondi %s. Nessuna ricompensa viene guadagnata durante il ritiro + dopo %s + Il pool che hai selezionato è inattivo a causa della mancata selezione dei validatori o del suo stake inferiore al minimo.\nSei sicuro di voler procedere con il Pool selezionato? + Il numero massimo di nominatori è stato raggiunto. Riprova più tardi + %s non è attualmente disponibile + Validatori: %d (massimo %d) + Modificato + Nuovo + Rimosso + Token per pagare la commissione di rete + La commissione di rete viene aggiunta alla somma inserita + Simulazione del passaggio di swap fallita + Questa coppia non è supportata + Fallito nell\'operazione #%s (%s) + Scambio da %s a %s su %s + Trasferimento di %s da %s a %s + + %s operazione + %s operazioni + + %s di %s operazioni + Scambiando %s con %s su %s + Trasferimento di %s a %s + Tempo di esecuzione + Dovresti mantenere almeno %s dopo aver pagato una commissione di rete di %s, in quanto detieni token non sufficienti + Devi mantenere almeno %s per ricevere il token %s + Devi avere almeno %s su %s per ricevere %s token + Puoi scambiare fino a %1$s poiché devi pagare %2$s per la commissione di rete. + È possibile scambiare fino a %1$s poiché è necessario pagare %2$s per la commissione di rete e convertire anche %3$s in %4$s per soddisfare il saldo minimo di %5$s. + Scambia massimo + Scambia minimo + Dovresti lasciare almeno %1$s sul tuo saldo. Vuoi eseguire lo scambio completo aggiungendo anche i restanti %2$s? + Sul tuo saldo rimane una somma troppo piccola + Dovresti mantenere almeno %1$s dopo aver pagato %2$s per la commissione di rete e convertire %3$s in %4$s per soddisfare il saldo minimo di %5$s.\n\nVuoi effettuare lo scambio completo aggiungendo anche i restanti %6$s? + Paga + Ricevi + Seleziona un token + Non ci sono abbastanza token da scambiare + Liquidità non sufficiente + Non puoi ricevere meno di %s + Acquista immediatamente %s con carta di credito + Trasferisci %s da un\'altra rete + Ricevi %s con QR o il tuo indirizzo + Ottieni %s usando + Durante l\'esecuzione dello swap, l\'importo intermediario ricevuto è %s, che è inferiore al saldo minimo di %s. Prova a specificare un importo di scambio maggiore. + Lo slippage deve essere specificato tra %s e %s + Slippage non valido + Seleziona un token per pagare + Seleziona un token da ricevere + Inserisci l\'importo + Inserisci un altro importo + Per pagare la tariffa di rete con %s, Pezkuwi effettuerà automaticamente lo scambio di %s per %s per mantenere il saldo minimo del tuo account di %s. + Una tariffa di rete addebitata dal blockchain per elaborare e convalidare qualsiasi transazione. Può variare a seconda delle condizioni di rete o della velocità delle transazioni. + Seleziona la rete per scambiare %s + Il pool non ha abbastanza liquidità per lo scambio + La differenza di prezzo si riferisce alla differenza di prezzo tra due diversi asset. Quando si effettua uno scambio in criptovaluta, la differenza di prezzo è di solito la differenza tra il prezzo dell\'asset che stai scambiando e il prezzo dell\'asset con cui stai scambiando. + Differenza di prezzo + %s \u2248 %s + Tasso di cambio tra due diverse criptovalute. Rappresenta quanto di una criptovaluta puoi ottenere in cambio di una certa quantità di un\'altra criptovaluta. + Tasso di cambio + Il vecchio tasso: %1$s ≈ %2$s.\nNuovo tasso: %1$s ≈ %3$s + Il tasso di cambio è stato aggiornato + Ripeti l\'operazione + Percorso + Il percorso che il tuo token seguirà attraverso diverse reti per ottenere il token desiderato. + Scambio + Trasferimento + Impostazioni di scambio + Scivolamento + Il cambio di scivolamento è un evento comune nel trading decentralizzato in cui il prezzo finale di una transazione di scambio potrebbe differire leggermente dal prezzo atteso, a causa delle mutevoli condizioni di mercato. + Inserisci un altro valore + Inserisci un valore compreso tra %s e %s + Scivolamento + La transazione potrebbe essere front-runner a causa di un elevato scivolamento. + La transazione potrebbe essere annullata a causa di una bassa tolleranza per lo scivolamento. + La quantità di %s è inferiore al saldo minimo di %s + Stai cercando di scambiare una quantità troppo piccola + Astenuto: %s + Sì: %s + Puoi sempre votare per questo referendum in seguito + Rimuovere il referendum %s dalla lista di voto? + Alcuni dei referendum non sono più disponibili per il voto o potresti non avere abbastanza token disponibili per votare. Disponibile per votare: %s. + Alcuni dei referendum esclusi dalla lista di voto + I dati del referendum non sono stati caricati + Nessun dato recuperato + Hai già votato per tutti i referendum disponibili o non ci sono referendum su cui votare in questo momento. Torna più tardi. + Hai già votato per tutti i referendum disponibili + Richiesto: + Lista di voto + %d rimanenti + Conferma voti + Nessun referendum su cui votare + Conferma i tuoi voti + Nessun voto + Hai votato con successo per %d referendum + Non hai abbastanza saldo per votare con la potenza di voto corrente %s (%sx). Si prega di cambiare la potenza di voto o aggiungere più fondi al tuo portafoglio. + Saldo insufficiente per votare + No: %s + SwipeGov + Vota per %d referendum + Il voto sarà impostato per i voti futuri in SwipeGov + Potenza di voto + Staking + Portafoglio + Oggi + Collegamento Coingecko per informazioni sul prezzo (opzionale) + Seleziona un fornitore per acquistare il token %s + Metodi di pagamento, commissioni e limiti variano a seconda del fornitore.\nConfronta le loro offerte per trovare l\'opzione migliore per te. + Seleziona un fornitore per vendere il token %s + Per continuare l\'acquisto sarai reindirizzato dall\'app Pezkuwi Wallet a %s + Continuare nel browser? + Nessuno dei nostri fornitori supporta attualmente l\'acquisto o la vendita di questo token. Scegli un token diverso, una rete diversa o riprova più tardi. + Questo token non è supportato dalla funzionalità di acquisto/vendita + Copia hash + Commissione + Da + Hash estrinseco + Dettagli transazione + Visualizza in %s + Visualizza in Polkascan + Visualizza in Subscan + %s alle %s + Il tuo precedente storico delle transazioni %s è ancora disponibile su %s + Completato + Fallito + In sospeso + Canale di Notifica Transazioni + Acquista criptovaluta a partire da soli $5 + Vendi criptovaluta a partire da soli $10 + Da: %s + A: %s + Trasferimento + Le operazioni in entrata e in uscita\nappariranno qui + Le tue operazioni verranno visualizzate qui + Rimuovi i voti per delegare in queste tracce + Tracce a cui hai già delegato i voti + Tracce non disponibili + Tracce in cui hai già dei voti + Non mostrare più.\nPuoi trovare l\'indirizzo legacy in Ricevi. + Formato legacy + Nuovo formato + Alcuni exchange possono ancora richiedere il formato legacy\nper operazioni mentre sono in aggiornamento. + Nuovo Indirizzo Unificato + Installa + Versione %s + Aggiornamento disponibile + Per evitare eventuali problemi e migliorare la tua esperienza utente, ti consigliamo vivamente di installare gli aggiornamenti più recenti il prima possibile + Aggiornamento critico + Più recente + Molte nuove incredibili funzionalità sono disponibili per Pezkuwi Wallet! Assicurati di aggiornare l\'applicazione per accedervi + Aggiornamento importante + Critica + Importante + Visualizza tutti gli aggiornamenti disponibili + Nome + Nome del portafoglio + Questo nome sarà visualizzato solo per te e memorizzato localmente sul tuo dispositivo mobile. + Questo account non è stato eletto dalla rete per partecipare all\'era attuale + Rivotare + Votare + Stato del voto + La tua carta sta ricevendo fondi! + La tua carta è in fase di emissione! + Può richiedere fino a 5 minuti.\nQuesta finestra si chiuderà automaticamente. + Stimato %s + Acquista + Acquista/Vendi + Acquista token + Acquista con + Ricevi + Ricevi %s + Invia solo token %1$s e token nella rete %2$s a questo indirizzo, altrimenti potresti perdere i tuoi fondi + Vendi + Vendi token + Invia + Scambia + Asset + I tuoi asset appariranno qui.\nAssicurati che il filtro \"Nascondi saldi a zero\"\nsia disattivato + Valore degli asset + Disponibile + Staked + Dettagli del saldo + Saldo totale + Totale dopo il trasferimento + Congelato + Bloccato + Riscattabile + Prenotato + Trasferibile + Sblocco in corso + Portafoglio + Nuova connessione + + %s account mancante. Aggiungi l\'account al portafoglio nelle impostazioni + %s account mancanti. Aggiungi gli account al portafoglio nelle impostazioni + + Alcune reti richieste da \"%s\" non sono supportate in Pezkuwi Wallet + Le sessioni Wallet Connect appariranno qui + WalletConnect + dApp sconosciuta + + %s rete non supportata è nascosta + %s reti non supportate sono nascoste + + WalletConnect v2 + Trasferimento tra catene + Criptovalute + Valute fiat + Valute fiat popolari + Valuta + Dettagli transazione + Nascondi asset con saldo zero + Altre transazioni + Mostra + Ricompense e penalità + Scambi + Filtri + Trasferimenti + Gestione asset + Come aggiungere un wallet? + Come aggiungere un wallet? + Come aggiungere un wallet? + Esempi di nomi: Account principale, Il mio convalidatore, Crowdloan di Dotsama, ecc. + Condividi questo QR con il mittente + Fai scansionare questo QR code al mittente + Il mio indirizzo %s per ricevere %s: + Condividi codice QR + Destinatario + Assicurati che l\'indirizzo sia della rete giusta + Il formato dell\'indirizzo non è valido. Assicurati che l\'indirizzo corrisponda alla rete giusta + Saldo minimo + A causa delle restrizioni cross-chain, puoi trasferire non più di %s + Non hai abbastanza saldo per pagare la commissione cross-chain di %s. Saldo rimanente dopo il trasferimento: %s + La commissione cross-chain è aggiunta all\'importo inserito. Il destinatario potrebbe ricevere una parte della commissione cross-chain + Conferma trasferimento + Cross-chain + Commissione cross-chain + Il tuo trasferimento fallirà in quanto il conto di destinazione non ha abbastanza %s per accettare altri trasferimenti di token + Il destinatario non è in grado di accettare il trasferimento + Il tuo trasferimento fallirà in quanto l\'importo finale sul conto di destinazione sarà inferiore al saldo minimo. Per favore, cerca di aumentare l\'importo. + Il tuo trasferimento rimuoverà l\'account dal blockstore poiché renderà il saldo complessivo inferiore al saldo minimo. + Il tuo account verrà rimosso dalla blockchain dopo il trasferimento perché il saldo totale diventa inferiore al minimo + Il trasferimento rimuoverà l\'account + Il tuo account verrà rimosso dalla blockchain dopo il trasferimento perché il saldo totale diventa inferiore al minimo. Il saldo rimanente verrà trasferito anche al destinatario. + Dalla rete + Devi avere almeno %s per pagare questa tassa di transazione e rimanere al di sopra del saldo minimo di rete. Il tuo saldo attuale è: %s. Devi aggiungere %s al tuo saldo per effettuare questa operazione. + Me stesso + In catena + L\'indirizzo seguente: %s è noto per essere utilizzato in attività di phishing, pertanto non raccomandiamo di inviare token a quell\'indirizzo. Desideri procedere comunque? + Avviso frode + Il destinatario è stato bloccato dal proprietario del token e al momento non può accettare trasferimenti in entrata + Il destinatario non può accettare il trasferimento + Rete del destinatario + Alla rete + Invia %s da + Invia %s su + a + Mittente + Token + Invia a questo contatto + Dettagli del trasferimento + %s (%s) + %s indirizzi per %s + Pezkuwi ha rilevato problemi con l\'integrità delle informazioni sugli indirizzi %1$s. Si prega di contattare il proprietario di %1$s per risolvere i problemi di integrità. + Controllo dell\'integrità non riuscito + Destinatario non valido + Nessun indirizzo valido è stato trovato per %s sulla rete %s + %s non trovato + I servizi w3n di %1$s non sono disponibili. Riprova più tardi o inserisci manualmente l\'indirizzo %1$s + Errore nella risoluzione di w3n + Pezkuwi non può risolvere il codice per il token %s + Il token %s non è ancora supportato + Ieri + Yield Boost verrà disattivato per i collatori attuali. Nuovo collatore: %s + Cambiare il collatore con Yield Boost? + Non hai abbastanza saldo per pagare la commissione di rete di %s e la commissione di esecuzione di Yield boost di %s.\nSaldo disponibile per pagare la commissione: %s + Non abbastanza token per pagare la prima commissione di esecuzione + Non hai abbastanza saldo per pagare la commissione di rete di %s e non scendere al di sotto della soglia %s.\nSaldo disponibile per pagare la commissione: %s + Non abbastanza token per rimanere al di sopra della soglia + Tempo di aumento dello stake + Yield Boost stakerà automaticamente %s tutti i miei token trasferibili oltre %s + Yield Boosted + + + Ponte DOT ↔ HEZ + Ponte DOT ↔ HEZ + Invii + Ricevi (stimato) + Tasso di cambio + Commissione ponte + Minimo + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Scambia + Gli scambi HEZ→DOT vengono elaborati quando la liquidità DOT è sufficiente. + Saldo insufficiente + Importo inferiore al minimo + Inserisci importo + Gli scambi HEZ→DOT potrebbero avere disponibilità limitata in base alla liquidità. + Gli scambi HEZ→DOT sono temporaneamente non disponibili. Riprova quando la liquidità DOT sarà sufficiente. + diff --git a/common/src/main/res/values-ja/strings.xml b/common/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..e2710f1 --- /dev/null +++ b/common/src/main/res/values-ja/strings.xml @@ -0,0 +1,2033 @@ + + + お問い合わせ + Github + プライバシーポリシー + 評価する + Telegram + 利用規約 + 利用規約 + アプリについて + アプリバージョン + ウェブサイト + 有効な%sアドレスを入力してください... + Evmアドレスは有効であるか、空でなければなりません... + 有効なsubstrateアドレスを入力してください... + アカウントを追加 + アドレスを追加 + アカウントは既に存在します。別のものをお試しください。 + アドレスで任意のウォレットを追跡 + ウォッチオンリーウォレットを追加 + パスフレーズを書き留めてください + フレーズが正確かつ判読可能に書かれていることを確認してください。 + %s アドレス + アカウントが%sにありません + ニーモニックを確認 + ダブルチェックしましょう + 正しい順序で言葉を選んでください + 新しいアカウントを作成 + クリップボードやスクリーンショットを使用せず、モバイルデバイスでより安全なバックアップ方法(例:紙)を見つけてください。 + 名前はこのアプリケーション内でのみ使用されます。後で編集できます + ウォレット名を作成 + バックアップニーモニック + 新しいウォレットを作成 + ニーモニックはアカウントにアクセスするために使用されます。書き留めてください、それがないとアカウントを回復できません! + 変更されたシークレットを持つアカウント + 忘れる + 続行する前に、ウォレットをエクスポートしたことを確認してください。 + ウォレットを忘れますか? + 無効なEthereum導出パス + 無効なSubstrate導出パス + このウォレットは%1$sとペアリングされています。Pezkuwiは任意の操作を形成するのを手助けし、%1$sを使用してそれらをサインするよう求められます + %sにサポートされていません + これはウォッチオンリーウォレットです。Pezkuwiはあなたに残高やその他の情報を表示できますが、このウォレットでトランザクションを行うことはできません + ウォレットのニックネームを入力してください... + 別のものを試してください。 + Ethereumキーの暗号タイプ + Ethereum 秘密派生パス + EVMアカウント + アカウントをエクスポート + エクスポート + 既存のアカウントをインポート + このパスワードはアカウントを暗号化するために必要であり、このJSONファイルと共にウォレットを復元するために使用されます。 + JSONファイル用のパスワードを設定 + 秘密を保存し、安全な場所に保管してください + 秘密を書き留め、安全な場所に保管してください + 無効な復元jsonです。有効なjson形式であることを確認してください。 + Seedが無効です。入力が64進数のシンボルを含むことを確認してください。 + JSONにはネットワーク情報が含まれていません。以下に指定してください。 + 復元JSONを提供してください + 通常は12の単語(場合によっては15、18、21、または24) + 単語はスペースで区切り、カンマやその他の記号は使わないでください + 単語を正しい順序で入力 + パスワード + 秘密鍵をインポート + 0xAB + 生のSeedを入力 + Pezkuwiは全てのアプリに対応しています + ウォレットをインポート + アカウント + 導出パスにはサポートされていない記号が含まれているか、構造が正しくありません + 無効な導出パス + JSONファイル + Ledger Liveアプリを使用してLedgerデバイスに%sしてください + Polkadotアプリがインストールされています + Ledger デバイスで%s + ポルカドットアプリを開く + フレックス、スタックス、ナノ X、ナノ S プラス + Ledger (一般的な Polkadot アプリ) + Ledger Liveアプリを使用してネットワークアプリをLedgerデバイスにインストールしてください。Ledgerデバイスでネットワークアプリを開いてください。 + 少なくとも1つのアカウントを追加してください + ウォレットにアカウントを追加 + Ledger接続ガイド + Ledger Liveアプリを使ってLedgerデバイスに%sを確実にしてください + ネットワークアプリがインストールされている + あなたのLedgerデバイス上の%s + ネットワークアプリを開く + Pezkuwi Walletに%sしてください + Bluetoothへのアクセス + %s を選択する。 + アカウントを選択 + %s を電話の設定で + OTGを有効にする + Ledgerを接続 + 操作にサインしてアカウントを新しいGeneric Ledgerアプリに移行するには、Migrationアプリをインストールして開いてください。Legacy OldおよびMigration Ledgerアプリは将来サポートされなくなります。 + 新しいLedgerアプリがリリースされました + Polkadot Migration + Migrationアプリは近い将来使用できなくなります。新しいLedgerアプリにアカウントを移行して資金を失わないようにしてください。 + Polkadot + Ledger Nano X + レジャー・レガシー + LedgerをBluetoothで使用する場合は、両方のデバイスでBluetoothを有効にし、PezkuwiにBluetoothおよび位置情報の権限を付与してください。USBの場合は、電話の設定でOTGを有効にしてください。 + 電話の設定とLedgerデバイスでBluetoothを有効にしてください。Ledgerデバイスを解除し、%sアプリを開いてください。 + Ledgerデバイスを選択 + Ledgerデバイスを有効にしてください。Ledgerデバイスをアンロックし、%s アプリを開いてください。 + もう少しです!🎉\n 設定を完了して、Polkadot AppとPezkuwi Walletの両方でアカウントをスムーズに使用するには、以下をタップしてください。 + Pezkuwiへようこそ! + 12, 15, 18, 21または24単語のフレーズ + マルチシグ + 共有コントロール(マルチシグ) + このネットワークにはアカウントがありません。アカウントを作成するか、インポートすることができます。 + アカウントが必要です + アカウントが見つかりません + 公開鍵をペアリング + Parity Signer + %sが%sをサポートしていません + 次のアカウントが%sから正常に読み取られました + これがあなたのアカウントです + 無効なQRコードです。%sのQRコードをスキャンしていることを確認してください + 一番上を選択していることを確認してください + スマートフォンの%sアプリケーション + Parity Signer を開く + %sPezkuwi Walletに追加したい + キータブに行く。シードを選択し、次にアカウントを選択 + Parity Signerはあなたに%sを提供します + スキャン用のQRコード + %sからウォレットを追加 + %sは任意のメッセージの署名をサポートしていません — トランザクションのみをサポートします + 署名はサポートされていません + %sからQRコードをスキャン + レガシー + 新しい (Vault v7+) + %sにエラーがあります + QRコードの有効期限が切れました + セキュリティ上の理由から、生成された操作は%s間のみ有効です。\n新しいQRコードを生成し、%sで署名してください + QRコードは%s間有効です + 現在署名している操作のQRコードをスキャンしていることを確認してください + %sで署名 + Polkadot Vault + 派生パス名が空であることに注意してください + スマートフォンの%sアプリケーション + Polkadot Vault を開く + %s をPezkuwi Walletに追加しますか + 派生キーをタップ + Polkadot Vaultが%s + スキャン用のQRコード + 右上のアイコンをタップして%sを選択 + 秘密鍵をエクスポート + プライベートキー + プロキシ + 委任された(プロキシ) + 任意の操作 + オークション + プロキシのキャンセル + ガバナンス + 身分判断 + ノミネーションプール + 非転送 + Stake + すでにアカウントを持っています + シークレット + 64の16進数記号 + ハードウェアウォレットを選択 + 秘密のタイプを選択 + ウォレットを選択 + 共有された秘密を持つアカウント + Substrateアカウント + Substrateキーの暗号タイプ + Substrate秘密の導出パス + ウォレット名 + ウォレットのニックネーム + Moonbeam、Moonriverおよびその他のネットワーク + EVMアドレス(任意) + プリセットウォレット + Polkadot、Kusama、Karura、KILTおよび50+のネットワーク + 👀 プライベートキーをPezkuwi Walletに入力せずに任意のウォレットの活動を追跡 + あなたのウォレットは閲覧専用であり、操作を行うことはできません + おっと! キーが見つかりません + 閲覧専用 + Polkadot Vault、Parity SignerまたはLedgerを使用 + ハードウェアウォレット + PezkuwiでTrust Walletアカウントを使用する + Trust Wallet + %s アカウントを追加 + ウォレットを追加 + %s アカウントを変更 + アカウントを変更 + Ledger(Legacy) + あなたに委任されています + 共有コントロール + カスタムノードを追加 + 委任するためにはウォレットに%sアカウントを追加する必要があります + ネットワークの詳細を入力してください + 委任先 + 委任アカウント + 委任ウォレット + アクセス種類を付与 + デポジットはプロキシが削除されるまであなたのアカウントに予約されたままになります。 + %sで追加できるプロキシの上限に達しました。新しいものを追加するためには既存のプロキシを削除してください。 + プロキシの最大数に達しました + 追加されたカスタムネットワーク\nここに表示されます + +%d + ノヴァは自動的にあなたのマルチシグウォレットに切り替え、保留中のトランザクションを確認できるようにしました。 + カラー + 外観 + トークンアイコン + ホワイト + 入力されたコントラクトアドレスは%sトークンとしてPezkuwiに存在します。 + 入力されたコントラクトアドレスは%sトークンとしてPezkuwiに存在します。本当にそれを変更しますか? + このトークンはすでに存在します + 指定されたURLが次の形式であることを確認してください: www.coingecko.com/en/coins/tether. + 無効なCoinGeckoリンク + 入力されたコントラクトアドレスは%s ERC-20コントラクトではありません。 + 無効なコントラクトアドレス + 小数桁数は0以上、36以下でなければなりません。 + 無効な小数値 + コントラクトアドレスを入力 + 小数を入力 + シンボルを入力 + %sに移動 + %1$sより、あなたの%2$s の残高、Staking、およびガバナンスは%3$sで利用可能になります — 性能向上とコスト削減を実現します。 + あなたの%s トークンが%sで利用可能です + ネットワーク + トークン + トークンを追加 + コントラクトアドレス + 小数 + シンボル + ERC-20トークンの詳細を入力 + ERC-20トークンを追加するネットワークを選択 + クラウドローン + Governance v1 + OpenGov + 選挙 + Staking + ベスティング + トークンを購入 + クラウドローンからDOTを受け取りましたか? DOTを今日からステークして最大限の報酬を得ましょう! + DOTをブースト 🚀 + トークンをフィルター + 贈り物にできるトークンがありません。\nアカウントにトークンを購入または入金してください。 + すべてのネットワーク + トークンを管理 + %sをLedgerで管理されたアカウントに送信しないでください。Ledgerは%sの送信をサポートしていないため、資産がこのアカウントで凍結される可能性があります。 + Ledgerはこのトークンをサポートしていません + ネットワークまたはトークンで検索 + 入力した名前のネットワークやトークンが見つかりませんでした + トークンで検索 + あなたのウォレット + 送信するトークンがありません。\nトークンを購入または受け取ってください\nアカウントに。 + 支払い用トークン + 受け取り用トークン + 変更を進める前に、変更されたウォレットと削除されたウォレットのために、%sを確認してください! + パスフレーズを保存したことを確認してください + バックアップの更新を適用しますか? + ウォレットを保存する準備をしてください! + このパスフレーズは、すべての接続されたウォレットとその中の資金への完全かつ永久的なアクセスを提供します。\n%s + 誰にも共有しないでください。 + いかなるフォームやウェブサイトにもパスフレーズを入力しないでください。\n%s + 資金が永久に失われる可能性があります。 + サポートや管理者がいかなる状況でもあなたのパスフレーズを要求することはありません。\n%s + 詐欺師に注意してください。 + 確認&同意して続行 + バックアップを削除 + このデバイスへのアクセスを失った場合にウォレット資金へのアクセスを確保するため、手動でパスフレーズをバックアップできます。 + 手動でバックアップ + 手動 + パスフレーズ付きのウォレットを追加していません。 + バックアップするウォレットがありません + 設定したパスワードで保護されたすべてのウォレットの暗号化コピーを保存するために、Googleドライブのバックアップを有効にできます。 + Googleドライブにバックアップ + Googleドライブ + バックアップ + バックアップ + 簡単で効率的なKYCプロセス + 生体認証 + すべて閉じる + 購入が開始されました! 最大60分お待ちください。メールでステータスを追跡できます。 + 購入するネットワークを選択 %s + 購入が開始されました!60分までお待ちください。ステータスはメールで確認できます。 + 現在、弊社のプロバイダーはこのトークンの購入をサポートしていません。別のトークン、異なるネットワークを選択するか、後ほど再確認してください。 + このトークンは購入機能でサポートされていません + 任意のトークンで手数料を支払う機能 + 移行は自動で行われ、アクションは不要です + 古い取引履歴は%sに残ります + %1$s を使ってあなたの %2$s 残高に基づき、ステーキングとガバナンスは %3$s にあります。これらの機能は最長24時間ご利用いただけない場合があります。 + 取引手数料が%1$sx低下\n(%2$sから%3$sへ) + 最小残高が%1$sx低減\n(%2$sから%3$sへ) + Asset Hubが素晴らしい理由は? + %1$s の開始時点の残高、%2$s の残高、ステーキングとガバナンスは %3$s 上にあります + より多くのトークンがサポートされます:%sと他のエコシステムトークン + %s、アセット、Staking、およびガバナンスへの統合アクセス + 自動バランスノード + 接続を有効にする + クラウドバックアップからウォレットを復元するために使用するパスワードを入力してください。このパスワードは将来的に復元できないため、必ず覚えておいてください! + バックアップパスワードを更新 + アセット + 利用可能な残高 + 申し訳ありませんが、残高確認要求が失敗しました。少し後でもう一度試してください。 + 申し訳ありませんが、転送プロバイダーに接触できませんでした。少し後でもう一度試してください。 + 申し訳ありませんが、指定された金額を使うための資金が不足しています。 + 転送手数料 + ネットワークが応答していません + 宛先 + おっと、贈り物はすでに受け取られています + 贈り物を受け取れません + 贈り物を受け取る + サーバーに問題があるかもしれません。後でもう一度お試しください。 + おっと、何かが間違っています + 別のウォレットを使用するか、新しいアカウントを作成するか、または設定でこのウォレットに%sアカウントを追加してください。 + 贈り物を正常に受け取りました。トークンは間もなくあなたの残高に表示されます。 + 暗号通貨の贈り物を受け取りました! + 贈り物を受け取るために、新しいウォレットを作成するか既存のウォレットをインポートしてください + %sウォレットでは贈り物を受け取ることができません + DAppブラウザーで開いているすべてのタブが閉じられます。 + すべてのDAppを閉じますか? + %s と、いつでも復元できるように必ずオフラインで保持してください。バックアップ設定でこれを行うことができます。 + 進む前にすべてのウォレットのパスフレーズを書き留めてください + Googleドライブからバックアップが削除されます + あなたのウォレットの現在のバックアップは永久に削除されます! + クラウドバックアップを削除してもよろしいですか? + バックアップを削除 + 現在、バックアップは同期されていません。これらの更新を確認してください。 + クラウドバックアップの変更が見つかりました + 更新を確認 + 削除されるウォレットのパスフレーズを手書きで記録していない場合、それらのウォレットとすべての資産は永久に失われ、取り戻せなくなります。 + これらの変更を適用してもよろしいですか? + 問題を確認 + 現在、バックアップは同期されていません。問題を確認してください。 + ウォレットの変更をクラウドバックアップに更新できませんでした + Googleアカウントに正しい資格情報でログインし、Pezkuwi WalletのGoogle Driveへのアクセスを許可していることを確認してください。 + Google Driveの認証に失敗しました + Google Driveの利用可能なストレージが不足しています。 + ストレージが不足しています + 残念ながら、Google PlayサービスがないとGoogle Driveは動作しません。Google Playサービスを取得してください。 + Google Playサービスが見つかりません + ウォレットをGoogle Driveにバックアップできません。Pezkuwi WalletがGoogle Driveを使用する許可があり、利用可能なストレージスペースが十分であることを確認し、再試行してください。 + Google Driveエラー + パスワードが正しいことを確認して、再試行してください。 + パスワードが無効です + 残念ながら、ウォレットを復元するためのバックアップが見つかりませんでした。 + バックアップが見つかりません + 続行する前に、ウォレットのパスフレーズを保存したことを確認してください。 + クラウドバックアップからウォレットが削除されます + バックアップエラーを確認する + バックアップの更新を確認する + バックアップパスワードを入力する + 有効にしてウォレットをGoogleドライブにバックアップしてください + 最終同期: %s で %s + Googleドライブにサインインする + Googleドライブの問題を確認する + バックアップが無効になっています + バックアップが同期されました + バックアップを同期中... + バックアップが同期されていません + 新しいウォレットは自動的にクラウドバックアップに追加されます。設定でクラウドバックアップを無効にすることができます。 + ウォレットの変更がクラウドバックアップに更新されます + 利用規約に同意する... + アカウント + アカウントアドレス + アクティブ + 追加 + 委任を追加 + ネットワークを追加 + 住所 + 高度な + すべて + 許可する + + 量が少なすぎます + 量が大きすぎます + 適用済み + 適用 + もう一度尋ねる + ウォレットの保存の準備をしてください! + 利用可能: %s + 平均 + 残高 + ボーナス + 呼び出し + コールデータ + コールハッシュ + キャンセル + この操作をキャンセルしてもよろしいですか? + 申し訳ありませんが、このリクエストを処理するための適切なアプリがありません + このリンクを開くことができません + ネットワーク手数料に %s を支払わなければならないため、%s まで使用できます。 + チェーン + 変更 + パスワードを変更 + 今後自動的に続行する + ネットワークを選択 + クリア + 閉じる + この画面を閉じてよろしいですか?\n変更は適用されません。 + クラウドバックアップ + 完了 + 完了 (%s) + 確認 + 確認 + 本当に? + 確認済み + %d ミリ秒 + 接続中... + 接続を確認するか、後で再試行してください + 接続失敗 + 続行 + クリップボードにコピーされました + アドレスをコピー + コールデータをコピー + ハッシュをコピー + IDをコピー + キーペア暗号タイプ + 日付 + %s と %s + 削除 + 預金者 + 詳細 + 無効 + 切断する + アプリを閉じないでください! + 完了 + Pezkuwiはエラーを防ぐためにトランザクションを事前にシミュレートします。このシミュレーションは成功しませんでした。後でもう一度試すか、より高い額で試してください。問題が続く場合は、設定でPezkuwiウォレットのサポートに連絡してください。 + トランザクションシミュレーション失敗 + 編集 + %s (さらに %s) + メールアプリを選択 + 有効にする + アドレスを入力… + 金額を入力... + 詳細を入力してください + 別の金額を入力 + パスワードを入力 + エラー + トークンが足りません + イベント + EVM + EVM アドレス + この操作を行うと、アカウントの残高が最小限度を下回るため、ブロックチェーンからアカウントが削除されます。 + 操作によりアカウントが削除されます + 期限切れ + 探索する + 失敗しました + 推定ネットワーク手数料%sがデフォルトのネットワーク手数料(%s)よりも大幅に高くなっています。これは一時的なネットワーク渋滞が原因かもしれません。ネットワーク手数料をリフレッシュして、より低い手数料を待つことができます。 + ネットワーク手数料が高すぎます + 手数料: %s + ソート順: + フィルター + 詳しく知る + パスワードを忘れましたか? + + 毎%s日 + + 毎日 + 毎日 + 詳細 + %sを取得 + 贈り物 + 了解しました + ガバナンス + 16進文字列 + 非表示 + + %d時間 + + 仕組み + 了解しました + 情報 + QRコードが無効です + 無効なQRコード + さらに詳しく + についてもっと知る + Ledger + 残り%s + ウォレットを管理 + 最大 + %s最大 + + %d分 + + %sアカウントがありません + 修正 + モジュール + 名前 + ネットワーク + Ethereum + %sはサポートされていません + Polkadot + ネットワーク + + ネットワーク + + 次へ + いいえ + デバイスにファイルインポートアプリケーションが見つかりません。インストールしてもう一度お試しください。 + このインテントを処理するのに適したアプリがデバイスに見つかりません。 + 変更なし + パスフレーズを表示します。誰も画面を見ていないことを確認し、スクリーンショットを撮らないでください。それは第三者のマルウェアによって収集される可能性があります。 + なし + 利用できません + 申し訳ありませんが、ネットワーク手数料を支払うための十分な資金がありません。 + 残高不足 + ネットワーク手数料%sを支払うには残高が不足しています。現在の残高は%sです + 今はしません + オフ + OK + わかりました、戻る + オン + 進行中 + 任意 + オプションを選択 + パスフレーズ + 貼り付け + / 年 + %s / 年 + 年間 + %% + この画面を使用するには要求された権限が必要です。設定で有効にしてください。 + 権限が拒否されました + この画面を使用するには要求された権限が必要です。 + 権限が必要です + 価格 + プライバシーポリシー + 続行 + プロキシデポジット + アクセスを取り消す + プッシュ通知 + 続きを読む + おすすめ + 手数料を更新 + 拒否 + 削除 + 必須 + リセット + 再試行 + 何かがうまくいきませんでした。もう一度やり直してください。 + 取り消す + 保存 + QRコードをスキャンする + 検索 + 検索結果がここに表示されます + 検索結果:%d + + + %d秒 + + シークレット導出パス + すべて見る + トークンを選択 + 設定 + 共有 + コールデータを共有 + ハッシュを共有 + 表示 + サインイン + 署名のリクエスト + 署名者 + 署名が無効です + スキップ + プロセスをスキップ + いくつかのトランザクションの送信中にエラーが発生しました。再試行しますか? + いくつかのトランザクションの送信に失敗しました + 何かがうまくいきませんでした + 並び替え + ステータス + Substrate + Substrate アドレス + タップして表示 + 利用規約 + Testnet + 残り時間 + タイトル + 設定を開く + 残高が少なすぎます + 合計 + 合計手数料 + 取引ID + 取引が送信されました + もう一度試してください + タイプ + 別の入力で再度試してください。エラーが再発する場合は、サポートに連絡してください。 + 不明 + + %s はサポートされていません + + 無制限 + 更新 + 使用 + 最大を使用 + 受信者は有効な %s アドレスである必要があります + 無効な受信者 + 表示 + 待機中 + ウォレット + 警告 + はい + あなたの贈り物 + 金額は正の数である必要があります + バックアッププロセス中に作成したパスワードを入力してください + 現在のバックアップパスワードを入力してください + 確認すると、トークンがあなたのアカウントから転送されます + 単語を選択してください... + 無効なパスフレーズです。単語の順序をもう一度確認してください。 + 国民投票 + 投票 + トラック + このノードは既に追加されています。別のノードを試してください。 + ノードに接続できません。別のノードを試してください。 + 残念ながら、このネットワークはサポートされていません。以下のいずれかをお試しください: %s。 + %sを削除します。 + ネットワークを削除しますか? + 接続を確認するか、後で再試行してください + カスタム + デフォルト + ネットワーク + 接続を追加 + QRコードをスキャン + バックアップに問題が検出されました。現在のバックアップを削除して、新しいものを作成できます。続行する前に%sとしてください。 + すべてのウォレットのパスフレーズを保存したことを確認してください + バックアップが見つかりましたが、空または破損しています + 将来的には、バックアップパスワードなしではクラウドバックアップからウォレットを復元できません。\n%s + このパスワードは復元できません。 + バックアップパスワードを覚えてください + パスワードを確認 + バックアップパスワード + 文字 + 最小8文字 + 数字 + パスワードが一致 + バックアップにアクセスするためのパスワードを入力してください。このパスワードは復元できないため、必ず覚えておいてください! + バックアップパスワードを作成する + 入力されたチェーンIDはRPC URLのネットワークと一致しません。 + 無効なチェーンID + プライベートクラウドローンはまだサポートされていません。 + プライベートクラウドローン + クラウドローンについて + 直接 + Acalaへのさまざまな貢献について詳しく学ぶ + 液体 + アクティブ (%s) + 利用規約に同意する + Pezkuwi Walletボーナス (%s) + Astar紹介コードは有効なPolkadotアドレスである必要があります + 選択した金額を投資できません。合計額がクラウドローンの上限を超えてしまいます。最大許容投資額は%sです。 + 選択したクラウドローンに投資できません。上限額に達しています。 + クラウドローンの上限を超えました + クラウドローンに貢献する + 貢献 + あなたの貢献: %s + Liquid + Parallel + あなたの貢献\nはこちらに表示されます + %s後に返却 + パラチェーンによって返却されます + %s (経由 %s) + クラウドローン + 特別ボーナスを取得 + クラウドローンはこちらに表示されます + 選択されたクラウドローンは既に終了しているため、貢献できません。 + クラウドローンは終了しました + リファラルコードを入力 + クラウドローン情報 + %sのクラウドローンについて学ぶ + %sのクラウドローンウェブサイト + リース期間 + パラチェーンを選択してあなたの%sを貢献してください。貢献したトークンは返却され、パラチェーンがスロットを獲得すれば、オークション終了後に報酬を受け取ります。 + 貢献するためにウォレットに%sアカウントを追加する必要があります + ボーナスを適用する + 紹介コードがない場合は、Pezkuwiの紹介コードを適用して、貢献に対するボーナスを受け取ることができます + ボーナスが適用されていません + MoonbeamクラウドローンはSR25519またはED25519の暗号タイプアカウントのみをサポートしています。別のアカウントを使用して貢献することを検討してください + このアカウントでは貢献できません + Moonbeamクラウドローンに参加するためにウォレットにMoonbeamアカウントを追加する必要があります + Moonbeamアカウントがありません + このクラウドローンはご利用の地域では利用できません。 + お住まいの地域はサポートされていません + %s 報酬の受取場所 + 同意を提出 + 続行するには、ブロックチェーン上で利用規約と条件に同意する必要があります。これは、すべての後続のMoonbeam貢献に対して一度だけ行う必要があります。 + 利用規約に同意しました + 集められた + 紹介コード + 紹介コードが無効です。別のコードをお試しください。 + %sの利用規約 + 貢献するための最小許容額は%sです。 + 貢献額が小さすぎます + リース期間の終了後にあなたの %s トークンが返還されます。 + あなたの貢献 + 集められた金額: %s の %s + 入力されたRPC URLは%sカスタムネットワークとしてPezkuwiに存在します。本当に修正しますか? + https://networkscan.io + ブロックエクスプローラーURL(オプション) + 012345 + チェーンID + TOKEN + 通貨記号 + ネットワーク名 + ノードを追加 + カスタムノードを追加する + 詳細を入力してください + 保存 + カスタムノードを編集する + 名前 + ノード名 + wss:// + ノードURL + RPC URL + 使用する際にアドレスへのアクセスを許可したDApps + DApp “%s” は認証済みリストから削除されます + 認証済みリストから削除しますか? + 認証済みDApps + カタログ + アプリケーションを信頼する場合はこのリクエストを承認してください + アカウントのアドレスへのアクセスを許可しますか “%s”? + アプリケーションを信頼する場合はこのリクエストを承認してください。\nトランザクションの詳細を確認してください。 + DApp + DApps + %d DApps + お気に入り + お気に入り + お気に入りに追加 + 「%s」DAppはお気に入りから削除されます + お気に入りから削除しますか? + ここにDAppsのリストが表示されます + お気に入りに追加 + デスクトップモード + お気に入りから削除 + ページ設定 + OK、戻る + Pezkuwi Walletはこのウェブサイトがあなたのアカウントとトークンのセキュリティを危険にさらす可能性があると考えています + フィッシング検出 + 名前で検索またはURLを入力 + 生成ハッシュ %s の未対応チェーン + 操作が正しいことを確認してください + 要求された操作の署名に失敗しました + それでも開く + 悪意のあるDAppsはすべての資金を引き出す可能性があります。DAppを使用する前に、許可を与えたり、資金を送金したりする前に、必ず自分で調査してください。\n\n誰かがこのDAppを訪問するよう促している場合、それは詐欺である可能性が高いです。疑わしい場合は、Pezkuwi Walletサポートに連絡してください: %s。 + 警告! DAppは未知です + チェーンが見つかりません + リンクのドメイン %s は許可されていません + ガバナンスタイプが指定されていません + ガバナンスタイプがサポートされていません + 暗号タイプが無効です + 派生パスが無効です + ニモニックが無効です + URLが無効です + 入力されたRPC URLは%sネットワークとしてPezkuwiに存在します。 + デフォルト通知チャンネル + +%d + アドレスまたは名前で検索 + アドレス形式が無効です。アドレスが正しいネットワークに属していることを確認してください + 検索結果: %d + プロキシとマルチシグアカウントが自動検出されて整理されます。設定でいつでも管理できます。 + ウォレットリストが更新されました + 全期間の投票 + デリゲート + 全アカウント + 個人 + 組織 + デリゲーションを取り消した後、解除期間が始まります + あなたの投票は自動的に代理人の投票と共に行われます + デリゲート情報 + 個人 + 組織 + 代理投票 + デリゲーション + デリゲーションを編集 + 自分自身にデリゲートすることはできません。別のアドレスを選択してください + 自分自身にデリゲートできません + 自分自身についてもっと教えてください。Pezkuwiユーザーがあなたのことをよりよく知ることができます + デリゲートですか? + 自己紹介を追加 + %sトラックにわたって + 最近の投票 %s + %sを通じたあなたの投票 + あなたの投票: %s 経由 %s + 投票を削除 + 委任を取り消す + 解除期間が終了した後、トークンをアンロックする必要があります。 + 委任された投票 + 委任 + 最後に投票した %s + トラック + すべて選択 + 少なくとも1つのトラックを選択してください... + 委任に利用可能なトラックがありません + Fellowship + ガバナンス + トレジャリー + 未委任期間 + 無効になりました + あなたの委任 + あなたの委任 + 表示 + バックアップを削除中... + 以前にバックアップパスワードが更新されました。Cloud Backup を使用し続けるには、%s + 新しいバックアップパスワードを入力してください。 + バックアップパスワードが変更されました + 無効なネットワークのトランザクションにはサインできません。設定で%sを有効にしてもう一度お試しください。 + %sは無効です + このアカウントにすでに委任しています: %s + 委任はすでに存在します + (BTC/ETH 対応) + ECDSA + ed25519 (代替) + Edwards + バックアップパスワード + コールデータを入力 + 手数料を支払うのに十分なトークンがありません + 契約 + 契約呼び出し + 機能 + ウォレットを復元 + %s すべてのウォレットはGoogleドライブに安全にバックアップされます。 + ウォレットを復元しますか? + 既存のクラウドバックアップが見つかりました + 復元用JSONをダウンロード + パスワードを確認 + パスワードが一致しません + パスワードを設定 + ネットワーク: %s\nニーモニック: %s\n導出パス: %s + ネットワーク: %s\nニーモニック: %s + 手数料が計算されるまでお待ちください + 手数料の計算中 + デビットカードを管理 + %s トークンを売却 + %s Stakingのデリゲーションを追加 + スワップの詳細 + 最大: + あなたが支払う + あなたが受け取る + トークンを選択 + カードに%sをチャージ + support@pezkuwichain.ioに連絡してください。カードを発行するために使用したメールアドレスを添えてください。 + サポートに連絡 + 請求済み + 作成済み: %s + 金額を入力 + 最小贈り物は%sです + 回収済み + 贈るトークンを選択 + 請求時のネットワーク料金 + 贈り物を作成 + Pezkuwiで迅速、簡単、安全に贈り物を送る + 誰にでも、どこにでもクリプトギフトを共有 + あなたが作成した贈り物 + %sの贈り物のネットワークを選択 + 贈り物の金額を入力 + %sをリンクとして共有し、誰でもPezkuwiに招待 + 贈り物を直接共有 + %s、そして、このデバイスからいつでも請求されていないギフトを回収できます + 贈り物は瞬時に利用可能 + ガバナンス通知チャンネル + + 少なくとも %d トラックを選択する必要があります + + ロック解除 + この交換を実行すると、大幅なスリッページと財務上の損失が発生します。取引サイズを減らすか、取引を複数のトランザクションに分けることを検討してください。 + 高価格インパクト検出 (%s) + 履歴 + メール + 法的氏名 + Element名 + アイデンティティ + ウェブ + 提供されたJSONファイルは別のネットワーク用に作成されました。 + 入力が有効なJSON形式であることを確認してください。 + 復元JSONが無効です + パスワードの正確さを確認して、もう一度お試しください。 + Keystoreの復号に失敗しました + JSONを貼り付け + サポートされていない暗号化タイプ + Ethereum暗号化のネットワークにSubstrateシークレットを持つアカウントをインポートできません + Substrate暗号化のネットワークにEthereumシークレットを持つアカウントをインポートできません + あなたのニーモニックは無効です + 入力が64個の16進数記号を含んでいることを確認してください。 + シードが無効です + 残念ですが、あなたのウォレットのバックアップが見つかりませんでした。 + バックアップが見つかりません + Googleドライブからウォレットを復元 + 12, 15, 18, 21または24単語のフレーズを使用 + ウォレットのインポート方法を選択してください + Watch-only + あなたが構築しているネットワークのすべての機能をPezkuwi Walletに統合し、誰でもアクセスできるようにします。 + ネットワークを統合する + Polkadot用に構築していますか? + あなたが提供したコールデータは無効か、形式が間違っています。それが正しいことを確認し、もう一度お試しください。 + このコールデータは別の操作用のもので、コールハッシュ%sによります + 無効なコールデータ + プロキシアドレスは有効な%sアドレスである必要があります + 無効なプロキシアドレス + 入力された通貨記号(%1$s)はネットワーク(%2$s)と一致しません。正しい通貨記号を使用しますか? + 無効な通貨記号 + QRをデコードできません + QRコード + ギャラリーからアップロード + JSONファイルをエクスポート + 言語 + Ledgerは〜をサポートしていません + Ledger Flex + Nano S + Nano S Plus + ナノ X + Ledger Stax + 操作はデバイスによってキャンセルされました。Ledgerがロック解除されていることを確認してください。 + 操作がキャンセルされました + Ledgerデバイスで〜アプリを開いてください + 〜アプリが起動されていません + デバイスでエラーが発生して操作が完了しました。後でもう一度お試しください。 + Ledger操作失敗 + %s の確認ボタンを長押しして取引を承認 + アカウントをもっと読み込む + レビューして承認 + アカウント %s + アカウントが見つかりません + Ledgerデバイスは古いGenericアプリを使用しており、EVMアドレスをサポートしていません。Ledger Liveを介して更新してください。 + Ledger Genericアプリを更新 + トランザクションを承認するために両方のボタンを押してください + Ledger Liveアプリを使って〜を更新してください + メタデータが古いです + Ledgerは任意のメッセージの署名をサポートしていません—トランザクションのみ + 現在承認中の操作に対して正しいLedgerデバイスが選ばれていることを確認してしてください + セキュリティ上の理由から、生成された操作は%sのみ有効です。もう一度お試しください。そしてLedgerで承認してください + トランザクションの有効期限が切れました + トランザクションは%s有効です + Ledgerはこのトランザクションをサポートしていません。 + トランザクションがサポートされていません + %s の両方のボタンを押してアドレスを承認 + %s の確認ボタンを押してアドレスを承認 + アドレスを承認するには%1$sの両方のボタンを押してください + アドレスを承認するには%1$sの確認ボタンを押してください + このウォレットはLedgerとペアリングされています。Pezkuwiは望む操作を形成する手助けをし、Ledgerを使用してそれらを署名するよう求められます + ウォレットに追加するアカウントを選択 + ネットワーク情報を読み込み中... + トランザクションの詳細を読み込んでいます… + バックアップを探しています... + 支払いトランザクションが送信されました + トークンを追加 + バックアップの管理 + ネットワークを削除 + ネットワークを編集 + 追加されたネットワークの管理 + 資産画面でそのネットワークのトークン残高を確認できなくなります + ネットワークを削除しますか? + ノードを削除 + ノードを編集 + 追加されたノードの管理 + ノード「%s」は削除されます + ノードを削除しますか? + カスタムキー + デフォルトキー + この情報を他の誰とも共有しないでください - 共有した場合、すべての資産を永久かつ回復不能に失うことになります + カスタムキーを持つアカウント + デフォルトアカウント + %s、他に+%d + デフォルトキーを持つアカウント + バックアップするキーを選択 + バックアップするウォレットを選択 + バックアップを表示する前に以下をよくお読みください + パスフレーズを共有しないでください! + 手数料最大3.95%%で最良の価格 + 誰もあなたの画面を見れないようにし、スクリーンショットを撮らないでください + 誰にも%sしないでください + 共有しないでください + 別のものを試してください。 + 無効なニーモニックパスフレーズです。もう一度単語の順序を確認してください + 最大%dウォレットまでしか選択できません + + %dウォレットを少なくとも選択してください + + このトランザクションはすでに拒否または実行されています。 + このトランザクションを実行できません + %sは同じ操作をすでに開始しており、現在他の署名者による署名を待っています。 + 操作は既に存在します + デビットカードを管理するには、異なるタイプのウォレットに切り替えてください。 + マルチシグにはデビットカードはサポートされていません + マルチシグ預金 + この預金は、マルチシグ操作が実行または拒否されるまで預金者のアカウントにロックされます。 + 操作が正しいことを確認してください + ギフトを作成または管理するには、別の種類のウォレットに切り替えてください。 + マルチシグウォレットではギフト機能はサポートされていません + %s により署名され、実行されました。 + ✅ マルチシグトランザクションが実行されました + %s が %s で。 + ✍🏻 あなたの署名が要求されています + %s により開始されました。 + ウォレット: %s + %s により署名されました + %d のうち %d の署名が集まりました。 + %s を代表しています。 + %s により拒否されました。 + ❌ マルチシグトランザクションが拒否されました + 代表して + %s: %s + 承認 + 承認と実行 + 詳細を表示するにはコールデータを入力してください + トランザクションは既に実行されたか拒否されました。 + 署名は終了しました + 拒否 + 署名者 (%d/%d) + バッチオール (エラー時にリバート) + バッチ (エラーまで実行) + 強制バッチ (エラーを無視) + あなたによって作成された + まだトランザクションはありません。\n署名リクエストがここに表示されます + 署名中 (%s/%s) + あなたによって署名された + 不明な操作 + 署名すべきトランザクション + 代表として: + 署名者による署名 + マルチシグトランザクションが実行されました + あなたの署名が要求されています + マルチシグトランザクションが拒否されました + 仮想通貨をフィアットに売却するには、異なるタイプのウォレットに切り替えてください。 + マルチシグには売却はサポートされていません + 署名者: + %sは%sのマルチシグ預金をするのに十分な残高がありません。残高にさらに%sを追加する必要があります。 + %sは%sのネットワーク手数料を支払い、%sのマルチシグ預金をするのに十分な残高がありません。残高にさらに%sを追加する必要があります。 + %sはこの取引手数料を支払い、最低ネットワーク残高を超えるために少なくとも%sが必要です。現在の残高は: %s + %sは%sのネットワーク手数料を支払うのに十分な残高がありません。残高にさらに%sを追加する必要があります。 + マルチシグウォレットは任意のメッセージの署名をサポートしていません — トランザクションのみ + トランザクションは拒否されます。マルチシグのデポジットは%sに返されます。 + マルチシグトランザクションは%sによって開始されます。開始者はネットワーク手数料を支払い、マルチシグデポジットを予約します。このデポジットはトランザクションが実行されるとアンリザーブされます。 + マルチシグトランザクション + 他の署名者はトランザクションを確認できます。\n%sでそのステータスを追跡できます。 + 署名するトランザクション + マルチシグトランザクションが作成されました + 詳細を表示 + 初期のオンチェーン情報 (コールデータ) のないトランザクションが拒否されました + %s が %s で。\nこれ以上の操作は必要ありません。 + マルチシグトランザクションが実行されました + %1$s → %2$s + %s が %s で。\n%s により拒否されました。\nこれ以上の操作は必要ありません。 + マルチシグトランザクションが拒否されました + このウォレットからのトランザクションには、複数の署名者の承認が必要です。あなたのアカウントはその署名者の一つです: + 他の署名者: + %d分の%dの閾値 + マルチシグトランザクション通知チャンネル + 設定で有効にする + 署名リクエスト、新しい署名、完了したトランザクションについて通知を受け取り、常に管理を維持しましょう。設定でいつでも管理できます。 + マルチシグプッシュ通知が到着しました! + このノードはすでに存在します + ネットワーク手数料 + ノードアドレス + ノード情報 + 追加済み + カスタムノード + デフォルトノード + デフォルト + 接続中… + コレクション + 作成者 + %s の価格は %s + %s 単位の %s + #%s エディションの %s + 無制限シリーズ + 所有者 + リストされていません + あなたのNFT + ノヴァにマルチシグウォレットを追加または選択していません。マルチシグプッシュ通知を受け取るために少なくとも1つ追加し、選択してください。 + マルチシグウォレットがありません + 入力されたURLは「%s」ノードとしてすでに存在します。 + このノードはすでに存在します + 入力されたノードURLが応答していないか、フォーマットが不正です。URLのフォーマットは「wss://」から始まる必要があります。 + ノードエラー + 入力されたURLは%1$sのノードに対応していません。 有効な%1$sノードのURLを入力してください。 + 間違ったネットワーク + 報酬を請求 + トークンが再びStakeに追加されます + 直接 + プールステーキング情報 + 報酬 (%s) も請求され、使用可能なバランスに追加されます + プール + プールが閉鎖状態にあるため、指定した操作を実行できません。まもなく閉鎖されます。 + プールが閉鎖中 + 現在、お使いのプールにはUnstakingキューに空きがありません。%s後に再試行してください + プールからのUnstakingが多すぎます + あなたのプール + あなたのプール (#%s) + アカウントを作成 + 新しいウォレットを作成 + プライバシーポリシー + アカウントをインポート + 既にウォレットがあります + 続行することで、\n%1$s と %2$s に同意したことになります + 利用規約 + スワップ + あなたのコレーターの1つが報酬を生成していません + あなたのコレーターの1つが現在のラウンドで選ばれていません + %s のアンステーキング期間が過ぎました。トークンの引き出しを忘れないでください + このコレーターでStakeできません + コレーターを変更 + このコレーターにステークを追加できません + コレーターを管理 + 選択したコレータはステーキングへの参加を中止する意向を示しています。 + 全てのトークンをUnstakingしているコレータにStakeを追加することはできません。 + あなたのStakeはこのコレータの最小Stake(%s)よりも少なくなります。 + 残りのステーキングバランスはネットワークの最小値(%s)を下回り、Unstaking金額に追加されます。 + 認証されていません。もう一度お試しください。 + 認証にバイオメトリクスを使用する + Pezkuwi Walletは、アプリへのアクセスを制限するためにバイオメトリクス認証を使用しています。 + バイオメトリクス + PINコードが正常に変更されました。 + PINコードを確認 + 新しいPINコードを作成 + PINコードを入力 + PINコードを設定 + プールに参加できません。最大メンバー数に達しました。 + プールが満員です + 開いていないプールに参加できません。プールのオーナーに連絡してください。 + プールが開いていません + 同じアカウントでDirect StakingとPool Stakingの両方を使用することはできなくなりました。Pool Stakingを管理するには、まずDirect Stakingからトークンをアンステークする必要があります。 + プール操作は利用できません + 人気のある + 手動でネットワークを追加 + ネットワークリストを読み込み中... + ネットワーク名で検索 + ネットワークを追加 + %sの%s + 1日 + 全て + 1ヶ月 + %s (%s) + %s価格 + 1週間 + 1年 + 全期間 + 先月 + 今日 + 先週 + 昨年 + アカウント + ウォレット + 言語 + PINコードを変更 + 残高を非表示にしてアプリを開く + PINで承認 + セーフモード + 設定 + デビットカードを管理するには、Polkadotネットワークに切り替えて別のウォレットを使用してください。 + このプロクシウォレットではデビットカードはサポートされていません + このアカウントは以下のアカウントに取引を行うアクセスを許可しています: + Stakingオペレーション + 委任されたアカウント%sは、ネットワーク手数料%sを支払うのに十分な残高がありません。手数料支払いに利用可能な残高: %s + プロキシされたウォレットは任意のメッセージの署名をサポートしていません - 取引のみ + %1$sは%2$sに権限を委任していません + %1$sは%2$sを%3$sのためだけに委任しました + おっと! 許可が不足しています + トランザクションは%sが委任されたアカウントとして開始されます。ネットワーク手数料は委任されたアカウントによって支払われます。 + これは委任(プロキシ)アカウントです + %sプロキシ + 代理は投票しました + 新しい国民投票 + 国民投票の更新 + %s 国民投票 #%s がライブになりました! + 🗳️ 新しい国民投票 + すべての新機能を得るために Pezkuwi Wallet v%s をダウンロードしてください! + Pezkuwi Wallet の新しい更新が利用可能です! + %s 国民投票 #%s が終了し、承認されました 🎉 + ✅ 国民投票が承認されました! + %s 国民投票 #%s のステータスが %s から %s に変更されました + %s 国民投票 #%s が終了し、拒否されました! + ❌ 国民投票が拒否されました! + 🗳️ 国民投票のステータスが変更されました + %s 国民投票 #%s がステータス %s に変更されました + Pezkuwiのお知らせ + 残高 + 通知を有効にする + マルチシグトランザクション + ウォレットを選択していないため、ウォレット活動(残高、ステーキング)についての通知を受け取ることができません。 + その他 + 受け取ったトークン + 送信されたトークン + ステーキング報酬 + ウォレット + ⭐🆕 新しい報酬 %s + %s から %s ステーキングを受け取りました + ⭐🆕 新しい報酬 + Pezkuwi Wallet • 今 + Kusamaステーキングから +0.6068 KSM ($20.35) を受け取りました + %s を %s で受け取りました + ⬇️ 受け取りました + ⬇️ 受け取りました %s + %s を %s に %s で送信しました + 💸 送信されました + 💸 送信されました %s + ウォレットにアクティビティがあると通知されるように最大 %d つのウォレットを選択 + プッシュ通知を有効にする + ウォレット操作、ガバナンスの更新、ステーキング活動、そしてセキュリティについての通知を受け取ることで、常に最新の情報を得られます + プッシュ通知を有効にすることで、%sと%sに同意したことになります + 設定タブから通知設定にアクセスして、後でもう一度お試しください + お見逃しなく! + 受信するネットワークを選択 %s + 住所をコピー + この贈り物を回収すると、共有リンクが無効になり、トークンがウォレットに返金されます。\n続行しますか? + %sギフトを回収しますか? + ギフトを正常に再取得しました + jsonを貼り付けるかファイルをアップロード... + ファイルをアップロード + JSONを復元 + ニーモニック フレーズ + 生のシード + ソースタイプ + すべてのレファレンダム + 表示: + 未投票 + 投票済み + 適用されたフィルターで見つかったレファレンダムはありません + レファレンダム情報は開始されるとここに表示されます + 入力されたタイトルまたはIDのレファレンダムが見つかりませんでした + レファレンダムのタイトルまたはIDで検索 + %d レファレンダ + スワイプしてAIサマリーでレファレンダに投票。迅速かつ簡単! + レファレンダム + レファレンダムが見つかりません + 棄権票は0.1xの信念でのみ行うことができます。0.1xの信念で投票しますか? + 信念の更新 + 棄権票 + 賛成: %s + Pezkuwi DAppブラウザを使用 + 提案者のみがこの説明とタイトルを編集できます。提案者のアカウントをお持ちの場合は、Polkassembly にアクセスして提案に関する情報を入力してください + 要求された金額 + タイムライン + あなたの投票: + 承認曲線 + 受益者 + デポジット + 有権者 + プレビューには長すぎます + パラメータ JSON + 提案者 + 支持曲線 + 投票率 + 投票閾値 + 位置: %s の %s + 投票するにはウォレットに %s アカウントを追加する必要があります + レファレンダム %s + 反対: %s + 反対票 + %s 投票者の %s + 賛成票 + Staking + 承認済み + キャンセル済み + 決定中 + %s で決定中 + 実行済み + キュー待ち + キュー内 (%s の %s) + 削除済み + 進行中ではない + 進行中 + 準備中 + 拒否済み + %s で承認 + %s で実行 + %s でタイムアウト + %s で拒否 + タイムアウト + デポジット待ち + 閾値: %s の %s + 投票結果: 賛成 + キャンセル済み + 作成済み + 投票中: 決定中 + 実行済み + 投票中: キューに入っている + 削除済み + 投票中: 否決 + 投票中: 可決 + 投票中: 準備中 + 投票結果: 否決 + タイムアウト + 投票中: デポジット待ち + 可決条件: %s + クラウドローン + 財務省: 大きな支出 + 財務省: 大きなチップ + フェローシップ: 管理 + ガバナンス: レジストラ + ガバナンス: リース + 財務省: 中程度の支出 + ガバナンス: キャンセル + ガバナンス: 削除 + 主要議題 + 財務省: 小さな支出 + 財務省: 小さなチップ + 財務省: 任意 + %sでロックされたまま + ロック解除可能 + 棄権 + 賛成 + 全てのロックを再利用: %s + ガバナンスロックを再利用: %s + ガバナンスロック + ロック期間 + 反対 + ロック期間を延ばして投票を増やす + %sに投票する + ロック期間終了後、トークンのアンロックを忘れないでください + 投票済みのレファレンダ + %s票 + %s \u00d7 %sx + 投票者リストはここに表示されます + %s 票 + Fellowship: ホワイトリスト + あなたの投票: %s 票 + 国民投票は完了し、投票は終了しました + 国民投票は完了しました + 選択された国民投票のトラックに投票を委任しています。 委任者に投票を依頼するか、委任を解除して直接投票できるようにしてください。 + すでに投票を委任しています + トラックの最大の%s票に達しました + 最大投票数に達しました + 投票するのに十分な利用可能なトークンがありません。投票可能: %s。 + アクセス種別の取り消し + 取り消し対象 + 投票を削除 + + 以前に%dトラックで国民投票に投票しました。これらのトラックを委任可能にするために、既存の票を削除する必要があります。 + + 投票履歴を削除しますか? + %s それはあなた専用で、安全に保管され、他人にはアクセスできません。バックアップパスワードなしでは、Googleドライブからウォレットを復元することは不可能です。紛失した場合は、現在のバックアップを削除して、新しいパスワードで新しいバックアップを作成してください。 %s + 残念ながら、あなたのパスワードは復元できません。 + 代わりに、復元にはパスフレーズを使用してください。 + パスワードを紛失しましたか? + バックアップパスワードは以前に更新されました。クラウドバックアップを引き続き利用するには、新しいバックアップパスワードを入力してください。 + バックアッププロセス中に作成したパスワードを入力してください + バックアップパスワードを入力 + ブロックチェーン実行環境の情報を更新できませんでした。一部の機能が動作しない場合があります。 + 実行環境更新失敗 + 連絡先 + 私のアカウント + 入力された名前またはプールIDのプールが見つかりませんでした。正しいデータが入力されたか確認してください。 + アカウントアドレスまたはアカウント名 + ここに検索結果が表示されます。 + 検索結果 + アクティブプール: %d + メンバー + プールを選択 + デリゲーションを追加するトラックを選択 + 利用可能なトラック + 投票権をデリゲートしたいトラックを選択してください。 + デリゲーションを編集するトラックを選択 + デリゲーションを取り消すトラックを選択 + 利用不可のトラック + 贈り物を送る + Bluetoothを有効にして権限を付与 + Pezkuwiは、Ledgerデバイスを見つけるためにBluetoothスキャンを実行するために位置情報の有効化が必要です + デバイス設定で位置情報を有効にしてください + ネットワークを選択 + 投票するトークンを選択 + トラックを選択 + %d / %d + %s を売るためのネットワークを選択 + 売却が開始されました!60分までお待ちください。ステータスはメールで確認できます。 + 現在、弊社のプロバイダーはこのトークンの売却をサポートしていません。別のトークン、異なるネットワークを選択するか、後ほど再確認してください。 + このトークンは売却機能でサポートされていません + アドレスまたはw3n + 送信するネットワークを選択 %s + 受信者はシステムアカウントです。どの会社や個人にも管理されていません。\nそれでもこの転送を実行してもよろしいですか? + トークンが失われます + 権限を与える + 設定で生体認証が有効になっていることを確認してください + 設定で生体認証が無効になっています + コミュニティ + メールでサポートを受ける + 一般 + ウォレットでの署名操作(Pezkuwi Walletで作成またはインポートされたもの)は、署名の作成前にPIN確認が必要です + 操作の署名に認証を要求 + 設定 + Push通知はGoogle PlayからダウンロードしたPezkuwi Walletバージョンでのみ利用可能です。 + Push通知はGoogleサービスを搭載したデバイスでのみ利用可能です。 + 画面録画とスクリーンショットは利用できません。最小化されたアプリは内容を表示しません。 + セーフモード + セキュリティ + サポート&フィードバック + Twitter + ウィキとヘルプセンター + Youtube + 棄権時には信念が0.1xに設定されます + Direct StakingとNomination Poolsを同時にステークすることはできません + すでにステーキング中 + 高度なStaking管理 + Stakingタイプを変更できません + すでに直接Stakingを行っています + 直接ステーキング + 報酬を得るために必要な最低ステーク額 %s より少ないです。報酬を得るためにプール ステーキングを使用することを検討してください。 + ガバナンスでトークンを再利用 + 最低ステーク: %s + 報酬: 自動的に支払われる + 報酬: 手動で請求する + 既にプールでステーキングしています + プールステーキング + ステークが報酬を得るための最低額を下回っています + サポートされていないステーキングタイプ + 贈り物リンクを共有 + 回収 + こんにちは!あなたには%sの贈り物が待っています!\n\nPezkuwiウォレットアプリをインストールし、ウォレットを設定して、この特別リンクで請求してください:\n%s + 贈り物が準備できました。\n今すぐ共有しましょう! + sr25519 (推奨) + Schnorrkel + 選択されたアカウントはすでにコントローラーとして使用されています + 委任された権限を追加 (Proxy) + あなたの委任 + アクティブなデリゲーター + このアクションを実行するには、アプリケーションにコントローラーアカウント%sを追加してください。 + 委任を追加 + あなたのステークは最低限の%s以下です。\n 最低限のステーク未満では、報酬を生成する確率が減少します + トークンをさらにステーク + バリデーターを変更してください。 + 現在、すべて順調です。通知はここに表示されます。 + ステークの割り当て順の位置が古い場合、報酬が停止する可能性があります + ステーキングの改善 + アンステーク済みトークンを引き出してください。 + 次の時代が始まるのをお待ちください。 + 通知 + すでにコントローラーです + あなたはすでに %s でステーキングしています + ステーキングバランス + バランス + ステークを増やす + あなたはノミネートもバリデートもしていません + コントローラーを変更 + バリデーターを変更 + %s の %s + 選択されたバリデーター + コントローラー + コントローラーアカウント + このアカウントに自由なトークンがないことが確認されました。本当にコントローラーを変更しますか? + コントローラーは、アンステーク、引き出し、ステークへの返却、報酬の行き先変更、バリデーターの変更ができます。 + コントローラーは以下を行います:アンステーク、引き出し、ステークへの返却、バリデーターの変更、報酬の行き先設定 + コントローラーが変更されました + このバリデーターはブロックされており、現在選択することはできません。次のエラで再試行してください。 + フィルターをクリア + 全てを選択解除 + 推奨されたもので残りを埋める + バリデーター: %d の %d + バリデーターを選択 (最大 %d) + 選択したものを表示: %d (最大 %d) + バリデーターを選択 + 推定報酬 (%% APR) + 推定報酬 (%% APY) + リストを更新 + Pezkuwi DApp ブラウザを通じてのStaking + 追加のStakingオプション + Stakeして報酬を稼ぐ + %1$s Stakingは%2$sでライブ開始 %3$s + 推定報酬 + エラ #%s + 推定収益 + 推定 %s 収益 + バリデーターの固有のStake + バリデーターの固有のStake (%s) + Unstaking期間中のトークンは報酬を生成しません。 + Unstaking期間中、トークンは報酬を生成しません。 + Unstaking期間の後、トークンをリデームする必要があります。 + Unstaking期間の後、トークンをリデームするのを忘れないでください。 + 次のエラから報酬が増加します。 + 次のエラから報酬が増加します。 + Stakedトークンは各エラで報酬を生成します (%s)。 + ステークされたトークンは、各エポックごとに報酬を生成します (%s) + ステークを避けるために、Pezkuwi walletは報酬の受け取り先をあなたのアカウントに変更します。 + トークンをアンステークする場合、アンステーキング期間を待つ必要があります (%s)。 + トークンをアンステークするには、アンステーキング期間を待つ必要があります (%s) + ステーキング情報 + アクティブなノミネーター + + %d 日 + + 最小ステーク + %s ネットワーク + ステーク済み + プロキシを設定するにはウォレットを %s に切り替えてください + プロキシ設定のためのステーシュアカウントを選択 + 管理 + %s (最大 %s) + ノミネーターの最大数に達しました。後でもう一度お試しください + ステーキングを開始できません + 最小ステーク + ステーキングを開始するには、ウォレットに %s アカウントを追加する必要があります + 毎月 + デバイスにコントローラーアカウントを追加してください。 + コントローラーアカウントにアクセスできません + ノミネート: + %s 報酬 + あなたのバリデーターの一人がネットワークによって選出されました。 + アクティブステータス + 非アクティブステータス + あなたのStaked量は報酬を得るための最低Stake量を下回っています。 + あなたのバリデーターはネットワークに選ばれていません。 + あなたのStakingは次の時代で開始されます。 + 非アクティブ + 次の時代を待っています + 次の時代を待っています (%s) + プロキシデポジットのための残高が不足しています %s。利用可能な残高: %s + Staking通知チャネル + コレーター + コレーターの最小Stake量はあなたのデリゲーションよりも高いです。コレーターから報酬を受け取ることはできません。 + コレーター情報 + コレーターの自分のStake + コレーター数: %s + 1人以上のコレーターがネットワークに選ばれました。 + デリゲーター + あなたは最大数のデリゲーションに達しました %d コレーター + 新しいコレーターを選択できません + 新しいコレーター + 次のラウンドを待っています (%s) + すべてのコレーターに対してアンステークリクエストが保留中です。 + アンステーク可能なコレーターがいません + 返却されたトークンは次のラウンドからカウントされます + ステーキングされたトークンは各ラウンドで報酬を生成します (%s) + コレータを選択 + コレータを選択… + 次のラウンドから報酬が増加します + どの委任もアクティブでないため、このラウンドでは報酬を受け取ることができません。 + このコレータから既にトークンのアンステーキングを行っています。コレータごとに一つのアンステーキが保留中であることが可能です + このコレータからアンステーキングすることはできません + コレータ用にステークされたトークンが最小ステーク (%s) を上回らなければなりません。 + 報酬を受け取ることはできません + 一部のコレータは選出されていないか、ステーク要件があなたのステーク量を上回っています。そのため、このラウンドでは報酬を受け取ることができません。 + あなたのコレータ + ステークは次のコレータにアサインされます + アクティブなコレータ(報酬なし) + 選出されるためのステークが不足しているコレータ + 次のラウンドからアクティブになるコレータ + 保留中 (%s) + 支払い + 支払い期限が切れました + + 残り %d 日 + + 報酬が期限切れに近い場合、自分で支払うこともできますが、その場合は手数料がかかります。 + 報酬はバリデータによって2~3日ごとに支払われます + すべて + 常に + 終了日が常に今日です + カスタム期間 + %d日 + 終了日を選択 + 終了日 + 過去6か月 (6M) + 6M + 過去30日間 (30D) + 30D + 過去3か月 (3M) + 3M + 日付を選択 + 開始日を選択 + 開始日 + 報酬を表示 + 過去7日間 (7D) + 7D + 過去1年 (1Y) + 1Y + ご利用可能な残高は %s です。 最小残高として %s を残す必要があり、ネットワーク手数料として %s を支払う必要があります。 したがって、Staking できるのは %s を超えません。 + 委任された権限(プロキシ) + 現在のキューウエースロット + 新しいキューウエースロット + ステークに戻る + すべてのUnstaking + 返却されたトークンは次のエラからカウントされます + カスタム額 + Stakingに戻す金額はUnstakingの残高を超えています + 最新のUnstaked + 最も利益のある + オーバーサブスクライブされていない + オンチェーンIDを持っている + スラッシュされていない + IDあたりのバリデーターの制限は2つまで + 少なくとも1つのアイデンティティ連絡先を持つ + 推奨バリデーター + バリデーター + 推定報酬(APY) + 引き換え + 引き換え可能: %s + 報酬 + 報酬の送付先 + 移行可能な報酬 + エラ + 報酬の詳細 + バリデーター + 再投資での収益 + 再投資なしの収益 + 完璧です!すべての報酬は支払われました。 + 素晴らしい!未払いの報酬はありません + すべて支払う (%s) + 保留中の報酬 + 未払いの報酬 + %s 報酬 + 報酬について + 報酬 (APY) + 報酬の宛先 + 自分で選択 + 支払いアカウントを選択 + 推薦を選択 + 選択された %d (最大 %d) + バリデータ (%d) + コントローラーをStashに更新 + プロキシを使用して別のアカウントにStaking操作を委任 + コントローラーアカウントは廃止されます + 他のアカウントをコントローラーとして選択し、それにstaking管理操作を委任 + stakingのセキュリティを向上 + バリデータを設定 + バリデーターが選択されていません + ステーキングを開始するためにバリデーターを選択してください + 報酬を継続的に受け取るための推奨される最低ステークは%sです。 + ネットワークの最小値(%s)未満のステークはできません + 最低ステークは%sより大きくする必要があります + 再ステーク + 報酬を再ステークする + 報酬の使い方 + 報酬の種類を選択 + 支払いアカウント + スラッシュ + ステーク %s + 最大ステーク + ステーキング期間 + ステーキングの種類 + 候補を行動に信頼するべきであり、現在の収益性だけで判断すると利益が減少したり、資金を失ったりする可能性があります + バリデーターを慎重に選んでください。収益性だけで判断すると、報酬が減少したり、ステークの損失につながる可能性があります + バリデーターとステーキング + Pezkuwi Walletは、セキュリティと収益性の基準に基づいてトップバリデータを選択します + 推奨バリデータでStakeする + Stakingを開始 + Stash + StashはさらにBondしコントローラを設定できます。 + Stashは以下の用途に使用されます: さらにStakeし、コントローラを設定する + Stashアカウント%sはStaking設定の更新ができません。 + ノミネーターはネットワークのセキュリティを確保するためにトークンをロックすることでパッシブインカムを得ます。そのためには、ノミネーターはいくつかのバリデータを選択する必要があります。バリデータを選ぶ際には注意が必要です。選ばれたバリデータが適切に行動しない場合、インシデントの重大さに応じて、両者にスラッシュペナルティが課されます。 + Pezkuwi Walletは、ノミネーターがバリデーターを選択するのを支援します。モバイルアプリはブロックチェーンからデータを取得し、次の条件を満たすバリデーターのリストを作成します:最も利益をもたらす、連絡先情報を提供している、スラッシュされていない、そしてノミネートを受け付けているバリデーターです。Pezkuwi Walletはまた、分散化にも配慮しているため、1人または1つの会社が複数のバリデーター ノードを運営している場合、推奨リストには最大2つのノードのみが表示されます。 + ノミネーターとは? + Stakingの報酬は各エラの終わり(Kusamaでは6時間、Polkadotでは24時間)に支払い可能です。ネットワークは保留中の報酬を84エラの間保存し、ほとんどの場合バリデーターが全員に報酬を支払います。しかし、バリデーターが忘れることや何か問題が発生することもあるため、ノミネーターは自分で報酬を支払うことができます。 + 報酬は通常バリデーターによって支払われますが、Pezkuwi Walletは未払いの報酬が期限切れに近づいている場合に警告します。この通知と他の活動に関するアラートをStaking画面で受け取ることができます。 + 報酬を受け取る + Stakingは、ネットワーク内にトークンをロックすることでパッシブインカムを得る方法です。Stakingの報酬は、各エラごとに(Kusamaでは6時間ごと、Polkadotでは24時間ごとに)配布されます。希望するだけ長くStakingすることができ、Unstakingするためには、Unstaking期間が終了するのを待つ必要があり、トークンを引き出せるようになります。 + Stakingは、ネットワークのセキュリティと信頼性の重要な要素です。誰でもバリデータノードを運営することができますが、十分にStakeされたトークンを持つ者だけが、ネットワークによって新しいブロックを作成し報酬を受け取るために選出されます。バリデータはしばしば自力で十分なトークンを持っていないため、Nominateすることで必要なStake量に達するためにトークンをロックすることで助けます。 + Stakingとは? + バリデーターは24時間365日ブロックチェーンノードを運用し、ネットワークによって選出されるために十分なステーク(所有およびノミネーターから提供されたステークの両方)がロックされている必要があります。バリデーターはそのノードのパフォーマンスと信頼性を維持することで報酬を得ます。バリデーターになることはほぼフルタイムの仕事であり、ブロックチェーンネットワーク上のバリデーターとしての専門会社も存在します。 + 誰でもバリデーターになってブロックチェーンノードを運用できますが、それには特定の技術的スキルと責任が必要です。PolkadotとKusamaネットワークには初心者を支援するためのThousand Validators Programmeと呼ばれるプログラムがあります。さらに、ネットワーク自体は常により少ないステーク(選出されるのに十分なステークを持っているが)を持つバリデーターに報酬を与えることで、分散化を促進します。 + バリデーターとは? + コントローラーを設定するためにアカウントをstashに切り替えてください。 + Staking + %s staking + 報酬 + 合計Staked + Boost閾値 + 私のコレーターのために + Yield Boostなし + Yield Boost付き + 自動的に %s すべての譲渡可能トークンを超過してステークするため + 自動的に %s (以前: %s) すべての譲渡可能トークンを超過してステークするため + ステークしたい + イールドブースト + ステーキングタイプ + すべてのトークンをアンステークしているため、これ以上ステークできません。 + これ以上ステークできません + 部分的にアンステークする場合、少なくとも %s をステークに残しておく必要があります。残りの %s をアンステークして完全なアンステークにしますか? + ステークに残る金額が少なすぎます + アンステークしようとしている金額がステークされた残高を超えています + アンステーク + アンステーキングのトランザクションがここに表示されます + アンステーキングのトランザクションがここに表示されます + アンステーキング: %s + トークンはアンステーキング期間終了後に引き出し可能となります。 + アンステーキングリクエストの制限に達しました(%d 件のアクティブリクエスト)。 + アンステーキングリクエストの制限に達しました + アンステーキング期間 + すべてアンステーク + すべてアンステークしますか? + 推定報酬 (%% APY) + 推定報酬 + バリデータ情報 + 超過申込。あなたはこの時代にバリデータから報酬を受け取ることはありません。 + ノミネーター + 超過申込。ステークした上位ノミネーターのみが報酬を受け取ります。 + 自己 + 検索結果がありません。\nアカウントアドレスが完全に入力されていることを確認してください。 + バリデータはネットワークで不正行為(例:オフラインになる、ネットワークを攻撃する、修正されたソフトウェアを実行する)を行ったために罰せられます。 + 合計ステーク + 合計ステーク (%s) + 報酬はネットワーク手数料より少ないです。 + 年間 + あなたのステークは次のバリデータに割り当てられています。 + あなたのステークは次のバリデータに割り当てられています。 + 当選 (%s) + この時代に選ばれなかったバリデータ。 + 選ばれるのに十分なStakeがないバリデータ + 他のアクティブな者はあなたのステーク割り当てなしで活動しています。 + ステーク割り当てなしのアクティブなバリデータ + 未当選 (%s) + あなたのトークンはオーバーサブスクライブされたバリデータに割り当てられています。この時代では報酬を受け取れません。 + 報酬 + あなたのStake + あなたのバリデータ + 次の時代であなたのバリデータが変更されます。 + 今すぐウォレットのバックアップをしましょう。これにより、あなたの資金が安全で確実な状態になります。バックアップはいつでもウォレットを復元することができます。 + Googleで続行 + ウォレット名を入力してください + 私の新しいウォレット + 手動バックアップで続行 + ウォレットに名前を付けてください + これはあなたにしか見えず、後で編集することができます。 + あなたのウォレットは準備ができました + Bluetooth + USB + あなたのバランスには%sによってロックされたトークンがあります。続行するには、%s未満または%s以上を入力してください。別の金額をStakeするには、%sロックを解除する必要があります。 + 指定された金額をStakeすることはできません + 選択済み: %d (最大 %d) + 利用可能なバランス: %1$s (%2$s) + %s あなたのStakedトークンで + ガバナンスに参加する + Stakeを%1$s以上にして、あなたのStakedトークンで%2$s + ガバナンスに参加する + 最低 %1$s でいつでも Stake できます。あなたの Stake は %2$s によって積極的に報酬を得ます + %s で + いつでも Stake できます。あなたの Stake は %s によって積極的に報酬を得ます + %1$s staking についての詳細情報は %2$s で見つけてください + Pezkuwi Wiki + 報酬は %1$s ごとに累積されます。報酬の自動支払いを受けるには %2$s 以上を Stake してください。それ以外の場合は手動で報酬を請求する必要があります + %s ごとに + 報酬は %s ごとに累積されます + 報酬は %s ごとに累積されます。報酬を手動で請求する必要があります + 報酬は %s ごとに累積され、移転可能な残高に追加されます + 報酬は %s ごとに累積され、再び Stake に追加されます + 報酬と Staking のステータスは時間とともに変動します。時々 %s + あなたの Stake を監視する + Stake を始める + %s を見る + 利用規約 + %1$s は %2$s であり、%3$s が含まれます + トークン価値なし + テストネットワーク + 年間 %1$s\nあなたのトークン %2$s に対して + 最大 %s を稼ぐ + いつでもUnstakeでき、資金を%s償還できます。Unstaking中は報酬が得られません + %s後 + 選択したプールは、バリデーターが選択されていないか、ステークが最小限に満たないため非アクティブです。\n選択したプールで進行してもよろしいですか? + 最大数のノミネーターが達成されました。後で再試行してください + %sは現在利用できません + バリデーター: %d (最大 %d) + 変更済み + 新規 + 削除済み + ネットワーク手数料を支払うトークン + 入力された金額の上にネットワーク手数料が追加されます + スワップステップのシミュレーションが失敗しました + このペアはサポートされていません + 操作 #%s (%s) が失敗しました + %sから%sへのスワップ on %s + %sから%sへの%s転送 + + %s操作 + + %s/%sの操作 + %sから%sへのスワップ on %s + %sを%sに転送 + 実行時間 + 十分なトークンを保持していないため、%sのネットワーク手数料を支払った後に少なくとも%sを保持する必要があります + %sトークンを受け取るために少なくとも%sを保持する必要があります + %sトークンを受け取るには、%s上に少なくとも%sが必要です + ネットワーク手数料として%2$sを支払うため、最大%1$sをスワップできます。 + ネットワーク手数料として%2$sを支払う必要があり、さらに%3$sを%4$sに変換して%5$sの最低バランスを確保する必要があるため、最大%1$sをスワップできます。 + 最大をスワップ + スワップ最小 + 残高に少なくとも %1$s を残しておく必要があります。残りの %2$s を追加して全額スワップしますか? + 残高が少なすぎます + ネットワーク手数料 %2$s を支払い、%3$s を %4$s に変換し、最低残高 %5$s を満たした後で少なくとも %1$s を残しておく必要があります。\n\n残りの %6$s を追加して全額スワップしますか? + 支払う + 受け取る + トークンを選択 + スワップするトークンが不足しています + 流動性が不足しています + 受け取る金額は %s 以上でなければなりません + クレジットカードで %s を即座に購入 + 他のネットワークから %s を転送 + QRコードまたはあなたのアドレスで %s を受け取る + %s を使って取得 + スワップ実行中に中間受取額が%sで、これは最小残高%sを下回っています。より大きなスワップ額を指定してみてください。 + スリッページは %s から %s の間で指定する必要があります + 無効なスリッページ + 支払うトークンを選択 + 受け取るトークンを選択 + 金額を入力 + 別の金額を入力 + ネットワーク手数料を %s で支払うため、Pezkuwiは %s を %s に自動的にスワップして、アカウントの最低 %s 残高を維持します。 + ブロックチェーンによって取引や検証を処理するためのネットワーク手数料。ネットワークの状況や取引速度によって異なる場合があります。 + スワップするネットワークを選択 %s + プールにスワップするための十分な流動性がありません + 価格差とは、二つの異なる資産間の価格差を指します。暗号通貨のスワップを行う際、価格差は通常、交換する資産の価格と交換される資産の価格の違いを意味します。 + 価格差 + %s ≈ %s + 二つの異なる暗号通貨間の交換レートです。これは、ある量の暗号通貨と引き換えにどれだけの別の暗号通貨を得られるかを表します。 + レート + 旧レート: %1$s ≈ %2$s。\n新レート: %1$s ≈ %3$s + 交換レートが更新されました + 操作を繰り返す + ルート + 異なるネットワークを通じて希望するトークンを得るためにトークンが通る経路です。 + スワップ + 転送 + スワップ設定 + スリッページ + スワップのスリッページは、分散型取引でよく見られる現象で、取引の最終価格が市場状況の変化により予想価格から少し異なる場合があります。 + 他の値を入力してください + %sから%sの範囲内の値を入力してください + スリッページ + 高いスリッページのため、トランザクションがフロントランされる可能性があります。 + 低スリッページ許容のため、トランザクションがリバートされる可能性があります。 + %sの金額は%sの最低残高を下回っています + 交換する金額が小さすぎます + 棄権: %s + 賛成: %s + 後でこのレファレンダムに投票することができます + 投票リストからレファレンダ %s を削除しますか? + 一部のレファレンダはもはや投票に利用できないか、投票に必要なトークンが不足している可能性があります。利用可能な投票:%s。 + 投票リストから除外されたレファレンダの一部 + レファレンダのデータを読み込むことができませんでした + データが取得できません + 利用可能なレファレンダに既に投票したか、現在投票できるレファレンダがありません。後で戻ってきてください。 + 利用可能なレファレンダに既に投票しています + リクエスト済み: + 投票リスト + 残り %d + 投票を確認 + 投票するレファレンダはありません + 投票を確認する + 投票がありません + %d レファレンダへの投票に成功しました + 現在の投票力 %s (%sx) で投票するには残高が不足しています。投票力を変更するか、ウォレットに資金を追加してください。 + 投票に必要な残高が不足しています + 反対: %s + SwipeGov + %d レファレンダに投票 + SwipeGovで将来の投票を設定します + 投票力 + Staking + ウォレット + 今日 + 価格情報のためのCoingeckoリンク(オプション) + %s トークンを買うためのプロバイダーを選択 + プロバイダーによって支払い方法、手数料、制限が異なります。\nそれらの見積もりを比較して最適なオプションを見つけてください。 + %s トークンを売るためのプロバイダーを選択 + 購入を続行するために、Pezkuwi Walletアプリから%sにリダイレクトされます。 + ブラウザで続行しますか? + 現在、弊社のプロバイダーはこのトークンの購入または売却をサポートしていません。別のトークン、異なるネットワークを選択するか、後ほど再確認してください。 + このトークンは購入/売却機能でサポートされていません + ハッシュをコピー + 手数料 + 送信元 + エクストリンシックハッシュ + トランザクション詳細 + %sで表示 + Polkascanで表示 + Subscanで表示 + %s(%sで) + 以前の%s トランザクション履歴は%sで引き続き利用可能です + 完了 + 失敗 + 保留中 + トランザクション通知チャネル + わずか$5からクリプトを購入 + わずか$10からクリプトを売る + 送信元: %s + 送信先: %s + 送金 + 入金および出金\nトランザクションがここに表示されます + あなたの操作はここに表示されます + これらのトラックで委任するために投票を削除 + すでに投票を委任したトラック + 利用できないトラック + 既に投票しているトラック + 再び表示しない。\n受取で従来のアドレスを見つけることができます。 + 従来の形式 + 新しい形式 + いくつかの取引所では、更新中に操作用に従来の形式が必要な場合があります。 + 新しい統一アドレス + インストール + バージョン %s + 更新が利用可能 + 問題を回避し、ユーザー体験を向上させるため、できるだけ早く最新の更新をインストールすることを強くお勧めします + 重要な更新 + 最新 + Pezkuwi Walletにはたくさんの素晴らしい新機能が利用可能です! アプリケーションを更新して、それらにアクセスしてください + 大規模な更新 + 重要 + 主要 + すべての利用可能な更新を表示 + 名前 + ウォレット名 + この名前はあなた自身だけに表示され、モバイルデバイスにローカルで保存されます。 + このアカウントは現在のエラに参加するためにネットワークによって選出されていません + 再投票 + 投票 + 投票状況 + あなたのカードに資金が追加されています! + カードが発行されています! + 最大で5分かかる場合があります。\nこのウィンドウは自動的に閉じられます。 + 推定%s + 購入 + 購入/売却 + トークンを購入 + で購入 + 受け取る + 受け取る %s + 送信するのは%1$sトークンと%2$sネットワークのトークンのみ可能です、それ以外の場合、資金を失う可能性があります + 販売 + トークンを販売 + 送信 + スワップ + 資産 + ここに資産が表示されます。\n「ゼロ残高を隠す」フィルターが\nオフになっていることを確認してください + 資産価値 + 利用可能 + Staked + 残高の詳細 + 総残高 + 転送後の総残高 + 凍結 + ロック済み + 引き換え可能 + 予約済み + 移行可能 + Unstaking + ウォレット + 新しい接続 + + %s アカウントが見つかりません。設定でアカウントをウォレットに追加してください + + “%s” によって要求された一部の必須ネットワークは Pezkuwi Wallet でサポートされていません + Wallet Connect セッションがここに表示されます + WalletConnect + 不明なdApp + + %s サポートされていないネットワークが隠されています + + WalletConnect v2 + クロスチェーントランスファー + 暗号通貨 + 法定通貨 + 人気の法定通貨 + 通貨 + エクストリンシックの詳細 + ゼロ残高の資産を非表示 + その他のトランザクション + 表示 + 報酬とスラッシュ + スワップ + フィルター + 転送 + 資産を管理 + ウォレットを追加する方法? + ウォレットを追加する方法? + ウォレットを追加する方法? + 名前の例: メインアカウント, 私のバリデーター, Dotsamaクラウドローンなど。 + このQRを送信者と共有 + 送信者にこのQRコードをスキャンさせる + 私の%sアドレスで%sを受け取る: + QRコードを共有 + 受信者 + アドレスが正しいネットワークに属していることを確認してください + アドレス形式が無効です。アドレスが正しいネットワークに属していることを確認してください + 最小残高 + クロスチェーンの制限により、%sを超えて転送することはできません + クロスチェーン手数料の%sを支払うのに十分な残高がありません。転送後の残高: %s + クロスチェーン手数料が入力された金額に追加されます。受取人はクロスチェーン手数料の一部を受け取る可能性があります。 + 転送を確認 + クロスチェーン + クロスチェーン手数料 + 送金先アカウントが他のトークン転送を受け取るための十分な%sを持っていないため、送金は失敗します。 + 受取人は転送を受け取れません + 送金先アカウントに最低残高未満の金額しかないため、送金は失敗します。金額を増やしてみてください。 + 送金によりアカウントの合計残高が最低残高を下回るため、アカウントはブロックストアから削除されます。 + 送金により合計残高が最低残高を下回るため、アカウントがブロックチェーンから削除されます。 + 送金によりアカウントが削除されます + 送金により合計残高が最低残高を下回るため、アカウントがブロックチェーンから削除されます。残りの残高も受取人に転送されます。 + ネットワークから + このトランザクション手数料を支払うためには最低でも%sが必要であり、かつネットワークの最小バランスを上回る必要があります。現在のバランスは:%s。操作を実行するためには、バランスに%sを追加する必要があります。 + 自分自身 + オンチェーン + 次のアドレス:%s はフィッシング活動に使用されていることが知られているため、このアドレスにトークンを送ることをお勧めしません。それでも続行しますか? + 詐欺警告 + 受信者はトークンオーナーによってブロックされており、現在は着信転送を受け付けることができません + 受信者は転送を受け付けられません + 受信者のネットワーク + ネットワークへ + %sを送信元 + %sを送信先 + + 送信者 + トークン + この連絡先に送信 + 転送の詳細 + %s (%s) + %s のアドレス %s + Pezkuwi は %1$s アドレスの情報の整合性に問題を検出しました。整合性の問題を解決するために %1$s の所有者にご連絡ください。 + 整合性チェック失敗 + 無効な受信者 + %s のネットワーク上で有効なアドレスが見つかりませんでした + %sが見つかりません + %1$s w3nサービスは利用できません。後で再試行するか、%1$sのアドレスを手動で入力してください + w3nの解決エラー + Pezkuwiはトークン%sのコードを解決できません + トークン%sはまだサポートされていません + 昨日 + 現在のコレータのためにYield Boostがオフになります。新しいコレータ: %s + Yield Boostされたコレータを変更しますか? + ネットワーク手数料%sとYield Boostの実行手数料%sを支払うための残高が不足しています。\n手数料を支払うために利用可能な残高:%s + 最初の実行手数料を支払うためのトークンが不足しています + ネットワーク手数料%sを支払い、閾値%sを下回らないための残高が不足しています。\n手数料を支払うために利用可能な残高:%s + 閾値を維持するためのトークンが不足しています + Stake増加時間 + Yield Boostは自動的に%s私の全ての譲渡可能なトークンを%s以上ステークします + Yield Boosted + + + DOT ↔ HEZ ブリッジ + DOT ↔ HEZ ブリッジ + 送金額 + 受取額(概算) + 為替レート + ブリッジ手数料 + 最低額 + 1 DOT = %s HEZ + 1 HEZ = %s DOT + 交換 + HEZ→DOT交換はDOTの流動性が十分な場合に処理されます。 + 残高不足 + 最低額未満 + 金額を入力 + HEZ→DOT交換は流動性により制限される場合があります。 + HEZ→DOT交換は一時的に利用できません。DOTの流動性が十分になったら再試行してください。 + diff --git a/common/src/main/res/values-ko/strings.xml b/common/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..73e109d --- /dev/null +++ b/common/src/main/res/values-ko/strings.xml @@ -0,0 +1,2033 @@ + + + 문의하기 + Github + 개인정보 보호정책 + 앱 평가하기 + Telegram + 이용 약관 + 이용 약관 + 정보 + 앱 버전 + 웹사이트 + 유효한 %s 주소를 입력하세요... + Evm 주소는 유효해야 하거나 비워야 합니다... + 유효한 substrate 주소를 입력하세요... + 계정 추가 + 주소 추가 + 계정이 이미 존재합니다. 다른 계정을 시도해 주세요. + 주소로 지갑을 추적하세요 + watch-only 지갑 추가 + 당신의 암호구절을 작성하세요 + 문구를 정확하고 읽기 쉽게 적어 두셨는지 확인하세요. + %s 주소 + 계정이 %s에 없습니다 + 니모닉 확인 + 다시 한번 확인해 보겠습니다 + 올바른 순서로 단어를 선택하세요 + 새 계정 생성 + 클립보드나 스크린샷을 사용하지 마시고, 백업을 위한 안전한 방법(예: 종이)을 찾으세요. + 이름은 이 애플리케이션에서만 로컬로 사용됩니다. 나중에 수정할 수 있습니다 + 지갑 이름 생성 + 백업 니모닉 + 새 지갑 만들기 + 니모닉은 계정 액세스를 복구하는 데 사용됩니다. 이를 적어 두세요. 없이는 계정을 복구할 수 없습니다! + 변경된 비밀을 가진 계정 + 잊어버리기 + 계속 진행하기 전에 지갑을 내보냈는지 확인하세요. + 지갑을 잊어버리겠습니까? + 유효하지 않은 Ethereum 파생 경로 + 유효하지 않은 Substrate 파생 경로 + 이 지갑은 %1$s와 쌍을 이루고 있습니다. Pezkuwi는 원하는 모든 작업을 수행하는 데 도움을 줄 것이며, %1$s를 사용하여 서명해야 합니다 + %s에서 지원되지 않음 + 이것은 watch-only 지갑으로, Pezkuwi는 잔액 및 기타 정보를 보여줄 수 있지만 이 지갑으로는 어떤 거래도 수행할 수 없습니다 + 지갑 닉네임을 입력하세요... + 다른 것을 시도해 보세요. + Ethereum 키페어 암호화 유형 + 이더리움 비밀 파생 경로 + EVM 계정 + 계정 내보내기 + 내보내기 + 기존 계정 가져오기 + 이 비밀번호는 계정을 암호화하는 데 필요하며, 이 JSON 파일과 함께 지갑을 복원하는 데 사용됩니다. + JSON 파일의 비밀번호 설정 + 비밀을 저장하고 안전한 장소에 보관하세요 + 비밀을 적어 두고 안전한 장소에 보관하세요 + 잘못된 복원 json입니다. 입력한 내용이 유효한 json인지 확인하세요. + Seed가 유효하지 않습니다. 입력한 내용이 64개의 hex 기호를 포함하고 있는지 확인하세요. + JSON에 네트워크 정보가 없습니다. 아래에서 지정하세요. + 복원 JSON 제공 + 일반적으로 12단어 구문 (15, 18, 21 또는 24일 수도 있음) + 단어들을 한 칸의 공백으로 나누어 입력하세요. 쉼표나 다른 기호는 포함하지 마세요 + 올바른 순서로 단어들을 입력하세요 + 비밀번호 + 개인 키 가져오기 + 0xAB + 원시 seed 입력 + Pezkuwi는 모든 앱과 호환됩니다 + 지갑 가져오기 + 계정 + 파생 경로에 지원되지 않는 기호가 포함되어 있거나 구조가 잘못되었습니다 + 잘못된 파생 경로 + JSON 파일 + Ledger Live 앱을 사용하여 Ledger 장치에 %s되어 있는지 확인하십시오 + 폴카닷 앱이 설치되었습니다 + Ledger 기기에서 %s + Polkadot 앱 열기 + Flex, Stax, Nano X, Nano S Plus + Ledger (Generic Polkadot 앱) + Ledger Live 앱을 사용하여 Ledger 장치에 Network 앱이 설치되어 있는지 확인하십시오. Ledger 장치에서 네트워크 앱을 여십시오. + 계정을 적어도 하나 추가하십시오 + 지갑에 계정을 추가하십시오 + Ledger 연결 가이드 + Ledger Live 앱을 사용하여 Ledger 기기에 %s 있는지 확인하십시오 + 네트워크 앱이 설치되었습니다 + Ledger 기기에서 %s + 네트워크 앱을 여세요 + %s에 대한 Pezkuwi Wallet 접근을 허용하십시오 + Bluetooth 접근 + 지갑에 추가하려면 %s + 계정을 선택 + %s 전화 설정에서 + OTG 활성화 + Ledger 연결 + 작업을 서명하고 계정을 새로운 Generic Ledger 앱으로 마이그레이션하려면 Migration 앱을 설치하고 여십시오. 레거시 구형 및 Migration Ledger 앱은 더 이상 지원되지 않을 것입니다. + 새로운 Ledger 앱이 출시되었습니다 + Polkadot Migration + Migration 앱은 곧 사용 불가능할 것입니다. 자금을 잃지 않기 위해 새 Ledger 앱으로 계정을 마이그레이션하세요. + Polkadot + Ledger Nano X + 레저 레거시 + Ledger를 Bluetooth로 사용하는 경우, 두 기기에서 Bluetooth를 활성화하고 Pezkuwi Bluetooth 및 위치 권한을 부여하세요. USB를 사용하려면, 전화 설정에서 OTG를 활성화하세요. + 전화 설정에서 Bluetooth를 활성화하고 Ledger 장치를 여십시오. Ledger 장치를 잠금 해제하고 %s 앱을 여십시오. + Ledger 장치를 선택하십시오 + Ledger 장치를 활성화해주세요. Ledger 장치를 잠금 해제하고 %s 앱을 여세요. + 거의 다 왔어요! 🎉\n 아래를 탭하여 설정을 완료하고 Polkadot 앱과 Pezkuwi Wallet에서 계정을 원활하게 사용하세요 + Pezkuwi에 오신 것을 환영합니다! + 12, 15, 18, 21 또는 24 단어 구 + 다중 서명 + 공유 제어 (다중 서명) + 이 네트워크에 사용할 계정이 없습니다. 계정을 생성하거나 가져올 수 있습니다. + 계정 필요 + 계정을 찾을 수 없습니다 + 공개 키 쌍 + Parity Signer + %s는 %s를 지원하지 않습니다 + %s에서 다음 계정을 성공적으로 읽었습니다 + 여기에 계정이 있습니다 + 잘못된 QR 코드입니다. %s에서 QR 코드를 스캔하고 있는지 확인하십시오. + 가장 위쪽에 있는 것을 선택했는지 확인하십시오 + %s 스마트폰 애플리케이션 + Parity Signer 열기 + Pezkuwi Wallet에 추가하려는 %s + “Keys” 탭으로 이동하여, 시드 선택 후 계정을 선택하십시오 + Parity Signer에서 %s을(를) 제공합니다 + 스캔할 QR 코드 + %s에서 지갑을 추가 + %s는 임의의 메시지 서명을 지원하지 않습니다 \n— 오직 거래만 지원합니다 + 서명이 지원되지 않음 + %s에서 QR 코드를 스캔하세요 + 레거시 + 새로운 (Vault v7+) + %s에 오류가 있습니다 + QR 코드가 만료되었습니다 + 보안을 위해 생성된 작업은 %s 동안만 유효합니다.\n새 QR 코드를 생성하고 %s로 서명하세요 + QR 코드는 %s 동안 유효합니다 + 현재 서명 중인 작업을 위한 QR 코드를 스캔하고 있는지 확인하세요 + %s로 서명 + Polkadot Vault + 유의하세요, 파생 경로 이름은 비어 있어야 합니다 + %s 스마트폰 애플리케이션 + Polkadot Vault 열기 + 추가하고 싶은 %s를 Pezkuwi Wallet에 추가하세요 + Derived Key를 탭하세요 + Polkadot Vault가 %s합니다 + 스캔할 QR 코드 + 오른쪽 상단 모서리의 아이콘을 탭하고 %s를 선택하세요 + 개인 키 내보내기 + 개인 키 + 프록시됨 + 위임된 사용(프록시됨) + 어떤 동작이든 + 경매 + 프록시 취소 + 거버넌스 + 신원 판단 + 지명 풀 + 전송 불가 + Staking + 이미 계정이 있습니다 + 비밀 + 64 16진수 기호 + 하드웨어 지갑 선택 + 비밀 유형 선택 + 지갑 선택 + 공유 비밀이 있는 계정 + Substrate 계정 + Substrate 키페어 암호화 유형 + Substrate 비밀 파생 경로 + 지갑 이름 + 지갑 별명 + Moonbeam, Moonriver 및 기타 네트워크 + EVM 주소(선택 사항) + 사전 설정 지갑 + Polkadot, Kusama, Karura, KILT 및 50+ 네트워크 + Pezkuwi Wallet에 개인 키를 입력하지 않고 지갑 활동 추적 + 지갑은 보기 전용이므로, 계정으로 어떤 작업도 할 수 없습니다 + 이런! 키가 없습니다 + 보기 전용 + Polkadot Vault, Parity Signer 또는 Ledger 사용 + 하드웨어 지갑 + Pezkuwi에서 Trust Wallet 계정을 사용하세요 + Trust Wallet + %s 계정 추가 + 지갑 추가 + %s 계정 변경 + 계정 변경 + Ledger (Legacy) + 귀하에게 위임됨 + 공유 제어 + 사용자 정의 노드 추가 + 위임하기 위해 지갑에 %s 계정을 추가해야 합니다 + 네트워크 세부사항 입력 + 위임할 대상 + 위임 계정 + 위임 지갑 + 접근 유형 부여 + 프록시가 제거될 때까지 예치금은 계좌에 예약된 상태로 남아 있습니다. + %s에서 추가된 프록시 수의 한도에 도달했습니다. 새로운 프록시를 추가하려면 기존 프록시를 제거하세요. + 최대 프록시 수에 도달했습니다 + 추가된 사용자 정의 네트워크는 여기에 나타납니다 + +%d + Pezkuwi가 자동으로 귀하의 멀티시그 지갑으로 전환되어, 대기 중인 거래를 확인할 수 있습니다. + 컬러 + 외관 + 토큰 아이콘 + 화이트 + 입력된 계약 주소는 Pezkuwi에서 %s 토큰으로 이미 존재합니다. + 입력된 계약 주소는 Pezkuwi에서 %s 토큰으로 존재합니다. 수정하시겠습니까? + 이 토큰은 이미 존재합니다 + 제공된 URL이 다음 형식을 갖추었는지 확인하세요: www.coingecko.com/en/coins/tether. + 유효하지 않은 CoinGecko 링크 + 입력한 계약 주소가 %s ERC-20 계약이 아닙니다. + 유효하지 않은 계약 주소 + 소수 자릿수는 최소 0 이상, 36 이하이어야 합니다. + 유효하지 않은 소수값 + 계약 주소 입력 + 소수 자릿수 입력 + 심볼 입력 + 이동 %s + %1$s부터, 귀하의 %2$s 잔고, Staking, Governance는 %3$s에 있으며 성능이 향상되고 비용이 낮아졌습니다. + 귀하의 %s 토큰이 이제 %s에 있습니다 + 네트워크 + 토큰 + 토큰 추가 + 계약 주소 + 소수 자릿수 + 심볼 + ERC-20 토큰 세부정보 입력 + ERC-20 토큰을 추가할 네트워크 선택 + 크라우드론 + Governance v1 + OpenGov + 선거 + Staking + 베스팅 + 토큰 구매 + 크라우드론에서 DOT을 돌려받았나요? 최대한의 보상을 받기 위해 오늘부터 DOT을 스테이킹하세요! + 당신의 DOT 부스트 🚀 + 토큰 필터링 + 증여할 토큰이 없습니다.\n계정에 토큰을 구매하거나 입금하세요. + 모든 네트워크 + 토큰 관리 + Ledger가 %s 전송을 지원하지 않으므로 %s을 Ledger 제어 계정으로 전송하지 마세요. 그렇지 않으면 자산이 이 계정에 고정될 수 있습니다. + Ledger에서 이 토큰을 지원하지 않습니다. + 네트워크 또는 토큰으로 검색 + 입력한 이름과 일치하는 네트워크 또는 토큰이 없습니다. + 토큰으로 검색 + 당신의 지갑 + 보낼 토큰이 없습니다.\n계정에 토큰을 구매하거나 수신하십시오. + 지불할 토큰 + 받을 토큰 + 수정 및 제거된 지갑을 위해 %s를 진행하기 전에! + 비밀번호를 저장했는지 확인하십시오 + 백업 업데이트를 적용하시겠습니까? + 지갑을 저장할 준비를 하십시오! + 이 비밀번호는 연결된 모든 지갑과 그 안의 자금에 대한 완전하고 영구적인 접근을 제공합니다.\n%s + 절대 공유하지 마십시오. + 어떠한 양식이나 웹사이트에도 당신의 비밀번호를 입력하지 마십시오.\n%s + 자금이 영원히 사라질 수 있습니다. + 지원팀이나 관리자들은 어떤 경우에도 절대로 당신의 암호구문을 요청하지 않을 것입니다.\n%s + 사칭자 조심하세요. + 검토 & 수락 후 계속 + 백업 삭제 + 이 기기에 대한 접근이 불가능해질 경우, 지갑 자금에 접근하기 위해 암호구문을 수동으로 백업할 수 있습니다. + 수동 백업 + 수동 + 암호구문이 있는 지갑을 추가하지 않았습니다. + 백업할 지갑 없음 + 설정한 비밀번호로 보호된 모든 지갑의 암호화된 복사본을 저장하기 위해 Google Drive 백업을 활성화할 수 있습니다. + Google Drive에 백업 + Google Drive + 백업 + 백업 + 직관적이고 효율적인 KYC 프로세스 + 생체 인증 + 모두 닫기 + 구매가 시작되었습니다! 최대 60분까지 기다려주세요. 이메일에서 상태를 추적할 수 있습니다. + %s 구매를 위한 네트워크 선택 + 구매 시작됨! 최대 60분 정도 기다려 주세요. 상태는 이메일에서 확인할 수 있습니다. + 현재 제공업체 중 이 토큰을 구매할 수 있는 곳이 없습니다. 다른 토큰이나 네트워크를 선택하거나 나중에 다시 확인하세요. + 이 토큰은 구매 기능에서 지원되지 않습니다 + 어떤 토큰으로든 수수료를 지불할 수 있는 기능 + 이전이 자동으로 발생하므로, 별도의 조치가 필요하지 않습니다 + 이전 거래 내역은 %s에 그대로 남아 있습니다 + %1$s 의 %2$s 잔액을 기준으로 시작합니다. 스테이킹과 거버넌스는 %3$s에 있습니다. 이러한 기능은 최대 24시간 동안 사용 불가할 수 있습니다. + %1$sx 낮은 거래 수수료\n(%2$s에서 %3$s로) + %1$sx 최소 잔고 감소\n(%2$s에서 %3$s로) + Asset Hub의 뛰어난 점은 무엇인가요? + %1$s 기준 %2$s 잔액으로 시작하며, 스테이킹과 거버넌스는 %3$s에 있습니다 + 더 많은 토큰 지원: %s 및 기타 생태계 토큰 + 통합 접근: %s, 자산, Staking, Governance + 자동 균형 노드 + 연결 활성화 + 클라우드 백업에서 지갑을 복구하는 데 사용할 비밀번호를 입력하십시오. 이 비밀번호는 나중에 복구할 수 없으므로 반드시 기억해 두십시오! + 백업 비밀번호 업데이트 + 자산 + 사용 가능한 잔액 + 죄송합니다. 잔액 확인 요청에 실패했습니다. 나중에 다시 시도해 주세요. + 죄송합니다. 전송 제공업체에 연결할 수 없습니다. 나중에 다시 시도해 주세요. + 죄송합니다. 지정된 금액을 사용할 충분한 자금이 없습니다 + 전송 수수료 + 네트워크가 응답하지 않습니다 + 받는 사람 + 이미 선물이 수령되었습니다 + 선물을 받을 수 없습니다 + 선물 받기 + 서버에 문제가 있을 수 있습니다. 나중에 다시 시도하세요. + 문제가 발생했습니다 + 다른 지갑을 사용하거나, 새로 만들거나, 설정에서 %s 계정을 이 지갑에 추가하세요. + 성공적으로 선물을 수령했습니다. 곧 잔액에 반영될 것입니다. + 암호화폐 선물을 받았습니다! + 새 지갑을 만들거나 기존 지갑을 가져와 선물을 받으세요 + %s 지갑으로는 선물을 받을 수 없습니다 + DApp 브라우저의 모든 열린 탭이 닫힙니다. + 모든 DApp 닫기? + %s 및 언제든지 복원할 수 있도록 항상 오프라인으로 보관하십시오. 백업 설정에서 이 작업을 수행할 수 있습니다. + 진행하기 전에 모든 지갑의 암호 구문을 적어 두십시오 + Google 드라이브에서 백업이 삭제됩니다 + 현재 귀하의 지갑 백업이 영구적으로 삭제됩니다! + 클라우드 백업을 삭제하시겠습니까? + 백업 삭제 + 현재 백업이 동기화되지 않았습니다. 이러한 업데이트를 검토하십시오. + 클라우드 백업 변경 사항 발견 + 업데이트 검토 + 삭제될 지갑의 암호 구문을 수동으로 적어두지 않았다면, 해당 지갑 및 모든 자산이 영구적이고 복구 불가능하게 손실됩니다. + 이 변경 사항을 적용하시겠습니까? + 문제 검토 + 현재 백업이 동기화되지 않았습니다. 문제를 검토하십시오. + 지갑 변경 사항이 클라우드 백업에 업데이트되지 않았습니다 + Google 계정에 올바르게 로그인하고 Pezkuwi Wallet에 Google Drive에 대한 접근 권한을 부여했는지 확인하십시오. + Google Drive 인증 실패 + 사용 가능한 Google Drive 저장 공간이 부족합니다. + 저장 공간 부족 + 안타깝지만, 기기에서 Google Play 서비스를 사용할 수 없어 Google Drive를 사용할 수 없습니다. Google Play 서비스를 얻어보십시오. + Google Play 서비스 찾을 수 없음 + 지갑을 Google Drive에 백업할 수 없습니다. Pezkuwi Wallet에 Google Drive를 사용할 수 있도록 설정하고, 충분한 저장 공간이 있는지 확인한 후 다시 시도하십시오. + Google Drive 오류 + 비밀번호가 올바른지 확인하고 다시 시도하십시오. + 비밀번호가 유효하지 않음 + 안타깝지만, 지갑을 복원할 백업을 찾을 수 없습니다. + 백업을 찾을 수 없음 + 진행하기 전에 지갑의 비밀번호 문구를 저장했는지 확인하십시오. + 클라우드 백업에서 지갑이 제거됩니다 + 백업 오류 검토 + 백업 업데이트 검토 + 백업 비밀번호 입력 + Google 드라이브에 지갑을 백업하려면 활성화하세요 + 마지막 동기화: %s 에 %s + Google 드라이브에 로그인 + Google 드라이브 문제 검토 + 백업 비활성화됨 + 백업이 동기화됨 + 백업 동기화 중... + 백업이 동기화되지 않음 + 새 지갑은 자동으로 클라우드 백업에 추가됩니다. 설정에서 클라우드 백업을 비활성화할 수 있습니다. + 지갑 변경 사항이 클라우드 백업에 업데이트됩니다 + 약관에 동의... + 계정 + 계정 주소 + 활성화됨 + 추가 + 위임 추가 + 네트워크 추가 + 주소 + 고급 + 전체 + 허용 + 금액 + 금액이 너무 낮습니다 + 금액이 너무 큽니다 + 적용됨 + 적용 + 다시 물어보기 + 지갑을 저장할 준비를 하세요! + 사용 가능: %s + 평균 + 잔액 + 보너스 + 호출 + 호출 데이터 + 호출 해시 + 취소 + 이 작업을 취소하시겠습니까? + 죄송합니다, 이 요청을 처리할 올바른 앱이 없습니다 + 이 링크를 열 수 없습니다 + 네트워크 수수료로 %s를 지불해야 하므로 최대 %s를 사용할 수 있습니다. + 체인 + 변경 + 비밀번호 변경 + 미래에 자동으로 계속 + 네트워크 선택 + 지우기 + 닫기 + 이 화면을 닫으시겠습니까?\n변경 사항이 적용되지 않습니다. + 클라우드 백업 + 완료됨 + 완료됨 (%s) + 확인 + 확인 + 확실합니까? + 확인됨 + %d ms + 연결 중... + 연결을 확인하거나 나중에 다시 시도해주세요 + 연결 실패 + 계속 + 클립보드에 복사됨 + 주소 복사 + 호출 데이터 복사 + 해시 복사 + ID 복사 + 키페어 암호화 유형 + 날짜 + %s 및 %s + 삭제 + 입금자 + 세부 사항 + 비활성화됨 + 연결 끊기 + 앱을 닫지 마세요! + 완료 + Pezkuwi는 오류를 방지하기 위해 거래를 사전에 시뮬레이션합니다. 이 시뮬레이션이 성공하지 못했습니다. 나중에 다시 시도하거나 더 높은 금액으로 시도하십시오. 문제가 지속되면, 설정의 Pezkuwi Wallet 지원팀에 문의하십시오. + 거래 시뮬레이션 실패 + 편집 + %s (+%s 더) + 이메일 앱 선택 + 활성화 + 주소 입력... + 금액 입력... + 세부사항 입력 + 다른 금액 입력 + 비밀번호 입력 + 오류 + 토큰이 부족합니다 + 이벤트 + EVM + EVM 주소 + 이 작업 후 계정의 전체 잔액이 최소 잔액보다 낮아지면 블록체인에서 계정이 제거됩니다 + 작업이 계정을 제거합니다 + 만료됨 + 탐색 + 실패 + 예상 네트워크 수수료 %s가 기본 네트워크 수수료(%s)보다 훨씬 높습니다. 이는 일시적인 네트워크 혼잡으로 인해 발생할 수 있습니다. 더 낮은 네트워크 수수료를 기다리기 위해 새로 고칠 수 있습니다. + 네트워크 수수료가 너무 높습니다 + 수수료: %s + 정렬 기준: + 필터 + 더 알아보기 + 비밀번호를 잊으셨나요? + + 매 %s일 + + 매일 + 매일 + 전체 세부 정보 + %s 받기 + 선물 + 알겠습니다 + 거버넌스 + 16진수 문자열 + 숨기기 + + %d 시간 + + 작동 방식 + 이해했습니다 + 정보 + QR 코드가 유효하지 않습니다 + 유효하지 않은 QR 코드 + 더 알아보기 + 더 알아보기 + Ledger + %s 남음 + 지갑 관리 + 최대 + %s 최대 + + %d 분 + + %s 계정이 없습니다 + 수정 + 모듈 + 이름 + 네트워크 + Ethereum + %s은(는) 지원되지 않습니다 + Polkadot + 네트워크 + + 네트워크들 + + 다음 + 아니요 + 기기에서 파일 가져오기 애플리케이션을 찾을 수 없습니다. 설치 후 다시 시도하세요 + 이 의도를 처리할 적절한 애플리케이션을 기기에서 찾을 수 없습니다 + 변경 사항 없음 + 귀하의 비밀번호 문구를 표시할 예정입니다. 아무도 귀하의 화면을 볼 수 없도록 하고 스크린샷을 찍지 마세요 — 제3의 악성 코드에 의해 수집될 수 있습니다 + 없음 + 이용 불가 + 죄송합니다. 네트워크 수수료를 지불할 충분한 자금이 없습니다. + 잔액 부족 + 네트워크 수수료 %s를 지불할 잔액이 부족합니다. 현재 잔액은 %s입니다 + 지금은 안 함 + + OK + 확인, 뒤로 + + 진행 중 + 선택 사항 + 옵션 선택 + 비밀번호 문구 + 붙여넣기 + / 년 + %s / 년 + 년마다 + %% + 이 화면을 사용하려면 요청된 권한이 필요합니다. 설정에서 활성화하세요. + 권한 거부됨 + 이 화면을 사용하려면 요청된 권한이 필요합니다. + 권한 필요 + 가격 + 개인정보 보호정책 + 계속 + 프록시 입금 + 접근 권한 취소 + 푸시 알림 + 더 읽기 + 추천 + 갱신 수수료 + 거절 + 제거 + 필수 + 리셋 + 재시도 + 문제가 발생했습니다. 다시 시도해주세요. + 취소 + 저장 + QR 코드 스캔 + 검색 + 검색 결과가 여기에 표시됩니다 + 검색 결과: %d + + + %d초 + + 비밀 경로 도출 + 모두 보기 + 토큰 선택 + 설정 + 공유 + 호출 데이터 공유 + 해시 공유 + 표시 + 로그인 + 서명 요청 + 서명자 + 서명이 유효하지 않습니다 + 건너뛰기 + 프로세스 건너뛰기 + 일부 거래 전송 중 오류가 발생했습니다. 다시 시도하시겠습니까? + 일부 거래 전송 실패 + 문제가 발생했습니다 + 정렬 기준 + 상태 + Substrate + Substrate 주소 + 누르면 나타납니다 + 이용 약관 + Testnet + 남은 시간 + 제목 + 설정 열기 + 잔액이 너무 적습니다 + 총합 + 총 수수료 + 거래 ID + 트랜잭션이 제출되었습니다 + 다시 시도하십시오 + 유형 + 다른 입력 값으로 다시 시도하십시오. 오류가 다시 발생하면 지원팀에 연락하십시오. + 알 수 없음 + + %s 지원되지 않음 + + 무제한 + 업데이트 + 사용 + 최대 사용 + 수신자는 유효한 %s 주소여야 합니다 + 잘못된 수신자 + 보기 + 기다리는 중 + 지갑 + 경고 + + 당신의 선물 + 금액은 0보다 커야 합니다 + 백업 과정 중 생성한 비밀번호를 입력하십시오 + 현재 백업 비밀번호를 입력하십시오 + 확인하면 계정에서 토큰이 전송됩니다 + 단어 선택... + 잘못된 비밀번호입니다. 단어 순서를 다시 확인하십시오 + 국민투표 + 투표 + 트랙 + 노드는 이미 추가되었습니다. 다른 노드를 시도하십시오. + 노드와 연결할 수 없습니다. 다른 노드를 시도하십시오. + 안타깝게도, 네트워크가 지원되지 않습니다. 다음 중 하나를 시도해 보세요: %s. + %s 삭제를 확인하세요. + 네트워크를 삭제할까요? + 연결을 확인하거나 나중에 다시 시도해 주세요 + 커스텀 + 기본값 + 네트워크 + 연결 추가 + QR 코드 스캔 + 백업에서 문제가 확인되었습니다. 현재 백업을 삭제하고 새로운 백업을 만들 수 있습니다. 계속 진행하기 전에 %s. + 모든 지갑의 비밀번호 문구를 저장했는지 확인하세요 + 백업이 발견되었으나 비어 있거나 손상되었습니다 + 향후, 백업 암호 없이는 Cloud Backup에서 지갑을 복구할 수 없습니다.\n%s + 이 암호는 복구할 수 없습니다. + 백업 암호를 기억하세요 + 암호 확인 + 백업 암호 + 문자 + 최소 8자 + 숫자 + 암호가 일치합니다 + 백업에 접근할 수 있는 암호를 입력하세요. 암호는 복구할 수 없으므로 반드시 기억하세요! + 백업 비밀번호 생성 + 입력한 체인 ID가 RPC URL의 네트워크와 일치하지 않습니다. + 유효하지 않은 체인 ID + 개인 크라우드론은 아직 지원되지 않습니다. + 개인 크라우드론 + 크라우드론에 대해서 + 직접 + Acala에 대한 다양한 기여에 대해 더 알아보기 + Liquid + 활성 (%s) + 이용 약관에 동의합니다 + Pezkuwi Wallet 보너스 (%s) + Astar 추천 코드는 유효한 Polkadot 주소여야 합니다 + 선택한 금액을 기여할 수 없습니다. 결과적으로 모금된 금액이 크라우드론 한도를 초과할 것입니다. 허용된 최대 기여는 %s입니다. + 선택한 크라우드론에 기여할 수 없습니다. 이미 한도에 도달했습니다. + 크라우드론 상한 초과 + 크라우드론에 기여 + 기여 + 당신의 기여: %s + Liquid + Parallel + 여기에 당신의 기여가 나타납니다 + 여기서 %s 후 반환됩니다 + 파라체인이 반환할 예정 + %s (via %s) + 크라우드론 + 특별 보너스 받기 + 크라우드론이 여기에 표시됩니다 + 선택된 크라우드론이 이미 종료되었으므로 기여 불가합니다. + 크라우드론 종료됨 + 추천 코드를 입력하세요 + 크라우드론 정보 + 크라우드론 %s 배우기 + %s의 크라우드론 웹사이트 + 임대 기간 + 파라체인을 선택하여 %s을(를) 기여하세요. 기여한 토큰을 돌려받게 되며, 파라체인이 슬롯에 당선되면 경매 종료 후 보상을 받게 됩니다. + 기여를 위해 지갑에 %s 계정을 추가해야 합니다. + 보너스 적용 + 추천 코드가 없으시면 Pezkuwi 추천 코드를 적용하여 기여에 대한 보너스를 받을 수 있습니다 + 보너스가 적용되지 않았습니다 + Moonbeam crowdloan은 SR25519 또는 ED25519 암호화 유형 계정만 지원합니다. 다른 계정을 사용하여 기여하는 것을 고려해 주세요 + 이 계정으로 기여할 수 없습니다 + Moonbeam 계정을 지갑에 추가하여 Moonbeam crowdloan에 참여해야 합니다 + Moonbeam 계정이 없습니다 + 이 crowdloan은 귀하의 위치에서 사용할 수 없습니다. + 귀하의 지역은 지원되지 않습니다 + %s 보상 목적지 + 동의 제출 + 진행하려면 블록체인에서 이용 약관 및 조건에 동의해야 합니다. 이는 모든 후속 Moonbeam 기여에 대해 한 번만 수행하면 됩니다 + 이용 약관을 읽고 동의했습니다 + 모금됨 + 추천 코드 + 추천 코드가 유효하지 않습니다. 다른 코드를 시도해 주세요 + %s의 이용 약관 + 기여할 수 있는 최소 금액은 %s입니다. + 기여 금액이 너무 작습니다 + 임대 기간이 끝난 후 당신의 %s 토큰이 반환됩니다. + 당신의 기여 + 모금액: %s 중 %s + 입력한 RPC URL은 Pezkuwi에 %s 사용자 정의 네트워크로 이미 존재합니다. 정말로 수정하시겠습니까? + https://networkscan.io + 블록 탐색기 URL (선택 사항) + 012345 + 체인 ID + TOKEN + 통화 기호 + 네트워크 이름 + 노드 추가 + 사용자 정의 노드 추가 + 세부사항 입력 + 저장 + 사용자 정의 노드 수정 + 이름 + 노드 이름 + wss:// + 노드 URL + RPC URL + 사용 중에 주소 접근을 허용한 DApps + \"%s\" DApp이 인증된 목록에서 제거됩니다 + 인증된 목록에서 제거하시겠습니까? + 인증된 DApps + 카탈로그 + 애플리케이션을 신뢰하는 경우 이 요청을 승인하십시오 + \"%s\"이(가) 계정 주소에 접근을 허용하시겠습니까? + 애플리케이션을 신뢰하는 경우 이 요청을 승인하십시오.\n거래 세부 정보를 확인하십시오. + DApp + DApps + %d DApps + 즐겨찾기 + 즐겨찾기 + 즐겨찾기에 추가 + “%s” DApp이(가) 즐겨찾기에서 제거됩니다. + 즐겨찾기에서 제거하시겠습니까? + 여기에 DApps 목록이 표시됩니다. + 즐겨찾기에 추가 + 데스크탑 모드 + 즐겨찾기에서 제거 + 페이지 설정 + 알겠어요, 돌아갈게요 + Pezkuwi Wallet은 이 웹사이트가 귀하의 계정과 토큰의 보안을 위협할 수 있다고 믿습니다. + 피싱 감지됨 + 이름으로 검색하거나 URL을 입력하세요 + 제네시스 해시 %s가 포함된 지원되지 않는 체인 + 작업이 정확한지 확인하세요 + 요청된 작업 서명에 실패했습니다 + 어쨌든 열기 + 악성 DApp은 모든 자금을 인출할 수 있습니다. DApp을 사용하기 전, 권한을 부여하기 전 또는 자금을 송금하기 전에 항상 철저한 조사를 하십시오.\n\n누군가 이 DApp을 방문하라고 재촉할 경우, 그것은 사기일 가능성이 큽니다. 의심스러울 경우, 노바 월렛 지원팀에 연락하십시오: %s. + 경고! DApp이 알 수 없습니다 + 체인을 찾을 수 없음 + 링크의 도메인 %s이(가) 허용되지 않습니다 + 거버넌스 유형이 지정되지 않음 + 거버넌스 유형이 지원되지 않음 + 잘못된 암호화 유형 + 잘못된 파생 경로 + 잘못된 니모닉 + 잘못된 URL + 입력한 RPC URL은 Pezkuwi에 %s 네트워크로 이미 존재합니다. + 기본 알림 채널 + +%d + 주소나 이름으로 검색 + 주소 형식이 잘못되었습니다. 주소가 올바른 네트워크에 속하는지 확인하세요 + 검색 결과: %d + 프록시 및 다중 서명 계정이 자동으로 감지 및 구성되었습니다. 설정에서 언제든지 관리할 수 있습니다. + 지갑 목록이 업데이트되었습니다 + 전체 시간 동안 투표됨 + Delegate + 모든 계정 + 개인 + 조직 + 위임을 취소하면 위임 해제 기간이 시작됩니다 + 귀하의 투표는 귀하의 Delegate의 투표에 자동으로 동반 투표됩니다 + Delegate 정보 + 개인 + 조직 + 위임된 투표 + 위임 + 위임 수정 + 자신에게 위임할 수 없습니다. 다른 주소를 선택하세요 + 자신에게 위임할 수 없음 + Pezkuwi 사용자가 귀하를 더 잘 알 수 있도록 자신에 대해 더 알려주세요 + Delegate가 되셨습니까? + 자신을 설명하세요 + %s 트랙에 따라서 + 최근 %s 투표됨 + %s을 통한 귀하의 투표 + 귀하의 투표: %s 통해 %s + 투표 제거 + 위임 취소 + 위임 해제 기간이 만료된 후 토큰을 잠금 해제해야 합니다. + 위임된 투표 + 위임 + 마지막으로 투표한 %s + 트랙 + 모두 선택 + 최소 1개의 트랙을 선택... + 위임할 수 있는 트랙이 없습니다 + Fellowship + Governance + Treasury + Unstaking 기간 + 더 이상 유효하지 않음 + 귀하의 위임 + 귀하의 위임들 + 표시 + 백업 삭제 중... + 귀하의 백업 비밀번호가 이전에 업데이트되었습니다. Cloud Backup을 계속 사용하려면 %s + 새 백업 비밀번호를 입력하십시오. + 백업 비밀번호가 변경되었습니다 + 비활성화된 네트워크의 트랜잭션을 서명할 수 없습니다. 설정에서 %s를 활성화하고 다시 시도하세요. + %s가 비활성화되었습니다 + 이미 이 계정에 위임하고 있습니다: %s + 위임이 이미 존재합니다 + (BTC/ETH 호환 가능) + ECDSA + ed25519 (대안) + Edwards + 백업 비밀번호 + 호출 데이터 입력 + 수수료를 지불할 충분한 토큰이 없습니다 + 컨트랙트 + 컨트랙트 호출 + 기능 + 지갑 복구 + %s 모든 지갑이 Google Drive에 안전하게 백업됩니다. + 귀하의 지갑을 복구하시겠습니까? + 기존 클라우드 백업 발견 + 복원 JSON 다운로드 + 비밀번호 확인 + 비밀번호가 일치하지 않습니다 + 비밀번호 설정 + 네트워크: %s\nMnemonic: %s\n파생 경로: %s + 네트워크: %s\nMnemonic: %s + 수수료 계산이 완료될 때까지 기다려주세요 + 수수료 계산 진행 중 + 직불카드 관리 + %s 토큰 판매 + %s Staking을 위한 대리 추가 + 스왑 세부 정보 + 최대: + 지불 금액 + 수령 금액 + 토큰 선택 + 카드에 %s 충전 + support@pezkuwichain.io에 문의하세요. 카드 발급에 사용된 이메일 주소를 포함하세요. + 지원에 문의 + 수령됨 + 생성됨: %s + 금액 입력 + 최소 선물 금액은 %s입니다 + 회수됨 + 증여할 토큰 선택 + 청구 시 네트워크 수수료 + 선물 생성 + Pezkuwi에서 빠르고 쉽게 안전하게 선물을 보내세요 + 누구와도 언제 어디서나 암호화폐 선물 공유 + 생성한 선물 + %s 선물에 대한 네트워크 선택 + 선물 금액 입력 + %s를 링크로 공유하고 Pezkuwi로 초대하세요 + 선물을 직접 공유 + %s, 그리고 이 디바이스에서 언제든지 회수 가능 + 선물이 즉시 사용 가능합니다 + 거버넌스 알림 채널 + + 최소한 %d개의 트랙을 선택해야 합니다 + + 잠금 해제 + 이 스왑을 실행하면 상당한 슬리패지와 재정적 손실이 발생할 것입니다. 거래 크기를 줄이거나 거래를 여러 개로 나누는 것을 고려하세요. + 높은 가격 영향이 감지됨 (%s) + 역사 + 이메일 + 법적 이름 + Element 이름 + 신원 + + 제공된 JSON 파일은 다른 네트워크를 위해 생성되었습니다. + 입력한 내용이 유효한 JSON 형식인지 확인하십시오. + 복원 JSON이 유효하지 않습니다 + 비밀번호의 정확성을 확인하고 다시 시도하십시오. + Keystore 복호화 실패 + JSON 붙여넣기 + 지원되지 않는 암호화 유형 + Ethereum 암호화 네트워크에 Substrate 비밀이 있는 계정을 가져올 수 없습니다 + Substrate 암호화 네트워크에 Ethereum 비밀이 있는 계정을 가져올 수 없습니다 + 입력하신 니모닉이 유효하지 않습니다 + 입력한 내용이 64개의 16진수 기호를 포함하는지 확인하십시오. + 시드가 유효하지 않습니다 + 안타깝게도 귀하의 지갑이 포함된 백업을 찾지 못했습니다. + 백업을 찾을 수 없음 + Google 드라이브에서 지갑 복원 + 12, 15, 18, 21 또는 24개의 단어로 된 구문 사용 + 지갑을 가져올 방법 선택 + Watch-only + 당신이 구축하고 있는 네트워크의 모든 기능을 Pezkuwi Wallet에 통합하여 모든 사람이 접근할 수 있게 만드세요. + 네트워크 통합 + Polkadot을 위해 개발 중인가요? + 제공된 호출 데이터가 유효하지 않거나 형식이 잘못되었습니다. 올바른지 확인하고 다시 시도하십시오. + 이 호출 데이터는 호출 해시 %s의 다른 작업에 대한 것입니다 + 잘못된 호출 데이터 + 프록시 주소는 유효한 %s 주소여야 합니다 + 유효하지 않은 프록시 주소 + 입력한 통화 기호(%1$s)가 네트워크(%2$s)와 일치하지 않습니다. 올바른 통화 기호를 사용하시겠습니까? + 유효하지 않은 통화 기호 + QR을 해독할 수 없습니다 + QR 코드 + 갤러리에서 업로드 + JSON 파일 내보내기 + 언어 + Ledger는 %s을(를) 지원하지 않습니다 + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + 기기에서 작업이 취소되었습니다. Ledger를 잠금 해제했는지 확인하세요. + 작업이 취소되었습니다 + Ledger 기기에서 %s 앱을 여세요 + %s 앱이 실행되지 않았습니다 + 기기에서 작업이 오류로 완료되었습니다. 나중에 다시 시도해 주세요. + Ledger 작업 실패 + %s의 확인 버튼을 길게 눌러 거래를 승인하세요 + 더 많은 계정을 로드 + 검토하고 승인하기 + 계정 %s + 계정 찾을 수 없음 + Ledger 장치가 EVM 주소를 지원하지 않는 구식 Generic 앱을 사용 중입니다. Ledger Live를 통해 업데이트하세요. + Ledger Generic 앱 업데이트 + 거래를 승인하려면 %s에서 두 버튼을 모두 누르세요 + Ledger Live 앱을 사용하여 %s을(를) 업데이트하세요 + 메타데이터가 오래되었습니다 + Ledger는 임의 메시지 서명을 지원하지 않습니다 — 거래만 지원 + 현재 승인 중인 작업에 맞는 Ledger 기기를 선택했는지 확인하세요 + 보안을 위해 생성된 작업은 %s 동안만 유효합니다. 다시 시도하고 Ledger로 승인하세요. + 거래가 만료되었습니다 + 거래는 %s 동안 유효합니다 + Ledger가 이 거래를 지원하지 않습니다. + 거래가 지원되지 않습니다 + %s의 두 버튼을 눌러 주소를 승인하세요 + %s의 확인 버튼을 눌러 주소를 승인하세요 + %s의 두 버튼을 눌러 주소를 승인하세요 + %s의 확인 버튼을 눌러 주소를 승인하세요 + 이 지갑은 Ledger와 연동되어 있습니다. Pezkuwi는 원하는 모든 작업을 생성하는 데 도움을 줄 것이며, Ledger를 사용하여 서명하라는 요청을 받을 것입니다. + 지갑에 추가할 계정을 선택하세요 + 네트워크 정보를 불러오는 중... + 거래 세부 정보 로딩 중... + 백업을 찾고 있습니다... + 출금 거래가 전송되었습니다 + 토큰 추가 + 백업 관리 + 네트워크 삭제 + 네트워크 편집 + 추가된 네트워크 관리 + 해당 네트워크에서 자산 화면에서 토큰 잔액을 볼 수 없습니다. + 네트워크 삭제? + 노드 삭제 + 노드 편집 + 추가된 노드 관리 + 노드 \\\"%s\\"이(가) 삭제됩니다 + 노드를 삭제하시겠습니까? + 사용자 정의 키 + 기본 키 + 이 정보를 누구와도 공유하지 마십시오 - 공유하면 모든 자산을 영구적이고 돌이킬 수 없이 잃게 됩니다 + 사용자 정의 키가 있는 계정 + 기본 계정 + %s, +%d 개의 다른 계정 + 기본 키가 있는 계정 + 백업할 키를 선택하세요 + 백업할 지갑을 선택하세요 + 백업을 보기 전에 다음 사항을 주의 깊게 읽어주세요 + 비밀번호 구절을 공유하지 마세요! + 최대 3.95%%의 수수료로 최고의 가격 제공 + 아무도 화면을 볼 수 없도록 하고\n스크린샷을 찍지 마세요 + 아무에게도 %s 하지 마세요 + 공유하지 마세요 + 다른 것을 시도해 보세요. + 유효하지 않은 니모닉 구절입니다. 단어 순서를 다시 한번 확인하세요 + %d 개 이상의 지갑을 선택할 수 없습니다 + + 최소 %d 개의 지갑을 선택하세요 + + 이 거래는 이미 거부되었거나 실행되었습니다. + 이 거래를 수행할 수 없음 + %s가 동일 작업을 이미 시작했으며, 다른 서명자의 서명을 기다리고 있습니다. + 작업이 이미 존재합니다 + 데빗 카드를 관리하려면 다른 종류의 지갑으로 전환하십시오. + 다중 서명에는 데빗 카드가 지원되지 않음 + 다중 서명 예치금 + 예치금은 다중 서명 작업이 실행되거나 거부될 때까지 입금자 계정에 잠겨 있습니다. + 작업이 올바른지 확인하십시오 + 선물을 생성하거나 관리하려면 다른 유형의 지갑으로 전환하세요. + 멀티시그 지갑에서는 선물하기가 지원되지 않습니다 + %s에 의해 서명 및 실행되었습니다. + ✅ 멀티시그 거래 실행 완료 + %s에서 %s. + ✍🏻 귀하의 서명이 요청되었습니다 + %s에 의해 시작됨. + 지갑: %s + %s에 의해 서명됨 + %d개의 서명 중 %d개가 수집되었습니다. + %s를 대신하여. + %s에 의해 거부됨. + ❌ 멀티시그 거래 거부됨 + 다른 사람을 대신하여 + %s: %s + 승인 + 승인 및 실행 + 세부 정보를 보려면 호출 데이터를 입력하십시오 + 거래가 이미 실행되었거나 거부되었습니다. + 서명이 종료됨 + 거부 + 서명자 (%d 중 %d) + 모두 묶음 (오류 시 되돌림) + 묶음 (오류 발생 시까지 실행) + 강제 묶음 (오류 무시) + 귀하에 의해 생성됨 + 아직 거래가 없습니다.\n서명 요청이 여기에 표시됩니다 + 서명 중 (%s 중 %s) + 귀하에 의해 서명됨 + 알 수 없는 작업 + 서명할 거래 + 다른 사람을 대신하여: + 서명자에 의해 서명됨 + 멀티시그 거래 실행 완료 + 귀하의 서명이 요청되었습니다 + 멀티시그 거래 거부됨 + 암호화폐를 법정 화폐로 판매하려면 다른 종류의 지갑으로 전환하십시오. + 다중 서명에는 판매가 지원되지 않음 + 서명자: + %s는 %s의 다중 서명 예치금을 마련할 충분한 잔액이 없습니다. 잔액에 %s를 더 추가해야 합니다 + %s는 %s의 네트워크 수수료를 지불하고 %s의 다중 서명 예치금을 마련할 충분한 잔액이 없습니다. 잔액에 %s를 더 추가해야 합니다 + %s는 이 거래 수수료를 지불하고 최소 네트워크 잔액 이상을 유지하기 위해 최소 %s가 필요합니다. 현재 잔액: %s + %s는 %s의 네트워크 수수료를 지불할 충분한 잔액이 없습니다. 잔액에 %s를 더 추가해야 합니다 + 멀티시그 지갑은 임의 메시지 서명을 지원하지 않으며, 거래만 가능합니다 + 거래가 거부됩니다. 멀티시그 예치금은 %s에게 반환됩니다. + 멀티시그 거래는 %s에 의해 시작됩니다. 시작자는 네트워크 수수료를 지불하고 멀티시그 예치를 예약하며, 거래가 실행되면 해제됩니다. + 다중 서명 거래 + 다른 서명자가 이제 거래를 확인할 수 있습니다.\n%s에서 상태를 추적할 수 있습니다. + 서명할 거래 + 다중 서명 거래 생성됨 + 세부 사항 보기 + 체인 상의 초기 정보(호출 데이터)가 없는 거래가 거부되었습니다 + %s에서 %s.\n더 이상의 추가 작업이 필요 없습니다. + 멀티시그 거래 실행 완료 + %1$s → %2$s + %s에서 %s.\n거부됨: %s.\n더 이상의 추가 작업이 필요 없습니다. + 멀티시그 거래 거부됨 + 이 지갑의 거래는 여러 서명자의 승인이 필요합니다. 귀하의 계정은 서명자 중 하나입니다: + 다른 서명자: + 임계값 %d 중 %d + 멀티시그 거래 알림 채널 + 설정에서 활성화 + 서명 요청, 새로운 서명 및 완료된 거래에 대한 알림을 받아 항상 제어할 수 있습니다. 설정에서 언제든지 관리하세요. + 멀티시그 푸시 알림이 여기에 있습니다! + 이 노드가 이미 존재합니다 + 네트워크 수수료 + 노드 주소 + 노드 정보 + 추가됨 + 사용자 정의 노드 + 기본 노드 + 기본 + 연결 중… + 컬렉션 + 제작자 + %s 당 %s + %s 개의 %s 단위 + #%s %s의 에디션 + 무제한 시리즈 + 소유자 + 가격 미정 + 당신의 NFT + Pezkuwi에 멀티시그 지갑을 추가하거나 선택하지 않았습니다. 멀티시그 푸시 알림을 받으려면 최소 한 개를 추가하고 선택하세요. + 멀티시그 지갑 없음 + 입력된 URL이 \\\"%s\\\" 노드로 이미 존재합니다. + 이 노드가 이미 존재합니다 + 입력된 노드 URL이 응답하지 않거나 잘못된 형식을 포함하고 있습니다. URL 형식은 \"wss://\"로 시작해야 합니다. + 노드 오류 + 입력된 URL이 %1$s의 노드에 해당되지 않습니다.\n유효한 %1$s 노드의 URL을 입력하십시오. + 잘못된 네트워크 + 보상 청구 + 귀하의 토큰이 다시 Stake에 추가됩니다 + 직접 + 풀 Staking 정보 + 귀하의 보상 (%s) 도 청구되어 귀하의 사용 가능 잔액에 추가될 것입니다 + + 풀이 파괴 상태에 있어 지정된 작업을 수행할 수 없습니다. 곧 닫힐 예정입니다. + 풀 파괴 중 + 현재 귀하의 풀에 대한 Unstaking 대기열에 빈 자리가 없습니다. %s 후에 다시 시도해 주세요 + 너무 많은 사람들이 귀하의 풀에서 Unstaking 하고 있습니다 + 귀하의 풀 + 귀하의 풀 (#%s) + 계정 생성 + 새 지갑 생성 + 개인정보 처리방침 + 계정 가져오기 + 이미 지갑이 있습니다 + 계속 진행하면, 당신은 우리의\n%1$s와 %2$s에 동의하는 것입니다 + 이용 약관 + 교환 + 당신의 코레이터 중 하나가 보상을 생성하지 않습니다 + 당신의 코레이터 중 하나가 현재 라운드에서 선택되지 않았습니다 + 당신의 %s의 언스테이킹 기간이 지났습니다. 잊지 말고 토큰을 상환하세요 + 이 코레이터와 함께 스테이킹할 수 없습니다 + 코레이터 변경 + 이 코레이터에 스테이크를 추가할 수 없습니다 + 코레이터 관리 + 선택한 콜레이터가 Staking에 참여를 중단하려는 의사를 표명했습니다. + 모든 토큰을 Unstaking 중인 콜레이터에 Stake를 추가할 수 없습니다. + 귀하의 Stake가 이 콜레이터의 최소 Stake(%s)보다 적습니다. + 남은 Staking 잔액이 최소 네트워크 값(%s) 이하로 떨어지며 Unstaking 금액에 추가됩니다. + 인증되지 않았습니다. 다시 시도해 주세요. + 인증을 위해 생체 인식을 사용하십시오. + Pezkuwi Wallet은 비인가 사용자가 앱에 접근하지 못하도록 생체 인식을 사용합니다. + 생체 인식 + PIN 코드가 성공적으로 변경되었습니다. + PIN 코드를 확인하십시오. + PIN 코드를 생성하십시오. + PIN 코드를 입력하십시오. + PIN 코드를 설정하십시오. + 풀이 최대 회원 수에 도달하여 가입할 수 없습니다 + 풀이 가득 찼습니다 + 열려 있지 않은 풀에는 가입할 수 없습니다. 풀 소유자에게 연락해 주세요. + 풀은 열려 있지 않습니다 + 더 이상 동일한 계정에서 직접 Staking과 Pool Staking을 동시에 사용할 수 없습니다. Pool Staking을 관리하려면 먼저 직접 Staking에서 토큰을 Unstake해야 합니다. + Pool 작업을 사용할 수 없습니다 + 인기 있는 + 네트워크 수동 추가 + 네트워크 목록을 로드 중... + 네트워크 이름으로 검색 + 네트워크 추가 + %s 에 %s + 1D + 모두 + 1M + %s (%s) + %s 가격 + 1W + 1Y + 전체 기간 + 지난달 + 오늘 + 지난주 + 지난해 + 계정 + 지갑 + 언어 + PIN 코드 변경 + 숨겨진 잔액으로 앱 열기 + PIN으로 승인 + 안전 모드 + 설정 + 데빗 카드를 관리하려면 다른 월렛으로 전환해주세요. Polkadot 네트워크에서 사용하세요. + 이 프록시드 월렛에서는 데빗 카드가 지원되지 않습니다 + 이 계정은 다음 계정에 거래를 수행할 수 있는 권한을 부여했습니다: + Staking 작업 + 위임된 계정 %s에 네트워크 수수료 %s를 결제할 충분한 잔액이 없습니다. 결제 가능한 잔액: %s + 프록시 지갑은 임의 메시지 서명을 지원하지 않습니다 — 오직 거래만 + %1$s는 %2$s를 위임하지 않았습니다 + %1$s는 %2$s를 오직 %3$s에만 위임했습니다 + 이런! 권한이 부족합니다 + 트랜잭션은 %s이(가) 위임된 계정으로 시작됩니다. 네트워크 수수료는 위임된 계정이 부담합니다. + 이것은 위임된 (프로시) 계정입니다 + %s 프로시 + 대리인이 투표했습니다 + 새로운 국민투표 + 국민투표 업데이트 + %s 국민투표 #%s이(가) 이제 활성화되었습니다! + 🗳️ 새로운 국민투표 + 모든 새로운 기능을 얻으려면 Pezkuwi Wallet v%s을(를) 다운로드하세요! + Pezkuwi Wallet의 새로운 업데이트가 가능합니다! + %s 국민투표 #%s이(가) 종료되고 승인되었습니다 🎉 + ✅ 국민투표 승인됨! + %s 국민투표 #%s의 상태가 %s에서 %s로 변경되었습니다 + %s 국민투표 #%s가 종료되고 거부되었습니다! + ❌ 국민투표 거부됨! + 🗳️ 국민투표 상태가 변경되었습니다 + %s 국민투표 #%s의 상태가 %s로 변경되었습니다 + Pezkuwi 공지사항 + 잔액 + 알림 활성화 + 다중 서명 거래 + 지갑을 선택하지 않았기 때문에 지갑 활동(잔액, Staking)에 대한 알림을 받지 못합니다 + 기타 + 받은 토큰 + 보낸 토큰 + Staking 보상 + 지갑 + ⭐🩵 새 보상 %s + %s Staking으로부터 %s을(를) 받았습니다 + ⭐🩵 새 보상 + Pezkuwi Wallet • 지금 + Kusama Staking으로부터 +0.6068 KSM ($20.35)을(를) 받았습니다 + %s에서 %s을(를) 받았습니다 + ⬇️ 받은 토큰 + ⬇️ 받은 토큰 %s + %s 네트워크의 %s에게 %s을(를) 보냈습니다 + 💸 보낸 토큰 + 💸 보낸 토큰 %s + 최대 %d개의 월렛을 선택하여 활동이 있을 때 알림 받기 + 푸시 알림 활성화 + 지갑 작업, 거버넌스 업데이트, Staking 활동 및 보안에 대한 알림을 받아 항상 최신 정보를 유지하세요 + 푸시 알림을 활성화함으로써 귀하는 우리의 %s 및 %s에 동의하게 됩니다 + 설정 탭에서 알림 설정에 접근하여 나중에 다시 시도해주세요 + 중요한 것을 놓치지 마세요! + %s 수신을 위한 네트워크 선택 + 주소 복사 + 이 선물을 회수하면 공유된 링크가 비활성화되고 토큰이 지갑으로 반환됩니다. 계속하시겠습니까? + %s 선물을 회수하시겠습니까? + 선물을 성공적으로 회수했습니다 + json을 붙여넣거나 파일을 업로드하세요... + 파일 업로드 + JSON 복원 + Mnemonic passphrase + Raw seed + 소스 유형 + 모든 국민투표 + 표시: + 투표하지 않음 + 투표함 + 적용된 필터로 국민투표가 없습니다 + 국민투표 정보는 시작되면 여기에 나타납니다 + 입력한 제목 또는 ID로 국민투표를 찾을 수 없습니다 + 국민투표 제목 또는 ID로 검색 + %d 개 국민투표 + 국민투표에 인공지능 요약으로 투표하세요. 빠르고 간편하게! + 국민투표 + 국민투표를 찾을 수 없습니다 + 기권 투표는 0.1x 확신도로만 가능합니다. 0.1x 확신도로 투표하시겠습니까? + 확신도 업데이트 + 기권 투표 + 찬성: %s + Pezkuwi DApp 브라우저 사용 + 제안자만 이 설명과 제목을 수정할 수 있습니다. 제안자의 계정을 소유하고 있다면, Polkassembly에 방문하여 제안에 대한 정보를 입력하세요. + 요청된 금액 + 일정 + 당신의 투표: + 승인 곡선 + 수혜자 + 예치금 + 유권자 + 미리 보기에는 너무 깁니다 + 파라미터 JSON + 제안자 + 지원 곡선 + 투표율 + 투표 기준 + 위치: %s/%s + 투표하려면 지갑에 %s 계정을 추가해야 합니다 + 국민투표 %s + 반대: %s + 반대 투표 + %s 명의 투표자 %s + 찬성 투표 + Staking + 승인됨 + 취소됨 + 결정 중 + %s 후 결정 중 + 실행됨 + 대기 중 + 대기 중 (%s/%s) + 제거됨 + 통과하지 않음 + 통과함 + 준비 중 + 거부됨 + %s 후 승인됨 + %s 후 실행됨 + %s 후 타임아웃 + %s 후 거부됨 + 타임아웃 + 예치금 대기 중 + 기준: %s/%s + 투표 결과: 승인됨 + 취소됨 + 생성됨 + 투표 중: 결정 중 + 실행됨 + 투표 중: 대기 중 + 삭제됨 + 투표 중: 미통과 + 투표 중: 통과 + 투표 중: 준비 중 + 투표 결과: 거부됨 + 시간 초과 + 투표 중: 보증금 대기 중 + 통과하려면: %s + 크라우드론 + 재무부: 대규모 지출 + 재무부: 큰 팁 + 펠로우십: 관리 + 거버넌스: 등록 기관 + 거버넌스: 임대 + 재무부: 중간 지출 + 거버넌스: 취소 + 거버넌스: 삭제 + 주요 안건 + 재무부: 소액 지출 + 재무부: 작은 팁 + 재무부: 모든 지출 + %s 동안 잠금 유지 + 잠금 해제 가능 + 기권 + 찬성 + 모든 잠금 재사용: %s + 거버넌스 잠금 재사용: %s + 거버넌스 잠금 + 잠금 기간 + 반대 + 잠금 기간을 늘려서 투표 수 곱하기 + %s에 대해 투표 + 잠금 기간 후 토큰 잠금 해제 잊지 마세요 + 투표된 국민투표 + %s 표 + %s \u00d7 %sx + 투표자가 여기에 나타날 것입니다 + %s 투표 + Fellowship: whitelist + 당신의 투표: %s 투표 + 국민투표가 완료되었으며 투표가 종료되었습니다 + 국민투표가 완료되었습니다 + 선택한 국민투표의 트랙에 대한 투표를 위임 중입니다. 대표자에게 투표를 요청하시거나 위임을 제거하여 직접 투표하십시오. + 이미 투표를 위임 중입니다 + 트랙에 대한 최대 %s 투표에 도달했습니다 + 최대 투표 수에 도달했습니다 + 투표할 수 있는 충분한 토큰이 없습니다. 사용 가능한 투표 수: %s. + 접근 유형 취소 + 취소 대상 + 투표 제거 + + 당신은 이전에 %d 트랙에서 국민투표에 투표했습니다. 이 트랙들을 위임 가능하게 만들기 위해 기존 투표를 제거해야 합니다. + + 당신의 투표 기록을 제거하시겠습니까? + %s 그것은 오직 귀하의 것이며, 안전하게 보관되어 다른 사람들은 접근할 수 없습니다. 백업 비밀번호 없이는 Google Drive에서 지갑을 복원할 수 없습니다. 분실 시, 현재 백업을 삭제하여 새로운 비밀번호로 백업을 다시 생성하십시오. %s + 안타깝지만 비밀번호를 복구할 수 없습니다. + 대신, 복구를 위해 Passphrase를 사용할 수 있습니다. + 비밀번호를 잃어버리셨습니까? + 백업 비밀번호가 이전에 업데이트되었습니다. Cloud Backup 사용을 계속하려면 새로운 백업 비밀번호를 입력하십시오. + 백업 프로세스 중에 생성한 비밀번호를 입력하십시오 + 백업 비밀번호 입력 + 블록체인 런타임에 대한 정보를 업데이트하지 못했습니다. 일부 기능이 작동하지 않을 수 있습니다. + 런타임 업데이트 실패 + 연락처 + 내 계정 + 입력한 이름이나 풀 ID와 일치하는 풀이 없습니다. 올바른 데이터를 입력했는지 확인하십시오. + 계정 주소나 계정 이름 + 검색 결과가 여기에 표시됩니다. + 검색 결과 + 활성 풀: %d + 회원 + 풀 선택 + 위임할 트랙 선택 + 사용 가능한 트랙 + 투표권을 위임하고 싶은 트랙을 선택해주세요. + 위임 수정할 트랙 선택 + 위임 취소할 트랙 선택 + 사용할 수 없는 트랙 + 선물 발송 + Bluetooth 활성화 및 권한 부여 + Ledger 장치를 찾기 위해 블루투스 스캔을 수행하려면 Pezkuwi는 위치 기능을 활성화해야 합니다. + 기기 설정에서 지리적 위치를 활성화해주세요. + 네트워크 선택 + 투표할 토큰 선택 + 트랙 선택 + %d 중 %d + %s 판매를 위한 네트워크 선택 + 판매 시작됨! 최대 60분 정도 기다려 주세요. 상태는 이메일에서 확인할 수 있습니다. + 현재 제공업체 중 이 토큰을 판매할 수 있는 곳이 없습니다. 다른 토큰이나 네트워크를 선택하거나 나중에 다시 확인하세요. + 이 토큰은 판매 기능에서 지원되지 않습니다 + 주소 또는 w3n + %s 전송을 위한 네트워크 선택 + 수신자가 시스템 계정입니다. 이 계정은 어떤 회사나 개인에 의해 제어되지 않습니다.\n여전히 이 전송을 수행하시겠습니까? + 토큰이 소실됩니다 + 권한 부여 + 설정에서 생체 인식이 활성화되었는지 확인하세요 + 설정에서 생체 인식이 비활성화되었습니다 + 커뮤니티 + 이메일을 통해 지원 받기 + 일반 + 키 페어가 있는 지갑(Pezkuwi Wallet에서 생성 또는 가져옴)에 대한 각 서명 작업은 서명을 생성하기 전에 PIN 확인이 필요합니다 + 작업 서명을 위해 인증 요청 + 환경설정 + 푸시 알림은 Google Play에서 다운로드한 Pezkuwi Wallet 버전에서만 사용할 수 있습니다. + 푸시 알림은 Google 서비스가 있는 기기에서만 사용할 수 있습니다. + 화면 기록 및 스크린샷을 사용할 수 없습니다. 최소화된 앱은 내용을 표시하지 않습니다 + 안전 모드 + 보안 + 지원 및 피드백 + Twitter + 위키 및 도움말 센터 + Youtube + 기권 시 확신도가 0.1x로 설정됩니다 + 직접 Staking과 Nomination Pools를 동시에 사용할 수 없습니다 + 이미 Staking 중 + 고급 Staking 관리 + Staking 유형을 변경할 수 없습니다 + 이미 Direct Staking을 사용 중입니다 + 직접 스테이킹 + 지정한 금액이 %s의 최소 Stake 이하이므로 %s로 보상을 받을 수 없습니다. Pool staking을 사용하여 보상을 받는 것을 고려해야 합니다. + 거버넌스에서 토큰 재사용 + 최소 Stake: %s + 보상: 자동 지급 + 보상: 수동 청구 + 당신은 이미 풀에서 스테이킹하고 있습니다 + 풀 스테이킹 + 보상을 받기 위한 최소 금액 이하의 Stake입니다 + 지원되지 않는 스테이킹 유형 + 선물 링크 공유 + 회수 + 안녕하세요! %s 선물이 기다리고 있습니다!\n\nPezkuwi Wallet 앱을 설치하고 지갑을 설정한 후 이 특별한 링크를 통해 수령하세요:\n%s + 선물이 준비되었습니다.\n지금 공유하세요! + sr25519 (추천) + Schnorrkel + 선택된 계정이 이미 컨트롤러로 사용 중입니다 + 위임 권한 추가 (프록시) + 귀하의 위임 + 활성 위임자 + 이 작업을 수행하기 위해 애플리케이션에 컨트롤러 계정 %s를 추가하십시오. + 위임 추가 + 귀하의 Stake가 최소 %s보다 적습니다. 최소 미만의 Stake는 보상이 발생하지 않을 가능성을 높입니다 + 토큰을 더 Stake하십시오 + 검증자를 변경하십시오. + 지금은 모든 것이 잘 되고 있습니다. 여기에 경고가 표시될 것입니다. + 검증자에게 스테이크 할당 대기열에서 오래된 위치를 가지고 있으면 보상이 중단될 수 있습니다 + 스테이킹 개선 + Unstaked 토큰을 교환하십시오. + 다음 시대가 시작되기를 기다려주세요. + 경고 + 이미 컨트롤러 + 귀하는 이미 %s에서 스테이킹하고 있습니다 + 스테이킹 잔액 + 잔액 + 더 Stake 하십시오 + 귀하는 후보자도 검증자도 아닙니다 + 컨트롤러 변경 + 검증자 변경 + %s 중 %s + 선택한 검증자 + 컨트롤러 + 컨트롤러 계정 + 이 계정에 여유 토큰이 없는 것을 발견했습니다. 컨트롤러를 변경하시겠습니까? + 컨트롤러는 unstake, 교환, 스테이킹 반환, 보상 대상 변경 및 검증자 변경을 할 수 있습니다. + 컨트롤러는 다음을 수행할 수 있습니다: unstake, 교환, 스테이킹 반환, 검증자 변경 및 보상 대상 설정 + 컨트롤러가 변경되었습니다. + 이 검증자는 차단되어 현재 선택할 수 없습니다. 다음 era에서 다시 시도해 주세요. + 필터 지우기 + 모두 선택 해제 + 나머지를 추천으로 채우기 + 검증자: %d / %d + 검증자 선택 (최대 %d) + 선택된 검증자 보기: %d (최대 %d) + 검증자 선택 + 예상 보상 (%% APR) + 예상 보상 (%% APY) + 목록 업데이트 + Pezkuwi DApp 브라우저를 통한 Staking + 더 많은 Staking 옵션 + Stake하고 보상 받기 + %1$s Staking은 %2$s에서 %3$s부터 활성화됩니다 + 예상 보상 + era #%s + 예상 수익 + 예상 %s 수익 + 검증자의 own stake + 검증자의 own stake (%s) + Unstaking 기간 중에는 토큰이 보상을 생성하지 않습니다. + Unstaking 기간 중에는 토큰이 보상을 생성하지 않습니다 + Unstaking 기간 후 토큰을 찾는 것을 잊지 마세요. + Unstaking 기간 후 토큰을 찾는 것을 잊지 마세요 + 다음 era부터 보상이 증가할 것입니다. + 다음 era부터 보상이 증가할 것입니다 + Staked 토큰은 매 era마다 보상을 생성합니다 (%s). + 스테이크된 토큰은 각 에포크마다 보상을 생성합니다 (%s) + 남은 스테이크를 방지하기 위해 Pezkuwi wallet이 보상 대상을 귀하의 계정으로 변경합니다. + 토큰을 언스테이크하려면 언스테이킹 기간을 기다려야 합니다 (%s). + 토큰을 언스테이크하려면 언스테이킹 기간을 기다려야 합니다 (%s) + 스테이킹 정보 + 활성 노미네이터 + + %d 일 + + 최소 스테이크 + %s 네트워크 + 스테이크됨 + 프록시를 설정하려면 지갑을 %s로 전환하십시오 + 프록시 설정을 위한 스태시 계정 선택 + 관리 + %s (최대 %s) + 노미네이터의 최대 수에 도달했습니다. 나중에 다시 시도하십시오 + 스테이킹을 시작할 수 없음 + 최소 스테이크 + 스테이킹을 시작하려면 지갑에 %s 계정을 추가해야 합니다 + 매월 + 기기에 컨트롤러 계정을 추가하십시오. + 컨트롤러 계정에 접근할 수 없음 + 노미네이트됨: + %s 보상됨 + 귀하의 밸리데이터 중 하나가 네트워크에 의해 선출되었습니다. + 활성 상태 + 비활성 상태 + 귀하의 staked 금액이 보상을 받기 위한 최소 stake보다 적습니다. + 네트워크에 의해 선택된 validator가 없습니다. + 귀하의 staking은 다음 시대(e)를 시작합니다. + 비활성 + 다음 시대를 기다리는 중 + 다음 시대를 기다리는 중 (%s) + proxy deposit %s을(를) 위한 충분한 잔고가 없습니다. 사용 가능한 잔고: %s + Staking 알림 채널 + Collator + Collator의 최소 stake가 귀하의 위임보다 높습니다. 이 Collator로부터 보상을 받을 수 없습니다. + Collator 정보 + Collator의 자체 stake + Collators: %s + 하나 이상의 collators가 네트워크에 의해 선택되었습니다. + Delegators + 최대 %d개의 collators에 대한 위임 수에 도달했습니다 + 새로운 collator를 선택할 수 없습니다 + 새로운 collator + 다음 라운드를 기다리는 중 (%s) + 모든 collators에 대해 대기 중인 unstake 요청이 있습니다. + Unstake 가능한 collators가 없습니다 + 반환된 토큰은 다음 라운드부터 반영됩니다. + Stake된 토큰은 각 라운드마다 보상을 생성합니다 (%s) + 콜레이터 선택 + 콜레이터 선택... + 다음 라운드부터 보상이 증가됩니다 + 당신의 대표단 중 어느 것도 활성화되지 않았기 때문에 이 라운드에서는 보상을 받지 않습니다. + 이미 이 콜레이터에서 Unstake 중입니다. 콜레이터당 하나의 보류 중인 Unstake만 가질 수 있습니다 + 이 콜레이터에서 Unstake할 수 없습니다 + 이 콜레이터의 최소 Stake (%s)보다 큰 Stake를 보유해야 합니다. + 보상을 받지 않습니다 + 일부 콜레이터는 선출되지 않았거나 당신의 stake된 금액보다 높은 최소 Stake를 가지고 있습니다. 이 라운드에서는 그들의 Staking으로 보상을 받지 않습니다. + 당신의 콜레이터 + 당신의 Stake는 다음 콜레이터에게 지정되었습니다 + 보상을 생성하지 않는 활동적인 콜레이터 + 선출되기 위한 충분한 Stake를 보유하지 않은 콜레이터 + 다음 라운드에서 시행될 콜레이터 + 대기 중 (%s) + 보상 + 보상 만료됨 + + %d일 남음 + + 보상이 만료에 가까워지면 스스로 지급할 수 있지만, 수수료를 지불해야 합니다 + 보상은 매 2-3일마다 검증자들에 의해 지급됩니다 + 전체 + 전체 기간 + 종료 날짜는 항상 오늘입니다 + 사용자 지정 기간 + %d일 + 종료 날짜 선택 + 종료 + 지난 6개월 (6M) + 6M + 지난 30일 (30D) + 30D + 지난 3개월 (3M) + 3M + 날짜 선택 + 시작 날짜 선택 + 시작 + Staking 보상 표시 + 지난 7일 (7D) + 7D + 지난 해 (1Y) + 1Y + 귀하의 사용 가능 잔액은 %s입니다. 최소 잔액으로 %s를 남기고 네트워크 수수료로 %s를 지불해야 합니다. 최대 %s까지 Stake할 수 있습니다. + 위임된 권한 (프록시) + 현재 대기열 슬롯 + 새로운 대기열 슬롯 + Stake로 반환 + 모든 Unstaking + 반환된 토큰은 다음 시대부터 계산됩니다 + 사용자 지정 금액 + Stake로 반환하려는 금액이 Unstaking 잔액보다 큽니다 + 최신 Unstaked + 가장 수익성 높은 + 초과 구독되지 않음 + 온라인 신분 확인됨 + 슬래시되지 않음 + 신분당 최대 2명의 검증인 제한 + 최소 하나의 신원 연락처 있음 + 추천 검증인 + 검증인 + 예상 보상 (APY) + 상환 + 상환 가능: %s + 보상 + 보상 목적지 + 이동 가능한 보상 + 시대 + 보상 세부사항 + 검증인 + 재투자 수익 + 재투자 없는 수익 + 완벽해요! 모든 보상이 지급되었습니다. + 멋져요! 지급되지 않은 보상이 없습니다 + 모두 지급 (%s) + 지급 대기 중인 보상 + 지급되지 않은 보상 + %s 보상 + 보상에 대하여 + 보상 (APY) + 보상 목적지 + 직접 선택하기 + 지급 계정 선택 + 추천 항목 선택 + 선택됨 %d (최대 %d) + 밸리데이터 (%d) + 컨트롤러를 Stash로 업데이트 + 프록시를 사용하여 Staking 작업을 다른 계정으로 위임하십시오 + 컨트롤러 계정이 폐기되고 있습니다 + 스테이킹 관리 작업을 위임하기 위해 다른 계정을 컨트롤러로 선택하십시오 + 스테이킹 보안 향상 + 밸리데이터 설정 + 검증자가 선택되지 않았습니다 + Stake를 시작하려면 검증자를 선택하세요 + 보상을 꾸준히 받으려면 권장되는 최소 Stake는 %s입니다. + 네트워크의 최소값 (%s)보다 적게 Stake할 수 없습니다 + 최소 Stake는 %s보다 커야 합니다 + 재Stake + 보상 재Stake + 보상을 어떻게 사용할까요? + 보상 유형 선택 + 보상 계정 + 슬래시 + Stake %s + 최대 Stake + Stake 기간 + Stake 유형 + 검증자가 능숙하고 정직하게 행동하도록 신뢰해야 합니다. 현재 수익성만을 기준으로 결정하는 것은 수익 감소나 자금 손실로 이어질 수 있습니다. + 검증자가 능숙하고 정직하게 행동하도록 신중히 선택해야 합니다. 수익성만을 기준으로 결정하는 것은 보상 감소나 Stake 손실로 이어질 수 있습니다 + 선택한 검증자와 Stake + Pezkuwi Wallet는 보안 및 수익성 기준에 따라 상위 검증자를 선택합니다. + 추천 검증자들과 Stake + Staking 시작 + Stash + Stash는 더 많은 Bond를 설정하고 컨트롤러를 설정할 수 있습니다. + Stash는 더 많은 Stake와 컨트롤러 설정에 사용됩니다. + Stash 계정 %s 은(는) Staking 설정을 업데이트할 수 없습니다. + 지명자는 네트워크를 보호하기 위해 자신의 토큰을 잠금으로써 수동 소득을 얻습니다. 이를 위해 지명자는 지원할 여러 검증자를 선택해야 합니다. 지명자는 검증자를 선택할 때 신중해야 합니다. 선택한 검증자가 잘못 행동하면 사건의 심각도에 따라 두 사람 모두 슬래싱 벌칙이 부과될 수 있습니다. + Pezkuwi Wallet는 사용자들이 적절한 밸리데이터를 선택할 수 있도록 도와줍니다. 모바일 앱은 블록체인에서 데이터를 받아들이고, 대부분의 수익, 연락 정보가 있는 신원, 슬래시되지 않은 상태 및 지명받을 수 있는 밸리데이터 목록을 작성합니다. Pezkuwi Wallet는 또한 분산화에도 신경을 쓰기 때문에 한 사람이나 한 회사가 여러 개의 밸리데이터 노드를 운영할 경우 추천 목록에는 최대 2개의 노드만 표시됩니다. + 지명자는 누구인가요? + Staking 보상은 각 시대의 종료 시에 지급 받을 수 있습니다 (Kusama는 6시간, Polkadot는 24시간). 네트워크는 84 시대 동안 보류 중인 보상을 저장하며, 대부분의 경우 밸리데이터가 모든 보상을 지급합니다. 그러나 밸리데이터가 잊어버리거나 문제가 발생할 수 있으므로, 지명자는 스스로 보상을 청구할 수 있습니다. + 보통 보상은 밸리데이터에 의해 분배되지만, Pezkuwi Wallet은 만료가 가까운 미지급 보상이 있으면 알림을 통해 도와줍니다. 이와 다른 활동에 대한 경고는 staking 화면에서 확인할 수 있습니다. + 보상 수령 + Staking은 네트워크에 토큰을 잠금으로써 수동 소득을 얻는 옵션입니다. Staking 보상은 매 에라 (Kusama에서는 6시간, Polkadot에서는 24시간)마다 할당됩니다. 원하는 만큼 Staking을 할 수 있으며, 토큰을 언스테이킹하려면 언스테이킹 기간이 끝날 때까지 기다려야 하며, 그 후 토큰을 다시 사용할 수 있게 됩니다. + Staking은 네트워크 보안 및 신뢰성의 중요한 부분입니다. 누구나 검증자 노드를 운영할 수 있지만, 충분한 토큰을 스테이킹한 사람만이 네트워크에 의해 새로운 블록을 구성하고 보상을 받을 수 있게 선택됩니다. 검증자는 종종 자체 토큰이 충분하지 않기 때문에, 지명자는 필요한 스테이크 양을 달성할 수 있도록 토큰을 잠금으로써 그들을 도와줍니다. + 스테이킹이란? + 검증자는 블록체인 노드를 24시간 내내 운영하며 네트워크에 의해 선출되기 위해 충분한 Stake (본인이 소유한 것과 지명자들이 제공한 것 모두) 자산을 잠가야 합니다. 검증자는 노드의 성능과 신뢰성을 유지하여 보상을 받습니다. 검증자가 되는 것은 거의 전일제 직업과도 같으며, 블록체인 네트워크에서 검증자가 되기 위해 집중하는 회사들도 존재합니다. + 모든 사람이 검증자가 되어 블록체인 노드를 운영할 수 있지만, 이는 일정 수준의 기술적인 능력과 책임이 필요합니다. Polkadot과 Kusama 네트워크에는 초보자들을 지원하기 위한 Thousand Validators Programme이라는 프로그램이 있습니다. 더욱이, 네트워크는 항상 더 적은 Stake (하지만 선출될만한 충분한 Stake)를 가진 검증자들을 보상하여 탈중앙화를 향상시키려 합니다. + 검증자가 누구인가요? + 컨트롤러를 설정하려면 계정을 스태시로 전환하세요. + Staking + %s staking + 보상받음 + 총 Staked + Boost 임계값 + 내 Collator를 위해 + Yield Boost 없이 + Yield Boost와 함께 + 모든 전송 가능한 토큰을 자동으로 Stake하기 위해 %s 이상 + 모든 전송 가능한 토큰을 자동으로 Stake하기 위해 %s (이전: %s) 이상 + Stake 하고 싶습니다 + Yield Boost + Staking 종류 + 모든 토큰을 Unstake하고 있고, 더 이상 Stake할 수 없습니다. + 더 이상 Stake 불가 + 부분적으로 Unstaking할 경우, 최소 %s를 Stake 상태로 유지해야 합니다. 나머지 %s도 Unstake하여 완전히 Unstake하시겠습니까? + Stake에 남은 금액이 너무 적습니다 + Unstake하려는 금액이 Staked된 잔액보다 큽니다 + Unstake + Unstaking 거래가 여기 나타납니다 + Unstaking 거래가 여기 표시됩니다 + Unstaking: %s + Unstaking 기간이 끝난 후에 토큰을 상환할 수 있습니다. + Unstaking 요청 한도에 도달했습니다 (%d 활성 요청). + Unstaking 요청 한도 도달 + Unstaking 기간 + 모두 Unstake + 모두 Unstake? + 예상 보상 (%% APY) + 예상 보상 + 밸리데이터 정보 + 초과 구독. 이번 시기에 밸리데이터로부터 보상을 받지 못합니다. + 노미네이터 + 초과 구독. 상위 스테이킹 노미네이터만 보상을 받습니다. + 자체 + 검색 결과가 없습니다.\n전체 계정 주소를 입력했는지 확인하세요 + 밸리데이터가 네트워크에서 부정행위(예: 오프라인 상태, 네트워크 공격, 수정된 소프트웨어 실행)로 인해 처벌받았습니다. + 총 스테이크 + 총 스테이크 (%s) + 보상이 네트워크 수수료보다 적습니다. + 연간 + 귀하의 스테이크는 다음 밸리데이터에 할당됩니다. + 귀하의 스테이크는 다음 밸리데이터에 할당됩니다 + 선출됨 (%s) + 이번 시기에 선출되지 않은 밸리데이터입니다. + 선출될 만큼의 스테이크가 부족한 밸리데이터 + 귀하의 스테이크 할당 없이 활동 중인 노드들. + 귀하의 스테이크 할당 없이 활동 중인 밸리데이터 + 낙선 (%s) + 당신의 토큰이 초과 할당된 검증자에게 할당됩니다. 이번 시대에는 보상을 받을 수 없습니다. + 보상 + 당신의 스테이크 + 당신의 검증자 + 다음 시대에 당신의 검증자가 변경됩니다. + 이제 지갑을 백업합시다. 이렇게 하면 자금이 안전하게 보호됩니다. 백업을 통해 언제든지 지갑을 복원할 수 있습니다. + Google로 계속하기 + 지갑 이름 입력 + 나의 새로운 지갑 + 수동 백업으로 계속하기 + 지갑에 이름을 지정하세요 + 이것은 당신에게만 보이며 나중에 수정할 수 있습니다. + 지갑이 준비되었습니다. + 블루투스 + USB + 당신의 잔액에는 %s에 의해 잠긴 토큰이 있습니다. 계속하려면 %s 이하 또는 %s 이상을 입력해야 합니다. 다른 금액을 스테이크하려면 %s 잠금을 해제해야 합니다. + 지정된 금액을 스테이크할 수 없습니다 + 선택됨: %d (최대 %d) + 사용 가능한 잔액: %1$s (%2$s) + 스테이크된 토큰과 함께 %s + 거버넌스 참여 + 스테이크 %1$s 이상 및 스테이크된 토큰과 함께 %2$s + 거버넌스에 참여하세요 + 최소 %1$s로 언제든지 Staking 가능합니다. Staking은 %2$s 동안 활발하게 보상을 얻습니다 + %s 후 + 언제든지 Staking 가능합니다. Staking은 %s 동안 활발하게 보상을 얻습니다 + %1$s Staking에 대해 더 많은 정보를 알아보세요 %2$s에서 + Pezkuwi Wiki + 보상은 %1$s 동안 누적됩니다. 자동 보상 지급을 받으려면 %2$s 이상 Staking 하세요, 그렇지 않으면 수동으로 보상을 청구해야 합니다 + %s마다 + 보상은 %s 동안 누적됩니다 + 보상은 %s 동안 누적됩니다. 수동으로 보상을 청구해야 합니다 + 보상은 %s 동안 누적되고 전송 가능한 잔액에 추가됩니다 + 보상은 %s 동안 누적되고 다시 스테이크에 추가됩니다 + 보상과 Staking 상태는 시간이 지남에 따라 변경됩니다. %s 때때로 확인하세요 + 스테이크를 모니터링하세요 + Staking 시작 + %s 보기 + 사용 약관 + %1$s은(는) %2$s이며 %3$s입니다 + 토큰 가치 없음 + 테스트 네트워크 + %1$s\n당신의 %2$s 토큰으로\n1년간 + 최대 %s까지 적립됩니다 + 언제든지 Unstake하고, %s을(를) 환급받으세요. Unstaking하는 동안에는 보상이 지급되지 않습니다. + %s 후 + 선택한 풀이 검증자가 선택되지 않았거나 Stake가 최소치보다 적기 때문에 비활성 상태입니다.\n선택한 풀로 진행하시겠습니까? + 최대 노미네이터 수에 도달했습니다. 나중에 다시 시도하세요 + %s은(는) 현재 사용 불가능합니다 + 검증자: %d (최대 %d) + 수정됨 + 새로운 + 제거됨 + 네트워크 수수료를 지불할 토큰 + 입력한 금액 외에 네트워크 수수료가 추가됩니다 + 스왑 단계 시뮬레이션 실패 + 이 페어는 지원되지 않습니다 + 작업 #%s (%s) 실패 + %s에서 %s로의 스왑 %s + %s에서 %s로의 전송 + + %s 작업 + + %s of %s 작업 + %s에서 %s로 %s에서 스왑 중 + %s에서 %s로 전송 중 + 실행 시간 + 충분하지 않은 토큰을 보유하고 있으므로 %s의 네트워크 수수료를 지불한 후 최소 %s을(를) 유지해야 합니다 + %s 토큰을 받기 위해 최소 %s을(를) 유지해야 합니다 + %s 토큰을 받으려면 %s에 최소 %s가 있어야 합니다 + 네트워크 수수료로 %2$s를 지불해야 하므로 최대 %1$s까지 교환할 수 있습니다. + 네트워크 수수료로 %2$s를 지불하고 %3$s을(를) %4$s으로 변환하여 %5$s 최소 잔액을 유지해야 하므로 최대 %1$s까지 교환할 수 있습니다. + 최대 교환 + 최소 교환 + 잔액에 최소 %1$s를 남겨두어야 합니다. 남은 %2$s을 추가하여 전체 교환을 수행하시겠습니까? + 잔액이 너무 적습니다 + 네트워크 수수료 %2$s을 지불하고 %3$s를 %4$s로 변환하여 %5$s 최소 잔액을 맞추기 위해 최소 %1$s를 유지해야 합니다.\n\n남은 %6$s을 추가하여 전체 교환을 원하십니까? + 지불할 금액 + 받을 금액 + 토큰 선택 + 교환할 토큰이 부족합니다 + 유동성이 부족합니다 + 최소 %s 이상 받아야 합니다 + 신용카드로 %s 즉시 구매 + 다른 네트워크에서 %s 전송 + QR 또는 주소로 %s 받기 + %s 받기 + 스왑 실행 중 중간 수령 금액이 %s로 최소 잔액 %s보다 적습니다. 더 큰 스왑 금액을 지정해보세요. + 슬리피지는 %s에서 %s 사이로 지정해야 합니다 + 잘못된 슬리피지 + 지불할 토큰 선택 + 받을 토큰 선택 + 금액 입력 + 다른 금액 입력 + %s로 네트워크 수수료를 지불하기 위해, Pezkuwi는 계정의 최소 %s 잔액을 유지하기 위해 자동으로 %s를 %s로 교환합니다. + 블록체인이 모든 거래를 처리하고 검증하기 위해 부과하는 네트워크 수수료입니다. 네트워크 상태나 거래 속도에 따라 다를 수 있습니다. + %s 교환을 위한 네트워크 선택 + 풀에 교환할 수 있는 유동성이 충분하지 않습니다 + 가격 차이는 두 자산 간의 가격 차이를 나타냅니다. 암호화폐 교환 시, 가격 차이는 일반적으로 교환하는 자산의 가격과 교환받는 자산의 가격 간의 차이를 의미합니다. + 가격 차이 + %s ≈ %s + 두 개의 다른 암호화폐 간의 환율입니다. 이는 일정량의 한 암호화폐를 교환할 때 얼마나 많은 다른 암호화폐를 얻을 수 있는지를 나타냅니다. + 환율 + 이전 환율: %1$s ≈ %2$s.\n새 환율: %1$s ≈ %3$s + 교환 비율이 업데이트되었습니다 + 작업 반복 + 경로 + 당신의 토큰이 원하는 토큰을 얻기 위해 다른 네트워크를 통해 이동하는 방식입니다. + 스왑 + 전송 + 교환 설정 + 슬리페이지 + 슬리페이지는 분산형 거래에서 흔히 발생하는 현상으로, 교환 거래의 최종 가격이 시장 상황 변화로 인해 예상 가격과 다를 수 있습니다. + 다른 값을 입력하세요 + %s와 %s 사이의 값을 입력하세요 + 슬리피지 + 높은 슬리피지로 인해 거래가 프론트런 당할 수 있습니다. + 낮은 슬리피지 허용량으로 인해 거래가 되돌릴 수 있습니다. + %s의 금액이 %s의 최소 잔액보다 적습니다 + 너무 적은 금액을 스왑하려고 합니다 + 기권: %s + 찬성: %s + 나중에 이 국민투표에 다시 투표할 수 있습니다 + 투표 목록에서 국민투표 %s 제거? + 일부 국민투표는 더 이상 투표할 수 없거나 투표에 충분한 토큰이 없을 수 있습니다. 투표 가능: %s. + 투표 목록에서 국민투표 일부 제외 + 국민투표 데이터를 불러올 수 없습니다 + 데이터 없음 + 모든 가능한 국민투표에 이미 투표한 상태이거나 지금 투표할 국민투표가 없습니다. 나중에 다시 오세요. + 모든 가능한 국민투표에 이미 투표했습니다 + 요청됨: + 투표 목록 + %d 남음 + 투표 확인 + 투표할 국민투표 없음 + 투표 확인하기 + 투표 없음 + %d 개 국민투표에 성공적으로 투표하셨습니다 + 현재 투표권 %s (%sx)으로 투표할 자금이 부족합니다. 투표권을 변경하거나 지갑에 더 많은 자금을 추가하세요. + 투표에 필요한 잔액 부족 + 반대: %s + SwipeGov + %d 개 국민투표에 투표 + 앞으로 있을 투표는 SwipeGov에서 설정됩니다 + 투표권 + Staking + 지갑 + 오늘 + 가격 정보용 Coingecko 링크 (선택 사항) + %s 토큰 구매를 위한 제공업체 선택 + 결제 수단, 수수료 및 제한은 제공업체에 따라 다릅니다.\n그들의 견적을 비교하여 최적의 옵션을 찾으세요. + %s 토큰 판매를 위한 제공업체 선택 + 구매를 계속하려면 Pezkuwi Wallet 앱에서 %s로 리디렉션됩니다. + 브라우저에서 계속할까요? + 현재 제공업체 중 이 토큰을 구매하거나 판매할 수 있는 곳이 없습니다. 다른 토큰이나 네트워크를 선택하거나 나중에 다시 확인하세요. + 이 토큰은 구매/판매 기능에서 지원되지 않습니다 + 해시 복사 + 수수료 + 보낸 사람 + 거래 해시 + 거래 세부정보 + %s에서 보기 + Polkascan에서 보기 + Subscan에서 보기 + %s 시 %s + 이전 %s 거래 내역은 여전히 %s에서 확인 가능합니다 + 완료됨 + 실패 + 대기 중 + 거래 알림 채널 + $5부터 암호화폐 구매 시작 + $10부터 암호화폐 판매 시작 + 보낸 사람: %s + 받는 사람: %s + 전송 + 수신 및 송신\n전송이 여기에 표시됩니다 + 귀하의 작업이 여기에 표시됩니다 + 이 트랙에서 위임하려면 투표를 삭제하세요 + 이미 투표를 위임한 트랙 + 사용할 수 없는 트랙 + 기존에 투표한 트랙 + 다시 표시하지 않습니다.\n레거시 주소는 수신에서 찾을 수 있습니다. + 레거시 형식 + 새 형식 + 일부 거래소는 업데이트하는 동안\n작업을 위해 아직 레거시 형식을 요구할 수 있습니다. + 새로운 통합 주소 + 설치 + 버전 %s + 사용 가능한 업데이트 + 문제를 피하고 사용자 경험을 향상시키기 위해 가능한 한 빨리 최근 업데이트를 설치할 것을 강력히 권장합니다 + 중요 업데이트 + 최신 + Pezkuwi Wallet의 새로운 놀라운 기능들을 사용할 수 있습니다! 애플리케이션을 업데이트하여 이 기능들을 꼭 즐기세요 + 주요 업데이트 + 중요한 + 중요한 + 모든 사용 가능한 업데이트 보기 + 이름 + 지갑 이름 + 이 이름은 오직 당신만 볼 수 있으며, 본인의 모바일 기기에만 저장됩니다. + 이 계정은 현재 주기에 참여하기 위해 네트워크에 의해 선택되지 않았습니다 + 재투표 + 투표 + 투표 상태 + 카드가 충전 중입니다! + 카드가 발급되고 있습니다! + 최대 5분이 소요될 수 있습니다.\n이 창은 자동으로 닫힙니다. + 예상 %s + 구매 + 구매/판매 + 토큰 구매 + 구매 방법 + 받기 + %s 수령 + 이 주소로 %1$s 토큰과 %2$s 네트워크에서의 토큰만 보내세요, 그렇지 않으면 자금을 잃을 수 있습니다. + 판매 + 토큰 판매 + 보내기 + 스왑 + 자산 + 여기에 자산이 표시됩니다.\n\"잔액 숨기기\" 필터가 꺼져 있는지 확인하세요. + 자산 가치 + 이용 가능 + Staked + 잔액 세부 정보 + 총 잔액 + 전송 후 총액 + 동결됨 + 잠겨 있음 + 회수 가능 + 예약됨 + 전송 가능 + Unstaking + 지갑 + 새 연결 + + %s 계정이 없습니다. 설정에서 지갑에 계정을 추가하세요. + + \"%s\"이(가) 요청한 네트워크 중 일부는 Pezkuwi Wallet에서 지원되지 않습니다. + 여기에 Wallet Connect 세션이 표시됩니다. + WalletConnect + 알 수 없는 dApp + + %s 지원되지 않는 네트워크가 숨겨져 있습니다. + + WalletConnect v2 + 크로스체인 전송 + 암호 화폐 + 법정 화폐 + 인기 있는 법정 화폐 + 통화 + 외부 내역 + 잔고가 0인 자산 숨기기 + 기타 거래 + 표시 + 보상 및 슬래시 + 스왑 + 필터 + 이체 + 자산 관리 + 지갑 추가 방법? + 지갑 추가 방법? + 지갑 추가 방법? + 이름 예시: 주 계정, 나의 발리데이터, Dotsama 크라우드론 등. + 이 QR을 발신자에게 공유하세요 + 발신자가 이 QR 코드를 스캔할 수 있게 하세요 + 저의 %s 주소입니다: %s: + QR 코드 공유 + 수신자 + 주소가 올바른 네트워크의 것인지 확인하세요 + 주소 형식이 잘못되었습니다. 네트워크에 맞는 주소인지 확인하세요 + 최소 잔고 + 크로스체인 제한으로 인해 %s 이상을 전송할 수 없습니다 + 크로스체인 수수료인 %s를 지불할 충분한 잔고가 없습니다. 전송 후 잔고: %s + 입력된 금액에는 교차 체인 수수료가 추가됩니다. 수신자는 교차 체인 수수료의 일부를 받을 수 있습니다. + 전송 확인 + 교차 체인 + 교차 체인 수수료 + 대상 계정에 다른 토큰 전송을 수락할 수 있는 충분한 %s이(가) 없기 때문에 전송이 실패할 것입니다. + 수신자가 전송을 수락할 수 없음 + 대상 계정의 최종 금액이 최소 잔액보다 적기 때문에 전송이 실패할 것입니다. 금액을 늘려보세요. + 전송 후 총 잔액이 최소 잔액보다 낮아지기 때문에 계정이 블록 스토어에서 제거될 것입니다. + 전송 후 총 잔액이 최소 잔액보다 낮아지기 때문에 계정이 블록체인에서 제거될 것입니다. + 전송으로 계정이 제거됩니다 + 전송 후 총 잔액이 최소 잔액보다 낮아지기 때문에 계정이 블록체인에서 제거될 것입니다. 남은 잔액도 수신자에게 전송됩니다. + 네트워크에서 + 이 거래 수수료를 지불하고 최소 네트워크 잔액 이상을 유지하려면 최소 %s가 필요합니다. 현재 잔액: %s. 이 작업을 수행하려면 잔액에 %s를 추가해야 합니다. + 나에게 + 온체인 + 다음 주소: %s는 피싱 활동에 사용된 것으로 알려져 있으므로 해당 주소로 토큰을 보내는 것을 권장하지 않습니다. 그래도 진행하시겠습니까? + 사기 경고 + 받는 사람이 토큰 소유자가 차단했으며 현재 들어오는 전송을 받을 수 없습니다 + 받는 사람이 전송을 받을 수 없습니다 + 받는 사람 네트워크 + 네트워크로 + %s을(를) 발송 + %s을(를) 보내기 + 에게 + 발송자 + 토큰 + 이 연락처로 전송 + 전송 세부 사항 + %s (%s) + %s의 %s 주소 + Pezkuwi에서 %1$s 주소에 대한 정보의 무결성 문제를 감지했습니다. %1$s의 소유자에게 연락하여 무결성 문제를 해결하세요. + 무결성 검사 실패 + 유효하지 않은 수신자 + %s 네트워크에서 %s에 대한 유효한 주소를 찾을 수 없습니다. + %s을(를) 찾을 수 없습니다 + %1$s w3n 서비스가 사용할 수 없습니다. 나중에 다시 시도하거나 %1$s 주소를 수동으로 입력하십시오 + w3n 해상도 오류 + Pezkuwi에서 토큰 %s의 코드를 해석할 수 없습니다 + 토큰 %s은(는) 아직 지원되지 않습니다 + 어제 + 지금 콜레이터의 Yield Boost가 비활성화됩니다. 새로운 콜레이터: %s + Yield Boost 콜레이터 변경? + 네트워크 수수료 %s와 Yield Boost 실행 수수료 %s를 지불할 충분한 잔액이 없습니다.\n수수료 지불 가능 잔액: %s + 첫 실행 수수료를 지불할 충분한 토큰이 없습니다 + 네트워크 수수료 %s를 지불하고 임계값 %s 아래로 떨어지지 않을 충분한 잔액이 없습니다.\n수수료 지불 가능 잔액: %s + 임계값 위에 머무를 충분한 토큰이 없습니다 + Stake 증가 시간 + Yield Boost가 %s 모든 양도 가능한 토큰을 %s 이상 자동으로 Stake 합니다 + Yield Boosted + + + DOT ↔ HEZ 브릿지 + DOT ↔ HEZ 브릿지 + 보내는 금액 + 받는 금액 (예상) + 환율 + 브릿지 수수료 + 최소 금액 + 1 DOT = %s HEZ + 1 HEZ = %s DOT + 교환 + HEZ→DOT 교환은 DOT 유동성이 충분할 때 처리됩니다. + 잔액 부족 + 최소 금액 미만 + 금액 입력 + HEZ→DOT 교환은 유동성에 따라 제한될 수 있습니다. + HEZ→DOT 교환은 일시적으로 사용할 수 없습니다. DOT 유동성이 충분해지면 다시 시도하세요. + diff --git a/common/src/main/res/values-ku/strings.xml b/common/src/main/res/values-ku/strings.xml new file mode 100644 index 0000000..ddb401e --- /dev/null +++ b/common/src/main/res/values-ku/strings.xml @@ -0,0 +1,2760 @@ + + + + Flex, Stax, Nano + + Ledger Nano Gen5 + + Diyarîkirin ji bo cîzdanên Multisig nayê piştgirî kirin + Bo create an manage gifts, ji kerema xwe switch to a different type of wallet. + + Cîzdan name + Nav + Nederbasdar Koda QR + + %s you would like to add to Pezkuwi Cîzdan + Tap on Derived Key + + Pair public key + Têxe private key + + Tap icon in top-right corner û select %s + Derxe Taybetî Key + + Te diyariya xwe bi serkeftî paşve girt + Mîqdara diyariyê binivîse + + Paş bixe %s Diyarî? + If you reclaim ev gift, shared link dê be disabled, û tokens dê be refunded to yê te wallet.\nDo you want to continue? + + Paş bixe + secrets + + Hesab tune on %s + Use another wallet, create a nû one, an add a %s account to ev wallet in Mîheng. + + You dikare’t receive a gift bi a %s wallet + Ji bo wergirtina diyariyê cîzdanek nû biafirîne an yekê heyî import bike + Cîzdanek nû biafirîne + Cîzdanên nû bixweber li Cloud Backupê têne zêdekirin. Tu dikarî vê yekê di Mîhengan de biguherînî. + Cîzdana min a nû + Cîzdanek nû biafirîne + + Cîzdanan birêve bibe + + You’ve successfully claimed a gift. Token dê appear in yê te balance shortly. + + gift nikare be received + Oops, gift has already been 
claimed + + Oops, something went wrong + There may be a problem bi server. Ji kerema xwe, try again later. + Claim gift + You’ve got a crypto gift! + + Hate afirandin: %s + Claimed + Reclaimed + Heqê torê on claim + + Hello! You’ve got a %s gift waiting ji bo you!\n\nInstall Pezkuwi Cîzdan app, set up yê te wallet, û claim it via ev special link:\n%s + + Diyarî Has Been Prepared.\nShare It Niha! + Diyarî parve bike link + + Confirming dê transfer tokens ji yê te account + Yê te Diyarî + + Bişîne gift on + + Enter another amount + + minimum gift e %s + + Mîqdar binivîse + + Hilbijêre a token to gift + Tor hilbijêre ji bo %s gift + You don’t have tokens to gift.\nBuy an Deposit tokens to yê te account. + + Diyarî çêke + %s as a link û invite anyone to Pezkuwi Cîzdan + Parve bike gift directly + %s, û you dikare return unclaimed gifts anytime ji ev device + gift e available instantly + + Parve bike Crypto Diyarî bi Anyone, Anywhere + Bişîne gifts fast, easily, û securely in Pezkuwi Cîzdan + Diyarî you created + Diyarî + + Token hilbijêre to vote + Token hilbijêre + + %s rewards + + Kevn transaction history remains on %s + + Starting %1$s yê te %2$s balance, Staking û Rêveberî in on %3$s. These features might be unavailable ji bo up to 24 hours. + %1$s staking e live on %2$s starting %3$s + + Bibîne + Yê te previous %s transaction history e still available on %s + + Yê te %s tokens niha on %s + Starting %1$s, yê te %2$s balance, Staking, û Rêveberî dê be on %3$s — bi improved performance û lower costs. + Go to %s + + Migration happens automatically, no action needed + + Starting %1$s yê te %2$s balance, Staking û Rêveberî in on %3$s + What makes Malûmilk Hub awesome? + %1$sx reduction in minimal balance\n(ji %2$s to %3$s) + %1$sx lower transaction fees\n(ji %2$s to %3$s) + Bêtir tokens supported: %s, û din ecosystem tokens + Unified access to %s, assets, staking, û governance + Ability to pay fees in her token + + Veke app bi hidden balance + + Enable in Mîheng + + Multisig push notifications in li vir! + Get notified about signing requests, nû signatures, û completed transactions — so you’re always in control. Manage anytime in Mîheng. + + Na multisig wallets + You haven’t added an selected her multisig wallets in Pezkuwi Cîzdan. Lê zêde bike û select at least one to receive multisig push notifications. + + Trust Cîzdan + Use yê te Trust Cîzdan accounts in Pezkuwi Cîzdan + + Yê te signature requested + Hate îmzekirin by signatory + Multisig transaction executed + Multisig transaction rejected + Batch (executes until error) + Batch Hemû (reverts on error) + Force Batch (ignores errors) + Tê îmzekirin e over + + Danûstandin has already been executed an rejected. + + Tê barkirin transaction details… + + %1$s → %2$s + + Multisig Danûstandin Hate înfazkirin + Multisig Danûstandin Hate redkirin + + %s on %s.\nRejected by: %s.\nNo further actions in required ji you. + %s on %s.\nNo further actions in required ji you. + + Danûstandin without initial on-chain information (call data) bû rejected + + Pezkuwi Cîzdan has automatically switched to yê te multisig wallet so you dikare view pending transactions. + + On behalf of %s. + ❌ Multisig transaction rejected + Hate redkirin by %s. + + ✅ Multisig transaction executed + Hate îmzekirin û executed by %s. + + %d of %d signatures collected. + Hate îmzekirin by %s + + ✍🏻 Yê te signature requested + %s on %s. + %s: %s + Initiated by %s. + Cîzdan: %s + + Multisig Danûstandin Notification Channel + multisigs_notification_channel_id + + Hilbijêre up to %d wallets to be notified when wallet has activity + + Multisig transactions + + call data you provided e invalid an has wrong format. Ji kerema xwe ensure it e correct û try again. + + Na transactions yet.\nSigning requests dê show up li vir + + Debit Card e ne supported ji bo ev Proxied wallet + Bo manage yê te Debit Card, ji kerema xwe switch to a different wallet bi Polkadot network. + + Call hash + + Kopî bike call data + Parve bike call data + + Cannot perform ev transaction + Ev transaction has already been rejected an executed. + + Din signatories dikare niha confirm transaction.\nYou dikare track its status in %s. + Danûstandin to sign + + Delegated to you + + Delegated to you (Proxied) + + Danûstandin dê be rejected. Multisig deposit dê be returned to %s. + + Full details + + deposit stays locked on depositor’s account until multisig operation e executed an rejected. + + Kopî bike hash + Parve bike hash + + Depositor + Multisig deposit + Call data + Make sure operation e correct + + On behalf of + Îmzekar + Hide + Show + Îmzekar (%d of %d) + + Simulation of swap step failed + + Danûstandin simulation failed + Pezkuwi Cîzdan simulates transaction beforehand to prevent errors. Ev simulation didn’t succeed. Dîsa biceribîne later an bi a higher amount. If issue persists, ji kerema xwe contact Pezkuwi Cîzdan Support in Mîheng. + + %s doesn’t have enough balance to pay network fee of %s û place multisig deposit of %s. You need to add %s bêtir to yê te balance + %s doesn’t have enough balance to place multisig deposit of %s. You need to add %s bêtir to yê te balance + %s doesn’t have enough balance to pay network fee of %s. You need to add %s bêtir to yê te balance + + Nederbasdar call data + Ev call data ji bo another operation bi call hash %s + + Enter call data to view details + + Enter call data + + + Din signatories: + Threshold %d out of %d + + Debit Card e ne supported ji bo Multisig + Bo manage yê te Debit Card, ji kerema xwe switch to a different type of wallet. + + Sell e ne supported ji bo Multisig + Bo sell crypto ji bo fiat, ji kerema xwe switch to a different type of wallet. + + + Danûstandin to sign + + Tê îmzekirin (%s of %s) + + + Hate afirandin by you + Hate îmzekirin by you + + + On behalf of: + + Unknown operation + + + Operation already exists + %s has already initiated same operation û it e currently waiting to be signed by din signatories. + + %s needs at least %s to pay ev transaction fee û stay above minimum network balance. Current balance e: %s + + Multisig wallets do ne support signing arbitrary messages — only transactions + + Multisig transaction + multisig transaction dê be initiated by %s. initiator pays network fee û reserves a multisig deposit, which dê be unreserved once transaction e executed. + + shared control (multisig) + + Cîzdan list has been updated + + Proxy û Multisig accounts auto-detected û organized ji bo you. Manage anytime in Mîheng. + + Proxied + Shared control + + Na longer valid + + Multisig transaction created + Bibîne details + + Bipejirîne + Bipejirîne & Execute + Red bike + + Multisig + Danûstandin ji ev wallet require approval ji multiple signatories. Yê te account e one of signatories: + Îmzekar: + + + You must have at least %s on %s to receive %s token + + Bi xêr hatî to Pezkuwi Cîzdan! + You’re almost there! 🎉\n Just tap below to complete setup û start using yê te accounts seamlessly in both Polkadot App û Pezkuwi Cîzdan + + %s Wallet + + %s://polkadot/migration-accepted?key=%s + access Bluetooth + + pezkuwi-wallet.app.link + pezkuwi-wallet-alternate.app.link + + Press both buttons on yê te %s to approve addresses + Press confirm button on yê te %s to approve addresses + + Lê zêde bike address + Substrate Hesab + EVM Hesab + + Nûve bike Ledger Generic App + Yê te Ledger device e using an outdated Generic app ew doesn’t support EVM addresses. Nûve bike it via Ledger Live. + + Hesab ne found + + Hesab %s + Nano S + + Berdewam bike in browser? + Bo continue purchase you dê be redirected ji Pezkuwi Cîzdan app to %s + + Ev token e ne supported by buy feature + None of our providers currently support buying of ev token. Ji kerema xwe choose a different token, a different network, an check back later. + + Ev token e ne supported by sell feature + None of our providers currently support selling of ev token. Ji kerema xwe choose a different token, a different network, an check back later. + + Ev token e ne supported by buy/sell feature + None of our providers currently support buying an selling of ev token. Ji kerema xwe choose a different token, a different network, an check back later. + + Purchase initiated! Ji kerema xwe bisekine up to 60 minutes. You dikare track status on email. + Sale initiated! Ji kerema xwe bisekine up to 60 minutes. You dikare track status on email. + + Sell %s token + Tor hilbijêre ji bo selling %s + + Best price bi fees up to 3.95%% + Buy crypto starting ji just $5 + Straightforward û efficient KYC process + + Sell crypto starting ji just $10 + + Sell + + +%d + + Hilbijêre a provider to buy %s token + + Hilbijêre a provider to sell %s token + + Payment methods, fees û limits differ by provider.\nCompare their quotes to find best option ji bo you. + + Sell tokens + Buy tokens + + Buy/Sell + + + %s in yê te phone settings + Enable OTG + + Enable Bluetooth & Grant Permissions + + Press both buttons on yê te %s to approve address + Press confirm button on yê te %s to approve address + Hold confirm button on yê te %s to approve transaction + + Nano X + Ledger Flex + Ledger Stax + Nano S Plus + + If using a Ledger via Bluetooth, enable Bluetooth on both devices û grant Pezkuwi Cîzdan Bluetooth û location permissions. Ji bo USB, enable OTG in yê te phone settings. + Ji kerema xwe, enable Ledger device. Unlock yê te Ledger device û open %s app. + + Ledger Connection Guide + + Connect Ledger + + Bluetooth + USB + + Ledger Legacy + + Ledger (Generic Polkadot app) + + Legacy + Nû (Vault v7+) + + Nû Unified Navnîşan + Hin exchanges may still require legacy format\nfor operations while they update. %s + nû format + legacy format + Do ne show ev again.\nYou dikare find legacy address in Werbigire. + + Estimated %s + Yê te card e being issued! + Yê te card e being funded! + It dikare take up to 5 minutes.\nThis window dê be closed automatically. + + Têkilî support + Ji kerema xwe contact support@pezkuwichain.io. Include email address ew you have used ji bo issuing card. + + Manage Debit Card + + Top up card bi %s + + Îro + Last Week + Last Month + Last Year + Hemû Dem + + %s at %s + %s (%s) + + %s price + 1D + 1W + 1M + 1Y + Hemû + + app.pezkuwichain.io + + High price impact detected (%s) + + Executing ev swap dê result in significant slippage û financial losses. Consider reducing yê te trade size of splitting yê te trade nav multiple transactions. + + Popular + + During swap execution intermediate receive amount e %s which e kêmtir than minimum balance of %s. Try specifying larger swap amount. + + You don\'t have enough balance to pay network fee of %s. Current balance e %s + + sec + + Do ne close app! + + Serneket + + %s of %s operations + + + %s operation + %s operations + + + Swapping %s to %s on %s + Transferring %s to %s + + Serneket on operation #%s (%s) + %s to %s swap on %s + %s transfer ji %s to %s + + + %d second + %d seconds + + + Execution time + + Tevahî fee + + Heq: %s + + Veguhestin + Swap + way ew yê te token dê take through different networks to get desired token. + Route + + See Hemû + Favorites + %d DApps + + Bigire Hemû DApps? + Hemû opened tabs in DApp browser dê be closed. + + Bigire Hemû + + Tor hilbijêre + + Bigere by token + + Kopî bike Navnîşan + Bişîne only %1$s token û tokens in %2$s network to ev address, an you might lose yê te funds + + token icons + Appearance + White + Colored + + Tor hilbijêre ji bo buying %s + Tor hilbijêre ji bo receiving %s + Tor hilbijêre ji bo sending %s + Tor hilbijêre ji bo swapping %s + + Token + Tor + + Wiki & Alîkarî Center + + Get support via Email + + You have already voted ji bo hemû available referenda + Bipejirîne yê te votes + Deng bide list + Na referenda to vote + %d left + + Use max + + Insufficient balance ji bo voting + You don\'t have enough balance to vote bi current voting power %s (%sx). Ji kerema xwe change voting power an add bêtir funds to yê te wallet. + You have successfully voted ji bo %d referenda + + Deng bide ji bo %d referenda + Rake referendum %s ji vote list? + You always dikare vote ji bo ev referendum later + + Hin of referenda excluded ji vote list + Hin of referenda in no longer available ji bo voting an you may ne have enough tokens available to vote. Peyda to vote: %s. + + Aye: %s + Nay: %s + Abstain: %s + + Dengdan power + Dengdan dê be set ji bo future votes in SwipeGov + Bipejirîne votes + Na votes + Na data retrived + data of referendum could ne be loaded + Requested: + You have already voted ji bo hemû available referenda an there in no referenda to vote right niha. Come back later. + SwipeGov + Swipe to vote on referenda bi AI summaries. Fast & easy! + %d referenda + + Polkadot app e installed + + Veke Polkadot app + + Make sure %s to yê te Ledger device using Ledger Live app + + %s on yê te Ledger device + + Allow Pezkuwi Cîzdan to %s + + %s to add to wallet + Hesab hilbijêre + + Polkadot Vault dê provide you %s + Koda QR to scan + + %s application on yê te smartphone + Veke Polkadot Vault + + %s on yê te Ledger device + Veke network app + + Make sure %s to yê te Ledger device using Ledger Live app + Tor app e installed + + Parity Signer dê provide you %s + Koda QR to scan + + %s you would like to add to Pezkuwi Cîzdan + Go to “Keys” tab. Hilbijêre seed, then account + + %s application on yê te smartphone + Veke Parity Signer + + Conviction dê be set to 0.1x when you vote Abstain + + Abstain votes + Conviction update + Abstain votes dikare only be done bi 0.1x conviction. Deng bide bi 0.1x conviction? + + Hişyarî! DApp e unknown + Malicious DApps dikare withdraw hemû yê te funds. Always do yê te own research before using a DApp, granting permission, an sending funds out.\n\nIf someone e urging you to visit ev DApp, it e likely a scam. When in doubt, ji kerema xwe contact Pezkuwi Cîzdan support: %s. + Veke anyway + + Already staking + You nikare stake bi Direct Staking û Nomination Hewz at same time + + Hewz operations in ne available + You dikare no longer use both Direct Staking û Hewz Staking ji same account. Bo manage yê te Hewz Staking you first need to unstake yê te tokens ji Direct Staking. + + Auto-balance nodes + Enable connection + + Migration app dê be unavailable in near future. Use it to migrate yê te accounts to nû Ledger app to avoid losing yê te funds. + + Polkadot Migration + Polkadot + + Nû Ledger app has been released + Bo sign operations û migrate yê te accounts to nû Generic Ledger app install û open Migration app. Legacy Kevn û Migration Ledger apps dê ne be supported in future. + + Ledger Nano X + + Ledger (Legacy) + + Passphrase + + Cîzdan dê be removed in Cloud Paşvekişandin + Ensure you have saved Passphrase ji bo wallet before proceeding. + + + Paşvekişandin password + + We in going to show yê te passphrase. Make sure no one dikare see yê te screen û do ne take screenshots — they dikare be collected by third-party malware + + %s e disabled + You dikare ne sign transactions of disabled networks. Enable %s in settings û try again + + Nederbasdar Yekeya pereyî Symbol + entered Yekeya pereyî Symbol (%1$s) does ne match network (%2$s). Do you want to use correct currency symbol? + + Manage added node + Jê bibe node? + Girêk \"%s\" dê be deleted + + Sererast bike node + Jê bibe node + + Jê bibe network? + You dê ne be able to see yê te token balances on ew network on Malûmilk screen + + Sererast bike network + Jê bibe network + Manage added network + + Jê bibe + + Enter network details + Tê barkirin network info... + + Nederbasdar Zincîr ID + entered Zincîr ID does ne match network in RPC URL. + + Modify + Ev Girêk already exists + entered RPC URL e present in Pezkuwi Cîzdan as a %s network. + entered RPC URL e present in Pezkuwi Cîzdan as a %s custom network. Tu bawer î you want to modify it? + + %s Default Node + %s Default Block Explorer + + Enter details + Substrate + EVM + + Coingecko link ji bo price info (Optional) + + www.coingecko.com/en/coins/tether + + Block explorer URL (Optional) + https://networkscan.io + Zincîr ID + 012345 + Yekeya pereyî Symbol + TOKEN + Tor name + RPC URL + + Bigere by network name + Tê barkirin networks list... + Lê zêde bike network + Lê zêde bike network manually + + Ev Girêk already exists + URL you entered already exists as "%s" Girêk. + + Wrong network + URL you entered e ne corresponding to Girêk ji bo %1$s.\nPlease enter URL of valid %1$s node. + + Girêk error + Girêk URL you entered e either ne responding an contains incorrect format. URL format should start bi "wss://". + + Lê zêde bike custom node ji bo + Sererast bike custom node ji bo + Girêk URL + wss:// + Girêk name + Nav + Enter details + Lê zêde bike node + Tomar bike + + %d ms + Lê zêde bike custom node + + custom nodes + default nodes + Hate lêzêdekirin custom networks\nwill appear li vir + + Building ji bo Pezkuwichain? + Integrate hemû features of network you in building nav Pezkuwi Cîzdan, making it accessible to everyone. + Integrate yê te network + + connecting... + Testnet + Default + Hate lêzêdekirin + Lê zêde bike network + Ne available + Push notifications in only available ji bo devices bi Google services. + Push notifications in available only ji bo Pezkuwi Cîzdan version downloaded ji Google Play. + + By continuing, you agree to our\n%1$s û %2$s + + Na wallets to back up + You haven\'t added her wallets bi a passphrase. + + Têxe existing + Cîzdan changes dê be updated in Cloud Paşvekişandin + Automatically continue in future + + How to add wallet? + How to add wallet? + How to add wallet? + + Ev password e required to encrypt yê te account û e used alongside ev JSON file to restore yê te wallet. + + Download Vegerîne JSON + + Şîfre binivîse + + Polkadot + Ethereum + + Do ne share her of ev information bi anyone - If you do you dê permanently û irretreveably lose hemû of yê te assets + + Custom Key + Default Key + + Derxe JSON file + + Review & Accept to Berdewam bike + + Do ne share yê te passphrase! + Ji kerema xwe read following carefully before viewing yê te backup + Ev passphrase gives you total û permanent access to hemû connected wallets û funds within them.\n%s + DO NOT SHARE IT. + Do ne enter yê te Passphrase nav her form an website.\n%s + FUNDS MAY BE LOST FOREVER. + Support an admins dê never request yê te Passphrase under her circumstances.\n%s + BEWARE OF IMPERSONATORS. + + Hesab bi default key + Hesab bi custom key + Default accounts + %s, +%d others + Hilbijêre key to back up + + Hilbijêre a wallet to back up + Cîzdan changes failed to update in Cloud Paşvekişandin + At moment yê te backup e ne synchronized. Ji kerema xwe review issue. + Review Issue + + Tu bawer î ew you want to apply these changes? + If you have ne manually written down yê te Passphrase ji bo wallets ew dê be removed, then those wallets û hemû of their assets dê be permanently û irretrievably lost forever. + + Cloud Paşvekişandin changes found + At moment yê te backup e ne synchronized. Ji kerema xwe review these updates. + Review Updates + + + Modified + Removed + + Bicîh bîne Paşvekişandin Updates? + Before proceeding bi changes, %s ji bo modified û removed wallets! + ensure you\'ve saved Passphrases + Îmze bike In + + Ne niha + + Paşvekişandin password bû changed + Yê te backup password bû previously updated. Bo continue using Cloud Paşvekişandin, %s + ji kerema xwe enter nû backup password. + + Yê te backup password bû previously updated. Bo continue using Cloud Paşvekişandin, ji kerema xwe enter nû backup password. + + Nûve bike backup password + Ji kerema xwe enter a password ew dê be used to recover yê te wallets ji cloud backup. Ev password dikare\'t be recovered in future, so be sure to remember it! + + Enter current backup password + Ji kerema xwe enter password you created during backup process + + Deleting a backup... + Paşvekişandin dê be deleted ji Google Drive + %s û remember to always keep them offline to restore them anytime. You dikare do ev in Paşvekişandin Mîheng. + Ji kerema xwe write down hemû yê te wallet’s Passphrases before proceeding + + Manage backup + + Jê bibe backup + + Change password + + Enable to backup wallets to yê te Google Drive + Last sync: %s at %s + Îmze bike In to Google Drive + Enter Paşvekişandin Şîfre + Review Paşvekişandin Updates + Review Google Drive Issue + Review Paşvekişandin Çewtî + + Paşvekişandin Syncing... + Paşvekişandin Neçalak + Paşvekişandin Unsynced + Paşvekişandin Synced + Paşvekişandin + Google Drive + You dikare enable Google Drive backups to store encrypted copies of hemû yê te wallets, secured by a password you set. + Manual + You dikare manually back up yê te passphrase to ensure access to yê te wallet’s funds if you lose access to ev device + Paş up to Google Drive + Paş up manually + + Paşvekişandin found but empty an broken + An issue has been identified bi yê te backup. You have option to delete current backup û create a nû one. %s before proceeding. + Ensure you have saved Passphrases ji bo hemû wallets + + Nederbasdar passphrase, ji kerema xwe check one bêtir time words order + Hilbijêre words... + + Recover Cîzdan + Existing Cloud Paşvekişandin found + %s Hemû of yê te wallets dê be safely backed up in Google Drive. + Do you want to recover yê te wallets? + + Paşvekişandin Ne Found + Unfortunately, Paşvekişandin bi yê te wallets has ne been found. + + Tu bawer î you want to delete Cloud Paşvekişandin? + Current backup bi yê te wallets dê be permanently deleted! + + Jê bibe Paşvekişandin + Have you lost yê te password? + %s It\'s exclusively yours, securely stored, inaccessible to others. Without backup password, restoring wallets ji Google Drive e impossible. If lost, delete current backup to create a nû one bi a fresh password. %s + Unfortunately, yê te password nikare be recovered. + Alternatively, use Passphrase ji bo restoration. + + Forgot Şîfre? + Enter backup password + Ji kerema xwe enter password you created during backup process + + Got it + + Remember Paşvekişandin Şîfre + In future, without backup password it e ne possible to restore yê te wallets ji Cloud Paşvekişandin.\n%s + Ev password nikare be recovered. + Create yê te backup password + Ji kerema xwe enter a password to access yê te backup anytime. password dikare’t be recovered, be sure to remember it! + Paşvekişandin password + Bipejirîne password + Min. 8 characters + Numbers + Letters + Passwords match + + Şîfre e invalid + Ji kerema xwe, check password correctness û try again. + + Connection Serneket + Ji kerema xwe, check yê te connection an try again later + + Na Backups Found + Unfortunately, we have ne found a backup to restore wallets + + Google Drive authentication failed + Ji kerema xwe ensure ew you in logged nav yê te Google account bi correct credentials û have granted Pezkuwi Cîzdan access to Google Drive + + Google Drive error + Unable to backup yê te wallets to Google Drive. Ji kerema xwe ensure ew you have enabled Pezkuwi Cîzdan to use yê te Google Drive û have enough available storage space û then try again. + + Ne Enough Storage + You do ne have enough available Google Drive storage. + + Google Play services ne found + Unfortunately, Google Drive e ne working without Google Play services, which in missing on yê te device. Try to get Google Play services + + Yê te wallet e ready + Paşvekişandin + Ev dê only be visible to you û you dikare edit it later. + Niha lets back up yê te wallet. Ev makes sure ew yê te funds in safe û secure. Backups allow you to restore yê te wallet at her time. + Berdewam bike bi Google + Berdewam bike bi manual backup + + Prepare to save yê te wallet! + Tap to reveal + Make sure no one dikare see yê te screen\nand do ne take screenshots + + Ji kerema xwe %s anyone + do ne share bi + + Write down yê te Passphrase + + Give yê te wallet a name + Enter wallet name + + Cloud backup + Recover wallets ji Google Drive + Looking ji bo yê te backup... + + Use yê te 12, 15, 18, 21 an 24-word phrase + Watch-only + + Hardware wallet + + Polkadot Vault, Parity Signer an Ledger + + Choose how you would like to import yê te wallet + + Tiştek şaş çû + Ji kerema xwe try again later by accessing notification settings ji Mîheng tab + + + Select at least %d wallet + Select at least %d wallets + + + Şert û Conditions + Polîtîkaya Nepenîtiyê + + Hate wergirtin +0.6068 KSM ($20.35) ji Kusama staking + Pezkuwi Cîzdan • niha + + You won\'t receive notifications about Cîzdan Activities (Hevseng, Staking) because you haven\'t selected her wallet + + pezkuwi + pezkuwiwallet + + ⬇️ Hate wergirtin %s + ⬇️ Hate wergirtin + Hate wergirtin %s on %s + + 💸 Hate şandin %s + 💸 Hate şandin + Hate şandin %1$s to %2$s on %3$s + + ⭐️ Nû reward + ⭐️ Nû reward %s + Hate wergirtin %s ji %s staking + + ✅ Referandom approved! + %1$s referendum #%2$s has ended û been approved 🎉 + ❌ Referandom rejected! + %1$s referendum #%2$s has ended û been rejected! + 🗳️ Referandom status changed + %1$s Referandom #%2$s status changed ji %3$s to %4$s + %1$s Referandom #%2$s status changed to %3$s + + In queue + + A nû update of Pezkuwi Cîzdan e available! + Download Pezkuwi Cîzdan v%s to get hemû nû features! + 🗳️ Nû referendum + %s Referandom #%s e niha live! + + + You need to select at least %d track + You need to select at least %d tracks + + + Hilbijêre tracks ji bo + %d of %d + + default_notification_channel_id + transactions_notification_channel_id + default_notifications_channel_id + staking_notification_channel_id + Default Notification Channel + Danûstandin Notification Channel + Rêveberî Notification Channel + Staking Agahdarî Channel + + Nû Referandom + Referandom Nûve bike + Delegat bike has voted + Paqij bike + + You nikare select bêtir than %d wallets + + Staking rewards + Others + + Hate wergirtin tokens + Hate şandin tokens + Hevseng + + Pezkuwi announcements + + Enable notifications + Cîzdan + + Tiştek şaş çû. Ji kerema xwe try again + Push notifications + + Don’t miss a thing! + Get notified about Cîzdan operations, Rêveberî updates, Staking activity û Ewlehî, so you’re always in know + Enable push notifications + By enabling push notifications, you agree to our %s û %s + + Due to cross-chain restrictions you dikare transfer ne bêtir than %s + + Yê te balance e too small + You need to have at least %s to pay ev transaction fee û stay above minimum network balance. Yê te current balance e: %s. You need to add %s to yê te balance to perform ev operation. + + %s units of %s + + %s ji bo %s + + Use Proxies to delegate Staking operations to another account + + Hilbijêre stash account to setup proxy + Ji kerema xwe switch yê te wallet to %s to setup a proxy + + %s e ne supported + + Revoke access type + Revoke ji bo + + Lê zêde bike delegation + + Delegated authorities (proxy) + Revoke access + + Staking operations + + Delegating wallet + Delegating account + Grant access type + Delegat bike to + + Lê zêde bike delegation + + Delegasyon already exists + You in already delegating to ev account: %s + + Nederbasdar proxy address + Proxy address should be a valid %s address + + deposit stays reserved on yê te account until proxy e removed. + + Herî zêde number of proxies has been reached + You have reached limit of %s added proxies in %s. Rake proxies to add nû ones. + + Ne enough tokens + You don’t have enough balance ji bo proxy deposit of %s. Peyda balance: %s + + Yê te delegations + Lê zêde bike delegated authority (Proxy) + + Give authority to + Proxy deposit + Lê zêde bike delegation ji bo %s staking + + Enter address… + Delegated account %s doesn’t have enough balance to pay network fee of %s. Peyda balance to pay fee: %s + Na access to controller account + Lê zêde bike yê te controller account in device. + + Staking e an option to earn passive income by locking yê te tokens in network. Staking rewards in allocated every era (6 hours on Kusama û 24 hours on Polkadot). You dikare stake as long as you wish, û ji bo unstaking yê te tokens you need to wait ji bo unstaking period to end, making yê te tokens available to be redeemed. + + %1$s has ne delegated %2$s + + Ne enough tokens to pay fee + + Ev account granted access to perform transactions to following account: + + Ev e Delegating (Proxied) account + Danûstandin dê be initiated by %s as a delegated account. Heqê torê dê be paid by delegated account. + + Oops! Ne enough permission + %1$s delegated %2$s only ji bo %3$s + + Proxied wallets do ne support signing arbitrary messages — only transactions + + %s proxy + Her + Non Veguhestin + Rêveberî + Staking + Identity Judgement + Betal Proxy + Auction + Nomination Hewz + + Zincîr e ne found + Rêveberî type e ne specified + Rêveberî type e ne supported + Nederbasdar url + Domain ji link %s e ne allowed + Gotinên veşartî e ne valid + Crypto type e invalid + Nederbasdar derivation path + + Referandom ne found + + Swap + + Repeat operation + + Swaps + + You should keep at least %s after paying %s network fee as you in holding non sufficient tokens + You must keep at least %s to receive %s token + + Exchange rate between two different cryptocurrencies. It represents how much of one cryptocurrency you dikare get in exchange ji bo a certain amount of another cryptocurrency. + Biha difference refers to difference in price between two different assets. When making a swap in crypto, price difference e usually difference between price of asset you in swapping ji bo û price of asset you in swapping bi. + Swap slippage e a common occurrence in decentralized trading where final price of a swap transaction might slightly differ ji expected price, due to changing market conditions. + A network fees charged by blockchain to process û validate her transactions. May vary depending on network conditions an transaction speed. + + Hilbijêre a token to pay + Hilbijêre a token to receive + Mîqdar binivîse + Enter din amount + + Ne enough liquidity + Ne enough tokens to swap + You dikare’t receive kêmtir than %s + + You dikare use up to %s since you need to pay\n%s ji bo network fee. + + You in trying to swap too small amount + Mîqdar of %s e kêmtir than minimum balance of %s + + Nederbasdar slippage + Slippage must be specified between %s û %s + Swap rate bû updated + Kevn rate: %1$s ≈ %2$s.\nNew rate: %1$s ≈ %3$s + Hewz doesn’t have enough liquidity to swap + Too small amount remains on yê te balance + You should leave at least %1$s on yê te balance. Do you want to perform full swap by adding remaining %2$s as well? + You should keep at least %1$s after paying %2$s network fee û converting %3$s to %4$s to meet %5$s minimum balance.\n\nDo you want to fully swap by adding remaining %6$s as well? + You dikare swap up to %1$s since you need to pay %2$s ji bo network fee. + You dikare swap up to %1$s since you need to pay %2$s ji bo network fee û also convert %3$s to %4$s to meet %5$s minimum balance. + + Swap max + Swap min + + Cross-chain transfer + + Hexadecimal string + + Get %s using + Veguhestin %s ji another network + Werbigire %s bi QR an yê te address + Instantly buy %s bi a credit card + + Get %s + + Token ji bo paying network fee + Heqê torê e added on top of entered amount + Bo pay network fee bi %s, Pezkuwi Cîzdan dê automatically swap %s ji bo %s to maintain yê te account\'s minimum %s balance. + + Enter din value + Enter a value between %s û %s + Danûstandin might be reverted because of low slippage tolerance. + Danûstandin might be frontrun because of high slippage. + Slippage + Swap settings + + % + + Token to pay + Token to receive + + Ev pair e ne supported + %s ≈ %s + + Hilbijêre a token + Pay + Werbigire + + Rate + Biha difference + Slippage + Cîzdan + + Max: + You pay + You receive + Swap details + Swap + Hilbijêre a token + + Boost yê te DOT 🚀 + Hate wergirtin yê te DOT back ji crowdloans? Dest pê bike staking yê te DOT today to get maximum possible rewards! + + Buy tokens + + You don’t have tokens to send.\nBuy an Deposit tokens to yê te account. + %s û %s + daily + + Deciding + Dengdan: Deciding + + Stake anytime. Yê te stake dê actively earn rewards %s + + + Hewz e full + You nikare join pool since it reached maximum number of members + + Hewz e ne open + You nikare join pool ew e ne open. Ji kerema xwe, contact pool owner. + + Tu bawer î you want to close ev screen?\nYour changes dê ne be applied. + + Bigere result dê be displayed li vir + + Na pool bi entered name an pool ID were found. Make sure you entered correct data + + Hilbijêre pool + active pools: %d + members + + Yê te stake e kêmtir than minimum to earn rewards + You have specified kêmtir than minimum stake of %s required to earn rewards bi %s. You should consider using Hewz staking to earn rewards. + Staking type nikare be changed + You in already staking in a pool + You in already have Direct staking + Unsupported staking type + + Herî kêm stake: %s + Xelat: Claim manually + Xelat: Paid automatically + Reuse tokens in Rêveberî + Advanced staking management + + Staking Cure + Hewz staking + Direct staking + + Unstake hemû + + Sorry, you don\'t have enough funds to spend specified amount + + You dikare\'t stake kêmtir than minimal value (%s) + + You need to add a %s account to yê te wallet in order to start staking + You need to add a %s account to wallet in order to delegate + You need to add a %s account to wallet in order to vote + You need to add a %s account to wallet in order to contribute + + + Staking info + Hewz staking info + + You in already have staking in %s + + Unable to stake bêtir + You in unstaking hemû of yê te tokens û dikare\'t stake bêtir. + + Stake max + Yê te available balance e %s, you need to leave %s as minimal balance û pay network fee of %s. You dikare stake ne bêtir than %s. + Staking type + + pool you have selected e inactive due to no validators selected an its stake e kêmtir than minimum.\nAre you sure you want to proceed bi selected Hewz? + + You dikare\'t stake specified amount + You have locked tokens on yê te balance due to %s. In order to continue you should enter kêmtir than %s an bêtir than %s. Bo stake another amount you should remove yê te %s locks. + + %s e currently unavailable + maximum number of nominators has been reached. Dîsa biceribîne later + + Pejirandkar: %d (max %d) + Selected: %d (max %d) + Recommended + + Heqê torê e too high + estimated network fee %s e much higher than default network fee (%s). Ev might be due to temporary network congestion. You dikare refresh to wait ji bo a lower network fee. + Nû bike fee + + Token dê be lost + Recipient e a system account. It e ne controlled by her company an individual.\nAre you sure you still want to perform ev transfer? + + Ev token already exists + entered contract address e present in Pezkuwi Cîzdan as a %s token. Tu bawer î you want to modify it? + + Claim rewards + Yê te tokens dê be added back to stake + + Too many people in unstaking ji yê te pool + There in currently no free spots in unstaking queue ji bo yê te pool. Ji kerema xwe try again in %s + + When unstaking partially, you should leave at least %s in stake. Do you want to perform full unstake by unstaking remaining %s as well? + Too small amount remains in stake + + Cannot perform specified operation since pool e in destroying state. It dê be closed soon. + Hewz e destroying + + Yê te rewards (%s) dê also be claimed û added to yê te free balance + + Yê te pool (#%s) + Yê te pool + + Hewz + Direct + + Dest pê bike Staking + Peyda balance: %1$s (%2$s) + + Earn up to %s + %1$s\non yê te %2$s tokens\nper year + + Stake anytime bi as little as %1$s. Yê te stake dê actively earn rewards %2$s + in %s + %1$s e a %2$s bi %3$s + test network + no token value + + Unstake anytime, û redeem yê te funds %s. Na rewards in earned while unstaking + after %s + + every %s + Xelat accrue %s û added back to stake + Xelat accrue %s û added to transferable balance + Xelat accrue %1$s. Stake over %2$s ji bo automatic rewards payout, otherwise you need to claim rewards manually + Xelat accrue %s. You need to claim rewards manually + Xelat accrue %s + + %s bi yê te staked tokens + Participate in governance + + Stake over %1$s û %2$s bi yê te staked tokens + participate in governance + + Monitor yê te stake + Xelat û staking status vary over time. %s ji time to time + + Find out bêtir information about\n%1$s staking over at %2$s + Pezkuwi Wiki + + See %s + Şert of Use + + Ji bo security reasons generated operations valid ji bo only %s.\nPlease generate nû Koda QR û sign it bi %s + Nederbasdar Koda QR, ji kerema xwe make sure you in scanning Koda QR ji %s + I have an error in %s + Îmze bike bi %s + Following accounts have been successfully read ji %s + Bişopîne Koda QR ji %s + Cîzdan lê zêde bike ji %s + Ev wallet e paired bi %1$s. Pezkuwi Cîzdan dê help you to form her operations you want, û you dê be requested to sign them using %1$s + Ne supported by %s + %s does ne support signing arbitrary messages — only transactions + %s doesn’t support %s + + Pay attention, derivation path name should be empty + + Parity Signer + Polkadot Vault + + Controller Hesab Are Being Deprecated + Nûve bike Controller to Stash + + Na networks an tokens bi entered\nname were found + + Ji: %s + Bo: %s + + %s at %s + + There in no referenda bi filters applied + + Use biometric to authorize + + You in ne authorized. Dîsa biceribîne, ji kerema xwe. + + Mîheng + Biometrics disabled in Mîheng + Ji kerema xwe, make sure biometrics e enabled in Mîheng + + Show + Hemû referenda + Ne voted + Voted + Filters + + Referandom + Bigere by referendum title an ID + Na referenda bi entered title an ID were found + + Pezkuwi Cîzdan uses biometric authentication to restrict unauthorized users ji accessing app. + + Hilbijêre date + Hilbijêre start date + Hilbijêre end date + + Hemû + 7D + 30D + 3M + 6M + 1Y + %dD + + Xelat + + Show staking rewards ji bo + Hemû time + Last 7 days (7D) + Last 30 days (30D) + Last 3 month (3M) + Last 6 months (6M) + Last year (1Y) + Custom period + + Starts + End date e always today + Ends + + Ask authentication ji bo operation signing + Each sign operation on wallets bi key pair (created in Pezkuwi Cîzdan an imported) should require PIN verification before constructing signature + + Biometric auth + Bipejirîne bi PIN + + Navnîşan an w3n + + Recipient nikare accept transfer + Recipient has been blocked by token owner û nikare currently accept incoming transfers + + %s staking + + Bêtir staking options + Stake û earn rewards + Staking via Pezkuwi DApp browser + + Waiting + / year + per year + Estimated rewards + + Expired + + + Network + Networks + + + Required + Optional + + + %s unsupported network is hidden + %s unsupported networks are hidden + + + Hin of required networks requested by \"%s\" in ne supported in Pezkuwi Cîzdan + + + %s account is missing. Add account to the wallet in Settings + %s accounts are missing. Add accounts to the wallet in Settings + + + + %s unsupported + %s unsupported + + + WalletConnect v2 + + Îmze bike Request + + Cîzdan Connect sessions dê appear li vir + + Çalak + None + Tor + Disconnect + + Unknown dApp + Nû connection + WalletConnect + Bişopîne Koda QR + + Function + Contract + Contract call + + Integrity check failed + Pezkuwi Cîzdan detected issues bi integrity of information about %1$s addresses. Ji kerema xwe contact owner of %1$s to resolve integrity issues. + + Token %s e ne supported yet + Pezkuwi Cîzdan dikare\'t resolve code ji bo token %s + %s (%s) + Nederbasdar recipient + Çewtî resolving w3n + %1$s w3n services in unavailable. Dîsa biceribîne later an enter %1$s address manually + %s ne found + Na valid address bû found ji bo %s on %s network + %s addresses ji bo %s + + Na suitable app found on device to handle ev intent + + Ji kerema xwe enable geo-location in device settings + Pezkuwi Cîzdan needs location to be enabled to be able to perform bluetooth scanning to find yê te Ledger device + + Serneket to submit hin transactions + There bû an error while submitting hin transactions. Do you want to retry? + + recommended minimum stake to consistently receive rewards e %s. + + Stake bêtir tokens + + Yê te stake e kêmtir than minimum of %s.\nHaving stake kêmtir than minimum increases chances of staking to ne generate rewards + + Current queue slot + Nû queue slot + + Staking improvements + Having outdated position in queue of stake assignment to a validator may suspend yê te rewards + + Navnîşan format e invalid. Make sure ew address belongs to right network + search results: %d + Bigere by address an name + + Governance v1 + OpenGov + + Pay out hemû (%s) + You dikare pay them out yourself, when they in close to expire, but you dê pay fee + + + Abstain + + After undelegating period has expired, you dê need to unlock yê te tokens. + + Sererast bike delegation + Revoke delegation + + Hilbijêre tracks to add delegation + Hilbijêre tracks to revoke yê te delegation + Hilbijêre tracks to edit delegation + + Revoke + + Main agenda + Fellowship: whitelist + Staking + Treasury: her + Rêveberî: lease + Fellowship: admin + Rêveberî: registrar + Crowdloan + Rêveberî: canceller + Rêveberî: killer + Treasury: small tips + Treasury: big tips + Treasury: small spend + Treasury: medium spend + Treasury: big spend + + Yê te votes: %s via %s + Yê te votes via %s + + Delegat bike + Yê te delegation + + Yê te votes dê automatically vote alongside yê te delegates\' vote + Undelegating period dê start after you revoke a delegation + + Cannot delegate to yourself + You nikare delegate to yourself, ji kerema xwe choose different address + + Undelegating period + + Across %s tracks + + Rake votes + Tracks + + %s (+%s bêtir) + + Voted ji bo hemû time + Delegat bike info + + Voted referenda + + %s votes by %s + + Yê te delegations + + Are you a Delegat bike? + Tell us bêtir about yourself so Pezkuwi Cîzdan users get to know you better + Describe yourself + + Sort by + + Hemû accounts + Organizations + Individuals + + Organization + Individual + + Delegations + Delegated votes + Voted last %s + + Delegations + Delegated votes + Voted last %s + + Peyda tracks + Unavailable tracks + Ji kerema xwe select tracks in which you would like to delegate yê te voting power. + + Hilbijêre hemû + Treasury + Rêveberî + Fellowship + + Hilbijêre at least 1 track... + Na tracks in available to delegate + + Rake history of yê te votes? + + You have previously voted in referendums in %d track. In order to make this track available for delegation, you need to remove your existing votes. + You have previously voted in referendums in %d tracks. In order to make these tracks available for delegation, you need to remove your existing votes. + + + Rake votes + + Unavailable tracks + Rake votes in order to delegate in these tracks + + Tracks which you have existing votes in + Tracks ew you have already delegated votes to + +%d + + Show + + Guhertin %s + + Hemûyan bibîne available updates + Latest + Critical + Major + + Critical update + Bo avoid her issues, û improve yê te user experience, we strongly recommend ew you install recent updates as soon as possible + + Major update + Lots of amazing nû features in available ji bo Pezkuwi Cîzdan! Make sure to update yê te application to access them + + Nûve bike available + Install + + Sorry, you don\'t have right app to process ev request + + Enable + Safe mode + Screen recording û screenshots dê ne be available. minimized app dê ne display content + + Safe mode + + Page settings + Lê zêde bike to Favorites + Rake ji Favorites + Desktop mode + + + entered contract address e ne a %s ERC-20 contract. + + Filter tokens + + Lê zêde bike token + + entered contract address e present in Pezkuwi Cîzdan as a %s token. + + Nederbasdar contract address + + Nederbasdar decimals value + Decimals must be at least 0, û ne over 36. + + Nederbasdar CoinGecko link + Ji kerema xwe make sure supplied url has following form: www.coingecko.com/en/coins/tether. + + Enter contract address + Enter symbol + Enter decimals + Lê zêde bike token + + Enter ERC-20 token details + + Contract address + 0x... + + Symbol + USDT + + Decimals + 18 + + Tor hilbijêre to add ERC-20 token + + Neçalak + + Manage tokens + Hemû networks + + File import application ne found on device. Ji kerema xwe install it û try again + + Referandom information dê appear li vir when they start + + Unsupported chain bi genesis hash %s + + remains locked in %s + + Unlockable + + Reuse governance lock: %s + Reuse hemû locks : %s + + Mîqdar e too big + You don’t have enough available tokens to vote. Peyda to vote: %s. + + Referandom e completed + Referandom e completed û voting has finished + + Already delegating votes + You in delegating votes ji bo selected referendum’s track. Ji kerema xwe either ask yê te delegatee to vote an remove delegation to be able to vote directly. + + Herî zêde number of votes reached + You have reached a maximum of %s votes ji bo track + + Revote + + After locking period don’t forget to unlock yê te tokens + + %s maximum + + Rêveberî lock + Locking period + + Deng bide ji bo %s + Multiply votes by increasing locking period + + Dengdan: Tê amade kirin + Dengdan: Waiting ji bo deposit + Dengdan: In queue + Dengdan: Passing + Dengdan: Ne passing + + Position: %s of %s + + Hate afirandin + Voted: Hate pejirandin + Voted: Hate redkirin + Hate înfazkirin + Cancelled + Killed + Timed out + + %s votes + + Read bêtir + + Ongoing + Temam + + Aye: %s + Nay: %s + Bo pass: %s + + Deng bide + Rêveberî + + Referandom %s + + Threshold: %s of %s + Yê te vote: %s votes + + Hate pejirandin + In queue (%s of %s) + Timed out + Cancelled + Passing + Ne passing + Hate redkirin + Tê amade kirin + Hate înfazkirin + Killed + + Deciding in %s + Waiting ji bo deposit + Dem out in %s + Red bike in %s + Bipejirîne in %s + Execute in %s + + Aye + Nay + + Yê te vote: + Timeline + + Use Pezkuwi DApp browser + + Dengdan status + Requested amount + + Parameters JSON + Too long ji bo preview + Proposer + Deposit + Beneficiary + Deng bide threshold + Turnout + Electorate + Bipejirîne curve + Support curve + Aye votes + Nay votes + + Only proposer dikare edit ev description û title. If you own proposer\'s account, visit Polkassembly û fill in information about yê te proposal + + %s votes + %s × %sx + + Deng bide + Track + Referandom + List of voters dê appear li vir + + Unlock + + Liquid + Parallel + + Crowdloan + + Accept terms... + Yield Boost dê automatically stake %s hemû my transferable tokens above %s + + Stake increase time + Yield Boosted + + Change Yield Boosted Collator? + Yield Boost dê be turned off ji bo current collators. Nû collator: %s + + Ne enough tokens to stay above threshold + You don’t have enough balance to pay network fee of %s û ne drop below threshold %s.\nAvailable balance to pay fee: %s + + Ne enough tokens to pay first execution fee + You don’t have enough balance to pay network fee of %s û yield boost execution fee of %s.\nAvailable balance to pay fee: %s + + Na changes + + without Yield Boost + bi Yield Boost + + Ji bo my collator + I want to stake + to automatically stake %s hemû my transferable tokens above + to automatically stake %s (before: %s) hemû my transferable tokens above + + everyday + + everyday + every %s days + + Boost threshold + + On + Off + Yield Boost + + Ledger doesn’t support %s + + Ledger does ne support signing arbitrary messages — only transactions + + Do ne transfer %s to Ledger-controlled account since Ledger does ne support sending of %s, so assets dê be stuck on ev account + Ledger does ne support ev token + + Îmze e invalid + Ji kerema xwe, make sure you have selected right Ledger device ji bo currently approving operation + + Danûstandin e ne supported + Ledger does ne support ev transaction. + + Metadata e outdated + Ji kerema xwe, update %s app using Ledger Live app + + Danûstandin e valid ji bo %s + + Review û Bipejirîne + Press both buttons on yê te %s to approve transaction + + Ji bo security reasons generated operations valid ji bo only %s. Ji kerema xwe try again û approve it bi Ledger + Danûstandin has expired + + Ev wallet e paired bi Ledger. Pezkuwi Cîzdan dê help you to form her operations you want, û you dê be requested to sign them using Ledger + + Ledger + + Load bêtir accounts + Hesab hilbijêre to add to wallet + + Ledger operation failed + Operation completed bi error on device. Ji kerema xwe, try again later. + + Operation cancelled + Operation bû cancelled by device. Make sure you unlocked yê te Ledger. + + %s app ne launched + Veke %s app on yê te Ledger device + + Hilbijêre yê te Ledger device + Ji kerema xwe, enable Bluetooth in yê te phone settings û Ledger device. Unlock yê te Ledger device û open %s app. + + Tu bawer î you want to cancel ev operation? + Erê + Na + + Lê zêde bike at least one account + Lê zêde bike accounts to yê te wallet + Make sure Tor app e installed to yê te Ledger device using Ledger Live app. Veke network app on yê te Ledger device. + + Hilbijêre hardware wallet + + Tevahî + + Yekeya pereyî + Cryptocurrencies + Fiat currencies + Popular fiat currencies + + Yê te wallets + + Staking + Vesting + Elections + + Tê îmzekirin e ne supported + + Koda QR e invalid + Ji kerema xwe make sure you in scanning Koda QR ji bo currently signing operation + + Dîsa biceribîne + + Koda QR has expired + Koda QR e valid ji bo %s + + Li vir in yê te accounts + + + Permissions needed + Requested permissions in required to use ev screen. + Ask again + + Permissions denied + Requested permissions in required to use ev screen. You should enable them in Mîheng. + Veke Mîheng + + Make sure to select top one + Make sure you have exported yê te wallet before proceeding. + Forget wallet? + + Enter valid %s address... + %s address + + Ev e watch-only wallet, Pezkuwi Cîzdan dikare show you balances û din information, but you nikare perform her transactions bi ev wallet + Hesab lê zêde bike + + Hilbijêre wallet + + watch-only + + Cîzdan têxe + Pezkuwi Cîzdan e compatible bi hemû apps + + Track her wallet by its address + + Okay, back + + Oops! Key e missing + Yê te wallet e watch-only, meaning ew you nikare do her operations bi it + + Lê zêde bike watch-only wallet + + Enter wallet nickname... + Enter valid substrate address... + Evm address must be valid an empty... + + Substrate Navnîşan + Polkadot, Kusama, Karura, KILT û 50+ networks + + EVM address + EVM address (Optional) + Moonbeam, Moonriver û din networks + + Preset wallets + Track activity of her wallet without injecting yê te private key to Pezkuwi Cîzdan + + Token + + Bigere by network an token + + Returns in %s + Bo be returned by parachain + + + %d minute + %d minutes + + Myself + + You don’t have enough balance to pay Cross-chain fee of %s.\nRemaining balance after transfer: %s + + Cross-chain fee e added on top of entered amount. Recipient may receive part of cross-chain fee + + Bo network + Ji network + Cross-chain fee + + On-chain + Cross-chain + Recipient network + to + + Bişîne %s on + Bişîne %s ji + + Cannot stake bi ev collator + selected collator showed intention to stop participating in staking. + + Cannot add stake to ev collator + You nikare add stake to collator ji bo which you in unstaking hemû tokens. + + Manage collators + + Change collator + One of yê te collators e ne selected in current round + + Yê te unstaking period ji bo %s has passed. Don’t forget to redeem yê te tokens + One of yê te collators e ne generating rewards + + Returned tokens dê be counted ji next round + + Na collators available ji bo unstake + You have pending unstake requests ji bo hemû of yê te collators. + + Unstake hemû? + Remaining staking balance dê drop under minimum network value (%s) û dê also be added to unstaking amount + Yê te stake dê be kêmtir than minimum stake (%s) ji bo ev collator. + + You in already unstaking tokens ji ev collator. You dikare only have one pending unstake per collator + You nikare unstake ji ev collator + You dê get increased rewards starting ji next round + You dikare\'t select a nû collator + You have reached maximum number of delegations of %d collators + + Nû collator + Collator\'s minimum stake e higher than yê te delegation. You dê ne receive rewards ji collator. + + Hin of yê te collators in either ne elected an have a higher minimum stake than yê te staked amount. You dê ne receive a reward in ev round staking bi them. + Yê te stake e assigned to next collators + Çalak collators without producing rewards + Collators without enough stake to be elected + Collators ew dê enact in next round + Li bendê collators (%s) + + Yê te collators + + waiting ji bo next round (%s) + You dê ne receive rewards ji bo ev round since none of yê te delegations e active. + + Collator info + Delegators + Estimated rewards (%% APR) + + Collator\'s own stake + Collators: %s + Min. stake + + Token in stake produce rewards each round (%s) + Herî kêm stake should be greater than %s + + %s account e missing + + Mîqdar binivîse… + Hilbijêre collator… + You dê ne receive rewards + Yê te stake must be greater than minimum stake (%s) ji bo ev collator. + %s / year + + Collator + Hilbijêre collator + + Average + Herî zêde + + Earnings bi restake + Earnings without restake + + + Çalak delegators + One an bêtir of yê te collators have been elected by network. + + List of DApps dê appear li vir + + Rake ji Authorized? + “%s” DApp dê be removed ji Authorized + + Catalog + + Authorized DApps + DApps to which you allowed access to see yê te address when you use them + + Title + + Tomar bike + Lê zêde bike to favorites + + Rake ji Favorites? + “%s” DApp dê be removed ji Favorites + + Rake + Favorites + + Mîqdar must be positive + + Phishing detected + Pezkuwi Cîzdan believes ew ev website could compromise security of yê te accounts û yê te tokens + Okay, take me back + + Pezkuwi Cîzdan dê select top validators based on security û profitability criteria + + %s rewarded + + Rewş + + Nominated: + + Change controller + Controller + Stash + + Find out bêtir + Improve staking security + Hilbijêre another account as a controller to delegate staking management operations to it + + Xelat in paid every 2–3 days by validators + + How it works + Payout + Transferable rewards + + Mîqdar you want to unstake e greater than staked balance + Returned tokens dê be counted ji next era + Change validators + Set validators + bi at least one identity contact + Custom amount + Hemû unstaking + Latest unstake + Hilbijêre payout account + + Derbarê rewards + Xelat destination + Stake %s + + Cannot open ev link + + + Nederbasdar recipient + Recipient should be a valid %s address + + Paste + + Sender + Recipient + Bişîne to ev contact + + Danûstandin ID + Cure + + Ne listed + Biha + + Collection + Owned by + Hate afirandin by + + Unlimited series + #%s Edition of %s + Yê te NFTs + + Yê te assets dê appear li vir.\nMake sure "Hide zero balances" filter e turned off + Hide assets bi zero balances + + Yê te account dê be removed ji blockchain after transfer cause it makes total balance lower than minimal. Remaining balance dê be transferred to recipient as well. + Recipient e ne able to accept transfer + Yê te transfer dê fail since destination account does ne have enough %s to accept din token transfers + + Bigire + + Bigere by name an enter URL + Hemû + Make sure operation e correct + Serneket to sign requested operation + Tor + Hesab address + Allow “%s” to access yê te account addresses? + DApp + Bipejirîne ev request if you trust application + Bipejirîne ev request if you trust application.\nCheck transaction details. + Red bike + Allow + + Têkilî Us + Github + Nepenîtî policy + Me binirxînin + Telegram + Şert û conditions + Şert & Conditions + Derbarê + App version + Pezkuwi Wallet v%s + Malper + Hesab already exists. Ji kerema xwe, try another one. + Ji kerema xwe make sure to write down yê te phrase correctly û legibly. + Bipejirîne mnemonic + Let’s double check it + Choose words in right order + Create a nû account + Do ne use clipboard an screenshots on yê te mobile device, try to find secure methods ji bo backup (e.g. paper) + Nav dê be used only locally in ev application. You dikare edit it later + Cîzdan çêke name + Paşvekişandin mnemonic + Gotinên veşartî e used to recover access to account. Write it down, we dê ne be able to recover yê te account without it! + Hesab bi a changed secret + Forget + Nederbasdar Ethereum derivation path + Nederbasdar Substrate derivation path + Ji kerema xwe, try another one. + Ethereum keypair crypto type + Ethereum secret derivation path + Derxe account + Derxe + Set a password ji bo yê te JSON file + Tomar bike yê te secret û store it in a safe place + Write down yê te secret û store it in a safe place + Nederbasdar restore json. Ji kerema xwe, make sure ew input contains valid json. + Seed e invalid. Ji kerema xwe, make sure ew yê te input contains 64 hex symbols. + JSON contains no network information. Ji kerema xwe specify it below. + Provide yê te Vegerîne JSON + Typically 12-word phrase (but may be 15, 18, 21 an 24) + Write words separately bi one space, no commas an din signs + Enter words in right order + Şîfre + 0xAB + Enter yê te raw seed + Hesab + Yê te derivation path contains unsupported symbols an has incorrect structure + Nederbasdar derivation path + JSON file + 12, 15, 18, 21 an 24-word phrase + You don’t have account ji bo ev network, you dikare create an import account. + Hesab needed + Hesab tune found + Taybetî key + Already have an account + 64 hex symbols + Hilbijêre yê te secret type + Hesab bi a shared secret + Substrate keypair crypto type + Substrate secret derivation path + Cîzdan nickname + Lê zêde bike %s account + Cîzdan lê zêde bike + Change %s account + Change account + Purchase initiated! Ji kerema xwe bisekine up to 60 minutes. You dikare track status on email. + Malûmilk + Peyda balance + Sorry, balance checking request failed. Ji kerema xwe, try again later. + Sorry, we couldn\'t contact transfer provider. Ji kerema xwe, try again later. + Veguhestin fee + 0 + Tor ne responding + Bo + Hesab + Lê zêde bike + Navnîşan + Advanced + Mîqdar + Mîqdar e too low + Applied + Bicîh bîne + Attention! + Peyda: %s + Hevseng + Bonus + Call + Betal + Zincîr + Change + Choose network + Temam (%s) + Bipejirîne + Confirmation + Tu bawer î? + Hate pejirandin + Berdewam bike + Hate kopîkirin to clipboard + Navnîşan kopî bike + Kopî bike id + Keypair crypto type + Dîrok + Hûrgulî + Qediya + Sererast bike + Hilbijêre email app + Çewtî + Event + Yê te account dê be removed ji blockchain after ev operation cause it makes total balance lower than minimal + Operation dê remove account + Explore + Sort by: + + %d hour + %d hours + + I understand + Agahdarî + Bêtir hîn bibe + Find out bêtir about + %s left + Module + Paşîn + Sorry, you don\'t have enough funds to pay network fee. + Insufficient balance + Baş e + Hilbijêre an option + Proceed + Ji nû ve bike + Dîsa biceribîne + Bigere + Bigere results: %d + Nepenî derivation path + Parve bike + Derbas bike + Derbas bike process + Dem left + Danûstandin submitted + Ji kerema xwe, try again bi another input. If error appears again, ji kerema xwe, contact support. + Unknown + Unlimited + Nûve bike + Use + Hişyarî + node has already been added previously. Ji kerema xwe, try another node. + Can\'t establish connection bi node. Ji kerema xwe, try another one. + Unfortunately, network e unsupported. Ji kerema xwe, try one of following: %s. + Bipejirîne %s deletion. + Jê bibe network? + Ji kerema xwe, check yê te connection an try again later + Custom + Default + Tor + Lê zêde bike connection + Bişopîne Koda QR + Taybetî crowdloans in ne yet supported. + Taybetî crowdloan + Derbarê crowdloans + Direct + Bêtir hîn bibe about different contributions to Acala + Liquid + Çalak (%s) + Agree to Şert û Conditions + Pezkuwi Cîzdan bonus (%s) + Astar referral code should be a valid Polkadot address + Cannot contribute chosen amount since resulting raised amount dê exceed crowdloan cap. Herî zêde allowed contribution e %s. + Cannot contribute to selected crowdloan since its cap e already reached. + Crowdloan cap exceeded + Contribute to crowdloan + Contribution + You contributed: %s + Yê te contributions\n dê appear li vir + %s (via %s) + Crowdloan + Get a special bonus + Crowdloan dê be displayed li vir + Cannot contribute to selected crowdloan since it e already ended. + Crowdloan e ended + Enter yê te referral code + Crowdloan info + Learn %s\'s crowdloan + %s\'s crowdloan website + Leasing period + Choose parachains to contribute yê te %s. You\'ll get back yê te contributed tokens, û if parachain wins a slot, you\'ll receive rewards after end of auction + Bicîh bîne bonus + If you don\'t have referral code, you dikare apply Pezkuwi referral code to receive bonus ji bo yê te contribution + You have ne applied bonus + Moonbeam crowdloan supports only SR25519 an ED25519 crypto type accounts. Ji kerema xwe consider using another account ji bo contribution + Cannot contribute bi ev account + You should add Moonbeam account to wallet in order to participate in Moonbeam crowdloan + Moonbeam account e missing + Ev crowdloan isn\'t available in yê te location. + Yê te region e ne supported + %s reward destination + Bişîne agreement + You need to submit agreement bi Şert & Conditions on blockchain to proceed. Ev e required to be done only once ji bo hemû following Moonbeam contributions + I have read û agree to Şert û Conditions + Raised + Referral code + Referral code e invalid. Ji kerema xwe, try another one + %s\'s Şert û Conditions + minimum allowed amount to contribute e %s. + Contribution amount e too small + Yê te %s tokens dê be returned after leasing period. + Yê te contributions + Raised: %s of %s + DApps + (BTC/ETH compatible) + ECDSA + ed25519 (alternative) + Edwards + Bipejirîne password + Passwords do ne match + Set password + Tor: %s\nMnemonic: %s\nDerivation path: %s + Tor: %s\nMnemonic: %s + Ji kerema xwe bisekine until fee e calculated + Heq calculation e in progress + History + Email + Legal name + Element name + Identity + Web + Supplied JSON file bû created ji bo different network. + Ji kerema xwe, make sure ew yê te input contains valid json. + Vegerîne JSON e invalid + Ji kerema xwe, check password correctness û try again. + Keystore decryption failed + Paste json + Unsupported encryption type + Cannot import account bi Substrate secret nav network bi Ethereum encryption + Cannot import account bi Ethereum secret nav network bi Substrate encryption + Yê te mnemonic e invalid + Ji kerema xwe, make sure ew yê te input contains 64 hex symbols. + Seed e invalid + QR dikare\'t be decoded + Koda QR + Upload ji gallery + Ziman + Payout transaction sent + Ji kerema xwe, try another one. + Nederbasdar mnemonic passphrase, ji kerema xwe check one bêtir time words order + Heqê torê + Girêk address + Girêk Agahdarî + Tê girêdan… + Create account + Polîtîkaya Nepenîtiyê + Têxe account + Already have a wallet + Şert û Conditions + Biometry + Pin code has been successfully changed + Bipejirîne yê te pin code + Create pin code + Enter PIN code + Set yê te pin code + Hesab + Cîzdan + Ziman + Change pin code + Mîheng + Paste json an upload file… + Upload file + Vegerîne JSON + Gotinên veşartî passphrase + Raw seed + Çavkanî type + Serneket to update information about blockchain runtime. Hin functionality may ne work. + Runtime update failure + Contacts + my accounts + Hesab address an account name + Bigere results dê be displayed li vir + Bigere results + Community + General + Preferences + Ewlehî + Support & Feedback + Twitter + Youtube + sr25519 (recommended) + Schnorrkel + Selected account e already in use as controller + Lê zêde bike controller account %s to application to perform ev action. + Change yê te validators. + Everything e fine niha. Alerts dê appear li vir. + Redeem unstaked tokens + Ji kerema xwe bisekine ji bo next era to start. + Alerts + Already controller + %s APR + %s APY + Staking balance + Hevseng + Stake bêtir + You in neither nominating nor validating + %s of %s + Selected validators + Controller account + We found ew ev account has no free tokens, in you sure ew you want to change controller? + Controller dikare unstake, redeem, return to stake, change rewards destination û validators. + Controller e used to: unstake, redeem, return to stake, change validators û set rewards destination + Controller e changed + Ev validator e blocked û nikare be selected at moment. Ji kerema xwe, try again in next era. + Paqij bike filters + Deselect hemû + Fill rest bi recommended + Pejirandkar: %d of %d + Hilbijêre validators (max %d) + Show selected: %d (max %d) + Hilbijêre validators + Estimated rewards (%% APY) + Nûve bike yê te list + #%d + era #%s + Estimated earnings + Estimated %s earnings + Pejirandkar\'s own stake + Pejirandkar\'s own stake (%s) + Token in unstaking period generate no rewards. + During unstaking period tokens produce no rewards + After unstaking period you dê need to redeem yê te tokens. + After unstaking period don\'t forget to redeem yê te tokens + Yê te rewards dê be increased starting ji next era. + You dê get increased rewards starting ji next era + Staked tokens generate rewards each era (%s). + Token in stake produce rewards each era (%s) + Pezkuwi Cîzdan dê change rewards destination\nto yê te account to avoid remaining stake. + If you want to unstake tokens, you dê have to wait ji bo unstaking period (%s). + Bo unstake tokens you dê have to wait ji bo unstaking period (%s) + Çalak nominators + + %d day + %d days + + Herî kêm stake + %s network + Staked + Manage + %s (max %s) + Herî zêde number of nominators has been reached + Cannot start staking + Monthly + One of yê te validators have been elected by network. + Çalak status + Neçalak status + Yê te staked amount e kêmtir than minimum stake to get a reward. + None of yê te validators have been elected by network. + Yê te staking dê start in next era. + Neçalak + Waiting ji bo next Era + waiting ji bo next era (%s) + Payout expired + + %d day left + %d days left + + Return to stake + Mîqdar you want to return to stake e greater than unstaking balance + Most profitable + Ne oversubscribed + Having onchain identity + Ne slashed + Limit of 2 validators per identity + Recommended validators + Pejirandkar + Estimated reward (APY) + Redeem + Redeemable: %s + Xelat + Era + Xelat details + Pejirandkar + + Perfect! Hemû rewards in paid. + Awesome! You have no unpaid rewards + Li bendê rewards + Unpaid rewards + Xelat (APY) + Xelat destination + Hilbijêre by yourself + Hilbijêre recommended + selected %d (max %d) + Pejirandkar (%d) + Pejirandkar in ne selected + Hilbijêre validators to start staking + Restake + Restake rewards + How to use yê te rewards? + Hilbijêre yê te rewards type + Payout account + Slash + + Staking period + You should trust yê te nominations to act competently û honest, basing yê te decision purely on their current profitability could lead to reduced profits an even loss of funds. + Choose yê te validators carefully, as they should act proficiently û honest. Basing yê te decision purely on profitability could lead to reduced rewards an even loss of stake + Stake bi yê te validators + + Stake bi recommended validators + Dest pê bike staking + Stash dikare bond bêtir û set controller. + Stash e used to: stake bêtir û set controller + Stash account %s e unavailable to update staking setup. + Nominator earns passive income by locking his tokens ji bo securing network. Bo achieve ew, nominator should select a number of validators to support. nominator should be careful when selecting validators. If selected validator won’t behave properly, slashing penalties would be applied to both of them, based on severity of incident. + Pezkuwi Cîzdan provides a support ji bo nominators by helping them to select validators. mobile app fetches data ji blockchain û composes a list of validators, which have: most profits, identity bi contact info, ne slashed û available to receive nominations. Pezkuwi Cîzdan also cares about decentralization, so if one person an a company runs several validator nodes, only up to 2 nodes ji them dê be shown in recommended list. + Who e a nominator? + Xelat ji bo staking in available to payout at end of each era (6 hours in Kusama û 24 hours in Polkadot). Tor stores pending rewards during 84 eras û in most cases validators in paying out rewards ji bo everyone. However, validators might forget an something might happen bi them, so nominators dikare payout their rewards by themselves. + Although rewards in usually distributed by validators, Pezkuwi Cîzdan helps by alerting if there in her unpaid rewards ew in close to expiring. You dê receive alerts about ev û din activities on staking screen. + Tê wergirtin rewards + Staking e an important part of network security û reliability. Anyone dikare run validator nodes, but only those who have enough tokens staked dê be elected by network to participate in composing nû blocks û receive rewards. Pejirandkar often do ne have enough tokens by themselves, so nominators in helping them by locking their tokens ji bo them to achieve required amount of stake. + What e staking? + validator runs a blockchain node 24/7 û e required to have enough stake locked (both owned û provided by nominators) to be elected by network. Pejirandkar should maintain their nodes\' performance û reliability to be rewarded. Being a validator e almost a full-time job, there in companies ew in focused to be validators on blockchain networks. + Everyone dikare be a validator û run a blockchain node, but ew requires a certain level of technical skills û responsibility. Polkadot û Kusama networks have a program, named Thousand Pejirandkar Programme, to provide support ji bo beginners. Moreover, network itself dê always reward bêtir validators, who have kêmtir stake (but enough to be elected) to improve decentralization. + Who e a validator? + Switch yê te account to stash to set controller. + Staking + Rewarded + Tevahî staked + Unstake + Unstaking transactions dê appear li vir + Unstaking transactions dê be displayed li vir + Unstaking: %s + Yê te tokens dê be available to redeem after unstaking period. + You have reached unstaking requests limit (%d active requests). + Unstaking requests limit reached + Unstaking period + Estimated reward (%% APY) + Estimated reward + Pejirandkar info + Oversubscribed. You dê ne receive rewards ji validator in ev era. + Nominators + Oversubscribed. Only top staked nominators in paid rewards. + Own + Na search results.\nBe sure you typed full account address + Pejirandkar e slashed ji bo misbehaves (e.g. goes offline, attacks network, an runs modified software) in network. + Tevahî stake + Tevahî stake (%s) + reward e kêmtir than network fee. + Yearly + Yê te stake e allocated to following validators. + Yê te stake e assigned to next validators + Elected (%s) + Pejirandkar who were ne elected in ev era. + Pejirandkar without enough stake to be elected + Others, who in active without yê te stake allocation. + Çalak validators without yê te stake assignment + Ne elected (%s) + Yê te tokens in allocated to oversubscribed validators. You dê ne receive rewards in ev era. + Yê te stake + Yê te validators + Yê te validators dê change in next era. + Staking + Cîzdan + Îro + Kopî bike hash + Heq + Ji + Extrinsic Hash + Danûstandin details + Bibîne in %s + Bibîne in Polkascan + Bibîne in Subscan + Temam + Serneket + Li bendê + Veguhestin + Incoming û outgoing\ntransfers dê appear li vir + Yê te operations dê be displayed li vir + Nav + Cîzdan name + Ev name dê be displayed only ji bo you û stored locally on yê te mobile device. + Ev account e ne elected by network to participate in current era + Buy + Buy bi + Werbigire + Werbigire %s + Bişîne + Malûmilk + Malûmilk value + Peyda + Staked + Hevseng details + Hevsengiya tevahî + Tevahî after transfer + Frozen + Locked + Redeemable + Reserved + Transferable + Unstaking + Cîzdan + Extrinsic details + Din transactions + Show + Xelat û Slashes + Filters + Veguhestin + Manage assets + Nav examples: Main account, My validator, Dotsama crowdloans, etc. + Parve bike ev QR to sender + Let sender scan ev Koda QR + My %s address to receive %s: + Parve bike Koda QR + Make sure ew address e\nfrom right network + Navnîşan format e invalid.\nMake sure ew address\nbelongs to right network + Minimal balance + Bipejirîne transfer + Yê te transfer dê fail since final amount on destination account dê be kêmtir than minimal balance. Ji kerema xwe, try to increase amount. + Yê te transfer dê remove account ji blockstore since it dê make total balance lower than minimal balance. + Yê te account dê be removed ji blockchain after transfer cause it makes total balance lower than minimal + Veguhestin dê remove account + Following address: %s e known to be used in phishing activities, thus we in ne recommending sending tokens to ew address. Would you like to proceed anyway? + Scam alert + Veguhestin details + Duh + + + Pira DOT ↔ HEZ + Pira DOT ↔ HEZ + Tu dişînî + Tu distînî (texmîn) + Rêjeya guherandinê + Xerca pir + Hindiktirîn + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Biguherîne + Guherandina HEZ→DOT dema ku têra DOT heye tê kirin. + Têra balance tune + Mîqdar ji hindiktirîn kêmtir e + Mîqdar binivîse + Guherandina HEZ→DOT li gorî rewşa DOT sînordar dibe. + Guherandina HEZ→DOT niha tune. Dema DOT têr bibe dîsa biceribîne. + diff --git a/common/src/main/res/values-pl/strings.xml b/common/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..692102e --- /dev/null +++ b/common/src/main/res/values-pl/strings.xml @@ -0,0 +1,2075 @@ + + + Skontaktuj się z nami + Github + Polityka prywatności + Oceń nas + Telegram + Warunki użytkowania + Warunki użytkowania + O aplikacji + Wersja aplikacji + Strona + Wprowadź prawidłowy adres %s... + Adres EVM musi być prawidłowy lub pusty... + Wprowadź prawidłowy adres substrate... + Dodaj konto + Dodaj adres + Konto już istnieje. Proszę wybrać inne. + Śledź dowolny portfel po jego adresie + Dodaj watch-only portfel + Zapisz swoją sekretną frazę + Upewnij się, że zapisałeś frazę poprawnie i czytelnie. + Adres %s + Brak konta na %s + Potwierdź mnemonic + Sprawdźmy to podwójnie + Wybierz słowa w odpowiedniej kolejności + Utwórz nowe konto + Nie używaj schowka ani zrzutów ekranu na swoim urządzeniu mobilnym, postaraj się znaleźć bezpieczne metody tworzenia kopii zapasowych (np. papier) + Nazwa będzie używana wyłącznie lokalnie w tej aplikacji. Możesz ją później edytować + Utwórz nazwę portfela + Kopia zapasowa mnemonika + Utwórz nowy portfel + Mnemonik jest używany do odzyskania dostępu do konta. Zapisz go, nie będziemy w stanie odzyskać Twojego konta bez niego! + Konta z zmienionym sekretem + Zapomnij + Upewnij się, że przed kontynuacją wyeksportowałeś swój portfel. + Zapomnieć portfel? + Nieprawidłowa ścieżka derywacji dla Ethereum + Nieprawidłowa ścieżka derywacji dla Substrate + Ten portfel jest sparowany z %1$s. Pezkuwi pomoże Ci w zainicjowaniu wszelkich operacji, a Ty zostaniesz poproszony o ich podpisanie za pomocą %1$s + Nieobsługiwany przez %s + To jest portfel tylko do podglądu, Pezkuwi może pokazać Ci salda i inne informacje, ale nie możesz wykonywać żadnych transakcji tym portfelem + Wpisz pseudonim portfela... + Proszę, spróbuj inny. + Typ kryptograficznej pary kluczy dla Ethereum + Ścieżka pochodzenia sekretu Ethereum + Konta EVM + Eksportuj konto + Eksport + Importuj istniejące + To hasło jest wymagane do zaszyfrowania Twojego konta i jest używane razem z tym plikiem JSON do przywrócenia portfela. + Ustaw hasło dla swojego pliku JSON + Zapisz swój sekret i przechowuj go w bezpiecznym miejscu + Zapisz swój sekret i przechowuj go w bezpiecznym miejscu + Nieprawidłowy format przywracania json. Proszę upewnij się, że dane zawierają prawidłowy format json. + Seed jest nieprawidłowy. Proszę upewnij się, że wprowadzone dane zawierają 64 symbole hex. + JSON nie zawiera informacji o sieci. Proszę podaj ją poniżej. + Podaj swój plik JSON do przywracania + Zazwyczaj 12 słów (ale możliwe jest importowanie 15, 18, 21 lub 24) + Słowa powinny być rozdzielone spacją, bez przecinków lub innych znaków + Wprowadź słowa we właściwej kolejności + Hasło + Importuj klucz prywatny + 0xAB + Wprowadź swój surowy seed + Pezkuwi jest kompatybilna ze wszystkimi aplikacjami + Importuj portfel + Konto + Twoja ścieżka wyprowadzenia zawiera nieobsługiwane symbole lub ma niepoprawną strukturę + Niepoprawna ścieżka wyprowadzenia + Plik JSON + Upewnij się, że %s na Twoim urządzeniu Ledger przy użyciu aplikacji Ledger Live + Aplikacja Polkadot jest zainstalowana + %s na swoim urządzeniu Ledger + Otwórz aplikację Polkadot + Flex, Stax, Nano X, Nano S Plus + Ledger (Aplikacja Polkadot Ogólna) + Upewnij się, że aplikacja sieciowa jest zainstalowana na Twoim urządzeniu Ledger przy użyciu aplikacji Ledger Live. Otwórz aplikację sieciową na swoim urządzeniu Ledger. + Dodaj przynajmniej jedno konto + Dodaj konta do swojego portfela + Przewodnik połączenia Ledger + Upewnij się %s na swoim urządzeniu Ledger, używając aplikacji Ledger Live + aplikacja Network jest zainstalowana + %s na Twoim urządzeniu Ledger + Otwórz aplikację sieciową + Zezwól Pezkuwi Wallet na %s + dostęp do Bluetooth + %s, które chcesz dodać do portfela + Wybierz konto + %s w ustawieniach telefonu + Włącz OTG + Połącz Ledger + Aby podpisywać operacje i migrować swoje konta do nowej aplikacji Generic Ledger, zainstaluj i otwórz aplikację Migration. Aplikacje Legacy Old i Migration Ledger nie będą wspierane w przyszłości. + Wydano nową aplikację Ledger + Polkadot Migration + Aplikacja Migration będzie wkrótce niedostępna. Użyj jej do migracji swoich kont do nowej aplikacji Ledger, aby nie stracić swoich środków. + Polkadot + Ledger Nano X + Ledger Legacy + Jeśli używasz Ledger przez Bluetooth, włącz Bluetooth na obu urządzeniach i przyznaj Pezkuwi uprawnienia do Bluetooth i lokalizacji. Dla USB, włącz OTG w ustawieniach telefonu. + Proszę, włącz Bluetooth w ustawieniach telefonu i urządzeniu Ledger. Odblokuj urządzenie Ledger i otwórz aplikację %s. + Wybierz swoje urządzenie Ledger + Proszę, włącz urządzenie Ledger. Odblokuj swoje urządzenie Ledger i otwórz aplikację %s. + Jesteś prawie na miejscu! 🎉\n Po prostu stuknij poniżej, aby ukończyć konfigurację i zacząć korzystać ze swoich kont bezproblemowo w Polkadot App i Pezkuwi Wallet + Witamy w Pezkuwi! + fraza o długości 12, 15, 18, 21 lub 24 słowa + Multisig + Wspólna kontrola (multisig) + Nie masz konta w tej sieci, możesz stworzyć lub zaimportować konto. + Potrzebne konto + Nie znaleziono konta + Połącz klucz publiczny + Parity Signer + %s nie wspiera %s + Poniższe konta zostały pomyślnie wczytane z %s + Oto twoje konta + Nieprawidłowy kod QR, proszę upewnij się, że skanujesz kod QR z %s + Upewnij się, że wybrałeś ten na górze + %s aplikacja na Twoim smartfonie + Otwórz Parity Signer + %s, który chcesz dodać do Pezkuwi Wallet + Przejdź do zakładki „Keys”. Wybierz seed, następnie konto + Parity Signer zapewni Ci %s + kod QR do zeskanowania + Dodaj portfel z %s + %s nie obsługuje podpisywania dowolnych wiadomości \n— tylko transakcje + Podpisywanie nie jest obsługiwane + Zeskanuj kod QR z %s + Legacy + Nowy (Vault v7+) + Mam błąd w %s + Kod QR wygasł + Ze względów bezpieczeństwa wygenerowane operacje są ważne tylko przez %s.\nProszę wygenerować nowy kod QR i podpisać go przy użyciu %s + Kod QR jest ważny przez %s + Upewnij się, że skanujesz kod QR dla aktualnie podpisywanej operacji + Podpisz z %s + Polkadot Vault + Zwróć uwagę, że nazwa ścieżki wyznaczania powinna być pusta + %s aplikacja na Twoim smartfonie + Otwórz Polkadot Vault + %s, który chciałbyś dodać do Pezkuwi Wallet + Stuknij Wygenerowany Klucz + Polkadot Vault dostarczy ci %s + kod QR do zeskanowania + Stuknij ikonę w prawym górnym rogu i wybierz %s + Eksportuj Klucz Prywatny + Klucz prywatny + Zastępczy + Delegowane do Ciebie (Proxied) + Dowolne + Aukcja + Anuluj Proxy + Zarządzanie + Ocena tożsamości + Pule nominacji + Brak transferu + Staking + Już posiadasz konto + sekrety + 64 symbole szesnastkowe + Wybierz sprzętowy portfel + Wybierz typ sekretu + Wybierz portfel + Konta z wspólnym sekretem + Konta Substrate + Typ klucza kryptograficznego Substrate + Ścieżka wyprowadzenia sekretu Substrate + Nazwa portfela + Pseudonim portfela + Moonbeam, Moonriver i inne sieci + Adres EVM (opcjonalnie) + Portfele predefiniowane + Polkadot, Kusama, Karura, KILT i 50+ sieci + Śledź aktywność dowolnego portfela bez wprowadzania swojego klucza prywatnego do Pezkuwi Wallet + Twój portfel jest tylko do przeglądania, co oznacza, że nie możesz wykonywać na nim żadnych operacji + Ups! Brak klucza + tylko do przeglądania + Polkadot Vault, Parity Signer lub Ledger + Sprzętowy portfel + Używaj swoich kont Trust Wallet w Pezkuwi + Trust Wallet + Dodaj konto %s + Dodaj portfel + Zmień konto %s + Zmień konto + Ledger (Legacy) + Delegowane do Ciebie + Wspólna kontrola + Dodaj niestandardowy węzeł + Musisz dodać konto %s do portfela, aby delegować + Wprowadź szczegóły sieci + Delegować do + Delegowanie konta + Delegowanie portfela + Typ dostępu + Depozyt pozostaje zarezerwowany na Twoim koncie, dopóki proxy nie zostanie usunięty. + Osiągnąłeś limit %s dodanych proxy w %s. Usuń proxy, aby dodać nowe. + Osiągnięto maksymalną liczbę proxy + Dodane niestandardowe sieci\npojawią się tutaj + +%d + Pezkuwi automatycznie przełączyła się na Twój portfel multisig, abyś mógł zobaczyć oczekujące transakcje. + Kolorowe + Wygląd + ikony tokenów + Białe + Wprowadzony adres kontraktu jest obecny w Pezkuwi jako token %s. + Wprowadzony adres kontraktu jest obecny w Pezkuwi jako token %s. Czy na pewno chcesz go zmodyfikować? + Ten token już istnieje + Proszę upewnij się, że dostarczony adres url ma następującą formę: www.coingecko.com/en/coins/tether. + Nieprawidłowy link do CoinGecko + Wprowadzony adres kontraktu nie jest %s ERC-20 kontraktem. + Nieprawidłowy adres kontraktu + Liczba miejsc dziesiętnych musi być co najmniej 0, a nie więcej niż 36. + Nieprawidłowa wartość miejsc dziesiętnych + Wprowadź adres kontraktu + Wprowadź liczbę miejsc dziesiętnych + Wprowadź symbol + Przejdź do %s + Od %1$s, Twoje saldo %2$s, Staking i Zarządzanie będą na %3$s — z lepszą wydajnością i niższymi kosztami. + Twoje tokeny %s teraz na %s + Sieci + Tokeny + Dodaj token + Adres kontraktu + Liczba miejsc dziesiętnych + Symbol + Wprowadź szczegóły tokena ERC-20 + Wybierz sieć, aby dodać token ERC-20 + Crowdloans + Governance v1 + OpenGov + Wybory + Staking + Vesting + Kup tokeny + Odebrałeś swoje DOT z crowdloans? Zacznij Stake swoje DOT już dziś, aby uzyskać maksymalne możliwe nagrody! + Zwiększ swoje DOT 🚀 + Filtruj tokeny + Nie masz tokenów do podarowania.\nKup lub zdeponuj tokeny na swoje konto. + Wszystkie sieci + Zarządzaj tokenami + Nie przenoś %s na konto zarządzane przez Ledger, ponieważ Ledger nie obsługuje wysyłania %s. W ten sposób aktywa będą zablokowane na tym koncie. + Ledger nie obsługuje tego tokena + Wyszukaj według sieci lub tokena + Nie znaleziono sieci ani tokenów z\npodaną nazwą + Wyszukaj według tokena + Twoje portfele + Nie masz tokenów do wysłania.\nKup lub odbierz tokeny na swoje\nkonto. + Token do zapłaty + Token do odbioru + Przed wprowadzeniem zmian, %s dla zmodyfikowanych i usuniętych portfeli! + upewnij się, że zapisałeś Tajne Frazy + Zastosować aktualizacje kopii zapasowej? + Przygotuj się na zapisanie swojego portfela! + Ta tajna fraza daje Ci pełny i stały dostęp do wszystkich połączonych portfeli i środków w nich.\n%s + NIE UDOSTĘPNIAJ JEJ. + Nie wpisuj swojej tajnej frazy w jakiekolwiek formularze ani na strony internetowe.\n%s + ŚRODKI MOGĄ ZOSTAĆ UTRACONE NA ZAWSZE. + Wsparcie techniczne ani administratorzy nigdy nie poproszą o Twoją Fraze Hasła w żadnych okolicznościach.\n%s + UWAŻAJ NA OSZUSTÓW. + Przejrzyj i Zaakceptuj, aby Kontynuować + Usuń kopię zapasową + Możesz ręcznie utworzyć kopię zapasową swojej frazy hasła, aby zapewnić dostęp do środków w portfelu, jeśli stracisz dostęp do tego urządzenia + Utwórz ręczną kopię zapasową + Ręcznie + Nie dodałeś żadnego portfela z frazą hasła. + Brak portfeli do kopii zapasowej + Możesz włączyć kopie zapasowe Google Drive, aby przechowywać zaszyfrowane kopie wszystkich swoich portfeli zabezpieczone hasłem, które ustawiłeś. + Zapisz na Google Drive + Google Drive + Kopia zapasowa + Kopia zapasowa + Prosty i efektywny proces KYC + Autoryzacja biometryczna + Zamknij wszystkie + Zakup zainicjowany! Proszę czekać do 60 minut. Możesz śledzić status na e-mail. + Wybierz sieć do kupna %s + Zakup zainicjowany! Proszę czekać do 60 minut. Możesz śledzić status w e-mailu. + Żaden z naszych dostawców obecnie nie obsługuje zakupu tego tokena. Proszę wybrać inny token, inną sieć lub sprawdzić później. + Ten token nie jest obsługiwany przez funkcję zakupu + Możliwość płacenia opłat w dowolnym tokenie + Migracja odbywa się automatycznie, nie wymaga działania + Stara historia transakcji pozostaje na %s + Zaczynając od %1$s salda %2$s, Staking i Governance są włączone na %3$s. Te funkcje mogą być niedostępne nawet przez 24 godziny. + %1$sx niższe opłaty transakcyjne\n(z %2$s do %3$s) + %1$sx redukcja minimalnego salda\n(z %2$s do %3$s) + Co czyni Asset Hub niesamowitym? + Rozpoczynając %1$s saldo %2$s, Staking i Governance są na %3$s + Więcej obsługiwanych tokenów: %s i inne tokeny ekosystemu + Zunifikowany dostęp do %s, aktywów, stakingu i zarządzania + Automatyczne równoważenie węzłów + Włącz połączenie + Wprowadź hasło, które będzie używane do odzyskania portfeli z kopii zapasowej w chmurze. Tego hasła nie można będzie odzyskać w przyszłości, więc pamiętaj, aby je zapamiętać! + Zaktualizuj hasło do kopii zapasowej + Aktywo + Dostępny balans + Przepraszamy, żądanie sprawdzenia salda nie powiodło się. Spróbuj ponownie później. + Przepraszamy, nie udało się skontaktować z dostawcą transferu. Spróbuj ponownie później. + Przepraszamy, nie masz wystarczających środków, aby wydać określoną kwotę + Opłata transferowa + Sieć nie odpowiada + Do + Ups, ten prezent został już odebrany + Prezent nie może zostać odebrany + Odbierz prezent + Może być problem z serwerem. Spróbuj ponownie później. + Ups, coś poszło nie tak + Użyj innego portfela, stwórz nowy lub dodaj konto %s do tego portfela w Ustawieniach. + Pomyślnie odebrano prezent. Tokeny pojawią się wkrótce na twoim saldzie. + Masz prezent kryptograficzny! + Stwórz nowy portfel lub zaimportuj istniejący, aby odebrać prezent + Nie można odebrać prezentu z portfelem %s + Wszystkie otwarte karty w przeglądarce DApp zostaną zamknięte. + Zamknąć wszystkie DAppy? + %s i pamiętaj, aby zawsze trzymać je w formie pisemnej, aby móc je odzyskać w każdej chwili. Możesz to zrobić w ustawieniach kopii zapasowej. + Proszę zapisz wszystkie Passphrases do swoich portfeli przed kontynuacją + Kopia zapasowa zostanie usunięta z Google Drive + Obecna kopia zapasowa twoich portfeli zostanie trwale usunięta! + Czy na pewno chcesz usunąć Cloud Backup? + Usuń kopię zapasową + W tej chwili twoja kopia zapasowa nie jest zsynchronizowana. Proszę przejrzyj te aktualizacje. + Znaleziono zmiany w Cloud Backup + Przejrzyj aktualizacje + Jeśli nie zapisałeś ręcznie swojej Passphrase dla portfeli, które zostaną usunięte, to te portfele i wszystkie ich aktywa zostaną na zawsze i nieodwracalnie utracone. + Czy na pewno chcesz zastosować te zmiany? + Przegląd problemu + W tej chwili twoja kopia zapasowa nie jest zsynchronizowana. Proszę, przejrzyj problem. + Nie udało się zaktualizować zmian portfela w Cloud Backup + Upewnij się, że jesteś zalogowany na swoje konto Google z poprawnymi danymi i udzieliłeś Pezkuwi Wallet dostępu do Google Drive + Nieudane uwierzytelnienie Google Drive + Nie masz wystarczającej ilości dostępnego miejsca w Google Drive. + Brak Wystarczającego Miejsca + Niestety, Google Drive nie działa bez usług Google Play, które są na twoim urządzeniu niedostępne. Spróbuj uzyskać dostęp do usług Google Play + Nie znaleziono usług Google Play + Nie można wykonać kopii zapasowej portfeli na Google Drive. Upewnij się, że zezwoliłeś Pezkuwi Wallet na używanie Google Drive i masz wystarczającą ilość wolnego miejsca, a następnie spróbuj ponownie. + Błąd Google Drive + Proszę, sprawdź poprawność hasła i spróbuj ponownie. + Niepoprawne hasło + Niestety, nie znaleźliśmy kopii zapasowej do przywrócenia portfeli + Nie znaleziono kopii zapasowych + Upewnij się, że zapisałeś Passphrase dla portfela przed kontynuowaniem. + Portfel zostanie usunięty z Cloud Backup + Przejrzyj ustawienia błędu kopii zapasowej + Przejrzyj zmiany kopii zapasowej + Wpisz hasło kopii zapasowej + Włącz, aby tworzyć kopie zapasowe portfeli na Google Drive + Ostatnia synchronizacja: %s o %s + Zaloguj się do Google Drive + Przejrzyj problem z Google Drive + Kopia zapasowa wyłączona + Kopia zapasowa zsynchronizowana + Synchronizacja kopii zapasowej... + Kopia zapasowa niezsynchronizowana + Nowe portfele są automatycznie dodawane do Cloud Backup. Możesz wyłączyć Cloud Backup w ustawieniach. + Zmiany portfela zostaną zaktualizowane w Cloud Backup + Zaakceptuj warunki... + Konto + Adres konta + Aktywny + Dodaj + Dodaj delegację + Dodaj sieć + Adres + Zaawansowane + Wszystko + Zezwól + Kwota + Kwota jest za niska + Kwota jest za wysoka + Zastosowano + Zastosuj + Zapytaj ponownie + Przygotuj się do zapisania swojego portfela! + Dostępne: %s + Średni + Saldo + Bonus + Połączenie + Dane wywołania + Hash wywołania + Anuluj + Czy na pewno chcesz anulować tę operację? + Przepraszamy, nie masz odpowiedniej aplikacji do przetworzenia tego żądania + Nie można otworzyć tego linku + Możesz użyć do %s, ponieważ musisz zapłacić\n%s za opłatę sieciową. + Łańcuch + Zmień + Zmień hasło + Automatycznie kontynuuj w przyszłości + Wybierz sieć + Wyczyść + Zamknij + Czy na pewno chcesz zamknąć ten ekran?\nTwoje zmiany nie zostaną zapisane. + Kopia zapasowa w chmurze + Zakończone + Zakończone (%s) + Potwierdź + Potwierdzenie + Czy jesteś pewien? + Potwierdzono + %d ms + łączenie... + Sprawdź swoje połączenie lub spróbuj ponownie później + Niepowodzenie połączenia + Kontynuuj + Skopiowano do schowka + Kopiuj adres + Skopiuj dane wywołania + Skopiuj hash + Kopiuj id + Typ klucza kryptograficznego + Data + %s i %s + Usuń + Depozytariusz + Szczegóły + Wyłączony + Odłącz + Nie zamykaj aplikacji! + Gotowe + Pezkuwi symuluje transakcję wcześniej, aby zapobiec błędom. Ta symulacja nie powiodła się. Spróbuj ponownie później lub z większą kwotą. Jeśli problem będzie się powtarzał, skontaktuj się z pomocą techniczną Pezkuwi Wallet w ustawieniach. + Symulacja transakcji nie powiodła się + Edytuj + %s (+%s więcej) + Wybierz aplikację do poczty + Włącz + Wprowadź adres... + Wprowadź kwotę... + Wprowadź szczegóły + Wprowadź inną kwotę + Wprowadź hasło + Błąd + Niewystarczająca liczba tokenów + Wydarzenie + EVM + Adres EVM + Konto zostanie usunięte z blockchain po tej operacji, ponieważ całkowity stan konta będzie niższy od minimalnego + Operacja usunie konto + Wygasło + Odkryj + Niepowodzenie + Szacowana opłata sieciowa %s jest znacznie wyższa niż domyślna opłata sieciowa (%s). Może to być spowodowane przejściowym przeciążeniem sieci. Możesz odświeżyć stronę, aby poczekać na niższą opłatę. + Opłata sieciowa jest zbyt wysoka + Opłata: %s + Sortuj według: + Filtry + Dowiedz się więcej + Zapomniałeś hasła? + + codziennie + co %d dzień + co %d dzień + co %s dni + + codziennie + codziennie + Pełne szczegóły + Odbierz %s + Prezent + Rozumiem + Zarządzanie + Ciąg szesnastkowy + Ukryj + + %d godzina + %d godziny + %d godzin + %d godzin + + Jak to działa + Rozumiem + Informacja + Kod QR jest nieprawidłowy + Nieprawidłowy kod QR + Dowiedz się więcej + Dowiedz się więcej o + Ledger + Pozostało %s + Zarządzaj portfelami + Maksymalny + %s maksymalnie + + %d minuta + %d minuty + %d minut + %d minut + + Konto %s nie istnieje + Modyfikuj + Moduł + Nazwa + Sieć + Ethereum + %s nie jest obsługiwany + Polkadot + Sieci + + Sieć + Sieci + Sieci + Sieci + + Dalej + Nie + Nie znaleziono aplikacji do importowania plików na urządzeniu. Zainstaluj ją i spróbuj ponownie + Nie znaleziono odpowiedniej aplikacji na urządzeniu do obsługi tego zamiaru + Brak zmian + Zamierzamy pokazać Twoją frazę zabezpieczającą. Upewnij się, że nikt nie widzi Twojego ekranu i nie rób zrzutów ekranu — mogą być zbierane przez złośliwe oprogramowanie stron trzecich + Brak + Niedostępne + Przepraszamy, nie masz wystarczających środków na opłatę sieciową. + Niewystarczające saldo + Nie masz wystarczającego salda, aby zapłacić opłatę sieciową w wysokości %s. Obecne saldo to %s + Nie teraz + Wył + OK + Dobrze, wróć + + W trakcie + Opcjonalne + Wybierz opcję + Frazę zabezpieczającą + Wklej + / rok + %s / rok + na rok + %% + Wymagane uprawnienia są niezbędne do korzystania z tego ekranu. Powinieneś je włączyć w Ustawieniach. + Odmówiono uprawnień + Wymagane uprawnienia są niezbędne do korzystania z tego ekranu. + Wymagane uprawnienia + Cena + Polityka prywatności + Przejdź dalej + Depozyt proxy + Cofnij dostęp + Powiadomienia push + Czytaj więcej + Polecane + Odśwież opłatę + Odrzuć + Usuń + Wymagane + Resetuj + Ponów + Coś poszło nie tak. Proszę spróbuj ponownie + Odwołaj + Zapisz + Skanuj kod QR + Szukaj + Wyniki wyszukiwania będą wyświetlane tutaj + Wyniki wyszukiwania: %d + sek + + %d sekunda + %d sekunda + %d sekunda + %d sekund + + Ścieżka pochodzenia sekretu + Zobacz wszystkie + Wybierz token + Ustawienia + Udostępnij + Udostępnij dane wywołania + Udostępnij hash + Pokaż + Zaloguj się + Żądanie podpisu + Sygnatariusz + Podpis jest nieważny + Pomiń + Pomiń proces + Wystąpił błąd podczas wysyłania niektórych transakcji. Czy chcesz spróbować ponownie? + Nie udało się wysłać niektórych transakcji + Coś poszło nie tak + Sortuj według + Status + Substrate + Adres Substrate + Kliknij, aby ujawnić + Regulamin + Testnet + Pozostały czas + Tytuł + Otwórz Ustawienia + Twój balans jest zbyt mały + Razem + Łączna opłata + ID transakcji + Transakcja wysłana + Spróbuj ponownie + Typ + Spróbuj ponownie, używając innego wejścia. Jeśli błąd pojawi się ponownie, skontaktuj się z pomocą techniczną. + Nieznane + + %s nieobsługiwane + %s nieobsługiwane + %s nieobsługiwane + %s nieobsługiwanych + + Nieograniczone + Aktualizuj + Użyj + Use max + Odbiorca powinien być prawidłowym adresem %s + Nieprawidłowy odbiorca + Wyświetl + Oczekiwanie + Portfel + Ostrzeżenie + Tak + Twój prezent + Kwota musi być dodatnia + Wpisz hasło utworzone podczas procesu tworzenia kopii zapasowej + Wpisz aktualne hasło do kopii zapasowej + Potwierdzenie spowoduje przelanie tokenów z twojego konta + Wybierz słowa... + Niepoprawna fraza, sprawdź jeszcze raz kolejność słów + Referendum + Głosowanie + Śledź + Węzeł został już wcześniej dodany. Spróbuj inny węzeł. + Nie można nawiązać połączenia z węzłem. Spróbuj inny węzeł. + Niestety, sieć nie jest obsługiwana. Proszę spróbować jedną z następujących: %s. + Potwierdź usunięcie %s. + Usunąć sieć? + Proszę sprawdzić połączenie lub spróbować ponownie później + Niestandardowe + Domyślne + Sieci + Dodaj połączenie + Skanuj kod QR + Wykryto problem z twoją kopią zapasową. Masz możliwość usunięcia bieżącej kopii zapasowej i utworzenia nowej. %s przed kontynuowaniem. + Upewnij się, że zapisałeś Passphrases dla wszystkich portfeli + Znaleziono kopię zapasową, ale jest pusta lub uszkodzona + W przyszłości bez hasła kopii zapasowej nie będzie można przywrócić portfeli z kopii zapasowej w Chmurze.\n%s + Tego hasła nie można odzyskać. + Zapamiętaj hasło do kopii zapasowej + Potwierdź hasło + Hasło kopii zapasowej + Litery + Min. 8 znaków + Liczby + Hasła się zgadzają + Proszę wprowadzić hasło, aby uzyskać dostęp do kopii zapasowej w dowolnym momencie. Hasło nie może być odzyskane, pamiętaj o nim! + Utwórz swoje hasło kopii zapasowej + Wprowadzony Chain ID nie pasuje do sieci w adresie URL RPC. + Nieprawidłowy Chain ID + Prywatne crowdloany nie są jeszcze obsługiwane. + Prywatny crowdloan + O crowdloanach + Bezpośredni + Dowiedz się więcej o różnych wkładach w Acala + Liquid + Aktywne (%s) + Zgadzam się na Zasady i Warunki + Bonus Pezkuwi Wallet (%s) + Kod referencyjny Astar powinien być poprawnym adresem Polkadot + Nie można wpłacić wybranej kwoty, ponieważ suma zebranych środków przekroczy limit crowdloanu. Maksymalny dozwolony wkład to %s. + Nie można uczestniczyć w wybranym crowdloanie, ponieważ jego limit został już osiągnięty. + Przekroczono limit crowdloan + Wnieś wkład do crowdloan + Wkład + Wniosłeś: %s + Płynny + Równoległy + Twoje wkłady\n pojawią się tutaj + Zwróci się za %s + Do zwrotu przez parachain + %s (przez %s) + Crowdloans + Zdobądź specjalny bonus + Crowdloans pojawią się tutaj + Nie można wnieść wkładu do wybranego crowdloan, ponieważ już się zakończył. + Crowdloan zakończony + Wprowadź swój kod referencyjny + Informacje o crowdloan + Sprawdź crowdloan %s + Strona crowdloan %s + Okres leasingu + Wybierz parachainy do wniesienia swoich %s. Otrzymasz z powrotem swoje wniesione tokeny, a jeśli parachain wygra slot, otrzymasz nagrody po zakończeniu aukcji + Musisz dodać konto %s do portfela, aby wnieść wkład + Zastosuj bonus + Jeśli nie masz kodu referencyjnego, możesz zastosować kod referencyjny Pezkuwi, aby otrzymać bonus za swój wkład + Nie zastosowano bonusu + Crowdloan Moonbeam obsługuje tylko konta kryptograficzne typu SR25519 lub ED25519. Proszę rozważyć użycie innego konta do wkładu + Nie można wnieść wkładu z tego konta + Powinieneś dodać konto Moonbeam do portfela, aby uczestniczyć w crowdloadzie Moonbeam + Brak konta Moonbeam + Ten crowdloan nie jest dostępny w Twojej lokalizacji. + Twój region nie jest obsługiwany + %s miejsce docelowe nagród + Przyjęcie umowy + Aby kontynuować, musisz zatwierdzić zgodę z Regulaminem na blockchainie. Ta procedura jest wymagana tylko raz dla wszystkich kolejnych wkładów Moonbeam + Przeczytałem i zgadzam się z Regulaminem + Zebrano + Kod referencyjny + Kod referencyjny jest niepoprawny. Proszę spróbować inny. + Regulamin %s + Minimalna dozwolona kwota wkładu wynosi %s. + Kwota wkładu jest za mała + Twoje %s tokeny zostaną zwrócone po okresie leasingu. + Twoje wkłady + Zebrano: %s z %s + Wprowadzony adres URL RPC jest obecny w Pezkuwi jako %s niestandardowa sieć. Czy na pewno chcesz ją zmodyfikować? + https://networkscan.io + Adres URL eksploratora bloków (Opcjonalny) + 012345 + Chain ID + TOKEN + Symbol waluty + Nazwa sieci + Dodaj węzeł + Dodaj niestandardowy węzeł dla + Wprowadź szczegóły + Zapisz + Edytuj niestandardowy węzeł dla + Nazwa + Nazwa węzła + wss:// + Adres URL węzła + Adres URL RPC + DApps, którym zezwoliłeś na dostęp do twojego adresu, gdy z nich korzystasz + DApp „%s” zostanie usunięty z Autoryzowanych + Usunąć z Autoryzowanych? + Autoryzowane DApps + Katalog + Zatwierdź to żądanie, jeśli ufasz aplikacji + Zezwolić „%s” na dostęp do adresów twojego konta? + Zatwierdź to żądanie, jeśli ufasz aplikacji. \nSprawdź szczegóły transakcji. + DApp + DApps + %d DAppy + Ulubione + Ulubione + Dodaj do ulubionych + „%s” DApp zostanie usunięty z Ulubionych + Usunąć z Ulubionych? + Lista DApps pojawi się tutaj + Dodaj do Ulubionych + Tryb pulpitu + Usuń z Ulubionych + Ustawienia strony + OK, zabierz mnie z powrotem + Pezkuwi Wallet uważa, że ta strona może zagrozić bezpieczeństwu twoich kont i tokenów + Wykryto phishing + Szukaj po nazwie lub wprowadź URL + Nieobsługiwana sieć z genesis hashem %s + Upewnij się, że operacja jest poprawna + Nie udało się podpisać żądanej operacji + Otwórz mimo to + Złośliwe DApps mogą wypłacić wszystkie Twoje środki. Zawsze prowadź własne badania przed użyciem DApp, nadaniem uprawnień lub wysłaniem środków.\n\nJeśli ktoś nalega, abyś odwiedził ten DApp, prawdopodobnie jest to oszustwo. W razie wątpliwości, skontaktuj się z pomocą techniczną Pezkuwi Wallet: %s. + Ostrzeżenie! DApp jest nieznany + Sieć nie została znaleziona + Domena z linku %s nie jest dozwolona + Typ zarządzania nie jest określony + Typ zarządzania nie jest obsługiwany + Nieprawidłowy typ kryptografii + Nieprawidłowa ścieżka pochodna + Błędny format mnemonika + Nieprawidłowy format linku + Wprowadzony adres URL RPC jest obecny w Pezkuwi jako %s sieć. + Domyślny kanał powiadomień + +%d + Szukaj po adresie lub nazwie + Format adresu jest niepoprawny. Upewnij się, że adres należy do właściwej sieci + wyniki wyszukiwania: %d + Konta Proxy i Multisig są automatycznie wykrywane i organizowane dla Ciebie. Zarządzaj w dowolnym momencie w Ustawieniach. + Lista portfeli została zaktualizowana + Głosy przez cały czas + Delegat + Wszystkie konta + Osoby fizyczne + Organizacje + Okres Unstaking rozpocznie się po cofnięciu delegacji + Twoje głosy będą automatycznie głosowały wraz z głosami Twoich delegatów + Informacje o delegacie + Osoba fizyczna + Organizacja + Delegowane głosy + Delegacje + Edytuj delegację + Nie możesz delegować do siebie, wybierz inny adres + Nie można delegować do siebie + Powiedz nam więcej o sobie, aby użytkownicy Pezkuwi lepiej Cię poznali + Czy jesteś Delegatem? + Opisz siebie + W %s tracks + Głosy za ostatnie %s + Twoje głosy przez %s + Twoje głosy: %s przez %s + Usuń głosy + Odwołaj delegację + Po upływie okresu oddelegowania będziesz musiał odblokować swoje tokeny. + Delegowane głosy + Delegacje + Głosowane ostatnio %s + Ścieżki + Zaznacz wszystko + Zaznacz co najmniej 1 ścieżkę... + Brak dostępnych ścieżek do delegowania + Fellowship + Zarządzanie + Skarb + Okres oddelegowania + Już nieważne + Twoja delegacja + Twoje delegacje + Pokaż + Usuwanie kopii zapasowej... + Twoje hasło do kopii zapasowej zostało wcześniej zaktualizowane. Aby nadal korzystać z Cloud Backup, %s + wprowadź nowe hasło do kopii zapasowej. + Hasło do kopii zapasowej zostało zmienione + Nie możesz podpisywać transakcji wyłączonych sieci. Włącz %s w ustawieniach i spróbuj ponownie + %s jest wyłączony + Już delegujesz uprawnienia temu kontu: %s + Delegacja już istnieje + (BTC/ETH kompatybilny) + ECDSA + ed25519 (alternatywny) + Edwards + Hasło do kopii zapasowej + Wprowadź dane wywołania + Niewystarczająca ilość tokenów do opłacenia opłaty + Kontrakt + Wywołanie kontraktu + Funkcja + Odzyskaj portfele + %s Wszystkie twoje portfele będą bezpiecznie zapisane na Dysku Google. + Czy chcesz odzyskać swoje portfele? + Znaleziono istniejącą kopię zapasową w chmurze + Pobierz plik JSON do przywracania + Potwierdź hasło + Hasła się nie zgadzają + Ustaw hasło + Sieć: %s\nMnemonika: %s\nŚcieżka pochodna: %s + Sieć: %s\nMnemonika: %s + Proszę czekać, aż prowizja zostanie obliczona + Trwa obliczanie prowizji + Zarządzaj kartą debetową + Sprzedaj token %s + Dodaj delegację dla %s staking + Szczegóły zamiany + Maks: + Płacisz + Otrzymujesz + Wybierz token + Doładuj kartę %s + Proszę skontaktować się z support@pezkuwichain.io. Dołącz adres e-mail, którego użyłeś do wydania karty. + Skontaktuj się z pomocą techniczną + Odebrano + Stworzono: %s + Wprowadź kwotę + Minimalna wartość prezentu to %s + Odzyskano + Wybierz token do podarowania + Opłata sieciowa za odebranie + Stwórz prezent + Wysyłaj prezenty szybko, łatwo i bezpiecznie w Pezkuwi + Udostępniaj prezenty kryptograficzne każdemu, wszędzie + Stworzone przez Ciebie prezenty + Wybierz sieć dla prezentu %s + Wprowadź kwotę prezentu + %s jako link i zaproś kogoś do Pezkuwi + Udostępnij prezent bezpośrednio + %s, a możesz odzyskać nieodebrane prezenty w każdej chwili z tego urządzenia + Prezent jest dostępny natychmiastowo + Kanał powiadomień o zarządzaniu + + Musisz wybrać przynajmniej %d ścieżkę + Musisz wybrać co najmniej %d ścieżki + Musisz wybrać co najmniej %d ścieżek + Musisz wybrać przynajmniej %d ścieżki + + Odblokować + Wykonanie tej zamiany spowoduje znaczący poślizg cenowy i straty finansowe. Rozważ zmniejszenie wielkości swojego handlu lub podzielenie go na kilka transakcji. + Wykryto wysoki wpływ na cenę (%s) + Historia + Email + Imię oficjalne + Imię w Element + Tożsamość + Strona + Dostarczony plik JSON został utworzony dla innej sieci. + Proszę, upewnij się, że dane wejściowe zawierają prawidłowy json. + Przywróć JSON jest nieprawidłowy + Proszę, sprawdź poprawność hasła i spróbuj ponownie. + Niepowodzenie deszyfrowania Keystore + Wklej json + Nieobsługiwany typ szyfrowania + Nie można zaimportować konta z sekretem Substrate do sieci z szyfrowaniem Ethereum + Nie można zaimportować konta z sekretem Ethereum do sieci z szyfrowaniem Substrate + Twoja mnemonika jest nieprawidłowa + Proszę, upewnij się, że dane wejściowe zawierają 64 symbole hex. + Seed jest nieprawidłowy + Niestety, nie znaleziono backupu z twoimi portfelami. + Backup nie znaleziony + Odzyskaj portfele z Google Drive + Użyj swojej frazy o długości 12, 15, 18, 21 lub 24 słowa + Wybierz, jak chcesz zaimportować swój portfel + Watch-only + Zintegruj wszystkie funkcje sieci, którą tworzysz, z Pezkuwi Wallet, udostępniając ją wszystkim. + Zintegruj swoją sieć + Tworzysz dla Polkadot? + Dostarczone dane wywołania są nieprawidłowe lub mają niewłaściwy format. Upewnij się, że są poprawne i spróbuj ponownie. + Te dane wywołania dla innej operacji z hashem wywołania %s + Nieprawidłowe dane wywołania + Adres proxy powinien być prawidłowym adresem %s + Nieprawidłowy adres proxy + Wprowadzony symbol waluty (%1$s) nie pasuje do sieci (%2$s). Czy chcesz użyć poprawnego symbolu waluty? + Nieprawidłowy symbol waluty + Nie można zdekodować QR + QR code + Załaduj z galerii + Eksportuj plik JSON + Język + Ledger nie obsługuje %s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + Operacja została anulowana przez urządzenie. Upewnij się, że odblokowałeś swojego Ledger. + Operacja anulowana + Otwórz aplikację %s na swoim urządzeniu Ledger + Aplikacja %s nie została uruchomiona + Operacja zakończona błędem na urządzeniu. Proszę spróbować ponownie później. + Błąd operacji Ledger + Przytrzymaj przycisk zatwierdzenia na swoim %s, aby zatwierdzić transakcję + Załaduj więcej kont + Przegląd i zatwierdzenie + Konto %s + Konto nie znaleziono + Twoje urządzenie Ledger używa przestarzałej aplikacji ogólnej, która nie obsługuje adresów EVM. Zaktualizuj ją przez Ledger Live. + Zaktualizuj aplikację ogólną Ledger + Naciśnij oba przyciski na swoim %s, aby zatwierdzić transakcję + Proszę, zaktualizuj %s używając aplikacji Ledger Live + Metadane są nieaktualne + Ledger nie obsługuje podpisywania dowolnych wiadomości — tylko transakcji + Proszę, upewnij się, że wybrałeś właściwe urządzenie Ledger do zatwierdzanej obecnie operacji + Dla bezpieczeństwa wygenerowane operacje są ważne tylko przez %s. Prosimy spróbować ponownie i zatwierdzić je za pomocą Ledger + Transakcja wygasła + Transakcja jest ważna przez %s + Ledger nie obsługuje tej transakcji. + Transakcja nieobsługiwana + Naciśnij oba przyciski na swoim %s, aby zatwierdzić adres + Naciśnij przycisk zatwierdzenia na swoim %s, aby zatwierdzić adres + Naciśnij oba przyciski na swoim %s, aby zatwierdzić adresy + Naciśnij przycisk potwierdzenia na swoim %s, aby zatwierdzić adresy + Ten portfel jest sparowany z Ledger. Pezkuwi pomoże ci utworzyć dowolne operacje, jakie chcesz, a ty będziesz musiał je podpisać za pomocą Ledger + Wybierz konto do dodania do portfela + Ładowanie informacji o sieci... + Ładowanie szczegółów transakcji… + Szukanie twojego backupu... + Transakcja wypłaty wysłana + Dodaj token + Zarządzaj backupem + Usuń sieć + Edytuj sieć + Zarządzaj dodaną siecią + Nie będziesz mógł zobaczyć salda swoich tokenów w tej sieci na ekranie Asset + Usunąć sieć? + Usuń węzeł + Edytuj węzeł + Zarządzaj dodanym węzłem + Węzeł \\"%s\\" zostanie usunięty + Usunąć węzeł? + Własny klucz + Domyślny klucz + Nie udostępniaj tych informacji nikomu - jeżeli to zrobisz, trwale i nieodwracalnie stracisz wszystkie swoje aktywa + Konta z własnym kluczem + Domyślne konta + %s, +%d innych + Konta z domyślnym kluczem + Wybierz klucz do backupu + Wybierz portfel do backupu + Proszę dokładnie przeczytać przed wyświetleniem swojego backupu + Nie udostępniaj swojej frazy kluczowej! + Najlepsza cena z opłatami do 3,95% + Upewnij się, że nikt nie widzi twojego ekranu\ni nie rób zrzutów ekranu + Proszę %s nikomu + nie udostępniaj + Proszę, spróbuj inny. + Nieprawidłowa fraza mnemoniczna, proszę jeszcze raz sprawdzić kolejność słów + Nie możesz wybrać więcej niż %d portfeli + + Wybierz co najmniej %d portfel + Wybierz co najmniej %d portfele + Wybierz co najmniej %d portfeli + Wybierz co najmniej %d portfeli + + Ta transakcja została już odrzucona lub wykonana. + Nie można wykonać tej transakcji + %s już zainicjował taką samą operację i obecnie oczekuje ona na podpisanie przez innych sygnatariuszy. + Operacja już istnieje + Aby zarządzać kartą debetową, przełącz się na inny typ portfela. + Karta debetowa nie jest obsługiwana dla Multisig + Depozyt multisig + Depozyt pozostaje zablokowany na koncie depozytariusza do momentu wykonania lub odrzucenia operacji multisig. + Upewnij się, że operacja jest poprawna + Aby utworzyć lub zarządzać prezentami, proszę przełączyć się na inny typ portfela. + Obdarowywanie nie jest obsługiwane dla portfeli Multisig + Podpisano i wykonano przez %s. + ✅ Transakcja multisig wykonana + %s na %s. + ✍🏻 Wymagany Twój podpis + Zainicjowane przez %s. + Portfel: %s + Podpisane przez %s + Zebrano %d z %d podpisów. + W imieniu %s. + Odrzucone przez %s. + ❌ Transakcja multisig odrzucona + W imieniu + %s: %s + Zatwierdź + Zatwierdź i wykonaj + Wprowadź dane wywołania, aby zobaczyć szczegóły + Transakcja została już wykonana lub odrzucona. + Podpisywanie zakończone + Odrzuć + Sygnatariusze (%d z %d) + Batch All (przerywa po błędzie) + Batch (wykonuje do błędu) + Force Batch (ignoruje błędy) + Utworzone przez Ciebie + Brak transakcji.\nŻądania podpisu pojawią się tutaj + Podpisywanie (%s z %s) + Podpisano przez Ciebie + Nieznana operacja + Transakcje do podpisania + W imieniu: + Podpisane przez sygnatariusza + Transakcja multisig wykonana + Wymagany Twój podpis + Transakcja multisig odrzucona + Aby sprzedać kryptowaluty za fiat, przełącz się na inny typ portfela. + Sprzedaż nie jest obsługiwana dla Multisig + Sygnatariusz: + %s nie ma wystarczającego salda, aby umieścić depozyt multisig w wysokości %s. Musisz dodać do swojego salda jeszcze %s + %s nie ma wystarczającego salda, aby zapłacić opłatę sieciową w wysokości %s i umieścić depozyt multisig w wysokości %s. Musisz dodać do swojego salda jeszcze %s + %s potrzebuje co najmniej %s, aby opłacić tę opłatę transakcyjną i pozostać powyżej minimalnego salda sieciowego. Aktualne saldo wynosi: %s + %s nie ma wystarczającego salda, aby zapłacić opłatę sieciową w wysokości %s. Musisz dodać do swojego salda jeszcze %s + Portfele multisig nie obsługują podpisywania dowolnych wiadomości — tylko transakcje + Transakcja zostanie odrzucona. Depozyt multisig zostanie zwrócony do %s. + Transakcja multisig zostanie zainicjowana przez %s. Inicjator płaci opłatę sieciową i rezerwuje depozyt multisig, który zostanie odblokowany po wykonaniu transakcji. + Transakcja multisig + Inni sygnatariusze mogą teraz potwierdzić transakcję.\nMożesz śledzić jej status w %s. + Transakcje do podpisania + Transakcja multisig utworzona + Pokaż szczegóły + Transakcja bez początkowych informacji on-chain (danych wywołania) została odrzucona + %s w dniu %s.\nNie są wymagane żadne dalsze działania z Twojej strony. + Transakcja multisig wykonana + %1$s → %2$s + %s w dniu %s.\nOdrzucone przez: %s.\nNie są wymagane żadne dalsze działania z Twojej strony. + Transakcja multisig odrzucona + Transakcje z tego portfela wymagają zatwierdzenia przez wielu sygnatariuszy. Twoje konto jest jednym z sygnatariuszy: + Inni sygnatariusze: + Próg %d z %d + Kanał powiadomień transakcji multisig + Aktywuj w Ustawieniach + Otrzymuj powiadomienia o prośbach o podpis, nowych podpisach i zakończonych transakcjach — dzięki temu zawsze masz kontrolę. Zarządzaj nimi w dowolnym momencie w Ustawieniach. + Powiadomienia push multisig są dostępne! + Ten węzeł już istnieje + Opłata sieciowa + Adres węzła + Informacje o węźle + Dodane + niestandardowe węzły + domyślne węzły + Domyślne + Łączenie… + Kolekcja + Utworzone przez + %s za %s + %s jednostek z %s + #%s Wydanie z %s + Nieograniczona seria + W posiadaniu + Bez ceny + Twoje NFT + Nie dodałeś ani nie wybrałeś żadnych portfeli multisig w Pezkuwi. Dodaj i wybierz przynajmniej jeden, aby otrzymywać powiadomienia push multisig. + Brak portfeli multisig + Wprowadzony URL już istnieje jako węzeł \"%s\". + Ten węzeł już istnieje + Wprowadzony URL węzła albo nie odpowiada, albo zawiera niepoprawny format. Format URL powinien zaczynać się od \"wss://\". + Błąd węzła + Wprowadzony URL nie odpowiada węzłowi dla %1$s.\nProszę wprowadzić URL prawidłowego węzła %1$s. + Nieprawidłowa sieć + Odbierz nagrody + Twoje tokeny zostaną ponownie dodane do stake + Bezpośredni + Informacje o stakingu w pulach + Twoje nagrody (%s) również zostaną odebrane i dodane do twojego wolnego salda + Pula + Nie można wykonać określonej operacji, ponieważ pula jest w stanie zamykania. Wkrótce zostanie zamknięta. + Pula jest zamykana + Obecnie nie ma wolnych miejsc w kolejce do unstake w twojej puli. Spróbuj ponownie za %s + Zbyt wiele osób wycofuje środki z twojej puli + Twoja pula + Twoja pula (#%s) + Utwórz konto + Utwórz nowy portfel + Polityka prywatności + Importuj konto + Mam już portfel + Kontynuując, zgadzasz się na nasze\n%1$s oraz %2$s + Warunki użytkowania + Swap + Jeden z twoich kolatorów nie generuje nagród + Jeden z twoich kolatorów nie został wybrany w bieżącej rundzie + Twój okres unstaking dla %s minął. Nie zapomnij odebrać swoich tokenów + Nie można stake z tym kolatorem + Zmień kolatora + Nie można dodać stake do tego kolatora + Zarządzaj kolatorami + Wybrany kolator pokazał zamiar zaprzestania uczestnictwa w staking. + Nie możesz dodać stake do kolatora, dla którego wycofujesz wszystkie tokeny. + Twoje stake będzie mniejsze niż minimalne stake (%s) dla tego kolatora. + Pozostałe saldo staking spadnie poniżej minimalnej wartości sieci (%s) i zostanie dodane do ilości unstaking. + Nie jesteś autoryzowany. Spróbuj ponownie, proszę. + Użyj biometrii do autoryzacji + Pezkuwi Wallet używa uwierzytelniania biometrycznego, aby ograniczyć dostęp nieautoryzowanych użytkowników do aplikacji. + Biometria + PIN został pomyślnie zmieniony + Potwierdź swój PIN + Utwórz PIN + Wprowadź PIN + Ustaw swój PIN + Nie możesz dołączyć do puli, ponieważ osiągnęła maksymalną liczbę członków + Pula jest pełna + Nie możesz dołączyć do puli, która nie jest otwarta. Proszę skontaktować się z właścicielem puli. + Pula nie jest otwarta + Nie możesz już używać zarówno Direct Staking, jak i Pool Staking z tego samego konta. Aby zarządzać swoim Pool Staking, najpierw musisz unstake swoje tokeny z Direct Staking. + Operacje Pool są niedostępne + Popularne + Dodaj sieć ręcznie + Ładowanie listy sieci... + Szukaj wg nazwy sieci + Dodaj sieć + %s o %s + 1D + Wszystko + 1M + %s (%s) + Cena %s + 1W + 1Y + Cały czas + Ostatni miesiąc + Dziś + Ostatni tydzień + Ostatni rok + Konta + Portfele + Język + Zmień kod PIN + Otwórz aplikację z ukrytym saldem + Zatwierdź kodem PIN + Tryb bezpieczny + Ustawienia + Aby zarządzać swoją kartą Debit, przełącz się na inny portfel z siecią Polkadot. + Karta Debit nie jest obsługiwana dla tego portfela Proxied + To konto przyznało dostęp do realizacji transakcji następującemu kontu: + Operacje Staking + Delegowane konto %s nie ma wystarczającego salda, aby zapłacić opłatę sieciową %s. Dostępne saldo na zapłatę opłaty: %s + Proxied portfele nie obsługują podpisywania dowolnych wiadomości — tylko transakcji + %1$s nie delegował %2$s + %1$s delegował %2$s tylko do %3$s + Oops! Niewystarczające uprawnienia + Transakcja zostanie zainicjowana przez %s jako konto delegowane. Opłata sieciowa zostanie pokryta przez konto delegowane. + To jest konto delegujące (Proxied) + %s proxy + Delegat zagłosował + Nowy Referendum + Aktualizacja Referendum + %s Referendum #%s jest już dostępny! + 🗳️ Nowy referendum + Pobierz Pezkuwi Wallet v%s, aby uzyskać wszystkie nowe funkcje! + Dostępna jest nowa aktualizacja Pezkuwi Wallet! + %s referendum #%s zakończyło się i zostało zatwierdzone 🎉 + ✅ Referendum zatwierdzone! + %s Referendum #%s zmieniło status z %s na %s + %s referendum #%s zakończyło się i zostało odrzucone! + ❌ Referendum odrzucone! + 🗳️ Status referendum zmieniony + %s Referendum #%s zmieniło status na %s + Ogłoszenia Pezkuwi + Saldo + Włącz powiadomienia + Transakcje multisig + Nie będziesz otrzymywać powiadomień o aktywnościach portfela (Salda, Staking), ponieważ nie wybrałeś żadnego portfela + Inne + Odebrane tokeny + Wysłane tokeny + Nagrody za Staking + Portfele + ⭐🖤 Nowa nagroda %s + Otrzymano %s od %s stakingu + ⭐🖤 Nowa nagroda + Pezkuwi Wallet • teraz + Otrzymano +0.6068 KSM ($20.35) od Kusama stakingu + Otrzymano %s w sieci %s + ⬇️ Otrzymano + ⬇️ Otrzymano %s + Wysłano %s do %s w sieci %s + 💸 Wysłano + 💸 Wysłano %s + Wybierz do %d portfeli, aby otrzymywać powiadomienia, gdy portfel ma aktywność + Włącz powiadomienia push + Otrzymuj powiadomienia o operacjach portfela, aktualizacjach governance, aktywności Staking i bezpieczeństwie, aby zawsze być na bieżąco + Włączając powiadomienia push, zgadzasz się na nasze %s i %s + Spróbuj ponownie później, otwierając ustawienia powiadomień w zakładce Ustawienia + Nie przegap niczego! + Wybierz sieć do otrzymania %s + Skopiuj adres + Jeśli odzyskasz ten prezent, udostępniony link zostanie dezaktywowany, a tokeny zostaną zwrócone do twojego portfela.\nCzy chcesz kontynuować? + Odzyskać prezent %s? + Pomyślnie odzyskano prezent + Wklej json lub załaduj plik… + Załaduj plik + Przywróć JSON + Fraza mnemoniczna + Raw seed + Typ źródła + Wszystkie referenda + Pokaż: + Nie głosowane + Głosowane + Nie znaleziono referendów z podanymi filtrami + Informacje o referendach pojawią się tutaj, kiedy się rozpoczną + Nie znaleziono referendów z podanym tytułem lub ID + Szukaj według tytułu lub ID + %d referenda + Przesuń, aby głosować na referenda z podsumowaniami AI. Szybko i łatwo! + Referenda + Referendum nie znaleziono + Wstrzymanie się od głosu może być dokonane tylko z 0.1x conviction. Głosować z 0.1x conviction? + Aktualizacja conviction + Głosy wstrzymujące się + Za: %s + Użyj przeglądarki Pezkuwi DApp + Tylko wnioskodawca może edytować ten opis i tytuł. Jeśli posiadasz konto wnioskodawcy, odwiedź Polkassembly i uzupełnij informacje o swoim wniosku + Żądana kwota + Oś czasu + Twój głos: + Krzywa zatwierdzenia + Beneficjent + Depozyt + Elektorat + Zbyt długie do podglądu + Parametry JSON + Wnioskodawca + Krzywa wsparcia + Frekwencja + Próg głosowania + Pozycja: %s z %s + Musisz dodać konto %s do portfela, aby głosować + Referendum %s + Przeciw: %s + Głosy \"przeciw\" + %s głosów przez %s + Głosy \"za\" + Staking + Zatwierdzony + Anulowany + Decyzja + Decyzja za %s + Wykonany + W kolejce + W kolejce (%s z %s) + Usunięty + Nieprzechodzący + Przechodzący + Przygotowanie + Odrzucony + Zatwierdzenie za %s + Wykonanie za %s + Przerwa za %s + Odrzucenie za %s + Przerwa czasowa + Oczekiwanie na depozyt + Próg: %s z %s + Głosowano: Zatwierdzono + Anulowano + Utworzono + Głosowanie: Decydowanie + Zrealizowano + Głosowanie: W kolejce + Usunięto + Głosowanie: Nie przechodzi + Głosowanie: Przechodzi + Głosowanie: Przygotowanie + Głosowanie: Odrzucono + Czas minął + Głosowanie: Oczekiwanie na depozyt + Do przejścia: %s + Crowdloans + Skarb: duże wydatki + Skarb: duże napiwki + Fellowship: administracja + Zarządzanie: rejestrator + Zarządzanie: najem + Skarb: średnie wydatki + Zarządzanie: anulowanie + Zarządzanie: usuwanie + Główna agenda + Skarb: małe wydatki + Skarb: małe napiwki + Skarb: dowolne + pozostają zablokowane w %s + Można odblokować + Wstrzymać się + Za + Użyj ponownie wszystkie blokady: %s + Użyj ponownie blokady zarządzania: %s + Zablokowane w zarządzaniu + Okres blokowania + Przeciw + Pomnóż głosy, zwiększając okres blokowania + Głosuj za %s + Po okresie blokowania nie zapomnij odblokować swoich tokenów + Głosowane referenda + %s głosów + %s × %sx + Lista głosujących pojawi się tutaj + %s głosów + Fellowship: whitelist + Twój głos: %s głosów + Referendum jest zakończone, a głosowanie zakończone + Referendum zakończone + Delegujesz głosy na wybrany tor referendum. Proszę poprosić swojego delegata o zagłosowanie lub usuń delegację, aby móc zagłosować bezpośrednio. + Delegowanie głosów + Osiągnięto maksymalną liczbę %s głosów dla toru + Osiągnięto maksymalną liczbę głosów + Nie masz wystarczającej liczby dostępnych tokenów do głosowania. Dostępne do głosowania: %s. + Odwołaj typ dostępu + Odwołaj dla + Usuń głosy + + Głosowałeś wcześniej w referendach na %d torze. Aby ten tor był dostępny do delegowania, musisz usunąć swoje istniejące głosy. + Głosowałeś wcześniej w referendach na %d torach. Aby te tory były dostępne do delegowania, musisz usunąć swoje istniejące głosy. + Głosowałeś wcześniej w referendach na %d torach. Aby te tory były dostępne do delegowania, musisz usunąć swoje istniejące głosy. + Głosowałeś wcześniej w referendach na %d torach. Aby te tory były dostępne do delegowania, musisz usunąć swoje istniejące głosy. + + Usunąć historię głosowania? + %s Jest wyłącznie twoje, bezpiecznie przechowywane, niedostępne dla innych. Bez hasła kopii zapasowej, przywrócenie portfeli z dysku Google jest niemożliwe. Jeśli zgubisz hasło, usuń bieżącą kopię zapasową, aby utworzyć nową z nowym hasłem. %s + Niestety, twoje hasło nie może być odzyskane. + Alternatywnie, użyj Passphrase do przywrócenia. + Czy zgubiłeś swoje hasło? + Twoje hasło do kopii zapasowej zostało wcześniej zaktualizowane. Aby kontynuować korzystanie z kopii zapasowej w chmurze, wprowadź nowe hasło kopii zapasowej. + Proszę, wprowadź hasło utworzone podczas procesu tworzenia kopii zapasowej + Wprowadź hasło kopii zapasowej + Nie udało się zaktualizować informacji o środowisku wykonawczym blockchain. Niektóre funkcje mogą nie działać. + Błąd aktualizacji runtime + Kontakty + moje konta + Nie znaleziono puli o wprowadzonym imieniu lub ID puli. Upewnij się, że wprowadziłeś poprawne dane + Adres konta lub nazwa konta + Tutaj pojawią się wyniki wyszukiwania + Wyniki wyszukiwania + aktywne pule: %d + członkowie + Wybierz pulę + Wybierz trasy do dodania delegacji + Dostępne trasy + Proszę wybrać trasy, w których chcesz delegować swoją moc głosowania. + Wybierz trasy do edycji delegacji + Wybierz trasy do odwołania delegacji + Niedostępne trasy + Wyślij prezent na + Włącz Bluetooth i przyznaj uprawnienia + Pezkuwi wymaga włączenia lokalizacji, aby móc przeprowadzać skanowanie Bluetooth w celu znalezienia urządzenia Ledger + Proszę włączyć geolokalizację w ustawieniach urządzenia + Wybierz sieć + Wybierz token do głosowania + Wybierz trasy dla + %d z %d + Wybierz sieć do sprzedaży %s + Sprzedaż zainicjowana! Proszę czekać do 60 minut. Możesz śledzić status w e-mailu. + Żaden z naszych dostawców obecnie nie obsługuje sprzedaży tego tokena. Proszę wybrać inny token, inną sieć lub sprawdzić później. + Ten token nie jest obsługiwany przez funkcję sprzedaży + Adres lub w3n + Wybierz sieć do wysyłki %s + Odbiorca jest kontem systemowym. Nie jest kontrolowany przez żadną firmę ani osobę prywatną.\nCzy na pewno chcesz wykonać ten transfer? + Tokeny zostaną utracone + Nadaj uprawnienia + Proszę upewnij się, że biometria jest włączona w Ustawieniach + Biometria wyłączona w Ustawieniach + Społeczność + Uzyskaj wsparcie przez Email + Ogólne + Każda operacja podpisania na portfelach z parą kluczy (utworzonych w Pezkuwi Wallet lub zaimportowanych) powinna wymagać weryfikacji PIN przed utworzeniem podpisu + Prośba o uwierzytelnienie dla operacji podpisywania + Preferencje + Powiadomienia push są dostępne tylko dla wersji Pezkuwi Wallet pobranej z Google Play. + Powiadomienia push są dostępne tylko dla urządzeń z usługami Google. + Nagrywanie ekranu i zrzuty ekranu będą niedostępne. Zminimalizowana aplikacja nie będzie wyświetlać zawartości + Tryb bezpieczny + Bezpieczeństwo + Wsparcie i opinie + Twitter + Wiki i Centrum Pomocy + Youtube + Conviction zostanie ustawione na 0.1x podczas Wstrzymania się + Nie możesz stake z Direct Staking i Nomination Pools jednocześnie + Już staking + Zaawansowane zarządzanie Staking + Typ Staking nie może być zmieniony + Masz już Staking bezpośredni + Bezpośredni staking + Określiłeś mniej niż minimalną stawkę %s wymaganą do zdobycia nagród za pomocą %s. Powinieneś rozważyć użycie stakingu w puli, aby zarobić nagrody. + Ponowne użycie tokenów w zarządzaniu + Minimalna stawka: %s + Nagrody: Płatne automatycznie + Nagrody: Pobieraj ręcznie + Już stakeujesz w puli + Staking w puli + Twoja stawka jest mniejsza niż minimalna do zdobycia nagród + Nieobsługiwany typ stakingu + Udostępnij link do prezentu + Odzyskaj + Cześć! Masz prezent %s czekający na Ciebie!\n\nZainstaluj aplikację Pezkuwi Wallet, skonfiguruj swój portfel i odbierz go za pomocą tego specjalnego linka:\n%s + Prezent został przygotowany.\nUdostępnij go teraz! + sr25519 (zalecane) + Schnorrkel + Wybrane konto jest już używane jako kontroler + Dodaj delegowane uprawnienia (Proxy) + Twoje delegacje + Aktywni delegatorzy + Dodaj konto kontrolera %s do aplikacji, aby wykonać tę akcję. + Dodaj delegację + Twój stake jest mniejszy niż minimum %s.\nStake mniejszy niż minimum zmniejsza szanse na generowanie nagród + Stake więcej tokenów + Zmień swoich walidatorów. + Wszystko jest teraz w porządku. Alarmy pojawią się tutaj. + Posiadanie przestarzałej pozycji w kolejce przydzielania stake do walidatora może zawiesić Twoje nagrody + Ulepszenia stakingu + Odbierz unstaked tokeny. + Proszę czekać na rozpoczęcie następnej ery. + Alarmy + Już jesteś kontrolerem + Już masz staking w %s + Saldo stakingu + Saldo + Stake więcej + Nie nominujesz ani nie walidujesz + Zmień kontrolera + Zmień walidatorów + %s z %s + Wybrani walidatorzy + Kontroler + Konto kontrolera + Znaleźliśmy, że to konto nie ma wolnych tokenów, czy na pewno chcesz zmienić kontrolera? + Konto kontrolera może unstake, odebrać, powrócić do stake, zmienić cel nagród i walidatorów. + Kontroler służy do: unstake, odebrać, powrócić do stake, zmienić walidatorów i ustalić cel nagród + Kontroler został zmieniony + Ten walidator jest zablokowany i nie można go wybrać w tej chwili. Spróbuj ponownie w następnej erze. + Wyczyść filtry + Odznacz wszystkie + Wypełnij resztę polecanymi + Walidatorzy: %d z %d + Wybierz walidatorów (maks %d) + Pokaż wybrane: %d (maks %d) + Wybierz walidatorów + Szacowane nagrody (%% APR) + Szacowane nagrody (%% APY) + Zaktualizuj swoją listę + Staking przez przeglądarkę Pezkuwi DApp + Więcej opcji stakingu + Stake i zdobywaj nagrody + %1$s staking jest dostępny na %2$s od %3$s + Szacowane nagrody + era #%s + Szacowane zarobki + Szacowane zarobki %s + Stake walidatora + Stake walidatora (%s) + Tokeny w okresie unstaking nie generują nagród. + Podczas okresu unstaking tokeny nie przynoszą nagród + Po okresie unstaking będziesz musiał odebrać swoje tokeny. + Po okresie unstaking nie zapomnij odebrać swoich tokenów + Twoje nagrody wzrosną od następnej ery. + Otrzymasz zwiększone nagrody od następnej ery + ZaStake\'owane tokeny generują nagrody każdą erę (%s). + Tokeny w stake generują nagrody każdą erę (%s) + Pezkuwi wallet zmieni cel nagród na Twoje konto, aby uniknąć resztek stake. + Jeśli chcesz unstake tokeny, będziesz musiał poczekać na okres unstaking (%s). + Aby unstake tokeny, będziesz musiał poczekać na okres unstaking (%s) + Informacje o stake + Aktywni nominatorzy + + %d dzień + %d dni + %d dni + %d dni + + Minimalny stake + %s sieć + Staked + Proszę przełącz swój portfel na %s, aby ustawić proxy + Wybierz konto stash, aby ustawić proxy + Zarządzanie + %s (maks. %s) + Osiągnięto maksymalną liczbę nominatorów. Spróbuj ponownie później + Nie można rozpocząć staking + Min. stake + Musisz dodać konto %s do swojego portfela, aby rozpocząć staking + Miesięcznie + Dodaj swoje konto kontrolera do urządzenia. + Brak dostępu do konta kontrolera + Nominowany: + %s nagrodzone + Jeden z Twoich walidatorów został wybrany przez sieć. + Status aktywny + Status nieaktywny + Twoja kwota staked jest niższa niż minimalna kwota stake do uzyskania nagrody. + Żaden z twoich walidatorów nie został wybrany przez sieć. + Twój staking rozpocznie się w następnym okresie. + Nieaktywny + Oczekiwanie na następną Erę + oczekiwanie na następną erę (%s) + Nie masz wystarczającego salda na proxy deposit w wysokości %s. Dostępne saldo: %s + Kanał Powiadomień o Staking + Kolator + Minimalny stake kolatora jest wyższy niż twoja delegacja. Nie otrzymasz nagród od kolatora. + Informacje o kolatorze + Własny stake kolatora + Kolatorzy: %s + Jeden lub więcej twoich kolatorów zostało wybranych przez sieć. + Delegatorzy + Osiągnąłeś maksymalną liczbę delegacji %d kolatorów + Nie możesz wybrać nowego kolatora + Nowy kolator + oczekiwanie na następny runda (%s) + Masz oczekujące wnioski o unstake dla wszystkich twoich kolatorów. + Brak kolatorów dostępnych do unstake + Zwrócone tokeny będą liczone od następnego runda + Tokeny w stake przynoszą nagrody w każdej rundzie (%s) + Wybierz kolatora + Wybierz kolatora… + Otrzymasz zwiększone nagrody od następnej rundy + Nie otrzymasz nagród za tę rundę, ponieważ żadna z Twoich delegacji nie jest aktywna. + Już unstakujesz tokeny od tego kolatora. Możesz mieć tylko jeden oczekujący unstake na kolatora + Nie możesz unstake od tego kolatora + Twój stake musi być większy niż minimalny stake (%s) dla tego kolatora. + Nie otrzymasz nagród + Niektórzy z Twoich kolatorów nie zostali wybrani bądź ich minimalny stake jest wyższy od Twojego stakowanego. Nie otrzymasz nagrody w tej rundzie stakingu z nimi. + Twoi kolatorzy + Twój stake został przypisany do następnych kolatorów + Aktywni kolatorzy bez generowania nagród + Kolatorzy bez wystarczającego stake do wyboru + Kolatorzy, których staking rozpocznie się w następnej rundzie + Oczekujący (%s) + Wypłata + Termin wypłaty minął + + %d dzień pozostał + %d dni pozostały + %d dni pozostało + %d dni pozostało + + Możesz wypłacić je samodzielnie, gdy są bliskie wygaśnięcia, ale zapłacisz opłatę. + Nagrody są wypłacane co 2–3 dni przez walidatorów. + Wszystko + Cały czas + Data końcowa zawsze aktualna + Niestandardowy okres + %dD + Wybierz datę końcową + Koniec + Ostatnie 6 miesięcy (6M) + 6M + Ostatnie 30 dni (30D) + 30D + Ostatnie 3 miesiące (3M) + 3M + Wybierz datę + Wybierz datę początkową + Początek + Pokaż nagrody ze Stake + Ostatnie 7 dni (7D) + 7D + Ostatni rok (1Y) + 1Y + Twoje dostępne saldo wynosi %s, musisz pozostawić %s jako minimalne saldo i zapłacić opłatę sieciową w wysokości %s. Możesz stake\'ować nie więcej niż %s. + Delegowane uprawnienia (proxy) + Obecne miejsce w kolejce + Nowe miejsce w kolejce + Powrót do stake + Całe unstaking + Zwrócone tokeny będą liczone od następnej ery + Niestandardowa kwota + Kwota, którą chcesz zwrócić do stake, jest większa niż saldo unstaking + Ostatnie unstaked + Najbardziej zyskowne + Brak przekroczenia limitu nominacji + Posiada onchain identity + Nie został zaslashowany + Limit 2 walidatorów na identyfikację + z przynajmniej jednym kontaktem + Polecani walidatorzy + Walidatorzy + Szacowana nagroda (APY) + Odbierz + Można odebrać: %s + Nagroda + Miejsce przeznaczenia nagrody + Transferowalne nagrody + Era + Szczegóły nagrody + Walidator + Zyski z reinwestycją + Zyski bez reinwestycji + Świetnie! Wszystkie nagrody zostały wypłacone. + Świetnie! Nie masz żadnych niewypłaconych nagród. + Wypłać wszystko (%s) + Oczekujące nagrody + Niewypłacone nagrody + %s nagrody + O nagrodach + Nagrody (APY) + Miejsce przeznaczenia nagród + Wybierz sam + Wybierz konto do wypłat + Wybierz rekomendowane + wybrano %d (maks. %d) + Walidatory (%d) + Aktualizuj Kontroler na Stash + Użyj Proxies, aby delegować operacje Staking na inne konto + Kontroler Konta są Przedawniane + Wybierz inne konto jako kontroler, aby delegować operacje zarządzania stakingiem + Zwiększ bezpieczeństwo stakingu + Ustaw walidatory + Weryfikatorzy nie zostali wybrani + Wybierz weryfikatorów, aby rozpocząć staking + Rekomendowana minimalna kwota stake do regularnego otrzymywania nagród wynosi %s. + Nie możesz stakingować mniej niż minimalna wartość sieci (%s) + Minimalny stake powinien być większy niż %s + Ponowne stakingowanie + Ponowne stakingowanie nagród + Jak wykorzystać swoje nagrody? + Wybierz rodzaj nagród + Konto wypłat + Cięcie + Stake %s + Stake max + Okres stakingu + Rodzaj stakingu + Powinieneś ufać swoim nominacjom, aby działały kompetentnie i uczciwie. Podejmowanie decyzji wyłącznie na podstawie ich bieżącej rentowności może doprowadzić do zmniejszenia zysków, a nawet utraty środków. + Wybieraj weryfikatorów starannie, ponieważ powinni działać profesjonalnie i uczciwie. Podejmowanie decyzji wyłącznie na podstawie rentowności może prowadzić do zmniejszenia nagród, a nawet utraty stake + Stake z wybranymi weryfikatorami + Pezkuwi Wallet wybierze najlepszych walidatorów na podstawie kryteriów bezpieczeństwa i rentowności + Stake z rekomendowanymi walidatorami + Rozpocznij staking + Stash + Stash może związać więcej i ustawić kontrolera. + Stash jest używany do: stake więcej i ustawienie kontrolera + Konto stash %s jest niedostępne do aktualizacji ustawień staking. + Nominator zarabia pasywny dochód blokując swoje tokeny dla zabezpieczenia sieci. Aby to osiągnąć, nominator powinien wybrać kilku walidatorów do wsparcia. Nominator powinien być ostrożny przy wyborze walidatorów. Jeśli wybrany walidator nie będzie zachowywał się poprawnie, kary w postaci slashingu będą stosowane wobec obu, w zależności od powagi incydentu. + Pezkuwi Wallet zapewnia wsparcie dla nominatorów, pomagając im w wyborze walidatorów. Aplikacja mobilna pobiera dane z blockchaina i tworzy listę walidatorów, którzy mają: największe zyski, tożsamość z danymi kontaktowymi, nie zostali ukarani oraz są dostępni do nominacji. Pezkuwi Wallet również dba o decentralizację, więc jeśli jedna osoba lub firma prowadzi kilka węzłów walidatorów, w rekomendowanej liście znajdą się tylko maksymalnie 2 z nich. + Kim jest nominator? + Nagrody za Staking są dostępne do wypłaty na koniec każdej ery (6 godzin w Kusama i 24 godziny w Polkadot). Sieć przechowuje oczekujące nagrody przez 84 ery i w większości przypadków walidatorzy wypłacają nagrody wszystkim. Jednak walidatorzy mogą zapomnieć lub może się coś z nimi stać, więc nominatorzy mogą wypłacić swoje nagrody samodzielnie. + Chociaż nagrody są zazwyczaj dystrybuowane przez walidatorów, Pezkuwi Wallet pomaga, ostrzegając, jeśli są jakieś niezapłacone nagrody, które są bliskie wygaśnięcia. Otrzymasz powiadomienia o tym i innych działaniach na ekranie Staking. + Odbieranie nagród + Staking jest opcją zarabiania pasywnego dochodu poprzez blokowanie swoich tokenów w sieci. Nagrody za staking są przydzielane co erę (6 godzin na Kusama i 24 godziny na Polkadot). Możesz stakować tak długo, jak chcesz, a w celu odblokowania swoich tokenów musisz poczekać do końca okresu unstaking, co czyni twoje tokeny dostępnymi do wykupu. + Staking jest ważnym elementem bezpieczeństwa i niezawodności sieci. Każdy może uruchomić węzeł walidatora, ale tylko ci, którzy mają wystarczająco dużo tokenów staked, zostaną wybrani przez sieć do udziału w tworzeniu nowych bloków i odbieraniu nagród. Walidatorzy często nie mają wystarczającej ilości tokenów na własną rękę, więc nominatorzy pomagają im, blokując swoje tokeny, aby osiągnąć wymaganą wartość stake. + Czym jest staking? + Validator uruchamia węzeł blockchain 24/7 i musi mieć wystarczającą ilość stake zablokowaną (zarówno własną, jak i dostarczoną przez nominatorów), aby zostać wybranym przez sieć. Validatorzy powinni utrzymywać wydajność i niezawodność swoich węzłów, aby być wynagradzanymi. Bycie validatorem to prawie pełnoetatowa praca; są firmy, które specjalizują się w byciu validatorami na sieciach blockchain. + Każdy może zostać validatorem i uruchomić węzeł blockchain, ale wymaga to pewnego poziomu umiejętności technicznych i odpowiedzialności. Sieci Polkadot i Kusama mają program o nazwie Thousand Validators Programme, aby zapewnić wsparcie dla początkujących. Ponadto sama sieć zawsze będzie nagradzać więcej validatorów, którzy mają mniej stake (ale wystarczająco, aby zostać wybranym), aby poprawić decentralizację. + Kim jest validator? + Zmień swoje konto na stash, aby ustawić kontroler. + Staking + %s staking + Nagrodzono + Łączna ilość staked + Próg Boost + Dla mojego kollatora + bez Yield Boost + z Yield Boost + aby automatycznie stać %s wszystkimi moimi transferowalnymi tokenami powyżej + aby automatycznie stać %s (wcześniej: %s) wszystkimi moimi transferowalnymi tokenami powyżej + Chcę stać + Yield Boost + Typ Staking + Wycofujesz wszystkie swoje tokeny i nie możesz dodać więcej. + Niemożliwe dodać więcej tokenów w Staking + Podczas częściowego wycofania powinieneś zostawić przynajmniej %s w Stake. Czy chcesz wykonać pełne wycofanie, wycofując również pozostałe %s? + Zbyt mała kwota pozostaje w Stake + Kwota, którą chcesz unstakeować, jest większa niż stakowany balans + Unstake + Operacje unstakeowania pojawią się tutaj + Operacje unstakeowania będą wyświetlane tutaj + Unstaking: %s + Twoje tokeny będą dostępne do wykupu po okresie unstakeowania. + Osiągnąłeś limit zapytań unstakeowania (%d aktywnych zapytań). + Osiągnięto limit zapytań unstakeowania + Okres unstakeowania + Unstake wszystko + Unstake wszystko? + Szacowana nagroda (%% APY) + Szacowana nagroda + Informacje o walidatorze + Przekroczona liczba subs. Nie otrzymasz nagród od tego walidatora w tej erze. + Nominatorzy + Przekroczona liczba subs. Tylko najlepsi nominatorzy z największym Stake otrzymają nagrody. + Własny + Brak wyników wyszukiwania. Upewnij się, że wpisałeś pełny adres konta + Walidator został ukarany za niewłaściwe zachowanie (np. offline, atakowanie sieci lub korzystanie ze zmodyfikowanego oprogramowania) w sieci. + Całkowity Stake + Całkowity Stake (%s) + Nagroda jest mniejsza niż opłata sieciowa. + Rocznie + Twój Stake jest przypisany do następujących walidatorów. + Twój Stake jest przydzielony do następujących walidatorów + Wybrani (%s) + Walidatorzy, którzy nie zostali wybrani w tej erze. + Walidatorzy z niewystarczającym Stake, by zostać wybranymi + Inni, którzy są aktywni bez twojego przydziału Stake. + Aktywni walidatorzy bez twojego przydziału Stake + Nie wybrani (%s) + Twoje tokeny są przydzielane do przeliczonych validatorów. Nie otrzymasz nagród w tej erze. + Nagrody + Twój stake + Twoi validatorzy + Twoi validatorzy zmienią się w następnej erze. + Teraz zróbmy kopię zapasową Twojego portfela. To zapewnia bezpieczeństwo Twoich środków. Kopie zapasowe pozwalają na przywrócenie portfela w dowolnym momencie. + Kontynuuj z Google + Wprowadź nazwę portfela + Mój nowy portfel + Kontynuuj z ręczną kopią zapasową + Nazwij swój portfel + Będzie to widoczne tylko dla Ciebie i możesz to zmienić później. + Twój portfel jest gotowy + Bluetooth + USB + Masz zablokowane tokeny na swoim saldzie z powodu %s. Aby kontynuować, powinieneś wprowadzić mniej niż %s lub więcej niż %s. Aby zablokować inną kwotę, musisz usunąć swoje blokady %s. + Nie możesz zablokować podanej kwoty + Wybrane: %d (maks. %d) + Dostępne saldo: %1$s (%2$s) + %s z Twoimi tokenami w stake + Weź udział w zarządzaniu + Stake więcej niż %1$s i %2$s z Twoimi tokenami w stake + uczestnicz w zarządzaniu + Stake w dowolnym momencie z kwotą od %1$s. Twój stake będzie aktywnie zarabiać nagrody %2$s + za %s + Stake w dowolnym momencie. Twój stake będzie aktywnie zarabiać nagrody %s + Dowiedz się więcej o\n%1$s stakowaniu na %2$s + Pezkuwi Wiki + Nagrody naliczają się %1$s. Stake na ponad %2$s dla automatycznej wypłaty nagród, w przeciwnym razie musisz odbierać nagrody ręcznie + co %s + Nagrody naliczają się %s + Nagrody naliczają się %s. Musisz odbierać nagrody ręcznie + Nagrody naliczają się %s i dodawane są do przenoszalnego salda + Nagrody naliczają się %s i dodawane są z powrotem do stake + Nagrody i status staking zmieniają się z czasem. %s od czasu do czasu + Monitoruj swój stake + Start Staking + Zobacz %s + Warunki użytkowania + %1$s to %2$s z %3$s + bez wartości tokena + sieć testowa + %1$s\nna twoich tokenach %2$s\nrocznie + Zarabiaj do %s + Anuluj w dowolnym momencie i odbierz swoje środki %s. Podczas unstaking nie są naliczane żadne nagrody + po %s + Wybrany przez Ciebie pool jest nieaktywny, ponieważ nie wybrano walidatorów lub jego stake jest niższy niż minimalny.\nCzy na pewno chcesz kontynuować z wybranym pool? + Osiągnięto maksymalną liczbę nominatorów. Spróbuj ponownie później + %s jest obecnie niedostępny + Walidatorzy: %d (max %d) + Zmodyfikowany + Nowy + Usunięty + Token do opłacenia opłaty sieciowej + Opłata sieciowa jest dodawana do wprowadzonej kwoty + Symulacja kroku wymiany nie powiodła się + Ta para nie jest obsługiwana + Niepowodzenie w operacji #%s (%s) + Zamiana %s na %s na %s + Transfer %s z %s do %s + + %s operacja + + + %s operacji + + %s z %s operacji + Zamiana %s na %s na %s + Transfer %s do %s + Czas wykonania + Powinieneś zachować co najmniej %s po opłaceniu %s opłaty sieciowej, ponieważ posiadasz niewystarczającą ilość tokenów + Musisz zachować co najmniej %s, aby otrzymać token %s + Musisz mieć co najmniej %s na %s, aby otrzymać %s token + Możesz zamienić do %1$s, ponieważ musisz zapłacić %2$s jako opłatę sieciową. + Możesz zamienić do %1$s, ponieważ musisz zapłacić %2$s jako opłatę sieciową oraz przekonwertować %3$s na %4$s, aby spełnić minimalny bilans %5$s. + Zamień max + Zamień min + Powinno pozostać co najmniej %1$s na twoim saldzie. Czy chcesz wykonać pełną wymianę, dodając również pozostałe %2$s? + Za mała ilość pozostała na saldzie + Powinieneś zachować co najmniej %1$s po zapłaceniu opłaty sieciowej %2$s i zamianie %3$s na %4$s, aby osiągnąć minimalne saldo %5$s.\n\nCzy chcesz dokonać pełnej wymiany, dodając również %6$s? + Zapłać + Otrzymaj + Wybierz token + Niewystarczająca ilość tokenów do wymiany + Niewystarczająca płynność + Nie możesz otrzymać mniej niż %s + Kup natychmiast %s za pomocą karty kredytowej + Przenieś %s z innej sieci + Otrzymaj %s za pomocą QR lub swojego adresu + Otrzymaj %s za pomocą + Podczas wykonywania swapu pośrednia ilość odbierana wynosi %s co jest mniej niż minimalne saldo %s. Spróbuj określić większą kwotę swapu. + Poślizg musi być określony między %s a %s + Niedozwolony poślizg + Wybierz token do zapłaty + Wybierz token do otrzymania + Wprowadź kwotę + Wprowadź inną kwotę + Aby zapłacić opłatę sieciową za pomocą %s, Pezkuwi automatycznie zamieni %s na %s, aby utrzymać minimalne saldo %s na twoim koncie. + Opłaty sieciowe pobierane przez blockchain za przetwarzanie i walidację dowolnych transakcji. Mogą się różnić w zależności od warunków sieci lub szybkości transakcji. + Wybierz sieć do zamiany %s + W puli brakuje płynności do wymiany + Różnica cenowa odnosi się do różnicy w cenie pomiędzy dwoma różnymi aktywami. Podczas wymiany w kryptowalutach, różnica cenowa zazwyczaj oznacza różnicę między ceną aktywa, którą otrzymujesz, a ceną aktywa, którą płacisz. + Różnica cen + %s ≈ %s + Kurs wymiany między dwiema różnymi kryptowalutami. Oznacza to, ile jednej kryptowaluty możesz otrzymać w zamian za określoną ilość innej kryptowaluty. + Kurs + Stary kurs: %1$s ≈ %2$s.\nNowy kurs: %1$s ≈ %3$s + Kurs wymiany został zaktualizowany + Powtórz operację + Trasa + Droga, którą twoje tokeny przejdą przez różne sieci, aby uzyskać żądany token. + Swap + Transfer + Ustawienia wymiany + Poślizg + Poślizg wymiany jest powszechnym zjawiskiem w zdecentralizowanym handlu, gdzie ostateczna cena transakcji wymiany może nieznacznie różnić się od oczekiwanej ceny, z powodu zmieniających się warunków rynkowych. + Wprowadź inną wartość + Wprowadź wartość pomiędzy %s a %s + Poślizg + Transakcja może zostać przechwycona z powodu wysokiego poślizgu. + Transakcja może zostać anulowana z powodu niskiej tolerancji na poślizg. + Kwota %s jest mniejsza niż minimalny balans %s + Próbujesz wymienić zbyt małą kwotę + Wstrzymaj się: %s + Za: %s + Zawsze możesz zagłosować na to referendum później + Usuń referendum %s z listy głosów? + Niektóre z referendów nie są już dostępne do głosowania lub możesz nie mieć wystarczającej liczby tokenów do głosowania. Dostępne do głosowania: %s. + Niektóre z referendów wykluczone z listy głosów + Nie można załadować danych referendum + Brak danych + Już zagłosowałeś we wszystkich dostępnych referendach lub obecnie nie ma referendów do głosowania. Wróć później. + Już zagłosowałeś we wszystkich dostępnych referendach + Żądane: + Lista głosów + Pozostało %d + Potwierdź głosy + Brak referendów do głosowania + Potwierdź swoje głosy + Brak głosów + Pomyślnie zagłosowałeś na %d referendów + Nie masz wystarczającego salda, aby głosować z bieżącą mocą głosu %s (%sx). Proszę zmienić moc głosu lub dodać więcej środków do swojego portfela. + Niewystarczające saldo do głosowania + Przeciw: %s + SwipeGov + Głosuj na %d referendów + Głosowanie zostanie ustawione na przyszłe głosowania w SwipeGov + Moc głosowania + Staking + Portfel + Dziś + Link Coingecko do informacji o cenach (Opcjonalnie) + Wybierz dostawcę do zakupu %s tokena + Metody płatności, opłaty i limity różnią się w zależności od dostawcy.\nPorównaj ich oferty, aby znaleźć najlepszą opcję dla siebie. + Wybierz dostawcę do sprzedaży %s tokena + Aby kontynuować zakup, zostaniesz przekierowany z aplikacji Pezkuwi Wallet na %s + Kontynuować w przeglądarce? + Żaden z naszych dostawców obecnie nie obsługuje zakupu ani sprzedaży tego tokena. Proszę wybrać inny token, inną sieć lub sprawdzić później. + Ten token nie jest obsługiwany przez funkcję kupna/sprzedaży + Kopiuj hash + Opłata + Od + Hash transakcji + Szczegóły transakcji + Zobacz w %s + Zobacz w Polkascan + Zobacz w Subscan + %s o %s + Twoja poprzednia historia transakcji %s jest nadal dostępna na %s + Zakończone + Niepowodzenie + Oczekujące + Kanał Powiadomień o Transakcjach + Kup kryptowaluty już od $5 + Sprzedaj kryptowaluty już od $10 + Od: %s + Do: %s + Przelew + Przychodzące i wychodzące\ntransakcje pojawią się tutaj + Twoje operacje pojawią się tutaj + Usuń głosy, aby delegować na tych ścieżkach + Ścieżki, do których już oddelegowałeś głosy + Niedostępne ścieżki + Ścieżki, na których już oddałeś głosy + Nie pokazuj tego ponownie.\nStarszy adres możesz znaleźć w Odbiorze. + Format starszy + Nowy format + Niektóre giełdy mogą wciąż wymagać starszego formatu\ndo operacji, podczas gdy dokonują aktualizacji. + Nowy Zunifikowany Adres + Zainstaluj + Wersja %s + Aktualizacja dostępna + Aby uniknąć wszelkich problemów i poprawić swoje doświadczenia użytkownika, zdecydowanie zalecamy jak najszybsze zainstalowanie najnowszych aktualizacji + Krytyczna aktualizacja + Najnowsza + Wiele niesamowitych nowych funkcji jest już dostępnych dla Pezkuwi Wallet! Upewnij się, że zaktualizujesz aplikację, aby uzyskać do nich dostęp + Główna aktualizacja + Krytyczna + Ważna + Zobacz wszystkie dostępne aktualizacje + Imię + Nazwa portfela + Ta nazwa będzie wyświetlana tylko dla ciebie i przechowywana lokalnie na twoim urządzeniu mobilnym. + To konto nie zostało wybrane przez sieć do udziału w bieżącej erze + Oddaj ponownie głos + Głosuj + Status głosowania + Twoja karta jest doładowywana! + Twoja karta jest w trakcie wydawania! + Może to potrwać do 5 minut.\nTo okno zostanie zamknięte automatycznie. + Szacowany czas %s + Kup + Kup/Sprzedaj + Kup tokeny + Kup za + Otrzymaj + Otrzymaj %s + Wysyłaj tylko token %1$s i tokeny w sieci %2$s na ten adres, w przeciwnym razie możesz stracić swoje środki + Sprzedaj + Sprzedaj tokeny + Wyślij + Wymiana + Aktywa + Twoje aktywa pojawią się tutaj.\nUpewnij się, że filtr\n\"Ukryj zerowe salda\"\njest wyłączony + Wartość aktywów + Dostępne + Staked + Szczegóły bilansu + Łączny bilans + Łączny po transferze + Zamrożone + Zablokowane + Do odebrania + Zarezerwowane + Transferowalne + Unstaking + Portfel + Nowe połączenie + + Brak konta %s. Dodaj konto do portfela w Ustawieniach + %s brakuje konta. Dodaj konto do portfela w Ustawieniach + %s brakuje kont. Dodaj konta do portfela w Ustawieniach + Brak kont %s. Dodaj konta do portfela w Ustawieniach + + Niektóre z wymaganych sieci, o które prosi \\"%s\\", nie są obsługiwane w Pezkuwi Wallet + Sesje Wallet Connect pojawią się tutaj + WalletConnect + Nieznana dApp + + %s nieobsługiwana sieć jest ukryta + %d nieobsługiwane sieci ukryte + %d ukrytych sieci nieobsługiwanych + %d nieobsługiwanych sieci ukrytych + + WalletConnect v2 + Transfer międzyłańcuchowy + Kryptowaluty + Waluty fiat + Popularne waluty fiat + Waluta + Szczegóły transakcji + Ukryj aktywa z zerowym saldem + Inne transakcje + Pokaż + Nagrody i opłaty + Wymiany + Filtry + Przelewy + Zarządzaj aktywami + Jak dodać portfel? + Jak dodać portfel? + Jak dodać portfel? + Przykłady nazw: Główne konto, Mój validator, Dotsama crowdloans, itp. + Udostępnij ten kod QR nadawcy + Pokaż ten kod QR nadawcy do zeskanowania + Mój adres %s do odbioru %s: + Udostępnij kod QR + Odbiorca + Upewnij się, że adres\njest z odpowiedniej sieci + Nieprawidłowy format adresu.\nUpewnij się, że adres\nnależy do odpowiedniej sieci + Minimalne saldo + Z powodu ograniczeń międzyłańcuchowych nie możesz przelać więcej niż %s + Nie masz wystarczającego salda, aby zapłacić opłatę międzyłańcuchową w wysokości %s.\nPozostałe saldo po transferze: %s + Opłata międzyłańcuchowa jest dodawana do wprowadzonej kwoty. Odbiorca może otrzymać część opłaty międzyłańcuchowej + Potwierdź transfer + Międzyłańcuchowy + Opłata międzyłańcuchowa + Twój transfer nie powiedzie się, ponieważ konto docelowe nie ma wystarczająco dużo %s, aby zaakceptować inne transfery tokenów + Odbiorca nie może zaakceptować transferu + Twój transfer nie powiedzie się, ponieważ końcowa kwota na koncie docelowym będzie mniejsza niż minimalny balans. Proszę, spróbuj zwiększyć kwotę. + Twój transfer usunie konto z blockchain, ponieważ spowoduje obniżenie całkowitego salda poniżej minimalnego poziomu. + Twoje konto zostanie usunięte z blockchain po transferze, ponieważ obniży całkowite saldo poniżej minimalnego + Transfer usunie konto + Twoje konto zostanie usunięte z blockchain po transferze, ponieważ spowoduje obniżenie całkowitego salda poniżej minimalnego. Pozostały saldo również zostanie przekazane odbiorcy. + Z sieci + Musisz mieć co najmniej %s, aby zapłacić tę opłatę transakcyjną i pozostać powyżej minimalnego salda sieci. Twój bieżący saldo: %s. Musisz dodać %s do swojego salda, aby wykonać tę operację. + Sobie + Na-chain + Następujący adres: %s jest znany z działalności phishingowej, dlatego nie zalecamy wysyłania tokenów na ten adres. Czy chcesz kontynuować? + Ostrzeżenie o oszustwie + Odbiorca został zablokowany przez właściciela tokena i nie może obecnie przyjmować przychodzących transferów. + Odbiorca nie może przyjąć transferu + Sieć odbiorcy + Do sieci + Wyślij %s z + Wyślij %s na + do + Nadawca + Tokeny + Wyślij do tego kontaktu + Szczegóły transferu + %s (%s) + %s adresy dla %s + Pezkuwi wykryła problemy z integralnością informacji o adresach %1$s. Proszę skontaktować się z właścicielem %1$s, aby rozwiązać problemy z integralnością. + Błąd sprawdzania integralności + Nieprawidłowy odbiorca + Nie znaleziono ważnego adresu dla %s w sieci %s + %s nie znaleziono + %1$s usługi w3n są niedostępne. Spróbuj ponownie później lub wprowadź adres %1$s ręcznie + Błąd rozwiązywania w3n + Pezkuwi nie może rozwiązać kodu dla tokena %s + Token %s jest jeszcze nieobsługiwany + Wczoraj + Yield Boost zostanie wyłączony dla obecnych kolatorów. Nowy kolator: %s + Zmień kolatora z Yield Boost? + Nie masz wystarczającego salda, aby zapłacić opłatę sieciową w wysokości %s i opłatę za wykonanie Yield Boost w wysokości %s.\nDostępne saldo do zapłaty opłaty: %s + Niewystarczająca liczba tokenów na pokrycie pierwszej opłaty wykonawczej + Nie masz wystarczającego salda, aby zapłacić opłatę sieciową w wysokości %s i nie spaść poniżej progu %s.\nDostępne saldo do zapłaty opłaty: %s + Niewystarczająca liczba tokenów, aby pozostać powyżej progu + Czas zwiększenia Stake + Yield Boost automatycznie Stake %s wszystkie moje przenośne tokeny powyżej %s + Yield Boosted + + + Most DOT ↔ HEZ + Most DOT ↔ HEZ + Wysyłasz + Otrzymasz (szacunkowo) + Kurs wymiany + Opłata mostowa + Minimum + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Wymień + Wymiany HEZ→DOT są przetwarzane, gdy płynność DOT jest wystarczająca. + Niewystarczające saldo + Kwota poniżej minimum + Wprowadź kwotę + Wymiany HEZ→DOT mogą mieć ograniczoną dostępność w zależności od płynności. + Wymiany HEZ→DOT są tymczasowo niedostępne. Spróbuj ponownie, gdy płynność DOT będzie wystarczająca. + diff --git a/common/src/main/res/values-pt/strings.xml b/common/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000..f44ee4a --- /dev/null +++ b/common/src/main/res/values-pt/strings.xml @@ -0,0 +1,2047 @@ + + + Contate-nos + Github + Política de privacidade + Avalie-nos + Telegram + Termos e Condições + Termos & Condições + Sobre + Versão do aplicativo + Site + Digite um endereço %s válido... + O endereço EVM deve ser válido ou vazio... + Digite um endereço de substrato válido... + Adicionar conta + Adicionar endereço + A conta já existe. Por favor, tente outra. + Rastreie qualquer carteira pelo seu endereço + Adicionar carteira apenas para visualização + Anote sua Frase de Passagem + Por favor, certifique-se de que sua frase está escrita de forma correta e legível. + Endereço %s + Nenhuma conta em %s + Confirmar mnemônica + Vamos verificar novamente + Escolha as palavras na ordem correta + Criar uma nova conta + Não use a área de transferência ou capturas de tela em seu dispositivo móvel, tente encontrar métodos seguros para backup (por exemplo, papel) + O nome será usado apenas localmente nesta aplicação. Você pode editá-lo mais tarde + Criar nome da carteira + Backup da mnemônica + Crie uma nova carteira + A mnemônica é usada para recuperar o acesso à conta. Anote-a, não seremos capazes de recuperar sua conta sem ela! + Contas com um segredo alterado + Esquecer + Certifique-se de ter exportado sua carteira antes de prosseguir. + Esquecer carteira? + Caminho de derivação Ethereum inválido + Caminho de derivação Substrate inválido + Esta carteira está emparelhada com %1$s. A Pezkuwi ajudará você a formar quaisquer operações que desejar, e você será solicitado a assiná-las usando %1$s + Não suportado por %s + Esta é uma carteira somente de visualização, a Pezkuwi pode mostrar seus saldos e outras informações, mas você não pode realizar nenhuma transação com esta carteira + Insira o apelido da carteira... + Por favor, tente outro. + Tipo de criptografia de par de chaves Ethereum + Caminho de derivação do segredo Ethereum + Contas EVM + Exportar conta + Exportar + Importar existente + Isso é necessário para criptografar os dados e salvar o arquivo JSON. + Defina uma nova senha + Salve seu segredo e armazene-o em um local seguro + Anote seu segredo e guarde-o em um local seguro + JSON de restauração inválido. Por favor, certifique-se de que a entrada contém um JSON válido. + Seed inválido. Por favor, certifique-se de que sua entrada contém 64 símbolos hexadecimais. + O JSON não contém informações de rede. Por favor, especifique abaixo. + Forneça seu JSON de Restauração + Tipicamente frase de 12 palavras (mas pode ser 15, 18, 21 ou 24) + Escreva as palavras separadas por um espaço, sem vírgulas ou outros sinais + Insira as palavras na ordem correta + Senha + Importar chave privada + 0xAB + Insira seu seed bruto + A Pezkuwi é compatível com todos os aplicativos + Importar carteira + Conta + O seu caminho de derivação contém símbolos não suportados ou tem uma estrutura incorreta + Caminho de derivação inválido + Arquivo JSON + Certifique-se de que %s no seu dispositivo Ledger usando o aplicativo Ledger Live + o aplicativo Polkadot está instalado + %s no seu dispositivo Ledger + Abra o aplicativo Polkadot + Flex, Stax, Nano X, Nano S Plus + Ledger (App Genérico Polkadot) + Certifique-se de que o aplicativo da Rede está instalado no seu dispositivo Ledger usando o aplicativo Ledger Live. Abra o aplicativo da rede no seu dispositivo Ledger. + Adicione pelo menos uma conta + Adicionar contas à sua carteira + Guia de Conexão Ledger + Certifique-se de que o %s no seu dispositivo Ledger usando o aplicativo Ledger Live + aplicativo da Network está instalado + %s no seu dispositivo Ledger + Abra o aplicativo da rede + Permitir que a Pezkuwi Wallet %s + acesso ao Bluetooth + %s para adicionar à carteira + Selecione a conta + %s nas configurações do seu telefone + Habilitar OTG + Conectar Ledger + Para assinar operações e migrar suas contas para o novo aplicativo Genérico Ledger, instale e abra o aplicativo Migration. Os aplicativos Legacy Old e Migration Ledger não serão suportados no futuro. + Novo aplicativo Ledger foi lançado + Polkadot Migration + O aplicativo Migration estará indisponível em breve. Use-o para migrar suas contas para o novo aplicativo Ledger e evitar perder seus fundos. + Polkadot + Ledger Nano X + Ledger Legacy + Se estiver usando um Ledger via Bluetooth, ative o Bluetooth em ambos os dispositivos e conceda permissões de Bluetooth e localização ao Pezkuwi. Para USB, habilite OTG nas configurações do seu telefone. + Por favor, ative o Bluetooth nas configurações do seu telefone e no dispositivo Ledger. Desbloqueie seu dispositivo Ledger e abra o aplicativo %s. + Selecione seu dispositivo Ledger + Por favor, ative o dispositivo Ledger. Desbloqueie seu dispositivo Ledger e abra o aplicativo %s. + Você está quase lá! 🎉\n Basta tocar abaixo para completar a configuração e começar a usar suas contas perfeitamente tanto no Polkadot App quanto na Pezkuwi Wallet + Bem-vindo ao Pezkuwi! + frase de 12, 15, 18, 21 ou 24 palavras + Multisig + Controle compartilhado (multisig) + Você não tem uma conta para esta rede, você pode criar ou importar uma conta. + Conta necessária + Nenhuma conta encontrada + Emparelhar chave pública + Parity Signer + %s não suporta %s + As seguintes contas foram lidas com sucesso de %s + Aqui estão suas contas + Código QR inválido, por favor certifique-se de que você está escaneando o código QR de %s + Certifique-se de selecionar o primeiro + %s no seu smartphone + Abra o Parity Signer + %s que você gostaria de adicionar à Pezkuwi Wallet + Vá até a aba \'Chaves\'. Selecione seed, depois a conta + Parity Signer fornecerá um %s + código QR para escanear + Adicionar carteira de %s + %s não suporta a assinatura de mensagens arbitrárias — apenas transações + Assinatura não é suportada + Digitalize o código QR do %s + Legado + Novo (Vault v7+) + Eu tenho um erro em %s + O QR code expirou + Por razões de segurança, operações geradas são válidas apenas por %s.\nPor favor, gere um novo código QR e assine-o com %s + QR code é válido para %s + Por favor, certifique-se de que está a escanear o código QR para a operação de assinatura atual + Assinar com %s + Cofre Polkadot + Atenção, o nome do caminho de derivação deve estar vazio + %s no seu smartphone + Abra o Polkadot Vault + %s que você gostaria de adicionar ao Pezkuwi Wallet + Toque em Chave Derivada + Polkadot Vault fornecerá a você %s + código QR para escanear + Toque no ícone no canto superior direito e selecione %s + Exportar Chave Privada + Chave privada + Delegado + Delegado para você (Proxied) + Qualquer + Leilão + Cancelar Proxy + Governança + Julgamento de Identidade + Piscinas de Nomeação + Não Transferência + Staking + Já tenho uma conta + segredos + 64 símbolos hex + Selecione a carteira de hardware + Selecione o tipo do seu segredo + Selecione a carteira + Contas com um segredo compartilhado + Contas Substrate + Tipo de criptografia de par de chaves Substrate + Caminho de derivação secreta Substrate + Nome da carteira + Apelido da carteira + Moonbeam, Moonriver e outras redes + Endereço EVM (Opcional) + Carteiras predefinidas + Polkadot, Kusama, Karura, KILT e mais de 50 redes + Acompanhe a atividade de qualquer carteira sem injetar sua chave privada na Pezkuwi Wallet + Sua carteira é apenas para visualização, o que significa que você não pode realizar nenhuma operação com ela + Ops! A chave está faltando + somente visualização + Use Polkadot Vault, Ledger ou Parity Signer + Conectar carteira de hardware + Use suas contas do Trust Wallet no Pezkuwi + Trust Wallet + Adicionar conta %s + Adicionar carteira + Alterar conta %s + Alterar conta + Ledger (Legacy) + Delegado para você + Controle compartilhado + Adicionar nó personalizado + Você precisa adicionar uma conta %s à carteira para delegar + Inserir detalhes da rede + Delegar para + Conta de delegação + Carteira de delegação + Conceder tipo de acesso + O depósito permanece reservado em sua conta até que o proxy seja removido. + Você alcançou o limite de %s proxies adicionados em %s. Remova proxies para adicionar novos. + Número máximo de proxies foi alcançado + Redes personalizadas adicionadas\naparecerão aqui + +%d + Pezkuwi mudou automaticamente para sua carteira multisig para que você possa visualizar transações pendentes. + Colorido + Aparência + ícones de token + Branco + O endereço do contrato inserido já está presente na Pezkuwi como um token %s. + O endereço do contrato inserido já está presente na Pezkuwi como um token %s. Tem certeza de que deseja modificá-lo? + Este token já existe + Por favor, certifique-se de que o url fornecido tem a seguinte forma: www.coingecko.com/en/coins/tether. + Link CoinGecko inválido + O endereço do contrato inserido não é um contrato ERC-20 %s. + Endereço de contrato inválido + Os decimais devem ser pelo menos 0, e não mais de 36. + Valor de decimais inválido + Insira o endereço do contrato + Insira os decimais + Insira o símbolo + Ir para %s + A partir de %1$s, seu saldo de %2$s, Staking e Governança estarão em %3$s — com desempenho aprimorado e custos menores. + Seus tokens %s agora estão em %s + Redes + Tokens + Adicionar token + Endereço do contrato + Decimais + Símbolo + Insira os detalhes do token ERC-20 + Selecione a rede para adicionar o token ERC-20 + Crowdloans + Governança v1 + OpenGov + Eleições + Staking + Vesting + Comprar tokens + Recebeu de volta seus DOTs dos crowdloans? Comece a fazer staking dos seus DOTs hoje mesmo para obter as recompensas máximas possíveis! + Impulsione seus DOTs 🚀 + Filtrar tokens + Você não tem tokens para presentear.\nCompre ou Deposite tokens na sua conta. + Todas as redes + Gerenciar tokens + Não transfira %s para a conta controlada por Ledger já que Ledger não suporta o envio de %s, assim os ativos ficarão presos nesta conta + Ledger não suporta este token + Buscar por rede ou token + Nenhuma rede ou token com o nome inserido foi encontrado + Pesquisar por token + Suas carteiras + Você não tem tokens para enviar. Compre ou Receba tokens na sua conta. + Token para pagamento + Token para receber + Antes de prosseguir com as mudanças, %s para carteiras modificadas e removidas! + certifique-se de ter salvo as Frases de Passagem + Aplicar Atualizações de Backup? + Prepare-se para salvar sua carteira! + Esta frase de passagem dá acesso total e permanente a todas as carteiras conectadas e aos fundos nelas.\n%s + NÃO COMPARTILHE-A. + Não insira sua Frase de Recuperação em nenhum formulário ou site.\n%s + OS FUNDOS PODEM SER PERDIDOS PARA SEMPRE. + O suporte ou administradores nunca solicitarão sua Frase de Recuperação sob nenhuma circunstância.\n%s + CUIDADO COM IMITADORES. + Rever & Aceitar para Continuar + Excluir backup + Você pode fazer backup manual da sua frase secreta para garantir acesso aos fundos da sua carteira se perder o acesso a este dispositivo + Backup manual + Manual + Você não adicionou nenhuma carteira com uma frase de recuperação. + Nenhuma carteira para fazer backup + Você pode ativar backups no Google Drive para armazenar cópias criptografadas de todas as suas carteiras, protegidas por uma senha que você define. + Fazer backup no Google Drive + Google Drive + Backup + Backup + Processo KYC direto e eficiente + Autenticação biométrica + Fechar Tudo + Compra iniciada! Por favor, aguarde até 60 minutos. Você pode rastrear o status pelo email. + Selecione a rede para comprar %s + Compra iniciada! Por favor, aguarde até 60 minutos. Você pode acompanhar o status no e-mail. + Nenhum de nossos provedores atualmente suporta a compra deste token. Por favor, escolha um token diferente, uma rede diferente ou volte mais tarde. + Este token não é suportado pela funcionalidade de compra + Capacidade de pagar taxas em qualquer token + A migração acontece automaticamente, nenhuma ação é necessária + O histórico de transações antigo permanece em %s + A partir de %1$s seu saldo de %2$s, Staking e Governança estão em %3$s. Esses recursos podem ficar indisponíveis por até 24 horas. + Taxas de transação %1$sx menores\n(de %2$s para %3$s) + Redução %1$sx no saldo mínimo\n(de %2$s para %3$s) + O que torna o Asset Hub incrível? + A começar %1$s seu saldo de %2$s, Staking e Governança estão em %3$s + Mais tokens suportados: %s, e outros tokens do ecossistema + Acesso unificado a %s, ativos, staking e governança + Equilibrar nós automaticamente + Ativar conexão + Por favor, insira uma senha que será usada para recuperar suas carteiras do backup na nuvem. Essa senha não pode ser recuperada no futuro, então certifique-se de se lembrar dela! + Atualizar senha de backup + Ativo + Saldo disponível + Desculpe, a solicitação de verificação de saldo falhou. Por favor, tente novamente mais tarde. + Desculpe, não conseguimos contatar o provedor de transferências. Por favor, tente novamente mais tarde. + Desculpe, você não tem fundos suficientes para gastar o valor especificado + Taxa de transferência + Rede não está respondendo + Para + Ops, o presente já foi reivindicado + O presente não pode ser recebido + Reivindicar o presente + Pode haver um problema com o servidor. Por favor, tente novamente mais tarde. + Ops, algo deu errado + Utilize outra carteira, crie uma nova, ou adicione uma conta %s a esta carteira em Configurações. + Você reivindicou um presente com sucesso. Os tokens aparecerão em seu saldo em breve. + Você recebeu um presente cripto! + Crie uma nova carteira ou importe uma existente para reivindicar o presente + Você não pode receber um presente com uma carteira %s + Todas as abas abertas no navegador DApp serão fechadas. + Fechar Todos os DApps? + %s e lembre-se de sempre mantê-los offline para restaurá-los a qualquer momento. Você pode fazer isso nas Configurações de Backup. + Por favor, anote todas as Frases de Segurança da sua carteira antes de prosseguir + O backup será excluído do Google Drive + O backup atual das suas carteiras será permanentemente excluído! + Você tem certeza de que deseja excluir o Backup na Nuvem? + Excluir Backup + No momento, seu backup não está sincronizado. Por favor, reveja estas atualizações. + Alterações no Backup na Nuvem encontradas + Revisar Atualizações + Se você não anotou manualmente sua Frase de Segurança para as carteiras que serão removidas, essas carteiras e todos os seus ativos serão permanentemente e irremediavelmente perdidos para sempre. + Você tem certeza de que deseja aplicar essas alterações? + Revisar o Problema + No momento, seu backup não está sincronizado. Por favor, revise o problema. + Falha na atualização das alterações da carteira no Backup na Nuvem + Por favor, certifique-se de que você está logado na sua conta do Google com as credenciais corretas e que concedeu acesso ao Google Drive para a Pezkuwi Wallet + Falha na autenticação do Google Drive + Você não tem espaço de armazenamento disponível no Google Drive. + Armazenamento Insuficiente + Infelizmente, o Google Drive não funciona sem os serviços do Google Play, que estão ausentes em seu dispositivo. Tente obter os serviços do Google Play. + Serviços Google Play não encontrados + Não foi possível fazer backup de suas carteiras no Google Drive. Certifique-se de que você habilitou o Pezkuwi Wallet para usar seu Google Drive e que há espaço de armazenamento disponível, depois tente novamente. + Erro no Google Drive + Por favor, verifique a corretude da senha e tente novamente. + Senha inválida + Infelizmente, não encontramos um backup para restaurar as carteiras. + Nenhum Backup Encontrado + Certifique-se de ter salvo a Frase de Recuperação para a carteira antes de prosseguir. + A Carteira será removida no Backup em Nuvem + Revisar Erro de Backup + Revisar Atualizações de Backup + Digite a Senha de Backup + Ative para fazer backup das carteiras no seu Google Drive + Última sincronização: %s às %s + Entrar no Google Drive + Revisar Problema do Google Drive + Backup Desativado + Backup Sincronizado + Sincronizando Backup... + Backup Não Sincronizado + Pezkuwis carteiras são automaticamente adicionadas ao Backup na Nuvem. Você pode desativar o Backup na Nuvem nas Configurações. + Alterações da carteira serão atualizadas no Backup na Nuvem + Aceitar os termos... + Conta + Endereço da conta + Ativo + Adicionar + Adicionar delegação + Adicionar rede + Endereço + Avançado + Todos + Permitir + Quantidade + Quantidade muito baixa + Quantidade muito grande + Aplicado + Aplicar + Perguntar novamente + Atenção! + Disponível: %s + Média + Saldo + Bônus + Ligar + Dados de chamada + Hash da chamada + Cancelar + Tem certeza de que deseja cancelar esta operação? + Desculpe, você não tem o aplicativo certo para processar este pedido + Não é possível abrir este link + Você pode usar até %s, uma vez que precisa pagar\n%s pela taxa de rede. + Cadeia + Alterar + Alterar senha + Continuar automaticamente no futuro + Escolher rede + Limpar + Fechar + Tem certeza de que deseja fechar esta tela?\nSuas alterações não serão aplicadas. + Backup na nuvem + Concluído + Concluído (%s) + Confirmar + Confirmação + Tem certeza? + Confirmado + %d ms + conectando... + Por favor, verifique sua conexão ou tente novamente mais tarde + Falha na Conexão + Continuar + Copiado para a área de transferência + Copiar endereço + Copiar dados de chamada + Copiar hash + Copiar id + Tipo de criptografia da chave + Data + %s e %s + Excluir + Depositante + Detalhes + Desativado + Desconectar + Não feche o aplicativo! + Feito + Pezkuwi simula a transação antecipadamente para evitar erros. Esta simulação não teve sucesso. Tente novamente mais tarde ou com um valor maior. Se o problema persistir, entre em contato com o Suporte da Pezkuwi Wallet em Configurações. + Falha na simulação de transação + Editar + %s (+%s mais) + Selecionar aplicativo de email + Ativar + Digite o endereço… + Digite o valor... + Inserir detalhes + Insira outro valor + Digite a senha + Erro + Tokens insuficientes + Evento + EVM + Endereço EVM + Sua conta será removida da blockchain após esta operação, pois faz com que o saldo total seja menor que o mínimo + A operação removerá a conta + Expirado + Explorar + Falhou + A taxa de rede estimada %s é muito maior que a taxa de rede padrão (%s). Isso pode ser devido à congestão temporária da rede. Você pode atualizar para esperar por uma taxa de rede menor. + Taxa de rede está muito alta + Taxa: %s + Ordenar por: + Filtros + Descubra mais + Esqueceu a Senha? + + todos os dias + a cada %s dias + + diariamente + todos os dias + Detalhes completos + Obter %s + Presente + Entendido + Governança + String hexadecimal + Ocultar + + %d hora + %d horas + + Como funciona + Entendi + Informação + Código QR é inválido + Código QR inválido + Saiba mais + Descubra mais sobre + Ledger + %s restantes + Gerenciar Carteiras + Máximo + %s máximo + + %d minuto + %d minutos + + Conta %s está faltando + Modificar + Módulo + Nome + Rede + Ethereum + %s não é suportado + Polkadot + Redes + + Rede + Redes + + Próximo + Não + Aplicativo para importação de arquivo não encontrado no dispositivo. Por favor, instale-o e tente novamente + Nenhum aplicativo adequado encontrado no dispositivo para lidar com esta ação + Sem alterações + Vamos mostrar sua mnemônica. Certifique-se de que ninguém pode ver sua tela e não tire capturas de tela — elas podem ser coletadas por malwares de terceiros + Nenhum + Não disponível + Desculpe, você não tem fundos suficientes para pagar a taxa de rede. + Saldo insuficiente + Você não tem saldo suficiente para pagar a taxa de rede de %s. Saldo atual é %s + Agora não + Desligado + OK + Ok, voltar + Ligado + Em andamento + Opcional + Selecione uma opção + Frase secreta + Colar + / ano + %s / ano + por ano + %% + As permissões solicitadas são necessárias para usar esta tela. Você deve ativá-las nas Configurações. + Permissões negadas + As permissões solicitadas são necessárias para usar esta tela. + Permissões necessárias + Preço + Política de Privacidade + Prosseguir + Depósito de proxy + Revogar acesso + Notificações push + Ler mais + Recomendado + Atualizar taxa + Rejeitar + Remover + Obrigatório + Redefinir + Tentar novamente + Algo deu errado. Por favor, tente novamente + Revogar + Salvar + Escaneie o código QR + Pesquisar + Os resultados da pesquisa serão exibidos aqui + Resultados da pesquisa: %d + seg + + %d segundo + %d segundos + + Sequência de derivação secreta + Ver Tudo + Selecionar token + Configurações + Compartilhar + Compartilhar dados de chamada + Compartilhar hash + Mostrar + Entrar + Solicitação de assinatura + Signatário + Assinatura inválida + Pular + Pular processo + Ocorreu um erro ao enviar algumas transações. Deseja tentar novamente? + Falha ao enviar algumas transações + Algo deu errado + Ordenar por + Status + Substrate + Endereço Substrate + Toque para revelar + Termos e Condições + Testnet + Tempo restante + Título + Abrir Configurações + Seu saldo é muito pequeno + Total + Taxa total + ID da transação + Transação enviada + Tente novamente + Tipo + Por favor, tente novamente com outra entrada. Se o erro persistir, entre em contato com o suporte. + Desconhecido + + %s não suportado + %s não suportado + + Ilimitado + Atualizar + Usar + Usar máximo + O destinatário deve ser um endereço %s válido + Destinatário inválido + Ver + Aguardando + Carteira + Aviso + Sim + Seu Presente + O valor deve ser positivo + Por favor, insira a senha que você criou durante o processo de backup + Insira a senha atual do backup + Confirmar transferirá tokens da sua conta + Selecione as palavras... + Frase secreta inválida, por favor, verifique novamente a ordem das palavras + Referendo + Votar + Rastrear + O nó já foi adicionado anteriormente. Por favor, tente outro nó. + Não é possível estabelecer conexão com o nó. Por favor, tente outro. + Infelizmente, a rede não é suportada. Por favor, tente uma das seguintes: %s. + Confirme a deleção de %s. + Deletar rede? + Por favor, verifique sua conexão ou tente novamente mais tarde + Personalizado + Padrão + Redes + Adicionar conexão + Escanear código QR + Foi identificado um problema com o seu backup. Você tem a opção de deletar o backup atual e criar um novo. %s antes de continuar. + Certifique-se de que salvou as Frases Secretas para todas as carteiras + Backup encontrado, mas vazio ou corrompido + No futuro, sem a senha de backup, não é possível restaurar suas carteiras do Backup na Nuvem.\n%s + Esta senha não pode ser recuperada. + Lembre-se da Senha de Backup + Confirme a senha + Senha de backup + Letras + Mín. 8 caracteres + Números + Senhas coincidem + Por favor, insira uma senha para acessar seu backup a qualquer momento. A senha não pode ser recuperada, certifique-se de lembrá-la! + Crie sua senha de backup + O ID da Cadeia inserido não corresponde à rede no URL RPC. + ID da Cadeia inválido + Crowdloans privados ainda não são suportados. + Crowdloan privado + Sobre crowdloans + Direto + Saiba mais sobre os diferentes contributos para a Acala + Líquido + Ativo (%s) + Concorde com os Termos e Condições + Bônus da Pezkuwi Wallet (%s) + O código de referência do Astar deve ser um endereço Polkadot válido + Não é possível contribuir com o valor escolhido uma vez que o montante arrecadado resultante excederá o limite do crowdloan. A contribuição máxima permitida é %s. + Não é possível contribuir para o crowdloan selecionado, uma vez que seu limite já foi atingido. + Limite do crowdloan excedido + Contribua para o crowdloan + Contribuição + Você contribuiu: %s + Liquid + Parallel + Suas contribuições\n aparecerão aqui + Retornos em %s + A ser devolvido pela parachain + %s (via %s) + Crowdloans + Obtenha um bônus especial + Crowdloans serão exibidos aqui + Não é possível contribuir para o crowdloan selecionado, pois já terminou. + Crowdloan terminou + Insira seu código de referência + Informações sobre o crowdloan + Saiba mais sobre o crowdloan de %s + Site do crowdloan de %s + Período de leasing + Escolha parachains para contribuir com seu %s. Você receberá de volta seus tokens contribuídos, e se a parachain ganhar um slot, você receberá recompensas após o final do leilão + Você precisa adicionar uma conta %s à carteira para poder contribuir + Aplicar bônus + Se você não tem um código de referência, você pode aplicar o código de referência da Pezkuwi para receber um bônus pela sua contribuição + Você não aplicou o bônus + O crowdloan Moonbeam suporta somente contas do tipo criptográfico SR25519 ou ED25519. Por favor, considere usar outra conta para contribuição + Não é possível contribuir com esta conta + Você deve adicionar uma conta Moonbeam à carteira para participar do crowdloan Moonbeam + Conta Moonbeam está faltando + Este crowdloan não está disponível na sua localização. + Sua região não é suportada + Destino da recompensa %s + Submeter acordo + Você precisa submeter o acordo com os Termos & Condições na blockchain para prosseguir. Isso é necessário ser feito apenas uma vez para todas as contribuições subsequentes no Moonbeam + Eu li e concordo com os Termos e Condições + Arrecadado + Código de referência + Código de referência é inválido. Por favor, tente outro + Termos e Condições de %s + A quantidade mínima permitida para contribuição é %s. + Quantia da contribuição é muito pequena + Seus tokens %s serão devolvidos após o período de locação. + Suas contribuições + Arrecadado: %s de %s + O URL RPC inserido está presente no Pezkuwi como uma rede personalizada %s. Tem certeza de que deseja modificá-lo? + https://networkscan.io + URL do explorador de blocos (Opcional) + 012345 + ID da Cadeia + TOKEN + Símbolo da Moeda + Nome da Rede + Adicionar nó + Adicionar nó personalizado para + Inserir detalhes + Salvar + Editar nó personalizado para + Nome + Nome do nó + wss:// + URL do Nó + URL RPC + DApps para os quais você permitiu acesso para ver seu endereço quando os usa + O DApp “%s” será removido dos Autorizados + Remover dos Autorizados? + DApps Autorizados + Catálogo + Aprove esta solicitação se você confia no aplicativo + Permitir que “%s” acesse os endereços de sua conta? + Aprove esta solicitação se você confia no aplicativo.\nVerifique os detalhes da transação. + DApp + DApps + %d DApps + Favoritos + Favoritos + Adicionar aos favoritos + O DApp “%s” será removido dos Favoritos + Remover dos Favoritos? + A lista de DApps aparecerá aqui + Adicionar aos Favoritos + Modo Desktop + Remover dos Favoritos + Configurações da página + Ok, leve-me de volta + O Pezkuwi Wallet acredita que este site pode comprometer a segurança de suas contas e seus tokens + Phishing detectado + Busque por nome ou insira URL + Cadeia não suportada com hash de gênese %s + Certifique-se de que a operação está correta + Falha ao assinar a operação solicitada + Abrir de qualquer forma + DApps maliciosos podem retirar todos os seus fundos. Sempre faça sua própria pesquisa antes de usar um DApp, conceder permissão ou enviar fundos.\n\nSe alguém estiver insistindo para você visitar este DApp, provavelmente é um golpe. Em caso de dúvida, por favor, entre em contato com o suporte da Pezkuwi Wallet: %s. + Aviso! DApp desconhecido + Cadeia não encontrada + Domínio do link %s não é permitido + Tipo de governança não especificado + Tipo de governança não suportado + Tipo de criptografia inválido + Caminho de derivação inválido + Mnemônico não é válido + URL inválido + O URL RPC inserido está presente no Pezkuwi como uma rede %s. + Canal de Notificação Padrão + +%d + Pesquisar por endereço ou nome + O formato do endereço é inválido. Certifique-se de que o endereço pertence à rede correta + resultados da pesquisa: %d + Contas Proxy e Multisig detectadas automaticamente e organizadas para você. Gerencie a qualquer momento nas Configurações. + Lista de carteiras foi atualizada + Votado por todo o tempo + Delegar + Todas as contas + Indivíduos + Organizações + O período de remoção da delegação começará depois que você revogar uma delegação + Seus votos votarão automaticamente junto com o voto de seus delegados + Informação do delegado + Indivíduo + Organização + Votos delegados + Delegações + Editar delegação + Você não pode delegar para si mesmo, por favor escolha um endereço diferente + Não pode delegar para si mesmo + Conte-nos mais sobre você para que os usuários da Pezkuwi conheçam você melhor + Você é um Delegado? + Descreva-se + Através de %s trilhas + Votado nos últimos %s + Seus votos via %s + Seus votos: %s via %s + Remover votos + Revogar delegação + Após o período de desdelegação ter expirado, você precisará desbloquear seus tokens. + Votos delegados + Delegações + Votou pela última vez %s + Faixas + Selecionar tudo + Selecione ao menos 1 faixa... + Não há faixas disponíveis para delegação + Comunhão + Governança + Tesouraria + Período de desdelegação + Não é mais válido + Sua delegação + Suas delegações + Mostrar + Excluindo um backup... + Sua senha de backup foi atualizada anteriormente. Para continuar usando o Backup na Nuvem, %s + por favor, insira a nova senha de backup. + A senha de backup foi alterada + Você não pode assinar transações de redes desativadas. Ative %s nas configurações e tente novamente + %s está desativado + Você já está delegando a esta conta: %s + Delegação já existe + (Compatível com BTC/ETH) + ECDSA + ed25519 (alternativo) + Edwards + Senha de backup + Insira dados de chamada + Tokens insuficientes para pagar a taxa + Contrato + Chamada de contrato + Função + Recuperar Carteiras + %s Todas as suas carteiras serão salvas com segurança no Google Drive. + Você quer recuperar suas carteiras? + Backup na Nuvem Existente encontrado + Baixar JSON de Restauração + Confirme a senha + As senhas não correspondem + Definir senha + Rede: %s\nMnemônico: %s\nCaminho de derivação: %s + Rede: %s\nMnemônico: %s + Por favor, aguarde até que a taxa seja calculada + Cálculo da taxa em andamento + Gerenciar Cartão de Débito + Vender token %s + Adicionar delegação para staking em %s + Detalhes da troca + Máx: + Você paga + Você recebe + Selecione um token + Recarregar cartão com %s + Por favor, entre em contato com support@pezkuwichain.io. Inclua o endereço de email que você usou para emitir o cartão. + Entre em contato com o suporte + Reivindicado + Criado: %s + Insira o valor + O presente mínimo é de %s + Reclamado + Selecione um token para presentear + Taxa de rede ao reivindicar + Criar Presente + Envie presentes de forma rápida, fácil e segura na Pezkuwi + Compartilhe Presentes Cripto com Qualquer Um, em Qualquer Lugar + Presentes que você criou + Selecione a rede para o presente de %s + Insira o valor do seu presente + %s como um link e convide qualquer um para Pezkuwi + Compartilhe o presente diretamente + %s, e você pode retornar presentes não reivindicados a qualquer momento deste dispositivo + O presente está disponível instantaneamente + Canal de Notificação da Governança + + Você precisa selecionar pelo menos %d trilha + Você precisa selecionar pelo menos %d trilhas + + Desbloquear + Executar esta troca resultará em deslizamento significativo e perdas financeiras. Considere reduzir o tamanho da sua negociação ou divida-a em várias transações. + Alto impacto de preço detectado (%s) + Histórico + Email + Nome Legal + Nome no Element + Identidade + Web + O arquivo JSON fornecido foi criado para uma rede diferente. + Por favor, certifique-se de que sua entrada contém um json válido. + O JSON para restauração é inválido + Por favor, verifique a correção da senha e tente novamente. + Falha na descriptografia do armazenamento de chaves + Colar json + Tipo de criptografia não suportado + Não é possível importar conta com segredo Substrate para a rede com criptografia Ethereum + Não é possível importar conta com segredo Ethereum para a rede com criptografia Substrate + Sua mnemônica é inválida + Por favor, certifique-se de que sua entrada contém 64 símbolos hex. + Seed é inválido + Infelizmente, o backup com suas carteiras não foi encontrado. + Backup Não Encontrado + Recuperar carteiras do Google Drive + Use sua frase de 12, 15, 18, 21 ou 24 palavras + Escolha como você gostaria de importar sua carteira + Somente visualização + Integre todos os recursos da rede que você está construindo na Pezkuwi Wallet, tornando-a acessível a todos. + Integre sua rede + Construindo para Polkadot? + Os dados da chamada que você forneceu são inválidos ou estão no formato errado. Por favor, verifique se estão corretos e tente novamente. + Esses dados de chamada são para outra operação com hash de chamada %s + Dados de chamada inválidos + O endereço proxy deve ser um endereço %s válido + Endereço proxy inválido + O Símbolo da Moeda inserido (%1$s) não corresponde à rede (%2$s). Deseja usar o símbolo correto da moeda? + Símbolo da Moeda inválido + Não foi possível decodificar o QR + Código QR + Carregar da galeria + Exportar arquivo JSON + Idioma + Ledger não suporta %s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + A operação foi cancelada pelo dispositivo. Certifique-se de que seu Ledger está desbloqueado. + Operação cancelada + Abra o aplicativo %s no seu dispositivo Ledger + Aplicativo %s não iniciado + Operação concluída com erro no dispositivo. Por favor, tente novamente mais tarde. + Falha na operação do Ledger + Mantenha pressionado o botão de confirmação no seu %s para aprovar a transação + Carregar mais contas + Revise e Aprove + Conta %s + Conta não encontrada + Seu dispositivo Ledger está usando um app Genérico desatualizado que não suporta endereços EVM. Atualize-o via Ledger Live. + Atualizar App Genérico do Ledger + Pressione ambos os botões no seu %s para aprovar a transação + Por favor, atualize %s usando o aplicativo Ledger Live + Os metadados estão desatualizados + Ledger não suporta a assinatura de mensagens arbitrarias — apenas transações + Por favor, certifique-se de ter selecionado o dispositivo Ledger correto para a operação atualmente em aprovação + Por motivos de segurança, as operações geradas são válidas apenas por %s. Por favor, tente novamente e aprove com o Ledger + A transação expirou + A transação é válida por %s + Ledger não suporta esta transação. + Transação não suportada + Pressione ambos os botões no seu %s para aprovar o endereço + Pressione o botão de confirmação no seu %s para aprovar o endereço + Pressione ambos os botões no seu %s para aprovar os endereços + Pressione o botão de confirmar no seu %s para aprovar os endereços + Esta carteira está pareada com o Ledger. Pezkuwi ajudará você a formar quaisquer operações que desejar, e você será solicitado a assiná-las usando o Ledger + Selecione a conta para adicionar à carteira + Carregando informações da rede... + Carregando detalhes da transação… + Procurando seu backup... + Transação de pagamento enviada + Adicionar token + Gerenciar backup + Excluir rede + Editar rede + Gerenciar rede adicionada + Você não poderá ver seus saldos de token nessa rede na tela de Ativos + Excluir rede? + Excluir nó + Editar nó + Gerenciar nó adicionado + Nó \"%s\" será excluído + Excluir nó? + Chave Customizada + Chave Padrão + Não compartilhe nenhuma dessas informações com ninguém - Se você fizer isso, perderá permanentemente e irreversivelmente todos os seus ativos + Contas com chave customizada + Contas padrão + %s, +%d outros + Contas com chave padrão + Selecione a chave para fazer backup + Selecione uma carteira para fazer backup + Por favor, leia o seguinte com atenção antes de visualizar seu backup + Não compartilhe sua frase-segredo! + Melhor preço com taxas de até 3,95% + Certifique-se de que ninguém possa ver sua tela\ne não tire capturas de tela + Por favor %s ninguém + não compartilhe com + Por favor, tente outra. + Frase secreta mnemônica inválida, por favor verifique mais uma vez a ordem das palavras + Você não pode selecionar mais do que %d carteiras + + Selecione pelo menos %d carteira + Selecione pelo menos %d carteiras + + Esta transação já foi rejeitada ou executada. + Não é possível realizar esta transação + %s já iniciou a mesma operação e ela está atualmente aguardando para ser assinada por outros signatários. + Operação já existe + Para gerenciar seu cartão de débito, altere para um tipo diferente de carteira. + Cartão de débito não é suportado para Multisig + Depósito multisig + O depósito permanece bloqueado na conta do depositante até que a operação multisig seja executada ou rejeitada. + Certifique-se de que a operação está correta + Para criar ou gerenciar presentes, mude para um tipo diferente de carteira. + Presentear não é suportado para carteiras Multisig + Assinado e executado por %s. + ✅ Transação multisig executada + %s em %s. + ✍🏻 Sua assinatura foi solicitada + Iniciado por %s. + Carteira: %s + Assinado por %s + %d de %d assinaturas coletadas. + Em nome de %s. + Rejeitado por %s. + ❌ Transação multisig rejeitada + Em nome de + %s: %s + Aprovar + Aprovar & Executar + Insira dados de chamada para ver detalhes + Transação já foi executada ou rejeitada. + Assinatura encerrada + Rejeitar + Signatários (%d de %d) + Lote Tudo (reverte no erro) + Lote (executa até ocorrer erro) + Forçar Lote (ignora erros) + Criado por você + Nenhuma transação ainda.\nSolicitações de assinatura aparecerão aqui + Assinando (%s de %s) + Assinado por você + Operação desconhecida + Transações a assinar + Em nome de: + Assinado pelo signatário + Transação multisig executada + Sua assinatura foi solicitada + Transação multisig rejeitada + Para vender cripto por fiat, altere para um tipo diferente de carteira. + Venda não é suportada para Multisig + Signatário: + %s não tem saldo suficiente para colocar um depósito multisig de %s. Você precisa adicionar %s mais ao seu saldo + %s não tem saldo suficiente para pagar a taxa de rede de %s e colocar um depósito multisig de %s. Você precisa adicionar %s mais ao seu saldo + %s precisa de pelo menos %s para pagar esta taxa de transação e permanecer acima do saldo mínimo de rede. O saldo atual é: %s + %s não tem saldo suficiente para pagar a taxa de rede de %s. Você precisa adicionar %s mais ao seu saldo + Carteiras multisig não suportam a assinatura de mensagens arbitrárias — apenas transações + Transação será rejeitada. Depósito multisig será devolvido a %s. + A transação multisig será iniciada por %s. O iniciador paga a taxa de rede e reserva um depósito multisig, que será liberado uma vez que a transação seja executada. + Transação Multisig + Outros signatários podem agora confirmar a transação.\nVocê pode acompanhar seu status no %s. + Transações a assinar + Transação Multisig criada + Ver detalhes + Transação sem informações iniciais na cadeia (dados da chamada) foi rejeitada + %s em %s.\nNenhuma ação adicional é necessária de sua parte. + Transação Multisig Executada + %1$s → %2$s + %s em %s.\nRejeitado por: %s.\nNenhuma ação adicional é necessária de sua parte. + Transação Multisig Rejeitada + Transações desta carteira requerem aprovação de múltiplos signatários. Sua conta é um dos signatários: + Outros signatários: + Limite %d de %d + Canal de Notificações de Transações Multisig + Ativar nas Configurações + Receba notificações sobre solicitações de assinatura, novas assinaturas e transações concluídas — para que você tenha sempre o controle. Gerencie a qualquer momento nas Configurações. + Notificações push multisig chegaram! + Este Nodo já existe + Taxa da rede + Endereço do nó + Informação do Nó + Adicionado + nodos personalizados + nodos padrão + Padrão + Conectando… + Coleção + Criado por + %s por %s + %s unidades de %s + Edição #%s de %s + Série ilimitada + Propriedade de + Não listado + Seus NFTs + Você não adicionou nem selecionou nenhuma carteira multisig na Pezkuwi. Adicione e selecione pelo menos uma para receber notificações push multisig. + Nenhuma carteira multisig + A URL que você inseriu já existe como o Nodo \"%s\". + Este Nodo já existe + A URL do Nodo que você inseriu não está respondendo ou contém um formato incorreto. O formato da URL deve começar com \"wss://\". + Erro do nodo + A URL que você inseriu não corresponde ao Nodo para %1$s.\nPor favor, insira a URL de um nodo %1$s válido. + Rede errada + Reivindicar recompensas + Seus tokens serão adicionados de volta ao stake + Direto + Informação de staking no pool + Suas recompensas (%s) também serão reivindicadas e adicionadas ao seu saldo livre + Pool + Não é possível realizar a operação especificada pois o pool está em estado de destruição. Será fechado em breve. + Pool está sendo destruído + Atualmente não há vagas disponíveis na fila de desvinculação para o seu pool. Por favor, tente novamente em %s + Muitas pessoas estão desvinculando do seu pool + Seu pool + Seu pool (#%s) + Criar conta + Criar uma nova carteira + Política de Privacidade + Importar conta + Já possui uma carteira + Ao continuar, você concorda com nossos\n%1$s e %2$s + Termos e Condições + Trocar + Um de seus collators não está gerando recompensas + Um de seus collators não foi selecionado na rodada atual + Seu período de desbloqueio para %s passou. Não esqueça de resgatar seus tokens + Não é possível apostar com este collator + Mudar collator + Não é possível adicionar aposta neste collator + Gerenciar collators + O collator selecionado indicou intenção de parar de participar do staking. + Não é possível adicionar stake para o collator pelo qual você está desvinculando todos os tokens. + Sua stake será menor que a stake mínima (%s) para este collator. + O saldo da stake restante cairá abaixo do valor mínimo da rede (%s) e também será adicionado ao valor de desvinculação + Você não está autorizado. Tente novamente, por favor. + Use biometria para autorizar + A Pezkuwi Wallet usa autenticação biométrica para restringir usuários não autorizados de acessar o aplicativo. + Biometria + Código PIN alterado com sucesso + Confirme seu código PIN + Crie o código PIN + Digite o código PIN + Defina o seu código PIN + Você não pode entrar no pool, pois ele alcançou o número máximo de membros + Pool está cheio + Você não pode entrar em um pool que não está aberto. Por favor, entre em contato com o proprietário do pool. + Pool não está aberto + Você não pode mais usar Staking Direto e Staking em Pool a partir da mesma conta. Para gerenciar seu Staking em Pool, você primeiro precisa retirar seus tokens do Staking Direto. + Operações de Pool não disponíveis + Populares + Adicionar rede manualmente + Carregando lista de redes... + Pesquisar pelo nome da rede + Adicionar rede + %s às %s + 1D + Tudo + 1M + %s (%s) + Preço de %s + 1S + 1A + Todo o Tempo + Último Mês + Hoje + Última Semana + Último Ano + Contas + Carteiras + Idioma + Alterar código PIN + Abrir app com saldo oculto + Aprovar com PIN + Modo seguro + Configurações + Para gerenciar seu Cartão de Débito, troque para uma carteira diferente com a rede Polkadot. + Cartão de Débito não é suportado para esta carteira Proxied + Esta conta concedeu acesso para realizar transações para a seguinte conta: + Operações de Staking + A conta delegada %s não possui saldo suficiente para pagar a taxa da rede de %s. Saldo disponível para pagar a taxa: %s + Carteiras proxy não suportam a assinatura de mensagens arbitrárias — apenas transações + %1$s não delegou %2$s + %1$s delegou %2$s apenas para %3$s + Ops! Permissão insuficiente + A transação será iniciada por %s como uma conta delegada. A taxa de rede será paga pela conta delegada. + Esta é uma conta delegada (Proxied) + %s proxy + Voto do delegado realizado + Novo Referendo + Atualização de Referendo + %s Referendo #%s está agora ativo! + 🏛️ Novo referendo + Baixe Pezkuwi Wallet v%s para obter todas as novas funcionalidades! + Uma nova atualização da Pezkuwi Wallet está disponível! + %s referendo #%s terminou e foi aprovado ud83c df89 + ✅ Referendo aprovado! + %s Referendo #%s alterou o status de %s para %s + %s referendo #%s terminou e foi rejeitado! + ❌ Referendo rejeitado! + 🏛️ Status do referendo alterado + %s Referendo #%s alterou o status para %s + Anúncios Pezkuwi + Saldos + Ativar notificações + Transações Multisig + Você não receberá notificações sobre Atividades da Carteira (Saldos, Staking) porque você não selecionou nenhuma carteira + Outros + Tokens recebidos + Tokens enviados + Recompensas de Staking + Carteiras + ✨ Pezkuwi recompensa %s + Recebido %s de staking %s + ✨ Pezkuwi recompensa + Pezkuwi Wallet • agora + Recebido +0.6068 KSM ($20.35) de staking Kusama + Recebido %s em %s + ⬇️ Recebido + ⬇️ Recebido %s + Enviado %s para %s em %s + 💸 Enviado + 💸 Enviado %s + Selecione até %d carteiras para ser notificado quando a carteira tiver atividade + Ativar notificações push + Receba notificações sobre operações de carteira, atualizações de Governança, atividade de Staking e Segurança, para que você esteja sempre informado + Ao ativar as notificações push, você concorda com nossos %s e %s + Por favor, tente novamente mais tarde acessando as configurações de notificação na aba Configurações + Não perca nada! + Selecione a rede para receber %s + Copiar Endereço + Se você reclamar este presente, o link compartilhado será desativado e os tokens serão reembolsados para sua carteira.\nVocê deseja continuar? + Reclamar Presente de %s? + Você recuperou seu presente com sucesso + Cole o json ou faça upload do arquivo… + Fazer upload de arquivo + Restaurar JSON + Frase-senha mnemônica + Semente bruta + Tipo de fonte + Todos os referendos + Mostrar: + Não votados + Votados + Não foram encontrados referendos com os filtros aplicados + As informações dos referendos aparecerão aqui quando começarem + Nenhum referendo com o título ou ID inserido foi encontrado + Buscar por título de referendo ou ID + %d referendos + Deslize para votar nos referendos com resumos de IA. Rápido e fácil! + Referendos + Referendo não encontrado + Os votos de abstenção só podem ser feitos com convicção de 0.1x. Votar com convicção de 0.1x? + Atualização de convicção + Votos de abstenção + A favor: %s + Use o navegador Pezkuwi DApp + Somente o proponente pode editar esta descrição e o título. Se você possui a conta do proponente, visite o Polkassembly e preencha informações sobre sua proposta + Quantia solicitada + Linha do tempo + Seu voto: + Curva de aprovação + Beneficiário + Depósito + Eleitorado + Muito longo para visualização + Parâmetros JSON + Proponente + Curva de suporte + Participação + Limiar de voto + Posição: %s de %s + Você precisa adicionar uma conta %s à carteira para poder votar + Referendo %s + Contra: %s + Votos \'contra\' + %s votos por %s + Votos \'a favor\' + Staking + Aprovado + Cancelado + Decidindo + Decidirá em %s + Executado + Na fila + Na fila (%s de %s) + Cancelado + Não aprovado + Aprovado + Preparando + Rejeitado + Aprovação em %s + Execução em %s + Tempo esgotado em %s + Rejeição em %s + Tempo esgotado + Aguardando depósito + Limiar: %s de %s + Votado: Aprovado + Cancelado + Criado + Votando: Decidindo + Executado + Votando: Na fila + Removido + Votando: Não passando + Votando: Passando + Votando: Preparando + Votado: Rejeitado + Tempo esgotado + Votando: Aguardando depósito + Para passar: %s + Crowdloans + Tesouro: grande despesa + Tesouro: grandes gorjetas + Fellowship: administração + Governo: registrador + Governo: aluguel + Tesouro: despesa média + Governo: cancelador + Governo: removedor + Agenda principal + Tesouro: pequena despesa + Tesouro: pequenas gorjetas + Tesouro: qualquer + permanece bloqueado em %s + Pode ser desbloqueado + Abster-se + Sim + Reutilizar todos os bloqueios: %s + Reutilizar bloqueio de governança: %s + Bloqueado em governança + Período de bloqueio + Não + Multiplique votos aumentando o período de bloqueio + Vote por %s + Após o período de bloqueio, não esqueça de desbloquear seus tokens + Referendos votados + %s votos + %s × %sx + A lista de eleitores aparecerá aqui + %s votos + Comunidade: lista branca + Seu voto: %s votos + Referendo concluído e a votação finalizada + Referendo concluído + Você está delegando votos para o trilho de referendo selecionado. Por favor, peça ao seu delegado para votar ou remova a delegação para poder votar diretamente. + Já delegando votos + Você alcançou o máximo de %s votos para o trilho + Número máximo de votos alcançado + Você não tem tokens suficientes disponíveis para votar. Disponíveis para votar: %s. + Revogar tipo de acesso + Revogar para + Remover votos + + Você votou anteriormente em referendos em %d trilho. Para tornar este trilho disponível para delegação, você precisa remover seus votos existentes. + Você votou anteriormente em referendos em %d trilhos. Para tornar estes trilhos disponíveis para delegação, você precisa remover seus votos existentes. + + Remover o histórico dos seus votos? + %s É exclusivamente seu, armazenado com segurança, inacessível para outros. Sem a senha de backup, restaurar carteiras do Google Drive é impossível. Se perdido, exclua o backup atual para criar um novo com uma senha nova. %s + Infelizmente, sua senha não pode ser recuperada. + Alternativamente, use a Frase de Segurança para restauração. + Você perdeu sua senha? + Sua senha de backup foi atualizada anteriormente. Para continuar usando o Backup na Nuvem, por favor insira a nova senha de backup. + Por favor, insira a senha que você criou durante o processo de backup + Digite a senha de backup + Falha ao atualizar informações sobre o runtime do blockchain. Algumas funcionalidades podem não funcionar. + Falha na atualização do runtime + Contatos + minhas contas + Nenhum pool com o nome ou ID fornecido foi encontrado. Certifique-se de que inseriu os dados corretos + Endereço da conta ou nome da conta + Os resultados da pesquisa serão exibidos aqui + Resultados da pesquisa + pools ativos: %d + membros + Selecionar pool + Selecionar faixas para adicionar delegação + Faixas disponíveis + Por favor, selecione as faixas nas quais você gostaria de delegar seu poder de voto. + Selecionar faixas para editar delegação + Selecionar faixas para revogar sua delegação + Faixas indisponíveis + Enviar presente em + Ativar Bluetooth & Conceder Permissões + Pezkuwi precisa que a localização seja ativada para poder realizar a varredura Bluetooth para encontrar seu dispositivo Ledger + Por favor, ative a localização geográfica nas configurações do dispositivo + Selecionar rede + Selecionar token para votar + Selecione faixas para + %d de %d + Selecione a rede para vender %s + Venda iniciada! Por favor, aguarde até 60 minutos. Você pode acompanhar o status no e-mail. + Nenhum de nossos provedores atualmente suporta a venda deste token. Por favor, escolha um token diferente, uma rede diferente ou volte mais tarde. + Este token não é suportado pela funcionalidade de venda + Endereço ou w3n + Selecione a rede para enviar %s + O destinatário é uma conta do sistema. Não é controlado por nenhuma empresa ou indivíduo.\nVocê ainda deseja realizar esta transferência? + Os tokens serão perdidos + Dar autoridade para + Por favor, certifique-se de que a biometria está ativada nas Configurações + Biometria desativada nas Configurações + Comunidade + Obtenha suporte via Email + Geral + Cada operação de assinatura em carteiras com par de chaves (criada na nova wallet ou importada) deve exigir a verificação do PIN antes da construção da assinatura + Solicitar autenticação para assinatura de operações + Preferências + As notificações push estão disponíveis apenas para a versão da Pezkuwi Wallet baixada do Google Play. + Notificações push estão disponíveis apenas para dispositivos com serviços Google. + A gravação de tela e capturas de tela não estarão disponíveis. O aplicativo minimizado não exibirá o conteúdo + Modo seguro + Segurança + Suporte & Feedback + Twitter + Wiki e Central de Ajuda + Youtube + A convicção será definida como 0.1x quando Abstain + Você não pode fazer staking com Staking Direto e Pools de Nomeação ao mesmo tempo + Já está em staking + Gerenciamento avançado de staking + O tipo de staking não pode ser alterado + Você já tem Staking Direto + Staking Direto + Você especificou menos do que a aposta mínima de %s necessária para ganhar recompensas com %s. Você deve considerar usar Staking em Pool para ganhar recompensas. + Reutilize tokens na Governança + Aposta mínima: %s + Recompensas: Pagas automaticamente + Recompensas: Reivindicar manualmente + Você já está fazendo staking em um pool + Staking em Pool + Sua aposta é menor que o mínimo para ganhar recompensas + Tipo de staking não suportado + Compartilhar link do presente + Reclamar + Olá! Você tem um presente de %s esperando por você!\n\nInstale o aplicativo Pezkuwi Wallet, configure sua carteira e reivindique-o através deste link especial:\n%s + O Presente foi Preparado.\nCompartilhe Agora! + sr25519 (recomendado) + Schnorrkel + A conta selecionada já está em uso como controladora + Adicionar autoridade delegada (Proxy) + Suas delegações + Delegadores ativos + Adicione a conta controladora %s ao aplicativo para realizar esta ação. + Adicionar delegação + Seu stake é menor que o mínimo de %s. Ter um stake abaixo do mínimo aumenta as chances de não gerar recompensas + Stake mais tokens + Altere seus validadores. + Tudo está bem agora. Alertas aparecerão aqui. + Ter uma posição desatualizada na fila de atribuição de stake a um validador pode suspender suas recompensas + Melhorias no staking + Resgate tokens unstaked. + Por favor, aguarde o início da próxima era. + Alertas + Já é controlador + Você já possui staking em %s + Saldo de staking + Saldo + Stake mais + Você não está nomeando nem validando + Alterar controlador + Alterar validadores + %s de %s + Validadores selecionados + Controlador + Conta controladora + Descobrimos que esta conta não possui tokens livres, tem certeza de que deseja alterar o controlador? + O controlador pode desfazer o stake, resgatar, retornar ao stake, alterar o destino das recompensas e os validadores. + O controlador é usado para: desfazer o stake, resgatar, retornar ao stake, alterar validadores e definir o destino das recompensas + Controlador alterado + Este validador está bloqueado e não pode ser selecionado no momento. Por favor, tente novamente na próxima era. + Limpar filtros + Desmarcar todos + Preencher o restante com recomendados + Validadores: %d de %d + Selecionar validadores (máx %d) + Mostrar selecionados: %d (máx %d) + Selecionar validadores + Recompensas estimadas (%% APR) + Recompensas estimadas (%% APY) + Atualizar sua lista + Staking via navegador DApp Pezkuwi + Mais opções de staking + Faça stake e ganhe recompensas + Staking de %1$s está ativo em %2$s a partir de %3$s + Recompensas estimadas + era #%s + Ganhos estimados + Ganhos estimados %s + Stake próprio do validador + Stake próprio do validador (%s) + Tokens no período de desbloqueio não geram recompensas. + Durante o período de desbloqueio, tokens não produzem recompensas + Após o período de desbloqueio, você precisará resgatar seus tokens. + Após o período de desbloqueio, não esqueça de resgatar seus tokens + Suas recompensas serão aumentadas a partir da próxima era. + Você receberá recompensas aumentadas a partir da próxima era + Tokens apostados geram recompensas a cada era (%s). + Tokens em stake produzem recompensas a cada era (%s) + A carteira Pezkuwi alterará o destino das recompensas para a sua conta para evitar a permanência em stake. + Se você deseja desbloquear tokens, terá que esperar pelo período de desbloqueio (%s). + Para desbloquear tokens, você terá que esperar pelo período de desbloqueio (%s) + Informações sobre Staking + Nominadores ativos + + %d dia + %d dias + + Stake mínimo + Rede %s + Apostado + Por favor, troque sua carteira para %s para configurar um proxy + Selecione a conta stash para configurar o proxy + Gerenciar + %s (máx. %s) + O número máximo de nominadores foi alcançado. Tente novamente mais tarde + Não é possível iniciar o staking + Stake mín. + Você precisa adicionar uma conta %s à sua carteira para começar o staking + Mensal + Adicione sua conta controladora no dispositivo. + Sem acesso à conta controladora + Nomeado: + %s recompensado + Um dos seus validadores foi eleito pela rede. + Status ativo + Status inativo + Sua quantia staked é menor que o stake mínimo para receber uma recompensa. + Nenhum dos seus validadores foi eleito pela rede. + Seu staking começará na próxima era. + Inativo + Aguardando a próxima Era + aguardando a próxima era (%s) + Você não tem saldo suficiente para o depósito proxy de %s. Saldo disponível: %s + Canal de notificações de piquetagem + Colator + O stake mínimo do colator é maior que a sua delegação. Você não receberá recompensas deste colator. + Informação do colator + Stake próprio do colator + Colatores: %s + Um ou mais dos seus colatores foram eleitos pela rede. + Delegadores + Você atingiu o número máximo de delegações de %d colatores + Você não pode selecionar um novo collator + Novo collator + esperando pela próxima rodada (%s) + Você tem solicitações de desvinculação pendentes para todos os seus collators. + Nenhum collator disponível para desvinculação + Os tokens retornados serão contabilizados a partir da próxima rodada + Tokens em stake produzem recompensas a cada rodada (%s) + Selecionar collator + Selecionar collator... + Você receberá recompensas aumentadas a partir da próxima rodada + Você não receberá recompensas para esta rodada, pois nenhuma das suas delegações está ativa. + Você já está desvinculando tokens deste collator. Você só pode ter uma desvinculação pendente por collator + Você não pode desvincular deste collator + Seu stake deve ser maior que o stake mínimo (%s) para este collator. + Você não receberá recompensas + Alguns dos seus collators ou não foram eleitos ou têm um stake mínimo maior do que o valor que você apostou. Você não receberá uma recompensa nesta rodada ao apostar com eles. + Seus collators + Sua participação está atribuída aos próximos coletores + Coletores ativos sem produzir recompensas + Coletores sem participação suficiente para serem eleitos + Coletores que entrarão em ação na próxima rodada + Pendente (%s) + Pagamento + Pagamento expirado + + %d dia restante + %d dias restantes + + Você pode pagá-las por si mesmo, quando estiverem perto de expirar, mas você pagará a taxa + As recompensas são pagas a cada 2–3 dias pelos validadores + Todos + Todo o tempo + A data final é sempre hoje + Período personalizado + %dD + Selecione a data final + Termina + Últimos 6 meses (6M) + 6M + Últimos 30 dias (30D) + 30D + Últimos 3 meses (3M) + 3M + Selecionar data + Selecionar data de início + Inicia + Mostrar recompensas de staking para + Últimos 7 dias (7D) + 7D + Último ano (1Y) + 1Y + Seu saldo disponível é %s, você precisa deixar %s como saldo mínimo e pagar a taxa de rede de %s. Você pode fazer staking de no máximo %s. + Autoridades delegadas (proxy) + Slot atual da fila + Novo slot da fila + Retornar ao staking + Todo o unstaking + Os tokens retornados serão contados a partir da próxima era + Quantia customizada + A quantia que você deseja retornar ao staking é maior que o saldo de unstaking + Último unstaked + Mais rentável + Não sobrescrito + Com identidade onchain + Não penalizado + Limite de 2 validadores por identidade + com pelo menos um contato de identidade + Validadores recomendados + Validadores + Recompensa estimada (APY) + Resgatar + Resgatável: %s + Recompensa + Destino da recompensa + Recompensas transferíveis + Era + Detalhes da recompensa + Validador + Ganhos com reinvestimento + Ganhos sem reinvestimento + Perfeito! Todas as recompensas estão pagas. + Incrível! Você não tem recompensas não pagas + Pagar tudo (%s) + Recompensas pendentes + Recompensas não pagas + %s recompensas + Sobre recompensas + Recompensas (APY) + Destino das recompensas + Selecionar por conta própria + Selecionar conta para pagamento + Selecionar recomendação + selecionado %d (máx. %d) + Validadores (%d) + Atualizar Controlador para Stash + Use Proxies para delegar operações de Staking a outra conta + Contas de Controlador Estão Sendo Descontinuadas + Selecione outra conta como controladora para delegar a ela operações de gerenciamento de staking + Melhore a segurança do staking + Definir validadores + Validadores não foram selecionados + Selecione validadores para iniciar o staking + A aposta mínima recomendada para receber recompensas consistentemente é %s. + Você não pode apostar menos do que o valor mínimo da rede (%s) + A aposta mínima deve ser maior que %s + Reapostar + Reapostar recompensas + Como usar suas recompensas? + Selecione o tipo de suas recompensas + Conta para pagamento + Corte + Apostar %s + Apostar o máximo + Período de staking + Tipo de staking + Você deve confiar nas suas nomeações para agir de forma competente e honesta, baseando sua decisão puramente em sua lucratividade atual pode levar a lucros reduzidos ou até mesmo perda de fundos. + Escolha seus validadores com cuidado, uma vez que eles devem agir com proficiência e honestidade. Basear sua decisão puramente na lucratividade pode levar a recompensas reduzidas ou até mesmo perda de participação + Participe com seus validadores + A Pezkuwi Wallet selecionará os principais validadores com base em critérios de segurança e lucratividade + Participe com validadores recomendados + Comece a participar + Reserva + A Reserva pode vincular mais e definir o controlador. + A Reserva é usada para: participar mais e definir o controlador + Conta de Reserva %s não está disponível para atualizar a configuração de participação. + O Nominador ganha renda passiva ao bloquear seus tokens para a segurança da rede. Para isso, o Nominador deve selecionar um número de validadores para apoiar. O Nominador deve ser cuidadoso ao selecionar validadores. Se o validador selecionado não se comportar adequadamente, penalidades de corte serão aplicadas a ambos, baseadas na gravidade do incidente. + A Pezkuwi Wallet oferece suporte para nominadores ajudando-os a selecionar validadores. O aplicativo móvel obtém dados da blockchain e compõe uma lista de validadores, que têm: mais lucros, identidade com informações de contato, não foram penalizados com corte e estão disponíveis para receber nomeações. A Pezkuwi Wallet também se preocupa com a descentralização, então se uma pessoa ou uma empresa operar vários nós de validador, apenas até 2 nós deles serão mostrados na lista recomendada. + Quem é um Nominador? + As recompensas por staking estão disponíveis para pagamento no final de cada era (6 horas em Kusama e 24 horas em Polkadot). A rede armazena recompensas pendentes durante 84 eras e, na maioria dos casos, os validadores estão pagando as recompensas para todos. No entanto, os validadores podem esquecer ou algo pode acontecer com eles, então os nomeadores podem pagar suas próprias recompensas. + Embora as recompensas sejam geralmente distribuídas pelos validadores, a Pezkuwi Wallet ajuda alertando se houver quaisquer recompensas não pagas que estão próximas de expirar. Você receberá alertas sobre isso e outras atividades na tela de staking. + Recebimento de recompensas + Staking é uma opção para ganhar renda passiva ao bloquear seus tokens na rede. As recompensas de staking são alocadas a cada era (6 horas em Kusama e 24 horas em Polkadot). Você pode fazer staking pelo tempo que desejar, e para desfazer o staking de seus tokens você precisa esperar pelo período de unstaking terminar, tornando seus tokens disponíveis para serem resgatados. + O staking é uma parte importante da segurança e confiabilidade da rede. Qualquer um pode executar nós validadores, mas apenas aqueles que têm tokens suficientes apostados serão eleitos pela rede para participar da composição de novos blocos e receber as recompensas. Os validadores muitas vezes não têm tokens suficientes por si mesmos, então os nomeadores os ajudam bloqueando seus tokens para eles atingirem a quantidade necessária de stake. + O que é staking? + O validador executa um nó de blockchain 24/7 e precisa ter stake suficiente bloqueada (tanto própria quanto fornecida por nomeadores) para ser eleito pela rede. Os validadores devem manter o desempenho e a confiabilidade de seus nós para serem recompensados. Ser um validador é quase um trabalho em tempo integral, existem empresas que se concentram em ser validadores nas redes blockchain. + Qualquer um pode ser um validador e executar um nó de blockchain, mas isso exige um certo nível de habilidades técnicas e responsabilidade. As redes Polkadot e Kusama têm um programa, chamado Thousand Validators Programme, para fornecer suporte aos iniciantes. Além disso, a própria rede sempre recompensará mais os validadores, que têm menos participação (mas o suficiente para ser eleito) para melhorar a descentralização. + Quem é um validador? + Troque sua conta para stash para definir o controlador. + Staking + %s staking + Recompensado + Total apostado + Limite de impulso + Para meu coletor + sem Aumento de Rendimento + com Aumento de Rendimento + para apostar automaticamente %s todos os meus tokens transferíveis acima + para apostar automaticamente %s (antes: %s) todos os meus tokens transferíveis acima + Eu quero apostar + Aumento de Rendimento + Tipo de Staking + Você está retirando todos os seus tokens e não pode apostar mais. + Incapaz de apostar mais + Ao desvincular parcialmente, você deve deixar pelo menos %s em stake. Deseja realizar o desvinculamento total desvinculando os %s restantes também? + Quantia muito pequena restante em stake + A quantia que você quer desvincular é maior que o saldo em stake + Desvincular + Transações de desvinculação aparecerão aqui + Transações de desvinculação serão exibidas aqui + Desvinculando: %s + Seus tokens estarão disponíveis para resgate após o período de desvinculação. + Você atingiu o limite de pedidos de desvinculação (%d pedidos ativos). + Limite de pedidos de desvinculação atingido + Período de desvinculação + Desvincular tudo + Desvincular tudo? + Recompensa estimada (%% APY) + Recompensa estimada + Informações do validador + Sobrescrito. Você não receberá recompensas do validador nesta era. + Nomeadores + Sobrescrito. Apenas os nomeadores com maior stake são recompensados. + Próprio + Nenhum resultado de busca.\nCertifique-se de que digitou o endereço completo da conta + Validador é punido por mau comportamento (por exemplo, fica offline, ataca a rede ou executa software modificado) na rede. + Aposta total + Aposta total (%s) + A recompensa é menor que a taxa de rede. + Anual + Sua aposta é alocada aos seguintes validadores. + Sua aposta é destinada aos próximos validadores + Eleitos (%s) + Validadores que não foram eleitos nesta era. + Validadores sem aposta suficiente para serem eleitos + Outros, que estão ativos sem a sua alocação de aposta. + Validadores ativos sem a sua alocação de aposta + Não eleitos (%s) + Seus tokens são alocados aos validadores com excesso de subscrição. Você não receberá recompensas nesta era. + Recompensas + Sua aposta + Seus validadores + Seus validadores mudarão na próxima era. + Agora vamos fazer o backup da sua carteira. Isso garante que seus fundos estejam seguros e protegidos. Os backups permitem que você restaure sua carteira a qualquer momento. + Continuar com o Google + Digite o nome da carteira + Minha nova carteira + Continuar com backup manual + Dê um nome à sua carteira + Isso será visível apenas para você e pode ser editado mais tarde. + Sua carteira está pronta + Bluetooth + USB + Você bloqueou tokens em seu saldo devido a %s. Para continuar, você deve inserir menos que %s ou mais que %s. Para apostar outro valor, você deve remover seus bloqueios %s. + Você não pode apostar o valor especificado + Selecionado: %d (máx %d) + Saldo disponível: %1$s (%2$s) + %s com seus tokens em stake + Participe da governança + Aposte mais de %1$s e %2$s com seus tokens em stake + participe da governança + Aposte a qualquer momento com tão pouco quanto %1$s. Seu stake ganhará recompensas ativamente %2$s + em %s + Aposte a qualquer momento. Seu stake ganhará recompensas ativamente %s + Descubra mais informações sobre\n%1$s apostando na %2$s + Pezkuwi Wiki + Recompensas acumulam %1$s. Aposte mais de %2$s para o pagamento automático de recompensas, caso contrário, você precisa reivindicar as recompensas manualmente + a cada %s + Recompensas acumulam %s + Recompensas acumulam %s. Você precisa reivindicar as recompensas manualmente + As recompensas são acumuladas %s e adicionadas ao saldo transferível + As recompensas são acumuladas %s e adicionadas de volta à stake + O status das recompensas e da stake varia ao longo do tempo. %s de tempos em tempos + Monitore sua stake + Começar a Stake + Veja %s + Termos de Uso + %1$s é uma %2$s com %3$s + sem valor de token + rede de teste + %1$s\nem seus tokens %2$s\npor ano + Ganhe até %s + Desfaça a stake a qualquer momento e resgate seus fundos %s. Nenhuma recompensa é ganha enquanto desfaz a stake + após %s + O pool selecionado está inativo devido à não seleção de validadores ou sua stake é menor que o mínimo.\nTem certeza de que deseja continuar com o Pool selecionado? + O número máximo de nomeadores foi alcançado. Tente novamente mais tarde + %s está atualmente indisponível + Validadores: %d (máx %d) + Modificado + Novo + Removido + Token para pagar a taxa de rede + A taxa de rede é adicionada em cima do valor inserido + Falha na simulação da etapa de troca + Este par não é suportado + Falha na operação #%s (%s) + Troca de %s para %s em %s + Transferência de %s de %s para %s + + %s operação + %s operações + + %s de %s operações + Trocando %s para %s em %s + Transferindo %s para %s + Tempo de execução + Você deve manter pelo menos %s após pagar a taxa de rede %s, pois você está com tokens insuficientes + Você deve manter pelo menos %s para receber o token %s + Você deve ter pelo menos %s em %s para receber %s token + Você pode trocar até %1$s, pois você precisa pagar %2$s de taxa de rede. + Você pode trocar até %1$s, pois você precisa pagar %2$s de taxa de rede e também converter %3$s em %4$s para atender ao saldo mínimo de %5$s. + Trocar máximo + Trocar mínimo + Você deve deixar pelo menos %1$s no seu saldo. Deseja realizar a troca total adicionando os %2$s restantes também? + Quantia muito pequena restante no seu saldo + Você deve manter pelo menos %1$s após pagar a taxa de rede %2$s e converter %3$s em %4$s para atender ao saldo mínimo de %5$s.\n\nDeseja realizar a troca total adicionando os %6$s restantes também? + Pagar + Receber + Selecione um token + Tokens insuficientes para a troca + Liquidez insuficiente + Você não pode receber menos de %s + Compra instantânea de %s com cartão de crédito + Transfira %s de outra rede + Receba %s com QR ou seu endereço + Obtenha %s usando + Durante a execução da troca, o valor intermediário recebido é %s, que é menor que o saldo mínimo de %s. Tente especificar um valor de troca maior. + O deslizamento deve ser especificado entre %s e %s + Deslizamento inválido + Selecione um token para pagar + Selecione um token para receber + Digite a quantidade + Digite outra quantidade + Para pagar a taxa de rede com %s, a Pezkuwi fará automaticamente a troca de %s por %s para manter o saldo mínimo de %s na sua conta. + Uma taxa de rede cobrada pela blockchain para processar e validar quaisquer transações. Pode variar dependendo das condições da rede ou velocidade da transação. + Selecione a rede para trocar %s + O pool não tem liquidez suficiente para a troca + Diferença de preço refere-se à diferença de preço entre dois ativos diferentes. Ao fazer uma troca em cripto, a diferença de preço geralmente é entre o preço do ativo que você está trocando e o preço do ativo com o qual você está trocando. + Diferença de preço + %s ≈ %s + Taxa de câmbio entre duas criptomoedas diferentes. Representa quanto de uma criptomoeda você pode obter em troca por uma certa quantidade de outra criptomoeda. + Taxa + Taxa antiga: %1$s ≈ %2$s.\nPezkuwi taxa: %1$s ≈ %3$s + A taxa de troca foi atualizada + Repetir a operação + Rota + O caminho que seu token percorrerá através de diferentes redes para obter o token desejado. + Troca + Transferência + Configurações de Troca + Deslizamento + Deslizamento em trocas é um acontecimento comum em negociações descentralizadas onde o preço final de uma transação de troca pode diferir ligeiramente do preço esperado, devido às condições de mercado em mudança. + Insira outro valor + Insira um valor entre %s e %s + Deslizamento + A transação pode ser antecipada por causa do alto deslizamento. + A transação pode ser revertida por causa da baixa tolerância ao deslizamento. + A quantia de %s é menor que o saldo mínimo de %s + Você está tentando trocar uma quantia muito pequena + Abstenção: %s + A favor: %s + Você sempre pode votar neste referendo mais tarde + Remover referendo %s da lista de votos? + Alguns dos referendos não estão mais disponíveis para votação ou você pode não ter tokens suficientes disponíveis para votar. Disponível para votar: %s. + Alguns dos referendos foram excluídos da lista de votos + Os dados do referendo não puderam ser carregados + Nenhum dado recuperado + Você já votou em todos os referendos disponíveis ou não há referendos para votar agora. Volte mais tarde. + Você já votou em todos os referendos disponíveis + Solicitado: + Lista de votos + Faltam %d + Confirmar votos + Nenhum referendo para votar + Confirme seus votos + Sem votos + Você votou com sucesso em %d referendos + Você não tem saldo suficiente para votar com o poder de voto atual %s (%sx). Por favor, altere o poder de voto ou adicione mais fundos à sua carteira. + Saldo insuficiente para votar + Contra: %s + SwipeGov + Vote em %d referendos + A votação será definida para votos futuros no SwipeGov + Poder de voto + Staking + Carteira + Hoje + Link Coingecko para informações de preço (Opcional) + Selecione um provedor para comprar o token %s + Os métodos de pagamento, taxas e limites diferem por provedor.\nCompare suas ofertas para encontrar a melhor opção para você. + Selecione um provedor para vender o token %s + Para continuar a compra você será redirecionado do aplicativo Pezkuwi Wallet para %s + Continuar no navegador? + Nenhum de nossos provedores atualmente suporta a compra ou venda deste token. Por favor, escolha um token diferente, uma rede diferente ou volte mais tarde. + Este token não é suportado pela funcionalidade de compra/venda + Copiar hash + Taxa + De + Hash da Transação + Detalhes da Transação + Ver em %s + Ver no Polkascan + Ver no Subscan + %s em %s + Seu histórico de transações %s anterior ainda está disponível em %s + Concluído + Falhou + Pendente + Canal de Notificação de Transações + Compre cripto a partir de apenas $5 + Venda cripto a partir de apenas $10 + De: %s + Para: %s + Transferência + Transferências de entrada e saída serão exibidas aqui + Suas operações serão exibidas aqui + Remova votos para delegar nestas faixas + Faixas que você já delegou votos + Faixas indisponíveis + Faixas em que você tem votos existentes + Não mostrar isso novamente.\nVocê pode encontrar o endereço legado em Receber. + Formato legado + Novo formato + Algumas trocas ainda podem exigir o formato legado\npara operações enquanto atualizam. + Novo Endereço Unificado + Instalar + Versão %s + Atualização disponível + Para evitar quaisquer problemas e melhorar sua experiência de usuário, recomendamos fortemente que instale as atualizações recentes o mais rápido possível + Atualização crítica + Mais recente + Muitas novas funcionalidades incríveis estão disponíveis para a Pezkuwi Wallet! Certifique-se de atualizar sua aplicação para acessá-las + Atualização importante + Crítica + Importante + Ver todas as atualizações disponíveis + Nome + Nome da carteira + Este nome será exibido apenas para você e armazenado localmente em seu dispositivo móvel. + Esta conta não foi eleita pela rede para participar da era atual + Re-votar + Votar + Status da votação + Seu cartão está sendo carregado! + Seu cartão está sendo emitido! + Pode levar até 5 minutos.\nEsta janela será fechada automaticamente. + Estimativa %s + Comprar + Comp./Vend. + Comprar tokens + Comprar com + Receber + Receber %s + Envie somente o token %1$s e tokens na rede %2$s para este endereço, ou você pode perder seus fundos + Vender + Vender tokens + Enviar + Trocar + Ativos + Seus ativos aparecerão aqui.\nCertifique-se de que o filtro\n\"Ocultar saldos zero\"\nestá desativado + Valor dos ativos + Disponível + Delegado + Detalhes do saldo + Saldo total + Total após a transferência + Congelado + Bloqueado + Resgatável + Reservado + Transferível + Desvinculando + Carteira + Pezkuwi conexão + + %s conta está faltando. Adicione a conta na carteira em Configurações + %s contas estão faltando. Adicione as contas na carteira em Configurações + + Algumas das redes necessárias solicitadas por \"%s\" não são suportadas na Pezkuwi Carteira + As sessões do Wallet Connect aparecerão aqui + WalletConnect + dApp desconhecido + + %s rede não suportada é oculta + %s redes não suportadas são ocultas + + WalletConnect v2 + Transferência entre cadeias + Criptomoedas + Moedas fiduciárias + Moedas fiduciárias populares + Moeda + Detalhes extrínsecos + Ocultar ativos com saldo zero + Outras transações + Exibir + Recompensas e Penalidades + Trocas + Filtros + Transferências + Gerenciar ativos + Como adicionar carteira? + Como adicionar carteira? + Como adicionar carteira? + Exemplos de nomes: Conta principal, Meu validador, Empréstimos coletivos Dotsama, etc. + Compartilhe este QR com o remetente + Permita que o remetente escaneie este código QR + Meu endereço %s para receber %s: + Compartilhar código QR + Destinatário + Certifique-se de que o endereço é\nda rede correta + Formato de endereço inválido.\nCertifique-se de que o endereço\npertence à rede correta + Saldo mínimo + Devido às restrições entre cadeias, você pode transferir no máximo %s + Você não tem saldo suficiente para pagar a taxa de Cross-chain de %s.\nSaldo restante após a transferência: %s + A taxa Cross-chain é adicionada ao valor inserido. O destinatário pode receber parte da taxa Cross-chain + Confirmar transferência + Entre cadeias + Taxa Cross-chain + Sua transferência falhará, pois a conta de destino não tem %s suficiente para aceitar outras transferências de token + O destinatário não pode aceitar a transferência + Sua transferência falhará, pois o valor final na conta de destino será menor que o saldo mínimo. Por favor, tente aumentar o valor. + Sua transferência removerá a conta do bloco de armazenamento, pois fará com que o saldo total seja menor que o saldo mínimo. + Sua conta será removida da blockchain após a transferência pois reduzirá o saldo total abaixo do mínimo + Transferência removerá conta + Sua conta será removida da blockchain após a transferência pois isso fará com que o saldo total fique abaixo do mínimo. O saldo restante também será transferido ao destinatário. + Da rede + Você precisa ter pelo menos %s para pagar esta taxa de transação e permanecer acima do saldo mínimo da rede. Seu saldo atual é: %s. Você precisa adicionar %s ao seu saldo para realizar esta operação. + Para mim + Na cadeia + O seguinte endereço: %s é conhecido por ser utilizado em atividades de phishing, portanto não recomendamos enviar tokens para esse endereço. Deseja prosseguir mesmo assim? + Alerta de golpe + O destinatário foi bloqueado pelo proprietário do token e atualmente não pode aceitar transferências recebidas + O destinatário não pode aceitar transferência + Rede do destinatário + Para a rede + Enviar %s de + Enviar %s em + para + Remetente + Tokens + Enviar para este contato + Detalhes da transferência + %s (%s) + Endereços %s para %s + Pezkuwi detectou problemas com a integridade das informações sobre os endereços %1$s. Entre em contato com o proprietário de %1$s para resolver os problemas de integridade. + Falha na verificação de integridade + Destinatário inválido + Nenhum endereço válido foi encontrado para %s na rede %s + %s não encontrado + Os serviços w3n %1$s estão indisponíveis. Tente novamente mais tarde ou insira o endereço %1$s manualmente + Erro ao resolver w3n + Pezkuwi não pode resolver o código do token %s + Token %s ainda não é suportado + Ontem + O impulso de rendimento será desligado para os colatores atuais. Novo colator: %s + Alterar o Colator com Impulso de Rendimento? + Você não tem saldo suficiente para pagar a taxa de rede de %s e a taxa de execução do impulso de rendimento de %s.\nSaldo disponível para pagar a taxa: %s + Não há tokens suficientes para pagar a primeira taxa de execução + Você não tem saldo suficiente para pagar a taxa de rede de %s e não cair abaixo do limiar de %s.\nSaldo disponível para pagar a taxa: %s + Não há tokens suficientes para permanecer acima do limiar + Tempo de aumento de stake + O Impulso de Rendimento irá automaticamente fazer o stake de %s de todos os meus tokens transferíveis acima de %s + Com Impulso de Rendimento + + + Ponte DOT ↔ HEZ + Ponte DOT ↔ HEZ + Você envia + Você recebe (estimado) + Taxa de câmbio + Taxa da ponte + Mínimo + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Trocar + As trocas HEZ→DOT são processadas quando há liquidez suficiente de DOT. + Saldo insuficiente + Valor abaixo do mínimo + Digite o valor + As trocas HEZ→DOT podem ter disponibilidade limitada dependendo da liquidez. + As trocas HEZ→DOT estão temporariamente indisponíveis. Tente novamente quando houver liquidez suficiente de DOT. + diff --git a/common/src/main/res/values-ru/strings.xml b/common/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..9ebce65 --- /dev/null +++ b/common/src/main/res/values-ru/strings.xml @@ -0,0 +1,2075 @@ + + + Свяжитесь с нами + Github + Политика конфиденциальности + Оцените приложение + Telegram + Условия использования + Условия использования + О приложении + Версия + Сайт + Введите действительный %s адрес... + EVM Адрес должен быть действительным или пустым... + Введите действительный substrate адрес... + Добавить аккаунт + Добавить адрес + Аккаунт уже существует. Пожалуйста, попробуйте другой. + Отследить любой кошелек по его адресу + Добавить watch-only кошелёк + Запишите вашу секретную фразу + Пожалуйста, убедитесь, что ваша фраза записана корректно и разборчиво. + %s адрес + Нет аккаунта на %s + Подтверждение + Давайте перепроверим + Выберите слова в правильном порядке + Создать новый аккаунт + Не используйте буфер обмена или скриншоты на вашем мобильном устройстве, постарайтесь найти безопасные способы резервного копирования (например на бумагу) + Имя будет использоваться только в приложении. Вы сможете изменить его позже + Назовите кошелёк + Мнемоника + Создать новый кошелёк + Мнемоническая фраза используется для восстановления доступа к аккаунту. Запишите ее, мы не сможем восстановить доступ к аккаунту без нее! + Аккаунты с изменённым секретом + Забыть + Прежде чем продолжить, убедитесь, что вы создали резервную копию своего кошелька. + Забыть кошелек? + Некорректный путь деривации для Ethereum + Некорректный путь деривации для Substrate + Этот кошелек работает в паре с %1$s. Pezkuwi поможет вам сформировать любые операции, которые вы хотите, и вам будет предложено подписать их с помощью %1$s + Не поддерживается %s + Это watch-only кошелек, Pezkuwi может показать вам баланс и другую информацию, но вы не можете выполнять какие-либо транзакции с этим кошельком. + Введите имя кошелька... + Пожалуйста, попробуйте другой. + Тип ключевой пары Ethereum + Последовательность для вывода секрета Ethereum + EVM Аккаунты + Экспорт аккаунта + Экспортировать + Импортировать существующий + Это необходимо для шифрования данных и сохранения файла JSON. + Установите пароль для вашего JSON файла + Сохраните ваш секрет и храните его в надежном месте + Запишите секрет и храните его в надежном месте + Неверный формат данных восстановления. Пожалуйста, удостоверьтесь, что данные соответствуют json формату. + Seed неверный. Пожалуйста, убедитесь, что введенные данные содержат 64 hex символа. + JSON не содержит информации о сети. Пожалуйста, укажите её в блоке дополнительных ниже. + Загрузите JSON для восстановления + Зачастую 12 слов (но возможно импортирование 15, 18, 21 или 24) + Слова должны быть разделены пробелом + Введите слова в правильном порядке + Пароль + приватный ключ + 0xAB + Введите ваш сид + Pezkuwi совместима со всеми приложениями + Импортировать кошелёк + Аккаунт + Ваш путь деривации содержит неподдерживаемые символы или имеет некорректную структуру + Некорректный путь деривации + JSON файл + Удостоверьтесь, что %s на Ваше устройство Ledger, используя приложение Ledger Live + Polkadot приложение установлено + %s на своем устройстве Ledger + Откройте Polkadot приложение + Flex, Stax, Nano X, Nano S Plus + Ledger (Generic Polkadot app) + Убедитесь, что приложение сети установлено на вашем Ledger устройстве через приложение Ledger Live. Откройте приложение сети на вашем Ledger устройстве. + Добавьте аккаунт + Добавьте аккаунты в Ваш кошелёк + Руководство по подключению Ledger + Удостоверьтесь, что %s на Ваше устройство Ledger, используя приложение Ledger Live + приложение Сети установлено + %s на Вашем Ledger устройстве + Откройте приложение Сети + Разрешите Pezkuwi Wallet %s + доступ к Bluetooth + %s для добавления в кошелёк + Выберите аккаунт + %s в настройках вашего телефона + Включите OTG + Подключить Ledger + Чтобы подписать операции и перенести свои учетные записи в новое приложение Generic Ledger, установите и откройте приложение Migration. Приложения Ledger (Legacy) и Migration Ledger не будут поддерживаться в будущем. + Выпущено новое приложение Ledger + Polkadot Migration + Migration приложение в ближайшее время будет недоступно. Используйте его для переноса своих учетных записей в новое приложение Ledger, чтобы не потерять свои средства. + Polkadot + Ledger Nano X + Ledger Legacy + Если вы используете Ledger через Bluetooth, включите Bluetooth на обоих устройствах и предоставьте Pezkuwi разрешения на использование Bluetooth и местоположения. Для USB включите OTG в настройках вашего телефона. + Пожалуйста, включите Bluetooth в настройках телефона. Разблокируйте Ledger устройство и откройте %s приложение. + Выберите устройство Ledger + Пожалуйста, активируйте устройство Ledger. Разблокируйте ваше устройство Ledger и откройте приложение %s. + Почти готово! 🎉\n Просто нажмите ниже, чтобы завершить настройку и начать использовать свои учетные записи без проблем как в Polkadot App, так и в Pezkuwi Wallet + Добро пожаловать в Pezkuwi! + фраза длиной 12, 15, 18, 21 или 24 слова + Мультисиг + Совместный контроль (мультисиг) + У вас нет учетной записи в этой сети, вы можете создать или импортировать учетную запись. + Необходим аккаунт + Аккаунт не найден + публичный ключ + Parity Signer + %s не поддерживает %s + Следующие аккаунты были успешно считаны с %s + Вот ваши аккаунты + Неверный QR-код, пожалуйста, убедитесь, что вы сканируете QR-код из %s + Убедитесь, что выбрали самый верхний + %s на вашем смартфоне + Откройте приложение Parity Signer + %s, который вы хотите добавить в Pezkuwi Wallet. + Перейдите на вкладку «Ключи». Выберите сид, затем аккаунт + Parity Signer предоставит вам %s + QR-код для сканирования + Добавить кошелёк из %s + %s не поддерживает подписание произвольных сообщений — только транзакции + Подписание не поддерживается + Отсканируйте QR-код из %s + Устаревший + Новый (Vault v7+) + У меня ошибка в %s + Срок действия QR-кода истек + В целях безопасности сгенерированные операции действительны только в течение %s.\nПожалуйста, сгенерируйте новый QR-код и подпишите его с помощью %s + QR-код действителен в течение %s + Убедитесь, что вы сканируете QR-код для текущей подписываемой операции. + Подписать с %s + Polkadot Vault + Обратите внимание, имя пути деривации должно быть пустым + %s на смартфоне + Откройте приложение Polkadot Vault + %s, который вы хотите добавить в Pezkuwi Wallet + Нажмите на Derived Key + Polkadot Vault предоставит вам %s + QR-код для сканирования + Нажмите на значок в правом верхнем углу и выберите %s + Экспортировать приватный ключ + Приватный ключ + Проксированный + Делегировано вам (Proxied) + Любые действия + Аукционы + Отмена прокси + Демократия + Идентификация личности + Пулы номинаций + Не переводы + Стейкинг + У меня уже есть аккаунт + секреты + 64 шест. символа + Выберите аппаратный кошелёк + Выберите ваш тип секрета + Выберите кошелек + Аккаунты с общим секретом + Substrate Аккаунты + Тип ключевой пары Substrate + Последовательность для вывода секрета Substrate + Имя кошелька + Имя кошелька + Moonbeam, Moonriver и другие сети + EVM Адрес (необязательно) + Предустановленные кошельки + Polkadot, Kusama, Karura, KILT и 50+ сетей + Отслеживайте активность любого кошелька, не вводя свой приватный ключ в Pezkuwi Wallet + Ваш кошелек доступен только для просмотра, то есть вы не можете выполнять с ним никаких операций + Упс! Ключ отсутствует + watch-only + Использовать Polkadot Vault, Ledger или Parity Signer + Аппаратный кошелёк + Используйте свои аккаунты Trust Wallet в Pezkuwi + Trust Wallet + Добавить %s аккаунт + Добавить кошелёк + Изменить аккаунт %s + Изменить аккаунт + Ledger (Legacy) + Делегировано вам + Совместный контроль + Добавить пользовательскую ноду + Вы должны добавить %s аккаунт в кошелек, чтобы иметь возможность делегировать + Введите данные сети + Делегировать аккаунту + Аккаунт делегирования + Кошелек делегирования + Тип доступа + Депозит остается зарезервированным на вашем счете до тех пор, пока прокси не будет удален. + Вы достигли лимита добавленных прокси (%s) в %s . Удалите прокси, чтобы добавить новые. + Достигнуто максимальное количество прокси + Добавленные пользовательские сети\nпоявятся здесь + +%d + Pezkuwi автоматически переключилась на ваш мультисига кошелек, чтобы вы могли просматривать ожидающие транзакции. + Цветной + Внешний вид + иконки токенов + Белый + Введенный адрес контракта уже добавлен в Pezkuwi как токен %s. + Введенный адрес контракта присутствует в Pezkuwi как токен %s. Вы уверены, что хотите изменить его? + Этот токен уже добавлен + Пожалуйста, убедитесь, что введенный URL-адрес имеет следующую форму: www.coingecko.com/en/coins/tether. + Недействительная ссылка на CoinGecko + Введенный адрес контракта не является %s ERC-20 контрактом. + Неверный адрес контракта + Число десятичных знаков должно быть не менее 0 и не более 36. + Недопустимое число десятичных знаков + Введите адрес контракта + Введите число десятичных знаков + Введите символ + Перейти к %s + Начиная с %1$s, ваш баланс %2$s, Staking и Governance будут на %3$s — с улучшенной производительностью и меньшими затратами. + Ваши %s токены теперь на %s + Сети + Токены + Добавить токен + Адрес контракта + Число десятичных знаков + Символ + Введите данные ERC-20 токена + Выберите сеть для добавления ERC-20 токена + Краудлоуны + Governance v1 + OpenGov + Выборы + Стейкинг + Вестинг + Купить токены + Получили свои DOT из краудлоунов? Начните стейкать DOT уже сегодня, чтобы получить максимальные вознаграждения! + Максимизируйте награды от DOT 🚀 + Фильтр токенов + У вас нет токенов для подарка.\nКупите или внесите токены на свой счет. + Все сети + Управление токенами + Не переводите %s на Ledger аккаунт так как Ledger не поддерживает переводы %s, таким образом ассет будет недоступен для перевода на этом аккаунте + Ledger не поддерживает этот токен + Поиск по названию сети или токена + Токены и сети с указанным именем\nне найдены + Поиск по токену + Ваши кошельки + У вас нет токенов для отправки.\nКупите или получите токены\nна свой аккаунт. + Токен для оплаты + Токен для получения + Прежде чем приступить к изменениям, %s для модифицированных и удаленных кошельков! + убедитесь, что вы сохранили секретные фразы + Применить Изменения Бэкапа? + Приготовьтесь сохранить ваш кошелёк! + Эта секретная фраза дает вам полный и постоянный доступ ко всем подключенным кошелькам и средствам в них. \n %s + НЕ ПЕРЕДАВАЙТЕ ЕЁ НИКОМУ. + Не вводите свою секретную фразу ни в какие формы или на веб-сайтах. \n %s + ВАШИ СРЕДСТВА БУДУТ ПОТЕРЯНЫ НАВСЕГДА. + Служба поддержки или администраторы никогда и ни при каких обстоятельствах не будут запрашивать вашу секретную фразу. \n %s + ОСТЕРЕГАЙТЕСЬ МОШЕННИКОВ. + Ознакомьтесь и Примите, чтобы продолжить + Удалить бэкап + Вы можете вручную создать бэкап своей секретной фразы, чтобы получить доступ к средствам вашего кошелька, если потеряете доступ к этому устройству + Создать бэкап вручную + Вручную + Вы не добавили ни одного кошелька с секретной фразой. + Нет кошельков для бэкапа + Вы можете включить бэкап на Google Диске для хранения зашифрованных копий ваших кошельков, защищенных установленным паролем. + Сохранить на Google Диск + Google Диск + Бэкап + Бэкап + Простой и эффективный процесс KYC + Биометрия + Закрыть все + Покупка совершена! Ожидайте до 60 минут. Вы можете отслеживать статус по электронной почте. + Выберите сеть для покупки %s + Покупка инициирована! Пожалуйста, подождите до 60 минут. Вы можете отслеживать статус по email. + Ни один из наших провайдеров в настоящее время не поддерживает покупку этого токена. Пожалуйста, выберите другой токен, другую сеть или попробуйте позже. + Этот токен не поддерживается функцией покупки + Возможность оплатить комиссии любым токеном + Миграция происходит автоматически, действия не требуются + Старая история транзакций остается на %s + Начиная с %1$s ваш баланс %2$s, Staking и Governance включены на %3$s. Эти функции могут быть недоступны до 24 часов. + %1$sx более низкие транзакционные комиссии\n(от %2$s до %3$s) + %1$sx сокращение минимального баланса\n(от %2$s до %3$s) + Что делает Asset Hub замечательным? + Начиная с %1$s ваш баланс %2$s, Staking и Governance находятся на %3$s + Поддерживаются больше токенов: %s и другие токены экосистемы + Унифицированный доступ к %s, активам, staking и governance + Автоматическая балансировка нод + Включить подключение + Введите пароль, который будет использоваться для восстановления ваших кошельков из облачного бэкапа. Этот пароль нельзя будет восстановить в будущем, поэтому обязательно запомните его! + Изменить пароль бэкапа + Токен + Свободный баланс + К сожалению, запрос проверки баланса не выполнен. Пожалуйста, попробуйте позже. + Извините, сервис переводов недоступен. Пожалуйста, попробуйте позже. + К сожалению, у вас недостаточно средств для совершения данной операции + Комиссия + Сеть недоступна + Кому + Упс, подарок уже был востребован + Подарок не может быть получен + Забрать подарок + Возможно, есть проблема с сервером. Пожалуйста, попробуйте позже. + Упс, что-то пошло не так + Используйте другой кошелек, создайте новый или добавьте аккаунт %s в этот кошелек в Настройках. + Вы успешно забрали подарок. Токены скоро появятся на вашем балансе. + У вас крипто-подарок! + Создайте новый кошелек или импортируйте существующий, чтобы забрать подарок + Вы не можете получить подарок с кошельком %s + Все открытые вкладки в DApp браузере будут закрыты. + Закрыть все DApps? + %s и не забывайте всегда держать их в письменном виде, чтобы иметь возможность восстановить в любое время. Сделать это можно в Настройках Бэкапа. + Пожалуйста, запишите секретные фразы от всех ваших кошельков перед тем как продолжить + Бэкап будет удалён из Google Диска + Текущий бэкап ваших кошельков будет удален навсегда! + Вы уверены, что хотите удалить Облачный Бэкап? + Удалить Бэкап + На данный момент ваш бэкап не синхронизирован. Пожалуйста, ознакомьтесь с этими изменениями. + Обнаружены изменения в Облачном Бэкапе + Обзор Изменений + Если вы не записали вручную свою секретную фразу для кошельков, которые будут удалены, то эти кошельки и все их активы будут навсегда и безвозвратно потеряны. + Вы уверены, что хотите применить эти изменения? + Обзор проблемы + На данный момент ваш бэкап не синхронизирован. Пожалуйста, ознакомьтесь с проблемой. + Не удалось применить изменения кошелька в Облачном Бэкапе + Пожалуйста, убедитесь, что вы вошли в свою учетную запись Google с правильными учетными данными и предоставили Pezkuwi Wallet доступ к Google Диску + Ошибка аутентификации на Google Диске + У вас недостаточно свободного места на Google Диске. + Недостаточно места + К сожалению, Google Диск не работает без сервисов Google Play, которые отсутствуют на вашем устройстве. Попробуйте получить доступ к сервисам Google Play + Службы Google Play не найдены + Не удается создать бэкап ваших кошельков на Google Диске. Пожалуйста, убедитесь, что вы разрешили Pezkuwi Wallet использовать ваш Google Диск и у вас достаточно свободного места для хранения, а затем повторите попытку. + Ошибка Google Диска + Пожалуйста, проверьте корректность пароля и попробуйте снова. + Неверный пароль + К сожалению, мы не нашли бэкап для восстановления кошельков + Бэкапы не найдены + Убедитесь, что вы сохранили секретную фразу для кошелька, прежде чем продолжить. + Кошелек будет удален из Облачного Бэкапа. + Обзор Ошибки Бэкапа + Обзор Изменений Бэкапа + Ввести Пароль + Включите, чтобы создать бэкап кошельков в Google Диске + Последняя синхронизация: %s в %s + Войдите в Google Диск + Обзор Проблемы с Google Диском + Бэкап Отключён + Бэкап Синхронизирован + Синхронизация Бэкапа... + Бэкап Не Синхронизирован + Новые кошельки автоматически добавляются в Облачный Бэкап. Вы можете отключить Облачный Бэкап в настройках. + Изменения кошелька будут применены в Облачном Бэкапе + Примите условия... + Аккаунт + Адрес аккаунта + Активно + Добавить + Добавить делегацию + Добавить сеть + Адрес + Продвинутый + Все + Разрешить + Сумма + Сумма слишком мала + Сумма слишком большая + Применено + Применить + Запросить еще раз + Подготовьтесь к сохранению кошелька! + Доступно: %s + Средний + Баланс + Бонус + Вызов + Данные вызова + Хеш вызова + Отмена + Вы уверены, что хотите отменить операцию? + К сожалению, у вас нет подходящего приложения для обработки этого запроса + Не удается открыть данную ссылку + Вы можете использовать не больше %s, поскольку вам нужно заплатить\n%s за комиссию за сети. + Сеть + Изменить + Изменить пароль + Автоматически продолжать в будущем + Выберите сеть + Очистить + Закрыть + Вы уверены, что хотите закрыть этот экран?\nИзменения не будут применены. + Облачный бэкап + Завершённые + Завершённые (%s) + Подтвердить + Подтверждение + Уверены ли вы? + Подтверждено + %d мс + подключение... + Пожалуйста, проверьте подключение к интернету и повторите попытку + Сбой подключения + Продолжить + Скопировано в буфер обмена + Копировать адрес + Скопировать данные вызова + Копировать хэш + Копировать идентификатор + Тип ключевой пары + Дата + %s и %s + Удалить + Депозитор + Подробности + Отключен + Отключить + Не закрывайте приложение! + Готово + Pezkuwi заранее симулирует транзакцию, чтобы предотвратить ошибки. Эта симуляция не удалась. Попробуйте позже или с большей суммой. Если проблема сохраняется, пожалуйста, свяжитесь со службой поддержки Pezkuwi Wallet в Настройках. + Ошибка симуляции транзакции + Редактировать + %s (и еще %s) + Выберите приложение для работы с почтой + Включить + Введите адрес… + Введите сумму... + Введите данные + Введите другую сумму + Введите пароль + Ошибка + Недостаточно токенов + Событие + EVM + EVM Адрес + Ваша учетная запись будет удалена из сети после операции, так как ваш баланс опустится ниже минимального + Операция удалит аккаунт + Истекла + Исследуй + Не удалось + Предполагаемая комиссия %s намного выше, чем комиссия по умолчанию (%s). Это может быть связано с временной перегрузкой сети. Вы можете обновить комиссию, чтобы дождаться более низкой суммы. + Комиссия сети слишком высокая + Комиссия: %s + Сортировать по: + Фильтры + Узнать больше + Забыли пароль? + + каждый %s день + каждые %s дня + каждые %s дней + каждый %s день + + ежедневно + ежедневно + Полная информация + Получить %s + Подарить + Понятно + Демократия + Шестнадцатеричная строка + Скрыть + + %d час + %d часа + %d часов + %d час + + Как это работает + Понятно + Информация + QR-код недействителен + Недействительный QR-код + Узнать больше + Узнать больше + Ledger + %s осталось + Управление кошельками + Максимальный + %s максимум + + %d минута + %d минуты + %d минут + %d минута + + %s аккаунт отсутствует + Изменить + Модуль + Имя + Сеть + Ethereum + Сеть %s не поддерживается + Polkadot + Сети + + Сеть + Сети + Сети + Сети + + Далее + Нет + Приложение для импорта файлов не найдено на устройстве. Пожалуйста, установите его и повторите попытку + На устройстве не найдено подходящего приложения для обработки данного действия + Нет изменений + Мы собираемся показать вашу мнемонику. Убедитесь, что никто не видит ваш экран и не делайте скриншотов - к ним могут получить доступ вредоносные программы + Нет + Не доступно + К сожалению, у вас недостаточно средств для оплаты сетевого сбора. + Недостаточный баланс + У вас недостаточно средств для оплаты сетевой комиссии в размере %s. Текущий баланс: %s + Не сейчас + Выкл + OK + Хорошо, назад + Вкл + На голосовании + Необязательные + Выберите опцию + Секретная фраза + Вставить + / год + %s / год + в год + %% + Для использования этого экрана необходимы запрашиваемые разрешения. Вы должны включить их в Настройках. + Отказано в разрешениях + Для использования этого экрана требуются запрашиваемые разрешения. + Необходимы разрешения + Цена + Политикой конфиденциальности + Продолжить + Прокси депозит + Отозвать доступ + Push-уведомления + Подробнее + Рекомендовано + Обновить + Отклонить + Удалить + Обязательные + Сброс + Повторить + Что-то пошло не так. Пожалуйста, попробуйте еще раз + Отменить + Сохранить + Отсканируйте QR-код + Поиск + Результаты поиска будут отображаться здесь + Результаты поиска: %d + сек + + %d секунда + %d секунды + %d секунд + %d секунд + + Последовательность для вывода + Посмотреть все + Выберите токен + Настройки + Поделиться + Поделиться данными вызова + Поделиться хэшем + Показать + Войти + Запрос на подпись + Подписант + Неверная подпись + Пропустить + Пропустить + Произошла ошибка при отправке некоторых транзакций. Вы хотите повторить попытку? + Не удалось отправить некоторые транзакции + Что-то пошло не так + Сортировка по + Статус + Substrate + Substrate адрес + Нажмите, чтобы открыть + Условиями использования + Тестнет + Оставшееся время + Заголовок + Открыть Настройки + Ваш баланс слишком мал + Всего + Общая комиссия + ID транзакции + Транзакция отправлена + Попробовать снова + Тип + Пожалуйста, попробуйте снова с другими входными данными. Если ошибка повторяется, то свяжитесь со службой поддержки. + Неизвестно + + %s неизвестная + %s неизвестных + %s неизвестных + %s неизвестных + + Неограниченно + Обновить + Использовать + Использовать максимум + Получатель должен быть допустимым адресом %s + Недопустимый получатель + Посмотреть + Ожидание + Кошелек + Предупреждение + Да + Ваш подарок + Сумма должна быть больше нуля + Введите пароль, созданный в процессе бэкапа + Введите текущий пароль для бэкапа + Подтверждение переведет токены с вашего счета + Выберите слова... + Неверная секретная фраза, пожалуйста, проверьте еще раз порядок слов + Референдум + Голосование + Трек + Узел уже был добавлен ранее. Пожалуйста, попробуйте другой узел. + Не удается установить соединение с узлом. Пожалуйста, попробуйте другой узел. + К сожалению, сеть не поддерживается. Пожалуйста, попробуйте одну из следующих: %s. + Подтвердите удаление %s. + Удалить сеть? + Пожалуйста, проверьте ваше подключение или повторите позднее + Добавленные + По умолчанию + Сети + Добавить соединение + Сканировать QR-код + Обнаружена проблема с бэкапом. Вы можете удалить текущий бэкап и создать новый. %s, перед тем как продолжить. + Убедитесь, что вы сохранили секретные фразы для всех кошельков + Найден пустой или поврежденный бэкап + В будущем без пароля к бэкапу невозможно будет восстановить ваши кошельки из Облачного Бэкапа.\n%s + Этот пароль не может быть восстановлен. + Запомните пароль Бэкапа + Подтвердите пароль + Пароль бэкапа + Буквы + Мин. 8 символов + Числа + Пароли совпадают + Пожалуйста, введите пароль для доступа к бэкапу. Пароль восстановлению не подлежит, обязательно запомните его! + Создание пароля для бэкапа + Введенный Chain ID не совпадает с сетью в RPC URL. + Неверный Chain ID + Приватные краудлоуны пока не поддерживаются + Приватный краудлоун + О краудлоунах + Напрямую + Подробнее о различных вкладах в Acala + Liquid вклад + Активные (%s) + Согласитесь с Правилами и Условиями + Бонус Pezkuwi Wallet (%s) + Реферальный код Astar должен являться корректным Polkadot адресом + Невозможно вложить введённую сумму, поскольку общая сумма сбора будет превышать максимальную сумму краудлоуна. Максимально допустимый вклад составляет %s . + Невозможно внести вклад в выбранный краудлоун, поскольку его максимальная сумма сбора уже достигнута. + Превышена максимальная сумма краудлоуна + Внести вклад в краудлоун + Тип вклада + Вы вложили: %s + Liquid + Parallel + Ваши взносы\n появятся здесь + Вернется через %s + Ожидает возвращения парачейном + %s (через %s) + Краудлоуны + Получите специальный бонус + Краудлоуны появятся здесь + Невозможно внести вклад в выбранный краудлоун, поскольку он уже завершён. + Краудлоун завершён + Введите реферальный код + Информация о краундлоуне + О краудлоуне %s + Cайт краудлоана %s + Период лизинга + Выберите парачейны для внесения своих %s. Вы получите внесенные токены обратно, и если парачейн выиграет слот вы получите награду после окончания аукциона + Вы должны добавить %s аккаунт в кошелёк для того, чтобы внести вклад + Применить + Если у вас нет реферального кода, вы можете применить код Pezkuwi, чтобы получить бонус за свой вклад + Вы не применили бонус + \n Краудлоун Moonbeam поддерживает только аккаунты с крипографией SR25519 или ED25519. Пожалуйста, используйте другой аккаунт для взноса\n + Используемый аккаунт не поддерживается для взноса + Добавьте аккаунт Moonbeam в кошелек, чтобы участвовать в краудлоуне Moonbeam + Отсутствует аккаунт Moonbeam + Этот краудлоун не поддерживается в вашем регионе + Ваш регион не поддерживается + %s назначение вознаграждений + Подтвердить согласие + Для участия вам необходимо предоставить своё согласие с Условиями использования и Политикой конфиденциальности он-чейн.\nДанная процедура выполняется единоразово и распространяется на все последующие взносы в краудлоун Moonbeam + Я ознакомился и согласен с Правилами и Условиями + Собрано + Реферальный код + Реферальный код недействителен. Пожалуйста, попробуйте другой. + %s\'s Условиями использования и Политикой конфиденциальности + Минимально допустимая сумма вклада составляет %s . + Сумма вклада слишком мала + Ваши %s токены будут возвращены обратно после срока лизинга парачейна. + Ваш вклад + Собрано: %s из %s + Введенный RPC URL присутствует в Pezkuwi как %s пользовательская сеть. Вы уверены, что хотите изменить его? + https://networkscan.io + URL block explorer (необязательно) + 012345 + Chain ID + TOKEN + Символ валюты + Имя сети + Добавить ноду + Добавить пользовательскую ноду для + Введите данные + Сохранить + Редактировать пользовательскую ноду для + Имя + Имя ноды + wss:// + URL ноды + RPC URL + DApps, к которым вы разрешили доступ, видят ваш адрес, когда вы их используете + ”%s” DApp будет удален из Авторизованных + Удалить из Авторизованных? + Авторизованные DApps + Каталог + Одобрите этот запрос, если вы доверяете приложению + Разрешить “%s” доступ к адресам вашего кошелька? + Одобрите этот запрос, если вы доверяете приложению.\nПроверьте детали транзакции. + DApp + DApps + %d DApps + Избранное + Избранное + Добавить в избранное + ”%s” DApp будет удален из Избранного + Удалить из Избранного? + Список DApps появится здесь + Добавить в избранное + Версия для ПК + Убрать из Избранного + Параметры страницы + Хорошо, уходим отсюда + Pezkuwi Wallet считает, что этот веб-сайт может поставить под угрозу безопасность ваших учетных записей и ваших токенов. + Обнаружен фишинг + Введите имя или URL + Неподдерживаемый блокчейн с генезис хэшем %s + Убедитесь, что операция правильная + Не удалось подписать запрошенную операцию + Все равно открыть + Вредоносные DApps могут вывести все ваши средства. Всегда проводите собственное исследование перед использованием DApp, предоставлением разрешения или отправкой средств.\n\nЕсли кто-то настаивает на посещении этого DApp, это, вероятно, мошенничество. Если у вас есть сомнения, пожалуйста, свяжитесь с поддержкой Pezkuwi Wallet: %s. + Внимание! DApp неизвестен + Сеть не найдена + Домен из ссылки %s не в списке разрешённых + Не указан тип управления + Тип управления не поддерживается + Неверный тип криптографии + Неверный формат пути деривации + Неверный формат мнемоники + Неверный формат ссылки + Введенный RPC URL присутствует в Pezkuwi как %s сеть. + Основной канал уведомлений + +%d + Поиск по адресу или имени + Недопустимый формат адреса. Убедитесь, что адрес принадлежит правильной сети + результаты поиска: %d + Прокси и Мультисиг счета автоматически обнаружены и организованы для вас. Управляйте в любое время в Настройках. + Список кошельков был обновлен + Голоса за все время + Делегат + Все аккаунты + Персон + Организации + Период выхода из делегации начнется после ее отмены + Ваши голоса будут автоматически применяться вместе с голосами ваших делегатов + Информация о делегате + Персона + Организация + Голоса делегаторов + Делегации + Изменить делегацию + Вы не можете делегировать голоса себе, пожалуйста, выберите другой адрес + Нельзя делегировать себе + Расскажите нам о себе, чтобы пользователи Pezkuwi узнали вас лучше + Вы уже стали делегатом? + Добавить описание + В %s треках + Голоса за %s + Ваши голоса через %s + Ваши голоса: %s через %s + Удалить голоса + Отменить делегацию + После истечения периода выхода из делегации не забудьте разблокировать токены + Голосам делегаторов + Делегациям + Голосам за %s + Треки + Выбрать все + Выберите хотя бы 1 трек... + Нет доступных треков для делегирования + Fellowship + Управление + Казна + Период выхода из делегации + Больше недействителен + Ваша делегация + Ваши делегации + Показывать + Удаление бэкапа... + Пароль для бэкапа был обновлен ранее. Чтобы продолжить использование Облачного Бэкапа, %s + введите новый пароль от бэкапа. + Пароль от бэкапа изменился + Вы не можете подписывать транзакции отключенных сетей. Включите %s в настройках и попробуйте снова + %s отключена + Вы уже делегируете полномочия этому аккаунту: %s + Делегация уже существует + (BTC/ETH совместимый) + ECDSA + ed25519 (альтернативный) + Edwards + Пароль бэкапа + Введите данные вызова + Недостаточно токенов для оплаты комиссии + Контракт + Вызов контракта + Функция + Восстановить кошельки + %s Все ваши кошельки будут надежно сохранены на Google Диске. + Хотите восстановить свои кошельки? + Обнаружен существующий Бэкап + Скачать JSON для восстановления + Подтверждение пароля + Пароли не совпадают + Новый пароль + Сеть: %s\nМнемоника: %s\nПуть деривации: %s + Сеть: %s\nМнемоника: %s + Подождите, пока будет рассчитана комиссия + Расчет комиссии в процессе + Управление картой + Продать токен %s + Добавить делегацию для %s стекинга + Детали обмена + Макс: + Вы платите + Вы получаете + Выберите токен + Пополнить карту с помощью %s + Пожалуйста, обратитесь в support@pezkuwichain.io. Укажите адрес электронной почты, который вы использовали для выпуска карты. + Связаться с поддержкой + Забрано + Создано: %s + Введите сумму + Минимальный подарок — %s + Возвращено + Выберите токен для подарка + Сетевая комиссия за запрос + Создать подарок + Отправляйте подарки быстро, легко и безопасно в Pezkuwi + Делитесь крипто-подарками с кем угодно, где угодно + Созданные вами подарки + Выберите сеть для подарка %s + Введите сумму вашего подарка + %s в виде ссылки и пригласите кого-нибудь в Pezkuwi + Поделитесь подарком напрямую + %s, и вы можете вернуть невостребованные подарки в любое время с этого устройства + Подарок доступен мгновенно + Канал уведомлений демократии + + Необходимо выбрать хотя бы %d трек + Необходимо выбрать хотя бы %d трека + Необходимо выбрать хотя бы %d треков + + + Разблокировать + Выполнение этого свапа приведет к значительному проскальзыванию и финансовым потерям. Рассмотрите возможность уменьшения размера вашей сделки или разделения её на несколько транзакций. + Обнаружено существенное влияние на цену (%s) + История + Почта + Официальное имя + Имя в Element + Персонализация + Сайт + Выбранный JSON файл был создан для другой сети. + Пожалуйста, удостоверьтесь, что данные соответствуют json формату. + JSON для восстановления недействителен + Пожалуйста, проверьте корректность пароля и попробуйте снова. + Ошибка дешифрования + Вставьте json + Неподдерживаемый тип шиврования + Не поддерживается импорт аккаунта с Substrate секретом в сеть с Ethereum шифрованием + Не поддерживается импорт аккаунта с Ethereum секретом в сеть с Substrate шифрованием + Ваша мнемоника недействительна + Пожалуйста, убедитесь, что введенные данные содержат 64 hex символа. + Сид неверный + К сожалению, Бэкап с вашими кошельками не найден. + Бэкап не найден + Восстановление кошельков из Google Диска + Используйте свою фразу длинной 12, 15, 18, 21 или 24 слова + Выберите как вы бы хотели импортировать кошелёк + Watch-only + Интегрируйте все функции сети, которую вы создаете, в Pezkuwi Wallet, чтобы сделать ее доступной для всех. + Интегрировать вашу сеть + Создаете для Polkadot? + Предоставленные вами данные вызова неверны или имеют неверный формат. Пожалуйста, убедитесь, что они правильные, и попробуйте снова. + Эти данные вызова для другой операции с хэшом вызова %s + Некорректные данные вызова + Адрес прокси должен быть действительным адресом %s + Неверный адрес прокси + Введенный символ валюты (%1$s) не совпадает с сетью (%2$s). Вы хотите использовать правильный символ валюты? + Неверный символ валюты + Не удается декодировать QR + QR код + Из галереи + Экспортировать JSON-файл + Язык + Ledger не поддерживает %s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + Операция была отменена устройством. Удостоверьтесь, что Ledger разблокирован. + Операция отменена + Откройте %s приложение на вашем Ledger устройстве + %s приложение не запущено + Операция завершилась ошибкой на устройстве. Пожалуйста, попробуйте позже. + Ошибка Ledger + Удерживайте кнопку подтверждения на вашем %s для одобрения транзакции + Загрузить больше аккаунтов + Проверьте и Подтвердите + Аккаунт %s + Аккаунт не найден + Ваше устройство Ledger использует устаревшее универсальное приложение, которое не поддерживает EVM адреса. Обновите его через Ledger Live. + Обновите универсальное приложение Ledger + Нажмите обе кнопки на Вашем %s для подтверждения транзакции + Пожалуйста, обновите %s приложение, используя Ledger Live + Метаданные устарели + Ledger не поддерживает подтверждение произвольных сообщений — только транзакции + Пожалуйста, удостоверьтесь, что Вы выбрали верное устройство Ledger + В целях безопасности сгенерированные операции действительны только в течение %s. Пожалуйста, попробуйте еще раз и подтвердите транзакцию с помощью Ledger + Срок действия транзакции истек + Транзакция валидна %s + Ledger не поддерживает данную транзакцию. + Транзакция не поддерживается + Нажмите обе кнопки на вашем %s для одобрения адреса + Нажмите кнопку подтверждения на вашем %s для одобрения адреса + Нажмите обе кнопки на вашем %s, чтобы подтвердить адреса + Нажмите кнопку подтверждения на вашем %s, чтобы подтвердить адреса + Этот кошелёк добавлен из Ledger устройства. Pezkuwi Wallet поможет создать любую операцию, и вам будет необходимо подписать ее с помощью Ledger устройства + Выберите аккаунт для добавления в кошелёк + Загрузка информации о сети... + Загрузка деталей транзакции… + В процессе поиска вашего бэкапа... + Транзакция на выплату отправлена + Добавить + Управление бэкапом + Удалить сеть + Редактировать сеть + Управление добавленной сетью + Вы не сможете видеть свои балансы токенов в этой сети на экране активов + Удалить сеть? + Удалить ноду + Редактировать ноду + Управление добавленной нодой + Нода \\"%s\\" будет удалена + Удалить ноду? + Добавленный ключ + Ключ по умолчанию + Не делитесь этой информацией с кем-либо. Если вы это сделаете, вы навсегда и безвозвратно потеряете все свои активы. + Аккаунты с кастомным секретом + Аккаунты по умолчанию + %s, +%d других + Аккаунты с общим секретом + Выберите ключ для бэкапа + Выберите кошелёк для бэкапа + Пожалуйста, внимательно прочитайте следующее перед просмотром своего бэкапа + Не сообщайте свою секретную фразу! + Лучшая цена с комиссиями до 3.95%% + Убедитесь, что никто не видит ваш экран,\nи не делайте скриншотов + Пожалуйста %s ни с кем + не делитесь + Пожалуйста, попробуйте другую. + Ваша мнемоника недействительна, пожалуйста, проверьте еще раз порядок слов + Вы не можете выбрать более %d кошельков. + + Выберите хотя бы %d кошелек + Выберите хотя бы %d кошелька + Выберите хотя бы %d кошельков + Выберите хотя бы %d кошельков + + Эта транзакция уже была отклонена или выполнена. + Невозможно выполнить эту транзакцию + %s уже инициировал ту же операцию, и в настоящий момент она ожидает подписи других подписантов. + Операция уже существует + Чтобы управлять своей дебетовой картой, пожалуйста, переключитесь на другой тип кошелька. + Дебетовая карта не поддерживается для мультиподписей + Депозит мультиподписи + Депозит остаётся заблокированным на счету депозита до тех пор, пока операция мультиподписи не будет выполнена или отклонена. + Убедитесь, что операция корректна + Чтобы создать или управлять подарками, пожалуйста, переключитесь на другой тип кошелька. + Подарки не поддерживаются мультисиг кошельками + Подписано и выполнено %s. + ✅ Мультисиг транзакция выполнена + %s на %s. + ✍🏻 Требуется ваша подпись + Инициатор: %s. + Кошелек: %s + Подписано %s + Собрано подписей: %d из %d. + От имени %s. + Отклонено %s. + ❌ Мультисиг транзакция отклонена + От имени + %s: %s + Одобрить + Одобрить и выполнить + Введите данные вызова, чтобы просмотреть детали + Транзакция уже была выполнена или отклонена. + Подписание завершено + Отклонить + Подписанты (%d из %d) + Пакет Все (откатывается при ошибке) + Пакет (выполняется до ошибки) + Принудительный Пакет (игнорирует ошибки) + Создано вами + Пока нет транзакций.\nЗапросы на подписание будут отображаться здесь + Подписание (%s из %s) + Подписано вами + Неизвестная операция + Транзакции для подписи + От имени: + Подписано участником + Мультисиг транзакция выполнена + Требуется ваша подпись + Мультисиг транзакция отклонена + Чтобы продать криптовалюту за фиат, пожалуйста, переключитесь на другой тип кошелька. + Продажа не поддерживается для мультиподписей + Подписант: + У %s недостаточно средств для размещения депозита мультиподписи в размере %s. Вам необходимо добавить ещё %s на ваш баланс + У %s недостаточно средств для оплаты сетевого сбора в размере %s и размещения депозита мультиподписи в размере %s. Вам необходимо добавить ещё %s на ваш баланс + %s необходимо как минимум %s для оплаты этой комиссии на транзакцию и сохранения баланса выше минимального сетевого уровня. Текущий баланс: %s + У %s недостаточно средств для оплаты сетевого сбора в размере %s. Вам необходимо добавить ещё %s на ваш баланс + Мультиподпись кошельки не поддерживают подписание произвольных сообщений — только транзакции + Транзакция будет отклонена. Депозит мультиподписи будет возвращен %s. + Транзакция с мультиподписью будет инициирована %s. Инициатор оплачивает сетевую комиссию и резервирует депозит под мультиподпись, который будет освобожден после выполнения транзакции. + Мультисиг транзакция + Другие подписанты теперь могут подтвердить транзакцию.\nВы можете отслеживать её статус в %s. + Транзакции для подписи + Мультисиг транзакция создана + Просмотреть детали + Транзакция без начальной информации в цепочке (данные вызова) была отклонена + %s на %s.\nБольше не требуется никаких действий с вашей стороны. + Мультисиг транзакция выполнена + %1$s → %2$s + %s на %s.\nОтклонено: %s.\nБольше не требуется никаких действий с вашей стороны. + Мультисиг транзакция отклонена + Транзакции с этого кошелька требуют одобрения от нескольких подписантов. Ваш аккаунт — один из подписантов: + Другие подписанты: + Порог %d из %d + Канал уведомлений о мультисиг транзакциях + Включить в настройках + Получайте уведомления о запросах на подпись, новых подписях и завершенных транзакциях — чтобы всегда контролировать процесс. Управляйте в любое время в Настройках. + Пуш-уведомления о мультисиг транзакциях здесь! + Эта нода уже существует + Комиссия сети + Адрес ноды + Информация ноды + Добавлено + пользовательские ноды + ноды по умолчанию + По умолчанию + Подключение… + Коллекция + Создатель + %s за %s + %s единиц из %s + #%s Издание из %s + Неограниченная серия + Владелец + Без цены + Ваши NFT + Вы не добавили и не выбрали ни одного мультисиг кошелька в Pezkuwi. Добавьте и выберите хотя бы один, чтобы получать пуш-уведомления о мультисиг транзакциях. + Нет мультисиг кошельков + Введенный вами URL уже существует как \"%s\" нода. + Этот Узел уже существует + Введенный вами URL Узла либо не отвечает, либо имеет неверный формат. Формат URL должен начинаться с \"wss://\". + Ошибка ноды + Введенный вами URL не соответствует ноде для %1$s.\nПожалуйста, введите URL валидной ноды %1$s. + Неверная сеть + Забрать награды + Ваши токены будут добавлены обратно в стейкинг + Прямой + Информация о стейкинге в пулах + Ваши награды (%s) также будут забраны и добавлены к вашему доступному балансу. + Пул + Невозможно выполнить указанную операцию, так как пул находится в состоянии закрытия. В ближайшее время он будет закрыт. + Пул закрывается + В настоящее время в очереди на вывод средств для вашего пула нет свободных мест. Пожалуйста, повторите попытку через %s + Слишком много участников выводят средства из вашего пула + Ваш пул + Ваш пул (#%s) + Создать аккаунт + Создать новый кошелёк + Политикой конфиденциальности + Импортировать аккаунт + У меня уже есть кошелёк + Продолжая, вы соглашаетесь с нашими\n%1$s и %2$s + Условиями использования + Обмен + Один из ваших коллаторов не генерирует награды + Один из ваших коллаторов не выбран в текущем раунде + Ваш период разблокировки %s истек. Не забудьте забрать разблокированные токены + Невозможно застейкать с выбранным коллатором + Смена коллатора + Невозможно добавить стейк в выбранного коллатора + Управление коллаторами + Выбранный коллатор намерен прекратить участие в стейкинге. + Вы не можете добавить стейк в коллатора, для которого вы разблокируете все токены. + Ваш стейк будет меньше минимального (%s) для этого коллатора. + Оставшийся стейк опустится ниже минимального значения сети (%s) и также будет добавлен к сумме вывода + Вы не авторизованы. Попробуйте еще раз, пожалуйста. + Использовать биометрию для авторизации + Pezkuwi Wallet использует биометрическую аутентификацию для ограничения доступа неавторизованных пользователей к приложению. + Биометрия + PIN-код успешно изменен + Подтвердите свой PIN-код + Новый PIN-код + Введите PIN-код + Установите свой PIN-код + Вы не можете присоединиться к пулу, поскольку в нем достигнуто максимальное количество участников. + Пул заполнен + Вы не можете присоединиться к не открытому пулу. Пожалуйста, свяжитесь с владельцем пула. + Пул не открыт + Вы больше не можете использовать одновременно стейкинг напрямую и в пуле с одного аккаунта. Для управления стейкингом в пуле вам сначала нужно вывести ваши токены из стейкинга напрямую. + Действия в пуле недоступны + Популярные + Добавить сеть вручную + Загрузка списка сетей... + Поиск по имени сети + Добавить сеть + %s в %s + + Все + + %s (%s) + Цена %s + + + Все время + Прошлый месяц + Сегодня + Прошлая неделя + Прошлый год + Аккаунты + Кошельки + Язык + Изменить PIN-код + Вход со скрытым балансом + Подтверждение с PIN + Безопасный режим + Настройки + Для управления вашей Дебетовой Картой, пожалуйста, переключитесь на другой кошелек с сетью Polkadot. + Дебетовая карта не поддерживается для этого Проксируемого кошелька + Этот аккаунт предоставил доступ для выполнения транзакций следующему аккаунту: + Операции стейкинга + Делегированный аккаунт %s не имеет достаточного баланса для оплаты сетевой комиссии %s. Доступный баланс для оплаты комиссии: %s + Проксированные кошельки не поддерживают подпись произвольных сообщений - только транзакций + %1$s не делегировал прав %2$s + %1$s делегировал %2$s только для %3$s + Упс! Недостаточно разрешений + Транзакция будет инициирована %s как делегированным аккаунтом. Сетевая комиссия будет оплачена делегированным аккаунтом. + Это делегирующий (проксированный) аккаунт + %s + Голоса делегата + Новые референдумы + Обновление референдума + %s Референдум #%s уже доступен! + 🗳️ Новый референдум + Загрузите Pezkuwi Wallet v%s, чтобы получить новые функции! + Доступно новое обновление Pezkuwi Wallet! + %s Референдум #%s завершился и был одобрен 🎉 + ✅ Референдум одобрен! + %s Референдум #%s изменил статус с %s на %s + %s Референдум #%s завершился и был отклонен! + ❌ Референдум отклонен! + 🗳️ Статус референдума изменен + %s Референдум #%s изменил статус на %s + Объявления Pezkuwi + Балансы + Включить уведомления + Транзакции Multisig + Вы не будете получать уведомления о деятельности кошелька (балансы, стейкинг), поскольку вы не выбрали ни один кошелек. + Прочее + Полученные токены + Отправленные токены + Награды стейкинга + Кошельки + ⭐️ Новая награда %s + Получено %s от %s стейкинга + ⭐️ Новая награда + Pezkuwi Wallet • сейчас + Получено +0.6068 KSM ($20.35) от Kusama стейкинга + Получено %s в сети %s + ⬇️ Получено + ⬇️ Получено %s + Отправлено %s на %s в сети %s + 💸 Отправлено + 💸 Отправлено %s + Выберите до %d кошельков, чтобы получать уведомления об активности кошелька + Включить push-уведомления + Получайте уведомления об операциях кошелька, обновлениях демократии, наград стейкинга и безопасности, чтобы всегда быть в куре событий + Включая push-уведомления, вы соглашаетесь с нашими %s и %s + Повторите попытку позже, открыв настройки уведомлений на вкладке «Настройки» + Не пропустите ничего! + Выберите сеть для получения %s + Скопировать адрес + Если вы вернете этот подарок, общая ссылка будет отключена, а токены будут возвращены в ваш кошелек.\nХотите продолжить? + Вернуть подарок %s? + Вы успешно забрали свой подарок + Вставьте json строку или загрузите файл… + Загрузите файл + JSON для восстановления + Мнемоническая фраза + Сид + Тип источника + Все референдумы + Показывать: + Не проголосованные + Проголосованные + Не найдены референдумы с указанными фильтрами + Информация о референдумах появится здесь, когда они начнутся + Не найдены референдумы\nс таким названием или ID + Поиск по заголовку или ID + %d референдума + Свайпайте, чтобы проголосовать за референдумы с резюме от AI. Быстро и легко! + Референдумы + Референдум не найден + Голосование с воздержанием возможно только с убежденностью 0.1x. Проголосовать с убежденностью 0.1x? + Обновление убежденности + Голоса \\"воздержался\\" + За: %s + Используйте Pezkuwi DApp браузер + Только автор референдума может редактировать это описание и заголовок. Если у вас есть доступ к аккаунту автора, посетите Polkassembly и заполните информацию о вашем референдуме + Запрашиваемая сумма + Временная шкала + Ваш голос: + Кривая одобрения + Получатель + Депозит + Электорат + Слишком большая длина для просмотра + Параметры JSON + Автор + Кривая поддержки + Явка + Порог голосования + Позиция: %s из %s + Вы должны добавить %s аккаунт в кошелёк для того, чтобы голосовать + Референдум %s + Против: %s + Голоса \"против\" + %s голосов через %s + Голоса \"за\" + Стейкинг + Одобрен + Отменен + Решение + Начнёт решаться через %s + Выполнен + В очереди + В очереди (%s из %s) + Удалён + Не проходит + Проходит + Подготовка + Отклонён + Одобрение через %s + Выполнение через %s + Тайм-аут через %s + Отклонение через %s + Тайм-аут + Ожидание депозита + Порог: %s из %s + Результат: Одобрен + Отменён + Создан + Голосование: Решение + Выполнен + Голосование: В очереди + Удалён + Голосование: Не проходит + Голосование: Проходит + Голосование: Подготовка + Результат: Отклонён + Тайм-аут + Голосование: Ожидание депозита + Для принятия: %s + Краудлоуны + Казна: большие траты + Казна: большие чаевые + Fellowship: управление + Управление: регистратор + Управление: аренда + Казна: средние траты + Управление: отмена + Управление: удаление + Основная повестка + Казна: мелкие траты + Казна: мелкие чаевые + Казна: любое + остаются заблокированными в %s + Можно разблокировать + Воздержался + За + Уже заблокировано: %s + Уже в демократии: %s + Заблокировано в демократии + Период блокировки + Против + Умножьте количество голосов, увеличив период блокировки + Проголосовать за %s + По истечении периода блокировки не забудьте разблокировать свои токены + Голоса за все время + %s голосов + %s × %sx + Список голосовавших появится здесь + %s голосов + Fellowship: белый список + Ваш голос: %s голосов + Референдум завершен, голосовать больше нельзя + Референдум завершен + Вы делегируете голоса в треке данного референдума. Пожалуйста, либо попросите делегата проголосовать, либо удалите делегирование, чтобы иметь возможность голосовать напрямую. + Вы уже делегируете голоса + Вы набрали максимальное количество голосов за трек: %s + Достигнуто максимальное количество голосов + У вас недостаточно токенов для голосования. Доступно для голосования: %s. + Отзываемый типа доступа + Отозвать у + Удалить голоса + + Вы ранее голосовали в референдумах в %d треке. Для того, чтобы сделать этот трек доступным для делегирования вам необходимо удалить голоса. + + Вы ранее голосовали в референдумах в %d треках. Для того, чтобы сделать эти треки доступным для делегирования вам необходимо удалить голоса. + + + Удалить историю ваших голосов? + %s Он принадлежит исключительно вам, хранится в надежном месте и недоступен для других. Без пароля для бекапа восстановление кошельков из Google Диска невозможно. В случае потери удалите текущий бэкап, чтобы создать новый с новым паролем. %s + К сожалению, ваш пароль не может быть восстановлен. + Альтернативно, вы можете использовать Секретную фразу для восставновления кошелька. + Потеряли пароль? + Пароль для бэкапа был изменён ранее. Чтобы продолжить использование бэкапа, пожалуйста введите новый пароль. + Пожалуйста, введите пароль, установленный во время бэкапа + Введите пароль от бэкапа + Не удалось обновить информацию о среде выполнения блокчейна. Некоторые функции могут не работать. + Ошибка обновления среды выполнения + Контакты + мои аккаунты + Пул с введенным именем или\nномером не найден. Убедитесь, что вы\nввели правильные данные + Адрес аккаунта или имя аккаунта + Здесь появятся результаты поиска + Результат поиска + активные пулы: %d + участники + Выбрать пул + Выберите треки для делегирования + Доступные треки + Пожалуйста, выберите треки, в которых вы бы хотели делегировать ваши голоса. + Выберите треки для изменения делегации + Выберите треки для отмены делегации + Недоступные треки + Отправить подарок на + Включите Bluetooth и предоставьте разрешения + Pezkuwi нуждается в включении местоположения, чтобы иметь возможность выполнять сканирование Bluetooth для поиска вашего устройства Ledger + Пожалуйста, включите геолокацию в настройках устройства + Выберите сеть + Выберите токен для голосования + Выберите треки для + %d из %d + Выберите сеть для продажи %s + Продажа инициирована! Пожалуйста, подождите до 60 минут. Вы можете отслеживать статус по email. + Ни один из наших провайдеров в настоящее время не поддерживает продажу этого токена. Пожалуйста, выберите другой токен, другую сеть или попробуйте позже. + Этот токен не поддерживается функцией продажи + Адрес или w3n + Выберите сеть для отправки %s + Получатель является системным аккаунтом. Этот аккаунт не контролируется какой-либо компанией или частным лицом. \nВы уверены, что все еще хотите выполнить данный перевод? + Токены будут потеряны + Выдать полномочия аккаунту + Пожалуйста, убедитесь, что биометрия включена в настройках + Биометрия отключена в настройках + Сообщество + Получите поддержку по Email + Основные + Каждая операция подписи на кошельках с парой ключей (созданной в кошельке nova или импортированной) должна требовать проверки PIN-кода перед созданием подписи + Запрашивать аутентификацию для подписи операций + Предпочтения + Push-уведомления доступны только для версии Pezkuwi Wallet, загруженной из Google Play. + Push-уведомления доступны только для устройств с сервисами Google. + Запись экрана и скриншоты будут недоступны. Свернутое приложение не будет отображать содержимое + Безопасный режим + Безопасность + Поддержка и обратная связь + Twitter + Вики и справочный центр + Youtube + Убежденность будет установлена на 0.1x при Воздержании + Невозможно стейкать напрямую и в пуле одновременно + Уже застейкано + Расширенное управление стейкингом + Тип стейкинга не может быть изменен + У вас уже есть стейкинг напрямую + Стейкинг напрямую + Вы указали сумму меньше минимальной в %s, необходимую для получения вознаграждений используя %s. Вам следует подумать об использовании стейкинга в пуле для получения вознаграждений. + Переиспользуйте токены в Голосованиях + Минимальный стейк: %s + Вознаграждения: выплачиваются автоматически + Вознаграждения: нужно забирать вручную + Вы уже стейкаете в пуле + Стейкинг в пуле + Ваш стейк меньше минимального для получения вознаграждений + Неподдерживаемый тип стейкинга + Поделиться ссылкой на подарок + Вернуть + Привет! У вас есть %s подарок, который ждет вас!\n\nУстановите приложение Pezkuwi Wallet, настройте кошелек и заберите его по этой специальной ссылке:\n%s + Подарок подготовлен.\nПоделитесь им сейчас! + sr25519 (рекомендованный) + Schnorrkel + Выбранный аккаунт уже используется в качестве контроллера. + Делегировать полномочия (прокси) + Ваши делегации + Активные делегаторы + Чтобы выполнить это действие, добавьте контроллер аккаунт %s в приложение + Добавить делегацию + Ваш стейк меньше минимума %s.\nСтейк меньше минимума уменьшает шансы получить вознаграждение + Застейкайте ещё токенов + Смените своих валидаторов. + Сейчас всё хорошо. Здесь будут появляться предупреждения. + Устаревшая позиция в очереди назначения стейка валидатору может приостановить получение вознаграждений + Улучшения стейкинга + Заберите токены после вывода + Пожалуйста, дождитесь начала следующей эры. + Предупреждения + Уже контроллер + У вас уже есть стейкинг в %s + Баланс стейкинга + Баланс + Застейкать ещё + Вы не номинируете и не валидируете + Сменить контроллер + Сменить валидаторов + %s из %s + Выбранные валидаторы + Контроллер + Контроллер аккаунт + Мы обнаружили, что на этом аккаунте нет свободных токенов. Вы уверены, что хотите сменить контроллер? + Контроллер аккаунт может вывести из стейка, забрать, вернуть в стейк, сменить назначение вознаграждений и валидаторов. + Контроллер аккаунт может: вывести, забрать, вернуть в стейк, сменить валидаторов и установить назначение вознаграждений + Контроллер изменен + Этот валидатор заблокирован и сейчас его невозможно выбрать. Пожалуйста, попробуйте снова в следующую эру. + Очистить фильтры + Отменить выбор + Дополнить рекомендованными + Валидаторов: %d из %d + Выбрать валидаторов (макс. %d) + Показать выбранных: %d (макс. %d) + Выберите валидаторов + Примерная награда (%% APR) + Примерное вознаграждение (%% APY) + Обновить свой список + Стейкинг через DApp браузер Pezkuwi + Больше вариантов стейкинга + Стейкайте и получайте награды + %1$s Staking доступен на %2$s начиная с %3$s + Примерный доход + эра %s + Расчёт доходности + Расчёт доходности %s + Стейк валидатора + Стейк валидатора (%s) + Во время вывода токены не приносят наград. + Токены не приносят наград во время вывода + После вывода токенов из стейкинга не забудьте их забрать. + Не забудьте забрать токены после вывода + Ваши вознаграждения увеличатся со следующей эры. + Вознаграждения увеличатся начиная со следующей эры + Застейканые токены приносят награду каждую эру (%s). + Токены в стейке приносят награду каждую эру (%s) + Pezkuwi wallet изменит назначение вознаграждений\nна ваш аккаунт, чтобы избежать остатка в стейкинге. + Для вывода токенов из стейкинга потребуется %s. + Период вывода токенов из стейкинга занимает %s + Информация о стейкинге + Активные номинаторы + + %d день + %d дня + %d дней + %d дней + + Минимальный стейк + Сеть %s + Застейкано + Пожалуйста, переключите свой кошелек на %s , чтобы настроить прокси. + Выберите стэш-аккаунт для настройки прокси + Управление + %s (макс. %s ) + Достигнуто максимальное количество номинаторов. Пожалуйста, попробуйте позже + Невозможно начать стейкинг + Мин. стейк + Вы должны добавить %s аккаунт в кошелек для того, чтобы начать стейкинг + Eжемесячно + Добавьте учетную запись контроллера в устройство. + Нет доступа к учетной записи контроллера + Номинировано: + %s вознаграждены + Один из ваших валидаторов был выбран сетью. + Активный статус + Неактивный статус + Ваш стейк меньше минимального стейка для получения вознаграждений. + Ни один из ваших валидаторов не был избран сетью. + Ваш стейкинг начнётся со следующий эры. + Неактивен + Ожидание следующей Эры + ожидание следующей эры (%s) + У вас недостаточный баланс для прокси-депозита %s. Доступный баланс: %s + Канал уведомлений стейкинга + Коллатор + Минимальный стейк коллатора выше, чем ваша делегация. Вы не будете получать вознаграждения от этого коллатора. + Информация о коллаторе + Собственный стейк коллатора + Коллаторы: %s + Один или несколько ваших коллаторов были избраны сетью. + Делегаторы + Вы достигли максимального количества делегаций - %d коллаторов + Вы не можете выбрать нового коллатора + Новый коллатор + ожидание следующего раунда (%s) + У вас есть запросы на вывод из стейка для всех ваших коллаторов. + Нет коллаторов, доступных для вывода токенов из стейка + Возвращенные токены будут учитываться со следующего раунда + Застейканые токены приносят вознаграждение раз в раунд (%s) + Выберите коллатора + Выберите коллатора... + Вы получите увеличенное вознаграждение, начиная со следующего раунда + Вы не получите награды за этот раунд, так как ни одна из ваших делегаций не активна. + Вы уже выводите токены из стейка у этого коллатора. На одного коллатора можно иметь только один запрос на вывод. + Вы не можете вывести токены из стейка у выбранного коллатора + Ваш стейк должен быть больше минимального (%s) для этого коллатора. + Вы не будете получать награды + Некоторые из ваших коллаторов либо не выбраны сетью, либо имеют более высокий минимальный стейк, чем ваш. Вы не получите вознаграждение в этом раунде, стейкая с ними. + Ваши коллаторы + Ваш стейк назначен следующим коллаторам + Активные коллаторы, не производящие вам наград + Коллаторы, не имеющие достаточного стейка для избрания сетью + Коллаторы, стейкинг в которых начнется в следующем раунде + Ожидающие (%s) + Выплата + Срок выплаты истёк + + %d день + %d дня + %d дней + %d дней + + Вы можете выплатить их самостоятельно, когда они близки к истечению, но вы заплатите комиссию + Вознаграждения выплачиваются каждые 2–3 дня валидаторами + Все + Все время + Конечная дата всегда текущая + Другой период + %dД + Выберите конечную дату + Конец + Последние 6 месяцев (6М) + + Последние 30 дней (30Д) + 30Д + Последние 3 месяца (3М) + + Выберите дату + Выберите начальную дату + Начало + Показывать вознаграждения за + Последние 7 дней (7Д) + + Последний год (1Г) + + Ваш доступный баланс составляет %s, вам необходимо оставить %s в качестве минимального баланса и оплатить комиссию сети в размере %s. Таким образом, вы можете застейкать не более %s. + Делегированные полномочия (прокси) + Текущий слот очереди + Новый слот очереди + Вернуть в стейк + Всё, что выводится из стейка + Возвращенные токены будут учитываться со следующей эры + Выбрать сумму + Сумма, которую вы хотите вернуть в стейк больше суммы вывода + Последний вывод из стейка + Самые доходные + Не превышен лимит номинаторов + Идентифицирован в сети + Не был заслешан + Лимит 2 валидатора на персонализацию + хотя бы с одним контактом + Рекомендованные валидаторы + Валидаторы + Вознаграждения (APY) + Забрать + Можно забрать: %s + Вознаграждение + Место назначения вознаграждения + Увеличивать баланс + Эра + Детали награды + Валидатор + Доходность с реинвестированием + Заработок без реинвестирования + Прекрасно! Все вознаграждения выплачены. + Замечательно! Все вознаграждения выплачены + Выплатить все (%s) + Ожидаемые вознаграждения + Невыплаченные награды + %s награды + О наградах + Вознаграждение (APY) + Назначение наград + Выбрать самому + Выберите аккаунт для выплат + Выбрать рекомендацию + выбрано %d (макс. %d) + Валидаторы (%d) + Изменить контроллер на стэш + Используйте прокси для делегирования операций стейкинга другому аккаунту + Контроллер Аккаунты Устаревают + Выберите другой аккаунт в качестве контроллера, чтобы делегировать ему операции по управлению стейкингом + Улучшите безопасность стейкинга + Установить валидаторов + Валидаторы не выбраны + Выберите валидаторов для начала стейкинга + Рекомендуемый минимальный стейк для постоянного получения вознаграждений составляет %s. + Вы не можете застейкать меньше минимального значения сети (%s) + Минимальный стейк должен быть больше чем %s + Увеличения стейка + Увеличивать стейк + Использовать вознаграждения для + Использовать вознаграждения чтобы + Аккаунт для выплат + Слэш + Застейкать %s + Застейкать всё + Время стейкинга + Тип стейкинга + Вы должны доверять своим валидаторам, чтобы они действовали грамотно и честно. Принятие решения исключительно на основе их текущей прибыльности может привести к снижению прибыли или даже потере средств. + Тщательно выбирайте своих валидаторов, чтобы они действовали профессионально и честно. Принятие решения исключительно на основе прибыльности может привести к уменьшению наград или даже потере стейка + Стейкинг с выбранными валидаторами + Pezkuwi Wallet выберет лучших валидаторов по критериям безопасности и прибыльности + Стекинг с рекомендованными валидаторами + Начать стейкинг + Стэш + С помощью стэш аккаунта можно застейкать больше и установить контроллер аккаунт + Стэш аккаунт может: застейкать больше и установить контроллер аккаунт + Стэш аккаунт %s недоступен для обновления настроек стейкинга + Номинатор получает пассивный доход за удержание своих токенов для безопасности сети. Для этого номинатор должен выбрать валидаторов, с которыми он будет стейкать. Номинатору нужно осторожно подходить к выбору валидаторов. Если выбранный валидатор не будет поддерживать свою ноду в рабочем состоянии или будет пытаться атаковать сеть, то им обоим будет назначен штраф в виде слэша определенной части стейкинга в зависимости от масштаба причиненного ущерба. + Pezkuwi Wallet помогает номинаторам в выборе валидаторов. Приложение получает данные из сети и составляет список доступных для номинирования валидаторов, имеющих максимальный доход, персонализацию и контакты для связи, а также не имеющих слэшей. Pezkuwi Wallet также заботится о децентрализации, поэтому если один человек или компания запустили несколько валидирующих нод, то только 2 из них будут присутствовать в списке рекомендованных. + Кто такой номинатор? + Вознаграждения за стейкинг доступны для выплаты в конце каждой эры (6 часов в Kusama и 24 часа в Polkadot). Сеть хранит ожидаемые вознаграждения в течении 84 эр и в большинстве случаев валидаторы сами выплачивают всем награды. Однако, валидаторы могут забыть это сделать или с ними может что-то случиться, поэтому номинаторы могут выплатить свои награды самостоятельно. + Несмотря на то, что обычно вознаграждения выплачиваются валидаторами, Pezkuwi Wallet помогает узнать о вознаграждениях, срок выплаты которых близок к истечению, с помощью предупреждений. Предупреждения об этом и других важных событиях появятся на главном экране стейкинга. + Получение наград + Стейкинг — это один из способов пассивного дохода с помощью удержания токенов в сети. Вознаграждения за стейкинг выпускаются каждую эру (6 часов в Kusama и 24 часа в Polkadot). Вы можете стейкать неограниченное количество времени, и для вывода из стейкинга вам нужно будет подождать период вывода, после которого вы сможете забрать свои токены. + Стейкинг играет ключевую роль в безопасности и надежности сети. Любой может запустить собственную ноду, но только тот, кто наберёт необходимое количество токенов в стейкинге, будет избран сетью для создания новых блоков и получения наград. Часто валидаторы не имеют необходимого количества токенов, поэтому номинаторы помогают им с помощью своих токенов. + Что такое стейкинг? + Валидатор обеспечивает работу ноды блокчейна 24/7 и обязан иметь необходимое количество стейка (общий стейк самого валидатора и его номинаторов), чтобы быть избранным сетью. Валидаторы должны поддерживать производительность и надежность своих нод, за что они получают вознаграждения. Валидатор — это полноценная работа, существуют профильные компании, которые специализируются на валидировании в блокчейн сетях. + Любой может стать валидатором и запустить ноду блокчейна, однако это требует определённых технических знаний и ответственности. Сети Polkadot и Kusama запустили программу Thousand Validators Programme (Программа Тысячи Валидаторов), чтобы помочь начинающим. Более того, сеть всегда будет стремиться вознаграждать тех валидаторов, чей суммарный стейк меньше (но достаточен чтобы быть избранным в сети), для поддержки децентрализации. + Кто такой валидатор? + Для установки контроллер аккаунта смените аккаунт на стэш. + Стейкинг + %s стейкинг + Заработано + Всего застейкано + Порог Boost + Для моего коллатора + без Yield Boost + с Yield Boost + чтобы автоматически, %s, отправлять все мои переводимые токены выше + чтобы автоматически, %s (раньше: %s), отправлять все мои переводимые токены выше + Я хочу стейкать + Yield Boost + Тип стейкинга + Вы выводите из стейкинга все свои токены и не можете добавить токены в стейкинг. + Невозможно добавить токены в стейкинг + При частичном выводе средств вы должны оставить в стейке не менее %s. Хотите ли вы полностью вывести средства, также разблокировав оставшиеся %s? + В стейкинге остается слишком маленькая сумма + Сумма, которую вы хотите вывести из стейкинга, превышает сумму застейканных токенов. + Вывод из стейка + Операции по выводу из стейка появятся здесь + Операции по выводу из стейка появятся здесь + Время вывода: %s + Ваши токены будут доступны после истечения периода вывода. + Вы достигли лимита запросов на вывод из стейка ( %d активных запросов). + Достигнут лимит запросов на вывод из стейка + Время вывода + Вывести всё + Вывести из стейка всё? + Примерное вознаграждение (%% APY) + Примерное вознаграждение + Информация о валидаторе + Превышен лимит номинаторов для валидатора. В этой эре вы не получите награду от этого валидатора. + Номинаторы + Превышен лимит номинаторов для валидатора. Только номинаторы с наибольшим стейком получат награду. + Собственный + Поиск не дал результатов.\nПроверьте, что вы указали полный адрес аккаунта + Валидатор наказан за неправильные действия в сети (например, был оффлайн, атаковал сеть, работал с модифицированным ПО). + Всего застейкано + Общий стейк (%s) + Вознаграждение меньше комиссии сети. + Eжегодно + Ваш стейк распределяется между этими валидаторами. + Ваш стейк распределен между следующими валидаторами + Избраны (%s) + Валидаторы, которые не были избраны в этой эре. + Валидаторы с недостаточным стейком, чтобы быть избранными + Другие, которые активны без вашего стейка. + Активные валидаторы без вашего стейка + Не избраны (%s) + Ваши токены попали к валидатору, превысившему лимит номинаторов. В этой эре вы не получите награду от этого валидатора. + Вознаграждения + Ваш стейк + Ваши валидаторы + Ваши валидаторы поменяются в следующую эру + Теперь давайте создадим резервную копию вашего кошелька. Это гарантирует, что ваши средства будут в безопасности. Бэкапы позволяют восстановить ваш кошелёк в любое время. + Продолжить с Google + Введите имя кошелька + Мой новый кошелёк + Продолжить с бэкапом вручную + Назовите свой кошелёк + Оно будет видно только вам и может быть изменено позже. + Ваш кошелёк готов + Bluetooth + USB + Вы заблокировали токены на своем балансе используя %s. Чтобы продолжить, вам следует ввести меньше %s или больше %s. Чтобы поставить еще одну сумму, вам следует разблокировать токены из %s. + Вы не можете застейкать указанную сумму + Выбрано: %d (макс. %d) + Доступный баланс: %1$s (%2$s) + %s с вашими токенами в стейкинге + Участвуйте в голосованиях + Застейкайте больше %1$s и %2$s с вашими токенами в стейкинге + участвуйте в голосованиях + Стейкайте в любое время всего с %1$s. Ваш стейк активно будет зарабатывать вознаграждения %2$s + через %s + Стейкайте в любое время. Ваш стейк активно будет зарабатывать вознаграждения %s + Узнайте больше о стейкинге\n%1$s на %2$s + Pezkuwi Wiki + Вознаграждения начисляются %1$s. Для автоматической выплаты вознаграждений застейкайте более %2$s, в противном случае вам необходимо будет получать вознаграждения вручную + раз в %s + Вознаграждения начисляются %s + Вознаграждения начисляются %s. Вам нужно забрать награды вручную + Вознаграждения начисляются %s и добавляются к переводимому балансу + Вознаграждения начисляются %s и добавляются обратно в стейк + Вознаграждения и статус стейкинга меняются со временем. %s время от времени + Следите за своим стейкингом + Начать стейкинг + См. %s + Условия использования + %1$s — это %2$s с %3$s + токеном без цены + тестовая сеть + %1$s\nна ваших токенах %2$s\nв год + Зарабатывайте до %s + Отмените стейк в любое время, и верните свои средства %s. Вознаграждения не начисляются пока производится отмена + через %s + Выбранный вами пул неактивен, поскольку у него не выбраны валидаторы или его стейк меньше минимальной.\nВы уверены, что хотите продолжить с выбранным пулом? + Достигнуто максимальное количество номинаторов. Повторите попытку позже + %s в настоящее время недоступен + Валидаторы: %d (макс. %d) + Изменён + Добавлен + Удалён + Токен для оплаты комиссии сети + Комиссия сети добавится к введенной сумме + Симуляция шага обмена не удалась + Эта пара не поддерживается + Не удалось выполнить операцию #%s (%s) + Обмен %s на %s в %s + Перевод %s из %s в %s + + %s операция + + + %s операций + + %s из %s операций + Обмен %s на %s в %s + Перевод %s в %s + Время выполнения + Вы должны сохранить как минимум %s после оплаты сетевой комиссии %s, поскольку вы храните не самодостаточные токены + Вы должны сохранить как минимум %s, чтобы получить %s токены + У вас должно быть как минимум %s на %s, чтобы получить %s токен + Вы можете обменять до %1$s так как вам нужно заплатить комиссию сети в размере %2$s. + Вы можете обменять до %1$s так как вам нужно заплатить комиссию сети в размере %2$s, а также конвертировать %3$s в %4$s, чтобы сохранить минимальный баланс %5$s. + Использовать максимум + Использовать минимум + На вашем балансе должно оставаться не менее %1$s. Хотите обменять максимум, добавив также оставшиеся %2$s? + На вашем балансе остается слишком маленькая сумма + На вашем балансе должно оставаться как минимум %1$s после оплаты сетевой комиссии %2$s и конвертации %3$s в %4$s, для достижения минимального баланса %5$s.\n\nХотите обменять максимум, добавив еще %6$s? + Заплатить + Получить + Выберите токен + Недостаточно токенов для обмена + Недостаточно ликвидности + Вы не можете получить меньше %s + Мгновенная покупка %s с помощью кредитной карты + Перевести %s из другой сети + Получить %s по QR или вашему адресу + Получить %s с помощью + Во время выполнения обмена промежуточная полученная сумма равна %s, что меньше минимального баланса %s. Попробуйте указать большую сумму обмена. + Проскальзывание должно быть указано в диапазоне от %s до %s + Недопустимое проскальзывание + Выберите токен для оплаты + Выберите токен для получения + Введите сумму + Введите другую сумму + Чтобы оплатить комиссию сети с помощью %s, Pezkuwi автоматически обменяет %s на %s, чтобы поддерживать минимальный %s баланс вашей учетной записи. + Комиссия сети, взимается блокчейном за обработку и проверку транзакций. Может варьироваться в зависимости от условий сети или скорости транзакции. + Выберите сеть для обмена %s + В пуле недостаточно ликвидности для обмена + Разница в цене представляет собой разницу между двумя различными активами. При обмене криптовалюты под разницей в цене обычно имеется ввиду разница между ценой актива, которую вы получаете и ценой актива, которую вы платите. + Разница в цене + %s ≈ %s + Курс обмена двух разных криптовалют. Он показывает, сколько криптовалюты вы можете получить в обмен на определенное количество другой криптовалюты. + Курс + Старый курс: %1$s ≈ %2$s.\nНовый курс: %1$s ≈ %3$s + Обменный курс изменился + Повторить операцию + Маршрут + Путь, по которому ваш токен пройдет через разные сети, чтобы получить желаемый токен. + Обмен + Перевод + Настройки обмена + Проскальзывание + Проскальзывание - распространенное явление в децентрализованной торговле, когда конечная цена сделки по обмену может незначительно отличаться от ожидаемой в связи с изменением рыночных условий. + Введите другое значение + Введите значение между %s и %s + Проскальзывание + Транзакция может подвергнуться фронтрану из-за высокого проскальзывания + Транзакция может быть отменена из-за низкой устойчивости к проскальзыванию. + Сумма %s меньше минимального баланса %s + Вы пытаетесь обменять слишком маленькую сумму + Воздержаться: %s + За: %s + Вы всегда можете проголосовать за этот референдум позже + Удалить референдум %s из списка голосований? + Некоторые референдумы больше недоступны для голосования или у вас может быть недостаточно токенов для голосования. Доступно для голосования: %s. + Некоторые референдумы исключены из списка голосований + Данные референдума не удалось загрузить + Данные не получены + Вы уже проголосовали во всех доступных референдумах или на данный момент нет референдумов для голосования. Вернитесь позже. + Вы уже проголосовали во всех доступных референдумах + Запрошено: + Список голосований + Осталось %d + Подтвердить голоса + Нет референдумов для голосования + Подтвердите ваши голоса + Нет голосов + Вы успешно проголосовали за %d референдума + У вас недостаточно баланса, чтобы проголосовать с текущей мощностью голоса %s (%sx). Пожалуйста, измените мощность голоса или добавьте больше средств в ваш кошелек. + Недостаточно баланса для голосования + Против: %s + SwipeGov + Проголосовать за %d референдума + Голосование будет настроено для будущих голосов в SwipeGov + Мощность голоса + Стейкинг + Кошелёк + Сегодня + Ссылка Coingecko для курса валют (необязательно) + Выберите провайдера для покупки токена %s + Методы оплаты, комиссии и лимиты различаются в зависимости от провайдера.\nСравните их предложения, чтобы найти лучший вариант для вас. + Выберите провайдера для продажи токена %s + Для продолжения покупки вы будете перенаправлены из приложения Pezkuwi Wallet на сайт %s + Продолжить в браузере? + Ни один из наших провайдеров в настоящее время не поддерживает покупку или продажу этого токена. Пожалуйста, выберите другой токен, другую сеть или попробуйте позже. + Этот токен не поддерживается функцией покупки/продажи + Копировать хеш + Комиссия + От + Хеш транзакции + Детали транзакции + Открыть в %s + Посмотреть в Polkascan + Просмотреть в Subscan + %s в %s + Ваша предыдущая история транзакций %s всё ещё доступна на %s + Успешно + Ошибка + В ожидании + Канал уведомлений о транзакциях + Купите криптовалюту начиная с $5 + Продайте криптовалюту начиная с $10 + От: %s + Кому: %s + Перевод + Входящие и исходящие\nтранзакции будут отображаться здесь + Ваши операции появятся здесь + Удалить голоса для делегирования в этих треках + Треки, в которых вы уже делегировали голоса + Недоступные треки + Треки, в которых вы голосовали + Больше не показывать.\nВы можете найти старый адрес в разделе Получение. + Старый формат + Новый формат + Некоторые обменники могут по-прежнему требовать старый формат для операций, пока они обновляются. + Новый Унифицированный Адрес + Установить + Версия %s + Доступно обновление + Чтобы избежать каких-либо проблем и улучшить ваш пользовательский опыт, мы настоятельно рекомендуем как можно скорее установить последние обновления + Критичное обновление + Последняя + Новый потрясающий функционал уже доступен для Pezkuwi Wallet! Обязательно обновите приложение и наслаждайтесь им + Крупное обновление + Критичная + Важная + Посмотреть все доступные обновления + Имя + Имя кошелька + Данное имя будет отображаться только для вас и храниться только на вашем мобильном устройстве. + Эта учетная запись не выбрана сетью для участия в текущей эпохе + Переголосовать + Голосовать + Статус голосования + Ваша карта пополняется! + Ваша карта выпускается! + Это может занять до 5 минут.\nЭто окно закроется автоматически. + Примерно %s + Купить + Куп./Прод. + Купить токены + Купить с + Получить + Получить %s + Отправляйте только токен %1$s и токены в сети %2$s на этот адрес, иначе вы можете потерять свои средства + Продать + Продать токены + Перевести + Обменять + Активы + Здесь появятся ваши активы.\nУбедитесь, что фильтр\n\"Скрыть нулевой баланс\" отключен. + Стоимость активов + Доступно + Застейкано + Детали баланса + Общий баланс + Общий после перевода + Заморожено + Заблокировано + Можно забрать + Зарезервировано + Доступно + В процессе вывода + Кошелёк + Новое подключение + + %s аккаунт не найден. Добавьте аккаунт к кошельку в Настройках + %s аккаунты не найдены. Добавьте аккаунты к кошельку в Настройках + %s аккаунты не найдены. Добавьте аккаунты к кошельку в Настройках + %s аккаунты не найдены. Добавьте аккаунты к кошельку в Настройках + + Некоторые из обязательных сетей, запрошенных \\"%s\\", не поддерживаются в Pezkuwi Wallet + Сеансы Wallet Connect будут отображаться здесь + WalletConnect + Неизвестный dApp + + %s неизвестная сеть скрыта + %s неизвестных сети скрыто + %s неизвестных сетей скрыто + %s неизвестных сетей скрыто + + WalletConnect v2 + Межсетевой перевод + Криптовалюты + Фиатные валюты + Популярные фиатные валюты + Валюта + Детали транзакции + Скрыть активы с нулевым балансом + Прочие транзакции + Показывать + Вознаграждения и слэши + Обмены + Фильтры + Переводы + Управление ассетами + Как добавить кошелёк? + Как добавить кошелёк? + Как добавить кошелёк? + Примеры имён: Мой аккаунт, Валидатор, Dotsama краудлоуны, и т.д. + Поделитесь этим QR-кодом с отправителем + Покажите QR код отправителю для сканирования + Мой %s адрес для получения %s: + Поделиться + Получатель + Убедитесь, что адрес\nиз правильной сети + Неверный формат адреса.\nУбедитесь, что адрес \nсоответствует сети + Минимальный баланс + Из-за межсетевых ограничений вы можете перевести не более %s + У вас недостаточно токенов для оплаты межсетевой комиссии в размере %s.\nОстаток баланса после перевода: %s + Межсетевая комиссия добавляется к введенной сумме. Получатель может получить часть межсетевой комиссии + Подтвердить + Между сетями + Межсетевая комиссия + Ваш перевод завершится ошибкой, так как у получателя недостаточно %s для приема переводов в других токенах. + Получатель не может принять перевод + Ваш перевод не состоится, так как окончательная сумма на целевом счете будет меньше, чем минимальный баланс. Пожалуйста, попробуйте увеличить сумму. + Ваш перевод удалит аккаунт, так как общий баланс станет ниже минимального. + Ваша учетная запись будет удалена из сети после перевода, так как опустит общий баланс ниже минимального + Перевод удалит аккаунт + Ваша учетная запись будет удалена из блокчейна после перевода, потому что общий баланс становится ниже минимального. Остаток также будет переведен получателю. + Из сети + Вам необходимо иметь как минимум %s, чтобы оплатить комиссию за транзакцию и остаться выше минимального баланса. Ваш текущий баланс: %s. Для выполнения этой операции вам необходимо добавить %s к своему балансу. + Себе + Внутри сети + Следующий адрес: %s как известно, используется для фишинга, поэтому мы не рекомендуем отправлять токены на этот адрес. Вы все равно хотите продолжить? + Предупреждение о мошенничестве + Получатель был заблокирован владельцем токена и в настоящее время не может принимать входящие переводы + Получатель не может принять перевод + Сеть получателя + В сеть + Отправить %s из + Отправить %s в + в + Отправитель + Токены + Отправить этому контакту + Детали перевода + %s (%s) + %s адреса для %s + Pezkuwi обнаружила проблемы с целостностью информации об адресах %1$s. Пожалуйста, свяжитесь с владельцем %1$s для решения проблем с целостностью. + Проверка целостности не удалась + Неверный получатель + Не найден действительный адрес для %s в сети %s + %s не найден + %1$s сервисы w3n недоступны. Повторите попытку позже или введите адрес %1$s вручную + Ошибка при поиске w3n + Pezkuwi не может установить код для токена %s + Токен %s пока не поддерживается + Вчера + Yield Boost будет отключен для текущих коллаторов. Новый коллатор: %s + Изменить коллатора с Yield Boost? + У вас недостаточно средств для оплаты сетевой комиссии в размере %s и комиссии за выполнение первой Yield Boost операции в размере %s. \nДоступный баланс для оплаты комиссии: %s + Недостаточно токенов для оплаты комиссии за первое исполнение + У вас недостаточно средств, чтобы оплатить комиссию сети в размере %s и не опуститься ниже порога %s.\nДоступный баланс для оплаты комиссии: %s + Недостаточно токенов, чтобы оставаться выше порога + Частота увеличения стейка + Yield Boost будет автоматически стейкать %s все мои переводные токены выше %s + С Yield Boost + + + Мост DOT ↔ HEZ + Мост DOT ↔ HEZ + Вы отправляете + Вы получите (примерно) + Обменный курс + Комиссия моста + Минимум + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Обменять + Обмен HEZ→DOT выполняется при достаточной ликвидности DOT. + Недостаточный баланс + Сумма ниже минимума + Введите сумму + Обмен HEZ→DOT может быть ограничен в зависимости от ликвидности. + Обмен HEZ→DOT временно недоступен. Повторите попытку при достаточной ликвидности DOT. + diff --git a/common/src/main/res/values-vi/strings.xml b/common/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..5001537 --- /dev/null +++ b/common/src/main/res/values-vi/strings.xml @@ -0,0 +1,2033 @@ + + + Liên hệ chúng tôi + Github + Chính sách bảo mật + Đánh giá chúng tôi + Telegram + Điều khoản và điều kiện + Điều khoản & điều kiện + Giới thiệu + Phiên bản ứng dụng + Trang web + Nhập địa chỉ %s hợp lệ... + Địa chỉ EVM phải hợp lệ hoặc để trống... + Nhập địa chỉ substrate hợp lệ... + Thêm tài khoản + Thêm địa chỉ + Tài khoản đã tồn tại. Vui lòng thử tài khoản khác. + Theo dõi bất kỳ ví nào bằng địa chỉ của nó + Thêm ví chỉ xem + Ghi lại cụm mật khẩu của bạn + Vui lòng đảm bảo ghi đúng và rõ ràng cụm từ của bạn. + địa chỉ %s + Không có tài khoản trên %s + Xác nhận cụm từ ghi nhớ + Hãy kiểm tra lại lần nữa + Chọn các từ theo đúng thứ tự + Tạo tài khoản mới + Không sử dụng clipboard hoặc ảnh chụp màn hình trên thiết bị di động của bạn, hãy cố gắng tìm các phương pháp an toàn để sao lưu (ví dụ: giấy) + Tên này sẽ chỉ được sử dụng cục bộ trong ứng dụng này. Bạn có thể chỉnh sửa nó sau + Tạo tên ví + Sao lưu chuỗi ký tự gợi nhớ + Tạo ví mới + Chuỗi ký tự gợi nhớ được sử dụng để khôi phục quyền truy cập vào tài khoản. Hãy ghi lại, chúng tôi sẽ không thể khôi phục tài khoản của bạn nếu không có nó! + Tài khoản với bí mật đã thay đổi + Quên + Đảm bảo rằng bạn đã xuất ví của mình trước khi tiếp tục. + Quên ví? + Đường dẫn dẫn xuất Ethereum không hợp lệ + Đường dẫn dẫn xuất Substrate không hợp lệ + Ví này được ghép nối với %1$s. Pezkuwi sẽ giúp bạn thực hiện bất kỳ thao tác nào bạn muốn, và bạn sẽ được yêu cầu ký chúng bằng %1$s + Không được hỗ trợ bởi %s + Đây là ví chỉ xem, Pezkuwi có thể hiển thị cho bạn số dư và thông tin khác, nhưng bạn không thể thực hiện bất kỳ giao dịch nào với ví này + Nhập biệt danh ví... + Vui lòng thử cái khác. + Loại cặp khóa Ethereum + Chuỗi dẫn xuất bí mật của Ethereum + Tài khoản EVM + Xuất tài khoản + Xuất + Nhập tài khoản hiện có + Mật khẩu này cần thiết để mã hóa tài khoản của bạn và được sử dụng cùng với tệp JSON này để khôi phục ví của bạn. + Đặt mật khẩu cho tệp JSON của bạn + Lưu bí mật của bạn và cất giữ nó ở nơi an toàn + Ghi lại bí mật của bạn và cất giữ nó ở nơi an toàn + json khôi phục không hợp lệ. Vui lòng đảm bảo rằng đầu vào chứa json hợp lệ. + Seed không hợp lệ. Vui lòng đảm bảo rằng đầu vào của bạn chứa 64 ký tự hex. + JSON không chứa thông tin mạng. Vui lòng chỉ định nó bên dưới. + Cung cấp tệp JSON khôi phục của bạn + Thông thường chuỗi 12 từ (nhưng có thể là 15, 18, 21 hoặc 24) + Ghi các từ riêng biệt với một khoảng trắng, không có dấu phẩy hoặc ký tự khác + Nhập các từ theo thứ tự đúng + Mật khẩu + Nhập khóa riêng tư + 0xAB + Nhập seed gốc của bạn + Pezkuwi tương thích với tất cả các ứng dụng + Nhập ví + Tài khoản + Đường dẫn dẫn xuất của bạn chứa các ký hiệu không được hỗ trợ hoặc có cấu trúc không chính xác + Đường dẫn dẫn xuất không hợp lệ + Tệp JSON + Đảm bảo %s vào thiết bị Ledger của bạn bằng cách sử dụng ứng dụng Ledger Live + Ứng dụng Polkadot đã được cài đặt + %s trên thiết bị Ledger của bạn + Mở ứng dụng Polkadot + Flex, Stax, Nano X, Nano S Plus + Ledger (Ứng dụng Polkadot chung) + Đảm bảo ứng dụng mạng được cài đặt vào thiết bị Ledger của bạn bằng ứng dụng Ledger Live. Mở ứng dụng mạng trên thiết bị Ledger của bạn. + Thêm ít nhất một tài khoản + Thêm tài khoản vào ví của bạn + Hướng dẫn kết nối Ledger + Đảm bảo %s vào thiết bị Ledger của bạn bằng ứng dụng Ledger Live + Ứng dụng mạng đã được cài đặt + %s trên thiết bị Ledger của bạn + Mở ứng dụng mạng + Cho phép Pezkuwi Wallet %s + truy cập Bluetooth + %s để thêm vào ví + Chọn tài khoản + %s trong cài đặt điện thoại của bạn + Bật OTG + Kết nối Ledger + Để ký kết các hoạt động và di chuyển các tài khoản của bạn sang ứng dụng Generic Ledger mới, cài đặt và mở ứng dụng Migration. Ứng dụng Ledger (Legacy) và Migration Ledger sẽ không còn được hỗ trợ trong tương lai. + Ứng dụng Ledger mới đã được phát hành + Polkadot Migration + Ứng dụng Migration sẽ không còn khả dụng trong tương lai gần. Sử dụng nó để di chuyển các tài khoản của bạn sang ứng dụng Ledger mới để tránh mất tiền của bạn. + Polkadot + Ledger Nano X + Ledger Legacy + Nếu sử dụng Ledger qua Bluetooth, hãy bật Bluetooth trên cả hai thiết bị và cấp quyền Bluetooth và vị trí cho Pezkuwi. Đối với USB, hãy bật OTG trong cài đặt điện thoại của bạn. + Hãy bật Bluetooth trong cài đặt điện thoại của bạn và thiết bị Ledger. Mở khóa thiết bị Ledger của bạn và mở ứng dụng %s. + Chọn thiết bị Ledger của bạn + Vui lòng bật thiết bị Ledger. Mở khóa thiết bị Ledger của bạn và mở ứng dụng %s. + Bạn sắp hoàn tất! 🎉\n Chỉ cần nhấn bên dưới để hoàn tất thiết lập và bắt đầu sử dụng tài khoản của bạn một cách liền mạch trong cả Polkadot App và Pezkuwi Wallet + Chào mừng đến với Pezkuwi! + Cụm từ gồm 12, 15, 18, 21 hoặc 24 từ + Đa chữ ký + Kiểm soát chung (đa chữ ký) + Bạn không có tài khoản cho mạng này, bạn có thể tạo hoặc nhập tài khoản. + Cần tài khoản + Không tìm thấy tài khoản + Ghép khóa công khai + Parity Signer + %s không hỗ trợ %s + Các tài khoản sau đã được đọc thành công từ %s + Đây là các tài khoản của bạn + Mã QR không hợp lệ, hãy chắc chắn rằng bạn đang quét mã QR từ %s + Hãy chắc chắn rằng bạn đã chọn cái đầu tiên + ứng dụng %s trên điện thoại thông minh của bạn + mở Parity Signer + %s bạn muốn thêm vào Ví Pezkuwi + Đi tới tab \'Keys\'. Chọn seed, sau đó tài khoản + Parity Signer sẽ cung cấp cho bạn %s + quét mã QR + Thêm ví từ %s + %s không hỗ trợ ký các thông báo tùy ý — chỉ hỗ trợ giao dịch + Không hỗ trợ ký + Quét mã QR từ %s + + Mới (Vault v7+) + Tôi gặp lỗi trong %s + Mã QR đã hết hạn + Vì lý do bảo mật, các thao tác được tạo ra chỉ có giá trị trong %s.\nVui lòng tạo mã QR mới và ký với %s + Mã QR có giá trị trong %s + Vui lòng đảm bảo rằng bạn đang quét mã QR cho thao tác ký hiện tại + Ký với %s + Polkadot Vault + Hãy chú ý, tên đường dẫn dẫn xuất nên để trống + ứng dụng %s trên điện thoại thông minh của bạn + Mở Polkadot Vault + %s bạn muốn thêm vào Pezkuwi Wallet + Chạm vào Khóa dẫn xuất + Polkadot Vault sẽ cung cấp cho bạn %s + quét mã QR + Chạm vào biểu tượng ở góc trên bên phải và chọn %s + Xuất Khóa Riêng Tư + Khoá riêng tư + Được ủy quyền + Được ủy quyền cho bạn (Proxied) + Bất kỳ + Đấu giá + Hủy Proxy + Quản trị + Định danh + Pool đề cử + Không chuyển + Staking + Đã có tài khoản + bí mật + 64 ký tự hex + Chọn ví cứng + Chọn loại bí mật của bạn + Chọn ví + Tài khoản với bí mật chia sẻ + Tài khoản Substrate + Loại mã hóa keypair Substrate + Đường dẫn dẫn xuất bí mật Substrate + Tên ví + Biệt danh ví + Moonbeam, Moonriver và các mạng khác + Địa chỉ EVM (Không bắt buộc) + Ví cài sẵn + Polkadot, Kusama, Karura, KILT và hơn 50 mạng khác + Theo dõi hoạt động của bất kỳ ví nào mà không cần nhập khoá riêng tư của bạn vào Pezkuwi Wallet + Ví của bạn chỉ có thể xem, có nghĩa là bạn không thể thực hiện bất kỳ thao tác nào với nó + Ôi! Thiếu khoá + chỉ xem + Polkadot Vault, Parity Signer hoặc Ledger + Ví cứng + Sử dụng tài khoản Trust Wallet của bạn trong Pezkuwi + Trust Wallet + Thêm tài khoản %s + Thêm ví + Thay đổi tài khoản %s + Thay đổi tài khoản + Ledger (Legacy) + Ủy quyền cho bạn + Kiểm soát chung + Thêm node tùy chỉnh + Bạn cần thêm tài khoản %s vào ví để ủy quyền + Nhập chi tiết mạng + Ủy quyền cho + Tài khoản ủy quyền + Ví ủy quyền + Cấp loại quyền truy cập + Số tiền ký gửi sẽ được giữ nguyên trên tài khoản của bạn cho đến khi proxy bị xóa. + Bạn đã đạt đến giới hạn %s proxy được thêm vào %s. Xóa proxy để thêm proxy mới. + Đã đạt đến số lượng proxy tối đa + Các mạng tùy chỉnh đã thêm\nsẽ xuất hiện ở đây + +%d + Pezkuwi đã tự động chuyển sang ví multisig của bạn để bạn có thể xem các giao dịch đang chờ xử lý. + Đã tô màu + Giao diện + biểu tượng token + Trắng + Địa chỉ hợp đồng đã nhập có sẵn trong Pezkuwi là token %s. + Địa chỉ hợp đồng đã nhập có sẵn trong Pezkuwi là token %s. Bạn có chắc chắn muốn thay đổi không? + Token này đã tồn tại + Hãy chắc chắn rằng URL được cung cấp có dạng sau: www.coingecko.com/en/coins/tether. + Liên kết CoinGecko không hợp lệ + Địa chỉ hợp đồng đã nhập không phải là hợp đồng ERC-20 %s. + Địa chỉ hợp đồng không hợp lệ + Số thập phân phải ít nhất là 0 và không vượt quá 36. + Giá trị số thập phân không hợp lệ + Nhập địa chỉ hợp đồng + Nhập số thập phân + Nhập ký hiệu + Đi đến %s + Bắt đầu %1$s, số dư %2$s của bạn, Staking, và Quản trị sẽ trên %3$s — với hiệu suất cải thiện và chi phí thấp hơn. + Token %s của bạn hiện trên %s + Mạng + Token + Thêm token + Địa chỉ hợp đồng + Số thập phân + Ký hiệu + Nhập chi tiết token ERC-20 + Chọn mạng để thêm token ERC-20 + Crowdloans + Governance v1 + OpenGov + Bầu cử + Staking + Vesting + Mua token + Bạn đã nhận lại DOT từ crowdloans? Bắt đầu Stake DOT ngay hôm nay để nhận phần thưởng tối đa! + Tăng cường DOT của bạn 🚀 + Lọc token + Bạn không có token để tặng.\nMua hoặc Nạp token vào tài khoản của bạn. + Tất cả mạng + Quản lý token + Không chuyển %s vào tài khoản Ledger kiểm soát vì Ledger không hỗ trợ việc gửi %s, do đó tài sản sẽ bị kẹt trên tài khoản này + Ledger không hỗ trợ token này + Tìm kiếm theo mạng hoặc token + Không tìm thấy mạng hoặc token nào với\ntên đã nhập + Tìm kiếm theo token + Ví của bạn + Bạn không có token để gửi.\nMua hoặc Nhận token vào\ntài khoản của bạn. + Token để thanh toán + Token để nhận + Trước khi tiến hành thay đổi, %s cho ví đã thay đổi và bị xóa! + đảm bảo bạn đã lưu Passphrases + Áp dụng cập nhật sao lưu? + Chuẩn bị để lưu ví của bạn! + Cụm từ này cho bạn quyền truy cập toàn bộ và vĩnh viễn vào tất cả các ví được kết nối và các quỹ trong đó.\n%s + KHÔNG CHIA SẺ NÓ. + Không nhập Passphrase của bạn vào bất kỳ biểu mẫu hoặc trang web nào.\n%s + TIỀN CÓ THỂ BỊ MẤT VĨNH VIỄN. + Bộ phận hỗ trợ hoặc quản trị viên sẽ không bao giờ yêu cầu bạn cung cấp Cụm Từ Khóa trong bất kỳ trường hợp nào.\n%s + CẨN THẬN VỚI KẺ MẠO DANH. + Xem lại & Chấp nhận để Tiếp tục + Xóa bản sao lưu + Bạn có thể sao lưu thủ công cụm từ khóa của mình để đảm bảo truy cập vào tiền của ví nếu bạn mất quyền truy cập vào thiết bị này + Sao lưu thủ công + Thủ công + Bạn chưa thêm ví nào với cụm từ khóa. + Không có ví nào để sao lưu + Bạn có thể kích hoạt sao lưu Google Drive để lưu trữ các bản sao mã hóa của tất cả các ví của mình, được bảo vệ bằng mật khẩu mà bạn đặt. + Sao lưu vào Google Drive + Google Drive + Sao lưu + Sao lưu + Quy trình KYC đơn giản và hiệu quả + Xác thực sinh trắc học + Đóng tất cả + Mua hàng đã được khởi tạo! Vui lòng đợi tối đa 60 phút. Bạn có thể theo dõi trạng thái trong email. + Chọn mạng để mua %s + Đã bắt đầu mua! Vui lòng chờ tối đa 60 phút. Bạn có thể theo dõi trạng thái trên email. + Không có nhà cung cấp nào của chúng tôi hiện đang hỗ trợ mua token này. Vui lòng chọn token khác, mạng khác hoặc kiểm tra lại sau. + Token này không được tính năng mua hỗ trợ + Khả năng thanh toán phí bằng bất kỳ token nào + Việc di chuyển diễn ra tự động, không cần hành động + Lịch sử giao dịch cũ vẫn còn trên %s + Bắt đầu %1$s số dư %2$s của bạn, Staking và Quản trị đang ở %3$s. Những tính năng này có thể không khả dụng trong tối đa 24 giờ. + %1$s phí giao dịch thấp hơn\n(từ %2$s đến %3$s) + %1$s giảm số dư tối thiểu\n(từ %2$s đến %3$s) + Điều gì khiến Asset Hub tuyệt vời? + Bắt đầu %1$s số dư %2$s của bạn, Staking và Governance trị đang ở %3$s + Hỗ trợ nhiều token hơn: %s, và các token hệ sinh thái khác + Truy cập hợp nhất tới %s, tài sản, staking, và quản trị + Tự động cân bằng nodes + Kích hoạt kết nối + Vui lòng nhập mật khẩu sẽ được sử dụng để khôi phục ví của bạn từ sao lưu đám mây. Mật khẩu này không thể khôi phục trong tương lai, vì vậy hãy đảm bảo ghi nhớ nó! + Cập nhật mật khẩu sao lưu + Tài sản + Số dư hiện có + Xin lỗi, yêu cầu kiểm tra số dư thất bại. Vui lòng thử lại sau. + Xin lỗi, chúng tôi không thể liên hệ với nhà cung cấp dịch vụ chuyển khoản. Vui lòng thử lại sau. + Xin lỗi, bạn không có đủ tiền để chi tiêu số tiền đã chỉ định + Phí chuyển khoản + Mạng không phản hồi + Đến + Rất tiếc, món quà đã được nhận + Món quà không thể được nhận + Nhận món quà + Có thể có vấn đề với máy chủ. Vui lòng thử lại sau. + Rất tiếc, đã xảy ra lỗi + Sử dụng ví khác, tạo ví mới hoặc thêm tài khoản %s vào ví này trong Cài đặt. + Bạn đã nhận thành công một món quà. Token sẽ sớm xuất hiện trong số dư của bạn. + Bạn đã nhận được một món quà crypto! + Tạo ví mới hoặc nhập một ví hiện có để nhận món quà + Bạn không thể nhận quà với ví %s + Tất cả các tab mở trong trình duyệt DApp sẽ bị đóng. + Đóng tất cả DApps? + %s và nhớ luôn giữ chúng ngoại tuyến để khôi phục bất cứ lúc nào. Bạn có thể thực hiện việc này trong Cài đặt Sao lưu. + Vui lòng viết ra tất cả Passphrases của ví trước khi tiếp tục + Sao lưu sẽ bị xóa khỏi Google Drive + Sao lưu hiện tại với các ví của bạn sẽ bị xoá vĩnh viễn! + Bạn có chắc chắn muốn xoá Sao lưu Đám mây? + Xóa Sao lưu + Hiện tại sao lưu của bạn chưa được đồng bộ hóa. Vui lòng xem lại các cập nhật này. + Phát hiện các thay đổi trong Sao lưu Đám mây + Xem lại Cập nhật + Nếu bạn chưa viết tay Passphrase cho các ví sẽ bị xóa, thì những ví đó và tất cả tài sản của chúng sẽ bị mất vĩnh viễn và không thể nào khôi phục lại được. + Bạn có chắc chắn muốn áp dụng những thay đổi này? + Xem lại Vấn đề + Hiện tại bản sao lưu của bạn chưa được đồng bộ. Vui lòng xem lại vấn đề. + Thay đổi ví không thể cập nhật vào Sao lưu đám mây + Vui lòng đảm bảo rằng bạn đã đăng nhập vào tài khoản Google của mình với thông tin xác thực đúng và đã cấp quyền truy cập Google Drive cho Pezkuwi Wallet + Xác thực Google Drive thất bại + Bạn không có đủ dung lượng trống trên Google Drive. + Không đủ dung lượng + Rất tiếc, Google Drive không hoạt động mà không có các dịch vụ Google Play, các dịch vụ này hiện không có trên thiết bị của bạn. Thử tải các dịch vụ Google Play + Không tìm thấy các dịch vụ Google Play + Không thể sao lưu ví của bạn lên Google Drive. Vui lòng đảm bảo rằng bạn đã cấp quyền cho Pezkuwi Wallet sử dụng Google Drive của bạn và có đủ dung lượng lưu trữ trống, sau đó thử lại. + Lỗi Google Drive + Vui lòng kiểm tra tính chính xác của mật khẩu và thử lại. + Mật khẩu không hợp lệ + Rất tiếc, chúng tôi không tìm thấy bản sao lưu để khôi phục ví + Không tìm thấy bản sao lưu + Đảm bảo bạn đã lưu cụm từ bí mật cho ví trước khi tiếp tục. + Ví sẽ bị xóa trong Sao lưu Đám mây + Xem lại lỗi sao lưu + Xem lại cập nhật sao lưu + Nhập mật khẩu sao lưu + Kích hoạt để sao lưu ví vào Google Drive + Đồng bộ lần cuối: %s lúc %s + Đăng nhập vào Google Drive + Xem lại vấn đề Google Drive + Sao lưu bị vô hiệu hóa + Sao lưu đã đồng bộ + Đang đồng bộ sao lưu... + Sao lưu chưa được đồng bộ + Ví mới sẽ tự động được thêm vào Sao lưu Đám mây. Bạn có thể tắt Sao lưu Đám mây trong Cài đặt. + Thay đổi ví sẽ được cập nhật trong Sao lưu Đám mây + Chấp nhận điều khoản... + Tài khoản + Địa chỉ tài khoản + Hoạt động + Thêm + Thêm ủy quyền + Thêm mạng + Địa chỉ + Nâng cao + Tất cả + Cho phép + Số lượng + Số lượng quá thấp + Số lượng quá lớn + Đã áp dụng + Áp dụng + Hỏi lại + Chuẩn bị để lưu ví của bạn! + Có sẵn: %s + Trung bình + Số dư + Tiền thưởng + Gọi + Dữ liệu cuộc gọi + Mã băm cuộc gọi + Hủy + Bạn có chắc chắn muốn hủy thao tác này? + Rất tiếc, bạn không có ứng dụng phù hợp để xử lý yêu cầu này + Không thể mở liên kết này + Bạn có thể sử dụng tối đa %s vì bạn cần trả\n%s cho phí mạng. + Chuỗi + Thay đổi + Thay đổi mật khẩu + Tiếp tục tự động trong tương lai + Chọn mạng lưới + Xóa + Đóng + Bạn có chắc chắn muốn đóng màn hình này?\nCác thay đổi của bạn sẽ không được áp dụng. + Sao lưu đám mây + Hoàn thành + Hoàn thành (%s) + Xác nhận + Xác nhận + Bạn có chắc chắn? + Đã xác nhận + %d ms + đang kết nối... + Vui lòng kiểm tra kết nối của bạn hoặc thử lại sau + Kết nối thất bại + Tiếp tục + Đã sao chép vào bộ nhớ đệm + Sao chép địa chỉ + Sao chép dữ liệu cuộc gọi + Sao chép hash + Sao chép id + Loại khóa cặp + Ngày + %s và %s + Xóa + Người gửi tiền + Chi tiết + Đã tắt + Ngắt kết nối + Không đóng ứng dụng! + Hoàn thành + Pezkuwi mô phỏng giao dịch trước để ngăn ngừa lỗi. Mô phỏng này không thành công. Thử lại sau hoặc với số tiền lớn hơn. Nếu vấn đề vẫn tồn tại, vui lòng liên hệ Hỗ trợ Ví Pezkuwi trong Cài đặt. + Mô phỏng giao dịch thất bại + Chỉnh sửa + %s (+%s thêm) + Chọn ứng dụng email + Kích hoạt + Nhập địa chỉ... + Nhập số lượng... + Nhập chi tiết + Nhập số lượng khác + Nhập mật khẩu + Lỗi + Không đủ token + Sự kiện + EVM + Địa chỉ EVM + Tài khoản của bạn sẽ bị xóa khỏi blockchain sau thao tác này do nó làm tổng số dư thấp hơn mức tối thiểu + Thao tác sẽ xóa tài khoản + Hết hạn + Khám phá + Thất bại + Phí mạng ước tính %s cao hơn nhiều so với phí mạng mặc định (%s). Điều này có thể do tắc nghẽn mạng tạm thời. Bạn có thể làm mới để đợi phí mạng thấp hơn. + Phí mạng quá cao + Phí: %s + Sắp xếp theo: + Bộ lọc + Tìm hiểu thêm + Quên mật khẩu? + + mỗi %s ngày + + hàng ngày + mỗi ngày + Chi tiết đầy đủ + Nhận %s + Quà + Hiểu rồi + Quản trị + Chuỗi thập lục phân + Ẩn + + %d giờ + + Cách hoạt động + Hiểu rồi + Thông tin + Mã QR không hợp lệ + Mã QR không hợp lệ + Tìm hiểu thêm + Tìm hiểu thêm về + Ledger + còn lại %s + Quản lý Ví + Tối đa + tối đa %s + + %d phút + + tài khoản %s đang thiếu + Chỉnh sửa + Mô đun + Tên + Mạng + Ethereum + %s không được hỗ trợ + Polkadot + Mạng lưới + + Mạng lưới + + Tiếp theo + Không + Không tìm thấy ứng dụng nhập tệp trên thiết bị. Vui lòng cài đặt và thử lại + Không tìm thấy ứng dụng phù hợp trên thiết bị để xử lý yêu cầu này + Không có thay đổi + Chúng tôi sẽ hiển thị cụm mật khẩu của bạn. Hãy chắc chắn rằng không ai có thể nhìn thấy màn hình của bạn và không chụp ảnh màn hình — chúng có thể bị phần mềm độc hại từ bên thứ ba thu thập + Không có + Không khả dụng + Xin lỗi, bạn không có đủ tiền để trả phí mạng. + Số dư không đủ + Bạn không có đủ số dư để trả phí mạng %s. Số dư hiện tại là %s + Không phải bây giờ + Tắt + OK + Okay, quay lại + Bật + Đang diễn ra + Không bắt buộc + Chọn một tùy chọn + Cụm mật khẩu + Dán + / năm + %s / năm + mỗi năm + %% + Các quyền được yêu cầu là cần thiết để sử dụng màn hình này. Bạn nên kích hoạt chúng trong Cài đặt. + Quyền đã bị từ chối + Các quyền được yêu cầu là cần thiết để sử dụng màn hình này. + Cần quyền + Giá + Chính Sách Bảo Mật + Tiếp tục + Gửi tiền ủy quyền + Thu hồi quyền truy cập + Thông báo đẩy + Đọc thêm + Đề xuất + Phí làm mới + Từ chối + Gỡ bỏ + Bắt buộc + Đặt lại + Thử lại + Có sự cố. Vui lòng thử lại + Thu hồi + Lưu + Quét mã QR + Tìm kiếm + Kết quả tìm kiếm sẽ hiển thị ở đây + Kết quả tìm kiếm: %d + giây + + %d giây + + Đường dẫn dẫn xuất bí mật + Xem tất cả + Chọn token + Cài đặt + Chia sẻ + Chia sẻ dữ liệu cuộc gọi + Chia sẻ hash + Hiển thị + Đăng nhập + Yêu cầu ký + Người ký + Chữ ký không hợp lệ + Bỏ qua + Bỏ qua quy trình + Có lỗi khi gửi một số giao dịch. Bạn có muốn thử lại không? + Không thể gửi một số giao dịch + Có sự cố + Sắp xếp theo + Trạng thái + Substrate + Địa chỉ Substrate + Nhấn để hiển thị + Điều khoản và Điều kiện + Testnet + Thời gian còn lại + Tiêu đề + Mở Cài đặt + Số dư của bạn quá nhỏ + Tổng cộng + Tổng phí + ID Giao dịch + Giao dịch đã được gửi + Thử lại + Loại + Vui lòng thử lại với đầu vào khác. Nếu lỗi xuất hiện lại, vui lòng liên hệ hỗ trợ. + Không rõ + + %s không được hỗ trợ + + Không giới hạn + Cập nhật + Sử dụng + Sử dụng tối đa + Người nhận phải là địa chỉ %s hợp lệ + Người nhận không hợp lệ + Xem + Đang chờ + + Cảnh báo + + Quà của bạn + Số lượng phải lớn hơn 0 + Vui lòng nhập mật khẩu bạn đã tạo trong quá trình sao lưu + Nhập mật khẩu sao lưu hiện tại + Xác nhận sẽ chuyển token từ tài khoản của bạn + Chọn các từ... + Cụm mật khẩu không hợp lệ, vui lòng kiểm tra lại thứ tự các từ + Trưng cầu dân ý + Bỏ phiếu + Theo dõi + Nút đã được thêm trước đó. Vui lòng thử nút khác. + Không thể thiết lập kết nối với nút. Vui lòng thử nút khác. + Rất tiếc, mạng không được hỗ trợ. Vui lòng thử một trong những mạng sau: %s. + Xác nhận xóa %s. + Xóa mạng? + Vui lòng kiểm tra kết nối của bạn hoặc thử lại sau + Tùy chỉnh + Mặc định + Mạng + Thêm kết nối + Quét mã QR + Đã xác định vấn đề với bản sao lưu của bạn. Bạn có thể xóa bản sao lưu hiện tại và tạo một bản mới. %s trước khi tiến hành. + Đảm bảo bạn đã lưu giữ Passphrases cho tất cả các ví + Tìm thấy bản sao lưu nhưng rỗng hoặc bị hỏng + Trong tương lai, không có mật khẩu sao lưu thì không thể khôi phục ví của bạn từ Sao lưu đám mây.\n%s + Mật khẩu này không thể khôi phục. + Nhớ Mật khẩu Sao lưu + Xác nhận mật khẩu + Mật khẩu sao lưu + Chữ cái + Tối thiểu 8 ký tự + Số + Mật khẩu khớp + Vui lòng nhập mật khẩu để truy cập sao lưu bất kỳ lúc nào. Mật khẩu không thể khôi phục, hãy chắc chắn nhớ nó! + Tạo mật khẩu sao lưu của bạn + Chain ID đã nhập không khớp với mạng trong URL RPC. + Chain ID không hợp lệ + Crowdloan riêng tư chưa được hỗ trợ. + Crowdloan riêng tư + Về crowdloans + Trực tiếp + Tìm hiểu thêm về các khoản đóng góp vào Acala + Liquid + Hoạt động (%s) + Đồng ý với Điều khoản và Điều kiện + Thưởng Pezkuwi Wallet (%s) + Mã giới thiệu Astar phải là một địa chỉ hợp lệ của Polkadot + Không thể đóng góp số tiền đã chọn vì số tiền được huy động sẽ vượt quá hạn mức crowdloan. Số tiền đóng góp tối đa cho phép là %s. + Không thể đóng góp vào crowdloan đã chọn vì nó đã đạt hạn mức. + Vượt quá giới hạn Crowdloan + Đóng góp cho crowdloan + Đóng góp + Bạn đã đóng góp: %s + Liquid + Parallel + Các khoản đóng góp của bạn\n sẽ xuất hiện ở đây + Hoàn trả trong %s + Sẽ được hoàn trả bởi parachain + %s (thông qua %s) + Crowdloans + Nhận một phần thưởng đặc biệt + Crowdloans sẽ được hiển thị ở đây + Không thể đóng góp cho crowdloan đã chọn vì nó đã kết thúc. + Crowdloan đã kết thúc + Nhập mã giới thiệu của bạn + Thông tin về Crowdloan + Tìm hiểu về crowdloan của %s + Trang web crowdloan của %s + Thời gian thuê + Chọn parachains để đóng góp %s của bạn. Bạn sẽ nhận lại các token đã đóng góp, và nếu parachain thắng slot, bạn sẽ nhận được phần thưởng sau khi kết thúc cuộc đấu giá + Bạn cần thêm tài khoản %s vào ví để đóng góp + Áp dụng phần thưởng + Nếu bạn không có mã giới thiệu, bạn có thể áp dụng mã giới thiệu của Pezkuwi để nhận thưởng cho sự đóng góp của bạn + Bạn chưa áp dụng tiền thưởng + Moonbeam crowdloan chỉ hỗ trợ các tài khoản loại mã hóa SR25519 hoặc ED25519. Vui lòng xem xét sử dụng tài khoản khác để đóng góp + Không thể đóng góp bằng tài khoản này + Bạn nên thêm tài khoản Moonbeam vào ví để tham gia vào Moonbeam crowdloan + Thiếu tài khoản Moonbeam + Crowdloan này không có sẵn ở vị trí của bạn. + Khu vực của bạn không được hỗ trợ + Điểm đến phần thưởng của %s + Gửi đồng ý + Bạn cần phải gửi đồng ý với Các điều khoản và điều kiện trên blockchain để tiếp tục. Việc này chỉ cần thực hiện một lần cho tất cả các đóng góp Moonbeam tiếp theo + Tôi đã đọc và đồng ý với Các điều khoản và điều kiện + Đã thu được + Mã giới thiệu + Mã giới thiệu không hợp lệ. Vui lòng thử mã khác + Các điều khoản và điều kiện của %s + Số tiền tối thiểu được cho phép đóng góp là %s. + Số tiền đóng góp quá nhỏ + Các token %s của bạn sẽ được hoàn trả sau kỳ hạn cho thuê. + Các khoản đóng góp của bạn + Đã gây quỹ: %s trong %s + URL RPC đã nhập có mặt trong Pezkuwi như một mạng tùy chỉnh %s. Bạn có chắc chắn muốn chỉnh sửa nó không? + https://networkscan.io + URL Trình khám phá khối (Tùy chọn) + 012345 + Chain ID + TOKEN + Biểu tượng tiền tệ + Tên mạng + Thêm node + Thêm node tùy chỉnh cho + Nhập chi tiết + Lưu + Chỉnh sửa node tùy chỉnh cho + Tên + Tên node + wss:// + URL node + URL RPC + DApps mà bạn đã cho phép truy cập để xem địa chỉ của bạn khi bạn sử dụng chúng + DApp “%s” sẽ bị xóa khỏi Ủy quyền + Xóa khỏi Ủy quyền? + DApps được ủy quyền + Danh mục + Phê duyệt yêu cầu này nếu bạn tin tưởng ứng dụng + Cho phép “%s” truy cập vào địa chỉ tài khoản của bạn? + Phê duyệt yêu cầu này nếu bạn tin tưởng ứng dụng.\nKiểm tra chi tiết giao dịch. + DApp + DApps + %d DApps + Yêu thích + Yêu thích + Thêm vào yêu thích + DApp “%s” sẽ bị xóa khỏi Yêu thích + Xóa khỏi Yêu thích? + Danh sách DApps sẽ xuất hiện ở đây + Thêm vào Yêu thích + Chế độ máy tính + Xóa khỏi Yêu thích + Cài đặt trang + Được rồi, quay lại + Pezkuwi Wallet cho rằng trang web này có thể đe dọa đến bảo mật của tài khoản và token của bạn + Phát hiện lừa đảo + Tìm kiếm bằng tên hoặc nhập URL + Chuỗi không được hỗ trợ với mã băm genesis %s + Hãy chắc chắn rằng thao tác là chính xác + Không thể ký thao tác yêu cầu + Mở dù sao đi nữa + Các DApp độc hại có thể rút hết các khoản tiền của bạn. Luôn luôn tự nghiên cứu trước khi sử dụng một DApp, cấp quyền hoặc gửi tiền.\n\nNếu ai đó thúc giục bạn truy cập DApp này, rất có thể đó là một trò lừa đảo. Khi nghi ngờ, vui lòng liên hệ với bộ phận hỗ trợ của Pezkuwi Wallet: %s. + Cảnh báo! DApp chưa biết + Không tìm thấy chuỗi + Miền từ liên kết %s không được phép + Loại quản lý không được chỉ định + Loại quản lý không được hỗ trợ + Loại mã hóa không hợp lệ + Đường dẫn dẫn xuất không hợp lệ + Mnemonics không hợp lệ + URL không hợp lệ + URL RPC đã nhập có mặt trong Pezkuwi như một mạng %s. + Kênh thông báo mặc định + +%d + Tìm kiếm theo địa chỉ hoặc tên + Định dạng địa chỉ không hợp lệ. Hãy chắc chắn rằng địa chỉ thuộc về mạng đúng + kết quả tìm kiếm: %d + Tài khoản Proxy và Đa chữ ký tự động được phát hiện và tổ chức cho bạn. Quản lý bất kỳ lúc nào trong Cài đặt. + Danh sách ví đã được cập nhật + Đã bỏ phiếu cho toàn bộ thời gian + Delegate + Tất cả các tài khoản + Cá nhân + Tổ chức + Quá trình hủy ủy quyền sẽ bắt đầu sau khi bạn thu hồi ủy quyền + Phiếu bầu của bạn sẽ tự động cùng bầu với phiếu bầu của đại biểu của bạn + Thông tin Delegate + Cá nhân + Tổ chức + Phiếu bầu được ủy thác + Ủy nhiệm + Chỉnh sửa ủy nhiệm + Bạn không thể ủy thác cho chính mình, vui lòng chọn địa chỉ khác + Không thể ủy thác cho chính mình + Hãy cho chúng tôi biết thêm về bạn để người dùng Pezkuwi có thể hiểu rõ về bạn hơn + Bạn có phải là Delegate? + Mô tả về bạn + Qua %s tracks + Đã bỏ phiếu lần cuối %s + Phiếu bầu của bạn thông qua %s + Phiếu bầu của bạn: %s qua %s + Xóa phiếu bầu + Hủy bỏ ủy quyền + Sau khi khoảng thời gian Unstaking hết hạn, bạn sẽ cần mở khóa token của mình. + Phiếu bầu của người ủy quyền + Ủy quyền + Bỏ phiếu lần cuối %s + Các track + Chọn tất cả + Chọn ít nhất 1 track... + Không có track nào để ủy quyền + Fellowship + Quản trị + Kho bạc + Thời gian Undelegating + Không còn hiệu lực + Ủy quyền của bạn + Ủy quyền của bạn + Hiển thị + Đang xóa bản sao lưu... + Mật khẩu sao lưu của bạn đã được cập nhật trước đó. Để tiếp tục sử dụng Cloud Backup, %s + vui lòng nhập mật khẩu sao lưu mới. + Mật khẩu sao lưu đã thay đổi + Bạn không thể ký các giao dịch của các mạng bị vô hiệu hóa. Kích hoạt %s trong cài đặt và thử lại + %s bị vô hiệu hóa + Bạn đã ủy quyền cho tài khoản này: %s + Ủy quyền đã tồn tại + (BTC/ETH tương thích) + ECDSA + ed25519 (thay thế) + Edwards + Mật khẩu sao lưu + Nhập dữ liệu cuộc gọi + Không đủ token để trả phí + Hợp đồng + Gọi hợp đồng + Chức năng + Khôi phục ví + %s Tất cả ví của bạn sẽ được sao lưu an toàn trên Google Drive. + Bạn có muốn khôi phục các ví của mình không? + Đã tìm thấy Bản sao lưu Đám mây hiện tại + Tải xuống JSON Khôi phục + Xác nhận mật khẩu + Mật khẩu không khớp + Đặt mật khẩu + Mạng: %s\nMnemonic: %s\nĐường dẫn dẫn xuất: %s + Mạng: %s\nMnemonic: %s + Vui lòng chờ cho đến khi phí được tính toán + Đang tính toán phí + Quản lý Thẻ Ghi Nợ + Bán token %s + Thêm ủy quyền cho %s staking + Chi tiết đổi + Tối đa: + Bạn trả + Bạn nhận + Chọn một token + Nạp thẻ với %s + Vui lòng liên hệ support@pezkuwichain.io. Bao gồm địa chỉ email mà bạn đã sử dụng để phát hành thẻ. + Liên hệ hỗ trợ + Đã nhận + Đã tạo: %s + Nhập số lượng + Món quà tối thiểu là %s + Đã đòi lại + Chọn một token để tặng + Phí mạng khi nhận + Tạo Quà + Gửi quà nhanh chóng, dễ dàng và an toàn trong Pezkuwi + Chia sẻ Quà Crypto với Bất kỳ Ai, Ở Bất kỳ Đâu + Các món quà bạn đã tạo + Chọn mạng cho quà %s + Nhập số lượng quà của bạn + %s như một liên kết và mời bất kỳ ai đến với Pezkuwi + Chia sẻ quà trực tiếp + %s và bạn có thể trả lại các món quà chưa nhận bất cứ lúc nào từ thiết bị này + Món quà có sẵn ngay lập tức + Kênh thông báo quản trị + + Bạn cần chọn ít nhất %d track + + Mở khóa + Thực hiện giao dịch này sẽ dẫn đến trượt giá đáng kể và thiệt hại tài chính. Xem xét giảm kích thước giao dịch của bạn hoặc chia giao dịch thành nhiều lần thực hiện. + Phát hiện tác động giá cao (%s) + Lịch sử + Email + Tên pháp lý + Tên Element + Nhân dạng cá nhân + Trang web + Tệp JSON cung cấp được tạo cho mạng khác. + Vui lòng, đảm bảo rằng đầu vào của bạn chứa định dạng json hợp lệ. + Khôi phục JSON không hợp lệ + Vui lòng, kiểm tra mật khẩu và thử lại. + Giải mã Keystore thất bại + Dán json + Loại mã hóa không được hỗ trợ + Không thể nhập tài khoản với Substrate secret vào mạng với mã hóa Ethereum + Không thể nhập tài khoản với Ethereum secret vào mạng với mã hóa Substrate + Cụm từ khôi phục của bạn không hợp lệ + Vui lòng, đảm bảo rằng đầu vào của bạn chứa 64 ký tự hex. + Seed không hợp lệ + Thật tiếc, không tìm thấy bản sao lưu ví của bạn. + Không tìm thấy bản sao lưu + Khôi phục ví từ Google Drive + Sử dụng cụm từ 12, 15, 18, 21 hoặc 24 từ của bạn + Chọn cách bạn muốn nhập ví của mình + Chỉ quan sát + Tích hợp tất cả các tính năng của mạng bạn đang xây dựng vào Pezkuwi Wallet, làm cho nó trở nên dễ tiếp cận với mọi người. + Tích hợp mạng của bạn + Xây dựng cho Polkadot? + Dữ liệu cuộc gọi bạn cung cấp không hợp lệ hoặc sai định dạng. Vui lòng đảm bảo chính xác và thử lại. + Dữ liệu cuộc gọi này cho thao tác khác với call hash %s + Dữ liệu cuộc gọi không hợp lệ + Địa chỉ proxy phải là địa chỉ %s hợp lệ + Địa chỉ proxy không hợp lệ + Biểu tượng tiền tệ đã nhập (%1$s) không khớp với mạng (%2$s). Bạn có muốn sử dụng biểu tượng tiền tệ đúng không? + Biểu tượng tiền tệ không hợp lệ + Không thể giải mã QR + Mã QR + Tải lên từ thư viện + Xuất tệp JSON + Ngôn ngữ + Ledger không hỗ trợ %s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + Hoạt động đã bị hủy bởi thiết bị. Đảm bảo bạn đã mở khóa Ledger của mình. + Hoạt động bị hủy + Mở ứng dụng %s trên thiết bị Ledger của bạn + Ứng dụng %s chưa được khởi động + Hoạt động hoàn thành với lỗi trên thiết bị. Vui lòng thử lại sau. + Hoạt động Ledger thất bại + Giữ nút xác nhận trên %s của bạn để chấp thuận giao dịch + Tải thêm tài khoản + Xem xét và Phê duyệt + Tài khoản %s + Không tìm thấy tài khoản + Thiết bị Ledger của bạn đang sử dụng ứng dụng Generic cũ không hỗ trợ địa chỉ EVM. Cập nhật qua Ledger Live. + Cập nhật ứng dụng Generic Ledger + Nhấn cả hai nút trên %s của bạn để phê duyệt giao dịch + Vui lòng cập nhật %s bằng ứng dụng Ledger Live + Dữ liệu đã lỗi thời + Ledger không hỗ trợ ký tên cho các tin nhắn tùy ý — chỉ giao dịch + Vui lòng đảm bảo rằng bạn đã chọn đúng thiết bị Ledger cho hoạt động hiện tại + Vì lý do bảo mật, các thao tác được tạo chỉ tồn tại trong %s. Vui lòng thử lại và phê duyệt nó với Ledger + Giao dịch đã hết hạn + Giao dịch có hiệu lực trong %s + Ledger không hỗ trợ giao dịch này. + Giao dịch không được hỗ trợ + Nhấn cả hai nút trên %s của bạn để chấp thuận địa chỉ + Nhấn nút xác nhận trên %s của bạn để chấp thuận địa chỉ + Nhấn cả hai nút trên %s của bạn để xác nhận địa chỉ + Nhấn nút xác nhận trên %s của bạn để xác nhận địa chỉ + Ví này đã được ghép đôi với Ledger. Pezkuwi sẽ giúp bạn thực hiện bất kỳ thao tác nào bạn muốn, và bạn sẽ được yêu cầu ký chúng bằng Ledger + Chọn tài khoản để thêm vào ví + Đang tải thông tin mạng... + Đang tải chi tiết giao dịch… + Đang tìm kiếm bản sao lưu của bạn... + Giao dịch thanh toán đã được gửi + Thêm token + Quản lý sao lưu + Xóa mạng + Chỉnh sửa mạng + Quản lý mạng đã thêm + Bạn sẽ không thể thấy số dư token của bạn trên mạng đó trên màn hình Tài sản + Xóa mạng? + Xóa node + Chỉnh sửa node + Quản lý các node đã thêm + Node \"%s\" sẽ bị xóa + Xóa node? + Khóa tùy chỉnh + Khóa mặc định + Không chia sẻ bất kỳ thông tin nào này với bất kỳ ai - Nếu bạn làm vậy, bạn sẽ mất tất cả tài sản của mình vĩnh viễn và không thể khôi phục. + Tài khoản với khóa tùy chỉnh + Tài khoản mặc định + %s, +%d khác + Tài khoản với khóa mặc định + Chọn khóa để sao lưu + Chọn ví để sao lưu + Vui lòng đọc kỹ những điều sau đây trước khi xem bản sao lưu của bạn + Đừng chia sẻ cụm mật khẩu của bạn! + Giá tốt nhất với phí lên đến 3.95%% + Hãy đảm bảo không ai có thể nhìn thấy màn hình của bạn\nvà đừng chụp màn hình + Vui lòng không chia sẻ với %s + không chia sẻ với + Vui lòng thử cái khác. + Cụm từ mật khẩu mnemonic không hợp lệ, vui lòng kiểm tra lại thứ tự các từ + Bạn không thể chọn nhiều hơn %d ví + + Chọn ít nhất %d ví + + Giao dịch này đã bị từ chối hoặc đã được thực hiện. + Không thể thực hiện giao dịch này + %s đã khởi tạo hoạt động tương tự và hiện đang chờ để được ký bởi các bên ký khác. + Hoạt động đã tồn tại + Để quản lý Thẻ ghi nợ, vui lòng chuyển sang loại ví khác. + Thẻ ghi nợ không được hỗ trợ cho Đa chữ ký + Đặt cọc đa chữ ký + Khoản đặt cọc sẽ bị khóa trên tài khoản của người gửi tiền cho đến khi thao tác đa chữ ký được thực hiện hoặc bị từ chối. + Hãy chắc chắn rằng thao tác là đúng + Để tạo hoặc quản lý quà tặng, vui lòng chuyển sang loại ví khác. + Tính năng tặng quà không được hỗ trợ cho ví Multisig + Đã ký và thực hiện bởi %s. + ✅ Giao dịch multisig đã thực hiện + %s trên %s. + ✍🏻 Yêu cầu chữ ký của bạn + Được khởi xướng bởi %s. + Ví: %s + Được ký bởi %s + %d trong số %d chữ ký đã thu thập. + Thay mặt cho %s. + Bị từ chối bởi %s. + ❌ Giao dịch multisig bị từ chối + Thay mặt cho + %s: %s + Phê duyệt + Phê duyệt & Thực hiện + Nhập dữ liệu cuộc gọi để xem chi tiết + Giao dịch đã thực hiện hoặc bị từ chối. + Đã kết thúc ký kết + Từ chối + Người ký (%d của %d) + Batch All (khôi phục khi có lỗi) + Batch (thực thi cho đến khi có lỗi) + Force Batch (bỏ qua lỗi) + Được tạo bởi bạn + Chưa có giao dịch nào.\nYêu cầu ký sẽ xuất hiện ở đây + Ký (%s của %s) + Được bạn ký + Thao tác không rõ + Giao dịch cần ký + Thay mặt cho: + Được ký bởi người ký + Giao dịch multisig đã thực hiện + Yêu cầu chữ ký của bạn + Giao dịch multisig bị từ chối + Để bán tiền điện tử lấy tiền pháp định, vui lòng chuyển sang loại ví khác. + Bán không được hỗ trợ cho Đa chữ ký + Người ký: + %s không đủ số dư để đặt cọc đa chữ ký %s. Bạn cần thêm %s vào số dư của bạn + %s không đủ số dư để trả phí mạng của %s và đặt cọc đa chữ ký %s. Bạn cần thêm %s vào số dư của bạn + %s cần ít nhất %s để trả phí giao dịch này và duy trì trên số dư tối thiểu của mạng. Số dư hiện tại là: %s + %s không đủ số dư để trả phí mạng của %s. Bạn cần thêm %s vào số dư của bạn + Ví multisig không hỗ trợ ký các tin nhắn tùy ý — chỉ có các giao dịch + Giao dịch sẽ bị từ chối. Tiền gửi multisig sẽ được trả lại cho %s. + Giao dịch multisig sẽ được khởi tạo bởi %s. Người khởi tạo trả phí mạng và dự trữ một khoản đặt cọc multisig, sẽ được giải phóng sau khi giao dịch được thực thi. + Giao dịch Đa chữ ký + Các người ký khác bây giờ có thể xác nhận giao dịch.\nBạn có thể theo dõi trạng thái của nó trong %s. + Các giao dịch cần ký + Giao dịch Đa chữ ký đã được tạo + Xem chi tiết + Giao dịch không có thông tin trên chuỗi ban đầu (dữ liệu cuộc gọi) đã bị từ chối + %s trên %s.\nKhông cần thêm hành động nào từ bạn. + Giao dịch Multisig Đã Thực Hiện + %1$s → %2$s + %s trên %s.\nBị từ chối bởi: %s.\nKhông cần thêm hành động nào từ bạn. + Giao dịch Multisig Bị Từ Chối + Các giao dịch từ ví này yêu cầu sự chấp thuận từ nhiều người ký. Tài khoản của bạn là một trong những người ký: + Các người ký khác: + Ngưỡng %d trên %d + Kênh Thông Báo Giao Dịch Multisig + Kích hoạt trong Cài đặt + Nhận thông báo về các yêu cầu ký, chữ ký mới và giao dịch đã hoàn thành — để bạn luôn kiểm soát được mọi thứ. Quản lý bất cứ lúc nào trong Cài đặt. + Thông báo đẩy Multisig đã có mặt! + Node này đã tồn tại + Phí mạng + Địa chỉ nút + Thông tin nút + Đã thêm + nodes tuỳ chỉnh + nodes mặc định + Mặc định + Đang kết nối… + Bộ sưu tập + Được tạo bởi + %s cho %s + %s đơn vị của %s + #%s Phiên bản của %s + Chuỗi không giới hạn + Sở hữu bởi + Chưa niêm yết + NFT của bạn + Bạn chưa thêm hoặc chọn bất kỳ ví multisig nào trong Pezkuwi. Thêm và chọn ít nhất một cái để nhận thông báo đẩy multisig. + Không có ví multisig + URL bạn nhập đã tồn tại dưới dạng Node \"%s\". + Node này đã tồn tại + URL Node bạn nhập không phản hồi hoặc chứa định dạng không chính xác. Định dạng URL phải bắt đầu với \"wss://\". + Lỗi Node + URL bạn nhập không tương ứng với Node cho %1$s. Hãy nhập URL của node %1$s hợp lệ. + Sai mạng + Yêu cầu phần thưởng + Token của bạn sẽ được thêm lại vào stake + Trực tiếp + Thông tin stake nhóm + Phần thưởng của bạn (%s) cũng sẽ được yêu cầu và thêm vào số dư tự do của bạn + Nhóm + Không thể thực hiện thao tác chỉ định vì nhóm đang trong trạng thái hủy bỏ. Nhóm sẽ sớm được đóng lại. + Nhóm đang hủy bỏ + Hiện tại không còn chỗ trống trong hàng chờ unstaking cho nhóm của bạn. Vui lòng thử lại sau %s + Quá nhiều người đang unstaking từ nhóm của bạn + Nhóm của bạn + Nhóm của bạn (#%s) + Tạo tài khoản + Tạo ví mới + Chính sách Bảo mật + Nhập tài khoản + Đã có ví + Bằng cách tiếp tục, bạn đồng ý với\n%1$s và %2$s + Điều khoản và Điều kiện + Đổi + Một trong những collator của bạn không tạo ra phần thưởng + Một trong những collator của bạn không được chọn trong vòng hiện tại + Thời gian unstaking của bạn cho %s đã hết. Đừng quên nhận lại token của bạn + Không thể stake với collator này + Thay đổi collator + Không thể thêm stake vào collator này + Quản lý collator + Người thu thập được chọn đã bày tỏ ý định ngừng tham gia staking. + Bạn không thể thêm stake vào người thu thập mà bạn đang unstaking tất cả token. + Stake của bạn sẽ ít hơn mức stake tối thiểu (%s) cho người thu thập này. + Số dư staking còn lại sẽ giảm xuống dưới giá trị mạng tối thiểu (%s) và cũng sẽ được thêm vào số lượng unstaking + Bạn không được ủy quyền. Vui lòng thử lại. + Sử dụng sinh trắc học để ủy quyền + Pezkuwi Wallet sử dụng xác thực sinh trắc học để ngăn chặn người dùng trái phép truy cập vào ứng dụng. + Sinh trắc học + Pin code đã được thay đổi thành công + Xác nhận mã pin của bạn + Tạo mã pin + Nhập mã PIN + Đặt mã pin của bạn + Bạn không thể tham gia bể vì đã đạt số lượng thành viên tối đa + Bể đã đầy + Bạn không thể tham gia bể chưa mở. Vui lòng liên hệ với chủ bể. + Bể chưa mở + Bạn không thể sử dụng cả Staking trực tiếp và Pool Staking từ cùng một tài khoản. Để quản lý Pool Staking bạn trước tiên cần unstake token của mình từ Staking trực tiếp. + Các thao tác trong Pool không khả dụng + Phổ biến + Thêm mạng thủ công + Đang tải danh sách mạng... + Tìm kiếm theo tên mạng + Thêm mạng + %s lúc %s + 1D + Tất cả + 1M + %s (%s) + giá của %s + 1W + 1Y + Tất cả thời gian + Tháng trước + Hôm nay + Tuần trước + Năm trước + Tài khoản + Ví tiền + Ngôn ngữ + Đổi mã PIN + Mở ứng dụng với số dư ẩn + Xác nhận bằng mã PIN + Chế độ an toàn + Cài đặt + Để quản lý Thẻ Ghi Nợ của bạn, vui lòng chuyển sang một ví khác trên mạng Polkadot. + Thẻ Ghi Nợ không được hỗ trợ cho ví Ủy Quyền này + Tài khoản này đã cấp quyền thực hiện giao dịch cho tài khoản sau: + Giao dịch Staking + Tài khoản được ủy quyền %s không có đủ số dư để trả phí mạng là %s. Số dư có sẵn để trả phí: %s + Ví Proxy không hỗ trợ ký tin nhắn tùy ý — chỉ hỗ trợ giao dịch + %1$s chưa ủy quyền cho %2$s + %1$s chỉ ủy quyền %2$s cho %3$s + Rất tiếc! Không đủ quyền + Giao dịch sẽ được khởi tạo bởi %s như một tài khoản ủy nhiệm. Phí mạng lưới sẽ được thanh toán bởi tài khoản ủy nhiệm. + Đây là tài khoản Ủy nhiệm (Proxied) + %s proxy + Đại biểu đã bỏ phiếu + Trưng cầu dân ý mới + Cập nhật trưng cầu dân ý + %s Trưng cầu dân ý #%s đã trực tiếp! + 🗳️ Trưng cầu dân ý mới + Tải xuống Pezkuwi Wallet v%s để nhận tất cả các tính năng mới! + Có bản cập nhật mới của Pezkuwi Wallet! + %s trưng cầu dân ý #%s đã kết thúc và được chấp thuận 🎉 + ✅ Trưng cầu dân ý đã chấp thuận! + %s Trưng cầu dân ý #%s đã thay đổi trạng thái từ %s sang %s + %s trưng cầu dân ý #%s đã kết thúc và bị từ chối! + ❌ Trưng cầu dân ý bị từ chối! + 🗳️ Trạng thái trưng cầu dân ý đã thay đổi + %s Trưng cầu dân ý #%s đã thay đổi trạng thái thành %s + Thông báo từ Pezkuwi + Số dư + Bật thông báo + Giao dịch multisig + Bạn sẽ không nhận được thông báo về Hoạt động Ví (Số dư, Staking) vì bạn chưa chọn bất kỳ ví nào + Khác + Nhận token + Gửi token + Phần thưởng Staking + + ⭐️⃣ Phần thưởng mới %s + Nhận được %s từ %s staking + ⭐️⃣ Phần thưởng mới + Pezkuwi Wallet • bây giờ + Nhận được +0.6068 KSM ($20.35) từ Kusama staking + Nhận được %s trong %s + ⬇️ Nhận được + ⬇️ Nhận được %s + Gửi %s đến %s trong %s + 💸 Đã gửi + 💸 Đã gửi %s + Chọn tối đa %d ví để được thông báo khi ví có hoạt động + Bật thông báo đẩy + Nhận thông báo về các hoạt động của ví, cập nhật Quản trị, hoạt động Staking và Bảo mật để bạn luôn nắm bắt được thông tin + Bằng việc bật thông báo đẩy, bạn đồng ý với %s và %s của chúng tôi + Vui lòng thử lại sau bằng cách truy cập cài đặt thông báo từ tab Cài đặt + Đừng bỏ lỡ điều gì! + Chọn mạng để nhận %s + Sao chép Địa chỉ + Nếu bạn đòi lại món quà này, liên kết chia sẻ sẽ bị tắt và token sẽ được hoàn lại vào ví của bạn.\nBạn có muốn tiếp tục không? + Đòi lại Quà %s? + Bạn đã thu hồi thành công món quà của mình + Dán mã JSON hoặc tải lên tệp... + Tải lên tệp + Khôi phục JSON + Cụm từ ghi nhớ + Raw seed + Loại nguồn + Tất cả các cuộc trưng cầu dân ý + Hiển thị: + Chưa bỏ phiếu + Đã bỏ phiếu + Không có cuộc trưng cầu dân ý nào với các bộ lọc đã áp dụng + Thông tin về các cuộc trưng cầu dân ý sẽ xuất hiện ở đây khi chúng bắt đầu + Không tìm thấy cuộc trưng cầu dân ý với tiêu đề hoặc ID đã nhập\n + Tìm kiếm theo tiêu đề hoặc ID cuộc trưng cầu dân ý + %d cuộc trưng cầu + Vuốt để bình chọn cho cuộc trưng cầu với tóm tắt AI. Nhanh và dễ dàng! + Cuộc trưng cầu dân ý + Không tìm thấy cuộc trưng cầu dân ý + Bỏ phiếu Abstain chỉ có thể thực hiện với độ xác tín 0.1x. Bỏ phiếu với độ xác tín 0.1x? + Cập nhật độ xác tín + Bỏ phiếu Abstain + Đồng ý: %s + Sử dụng trình duyệt Pezkuwi DApp + Chỉ người đề xuất mới có thể chỉnh sửa mô tả và tiêu đề này. Nếu bạn sở hữu tài khoản của người đề xuất, hãy truy cập Polkassembly và điền thông tin về đề xuất của bạn. + Số tiền yêu cầu + Dòng thời gian + Phiếu bầu của bạn: + Đường phê duyệt + Người thụ hưởng + Đặt cọc + Cử tri + Quá dài để xem trước + Tham số JSON + Người đề xuất + Đường hỗ trợ + Tỷ lệ tham gia + Ngưỡng bầu chọn + Vị trí: %s của %s + Bạn cần thêm tài khoản %s vào ví để bầu chọn + Trưng cầu dân ý %s + Phản đối: %s + Phiếu bầu \'Phản đối\' + %s phiếu bầu bởi %s + Phiếu bầu \'Chấp thuận\' + Staking + Đã phê duyệt + Đã hủy + Đang quyết định + Quyết định trong %s + Đã thực hiện + Trong hàng đợi + Trong hàng đợi (%s của %s) + Đã xóa + Không vượt qua + Đang vượt qua + Đang chuẩn bị + Đã từ chối + Phê duyệt trong %s + Thực hiện trong %s + Hết thời gian trong %s + Từ chối trong %s + Hết thời gian + Đang chờ đặt cọc + Ngưỡng: %s của %s + Bỏ phiếu: Đã phê duyệt + Đã hủy + Đã tạo + Bỏ phiếu: Đang quyết định + Đã thực thi + Bỏ phiếu: Đang chờ + Đã xóa + Bỏ phiếu: Không vượt qua + Bỏ phiếu: Đang vượt qua + Bỏ phiếu: Đang chuẩn bị + Bỏ phiếu: Đã từ chối + Hết giờ + Bỏ phiếu: Đang chờ nạp + Để vượt qua: %s + Crowdloans + Kho bạc: Chi tiêu lớn + Kho bạc: Tiền boa lớn + Fellowship: Quản lý + Quản trị: Đăng ký + Quản trị: Cho thuê + Kho bạc: Chi tiêu trung bình + Quản trị: Hủy bỏ + Quản trị: Xóa + Chương trình chính + Kho bạc: Chi tiêu nhỏ + Kho bạc: Tiền boa nhỏ + Kho bạc: Bất kỳ + vẫn bị khóa trong %s + Có thể mở khóa + Không bỏ phiếu + Đồng ý + Tái sử dụng tất cả khóa: %s + Tái sử dụng khóa quản trị: %s + Khóa quản trị + Thời gian khóa + Không đồng ý + Nhân đôi phiếu bằng cách tăng thời gian khóa + Bỏ phiếu cho %s + Sau thời gian khóa, đừng quên mở khóa token của bạn + Các cuộc bỏ phiếu đã diễn ra + %s phiếu + %s %sx + Danh sách cử tri sẽ xuất hiện ở đây + %s phiếu + Fellowship: whitelist + Phiếu bầu của bạn: %s phiếu + Cuộc trưng cầu dân ý đã hoàn thành và kết thúc bỏ phiếu + Cuộc trưng cầu dân ý đã hoàn thành + Bạn đang ủy quyền phiếu cho track đã chọn của cuộc trưng cầu dân ý. Vui lòng yêu cầu người được ủy quyền bỏ phiếu hoặc hủy bỏ ủy quyền để có thể bỏ phiếu trực tiếp. + Đã ủy quyền phiếu + Bạn đã đạt đến số lượng tối đa %s phiếu cho track + Đạt đến số lượng tối đa phiếu + Bạn không có đủ token có sẵn để bỏ phiếu. Số lượng có sẵn để bỏ phiếu: %s. + Thu hồi loại quyền truy cập + Thu hồi cho + Xóa bỏ phiếu + + Bạn đã từng bỏ phiếu trong các cuộc trưng cầu dân ý trong các track %d. Để làm cho những track này khả dụng cho việc ủy quyền, bạn cần xóa phiếu hiện có của mình. + + Xóa lịch sử bỏ phiếu của bạn? + %s Nó là của riêng bạn, được lưu trữ an toàn, không thể truy cập bởi người khác. Nếu không có mật khẩu sao lưu, việc khôi phục ví từ Google Drive là không thể. Nếu mất, hãy xóa bản sao lưu hiện tại để tạo một bản mới với mật khẩu mới. %s + Rất tiếc, mật khẩu của bạn không thể được khôi phục. + Ngoài ra, hãy sử dụng Passphrase để khôi phục. + Bạn đã mất mật khẩu? + Mật khẩu sao lưu của bạn đã được cập nhật trước đó. Để tiếp tục sử dụng sao lưu đám mây, vui lòng nhập mật khẩu sao lưu mới. + Vui lòng nhập mật khẩu bạn đã tạo trong quá trình sao lưu + Nhập mật khẩu sao lưu + Không thể cập nhật thông tin về blockchain runtime. Một số chức năng có thể không hoạt động. + Lỗi cập nhật runtime + Danh bạ + tài khoản của tôi + Không tìm thấy pool với tên hoặc ID pool đã nhập. Hãy chắc chắn rằng bạn đã nhập đúng dữ liệu + Địa chỉ tài khoản hoặc tên tài khoản + Kết quả tìm kiếm sẽ được hiển thị ở đây + Kết quả tìm kiếm + pool đang hoạt động: %d + thành viên + Chọn pool + Chọn track để thêm ủy quyền + Track có sẵn + Vui lòng chọn các track mà bạn muốn ủy quyền quyền biểu quyết của mình. + Chọn track để chỉnh sửa ủy quyền + Chọn track để hủy ủy quyền + Track không có sẵn + Gửi quà vào + Bật Bluetooth & Cấp Quyền + Pezkuwi cần bật định vị để có thể thực hiện quét Bluetooth để tìm thiết bị Ledger của bạn + Vui lòng bật định vị địa lý trong cài đặt thiết bị + Chọn mạng + Chọn token để bỏ phiếu + Chọn track cho + %d trong %d + Chọn mạng để bán %s + Đã bắt đầu bán! Vui lòng chờ tối đa 60 phút. Bạn có thể theo dõi trạng thái trên email. + Không có nhà cung cấp nào của chúng tôi hiện đang hỗ trợ bán token này. Vui lòng chọn token khác, mạng khác hoặc kiểm tra lại sau. + Token này không được tính năng bán hỗ trợ + Địa chỉ hoặc w3n + Chọn mạng để gửi %s + Người nhận là một tài khoản hệ thống. Nó không được kiểm soát bởi bất kỳ công ty hoặc cá nhân nào.\nBạn có chắc chắn vẫn muốn thực hiện chuyển khoản này không? + Token sẽ bị mất + Cấp quyền cho + Vui lòng, đảm bảo sinh trắc học đã được bật trong Cài đặt + Sinh trắc học đã bị vô hiệu hóa trong Cài đặt + Cộng đồng + Nhận hỗ trợ qua Email + Chung + Mỗi hoạt động ký trên ví với cặp khóa (tạo trong ví nova hoặc nhập) cần yêu cầu xác minh PIN trước khi tạo chữ ký + Yêu cầu xác thực cho các hoạt động ký + Tùy chọn + Các thông báo đẩy chỉ có sẵn cho phiên bản Pezkuwi Wallet tải từ Google Play. + Các thông báo đẩy chỉ có sẵn cho thiết bị có dịch vụ Google. + Ghi màn hình và ảnh chụp màn hình sẽ không khả dụng. Ứng dụng thu nhỏ sẽ không hiển thị nội dung + Chế độ an toàn + Bảo mật + Hỗ trợ & Phản hồi + Twitter + Wiki & Trung tâm trợ giúp + Youtube + Độ xác tín sẽ được đặt là 0.1x khi bỏ phiếu Abstain + Bạn không thể Stake với Direct Staking và Nomination Pools cùng một lúc + Đã staking + Quản lý Stake nâng cao + Loại Stake không thể thay đổi + Bạn đã có Stake trực tiếp + Staking trực tiếp + Bạn đã chỉ định ít hơn mức stake tối thiểu là %s cần thiết để nhận thưởng với %s. Bạn nên xem xét sử dụng Pool staking để nhận thưởng. + Tái sử dụng token trong Quản trị + Mức stake tối thiểu: %s + Phần thưởng: Thanh toán tự động + Phần thưởng: Nhận thủ công + Bạn đã staking trong một pool + Pool staking + Stake của bạn ít hơn mức tối thiểu để nhận thưởng + Loại staking không được hỗ trợ + Chia sẻ liên kết quà + Đòi lại + Xin chào! Bạn đã có một món quà %s đang chờ bạn!\n\nCài đặt ứng dụng Pezkuwi Wallet, thiết lập ví của bạn và nhận nó qua liên kết đặc biệt này:\n%s + Quà Đã Được Chuẩn Bị.\nChia sẻ Ngay! + sr25519 (được khuyến nghị) + Schnorrkel + Tài khoản được chọn đã được sử dụng làm controller + Thêm quyền ủy nhiệm (Proxy) + Các ủy nhiệm của bạn + Các delegator hoạt động + Thêm tài khoản controller %s vào ứng dụng để thực hiện hành động này. + Thêm ủy nhiệm + Stake của bạn ít hơn mức tối thiểu %s.\nStake ít hơn mức tối thiểu tăng cơ hội staking không tạo ra phần thưởng + Stake thêm token + Thay đổi trình xác thực của bạn. + Hiện tại mọi thứ đều ổn. Các cảnh báo sẽ xuất hiện ở đây. + Vị trí cũ trong hàng đợi phân bổ stake cho trình xác thực có thể tạm dừng phần thưởng của bạn + Cải tiến staking + Rút token đã Unstaked. + Vui lòng đợi bắt đầu kỷ nguyên tiếp theo. + Cảnh báo + Đã là controller + Bạn đã có staking trong %s + Cân bằng staking + Cân bằng + Stake thêm + Bạn không đề cử và không xác thực + Thay đổi controller + Thay đổi trình xác thực + %s của %s + Trình xác thực được chọn + Controller + Tài khoản controller + Chúng tôi nhận thấy tài khoản này không có token tự do, bạn có chắc chắn muốn thay đổi controller không? + Tài khoản controller có thể Unstake, rút, trở lại stake, thay đổi điểm đến phần thưởng và trình xác thực. + Controller được sử dụng để: Unstake, rút, trở lại stake, thay đổi trình xác thực và thiết lập điểm đến phần thưởng + Controller đã được thay đổi + Trình xác thực này đã bị chặn và hiện không thể chọn. Vui lòng thử lại vào kỷ nguyên tiếp theo. + Xóa bộ lọc + Bỏ chọn tất cả + Điền phần còn lại với đề xuất + Trình xác thực: %d trong %d + Chọn trình xác thực (tối đa %d) + Hiển thị đã chọn: %d (tối đa %d) + Chọn trình xác thực + Phần thưởng ước tính (%% APR) + Phần thưởng ước tính (%% APY) + Cập nhật danh sách của bạn + Staking qua trình duyệt Pezkuwi DApp + Thêm tùy chọn staking + Stake và nhận phần thưởng + Staking %1$s đang hoạt động trên %2$s bắt đầu %3$s + Phần thưởng ước tính + kỷ nguyên #%s + Thu nhập ước tính + Thu nhập ước tính %s + Stake của trình xác thực + Stake của trình xác thực (%s) + Token trong thời gian unstaking không tạo ra phần thưởng. + Trong thời gian unstaking token không tạo ra phần thưởng + Sau thời gian unstaking, bạn sẽ cần nhận lại token của mình. + Sau thời gian unstaking đừng quên nhận lại token của bạn + Phần thưởng của bạn sẽ tăng bắt đầu từ kỷ nguyên tiếp theo. + Bạn sẽ nhận được phần thưởng tăng lên bắt đầu từ kỷ nguyên tiếp theo + Token đã staked tạo ra phần thưởng mỗi kỷ nguyên (%s). + Tokens trong stake tạo ra phần thưởng mỗi kỷ nguyên (%s) + Ví Pezkuwi sẽ thay đổi điểm đến phần thưởng\nvào tài khoản của bạn để tránh phần stake còn lại. + Nếu bạn muốn unstake tokens, bạn sẽ phải chờ đợi trong thời gian unstaking (%s). + Để unstake tokens bạn sẽ phải đợi trong thời gian unstaking (%s) + Thông tin Staking + Người đề cử đang hoạt động + + %d ngày + + Stake tối thiểu + %s mạng lưới + Staked + Vui lòng chuyển ví của bạn sang %s để thiết lập proxy + Chọn tài khoản stash để thiết lập proxy + Quản lý + %s (tối đa %s) + Đã đạt tối đa số lượng người đề cử. Thử lại sau + Không thể bắt đầu staking + Stake tối thiểu + Bạn cần thêm tài khoản %s vào ví của bạn để bắt đầu staking + Hàng tháng + Thêm tài khoản điều khiển vào thiết bị. + Không có quyền truy cập vào tài khoản điều khiển + Đề cử: + %s được thưởng + Một trong những người xác nhận của bạn đã được chọn bởi mạng lưới. + Trạng thái kích hoạt + Trạng thái không hoạt động + Số tiền Staked của bạn ít hơn số Stake tối thiểu để nhận phần thưởng. + Không có validator nào của bạn được mạng lưới bầu chọn. + Staking của bạn sẽ bắt đầu trong kỷ nguyên tiếp theo. + Không hoạt động + Đang chờ đợi kỷ nguyên tiếp theo + đang chờ đợi kỷ nguyên tiếp theo (%s) + Bạn không có đủ số dư cho tiền đặt cọc proxy %s. Số dư có sẵn: %s + Kênh thông báo Staking + Người soạn thảo + Stake tối thiểu của collator cao hơn sự ủy nhiệm của bạn. Bạn sẽ không nhận được phần thưởng từ collator này. + Thông tin người soạn thảo + Stake riêng của người soạn thảo + Người soạn thảo: %s + Một hoặc nhiều người soạn thảo của bạn đã được mạng lưới bầu chọn. + Người ủy nhiệm + Bạn đã đạt đến số lượng ủy nhiệm tối đa là %d người soạn thảo + Bạn không thể chọn một người soạn thảo mới + Người soạn thảo mới + đang chờ đợi vòng tiếp theo (%s) + Bạn có các yêu cầu unstake chờ xử lý cho tất cả người soạn thảo của bạn. + Không có người soạn thảo nào khả dụng để unstake + Các token được hoàn lại sẽ được tính từ vòng tiếp theo + Các token trong stake tạo ra phần thưởng mỗi vòng (%s) + Chọn collator + Chọn collator… + Bạn sẽ nhận được phần thưởng tăng lên kể từ vòng tiếp theo + Bạn sẽ không nhận được phần thưởng cho vòng này vì không có bất kỳ ủy quyền của bạn nào đang hoạt động. + Bạn đã thực hiện unstaking token từ collator này. Bạn chỉ có thể có một yêu cầu unstake chờ xử lý cho mỗi collator + Bạn không thể unstake từ collator này + Stake của bạn phải lớn hơn stake tối thiểu (%s) cho collator này. + Bạn sẽ không nhận được phần thưởng + Một số collator của bạn không được chọn hoặc có stake tối thiểu cao hơn số lượng bạn đã stake. Bạn sẽ không nhận được phần thưởng trong vòng này khi staking với họ. + Collators của bạn + Stake của bạn được chỉ định cho các collator tiếp theo + Collator đang hoạt động nhưng không tạo ra phần thưởng + Collator không có đủ stake để được chọn + Collator sẽ thực thi trong vòng tiếp theo + Đang chờ (%s) + Thanh toán + Thanh toán đã hết hạn + + Còn %d ngày + + Bạn có thể tự trả chúng, khi chúng sắp hết hạn, nhưng bạn sẽ phải trả phí + Phần thưởng được trả mỗi 2-3 ngày bởi nhà xác thực + Tất cả + Tất cả thời gian + Ngày kết thúc luôn là hôm nay + Giai đoạn tùy chỉnh + %dN + Chọn ngày kết thúc + Kết thúc + 6 tháng gần đây (6T) + 6T + 30 ngày gần đây (30N) + 30N + 3 tháng gần đây (3T) + 3T + Chọn ngày + Chọn ngày bắt đầu + Bắt đầu + Hiển thị phần thưởng staking cho + 7 ngày gần đây (7N) + 7N + Năm trước (1T) + 1T + Số dư khả dụng của bạn là %s, bạn cần để lại %s làm số dư tối thiểu và trả phí mạng là %s. Bạn có thể stake không quá %s. + Các quyền được ủy quyền (proxy) + Khe hàng chờ hiện tại + Khe hàng chờ mới + Trở về stake + Tất cả unstaking + Token trả về sẽ được tính từ kỳ sau + Số lượng tùy chỉnh + Số tiền bạn muốn trở về stake lớn hơn số dư unstaking + Unstaked gần nhất + Có lợi nhất + Không vượt quá hạn mức + Có danh tính trên chuỗi + Không bị slashed + Giới hạn 2 validator mỗi danh tính + với ít nhất một liên hệ danh tính + Validator đề xuất + Validator + Phần thưởng ước tính (APY) + Redeem + Có thể redeem: %s + Phần thưởng + Điểm đến phần thưởng + Phần thưởng có thể chuyển + Kỳ + Chi tiết phần thưởng + Validator + Thu nhập với restake + Thu nhập không có restake + Hoàn hảo! Tất cả các phần thưởng đều đã được trả. + Tuyệt vời! Bạn không có phần thưởng chưa trả nào + Thanh toán tất cả (%s) + Phần thưởng đang chờ xử lý + Phần thưởng chưa trả + %s phần thưởng + Về phần thưởng + Phần thưởng (APY) + Đích đến phần thưởng + Chọn tự động + Chọn tài khoản nhận thanh toán + Chọn được đề xuất + đã chọn %d (tối đa %d) + Validators (%d) + Cập nhật Controller đến Stash + Sử dụng Proxies để ủy quyền các hoạt động Staking cho tài khoản khác + Các tài khoản Controller đang bị loại bỏ + Chọn một tài khoản khác làm controller để ủy quyền các hoạt động quản lý staking cho tài khoản đó + Cải thiện bảo mật staking + Đặt validators + Các Validators chưa được chọn + Chọn validators để bắt đầu staking + Stake tối thiểu được khuyến nghị để nhận phần thưởng đều đặn là %s. + Bạn không thể stake ít hơn giá trị tối thiểu của mạng (%s) + Stake tối thiểu phải lớn hơn %s + Restake + Restake phần thưởng + Sử dụng phần thưởng của bạn như thế nào? + Chọn loại phần thưởng của bạn + Tài khoản thanh toán + Slash + Stake %s + Stake tối đa + Thời gian staking + Loại staking + Bạn nên tin tưởng các nominations của mình hành động một cách khôn ngoan và trung thực, chỉ dựa vào lợi nhuận hiện tại của họ có thể dẫn đến lợi nhuận giảm hoặc thậm chí mất vốn. + Chọn lựa validators của bạn cẩn thận, vì họ nên hành động một cách chuyên nghiệp và trung thực. Chỉ quyết định dựa trên lợi nhuận có thể dẫn đến giảm phần thưởng hoặc thậm chí mất stake + Stake với các validators của bạn + Ví Pezkuwi sẽ chọn những người xác thực hàng đầu dựa trên tiêu chí bảo mật và lợi nhuận + Stake với những người xác thực được khuyến nghị + Bắt đầu staking + Stash + Tài khoản stash có thể bond thêm và cài đặt controller. + Tài khoản stash được sử dụng để: stake thêm và cài đặt controller + Tài khoản stash %s không khả dụng để cập nhật cài đặt staking. + Người đề cử kiếm thu nhập thụ động bằng cách khóa token của mình để bảo vệ mạng lưới. Để đạt được điều đó, người đề cử nên chọn một số lượng người xác thực để hỗ trợ. Người đề cử nên cẩn thận khi chọn người xác thực. Nếu người xác thực được chọn không hoạt động đúng, hình phạt slashing sẽ được áp dụng cho cả hai, tùy theo mức độ nghiêm trọng của sự cố. + Pezkuwi Wallet cung cấp hỗ trợ cho những người đề cử bằng cách giúp họ chọn các validator. Ứng dụng di động lấy dữ liệu từ blockchain và tạo danh sách các validator có: lợi nhuận cao nhất, danh tính với thông tin liên lạc, không bị xử phạt và sẵn sàng nhận các đề cử. Pezkuwi Wallet cũng quan tâm đến phân quyền, vì vậy nếu một người hoặc một công ty chạy nhiều node validator, chỉ tối đa 2 node từ họ sẽ được hiển thị trong danh sách đề xuất. + Ai là người đề cử? + Phần thưởng cho Staking có thể được thanh toán vào cuối mỗi kỳ (6 giờ trong Kusama và 24 giờ trong Polkadot). Mạng lưu trữ phần thưởng chờ trong 84 kỳ và trong hầu hết các trường hợp, các validator sẽ trả phần thưởng cho tất cả mọi người. Tuy nhiên, các validator có thể quên hoặc có chuyện gì đó xảy ra với họ, vì vậy người đề cử có thể tự thanh toán phần thưởng của họ. + Mặc dù các phần thưởng thường được phân phối bởi các validator, Pezkuwi Wallet giúp thông báo nếu có bất kỳ phần thưởng nào chưa được thanh toán mà sắp hết hạn. Bạn sẽ nhận được thông báo về điều này và các hoạt động khác trên màn hình Staking. + Nhận thưởng + Staking là một lựa chọn để kiếm thu nhập thụ động bằng việc khóa các token của bạn trong mạng lưới. Phần thưởng từ việc Staking sẽ được phân bổ mỗi kỷ nguyên (6 giờ trên Kusama và 24 giờ trên Polkadot). Bạn có thể Staking bao lâu tùy ý, và để Unstake các token của mình bạn cần chờ cho đến khi hết thời gian Unstaking, làm cho các token của bạn sẵn sàng để đổi lấy. + Staking là một phần quan trọng của bảo mật và độ tin cậy của mạng lưới. Bất cứ ai cũng có thể chạy các node xác thực, nhưng chỉ những ai có đủ token Staked mới được mạng lưới chọn để tham gia vào việc tạo lập các khối mới và nhận phần thưởng. Các validator thường không đủ token, vì vậy các Nominator sẽ giúp họ bằng cách khóa token của mình để đạt được số lượng Stake cần thiết. + Staking là gì? + Người xác thực vận hành một node blockchain 24/7 và cần phải có đủ stake bị khóa (cả sở hữu và được cung cấp bởi những người đề danh) để được bầu chọn bởi mạng. Người xác thực nên duy trì hiệu suất và độ tin cậy của các node của họ để được nhận phần thưởng. Trở thành người xác thực gần như là một công việc toàn thời gian, có những công ty chuyên tập trung trở thành người xác thực trên các mạng blockchain. + Mọi người đều có thể trở thành người xác thực và vận hành một node blockchain, nhưng điều đó đòi hỏi một mức độ kỹ năng kỹ thuật và trách nhiệm nhất định. Mạng Polkadot và Kusama có một chương trình mang tên Thousand Validators Programme để hỗ trợ cho người mới bắt đầu. Hơn nữa, mạng lưới luôn thưởng nhiều hơn cho những người xác thực có ít stake hơn (nhưng đủ để được bầu chọn) để cải thiện tính phi tập trung. + Ai là người xác thực? + Chuyển tài khoản của bạn sang stash để thiết lập bộ điều khiển. + Staking + Staking của %s + Đã được thưởng + Tổng số staked + Ngưỡng Boost + Dành cho collator của tôi + không có Yield Boost + với Yield Boost + để tự động stake %s tất cả các token có thể chuyển nhượng của tôi trên + để tự động stake %s (trước: %s) tất cả các token có thể chuyển nhượng của tôi trên + Tôi muốn stake + Tăng năng suất + Loại Staking + Bạn đang unstaking tất cả token của mình và không thể stake thêm. + Không thể stake thêm + Khi unstaking một phần, bạn phải để lại ít nhất %s trong stake. Bạn có muốn thực hiện unstake hoàn toàn bằng cách unstake phần còn lại %s không? + Số tiền còn lại trong stake quá nhỏ + Số tiền bạn muốn unstake lớn hơn số dư đã staked + Unstake + Các giao dịch unstaking sẽ xuất hiện ở đây + Các giao dịch unstaking sẽ được hiển thị ở đây + Unstaking: %s + Token của bạn sẽ có thể rút được sau thời gian unstaking. + Bạn đã đạt đến giới hạn yêu cầu unstaking (%d yêu cầu đang hoạt động). + Đạt giới hạn yêu cầu unstaking + Thời gian unstaking + Unstake tất cả + Unstake tất cả? + Phần thưởng ước tính (%% APY) + Phần thưởng ước tính + Thông tin về người xác thực + Đăng ký quá mức. Bạn sẽ không nhận được phần thưởng từ người xác thực trong thời kỳ này. + Người đề cử + Đăng ký quá mức. Chỉ những người đề cử có Staked cao nhất mới nhận được phần thưởng. + Riêng + Không có kết quả tìm kiếm.\nHãy chắc chắn rằng bạn đã nhập đầy đủ địa chỉ tài khoản + Người xác thực bị phạt vì hành vi sai trái (ví dụ: ngoại tuyến, tấn công mạng, hoặc dùng phần mềm đã được sửa đổi) trong mạng lưới. + Tổng cộng Staked + Tổng cộng Staked (%s) + Phần thưởng nhỏ hơn phí mạng. + Hàng năm + Stake của bạn được phân bổ cho các người xác thực sau. + Stake của bạn được phân bổ cho những người xác thực tiếp theo + Đã được bầu (%s) + Những người xác thực không được bầu trong thời kỳ này. + Những người xác thực không đủ Stake để được bầu + Những người khác, những người hoạt động mà không có phân bổ Stake của bạn. + Những người xác thực hoạt động mà không có phân bổ Stake của bạn + Không được bầu (%s) + Token của bạn được phân bổ cho các validator đã vượt quá hạn mức. Bạn sẽ không nhận được phần thưởng trong kỷ nguyên này. + Phần thưởng của bạn + Stake của bạn + Validator của bạn + Validator của bạn sẽ thay đổi trong kỷ nguyên tiếp theo. + Bây giờ hãy sao lưu ví của bạn. Điều này đảm bảo rằng tiền của bạn được an toàn. Sao lưu cho phép bạn khôi phục ví của mình bất cứ lúc nào. + Tiếp tục với Google + Nhập tên ví + Ví mới của tôi + Tiếp tục với sao lưu thủ công + Đặt tên cho ví của bạn + Điều này chỉ hiển thị với bạn và bạn có thể chỉnh sửa sau. + Ví của bạn đã sẵn sàng + Bluetooth + USB + Bạn có token khóa trên số dư của mình do %s. Để tiếp tục, bạn nên nhập ít hơn %s hoặc nhiều hơn %s. Để stake số lượng khác, bạn nên loại bỏ khóa %s của mình. + Bạn không thể stake số lượng đã chỉ định + Đã chọn: %d (tối đa %d) + Số dư khả dụng: %1$s (%2$s) + %s với các token staked của bạn + Tham gia quản trị + Stake hơn %1$s và %2$s với các token staked của bạn + tham gia quản trị + Stake bất cứ lúc nào với ít nhất là %1$s. Stake của bạn sẽ tích cực kiếm phần thưởng %2$s + trong %s + Stake bất cứ lúc nào. Stake của bạn sẽ tích cực kiếm phần thưởng %s + Tìm hiểu thêm thông tin về\nstake %1$s tại %2$s + Pezkuwi Wiki + Phần thưởng được tích lũy %1$s. Stake hơn %2$s để tự động trả phần thưởng, nếu không, bạn cần phải đòi phần thưởng thủ công + mỗi %s + Phần thưởng được tích lũy %s + Phần thưởng được tích lũy %s. Bạn cần đòi phần thưởng thủ công + Phần thưởng được tích lũy %s và được thêm vào số dư có thể chuyển khoản + Phần thưởng được tích lũy %s và được thêm lại vào stake + Phần thưởng và trạng thái staking thay đổi theo thời gian. %s thỉnh thoảng + Theo dõi stake của bạn + Bắt đầu Staking + Xem %s + Điều khoản sử dụng + %1$s là %2$s với %3$s + không có giá trị token + mạng thử nghiệm + %1$s\ntrên các token của bạn %2$s\nmỗi năm + Kiếm lên đến %s + Huỷ bỏ bất cứ lúc nào, và rút lại số tiền của bạn %s. Không kiếm được phần thưởng trong khi unstaking + sau %s + Pool bạn đã chọn không hoạt động do không có validator nào được chọn hoặc stake của nó ít hơn mức tối thiểu.\nBạn có chắc chắn muốn tiếp tục với Pool đã chọn không? + Đã đạt đến số lượng nominator tối đa. Thử lại sau + %s hiện không khả dụng + Validators: %d (tối đa %d) + Đã sửa đổi + Mới + Đã xóa + Token để trả phí mạng + Phí mạng được thêm vào trên số tiền đã nhập + Mô phỏng bước hoán đổi đã thất bại + Cặp này không được hỗ trợ + Thất bại trên hoạt động #%s (%s) + %s sang %s hoán đổi trên %s + Chuyển %s từ %s sang %s + + %s hoạt động + + %s của %s hoạt động + Đang hoán đổi %s sang %s trên %s + Đang chuyển %s sang %s + Thời gian thực hiện + Bạn cần giữ ít nhất %s sau khi trả phí mạng %s vì bạn giữ các token không đủ + Bạn cần giữ ít nhất %s để nhận được token %s + Bạn phải có ít nhất %s trên %s để nhận %s token + Bạn có thể hoán đổi tối đa %1$s vì bạn cần trả %2$s cho phí mạng. + Bạn có thể hoán đổi tối đa %1$s vì bạn cần trả phí mạng %2$s và cũng chuyển đổi %3$s sang %4$s để giữ mức tối thiểu %5$s. + Hoán đổi tối đa + Swap tối thiểu + Bạn nên để lại ít nhất %1$s trong số dư của mình. Bạn có muốn thực hiện hoán đổi toàn bộ bằng cách thêm %2$s còn lại không? + Số dư còn lại quá nhỏ + Bạn nên giữ ít nhất %1$s sau khi thanh toán phí mạng %2$s và chuyển đổi %3$s sang %4$s để đạt số dư tối thiểu %5$s.\n\nBạn có muốn thực hiện hoán đổi toàn bộ bằng cách thêm %6$s còn lại không? + Thanh toán + Nhận + Chọn một token + Không đủ token để hoán đổi + Không đủ thanh khoản + Bạn không thể nhận ít hơn %s + Mua ngay %s bằng thẻ tín dụng + Chuyển %s từ mạng khác + Nhận %s bằng QR hoặc địa chỉ của bạn + Nhận %s bằng cách sử dụng + Trong khi hoán đổi thực hiện, số tiền nhận trung gian là %s, thấp hơn số dư tối thiểu %s. Hãy thử chỉ định số tiền hoán đổi lớn hơn. + Độ trượt giá phải được chỉ định trong khoảng %s đến %s + Độ trượt giá không hợp lệ + Chọn một token để thanh toán + Chọn một token để nhận + Nhập số tiền + Nhập số tiền khác + Để thanh toán phí mạng bằng %s, Pezkuwi sẽ tự động hoán đổi %s thành %s để duy trì số dư tối thiểu %s trong tài khoản của bạn. + Một khoản phí mạng được tính bởi blockchain để xử lý và xác nhận bất kỳ giao dịch nào. Có thể thay đổi tùy thuộc vào điều kiện mạng hoặc tốc độ giao dịch. + Chọn mạng để hoán đổi %s + Pool không có đủ thanh khoản để thực hiện hoán đổi + Sự khác biệt về giá đề cập đến sự chênh lệch giá giữa hai tài sản khác nhau. Khi thực hiện hoán đổi trong tiền điện tử, sự khác biệt về giá thường là sự chênh lệch giữa giá của tài sản mà bạn đang hoán đổi và giá của tài sản mà bạn đang hoán đổi. + Sự khác biệt về giá + %s %s + Tỷ giá trao đổi giữa hai loại tiền điện tử khác nhau. Nó thể hiện số lượng của một loại tiền điện tử mà bạn có thể nhận được để đổi lấy một số lượng nhất định của loại tiền điện tử khác. + Tỷ giá + Tỷ giá cũ: %1$s %2$s.\nTỷ giá mới: %1$s %3$s + Tỷ giá hoán đổi đã được cập nhật + Lặp lại thao tác + Lộ trình + Cách mà token của bạn sẽ đi qua các mạng lưới khác nhau để có được token mong muốn. + Hoán đổi + Chuyển + Cài đặt hoán đổi + Trượt giá + Trượt giá hoán đổi là một hiện tượng phổ biến trong giao dịch phi tập trung nơi giá cuối cùng của một giao dịch hoán đổi có thể hơi khác so với giá dự kiến do điều kiện thị trường thay đổi. + Nhập giá trị khác + Nhập một giá trị giữa %s và %s + Độ trượt giá + Giao dịch có thể bị frontrun do độ trượt giá cao. + Giao dịch có thể bị hoàn lại do mức chấp nhận độ trượt giá thấp. + Số lượng %s ít hơn số dư tối thiểu %s + Bạn đang cố gắng hoán đổi số tiền quá nhỏ + Kiềm chế: %s + Đồng ý: %s + Bạn luôn có thể bỏ phiếu cho cuộc trưng cầu này sau + Xóa cuộc trưng cầu %s khỏi danh sách bỏ phiếu? + Một số cuộc trưng cầu không còn khả dụng để bỏ phiếu hoặc bạn có thể không có đủ token để bỏ phiếu. Khả dụng để bỏ phiếu: %s. + Một số cuộc trưng cầu bị loại khỏi danh sách bỏ phiếu + Không thể tải dữ liệu cuộc trưng cầu + Không có dữ liệu + Bạn đã bỏ phiếu cho tất cả các cuộc trưng cầu khả dụng hoặc hiện không có cuộc trưng cầu nào để bỏ phiếu. Quay lại sau. + Bạn đã bỏ phiếu cho tất cả các cuộc trưng cầu khả dụng + Yêu cầu: + Danh sách bỏ phiếu + %d còn lại + Xác nhận phiếu + Không có cuộc trưng cầu để bỏ phiếu + Xác nhận phiếu của bạn + Không có phiếu + Bạn đã thành công bỏ phiếu cho %d cuộc trưng cầu + Bạn không có đủ số dư để bỏ phiếu với sức mạnh hiện tại %s (%sx). Vui lòng thay đổi sức mạnh bỏ phiếu hoặc thêm thêm tiền vào ví của bạn. + Số dư không đủ để bỏ phiếu + Không đồng ý: %s + SwipeGov + Bỏ phiếu cho %d cuộc trưng cầu + Việc bỏ phiếu sẽ được đặt cho các cuộc bỏ phiếu trong tương lai trong SwipeGov + Sức mạnh bỏ phiếu + Staking + + Hôm nay + Liên kết Coingecko cho thông tin giá (Tùy chọn) + Chọn một nhà cung cấp để mua token %s + Phương thức thanh toán, phí và giới hạn khác nhau tùy theo nhà cung cấp.\n So sánh báo giá của họ để tìm tùy chọn tốt nhất cho bạn. + Chọn một nhà cung cấp để bán token %s + Để tiếp tục mua hàng, bạn sẽ được chuyển hướng từ ứng dụng Pezkuwi Wallet tới %s + Tiếp tục trong trình duyệt? + Không có nhà cung cấp nào của chúng tôi hiện đang hỗ trợ mua hoặc bán token này. Vui lòng chọn token khác, mạng khác hoặc kiểm tra lại sau. + Token này không được tính năng mua/bán hỗ trợ + Sao chép hash + Phí + Từ + Hash giao dịch + Chi tiết giao dịch + Xem trong %s + Xem trong Polkascan + Xem trong Subscan + %s vào %s + Lịch sử giao dịch trước đây của bạn %s vẫn còn trên %s + Hoàn thành + Thất bại + Đang chờ xử lý + Kênh thông báo giao dịch + Mua tiền điện tử bắt đầu chỉ với $5 + Bán tiền điện tử bắt đầu chỉ với $10 + Từ: %s + Đến: %s + Chuyển + Giao dịch đến và đi\nsẽ xuất hiện ở đây + Giao dịch của bạn sẽ được hiển thị ở đây + Xóa bỏ phiếu để ủy quyền trong các track này + Các track mà bạn đã ủy quyền bỏ phiếu + Các track không khả dụng + Các track mà bạn đã có bỏ phiếu sẵn + Không hiển thị lại điều này.\nBạn có thể tìm thấy địa chỉ cũ trong Nhận. + Định dạng cũ + Định dạng mới + Một số sàn giao dịch có thể vẫn yêu cầu định dạng cũ\ncho các hoạt động trong khi họ cập nhật. + Địa Chỉ Thống Nhất Mới + Cài đặt + Phiên bản %s + Có cập nhật mới + Để tránh bất kỳ vấn đề nào và cải thiện trải nghiệm người dùng của bạn, chúng tôi khuyến nghị mạnh mẽ rằng bạn nên cài đặt các bản cập nhật gần đây nhất càng sớm càng tốt + Cập nhật quan trọng + Mới nhất + Nhiều tính năng mới tuyệt vời đã có sẵn cho Pezkuwi Wallet! Hãy cập nhật ứng dụng của bạn để truy cập chúng + Cập nhật lớn + Quan trọng + Chính + Xem tất cả các cập nhật có sẵn + Tên + Tên ví + Tên này sẽ chỉ hiển thị cho bạn và được lưu cục bộ trên thiết bị di động của bạn. + Tài khoản này không được mạng lưới bầu chọn để tham gia trong giai đoạn hiện tại + Bỏ phiếu lại + Bỏ phiếu + Trạng thái bỏ phiếu + Thẻ của bạn đang được nạp tiền! + Thẻ của bạn đang được phát hành! + Có thể mất đến 5 phút.\nCửa sổ này sẽ tự động đóng. + Dự đoán %s + Mua + Mua/Bán + Mua tokens + Mua với + Nhận + Nhận %s + Chỉ gửi token %1$s và các token trong mạng %2$s tới địa chỉ này, nếu không bạn có thể mất tiền + Bán + Bán tokens + Gửi + Hoán đổi + Tài sản + Tài sản của bạn sẽ xuất hiện ở đây.\nHãy chắc chắn là bộ lọc \"Ẩn số dư 0\" đã được tắt + Giá trị tài sản + Có sẵn + Staked + Chi tiết số dư + Tổng số dư + Tổng số sau khi chuyển + Đóng băng + Đã khóa + Có thể thu hồi + Đã dự trữ + Có thể chuyển + Unstaking + Ví tiền + Kết nối mới + + Các tài khoản %s bị thiếu. Thêm tài khoản vào ví trong Cài đặt + + Một số mạng lưới cần thiết được yêu cầu bởi \"%s\" không được hỗ trợ trong Ví Pezkuwi + Các phiên Wallet Connect sẽ xuất hiện ở đây + WalletConnect + dApp không xác định + + Các mạng lưới %s không được hỗ trợ đã bị ẩn + + WalletConnect v2 + Chuyển liên chuỗi + Tiền điện tử + Tiền fiat + Các loại tiền fiat phổ biến + Tiền tệ + Chi tiết giao dịch + Ẩn tài sản có số dư bằng 0 + Các giao dịch khác + Hiển thị + Phần thưởng và Hình phạt + Hoán đổi + Bộ lọc + Chuyển khoản + Quản lý tài sản + Làm thế nào để thêm ví? + Làm thế nào để thêm ví? + Làm thế nào để thêm ví? + Ví dụ về tên: Tài khoản chính, Trình xác thực của tôi, Dotsama crowdloans, v.v. + Chia sẻ QR này cho người gửi + Để người gửi quét mã QR này + Địa chỉ %s của tôi để nhận %s: + Chia sẻ mã QR + Người nhận + Đảm bảo rằng địa chỉ từ mạng đúng + Định dạng địa chỉ không hợp lệ. Đảm bảo rằng địa chỉ thuộc mạng đúng + Số dư tối thiểu + Do hạn chế chuỗi chéo bạn chỉ có thể chuyển không hơn %s + Bạn không có đủ số dư để trả phí chuỗi chéo là %s.\nSố dư còn lại sau khi chuyển: %s + Phí chuỗi chéo được thêm vào tổng số tiền đã nhập. Người nhận có thể nhận một phần phí chuỗi chéo + Xác nhận chuyển tiền + Chuỗi chéo + Phí chuỗi chéo + Chuyển tiền của bạn sẽ thất bại vì tài khoản đích không có đủ %s để chấp nhận các chuyển khoản token khác + Người nhận không thể chấp nhận chuyển tiền + Chuyển tiền của bạn sẽ thất bại vì số tiền cuối cùng trong tài khoản đích sẽ ít hơn số dư tối thiểu. Vui lòng thử tăng số tiền lên. + Chuyển tiền của bạn sẽ xóa tài khoản khỏi chuỗi khối vì tổng số dư sẽ thấp hơn số dư tối thiểu. + Tài khoản của bạn sẽ bị xóa khỏi chuỗi khối sau khi chuyển tiền vì nó làm cho tổng số dư thấp hơn mức tối thiểu. + Chuyển tiền sẽ xóa tài khoản + Tài khoản của bạn sẽ bị xóa khỏi chuỗi khối sau khi chuyển tiền vì nó làm cho tổng số dư thấp hơn mức tối thiểu. Số dư còn lại cũng sẽ được chuyển cho người nhận. + Từ mạng + Bạn cần có ít nhất %s để trả phí giao dịch này và duy trì trên mức cân bằng mạng tối thiểu. Số dư hiện tại của bạn là: %s. Bạn cần thêm %s vào số dư của mình để thực hiện thao tác này. + Chính tôi + Trên chuỗi + Địa chỉ sau: %s được biết là đã sử dụng trong các hoạt động lừa đảo, vì vậy chúng tôi không khuyến nghị gửi token đến địa chỉ đó. Bạn có muốn tiếp tục không? + Cảnh báo lừa đảo + Người nhận đã bị chặn bởi chủ sở hữu token và hiện không thể chấp nhận các chuyển khoản đến + Người nhận không thể chấp nhận chuyển khoản + Mạng lưới người nhận + Đến mạng lưới + Gửi %s từ + Gửi %s trên + đến + Người gửi + Token + Gửi đến liên hệ này + Chi tiết chuyển khoản + %s (%s) + Địa chỉ %s cho %s + Pezkuwi phát hiện các vấn đề liên quan đến tính toàn vẹn của thông tin về địa chỉ %1$s. Vui lòng liên hệ với chủ sở hữu %1$s để giải quyết các vấn đề liên quan đến tính toàn vẹn. + Kiểm tra tính toàn vẹn không thành công + Người nhận không hợp lệ + Không tìm thấy địa chỉ hợp lệ cho %s trên mạng %s + %s không tìm thấy + Dịch vụ %1$s w3n không khả dụng. Thử lại sau hoặc nhập thủ công địa chỉ %1$s + Lỗi khi giải quyết w3n + Pezkuwi không thể giải quyết mã cho token %s + Token %s chưa được hỗ trợ + Hôm qua + Yield Boost sẽ được tắt cho các collator hiện tại. Collator mới: %s + Thay đổi Collator có Boost Yield? + Bạn không có đủ số dư để trả phí mạng là %s và phí thực hiện Yield Boost là %s.\nSố dư khả dụng để trả phí: %s + Không đủ token để trả phí thực hiện đầu tiên + Bạn không có đủ số dư để trả phí mạng là %s và không giảm xuống dưới ngưỡng %s.\nSố dư khả dụng để trả phí: %s + Không đủ token để ở trên ngưỡng + Thời gian tăng Stake + Yield Boost sẽ tự động stake %s tất cả token có thể chuyển được của tôi trên %s + Yield Boosted + + + Cầu nối DOT ↔ HEZ + Cầu nối DOT ↔ HEZ + Bạn gửi + Bạn nhận (ước tính) + Tỷ giá + Phí cầu nối + Tối thiểu + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Đổi + Giao dịch HEZ→DOT được xử lý khi thanh khoản DOT đủ. + Số dư không đủ + Số tiền dưới mức tối thiểu + Nhập số tiền + Giao dịch HEZ→DOT có thể bị hạn chế tùy thuộc vào thanh khoản. + Giao dịch HEZ→DOT tạm thời không khả dụng. Vui lòng thử lại khi thanh khoản DOT đủ. + diff --git a/common/src/main/res/values-zh-rCN/strings.xml b/common/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..703db25 --- /dev/null +++ b/common/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,2033 @@ + + + 联系我们 + Github + 隐私政策 + 给我们评分 + Telegram + 条款和条件 + 条款与条件 + 关于 + 应用版本 + 网站 + 输入有效的%s地址… + EVM地址必须有效或为空… + 输入有效的substrate地址… + 添加账户 + 添加地址 + 账户已存在。请尝试另一个。 + 通过其地址跟踪任何钱包 + 添加仅观察钱包 + 写下你的密码短语 + 请确保您的短语正确且清晰地写下。 + %s地址 + 在%s上无账户 + 确认助记词 + 让我们再次检查一下 + 按正确的顺序选择单词 + 创建一个新账户 + 不要在您的移动设备上使用剪贴板或截图,请尝试寻找备份的安全方法(例如纸张) + 名称将仅在此应用中本地使用。您可以稍后编辑它 + 创建钱包名称 + 备份助记词 + 创建新钱包 + 助记词用于恢复对账户的访问。请将其写下来,没有它我们将无法恢复您的账户! + 带有更改密钥的账户 + 忘记 + 继续之前,请确保您已导出您的钱包。 + 忘记钱包? + 以太坊派生路径无效 + Substrate派生路径无效 + 此钱包与%1$s配对。Pezkuwi将帮助您形成任何您想要的操作,并且您将被请求使用%1$s签名它们 + 不支持%s + 这是仅查看钱包,Pezkuwi可以显示您的余额和其他信息,但您不能使用此钱包进行任何交易 + 输入钱包昵称... + 请尝试另一个。 + Ethereum密钥对加密类型 + Ethereum密钥派生路径 + EVM账户 + 导出账户 + 导出 + 导入现有账户 + 这是加密数据并保存JSON文件所必需的。 + 设置一个新密码 + 保存您的密钥并将其存放在安全的地方 + 记录您的密钥并将其存放在安全的地方 + 恢复json无效。请确保输入包含有效的json。 + Seed无效。请确保您的输入包含64个十六进制符号。 + JSON不包含网络信息。请在下面指定。 + 提供您的恢复JSON + 通常是12个单词短语(但可能是15、18、21或24) + 单词之间用一个空格分开,没有逗号或其他符号 + 按正确顺序输入单词 + 密码 + 导入私钥 + 0xAB + 输入您的原始seed + Pezkuwi与所有应用程序兼容 + 导入钱包 + 账户 + 您的派生路径包含不支持的符号或结构不正确 + 无效的派生路径 + JSON文件 + 确保已安装%s到您的 Ledger 设备,使用 Ledger Live 应用程序 + Polkadot 应用程序 + 在 Ledger 设备上%s + 打开 Polkadot 应用程序 + Flex, Stax, Nano X, Nano S Plus + Ledger(通用 Polkadot 应用) + 确保通过Ledger Live应用把网络应用安装到您的Ledger设备上。在您的Ledger设备上打开网络应用。 + 至少添加一个账户 + 将账户添加到您的钱包 + Ledger 连接指南 + 确保%s到您的Ledger设备中,使用Ledger Live应用 + Network应用程序已安装 + 在您的Ledger设备上%s + 打开network应用程序 + 允许Pezkuwi Wallet%s + 访问蓝牙 + %s添加到钱包 + 选择账户 + %s 在你的手机设置中 + 启用 OTG + 连接 Ledger + 要进行操作签名并将您的账户迁移到新的 Generic Ledger 应用程序,请安装并打开 Migration 应用程序。旧版 Legacy 和 Migration Ledger 应用程序将来不会被支持。 + 新版 Ledger 应用程序已发布 + Polkadot Migration + Migration 应用程序在不久的将来将无法使用。使用它将您的账户迁移到新的 Ledger 应用程序,以免丢失资金。 + Polkadot + Ledger Nano X + 账本传承 + 如果使用通过蓝牙连接的 Ledger,请在两个设备上启用蓝牙,并授予 Pezkuwi 蓝牙和位置权限。如需使用 USB,请在手机设置中启用 OTG。 + 请在手机设置和Ledger设备中启用蓝牙。解锁您的Ledger设备并打开%s应用。 + 选择您的Ledger设备 + 请启用 Ledger 设备。解锁你的 Ledger 设备并打开 %s 应用。 + 您快到了!🎉\n 只需点击下面即可完成设置,并在 Polkadot App 和 Pezkuwi Wallet 中无缝使用您的账户 + 欢迎来到 Pezkuwi! + 12、15、18、21或24个单词的短语 + 多重签名 + 共享控制(多重签名) + 您在这个网络上没有账户,您可以创建或导入账户。 + 需要账户 + 没有找到账户 + 配对公钥 + Parity 签名器 + %s不支持%s + 以下账户已经成功从%s读取 + 这里是您的账户 + 无效的QR码,请确保您正在扫描来自%s的QR码 + 请确保选择最上面的一个 + 您智能手机上的 %s 应用程序 + 打开Parity Signer + %s您想要添加到 Pezkuwi 钱包 + 前往“Keys”标签。选择种子,然后账户 + Parity Signer将为您提供%s + QR 码扫描 + 从%s添加钱包 + %s不支持签名任意消息 — 只有交易 + 不支持签名 + 从%s扫描QR码 + 传统 + 新的 (Vault v7+) + 我在%s中有一个错误 + QR码已过期 + 出于安全原因,生成的操作仅在%s内有效。\n请生成新的QR码并用%s签名 + QR码有效期为%s + 请确保您正在扫描当前签名操作的二维码 + 使用 %s 签名 + Polkadot Vault + 注意,衍生路径名称应为空 + 您智能手机上的 %s 应用程序 + 打开Polkadot Vault + %s 您想要添加到 Pezkuwi 钱包 + 点击派生密钥 + Polkadot Vault 将提供给您%s + QR 码扫描 + 点击右上角的图标并选择 %s + 导出私钥 + 私钥 + 代理 + 代理给你(代理) + 任何 + 拍卖 + 取消代理 + 治理 + 身份判断 + 提名池 + 非转移 + 质押 + 已有账户 + 密钥 + 64 个十六进制符号 + 选择硬件钱包 + 选择您的秘密类型 + 选择钱包 + 具有共享秘密的账户 + Substrate账户 + Substrate 密钥对加密类型 + Substrate 秘密衍生路径 + 钱包名称 + 钱包昵称 + Moonbeam、Moonriver 以及其他网络 + EVM 地址(可选) + 预设钱包 + Polkadot、Kusama、Karura、KILT 以及 50+ 网络 + 跟踪任何钱包的活动,无需将您的私钥输入到Pezkuwi Wallet + 您的钱包是仅查看的,这意味着您不能对其进行任何操作 + 哎呀!缺少密钥 + 仅查看 + 使用Polkadot Vault、Ledger或Parity Signer + 连接硬件钱包 + 在 Pezkuwi 中使用您的 Trust Wallet 账户 + Trust Wallet + 添加%s帐户 + 添加钱包 + 更改%s帐户 + 更改帐户 + Ledger(旧版) + 委托给您 + 共享控制 + 添加自定义节点 + 您需要添加一个%s帐户到钱包才能委托 + 输入网络详情 + 委托给 + 委托帐户 + 委托钱包 + 授予访问类型 + 存款在您的帐户上保留,直到代理被删除。 + 您已达到在%s中添加的代理的上限%s。移除代理以添加新代理。 + 已达到代理最大数量 + 添加的自定义网络会显示在这里 + +%d + Pezkuwi 已自动切换到您的多重签名钱包,以便您查看待处理的交易。 + 彩色图标 + 外观 + 代币图标 + 白色图标 + 输入的合约地址已作为%s代币存在于Pezkuwi中。 + 输入的合约地址已作为%s代币存在于Pezkuwi中。您确定要修改它吗? + 该代币已存在 + 请确保提供的网址格式为:www.coingecko.com/en/coins/tether。 + CoinGecko链接无效 + 输入的合约地址不是%s ERC-20合约。 + 合约地址无效 + 小数点后数字必须至少为0,且不超过36。 + 小数值无效 + 输入合约地址 + 输入小数 + 输入符号 + 前往 %s + 从 %1$s 开始,您的 %2$s 余额、质押和治理将移至 %3$s — 提升性能,降低成本。 + 您的 %s 代币现已在 %s 上 + 网络 + 代币 + 添加代币 + 合约地址 + 小数点 + 符号 + 输入 ERC-20 代币详情 + 选择网络以添加 ERC-20 代币 + 众贷 + 治理 v1 + 开放治理 + 选举 + 质押 + 归属 + 购买代币 + 从众贷中收回你的 DOT 了吗?今天开始质押你的 DOT,以获得最大可能的奖励! + 提升你的 DOT 🚀 + 筛选代币 + 您没有可赠送的代币。\n购买或存入代币到您的账户。 + 所有网络 + 代币管理 + 不要将 %s 转账至 Ledger 控制的账户,因为 Ledger 不支持 %s 的发送,所以资产将被卡在这个账户上 + Ledger 不支持此代币 + 按网络或代币搜索 + 没有找到输入名称的网络或代币 + 按代币搜索 + 你的钱包 + 你没有要发送的代币。购买或接收代币到你的账户。 + 要支付的代币 + 要接收的代币 + 在进行更改之前,%s 修改和删除的钱包! + 确保你已经保存了密码短语 + 应用备份更新? + 准备保存你的钱包! + 这个密码短语让你对所有已连接的钱包和其中的资金拥有完全的永久访问权。\n%s + 不要分享。 + 不要在任何表单或网站中输入您的密码短语。\n%s + 资金可能会永远丢失。 + 支持人员或管理员在任何情况下都不会请求您的密码短语。\n%s + 提防冒名顶替者。 + 查看并接受以继续 + 删除备份 + 您可以手动备份您的密码短语,以确保在丢失对设备的访问时能够访问您的钱包资金。 + 手动备份 + 手动 + 您没有添加任何带有密码短语的钱包。 + 没有可以备份的钱包 + 您可以启用Google Drive备份来存储加密的所有钱包副本,并由您设置的密码保护。 + 备份到Google云端硬盘 + Google云端硬盘 + 备份 + 备份 + 简单高效的KYC流程 + 生物认证 + 关闭所有 + 购买已启动!请等待最多 60 分钟。你可以在电子邮件上追踪状态。 + 选择购买%s的网络 + 购买已启动!请等待最多60分钟。您可以在电子邮件中跟踪状态。 + 当前没有供应商支持购买此代币。请选择其他代币、其他网络或稍后再试。 + 买入功能不支持此代币 + 可用任意代币支付费用 + 迁移会自动进行,无需操作 + 旧的交易历史保留在%s上 + 从%1$s您的%2$s余额开始,质押和治理在%3$s上。这些功能可能在长达24小时内不可用。 + %1$sx 交易费率降低(从 %2$s 到 %3$s) + %1$sx 最低余额减少(从 %2$s 到 %3$s) + 是什么让 Asset Hub 如此出色? + 从 %1$s 您的 %2$s 余额开始,质押和治理位于 %3$s + 支持更多代币:%s 和其他生态系统代币 + 统一访问 %s,资产,质押和治理 + 自动平衡节点 + 启用连接 + 请输入用于从云备份恢复钱包的密码。此密码将无法在未来恢复,请务必记住! + 更新备份密码 + 资产 + 可用余额 + 抱歉,余额检查请求失败。请稍后再试。 + 很抱歉,我们无法联系转账提供者。请稍后再试。 + 抱歉,您没有足够的资金支付指定金额 + 转账费 + 网络无响应 + + 哦哦,礼物已经被领取了 + 礼物无法领取 + 领取礼物 + 服务器可能出现问题。请稍后再试。 + 哦哦,出了点问题 + 使用其他钱包,创建新钱包,或在设置中添加%s账户到此钱包。 + 您已成功领取礼物。代币很快会出现在您的余额中。 + 您收到了一份加密礼物! + 创建一个新钱包或导入现有钱包以领取礼物 + 您无法使用%s钱包领取礼物 + DApp浏览器中所有打开的标签页将被关闭。 + 关闭所有DApps? + %s 并且记住始终将它们离线保存,以便随时恢复它们。您可以在备份设置中执行此操作。 + 请在继续之前记下您钱包的所有助记词 + 备份将从Google Drive中删除 + 当前的备份将被永久删除! + 您确定要删除云备份吗? + 删除备份 + 目前您的备份未同步。请查看这些更新。 + 发现云备份更改 + 查看更新 + 如果您未手动写下要删除的钱包的助记词,那么这些钱包及其所有资产将永久且不可恢复地丢失。 + 您确定要应用这些更改吗? + 查看问题 + 目前您的备份未同步。请查看问题。 + 钱包更改未能更新到云备份 + 请确保您已使用正确的凭据登录到您的Google帐户,并且已授予Pezkuwi Wallet对Google Drive的访问权限 + Google Drive 身份验证失败 + 您的 Google Drive 存储空间不足。 + 存储空间不足 + 很遗憾,Google Drive 无法在没有Google Play服务的情况下运行,而您的设备缺少这些服务。请尝试获取 Google Play 服务。 + 未找到 Google Play 服务 + 无法将您的钱包备份到 Google Drive。请确保您已启用 Pezkuwi Wallet 使用您的 Google Drive,并且有足够的可用存储空间,然后重试。 + Google Drive 错误 + 请检查密码正确性并重试。 + 密码无效 + 很遗憾,我们没有找到可用于恢复钱包的备份。 + 未找到备份 + 确保您在继续之前已经保存了钱包的助记词。 + 钱包将被从云备份中移除 + 查看备份错误 + 查看备份更新 + 输入备份密码 + 启用以将钱包备份到你的Google云端硬盘 + 上次同步:%s 于 %s + 登录 Google Drive + 查看 Google Drive 问题 + 备份已禁用 + 备份已同步 + 备份同步中... + 备份未同步 + 新钱包会自动添加到云备份。您可以在设置中禁用云备份。 + 钱包更改将更新到云备份中 + 接受条款... + 账户 + 账户地址 + 活跃 + 添加 + 添加委托 + 添加网络 + 地址 + 高级 + 全部 + 允许 + 金额 + 金额太低 + 金额太大 + 已应用 + 应用 + 再次询问 + 注意! + 可用:%s + 平均 + 余额 + 奖金 + 呼叫 + 调用数据 + 调用哈希 + 取消 + 您确定要取消这项操作吗? + 抱歉,您没有适当的应用程序来处理这个请求 + 无法打开此链接 + 您最多可以使用%s,因为您需要支付\n%s的网络费用。 + + 更改 + 更改密码 + 未来自动继续 + 选择网络 + 清除 + 关闭 + 您确定要关闭此屏幕吗?\n您的更改将不会被应用。 + 云备份 + 已完成 + 已完成(%s) + 确认 + 确认 + 你确定吗? + 已确认 + %d 毫秒 + 连接中... + 请检查您的连接或稍后再试 + 连接失败 + 继续 + 已复制到剪贴板 + 复制地址 + 复制调用数据 + 复制哈希 + 复制ID + 密钥对加密类型 + 日期 + %s和%s + 删除 + 存款人 + 详细信息 + 已禁用 + 断开 + 不要关闭应用程序! + 完成 + Pezkuwi 会提前模拟交易以防止错误。此次模拟未成功。稍后再试或尝试更高金额。如果问题仍然存在,请在设置中联系 Pezkuwi Wallet 支持。 + 交易模拟失败 + 编辑 + %s(+%s更多) + 选择电子邮件应用程序 + 启用 + 输入地址… + 输入金额… + 输入详情 + 输入其他金额 + 输入密码 + 错误 + 代币不足 + 事件 + EVM + EVM 地址 + 此操作后,如果您的总余额低于最低余额,您的账户将从区块链中移除 + 操作将移除账户 + 已过期 + 探索 + 失败 + 预估的网络费用%s远高于默认的网络费用(%s)。这可能是由于临时网络拥堵。您可以刷新以等待更低的网络费用。 + 网络费用过高 + 费用: %s + 排序依据: + 过滤器 + 了解更多 + 忘记密码? + + 每%s天 + + 每天 + 每天 + 完整详情 + 获取%s + 礼物 + 知道了 + 治理 + 十六进制字符串 + 隐藏 + + %d小时 + + 它是如何工作的 + 我明白了 + 信息 + 二维码无效 + 无效的二维码 + 了解更多 + 了解更多关于 + 账本 + %s 剩余 + 管理钱包 + 最大 + %s 最大值 + + %d 分钟 + + %s 账户缺失 + 修改 + 模块 + 名称 + 网络 + Ethereum + %s 不支持 + Polkadot + 网络 + + 网络 + + 下一步 + + 设备上未发现文件导入应用。请安装后重试 + 设备上未发现适合处理此意图的应用 + 无变化 + 我们即将显示您的助记词。确保没有人能看到您的屏幕并且不要截屏 —— 它们可能被第三方恶意软件收集 + + 不可用 + 抱歉,您没有足够的资金支付网络费用。 + 余额不足 + 你的余额不足以支付 %s 的网络费用。当前余额为 %s + 暂不 + 关闭 + 确定 + 好的,返回 + 开启 + 进行中 + 可选的 + 选择一个选项 + 助记词 + 粘贴 + / 年 + %s / 年 + 每年 + %% + 请求的权限是使用此屏幕所必需的。您应该在设置中启用它们。 + 权限被拒绝 + 请求的权限是使用此屏幕所必需的。 + 需要权限 + 价格 + 隐私政策 + 继续 + 代理存款 + 撤销访问 + 推送通知 + 阅读更多 + 推荐 + 刷新费用 + 拒绝 + 移除 + 必需的 + 重置 + 重试 + 出了些问题。请再试一次 + 撤销 + 保存 + 扫描二维码 + 搜索 + 搜索结果将在这里显示 + 搜索结果:%d + + + %d 秒 + + 密钥派生路径 + 查看全部 + 选择代币 + 设置 + 分享 + 分享调用数据 + 分享哈希 + 显示 + 登录 + 签名请求 + 签署人 + 签名无效 + 跳过 + 跳过过程 + 提交某些交易时出错。您想要重试吗? + 提交某些交易失败 + 出了些问题 + 排序方式 + 状态 + Substrate + Substrate 地址 + 点击显示 + 条款和条件 + 测试网络 + 剩余时间 + 标题 + 打开设置 + 您的余额太小 + 总计 + 总费用 + 交易ID + 交易已提交 + 再试一次 + 类型 + 请用其他的输入再试一次。如果错误再次出现,请联系客服。 + 未知 + + %s 未支持 + + 无限 + 更新 + 使用 + 使用最大值 + 接收者应该是一个有效的%s地址 + 无效的接收者 + 查看 + 等待中 + 钱包 + 警告 + + 您的礼物 + 金额必须为正 + 请输入您在备份过程中创建的密码 + 输入当前备份密码 + 确认将从您的账户转移代币 + 选择单词... + 无效的助记词,请再次检查单词顺序 + 公投 + 投票 + 追踪 + 节点已经之前被添加。请试用另一个节点。 + 无法与节点建立连接。请尝试另一个。 + 不幸的是,网络不支持。请尝试以下之一:%s。 + 确认删除%s。 + 删除网络? + 请检查您的连接或稍后再试 + 自定义 + 默认 + 网络 + 添加连接 + 扫描二维码 + 已检测到您的备份出现问题。您可以选择删除当前备份并创建新备份。在继续之前 %s。 + 确保您已保存所有钱包的密码短语 + 找到备份但为空或已损坏 + 将来,如果没有备份密码,将无法从云备份中恢复您的钱包。\n%s + 此密码无法恢复。 + 记住备份密码 + 确认密码 + 备份密码 + 字母 + 最少8个字符 + 数字 + 密码匹配 + 请输入密码以随时访问您的备份。密码无法恢复,请务必记住! + 创建备份密码 + 输入的链 ID 与 RPC URL 中的网络不匹配。 + 无效的链 ID + 私有众贷暂不支持。 + 私有众筹 + 关于众贷 + 直接 + 了解Acala不同贡献类型的更多信息 + 流动性贡献 + 活跃 (%s) + 同意条款和条件 + Pezkuwi Wallet 奖金 (%s) + Astar 推荐码应为有效的Polkadot地址 + 由于结果增加的金额将超过众贷上限,无法贡献选定金额。允许的最大贡献为 %s。 + 由于已达到上限,无法对选定的众贷进行贡献。 + 众贷上限超出 + 对众贷进行贡献 + 贡献 + 你已贡献:%s + 流动性 + 并行 + 你的贡献\n将显示在这里 + 将在 %s 内返回 + 将由平行链返回 + %s (通过 %s) + 众贷 + 获得特别奖励 + 众贷将显示在这里 + 由于已经结束,无法对选定的众贷进行贡献。 + 众贷已结束 + 输入你的推荐码 + 众贷信息 + 了解 %s 的众贷 + %s 的众贷网站 + 租赁期 + 选择要贡献您的%s的平行链。您将收回您贡献的代币,如果平行链赢得了一个插槽,您将在拍卖结束后收到奖励 + 您需要在钱包中添加一个%s账户以便贡献 + 应用奖金 + 如果您没有推荐码,您可以应用Pezkuwi推荐码以接收您贡献的奖金 + 您未应用奖金 + Moonbeam众筹仅支持SR25519或ED25519加密类型账户。请考虑使用另一个账户进行贡献 + 无法使用此账户贡献 + 您应该添加Moonbeam账户到钱包,以参与Moonbeam众筹 + 缺少Moonbeam账户 + 此众筹在您的位置不可用。 + 您的地区不受支持 + %s奖金去向 + 提交同意 + 为了继续参与,您需要在区块链上提交同意《条款与条件》的协议。这只需要为所有后续的Moonbeam贡献做一次。 + 我已阅读并同意条款和条件 + 已筹集 + 推荐代码 + 推荐码无效,请尝试另一个 + %s的条款和条件 + 允许贡献的最低金额是%s。 + 贡献金额太小 + 租赁期结束后,您的%s代币将被退还。 + 您的贡献 + 已筹集:%s / %s + 输入的 RPC URL 已作为 %s 自定义网络存在于 Pezkuwi。确定要修改它吗? + https://networkscan.io + 区块浏览器 URL(可选) + 012345 + 链 ID + TOKEN + 货币符号 + 网络名称 + 添加节点 + 为...添加自定义节点 + 输入详情 + 保存 + 编辑自定义节点 + 名称 + 节点名称 + wss:// + 节点 URL + RPC URL + DApps,当您使用它们时,允许访问看到您的地址 + “%s”DApp将从授权中移除 + 从授权中删除? + 授权的DApps + 目录 + 如果您信任该应用,请批准此请求 + 允许“%s”访问您的账户地址? + 如果您信任此应用,请批准此请求。\n检查交易详情。 + DApp + DApps + %d DApps + 收藏夹 + 收藏夹 + 添加到收藏夹 + “%s”DApp将从收藏夹中移除 + 从收藏夹中移除? + DApps列表将出现在这里 + 添加到收藏夹 + 桌面模式 + 从收藏夹中移除 + 页面设置 + 好的,带我回去 + Pezkuwi钱包认为这个网站可能会危害您账户和代币的安全 + 检测到钓鱼 + 按名称搜索或输入URL + 不支持的链,创世哈希%s + 确保操作是正确的 + 未能签署所请求的操作 + 仍然打开 + 恶意的 DApps 可以提取你所有的资金。使用 DApp、授予权限或转出资金前请务必自行研究。\n\n如果有人迫使你访问这个 DApp,很可能是骗局。如有疑问,请联系 Pezkuwi Wallet 支持:%s。 + 警告!DApp 未知 + 未找到链 + 链接%s中的域名不被允许 + 未指定治理类型 + 不支持的治理类型 + 无效的加密类型 + 无效的派生路径 + 无效的助记词 + 无效的URL + 输入的 RPC URL 已作为 %s 网络存在于 Pezkuwi。 + 默认通知渠道 + +%d + 通过地址或名称搜索 + 地址格式无效。请确保地址属于正确的网络 + 搜索结果:%d + 为您自动检测并组织代理和多重签名账户。可以随时在设置中管理。 + 钱包列表已更新 + 所有时间的投票 + 代理 + 所有账户 + 个人 + 组织 + 撤销委托后将开始撤销期 + 您的投票将自动与您的代表的投票一起投票 + 代表信息 + 个人 + 组织 + 代理投票 + 委托 + 编辑委派 + 您不能委托给自己,请选择另一个地址 + 不能自我委托 + 告诉我们更多关于您自己的信息,以便Pezkuwi用户更好地了解您 + 您是代表吗? + 描述自己 + 跨越 %s 轨道 + 最后投票 %s + 通过 %s 的您的投票 + 您的投票:%s 通过 %s + 移除投票 + 撤销委托 + 取消委托期结束后,您将需要解锁您的代币。 + 委托投票 + 委托 + 最后投票 %s + 轨道 + 选择全部 + 至少选择 1 个轨道... + 没有可供委托的轨道 + Fellowship + 管理 + 财政 + 取消委托期 + 不再有效 + 您的委托 + 您的委托 + 显示 + 正在删除备份... + 您的备份密码已更新。要继续使用云备份,%s + 请输入新备份密码。 + 备份密码已更改 + 您无法签署已禁用网络的交易。请在设置中启用 %s 后重试。 + %s 已禁用 + 您已经向此账户委托了:%s + 委托已存在 + (BTC/ETH 兼容) + ECDSA + ed25519(替代方案) + Edwards + 备份密码 + 输入调用数据 + 代币不足以支付费用 + 合约 + 合约调用 + 功能 + 恢复钱包 + %s 您的所有钱包都将安全备份到Google Drive。 + 你想恢复你的钱包吗? + 找到现有的云备份 + 下载恢复 JSON + 确认密码 + 密码不匹配 + 设置密码 + 网络:%s\n助记词:%s\n推导路径:%s + 网络:%s\n助记词:%s + 请等待费用计算 + 费用计算正在进行中 + 管理借记卡 + 出售 %s 代币 + 为%s质押添加委托 + 兑换详情 + 最大: + 您支付 + 您收到 + 选择一个代币 + 使用%s充值卡 + 请联系support@pezkuwichain.io。包括您用于发卡的电子邮件地址。 + 联系支持 + 已领取 + 创建时间:%s + 输入金额 + 最小礼物为%s + 已追回 + 选择一个代币赠送 + 领取的网络费用 + 创建礼物 + 在Pezkuwi中快速、安全、轻松地发送礼物 + 与任何人、任何地方分享加密礼物 + 您创建的礼物 + 选择%s礼物的网络 + 输入您的礼物金额 + %s作为链接并邀请任何人到Pezkuwi + 直接分享礼物 + %s,您可以随时从此设备返回未领取的礼物 + 礼物可立即使用 + 治理通知渠道 + + 您至少需要选择%d轨道 + + 解锁 + 执行此交换将导致显著的滑点和经济损失。建议减小交易规模或将交易分拆为多笔交易。 + 检测到高价格影响 (%s) + 历史 + 电子邮件 + 法定名字 + Element 名称 + 身份 + 网站 + 所提供的 JSON 文件是为不同网络创建的。 + 请确保您的输入包含有效的 json。 + 恢复 JSON 无效 + 请检查密码正确性并重试。 + 密钥库解密失败 + 粘贴json + 不支持的加密类型 + 不能将含有Substrate秘密的账户导入到以太坊加密网络 + 不能将含有以太坊秘密的账户导入到Substrate加密网络 + 您的助记词无效 + 请确保您的输入包含64个十六进制符号。 + 种子无效 + 很遗憾,没有找到您的钱包备份。 + 未找到备份 + 从Google云端硬盘恢复钱包 + 使用您 12、15、18、21 或 24 个字的短语 + 选择您想如何导入您的钱包 + 仅观看 + 将您构建的网络的所有功能集成到 Pezkuwi Wallet,使每个人都能访问。 + 集成您的网络 + 为 Polkadot 构建? + 您提供的调用数据无效或格式错误。请确保其正确并重试。 + 此调用数据用于另一个调用哈希为 %s 的操作 + 无效的调用数据 + 代理地址应该是一个有效的%s地址 + 代理地址无效 + 输入的货币符号(%1$s)与网络(%2$s)不匹配。您想使用正确的货币符号吗? + 无效的货币符号 + 无法解码QR + QR码 + 从相册上传 + 导出 JSON 文件 + 语言 + Ledger不支持%s + Ledger Flex + Nano S + Nano S Plus + Nano X + Ledger Stax + 操作已被设备取消。请确保您已解锁您的Ledger。 + 操作已取消 + 请在您的Ledger设备上打开%s应用 + %s应用未启动 + 设备上的操作出错已完成。请稍后再试。 + Ledger操作失败 + 在你的 %s 上按住确认按钮以批准交易 + 加载更多账户 + 审核并批准 + 账户 %s + 未找到账户 + 您的Ledger设备正在使用一个过时的通用应用程序,它不支持EVM地址。请通过Ledger Live进行更新。 + 更新Ledger通用应用程序 + 按下您%s上的两个按钮以批准交易 + 请使用Ledger Live应用更新%s + 元数据已过时 + Ledger不支持签名任意消息 — 只支持交易 + 请确保您为当前批准操作选择了正确的Ledger设备 + 出于安全原因,生成的操作仅在%s内有效。请再试一次,并使用Ledger批准 + 交易已过期 + 交易有效期为%s + Ledger不支持此交易。 + 交易不受支持 + 按住你的 %s 上的两个按钮以批准地址 + 按下你的 %s 上的确认按钮以批准地址 + 请按下%s中的两个按钮以批准地址 + 请按下%s中的确认键以批准地址 + 这个钱包与Ledger配对。Pezkuwi将帮助您形成任何您想要的操作,并且您将被要求使用Ledger签名 + 选择要添加到钱包的账户 + 加载网络信息... + 正在加载交易详情… + 正在寻找您的备份... + 支付交易已发送 + 添加代币 + 管理备份 + 删除网络 + 编辑网络 + 管理已添加的网络 + 您将无法在资产屏幕上看到该网络的代币余额。 + 删除网络? + 删除节点 + 编辑节点 + 管理已添加的节点 + 节点“%s”将被删除 + 删除节点? + 自定义密钥 + 默认密钥 + 不要与任何人分享这些信息 — 如果您这样做,您将永久且不可恢复地失去所有资产 + 具有自定义密钥的帐户 + 默认帐户 + %s,+%d 其他 + 具有默认密钥的帐户 + 选择要备份的密钥 + 选择要备份的钱包 + 在查看您的备份之前,请仔细阅读以下内容 + 不要分享您的助记词! + 最佳价格,手续费最高至3.95%% + 确保没有人能够看到你的屏幕\n且不要截图 + 请%s任何人 + 不要分享给 + 请尝试另一个。 + 助记词短语无效,请再次检查单词顺序 + 您不能选择超过%d个钱包 + + 请至少选择%d个钱包 + + 此交易已被拒绝或执行。 + 无法执行此交易 + %s 已经发起相同操作,当前正等待其他签署人签署。 + 操作已存在 + 要管理您的借记卡,请切换到不同类型的钱包。 + 多签不支持借记卡 + 多签存款 + 存款将在发起人的账户上锁定,直到多签操作被执行或拒绝。 + 确保操作正确 + 要创建或管理礼物,请切换到其他类型的钱包。 + 多签钱包不支持赠送功能 + 由 %s 签署并执行。 + ✅ 多重签名交易已执行 + %s 于 %s。 + ✍🏻 请求您的签名 + 由 %s 发起。 + 钱包:%s + 由 %s 签署 + 已收集 %d 中的 %d 个签名。 + 代表 %s。 + 由 %s 拒绝。 + ❌ 多重签名交易被拒绝 + 代表 + %s:%s + 批准 + 批准并执行 + 输入调用数据以查看详细信息 + 交易已执行或被拒绝。 + 签署已结束 + 拒绝 + 签署人 (%d / %d) + 批量全部(错误时推翻) + 批量(执行直到错误) + 强制批量(忽略错误) + 由您创建 + 还没有交易。\n签署请求将显示在这里 + 签署中(%s / %s) + 由您签署 + 未知操作 + 待签署的交易 + 代表: + 由签署人签署 + 多重签名交易已执行 + 请求您的签名 + 多重签名交易被拒绝 + 要将加密货币出售为法币,请切换到不同类型的钱包。 + 多签不支持出售 + 签署人: + %s 没有足够的余额放置多签存款 %s。您需要在余额中添加 %s。 + %s 没有足够的余额支付 %s 的网络费用并放置多签存款 %s。您需要在余额中添加 %s。 + %s 需要至少 %s 才能支付此交易费用并保持在最低网络余额之上。当前余额为:%s + %s 没有足够的余额支付网络费用 %s。您需要在余额中添加 %s。 + 多签钱包不支持签署任意消息 —— 仅限交易 + 交易将会被拒绝。多重签名存款将返还给 %s。 + 多签交易将由 %s 发起。发起人承担网络费用并保留多签存款,一旦交易完成,存款将被解除保留。 + 多重签名交易 + 其他签署者现在可以确认交易。\n您可以在 %s 中跟踪其状态。 + 待签署交易 + 多重签名交易已创建 + 查看详细信息 + 没有初始链上信息(调用数据)的交易被拒绝 + %s 于 %s。\n您无需采取进一步动作。 + 多重签名交易已执行 + %1$s → %2$s + %s 于 %s。\n由 %s 拒绝。\n您无需采取进一步动作。 + 多重签名交易被拒绝 + 从此钱包进行的交易需要多个签署人的批准。您的账户是其中之一的签署人: + 其他签署者: + 阈值 %d 中的 %d + 多重签名交易通知频道 + 在设置中启用 + 接收签署请求、新签名和已完成交易的通知—确保您始终处于控制之中。可随时在设置中管理。 + 多重签名推送通知来了! + 该节点已存在 + 网络费用 + 节点地址 + 节点信息 + 已添加 + 自定义节点 + 默认节点 + 默认 + 正在连接… + 收藏 + 创建者 + %s为%s + %s单位的%s + #%s版次的%s + 无限系列 + 拥有者 + 未上市 + 您的NFT + 您尚未在 Pezkuwi 中添加或选择任何多重签名钱包。添加并至少选择一个以接收多重签名推送通知。 + 没有多重签名钱包 + 您输入的 URL 已作为“%s”节点存在。 + 该节点已存在 + 您输入的节点 URL 无响应或格式不正确。URL 格式应以“wss://”开头。 + 节点错误 + 您输入的 URL 不对应 %1$s 的节点。\n请输入有效的 %1$s 节点 URL。 + 错误的网络 + 领取奖励 + 您的代币将被重新加入到质押中 + 直接 + 质押池信息 + 您的奖励(%s)也将被领取并添加到您的可用余额中 + + 由于池处于销毁状态,无法执行指定操作。它将很快被关闭。 + 池正在销毁 + 您的池目前在解绑队列中没有空闲位置。请在 %s 后再试。 + 太多人正在从您的池中解绑 + 您的池 + 您的池(#%s) + 创建账户 + 创建一个新钱包 + 隐私政策 + 导入账户 + 已有钱包 + 继续访问即表示您同意我们的\n%1$s和%2$s + 使用条款 + 交换 + 您的某个受托人未产生奖励 + 您的某个受托人在当前轮次中未被选中 + 您的 %s 解绑期已过,请不要忘记赎回您的代币 + 无法与此受托人质押 + 更换受托人 + 无法向此受托人增加质押 + 管理受托人 + 所选受托人表示有意停止参与质押 + 您不能为您正在解绑所有代币的受托人增加质押 + 您的质押将低于此受托人的最低质押额(%s) + 剩余质押余额将降低至网络最低值(%s)以下,也将加入到解绑金额中 + 您未被授权。请再试一次。 + 使用生物识别授权 + Pezkuwi Wallet使用生物认证来阻止未经授权的用户访问应用程序。 + 生物测量学 + 密码已成功更改 + 确认您的密码 + 创建密码 + 输入密码 + 设置您的密码 + 由于成员人数已达上限,您无法加入池 + 池已满 + 您无法加入未开放的池。请联系池主。 + 池未开放 + 你不能再同时使用同一个账号进行直接质押和池质押。要管理你的池质押,首先需要从直接质押中取消质押你的代币。 + 池操作不可用 + 流行 + 手动添加网络 + 正在加载网络列表... + 按网络名称搜索 + 添加网络 + %s 于 %s + 1天 + 所有 + 1月 + %s (%s) + %s 价格 + 1周 + 1年 + 所有时间 + 上个月 + 今天 + 上周 + 去年 + 账户 + 钱包 + 语言 + 更改密码 + 使用隐藏余额打开应用 + 通过PIN码确认 + 安全模式 + 设置 + 要管理您的借记卡,请切换到其他具有 Polkadot 网络的钱包。 + 借记卡不支持此代理钱包 + 该账户已授权以下账户执行交易: + 赌注操作 + 委托账户%s的余额不足以支付%s的网络费用。可用余额支付费用:%s + 代理钱包不支持签署任意消息 - 仅支持交易 + %1$s未委托%2$s的权限 + %1$s仅为%3$s委托了%2$s + 哎呀!权限不足 + 交易将由被委托账户%s发起。网络费用将由被委托账户支付。 + 这是委托(代理)账户 + %s代理 + 代表已投票 + 新公投 + 公投更新 + %s公投#%s现已生效! + 🗳️新公投 + 下载Pezkuwi Wallet v%s以获取所有新功能! + Pezkuwi Wallet有新的更新可用! + %s公投#%s已结束并被批准🎉 + ✅公投批准! + %s公投#%s状态从%s变为%s + %s公投#%s已结束并被驳回! + ❌公投被驳回! + 🗳️公投状态变更 + %s 公投 #%s 状态变为 %s + Pezkuwi 公告 + 余额 + 启用通知 + 多重签名交易 + 因为您没有选择任何钱包,您将不会接收到有关钱包活动(余额,质押)的通知 + 其他 + 收到的代币 + 发送的代币 + 质押奖励 + 钱包 + ⭐️ 新奖励 %s + 从 %s 质押中收到 %s + ⭐ 新奖励 + Pezkuwi Wallet • 现在 + 从 Kusama 质押收到 +0.6068 KSM ($20.35) + 在 %s 上收到 %s + ⬇️ 已收到 + ⬇️ 收到 %s + 已发送 %s 到 %s 上的 %s + 💸 已发送 + 💸 发送 %s + 选择最多 %d 个钱包以在钱包有活动时收到通知 + 启用推送通知 + 接收有关钱包操作、治理更新、质押活动和安全的通知,让您始终了解最新情况 + 启用推送通知,即表示您同意我们的 %s 和 %s + 请稍后再试,通过设置选项卡中的通知设置访问 + 不要错过任何事情! + 选择接收%s的网络 + 复制地址 + 如果您追回此礼物,分享链接将被禁用,代币将退回到您的钱包。\n您想继续吗? + 追回%s礼物? + 您已成功领取您的礼物 + 粘贴 json 字符串或上传文件… + 上传文件 + 恢复 JSON + 助记词短语 + 原始种子 + 源类型 + 所有公投 + 显示: + 未投票 + 已投票 + 应用过滤器后未找到公投 + 公投信息将在这里显示,当它们开始时 + 未找到输入的标题或 ID 的公投 + 按公投标题或 ID 搜索 + %d 个公投 + 滑动以通过 AI 概要投票。快速简单! + 公投 + 未找到公投 + 弃权票只能通过 0.1x 决心投票。是否以 0.1x 决心投票? + 更新决心 + 弃权投票 + 赞成:%s + 使用 Pezkuwi DApp 浏览器 + 只有提案人可以编辑此描述和标题。如果您拥有提案人的账户,请访问 Polkassembly 并填写有关您提案的信息 + 请求的金额 + 时间线 + 你的投票: + 批准曲线 + 受益人 + 存款 + 选民 + 预览过长 + 参数 JSON + 提议者 + 支持曲线 + 投票率 + 投票阈值 + 位置:%s 来自 %s + 为了投票,您需要在钱包中添加一个 %s 账户 + 公投 %s + 反对:%s + 反对票 + %s 通过 %s 的投票 + 支持票 + 质押 + 已批准 + 已取消 + 决策中 + 在 %s 中决策 + 已执行 + 在队列中 + 在队列中(%s of %s) + 已删除 + 未通过 + 通过中 + 准备中 + 已拒绝 + %s 后批准 + %s 后执行 + %s 后超时 + %s 后拒绝 + 已超时 + 等待存款 + 门槛:%s of %s + 投票结果:已批准 + 已取消 + 已创建 + 投票:决策中 + 已执行 + 投票:在队列中 + 已删除 + 投票:未通过 + 投票:通过中 + 投票:准备中 + 投票结果:已拒绝 + 已超时 + 投票:等待存款 + 要通过:%s + 众贷 + 国库:大额支出 + 国库:大额小费 + Fellowship: 管理 + 治理:注册员 + 治理:租赁 + 国库:中等支出 + 治理:取消者 + 治理:删除者 + 主要议程 + 财政:小额支出 + 财政:小费 + 财政:任何 + 仍锁定于 %s + 可解锁 + 弃权 + 赞成 + 重新使用所有锁:%s + 重新使用治理锁:%s + 治理锁 + 锁定期 + 反对 + 通过增加锁定期来倍增票数 + 为 %s 投票 + 锁定期过后不要忘记解锁您的代币 + 已投票的公投 + %s 票 + %s × %sx + 投票者列表将出现在这里 + %s 票 + 团契:白名单 + 你的投票:%s 票 + 公投已完成,投票结束 + 公投已完成 + 您正在为选定的公投轨道委托投票。请要么请求您的受托人投票,要么删除委托以便直接投票。 + 已经委托投票 + 您已达到轨道的最大投票数 %s + 达到最大投票数 + 您的可用投票代币不足。可用于投票:%s。 + 撤销访问类型 + 撤销权利 + 移除投票 + + 您之前在%d个轨道上参与了公投。为了使这些轨道可用于委托,您需要移除现有的投票。 + + 移除您的投票记录? + %s 它是独属于你的,安全存储,对他人不可访问。没有备份密码,无法从Google云盘中恢复钱包。如果丢失,请删除当前备份,以创建一个新的备份并设置新密码。%s + 很遗憾,您的密码无法恢复。 + 或者,使用Passphrase进行恢复。 + 你是否丢失了密码? + 您的备份密码之前已更新。要继续使用云备份,请输入新的备份密码。 + 请输入您在备份过程中创建的密码 + 输入备份密码 + 更新区块链运行时信息失败。部分功能可能无法工作。 + 运行时更新失败 + 联系人 + 我的账户 + 未找到输入名称或池ID的池。请确保输入的数据正确 + 账户地址或账户名称 + 搜索结果将在此显示 + 搜索结果 + 活跃池:%d + 成员 + 选择池 + 选择要添加委托的轨道 + 可用轨道 + 请选择您希望委托投票权的轨道。 + 选择要编辑委托的轨道 + 选择要撤销委托的轨道 + 不可用的轨道 + 发送礼物到 + 启用蓝牙并授予权限 + Pezkuwi需要启用地理位置,以便能够进行蓝牙扫描查找您的Ledger设备 + 请在设备设置中启用地理位置 + 选择网络 + 选择代币进行投票 + 选择曲目 + %d / %d + 选择网络出售 %s + 销售已启动!请等待最多60分钟。您可以在电子邮件中跟踪状态。 + 当前没有供应商支持出售此代币。请选择其他代币、其他网络或稍后再试。 + 卖出功能不支持此代币 + 地址或w3n + 选择发送%s的网络 + 收件人是一个系统账户。它不受任何公司或个人控制。您确定仍然要执行此转账吗? + 代币将会丢失 + 授权给 + 请确保在设置中启用了生物识别 + 设置中禁用了生物识别 + 社区 + 通过电子邮件获得支持 + 通用 + 带有密钥对的钱包(在nova钱包中创建或导入的)上的每次签名操作都应在构造签名前要求进行PIN验证 + 请求操作签名的认证 + 首选项 + 推送通知仅适用于从Google Play下载的Pezkuwi Wallet版本。 + 推送通知仅适用于具有Google服务的设备。 + 屏幕录制和截图将不可用。最小化的应用程序将不显示内容 + 安全模式 + 安全性 + 支持和反馈 + Twitter + 维基百科和帮助中心 + Youtube + 弃权时将把决心设置为 0.1x + 不能同时使用直接质押和提名池进行质押 + 已质押 + 高级质押管理 + 不能更改质押类型 + 您已经有了直接质押 + 直接质押 + 您指定的金额少于使用%s获得奖励所需的最低质押额%s。您应该考虑使用池质押来获得奖励。 + 在治理中重用代币 + 最低质押: %s + 奖励:自动支付 + 奖励:手动领取 + 您已经在矿池中质押了 + 池质押 + 您的质押小于获得奖励的最小金额 + 不支持的质押类型 + 分享礼物链接 + 追回 + 您好!您有一个%s礼物在等您!\n\n安装Pezkuwi Wallet应用,设置您的钱包,并通过此特定链接领取:\n%s + 礼物已准备好。\n立即分享! + sr25519(推荐) + Schnorrkel + 所选账户已作为控制器使用 + 添加委托权限(代理) + 您的委托 + 活跃委托者 + 将控制器账户%s添加到应用程序中执行此操作。 + 添加委托 + 您的质押金额少于%s的最低限额。质押金额低于最低限额会增加质押不产生奖励的几率 + 质押更多代币 + 更换您的验证者。 + 现在一切正常。警告将出现在这里。 + 在验证者质押指派队列中的过时位置可能会暂停您的奖励 + 质押改进 + 赎回未质押的代币。 + 请等待下一个时代的开始。 + 警报 + 已经是控制器 + 您已在 %s 中有质押 + 质押余额 + 余额 + 更多质押 + 您既未提名也未验证 + 更换控制器 + 更换验证器 + %s / %s + 已选验证器 + 控制器 + 控制器账户 + 我们发现这个账户没有自由令牌,您确定要更改控制器吗? + 控制器账户可以:从质押中提取,赎回,返回到质押中,更改奖励目的地和验证器。 + 控制器用途:提取,赎回,返回质押,更换验证器并设置奖励目的地 + 控制器已更改 + 此验证器被封锁,目前不能选择。请在下一个时代再试。 + 清除过滤器 + 取消全选 + 用推荐的填满其余部分 + 验证器:%d / %d + 选择验证器(最多 %d) + 显示所选:%d(最多 %d) + 选择验证器 + 预计奖励(%APR) + 预计奖励(%APY) + 更新你的列表 + 通过Pezkuwi DApp浏览器质押 + 更多质押选项 + 质押并赚取奖励 + %1$s 质押在 %2$s 上线,始于 %3$s + 预计奖励 + 时代 #%s + 预计收益 + 预计%s收益 + 验证者自己的质押 + 验证者的自有质押(%s) + 解质押期间的代币不产生奖励。 + 解质押期间代币不产生奖励 + 解质押期后别忘了赎回你的代币。 + 解质押期后不要忘记赎回你的代币 + 从下一个时代开始,你的奖励将会增加。 + 从下一个时代开始,你将获得增加的奖励 + 每个时代质押的代币都会产生奖励(%s)。 + 质押中的代币在每个时代都会产生奖励(%s) + Pezkuwi钱包将更改奖励目的地至你的账户,以避免剩余质押。 + 如果你想解除质押代币,你必须等待解质押期(%s)。 + 要解质押代币,你必须等待解质押期(%s) + 质押信息 + 活跃提名人 + + %d天 + + 最低质押 + %s网络 + 已质押 + 请切换你的钱包到%s以设置代理 + 选择贮藏账户以设置代理 + 管理 + %s(最大 %s) + 已达到提名人最大数量。请稍后再试 + 无法开始质押 + 最小质押 + 为了开始质押,你需要在你的钱包中添加一个%s账户 + 每月 + 在设备中添加你的控制器账户。 + 无法访问控制器账户 + 已提名: + %s已奖励 + 你的一个验证者已被网络选中。 + 活跃状态 + 非活跃状态 + 你的质押金额低于获得奖励的最低质押。 + 你的所有验证者均未被网络选中。 + 你的质押将从下一个时代开始。 + 非活跃 + 等待下一个时代 + 等待下一个时代(%s) + 你的余额不足以支付%s的代理存款。可用余额:%s + 质押通知频道 + 整理员 + 担保人的最低股份高于您的委托。您将不会从担保人那里收到奖励。 + 担保人信息 + 担保人自己的股份 + 担保人:%s + 您的一个或多个担保人已被网络选举。 + 委托人 + 您已达到%d担保人委托的最大数量 + 您不能选择新的担保人 + 新的担保人 + 等待下一轮(%s) + 您有所有担保人的待处理取消质押请求。 + 没有可用于取消质押的担保人 + 返回的代币将从下一轮开始计算 + 每轮质押的代币都会产生奖励(%s) + 选择担保人 + 选择担保人… + 从下一轮开始,您将获得增加的奖励 + 由于您的委托中没有任何一个是活跃的,您将不会收到这一轮的奖励。 + 您已经从这个担保人那里解除质押代币。每个担保人只能有一个待处理的解除质押请求。 + 您无法从此验证人解除抵押 + 您的抵押必须大于此验证人的最小抵押额(%s)。 + 您将不会收到奖励 + 您的一些验证人要么没有被选中,要么他们的最小抵押额高于您的抵押金额。在本轮中与他们抵押,您将不会收到奖励。 + 您的验证人 + 您的抵押已指派给下一批验证人 + 活跃的验证人但不产生奖励 + 没有足够抵押被选中的验证人 + 将在下一轮中生效的验证人 + 等待中(%s) + 支付 + 支付已过期 + + %d天后到期 + + 您可以自行支付它们,当它们即将到期时,但您将支付费用 + 奖励每2-3天由验证人支付 + 全部 + 全部时间 + 结束日期总是今天 + 自定义周期 + %d天 + 选择结束日期 + 结束 + 过去6个月(6个月) + 6个月 + 过去30天(30天) + 30天 + 过去3个月(3个月) + 3个月 + 选择日期 + 选择开始日期 + 开始 + 显示质押奖励 + 过去7天(7天) + 7天 + 去年(1年) + 1年 + 您的可用余额为%s,您需要保留%s作为最低余额并支付%s的网络费用。您最多可以质押%s。 + 委托权限(代理) + 当前队列槽 + 新队列槽 + 返回质押 + 全部取消质押 + 返回的代币将从下一个时代开始计算 + 自定义金额 + 您要返回质押的金额大于取消质押的余额 + 最新取消质押 + 最有利可图的 + 未超额认购 + 拥有链上身份 + 未被削减 + 每个身份限2个验证器 + 至少有一个身份联系方式 + 推荐的验证器 + 验证器 + 估计奖励(APY) + 兑换 + 可兑换:%s + 奖励 + 奖励去向 + 可转移奖励 + 时代 + 奖励详情 + 验证器 + 再投资收益 + 无需再投资的收益 + 太好了!所有奖励都已支付。 + 太棒了!您没有未支付的奖励 + 全部支付(%s) + 待发放奖励 + 未支付奖励 + %s奖励 + 关于奖励 + 奖励(APY) + 奖励目的地 + 自行选择 + 选择支付账户 + 选择推荐 + 已选%d(最多%d) + 验证器(%d) + 更新控制器到Stash + 使用代理将Staking操作委托给另一个帐户 + 控制器账户正在被弃用 + 选择另一个帐户作为控制器,将质押管理操作委托给它 + 提高质押安全性 + 设置验证人 + 验证人未被选择 + 选择验证人开始质押 + 要持续获得奖励的推荐最低质押额是%s。 + 您不能质押低于网络最小值(%s) + 最小质押额应大于%s + 增加质押 + 增加质押奖励 + 如何使用您的奖励? + 选择您的奖励类型 + 支付账户 + 惩罚 + 质押%s + 最大质押 + 质押期 + 质押类型 + 您应该信任您的提名人能够胜任且诚实地行事,完全根据他们目前的盈利能力来做决定可能导致利润减少甚至资金损失。 + 谨慎选择您的验证者,因为他们应该专业且诚实地行事。仅基于盈利性来做出决定可能会导致奖励减少甚至赌注损失 + 与您的验证者一起抵押 + Pezkuwi Wallet将根据安全和盈利性标准选择顶级验证者 + 与推荐的验证者一起抵押 + 开始抵押 + 保管库 + 使用保管库账户可以绑定更多并设置控制器。 + 保管库用于:抵押更多并设置控制器 + 保管库账户 %s 无法更新抵押设置。 + 提名人通过锁定他的代币来赚取被动收入,以确保网络安全。为此,提名人应选择多个验证者进行支持。提名时应谨慎选择验证者。如果所选的验证者行为不当,根据事件的严重程度,他们将面临削减惩罚。 + Pezkuwi Wallet通过帮助提名人选择验证者来提供支持。移动应用程序从区块链中获取数据,并组成一个验证者列表,这些验证者具有:最多的利润、带有联系信息的身份、未被削减且可接受提名。Pezkuwi Wallet还关心去中心化,所以如果一个人或公司运行多个验证节点,推荐列表中最多只会显示2个节点。 + 什么是提名人? + 质押奖励在每个时代结束时可供支付(Kusama中为6小时,Polkadot中为24小时)。网络在84个时代内存储待处理奖励,在大多数情况下,验证者会为所有人支付奖励。然而,验证者可能会忘记或他们可能会遇到某些问题,因此提名人可以自行支付他们的奖励。 + 尽管奖励通常由验证者分配,Pezkuwi Wallet通过提醒即将到期的未支付奖励来提供帮助。您将收到关于此及其他质押活动的警报。 + 接收奖励 + 质押是通过在网络中锁定您的代币以赚取被动收入的一种选择。质押奖励每个时代分配一次(Kusama为6小时,Polkadot为24小时)。您可以随意质押,为了解质押您的代币,您需要等待解质押期结束,使您的代币可供赎回。 + 质押是网络安全性和可靠性的重要部分。任何人都可以运行验证节点,但只有那些质押了足够代币的人才会被网络选举参与组成新区块并获得奖励。验证者往往自己没有足够的代币,因此,提名人通过锁定他们的代币来帮助他们达到所需的质押量。 + 什么是质押? + 验证者需要全天候运行一个区块链节点,并且必须拥有足够的质押额(包括自己拥有的和提名人提供的)被网络选举。验证者应维护其节点的性能和可靠性以获得奖励。成为一个验证者几乎是一份全职工作,有些公司专注于成为区块链网络上的验证者。 + 任何人都可以成为验证者并运行区块链节点,但这需要一定的技术技能和责任感。Polkadot和Kusama网络有一个名为Thousand Validators Programme的项目,为初学者提供支持。此外,网络本身将始终奖励那些拥有较少股份(但足以被选举)的验证者,以改善去中心化。 + 什么是验证者? + 将您的账户切换到贮藏账户以设置控制器。 + 质押 + %s 质押 + 已奖励 + 共计质押 + 提升阈值 + 对我的整理者 + 不含Yield Boost + 含Yield Boost + 自动质押%s我所有可转账代币之上 + 自动质押%s(之前:%s)我所有可转账代币之上 + 我想要质押 + Yield Boost + 质押类型 + 您正在解除所有代币的质押,并且不能再质押更多。 + 无法质押更多 + 在部分解除质押时,您应至少保留 %s 的质押。您是否要通过解除剩余的 %s 来完全解除质押? + 质押的金额过小 + 您想要解除质押的金额大于质押余额 + 解除质押 + 解除质押的交易将显示在这里 + 解除质押的交易将会在这里显示 + 解除质押:%s + 在解质押期结束后,您的代币将可供赎回。 + 您已达到解除质押请求的限制(%d 个活跃请求)。 + 达到解除质押请求的限制 + 解除质押期 + 全部解除质押 + 全部解除质押? + 预计奖励(%% APY) + 预计奖励 + 验证者信息 + 超额认购。这个时代您将不会从验证者那里收到奖励。 + 提名人 + 超额认购。只有最高质押的提名人才能获得奖励。 + 自己的 + 没有搜索结果。\n确保您输入了完整的账户地址 + 因网络中的不当行为(例如,离线、攻击网络或运行修改过的软件)而被处罚的验证者。 + 总质押量 + 总质押量(%s) + 奖励低于网络费用。 + 年度 + 您的质押已分配给以下验证者。 + 您的质押已分配给下一个验证者 + 已选(%s) + 本时代未被选中的验证者。 + 没有足够质押以被选中的验证者 + 未分配您的质押的其他活跃验证者。 + 没有您的质押分配的活跃验证者 + 未选中(%s) + 你的代币被分配给了超额认购的验证者。在这个时代,你将不会收到来自这个验证者的奖励。 + 奖励 + 您的质押 + 您的验证者 + 您的验证者将在下个时代变更。 + 现在让我们备份您的钱包。这可以确保您的资金安全。备份允许您在任何时候恢复您的钱包。 + 继续使用Google + 输入钱包名称 + 我的新钱包 + 继续手动备份 + 给您的钱包取个名字 + 这将只对您可见,您可以稍后编辑它。 + 您的钱包已准备好 + 蓝牙 + USB + 由于%s,您的余额中有锁定的代币。为了继续,请输入少于%s或多于%s的金额。要质押其他金额,您应该解除%s的锁定。 + 您不能质押指定的金额 + 已选:%d(最多 %d) + 可用余额:%1$s(%2$s) + %s与您质押的代币 + 参与治理 + 质押超过%1$s并且%2$s与您质押的代币 + 参与治理 + 随时用最少的%1$s质押。您的质押将积极获得奖励%2$s + 在%s内 + 随时质押。您的质押将积极获得奖励%s + 了解更多信息关于%1$s质押在%2$s + Pezkuwi Wiki + 奖励每%1$s累积一次。质押超过%2$s自动获得奖励支付,否则您需要手动领取奖励 + 每%s + 奖励累积%s + 奖励累积%s。您需要手动领取奖励 + 奖励在%s累积并添加到可转账余额中 + 奖励在%s累积并重新加入赌注 + 奖励和质押状态会随时间变化。%s不时 + 监控您的赌注 + 开始质押 + 查看%s + 使用条款 + %1$s是一个%2$s带有%3$s + 没有代币价值 + 测试网络 + %1$s\n在您的%2$s代币\n每年 + 最高可赚取%s + 随时取消质押,并在%s赎回您的资金。取消质押时不会获得奖励 + 经过%s + 您选择的池因为没有选定的验证者或其赌注低于最小值而处于非活跃状态。\n您确定要继续选择的池吗? + 已达到提名人的最大数量。请稍后再试 + %s目前不可用 + 验证者:%d(最大%d) + 已修改 + 新的 + 已移除 + 用于支付网络费用的代币 + 网络费用将添加到输入的金额之上 + 交换步骤模拟失败 + 不支持此对 + 操作 #%s (%s) 失败 + %s 到 %s 的交换在 %s 上 + %s 从 %s 转到 %s + + %s 操作 + + %s 共 %s 操作 + 正在进行 %s 到 %s 的交换在 %s 上 + 正在传输 %s 到 %s + 执行时间 + 您在支付%s网络费后至少应保留%s,因为您持有的代币不足 + 您必须至少保留%s,以接收%s代币 + 要接收 %s 代币,您必须在 %s 至少拥有 %s + 您最多可以交换%1$s,因为您需要支付%2$s的网络费 + 您最多可以交换%1$s,因为您需要支付%2$s的网络费并将%3$s兑换为%4$s,以满足%5$s的最低余额 + 最大交换 + 最小交换 + 您的余额至少应保留%1$s。您是否想通过添加剩余的%2$s来进行全部交换? + 您的余额剩余太少 + 在支付%2$s的网络费和将%3$s转换为%4$s以满足%5$s的最低余额后,您的余额至少应保留%1$s。\n\n您是否想通过添加剩余的%6$s来进行全部交换? + 支付 + 接收 + 选择一个代币 + 代币不足以交换 + 流动性不足 + 您不能接收少于%s + 使用信用卡立即购买%s + 从另一个网络转移%s + 通过QR或您的地址接收%s + 使用%s获取 + 在交换执行期间,中间接收金额为 %s,少于最低余额 %s。请尝试指定更大的交换金额。 + 滑点必须指定在%s到%s之间 + 无效滑点 + 选择一个代币来支付 + 选择一个代币来接收 + 输入金额 + 输入其他金额 + 为了支付网络费用,Pezkuwi将自动将%s兑换为%s,以保持您账户的最低%s余额。 + 区块链收取的网络费用,用于处理和验证任何交易。可能会根据网络状况或交易速度而变化。 + 选择交换%s的网络 + 池子没有足够的流动性进行交换 + 价格差异指的是两种不同资产之间的价格差。在加密货币交换中,价格差异通常是您要交换的资产的价格与您交换的资产的价格之间的差异。 + 价格差异 + %s ≈ %s + 两种不同加密货币之间的汇率。它表示您可以用一定数量的另一种加密货币交换获得多少加密货币。 + 汇率 + 旧汇率:%1$s ≈ %2$s。\n新汇率:%1$s ≈ %3$s + 兑换率已更新 + 重复操作 + 路线 + 你的代币通过不同网络到达所需代币的路径。 + 交换 + 转移 + 交换设置 + 滑点 + 在去中心化交易中,交换滑点是一种常见现象,由于市场条件的变化,交易的最终价格可能会与预期价格略有不同。 + 输入其他值 + 请输入介于%s和%s之间的值 + 滑点 + 因高滑点而可能遭受前置交易。 + 因低滑点容忍度可能会被回退。 + %s的金额小于%s的最低余额 + 您尝试交换的金额太小 + 弃权: %s + 赞成: %s + 你永远可以稍后为这个公投投票 + 从投票列表中移除公投 %s ? + 某些公投不再可供投票或您的代币不足。可投票的:%s。 + 某些公投已从投票列表中排除 + 无法加载公投数据 + 没有数据 + 您已为所有可用的公投投票,或目前没有公投可供投票。请稍后再回来。 + 您已为所有可用的公投投票 + 请求: + 投票列表 + 剩余 %d + 确认投票 + 没有公投可投票 + 确认您的投票 + 没有投票 + 你已经成功为 %d 个公投投票 + 您的余额不足以使用当前的投票权 %s (%sx) 进行投票。请更改投票权或向您的钱包添加更多资金。 + 投票余额不足 + 反对: %s + SwipeGov + 为 %d 个公投投票 + 投票将在 SwipeGov 的未来投票中设置 + 投票权 + 质押 + 钱包 + 今天 + Coingecko链接用于价格信息(可选) + 选择一个提供商购买 %s 代币 + 不同提供商的支付方式、费用和限额有所不同。\n比较他们的报价以找到适合您的最佳选项。 + 选择一个提供商出售 %s 代币 + 为了继续购买,你将从 Pezkuwi Wallet 应用被重定向到 %s + 在浏览器中继续? + 当前没有供应商支持购买或出售此代币。请选择其他代币、其他网络或稍后再试。 + 买入/卖出功能不支持此代币 + 复制哈希 + 费用 + 来自 + 交易哈希 + 交易详情 + 在%s中查看 + 在Polkascan中查看 + 在Subscan中查看 + %s 在 %s + 您之前的 %s 交易历史仍然可以在 %s 上查阅 + 完成 + 失败 + 待定 + 交易通知频道 + 最低仅需$5即可购买加密货币 + 最低仅需$10即可出售加密货币 + 来自:%s + 发送至:%s + 转账 + 传入和传出\n转账将显示在这里 + 您的操作将显示在此处 + 删除投票以便在这些轨道中代理 + 您已经代理投票的轨道 + 不可用轨道 + 您已投票的轨道 + 不要再显示。\n您可以在接收中找到传统地址。 + 传统格式 + 新格式 + 一些交易所可能仍然需要传统格式\n在他们更新期间进行操作。 + 新统一地址 + 安装 + 版本 %s + 更新可用 + 为避免任何问题,并改善您的用户体验,我们强烈建议您尽快安装最新更新 + 关键更新 + 最新 + Pezkuwi Wallet 可用许多惊人的新功能!确保更新您的应用程序以访问它们 + 重大更新 + 关键 + 重要 + 查看所有可用更新 + 名称 + 钱包名称 + 此名称仅为您显示,并将仅存储在您的移动设备上。 + 此账户未被网络选举参与当前时代 + 重新投票 + 投票 + 投票状态 + 您的卡正在充值中! + 您的卡正在发行! + 最多可能需要 5 分钟。\n此窗口将自动关闭。 + 预计 %s + 购买 + 买入/卖出 + 购买代币 + 用...购买 + 接收 + 接收 %s + 仅将%1$s代币和%2$s网络中的代币发送到此地址,否则您可能会失去资金 + 出售 + 出售代币 + 发送 + 兑换 + 资产 + 您的资产将显示在这里。\n确保\"隐藏零余额\"\n过滤器处于关闭状态 + 资产价值 + 可用 + 已质押 + 余额详情 + 总余额 + 转账后总计 + 已冻结 + 已锁定 + 可赎回 + 已预留 + 可转让 + 解除绑定中 + 钱包 + 新连接 + + %s 账户缺失。请在设置中向钱包添加账户 + + Pezkuwi Wallet 不支持“%s”请求的某些必需网络 + Wallet Connect 会话将在这里显示 + WalletConnect + 未知 dApp + + %s 不支持的网络已隐藏 + + WalletConnect v2 + 跨链转账 + 加密货币 + 法定货币 + 热门法定货币 + 货币 + 交易详情 + 隐藏余额为零的资产 + 其他交易 + 显示 + 奖励和惩罚 + 交换 + 过滤器 + 转账 + 资产管理 + 如何添加钱包? + 如何添加钱包? + 如何添加钱包? + 名称示例:主账户、我的验证器、Dotsama 众贷等。 + 将此 QR 码分享给发送方 + 让发送方扫描此 QR 码 + 我的 %s 地址用于接收 %s: + 分享 QR 码 + 接收者 + 确保地址来自正确的网络 + 地址格式无效。确保地址属于正确的网络 + 最小余额 + 由于跨链限制,您转账的金额不能超过%s + 您的余额不足以支付%s的跨链费用。\n转账后的剩余余额:%s + 跨链费用将加在输入金额之上。接收者可能会收到部分跨链费用 + 确认转账 + 跨链 + 跨链费 + 您的转账将因目的账户没有足够的%s来接受其他代币转账而失败 + 收件人无法接受转账 + 您的转账将失败,因为目的账户的最终金额将低于最小余额。请尝试增加金额。 + 您的转账将从区块存储中移除账户,因为它将使总余额低于最小余额。 + 转账后您的账户将因总余额低于最低限额而从区块链中移除 + 转账将移除账户 + 转账后您的账户将因总余额低于最低限额而从区块链中移除。剩余余额也将转给接收者。 + 来自网络 + 您需要至少有%s来支付此次交易费用并保持网络最低余额以上。您当前的余额是:%s。您需要向您的余额中添加%s来执行此操作。 + 自己 + 在链上 + 以下地址:%s 已知用于网络钓鱼活动,因此我们不建议向该地址发送代币。您还想继续吗? + 欺诈警告 + 接收者被代币所有者阻止,当前无法接受入账转账 + 接收者无法接受转账 + 接收者网络 + 到网络 + 从发送 %s + 在发送 %s + + 发送者 + 代币 + 发送给此联系人 + 转账详情 + %s (%s) + %s地址为%s + Pezkuwi检测到关于%1$s地址信息完整性的问题。请联系%1$s的所有者解决完整性问题。 + 完整性检查失败 + 无效的接收者 + 在%s网络上未找到%s的有效地址 + %s未找到 + %1$s w3n服务不可用。请稍后重试或手动输入%1$s地址 + 解析w3n时出错 + Pezkuwi无法为令牌%s解析代码 + 令牌%s尚未支持 + 昨天 + 当前Collator的收益增强将被关闭。新Collator:%s + 更改已增强收益的Collator? + 您的余额不足以支付%s的网络费用和%s的收益增强执行费用。\n用于支付费用的可用余额:%s + 没有足够的代币支付首次执行费 + 您的余额不足以支付%s的网络费用且不低于%s的门槛。\n用于支付费用的可用余额:%s + 代币不足以保持高于门槛 + 加注时间 + 收益增强将自动加注我所有可转让代币中超过%s的%s + 已增强收益 + + + DOT ↔ HEZ 跨链桥 + DOT ↔ HEZ 跨链桥 + 发送 + 接收(预计) + 汇率 + 桥接费用 + 最低金额 + 1 DOT = %s HEZ + 1 HEZ = %s DOT + 兑换 + HEZ→DOT兑换在DOT流动性充足时处理。 + 余额不足 + 金额低于最低限额 + 输入金额 + HEZ→DOT兑换可能因流动性而受限。 + HEZ→DOT兑换暂时不可用。请在DOT流动性充足时重试。 + diff --git a/common/src/main/res/values/attrs.xml b/common/src/main/res/values/attrs.xml new file mode 100644 index 0000000..62e3108 --- /dev/null +++ b/common/src/main/res/values/attrs.xml @@ -0,0 +1,384 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/values/colors.xml b/common/src/main/res/values/colors.xml new file mode 100644 index 0000000..1a877c5 --- /dev/null +++ b/common/src/main/res/values/colors.xml @@ -0,0 +1,165 @@ + + + + #eeeeee + #FFFFFF + #101014 + + #373A49 + #3DFFFFFF + + + #05081C + + + #009639 + #009639 + + + #E0FFFFFF + #000000 + #7AFFFFFF + #8F000000 + #05081C + #E0FFFFFF + #A3FFFFFF + #52FFFFFF + #52FFFFFF + #009639 + #EBC50A + #EBC50A + #E53450 + #2FC864 + #2AB0F2 + #2AB0F2 + #2AB0F2 + #41D4D2 + #58F8B2 + #86F574 + #B6F234 + #DAC71B + #F19B27 + #A3FFFFFF + #A3FFFFFF + #52FFFFFF + #A3FFFFFF + + #BD387F + #FF7A00 + + #00072E + #661D78 + + + #E0FFFFFF + #05081C + #52FFFFFF + #29FFFFFF + #7AFFFFFF + #7AFFFFFF + #66FFFFFF + #009639 + #EBC50A + #E53450 + #E53450 + #2FC864 + #8E8F9A + + + #08090E + #181920 + #1A999EC7 + #3D999EC7 + + #5205081C + #291F78FF + #1A999EC7 + #29999EC7 + #443679 + #1A999EC7 + #3D08090E + #5208090E + #1A999EC7 + #FFFFFF + #E0FFFFFF + #1E223C + + #3D999EC7 + #3D999EC7 + #3D999EC7 + #3D999EC7 + #29999EC7 + #29E53450 + #29EDCE36 + #1FEDCE36 + #1F2AB0F2 + #66151D27 + #CC151D27 + #0F111A + #7A08090E + #3D999EC7 + #3D2FC864 + #060A33 + + + #009639 + #29999EC7 + #2FC864 + #E53450 + #1A999EC7 + #181920 + + #353D67 + #101636 + + + #009639 + #3D999EC7 + #3D999EC7 + #29999EC7 + #52999EC7 + #29999EC7 + #3D999EC7 + #0A999EC7 + #3D999EC7 + #E53450 + + + #29999EC7 + #29999EC7 + + + #009639 + #29FFFFFF + #29FFFFFF + #A3FFFFFF + #E53450 + #2FC864 + #2AB0F2 + #FFFFFF + #08090E + #7A08090E + #FFFFFF + + #2AB0F2 + #2AB0F2 + #292AB0F2 + + #EBC50A + #EBC50A + #1FEDCE36 + + + #B8000000 + #B8000000 + #B8000000 + + #3B3E4E + #252733 + #1D1D28 + + #29999EC7 + #454968 + #2FC864 + #E53450 + \ No newline at end of file diff --git a/common/src/main/res/values/dimens.xml b/common/src/main/res/values/dimens.xml new file mode 100644 index 0000000..e74ceda --- /dev/null +++ b/common/src/main/res/values/dimens.xml @@ -0,0 +1,33 @@ + + + + 30sp + 22sp + 18sp + 16sp + 14sp + + 16sp + 14sp + 12sp + 10sp + + 20sp + + 15sp + + 12dp + 12dp + + 4dp + 8dp + 12dp + 16dp + 24dp + + 16dp + + 40dp + + 12dp + \ No newline at end of file diff --git a/common/src/main/res/values/ids.xml b/common/src/main/res/values/ids.xml new file mode 100644 index 0000000..81f0e30 --- /dev/null +++ b/common/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml new file mode 100644 index 0000000..0369a00 --- /dev/null +++ b/common/src/main/res/values/strings.xml @@ -0,0 +1,2762 @@ + + + + + Flex, Stax, Nano + + Ledger Nano Gen5 + + Gifting is not supported for Multisig wallets + To create or manage gifts, please switch to a different type of wallet. + + Wallet name + Name + Invalid QR code + + %s you would like to add to Pezkuwi Wallet + Tap on Derived Key + + Pair public key + Import private key + + Tap the icon in the top-right corner and select %s + Export Private Key + + You’ve successfully reclaimed your gift + + Reclaim %s Gift? + If you reclaim this gift, the shared link will be disabled, and tokens will be refunded to your wallet.\nDo you want to continue? + + Reclaim + secrets + + No account on %s + Use another wallet, create a new one, or add a %s account to this wallet in Settings. + + You can’t receive a gift with a %s wallet + Create a new wallet or import an existing one to claim the gift + + Manage Wallets + + You’ve successfully claimed a gift. Tokens will appear in your balance shortly. + + The gift cannot be received + Oops, the gift has already been 
claimed + + Oops, something went wrong + There may be a problem with the server. Please, try again later. + Claim the gift + You’ve got a crypto gift! + + Created: %s + Claimed + Reclaimed + Network fee on claim + + Hello! You’ve got a %s gift waiting for you!\n\nInstall Pezkuwi Wallet app, set up your wallet, and claim it via this special link:\n%s + + Gift Has Been Prepared.\nShare It Now! + Share gift link + + Confirming will transfer tokens from your account + Your Gift + + Send gift on + + Enter another amount + + The minimum gift is %s + + Enter amount + + Select a token to gift + Select network for %s gift + You don’t have tokens to gift.\nBuy or Deposit tokens to your account. + + Create Gift + Enter your gift amount + %s as a link and invite anyone to Pezkuwi Wallet + Share the gift directly + %s, and you can return unclaimed gifts anytime from this device + The gift is available instantly + + Share Crypto Gifts with Anyone, Anywhere + Send gifts fast, easily, and securely in Pezkuwi Wallet + Gifts you created + Gift + + Select token to vote + Select token + + %s rewards + + Old transaction history remains on %s + + Starting %1$s your %2$s balance, Staking and Governance are on %3$s. These features might be unavailable for up to 24 hours. + %1$s staking is live on %2$s starting %3$s + + View + Your previous %s transaction history is still available on %s + + Your %s tokens now on %s + Starting %1$s, your %2$s balance, Staking, and Governance will be on %3$s — with improved performance and lower costs. + Go to %s + + Migration happens automatically, no action needed + + Starting %1$s your %2$s balance, Staking and Governance are on %3$s + What makes Asset Hub awesome? + %1$sx reduction in minimal balance\n(from %2$s to %3$s) + %1$sx lower transaction fees\n(from %2$s to %3$s) + More tokens supported: %s, and other ecosystem tokens + Unified access to %s, assets, staking, and governance + Ability to pay fees in any token + + Open app with hidden balance + + Enable in Settings + + Multisig push notifications are here! + Get notified about signing requests, new signatures, and completed transactions — so you’re always in control. Manage anytime in Settings. + + No multisig wallets + You haven’t added or selected any multisig wallets in Pezkuwi Wallet. Add and select at least one to receive multisig push notifications. + + Trust Wallet + Use your Trust Wallet accounts in Pezkuwi Wallet + + Your signature requested + Signed by signatory + Multisig transaction executed + Multisig transaction rejected + Batch (executes until error) + Batch All (reverts on error) + Force Batch (ignores errors) + Signing is over + + Transaction has already been executed or rejected. + + Loading transaction details… + + %1$s → %2$s + + Multisig Transaction Executed + Multisig Transaction Rejected + + %s on %s.\nRejected by: %s.\nNo further actions are required from you. + %s on %s.\nNo further actions are required from you. + + Transaction without initial on-chain information (call data) was rejected + + Pezkuwi Wallet has automatically switched to your multisig wallet so you can view pending transactions. + + On behalf of %s. + ❌ Multisig transaction rejected + Rejected by %s. + + ✅ Multisig transaction executed + Signed and executed by %s. + + %d of %d signatures collected. + Signed by %s + + ✍🏻 Your signature requested + %s on %s. + %s: %s + Initiated by %s. + Wallet: %s + + Multisig Transactions Notification Channel + multisigs_notification_channel_id + + Select up to %d wallets to be notified when wallet has activity + + Multisig transactions + + The call data you provided is invalid or has the wrong format. Please ensure it is correct and try again. + + No transactions yet.\nSigning requests will show up here + + Debit Card is not supported for this Proxied wallet + To manage your Debit Card, please switch to a different wallet with Polkadot network. + + Call hash + + Copy call data + Share call data + + Cannot perform this transaction + This transaction has already been rejected or executed. + + Other signatories can now confirm the transaction.\nYou can track its status in the %s. + Transactions to sign + + Delegated to you + + Delegated to you (Proxied) + + Transaction will be rejected. Multisig deposit will be returned to %s. + + Full details + + The deposit stays locked on the depositor’s account until the multisig operation is executed or rejected. + + Copy hash + Share hash + + Depositor + Multisig deposit + Call data + Make sure the operation is correct + + On behalf of + Signatory + Hide + Show + Signatories (%d of %d) + + Simulation of swap step failed + + Transaction simulation failed + Pezkuwi Wallet simulates the transaction beforehand to prevent errors. This simulation didn’t succeed. Try again later or with a higher amount. If the issue persists, please contact Pezkuwi Wallet Support in Settings. + + %s doesn’t have enough balance to pay the network fee of %s and place multisig deposit of %s. You need to add %s more to your balance + %s doesn’t have enough balance to place multisig deposit of %s. You need to add %s more to your balance + %s doesn’t have enough balance to pay the network fee of %s. You need to add %s more to your balance + + Invalid call data + This call data for another operation with call hash %s + + Enter call data to view details + + Enter call data + + + Other signatories: + Threshold %d out of %d + + Debit Card is not supported for Multisig + To manage your Debit Card, please switch to a different type of wallet. + + Sell is not supported for Multisig + To sell crypto for fiat, please switch to a different type of wallet. + + + Transactions to sign + + Signing (%s of %s) + + + Created by you + Signed by you + + + On behalf of: + + Unknown operation + + + Operation already exists + %s has already initiated the same operation and it is currently waiting to be signed by other signatories. + + %s needs at least %s to pay this transaction fee and stay above the minimum network balance. Current balance is: %s + + Multisig wallets do not support signing arbitrary messages — only transactions + + Multisig transaction + The multisig transaction will be initiated by %s. The initiator pays the network fee and reserves a multisig deposit, which will be unreserved once the transaction is executed. + + shared control (multisig) + + Wallet list has been updated + + Proxy and Multisig accounts auto-detected and organized for you. Manage anytime in Settings. + + Proxied + Shared control + + No longer valid + + Multisig transaction created + View details + + Approve + Approve & Execute + Reject + + Multisig + Transactions from this wallet require approval from multiple signatories. Your account is one of the signatories: + Signatory: + + + You must have at least %s on %s to receive %s token + + Welcome to Pezkuwi Wallet! + You’re almost there! 🎉\n Just tap below to complete the setup and start using your accounts seamlessly in both the Polkadot App and Pezkuwi Wallet + + %s Wallet + + %s://polkadot/migration-accepted?key=%s + access Bluetooth + + Press both buttons on your %s to approve the addresses + Press confirm button on your %s to approve the addresses + + Add address + Substrate Accounts + EVM Accounts + + Update Ledger Generic App + Your Ledger device is using an outdated Generic app that doesn’t support EVM addresses. Update it via Ledger Live. + + Account not found + + Account %s + Nano S + + Continue in browser? + To continue the purchase you will be redirected from Pezkuwi Wallet app to %s + + This token is not supported by the buy feature + None of our providers currently support the buying of this token. Please choose a different token, a different network, or check back later. + + This token is not supported by the sell feature + None of our providers currently support the selling of this token. Please choose a different token, a different network, or check back later. + + This token is not supported by the buy/sell feature + None of our providers currently support the buying or selling of this token. Please choose a different token, a different network, or check back later. + + Purchase initiated! Please wait up to 60 minutes. You can track status on the email. + Sale initiated! Please wait up to 60 minutes. You can track status on the email. + + Sell %s token + Select network for selling %s + + Best price with fees up to 3.95%% + Buy crypto starting from just $5 + Straightforward and efficient KYC process + + Sell crypto starting from just $10 + + Sell + + +%d + + Select a provider to buy %s token + + Select a provider to sell %s token + + Payment methods, fees and limits differ by provider.\nCompare their quotes to find the best option for you. + + Sell tokens + Buy tokens + Bridge DOT ↔ HEZ + + + DOT ↔ HEZ Bridge + You send + You receive (estimated) + Exchange rate + Bridge fee + Minimum + 1 DOT = %s HEZ + 1 HEZ = %s DOT + Swap + HEZ→DOT swaps are processed when sufficient DOT liquidity is available. + Insufficient balance + Amount below minimum + Enter amount + HEZ→DOT swaps may have limited availability based on current liquidity. + HEZ→DOT swaps are temporarily unavailable. Please try again when DOT liquidity is sufficient. + + Buy/Sell + + + %s in your phone settings + Enable OTG + + Enable Bluetooth & Grant Permissions + + Press both buttons on your %s to approve the address + Press confirm button on your %s to approve the address + Hold confirm button on your %s to approve the transaction + + Nano X + Ledger Flex + Ledger Stax + Nano S Plus + + If using a Ledger via Bluetooth, enable Bluetooth on both devices and grant Pezkuwi Wallet Bluetooth and location permissions. For USB, enable OTG in your phone settings. + Please, enable Ledger device. Unlock your Ledger device and open the %s app. + + Ledger Connection Guide + + Connect Ledger + + Bluetooth + USB + + Ledger Legacy + + Ledger (Generic Polkadot app) + + Legacy + New (Vault v7+) + + New Unified Address + Some exchanges may still require the legacy format\nfor operations while they update. %s + new format + legacy format + Do not show this again.\nYou can find legacy address in Receive. + + Estimated %s + Your card is being issued! + Your card is being funded! + It can take up to 5 minutes.\nThis window will be closed automatically. + + Contact support + Please contact support@pezkuwichain.io. Include email address that you have used for issuing the card. + + Manage Debit Card + + Top up card with %s + + Today + Last Week + Last Month + Last Year + All Time + + %s at %s + %s (%s) + + %s price + 1D + 1W + 1M + 1Y + All + + app.pezkuwichain.io + + High price impact detected (%s) + + Executing this swap will result in significant slippage and financial losses. Consider reducing your trade size of splitting your trade into multiple transactions. + + Popular + + During swap execution intermediate receive amount is %s which is less than minimum balance of %s. Try specifying larger swap amount. + + You don\'t have enough balance to pay network fee of %s. Current balance is %s + + sec + + Do not close the app! + + Failed + + %s of %s operations + + + %s operation + %s operations + + + Swapping %s to %s on %s + Transferring %s to %s + + Failed on operation #%s (%s) + %s to %s swap on %s + %s transfer from %s to %s + + + %d second + %d seconds + + + Execution time + + Total fee + + Fee: %s + + Transfer + Swap + The way that your token will take through different networks to get the desired token. + Route + + See All + Favorites + %d DApps + + Close All DApps? + All opened tabs in DApp browser will be closed. + + Close All + + Select network + + Search by token + + Copy Address + Send only %1$s token and tokens in %2$s network to this address, or you might lose your funds + + token icons + Appearance + White + Colored + + Select network for buying %s + Select network for receiving %s + Select network for sending %s + Select network for swapping %s + + Tokens + Networks + + Wiki & Help Center + + Get support via Email + + You have already voted for all available referenda + Confirm your votes + Vote list + No referenda to vote + %d left + + Use max + + Insufficient balance for voting + You don\'t have enough balance to vote with the current voting power %s (%sx). Please change voting power or add more funds to your wallet. + You have successfully voted for %d referenda + + Vote for %d referenda + Remove referendum %s from vote list? + You always can vote for this referendum later + + Some of the referenda excluded from vote list + Some of the referenda are no longer available for voting or you may not have enough tokens available to vote. Available to vote: %s. + + Aye: %s + Nay: %s + Abstain: %s + + Voting power + Voting will be set for future votes in SwipeGov + Confirm votes + No votes + No data retrived + The data of the referendum could not be loaded + Requested: + You have already voted for all available referenda or there are no referenda to vote right now. Come back later. + SwipeGov + Swipe to vote on referenda with AI summaries. Fast & easy! + %d referenda + + Polkadot app is installed + + Open the Polkadot app + + Make sure %s to your Ledger device using Ledger Live app + + %s on your Ledger device + + Allow Pezkuwi Wallet to %s + + %s to add to wallet + Select account + + Polkadot Vault will provide you %s + QR code to scan + + %s application on your smartphone + Open Polkadot Vault + + %s on your Ledger device + Open the network app + + Make sure %s to your Ledger device using Ledger Live app + Network app is installed + + Parity Signer will provide you %s + QR code to scan + + %s you would like to add to Pezkuwi Wallet + Go to “Keys” tab. Select seed, then account + + %s application on your smartphone + Open Parity Signer + + Conviction will be set to 0.1x when you vote Abstain + + Abstain votes + Conviction update + Abstain votes can only be done with 0.1x conviction. Vote with 0.1x conviction? + + Warning! DApp is unknown + Malicious DApps can withdraw all your funds. Always do your own research before using a DApp, granting permission, or sending funds out.\n\nIf someone is urging you to visit this DApp, it is likely a scam. When in doubt, please contact Pezkuwi Wallet support: %s. + Open anyway + + Already staking + You cannot stake with Direct Staking and Nomination Pools at the same time + + Pool operations are not available + You can no longer use both Direct Staking and Pool Staking from the same account. To manage your Pool Staking you first need to unstake your tokens from Direct Staking. + + Auto-balance nodes + Enable connection + + The Migration app will be unavailable in the near future. Use it to migrate your accounts to the new Ledger app to avoid losing your funds. + + Polkadot Migration + Polkadot + + New Ledger app has been released + To sign operations and migrate your accounts to the new Generic Ledger app install and open Migration app. Legacy Old and Migration Ledger apps will not be supported in the future. + + Ledger Nano X + + Ledger (Legacy) + + Passphrase + + Wallet will be removed in the Cloud Backup + Ensure you have saved Passphrase for the wallet before proceeding. + + Create a new wallet + + Backup password + + We are going to show your passphrase. Make sure no one can see your screen and do not take screenshots — they can be collected by third-party malware + + %s is disabled + You can not sign transactions of disabled networks. Enable %s in settings and try again + + Invalid Currency Symbol + The entered Currency Symbol (%1$s) does not match the network (%2$s). Do you want to use the correct currency symbol? + + Manage added node + Delete node? + Node \"%s\" will be deleted + + Edit node + Delete node + + Delete network? + You will not be able to see your token balances on that network on Assets screen + + Edit network + Delete network + Manage added network + + Delete + + Enter network details + Loading network info... + + Invalid Chain ID + The entered Chain ID does not match the network in the RPC URL. + + Modify + This Node already exists + The entered RPC URL is present in Pezkuwi Wallet as a %s network. + The entered RPC URL is present in Pezkuwi Wallet as a %s custom network. Are you sure you want to modify it? + + %s Default Node + %s Default Block Explorer + + Enter details + Substrate + EVM + + Coingecko link for price info (Optional) + + www.coingecko.com/en/coins/tether + + Block explorer URL (Optional) + https://networkscan.io + Chain ID + 012345 + Currency Symbol + TOKEN + Network name + RPC URL + + Search by network name + Loading networks list... + Add network + Add network manually + + This Node already exists + The URL you entered already exists as the "%s" Node. + + Wrong network + The URL you entered is not corresponding to Node for %1$s.\nPlease enter URL of the valid %1$s node. + + Node error + The Node URL you entered is either not responding or contains incorrect format. The URL format should start with "wss://". + + Add custom node for + Edit custom node for + Node URL + wss:// + Node name + Name + Enter details + Add node + Save + + %d ms + Add custom node + + custom nodes + default nodes + Added custom networks\nwill appear here + + Building for Pezkuwichain? + Integrate all the features of the network you are building into Pezkuwi Wallet, making it accessible to everyone. + Integrate your network + + connecting... + Testnet + Default + Added + Add network + Not available + Push notifications are only available for devices with Google services. + Push notifications are available only for the Pezkuwi Wallet version downloaded from Google Play. + + By continuing, you agree to our\n%1$s and %2$s + + No wallets to back up + You haven\'t added any wallets with a passphrase. + + Import existing + Wallet changes will be updated in the Cloud Backup + New wallets are automatically added to the Cloud Backup. You can disable Cloud Backup in Settings. + Automatically continue in the future + + How to add wallet? + How to add wallet? + How to add wallet? + + This password is required to encrypt your account and is used alongside this JSON file to restore your wallet. + + Download Restore JSON + + Enter password + + Polkadot + Ethereum + + Do not share any of this information with anyone - If you do you will permanently and irretreveably lose all of your assets + + Custom Key + Default Key + + Export JSON file + + Review & Accept to Continue + + Do not share your passphrase! + Please read the following carefully before viewing your backup + This passphrase gives you total and permanent access to all connected wallets and the funds within them.\n%s + DO NOT SHARE IT. + Do not enter your Passphrase into any form or website.\n%s + FUNDS MAY BE LOST FOREVER. + Support or admins will never request your Passphrase under any circumstances.\n%s + BEWARE OF IMPERSONATORS. + + Accounts with default key + Accounts with custom key + Default accounts + %s, +%d others + Select the key to back up + + Select a wallet to back up + Wallet changes failed to update in the Cloud Backup + At the moment your backup is not synchronized. Please review the issue. + Review the Issue + + Are you sure that you want to apply these changes? + If you have not manually written down your Passphrase for wallets that will be removed, then those wallets and all of their assets will be permanently and irretrievably lost forever. + + Cloud Backup changes found + At the moment your backup is not synchronized. Please review these updates. + Review Updates + + New + Modified + Removed + + Apply Backup Updates? + Before proceeding with changes, %s for modified and removed wallets! + ensure you\'ve saved Passphrases + Sign In + + Not now + + Backup password was changed + Your backup password was previously updated. To continue using Cloud Backup, %s + please enter the new backup password. + + Your backup password was previously updated. To continue using Cloud Backup, please enter the new backup password. + + Update backup password + Please enter a password that will be used to recover your wallets from cloud backup. This password can\'t be recovered in the future, so be sure to remember it! + + Enter current backup password + Please enter the password you created during the backup process + + Deleting a backup... + Backup will be deleted from Google Drive + %s and remember to always keep them offline to restore them anytime. You can do this in the Backup Settings. + Please write down all your wallet’s Passphrases before proceeding + + Manage backup + + Delete backup + + Change password + + Enable to backup wallets to your Google Drive + Last sync: %s at %s + Sign In to Google Drive + Enter Backup Password + Review Backup Updates + Review Google Drive Issue + Review Backup Error + + Backup Syncing... + Backup Disabled + Backup Unsynced + Backup Synced + Backup + Google Drive + You can enable Google Drive backups to store encrypted copies of all your wallets, secured by a password you set. + Manual + You can manually back up your passphrase to ensure access to your wallet’s funds if you lose access to this device + Back up to Google Drive + Back up manually + + Backup found but empty or broken + An issue has been identified with your backup. You have the option to delete the current backup and create a new one. %s before proceeding. + Ensure you have saved Passphrases for all wallets + + Invalid passphrase, please check one more time the words order + Select the words... + + Recover Wallets + Existing Cloud Backup found + %s All of your wallets will be safely backed up in Google Drive. + Do you want to recover your wallets? + + Backup Not Found + Unfortunately, Backup with your wallets has not been found. + + Are you sure you want to delete Cloud Backup? + Current backup with your wallets will be permanently deleted! + + Delete Backup + Have you lost your password? + %s It\'s exclusively yours, securely stored, inaccessible to others. Without the backup password, restoring wallets from Google Drive is impossible. If lost, delete the current backup to create a new one with a fresh password. %s + Unfortunately, your password cannot be recovered. + Alternatively, use Passphrase for restoration. + + Forgot Password? + Enter backup password + Please enter the password you created during the backup process + + Got it + + Remember Backup Password + In the future, without backup password it is not possible to restore your wallets from Cloud Backup.\n%s + This password cannot be recovered. + Create your backup password + Please enter a password to access your backup anytime. The password can’t be recovered, be sure to remember it! + Backup password + Confirm password + Min. 8 characters + Numbers + Letters + Passwords match + + Password is invalid + Please, check password correctness and try again. + + Connection Failed + Please, check your connection or try again later + + No Backups Found + Unfortunately, we have not found a backup to restore wallets + + Google Drive authentication failed + Please ensure that you are logged into your Google account with the correct credentials and have granted Pezkuwi Wallet access to Google Drive + + Google Drive error + Unable to backup your wallets to Google Drive. Please ensure that you have enabled Pezkuwi Wallet to use your Google Drive and have enough available storage space and then try again. + + Not Enough Storage + You do not have enough available Google Drive storage. + + Google Play services not found + Unfortunately, Google Drive is not working without Google Play services, which are missing on your device. Try to get Google Play services + + Your wallet is ready + Backup + This will only be visible to you and you can edit it later. + Now lets back up your wallet. This makes sure that your funds are safe and secure. Backups allow you to restore your wallet at any time. + Continue with Google + Continue with manual backup + + Prepare to save your wallet! + Tap to reveal + Make sure no one can see your screen\nand do not take screenshots + + Please %s anyone + do not share with + + + Write down your Passphrase + + My new wallet + Give your wallet a name + Enter wallet name + + Cloud backup + Recover wallets from Google Drive + Looking for your backup... + + Use your 12, 15, 18, 21 or 24-word phrase + Watch-only + + Hardware wallet + + Polkadot Vault, Parity Signer or Ledger + + Choose how you would like to import your wallet + + Something went wrong + Please try again later by accessing notification settings from the Settings tab + + + Select at least %d wallet + Select at least %d wallets + + + Terms and Conditions + Privacy Policy + + Received +0.6068 KSM ($20.35) from Kusama staking + Pezkuwi Wallet • now + + You won\'t receive notifications about Wallet Activities (Balances, Staking) because you haven\'t selected any wallet + + pezkuwi + pezkuwiwallet + + ⬇️ Received %s + ⬇️ Received + Received %s on %s + + 💸 Sent %s + 💸 Sent + Sent %1$s to %2$s on %3$s + + ⭐️ New reward + ⭐️ New reward %s + Received %s from %s staking + + ✅ Referendum approved! + %1$s referendum #%2$s has ended and been approved 🎉 + ❌ Referendum rejected! + %1$s referendum #%2$s has ended and been rejected! + 🗳️ Referendum status changed + %1$s Referendum #%2$s status changed from %3$s to %4$s + %1$s Referendum #%2$s status changed to %3$s + + In queue + + A new update of Pezkuwi Wallet is available! + Download Pezkuwi Wallet v%s to get all the new features! + 🗳️ New referendum + %s Referendum #%s is now live! + + + You need to select at least %d track + You need to select at least %d tracks + + + Select tracks for + %d of %d + + default_notification_channel_id + transactions_notification_channel_id + default_notifications_channel_id + staking_notification_channel_id + Default Notification Channel + Transactions Notification Channel + Governance Notification Channel + Staking Notifications Channel + + New Referendum + Referendum Update + Delegate has voted + Clear + + You cannot select more than %d wallets + + Staking rewards + Others + + Received tokens + Sent tokens + Balances + + Pezkuwi announcements + + Enable notifications + Wallets + + Something went wrong. Please try again + Push notifications + + Don’t miss a thing! + Get notified about Wallet operations, Governance updates, Staking activity and Security, so you’re always in the know + Enable push notifications + By enabling push notifications, you agree to our %s and %s + + Due to the cross-chain restrictions you can transfer not more than %s + + Your balance is too small + You need to have at least %s to pay this transaction fee and stay above the minimum network balance. Your current balance is: %s. You need to add %s to your balance to perform this operation. + + %s units of %s + + %s for %s + + Use Proxies to delegate Staking operations to another account + + Select stash account to setup proxy + Please switch your wallet to %s to setup a proxy + + %s is not supported + + Revoke access type + Revoke for + + Add delegation + + Delegated authorities (proxy) + Revoke access + + Staking operations + + Delegating wallet + Delegating account + Grant access type + Delegate to + + Add delegation + + Delegation already exists + You are already delegating to this account: %s + + Invalid proxy address + Proxy address should be a valid %s address + + The deposit stays reserved on your account until the proxy is removed. + + Maximum number of proxies has been reached + You have reached the limit of %s added proxies in %s. Remove proxies to add new ones. + + Not enough tokens + You don’t have enough balance for proxy deposit of %s. Available balance: %s + + Your delegations + Add delegated authority (Proxy) + + Give authority to + Proxy deposit + Add delegation for %s staking + + Enter address… + Delegated account %s doesn’t have enough balance to pay the network fee of %s. Available balance to pay fee: %s + No access to controller account + Add your controller account in device. + + Staking is an option to earn passive income by locking your tokens in the network. Staking rewards are allocated every era (6 hours on Kusama and 24 hours on Polkadot). You can stake as long as you wish, and for unstaking your tokens you need to wait for the unstaking period to end, making your tokens available to be redeemed. + + %1$s has not delegated %2$s + + Not enough tokens to pay the fee + + This account granted access to perform transactions to the following account: + + This is Delegating (Proxied) account + Transaction will be initiated by %s as a delegated account. Network fee will be paid by delegated account. + + Oops! Not enough permission + %1$s delegated %2$s only for %3$s + + Proxied wallets do not support signing arbitrary messages — only transactions + + %s proxy + Any + Non Transfer + Governance + Staking + Identity Judgement + Cancel Proxy + Auction + Nomination Pools + + Chain is not found + Governance type is not specified + Governance type is not supported + Invalid url + Domain from the link %s is not allowed + Mnemonic is not valid + Crypto type is invalid + Invalid derivation path + + Referendum not found + + Swap + + Repeat the operation + + Swaps + + You should keep at least %s after paying %s network fee as you are holding non sufficient tokens + You must keep at least %s to receive %s token + + Exchange rate between two different cryptocurrencies. It represents how much of one cryptocurrency you can get in exchange for a certain amount of another cryptocurrency. + Price difference refers to the difference in price between two different assets. When making a swap in crypto, the price difference is usually the difference between the price of the asset you are swapping for and the price of the asset you are swapping with. + Swap slippage is a common occurrence in decentralized trading where the final price of a swap transaction might slightly differ from the expected price, due to changing market conditions. + A network fees charged by the blockchain to process and validate any transactions. May vary depending on network conditions or transaction speed. + + Select a token to pay + Select a token to receive + Enter amount + Enter other amount + + Not enough liquidity + Not enough tokens to swap + You can’t receive less than %s + + You can use up to %s since you need to pay\n%s for network fee. + + You are trying to swap too small amount + Amount of %s is less than minimum balance of %s + + Invalid slippage + Slippage must be specified between %s and %s + Swap rate was updated + Old rate: %1$s ≈ %2$s.\nNew rate: %1$s ≈ %3$s + Pool doesn’t have enough liquidity to swap + Too small amount remains on your balance + You should leave at least %1$s on your balance. Do you want to perform full swap by adding remaining %2$s as well? + You should keep at least %1$s after paying %2$s network fee and converting %3$s to %4$s to meet %5$s minimum balance.\n\nDo you want to fully swap by adding remaining %6$s as well? + You can swap up to %1$s since you need to pay %2$s for network fee. + You can swap up to %1$s since you need to pay %2$s for network fee and also convert %3$s to %4$s to meet %5$s minimum balance. + + Swap max + Swap min + + Cross-chain transfer + + Hexadecimal string + + Get %s using + Transfer %s from another network + Receive %s with QR or your address + Instantly buy %s with a credit card + + Get %s + + Token for paying network fee + Network fee is added on top of entered amount + To pay network fee with %s, Pezkuwi Wallet will automatically swap %s for %s to maintain your account\'s minimum %s balance. + + Enter other value + Enter a value between %s and %s + Transaction might be reverted because of low slippage tolerance. + Transaction might be frontrun because of high slippage. + Slippage + Swap settings + + % + + Token to pay + Token to receive + + This pair is not supported + %s ≈ %s + + Select a token + Pay + Receive + + Rate + Price difference + Slippage + Wallet + + Max: + You pay + You receive + Swap details + Swap + Select a token + + Boost your DOT 🚀 + Received your DOT back from crowdloans? Start staking your DOT today to get the maximum possible rewards! + + Buy tokens + + You don’t have tokens to send.\nBuy or Deposit tokens to your account. + %s and %s + daily + + Deciding + Voting: Deciding + + Stake anytime. Your stake will actively earn rewards %s + + + Pool is full + You cannot join pool since it reached maximum number of members + + Pool is not open + You cannot join pool that is not open. Please, contact the pool owner. + + Are you sure you want to close this screen?\nYour changes will not be applied. + + Search result will be displayed here + + No pool with entered name or pool ID were found. Make sure you entered correct data + + Select pool + active pools: %d + members + + Your stake is less than the minimum to earn rewards + You have specified less than the minimum stake of %s required to earn rewards with %s. You should consider using Pool staking to earn rewards. + Staking type cannot be changed + You are already staking in a pool + You are already have Direct staking + Unsupported staking type + + Minimum stake: %s + Rewards: Claim manually + Rewards: Paid automatically + Reuse tokens in Governance + Advanced staking management + + Staking Type + Pool staking + Direct staking + + Unstake all + + Sorry, you don\'t have enough funds to spend the specified amount + + You can\'t stake less than the minimal value (%s) + + You need to add a %s account to your wallet in order to start staking + You need to add a %s account to the wallet in order to delegate + You need to add a %s account to the wallet in order to vote + You need to add a %s account to the wallet in order to contribute + + + Staking info + Pool staking info + + You are already have staking in %s + + Unable to stake more + You are unstaking all of your tokens and can\'t stake more. + + Stake max + Your available balance is %s, you need to leave %s as minimal balance and pay network fee of %s. You can stake not more than %s. + Staking type + + The pool you have selected is inactive due to no validators selected or its stake is less than the minimum.\nAre you sure you want to proceed with the selected Pool? + + You can\'t stake the specified amount + You have locked tokens on your balance due to %s. In order to continue you should enter less than %s or more than %s. To stake another amount you should remove your %s locks. + + %s is currently unavailable + The maximum number of nominators has been reached. Try again later + + Validators: %d (max %d) + Selected: %d (max %d) + Recommended + + Network fee is too high + The estimated network fee %s is much higher than the default network fee (%s). This might be due to temporary network congestion. You can refresh to wait for a lower network fee. + Refresh fee + + Tokens will be lost + Recipient is a system account. It is not controlled by any company or individual.\nAre you sure you still want to perform this transfer? + + This token already exists + The entered contract address is present in Pezkuwi Wallet as a %s token. Are you sure you want to modify it? + + Claim rewards + Your tokens will be added back to the stake + + Too many people are unstaking from your pool + There are currently no free spots in unstaking queue for your pool. Please try again in %s + + When unstaking partially, you should leave at least %s in stake. Do you want to perform full unstake by unstaking remaining %s as well? + Too small amount remains in stake + + Cannot perform specified operation since pool is in destroying state. It will be closed soon. + Pool is destroying + + Your rewards (%s) will also be claimed and added to your free balance + + Your pool (#%s) + Your pool + + Pool + Direct + + Start Staking + Available balance: %1$s (%2$s) + + Earn up to %s + %1$s\non your %2$s tokens\nper year + + Stake anytime with as little as %1$s. Your stake will actively earn rewards %2$s + in %s + %1$s is a %2$s with %3$s + test network + no token value + + Unstake anytime, and redeem your funds %s. No rewards are earned while unstaking + after %s + + every %s + Rewards accrue %s and added back to the stake + Rewards accrue %s and added to the transferable balance + Rewards accrue %1$s. Stake over %2$s for automatic rewards payout, otherwise you need to claim rewards manually + Rewards accrue %s. You need to claim rewards manually + Rewards accrue %s + + %s with your staked tokens + Participate in governance + + Stake over %1$s and %2$s with your staked tokens + participate in governance + + Monitor your stake + Rewards and staking status vary over time. %s from time to time + + Find out more information about\n%1$s staking over at the %2$s + Pezkuwi Wiki + + See %s + Terms of Use + + For security reasons generated operations valid for only %s.\nPlease generate new QR code and sign it with %s + Invalid QR code, please make sure you are scanning QR code from %s + I have an error in %s + Sign with %s + Following accounts have been successfully read from %s + Scan the QR code from the %s + Add wallet from %s + This wallet is paired with %1$s. Pezkuwi Wallet will help you to form any operations you want, and you will be requested to sign them using %1$s + Not supported by %s + %s does not support signing arbitrary messages — only transactions + %s doesn’t support %s + + Pay attention, the derivation path name should be empty + + Parity Signer + Polkadot Vault + + Controller Accounts Are Being Deprecated + Update Controller to Stash + + No networks or tokens with entered\nname were found + + From: %s + To: %s + + %s at %s + + There are no referenda with filters applied + + Use biometric to authorize + + You are not authorized. Try again, please. + + Settings + Biometrics disabled in Settings + Please, make sure biometrics is enabled in Settings + + Show + All referenda + Not voted + Voted + Filters + + Referenda + Search by referendum title or ID + No referenda with entered title or ID were found + + Pezkuwi Wallet uses biometric authentication to restrict unauthorized users from accessing the app. + + Select date + Select start date + Select end date + + All + 7D + 30D + 3M + 6M + 1Y + %dD + + Rewards + + Show staking rewards for + All time + Last 7 days (7D) + Last 30 days (30D) + Last 3 month (3M) + Last 6 months (6M) + Last year (1Y) + Custom period + + Starts + End date is always today + Ends + + Ask authentication for operation signing + Each sign operation on wallets with key pair (created in Pezkuwi Wallet or imported) should require PIN verification before constructing signature + + Biometric auth + Approve with PIN + + Address or w3n + + Recipient cannot accept transfer + Recipient has been blocked by token owner and cannot currently accept incoming transfers + + %s staking + + More staking options + Stake and earn rewards + Staking via Pezkuwi DApp browser + Loading staking info… + + Waiting + / year + per year + Estimated rewards + + Expired + + + Network + Networks + + + Required + Optional + + + %s unsupported network is hidden + %s unsupported networks are hidden + + + Some of the required networks requested by \"%s\" are not supported in Pezkuwi Wallet + + + %s account is missing. Add account to the wallet in Settings + %s accounts are missing. Add accounts to the wallet in Settings + + + + %s unsupported + %s unsupported + + + WalletConnect v2 + + Sign Request + + Wallet Connect sessions will appear here + + Active + None + Networks + Disconnect + + Unknown dApp + New connection + WalletConnect + Scan the QR code + + Function + Contract + Contract call + + Integrity check failed + Pezkuwi Wallet detected issues with integrity of information about %1$s addresses. Please contact owner of %1$s to resolve integrity issues. + + Token %s is not supported yet + Pezkuwi Wallet can\'t resolve code for token %s + %s (%s) + Invalid recipient + Error resolving w3n + %1$s w3n services are unavailable. Try again later or enter the %1$s address manually + %s not found + No valid address was found for %s on the %s network + %s addresses for %s + + No suitable app found on device to handle this intent + + Please enable geo-location in device settings + Pezkuwi Wallet needs location to be enabled to be able to perform bluetooth scanning to find your Ledger device + + Failed to submit some transactions + There was an error while submitting some transactions. Do you want to retry? + + The recommended minimum stake to consistently receive rewards is %s. + + Stake more tokens + + Your stake is less than minimum of %s.\nHaving stake less than minimum increases chances of staking to not generate rewards + + Current queue slot + New queue slot + + Staking improvements + Having outdated position in the queue of stake assignment to a validator may suspend your rewards + + Address format is invalid. Make sure that address belongs to the right network + search results: %d + Search by address or name + + Governance v1 + OpenGov + + Pay out all (%s) + You can pay them out yourself, when they are close to expire, but you will pay the fee + + + Abstain + + After the undelegating period has expired, you will need to unlock your tokens. + + Edit delegation + Revoke delegation + + Select tracks to add delegation + Select tracks to revoke your delegation + Select tracks to edit delegation + + Revoke + + Main agenda + Fellowship: whitelist + Staking + Treasury: any + Governance: lease + Fellowship: admin + Governance: registrar + Crowdloans + Governance: canceller + Governance: killer + Treasury: small tips + Treasury: big tips + Treasury: small spend + Treasury: medium spend + Treasury: big spend + + Your votes: %s via %s + Your votes via %s + + Delegate + Your delegation + + Your votes will automatically vote alongside your delegates\' vote + Undelegating period will start after you revoke a delegation + + Cannot delegate to yourself + You cannot delegate to yourself, please choose different address + + Undelegating period + + Across %s tracks + + Remove votes + Tracks + + %s (+%s more) + + Voted for all time + Delegate info + + Voted referenda + + %s votes by %s + + Your delegations + + Are you a Delegate? + Tell us more about yourself so Pezkuwi Wallet users get to know you better + Describe yourself + + Sort by + + All accounts + Organizations + Individuals + + Organization + Individual + + Delegations + Delegated votes + Voted last %s + + Delegations + Delegated votes + Voted last %s + + Available tracks + Unavailable tracks + Please select the tracks in which you would like to delegate your voting power. + + Select all + Treasury + Governance + Fellowship + + Select at least 1 track... + No tracks are available to delegate + + Remove history of your votes? + + You have previously voted in referendums in %d track. In order to make this track available for delegation, you need to remove your existing votes. + You have previously voted in referendums in %d tracks. In order to make these tracks available for delegation, you need to remove your existing votes. + + + Remove votes + + Unavailable tracks + Remove votes in order to delegate in these tracks + + Tracks which you have existing votes in + Tracks that you have already delegated votes to + +%d + + Show + + Version %s + + See all available updates + Latest + Critical + Major + + Critical update + To avoid any issues, and improve your user experience, we strongly recommend that you install recent updates as soon as possible + + Major update + Lots of amazing new features are available for Pezkuwi Wallet! Make sure to update your application to access them + + Update available + Install + + Sorry, you don\'t have the right app to process this request + + Enable + Safe mode + Screen recording and screenshots will not be available. The minimized app will not display the content + + Safe mode + + Page settings + Add to Favorites + Remove from Favorites + Desktop mode + + + The entered contract address is not a %s ERC-20 contract. + + Filter tokens + + Add token + + The entered contract address is present in Pezkuwi Wallet as a %s token. + + Invalid contract address + + Invalid decimals value + Decimals must be at least 0, and not over 36. + + Invalid CoinGecko link + Please make sure supplied url has the following form: www.coingecko.com/en/coins/tether. + + Enter contract address + Enter symbol + Enter decimals + Add token + + Enter ERC-20 token details + + Contract address + 0x... + + Symbol + USDT + + Decimals + 18 + + Select network to add ERC-20 token + + Disabled + + Manage tokens + All networks + + File import application not found on device. Please install it and try again + + Referenda information will appear here when they start + + Unsupported chain with genesis hash %s + + remains locked in %s + + Unlockable + + Reuse governance lock: %s + Reuse all locks : %s + + Amount is too big + You don’t have enough available tokens to vote. Available to vote: %s. + + Referendum is completed + Referendum is completed and voting has finished + + Already delegating votes + You are delegating votes for selected referendum’s track. Please either ask your delegatee to vote or remove delegation to be able to vote directly. + + Maximum number of votes reached + You have reached a maximum of %s votes for track + + Revote + + After locking period don’t forget to unlock your tokens + + %s maximum + + Governance lock + Locking period + + Vote for %s + Multiply votes by increasing locking period + + Voting: Preparing + Voting: Waiting for deposit + Voting: In queue + Voting: Passing + Voting: Not passing + + Position: %s of %s + + Created + Voted: Approved + Voted: Rejected + Executed + Cancelled + Killed + Timed out + + %s votes + + Read more + + Ongoing + Completed + + Aye: %s + Nay: %s + To pass: %s + + Vote + Governance + + Referendum %s + + Threshold: %s of %s + Your vote: %s votes + + Approved + In queue (%s of %s) + Timed out + Cancelled + Passing + Not passing + Rejected + Preparing + Executed + Killed + + Deciding in %s + Waiting for deposit + Time out in %s + Reject in %s + Approve in %s + Execute in %s + + Aye + Nay + + Your vote: + Timeline + + Use Pezkuwi DApp browser + + Voting status + Requested amount + + Parameters JSON + Too long for preview + Proposer + Deposit + Beneficiary + Vote threshold + Turnout + Electorate + Approve curve + Support curve + Aye votes + Nay votes + + Only the proposer can edit this description and the title. If you own proposer\'s account, visit Polkassembly and fill in information about your proposal + + %s votes + %s × %sx + + Vote + Track + Referendum + List of voters will appear here + + Unlock + + Liquid + Parallel + + Crowdloans + + Accept terms... + Yield Boost will automatically stake %s all my transferable tokens above %s + + Stake increase time + Yield Boosted + + Change the Yield Boosted Collator? + Yield Boost will be turned off for current collators. New collator: %s + + Not enough tokens to stay above threshold + You don’t have enough balance to pay the network fee of %s and not drop below the threshold %s.\nAvailable balance to pay the fee: %s + + Not enough tokens to pay first execution fee + You don’t have enough balance to pay the network fee of %s and the yield boost execution fee of %s.\nAvailable balance to pay the fee: %s + + No changes + + without Yield Boost + with Yield Boost + + For my collator + I want to stake + to automatically stake %s all my transferable tokens above + to automatically stake %s (before: %s) all my transferable tokens above + + everyday + + everyday + every %s days + + Boost threshold + + On + Off + Yield Boost + + Ledger doesn’t support %s + + Ledger does not support signing arbitrary messages — only transactions + + Do not transfer %s to the Ledger-controlled account since Ledger does not support sending of %s, so assets will be stuck on this account + Ledger does not support this token + + Signature is invalid + Please, make sure you have selected the right Ledger device for currently approving operation + + Transaction is not supported + Ledger does not support this transaction. + + Metadata is outdated + Please, update %s app using Ledger Live app + + Transaction is valid for %s + + Review and Approve + Press both buttons on your %s to approve the transaction + + For security reasons generated operations valid for only %s. Please try again and approve it with Ledger + Transaction has expired + + This wallet is paired with Ledger. Pezkuwi Wallet will help you to form any operations you want, and you will be requested to sign them using Ledger + + Ledger + + Load more accounts + Select account to add to wallet + + Ledger operation failed + Operation completed with error on device. Please, try again later. + + Operation cancelled + Operation was cancelled by the device. Make sure you unlocked your Ledger. + + %s app not launched + Open the %s app on your Ledger device + + Select your Ledger device + Please, enable Bluetooth in your phone settings and Ledger device. Unlock your Ledger device and open the %s app. + + Are you sure you want to cancel this operation? + Yes + No + + Add at least one account + Add accounts to your wallet + Make sure Network app is installed to your Ledger device using Ledger Live app. Open the network app on your Ledger device. + + Select hardware wallet + + Total + + Currency + Cryptocurrencies + Fiat currencies + Popular fiat currencies + + Your wallets + + Staking + Vesting + Elections + + Signing is not supported + + QR code is invalid + Please make sure you are scanning QR code for currently signing operation + + Try again + + QR code has expired + QR code is valid for %s + + Here are your accounts + + + Permissions needed + Requested permissions are required to use this screen. + Ask again + + Permissions denied + Requested permissions are required to use this screen. You should enable them in Settings. + Open Settings + + Make sure to select top one + Make sure you have exported your wallet before proceeding. + Forget wallet? + + Enter valid %s address... + %s address + + This is watch-only wallet, Pezkuwi Wallet can show you balances and other information, but you cannot perform any transactions with this wallet + Add account + + Select wallet + + watch-only + + Import wallet + Pezkuwi Wallet is compatible with all apps + + Track any wallet by its address + + Okay, back + + Oops! Key is missing + Your wallet is watch-only, meaning that you cannot do any operations with it + + Add watch-only wallet + + Enter wallet nickname... + Enter valid substrate address... + Evm address must be valid or empty... + + Substrate Address + Polkadot, Kusama, Karura, KILT and 50+ networks + + EVM address + EVM address (Optional) + Moonbeam, Moonriver and other networks + + Preset wallets + Track the activity of any wallet without injecting your private key to Pezkuwi Wallet + + Tokens + + Search by network or token + + Returns in %s + To be returned by parachain + + + %d minute + %d minutes + + Myself + + You don’t have enough balance to pay the Cross-chain fee of %s.\nRemaining balance after transfer: %s + + Cross-chain fee is added on top of entered amount. Recipient may receive part of cross-chain fee + + To network + From network + Cross-chain fee + + On-chain + Cross-chain + Recipient network + to + + Send %s on + Send %s from + + Cannot stake with this collator + The selected collator showed intention to stop participating in the staking. + + Cannot add stake to this collator + You cannot add stake to collator for which you are unstaking all tokens. + + Manage collators + + Change collator + One of your collators is not selected in the current round + + Your unstaking period for %s has passed. Don’t forget to redeem your tokens + One of your collators is not generating rewards + + Returned tokens will be counted from the next round + + No collators available for unstake + You have pending unstake requests for all of your collators. + + Unstake all? + Remaining staking balance will drop under minimum network value (%s) and will also be added to the unstaking amount + Your stake will be less than the minimum stake (%s) for this collator. + + You are already unstaking tokens from this collator. You can only have one pending unstake per collator + You cannot unstake from this collator + You will get increased rewards starting from the next round + You can\'t select a new collator + You have reached the maximum number of delegations of %d collators + + New collator + Collator\'s minimum stake is higher than your delegation. You will not receive rewards from the collator. + + Some of your collators are either not elected or have a higher minimum stake than your staked amount. You will not receive a reward in this round staking with them. + Your stake is assigned to next collators + Active collators without producing rewards + Collators without enough stake to be elected + Collators that will enact in the next round + Pending collators (%s) + + Your collators + + waiting for the next round (%s) + You will not receive rewards for this round since none of your delegations is active. + + Collator info + Delegators + Estimated rewards (%% APR) + + Collator\'s own stake + Collators: %s + Min. stake + + Tokens in stake produce rewards each round (%s) + Minimum stake should be greater than %s + + %s account is missing + + Enter amount… + Select collator… + You will not receive rewards + Your stake must be greater than the minimum stake (%s) for this collator. + %s / year + + Collator + Select collator + + Average + Maximum + + Earnings with restake + Earnings without restake + + + Active delegators + One or more of your collators have been elected by the network. + + List of DApps will appear here + + Remove from Authorized? + “%s” DApp will be removed from Authorized + + Catalog + + Authorized DApps + DApps to which you allowed access to see your address when you use them + + Title + + Save + Add to favorites + + Remove from Favorites? + “%s” DApp will be removed from Favorites + + Remove + Favorites + + Amount must be positive + + Phishing detected + Pezkuwi Wallet believes that this website could compromise the security of your accounts and your tokens + Okay, take me back + + Pezkuwi Wallet will select the top validators based on security and profitability criteria + + %s rewarded + + Status + + Nominated: + + Change controller + Controller + Stash + + Find out more + Improve staking security + Select another account as a controller to delegate staking management operations to it + + Rewards are paid every 2–3 days by validators + + How it works + Payout + Transferable rewards + + Amount you want to unstake is greater than staked balance + Returned tokens will be counted from the next era + Change validators + Set validators + with at least one identity contact + Custom amount + All unstaking + Latest unstake + Select payout account + + About rewards + Reward destination + Stake %s + + Cannot open this link + + + Invalid recipient + Recipient should be a valid %s address + + Paste + + Sender + Recipient + Send to this contact + + Transaction ID + Type + + Not listed + Price + + Collection + Owned by + Created by + + Unlimited series + #%s Edition of %s + Your NFTs + + Your assets will appear here.\nMake sure the "Hide zero balances" filter is turned off + Hide assets with zero balances + + Your account will be removed from blockchain after transfer cause it makes total balance lower than minimal. Remaining balance will be transferred to recipient as well. + Recipient is not able to accept transfer + Your transfer will fail since the destination account does not have enough %s to accept other token transfers + + Close + + Search by name or enter URL + All + Make sure the operation is correct + Failed to sign requested operation + Network + Account address + Allow “%s” to access your account addresses? + DApp + Approve this request if you trust the application + Approve this request if you trust the application.\nCheck the transaction details. + Reject + Allow + + Contact Us + Github + Privacy policy + Rate us + Telegram + Terms and conditions + Terms & Conditions + About + App version + Pezkuwi Wallet v%s + Website + Account already exists. Please, try another one. + Please make sure to write down your phrase correctly and legibly. + Confirm mnemonic + Let’s double check it + Choose words in the right order + Create a new account + Do not use clipboard or screenshots on your mobile device, try to find secure methods for backup (e.g. paper) + Name will be used only locally in this application. You can edit it later + Create wallet name + Backup mnemonic + Mnemonic is used to recover access to account. Write it down, we will not be able to recover your account without it! + Accounts with a changed secret + Forget + Invalid Ethereum derivation path + Invalid Substrate derivation path + Please, try another one. + Ethereum keypair crypto type + Ethereum secret derivation path + Export account + Export + Set a password for your JSON file + Save your secret and store it in a safe place + Write down your secret and store it in a safe place + Invalid restore json. Please, make sure that input contains valid json. + Seed is invalid. Please, make sure that your input contains 64 hex symbols. + JSON contains no network information. Please specify it below. + Provide your Restore JSON + Typically 12-word phrase (but may be 15, 18, 21 or 24) + Write words separately with one space, no commas or other signs + Enter the words in the right order + Password + 0xAB + Enter your raw seed + Account + Your derivation path contains unsupported symbols or has incorrect structure + Invalid derivation path + JSON file + 12, 15, 18, 21 or 24-word phrase + You don’t have account for this network, you can create or import account. + Account needed + No account found + Private key + Already have an account + 64 hex symbols + Select your secret type + Accounts with a shared secret + Substrate keypair crypto type + Substrate secret derivation path + Wallet nickname + Add %s account + Add wallet + Change %s account + Change account + Purchase initiated! Please wait up to 60 minutes. You can track status on the email. + Asset + Available balance + Sorry, balance checking request failed. Please, try again later. + Sorry, we couldn\'t contact transfer provider. Please, try again later. + Transfer fee + 0 + Network not responding + To + Account + Add + Address + Advanced + Amount + Amount is too low + Applied + Apply + Attention! + Available: %s + Balance + Bonus + Call + Cancel + Chain + Change + Choose network + Completed (%s) + Confirm + Confirmation + Are you sure? + Confirmed + Continue + Copied to clipboard + Copy address + Copy id + Keypair crypto type + Date + Details + Done + Edit + Select email app + Error + Event + Your account will be removed from blockchain after this operation cause it makes total balance lower than minimal + Operation will remove account + Explore + Sort by: + + %d hour + %d hours + + I understand + Info + Learn more + Find out more about + %s left + Module + Next + Sorry, you don\'t have enough funds to pay the network fee. + Insufficient balance + OK + Select an option + Proceed + Reset + Retry + Search + Search results: %d + Secret derivation path + Share + Skip + Skip process + Time left + Transaction submitted + Please, try again with another input. If the error appears again, please, contact support. + Unknown + Unlimited + Update + Use + Warning + The node has already been added previously. Please, try another node. + Can\'t establish connection with node. Please, try another one. + Unfortunately, the network is unsupported. Please, try one of the following: %s. + Confirm %s deletion. + Delete network? + Please, check your connection or try again later + Custom + Default + Networks + Add connection + Scan QR code + Private crowdloans are not yet supported. + Private crowdloan + About crowdloans + Direct + Learn more about different contributions to Acala + Liquid + Active (%s) + Agree to Terms and Conditions + Pezkuwi Wallet bonus (%s) + Astar referral code should be a valid Polkadot address + Cannot contribute chosen amount since resulting raised amount will exceed crowdloan cap. Maximum allowed contribution is %s. + Cannot contribute to selected crowdloan since its cap is already reached. + Crowdloan cap exceeded + Contribute to crowdloan + Contribution + You contributed: %s + Your contributions\n will appear here + %s (via %s) + Crowdloans + Get a special bonus + Crowdloans will be displayed here + Cannot contribute to selected crowdloan since it is already ended. + Crowdloan is ended + Enter your referral code + Crowdloan info + Learn %s\'s crowdloan + %s\'s crowdloan website + Leasing period + Choose parachains to contribute your %s. You\'ll get back your contributed tokens, and if parachain wins a slot, you\'ll receive rewards after the end of the auction + Apply bonus + If you don\'t have referral code, you can apply Pezkuwi referral code to receive bonus for your contribution + You have not applied bonus + Moonbeam crowdloan supports only SR25519 or ED25519 crypto type accounts. Please consider using another account for contribution + Cannot contribute with this account + You should add Moonbeam account to the wallet in order to participate in Moonbeam crowdloan + Moonbeam account is missing + This crowdloan isn\'t available in your location. + Your region is not supported + %s reward destination + Submit agreement + You need to submit agreement with Terms & Conditions on the blockchain to proceed. This is required to be done only once for all following Moonbeam contributions + I have read and agree to Terms and Conditions + Raised + Referral code + Referral code is invalid. Please, try another one + %s\'s Terms and Conditions + The minimum allowed amount to contribute is %s. + Contribution amount is too small + Your %s tokens will be returned after the leasing period. + Your contributions + Raised: %s of %s + DApps + (BTC/ETH compatible) + ECDSA + ed25519 (alternative) + Edwards + Confirm password + Passwords do not match + Set password + Network: %s\nMnemonic: %s\nDerivation path: %s + Network: %s\nMnemonic: %s + Please wait until fee is calculated + Fee calculation is in progress + History + Email + Legal name + Element name + Identity + Web + Supplied JSON file was created for different network. + Please, make sure that your input contains valid json. + Restore JSON is invalid + Please, check password correctness and try again. + Keystore decryption failed + Paste json + Unsupported encryption type + Cannot import account with Substrate secret into the network with Ethereum encryption + Cannot import account with Ethereum secret into the network with Substrate encryption + Your mnemonic is invalid + Please, make sure that your input contains 64 hex symbols. + Seed is invalid + QR can\'t be decoded + QR code + Upload from gallery + Language + Payout transaction sent + Please, try another one. + Invalid mnemonic passphrase, please check one more time the words order + Network fee + Node address + Node Info + Connecting… + Create account + Create a new wallet + Privacy Policy + Import account + Already have a wallet + Terms and Conditions + Biometry + Pin code has been successfully changed + Confirm your pin code + Create pin code + Enter PIN code + Set your pin code + Accounts + Wallets + Language + Change pin code + Settings + Paste json or upload file… + Upload file + Restore JSON + Mnemonic passphrase + Raw seed + Source type + Failed to update information about blockchain runtime. Some functionality may not work. + Runtime update failure + Contacts + my accounts + Account address or account name + Search results will be displayed here + Search results + Community + General + Preferences + Security + Support & Feedback + Twitter + Youtube + sr25519 (recommended) + Schnorrkel + Selected account is already in use as controller + Add controller account %s to the application to perform this action. + Change your validators. + Everything is fine now. Alerts will appear here. + Redeem unstaked tokens + Please wait for the next era to start. + Alerts + Already controller + %s APR + %s APY + Staking balance + Balance + Stake more + You are neither nominating nor validating + %s of %s + Selected validators + Controller account + We found that this account has no free tokens, are you sure that you want to change the controller? + Controller can unstake, redeem, return to stake, change rewards destination and validators. + Controller is used to: unstake, redeem, return to stake, change validators and set rewards destination + Controller is changed + This validator is blocked and cannot be selected at the moment. Please, try again in the next era. + Clear filters + Deselect all + Fill rest with recommended + Validators: %d of %d + Select validators (max %d) + Show selected: %d (max %d) + Select validators + Estimated rewards (%% APY) + Update your list + #%d + era #%s + Estimated earnings + Estimated %s earnings + Validator\'s own stake + Validator\'s own stake (%s) + Tokens in unstaking period generate no rewards. + During unstaking period tokens produce no rewards + After unstaking period you will need to redeem your tokens. + After unstaking period don\'t forget to redeem your tokens + Your rewards will be increased starting from the next era. + You will get increased rewards starting from next era + Staked tokens generate rewards each era (%s). + Tokens in stake produce rewards each era (%s) + Pezkuwi Wallet will change rewards destination\nto your account to avoid remaining stake. + If you want to unstake tokens, you will have to wait for the unstaking period (%s). + To unstake tokens you will have to wait for the unstaking period (%s) + Active nominators + + %d day + %d days + + Minimum stake + %s network + Staked + Manage + %s (max %s) + Maximum number of nominators has been reached + Cannot start staking + Monthly + One of your validators have been elected by network. + Active status + Inactive status + Your staked amount is less than the minimum stake to get a reward. + None of your validators have been elected by network. + Your staking will start in the next era. + Inactive + Waiting for the next Era + waiting for the next era (%s) + Payout expired + + %d day left + %d days left + + Return to stake + Amount you want to return to stake is greater than unstaking balance + Most profitable + Not oversubscribed + Having onchain identity + Not slashed + Limit of 2 validators per identity + Recommended validators + Validators + Estimated reward (APY) + Redeem + Redeemable: %s + Reward + Era + Reward details + Validator + + Perfect! All rewards are paid. + Awesome! You have no unpaid rewards + Pending rewards + Unpaid rewards + Rewards (APY) + Rewards destination + Select by yourself + Select recommended + selected %d (max %d) + Validators (%d) + Validators are not selected + Select validators to start staking + Restake + Restake rewards + How to use your rewards? + Select your rewards type + Payout account + Slash + + Staking period + You should trust your nominations to act competently and honest, basing your decision purely on their current profitability could lead to reduced profits or even loss of funds. + Choose your validators carefully, as they should act proficiently and honest. Basing your decision purely on the profitability could lead to reduced rewards or even loss of stake + Stake with your validators + + Stake with recommended validators + Start staking + Stash can bond more and set the controller. + Stash is used to: stake more and set the controller + Stash account %s is unavailable to update staking setup. + Nominator earns passive income by locking his tokens for securing the network. To achieve that, the nominator should select a number of validators to support. The nominator should be careful when selecting validators. If selected validator won’t behave properly, slashing penalties would be applied to both of them, based on the severity of the incident. + Pezkuwi Wallet provides a support for nominators by helping them to select validators. The mobile app fetches data from the blockchain and composes a list of validators, which have: most profits, identity with contact info, not slashed and available to receive nominations. Pezkuwi Wallet also cares about decentralization, so if one person or a company runs several validator nodes, only up to 2 nodes from them will be shown in the recommended list. + Who is a nominator? + Rewards for staking are available to payout at the end of each era (6 hours in Kusama and 24 hours in Polkadot). Network stores pending rewards during 84 eras and in most cases validators are paying out the rewards for everyone. However, validators might forget or something might happen with them, so nominators can payout their rewards by themselves. + Although rewards are usually distributed by validators, Pezkuwi Wallet helps by alerting if there are any unpaid rewards that are close to expiring. You will receive alerts about this and other activities on the staking screen. + Receiving rewards + Staking is an important part of network security and reliability. Anyone can run validator nodes, but only those who have enough tokens staked will be elected by the network to participate in composing new blocks and receive the rewards. Validators often do not have enough tokens by themselves, so nominators are helping them by locking their tokens for them to achieve the required amount of stake. + What is staking? + The validator runs a blockchain node 24/7 and is required to have enough stake locked (both owned and provided by nominators) to be elected by the network. Validators should maintain their nodes\' performance and reliability to be rewarded. Being a validator is almost a full-time job, there are companies that are focused to be validators on the blockchain networks. + Everyone can be a validator and run a blockchain node, but that requires a certain level of technical skills and responsibility. Polkadot and Kusama networks have a program, named Thousand Validators Programme, to provide support for beginners. Moreover, the network itself will always reward more validators, who have less stake (but enough to be elected) to improve decentralization. + Who is a validator? + Switch your account to stash to set the controller. + Staking + Rewarded + Total staked + Unstake + Unstaking transactions will appear here + Unstaking transactions will be displayed here + Unstaking: %s + Your tokens will be available to redeem after the unstaking period. + You have reached the unstaking requests limit (%d active requests). + Unstaking requests limit reached + Unstaking period + Estimated reward (%% APY) + Estimated reward + Validator info + Oversubscribed. You will not receive rewards from the validator in this era. + Nominators + Oversubscribed. Only the top staked nominators are paid rewards. + Own + No search results.\nBe sure you typed full account address + Validator is slashed for misbehaves (e.g. goes offline, attacks the network, or runs modified software) in the network. + Total stake + Total stake (%s) + The reward is less than the network fee. + Yearly + Your stake is allocated to the following validators. + Your stake is assigned to next validators + Elected (%s) + Validators who were not elected in this era. + Validators without enough stake to be elected + Others, who are active without your stake allocation. + Active validators without your stake assignment + Not elected (%s) + Your tokens are allocated to the oversubscribed validators. You will not receive rewards in this era. + Your stake + Your validators + Your validators will change in the next era. + Staking + Wallet + Today + Copy hash + Fee + From + Extrinsic Hash + Transaction details + View in %s + View in Polkascan + View in Subscan + Completed + Failed + Pending + Transfer + Incoming and outgoing\ntransfers will appear here + Your operations will be displayed here + Name + Wallet name + This name will be displayed only for you and stored locally on your mobile device. + This account is not elected by network to participate in the current era + Buy + Buy with + Receive + Receive %s + Send + Assets + Assets value + Available + Staked + Balance details + Total balance + Total after transfer + Frozen + Locked + Redeemable + Reserved + Transferable + Unstaking + Wallet + Extrinsic details + Other transactions + Show + Rewards and Slashes + Filters + Transfers + Manage assets + Name examples: Main account, My validator, Dotsama crowdloans, etc. + Share this QR to sender + Let sender scan this QR code + My %s address to receive %s: + Share QR code + Make sure that the address is\nfrom the right network + Address format is invalid.\nMake sure that address\nbelongs to the right network + Minimal balance + Confirm transfer + Your transfer will fail since the final amount on the destination account will be less than the minimal balance. Please, try to increase the amount. + Your transfer will remove account from blockstore since it will make total balance lower than minimal balance. + Your account will be removed from blockchain after transfer cause it makes total balance lower than minimal + Transfer will remove account + Following address: %s is known to be used in phishing activities, thus we are not recommending sending tokens to that address. Would you like to proceed anyway? + Scam alert + Transfer details + Yesterday + pezkuwi-wallet.app.link + pezkuwi-wallet-alternate.app.link + diff --git a/common/src/main/res/values/styles.xml b/common/src/main/res/values/styles.xml new file mode 100644 index 0000000..ea25f4c --- /dev/null +++ b/common/src/main/res/values/styles.xml @@ -0,0 +1,698 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/res/values/themes.xml b/common/src/main/res/values/themes.xml new file mode 100644 index 0000000..8286e5b --- /dev/null +++ b/common/src/main/res/values/themes.xml @@ -0,0 +1,56 @@ + + + + + + + \ No newline at end of file diff --git a/common/src/test/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2Test.kt b/common/src/test/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2Test.kt new file mode 100644 index 0000000..7914827 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2Test.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.common.data.secrets.v2 + +import io.novafoundation.nova.common.data.secrets.v1.Keypair +import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema.PrivateKey +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets.SubstrateDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets.SubstrateKeypair +import io.novafoundation.nova.test_shared.HashMapEncryptedPreferences +import io.novafoundation.nova.test_shared.assertSetEquals +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +private const val META_ID = 1L +private val ACCOUNT_ID = byteArrayOf(1) + +@RunWith(JUnit4::class) +class SecretStoreV2Test { + + private val secretStore = SecretStoreV2(HashMapEncryptedPreferences()) + + @Test + fun `should save and retrieve meta account secrets`() = runBlocking { + + val secrets = createMetaSecrets() + + secretStore.putMetaAccountSecrets(META_ID, secrets) + + val secretsFromStore = secretStore.getMetaAccountSecrets(META_ID) + + requireNotNull(secretsFromStore) + assertArrayEquals(secrets[SubstrateKeypair][PrivateKey], secretsFromStore[SubstrateKeypair][PrivateKey]) + } + + @Test + fun `should save and retrieve chain account secrets`() = runBlocking { + val secrets = createChainSecrets() + + secretStore.putChainAccountSecrets(META_ID, ACCOUNT_ID, secrets) + + val secretsFromStore = secretStore.getChainAccountSecrets(META_ID, ACCOUNT_ID) + + requireNotNull(secretsFromStore) + assertArrayEquals(secrets[ChainAccountSecrets.Keypair][PrivateKey], secretsFromStore[ChainAccountSecrets.Keypair][PrivateKey]) + + val metaSecrets = secretStore.getMetaAccountSecrets(META_ID) + + assertNull("Chain secrets should not overwrite meta account secrets", metaSecrets) + } + + @Test + fun `chain secrets should not overwrite meta secrets`() = runBlocking { + val metaSecrets = createMetaSecrets(derivationPath = "/1") + val chainSecrets = createChainSecrets(derivationPath = "/2") + + secretStore.putMetaAccountSecrets(metaId = 11, metaSecrets) + secretStore.putChainAccountSecrets(metaId = 1, accountId = ACCOUNT_ID, chainSecrets) + + val secretsFromStore = secretStore.getMetaAccountSecrets(11) + + requireNotNull(secretsFromStore) + assertEquals(metaSecrets[SubstrateDerivationPath], secretsFromStore[SubstrateDerivationPath]) + } + + @Test + fun `should delete secrets`() = runBlocking { + val metaSecrets = createMetaSecrets() + val chainSecrets = createChainSecrets() + + secretStore.putMetaAccountSecrets(metaId = META_ID, metaSecrets) + secretStore.putChainAccountSecrets(metaId = META_ID, accountId = ACCOUNT_ID, chainSecrets) + + secretStore.clearMetaAccountSecrets(META_ID, chainAccountIds = listOf(ACCOUNT_ID)) + + val metaSecretsLocal = secretStore.getMetaAccountSecrets(META_ID) + assertNull(metaSecretsLocal) + + val chainSecretsLocal = secretStore.getChainAccountSecrets(META_ID, ACCOUNT_ID) + assertNull(chainSecretsLocal) + } + + @Test + fun `should CRUD single additional secret`() = runBlocking { + val secretName = "additional secret key" + val secretValue = "value" + + val metaId = 0L + + secretStore.putAdditionalMetaAccountSecret(metaId, secretName, secretValue) + + val valueFromStore = secretStore.getAdditionalMetaAccountSecret(metaId, secretName) + assertEquals(secretValue, valueFromStore) + + val changedValue = "value changed" + secretStore.putAdditionalMetaAccountSecret(metaId, secretName, changedValue) + + val changedValueFromStore = secretStore.getAdditionalMetaAccountSecret(metaId, secretName) + assertEquals(changedValue, changedValueFromStore) + + secretStore.clearMetaAccountSecrets(metaId, chainAccountIds = emptyList()) + val shouldNotExists = secretStore.getAdditionalMetaAccountSecret(metaId, secretName) + assertNull(shouldNotExists) + } + + @Test + fun `should manage multiple additional secrets`() = runBlocking { + val metaId = 0L + + val secretNames = (0..10).map { "secret $it" } + + secretNames.forEach { + secretStore.putAdditionalMetaAccountSecret(metaId, secretName = it, value = it) + } + + val knownSecrets = secretStore.allKnownAdditionalSecretKeys(metaId) + assertSetEquals(secretNames.toSet(), knownSecrets) + + secretStore.clearMetaAccountSecrets(metaId, emptyList()) + + val knownSecretsAfterClear = secretStore.allKnownAdditionalSecretKeys(metaId) + assertTrue(knownSecretsAfterClear.isEmpty()) + } + + @Test + fun `known keys should be unique`() = runBlocking { + val metaId = 0L + + repeat(2) { + secretStore.putAdditionalMetaAccountSecret(metaId, "key", "value $it") + } + + val knownSecrets = secretStore.allKnownAdditionalSecretKeys(metaId) + assertEquals(1, knownSecrets.size) + } + + private fun createMetaSecrets( + derivationPath: String? = null, + ): EncodableStruct { + return MetaAccountSecrets( + substrateDerivationPath = derivationPath, + substrateKeyPair = Keypair( + privateKey = byteArrayOf(), + publicKey = byteArrayOf() + ) + ) + } + + private fun createChainSecrets( + derivationPath: String? = null, + ): EncodableStruct { + return ChainAccountSecrets( + derivationPath = derivationPath, + keyPair = Keypair( + privateKey = byteArrayOf(), + publicKey = byteArrayOf() + ) + ) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/FlowExtKtTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/FlowExtKtTest.kt new file mode 100644 index 0000000..c6ca500 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/FlowExtKtTest.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.common.utils + +import io.novafoundation.nova.common.utils.CollectionDiffer.Diff +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.withIndex +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +@JvmInline +private value class IntIdentifiable(val value: Int): Identifiable { + override val identifier: String + get() = value.toString() +} + +private data class Struct(val id: Int, val data: Int) : Identifiable { + override val identifier: String + get() = id.toString() +} + +class FlowExtKtTest { + + @Test + fun testDiffed() { + runBlocking { + performIntTest( + first = emptyList(), + second = listOf(1, 2, 3), + expectedDiff = Diff( + removed = emptyList(), + added = listOf(1, 2, 3), + updated = emptyList(), + all = listOf(1, 2, 3) + ), + ) + + performIntTest( + first = listOf(1, 2, 3), + second = listOf(1, 2, 3), + expectedDiff = Diff( + removed = emptyList(), + added = emptyList(), + updated = emptyList(), + all = listOf(1, 2, 3) + ) + ) + + performIntTest( + first = listOf(1, 2, 3), + second = emptyList(), + expectedDiff = Diff( + removed = listOf(1, 2, 3), + added = emptyList(), + updated = emptyList(), + all = emptyList() + ) + ) + + performIntTest( + first = listOf(1, 2), + second = listOf(2, 3), + expectedDiff = Diff( + removed = listOf(1), + added = listOf(3), + updated = emptyList(), + all = listOf(2, 3) + ) + ) + + val newStruct = Struct(id = 1, data = 1) + performTest( + first = listOf(Struct(id = 1, data = 0)), + second = listOf(newStruct), + expectedDiff = Diff( + removed = emptyList(), + added = emptyList(), + updated = listOf(newStruct), + all = listOf(newStruct) + ) + ) + } + } + + private suspend fun performIntTest( + first: List, + second: List, + expectedDiff: Diff, + ) { + performTest(first, second, expectedDiff, ::IntIdentifiable) + } + + private suspend fun performTest( + first: List, + second: List, + expectedDiff: Diff, + ) { + performTest(first, second, expectedDiff) { it } + } + + private suspend fun performTest( + first: List, + second: List, + expectedDiff: Diff, + toIdentifiable: (T) -> Identifiable + ) { + val firstIdentifiable = first.map(toIdentifiable) + val secondIdentifiable = second.map(toIdentifiable) + + val diffed = flowOf(firstIdentifiable, secondIdentifiable) + .diffed() + .withIndex().first { (index, _) -> index == 1 } // take second element which will actually represent diff + .value + + assertEquals(expectedDiff.map(toIdentifiable), diffed) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/FormatNamedTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/FormatNamedTest.kt new file mode 100644 index 0000000..958b087 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/FormatNamedTest.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.common.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FormatNamedTest { + + @Test + fun `should format one argument`() = runTest( + template = "https://moonbase.subscan.io/account/{address}", + values = mapOf( + "address" to "test" + ), + expected = "https://moonbase.subscan.io/account/test" + ) + + @Test + fun `should format multiple arguments`() = runTest( + template = "https://moonbase.subscan.io/{a}/{b}/{c}", + values = mapOf( + "a" to "A", + "b" to "B", + "c" to "C", + ), + expected = "https://moonbase.subscan.io/A/B/C" + ) + + @Test + fun `can use the same argument twice`() = runTest( + template = "https://moonbase.subscan.io/{a}/{a}", + values = mapOf( + "a" to "A", + ), + expected = "https://moonbase.subscan.io/A/A" + ) + + @Test + fun `should format missing value`() = runTest( + template = "https://moonbase.subscan.io/account/{address}", + values = emptyMap(), + expected = "https://moonbase.subscan.io/account/null" + ) + + private fun runTest(template: String, values: Map, expected: String) { + val actual = template.formatNamed(values) + + assertEquals(expected, actual) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/KotlinExtTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/KotlinExtTest.kt new file mode 100644 index 0000000..7f88b45 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/KotlinExtTest.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.common.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class KotlinExtTest { + + @Test + fun `endsWith should return false for suffix bigger than content`() { + assertFalse(byteArrayOf(0, 1).endsWith(byteArrayOf(0, 1, 2))) + } + + @Test + fun `endsWith should return true for suffix equal to the content`() { + assertTrue(byteArrayOf(0, 1, 2).endsWith(byteArrayOf(0, 1, 2))) + } + + @Test + fun `endsWith should return true for correct suffix`() { + assertTrue(byteArrayOf(0, 1, 2).endsWith(byteArrayOf(1, 2))) + } + + @Test + fun `endsWith should return true for incorrect suffix`() { + assertFalse(byteArrayOf(0, 1, 2, 3).endsWith(byteArrayOf(1, 2))) + assertFalse(byteArrayOf(0, 1, 2, 3).endsWith(byteArrayOf(2))) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/MedianTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/MedianTest.kt new file mode 100644 index 0000000..663f67a --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/MedianTest.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.common.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MedianTest { + + @Test + fun `should calculate median of single element list`() { + val median = listOf(2.0).median() + + assertEquals(2.0, median, 0.0001) + } + + @Test + fun `should calculate median of odd sized list`() { + val median = listOf(2.0, 2.0, 3.0, 4.0, 5.0).median() + + assertEquals(3.0, median, 0.0001) + } + + @Test + fun `should calculate median of even sized list`() { + val median = listOf(2.0, 2.0, 4.0, 5.0).median() + + assertEquals(3.0, median, 0.0001) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/PartitionTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/PartitionTest.kt new file mode 100644 index 0000000..c945ef7 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/PartitionTest.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.common.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class PartitionTest { + + @Test + fun testEveryCombinationBelowSize100() { + (1..100).map { size -> + (0 .. size).map { truePointIndex -> + val list = List(truePointIndex) { false } + List(size - truePointIndex) { true } + runTest(list, expectedResult = truePointIndex.takeIf { truePointIndex < size }) + } + } + } + + private fun runTest( + list: List, + expectedResult: Int? + ) { + var iterationCount = 0 + val actualResult = list.findPartitionPoint { iterationCount++; it } + + assertEquals("Expected: ${expectedResult}, Got: $actualResult in $list", expectedResult, actualResult) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/PercentageKtTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/PercentageKtTest.kt new file mode 100644 index 0000000..9a5828f --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/PercentageKtTest.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.common.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigInteger + +class BigIntegerSplitByWeightsTest { + + @Test + fun `test empty weights`() { + val total = BigInteger("100") + val weights = emptyList() + val parts = total.splitByWeights(weights) + + // Since weights are empty, expect an empty list. + assertTrue(parts.isEmpty()) + } + + @Test + fun `test negative total`() { + val total = BigInteger("-10") + val weights = listOf(BigInteger("2"), BigInteger("5")) + val parts = total.splitByWeights(weights) + + // Because total is negative, we return zeros (same size as weights). + assertEquals(listOf(BigInteger.ZERO, BigInteger.ZERO), parts) + } + + @Test + fun `test any negative weight`() { + val total = BigInteger("10") + val weights = listOf(BigInteger("2"), BigInteger("-1")) + val parts = total.splitByWeights(weights) + + // Because there's a negative weight, we return zeros. + assertEquals(listOf(BigInteger.ZERO, BigInteger.ZERO), parts) + } + + @Test + fun `test sum of weights is zero`() { + val total = BigInteger("10") + val weights = listOf(BigInteger.ZERO, BigInteger.ZERO) + val parts = total.splitByWeights(weights) + + // Because sumOfWeights == 0, we return zeros. + assertEquals(listOf(BigInteger.ZERO, BigInteger.ZERO), parts) + } + + @Test + fun `test normal distribution`() { + val total = BigInteger("10") + val weights = listOf(BigInteger.ONE, BigInteger("2"), BigInteger("3")) + val parts = total.splitByWeights(weights) + + // sumOfWeights = 6 + // Weighted splits: (1*10)/6 = 1 remainder 4, (2*10)/6 = 3 remainder 2, (3*10)/6 = 5 remainder 0. + // leftover = total - (1+3+5) = 1 + // We allocate that leftover to the largest remainder => first part => final: [2,3,5] + val expected = listOf(BigInteger("2"), BigInteger("3"), BigInteger("5")) + assertEquals(expected, parts) + } + + @Test + fun `test all equal weights`() { + val total = BigInteger("10") + val weights = (0 until 5).map { BigInteger.ONE } + val parts = total.splitByWeights(weights) + val expected = (0 until 5).map { BigInteger.TWO } + + assertEquals(expected, parts) + } + + @Test + fun `test weights larger than total`() { + val total = BigInteger("10") + val weights = listOf(BigInteger("500"), BigInteger("500")) + val parts = total.splitByWeights(weights) + val expected = listOf(BigInteger("5"), BigInteger("5")) + + assertEquals(expected, parts) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/UrlsTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/UrlsTest.kt new file mode 100644 index 0000000..2732324 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/UrlsTest.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.common.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class UrlsTest { + + @Test + fun `should ensure url protocol`() { + runEnsureProtocolTest("onlyHost.com", "https://onlyHost.com") + runEnsureProtocolTest("https://alreadyHttps.com", "https://alreadyHttps.com") + runEnsureProtocolTest("http://withHttp.com", "https://withHttp.com") + } + + private fun runEnsureProtocolTest( + input: String, + expected: String + ) { + val actual = Urls.ensureHttpsProtocol(input) + + assertEquals(expected, actual) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/WindowedTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/WindowedTest.kt new file mode 100644 index 0000000..b3a9286 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/WindowedTest.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.common.utils + +import io.novafoundation.nova.test_shared.assertListEquals +import org.junit.Test + +class WindowedTest { + + @Test + fun `window size divides array size`() { + runTest( + array = byteArrayOf(1, 2, 3, 4), + windowSize = 2, + expected = listOf( + byteArrayOf(1, 2), + byteArrayOf(3, 4), + ) + ) + } + + @Test + fun `window size does not divide array size`() { + runTest( + array = byteArrayOf(1, 2, 3, 4, 5), + windowSize = 2, + expected = listOf( + byteArrayOf(1, 2), + byteArrayOf(3, 4), + byteArrayOf(5), + ) + ) + } + + @Test + fun `window size 1`() { + runTest( + array = byteArrayOf(1, 2, 3), + windowSize = 1, + expected = listOf( + byteArrayOf(1), + byteArrayOf(2), + byteArrayOf(3) + ) + ) + } + + @Test + fun `input size smaller than window`() { + runTest( + array = byteArrayOf(1, 2), + windowSize = 3, + expected = listOf( + byteArrayOf(1, 2) + ) + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `window size is zero`() { + byteArrayOf().windowed(0) + } + + private fun runTest( + array: ByteArray, + windowSize: Int, + expected: List + ) { + val result = array.windowed(windowSize) + + assertListEquals(expected, result) { a, b -> a.contentEquals(b) } + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/formatting/Common.kt b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/Common.kt new file mode 100644 index 0000000..93d3706 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/Common.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.common.utils.formatting + +import org.junit.Assert +import java.math.BigDecimal + +fun testFormatter(formatter: NumberFormatter, expected: String, unformattedValue: String) { + Assert.assertEquals(expected, formatter.format(BigDecimal(unformattedValue))) +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/formatting/CompoundNumberFormatterTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/CompoundNumberFormatterTest.kt new file mode 100644 index 0000000..ecaf0d6 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/CompoundNumberFormatterTest.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.common.utils.formatting + +import org.junit.Test + +class CompoundNumberFormatterTest { + + private val formatter = defaultNumberFormatter() + + @Test + fun `should format all cases`() { + testFormatter(formatter, "0.00000001", "0.000000011676979") + testFormatter(formatter, "0.00002", "0.000021676979") + testFormatter(formatter, "0.315", "0.315000041811") + testFormatter(formatter, "0.99999", "0.99999999999") + testFormatter(formatter, "999.99999", "999.99999999") + testFormatter(formatter, "1M", "1000000") + testFormatter(formatter, "888,888.12", "888888.1234") + testFormatter(formatter, "1.24M", "1243000") + testFormatter(formatter, "1.24M", "1243011") + testFormatter(formatter, "100.04B", "100041000000") + testFormatter(formatter, "1T", "1001000000000") + testFormatter(formatter, "1,001T", "1001000000000000") + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/formatting/DynamicPrecisionFormatterTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/DynamicPrecisionFormatterTest.kt new file mode 100644 index 0000000..0ee5801 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/DynamicPrecisionFormatterTest.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.common.utils.formatting + +import org.junit.Test + +class DynamicPrecisionFormatterTest { + private val formatter = DynamicPrecisionFormatter(minScale = 2, minPrecision = 3) + + @Test + fun `test format`() { + testFormatter(formatter, "0.123", "0.123") + testFormatter(formatter, "0.00001", "0.00001") + testFormatter(formatter, "0.0000123", "0.0000123") + testFormatter(formatter, "0.0000123", "0.000012345") + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/formatting/FixedPrecisionFormatterTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/FixedPrecisionFormatterTest.kt new file mode 100644 index 0000000..8c8152a --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/FixedPrecisionFormatterTest.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.common.utils.formatting + +import org.junit.Test + +class FixedPrecisionFormatterTest { + + private val formatter = FixedPrecisionFormatter(2) + + @Test + fun `test format`() { + testFormatter(formatter, "1.23", "1.2345") + testFormatter(formatter, "1.2", "1.2") + testFormatter(formatter, "1.23", "1.23") + testFormatter(formatter, "1", "1") + testFormatter(formatter, "123,456", "123456") + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/formatting/SpannableFormatterTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/SpannableFormatterTest.kt new file mode 100644 index 0000000..01064be --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/formatting/SpannableFormatterTest.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.common.utils.formatting + +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import org.junit.Assert.assertEquals +import org.junit.Test + + +class SpannableFormatterTest { + + @Test + fun `format not numeric arguments`() { + val builder = getBuilder("Hello, %s! This is a test: %s") + val result = SpannableFormatter.fill(builder, "Alice", "123") + val expected = "Hello, Alice! This is a test: 123" + + assertEquals(expected, result.toString()) + } + + @Test + fun `format all numeric arguments`() { + val builder = getBuilder("Hello, %1\$s! This is a test: %2\$s") + val result = SpannableFormatter.fill(builder, "Alice", "123") + val expected = "Hello, Alice! This is a test: 123" + + assertEquals(expected, result.toString()) + } + + @Test + fun `format all numeric arguments reversed`() { + val builder = getBuilder("Hello, %2\$s! This is a test: %1\$s") + val result = SpannableFormatter.fill(builder, "123", "Alice") + val expected = "Hello, Alice! This is a test: 123" + + assertEquals(expected, result.toString()) + } + + @Test + fun `format numeric and not numeric arguments`() { + val builder = getBuilder("Hello, %1\$s! This is a test: %s") + val result = SpannableFormatter.fill(builder, "Alice", "123") + val expected = "Hello, Alice! This is a test: 123" + + assertEquals(expected, result.toString()) + } + + private fun getBuilder(format: CharSequence): SpannableFormatter.Builder { + return StubFormatterBuilder(format) + } +} + +class StubFormatterBuilder(override val format: CharSequence) : SpannableFormatter.Builder { + + private var result = format + + override fun replace(start: Int, end: Int, text: CharSequence) { + result = result.replaceRange(start, end, text) + } + + override fun result(): CharSequence { + return result + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/graph/GraphKtTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/graph/GraphKtTest.kt new file mode 100644 index 0000000..2da91cc --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/graph/GraphKtTest.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.common.utils.graph + +import io.novafoundation.nova.test_shared.assertListEquals +import kotlinx.coroutines.runBlocking +import org.junit.Ignore +import org.junit.Test +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +internal class GraphKtTest { + + @Test + fun shouldFindPaths() = runBlocking { + val graph = Graph.createSimple( + 1 to listOf(2, 3, 4), + 2 to listOf(1, 4, 3), + 3 to listOf(1, 2), + 4 to listOf(1, 2) + ) + + var actual = graph.findDijkstraPathsBetween(2, 3, limit = 3) + var expected = listOf( + listOf(SimpleEdge(2, 3)), + listOf(SimpleEdge(2, 1), SimpleEdge(1, 3)), + listOf(SimpleEdge(2, 4), SimpleEdge(4, 1), SimpleEdge(1, 3)), + ) + assertListEquals(expected, actual) + + actual = graph.findDijkstraPathsBetween(2, 3, limit = 1) + expected = listOf( + listOf(SimpleEdge(2, 3)), + ) + + assertListEquals(expected, actual) + } + + @OptIn(ExperimentalTime::class) + @Ignore("Performance") + @Test + fun testPerformance() = runBlocking { + val graphSize = 200 + val graph = fullyConnectedGraph(graphSize) + + val time = measureTime { + repeat(100) { i -> + graph.findDijkstraPathsBetween(i, graphSize - i, limit = 10) + } + + } + + print("Execution time: ${time / 100}") + } + + private fun fullyConnectedGraph(size: Int): SimpleGraph { + return Graph.build { + (0..size).onEach { i -> + (0..size).onEach { j -> + if (i != j) { + val edge = SimpleEdge(i, j) + addEdge(edge) + } + } + } + } + } +} diff --git a/core-api/.gitignore b/core-api/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/core-api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core-api/build.gradle b/core-api/build.gradle new file mode 100644 index 0000000..279bd6d --- /dev/null +++ b/core-api/build.gradle @@ -0,0 +1,11 @@ + +android { + namespace 'io.novafoundation.nova.core' +} + +dependencies { + implementation coroutinesDep + implementation substrateSdkDep + + api web3jDep +} \ No newline at end of file diff --git a/core-api/src/main/AndroidManifest.xml b/core-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/core-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core-api/src/main/java/io/novafoundation/nova/core/ethereum/Web3Api.kt b/core-api/src/main/java/io/novafoundation/nova/core/ethereum/Web3Api.kt new file mode 100644 index 0000000..e51f992 --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/ethereum/Web3Api.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.core.ethereum + +import io.novafoundation.nova.core.ethereum.log.Topic +import kotlinx.coroutines.flow.Flow +import org.web3j.protocol.Web3j +import org.web3j.protocol.websocket.events.LogNotification +import org.web3j.protocol.websocket.events.NewHeadsNotification + +interface Web3Api : Web3j { + + fun newHeadsFlow(): Flow + + fun logsNotifications(addresses: List, topics: List): Flow +} diff --git a/core-api/src/main/java/io/novafoundation/nova/core/ethereum/log/Topic.kt b/core-api/src/main/java/io/novafoundation/nova/core/ethereum/log/Topic.kt new file mode 100644 index 0000000..0fe56a8 --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/ethereum/log/Topic.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.core.ethereum.log + +sealed class Topic { + + object Any : Topic() + + data class Single(val value: String) : Topic() + + data class AnyOf(val values: List) : Topic() { + + constructor(vararg values: String) : this(values.toList()) + } +} diff --git a/core-api/src/main/java/io/novafoundation/nova/core/model/CryptoType.kt b/core-api/src/main/java/io/novafoundation/nova/core/model/CryptoType.kt new file mode 100644 index 0000000..f06336a --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/model/CryptoType.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.core.model + +enum class CryptoType { + SR25519, + ED25519, + ECDSA +} diff --git a/core-api/src/main/java/io/novafoundation/nova/core/model/Language.kt b/core-api/src/main/java/io/novafoundation/nova/core/model/Language.kt new file mode 100644 index 0000000..27a5ac0 --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/model/Language.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.core.model + +data class Language( + val iso639Code: String, + val name: String? = null +) diff --git a/core-api/src/main/java/io/novafoundation/nova/core/model/Network.kt b/core-api/src/main/java/io/novafoundation/nova/core/model/Network.kt new file mode 100644 index 0000000..1832c41 --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/model/Network.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.core.model + +data class Network( + val type: Node.NetworkType +) { + val name = type.readableName +} diff --git a/core-api/src/main/java/io/novafoundation/nova/core/model/Node.kt b/core-api/src/main/java/io/novafoundation/nova/core/model/Node.kt new file mode 100644 index 0000000..bfb7ca1 --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/model/Node.kt @@ -0,0 +1,64 @@ +@file:Suppress("EXPERIMENTAL_UNSIGNED_LITERALS") + +package io.novafoundation.nova.core.model + +data class Node( + val id: Int, + val name: String, + val networkType: NetworkType, + val link: String, + val isActive: Boolean, + val isDefault: Boolean, +) { + enum class NetworkType( + val readableName: String, + val runtimeConfiguration: RuntimeConfiguration, + ) { + KUSAMA( + "Kusama", + RuntimeConfiguration( + addressByte = 2, + genesisHash = "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + erasPerDay = 4 + ) + ), + POLKADOT( + "Polkadot", + RuntimeConfiguration( + addressByte = 0, + genesisHash = "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + erasPerDay = 1, + ) + ), + WESTEND( + "Westend", + RuntimeConfiguration( + addressByte = 42, + genesisHash = "e143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e", + erasPerDay = 4 + ) + ), + + ROCOCO( + "Rococo", + RuntimeConfiguration( + addressByte = 43, // TODO wrong address type, actual is 42, but it will conflict with Westend + genesisHash = "0x1ab7fbd1d7c3532386268ec23fe4ff69f5bb6b3e3697947df3a2ec2786424de3", + erasPerDay = 4 + ) + ); + + companion object { + fun find(value: T, extractor: (NetworkType) -> T): NetworkType? { + return values().find { extractor(it) == value } + } + + fun findByAddressByte(addressByte: Short) = find(addressByte) { it.runtimeConfiguration.addressByte } + + fun findByGenesis(genesis: String) = find(genesis.removePrefix("0x")) { it.runtimeConfiguration.genesisHash } + } + } +} + +val Node.NetworkType.chainId + get() = runtimeConfiguration.genesisHash diff --git a/core-api/src/main/java/io/novafoundation/nova/core/model/RuntimeConfiguration.kt b/core-api/src/main/java/io/novafoundation/nova/core/model/RuntimeConfiguration.kt new file mode 100644 index 0000000..f85c4a0 --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/model/RuntimeConfiguration.kt @@ -0,0 +1,9 @@ +@file:Suppress("EXPERIMENTAL_API_USAGE", "EXPERIMENTAL_UNSIGNED_LITERALS") + +package io.novafoundation.nova.core.model + +class RuntimeConfiguration( + val genesisHash: String, + val erasPerDay: Int, + val addressByte: Short, +) diff --git a/core-api/src/main/java/io/novafoundation/nova/core/model/SecuritySource.kt b/core-api/src/main/java/io/novafoundation/nova/core/model/SecuritySource.kt new file mode 100644 index 0000000..b5c6a9c --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/model/SecuritySource.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.core.model + +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair + +sealed class SecuritySource( + val keypair: Keypair +) { + + open class Specified( + final override val seed: ByteArray?, + keypair: Keypair + ) : SecuritySource(keypair), WithJson, WithSeed { + + override fun jsonFormer() = jsonFormer(seed) + + class Create( + seed: ByteArray?, + keypair: Keypair, + override val mnemonic: String, + override val derivationPath: String? + ) : Specified(seed, keypair), WithMnemonic, WithDerivationPath + + class Seed( + seed: ByteArray?, + keypair: Keypair, + override val derivationPath: String? + ) : Specified(seed, keypair), WithDerivationPath + + class Mnemonic( + seed: ByteArray?, + keypair: Keypair, + override val mnemonic: String, + override val derivationPath: String? + ) : Specified(seed, keypair), WithMnemonic, WithDerivationPath + + class Json( + seed: ByteArray?, + keypair: Keypair + ) : Specified(seed, keypair) + } + + open class Unspecified( + keypair: Keypair + ) : SecuritySource(keypair) +} + +interface WithMnemonic { + val mnemonic: String + + fun mnemonicWords() = mnemonic.split(" ") +} + +interface WithSeed { + val seed: ByteArray? +} + +interface WithJson { + fun jsonFormer(): JsonFormer +} + +interface WithDerivationPath { + val derivationPath: String? +} + +sealed class JsonFormer { + object KeyPair : JsonFormer() + + class Seed(val seed: ByteArray) : JsonFormer() +} + +fun jsonFormer(seed: ByteArray?): JsonFormer { + return if (seed != null) { + JsonFormer.Seed(seed) + } else { + JsonFormer.KeyPair + } +} diff --git a/core-api/src/main/java/io/novafoundation/nova/core/model/StorageChange.kt b/core-api/src/main/java/io/novafoundation/nova/core/model/StorageChange.kt new file mode 100644 index 0000000..3c084cb --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/model/StorageChange.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.core.model + +data class StorageChange(val block: String, val key: String, val value: String?) diff --git a/core-api/src/main/java/io/novafoundation/nova/core/model/StorageEntry.kt b/core-api/src/main/java/io/novafoundation/nova/core/model/StorageEntry.kt new file mode 100644 index 0000000..0b534a9 --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/model/StorageEntry.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.core.model + +class StorageEntry( + val storageKey: String, + val content: String?, +) diff --git a/core-api/src/main/java/io/novafoundation/nova/core/storage/StorageCache.kt b/core-api/src/main/java/io/novafoundation/nova/core/storage/StorageCache.kt new file mode 100644 index 0000000..7de9c1b --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/storage/StorageCache.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.core.storage + +import io.novafoundation.nova.core.model.StorageEntry +import kotlinx.coroutines.flow.Flow + +interface StorageCache { + + suspend fun isPrefixInCache(prefixKey: String, chainId: String): Boolean + + suspend fun isFullKeyInCache(fullKey: String, chainId: String): Boolean + + suspend fun insert(entry: StorageEntry, chainId: String) + + suspend fun insert(entries: List, chainId: String) + + suspend fun insertPrefixEntries(entries: List, prefixKey: String, chainId: String) + + suspend fun removeByPrefix(prefixKey: String, chainId: String) + suspend fun removeByPrefixExcept( + prefixKey: String, + fullKeyExceptions: List, + chainId: String + ) + + fun observeEntry(key: String, chainId: String): Flow + + /** + * First result will be emitted when all keys are found in the cache + * Thus, result.size == fullKeys.size + */ + fun observeEntries(keys: List, chainId: String): Flow> + + suspend fun observeEntries(keyPrefix: String, chainId: String): Flow> + + /** + * Should suspend until any matched result found + */ + suspend fun getEntry(key: String, chainId: String): StorageEntry + + suspend fun filterKeysInCache(keys: List, chainId: String): List + + suspend fun getKeys(keyPrefix: String, chainId: String): List + + /** + * Should suspend until all keys will be found + * Thus, result.size == fullKeys.size + */ + suspend fun getEntries(fullKeys: List, chainId: String): List +} + +suspend fun StorageCache.insert(entries: Map, chainId: String) { + val changes = entries.map { (key, value) -> StorageEntry(key, value) } + + insert(changes, chainId) +} + +suspend fun StorageCache.insertPrefixEntries(entries: Map, prefix: String, chainId: String) { + val changes = entries.map { (key, value) -> StorageEntry(key, value) } + + insertPrefixEntries(changes, prefixKey = prefix, chainId = chainId) +} diff --git a/core-api/src/main/java/io/novafoundation/nova/core/updater/SharedRequestsBuilder.kt b/core-api/src/main/java/io/novafoundation/nova/core/updater/SharedRequestsBuilder.kt new file mode 100644 index 0000000..7f6a057 --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/updater/SharedRequestsBuilder.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.core.updater + +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.core.ethereum.log.Topic +import io.novafoundation.nova.core.model.StorageChange +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import kotlinx.coroutines.flow.Flow +import org.web3j.protocol.core.Request +import org.web3j.protocol.core.Response +import org.web3j.protocol.websocket.events.LogNotification +import java.util.concurrent.CompletableFuture + +interface SubstrateSubscriptionBuilder { + + val socketService: SocketService? + + fun subscribe(key: String): Flow +} + +interface EthereumSharedRequestsBuilder { + + val callApi: Web3Api? + + val subscriptionApi: Web3Api? + + fun > ethBatchRequestAsync(batchId: String, request: Request): CompletableFuture + + fun subscribeEthLogs(address: String, topics: List): Flow +} + +val EthereumSharedRequestsBuilder.callApiOrThrow: Web3Api + get() = requireNotNull(callApi) { + "Chain doesn't have any ethereum apis available" + } + +interface SharedRequestsBuilder : SubstrateSubscriptionBuilder, EthereumSharedRequestsBuilder diff --git a/core-api/src/main/java/io/novafoundation/nova/core/updater/Updater.kt b/core-api/src/main/java/io/novafoundation/nova/core/updater/Updater.kt new file mode 100644 index 0000000..bebf775 --- /dev/null +++ b/core-api/src/main/java/io/novafoundation/nova/core/updater/Updater.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.core.updater + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.transform + +/** + * We do not want this extension to be visible outside of update system + * So, we put it into marker interface, which will allow to reach it in consumers code + */ +interface SideEffectScope { + + fun Flow.noSideAffects(): Flow = transform { } +} + +interface UpdateScope { + + fun invalidationFlow(): Flow +} + +object GlobalScope : UpdateScope { + + override fun invalidationFlow() = flowOf(Unit) +} + +class EmptyScope : UpdateScope { + + override fun invalidationFlow() = emptyFlow() +} + +interface GlobalScopeUpdater : Updater { + + override val scope + get() = GlobalScope +} + +interface Updater : SideEffectScope { + + @Deprecated( + "This feature is not flexible enough" + + "Updaters should check presense of relevant modules themselves and fallback to no-op in case module is not found" + ) + val requiredModules: List + get() = emptyList() + + val scope: UpdateScope + + /** + * Implementations should be aware of cancellation + */ + suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: V, + ): Flow + + interface SideEffect +} + +interface UpdateSystem { + + fun start(): Flow +} diff --git a/core-db/.gitignore b/core-db/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core-db/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-db/build.gradle b/core-db/build.gradle new file mode 100644 index 0000000..deecebd --- /dev/null +++ b/core-db/build.gradle @@ -0,0 +1,54 @@ + +android { + + defaultConfig { + + + + javaCompileOptions { + annotationProcessorOptions { + arguments += ["room.schemaLocation": + "$projectDir/schemas".toString()] + } + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } + namespace 'io.novafoundation.nova.core_db' +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation substrateSdkDep + + implementation project(":common") + implementation gsonDep + + implementation kotlinDep + + implementation coroutinesDep + + implementation daggerDep + ksp daggerCompiler + + implementation roomDep + implementation roomKtxDep + ksp roomCompiler + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep + + androidTestImplementation roomTestsDep + testImplementation project(':test-shared') +} \ No newline at end of file diff --git a/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/1.json b/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/1.json new file mode 100644 index 0000000..584506a --- /dev/null +++ b/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/1.json @@ -0,0 +1,1034 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "e4f0dd2774beec278193906984039a6c", + "entities": [ + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `username` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `cryptoType` INTEGER NOT NULL, `position` INTEGER NOT NULL, `networkType` INTEGER NOT NULL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cryptoType", + "columnName": "cryptoType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "networkType", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "address" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `link` TEXT NOT NULL, `networkType` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `isActive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "networkType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tokenSymbol` TEXT NOT NULL, `chainId` TEXT NOT NULL, `metaId` INTEGER NOT NULL, `freeInPlanks` TEXT NOT NULL, `reservedInPlanks` TEXT NOT NULL, `miscFrozenInPlanks` TEXT NOT NULL, `feeFrozenInPlanks` TEXT NOT NULL, `bondedInPlanks` TEXT NOT NULL, `redeemableInPlanks` TEXT NOT NULL, `unbondingInPlanks` TEXT NOT NULL, PRIMARY KEY(`tokenSymbol`, `chainId`, `metaId`))", + "fields": [ + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "freeInPlanks", + "columnName": "freeInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reservedInPlanks", + "columnName": "reservedInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "miscFrozenInPlanks", + "columnName": "miscFrozenInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feeFrozenInPlanks", + "columnName": "feeFrozenInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bondedInPlanks", + "columnName": "bondedInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "redeemableInPlanks", + "columnName": "redeemableInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unbondingInPlanks", + "columnName": "unbondingInPlanks", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tokenSymbol", + "chainId", + "metaId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_assets_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_assets_metaId` ON `${TABLE_NAME}` (`metaId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`symbol` TEXT NOT NULL, `dollarRate` TEXT, `recentRateChange` TEXT, PRIMARY KEY(`symbol`))", + "fields": [ + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dollarRate", + "columnName": "dollarRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recentRateChange", + "columnName": "recentRateChange", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "symbol" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "phishing_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, PRIMARY KEY(`publicKey`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "publicKey" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "storage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`storageKey` TEXT NOT NULL, `content` TEXT, `chainId` TEXT NOT NULL, PRIMARY KEY(`chainId`, `storageKey`))", + "fields": [ + { + "fieldPath": "storageKey", + "columnName": "storageKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "storageKey" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "account_staking_accesses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `chainAssetId` INTEGER NOT NULL, `accountId` BLOB NOT NULL, `stashId` BLOB, `controllerId` BLOB, PRIMARY KEY(`chainId`, `chainAssetId`, `accountId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "stakingAccessInfo.stashId", + "columnName": "stashId", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "stakingAccessInfo.controllerId", + "columnName": "controllerId", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "chainAssetId", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "total_reward", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountAddress` TEXT NOT NULL, `totalReward` TEXT NOT NULL, PRIMARY KEY(`accountAddress`))", + "fields": [ + { + "fieldPath": "accountAddress", + "columnName": "accountAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalReward", + "columnName": "totalReward", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "accountAddress" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `chainAssetId` INTEGER NOT NULL, `time` INTEGER NOT NULL, `status` INTEGER NOT NULL, `source` INTEGER NOT NULL, `operationType` INTEGER NOT NULL, `module` TEXT, `call` TEXT, `amount` TEXT, `sender` TEXT, `receiver` TEXT, `hash` TEXT, `fee` TEXT, `isReward` INTEGER, `era` INTEGER, `validator` TEXT, PRIMARY KEY(`id`, `address`, `chainId`, `chainAssetId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "operationType", + "columnName": "operationType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "module", + "columnName": "module", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "call", + "columnName": "call", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiver", + "columnName": "receiver", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isReward", + "columnName": "isReward", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "era", + "columnName": "era", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "validator", + "columnName": "validator", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "address", + "chainId", + "chainAssetId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `prefix` INTEGER NOT NULL, `isEthereumBased` INTEGER NOT NULL, `isTestNet` INTEGER NOT NULL, `hasCrowdloans` INTEGER NOT NULL, `url` TEXT, `overridesCommon` INTEGER, `staking_url` TEXT, `staking_type` TEXT, `history_url` TEXT, `history_type` TEXT, `crowdloans_url` TEXT, `crowdloans_type` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prefix", + "columnName": "prefix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEthereumBased", + "columnName": "isEthereumBased", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTestNet", + "columnName": "isTestNet", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCrowdloans", + "columnName": "hasCrowdloans", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "types.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "types.overridesCommon", + "columnName": "overridesCommon", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalApi.staking.url", + "columnName": "staking_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.staking.type", + "columnName": "staking_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.history.url", + "columnName": "history_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.history.type", + "columnName": "history_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.crowdloans.url", + "columnName": "crowdloans_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.crowdloans.type", + "columnName": "crowdloans_type", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chain_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`chainId`, `url`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_nodes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_nodes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `priceId` TEXT, `staking` TEXT NOT NULL, `precision` INTEGER NOT NULL, PRIMARY KEY(`chainId`, `id`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priceId", + "columnName": "priceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "staking", + "columnName": "staking", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "precision", + "columnName": "precision", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_assets_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_assets_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_runtimes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `syncedVersion` INTEGER NOT NULL, `remoteVersion` INTEGER NOT NULL, PRIMARY KEY(`chainId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncedVersion", + "columnName": "syncedVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteVersion", + "columnName": "remoteVersion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_runtimes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_runtimes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chain_explorers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `name` TEXT NOT NULL, `extrinsic` TEXT, `account` TEXT, `event` TEXT, PRIMARY KEY(`chainId`, `name`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extrinsic", + "columnName": "extrinsic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "event", + "columnName": "event", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_explorers_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_explorers_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "meta_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `substratePublicKey` BLOB NOT NULL, `substrateCryptoType` TEXT NOT NULL, `substrateAccountId` BLOB NOT NULL, `ethereumPublicKey` BLOB, `ethereumAddress` BLOB, `name` TEXT NOT NULL, `isSelected` INTEGER NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "substratePublicKey", + "columnName": "substratePublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "substrateCryptoType", + "columnName": "substrateCryptoType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "substrateAccountId", + "columnName": "substrateAccountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ethereumPublicKey", + "columnName": "ethereumPublicKey", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ethereumAddress", + "columnName": "ethereumAddress", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_meta_accounts_substrateAccountId", + "unique": false, + "columnNames": [ + "substrateAccountId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_substrateAccountId` ON `${TABLE_NAME}` (`substrateAccountId`)" + }, + { + "name": "index_meta_accounts_ethereumAddress", + "unique": false, + "columnNames": [ + "ethereumAddress" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_ethereumAddress` ON `${TABLE_NAME}` (`ethereumAddress`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chain_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `accountId` BLOB NOT NULL, `cryptoType` TEXT NOT NULL, PRIMARY KEY(`metaId`, `chainId`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cryptoType", + "columnName": "cryptoType", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "metaId", + "chainId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_accounts_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_chainId` ON `${TABLE_NAME}` (`chainId`)" + }, + { + "name": "index_chain_accounts_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_metaId` ON `${TABLE_NAME}` (`metaId`)" + }, + { + "name": "index_chain_accounts_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "dapp_authorizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `authorized` INTEGER, PRIMARY KEY(`baseUrl`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorized", + "columnName": "authorized", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "baseUrl" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e4f0dd2774beec278193906984039a6c')" + ] + } +} \ No newline at end of file diff --git a/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/2.json b/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/2.json new file mode 100644 index 0000000..0a05729 --- /dev/null +++ b/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/2.json @@ -0,0 +1,1034 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "e4f0dd2774beec278193906984039a6c", + "entities": [ + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `username` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `cryptoType` INTEGER NOT NULL, `position` INTEGER NOT NULL, `networkType` INTEGER NOT NULL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cryptoType", + "columnName": "cryptoType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "networkType", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "address" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `link` TEXT NOT NULL, `networkType` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `isActive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "networkType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tokenSymbol` TEXT NOT NULL, `chainId` TEXT NOT NULL, `metaId` INTEGER NOT NULL, `freeInPlanks` TEXT NOT NULL, `reservedInPlanks` TEXT NOT NULL, `miscFrozenInPlanks` TEXT NOT NULL, `feeFrozenInPlanks` TEXT NOT NULL, `bondedInPlanks` TEXT NOT NULL, `redeemableInPlanks` TEXT NOT NULL, `unbondingInPlanks` TEXT NOT NULL, PRIMARY KEY(`tokenSymbol`, `chainId`, `metaId`))", + "fields": [ + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "freeInPlanks", + "columnName": "freeInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reservedInPlanks", + "columnName": "reservedInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "miscFrozenInPlanks", + "columnName": "miscFrozenInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feeFrozenInPlanks", + "columnName": "feeFrozenInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bondedInPlanks", + "columnName": "bondedInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "redeemableInPlanks", + "columnName": "redeemableInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unbondingInPlanks", + "columnName": "unbondingInPlanks", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tokenSymbol", + "chainId", + "metaId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_assets_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_assets_metaId` ON `${TABLE_NAME}` (`metaId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`symbol` TEXT NOT NULL, `dollarRate` TEXT, `recentRateChange` TEXT, PRIMARY KEY(`symbol`))", + "fields": [ + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dollarRate", + "columnName": "dollarRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recentRateChange", + "columnName": "recentRateChange", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "symbol" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "phishing_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, PRIMARY KEY(`publicKey`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "publicKey" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "storage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`storageKey` TEXT NOT NULL, `content` TEXT, `chainId` TEXT NOT NULL, PRIMARY KEY(`chainId`, `storageKey`))", + "fields": [ + { + "fieldPath": "storageKey", + "columnName": "storageKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "storageKey" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "account_staking_accesses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `chainAssetId` INTEGER NOT NULL, `accountId` BLOB NOT NULL, `stashId` BLOB, `controllerId` BLOB, PRIMARY KEY(`chainId`, `chainAssetId`, `accountId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "stakingAccessInfo.stashId", + "columnName": "stashId", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "stakingAccessInfo.controllerId", + "columnName": "controllerId", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "chainAssetId", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "total_reward", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountAddress` TEXT NOT NULL, `totalReward` TEXT NOT NULL, PRIMARY KEY(`accountAddress`))", + "fields": [ + { + "fieldPath": "accountAddress", + "columnName": "accountAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalReward", + "columnName": "totalReward", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "accountAddress" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `chainAssetId` INTEGER NOT NULL, `time` INTEGER NOT NULL, `status` INTEGER NOT NULL, `source` INTEGER NOT NULL, `operationType` INTEGER NOT NULL, `module` TEXT, `call` TEXT, `amount` TEXT, `sender` TEXT, `receiver` TEXT, `hash` TEXT, `fee` TEXT, `isReward` INTEGER, `era` INTEGER, `validator` TEXT, PRIMARY KEY(`id`, `address`, `chainId`, `chainAssetId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "operationType", + "columnName": "operationType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "module", + "columnName": "module", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "call", + "columnName": "call", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiver", + "columnName": "receiver", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isReward", + "columnName": "isReward", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "era", + "columnName": "era", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "validator", + "columnName": "validator", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "address", + "chainId", + "chainAssetId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parentId` TEXT, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `prefix` INTEGER NOT NULL, `isEthereumBased` INTEGER NOT NULL, `isTestNet` INTEGER NOT NULL, `hasCrowdloans` INTEGER NOT NULL, `url` TEXT, `overridesCommon` INTEGER, `staking_url` TEXT, `staking_type` TEXT, `history_url` TEXT, `history_type` TEXT, `crowdloans_url` TEXT, `crowdloans_type` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prefix", + "columnName": "prefix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEthereumBased", + "columnName": "isEthereumBased", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTestNet", + "columnName": "isTestNet", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCrowdloans", + "columnName": "hasCrowdloans", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "types.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "types.overridesCommon", + "columnName": "overridesCommon", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalApi.staking.url", + "columnName": "staking_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.staking.type", + "columnName": "staking_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.history.url", + "columnName": "history_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.history.type", + "columnName": "history_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.crowdloans.url", + "columnName": "crowdloans_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.crowdloans.type", + "columnName": "crowdloans_type", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chain_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`chainId`, `url`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_nodes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_nodes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `priceId` TEXT, `staking` TEXT NOT NULL, `precision` INTEGER NOT NULL, PRIMARY KEY(`chainId`, `id`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priceId", + "columnName": "priceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "staking", + "columnName": "staking", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "precision", + "columnName": "precision", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_assets_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_assets_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_runtimes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `syncedVersion` INTEGER NOT NULL, `remoteVersion` INTEGER NOT NULL, PRIMARY KEY(`chainId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncedVersion", + "columnName": "syncedVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteVersion", + "columnName": "remoteVersion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_runtimes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_runtimes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chain_explorers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `name` TEXT NOT NULL, `extrinsic` TEXT, `account` TEXT, `event` TEXT, PRIMARY KEY(`chainId`, `name`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extrinsic", + "columnName": "extrinsic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "event", + "columnName": "event", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_explorers_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_explorers_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "meta_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `substratePublicKey` BLOB NOT NULL, `substrateCryptoType` TEXT NOT NULL, `substrateAccountId` BLOB NOT NULL, `ethereumPublicKey` BLOB, `ethereumAddress` BLOB, `name` TEXT NOT NULL, `isSelected` INTEGER NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "substratePublicKey", + "columnName": "substratePublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "substrateCryptoType", + "columnName": "substrateCryptoType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "substrateAccountId", + "columnName": "substrateAccountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ethereumPublicKey", + "columnName": "ethereumPublicKey", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ethereumAddress", + "columnName": "ethereumAddress", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_meta_accounts_substrateAccountId", + "unique": false, + "columnNames": [ + "substrateAccountId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_substrateAccountId` ON `${TABLE_NAME}` (`substrateAccountId`)" + }, + { + "name": "index_meta_accounts_ethereumAddress", + "unique": false, + "columnNames": [ + "ethereumAddress" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_ethereumAddress` ON `${TABLE_NAME}` (`ethereumAddress`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chain_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `accountId` BLOB NOT NULL, `cryptoType` TEXT NOT NULL, PRIMARY KEY(`metaId`, `chainId`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cryptoType", + "columnName": "cryptoType", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "metaId", + "chainId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_accounts_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_chainId` ON `${TABLE_NAME}` (`chainId`)" + }, + { + "name": "index_chain_accounts_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_metaId` ON `${TABLE_NAME}` (`metaId`)" + }, + { + "name": "index_chain_accounts_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "dapp_authorizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `authorized` INTEGER, PRIMARY KEY(`baseUrl`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorized", + "columnName": "authorized", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "baseUrl" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e4f0dd2774beec278193906984039a6c')" + ] + } +} \ No newline at end of file diff --git a/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/8.json b/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/8.json new file mode 100644 index 0000000..b5d4d3d --- /dev/null +++ b/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/8.json @@ -0,0 +1,1185 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "f6a7485290fd689d9da610e31e393244", + "entities": [ + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `username` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `cryptoType` INTEGER NOT NULL, `position` INTEGER NOT NULL, `networkType` INTEGER NOT NULL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cryptoType", + "columnName": "cryptoType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "networkType", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "address" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `link` TEXT NOT NULL, `networkType` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `isActive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "networkType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tokenSymbol` TEXT NOT NULL, `chainId` TEXT NOT NULL, `metaId` INTEGER NOT NULL, `freeInPlanks` TEXT NOT NULL, `frozenInPlanks` TEXT NOT NULL, `reservedInPlanks` TEXT NOT NULL, `bondedInPlanks` TEXT NOT NULL, `redeemableInPlanks` TEXT NOT NULL, `unbondingInPlanks` TEXT NOT NULL, PRIMARY KEY(`tokenSymbol`, `chainId`, `metaId`))", + "fields": [ + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "freeInPlanks", + "columnName": "freeInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frozenInPlanks", + "columnName": "frozenInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reservedInPlanks", + "columnName": "reservedInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bondedInPlanks", + "columnName": "bondedInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "redeemableInPlanks", + "columnName": "redeemableInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unbondingInPlanks", + "columnName": "unbondingInPlanks", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tokenSymbol", + "chainId", + "metaId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_assets_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_assets_metaId` ON `${TABLE_NAME}` (`metaId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`symbol` TEXT NOT NULL, `dollarRate` TEXT, `recentRateChange` TEXT, PRIMARY KEY(`symbol`))", + "fields": [ + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dollarRate", + "columnName": "dollarRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recentRateChange", + "columnName": "recentRateChange", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "symbol" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "phishing_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, PRIMARY KEY(`publicKey`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "publicKey" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "storage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`storageKey` TEXT NOT NULL, `content` TEXT, `chainId` TEXT NOT NULL, PRIMARY KEY(`chainId`, `storageKey`))", + "fields": [ + { + "fieldPath": "storageKey", + "columnName": "storageKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "storageKey" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "account_staking_accesses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `chainAssetId` INTEGER NOT NULL, `accountId` BLOB NOT NULL, `stashId` BLOB, `controllerId` BLOB, PRIMARY KEY(`chainId`, `chainAssetId`, `accountId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "stakingAccessInfo.stashId", + "columnName": "stashId", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "stakingAccessInfo.controllerId", + "columnName": "controllerId", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "chainAssetId", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "total_reward", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountAddress` TEXT NOT NULL, `totalReward` TEXT NOT NULL, PRIMARY KEY(`accountAddress`))", + "fields": [ + { + "fieldPath": "accountAddress", + "columnName": "accountAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalReward", + "columnName": "totalReward", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "accountAddress" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `chainAssetId` INTEGER NOT NULL, `time` INTEGER NOT NULL, `status` INTEGER NOT NULL, `source` INTEGER NOT NULL, `operationType` INTEGER NOT NULL, `module` TEXT, `call` TEXT, `amount` TEXT, `sender` TEXT, `receiver` TEXT, `hash` TEXT, `fee` TEXT, `isReward` INTEGER, `era` INTEGER, `validator` TEXT, PRIMARY KEY(`id`, `address`, `chainId`, `chainAssetId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "operationType", + "columnName": "operationType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "module", + "columnName": "module", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "call", + "columnName": "call", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiver", + "columnName": "receiver", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isReward", + "columnName": "isReward", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "era", + "columnName": "era", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "validator", + "columnName": "validator", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "address", + "chainId", + "chainAssetId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parentId` TEXT, `color` TEXT, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `prefix` INTEGER NOT NULL, `isEthereumBased` INTEGER NOT NULL, `isTestNet` INTEGER NOT NULL, `hasCrowdloans` INTEGER NOT NULL, `url` TEXT, `overridesCommon` INTEGER, `staking_url` TEXT, `staking_type` TEXT, `history_url` TEXT, `history_type` TEXT, `crowdloans_url` TEXT, `crowdloans_type` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prefix", + "columnName": "prefix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEthereumBased", + "columnName": "isEthereumBased", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTestNet", + "columnName": "isTestNet", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCrowdloans", + "columnName": "hasCrowdloans", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "types.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "types.overridesCommon", + "columnName": "overridesCommon", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalApi.staking.url", + "columnName": "staking_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.staking.type", + "columnName": "staking_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.history.url", + "columnName": "history_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.history.type", + "columnName": "history_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.crowdloans.url", + "columnName": "crowdloans_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.crowdloans.type", + "columnName": "crowdloans_type", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chain_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`chainId`, `url`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_nodes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_nodes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `priceId` TEXT, `staking` TEXT NOT NULL, `precision` INTEGER NOT NULL, `icon` TEXT, `type` TEXT, `buyProviders` TEXT, `typeExtras` TEXT, PRIMARY KEY(`chainId`, `id`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priceId", + "columnName": "priceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "staking", + "columnName": "staking", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "precision", + "columnName": "precision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "buyProviders", + "columnName": "buyProviders", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "typeExtras", + "columnName": "typeExtras", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_assets_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_assets_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_runtimes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `syncedVersion` INTEGER NOT NULL, `remoteVersion` INTEGER NOT NULL, PRIMARY KEY(`chainId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncedVersion", + "columnName": "syncedVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteVersion", + "columnName": "remoteVersion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_runtimes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_runtimes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chain_explorers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `name` TEXT NOT NULL, `extrinsic` TEXT, `account` TEXT, `event` TEXT, PRIMARY KEY(`chainId`, `name`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extrinsic", + "columnName": "extrinsic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "event", + "columnName": "event", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_explorers_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_explorers_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "meta_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `substratePublicKey` BLOB NOT NULL, `substrateCryptoType` TEXT NOT NULL, `substrateAccountId` BLOB NOT NULL, `ethereumPublicKey` BLOB, `ethereumAddress` BLOB, `name` TEXT NOT NULL, `isSelected` INTEGER NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "substratePublicKey", + "columnName": "substratePublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "substrateCryptoType", + "columnName": "substrateCryptoType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "substrateAccountId", + "columnName": "substrateAccountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ethereumPublicKey", + "columnName": "ethereumPublicKey", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ethereumAddress", + "columnName": "ethereumAddress", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_meta_accounts_substrateAccountId", + "unique": false, + "columnNames": [ + "substrateAccountId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_substrateAccountId` ON `${TABLE_NAME}` (`substrateAccountId`)" + }, + { + "name": "index_meta_accounts_ethereumAddress", + "unique": false, + "columnNames": [ + "ethereumAddress" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_ethereumAddress` ON `${TABLE_NAME}` (`ethereumAddress`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chain_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `accountId` BLOB NOT NULL, `cryptoType` TEXT NOT NULL, PRIMARY KEY(`metaId`, `chainId`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cryptoType", + "columnName": "cryptoType", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "metaId", + "chainId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_accounts_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_chainId` ON `${TABLE_NAME}` (`chainId`)" + }, + { + "name": "index_chain_accounts_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_metaId` ON `${TABLE_NAME}` (`metaId`)" + }, + { + "name": "index_chain_accounts_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "dapp_authorizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `authorized` INTEGER, PRIMARY KEY(`baseUrl`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorized", + "columnName": "authorized", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "baseUrl" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "nfts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identifier` TEXT NOT NULL, `metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `collectionId` TEXT NOT NULL, `instanceId` TEXT, `metadata` BLOB, `type` TEXT NOT NULL, `wholeDetailsLoaded` INTEGER NOT NULL, `name` TEXT, `label` TEXT, `media` TEXT, `issuanceTotal` INTEGER, `issuanceMyEdition` TEXT, `price` TEXT, PRIMARY KEY(`identifier`))", + "fields": [ + { + "fieldPath": "identifier", + "columnName": "identifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "instanceId", + "columnName": "instanceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wholeDetailsLoaded", + "columnName": "wholeDetailsLoaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media", + "columnName": "media", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "issuanceTotal", + "columnName": "issuanceTotal", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "issuanceMyEdition", + "columnName": "issuanceMyEdition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "identifier" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_nfts_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nfts_metaId` ON `${TABLE_NAME}` (`metaId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "phishing_sites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, PRIMARY KEY(`host`))", + "fields": [ + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "host" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f6a7485290fd689d9da610e31e393244')" + ] + } +} \ No newline at end of file diff --git a/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/9.json b/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/9.json new file mode 100644 index 0000000..d7363d4 --- /dev/null +++ b/core-db/schemas/io.novafoundation.nova.core_db.AppDatabase/9.json @@ -0,0 +1,1211 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "3fe75e25ea969484c2cff712aad4da48", + "entities": [ + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `username` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `cryptoType` INTEGER NOT NULL, `position` INTEGER NOT NULL, `networkType` INTEGER NOT NULL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cryptoType", + "columnName": "cryptoType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "networkType", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "address" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `link` TEXT NOT NULL, `networkType` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `isActive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkType", + "columnName": "networkType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`assetId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `metaId` INTEGER NOT NULL, `freeInPlanks` TEXT NOT NULL, `frozenInPlanks` TEXT NOT NULL, `reservedInPlanks` TEXT NOT NULL, `bondedInPlanks` TEXT NOT NULL, `redeemableInPlanks` TEXT NOT NULL, `unbondingInPlanks` TEXT NOT NULL, PRIMARY KEY(`assetId`, `chainId`, `metaId`), FOREIGN KEY(`assetId`, `chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "assetId", + "columnName": "assetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "freeInPlanks", + "columnName": "freeInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frozenInPlanks", + "columnName": "frozenInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reservedInPlanks", + "columnName": "reservedInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bondedInPlanks", + "columnName": "bondedInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "redeemableInPlanks", + "columnName": "redeemableInPlanks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unbondingInPlanks", + "columnName": "unbondingInPlanks", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "assetId", + "chainId", + "metaId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_assets_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_assets_metaId` ON `${TABLE_NAME}` (`metaId`)" + } + ], + "foreignKeys": [ + { + "table": "chain_assets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "assetId", + "chainId" + ], + "referencedColumns": [ + "id", + "chainId" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`symbol` TEXT NOT NULL, `dollarRate` TEXT, `recentRateChange` TEXT, PRIMARY KEY(`symbol`))", + "fields": [ + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dollarRate", + "columnName": "dollarRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recentRateChange", + "columnName": "recentRateChange", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "symbol" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "phishing_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, PRIMARY KEY(`publicKey`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "publicKey" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "storage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`storageKey` TEXT NOT NULL, `content` TEXT, `chainId` TEXT NOT NULL, PRIMARY KEY(`chainId`, `storageKey`))", + "fields": [ + { + "fieldPath": "storageKey", + "columnName": "storageKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "storageKey" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "account_staking_accesses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `chainAssetId` INTEGER NOT NULL, `accountId` BLOB NOT NULL, `stashId` BLOB, `controllerId` BLOB, PRIMARY KEY(`chainId`, `chainAssetId`, `accountId`))", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "stakingAccessInfo.stashId", + "columnName": "stashId", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "stakingAccessInfo.controllerId", + "columnName": "controllerId", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "chainAssetId", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "total_reward", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountAddress` TEXT NOT NULL, `totalReward` TEXT NOT NULL, PRIMARY KEY(`accountAddress`))", + "fields": [ + { + "fieldPath": "accountAddress", + "columnName": "accountAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalReward", + "columnName": "totalReward", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "accountAddress" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `chainAssetId` INTEGER NOT NULL, `time` INTEGER NOT NULL, `status` INTEGER NOT NULL, `source` INTEGER NOT NULL, `operationType` INTEGER NOT NULL, `module` TEXT, `call` TEXT, `amount` TEXT, `sender` TEXT, `receiver` TEXT, `hash` TEXT, `fee` TEXT, `isReward` INTEGER, `era` INTEGER, `validator` TEXT, PRIMARY KEY(`id`, `address`, `chainId`, `chainAssetId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chainAssetId", + "columnName": "chainAssetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "operationType", + "columnName": "operationType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "module", + "columnName": "module", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "call", + "columnName": "call", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receiver", + "columnName": "receiver", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hash", + "columnName": "hash", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isReward", + "columnName": "isReward", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "era", + "columnName": "era", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "validator", + "columnName": "validator", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "address", + "chainId", + "chainAssetId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parentId` TEXT, `color` TEXT, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `prefix` INTEGER NOT NULL, `isEthereumBased` INTEGER NOT NULL, `isTestNet` INTEGER NOT NULL, `hasCrowdloans` INTEGER NOT NULL, `url` TEXT, `overridesCommon` INTEGER, `staking_url` TEXT, `staking_type` TEXT, `history_url` TEXT, `history_type` TEXT, `crowdloans_url` TEXT, `crowdloans_type` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prefix", + "columnName": "prefix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEthereumBased", + "columnName": "isEthereumBased", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTestNet", + "columnName": "isTestNet", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCrowdloans", + "columnName": "hasCrowdloans", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "types.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "types.overridesCommon", + "columnName": "overridesCommon", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "externalApi.staking.url", + "columnName": "staking_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.staking.type", + "columnName": "staking_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.history.url", + "columnName": "history_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.history.type", + "columnName": "history_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.crowdloans.url", + "columnName": "crowdloans_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "externalApi.crowdloans.type", + "columnName": "crowdloans_type", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "chain_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`chainId`, `url`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "url" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_nodes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_nodes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_assets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `priceId` TEXT, `staking` TEXT NOT NULL, `precision` INTEGER NOT NULL, `icon` TEXT, `type` TEXT, `buyProviders` TEXT, `typeExtras` TEXT, PRIMARY KEY(`chainId`, `id`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priceId", + "columnName": "priceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "staking", + "columnName": "staking", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "precision", + "columnName": "precision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "buyProviders", + "columnName": "buyProviders", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "typeExtras", + "columnName": "typeExtras", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_assets_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_assets_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_runtimes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `syncedVersion` INTEGER NOT NULL, `remoteVersion` INTEGER NOT NULL, PRIMARY KEY(`chainId`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "syncedVersion", + "columnName": "syncedVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteVersion", + "columnName": "remoteVersion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "chainId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_runtimes_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_runtimes_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chain_explorers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chainId` TEXT NOT NULL, `name` TEXT NOT NULL, `extrinsic` TEXT, `account` TEXT, `event` TEXT, PRIMARY KEY(`chainId`, `name`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extrinsic", + "columnName": "extrinsic", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "event", + "columnName": "event", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "chainId", + "name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_explorers_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_explorers_chainId` ON `${TABLE_NAME}` (`chainId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "meta_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `substratePublicKey` BLOB NOT NULL, `substrateCryptoType` TEXT NOT NULL, `substrateAccountId` BLOB NOT NULL, `ethereumPublicKey` BLOB, `ethereumAddress` BLOB, `name` TEXT NOT NULL, `isSelected` INTEGER NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "substratePublicKey", + "columnName": "substratePublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "substrateCryptoType", + "columnName": "substrateCryptoType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "substrateAccountId", + "columnName": "substrateAccountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ethereumPublicKey", + "columnName": "ethereumPublicKey", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ethereumAddress", + "columnName": "ethereumAddress", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_meta_accounts_substrateAccountId", + "unique": false, + "columnNames": [ + "substrateAccountId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_substrateAccountId` ON `${TABLE_NAME}` (`substrateAccountId`)" + }, + { + "name": "index_meta_accounts_ethereumAddress", + "unique": false, + "columnNames": [ + "ethereumAddress" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_meta_accounts_ethereumAddress` ON `${TABLE_NAME}` (`ethereumAddress`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "chain_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `accountId` BLOB NOT NULL, `cryptoType` TEXT NOT NULL, PRIMARY KEY(`metaId`, `chainId`), FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "cryptoType", + "columnName": "cryptoType", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "metaId", + "chainId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_chain_accounts_chainId", + "unique": false, + "columnNames": [ + "chainId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_chainId` ON `${TABLE_NAME}` (`chainId`)" + }, + { + "name": "index_chain_accounts_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_metaId` ON `${TABLE_NAME}` (`metaId`)" + }, + { + "name": "index_chain_accounts_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chain_accounts_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "chains", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "chainId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "meta_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "metaId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "dapp_authorizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `authorized` INTEGER, PRIMARY KEY(`baseUrl`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorized", + "columnName": "authorized", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "baseUrl" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "nfts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identifier` TEXT NOT NULL, `metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `collectionId` TEXT NOT NULL, `instanceId` TEXT, `metadata` BLOB, `type` TEXT NOT NULL, `wholeDetailsLoaded` INTEGER NOT NULL, `name` TEXT, `label` TEXT, `media` TEXT, `issuanceTotal` INTEGER, `issuanceMyEdition` TEXT, `price` TEXT, PRIMARY KEY(`identifier`))", + "fields": [ + { + "fieldPath": "identifier", + "columnName": "identifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "metaId", + "columnName": "metaId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chainId", + "columnName": "chainId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collectionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "instanceId", + "columnName": "instanceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wholeDetailsLoaded", + "columnName": "wholeDetailsLoaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media", + "columnName": "media", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "issuanceTotal", + "columnName": "issuanceTotal", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "issuanceMyEdition", + "columnName": "issuanceMyEdition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "identifier" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_nfts_metaId", + "unique": false, + "columnNames": [ + "metaId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nfts_metaId` ON `${TABLE_NAME}` (`metaId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "phishing_sites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, PRIMARY KEY(`host`))", + "fields": [ + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "host" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3fe75e25ea969484c2cff712aad4da48')" + ] + } +} \ No newline at end of file diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/AssetsDaoTest.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/AssetsDaoTest.kt new file mode 100644 index 0000000..9a4a510 --- /dev/null +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/AssetsDaoTest.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.novafoundation.nova.core_db.AppDatabase +import io.novafoundation.nova.core_db.model.AssetLocal +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AssetsDaoTest : DaoTest(AppDatabase::assetDao) { + + private val chainDao by dao() + private val metaAccountDao by dao() + private val currencyDao by dao() + private val assetDao by dao() + + private var metaId: Long = 0 + + private val chainId = "0" + private val testChain = createTestChain(chainId) + private val asset = testChain.assets.first() + private val assetId = asset.id + + @Before + fun setupDb() = runBlocking { + metaId = metaAccountDao.insertMetaAccount(testMetaAccount()) + chainDao.addChain(testChain) + } + + @Test + fun shouldDeleteAssetAfterChainIsDeleted() = runBlocking { + dao.insertAsset(AssetLocal.createEmpty(assetId = assetId, chainId = chainId, metaId)) + chainDao.removeChain(testChain) + + val assets = dao.getSupportedAssets(metaId) + + assert(assets.isEmpty()) + } + + @Test + fun testRetrievingAssetsByMetaId() = runBlocking { + currencyDao.insert(createCurrency(selected = true)) + + val assetWithToken = dao.getAssetWithToken(metaId, chainId, assetId) + + assert(assetWithToken != null) + } + + @Test + fun testRetrievingAssetsByMetaIdWithoutCurrency() = runBlocking { + currencyDao.insert(createCurrency(selected = false)) + + val assetWithToken = dao.getAssetWithToken(metaId, chainId, assetId) + + assert(assetWithToken == null) + } + + @Test + fun testRetrievingSyncedAssets() = runBlocking { + assetDao.insertAsset(AssetLocal.createEmpty(assetId, chainId, metaId)) + currencyDao.insert(createCurrency(selected = true)) + + val assetWithToken = dao.getSyncedAssets(metaId) + + assert(assetWithToken.isNotEmpty()) + } + + @Test + fun testRetrievingSyncedAssetsWithoutCurrency() = runBlocking { + assetDao.insertAsset(AssetLocal.createEmpty(assetId, chainId, metaId)) + currencyDao.insert(createCurrency(selected = false)) + + val assetsWithTokens = dao.getSyncedAssets(metaId) + + assert(assetsWithTokens.isEmpty()) + } + + @Test + fun testRetrievingSyncedAssetsWithoutAssetBalance() = runBlocking { + currencyDao.insert(createCurrency(selected = false)) + + val assetsWithTokens = dao.getSyncedAssets(metaId) + + assert(assetsWithTokens.isEmpty()) + } + + @Test + fun testRetrievingSupportedAssets() = runBlocking { + currencyDao.insert(createCurrency(selected = true)) + + val assetsWithTokens = dao.getSupportedAssets(metaId) + + assert(assetsWithTokens.isNotEmpty()) + } + + @Test + fun testRetrievingSupportedAssetsWithoutCurrency() = runBlocking { + currencyDao.insert(createCurrency(selected = false)) + + val assetsWithTokens = dao.getSupportedAssets(metaId) + + assert(assetsWithTokens.isEmpty()) + } +} diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/ChainDaoTest.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/ChainDaoTest.kt new file mode 100644 index 0000000..e3bf83b --- /dev/null +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/ChainDaoTest.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.AppDatabase +import io.novafoundation.nova.core_db.model.chain.JoinedChainInfo +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChainDaoTest : DaoTest(AppDatabase::chainDao) { + + @Test + fun shouldInsertWholeChain() = runBlocking { + val chainInfo = createTestChain("0x00") + + dao.addChain(chainInfo) + + val chainsFromDb = dao.getJoinChainInfo() + + assertEquals(1, chainsFromDb.size) + + val chainFromDb = chainsFromDb.first() + + assertEquals(chainInfo.assets.size, chainFromDb.assets.size) + assertEquals(chainInfo.nodes.size, chainFromDb.nodes.size) + } + + @Test + fun shouldDeleteChainWithCascade() = runBlocking { + val chainInfo = createTestChain("0x00") + + dao.addChain(chainInfo) + dao.removeChain(chainInfo) + + val assetsCursor = db.query("SELECT * FROM chain_assets", emptyArray()) + assertEquals(0, assetsCursor.count) + + val nodesCursor = db.query("SELECT * FROM chain_nodes", emptyArray()) + assertEquals(0, nodesCursor.count) + } + + @Test + fun shouldNotDeleteRuntimeCacheEntryAfterChainUpdate() = runBlocking { + val chainInfo = createTestChain("0x00") + + dao.addChain(chainInfo) + dao.updateRemoteRuntimeVersionIfChainExists(chainInfo.chain.id, runtimeVersion = 1, transactionVersion = 1) + + dao.updateChain(chainInfo) + + val runtimeEntry = dao.runtimeInfo(chainInfo.chain.id) + + assertNotNull(runtimeEntry) + } + + @Test + fun shouldDeleteRemovedNestedFields() = runBlocking { + val chainInfo = createTestChain("0x00", nodesCount = 3, assetsCount = 3) + + dao.addChain(chainInfo) + + dao.applyDiff( + chainDiff = updatedDiff(chainInfo.chain), + assetsDiff = CollectionDiffer.Diff( + added = emptyList(), + updated = emptyList(), + removed = chainInfo.assets.takeLast(1), + all = chainInfo.assets + ), + nodesDiff = CollectionDiffer.Diff( + added = emptyList(), + updated = emptyList(), + removed = chainInfo.nodes.takeLast(1), + all = chainInfo.nodes + ), + explorersDiff = emptyDiff(), + externalApisDiff = emptyDiff(), + nodeSelectionPreferencesDiff = emptyDiff() + ) + + val chainFromDb2 = dao.getJoinChainInfo().first() + + assertEquals(2, chainFromDb2.nodes.size) + assertEquals(2, chainFromDb2.assets.size) + } + + @Test + fun shouldUpdate() = runBlocking { + val toBeRemoved = listOf( + createTestChain("to be removed 1"), + createTestChain("to be removed 2"), + ) + + val stayTheSame = listOf( + createTestChain("stay the same") + ) + + val chainsInitial = listOf(createTestChain("to be changed")) + stayTheSame + toBeRemoved + + dao.addChains(chainsInitial) + + val added = listOf(createTestChain("to be added")) + val updated = listOf(createTestChain("to be changed", "new name")) + + val expectedResult = stayTheSame + added + updated + + dao.applyDiff( + chainDiff = CollectionDiffer.Diff( + added = added.map(JoinedChainInfo::chain), + updated = updated.map(JoinedChainInfo::chain), + removed = toBeRemoved.map(JoinedChainInfo::chain), + all = emptyList() + ), + assetsDiff = emptyDiff(), + nodesDiff = emptyDiff(), + explorersDiff = emptyDiff(), + externalApisDiff = emptyDiff(), + nodeSelectionPreferencesDiff = emptyDiff() + ) + + val chainsFromDb = dao.getJoinChainInfo() + + assertEquals(expectedResult.size, chainsFromDb.size) + expectedResult.forEach { expected -> + val tryFind = chainsFromDb.firstOrNull { actual -> expected.chain.id == actual.chain.id && expected.chain.name == actual.chain.name } + + assertNotNull("Did not find ${expected.chain.id} in result set", tryFind) + } + } + + @Test + fun shouldUpdateRuntimeVersions() { + runBlocking { + val chainId = "0x00" + + dao.addChain(createTestChain(chainId)) + + dao.updateRemoteRuntimeVersionIfChainExists(chainId, 1, transactionVersion = 1) + + checkRuntimeVersions(remote = 1, synced = 0) + + dao.updateSyncedRuntimeVersion(chainId, 1, localMigratorVersion = 1) + + checkRuntimeVersions(remote = 1, synced = 1) + + dao.updateRemoteRuntimeVersionIfChainExists(chainId, 2, transactionVersion = 1) + + checkRuntimeVersions(remote = 2, synced = 1) + } + } + + private suspend fun checkRuntimeVersions(remote: Int, synced: Int) { + val runtimeInfo = dao.runtimeInfo("0x00") + + requireNotNull(runtimeInfo) + + assertEquals(runtimeInfo.remoteVersion, remote) + assertEquals(runtimeInfo.syncedVersion, synced) + } +} diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/DaoTest.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/DaoTest.kt new file mode 100644 index 0000000..b90c106 --- /dev/null +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/DaoTest.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.core_db.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import io.novafoundation.nova.core_db.AppDatabase +import org.junit.After +import org.junit.Before +import java.io.IOException + +abstract class DaoTest(private val daoFetcher: (AppDatabase) -> D) { + protected lateinit var dao: D + protected lateinit var db: AppDatabase + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .build() + + dao = daoFetcher(db) + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + protected inline fun dao(): Lazy = lazy { + val method = db.javaClass.declaredMethods.first { it.returnType == T::class.java } + + method.invoke(db) as T + } +} diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt new file mode 100644 index 0000000..73e241a --- /dev/null +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt @@ -0,0 +1,207 @@ +package io.novafoundation.nova.core_db.dao + +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.model.CurrencyLocal +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainExplorerLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.JoinedChainInfo +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal + +fun createTestChain( + id: String, + name: String = id, + nodesCount: Int = 3, + assetsCount: Int = 2, +): JoinedChainInfo { + val chain = chainOf(id, name) + val nodes = with(chain) { + (1..nodesCount).map { + nodeOf("link${it}") + } + } + val assets = with(chain) { + (1..assetsCount).map { + assetOf(assetId = it, symbol = it.toString()) + } + } + val explorers = emptyList() + val externalApis = emptyList() + + return JoinedChainInfo( + chain, + NodeSelectionPreferencesLocal(chain.id, autoBalanceEnabled = true, selectedNodeUrl = null), + nodes, + assets, + explorers, + externalApis + ) +} + +fun chainOf( + id: String, + name: String = id, +) = ChainLocal( + id = id, + parentId = null, + name = name, + icon = "Test", + types = null, + prefix = 0, + legacyPrefix = null, + isTestNet = false, + isEthereumBased = false, + hasCrowdloans = false, + additional = "", + governance = "governance", + connectionState = ChainLocal.ConnectionStateLocal.FULL_SYNC, + pushSupport = true, + supportProxy = false, + swap = "", + hasSubstrateRuntime = true, + nodeSelectionStrategy = ChainLocal.AutoBalanceStrategyLocal.ROUND_ROBIN, + source = ChainLocal.Source.CUSTOM, + customFee = "", + multisigSupport = true +) + +fun ChainLocal.nodeOf( + link: String, +) = ChainNodeLocal( + name = "Test", + url = link, + chainId = id, + orderId = 0, + source = ChainNodeLocal.Source.CUSTOM, +) + +fun ChainLocal.assetOf( + assetId: Int, + symbol: String, +) = ChainAssetLocal( + name = "Test", + chainId = id, + symbol = symbol, + id = assetId, + precision = 10, + priceId = null, + staking = "test", + icon = "test", + type = "test", + buyProviders = "test", + sellProviders = "test", + typeExtras = null, + enabled = true, + source = AssetSourceLocal.DEFAULT +) + +suspend fun ChainDao.addChains(chains: List) { + applyDiff( + chainDiff = addedDiff(chains.map(JoinedChainInfo::chain)), + assetsDiff = addedDiff(chains.flatMap(JoinedChainInfo::assets)), + nodesDiff = addedDiff(chains.flatMap(JoinedChainInfo::nodes)), + explorersDiff = addedDiff(chains.flatMap(JoinedChainInfo::explorers)), + externalApisDiff = addedDiff(chains.flatMap(JoinedChainInfo::externalApis)), + nodeSelectionPreferencesDiff = emptyDiff() + ) +} + +suspend fun ChainDao.addChain(joinedChainInfo: JoinedChainInfo) = addChains(listOf(joinedChainInfo)) + +suspend fun ChainDao.removeChain(joinedChainInfo: JoinedChainInfo) { + applyDiff( + chainDiff = removedDiff(joinedChainInfo.chain), + assetsDiff = removedDiff(joinedChainInfo.assets), + nodesDiff = removedDiff(joinedChainInfo.nodes), + explorersDiff = removedDiff(joinedChainInfo.explorers), + externalApisDiff = removedDiff(joinedChainInfo.externalApis), + nodeSelectionPreferencesDiff = emptyDiff() + ) +} + +suspend fun ChainDao.updateChain(joinedChainInfo: JoinedChainInfo) { + applyDiff( + chainDiff = updatedDiff(joinedChainInfo.chain), + assetsDiff = updatedDiff(joinedChainInfo.assets), + nodesDiff = updatedDiff(joinedChainInfo.nodes), + explorersDiff = updatedDiff(joinedChainInfo.explorers), + externalApisDiff = updatedDiff(joinedChainInfo.externalApis), + nodeSelectionPreferencesDiff = emptyDiff() + ) +} + +fun addedDiff(elements: List) = CollectionDiffer.Diff( + added = elements, + updated = emptyList(), + removed = emptyList(), + all = elements +) + +fun updatedDiff(elements: List) = CollectionDiffer.Diff( + added = emptyList(), + updated = elements, + removed = emptyList(), + all = elements +) + +fun updatedDiff(element: T) = updatedDiff(listOf(element)) + +fun addedDiff(element: T) = addedDiff(listOf(element)) + +fun removedDiff(element: T) = removedDiff(listOf(element)) + +fun removedDiff(elements: List) = CollectionDiffer.Diff( + added = emptyList(), + updated = emptyList(), + removed = elements, + all = elements +) + +fun emptyDiff() = CollectionDiffer.Diff(emptyList(), emptyList(), emptyList(), emptyList()) + +fun testMetaAccount(name: String = "Test") = MetaAccountLocal( + substratePublicKey = byteArrayOf(), + substrateCryptoType = CryptoType.SR25519, + ethereumPublicKey = null, + name = name, + isSelected = false, + substrateAccountId = byteArrayOf(), + ethereumAddress = null, + position = 0, + type = MetaAccountLocal.Type.WATCH_ONLY, + globallyUniqueId = "", + parentMetaId = 1, + status = MetaAccountLocal.Status.ACTIVE, + typeExtras = null +) + +fun testChainAccount( + metaId: Long, + chainId: String, + accountId: ByteArray = byteArrayOf() +) = ChainAccountLocal( + metaId = metaId, + chainId = chainId, + publicKey = byteArrayOf(), + cryptoType = CryptoType.SR25519, + accountId = accountId +) + +fun createCurrency(symbol: String = "$", selected: Boolean = true): CurrencyLocal { + return CurrencyLocal( + code = "USD", + name = "Dollar", + symbol = symbol, + category = CurrencyLocal.Category.FIAT, + popular = true, + id = 0, + coingeckoId = "usd", + selected = selected + ) +} diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/MetaAccountDaoTest.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/MetaAccountDaoTest.kt new file mode 100644 index 0000000..a73d3e0 --- /dev/null +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/MetaAccountDaoTest.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.novafoundation.nova.core_db.AppDatabase +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +private const val CHAIN_ID = "1" + +@RunWith(AndroidJUnit4::class) +class MetaAccountDaoTest : DaoTest(AppDatabase::metaAccountDao) { + + private val chainDao by dao() + + @Before + fun insertChain() = runBlocking { + chainDao.addChain(createTestChain(id = CHAIN_ID)) + } + + @Test + fun shouldInsertMetaAccount() { + runBlocking { + dao.insertMetaAccount(testMetaAccount()) + dao.insertMetaAccount(testMetaAccount()) + + val accountsFromDb = dao.getMetaAccounts() + + assertEquals(2, accountsFromDb.size) + + val isIdAutoGenerated = accountsFromDb.withIndex().all { (index, account) -> + account.id == index + 1L + } + + assertTrue("Id should be autogenerated", isIdAutoGenerated) + } + } + + @Test + fun shouldInsertAndRetrieveChainAccounts() { + runBlocking { + val metaId = dao.insertMetaAccount(testMetaAccount()) + + assertNotEquals(-1, metaId) + + dao.insertChainAccount(testChainAccount(metaId, CHAIN_ID)) + + val joinedMetaAccountInfo = dao.getJoinedMetaAccountInfo(metaId) + + assertEquals(1, joinedMetaAccountInfo.chainAccounts.size) + } + } + + @Test + fun shouldReplaceChainAccounts() { + runBlocking { + val metaId = dao.insertMetaAccount(testMetaAccount()) + + val newAccountId = byteArrayOf(1) + + dao.insertChainAccount(testChainAccount(metaId, CHAIN_ID, accountId = byteArrayOf(0))) + dao.insertChainAccount(testChainAccount(metaId, CHAIN_ID, accountId = newAccountId)) + + val chainAccounts = dao.getJoinedMetaAccountInfo(metaId).chainAccounts + + assertEquals(1, chainAccounts.size) + assertArrayEquals(newAccountId, chainAccounts.single().accountId) + } + } +} diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/TokenDaoTest.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/TokenDaoTest.kt new file mode 100644 index 0000000..669e0c5 --- /dev/null +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/TokenDaoTest.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.novafoundation.nova.core_db.AppDatabase +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TokenDaoTest : DaoTest(AppDatabase::tokenDao) { + + private val currencyDao by dao() + + private val tokenSymbol = "$" + + @Test + fun getTokenWhenCurrencySelected() = runBlocking { + currencyDao.insert(createCurrency(tokenSymbol, true)) + + val tokenWithCurrency = dao.getTokenWithCurrency(tokenSymbol) + + assert(tokenWithCurrency != null) + assert(tokenWithCurrency?.token == null) + } + + @Test + fun getTokenWhenCurrencyNotSelected() = runBlocking { + currencyDao.insert(createCurrency(tokenSymbol, false)) + + val token = dao.getTokenWithCurrency(tokenSymbol) + + assert(token == null) + } + + @Test + fun getTokensWhenCurrencySelected() = runBlocking { + currencyDao.insert(createCurrency(tokenSymbol, true)) + + val tokensWithCurrencies = dao.getTokensWithCurrency(listOf(tokenSymbol)) + + assert(tokensWithCurrencies.isNotEmpty()) + } + + @Test + fun getTokensWhenCurrencyNotSelected() = runBlocking { + currencyDao.insert(createCurrency(tokenSymbol, false)) + + val tokensWithCurrencies = dao.getTokensWithCurrency(listOf(tokenSymbol)) + + assert(tokensWithCurrencies.isEmpty()) + } + + @Test + fun shouldInsertTokenWithDefaultCurrency() = runBlocking { + currencyDao.insert(createCurrency(tokenSymbol, true)) + + dao.insertTokenWithSelectedCurrency(tokenSymbol) + val tokenWithCurrency = dao.getTokenWithCurrency(tokenSymbol) + assert(tokenWithCurrency != null) + } + + @Test + fun shouldInsertTokenWithoutCurrency() = runBlocking { + currencyDao.insert(createCurrency(tokenSymbol, false)) + + dao.insertTokenWithSelectedCurrency(tokenSymbol) + val tokenWithCurrency = dao.getTokenWithCurrency(tokenSymbol) + assert(tokenWithCurrency == null) + } +} diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/migrations/BaseMigrationTest.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/migrations/BaseMigrationTest.kt new file mode 100644 index 0000000..e2a5c49 --- /dev/null +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/migrations/BaseMigrationTest.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.Room +import androidx.room.migration.Migration +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.novafoundation.nova.core_db.AppDatabase +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.runner.RunWith + + +private const val DB_TEST_NAME = "test-db" + +@RunWith(AndroidJUnit4::class) +abstract class BaseMigrationTest { + + @get:Rule + val migrationHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java.canonicalName, + + FrameworkSQLiteOpenHelperFactory() + ) + + protected fun runMigrationTest( + from: Int, + to: Int, + vararg migrations: Migration, + preMigrateBlock: (SupportSQLiteDatabase) -> Unit = {}, + postMigrateBlock: suspend (AppDatabase) -> Unit = {} + ) { + runBlocking { + val db = migrationHelper.createDatabase(DB_TEST_NAME, from) + preMigrateBlock(db) + + val validateDroppedTables = true + migrationHelper.runMigrationsAndValidate(DB_TEST_NAME, to, validateDroppedTables, *migrations) + + postMigrateBlock(getMigratedRoomDatabase(*migrations)) + } + } + + protected fun validateSchema( + from: Int, + to: Int, + vararg migrations: Migration, + ) = runMigrationTest(from, to, *migrations) + + private fun getMigratedRoomDatabase(vararg migrations: Migration): AppDatabase { + val database: AppDatabase = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), + AppDatabase::class.java, DB_TEST_NAME) + .addMigrations(*migrations) + .build() + + migrationHelper.closeWhenFinished(database) + + return database + } +} diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/migrations/BetterChainDiffingTest_8_9.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/migrations/BetterChainDiffingTest_8_9.kt new file mode 100644 index 0000000..370dacd --- /dev/null +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/migrations/BetterChainDiffingTest_8_9.kt @@ -0,0 +1,195 @@ +package io.novafoundation.nova.core_db.migrations + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.AppDatabase +import io.novafoundation.nova.core_db.converters.CryptoTypeConverters +import io.novafoundation.nova.core_db.dao.assetOf +import io.novafoundation.nova.core_db.dao.chainOf +import io.novafoundation.nova.core_db.dao.testMetaAccount +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigInteger + +private class OldAsset( + val metaId: Long, + val chainId: String, + val tokenSymbol: String, + val freeInPlanks: Int +) + +class BetterChainDiffingTest_8_9 : BaseMigrationTest() { + + private val cryptoTypeConverters = CryptoTypeConverters() + + var meta1Id: Long = -1 + var meta2Id: Long = -1 + + val chain1Id = "1" + val chain2Id = "2" + + private lateinit var assetsOld: List + + @Test + fun validateMigration() = runMigrationTest( + from = 8, + to = 9, + BetterChainDiffing_8_9, + preMigrateBlock = ::preMigrate, + postMigrateBlock = ::postMigrate + ) + + private fun preMigrate(db: SupportSQLiteDatabase) { + db.beginTransaction() + + db.insertChain(chain1Id, assetSymbols = listOf("A", "B", "C")) + db.insertChain(chain2Id, assetSymbols = listOf("C", "D", "E")) + + meta1Id = db.insertMetaAccount(name = "1") + meta2Id = db.insertMetaAccount(name = "2") + + assetsOld = listOf( + OldAsset(meta1Id, chain1Id, tokenSymbol = "A", freeInPlanks = 1), + OldAsset(meta1Id, chain1Id, tokenSymbol = "B", freeInPlanks = 2), + OldAsset(meta1Id, chain1Id, tokenSymbol = "C", freeInPlanks = 3), + OldAsset(meta1Id, chain2Id, tokenSymbol = "C", freeInPlanks = 4), + OldAsset(meta1Id, chain2Id, tokenSymbol = "D", freeInPlanks = 5), + OldAsset(meta1Id, chain2Id, tokenSymbol = "E", freeInPlanks = 6), + + OldAsset(meta2Id, chain1Id, tokenSymbol = "A", freeInPlanks = 11), + OldAsset(meta2Id, chain1Id, tokenSymbol = "C", freeInPlanks = 13), + ) + + assetsOld.forEach { db.insertAsset(it) } + + db.setTransactionSuccessful() + db.endTransaction() + } + + private suspend fun postMigrate(db: AppDatabase) { + val assetsForMeta1 = db.assetDao().getSupportedAssets(meta1Id) + + val symbolToAssetIdMapping = mapOf( + (chain1Id to "A") to 0, + (chain1Id to "B") to 1, + (chain1Id to "C") to 2, + (chain2Id to "C") to 0, + (chain2Id to "D") to 1, + (chain2Id to "E") to 2, + ) + + assetsForMeta1.forEach { + val actualChainId = it.asset!!.chainId + val actualTokenSymbol = it.token!!.tokenSymbol + + val assetIdExpected = symbolToAssetIdMapping[actualChainId to actualTokenSymbol] + assertEquals(assetIdExpected, it.asset!!.assetId) + + val expectedOldAsset = assetsOld.first { it.chainId == actualChainId && it.metaId == meta1Id && it.tokenSymbol == actualTokenSymbol } + assertEquals(expectedOldAsset.freeInPlanks.toBigInteger(), it.asset!!.freeInPlanks) + } + } + + private fun SupportSQLiteDatabase.insertMetaAccount( + name: String + ): Long { + val metaAccount = testMetaAccount(name) + + val contentValues = ContentValues().apply { + put(MetaAccountLocal.Table.Column.SUBSTRATE_PUBKEY, metaAccount.substratePublicKey) + put(MetaAccountLocal.Table.Column.SUBSTRATE_ACCOUNT_ID, metaAccount.substrateAccountId) + put(MetaAccountLocal.Table.Column.ETHEREUM_ADDRESS, metaAccount.ethereumAddress) + put(MetaAccountLocal.Table.Column.ETHEREUM_PUBKEY, metaAccount.ethereumPublicKey) + put(MetaAccountLocal.Table.Column.NAME, metaAccount.name) + put(MetaAccountLocal.Table.Column.SUBSTRATE_CRYPTO_TYPE, cryptoTypeConverters.from(metaAccount.substrateCryptoType)) + put(MetaAccountLocal.Table.Column.IS_SELECTED, metaAccount.isSelected) + put(MetaAccountLocal.Table.Column.POSITION, metaAccount.position) + } + + return insert(MetaAccountLocal.TABLE_NAME, 0, contentValues) + } + + private fun SupportSQLiteDatabase.insertChain( + id: String, + assetSymbols: List + ) { + val chain = chainOf(id) + + val contentValues = ContentValues().apply { + put("parentId", chain.parentId) + put("name", chain.name) + put("additional", chain.additional) + put("id", chain.id) + put("icon", chain.icon) + // types + putNull("url") + putNull("overridesCommon") + // externalApi + putNull("staking_url") + putNull("staking_type") + putNull("history_type") + putNull("history_url") + putNull("crowdloans_url") + putNull("crowdloans_type") + + put("prefix", chain.prefix) + put("isEthereumBased", chain.isEthereumBased) + put("isTestNet", chain.isTestNet) + put("hasCrowdloans", chain.hasCrowdloans) + } + + insert("chains", 0, contentValues) + + val assets = assetSymbols.mapIndexed { index, symbol -> + chain.assetOf(assetId = index, symbol) + } + + assets.forEach { + val contentValues = ContentValues().apply { + put("id", it.id) + put("chainId", it.chainId) + put("name", it.name) + put("symbol", it.symbol) + put("priceId", it.priceId) + put("staking", it.staking) + put("precision", it.precision) + put("icon", it.icon) + put("type", it.type) + put("typeExtras", it.typeExtras) + put("buyProviders", it.buyProviders) + } + + insert("chain_assets", 0, contentValues) + } + } + + private fun SupportSQLiteDatabase.insertAsset(oldAsset: OldAsset) { + val tokenContentValues = ContentValues().apply { + put("symbol", oldAsset.tokenSymbol) + putNull("dollarRate") + putNull("recentRateChange") + } + + insert("tokens", SQLiteDatabase.CONFLICT_REPLACE, tokenContentValues) + + val assetContentValues = ContentValues().apply { + put("tokenSymbol", oldAsset.tokenSymbol) + put("chainId", oldAsset.chainId) + put("metaId", oldAsset.metaId) + + val amountZero = BigInteger.ZERO.toString() + + put("freeInPlanks", oldAsset.freeInPlanks.toString()) + put("frozenInPlanks", amountZero) + put("reservedInPlanks", amountZero) + + put("bondedInPlanks", amountZero) + put("redeemableInPlanks", amountZero) + put("unbondingInPlanks", amountZero) + } + + insert("assets", 0, assetContentValues) + } +} diff --git a/core-db/src/main/AndroidManifest.xml b/core-db/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/core-db/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt new file mode 100644 index 0000000..f6639ce --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt @@ -0,0 +1,341 @@ +package io.novafoundation.nova.core_db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import io.novafoundation.nova.core_db.converters.AssetConverters +import io.novafoundation.nova.core_db.converters.ChainConverters +import io.novafoundation.nova.core_db.converters.CryptoTypeConverters +import io.novafoundation.nova.core_db.converters.CurrencyConverters +import io.novafoundation.nova.core_db.converters.ExternalApiConverters +import io.novafoundation.nova.core_db.converters.ExternalBalanceTypeConverters +import io.novafoundation.nova.core_db.converters.LongMathConverters +import io.novafoundation.nova.core_db.converters.MetaAccountTypeConverters +import io.novafoundation.nova.core_db.converters.NetworkTypeConverters +import io.novafoundation.nova.core_db.converters.NftConverters +import io.novafoundation.nova.core_db.converters.OperationConverters +import io.novafoundation.nova.core_db.converters.ProxyAccountConverters +import io.novafoundation.nova.core_db.dao.AccountDao +import io.novafoundation.nova.core_db.dao.AccountStakingDao +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao +import io.novafoundation.nova.core_db.dao.BrowserTabsDao +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.dao.CoinPriceDao +import io.novafoundation.nova.core_db.dao.ContributionDao +import io.novafoundation.nova.core_db.dao.CurrencyDao +import io.novafoundation.nova.core_db.dao.DappAuthorizationDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.dao.FavouriteDAppsDao +import io.novafoundation.nova.core_db.dao.GiftsDao +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.core_db.dao.HoldsDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.MultisigOperationsDao +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.core_db.dao.NodeDao +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.core_db.dao.PhishingAddressDao +import io.novafoundation.nova.core_db.dao.PhishingSitesDao +import io.novafoundation.nova.core_db.dao.StakingDashboardDao +import io.novafoundation.nova.core_db.dao.StakingRewardPeriodDao +import io.novafoundation.nova.core_db.dao.StakingTotalRewardDao +import io.novafoundation.nova.core_db.dao.StorageDao +import io.novafoundation.nova.core_db.dao.TinderGovDao +import io.novafoundation.nova.core_db.dao.TokenDao +import io.novafoundation.nova.core_db.dao.WalletConnectSessionsDao +import io.novafoundation.nova.core_db.migrations.AddAdditionalFieldToChains_12_13 +import io.novafoundation.nova.core_db.migrations.AddBalanceHolds_60_61 +import io.novafoundation.nova.core_db.migrations.AddBalanceModesToAssets_51_52 +import io.novafoundation.nova.core_db.migrations.AddBrowserHostSettings_34_35 +import io.novafoundation.nova.core_db.migrations.AddBrowserTabs_64_65 +import io.novafoundation.nova.core_db.migrations.AddBuyProviders_7_8 +import io.novafoundation.nova.core_db.migrations.AddChainColor_4_5 +import io.novafoundation.nova.core_db.migrations.AddChainForeignKeyForProxy_63_64 +import io.novafoundation.nova.core_db.migrations.AddConnectionStateToChains_53_54 +import io.novafoundation.nova.core_db.migrations.AddFieldsToContributions +import io.novafoundation.nova.core_db.migrations.AddContributions_23_24 +import io.novafoundation.nova.core_db.migrations.AddCurrencies_18_19 +import io.novafoundation.nova.core_db.migrations.AddDAppAuthorizations_1_2 +import io.novafoundation.nova.core_db.migrations.AddEnabledColumnToChainAssets_30_31 +import io.novafoundation.nova.core_db.migrations.AddEventIdToOperation_47_48 +import io.novafoundation.nova.core_db.migrations.AddExternalBalances_45_46 +import io.novafoundation.nova.core_db.migrations.AddExtrinsicContentField_37_38 +import io.novafoundation.nova.core_db.migrations.AddFavoriteDAppsOrdering_65_66 +import io.novafoundation.nova.core_db.migrations.AddFavouriteDApps_9_10 +import io.novafoundation.nova.core_db.migrations.AddFungibleNfts_55_56 +import io.novafoundation.nova.core_db.migrations.AddGifts_71_72 +import io.novafoundation.nova.core_db.migrations.AddGloballyUniqueIdToMetaAccounts_58_59 +import io.novafoundation.nova.core_db.migrations.AddGovernanceDapps_25_26 +import io.novafoundation.nova.core_db.migrations.AddGovernanceExternalApiToChain_27_28 +import io.novafoundation.nova.core_db.migrations.AddGovernanceFlagToChains_24_25 +import io.novafoundation.nova.core_db.migrations.AddGovernanceNetworkToExternalApi_33_34 +import io.novafoundation.nova.core_db.migrations.AddLegacyAddressPrefix_66_67 +import io.novafoundation.nova.core_db.migrations.AddLocalMigratorVersionToChainRuntimes_57_58 +import io.novafoundation.nova.core_db.migrations.AddLocks_22_23 +import io.novafoundation.nova.core_db.migrations.AddMetaAccountType_14_15 +import io.novafoundation.nova.core_db.migrations.AddMultisigCalls_69_70 +import io.novafoundation.nova.core_db.migrations.AddMultisigSupportFlag_70_71 +import io.novafoundation.nova.core_db.migrations.AddNfts_5_6 +import io.novafoundation.nova.core_db.migrations.AddNodeSelectionStrategyField_38_39 +import io.novafoundation.nova.core_db.migrations.AddPoolIdToOperations_46_47 +import io.novafoundation.nova.core_db.migrations.AddProxyAccount_54_55 +import io.novafoundation.nova.core_db.migrations.AddRewardAccountToStakingDashboard_43_44 +import io.novafoundation.nova.core_db.migrations.AddRuntimeFlagToChains_36_37 +import io.novafoundation.nova.core_db.migrations.AddSellProviders_67_68 +import io.novafoundation.nova.core_db.migrations.AddSitePhishing_6_7 +import io.novafoundation.nova.core_db.migrations.AddSourceToLocalAsset_28_29 +import io.novafoundation.nova.core_db.migrations.AddStakingDashboardItems_41_42 +import io.novafoundation.nova.core_db.migrations.AddStakingTypeToTotalRewards_44_45 +import io.novafoundation.nova.core_db.migrations.AddSwapOption_48_49 +import io.novafoundation.nova.core_db.migrations.AddTransactionVersionToRuntime_50_51 +import io.novafoundation.nova.core_db.migrations.AddTransferApisTable_29_30 +import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69 +import io.novafoundation.nova.core_db.migrations.AddVersioningToGovernanceDapps_32_33 +import io.novafoundation.nova.core_db.migrations.AddWalletConnectSessions_39_40 +import io.novafoundation.nova.core_db.migrations.AssetTypes_2_3 +import io.novafoundation.nova.core_db.migrations.BetterChainDiffing_8_9 +import io.novafoundation.nova.core_db.migrations.ChainNetworkManagement_59_60 +import io.novafoundation.nova.core_db.migrations.ChainNetworkManagement_61_62 +import io.novafoundation.nova.core_db.migrations.ChainPushSupport_56_57 +import io.novafoundation.nova.core_db.migrations.ChangeAsset_3_4 +import io.novafoundation.nova.core_db.migrations.ChangeChainNodes_20_21 +import io.novafoundation.nova.core_db.migrations.ChangeDAppAuthorization_10_11 +import io.novafoundation.nova.core_db.migrations.ChangeSessionTopicToParing_52_53 +import io.novafoundation.nova.core_db.migrations.ChangeTokens_19_20 +import io.novafoundation.nova.core_db.migrations.ExtractExternalApiToSeparateTable_35_36 +import io.novafoundation.nova.core_db.migrations.FixBrokenForeignKeys_31_32 +import io.novafoundation.nova.core_db.migrations.FixMigrationConflicts_13_14 +import io.novafoundation.nova.core_db.migrations.GovernanceFlagToEnum_26_27 +import io.novafoundation.nova.core_db.migrations.NullableSubstrateAccountId_21_22 +import io.novafoundation.nova.core_db.migrations.NullableSubstratePublicKey_15_16 +import io.novafoundation.nova.core_db.migrations.RefactorOperations_49_50 +import io.novafoundation.nova.core_db.migrations.RemoveChainForeignKeyFromChainAccount_11_12 +import io.novafoundation.nova.core_db.migrations.RemoveColorFromChains_17_18 +import io.novafoundation.nova.core_db.migrations.StakingRewardPeriods_42_43 +import io.novafoundation.nova.core_db.migrations.TinderGovBasket_62_63 +import io.novafoundation.nova.core_db.migrations.TransferFiatAmount_40_41 +import io.novafoundation.nova.core_db.migrations.WatchOnlyChainAccounts_16_17 +import io.novafoundation.nova.core_db.model.AccountLocal +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.core_db.model.AssetLocal +import io.novafoundation.nova.core_db.model.BalanceHoldLocal +import io.novafoundation.nova.core_db.model.BalanceLockLocal +import io.novafoundation.nova.core_db.model.BrowserHostSettingsLocal +import io.novafoundation.nova.core_db.model.BrowserTabLocal +import io.novafoundation.nova.core_db.model.CoinPriceLocal +import io.novafoundation.nova.core_db.model.ContributionLocal +import io.novafoundation.nova.core_db.model.CurrencyLocal +import io.novafoundation.nova.core_db.model.DappAuthorizationLocal +import io.novafoundation.nova.core_db.model.ExternalBalanceLocal +import io.novafoundation.nova.core_db.model.FavouriteDAppLocal +import io.novafoundation.nova.core_db.model.GiftLocal +import io.novafoundation.nova.core_db.model.GovernanceDAppLocal +import io.novafoundation.nova.core_db.model.MultisigOperationCallLocal +import io.novafoundation.nova.core_db.model.NftLocal +import io.novafoundation.nova.core_db.model.NodeLocal +import io.novafoundation.nova.core_db.model.PhishingAddressLocal +import io.novafoundation.nova.core_db.model.PhishingSiteLocal +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal +import io.novafoundation.nova.core_db.model.StakingRewardPeriodLocal +import io.novafoundation.nova.core_db.model.StorageEntryLocal +import io.novafoundation.nova.core_db.model.TinderGovBasketItemLocal +import io.novafoundation.nova.core_db.model.TinderGovVotingPowerLocal +import io.novafoundation.nova.core_db.model.TokenLocal +import io.novafoundation.nova.core_db.model.TotalRewardLocal +import io.novafoundation.nova.core_db.model.WalletConnectPairingLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainExplorerLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.ChainRuntimeInfoLocal +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.ProxyAccountLocal +import io.novafoundation.nova.core_db.model.operation.DirectRewardTypeLocal +import io.novafoundation.nova.core_db.model.operation.ExtrinsicTypeLocal +import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal +import io.novafoundation.nova.core_db.model.operation.PoolRewardTypeLocal +import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal +import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal + +@Database( + version = 73, + entities = [ + AccountLocal::class, + NodeLocal::class, + AssetLocal::class, + TokenLocal::class, + PhishingAddressLocal::class, + StorageEntryLocal::class, + AccountStakingLocal::class, + TotalRewardLocal::class, + OperationBaseLocal::class, + TransferTypeLocal::class, + DirectRewardTypeLocal::class, + PoolRewardTypeLocal::class, + ExtrinsicTypeLocal::class, + SwapTypeLocal::class, + ChainLocal::class, + ChainNodeLocal::class, + ChainAssetLocal::class, + ChainRuntimeInfoLocal::class, + ChainExplorerLocal::class, + ChainExternalApiLocal::class, + MetaAccountLocal::class, + ChainAccountLocal::class, + DappAuthorizationLocal::class, + NftLocal::class, + PhishingSiteLocal::class, + FavouriteDAppLocal::class, + CurrencyLocal::class, + BalanceLockLocal::class, + ContributionLocal::class, + GovernanceDAppLocal::class, + BrowserHostSettingsLocal::class, + WalletConnectPairingLocal::class, + CoinPriceLocal::class, + StakingDashboardItemLocal::class, + StakingRewardPeriodLocal::class, + ExternalBalanceLocal::class, + ProxyAccountLocal::class, + BalanceHoldLocal::class, + NodeSelectionPreferencesLocal::class, + TinderGovBasketItemLocal::class, + TinderGovVotingPowerLocal::class, + BrowserTabLocal::class, + MultisigOperationCallLocal::class, + GiftLocal::class + ], +) +@TypeConverters( + LongMathConverters::class, + NetworkTypeConverters::class, + OperationConverters::class, + CryptoTypeConverters::class, + NftConverters::class, + MetaAccountTypeConverters::class, + CurrencyConverters::class, + AssetConverters::class, + ExternalApiConverters::class, + ChainConverters::class, + ExternalBalanceTypeConverters::class, + ProxyAccountConverters::class, +) +abstract class AppDatabase : RoomDatabase() { + + companion object { + + private var instance: AppDatabase? = null + + @Synchronized + fun get( + context: Context + ): AppDatabase { + if (instance == null) { + instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "app.db" + ) + .addMigrations(AddDAppAuthorizations_1_2, AssetTypes_2_3, ChangeAsset_3_4) + .addMigrations(AddChainColor_4_5, AddNfts_5_6, AddSitePhishing_6_7, AddBuyProviders_7_8, BetterChainDiffing_8_9) + .addMigrations(AddFavouriteDApps_9_10, ChangeDAppAuthorization_10_11, RemoveChainForeignKeyFromChainAccount_11_12) + .addMigrations(AddAdditionalFieldToChains_12_13, FixMigrationConflicts_13_14, AddMetaAccountType_14_15) + .addMigrations(NullableSubstratePublicKey_15_16, WatchOnlyChainAccounts_16_17, RemoveColorFromChains_17_18) + .addMigrations(AddCurrencies_18_19, ChangeTokens_19_20, ChangeChainNodes_20_21) + .addMigrations(NullableSubstrateAccountId_21_22, AddLocks_22_23, AddContributions_23_24) + .addMigrations(AddGovernanceFlagToChains_24_25, AddGovernanceDapps_25_26, GovernanceFlagToEnum_26_27) + .addMigrations(AddGovernanceExternalApiToChain_27_28) + .addMigrations(AddSourceToLocalAsset_28_29, AddTransferApisTable_29_30, AddEnabledColumnToChainAssets_30_31) + .addMigrations(FixBrokenForeignKeys_31_32, AddVersioningToGovernanceDapps_32_33) + .addMigrations(AddGovernanceNetworkToExternalApi_33_34, AddBrowserHostSettings_34_35) + .addMigrations(ExtractExternalApiToSeparateTable_35_36, AddRuntimeFlagToChains_36_37) + .addMigrations(AddExtrinsicContentField_37_38, AddNodeSelectionStrategyField_38_39) + .addMigrations(AddWalletConnectSessions_39_40, TransferFiatAmount_40_41) + .addMigrations(AddStakingDashboardItems_41_42, StakingRewardPeriods_42_43) + .addMigrations(AddRewardAccountToStakingDashboard_43_44, AddStakingTypeToTotalRewards_44_45, AddExternalBalances_45_46) + .addMigrations(AddPoolIdToOperations_46_47, AddEventIdToOperation_47_48, AddSwapOption_48_49) + .addMigrations(RefactorOperations_49_50, AddTransactionVersionToRuntime_50_51, AddBalanceModesToAssets_51_52) + .addMigrations(ChangeSessionTopicToParing_52_53, AddConnectionStateToChains_53_54, AddProxyAccount_54_55) + .addMigrations(AddFungibleNfts_55_56, ChainPushSupport_56_57) + .addMigrations(AddLocalMigratorVersionToChainRuntimes_57_58, AddGloballyUniqueIdToMetaAccounts_58_59) + .addMigrations(ChainNetworkManagement_59_60, AddBalanceHolds_60_61, ChainNetworkManagement_61_62) + .addMigrations(TinderGovBasket_62_63, AddChainForeignKeyForProxy_63_64, AddBrowserTabs_64_65) + .addMigrations(AddFavoriteDAppsOrdering_65_66, AddLegacyAddressPrefix_66_67, AddSellProviders_67_68) + .addMigrations(AddTypeExtrasToMetaAccount_68_69, AddMultisigCalls_69_70, AddMultisigSupportFlag_70_71) + .addMigrations(AddGifts_71_72, AddFieldsToContributions) + .build() + } + return instance!! + } + } + + abstract fun nodeDao(): NodeDao + + abstract fun userDao(): AccountDao + + abstract fun assetDao(): AssetDao + + abstract fun operationDao(): OperationDao + + abstract fun phishingAddressesDao(): PhishingAddressDao + + abstract fun storageDao(): StorageDao + + abstract fun tokenDao(): TokenDao + + abstract fun accountStakingDao(): AccountStakingDao + + abstract fun stakingTotalRewardDao(): StakingTotalRewardDao + + abstract fun chainDao(): ChainDao + + abstract fun chainAssetDao(): ChainAssetDao + + abstract fun metaAccountDao(): MetaAccountDao + + abstract fun dAppAuthorizationDao(): DappAuthorizationDao + + abstract fun nftDao(): NftDao + + abstract fun phishingSitesDao(): PhishingSitesDao + + abstract fun favouriteDAppsDao(): FavouriteDAppsDao + + abstract fun currencyDao(): CurrencyDao + + abstract fun lockDao(): LockDao + + abstract fun contributionDao(): ContributionDao + + abstract fun governanceDAppsDao(): GovernanceDAppsDao + + abstract fun browserHostSettingsDao(): BrowserHostSettingsDao + + abstract fun walletConnectSessionsDao(): WalletConnectSessionsDao + + abstract fun stakingDashboardDao(): StakingDashboardDao + + abstract fun coinPriceDao(): CoinPriceDao + + abstract fun stakingRewardPeriodDao(): StakingRewardPeriodDao + + abstract fun externalBalanceDao(): ExternalBalanceDao + + abstract fun holdsDao(): HoldsDao + + abstract fun tinderGovDao(): TinderGovDao + + abstract fun browserTabsDao(): BrowserTabsDao + + abstract fun multisigOperationsDao(): MultisigOperationsDao + + abstract fun giftsDao(): GiftsDao +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/AssetConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/AssetConverters.kt new file mode 100644 index 0000000..5fd107d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/AssetConverters.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal +import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal + +class AssetConverters { + + @TypeConverter + fun fromCategory(type: AssetSourceLocal): String { + return type.name + } + + @TypeConverter + fun toCategory(name: String): AssetSourceLocal { + return enumValueOf(name) + } + + @TypeConverter + fun fromTransferableMode(mode: TransferableModeLocal): Int { + return mode.ordinal + } + + @TypeConverter + fun toTransferableMode(index: Int): TransferableModeLocal { + return TransferableModeLocal.values()[index] + } + + @TypeConverter + fun fromEdCountingMode(mode: EDCountingModeLocal): Int { + return mode.ordinal + } + + @TypeConverter + fun toEdCountingMode(index: Int): EDCountingModeLocal { + return EDCountingModeLocal.values()[index] + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ChainConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ChainConverters.kt new file mode 100644 index 0000000..0a6caa3 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ChainConverters.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.common.utils.enumValueOfOrNull +import io.novafoundation.nova.core_db.model.chain.ChainLocal.ConnectionStateLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal.AutoBalanceStrategyLocal + +class ChainConverters { + + @TypeConverter + fun fromNodeStrategy(strategy: AutoBalanceStrategyLocal): String = strategy.name + + @TypeConverter + fun toNodeStrategy(name: String): AutoBalanceStrategyLocal { + return enumValueOfOrNull(name) ?: AutoBalanceStrategyLocal.UNKNOWN + } + + @TypeConverter + fun fromConnection(connectionState: ConnectionStateLocal): String = connectionState.name + + @TypeConverter + fun toConnection(name: String): ConnectionStateLocal { + return enumValueOfOrNull(name) ?: ConnectionStateLocal.LIGHT_SYNC + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/CryptoTypeConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/CryptoTypeConverters.kt new file mode 100644 index 0000000..e35ae71 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/CryptoTypeConverters.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.core.model.CryptoType + +class CryptoTypeConverters { + + @TypeConverter + fun from(cryptoType: CryptoType?): String? = cryptoType?.name + + @TypeConverter + fun to(name: String?): CryptoType? = name?.let { enumValueOf(it) } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/CurrencyConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/CurrencyConverters.kt new file mode 100644 index 0000000..7ef0f2f --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/CurrencyConverters.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.core_db.model.CurrencyLocal + +class CurrencyConverters { + + @TypeConverter + fun fromCategory(type: CurrencyLocal.Category): String { + return type.name + } + + @TypeConverter + fun toCategory(name: String): CurrencyLocal.Category { + return enumValueOf(name) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ExternalApiConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ExternalApiConverters.kt new file mode 100644 index 0000000..da2a189 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ExternalApiConverters.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.common.utils.enumValueOfOrNull +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal.SourceType + +class ExternalApiConverters { + + @TypeConverter + fun fromApiType(apiType: SourceType): String { + return apiType.name + } + + @TypeConverter + fun toApiType(raw: String): SourceType { + return enumValueOfOrNull(raw) ?: SourceType.UNKNOWN + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ExternalBalanceTypeConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ExternalBalanceTypeConverters.kt new file mode 100644 index 0000000..2bd5d84 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ExternalBalanceTypeConverters.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.core_db.model.ExternalBalanceLocal + +class ExternalBalanceTypeConverters { + + @TypeConverter + fun fromType(type: ExternalBalanceLocal.Type): String { + return type.name + } + + @TypeConverter + fun toType(name: String): ExternalBalanceLocal.Type { + return ExternalBalanceLocal.Type.valueOf(name) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/LongMathConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/LongMathConverters.kt new file mode 100644 index 0000000..56b540b --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/LongMathConverters.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import java.math.BigDecimal +import java.math.BigInteger + +class LongMathConverters { + + @TypeConverter + fun fromBigDecimal(balance: BigDecimal?): String? { + return balance?.toString() + } + + @TypeConverter + fun toBigDecimal(balance: String?): BigDecimal? { + return balance?.let { BigDecimal(it) } + } + + @TypeConverter + fun fromBigInteger(balance: BigInteger?): String? { + return balance?.toString() + } + + @TypeConverter + fun toBigInteger(balance: String?): BigInteger? { + return balance?.let { + // When using aggregates like SUM in SQL queries, SQLite might return the result in a scientific notation especially if aggregation is done + // BigInteger, which is stored as a string and SQLite casts it to REAL which causing the scientific notation on big numbers + // This can be avoided by adjusting the query but we keep the fallback to BigDecimal parsing here anyways to avoid unpleasant crashes + // It doesn't bring much impact since try-catch doesn't have an overhead unless the exception is thrown + try { + BigInteger(it) + } catch (e: NumberFormatException) { + BigDecimal(it).toBigInteger() + } + } + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/MetaAccountTypeConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/MetaAccountTypeConverters.kt new file mode 100644 index 0000000..59ec7e0 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/MetaAccountTypeConverters.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal + +class MetaAccountTypeConverters { + + @TypeConverter + fun fromEnum(type: MetaAccountLocal.Type): String { + return type.name + } + + @TypeConverter + fun toEnum(name: String): MetaAccountLocal.Type { + return MetaAccountLocal.Type.valueOf(name) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/NetworkTypeConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/NetworkTypeConverters.kt new file mode 100644 index 0000000..9b71d86 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/NetworkTypeConverters.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.core.model.Node + +class NetworkTypeConverters { + + @TypeConverter + fun fromNetworkType(networkType: Node.NetworkType): Int { + return networkType.ordinal + } + + @TypeConverter + fun toNetworkType(ordinal: Int): Node.NetworkType { + return Node.NetworkType.values()[ordinal] + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/NftConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/NftConverters.kt new file mode 100644 index 0000000..0b2bcbc --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/NftConverters.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.core_db.model.NftLocal + +class NftConverters { + + @TypeConverter + fun fromNftType(type: NftLocal.Type): String { + return type.name + } + + @TypeConverter + fun toNftType(name: String): NftLocal.Type { + return enumValueOf(name) + } + + @TypeConverter + fun fromNftIssuanceType(type: NftLocal.IssuanceType): String { + return type.name + } + + @TypeConverter + fun toNftIssuanceType(name: String): NftLocal.IssuanceType { + return enumValueOf(name) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/OperationConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/OperationConverters.kt new file mode 100644 index 0000000..b116293 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/OperationConverters.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.core_db.model.operation.ExtrinsicTypeLocal +import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal + +class OperationConverters { + + @TypeConverter + fun fromOperationSource(source: OperationBaseLocal.Source) = source.ordinal + + @TypeConverter + fun toOperationSource(ordinal: Int) = OperationBaseLocal.Source.values()[ordinal] + + @TypeConverter + fun fromOperationStatus(status: OperationBaseLocal.Status) = status.ordinal + + @TypeConverter + fun toOperationStatus(ordinal: Int) = OperationBaseLocal.Status.values()[ordinal] + + @TypeConverter + fun fromExtrinsicContentType(type: ExtrinsicTypeLocal.ContentType) = type.name + + @TypeConverter + fun toExtrinsicContentType(name: String): ExtrinsicTypeLocal.ContentType = enumValueOf(name) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ProxyAccountConverters.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ProxyAccountConverters.kt new file mode 100644 index 0000000..8cfbd4a --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/converters/ProxyAccountConverters.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.core_db.converters + +import androidx.room.TypeConverter +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal + +class ProxyAccountConverters { + @TypeConverter + fun fromStatusType(type: MetaAccountLocal.Status): String { + return type.name + } + + @TypeConverter + fun toStatusType(name: String): MetaAccountLocal.Status { + return MetaAccountLocal.Status.valueOf(name) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/AccountDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/AccountDao.kt new file mode 100644 index 0000000..8c645df --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/AccountDao.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.core_db.model.AccountLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class AccountDao { + + @Query("select * from users order by networkType, position") + abstract fun accountsFlow(): Flow> + + @Query("select * from users order by networkType, position") + abstract suspend fun getAccounts(): List + + @Query("select * from users where address = :address") + abstract suspend fun getAccount(address: String): AccountLocal? + + @Insert(onConflict = OnConflictStrategy.ABORT) + abstract suspend fun insert(account: AccountLocal): Long + + @Query("DELETE FROM users where address = :address") + abstract suspend fun remove(address: String) + + @Update + abstract suspend fun updateAccount(account: AccountLocal) + + @Update + abstract suspend fun updateAccounts(accounts: List) + + @Query("SELECT COALESCE(MAX(position), 0) + 1 from users") + abstract suspend fun getNextPosition(): Int + + @Query("select * from users where networkType = :networkType") + abstract suspend fun getAccountsByNetworkType(networkType: Int): List + + @Query("select * from users where (address LIKE '%' || :query || '%') AND networkType = :networkType") + abstract suspend fun getAccounts(query: String, networkType: Node.NetworkType): List + + @Query("SELECT EXISTS(SELECT * FROM users WHERE address = :accountAddress)") + abstract suspend fun accountExists(accountAddress: String): Boolean +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/AccountStakingDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/AccountStakingDao.kt new file mode 100644 index 0000000..a9a697d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/AccountStakingDao.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull + +private const val SELECT_QUERY = """ + SELECT * FROM account_staking_accesses + WHERE accountId = :accountId AND chainId = :chainId AND chainAssetId = :chainAssetId + """ + +@Dao +abstract class AccountStakingDao { + + @Query(SELECT_QUERY) + abstract suspend fun get(chainId: String, chainAssetId: Int, accountId: ByteArray): AccountStakingLocal + + @Query(SELECT_QUERY) + protected abstract fun observeInternal(chainId: String, chainAssetId: Int, accountId: ByteArray): Flow + + fun observeDistinct(chainId: String, chainAssetId: Int, accountId: ByteArray): Flow { + return observeInternal(chainId, chainAssetId, accountId) + .filterNotNull() + .distinctUntilChanged() + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(accountStaking: AccountStakingLocal) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/AssetDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/AssetDao.kt new file mode 100644 index 0000000..ea190a0 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/AssetDao.kt @@ -0,0 +1,130 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.core_db.model.AssetAndChainId +import io.novafoundation.nova.core_db.model.AssetLocal +import io.novafoundation.nova.core_db.model.AssetWithToken +import kotlinx.coroutines.flow.Flow + +private const val RETRIEVE_ASSET_SQL_META_ID = """ + SELECT *, ca.chainId as ca_chainId, ca.id as ca_assetId FROM chain_assets AS ca + LEFT JOIN assets AS a ON a.assetId = ca.id AND a.chainId = ca.chainId AND a.metaId = :metaId + INNER JOIN currencies as currency ON currency.selected = 1 + LEFT JOIN tokens AS t ON ca.symbol = t.tokenSymbol AND currency.id = t.currencyId + WHERE ca.chainId = :chainId AND ca.id = :assetId +""" + +private const val RETRIEVE_SYNCED_ACCOUNT_ASSETS_QUERY = """ + SELECT *, ca.chainId as ca_chainId, ca.id as ca_assetId FROM assets AS a + INNER JOIN chain_assets AS ca ON a.assetId = ca.id AND a.chainId = ca.chainId + INNER JOIN currencies as currency ON currency.selected = 1 + LEFT JOIN tokens AS t ON ca.symbol = t.tokenSymbol AND currency.id = t.currencyId + WHERE a.metaId = :metaId +""" + +private const val RETRIEVE_SUPPORTED_ACCOUNT_ASSETS_QUERY = """ + SELECT *, ca.chainId as ca_chainId, ca.id as ca_assetId FROM chain_assets AS ca + LEFT JOIN assets AS a ON a.assetId = ca.id AND a.chainId = ca.chainId AND a.metaId = :metaId + INNER JOIN currencies as currency ON currency.selected = 1 + LEFT JOIN tokens AS t ON ca.symbol = t.tokenSymbol AND currency.id = t.currencyId +""" + +private const val RETRIEVE_ASSETS_SQL_META_ID = """ + SELECT *, ca.chainId as ca_chainId, ca.id as ca_assetId FROM chain_assets AS ca + LEFT JOIN assets AS a ON a.assetId = ca.id AND a.chainId = ca.chainId AND a.metaId = :metaId + INNER JOIN currencies as currency ON currency.selected = 1 + LEFT JOIN tokens AS t ON ca.symbol = t.tokenSymbol AND currency.id = t.currencyId + WHERE ca.chainId || ':' || ca.id in (:joinedChainAndAssetIds) +""" + +interface AssetReadOnlyCache { + + fun observeSyncedAssets(metaId: Long): Flow> + + suspend fun getSyncedAssets(metaId: Long): List + + fun observeSupportedAssets(metaId: Long): Flow> + + suspend fun getSupportedAssets(metaId: Long): List + + fun observeAsset(metaId: Long, chainId: String, assetId: Int): Flow + + fun observeAssetOrNull(metaId: Long, chainId: String, assetId: Int): Flow + + fun observeAssets(metaId: Long, assetIds: Collection): Flow> + + suspend fun getAssetWithToken(metaId: Long, chainId: String, assetId: Int): AssetWithToken? + + suspend fun getAsset(metaId: Long, chainId: String, assetId: Int): AssetLocal? + + suspend fun getAssetsInChain(metaId: Long, chainId: String): List + + suspend fun getAllAssets(): List + + suspend fun getAssetsById(id: Int): List +} + +@Dao +abstract class AssetDao : AssetReadOnlyCache { + + @Query(RETRIEVE_SYNCED_ACCOUNT_ASSETS_QUERY) + abstract override fun observeSyncedAssets(metaId: Long): Flow> + + @Query(RETRIEVE_SYNCED_ACCOUNT_ASSETS_QUERY) + abstract override suspend fun getSyncedAssets(metaId: Long): List + + @Query(RETRIEVE_SUPPORTED_ACCOUNT_ASSETS_QUERY) + abstract override fun observeSupportedAssets(metaId: Long): Flow> + + @Query(RETRIEVE_SUPPORTED_ACCOUNT_ASSETS_QUERY) + abstract override suspend fun getSupportedAssets(metaId: Long): List + + @Query(RETRIEVE_ASSET_SQL_META_ID) + abstract override fun observeAsset(metaId: Long, chainId: String, assetId: Int): Flow + + @Query(RETRIEVE_ASSET_SQL_META_ID) + abstract override fun observeAssetOrNull(metaId: Long, chainId: String, assetId: Int): Flow + + @Query(RETRIEVE_ASSET_SQL_META_ID) + abstract override suspend fun getAssetWithToken(metaId: Long, chainId: String, assetId: Int): AssetWithToken? + + @Query("SELECT * FROM assets WHERE metaId = :metaId AND chainId = :chainId AND assetId = :assetId") + abstract override suspend fun getAsset(metaId: Long, chainId: String, assetId: Int): AssetLocal? + + @Query("SELECT * FROM assets WHERE metaId = :metaId AND chainId = :chainId") + abstract override suspend fun getAssetsInChain(metaId: Long, chainId: String): List + + @Query("SELECT * FROM assets") + abstract override suspend fun getAllAssets(): List + + @Query("SELECT * FROM assets WHERE assetId IS :id") + abstract override suspend fun getAssetsById(id: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertAsset(asset: AssetLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertAssets(assets: List) + + @Delete(entity = AssetLocal::class) + abstract suspend fun clearAssets(assetIds: List) + + @Query(RETRIEVE_ASSETS_SQL_META_ID) + protected abstract fun observeJoinedAssets(metaId: Long, joinedChainAndAssetIds: Set): Flow> + + override fun observeAssets(metaId: Long, assetIds: Collection): Flow> { + return flowOfAll { + val joinedChainAndAssetIds = assetIds.mapToSet { (chainId, assetId) -> "$chainId:$assetId" } + + observeJoinedAssets(metaId, joinedChainAndAssetIds) + } + } +} + +class ClearAssetsParams(val chainId: String, val assetId: Int) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/BrowserHostSettingsDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/BrowserHostSettingsDao.kt new file mode 100644 index 0000000..6830eda --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/BrowserHostSettingsDao.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.BrowserHostSettingsLocal + +@Dao +abstract class BrowserHostSettingsDao { + + @Query("SELECT * FROM browser_host_settings") + abstract suspend fun getBrowserAllHostSettings(): List + + @Query("SELECT * FROM browser_host_settings WHERE hostUrl = :host") + abstract suspend fun getBrowserHostSettings(host: String): BrowserHostSettingsLocal? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertBrowserHostSettings(settings: BrowserHostSettingsLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertBrowserHostSettings(settings: List) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/BrowserTabsDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/BrowserTabsDao.kt new file mode 100644 index 0000000..42b1c07 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/BrowserTabsDao.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.novafoundation.nova.core_db.model.BrowserTabLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class BrowserTabsDao { + + @Query("SELECT id FROM browser_tabs WHERE metaId = :metaId") + abstract fun getTabIdsFor(metaId: Long): List + + @Query("SELECT * FROM browser_tabs WHERE metaId = :metaId ORDER BY creationTime DESC") + abstract fun observeTabsByMetaId(metaId: Long): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertTab(tab: BrowserTabLocal) + + @Transaction + open suspend fun removeTabsByMetaId(metaId: Long): List { + val tabIds = getTabIdsFor(metaId) + removeTabsByIds(tabIds) + return tabIds + } + + @Query("DELETE FROM browser_tabs WHERE id = :tabId") + abstract suspend fun removeTab(tabId: String) + + @Query("DELETE FROM browser_tabs WHERE id IN (:tabIds)") + abstract suspend fun removeTabsByIds(tabIds: List) + + @Query("UPDATE browser_tabs SET pageName = :pageName, pageIconPath = :pageIconPath, pagePicturePath = :pagePicturePath WHERE id = :tabId") + abstract suspend fun updatePageSnapshot(tabId: String, pageName: String?, pageIconPath: String?, pagePicturePath: String?) + + @Query("UPDATE browser_tabs SET currentUrl = :url WHERE id = :tabId") + abstract fun updateCurrentUrl(tabId: String, url: String) + + @Query("UPDATE browser_tabs SET dappMetadata_iconLink = :dappIconUrl WHERE id = :tabId") + abstract fun updateKnownDAppMetadata(tabId: String, dappIconUrl: String?) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainAssetDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainAssetDao.kt new file mode 100644 index 0000000..8d26f1a --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainAssetDao.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal + +typealias FullAssetIdLocal = Pair + +@Dao +abstract class ChainAssetDao { + + @Transaction + open suspend fun updateAssets(diff: CollectionDiffer.Diff) { + insertAssets(diff.newOrUpdated) + deleteChainAssets(diff.removed) + } + + @Query("SELECT * FROM chain_assets WHERE id = :id AND chainId = :chainId") + abstract suspend fun getAsset(id: Int, chainId: String): ChainAssetLocal? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertAsset(asset: ChainAssetLocal) + + @Query("SELECT * FROM chain_assets WHERE source = :source") + abstract suspend fun getAssetsBySource(source: AssetSourceLocal): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertAssets(assets: List) + + @Query("UPDATE chain_assets SET enabled = :enabled WHERE chainId = :chainId AND id = :assetId") + protected abstract suspend fun setAssetEnabled(enabled: Boolean, chainId: String, assetId: Int) + + @Query("SELECT * FROM chain_assets WHERE enabled=1") + abstract suspend fun getEnabledAssets(): List + + @Update(entity = ChainAssetLocal::class) + abstract suspend fun setAssetsEnabled(params: List) + + @Delete + protected abstract suspend fun deleteChainAssets(assets: List) +} + +class SetAssetEnabledParams(val enabled: Boolean, val chainId: String, val id: Int) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt new file mode 100644 index 0000000..e72205a --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt @@ -0,0 +1,245 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainExplorerLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.ChainRuntimeInfoLocal +import io.novafoundation.nova.core_db.model.chain.JoinedChainInfo +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class ChainDao { + + @Transaction + open suspend fun applyDiff( + chainDiff: CollectionDiffer.Diff, + assetsDiff: CollectionDiffer.Diff, + nodesDiff: CollectionDiffer.Diff, + explorersDiff: CollectionDiffer.Diff, + externalApisDiff: CollectionDiffer.Diff, + nodeSelectionPreferencesDiff: CollectionDiffer.Diff + ) { + deleteChains(chainDiff.removed) + deleteChainAssets(assetsDiff.removed) + deleteChainNodes(nodesDiff.removed) + deleteChainExplorers(explorersDiff.removed) + deleteExternalApis(externalApisDiff.removed) + deleteNodePreferences(nodeSelectionPreferencesDiff.removed) + + addChains(chainDiff.added) + addChainAssets(assetsDiff.added) + addChainNodes(nodesDiff.added) + addChainExplorers(explorersDiff.added) + addExternalApis(externalApisDiff.added) + addNodePreferences(nodeSelectionPreferencesDiff.added) + + updateChains(chainDiff.updated) + updateChainAssets(assetsDiff.updated) + updateChainNodes(nodesDiff.updated) + updateChainExplorers(explorersDiff.updated) + updateExternalApis(externalApisDiff.updated) + updateNodePreferences(nodeSelectionPreferencesDiff.added) + } + + @Transaction + open suspend fun addChainOrUpdate( + chain: ChainLocal, + assets: List, + nodes: List, + explorers: List, + externalApis: List, + nodeSelectionPreferences: NodeSelectionPreferencesLocal + ) { + addChainOrUpdate(chain) + addChainAssetsOrUpdate(assets) + addChainNodesOrUpdate(nodes) + addChainExplorersOrUpdate(explorers) + addExternalApisOrUpdate(externalApis) + addNodePreferencesOrUpdate(nodeSelectionPreferences) + } + + @Transaction + open suspend fun editChain( + chainId: String, + assetId: Int, + chainName: String, + symbol: String, + explorer: ChainExplorerLocal?, + priceId: String? + ) { + updateChainName(chainId, chainName) + updateAssetToken(chainId, assetId, symbol, priceId) + addChainExplorersOrUpdate(listOfNotNull(explorer)) + } + + // ------ Delete -------- + @Delete + protected abstract suspend fun deleteChains(chains: List) + + @Delete + protected abstract suspend fun deleteChainNodes(nodes: List) + + @Delete + protected abstract suspend fun deleteChainAssets(assets: List) + + @Delete + protected abstract suspend fun deleteChainExplorers(explorers: List) + + @Delete + protected abstract suspend fun deleteExternalApis(apis: List) + + @Delete + protected abstract suspend fun deleteNodePreferences(apis: List) + + // ------ Add -------- + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract suspend fun addChains(chains: List) + + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract suspend fun addChainNodes(nodes: List) + + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract suspend fun addChainAssets(assets: List) + + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract suspend fun addChainExplorers(explorers: List) + + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract suspend fun addExternalApis(apis: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun addChainOrUpdate(node: ChainLocal) + + @Insert(onConflict = OnConflictStrategy.ABORT) + abstract suspend fun addChainNode(node: ChainNodeLocal) + + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract suspend fun addNodePreferences(model: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun addNodePreferencesOrUpdate(model: NodeSelectionPreferencesLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun addChainNodesOrUpdate(nodes: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun addChainAssetsOrUpdate(assets: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun addChainExplorersOrUpdate(explorers: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun addExternalApisOrUpdate(apis: List) + + // ------ Update ----- + + @Update + protected abstract suspend fun updateChains(chains: List) + + @Update + protected abstract suspend fun updateChainNodes(nodes: List) + + @Update + protected abstract suspend fun updateChainAssets(assets: List) + + @Update + protected abstract suspend fun updateChainExplorers(explorers: List) + + @Update + protected abstract suspend fun updateExternalApis(apis: List) + + @Update + protected abstract suspend fun updateNodePreferences(apis: List) + + // ------- Queries ------ + + @Query("SELECT * FROM chains") + @Transaction + abstract suspend fun getJoinChainInfo(): List + + @Query("SELECT id FROM chains") + @Transaction + abstract suspend fun getAllChainIds(): List + + @Query("SELECT * FROM chains") + @Transaction + abstract fun joinChainInfoFlow(): Flow> + + @Query("SELECT orderId FROM chain_nodes WHERE chainId = :chainId ORDER BY orderId DESC LIMIT 1") + abstract suspend fun getLastChainNodeOrderId(chainId: String): Int + + @Query("SELECT EXISTS(SELECT * FROM chains WHERE id = :chainId)") + abstract suspend fun chainExists(chainId: String): Boolean + + @Query("SELECT * FROM chain_runtimes WHERE chainId = :chainId") + abstract suspend fun runtimeInfo(chainId: String): ChainRuntimeInfoLocal? + + @Query("SELECT * FROM chain_runtimes") + abstract suspend fun allRuntimeInfos(): List + + @Query("UPDATE chain_runtimes SET syncedVersion = :syncedVersion, localMigratorVersion = :localMigratorVersion WHERE chainId = :chainId") + abstract suspend fun updateSyncedRuntimeVersion(chainId: String, syncedVersion: Int, localMigratorVersion: Int) + + @Query("UPDATE chains SET connectionState = :connectionState WHERE id = :chainId") + abstract suspend fun setConnectionState(chainId: String, connectionState: ChainLocal.ConnectionStateLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun setNodePreferences(model: NodeSelectionPreferencesLocal) + + @Transaction + open suspend fun updateRemoteRuntimeVersionIfChainExists( + chainId: String, + runtimeVersion: Int, + transactionVersion: Int, + ) { + if (!chainExists(chainId)) return + + if (isRuntimeInfoExists(chainId)) { + updateRemoteRuntimeVersionUnsafe(chainId, runtimeVersion, transactionVersion) + } else { + val runtimeInfoLocal = ChainRuntimeInfoLocal( + chainId, + syncedVersion = 0, + remoteVersion = runtimeVersion, + transactionVersion = transactionVersion, + localMigratorVersion = 1 + ) + insertRuntimeInfo(runtimeInfoLocal) + } + } + + @Query("UPDATE chain_nodes SET url = :newUrl, name = :name WHERE chainId = :chainId AND url = :oldUrl") + abstract suspend fun updateChainNode(chainId: String, oldUrl: String, newUrl: String, name: String) + + @Query("UPDATE chain_runtimes SET remoteVersion = :remoteVersion, transactionVersion = :transactionVersion WHERE chainId = :chainId") + protected abstract suspend fun updateRemoteRuntimeVersionUnsafe(chainId: String, remoteVersion: Int, transactionVersion: Int) + + @Query("SELECT EXISTS (SELECT * FROM chain_runtimes WHERE chainId = :chainId)") + protected abstract suspend fun isRuntimeInfoExists(chainId: String): Boolean + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertRuntimeInfo(runtimeInfoLocal: ChainRuntimeInfoLocal) + + @Query("UPDATE chains SET name = :name WHERE id = :chainId") + abstract suspend fun updateChainName(chainId: String, name: String) + + @Query("UPDATE chain_assets SET symbol = :symbol, priceId = :priceId WHERE chainId = :chainId and id == :assetId") + abstract suspend fun updateAssetToken(chainId: String, assetId: Int, symbol: String, priceId: String?) + + @Query("DELETE FROM chains WHERE id = :chainId") + abstract suspend fun deleteChain(chainId: String) + + @Query("DELETE FROM chain_nodes WHERE chainId = :chainId AND url = :url") + abstract suspend fun deleteNode(chainId: String, url: String) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/CoinPriceDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/CoinPriceDao.kt new file mode 100644 index 0000000..bd6c653 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/CoinPriceDao.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.novafoundation.nova.core_db.model.CoinPriceLocal + +@Dao +interface CoinPriceDao { + + @Query( + """ + SELECT * FROM coin_prices + WHERE priceId = :priceId AND currencyId = :currencyId + AND timestamp <= :timestamp + ORDER BY timestamp DESC LIMIT 1 + """ + ) + suspend fun getFloorCoinPriceAtTime(priceId: String, currencyId: String, timestamp: Long): CoinPriceLocal? + + @Query( + """ + SELECT EXISTS( + SELECT * FROM coin_prices + WHERE priceId = :priceId AND currencyId = :currencyId + AND timestamp >= :timestamp + ORDER BY timestamp ASC LIMIT 1 + ) + """ + ) + suspend fun hasCeilingCoinPriceAtTime(priceId: String, currencyId: String, timestamp: Long): Boolean + + @Query( + """ + SELECT * FROM coin_prices + WHERE priceId = :priceId AND currencyId = :currencyId + AND timestamp BETWEEN :fromTimestamp AND :toTimestamp + ORDER BY timestamp ASC + """ + ) + suspend fun getCoinPriceRange(priceId: String, currencyId: String, fromTimestamp: Long, toTimestamp: Long): List + + @Transaction + suspend fun updateCoinPrices(priceId: String, currencyId: String, coinRates: List) { + deleteCoinPrices(priceId, currencyId) + setCoinPrices(coinRates) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setCoinPrices(coinPrices: List) + + @Query( + """ + DELETE FROM coin_prices + WHERE priceId = :priceId AND currencyId = :currencyId + """ + ) + fun deleteCoinPrices(priceId: String, currencyId: String) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ContributionDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ContributionDao.kt new file mode 100644 index 0000000..920fe65 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ContributionDao.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.model.ContributionLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class ContributionDao { + + @Transaction + open suspend fun updateContributions(contributions: CollectionDiffer.Diff) { + insertContributions(contributions.added) + updateContributions(contributions.updated) + deleteContributions(contributions.removed) + } + + @Query("SELECT * FROM contributions WHERE metaId = :metaId AND chainId = :chainId AND assetId = :assetId") + abstract fun observeContributions(metaId: Long, chainId: String, assetId: Int): Flow> + + @Query("SELECT * FROM contributions WHERE metaId = :metaId") + abstract fun observeContributions(metaId: Long): Flow> + + @Query("SELECT * FROM contributions WHERE metaId = :metaId AND chainId = :chainId AND assetId = :assetId AND sourceId = :sourceId") + abstract suspend fun getContributions(metaId: Long, chainId: String, assetId: Int, sourceId: String): List + + @Query("DELETE FROM contributions WHERE chainId = :chainId AND assetId = :assetId") + abstract suspend fun deleteContributions(chainId: String, assetId: Int) + + @Delete + protected abstract suspend fun deleteContributions(contributions: List) + + @Update(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun updateContributions(contributions: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertContributions(contributions: List) + + @Delete(entity = ContributionLocal::class) + abstract suspend fun deleteAssetContributions(params: List) +} + +class DeleteAssetContributionsParams(val chainId: String, val assetId: Int) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/CurrencyDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/CurrencyDao.kt new file mode 100644 index 0000000..b7e7422 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/CurrencyDao.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.model.CurrencyLocal +import kotlinx.coroutines.flow.Flow + +private const val RETRIEVE_CURRENCIES = "SELECT * FROM currencies" + +private const val RETRIEVE_SELECTED_CURRENCY = "SELECT * FROM currencies WHERE selected = 1" + +@Dao +abstract class CurrencyDao { + + @Transaction + open suspend fun updateCurrencies(currencies: CollectionDiffer.Diff) { + deleteCurrencies(currencies.removed) + insertCurrencies(currencies.added) + updateCurrencies(currencies.updated) + + if (getSelectedCurrency() == null) { + selectCurrency(0) + } + } + + @Query("SELECT * FROM currencies WHERE id = 0") + abstract fun getFirst(): CurrencyLocal + + @Query(RETRIEVE_CURRENCIES) + abstract suspend fun getCurrencies(): List + + @Query(RETRIEVE_CURRENCIES) + abstract fun observeCurrencies(): Flow> + + @Query(RETRIEVE_SELECTED_CURRENCY) + abstract suspend fun getSelectedCurrency(): CurrencyLocal? + + @Query(RETRIEVE_SELECTED_CURRENCY) + abstract fun observeSelectCurrency(): Flow + + @Query("UPDATE currencies SET selected = (id = :currencyId)") + abstract fun selectCurrency(currencyId: Int) + + @Insert(onConflict = OnConflictStrategy.ABORT) + abstract suspend fun insert(currency: CurrencyLocal) + + @Delete + protected abstract suspend fun deleteCurrencies(currencies: List) + + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract suspend fun insertCurrencies(currencies: List) + + @Update + protected abstract suspend fun updateCurrencies(currencies: List) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/DappAuthorizationDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/DappAuthorizationDao.kt new file mode 100644 index 0000000..a54efd1 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/DappAuthorizationDao.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.DappAuthorizationLocal +import kotlinx.coroutines.flow.Flow + +@Dao +interface DappAuthorizationDao { + + @Query("SELECT * FROM dapp_authorizations WHERE baseUrl = :baseUrl AND metaId = :metaId") + suspend fun getAuthorization(baseUrl: String, metaId: Long): DappAuthorizationLocal? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun updateAuthorization(dappAuthorization: DappAuthorizationLocal) + + @Query("UPDATE dapp_authorizations SET authorized = 0 WHERE baseUrl = :baseUrl AND metaId = :metaId") + suspend fun removeAuthorization(baseUrl: String, metaId: Long) + + @Query("SELECT * FROM dapp_authorizations WHERE metaId = :metaId") + fun observeAuthorizations(metaId: Long): Flow> +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ExternalBalanceDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ExternalBalanceDao.kt new file mode 100644 index 0000000..f9f8119 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ExternalBalanceDao.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.AggregatedExternalBalanceLocal +import io.novafoundation.nova.core_db.model.ExternalBalanceLocal +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.flow.Flow + +@Dao +interface ExternalBalanceDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertExternalBalance(externalBalance: ExternalBalanceLocal) + + @Query("DELETE FROM externalBalances WHERE metaId = :metaId AND chainId = :chainId AND assetId = :assetId AND type = :type AND subtype = :subtype") + suspend fun removeExternalBalance( + metaId: Long, + chainId: String, + assetId: Int, + type: ExternalBalanceLocal.Type, + subtype: String?, + ) + + @Delete(entity = ExternalBalanceLocal::class) + suspend fun deleteAssetExternalBalances(params: List) + + @Query( + """ + SELECT chainId, assetId, type, SUM(amount) as aggregatedAmount + FROM externalBalances + WHERE metaId = :metaId + GROUP BY chainId, assetId, type + """ + ) + fun observeAggregatedExternalBalances(metaId: Long): Flow> + + @Query( + """ + SELECT chainId, assetId, type, SUM(amount) as aggregatedAmount + FROM externalBalances + WHERE metaId = :metaId AND chainId = :chainId AND assetId = :assetId + GROUP BY type + """ + ) + fun observeChainAggregatedExternalBalances(metaId: Long, chainId: String, assetId: Int): Flow> +} + +suspend fun ExternalBalanceDao.updateExternalBalance(externalBalance: ExternalBalanceLocal) { + if (externalBalance.amount.isPositive()) { + insertExternalBalance(externalBalance) + } else { + removeExternalBalance( + metaId = externalBalance.metaId, + chainId = externalBalance.chainId, + assetId = externalBalance.assetId, + type = externalBalance.type, + subtype = externalBalance.subtype + ) + } +} + +class ExternalBalanceAssetDeleteParams(val chainId: String, val assetId: Int) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/FavouriteDAppsDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/FavouriteDAppsDao.kt new file mode 100644 index 0000000..e72c02b --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/FavouriteDAppsDao.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import io.novafoundation.nova.core_db.model.FavouriteDAppLocal +import kotlinx.coroutines.flow.Flow + +@Dao +interface FavouriteDAppsDao { + + @Query("SELECT * FROM favourite_dapps") + fun observeFavouriteDApps(): Flow> + + @Query("SELECT * FROM favourite_dapps") + suspend fun getFavouriteDApps(): List + + @Query("SELECT EXISTS(SELECT * FROM favourite_dapps WHERE url = :dAppUrl)") + fun observeIsFavourite(dAppUrl: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertFavouriteDApp(dApp: FavouriteDAppLocal) + + @Query("DELETE FROM favourite_dapps WHERE url = :dAppUrl") + suspend fun deleteFavouriteDApp(dAppUrl: String) + + @Update(onConflict = OnConflictStrategy.REPLACE) + suspend fun updateFavourites(dapps: List) + + @Query("SELECT MAX(orderingIndex) FROM favourite_dapps") + suspend fun getMaxOrderingIndex(): Int +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/GiftsDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/GiftsDao.kt new file mode 100644 index 0000000..20bf4bb --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/GiftsDao.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import io.novafoundation.nova.core_db.model.GiftLocal +import kotlinx.coroutines.flow.Flow + +@Dao +interface GiftsDao { + + @Query("SELECT * from gifts WHERE id = :id") + suspend fun getGiftById(id: Long): GiftLocal + + @Query("SELECT * from gifts") + suspend fun getAllGifts(): List + + @Query("SELECT * from gifts WHERE id = :id") + fun observeGiftById(id: Long): Flow + + @Query("SELECT * from gifts") + fun observeAllGifts(): Flow> + + @Query("SELECT * from gifts WHERE chainId = :chainId AND assetId = :assetId") + fun observeGiftsByAsset(chainId: String, assetId: Int): Flow> + + @Insert + suspend fun createNewGift(giftLocal: GiftLocal): Long + + @Query("UPDATE gifts SET status = :status WHERE id = :id") + suspend fun setGiftState(id: Long, status: GiftLocal.Status) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/GovernanceDAppsDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/GovernanceDAppsDao.kt new file mode 100644 index 0000000..040ce64 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/GovernanceDAppsDao.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.model.GovernanceDAppLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class GovernanceDAppsDao { + + @Transaction + open suspend fun update(newDapps: List) { + val oldDapps = getAll() + val dappDiffs = CollectionDiffer.findDiff(newDapps, oldDapps, false) + + deleteDapps(dappDiffs.removed) + updateDapps(dappDiffs.updated) + insertDapps(dappDiffs.added) + } + + @Query("SELECT * FROM governance_dapps") + abstract fun getAll(): List + + @Query("SELECT * FROM governance_dapps WHERE chainId = :chainId") + abstract fun observeChainDapps(chainId: String): Flow> + + @Delete + abstract suspend fun deleteDapps(dapps: List) + + @Insert(onConflict = OnConflictStrategy.ABORT) + abstract suspend fun insertDapps(dapps: List) + + @Update + abstract suspend fun updateDapps(dapps: List) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/HoldsDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/HoldsDao.kt new file mode 100644 index 0000000..d85f4f7 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/HoldsDao.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.novafoundation.nova.core_db.model.BalanceHoldLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class HoldsDao { + + @Transaction + open suspend fun updateHolds( + holds: List, + metaId: Long, + chainId: String, + chainAssetId: Int + ) { + deleteHolds(metaId, chainId, chainAssetId) + + insert(holds) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract fun insert(holds: List) + + @Query("DELETE FROM holds WHERE metaId = :metaId AND chainId = :chainId AND assetId = :chainAssetId") + protected abstract fun deleteHolds(metaId: Long, chainId: String, chainAssetId: Int) + + @Query("SELECT * FROM holds WHERE metaId = :metaId") + abstract fun observeHoldsForMetaAccount(metaId: Long): Flow> + + @Query("SELECT * FROM holds WHERE metaId = :metaId AND chainId = :chainId AND assetId = :chainAssetId") + abstract fun observeBalanceHolds(metaId: Long, chainId: String, chainAssetId: Int): Flow> +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/LockDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/LockDao.kt new file mode 100644 index 0000000..3bb4634 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/LockDao.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.novafoundation.nova.core_db.model.BalanceLockLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class LockDao { + + @Transaction + open suspend fun updateLocks( + locks: List, + metaId: Long, + chainId: String, + chainAssetId: Int + ) { + deleteLocks(metaId, chainId, chainAssetId) + + insert(locks) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insert(locks: List) + + @Query("DELETE FROM locks WHERE metaId = :metaId AND chainId = :chainId AND assetId = :chainAssetId") + abstract fun deleteLocks(metaId: Long, chainId: String, chainAssetId: Int) + + @Query("SELECT * FROM locks WHERE metaId = :metaId") + abstract fun observeLocksForMetaAccount(metaId: Long): Flow> + + @Query("SELECT * FROM locks WHERE metaId = :metaId AND chainId = :chainId AND assetId = :chainAssetId") + abstract fun observeBalanceLocks(metaId: Long, chainId: String, chainAssetId: Int): Flow> + + @Query("SELECT * FROM locks WHERE metaId = :metaId AND chainId = :chainId AND assetId = :chainAssetId") + abstract suspend fun getBalanceLocks(metaId: Long, chainId: String, chainAssetId: Int): List + + @Query( + """ + SELECT * FROM locks + WHERE metaId = :metaId AND chainId = :chainId AND assetId = :chainAssetId + ORDER BY amount DESC + LIMIT 1 + """ + ) + abstract suspend fun getBiggestBalanceLock(metaId: Long, chainId: String, chainAssetId: Int): BalanceLockLocal? + + @Query("SELECT * FROM locks WHERE metaId = :metaId AND chainId = :chainId AND assetId = :chainAssetId AND type = :lockId") + abstract fun observeBalanceLock(metaId: Long, chainId: String, chainAssetId: Int, lockId: String): Flow +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/MetaAccountDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/MetaAccountDao.kt new file mode 100644 index 0000000..f51167e --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/MetaAccountDao.kt @@ -0,0 +1,309 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountIdWithType +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountPositionUpdate +import io.novafoundation.nova.core_db.model.chain.account.ProxyAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.RelationJoinedMetaAccountInfo +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import org.intellij.lang.annotations.Language +import java.math.BigDecimal +import java.math.BigInteger +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * Fetch meta account where either + * 1. chain account for specified chain is present and its accountId matches + * 2. chain account for specified is missing but one of base accountIds matches + * + * Note that if both chain account and base accounts are present than we should filter out entries where chain account matches but base accounts does not + */ +@Language("RoomSql") +private const val FIND_BY_ADDRESS_WHERE_CLAUSE = """ + LEFT JOIN chain_accounts as c ON m.id = c.metaId AND c.chainId = :chainId + WHERE + (c.accountId IS NOT NULL AND c.accountId = :accountId) + OR (c.accountId IS NULL AND (substrateAccountId = :accountId OR ethereumAddress = :accountId)) + ORDER BY (CASE WHEN isSelected THEN 0 ELSE 1 END) + """ + +@Language("RoomSql") +private const val FIND_ACCOUNT_BY_ADDRESS_QUERY = """ + SELECT * FROM meta_accounts as m + $FIND_BY_ADDRESS_WHERE_CLAUSE +""" + +@Language("RoomSql") +private const val FIND_NAME_BY_ADDRESS_QUERY = """ + SELECT name FROM meta_accounts as m + $FIND_BY_ADDRESS_WHERE_CLAUSE +""" + +@Language("RoomSql") +private const val META_ACCOUNTS_WITH_BALANCE_PART = """ + SELECT + m.id, + a.freeInPlanks, + a.reservedInPlanks, + (SELECT SUM(amountInPlanks) FROM contributions WHERE chainId = a.chainId AND assetId = a.assetId AND metaId = m.id) offChainBalance, + ca.precision, + t.rate + FROM meta_accounts as m + INNER JOIN assets as a ON a.metaId = m.id + INNER JOIN chain_assets AS ca ON a.assetId = ca.id AND a.chainId = ca.chainId + INNER JOIN currencies as currency ON currency.selected = 1 + INNER JOIN tokens as t ON t.tokenSymbol = ca.symbol AND t.currencyId = currency.id +""" + +@Language("RoomSql") +private const val META_ACCOUNTS_WITH_BALANCE_QUERY = """ + $META_ACCOUNTS_WITH_BALANCE_PART + ORDER BY m.position +""" + +@Language("RoomSql") +private const val META_ACCOUNT_WITH_BALANCE_QUERY = """ + $META_ACCOUNTS_WITH_BALANCE_PART + WHERE m.id == :metaId +""" + +@Dao +interface MetaAccountDao { + + @Transaction + suspend fun insertProxiedMetaAccount( + metaAccount: MetaAccountLocal, + chainAccount: (metaId: Long) -> ChainAccountLocal, + proxyAccount: (metaId: Long) -> ProxyAccountLocal + ): Long { + val metaId = insertMetaAccount(metaAccount) + insertChainAccount(chainAccount(metaId)) + insertProxy(proxyAccount(metaId)) + + return metaId + } + + @Transaction + suspend fun runInTransaction(action: suspend () -> Unit) { + action() + } + + @Insert + suspend fun insertMetaAccount(metaAccount: MetaAccountLocal): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun updateMetaAccount(metaAccount: MetaAccountLocal): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertChainAccount(chainAccount: ChainAccountLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertChainAccounts(chainAccounts: List) + + @Delete + suspend fun deleteChainAccounts(chainAccounts: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertProxy(proxyLocal: ProxyAccountLocal) + + @Query("SELECT * FROM meta_accounts") + suspend fun getMetaAccounts(): List + + @Query("SELECT * FROM meta_accounts WHERE id IN (:metaIds)") + suspend fun getMetaAccountsByIds(metaIds: List): List + + @Query("SELECT * FROM meta_accounts WHERE id = :id") + suspend fun getMetaAccount(id: Long): MetaAccountLocal? + + @Query("SELECT COUNT(*) FROM meta_accounts WHERE status = :status") + @Transaction + suspend fun getMetaAccountsQuantityByStatus(status: MetaAccountLocal.Status): Int + + @Query("SELECT * FROM meta_accounts WHERE status = :status") + @Transaction + suspend fun getMetaAccountsByStatus(status: MetaAccountLocal.Status): List + + @Query("SELECT * FROM meta_accounts") + @Transaction + suspend fun getFullMetaAccounts(): List + + @Query("SELECT * FROM meta_accounts") + fun getJoinedMetaAccountsInfoFlow(): Flow> + + @Query("SELECT * FROM meta_accounts WHERE status = :status") + fun getJoinedMetaAccountsInfoByStatusFlow(status: MetaAccountLocal.Status): Flow> + + @Query("SELECT id FROM meta_accounts WHERE status = :status") + fun getMetaAccountsIdsByStatus(status: MetaAccountLocal.Status): List + + @Query(META_ACCOUNTS_WITH_BALANCE_QUERY) + fun metaAccountsWithBalanceFlow(): Flow> + + @Query(META_ACCOUNT_WITH_BALANCE_QUERY) + fun metaAccountWithBalanceFlow(metaId: Long): Flow> + + @Query("UPDATE meta_accounts SET isSelected = (id = :metaId)") + suspend fun selectMetaAccount(metaId: Long) + + @Update(entity = MetaAccountLocal::class) + suspend fun updatePositions(updates: List) + + @Query("SELECT * FROM meta_accounts WHERE id = :metaId") + @Transaction + suspend fun getJoinedMetaAccountInfo(metaId: Long): RelationJoinedMetaAccountInfo + + @Query("SELECT type FROM meta_accounts WHERE id = :metaId") + suspend fun getMetaAccountType(metaId: Long): MetaAccountLocal.Type? + + @Query("SELECT * FROM meta_accounts WHERE isSelected = 1") + @Transaction + fun selectedMetaAccountInfoFlow(): Flow + + @Query("SELECT * FROM meta_accounts WHERE id = :metaId") + @Transaction + fun metaAccountInfoFlow(metaId: Long): Flow + + @Query("SELECT EXISTS ($FIND_ACCOUNT_BY_ADDRESS_QUERY)") + fun isMetaAccountExists(accountId: AccountId, chainId: String): Boolean + + @Query(FIND_ACCOUNT_BY_ADDRESS_QUERY) + @Transaction + fun getMetaAccountInfo(accountId: AccountId, chainId: String): RelationJoinedMetaAccountInfo? + + @Query(FIND_NAME_BY_ADDRESS_QUERY) + fun metaAccountNameFor(accountId: AccountId, chainId: String): String? + + @Query("UPDATE meta_accounts SET name = :newName WHERE id = :metaId") + suspend fun updateName(metaId: Long, newName: String) + + @Query( + """ + WITH RECURSIVE accounts_to_delete AS ( + SELECT id, parentMetaId, type FROM meta_accounts WHERE id IN (:metaIds) + UNION ALL + SELECT m.id, m.parentMetaId, m.type + FROM meta_accounts m + JOIN accounts_to_delete r ON m.parentMetaId = r.id + ) + SELECT id, type FROM accounts_to_delete + """ + ) + suspend fun findAffectedMetaIdsOnDelete(metaIds: List): List + + @Query("DELETE FROM meta_accounts WHERE id IN (:ids)") + suspend fun deleteByIds(ids: List) + + @Transaction + suspend fun delete(vararg metaId: Long): List { + val affectingMetaAccounts = findAffectedMetaIdsOnDelete(metaId.toList()) + if (affectingMetaAccounts.isNotEmpty()) { + val ids = affectingMetaAccounts.map { it.id } + deleteByIds(ids) + } + return affectingMetaAccounts + } + + @Transaction + suspend fun delete(metaIds: List): List { + return delete(*metaIds.toLongArray()) + } + + @Query("SELECT COALESCE(MAX(position), 0) + 1 FROM meta_accounts") + suspend fun nextAccountPosition(): Int + + @Query("SELECT * FROM meta_accounts WHERE isSelected = 1") + suspend fun selectedMetaAccount(): RelationJoinedMetaAccountInfo? + + @Query("SELECT EXISTS(SELECT id FROM meta_accounts WHERE type = :type)") + fun hasMetaAccountsCountOfTypeFlow(type: MetaAccountLocal.Type): Flow + + @Query("SELECT * FROM meta_accounts WHERE type = :type") + fun observeMetaAccountsByTypeFlow(type: MetaAccountLocal.Type): Flow> + + @Query( + """ + DELETE FROM meta_accounts + WHERE id IN ( + SELECT proxiedMetaId + FROM proxy_accounts + WHERE chainId = :chainId + ) + """ + ) + fun deleteProxiedMetaAccountsByChain(chainId: String) + + @Transaction + suspend fun insertMetaAndChainAccounts( + metaAccount: MetaAccountLocal, + createChainAccounts: suspend (metaId: Long) -> List + ): Long { + val metaId = insertMetaAccount(metaAccount) + + insertChainAccounts(createChainAccounts(metaId)) + + return metaId + } + + @Query("SELECT EXISTS(SELECT * FROM meta_accounts WHERE status = :status)") + suspend fun hasMetaAccountsByStatus(status: MetaAccountLocal.Status): Boolean + + @Query("SELECT EXISTS(SELECT * FROM meta_accounts WHERE type = :type)") + suspend fun hasMetaAccountsByType(type: MetaAccountLocal.Type): Boolean + + @Query("SELECT EXISTS(SELECT * FROM meta_accounts WHERE id IN (:metaIds) AND type = :type)") + suspend fun hasMetaAccountsByType(metaIds: Set, type: MetaAccountLocal.Type): Boolean + + @Query("UPDATE meta_accounts SET status = :status WHERE id IN (:metaIds)") + suspend fun changeAccountsStatus(metaIds: List, status: MetaAccountLocal.Status) + + @Query("DELETE FROM meta_accounts WHERE status = :status ") + fun removeMetaAccountsByStatus(status: MetaAccountLocal.Status) +} + +suspend inline fun MetaAccountDao.withTransaction(crossinline action: suspend () -> T): T { + var result: T? = null + + runInTransaction { + result = action() + } + + return result!! +} + +@OptIn(ExperimentalContracts::class) +suspend fun MetaAccountDao.updateMetaAccount(metaId: Long, updateClosure: (MetaAccountLocal) -> MetaAccountLocal) { + contract { + callsInPlace(updateClosure, InvocationKind.EXACTLY_ONCE) + } + + val metaAccount = requireNotNull(getMetaAccount(metaId)) { + "Meta account $metaId was not found" + } + + val updated = updateClosure(metaAccount) + require(updated.id == metaId) { + "Cannot modify metaId" + } + + updateMetaAccount(updated) +} + +class MetaAccountWithBalanceLocal( + val id: Long, + val freeInPlanks: BigInteger, + val reservedInPlanks: BigInteger, + val offChainBalance: BigInteger?, + val precision: Int, + val rate: BigDecimal? +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/MultisigOperationsDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/MultisigOperationsDao.kt new file mode 100644 index 0000000..fbdbf27 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/MultisigOperationsDao.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.MultisigOperationCallLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class MultisigOperationsDao { + + @Query("SELECT * FROM multisig_operation_call") + abstract fun observeOperations(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertOperation(operation: MultisigOperationCallLocal) + + @Query("DELETE FROM multisig_operation_call WHERE metaId = :metaId AND chainId = :chainId AND callHash NOT IN (:excludedCallHashes)") + abstract fun removeOperationsExclude(metaId: Long, chainId: String, excludedCallHashes: List) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/NftDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/NftDao.kt new file mode 100644 index 0000000..4434ec7 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/NftDao.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.model.NftLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class NftDao { + + @Query("SELECT * FROM nfts WHERE metaId = :metaId") + abstract fun nftsFlow(metaId: Long): Flow> + + @Query("SELECT * FROM nfts WHERE metaId = :metaId AND type = :type AND chainId = :chainId") + abstract suspend fun getNfts(chainId: String, metaId: Long, type: NftLocal.Type): List + + @Delete + protected abstract suspend fun deleteNfts(nfts: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertNfts(nfts: List) + + @Update + protected abstract suspend fun updateNft(nft: NftLocal) + + @Query("SELECT * FROM nfts WHERE identifier = :nftIdentifier") + abstract suspend fun getNft(nftIdentifier: String): NftLocal + + @Query("SELECT type FROM nfts WHERE identifier = :nftIdentifier") + abstract suspend fun getNftType(nftIdentifier: String): NftLocal.Type + + @Query("UPDATE nfts SET wholeDetailsLoaded = 1 WHERE identifier = :nftIdentifier") + abstract suspend fun markFullSynced(nftIdentifier: String) + + @Transaction + open suspend fun insertNftsDiff( + nftType: NftLocal.Type, + chainId: String, + metaId: Long, + newNfts: List, + forceOverwrite: Boolean + ) { + val oldNfts = getNfts(chainId, metaId, nftType) + + val diff = CollectionDiffer.findDiff(newNfts, oldNfts, forceUseNewItems = forceOverwrite) + + deleteNfts(diff.removed) + insertNfts(diff.newOrUpdated) + } + + @Transaction + open suspend fun updateNft(nftIdentifier: String, update: (NftLocal) -> NftLocal) { + val nft = getNft(nftIdentifier) + + val updated = update(nft) + + updateNft(updated) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/NodeDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/NodeDao.kt new file mode 100644 index 0000000..343ba12 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/NodeDao.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.novafoundation.nova.core_db.model.NodeLocal +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class NodeDao { + + @Query("select * from nodes") + abstract fun nodesFlow(): Flow> + + @Query("select * from nodes") + abstract suspend fun getNodes(): List + + @Query("select * from nodes where link = :link") + abstract suspend fun getNode(link: String): NodeLocal + + @Query("select * from nodes where id = :id") + abstract suspend fun getNodeById(id: Int): NodeLocal + + @Query("select count(*) from nodes where link = :nodeHost") + abstract suspend fun getNodesCountByHost(nodeHost: String): Int + + @Query("select exists (select * from nodes where link = :nodeHost)") + abstract suspend fun checkNodeExists(nodeHost: String): Boolean + + @Query("DELETE FROM nodes where link = :link") + abstract suspend fun remove(link: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(nodes: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(nodes: NodeLocal): Long + + @Query("update nodes set name = :newName, link = :newHost, networkType = :networkType where id = :id") + abstract suspend fun updateNode(id: Int, newName: String, newHost: String, networkType: Int) + + @Query("SELECT * from nodes where isDefault = 1 AND networkType = :networkType") + abstract suspend fun getDefaultNodeFor(networkType: Int): NodeLocal + + @Query("select * from nodes limit 1") + abstract suspend fun getFirstNode(): NodeLocal + + @Query("delete from nodes where id = :nodeId") + abstract suspend fun deleteNode(nodeId: Int) + + @Query("UPDATE nodes SET isActive = 1 WHERE id = :newActiveNodeId") + protected abstract suspend fun makeActive(newActiveNodeId: Int) + + @Query("UPDATE nodes SET isActive = 0 WHERE isActive = 1") + protected abstract suspend fun inactiveCurrentNode() + + @Query("SELECT * FROM nodes WHERE isActive = 1") + abstract fun activeNodeFlow(): Flow + + @Transaction + open suspend fun switchActiveNode(newNodeId: Int) { + inactiveCurrentNode() + + makeActive(newNodeId) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/OperationDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/OperationDao.kt new file mode 100644 index 0000000..34a7413 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/OperationDao.kt @@ -0,0 +1,188 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.novafoundation.nova.common.utils.mapNotNullToSet +import io.novafoundation.nova.core_db.model.operation.DirectRewardTypeLocal +import io.novafoundation.nova.core_db.model.operation.ExtrinsicTypeLocal +import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal +import io.novafoundation.nova.core_db.model.operation.OperationJoin +import io.novafoundation.nova.core_db.model.operation.OperationLocal +import io.novafoundation.nova.core_db.model.operation.OperationTypeLocal +import io.novafoundation.nova.core_db.model.operation.PoolRewardTypeLocal +import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal +import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal +import kotlinx.coroutines.flow.Flow + +private const val ID_FILTER = "address = :address AND chainId = :chainId AND assetId = :chainAssetId" + +@Dao +abstract class OperationDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertOperationBase(operation: OperationBaseLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertTransferType(type: TransferTypeLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertDirectRewardType(type: DirectRewardTypeLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertPoolRewardType(type: PoolRewardTypeLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertExtrinsicType(type: ExtrinsicTypeLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertSwapType(type: SwapTypeLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertOperationsBase(operations: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertTransferTypes(types: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertDirectRewardTypes(types: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertPoolRewardTypes(types: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertExtrinsicTypes(types: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertSwapTypes(types: List) + + @Transaction + open suspend fun insert(operation: OperationLocal) { + insertOperationBase(operation.base) + insertOperationType(operation.type) + } + + @Transaction + open suspend fun insertAll(operations: List) { + insertAllInternal(operations) + } + + @Query( + """ + SELECT + o.assetId as o_assetId, o.chainId o_chainId, o.id as o_id, o.address as o_address, o.time o_time, o.status as o_status, o.source o_source, o.hash as o_hash, + t.amount as t_amount, t.fee as t_fee, t.sender as t_sender, t.receiver as t_receiver, + e.contentType as e_contentType, e.module as e_module, e.call as e_call, e.fee as e_fee, + rd.isReward as rd_isReward, rd.amount as rd_amount, rd.validator as rd_validator, rd.era as rd_era, rd.eventId as rd_eventId, + rp.isReward as rp_isReward, rp.amount as rp_amount, rp.poolId as rp_poolId, rp.eventId as rp_eventId, + s.fee_chainId as s_fee_chainId, s.fee_assetId as s_fee_assetId, s.fee_amount as s_fee_amount, + s.assetIn_chainId as s_assetIn_chainId, s.assetIn_assetId as s_assetIn_assetId, s.assetIn_amount as s_assetIn_amount, + s.assetOut_chainId as s_assetOut_chainId, s.assetOut_assetId as s_assetOut_assetId, s.assetOut_amount as s_assetOut_amount + FROM operations as o + LEFT JOIN operation_transfers as t ON t.operationId = o.id AND t.assetId = o.assetId AND t.chainId = o.chainId AND t.address = o.address + LEFT JOIN operation_extrinsics as e ON e.operationId = o.id AND e.assetId = o.assetId AND e.chainId = o.chainId AND e.address = o.address + LEFT JOIN operation_rewards_direct as rd ON rd.operationId = o.id AND rd.assetId = o.assetId AND rd.chainId = o.chainId AND rd.address = o.address + LEFT JOIN operation_rewards_pool as rp ON rp.operationId = o.id AND rp.assetId = o.assetId AND rp.chainId = o.chainId AND rp.address = o.address + LEFT JOIN operation_swaps as s ON s.operationId = o.id AND s.assetId = o.assetId AND s.chainId = o.chainId AND s.address = o.address + WHERE o.address = :address AND o.chainId = :chainId AND o.assetId = :chainAssetId + ORDER BY (case when o.status = :statusUp then 0 else 1 end), o.time DESC + """ + ) + abstract fun observe( + address: String, + chainId: String, + chainAssetId: Int, + statusUp: OperationBaseLocal.Status = OperationBaseLocal.Status.PENDING + ): Flow> + + @Query( + """ + SELECT * FROM operation_transfers + WHERE address = :address AND chainId = :chainId AND assetId = :chainAssetId AND operationId = :operationId + """ + ) + abstract suspend fun getTransferType( + operationId: String, + address: String, + chainId: String, + chainAssetId: Int + ): TransferTypeLocal? + + @Transaction + open suspend fun insertFromRemote( + accountAddress: String, + chainId: String, + chainAssetId: Int, + operations: List + ) { + clearBySource(accountAddress, chainId, chainAssetId, OperationBaseLocal.Source.REMOTE) + + val operationsWithHashes = operations.mapNotNullToSet { it.base.hash } + if (operationsWithHashes.isNotEmpty()) { + clearByHashes(accountAddress, chainId, chainAssetId, operationsWithHashes) + } + + val oldestTime = operations.minOfOrNull { it.base.time } + oldestTime?.let { + clearOld(accountAddress, chainId, chainAssetId, oldestTime) + } + + insertAllInternal(operations) + } + + @Query("DELETE FROM operations WHERE $ID_FILTER AND source = :source") + protected abstract suspend fun clearBySource( + address: String, + chainId: String, + chainAssetId: Int, + source: OperationBaseLocal.Source + ): Int + + @Query("DELETE FROM operations WHERE time < :minTime AND $ID_FILTER") + protected abstract suspend fun clearOld( + address: String, + chainId: String, + chainAssetId: Int, + minTime: Long + ): Int + + @Query("DELETE FROM operations WHERE $ID_FILTER AND hash in (:hashes)") + protected abstract suspend fun clearByHashes( + address: String, + chainId: String, + chainAssetId: Int, + hashes: Set + ): Int + + private suspend fun insertOperationType(type: OperationTypeLocal) { + when (type) { + is ExtrinsicTypeLocal -> insertExtrinsicType(type) + is DirectRewardTypeLocal -> insertDirectRewardType(type) + is PoolRewardTypeLocal -> insertPoolRewardType(type) + is SwapTypeLocal -> insertSwapType(type) + is TransferTypeLocal -> insertTransferType(type) + else -> {} + } + } + + private suspend fun insertAllInternal(operations: List) { + insertOperationsBase(operations.map { it.base }) + insertOperationTypes(operations.map { it.type }) + } + + private suspend fun insertOperationTypes(types: List) { + val transfers = types.filterIsInstance() + val extrinsics = types.filterIsInstance() + val directRewards = types.filterIsInstance() + val poolRewards = types.filterIsInstance() + val swaps = types.filterIsInstance() + + insertTransferTypes(transfers) + insertExtrinsicTypes(extrinsics) + insertDirectRewardTypes(directRewards) + insertPoolRewardTypes(poolRewards) + insertSwapTypes(swaps) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/PhishingAddressDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/PhishingAddressDao.kt new file mode 100644 index 0000000..0ca699d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/PhishingAddressDao.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.PhishingAddressLocal + +@Dao +interface PhishingAddressDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(addresses: List) + + @Query("delete from phishing_addresses") + suspend fun clearTable() + + @Query("select publicKey from phishing_addresses") + suspend fun getAllAddresses(): List +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/PhishingSitesDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/PhishingSitesDao.kt new file mode 100644 index 0000000..cd9722e --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/PhishingSitesDao.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.novafoundation.nova.core_db.model.PhishingSiteLocal + +@Dao +abstract class PhishingSitesDao { + + @Query("SELECT EXISTS (SELECT * FROM phishing_sites WHERE host in (:hostSuffixes))") + abstract suspend fun isPhishing(hostSuffixes: List): Boolean + + @Transaction + open suspend fun updatePhishingSites(newSites: List) { + clearPhishingSites() + + insertPhishingSites(newSites) + } + + @Query("DELETE FROM phishing_sites") + protected abstract suspend fun clearPhishingSites() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertPhishingSites(sites: List) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StakingDashboardDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StakingDashboardDao.kt new file mode 100644 index 0000000..cd75e99 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StakingDashboardDao.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.StakingDashboardAccountsView +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal +import kotlinx.coroutines.flow.Flow + +@Dao +interface StakingDashboardDao { + + @Query( + """ + SELECT * FROM staking_dashboard_items WHERE + metaId = :metaId + AND chainId = :chainId + AND chainAssetId = :chainAssetId + AND stakingType = :stakingType + """ + ) + suspend fun getDashboardItem( + chainId: String, + chainAssetId: Int, + stakingType: String, + metaId: Long, + ): StakingDashboardItemLocal? + + @Query("SELECT * FROM staking_dashboard_items WHERE metaId = :metaId") + fun dashboardItemsFlow(metaId: Long): Flow> + + @Query( + """ + SELECT * FROM staking_dashboard_items + WHERE metaId = :metaId AND chainId = :chainId AND chainAssetId = :assetId AND stakingType IN (:assetTypes) + """ + ) + fun dashboardItemsFlow(metaId: Long, chainId: String, assetId: Int, assetTypes: List): Flow> + + @Query("SELECT chainId, chainAssetId, stakingType, stakeStatusAccount, rewardsAccount FROM staking_dashboard_items WHERE metaId = :metaId") + fun stakingAccountsViewFlow(metaId: Long): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItem(dashboardItemLocal: StakingDashboardItemLocal) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StakingRewardPeriodDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StakingRewardPeriodDao.kt new file mode 100644 index 0000000..f7956bb --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StakingRewardPeriodDao.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.StakingRewardPeriodLocal +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +@Dao +interface StakingRewardPeriodDao { + + @Query("SELECT * FROM staking_reward_period WHERE accountId = :accountId AND chainId = :chainId AND assetId = :assetId AND stakingType = :stakingType") + suspend fun getStakingRewardPeriod(accountId: AccountId, chainId: String, assetId: Int, stakingType: String): StakingRewardPeriodLocal? + + @Query("SELECT * FROM staking_reward_period WHERE accountId = :accountId AND chainId = :chainId AND assetId = :assetId AND stakingType = :stakingType") + fun observeStakingRewardPeriod(accountId: AccountId, chainId: String, assetId: Int, stakingType: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertStakingRewardPeriod(stakingRewardPeriodLocal: StakingRewardPeriodLocal) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StakingTotalRewardDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StakingTotalRewardDao.kt new file mode 100644 index 0000000..8df74b6 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StakingTotalRewardDao.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.TotalRewardLocal +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +@Dao +abstract class StakingTotalRewardDao { + + @Query( + """ + SELECT * FROM total_reward + WHERE accountId = :accountId AND chainId = :chainId AND chainAssetId = :chainAssetId and stakingType = :stakingType + """ + ) + abstract fun observeTotalRewards(accountId: AccountId, chainId: String, chainAssetId: Int, stakingType: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(totalRewardLocal: TotalRewardLocal) + + @Query("DELETE FROM total_reward") + abstract suspend fun deleteAll() +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StorageDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StorageDao.kt new file mode 100644 index 0000000..c431aa4 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/StorageDao.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.novafoundation.nova.core_db.model.StorageEntryLocal +import kotlinx.coroutines.flow.Flow + +private const val SELECT_FULL_KEY_QUERY = "SELECT * from storage WHERE chainId = :chainId AND storageKey = :fullKey" +private const val SELECT_PREFIX_KEY_QUERY = "SELECT * from storage WHERE chainId = :chainId AND storageKey LIKE :keyPrefix || '%'" + +@Dao +abstract class StorageDao { + + @Query("SELECT EXISTS($SELECT_PREFIX_KEY_QUERY)") + abstract suspend fun isPrefixInCache(chainId: String, keyPrefix: String): Boolean + + @Query("SELECT EXISTS($SELECT_FULL_KEY_QUERY)") + abstract suspend fun isFullKeyInCache(chainId: String, fullKey: String): Boolean + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(entry: StorageEntryLocal) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(entries: List) + + @Query("DELETE FROM storage WHERE chainId = :chainId AND storageKey LIKE :prefix || '%'") + abstract suspend fun removeByPrefix(prefix: String, chainId: String) + + @Query( + """ + DELETE FROM storage WHERE chainId = :chainId + AND storageKey LIKE :prefix || '%' + AND storageKey NOT IN (:exceptionFullKeys) + """ + ) + abstract suspend fun removeByPrefixExcept(prefix: String, exceptionFullKeys: List, chainId: String) + + @Query(SELECT_FULL_KEY_QUERY) + abstract fun observeEntry(chainId: String, fullKey: String): Flow + + @Query(SELECT_PREFIX_KEY_QUERY) + abstract fun observeEntries(chainId: String, keyPrefix: String): Flow> + + @Query("SELECT storageKey from storage WHERE chainId = :chainId AND storageKey LIKE :keyPrefix || '%'") + abstract suspend fun getKeys(chainId: String, keyPrefix: String): List + + @Query("SELECT * from storage WHERE chainId = :chainId AND storageKey in (:fullKeys)") + abstract fun observeEntries(chainId: String, fullKeys: List): Flow> + + @Query("SELECT storageKey from storage WHERE chainId = :chainId AND storageKey in (:keys)") + abstract suspend fun filterKeysInCache(chainId: String, keys: List): List + + @Transaction + open suspend fun insertPrefixedEntries(entries: List, prefix: String, chainId: String) { + removeByPrefix(prefix, chainId) + + insert(entries) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/TinderGovDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/TinderGovDao.kt new file mode 100644 index 0000000..0144e73 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/TinderGovDao.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.TinderGovBasketItemLocal +import io.novafoundation.nova.core_db.model.TinderGovVotingPowerLocal +import kotlinx.coroutines.flow.Flow + +@Dao +interface TinderGovDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun setVotingPower(item: TinderGovVotingPowerLocal) + + @Query("SELECT * FROM tinder_gov_voting_power WHERE metaId = :metaId AND chainId = :chainId") + suspend fun getVotingPower(metaId: Long, chainId: String): TinderGovVotingPowerLocal? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addToBasket(item: TinderGovBasketItemLocal) + + @Delete + suspend fun removeFromBasket(item: TinderGovBasketItemLocal) + + @Delete + suspend fun removeFromBasket(items: List) + + @Query("SELECT * FROM tinder_gov_basket WHERE metaId = :metaId AND chainId == :chainId") + suspend fun getBasket(metaId: Long, chainId: String): List + + @Query("SELECT * FROM tinder_gov_basket WHERE metaId = :metaId AND chainId == :chainId") + fun observeBasket(metaId: Long, chainId: String): Flow> + + @Query("SELECT COUNT(*) FROM tinder_gov_basket WHERE metaId = :metaId AND chainId == :chainId") + fun basketSize(metaId: Long, chainId: String): Int + + @Query("DELETE FROM tinder_gov_basket WHERE metaId = :metaId AND chainId == :chainId") + fun clearBasket(metaId: Long, chainId: String) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/TokenDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/TokenDao.kt new file mode 100644 index 0000000..65baa6b --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/TokenDao.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.model.TokenLocal +import io.novafoundation.nova.core_db.model.TokenWithCurrency +import kotlinx.coroutines.flow.Flow + +private const val RETRIEVE_TOKEN_WITH_CURRENCY = """ + SELECT * FROM currencies AS currency + LEFT OUTER JOIN tokens AS token ON token.currencyId = currency.id AND token.tokenSymbol = :symbol + WHERE currency.selected = 1 +""" + +private const val RETRIEVE_TOKENS_WITH_CURRENCY = """ + SELECT * FROM currencies AS currency + LEFT OUTER JOIN tokens AS token ON token.currencyId = currency.id AND token.tokenSymbol in (:symbols) + WHERE currency.selected = 1 +""" + +private const val INSERT_TOKEN_WITH_SELECTED_CURRENCY = """ + INSERT OR IGNORE INTO tokens (tokenSymbol, rate, currencyId, recentRateChange) + VALUES(:symbol, NULL, (SELECT id FROM currencies WHERE selected = 1), NULL) +""" + +@Dao +abstract class TokenDao { + + @Transaction + open suspend fun applyDiff(diff: CollectionDiffer.Diff) { + deleteTokens(diff.removed) + insertTokens(diff.added) + updateTokens(diff.updated) + } + + @Query(RETRIEVE_TOKEN_WITH_CURRENCY) + abstract suspend fun getTokenWithCurrency(symbol: String): TokenWithCurrency? + + @Query(RETRIEVE_TOKENS_WITH_CURRENCY) + abstract fun observeTokensWithCurrency(symbols: List): Flow> + + @Query(RETRIEVE_TOKENS_WITH_CURRENCY) + abstract fun getTokensWithCurrency(symbols: List): List + + @Query(RETRIEVE_TOKEN_WITH_CURRENCY) + abstract fun observeTokenWithCurrency(symbol: String): Flow + + @Query("SELECT * FROM tokens") + abstract suspend fun getTokens(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertTokens(tokens: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertToken(token: TokenLocal) + + @Query(INSERT_TOKEN_WITH_SELECTED_CURRENCY) + abstract suspend fun insertTokenWithSelectedCurrency(symbol: String) + + @Update + abstract suspend fun updateTokens(chains: List) + + @Delete + abstract suspend fun deleteTokens(tokens: List) + + @Query("DELETE FROM tokens") + abstract suspend fun deleteAll() +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/WalletConnectSessionsDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/WalletConnectSessionsDao.kt new file mode 100644 index 0000000..4033466 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/WalletConnectSessionsDao.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.core_db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.novafoundation.nova.core_db.model.WalletConnectPairingLocal +import kotlinx.coroutines.flow.Flow + +@Dao +interface WalletConnectSessionsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPairing(pairing: WalletConnectPairingLocal) + + @Query("DELETE FROM wallet_connect_pairings WHERE pairingTopic = :pairingTopic") + suspend fun deletePairing(pairingTopic: String) + + @Query("SELECT * FROM wallet_connect_pairings WHERE pairingTopic = :pairingTopic") + suspend fun getPairing(pairingTopic: String): WalletConnectPairingLocal? + + @Query("SELECT * FROM wallet_connect_pairings WHERE pairingTopic = :pairingTopic") + fun pairingFlow(pairingTopic: String): Flow + + @Query("DELETE FROM wallet_connect_pairings WHERE pairingTopic NOT IN (:pairingTopics)") + suspend fun removeAllPairingsOtherThan(pairingTopics: List) + + @Query("SELECT * FROM wallet_connect_pairings") + fun allPairingsFlow(): Flow> + + @Query("SELECT * FROM wallet_connect_pairings WHERE metaId = :metaId") + fun pairingsByMetaIdFlow(metaId: Long): Flow> +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbApi.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbApi.kt new file mode 100644 index 0000000..f2b28d5 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbApi.kt @@ -0,0 +1,101 @@ +package io.novafoundation.nova.core_db.di + +import io.novafoundation.nova.core_db.AppDatabase +import io.novafoundation.nova.core_db.dao.AccountDao +import io.novafoundation.nova.core_db.dao.AccountStakingDao +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao +import io.novafoundation.nova.core_db.dao.BrowserTabsDao +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.dao.CoinPriceDao +import io.novafoundation.nova.core_db.dao.ContributionDao +import io.novafoundation.nova.core_db.dao.CurrencyDao +import io.novafoundation.nova.core_db.dao.DappAuthorizationDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.dao.FavouriteDAppsDao +import io.novafoundation.nova.core_db.dao.GiftsDao +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.core_db.dao.HoldsDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.MultisigOperationsDao +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.core_db.dao.NodeDao +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.core_db.dao.PhishingAddressDao +import io.novafoundation.nova.core_db.dao.PhishingSitesDao +import io.novafoundation.nova.core_db.dao.StakingDashboardDao +import io.novafoundation.nova.core_db.dao.StakingRewardPeriodDao +import io.novafoundation.nova.core_db.dao.StakingTotalRewardDao +import io.novafoundation.nova.core_db.dao.StorageDao +import io.novafoundation.nova.core_db.dao.TinderGovDao +import io.novafoundation.nova.core_db.dao.TokenDao +import io.novafoundation.nova.core_db.dao.WalletConnectSessionsDao + +interface DbApi { + + val phishingSitesDao: PhishingSitesDao + + val favouritesDAppsDao: FavouriteDAppsDao + + val currencyDao: CurrencyDao + + val walletConnectSessionsDao: WalletConnectSessionsDao + + val stakingDashboardDao: StakingDashboardDao + + val externalBalanceDao: ExternalBalanceDao + + val holdsDao: HoldsDao + + fun provideDatabase(): AppDatabase + + fun provideLockDao(): LockDao + + fun provideAccountDao(): AccountDao + + fun contributionDao(): ContributionDao + + fun provideNodeDao(): NodeDao + + fun provideAssetDao(): AssetDao + + fun provideOperationDao(): OperationDao + + fun providePhishingAddressDao(): PhishingAddressDao + + fun storageDao(): StorageDao + + fun tokenDao(): TokenDao + + fun accountStakingDao(): AccountStakingDao + + fun stakingTotalRewardDao(): StakingTotalRewardDao + + fun chainDao(): ChainDao + + fun chainAssetDao(): ChainAssetDao + + fun metaAccountDao(): MetaAccountDao + + fun dappAuthorizationDao(): DappAuthorizationDao + + fun nftDao(): NftDao + + fun governanceDAppsDao(): GovernanceDAppsDao + + fun browserHostSettingsDao(): BrowserHostSettingsDao + + fun coinPriceDao(): CoinPriceDao + + fun stakingRewardPeriodDao(): StakingRewardPeriodDao + + fun tinderGovDao(): TinderGovDao + + fun browserTabsDao(): BrowserTabsDao + + fun multisigOperationsDao(): MultisigOperationsDao + + fun giftsDao(): GiftsDao +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbComponent.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbComponent.kt new file mode 100644 index 0000000..e2b98ea --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.core_db.di + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.ApplicationScope + +@Component( + modules = [ + DbModule::class + ], + dependencies = [ + DbDependencies::class + ] +) +@ApplicationScope +abstract class DbComponent : DbApi { + + @Component( + dependencies = [ + CommonApi::class + ] + ) + interface DbDependenciesComponent : DbDependencies +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbDependencies.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbDependencies.kt new file mode 100644 index 0000000..e1441bb --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbDependencies.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.core_db.di + +import android.content.Context +import com.google.gson.Gson +import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1 +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences + +interface DbDependencies { + + fun gson(): Gson + + fun preferences(): Preferences + + fun context(): Context + + fun secretStoreV1(): SecretStoreV1 + + fun secretStoreV2(): SecretStoreV2 +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbHolder.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbHolder.kt new file mode 100644 index 0000000..795df6c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbHolder.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.core_db.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import javax.inject.Inject + +@ApplicationScope +class DbHolder @Inject constructor( + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dbDependencies = DaggerDbComponent_DbDependenciesComponent.builder() + .commonApi(commonApi()) + .build() + return DaggerDbComponent.builder() + .dbDependencies(dbDependencies) + .build() + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbModule.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbModule.kt new file mode 100644 index 0000000..b9c44ff --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/di/DbModule.kt @@ -0,0 +1,236 @@ +package io.novafoundation.nova.core_db.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.AppDatabase +import io.novafoundation.nova.core_db.dao.AccountDao +import io.novafoundation.nova.core_db.dao.AccountStakingDao +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao +import io.novafoundation.nova.core_db.dao.BrowserTabsDao +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.dao.CoinPriceDao +import io.novafoundation.nova.core_db.dao.ContributionDao +import io.novafoundation.nova.core_db.dao.CurrencyDao +import io.novafoundation.nova.core_db.dao.DappAuthorizationDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.dao.FavouriteDAppsDao +import io.novafoundation.nova.core_db.dao.GiftsDao +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.core_db.dao.HoldsDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.MultisigOperationsDao +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.core_db.dao.NodeDao +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.core_db.dao.PhishingAddressDao +import io.novafoundation.nova.core_db.dao.PhishingSitesDao +import io.novafoundation.nova.core_db.dao.StakingDashboardDao +import io.novafoundation.nova.core_db.dao.StakingRewardPeriodDao +import io.novafoundation.nova.core_db.dao.StakingTotalRewardDao +import io.novafoundation.nova.core_db.dao.StorageDao +import io.novafoundation.nova.core_db.dao.TinderGovDao +import io.novafoundation.nova.core_db.dao.TokenDao +import io.novafoundation.nova.core_db.dao.WalletConnectSessionsDao + +@Module +class DbModule { + + @Provides + @ApplicationScope + fun provideAppDatabase( + context: Context + ): AppDatabase { + return AppDatabase.get(context) + } + + @Provides + @ApplicationScope + fun provideUserDao(appDatabase: AppDatabase): AccountDao { + return appDatabase.userDao() + } + + @Provides + @ApplicationScope + fun provideNodeDao(appDatabase: AppDatabase): NodeDao { + return appDatabase.nodeDao() + } + + @Provides + @ApplicationScope + fun provideAssetDao(appDatabase: AppDatabase): AssetDao { + return appDatabase.assetDao() + } + + @Provides + @ApplicationScope + fun provideLockDao(appDatabase: AppDatabase): LockDao { + return appDatabase.lockDao() + } + + @Provides + @ApplicationScope + fun provideContributionDao(appDatabase: AppDatabase): ContributionDao { + return appDatabase.contributionDao() + } + + @Provides + @ApplicationScope + fun provideOperationHistoryDao(appDatabase: AppDatabase): OperationDao { + return appDatabase.operationDao() + } + + @Provides + @ApplicationScope + fun providePhishingAddressDao(appDatabase: AppDatabase): PhishingAddressDao { + return appDatabase.phishingAddressesDao() + } + + @Provides + @ApplicationScope + fun provideStorageDao(appDatabase: AppDatabase): StorageDao { + return appDatabase.storageDao() + } + + @Provides + @ApplicationScope + fun provideTokenDao(appDatabase: AppDatabase): TokenDao { + return appDatabase.tokenDao() + } + + @Provides + @ApplicationScope + fun provideAccountStakingDao(appDatabase: AppDatabase): AccountStakingDao { + return appDatabase.accountStakingDao() + } + + @Provides + @ApplicationScope + fun provideStakingTotalRewardDao(appDatabase: AppDatabase): StakingTotalRewardDao { + return appDatabase.stakingTotalRewardDao() + } + + @Provides + @ApplicationScope + fun provideChainDao(appDatabase: AppDatabase): ChainDao { + return appDatabase.chainDao() + } + + @Provides + @ApplicationScope + fun provideChainAssetDao(appDatabase: AppDatabase): ChainAssetDao { + return appDatabase.chainAssetDao() + } + + @Provides + @ApplicationScope + fun provideMetaAccountDao(appDatabase: AppDatabase): MetaAccountDao { + return appDatabase.metaAccountDao() + } + + @Provides + @ApplicationScope + fun provideDappAuthorizationDao(appDatabase: AppDatabase): DappAuthorizationDao { + return appDatabase.dAppAuthorizationDao() + } + + @Provides + @ApplicationScope + fun provideNftDao(appDatabase: AppDatabase): NftDao { + return appDatabase.nftDao() + } + + @Provides + @ApplicationScope + fun providePhishingSitesDao(appDatabase: AppDatabase): PhishingSitesDao { + return appDatabase.phishingSitesDao() + } + + @Provides + @ApplicationScope + fun provideFavouriteDappsDao(appDatabase: AppDatabase): FavouriteDAppsDao { + return appDatabase.favouriteDAppsDao() + } + + @Provides + @ApplicationScope + fun provideCurrencyDao(appDatabase: AppDatabase): CurrencyDao { + return appDatabase.currencyDao() + } + + @Provides + @ApplicationScope + fun provideGovernanceDAppDao(appDatabase: AppDatabase): GovernanceDAppsDao { + return appDatabase.governanceDAppsDao() + } + + @Provides + @ApplicationScope + fun provideBrowserHostSettingsDao(appDatabase: AppDatabase): BrowserHostSettingsDao { + return appDatabase.browserHostSettingsDao() + } + + @Provides + @ApplicationScope + fun provideWalletConnectSessionsDao(appDatabase: AppDatabase): WalletConnectSessionsDao { + return appDatabase.walletConnectSessionsDao() + } + + @Provides + @ApplicationScope + fun provideStakingDashboardDao(appDatabase: AppDatabase): StakingDashboardDao { + return appDatabase.stakingDashboardDao() + } + + @Provides + @ApplicationScope + fun provideCoinPriceDao(appDatabase: AppDatabase): CoinPriceDao { + return appDatabase.coinPriceDao() + } + + @Provides + @ApplicationScope + fun provideStakingRewardPeriodDao(appDatabase: AppDatabase): StakingRewardPeriodDao { + return appDatabase.stakingRewardPeriodDao() + } + + @Provides + @ApplicationScope + fun provideExternalBalanceDao(appDatabase: AppDatabase): ExternalBalanceDao { + return appDatabase.externalBalanceDao() + } + + @Provides + @ApplicationScope + fun provideHoldsDao(appDatabase: AppDatabase): HoldsDao { + return appDatabase.holdsDao() + } + + @Provides + @ApplicationScope + fun provideTinderGovDao(appDatabase: AppDatabase): TinderGovDao { + return appDatabase.tinderGovDao() + } + + @Provides + @ApplicationScope + fun provideBrowserTabsDao(appDatabase: AppDatabase): BrowserTabsDao { + return appDatabase.browserTabsDao() + } + + @Provides + @ApplicationScope + fun provideMultisigOperationsDao(appDatabase: AppDatabase): MultisigOperationsDao { + return appDatabase.multisigOperationsDao() + } + + @Provides + @ApplicationScope + fun provideGiftsDao(appDatabase: AppDatabase): GiftsDao { + return appDatabase.giftsDao() + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/ext/ChainAssetExtensions.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/ext/ChainAssetExtensions.kt new file mode 100644 index 0000000..5db31db --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/ext/ChainAssetExtensions.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.core_db.ext + +import io.novafoundation.nova.core_db.dao.FullAssetIdLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal + +fun ChainAssetLocal.fullId(): FullAssetIdLocal { + return FullAssetIdLocal(this.chainId, this.id) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/10_11_ChangeDAppAuthorization.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/10_11_ChangeDAppAuthorization.kt new file mode 100644 index 0000000..e439716 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/10_11_ChangeDAppAuthorization.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val ChangeDAppAuthorization_10_11 = object : Migration(10, 11) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE dapp_authorizations") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `dapp_authorizations` ( + `baseUrl` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + `dAppTitle` TEXT, `authorized` INTEGER, + PRIMARY KEY(`baseUrl`, `metaId`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/11_12_RemoveChainForeignKeyFromChainAccount.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/11_12_RemoveChainForeignKeyFromChainAccount.kt new file mode 100644 index 0000000..758dd93 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/11_12_RemoveChainForeignKeyFromChainAccount.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val RemoveChainForeignKeyFromChainAccount_11_12 = object : Migration(11, 12) { + + override fun migrate(database: SupportSQLiteDatabase) { + // rename + database.execSQL("DROP INDEX `index_chain_accounts_chainId`") + database.execSQL("DROP INDEX `index_chain_accounts_metaId`") + database.execSQL("DROP INDEX `index_chain_accounts_accountId`") + database.execSQL("ALTER TABLE chain_accounts RENAME TO chain_accounts_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_accounts` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `publicKey` BLOB NOT NULL, + `accountId` BLOB NOT NULL, + `cryptoType` TEXT NOT NULL, + PRIMARY KEY(`metaId`, `chainId`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_accounts_chainId` ON `chain_accounts` (`chainId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_accounts_metaId` ON `chain_accounts` (`metaId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_accounts_accountId` ON `chain_accounts` (`accountId`)") + + // insert to new from old + database.execSQL( + """ + INSERT INTO chain_accounts + SELECT * + FROM chain_accounts_old + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE chain_accounts_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/12_14.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/12_14.kt new file mode 100644 index 0000000..3548f0a --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/12_14.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +// used on master for Astar hotfix +val AddAdditionalFieldToChains_12_13 = object : Migration(12, 13) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chains ADD COLUMN additional TEXT DEFAULT null") + } +} + +// used on develop for parachainStaking rewards +val AddChainToTotalRewards_12_13 = object : Migration(12, 13) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE total_reward") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `total_reward` ( + `accountAddress` TEXT NOT NULL, + `chainId` TEXT NOT NULL, + `chainAssetId` INTEGER NOT NULL, + `totalReward` TEXT NOT NULL, + PRIMARY KEY(`chainId`, `chainAssetId`, `accountAddress`) + ) + """.trimIndent() + ) + } +} + +val FixMigrationConflicts_13_14 = object : Migration(13, 14) { + override fun migrate(database: SupportSQLiteDatabase) { + if (isMigratingFromMaster(database)) { + // migrating from master -> execute missing develop migration + AddChainToTotalRewards_12_13.migrate(database) + } else { + // migrating from develop -> execute missing master migration + AddAdditionalFieldToChains_12_13.migrate(database) + } + } + + private fun isMigratingFromMaster(database: SupportSQLiteDatabase): Boolean { + return runCatching { + // check for column added in astar hotfix (master) + database.query("SELECT additional FROM chains LIMIT 1") + }.fold( + onSuccess = { true }, + onFailure = { false } + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/14_15_AddMetaAccountType.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/14_15_AddMetaAccountType.kt new file mode 100644 index 0000000..64aa2b0 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/14_15_AddMetaAccountType.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.converters.MetaAccountTypeConverters +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal + +val AddMetaAccountType_14_15 = object : Migration(14, 15) { + + override fun migrate(database: SupportSQLiteDatabase) { + val converters = MetaAccountTypeConverters() + + // all accounts that exist till now are added via secrets + val defaultType = MetaAccountLocal.Type.SECRETS + val typeRepresentationInDb = converters.fromEnum(defaultType) + + database.execSQL("ALTER TABLE meta_accounts ADD COLUMN type TEXT NOT NULL DEFAULT '$typeRepresentationInDb'") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/15_16_NullableSubstratePublicKey.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/15_16_NullableSubstratePublicKey.kt new file mode 100644 index 0000000..3ff289c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/15_16_NullableSubstratePublicKey.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val NullableSubstratePublicKey_15_16 = object : Migration(15, 16) { + override fun migrate(database: SupportSQLiteDatabase) { + // rename + database.execSQL("DROP INDEX `index_meta_accounts_substrateAccountId`") + database.execSQL("DROP INDEX `index_meta_accounts_ethereumAddress`") + database.execSQL("ALTER TABLE meta_accounts RENAME TO meta_accounts_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `meta_accounts` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `substratePublicKey` BLOB, + `substrateCryptoType` TEXT, + `substrateAccountId` BLOB NOT NULL, + `ethereumPublicKey` BLOB, + `ethereumAddress` BLOB, + `name` TEXT NOT NULL, + `isSelected` INTEGER NOT NULL, + `position` INTEGER NOT NULL, + `type` TEXT NOT NULL + ) + """.trimIndent() + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_meta_accounts_substrateAccountId` ON `meta_accounts` (`substrateAccountId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_meta_accounts_ethereumAddress` ON `meta_accounts` (`ethereumAddress`)") + + // insert to new from old + database.execSQL( + """ + INSERT INTO meta_accounts + SELECT * + FROM meta_accounts_old + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE meta_accounts_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/16_17_NullableChainAccountCryptoType.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/16_17_NullableChainAccountCryptoType.kt new file mode 100644 index 0000000..c1ce3a0 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/16_17_NullableChainAccountCryptoType.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val WatchOnlyChainAccounts_16_17 = object : Migration(16, 17) { + override fun migrate(database: SupportSQLiteDatabase) { + // rename + database.execSQL("DROP INDEX `index_chain_accounts_chainId`") + database.execSQL("DROP INDEX `index_chain_accounts_metaId`") + database.execSQL("DROP INDEX `index_chain_accounts_accountId`") + database.execSQL("ALTER TABLE chain_accounts RENAME TO chain_accounts_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_accounts` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `publicKey` BLOB, + `accountId` BLOB NOT NULL, + `cryptoType` TEXT, + PRIMARY KEY(`metaId`, `chainId`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_accounts_chainId` ON `chain_accounts` (`chainId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_accounts_metaId` ON `chain_accounts` (`metaId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_accounts_accountId` ON `chain_accounts` (`accountId`)") + + // insert to new from old + database.execSQL( + """ + INSERT INTO chain_accounts + SELECT * + FROM chain_accounts_old + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE chain_accounts_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/17_18_RemoveColorFromChains.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/17_18_RemoveColorFromChains.kt new file mode 100644 index 0000000..5de5661 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/17_18_RemoveColorFromChains.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val RemoveColorFromChains_17_18 = object : Migration(17, 18) { + override fun migrate(database: SupportSQLiteDatabase) { + // rename + database.execSQL("ALTER TABLE chains RENAME TO chains_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chains` ( + `id` TEXT NOT NULL, + `parentId` TEXT, + `name` TEXT NOT NULL, + `icon` TEXT NOT NULL, + `prefix` INTEGER NOT NULL, + `isEthereumBased` INTEGER NOT NULL, + `isTestNet` INTEGER NOT NULL, + `hasCrowdloans` INTEGER NOT NULL, + `additional` TEXT, + `url` TEXT, + `overridesCommon` INTEGER, + `staking_url` TEXT, + `staking_type` TEXT, + `history_url` TEXT, + `history_type` TEXT, + `crowdloans_url` TEXT, + `crowdloans_type` TEXT, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + // insert to new from old + database.execSQL( + // select all but color + """ + INSERT INTO chains + SELECT id, parentId, name, icon, prefix, isEthereumBased, isTestNet, hasCrowdloans, additional, url, overridesCommon, + staking_url, staking_type, history_url, history_type, crowdloans_url, crowdloans_type + FROM chains_old + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE chains_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/18_19_AddCurrencies.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/18_19_AddCurrencies.kt new file mode 100644 index 0000000..f648912 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/18_19_AddCurrencies.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddCurrencies_18_19 = object : Migration(18, 19) { + override fun migrate(database: SupportSQLiteDatabase) { + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `currencies` ( + `code` TEXT NOT NULL, + `name` TEXT NOT NULL, + `symbol` TEXT, + `category` TEXT NOT NULL, + `popular` INTEGER NOT NULL, + `id` INTEGER NOT NULL, + `coingeckoId` TEXT NOT NULL, + `selected` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/19_20_ChangeTokens.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/19_20_ChangeTokens.kt new file mode 100644 index 0000000..ace6631 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/19_20_ChangeTokens.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val ChangeTokens_19_20 = object : Migration(19, 20) { + override fun migrate(database: SupportSQLiteDatabase) { + // rename table + database.execSQL("ALTER TABLE tokens RENAME TO tokens_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `tokens` ( + `tokenSymbol` TEXT NOT NULL, + `rate` TEXT, + `recentRateChange` TEXT, + `currencyId` INTEGER NOT NULL, + PRIMARY KEY(`tokenSymbol`, `currencyId`) + ) + """.trimIndent() + ) + + // insert to new from old + database.execSQL( + """ + INSERT INTO tokens (tokenSymbol, rate, recentRateChange, currencyId) + SELECT symbol, dollarRate, recentRateChange, 0 + FROM tokens_old + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE tokens_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/1_2_AddDappAuthorizations.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/1_2_AddDappAuthorizations.kt new file mode 100644 index 0000000..18363c7 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/1_2_AddDappAuthorizations.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddDAppAuthorizations_1_2 = object : Migration(1, 2) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `dapp_authorizations` ( + `baseUrl` TEXT NOT NULL, + `authorized` INTEGER, + PRIMARY KEY(`baseUrl`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/20_21_ChangeChainNodes.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/20_21_ChangeChainNodes.kt new file mode 100644 index 0000000..e536c4c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/20_21_ChangeChainNodes.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val ChangeChainNodes_20_21 = object : Migration(20, 21) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chain_nodes ADD `orderId` INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/21_22_NullableSubstrateAccountId.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/21_22_NullableSubstrateAccountId.kt new file mode 100644 index 0000000..7977098 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/21_22_NullableSubstrateAccountId.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val NullableSubstrateAccountId_21_22 = object : Migration(21, 22) { + + override fun migrate(database: SupportSQLiteDatabase) { + // rename + database.execSQL("DROP INDEX `index_meta_accounts_substrateAccountId`") + database.execSQL("DROP INDEX `index_meta_accounts_ethereumAddress`") + database.execSQL("ALTER TABLE meta_accounts RENAME TO meta_accounts_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `meta_accounts` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `substratePublicKey` BLOB, + `substrateCryptoType` TEXT, + `substrateAccountId` BLOB, + `ethereumPublicKey` BLOB, + `ethereumAddress` BLOB, + `name` TEXT NOT NULL, + `isSelected` INTEGER NOT NULL, + `position` INTEGER NOT NULL, + `type` TEXT NOT NULL + ) + """.trimIndent() + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_meta_accounts_substrateAccountId` ON `meta_accounts` (`substrateAccountId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_meta_accounts_ethereumAddress` ON `meta_accounts` (`ethereumAddress`)") + + // insert to new from old + database.execSQL( + """ + INSERT INTO meta_accounts + SELECT * + FROM meta_accounts_old + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE meta_accounts_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/22_23_AddLocks.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/22_23_AddLocks.kt new file mode 100644 index 0000000..b817328 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/22_23_AddLocks.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddLocks_22_23 = object : Migration(22, 23) { + override fun migrate(database: SupportSQLiteDatabase) { + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `locks` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `assetId` INTEGER NOT NULL, + `type` TEXT NOT NULL, + `amount` TEXT NOT NULL, + PRIMARY KEY(`metaId`, `chainId`, `assetId`, `type`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , + FOREIGN KEY(`assetId`, `chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/23_24_AddContributions.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/23_24_AddContributions.kt new file mode 100644 index 0000000..63aa25d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/23_24_AddContributions.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddContributions_23_24 = object : Migration(23, 24) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `contributions` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `assetId` INTEGER NOT NULL, + `paraId` TEXT NOT NULL, + `amountInPlanks` TEXT NOT NULL, + `sourceId` TEXT NOT NULL, + PRIMARY KEY(`metaId`, `chainId`, `assetId`, `paraId`, `sourceId`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/24_25_AddGovernanceFlagToChains.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/24_25_AddGovernanceFlagToChains.kt new file mode 100644 index 0000000..c7ce802 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/24_25_AddGovernanceFlagToChains.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddGovernanceFlagToChains_24_25 = object : Migration(24, 25) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chains ADD COLUMN hasGovernance INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/25_26_AddGovernanceDapps.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/25_26_AddGovernanceDapps.kt new file mode 100644 index 0000000..82356f7 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/25_26_AddGovernanceDapps.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddGovernanceDapps_25_26 = object : Migration(25, 26) { + override fun migrate(database: SupportSQLiteDatabase) { + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `governance_dapps` ( + `chainId` TEXT NOT NULL, + `name` TEXT NOT NULL, + `referendumUrl` TEXT NOT NULL, + `iconUrl` TEXT NOT NULL, + `details` TEXT NOT NULL, + PRIMARY KEY(`chainId`, `name`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/26_27_GovernanceFlagToEnum.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/26_27_GovernanceFlagToEnum.kt new file mode 100644 index 0000000..9bc2de5 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/26_27_GovernanceFlagToEnum.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val GovernanceFlagToEnum_26_27 = object : Migration(26, 27) { + + override fun migrate(database: SupportSQLiteDatabase) { + // rename + database.execSQL("ALTER TABLE chains RENAME TO chains_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chains` ( + `id` TEXT NOT NULL, + `parentId` TEXT, + `name` TEXT NOT NULL, + `icon` TEXT NOT NULL, + `prefix` INTEGER NOT NULL, + `isEthereumBased` INTEGER NOT NULL, + `isTestNet` INTEGER NOT NULL, + `hasCrowdloans` INTEGER NOT NULL, + `governance` TEXT NOT NULL, + `additional` TEXT, + `url` TEXT, + `overridesCommon` INTEGER, + `staking_url` TEXT, + `staking_type` TEXT, + `history_url` TEXT, + `history_type` TEXT, + `crowdloans_url` TEXT, + `crowdloans_type` TEXT, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + val governanceDefault = "NONE" + + // insert to new from old + database.execSQL( + // select all but color + """ + INSERT INTO chains + SELECT id, parentId, name, icon, prefix, isEthereumBased, isTestNet, hasCrowdloans, "$governanceDefault", additional, url, overridesCommon, + staking_url, staking_type, history_url, history_type, crowdloans_url, crowdloans_type + FROM chains_old + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE chains_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/27_28_AddGovernanceExternalApiToChain.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/27_28_AddGovernanceExternalApiToChain.kt new file mode 100644 index 0000000..34c7fe7 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/27_28_AddGovernanceExternalApiToChain.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddGovernanceExternalApiToChain_27_28 = object : Migration(27, 28) { + + override fun migrate(database: SupportSQLiteDatabase) { + // new columns + database.execSQL("ALTER TABLE `chains` ADD COLUMN `governance_url` TEXT") + database.execSQL("ALTER TABLE `chains` ADD COLUMN `governance_type` TEXT") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/28_29_AddSourceToChainAsset.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/28_29_AddSourceToChainAsset.kt new file mode 100644 index 0000000..d7e4327 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/28_29_AddSourceToChainAsset.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal + +val AddSourceToLocalAsset_28_29 = object : Migration(28, 29) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `chain_assets` ADD COLUMN `source` TEXT NOT NULL DEFAULT '${ChainAssetLocal.SOURCE_DEFAULT}'") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/29_30_AddTransferApisTable.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/29_30_AddTransferApisTable.kt new file mode 100644 index 0000000..8388818 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/29_30_AddTransferApisTable.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddTransferApisTable_29_30 = object : Migration(29, 30) { + override fun migrate(database: SupportSQLiteDatabase) { + removeTransferApiFieldsFromChains(database) + + addTransferApiTable(database) + + clearOperationsCache(database) + } + + private fun clearOperationsCache(database: SupportSQLiteDatabase) { + database.execSQL("DELETE FROM operations") + } + + private fun addTransferApiTable(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_transfer_history_apis` ( + `chainId` TEXT NOT NULL, + `assetType` TEXT NOT NULL, + `apiType` TEXT NOT NULL, + `url` TEXT NOT NULL, + PRIMARY KEY(`chainId`, `url`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL( + """ + CREATE INDEX IF NOT EXISTS `index_chain_transfer_history_apis_chainId` ON `chain_transfer_history_apis` (`chainId`) + """.trimIndent() + ) + } + + private fun removeTransferApiFieldsFromChains(database: SupportSQLiteDatabase) { + // rename + database.execSQL("ALTER TABLE chains RENAME TO chains_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chains` ( + `id` TEXT NOT NULL, + `parentId` TEXT, + `name` TEXT NOT NULL, + `icon` TEXT NOT NULL, + `prefix` INTEGER NOT NULL, + `isEthereumBased` INTEGER NOT NULL, + `isTestNet` INTEGER NOT NULL, + `hasCrowdloans` INTEGER NOT NULL, + `governance` TEXT NOT NULL, + `additional` TEXT, + `url` TEXT, + `overridesCommon` INTEGER, + `staking_url` TEXT, + `staking_type` TEXT, + `crowdloans_url` TEXT, + `crowdloans_type` TEXT, + `governance_url` TEXT, + `governance_type` TEXT, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + // insert to new from old + database.execSQL( + // select all but color + """ + INSERT INTO chains + SELECT id, parentId, name, icon, prefix, isEthereumBased, isTestNet, hasCrowdloans, governance, + additional, url, overridesCommon, staking_url, staking_type, crowdloans_url, crowdloans_type, governance_url, governance_type + FROM chains_old + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE chains_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/2_3_AssetTypes.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/2_3_AssetTypes.kt new file mode 100644 index 0000000..f9fb773 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/2_3_AssetTypes.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AssetTypes_2_3 = object : Migration(2, 3) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chain_assets ADD COLUMN type TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE chain_assets ADD COLUMN typeExtras TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE chain_assets ADD COLUMN icon TEXT DEFAULT NULL") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/30_31_AddEnabledColumnToChainAssets.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/30_31_AddEnabledColumnToChainAssets.kt new file mode 100644 index 0000000..f792a6c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/30_31_AddEnabledColumnToChainAssets.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal + +val AddEnabledColumnToChainAssets_30_31 = object : Migration(30, 31) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `chain_assets` ADD COLUMN `enabled` INTEGER NOT NULL DEFAULT ${ChainAssetLocal.ENABLED_DEFAULT_STR}") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/31_32_FixBrokenForeignKeys.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/31_32_FixBrokenForeignKeys.kt new file mode 100644 index 0000000..78568bd --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/31_32_FixBrokenForeignKeys.kt @@ -0,0 +1,272 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal + +/** + * Due to previous migration of chain & meta account tables by means of rename-create-insert-delete strategy + * foreign keys to these tables got renamed and now points to wrong table which causes crashes for subset of users + * This migration recreates all affected tables + */ +val FixBrokenForeignKeys_31_32 = object : Migration(31, 32) { + + override fun migrate(database: SupportSQLiteDatabase) { + // foreign key to ChainLocal + recreateChainAssets(database) + + // foreign key to ChainAssetLocal which was recreated above + recreateAssets(database) + + // foreign key to MetaAccountLocal + recreateChainAccount(database) + + // foreign key to ChainLocal + recreateChainRuntimeInfo(database) + + // foreign key to ChainLocal + recreateChainExplorers(database) + + // foreign key to ChainLocal + recreateChainNodes(database) + + // foreign key to ChainLocal, ChainAssetLocal, MetaAccount + recreateBalanceLocks(database) + } + + private fun recreateChainAssets(database: SupportSQLiteDatabase) { + database.execSQL("DROP INDEX IF EXISTS `index_chain_assets_chainId`") + + database.execSQL("ALTER TABLE chain_assets RENAME TO chain_assets_old") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_assets` ( + `id` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `name` TEXT NOT NULL, + `symbol` TEXT NOT NULL, + `priceId` TEXT, + `staking` TEXT NOT NULL, + `precision` INTEGER NOT NULL, + `icon` TEXT, + `type` TEXT, + `source` TEXT NOT NULL DEFAULT '${ChainAssetLocal.SOURCE_DEFAULT}', + `buyProviders` TEXT, + `typeExtras` TEXT, + `enabled` INTEGER NOT NULL DEFAULT ${ChainAssetLocal.ENABLED_DEFAULT_STR}, + PRIMARY KEY(`chainId`,`id`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL( + """ + INSERT INTO chain_assets + SELECT + id, chainId, name, symbol, priceId, + staking, precision, icon, type, source, + buyProviders, typeExtras, enabled + FROM chain_assets_old + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_assets_chainId` ON `chain_assets` (`chainId`)") + + database.execSQL("DROP TABLE chain_assets_old") + } + + private fun recreateAssets(database: SupportSQLiteDatabase) { + database.execSQL("DROP INDEX IF EXISTS `index_assets_metaId`") + database.execSQL("ALTER TABLE assets RENAME TO assets_old") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `assets` ( + `assetId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + `freeInPlanks` TEXT NOT NULL, + `frozenInPlanks` TEXT NOT NULL, + `reservedInPlanks` TEXT NOT NULL, + `bondedInPlanks` TEXT NOT NULL, + `redeemableInPlanks` TEXT NOT NULL, + `unbondingInPlanks` TEXT NOT NULL, + PRIMARY KEY(`assetId`,`chainId`,`metaId`), + FOREIGN KEY(`assetId`,`chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL( + """ + INSERT INTO assets + SELECT + assetId, chainId, metaId, + freeInPlanks, frozenInPlanks, reservedInPlanks, + bondedInPlanks, redeemableInPlanks, unbondingInPlanks + FROM assets_old + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_assets_metaId` ON `assets` (`metaId`)") + + database.execSQL("DROP TABLE assets_old") + } + + private fun recreateChainAccount(database: SupportSQLiteDatabase) { + database.execSQL("DROP INDEX IF EXISTS `index_chain_accounts_chainId`") + database.execSQL("DROP INDEX IF EXISTS `index_chain_accounts_metaId`") + database.execSQL("DROP INDEX IF EXISTS `index_chain_accounts_accountId`") + + database.execSQL("ALTER TABLE chain_accounts RENAME TO chain_accounts_old") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_accounts` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `publicKey` BLOB, + `accountId` BLOB NOT NULL, + `cryptoType` TEXT, + PRIMARY KEY(`metaId`, `chainId`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL( + """ + INSERT INTO chain_accounts + SELECT metaId, chainId, publicKey, accountId, cryptoType + FROM chain_accounts_old + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_accounts_chainId` ON `chain_accounts` (`chainId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_accounts_metaId` ON `chain_accounts` (`metaId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_accounts_accountId` ON `chain_accounts` (`accountId`)") + + database.execSQL("DROP TABLE chain_accounts_old") + } + + private fun recreateChainRuntimeInfo(database: SupportSQLiteDatabase) { + database.execSQL("DROP INDEX IF EXISTS `index_chain_runtimes_chainId`") + + database.execSQL("ALTER TABLE chain_runtimes RENAME TO chain_runtimes_old") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_runtimes` ( + `chainId` TEXT NOT NULL, + `syncedVersion` INTEGER NOT NULL, + `remoteVersion` INTEGER NOT NULL, + PRIMARY KEY(`chainId`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL( + """ + INSERT INTO chain_runtimes + SELECT chainId, syncedVersion, remoteVersion FROM chain_runtimes_old + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_runtimes_chainId` ON `chain_runtimes` (`chainId`)") + + database.execSQL("DROP TABLE chain_runtimes_old") + } + + private fun recreateChainExplorers(database: SupportSQLiteDatabase) { + database.execSQL("DROP INDEX IF EXISTS `index_chain_explorers_chainId`") + + database.execSQL("ALTER TABLE chain_explorers RENAME TO chain_explorers_old") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_explorers` ( + `chainId` TEXT NOT NULL, + `name` TEXT NOT NULL, + `extrinsic` TEXT, + `account` TEXT, + `event` TEXT, + PRIMARY KEY(`chainId`, `name`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL( + """ + INSERT INTO chain_explorers + SELECT chainId, name, extrinsic, account, event FROM chain_explorers_old + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_explorers_chainId` ON `chain_explorers` (`chainId`)") + + database.execSQL("DROP TABLE chain_explorers_old") + } + + private fun recreateChainNodes(database: SupportSQLiteDatabase) { + database.execSQL("DROP INDEX IF EXISTS `index_chain_nodes_chainId`") + + database.execSQL("ALTER TABLE chain_nodes RENAME TO chain_nodes_old") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_nodes` ( + `chainId` TEXT NOT NULL, + `url` TEXT NOT NULL, + `name` TEXT NOT NULL, + `orderId` INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(`chainId`, `url`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL( + """ + INSERT INTO chain_nodes + SELECT chainId, url, name, orderId FROM chain_nodes_old + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_nodes_chainId` ON `chain_nodes` (`chainId`)") + + database.execSQL("DROP TABLE chain_nodes_old") + } + + private fun recreateBalanceLocks(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE locks RENAME TO locks_old") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `locks` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `assetId` INTEGER NOT NULL, + `type` TEXT NOT NULL, + `amount` TEXT NOT NULL, + PRIMARY KEY(`metaId`, `chainId`, `assetId`, `type`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , + FOREIGN KEY(`assetId`, `chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL( + """ + INSERT INTO locks + SELECT metaId, chainId, assetId, type, amount FROM locks_old + """.trimIndent() + ) + + database.execSQL("DROP TABLE locks_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/32_33_AddVersioningToGovernanceDapps.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/32_33_AddVersioningToGovernanceDapps.kt new file mode 100644 index 0000000..244453c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/32_33_AddVersioningToGovernanceDapps.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddVersioningToGovernanceDapps_32_33 = object : Migration(32, 33) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE `governance_dapps`") + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `governance_dapps` ( + `chainId` TEXT NOT NULL, + `name` TEXT NOT NULL, + `referendumUrlV1` TEXT, + `referendumUrlV2` TEXT, + `iconUrl` TEXT NOT NULL, + `details` TEXT NOT NULL, + PRIMARY KEY(`chainId`, `name`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/33_34_AddGovernanceNetworkToExternalApi.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/33_34_AddGovernanceNetworkToExternalApi.kt new file mode 100644 index 0000000..523251e --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/33_34_AddGovernanceNetworkToExternalApi.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddGovernanceNetworkToExternalApi_33_34 = object : Migration(33, 34) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chains ADD COLUMN `governance_parameters` TEXT") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/34_35_AddBrowserHostSettings.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/34_35_AddBrowserHostSettings.kt new file mode 100644 index 0000000..773ba8f --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/34_35_AddBrowserHostSettings.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddBrowserHostSettings_34_35 = object : Migration(34, 35) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `browser_host_settings` ( + `hostUrl` TEXT NOT NULL, + `isDesktopModeEnabled` INTEGER NOT NULL, + PRIMARY KEY(`hostUrl`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/35_36_ExtractExternalApiToSeparateTable.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/35_36_ExtractExternalApiToSeparateTable.kt new file mode 100644 index 0000000..50becac --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/35_36_ExtractExternalApiToSeparateTable.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val ExtractExternalApiToSeparateTable_35_36 = object : Migration(35, 36) { + + override fun migrate(database: SupportSQLiteDatabase) { + removeExternalApisColumnsFromChains(database) + + migrateExternalApisTable(database) + + // recreating chainId causes broken foreign keys to appear on some devices + // so we run this migration again to fix it + FixBrokenForeignKeys_31_32.migrate(database) + } + + private fun migrateExternalApisTable(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE chain_transfer_history_apis") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_external_apis` ( + `chainId` TEXT NOT NULL, + `sourceType` TEXT NOT NULL, + `apiType` TEXT NOT NULL, + `parameters` TEXT, + `url` TEXT NOT NULL, + PRIMARY KEY(`chainId`, `url`, `apiType`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_chain_external_apis_chainId` ON `chain_external_apis` (`chainId`)") + } + + private fun removeExternalApisColumnsFromChains(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chains RENAME TO chains_old") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chains` ( + `id` TEXT NOT NULL, + `parentId` TEXT, + `name` TEXT NOT NULL, + `icon` TEXT NOT NULL, + `prefix` INTEGER NOT NULL, + `isEthereumBased` INTEGER NOT NULL, + `isTestNet` INTEGER NOT NULL, + `hasCrowdloans` INTEGER NOT NULL, + `governance` TEXT NOT NULL, + `additional` TEXT, + `url` TEXT, + `overridesCommon` INTEGER, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + database.execSQL( + """ + INSERT INTO chains + SELECT + id, parentId, name, icon, prefix, + isEthereumBased, isTestNet, hasCrowdloans, governance, additional, + url, overridesCommon + FROM chains_old + """.trimIndent() + ) + + database.execSQL("DROP TABLE chains_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/36_37_AddRuntimeFlagToChains.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/36_37_AddRuntimeFlagToChains.kt new file mode 100644 index 0000000..d0b94a3 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/36_37_AddRuntimeFlagToChains.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.model.chain.ChainLocal + +val AddRuntimeFlagToChains_36_37 = object : Migration(36, 37) { + + override fun migrate(database: SupportSQLiteDatabase) { + val default = ChainLocal.Default.HAS_SUBSTRATE_RUNTIME + + database.execSQL("ALTER TABLE chains ADD COLUMN `hasSubstrateRuntime` INTEGER NOT NULL DEFAULT $default") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/37_38_AddExtrinsicContentField.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/37_38_AddExtrinsicContentField.kt new file mode 100644 index 0000000..0dee0d9 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/37_38_AddExtrinsicContentField.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddExtrinsicContentField_37_38 = object : Migration(37, 38) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE operations") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `operations` ( + `id` TEXT NOT NULL, + `address` TEXT NOT NULL, + `chainId` TEXT NOT NULL, + `chainAssetId` INTEGER NOT NULL, + `time` INTEGER NOT NULL, + `status` INTEGER NOT NULL, + `source` INTEGER NOT NULL, + `operationType` INTEGER NOT NULL, + `amount` TEXT, + `sender` TEXT, + `receiver` TEXT, + `hash` TEXT, + `fee` TEXT, + `isReward` INTEGER, + `era` INTEGER, + `validator` TEXT, + `extrinsicContent_type` TEXT, + `extrinsicContent_module` TEXT, + `extrinsicContent_call` TEXT, + PRIMARY KEY(`id`, `address`, `chainId`, `chainAssetId`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/38_39_AddNodeSelectionStrategyField.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/38_39_AddNodeSelectionStrategyField.kt new file mode 100644 index 0000000..c230f71 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/38_39_AddNodeSelectionStrategyField.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.model.chain.ChainLocal + +val AddNodeSelectionStrategyField_38_39 = object : Migration(38, 39) { + override fun migrate(database: SupportSQLiteDatabase) { + val default = ChainLocal.Default.NODE_SELECTION_STRATEGY_DEFAULT + + database.execSQL("ALTER TABLE chains ADD COLUMN `nodeSelectionStrategy` TEXT NOT NULL DEFAULT '$default'") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/39_40_AddWalletConnectSessions.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/39_40_AddWalletConnectSessions.kt new file mode 100644 index 0000000..bcdf9f0 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/39_40_AddWalletConnectSessions.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddWalletConnectSessions_39_40 = object : Migration(39, 40) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `wallet_connect_sessions` ( + `sessionTopic` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + PRIMARY KEY(`sessionTopic`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_wallet_connect_sessions_metaId` ON `wallet_connect_sessions` (`metaId`)") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/3_4_ChangeAsset.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/3_4_ChangeAsset.kt new file mode 100644 index 0000000..6f57191 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/3_4_ChangeAsset.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val ChangeAsset_3_4 = object : Migration(3, 4) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE assets") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `assets` ( + `tokenSymbol` TEXT NOT NULL, + `chainId` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + `freeInPlanks` TEXT NOT NULL, + `frozenInPlanks` TEXT NOT NULL, + `reservedInPlanks` TEXT NOT NULL, + `bondedInPlanks` TEXT NOT NULL, + `redeemableInPlanks` TEXT NOT NULL, + `unbondingInPlanks` TEXT NOT NULL, + PRIMARY KEY(`tokenSymbol`, `chainId`, `metaId`) + ) + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_assets_metaId` ON `assets` (`metaId`)") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/40_41_AddStakingDashboardItems.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/40_41_AddStakingDashboardItems.kt new file mode 100644 index 0000000..7a2c6a4 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/40_41_AddStakingDashboardItems.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddStakingDashboardItems_41_42 = object : Migration(41, 42) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `staking_dashboard_items` ( + `chainId` TEXT NOT NULL, + `chainAssetId` INTEGER NOT NULL, + `stakingType` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + `hasStake` INTEGER NOT NULL, + `stake` TEXT, + `status` TEXT, + `rewards` TEXT, + `estimatedEarnings` REAL, + `primaryStakingAccountId` BLOB, + PRIMARY KEY(`chainId`, `chainAssetId`, `stakingType`, `metaId`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`chainAssetId`, `chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE , + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_staking_dashboard_items_metaId` ON `staking_dashboard_items` (`metaId`)") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/40_41_TransferFiatAmount.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/40_41_TransferFiatAmount.kt new file mode 100644 index 0000000..086b08c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/40_41_TransferFiatAmount.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val TransferFiatAmount_40_41 = object : Migration(40, 41) { + override fun migrate(database: SupportSQLiteDatabase) { + createCoinPriceTable(database) + } + + private fun createCoinPriceTable(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `coin_prices` ( + `priceId` TEXT NOT NULL, + `currencyId` TEXT NOT NULL, + `timestamp` INTEGER NOT NULL, + `rate` TEXT NOT NULL, + PRIMARY KEY(`priceId`, `currencyId`, `timestamp`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/42_43_StakingRewardPeriods.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/42_43_StakingRewardPeriods.kt new file mode 100644 index 0000000..0a88222 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/42_43_StakingRewardPeriods.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val StakingRewardPeriods_42_43 = object : Migration(42, 43) { + + override fun migrate(database: SupportSQLiteDatabase) { + createCoinPriceTable(database) + } + + private fun createCoinPriceTable(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `staking_reward_period` ( + `accountId` BLOB NOT NULL, + `chainId` TEXT NOT NULL, + `assetId` INTEGER NOT NULL, + `stakingType` TEXT NOT NULL, + `periodType` TEXT NOT NULL, + `customPeriodStart` INTEGER, + `customPeriodEnd` INTEGER, + PRIMARY KEY(`accountId`, `chainId`, `assetId`, `stakingType`)) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/43_44_AddRewardAccountToStakingDashboard.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/43_44_AddRewardAccountToStakingDashboard.kt new file mode 100644 index 0000000..2b1d7d4 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/43_44_AddRewardAccountToStakingDashboard.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddRewardAccountToStakingDashboard_43_44 = object : Migration(43, 44) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE `staking_dashboard_items`") + database.execSQL("DROP INDEX IF EXISTS `index_staking_dashboard_items_metaId`") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `staking_dashboard_items` ( + `chainId` TEXT NOT NULL, + `chainAssetId` INTEGER NOT NULL, + `stakingType` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + `hasStake` INTEGER NOT NULL, + `stake` TEXT, + `status` TEXT, + `rewards` TEXT, + `estimatedEarnings` REAL, + `stakeStatusAccount` BLOB, + `rewardsAccount` BLOB, + PRIMARY KEY(`chainId`, `chainAssetId`, `stakingType`, `metaId`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`chainAssetId`, `chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_staking_dashboard_items_metaId` ON `staking_dashboard_items` (`metaId`)") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/44_45_AddStakingTypeToTotalRewards.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/44_45_AddStakingTypeToTotalRewards.kt new file mode 100644 index 0000000..cde1df0 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/44_45_AddStakingTypeToTotalRewards.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddStakingTypeToTotalRewards_44_45 = object : Migration(44, 45) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE total_reward") + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `total_reward` ( + `accountId` BLOB NOT NULL, + `chainId` TEXT NOT NULL, + `chainAssetId` INTEGER NOT NULL, + `stakingType` TEXT NOT NULL, + `totalReward` TEXT NOT NULL, + PRIMARY KEY(`chainId`,`chainAssetId`,`stakingType`, `accountId`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/45_46_AddExternalBalances.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/45_46_AddExternalBalances.kt new file mode 100644 index 0000000..c760c49 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/45_46_AddExternalBalances.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddExternalBalances_45_46 = object : Migration(45, 46) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `externalBalances` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `assetId` INTEGER NOT NULL, + `type` TEXT NOT NULL, + `subtype` TEXT NOT NULL, + `amount` TEXT NOT NULL, + PRIMARY KEY(`metaId`, `chainId`, `assetId`, `type`, `subtype`), + FOREIGN KEY(`assetId`, `chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE ) + """ + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/46_47_AddPoolIdToOperations.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/46_47_AddPoolIdToOperations.kt new file mode 100644 index 0000000..6e0d8e9 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/46_47_AddPoolIdToOperations.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddPoolIdToOperations_46_47 = object : Migration(46, 47) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE operations ADD COLUMN poolId INTEGER") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/47_48_AddEventIdToOperation.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/47_48_AddEventIdToOperation.kt new file mode 100644 index 0000000..07d6caf --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/47_48_AddEventIdToOperation.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddEventIdToOperation_47_48 = object : Migration(47, 48) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE operations ADD COLUMN eventId TEXT") + + database.execSQL("DELETE FROM operations") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/48_49_AddEventIdToOperation.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/48_49_AddEventIdToOperation.kt new file mode 100644 index 0000000..7549f2c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/48_49_AddEventIdToOperation.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddSwapOption_48_49 = object : Migration(48, 49) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chains ADD COLUMN `swap` TEXT NOT NULL DEFAULT ''") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/49_50_RefactorOperations.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/49_50_RefactorOperations.kt new file mode 100644 index 0000000..ded794f --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/49_50_RefactorOperations.kt @@ -0,0 +1,26 @@ +@file:Suppress("ktlint") + +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val RefactorOperations_49_50 = object : Migration(49, 50) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE operations") + + database.execSQL("CREATE TABLE IF NOT EXISTS `operations` (`id` TEXT NOT NULL, `address` TEXT NOT NULL, `time` INTEGER NOT NULL, `status` INTEGER NOT NULL, `source` INTEGER NOT NULL, `hash` TEXT, `chainId` TEXT NOT NULL, `assetId` INTEGER NOT NULL, PRIMARY KEY(`id`, `address`, `chainId`, `assetId`))") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_operations_hash` ON `operations` (`hash`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `operation_transfers` (`amount` TEXT NOT NULL, `sender` TEXT NOT NULL, `receiver` TEXT NOT NULL, `fee` TEXT, `operationId` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `assetId` INTEGER NOT NULL, PRIMARY KEY(`operationId`, `address`, `chainId`, `assetId`), FOREIGN KEY(`operationId`, `address`, `chainId`, `assetId`) REFERENCES `operations`(`id`, `address`, `chainId`, `assetId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_operation_transfers_operationId_address_chainId_assetId` ON `operation_transfers` (`operationId`, `address`, `chainId`, `assetId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `operation_rewards_direct` (`isReward` INTEGER NOT NULL, `amount` TEXT NOT NULL, `eventId` TEXT NOT NULL, `era` INTEGER, `validator` TEXT, `operationId` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `assetId` INTEGER NOT NULL, PRIMARY KEY(`operationId`, `address`, `chainId`, `assetId`), FOREIGN KEY(`operationId`, `address`, `chainId`, `assetId`) REFERENCES `operations`(`id`, `address`, `chainId`, `assetId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_operation_rewards_direct_operationId_address_chainId_assetId` ON `operation_rewards_direct` (`operationId`, `address`, `chainId`, `assetId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `operation_rewards_pool` (`isReward` INTEGER NOT NULL, `amount` TEXT NOT NULL, `eventId` TEXT NOT NULL, `poolId` INTEGER NOT NULL, `operationId` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `assetId` INTEGER NOT NULL, PRIMARY KEY(`operationId`, `address`, `chainId`, `assetId`), FOREIGN KEY(`operationId`, `address`, `chainId`, `assetId`) REFERENCES `operations`(`id`, `address`, `chainId`, `assetId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_operation_rewards_pool_operationId_address_chainId_assetId` ON `operation_rewards_pool` (`operationId`, `address`, `chainId`, `assetId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `operation_extrinsics` (`contentType` TEXT NOT NULL, `module` TEXT NOT NULL, `call` TEXT, `fee` TEXT NOT NULL, `operationId` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `assetId` INTEGER NOT NULL, PRIMARY KEY(`operationId`, `address`, `chainId`, `assetId`), FOREIGN KEY(`operationId`, `address`, `chainId`, `assetId`) REFERENCES `operations`(`id`, `address`, `chainId`, `assetId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_operation_extrinsics_operationId_address_chainId_assetId` ON `operation_extrinsics` (`operationId`, `address`, `chainId`, `assetId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `operation_swaps` (`operationId` TEXT NOT NULL, `address` TEXT NOT NULL, `chainId` TEXT NOT NULL, `assetId` INTEGER NOT NULL, `fee_amount` TEXT NOT NULL, `fee_chainId` TEXT NOT NULL, `fee_assetId` INTEGER NOT NULL, `assetIn_amount` TEXT NOT NULL, `assetIn_chainId` TEXT NOT NULL, `assetIn_assetId` INTEGER NOT NULL, `assetOut_amount` TEXT NOT NULL, `assetOut_chainId` TEXT NOT NULL, `assetOut_assetId` INTEGER NOT NULL, PRIMARY KEY(`operationId`, `address`, `chainId`, `assetId`), FOREIGN KEY(`operationId`, `address`, `chainId`, `assetId`) REFERENCES `operations`(`id`, `address`, `chainId`, `assetId`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_operation_swaps_operationId_address_chainId_assetId` ON `operation_swaps` (`operationId`, `address`, `chainId`, `assetId`)") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/4_5_AddChainColor.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/4_5_AddChainColor.kt new file mode 100644 index 0000000..3bd9958 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/4_5_AddChainColor.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddChainColor_4_5 = object : Migration(4, 5) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chains ADD COLUMN color TEXT DEFAULT NULL") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/50_51_AddTransactionVersionToRuntime.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/50_51_AddTransactionVersionToRuntime.kt new file mode 100644 index 0000000..2f6e45a --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/50_51_AddTransactionVersionToRuntime.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddTransactionVersionToRuntime_50_51 = object : Migration(50, 51) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chain_runtimes ADD COLUMN transactionVersion INTEGER DEFAULT NULL") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/51_52_AddNewBalanceFieldToAssets.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/51_52_AddNewBalanceFieldToAssets.kt new file mode 100644 index 0000000..3fa2f6b --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/51_52_AddNewBalanceFieldToAssets.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.converters.AssetConverters +import io.novafoundation.nova.core_db.model.AssetLocal + +val AddBalanceModesToAssets_51_52 = object : Migration(51, 52) { + + override fun migrate(database: SupportSQLiteDatabase) { + val assetsConverters = AssetConverters() + val defaultTransferableMode = assetsConverters.fromTransferableMode(AssetLocal.defaultTransferableMode()) + val defaultEdCountingMode = assetsConverters.fromEdCountingMode(AssetLocal.defaultEdCountingMode()) + + database.execSQL("ALTER TABLE assets ADD COLUMN transferableMode INTEGER NOT NULL DEFAULT $defaultTransferableMode") + database.execSQL("ALTER TABLE assets ADD COLUMN edCountingMode INTEGER NOT NULL DEFAULT $defaultEdCountingMode") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/52_53_ChangeSessionTopicToParing.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/52_53_ChangeSessionTopicToParing.kt new file mode 100644 index 0000000..3550757 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/52_53_ChangeSessionTopicToParing.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val ChangeSessionTopicToParing_52_53 = object : Migration(52, 53) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP TABLE wallet_connect_sessions") + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `wallet_connect_pairings` ( + `pairingTopic` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + PRIMARY KEY(`pairingTopic`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """ + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_wallet_connect_pairings_metaId` ON `wallet_connect_pairings` (`metaId`)") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/53_54_AddConnectionStateToChains.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/53_54_AddConnectionStateToChains.kt new file mode 100644 index 0000000..fce21e9 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/53_54_AddConnectionStateToChains.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.model.chain.ChainLocal + +val AddConnectionStateToChains_53_54 = object : Migration(53, 54) { + override fun migrate(database: SupportSQLiteDatabase) { + val defaultConnectionState = ChainLocal.Default.CONNECTION_STATE_DEFAULT + + database.execSQL("ALTER TABLE chains ADD COLUMN connectionState TEXT NOT NULL DEFAULT '$defaultConnectionState'") + + // Enable full for chains that have some balance synced + database.execSQL( + """ + UPDATE chains SET connectionState = 'FULL_SYNC' + WHERE id IN ( + SELECT DISTINCT chainId + FROM assets + WHERE freeInPlanks > 0 OR frozenInPlanks > 0 OR reservedInPlanks > 0 + ) OR hasSubstrateRuntime = 0 + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/54_55_AddProxyAccount.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/54_55_AddProxyAccount.kt new file mode 100644 index 0000000..7847477 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/54_55_AddProxyAccount.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddProxyAccount_54_55 = object : Migration(54, 55) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `proxy_accounts` ( + `proxiedMetaId` INTEGER NOT NULL, + `proxyMetaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `proxiedAccountId` BLOB NOT NULL, + `proxyType` TEXT NOT NULL, + PRIMARY KEY(`proxyMetaId`, `proxiedAccountId`, `chainId`, `proxyType`), + FOREIGN KEY(`proxiedMetaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`proxyMetaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE ) + """.trimMargin() + ) + + database.execSQL("ALTER TABLE `meta_accounts` ADD COLUMN `status` TEXT NOT NULL DEFAULT 'ACTIVE'") + database.execSQL("ALTER TABLE `meta_accounts` ADD COLUMN `parentMetaId` INTEGER") + database.execSQL("ALTER TABLE `chains` ADD COLUMN `supportProxy` INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/55_56_AddFungibleNfts.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/55_56_AddFungibleNfts.kt new file mode 100644 index 0000000..a4cfca1 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/55_56_AddFungibleNfts.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddFungibleNfts_55_56 = object : Migration(55, 56) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("DROP INDEX IF EXISTS `index_nfts_metaId`") + database.execSQL("DROP TABLE nfts") + + /* ktlint-disable max-line-length */ + database.execSQL( + "CREATE TABLE IF NOT EXISTS `nfts` (`identifier` TEXT NOT NULL, `metaId` INTEGER NOT NULL, `chainId` TEXT NOT NULL, `collectionId` TEXT NOT NULL, `instanceId` TEXT, `metadata` BLOB, `type` TEXT NOT NULL, `wholeDetailsLoaded` INTEGER NOT NULL, `name` TEXT, `label` TEXT, `media` TEXT, `issuanceType` TEXT NOT NULL, `issuanceTotal` TEXT, `issuanceMyEdition` TEXT, `issuanceMyAmount` TEXT, `price` TEXT, `pricedUnits` TEXT, PRIMARY KEY(`identifier`))" + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_nfts_metaId` ON `nfts` (`metaId`)") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/56_57_ChainPushSupport.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/56_57_ChainPushSupport.kt new file mode 100644 index 0000000..93ba5d7 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/56_57_ChainPushSupport.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.model.chain.PUSH_DEFAULT_VALUE + +val ChainPushSupport_56_57 = object : Migration(56, 57) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `chains` ADD COLUMN `pushSupport` INTEGER NOT NULL DEFAULT $PUSH_DEFAULT_VALUE") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/57_58_AddLocalMigratorVersionToChainRuntimes.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/57_58_AddLocalMigratorVersionToChainRuntimes.kt new file mode 100644 index 0000000..5fbb4cd --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/57_58_AddLocalMigratorVersionToChainRuntimes.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddLocalMigratorVersionToChainRuntimes_57_58 = object : Migration(57, 58) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `chain_runtimes` ADD COLUMN `localMigratorVersion` INTEGER NOT NULL DEFAULT 1") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/58_59_AddGloballyUniqueIdToMetaAccounts.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/58_59_AddGloballyUniqueIdToMetaAccounts.kt new file mode 100644 index 0000000..f63e578 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/58_59_AddGloballyUniqueIdToMetaAccounts.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.common.utils.collectAll +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal + +val AddGloballyUniqueIdToMetaAccounts_58_59 = object : Migration(58, 59) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE meta_accounts ADD COLUMN globallyUniqueId TEXT NOT NULL DEFAULT ''") + + val ids = database.getAllMetaAccountIds() + + ids.forEach { id -> + val uuid = MetaAccountLocal.generateGloballyUniqueId() + database.execSQL("UPDATE meta_accounts SET globallyUniqueId = '$uuid' WHERE id = $id") + } + } + + private fun SupportSQLiteDatabase.getAllMetaAccountIds(): List { + val cursor = query("SELECT id FROM meta_accounts") + val column = cursor.getColumnIndex("id") + + val ids = cursor.collectAll { cursor.getLong(column) } + + cursor.close() + + return ids + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/59_60_ChainNetworkManagement.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/59_60_ChainNetworkManagement.kt new file mode 100644 index 0000000..29ece09 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/59_60_ChainNetworkManagement.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal + +val ChainNetworkManagement_59_60 = object : Migration(59, 60) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chains ADD COLUMN source TEXT NOT NULL DEFAULT '${ChainLocal.DEFAULT_NETWORK_SOURCE_STR}'") + database.execSQL("ALTER TABLE chain_nodes ADD COLUMN source TEXT NOT NULL DEFAULT '${ChainNodeLocal.DEFAULT_NODE_SOURCE_STR}'") + + // Create node preferences table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `node_selection_preferences` ( + `chainId` TEXT NOT NULL, + `autoBalanceEnabled` INTEGER NOT NULL DEFAULT ${NodeSelectionPreferencesLocal.DEFAULT_AUTO_BALANCE_DEFAULT_STR}, + `selectedNodeUrl` TEXT, PRIMARY KEY(`chainId`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_node_selection_preferences_chainId` ON `node_selection_preferences` (`chainId`)") + + // Fill new table with default values + database.execSQL( + """ + INSERT INTO node_selection_preferences (chainId, autobalanceEnabled, selectedNodeUrl) + SELECT id, ${NodeSelectionPreferencesLocal.DEFAULT_AUTO_BALANCE_DEFAULT_STR}, NULL FROM chains + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/5_6_AddNfts.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/5_6_AddNfts.kt new file mode 100644 index 0000000..5093e14 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/5_6_AddNfts.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddNfts_5_6 = object : Migration(5, 6) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `nfts` ( + `identifier` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `collectionId` TEXT NOT NULL, + `instanceId` TEXT, + `metadata` BLOB, + `type` TEXT NOT NULL, + `wholeDetailsLoaded` INTEGER NOT NULL, + `name` TEXT, + `label` TEXT, + `media` TEXT, + `issuanceTotal` INTEGER, + `issuanceMyEdition` TEXT, + `price` TEXT, + PRIMARY KEY(`identifier`)) + """.trimIndent() + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_nfts_metaId` ON `nfts` (`metaId`)") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/60_61_AddBalanceHolds.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/60_61_AddBalanceHolds.kt new file mode 100644 index 0000000..7a9751d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/60_61_AddBalanceHolds.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddBalanceHolds_60_61 = object : Migration(60, 61) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `holds` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `assetId` INTEGER NOT NULL, + `amount` TEXT NOT NULL, + `id_module` TEXT NOT NULL, + `id_reason` TEXT NOT NULL, + PRIMARY KEY(`metaId`, `chainId`, `assetId`, `id_module`, `id_reason`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , + FOREIGN KEY(`assetId`, `chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/61_62_ChainCustomFee.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/61_62_ChainCustomFee.kt new file mode 100644 index 0000000..42ddef0 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/61_62_ChainCustomFee.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val ChainNetworkManagement_61_62 = object : Migration(61, 62) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chains ADD COLUMN `customFee` TEXT NOT NULL DEFAULT ''") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/62_63_TinderGovBasket.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/62_63_TinderGovBasket.kt new file mode 100644 index 0000000..145720c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/62_63_TinderGovBasket.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val TinderGovBasket_62_63 = object : Migration(62, 63) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `tinder_gov_basket` ( + `referendumId` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `amount` TEXT NOT NULL, + `conviction` TEXT NOT NULL, + `voteType` TEXT NOT NULL, + PRIMARY KEY(`referendumId`, `metaId`, `chainId`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `tinder_gov_voting_power` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `amount` TEXT NOT NULL, + `conviction` TEXT NOT NULL, + PRIMARY KEY(`metaId`, `chainId`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/63_64_AddChainForeignKeyForProxy.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/63_64_AddChainForeignKeyForProxy.kt new file mode 100644 index 0000000..1e04855 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/63_64_AddChainForeignKeyForProxy.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddChainForeignKeyForProxy_63_64 = object : Migration(63, 64) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `proxy_accounts_new` ( + `proxiedMetaId` INTEGER NOT NULL, + `proxyMetaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `proxiedAccountId` BLOB NOT NULL, + `proxyType` TEXT NOT NULL, + PRIMARY KEY(`proxyMetaId`, `proxiedAccountId`, `chainId`, `proxyType`), + FOREIGN KEY(`proxiedMetaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`proxyMetaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """ + ) + + database.execSQL( + """ + INSERT INTO `proxy_accounts_new` (`proxiedMetaId`, `proxyMetaId`, `chainId`, `proxiedAccountId`, `proxyType`) + SELECT `proxiedMetaId`, `proxyMetaId`, `chainId`, `proxiedAccountId`, `proxyType` + FROM `proxy_accounts` + """ + ) + + database.execSQL("DROP TABLE `proxy_accounts`") + + database.execSQL("ALTER TABLE `proxy_accounts_new` RENAME TO `proxy_accounts`") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/64_65_AddBrowserTabs.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/64_65_AddBrowserTabs.kt new file mode 100644 index 0000000..3a94d17 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/64_65_AddBrowserTabs.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddBrowserTabs_64_65 = object : Migration(64, 65) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `browser_tabs` ( + `id` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + `currentUrl` TEXT NOT NULL, + `creationTime` INTEGER NOT NULL, + `pageName` TEXT, `pageIconPath` TEXT, + `pagePicturePath` TEXT, + `dappMetadata_iconLink` TEXT, + PRIMARY KEY(`id`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """ + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/65_66_AddFavoritesOrdering.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/65_66_AddFavoritesOrdering.kt new file mode 100644 index 0000000..c74f41f --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/65_66_AddFavoritesOrdering.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddFavoriteDAppsOrdering_65_66 = object : Migration(65, 66) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE favourite_dapps ADD COLUMN orderingIndex INTEGER NOT NULL DEFAULT 0") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/66_67_AddLegacyAddressPrefix.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/66_67_AddLegacyAddressPrefix.kt new file mode 100644 index 0000000..af923f9 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/66_67_AddLegacyAddressPrefix.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddLegacyAddressPrefix_66_67 = object : Migration(66, 67) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chains ADD COLUMN legacyPrefix INTEGER") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/67_68_AddSellProviders.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/67_68_AddSellProviders.kt new file mode 100644 index 0000000..459abe6 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/67_68_AddSellProviders.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddSellProviders_67_68 = object : Migration(67, 68) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chain_assets ADD COLUMN sellProviders TEXT") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/68_69_AddTypeExtrasToMetaAccount.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/68_69_AddTypeExtrasToMetaAccount.kt new file mode 100644 index 0000000..82c13d2 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/68_69_AddTypeExtrasToMetaAccount.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddTypeExtrasToMetaAccount_68_69 = object : Migration(68, 69) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE meta_accounts ADD COLUMN typeExtras TEXT") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/69_70_AddMultisigCalls.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/69_70_AddMultisigCalls.kt new file mode 100644 index 0000000..1053bba --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/69_70_AddMultisigCalls.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddMultisigCalls_69_70 = object : Migration(69, 70) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `multisig_operation_call` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `callHash` TEXT NOT NULL, + `callInstance` TEXT NOT NULL, + PRIMARY KEY(`metaId`, `chainId`, `callHash`), + FOREIGN KEY(`metaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/6_7_AddSitePhishing.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/6_7_AddSitePhishing.kt new file mode 100644 index 0000000..a29fd66 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/6_7_AddSitePhishing.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddSitePhishing_6_7 = object : Migration(6, 7) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `phishing_sites` (`host` TEXT NOT NULL, PRIMARY KEY(`host`))") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/70_71_AddMultisigSupportFlag.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/70_71_AddMultisigSupportFlag.kt new file mode 100644 index 0000000..c129635 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/70_71_AddMultisigSupportFlag.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddMultisigSupportFlag_70_71 = object : Migration(70, 71) { + override fun migrate(db: SupportSQLiteDatabase) { + // Add multisigSupport column to chains table + db.execSQL("ALTER TABLE `chains` ADD COLUMN `multisigSupport` INTEGER NOT NULL DEFAULT 0") + + // Update multisigSupport flag based on the presence of MULTISIG external API + db.execSQL( + """ + UPDATE chains SET multisigSupport = 1 + WHERE id IN ( + SELECT DISTINCT chainId FROM chain_external_apis + WHERE apiType = 'MULTISIG' + ) + """.trimIndent() + ) + + // Delete all multisig external apis + db.execSQL( + """ + DELETE FROM chain_external_apis + WHERE apiType = 'MULTISIG' + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/71_72_AddGifts.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/71_72_AddGifts.kt new file mode 100644 index 0000000..92d8924 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/71_72_AddGifts.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddGifts_71_72 = object : Migration(71, 72) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS `gifts` ( + `amount` TEXT NOT NULL, + `giftAccountId` BLOB NOT NULL, + `creatorMetaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `assetId` INTEGER NOT NULL, + `status` TEXT NOT NULL, + `creationDate` INTEGER NOT NULL, + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + FOREIGN KEY(`creatorMetaId`) REFERENCES `meta_accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , + FOREIGN KEY(`assetId`, `chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE ) + """.trimMargin() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/72_73_AddContributionUnlockBlock.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/72_73_AddContributionUnlockBlock.kt new file mode 100644 index 0000000..29b57e0 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/72_73_AddContributionUnlockBlock.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddFieldsToContributions = object : Migration(72, 73) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE contributions") + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `contributions` ( + `metaId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `assetId` INTEGER NOT NULL, + `paraId` TEXT NOT NULL, + `amountInPlanks` TEXT NOT NULL, + `sourceId` TEXT NOT NULL, + `unlockBlock` TEXT NOT NULL, + `leaseDepositor` BLOB NOT NULL, + PRIMARY KEY(`metaId`, `chainId`, `assetId`, `paraId`, `sourceId`)); + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/7_8_AddBuyProviders.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/7_8_AddBuyProviders.kt new file mode 100644 index 0000000..722f430 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/7_8_AddBuyProviders.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddBuyProviders_7_8 = object : Migration(7, 8) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE chain_assets ADD COLUMN buyProviders TEXT DEFAULT null") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/8_9_BetterChainDiffing.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/8_9_BetterChainDiffing.kt new file mode 100644 index 0000000..1759e21 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/8_9_BetterChainDiffing.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val BetterChainDiffing_8_9 = object : Migration(8, 9) { + + override fun migrate(database: SupportSQLiteDatabase) { + migrateAssets(database) + + migrateRuntimeVersions(database) + } + + private fun migrateRuntimeVersions(database: SupportSQLiteDatabase) { + // rename + database.execSQL("DROP INDEX `index_chain_runtimes_chainId`") + database.execSQL("ALTER TABLE chain_runtimes RENAME TO chain_runtimes_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `chain_runtimes` ( + `chainId` TEXT NOT NULL, + `syncedVersion` INTEGER NOT NULL, + `remoteVersion` INTEGER NOT NULL, + PRIMARY KEY(`chainId`), + FOREIGN KEY(`chainId`) REFERENCES `chains`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + database.execSQL("CREATE INDEX `index_chain_runtimes_chainId` ON `chain_runtimes` (`chainId`)") + + // insert to new from old + database.execSQL( + """ + INSERT INTO chain_runtimes + SELECT chainId, syncedVersion, remoteVersion + FROM chain_runtimes_old + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE chain_runtimes_old") + } + + private fun migrateAssets(database: SupportSQLiteDatabase) { + // rename + database.execSQL("DROP INDEX `index_assets_metaId`") + database.execSQL("ALTER TABLE assets RENAME TO assets_old") + + // new table + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `assets` ( + `assetId` INTEGER NOT NULL, + `chainId` TEXT NOT NULL, + `metaId` INTEGER NOT NULL, + `freeInPlanks` TEXT NOT NULL, + `frozenInPlanks` TEXT NOT NULL, + `reservedInPlanks` TEXT NOT NULL, + `bondedInPlanks` TEXT NOT NULL, + `redeemableInPlanks` TEXT NOT NULL, + `unbondingInPlanks` TEXT NOT NULL, + PRIMARY KEY(`assetId`,`chainId`,`metaId`), + FOREIGN KEY(`assetId`, `chainId`) REFERENCES `chain_assets`(`id`, `chainId`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_assets_metaId` ON `assets` (`metaId`)".trimIndent()) + + // insert to new from old + database.execSQL( + """ + INSERT INTO assets + SELECT + ca.id, ca.chainId, + a.metaId, a.freeInPlanks, a.frozenInPlanks, a.reservedInPlanks, a.bondedInPlanks, a.redeemableInPlanks, a.unbondingInPlanks + FROM assets_old AS a INNER JOIN chain_assets AS ca WHERE a.tokenSymbol = ca.symbol AND a.chainId = ca.chainId + """.trimIndent() + ) + + // delete old + database.execSQL("DROP TABLE assets_old") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/9_10_AddFavouriteDApps.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/9_10_AddFavouriteDApps.kt new file mode 100644 index 0000000..d450650 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/9_10_AddFavouriteDApps.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddFavouriteDApps_9_10 = object : Migration(9, 10) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `favourite_dapps` ( + `url` TEXT NOT NULL, + `label` TEXT NOT NULL, + `icon` TEXT, PRIMARY KEY(`url`) + ) + """.trimIndent() + ) + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/AccountLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/AccountLocal.kt new file mode 100644 index 0000000..dada759 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/AccountLocal.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.novafoundation.nova.core.model.Node + +@Entity(tableName = "users") +data class AccountLocal( + @PrimaryKey val address: String, + val username: String, + val publicKey: String, + val cryptoType: Int, + val position: Int, + val networkType: Node.NetworkType +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/AccountStakingLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/AccountStakingLocal.kt new file mode 100644 index 0000000..6dc2b48 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/AccountStakingLocal.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Embedded +import androidx.room.Entity + +@Suppress("EqualsOrHashCode") +@Entity( + tableName = "account_staking_accesses", + primaryKeys = ["chainId", "chainAssetId", "accountId"] +) +class AccountStakingLocal( + val chainId: String, + val chainAssetId: Int, + val accountId: ByteArray, + @Embedded + val stakingAccessInfo: AccessInfo? +) { + class AccessInfo(val stashId: ByteArray, val controllerId: ByteArray) { + + override fun equals(other: Any?): Boolean { + return other is AccessInfo && + stashId.contentEquals(other.stashId) && + controllerId.contentEquals(other.controllerId) + } + } + + override fun equals(other: Any?): Boolean { + return other is AccountStakingLocal && + other.chainId == chainId && + other.accountId.contentEquals(accountId) && + other.stakingAccessInfo == stakingAccessInfo && + other.chainAssetId == chainAssetId + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/AssetLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/AssetLocal.kt new file mode 100644 index 0000000..8c5fabf --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/AssetLocal.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import java.math.BigInteger + +@Entity( + tableName = "assets", + primaryKeys = ["assetId", "chainId", "metaId"], + foreignKeys = [ + ForeignKey( + entity = ChainAssetLocal::class, + parentColumns = ["id", "chainId"], + childColumns = ["assetId", "chainId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class AssetLocal( + val assetId: Int, + val chainId: String, + @ColumnInfo(index = true) val metaId: Long, + + val freeInPlanks: BigInteger, + val frozenInPlanks: BigInteger, + val reservedInPlanks: BigInteger, + + val transferableMode: TransferableModeLocal, + val edCountingMode: EDCountingModeLocal, + + // TODO move to runtime storage + val bondedInPlanks: BigInteger, + val redeemableInPlanks: BigInteger, + val unbondingInPlanks: BigInteger, +) : Identifiable { + + companion object { + + fun defaultTransferableMode(): TransferableModeLocal = TransferableModeLocal.REGULAR + + fun defaultEdCountingMode(): EDCountingModeLocal = EDCountingModeLocal.TOTAL + + fun createEmpty( + assetId: Int, + chainId: String, + metaId: Long + ) = AssetLocal( + assetId = assetId, + chainId = chainId, + metaId = metaId, + freeInPlanks = BigInteger.ZERO, + reservedInPlanks = BigInteger.ZERO, + transferableMode = defaultTransferableMode(), + edCountingMode = defaultEdCountingMode(), + frozenInPlanks = BigInteger.ZERO, + bondedInPlanks = BigInteger.ZERO, + redeemableInPlanks = BigInteger.ZERO, + unbondingInPlanks = BigInteger.ZERO, + ) + } + + enum class TransferableModeLocal { + REGULAR, HOLDS_AND_FREEZES + } + + enum class EDCountingModeLocal { + TOTAL, FREE + } + + override val identifier: String + get() = "$metaId:$chainId:$assetId" +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/AssetWithToken.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/AssetWithToken.kt new file mode 100644 index 0000000..db5609d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/AssetWithToken.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Embedded + +class AssetWithToken( + @Embedded + val asset: AssetLocal?, + + @Embedded + val token: TokenLocal?, + + @Embedded(prefix = "ca_") + val assetAndChainId: AssetAndChainId, + + @Embedded + val currency: CurrencyLocal +) + +data class AssetAndChainId( + val chainId: String, + val assetId: Int +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/BalanceHoldLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/BalanceHoldLocal.kt new file mode 100644 index 0000000..d769de1 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/BalanceHoldLocal.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import java.math.BigInteger + +@Entity( + tableName = "holds", + primaryKeys = ["metaId", "chainId", "assetId", "id_module", "id_reason"], + foreignKeys = [ + ForeignKey( + entity = MetaAccountLocal::class, + parentColumns = ["id"], + childColumns = ["metaId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = ChainAssetLocal::class, + parentColumns = ["id", "chainId"], + childColumns = ["assetId", "chainId"], + onDelete = ForeignKey.CASCADE + ) + ], +) +class BalanceHoldLocal( + val metaId: Long, + val chainId: String, + val assetId: Int, + @Embedded(prefix = "id_") val id: HoldIdLocal, + val amount: BigInteger +) { + + class HoldIdLocal(val module: String, val reason: String) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/BalanceLockLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/BalanceLockLocal.kt new file mode 100644 index 0000000..d3602fc --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/BalanceLockLocal.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import java.math.BigInteger + +@Entity( + tableName = "locks", + primaryKeys = ["metaId", "chainId", "assetId", "type"], + foreignKeys = [ + ForeignKey( + entity = MetaAccountLocal::class, + parentColumns = ["id"], + childColumns = ["metaId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = ChainAssetLocal::class, + parentColumns = ["id", "chainId"], + childColumns = ["assetId", "chainId"], + onDelete = ForeignKey.CASCADE + ) + ], +) +class BalanceLockLocal( + val metaId: Long, + val chainId: String, + val assetId: Int, + val type: String, + val amount: BigInteger +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/BrowserHostSettingsLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/BrowserHostSettingsLocal.kt new file mode 100644 index 0000000..75f2a4f --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/BrowserHostSettingsLocal.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.novafoundation.nova.common.utils.Identifiable + +@Entity(tableName = "browser_host_settings") +data class BrowserHostSettingsLocal( + @PrimaryKey + val hostUrl: String, + val isDesktopModeEnabled: Boolean +) : Identifiable { + @Ignore + override val identifier: String = hostUrl +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/BrowserTabLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/BrowserTabLocal.kt new file mode 100644 index 0000000..e6fd61b --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/BrowserTabLocal.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal + +@Entity( + tableName = "browser_tabs", + foreignKeys = [ + ForeignKey( + entity = MetaAccountLocal::class, + parentColumns = ["id"], + childColumns = ["metaId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class BrowserTabLocal( + @PrimaryKey(autoGenerate = false) val id: String, + val metaId: Long, + val currentUrl: String, + val creationTime: Long, + val pageName: String?, + val pageIconPath: String?, + @Embedded(prefix = "dappMetadata_") + val knownDAppMetadata: KnownDAppMetadata?, + val pagePicturePath: String? +) { + + class KnownDAppMetadata(val iconLink: String) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/CoinPriceLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/CoinPriceLocal.kt new file mode 100644 index 0000000..9d113e5 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/CoinPriceLocal.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import java.math.BigDecimal + +@Entity( + tableName = "coin_prices", + primaryKeys = ["priceId", "currencyId", "timestamp"] +) +data class CoinPriceLocal( + val priceId: String, + val currencyId: String, + val timestamp: Long, + val rate: BigDecimal +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/ContributionLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/ContributionLocal.kt new file mode 100644 index 0000000..c24c185 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/ContributionLocal.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import io.novafoundation.nova.common.utils.Identifiable +import java.math.BigInteger + +@Entity(tableName = "contributions", primaryKeys = ["metaId", "chainId", "assetId", "paraId", "sourceId"]) +class ContributionLocal( + val metaId: Long, + val chainId: String, + val assetId: Int, + val paraId: BigInteger, + val amountInPlanks: BigInteger, + val sourceId: String, + val unlockBlock: BigInteger, + val leaseDepositor: ByteArray // AccountId +) : Identifiable { + override val identifier: String + get() = "$metaId|$chainId|$paraId|$sourceId" +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/CurrencyLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/CurrencyLocal.kt new file mode 100644 index 0000000..d5f3c42 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/CurrencyLocal.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.novafoundation.nova.common.utils.Identifiable + +@Entity( + tableName = "currencies" +) +data class CurrencyLocal( + val code: String, + val name: String, + val symbol: String?, + val category: Category, + val popular: Boolean, + @PrimaryKey val id: Int, + val coingeckoId: String, + val selected: Boolean, +) : Identifiable { + + enum class Category { + FIAT, CRYPTO + } + + @Ignore + override val identifier: String = id.toString() +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/DappAuthorizationLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/DappAuthorizationLocal.kt new file mode 100644 index 0000000..b1ff942 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/DappAuthorizationLocal.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity + +@Entity( + tableName = "dapp_authorizations", + primaryKeys = ["baseUrl", "metaId"] +) +class DappAuthorizationLocal( + val baseUrl: String, + val metaId: Long, + val dAppTitle: String?, + val authorized: Boolean? +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/ExternalBalanceLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/ExternalBalanceLocal.kt new file mode 100644 index 0000000..1645496 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/ExternalBalanceLocal.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import java.math.BigInteger + +@Entity( + tableName = "externalBalances", + primaryKeys = ["metaId", "chainId", "assetId", "type", "subtype"], + foreignKeys = [ + ForeignKey( + entity = ChainAssetLocal::class, + parentColumns = ["id", "chainId"], + childColumns = ["assetId", "chainId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = MetaAccountLocal::class, + parentColumns = ["id"], + childColumns = ["metaId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +class ExternalBalanceLocal( + val metaId: Long, + val chainId: String, + val assetId: Int, + val type: Type, + val subtype: String, + val amount: BigInteger +) { + + companion object { + const val EMPTY_SUBTYPE = "" + } + + enum class Type { + CROWDLOAN, NOMINATION_POOL + } +} + +class AggregatedExternalBalanceLocal( + val chainId: String, + val assetId: Int, + val type: ExternalBalanceLocal.Type, + val aggregatedAmount: BigInteger +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/FavouriteDAppLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/FavouriteDAppLocal.kt new file mode 100644 index 0000000..bbc5f55 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/FavouriteDAppLocal.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.novafoundation.nova.common.utils.Identifiable + +@Entity(tableName = "favourite_dapps") +class FavouriteDAppLocal( + @PrimaryKey + val url: String, + val label: String, + val icon: String?, + @ColumnInfo(defaultValue = "0") + val orderingIndex: Int +) : Identifiable { + + @Ignore + override val identifier: String = url +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/GiftLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/GiftLocal.kt new file mode 100644 index 0000000..bfbdeb7 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/GiftLocal.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import java.math.BigInteger + +@Entity( + tableName = "gifts", + foreignKeys = [ + ForeignKey( + entity = MetaAccountLocal::class, + parentColumns = ["id"], + childColumns = ["creatorMetaId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = ChainAssetLocal::class, + parentColumns = ["id", "chainId"], + childColumns = ["assetId", "chainId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +class GiftLocal( + val amount: BigInteger, + val giftAccountId: ByteArray, + val creatorMetaId: Long, + val chainId: String, + val assetId: Int, + val status: Status, + val creationDate: Long +) { + @PrimaryKey(autoGenerate = true) + var id: Long = 0 + + enum class Status { + PENDING, + CLAIMED, + RECLAIMED + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/GovernanceDAppLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/GovernanceDAppLocal.kt new file mode 100644 index 0000000..8a4d6df --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/GovernanceDAppLocal.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import io.novafoundation.nova.common.utils.Identifiable + +@Entity(tableName = "governance_dapps", primaryKeys = ["chainId", "name"]) +data class GovernanceDAppLocal( + val chainId: String, + val name: String, + val referendumUrlV1: String?, + val referendumUrlV2: String?, + val iconUrl: String, + val details: String, +) : Identifiable { + + override val identifier: String + get() = "$chainId|$name" +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/MultisigOperationCallLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/MultisigOperationCallLocal.kt new file mode 100644 index 0000000..4d0fbae --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/MultisigOperationCallLocal.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal + +@Entity( + tableName = "multisig_operation_call", + primaryKeys = ["metaId", "chainId", "callHash"], + foreignKeys = [ + ForeignKey( + entity = MetaAccountLocal::class, + parentColumns = ["id"], + childColumns = ["metaId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class MultisigOperationCallLocal( + val metaId: Long, + val chainId: String, + val callHash: String, + val callInstance: String +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/NftLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/NftLocal.kt new file mode 100644 index 0000000..4571b6d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/NftLocal.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.utils.optionalContentEquals +import java.math.BigInteger + +@Entity(tableName = "nfts") +data class NftLocal( + @PrimaryKey + override val identifier: String, + @ColumnInfo(index = true) + val metaId: Long, + val chainId: String, + val collectionId: String, + val instanceId: String?, + val metadata: ByteArray?, + val type: Type, + + val wholeDetailsLoaded: Boolean, + + // --- metadata fields --- + val name: String? = null, + val label: String? = null, + val media: String? = null, + // --- !metadata fields --- + + val issuanceType: IssuanceType, + val issuanceTotal: BigInteger? = null, + val issuanceMyEdition: String? = null, + val issuanceMyAmount: BigInteger? = null, + + val price: BigInteger? = null, + // use null to indicate non-fungible price + val pricedUnits: BigInteger? = null +) : Identifiable { + + enum class Type { + UNIQUES, RMRK1, RMRK2, PDC20, KODADOT, UNIQUE_NETWORK + } + + enum class IssuanceType { + + // issuanceMyEdition: optional + UNLIMITED, + + // issuanceMyEdition + issuanceTotal + LIMITED, + + // issuanceTotal + issuanceFungible + FUNGIBLE + } + + override fun equals(other: Any?): Boolean { + return other is NftLocal && + identifier == other.identifier && + // metadata is either direct data or a link to immutable distributed storage + metadata.optionalContentEquals(other.metadata) && + price == other.price + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/NodeLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/NodeLocal.kt new file mode 100644 index 0000000..6173348 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/NodeLocal.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "nodes") +data class NodeLocal( + val name: String, + val link: String, + val networkType: Int, + val isDefault: Boolean, + val isActive: Boolean = false +) { + @PrimaryKey(autoGenerate = true) + var id: Int = 0 +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/PhishingAddressLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/PhishingAddressLocal.kt new file mode 100644 index 0000000..83f4aaf --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/PhishingAddressLocal.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "phishing_addresses") +data class PhishingAddressLocal( + @PrimaryKey val publicKey: String +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/PhishingSiteLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/PhishingSiteLocal.kt new file mode 100644 index 0000000..7ce4a99 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/PhishingSiteLocal.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "phishing_sites") +class PhishingSiteLocal( + @PrimaryKey val host: String +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/StakingDashboardItemLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/StakingDashboardItemLocal.kt new file mode 100644 index 0000000..c4de12e --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/StakingDashboardItemLocal.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +@Entity( + tableName = "staking_dashboard_items", + primaryKeys = ["chainId", "chainAssetId", "stakingType", "metaId"], + foreignKeys = [ + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = ChainAssetLocal::class, + parentColumns = ["id", "chainId"], + childColumns = ["chainAssetId", "chainId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = MetaAccountLocal::class, + parentColumns = ["id"], + childColumns = ["metaId"], + onDelete = ForeignKey.CASCADE + ) + ], +) +class StakingDashboardItemLocal( + val chainId: String, + val chainAssetId: Int, + val stakingType: String, + @ColumnInfo(index = true) val metaId: Long, + val hasStake: Boolean, + val stake: BigInteger?, + val status: Status?, + val rewards: BigInteger?, + val estimatedEarnings: Double?, + val stakeStatusAccount: AccountId?, + val rewardsAccount: AccountId?, +) { + + companion object { + + fun notStaking( + chainId: String, + chainAssetId: Int, + stakingType: String, + metaId: Long, + estimatedEarnings: Double? + ) = StakingDashboardItemLocal( + chainId = chainId, + chainAssetId = chainAssetId, + stakingType = stakingType, + metaId = metaId, + hasStake = false, + stake = null, + status = null, + rewards = null, + estimatedEarnings = estimatedEarnings, + stakeStatusAccount = null, + rewardsAccount = null + ) + + fun staking( + chainId: String, + chainAssetId: Int, + stakingType: String, + stake: BigInteger, + stakeStatusAccount: AccountId, + rewardsAccount: AccountId, + metaId: Long, + status: Status?, + rewards: BigInteger?, + estimatedEarnings: Double? + ) = StakingDashboardItemLocal( + chainId = chainId, + chainAssetId = chainAssetId, + stakingType = stakingType, + metaId = metaId, + hasStake = true, + stake = stake, + status = status, + rewards = rewards, + estimatedEarnings = estimatedEarnings, + stakeStatusAccount = stakeStatusAccount, + rewardsAccount = rewardsAccount + ) + } + + enum class Status { + ACTIVE, INACTIVE, WAITING + } +} + +class StakingDashboardAccountsView( + val chainId: String, + val chainAssetId: Int, + val stakingType: String, + val stakeStatusAccount: AccountId?, + val rewardsAccount: AccountId? +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/StakingRewardPeriodLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/StakingRewardPeriodLocal.kt new file mode 100644 index 0000000..81ef99b --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/StakingRewardPeriodLocal.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import io.novasama.substrate_sdk_android.runtime.AccountId + +@Entity(tableName = "staking_reward_period", primaryKeys = ["accountId", "chainId", "assetId", "stakingType"]) +class StakingRewardPeriodLocal( + val accountId: AccountId, + val chainId: String, + val assetId: Int, + val stakingType: String, + val periodType: String, + val customPeriodStart: Long?, + val customPeriodEnd: Long? +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/StorageEntryLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/StorageEntryLocal.kt new file mode 100644 index 0000000..7f4d17c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/StorageEntryLocal.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity + +@Entity( + tableName = "storage", + primaryKeys = ["chainId", "storageKey"] +) +class StorageEntryLocal( + val storageKey: String, + val content: String?, + val chainId: String, +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/TinderGovBasketItemLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TinderGovBasketItemLocal.kt new file mode 100644 index 0000000..c6ac4dc --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TinderGovBasketItemLocal.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.common.ConvictionLocal +import java.math.BigInteger + +@Entity( + tableName = "tinder_gov_basket", + primaryKeys = ["referendumId", "metaId", "chainId"], + foreignKeys = [ + ForeignKey( + parentColumns = ["id"], + childColumns = ["metaId"], + entity = MetaAccountLocal::class, + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + parentColumns = ["id"], + childColumns = ["chainId"], + entity = ChainLocal::class, + onDelete = ForeignKey.CASCADE + ) + ] +) +data class TinderGovBasketItemLocal( + val referendumId: BigInteger, + val metaId: Long, + val chainId: String, + val amount: BigInteger, + val conviction: ConvictionLocal, + val voteType: VoteType +) { + + enum class VoteType { + AYE, NAY, ABSTAIN + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/TinderGovVotingPowerLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TinderGovVotingPowerLocal.kt new file mode 100644 index 0000000..c03b21e --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TinderGovVotingPowerLocal.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.common.ConvictionLocal +import java.math.BigInteger + +@Entity( + tableName = "tinder_gov_voting_power", + primaryKeys = ["metaId", "chainId"], + foreignKeys = [ + ForeignKey( + parentColumns = ["id"], + childColumns = ["metaId"], + entity = MetaAccountLocal::class, + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + parentColumns = ["id"], + childColumns = ["chainId"], + entity = ChainLocal::class, + onDelete = ForeignKey.CASCADE + ) + ] +) +data class TinderGovVotingPowerLocal( + val metaId: Long, + val chainId: String, + val amount: BigInteger, + val conviction: ConvictionLocal +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/TokenLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TokenLocal.kt new file mode 100644 index 0000000..43dfcd7 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TokenLocal.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import io.novafoundation.nova.common.utils.Identifiable +import java.math.BigDecimal + +@Entity(tableName = "tokens", primaryKeys = ["tokenSymbol", "currencyId"]) +data class TokenLocal( + val tokenSymbol: String, + val rate: BigDecimal?, + val currencyId: Int, + val recentRateChange: BigDecimal?, +) : Identifiable { + companion object { + fun createEmpty(symbol: String, currencyId: Int): TokenLocal = TokenLocal(symbol, null, currencyId, null) + } + + override val identifier: String + get() = "$tokenSymbol:$currencyId" +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/TokenWithCurrency.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TokenWithCurrency.kt new file mode 100644 index 0000000..5ed933d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TokenWithCurrency.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Embedded + +class TokenWithCurrency( + @Embedded + val token: TokenLocal?, + + @Embedded + val currency: CurrencyLocal +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/TotalRewardLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TotalRewardLocal.kt new file mode 100644 index 0000000..8ae6cee --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/TotalRewardLocal.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.Entity +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +@Entity( + tableName = "total_reward", + primaryKeys = ["chainId", "chainAssetId", "stakingType", "accountId"] +) +class TotalRewardLocal( + val accountId: AccountId, + val chainId: String, + val chainAssetId: Int, + val stakingType: String, + val totalReward: BigInteger +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/WalletConnectPairingLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/WalletConnectPairingLocal.kt new file mode 100644 index 0000000..0035e68 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/WalletConnectPairingLocal.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.core_db.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal + +@Entity( + tableName = "wallet_connect_pairings", + foreignKeys = [ + ForeignKey( + parentColumns = ["id"], + childColumns = ["metaId"], + entity = MetaAccountLocal::class, + onDelete = ForeignKey.CASCADE + ), + ] +) +class WalletConnectPairingLocal( + @PrimaryKey + val pairingTopic: String, + @ColumnInfo(index = true) + val metaId: Long +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/AssetSourceLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/AssetSourceLocal.kt new file mode 100644 index 0000000..528d24c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/AssetSourceLocal.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.core_db.model.chain + +enum class AssetSourceLocal { + DEFAULT, ERC20, MANUAL +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainAssetLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainAssetLocal.kt new file mode 100644 index 0000000..9c6aa42 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainAssetLocal.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.core_db.model.chain + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.Index +import io.novafoundation.nova.common.utils.Identifiable + +@Entity( + tableName = "chain_assets", + primaryKeys = ["chainId", "id"], + foreignKeys = [ + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["chainId"]) + ] +) +data class ChainAssetLocal( + val id: Int, + val chainId: String, + val name: String, + val symbol: String, + val priceId: String?, + val staking: String, + val precision: Int, + val icon: String?, + val type: String?, + @ColumnInfo(defaultValue = SOURCE_DEFAULT) + val source: AssetSourceLocal, + val buyProviders: String?, + val sellProviders: String?, + val typeExtras: String?, + @ColumnInfo(defaultValue = ENABLED_DEFAULT_STR) + val enabled: Boolean, +) : Identifiable { + + companion object { + + const val SOURCE_DEFAULT = "DEFAULT" + const val ENABLED_DEFAULT_STR = "1" + const val ENABLED_DEFAULT_BOOL = true + } + + @Ignore + override val identifier: String = "$id:$chainId" +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainExplorerLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainExplorerLocal.kt new file mode 100644 index 0000000..b8f7d13 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainExplorerLocal.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.core_db.model.chain + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.Index +import io.novafoundation.nova.common.utils.Identifiable + +@Entity( + tableName = "chain_explorers", + primaryKeys = ["chainId", "name"], + foreignKeys = [ + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["chainId"]) + ] +) +data class ChainExplorerLocal( + val chainId: String, + val name: String, + val extrinsic: String?, + val account: String?, + val event: String? +) : Identifiable { + + @Ignore + override val identifier: String = "$chainId:$name" +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainExternalApiLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainExternalApiLocal.kt new file mode 100644 index 0000000..e1b2425 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainExternalApiLocal.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.core_db.model.chain + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.Index +import io.novafoundation.nova.common.utils.Identifiable + +@Entity( + tableName = "chain_external_apis", + primaryKeys = ["chainId", "url", "apiType"], + foreignKeys = [ + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["chainId"]) + ] +) +data class ChainExternalApiLocal( + val chainId: String, + val sourceType: SourceType, + val apiType: ApiType, + val parameters: String?, + val url: String +) : Identifiable { + + enum class SourceType { + SUBQUERY, GITHUB, POLKASSEMBLY, ETHERSCAN, SUBSQUARE, + UNKNOWN + } + + enum class ApiType { + TRANSFERS, STAKING, STAKING_REWARDS, CROWDLOANS, + GOVERNANCE_REFERENDA, GOVERNANCE_DELEGATIONS, + REFERENDUM_SUMMARY, + + UNKNOWN + } + + @Ignore + override val identifier: String = "$chainId:$url:$apiType" +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt new file mode 100644 index 0000000..79975b0 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.core_db.model.chain + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core_db.model.chain.ChainLocal.Default.NODE_SELECTION_STRATEGY_DEFAULT + +const val PUSH_DEFAULT_VALUE = "0" + +@Entity(tableName = "chains") +data class ChainLocal( + @PrimaryKey val id: String, + val parentId: String?, + val name: String, + val icon: String, + @Embedded + val types: TypesConfig?, + val prefix: Int, + val legacyPrefix: Int?, + val isEthereumBased: Boolean, + val isTestNet: Boolean, + @ColumnInfo(defaultValue = "1") + val hasSubstrateRuntime: Boolean, + @ColumnInfo(defaultValue = PUSH_DEFAULT_VALUE) + val pushSupport: Boolean, + val hasCrowdloans: Boolean, + @ColumnInfo(defaultValue = "0") + val supportProxy: Boolean, + @ColumnInfo(defaultValue = "0") + val multisigSupport: Boolean, + val swap: String, + val customFee: String, + val governance: String, + val additional: String?, + val connectionState: ConnectionStateLocal, + @Deprecated("Use autoBalanceStrategy") + @ColumnInfo(defaultValue = NODE_SELECTION_STRATEGY_DEFAULT) + val nodeSelectionStrategy: AutoBalanceStrategyLocal, + @ColumnInfo(defaultValue = DEFAULT_NETWORK_SOURCE_STR) + val source: Source +) : Identifiable { + + @Suppress("DEPRECATION") + val autoBalanceStrategy: AutoBalanceStrategyLocal + get() = nodeSelectionStrategy + + companion object { + + const val EMPTY_CHAIN_ICON = "" + + const val DEFAULT_NETWORK_SOURCE_STR = "DEFAULT" + } + + enum class AutoBalanceStrategyLocal { + ROUND_ROBIN, UNIFORM, UNKNOWN + } + + enum class ConnectionStateLocal { + FULL_SYNC, LIGHT_SYNC, DISABLED + } + + enum class Source { + DEFAULT, CUSTOM + } + + object Default { + + const val NODE_SELECTION_STRATEGY_DEFAULT = "ROUND_ROBIN" + + const val HAS_SUBSTRATE_RUNTIME = 1 + + const val CONNECTION_STATE_DEFAULT = "LIGHT_SYNC" + } + + @Ignore + override val identifier: String = id + + data class TypesConfig( + val url: String, + val overridesCommon: Boolean, + ) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainNodeLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainNodeLocal.kt new file mode 100644 index 0000000..3eb03f3 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainNodeLocal.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.core_db.model.chain + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.Index +import io.novafoundation.nova.common.utils.Identifiable + +@Entity( + tableName = "chain_nodes", + primaryKeys = ["chainId", "url"], + foreignKeys = [ + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["chainId"]) + ] +) +data class ChainNodeLocal( + val chainId: String, + val url: String, + val name: String, + @ColumnInfo(defaultValue = "0") + val orderId: Int, + @ColumnInfo(defaultValue = DEFAULT_NODE_SOURCE_STR) + val source: Source +) : Identifiable { + + companion object { + + const val DEFAULT_NODE_SOURCE_STR = "DEFAULT" + } + + enum class Source { + DEFAULT, CUSTOM + } + + @Ignore + override val identifier: String = "$chainId:$url" +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainRuntimeInfoLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainRuntimeInfoLocal.kt new file mode 100644 index 0000000..a272a09 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainRuntimeInfoLocal.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.core_db.model.chain + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +@Entity( + tableName = "chain_runtimes", + primaryKeys = ["chainId"], + indices = [ + Index(value = ["chainId"]) + ], + foreignKeys = [ + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +class ChainRuntimeInfoLocal( + val chainId: String, + val syncedVersion: Int, + val remoteVersion: Int, + val transactionVersion: Int?, + val localMigratorVersion: Int +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/JoinedChainInfo.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/JoinedChainInfo.kt new file mode 100644 index 0000000..884f106 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/JoinedChainInfo.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.core_db.model.chain + +import androidx.room.Embedded +import androidx.room.Relation + +class JoinedChainInfo( + @Embedded + val chain: ChainLocal, + + @Relation(parentColumn = "id", entityColumn = "chainId", entity = NodeSelectionPreferencesLocal::class) + val nodeSelectionPreferences: NodeSelectionPreferencesLocal?, + + @Relation(parentColumn = "id", entityColumn = "chainId", entity = ChainNodeLocal::class) + val nodes: List, + + @Relation(parentColumn = "id", entityColumn = "chainId", entity = ChainAssetLocal::class) + val assets: List, + + @Relation(parentColumn = "id", entityColumn = "chainId", entity = ChainExplorerLocal::class) + val explorers: List, + + @Relation(parentColumn = "id", entityColumn = "chainId", entity = ChainExternalApiLocal::class) + val externalApis: List +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/NodeSelectionPreferencesLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/NodeSelectionPreferencesLocal.kt new file mode 100644 index 0000000..39df554 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/NodeSelectionPreferencesLocal.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.core_db.model.chain + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import androidx.room.Index +import io.novafoundation.nova.common.utils.Identifiable + +@Entity( + tableName = "node_selection_preferences", + primaryKeys = ["chainId"], + foreignKeys = [ + ForeignKey( + entity = ChainLocal::class, + parentColumns = ["id"], + childColumns = ["chainId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index(value = ["chainId"]) + ] +) +data class NodeSelectionPreferencesLocal( + val chainId: String, + @ColumnInfo(defaultValue = DEFAULT_AUTO_BALANCE_DEFAULT_STR) + val autoBalanceEnabled: Boolean, + @Deprecated("Use [selectedUnformattedWssNodeUrl]") + val selectedNodeUrl: String? +) : Identifiable { + + @Suppress("DEPRECATION") + val selectedUnformattedWssNodeUrl: String? + get() = selectedNodeUrl + + companion object { + + const val DEFAULT_AUTO_BALANCE_DEFAULT_STR = "1" + const val DEFAULT_AUTO_BALANCE_BOOLEAN = true + } + + @Ignore + override val identifier: String = chainId +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/ChainAccountLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/ChainAccountLocal.kt new file mode 100644 index 0000000..c263615 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/ChainAccountLocal.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.core_db.model.chain.account + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import io.novafoundation.nova.core.model.CryptoType + +@Entity( + tableName = "chain_accounts", + foreignKeys = [ + // no foreign key for `chainId` since we do not want ChainAccounts to be deleted or modified when chain is deleted + // but rather keep it in db in case future UI will show them somehow + + ForeignKey( + parentColumns = ["id"], + childColumns = ["metaId"], + entity = MetaAccountLocal::class, + onDelete = ForeignKey.CASCADE + ), + ], + indices = [ + Index(value = ["chainId"]), + Index(value = ["metaId"]), + Index(value = ["accountId"]), + ], + primaryKeys = ["metaId", "chainId"] +) +class ChainAccountLocal( + val metaId: Long, + val chainId: String, + val publicKey: ByteArray?, + val accountId: ByteArray, + val cryptoType: CryptoType?, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ChainAccountLocal) return false + + if (metaId != other.metaId) return false + if (chainId != other.chainId) return false + if (!publicKey.contentEquals(other.publicKey)) return false + if (!accountId.contentEquals(other.accountId)) return false + if (cryptoType != other.cryptoType) return false + + return true + } + + override fun hashCode(): Int { + var result = metaId.hashCode() + result = 31 * result + chainId.hashCode() + result = 31 * result + (publicKey?.contentHashCode() ?: 0) + result = 31 * result + accountId.contentHashCode() + result = 31 * result + (cryptoType?.hashCode() ?: 0) + return result + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/JoinedMetaAccountInfo.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/JoinedMetaAccountInfo.kt new file mode 100644 index 0000000..23ae6c4 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/JoinedMetaAccountInfo.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.core_db.model.chain.account + +import androidx.room.Embedded +import androidx.room.Relation + +interface JoinedMetaAccountInfo { + + val metaAccount: MetaAccountLocal + + val chainAccounts: List + + val proxyAccountLocal: ProxyAccountLocal? +} + +data class RelationJoinedMetaAccountInfo( + @Embedded + override val metaAccount: MetaAccountLocal, + + @Relation(parentColumn = "id", entityColumn = "metaId", entity = ChainAccountLocal::class) + override val chainAccounts: List, + + @Relation(parentColumn = "id", entityColumn = "proxiedMetaId", entity = ProxyAccountLocal::class) + override val proxyAccountLocal: ProxyAccountLocal?, +) : JoinedMetaAccountInfo diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountIdsLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountIdsLocal.kt new file mode 100644 index 0000000..a99b33d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountIdsLocal.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.core_db.model.chain.account + +class MetaAccountIdsLocal( + val globallyUniqueId: String, + val id: Long +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt new file mode 100644 index 0000000..e72c53d --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt @@ -0,0 +1,159 @@ +package io.novafoundation.nova.core_db.model.chain.account + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.model.common.SerializedJson +import java.util.UUID + +/* + TODO: on next migration please add following changes: + - Foreign key for parentMetaId to remove proxy meta account automatically when proxied is deleted + - Foreign key to ProxyAccountLocal to remove proxies meta accounts automatically when chain is deleted + */ +// NB!: We intentionally do not make MetaAccountLocal a data-class since it is easy to misuse copy due to value of `id` is not being copied +// All copy-like methods should be implemented explicitly, like `addEvmAccount` +@Entity( + tableName = MetaAccountLocal.TABLE_NAME, + indices = [ + Index(value = ["substrateAccountId"]), + Index(value = ["ethereumAddress"]) + ] +) +class MetaAccountLocal( + val substratePublicKey: ByteArray?, + val substrateCryptoType: CryptoType?, + val substrateAccountId: ByteArray?, + val ethereumPublicKey: ByteArray?, + val ethereumAddress: ByteArray?, + val name: String, + val parentMetaId: Long?, + val isSelected: Boolean, + val position: Int, + val type: Type, + @ColumnInfo(defaultValue = "ACTIVE") + val status: Status, + val globallyUniqueId: String, + val typeExtras: SerializedJson? +) { + + enum class Status { + ACTIVE, DEACTIVATED + } + + companion object Table { + const val TABLE_NAME = "meta_accounts" + + object Column { + const val SUBSTRATE_PUBKEY = "substratePublicKey" + const val SUBSTRATE_CRYPTO_TYPE = "substrateCryptoType" + const val SUBSTRATE_ACCOUNT_ID = "substrateAccountId" + + const val ETHEREUM_PUBKEY = "ethereumPublicKey" + const val ETHEREUM_ADDRESS = "ethereumAddress" + + const val NAME = "name" + const val IS_SELECTED = "isSelected" + const val POSITION = "position" + const val ID = "id" + } + + fun generateGloballyUniqueId(): String { + return UUID.randomUUID().toString() + } + } + + // We do not use copy as we need explicitly set id + fun addEvmAccount( + ethereumPublicKey: ByteArray, + ethereumAddress: ByteArray, + ): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumPublicKey = ethereumPublicKey, + ethereumAddress = ethereumAddress, + name = name, + parentMetaId = parentMetaId, + isSelected = isSelected, + position = position, + type = type, + status = status, + globallyUniqueId = globallyUniqueId, + typeExtras = typeExtras + ).also { + it.id = id + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MetaAccountLocal) return false + + if (id != other.id) return false + + if (!substratePublicKey.contentEquals(other.substratePublicKey)) return false + if (substrateCryptoType != other.substrateCryptoType) return false + if (!substrateAccountId.contentEquals(other.substrateAccountId)) return false + if (!ethereumPublicKey.contentEquals(other.ethereumPublicKey)) return false + if (!ethereumAddress.contentEquals(other.ethereumAddress)) return false + if (name != other.name) return false + if (parentMetaId != other.parentMetaId) return false + if (isSelected != other.isSelected) return false + if (position != other.position) return false + if (type != other.type) return false + if (status != other.status) return false + if (globallyUniqueId != other.globallyUniqueId) return false + if (typeExtras != other.typeExtras) return false + + return true + } + + override fun hashCode(): Int { + var result = substratePublicKey?.contentHashCode() ?: 0 + result = 31 * result + id.hashCode() + result = 31 * result + (substrateCryptoType?.hashCode() ?: 0) + result = 31 * result + (substrateAccountId?.contentHashCode() ?: 0) + result = 31 * result + (ethereumPublicKey?.contentHashCode() ?: 0) + result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0) + result = 31 * result + name.hashCode() + result = 31 * result + (parentMetaId?.hashCode() ?: 0) + result = 31 * result + isSelected.hashCode() + result = 31 * result + position + result = 31 * result + type.hashCode() + result = 31 * result + status.hashCode() + result = 31 * result + globallyUniqueId.hashCode() + result = 31 * result + (typeExtras?.hashCode() ?: 0) + result = 31 * result + id.hashCode() + return result + } + + @PrimaryKey(autoGenerate = true) + var id: Long = 0 + + enum class Type { + SECRETS, + WATCH_ONLY, + PARITY_SIGNER, + + // We did not rename LEDGER -> LEDGER_LEGACY as in domain to avoid writing a migration + LEDGER, + LEDGER_GENERIC, + POLKADOT_VAULT, + PROXIED, + MULTISIG + } +} + +class MetaAccountPositionUpdate( + val id: Long, + val position: Int +) + +data class MetaAccountIdWithType( + val id: Long, + val type: MetaAccountLocal.Type +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MultisigTypeExtras.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MultisigTypeExtras.kt new file mode 100644 index 0000000..606bcd1 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MultisigTypeExtras.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.core_db.model.chain.account + +import com.google.gson.annotations.JsonAdapter +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.AccountIdKeyListAdapter +import io.novafoundation.nova.common.address.AccountIdSerializer + +class MultisigTypeExtras( + @JsonAdapter(AccountIdKeyListAdapter::class) + val otherSignatories: List, + val threshold: Int, + @JsonAdapter(AccountIdSerializer::class) + val signatoryAccountId: AccountIdKey +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/ProxyAccountLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/ProxyAccountLocal.kt new file mode 100644 index 0000000..4d27224 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/ProxyAccountLocal.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.core_db.model.chain.account + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Ignore +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novasama.substrate_sdk_android.extensions.toHexString + +@Entity( + tableName = "proxy_accounts", + foreignKeys = [ + ForeignKey( + parentColumns = ["id"], + childColumns = ["proxiedMetaId"], + entity = MetaAccountLocal::class, + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + parentColumns = ["id"], + childColumns = ["proxyMetaId"], + entity = MetaAccountLocal::class, + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + parentColumns = ["id"], + childColumns = ["chainId"], + entity = ChainLocal::class, + onDelete = ForeignKey.CASCADE + ), + ], + primaryKeys = ["proxyMetaId", "proxiedAccountId", "chainId", "proxyType"] +) +data class ProxyAccountLocal( + val proxiedMetaId: Long, + val proxyMetaId: Long, + val chainId: String, + @Deprecated("Unused") + val proxiedAccountId: ByteArray, + val proxyType: String +) : Identifiable { + + @Ignore + override val identifier: String = makeIdentifier(proxyMetaId, chainId, proxiedAccountId, proxyType) + + companion object { + fun makeIdentifier( + proxyMetaId: Long, + chainId: String, + proxiedAccountId: ByteArray, + proxyType: String + ): String { + return "$proxyMetaId:$chainId:${proxiedAccountId.toHexString()}:$proxyType" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ProxyAccountLocal) return false + + if (proxiedMetaId != other.proxiedMetaId) return false + if (proxyMetaId != other.proxyMetaId) return false + if (chainId != other.chainId) return false + if (!proxiedAccountId.contentEquals(other.proxiedAccountId)) return false + if (proxyType != other.proxyType) return false + if (identifier != other.identifier) return false + + return true + } + + override fun hashCode(): Int { + var result = proxiedMetaId.hashCode() + result = 31 * result + proxyMetaId.hashCode() + result = 31 * result + chainId.hashCode() + result = 31 * result + proxiedAccountId.contentHashCode() + result = 31 * result + proxyType.hashCode() + result = 31 * result + identifier.hashCode() + return result + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/common/ConvictionLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/common/ConvictionLocal.kt new file mode 100644 index 0000000..7676028 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/common/ConvictionLocal.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.core_db.model.common + +enum class ConvictionLocal { + NONE, + LOCKED_1X, + LOCKED_2X, + LOCKED_3X, + LOCKED_4X, + LOCKED_5X, + LOCKED_6X +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/common/SerializedJson.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/common/SerializedJson.kt new file mode 100644 index 0000000..36b7814 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/common/SerializedJson.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.core_db.model.common + +typealias SerializedJson = String diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/ExtrinsicTypeLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/ExtrinsicTypeLocal.kt new file mode 100644 index 0000000..c1f13e2 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/ExtrinsicTypeLocal.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.core_db.model.operation + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import io.novafoundation.nova.core_db.model.operation.OperationTypeLocal.OperationForeignKey +import java.math.BigInteger + +@Entity( + tableName = "operation_extrinsics", + primaryKeys = ["operationId", "address", "chainId", "assetId"], + indices = [ + Index("operationId", "address", "chainId", "assetId") + ], + foreignKeys = [ + ForeignKey( + entity = OperationBaseLocal::class, + parentColumns = ["id", "address", "chainId", "assetId"], + childColumns = ["operationId", "address", "chainId", "assetId"], + onDelete = ForeignKey.CASCADE, + deferred = true, + ) + ] +) +class ExtrinsicTypeLocal( + @Embedded + override val foreignKey: OperationForeignKey, + val contentType: ContentType, + val module: String, + val call: String?, + val fee: BigInteger, +) : OperationTypeLocal { + + enum class ContentType { + SUBSTRATE_CALL, SMART_CONTRACT_CALL + } +} + +class ExtrinsicTypeJoin( + val contentType: ExtrinsicTypeLocal.ContentType, + val module: String, + val call: String?, + val fee: BigInteger, +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationBaseLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationBaseLocal.kt new file mode 100644 index 0000000..d66a8c4 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationBaseLocal.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.core_db.model.operation + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import io.novafoundation.nova.core_db.model.AssetAndChainId + +@Entity( + tableName = "operations", + primaryKeys = ["id", "address", "chainId", "assetId"], +) +data class OperationBaseLocal( + val id: String, + val address: String, + @Embedded + val assetId: AssetAndChainId, + val time: Long, + val status: Status, + val source: Source, + @ColumnInfo(index = true) + val hash: String?, +) { + + enum class Source { + BLOCKCHAIN, REMOTE, APP + } + + enum class Status { + PENDING, COMPLETED, FAILED + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationJoin.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationJoin.kt new file mode 100644 index 0000000..115ed1e --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationJoin.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.core_db.model.operation + +import androidx.room.Embedded + +class OperationJoin( + @Embedded(prefix = "o_") + val base: OperationBaseLocal, + @Embedded(prefix = "t_") + val transfer: TransferTypeJoin?, + @Embedded(prefix = "rd_") + val directReward: DirectRewardTypeJoin?, + @Embedded(prefix = "rp_") + val poolReward: PoolRewardTypeJoin?, + @Embedded(prefix = "s_") + val swap: SwapTypeJoin?, + @Embedded(prefix = "e_") + val extrinsic: ExtrinsicTypeJoin?, +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationLocal.kt new file mode 100644 index 0000000..6de16e3 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationLocal.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.core_db.model.operation + +import io.novafoundation.nova.core_db.model.AssetAndChainId +import io.novafoundation.nova.core_db.model.operation.OperationTypeLocal.OperationForeignKey +import java.math.BigInteger + +class OperationLocal( + val base: OperationBaseLocal, + val type: OperationTypeLocal +) { + + companion object { + + @Suppress("UnnecessaryVariable") + fun manualTransfer( + hash: String, + address: String, + chainId: String, + chainAssetId: Int, + amount: BigInteger, + senderAddress: String, + receiverAddress: String, + fee: BigInteger?, + status: OperationBaseLocal.Status, + source: OperationBaseLocal.Source + ): OperationLocal { + val assetId = AssetAndChainId(chainId, chainAssetId) + val id = hash + + return OperationLocal( + base = OperationBaseLocal( + id = id, + address = address, + assetId = assetId, + time = System.currentTimeMillis(), + status = status, + source = source, + hash = hash + ), + type = TransferTypeLocal( + foreignKey = OperationForeignKey( + address = address, + operationId = id, + assetId = assetId + ), + sender = senderAddress, + receiver = receiverAddress, + fee = fee, + amount = amount, + ) + ) + } + + @Suppress("UnnecessaryVariable") + fun manualSwap( + hash: String, + originAddress: String, + assetId: AssetAndChainId, + fee: SwapTypeLocal.AssetWithAmount, + amountIn: SwapTypeLocal.AssetWithAmount, + amountOut: SwapTypeLocal.AssetWithAmount, + status: OperationBaseLocal.Status, + source: OperationBaseLocal.Source + ): OperationLocal { + val id = hash // we're ok here even if remote data source uses other ids since we clear operations by transactionHash in OperationDao + + return OperationLocal( + base = OperationBaseLocal( + id = id, + address = originAddress, + assetId = assetId, + time = System.currentTimeMillis(), + status = status, + source = source, + hash = hash + ), + type = SwapTypeLocal( + foreignKey = OperationForeignKey( + address = originAddress, + operationId = id, + assetId = assetId + ), + fee = fee, + assetIn = amountIn, + assetOut = amountOut + ) + ) + } + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationTypeLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationTypeLocal.kt new file mode 100644 index 0000000..c50fb1f --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/OperationTypeLocal.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.core_db.model.operation + +import androidx.room.Embedded +import io.novafoundation.nova.core_db.model.AssetAndChainId + +sealed interface OperationTypeLocal { + + val foreignKey: OperationForeignKey + + class OperationForeignKey( + val operationId: String, + val address: String, + @Embedded + val assetId: AssetAndChainId + ) +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/RewardTypeLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/RewardTypeLocal.kt new file mode 100644 index 0000000..3591dda --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/RewardTypeLocal.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.core_db.model.operation + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import io.novafoundation.nova.core_db.model.operation.OperationTypeLocal.OperationForeignKey +import java.math.BigInteger + +sealed interface RewardTypeLocal : OperationTypeLocal { + + val isReward: Boolean + + val amount: BigInteger + + val eventId: String +} + +@Entity( + tableName = "operation_rewards_direct", + primaryKeys = ["operationId", "address", "chainId", "assetId"], + indices = [ + Index("operationId", "address", "chainId", "assetId") + ], + foreignKeys = [ + ForeignKey( + entity = OperationBaseLocal::class, + parentColumns = ["id", "address", "chainId", "assetId"], + childColumns = ["operationId", "address", "chainId", "assetId"], + onDelete = ForeignKey.CASCADE, + deferred = true, + ) + ] +) +class DirectRewardTypeLocal( + @Embedded + override val foreignKey: OperationForeignKey, + override val isReward: Boolean, + override val amount: BigInteger, + override val eventId: String, + val era: Int?, + val validator: String?, +) : RewardTypeLocal + +class DirectRewardTypeJoin( + val isReward: Boolean, + val amount: BigInteger, + val eventId: String, + val era: Int?, + val validator: String?, +) + +@Entity( + tableName = "operation_rewards_pool", + primaryKeys = ["operationId", "address", "chainId", "assetId"], + indices = [ + Index("operationId", "address", "chainId", "assetId") + ], + foreignKeys = [ + ForeignKey( + entity = OperationBaseLocal::class, + parentColumns = ["id", "address", "chainId", "assetId"], + childColumns = ["operationId", "address", "chainId", "assetId"], + onDelete = ForeignKey.CASCADE, + deferred = true, + ) + ] +) +class PoolRewardTypeLocal( + @Embedded + override val foreignKey: OperationForeignKey, + override val isReward: Boolean, + override val amount: BigInteger, + override val eventId: String, + val poolId: Int +) : RewardTypeLocal + +class PoolRewardTypeJoin( + val eventId: String, + val isReward: Boolean, + val amount: BigInteger, + val poolId: Int +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/SwapTypeLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/SwapTypeLocal.kt new file mode 100644 index 0000000..7b3d01e --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/SwapTypeLocal.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.core_db.model.operation + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import io.novafoundation.nova.core_db.model.AssetAndChainId +import io.novafoundation.nova.core_db.model.operation.OperationTypeLocal.OperationForeignKey +import java.math.BigInteger + +@Entity( + tableName = "operation_swaps", + primaryKeys = ["operationId", "address", "chainId", "assetId"], + indices = [ + Index("operationId", "address", "chainId", "assetId") + ], + foreignKeys = [ + ForeignKey( + entity = OperationBaseLocal::class, + parentColumns = ["id", "address", "chainId", "assetId"], + childColumns = ["operationId", "address", "chainId", "assetId"], + onDelete = ForeignKey.CASCADE, + deferred = true, + ) + ] +) +class SwapTypeLocal( + @Embedded + override val foreignKey: OperationForeignKey, + @Embedded(prefix = "fee_") + val fee: AssetWithAmount, + @Embedded(prefix = "assetIn_") + val assetIn: AssetWithAmount, + @Embedded(prefix = "assetOut_") + val assetOut: AssetWithAmount +) : OperationTypeLocal { + + class AssetWithAmount( + @Embedded + val assetId: AssetAndChainId, + val amount: BigInteger + ) +} + +class SwapTypeJoin( + @Embedded(prefix = "fee_") + val fee: SwapTypeLocal.AssetWithAmount, + @Embedded(prefix = "assetIn_") + val assetIn: SwapTypeLocal.AssetWithAmount, + @Embedded(prefix = "assetOut_") + val assetOut: SwapTypeLocal.AssetWithAmount +) diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/TransferTypeLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/TransferTypeLocal.kt new file mode 100644 index 0000000..aa1d296 --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/operation/TransferTypeLocal.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.core_db.model.operation + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import io.novafoundation.nova.core_db.model.operation.OperationTypeLocal.OperationForeignKey +import java.math.BigInteger + +@Entity( + tableName = "operation_transfers", + primaryKeys = ["operationId", "address", "chainId", "assetId"], + indices = [ + Index("operationId", "address", "chainId", "assetId") + ], + foreignKeys = [ + ForeignKey( + entity = OperationBaseLocal::class, + parentColumns = ["id", "address", "chainId", "assetId"], + childColumns = ["operationId", "address", "chainId", "assetId"], + onDelete = ForeignKey.CASCADE, + deferred = true, + ) + ] +) +class TransferTypeLocal( + @Embedded + override val foreignKey: OperationForeignKey, + val amount: BigInteger, + val sender: String, + val receiver: String, + val fee: BigInteger? +) : OperationTypeLocal + +class TransferTypeJoin( + val amount: BigInteger, + val sender: String, + val receiver: String, + val fee: BigInteger? +) diff --git a/design/nevroz_fire_logo.svg b/design/nevroz_fire_logo.svg new file mode 100644 index 0000000..ebeb354 --- /dev/null +++ b/design/nevroz_fire_logo.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/nevroz_fire_logo_static.svg b/design/nevroz_fire_logo_static.svg new file mode 100644 index 0000000..3f14945 --- /dev/null +++ b/design/nevroz_fire_logo_static.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/pezkuwi_launcher_icon.svg b/design/pezkuwi_launcher_icon.svg new file mode 100644 index 0000000..32acfd1 --- /dev/null +++ b/design/pezkuwi_launcher_icon.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/DijitalKurdistan.png b/docs/DijitalKurdistan.png new file mode 100644 index 0000000..aa2fb43 Binary files /dev/null and b/docs/DijitalKurdistan.png differ diff --git a/docs/PACKAGE_STRUCTURE_REBRAND.md b/docs/PACKAGE_STRUCTURE_REBRAND.md new file mode 100644 index 0000000..39dfcbc --- /dev/null +++ b/docs/PACKAGE_STRUCTURE_REBRAND.md @@ -0,0 +1,190 @@ +# Package Structure Rebrand Guide + +**Tarih:** 2026-01-23 +**Durum:** BEKLEMEDE - Büyük değişiklik, dikkatli planlama gerektirir + +--- + +## Mevcut Durum + +| Öğe | Sayı | +|-----|------| +| `io.novafoundation` package referansları | ~49,041 | +| Etkilenen Kotlin/Java dosyaları | ~2,000+ | +| Module sayısı | 65+ | + +--- + +## Hedef Dönüşüm + +``` +io.novafoundation.nova → io.pezkuwichain.wallet +``` + +### Örnekler: + +| Mevcut | Hedef | +|--------|-------| +| `io.novafoundation.nova.app` | `io.pezkuwichain.wallet.app` | +| `io.novafoundation.nova.common` | `io.pezkuwichain.wallet.common` | +| `io.novafoundation.nova.feature_wallet_api` | `io.pezkuwichain.wallet.feature_wallet_api` | +| `io.novafoundation.nova.runtime` | `io.pezkuwichain.wallet.runtime` | + +--- + +## Değişiklik Kapsamı + +### 1. Dizin Yapısı Değişikliği + +Her modülde: +``` +src/main/java/io/novafoundation/nova/ + ↓ +src/main/java/io/pezkuwichain/wallet/ +``` + +### 2. Package Declaration Değişikliği + +Her Kotlin/Java dosyasının ilk satırı: +```kotlin +// ÖNCE: +package io.novafoundation.nova.feature_wallet_api.domain + +// SONRA: +package io.pezkuwichain.wallet.feature_wallet_api.domain +``` + +### 3. Import Statement Değişikliği + +```kotlin +// ÖNCE: +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_account_api.domain.model.Account + +// SONRA: +import io.pezkuwichain.wallet.common.utils.Event +import io.pezkuwichain.wallet.feature_account_api.domain.model.Account +``` + +--- + +## Otomatik Rebrand Script + +```bash +#!/bin/bash +# package_rebrand.sh +# DIKKAT: Bu script'i çalıştırmadan önce backup alın! + +WALLET_DIR="/home/mamostehp/pezWallet/pezkuwi-wallet-android" +OLD_PACKAGE="io.novafoundation.nova" +NEW_PACKAGE="io.pezkuwichain.wallet" +OLD_PATH="io/novafoundation/nova" +NEW_PATH="io/pezkuwichain/wallet" + +# 1. Dizin yapısını değiştir +find "$WALLET_DIR" -type d -path "*/$OLD_PATH" | while read dir; do + new_dir=$(echo "$dir" | sed "s|$OLD_PATH|$NEW_PATH|g") + mkdir -p "$(dirname "$new_dir")" + mv "$dir" "$new_dir" +done + +# 2. Package declarations ve imports değiştir +find "$WALLET_DIR" -type f \( -name "*.kt" -o -name "*.java" \) | while read file; do + sed -i "s|$OLD_PACKAGE|$NEW_PACKAGE|g" "$file" +done + +# 3. build.gradle namespace'lerini kontrol et (zaten yapıldı) +# grep -rn "namespace" --include="*.gradle" "$WALLET_DIR" + +echo "Rebrand tamamlandı. Build test edin." +``` + +--- + +## Riskler ve Dikkat Edilmesi Gerekenler + +### 1. Android Resource ID'leri +- `R.drawable.*`, `R.string.*` gibi resource referansları etkilenmez +- Ama `BuildConfig` referansları güncellenebilir + +### 2. Dagger/Hilt Dependency Injection +- Component, Module, Scope annotation'ları +- Generated kod yeniden oluşturulmalı (clean build) + +### 3. Room Database +- Entity, DAO sınıfları +- Migration'lar kontrol edilmeli + +### 4. ProGuard/R8 +- `proguard-rules.pro` dosyalarındaki referanslar + +### 5. AndroidManifest.xml +- Activity, Service, Provider tanımları +- Intent filter'lar + +### 6. Test Dosyaları +- `androidTest` ve `test` klasörlerindeki dosyalar da değişmeli + +--- + +## Önerilen Yaklaşım + +### Faz 1: Hazırlık (1-2 gün) +1. [ ] Mevcut durumun tam backup'ı +2. [ ] Tüm testlerin geçtiğini doğrula +3. [ ] CI/CD pipeline'ı geçici olarak durdur + +### Faz 2: Otomatik Dönüşüm (2-4 saat) +1. [ ] Script'i çalıştır +2. [ ] Build hatalarını kontrol et +3. [ ] IDE'de proje yapısını yenile (Invalidate Caches) + +### Faz 3: Manuel Düzeltmeler (1-2 gün) +1. [ ] Build hatalarını düzelt +2. [ ] Dagger/Hilt generated kod sorunları +3. [ ] ProGuard kuralları güncelle + +### Faz 4: Test (1 gün) +1. [ ] Unit test'leri çalıştır +2. [ ] Integration test'leri çalıştır +3. [ ] Manual UI testing +4. [ ] APK build ve install test + +### Faz 5: Finalize +1. [ ] Commit ve push +2. [ ] CI/CD'yi yeniden aktif et +3. [ ] Release build test + +--- + +## Alternatif: Kademeli Rebrand + +Eğer tek seferde yapmak riskli görünüyorsa: + +1. **Modül bazlı değişiklik** - Her modülü ayrı ayrı rebrand et +2. **Alias kullanımı** - Geçiş döneminde typealias ile uyumluluk +3. **Git branch** - Ayrı bir branch'te çalış, test et, merge et + +--- + +## Zaten Tamamlanan İşler + +✅ Gradle namespace'ler: `io.novafoundation.nova.*` → `io.pezkuwichain.wallet.*` +✅ Display name'ler: "Nova Wallet" → "Pezkuwi Wallet" +✅ Deep link scheme: `novawallet://` → `pezkuwiwallet://` +✅ JavaScript interface: `Nova_*` → `Pezkuwi_*` +✅ Backup dosya adları: `novawallet_backup.json` → `pezkuwiwallet_backup.json` +✅ User-Agent: "Nova Wallet (Android)" → "Pezkuwi Wallet (Android)" +✅ Nevroz fire branding asset'leri + +--- + +## Sonuç + +Bu değişiklik büyük ve riskli. Yapılması tavsiye edilir ama dikkatli planlama ile: + +1. **Şu an için:** Mevcut durum çalışır durumda, build alınabilir +2. **Kısa vadede:** Package structure değişikliği planlanmalı +3. **Uzun vadede:** Tamamen `io.pezkuwichain.wallet` kullanılmalı + +**Öneri:** Önce mevcut haliyle release build alıp test edin. Ardından bu değişikliği ayrı bir sprint'te planlayın. diff --git a/docs/PEZ_Token_Logo_512.png b/docs/PEZ_Token_Logo_512.png new file mode 100644 index 0000000..3fd2180 Binary files /dev/null and b/docs/PEZ_Token_Logo_512.png differ diff --git a/docs/Pezkuwi.png b/docs/Pezkuwi.png new file mode 100644 index 0000000..b114952 Binary files /dev/null and b/docs/Pezkuwi.png differ diff --git a/docs/PezkuwiExplorer.png b/docs/PezkuwiExplorer.png new file mode 100644 index 0000000..e3d5f1e Binary files /dev/null and b/docs/PezkuwiExplorer.png differ diff --git a/docs/PezkuwiJS.png b/docs/PezkuwiJS.png new file mode 100644 index 0000000..996d96a Binary files /dev/null and b/docs/PezkuwiJS.png differ diff --git a/docs/PezkuwiStaking.png b/docs/PezkuwiStaking.png new file mode 100644 index 0000000..0400e55 Binary files /dev/null and b/docs/PezkuwiStaking.png differ diff --git a/docs/Pezsnowbridge.png b/docs/Pezsnowbridge.png new file mode 100644 index 0000000..247da01 Binary files /dev/null and b/docs/Pezsnowbridge.png differ diff --git a/docs/USDT(hez)logo.png b/docs/USDT(hez)logo.png new file mode 100644 index 0000000..130b563 Binary files /dev/null and b/docs/USDT(hez)logo.png differ diff --git a/docs/hez_token_512.png b/docs/hez_token_512.png new file mode 100644 index 0000000..808f590 Binary files /dev/null and b/docs/hez_token_512.png differ diff --git a/docs/ic_create_wallet_background.png b/docs/ic_create_wallet_background.png new file mode 100644 index 0000000..b1dc1e2 Binary files /dev/null and b/docs/ic_create_wallet_background.png differ diff --git a/docs/ic_import_option_trust_wallet.png b/docs/ic_import_option_trust_wallet.png new file mode 100644 index 0000000..59362a0 Binary files /dev/null and b/docs/ic_import_option_trust_wallet.png differ diff --git a/docs/ic_launcher.png b/docs/ic_launcher.png new file mode 100644 index 0000000..dd59c8b Binary files /dev/null and b/docs/ic_launcher.png differ diff --git a/docs/ic_nevruz_logo.png b/docs/ic_nevruz_logo.png new file mode 100644 index 0000000..8652763 Binary files /dev/null and b/docs/ic_nevruz_logo.png differ diff --git a/docs/ic_pezkuwi_card_logo.png b/docs/ic_pezkuwi_card_logo.png new file mode 100644 index 0000000..8e5200b Binary files /dev/null and b/docs/ic_pezkuwi_card_logo.png differ diff --git a/docs/ic_pezkuwi_launcher.png b/docs/ic_pezkuwi_launcher.png new file mode 100644 index 0000000..cada654 Binary files /dev/null and b/docs/ic_pezkuwi_launcher.png differ diff --git a/docs/nevruz_sun_nobuckground1.png b/docs/nevruz_sun_nobuckground1.png new file mode 100644 index 0000000..5ad4cf1 Binary files /dev/null and b/docs/nevruz_sun_nobuckground1.png differ diff --git a/docs/nevruz_sun_with_pezkuwi_wallet.png b/docs/nevruz_sun_with_pezkuwi_wallet.png new file mode 100644 index 0000000..392f04f Binary files /dev/null and b/docs/nevruz_sun_with_pezkuwi_wallet.png differ diff --git a/docs/nevruz_sun_withbuckground.png b/docs/nevruz_sun_withbuckground.png new file mode 100644 index 0000000..615330d Binary files /dev/null and b/docs/nevruz_sun_withbuckground.png differ diff --git a/docs/pezkuwi_asset_hub_logo.png b/docs/pezkuwi_asset_hub_logo.png new file mode 100644 index 0000000..01f54d2 Binary files /dev/null and b/docs/pezkuwi_asset_hub_logo.png differ diff --git a/docs/pezkuwi_logo.png b/docs/pezkuwi_logo.png new file mode 100644 index 0000000..51ee7bd Binary files /dev/null and b/docs/pezkuwi_logo.png differ diff --git a/docs/pezkuwi_people_logo.png b/docs/pezkuwi_people_logo.png new file mode 100644 index 0000000..256f153 Binary files /dev/null and b/docs/pezkuwi_people_logo.png differ diff --git a/feature-account-api/.gitignore b/feature-account-api/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/feature-account-api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature-account-api/build.gradle b/feature-account-api/build.gradle new file mode 100644 index 0000000..bd875e2 --- /dev/null +++ b/feature-account-api/build.gradle @@ -0,0 +1,44 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_account_api' + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + implementation project(":feature-currency-api") + implementation project(":feature-proxy-api") + implementation project(':feature-cloud-backup-api') + implementation project(':feature-ledger-api') + implementation project(':feature-xcm:api') + implementation project(':web3names') + implementation project(':feature-deep-linking') + + implementation liveDataKtxDep + implementation lifeCycleKtxDep + implementation androidDep + implementation materialDep + + implementation web3jDep + +// implementation fragmentKtxDep + + implementation constraintDep + + implementation daggerDep + ksp daggerCompiler + + api substrateSdkDep + + api project(':core-api') +} \ No newline at end of file diff --git a/feature-account-api/src/main/AndroidManifest.xml b/feature-account-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/feature-account-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/cloudBackup/Extensions.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/cloudBackup/Extensions.kt new file mode 100644 index 0000000..70a3d31 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/cloudBackup/Extensions.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_api.data.cloudBackup + +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup + +fun CloudBackup.WalletPublicInfo.Type.toMetaAccountType(): LightMetaAccount.Type { + return when (this) { + CloudBackup.WalletPublicInfo.Type.SECRETS -> LightMetaAccount.Type.SECRETS + CloudBackup.WalletPublicInfo.Type.WATCH_ONLY -> LightMetaAccount.Type.WATCH_ONLY + CloudBackup.WalletPublicInfo.Type.PARITY_SIGNER -> LightMetaAccount.Type.PARITY_SIGNER + CloudBackup.WalletPublicInfo.Type.LEDGER -> LightMetaAccount.Type.LEDGER_LEGACY + CloudBackup.WalletPublicInfo.Type.POLKADOT_VAULT -> LightMetaAccount.Type.POLKADOT_VAULT + CloudBackup.WalletPublicInfo.Type.LEDGER_GENERIC -> LightMetaAccount.Type.LEDGER + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/cloudBackup/LocalAccountsCloudBackupFacade.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/cloudBackup/LocalAccountsCloudBackupFacade.kt new file mode 100644 index 0000000..ae91cfa --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/cloudBackup/LocalAccountsCloudBackupFacade.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_account_api.data.cloudBackup + +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.localVsCloudDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategyFactory +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CannotApplyNonDestructiveDiff +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +const val CLOUD_BACKUP_APPLY_SOURCE = "CLOUD_BACKUP_APPLY_SOURCE" + +interface LocalAccountsCloudBackupFacade { + + /** + * Constructs full backup instance, including sensitive information + * Should only be used when full backup instance is needed, for example when writing backup to cloud. Otherwise use [publicBackupInfoFromLocalSnapshot] + * + * Important note: Should only be called as the result of direct user interaction! + * We don't want to exposure secrets to RAM until user explicitly directs app to do so + */ + suspend fun fullBackupInfoFromLocalSnapshot(): CloudBackup + + /** + * Constructs partial backup instance, including only the public information (addresses, metadata e.t.c) + * + * Can be used without direct user interaction (e.g. in background) to compare backup states between local and remote sources + */ + suspend fun publicBackupInfoFromLocalSnapshot(): CloudBackup.PublicData + + /** + * Creates a backup from external input. Useful for creating initial backup + */ + suspend fun constructCloudBackupForFirstWallet( + metaAccount: MetaAccountLocal, + baseSecrets: EncodableStruct, + ): CloudBackup + + /** + * Check if it is possible to apply given [diff] to local state in non-destructive manner + * In other words, whether it is possible to apply backup without notifying the user + */ + suspend fun canPerformNonDestructiveApply(diff: CloudBackupDiff): Boolean + + /** + * Applies cloud version of the backup to the local state. + * This is a destructive action as may overwrite or delete secrets stored in the app + * + * Important note: Should only be called as the result of direct user interaction! + */ + suspend fun applyBackupDiff(diff: CloudBackupDiff, cloudVersion: CloudBackup) +} + +/** + * Attempts to apply cloud backup version to current local application state in non-destructive manner + * Will do nothing if it is not possible to apply changes in non-destructive manner + * + * @return whether the attempt succeeded + */ +suspend fun LocalAccountsCloudBackupFacade.applyNonDestructiveCloudVersionOrThrow( + cloudVersion: CloudBackup, + diffStrategy: BackupDiffStrategyFactory +): CloudBackupDiff { + val localSnapshot = publicBackupInfoFromLocalSnapshot() + val diff = localSnapshot.localVsCloudDiff(cloudVersion.publicData, diffStrategy) + + return if (canPerformNonDestructiveApply(diff)) { + applyBackupDiff(diff, cloudVersion) + + diff + } else { + throw CannotApplyNonDestructiveDiff(diff, cloudVersion) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/conversion/assethub/AssetConversionApi.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/conversion/assethub/AssetConversionApi.kt new file mode 100644 index 0000000..8d8cfeb --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/conversion/assethub/AssetConversionApi.kt @@ -0,0 +1,29 @@ +@file:Suppress("RedundantUnitExpression") + +package io.novafoundation.nova.feature_account_api.data.conversion.assethub + +import io.novafoundation.nova.common.data.network.runtime.binding.bindPair +import io.novafoundation.nova.common.utils.assetConversionOrNull +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.bindMultiLocation +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class AssetConversionApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.assetConversionOrNull: AssetConversionApi? + get() = assetConversionOrNull()?.let(::AssetConversionApi) + +context(StorageQueryContext) +val AssetConversionApi.pools: QueryableStorageEntry1, Unit> + get() = storage1( + name = "Pools", + binding = { _, _ -> Unit }, + keyBinding = { bindPair(it, ::bindMultiLocation, ::bindMultiLocation) } + ) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/derivationPath/DerivationPathDecoder.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/derivationPath/DerivationPathDecoder.kt new file mode 100644 index 0000000..ebbb155 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/derivationPath/DerivationPathDecoder.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_account_api.data.derivationPath + +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.junction.JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.junction.SubstrateJunctionDecoder + +object DerivationPathDecoder { + + @Throws + fun decodeEthereumDerivationPath(derivationPath: String?): JunctionDecoder.DecodeResult? { + if (derivationPath.isNullOrEmpty()) return null + + return BIP32JunctionDecoder.decode(derivationPath) + } + + @Throws + fun decodeSubstrateDerivationPath(derivationPath: String?): JunctionDecoder.DecodeResult? { + if (derivationPath.isNullOrEmpty()) return null + + return SubstrateJunctionDecoder.decode(derivationPath) + } + + fun isEthereumDerivationPathValid(derivationPath: String?): Boolean { + return try { + decodeEthereumDerivationPath(derivationPath) + true + } catch (e: Exception) { + false + } + } + + fun isSubstrateDerivationPathValid(derivationPath: String?): Boolean { + return try { + decodeSubstrateDerivationPath(derivationPath) + true + } catch (e: Exception) { + false + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/EthereumTransactionExecution.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/EthereumTransactionExecution.kt new file mode 100644 index 0000000..67004f8 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/EthereumTransactionExecution.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.data.ethereum.transaction + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy + +class EthereumTransactionExecution( + val extrinsicHash: String, + val blockHash: BlockHash, + val submissionHierarchy: SubmissionHierarchy +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/EvmTransactionService.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/EvmTransactionService.kt new file mode 100644 index 0000000..6a6bbe4 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/EvmTransactionService.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_account_api.data.ethereum.transaction + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import org.web3j.tx.gas.DefaultGasProvider +import java.math.BigInteger + +typealias EvmTransactionBuilding = EvmTransactionBuilder.() -> Unit + +interface EvmTransactionService { + + suspend fun calculateFee( + chainId: ChainId, + origin: TransactionOrigin, + fallbackGasLimit: BigInteger = DefaultGasProvider.GAS_LIMIT, + building: EvmTransactionBuilding, + ): Fee + + suspend fun transact( + chainId: ChainId, + presetFee: Fee?, + origin: TransactionOrigin, + fallbackGasLimit: BigInteger = DefaultGasProvider.GAS_LIMIT, + building: EvmTransactionBuilding, + ): Result + + suspend fun transactAndAwaitExecution( + chainId: ChainId, + presetFee: Fee?, + origin: TransactionOrigin, + fallbackGasLimit: BigInteger, + building: EvmTransactionBuilding + ): Result +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/TransactionHash.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/TransactionHash.kt new file mode 100644 index 0000000..73e7bf8 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/TransactionHash.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_account_api.data.ethereum.transaction + +typealias TransactionHash = String diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/TransactionOrigin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/TransactionOrigin.kt new file mode 100644 index 0000000..b0a1ea2 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/ethereum/transaction/TransactionOrigin.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_api.data.ethereum.transaction + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novasama.substrate_sdk_android.runtime.AccountId + +sealed class TransactionOrigin { + + data object SelectedWallet : TransactionOrigin() + + class WalletWithAccount(val accountId: AccountId) : TransactionOrigin() + + class Wallet(val metaAccount: MetaAccount) : TransactionOrigin() + + class WalletWithId(val metaId: Long) : TransactionOrigin() +} + +fun AccountId.intoOrigin(): TransactionOrigin = TransactionOrigin.WalletWithAccount(this) + +fun MetaAccount.intoOrigin(): TransactionOrigin = TransactionOrigin.Wallet(this) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/events/MetaAccountChangesEventBus.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/events/MetaAccountChangesEventBus.kt new file mode 100644 index 0000000..01a4b86 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/events/MetaAccountChangesEventBus.kt @@ -0,0 +1,150 @@ +package io.novafoundation.nova.feature_account_api.data.events + +import io.novafoundation.nova.common.utils.bus.EventBus +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus.Event +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount + +interface MetaAccountChangesEventBus : EventBus { + + sealed interface Event : EventBus.Event { + + data class BatchUpdate(val updates: Collection) : Event + + data class AccountAdded(override val metaId: Long, override val metaAccountType: LightMetaAccount.Type) : Event, SingleUpdateEvent + + data class AccountStructureChanged(override val metaId: Long, override val metaAccountType: LightMetaAccount.Type) : Event, SingleUpdateEvent + + data class AccountRemoved(override val metaId: Long, override val metaAccountType: LightMetaAccount.Type) : Event, SingleUpdateEvent + + data class AccountNameChanged(override val metaId: Long, override val metaAccountType: LightMetaAccount.Type) : Event, SingleUpdateEvent + } + + interface SingleUpdateEvent { + + val metaId: Long + + val metaAccountType: LightMetaAccount.Type + } + + interface EventVisitor { + + fun visitAccountAdded(added: Event.AccountAdded) {} + + fun visitAccountStructureChanged(structureChanged: Event.AccountStructureChanged) {} + + fun visitAccountNameChanged(accountNameChanged: Event.AccountNameChanged) {} + + fun visitAccountRemoved(accountRemoved: Event.AccountRemoved) {} + } +} + +inline fun buildChangesEvent(builder: MutableList.() -> Unit): Event? { + val allEvents = buildList(builder) + return allEvents.combineBusEvents() +} + +fun List.combineBusEvents(): Event? { + return when (size) { + 0 -> null + 1 -> single() + else -> Event.BatchUpdate(this) + } +} + +fun Event.visit(visitor: MetaAccountChangesEventBus.EventVisitor) { + when (this) { + is Event.AccountAdded -> visitor.visitAccountAdded(this) + is Event.AccountNameChanged -> visitor.visitAccountNameChanged(this) + is Event.AccountRemoved -> visitor.visitAccountRemoved(this) + is Event.AccountStructureChanged -> visitor.visitAccountStructureChanged(this) + is Event.BatchUpdate -> updates.onEach { it.visit(visitor) } + } +} + +typealias EventBusEventCollator = (E) -> T? + +fun Event.collect( + onAdd: EventBusEventCollator? = null, + onStructureChanged: EventBusEventCollator? = null, + onNameChanged: EventBusEventCollator? = null, + onRemoved: EventBusEventCollator? = null, +): List { + val result = mutableListOf() + + visit(object : MetaAccountChangesEventBus.EventVisitor { + override fun visitAccountAdded(added: Event.AccountAdded) { + onAdd?.invoke(added)?.let(result::add) + } + + override fun visitAccountStructureChanged(structureChanged: Event.AccountStructureChanged) { + onStructureChanged?.invoke(structureChanged)?.let(result::add) + } + + override fun visitAccountNameChanged(accountNameChanged: Event.AccountNameChanged) { + onNameChanged?.invoke(accountNameChanged)?.let(result::add) + } + + override fun visitAccountRemoved(accountRemoved: Event.AccountRemoved) { + onRemoved?.invoke(accountRemoved)?.let(result::add) + } + }) + + return result +} + +typealias SingleAccountEventVisitor = (T) -> Unit + +fun Event.visit( + onAdd: SingleAccountEventVisitor? = null, + onStructureChanged: SingleAccountEventVisitor? = null, + onNameChanged: SingleAccountEventVisitor? = null, + onRemoved: SingleAccountEventVisitor? = null, +) { + visit(object : MetaAccountChangesEventBus.EventVisitor { + override fun visitAccountAdded(added: Event.AccountAdded) { + onAdd?.invoke(added) + } + + override fun visitAccountStructureChanged(structureChanged: Event.AccountStructureChanged) { + onStructureChanged?.invoke(structureChanged) + } + + override fun visitAccountNameChanged(accountNameChanged: Event.AccountNameChanged) { + onNameChanged?.invoke(accountNameChanged) + } + + override fun visitAccountRemoved(accountRemoved: Event.AccountRemoved) { + onRemoved?.invoke(accountRemoved) + } + }) +} + +fun Event.checkIncludes( + checkAdd: Boolean = false, + checkStructureChange: Boolean = false, + checkNameChange: Boolean = false, + checkAccountRemoved: Boolean = false +): Boolean { + var includes = false + val updateClosure: SingleAccountEventVisitor = { + includes = true + } + + visit( + onAdd = updateClosure.takeIf { checkAdd }, + onStructureChanged = updateClosure.takeIf { checkStructureChange }, + onNameChanged = updateClosure.takeIf { checkNameChange }, + onRemoved = updateClosure.takeIf { checkAccountRemoved } + ) + + return includes +} + +fun Event.allAffectedMetaAccountTypes(): List { + return collect( + onAdd = { it.metaAccountType }, + onRemoved = { it.metaAccountType }, + onNameChanged = { it.metaAccountType }, + onStructureChanged = { it.metaAccountType } + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/externalAccounts/ExternalAccountsSyncService.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/externalAccounts/ExternalAccountsSyncService.kt new file mode 100644 index 0000000..4c0719e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/externalAccounts/ExternalAccountsSyncService.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.data.externalAccounts + +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus + +interface ExternalAccountsSyncService { + + fun syncOnAccountChange(event: MetaAccountChangesEventBus.Event, changeSource: String?) + + fun sync() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/ExtrinsicService.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/ExtrinsicService.kt new file mode 100644 index 0000000..8f17bbb --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/ExtrinsicService.kt @@ -0,0 +1,114 @@ +package io.novafoundation.nova.feature_account_api.data.extrinsic + +import io.novafoundation.nova.common.data.network.runtime.model.FeeResponse +import io.novafoundation.nova.common.utils.multiResult.RetriableMultiResult +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +typealias FormExtrinsicWithOrigin = suspend ExtrinsicBuilder.(context: ExtrinsicBuildingContext) -> Unit +typealias FormMultiExtrinsicWithOrigin = suspend CallBuilder.(context: ExtrinsicBuildingContext) -> Unit + +class ExtrinsicSubmission( + val hash: String, + val submissionOrigin: SubmissionOrigin, + val callExecutionType: CallExecutionType, + val submissionHierarchy: SubmissionHierarchy +) + +class ExtrinsicBuildingContext( + val submissionOrigin: SubmissionOrigin, + val signer: NovaSigner, + val chain: Chain +) + +private val DEFAULT_BATCH_MODE = BatchMode.BATCH_ALL + +interface ExtrinsicService { + + interface Factory { + + fun create(feeConfig: FeePaymentConfig): ExtrinsicService + } + + class SubmissionOptions( + val feePaymentCurrency: FeePaymentCurrency = FeePaymentCurrency.Native, + val batchMode: BatchMode = DEFAULT_BATCH_MODE, + ) + + class FeePaymentConfig( + val coroutineScope: CoroutineScope, + /** + * Specify to use it instead of default [FeePaymentProviderRegistry] to perform fee computations + */ + val customFeePaymentRegistry: FeePaymentProviderRegistry? = null, + ) + + suspend fun submitExtrinsic( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions = SubmissionOptions(), + formExtrinsic: FormExtrinsicWithOrigin + ): Result + + suspend fun submitAndWatchExtrinsic( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions = SubmissionOptions(), + formExtrinsic: FormExtrinsicWithOrigin + ): Result>> + + suspend fun submitExtrinsicAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions = SubmissionOptions(), + formExtrinsic: FormExtrinsicWithOrigin + ): Result + + suspend fun submitMultiExtrinsicAwaitingInclusion( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions = SubmissionOptions(), + formExtrinsic: FormMultiExtrinsicWithOrigin + ): RetriableMultiResult> + + suspend fun paymentInfo( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions = SubmissionOptions(), + formExtrinsic: FormExtrinsicWithOrigin + ): FeeResponse + + suspend fun estimateFee( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions = SubmissionOptions(), + formExtrinsic: FormExtrinsicWithOrigin + ): Fee + + suspend fun estimateMultiFee( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions = SubmissionOptions(), + formExtrinsic: FormMultiExtrinsicWithOrigin + ): Fee + + suspend fun estimateFee( + chain: Chain, + extrinsic: String, + usedSigner: NovaSigner, + ): Fee +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/ExtrinsicServiceExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/ExtrinsicServiceExt.kt new file mode 100644 index 0000000..0e3dcc2 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/ExtrinsicServiceExt.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_api.data.extrinsic + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService.FeePaymentConfig +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.mapWithStatus +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +suspend fun Result>>.awaitInBlock(): Result> = + mapCatching { watchResult -> + watchResult.filter { it.status is ExtrinsicStatus.InBlock } + .map { it.mapWithStatus() } + .first() + } + +suspend inline fun Flow>.awaitStatus(): ExtrinsicWatchResult { + return filterStatus().first() +} + +inline fun Flow>.filterStatus(): Flow> { + return filter { it.status is T } + .map { it.mapWithStatus() } +} + +fun ExtrinsicService.Factory.createDefault(coroutineScope: CoroutineScope): ExtrinsicService { + return create(FeePaymentConfig(coroutineScope)) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/ExtrinsicSplitter.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/ExtrinsicSplitter.kt new file mode 100644 index 0000000..a4c8a2c --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/ExtrinsicSplitter.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_account_api.data.extrinsic + +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +typealias SplitCalls = List> + +interface ExtrinsicSplitter { + + suspend fun split(signer: NovaSigner, callBuilder: CallBuilder, chain: Chain): SplitCalls + + suspend fun estimateCallWeight(signer: NovaSigner, call: GenericCall.Instance, chain: Chain): WeightV2 +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/SubmissionOrigin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/SubmissionOrigin.kt new file mode 100644 index 0000000..4384b75 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/SubmissionOrigin.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_account_api.data.extrinsic + +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.Signer + +data class SubmissionOrigin( + /** + * Account on which behalf the operation will be executed + */ + val executingAccount: AccountId, + + /** + * Account that will sign and submit transaction + * It might differ from [executingAccount] if [Signer] modified the origin. + * For example in the case of Proxied wallet [executingAccount] is proxied and [signingAccount] is proxy + */ + val signingAccount: AccountId +) { + + companion object { + + fun singleOrigin(origin: AccountId) = SubmissionOrigin(origin, origin) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SubmissionOrigin + + if (!executingAccount.contentEquals(other.executingAccount)) return false + return signingAccount.contentEquals(other.signingAccount) + } + + override fun hashCode(): Int { + var result = executingAccount.contentHashCode() + result = 31 * result + signingAccount.contentHashCode() + return result + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/execution/ExtrinsicDispatch.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/execution/ExtrinsicDispatch.kt new file mode 100644 index 0000000..9ae997c --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/execution/ExtrinsicDispatch.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_api.data.extrinsic.execution + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.common.data.network.runtime.binding.DispatchError +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +data class ExtrinsicExecutionResult( + val extrinsicHash: String, + val blockHash: BlockHash, + val outcome: ExtrinsicDispatch, + val submissionHierarchy: SubmissionHierarchy +) + +sealed interface ExtrinsicDispatch { + + data class Ok(val emittedEvents: List) : ExtrinsicDispatch + + data class Failed(val error: DispatchError) : ExtrinsicDispatch + + object Unknown : ExtrinsicDispatch +} + +fun ExtrinsicExecutionResult.requireOk(): ExtrinsicExecutionResult { + return when (outcome) { + is ExtrinsicDispatch.Failed -> throw outcome.error + is ExtrinsicDispatch.Ok -> this + ExtrinsicDispatch.Unknown -> throw IllegalArgumentException("Unknown extrinsic execution result") + } +} + +fun Result.requireOk(): Result { + return mapCatching { + it.requireOk() + } +} + +fun ExtrinsicExecutionResult.requireOutcomeOk(): ExtrinsicDispatch.Ok { + return requireOk().outcome as ExtrinsicDispatch.Ok +} + +fun ExtrinsicDispatch.isOk(): Boolean { + return this is ExtrinsicDispatch.Ok +} + +fun ExtrinsicDispatch.isModuleError(moduleName: String, errorName: String): Boolean { + return this is ExtrinsicDispatch.Failed && + error is DispatchError.Module && + error.module.name == moduleName && + error.error.name == errorName +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/execution/watch/ExtrinsicWatchResult.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/execution/watch/ExtrinsicWatchResult.kt new file mode 100644 index 0000000..4215ad1 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/extrinsic/execution/watch/ExtrinsicWatchResult.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch + +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus + +class ExtrinsicWatchResult( + val status: T, + val submissionHierarchy: SubmissionHierarchy +) + +fun List>.submissionHierarchy() = first().submissionHierarchy + +inline fun ExtrinsicWatchResult<*>.mapWithStatus() = ExtrinsicWatchResult(status as T, submissionHierarchy) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/DefaultFastLookupCustomFeeCapability.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/DefaultFastLookupCustomFeeCapability.kt new file mode 100644 index 0000000..f1d843f --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/DefaultFastLookupCustomFeeCapability.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_api.data.fee + +import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId + +class DefaultFastLookupCustomFeeCapability : FastLookupCustomFeeCapability { + + override val nonUtilityFeeCapableTokens: Set = emptySet() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/FeePayment.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/FeePayment.kt new file mode 100644 index 0000000..ae7b138 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/FeePayment.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_api.data.fee + +import android.util.Log +import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic +import kotlinx.coroutines.CoroutineScope + +interface FeePayment { + + suspend fun modifyExtrinsic(extrinsicBuilder: ExtrinsicBuilder) + + suspend fun convertNativeFee(nativeFee: Fee): Fee +} + +interface FeePaymentProvider { + + val chain: Chain + + suspend fun feePaymentFor(feePaymentCurrency: FeePaymentCurrency, coroutineScope: CoroutineScope?): FeePayment + + suspend fun detectFeePaymentFromExtrinsic(extrinsic: SendableExtrinsic): FeePayment + + suspend fun canPayFee(feePaymentCurrency: FeePaymentCurrency): Result + + suspend fun fastLookupCustomFeeCapability(): Result +} + +interface FeePaymentProviderRegistry { + + suspend fun providerFor(chainId: ChainId): FeePaymentProvider +} + +suspend fun FeePaymentProvider.fastLookupCustomFeeCapabilityOrDefault(): FastLookupCustomFeeCapability { + return fastLookupCustomFeeCapability() + .onFailure { Log.e("FeePaymentProvider", "Failed to construct fast custom fee lookup for chain ${chain.name}", it) } + .getOrElse { DefaultFastLookupCustomFeeCapability() } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/FeePaymentCurrency.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/FeePaymentCurrency.kt new file mode 100644 index 0000000..555690e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/FeePaymentCurrency.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_account_api.data.fee + +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.isCommissionAsset +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed interface FeePaymentCurrency { + + /** + * Use native currency of the chain to pay the fee + */ + object Native : FeePaymentCurrency { + + override fun toString(): String { + return "Native" + } + } + + /** + * Request to use a specific [asset] for payment fees + * This does not guarantee that the exact [asset] will be used for fee payments, + * for example if the chain doesn't support paying fees in asset. In that case it will fall-back to using [FeePaymentCurrency.Native] + * + * The actual asset used to pay fees will be available in [Fee.asset] + */ + class Asset private constructor(val asset: Chain.Asset) : FeePaymentCurrency { + + companion object { + + fun Chain.Asset.toFeePaymentCurrency(): FeePaymentCurrency { + return when { + isCommissionAsset -> Native + else -> Asset(this) + } + } + } + + override fun equals(other: Any?): Boolean { + if (other !is Asset) return false + return asset.fullId == other.asset.fullId + } + + override fun hashCode(): Int { + return asset.hashCode() + } + + override fun toString(): String { + return "Asset(${asset.symbol})" + } + } +} + +fun FeePaymentCurrency.toChainAsset(chain: Chain): Chain.Asset { + return toChainAsset(chain.utilityAsset) +} + +fun FeePaymentCurrency.toChainAsset(chainUtilityAsset: Chain.Asset): Chain.Asset { + return when (this) { + is FeePaymentCurrency.Asset -> asset + FeePaymentCurrency.Native -> chainUtilityAsset + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/capability/CustomFeeAvailabilityFacade.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/capability/CustomFeeAvailabilityFacade.kt new file mode 100644 index 0000000..8eefd53 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/capability/CustomFeeAvailabilityFacade.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_api.data.fee.capability + +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId + +interface FastLookupCustomFeeCapability { + + val nonUtilityFeeCapableTokens: Set + + fun canPayFeeInNonUtilityToken(chainAssetId: ChainAssetId): Boolean { + return chainAssetId in nonUtilityFeeCapableTokens + } +} + +interface CustomFeeCapabilityFacade { + + suspend fun canPayFeeInCurrency(currency: FeePaymentCurrency): Boolean + + /** + * Whether fee payment in custom assets is not possible at all in the current environment + * This check is also accounted for internally in [canPayFeeInNonUtilityToken] + * but can be used separately for optimizing bulk checks + */ + suspend fun hasGlobalFeePaymentRestrictions(): Boolean +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/chains/CustomOrNativeFeePaymentProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/chains/CustomOrNativeFeePaymentProvider.kt new file mode 100644 index 0000000..2a16ce5 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/chains/CustomOrNativeFeePaymentProvider.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_api.data.fee.chains + +import io.novafoundation.nova.feature_account_api.data.fee.FeePayment +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProvider +import io.novafoundation.nova.feature_account_api.data.fee.types.NativeFeePayment +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope + +abstract class CustomOrNativeFeePaymentProvider : FeePaymentProvider { + + protected abstract suspend fun feePaymentFor(customFeeAsset: Chain.Asset, coroutineScope: CoroutineScope?): FeePayment + + protected abstract suspend fun canPayFeeInNonUtilityToken(customFeeAsset: Chain.Asset): Result + + final override suspend fun feePaymentFor(feePaymentCurrency: FeePaymentCurrency, coroutineScope: CoroutineScope?): FeePayment { + return when (feePaymentCurrency) { + is FeePaymentCurrency.Asset -> feePaymentFor(feePaymentCurrency.asset, coroutineScope) + + FeePaymentCurrency.Native -> NativeFeePayment() + } + } + + final override suspend fun canPayFee(feePaymentCurrency: FeePaymentCurrency): Result { + return when (feePaymentCurrency) { + is FeePaymentCurrency.Asset -> canPayFeeInNonUtilityToken(feePaymentCurrency.asset) + FeePaymentCurrency.Native -> Result.success(true) + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/types/NativeFeePayment.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/types/NativeFeePayment.kt new file mode 100644 index 0000000..2298277 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/types/NativeFeePayment.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_account_api.data.fee.types + +import io.novafoundation.nova.feature_account_api.data.fee.FeePayment +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder + +class NativeFeePayment : FeePayment { + + override suspend fun modifyExtrinsic(extrinsicBuilder: ExtrinsicBuilder) { + // no modifications needed + } + + override suspend fun convertNativeFee(nativeFee: Fee): Fee { + return nativeFee + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/types/assetHub/ChargeAssetTxPayment.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/types/assetHub/ChargeAssetTxPayment.kt new file mode 100644 index 0000000..1960973 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/types/assetHub/ChargeAssetTxPayment.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_api.data.fee.types.assetHub + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.transactionExtensionOrNull +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.bindMultiLocation +import io.novafoundation.nova.runtime.extrinsic.extensions.ChargeAssetTxPayment +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.Type +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Extrinsic +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.findExplicitOrNull + +fun Extrinsic.Instance.findChargeAssetTxPayment(): ChargeAssetTxPaymentValue? { + val value = findExplicitOrNull(ChargeAssetTxPayment.ID) ?: return null + return ChargeAssetTxPaymentValue.bind(value) +} + +fun RuntimeSnapshot.decodeCustomTxPaymentId(assetIdHex: String): Any? { + val chargeAssetTxPaymentType = metadata.extrinsic.transactionExtensionOrNull(ChargeAssetTxPayment.ID) ?: return null + val type = chargeAssetTxPaymentType.includedInExtrinsic!! + val assetIdType = type.cast().get>("assetId")!! + + return assetIdType.fromHex(this, assetIdHex) +} + +class ChargeAssetTxPaymentValue( + val tip: BalanceOf, + val assetId: RelativeMultiLocation? +) { + + companion object { + + fun bind(decoded: Any?): ChargeAssetTxPaymentValue { + val asStruct = decoded.castToStruct() + return ChargeAssetTxPaymentValue( + tip = bindNumber(asStruct["tip"]), + assetId = asStruct.get("assetId")?.let(::bindMultiLocation) + ) + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/types/hydra/HydrationFeeInjector.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/types/hydra/HydrationFeeInjector.kt new file mode 100644 index 0000000..5a48857 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/fee/types/hydra/HydrationFeeInjector.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_account_api.data.fee.types.hydra + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import java.math.BigInteger + +interface HydrationFeeInjector { + + class SetFeesMode( + val setMode: SetMode, + val resetMode: ResetMode + ) + + sealed class SetMode { + + /** + * Always sets the fee to the required token, regardless of whether fees are already in the needed state or not + */ + object Always : SetMode() + + /** + * Sets the fee token to the required one only the current fee payment asset is different + */ + class Lazy(val currentlySetFeeAsset: BigInteger) : SetMode() + } + + sealed class ResetMode { + + /** + * Always resets the fee to the native token, regardless of whether fees are already in the needed state or not + */ + object ToNative : ResetMode() + + /** + * Resets the fee to the native one only the current fee payment asset is different + */ + class ToNativeLazily(val feeAssetBeforeTransaction: BigInteger) : ResetMode() + } + + suspend fun setFees( + extrinsicBuilder: ExtrinsicBuilder, + paymentAsset: Chain.Asset, + mode: SetFeesMode, + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/mappers/Chain.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/mappers/Chain.kt new file mode 100644 index 0000000..d8e7e17 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/mappers/Chain.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_account_api.data.mappers + +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun mapChainToUi(chain: Chain): ChainUi = with(chain) { + ChainUi( + id = id, + name = name, + icon = icon, + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/mappers/Network.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/mappers/Network.kt new file mode 100644 index 0000000..b81c331 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/mappers/Network.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_api.data.mappers + +import io.novafoundation.nova.core.model.Network +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +fun stubNetwork(chainId: ChainId): Network { + val networkType = Node.NetworkType.findByGenesis(chainId) ?: Node.NetworkType.POLKADOT + + return Network(networkType) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/AccountIdMap.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/AccountIdMap.kt new file mode 100644 index 0000000..f92d98c --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/AccountIdMap.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_api.data.model + +import io.novafoundation.nova.common.address.AccountIdKey + +@Deprecated("Use AccountIdKeyMap instead") +typealias AccountIdMap = Map + +@Deprecated("Use AccountIdKeyMap instead") +typealias AccountAddressMap = Map + +typealias AccountIdKeyMap = Map diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt new file mode 100644 index 0000000..d683e7f --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_account_api.data.model + +import io.novafoundation.nova.common.utils.amountFromPlanks +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigDecimal +import java.math.BigInteger + +// TODO rename FeeBase -> Fee and use SubmissionFee everywhere Fee is currently used +typealias Fee = SubmissionFee + +interface SubmissionFee : FeeBase, MaxAvailableDeduction { + + companion object + + /** + * Information about origin that is supposed to send the transaction fee was calculated against + */ + val submissionOrigin: SubmissionOrigin + + /** + * Submission fee deducts fee amount from max balance only when executing account pays fees + * When signing account is different from executing one, executing account balance remains unaffected by submission fee payment + */ + override fun maxAmountDeductionFor(amountAsset: Chain.Asset): BigInteger { + return getAmountByExecutingAccount(amountAsset) + } +} + +val SubmissionFee.submissionFeesPayer: AccountId + get() = submissionOrigin.signingAccount + +/** + * Fee that doesn't have a particular origin + * For example, fees paid during cross chain transfers do not have a specific account that pays them + */ +interface FeeBase { + + val amount: BigInteger + + val asset: Chain.Asset +} + +val FeeBase.decimalAmount: BigDecimal + get() = amount.amountFromPlanks(asset.precision) + +data class EvmFee( + val gasLimit: BigInteger, + val gasPrice: BigInteger, + override val submissionOrigin: SubmissionOrigin, + override val asset: Chain.Asset +) : Fee { + + override val amount = gasLimit * gasPrice +} + +class SubstrateFee( + override val amount: BigInteger, + override val submissionOrigin: SubmissionOrigin, + override val asset: Chain.Asset +) : Fee + +class SubstrateFeeBase( + override val amount: BigInteger, + override val asset: Chain.Asset +) : FeeBase + +val Fee.amountByExecutingAccount: BigInteger + get() = getAmount(asset, submissionOrigin.executingAccount) + +val Fee.decimalAmountByExecutingAccount: BigDecimal + get() = amountByExecutingAccount.amountFromPlanks(asset.precision) + +fun FeeBase.addPlanks(extraPlanks: BigInteger): FeeBase { + return SubstrateFeeBase(amount + extraPlanks, asset) +} + +fun List.totalAmount(chainAsset: Chain.Asset): BigInteger { + return sumOf { it.getAmount(chainAsset) } +} + +fun List.totalAmount(chainAsset: Chain.Asset, origin: AccountId): BigInteger { + return sumOf { it.getAmount(chainAsset, origin) } +} + +fun List.totalPlanksEnsuringAsset(requireAsset: Chain.Asset): BigInteger { + return sumOf { + require(it.asset.fullId == requireAsset.fullId) { + "Fees contain fee in different assets: ${it.asset.fullId}" + } + + it.amount + } +} + +fun SubmissionFee.getAmount(chainAsset: Chain.Asset, origin: AccountId): BigInteger { + return if (asset.fullId == chainAsset.fullId && submissionFeesPayer.contentEquals(origin)) { + amount + } else { + BigInteger.ZERO + } +} + +fun SubmissionFee.getAmountByExecutingAccount(chainAsset: Chain.Asset): BigInteger { + return getAmount(chainAsset, submissionOrigin.executingAccount) +} + +fun FeeBase.getAmount(expectedAsset: Chain.Asset): BigInteger { + return if (expectedAsset.fullId == asset.fullId) amount else BigInteger.ZERO +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/OnChainIdentity.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/OnChainIdentity.kt new file mode 100644 index 0000000..d1c9982 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/OnChainIdentity.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_account_api.data.model + +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface OnChainIdentity { + val display: String? + val legal: String? + val web: String? + val matrix: String? + val email: String? + val pgpFingerprint: String? + val image: String? + val twitter: String? +} + +class RootIdentity( + override val display: String?, + override val legal: String?, + override val web: String?, + override val matrix: String?, + override val email: String?, + override val pgpFingerprint: String?, + override val image: String?, + override val twitter: String?, +) : OnChainIdentity + +class ChildIdentity( + val childName: String?, + val parentIdentity: OnChainIdentity, +) : OnChainIdentity by parentIdentity { + + override val display: String = "${parentIdentity.display} / ${childName.orEmpty()}" +} + +class SuperOf( + val parentId: AccountId, + val childName: String?, +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/MultisigCalls.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/MultisigCalls.kt new file mode 100644 index 0000000..43b924d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/MultisigCalls.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_account_api.data.multisig + +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.composeCall +import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigTimePoint +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +fun RuntimeSnapshot.composeMultisigAsMulti( + multisigMetaAccount: MultisigMetaAccount, + maybeTimePoint: MultisigTimePoint?, + call: GenericCall.Instance, + maxWeight: WeightV2 +): GenericCall.Instance { + return composeCall( + moduleName = Modules.MULTISIG, + callName = "as_multi", + arguments = mapOf( + "threshold" to multisigMetaAccount.threshold.toBigInteger(), + "other_signatories" to multisigMetaAccount.otherSignatories.map { it.value }, + "maybe_timepoint" to maybeTimePoint?.toEncodableInstance(), + "call" to call, + "max_weight" to maxWeight.toEncodableInstance() + ) + ) +} + +fun RuntimeSnapshot.composeMultisigAsMultiThreshold1( + multisigMetaAccount: MultisigMetaAccount, + call: GenericCall.Instance, +): GenericCall.Instance { + return composeCall( + moduleName = Modules.MULTISIG, + callName = "as_multi_threshold_1", + arguments = mapOf( + "other_signatories" to multisigMetaAccount.otherSignatories.map { it.value }, + "call" to call, + ) + ) +} + +fun RuntimeSnapshot.composeMultisigCancelAsMulti( + multisigMetaAccount: MultisigMetaAccount, + maybeTimePoint: MultisigTimePoint, + callHash: CallHash, +): GenericCall.Instance { + return composeCall( + moduleName = Modules.MULTISIG, + callName = "cancel_as_multi", + arguments = mapOf( + "threshold" to multisigMetaAccount.threshold.toBigInteger(), + "other_signatories" to multisigMetaAccount.otherSignatories.map { it.value }, + "timepoint" to maybeTimePoint.toEncodableInstance(), + "call_hash" to callHash.value + ) + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/MultisigDetailsRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/MultisigDetailsRepository.kt new file mode 100644 index 0000000..cfc80bf --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/MultisigDetailsRepository.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_account_api.data.multisig + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface MultisigDetailsRepository { + + suspend fun hasMultisigOperation( + chain: Chain, + accountIdKey: AccountIdKey, + callHash: CallHash + ): Boolean +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/MultisigPendingOperationsService.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/MultisigPendingOperationsService.kt new file mode 100644 index 0000000..0cfdc09 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/MultisigPendingOperationsService.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_api.data.multisig + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId +import kotlinx.coroutines.flow.Flow + +interface MultisigPendingOperationsService { + + context(ComputationalScope) + fun performMultisigOperationsSync(): Flow + + context(ComputationalScope) + fun pendingOperationsCountFlow(): Flow + + context(ComputationalScope) + suspend fun getPendingOperationsCount(): Int + + context(ComputationalScope) + fun pendingOperations(): Flow> + + context(ComputationalScope) + fun pendingOperationFlow(id: PendingMultisigOperationId): Flow + + context(ComputationalScope) + suspend fun pendingOperation(id: PendingMultisigOperationId): PendingMultisigOperation? +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/MultisigAction.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/MultisigAction.kt new file mode 100644 index 0000000..e06fa7b --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/MultisigAction.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.data.multisig.model + +sealed class MultisigAction { + + data object Signed : MultisigAction() + + data object CanReject : MultisigAction() + + data class CanApprove(val isFinalApproval: Boolean) : MultisigAction() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/MultisigTimePoint.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/MultisigTimePoint.kt new file mode 100644 index 0000000..bf53cbe --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/MultisigTimePoint.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_api.data.multisig.model + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindBlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindInt +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.scale.ToDynamicScaleInstance +import io.novafoundation.nova.common.utils.structOf +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct + +class MultisigTimePoint( + val height: BlockNumber, + val extrinsicIndex: Int +) : ToDynamicScaleInstance { + + companion object { + + fun bind(decoded: Any?): MultisigTimePoint { + val asStruct = decoded.castToStruct() + + return MultisigTimePoint( + height = bindBlockNumber(asStruct["height"]), + extrinsicIndex = bindInt(asStruct["index"]) + ) + } + } + + override fun toEncodableInstance(): Struct.Instance { + return structOf( + "height" to height, + "index" to extrinsicIndex.toBigInteger() + ) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/PendingMultisigOperation.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/PendingMultisigOperation.kt new file mode 100644 index 0000000..79fc078 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/PendingMultisigOperation.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_account_api.data.multisig.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.toHex +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import java.math.BigInteger +import kotlin.time.Duration + +class PendingMultisigOperation( + val multisigMetaId: Long, + val call: GenericCall.Instance?, + val callHash: CallHash, + val chain: Chain, + val timePoint: MultisigTimePoint, + val approvals: List, + val depositor: AccountIdKey, + val deposit: BigInteger, + val signatoryAccountId: AccountIdKey, + val signatoryMetaId: Long, + val threshold: Int, + val timestamp: Duration, +) : Identifiable { + + val operationId = PendingMultisigOperationId(multisigMetaId, chain.id, callHash.toHex()) + + override val identifier: String = operationId.identifier() + + override fun toString(): String { + val callFormatted = if (call != null) { + "${call.module.name}.${call.function.name}" + } else { + callHash.toHex() + } + + return "Call: $callFormatted, Chain: ${chain.name}, Approvals: ${approvals.size}/$threshold, User action: ${userAction()}" + } + + companion object +} + +data class PendingMultisigOperationId( + val metaId: Long, + val chainId: ChainId, + val callHash: String, +) { + companion object; +} + +fun PendingMultisigOperation.userAction(): MultisigAction { + return when (signatoryAccountId) { + depositor -> MultisigAction.CanReject + !in approvals -> MultisigAction.CanApprove( + isFinalApproval = approvals.size == threshold - 1 + ) + + else -> MultisigAction.Signed + } +} + +fun PendingMultisigOperationId.identifier() = toString() + +/** + * operation hash is based on address in chain and ignored meta account id + */ +fun PendingMultisigOperation.Companion.createOperationHash(metaAccount: MetaAccount, chain: Chain, callHash: String): String { + return "${metaAccount.addressIn(chain)}:${chain.id}:$callHash" + .toByteArray() + .toHexString(withPrefix = true) +} + +fun PendingMultisigOperationId.Companion.create(metaAccount: MetaAccount, chain: Chain, callHash: String): PendingMultisigOperationId { + return PendingMultisigOperationId(metaAccount.id, chain.id, callHash) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/repository/MultisigOperationLocalCallRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/repository/MultisigOperationLocalCallRepository.kt new file mode 100644 index 0000000..18c00b7 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/repository/MultisigOperationLocalCallRepository.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_api.data.multisig.repository + +import io.novafoundation.nova.feature_account_api.domain.model.SavedMultisigOperationCall +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface MultisigOperationLocalCallRepository { + + suspend fun setMultisigCall(operation: SavedMultisigOperationCall) + + fun callsFlow(): Flow> + + suspend fun removeCallHashesExclude(metaId: Long, chainId: ChainId, excludedCallHashes: Set) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/repository/MultisigValidationsRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/repository/MultisigValidationsRepository.kt new file mode 100644 index 0000000..5034e33 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/repository/MultisigValidationsRepository.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_api.data.multisig.repository + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.utils.times +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface MultisigValidationsRepository { + + suspend fun getMultisigDepositBase(chainId: ChainId): BalanceOf + + suspend fun getMultisigDepositFactor(chainId: ChainId): BalanceOf + + suspend fun hasPendingCallHash(chainId: ChainId, accountIdKey: AccountIdKey, callHash: CallHash): Boolean +} + +suspend fun MultisigValidationsRepository.getMultisigDeposit(chainId: ChainId, threshold: Int): BalanceOf { + if (threshold == 1) return BalanceOf.ZERO + + val base = getMultisigDepositBase(chainId) + val factor = getMultisigDepositFactor(chainId) + + return base + factor * threshold +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationFailure.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationFailure.kt new file mode 100644 index 0000000..50aeaf1 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationFailure.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_account_api.data.multisig.validation + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +sealed interface MultisigExtrinsicValidationFailure { + + class NotEnoughSignatoryBalance( + val signatory: MetaAccount, + val asset: Chain.Asset, + val deposit: BigInteger?, + val fee: BigInteger?, + val balanceToAdd: BigInteger + ) : MultisigExtrinsicValidationFailure + + class OperationAlreadyExists( + val multisigAccount: MultisigMetaAccount + ) : MultisigExtrinsicValidationFailure +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationPayload.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationPayload.kt new file mode 100644 index 0000000..7a63bd9 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationPayload.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_api.data.multisig.validation + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class MultisigExtrinsicValidationPayload( + val multisig: MultisigMetaAccount, + val signatory: MetaAccount, + val chain: Chain, + val signatoryFeePaymentMode: SignatoryFeePaymentMode, + // Call that is passed to as_multi. Might be both the actual call (in case multisig is a the root signer) or be wrapped by some other signer + val callInsideAsMulti: GenericCall.Instance, +) + +sealed class SignatoryFeePaymentMode { + + data object PaysSubmissionFee : SignatoryFeePaymentMode() + + data object NothingToPay : SignatoryFeePaymentMode() +} + +fun MultisigExtrinsicValidationPayload.signatoryAccountId(): AccountId { + return signatory.requireAccountIdIn(chain) +} + +fun MultisigExtrinsicValidationPayload.multisigAccountId(): AccountIdKey { + return multisig.requireAccountIdKeyIn(chain) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationRequestBus.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationRequestBus.kt new file mode 100644 index 0000000..caac541 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationRequestBus.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_account_api.data.multisig.validation + +import io.novafoundation.nova.common.utils.bus.BaseRequestBus +import io.novafoundation.nova.common.utils.bus.RequestBus +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationRequestBus.Request +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationRequestBus.ValidationResponse + +class MultisigExtrinsicValidationRequestBus() : BaseRequestBus() { + + class Request(val validationPayload: MultisigExtrinsicValidationPayload) : RequestBus.Request + + class ValidationResponse(val validationResult: Result>) : RequestBus.Response +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationSystem.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationSystem.kt new file mode 100644 index 0000000..4b4315f --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/validation/MultisigExtrinsicValidationSystem.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_api.data.multisig.validation + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder + +typealias MultisigExtrinsicValidationBuilder = ValidationSystemBuilder +typealias MultisigExtrinsicValidation = Validation +typealias MultisigExtrinsicValidationStatus = ValidationStatus +typealias MultisigExtrinsicValidationSystem = ValidationSystem diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/proxy/MetaAccountsUpdatesRegistry.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/proxy/MetaAccountsUpdatesRegistry.kt new file mode 100644 index 0000000..557b406 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/proxy/MetaAccountsUpdatesRegistry.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_api.data.proxy + +import kotlinx.coroutines.flow.Flow + +interface MetaAccountsUpdatesRegistry { + + fun addMetaIds(ids: List) + + fun observeUpdates(): Flow> + + fun getUpdates(): Set + + fun remove(ids: List) + + fun clear() + + fun hasUpdates(): Boolean + + fun observeUpdatesExist(): Flow + + fun observeLastConsumedUpdatesMetaIds(): Flow> +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/proxy/validation/ProxiedExtrinsicValidationSystem.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/proxy/validation/ProxiedExtrinsicValidationSystem.kt new file mode 100644 index 0000000..bbc0034 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/proxy/validation/ProxiedExtrinsicValidationSystem.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_api.data.proxy.validation + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import java.math.BigInteger + +class ProxiedExtrinsicValidationPayload( + val proxiedMetaAccount: ProxiedMetaAccount, + val proxyMetaAccount: MetaAccount, + val chainWithAsset: ChainWithAsset, + val proxiedCall: GenericCall.Instance +) + +val ProxiedExtrinsicValidationPayload.proxyAccountId: AccountId + get() = proxyMetaAccount.requireAccountIdIn(chainWithAsset.chain) + +sealed interface ProxiedExtrinsicValidationFailure { + + class ProxyNotEnoughFee( + val proxy: MetaAccount, + val asset: Chain.Asset, + val fee: BigInteger, + val availableBalance: BigInteger + ) : ProxiedExtrinsicValidationFailure +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/proxy/validation/ProxyExtrinsicValidationRequestBus.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/proxy/validation/ProxyExtrinsicValidationRequestBus.kt new file mode 100644 index 0000000..f803a00 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/proxy/validation/ProxyExtrinsicValidationRequestBus.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_account_api.data.proxy.validation + +import io.novafoundation.nova.common.utils.bus.BaseRequestBus +import io.novafoundation.nova.common.utils.bus.RequestBus +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxyExtrinsicValidationRequestBus.Request +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxyExtrinsicValidationRequestBus.ValidationResponse + +class ProxyExtrinsicValidationRequestBus() : BaseRequestBus() { + + class Request(val validationPayload: ProxiedExtrinsicValidationPayload) : RequestBus.Request + + class ValidationResponse(val validationResult: Result>) : RequestBus.Response +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/CreateSecretsRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/CreateSecretsRepository.kt new file mode 100644 index 0000000..5515e52 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/CreateSecretsRepository.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_api.data.repository + +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.core.model.CryptoType +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +interface CreateSecretsRepository { + + suspend fun createSecretsWithSeed( + seed: ByteArray, + cryptoType: CryptoType, + derivationPath: String?, + isEthereum: Boolean, + ): EncodableStruct +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/OnChainIdentityRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/OnChainIdentityRepository.kt new file mode 100644 index 0000000..8d99972 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/OnChainIdentityRepository.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_api.data.repository + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.data.model.AccountAddressMap +import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface OnChainIdentityRepository { + + @Deprecated("Use getIdentitiesFromIds instead to avoid extra from/to hex conversions") + suspend fun getIdentitiesFromIdsHex(chainId: ChainId, accountIdsHex: Collection): AccountIdMap + + suspend fun getIdentitiesFromIds(accountIds: Collection, chainId: ChainId): AccountIdKeyMap + + suspend fun getIdentityFromId(chainId: ChainId, accountId: AccountId): OnChainIdentity? + + suspend fun getMultiChainIdentities(accountIds: Collection): AccountIdKeyMap + + @Deprecated("Use getIdentitiesFromIds instead to avoid extra from/to address conversions") + suspend fun getIdentitiesFromAddresses(chain: Chain, accountAddresses: List): AccountAddressMap +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/AddAccountRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/AddAccountRepository.kt new file mode 100644 index 0000000..0d7751d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/AddAccountRepository.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_account_api.data.repository.addAccount + +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus.Event +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount + +interface AddAccountRepository { + + suspend fun addAccount(payload: T): AddAccountResult +} + +sealed interface AddAccountResult { + + sealed interface HadEffect : AddAccountResult + + interface SingleAccountChange { + + val metaId: Long + } + + class AccountAdded(override val metaId: Long, val type: LightMetaAccount.Type) : HadEffect, SingleAccountChange + + class AccountChanged(override val metaId: Long, val type: LightMetaAccount.Type) : HadEffect, SingleAccountChange + + class Batch(val updates: List) : HadEffect + + data object NoOp : AddAccountResult +} + +fun AddAccountResult.toAccountBusEvent(): Event? { + return when (this) { + is AddAccountResult.HadEffect -> toAccountBusEvent() + is AddAccountResult.NoOp -> null + } +} + +fun AddAccountResult.HadEffect.toAccountBusEvent(): Event { + return when (this) { + is AddAccountResult.AccountAdded -> Event.AccountAdded(metaId, type) + is AddAccountResult.AccountChanged -> Event.AccountStructureChanged(metaId, type) + is AddAccountResult.Batch -> Event.BatchUpdate(updates.map { it.toAccountBusEvent() }) + } +} + +suspend fun AddAccountRepository.addAccountWithSingleChange(payload: T): AddAccountResult.SingleAccountChange { + val result = addAccount(payload) + require(result is AddAccountResult.SingleAccountChange) + + return result +} + +fun List.batchIfNeeded(): AddAccountResult { + val updatesThatHadEffect = filterIsInstance() + + return when (updatesThatHadEffect.size) { + 0 -> AddAccountResult.NoOp + 1 -> updatesThatHadEffect.single() + else -> AddAccountResult.Batch(updatesThatHadEffect) + } +} + +fun AddAccountResult.visit( + onAdd: (AddAccountResult.AccountAdded) -> Unit +) { + when (this) { + is AddAccountResult.AccountAdded -> onAdd(this) + is AddAccountResult.AccountChanged -> Unit + is AddAccountResult.Batch -> updates.onEach { it.visit(onAdd) } + AddAccountResult.NoOp -> Unit + } +} + +fun AddAccountResult.collectAddedIds(): List { + return buildList { + visit { + add(it.metaId) + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/ledger/GenericLedgerAddAccountRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/ledger/GenericLedgerAddAccountRepository.kt new file mode 100644 index 0000000..df8a16b --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/ledger/GenericLedgerAddAccountRepository.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger + +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountRepository +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerEvmAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount + +interface GenericLedgerAddAccountRepository : AddAccountRepository { + + sealed interface Payload { + + class NewWallet( + val name: String, + val substrateAccount: LedgerSubstrateAccount, + val evmAccount: LedgerEvmAccount?, + ) : Payload + + class AddEvmAccount( + val metaId: Long, + val evmAccount: LedgerEvmAccount + ) : Payload + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/ledger/LegacyLedgerAddAccountRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/ledger/LegacyLedgerAddAccountRepository.kt new file mode 100644 index 0000000..b97dbf6 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/ledger/LegacyLedgerAddAccountRepository.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger + +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountRepository +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface LegacyLedgerAddAccountRepository : AddAccountRepository { + + sealed interface Payload { + class MetaAccount( + val name: String, + val ledgerChainAccounts: Map + ) : Payload + + class ChainAccount( + val metaId: Long, + val chainId: ChainId, + val ledgerChainAccount: LedgerSubstrateAccount + ) : Payload + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/multisig/MultisigAddAccountRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/multisig/MultisigAddAccountRepository.kt new file mode 100644 index 0000000..5bb1af4 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/multisig/MultisigAddAccountRepository.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_account_api.data.repository.addAccount.multisig + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.multisig.MultisigAddAccountRepository.Payload +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface MultisigAddAccountRepository : AddAccountRepository { + + class Payload( + val accounts: List + ) + + class AccountPayload( + val chain: Chain, + val multisigAccountId: AccountIdKey, + val otherSignatories: List, + val threshold: Int, + val signatoryMetaId: Long, + val signatoryAccountId: AccountIdKey, + val identity: Identity? + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/proxied/ProxiedAddAccountRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/proxied/ProxiedAddAccountRepository.kt new file mode 100644 index 0000000..1554279 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/proxied/ProxiedAddAccountRepository.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_account_api.data.repository.addAccount.proxied + +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface ProxiedAddAccountRepository : AddAccountRepository { + + class Payload( + val chainId: ChainId, + val proxiedAccountId: AccountId, + val proxyType: ProxyType, + val proxyMetaId: Long, + val identity: Identity? + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/secrets/MnemonicAddAccountRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/secrets/MnemonicAddAccountRepository.kt new file mode 100644 index 0000000..0ed888b --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/repository/addAccount/secrets/MnemonicAddAccountRepository.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets + +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType + +interface MnemonicAddAccountRepository : AddAccountRepository { + + class Payload( + val mnemonic: String, + val advancedEncryption: AdvancedEncryption, + val addAccountType: AddAccountType + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt new file mode 100644 index 0000000..5f172b4 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_account_api.data.secrets + +import io.novafoundation.nova.common.data.secrets.v2.AccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.getAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.mapChainAccountSecretsToKeypair +import io.novafoundation.nova.common.data.secrets.v2.mapMetaAccountSecretsToDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.mapMetaAccountSecretsToKeypair +import io.novafoundation.nova.common.utils.fold +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +suspend fun SecretStoreV2.getAccountSecrets( + metaAccount: MetaAccount, + chain: Chain +): AccountSecrets { + val accountId = metaAccount.accountIdIn(chain) ?: error("No account for chain $chain in meta account ${metaAccount.name}") + + return getAccountSecrets(metaAccount.id, accountId) +} + +fun AccountSecrets.keypair(chain: Chain): Keypair { + return fold( + left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased) }, + right = { mapChainAccountSecretsToKeypair(it) } + ) +} + +fun AccountSecrets.derivationPath(chain: Chain): String? { + return fold( + left = { mapMetaAccountSecretsToDerivationPath(it, ethereum = chain.isEthereumBased) }, + right = { it[ChainAccountSecrets.DerivationPath] } + ) +} + +fun EncodableStruct.keypair(ethereum: Boolean): Keypair { + return mapMetaAccountSecretsToKeypair(this, ethereum) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/CallExecutionType.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/CallExecutionType.kt new file mode 100644 index 0000000..abdd52a --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/CallExecutionType.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_api.data.signer + +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType.DELAYED +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType.IMMEDIATE + +/** + * Specifies whether the actual transaction call (e.g. transfer) will be executed immediately or delayed + */ +enum class CallExecutionType { + + /** + * Actual call is executed immediately, together with the transaction itself + * This is the most common case + */ + IMMEDIATE, + + /** + * Actual call's executed is delayed - transaction only executes preparation step + * Examples: multisig or delayed proxies operations + */ + DELAYED +} + +fun CallExecutionType.isImmediate(): Boolean { + return this == IMMEDIATE +} + +fun CallExecutionType.intersect(other: CallExecutionType): CallExecutionType { + return if (isImmediate() && other.isImmediate()) { + IMMEDIATE + } else { + DELAYED + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/NovaSigner.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/NovaSigner.kt new file mode 100644 index 0000000..bd78291 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/NovaSigner.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_account_api.data.signer + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.extrinsic.signer.SignerPayloadRawWithChain +import io.novafoundation.nova.runtime.extrinsic.signer.withChain +import io.novafoundation.nova.runtime.extrinsic.signer.withoutChain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.Signer +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw + +interface NovaSigner : Signer { + + /** + * Determines execution type of the actual call (e.g. transfer) + * Implementation node: signers that delegate signing to nested signers should intersect their execution type with the nested one + */ + suspend fun callExecutionType(): CallExecutionType + + /** + * Meta account this signer was created for + * This is the same value that was passed to [SignerProvider.rootSignerFor] or [SignerProvider.nestedSignerFor] + */ + val metaAccount: MetaAccount + + /** + * Returns full signing hierarchy for root and nested signers + */ + suspend fun getSigningHierarchy(): SubmissionHierarchy + + /** + * Modify the extrinsic to enrich it with the signing data relevant for this type + * In all situations, at least nonce and signature will be required + * However some signers may even modify the call (e.g. Proxied signer will wrap the current call into proxy call) + * + * This should only be called after all other extrinsic information has been set, including all non-signer related extensions and calls + * So, this should be the final operation that modifies the extrinsic, followed just by [ExtrinsicBuilder.buildExtrinsic] + * + * Note for nested signers: + * + * Since signers delegation work in top-down approach(root signer is the executing account), + * but the wrapping should be done in the bottom-up way (the actual call is the inner-most one), + * nested signers should perform call wrapping themselves, and only after that perform nested [setSignerDataForSubmission] call. + * + * For example: + * + * With Secrets Wallet -> Proxy -> Multisig setup, the signing sequence will be MultisigSigner -> ProxiedSigner -> SecretsSigner. + * The final call should be proxy(as_multi(actual)) from Secrets origin. + * So, the wrapping should be done in the following sequence: actual -> wrap in as_multi -> wrap in proxy. + * So, the top-most signer (MultisigSigner) should first wrap the actual call into as_multi and only then delegate to ProxiedSigner. + */ + context(ExtrinsicBuilder) + suspend fun setSignerDataForSubmission(context: SigningContext) + + /** + * Same as [setSignerDataForSubmission] but should use fake signature so signed extrinsic can be safely used for fee calculation + * This may also apply certain optimizations like hard-coding the nonce or other values to speedup the extrinsic construction + * and thus, fee calculation + * + * This should only be called after all other extrinsic information has been set, including all non-signer related extensions and calls + * So, this should be the final operation that modifies the extrinsic, followed just by [ExtrinsicBuilder.buildExtrinsic] + * + * You can find notes about nested signers in [setSignerDataForSubmission] + */ + context(ExtrinsicBuilder) + suspend fun setSignerDataForFee(context: SigningContext) + + /** + * Return accountId of a signer that will actually sign this extrinsic + * For example, for Proxied account the actual signer is its Proxy + */ + suspend fun submissionSignerAccountId(chain: Chain): AccountId + + /** + * Determines whether this particular instance of signer imposes additional limits to the number of calls + * it is possible to add to a single transaction. + * This is useful for signers that run in resource-constrained environment and thus cannot handle large transactions, e.g. Ledger + */ + suspend fun maxCallsPerTransaction(): Int? + + // TODO this is a temp solution to workaround Polkadot Vault requiring chain id to sign a raw message + // This method should be removed once Vault behavior is improved + suspend fun signRawWithChain(payload: SignerPayloadRawWithChain): SignedRaw { + return signRaw(payload.withoutChain()) + } +} + +context(ExtrinsicBuilder) +suspend fun NovaSigner.setSignerData(context: SigningContext, mode: SigningMode) { + when (mode) { + SigningMode.FEE -> setSignerDataForFee(context) + SigningMode.SUBMISSION -> setSignerDataForSubmission(context) + } +} + +suspend fun NovaSigner.signRaw(payloadRaw: SignerPayloadRaw, chainId: ChainId?): SignedRaw { + return if (chainId != null) { + signRawWithChain(payloadRaw.withChain(chainId)) + } else { + signRaw(payloadRaw) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SeparateFlowSignerState.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SeparateFlowSignerState.kt new file mode 100644 index 0000000..126026e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SeparateFlowSignerState.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_account_api.data.signer + +import io.novafoundation.nova.common.utils.MutableSharedState +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.extrinsic.signer.SignerPayloadRawWithChain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.getGenesisHashOrThrow +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.signingPayload + +typealias SigningSharedState = MutableSharedState + +class SeparateFlowSignerState(val payload: SignerPayload, val metaAccount: MetaAccount) + +sealed class SignerPayload { + + class Extrinsic(val extrinsic: InheritedImplication, val accountId: AccountId) : SignerPayload() + + class Raw(val raw: SignerPayloadRawWithChain) : SignerPayload() +} + +fun SignerPayload.chainId(): ChainId { + return when (this) { + is SignerPayload.Extrinsic -> extrinsic.getGenesisHashOrThrow().toHexString() + is SignerPayload.Raw -> raw.chainId + } +} + +fun SignerPayload.accountId(): AccountId { + return when (this) { + is SignerPayload.Extrinsic -> accountId + is SignerPayload.Raw -> raw.accountId + } +} + +fun SignerPayload.signaturePayload(): ByteArray { + return when (this) { + is SignerPayload.Extrinsic -> extrinsic.signingPayload() + is SignerPayload.Raw -> raw.message + } +} + +fun SeparateFlowSignerState.requireExtrinsic(): InheritedImplication { + require(payload is SignerPayload.Extrinsic) + return payload.extrinsic +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SignerProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SignerProvider.kt new file mode 100644 index 0000000..912ced6 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SignerProvider.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.data.signer + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount + +interface SignerProvider { + + fun rootSignerFor(metaAccount: MetaAccount): NovaSigner + + fun nestedSignerFor(metaAccount: MetaAccount): NovaSigner +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SigningContext.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SigningContext.kt new file mode 100644 index 0000000..8e5a629 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SigningContext.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_api.data.signer + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.Nonce + +interface SigningContext { + + interface Factory { + + fun default(chain: Chain): SigningContext + } + + val chain: Chain + + suspend fun getNonce(accountId: AccountIdKey): Nonce +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SigningMode.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SigningMode.kt new file mode 100644 index 0000000..cb07ca5 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SigningMode.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_api.data.signer + +enum class SigningMode { + FEE, SUBMISSION +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SubmissionHierarchy.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SubmissionHierarchy.kt new file mode 100644 index 0000000..ed109a1 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/signer/SubmissionHierarchy.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_api.data.signer + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount + +/** + * A signing chain of accounts. + * Contains at least 1 item in path. + * Ordering of accounts is built in the following order: + * - path[0] always contains account for Leaf Signer + * - path[1] Nested account + * ... + * - path[n - 1] Nested account + * - path[n] is always Selected account + * + */ +class SubmissionHierarchy( + val path: List +) { + + class Node( + val account: MetaAccount, + val callExecutionType: CallExecutionType + ) + + constructor(metaAccount: MetaAccount, callExecutionType: CallExecutionType) : this(listOf(Node(metaAccount, callExecutionType))) + + operator fun plus(submissionHierarchy: SubmissionHierarchy): SubmissionHierarchy { + return SubmissionHierarchy(path + submissionHierarchy.path) + } +} + +fun SubmissionHierarchy.isDelayed() = path.any { it.callExecutionType == CallExecutionType.DELAYED } + +fun SubmissionHierarchy.selectedAccount() = path.last().account diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/di/AccountFeatureApi.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/di/AccountFeatureApi.kt new file mode 100644 index 0000000..31ba8f8 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/di/AccountFeatureApi.kt @@ -0,0 +1,185 @@ +package io.novafoundation.nova.feature_account_api.di + +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationExecutor +import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxyExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.data.repository.CreateSecretsRepository +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.GenericLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.LegacyLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_api.di.deeplinks.AccountDeepLinks +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalWithOnChainIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.OnChainIdentity +import io.novafoundation.nova.feature_account_api.domain.cloudBackup.ApplyLocalSnapshotToCloudBackupUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.CreateGiftMetaAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageUseCase +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +interface AccountFeatureApi { + + val addressInputMixinFactory: AddressInputMixinFactory + + val walletUiUseCase: WalletUiUseCase + + val signerProvider: SignerProvider + + val watchOnlyMissingKeysPresenter: WatchOnlyMissingKeysPresenter + + val signSharedState: SigningSharedState + + val onChainIdentityRepository: OnChainIdentityRepository + + val metaAccountTypePresentationMapper: MetaAccountTypePresentationMapper + + val legacyLedgerAddAccountRepository: LegacyLedgerAddAccountRepository + + val genericLedgerAddAccountRepository: GenericLedgerAddAccountRepository + + val evmTransactionService: EvmTransactionService + + val identityMixinFactory: IdentityMixin.Factory + + val languageUseCase: LanguageUseCase + + val selectWalletMixinFactory: SelectWalletMixin.Factory + + val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider + + val selectAddressMixinFactory: SelectAddressMixin.Factory + + val metaAccountChangesEventBus: MetaAccountChangesEventBus + + val applyLocalSnapshotToCloudBackupUseCase: ApplyLocalSnapshotToCloudBackupUseCase + + val feePaymentProviderRegistry: FeePaymentProviderRegistry + + val customFeeCapabilityFacade: CustomFeeCapabilityFacade + + val hydrationFeeInjector: HydrationFeeInjector + + val addressActionsMixinFactory: AddressActionsMixin.Factory + + val accountDeepLinks: AccountDeepLinks + + val mnemonicAddAccountRepository: MnemonicAddAccountRepository + + val multisigPendingOperationsService: MultisigPendingOperationsService + + val signingContextFactory: SigningContext.Factory + + val extrinsicSplitter: ExtrinsicSplitter + + val externalAccountsSyncService: ExternalAccountsSyncService + + val multisigValidationsRepository: MultisigValidationsRepository + + val multisigExtrinsicValidationRequestBus: MultisigExtrinsicValidationRequestBus + + val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + + val multisigOperationLocalCallRepository: MultisigOperationLocalCallRepository + + val multisigFormatter: MultisigFormatter + + val proxyFormatter: ProxyFormatter + + val accountUIUseCase: AccountUIUseCase + + val multisigDetailsRepository: MultisigDetailsRepository + + val metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry + + val createSecretsRepository: CreateSecretsRepository + + val createGiftMetaAccountUseCase: CreateGiftMetaAccountUseCase + + val selectSingleWalletMixin: SelectSingleWalletMixin.Factory + + @LocalIdentity + fun localIdentityProvider(): IdentityProvider + + @OnChainIdentity + fun onChainIdentityProvider(): IdentityProvider + + @LocalWithOnChainIdentity + fun localWithOnChainIdentityProvider(): IdentityProvider + + fun metaAccountGroupingInteractor(): MetaAccountGroupingInteractor + + fun accountInteractor(): AccountInteractor + + fun provideAccountRepository(): AccountRepository + + fun externalAccountActions(): ExternalActions.Presentation + + fun accountUpdateScope(): AccountUpdateScope + + fun addressDisplayUseCase(): AddressDisplayUseCase + + fun accountUseCase(): SelectedAccountUseCase + + fun extrinsicService(): ExtrinsicService + + fun extrinsicServiceFactory(): ExtrinsicService.Factory + + fun importTypeChooserMixin(): ImportTypeChooserMixin.Presentation + + fun twoFactorVerificationExecutor(): TwoFactorVerificationExecutor + + fun biometricServiceFactory(): BiometricServiceFactory + + fun encryptionDefaults(): EncryptionDefaults + + fun proxyExtrinsicValidationRequestBus(): ProxyExtrinsicValidationRequestBus + + fun cloudBackupFacade(): LocalAccountsCloudBackupFacade + + fun syncWalletsBackupPasswordCommunicator(): SyncWalletsBackupPasswordCommunicator + + fun copyAddressMixin(): CopyAddressMixin +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/di/deeplinks/AccountDeepLinks.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/di/deeplinks/AccountDeepLinks.kt new file mode 100644 index 0000000..432f60d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/di/deeplinks/AccountDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_api.di.deeplinks + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class AccountDeepLinks(val deepLinkHandlers: List) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/advancedEncryption/AdvancedEncryption.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/advancedEncryption/AdvancedEncryption.kt new file mode 100644 index 0000000..f6efbc2 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/advancedEncryption/AdvancedEncryption.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption + +import io.novafoundation.nova.common.utils.input.Input +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults + +class AdvancedEncryptionInput( + val substrateCryptoType: Input, + val substrateDerivationPath: Input, + val ethereumCryptoType: Input, + val ethereumDerivationPath: Input +) + +data class AdvancedEncryption( + val substrateCryptoType: CryptoType?, + val ethereumCryptoType: CryptoType?, + val derivationPaths: DerivationPaths +) { + + companion object; + + data class DerivationPaths( + val substrate: String?, + val ethereum: String? + ) { + companion object { + fun empty() = DerivationPaths(null, null) + } + } +} + +fun EncryptionDefaults.recommended() = AdvancedEncryption( + substrateCryptoType = substrateCryptoType, + ethereumCryptoType = ethereumCryptoType, + derivationPaths = AdvancedEncryption.DerivationPaths( + substrate = substrateDerivationPath, + ethereum = ethereumDerivationPath + ) +) + +fun AdvancedEncryption.Companion.substrate( + cryptoType: CryptoType, + substrateDerivationPaths: String? +) = AdvancedEncryption( + substrateCryptoType = cryptoType, + ethereumCryptoType = null, + derivationPaths = AdvancedEncryption.DerivationPaths( + substrate = substrateDerivationPaths, + ethereum = null + ) +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/common/ChainWithAccountId.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/common/ChainWithAccountId.kt new file mode 100644 index 0000000..09d92fd --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/common/ChainWithAccountId.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_api.domain.account.common + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ChainWithAccountId( + val chain: Chain, + val accountId: ByteArray +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/common/EncryptionDefaults.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/common/EncryptionDefaults.kt new file mode 100644 index 0000000..8bac070 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/common/EncryptionDefaults.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_api.domain.account.common + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class EncryptionDefaults( + val substrateCryptoType: CryptoType, + val ethereumCryptoType: CryptoType, + val substrateDerivationPath: String, + val ethereumDerivationPath: String +) + +class ChainEncryptionDefaults( + val cryptoType: CryptoType, + val derivationPath: String +) + +fun EncryptionDefaults.forChain(chain: Chain): ChainEncryptionDefaults { + return if (chain.isEthereumBased) { + ChainEncryptionDefaults( + cryptoType = ethereumCryptoType, + derivationPath = ethereumDerivationPath + ) + } else { + ChainEncryptionDefaults( + cryptoType = substrateCryptoType, + derivationPath = substrateDerivationPath + ) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/Identity.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/Identity.kt new file mode 100644 index 0000000..cfc829a --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/Identity.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_api.domain.account.identity + +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity + +data class Identity(val name: String) + +fun Identity(onChainIdentity: OnChainIdentity): Identity? { + return onChainIdentity.display?.let { Identity(it) } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/IdentityExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/IdentityExt.kt new file mode 100644 index 0000000..a36c2aa --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/IdentityExt.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.domain.account.identity + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.presentation.ellipsizeAddress +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +suspend fun IdentityProvider.getNameOrAddress(accountId: AccountIdKey, chain: Chain): String { + return identityFor(accountId.value, chain.id)?.name ?: chain.addressOf(accountId).ellipsizeAddress() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/IdentityProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/IdentityProvider.kt new file mode 100644 index 0000000..867748f --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/IdentityProvider.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_api.domain.account.identity + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface IdentityProvider { + + companion object; + + /** + * Returns, if present, an identity for the given [accountId] inside specified [chainId] + */ + suspend fun identityFor(accountId: AccountId, chainId: ChainId): Identity? + + /** + * Bulk version of [identityFor]. Default implementation is unoptimized and just performs N single requests to [identityFor]. + */ + suspend fun identitiesFor(accountIds: Collection, chainId: ChainId): Map { + return accountIds.associateBy( + keySelector = ::AccountIdKey, + valueTransform = { identityFor(it, chainId) } + ) + } +} + +fun IdentityProvider.Companion.oneOf(vararg delegates: IdentityProvider): IdentityProvider { + return OneOfIdentityProvider(delegates.toList()) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/OneOfIdentityProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/OneOfIdentityProvider.kt new file mode 100644 index 0000000..a5110f7 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/OneOfIdentityProvider.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_api.domain.account.identity + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class OneOfIdentityProvider( + private val delegates: List +) : IdentityProvider { + + override suspend fun identityFor(accountId: AccountId, chainId: ChainId): Identity? = withContext(Dispatchers.IO) { + delegates.tryFindNonNull { + it.identityFor(accountId, chainId) + } + } + + override suspend fun identitiesFor(accountIds: Collection, chainId: ChainId): Map = withContext(Dispatchers.IO) { + delegates.tryFindNonNull { + it.identitiesFor(accountIds, chainId) + }.orEmpty() + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/Sources.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/Sources.kt new file mode 100644 index 0000000..04a376e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/identity/Sources.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_account_api.domain.account.identity + +import javax.inject.Qualifier + +@Qualifier +annotation class OnChainIdentity + +@Qualifier +annotation class LocalIdentity + +@Qualifier +annotation class LocalWithOnChainIdentity diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/AccountSystemAccountMatcher.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/AccountSystemAccountMatcher.kt new file mode 100644 index 0000000..a12d100 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/AccountSystemAccountMatcher.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_api.domain.account.system + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novasama.substrate_sdk_android.runtime.AccountId + +class AccountSystemAccountMatcher(private val accountIdKey: AccountIdKey) : SystemAccountMatcher { + + override fun isSystemAccount(accountId: AccountId): Boolean { + return accountIdKey.value.contentEquals(accountId) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/CompoundSystemAccountMatcher.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/CompoundSystemAccountMatcher.kt new file mode 100644 index 0000000..36f216c --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/CompoundSystemAccountMatcher.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_account_api.domain.account.system + +import io.novasama.substrate_sdk_android.runtime.AccountId + +class CompoundSystemAccountMatcher( + private val delegates: List +) : SystemAccountMatcher { + + constructor(vararg delegates: SystemAccountMatcher) : this(delegates.toList()) + + override fun isSystemAccount(accountId: AccountId): Boolean { + return delegates.any { it.isSystemAccount(accountId) } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/PrefixSystemAccountMatcher.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/PrefixSystemAccountMatcher.kt new file mode 100644 index 0000000..eb2242e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/PrefixSystemAccountMatcher.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_api.domain.account.system + +import io.novafoundation.nova.common.utils.startsWith +import io.novasama.substrate_sdk_android.runtime.AccountId + +class PrefixSystemAccountMatcher(private val prefix: ByteArray) : SystemAccountMatcher { + + constructor(utf8Prefix: String) : this(utf8Prefix.encodeToByteArray()) + + override fun isSystemAccount(accountId: AccountId): Boolean { + return accountId.startsWith(prefix) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/SystemAccountMatcher.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/SystemAccountMatcher.kt new file mode 100644 index 0000000..dacd6e9 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/account/system/SystemAccountMatcher.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_api.domain.account.system + +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface SystemAccountMatcher { + + companion object + + fun isSystemAccount(accountId: AccountId): Boolean +} + +fun SystemAccountMatcher.Companion.default(): SystemAccountMatcher { + return CompoundSystemAccountMatcher( + // Pallet-specific technical accounts, e.g. crowdloan-fund, nomination pool, + PrefixSystemAccountMatcher("modl"), + // Parachain sovereign accounts on relaychain + PrefixSystemAccountMatcher("para"), + // Relaychain sovereign account on parachains + PrefixSystemAccountMatcher("Parent"), + // Sibling parachain soveregin accounts + PrefixSystemAccountMatcher("sibl") + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/cloudBackup/ApplyLocalSnapshotToCloudBackupUseCase.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/cloudBackup/ApplyLocalSnapshotToCloudBackupUseCase.kt new file mode 100644 index 0000000..0cc8bc7 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/cloudBackup/ApplyLocalSnapshotToCloudBackupUseCase.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_api.domain.cloudBackup + +interface ApplyLocalSnapshotToCloudBackupUseCase { + + suspend fun applyLocalSnapshotToCloudBackupIfSyncEnabled(): Result +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/filter/selectAddress/SelectAccountFilter.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/filter/selectAddress/SelectAccountFilter.kt new file mode 100644 index 0000000..4c9804b --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/filter/selectAddress/SelectAccountFilter.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_api.domain.filter.selectAddress + +import io.novafoundation.nova.common.utils.Filter +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.isControllableWallet + +sealed interface SelectAccountFilter : Filter { + + class Everything : SelectAccountFilter { + + override fun shouldInclude(model: MetaAccount): Boolean { + return true + } + } + + class ControllableWallets() : SelectAccountFilter { + + override fun shouldInclude(model: MetaAccount): Boolean { + return model.type.isControllableWallet() + } + } + + class ExcludeMetaAccounts(val metaIds: List) : SelectAccountFilter { + + override fun shouldInclude(model: MetaAccount): Boolean { + return !metaIds.contains(model.id) + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountInteractor.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountInteractor.kt new file mode 100644 index 0000000..f77bfa9 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountInteractor.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_account_api.domain.interfaces + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core.model.Language +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.feature_account_api.domain.model.Account +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.PreferredCryptoType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.mnemonic.Mnemonic +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface AccountInteractor { + + suspend fun getActiveMetaAccounts(): List + + suspend fun generateMnemonic(): Mnemonic + + fun getCryptoTypes(): List + + suspend fun getPreferredCryptoType(chainId: ChainId? = null): PreferredCryptoType + + suspend fun isCodeSet(): Boolean + + suspend fun savePin(code: String) + + suspend fun isPinCorrect(code: String): Boolean + + suspend fun getMetaAccount(metaId: Long): MetaAccount + + suspend fun selectMetaAccount(metaId: Long) + + suspend fun selectedMetaAccount(): MetaAccount + + suspend fun deleteAccount(metaId: Long): Boolean + + suspend fun updateMetaAccountPositions(idsInNewOrder: List) + + fun chainFlow(chainId: ChainId): Flow + + fun nodesFlow(): Flow> + + suspend fun getNode(nodeId: Int): Node + + fun getLanguages(): List + + suspend fun getSelectedLanguage(): Language + + suspend fun changeSelectedLanguage(language: Language) + + suspend fun addNode(nodeName: String, nodeHost: String): Result + + suspend fun updateNode(nodeId: Int, newName: String, newHost: String): Result + + suspend fun getAccountsByNetworkTypeWithSelectedNode(networkType: Node.NetworkType): Pair, Node> + + suspend fun selectNodeAndAccount(nodeId: Int, accountAddress: String) + + suspend fun selectNode(nodeId: Int) + + suspend fun deleteNode(nodeId: Int) + + suspend fun getChainAddress(metaId: Long, chainId: ChainId): String? + + suspend fun removeDeactivatedMetaAccounts() + + suspend fun switchToNotDeactivatedAccountIfNeeded() + + suspend fun hasSecretsAccounts(): Boolean + + suspend fun hasCustomChainAccounts(metaId: Long): Boolean + + suspend fun deleteProxiedMetaAccountsByChain(chainId: String) + + suspend fun findMetaAccount(chain: Chain, value: AccountId): MetaAccount? +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountRepository.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountRepository.kt new file mode 100644 index 0000000..16312de --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountRepository.kt @@ -0,0 +1,148 @@ +package io.novafoundation.nova.feature_account_api.domain.interfaces + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core.model.Language +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.feature_account_api.domain.model.Account +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountAssetBalance +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountOrdering +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.mnemonic.Mnemonic +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +class AccountAlreadyExistsException : Exception() + +interface AccountRepository { + + fun getEncryptionTypes(): List + + suspend fun getNode(nodeId: Int): Node + + suspend fun getSelectedNodeOrDefault(): Node + + suspend fun selectNode(node: Node) + + suspend fun getDefaultNode(networkType: Node.NetworkType): Node + + suspend fun selectAccount(account: Account, newNode: Node? = null) + + suspend fun getSelectedMetaAccount(): MetaAccount + + suspend fun getMetaAccount(metaId: Long): MetaAccount + + fun metaAccountFlow(metaId: Long): Flow + + fun selectedMetaAccountFlow(): Flow + + suspend fun findMetaAccount(accountId: ByteArray, chainId: ChainId): MetaAccount? + + suspend fun accountNameFor(accountId: AccountId, chainId: ChainId): String? + + suspend fun hasActiveMetaAccounts(): Boolean + + fun allMetaAccountsFlow(): Flow> + + fun activeMetaAccountsFlow(): Flow> + + fun metaAccountsByTypeFlow(type: LightMetaAccount.Type): Flow> + + fun metaAccountBalancesFlow(): Flow> + + fun metaAccountBalancesFlow(metaId: Long): Flow> + + suspend fun selectMetaAccount(metaId: Long) + + suspend fun updateMetaAccountName(metaId: Long, newName: String) + + suspend fun isAccountSelected(): Boolean + + suspend fun deleteAccount(metaId: Long) + + suspend fun getAccounts(): List + + suspend fun getAccount(address: String): Account + + suspend fun getAccountOrNull(address: String): Account? + + suspend fun getMyAccounts(query: String, chainId: String): Set + + suspend fun isCodeSet(): Boolean + + suspend fun savePinCode(code: String) + + suspend fun getPinCode(): String? + + suspend fun generateMnemonic(): Mnemonic + + fun isBiometricEnabledFlow(): Flow + + fun isBiometricEnabled(): Boolean + + fun setBiometricOn() + + fun setBiometricOff() + + fun nodesFlow(): Flow> + + suspend fun updateAccountsOrdering(accountOrdering: List) + + fun getLanguages(): List + + suspend fun selectedLanguage(): Language + + suspend fun changeLanguage(language: Language) + + suspend fun addNode(nodeName: String, nodeHost: String, networkType: Node.NetworkType) + + suspend fun updateNode(nodeId: Int, newName: String, newHost: String, networkType: Node.NetworkType) + + suspend fun checkNodeExists(nodeHost: String): Boolean + + /** + * @throws NovaException + * @throws NovaException + */ + suspend fun getNetworkName(nodeHost: String): String + + suspend fun getAccountsByNetworkType(networkType: Node.NetworkType): List + + suspend fun deleteNode(nodeId: Int) + + suspend fun createQrAccountContent(chain: Chain, account: MetaAccount): String + + suspend fun generateRestoreJson( + metaAccount: MetaAccount, + chain: Chain, + password: String + ): String + + suspend fun isAccountExists(accountId: AccountId, chainId: ChainId): Boolean + + suspend fun removeDeactivatedMetaAccounts() + + suspend fun getActiveMetaAccounts(): List + + suspend fun getAllMetaAccounts(): List + + suspend fun getActiveMetaAccountsQuantity(): Int + + fun hasMetaAccountsCountOfTypeFlow(type: LightMetaAccount.Type): Flow + + suspend fun hasMetaAccountsByType(type: LightMetaAccount.Type): Boolean + + suspend fun hasMetaAccountsByType(metaIds: Set, type: LightMetaAccount.Type): Boolean + + suspend fun generateRestoreJson(metaAccount: MetaAccount, password: String): String + + suspend fun hasSecretsAccounts(): Boolean + + suspend fun deleteProxiedMetaAccountsByChain(chainId: String) + + suspend fun getMetaAccountsByIds(metaIds: List): List + + suspend fun getAvailableMetaIdsFromSet(metaIds: Set): Set +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountRepositoryExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountRepositoryExt.kt new file mode 100644 index 0000000..6df6150 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountRepositoryExt.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_account_api.domain.interfaces + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId + +suspend fun AccountRepository.findMetaAccountOrThrow(accountId: AccountId, chainId: ChainId) = findMetaAccount(accountId, chainId) + ?: error("No meta account found for accountId: ${accountId.toHexString()}") + +suspend fun AccountRepository.requireIdOfSelectedMetaAccountIn(chain: Chain): AccountId { + val metaAccount = getSelectedMetaAccount() + + return metaAccount.requireAccountIdIn(chain) +} + +suspend fun AccountRepository.requireIdKeyOfSelectedMetaAccountIn(chain: Chain): AccountIdKey { + return requireIdOfSelectedMetaAccountIn(chain).intoKey() +} + +suspend fun AccountRepository.getIdOfSelectedMetaAccountIn(chain: Chain): AccountId? { + val metaAccount = getSelectedMetaAccount() + + return metaAccount.accountIdIn(chain) +} + +suspend fun AccountRepository.requireMetaAccountFor(transactionOrigin: TransactionOrigin, chainId: ChainId): MetaAccount { + return when (transactionOrigin) { + TransactionOrigin.SelectedWallet -> getSelectedMetaAccount() + is TransactionOrigin.WalletWithAccount -> findMetaAccountOrThrow(transactionOrigin.accountId, chainId) + is TransactionOrigin.Wallet -> transactionOrigin.metaAccount + is TransactionOrigin.WalletWithId -> getMetaAccount(transactionOrigin.metaId) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountUIUseCase.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountUIUseCase.kt new file mode 100644 index 0000000..d174a12 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/AccountUIUseCase.kt @@ -0,0 +1,108 @@ +package io.novafoundation.nova.feature_account_api.domain.interfaces + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressIconGenerator.Companion.BACKGROUND_TRANSPARENT +import io.novafoundation.nova.common.address.AddressIconGenerator.Companion.SIZE_MEDIUM +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.accountIdKeyIn +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed interface AccountModel { + + fun address(): String + + fun drawable(): Drawable? + + fun nameOrAddress(): String + + class Wallet( + metaId: Long, + name: String, + icon: Drawable?, + private val address: String + ) : WalletModel(metaId, name, icon), AccountModel { + + constructor(walletModel: WalletModel, address: String) : this(walletModel.metaId, walletModel.name, walletModel.icon, address) + + override fun address() = address + override fun drawable() = icon + override fun nameOrAddress() = name + } + + class Address( + address: String, + image: Drawable, + name: String? = null + ) : AddressModel(address, image, name), AccountModel { + + constructor(addressModel: AddressModel) : this(addressModel.address, addressModel.image, addressModel.name) + + override fun address() = address + override fun drawable() = image + override fun nameOrAddress() = nameOrAddress + } +} + +interface AccountUIUseCase { + + suspend fun getAccountModel(accountId: AccountIdKey, chain: Chain): AccountModel + + suspend fun getAccountModels(accountIds: Set, chain: Chain): Map +} + +class RealAccountUIUseCase( + private val accountRepository: AccountRepository, + private val walletUiUseCase: WalletUiUseCase, + private val addressIconGenerator: AddressIconGenerator, + private val identityProvider: IdentityProvider +) : AccountUIUseCase { + + override suspend fun getAccountModel(accountId: AccountIdKey, chain: Chain): AccountModel { + return getAccountModelInternal( + accountId, + chain, + accountRepository.findMetaAccount(accountId.value, chain.id), + identityProvider.identityFor(accountId.value, chain.id) + ) + } + + override suspend fun getAccountModels(accountIds: Set, chain: Chain): Map { + val identities = identityProvider.identitiesFor(accountIds.map { it.value }, chain.id) + val metaAccounts = accountRepository.getActiveMetaAccounts().associateBy { it.accountIdKeyIn(chain) } + + return accountIds.associateWith { accountId -> + val metaAccount = metaAccounts[accountId] + val identity = identities[accountId] + getAccountModelInternal(accountId, chain, metaAccount, identity) + } + } + + private suspend fun getAccountModelInternal(accountId: AccountIdKey, chain: Chain, metaAccount: MetaAccount?, identity: Identity?): AccountModel { + return when (metaAccount) { + null -> { + val addressModel = addressIconGenerator.createAddressModel( + chain, + chain.addressOf(accountId), + SIZE_MEDIUM, + accountName = identity?.name, + background = BACKGROUND_TRANSPARENT + ) + AccountModel.Address(addressModel) + } + + else -> { + val walletModel = walletUiUseCase.walletUiFor(metaAccount) + AccountModel.Wallet(walletModel, chain.addressOf(accountId)) + } + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/CreateGiftMetaAccountUseCase.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/CreateGiftMetaAccountUseCase.kt new file mode 100644 index 0000000..618cabf --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/CreateGiftMetaAccountUseCase.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_api.domain.interfaces + +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +interface CreateGiftMetaAccountUseCase { + + fun createTemporaryGiftMetaAccount(chain: Chain, chainSecrets: EncodableStruct): MetaAccount +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/MetaAccountGroupingInteractor.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/MetaAccountGroupingInteractor.kt new file mode 100644 index 0000000..f333c00 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/MetaAccountGroupingInteractor.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_api.domain.interfaces + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.common.utils.Filter +import io.novafoundation.nova.feature_account_api.domain.model.AccountDelegation +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountListingItem +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface MetaAccountGroupingInteractor { + + fun metaAccountsWithTotalBalanceFlow(): Flow> + + fun metaAccountWithTotalBalanceFlow(metaId: Long): Flow + + fun getMetaAccountsWithFilter(metaAccountFilter: Filter): Flow> + + fun updatedDelegates(): Flow> + + suspend fun hasAvailableMetaAccountsForChain( + chainId: ChainId, + metaAccountFilter: Filter + ): Boolean +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/SelectedAccountUseCase.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/SelectedAccountUseCase.kt new file mode 100644 index 0000000..474d4e3 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/interfaces/SelectedAccountUseCase.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_api.domain.interfaces + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.view.TintedIcon +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class SelectedWalletModel( + val typeIcon: TintedIcon?, + val walletIcon: Drawable, + val name: String, + val hasUpdates: Boolean, +) + +interface SelectedAccountUseCase { + + fun selectedMetaAccountFlow(): Flow + + fun selectedAddressModelFlow(chain: suspend () -> Chain): Flow + + fun selectedWalletModelFlow(): Flow + + suspend fun getSelectedMetaAccount(): MetaAccount +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/Account.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/Account.kt new file mode 100644 index 0000000..5aa775e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/Account.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core.model.Network +import io.novasama.substrate_sdk_android.extensions.fromHex + +data class Account( + val address: String, + val name: String?, + val accountIdHex: String, + val cryptoType: CryptoType, // TODO make optional + val position: Int, + val network: Network, // TODO remove when account management will be rewritten, +) { + + val accountId = accountIdHex.fromHex() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/AddAccountType.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/AddAccountType.kt new file mode 100644 index 0000000..26dfa2e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/AddAccountType.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +sealed class AddAccountType { + + class MetaAccount(val name: String) : AddAccountType() + + class ChainAccount(val chainId: ChainId, val metaId: Long) : AddAccountType() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/AuthType.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/AuthType.kt new file mode 100644 index 0000000..4f8c837 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/AuthType.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +enum class AuthType { + PINCODE, + BIOMETRY +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/Extensions.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/Extensions.kt new file mode 100644 index 0000000..b493fb6 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/Extensions.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +fun metaAccountTypeComparator() = compareBy { + when (it) { + LightMetaAccount.Type.SECRETS -> 0 + LightMetaAccount.Type.POLKADOT_VAULT -> 1 + LightMetaAccount.Type.PARITY_SIGNER -> 2 + LightMetaAccount.Type.LEDGER -> 3 + LightMetaAccount.Type.LEDGER_LEGACY -> 4 + LightMetaAccount.Type.PROXIED -> 5 + LightMetaAccount.Type.MULTISIG -> 6 + LightMetaAccount.Type.WATCH_ONLY -> 7 + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/ImportJsonMetaData.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/ImportJsonMetaData.kt new file mode 100644 index 0000000..b078475 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/ImportJsonMetaData.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +import io.novafoundation.nova.core.model.CryptoType + +class ImportJsonMetaData( + val name: String?, + val chainId: String?, + val encryptionType: CryptoType +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/LedgerVariant.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/LedgerVariant.kt new file mode 100644 index 0000000..326c8a7 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/LedgerVariant.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +enum class LedgerVariant { + LEGACY, GENERIC +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt new file mode 100644 index 0000000..57f1c4d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt @@ -0,0 +1,298 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.mappers.mapCryptoTypeToEncryption +import io.novafoundation.nova.common.data.mappers.mapEncryptionToCryptoType +import io.novafoundation.nova.common.utils.DEFAULT_PREFIX +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.toEthereumAddress +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption +import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAddress +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +class MetaIdWithType( + val metaId: Long, + val type: LightMetaAccount.Type +) + +class MetaAccountOrdering( + val id: Long, + val position: Int, +) + +interface LightMetaAccount { + + val id: Long + + /** + * In contrast to [id] which should only be unique **locally**, [globallyUniqueId] should be unique **globally**, + * meaning it should be unique across all application instances. This is useful to compare meta accounts from different application instances + */ + val globallyUniqueId: String + + val substratePublicKey: ByteArray? + val substrateCryptoType: CryptoType? + val substrateAccountId: ByteArray? + val ethereumAddress: ByteArray? + val ethereumPublicKey: ByteArray? + val isSelected: Boolean + val name: String + val type: Type + val status: Status + + val parentMetaId: Long? + + enum class Type { + SECRETS, + WATCH_ONLY, + PARITY_SIGNER, + LEDGER_LEGACY, + LEDGER, + POLKADOT_VAULT, + PROXIED, + MULTISIG + } + + enum class Status { + ACTIVE, DEACTIVATED + } +} + +fun LightMetaAccount( + id: Long, + globallyUniqueId: String, + substratePublicKey: ByteArray?, + substrateCryptoType: CryptoType?, + substrateAccountId: ByteArray?, + ethereumAddress: ByteArray?, + ethereumPublicKey: ByteArray?, + isSelected: Boolean, + name: String, + type: LightMetaAccount.Type, + status: LightMetaAccount.Status, + parentMetaId: Long?, +) = object : LightMetaAccount { + override val id: Long = id + override val globallyUniqueId: String = globallyUniqueId + override val substratePublicKey: ByteArray? = substratePublicKey + override val substrateCryptoType: CryptoType? = substrateCryptoType + override val substrateAccountId: ByteArray? = substrateAccountId + override val ethereumAddress: ByteArray? = ethereumAddress + override val ethereumPublicKey: ByteArray? = ethereumPublicKey + override val isSelected: Boolean = isSelected + override val name: String = name + override val type: LightMetaAccount.Type = type + override val status: LightMetaAccount.Status = status + override val parentMetaId: Long? = parentMetaId +} + +interface MetaAccount : LightMetaAccount { + + // TODO this should not be exposed as its a implementation detail + // We should rather use something like + // fun iterateAccounts(): Iterable<(AccountId, ChainId?, MultiChainEncryption?)> + val chainAccounts: Map + + class ChainAccount( + val metaId: Long, + val chainId: ChainId, + val publicKey: ByteArray?, + val accountId: ByteArray, + // TODO this should be MultiChainEncryption + val cryptoType: CryptoType?, + ) + + suspend fun supportsAddingChainAccount(chain: Chain): Boolean + + fun hasAccountIn(chain: Chain): Boolean + + fun accountIdIn(chain: Chain): AccountId? + + fun publicKeyIn(chain: Chain): ByteArray? +} + +interface SecretsMetaAccount : MetaAccount { + + fun multiChainEncryptionIn(chain: Chain): MultiChainEncryption? +} + +interface ProxiedMetaAccount : MetaAccount { + + val proxy: ProxyAccount +} + +interface MultisigMetaAccount : MetaAccount { + + val signatoryMetaId: Long + + val signatoryAccountId: AccountIdKey + + /** + * A **sorted** list of other signatories signatories of the account + */ + val otherSignatories: List + + val threshold: Int + + val availability: MultisigAvailability +} + +sealed class MultisigAvailability { + + class Universal(val addressScheme: AddressScheme) : MultisigAvailability() + + class SingleChain(val chainId: ChainId) : MultisigAvailability() +} + +fun MetaAccount.isUniversal(): Boolean { + return substrateAccountId != null || ethereumAddress != null +} + +fun MultisigAvailability.singleChainId(): ChainId? { + return when (this) { + is MultisigAvailability.SingleChain -> chainId + is MultisigAvailability.Universal -> null + } +} + +fun MultisigMetaAccount.isThreshold1(): Boolean { + return threshold == 1 +} + +fun MetaAccount.requireMultisigAccount() = this as MultisigMetaAccount + +fun MetaAccount.hasChainAccountIn(chainId: ChainId) = chainId in chainAccounts + +fun MetaAccount.addressIn(chain: Chain): String? { + return accountIdIn(chain)?.let(chain::addressOf) +} + +fun MetaAccount.accountIdKeyIn(chain: Chain): AccountIdKey? { + return accountIdIn(chain)?.let(::AccountIdKey) +} + +fun MetaAccount.mainEthereumAddress() = ethereumAddress?.toEthereumAddress() + +fun MetaAccount.requireAddressIn(chain: Chain): String = addressIn(chain) ?: throw NoSuchElementException("No chain account found for ${chain.name} in $name") + +val MetaAccount.defaultSubstrateAddress: String? + get() = substrateAccountId?.toDefaultSubstrateAddress() + +fun ByteArray.toDefaultSubstrateAddress(): String { + return toAddress(SS58Encoder.DEFAULT_PREFIX) +} + +fun MetaAccount.substrateMultiChainEncryption(): MultiChainEncryption? { + return substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) +} + +fun MetaAccount.requireAccountIdIn(chain: Chain): ByteArray { + return requireNotNull(accountIdIn(chain)) +} + +fun MetaAccount.requireAccountIdKeyIn(chain: Chain): AccountIdKey { + return requireAccountIdIn(chain).intoKey() +} + +fun MetaAccount.multiChainEncryptionIn(chain: Chain): MultiChainEncryption? { + return (this as? SecretsMetaAccount)?.multiChainEncryptionIn(chain) +} + +fun MetaAccount.cryptoTypeIn(chain: Chain): CryptoType? { + return multiChainEncryptionIn(chain)?.toCryptoType() +} + +private fun MultiChainEncryption.toCryptoType(): CryptoType { + return when (this) { + is MultiChainEncryption.Substrate -> mapEncryptionToCryptoType(encryptionType) + MultiChainEncryption.Ethereum -> CryptoType.ECDSA + } +} + +fun MultiChainEncryption.Companion.substrateFrom(cryptoType: CryptoType): MultiChainEncryption.Substrate { + return MultiChainEncryption.Substrate(mapCryptoTypeToEncryption(cryptoType)) +} + +fun MetaAccount.ethereumAccountId() = ethereumPublicKey?.asEthereumPublicKey()?.toAccountId()?.value + +fun MetaAccount.chainAccountFor(chainId: ChainId) = chainAccounts.getValue(chainId) + +fun LightMetaAccount.Type.asPolkadotVaultVariantOrNull(): PolkadotVaultVariant? { + return when (this) { + LightMetaAccount.Type.PARITY_SIGNER -> PolkadotVaultVariant.PARITY_SIGNER + LightMetaAccount.Type.POLKADOT_VAULT -> PolkadotVaultVariant.POLKADOT_VAULT + else -> null + } +} + +fun LightMetaAccount.Type.asPolkadotVaultVariantOrThrow(): PolkadotVaultVariant { + return requireNotNull(asPolkadotVaultVariantOrNull()) { + "Not a Polkadot Vault compatible account type" + } +} + +fun LightMetaAccount.Type.requestedAccountPaysFees(): Boolean { + return when (this) { + LightMetaAccount.Type.SECRETS, + LightMetaAccount.Type.WATCH_ONLY, + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.LEDGER_LEGACY, + LightMetaAccount.Type.LEDGER, + LightMetaAccount.Type.POLKADOT_VAULT -> true + + LightMetaAccount.Type.PROXIED, + LightMetaAccount.Type.MULTISIG -> false + } +} + +fun LightMetaAccount.Type.isControllableWallet(): Boolean { + return when (this) { + LightMetaAccount.Type.SECRETS, + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.LEDGER_LEGACY, + LightMetaAccount.Type.LEDGER, + LightMetaAccount.Type.POLKADOT_VAULT -> true + + LightMetaAccount.Type.WATCH_ONLY, + LightMetaAccount.Type.PROXIED, + LightMetaAccount.Type.MULTISIG -> false + } +} + +@OptIn(ExperimentalContracts::class) +fun LightMetaAccount.isProxied(): Boolean { + contract { + returns(true) implies (this@isProxied is ProxiedMetaAccount) + } + + return this is ProxiedMetaAccount +} + +@OptIn(ExperimentalContracts::class) +fun LightMetaAccount.isMultisig(): Boolean { + contract { + returns(true) implies (this@isMultisig is MultisigMetaAccount) + } + + return this is MultisigMetaAccount +} + +fun LightMetaAccount.asProxied(): ProxiedMetaAccount = this as ProxiedMetaAccount +fun LightMetaAccount.asMultisig(): MultisigMetaAccount = this as MultisigMetaAccount + +fun MultisigMetaAccount.signatoriesCount() = 1 + otherSignatories.size + +fun MultisigMetaAccount.allSignatories() = buildSet { + add(signatoryAccountId) + addAll(otherSignatories.toSet()) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccountAssetBalance.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccountAssetBalance.kt new file mode 100644 index 0000000..ea9c4db --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccountAssetBalance.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +import io.novafoundation.nova.common.utils.Precision +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +class MetaAccountAssetBalance( + val metaId: Long, + val freeInPlanks: BigInteger, + val reservedInPlanks: BigInteger, + val offChainBalance: BigInteger?, + val precision: Precision, + val rate: BigDecimal? +) + +sealed interface MetaAccountListingItem { + + val metaAccount: MetaAccount + + val hasUpdates: Boolean + + val totalBalance: BigDecimal + + val currency: Currency + + class Proxied( + val proxyMetaAccount: MetaAccount, + val proxyChain: Chain, + override val totalBalance: BigDecimal, + override val currency: Currency, + override val metaAccount: ProxiedMetaAccount, + override val hasUpdates: Boolean + ) : MetaAccountListingItem + + class Multisig( + val signatory: MetaAccount, + val singleChain: Chain?, // null in case multisig is universal + override val totalBalance: BigDecimal, + override val currency: Currency, + override val metaAccount: MultisigMetaAccount, + override val hasUpdates: Boolean + ) : MetaAccountListingItem + + class TotalBalance( + override val totalBalance: BigDecimal, + override val currency: Currency, + override val metaAccount: MetaAccount, + override val hasUpdates: Boolean + ) : MetaAccountListingItem +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/PolkadotVaultVariant.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/PolkadotVaultVariant.kt new file mode 100644 index 0000000..46d615e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/PolkadotVaultVariant.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +enum class PolkadotVaultVariant { + + POLKADOT_VAULT, PARITY_SIGNER +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/PreferredCryptoType.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/PreferredCryptoType.kt new file mode 100644 index 0000000..93d406d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/PreferredCryptoType.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +import io.novafoundation.nova.core.model.CryptoType + +data class PreferredCryptoType( + val cryptoType: CryptoType, + val frozen: Boolean +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/ProxiedAndProxyMetaAccount.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/ProxiedAndProxyMetaAccount.kt new file mode 100644 index 0000000..ebf1f0d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/ProxiedAndProxyMetaAccount.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed interface AccountDelegation { + + val delegator: MetaAccount + + class Proxy( + val proxied: ProxiedMetaAccount, + val proxy: MetaAccount, + val chain: Chain + ) : AccountDelegation { + + override val delegator = proxied + } + + class Multisig( + val metaAccount: MultisigMetaAccount, + val signatory: MetaAccount, + val singleChain: Chain?, // null in case multisig is universal + ) : AccountDelegation { + + override val delegator = metaAccount + } +} + +fun AccountDelegation.getChainOrNull(): Chain? { + return when (this) { + is AccountDelegation.Multisig -> singleChain + is AccountDelegation.Proxy -> chain + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/ProxyAccount.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/ProxyAccount.kt new file mode 100644 index 0000000..68855aa --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/ProxyAccount.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class ProxyAccount( + val proxyMetaId: Long, + val chainId: ChainId, + val proxyType: ProxyType, +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/SavedMultisigOperationCall.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/SavedMultisigOperationCall.kt new file mode 100644 index 0000000..6115f2f --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/SavedMultisigOperationCall.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_api.domain.model + +class SavedMultisigOperationCall( + val metaId: Long, + val chainId: String, + val callHash: ByteArray, + val callInstance: String +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/multisig/CallHash.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/multisig/CallHash.kt new file mode 100644 index 0000000..8b1af0d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/multisig/CallHash.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_account_api.domain.multisig + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novasama.substrate_sdk_android.extensions.fromHex + +// TODO multisig: we are using `AccountIdKey` as it logically represents the `DataByteArray` +// We need to create DataByteArray class that AccountIdKey will typealias to +typealias CallHash = AccountIdKey + +fun String.intoCallHash() = fromHex().intoCallHash() + +fun ByteArray.intoCallHash() = intoKey() + +fun bindCallHash(decoded: Any?) = bindAccountIdKey(decoded) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/updaters/AccountUpdateScope.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/updaters/AccountUpdateScope.kt new file mode 100644 index 0000000..ff2dddd --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/updaters/AccountUpdateScope.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_api.domain.updaters + +import io.novafoundation.nova.core.updater.UpdateScope +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import kotlinx.coroutines.flow.Flow + +class AccountUpdateScope( + private val accountRepository: AccountRepository +) : UpdateScope { + + override fun invalidationFlow(): Flow { + return accountRepository.selectedMetaAccountFlow() + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/updaters/ChainUpdateScope.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/updaters/ChainUpdateScope.kt new file mode 100644 index 0000000..c16d25e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/updaters/ChainUpdateScope.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_account_api.domain.updaters + +import io.novafoundation.nova.core.updater.UpdateScope +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class ChainUpdateScope( + private val chainFlow: Flow +) : UpdateScope { + + override fun invalidationFlow(): Flow { + return chainFlow + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/validation/HasChainAccountValidation.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/validation/HasChainAccountValidation.kt new file mode 100644 index 0000000..7288c87 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/validation/HasChainAccountValidation.kt @@ -0,0 +1,125 @@ +package io.novafoundation.nova.feature_account_api.domain.validation + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer.Payload.DialogAction.Companion.noOp +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.LEDGER +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.LEDGER_LEGACY +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.MULTISIG +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.PARITY_SIGNER +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.POLKADOT_VAULT +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.PROXIED +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.domain.model.asPolkadotVaultVariantOrThrow +import io.novafoundation.nova.feature_account_api.domain.validation.NoChainAccountFoundError.AddAccountState +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.polkadotVaultLabelFor +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface NoChainAccountFoundError { + + val chain: Chain + val account: MetaAccount + val addAccountState: AddAccountState + + sealed class AddAccountState { + + object CanAdd : AddAccountState() + + object LedgerNotSupported : AddAccountState() + + class PolkadotVaultNotSupported(val variant: PolkadotVaultVariant) : AddAccountState() + + object ProxyAccountNotSupported : AddAccountState() + + object MultisigNotSupported : AddAccountState() + } +} + +class HasChainAccountValidation( + private val chainExtractor: (P) -> Chain, + private val metaAccountExtractor: (P) -> MetaAccount, + private val errorProducer: (chain: Chain, account: MetaAccount, addAccountState: AddAccountState) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val account = metaAccountExtractor(value) + val chain = chainExtractor(value) + + return when { + account.hasAccountIn(chain) -> ValidationStatus.Valid() + + account.supportsAddingChainAccount(chain) -> errorProducer(chain, account, AddAccountState.CanAdd).validationError() + + else -> when (account.type) { + LEDGER_LEGACY, LEDGER -> errorProducer(chain, account, AddAccountState.LedgerNotSupported).validationError() + PROXIED -> errorProducer(chain, account, AddAccountState.ProxyAccountNotSupported).validationError() + MULTISIG -> errorProducer(chain, account, AddAccountState.ProxyAccountNotSupported).validationError() + POLKADOT_VAULT, PARITY_SIGNER -> { + val variant = account.type.asPolkadotVaultVariantOrThrow() + errorProducer(chain, account, AddAccountState.PolkadotVaultNotSupported(variant)).validationError() + } + + LightMetaAccount.Type.SECRETS, LightMetaAccount.Type.WATCH_ONLY -> + error("Unexpected type with not possible to add account: ${account.type}") + } + } + } +} + +fun ValidationSystemBuilder.hasChainAccount( + chain: (P) -> Chain, + metaAccount: (P) -> MetaAccount, + error: (chain: Chain, account: MetaAccount, addAccountState: AddAccountState) -> E +) { + validate(HasChainAccountValidation(chain, metaAccount, error)) +} + +fun handleChainAccountNotFound( + failure: NoChainAccountFoundError, + @StringRes addAccountDescriptionRes: Int, + resourceManager: ResourceManager, + goToWalletDetails: (metaAccountId: Long) -> Unit +): TransformedFailure { + val chainName = failure.chain.name + + return when (val state = failure.addAccountState) { + AddAccountState.CanAdd -> TransformedFailure.Custom( + dialogPayload = CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.common_missing_account_title, chainName), + message = resourceManager.getString(addAccountDescriptionRes, chainName), + okAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_add), + action = { goToWalletDetails(failure.account.id) } + ), + cancelAction = noOp(resourceManager.getString(R.string.common_cancel)), + customStyle = R.style.AccentNegativeAlertDialogTheme + ) + ) + + AddAccountState.LedgerNotSupported -> TransformedFailure.Default( + resourceManager.getString(R.string.ledger_chain_not_supported, chainName) to null + ) + + is AddAccountState.PolkadotVaultNotSupported -> { + val vaultLabel = resourceManager.polkadotVaultLabelFor(state.variant) + + TransformedFailure.Default( + resourceManager.getString(R.string.account_parity_signer_chain_not_supported, vaultLabel, chainName) to null + ) + } + + AddAccountState.ProxyAccountNotSupported, + AddAccountState.MultisigNotSupported -> TransformedFailure.Default( + resourceManager.getString(R.string.common_network_not_supported, chainName) to null + ) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/validation/NotSelfAccountValidation.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/validation/NotSelfAccountValidation.kt new file mode 100644 index 0000000..8d8b912 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/validation/NotSelfAccountValidation.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_account_api.domain.validation + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class NotSelfAccountValidation( + private val chainProvider: (P) -> Chain, + private val accountIdProvider: (P) -> AccountId, + private val failure: (P) -> F, + private val accountRepository: AccountRepository, +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val chain = chainProvider(value) + val accountId = accountIdProvider(value) + val selfAccountId = accountRepository.getSelectedMetaAccount() + .accountIdIn(chain) + + val isDifferentAccounts = !accountId.contentEquals(selfAccountId) + return validOrError(isDifferentAccounts) { + failure(value) + } + } +} + +fun ValidationSystemBuilder.notSelfAccount( + chainProvider: (P) -> Chain, + accountIdProvider: (P) -> AccountId, + failure: (P) -> F, + accountRepository: AccountRepository, +) { + validate(NotSelfAccountValidation(chainProvider, accountIdProvider, failure, accountRepository)) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/validation/SystemAccountRecipientValidation.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/validation/SystemAccountRecipientValidation.kt new file mode 100644 index 0000000..12f8908 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/validation/SystemAccountRecipientValidation.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_account_api.domain.validation + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isFalseOrWarning +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.domain.account.system.SystemAccountMatcher +import io.novafoundation.nova.feature_account_api.domain.account.system.default +import io.novasama.substrate_sdk_android.runtime.AccountId + +class SystemAccountRecipientValidation( + private val accountId: (P) -> AccountId?, + private val error: (AccountId) -> E, + private val matcher: SystemAccountMatcher = SystemAccountMatcher.default(), +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val accountId = accountId(value) ?: return valid() + + return matcher.isSystemAccount(accountId) isFalseOrWarning { + error(accountId) + } + } +} + +fun ValidationSystemBuilder.notSystemAccount( + accountId: (P) -> AccountId?, + error: (AccountId) -> E, +) { + validate(SystemAccountRecipientValidation(accountId, error)) +} + +fun handleSystemAccountValidationFailure(resourceManager: ResourceManager): TitleAndMessage { + return resourceManager.getString(R.string.send_recipient_system_account_title) to + resourceManager.getString(R.string.send_recipient_system_account_message) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/AddressDisplayUseCase.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/AddressDisplayUseCase.kt new file mode 100644 index 0000000..0d47cee --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/AddressDisplayUseCase.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AddressDisplayUseCase( + private val accountRepository: AccountRepository, +) { + + class Identifier(private val addressToName: Map) { + + fun nameOrAddress(address: String): String { + return addressToName[address] ?: address + } + } + + suspend operator fun invoke(accountId: AccountId, chainId: ChainId): String? = withContext(Dispatchers.Default) { + accountRepository.findMetaAccount(accountId, chainId)?.name + } + + suspend fun createIdentifier(): Identifier = withContext(Dispatchers.Default) { + val accounts = accountRepository.getAccounts().associateBy( + keySelector = { it.address }, + valueTransform = { it.name } + ) + + Identifier(accounts) + } +} + +suspend operator fun AddressDisplayUseCase.invoke(chain: Chain, address: String): String? { + return runCatching { invoke(chain.accountIdOf(address), chain.id) }.getOrNull() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/add/AddAccountPayload.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/add/AddAccountPayload.kt new file mode 100644 index 0000000..6662121 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/add/AddAccountPayload.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.add + +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +sealed class AddAccountPayload : Parcelable { + + @Parcelize + object MetaAccount : AddAccountPayload() + + @Parcelize + class ChainAccount(val chainId: ChainId, val metaId: Long) : AddAccountPayload() +} + +val AddAccountPayload.chainIdOrNull + get() = (this as? AddAccountPayload.ChainAccount)?.chainId diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/add/ImportAccountPayload.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/add/ImportAccountPayload.kt new file mode 100644 index 0000000..db43ec1 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/add/ImportAccountPayload.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.add + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.presenatation.account.common.model.AdvancedEncryptionModel +import kotlinx.parcelize.Parcelize + +@Parcelize +class ImportAccountPayload( + val importType: ImportType, + val addAccountPayload: AddAccountPayload, +) : Parcelable + +sealed interface ImportType : Parcelable { + + @Parcelize + class Mnemonic( + val mnemonic: String? = null, + val preset: AdvancedEncryptionModel? = null, + val origin: Origin = Origin.DEFAULT + ) : ImportType { + + /** + * A hint on which app mnemonic is imported from. + * Some apps might use different of deriving a keypair from passphrase + */ + enum class Origin { + DEFAULT, + TRUST_WALLET + } + } + + @Parcelize + object Seed : ImportType + + @Parcelize + object Json : ImportType +} + +fun SecretType.asImportType(): ImportType { + return when (this) { + SecretType.MNEMONIC -> ImportType.Mnemonic() + SecretType.SEED -> ImportType.Seed + SecretType.JSON -> ImportType.Json + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/add/SecretType.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/add/SecretType.kt new file mode 100644 index 0000000..991bb63 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/add/SecretType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.add + +enum class SecretType { + MNEMONIC, SEED, JSON +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/ChainAccountsAdapter.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/ChainAccountsAdapter.kt new file mode 100644 index 0000000..1aa6d81 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/ChainAccountsAdapter.kt @@ -0,0 +1,120 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.chain + +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import coil.ImageLoader +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ItemChainAccountGroupBinding +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.AccountInChainUi +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.ChainAccountGroupUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_account_api.view.ItemChainAccount + +class ChainAccountsAdapter( + private val handler: Handler, + private val imageLoader: ImageLoader +) : GroupedListAdapter(DiffCallback()) { + + interface Handler { + + fun chainAccountClicked(item: AccountInChainUi) + + fun onGroupActionClicked(item: ChainAccountGroupUi) {} + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return ChainAccountGroupHolder( + viewBinding = ItemChainAccountGroupBinding.inflate(parent.inflater(), parent, false), + handler = handler + ) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + val view = ItemChainAccount(parent.context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + + return ChainAccountHolder(view) + } + + override fun bindGroup(holder: GroupedListHolder, group: ChainAccountGroupUi) { + holder.castOrNull()?.bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: AccountInChainUi) { + holder.castOrNull()?.bind(child, handler, imageLoader) + } +} + +class ChainAccountHolder(override val containerView: ItemChainAccount) : GroupedListHolder(containerView) { + + fun bind( + item: AccountInChainUi, + handler: ChainAccountsAdapter.Handler, + imageLoader: ImageLoader + ) = with(containerView) { + chainIcon.loadChainIcon(item.chainUi.icon, imageLoader) + chainName.text = item.chainUi.name + + accountIcon.setImageDrawable(item.accountIcon) + accountAddress.text = item.addressOrHint + + action.setVisible(item.actionsAvailable) + if (item.actionsAvailable) { + setOnClickListener { handler.chainAccountClicked(item) } + } else { + setOnClickListener(null) + } + } +} + +class ChainAccountGroupHolder( + private val viewBinding: ItemChainAccountGroupBinding, + private val handler: ChainAccountsAdapter.Handler, +) : GroupedListHolder(viewBinding.root) { + + fun bind(item: ChainAccountGroupUi) = with(viewBinding) { + itemChainAccountGroupTitle.text = item.title + + val action = item.action + if (action != null) { + itemChainAccountGroupAction.makeVisible() + + itemChainAccountGroupAction.text = action.name + itemChainAccountGroupAction.setDrawableStart(action.icon, widthInDp = 16, paddingInDp = 4, tint = R.color.icon_accent) + + itemChainAccountGroupAction.setOnClickListener { handler.onGroupActionClicked(item) } + } else { + itemChainAccountGroupAction.makeGone() + itemChainAccountGroupAction.setOnClickListener(null) + } + } +} + +private class DiffCallback : BaseGroupedDiffCallback(ChainAccountGroupUi::class.java) { + + override fun areGroupItemsTheSame(oldItem: ChainAccountGroupUi, newItem: ChainAccountGroupUi): Boolean { + return oldItem.id == newItem.id + } + + override fun areGroupContentsTheSame(oldItem: ChainAccountGroupUi, newItem: ChainAccountGroupUi): Boolean { + return oldItem == newItem + } + + override fun areChildItemsTheSame(oldItem: AccountInChainUi, newItem: AccountInChainUi): Boolean { + return oldItem.chainUi.id == newItem.chainUi.id + } + + override fun areChildContentsTheSame(oldItem: AccountInChainUi, newItem: AccountInChainUi): Boolean { + return oldItem.chainUi == newItem.chainUi && oldItem.addressOrHint == newItem.addressOrHint + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/model/AccountInChainUi.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/model/AccountInChainUi.kt new file mode 100644 index 0000000..9ebfe12 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/model/AccountInChainUi.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.chain.model + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi + +class AccountInChainUi( + val chainUi: ChainUi, + val addressOrHint: String, + val address: String?, + val accountIcon: Drawable, + val actionsAvailable: Boolean +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/model/ChainAccountGroupUi.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/model/ChainAccountGroupUi.kt new file mode 100644 index 0000000..8fd84f5 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/model/ChainAccountGroupUi.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.chain.model + +import androidx.annotation.DrawableRes + +data class ChainAccountGroupUi( + val id: String, + val title: String, + val action: Action? +) { + + data class Action( + val name: String, + @DrawableRes val icon: Int + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/preview/BaseChainAccountsPreviewFragment.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/preview/BaseChainAccountsPreviewFragment.kt new file mode 100644 index 0000000..0e61ffa --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/preview/BaseChainAccountsPreviewFragment.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview + +import androidx.annotation.CallSuper + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.databinding.FragmentChainAccountPreviewBinding +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.ChainAccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.AccountInChainUi +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions + +import javax.inject.Inject + +abstract class BaseChainAccountsPreviewFragment : + BaseFragment(), + ChainAccountsAdapter.Handler { + + override fun createBinding() = FragmentChainAccountPreviewBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + ChainAccountsAdapter(this, imageLoader) + } + + @CallSuper + override fun initViews() { + binder.previewChainAccountToolbar.setHomeButtonListener { + viewModel.backClicked() + } + + binder.previewChainAccountAccounts.setHasFixedSize(true) + binder.previewChainAccountAccounts.adapter = adapter + + binder.previewChainAccountContinue.setOnClickListener { viewModel.continueClicked() } + binder.previewChainAccountContinue.prepareForProgress(viewLifecycleOwner) + } + + @CallSuper + override fun subscribe(viewModel: V) { + setupExternalActions(viewModel) + + viewModel.chainAccountProjections.observe(adapter::submitList) + + binder.previewChainAccountDescription.setTextOrHide(viewModel.subtitle) + + viewModel.buttonState.observe(binder.previewChainAccountContinue::setState) + } + + override fun chainAccountClicked(item: AccountInChainUi) { + viewModel.chainAccountClicked(item) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/preview/BaseChainAccountsPreviewViewModel.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/preview/BaseChainAccountsPreviewViewModel.kt new file mode 100644 index 0000000..ab1f434 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/preview/BaseChainAccountsPreviewViewModel.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.AccountInChainUi +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview.model.ChainAccountPreview +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +abstract class BaseChainAccountsPreviewViewModel( + private val iconGenerator: AddressIconGenerator, + private val externalActions: ExternalActions.Presentation, + private val chainRegistry: ChainRegistry, + private val router: ReturnableRouter, +) : BaseViewModel(), ExternalActions.Presentation by externalActions { + + open val subtitle: String? = null + + // List + abstract val chainAccountProjections: Flow> + + abstract val buttonState: Flow + + abstract fun continueClicked() + + fun backClicked() { + router.back() + } + + fun chainAccountClicked(item: AccountInChainUi) = launch { + val chain = chainRegistry.getChain(item.chainUi.id) + + externalActions.showAddressActions(item.address, chain) + } + + protected suspend fun mapChainAccountPreviewToUi(account: ChainAccountPreview): AccountInChainUi = with(account) { + val address = chain.addressOf(accountId) + + val icon = iconGenerator.createAccountAddressModel(chain, accountId).image + + AccountInChainUi( + chainUi = mapChainToUi(chain), + addressOrHint = address, + address = address, + accountIcon = icon, + actionsAvailable = true + ) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/preview/model/ChainAccountPreview.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/preview/model/ChainAccountPreview.kt new file mode 100644 index 0000000..31cdb6e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chain/preview/model/ChainAccountPreview.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class ChainAccountPreview( + val chain: Chain, + val accountId: AccountId, +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chooser/AccountChooserBottomSheetDialog.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chooser/AccountChooserBottomSheetDialog.kt new file mode 100644 index 0000000..ce34c6d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/chooser/AccountChooserBottomSheetDialog.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.chooser + +import android.content.Context +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ClickHandler +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_account_api.databinding.ItemAccountChooserBinding + +class AccountChooserBottomSheetDialog( + context: Context, + payload: Payload, + onSuccess: ClickHandler, + onCancel: (() -> Unit)? = null, + @StringRes val title: Int +) : DynamicListBottomSheet( + context = context, + payload = payload, + diffCallback = AddressModelDiffCallback, + onClicked = onSuccess, + onCancel = onCancel +) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(title) + } + + override fun holderCreator(): HolderCreator = { parent -> + AddressModelHolder(ItemAccountChooserBinding.inflate(parent.inflater(), parent, false)) + } +} + +private class AddressModelHolder(private val binder: ItemAccountChooserBinding) : DynamicListSheetAdapter.Holder(binder.root) { + + override fun bind( + item: AddressModel, + isSelected: Boolean, + handler: DynamicListSheetAdapter.Handler + ) { + super.bind(item, isSelected, handler) + + with(itemView) { + binder.itemAccountChooserAddress.setAddressModel(item) + binder.itemAccountChooserCheck.isChecked = isSelected + } + } +} + +private object AddressModelDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AddressModel, newItem: AddressModel): Boolean { + return oldItem.address == newItem.address + } + + override fun areContentsTheSame(oldItem: AddressModel, newItem: AddressModel): Boolean { + return true + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/AccountModelExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/AccountModelExt.kt new file mode 100644 index 0000000..06860b9 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/AccountModelExt.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.common + +import android.text.TextUtils +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountModel + +fun AccountModel.relevantEllipsizeMode(): TextUtils.TruncateAt { + return when (this) { + is AccountModel.Address -> when (this.name) { + // For address use middle + null -> TextUtils.TruncateAt.MIDDLE + + // For name use end + else -> TextUtils.TruncateAt.END + } + + // For wallet always use end + is AccountModel.Wallet -> TextUtils.TruncateAt.END + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/listing/MetaAccountTypePresentationMapper.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/listing/MetaAccountTypePresentationMapper.kt new file mode 100644 index 0000000..d1a3a85 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/listing/MetaAccountTypePresentationMapper.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.common.listing + +import io.novafoundation.nova.common.view.ChipLabelModel +import io.novafoundation.nova.common.view.TintedIcon +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountChipGroupRvItem + +interface MetaAccountTypePresentationMapper { + + suspend fun mapMetaAccountTypeToUi(type: LightMetaAccount.Type): AccountChipGroupRvItem? + + suspend fun mapTypeToChipLabel(type: LightMetaAccount.Type): ChipLabelModel? + + suspend fun iconFor(type: LightMetaAccount.Type): TintedIcon? +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/listing/delegeted/MultisigFormatter.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/listing/delegeted/MultisigFormatter.kt new file mode 100644 index 0000000..f7f260d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/listing/delegeted/MultisigFormatter.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount + +interface MultisigFormatter { + + suspend fun formatSignatorySubtitle(signatory: MetaAccount): CharSequence + + fun formatSignatorySubtitle(signatory: MetaAccount, icon: Drawable): CharSequence + + suspend fun formatSignatory(signatory: MetaAccount): CharSequence + + suspend fun makeAccountDrawable(metaAccount: MetaAccount): Drawable + + suspend fun makeAccountDrawable(accountId: ByteArray): Drawable +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/listing/delegeted/ProxyFormatter.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/listing/delegeted/ProxyFormatter.kt new file mode 100644 index 0000000..e42672b --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/listing/delegeted/ProxyFormatter.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxyAccount +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType + +interface ProxyFormatter { + + fun mapProxyMetaAccountSubtitle(proxyAccountName: String, proxyAccountIcon: Drawable, proxyAccount: ProxyAccount): CharSequence + + fun mapProxyMetaAccount(proxyAccountName: String, proxyAccountIcon: Drawable): CharSequence + + fun mapProxyTypeToString(type: ProxyType): String + + suspend fun makeAccountDrawable(metaAccount: MetaAccount): Drawable +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/model/AdvancedEncryptionModel.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/model/AdvancedEncryptionModel.kt new file mode 100644 index 0000000..ec2120c --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/common/model/AdvancedEncryptionModel.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.common.model + +import android.os.Parcelable +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import kotlinx.parcelize.Parcelize + +@Parcelize +class AdvancedEncryptionModel( + val substrateCryptoType: CryptoType?, + val substrateDerivationPath: String?, + val ethereumCryptoType: CryptoType?, + val ethereumDerivationPath: String? +) : Parcelable + +fun AdvancedEncryptionModel.toAdvancedEncryption(): AdvancedEncryption { + return AdvancedEncryption( + substrateCryptoType, + ethereumCryptoType, + derivationPaths = AdvancedEncryption.DerivationPaths( + substrateDerivationPath, + ethereumDerivationPath + ) + ) +} + +fun AdvancedEncryption.toAdvancedEncryptionModel(): AdvancedEncryptionModel { + return AdvancedEncryptionModel( + substrateCryptoType, + derivationPaths.substrate, + ethereumCryptoType, + derivationPaths.ethereum + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/copyAddress/CopyAddressMixin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/copyAddress/CopyAddressMixin.kt new file mode 100644 index 0000000..6038529 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/copyAddress/CopyAddressMixin.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress + +import io.novafoundation.nova.feature_account_api.domain.account.common.ChainWithAccountId + +interface CopyAddressMixin { + fun copyAddressOrOpenSelector(chainWithAccountId: ChainWithAccountId) + + fun copyPrimaryAddress(chainWithAccountId: ChainWithAccountId) + + fun copyLegacyAddress(chainWithAccountId: ChainWithAccountId) + + fun getPrimaryAddress(chainWithAccountId: ChainWithAccountId): String + + fun getLegacyAddress(chainWithAccountId: ChainWithAccountId): String? + + fun shouldShowAddressSelector(): Boolean + + fun enableAddressSelector(enable: Boolean) + + fun openAddressSelector(chainId: String, accountId: ByteArray) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/createName/CreateWalletNameFragment.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/createName/CreateWalletNameFragment.kt new file mode 100644 index 0000000..f138b5e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/createName/CreateWalletNameFragment.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.createName + +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.databinding.FragmentCreateWalletNameBinding + +abstract class CreateWalletNameFragment : BaseFragment() { + + override fun createBinding() = FragmentCreateWalletNameBinding.inflate(layoutInflater) + + override fun initViews() { + binder.createWalletNameToolbar.setHomeButtonListener { viewModel.homeButtonClicked() } + + binder.createWalletNameContinue.setOnClickListener { + binder.createWalletNameInput.hideSoftKeyboard() + viewModel.nextClicked() + } + } + + override fun subscribe(viewModel: V) { + viewModel.continueState.observe(binder.createWalletNameContinue::setState) + + binder.createWalletNameInput.bindTo(viewModel.name, viewLifecycleOwner.lifecycleScope) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/createName/CreateWalletNameViewModel.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/createName/CreateWalletNameViewModel.kt new file mode 100644 index 0000000..a682ba1 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/createName/CreateWalletNameViewModel.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.createName + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.R +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +abstract class CreateWalletNameViewModel( + private val router: ReturnableRouter, + private val resourceManager: ResourceManager +) : BaseViewModel() { + + val name = MutableStateFlow("") + + val continueState = name.map { + if (it.isNotEmpty()) { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } else { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.account_enter_wallet_nickname)) + } + } + + abstract fun proceed(name: String) + + fun homeButtonClicked() { + router.back() + } + + fun nextClicked() { + proceed(name.value) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/details/ChainAccountActionsSheet.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/details/ChainAccountActionsSheet.kt new file mode 100644 index 0000000..fc1a92f --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/details/ChainAccountActionsSheet.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.details + +import android.content.Context +import android.os.Bundle +import androidx.annotation.StringRes +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem +import io.novafoundation.nova.feature_account_api.presenatation.actions.CopyCallback +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActionsSheet +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalViewCallback +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ChainAccountActionsSheet( + context: Context, + payload: ExternalActions.Payload, + onCopy: CopyCallback, + onViewExternal: ExternalViewCallback, + private val availableAccountActions: Set, + private val onChange: (inChain: Chain) -> Unit, + private val onExport: (inChain: Chain) -> Unit, +) : ExternalActionsSheet(context, payload, onCopy, onViewExternal) { + + enum class AccountAction { + EXPORT, + CHANGE + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + showAvailableAccountActions() + } + + private fun showAvailableAccountActions() { + availableAccountActions.forEach { + when (it) { + AccountAction.EXPORT -> maybeShowExport() + AccountAction.CHANGE -> maybeShowChange() + } + } + } + + private fun maybeShowExport() { + accountAddress()?.let { + textItem(R.drawable.ic_share_outline, R.string.account_export, showArrow = true) { + onExport.invoke(payload.chain) + } + } + } + + private fun maybeShowChange() { + val address = accountAddress() + + if (address != null) { + changeAccountItem(R.string.accounts_change_chain_secrets) + } else { + changeAccountItem(R.string.account_add_account) + } + } + + private fun changeAccountItem(@StringRes labelRes: Int) { + textItem(R.drawable.ic_staking_operations, labelRes, showArrow = true) { + onChange.invoke(payload.chain) + } + } + + private fun accountAddress() = payload.type.castOrNull()?.address +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/external/ExternalAccountsBottomSheet.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/external/ExternalAccountsBottomSheet.kt new file mode 100644 index 0000000..f6f9f11 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/external/ExternalAccountsBottomSheet.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.external + +import android.content.Context +import android.os.Bundle +import android.text.TextUtils +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ClickHandler +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ItemExternalAccountIdentifierBinding +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputState +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.ExternalAccount + +class ExternalAccountsBottomSheet( + context: Context, + private val title: String, + payload: Payload, + onClicked: ClickHandler +) : DynamicListBottomSheet(context, payload, AccountDiffCallback, onClicked, dismissOnClick = false) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTitle(title) + } + + override fun holderCreator(): HolderCreator = { + AccountHolder(ItemExternalAccountIdentifierBinding.inflate(it.inflater(), it, false)) + } +} + +class AccountHolder( + private val binder: ItemExternalAccountIdentifierBinding +) : DynamicListSheetAdapter.Holder(binder.root) { + + override fun bind(item: ExternalAccount, isSelected: Boolean, handler: DynamicListSheetAdapter.Handler) { + super.bind(item, isSelected, handler) + + with(itemView) { + setIdenticonState(item.icon) + binder.externalAccountIdentifierTitle.text = item.description ?: item.address + binder.externalAccountIdentifierSubtitle.isVisible = item.description != null + binder.externalAccountIdentifierSubtitle.text = item.address + binder.externalAccountIdentifierIsSelected.isChecked = isSelected + + if (item.description == null) { + binder.externalAccountIdentifierTitle.ellipsize = TextUtils.TruncateAt.MIDDLE + } else { + binder.externalAccountIdentifierTitle.ellipsize = TextUtils.TruncateAt.END + } + } + } + + private fun setIdenticonState(state: AddressInputState.IdenticonState) = when (state) { + is AddressInputState.IdenticonState.Address -> { + binder.externalAccountIcon.setImageDrawable(state.drawable) + } + + AddressInputState.IdenticonState.Placeholder -> { + binder.externalAccountIcon.setImageResource(R.drawable.ic_identicon_placeholder) + } + } +} + +private object AccountDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ExternalAccount, newItem: ExternalAccount): Boolean { + return oldItem.address == newItem.address + } + + override fun areContentsTheSame(oldItem: ExternalAccount, newItem: ExternalAccount): Boolean { + return false + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/icon/AddressIconGeneratorExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/icon/AddressIconGeneratorExt.kt new file mode 100644 index 0000000..2d6b271 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/icon/AddressIconGeneratorExt.kt @@ -0,0 +1,186 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.icon + +import android.graphics.drawable.Drawable +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.OptionalAddressModel +import io.novafoundation.nova.common.address.format.AddressFormat +import io.novafoundation.nova.common.address.format.asAccountId +import io.novafoundation.nova.common.address.format.asAddress +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.invoke +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +suspend fun AddressIconGenerator.createAddressModel( + chain: Chain, + address: String, + sizeInDp: Int, + accountName: String? = null, + background: Int = AddressIconGenerator.BACKGROUND_DEFAULT, +): AddressModel { + val icon = createAddressIcon(chain, address, sizeInDp, background) + + return AddressModel(address, icon, accountName) +} + +suspend fun AddressIconGenerator.createAddressModel( + chain: Chain, + address: String, + sizeInDp: Int, + addressDisplayUseCase: AddressDisplayUseCase? = null, + @ColorRes background: Int = AddressIconGenerator.BACKGROUND_DEFAULT, +): AddressModel { + val icon = createAddressIcon(chain, address, sizeInDp, background) + + return AddressModel(address, icon, addressDisplayUseCase?.invoke(chain, address)) +} + +suspend fun AddressIconGenerator.createOptionalAddressModel( + chain: Chain, + address: String, + sizeInDp: Int = AddressIconGenerator.SIZE_SMALL, + addressDisplayUseCase: AddressDisplayUseCase? = null, + @ColorRes background: Int = AddressIconGenerator.BACKGROUND_DEFAULT, +): OptionalAddressModel { + val icon = runCatching { createAddressIcon(chain, address, sizeInDp, background) }.getOrNull() + + return OptionalAddressModel(address, icon, addressDisplayUseCase?.invoke(chain, address)) +} + +suspend fun AddressIconGenerator.createAddressModel( + chain: Chain, + accountId: ByteArray, + sizeInDp: Int, + addressDisplayUseCase: AddressDisplayUseCase, + @ColorRes background: Int = AddressIconGenerator.BACKGROUND_DEFAULT, +): AddressModel { + val icon = createAddressIcon(accountId, sizeInDp, background) + val address = chain.addressOf(accountId) + + return AddressModel(address, icon, addressDisplayUseCase(chain, address)) +} + +suspend fun AddressIconGenerator.createAddressIcon( + chain: Chain, + address: String, + sizeInDp: Int, + @ColorRes background: Int = AddressIconGenerator.BACKGROUND_DEFAULT, +): Drawable { + return createAddressIcon(chain.accountIdOf(address), sizeInDp, background) +} + +suspend fun AddressIconGenerator.createAddressIcon( + addressFormat: AddressFormat, + address: String, + sizeInDp: Int = AddressIconGenerator.SIZE_SMALL, + @ColorRes background: Int = AddressIconGenerator.BACKGROUND_DEFAULT, +): Drawable { + val accountId = addressFormat.accountIdOf(address.asAddress()).value + return createAddressIcon(accountId, sizeInDp, background) +} + +suspend fun AddressIconGenerator.createAccountAddressModel( + chain: Chain, + address: String, + name: String? = null, +) = createAddressModel( + chain = chain, + address = address, + sizeInDp = AddressIconGenerator.SIZE_SMALL, + accountName = name, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT +) + +suspend fun AddressIconGenerator.createAccountAddressModel( + address: String, + addressFormat: AddressFormat, + name: String? = null, +): AddressModel { + val icon = createAddressIcon(addressFormat, address) + return AddressModel(address, icon, name) +} + +suspend fun AddressIconGenerator.createAccountAddressModel( + addressFormat: AddressFormat, + accountId: ByteArray, + name: String? = null, +): AddressModel { + val icon = createAddressIcon(accountId, AddressIconGenerator.SIZE_SMALL, AddressIconGenerator.BACKGROUND_TRANSPARENT) + return AddressModel(addressFormat.addressOf(accountId.asAccountId()).value, icon, name) +} + +suspend fun AddressIconGenerator.createOptionalAccountAddressIcon( + chain: Chain, + address: String, +) = kotlin.runCatching { + createAddressIcon(chain.accountIdOf(address), AddressIconGenerator.SIZE_SMALL, AddressIconGenerator.BACKGROUND_TRANSPARENT) +}.getOrNull() + +suspend fun AddressIconGenerator.createAccountAddressModel( + chain: Chain, + accountId: ByteArray, + name: String? = null, +) = createAddressModel( + chain = chain, + address = chain.addressOf(accountId), + sizeInDp = AddressIconGenerator.SIZE_SMALL, + accountName = name, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT +) + +suspend fun AddressIconGenerator.createAccountAddressModel( + chain: Chain, + account: MetaAccount, + name: String? = account.name +) = createAddressModel( + chain = chain, + address = account.addressIn(chain) ?: throw IllegalArgumentException("No address found for ${account.name} in ${chain.name}"), + sizeInDp = AddressIconGenerator.SIZE_SMALL, + accountName = name, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT +) + +suspend fun AddressIconGenerator.createAccountAddressModelOrNull( + chain: Chain, + account: MetaAccount, + name: String? = account.name +): AddressModel? { + val address = account.addressIn(chain) ?: return null + + return createAddressModel( + chain = chain, + address = address, + sizeInDp = AddressIconGenerator.SIZE_SMALL, + accountName = name, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) +} + +suspend fun AddressIconGenerator.createAccountAddressModel( + chain: Chain, + address: String, + addressDisplayUseCase: AddressDisplayUseCase, +) = createAccountAddressModel(chain, address, addressDisplayUseCase(chain, address)) + +suspend fun AddressIconGenerator.createAccountAddressModel( + chain: Chain, + accountId: AccountId, + addressDisplayUseCase: AddressDisplayUseCase, +) = createAccountAddressModel(chain, accountId, addressDisplayUseCase.invoke(accountId, chain.id)) + +suspend fun AddressIconGenerator.createIdentityAddressModel( + chain: Chain, + accountId: ByteArray, + identityProvider: IdentityProvider +) = createAccountAddressModel( + chain = chain, + accountId = accountId, + name = identityProvider.identityFor(accountId, chain.id)?.name +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/AccountListAdapter.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/AccountListAdapter.kt new file mode 100644 index 0000000..474a257 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/AccountListAdapter.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.listing + +import androidx.annotation.ColorRes +import coil.ImageLoader +import io.novafoundation.nova.common.view.ChipLabelView +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountChipHolder +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountChipGroupRvItem + +class AccountsAdapter( + private val accountItemHandler: AccountHolder.AccountItemHandler, + private val imageLoader: ImageLoader, + @ColorRes private val chainBorderColor: Int, + initialMode: AccountHolder.Mode +) : CommonAccountsAdapter( + accountItemHandler = accountItemHandler, + imageLoader = imageLoader, + diffCallback = AccountDiffCallback(AccountChipGroupRvItem::class.java), + groupFactory = { AccountChipHolder(ChipLabelView(it.context)) }, + groupBinder = { holder, item -> (holder as AccountChipHolder).bind(item.chipLabelModel) }, + chainBorderColor = chainBorderColor, + initialMode = initialMode +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/CommonAccountsAdapter.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/CommonAccountsAdapter.kt new file mode 100644 index 0000000..aa04baf --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/CommonAccountsAdapter.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.listing + +import android.view.ViewGroup +import androidx.annotation.ColorRes +import coil.ImageLoader +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.databinding.ItemAccountBinding +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi + +fun interface AccountGroupViewHolderFactory { + fun create(parent: ViewGroup): GroupedListHolder +} + +fun interface AccountGroupViewHolderBinder { + fun bind(holder: GroupedListHolder, item: Group) +} + +interface AccountGroupRvItem { + fun isItemTheSame(other: AccountGroupRvItem): Boolean +} + +abstract class CommonAccountsAdapter( + private val accountItemHandler: AccountHolder.AccountItemHandler?, + private val imageLoader: ImageLoader, + private val diffCallback: AccountDiffCallback, + private val groupFactory: AccountGroupViewHolderFactory, + private val groupBinder: AccountGroupViewHolderBinder, + @ColorRes private val chainBorderColor: Int, + initialMode: AccountHolder.Mode, +) : GroupedListAdapter(diffCallback) { + + private var mode: AccountHolder.Mode = initialMode + + fun setMode(mode: AccountHolder.Mode) { + this.mode = mode + + notifyItemRangeChanged(0, itemCount, mode) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return groupFactory.create(parent) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return AccountHolder(ItemAccountBinding.inflate(parent.inflater(), parent, false), imageLoader, chainBorderColor) + } + + override fun bindGroup(holder: GroupedListHolder, group: Group) { + groupBinder.bind(holder, group) + } + + override fun bindChild(holder: GroupedListHolder, child: AccountUi) { + (holder as AccountHolder).bind(mode, child, accountItemHandler) + } + + override fun bindChild(holder: GroupedListHolder, position: Int, child: AccountUi, payloads: List) { + require(holder is AccountHolder) + + resolvePayload( + holder, + position, + payloads, + onUnknownPayload = { holder.bindMode(mode, child, accountItemHandler) }, + onDiffCheck = { + when (it) { + AccountUi::title -> holder.bindName(child) + AccountUi::subtitle -> holder.bindSubtitle(child) + AccountUi::isSelected -> holder.bindMode(mode, child, accountItemHandler) + } + } + ) + } +} + +class AccountDiffCallback(groupClass: Class) : BaseGroupedDiffCallback(groupClass) { + override fun areGroupItemsTheSame(oldItem: Group, newItem: Group): Boolean { + return oldItem.isItemTheSame(newItem) + } + + override fun areGroupContentsTheSame(oldItem: Group, newItem: Group): Boolean { + return oldItem == newItem + } + + override fun areChildItemsTheSame(oldItem: AccountUi, newItem: AccountUi): Boolean { + return oldItem.id == newItem.id + } + + override fun areChildContentsTheSame(oldItem: AccountUi, newItem: AccountUi): Boolean { + return oldItem.title == newItem.title && oldItem.subtitle == newItem.subtitle && oldItem.isSelected == newItem.isSelected + } + + override fun getChildChangePayload(oldItem: AccountUi, newItem: AccountUi): Any? { + return MetaAccountPayloadGenerator.diff(oldItem, newItem) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/MetaAccountPayloadGenerator.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/MetaAccountPayloadGenerator.kt new file mode 100644 index 0000000..0db9601 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/MetaAccountPayloadGenerator.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.listing + +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi + +object MetaAccountPayloadGenerator : PayloadGenerator( + AccountUi::title, + AccountUi::subtitle, + AccountUi::isSelected +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/holders/AccountChipHolder.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/holders/AccountChipHolder.kt new file mode 100644 index 0000000..4c0d581 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/holders/AccountChipHolder.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders + +import android.view.ViewGroup +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.view.ChipLabelModel +import io.novafoundation.nova.common.view.ChipLabelView + +class AccountChipHolder(override val containerView: ChipLabelView) : GroupedListHolder(containerView) { + + init { + val context = containerView.context + + containerView.layoutParams = ViewGroup.MarginLayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(16.dp(context), 16.dp(context), 0, 8.dp(context)) + } + } + + fun bind(item: ChipLabelModel) { + containerView.setModel(item) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/holders/AccountHolder.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/holders/AccountHolder.kt new file mode 100644 index 0000000..ce00008 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/holders/AccountHolder.kt @@ -0,0 +1,150 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders + +import android.animation.LayoutTransition +import android.view.View +import androidx.annotation.ColorRes +import androidx.core.view.isVisible +import coil.ImageLoader +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.AlphaColorFilter +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.removeDrawableEnd +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ItemAccountBinding +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi + +class AccountHolder( + private val binder: ItemAccountBinding, + private val imageLoader: ImageLoader, + @ColorRes private val chainBorderColor: Int +) : GroupedListHolder(binder.root) { + + interface AccountItemHandler { + + fun itemClicked(accountModel: AccountUi) + + fun deleteClicked(accountModel: AccountUi) { + // default no op + } + } + + enum class Mode { + VIEW, SELECT, SELECT_MULTIPLE, EDIT, SWITCH + } + + init { + val lt = LayoutTransition().apply { + disableTransitionType(LayoutTransition.DISAPPEARING) + disableTransitionType(LayoutTransition.APPEARING) + } + + binder.itemAccountContainer.layoutTransition = lt + binder.itemChainIcon.backgroundTintList = containerView.context.getColorStateList(chainBorderColor) + } + + fun bind( + mode: Mode, + accountModel: AccountUi, + handler: AccountItemHandler?, + ) = with(binder) { + bindName(accountModel) + bindSubtitle(accountModel) + bindMode(mode, accountModel, handler) + + itemAccountIcon.setImageDrawable(accountModel.picture) + itemChainIcon.letOrHide(accountModel.chainIcon) { + itemChainIcon.colorFilter = AlphaColorFilter(accountModel.chainIconOpacity) + itemChainIcon.setIcon(it, imageLoader = imageLoader) + } + + if (accountModel.updateIndicator) { + itemAccountTitle.setDrawableEnd(R.drawable.shape_account_updated_indicator, paddingInDp = 8) + } else { + itemAccountTitle.removeDrawableEnd() + } + } + + fun bindName(accountModel: AccountUi) { + binder.itemAccountTitle.text = accountModel.title + } + + fun bindSubtitle(accountModel: AccountUi) { + binder.itemAccountSubtitle.setTextOrHide(accountModel.subtitle) + binder.itemAccountSubtitle.setDrawableStart(accountModel.subtitleIconRes, paddingInDp = 4) + } + + fun bindMode( + mode: Mode, + accountModel: AccountUi, + handler: AccountItemHandler?, + ) = with(binder) { + when (mode) { + Mode.VIEW -> { + itemAccountArrow.visibility = View.GONE + itemAccountDelete.visibility = View.GONE + itemAccountRadioButton.visibility = View.GONE + itemAccountCheckBox.visibility = View.GONE + + itemAccountDelete.setOnClickListener(null) + + root.setOnClickListener(null) + } + + Mode.SELECT_MULTIPLE -> { + itemAccountArrow.visibility = View.GONE + + itemAccountDelete.visibility = View.GONE + + itemAccountCheckBox.isVisible = accountModel.isClickable + itemAccountCheckBox.isChecked = accountModel.isSelected + + itemAccountRadioButton.visibility = View.GONE + + root.setOnClickListener { handler?.itemClicked(accountModel) } + } + + Mode.SELECT -> { + itemAccountArrow.visibility = View.VISIBLE + + itemAccountDelete.visibility = View.GONE + itemAccountDelete.setOnClickListener(null) + + itemAccountRadioButton.visibility = View.GONE + itemAccountCheckBox.visibility = View.GONE + + root.setOnClickListener { handler?.itemClicked(accountModel) } + } + + Mode.EDIT -> { + itemAccountArrow.visibility = View.INVISIBLE + + itemAccountDelete.isVisible = accountModel.isEditable + + itemAccountDelete.setOnClickListener { handler?.deleteClicked(accountModel) } + itemAccountDelete.setImageResource(R.drawable.ic_delete_symbol) + + itemAccountRadioButton.visibility = View.GONE + itemAccountCheckBox.visibility = View.GONE + + root.setOnClickListener(null) + } + + Mode.SWITCH -> { + itemAccountArrow.visibility = View.GONE + + itemAccountDelete.visibility = View.GONE + + itemAccountRadioButton.isVisible = accountModel.isClickable + itemAccountRadioButton.isChecked = accountModel.isSelected + + itemAccountCheckBox.visibility = View.GONE + + root.setOnClickListener { handler?.itemClicked(accountModel) } + } + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/holders/AccountTitleHolder.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/holders/AccountTitleHolder.kt new file mode 100644 index 0000000..17f3cf8 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/holders/AccountTitleHolder.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders + +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.feature_account_api.databinding.ItemDelegatedAccountGroupBinding + +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountTitleGroupRvItem + +class AccountTitleHolder(private val binder: ItemDelegatedAccountGroupBinding) : GroupedListHolder(binder.root) { + + fun bind(item: AccountTitleGroupRvItem) { + binder.delegatedAccountGroup.text = item.title + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/items/AccountChipGroupRvItem.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/items/AccountChipGroupRvItem.kt new file mode 100644 index 0000000..124ffa5 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/items/AccountChipGroupRvItem.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.listing.items + +import io.novafoundation.nova.common.view.ChipLabelModel +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountGroupRvItem + +data class AccountChipGroupRvItem( + val chipLabelModel: ChipLabelModel +) : AccountGroupRvItem { + override fun isItemTheSame(other: AccountGroupRvItem): Boolean { + return other is AccountChipGroupRvItem && chipLabelModel.title == other.chipLabelModel.title + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/items/AccountTitleGroupRvItem.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/items/AccountTitleGroupRvItem.kt new file mode 100644 index 0000000..f6cae30 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/items/AccountTitleGroupRvItem.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.listing.items + +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountGroupRvItem + +data class AccountTitleGroupRvItem( + val title: String +) : AccountGroupRvItem { + override fun isItemTheSame(other: AccountGroupRvItem): Boolean { + return other is AccountTitleGroupRvItem && title == other.title + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/items/AccountUi.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/items/AccountUi.kt new file mode 100644 index 0000000..03881e2 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/listing/items/AccountUi.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.listing.items + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.common.utils.images.Icon + +class AccountUi( + val id: Long, + val title: CharSequence, + val subtitle: CharSequence?, + val isSelected: Boolean, + val isEditable: Boolean, + val isClickable: Boolean, + val picture: Drawable, + val chainIcon: Icon?, + val updateIndicator: Boolean, + val subtitleIconRes: Int?, + val chainIconOpacity: Float = 1f +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/polkadotVault/ResourceManagerExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/polkadotVault/ResourceManagerExt.kt new file mode 100644 index 0000000..efd863c --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/polkadotVault/ResourceManagerExt.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant + +fun ResourceManager.polkadotVaultLabelFor(polkadotVaultVariant: PolkadotVaultVariant): String { + return when (polkadotVaultVariant) { + PolkadotVaultVariant.POLKADOT_VAULT -> getString(R.string.account_polkadot_vault) + PolkadotVaultVariant.PARITY_SIGNER -> getString(R.string.account_parity_signer) + } +} + +fun ResourceManager.formatWithPolkadotVaultLabel(@StringRes stringRes: Int, polkadotVaultVariant: PolkadotVaultVariant): String { + return getString(stringRes, polkadotVaultLabelFor(polkadotVaultVariant)) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/polkadotVault/config/PolkadotVaultVariantConfig.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/polkadotVault/config/PolkadotVaultVariantConfig.kt new file mode 100644 index 0000000..be0eb18 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/polkadotVault/config/PolkadotVaultVariantConfig.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +class PolkadotVaultVariantConfig( + val pages: List, + val sign: Sign, + val common: Common +) { + + class ConnectPage(val pageName: String, val instructions: List) { + + sealed class Instruction { + + class Step(val index: Int, val content: CharSequence) : Instruction() + + class Image(val label: String?, @DrawableRes val imageRes: Int) : Instruction() + } + } + + class Sign(val troubleShootingLink: String, val supportsProofSigning: Boolean) + + class Common(@DrawableRes val iconRes: Int, @StringRes val nameRes: Int) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/polkadotVault/config/PolkadotVaultVariantConfigProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/polkadotVault/config/PolkadotVaultVariantConfigProvider.kt new file mode 100644 index 0000000..6b6da11 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/polkadotVault/config/PolkadotVaultVariantConfigProvider.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config + +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant + +interface PolkadotVaultVariantConfigProvider { + + fun variantConfigFor(variant: PolkadotVaultVariant): PolkadotVaultVariantConfig +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/proxy/ProxySigningPresenter.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/proxy/ProxySigningPresenter.kt new file mode 100644 index 0000000..655aa58 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/proxy/ProxySigningPresenter.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.proxy + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +interface ProxySigningPresenter { + + suspend fun acknowledgeProxyOperation(proxiedMetaAccount: ProxiedMetaAccount, proxyMetaAccount: MetaAccount): Boolean + + suspend fun notEnoughPermission(proxiedMetaAccount: MetaAccount, proxyMetaAccount: MetaAccount, proxyTypes: List) + + suspend fun signingIsNotSupported() + + suspend fun notEnoughFee(proxy: MetaAccount, chainAsset: Chain.Asset, availableBalance: BigInteger, fee: BigInteger) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/wallet/WalletUiUseCase.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/wallet/WalletUiUseCase.kt new file mode 100644 index 0000000..38eb9fd --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/wallet/WalletUiUseCase.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.wallet + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +open class WalletModel(val metaId: Long, val name: String, val icon: Drawable?) { + + override fun equals(other: Any?): Boolean { + return other is WalletModel && metaId == other.metaId && name == other.name + } +} + +interface WalletUiUseCase { + + fun selectedWalletUiFlow(showAddressIcon: Boolean = false): Flow + + fun walletUiFlow(metaId: Long, showAddressIcon: Boolean = false): Flow + + fun walletUiFlow(metaId: Long, chainId: String, showAddressIcon: Boolean = false): Flow + + suspend fun selectedWalletUi(): WalletModel + + suspend fun walletIcon( + substrateAccountId: AccountId?, + ethereumAccountId: AccountId?, + chainAccountIds: List, + iconSize: Int = AddressIconGenerator.SIZE_MEDIUM, + transparentBackground: Boolean = true + ): Drawable + + suspend fun walletIcon(metaAccount: MetaAccount, iconSize: Int = AddressIconGenerator.SIZE_MEDIUM, transparentBackground: Boolean = true): Drawable + + suspend fun walletUiFor(metaAccount: MetaAccount): WalletModel + + // TODO: Method is a crutch. Should be changed to return WalletModel when we migrate to new wallet icons + suspend fun walletAddressModel(metaAccount: MetaAccount, chain: Chain, iconSize: Int): AddressModel + suspend fun walletAddressModelOrNull(metaAccount: MetaAccount, chain: Chain, iconSize: Int): AddressModel? +} + +fun WalletUiUseCase.walletUiFlowFor(metaId: Long, chainId: String?, showAddressIcon: Boolean = false): Flow { + return if (chainId == null) { + walletUiFlow(metaId, showAddressIcon) + } else { + walletUiFlow(metaId, chainId, showAddressIcon) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/wallet/list/SelectMultipleWalletsCommunicator.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/wallet/list/SelectMultipleWalletsCommunicator.kt new file mode 100644 index 0000000..cc6a29e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/wallet/list/SelectMultipleWalletsCommunicator.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import kotlinx.parcelize.Parcelize + +interface SelectMultipleWalletsRequester : InterScreenRequester { + + @Parcelize + class Request( + val titleText: String, + val min: Int, + val max: Int, + val currentlySelectedMetaIds: Set + ) : Parcelable +} + +interface SelectMultipleWalletsResponder : InterScreenResponder { + + @Parcelize + class Response(val selectedMetaIds: Set) : Parcelable +} + +interface SelectMultipleWalletsCommunicator : SelectMultipleWalletsRequester, SelectMultipleWalletsResponder diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/watchOnly/WatchOnlyMissingKeysPresenter.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/watchOnly/WatchOnlyMissingKeysPresenter.kt new file mode 100644 index 0000000..19698d1 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/account/watchOnly/WatchOnlyMissingKeysPresenter.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly + +interface WatchOnlyMissingKeysPresenter { + + suspend fun presentNoKeysFound() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/CustomizableExternalActionsSheet.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/CustomizableExternalActionsSheet.kt new file mode 100644 index 0000000..e74de5f --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/CustomizableExternalActionsSheet.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_api.presenatation.actions + +import android.content.Context +import android.os.Bundle +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem + +class ExternalActionModel( + @DrawableRes val iconRes: Int, + val title: String, + val onClick: () -> Unit +) + +class CustomizableExternalActionsSheet( + context: Context, + payload: ExternalActions.Payload, + onCopy: CopyCallback, + onViewExternal: ExternalViewCallback, + val additionalOptions: List +) : ExternalActionsSheet(context, payload, onCopy, onViewExternal) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + additionalOptions.forEach { externalActionModel -> + textItem(externalActionModel.iconRes, externalActionModel.title, showArrow = true) { + externalActionModel.onClick() + } + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActions.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActions.kt new file mode 100644 index 0000000..d24639e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActions.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_account_api.presenatation.actions + +import android.graphics.drawable.Drawable +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ExplorerTemplateExtractor +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface ExternalActions : Browserable { + + class Payload( + val type: Type, + val chain: Chain, + val chainUi: ChainUi?, + val icon: Drawable?, + @StringRes val copyLabelRes: Int?, + ) + + sealed class Type( + val primaryValue: String?, + val explorerTemplateExtractor: ExplorerTemplateExtractor, + ) { + + object EmptyAccount : Type(null, explorerTemplateExtractor = Chain.Explorer::account) + + class Address(val address: String) : Type(address, explorerTemplateExtractor = Chain.Explorer::account) + + class Extrinsic(val hash: String) : Type(hash, explorerTemplateExtractor = Chain.Explorer::extrinsic) + + class Event(val id: String) : Type(id, explorerTemplateExtractor = Chain.Explorer::event) + } + + val showExternalActionsEvent: LiveData> + + fun viewExternalClicked(explorer: Chain.Explorer, type: Type) + + fun copyValue(payload: Payload) + + interface Presentation : ExternalActions, Browserable.Presentation { + + suspend fun showExternalActions(type: Type, chain: Chain) + } +} + +suspend fun ExternalActions.Presentation.showAddressActions(metaAccount: MetaAccount, chain: Chain) = showAddressActions( + address = metaAccount.addressIn(chain), + chain = chain +) + +suspend fun ExternalActions.Presentation.showAddressActions(accountId: AccountId, chain: Chain) = showAddressActions( + address = chain.addressOf(accountId), + chain = chain +) + +suspend fun ExternalActions.Presentation.showAddressActions(address: String?, chain: Chain) { + if (address == null) { + showExternalActions(ExternalActions.Type.EmptyAccount, chain) + } else { + showExternalActions( + type = ExternalActions.Type.Address(address), + chain = chain + ) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActionsProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActionsProvider.kt new file mode 100644 index 0000000..bdabc25 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActionsProvider.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_account_api.presenatation.actions + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.CopyValueMixin +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.domain.account.common.ChainWithAccountId +import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createOptionalAccountAddressIcon +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.accountUrlOf +import io.novafoundation.nova.runtime.ext.eventUrlOf +import io.novafoundation.nova.runtime.ext.extrinsicUrlOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ExternalActionsProvider( + private val resourceManager: ResourceManager, + private val addressIconGenerator: AddressIconGenerator, + private val copyAddressMixin: CopyAddressMixin, + private val copyValueMixin: CopyValueMixin +) : ExternalActions.Presentation { + + override val openBrowserEvent = MutableLiveData>() + + override val showExternalActionsEvent = MutableLiveData>() + + override fun viewExternalClicked(explorer: Chain.Explorer, type: ExternalActions.Type) { + val url = when (type) { + ExternalActions.Type.EmptyAccount -> null + is ExternalActions.Type.Address -> type.address.let { explorer.accountUrlOf(it) } + is ExternalActions.Type.Event -> explorer.eventUrlOf(type.id) + is ExternalActions.Type.Extrinsic -> explorer.extrinsicUrlOf(type.hash) + } + + url?.let { showBrowser(url) } + } + + override fun showBrowser(url: String) { + openBrowserEvent.value = Event(url) + } + + override suspend fun showExternalActions(type: ExternalActions.Type, chain: Chain) { + val copyLabelRes = when (type) { + is ExternalActions.Type.Address -> R.string.common_copy_address + is ExternalActions.Type.Event -> R.string.common_copy_id + is ExternalActions.Type.Extrinsic -> R.string.transaction_details_copy_hash + ExternalActions.Type.EmptyAccount -> null + } + + // only show chain button for address as for now + val chainUi = when (type) { + is ExternalActions.Type.Address -> mapChainToUi(chain) + else -> null + } + + // only show icon for address as for now + val icon = when (type) { + is ExternalActions.Type.Address -> addressIconGenerator.createOptionalAccountAddressIcon(chain, type.address) + ?: resourceManager.getDrawable(R.drawable.ic_identicon_placeholder) + + is ExternalActions.Type.EmptyAccount -> resourceManager.getDrawable(R.drawable.ic_identicon_placeholder) + + is ExternalActions.Type.Event, + is ExternalActions.Type.Extrinsic -> null + } + + val payload = ExternalActions.Payload( + type = type, + chain = chain, + chainUi = chainUi, + icon = icon, + copyLabelRes = copyLabelRes + ) + + showExternalActionsEvent.value = Event(payload) + } + + override fun copyValue(payload: ExternalActions.Payload) { + when (val type = payload.type) { + is ExternalActions.Type.Address -> { + val accountId = payload.chain.accountIdOf(type.address) + val chainWithAccountId = ChainWithAccountId(payload.chain, accountId) + copyAddressMixin.copyAddressOrOpenSelector(chainWithAccountId) + } + + is ExternalActions.Type.Event -> copyValueMixin.copyValue(type.id) + is ExternalActions.Type.Extrinsic -> copyValueMixin.copyValue(type.hash) + + ExternalActions.Type.EmptyAccount -> {} + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActionsSheet.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActionsSheet.kt new file mode 100644 index 0000000..7bc9f43 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActionsSheet.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_account_api.presenatation.actions + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.BottomSheetExternalActionsBinding +import io.novafoundation.nova.runtime.ext.availableExplorersFor +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +typealias ExternalViewCallback = (Chain.Explorer, ExternalActions.Type) -> Unit +typealias CopyCallback = (ExternalActions.Payload) -> Unit + +open class ExternalActionsSheet( + context: Context, + protected val payload: ExternalActions.Payload, + val onCopy: CopyCallback, + val onViewExternal: ExternalViewCallback, +) : FixedListBottomSheet( + context, + viewConfiguration = ViewConfiguration( + configurationBinder = BottomSheetExternalActionsBinding.inflate(LayoutInflater.from(context)), + title = { configurationBinder.externalActionsValue }, + container = { configurationBinder.externalActionsContainer } + ) +) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (payload.chainUi != null) { + binder.externalActionsChain.makeVisible() + binder.externalActionsChain.setChain(payload.chainUi) + } else { + binder.externalActionsChain.makeGone() + } + + if (payload.icon != null) { + binder.externalActionsIcon.makeVisible() + binder.externalActionsIcon.setImageDrawable(payload.icon) + } else { + binder.externalActionsIcon.makeGone() + } + + setTitle(payload.type.primaryValue) + + payload.copyLabelRes?.let { + textItem(R.drawable.ic_copy_outline, payload.copyLabelRes) { + onCopy(payload) + } + + showExplorers() + } + } + + private fun showExplorers() { + payload.chain + .availableExplorersFor(payload.type.explorerTemplateExtractor) + .forEach { explorer -> + val title = context.getString(R.string.transaction_details_view_explorer, explorer.name) + + textItem(R.drawable.ic_browser_outline, title, showArrow = true) { + onViewExternal(explorer, payload.type) + } + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActionsUi.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActionsUi.kt new file mode 100644 index 0000000..d536c62 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/actions/ExternalActionsUi.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_api.presenatation.actions + +import android.content.Context +import androidx.lifecycle.lifecycleScope +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents + +fun BaseFragment.setupExternalActions(viewModel: T) where T : BaseViewModel, T : ExternalActions { + setupExternalActions(viewModel) { context, payload -> + ExternalActionsSheet( + context, + payload, + viewModel::copyValue, + viewModel::viewExternalClicked + ) + } +} + +inline fun BaseFragment.setupExternalActions( + viewModel: T, + crossinline customSheetCreator: suspend (Context, ExternalActions.Payload) -> ExternalActionsSheet, +) where T : BaseViewModel, T : ExternalActions { + observeBrowserEvents(viewModel) + + viewModel.showExternalActionsEvent.observeEvent { + viewLifecycleOwner.lifecycleScope.launchWhenResumed { + customSheetCreator(requireContext(), it).show() + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/addressActions/AddressActionsMixin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/addressActions/AddressActionsMixin.kt new file mode 100644 index 0000000..0f559b3 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/addressActions/AddressActionsMixin.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_api.presenatation.addressActions + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.format.AddressFormat +import io.novafoundation.nova.common.address.format.asAddress +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.view.ChipLabelModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope + +/** + * Simplified version of [ExternalActions] that does not require [Chain] to show address info but relies on [AddressFormat] instead + * Note that this mixin just allows to copy the address and does not work with Unified Address Format (which is tied to a particular chain) since it requires to have + * chain in the context + */ +interface AddressActionsMixin { + + interface Factory { + + fun create(coroutineScope: CoroutineScope): Presentation + } + + class Payload( + val addressModel: AddressModel, + val addressTypeLabel: ChipLabelModel, + ) + + val showAddressActionsEvent: LiveData> + + fun copyValue(payload: Payload) + + interface Presentation : AddressActionsMixin { + + fun showAddressActions(accountId: AccountId, addressFormat: AddressFormat) + } +} + +fun AddressActionsMixin.Presentation.showAddressActions(address: String, addressFormat: AddressFormat) { + val accountId = addressFormat.accountIdOf(address.asAddress()).value + return showAddressActions(accountId, addressFormat) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/addressActions/AddressActionsSheet.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/addressActions/AddressActionsSheet.kt new file mode 100644 index 0000000..264732d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/addressActions/AddressActionsSheet.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_account_api.presenatation.addressActions + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.BottomSheetAddressActionsBinding + +typealias CopyAddressCallback = (AddressActionsMixin.Payload) -> Unit + +class AddressActionsSheet( + context: Context, + private val payload: AddressActionsMixin.Payload, + val onCopy: CopyAddressCallback, +) : FixedListBottomSheet( + context, + viewConfiguration = ViewConfiguration( + configurationBinder = BottomSheetAddressActionsBinding.inflate(LayoutInflater.from(context)), + title = { configurationBinder.addressActionsValue }, + container = { configurationBinder.addressActionsContainer } + ) +) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binder.addressActionsScheme.setModel(payload.addressTypeLabel) + binder.addressActionsIcon.setImageDrawable(payload.addressModel.image) + + binder.addressActionsValue.text = payload.addressModel.address + + textItem(R.drawable.ic_copy_outline, R.string.common_copy_address) { + onCopy(payload) + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/addressActions/AddressActionsUi.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/addressActions/AddressActionsUi.kt new file mode 100644 index 0000000..aba6772 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/addressActions/AddressActionsUi.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_api.presenatation.addressActions + +import androidx.lifecycle.lifecycleScope +import io.novafoundation.nova.common.base.BaseFragmentMixin + +context(BaseFragmentMixin<*>) +fun AddressActionsMixin.setupAddressActions() { + showAddressActionsEvent.observeEvent { payload -> + lifecycleOwner.lifecycleScope.launchWhenResumed { + AddressActionsSheet( + context = providedContext, + payload = payload, + onCopy = ::copyValue, + ).show() + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/chain/ChainListBottomSheet.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/chain/ChainListBottomSheet.kt new file mode 100644 index 0000000..175e6fb --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/chain/ChainListBottomSheet.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_api.presenatation.chain + +import android.content.Context +import android.os.Bundle +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ItemBottomSheetChainListBinding + +class ChainListBottomSheet( + context: Context, + data: List, +) : DynamicListBottomSheet(context, Payload(data), AccountDiffCallback, onClicked = null) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.common_networks) + } + + override fun holderCreator(): HolderCreator = { + ChainListHolder(ItemBottomSheetChainListBinding.inflate(it.inflater(), it, false)) + } +} + +class ChainListHolder( + private val binder: ItemBottomSheetChainListBinding +) : DynamicListSheetAdapter.Holder(binder.root) { + + override fun bind(item: ChainUi, isSelected: Boolean, handler: DynamicListSheetAdapter.Handler) { + binder.itemChainListBottomSheet.setChain(item) + } +} + +private object AccountDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ChainUi, newItem: ChainUi): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ChainUi, newItem: ChainUi): Boolean { + return true + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/chain/ChainListOverview.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/chain/ChainListOverview.kt new file mode 100644 index 0000000..2165501 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/chain/ChainListOverview.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_account_api.presenatation.chain + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.resources.formatListPreview +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.feature_account_api.R + +class ChainListOverview( + val icon: Icon?, + val value: String, + val label: String, + val hasMoreElements: Boolean, +) + +fun ResourceManager.formatChainListOverview(chains: List): ChainListOverview { + return ChainListOverview( + icon = chains.singleOrNull()?.let { it.icon.asIconOrFallback() }, + value = formatListPreview(chains.map(ChainUi::name)), + hasMoreElements = chains.size > 1, + label = getQuantityString(R.plurals.common_networks_plural, chains.size) + ) +} + +fun TableCellView.showChainsOverview(chainListOverview: ChainListOverview) { + setTitle(chainListOverview.label) + showValue(chainListOverview.value) + + loadImage(chainListOverview.icon) + + if (chainListOverview.hasMoreElements) { + isClickable = true + setPrimaryValueEndIcon(R.drawable.ic_info) + } else { + isClickable = false + setPrimaryValueEndIcon(null) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/chain/ChainUi.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/chain/ChainUi.kt new file mode 100644 index 0000000..d8128a0 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/chain/ChainUi.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_account_api.presenatation.chain + +import android.content.Context +import android.graphics.drawable.Drawable +import android.widget.ImageView +import coil.ImageLoader +import coil.load +import coil.request.ImageRequest +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.fallbackIcon +import io.novafoundation.nova.common.presentation.getAssetIconOrFallback +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.asIcon +import io.novafoundation.nova.common.utils.images.asUrlIcon +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +data class ChainUi( + val id: String, + val name: String, + val icon: String? +) + +private val ASSET_ICON_PLACEHOLDER = R.drawable.ic_pezkuwi + +fun ImageView.loadChainIcon(icon: String?, imageLoader: ImageLoader) { + load(icon, imageLoader) { + placeholder(R.drawable.bg_chain_placeholder) + error(R.drawable.bg_chain_placeholder) + fallback(R.drawable.ic_fallback_network_icon) + } +} + +fun ImageLoader.loadChainIconToTarget(icon: String?, context: Context, target: (Drawable) -> Unit) { + val request = ImageRequest.Builder(context) + .data(icon) + .placeholder(R.drawable.bg_chain_placeholder) + .error(R.drawable.bg_chain_placeholder) + .fallback(R.drawable.ic_fallback_network_icon) + .target { target(it) } + .build() + + this.enqueue(request) +} + +fun ImageView.setTokenIcon(icon: Icon, imageLoader: ImageLoader) { + setIcon(icon, imageLoader) { + fallback(ASSET_ICON_PLACEHOLDER) + } +} + +fun Chain.iconOrFallback(): Icon { + return icon?.asUrlIcon() ?: chainIconFallback() +} + +fun String?.asIconOrFallback(): Icon { + return this?.asUrlIcon() ?: chainIconFallback() +} + +fun chainIconFallback(): Icon { + return R.drawable.ic_fallback_network_icon.asIcon() +} + +fun AssetIconProvider.getAssetIconOrFallback(asset: Chain.Asset, fallbackIcon: Icon = AssetIconProvider.fallbackIcon): Icon { + return this.getAssetIconOrFallback(asset.icon, fallbackIcon) +} + +fun AssetIconProvider.getAssetIconOrFallback(asset: Chain.Asset, iconMode: AssetIconMode, fallbackIcon: Icon = AssetIconProvider.fallbackIcon): Icon { + return this.getAssetIconOrFallback(asset.icon, iconMode, fallbackIcon) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/cloudBackup/changePassword/ChangeBackupPasswordCommunicator.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/cloudBackup/changePassword/ChangeBackupPasswordCommunicator.kt new file mode 100644 index 0000000..864a7b9 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/cloudBackup/changePassword/ChangeBackupPasswordCommunicator.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import kotlinx.parcelize.Parcelize + +interface ChangeBackupPasswordRequester : + InterScreenRequester { + + @Parcelize + object EmptyRequest : Parcelable +} + +interface ChangeBackupPasswordResponder : + InterScreenResponder { + + @Parcelize + object Success : Parcelable +} + +interface ChangeBackupPasswordCommunicator : ChangeBackupPasswordRequester, ChangeBackupPasswordResponder diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/cloudBackup/changePassword/RestoreBackupPasswordCommunicator.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/cloudBackup/changePassword/RestoreBackupPasswordCommunicator.kt new file mode 100644 index 0000000..ad97c9f --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/cloudBackup/changePassword/RestoreBackupPasswordCommunicator.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import kotlinx.parcelize.Parcelize + +interface RestoreBackupPasswordRequester : + InterScreenRequester { + + @Parcelize + object EmptyRequest : Parcelable +} + +interface RestoreBackupPasswordResponder : + InterScreenResponder { + + @Parcelize + object Success : Parcelable +} + +interface RestoreBackupPasswordCommunicator : RestoreBackupPasswordRequester, RestoreBackupPasswordResponder diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/cloudBackup/createPassword/SyncWalletsBackupPasswordCommunicator.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/cloudBackup/createPassword/SyncWalletsBackupPasswordCommunicator.kt new file mode 100644 index 0000000..cea2c3c --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/cloudBackup/createPassword/SyncWalletsBackupPasswordCommunicator.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import kotlinx.parcelize.Parcelize + +interface SyncWalletsBackupPasswordRequester : + InterScreenRequester { + + @Parcelize + object EmptyRequest : Parcelable +} + +interface SyncWalletsBackupPasswordResponder : + InterScreenResponder { + + @Parcelize + class Response(val isSyncingSuccessful: Boolean) : Parcelable +} + +interface SyncWalletsBackupPasswordCommunicator : SyncWalletsBackupPasswordRequester, SyncWalletsBackupPasswordResponder diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/common/WalletTypeNameMapping.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/common/WalletTypeNameMapping.kt new file mode 100644 index 0000000..0b855d5 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/common/WalletTypeNameMapping.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_api.presenatation.common + +import androidx.annotation.StringRes +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount + +@StringRes +fun LightMetaAccount.Type.mapMetaAccountTypeToNameRes(): Int { + return when (this) { + LightMetaAccount.Type.SECRETS -> R.string.account_secrets + LightMetaAccount.Type.WATCH_ONLY -> R.string.account_watch_only + + LightMetaAccount.Type.PARITY_SIGNER -> R.string.account_parity_signer + LightMetaAccount.Type.POLKADOT_VAULT -> R.string.account_polkadot_vault + + LightMetaAccount.Type.LEDGER_LEGACY, + LightMetaAccount.Type.LEDGER -> R.string.common_ledger + + LightMetaAccount.Type.PROXIED -> R.string.account_proxieds + + LightMetaAccount.Type.MULTISIG -> R.string.account_multisig_group_label + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/fee/FeePaymentCurrencyParcel.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/fee/FeePaymentCurrencyParcel.kt new file mode 100644 index 0000000..c9b08a8 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/fee/FeePaymentCurrencyParcel.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_api.presenatation.fee + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency +import io.novafoundation.nova.runtime.util.ChainAssetParcel +import kotlinx.android.parcel.Parcelize + +sealed class FeePaymentCurrencyParcel : Parcelable { + + @Parcelize + object Native : FeePaymentCurrencyParcel() + + @Parcelize + class Asset(val asset: ChainAssetParcel) : FeePaymentCurrencyParcel() +} + +fun FeePaymentCurrency.toParcel(): FeePaymentCurrencyParcel { + return when (this) { + is FeePaymentCurrency.Asset -> FeePaymentCurrencyParcel.Asset(ChainAssetParcel(asset)) + FeePaymentCurrency.Native -> FeePaymentCurrencyParcel.Native + } +} + +fun FeePaymentCurrencyParcel.toDomain(): FeePaymentCurrency { + return when (this) { + is FeePaymentCurrencyParcel.Asset -> asset.value.toFeePaymentCurrency() + FeePaymentCurrencyParcel.Native -> FeePaymentCurrency.Native + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/fee/select/FeeAssetSelectorBottomSheet.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/fee/select/FeeAssetSelectorBottomSheet.kt new file mode 100644 index 0000000..3a5af1a --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/fee/select/FeeAssetSelectorBottomSheet.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_api.presenatation.fee.select + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import io.novafoundation.nova.common.utils.indexOfFirstOrNull +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet +import io.novafoundation.nova.feature_account_api.databinding.BottomSheetFeeSelectionBinding +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class FeeAssetSelectorBottomSheet( + context: Context, + private val payload: Payload, + val onOptionClicked: (Chain.Asset) -> Unit, + onCancel: () -> Unit, +) : BaseBottomSheet(context, onCancel = onCancel) { + + override val binder: BottomSheetFeeSelectionBinding = BottomSheetFeeSelectionBinding.inflate(LayoutInflater.from(context)) + + class Payload( + val options: List, + val selectedOption: Chain.Asset, + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + payload.options.forEach { feeOption -> + binder.bottomSheetFeeSelectionAssets.addTab(feeOption.symbol.value) + } + + binder.bottomSheetFeeSelectionAssets.onTabSelected { index -> + onOptionClicked(payload.options[index]) + dismiss() + } + + payload.selectedOptionIndex?.let { index -> + binder.bottomSheetFeeSelectionAssets.setCheckedTab(index, triggerListener = false) + } + } + + private val Payload.selectedOptionIndex: Int? + get() = options.indexOfFirstOrNull { it.id == selectedOption.id } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/language/LanguageModel.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/language/LanguageModel.kt new file mode 100644 index 0000000..56d4ba1 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/language/LanguageModel.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_account_api.presenatation.language + +data class LanguageModel( + val iso: String, + val displayName: String, + val nativeDisplayName: String +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/language/LanguageUseCase.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/language/LanguageUseCase.kt new file mode 100644 index 0000000..235006e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/language/LanguageUseCase.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_api.presenatation.language + +interface LanguageUseCase { + + suspend fun selectedLanguageModel(): LanguageModel +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputField.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputField.kt new file mode 100644 index 0000000..d78979e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputField.kt @@ -0,0 +1,144 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput + +import android.content.Context +import android.util.AttributeSet +import android.widget.EditText +import android.widget.LinearLayout +import androidx.core.view.isGone +import androidx.core.view.isVisible +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeInvisible +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getInputBackground +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ViewAddressInputBinding +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.ExternalAccount + +class AddressInputField @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewAddressInputBinding.inflate(inflater(), this) + + val content: EditText + get() = binder.addressInputAddress + + init { + orientation = VERTICAL + + binder.addressInputW3NAddress.setDrawableStart(R.drawable.ic_checkmark_circle_16, tint = R.color.icon_positive, paddingInDp = 4) + binder.addressInputW3NAddress.setDrawableEnd(R.drawable.ic_info, paddingInDp = 4) + + setAddStatesFromChildren(true) + + setBackgrounds() + + attrs?.let(::applyAttributes) + } + + fun setState(state: AddressInputState) { + setIdenticonState(state.iconState) + + binder.addressInputScan.setVisible(state.scanShown && isEnabled) + binder.addressInputPaste.setVisible(state.pasteShown && isEnabled) + binder.addressInputClear.setVisible(state.clearShown && isEnabled) + binder.addressInputMyself.setVisible(state.myselfShown && isEnabled) + } + + fun setExternalAccount(externalAccountState: ExtendedLoadingState) { + if (binder.addressInputW3NContainer.isGone) return + + when { + externalAccountState is ExtendedLoadingState.Loading -> { + binder.addressInputW3NAddress.makeInvisible() + binder.addressInputW3NProgress.makeVisible() + } + + externalAccountState is ExtendedLoadingState.Loaded && externalAccountState.data != null -> { + val externalAccount = externalAccountState.data!! + binder.addressInputW3NAddress.text = externalAccount.addressWithDescription + binder.addressInputW3NAddress.makeVisible() + binder.addressInputW3NProgress.makeInvisible() + } + + externalAccountState is ExtendedLoadingState.Loaded && externalAccountState.data == null -> { + binder.addressInputW3NAddress.text = null + binder.addressInputW3NAddress.makeInvisible() + binder.addressInputW3NProgress.makeInvisible() + } + } + } + + fun onPasteClicked(listener: OnClickListener) { + binder.addressInputPaste.setOnClickListener(listener) + } + + fun onClearClicked(listener: OnClickListener) { + binder.addressInputClear.setOnClickListener(listener) + } + + fun onScanClicked(listener: OnClickListener) { + binder.addressInputScan.setOnClickListener(listener) + } + + fun onMyselfClicked(listener: OnClickListener) { + binder.addressInputMyself.setOnClickListener(listener) + } + + fun onExternalAddressClicked(listener: OnClickListener) { + binder.addressInputW3NAddress.setOnClickListener(listener) + } + + override fun setEnabled(enabled: Boolean) { + content.isEnabled = enabled + super.setEnabled(enabled) + } + + private fun setIdenticonState(state: AddressInputState.IdenticonState) { + when (state) { + is AddressInputState.IdenticonState.Address -> { + binder.addressInputIdenticon.makeVisible() + binder.addressInputIdenticon.setImageDrawable(state.drawable) + } + + AddressInputState.IdenticonState.Placeholder -> { + binder.addressInputIdenticon.makeVisible() + binder.addressInputIdenticon.setImageResource(R.drawable.ic_identicon_placeholder) + } + } + } + + private fun setBackgrounds() = with(context) { + binder.addressInputField.background = context.getInputBackground() + + binder.addressInputPaste.background = buttonBackground() + binder.addressInputMyself.background = buttonBackground() + binder.addressInputScan.background = buttonBackground() + } + + private fun Context.buttonBackground() = addRipple(getRoundedCornerDrawable(R.color.button_background_secondary)) + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.AddressInputField) { + isEnabled = it.getBoolean(R.styleable.AddressInputField_android_enabled, true) + + val hint = it.getString(R.styleable.AddressInputField_android_hint) + hint?.let { content.hint = hint } + + val hasExternalAccountIdentifiers = it.getBoolean(R.styleable.AddressInputField_hasExternalAccountIdentifiers, false) + binder.addressInputW3NContainer.isVisible = hasExternalAccountIdentifiers + if (hasExternalAccountIdentifiers) { + binder.addressInputW3NAddress.background = getRoundedCornerDrawable(cornerSizeDp = 6) + .withRippleMask(getRippleMask(cornerSizeDp = 6)) + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixin.kt new file mode 100644 index 0000000..231a486 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixin.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput + +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.ExternalAccountResolver +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec.AddressInputSpec +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +interface AddressInputMixin : ExternalAccountResolver { + + suspend fun getInputSpec(): AddressInputSpec + + val inputFlow: MutableStateFlow + + val state: Flow + + fun pasteClicked() + + fun clearClicked() + + fun scanClicked() + + fun myselfClicked() + + suspend fun getAddress(): String + + fun clearExtendedAccount() +} + +suspend fun AddressInputMixin.isAddressValid(input: String) = getInputSpec().isValidAddress(input) + +fun AddressInputMixin.setAddress(input: String) { + inputFlow.value = input +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixinExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixinExt.kt new file mode 100644 index 0000000..a3a1fb6 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixinExt.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput + +import android.graphics.drawable.Drawable + +fun Result.toIdenticonState(): AddressInputState.IdenticonState { + return fold( + onSuccess = { AddressInputState.IdenticonState.Address(it) }, + onFailure = { AddressInputState.IdenticonState.Placeholder } + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixinProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixinProvider.kt new file mode 100644 index 0000000..0c81af8 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixinProvider.kt @@ -0,0 +1,260 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.systemCall.ScanQrCodeCall +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.utils.systemCall.onSystemCallFailure +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.AccountIdentifierProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.ExternalAccount +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.providers.EmptyAccountIdentifierProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.providers.Web3NameIdentifierProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec.AddressInputSpec +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec.AddressInputSpecProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec.EVMSpecProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec.SingleChainSpecProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec.SubstrateSpecProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.myselfBehavior.CrossChainOnlyBehaviorProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.myselfBehavior.MyselfBehavior +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.myselfBehavior.MyselfBehaviorProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.myselfBehavior.NoMyselfBehaviorProvider +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.qr.MultiChainQrSharingFactory +import io.novafoundation.nova.web3names.domain.networking.Web3NamesInteractor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class AddressInputMixinFactory( + private val addressIconGenerator: AddressIconGenerator, + private val systemCallExecutor: SystemCallExecutor, + private val clipboardManager: ClipboardManager, + private val resourceManager: ResourceManager, + private val qrSharingFactory: MultiChainQrSharingFactory, + private val accountUseCase: SelectedAccountUseCase, + private val web3NamesInteractor: Web3NamesInteractor +) { + + fun create( + inputSpecProvider: AddressInputSpecProvider, + myselfBehaviorProvider: MyselfBehaviorProvider = noMyself(), + errorDisplayer: (cause: String) -> Unit, + showAccountEvent: ((address: String) -> Unit)?, + coroutineScope: CoroutineScope, + accountIdentifierProvider: AccountIdentifierProvider.Factory = noAccountIdentifiers(), + ): AddressInputMixin = AddressInputMixinProvider( + specProvider = inputSpecProvider, + myselfBehaviorProvider = myselfBehaviorProvider, + systemCallExecutor = systemCallExecutor, + clipboardManager = clipboardManager, + qrSharingFactory = qrSharingFactory, + resourceManager = resourceManager, + errorDisplayer = errorDisplayer, + coroutineScope = coroutineScope, + accountIdentifierProviderFactory = accountIdentifierProvider, + showAddressEventCallback = showAccountEvent + ) + + // address input + + fun singleChainInputSpec( + destinationChainFlow: Flow + ): AddressInputSpecProvider = SingleChainSpecProvider( + addressIconGenerator = addressIconGenerator, + targetChain = destinationChainFlow + ) + + fun substrateInputSpec(): AddressInputSpecProvider = SubstrateSpecProvider(addressIconGenerator) + + fun evmInputSpec(): AddressInputSpecProvider = EVMSpecProvider(addressIconGenerator) + + // myself behavior + + fun crossChainOnlyMyself( + originChain: Flow, + destinationChainFlow: Flow + ): MyselfBehaviorProvider = CrossChainOnlyBehaviorProvider( + accountUseCase = accountUseCase, + originChain = originChain, + destinationChain = destinationChainFlow + ) + + // external accounts + + fun noAccountIdentifiers() = AccountIdentifierProvider.Factory { EmptyAccountIdentifierProvider() } + + fun web3nIdentifiers( + destinationChainFlow: Flow, + inputSpecProvider: AddressInputSpecProvider, + coroutineScope: CoroutineScope, + ) = AccountIdentifierProvider.Factory { input -> + Web3NameIdentifierProvider( + web3NameInteractor = web3NamesInteractor, + destinationChain = destinationChainFlow, + addressInputSpecProvider = inputSpecProvider, + coroutineScope = coroutineScope, + input = input, + resourceManager = resourceManager + ) + } + + fun noMyself(): MyselfBehaviorProvider = NoMyselfBehaviorProvider() +} + +class AddressInputMixinProvider( + private val specProvider: AddressInputSpecProvider, + private val myselfBehaviorProvider: MyselfBehaviorProvider, + private val systemCallExecutor: SystemCallExecutor, + private val clipboardManager: ClipboardManager, + private val qrSharingFactory: MultiChainQrSharingFactory, + private val resourceManager: ResourceManager, + private val errorDisplayer: (error: String) -> Unit, + private val showAddressEventCallback: ((address: String) -> Unit)?, + private val accountIdentifierProviderFactory: AccountIdentifierProvider.Factory, + coroutineScope: CoroutineScope, +) : AddressInputMixin, + CoroutineScope by coroutineScope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + private val clipboardFlow = clipboardManager.observePrimaryClip() + .inBackground() + .share() + + override val inputFlow = MutableStateFlow("") + + private val accountIdentifierProvider = accountIdentifierProviderFactory.create(inputFlow) + + override val externalIdentifierEventLiveData = accountIdentifierProvider.eventsLiveData + + override val selectedExternalAccountFlow = accountIdentifierProvider.selectedExternalAccountFlow + + override val state = combine( + myselfBehaviorProvider.behavior, + specProvider.spec, + accountIdentifierProvider.selectedExternalAccountFlow, + inputFlow, + clipboardFlow, + ::createState + ).shareInBackground() + + init { + resetIdentifierInputOnSpecChange() + } + + override suspend fun getInputSpec(): AddressInputSpec { + return specProvider.spec.first() + } + + override fun pasteClicked() { + launch { + inputFlow.value = withContext(Dispatchers.IO) { + clipboardManager.getTextOrNull().orEmpty() + } + accountIdentifierProvider.loadExternalAccounts(inputFlow.value) + } + } + + override fun clearClicked() { + inputFlow.value = "" + } + + override fun scanClicked() { + launch { + systemCallExecutor.executeSystemCall(ScanQrCodeCall()).mapCatching { + val spec = specProvider.spec.first() + + qrSharingFactory.create(spec::isValidAddress).decode(it).address + }.onSuccess { address -> + inputFlow.value = address + }.onSystemCallFailure { + errorDisplayer(resourceManager.getString(R.string.invoice_scan_error_no_info)) + } + } + } + + override fun myselfClicked() { + launch { + val myself = myselfBehaviorProvider.behavior.first().myself() ?: return@launch + + inputFlow.value = myself + } + } + + override fun selectedExternalAddressClicked() { + if (showAddressEventCallback == null) return + + launch { + val selectedAccount = selectedExternalAccountFlow.first().dataOrNull + if (selectedAccount != null && selectedAccount.isValid) { + showAddressEventCallback.invoke(selectedAccount.address) + } + } + } + + override fun loadExternalIdentifiers() { + accountIdentifierProvider.loadExternalAccounts(inputFlow.value) + } + + override fun selectExternalAccount(externalAccount: ExternalAccount) { + accountIdentifierProvider.selectExternalAccount(externalAccount) + } + + override suspend fun getAddress(): String { + val externalAddress = selectedExternalAccountFlow.first().dataOrNull?.address + return externalAddress ?: inputFlow.value + } + + override fun clearExtendedAccount() { + accountIdentifierProvider.selectExternalAccount(null) + } + + private fun resetIdentifierInputOnSpecChange() { + specProvider.spec.onEach { + val currentInput = inputFlow.value + + if (accountIdentifierProvider.isIdentifierValid(currentInput)) { + inputFlow.value = "" + } + }.launchIn(this) + } + + private suspend fun createState( + myselfBehavior: MyselfBehavior, + inputSpec: AddressInputSpec, + externalAccount: ExtendedLoadingState, + input: String, + clipboard: String? + ): AddressInputState { + val icon = externalAccount.dataOrNull?.icon ?: generateIcon(inputSpec, input) + + return AddressInputState( + iconState = icon, + pasteShown = input.isEmpty() && clipboard != null, + scanShown = input.isEmpty(), + clearShown = input.isNotEmpty(), + myselfShown = input.isEmpty() && myselfBehavior.myselfAvailable() + ) + } + + private suspend fun generateIcon( + inputSpec: AddressInputSpec, + input: String + ): AddressInputState.IdenticonState { + return inputSpec.generateIcon(input).toIdenticonState() + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixinUi.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixinUi.kt new file mode 100644 index 0000000..8c2958a --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputMixinUi.kt @@ -0,0 +1,145 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput + +import androidx.lifecycle.lifecycleScope +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.SingletonDialogHolder +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.keyboard.isKeyboardVisible +import io.novafoundation.nova.common.utils.keyboard.setKeyboardVisibilityListener +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.presenatation.account.external.ExternalAccountsBottomSheet +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.AccountIdentifierProvider.Event.ErrorEvent +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.AccountIdentifierProvider.Event.ShowBottomSheetEvent +import io.novafoundation.nova.web3names.domain.exceptions.Web3NamesException + +fun BaseFragment<*, *>.setupAddressInput( + mixin: AddressInputMixin, + inputField: AddressInputField +) = with(inputField) { + content.bindTo(mixin.inputFlow, lifecycleScope) + + onScanClicked { mixin.scanClicked() } + onPasteClicked { mixin.pasteClicked() } + onClearClicked { mixin.clearClicked() } + onMyselfClicked { mixin.myselfClicked() } + + mixin.state.observe(::setState) +} + +/** + * Make sure that the insets are not consumed by the layer above for this method to work correctly + */ +fun BaseFragment<*, *>.setupExternalAccounts( + mixin: AddressInputMixin, + inputField: AddressInputField +) { + mixin.selectedExternalAccountFlow.observeWhenVisible { inputField.setExternalAccount(it) } + handleExternalAccountEvents(mixin) + setupExternalAccountsCallback(mixin, inputField) +} + +private fun BaseFragment<*, *>.setupExternalAccountsCallback( + mixin: AddressInputMixin, + inputField: AddressInputField +) { + inputField.onExternalAddressClicked { + mixin.selectedExternalAddressClicked() + } + + inputField.content.setOnFocusChangeListener { v, hasFocus -> + if (!hasFocus && isKeyboardVisible()) { + mixin.loadExternalIdentifiers() + } + } + + addInputKeyboardCallback(mixin, inputField) +} + +fun BaseFragment<*, *>.addInputKeyboardCallback(mixin: AddressInputMixin, inputField: AddressInputField) { + lifecycle.setKeyboardVisibilityListener(inputField) { keyboardVisible -> + if (!keyboardVisible && inputField.content.hasFocus()) { + mixin.loadExternalIdentifiers() + } + } +} + +fun BaseFragment<*, *>.removeInputKeyboardCallback(inputField: AddressInputField) { + lifecycle.setKeyboardVisibilityListener(inputField, null) +} + +private fun BaseFragment<*, *>.handleExternalAccountEvents(mixin: AddressInputMixin) { + val singletonDialogHolder = SingletonDialogHolder() + + mixin.externalIdentifierEventLiveData.observeEvent { + when (it) { + is ShowBottomSheetEvent -> showExternalAccountsBottomSheet(mixin, it, singletonDialogHolder) + is ErrorEvent -> handleError(it) + } + } +} + +private fun BaseFragment<*, *>.showExternalAccountsBottomSheet( + mixin: AddressInputMixin, + event: ShowBottomSheetEvent, + singletonDialogHolder: SingletonDialogHolder +) { + singletonDialogHolder.showNewDialogOrSkip { + ExternalAccountsBottomSheet( + requireContext(), + getString(R.string.web3names_identifiers_sheet_title, event.chainName, event.identifier), + DynamicListBottomSheet.Payload(event.externalAccounts, event.selectedAccount) + ) { bottomSheet, account -> + if (account.isValid) { + mixin.selectExternalAccount(account) + bottomSheet.dismiss() + } else { + showErrorWithTitle( + getString(R.string.web3names_invalid_recepient_title), + getString(R.string.common_validation_invalid_address_message, event.chainName) + ) + } + } + } +} + +private fun BaseFragment<*, *>.handleError(event: ErrorEvent) { + val titleAndMessage = when (val exception = event.exception) { + is Web3NamesException.ChainProviderNotFoundException -> { + getString(R.string.web3names_invalid_recepient_title) to getString(R.string.web3names_recepient_not_found_message, exception.web3Name) + } + + is Web3NamesException.ValidAccountNotFoundException -> { + getString(R.string.web3names_invalid_recepient_title) to getString( + R.string.web3names_no_valid_recepient_found_message, + exception.web3Name, + exception.chainName + ) + } + + is Web3NamesException.IntegrityCheckFailed -> { + getString(R.string.web3names_integrity_check_failed_title) to getString( + R.string.web3names_integrity_check_failed_message, + exception.web3Name + ) + } + + is Web3NamesException.UnknownException -> { + getString(R.string.web3names_service_unavailable_title) to getString( + R.string.web3names_service_unavailable_message, + exception.chainName + ) + } + is Web3NamesException.UnsupportedAsset -> { + getString(R.string.web3names_unsupported_asset_title, exception.chainAsset.symbol) to getString( + R.string.web3names_unsupported_asset_message, + exception.chainAsset.symbol + ) + } + } + + showErrorWithTitle( + titleAndMessage.first, + titleAndMessage.second + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputState.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputState.kt new file mode 100644 index 0000000..af7aa27 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/AddressInputState.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput + +import android.graphics.drawable.Drawable + +class AddressInputState( + val iconState: IdenticonState, + val pasteShown: Boolean, + val scanShown: Boolean, + val clearShown: Boolean, + val myselfShown: Boolean, +) { + + sealed class IdenticonState { + + object Placeholder : IdenticonState() + + class Address(val drawable: Drawable) : IdenticonState() + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/AccountIdentifierProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/AccountIdentifierProvider.kt new file mode 100644 index 0000000..03390f9 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/AccountIdentifierProvider.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.web3names.domain.exceptions.Web3NamesException +import kotlinx.coroutines.flow.Flow +import io.novafoundation.nova.common.utils.Event as OneShotEvent + +interface AccountIdentifierProvider { + + val selectedExternalAccountFlow: Flow> + + val eventsLiveData: LiveData> + + fun selectExternalAccount(account: ExternalAccount?) + + fun isIdentifierValid(raw: String): Boolean + + fun loadExternalAccounts(raw: String) + + sealed interface Event { + + class ShowBottomSheetEvent( + val identifier: String, + val chainName: String, + val externalAccounts: List, + val selectedAccount: ExternalAccount? + ) : Event + + class ErrorEvent(val exception: Web3NamesException) : Event + } + + fun interface Factory { + + fun create(input: Flow): AccountIdentifierProvider + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/ExternalAccount.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/ExternalAccount.kt new file mode 100644 index 0000000..40c9f4a --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/ExternalAccount.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount + +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputState + +class ExternalAccount( + val address: String, + val description: String?, + val addressWithDescription: String, + val isValid: Boolean, + val icon: AddressInputState.IdenticonState +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/ExternalAccountResolver.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/ExternalAccountResolver.kt new file mode 100644 index 0000000..1565761 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/ExternalAccountResolver.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.Event +import kotlinx.coroutines.flow.Flow + +interface ExternalAccountResolver { + + val externalIdentifierEventLiveData: LiveData> + + val selectedExternalAccountFlow: Flow> + + fun selectedExternalAddressClicked() + + fun loadExternalIdentifiers() + + fun selectExternalAccount(externalAccount: ExternalAccount) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/providers/EmptyAccountIdentifierProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/providers/EmptyAccountIdentifierProvider.kt new file mode 100644 index 0000000..29df574 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/providers/EmptyAccountIdentifierProvider.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.providers + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.loadedNothing +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.AccountIdentifierProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.ExternalAccount +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class EmptyAccountIdentifierProvider : AccountIdentifierProvider { + + override val selectedExternalAccountFlow: Flow> = flowOf(loadedNothing()) + + override val eventsLiveData: LiveData> = MutableLiveData() + + override fun selectExternalAccount(account: ExternalAccount?) { + // empty implementation + } + + override fun loadExternalAccounts(raw: String) { + // empty implementation + } + + override fun isIdentifierValid(raw: String) = false +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/providers/Web3NameIdentifierProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/providers/Web3NameIdentifierProvider.kt new file mode 100644 index 0000000..954d5f3 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/externalAccount/providers/Web3NameIdentifierProvider.kt @@ -0,0 +1,160 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.providers + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.emitLoaded +import io.novafoundation.nova.common.domain.emitLoading +import io.novafoundation.nova.common.presentation.ellipsizeAddress +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.removeSpacing +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.AccountIdentifierProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.AccountIdentifierProvider.Event.ErrorEvent +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.AccountIdentifierProvider.Event.ShowBottomSheetEvent +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.externalAccount.ExternalAccount +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec.AddressInputSpecProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.toIdenticonState +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.web3names.domain.exceptions.Web3NamesException +import io.novafoundation.nova.web3names.domain.models.Web3NameAccount +import io.novafoundation.nova.web3names.domain.networking.Web3NamesInteractor +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class Web3NameIdentifierProvider( + private val web3NameInteractor: Web3NamesInteractor, + private val destinationChain: Flow, + private val addressInputSpecProvider: AddressInputSpecProvider, + private val coroutineScope: CoroutineScope, + private val input: Flow, + private val resourceManager: ResourceManager +) : AccountIdentifierProvider, + CoroutineScope by coroutineScope { + + private var externalAccountsLoadingJob: Job? = null + private val _selectedExternalAccountFlow = MutableStateFlow(null) + private val _externalAccountsLoadingFlow = MutableStateFlow(false) + + override val selectedExternalAccountFlow = combineTransform>( + _selectedExternalAccountFlow, + _externalAccountsLoadingFlow + ) { selectedAccount, isLoading -> + if (isLoading) { + emitLoading() + } else { + emitLoaded(selectedAccount) + } + } + + override val eventsLiveData = MutableLiveData>() + + init { + input.onEach { + _selectedExternalAccountFlow.value = null + cancelLoadingJob() + }.launchIn(this) + } + + override fun selectExternalAccount(account: ExternalAccount?) { + _selectedExternalAccountFlow.value = account + } + + override fun isIdentifierValid(raw: String): Boolean { + return web3NameInteractor.isValidWeb3Name(raw) + } + + override fun loadExternalAccounts(raw: String) { + if (!web3NameInteractor.isValidWeb3Name(raw)) return + + cancelLoadingJob() + + externalAccountsLoadingJob = launch { + try { + startLoading() + + val chain = destinationChain.first().chain + runCatching { getExternalAccounts(raw) } + .onSuccess { onExternalAccountsLoaded(raw, chain.name, it) } + .onFailure { onError(it, chain.name, raw) } + + stopLoading() + } catch (_: CancellationException) { + // Just skip + } + } + } + + private suspend fun getExternalAccounts(raw: String): List { + val inputSpec = addressInputSpecProvider.spec.first() + val chainWithAsset = destinationChain.first() + val chain = chainWithAsset.chain + val asset = chainWithAsset.asset + + return web3NameInteractor.queryAccountsByWeb3Name(raw, chain, asset) + .map { + ExternalAccount( + address = it.address, + description = it.description, + addressWithDescription = resourceManager.addressWithDescription(it), + isValid = it.isValid, + icon = inputSpec.generateIcon(it.address).toIdenticonState() + ) + } + } + + private fun ResourceManager.addressWithDescription(w3nAccount: Web3NameAccount): String { + val description = w3nAccount.description + return if (description != null) { + return getString(R.string.web3names_address_with_description, w3nAccount.address.ellipsizeAddress(), description) + } else { + w3nAccount.address.ellipsizeAddress() + } + } + + private fun startLoading() { + _externalAccountsLoadingFlow.value = true + } + + private fun stopLoading() { + _externalAccountsLoadingFlow.value = false + externalAccountsLoadingJob = null + } + + private fun cancelLoadingJob() { + externalAccountsLoadingJob?.cancel() + stopLoading() + } + + private fun onExternalAccountsLoaded(w3nIdentifier: String, chainName: String, externalAccounts: List) { + if (externalAccounts.size == 1) { + _selectedExternalAccountFlow.value = externalAccounts.first() + } else if (externalAccounts.size > 1) { + eventsLiveData.value = ShowBottomSheetEvent( + w3nIdentifier.removeSpacing(), + chainName, + externalAccounts, + _selectedExternalAccountFlow.value + ).event() + } + } + + private fun onError(throwable: Throwable, chainName: String, web3NameInput: String) { + if (throwable is CancellationException) return + + if (throwable !is Web3NamesException) { + eventsLiveData.value = ErrorEvent(Web3NamesException.UnknownException(web3NameInput, chainName)).event() + } else { + eventsLiveData.value = ErrorEvent(throwable).event() + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/AddressInputSpec.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/AddressInputSpec.kt new file mode 100644 index 0000000..bfa5448 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/AddressInputSpec.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec + +import android.graphics.drawable.Drawable +import kotlinx.coroutines.flow.Flow + +interface AddressInputSpecProvider { + + val spec: Flow +} + +interface AddressInputSpec { + + fun isValidAddress(input: String): Boolean + + suspend fun generateIcon(input: String): Result +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/EVMSpecProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/EVMSpecProvider.kt new file mode 100644 index 0000000..28069e4 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/EVMSpecProvider.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novasama.substrate_sdk_android.extensions.asEthereumAddress +import io.novasama.substrate_sdk_android.extensions.isValid +import io.novasama.substrate_sdk_android.extensions.toAccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class EVMSpecProvider( + private val addressIconGenerator: AddressIconGenerator +) : AddressInputSpecProvider { + + override val spec: Flow = flowOf(Spec()) + + private inner class Spec : AddressInputSpec { + + override fun isValidAddress(input: String): Boolean { + return input.asEthereumAddress().isValid() + } + + override suspend fun generateIcon(input: String): Result { + return runCatching { + addressIconGenerator.createAddressIcon( + accountId = input.asEthereumAddress().toAccountId().value, + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + backgroundColorRes = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + } + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/SingleChainSpecProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/SingleChainSpecProvider.kt new file mode 100644 index 0000000..c56394c --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/SingleChainSpecProvider.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.isValidAddress +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class SingleChainSpecProvider( + private val addressIconGenerator: AddressIconGenerator, + targetChain: Flow, +) : AddressInputSpecProvider { + + override val spec: Flow = targetChain.map(::Spec) + + private inner class Spec(private val targetChain: Chain) : AddressInputSpec { + + override fun isValidAddress(input: String): Boolean { + return targetChain.isValidAddress(input) + } + + override suspend fun generateIcon(input: String): Result { + return runCatching { + require(targetChain.isValidAddress(input)) + + addressIconGenerator.createAddressIcon( + accountId = targetChain.accountIdOf(address = input), + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + backgroundColorRes = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + } + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/SubstrateSpecProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/SubstrateSpecProvider.kt new file mode 100644 index 0000000..faf7998 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/inputSpec/SubstrateSpecProvider.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.inputSpec + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.utils.isValidSS58Address +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class SubstrateSpecProvider( + private val addressIconGenerator: AddressIconGenerator, +) : AddressInputSpecProvider { + + override val spec: Flow = flowOf(Spec()) + + private inner class Spec : AddressInputSpec { + + override fun isValidAddress(input: String): Boolean { + return input.isValidSS58Address() + } + + override suspend fun generateIcon(input: String): Result { + return runCatching { + addressIconGenerator.createAddressIcon( + accountId = input.toAccountId(), + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + backgroundColorRes = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + } + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/maxAction/MaxAvailableDeduction.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/maxAction/MaxAvailableDeduction.kt new file mode 100644 index 0000000..c8844bf --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/maxAction/MaxAvailableDeduction.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +interface MaxAvailableDeduction { + + fun maxAmountDeductionFor(amountAsset: Chain.Asset): BigInteger +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/myselfBehavior/CrossChainOnlyBehaviorProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/myselfBehavior/CrossChainOnlyBehaviorProvider.kt new file mode 100644 index 0000000..a101f7e --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/myselfBehavior/CrossChainOnlyBehaviorProvider.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.myselfBehavior + +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class CrossChainOnlyBehaviorProvider( + private val accountUseCase: SelectedAccountUseCase, + private val originChain: Flow, + destinationChain: Flow, +) : MyselfBehaviorProvider { + + override val behavior: Flow = combine(originChain, destinationChain, ::Behavior) + + private inner class Behavior( + private val originChain: Chain, + private val destinationChain: Chain + ) : MyselfBehavior { + + override suspend fun myselfAvailable(): Boolean { + val metaAccount = accountUseCase.getSelectedMetaAccount() + + return originChain.id != destinationChain.id && metaAccount.hasAccountIn(destinationChain) + } + + override suspend fun myself(): String? { + val metaAccount = accountUseCase.getSelectedMetaAccount() + + return metaAccount.addressIn(destinationChain) + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/myselfBehavior/MyselfBehavior.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/myselfBehavior/MyselfBehavior.kt new file mode 100644 index 0000000..18cb4fd --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/myselfBehavior/MyselfBehavior.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.myselfBehavior + +import kotlinx.coroutines.flow.Flow + +interface MyselfBehaviorProvider { + + val behavior: Flow +} + +interface MyselfBehavior { + + suspend fun myselfAvailable(): Boolean + + suspend fun myself(): String? +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/myselfBehavior/NoMyselfBehaviorProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/myselfBehavior/NoMyselfBehaviorProvider.kt new file mode 100644 index 0000000..8bc478b --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/addressInput/myselfBehavior/NoMyselfBehaviorProvider.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.myselfBehavior + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class NoMyselfBehaviorProvider : MyselfBehaviorProvider { + + override val behavior: Flow = flowOf(Behavior()) + + private class Behavior : MyselfBehavior { + override suspend fun myselfAvailable(): Boolean = false + + override suspend fun myself(): String? = null + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/common/SelectWalletFilterPayload.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/common/SelectWalletFilterPayload.kt new file mode 100644 index 0000000..8db70b7 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/common/SelectWalletFilterPayload.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.common + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed interface SelectWalletFilterPayload : Parcelable { + @Parcelize + object Everything : SelectWalletFilterPayload + + @Parcelize + object ControllableWallets : SelectWalletFilterPayload + + @Parcelize + class ExcludeMetaIds(val metaIds: List) : SelectWalletFilterPayload +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/common/SelectWalletFilterPayloadMappingExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/common/SelectWalletFilterPayloadMappingExt.kt new file mode 100644 index 0000000..cb3aab5 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/common/SelectWalletFilterPayloadMappingExt.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.common + +import io.novafoundation.nova.feature_account_api.domain.filter.selectAddress.SelectAccountFilter + +fun SelectWalletFilterPayload.toMetaAccountsFilter(): SelectAccountFilter { + return when (this) { + is SelectWalletFilterPayload.Everything -> SelectAccountFilter.Everything() + + is SelectWalletFilterPayload.ControllableWallets -> SelectAccountFilter.ControllableWallets() + + is SelectWalletFilterPayload.ExcludeMetaIds -> SelectAccountFilter.ExcludeMetaAccounts(this.metaIds) + } +} + +fun SelectAccountFilter.toRequestFilter(): SelectWalletFilterPayload { + return when (this) { + is SelectAccountFilter.Everything -> SelectWalletFilterPayload.Everything + + is SelectAccountFilter.ControllableWallets -> SelectWalletFilterPayload.ControllableWallets + + is SelectAccountFilter.ExcludeMetaAccounts -> SelectWalletFilterPayload.ExcludeMetaIds(this.metaIds) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/common/SelectedAccountPayload.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/common/SelectedAccountPayload.kt new file mode 100644 index 0000000..f4d30d9 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/common/SelectedAccountPayload.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.common + +sealed interface SelectedAccountPayload { + data class MetaAccount(val metaId: Long) : SelectedAccountPayload + data class Address(val address: String) : SelectedAccountPayload +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/IdentityMixin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/IdentityMixin.kt new file mode 100644 index 0000000..a457be8 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/IdentityMixin.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.identity + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import kotlinx.coroutines.flow.Flow + +interface IdentityMixin : Browserable { + + val openEmailEvent: LiveData> + + val identityFlow: Flow + + fun emailClicked() + + fun twitterClicked() + + fun webClicked() + + interface Presentation : IdentityMixin { + + fun setIdentity(identity: IdentityModel?) + + fun setIdentity(identity: OnChainIdentity?) + } + + interface Factory { + + fun create(): Presentation + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/IdentityModel.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/IdentityModel.kt new file mode 100644 index 0000000..f13c87c --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/IdentityModel.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.identity + +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity + +class IdentityModel( + val display: String?, + val legal: String?, + val web: String?, + val matrix: String?, + val email: String?, + val image: String?, + val twitter: String? +) { + + companion object; +} + +fun IdentityModel.Companion.from(identity: OnChainIdentity): IdentityModel { + return identity.run { + IdentityModel( + display = display, + legal = legal, + web = web, + matrix = matrix, + email = email, + image = image, + twitter = twitter + ) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/IdentityView.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/IdentityView.kt new file mode 100644 index 0000000..704449d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/IdentityView.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.identity + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.view.TableView +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ViewIdentityBinding + +class IdentityView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TableView(context, attrs, defStyleAttr) { + + private val binder = ViewIdentityBinding.inflate(inflater(), this) + + init { + setTitle(context.getString(R.string.identity_title)) + } + + fun setModel(identityModel: IdentityModel) = with(identityModel) { + binder.viewIdentityLegalName.showValueOrHide(legal) + binder.viewIdentityEmail.showValueOrHide(email) + binder.viewIdentityTwitter.showValueOrHide(twitter) + binder.viewIdentityElementName.showValueOrHide(matrix) + binder.viewIdentityWeb.showValueOrHide(web) + } + + fun onEmailClicked(onClick: () -> Unit) { + binder.viewIdentityEmail.setOnClickListener(onClick) + } + + fun onWebClicked(onClick: () -> Unit) { + binder.viewIdentityWeb.setOnClickListener(onClick) + } + + fun onTwitterClicked(onClick: () -> Unit) { + binder.viewIdentityTwitter.setOnClickListener(onClick) + } + + private fun View.setOnClickListener(onClick: () -> Unit) { + setOnClickListener { onClick() } + } +} + +fun IdentityView.setModelOrHide(identityModel: IdentityModel?) = letOrHide(identityModel, ::setModel) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/Ui.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/Ui.kt new file mode 100644 index 0000000..ceec5f4 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/identity/Ui.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.identity + +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.sendEmailIntent + +fun BaseFragmentMixin<*>.setupIdentityMixin( + mixin: IdentityMixin, + view: IdentityView +) { + view.onEmailClicked { mixin.emailClicked() } + view.onWebClicked { mixin.webClicked() } + view.onTwitterClicked { mixin.twitterClicked() } + + observeBrowserEvents(mixin) + + mixin.openEmailEvent.observeEvent { + providedContext.sendEmailIntent(it) + } + + mixin.identityFlow.observe(view::setModelOrHide) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/ImportTypeChooserBottomSheet.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/ImportTypeChooserBottomSheet.kt new file mode 100644 index 0000000..e709b4d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/ImportTypeChooserBottomSheet.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.importType + +import android.content.Context +import android.os.Bundle +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textWithDescriptionItem +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.presenatation.account.add.SecretType + +class ImportTypeChooserBottomSheet( + context: Context, + private val onChosen: (SecretType) -> Unit, + private val allowedSources: Set +) : FixedListBottomSheet(context, viewConfiguration = ViewConfiguration.default(context)) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.account_select_secret_source) + + item( + type = SecretType.MNEMONIC, + title = R.string.recovery_passphrase, + subtitle = R.string.account_mnmonic_length_variants, + icon = R.drawable.ic_mnemonic_phrase + ) + + item( + type = SecretType.SEED, + title = R.string.recovery_raw_seed, + subtitle = R.string.common_hexadecimal_string, + icon = R.drawable.ic_raw_seed + ) + + item( + type = SecretType.JSON, + title = R.string.recovery_json, + subtitle = R.string.account_json_file, + icon = R.drawable.ic_file_outline + ) + } + + private fun item( + type: SecretType, + @StringRes title: Int, + @StringRes subtitle: Int, + @DrawableRes icon: Int + ) { + if (type !in allowedSources) return + + textWithDescriptionItem( + titleRes = title, + descriptionRes = subtitle, + iconRes = icon, + ) { + onChosen(type) + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/ImportTypeChooserMixin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/ImportTypeChooserMixin.kt new file mode 100644 index 0000000..f229e88 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/ImportTypeChooserMixin.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.importType + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_account_api.presenatation.account.add.SecretType + +interface ImportTypeChooserMixin { + + class Payload( + val allowedTypes: Set = SecretType.values().toSet(), + val onChosen: (SecretType) -> Unit + ) + + val showChooserEvent: LiveData> + + interface Presentation : ImportTypeChooserMixin { + + fun showChooser(payload: Payload) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/ImportTypeChooserProvider.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/ImportTypeChooserProvider.kt new file mode 100644 index 0000000..69afa89 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/ImportTypeChooserProvider.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.importType + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event + +class ImportTypeChooserProvider : ImportTypeChooserMixin.Presentation { + + override fun showChooser(payload: ImportTypeChooserMixin.Payload) { + showChooserEvent.value = Event(payload) + } + + override val showChooserEvent = MutableLiveData>() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/Ui.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/Ui.kt new file mode 100644 index 0000000..c0cd9be --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/importType/Ui.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.importType + +import io.novafoundation.nova.common.base.BaseFragment + +fun BaseFragment<*, *>.setupImportTypeChooser(mixin: ImportTypeChooserMixin) { + mixin.showChooserEvent.observeEvent { + ImportTypeChooserBottomSheet( + context = requireContext(), + onChosen = it.onChosen, + allowedSources = it.allowedTypes + ).show() + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectAddress/SelectAddressCommunicator.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectAddress/SelectAddressCommunicator.kt new file mode 100644 index 0000000..d567229 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectAddress/SelectAddressCommunicator.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.SelectWalletFilterPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +interface SelectAddressRequester : InterScreenRequester { + + @Parcelize + class Request( + val chainId: ChainId, + val selectedAddress: String?, + val filter: SelectWalletFilterPayload + ) : Parcelable +} + +interface SelectAddressResponder : InterScreenResponder { + + @Parcelize + class Response(val selectedAddress: String) : Parcelable +} + +interface SelectAddressCommunicator : SelectAddressRequester, SelectAddressResponder diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectAddress/SelectAddressMixin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectAddress/SelectAddressMixin.kt new file mode 100644 index 0000000..dea19cc --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectAddress/SelectAddressMixin.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress + +import androidx.core.view.isInvisible +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.view.YourWalletsView +import io.novafoundation.nova.feature_account_api.domain.filter.selectAddress.SelectAccountFilter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface SelectAddressMixin { + + class Payload(val chain: Chain, val filter: SelectAccountFilter) + + interface Factory { + + fun create( + coroutineScope: CoroutineScope, + payloadFlow: Flow, + onAddressSelect: (String) -> Unit + ): SelectAddressMixin + } + + val isSelectAddressAvailableFlow: Flow + + suspend fun openSelectAddress(selectedAddress: String?) +} + +fun BaseFragment<*, *>.setupYourWalletsBtn(view: YourWalletsView, selectAddressMixin: SelectAddressMixin) { + selectAddressMixin.isSelectAddressAvailableFlow.observe { + view.isInvisible = !it + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectSingleWallet/SelectSingleWalletCommunicator.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectSingleWallet/SelectSingleWalletCommunicator.kt new file mode 100644 index 0000000..0e7f975 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectSingleWallet/SelectSingleWalletCommunicator.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.SelectWalletFilterPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +interface SelectSingleWalletRequester : InterScreenRequester { + + @Parcelize + class Request( + val chainId: ChainId, + val selectedMetaId: Long?, + val filter: SelectWalletFilterPayload + ) : Parcelable +} + +interface SelectSingleWalletResponder : InterScreenResponder { + + @Parcelize + class Response(val metaId: Long) : Parcelable +} + +interface SelectSingleWalletCommunicator : SelectSingleWalletRequester, SelectSingleWalletResponder diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectSingleWallet/SelectSingleWalletMixin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectSingleWallet/SelectSingleWalletMixin.kt new file mode 100644 index 0000000..be3a39b --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectSingleWallet/SelectSingleWalletMixin.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.feature_account_api.domain.filter.selectAddress.SelectAccountFilter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface SelectSingleWalletMixin { + + class Payload(val chain: Chain, val filter: SelectAccountFilter) + + interface Factory { + + fun create( + coroutineScope: CoroutineScope, + payloadFlow: Flow, + onWalletSelect: (Long) -> Unit + ): SelectSingleWalletMixin + } + + val isSelectWalletAvailableFlow: Flow + + suspend fun openSelectWallet(selectedWallet: Long?) +} + +fun BaseFragment<*, *>.bindSelectWallet(selectAddressMixin: SelectSingleWalletMixin, isAvailable: (Boolean) -> Unit) { + selectAddressMixin.isSelectWalletAvailableFlow.observe { + isAvailable(it) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/SelectWalletMixin.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/SelectWalletMixin.kt new file mode 100644 index 0000000..17e0bd2 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/SelectWalletMixin.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +interface SelectWalletMixin { + + interface Factory { + + fun create( + coroutineScope: CoroutineScope, + selectionParams: suspend () -> SelectionParams + ): SelectWalletMixin + } + + class SelectionParams( + val selectionAllowed: Boolean, + val initialSelection: InitialSelection + ) { + + companion object { + + fun default(): SelectionParams = SelectionParams( + selectionAllowed = true, + initialSelection = InitialSelection.ActiveWallet + ) + } + } + + sealed interface InitialSelection { + + object ActiveWallet : InitialSelection + + class SpecificWallet(val metaId: Long) : InitialSelection + } + + val selectedMetaAccountFlow: Flow + + val selectedWalletModelFlow: Flow + + fun walletSelectorClicked() +} + +suspend fun SelectWalletMixin.selectedMetaAccount(): MetaAccount { + return selectedMetaAccountFlow.first() +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/SelectWalletRequester.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/SelectWalletRequester.kt new file mode 100644 index 0000000..83727a5 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/SelectWalletRequester.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator.Payload +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator.Response +import kotlinx.parcelize.Parcelize + +interface SelectWalletRequester : InterScreenRequester + +interface SelectWalletResponder : InterScreenResponder + +interface SelectWalletCommunicator : SelectWalletRequester, SelectWalletResponder { + + @Parcelize + class Payload(val currentMetaId: Long) : Parcelable + + @Parcelize + class Response(val newMetaId: Long) : Parcelable +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/SelectedWalletModel.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/SelectedWalletModel.kt new file mode 100644 index 0000000..011ed80 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/SelectedWalletModel.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet + +import android.graphics.drawable.Drawable + +class SelectedWalletModel( + val title: String, + val subtitle: String, + val icon: Drawable, + val selectionAllowed: Boolean +) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/Ui.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/Ui.kt new file mode 100644 index 0000000..b979208 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/mixin/selectWallet/Ui.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.view.AccountView + +fun BaseFragment<*, *>.setupSelectWalletMixin(mixin: SelectWalletMixin, view: AccountView) { + view.setActionTint(R.color.icon_secondary) + view.setShowBackground(true) + + mixin.selectedWalletModelFlow.observe { + view.setTitle(it.title) + view.setSubTitle(it.subtitle) + view.setIcon(it.icon) + + if (it.selectionAllowed) { + view.setActionIcon(R.drawable.ic_chevron_right) + view.isEnabled = true + } else { + view.setActionIcon(null as Int?) + view.isEnabled = false + } + } + + view.setOnClickListener { mixin.walletSelectorClicked() } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/navigation/ExtrinsicNavigationWrapper.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/navigation/ExtrinsicNavigationWrapper.kt new file mode 100644 index 0000000..007b3fa --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/navigation/ExtrinsicNavigationWrapper.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_api.presenatation.navigation + +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy + +interface ExtrinsicNavigationWrapper { + + suspend fun startNavigation( + submissionHierarchy: SubmissionHierarchy, + fallback: suspend () -> Unit + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/sign/LedgerSignCommunicator.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/sign/LedgerSignCommunicator.kt new file mode 100644 index 0000000..10c16bc --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/sign/LedgerSignCommunicator.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_api.presenatation.sign + +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant + +interface LedgerSignCommunicator : SignInterScreenCommunicator { + + fun setUsedVariant(variant: LedgerVariant) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/sign/SignInterScreenCommunicator.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/sign/SignInterScreenCommunicator.kt new file mode 100644 index 0000000..f116e7d --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/sign/SignInterScreenCommunicator.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_api.presenatation.sign + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator.Request +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator.Response +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import kotlinx.parcelize.Parcelize +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import java.util.UUID + +interface SignInterScreenRequester : InterScreenRequester + +interface SignInterScreenResponder : InterScreenResponder + +interface SignInterScreenCommunicator : SignInterScreenRequester, SignInterScreenResponder { + + @Parcelize + class Request(val id: String) : Parcelable + + sealed class Response : Parcelable { + + abstract val requestId: String + + @Parcelize + class Signed(val signature: SignatureWrapperParcel, override val requestId: String) : Response() + + @Parcelize + class Cancelled(override val requestId: String) : Response() + } +} + +suspend fun SignInterScreenRequester.awaitConfirmation(): Response { + val request = createNewRequest() + val responsesForRequest = responseFlow.filter { it.requestId == request.id } + + openRequest(request) + + return responsesForRequest.first() +} + +private fun createNewRequest(): Request { + val id = UUID.randomUUID().toString() + + return Request(id) +} + +fun Request.cancelled() = Response.Cancelled(id) +fun Request.signed(signature: SignatureWrapper) = Response.Signed(SignatureWrapperParcel(signature), id) diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/sign/SignatureWrapperParcel.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/sign/SignatureWrapperParcel.kt new file mode 100644 index 0000000..5d64cf1 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/presenatation/sign/SignatureWrapperParcel.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_api.presenatation.sign + +import android.os.Parcelable +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import kotlinx.parcelize.Parcelize + +sealed class SignatureWrapperParcel : Parcelable { + + @Parcelize + class Ed25519(val signature: ByteArray) : SignatureWrapperParcel() + + @Parcelize + class Sr25519(val signature: ByteArray) : SignatureWrapperParcel() + + @Parcelize + class Ecdsa( + val v: ByteArray, + val r: ByteArray, + val s: ByteArray + ) : SignatureWrapperParcel() +} + +fun SignatureWrapperParcel(signatureWrapper: SignatureWrapper): SignatureWrapperParcel { + return with(signatureWrapper) { + when (this) { + is SignatureWrapper.Ed25519 -> SignatureWrapperParcel.Ed25519(signature) + is SignatureWrapper.Sr25519 -> SignatureWrapperParcel.Sr25519(signature) + is SignatureWrapper.Ecdsa -> SignatureWrapperParcel.Ecdsa(v, r, s) + } + } +} + +fun SignatureWrapper(parcel: SignatureWrapperParcel): SignatureWrapper { + return with(parcel) { + when (this) { + is SignatureWrapperParcel.Ed25519 -> SignatureWrapper.Ed25519(signature) + is SignatureWrapperParcel.Sr25519 -> SignatureWrapper.Sr25519(signature) + is SignatureWrapperParcel.Ecdsa -> SignatureWrapper.Ecdsa(v, r, s) + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/AccountView.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/AccountView.kt new file mode 100644 index 0000000..8064eaa --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/AccountView.kt @@ -0,0 +1,132 @@ +package io.novafoundation.nova.feature_account_api.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.text.TextUtils +import android.util.AttributeSet +import android.view.View +import androidx.annotation.DrawableRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ViewAccountBinding + +private const val SHOW_BACKGROUND_DEFAULT = true + +class AccountView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewAccountBinding.inflate(inflater(), this) + + sealed interface Model { + class Address(val addressModel: AddressModel) : Model + + class NoAddress(val title: String, val subTitle: String) : Model + } + + init { + attrs?.let(::applyAttributes) + } + + fun setModel(model: Model) { + when (model) { + is Model.Address -> setAddressModel(model.addressModel) + is Model.NoAddress -> setNoAddress(model.title, model.subTitle) + } + } + + fun setAddressModel(addressModel: AddressModel) { + if (addressModel.name != null) { + binder.addressTitle.text = addressModel.name + binder.addressSubtitle.text = addressModel.address + + binder.addressSubtitle.makeVisible() + } else { + binder.addressTitle.text = addressModel.address + + binder.addressSubtitle.makeGone() + } + + binder.addressSubtitle.setDrawableStart(null) + binder.addressSubtitle.ellipsize = TextUtils.TruncateAt.MIDDLE + binder.addressPrimaryIcon.setImageDrawable(addressModel.image) + } + + fun setNoAddress(title: String, subTitle: String) { + setTitle(title) + setIcon(ContextCompat.getDrawable(context, R.drawable.ic_identicon_placeholder)) + binder.addressSubtitle.setDrawableStart(R.drawable.ic_warning_filled, widthInDp = 16, paddingInDp = 4) + binder.addressSubtitle.ellipsize = TextUtils.TruncateAt.END + setSubTitle(subTitle) + } + + fun setTitle(title: String) { + binder.addressTitle.text = title + } + + fun setSubTitle(subTitle: String?) { + binder.addressSubtitle.setTextOrHide(subTitle) + binder.addressSubtitle.setDrawableStart(null) + binder.addressSubtitle.ellipsize = TextUtils.TruncateAt.MIDDLE + } + + fun setIcon(icon: Drawable?) { + binder.addressPrimaryIcon.setImageDrawable(icon) + } + + fun setShowBackground(shouldShow: Boolean) { + background = if (shouldShow) { + getRoundedCornerDrawable(R.color.block_background, cornerSizeDp = 12).withRippleMask(getRippleMask(cornerSizeDp = 12)) + } else { + null + } + } + + fun setActionClickListener(listener: OnClickListener?) { + setOnClickListener(listener) + } + + fun setActionIcon(icon: Drawable?) { + binder.addressAction.setImageDrawable(icon) + binder.addressAction.setVisible(icon != null) + } + + fun setActionIcon(@DrawableRes icon: Int?) { + setActionIcon(icon?.let(context::getDrawable)) + } + + fun setActionTint(tintRes: Int?) { + binder.addressAction.setImageTintRes(tintRes) + } + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.AccountView) { typedArray -> + val actionIcon = typedArray.getDrawable(R.styleable.AccountView_actionIcon) + setActionIcon(actionIcon) + + val shouldShowBackground = typedArray.getBoolean(R.styleable.AccountView_showBackground, SHOW_BACKGROUND_DEFAULT) + setShowBackground(shouldShowBackground) + } +} + +fun AccountView.setSelectable(isSelectable: Boolean, onClickListener: View.OnClickListener) { + if (isSelectable) { + setActionIcon(R.drawable.ic_chevron_right) + setActionClickListener(onClickListener) + } else { + setActionIcon(null as Drawable?) + setActionClickListener(null) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/ChainChipView.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/ChainChipView.kt new file mode 100644 index 0000000..374732b --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/ChainChipView.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_account_api.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.removeDrawableEnd +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ViewChainChipBinding +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon + +class ChainChipModel( + val chainUi: ChainUi, + val changeable: Boolean +) + +class ChainChipView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private val binder = ViewChainChipBinding.inflate(inflater(), this) + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + binder.itemAssetGroupLabel.background = context.getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 8) + } + + fun setModel(chainChipModel: ChainChipModel) { + setChain(chainChipModel.chainUi) + setChangeable(chainChipModel.changeable) + } + + fun setChangeable(changeable: Boolean) { + isEnabled = changeable + + if (changeable) { + binder.itemAssetGroupLabel.setTextColorRes(R.color.button_text_accent) + binder.itemAssetGroupLabel.setDrawableEnd(R.drawable.ic_chevron_down, widthInDp = 16, paddingInDp = 4, tint = R.color.icon_accent) + } else { + binder.itemAssetGroupLabel.setTextColorRes(R.color.text_primary) + binder.itemAssetGroupLabel.removeDrawableEnd() + } + } + + fun setChain(chainUi: ChainUi) { + binder.itemAssetGroupLabel.text = chainUi.name + + binder.itemAssetGroupLabelIcon.loadChainIcon(chainUi.icon, imageLoader) + } + + fun unbind() { + binder.itemAssetGroupLabelIcon.clear() + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/ItemChainAccount.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/ItemChainAccount.kt new file mode 100644 index 0000000..3fe67cf --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/ItemChainAccount.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_account_api.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.utils.getDrawableCompat +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ItemChainAccountBinding + +class ItemChainAccount @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binder = ItemChainAccountBinding.inflate(inflater(), this) + + val chainIcon: ImageView + get() = binder.chainAccountChainIcon + + val chainName: TextView + get() = binder.chainAccountChainName + + val accountIcon: ImageView + get() = binder.chainAccountAccountIcon + + val accountAddress: TextView + get() = binder.chainAccountAccountAddress + + val action: ImageView + get() = binder.labeledTextAction + + init { + background = context.getDrawableCompat(R.drawable.bg_primary_list_item) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/SelectedWalletView.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/SelectedWalletView.kt new file mode 100644 index 0000000..f54f956 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/SelectedWalletView.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_account_api.view + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ViewSelectedWalletBinding +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedWalletModel + +class SelectedWalletView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binder = ViewSelectedWalletBinding.inflate(inflater(), this) + + fun setModel(model: SelectedWalletModel) { + binder.viewSelectedWalletAccountIcon.setImageDrawable(model.walletIcon) + + binder.viewSelectedWalletAccountUpdateIndicator.isVisible = model.hasUpdates + + if (model.typeIcon != null) { + background = context.getRoundedCornerDrawable( + fillColorRes = R.color.chips_background, + cornerSizeInDp = 80, + ) + + binder.viewSelectedWalletTypeIcon.setImageResource(model.typeIcon.icon) + val tint = R.color.icon_primary.takeIf { model.typeIcon.canApplyOwnTint } + binder.viewSelectedWalletTypeIcon.setImageTintRes(tint) + + binder.viewSelectedWalletTypeIcon.makeVisible() + } else { + background = null + binder.viewSelectedWalletTypeIcon.makeGone() + } + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/TableCellViewExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/TableCellViewExt.kt new file mode 100644 index 0000000..a7d8954 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/TableCellViewExt.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_account_api.view + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.OptionalAddressModel +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.common.view.showLoadingState +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi + +fun TableCellView.showAddress(addressModel: AddressModel) { + setImage(addressModel.image) + + showValue(addressModel.nameOrAddress) +} + +fun TableCellView.showAddressOrHide(addressModel: AddressModel?) = letOrHide(addressModel, ::showAddress) + +fun TableCellView.showOptionalAddress(addressModel: OptionalAddressModel) { + addressModel.image?.let(::setImage) + + showValue(addressModel.nameOrAddress) +} + +fun TableCellView.showChain(chainUi: ChainUi) { + loadImage( + url = chainUi.icon, + placeholderRes = R.drawable.bg_chain_placeholder, + roundedCornersDp = null + ) + + showValue(chainUi.name) +} + +fun TableCellView.showChainOrHide(chainUi: ChainUi?) { + if (chainUi != null) { + makeVisible() + + showChain(chainUi) + } else { + makeGone() + } +} + +fun TableCellView.showWallet(walletModel: WalletModel) { + showValue(walletModel.name) + + walletModel.icon?.let(::setImage) +} + +fun TableCellView.showAccountWithLoading(loadingState: ExtendedLoadingState) { + showLoadingState(loadingState) { + showAccount(it) + } +} + +fun TableCellView.showAccount(accountModel: AccountModel) { + when (accountModel) { + is AccountModel.Address -> showAddress(accountModel) + is AccountModel.Wallet -> showWallet(accountModel) + } +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/WalletConnectCounterView.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/WalletConnectCounterView.kt new file mode 100644 index 0000000..2b35446 --- /dev/null +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/WalletConnectCounterView.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_account_api.view + +import android.content.Context +import android.graphics.Color +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.core.view.isVisible +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawableFromColors +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.databinding.ViewWalletConnectBinding + +class WalletConnectCounterView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binder = ViewWalletConnectBinding.inflate(inflater(), this) + + init { + binder.viewWalletConnectIconContainer.background = context.addRipple( + drawable = context.getRoundedCornerDrawable( + fillColorRes = R.color.button_wallet_connect_background, + cornerSizeInDp = 80, + ), + mask = context.getRoundedCornerDrawableFromColors(Color.WHITE, strokeColor = null, cornerSizeInDp = 80) + ) + orientation = HORIZONTAL + } + + fun setConnectionCount(count: String?) { + binder.viewWalletConnectConnectedCount.text = count + binder.viewWalletConnectConnectedCount.isVisible = count != null + binder.viewWalletConnectConnectionsIcon.isVisible = count != null + + background = if (count != null) { + context.getRoundedCornerDrawable( + fillColorRes = R.color.wallet_connections_background, + cornerSizeInDp = 80, + ) + } else { + null + } + } +} diff --git a/feature-account-api/src/main/res/layout/bottom_sheet_address_actions.xml b/feature-account-api/src/main/res/layout/bottom_sheet_address_actions.xml new file mode 100644 index 0000000..d8f55f3 --- /dev/null +++ b/feature-account-api/src/main/res/layout/bottom_sheet_address_actions.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/bottom_sheet_external_actions.xml b/feature-account-api/src/main/res/layout/bottom_sheet_external_actions.xml new file mode 100644 index 0000000..9fc0dc1 --- /dev/null +++ b/feature-account-api/src/main/res/layout/bottom_sheet_external_actions.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/bottom_sheet_fee_selection.xml b/feature-account-api/src/main/res/layout/bottom_sheet_fee_selection.xml new file mode 100644 index 0000000..4b51fee --- /dev/null +++ b/feature-account-api/src/main/res/layout/bottom_sheet_fee_selection.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/fragment_chain_account_preview.xml b/feature-account-api/src/main/res/layout/fragment_chain_account_preview.xml new file mode 100644 index 0000000..ad54255 --- /dev/null +++ b/feature-account-api/src/main/res/layout/fragment_chain_account_preview.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/fragment_create_wallet_name.xml b/feature-account-api/src/main/res/layout/fragment_create_wallet_name.xml new file mode 100644 index 0000000..721f942 --- /dev/null +++ b/feature-account-api/src/main/res/layout/fragment_create_wallet_name.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/item_account.xml b/feature-account-api/src/main/res/layout/item_account.xml new file mode 100644 index 0000000..93f2bd9 --- /dev/null +++ b/feature-account-api/src/main/res/layout/item_account.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/item_account_chooser.xml b/feature-account-api/src/main/res/layout/item_account_chooser.xml new file mode 100644 index 0000000..0b9e680 --- /dev/null +++ b/feature-account-api/src/main/res/layout/item_account_chooser.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/item_bottom_sheet_chain_list.xml b/feature-account-api/src/main/res/layout/item_bottom_sheet_chain_list.xml new file mode 100644 index 0000000..6b1bfea --- /dev/null +++ b/feature-account-api/src/main/res/layout/item_bottom_sheet_chain_list.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/item_chain_account.xml b/feature-account-api/src/main/res/layout/item_chain_account.xml new file mode 100644 index 0000000..992b216 --- /dev/null +++ b/feature-account-api/src/main/res/layout/item_chain_account.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/item_chain_account_group.xml b/feature-account-api/src/main/res/layout/item_chain_account_group.xml new file mode 100644 index 0000000..e7c5f02 --- /dev/null +++ b/feature-account-api/src/main/res/layout/item_chain_account_group.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/feature-account-api/src/main/res/layout/item_delegated_account_group.xml b/feature-account-api/src/main/res/layout/item_delegated_account_group.xml new file mode 100644 index 0000000..64b9d39 --- /dev/null +++ b/feature-account-api/src/main/res/layout/item_delegated_account_group.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/item_external_account_identifier.xml b/feature-account-api/src/main/res/layout/item_external_account_identifier.xml new file mode 100644 index 0000000..99a569b --- /dev/null +++ b/feature-account-api/src/main/res/layout/item_external_account_identifier.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/view_account.xml b/feature-account-api/src/main/res/layout/view_account.xml new file mode 100644 index 0000000..47a100a --- /dev/null +++ b/feature-account-api/src/main/res/layout/view_account.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/view_address_input.xml b/feature-account-api/src/main/res/layout/view_address_input.xml new file mode 100644 index 0000000..92f3527 --- /dev/null +++ b/feature-account-api/src/main/res/layout/view_address_input.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/view_chain_chip.xml b/feature-account-api/src/main/res/layout/view_chain_chip.xml new file mode 100644 index 0000000..6093fb3 --- /dev/null +++ b/feature-account-api/src/main/res/layout/view_chain_chip.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/view_identity.xml b/feature-account-api/src/main/res/layout/view_identity.xml new file mode 100644 index 0000000..e0d058c --- /dev/null +++ b/feature-account-api/src/main/res/layout/view_identity.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/view_selected_wallet.xml b/feature-account-api/src/main/res/layout/view_selected_wallet.xml new file mode 100644 index 0000000..55766ee --- /dev/null +++ b/feature-account-api/src/main/res/layout/view_selected_wallet.xml @@ -0,0 +1,37 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/layout/view_wallet_connect.xml b/feature-account-api/src/main/res/layout/view_wallet_connect.xml new file mode 100644 index 0000000..0bbcfb4 --- /dev/null +++ b/feature-account-api/src/main/res/layout/view_wallet_connect.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-api/src/main/res/values/attrs.xml b/feature-account-api/src/main/res/values/attrs.xml new file mode 100644 index 0000000..ddafebb --- /dev/null +++ b/feature-account-api/src/main/res/values/attrs.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/.gitignore b/feature-account-impl/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/feature-account-impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature-account-impl/build.gradle b/feature-account-impl/build.gradle new file mode 100644 index 0000000..1283cf5 --- /dev/null +++ b/feature-account-impl/build.gradle @@ -0,0 +1,76 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' + +android { + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.feature_account_impl' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', "*.aar"]) + implementation project(':core-db') + implementation project(':common') + implementation project(':runtime') + implementation project(':feature-account-api') + implementation project(':feature-currency-api') + implementation project(':feature-ledger-api') + implementation project(':feature-ledger-core') + implementation project(':feature-versions-api') + implementation project(':feature-proxy-api') + implementation project(':feature-cloud-backup-api') + implementation project(":feature-swap-core:api") + implementation project(":feature-xcm:api") + implementation project(':web3names') + implementation project(':feature-deep-linking') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation cardViewDep + implementation constraintDep + + implementation zXingCoreDep + implementation zXingEmbeddedDep + + implementation bouncyCastleDep + + implementation substrateSdkDep + + implementation biometricDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation daggerDep + ksp daggerCompiler + + implementation roomDep + ksp roomCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation gsonDep + + implementation insetterDep + implementation flexBoxDep + + testImplementation project(":test-shared") + testImplementation project(":feature-cloud-backup-test") +} \ No newline at end of file diff --git a/feature-account-impl/src/main/AndroidManifest.xml b/feature-account-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/feature-account-impl/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/RealBiometricService.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/RealBiometricService.kt new file mode 100644 index 0000000..9ccb49d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/RealBiometricService.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.feature_account_impl + +import androidx.biometric.BiometricConstants +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import io.novafoundation.nova.common.sequrity.biometry.BiometricResponse +import io.novafoundation.nova.common.sequrity.biometry.BiometricService +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.common.sequrity.biometry.BiometricPromptFactory +import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory +import kotlinx.coroutines.flow.Flow + +class RealBiometricServiceFactory( + private val accountRepository: AccountRepository +) : BiometricServiceFactory { + override fun create( + biometricManager: BiometricManager, + biometricPromptFactory: BiometricPromptFactory, + promptInfo: BiometricPrompt.PromptInfo + ): BiometricService { + return RealBiometricService( + accountRepository, + biometricManager, + biometricPromptFactory, + promptInfo + ) + } +} + +class RealBiometricService( + private val accountRepository: AccountRepository, + private val biometricManager: BiometricManager, + private val biometricPromptFactory: BiometricPromptFactory, + private val promptInfo: BiometricPrompt.PromptInfo +) : BiometricPrompt.AuthenticationCallback(), BiometricService { + + override val biometryServiceResponseFlow = singleReplaySharedFlow() + + private val biometricPrompt = biometricPromptFactory.create(this) + + override fun isEnabled(): Boolean { + return accountRepository.isBiometricEnabled() + } + + override fun isEnabledFlow(): Flow = accountRepository.isBiometricEnabledFlow() + + override suspend fun toggle() { + enableBiometry(!isEnabled()) + } + + override fun cancel() { + biometricPrompt.cancelAuthentication() + } + + override fun enableBiometry(enable: Boolean) { + if (!isBiometricReady()) { + biometryServiceResponseFlow.tryEmit(BiometricResponse.NotReady) + return + } + + if (enable) { + accountRepository.setBiometricOn() + } else { + accountRepository.setBiometricOff() + } + } + + override fun refreshBiometryState() { + if (!isBiometricReady() && isEnabled()) { + accountRepository.setBiometricOff() + } + } + + override fun requestBiometric() { + if (!isBiometricReady()) { + biometryServiceResponseFlow.tryEmit(BiometricResponse.NotReady) + return + } + + biometricPrompt.authenticate(promptInfo) + } + + override fun isBiometricReady(): Boolean { + return biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + val cancelledByUser = errorCode == BiometricConstants.ERROR_CANCELED || + errorCode == BiometricConstants.ERROR_NEGATIVE_BUTTON || + errorCode == BiometricConstants.ERROR_USER_CANCELED + + biometryServiceResponseFlow.tryEmit(BiometricResponse.Error(cancelledByUser, errString.toString())) + } + + override fun onAuthenticationFailed() { + biometryServiceResponseFlow.tryEmit(BiometricResponse.Fail) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + biometryServiceResponseFlow.tryEmit(BiometricResponse.Success) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/CloudBackupAccountsModificationsTracker.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/CloudBackupAccountsModificationsTracker.kt new file mode 100644 index 0000000..b84abca --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/CloudBackupAccountsModificationsTracker.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_account_impl.data.cloudBackup + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount + +interface CloudBackupAccountsModificationsTracker { + + fun recordAccountModified(modifiedAccountTypes: List) + + fun recordAccountModified(modifiedAccountType: LightMetaAccount.Type) + + fun recordAccountsModified() + + fun getAccountsLastModifiedAt(): Long +} + +class RealCloudBackupAccountsModificationsTracker( + private val preferences: Preferences +) : CloudBackupAccountsModificationsTracker { + + init { + ensureInitialized() + } + + companion object { + private const val MODIFIED_AT_KEY = "AccountsModificationsTracker.Key" + } + + override fun recordAccountModified(modifiedAccountType: LightMetaAccount.Type) { + if (modifiedAccountType.isBackupable()) { + recordAccountsModified() + } + } + + override fun recordAccountModified(modifiedAccountTypes: List) { + if (modifiedAccountTypes.any { it.isBackupable() }) { + recordAccountsModified() + } + } + + override fun recordAccountsModified() { + preferences.putLong(MODIFIED_AT_KEY, System.currentTimeMillis()) + } + + override fun getAccountsLastModifiedAt(): Long { + return preferences.getLong(MODIFIED_AT_KEY, System.currentTimeMillis()) + } + + private fun ensureInitialized() { + if (!preferences.contains(MODIFIED_AT_KEY)) { + recordAccountsModified() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/Extensions.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/Extensions.kt new file mode 100644 index 0000000..1cff4f2 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/Extensions.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_impl.data.cloudBackup + +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount + +fun LightMetaAccount.Type.isBackupable(): Boolean { + return when (this) { + LightMetaAccount.Type.SECRETS, + LightMetaAccount.Type.WATCH_ONLY, + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.LEDGER, + LightMetaAccount.Type.LEDGER_LEGACY, + LightMetaAccount.Type.POLKADOT_VAULT -> true + + LightMetaAccount.Type.PROXIED, + LightMetaAccount.Type.MULTISIG -> false + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt new file mode 100644 index 0000000..3717e61 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt @@ -0,0 +1,621 @@ +package io.novafoundation.nova.feature_account_impl.data.cloudBackup + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.derivationPath +import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair +import io.novafoundation.nova.common.data.secrets.v2.keypair +import io.novafoundation.nova.common.data.secrets.v2.nonce +import io.novafoundation.nova.common.data.secrets.v2.privateKey +import io.novafoundation.nova.common.data.secrets.v2.publicKey +import io.novafoundation.nova.common.data.secrets.v2.seed +import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.findById +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.JoinedMetaAccountInfo +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.RelationJoinedMetaAccountInfo +import io.novafoundation.nova.feature_account_api.data.cloudBackup.CLOUD_BACKUP_APPLY_SOURCE +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.events.buildChangesEvent +import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.isEmpty +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.isNotDestructive +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.isCompletelyEmpty +import io.novafoundation.nova.feature_ledger_api.data.repository.LedgerDerivationPath +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainsById +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import io.novasama.substrate_sdk_android.encrypt.keypair.BaseKeypair +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.Sr25519Keypair +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +class RealLocalAccountsCloudBackupFacade( + private val secretsStoreV2: SecretStoreV2, + private val accountDao: MetaAccountDao, + private val cloudBackupAccountsModificationsTracker: CloudBackupAccountsModificationsTracker, + private val metaAccountChangedEvents: MetaAccountChangesEventBus, + private val chainRegistry: ChainRegistry, + private val accountMappers: AccountMappers, +) : LocalAccountsCloudBackupFacade { + + override suspend fun fullBackupInfoFromLocalSnapshot(): CloudBackup { + val allBackupableAccounts = getAllBackupableAccounts() + + return CloudBackup( + publicData = allBackupableAccounts.toBackupPublicData(), + privateData = preparePrivateBackupData(allBackupableAccounts) + ) + } + + override suspend fun publicBackupInfoFromLocalSnapshot(): CloudBackup.PublicData { + val allBackupableAccounts = getAllBackupableAccounts() + + return allBackupableAccounts.toBackupPublicData() + } + + override suspend fun constructCloudBackupForFirstWallet( + metaAccount: MetaAccountLocal, + baseSecrets: EncodableStruct, + ): CloudBackup { + val wrappedMetaAccount = listOf( + RelationJoinedMetaAccountInfo( + metaAccount = metaAccount, + chainAccounts = emptyList(), + proxyAccountLocal = null + ) + ) + + val walletPrivateInfo = CloudBackup.WalletPrivateInfo( + walletId = metaAccount.globallyUniqueId, + entropy = baseSecrets.entropy, + substrate = baseSecrets.getSubstrateBackupSecrets(), + ethereum = baseSecrets.getEthereumBackupSecrets(), + chainAccounts = emptyList(), + ) + + return CloudBackup( + publicData = wrappedMetaAccount.toBackupPublicData(modifiedAt = System.currentTimeMillis()), + privateData = CloudBackup.PrivateData( + wallets = listOf(walletPrivateInfo) + ) + ) + } + + override suspend fun canPerformNonDestructiveApply(diff: CloudBackupDiff): Boolean { + return diff.localChanges.isNotDestructive() + } + + override suspend fun applyBackupDiff(diff: CloudBackupDiff, cloudVersion: CloudBackup) { + val localChangesToApply = diff.localChanges + if (localChangesToApply.isEmpty()) return + + val metaAccountsByUuid = getAllBackupableAccounts().associateBy { it.metaAccount.globallyUniqueId } + + val changesEvent = buildChangesEvent { + accountDao.runInTransaction { + addAll(applyLocalRemoval(localChangesToApply.removed, metaAccountsByUuid)) + addAll(applyLocalAddition(localChangesToApply.added, cloudVersion)) + addAll( + applyLocalModification( + localChangesToApply.modified, + cloudVersion, + metaAccountsByUuid + ) + ) + } + } + + changesEvent?.let { metaAccountChangedEvents.notify(it, source = CLOUD_BACKUP_APPLY_SOURCE) } + } + + private suspend fun applyLocalRemoval( + toRemove: List, + metaAccountsByUUid: Map + ): List { + if (toRemove.isEmpty()) return emptyList() + + val localIds = toRemove.mapNotNull { metaAccountsByUUid[it.walletId]?.metaAccount?.id } + val allAffectedMetaAccounts = accountDao.delete(localIds) + + // Clear meta account secrets + toRemove.forEach { + val localWallet = metaAccountsByUUid[it.walletId] ?: return@forEach + val chainAccountIds = localWallet.chainAccounts.map(ChainAccountLocal::accountId) + + secretsStoreV2.clearMetaAccountSecrets(localWallet.metaAccount.id, chainAccountIds) + } + + // Return changes + return allAffectedMetaAccounts.map { + MetaAccountChangesEventBus.Event.AccountRemoved( + metaId = it.id, + metaAccountType = accountMappers.mapMetaAccountTypeFromLocal(it.type) + ) + } + } + + private suspend fun applyLocalAddition( + toAdd: List, + cloudBackup: CloudBackup + ): List { + return toAdd.map { publicWalletInfo -> + val metaAccountLocal = publicWalletInfo.toMetaAccountLocal( + accountPosition = accountDao.nextAccountPosition(), + localIdOverwrite = null, + isSelected = false + ) + val metaId = accountDao.insertMetaAccount(metaAccountLocal) + + val chainAccountsLocal = publicWalletInfo.getChainAccountsLocal(metaId) + if (chainAccountsLocal.isNotEmpty()) { + accountDao.insertChainAccounts(chainAccountsLocal) + } + + val metaAccountSecrets = cloudBackup.getMetaAccountSecrets(publicWalletInfo.walletId) + metaAccountSecrets?.let { + secretsStoreV2.putMetaAccountSecrets(metaId, metaAccountSecrets) + } + + val chainAccountsSecrets = cloudBackup.getAllChainAccountSecrets(publicWalletInfo) + chainAccountsSecrets.forEach { (accountId, secrets) -> + secretsStoreV2.putChainAccountSecrets(metaId, accountId.value, secrets) + } + + val additional = cloudBackup.getAllAdditionalSecrets(publicWalletInfo) + additional.forEach { (secretName, secretValue) -> + secretsStoreV2.putAdditionalMetaAccountSecret(metaId, secretName, secretValue) + } + + MetaAccountChangesEventBus.Event.AccountAdded( + metaId = metaId, + metaAccountType = accountMappers.mapMetaAccountTypeFromLocal(metaAccountLocal.type) + ) + } + } + + /** + * Modification of each meta account is done in the following steps: + * + * Insert updated MetaAccountLocal + * + * Delete all previous ChainAccountLocal associated with currently processed meta account + * Insert all ChainAccountLocal from backup + * + * Update meta account secrets + * Delete all chain account secrets associated with currently processed meta account + * Insert all chain account secrets from backup + */ + private suspend fun applyLocalModification( + toModify: List, + cloudVersion: CloudBackup, + localMetaAccountsByUUid: Map + ): List { + // There seems to be some bug in Kotlin compiler which prevents us to use `return flatMap` here: + // Some internal assertion in compiler fails with error "cannot cal suspend function without continuation" + // The closest issue I have found: https://youtrack.jetbrains.com/issue/KT-48319/JVM-IR-AssertionError-FUN-caused-by-suspend-lambda-inside-anonymous-function + val result = mutableListOf() + + toModify.onEach { publicWalletInfo -> + val oldMetaAccountJoinInfo = localMetaAccountsByUUid[publicWalletInfo.walletId] ?: return@onEach + val oldMetaAccountLocal = oldMetaAccountJoinInfo.metaAccount + val metaId = oldMetaAccountLocal.id + + // Insert updated MetaAccountLocal + val updatedMetaAccountLocal = publicWalletInfo.toMetaAccountLocal( + accountPosition = oldMetaAccountLocal.position, + localIdOverwrite = metaId, + isSelected = oldMetaAccountLocal.isSelected + ) + accountDao.updateMetaAccount(updatedMetaAccountLocal) + + // Delete all previous ChainAccountLocal associated with currently processed meta account + if (oldMetaAccountJoinInfo.chainAccounts.isNotEmpty()) { + accountDao.deleteChainAccounts(oldMetaAccountJoinInfo.chainAccounts) + } + + // Insert all ChainAccountLocal from backup + val updatedChainAccountsLocal = publicWalletInfo.getChainAccountsLocal(metaId) + if (updatedChainAccountsLocal.isNotEmpty()) { + accountDao.insertChainAccounts(updatedChainAccountsLocal) + } + + // Update meta account secrets + val metaAccountSecrets = cloudVersion.getMetaAccountSecrets(publicWalletInfo.walletId) + metaAccountSecrets?.let { + secretsStoreV2.putMetaAccountSecrets(metaId, metaAccountSecrets) + } + + // Delete all chain account secrets associated with currently processed meta account + val previousChainAccountIds = oldMetaAccountJoinInfo.chainAccounts.map { it.accountId } + secretsStoreV2.clearChainAccountsSecrets(metaId, previousChainAccountIds) + + // Insert all chain account secrets from backup + val chainAccountsSecrets = cloudVersion.getAllChainAccountSecrets(publicWalletInfo) + chainAccountsSecrets.forEach { (accountId, secrets) -> + secretsStoreV2.putChainAccountSecrets(metaId, accountId.value, secrets) + } + + val additional = cloudVersion.getAllAdditionalSecrets(publicWalletInfo) + additional.forEach { (secretName, secretValue) -> + secretsStoreV2.putAdditionalMetaAccountSecret(metaId, secretName, secretValue) + } + + val metaAccountType = accountMappers.mapMetaAccountTypeFromLocal(oldMetaAccountLocal.type) + + result.add( + MetaAccountChangesEventBus.Event.AccountStructureChanged( + metaId = metaId, + metaAccountType = metaAccountType + ) + ) + result.add( + MetaAccountChangesEventBus.Event.AccountNameChanged( + metaId = metaId, + metaAccountType = metaAccountType + ) + ) + } + + return result + } + + private fun CloudBackup.getMetaAccountSecrets(uuid: String): EncodableStruct? { + return privateData.wallets.findById(uuid)?.getLocalMetaAccountSecrets() + } + + private fun CloudBackup.getAllChainAccountSecrets(walletPublicInfo: CloudBackup.WalletPublicInfo): Map> { + val privateInfo = privateData.wallets.findById(walletPublicInfo.walletId) ?: return emptyMap() + + val chainAccountsSecretsByAccountId = privateInfo.chainAccounts.associateBy { it.accountId.intoKey() } + + return walletPublicInfo.chainAccounts.associateBy( + keySelector = { it.accountId.intoKey() }, + valueTransform = { chainAccountPublicInfo -> + val chainAccountSecrets = chainAccountsSecretsByAccountId[chainAccountPublicInfo.accountId.intoKey()] + + chainAccountSecrets?.toLocalSecrets() + } + ).filterNotNull() + } + + private fun CloudBackup.getAllAdditionalSecrets(walletPublicInfo: CloudBackup.WalletPublicInfo): Map { + val privateInfo = privateData.wallets.findById(walletPublicInfo.walletId) ?: return emptyMap() + val chainAccountsSecretsByAccountId = privateInfo.chainAccounts.associateBy { it.accountId.intoKey() } + + fun getAllLegacyLedgerAdditionalSecrets(): Map { + return walletPublicInfo.chainAccounts.mapNotNull { publicInfo -> + val derivationPath = chainAccountsSecretsByAccountId[publicInfo.accountId.intoKey()]?.derivationPath ?: return@mapNotNull null + val secretName = LedgerDerivationPath.legacyDerivationPathSecretKey(publicInfo.chainId) + + secretName to derivationPath + }.toMap() + } + + fun getAllGenericLedgerAdditionalSecrets(): Map { + val genericDerivationPath = privateInfo.substrate!!.derivationPath!! + val secretName = LedgerDerivationPath.genericDerivationPathSecretKey() + + return mapOf(secretName to genericDerivationPath) + } + + return when (walletPublicInfo.type) { + CloudBackup.WalletPublicInfo.Type.LEDGER -> getAllLegacyLedgerAdditionalSecrets() + CloudBackup.WalletPublicInfo.Type.LEDGER_GENERIC -> getAllGenericLedgerAdditionalSecrets() + + CloudBackup.WalletPublicInfo.Type.SECRETS, + CloudBackup.WalletPublicInfo.Type.WATCH_ONLY, + CloudBackup.WalletPublicInfo.Type.PARITY_SIGNER, + CloudBackup.WalletPublicInfo.Type.POLKADOT_VAULT -> emptyMap() + } + } + + private suspend fun getAllBackupableAccounts(): List { + return accountDao.getMetaAccountsByStatus(MetaAccountLocal.Status.ACTIVE) + .filter { accountMappers.mapMetaAccountTypeFromLocal(it.metaAccount.type).isBackupable() } + } + + private suspend fun preparePrivateBackupData(metaAccounts: List): CloudBackup.PrivateData { + return CloudBackup.PrivateData( + wallets = metaAccounts + .map { prepareWalletPrivateInfo(it) } + .filterNot { it.isCompletelyEmpty() } + ) + } + + private suspend fun prepareWalletPrivateInfo(joinedMetaAccountInfo: JoinedMetaAccountInfo): CloudBackup.WalletPrivateInfo { + val metaId = joinedMetaAccountInfo.metaAccount.id + val baseSecrets = secretsStoreV2.getMetaAccountSecrets(metaId) + + val chainAccountsFromChainSecrets = joinedMetaAccountInfo.chainAccounts + .mapToSet { it.accountId.intoKey() } // multiple chain accounts might refer to the same account id - remove duplicates + .mapNotNull { prepareChainAccountPrivateInfo(metaId, it.value) } + + val chainAccountFromAdditionalSecrets = prepareChainAccountsFromAdditionalSecrets(joinedMetaAccountInfo) + + return CloudBackup.WalletPrivateInfo( + walletId = joinedMetaAccountInfo.metaAccount.globallyUniqueId, + entropy = baseSecrets?.entropy, + substrate = prepareSubstrateBackupSecrets(baseSecrets, joinedMetaAccountInfo), + ethereum = baseSecrets.getEthereumBackupSecrets(), + chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets, + ) + } + + private suspend fun prepareSubstrateBackupSecrets( + baseSecrets: EncodableStruct?, + metaAccountLocal: JoinedMetaAccountInfo + ): CloudBackup.WalletPrivateInfo.SubstrateSecrets? { + return when (metaAccountLocal.metaAccount.type) { + MetaAccountLocal.Type.LEDGER_GENERIC -> prepareGenericLedgerSubstrateBackupSecrets(metaAccountLocal) + + MetaAccountLocal.Type.LEDGER, + MetaAccountLocal.Type.SECRETS, + MetaAccountLocal.Type.WATCH_ONLY, + MetaAccountLocal.Type.PARITY_SIGNER, + MetaAccountLocal.Type.POLKADOT_VAULT, + MetaAccountLocal.Type.PROXIED, + MetaAccountLocal.Type.MULTISIG -> baseSecrets.getSubstrateBackupSecrets() + } + } + + private suspend fun prepareGenericLedgerSubstrateBackupSecrets(metaAccountLocal: JoinedMetaAccountInfo): CloudBackup.WalletPrivateInfo.SubstrateSecrets { + val ledgerDerivationPathKey = LedgerDerivationPath.genericDerivationPathSecretKey() + val ledgerDerivationPath = secretsStoreV2.getAdditionalMetaAccountSecret(metaAccountLocal.metaAccount.id, ledgerDerivationPathKey) + + return CloudBackup.WalletPrivateInfo.SubstrateSecrets( + seed = null, + keypair = null, + derivationPath = ledgerDerivationPath + ) + } + + private suspend fun prepareChainAccountsFromAdditionalSecrets( + metaAccountLocal: JoinedMetaAccountInfo + ): List { + return when (metaAccountLocal.metaAccount.type) { + MetaAccountLocal.Type.LEDGER -> prepareLegacyLedgerChainAccountSecrets(metaAccountLocal) + + MetaAccountLocal.Type.LEDGER_GENERIC, + MetaAccountLocal.Type.SECRETS, + MetaAccountLocal.Type.WATCH_ONLY, + MetaAccountLocal.Type.PARITY_SIGNER, + MetaAccountLocal.Type.POLKADOT_VAULT, + MetaAccountLocal.Type.PROXIED, + MetaAccountLocal.Type.MULTISIG -> emptyList() + } + } + + private suspend fun prepareLegacyLedgerChainAccountSecrets( + ledgerAccountLocal: JoinedMetaAccountInfo + ): List { + return ledgerAccountLocal.chainAccounts.map { chainAccountLocal -> + val ledgerDerivationPathKey = LedgerDerivationPath.legacyDerivationPathSecretKey(chainAccountLocal.chainId) + val ledgerDerivationPath = secretsStoreV2.getAdditionalMetaAccountSecret(ledgerAccountLocal.metaAccount.id, ledgerDerivationPathKey) + + CloudBackup.WalletPrivateInfo.ChainAccountSecrets( + accountId = chainAccountLocal.accountId, + entropy = null, + seed = null, + keypair = null, + derivationPath = ledgerDerivationPath + ) + } + } + + private suspend fun prepareChainAccountPrivateInfo(metaAccount: Long, chainAccountId: AccountId): CloudBackup.WalletPrivateInfo.ChainAccountSecrets? { + val secrets = secretsStoreV2.getChainAccountSecrets(metaAccount, chainAccountId) ?: return null + + return secrets.toBackupSecrets(chainAccountId) + } + + private fun EncodableStruct.toBackupSecrets(chainAccountId: ByteArray): CloudBackup.WalletPrivateInfo.ChainAccountSecrets { + return CloudBackup.WalletPrivateInfo.ChainAccountSecrets( + accountId = chainAccountId, + entropy = entropy, + seed = seed, + keypair = keypair.toBackupKeypairSecrets(), + derivationPath = derivationPath + ) + } + + private fun CloudBackup.WalletPrivateInfo.ChainAccountSecrets.toLocalSecrets(): EncodableStruct? { + return ChainAccountSecrets( + entropy = entropy, + seed = seed, + derivationPath = derivationPath, + keyPair = keypair?.toLocalKeyPair() ?: return null + ) + } + + private fun CloudBackup.WalletPrivateInfo.KeyPairSecrets.toLocalKeyPair(): Keypair { + val nonce = nonce + + return if (nonce != null) { + Sr25519Keypair(privateKey = privateKey, publicKey = publicKey, nonce = nonce) + } else { + BaseKeypair(privateKey = privateKey, publicKey = publicKey) + } + } + + private fun CloudBackup.WalletPrivateInfo.getLocalMetaAccountSecrets(): EncodableStruct? { + return MetaAccountSecrets( + entropy = entropy, + substrateSeed = substrate?.seed, + // Keypair is optional in backup since Ledger backup has base substrate derivation path but doesn't have keypair + // MetaAccountSecrets, however, require substrateKeyPair to be non-null, so we return null here in case of null keypair + // Which is a expected behavior in case of Ledger secrets + substrateKeyPair = substrate?.keypair?.toLocalKeyPair() ?: return null, + substrateDerivationPath = substrate?.derivationPath, + ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(), + ethereumDerivationPath = ethereum?.derivationPath + ) + } + + private fun EncodableStruct?.getEthereumBackupSecrets(): CloudBackup.WalletPrivateInfo.EthereumSecrets? { + if (this == null) return null + + return CloudBackup.WalletPrivateInfo.EthereumSecrets( + keypair = ethereumKeypair?.toBackupKeypairSecrets() ?: return null, + derivationPath = ethereumDerivationPath + ) + } + + private fun EncodableStruct?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? { + if (this == null) return null + + return CloudBackup.WalletPrivateInfo.SubstrateSecrets( + seed = seed, + keypair = substrateKeypair.toBackupKeypairSecrets(), + derivationPath = substrateDerivationPath + ) + } + + private fun EncodableStruct.toBackupKeypairSecrets(): CloudBackup.WalletPrivateInfo.KeyPairSecrets { + return CloudBackup.WalletPrivateInfo.KeyPairSecrets( + publicKey = publicKey, + privateKey = privateKey, + nonce = nonce + ) + } + + private suspend fun List.toBackupPublicData( + modifiedAt: Long = cloudBackupAccountsModificationsTracker.getAccountsLastModifiedAt() + ): CloudBackup.PublicData { + val chainsById = chainRegistry.chainsById() + + return CloudBackup.PublicData( + modifiedAt = modifiedAt, + wallets = mapNotNull { it.toBackupPublicInfo(chainsById) }, + ) + } + + private fun JoinedMetaAccountInfo.toBackupPublicInfo( + chainsById: ChainsById, + ): CloudBackup.WalletPublicInfo? { + return CloudBackup.WalletPublicInfo( + walletId = metaAccount.globallyUniqueId, + substratePublicKey = metaAccount.substratePublicKey, + substrateAccountId = metaAccount.substrateAccountId, + substrateCryptoType = metaAccount.substrateCryptoType, + ethereumAddress = metaAccount.ethereumAddress, + ethereumPublicKey = metaAccount.ethereumPublicKey, + name = metaAccount.name, + type = metaAccount.type.toBackupWalletType() ?: return null, + chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) } + ) + } + + private fun CloudBackup.WalletPublicInfo.toMetaAccountLocal( + accountPosition: Int, + localIdOverwrite: Long?, + isSelected: Boolean + ): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = substratePublicKey, + substrateAccountId = substrateAccountId, + substrateCryptoType = substrateCryptoType, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + name = name, + type = type.toLocalWalletType(), + globallyUniqueId = walletId, + parentMetaId = null, + isSelected = isSelected, + position = accountPosition, + status = MetaAccountLocal.Status.ACTIVE, + typeExtras = null + ).also { + if (localIdOverwrite != null) { + it.id = localIdOverwrite + } + } + } + + private fun CloudBackup.WalletPublicInfo.getChainAccountsLocal(metaId: Long): List { + return chainAccounts.map { + ChainAccountLocal( + metaId = metaId, + chainId = it.chainId, + publicKey = it.publicKey, + accountId = it.accountId, + cryptoType = it.cryptoType?.toCryptoType() + ) + } + } + + private fun ChainAccountLocal.toBackupPublicChainAccount(chainsById: ChainsById): CloudBackup.WalletPublicInfo.ChainAccountInfo { + return CloudBackup.WalletPublicInfo.ChainAccountInfo( + chainId = chainId, + publicKey = publicKey, + accountId = accountId, + cryptoType = cryptoType?.toBackupChainAccountCryptoType(chainsById, chainId) + ) + } + + private fun MetaAccountLocal.Type.toBackupWalletType(): CloudBackup.WalletPublicInfo.Type? { + return when (this) { + MetaAccountLocal.Type.SECRETS -> CloudBackup.WalletPublicInfo.Type.SECRETS + MetaAccountLocal.Type.WATCH_ONLY -> CloudBackup.WalletPublicInfo.Type.WATCH_ONLY + MetaAccountLocal.Type.PARITY_SIGNER -> CloudBackup.WalletPublicInfo.Type.PARITY_SIGNER + MetaAccountLocal.Type.LEDGER -> CloudBackup.WalletPublicInfo.Type.LEDGER + MetaAccountLocal.Type.LEDGER_GENERIC -> CloudBackup.WalletPublicInfo.Type.LEDGER_GENERIC + MetaAccountLocal.Type.POLKADOT_VAULT -> CloudBackup.WalletPublicInfo.Type.POLKADOT_VAULT + + MetaAccountLocal.Type.PROXIED, MetaAccountLocal.Type.MULTISIG -> null + } + } + + private fun CloudBackup.WalletPublicInfo.Type.toLocalWalletType(): MetaAccountLocal.Type { + return when (this) { + CloudBackup.WalletPublicInfo.Type.SECRETS -> MetaAccountLocal.Type.SECRETS + CloudBackup.WalletPublicInfo.Type.WATCH_ONLY -> MetaAccountLocal.Type.WATCH_ONLY + CloudBackup.WalletPublicInfo.Type.PARITY_SIGNER -> MetaAccountLocal.Type.PARITY_SIGNER + CloudBackup.WalletPublicInfo.Type.LEDGER -> MetaAccountLocal.Type.LEDGER + CloudBackup.WalletPublicInfo.Type.LEDGER_GENERIC -> MetaAccountLocal.Type.LEDGER_GENERIC + CloudBackup.WalletPublicInfo.Type.POLKADOT_VAULT -> MetaAccountLocal.Type.POLKADOT_VAULT + } + } + + private fun ChainAccountCryptoType.toCryptoType(): CryptoType { + return when (this) { + ChainAccountCryptoType.SR25519 -> CryptoType.SR25519 + ChainAccountCryptoType.ED25519 -> CryptoType.ED25519 + ChainAccountCryptoType.ECDSA, ChainAccountCryptoType.ETHEREUM -> CryptoType.ECDSA + } + } + + private fun CryptoType.toBackupChainAccountCryptoType(chainsById: ChainsById, chainId: ChainId): ChainAccountCryptoType? { + val isEvm = chainsById.isEVM(chainId) ?: return null + + if (isEvm) return ChainAccountCryptoType.ETHEREUM + + return when (this) { + CryptoType.SR25519 -> ChainAccountCryptoType.SR25519 + CryptoType.ED25519 -> ChainAccountCryptoType.ED25519 + CryptoType.ECDSA -> ChainAccountCryptoType.ECDSA + } + } + + private fun ChainsById.isEVM(chainId: ChainId): Boolean? { + return get(chainId)?.isEthereumBased + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/ethereum/transaction/RealEvmTransactionService.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/ethereum/transaction/RealEvmTransactionService.kt new file mode 100644 index 0000000..7140306 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/ethereum/transaction/RealEvmTransactionService.kt @@ -0,0 +1,205 @@ +package io.novafoundation.nova.feature_account_impl.data.ethereum.transaction + +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.toEcdsaSignatureData +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EthereumTransactionExecution +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionBuilding +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.model.EvmFee +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.runtime.ethereum.EvmRpcException +import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import org.web3j.crypto.RawTransaction +import org.web3j.crypto.Sign +import org.web3j.crypto.TransactionEncoder +import org.web3j.protocol.core.DefaultBlockParameterName +import org.web3j.protocol.core.methods.request.Transaction +import org.web3j.rlp.RlpEncoder +import org.web3j.rlp.RlpList +import java.math.BigInteger +import kotlinx.coroutines.delay +import org.web3j.protocol.core.methods.response.TransactionReceipt +import kotlin.String +import kotlin.time.Duration.Companion.seconds + +internal class RealEvmTransactionService( + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val signerProvider: SignerProvider, + private val gasPriceProviderFactory: GasPriceProviderFactory, +) : EvmTransactionService { + + override suspend fun calculateFee( + chainId: ChainId, + origin: TransactionOrigin, + fallbackGasLimit: BigInteger, + building: EvmTransactionBuilding + ): Fee { + val web3Api = chainRegistry.getCallEthereumApiOrThrow(chainId) + val chain = chainRegistry.getChain(chainId) + + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chainId) + val submittingAddress = submittingMetaAccount.requireAddressIn(chain) + + val txBuilder = EvmTransactionBuilder().apply(building) + val txForFee = txBuilder.buildForFee(submittingAddress) + + val gasPrice = gasPriceProviderFactory.createKnown(chainId).getGasPrice() + val gasLimit = web3Api.gasLimitOrDefault(txForFee, fallbackGasLimit) + + return EvmFee( + gasLimit, + gasPrice, + SubmissionOrigin.singleOrigin(submittingMetaAccount.requireAccountIdIn(chain)), + chain.commissionAsset + ) + } + + override suspend fun transact( + chainId: ChainId, + presetFee: Fee?, + origin: TransactionOrigin, + fallbackGasLimit: BigInteger, + building: EvmTransactionBuilding + ): Result = runCatching { + val chain = chainRegistry.getChain(chainId) + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chainId) + val submittingAddress = submittingMetaAccount.requireAddressIn(chain) + val submittingAccountId = submittingMetaAccount.requireAccountIdIn(chain) + + val web3Api = chainRegistry.getCallEthereumApiOrThrow(chainId) + val txBuilder = EvmTransactionBuilder().apply(building) + + val evmFee = presetFee?.castOrNull() ?: run { + val txForFee = txBuilder.buildForFee(submittingAddress) + val gasPrice = gasPriceProviderFactory.createKnown(chainId).getGasPrice() + val gasLimit = web3Api.gasLimitOrDefault(txForFee, fallbackGasLimit) + + EvmFee( + gasLimit, + gasPrice, + SubmissionOrigin.singleOrigin(submittingAccountId), + chain.commissionAsset + ) + } + + val nonce = web3Api.getNonce(submittingAddress) + + val txForSign = txBuilder.buildForSign(nonce = nonce, gasPrice = evmFee.gasPrice, gasLimit = evmFee.gasLimit) + val toSubmit = signTransaction(txForSign, submittingMetaAccount, chain) + + val txHash = web3Api.sendTransaction(toSubmit) + val callExecutionType = CallExecutionType.IMMEDIATE + + ExtrinsicSubmission( + hash = txHash, + submissionOrigin = SubmissionOrigin.singleOrigin(submittingAccountId), + // Well, actually some smart-contracts might be "delayed", e.g. Gnosis Multisigs + // But we don't care at this point since this service is used for internal app txs only, basically just for the transfers + callExecutionType = callExecutionType, + submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, callExecutionType) + ) + } + + private suspend fun signTransaction(txForSign: RawTransaction, metaAccount: MetaAccount, chain: Chain): String { + val ethereumChainId = chain.addressPrefix.toLong() + val encodedTx = TransactionEncoder.encode(txForSign, ethereumChainId) + + val signer = signerProvider.rootSignerFor(metaAccount) + val accountId = metaAccount.requireAccountIdIn(chain) + + val signerPayload = SignerPayloadRaw(encodedTx, accountId) + val signatureData = signer.signRaw(signerPayload).toEcdsaSignatureData() + + val eip155SignatureData: Sign.SignatureData = TransactionEncoder.createEip155SignatureData(signatureData, ethereumChainId) + + return txForSign.encodeWith(eip155SignatureData).toHexString(withPrefix = true) + } + + override suspend fun transactAndAwaitExecution( + chainId: ChainId, + presetFee: Fee?, + origin: TransactionOrigin, + fallbackGasLimit: BigInteger, + building: EvmTransactionBuilding + ): Result { + return transact(chainId, presetFee, origin, fallbackGasLimit, building) + .mapCatching { + val transactionReceipt = it.awaitExecution(chainId) + EthereumTransactionExecution( + extrinsicHash = transactionReceipt.transactionHash, + blockHash = transactionReceipt.blockHash, + it.submissionHierarchy + ) + } + } + + private suspend fun ExtrinsicSubmission.awaitExecution(chainId: ChainId): TransactionReceipt { + val deadline = System.currentTimeMillis() + 60.seconds.inWholeMilliseconds + + while (true) { + val web3Api = chainRegistry.getCallEthereumApiOrThrow(chainId) + val response = web3Api.ethGetTransactionReceipt(hash).sendSuspend() + val optionalReceipt = response.transactionReceipt + + if (optionalReceipt.isPresent) { + val receipt = optionalReceipt.get() + + if (!receipt.isStatusOK()) { + throw RuntimeException("EVM tx reverted: $hash") + } + + return receipt + } + + if (System.currentTimeMillis() > deadline) { + throw RuntimeException("Timeout while waiting for tx: $hash") + } + + delay(3.seconds.inWholeMilliseconds) + } + } + + private suspend fun Web3Api.getNonce(address: String): BigInteger { + return ethGetTransactionCount(address, DefaultBlockParameterName.PENDING) + .sendSuspend() + .transactionCount + } + + private suspend fun Web3Api.gasLimitOrDefault(tx: Transaction, default: BigInteger): BigInteger = try { + ethEstimateGas(tx).sendSuspend().amountUsed + } catch (rpcException: EvmRpcException) { + default + } + + private fun RawTransaction.encodeWith(signatureData: Sign.SignatureData): ByteArray { + val values = TransactionEncoder.asRlpValues(this, signatureData) + val rlpList = RlpList(values) + return RlpEncoder.encode(rlpList) + } + + private suspend fun Web3Api.sendTransaction(transactionData: String): String { + return ethSendRawTransaction(transactionData).sendSuspend().transactionHash + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/events/MetaAccountChangesEventBus.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/events/MetaAccountChangesEventBus.kt new file mode 100644 index 0000000..0ace783 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/events/MetaAccountChangesEventBus.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_account_impl.data.events + +import io.novafoundation.nova.common.utils.bus.BaseEventBus +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.events.allAffectedMetaAccountTypes +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_impl.data.cloudBackup.CloudBackupAccountsModificationsTracker + +/** + * Implementation of [MetaAccountChangesEventBus] that also performs some additional action known to account feature + * Components from external modules can subscribe to this event bus on the upper level + */ +class RealMetaAccountChangesEventBus( + private val externalAccountsSyncService: dagger.Lazy, + private val cloudBackupAccountsModificationsTracker: CloudBackupAccountsModificationsTracker +) : BaseEventBus(), MetaAccountChangesEventBus { + + override suspend fun notify(event: MetaAccountChangesEventBus.Event, source: String?) { + super.notify(event, source) + + cloudBackupAccountsModificationsTracker.recordAccountModified(event.allAffectedMetaAccountTypes()) + externalAccountsSyncService.get().syncOnAccountChange(event, source) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt new file mode 100644 index 0000000..1fa5f38 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/ExtrinsicSplitter.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.feature_account_impl.data.extrinsic + +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.data.network.runtime.binding.fitsIn +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.common.utils.min +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter +import io.novafoundation.nova.feature_account_api.data.extrinsic.SplitCalls +import io.novafoundation.nova.runtime.ext.requireGenesisHash +import io.novafoundation.nova.runtime.extrinsic.CustomTransactionExtensions +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.network.binding.BlockWeightLimits +import io.novafoundation.nova.runtime.network.binding.PerDispatchClassWeight +import io.novafoundation.nova.runtime.network.binding.total +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.repository.BlockLimitsRepository +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Era +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode +import io.novasama.substrate_sdk_android.runtime.extrinsic.ExtrinsicVersion +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.ChargeTransactionPayment +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckGenesis +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckMortality +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckSpecVersion +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckTxVersion +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHash +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHashMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.math.BigInteger +import javax.inject.Inject + +private typealias CallWeightsByType = Map> + +private const val LEAVE_SOME_SPACE_MULTIPLIER = 0.8 + +@FeatureScope +internal class RealExtrinsicSplitter @Inject constructor( + private val rpcCalls: RpcCalls, + private val blockLimitsRepository: BlockLimitsRepository, + private val signingContextFactory: SigningContext.Factory, + private val chainRegistry: ChainRegistry, +) : ExtrinsicSplitter { + + override suspend fun split(signer: NovaSigner, callBuilder: CallBuilder, chain: Chain): SplitCalls = coroutineScope { + val weightByCallId = estimateWeightByCallType(signer, callBuilder, chain) + + val blockLimit = blockLimitsRepository.blockLimits(chain.id) + val lastBlockWeight = blockLimitsRepository.lastBlockWeight(chain.id) + val extrinsicLimit = determineExtrinsicLimit(blockLimit, lastBlockWeight) + + val signerLimit = signer.maxCallsPerTransaction() + + callBuilder.splitCallsWith(weightByCallId, extrinsicLimit, signerLimit) + } + + override suspend fun estimateCallWeight(signer: NovaSigner, call: GenericCall.Instance, chain: Chain): WeightV2 { + val runtime = chainRegistry.getRuntime(chain.id) + val fakeExtrinsic = wrapInFakeExtrinsic(signer, call, runtime, chain) + return rpcCalls.getExtrinsicFee(chain, fakeExtrinsic).weight + } + + private fun determineExtrinsicLimit(blockLimits: BlockWeightLimits, lastBlockWeight: PerDispatchClassWeight): WeightV2 { + val extrinsicLimit = blockLimits.perClass.normal.maxExtrinsic + val normalClassLimit = blockLimits.perClass.normal.maxTotal - lastBlockWeight.normal + val blockLimit = blockLimits.maxBlock - lastBlockWeight.total() + + val unionLimit = min(extrinsicLimit, normalClassLimit, blockLimit) + return unionLimit * LEAVE_SOME_SPACE_MULTIPLIER + } + + private val GenericCall.Instance.uniqueId: String + get() { + val (moduleIdx, functionIdx) = function.index + return "$moduleIdx:$functionIdx" + } + + @Suppress("SuspendFunctionOnCoroutineScope") + private suspend fun CoroutineScope.estimateWeightByCallType(signer: NovaSigner, callBuilder: CallBuilder, chain: Chain): CallWeightsByType { + return callBuilder.calls.groupBy { it.uniqueId } + .mapValues { (_, calls) -> + val sample = calls.first() + val sampleExtrinsic = wrapInFakeExtrinsic(signer, sample, callBuilder.runtime, chain) + + async { rpcCalls.getExtrinsicFee(chain, sampleExtrinsic).weight } + } + } + + private suspend fun CallBuilder.splitCallsWith( + weights: CallWeightsByType, + blockWeightLimit: WeightV2, + signerNumberOfCallsLimit: Int?, + ): SplitCalls { + val split = mutableListOf>() + + var currentBatch = mutableListOf() + var currentBatchWeight: WeightV2 = WeightV2.zero() + + calls.forEach { call -> + val estimatedCallWeight = weights.getValue(call.uniqueId).await() + val newWeight = currentBatchWeight + estimatedCallWeight + val exceedsByWeight = !newWeight.fitsIn(blockWeightLimit) + val exceedsByNumberOfCalls = signerNumberOfCallsLimit != null && currentBatch.size >= signerNumberOfCallsLimit + + if (exceedsByWeight || exceedsByNumberOfCalls) { + if (!estimatedCallWeight.fitsIn(blockWeightLimit)) throw IllegalArgumentException("Impossible to fit call $call into a block") + + split += currentBatch + + currentBatchWeight = estimatedCallWeight + currentBatch = mutableListOf(call) + } else { + currentBatchWeight += estimatedCallWeight + currentBatch += call + } + } + + if (currentBatch.isNotEmpty()) { + split.add(currentBatch) + } + + return split + } + + private suspend fun wrapInFakeExtrinsic( + signer: NovaSigner, + call: GenericCall.Instance, + runtime: RuntimeSnapshot, + chain: Chain + ): SendableExtrinsic { + val genesisHash = chain.requireGenesisHash().fromHex() + + return ExtrinsicBuilder( + runtime = runtime, + extrinsicVersion = ExtrinsicVersion.V4, + batchMode = BatchMode.BATCH, + ).apply { + setTransactionExtension(CheckMortality(Era.Immortal, genesisHash)) + setTransactionExtension(CheckGenesis(chain.requireGenesisHash().fromHex())) + setTransactionExtension(ChargeTransactionPayment(BigInteger.ZERO)) + setTransactionExtension(CheckMetadataHash(CheckMetadataHashMode.Disabled)) + setTransactionExtension(CheckSpecVersion(0)) + setTransactionExtension(CheckTxVersion(0)) + + CustomTransactionExtensions.defaultValues(runtime).forEach(::setTransactionExtension) + + call(call) + + val signingContext = signingContextFactory.default(chain) + signer.setSignerDataForFee(signingContext) + }.buildExtrinsic() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicService.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicService.kt new file mode 100644 index 0000000..6d83546 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicService.kt @@ -0,0 +1,436 @@ +package io.novafoundation.nova.feature_account_impl.data.extrinsic + +import android.util.Log +import io.novafoundation.nova.common.data.network.runtime.binding.DispatchError +import io.novafoundation.nova.common.data.network.runtime.binding.bindDispatchError +import io.novafoundation.nova.common.data.network.runtime.model.FeeResponse +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.mapAsync +import io.novafoundation.nova.common.utils.multiResult.RetriableMultiResult +import io.novafoundation.nova.common.utils.multiResult.runMultiCatching +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.provideContext +import io.novafoundation.nova.common.utils.takeWhileInclusive +import io.novafoundation.nova.common.utils.tip +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicBuildingContext +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService.SubmissionOptions +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.FormExtrinsicWithOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.FormMultiExtrinsicWithOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicDispatch +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.fee.FeePayment +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.toChainAsset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFee +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.data.signer.SigningMode +import io.novafoundation.nova.feature_account_api.data.signer.setSignerData +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.withSequenceSigning +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.extrinsic.multi.SimpleCallBuilder +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findExtrinsicFailureOrThrow +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.isSuccess +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Extrinsic +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class RealExtrinsicService( + private val rpcCalls: RpcCalls, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val extrinsicBuilderFactory: ExtrinsicBuilderFactory, + private val signerProvider: SignerProvider, + private val extrinsicSplitter: ExtrinsicSplitter, + private val feePaymentProviderRegistry: FeePaymentProviderRegistry, + private val eventsRepository: EventsRepository, + private val signingContextFactory: SigningContext.Factory, + private val coroutineScope: CoroutineScope? // TODO: Make it non-nullable +) : ExtrinsicService { + + override suspend fun submitExtrinsic( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions, + formExtrinsic: FormExtrinsicWithOrigin + ): Result = runCatching { + val (extrinsic, submissionOrigin, _, callExecutionType, signingHierarchy) = buildSubmissionExtrinsic(chain, origin, formExtrinsic, submissionOptions) + val hash = rpcCalls.submitExtrinsic(chain.id, extrinsic) + + ExtrinsicSubmission(hash, submissionOrigin, callExecutionType, signingHierarchy) + } + + override suspend fun submitMultiExtrinsicAwaitingInclusion( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions, + formExtrinsic: FormMultiExtrinsicWithOrigin + ): RetriableMultiResult> { + return runMultiCatching( + intermediateListLoading = { + val submission = constructSplitExtrinsics(chain, origin, formExtrinsic, submissionOptions, SigningMode.SUBMISSION) + + submission.extrinsics.map { it to submission } + }, + listProcessing = { (extrinsic, submission) -> + rpcCalls.submitAndWatchExtrinsic(chain.id, extrinsic) + .filterIsInstance() + .map { ExtrinsicWatchResult(it, submission.submissionHierarchy) } + .first() + } + ) + } + + // TODO: The flow in Result may produce an exception that will be not handled since Result can't catch an exception inside a flow + // For now it's handled in awaitInBlock() extension + override suspend fun submitAndWatchExtrinsic( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions, + formExtrinsic: FormExtrinsicWithOrigin + ): Result>> = runCatching { + val singleSubmission = buildSubmissionExtrinsic(chain, origin, formExtrinsic, submissionOptions) + + rpcCalls.submitAndWatchExtrinsic(chain.id, singleSubmission.extrinsic) + .map { ExtrinsicWatchResult(it, singleSubmission.submissionHierarchy) } + .takeWhileInclusive { !it.status.terminal } + } + + override suspend fun submitExtrinsicAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions, + formExtrinsic: FormExtrinsicWithOrigin + ): Result { + return submitAndWatchExtrinsic(chain, origin, submissionOptions, formExtrinsic) + .awaitInBlock() + .map { determineExtrinsicOutcome(it, chain) } + } + + override suspend fun paymentInfo( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions, + formExtrinsic: FormExtrinsicWithOrigin + ): FeeResponse { + val (extrinsic) = buildFeeExtrinsic(chain, origin, formExtrinsic, submissionOptions) + return rpcCalls.getExtrinsicFee(chain, extrinsic) + } + + override suspend fun estimateFee( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions, + formExtrinsic: FormExtrinsicWithOrigin + ): Fee { + val (extrinsic, submissionOrigin, feePayment) = buildFeeExtrinsic(chain, origin, formExtrinsic, submissionOptions) + val nativeFee = estimateNativeFee(chain, extrinsic, submissionOrigin) + return feePayment.convertNativeFee(nativeFee) + } + + override suspend fun estimateFee( + chain: Chain, + extrinsic: String, + usedSigner: NovaSigner, + ): Fee { + val runtime = chainRegistry.getRuntime(chain.id) + val sendableExtrinsic = SendableExtrinsic(runtime, Extrinsic.fromHex(runtime, extrinsic)) + val submissionOrigin = usedSigner.submissionOrigin(chain) + + val nativeFee = estimateNativeFee(chain, sendableExtrinsic, submissionOrigin) + + val feePaymentProvider = feePaymentProviderRegistry.providerFor(chain.id) + val feePayment = feePaymentProvider.detectFeePaymentFromExtrinsic(sendableExtrinsic) + + return feePayment.convertNativeFee(nativeFee) + } + + override suspend fun estimateMultiFee( + chain: Chain, + origin: TransactionOrigin, + submissionOptions: SubmissionOptions, + formExtrinsic: FormMultiExtrinsicWithOrigin + ): Fee { + val (extrinsics, submissionOrigin) = constructSplitExtrinsics(chain, origin, formExtrinsic, submissionOptions, SigningMode.FEE) + require(extrinsics.isNotEmpty()) { "Empty extrinsics list" } + + val fees = extrinsics.mapAsync { estimateNativeFee(chain, it, submissionOrigin) } + val totalFeeAmount = fees.sumOf { it.amount } + + val totalNativeFee = SubstrateFee( + amount = totalFeeAmount, + submissionOrigin = submissionOrigin, + asset = submissionOptions.feePaymentCurrency.toChainAsset(chain) + ) + + val feePaymentProvider = feePaymentProviderRegistry.providerFor(chain.id) + val feePayment = feePaymentProvider.feePaymentFor(submissionOptions.feePaymentCurrency, coroutineScope) + + return feePayment.convertNativeFee(totalNativeFee) + } + + private suspend fun determineExtrinsicOutcome( + watchResult: ExtrinsicWatchResult, + chain: Chain + ): ExtrinsicExecutionResult { + val status = watchResult.status + + val outcome = runCatching { + val extrinsicWithEvents = eventsRepository.getExtrinsicWithEvents(chain.id, status.extrinsicHash, status.blockHash) + val runtime = chainRegistry.getRuntime(chain.id) + + requireNotNull(extrinsicWithEvents) { + "No extrinsic included into expected block" + } + + extrinsicWithEvents.determineOutcome(runtime) + }.getOrElse { + Log.w(LOG_TAG, "Failed to determine extrinsic outcome", it) + + ExtrinsicDispatch.Unknown + } + + return ExtrinsicExecutionResult( + extrinsicHash = status.extrinsicHash, + blockHash = status.blockHash, + outcome = outcome, + submissionHierarchy = watchResult.submissionHierarchy + ) + } + + private fun ExtrinsicWithEvents.determineOutcome(runtimeSnapshot: RuntimeSnapshot): ExtrinsicDispatch { + return if (isSuccess()) { + ExtrinsicDispatch.Ok(events) + } else { + val errorEvent = events.findExtrinsicFailureOrThrow() + val dispatchError = parseErrorEvent(errorEvent, runtimeSnapshot) + + ExtrinsicDispatch.Failed(dispatchError) + } + } + + private fun parseErrorEvent(errorEvent: GenericEvent.Instance, runtimeSnapshot: RuntimeSnapshot): DispatchError { + val dispatchError = errorEvent.arguments.first() + + return runtimeSnapshot.provideContext { bindDispatchError(dispatchError) } + } + + private suspend fun constructSplitExtrinsics( + chain: Chain, + origin: TransactionOrigin, + formExtrinsic: FormMultiExtrinsicWithOrigin, + submissionOptions: SubmissionOptions, + signingMode: SigningMode + ): MultiSubmission { + val signer = getSigner(chain, origin) + + val extrinsicBuilderSequence = extrinsicBuilderFactory.createMulti( + chain = chain, + options = submissionOptions.toBuilderFactoryOptions() + ) + + val runtime = chainRegistry.getRuntime(chain.id) + + val submissionOrigin = signer.submissionOrigin(chain) + val buildingContext = ExtrinsicBuildingContext(submissionOrigin, signer, chain) + + val callBuilder = SimpleCallBuilder(runtime).apply { formExtrinsic(buildingContext) } + val splitCalls = extrinsicSplitter.split(signer, callBuilder, chain) + + val feePaymentProvider = feePaymentProviderRegistry.providerFor(chain.id) + val feePayment = feePaymentProvider.feePaymentFor(submissionOptions.feePaymentCurrency, coroutineScope) + + val extrinsicBuilderIterator = extrinsicBuilderSequence.iterator() + + // Setup signing + val signingContext = signingContextFactory.default(chain).withSequenceSigning() + val extrinsics = splitCalls.map { batch -> + // Create empty builder + val extrinsicBuilder = extrinsicBuilderIterator.next() + + // Add upstream calls + batch.forEach(extrinsicBuilder::call) + + // Setup fees + feePayment.modifyExtrinsic(extrinsicBuilder) + + // Setup signing + with(extrinsicBuilder) { + signer.setSignerData(signingContext, signingMode) + } + + // Build extrinsic + extrinsicBuilder.buildExtrinsic().also { + signingContext.incrementNonceOffset() + } + } + + val signingHierarchy = signer.getSigningHierarchy() + + return MultiSubmission(extrinsics, submissionOrigin, feePayment, signingHierarchy) + } + + private suspend fun buildSubmissionExtrinsic( + chain: Chain, + origin: TransactionOrigin, + formExtrinsic: FormExtrinsicWithOrigin, + submissionOptions: SubmissionOptions, + ): SingleSubmission { + return buildExtrinsic(chain, origin, formExtrinsic, submissionOptions, SigningMode.SUBMISSION) + } + + private suspend fun buildFeeExtrinsic( + chain: Chain, + origin: TransactionOrigin, + formExtrinsic: FormExtrinsicWithOrigin, + submissionOptions: SubmissionOptions, + ): SingleSubmission { + return buildExtrinsic(chain, origin, formExtrinsic, submissionOptions, SigningMode.FEE) + } + + private suspend fun buildExtrinsic( + chain: Chain, + origin: TransactionOrigin, + formExtrinsic: FormExtrinsicWithOrigin, + submissionOptions: SubmissionOptions, + signingMode: SigningMode + ): SingleSubmission { + val signer = getSigner(chain, origin) + + val submissionOrigin = signer.submissionOrigin(chain) + + // Create empty builder + val extrinsicBuilder = extrinsicBuilderFactory.create( + chain = chain, + options = submissionOptions.toBuilderFactoryOptions() + ) + + // Add upstream calls + val buildingContext = ExtrinsicBuildingContext(submissionOrigin, signer, chain) + extrinsicBuilder.formExtrinsic(buildingContext) + + // Setup fees + val feePaymentProvider = feePaymentProviderRegistry.providerFor(chain.id) + val feePayment = feePaymentProvider.feePaymentFor(submissionOptions.feePaymentCurrency, coroutineScope) + feePayment.modifyExtrinsic(extrinsicBuilder) + + // Setup signing + val signingContext = signingContextFactory.default(chain) + with(extrinsicBuilder) { + signer.setSignerData(signingContext, signingMode) + } + + // Build extrinsic + val extrinsic = try { + Log.d("RealExtrinsicService", "Building extrinsic for chain ${chain.name} (${chain.id})") + extrinsicBuilder.buildExtrinsic() + } catch (e: Exception) { + Log.e("RealExtrinsicService", "Failed to build extrinsic for chain ${chain.name}", e) + Log.e("RealExtrinsicService", "SigningMode: $signingMode, Chain: ${chain.id}") + Log.e("RealExtrinsicService", "Exception class: ${e::class.java.name}") + Log.e("RealExtrinsicService", "Message: ${e.message}") + Log.e("RealExtrinsicService", "Cause: ${e.cause?.message}") + Log.e("RealExtrinsicService", "Full stack trace:", e) + + // Get runtime diagnostics + try { + val runtime = chainRegistry.getRuntime(chain.id) + val typeRegistry = runtime.typeRegistry + val hasExtrinsicSignature = typeRegistry["ExtrinsicSignature"] != null + val hasMultiSignature = typeRegistry["MultiSignature"] != null + val hasMultiAddress = typeRegistry["MultiAddress"] != null + val hasAddress = typeRegistry["Address"] != null + Log.e( + "RealExtrinsicService", + "Types: ExtrinsicSig=$hasExtrinsicSignature, MultiSig=$hasMultiSignature, " + + "MultiAddress=$hasMultiAddress, Address=$hasAddress" + ) + + // Check extrinsic extensions + val signedExtensions = runtime.metadata.extrinsic.signedExtensions.map { it.id } + Log.e("RealExtrinsicService", "Signed extensions: $signedExtensions") + } catch (diagEx: Exception) { + Log.e("RealExtrinsicService", "Failed to get diagnostics: ${diagEx.message}") + } + throw e + } + + val signingHierarchy = signer.getSigningHierarchy() + + return SingleSubmission(extrinsic, submissionOrigin, feePayment, signer.callExecutionType(), signingHierarchy) + } + + private fun SubmissionOptions.toBuilderFactoryOptions(): ExtrinsicBuilderFactory.Options { + return ExtrinsicBuilderFactory.Options(batchMode) + } + + private suspend fun getSigner(chain: Chain, origin: TransactionOrigin): NovaSigner { + val metaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + return signerProvider.rootSignerFor(metaAccount) + } + + private data class SingleSubmission( + val extrinsic: SendableExtrinsic, + val submissionOrigin: SubmissionOrigin, + val feePayment: FeePayment, + val callExecutionType: CallExecutionType, + val submissionHierarchy: SubmissionHierarchy, + ) + + private data class MultiSubmission( + val extrinsics: List, + val submissionOrigin: SubmissionOrigin, + val feePayment: FeePayment, + val submissionHierarchy: SubmissionHierarchy + ) + + private suspend fun NovaSigner.submissionOrigin(chain: Chain): SubmissionOrigin { + val executingAccount = metaAccount.requireAccountIdIn(chain) + val signingAccount = submissionSignerAccountId(chain) + return SubmissionOrigin(executingAccount, signingAccount) + } + + private suspend fun estimateNativeFee( + chain: Chain, + sendableExtrinsic: SendableExtrinsic, + submissionOrigin: SubmissionOrigin + ): Fee { + val baseFee = rpcCalls.getExtrinsicFee(chain, sendableExtrinsic).partialFee + val tip = sendableExtrinsic.extrinsic.tip().orZero() + + return SubstrateFee( + amount = tip + baseFee, + submissionOrigin = submissionOrigin, + chain.commissionAsset + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicServiceFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicServiceFactory.kt new file mode 100644 index 0000000..63a44a1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/extrinsic/RealExtrinsicServiceFactory.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_account_impl.data.extrinsic + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.network.rpc.RpcCalls + +class RealExtrinsicServiceFactory( + private val rpcCalls: RpcCalls, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val extrinsicBuilderFactory: ExtrinsicBuilderFactory, + private val signerProvider: SignerProvider, + private val extrinsicSplitter: ExtrinsicSplitter, + private val eventsRepository: EventsRepository, + private val feePaymentProviderRegistry: FeePaymentProviderRegistry, + private val signingContextFactory: SigningContext.Factory, +) : ExtrinsicService.Factory { + + override fun create(feeConfig: ExtrinsicService.FeePaymentConfig): ExtrinsicService { + val registry = getRegistry(feeConfig) + return RealExtrinsicService( + rpcCalls = rpcCalls, + chainRegistry = chainRegistry, + accountRepository = accountRepository, + extrinsicBuilderFactory = extrinsicBuilderFactory, + signerProvider = signerProvider, + extrinsicSplitter = extrinsicSplitter, + feePaymentProviderRegistry = registry, + eventsRepository = eventsRepository, + coroutineScope = feeConfig.coroutineScope, + signingContextFactory = signingContextFactory + ) + } + + private fun getRegistry(config: ExtrinsicService.FeePaymentConfig): FeePaymentProviderRegistry { + return config.customFeePaymentRegistry ?: feePaymentProviderRegistry + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/FeePaymentProviderRegistry.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/FeePaymentProviderRegistry.kt new file mode 100644 index 0000000..d2fda65 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/FeePaymentProviderRegistry.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_impl.data.fee + +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProvider +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_impl.data.fee.chains.AssetHubFeePaymentProvider +import io.novafoundation.nova.feature_account_impl.data.fee.chains.DefaultFeePaymentProvider +import io.novafoundation.nova.feature_account_impl.data.fee.chains.HydrationFeePaymentProvider +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +internal class RealFeePaymentProviderRegistry( + private val assetHubFactory: AssetHubFeePaymentProvider.Factory, + private val hydrationFactory: HydrationFeePaymentProvider.Factory, + private val chainRegistry: ChainRegistry, +) : FeePaymentProviderRegistry { + + override suspend fun providerFor(chainId: ChainId): FeePaymentProvider { + val chain = chainRegistry.getChain(chainId) + + return when (chainId) { + Chain.Geneses.PEZKUWI_ASSET_HUB, + Chain.Geneses.POLKADOT_ASSET_HUB, + Chain.Geneses.KUSAMA_ASSET_HUB -> assetHubFactory.create(chain) + Chain.Geneses.HYDRA_DX -> hydrationFactory.create(chain) + else -> DefaultFeePaymentProvider(chain) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/capability/RealCustomCustomFeeCapabilityFacade.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/capability/RealCustomCustomFeeCapabilityFacade.kt new file mode 100644 index 0000000..daf3c61 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/capability/RealCustomCustomFeeCapabilityFacade.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.capability + +import android.util.Log +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.requestedAccountPaysFees + +class RealCustomCustomFeeCapabilityFacade( + private val accountRepository: AccountRepository, + private val feePaymentProviderRegistry: FeePaymentProviderRegistry +) : CustomFeeCapabilityFacade { + + override suspend fun canPayFeeInCurrency(currency: FeePaymentCurrency): Boolean { + return when (currency) { + is FeePaymentCurrency.Asset -> canPayFeeInNonUtilityAsset(currency) + + FeePaymentCurrency.Native -> true + } + } + + private suspend fun canPayFeeInNonUtilityAsset( + currency: FeePaymentCurrency.Asset, + ): Boolean { + if (hasGlobalFeePaymentRestrictions()) return false + + return feePaymentProviderRegistry.providerFor(currency.asset.chainId) + .canPayFee(currency) + .onFailure { Log.e("RealCustomCustomFeeCapabilityFacade", "Failed to check canPayFee", it) } + .getOrDefault(false) + } + + override suspend fun hasGlobalFeePaymentRestrictions(): Boolean { + val currentMetaAccount = accountRepository.getSelectedMetaAccount() + return !currentMetaAccount.type.requestedAccountPaysFees() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/chains/AssetHubFeePaymentProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/chains/AssetHubFeePaymentProvider.kt new file mode 100644 index 0000000..9c945ab --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/chains/AssetHubFeePaymentProvider.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.chains + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.novafoundation.nova.feature_account_api.data.fee.FeePayment +import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability +import io.novafoundation.nova.feature_account_api.data.fee.chains.CustomOrNativeFeePaymentProvider +import io.novafoundation.nova.feature_account_api.data.fee.types.NativeFeePayment +import io.novafoundation.nova.feature_account_api.data.fee.types.assetHub.findChargeAssetTxPayment +import io.novafoundation.nova.feature_account_impl.data.fee.types.assetHub.AssetConversionFeePayment +import io.novafoundation.nova.feature_account_impl.data.fee.types.assetHub.AssetHubFastLookupFeeCapability +import io.novafoundation.nova.feature_account_impl.data.fee.types.assetHub.AssetHubFeePaymentAssetsFetcherFactory +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Extrinsic +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic +import kotlinx.coroutines.CoroutineScope + +class AssetHubFeePaymentProvider @AssistedInject constructor( + @Assisted override val chain: Chain, + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, + private val multiLocationConverterFactory: MultiLocationConverterFactory, + private val assetHubFeePaymentAssetsFetcher: AssetHubFeePaymentAssetsFetcherFactory, + private val xcmVersionDetector: XcmVersionDetector +) : CustomOrNativeFeePaymentProvider() { + + @AssistedFactory + interface Factory { + + fun create(chain: Chain): AssetHubFeePaymentProvider + } + + override suspend fun feePaymentFor(customFeeAsset: Chain.Asset, coroutineScope: CoroutineScope?): FeePayment { + val multiLocationConverter = multiLocationConverterFactory.defaultSync(chain) + + return AssetConversionFeePayment( + paymentAsset = customFeeAsset, + multiChainRuntimeCallsApi = multiChainRuntimeCallsApi, + multiLocationConverter = multiLocationConverter, + xcmVersionDetector = xcmVersionDetector + ) + } + + override suspend fun canPayFeeInNonUtilityToken(customFeeAsset: Chain.Asset): Result { + // Asset hub does not support per-asset optimized query + return fastLookupCustomFeeCapability() + .map { it.canPayFeeInNonUtilityToken(customFeeAsset.id) } + } + + override suspend fun detectFeePaymentFromExtrinsic(extrinsic: SendableExtrinsic): FeePayment { + val multiLocationConverter = multiLocationConverterFactory.defaultSync(chain) + val feePaymentAsset = extrinsic.extrinsic.detectFeePaymentAsset(multiLocationConverter) ?: return NativeFeePayment() + + return AssetConversionFeePayment( + paymentAsset = feePaymentAsset, + multiChainRuntimeCallsApi = multiChainRuntimeCallsApi, + multiLocationConverter = multiLocationConverter, + xcmVersionDetector = xcmVersionDetector + ) + } + + override suspend fun fastLookupCustomFeeCapability(): Result { + return runCatching { + val fetcher = assetHubFeePaymentAssetsFetcher.create(chain) + AssetHubFastLookupFeeCapability(fetcher.fetchAvailablePaymentAssets()) + } + } + + private suspend fun Extrinsic.Instance.detectFeePaymentAsset(multiLocationConverter: MultiLocationConverter): Chain.Asset? { + val assetId = findChargeAssetTxPayment()?.assetId ?: return null + return multiLocationConverter.toChainAsset(assetId) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/chains/DefaultFeePaymentProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/chains/DefaultFeePaymentProvider.kt new file mode 100644 index 0000000..ddd6a3d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/chains/DefaultFeePaymentProvider.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.chains + +import io.novafoundation.nova.feature_account_api.data.fee.DefaultFastLookupCustomFeeCapability +import io.novafoundation.nova.feature_account_api.data.fee.FeePayment +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProvider +import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability +import io.novafoundation.nova.feature_account_api.data.fee.types.NativeFeePayment +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic +import kotlinx.coroutines.CoroutineScope + +class DefaultFeePaymentProvider(override val chain: Chain) : FeePaymentProvider { + + override suspend fun feePaymentFor(feePaymentCurrency: FeePaymentCurrency, coroutineScope: CoroutineScope?): FeePayment { + return NativeFeePayment() + } + + override suspend fun detectFeePaymentFromExtrinsic(extrinsic: SendableExtrinsic): FeePayment { + return NativeFeePayment() + } + + override suspend fun canPayFee(feePaymentCurrency: FeePaymentCurrency): Result { + val result = when (feePaymentCurrency) { + is FeePaymentCurrency.Asset -> false + FeePaymentCurrency.Native -> true + } + + return Result.success(result) + } + + override suspend fun fastLookupCustomFeeCapability(): Result { + return Result.success(DefaultFastLookupCustomFeeCapability()) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/chains/HydrationFeePaymentProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/chains/HydrationFeePaymentProvider.kt new file mode 100644 index 0000000..98520e9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/chains/HydrationFeePaymentProvider.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.chains + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.novafoundation.nova.feature_account_api.data.fee.FeePayment +import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability +import io.novafoundation.nova.feature_account_api.data.fee.chains.CustomOrNativeFeePaymentProvider +import io.novafoundation.nova.feature_account_api.data.fee.types.NativeFeePayment +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_impl.data.fee.types.hydra.HydraDxQuoteSharedComputation +import io.novafoundation.nova.feature_account_impl.data.fee.types.hydra.HydrationConversionFeePayment +import io.novafoundation.nova.feature_account_impl.data.fee.types.hydra.HydrationFastLookupFeeCapability +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic +import kotlinx.coroutines.CoroutineScope + +class HydrationFeePaymentProvider @AssistedInject constructor( + @Assisted override val chain: Chain, + private val chainRegistry: ChainRegistry, + private val hydraDxQuoteSharedComputation: HydraDxQuoteSharedComputation, + private val hydrationFeeInjector: HydrationFeeInjector, + private val hydrationPriceConversionFallback: HydrationPriceConversionFallback, + private val accountRepository: AccountRepository, + private val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher +) : CustomOrNativeFeePaymentProvider() { + + @AssistedFactory + interface Factory { + + fun create(chain: Chain): HydrationFeePaymentProvider + } + + override suspend fun feePaymentFor(customFeeAsset: Chain.Asset, coroutineScope: CoroutineScope?): FeePayment { + return HydrationConversionFeePayment( + paymentAsset = customFeeAsset, + chainRegistry = chainRegistry, + hydrationFeeInjector = hydrationFeeInjector, + hydraDxQuoteSharedComputation = hydraDxQuoteSharedComputation, + accountRepository = accountRepository, + coroutineScope = coroutineScope!!, + hydrationPriceConversionFallback = hydrationPriceConversionFallback, + hydrationAcceptedFeeCurrenciesFetcher = hydrationAcceptedFeeCurrenciesFetcher + ) + } + + override suspend fun canPayFeeInNonUtilityToken(customFeeAsset: Chain.Asset): Result { + return hydrationAcceptedFeeCurrenciesFetcher.isAcceptedCurrency(customFeeAsset) + } + + override suspend fun detectFeePaymentFromExtrinsic(extrinsic: SendableExtrinsic): FeePayment { + // Todo Hydration fee support from extrinsic + return NativeFeePayment() + } + + override suspend fun fastLookupCustomFeeCapability(): Result { + return hydrationAcceptedFeeCurrenciesFetcher.fetchAcceptedFeeCurrencies(chain) + .map(::HydrationFastLookupFeeCapability) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/assetHub/AssetConversionFeePayment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/assetHub/AssetConversionFeePayment.kt new file mode 100644 index 0000000..95bb38a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/assetHub/AssetConversionFeePayment.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.types.assetHub + +import android.util.Log +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumberOrNull +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.assetConversionAssetIdType +import io.novafoundation.nova.feature_account_api.data.fee.FeePayment +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFee +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.converter.toMultiLocationOrThrow +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Interior +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.feature_xcm_api.versions.orDefault +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.call.RuntimeCallsApi +import io.novafoundation.nova.runtime.extrinsic.extensions.ChargeAssetTxPayment.Companion.chargeAssetTxPayment +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import java.math.BigInteger + +internal class AssetConversionFeePayment( + private val paymentAsset: Chain.Asset, + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, + private val multiLocationConverter: MultiLocationConverter, + private val xcmVersionDetector: XcmVersionDetector +) : FeePayment { + + override suspend fun modifyExtrinsic(extrinsicBuilder: ExtrinsicBuilder) { + val xcmVersion = detectAssetIdXcmVersion(extrinsicBuilder.runtime) + return extrinsicBuilder.chargeAssetTxPayment(encodableAssetId(xcmVersion)) + } + + override suspend fun convertNativeFee(nativeFee: Fee): Fee { + val quote = multiChainRuntimeCallsApi.forChain(paymentAsset.chainId).convertNativeFee(nativeFee.amount) + requireNotNull(quote) { + Log.e(LOG_TAG, "Quote for ${paymentAsset.symbol} fee was null") + + "Failed to calculate fee in ${paymentAsset.symbol}" + } + + return SubstrateFee(amount = quote, submissionOrigin = nativeFee.submissionOrigin, asset = paymentAsset) + } + + private suspend fun encodableAssetId(xcmVersion: XcmVersion): Any { + return multiLocationConverter.toMultiLocationOrThrow(paymentAsset).toEncodableInstance(xcmVersion) + } + + private fun encodableNativeAssetId(xcmVersion: XcmVersion): Any { + return RelativeMultiLocation( + parents = 1, + interior = Interior.Here + ).toEncodableInstance(xcmVersion) + } + + private suspend fun RuntimeCallsApi.convertNativeFee(amount: BigInteger): BigInteger? { + val xcmVersion = detectAssetIdXcmVersion(runtime) + + return call( + section = "AssetConversionApi", + method = "quote_price_tokens_for_exact_tokens", + arguments = mapOf( + "asset1" to encodableAssetId(xcmVersion), + "asset2" to encodableNativeAssetId(xcmVersion), + "amount" to amount, + "include_fee" to true + ), + returnBinding = ::bindNumberOrNull + ) + } + + private suspend fun detectAssetIdXcmVersion(runtime: RuntimeSnapshot): XcmVersion { + val assetIdType = runtime.metadata.assetConversionAssetIdType() + return xcmVersionDetector.detectMultiLocationVersion(paymentAsset.chainId, assetIdType).orDefault() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/assetHub/AssetHubFastLookupFeeCapability.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/assetHub/AssetHubFastLookupFeeCapability.kt new file mode 100644 index 0000000..9e6b039 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/assetHub/AssetHubFastLookupFeeCapability.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.types.assetHub + +import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability + +class AssetHubFastLookupFeeCapability( + override val nonUtilityFeeCapableTokens: Set, +) : FastLookupCustomFeeCapability diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/assetHub/AssetHubFeePaymentFetcher.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/assetHub/AssetHubFeePaymentFetcher.kt new file mode 100644 index 0000000..f8094ba --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/assetHub/AssetHubFeePaymentFetcher.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.types.assetHub + +import io.novafoundation.nova.common.utils.mapNotNullToSet +import io.novafoundation.nova.feature_account_api.data.conversion.assethub.assetConversionOrNull +import io.novafoundation.nova.feature_account_api.data.conversion.assethub.pools +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.common.utils.metadata + +interface AssetHubFeePaymentAssetsFetcher { + + suspend fun fetchAvailablePaymentAssets(): Set +} + +class AssetHubFeePaymentAssetsFetcherFactory( + private val remoteStorageSource: StorageDataSource, + private val multiLocationConverterFactory: MultiLocationConverterFactory +) { + + suspend fun create(chain: Chain): AssetHubFeePaymentAssetsFetcher { + val multiLocationConverter = multiLocationConverterFactory.defaultSync(chain) + + return RealAssetHubFeePaymentAssetsFetcher(remoteStorageSource, multiLocationConverter, chain) + } + + fun create(chain: Chain, multiLocationConverter: MultiLocationConverter): AssetHubFeePaymentAssetsFetcher { + return RealAssetHubFeePaymentAssetsFetcher(remoteStorageSource, multiLocationConverter, chain) + } +} + +private class RealAssetHubFeePaymentAssetsFetcher( + private val remoteStorageSource: StorageDataSource, + private val multiLocationConverter: MultiLocationConverter, + private val chain: Chain, +) : AssetHubFeePaymentAssetsFetcher { + + override suspend fun fetchAvailablePaymentAssets(): Set { + return remoteStorageSource.query(chain.id) { + val allPools = metadata.assetConversionOrNull?.pools?.keys().orEmpty() + + constructAvailableCustomFeeAssets(allPools) + } + } + + private suspend fun constructAvailableCustomFeeAssets(pools: List>): Set { + return pools.mapNotNullToSet { (firstLocation, secondLocation) -> + val firstAsset = multiLocationConverter.toChainAsset(firstLocation) ?: return@mapNotNullToSet null + if (!firstAsset.isUtilityAsset) return@mapNotNullToSet null + + val secondAsset = multiLocationConverter.toChainAsset(secondLocation) ?: return@mapNotNullToSet null + + secondAsset.id + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/HydraDxQuoteSharedComputation.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/HydraDxQuoteSharedComputation.kt new file mode 100644 index 0000000..65b3e39 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/HydraDxQuoteSharedComputation.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.types.hydra + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.memory.SharedComputation +import io.novafoundation.nova.common.data.memory.SharedFlowCache +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.graph.Graph +import io.novafoundation.nova.common.utils.graph.create +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuotingSubscriptions +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn + +class HydraDxQuoteSharedComputation( + private val computationalCache: ComputationalCache, + private val quotingFactory: HydraDxQuoting.Factory, + private val pathQuoterFactory: PathQuoter.Factory, + private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + private val chainStateRepository: ChainStateRepository, +) : SharedComputation(computationalCache) { + + suspend fun getQuoter( + chain: Chain, + accountId: AccountId, + scope: CoroutineScope + ): PathQuoter { + val key = "HydraDxQuoter:${chain.id}:${accountId.toHexString()}" + + return computationalCache.useCache(key, scope) { + val assetConversion = getSwapQuoting(chain, accountId, scope) + val edges = assetConversion.availableSwapDirections() + val graph = Graph.create(edges) + + pathQuoterFactory.create(flowOf(graph), scope) + } + } + + suspend fun getSwapQuoting( + chain: Chain, + accountId: AccountId, + scope: CoroutineScope + ): SwapQuoting { + val key = "HydraDxAssetConversion:${chain.id}:${accountId.toHexString()}" + + return computationalCache.useCache(key, scope) { + val sharedSubscriptions = RealSwapQuotingSubscriptions(scope) + val host = RealQuotingHost(sharedSubscriptions) + + val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) + + val hydraDxQuoting = quotingFactory.create(chain, host) + + hydraDxQuoting.sync() + hydraDxQuoting.runSubscriptions(accountId, subscriptionBuilder) + .launchIn(this) + + subscriptionBuilder.subscribe(this) + + hydraDxQuoting + } + } + + private class RealQuotingHost( + override val sharedSubscriptions: SwapQuotingSubscriptions, + ) : SwapQuoting.QuotingHost + + private inner class RealSwapQuotingSubscriptions(scope: CoroutineScope) : SwapQuotingSubscriptions { + + private val blockNumberCache = SharedFlowCache(scope) { chainId -> + chainStateRepository.currentRemoteBlockNumberFlow(chainId) + } + + override suspend fun blockNumber(chainId: ChainId): Flow { + return blockNumberCache.getOrCompute(chainId) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/HydrationConversionFeePayment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/HydrationConversionFeePayment.kt new file mode 100644 index 0000000..05a5ab7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/HydrationConversionFeePayment.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.types.hydra + +import io.novafoundation.nova.feature_account_api.data.fee.FeePayment +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector.ResetMode +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector.SetFeesMode +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector.SetMode +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.quote +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.CoroutineScope + +internal class HydrationConversionFeePayment( + private val paymentAsset: Chain.Asset, + private val chainRegistry: ChainRegistry, + private val hydrationFeeInjector: HydrationFeeInjector, + private val hydraDxQuoteSharedComputation: HydraDxQuoteSharedComputation, + private val hydrationPriceConversionFallback: HydrationPriceConversionFallback, + private val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher, + private val accountRepository: AccountRepository, + private val coroutineScope: CoroutineScope +) : FeePayment { + + override suspend fun modifyExtrinsic(extrinsicBuilder: ExtrinsicBuilder) { + val setFeesMode = SetFeesMode( + setMode = SetMode.Always, + resetMode = ResetMode.ToNative + ) + hydrationFeeInjector.setFees(extrinsicBuilder, paymentAsset, setFeesMode) + } + + override suspend fun convertNativeFee(nativeFee: Fee): Fee { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = chainRegistry.getChain(paymentAsset.chainId) + val accountId = metaAccount.requireAccountIdIn(chain) + val fromAsset = chain.commissionAsset + + val quoter = hydraDxQuoteSharedComputation.getQuoter(chain, accountId, coroutineScope) + + val convertedAmount = runCatching { + quoter.findBestPath( + chainAssetIn = fromAsset, + chainAssetOut = paymentAsset, + amount = nativeFee.amount, + swapDirection = SwapDirection.SPECIFIED_IN + ).bestPath.quote + } + .recoverCatching { hydrationPriceConversionFallback.convertNativeAmount(nativeFee.amount, paymentAsset) } + .getOrThrow() + + return SubstrateFee( + amount = convertedAmount, + submissionOrigin = nativeFee.submissionOrigin, + asset = paymentAsset + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/HydrationFastLookupFeeCapability.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/HydrationFastLookupFeeCapability.kt new file mode 100644 index 0000000..bf63e72 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/HydrationFastLookupFeeCapability.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.types.hydra + +import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId + +class HydrationFastLookupFeeCapability( + override val nonUtilityFeeCapableTokens: Set +) : FastLookupCustomFeeCapability diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/RealHydrationFeeInjector.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/RealHydrationFeeInjector.kt new file mode 100644 index 0000000..c15c77a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/fee/types/hydra/RealHydrationFeeInjector.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.feature_account_impl.data.fee.types.hydra + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.network.toOnChainIdOrThrow +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call + +internal class RealHydrationFeeInjector( + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, +) : HydrationFeeInjector { + + override suspend fun setFees( + extrinsicBuilder: ExtrinsicBuilder, + paymentAsset: Chain.Asset, + mode: HydrationFeeInjector.SetFeesMode + ) { + val baseCalls = extrinsicBuilder.getCalls() + extrinsicBuilder.resetCalls() + + val justSetFees = getSetPhase(mode.setMode).setFees(extrinsicBuilder, paymentAsset) + extrinsicBuilder.addCalls(baseCalls) + getResetPhase(mode.resetMode).resetFees(extrinsicBuilder, justSetFees) + } + + private fun getSetPhase(mode: HydrationFeeInjector.SetMode): SetPhase { + return when (mode) { + HydrationFeeInjector.SetMode.Always -> AlwaysSetPhase() + is HydrationFeeInjector.SetMode.Lazy -> LazySetPhase(mode.currentlySetFeeAsset) + } + } + + private fun getResetPhase(mode: HydrationFeeInjector.ResetMode): ResetPhase { + return when (mode) { + HydrationFeeInjector.ResetMode.ToNative -> AlwaysResetPhase() + is HydrationFeeInjector.ResetMode.ToNativeLazily -> LazyResetPhase(mode.feeAssetBeforeTransaction) + } + } + + private interface SetPhase { + + /** + * @return just set on-chain asset id, if changed + */ + suspend fun setFees(extrinsicBuilder: ExtrinsicBuilder, paymentAsset: Chain.Asset): HydraDxAssetId? + } + + private interface ResetPhase { + + suspend fun resetFees( + extrinsicBuilder: ExtrinsicBuilder, + feesModifiedInSetPhase: HydraDxAssetId? + ) + } + + private inner class AlwaysSetPhase : SetPhase { + + override suspend fun setFees(extrinsicBuilder: ExtrinsicBuilder, paymentAsset: Chain.Asset): HydraDxAssetId { + val onChainId = hydraDxAssetIdConverter.toOnChainIdOrThrow(paymentAsset) + extrinsicBuilder.setFeeCurrency(onChainId) + return onChainId + } + } + + private inner class LazySetPhase( + private val currentFeeTokenId: HydraDxAssetId, + ) : SetPhase { + + override suspend fun setFees(extrinsicBuilder: ExtrinsicBuilder, paymentAsset: Chain.Asset): HydraDxAssetId? { + val paymentCurrencyToSet = getPaymentCurrencyToSetIfNeeded(paymentAsset) + + paymentCurrencyToSet?.let { + extrinsicBuilder.setFeeCurrency(paymentCurrencyToSet) + } + + return paymentCurrencyToSet + } + + private suspend fun getPaymentCurrencyToSetIfNeeded(expectedPaymentAsset: Chain.Asset): HydraDxAssetId? { + val expectedPaymentTokenId = hydraDxAssetIdConverter.toOnChainIdOrThrow(expectedPaymentAsset) + + return expectedPaymentTokenId.takeIf { currentFeeTokenId != expectedPaymentTokenId } + } + } + + private inner class AlwaysResetPhase : ResetPhase { + + override suspend fun resetFees( + extrinsicBuilder: ExtrinsicBuilder, + feesModifiedInSetPhase: HydraDxAssetId? + ) { + extrinsicBuilder.setFeeCurrency(hydraDxAssetIdConverter.systemAssetId) + } + } + + private inner class LazyResetPhase( + private val previousFeeCurrency: HydraDxAssetId + ) : ResetPhase { + + override suspend fun resetFees(extrinsicBuilder: ExtrinsicBuilder, feesModifiedInSetPhase: HydraDxAssetId?) { + val justSetFeeToNonNative = feesModifiedInSetPhase != null && feesModifiedInSetPhase != hydraDxAssetIdConverter.systemAssetId + val previousCurrencyRemainsNonNative = feesModifiedInSetPhase == null && previousFeeCurrency != hydraDxAssetIdConverter.systemAssetId + + if (justSetFeeToNonNative || previousCurrencyRemainsNonNative) { + extrinsicBuilder.setFeeCurrency(hydraDxAssetIdConverter.systemAssetId) + } + } + } + + private fun ExtrinsicBuilder.setFeeCurrency(onChainId: HydraDxAssetId) { + call( + moduleName = Modules.MULTI_TRANSACTION_PAYMENT, + callName = "set_currency", + arguments = mapOf( + "currency" to onChainId + ) + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt new file mode 100644 index 0000000..e4bd53b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt @@ -0,0 +1,207 @@ +package io.novafoundation.nova.feature_account_impl.data.mappers + +import android.util.Log +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.JoinedMetaAccountInfo +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MultisigTypeExtras +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.feature_account_impl.domain.account.model.DefaultMetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.GenericLedgerMetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.LegacyLedgerMetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.PolkadotVaultMetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.RealMultisigMetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.RealProxiedMetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.RealSecretsMetaAccount +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class AccountMappers( + private val ledgerMigrationTracker: LedgerMigrationTracker, + private val gson: Gson, + private val multisigRepository: MultisigRepository +) { + + suspend fun mapMetaAccountsLocalToMetaAccounts(joinedMetaAccountInfo: List): List { + val supportedGenericLedgerChains = ledgerMigrationTracker.supportedChainIdsByGenericApp() + + return joinedMetaAccountInfo.mapNotNull { + mapMetaAccountLocalToMetaAccount(it) { supportedGenericLedgerChains } + } + } + + suspend fun mapMetaAccountLocalToMetaAccount(joinedMetaAccountInfo: JoinedMetaAccountInfo): MetaAccount { + return mapMetaAccountLocalToMetaAccount(joinedMetaAccountInfo) { + ledgerMigrationTracker.supportedChainIdsByGenericApp() + }!! + } + + private suspend fun mapMetaAccountLocalToMetaAccount( + joinedMetaAccountInfo: JoinedMetaAccountInfo, + supportedGenericLedgerChains: suspend () -> Set + ): MetaAccount? { + val chainAccounts = joinedMetaAccountInfo.chainAccounts.associateBy( + keySelector = ChainAccountLocal::chainId, + valueTransform = { + mapChainAccountFromLocal(it) + } + ).filterNotNull() + + return with(joinedMetaAccountInfo.metaAccount) { + when (val type = mapMetaAccountTypeFromLocal(type)) { + LightMetaAccount.Type.SECRETS -> RealSecretsMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + chainAccounts = chainAccounts, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + status = mapMetaAccountStateFromLocal(status), + parentMetaId = parentMetaId + ) + + LightMetaAccount.Type.WATCH_ONLY -> DefaultMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + chainAccounts = chainAccounts, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = type, + status = mapMetaAccountStateFromLocal(status), + parentMetaId = parentMetaId + ) + + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.POLKADOT_VAULT -> PolkadotVaultMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + chainAccounts = chainAccounts, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = type, + status = mapMetaAccountStateFromLocal(status), + parentMetaId = parentMetaId + ) + + LightMetaAccount.Type.LEDGER -> GenericLedgerMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + chainAccounts = chainAccounts, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = type, + status = mapMetaAccountStateFromLocal(status), + supportedGenericLedgerChains = supportedGenericLedgerChains(), + parentMetaId = parentMetaId + ) + + LightMetaAccount.Type.LEDGER_LEGACY -> LegacyLedgerMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + chainAccounts = chainAccounts, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = type, + status = mapMetaAccountStateFromLocal(status), + parentMetaId = parentMetaId + ) + + LightMetaAccount.Type.PROXIED -> { + val proxyAccount = joinedMetaAccountInfo.proxyAccountLocal?.let { + mapProxyAccountFromLocal(it) + } + + RealProxiedMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + chainAccounts = chainAccounts, + proxy = proxyAccount ?: run { + Log.e("Proxy", "Null proxy account for proxied $id ($name)") + return null + }, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + status = mapMetaAccountStateFromLocal(status), + parentMetaId = parentMetaId + ) + } + + LightMetaAccount.Type.MULTISIG -> { + val multisigTypeExtras = gson.fromJson(requireNotNull(typeExtras) { "typeExtras is null: $id" }) + + RealMultisigMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + chainAccounts = chainAccounts, + isSelected = isSelected, + name = name, + status = mapMetaAccountStateFromLocal(status), + signatoryMetaId = requireNotNull(parentMetaId) { "parentMetaId is null: $id" }, + otherSignatoriesUnsorted = multisigTypeExtras.otherSignatories, + threshold = multisigTypeExtras.threshold, + signatoryAccountId = multisigTypeExtras.signatoryAccountId, + parentMetaId = parentMetaId, + multisigRepository = multisigRepository + ) + } + } + } + } + + fun mapMetaAccountTypeFromLocal(local: MetaAccountLocal.Type): LightMetaAccount.Type { + return when (local) { + MetaAccountLocal.Type.SECRETS -> LightMetaAccount.Type.SECRETS + MetaAccountLocal.Type.WATCH_ONLY -> LightMetaAccount.Type.WATCH_ONLY + MetaAccountLocal.Type.PARITY_SIGNER -> LightMetaAccount.Type.PARITY_SIGNER + MetaAccountLocal.Type.LEDGER -> LightMetaAccount.Type.LEDGER_LEGACY + MetaAccountLocal.Type.LEDGER_GENERIC -> LightMetaAccount.Type.LEDGER + MetaAccountLocal.Type.POLKADOT_VAULT -> LightMetaAccount.Type.POLKADOT_VAULT + MetaAccountLocal.Type.PROXIED -> LightMetaAccount.Type.PROXIED + MetaAccountLocal.Type.MULTISIG -> LightMetaAccount.Type.MULTISIG + } + } + + private fun mapMetaAccountStateFromLocal(local: MetaAccountLocal.Status): LightMetaAccount.Status { + return when (local) { + MetaAccountLocal.Status.ACTIVE -> LightMetaAccount.Status.ACTIVE + MetaAccountLocal.Status.DEACTIVATED -> LightMetaAccount.Status.DEACTIVATED + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/Mappers.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/Mappers.kt new file mode 100644 index 0000000..12086f8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/Mappers.kt @@ -0,0 +1,168 @@ +package io.novafoundation.nova.feature_account_impl.data.mappers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.asPrecision +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.core.model.Node.NetworkType +import io.novafoundation.nova.core_db.dao.MetaAccountWithBalanceLocal +import io.novafoundation.nova.core_db.model.NodeLocal +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.ProxyAccountLocal +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountAssetBalance +import io.novafoundation.nova.feature_account_api.domain.model.ProxyAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api.AccountNameChooserMixin +import io.novafoundation.nova.feature_account_impl.presentation.node.model.NodeModel +import io.novafoundation.nova.feature_account_impl.presentation.view.advanced.encryption.model.CryptoTypeModel +import io.novafoundation.nova.feature_account_impl.presentation.view.advanced.network.model.NetworkModel +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.feature_proxy_api.domain.model.fromString + +fun mapNetworkTypeToNetworkModel(networkType: NetworkType): NetworkModel { + val type = when (networkType) { + NetworkType.KUSAMA -> NetworkModel.NetworkTypeUI.Kusama + NetworkType.POLKADOT -> NetworkModel.NetworkTypeUI.Polkadot + NetworkType.WESTEND -> NetworkModel.NetworkTypeUI.Westend + NetworkType.ROCOCO -> NetworkModel.NetworkTypeUI.Rococo + } + + return NetworkModel(networkType.readableName, type) +} + +fun mapCryptoTypeToCryptoTypeModel( + resourceManager: ResourceManager, + encryptionType: CryptoType +): CryptoTypeModel { + val title = mapCryptoTypeToCryptoTypeTitle(resourceManager, encryptionType) + val subtitle = mapCryptoTypeToCryptoTypeSubtitle(resourceManager, encryptionType) + + return CryptoTypeModel("$title $subtitle", encryptionType) +} + +fun mapCryptoTypeToCryptoTypeTitle( + resourceManager: ResourceManager, + encryptionType: CryptoType +): String { + return when (encryptionType) { + CryptoType.SR25519 -> resourceManager.getString(R.string.sr25519_selection_title) + + CryptoType.ED25519 -> resourceManager.getString(R.string.ed25519_selection_title) + + CryptoType.ECDSA -> resourceManager.getString(R.string.ecdsa_selection_title) + } +} + +fun mapCryptoTypeToCryptoTypeSubtitle( + resourceManager: ResourceManager, + encryptionType: CryptoType +): String { + return when (encryptionType) { + CryptoType.SR25519 -> resourceManager.getString(R.string.sr25519_selection_subtitle) + + CryptoType.ED25519 -> resourceManager.getString(R.string.ed25519_selection_subtitle) + + CryptoType.ECDSA -> resourceManager.getString(R.string.ecdsa_selection_subtitle) + } +} + +fun mapNodeToNodeModel(node: Node): NodeModel { + val networkModelType = mapNetworkTypeToNetworkModel(node.networkType) + + return with(node) { + NodeModel( + id = id, + name = name, + link = link, + networkModelType = networkModelType.networkTypeUI, + isDefault = isDefault, + isActive = isActive + ) + } +} + +fun mapNodeLocalToNode(nodeLocal: NodeLocal): Node { + return with(nodeLocal) { + Node( + id = id, + name = name, + networkType = NetworkType.values()[nodeLocal.networkType], + link = link, + isActive = isActive, + isDefault = isDefault + ) + } +} + +fun mapMetaAccountTypeToLocal(local: LightMetaAccount.Type): MetaAccountLocal.Type { + return when (local) { + LightMetaAccount.Type.SECRETS -> MetaAccountLocal.Type.SECRETS + LightMetaAccount.Type.WATCH_ONLY -> MetaAccountLocal.Type.WATCH_ONLY + LightMetaAccount.Type.PARITY_SIGNER -> MetaAccountLocal.Type.PARITY_SIGNER + LightMetaAccount.Type.LEDGER_LEGACY -> MetaAccountLocal.Type.LEDGER + LightMetaAccount.Type.LEDGER -> MetaAccountLocal.Type.LEDGER_GENERIC + LightMetaAccount.Type.POLKADOT_VAULT -> MetaAccountLocal.Type.POLKADOT_VAULT + LightMetaAccount.Type.PROXIED -> MetaAccountLocal.Type.PROXIED + LightMetaAccount.Type.MULTISIG -> MetaAccountLocal.Type.MULTISIG + } +} + +fun mapMetaAccountWithBalanceFromLocal(local: MetaAccountWithBalanceLocal): MetaAccountAssetBalance { + return with(local) { + MetaAccountAssetBalance( + metaId = id, + freeInPlanks = freeInPlanks, + reservedInPlanks = reservedInPlanks, + offChainBalance = offChainBalance, + precision = precision.asPrecision(), + rate = rate, + ) + } +} + +fun mapChainAccountFromLocal(chainAccountLocal: ChainAccountLocal): MetaAccount.ChainAccount { + return with(chainAccountLocal) { + MetaAccount.ChainAccount( + metaId = metaId, + publicKey = publicKey, + chainId = chainId, + accountId = accountId, + cryptoType = cryptoType + ) + } +} + +fun mapProxyAccountFromLocal(proxyAccountLocal: ProxyAccountLocal): ProxyAccount { + return with(proxyAccountLocal) { + ProxyAccount( + proxyMetaId = proxyMetaId, + chainId = chainId, + proxyType = ProxyType.fromString(proxyType) + ) + } +} + +fun mapAddAccountPayloadToAddAccountType( + payload: AddAccountPayload, + accountNameState: AccountNameChooserMixin.State, +): AddAccountType { + return when (payload) { + AddAccountPayload.MetaAccount -> { + require(accountNameState is AccountNameChooserMixin.State.Input) { "Name input should be present for meta account" } + + AddAccountType.MetaAccount(accountNameState.value) + } + + is AddAccountPayload.ChainAccount -> AddAccountType.ChainAccount(payload.chainId, payload.metaId) + } +} + +fun mapOptionalNameToNameChooserState(name: String?) = when (name) { + null -> AccountNameChooserMixin.State.NoInput + else -> AccountNameChooserMixin.State.Input(name) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/MultisigRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/MultisigRepository.kt new file mode 100644 index 0000000..4cd17cc --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/MultisigRepository.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.model.OnChainMultisig +import io.novafoundation.nova.feature_account_impl.data.multisig.model.DiscoveredMultisig +import io.novafoundation.nova.feature_account_impl.data.multisig.model.OffChainPendingMultisigOperationInfo +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +interface MultisigRepository { + + fun supportsMultisigSync(chain: Chain): Boolean + + suspend fun findMultisigAccounts(accountIds: Set): List + + suspend fun getPendingOperationIds(chain: Chain, accountIdKey: AccountIdKey): Set + + suspend fun subscribePendingOperations( + chain: Chain, + accountIdKey: AccountIdKey, + operationIds: Collection + ): Flow> + + suspend fun getOffChainPendingOperationsInfo( + chain: Chain, + accountId: AccountIdKey, + pendingCallHashes: Collection + ): Result> +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/RealMultisigDetailsRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/RealMultisigDetailsRepository.kt new file mode 100644 index 0000000..16e8cd1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/RealMultisigDetailsRepository.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.model.OnChainMultisig +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.multisig +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.multisigs +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Inject +import javax.inject.Named + +@FeatureScope +class RealMultisigDetailsRepository @Inject constructor( + @Named(REMOTE_STORAGE_SOURCE) private val remoteStorageSource: StorageDataSource +) : MultisigDetailsRepository { + + override suspend fun hasMultisigOperation(chain: Chain, accountIdKey: AccountIdKey, callHash: CallHash): Boolean { + return getOnChainMultisig(chain, accountIdKey, callHash) != null + } + + private suspend fun getOnChainMultisig(chain: Chain, accountIdKey: AccountIdKey, operationId: CallHash): OnChainMultisig? { + return remoteStorageSource.query(chain.id) { + runtime.metadata.multisig.multisigs.query(accountIdKey, operationId) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/RealMultisigRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/RealMultisigRepository.kt new file mode 100644 index 0000000..20631d7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/RealMultisigRepository.kt @@ -0,0 +1,155 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.fromHexOrNull +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.config.GlobalConfigDataSource +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.HexString +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.callHash +import io.novafoundation.nova.common.utils.fromHex +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.feature_account_impl.data.multisig.api.FindMultisigsApi +import io.novafoundation.nova.feature_account_impl.data.multisig.api.request.FindMultisigsRequest +import io.novafoundation.nova.feature_account_impl.data.multisig.api.request.OffChainPendingMultisigInfoRequest +import io.novafoundation.nova.feature_account_impl.data.multisig.api.response.AccountMultisigRemote +import io.novafoundation.nova.feature_account_impl.data.multisig.api.response.FindMultisigsResponse +import io.novafoundation.nova.feature_account_impl.data.multisig.api.response.GetPedingMultisigOperationsResponse +import io.novafoundation.nova.feature_account_impl.data.multisig.api.response.GetPedingMultisigOperationsResponse.OperationRemote +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.model.OnChainMultisig +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.multisig +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.multisigs +import io.novafoundation.nova.feature_account_impl.data.multisig.model.DiscoveredMultisig +import io.novafoundation.nova.feature_account_impl.data.multisig.model.OffChainPendingMultisigOperationInfo +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Named +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@FeatureScope +class RealMultisigRepository @Inject constructor( + private val api: FindMultisigsApi, + @Named(REMOTE_STORAGE_SOURCE) + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val globalConfigDataSource: GlobalConfigDataSource +) : MultisigRepository { + + override fun supportsMultisigSync(chain: Chain): Boolean { + return chain.multisigSupport + } + + override suspend fun findMultisigAccounts(accountIds: Set): List { + val globalConfig = globalConfigDataSource.getGlobalConfig() + val request = FindMultisigsRequest(accountIds) + return api.findMultisigs(globalConfig.multisigsApiUrl, request).toDiscoveredMultisigs() + } + + override suspend fun getPendingOperationIds(chain: Chain, accountIdKey: AccountIdKey): Set { + return remoteStorageSource.query(chain.id) { + runtime.metadata.multisig.multisigs.keys(accountIdKey) + .mapToSet { it.second } + } + } + + override suspend fun subscribePendingOperations( + chain: Chain, + accountIdKey: AccountIdKey, + operationIds: Collection + ): Flow> { + return remoteStorageSource.subscribeBatched(chain.id) { + val allKeys = operationIds.map { accountIdKey to it } + + runtime.metadata.multisig.multisigs.observe(allKeys).map { operationsByKeys -> + operationsByKeys.mapKeys { (key, _) -> key.second } + } + } + } + + override suspend fun getOffChainPendingOperationsInfo( + chain: Chain, + accountId: AccountIdKey, + pendingCallHashes: Collection + ): Result> { + return runCatching { + val globalConfig = globalConfigDataSource.getGlobalConfig() + + val request = OffChainPendingMultisigInfoRequest(accountId, pendingCallHashes, chain.id) + val response = api.getCallDatas(globalConfig.multisigsApiUrl, request) + response.toDomain(chain) + } + .onFailure { Log.e("RealMultisigRepository", "Failed to fetch call datas in ${chain.name}", it) } + } + + private suspend fun SubQueryResponse.toDomain(chain: Chain): Map { + return chainRegistry.withRuntime(chain.id) { + data.multisigOperations.nodes.mapNotNull { multisigOperation -> + val callHash = CallHash.fromHexOrNull(multisigOperation.callHash) ?: return@mapNotNull null + val callData = parseCallData(multisigOperation.callData, callHash, chain) + + OffChainPendingMultisigOperationInfo( + timestamp = multisigOperation.timestamp(), + callData = callData, + callHash = callHash + ) + } + .associateBy { it.callHash } + } + } + + private fun OperationRemote.timestamp(): Duration { + val inSeconds = events.nodes.firstOrNull()?.timestamp ?: timestamp + return inSeconds.seconds + } + + context(RuntimeContext) + private fun parseCallData( + callData: HexString?, + callHash: CallHash, + chain: Chain + ): GenericCall.Instance? { + if (callData == null) return null + + return runCatching { + val hashFromCallData = callData.callHash().intoKey() + require(hashFromCallData == callHash) { + "Call-data does not match call hash. Expected hash: $callHash, Actual hash: $hashFromCallData. Call data: $callData" + } + + GenericCall.fromHex(callData) + } + .onFailure { Log.e("RealMultisigRepository", "Failed to decode call data on ${chain.name}: $callData}", it) } + .getOrNull() + } + + private fun SubQueryResponse.toDiscoveredMultisigs(): List { + return data.accountMultisigs.nodes.mapNotNull { multisigNode -> + val multisig = multisigNode.multisig + + DiscoveredMultisig( + accountId = AccountIdKey.fromHexOrNull(multisig.accountId) ?: return@mapNotNull null, + threshold = multisig.thresholdIfValid() ?: return@mapNotNull null, + allSignatories = multisig.signatories.nodes.map { signatoryNode -> + AccountIdKey.fromHexOrNull(signatoryNode.signatoryId) ?: return@mapNotNull null + } + ) + } + } + + private fun AccountMultisigRemote.MultisigRemote.thresholdIfValid(): Int? { + // Just to be sure we do not insert some invalid data + return threshold.takeIf { it >= 1 } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/FindMultisigsApi.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/FindMultisigsApi.kt new file mode 100644 index 0000000..e5421aa --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/FindMultisigsApi.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.api + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.feature_account_impl.data.multisig.api.request.FindMultisigsRequest +import io.novafoundation.nova.feature_account_impl.data.multisig.api.request.OffChainPendingMultisigInfoRequest +import io.novafoundation.nova.feature_account_impl.data.multisig.api.response.FindMultisigsResponse +import io.novafoundation.nova.feature_account_impl.data.multisig.api.response.GetPedingMultisigOperationsResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface FindMultisigsApi { + + @POST + suspend fun findMultisigs( + @Url url: String, + @Body body: FindMultisigsRequest + ): SubQueryResponse + + @POST + suspend fun getCallDatas( + @Url url: String, + @Body body: OffChainPendingMultisigInfoRequest + ): SubQueryResponse +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/request/FindMultisigsRequest.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/request/FindMultisigsRequest.kt new file mode 100644 index 0000000..76acfa6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/request/FindMultisigsRequest.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.api.request + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.toHexWithPrefix +import io.novafoundation.nova.common.data.network.subquery.SubQueryFilters + +class FindMultisigsRequest( + accountIds: Set +) : SubQueryFilters { + @Transient + private val accountIdsHex = accountIds.map { it.toHexWithPrefix() } + + val query = """ + query { + accountMultisigs( + filter: { + signatory: { + ${"id" presentIn accountIdsHex} + } + } + ) { + nodes { + multisig { + threshold + signatories { + nodes { + signatoryId + } + } + accountId + } + } + } + } + """.trimIndent() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/request/OffChainPendingMultisigInfoRequest.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/request/OffChainPendingMultisigInfoRequest.kt new file mode 100644 index 0000000..cb94647 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/request/OffChainPendingMultisigInfoRequest.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.api.request + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.toHexWithPrefix +import io.novafoundation.nova.common.data.network.subquery.SubQueryFilters +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.requireHexPrefix + +class OffChainPendingMultisigInfoRequest( + accountIdKey: AccountIdKey, + callHashes: Collection, + chainId: ChainId +) : SubQueryFilters { + + @Transient + private val callHashesHex = callHashes.map { it.toHexWithPrefix() } + + val query = """ + query { + multisigOperations(filter: { + ${"accountId" equalTo accountIdKey.toHexWithPrefix() } + ${"status" equalToEnum "pending"} + ${"callHash" presentIn callHashesHex} + ${"chainId" equalTo chainId.requireHexPrefix()} + }) { + nodes { + callHash + callData + timestamp + events(last: 1) { + nodes { + timestamp + } + } + } + } + } + """.trimIndent() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/response/FindMultisigsResponse.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/response/FindMultisigsResponse.kt new file mode 100644 index 0000000..3ff8f3c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/response/FindMultisigsResponse.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.api.response + +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import io.novafoundation.nova.common.utils.HexString + +class FindMultisigsResponse( + val accountMultisigs: SubQueryNodes +) + +class AccountMultisigRemote( + val multisig: MultisigRemote, +) { + + class MultisigRemote( + val accountId: HexString, + val threshold: Int, + val signatories: SubQueryNodes + ) + + class SignatoryRemote( + val signatoryId: HexString + ) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/response/GetPedingMultisigOperationsResponse.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/response/GetPedingMultisigOperationsResponse.kt new file mode 100644 index 0000000..a6360b5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/api/response/GetPedingMultisigOperationsResponse.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.api.response + +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import io.novafoundation.nova.common.utils.HexString + +class GetPedingMultisigOperationsResponse(val multisigOperations: SubQueryNodes) { + + class OperationRemote(val callHash: HexString, val callData: HexString?, val timestamp: Long, val events: SubQueryNodes) + + class OperationEvent(val timestamp: Long) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/blockhain/MultisigRuntimeApi.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/blockhain/MultisigRuntimeApi.kt new file mode 100644 index 0000000..2f29fa5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/blockhain/MultisigRuntimeApi.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.blockhain + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.multisig +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.model.OnChainMultisig +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry2 +import io.novafoundation.nova.runtime.storage.source.query.api.converters.scaleDecoder +import io.novafoundation.nova.runtime.storage.source.query.api.converters.scaleEncoder +import io.novafoundation.nova.runtime.storage.source.query.api.storage2 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class MultisigRuntimeApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.multisig: MultisigRuntimeApi + get() = MultisigRuntimeApi(multisig()) + +context(StorageQueryContext) +val MultisigRuntimeApi.multisigs: QueryableStorageEntry2 + get() = storage2( + name = "Multisigs", + binding = { decoded, _, callHash -> OnChainMultisig.bind(decoded, callHash) }, + key1ToInternalConverter = AccountIdKey.scaleEncoder, + key1FromInternalConverter = AccountIdKey.scaleDecoder, + key2ToInternalConverter = CallHash.scaleEncoder, + key2FromInternalConverter = CallHash.scaleDecoder + ) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/blockhain/model/OnChainMultisig.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/blockhain/model/OnChainMultisig.kt new file mode 100644 index 0000000..583f7d0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/blockhain/model/OnChainMultisig.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigTimePoint +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import java.math.BigInteger + +class OnChainMultisig( + val callHash: CallHash, + val approvals: List, + val deposit: BigInteger, + val depositor: AccountIdKey, + val timePoint: MultisigTimePoint, +) { + + companion object { + + fun bind(decoded: Any?, callHash: CallHash): OnChainMultisig { + val struct = decoded.castToStruct() + + return OnChainMultisig( + callHash = callHash, + approvals = bindList(struct["approvals"], ::bindAccountIdKey), + deposit = bindNumber(struct["deposit"]), + depositor = bindAccountIdKey(struct["depositor"]), + timePoint = MultisigTimePoint.bind(struct["when"]) + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/model/DiscoveredMultisig.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/model/DiscoveredMultisig.kt new file mode 100644 index 0000000..9e01367 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/model/DiscoveredMultisig.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.model + +import io.novafoundation.nova.common.address.AccountIdKey + +class DiscoveredMultisig( + val accountId: AccountIdKey, + val allSignatories: List, + val threshold: Int, +) + +fun DiscoveredMultisig.otherSignatories(signatory: AccountIdKey): List { + return allSignatories - signatory +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/model/OffChainPendingMultisigOperationInfo.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/model/OffChainPendingMultisigOperationInfo.kt new file mode 100644 index 0000000..3dee729 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/model/OffChainPendingMultisigOperationInfo.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.model + +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlin.time.Duration + +class OffChainPendingMultisigOperationInfo( + val timestamp: Duration, + val callHash: CallHash, + val callData: GenericCall.Instance? +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/repository/RealMultisigOperationLocalCallRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/repository/RealMultisigOperationLocalCallRepository.kt new file mode 100644 index 0000000..b8b7a29 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/repository/RealMultisigOperationLocalCallRepository.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.repository + +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.MultisigOperationsDao +import io.novafoundation.nova.core_db.model.MultisigOperationCallLocal +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository +import io.novafoundation.nova.feature_account_api.domain.model.SavedMultisigOperationCall +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.flow.Flow + +class RealMultisigOperationLocalCallRepository( + private val multisigOperationsDao: MultisigOperationsDao +) : MultisigOperationLocalCallRepository { + + override suspend fun setMultisigCall(operation: SavedMultisigOperationCall) { + multisigOperationsDao.insertOperation( + MultisigOperationCallLocal( + chainId = operation.chainId, + metaId = operation.metaId, + callHash = operation.callHash.toHexString(), + callInstance = operation.callInstance + ) + ) + } + + override fun callsFlow(): Flow> { + return multisigOperationsDao.observeOperations() + .mapList { + SavedMultisigOperationCall( + metaId = it.metaId, + chainId = it.chainId, + callHash = it.callHash.fromHex(), + callInstance = it.callInstance, + ) + } + } + + override suspend fun removeCallHashesExclude(metaId: Long, chainId: ChainId, excludedCallHashes: Set) { + multisigOperationsDao.removeOperationsExclude( + metaId, + chainId, + excludedCallHashes.map { it.value.toHexString() } + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/repository/RealMultisigValidationsRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/repository/RealMultisigValidationsRepository.kt new file mode 100644 index 0000000..337283c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/multisig/repository/RealMultisigValidationsRepository.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_impl.data.multisig.repository + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.multisig +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.multisig +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.multisigs +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Inject +import javax.inject.Named + +@FeatureScope +class RealMultisigValidationsRepository @Inject constructor( + private val chainRegistry: ChainRegistry, + @Named(REMOTE_STORAGE_SOURCE) private val storageDataSource: StorageDataSource +) : MultisigValidationsRepository { + + override suspend fun getMultisigDepositBase(chainId: ChainId): BalanceOf { + return chainRegistry.withRuntime(chainId) { + metadata.multisig().numberConstant("DepositBase") + } + } + + override suspend fun getMultisigDepositFactor(chainId: ChainId): BalanceOf { + return chainRegistry.withRuntime(chainId) { + metadata.multisig().numberConstant("DepositFactor") + } + } + + override suspend fun hasPendingCallHash(chainId: ChainId, accountIdKey: AccountIdKey, callHash: CallHash): Boolean { + val pendingCallHash = storageDataSource.query(chainId) { + metadata.multisig.multisigs.query(accountIdKey, callHash) + } + + return pendingCallHash != null + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/network/blockchain/AccountSubstrateSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/network/blockchain/AccountSubstrateSource.kt new file mode 100644 index 0000000..bf118ab --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/network/blockchain/AccountSubstrateSource.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_impl.data.network.blockchain + +interface AccountSubstrateSource { + + /** + * @throws NovaException + */ + suspend fun getNodeNetworkType(nodeHost: String): String +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/network/blockchain/AccountSubstrateSourceImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/network/blockchain/AccountSubstrateSourceImpl.kt new file mode 100644 index 0000000..8a891ff --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/network/blockchain/AccountSubstrateSourceImpl.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_account_impl.data.network.blockchain + +import io.novafoundation.nova.common.data.network.rpc.SocketSingleRequestExecutor +import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull +import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo +import io.novasama.substrate_sdk_android.wsrpc.request.base.RpcRequest +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.system.NodeNetworkTypeRequest + +class AccountSubstrateSourceImpl( + private val socketRequestExecutor: SocketSingleRequestExecutor +) : AccountSubstrateSource { + + override suspend fun getNodeNetworkType(nodeHost: String): String { + val request = NodeNetworkTypeRequest() + + return socketRequestExecutor.executeRequest(RpcRequest.Rpc2(request), nodeHost, pojo().nonNull()) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/network/blockchain/bindings/Identity.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/network/blockchain/bindings/Identity.kt new file mode 100644 index 0000000..41c8c35 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/network/blockchain/bindings/Identity.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_account_impl.data.network.blockchain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindData +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.castToStructOrNull +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.utils.second +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_account_api.data.model.RootIdentity +import io.novafoundation.nova.feature_account_api.data.model.SuperOf +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct + +@UseCaseBinding +fun bindIdentity(dynamic: Any?): OnChainIdentity? { + if (dynamic == null) return null + + val decoded = dynamic.castIdentityLegacy() ?: dynamic.castToIdentity() + + val identityInfo = decoded.get("info") ?: incompatible() + + val pgpFingerprint = identityInfo.get("pgpFingerprint") + + val matrix = bindIdentityData(identityInfo, "riot", onIncompatibleField = null) + ?: bindIdentityData(identityInfo, "matrix", onIncompatibleField = null) + + return RootIdentity( + display = bindIdentityData(identityInfo, "display"), + legal = bindIdentityData(identityInfo, "legal"), + web = bindIdentityData(identityInfo, "web"), + matrix = matrix, + email = bindIdentityData(identityInfo, "email"), + pgpFingerprint = pgpFingerprint?.toHexString(withPrefix = true), + image = bindIdentityData(identityInfo, "image"), + twitter = bindIdentityData(identityInfo, "twitter") + ) +} + +private fun Any?.castIdentityLegacy(): Struct.Instance? { + return this.castToStructOrNull() +} + +private fun Any?.castToIdentity(): Struct.Instance { + return this.castToList() + .first() + .castToStruct() +} + +@UseCaseBinding +fun bindSuperOf(decoded: Any?): SuperOf? { + if (decoded == null) return null + + val asList = decoded.castToList() + + val parentId: ByteArray = asList.first().cast() + + return SuperOf( + parentId = parentId, + childName = bindData(asList.second()).asString() + ) +} + +@HelperBinding +fun bindIdentityData( + identityInfo: Struct.Instance, + field: String, + onIncompatibleField: (() -> Unit)? = { incompatible() } +): String? { + val value = identityInfo.get(field) + ?: onIncompatibleField?.invoke() + ?: return null + + return bindData(value).asString() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/RealMetaAccountsUpdatesRegistry.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/RealMetaAccountsUpdatesRegistry.kt new file mode 100644 index 0000000..f6a01f8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/RealMetaAccountsUpdatesRegistry.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_account_impl.data.proxy + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.zipWithPrevious +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map + +class RealMetaAccountsUpdatesRegistry( + private val preferences: Preferences +) : MetaAccountsUpdatesRegistry { + + private val KEY = "meta_accounts_changes" + + override fun addMetaIds(ids: List) { + val metaIdsSet = getUpdates() + .toMutableSet() + metaIdsSet.addAll(ids) + val metaIdsJoinedToString = metaIdsSet.joinToString(",") + if (metaIdsJoinedToString.isNotEmpty()) { + preferences.putString(KEY, metaIdsJoinedToString) + } + } + + override fun observeUpdates(): Flow> { + return preferences.keyFlow(KEY) + .map { getUpdates() } + } + + override fun getUpdates(): Set { + val metaIds = preferences.getString(KEY) + if (metaIds.isNullOrEmpty()) return mutableSetOf() + + return metaIds.split(",") + .map { it.toLong() } + .toMutableSet() + } + + override fun remove(ids: List) { + val metaIdsSet = getUpdates() + .toMutableSet() + metaIdsSet.removeAll(ids) + val metaIdsJoinedToString = metaIdsSet.joinToString(",") + + if (metaIdsJoinedToString.isEmpty()) { + preferences.removeField(KEY) + } else { + preferences.putString(KEY, metaIdsJoinedToString) + } + } + + override fun clear() { + preferences.removeField(KEY) + } + + override fun observeUpdatesExist(): Flow { + return preferences.keyFlow(KEY) + .map { hasUpdates() } + } + + override fun observeLastConsumedUpdatesMetaIds(): Flow> { + return observeUpdates().zipWithPrevious() + .filter { (old, new) -> !old.isNullOrEmpty() && new.isEmpty() } // Check if updates was consumed + .map { (old, _) -> old.orEmpty() } // Emmit old ids as consumed updates + .distinctUntilChanged() + } + + override fun hasUpdates(): Boolean { + return preferences.contains(KEY) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/network/api/FindProxiesApi.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/network/api/FindProxiesApi.kt new file mode 100644 index 0000000..09cea4f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/network/api/FindProxiesApi.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_impl.data.proxy.network.api + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.feature_account_impl.data.proxy.network.api.request.FindProxiesRequest +import io.novafoundation.nova.feature_account_impl.data.proxy.network.api.response.FindProxiesResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface FindProxiesApi { + @POST + suspend fun findProxies(@Url url: String, @Body body: FindProxiesRequest): SubQueryResponse +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/network/api/request/FindProxiesRequest.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/network/api/request/FindProxiesRequest.kt new file mode 100644 index 0000000..725c4b3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/network/api/request/FindProxiesRequest.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_impl.data.proxy.network.api.request + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.toHexWithPrefix +import io.novafoundation.nova.common.data.network.subquery.SubQueryFilters + +class FindProxiesRequest( + accountIds: Collection +) : SubQueryFilters { + + @Transient + private val accountIdsHex = accountIds.map { it.toHexWithPrefix() } + + val query = """ + query { + proxieds( + filter: { + ${"proxyAccountId" presentIn accountIdsHex}, + ${"delay" equalTo 0} + }) { + nodes { + chainId + type + proxyAccountId + accountId + } + } + } + """.trimIndent() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/network/api/response/FindProxiesResponse.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/network/api/response/FindProxiesResponse.kt new file mode 100644 index 0000000..84ed325 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/network/api/response/FindProxiesResponse.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_account_impl.data.proxy.network.api.response + +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import io.novafoundation.nova.common.utils.HexString +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class FindProxiesResponse( + val proxieds: SubQueryNodes +) + +class ProxiedRemote( + val accountId: HexString, + val type: String, + val proxyAccountId: HexString, + val chainId: ChainId, +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/repository/MultiChainProxyRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/repository/MultiChainProxyRepository.kt new file mode 100644 index 0000000..e17eb54 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/repository/MultiChainProxyRepository.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_impl.data.proxy.repository + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.fromHexOrNull +import io.novafoundation.nova.common.data.config.GlobalConfigDataSource +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.feature_account_impl.data.proxy.network.api.FindProxiesApi +import io.novafoundation.nova.feature_account_impl.data.proxy.network.api.request.FindProxiesRequest +import io.novafoundation.nova.feature_account_impl.data.proxy.network.api.response.FindProxiesResponse +import io.novafoundation.nova.feature_account_impl.data.proxy.repository.model.MultiChainProxy +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.feature_proxy_api.domain.model.fromString +import javax.inject.Inject + +interface MultiChainProxyRepository { + + suspend fun getProxies(accountIds: Collection): List +} + +@FeatureScope +class RealMultiChainProxyRepository @Inject constructor( + private val proxiesApi: FindProxiesApi, + private val globalConfigDataSource: GlobalConfigDataSource +) : MultiChainProxyRepository { + + override suspend fun getProxies(accountIds: Collection): List { + val globalConfig = globalConfigDataSource.getGlobalConfig() + val request = FindProxiesRequest(accountIds) + return proxiesApi.findProxies(globalConfig.proxyApiUrl, request).toDomain() + } + + private fun SubQueryResponse.toDomain(): List { + return data.proxieds.nodes.mapNotNull { proxiedNode -> + MultiChainProxy( + chainId = proxiedNode.chainId.removeHexPrefix(), + proxyType = ProxyType.fromString(proxiedNode.type), + proxied = AccountIdKey.fromHexOrNull(proxiedNode.accountId) ?: return@mapNotNull null, + proxy = AccountIdKey.fromHexOrNull(proxiedNode.proxyAccountId) ?: return@mapNotNull null + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/repository/model/FindProxiesResponse.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/repository/model/FindProxiesResponse.kt new file mode 100644 index 0000000..33a9477 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/repository/model/FindProxiesResponse.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_account_impl.data.proxy.repository.model + +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import io.novafoundation.nova.common.utils.HexString + +class FindMultisigsResponse( + val accounts: SubQueryNodes +) + +class MultisigRemote( + val id: HexString, + val threshold: Int, + val signatories: SubQueryNodes +) { + + class SignatoryRemoteWrapper(val signatory: SignatoryRemote) + + class SignatoryRemote( + val id: HexString + ) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/repository/model/MultiChainProxy.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/repository/model/MultiChainProxy.kt new file mode 100644 index 0000000..e66f0ae --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/proxy/repository/model/MultiChainProxy.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_account_impl.data.proxy.repository.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class MultiChainProxy( + val chainId: ChainId, + val proxied: AccountIdKey, + val proxy: AccountIdKey, + val proxyType: ProxyType, +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/AccountRepositoryImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/AccountRepositoryImpl.kt new file mode 100644 index 0000000..87373dd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/AccountRepositoryImpl.kt @@ -0,0 +1,396 @@ +package io.novafoundation.nova.feature_account_impl.data.repository + +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.getAccountSecrets +import io.novafoundation.nova.common.resources.LanguagesHolder +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.utils.networkType +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core.model.Language +import io.novafoundation.nova.core.model.Network +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.core_db.dao.AccountDao +import io.novafoundation.nova.core_db.dao.NodeDao +import io.novafoundation.nova.core_db.model.AccountLocal +import io.novafoundation.nova.core_db.model.NodeLocal +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus.Event +import io.novafoundation.nova.feature_account_api.data.events.combineBusEvents +import io.novafoundation.nova.feature_account_api.data.secrets.keypair +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.Account +import io.novafoundation.nova.feature_account_api.domain.model.AuthType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountAssetBalance +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountOrdering +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.domain.model.defaultSubstrateAddress +import io.novafoundation.nova.feature_account_api.domain.model.multiChainEncryptionIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_account_api.domain.model.substrateMultiChainEncryption +import io.novafoundation.nova.feature_account_impl.data.mappers.mapNodeLocalToNode +import io.novafoundation.nova.feature_account_impl.data.network.blockchain.AccountSubstrateSource +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSource +import io.novafoundation.nova.runtime.ext.genesisHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.encrypt.json.JsonEncoder +import io.novasama.substrate_sdk_android.encrypt.mnemonic.Mnemonic +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class AccountRepositoryImpl( + private val accountDataSource: AccountDataSource, + private val accountDao: AccountDao, + private val nodeDao: NodeDao, + private val JsonEncoder: JsonEncoder, + private val languagesHolder: LanguagesHolder, + private val accountSubstrateSource: AccountSubstrateSource, + private val secretStoreV2: SecretStoreV2, + private val metaAccountChangesEventBus: MetaAccountChangesEventBus +) : AccountRepository { + + override fun getEncryptionTypes(): List { + return CryptoType.values().toList() + } + + override suspend fun getNode(nodeId: Int): Node { + return withContext(Dispatchers.IO) { + val node = nodeDao.getNodeById(nodeId) + + mapNodeLocalToNode(node) + } + } + + override suspend fun getSelectedNodeOrDefault(): Node { + return accountDataSource.getSelectedNode() ?: mapNodeLocalToNode(nodeDao.getFirstNode()) + } + + override suspend fun selectNode(node: Node) { + accountDataSource.saveSelectedNode(node) + } + + override suspend fun getDefaultNode(networkType: Node.NetworkType): Node { + return mapNodeLocalToNode(nodeDao.getDefaultNodeFor(networkType.ordinal)) + } + + override suspend fun selectAccount(account: Account, newNode: Node?) { + accountDataSource.saveSelectedAccount(account) + + when { + newNode != null -> { + require(account.network.type == newNode.networkType) { + "Account network type is not the same as chosen node type" + } + + selectNode(newNode) + } + + account.network.type != accountDataSource.getSelectedNode()?.networkType -> { + val defaultNode = getDefaultNode(account.address.networkType()) + + selectNode(defaultNode) + } + } + } + + override suspend fun getSelectedMetaAccount(): MetaAccount { + return accountDataSource.getSelectedMetaAccount() + } + + override suspend fun getMetaAccountsByIds(metaIds: List): List { + return accountDataSource.getMetaAccountsByIds(metaIds) + } + + override suspend fun getAvailableMetaIdsFromSet(metaIds: Set): Set { + val availableMetaIds = accountDataSource.getActiveMetaIds() + return metaIds.intersect(availableMetaIds) + } + + override suspend fun getMetaAccount(metaId: Long): MetaAccount { + return accountDataSource.getMetaAccount(metaId) + } + + override fun metaAccountFlow(metaId: Long): Flow { + return accountDataSource.metaAccountFlow(metaId) + } + + override fun selectedMetaAccountFlow(): Flow { + return accountDataSource.selectedMetaAccountFlow() + } + + override suspend fun findMetaAccount(accountId: ByteArray, chainId: String): MetaAccount? { + return accountDataSource.findMetaAccount(accountId, chainId) + } + + override suspend fun accountNameFor(accountId: AccountId, chainId: String): String? { + return accountDataSource.accountNameFor(accountId, chainId) + } + + override suspend fun hasActiveMetaAccounts(): Boolean { + return accountDataSource.hasActiveMetaAccounts() + } + + override fun allMetaAccountsFlow(): Flow> { + return accountDataSource.allMetaAccountsFlow() + } + + override fun activeMetaAccountsFlow(): Flow> { + return accountDataSource.activeMetaAccountsFlow() + } + + override fun metaAccountBalancesFlow(): Flow> { + return accountDataSource.metaAccountsWithBalancesFlow() + } + + override fun metaAccountBalancesFlow(metaId: Long): Flow> { + return accountDataSource.metaAccountBalancesFlow(metaId) + } + + override suspend fun selectMetaAccount(metaId: Long) { + return accountDataSource.selectMetaAccount(metaId) + } + + override suspend fun updateMetaAccountName(metaId: Long, newName: String) = withContext(Dispatchers.Default) { + accountDataSource.updateMetaAccountName(metaId, newName) + + val metaAccountType = requireNotNull(accountDataSource.getMetaAccountType(metaId)) + val event = Event.AccountNameChanged(metaId, metaAccountType) + + metaAccountChangesEventBus.notify(event, source = null) + } + + override suspend fun isAccountSelected(): Boolean { + return accountDataSource.anyAccountSelected() + } + + override suspend fun deleteAccount(metaId: Long) = withContext(Dispatchers.Default) { + val allAffectedMetaAccounts = accountDataSource.deleteMetaAccount(metaId) + + val deleteEvents = allAffectedMetaAccounts.map { Event.AccountRemoved(it.metaId, it.type) } + .combineBusEvents() ?: return@withContext + + metaAccountChangesEventBus.notify(deleteEvents, source = null) + } + + override suspend fun getAccounts(): List { + return accountDao.getAccounts() + .map { mapAccountLocalToAccount(it) } + } + + override suspend fun getAccount(address: String): Account { + val account = accountDao.getAccount(address) ?: throw NoSuchElementException("No account found for address $address") + return mapAccountLocalToAccount(account) + } + + override suspend fun getAccountOrNull(address: String): Account? { + return accountDao.getAccount(address)?.let { mapAccountLocalToAccount(it) } + } + + override suspend fun getMyAccounts(query: String, chainId: String): Set { +// return withContext(Dispatchers.Default) { +// accountDao.getAccounts(query, networkType) +// .map { mapAccountLocalToAccount(it) } +// .toSet() +// } + + return emptySet() // TODO wallet + } + + override suspend fun isCodeSet(): Boolean { + return accountDataSource.getPinCode() != null + } + + override suspend fun savePinCode(code: String) { + return accountDataSource.savePinCode(code) + } + + override suspend fun getPinCode(): String? { + return accountDataSource.getPinCode() + } + + override suspend fun generateMnemonic(): Mnemonic { + return MnemonicCreator.randomMnemonic(Mnemonic.Length.TWELVE) + } + + override fun isBiometricEnabledFlow(): Flow { + return accountDataSource.getAuthTypeFlow().map { it == AuthType.BIOMETRY } + } + + override fun isBiometricEnabled(): Boolean { + return accountDataSource.getAuthType() == AuthType.BIOMETRY + } + + override fun setBiometricOn() { + return accountDataSource.saveAuthType(AuthType.BIOMETRY) + } + + override fun setBiometricOff() { + return accountDataSource.saveAuthType(AuthType.PINCODE) + } + + override suspend fun updateAccountsOrdering(accountOrdering: List) { + return accountDataSource.updateAccountPositions(accountOrdering) + } + + override suspend fun generateRestoreJson( + metaAccount: MetaAccount, + chain: Chain, + password: String, + ): String { + return withContext(Dispatchers.Default) { + val accountId = metaAccount.accountIdIn(chain)!! + val address = metaAccount.addressIn(chain)!! + + val secrets = secretStoreV2.getAccountSecrets(metaAccount.id, accountId) + + JsonEncoder.generate( + keypair = secrets.keypair(chain), + password = password, + name = metaAccount.name, + multiChainEncryption = metaAccount.multiChainEncryptionIn(chain)!!, + genesisHash = chain.genesisHash.orEmpty(), + address = address + ) + } + } + + override suspend fun generateRestoreJson( + metaAccount: MetaAccount, + password: String, + ): String { + return withContext(Dispatchers.Default) { + val secrets = secretStoreV2.getMetaAccountSecrets(metaAccount.id)!! + + JsonEncoder.generate( + keypair = secrets.keypair(ethereum = false), + password = password, + name = metaAccount.name, + multiChainEncryption = metaAccount.substrateMultiChainEncryption()!!, + genesisHash = "", + address = metaAccount.defaultSubstrateAddress!! + ) + } + } + + override suspend fun isAccountExists(accountId: AccountId, chainId: String): Boolean { + return accountDataSource.accountExists(accountId, chainId) + } + + override suspend fun removeDeactivatedMetaAccounts() { + accountDataSource.removeDeactivatedMetaAccounts() + } + + override suspend fun getActiveMetaAccounts(): List { + return accountDataSource.getActiveMetaAccounts() + } + + override suspend fun getAllMetaAccounts(): List { + return accountDataSource.getAllMetaAccounts() + } + + override suspend fun getActiveMetaAccountsQuantity(): Int { + return accountDataSource.getActiveMetaAccountsQuantity() + } + + override fun hasMetaAccountsCountOfTypeFlow(type: LightMetaAccount.Type): Flow { + return accountDataSource.hasMetaAccountsCountOfTypeFlow(type) + } + + override suspend fun hasMetaAccountsByType(type: LightMetaAccount.Type): Boolean { + return accountDataSource.hasMetaAccountsByType(type) + } + + override suspend fun hasMetaAccountsByType(metaIds: Set, type: LightMetaAccount.Type): Boolean { + return accountDataSource.hasMetaAccountsByType(metaIds, type) + } + + override fun metaAccountsByTypeFlow(type: LightMetaAccount.Type): Flow> { + return accountDataSource.metaAccountsByTypeFlow(type) + } + + override suspend fun hasSecretsAccounts(): Boolean { + return accountDataSource.hasSecretsAccounts() + } + + override suspend fun deleteProxiedMetaAccountsByChain(chainId: String) { + accountDataSource.deleteProxiedMetaAccountsByChain(chainId) + } + + override fun nodesFlow(): Flow> { + return nodeDao.nodesFlow() + .mapList { mapNodeLocalToNode(it) } + .filter { it.isNotEmpty() } + .flowOn(Dispatchers.Default) + } + + override fun getLanguages(): List { + return languagesHolder.getLanguages() + } + + override suspend fun selectedLanguage(): Language { + return accountDataSource.getSelectedLanguage() + } + + override suspend fun changeLanguage(language: Language) { + return accountDataSource.changeSelectedLanguage(language) + } + + override suspend fun addNode(nodeName: String, nodeHost: String, networkType: Node.NetworkType) { + val nodeLocal = NodeLocal(nodeName, nodeHost, networkType.ordinal, false) + nodeDao.insert(nodeLocal) + } + + override suspend fun updateNode(nodeId: Int, newName: String, newHost: String, networkType: Node.NetworkType) { + nodeDao.updateNode(nodeId, newName, newHost, networkType.ordinal) + } + + override suspend fun checkNodeExists(nodeHost: String): Boolean { + return nodeDao.checkNodeExists(nodeHost) + } + + override suspend fun getNetworkName(nodeHost: String): String { + return accountSubstrateSource.getNodeNetworkType(nodeHost) + } + + override suspend fun getAccountsByNetworkType(networkType: Node.NetworkType): List { + val accounts = accountDao.getAccountsByNetworkType(networkType.ordinal) + + return withContext(Dispatchers.Default) { + accounts.map { mapAccountLocalToAccount(it) } + } + } + + override suspend fun deleteNode(nodeId: Int) { + return nodeDao.deleteNode(nodeId) + } + + override suspend fun createQrAccountContent(chain: Chain, account: MetaAccount): String { + return account.requireAddressIn(chain) + } + + private fun mapAccountLocalToAccount(accountLocal: AccountLocal): Account { + val network = getNetworkForType(accountLocal.networkType) + + return with(accountLocal) { + Account( + address = address, + name = username, + accountIdHex = publicKey, + cryptoType = CryptoType.values()[accountLocal.cryptoType], + network = network, + position = position + ) + } + } + + private fun getNetworkForType(networkType: Node.NetworkType): Network { + return Network(networkType) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/RealCreateSecretsRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/RealCreateSecretsRepository.kt new file mode 100644 index 0000000..b36520c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/RealCreateSecretsRepository.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.data.repository + +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.data.repository.CreateSecretsRepository +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +class RealCreateSecretsRepository( + private val accountSecretsFactory: AccountSecretsFactory, +) : CreateSecretsRepository { + override suspend fun createSecretsWithSeed( + seed: ByteArray, + cryptoType: CryptoType, + derivationPath: String?, + isEthereum: Boolean + ): EncodableStruct { + val seedString = seed.toHexString() + return accountSecretsFactory.chainAccountSecrets( + derivationPath, + AccountSecretsFactory.AccountSource.Seed(cryptoType, seedString), + isEthereum = isEthereum + ).secrets + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/RealOnChainIdentityRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/RealOnChainIdentityRepository.kt new file mode 100644 index 0000000..3025899 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/RealOnChainIdentityRepository.kt @@ -0,0 +1,179 @@ +package io.novafoundation.nova.feature_account_impl.data.repository + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.format.getAddressSchemeOrThrow +import io.novafoundation.nova.common.address.get +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.groupByToSet +import io.novafoundation.nova.common.utils.hasModule +import io.novafoundation.nova.common.utils.identity +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_account_api.data.model.AccountAddressMap +import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_account_api.data.model.ChildIdentity +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_impl.data.network.blockchain.bindings.bindIdentity +import io.novafoundation.nova.feature_account_impl.data.network.blockchain.bindings.bindSuperOf +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.addressScheme +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getChainOrNull +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class RealOnChainIdentityRepository( + private val storageDataSource: StorageDataSource, + private val chainRegistry: ChainRegistry +) : OnChainIdentityRepository { + + companion object { + + private val MULTICHAIN_IDENTITY_CHAINS = listOf(Chain.Geneses.POLKADOT_PEOPLE, Chain.Geneses.KUSAMA_PEOPLE) + } + + override suspend fun getIdentitiesFromIdsHex( + chainId: ChainId, + accountIdsHex: Collection + ): AccountIdMap = withContext(Dispatchers.Default) { + val accountIds = accountIdsHex.map { it.fromHex() } + + getIdentitiesFromIds(accountIds, chainId).mapKeys { (accountId, _) -> accountId.value.toHexString() } + } + + override suspend fun getIdentitiesFromIds( + accountIds: Collection, + chainId: ChainId + ): AccountIdKeyMap = withContext(Dispatchers.Default) { + val identityChainId = findIdentityChain(chainId) + val uniqueIds = accountIds.mapToSet(AccountId::intoKey) + + getIdentitiesFromIdOnIdentityChain(uniqueIds, identityChainId) + } + + private suspend fun getIdentitiesFromIdOnIdentityChain( + accountIds: Set, + identityChainId: ChainId + ): AccountIdKeyMap = withContext(Dispatchers.Default) { + storageDataSource.query(identityChainId) { + if (!runtime.metadata.hasModule(Modules.IDENTITY)) { + return@query emptyMap() + } + + val superOfArguments = accountIds.map { listOf(it.value) } + val superOfValues = runtime.metadata.identity().storage("SuperOf").entries( + keysArguments = superOfArguments, + keyExtractor = { (accountId: AccountId) -> AccountIdKey(accountId) }, + binding = { value, _ -> bindSuperOf(value) } + ) + + val parentIdentityIds = superOfValues.values.filterNotNull().mapToSet { AccountIdKey(it.parentId) } + val parentIdentities = fetchIdentities(parentIdentityIds) + + val childIdentities = superOfValues.filterNotNull().mapValues { (_, superOf) -> + val parentIdentity = parentIdentities[superOf.parentId] + + parentIdentity?.let { ChildIdentity(superOf.childName, it) } + } + + val leftAccountIds = accountIds - childIdentities.keys - parentIdentities.keys + + val rootIdentities = fetchIdentities(leftAccountIds.toList()) + + rootIdentities + childIdentities + parentIdentities + } + } + + override suspend fun getIdentityFromId( + chainId: ChainId, + accountId: AccountId + ): OnChainIdentity? = withContext(Dispatchers.Default) { + val identityChainId = findIdentityChain(chainId) + + storageDataSource.query(identityChainId) { + if (!runtime.metadata.hasModule(Modules.IDENTITY)) { + return@query null + } + + val parentRelationship = runtime.metadata.identity().storage("SuperOf").query(accountId, binding = ::bindSuperOf) + + if (parentRelationship != null) { + val parentIdentity = fetchIdentity(parentRelationship.parentId) + + parentIdentity?.let { + ChildIdentity(parentRelationship.childName, parentIdentity) + } + } else { + fetchIdentity(accountId) + } + } + } + + override suspend fun getMultiChainIdentities( + accountIds: Collection + ): AccountIdKeyMap { + val accountIdsByAddressScheme = accountIds.groupByToSet { it.getAddressSchemeOrThrow() } + val identityChains = MULTICHAIN_IDENTITY_CHAINS.mapNotNull { chainRegistry.getChainOrNull(it) } + + return buildMap { + identityChains.onEach { identityChain -> + val addressScheme = identityChain.addressScheme + val accountIdsPerScheme = accountIdsByAddressScheme[addressScheme] ?: return@onEach + + val identities = getIdentitiesFromIdOnIdentityChain(accountIdsPerScheme, identityChain.id) + putAll(identities) + + // Early return if we already fetched all required identities + if (size == accountIds.size) return@buildMap + } + } + } + + override suspend fun getIdentitiesFromAddresses(chain: Chain, accountAddresses: List): AccountAddressMap { + val accountIds = accountAddresses.map(chain::accountIdOf) + + val identitiesByAccountId = getIdentitiesFromIds(accountIds, chain.id) + + return accountAddresses.associateWith { address -> + val accountId = chain.accountIdOf(address) + + identitiesByAccountId[accountId] + } + } + + private suspend fun StorageQueryContext.fetchIdentities(accountIdsHex: Collection): AccountIdKeyMap { + return runtime.metadata.module("Identity").storage("IdentityOf").entries( + keysArguments = accountIdsHex.map { listOf(it.value) }, + keyExtractor = { (accountId: AccountId) -> AccountIdKey(accountId) }, + binding = { value, _ -> bindIdentity(value) } + ) + } + + private suspend fun StorageQueryContext.fetchIdentity(accountId: AccountId): OnChainIdentity? { + return runtime.metadata.module("Identity").storage("IdentityOf") + .query(accountId, binding = ::bindIdentity) + } + + private suspend fun findIdentityChain(identitiesRequestedOn: ChainId): ChainId { + val requestedChain = chainRegistry.getChain(identitiesRequestedOn) + return findIdentityChain(requestedChain).id + } + + private suspend fun findIdentityChain(requestedChain: Chain): Chain { + val identityChain = requestedChain.additional?.identityChain?.let { chainRegistry.getChainOrNull(it) } + return identityChain ?: requestedChain + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/WatchOnlyRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/WatchOnlyRepository.kt new file mode 100644 index 0000000..fb9a168 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/WatchOnlyRepository.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_impl.data.repository + +import io.novafoundation.nova.core_db.dao.MetaAccountDao + +class WatchWalletSuggestion( + val name: String, + val substrateAddress: String, + val evmAddress: String? +) + +interface WatchOnlyRepository { + + fun watchWalletSuggestions(): List +} + +class RealWatchOnlyRepository( + private val accountDao: MetaAccountDao +) : WatchOnlyRepository { + + override fun watchWalletSuggestions(): List { + return listOf( + WatchWalletSuggestion( + name = "\uD83C\uDF0C NOVA", + substrateAddress = "1ChFWeNRLarAPRCTM3bfJmncJbSAbSS9yqjueWz7jX7iTVZ", + evmAddress = "0x7Aa98AEb3AfAcf10021539d5412c7ac6AfE0fb00" + ) + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/BaseAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/BaseAddAccountRepository.kt new file mode 100644 index 0000000..6a5d72b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/BaseAddAccountRepository.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount + +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.toAccountBusEvent + +abstract class BaseAddAccountRepository( + private val metaAccountChangesEventBus: MetaAccountChangesEventBus +) : AddAccountRepository { + + final override suspend fun addAccount(payload: T): AddAccountResult { + val addAccountResult = addAccountInternal(payload) + + addAccountResult.toAccountBusEvent()?.let { metaAccountChangesEventBus.notify(it, source = null) } + + return addAccountResult + } + + protected abstract suspend fun addAccountInternal(payload: T): AddAccountResult +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/LocalAddMetaAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/LocalAddMetaAccountRepository.kt new file mode 100644 index 0000000..0c14989 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/LocalAddMetaAccountRepository.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount + +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +class LocalAddMetaAccountRepository( + metaAccountChangesEventBus: MetaAccountChangesEventBus, + private val metaAccountDao: MetaAccountDao, + private val secretStoreV2: SecretStoreV2 +) : BaseAddAccountRepository( + metaAccountChangesEventBus +) { + + class Payload(val metaAccountLocal: MetaAccountLocal, val secrets: EncodableStruct) + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + val metaId = metaAccountDao.insertMetaAccount(payload.metaAccountLocal) + secretStoreV2.putMetaAccountSecrets(metaId, payload.secrets) + return AddAccountResult.AccountAdded(metaId, LightMetaAccount.Type.SECRETS) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/ledger/RealGenericLedgerAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/ledger/RealGenericLedgerAddAccountRepository.kt new file mode 100644 index 0000000..ba2ea4b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/ledger/RealGenericLedgerAddAccountRepository.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.ledger + +import io.novafoundation.nova.common.data.mappers.mapEncryptionToCryptoType +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.updateMetaAccount +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.GenericLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.GenericLedgerAddAccountRepository.Payload +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.BaseAddAccountRepository +import io.novafoundation.nova.feature_ledger_api.data.repository.LedgerDerivationPath +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId + +class RealGenericLedgerAddAccountRepository( + private val accountDao: MetaAccountDao, + private val secretStoreV2: SecretStoreV2, + private val accountMappers: AccountMappers, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : BaseAddAccountRepository(metaAccountChangesEventBus), GenericLedgerAddAccountRepository { + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + return when (payload) { + is Payload.NewWallet -> addNewWallet(payload) + is Payload.AddEvmAccount -> addEvmAccount(payload) + } + } + + private suspend fun addNewWallet(payload: Payload.NewWallet): AddAccountResult { + val metaAccount = MetaAccountLocal( + substratePublicKey = payload.substrateAccount.publicKey, + substrateCryptoType = mapEncryptionToCryptoType(payload.substrateAccount.encryptionType), + substrateAccountId = payload.substrateAccount.address.toAccountId(), + ethereumPublicKey = payload.evmAccount?.publicKey, + ethereumAddress = payload.evmAccount?.accountId, + name = payload.name, + parentMetaId = null, + isSelected = false, + position = accountDao.nextAccountPosition(), + type = MetaAccountLocal.Type.LEDGER_GENERIC, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = null + ) + + val metaId = accountDao.insertMetaAccount(metaAccount) + val derivationPathKey = LedgerDerivationPath.genericDerivationPathSecretKey() + secretStoreV2.putAdditionalMetaAccountSecret(metaId, derivationPathKey, payload.substrateAccount.derivationPath) + + return AddAccountResult.AccountAdded(metaId, LightMetaAccount.Type.LEDGER) + } + + private suspend fun addEvmAccount(payload: Payload.AddEvmAccount): AddAccountResult { + val metaAccountType: LightMetaAccount.Type + + accountDao.updateMetaAccount(payload.metaId) { currentMetaAccount -> + metaAccountType = accountMappers.mapMetaAccountTypeFromLocal(currentMetaAccount.type) + + currentMetaAccount.addEvmAccount( + ethereumAddress = payload.evmAccount.accountId, + ethereumPublicKey = payload.evmAccount.publicKey + ) + } + + return AddAccountResult.AccountChanged(payload.metaId, metaAccountType) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/ledger/RealLegacyLedgerAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/ledger/RealLegacyLedgerAddAccountRepository.kt new file mode 100644 index 0000000..a69ed0a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/ledger/RealLegacyLedgerAddAccountRepository.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.ledger + +import io.novafoundation.nova.common.data.mappers.mapEncryptionToCryptoType +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.LegacyLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.LegacyLedgerAddAccountRepository.Payload +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.BaseAddAccountRepository +import io.novafoundation.nova.feature_ledger_api.data.repository.LedgerDerivationPath +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class RealLegacyLedgerAddAccountRepository( + private val accountDao: MetaAccountDao, + private val chainRegistry: ChainRegistry, + private val secretStoreV2: SecretStoreV2, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : BaseAddAccountRepository(metaAccountChangesEventBus), LegacyLedgerAddAccountRepository { + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + return when (payload) { + is Payload.MetaAccount -> addMetaAccount(payload) + is Payload.ChainAccount -> addChainAccount(payload) + } + } + + private suspend fun addMetaAccount(payload: Payload.MetaAccount): AddAccountResult { + val metaAccount = MetaAccountLocal( + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = null, + ethereumPublicKey = null, + ethereumAddress = null, + name = payload.name, + parentMetaId = null, + isSelected = false, + position = accountDao.nextAccountPosition(), + type = MetaAccountLocal.Type.LEDGER, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = null + ) + + val metaId = accountDao.insertMetaAndChainAccounts(metaAccount) { metaId -> + payload.ledgerChainAccounts.map { (chainId, account) -> + val chain = chainRegistry.getChain(chainId) + + ChainAccountLocal( + metaId = metaId, + chainId = chainId, + publicKey = account.publicKey, + accountId = chain.accountIdOf(account.publicKey), + cryptoType = mapEncryptionToCryptoType(account.encryptionType) + ) + } + } + + payload.ledgerChainAccounts.onEach { (chainId, ledgerAccount) -> + val derivationPathKey = LedgerDerivationPath.legacyDerivationPathSecretKey(chainId) + secretStoreV2.putAdditionalMetaAccountSecret(metaId, derivationPathKey, ledgerAccount.derivationPath) + } + + return AddAccountResult.AccountAdded(metaId, type = Type.LEDGER_LEGACY) + } + + private suspend fun addChainAccount(payload: Payload.ChainAccount): AddAccountResult { + val chain = chainRegistry.getChain(payload.chainId) + + val chainAccount = ChainAccountLocal( + metaId = payload.metaId, + chainId = payload.chainId, + publicKey = payload.ledgerChainAccount.publicKey, + accountId = chain.accountIdOf(payload.ledgerChainAccount.publicKey), + cryptoType = mapEncryptionToCryptoType(payload.ledgerChainAccount.encryptionType) + ) + + accountDao.insertChainAccount(chainAccount) + + val derivationPathKey = LedgerDerivationPath.legacyDerivationPathSecretKey(payload.chainId) + secretStoreV2.putAdditionalMetaAccountSecret(payload.metaId, derivationPathKey, payload.ledgerChainAccount.derivationPath) + + return AddAccountResult.AccountChanged(payload.metaId, type = Type.LEDGER) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/paritySigner/ParitySignerAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/paritySigner/ParitySignerAddAccountRepository.kt new file mode 100644 index 0000000..382123c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/paritySigner/ParitySignerAddAccountRepository.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.paritySigner + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.BaseAddAccountRepository +import io.novasama.substrate_sdk_android.runtime.AccountId + +class ParitySignerAddAccountRepository( + private val accountDao: MetaAccountDao, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : BaseAddAccountRepository(metaAccountChangesEventBus) { + + class Payload( + val name: String, + val substrateAccountId: AccountId, + val variant: PolkadotVaultVariant + ) + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + val metaAccount = MetaAccountLocal( + // it is safe to assume that accountId is equal to public key since Parity Signer only uses SR25519 + substratePublicKey = payload.substrateAccountId, + substrateAccountId = payload.substrateAccountId, + substrateCryptoType = CryptoType.SR25519, + ethereumPublicKey = null, + ethereumAddress = null, + name = payload.name, + parentMetaId = null, + isSelected = false, + position = accountDao.nextAccountPosition(), + type = payload.variant.asMetaAccountTypeLocal(), + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = null + ) + + val metaId = accountDao.insertMetaAccount(metaAccount) + + return AddAccountResult.AccountAdded(metaId, type = payload.variant.asMetaAccountType()) + } + + private fun PolkadotVaultVariant.asMetaAccountTypeLocal(): MetaAccountLocal.Type { + return when (this) { + PolkadotVaultVariant.POLKADOT_VAULT -> MetaAccountLocal.Type.POLKADOT_VAULT + PolkadotVaultVariant.PARITY_SIGNER -> MetaAccountLocal.Type.PARITY_SIGNER + } + } + + private fun PolkadotVaultVariant.asMetaAccountType(): LightMetaAccount.Type { + return when (this) { + PolkadotVaultVariant.POLKADOT_VAULT -> LightMetaAccount.Type.POLKADOT_VAULT + PolkadotVaultVariant.PARITY_SIGNER -> LightMetaAccount.Type.PARITY_SIGNER + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/Common.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/Common.kt new file mode 100644 index 0000000..c4eee8f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/Common.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets + +import android.database.sqlite.SQLiteConstraintException +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountAlreadyExistsException + +internal inline fun transformingAccountInsertionErrors(action: () -> R) = try { + action() +} catch (_: SQLiteConstraintException) { + throw AccountAlreadyExistsException() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/JsonAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/JsonAddAccountRepository.kt new file mode 100644 index 0000000..b3d231a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/JsonAddAccountRepository.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets + +import io.novafoundation.nova.common.data.mappers.mapEncryptionToCryptoType +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_api.domain.model.ImportJsonMetaData +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSource +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder +import io.novasama.substrate_sdk_android.encrypt.model.NetworkTypeIdentifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class JsonAddAccountRepository( + private val accountDataSource: AccountDataSource, + private val accountSecretsFactory: AccountSecretsFactory, + private val JsonDecoder: JsonDecoder, + private val chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : SecretsAddAccountRepository( + accountDataSource, + accountSecretsFactory, + chainRegistry, + metaAccountChangesEventBus +) { + + class Payload( + val json: String, + val password: String, + val addAccountType: AddAccountType + ) + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + return addSecretsAccount( + derivationPaths = AdvancedEncryption.DerivationPaths.empty(), + addAccountType = payload.addAccountType, + accountSource = AccountSecretsFactory.AccountSource.Json(payload.json, payload.password) + ) + } + + suspend fun extractJsonMetadata(importJson: String): ImportJsonMetaData = withContext(Dispatchers.Default) { + val importAccountMeta = JsonDecoder.extractImportMetaData(importJson) + + with(importAccountMeta) { + val chainId = (networkTypeIdentifier as? NetworkTypeIdentifier.Genesis)?.genesis?.removeHexPrefix() + val cryptoType = mapEncryptionToCryptoType(encryption.encryptionType) + + ImportJsonMetaData(name, chainId, cryptoType) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/MnemonicAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/MnemonicAddAccountRepository.kt new file mode 100644 index 0000000..728230a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/MnemonicAddAccountRepository.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets + +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository.Payload +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSource +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class RealMnemonicAddAccountRepository( + accountDataSource: AccountDataSource, + accountSecretsFactory: AccountSecretsFactory, + chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : SecretsAddAccountRepository( + accountDataSource, + accountSecretsFactory, + chainRegistry, + metaAccountChangesEventBus +), + MnemonicAddAccountRepository { + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + return addSecretsAccount( + derivationPaths = payload.advancedEncryption.derivationPaths, + addAccountType = payload.addAccountType, + accountSource = AccountSecretsFactory.AccountSource.Mnemonic( + cryptoType = pickCryptoType(payload.addAccountType, payload.advancedEncryption), + mnemonic = payload.mnemonic + ) + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/SecretsAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/SecretsAddAccountRepository.kt new file mode 100644 index 0000000..d48243e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/SecretsAddAccountRepository.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.BaseAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSource +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +abstract class SecretsAddAccountRepository( + private val accountDataSource: AccountDataSource, + private val accountSecretsFactory: AccountSecretsFactory, + private val chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : BaseAddAccountRepository(metaAccountChangesEventBus) { + + protected suspend fun pickCryptoType(addAccountType: AddAccountType, advancedEncryption: AdvancedEncryption): CryptoType { + val cryptoType = if (addAccountType is AddAccountType.ChainAccount && chainRegistry.getChain(addAccountType.chainId).isEthereumBased) { + advancedEncryption.ethereumCryptoType + } else { + advancedEncryption.substrateCryptoType + } + + requireNotNull(cryptoType) { "Expected crypto type was null" } + + return cryptoType + } + + protected suspend fun addSecretsAccount( + derivationPaths: AdvancedEncryption.DerivationPaths, + addAccountType: AddAccountType, + accountSource: AccountSecretsFactory.AccountSource + ): AddAccountResult = withContext(Dispatchers.Default) { + when (addAccountType) { + is AddAccountType.MetaAccount -> { + addMetaAccount(derivationPaths, accountSource, addAccountType) + } + + is AddAccountType.ChainAccount -> { + addChainAccount(addAccountType, derivationPaths, accountSource) + } + } + } + + private suspend fun addMetaAccount( + derivationPaths: AdvancedEncryption.DerivationPaths, + accountSource: AccountSecretsFactory.AccountSource, + addAccountType: AddAccountType.MetaAccount + ): AddAccountResult { + val (secrets, substrateCryptoType) = accountSecretsFactory.metaAccountSecrets( + substrateDerivationPath = derivationPaths.substrate, + ethereumDerivationPath = derivationPaths.ethereum, + accountSource = accountSource + ) + + val metaId = transformingAccountInsertionErrors { + accountDataSource.insertMetaAccountFromSecrets( + name = addAccountType.name, + substrateCryptoType = substrateCryptoType, + secrets = secrets + ) + } + + return AddAccountResult.AccountAdded(metaId, LightMetaAccount.Type.SECRETS) + } + + private suspend fun addChainAccount( + addAccountType: AddAccountType.ChainAccount, + derivationPaths: AdvancedEncryption.DerivationPaths, + accountSource: AccountSecretsFactory.AccountSource + ): AddAccountResult { + val chain = chainRegistry.getChain(addAccountType.chainId) + + val derivationPath = if (chain.isEthereumBased) derivationPaths.ethereum else derivationPaths.substrate + + val (secrets, cryptoType) = accountSecretsFactory.chainAccountSecrets( + derivationPath = derivationPath, + accountSource = accountSource, + isEthereum = chain.isEthereumBased + ) + + transformingAccountInsertionErrors { + accountDataSource.insertChainAccount( + metaId = addAccountType.metaId, + chain = chain, + cryptoType = cryptoType, + secrets = secrets + ) + } + + return AddAccountResult.AccountChanged(addAccountType.metaId, LightMetaAccount.Type.SECRETS) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/SeedAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/SeedAddAccountRepository.kt new file mode 100644 index 0000000..285d01b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/SeedAddAccountRepository.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets + +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSource +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class SeedAddAccountRepository( + accountDataSource: AccountDataSource, + accountSecretsFactory: AccountSecretsFactory, + chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : SecretsAddAccountRepository( + accountDataSource, + accountSecretsFactory, + chainRegistry, + metaAccountChangesEventBus +) { + + class Payload( + val seed: String, + val advancedEncryption: AdvancedEncryption, + val addAccountType: AddAccountType + ) + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + return addSecretsAccount( + derivationPaths = payload.advancedEncryption.derivationPaths, + addAccountType = payload.addAccountType, + accountSource = AccountSecretsFactory.AccountSource.Seed( + cryptoType = pickCryptoType(payload.addAccountType, payload.advancedEncryption), + seed = payload.seed + ) + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/SubstrateKeypairAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/SubstrateKeypairAddAccountRepository.kt new file mode 100644 index 0000000..853919b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/SubstrateKeypairAddAccountRepository.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets + +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSource +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class SubstrateKeypairAddAccountRepository( + accountDataSource: AccountDataSource, + accountSecretsFactory: AccountSecretsFactory, + chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : SecretsAddAccountRepository( + accountDataSource, + accountSecretsFactory, + chainRegistry, + metaAccountChangesEventBus +) { + class Payload( + val substrateKeypair: ByteArray, + val advancedEncryption: AdvancedEncryption, + val addAccountType: AddAccountType + ) + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + return addSecretsAccount( + derivationPaths = payload.advancedEncryption.derivationPaths, + addAccountType = payload.addAccountType, + accountSource = AccountSecretsFactory.AccountSource.EncodedSr25519Keypair(key = payload.substrateKeypair) + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/TrustWalletAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/TrustWalletAddAccountRepository.kt new file mode 100644 index 0000000..b5f47a9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/secrets/TrustWalletAddAccountRepository.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.BaseAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.TrustWalletAddAccountRepository.Payload +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSource +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData +import io.novafoundation.nova.feature_account_impl.data.secrets.TrustWalletSecretsFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import javax.inject.Inject + +@FeatureScope +class TrustWalletAddAccountRepository @Inject constructor( + val accountDataSource: AccountDataSource, + val accountSecretsFactory: TrustWalletSecretsFactory, + val chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : BaseAddAccountRepository(metaAccountChangesEventBus) { + + class Payload( + val mnemonic: String, + val addAccountType: AddAccountType.MetaAccount + ) + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + val (secrets, chainAccountSecrets, substrateCryptoType) = accountSecretsFactory.metaAccountSecrets(payload.mnemonic) + + val metaId = transformingAccountInsertionErrors { + accountDataSource.insertMetaAccountWithChainAccounts( + MetaAccountInsertionData( + name = payload.addAccountType.name, + substrateCryptoType = substrateCryptoType, + secrets = secrets + ), + chainAccountSecrets.map { (chainId, derivationPath) -> + ChainAccountInsertionData( + chain = chainRegistry.getChain(chainId), + cryptoType = substrateCryptoType, + secrets = derivationPath + ) + } + ) + } + + return AddAccountResult.AccountAdded(metaId, LightMetaAccount.Type.SECRETS) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/watchOnly/WatchOnlyAddAccountRepository.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/watchOnly/WatchOnlyAddAccountRepository.kt new file mode 100644 index 0000000..89b39fe --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/addAccount/watchOnly/WatchOnlyAddAccountRepository.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.addAccount.watchOnly + +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.BaseAddAccountRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +class WatchOnlyAddAccountRepository( + private val accountDao: MetaAccountDao, + metaAccountChangesEventBus: MetaAccountChangesEventBus +) : BaseAddAccountRepository(metaAccountChangesEventBus) { + + sealed interface Payload { + class MetaAccount( + val name: String, + val substrateAccountId: AccountId, + val ethereumAccountId: AccountId? + ) : Payload + + class ChainAccount( + val metaId: Long, + val chainId: ChainId, + val accountId: AccountId + ) : Payload + } + + override suspend fun addAccountInternal(payload: Payload): AddAccountResult { + return when (payload) { + is Payload.MetaAccount -> addWatchOnlyWallet(payload) + is Payload.ChainAccount -> changeWatchOnlyChainAccount(payload) + } + } + + private suspend fun addWatchOnlyWallet(payload: Payload.MetaAccount): AddAccountResult { + val metaAccount = MetaAccountLocal( + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = payload.substrateAccountId, + ethereumPublicKey = null, + ethereumAddress = payload.ethereumAccountId, + name = payload.name, + parentMetaId = null, + isSelected = false, + position = accountDao.nextAccountPosition(), + type = MetaAccountLocal.Type.WATCH_ONLY, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = null + ) + + val metaId = accountDao.insertMetaAccount(metaAccount) + + return AddAccountResult.AccountAdded(metaId, LightMetaAccount.Type.WATCH_ONLY) + } + + private suspend fun changeWatchOnlyChainAccount(payload: Payload.ChainAccount): AddAccountResult { + val chainAccount = ChainAccountLocal( + metaId = payload.metaId, + chainId = payload.chainId, + accountId = payload.accountId, + cryptoType = null, + publicKey = null + ) + + accountDao.insertChainAccount(chainAccount) + + return AddAccountResult.AccountChanged(payload.metaId, LightMetaAccount.Type.WATCH_ONLY) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSource.kt new file mode 100644 index 0000000..042f3b6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSource.kt @@ -0,0 +1,123 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource + +import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1 +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core.model.Language +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.feature_account_api.domain.model.Account +import io.novafoundation.nova.feature_account_api.domain.model.AuthType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountAssetBalance +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountOrdering +import io.novafoundation.nova.feature_account_api.domain.model.MetaIdWithType +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import kotlinx.coroutines.flow.Flow + +interface AccountDataSource : SecretStoreV1 { + + fun getAuthTypeFlow(): Flow + + fun saveAuthType(authType: AuthType) + + fun getAuthType(): AuthType + + suspend fun savePinCode(pinCode: String) + + suspend fun getPinCode(): String? + + suspend fun saveSelectedNode(node: Node) + + suspend fun getSelectedNode(): Node? + + suspend fun anyAccountSelected(): Boolean + + suspend fun saveSelectedAccount(account: Account) + + suspend fun getSelectedMetaAccount(): MetaAccount + + fun selectedMetaAccountFlow(): Flow + + suspend fun findMetaAccount(accountId: ByteArray, chainId: ChainId): MetaAccount? + + suspend fun accountNameFor(accountId: AccountId, chainId: ChainId): String? + + fun allMetaAccountsFlow(): Flow> + + fun activeMetaAccountsFlow(): Flow> + + fun metaAccountsWithBalancesFlow(): Flow> + + fun metaAccountBalancesFlow(metaId: Long): Flow> + + suspend fun selectMetaAccount(metaId: Long) + suspend fun updateAccountPositions(accountOrdering: List) + + suspend fun getSelectedLanguage(): Language + suspend fun changeSelectedLanguage(language: Language) + + suspend fun accountExists(accountId: AccountId, chainId: ChainId): Boolean + suspend fun getMetaAccount(metaId: Long): MetaAccount + + suspend fun getMetaAccountType(metaId: Long): LightMetaAccount.Type? + + fun metaAccountFlow(metaId: Long): Flow + + suspend fun updateMetaAccountName(metaId: Long, newName: String) + suspend fun deleteMetaAccount(metaId: Long): List + + suspend fun insertMetaAccountWithChainAccounts(metaAccount: MetaAccountInsertionData, chainAccounts: List): Long + + /** + * @return id of inserted meta account + */ + suspend fun insertMetaAccountFromSecrets( + name: String, + substrateCryptoType: CryptoType, + secrets: EncodableStruct + ): Long + + suspend fun insertChainAccount( + metaId: Long, + chain: Chain, + cryptoType: CryptoType, + secrets: EncodableStruct + ) + + suspend fun hasActiveMetaAccounts(): Boolean + + fun removeDeactivatedMetaAccounts() + + suspend fun getActiveMetaAccounts(): List + + suspend fun getActiveMetaIds(): Set + + suspend fun getAllMetaAccounts(): List + + suspend fun getMetaAccountsByIds(metaIds: List): List + + fun hasMetaAccountsCountOfTypeFlow(type: LightMetaAccount.Type): Flow + + suspend fun getActiveMetaAccountsQuantity(): Int + + suspend fun hasSecretsAccounts(): Boolean + + suspend fun deleteProxiedMetaAccountsByChain(chainId: String) + + fun metaAccountsByTypeFlow(type: LightMetaAccount.Type): Flow> + + suspend fun hasMetaAccountsByType(type: LightMetaAccount.Type): Boolean + + suspend fun hasMetaAccountsByType(metaIds: Set, type: LightMetaAccount.Type): Boolean +} + +suspend fun AccountDataSource.getMetaAccountTypeOrThrow(metaId: Long): LightMetaAccount.Type { + return requireNotNull(getMetaAccountType(metaId)) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt new file mode 100644 index 0000000..b4021b6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -0,0 +1,328 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource + +import android.util.Log +import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1 +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core.model.Language +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.NodeDao +import io.novafoundation.nova.core_db.dao.withTransaction +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountPositionUpdate +import io.novafoundation.nova.feature_account_api.domain.model.Account +import io.novafoundation.nova.feature_account_api.domain.model.AuthType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountAssetBalance +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountOrdering +import io.novafoundation.nova.feature_account_api.domain.model.MetaIdWithType +import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers +import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTypeToLocal +import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val PREFS_AUTH_TYPE = "auth_type" +private const val PREFS_PIN_CODE = "pin_code" + +class AccountDataSourceImpl( + private val preferences: Preferences, + private val encryptedPreferences: EncryptedPreferences, + private val nodeDao: NodeDao, + private val metaAccountDao: MetaAccountDao, + private val accountMappers: AccountMappers, + private val secretStoreV2: SecretStoreV2, + private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, + secretStoreV1: SecretStoreV1, + accountDataMigration: AccountDataMigration, +) : AccountDataSource, SecretStoreV1 by secretStoreV1 { + + init { + migrateIfNeeded(accountDataMigration) + } + + private fun migrateIfNeeded(migration: AccountDataMigration) = async { + if (migration.migrationNeeded()) { + migration.migrate(::saveSecuritySource) + } + } + + private val selectedMetaAccountFlow = metaAccountDao.selectedMetaAccountInfoFlow() + .distinctUntilChanged() + .onEach { Log.d("AccountDataSourceImpl", "Current meta account: ${it?.metaAccount?.id}") } + .filterNotNull() + .map(accountMappers::mapMetaAccountLocalToMetaAccount) + .inBackground() + .shareIn(GlobalScope, started = SharingStarted.Eagerly, replay = 1) + + override fun getAuthTypeFlow(): Flow { + return preferences.stringFlow(PREFS_AUTH_TYPE).map { savedValue -> + if (savedValue == null) { + AuthType.PINCODE + } else { + AuthType.valueOf(savedValue) + } + } + } + + override fun saveAuthType(authType: AuthType) { + preferences.putString(PREFS_AUTH_TYPE, authType.toString()) + } + + override fun getAuthType(): AuthType { + val savedValue = preferences.getString(PREFS_AUTH_TYPE) + + return if (savedValue == null) { + AuthType.PINCODE + } else { + AuthType.valueOf(savedValue) + } + } + + override suspend fun savePinCode(pinCode: String) = withContext(Dispatchers.IO) { + encryptedPreferences.putEncryptedString(PREFS_PIN_CODE, pinCode) + } + + override suspend fun getPinCode(): String? { + return withContext(Dispatchers.IO) { + encryptedPreferences.getDecryptedString(PREFS_PIN_CODE) + } + } + + override suspend fun saveSelectedNode(node: Node) = withContext(Dispatchers.Default) { + nodeDao.switchActiveNode(node.id) + } + + override suspend fun getSelectedNode(): Node? = null + + override suspend fun anyAccountSelected(): Boolean = metaAccountDao.selectedMetaAccount() != null + + override suspend fun saveSelectedAccount(account: Account) = withContext(Dispatchers.Default) { + // TODO remove compatibility stub + } + + override suspend fun getSelectedMetaAccount(): MetaAccount { + return selectedMetaAccountFlow.first() + } + + override fun selectedMetaAccountFlow(): Flow = selectedMetaAccountFlow + + override suspend fun findMetaAccount(accountId: ByteArray, chainId: ChainId): MetaAccount? { + return metaAccountDao.getMetaAccountInfo(accountId, chainId) + ?.let { accountMappers.mapMetaAccountLocalToMetaAccount(it) } + } + + override suspend fun accountNameFor(accountId: AccountId, chainId: ChainId): String? { + return metaAccountDao.metaAccountNameFor(accountId, chainId) + } + + override suspend fun getActiveMetaAccounts(): List { + val local = metaAccountDao.getMetaAccountsByStatus(MetaAccountLocal.Status.ACTIVE) + return accountMappers.mapMetaAccountsLocalToMetaAccounts(local) + } + + override suspend fun getActiveMetaIds(): Set { + return withContext(Dispatchers.IO) { metaAccountDao.getMetaAccountsIdsByStatus(MetaAccountLocal.Status.ACTIVE).toSet() } + } + + override suspend fun getAllMetaAccounts(): List { + val local = metaAccountDao.getFullMetaAccounts() + return accountMappers.mapMetaAccountsLocalToMetaAccounts(local) + } + + override suspend fun getMetaAccountsByIds(metaIds: List): List { + val localMetaAccounts = metaAccountDao.getMetaAccountsByIds(metaIds) + return accountMappers.mapMetaAccountsLocalToMetaAccounts(localMetaAccounts) + } + + override fun hasMetaAccountsCountOfTypeFlow(type: LightMetaAccount.Type): Flow { + return metaAccountDao.hasMetaAccountsCountOfTypeFlow(mapMetaAccountTypeToLocal(type)).distinctUntilChanged() + } + + override fun metaAccountsByTypeFlow(type: LightMetaAccount.Type): Flow> { + return metaAccountDao.observeMetaAccountsByTypeFlow(mapMetaAccountTypeToLocal(type)) + .map { accountMappers.mapMetaAccountsLocalToMetaAccounts(it) } + } + + override suspend fun hasMetaAccountsByType(type: LightMetaAccount.Type): Boolean { + return metaAccountDao.hasMetaAccountsByType(mapMetaAccountTypeToLocal(type)) + } + + override suspend fun hasMetaAccountsByType(metaIds: Set, type: LightMetaAccount.Type): Boolean { + return metaAccountDao.hasMetaAccountsByType(metaIds, mapMetaAccountTypeToLocal(type)) + } + + override suspend fun getActiveMetaAccountsQuantity(): Int { + return metaAccountDao.getMetaAccountsQuantityByStatus(MetaAccountLocal.Status.ACTIVE) + } + + override suspend fun deleteProxiedMetaAccountsByChain(chainId: String) { + return metaAccountDao.deleteProxiedMetaAccountsByChain(chainId) + } + + override suspend fun hasSecretsAccounts(): Boolean { + return metaAccountDao.hasMetaAccountsByType(MetaAccountLocal.Type.SECRETS) + } + + override fun allMetaAccountsFlow(): Flow> { + return metaAccountDao.getJoinedMetaAccountsInfoFlow().map { accountsLocal -> + accountMappers.mapMetaAccountsLocalToMetaAccounts(accountsLocal) + } + } + + override fun activeMetaAccountsFlow(): Flow> { + return metaAccountDao.getJoinedMetaAccountsInfoByStatusFlow(MetaAccountLocal.Status.ACTIVE) + .map { accountsLocal -> + accountMappers.mapMetaAccountsLocalToMetaAccounts(accountsLocal) + } + } + + override fun metaAccountsWithBalancesFlow(): Flow> { + return metaAccountDao.metaAccountsWithBalanceFlow().mapList(::mapMetaAccountWithBalanceFromLocal) + } + + override fun metaAccountBalancesFlow(metaId: Long): Flow> { + return metaAccountDao.metaAccountWithBalanceFlow(metaId).mapList(::mapMetaAccountWithBalanceFromLocal) + } + + override suspend fun selectMetaAccount(metaId: Long) { + metaAccountDao.selectMetaAccount(metaId) + } + + override suspend fun updateAccountPositions(accountOrdering: List) = withContext(Dispatchers.Default) { + val positionUpdates = accountOrdering.map { + MetaAccountPositionUpdate(id = it.id, position = it.position) + } + + metaAccountDao.updatePositions(positionUpdates) + } + + override suspend fun getSelectedLanguage(): Language = withContext(Dispatchers.IO) { + preferences.getCurrentLanguage() ?: throw IllegalArgumentException("No language selected") + } + + override suspend fun changeSelectedLanguage(language: Language) = withContext(Dispatchers.IO) { + preferences.saveCurrentLanguage(language.iso639Code) + } + + override suspend fun accountExists(accountId: AccountId, chainId: ChainId): Boolean { + return metaAccountDao.isMetaAccountExists(accountId, chainId) + } + + override suspend fun getMetaAccount(metaId: Long): MetaAccount { + val joinedMetaAccountInfo = metaAccountDao.getJoinedMetaAccountInfo(metaId) + + return accountMappers.mapMetaAccountLocalToMetaAccount(joinedMetaAccountInfo) + } + + override suspend fun getMetaAccountType(metaId: Long): LightMetaAccount.Type? { + return metaAccountDao.getMetaAccountType(metaId)?.let(accountMappers::mapMetaAccountTypeFromLocal) + } + + override fun metaAccountFlow(metaId: Long): Flow { + return metaAccountDao.metaAccountInfoFlow(metaId).mapNotNull { local -> + local?.let { accountMappers.mapMetaAccountLocalToMetaAccount(it) } + } + } + + override suspend fun updateMetaAccountName(metaId: Long, newName: String) { + metaAccountDao.updateName(metaId, newName) + } + + override suspend fun deleteMetaAccount(metaId: Long): List { + val joinedMetaAccountInfo = metaAccountDao.getJoinedMetaAccountInfo(metaId) + val chainAccountIds = joinedMetaAccountInfo.chainAccounts.map(ChainAccountLocal::accountId) + + val allAffectedMetaAccounts = metaAccountDao.delete(metaId) + secretStoreV2.clearMetaAccountSecrets(metaId, chainAccountIds) + return allAffectedMetaAccounts.map { MetaIdWithType(it.id, accountMappers.mapMetaAccountTypeFromLocal(it.type)) } + } + + override suspend fun insertMetaAccountWithChainAccounts( + metaAccount: MetaAccountInsertionData, + chainAccounts: List + ) = withContext(Dispatchers.Default) { + metaAccountDao.withTransaction { + val metaId = insertMetaAccountFromSecrets(metaAccount.name, metaAccount.substrateCryptoType, metaAccount.secrets) + chainAccounts.forEach { insertChainAccount(metaId, it.chain, it.cryptoType, it.secrets) } + metaId + } + } + + override suspend fun insertMetaAccountFromSecrets( + name: String, + substrateCryptoType: CryptoType, + secrets: EncodableStruct + ) = withContext(Dispatchers.Default) { + val metaAccountLocal = secretsMetaAccountLocalFactory.create(name, substrateCryptoType, secrets, metaAccountDao.nextAccountPosition()) + + val metaId = metaAccountDao.insertMetaAccount(metaAccountLocal) + secretStoreV2.putMetaAccountSecrets(metaId, secrets) + + metaId + } + + override suspend fun insertChainAccount( + metaId: Long, + chain: Chain, + cryptoType: CryptoType, + secrets: EncodableStruct + ) = withContext(Dispatchers.Default) { + val publicKey = secrets[ChainAccountSecrets.Keypair][KeyPairSchema.PublicKey] + val accountId = chain.accountIdOf(publicKey) + + val chainAccountLocal = ChainAccountLocal( + metaId = metaId, + chainId = chain.id, + publicKey = publicKey, + accountId = accountId, + cryptoType = cryptoType + ) + + metaAccountDao.insertChainAccount(chainAccountLocal) + secretStoreV2.putChainAccountSecrets(metaId, accountId, secrets) + } + + override suspend fun hasActiveMetaAccounts(): Boolean { + return metaAccountDao.hasMetaAccountsByStatus(MetaAccountLocal.Status.ACTIVE) + } + + override fun removeDeactivatedMetaAccounts() { + metaAccountDao.removeMetaAccountsByStatus(MetaAccountLocal.Status.DEACTIVATED) + } + + private inline fun async(crossinline action: suspend () -> Unit) { + GlobalScope.launch(Dispatchers.Default) { + action() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt new file mode 100644 index 0000000..4a2e524 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource + +import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.utils.substrateAccountId +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +interface SecretsMetaAccountLocalFactory { + + fun create( + name: String, + substrateCryptoType: CryptoType, + secrets: EncodableStruct, + accountSortPosition: Int, + ): MetaAccountLocal +} + +class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory { + + override fun create( + name: String, + substrateCryptoType: CryptoType, + secrets: EncodableStruct, + accountSortPosition: Int, + ): MetaAccountLocal { + val substratePublicKey = secrets[MetaAccountSecrets.SubstrateKeypair][KeyPairSchema.PublicKey] + val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey) + + return MetaAccountLocal( + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substratePublicKey.substrateAccountId(), + ethereumPublicKey = ethereumPublicKey, + ethereumAddress = ethereumPublicKey?.asEthereumPublicKey()?.toAccountId()?.value, + name = name, + parentMetaId = null, + isSelected = false, + position = accountSortPosition, + type = MetaAccountLocal.Type.SECRETS, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = null + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/AccountDataMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/AccountDataMigration.kt new file mode 100644 index 0000000..b7132fa --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/AccountDataMigration.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration + +import android.annotation.SuppressLint +import io.novafoundation.nova.common.data.secrets.v1.Keypair +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.core.model.SecuritySource +import io.novafoundation.nova.core_db.dao.AccountDao +import io.novafoundation.nova.core_db.model.AccountLocal +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import io.novasama.substrate_sdk_android.scale.Schema +import io.novasama.substrate_sdk_android.scale.byteArray +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bouncycastle.util.encoders.Hex + +private const val PREFS_PRIVATE_KEY = "private_%s" +private const val PREFS_SEED_MASK = "seed_%s" +private const val PREFS_DERIVATION_MASK = "derivation_%s" +private const val PREFS_ENTROPY_MASK = "entropy_%s" +private const val PREFS_MIGRATED_FROM_0_4_1_TO_1_0_0 = "migrated_from_0_4_1_to_1_0_0" + +private object ScaleSigningData : Schema() { + val PrivateKey by byteArray() + val PublicKey by byteArray() + + val Nonce by byteArray().optional() +} + +typealias SaveSourceCallback = suspend (String, SecuritySource) -> Unit + +@SuppressLint("CheckResult") +class AccountDataMigration( + private val preferences: Preferences, + private val encryptedPreferences: EncryptedPreferences, + private val accountsDao: AccountDao, +) { + + suspend fun migrationNeeded(): Boolean = withContext(Dispatchers.Default) { + val migrated = preferences.getBoolean(PREFS_MIGRATED_FROM_0_4_1_TO_1_0_0, false) + + !migrated + } + + suspend fun migrate(saveSourceCallback: SaveSourceCallback) = withContext(Dispatchers.Default) { + val accounts = accountsDao.getAccounts() + + migrateAllAccounts(accounts, saveSourceCallback) + + preferences.putBoolean(PREFS_MIGRATED_FROM_0_4_1_TO_1_0_0, true) + } + + private suspend fun migrateAllAccounts(accounts: List, saveSourceCallback: SaveSourceCallback) { + accounts.forEach { migrateAccount(it.address, saveSourceCallback) } + } + + private suspend fun migrateAccount(accountAddress: String, saveSourceCallback: SaveSourceCallback) { + val oldKey = PREFS_PRIVATE_KEY.format(accountAddress) + val oldRaw = encryptedPreferences.getDecryptedString(oldKey) ?: return + val data = ScaleSigningData.read(oldRaw) + + val keypair = Keypair( + publicKey = data[ScaleSigningData.PublicKey], + privateKey = data[ScaleSigningData.PrivateKey], + nonce = data[ScaleSigningData.Nonce] + ) + + val seedKey = PREFS_SEED_MASK.format(accountAddress) + val seedValue = encryptedPreferences.getDecryptedString(seedKey) + val seed = seedValue?.let { Hex.decode(it) } + + val derivationKey = PREFS_DERIVATION_MASK.format(accountAddress) + val derivationPath = encryptedPreferences.getDecryptedString(derivationKey) + + val entropyKey = PREFS_ENTROPY_MASK.format(accountAddress) + val entropyValue = encryptedPreferences.getDecryptedString(entropyKey) + val entropy = entropyValue?.let { Hex.decode(it) } + + val securitySource = if (entropy != null) { + val mnemonic = MnemonicCreator.fromEntropy(entropy) + + SecuritySource.Specified.Mnemonic(seed, keypair, mnemonic.words, derivationPath) + } else { + if (seed != null) { + SecuritySource.Specified.Seed(seed, keypair, derivationPath) + } else { + SecuritySource.Unspecified(keypair) + } + } + + saveSourceCallback(accountAddress, securitySource) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/model/ChainAccountInsertionData.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/model/ChainAccountInsertionData.kt new file mode 100644 index 0000000..dcfbb4c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/model/ChainAccountInsertionData.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model + +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +class ChainAccountInsertionData( + val chain: Chain, + val cryptoType: CryptoType, + val secrets: EncodableStruct +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/model/MetaAccountInsertionData.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/model/MetaAccountInsertionData.kt new file mode 100644 index 0000000..f20aedb --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/model/MetaAccountInsertionData.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model + +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.core.model.CryptoType +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +class MetaAccountInsertionData( + val name: String, + val substrateCryptoType: CryptoType, + val secrets: EncodableStruct +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt new file mode 100644 index 0000000..1f0d0b3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt @@ -0,0 +1,168 @@ +package io.novafoundation.nova.feature_account_impl.data.secrets + +import io.novafoundation.nova.common.data.mappers.mapCryptoTypeToEncryption +import io.novafoundation.nova.common.data.mappers.mapEncryptionToCryptoType +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.deriveSeed32 +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.data.derivationPath.DerivationPathDecoder +import io.novasama.substrate_sdk_android.encrypt.EncryptionType +import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption +import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder +import io.novasama.substrate_sdk_android.encrypt.junction.JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.Bip32EcdsaKeypairFactory +import io.novasama.substrate_sdk_android.encrypt.keypair.generate +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.Sr25519SubstrateKeypairFactory +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.SubstrateKeypairFactory +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import io.novasama.substrate_sdk_android.encrypt.seed.SeedFactory +import io.novasama.substrate_sdk_android.encrypt.seed.bip39.Bip39SeedFactory +import io.novasama.substrate_sdk_android.encrypt.seed.substrate.SubstrateSeedFactory +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import io.novasama.substrate_sdk_android.scale.Schema +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AccountSecretsFactory( + private val JsonDecoder: JsonDecoder +) { + + sealed class AccountSource { + + class Mnemonic(val cryptoType: CryptoType, val mnemonic: String) : AccountSource() + + class Seed(val cryptoType: CryptoType, val seed: String) : AccountSource() + + class Json(val json: String, val password: String) : AccountSource() + + class EncodedSr25519Keypair(val key: ByteArray) : AccountSource() + } + + sealed class SecretsError : Exception() { + + class NotValidEthereumCryptoType : SecretsError() + + class NotValidSubstrateCryptoType : SecretsError() + } + + data class Result>(val secrets: EncodableStruct, val cryptoType: CryptoType) + + suspend fun chainAccountSecrets( + derivationPath: String?, + accountSource: AccountSource, + isEthereum: Boolean, + ): Result = withContext(Dispatchers.Default) { + val mnemonicWords = accountSource.castOrNull()?.mnemonic + val entropy = mnemonicWords?.let(MnemonicCreator::fromWords)?.entropy + val decodedDerivationPath = decodeDerivationPath(derivationPath, ethereum = isEthereum) + + val decodedJson = accountSource.castOrNull()?.let { jsonSource -> + JsonDecoder.decode(jsonSource.json, jsonSource.password).also { + // only allow Ethereum JSONs for ethereum chains + if (isEthereum && it.multiChainEncryption != MultiChainEncryption.Ethereum) { + throw SecretsError.NotValidEthereumCryptoType() + } + + // only allow Substrate JSONs for substrate chains + if (!isEthereum && it.multiChainEncryption == MultiChainEncryption.Ethereum) { + throw SecretsError.NotValidSubstrateCryptoType() + } + } + } + + val encryptionType = when (accountSource) { + is AccountSource.Mnemonic -> mapCryptoTypeToEncryption(accountSource.cryptoType) + is AccountSource.Seed -> mapCryptoTypeToEncryption(accountSource.cryptoType) + is AccountSource.Json -> decodedJson!!.multiChainEncryption.encryptionType + is AccountSource.EncodedSr25519Keypair -> EncryptionType.SR25519 + } + + val seed = when (accountSource) { + is AccountSource.Mnemonic -> deriveSeed(accountSource.mnemonic, decodedDerivationPath?.password, ethereum = isEthereum).seed + is AccountSource.Seed -> accountSource.seed.fromHex() + is AccountSource.Json -> null + is AccountSource.EncodedSr25519Keypair -> null + } + + val keypair = when { + seed != null -> { + val junctions = decodedDerivationPath?.junctions.orEmpty() + + if (isEthereum) { + Bip32EcdsaKeypairFactory.generate(seed, junctions) + } else { + SubstrateKeypairFactory.generate(encryptionType, seed, junctions) + } + } + + decodedJson != null -> { + decodedJson.keypair + } + + else -> { + val encodedSr25519Keypair = accountSource.cast() + Sr25519SubstrateKeypairFactory.createKeypairFromSecret(encodedSr25519Keypair.key) + } + } + + val secrets = ChainAccountSecrets( + keyPair = keypair, + entropy = entropy, + seed = seed, + derivationPath = derivationPath, + ) + + Result(secrets = secrets, cryptoType = mapEncryptionToCryptoType(encryptionType)) + } + + suspend fun metaAccountSecrets( + substrateDerivationPath: String?, + ethereumDerivationPath: String?, + accountSource: AccountSource, + ): Result = withContext(Dispatchers.Default) { + val (substrateSecrets, substrateCryptoType) = chainAccountSecrets( + derivationPath = substrateDerivationPath, + accountSource = accountSource, + isEthereum = false + ) + + val ethereumKeypair = accountSource.castOrNull()?.let { + val decodedEthereumDerivationPath = decodeDerivationPath(ethereumDerivationPath, ethereum = true) + + val seed = deriveSeed(it.mnemonic, password = decodedEthereumDerivationPath?.password, ethereum = true).seed + + Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedEthereumDerivationPath?.junctions.orEmpty()) + } + + val secrets = MetaAccountSecrets( + entropy = substrateSecrets[ChainAccountSecrets.Entropy], + substrateSeed = substrateSecrets[ChainAccountSecrets.Seed], + substrateKeyPair = mapKeypairStructToKeypair(substrateSecrets[ChainAccountSecrets.Keypair]), + substrateDerivationPath = substrateDerivationPath, + ethereumKeypair = ethereumKeypair, + ethereumDerivationPath = ethereumDerivationPath + ) + + Result(secrets = secrets, cryptoType = substrateCryptoType) + } + + private fun deriveSeed(mnemonic: String, password: String?, ethereum: Boolean): SeedFactory.Result { + return if (ethereum) { + Bip39SeedFactory.deriveSeed(mnemonic, password) + } else { + SubstrateSeedFactory.deriveSeed32(mnemonic, password) + } + } + + private fun decodeDerivationPath(derivationPath: String?, ethereum: Boolean): JunctionDecoder.DecodeResult? { + return when { + ethereum -> DerivationPathDecoder.decodeEthereumDerivationPath(derivationPath) + else -> DerivationPathDecoder.decodeSubstrateDerivationPath(derivationPath) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/TrustWalletSecretsFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/TrustWalletSecretsFactory.kt new file mode 100644 index 0000000..fc11546 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/TrustWalletSecretsFactory.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.feature_account_impl.data.secrets + +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.DEFAULT_DERIVATION_PATH +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.Bip32EcdsaKeypairFactory +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.Bip32Ed25519KeypairFactory +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.generate +import io.novasama.substrate_sdk_android.encrypt.seed.SeedFactory +import io.novasama.substrate_sdk_android.encrypt.seed.bip39.Bip39SeedFactory +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@FeatureScope +class TrustWalletSecretsFactory @Inject constructor() { + + companion object { + + private const val TRUST_SUBSTRATE_DERIVATION_PATH = "//44//354//0//0//0" + + private fun trustWalletChainAccountDerivationPaths(): Map { + return mapOf( + ChainGeneses.KUSAMA to "//44//434//0//0//0", + ChainGeneses.KUSAMA_ASSET_HUB to "//44//434//0//0//0" + ) + } + } + + suspend fun metaAccountSecrets(mnemonicWords: String): TrustWalletMetaAccountSecrets = withContext(Dispatchers.Default) { + val seedResult = deriveSeed(mnemonicWords) + + val secrets = MetaAccountSecrets( + entropy = seedResult.mnemonic.entropy, + substrateDerivationPath = TRUST_SUBSTRATE_DERIVATION_PATH, + substrateSeed = seedResult.seed, + substrateKeyPair = deriveKeypair(seedResult, AddressScheme.SUBSTRATE), + ethereumKeypair = deriveKeypair(seedResult, AddressScheme.EVM), + ethereumDerivationPath = BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH + ) + + val chainAccountSecrets = trustWalletChainAccountDerivationPaths() + .mapValues { (_, derivationPath) -> + val chainKeyPair = deriveKeypair(seedResult, AddressScheme.SUBSTRATE, derivationPath) + ChainAccountSecrets( + entropy = seedResult.mnemonic.entropy, + seed = seedResult.seed, + derivationPath = derivationPath, + keyPair = chainKeyPair + ) + } + + TrustWalletMetaAccountSecrets(secrets, chainAccountSecrets, substrateCryptoType = CryptoType.ED25519) + } + + private fun deriveSeed(mnemonic: String): SeedFactory.Result { + return Bip39SeedFactory.deriveSeed(mnemonic, password = null) + } + + private fun deriveKeypair( + seedResult: SeedFactory.Result, + addressScheme: AddressScheme, + derivationPath: String = getDerivationPath(addressScheme) + ): Keypair { + return when (addressScheme) { + AddressScheme.EVM -> Bip32EcdsaKeypairFactory.generate(seedResult.seed, derivationPath) + AddressScheme.SUBSTRATE -> Bip32Ed25519KeypairFactory.generate(seedResult.seed, derivationPath) + } + } + + private fun getDerivationPath(addressScheme: AddressScheme): String { + return when (addressScheme) { + AddressScheme.EVM -> BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH + AddressScheme.SUBSTRATE -> TRUST_SUBSTRATE_DERIVATION_PATH + } + } + + data class TrustWalletMetaAccountSecrets( + val secrets: EncodableStruct, + val chainAccountSecrets: Map>, + val substrateCryptoType: CryptoType + ) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/LeafSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/LeafSigner.kt new file mode 100644 index 0000000..2cdcedd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/LeafSigner.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_account_impl.data.signer + +import android.util.Log +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.encrypt.EncryptionType +import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.Bip32EcdsaKeypairFactory +import io.novasama.substrate_sdk_android.encrypt.keypair.generate +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.SubstrateKeypairFactory +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.KeyPairSigner +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckNonce.Companion.setNonce +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.GeneralTransactionSigner +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.VerifySignature.Companion.setVerifySignature + +private val FAKE_CRYPTO_TYPE = EncryptionType.ECDSA + +/** + * A basic implementation of [NovaSigner] that implements foundation for any signer that + * does not delegate any of the [NovaSigner] methods to the nested signers + */ +abstract class LeafSigner( + override val metaAccount: MetaAccount, +) : NovaSigner, GeneralTransactionSigner { + + override suspend fun getSigningHierarchy(): SubmissionHierarchy { + return SubmissionHierarchy(metaAccount, callExecutionType()) + } + + // All leaf signers are immediate atm so we implement it here to reduce boilerplate + // Feel free to move it down if some leaf signer is actually immediate + override suspend fun callExecutionType(): CallExecutionType { + return CallExecutionType.IMMEDIATE + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForSubmission(context: SigningContext) { + val accountId = metaAccount.requireAccountIdKeyIn(context.chain) + setNonce(context.getNonce(accountId)) + + Log.d("Signer", "${this::class.simpleName}: set real signature") + + setVerifySignature(signer = this, accountId = accountId.value) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForFee(context: SigningContext) { + // We set it to 100 so we won't accidentally cause fee underestimation + // Underestimation might happen because fee depends on the extrinsic length and encoding of zero is more compact + setNonce(100.toBigInteger()) + + val (signer, accountId) = createFeeSigner(context.chain) + + Log.d("Signer", "${this::class.simpleName}: set fake signature") + + setVerifySignature(signer, accountId) + } + + override suspend fun submissionSignerAccountId(chain: Chain): AccountId { + return metaAccount.requireAccountIdIn(chain) + } + + context(ExtrinsicBuilder) + private fun createFeeSigner(chain: Chain): Pair { + val keypair = generateFeeKeyPair(chain) + val signer = KeyPairSigner(keypair, feeMultiChainEncryption(chain)) + + return signer to chain.accountIdOf(keypair.publicKey) + } + + private fun feeMultiChainEncryption(chain: Chain) = if (chain.isEthereumBased) { + MultiChainEncryption.Ethereum + } else { + MultiChainEncryption.Substrate(FAKE_CRYPTO_TYPE) + } + + context(ExtrinsicBuilder) + private fun generateFeeKeyPair(chain: Chain): Keypair { + return if (chain.isEthereumBased) { + val emptySeed = ByteArray(64) { 1 } + + Bip32EcdsaKeypairFactory.generate(emptySeed, junctions = emptyList()) + } else { + val emptySeed = ByteArray(32) { 1 } + + SubstrateKeypairFactory.generate(FAKE_CRYPTO_TYPE, emptySeed, junctions = emptyList()) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/RealSignerProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/RealSignerProvider.kt new file mode 100644 index 0000000..21f6918 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/RealSignerProvider.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_impl.data.signer + +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_impl.data.signer.ledger.LedgerSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.multisig.MultisigSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.proxy.ProxiedSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.secrets.SecretsSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.watchOnly.WatchOnlySignerFactory + +internal class RealSignerProvider( + private val secretsSignerFactory: SecretsSignerFactory, + private val proxiedSignerFactory: ProxiedSignerFactory, + private val watchOnlySigner: WatchOnlySignerFactory, + private val polkadotVaultSignerFactory: PolkadotVaultVariantSignerFactory, + private val ledgerSignerFactory: LedgerSignerFactory, + private val multisigSignerFactory: MultisigSignerFactory +) : SignerProvider { + + override fun rootSignerFor(metaAccount: MetaAccount): NovaSigner { + return signerFor(metaAccount, isRoot = true) + } + + override fun nestedSignerFor(metaAccount: MetaAccount): NovaSigner { + return signerFor(metaAccount, isRoot = false) + } + + private fun signerFor(metaAccount: MetaAccount, isRoot: Boolean): NovaSigner { + return when (metaAccount.type) { + LightMetaAccount.Type.SECRETS -> secretsSignerFactory.create(metaAccount) + LightMetaAccount.Type.WATCH_ONLY -> watchOnlySigner.create(metaAccount) + LightMetaAccount.Type.PARITY_SIGNER -> polkadotVaultSignerFactory.createParitySigner(metaAccount) + LightMetaAccount.Type.POLKADOT_VAULT -> polkadotVaultSignerFactory.createPolkadotVault(metaAccount) + LightMetaAccount.Type.LEDGER -> ledgerSignerFactory.create(metaAccount, LedgerVariant.GENERIC) + LightMetaAccount.Type.LEDGER_LEGACY -> ledgerSignerFactory.create(metaAccount, LedgerVariant.LEGACY) + LightMetaAccount.Type.PROXIED -> proxiedSignerFactory.create(metaAccount as ProxiedMetaAccount, this, isRoot) + LightMetaAccount.Type.MULTISIG -> multisigSignerFactory.create(metaAccount as MultisigMetaAccount, this, isRoot) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/SeparateFlowSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/SeparateFlowSigner.kt new file mode 100644 index 0000000..ebc7853 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/SeparateFlowSigner.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_account_impl.data.signer + +import io.novafoundation.nova.common.base.errors.SigningCancelledException +import io.novafoundation.nova.feature_account_api.data.signer.SeparateFlowSignerState +import io.novafoundation.nova.feature_account_api.data.signer.SignerPayload +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenRequester +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignatureWrapper +import io.novafoundation.nova.feature_account_api.presenatation.sign.awaitConfirmation +import io.novafoundation.nova.runtime.extrinsic.signer.SignerPayloadRawWithChain +import io.novafoundation.nova.runtime.extrinsic.signer.withoutChain +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +abstract class SeparateFlowSigner( + private val signingSharedState: SigningSharedState, + private val signFlowRequester: SignInterScreenRequester, + metaAccount: MetaAccount, +) : LeafSigner(metaAccount) { + + override suspend fun signInheritedImplication( + inheritedImplication: InheritedImplication, + accountId: AccountId + ): SignatureWrapper { + val payload = SeparateFlowSignerState(SignerPayload.Extrinsic(inheritedImplication, accountId), metaAccount) + + val result = awaitConfirmation(payload) + + if (result is SignInterScreenCommunicator.Response.Signed) { + return SignatureWrapper(result.signature) + } else { + throw SigningCancelledException() + } + } + + protected suspend fun useSignRawFlowRequester(payload: SignerPayloadRawWithChain): SignedRaw { + val state = SeparateFlowSignerState(SignerPayload.Raw(payload), metaAccount) + + val result = awaitConfirmation(state) + + if (result is SignInterScreenCommunicator.Response.Signed) { + val signature = SignatureWrapper(result.signature) + return SignedRaw(payload.withoutChain(), signature) + } else { + throw SigningCancelledException() + } + } + + private suspend fun awaitConfirmation(state: SeparateFlowSignerState): SignInterScreenCommunicator.Response { + signingSharedState.set(state) + + return withContext(Dispatchers.Main) { + try { + signFlowRequester.awaitConfirmation() + } finally { + signingSharedState.reset() + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/ledger/LedgerSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/ledger/LedgerSigner.kt new file mode 100644 index 0000000..132dac0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/ledger/LedgerSigner.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.ledger + +import io.novafoundation.nova.common.base.errors.SigningCancelledException +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.sign.LedgerSignCommunicator +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.signer.SeparateFlowSigner +import io.novafoundation.nova.feature_account_impl.presentation.common.sign.notSupported.SigningNotSupportedPresentable +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import javax.inject.Inject + +@FeatureScope +class LedgerSignerFactory @Inject constructor( + private val signingSharedState: SigningSharedState, + private val signFlowRequester: LedgerSignCommunicator, + private val resourceManager: ResourceManager, + private val messageSigningNotSupported: SigningNotSupportedPresentable, +) { + + fun create(metaAccount: MetaAccount, ledgerVariant: LedgerVariant): LedgerSigner { + return LedgerSigner( + metaAccount = metaAccount, + signingSharedState = signingSharedState, + signFlowRequester = signFlowRequester, + resourceManager = resourceManager, + messageSigningNotSupported = messageSigningNotSupported, + ledgerVariant = ledgerVariant, + ) + } +} + +class LedgerSigner( + metaAccount: MetaAccount, + signingSharedState: SigningSharedState, + private val signFlowRequester: LedgerSignCommunicator, + private val ledgerVariant: LedgerVariant, + private val resourceManager: ResourceManager, + private val messageSigningNotSupported: SigningNotSupportedPresentable, +) : SeparateFlowSigner(signingSharedState, signFlowRequester, metaAccount) { + + companion object { + + // Ledger runs with quite severe resource restrictions so we should explicitly lower the number of calls per transaction + // Otherwise Ledger will run out of RAM when decoding such big txs + private const val MAX_CALLS_PER_TRANSACTION = 6 + } + + override suspend fun signInheritedImplication(inheritedImplication: InheritedImplication, accountId: AccountId): SignatureWrapper { + signFlowRequester.setUsedVariant(ledgerVariant) + + return super.signInheritedImplication(inheritedImplication, accountId) + } + + override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + messageSigningNotSupported.presentSigningNotSupported( + SigningNotSupportedPresentable.Payload( + iconRes = R.drawable.ic_ledger, + message = resourceManager.getString(R.string.ledger_sign_raw_not_supported) + ) + ) + + throw SigningCancelledException() + } + + override suspend fun maxCallsPerTransaction(): Int { + return MAX_CALLS_PER_TRANSACTION + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/multisig/MultisigSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/multisig/MultisigSigner.kt new file mode 100644 index 0000000..db73837 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/multisig/MultisigSigner.kt @@ -0,0 +1,203 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.multisig + +import io.novafoundation.nova.common.base.errors.SigningCancelledException +import io.novafoundation.nova.common.data.memory.SingleValueCache +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_account_api.data.multisig.composeMultisigAsMulti +import io.novafoundation.nova.feature_account_api.data.multisig.composeMultisigAsMultiThreshold1 +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationPayload +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.data.multisig.validation.SignatoryFeePaymentMode +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.data.signer.intersect +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.isThreshold1 +import io.novafoundation.nova.feature_account_impl.data.signer.LeafSigner +import io.novafoundation.nova.feature_account_impl.presentation.multisig.MultisigSigningPresenter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import javax.inject.Inject + +@FeatureScope +class MultisigSignerFactory @Inject constructor( + private val accountRepository: AccountRepository, + private val multisigExtrinsicValidationEventBus: MultisigExtrinsicValidationRequestBus, + private val multisigSigningPresenter: MultisigSigningPresenter, +) { + + fun create(metaAccount: MultisigMetaAccount, signerProvider: SignerProvider, isRoot: Boolean): MultisigSigner { + return MultisigSigner( + accountRepository = accountRepository, + signerProvider = signerProvider, + isRootSigner = isRoot, + multisigExtrinsicValidationEventBus = multisigExtrinsicValidationEventBus, + multisigSigningPresenter = multisigSigningPresenter, + multisigAccount = metaAccount, + + ) + } +} + +// TODO multisig: +// 1. Create a base class NestedSigner for Multisig and Proxieds +class MultisigSigner( + private val multisigAccount: MultisigMetaAccount, + private val accountRepository: AccountRepository, + private val signerProvider: SignerProvider, + private val multisigExtrinsicValidationEventBus: MultisigExtrinsicValidationRequestBus, + private val multisigSigningPresenter: MultisigSigningPresenter, + private val isRootSigner: Boolean, +) : NovaSigner { + + override val metaAccount = multisigAccount + + private val selfCallExecutionType = if (multisigAccount.isThreshold1()) { + CallExecutionType.IMMEDIATE + } else { + CallExecutionType.DELAYED + } + + private val signatoryMetaAccount = SingleValueCache { + computeSignatoryMetaAccount() + } + + private val delegateSigner = SingleValueCache { + signerProvider.nestedSignerFor(signatoryMetaAccount()) + } + + override suspend fun getSigningHierarchy(): SubmissionHierarchy { + return delegateSigner().getSigningHierarchy() + SubmissionHierarchy(metaAccount, selfCallExecutionType) + } + + override suspend fun callExecutionType(): CallExecutionType { + return delegateSigner().callExecutionType().intersect(selfCallExecutionType) + } + + override suspend fun submissionSignerAccountId(chain: Chain): AccountId { + return delegateSigner().submissionSignerAccountId(chain) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForSubmission(context: SigningContext) { + if (isRootSigner) { + acknowledgeMultisigOperation() + } + + val callInsideAsMulti = getWrappedCall() + + // We intentionally do validation before wrapping to pass the actual call to the validation + validateExtrinsic(context.chain, callInsideAsMulti) + + wrapCallsInAsMultiForSubmission() + + delegateSigner().setSignerDataForSubmission(context) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForFee(context: SigningContext) { + delegateSigner().setSignerDataForFee(context) + + wrapCallsInProxyForFee() + } + + override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + multisigSigningPresenter.signingIsNotSupported() + throw SigningCancelledException() + } + + override suspend fun maxCallsPerTransaction(): Int? { + return delegateSigner().maxCallsPerTransaction() + } + + private suspend fun acknowledgeMultisigOperation() { + val resume = multisigSigningPresenter.acknowledgeMultisigOperation(multisigAccount, signatoryMetaAccount()) + if (!resume) throw SigningCancelledException() + } + + context(ExtrinsicBuilder) + private suspend fun validateExtrinsic( + chain: Chain, + callInsideAsMulti: GenericCall.Instance, + ) { + val validationPayload = MultisigExtrinsicValidationPayload( + multisig = multisigAccount, + signatory = signatoryMetaAccount(), + chain = chain, + signatoryFeePaymentMode = determineSignatoryFeePaymentMode(), + callInsideAsMulti = callInsideAsMulti + ) + + val requestBusPayload = MultisigExtrinsicValidationRequestBus.Request(validationPayload) + multisigExtrinsicValidationEventBus.handle(requestBusPayload) + .validationResult + .onSuccess { + if (it is ValidationStatus.NotValid) { + multisigSigningPresenter.presentValidationFailure(it.reason) + throw SigningCancelledException() + } + } + .onFailure { + throw it + } + } + + context(ExtrinsicBuilder) + private suspend fun determineSignatoryFeePaymentMode(): SignatoryFeePaymentMode { + // Our direct signatory only pay fees if it is a LeafSigner + // Otherwise it is paid by signer's own delegate + return if (delegateSigner() is LeafSigner) { + SignatoryFeePaymentMode.PaysSubmissionFee + } else { + SignatoryFeePaymentMode.NothingToPay + } + } + + context(ExtrinsicBuilder) + private fun wrapCallsInAsMultiForSubmission() { + // We do not calculate precise max_weight as it is only needed for the final approval + return wrapCallsInAsMulti(maxWeight = WeightV2.zero()) + } + + context(ExtrinsicBuilder) + private fun wrapCallsInProxyForFee() { + wrapCallsInAsMulti(maxWeight = WeightV2.zero()) + } + + context(ExtrinsicBuilder) + private fun wrapCallsInAsMulti(maxWeight: WeightV2) { + val call = getWrappedCall() + + val multisigCall = if (multisigAccount.isThreshold1()) { + runtime.composeMultisigAsMultiThreshold1( + multisigMetaAccount = multisigAccount, + call = call + ) + } else { + runtime.composeMultisigAsMulti( + multisigMetaAccount = multisigAccount, + maybeTimePoint = null, + call = call, + maxWeight = maxWeight + ) + } + + resetCalls() + call(multisigCall) + } + + private suspend fun computeSignatoryMetaAccount(): MetaAccount { + return accountRepository.getMetaAccount(multisigAccount.signatoryMetaId) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/ParitySignerSignCommunicator.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/ParitySignerSignCommunicator.kt new file mode 100644 index 0000000..5a76b8d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/ParitySignerSignCommunicator.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.paritySigner + +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator + +interface PolkadotVaultVariantSignCommunicator : SignInterScreenCommunicator { + + fun setUsedVariant(variant: PolkadotVaultVariant) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/PolkadotVaultSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/PolkadotVaultSigner.kt new file mode 100644 index 0000000..17b4fc0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/PolkadotVaultSigner.kt @@ -0,0 +1,137 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.paritySigner + +import io.novafoundation.nova.common.base.errors.SigningCancelledException +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.formatWithPolkadotVaultLabel +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.signer.SeparateFlowSigner +import io.novafoundation.nova.feature_account_impl.presentation.common.sign.notSupported.SigningNotSupportedPresentable +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novafoundation.nova.runtime.extrinsic.signer.SignerPayloadRawWithChain +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import javax.inject.Inject + +@FeatureScope +class PolkadotVaultVariantSignerFactory @Inject constructor( + private val signingSharedState: SigningSharedState, + private val signFlowRequester: PolkadotVaultVariantSignCommunicator, + private val resourceManager: ResourceManager, + private val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + private val messageSigningNotSupported: SigningNotSupportedPresentable, +) { + + fun createPolkadotVault(metaAccount: MetaAccount): PolkadotVaultSigner { + return PolkadotVaultSigner( + signingSharedState = signingSharedState, + metaAccount = metaAccount, + signFlowRequester = signFlowRequester, + resourceManager = resourceManager, + polkadotVaultVariantConfigProvider = polkadotVaultVariantConfigProvider, + messageSigningNotSupported = messageSigningNotSupported, + ) + } + + fun createParitySigner(metaAccount: MetaAccount): ParitySignerSigner { + return ParitySignerSigner( + signingSharedState = signingSharedState, + metaAccount = metaAccount, + signFlowRequester = signFlowRequester, + resourceManager = resourceManager, + polkadotVaultVariantConfigProvider = polkadotVaultVariantConfigProvider, + messageSigningNotSupported = messageSigningNotSupported, + ) + } +} + +abstract class PolkadotVaultVariantSigner( + signingSharedState: SigningSharedState, + metaAccount: MetaAccount, + private val signFlowRequester: PolkadotVaultVariantSignCommunicator, + private val resourceManager: ResourceManager, + private val variant: PolkadotVaultVariant, + private val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + private val messageSigningNotSupported: SigningNotSupportedPresentable, +) : SeparateFlowSigner(signingSharedState, signFlowRequester, metaAccount) { + + override suspend fun signInheritedImplication(inheritedImplication: InheritedImplication, accountId: AccountId): SignatureWrapper { + signFlowRequester.setUsedVariant(variant) + + return super.signInheritedImplication(inheritedImplication, accountId) + } + + // Vault does not support chain-less message signing yet + override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + rawSigningNotSupported() + } + + protected suspend fun rawSigningNotSupported(): Nothing { + val config = polkadotVaultVariantConfigProvider.variantConfigFor(variant) + + messageSigningNotSupported.presentSigningNotSupported( + SigningNotSupportedPresentable.Payload( + iconRes = config.common.iconRes, + message = resourceManager.formatWithPolkadotVaultLabel(R.string.account_parity_signer_not_supported_subtitle, variant) + ) + ) + + throw SigningCancelledException() + } + + override suspend fun maxCallsPerTransaction(): Int? { + return null + } +} + +class ParitySignerSigner( + signingSharedState: SigningSharedState, + metaAccount: MetaAccount, + signFlowRequester: PolkadotVaultVariantSignCommunicator, + resourceManager: ResourceManager, + polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + messageSigningNotSupported: SigningNotSupportedPresentable, +) : PolkadotVaultVariantSigner( + signingSharedState = signingSharedState, + metaAccount = metaAccount, + signFlowRequester = signFlowRequester, + resourceManager = resourceManager, + variant = PolkadotVaultVariant.PARITY_SIGNER, + polkadotVaultVariantConfigProvider = polkadotVaultVariantConfigProvider, + messageSigningNotSupported = messageSigningNotSupported, +) { + + override suspend fun signRawWithChain(payload: SignerPayloadRawWithChain): SignedRaw { + rawSigningNotSupported() + } +} + +class PolkadotVaultSigner( + signingSharedState: SigningSharedState, + metaAccount: MetaAccount, + private val signFlowRequester: PolkadotVaultVariantSignCommunicator, + resourceManager: ResourceManager, + polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + messageSigningNotSupported: SigningNotSupportedPresentable, +) : PolkadotVaultVariantSigner( + signingSharedState = signingSharedState, + metaAccount = metaAccount, + signFlowRequester = signFlowRequester, + resourceManager = resourceManager, + variant = PolkadotVaultVariant.POLKADOT_VAULT, + polkadotVaultVariantConfigProvider = polkadotVaultVariantConfigProvider, + messageSigningNotSupported = messageSigningNotSupported, +) { + + override suspend fun signRawWithChain(payload: SignerPayloadRawWithChain): SignedRaw { + signFlowRequester.setUsedVariant(PolkadotVaultVariant.POLKADOT_VAULT) + + return useSignRawFlowRequester(payload) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/multiFrame/LegacyMultiPart.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/multiFrame/LegacyMultiPart.kt new file mode 100644 index 0000000..506d71c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/multiFrame/LegacyMultiPart.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.multiFrame + +import io.novafoundation.nova.common.utils.toByteArray +import java.nio.ByteOrder + +object LegacyMultiPart { + + private val MULTI_FRAME_TYPE: ByteArray = byteArrayOf(0x00) + + fun createSingle(payload: ByteArray): ByteArray { + val frameCount: Short = 1 + val frameIndex: Short = 0 + + return MULTI_FRAME_TYPE + + frameCount.encodeMultiPartNumber() + + frameIndex.encodeMultiPartNumber() + + payload + } + + fun createMultiple(payloads: List): List { + val frameCount = payloads.size + + val prefix = MULTI_FRAME_TYPE + frameCount.encodeMultiPartNumber() + + return payloads.mapIndexed { index, payload -> + prefix + index.encodeMultiPartNumber() + payload + } + } + + private fun Number.encodeMultiPartNumber(): ByteArray = toShort().toByteArray(ByteOrder.BIG_ENDIAN) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/transaction/Encoder.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/transaction/Encoder.kt new file mode 100644 index 0000000..cf26dd7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/transaction/Encoder.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.transaction + +import io.novafoundation.nova.feature_account_api.data.signer.SignerPayload +import io.novafoundation.nova.runtime.extrinsic.metadata.ExtrinsicProof +import io.novafoundation.nova.runtime.extrinsic.signer.SignerPayloadRawWithChain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.getGenesisHashOrThrow +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.transientEncodedCallData + +fun SignerPayload.Extrinsic.paritySignerLegacyTxPayload(): ByteArray { + return accountId + extrinsic.transientEncodedCallData() + extrinsic.encodedExtensions() + extrinsic.getGenesisHashOrThrow() +} + +fun SignerPayload.Extrinsic.paritySignerTxPayloadWithProof(proof: ExtrinsicProof): ByteArray { + return accountId + proof.value + extrinsic.transientEncodedCallData() + extrinsic.encodedExtensions() + extrinsic.getGenesisHashOrThrow() +} + +fun SignerPayloadRawWithChain.polkadotVaultSignRawPayload(): ByteArray { + return accountId + message + chainId.fromHex() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/uos/ParitySigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/uos/ParitySigner.kt new file mode 100644 index 0000000..7307a26 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/uos/ParitySigner.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.uos + +import io.novafoundation.nova.core.model.CryptoType + +enum class ParitySignerUOSContentCode(override val value: Byte) : UOS.UOSPreludeValue { + + SUBSTRATE(0x53), +} + +enum class ParitySignerUOSPayloadCode(override val value: Byte) : UOS.UOSPreludeValue { + + TRANSACTION(0x02), MESSAGE(0x03), TRANSACTION_WITH_PROOF(0x06) +} + +fun CryptoType.paritySignerUOSCryptoType(): UOS.UOSPreludeValue { + val byte: Byte = when (this) { + CryptoType.ED25519 -> 0x00 + CryptoType.SR25519 -> 0x01 + CryptoType.ECDSA -> 0x02 + } + + return SimpleUOSPreludeValue(byte) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/uos/UOS.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/uos/UOS.kt new file mode 100644 index 0000000..7291a4e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/paritySigner/uos/UOS.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.uos + +object UOS { + + interface UOSPreludeValue { + val value: Byte + } + + fun createUOSPayload( + payload: ByteArray, + contentCode: UOSPreludeValue, + cryptoCode: UOSPreludeValue, + payloadCode: UOSPreludeValue + ): ByteArray { + return byteArrayOf(contentCode.value, cryptoCode.value, payloadCode.value) + payload + } +} + +class SimpleUOSPreludeValue(override val value: Byte) : UOS.UOSPreludeValue diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/ProxiedSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/ProxiedSigner.kt new file mode 100644 index 0000000..7f5e791 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/ProxiedSigner.kt @@ -0,0 +1,244 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.proxy + +import android.util.Log +import io.novafoundation.nova.common.base.errors.SigningCancelledException +import io.novafoundation.nova.common.data.memory.SingleValueCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.composeCall +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxiedExtrinsicValidationFailure.ProxyNotEnoughFee +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxiedExtrinsicValidationPayload +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxyExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.data.signer.intersect +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.presenatation.account.proxy.ProxySigningPresenter +import io.novafoundation.nova.feature_account_impl.data.signer.LeafSigner +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import javax.inject.Inject + +@FeatureScope +class ProxiedSignerFactory @Inject constructor( + private val accountRepository: AccountRepository, + private val proxySigningPresenter: ProxySigningPresenter, + private val getProxyRepository: GetProxyRepository, + private val proxyExtrinsicValidationEventBus: ProxyExtrinsicValidationRequestBus, + private val proxyCallFilterFactory: ProxyCallFilterFactory +) { + + fun create(metaAccount: ProxiedMetaAccount, signerProvider: SignerProvider, isRoot: Boolean): ProxiedSigner { + return ProxiedSigner( + accountRepository = accountRepository, + signerProvider = signerProvider, + proxySigningPresenter = proxySigningPresenter, + getProxyRepository = getProxyRepository, + proxyExtrinsicValidationEventBus = proxyExtrinsicValidationEventBus, + isRootSigner = isRoot, + proxyCallFilterFactory = proxyCallFilterFactory, + proxiedMetaAccount = metaAccount + ) + } +} + +class ProxiedSigner( + private val proxiedMetaAccount: ProxiedMetaAccount, + private val accountRepository: AccountRepository, + private val signerProvider: SignerProvider, + private val proxySigningPresenter: ProxySigningPresenter, + private val getProxyRepository: GetProxyRepository, + private val proxyExtrinsicValidationEventBus: ProxyExtrinsicValidationRequestBus, + private val isRootSigner: Boolean, + private val proxyCallFilterFactory: ProxyCallFilterFactory, +) : NovaSigner { + + override val metaAccount = proxiedMetaAccount + + private val selfCallExecutionType = CallExecutionType.IMMEDIATE + + private val proxyMetaAccount = SingleValueCache { + computeProxyMetaAccount() + } + + private val delegateSigner = SingleValueCache { + signerProvider.nestedSignerFor(proxyMetaAccount()) + } + + override suspend fun getSigningHierarchy(): SubmissionHierarchy { + return delegateSigner().getSigningHierarchy() + SubmissionHierarchy(metaAccount, selfCallExecutionType) + } + + override suspend fun submissionSignerAccountId(chain: Chain): AccountId { + return delegateSigner().submissionSignerAccountId(chain) + } + + override suspend fun callExecutionType(): CallExecutionType { + return delegateSigner().callExecutionType().intersect(selfCallExecutionType) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForSubmission(context: SigningContext) { + if (isRootSigner) { + acknowledgeProxyOperation(proxyMetaAccount()) + } + + val proxiedCall = getWrappedCall() + + validateExtrinsic(context.chain, proxyMetaAccount = proxyMetaAccount(), proxiedCall = proxiedCall) + + wrapCallsInProxyForSubmission(context.chain, proxiedCall = proxiedCall) + + Log.d("Signer", "ProxiedSigner: wrapped proxy calls for submission") + + delegateSigner().setSignerDataForSubmission(context) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForFee(context: SigningContext) { + val proxiedCall = getWrappedCall() + + wrapCallsInProxyForFee(context.chain, proxiedCall = proxiedCall) + + Log.d("Signer", "ProxiedSigner: wrapped proxy calls for fee") + + delegateSigner().setSignerDataForFee(context) + } + + override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + signingNotSupported() + } + + override suspend fun maxCallsPerTransaction(): Int? { + return delegateSigner().maxCallsPerTransaction() + } + + context(ExtrinsicBuilder) + private suspend fun validateExtrinsic( + chain: Chain, + proxyMetaAccount: MetaAccount, + proxiedCall: GenericCall.Instance, + ) { + if (!proxyPaysFees()) return + + val validationPayload = ProxiedExtrinsicValidationPayload( + proxiedMetaAccount = proxiedMetaAccount, + proxyMetaAccount = proxyMetaAccount, + chainWithAsset = ChainWithAsset(chain, chain.commissionAsset), + proxiedCall = proxiedCall + ) + + val requestBusPayload = ProxyExtrinsicValidationRequestBus.Request(validationPayload) + proxyExtrinsicValidationEventBus.handle(requestBusPayload) + .validationResult + .onSuccess { + if (it is ValidationStatus.NotValid && it.reason is ProxyNotEnoughFee) { + val reason = it.reason as ProxyNotEnoughFee + proxySigningPresenter.notEnoughFee(reason.proxy, reason.asset, reason.availableBalance, reason.fee) + + throw SigningCancelledException() + } + } + .onFailure { + throw it + } + } + + context(ExtrinsicBuilder) + private suspend fun wrapCallsInProxyForSubmission(chain: Chain, proxiedCall: GenericCall.Instance) { + val proxyAccountId = proxyMetaAccount().requireAccountIdIn(chain) + val proxiedAccountId = proxiedMetaAccount.requireAccountIdIn(chain) + + val availableProxyTypes = getProxyRepository.getDelegatedProxyTypesRemote( + chainId = chain.id, + proxiedAccountId = proxiedAccountId, + proxyAccountId = proxyAccountId + ) + + val proxyType = proxyCallFilterFactory.getFirstMatchedTypeOrNull(proxiedCall, availableProxyTypes) + ?: notEnoughPermission(proxyMetaAccount(), availableProxyTypes) + + return wrapCallsIntoProxy( + proxiedAccountId = proxiedAccountId, + proxyType = proxyType, + proxiedCall = proxiedCall + ) + } + + // Wrap without verifying proxy permissions and hardcode proxy type + // to speed up fee calculation + context(ExtrinsicBuilder) + private fun wrapCallsInProxyForFee(chain: Chain, proxiedCall: GenericCall.Instance) { + val proxiedAccountId = proxiedMetaAccount.requireAccountIdIn(chain) + + return wrapCallsIntoProxy( + proxiedAccountId = proxiedAccountId, + proxyType = ProxyType.Any, + proxiedCall = proxiedCall + ) + } + + context(ExtrinsicBuilder) + private fun wrapCallsIntoProxy( + proxiedAccountId: AccountId, + proxyType: ProxyType, + proxiedCall: GenericCall.Instance, + ) { + val proxyCall = runtime.composeCall( + moduleName = Modules.PROXY, + callName = "proxy", + arguments = mapOf( + "real" to PezkuwiAddressConstructor.constructInstance(runtime.typeRegistry, proxiedAccountId), + "force_proxy_type" to DictEnum.Entry(proxyType.name, null), + "call" to proxiedCall + ) + ) + + resetCalls() + call(proxyCall) + } + + private suspend fun acknowledgeProxyOperation(proxyMetaAccount: MetaAccount) { + val resume = proxySigningPresenter.acknowledgeProxyOperation(proxiedMetaAccount, proxyMetaAccount) + if (!resume) { + throw SigningCancelledException() + } + } + + private suspend fun computeProxyMetaAccount(): MetaAccount { + val proxyAccount = proxiedMetaAccount.proxy + return accountRepository.getMetaAccount(proxyAccount.proxyMetaId) + } + + private suspend fun notEnoughPermission(proxyMetaAccount: MetaAccount, availableProxyTypes: List): Nothing { + proxySigningPresenter.notEnoughPermission(proxiedMetaAccount, proxyMetaAccount, availableProxyTypes) + throw SigningCancelledException() + } + + private suspend fun signingNotSupported(): Nothing { + proxySigningPresenter.signingIsNotSupported() + throw SigningCancelledException() + } + + private suspend fun proxyPaysFees(): Boolean { + // Our direct proxy only pay fees it is a leaf. Otherwise fees paid by proxy's own delegate + return delegateSigner() is LeafSigner + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/ProxyCallFilterFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/ProxyCallFilterFactory.kt new file mode 100644 index 0000000..648adbd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/ProxyCallFilterFactory.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.proxy + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_account_impl.data.signer.proxy.callFilter.CallFilter +import io.novafoundation.nova.feature_account_impl.data.signer.proxy.callFilter.AnyOfCallFilter +import io.novafoundation.nova.feature_account_impl.data.signer.proxy.callFilter.EverythingFilter +import io.novafoundation.nova.feature_account_impl.data.signer.proxy.callFilter.WhiteListFilter +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class ProxyCallFilterFactory { + + fun getCallFilterFor(proxyType: ProxyType): CallFilter { + return when (proxyType) { + ProxyType.Any, + is ProxyType.Other -> EverythingFilter() + + ProxyType.NonTransfer -> AnyOfCallFilter( + WhiteListFilter(Modules.SYSTEM), + WhiteListFilter(Modules.SCHEDULER), + WhiteListFilter(Modules.BABE), + WhiteListFilter(Modules.TIMESTAMP), + WhiteListFilter(Modules.INDICES, listOf("claim", "free", "freeze")), + WhiteListFilter(Modules.STAKING), + WhiteListFilter(Modules.SESSION), + WhiteListFilter(Modules.GRANDPA), + WhiteListFilter(Modules.IM_ONLINE), + WhiteListFilter(Modules.TREASURY), + WhiteListFilter(Modules.BOUNTIES), + WhiteListFilter(Modules.CHILD_BOUNTIES), + WhiteListFilter(Modules.CONVICTION_VOTING), + WhiteListFilter(Modules.REFERENDA), + WhiteListFilter(Modules.WHITELIST), + WhiteListFilter(Modules.CLAIMS), + WhiteListFilter(Modules.VESTING, listOf("vest", "vest_other")), + WhiteListFilter(Modules.UTILITY), + WhiteListFilter(Modules.IDENTITY), + WhiteListFilter(Modules.PROXY), + WhiteListFilter(Modules.MULTISIG), + WhiteListFilter(Modules.REGISTRAR, listOf("register", "deregister", "reserve")), + WhiteListFilter(Modules.CROWDLOAN), + WhiteListFilter(Modules.SLOTS), + WhiteListFilter(Modules.AUCTIONS), + WhiteListFilter(Modules.VOTER_LIST), + WhiteListFilter(Modules.NOMINATION_POOLS), + WhiteListFilter(Modules.FAST_UNSTAKE) + ) + + ProxyType.Governance -> AnyOfCallFilter( + WhiteListFilter(Modules.TREASURY), + WhiteListFilter(Modules.BOUNTIES), + WhiteListFilter(Modules.UTILITY), + WhiteListFilter(Modules.CHILD_BOUNTIES), + WhiteListFilter(Modules.CONVICTION_VOTING), + WhiteListFilter(Modules.REFERENDA), + WhiteListFilter(Modules.WHITELIST) + ) + + ProxyType.Staking -> AnyOfCallFilter( + WhiteListFilter(Modules.STAKING), + WhiteListFilter(Modules.SESSION), + WhiteListFilter(Modules.UTILITY), + WhiteListFilter(Modules.FAST_UNSTAKE), + WhiteListFilter(Modules.VOTER_LIST), + WhiteListFilter(Modules.NOMINATION_POOLS) + ) + + ProxyType.NominationPools -> AnyOfCallFilter( + WhiteListFilter(Modules.NOMINATION_POOLS), + WhiteListFilter(Modules.UTILITY) + ) + + ProxyType.IdentityJudgement -> AnyOfCallFilter( + WhiteListFilter(Modules.IDENTITY, listOf("provide_judgement")), + WhiteListFilter(Modules.UTILITY) + ) + + ProxyType.CancelProxy -> WhiteListFilter(Modules.PROXY, listOf("reject_announcement")) + + ProxyType.Auction -> AnyOfCallFilter( + WhiteListFilter(Modules.AUCTIONS), + WhiteListFilter(Modules.CROWDLOAN), + WhiteListFilter(Modules.REGISTRAR), + WhiteListFilter(Modules.SLOTS) + ) + } + } +} + +fun ProxyCallFilterFactory.getFirstMatchedTypeOrNull(call: GenericCall.Instance, proxyTypes: List): ProxyType? { + return proxyTypes.firstOrNull { + val callFilter = this.getCallFilterFor(it) + callFilter.canExecute(call) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/AnyOfCallFilter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/AnyOfCallFilter.kt new file mode 100644 index 0000000..1efa97f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/AnyOfCallFilter.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.proxy.callFilter + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class AnyOfCallFilter( + private val filters: List +) : CallFilter { + + constructor(vararg filters: CallFilter) : this(filters.toList()) + + override fun canExecute(call: GenericCall.Instance): Boolean { + return filters.any { it.canExecute(call) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/CallFilter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/CallFilter.kt new file mode 100644 index 0000000..139a3a1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/CallFilter.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.proxy.callFilter + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +interface CallFilter { + fun canExecute(call: GenericCall.Instance): Boolean +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/EverythingFilter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/EverythingFilter.kt new file mode 100644 index 0000000..fed9832 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/EverythingFilter.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.proxy.callFilter + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class EverythingFilter : CallFilter { + + override fun canExecute(call: GenericCall.Instance): Boolean { + return true + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/WhiteListFilter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/WhiteListFilter.kt new file mode 100644 index 0000000..c3b4f78 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/proxy/callFilter/WhiteListFilter.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.proxy.callFilter + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class WhiteListFilter(private val matchingModule: String, private val matchingCalls: List?) : CallFilter { + + constructor(matchingModule: String) : this(matchingModule, null) + + override fun canExecute(call: GenericCall.Instance): Boolean { + val callModule = call.module.name + val callName = call.function.name + + if (matchingModule == callModule) { + if (matchingCalls == null) return true + if (matchingCalls.contains(callName)) return true + } + + return false + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt new file mode 100644 index 0000000..2336b9e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt @@ -0,0 +1,189 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.secrets + +import io.novafoundation.nova.common.base.errors.SigningCancelledException +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.getAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.getChainAccountKeypair +import io.novafoundation.nova.common.data.secrets.v2.getMetaAccountKeypair +import io.novafoundation.nova.common.data.secrets.v2.seed +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationResult +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ethereumAccountId +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_account_api.domain.model.substrateFrom +import io.novafoundation.nova.feature_account_impl.data.signer.LeafSigner +import io.novafoundation.nova.runtime.ext.isPezkuwiChain +import io.novafoundation.nova.runtime.extrinsic.signer.PezkuwiKeyPairSigner +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainsById +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.KeyPairSigner +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckNonce.Companion.setNonce +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.VerifySignature.Companion.setVerifySignature +import javax.inject.Inject + +@FeatureScope +class SecretsSignerFactory @Inject constructor( + private val secretStoreV2: SecretStoreV2, + private val chainRegistry: ChainRegistry, + private val twoFactorVerificationService: TwoFactorVerificationService +) { + + fun create(metaAccount: MetaAccount): SecretsSigner { + return SecretsSigner( + metaAccount = metaAccount, + secretStoreV2 = secretStoreV2, + chainRegistry = chainRegistry, + twoFactorVerificationService = twoFactorVerificationService + ) + } +} + +class SecretsSigner( + metaAccount: MetaAccount, + private val secretStoreV2: SecretStoreV2, + private val chainRegistry: ChainRegistry, + private val twoFactorVerificationService: TwoFactorVerificationService, +) : LeafSigner(metaAccount) { + + // Track current signing chain to determine which context to use + @Volatile + private var currentSigningChain: Chain? = null + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForSubmission(context: SigningContext) { + // Capture the chain for use in signInheritedImplication + currentSigningChain = context.chain + + val accountId = metaAccount.requireAccountIdKeyIn(context.chain) + setNonce(context.getNonce(accountId)) + setVerifySignature(signer = this, accountId = accountId.value) + } + + context(ExtrinsicBuilder) + override suspend fun setSignerDataForFee(context: SigningContext) { + // Capture the chain for use in signInheritedImplication + currentSigningChain = context.chain + + // Call parent implementation for fee signing + super.setSignerDataForFee(context) + } + + override suspend fun signInheritedImplication( + inheritedImplication: InheritedImplication, + accountId: AccountId + ): SignatureWrapper { + runTwoFactorVerificationIfEnabled() + + val chain = currentSigningChain + val keypair = getKeypair(accountId) + + // Use PezkuwiKeyPairSigner for Pezkuwi chains (bizinikiwi context) + // Use standard KeyPairSigner for other chains (substrate context) + return if (chain?.isPezkuwiChain == true) { + // Get the original seed for Pezkuwi signing + val seed = getSeed(accountId) ?: error("No seed found for Pezkuwi signing") + val pezkuwiSigner = PezkuwiKeyPairSigner.fromSeed(seed) + pezkuwiSigner.signInheritedImplication(inheritedImplication, accountId) + } else { + val delegate = createDelegate(accountId, keypair) + delegate.signInheritedImplication(inheritedImplication, accountId) + } + } + + override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + runTwoFactorVerificationIfEnabled() + + val chain = currentSigningChain + val keypair = getKeypair(payload.accountId) + + // Use PezkuwiKeyPairSigner for Pezkuwi chains (bizinikiwi context) + return if (chain?.isPezkuwiChain == true) { + // Get the original seed for Pezkuwi signing + val seed = getSeed(payload.accountId) ?: error("No seed found for Pezkuwi signing") + val pezkuwiSigner = PezkuwiKeyPairSigner.fromSeed(seed) + pezkuwiSigner.signRaw(payload) + } else { + val delegate = createDelegate(payload.accountId, keypair) + delegate.signRaw(payload) + } + } + + private suspend fun getSeed(accountId: AccountId): ByteArray? { + val secrets = secretStoreV2.getAccountSecrets(metaAccount.id, accountId) + return secrets.seed() + } + + override suspend fun maxCallsPerTransaction(): Int? { + return null + } + + private suspend fun runTwoFactorVerificationIfEnabled() { + if (twoFactorVerificationService.isEnabled()) { + val confirmationResult = twoFactorVerificationService.requestConfirmationIfEnabled() + if (confirmationResult != TwoFactorVerificationResult.CONFIRMED) { + throw SigningCancelledException() + } + } + } + + private suspend fun getKeypair(accountId: AccountId): Keypair { + val chainsById = chainRegistry.chainsById() + val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!! + + return secretStoreV2.getKeypair( + metaAccount = metaAccount, + accountId = accountId, + isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum + ) + } + + private suspend fun createDelegate(accountId: AccountId, keypair: Keypair): KeyPairSigner { + val chainsById = chainRegistry.chainsById() + val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!! + return KeyPairSigner(keypair, multiChainEncryption) + } + + private suspend fun SecretStoreV2.getKeypair( + metaAccount: MetaAccount, + accountId: AccountId, + isEthereumBased: Boolean + ) = if (hasChainSecrets(metaAccount.id, accountId)) { + getChainAccountKeypair(metaAccount.id, accountId) + } else { + getMetaAccountKeypair(metaAccount.id, isEthereumBased) + } + + /** + @return [MultiChainEncryption] for given [accountId] inside this meta account or null in case it was not possible to determine result + */ + private fun MetaAccount.multiChainEncryptionFor(accountId: ByteArray, chainsById: ChainsById): MultiChainEncryption? { + return when { + substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) + ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum + else -> { + val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null + val cryptoType = chainAccount.cryptoType ?: return null + val chain = chainsById[chainAccount.chainId] ?: return null + + if (chain.isEthereumBased) { + MultiChainEncryption.Ethereum + } else { + MultiChainEncryption.substrateFrom(cryptoType) + } + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/signingContext/DefaultSigningContext.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/signingContext/DefaultSigningContext.kt new file mode 100644 index 0000000..777745d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/signingContext/DefaultSigningContext.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.signingContext + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novasama.substrate_sdk_android.runtime.extrinsic.Nonce + +class DefaultSigningContext( + override val chain: Chain, + private val rpcCalls: RpcCalls, +) : SigningContext { + + override suspend fun getNonce(accountId: AccountIdKey): Nonce { + return rpcCalls.getNonce(chain.id, chain.addressOf(accountId)) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/signingContext/SequenceSigningContext.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/signingContext/SequenceSigningContext.kt new file mode 100644 index 0000000..9e485df --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/signingContext/SequenceSigningContext.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.signingContext + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novasama.substrate_sdk_android.runtime.extrinsic.Nonce +import java.math.BigInteger + +class SequenceSigningContext( + private val delegate: SigningContext +) : SigningContext by delegate { + + private var offset: BigInteger = BigInteger.ZERO + + fun incrementNonceOffset() { + offset += BigInteger.ONE + } + + override suspend fun getNonce(accountId: AccountIdKey): Nonce { + val delegateNonce = delegate.getNonce(accountId) + + return delegateNonce + offset + } +} + +fun SigningContext.withSequenceSigning(): SequenceSigningContext { + return SequenceSigningContext(this) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/signingContext/SigningContextFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/signingContext/SigningContextFactory.kt new file mode 100644 index 0000000..03f9311 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/signingContext/SigningContextFactory.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.signingContext + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import javax.inject.Inject + +@FeatureScope +internal class SigningContextFactory @Inject constructor( + private val rpcCalls: RpcCalls +) : SigningContext.Factory { + + override fun default(chain: Chain): SigningContext { + return DefaultSigningContext(chain, rpcCalls) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/watchOnly/WatchOnlySigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/watchOnly/WatchOnlySigner.kt new file mode 100644 index 0000000..a0ab3a8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/watchOnly/WatchOnlySigner.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.data.signer.watchOnly + +import io.novafoundation.nova.common.base.errors.SigningCancelledException +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter +import io.novafoundation.nova.feature_account_impl.data.signer.LeafSigner +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import javax.inject.Inject + +@FeatureScope +class WatchOnlySignerFactory @Inject constructor( + private val watchOnlySigningPresenter: WatchOnlyMissingKeysPresenter, +) { + + fun create(metaAccount: MetaAccount): WatchOnlySigner { + return WatchOnlySigner(watchOnlySigningPresenter, metaAccount) + } +} + +class WatchOnlySigner( + private val watchOnlySigningPresenter: WatchOnlyMissingKeysPresenter, + metaAccount: MetaAccount +) : LeafSigner(metaAccount) { + + override suspend fun signInheritedImplication( + inheritedImplication: InheritedImplication, + accountId: AccountId + ): SignatureWrapper { + cannotSign() + } + + override suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + cannotSign() + } + + override suspend fun maxCallsPerTransaction(): Int? { + return null + } + + private suspend fun cannotSign(): Nothing { + watchOnlySigningPresenter.presentNoKeysFound() + + throw SigningCancelledException() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/CompoundExternalAccountsSyncDataSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/CompoundExternalAccountsSyncDataSource.kt new file mode 100644 index 0000000..fcc14f1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/CompoundExternalAccountsSyncDataSource.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_account_impl.data.sync + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.flatMapAsync +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull + +internal class CompoundExternalAccountsSyncDataSource( + private val delegates: List +) : ExternalAccountsSyncDataSource { + + override fun supportedChains(): Collection { + return delegates.flatMap { it.supportedChains() } + .distinctBy { it.id } + } + + override suspend fun isCreatedFromDataSource(metaAccount: MetaAccount): Boolean { + return delegates.any { it.isCreatedFromDataSource(metaAccount) } + } + + override suspend fun getExternalCreatedAccount(metaAccount: MetaAccount): ExternalSourceCreatedAccount? { + return delegates.tryFindNonNull { it.getExternalCreatedAccount(metaAccount) } + } + + override suspend fun getControllableExternalAccounts( + accountIdsToQuery: Set, + ): List { + return delegates.flatMapAsync { + val label = it::class.simpleName + + Log.d("ExternalAccountsDiscovery", "Started fetching ${accountIdsToQuery.size} accounts using $label") + + try { + it.getControllableExternalAccounts(accountIdsToQuery).also { result -> + Log.d("ExternalAccountsDiscovery", "Finished fetching accounts using $label. Got ${result.size} accounts") + } + } catch (e: Throwable) { + Log.e("ExternalAccountsDiscovery", "Failed to fetch accounts using $label", e) + throw e + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/ExternalAccountsSyncDataSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/ExternalAccountsSyncDataSource.kt new file mode 100644 index 0000000..df66070 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/ExternalAccountsSyncDataSource.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_account_impl.data.sync + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +internal interface ExternalAccountsSyncDataSource { + + interface Factory { + + suspend fun create(): ExternalAccountsSyncDataSource + } + + fun supportedChains(): Collection + + suspend fun isCreatedFromDataSource(metaAccount: MetaAccount): Boolean + + suspend fun getExternalCreatedAccount(metaAccount: MetaAccount): ExternalSourceCreatedAccount? + + suspend fun getControllableExternalAccounts(accountIdsToQuery: Set): List +} + +internal interface ExternalControllableAccount { + + val accountId: AccountIdKey + + val controllerAccountId: AccountIdKey + + /** + * Check whether [localAccount] represents self in the data-base + * Implementation can assume that [accountId] and [controllerAccountId] check has already been done + */ + fun isRepresentedBy(localAccount: MetaAccount): Boolean + + fun isAvailableOn(chain: Chain): Boolean + + /** + * Add account to the data-base, WITHOUT notifying any external entities, + * like [MetaAccountChangesEventBus] - this is expected to be done by the calling code + * + * @return id of newly created account + */ + suspend fun addControlledAccount( + controller: MetaAccount, + identity: Identity?, + position: Int, + missingAccountChain: Chain + ): AddAccountResult.AccountAdded + + /** + * Whether dispatching call on behalf of this account changes the original call filters + * + * This might be used by certain data-sources to understand whether control of such account is actually possible + */ + fun dispatchChangesOriginFilters(): Boolean +} + +internal interface ExternalSourceCreatedAccount { + + fun canControl(candidate: ExternalControllableAccount): Boolean +} + +internal fun ExternalControllableAccount.address(chain: Chain): String { + return chain.addressOf(accountId) +} + +internal fun ExternalControllableAccount.controllerAddress(chain: Chain): String { + return chain.addressOf(controllerAccountId) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/MultisigAccountsSyncDataSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/MultisigAccountsSyncDataSource.kt new file mode 100644 index 0000000..328f5fc --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/MultisigAccountsSyncDataSource.kt @@ -0,0 +1,264 @@ +package io.novafoundation.nova.feature_account_impl.data.sync + +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.format.AddressFormat +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.address.format.addressOf +import io.novafoundation.nova.common.address.format.getAddressScheme +import io.novafoundation.nova.common.address.format.isEvm +import io.novafoundation.nova.common.address.format.isSubstrate +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MultisigTypeExtras +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.feature_account_impl.data.multisig.model.otherSignatories +import io.novafoundation.nova.runtime.ext.addressScheme +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.findChains +import javax.inject.Inject + +@FeatureScope +internal class MultisigAccountsSyncDataSourceFactory @Inject constructor( + private val multisigRepository: MultisigRepository, + private val gson: Gson, + private val accountDao: MetaAccountDao, + private val chainRegistry: ChainRegistry +) : ExternalAccountsSyncDataSource.Factory { + + override suspend fun create(): ExternalAccountsSyncDataSource { + val chainsWithMultisigs = chainRegistry.findChains(multisigRepository::supportsMultisigSync) + + return MultisigAccountsSyncDataSource(multisigRepository, gson, accountDao, chainsWithMultisigs) + } +} + +private class MultisigAccountsSyncDataSource( + private val multisigRepository: MultisigRepository, + private val gson: Gson, + private val accountDao: MetaAccountDao, + private val multisigChains: List, +) : ExternalAccountsSyncDataSource { + + private val multisigChainIds = multisigChains.mapToSet { it.id } + + override fun supportedChains(): Collection { + return multisigChains + } + + override suspend fun isCreatedFromDataSource(metaAccount: MetaAccount): Boolean { + return metaAccount is MultisigMetaAccount + } + + override suspend fun getExternalCreatedAccount(metaAccount: MetaAccount): ExternalSourceCreatedAccount? { + return if (isCreatedFromDataSource(metaAccount)) { + MultisigExternalSourceAccount() + } else { + null + } + } + + override suspend fun getControllableExternalAccounts(accountIdsToQuery: Set): List { + if (multisigChains.isEmpty()) return emptyList() + + return multisigRepository.findMultisigAccounts(accountIdsToQuery) + .flatMap { discoveredMultisig -> + discoveredMultisig.allSignatories + .filter { it in accountIdsToQuery } + .mapNotNull { ourSignatory -> + MultisigExternalControllableAccount( + accountId = discoveredMultisig.accountId, + controllerAccountId = ourSignatory, + threshold = discoveredMultisig.threshold, + otherSignatories = discoveredMultisig.otherSignatories(ourSignatory), + addressScheme = discoveredMultisig.accountId.getAddressScheme() ?: return@mapNotNull null + ) + } + } + } + + private inner class MultisigExternalControllableAccount( + override val accountId: AccountIdKey, + override val controllerAccountId: AccountIdKey, + private val threshold: Int, + private val otherSignatories: List, + private val addressScheme: AddressScheme + ) : ExternalControllableAccount { + + override fun isRepresentedBy(localAccount: MetaAccount): Boolean { + // Assuming accountId and controllerAccountId match, nothing else to check since both threshold and signers determine accountId + return localAccount is MultisigMetaAccount + } + + override fun isAvailableOn(chain: Chain): Boolean { + return chain.id in multisigChainIds && chain.addressScheme == addressScheme + } + + override suspend fun addControlledAccount( + controller: MetaAccount, + identity: Identity?, + position: Int, + missingAccountChain: Chain, + ): AddAccountResult.AccountAdded { + val newId = addMultisig(controller, identity, position, missingAccountChain) + return AddAccountResult.AccountAdded(newId, LightMetaAccount.Type.MULTISIG) + } + + override fun dispatchChangesOriginFilters(): Boolean { + return true + } + + private suspend fun addMultisig( + controller: MetaAccount, + identity: Identity?, + position: Int, + chain: Chain + ): Long { + return when (controller.type) { + LightMetaAccount.Type.SECRETS, + LightMetaAccount.Type.WATCH_ONLY -> addMultisigForComplexSigner(controller, identity, position, chain) + + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.POLKADOT_VAULT -> addUniversalMultisig(controller, identity, position) + + LightMetaAccount.Type.LEDGER_LEGACY, + LightMetaAccount.Type.LEDGER -> addSingleChainMultisig(controller, identity, position, chain) + + LightMetaAccount.Type.PROXIED -> addSingleChainMultisig(controller, identity, position, chain) + + LightMetaAccount.Type.MULTISIG -> addMultisigForComplexSigner(controller, identity, position, chain) + } + } + + private suspend fun addMultisigForComplexSigner( + controller: MetaAccount, + identity: Identity?, + position: Int, + chain: Chain + ): Long { + return if (controller.chainAccounts.isEmpty()) { + addUniversalMultisig(controller, identity, position) + } else { + addSingleChainMultisig(controller, identity, position, chain) + } + } + + private suspend fun addSingleChainMultisig( + controller: MetaAccount, + identity: Identity?, + position: Int, + chain: Chain + ): Long { + val metaAccount = createSingleChainMetaAccount(controller.id, identity, position) + return accountDao.insertMetaAndChainAccounts(metaAccount) { newId -> + listOf(createChainAccount(newId, chain)) + } + } + + private suspend fun addUniversalMultisig( + controller: MetaAccount, + identity: Identity?, + position: Int + ): Long { + val metaAccount = createUniversalMetaAccount(controller.id, identity, position) + return accountDao.insertMetaAccount(metaAccount) + } + + private fun createUniversalMetaAccount( + controllerMetaId: Long, + identity: Identity?, + position: Int + ): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = substrateAccountId(), + ethereumPublicKey = null, + ethereumAddress = ethereumAddress(), + name = accountName(identity), + parentMetaId = controllerMetaId, + isSelected = false, + position = position, + type = MetaAccountLocal.Type.MULTISIG, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = typeExtras() + ) + } + + private fun createSingleChainMetaAccount( + controllerMetaId: Long, + identity: Identity?, + position: Int + ): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = null, + ethereumPublicKey = null, + ethereumAddress = null, + name = accountName(identity), + parentMetaId = controllerMetaId, + isSelected = false, + position = position, + type = MetaAccountLocal.Type.MULTISIG, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = typeExtras() + ) + } + + private fun createChainAccount( + multisigId: Long, + chain: Chain + ): ChainAccountLocal { + return ChainAccountLocal( + metaId = multisigId, + chainId = chain.id, + publicKey = null, + accountId = accountId.value, + cryptoType = null + ) + } + + private fun ethereumAddress(): ByteArray? { + return accountId.value.takeIf { addressScheme.isEvm() } + } + + private fun substrateAccountId(): ByteArray? { + return accountId.value.takeIf { addressScheme.isSubstrate() } + } + + private fun accountName(identity: Identity?): String { + if (identity != null) return identity.name + + val addressFormat = AddressFormat.defaultForScheme(addressScheme) + return addressFormat.addressOf(accountId).value + } + + private fun typeExtras(): String { + val extras = MultisigTypeExtras( + otherSignatories, + threshold, + signatoryAccountId = controllerAccountId + ) + return gson.toJson(extras) + } + } + + private class MultisigExternalSourceAccount : ExternalSourceCreatedAccount { + + override fun canControl(candidate: ExternalControllableAccount): Boolean { + return true + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/ProxyExternalAccountsSyncDataSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/ProxyExternalAccountsSyncDataSource.kt new file mode 100644 index 0000000..dd01415 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/ProxyExternalAccountsSyncDataSource.kt @@ -0,0 +1,165 @@ +package io.novafoundation.nova.feature_account_impl.data.sync + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.ProxyAccountLocal +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_impl.data.proxy.repository.MultiChainProxyRepository +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainsById +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.findChainsById +import javax.inject.Inject + +@FeatureScope +internal class ProxyAccountsSyncDataSourceFactory @Inject constructor( + private val multiChainProxyRepository: MultiChainProxyRepository, + private val accountDao: MetaAccountDao, + private val chainRegistry: ChainRegistry +) : ExternalAccountsSyncDataSource.Factory { + + override suspend fun create(): ExternalAccountsSyncDataSource { + val proxyChains = chainRegistry.findChainsById { it.supportProxy } + + return ProxyExternalAccountsSyncDataSource(multiChainProxyRepository, accountDao, proxyChains) + } +} + +private class ProxyExternalAccountsSyncDataSource( + private val multiChainProxyRepository: MultiChainProxyRepository, + private val accountDao: MetaAccountDao, + private val proxyChains: ChainsById +) : ExternalAccountsSyncDataSource { + + override fun supportedChains(): Collection { + return proxyChains.values + } + + override suspend fun isCreatedFromDataSource(metaAccount: MetaAccount): Boolean { + return metaAccount is ProxiedMetaAccount + } + + override suspend fun getExternalCreatedAccount(metaAccount: MetaAccount): ExternalSourceCreatedAccount? { + return if (metaAccount is ProxiedMetaAccount) { + ProxyExternalSourceAccount(metaAccount.proxy.proxyType) + } else { + null + } + } + + override suspend fun getControllableExternalAccounts(accountIdsToQuery: Set): List { + return multiChainProxyRepository.getProxies(accountIdsToQuery) + .mapNotNull { + ProxiedExternalAccount( + chain = proxyChains[it.chainId] ?: return@mapNotNull null, + accountId = it.proxied, + proxyType = it.proxyType, + controllerAccountId = it.proxy + ) + } + } + + private inner class ProxiedExternalAccount( + override val accountId: AccountIdKey, + override val controllerAccountId: AccountIdKey, + private val proxyType: ProxyType, + private val chain: Chain + ) : ExternalControllableAccount { + + override fun isRepresentedBy(localAccount: MetaAccount): Boolean { + return localAccount is ProxiedMetaAccount && localAccount.proxy.proxyType == proxyType + } + + override fun isAvailableOn(chain: Chain): Boolean { + return chain.id == this.chain.id + } + + override suspend fun addControlledAccount( + controller: MetaAccount, + identity: Identity?, + position: Int, + missingAccountChain: Chain + ): AddAccountResult.AccountAdded { + require(missingAccountChain.id == chain.id) { + "Wrong chain requested for ProxiedExternalAccount.addControlledAccount. Expected: ${chain.name}, got: ${missingAccountChain.name}" + } + + val metaId = accountDao.insertProxiedMetaAccount( + metaAccount = createMetaAccount(controller.id, identity, position), + chainAccount = { proxiedMetaId -> createChainAccount(proxiedMetaId) }, + proxyAccount = { proxiedMetaId -> createProxyAccount(proxiedMetaId = proxiedMetaId, proxyMetaId = controller.id) } + ) + + return AddAccountResult.AccountAdded(metaId, LightMetaAccount.Type.PROXIED) + } + + override fun dispatchChangesOriginFilters(): Boolean { + return true + } + + private fun createMetaAccount( + controllerMetaId: Long, + identity: Identity?, + position: Int + ): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = null, + ethereumPublicKey = null, + ethereumAddress = null, + name = identity?.name ?: chain.addressOf(accountId), + parentMetaId = controllerMetaId, + isSelected = false, + position = position, + type = MetaAccountLocal.Type.PROXIED, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = null + ) + } + + private fun createChainAccount( + proxiedMetaId: Long, + ): ChainAccountLocal { + return ChainAccountLocal( + metaId = proxiedMetaId, + chainId = chain.id, + publicKey = null, + accountId = accountId.value, + cryptoType = null + ) + } + + private fun createProxyAccount( + proxiedMetaId: Long, + proxyMetaId: Long, + ): ProxyAccountLocal { + return ProxyAccountLocal( + proxiedMetaId = proxiedMetaId, + proxyMetaId = proxyMetaId, + chainId = chain.id, + proxiedAccountId = accountId.value, + proxyType = proxyType.name + ) + } + } + + private inner class ProxyExternalSourceAccount(private val proxyType: ProxyType) : ExternalSourceCreatedAccount { + + override fun canControl(candidate: ExternalControllableAccount): Boolean { + if (!candidate.dispatchChangesOriginFilters()) return true + + return proxyType is ProxyType.Any || proxyType is ProxyType.NonTransfer + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/RealExternalAccountsSyncService.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/RealExternalAccountsSyncService.kt new file mode 100644 index 0000000..b102e10 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/sync/RealExternalAccountsSyncService.kt @@ -0,0 +1,407 @@ +package io.novafoundation.nova.feature_account_impl.data.sync + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.associateMutableBy +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.filterToSet +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.put +import io.novafoundation.nova.common.utils.toMutableMultiMapList +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.events.checkIncludes +import io.novafoundation.nova.feature_account_api.data.events.combineBusEvents +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.toAccountBusEvent +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.accountIdKeyIn +import io.novafoundation.nova.feature_account_api.domain.model.isUniversal +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_account_impl.BuildConfig +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +@FeatureScope +internal class RealExternalAccountsSyncService @Inject constructor( + private val dataSourceFactories: Set<@JvmSuppressWildcards ExternalAccountsSyncDataSource.Factory>, + private val accountRepository: AccountRepository, + private val accountDao: MetaAccountDao, + private val eventBus: MetaAccountChangesEventBus, + private val metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry, + private val identityRepository: OnChainIdentityRepository, + private val rootScope: RootScope, + private val chainRegistry: ChainRegistry, +) : ExternalAccountsSyncService { + + private val canSyncWatchOnly = BuildConfig.DEBUG + private val syncMutex = Mutex() + + companion object { + + private const val ACCOUNTS_CHANGED_SOURCE = "ExternalAccountsSyncService" + } + + override fun syncOnAccountChange(event: MetaAccountChangesEventBus.Event, changeSource: String?) { + val hasRelevantEvents = event.checkIncludes( + checkAdd = true, + checkStructureChange = true, + checkNameChange = false, // Name changes are irrelevant as they don't affect structure + checkAccountRemoved = false // All dependent accounts are cleaned up automatically by DB so we don't need to re-sync on delete + ) + val notCausedByItself = changeSource != ACCOUNTS_CHANGED_SOURCE + + if (hasRelevantEvents && notCausedByItself) { + sync() + } else { + Log.d( + "ExternalAccountsDiscovery", + "syncOnAccountChange was ignored due to conditions not being met:" + + " hasRelevantEvents=$hasRelevantEvents, notCausedByItself=$notCausedByItself" + ) + } + } + + override fun sync() = rootScope.launchUnit(Dispatchers.IO) { + syncMutex.withLock { + syncInternal() + } + } + + private suspend fun syncInternal() { + val dataSource = dataSourceFactories + .map { it.create() } + .aggregate() + + sync(dataSource) + } + + /** + * Sync all reachable accounts from accounts that user directly controls. + * + * The terminology used here: + * * Controller - an account that can operation on behalf of another account + * * Controlled accounts - account that is controlled by a controller + * + * The high-level of the algorithm is the following: + * 1. We perform BFS starting from accounts user directly controls, fetching reachable accounts iteratively: + * once we fetched a new non-empty list of controllable accounts from data-sources, we start fetching again, now with just obtained accounts + * This way, we will be able to crawl all the reachable accounts + * Note that at this step, we do not yet check whether a certain pair of Controller + Controlled is actually usable + * Also, this step operates just with account ids and not with wallet ids to make it easier to implement data-sources + * It is also important to note, that each [ExternalControllableAccount] represents a unique Controller + Controlled pair. However, if there are + * several meta accounts with the same account ids that are controllers, [ExternalControllableAccount] will result in a separate meta account created for each of them + * On the other hand, if multiple different controllers control a single controlled accounts, they will have different corresponding [ExternalControllableAccount] instances + * + * 2. Once we fetched all accessible [ExternalControllableAccount] we start comparing already added meta accounts with the fetched list. The goal is to only add those accounts + * that has not been added yet + * For each external account, [updateLocalExternalAccounts] achieve this by doing the following: + * a. It first checks whether this pair of controller + controlled can actually be used. + * In particular, a controller, via [ExternalSourceCreatedAccount], checks that it can actually control the controlled account. This is usefully for Proxies + * since only Any/NonTransfer proxy account can dispatch proxy.proxy / multisig.as_multi calls, and thus, be able to control such accounts despite the permission being granted + * b. Get meta accounts for controllers that can control this external account + * c. For each controller, check all of its controlled accounts that are already in db. This is done via [MetaAccount.parentMetaId] field + * d. Compare each such controlled account with external account we are analyzing. + * Check is done comparing accountId of controlled account + allowing [ExternalControllableAccount] to do some extra check + * For example, it is not enough for a Proxy to just check for controlled and controller account ids match + * as there might be multiple connection between same pairs of accounts via multiple proxy types. + */ + private suspend fun sync(dataSource: ExternalAccountsSyncDataSource): Result = runCatching { + Log.d("ExternalAccountsDiscovery", "Started syncing external accounts") + + val directlyControlledAccounts = accountRepository.getAllMetaAccounts() + .filter { it.isAllowedToSyncExternalAccounts() && !dataSource.isCreatedFromDataSource(it) } + if (directlyControlledAccounts.isEmpty()) return@runCatching + + val supportedChains = dataSource.supportedChains() + + val (externalAccounts, allVisitedCandidates) = findReachableExternalAccounts(directlyControlledAccounts, dataSource, supportedChains) + + val identities = identityRepository.getMultiChainIdentities(allVisitedCandidates) + + updateLocalExternalAccounts(directlyControlledAccounts, externalAccounts, identities, dataSource, supportedChains) + } + .onFailure { Log.d("ExternalAccountsDiscovery", "Failed to sync external accounts", it) } + .onSuccess { Log.d("ExternalAccountsDiscovery", "Finished syncing external accounts ") } + + private suspend fun notifyAboutAddedAccounts(added: List, deactivatedMetaIds: Set) { + added.map { it.toAccountBusEvent() } + .combineBusEvents() + ?.let { eventBus.notify(it, source = ACCOUNTS_CHANGED_SOURCE) } + + val updatedAccountsIds = deactivatedMetaIds.toList() + added.map { it.metaId } + metaAccountsUpdatesRegistry.addMetaIds(updatedAccountsIds) + } + + private fun constructReachabilityReport( + allAccounts: List, + stillReachableExistingIds: Set, + ): ChainReachabilityReport { + val (universalAccounts, singleChainAccounts) = allAccounts.partition { it.isUniversal() } + val universalAccountIds = universalAccounts.mapToSet { it.id } + val singleChainAccountIds = singleChainAccounts.mapToSet { it.id } + + return ChainReachabilityReport( + singleChainNonReachable = singleChainAccountIds - stillReachableExistingIds, + universalNonReachable = universalAccountIds - stillReachableExistingIds, + stillReachable = stillReachableExistingIds + ) + } + + private suspend fun updateAccountStatusesForSingleChain(reachabilityReport: ChainReachabilityReport, chain: Chain) { + val singleChainNonReachable = reachabilityReport.singleChainNonReachable + if (singleChainNonReachable.isNotEmpty()) { + Log.d( + "ExternalAccountsDiscovery", + "Disabling ${singleChainNonReachable.size} non-reachable accounts" + + " when syncing ${chain.name}: $singleChainNonReachable" + ) + accountDao.changeAccountsStatus(singleChainNonReachable.toList(), MetaAccountLocal.Status.DEACTIVATED) + } else { + Log.d("ExternalAccountsDiscovery", "No accounts to disable found when syncing ${chain.name}") + } + + val reachable = reachabilityReport.stillReachable + Log.d("ExternalAccountsDiscovery", "Enabling ${reachable.size} still-reachable accounts when syncing ${chain.name}: $reachable") + accountDao.changeAccountsStatus(reachable.toList(), MetaAccountLocal.Status.ACTIVE) + } + + private suspend fun updateAccountStatusForUniversal(notReachableUniversalPerChain: List) { + if (notReachableUniversalPerChain.isEmpty()) return + + // Find universal account that every chain reported as non-reachable + val notReachable = notReachableUniversalPerChain.reduce { a, b -> a.intersect(b) } + .toList() + + if (notReachable.isNotEmpty()) { + Log.d("ExternalAccountsDiscovery", "Disabling ${notReachable.size} non-reachable universal accounts: $notReachable") + accountDao.changeAccountsStatus(notReachable, MetaAccountLocal.Status.DEACTIVATED) + metaAccountsUpdatesRegistry.addMetaIds(notReachable) + } else { + Log.d("ExternalAccountsDiscovery", "No universal accounts to disable found") + } + } + + private suspend fun updateLocalExternalAccounts( + directlyControlledAccounts: List, + externalAccounts: List, + identities: AccountIdKeyMap, + dataSource: ExternalAccountsSyncDataSource, + supportedChains: Collection, + ) { + val universalNonReachable = supportedChains.mapNotNull { chain -> + runCatching { + val allAccounts = accountRepository.getAllMetaAccounts().filter { it.isAllowedToSyncExternalAccounts() } + + val allAccountsAvailableOnChain = allAccounts.filter { it.hasAccountIn(chain) } + val directAccountsAvailableOnChain = directlyControlledAccounts.filter { it.hasAccountIn(chain) } + + val externalAccountsAvailableOnChain = externalAccounts.filter { it.isAvailableOn(chain) } + + val (added, reachableExistingMetaIds) = addNewExternalAccounts( + allAccounts = allAccountsAvailableOnChain, + directlyControlledAccounts = directAccountsAvailableOnChain, + foundExternalAccounts = externalAccountsAvailableOnChain, + identities = identities, + dataSource = dataSource, + chain = chain + ) + + val reachabilityReport = constructReachabilityReport(allAccountsAvailableOnChain, reachableExistingMetaIds) + + updateAccountStatusesForSingleChain(reachabilityReport, chain) + notifyAboutAddedAccounts(added, deactivatedMetaIds = reachabilityReport.singleChainNonReachable) + + reachabilityReport.universalNonReachable + } + .onFailure { Log.d("ExternalAccountsDiscovery", "Failed to add new external accounts for chain ${chain.name}", it) } + .onSuccess { Log.d("ExternalAccountsDiscovery", "Finished adding new external accounts for chain ${chain.name}") } + .getOrNull() + } + + updateAccountStatusForUniversal(universalNonReachable) + } + + private suspend fun addNewExternalAccounts( + allAccounts: List, + directlyControlledAccounts: List, + foundExternalAccounts: List, + identities: AccountIdKeyMap, + dataSource: ExternalAccountsSyncDataSource, + chain: Chain + ): AddReachableAccountResult { + val existingAccountsByAccountId = allAccounts.groupBy { it.requireAccountIdKeyIn(chain) } + val existingAccountsByParentId = allAccounts.groupBy { it.parentMetaId } + + val controllersByParentId = existingAccountsByParentId.toMutableMultiMapList() + val controllersByAccountId = existingAccountsByAccountId.toMutableMultiMapList() + val controllersById = allAccounts.associateMutableBy { it.id } + + fun signingPath(metaAccount: MetaAccount): List { + val path = mutableListOf(metaAccount.requireAccountIdKeyIn(chain)) + var current = metaAccount + + while (current.parentMetaId != null) { + val parent = controllersById[current.parentMetaId] ?: break + path.add(parent.requireAccountIdKeyIn(chain)) + current = parent + } + + return path + } + + val reachableExistingMetaIds = mutableSetOf() + val added = mutableListOf() + directlyControlledAccounts.forEach { reachableExistingMetaIds.add(it.id) } + + var position = accountDao.nextAccountPosition() + + Log.d("ExternalAccountsDiscovery", "Checking ${foundExternalAccounts.size} found external accounts against local state in ${chain.name}") + + foundExternalAccounts.onEach { externalAccount -> + val controllers = controllersByAccountId[externalAccount.controllerAccountId].orEmpty() + + controllers.onEach controllersLoop@{ controller -> + val signingPath = signingPath(controller) + if (externalAccount.accountId in signingPath) { + Log.v( + "ExternalAccountsDiscovery", + "Loop detected: ${externalAccount.address(chain)} already present " + + "in the signing path of ${externalAccount.controllerAddress(chain)}" + ) + return@controllersLoop + } + + val controllerAsExternal = dataSource.getExternalCreatedAccount(controller) + val canControl = controllerAsExternal == null || controllerAsExternal.canControl(externalAccount) + + if (!canControl) { + Log.v( + "ExternalAccountsDiscovery", + "Discovered account ${externalAccount.address(chain)} cannot be controlled by ${externalAccount.controllerAddress(chain)}" + ) + return@controllersLoop + } + + val existingControlledAccounts = controllersByParentId[controller.id].orEmpty() + + val existingAccountRepresentedByExternal = existingControlledAccounts.find { existingAccount -> + val existingAccountId = existingAccount.requireAccountIdKeyIn(chain) + existingAccountId == externalAccount.accountId && externalAccount.isRepresentedBy(existingAccount) + } + + if (existingAccountRepresentedByExternal != null) { + reachableExistingMetaIds.add(existingAccountRepresentedByExternal.id) + } else { + val identity = identities[externalAccount.accountId]?.let(::Identity) + val addResult = externalAccount.addControlledAccount(controller, identity, position, chain) + + val newMetaAccount = accountRepository.getMetaAccount(addResult.metaId) + + position++ + controllersByAccountId.put(externalAccount.accountId, newMetaAccount) + controllersByParentId.put(controller.id, newMetaAccount) + + controllersById[newMetaAccount.id] = newMetaAccount + added.add(addResult) + } + } + } + + Log.d("ExternalAccountsDiscovery", "Added ${added.size} new accounts on ${chain.name}: ${added.map { it.type }}") + + return AddReachableAccountResult(added, reachableExistingMetaIds) + } + + private suspend fun findReachableExternalAccounts( + directlyControlledAccounts: List, + dataSource: ExternalAccountsSyncDataSource, + supportedChains: Collection + ): ReachableExternalAccounts { + var nextSearchCandidates = getAccountIds(directlyControlledAccounts, supportedChains) + val foundExternalAccounts = mutableListOf() + val allVisitedCandidates = mutableSetOf() + + while (nextSearchCandidates.isNotEmpty()) { + val searchResults = dataSource.getControllableExternalAccounts(nextSearchCandidates) + foundExternalAccounts.addAll(searchResults) + + val previousSearchCandidates = nextSearchCandidates + + // We do not search already visited account ids as it might result in endless recursion + // Note however, that we still add such results to the found accounts since connection TO the visited account + // might be useful (e.g. we discovered alternative path to already visited account) + nextSearchCandidates = searchResults + .map { it.accountId } + .filterToSet { it !in allVisitedCandidates } + + allVisitedCandidates.addAll(previousSearchCandidates) + } + + return ReachableExternalAccounts(foundExternalAccounts, allVisitedCandidates) + } + + private fun getAccountIds(metaAccounts: List, chains: Collection): Set { + return buildSet { + metaAccounts.onEach { + addAccountIds(it, chains) + } + } + } + + context(MutableSet) + private fun addAccountIds(metaAccount: MetaAccount, chains: Collection) { + chains.forEach { chain -> + metaAccount.accountIdKeyIn(chain)?.let { add(it) } + } + } + + private data class ChainReachabilityReport( + val singleChainNonReachable: Set, + val universalNonReachable: Set, + val stillReachable: Set + ) + + private data class ReachableExternalAccounts( + val accounts: List, + val allVisitedCandidates: Set + ) + + private data class AddReachableAccountResult( + val added: List, + val stillReachableExistingIds: Set + ) + + private fun List.aggregate(): ExternalAccountsSyncDataSource { + return when (size) { + 0 -> error("Empty data-sources list") + 1 -> single() + else -> CompoundExternalAccountsSyncDataSource(this) + } + } + + private fun MetaAccount.isAllowedToSyncExternalAccounts(): Boolean { + return if (type == LightMetaAccount.Type.WATCH_ONLY) { + canSyncWatchOnly + } else { + true + } + } +} + +private typealias NonReachableUniversalIds = Set diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureComponent.kt new file mode 100644 index 0000000..eacbe24 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureComponent.kt @@ -0,0 +1,201 @@ +package io.novafoundation.nova.feature_account_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationCommunicator +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.sign.LedgerSignCommunicator +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignCommunicator +import io.novafoundation.nova.feature_account_impl.di.modules.ExportModule +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.di.AdvancedEncryptionComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.details.di.AccountDetailsComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.list.delegationUpdates.di.DelegatedAccountUpdatesComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.list.multipleSelecting.di.SelectMultipleWalletsComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.list.selectAddress.di.SelectAddressComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.list.singleSelecting.di.SelectSingleWalletComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.list.switching.di.SwitchWalletComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.management.di.WalletManagmentComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.changePassword.di.ChangeBackupPasswordComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet.di.CreateWalletBackupPasswordComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.syncWallets.di.SyncWalletsBackupPasswordComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.confirmPassword.di.CheckCloudBackupPasswordComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restoreBackup.di.RestoreCloudBackupComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restorePassword.di.RestoreCloudBackupPasswordComponent +import io.novafoundation.nova.feature_account_impl.presentation.exporting.json.ShareCompletedReceiver +import io.novafoundation.nova.feature_account_impl.presentation.exporting.json.di.ExportJsonComponent +import io.novafoundation.nova.feature_account_impl.presentation.exporting.seed.di.ExportSeedComponent +import io.novafoundation.nova.feature_account_impl.presentation.importing.di.ImportAccountComponent +import io.novafoundation.nova.feature_account_impl.presentation.language.di.LanguagesComponent +import io.novafoundation.nova.feature_account_impl.presentation.legacyAddress.di.ChainAddressSelectorComponent +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.di.ManualBackupSelectAccountComponent +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.advanced.di.ManualBackupAdvancedSecretsComponent +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.main.di.ManualBackupSecretsComponent +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.wallets.di.ManualBackupSelectWalletComponent +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.warning.di.ManualBackupWarningComponent +import io.novafoundation.nova.feature_account_impl.presentation.mixin.selectWallet.di.SelectWalletComponent +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup.di.BackupMnemonicComponent +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.di.ConfirmMnemonicComponent +import io.novafoundation.nova.feature_account_impl.presentation.node.add.di.AddNodeComponent +import io.novafoundation.nova.feature_account_impl.presentation.node.details.di.NodeDetailsComponent +import io.novafoundation.nova.feature_account_impl.presentation.node.list.di.NodesComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.finish.di.FinishImportParitySignerComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.preview.di.PreviewImportParitySignerComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.scan.di.ScanImportParitySignerComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.start.di.StartImportParitySignerComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.di.ScanSignParitySignerComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show.di.ShowSignParitySignerComponent +import io.novafoundation.nova.feature_account_impl.presentation.pincode.di.PinCodeComponent +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.di.ScanSeedComponent +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.di.StartCreateWalletComponent +import io.novafoundation.nova.feature_account_impl.presentation.watchOnly.change.di.ChangeWatchAccountComponent +import io.novafoundation.nova.feature_account_impl.presentation.watchOnly.create.di.CreateWatchWalletComponent +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi +import io.novafoundation.nova.feature_proxy_api.di.ProxyFeatureApi +import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.web3names.di.Web3NamesApi + +@Component( + dependencies = [ + AccountFeatureDependencies::class, + ], + modules = [ + AccountFeatureModule::class, + ExportModule::class + ] +) +@FeatureScope +interface AccountFeatureComponent : AccountFeatureApi { + + fun advancedEncryptionComponentFactory(): AdvancedEncryptionComponent.Factory + + fun importAccountComponentFactory(): ImportAccountComponent.Factory + + fun backupMnemonicComponentFactory(): BackupMnemonicComponent.Factory + + fun createWalletBackupPasswordFactory(): CreateWalletBackupPasswordComponent.Factory + + fun syncWalletsBackupPasswordFactory(): SyncWalletsBackupPasswordComponent.Factory + + fun changeBackupPasswordComponentFactory(): ChangeBackupPasswordComponent.Factory + + fun restoreCloudBackupFactory(): RestoreCloudBackupComponent.Factory + + fun checkCloudBackupPasswordFactory(): CheckCloudBackupPasswordComponent.Factory + + fun restoreCloudBackupPasswordFactory(): RestoreCloudBackupPasswordComponent.Factory + + fun pincodeComponentFactory(): PinCodeComponent.Factory + + fun confirmMnemonicComponentFactory(): ConfirmMnemonicComponent.Factory + + fun walletManagmentComponentFactory(): WalletManagmentComponent.Factory + + fun switchWalletComponentFactory(): SwitchWalletComponent.Factory + + fun selectWalletComponentFactory(): SelectWalletComponent.Factory + + fun selectAddressComponentFactory(): SelectAddressComponent.Factory + + fun chainAddressSelectorComponent(): ChainAddressSelectorComponent.Factory + + fun selectMultipleWalletsComponentFactory(): SelectMultipleWalletsComponent.Factory + + fun selectSingleWalletComponentFactory(): SelectSingleWalletComponent.Factory + + fun delegatedAccountUpdatesFactory(): DelegatedAccountUpdatesComponent.Factory + + fun accountDetailsComponentFactory(): AccountDetailsComponent.Factory + + fun connectionsComponentFactory(): NodesComponent.Factory + + fun nodeDetailsComponentFactory(): NodeDetailsComponent.Factory + + fun languagesComponentFactory(): LanguagesComponent.Factory + + fun addNodeComponentFactory(): AddNodeComponent.Factory + + fun exportSeedFactory(): ExportSeedComponent.Factory + + fun exportJsonPasswordFactory(): ExportJsonComponent.Factory + + fun inject(receiver: ShareCompletedReceiver) + + fun createWatchOnlyComponentFactory(): CreateWatchWalletComponent.Factory + fun changeWatchAccountComponentFactory(): ChangeWatchAccountComponent.Factory + + fun startImportParitySignerComponentFactory(): StartImportParitySignerComponent.Factory + fun scanImportParitySignerComponentFactory(): ScanImportParitySignerComponent.Factory + fun previewImportParitySignerComponentFactory(): PreviewImportParitySignerComponent.Factory + fun finishImportParitySignerComponentFactory(): FinishImportParitySignerComponent.Factory + + fun showSignParitySignerComponentFactory(): ShowSignParitySignerComponent.Factory + fun scanSignParitySignerComponentFactory(): ScanSignParitySignerComponent.Factory + + fun startCreateWallet(): StartCreateWalletComponent.Factory + + fun manualBackupSelectWallet(): ManualBackupSelectWalletComponent.Factory + + fun manualBackupWarning(): ManualBackupWarningComponent.Factory + + fun manualBackupSecrets(): ManualBackupSecretsComponent.Factory + + fun manualBackupSelectAccount(): ManualBackupSelectAccountComponent.Factory + + fun manualBackupAdvancedSecrets(): ManualBackupAdvancedSecretsComponent.Factory + + fun scanSeedComponentFactory(): ScanSeedComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance accountRouter: AccountRouter, + @BindsInstance polkadotVaultSignInterScreenCommunicator: PolkadotVaultVariantSignCommunicator, + @BindsInstance ledgerSignInterScreenCommunicator: LedgerSignCommunicator, + @BindsInstance selectAddressCommunicator: SelectAddressCommunicator, + @BindsInstance selectMultipleWalletsCommunicator: SelectMultipleWalletsCommunicator, + @BindsInstance selectWalletCommunicator: SelectWalletCommunicator, + @BindsInstance pinCodeTwoFactorVerificationCommunicator: PinCodeTwoFactorVerificationCommunicator, + @BindsInstance syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator, + @BindsInstance changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator, + @BindsInstance restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator, + @BindsInstance selectSingleWalletCommunicator: SelectSingleWalletCommunicator, + @BindsInstance scanSeedCommunicator: ScanSeedCommunicator, + deps: AccountFeatureDependencies + ): AccountFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + CurrencyFeatureApi::class, + ProxyFeatureApi::class, + DbApi::class, + VersionsFeatureApi::class, + Web3NamesApi::class, + LedgerCoreApi::class, + CloudBackupFeatureApi::class, + SwapCoreApi::class, + XcmFeatureApi::class + ] + ) + interface AccountFeatureDependenciesComponent : AccountFeatureDependencies +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureDependencies.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureDependencies.kt new file mode 100644 index 0000000..f9d5f94 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureDependencies.kt @@ -0,0 +1,234 @@ +package io.novafoundation.nova.feature_account_impl.di + +import android.content.Context +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.data.config.GlobalConfigDataSource +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.network.rpc.SocketSingleRequestExecutor +import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1 +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.di.modules.Caching +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.condition.ConditionMixinFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.LanguagesHolder +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.SafeModeService +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationExecutor +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService +import io.novafoundation.nova.common.utils.CopyValueMixin +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.vibration.DeviceVibrator +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.AccountDao +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.MultisigOperationsDao +import io.novafoundation.nova.core_db.dao.NodeDao +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase +import io.novafoundation.nova.runtime.extrinsic.MortalityConstructor +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.qr.MultiChainQrSharingFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.repository.BlockLimitsRepository +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.web3names.domain.networking.Web3NamesInteractor +import io.novasama.substrate_sdk_android.icon.IconGenerator +import java.util.Random +import javax.inject.Named + +interface AccountFeatureDependencies { + + val maskableValueFormatterProvider: MaskableValueFormatterProvider + + val hydraDxAssetConversionFactory: HydraDxQuoting.Factory + + val pathQuoterFactory: PathQuoter.Factory + + val hydraDxAssetIdConverter: HydraDxAssetIdConverter + + val systemCallExecutor: SystemCallExecutor + + val multiChainQrSharingFactory: MultiChainQrSharingFactory + + val contextManager: ContextManager + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val permissionsAskerFactory: PermissionsAskerFactory + + val qrCodeGenerator: QrCodeGenerator + + val mortalityConstructor: MortalityConstructor + + val currencyRepository: CurrencyRepository + + val extrinsicValidityUseCase: ExtrinsicValidityUseCase + + val gasPriceProviderFactory: GasPriceProviderFactory + + val rootScope: RootScope + + val cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory + + val listSelectorMixinFactory: ListSelectorMixin.Factory + + val ledgerMigrationTracker: LedgerMigrationTracker + + val multiLocationConverterFactory: MultiLocationConverterFactory + + val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory + + val storageCache: StorageCache + + val eventsRepository: EventsRepository + + val xcmVersionDetector: XcmVersionDetector + + val chainStateRepository: ChainStateRepository + + val metadataShortenerService: MetadataShortenerService + + val hydrationPriceConversionFallback: HydrationPriceConversionFallback + + val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher + + val blockLimitsRepository: BlockLimitsRepository + + val networkApiCreator: NetworkApiCreator + + val addressSchemeFormatter: AddressSchemeFormatter + + val automaticInteractionGate: AutomaticInteractionGate + + val extrinsicWalk: ExtrinsicWalk + + fun appLinksProvider(): AppLinksProvider + + fun preferences(): Preferences + + fun encryptedPreferences(): EncryptedPreferences + + fun resourceManager(): ResourceManager + + fun iconGenerator(): IconGenerator + + fun clipboardManager(): ClipboardManager + + fun context(): Context + + fun deviceVibrator(): DeviceVibrator + + fun userDao(): AccountDao + + fun nodeDao(): NodeDao + + fun multisigOperationsDao(): MultisigOperationsDao + + fun languagesHolder(): LanguagesHolder + + fun socketSingleRequestExecutor(): SocketSingleRequestExecutor + + fun jsonMapper(): Gson + + fun addressIconGenerator(): AddressIconGenerator + + fun currencyInteractor(): CurrencyInteractor + + @Caching + fun cachingIconGenerator(): AddressIconGenerator + + fun random(): Random + + fun secretStoreV1(): SecretStoreV1 + + fun secretStoreV2(): SecretStoreV2 + + fun metaAccountDao(): MetaAccountDao + + fun chainRegistry(): ChainRegistry + + fun extrinsicBuilderFactory(): ExtrinsicBuilderFactory + + fun rpcCalls(): RpcCalls + + fun imageLoader(): ImageLoader + + fun backgroundAccessObserver(): BackgroundAccessObserver + + fun appVersionProvider(): AppVersionProvider + + fun validationExecutor(): ValidationExecutor + + fun updateNotificationsInteractor(): UpdateNotificationsInteractor + + fun safeModeService(): SafeModeService + + fun web3NamesInteractor(): Web3NamesInteractor + + fun twoFactorVerificationService(): TwoFactorVerificationService + + fun twoFactorVerificationExecutor(): TwoFactorVerificationExecutor + + fun computationalCache(): ComputationalCache + + fun getProxyRepository(): GetProxyRepository + + fun cloudBackupService(): CloudBackupService + + fun provideActionBottomSheetLauncherFactory(): ActionBottomSheetLauncherFactory + + fun customDialogProvider(): CustomDialogDisplayer.Presentation + + fun provideConditionMixinFactory(): ConditionMixinFactory + + fun multiChainRuntimeCallsApi(): MultiChainRuntimeCallsApi + + fun copyValueMixin(): CopyValueMixin + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource + + fun globalConfigDataSource(): GlobalConfigDataSource +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureHolder.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureHolder.kt new file mode 100644 index 0000000..7bed528 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureHolder.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_account_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.sequrity.verification.PinCodeTwoFactorVerificationCommunicator +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.sign.LedgerSignCommunicator +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedCommunicator +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi +import io.novafoundation.nova.feature_proxy_api.di.ProxyFeatureApi +import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.web3names.di.Web3NamesApi +import javax.inject.Inject + +@ApplicationScope +class AccountFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val accountRouter: AccountRouter, + private val polkadotVaultSignCommunicator: PolkadotVaultVariantSignCommunicator, + private val ledgerSignCommunicator: LedgerSignCommunicator, + private val selectAddressCommunicator: SelectAddressCommunicator, + private val selectMultipleWalletsCommunicator: SelectMultipleWalletsCommunicator, + private val selectWalletCommunicator: SelectWalletCommunicator, + private val pinCodeTwoFactorVerificationCommunicator: PinCodeTwoFactorVerificationCommunicator, + private val syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator, + private val changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator, + private val restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator, + private val selectSingleWalletCommunicator: SelectSingleWalletCommunicator, + private val scanSeedCommunicator: ScanSeedCommunicator, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerAccountFeatureComponent_AccountFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java)) + .proxyFeatureApi(getFeature(ProxyFeatureApi::class.java)) + .versionsFeatureApi(getFeature(VersionsFeatureApi::class.java)) + .web3NamesApi(getFeature(Web3NamesApi::class.java)) + .cloudBackupFeatureApi(getFeature(CloudBackupFeatureApi::class.java)) + .ledgerCoreApi(getFeature(LedgerCoreApi::class.java)) + .swapCoreApi(getFeature(SwapCoreApi::class.java)) + .xcmFeatureApi(getFeature(XcmFeatureApi::class.java)) + .build() + + return DaggerAccountFeatureComponent.factory() + .create( + accountRouter = accountRouter, + polkadotVaultSignInterScreenCommunicator = polkadotVaultSignCommunicator, + ledgerSignInterScreenCommunicator = ledgerSignCommunicator, + selectAddressCommunicator = selectAddressCommunicator, + selectMultipleWalletsCommunicator = selectMultipleWalletsCommunicator, + selectWalletCommunicator = selectWalletCommunicator, + pinCodeTwoFactorVerificationCommunicator = pinCodeTwoFactorVerificationCommunicator, + syncWalletsBackupPasswordCommunicator = syncWalletsBackupPasswordCommunicator, + changeBackupPasswordCommunicator = changeBackupPasswordCommunicator, + restoreBackupPasswordCommunicator = restoreBackupPasswordCommunicator, + selectSingleWalletCommunicator = selectSingleWalletCommunicator, + scanSeedCommunicator = scanSeedCommunicator, + deps = accountFeatureDependencies + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt new file mode 100644 index 0000000..42a1bae --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt @@ -0,0 +1,881 @@ +package io.novafoundation.nova.feature_account_impl.di + +import com.google.gson.Gson +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.mappers.mapEncryptionToCryptoType +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.rpc.SocketSingleRequestExecutor +import io.novafoundation.nova.common.data.secrets.v1.SecretStoreV1 +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.LanguagesHolder +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory +import io.novafoundation.nova.common.utils.CopyValueMixin +import io.novafoundation.nova.common.utils.DEFAULT_DERIVATION_PATH +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.AccountDao +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.MultisigOperationsDao +import io.novafoundation.nova.core_db.dao.NodeDao +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_api.data.repository.CreateSecretsRepository +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.OnChainIdentity +import io.novafoundation.nova.feature_account_api.domain.cloudBackup.ApplyLocalSnapshotToCloudBackupUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.CreateGiftMetaAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.RealAccountUIUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActionsProvider +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageUseCase +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserProvider +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_account_impl.RealBiometricServiceFactory +import io.novafoundation.nova.feature_account_impl.data.cloudBackup.CloudBackupAccountsModificationsTracker +import io.novafoundation.nova.feature_account_impl.data.ethereum.transaction.RealEvmTransactionService +import io.novafoundation.nova.feature_account_impl.data.events.RealMetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_impl.data.extrinsic.RealExtrinsicService +import io.novafoundation.nova.feature_account_impl.data.extrinsic.RealExtrinsicServiceFactory +import io.novafoundation.nova.feature_account_impl.data.extrinsic.RealExtrinsicSplitter +import io.novafoundation.nova.feature_account_impl.data.fee.capability.RealCustomCustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.feature_account_impl.data.multisig.RealMultisigDetailsRepository +import io.novafoundation.nova.feature_account_impl.data.multisig.repository.RealMultisigOperationLocalCallRepository +import io.novafoundation.nova.feature_account_impl.data.network.blockchain.AccountSubstrateSource +import io.novafoundation.nova.feature_account_impl.data.network.blockchain.AccountSubstrateSourceImpl +import io.novafoundation.nova.feature_account_impl.data.proxy.RealMetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_impl.data.repository.AccountRepositoryImpl +import io.novafoundation.nova.feature_account_impl.data.repository.RealCreateSecretsRepository +import io.novafoundation.nova.feature_account_impl.data.repository.RealOnChainIdentityRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.JsonAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SeedAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SubstrateKeypairAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.TrustWalletAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSource +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSourceImpl +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.RealSecretsMetaAccountLocalFactory +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule +import io.novafoundation.nova.feature_account_impl.di.modules.AdvancedEncryptionStoreModule +import io.novafoundation.nova.feature_account_impl.di.modules.CloudBackupModule +import io.novafoundation.nova.feature_account_impl.di.modules.CustomFeeModule +import io.novafoundation.nova.feature_account_impl.di.modules.ExternalAccountsDiscoveryModule +import io.novafoundation.nova.feature_account_impl.di.modules.IdentityProviderModule +import io.novafoundation.nova.feature_account_impl.di.modules.MultisigModule +import io.novafoundation.nova.feature_account_impl.di.modules.ParitySignerModule +import io.novafoundation.nova.feature_account_impl.di.modules.WatchOnlyModule +import io.novafoundation.nova.feature_account_impl.di.modules.deeplinks.DeepLinkModule +import io.novafoundation.nova.feature_account_impl.di.modules.signers.SignersModule +import io.novafoundation.nova.feature_account_impl.domain.AccountInteractorImpl +import io.novafoundation.nova.feature_account_impl.domain.MetaAccountGroupingInteractorImpl +import io.novafoundation.nova.feature_account_impl.domain.NodeHostValidator +import io.novafoundation.nova.feature_account_impl.domain.RealCreateGiftMetaAccountUseCase +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.cloudBackup.RealApplyLocalSnapshotToCloudBackupUseCase +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.export.CommonExportSecretsInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.export.RealCommonExportSecretsInteractor +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.CreateCloudBackupPasswordInteractor +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.RealCreateCloudBackupPasswordInteractor +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword.EnterCloudBackupInteractor +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword.RealEnterCloudBackupInteractor +import io.novafoundation.nova.feature_account_impl.domain.manualBackup.ManualBackupSelectAccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.manualBackup.ManualBackupSelectWalletInteractor +import io.novafoundation.nova.feature_account_impl.domain.manualBackup.RealManualBackupSelectAccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.manualBackup.RealManualBackupSelectWalletInteractor +import io.novafoundation.nova.feature_account_impl.domain.startCreateWallet.RealStartCreateWalletInteractor +import io.novafoundation.nova.feature_account_impl.domain.startCreateWallet.StartCreateWalletInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.addressActions.AddressActionsMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountValidForTransactionListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountWithBalanceListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.RealMetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated.DelegatedMetaAccountUpdatesListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated.RealMultisigFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated.RealProxyFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.mixin.SelectAddressMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.mixin.SelectSingleWalletMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.wallet.WalletUiUseCaseImpl +import io.novafoundation.nova.feature_account_impl.presentation.common.RealSelectedAccountUseCase +import io.novafoundation.nova.feature_account_impl.presentation.common.address.RealCopyAddressMixin +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.AddAccountLauncherPresentationFactory +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.RealAddAccountLauncherPresentationFactory +import io.novafoundation.nova.feature_account_impl.presentation.common.sign.notSupported.RealSigningNotSupportedPresentable +import io.novafoundation.nova.feature_account_impl.presentation.common.sign.notSupported.SigningNotSupportedPresentable +import io.novafoundation.nova.feature_account_impl.presentation.language.RealLanguageUseCase +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.ManualBackupSecretsAdapterItemFactory +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.RealManualBackupSecretsAdapterItemFactory +import io.novafoundation.nova.feature_account_impl.presentation.mixin.identity.RealIdentityMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.mixin.selectWallet.RealRealSelectWalletMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.navigation.RealExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.config.RealPolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_impl.presentation.sign.NestedSigningPresenter +import io.novafoundation.nova.feature_account_impl.presentation.sign.RealNestedSigningPresenter +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.qr.MultiChainQrSharingFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.web3names.domain.networking.Web3NamesInteractor +import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption +import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder +import io.novasama.substrate_sdk_android.encrypt.json.JsonEncoder +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import javax.inject.Named + +@Module( + includes = [ + SignersModule::class, + WatchOnlyModule::class, + ParitySignerModule::class, + IdentityProviderModule::class, + AdvancedEncryptionStoreModule::class, + AddAccountsModule::class, + CloudBackupModule::class, + CustomFeeModule::class, + MultisigModule::class, + BindsModule::class, + DeepLinkModule::class, + ExternalAccountsDiscoveryModule::class + ] +) +class AccountFeatureModule { + + @Module + internal interface BindsModule { + + @Binds + fun bindExtrinsicSplitter(real: RealExtrinsicSplitter): ExtrinsicSplitter + + @Binds + fun bindSigningContextFactory(real: SigningContextFactory): SigningContext.Factory + + @Binds + fun bindAddressActionsMixinFactory(real: AddressActionsMixinFactory): AddressActionsMixin.Factory + + @Binds + fun bindNestedSigningPresenter(real: RealNestedSigningPresenter): NestedSigningPresenter + + @Binds + fun bindProxyFormatter(real: RealProxyFormatter): ProxyFormatter + + @Binds + fun bindMultisigFormatter(real: RealMultisigFormatter): MultisigFormatter + + @Binds + fun bindMultisigApprovalsRepository(real: RealMultisigDetailsRepository): MultisigDetailsRepository + } + + @Provides + @FeatureScope + fun provideMetaAccountsUpdatesRegistry( + preferences: Preferences + ): MetaAccountsUpdatesRegistry = RealMetaAccountsUpdatesRegistry(preferences) + + @Provides + @FeatureScope + fun provideEncryptionDefaults(): EncryptionDefaults = EncryptionDefaults( + substrateCryptoType = CryptoType.SR25519, + substrateDerivationPath = "", + ethereumCryptoType = mapEncryptionToCryptoType(MultiChainEncryption.Ethereum.encryptionType), + ethereumDerivationPath = BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH + ) + + @Provides + @FeatureScope + fun provideExtrinsicServiceFactory( + accountRepository: AccountRepository, + rpcCalls: RpcCalls, + extrinsicBuilderFactory: ExtrinsicBuilderFactory, + chainRegistry: ChainRegistry, + signerProvider: SignerProvider, + extrinsicSplitter: ExtrinsicSplitter, + feePaymentProviderRegistry: FeePaymentProviderRegistry, + eventsRepository: EventsRepository, + signingContextFactory: SigningContext.Factory + ): ExtrinsicService.Factory = RealExtrinsicServiceFactory( + rpcCalls, + chainRegistry, + accountRepository, + extrinsicBuilderFactory, + signerProvider, + extrinsicSplitter, + eventsRepository, + feePaymentProviderRegistry, + signingContextFactory + ) + + @Provides + @FeatureScope + fun provideExtrinsicService( + accountRepository: AccountRepository, + rpcCalls: RpcCalls, + extrinsicBuilderFactory: ExtrinsicBuilderFactory, + chainRegistry: ChainRegistry, + signerProvider: SignerProvider, + extrinsicSplitter: ExtrinsicSplitter, + feePaymentProviderRegistry: FeePaymentProviderRegistry, + eventsRepository: EventsRepository, + signingContextFactory: SigningContext.Factory, + ): ExtrinsicService = RealExtrinsicService( + rpcCalls, + chainRegistry, + accountRepository, + extrinsicBuilderFactory, + signerProvider, + extrinsicSplitter, + feePaymentProviderRegistry, + eventsRepository, + signingContextFactory, + coroutineScope = null + ) + + @Provides + @FeatureScope + fun provideJsonDecoder(jsonMapper: Gson) = JsonDecoder(jsonMapper) + + @Provides + @FeatureScope + fun provideJsonEncoder( + jsonMapper: Gson, + ) = JsonEncoder(jsonMapper) + + @Provides + @FeatureScope + fun provideMetaAccountChangesRequestBus( + externalAccountsSyncService: dagger.Lazy, + cloudBackupAccountsModificationsTracker: CloudBackupAccountsModificationsTracker, + ): MetaAccountChangesEventBus = RealMetaAccountChangesEventBus( + externalAccountsSyncService = externalAccountsSyncService, + cloudBackupAccountsModificationsTracker = cloudBackupAccountsModificationsTracker + ) + + @Provides + @FeatureScope + fun provideAccountRepository( + accountDataSource: AccountDataSource, + accountDao: AccountDao, + nodeDao: NodeDao, + JsonEncoder: JsonEncoder, + accountSubstrateSource: AccountSubstrateSource, + languagesHolder: LanguagesHolder, + secretStoreV2: SecretStoreV2, + metaAccountChangesEventBus: MetaAccountChangesEventBus, + ): AccountRepository { + return AccountRepositoryImpl( + accountDataSource, + accountDao, + nodeDao, + JsonEncoder, + languagesHolder, + accountSubstrateSource, + secretStoreV2, + metaAccountChangesEventBus + ) + } + + @Provides + @FeatureScope + fun provideAccountInteractor( + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + ): AccountInteractor { + return AccountInteractorImpl(chainRegistry, accountRepository) + } + + @Provides + @FeatureScope + fun provideCreateCloudBackupPasswordInteractor( + cloudBackupService: CloudBackupService, + accountRepository: AccountRepository, + encryptionDefaults: EncryptionDefaults, + accountSecretsFactory: AccountSecretsFactory, + secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, + metaAccountDao: MetaAccountDao, + cloudBackupFacade: LocalAccountsCloudBackupFacade + ): CreateCloudBackupPasswordInteractor { + return RealCreateCloudBackupPasswordInteractor( + cloudBackupService, + accountRepository, + encryptionDefaults, + accountSecretsFactory, + secretsMetaAccountLocalFactory, + metaAccountDao, + cloudBackupFacade + ) + } + + @Provides + @FeatureScope + fun provideRestoreCloudBackupInteractor( + cloudBackupService: CloudBackupService, + cloudBackupFacade: LocalAccountsCloudBackupFacade, + accountRepository: AccountRepository + ): EnterCloudBackupInteractor { + return RealEnterCloudBackupInteractor( + cloudBackupService = cloudBackupService, + cloudBackupFacade = cloudBackupFacade, + accountRepository = accountRepository + ) + } + + @Provides + @FeatureScope + fun provideSecretsMetaAccountLocalFactory(): SecretsMetaAccountLocalFactory { + return RealSecretsMetaAccountLocalFactory() + } + + @Provides + @FeatureScope + fun provideAccountMappers( + ledgerMigrationTracker: LedgerMigrationTracker, + multisigRepository: MultisigRepository, + gson: Gson + ): AccountMappers { + return AccountMappers(ledgerMigrationTracker, gson, multisigRepository) + } + + @Provides + @FeatureScope + fun provideAccountDataSource( + preferences: Preferences, + encryptedPreferences: EncryptedPreferences, + nodeDao: NodeDao, + secretStoreV1: SecretStoreV1, + accountDataMigration: AccountDataMigration, + metaAccountDao: MetaAccountDao, + secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, + secretStoreV2: SecretStoreV2, + accountMappers: AccountMappers, + ): AccountDataSource { + return AccountDataSourceImpl( + preferences, + encryptedPreferences, + nodeDao, + metaAccountDao, + accountMappers, + secretStoreV2, + secretsMetaAccountLocalFactory, + secretStoreV1, + accountDataMigration + ) + } + + @Provides + fun provideNodeHostValidator() = NodeHostValidator() + + @Provides + @FeatureScope + fun provideAccountSubstrateSource(socketRequestExecutor: SocketSingleRequestExecutor): AccountSubstrateSource { + return AccountSubstrateSourceImpl(socketRequestExecutor) + } + + @Provides + @FeatureScope + fun provideAccountDataMigration( + preferences: Preferences, + encryptedPreferences: EncryptedPreferences, + accountDao: AccountDao, + ): AccountDataMigration { + return AccountDataMigration(preferences, encryptedPreferences, accountDao) + } + + @Provides + @FeatureScope + fun provideExternalAccountActions( + resourceManager: ResourceManager, + addressIconGenerator: AddressIconGenerator, + copyAddressMixin: CopyAddressMixin, + copyValueMixin: CopyValueMixin, + ): ExternalActions.Presentation { + return ExternalActionsProvider(resourceManager, addressIconGenerator, copyAddressMixin, copyValueMixin) + } + + @Provides + @FeatureScope + fun provideAccountUpdateScope( + accountRepository: AccountRepository, + ) = AccountUpdateScope(accountRepository) + + @Provides + @FeatureScope + fun provideAddressDisplayUseCase( + accountRepository: AccountRepository, + ) = AddressDisplayUseCase(accountRepository) + + @Provides + @FeatureScope + fun provideAccountUseCase( + accountRepository: AccountRepository, + addressIconGenerator: AddressIconGenerator, + walletUiUseCase: WalletUiUseCase, + metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry, + presentationMapper: MetaAccountTypePresentationMapper, + maskableValueFormatterProvider: MaskableValueFormatterProvider, + resourceManager: ResourceManager + ): SelectedAccountUseCase = RealSelectedAccountUseCase( + accountRepository = accountRepository, + walletUiUseCase = walletUiUseCase, + addressIconGenerator = addressIconGenerator, + metaAccountsUpdatesRegistry = metaAccountsUpdatesRegistry, + accountTypePresentationMapper = presentationMapper, + maskableValueFormatterProvider = maskableValueFormatterProvider, + resourceManager = resourceManager + ) + + @Provides + @FeatureScope + fun providePolkadotVaultVariantConfigProvider( + resourceManager: ResourceManager, + appLinksProvider: AppLinksProvider + ): PolkadotVaultVariantConfigProvider = RealPolkadotVaultVariantConfigProvider(resourceManager, appLinksProvider) + + @Provides + @FeatureScope + fun provideAccountDetailsInteractor( + accountRepository: AccountRepository, + secretStoreV2: SecretStoreV2, + chainRegistry: ChainRegistry, + ) = WalletDetailsInteractor( + accountRepository, + secretStoreV2, + chainRegistry + ) + + @Provides + @FeatureScope + fun provideAccountSecretsFactory( + JsonDecoder: JsonDecoder + ) = AccountSecretsFactory(JsonDecoder) + + @Provides + @FeatureScope + fun provideAddAccountInteractor( + mnemonicAddAccountRepository: MnemonicAddAccountRepository, + jsonAddAccountRepository: JsonAddAccountRepository, + seedAddAccountRepository: SeedAddAccountRepository, + substrateKeypairAddAccountRepository: SubstrateKeypairAddAccountRepository, + trustWalletAddAccountRepository: TrustWalletAddAccountRepository, + accountRepository: AccountRepository, + advancedEncryptionInteractor: AdvancedEncryptionInteractor + ) = AddAccountInteractor( + mnemonicAddAccountRepository, + jsonAddAccountRepository, + seedAddAccountRepository, + substrateKeypairAddAccountRepository, + trustWalletAddAccountRepository, + accountRepository, + advancedEncryptionInteractor + ) + + @Provides + @FeatureScope + fun provideInteractor( + accountRepository: AccountRepository, + secretStoreV2: SecretStoreV2, + chainRegistry: ChainRegistry, + encryptionDefaults: EncryptionDefaults + ) = AdvancedEncryptionInteractor(accountRepository, secretStoreV2, chainRegistry, encryptionDefaults) + + @Provides + fun provideImportTypeChooserMixin(): ImportTypeChooserMixin.Presentation = ImportTypeChooserProvider() + + @Provides + fun provideAddAccountLauncherPresentationFactory( + cloudBackupService: CloudBackupService, + importTypeChooserMixin: ImportTypeChooserMixin.Presentation, + resourceManager: ResourceManager, + router: AccountRouter, + addAccountInteractor: AddAccountInteractor, + cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory + ): AddAccountLauncherPresentationFactory = RealAddAccountLauncherPresentationFactory( + cloudBackupService = cloudBackupService, + importTypeChooserMixin = importTypeChooserMixin, + resourceManager = resourceManager, + router = router, + addAccountInteractor = addAccountInteractor, + cloudBackupChangingWarningMixinFactory = cloudBackupChangingWarningMixinFactory + ) + + @Provides + @FeatureScope + fun provideAddressInputMixinFactory( + addressIconGenerator: AddressIconGenerator, + systemCallExecutor: SystemCallExecutor, + clipboardManager: ClipboardManager, + multiChainQrSharingFactory: MultiChainQrSharingFactory, + resourceManager: ResourceManager, + accountUseCase: SelectedAccountUseCase, + web3NamesInteractor: Web3NamesInteractor + ) = AddressInputMixinFactory( + addressIconGenerator = addressIconGenerator, + systemCallExecutor = systemCallExecutor, + clipboardManager = clipboardManager, + qrSharingFactory = multiChainQrSharingFactory, + resourceManager = resourceManager, + accountUseCase = accountUseCase, + web3NamesInteractor = web3NamesInteractor + ) + + @Provides + @FeatureScope + fun provideWalletUiUseCase( + accountRepository: AccountRepository, + addressIconGenerator: AddressIconGenerator, + chainRegistry: ChainRegistry + ): WalletUiUseCase { + return WalletUiUseCaseImpl(accountRepository, addressIconGenerator, chainRegistry) + } + + @Provides + @FeatureScope + fun provideMetaAccountGroupingInteractor( + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + currencyRepository: CurrencyRepository, + metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry + ): MetaAccountGroupingInteractor { + return MetaAccountGroupingInteractorImpl(chainRegistry, accountRepository, currencyRepository, metaAccountsUpdatesRegistry) + } + + @Provides + @FeatureScope + fun provideProxyFormatter( + walletUseCase: WalletUiUseCase, + resourceManager: ResourceManager + ) = RealProxyFormatter(walletUseCase, resourceManager) + + @Provides + @FeatureScope + fun provideDelegatedMetaAccountUpdatesListingMixinFactory( + walletUseCase: WalletUiUseCase, + metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + proxyFormatter: ProxyFormatter, + multisigFormatter: MultisigFormatter, + resourceManager: ResourceManager + ) = DelegatedMetaAccountUpdatesListingMixinFactory(walletUseCase, metaAccountGroupingInteractor, proxyFormatter, multisigFormatter, resourceManager) + + @Provides + @FeatureScope + fun provideAccountListingMixinFactory( + walletUseCase: WalletUiUseCase, + metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + proxyFormatter: ProxyFormatter, + multisigFormatter: MultisigFormatter, + accountTypePresentationMapper: MetaAccountTypePresentationMapper, + ) = MetaAccountWithBalanceListingMixinFactory( + walletUseCase, + metaAccountGroupingInteractor, + accountTypePresentationMapper, + multisigFormatter, + proxyFormatter + ) + + @Provides + @FeatureScope + fun provideAccountTypePresentationMapper( + resourceManager: ResourceManager, + polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + ledgerMigrationTracker: LedgerMigrationTracker + ): MetaAccountTypePresentationMapper = RealMetaAccountTypePresentationMapper(resourceManager, polkadotVaultVariantConfigProvider, ledgerMigrationTracker) + + @Provides + @FeatureScope + fun provideIdentityRepository( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + chainRegistry: ChainRegistry + ): OnChainIdentityRepository = RealOnChainIdentityRepository(remoteStorageSource, chainRegistry) + + @Provides + @FeatureScope + fun provideEvmTransactionService( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + signerProvider: SignerProvider, + gasPriceProviderFactory: GasPriceProviderFactory, + ): EvmTransactionService = RealEvmTransactionService( + accountRepository = accountRepository, + chainRegistry = chainRegistry, + signerProvider = signerProvider, + gasPriceProviderFactory = gasPriceProviderFactory + ) + + @Provides + @FeatureScope + fun provideIdentityMixinFactory( + appLinksProvider: AppLinksProvider + ): IdentityMixin.Factory { + return RealIdentityMixinFactory(appLinksProvider) + } + + @Provides + @FeatureScope + fun provideLanguageUseCase(accountInteractor: AccountInteractor): LanguageUseCase { + return RealLanguageUseCase(accountInteractor) + } + + @Provides + @FeatureScope + fun provideSelectWalletMixinFactory( + accountRepository: AccountRepository, + accountGroupingInteractor: MetaAccountGroupingInteractor, + walletUiUseCase: WalletUiUseCase, + communicator: SelectWalletCommunicator, + ): SelectWalletMixin.Factory { + return RealRealSelectWalletMixinFactory( + accountRepository = accountRepository, + accountGroupingInteractor = accountGroupingInteractor, + walletUiUseCase = walletUiUseCase, + requester = communicator + ) + } + + @Provides + @FeatureScope + fun provideBiometricServiceFactory(accountRepository: AccountRepository): BiometricServiceFactory { + return RealBiometricServiceFactory(accountRepository) + } + + @Provides + @FeatureScope + fun provideSigningNotSupportedPresentable( + contextManager: ContextManager + ): SigningNotSupportedPresentable = RealSigningNotSupportedPresentable(contextManager) + + @Provides + @FeatureScope + fun provideSelectAddressMixinFactory( + selectAddressCommunicator: SelectAddressCommunicator, + metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + ): SelectAddressMixin.Factory { + return SelectAddressMixinFactory( + selectAddressCommunicator, + metaAccountGroupingInteractor + ) + } + + @Provides + @FeatureScope + fun provideStartCreateWalletInteractor( + cloudBackupService: CloudBackupService, + addAccountInteractor: AddAccountInteractor, + accountRepository: AccountRepository + ): StartCreateWalletInteractor { + return RealStartCreateWalletInteractor(cloudBackupService, addAccountInteractor, accountRepository) + } + + @Provides + @FeatureScope + fun provideApplyLocalSnapshotToCloudBackupUseCase( + localAccountsCloudBackupFacade: LocalAccountsCloudBackupFacade, + cloudBackupService: CloudBackupService + ): ApplyLocalSnapshotToCloudBackupUseCase { + return RealApplyLocalSnapshotToCloudBackupUseCase( + localAccountsCloudBackupFacade, + cloudBackupService + ) + } + + @Provides + @FeatureScope + fun provideManualBackupSelectAccountInteractor( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry + ): ManualBackupSelectAccountInteractor { + return RealManualBackupSelectAccountInteractor(accountRepository, chainRegistry) + } + + @Provides + @FeatureScope + fun provideManualBackupSelectWalletInteractor( + accountRepository: AccountRepository + ): ManualBackupSelectWalletInteractor { + return RealManualBackupSelectWalletInteractor(accountRepository) + } + + @Provides + @FeatureScope + fun provideCommonExportSecretsInteractor( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + secretStoreV2: SecretStoreV2 + ): CommonExportSecretsInteractor { + return RealCommonExportSecretsInteractor( + accountRepository, + chainRegistry, + secretStoreV2 + ) + } + + @Provides + @FeatureScope + fun provideManualBackupSecretsAdapterItemFactory( + resourceManager: ResourceManager, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + commonExportSecretsInteractor: CommonExportSecretsInteractor + ): ManualBackupSecretsAdapterItemFactory { + return RealManualBackupSecretsAdapterItemFactory( + resourceManager, + accountRepository, + chainRegistry, + commonExportSecretsInteractor + ) + } + + @Provides + @FeatureScope + fun provideCustomFeeCapabilityFacade( + accountRepository: AccountRepository, + feePaymentProviderRegistry: FeePaymentProviderRegistry, + ): CustomFeeCapabilityFacade = RealCustomCustomFeeCapabilityFacade(accountRepository, feePaymentProviderRegistry) + + @Provides + @FeatureScope + fun provideCopyAddressMixin( + copyValueMixin: CopyValueMixin, + preferences: Preferences, + router: AccountRouter + ): CopyAddressMixin = RealCopyAddressMixin( + copyValueMixin, + preferences, + router + ) + + @Provides + @FeatureScope + fun provideExtrinsicNavigationWrapper( + accountRouter: AccountRouter, + accountUseCase: AccountInteractor + ): ExtrinsicNavigationWrapper { + return RealExtrinsicNavigationWrapper( + accountRouter = accountRouter, + accountUseCase = accountUseCase + ) + } + + @Provides + @FeatureScope + fun provideMultisigOperationLocalCallRepository(multisigOperationsDao: MultisigOperationsDao): MultisigOperationLocalCallRepository { + return RealMultisigOperationLocalCallRepository(multisigOperationsDao) + } + + @Provides + @FeatureScope + fun provideAccountUIUseCase( + accountRepository: AccountRepository, + walletUiUseCase: WalletUiUseCase, + addressIconGenerator: AddressIconGenerator, + @OnChainIdentity identityProvider: IdentityProvider + ): AccountUIUseCase { + return RealAccountUIUseCase( + accountRepository, + walletUiUseCase, + addressIconGenerator, + identityProvider + ) + } + + @Provides + @FeatureScope + fun provideCreateSecretsRepository(accountSecretsFactory: AccountSecretsFactory): CreateSecretsRepository { + return RealCreateSecretsRepository( + accountSecretsFactory + ) + } + + @Provides + @FeatureScope + fun provideCreateGiftMetaAccountUseCase(encryptionDefaults: EncryptionDefaults): CreateGiftMetaAccountUseCase { + return RealCreateGiftMetaAccountUseCase(encryptionDefaults) + } + + @Provides + @FeatureScope + fun provideAccountListingMixinValidForTransactionsFactory( + walletUiUseCase: WalletUiUseCase, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + accountTypePresentationMapper: MetaAccountTypePresentationMapper, + ): MetaAccountValidForTransactionListingMixinFactory { + return MetaAccountValidForTransactionListingMixinFactory( + walletUiUseCase = walletUiUseCase, + resourceManager = resourceManager, + chainRegistry = chainRegistry, + metaAccountGroupingInteractor = metaAccountGroupingInteractor, + accountTypePresentationMapper = accountTypePresentationMapper + ) + } + + @Provides + @FeatureScope + fun provideSelectSingleWalletMixinFactory( + selectSingleWalletRequester: SelectSingleWalletCommunicator, + metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + ): SelectSingleWalletMixin.Factory { + return SelectSingleWalletMixinFactory( + selectSingleWalletRequester, + metaAccountGroupingInteractor + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AddAccountsModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AddAccountsModule.kt new file mode 100644 index 0000000..700efee --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AddAccountsModule.kt @@ -0,0 +1,151 @@ +package io.novafoundation.nova.feature_account_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.GenericLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.LegacyLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.LocalAddMetaAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.ledger.RealGenericLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.ledger.RealLegacyLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.paritySigner.ParitySignerAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.JsonAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SubstrateKeypairAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.RealMnemonicAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SeedAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.watchOnly.WatchOnlyAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.AccountDataSource +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.feature_account_impl.di.AddAccountsModule.BindsModule +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder + +@Module(includes = [BindsModule::class]) +class AddAccountsModule { + + @Module + interface BindsModule + + @Provides + @FeatureScope + fun provideLocalAddMetaAccountRepository( + metaAccountChangesEventBus: MetaAccountChangesEventBus, + metaAccountDao: MetaAccountDao, + secretStoreV2: SecretStoreV2 + ) = LocalAddMetaAccountRepository( + metaAccountChangesEventBus, + metaAccountDao, + secretStoreV2 + ) + + @Provides + @FeatureScope + fun provideMnemonicAddAccountRepository( + accountDataSource: AccountDataSource, + accountSecretsFactory: AccountSecretsFactory, + chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus + ): MnemonicAddAccountRepository = RealMnemonicAddAccountRepository( + accountDataSource, + accountSecretsFactory, + chainRegistry, + metaAccountChangesEventBus + ) + + @Provides + @FeatureScope + fun provideJsonAddAccountRepository( + accountDataSource: AccountDataSource, + accountSecretsFactory: AccountSecretsFactory, + JsonDecoder: JsonDecoder, + chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus + ) = JsonAddAccountRepository( + accountDataSource, + accountSecretsFactory, + JsonDecoder, + chainRegistry, + metaAccountChangesEventBus + ) + + @Provides + @FeatureScope + fun provideRawKeyAddAccountRepository( + accountDataSource: AccountDataSource, + accountSecretsFactory: AccountSecretsFactory, + chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus + ) = SubstrateKeypairAddAccountRepository( + accountDataSource, + accountSecretsFactory, + chainRegistry, + metaAccountChangesEventBus + ) + + @Provides + @FeatureScope + fun provideSeedAddAccountRepository( + accountDataSource: AccountDataSource, + accountSecretsFactory: AccountSecretsFactory, + chainRegistry: ChainRegistry, + metaAccountChangesEventBus: MetaAccountChangesEventBus + ) = SeedAddAccountRepository( + accountDataSource, + accountSecretsFactory, + chainRegistry, + metaAccountChangesEventBus + ) + + @Provides + @FeatureScope + fun provideWatchOnlyAddAccountRepository( + accountDao: MetaAccountDao, + metaAccountChangesEventBus: MetaAccountChangesEventBus + ) = WatchOnlyAddAccountRepository( + accountDao, + metaAccountChangesEventBus + ) + + @Provides + @FeatureScope + fun provideParitySignerAddAccountRepository( + accountDao: MetaAccountDao, + metaAccountChangesEventBus: MetaAccountChangesEventBus + ) = ParitySignerAddAccountRepository( + accountDao, + metaAccountChangesEventBus + ) + + @Provides + @FeatureScope + fun provideLegacyLedgerAddAccountRepository( + accountDao: MetaAccountDao, + chainRegistry: ChainRegistry, + secretStoreV2: SecretStoreV2, + metaAccountChangesEventBus: MetaAccountChangesEventBus + ): LegacyLedgerAddAccountRepository = RealLegacyLedgerAddAccountRepository( + accountDao, + chainRegistry, + secretStoreV2, + metaAccountChangesEventBus + ) + + @Provides + @FeatureScope + fun provideGenericLedgerAddAccountRepository( + accountDao: MetaAccountDao, + secretStoreV2: SecretStoreV2, + metaAccountChangesEventBus: MetaAccountChangesEventBus, + accountMappers: AccountMappers, + ): GenericLedgerAddAccountRepository = RealGenericLedgerAddAccountRepository( + accountDao, + secretStoreV2, + accountMappers, + metaAccountChangesEventBus, + ) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/AdvancedEncryptionModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/AdvancedEncryptionModule.kt new file mode 100644 index 0000000..ceb72b5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/AdvancedEncryptionModule.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_impl.domain.common.AdvancedEncryptionSelectionStoreProvider + +@Module +class AdvancedEncryptionStoreModule { + + @Provides + @FeatureScope + fun provideAdvancedEncryptionSelectionStoreProvider( + computationalCache: ComputationalCache + ): AdvancedEncryptionSelectionStoreProvider { + return AdvancedEncryptionSelectionStoreProvider(computationalCache) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/CloudBackupModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/CloudBackupModule.kt new file mode 100644 index 0000000..9e21416 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/CloudBackupModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_impl.data.cloudBackup.CloudBackupAccountsModificationsTracker +import io.novafoundation.nova.feature_account_impl.data.cloudBackup.RealCloudBackupAccountsModificationsTracker +import io.novafoundation.nova.feature_account_impl.data.cloudBackup.RealLocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class CloudBackupModule { + + @FeatureScope + @Provides + fun provideModificationsTracker( + preferences: Preferences + ): CloudBackupAccountsModificationsTracker { + return RealCloudBackupAccountsModificationsTracker(preferences) + } + + @Provides + @FeatureScope + fun provideLocalAccountsCloudBackupFacade( + secretsStoreV2: SecretStoreV2, + accountDao: MetaAccountDao, + cloudBackupAccountsModificationsTracker: CloudBackupAccountsModificationsTracker, + metaAccountChangedEvents: MetaAccountChangesEventBus, + chainRegistry: ChainRegistry, + accountMappers: AccountMappers, + ): LocalAccountsCloudBackupFacade { + return RealLocalAccountsCloudBackupFacade( + secretsStoreV2 = secretsStoreV2, + accountDao = accountDao, + cloudBackupAccountsModificationsTracker = cloudBackupAccountsModificationsTracker, + metaAccountChangedEvents = metaAccountChangedEvents, + chainRegistry = chainRegistry, + accountMappers = accountMappers + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/CustomFeeModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/CustomFeeModule.kt new file mode 100644 index 0000000..ec210bd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/CustomFeeModule.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_account_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector +import io.novafoundation.nova.feature_account_impl.data.fee.RealFeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_impl.data.fee.chains.AssetHubFeePaymentProvider +import io.novafoundation.nova.feature_account_impl.data.fee.chains.HydrationFeePaymentProvider +import io.novafoundation.nova.feature_account_impl.data.fee.types.assetHub.AssetHubFeePaymentAssetsFetcherFactory +import io.novafoundation.nova.feature_account_impl.data.fee.types.hydra.HydraDxQuoteSharedComputation +import io.novafoundation.nova.feature_account_impl.data.fee.types.hydra.RealHydrationFeeInjector +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.BlockNumberUpdater +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class CustomFeeModule { + + @Provides + @FeatureScope + fun provideBlockNumberUpdater( + chainRegistry: ChainRegistry, + storageCache: StorageCache + ): BlockNumberUpdater { + return BlockNumberUpdater( + chainRegistry, + storageCache + ) + } + + @Provides + @FeatureScope + fun provideHydraDxQuoteSharedComputation( + computationalCache: ComputationalCache, + quotingFactory: HydraDxQuoting.Factory, + pathQuoterFactory: PathQuoter.Factory, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + chainStateRepository: ChainStateRepository + ): HydraDxQuoteSharedComputation { + return HydraDxQuoteSharedComputation( + computationalCache = computationalCache, + quotingFactory = quotingFactory, + pathQuoterFactory = pathQuoterFactory, + storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory, + chainStateRepository = chainStateRepository + ) + } + + @Provides + @FeatureScope + fun provideAssetHubFeePaymentAssetsFetcher( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + multiLocationConverterFactory: MultiLocationConverterFactory + ): AssetHubFeePaymentAssetsFetcherFactory { + return AssetHubFeePaymentAssetsFetcherFactory(remoteStorageSource, multiLocationConverterFactory) + } + + @Provides + @FeatureScope + fun provideHydraFeesInjector( + hydraDxAssetIdConverter: HydraDxAssetIdConverter, + ): HydrationFeeInjector = RealHydrationFeeInjector( + hydraDxAssetIdConverter, + ) + + @Provides + @FeatureScope + fun provideFeePaymentProviderRegistry( + assetHubFeePaymentProviderFactory: AssetHubFeePaymentProvider.Factory, + hydrationFeePaymentProviderFactory: HydrationFeePaymentProvider.Factory, + chainRegistry: ChainRegistry + ): FeePaymentProviderRegistry = RealFeePaymentProviderRegistry( + assetHubFeePaymentProviderFactory, + hydrationFeePaymentProviderFactory, + chainRegistry + ) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/ExportModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/ExportModule.kt new file mode 100644 index 0000000..238f332 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/ExportModule.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_account_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.ExportJsonInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.export.mnemonic.ExportMnemonicInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.export.seed.ExportPrivateKeyInteractor +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class ExportModule { + + @Provides + @FeatureScope + fun provideExportJsonInteractor( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + ) = ExportJsonInteractor( + accountRepository, + chainRegistry + ) + + @Provides + @FeatureScope + fun provideExportMnemonicInteractor( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + secretStoreV2: SecretStoreV2, + ) = ExportMnemonicInteractor( + accountRepository, + secretStoreV2, + chainRegistry + ) + + @Provides + @FeatureScope + fun provideExportSeedInteractor( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + secretStoreV2: SecretStoreV2, + ) = ExportPrivateKeyInteractor( + accountRepository, + secretStoreV2, + chainRegistry + ) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/ExternalAccountsDiscoveryModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/ExternalAccountsDiscoveryModule.kt new file mode 100644 index 0000000..76a4929 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/ExternalAccountsDiscoveryModule.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_account_impl.di.modules + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_impl.data.proxy.network.api.FindProxiesApi +import io.novafoundation.nova.feature_account_impl.data.proxy.repository.MultiChainProxyRepository +import io.novafoundation.nova.feature_account_impl.data.proxy.repository.RealMultiChainProxyRepository +import io.novafoundation.nova.feature_account_impl.data.sync.ExternalAccountsSyncDataSource +import io.novafoundation.nova.feature_account_impl.data.sync.MultisigAccountsSyncDataSourceFactory +import io.novafoundation.nova.feature_account_impl.data.sync.ProxyAccountsSyncDataSourceFactory +import io.novafoundation.nova.feature_account_impl.data.sync.RealExternalAccountsSyncService +import io.novafoundation.nova.feature_account_impl.di.modules.ExternalAccountsDiscoveryModule.BindsModule + +@Module(includes = [BindsModule::class]) +class ExternalAccountsDiscoveryModule { + + @Module + internal interface BindsModule { + + @Binds + @IntoSet + fun bindProxySyncSourceFactoryToSet(real: ProxyAccountsSyncDataSourceFactory): ExternalAccountsSyncDataSource.Factory + + @Binds + @IntoSet + fun bindMultisigSyncSourceFactoryToSet(real: MultisigAccountsSyncDataSourceFactory): ExternalAccountsSyncDataSource.Factory + + @Binds + fun bindsExternalSyncService(real: RealExternalAccountsSyncService): ExternalAccountsSyncService + + @Binds + fun bindMultiChainProxyRepository(real: RealMultiChainProxyRepository): MultiChainProxyRepository + } + + @Provides + @FeatureScope + fun provideMultiChainProxyApi(apiCreator: NetworkApiCreator): FindProxiesApi { + return apiCreator.create(FindProxiesApi::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/IdentityProviderModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/IdentityProviderModule.kt new file mode 100644 index 0000000..1687f7f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/IdentityProviderModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalWithOnChainIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.OnChainIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.oneOf +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_impl.domain.account.identity.LocalIdentityProvider +import io.novafoundation.nova.feature_account_impl.domain.account.identity.OnChainIdentityProvider +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class IdentityProviderModule { + + @Provides + @LocalIdentity + fun provideLocalIdentityProvider( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry + ): IdentityProvider { + return LocalIdentityProvider(accountRepository, chainRegistry) + } + + @Provides + @OnChainIdentity + fun provideOnChainIdentityProvider( + onChainIdentityRepository: OnChainIdentityRepository + ): IdentityProvider { + return OnChainIdentityProvider(onChainIdentityRepository) + } + + @Provides + @LocalWithOnChainIdentity + fun provideLocalWithOnChainIdentityProvider( + @LocalIdentity localIdentityProvider: IdentityProvider, + @OnChainIdentity onChainIdentityProvider: IdentityProvider + ): IdentityProvider { + return IdentityProvider.oneOf(localIdentityProvider, onChainIdentityProvider) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/MultisigModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/MultisigModule.kt new file mode 100644 index 0000000..4d4f2a6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/MultisigModule.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_account_impl.di.modules + +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.feature_account_impl.data.multisig.RealMultisigRepository +import io.novafoundation.nova.feature_account_impl.data.multisig.api.FindMultisigsApi +import io.novafoundation.nova.feature_account_impl.di.modules.MultisigModule.BindsModule +import io.novafoundation.nova.feature_account_impl.domain.multisig.RealMultisigPendingOperationsService + +@Module(includes = [BindsModule::class]) +class MultisigModule { + + @Module + internal interface BindsModule { + + @Binds + fun bindMultisigSyncRepository(real: RealMultisigRepository): MultisigRepository + + @Binds + fun bindMultisigPendingOperationsService(real: RealMultisigPendingOperationsService): MultisigPendingOperationsService + } + + @Provides + @FeatureScope + fun provideFindMultisigsApi(apiCreator: NetworkApiCreator): FindMultisigsApi { + return apiCreator.create(FindMultisigsApi::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/ParitySignerModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/ParitySignerModule.kt new file mode 100644 index 0000000..6b65e50 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/ParitySignerModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_account_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.SharedState +import io.novafoundation.nova.feature_account_api.data.signer.SeparateFlowSignerState +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.common.QrCodeExpiredPresentableFactory + +@Module +class ParitySignerModule { + + @Provides + @FeatureScope + fun provideReadOnlySharedState( + mutableSharedState: SigningSharedState + ): SharedState = mutableSharedState + + @Provides + @FeatureScope + fun provideQrCodeExpiredPresentableFactory( + resourceManager: ResourceManager, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + router: AccountRouter, + communicator: PolkadotVaultVariantSignCommunicator + ) = QrCodeExpiredPresentableFactory(resourceManager, actionAwaitableMixinFactory, router, communicator) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/WatchOnlyModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/WatchOnlyModule.kt new file mode 100644 index 0000000..ac7f5a7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/WatchOnlyModule.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter +import io.novafoundation.nova.feature_account_impl.data.repository.RealWatchOnlyRepository +import io.novafoundation.nova.feature_account_impl.data.repository.WatchOnlyRepository +import io.novafoundation.nova.feature_account_impl.presentation.watchOnly.sign.RealWatchOnlyMissingKeysPresenter + +@Module +class WatchOnlyModule { + + @Provides + @FeatureScope + fun provideWatchOnlySigningPresenter( + contextManager: ContextManager + ): WatchOnlyMissingKeysPresenter = RealWatchOnlyMissingKeysPresenter(contextManager) + + @Provides + @FeatureScope + fun provideWatchOnlyRepository( + accountDao: MetaAccountDao + ): WatchOnlyRepository = RealWatchOnlyRepository(accountDao) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/deeplinks/DeepLinkModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/deeplinks/DeepLinkModule.kt new file mode 100644 index 0000000..1d16bb0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/deeplinks/DeepLinkModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.di.modules.deeplinks + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_account_api.di.deeplinks.AccountDeepLinks +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.importing.deeplink.ImportMnemonicDeepLinkHandler + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideImportMnemonicDeepLinkHandler( + router: AccountRouter, + encryptionDefaults: EncryptionDefaults, + accountRepository: AccountRepository, + automaticInteractionGate: AutomaticInteractionGate + ) = ImportMnemonicDeepLinkHandler( + router, + encryptionDefaults, + accountRepository, + automaticInteractionGate + ) + + @Provides + @FeatureScope + fun provideDeepLinks(importMnemonic: ImportMnemonicDeepLinkHandler): AccountDeepLinks { + return AccountDeepLinks(listOf(importMnemonic)) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/signers/MultisigSignerModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/signers/MultisigSignerModule.kt new file mode 100644 index 0000000..db12ff9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/signers/MultisigSignerModule.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_impl.di.modules.signers + +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_impl.data.multisig.repository.RealMultisigValidationsRepository +import io.novafoundation.nova.feature_account_impl.presentation.multisig.MultisigSigningPresenter +import io.novafoundation.nova.feature_account_impl.presentation.multisig.RealMultisigSigningPresenter + +@Module(includes = [MultisigSignerModule.BindsModule::class]) +class MultisigSignerModule { + + @Module + interface BindsModule { + + @Binds + fun bindMultisigDepositRepository(real: RealMultisigValidationsRepository): MultisigValidationsRepository + + @Binds + fun bindMultisigPresenter(real: RealMultisigSigningPresenter): MultisigSigningPresenter + } + + @Provides + @FeatureScope + fun provideRequestBus() = MultisigExtrinsicValidationRequestBus() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/signers/ProxiedSignerModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/signers/ProxiedSignerModule.kt new file mode 100644 index 0000000..73d8ce1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/signers/ProxiedSignerModule.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_impl.di.modules.signers + +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.proxy.validation.ProxyExtrinsicValidationRequestBus +import io.novafoundation.nova.feature_account_api.presenatation.account.proxy.ProxySigningPresenter +import io.novafoundation.nova.feature_account_impl.data.signer.proxy.ProxyCallFilterFactory +import io.novafoundation.nova.feature_account_impl.di.modules.signers.ProxiedSignerModule.BindsModule +import io.novafoundation.nova.feature_account_impl.presentation.proxy.sign.RealProxySigningPresenter + +@Module(includes = [BindsModule::class]) +class ProxiedSignerModule { + + @Module + interface BindsModule { + + @Binds + fun bindProxySigningPresenter(real: RealProxySigningPresenter): ProxySigningPresenter + } + + @Provides + @FeatureScope + fun provideProxyExtrinsicValidationRequestBus() = ProxyExtrinsicValidationRequestBus() + + @Provides + @FeatureScope + fun provideProxyCallFilterFactory() = ProxyCallFilterFactory() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/signers/SignersModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/signers/SignersModule.kt new file mode 100644 index 0000000..9d19744 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/modules/signers/SignersModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.di.modules.signers + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.DefaultMutableSharedState +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_impl.data.signer.RealSignerProvider +import io.novafoundation.nova.feature_account_impl.data.signer.ledger.LedgerSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.multisig.MultisigSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.proxy.ProxiedSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.secrets.SecretsSignerFactory +import io.novafoundation.nova.feature_account_impl.data.signer.watchOnly.WatchOnlySignerFactory + +@Module(includes = [ProxiedSignerModule::class, MultisigSignerModule::class]) +class SignersModule { + + @Provides + @FeatureScope + fun provideSignSharedState(): SigningSharedState = DefaultMutableSharedState() + + @Provides + @FeatureScope + fun provideSignerProvider( + secretsSignerFactory: SecretsSignerFactory, + proxiedSignerFactory: ProxiedSignerFactory, + watchOnlySignerFactory: WatchOnlySignerFactory, + polkadotVaultSignerFactory: PolkadotVaultVariantSignerFactory, + ledgerSignerFactory: LedgerSignerFactory, + multisigSignerFactory: MultisigSignerFactory, + ): SignerProvider = RealSignerProvider( + secretsSignerFactory = secretsSignerFactory, + watchOnlySigner = watchOnlySignerFactory, + polkadotVaultSignerFactory = polkadotVaultSignerFactory, + proxiedSignerFactory = proxiedSignerFactory, + ledgerSignerFactory = ledgerSignerFactory, + multisigSignerFactory = multisigSignerFactory + ) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/AccountInteractorImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/AccountInteractorImpl.kt new file mode 100644 index 0000000..0647191 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/AccountInteractorImpl.kt @@ -0,0 +1,238 @@ +package io.novafoundation.nova.feature_account_impl.domain + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core.model.Language +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.Account +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountOrdering +import io.novafoundation.nova.feature_account_api.domain.model.PreferredCryptoType +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_impl.domain.errors.NodeAlreadyExistsException +import io.novafoundation.nova.feature_account_impl.domain.errors.UnsupportedNetworkException +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainFlow +import io.novasama.substrate_sdk_android.encrypt.mnemonic.Mnemonic +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +class AccountInteractorImpl( + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, +) : AccountInteractor { + + override suspend fun getActiveMetaAccounts(): List { + return accountRepository.getActiveMetaAccounts() + } + + override suspend fun generateMnemonic(): Mnemonic { + return accountRepository.generateMnemonic() + } + + override fun getCryptoTypes(): List { + return accountRepository.getEncryptionTypes() + } + + override suspend fun getPreferredCryptoType(chainId: ChainId?): PreferredCryptoType = withContext(Dispatchers.Default) { + if (chainId != null && chainRegistry.getChain(chainId).isEthereumBased) { + PreferredCryptoType(CryptoType.ECDSA, frozen = true) + } else { + PreferredCryptoType(CryptoType.SR25519, frozen = false) + } + } + + override suspend fun isCodeSet(): Boolean { + return accountRepository.isCodeSet() + } + + override suspend fun savePin(code: String) { + return accountRepository.savePinCode(code) + } + + override suspend fun isPinCorrect(code: String): Boolean { + val pinCode = accountRepository.getPinCode() + + return pinCode == code + } + + override suspend fun getMetaAccount(metaId: Long): MetaAccount { + return accountRepository.getMetaAccount(metaId) + } + + override suspend fun selectMetaAccount(metaId: Long) { + accountRepository.selectMetaAccount(metaId) + } + + override suspend fun selectedMetaAccount(): MetaAccount { + return accountRepository.getSelectedMetaAccount() + } + + /** + * return true if all accounts was deleted + */ + override suspend fun deleteAccount(metaId: Long) = withContext(Dispatchers.Default) { + accountRepository.deleteAccount(metaId) + if (!accountRepository.isAccountSelected()) { + val metaAccounts = getActiveMetaAccounts() + if (metaAccounts.isNotEmpty()) { + accountRepository.selectMetaAccount(metaAccounts.first().id) + } + metaAccounts.isEmpty() + } else { + false + } + } + + override suspend fun updateMetaAccountPositions(idsInNewOrder: List) = with(Dispatchers.Default) { + val ordering = idsInNewOrder.mapIndexed { index, id -> + MetaAccountOrdering(id, index) + } + + accountRepository.updateAccountsOrdering(ordering) + } + + override fun chainFlow(chainId: ChainId): Flow { + return chainRegistry.chainFlow(chainId) + } + + override fun nodesFlow(): Flow> { + return accountRepository.nodesFlow() + } + + override suspend fun getNode(nodeId: Int): Node { + return accountRepository.getNode(nodeId) + } + + override fun getLanguages(): List { + return accountRepository.getLanguages() + } + + override suspend fun getSelectedLanguage(): Language { + return accountRepository.selectedLanguage() + } + + override suspend fun changeSelectedLanguage(language: Language) { + return accountRepository.changeLanguage(language) + } + + override suspend fun addNode(nodeName: String, nodeHost: String): Result { + return ensureUniqueNode(nodeHost) { + val networkType = getNetworkTypeByNodeHost(nodeHost) + + accountRepository.addNode(nodeName, nodeHost, networkType) + } + } + + override suspend fun updateNode(nodeId: Int, newName: String, newHost: String): Result { + return ensureUniqueNode(newHost) { + val networkType = getNetworkTypeByNodeHost(newHost) + + accountRepository.updateNode(nodeId, newName, newHost, networkType) + } + } + + private suspend fun ensureUniqueNode(nodeHost: String, action: suspend () -> Unit): Result { + val nodeExists = accountRepository.checkNodeExists(nodeHost) + + return runCatching { + if (nodeExists) { + throw NodeAlreadyExistsException() + } else { + action() + } + } + } + + /** + * @throws UnsupportedNetworkException, if node network is not supported + * @throws NovaException - in case of network issues + */ + private suspend fun getNetworkTypeByNodeHost(nodeHost: String): Node.NetworkType { + val networkName = accountRepository.getNetworkName(nodeHost) + + val supportedNetworks = Node.NetworkType.values() + val networkType = supportedNetworks.firstOrNull { networkName == it.readableName } + + return networkType ?: throw UnsupportedNetworkException() + } + + override suspend fun getAccountsByNetworkTypeWithSelectedNode(networkType: Node.NetworkType): Pair, Node> { + val accounts = accountRepository.getAccountsByNetworkType(networkType) + val node = accountRepository.getSelectedNodeOrDefault() + return Pair(accounts, node) + } + + override suspend fun selectNodeAndAccount(nodeId: Int, accountAddress: String) { + val account = accountRepository.getAccount(accountAddress) + val node = accountRepository.getNode(nodeId) + + accountRepository.selectAccount(account, newNode = node) + } + + override suspend fun selectNode(nodeId: Int) { + val node = accountRepository.getNode(nodeId) + + accountRepository.selectNode(node) + } + + override suspend fun deleteNode(nodeId: Int) { + return accountRepository.deleteNode(nodeId) + } + + override suspend fun getChainAddress(metaId: Long, chainId: ChainId): String? { + val metaAccount = getMetaAccount(metaId) + val chain = chainRegistry.getChain(chainId) + return metaAccount.addressIn(chain) + } + + override suspend fun removeDeactivatedMetaAccounts() { + accountRepository.removeDeactivatedMetaAccounts() + + switchMetaAccountIfAccountNotSelected() + } + + override suspend fun switchToNotDeactivatedAccountIfNeeded() { + val metaAccount = accountRepository.getSelectedMetaAccount() + if (metaAccount.status != LightMetaAccount.Status.DEACTIVATED) return + + val metaAccounts = accountRepository.getActiveMetaAccounts() + if (metaAccounts.isNotEmpty()) { + accountRepository.selectMetaAccount(metaAccounts.first().id) + } + } + + override suspend fun hasSecretsAccounts(): Boolean { + return accountRepository.hasSecretsAccounts() + } + + override suspend fun hasCustomChainAccounts(metaId: Long): Boolean { + val metaAccount = getMetaAccount(metaId) + return metaAccount.chainAccounts.isNotEmpty() + } + + override suspend fun deleteProxiedMetaAccountsByChain(chainId: String) { + accountRepository.deleteProxiedMetaAccountsByChain(chainId) + + switchMetaAccountIfAccountNotSelected() + } + + override suspend fun findMetaAccount(chain: Chain, value: AccountId): MetaAccount? { + return accountRepository.findMetaAccount(value, chain.id) + } + + private suspend fun switchMetaAccountIfAccountNotSelected() { + if (!accountRepository.isAccountSelected()) { + val metaAccounts = getActiveMetaAccounts() + if (metaAccounts.isNotEmpty()) { + accountRepository.selectMetaAccount(metaAccounts.first().id) + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/MetaAccountGroupingInteractorImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/MetaAccountGroupingInteractorImpl.kt new file mode 100644 index 0000000..454ebdf --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/MetaAccountGroupingInteractorImpl.kt @@ -0,0 +1,204 @@ +package io.novafoundation.nova.feature_account_impl.domain + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.common.utils.Filter +import io.novafoundation.nova.common.utils.amountFromPlanks +import io.novafoundation.nova.common.utils.applyFilter +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.sumByBigDecimal +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.model.AccountDelegation +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountAssetBalance +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountListingItem +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.metaAccountTypeComparator +import io.novafoundation.nova.feature_account_api.domain.model.singleChainId +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull + +class MetaAccountGroupingInteractorImpl( + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val currencyRepository: CurrencyRepository, + private val metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry, +) : MetaAccountGroupingInteractor { + + override fun metaAccountsWithTotalBalanceFlow(): Flow> { + return combine( + currencyRepository.observeSelectCurrency(), + accountRepository.activeMetaAccountsFlow(), + accountRepository.metaAccountBalancesFlow(), + metaAccountsUpdatesRegistry.observeUpdates(), + chainRegistry.chainsById + ) { selectedCurrency, accounts, allBalances, updatedMetaAccounts, chains -> + val groupedBalances = allBalances.groupBy(MetaAccountAssetBalance::metaId) + + accounts.mapNotNull { metaAccount -> + val accountBalances = groupedBalances[metaAccount.id] ?: emptyList() + val hasUpdates = updatedMetaAccounts.contains(metaAccount.id) + metaAccountWithTotalBalance(accountBalances, metaAccount, accounts, selectedCurrency, chains, hasUpdates) + } + .groupBy { it.metaAccount.type } + .toSortedMap(metaAccountTypeComparator()) + } + } + + override fun metaAccountWithTotalBalanceFlow(metaId: Long): Flow { + return combine( + currencyRepository.observeSelectCurrency(), + accountRepository.activeMetaAccountsFlow(), + accountRepository.metaAccountFlow(metaId), + accountRepository.metaAccountBalancesFlow(metaId), + chainRegistry.chainsById + ) { selectedCurrency, allMetaAccounts, metaAccount, metaAccountBalances, chains -> + metaAccountWithTotalBalance(metaAccountBalances, metaAccount, allMetaAccounts, selectedCurrency, chains, false) + }.filterNotNull() + } + + override fun getMetaAccountsWithFilter( + metaAccountFilter: Filter + ): Flow> = flowOf { + getValidMetaAccountsForTransaction(metaAccountFilter) + .groupBy(MetaAccount::type) + .toSortedMap(metaAccountTypeComparator()) + } + + override fun updatedDelegates(): Flow> { + return combine( + metaAccountsUpdatesRegistry.observeUpdates(), + accountRepository.allMetaAccountsFlow(), + chainRegistry.chainsById + ) { updatedMetaIds, metaAccounts, chainsById -> + val metaById = metaAccounts.associateBy(MetaAccount::id) + + metaAccounts + .filter { updatedMetaIds.contains(it.id) } + .mapNotNull { + when (it) { + is ProxiedMetaAccount -> AccountDelegation.Proxy( + proxied = it, + proxy = metaById[it.proxy.proxyMetaId] ?: return@mapNotNull null, + chain = chainsById[it.proxy.chainId] ?: return@mapNotNull null + ) + + is MultisigMetaAccount -> { + val singleChainId = it.availability.singleChainId() + val singleChain = singleChainId?.let { chainsById[it] ?: return@mapNotNull null } + + AccountDelegation.Multisig( + metaAccount = it, + signatory = metaById[it.signatoryMetaId] ?: return@mapNotNull null, + singleChain = singleChain + ) + } + + else -> null + } + } + .groupBy { it.delegator.status } + .toSortedMap(metaAccountStateComparator()) + } + } + + override suspend fun hasAvailableMetaAccountsForChain( + chainId: ChainId, + metaAccountFilter: Filter + ): Boolean { + val chain = chainRegistry.getChain(chainId) + return getValidMetaAccountsForTransaction(metaAccountFilter) + .any { it.hasAccountIn(chain) } + } + + private fun metaAccountWithTotalBalance( + metaAccountBalances: List, + metaAccount: MetaAccount, + allMetaAccounts: List, + selectedCurrency: Currency, + chains: Map, + hasUpdates: Boolean + ): MetaAccountListingItem? { + val totalBalance = metaAccountBalances.sumByBigDecimal { + val totalInPlanks = it.freeInPlanks + it.reservedInPlanks + it.offChainBalance.orZero() + + totalInPlanks.amountFromPlanks(it.precision) * it.rate.orZero() + } + + return when (metaAccount) { + is ProxiedMetaAccount -> { + val proxyMetaAccount = allMetaAccounts.firstOrNull { it.id == metaAccount.proxy.proxyMetaId } ?: return null + val proxyChain = metaAccount.proxy.chainId.let(chains::get) ?: return null + + MetaAccountListingItem.Proxied( + proxyMetaAccount = proxyMetaAccount, + proxyChain = proxyChain, + metaAccount = metaAccount, + hasUpdates = hasUpdates, + totalBalance = totalBalance, + currency = selectedCurrency + ) + } + + is MultisigMetaAccount -> { + val signatoryMetaAccount = allMetaAccounts.firstOrNull { it.id == metaAccount.signatoryMetaId } ?: return null + + val singleChainId = metaAccount.availability.singleChainId() + val singleChain = singleChainId?.let { chains[it] ?: return null } + + MetaAccountListingItem.Multisig( + signatory = signatoryMetaAccount, + metaAccount = metaAccount, + hasUpdates = hasUpdates, + totalBalance = totalBalance, + currency = selectedCurrency, + singleChain = singleChain + ) + } + + else -> { + MetaAccountListingItem.TotalBalance( + totalBalance = totalBalance, + currency = selectedCurrency, + metaAccount = metaAccount, + hasUpdates = hasUpdates + ) + } + } + } + + private suspend fun getValidMetaAccountsForTransaction(metaAccountFilter: Filter): List { + return accountRepository.getActiveMetaAccounts() + .applyFilter(metaAccountFilter) + .filter { + when (it.type) { + LightMetaAccount.Type.SECRETS, + LightMetaAccount.Type.POLKADOT_VAULT, + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.PROXIED, + LightMetaAccount.Type.MULTISIG, + LightMetaAccount.Type.LEDGER, + LightMetaAccount.Type.LEDGER_LEGACY -> true + + LightMetaAccount.Type.WATCH_ONLY -> false + } + } + } + + private fun metaAccountStateComparator() = compareBy { + when (it) { + LightMetaAccount.Status.ACTIVE -> 0 + LightMetaAccount.Status.DEACTIVATED -> 1 + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/NodeHostValidator.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/NodeHostValidator.kt new file mode 100644 index 0000000..a5d9b78 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/NodeHostValidator.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.domain + +import java.util.regex.Pattern + +class NodeHostValidator { + + private val regular = "^" + + // protocol identifier (optional) + // short syntax // still required + "(?:(?:(?:wss?):)?\\/\\/)" + + // user:pass BasicAuth (optional) + "(?:\\S+(?::\\S*)?@)?" + + "(?:" + + // IP address exclusion + // private & local networks + "(?!(?:10|127)(?:\\.\\d{1,3}){3})" + + "(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" + + "(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" + + // IP address dotted notation octets + // excludes loopback network 0.0.0.0 + // excludes reserved space >= 224.0.0.0 + // excludes network & broadcast addresses + // (first & last IP address of each class) + "(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" + + "(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" + + "(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" + + "|" + + // host & domain names, may end with dot + // can be replaced by a shortest alternative + // (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+ + "(?:" + + "(?:" + + "[a-z0-9\\u00a1-\\uffff]" + + "[a-z0-9\\u00a1-\\uffff_-]{0,62}" + + ")?" + + "[a-z0-9\\u00a1-\\uffff]\\." + + ")+" + + // TLD identifier name, may end with dot + "(?:[a-z\\u00a1-\\uffff]{2,}\\.?)" + + ")" + + // port number (optional) + "(?::\\d{2,5})?" + + // resource path (optional) + "(?:[/?#]\\S*)?" + + "$" + + fun hostIsValid(host: String): Boolean { + return Pattern.matches(regular, host) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/RealCreateGiftMetaAccountUseCase.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/RealCreateGiftMetaAccountUseCase.kt new file mode 100644 index 0000000..862c154 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/RealCreateGiftMetaAccountUseCase.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.domain + +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.keypair +import io.novafoundation.nova.common.data.secrets.v2.publicKey +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.account.common.forChain +import io.novafoundation.nova.feature_account_api.domain.interfaces.CreateGiftMetaAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_impl.domain.account.model.RealSecretsMetaAccount +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import java.util.UUID +import kotlin.Long + +class RealCreateGiftMetaAccountUseCase( + private val encryptionDefaults: EncryptionDefaults +) : CreateGiftMetaAccountUseCase { + + override fun createTemporaryGiftMetaAccount(chain: Chain, chainSecrets: EncodableStruct): MetaAccount { + val publicKey = chainSecrets.keypair.publicKey + val chainAccount = MetaAccount.ChainAccount( + metaId = Long.MAX_VALUE, + chainId = chain.id, + publicKey = publicKey, + accountId = chain.accountIdOf(publicKey), + cryptoType = encryptionDefaults.forChain(chain).cryptoType, + ) + + return RealSecretsMetaAccount( + id = Long.MAX_VALUE, + globallyUniqueId = UUID.randomUUID().toString(), + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = null, + ethereumAddress = null, + ethereumPublicKey = null, + isSelected = false, + name = "Temporary Meta Account", + status = LightMetaAccount.Status.ACTIVE, + chainAccounts = mapOf(chain.id to chainAccount), + parentMetaId = null, + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/add/AddAccountInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/add/AddAccountInteractor.kt new file mode 100644 index 0000000..e63e857 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/add/AddAccountInteractor.kt @@ -0,0 +1,149 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.add + +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.addAccountWithSingleChange +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_api.domain.model.ImportJsonMetaData +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SubstrateKeypairAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.JsonAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SeedAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.TrustWalletAddAccountRepository +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.utils.isSubstrateKeypair +import io.novafoundation.nova.feature_account_impl.domain.utils.isSubstrateSeed +import io.novasama.substrate_sdk_android.extensions.fromHex +import java.util.InvalidPropertiesFormatException + +class AddAccountInteractor( + private val mnemonicAddAccountRepository: MnemonicAddAccountRepository, + private val jsonAddAccountRepository: JsonAddAccountRepository, + private val seedAddAccountRepository: SeedAddAccountRepository, + private val substrateKeypairAddAccountRepository: SubstrateKeypairAddAccountRepository, + private val trustWalletAddAccountRepository: TrustWalletAddAccountRepository, + private val accountRepository: AccountRepository, + private val advancedEncryptionInteractor: AdvancedEncryptionInteractor +) { + + suspend fun createMetaAccountWithRecommendedSettings(addAccountType: AddAccountType): Result { + val mnemonic = accountRepository.generateMnemonic() + val advancedEncryption = advancedEncryptionInteractor.getRecommendedAdvancedEncryption() + return createAccount(mnemonic.words, advancedEncryption, addAccountType) + } + + suspend fun createAccount( + mnemonic: String, + advancedEncryption: AdvancedEncryption, + addAccountType: AddAccountType + ): Result { + return addAccount( + addAccountType, + mnemonicAddAccountRepository, + MnemonicAddAccountRepository.Payload( + mnemonic, + advancedEncryption, + addAccountType + ) + ) + } + + suspend fun importFromMnemonic( + mnemonic: String, + advancedEncryption: AdvancedEncryption, + addAccountType: AddAccountType + ): Result { + return createAccount(mnemonic, advancedEncryption, addAccountType) + } + + suspend fun importFromSecret( + secret: String, + advancedEncryption: AdvancedEncryption, + addAccountType: AddAccountType + ): Result = runCatching { + when { + secret.isSubstrateSeed() -> importFromSeed(secret, advancedEncryption, addAccountType).getOrThrow() + + secret.isSubstrateKeypair() -> importFromSubstrateKeypair(secret, advancedEncryption, addAccountType).getOrThrow() + + else -> throw InvalidPropertiesFormatException("Invalid secret length: Expected 32 or 64 bytes.") + } + } + + suspend fun importFromSeed( + seed: String, + advancedEncryption: AdvancedEncryption, + addAccountType: AddAccountType + ): Result { + return addAccount( + addAccountType, + seedAddAccountRepository, + SeedAddAccountRepository.Payload( + seed, + advancedEncryption, + addAccountType + ) + ) + } + + suspend fun importFromSubstrateKeypair( + keypair: String, + advancedEncryption: AdvancedEncryption, + addAccountType: AddAccountType + ): Result { + return addAccount( + addAccountType, + substrateKeypairAddAccountRepository, + SubstrateKeypairAddAccountRepository.Payload( + keypair.fromHex(), + advancedEncryption, + addAccountType + ) + ) + } + + suspend fun importFromJson( + json: String, + password: String, + addAccountType: AddAccountType + ): Result { + return addAccount( + addAccountType, + jsonAddAccountRepository, + JsonAddAccountRepository.Payload( + json = json, + password = password, + addAccountType = addAccountType + ) + ) + } + + suspend fun importFromTrustWallet(mnemonic: String, addAccountType: AddAccountType.MetaAccount): Result { + return addAccount( + addAccountType = addAccountType, + addAccountRepository = trustWalletAddAccountRepository, + payload = TrustWalletAddAccountRepository.Payload(mnemonic, addAccountType) + ) + } + + suspend fun extractJsonMetadata(json: String): Result { + return runCatching { + jsonAddAccountRepository.extractJsonMetadata(json) + } + } + + private suspend inline fun addAccount( + addAccountType: AddAccountType, + addAccountRepository: AddAccountRepository, + payload: T + ): Result { + return runCatching { + val result = addAccountRepository.addAccountWithSingleChange(payload) + + if (addAccountType is AddAccountType.MetaAccount) { + accountRepository.selectMetaAccount(result.metaId) + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/AdvancedEncryptionInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/AdvancedEncryptionInteractor.kt new file mode 100644 index 0000000..7387f7d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/AdvancedEncryptionInteractor.kt @@ -0,0 +1,142 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption + +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.derivationPath +import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath +import io.novafoundation.nova.common.utils.fold +import io.novafoundation.nova.common.utils.input.Input +import io.novafoundation.nova.common.utils.input.disabledInput +import io.novafoundation.nova.common.utils.input.modifiableInput +import io.novafoundation.nova.common.utils.input.unmodifiableInput +import io.novafoundation.nova.common.utils.nullIfEmpty +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.data.secrets.getAccountSecrets +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryptionInput +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.recommended +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.chainAccountFor +import io.novafoundation.nova.feature_account_api.presenatation.account.add.chainIdOrNull +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionModePayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +private typealias DerivationPathModifier = String?.() -> Input + +class AdvancedEncryptionInteractor( + private val accountRepository: AccountRepository, + private val secretStoreV2: SecretStoreV2, + private val chainRegistry: ChainRegistry, + private val encryptionDefaults: EncryptionDefaults +) { + + fun getCryptoTypes(): List { + return accountRepository.getEncryptionTypes() + } + + fun getRecommendedAdvancedEncryption(): AdvancedEncryption { + return encryptionDefaults.recommended() + } + + suspend fun getInitialInputState(payload: AdvancedEncryptionModePayload): AdvancedEncryptionInput { + return when (payload) { + is AdvancedEncryptionModePayload.Change -> getChangeInitialInputState(payload.addAccountPayload.chainIdOrNull) + is AdvancedEncryptionModePayload.View -> getViewInitialInputState(payload.metaAccountId, payload.chainId, payload.hideDerivationPaths) + } + } + + private suspend fun getViewInitialInputState( + metaAccountId: Long, + chainId: ChainId, + hideDerivationPaths: Boolean + ): AdvancedEncryptionInput { + val chain = chainRegistry.getChain(chainId) + val metaAccount = accountRepository.getMetaAccount(metaAccountId) + + val accountSecrets = secretStoreV2.getAccountSecrets(metaAccount, chain) + + val derivationPathModifier = derivationPathModifier(hideDerivationPaths) + + return accountSecrets.fold( + left = { metaAccountSecrets -> + if (chain.isEthereumBased) { + AdvancedEncryptionInput( + substrateCryptoType = disabledInput(), + substrateDerivationPath = disabledInput(), + ethereumCryptoType = encryptionDefaults.ethereumCryptoType.asReadOnlyInput(), + ethereumDerivationPath = metaAccountSecrets.ethereumDerivationPath.derivationPathModifier() + ) + } else { + AdvancedEncryptionInput( + substrateCryptoType = metaAccount.substrateCryptoType.asReadOnlyInput(), + substrateDerivationPath = metaAccountSecrets.substrateDerivationPath.derivationPathModifier(), + ethereumCryptoType = disabledInput(), + ethereumDerivationPath = disabledInput() + ) + } + }, + right = { chainAccountSecrets -> + if (chain.isEthereumBased) { + AdvancedEncryptionInput( + substrateCryptoType = disabledInput(), + substrateDerivationPath = disabledInput(), + ethereumCryptoType = encryptionDefaults.ethereumCryptoType.asReadOnlyInput(), + ethereumDerivationPath = chainAccountSecrets.derivationPath.derivationPathModifier() + ) + } else { + val chainAccount = metaAccount.chainAccountFor(chainId) + + AdvancedEncryptionInput( + substrateCryptoType = chainAccount.cryptoType.asReadOnlyInput(), + substrateDerivationPath = chainAccountSecrets.derivationPath.derivationPathModifier(), + ethereumCryptoType = disabledInput(), + ethereumDerivationPath = disabledInput() + ) + } + } + ) + } + + private suspend fun getChangeInitialInputState(chainId: ChainId?) = if (chainId != null) { + val chain = chainRegistry.getChain(chainId) + + if (chain.isEthereumBased) { // Ethereum Chain Account + AdvancedEncryptionInput( + substrateCryptoType = disabledInput(), + substrateDerivationPath = disabledInput(), + ethereumCryptoType = encryptionDefaults.ethereumCryptoType.unmodifiableInput(), + ethereumDerivationPath = encryptionDefaults.ethereumDerivationPath.modifiableInput() + ) + } else { // Substrate Chain Account + AdvancedEncryptionInput( + substrateCryptoType = encryptionDefaults.substrateCryptoType.modifiableInput(), + substrateDerivationPath = encryptionDefaults.substrateDerivationPath.modifiableInput(), + ethereumCryptoType = disabledInput(), + ethereumDerivationPath = disabledInput() + ) + } + } else { // MetaAccount + AdvancedEncryptionInput( + substrateCryptoType = encryptionDefaults.substrateCryptoType.modifiableInput(), + substrateDerivationPath = encryptionDefaults.substrateDerivationPath.modifiableInput(), + ethereumCryptoType = encryptionDefaults.ethereumCryptoType.unmodifiableInput(), + ethereumDerivationPath = encryptionDefaults.ethereumDerivationPath.modifiableInput() + ) + } + + private fun Input.hideIf(condition: Boolean) = if (condition) { + Input.Disabled + } else { + this + } + + private fun derivationPathModifier(hideDerivationPaths: Boolean): DerivationPathModifier = { + asReadOnlyInput().hideIf(hideDerivationPaths) + } + + private fun T?.asReadOnlyInput() = this?.unmodifiableInput() ?: disabledInput() + + private fun String?.asReadOnlyStringInput() = this?.nullIfEmpty().asReadOnlyInput() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/AdvancedEncryptionValidationFailure.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/AdvancedEncryptionValidationFailure.kt new file mode 100644 index 0000000..7e5f62f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/AdvancedEncryptionValidationFailure.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.AdvancedEncryptionValidationFailure.ETHEREUM_DERIVATION_PATH +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.AdvancedEncryptionValidationFailure.SUBSTRATE_DERIVATION_PATH + +enum class AdvancedEncryptionValidationFailure { + ETHEREUM_DERIVATION_PATH, SUBSTRATE_DERIVATION_PATH +} + +fun mapAdvancedEncryptionValidationFailureToUi( + resourceManager: ResourceManager, + failure: AdvancedEncryptionValidationFailure, +): TitleAndMessage { + return when (failure) { + SUBSTRATE_DERIVATION_PATH -> resourceManager.getString(R.string.account_derivation_path_substrate_invalid_title) to + resourceManager.getString(R.string.account_invalid_derivation_path_message_v2_2_0) + + ETHEREUM_DERIVATION_PATH -> resourceManager.getString(R.string.account_derivation_path_ethereum_invalid_title) to + resourceManager.getString(R.string.account_invalid_derivation_path_message_v2_2_0) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/AdvancedEncryptionValidationPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/AdvancedEncryptionValidationPayload.kt new file mode 100644 index 0000000..22a550f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/AdvancedEncryptionValidationPayload.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion + +import io.novafoundation.nova.common.utils.input.Input + +class AdvancedEncryptionValidationPayload( + val substrateDerivationPathInput: Input, + val ethereumDerivationPathInput: Input, +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/AdvancedEncryptionValidationSystem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/AdvancedEncryptionValidationSystem.kt new file mode 100644 index 0000000..877fe6d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/AdvancedEncryptionValidationSystem.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem + +typealias AdvancedEncryptionValidationSystem = ValidationSystem +typealias AdvancedEncryptionValidation = Validation diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/DerivationPathValidation.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/DerivationPathValidation.kt new file mode 100644 index 0000000..c489759 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/advancedEncryption/valiadtion/DerivationPathValidation.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion + +import io.novafoundation.nova.common.utils.input.Input +import io.novafoundation.nova.common.utils.input.fold +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrError +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.junction.JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.junction.SubstrateJunctionDecoder + +sealed class DerivationPathValidation( + private val junctionDecoder: JunctionDecoder, + private val valueExtractor: (AdvancedEncryptionValidationPayload) -> Input, + private val failure: AdvancedEncryptionValidationFailure +) : AdvancedEncryptionValidation { + + override suspend fun validate(value: AdvancedEncryptionValidationPayload): ValidationStatus { + val isValid = valueExtractor(value) + .fold( + ifEnabled = { it.isEmpty() || checkCanDecode(it) }, + ifDisabled = true + ) + + return validOrError(isValid) { failure } + } + + private fun checkCanDecode(derivationPath: String): Boolean = runCatching { junctionDecoder.decode(derivationPath) }.isSuccess +} + +class SubstrateDerivationPathValidation : DerivationPathValidation( + junctionDecoder = SubstrateJunctionDecoder, + valueExtractor = AdvancedEncryptionValidationPayload::substrateDerivationPathInput, + failure = AdvancedEncryptionValidationFailure.SUBSTRATE_DERIVATION_PATH +) + +class EthereumDerivationPathValidation : DerivationPathValidation( + junctionDecoder = BIP32JunctionDecoder, + valueExtractor = AdvancedEncryptionValidationPayload::ethereumDerivationPathInput, + failure = AdvancedEncryptionValidationFailure.ETHEREUM_DERIVATION_PATH +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/cloudBackup/RealApplyLocalSnapshotToCloudBackupUseCase.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/cloudBackup/RealApplyLocalSnapshotToCloudBackupUseCase.kt new file mode 100644 index 0000000..1cb042e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/cloudBackup/RealApplyLocalSnapshotToCloudBackupUseCase.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.cloudBackup + +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.domain.cloudBackup.ApplyLocalSnapshotToCloudBackupUseCase +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.domain.fetchAndDecryptExistingBackupWithSavedPassword +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.isEmpty +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.localVsCloudDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategy +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CannotApplyNonDestructiveDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.setLastSyncedTimeAsNow +import io.novafoundation.nova.feature_cloud_backup_api.domain.writeCloudBackupWithSavedPassword + +class RealApplyLocalSnapshotToCloudBackupUseCase( + private val localAccountsCloudBackupFacade: LocalAccountsCloudBackupFacade, + private val cloudBackupService: CloudBackupService +) : ApplyLocalSnapshotToCloudBackupUseCase { + + override suspend fun applyLocalSnapshotToCloudBackupIfSyncEnabled(): Result { + if (!cloudBackupService.session.isSyncWithCloudEnabled()) return Result.success(Unit) + + return cloudBackupService.fetchAndDecryptExistingBackupWithSavedPassword() + .flatMap { cloudBackup -> + val localCloudBackupSnapshot = localAccountsCloudBackupFacade.fullBackupInfoFromLocalSnapshot() + val diff = localCloudBackupSnapshot.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + // If there are no changes to apply we can finish it + if (diff.cloudChanges.isEmpty() && diff.localChanges.isEmpty()) return Result.success(Unit) + + // If we don't have destructive local changes, we can apply the diff to cloud + if (localAccountsCloudBackupFacade.canPerformNonDestructiveApply(diff)) { + cloudBackupService.writeCloudBackupWithSavedPassword(localCloudBackupSnapshot) + .onSuccess { + cloudBackupService.session.setLastSyncedTimeAsNow() + } + } else { + return Result.failure(CannotApplyNonDestructiveDiff(diff, cloudBackup)) + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/details/AccountInChain.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/details/AccountInChain.kt new file mode 100644 index 0000000..dc0f99a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/details/AccountInChain.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.details + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class AccountInChain( + val chain: Chain, + val projection: Projection?, +) { + + class Projection(val address: String, val accountId: AccountId) + + enum class From { + META_ACCOUNT, CHAIN_ACCOUNT + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/details/WalletDetailsInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/details/WalletDetailsInteractor.kt new file mode 100644 index 0000000..bdeaa09 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/details/WalletDetailsInteractor.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.details + +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.address.format.defaultOrdering +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.common.data.secrets.v2.getAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.seed +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.domain.model.hasChainAccountIn +import io.novafoundation.nova.feature_account_api.presenatation.account.add.SecretType +import io.novafoundation.nova.feature_account_impl.domain.account.details.AccountInChain.From +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.accountInChainComparator +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.hasChainAccount +import io.novafoundation.nova.runtime.ext.addressScheme +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.enabledChains +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class +WalletDetailsInteractor( + private val accountRepository: AccountRepository, + private val secretStoreV2: SecretStoreV2, + private val chainRegistry: ChainRegistry, +) { + + suspend fun getMetaAccount(metaId: Long): MetaAccount { + return accountRepository.getMetaAccount(metaId) + } + + suspend fun updateName(metaId: Long, newName: String) { + accountRepository.updateMetaAccountName(metaId, newName) + } + + fun chainProjectionsByAddressSchemeFlow( + metaId: Long, + chains: List, + sorting: Comparator + ): Flow> { + return accountRepository.metaAccountFlow(metaId) + .map { metaAccount -> + chains.groupBy(Chain::addressScheme) + .mapValues { (_, chains) -> + chains.map { chain -> createAccountInChain(chain, metaAccount) } + .sortedWith(sorting) + } + .sortGroupsByMissingAccounts() + } + } + + fun chainProjectionsBySourceFlow( + metaId: Long, + chains: List, + sorting: Comparator + ): Flow> { + return accountRepository.metaAccountFlow(metaId) + .map { metaAccount -> + chains.map { chain -> + val from = if (metaAccount.hasChainAccountIn(chain.id)) From.CHAIN_ACCOUNT else From.META_ACCOUNT + val accountInChain = createAccountInChain(chain, metaAccount) + + from to accountInChain + } + .groupBy(keySelector = { it.first }, valueTransform = { it.second }) + .mapValues { (_, chainAccounts) -> chainAccounts.sortedWith(sorting) } + .toSortedMap(compareBy(From::ordering)) + } + } + + fun allPresentChainProjections( + metaAccount: MetaAccount + ): Flow> { + return flowOf { + val chains = getAllChains() + + chains.mapNotNull { createAccountInChainOrNull(it, metaAccount) } + .sortedWith(Chain.accountInChainComparator()) + } + } + + suspend fun availableExportTypes( + metaAccount: MetaAccount, + chain: Chain, + ): Set = withContext(Dispatchers.Default) { + val accountId = metaAccount.accountIdIn(chain) ?: return@withContext emptySet() + + val secrets = secretStoreV2.getAccountSecrets(metaAccount.id, accountId) + + setOfNotNull( + SecretType.MNEMONIC.takeIf { secrets.entropy() != null }, + SecretType.SEED.takeIf { secrets.seed() != null }, + SecretType.JSON // always available + ) + } + + suspend fun getAllChains(): List { + return chainRegistry.enabledChains() + } + + private fun GroupedList.sortGroupsByMissingAccounts(): GroupedList { + val hasAccountsByGroup = mapValues { (_, accounts) -> accounts.any { it.hasChainAccount } } + + val comparator = compareBy { hasAccountsByGroup.getValue(it) } + .thenBy { it.defaultOrdering } + + return toSortedMap(comparator) + } + + private fun createAccountInChain(chain: Chain, metaAccount: MetaAccount): AccountInChain { + val address = metaAccount.addressIn(chain) + val accountId = metaAccount.accountIdIn(chain) + + val projection = if (address != null && accountId != null) { + AccountInChain.Projection(address, accountId) + } else { + null + } + + return AccountInChain(chain = chain, projection = projection) + } + + private fun createAccountInChainOrNull(chain: Chain, metaAccount: MetaAccount): AccountInChain? { + val accountInChain = createAccountInChain(chain, metaAccount) + return accountInChain.takeIf { it.hasChainAccount } + } +} + +private val From.ordering + get() = when (this) { + From.CHAIN_ACCOUNT -> 0 + From.META_ACCOUNT -> 1 + } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/CommonExportSecretsInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/CommonExportSecretsInteractor.kt new file mode 100644 index 0000000..d8404e4 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/CommonExportSecretsInteractor.kt @@ -0,0 +1,131 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.export + +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.derivationPath +import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair +import io.novafoundation.nova.common.data.secrets.v2.keypair +import io.novafoundation.nova.common.data.secrets.v2.privateKey +import io.novafoundation.nova.common.data.secrets.v2.seed +import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.encrypt.mnemonic.Mnemonic +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface CommonExportSecretsInteractor { + + suspend fun getMetaAccountMnemonic(metaAccount: MetaAccount): Mnemonic? + + suspend fun getChainAccountMnemonic(metaAccount: MetaAccount, chain: Chain): Mnemonic? + + suspend fun getMetaAccountSeed(metaAccount: MetaAccount): String? + + suspend fun getMetaAccountEthereumPrivateKey(metaAccount: MetaAccount): String? + + suspend fun getChainAccountPrivateKey(metaAccount: MetaAccount, chain: Chain): String? + + suspend fun getChainAccountSeed(metaAccount: MetaAccount, chain: Chain): String? + + suspend fun getDerivationPath(metaAccount: MetaAccount, ethereum: Boolean): String? + + suspend fun getDerivationPath(metaAccount: MetaAccount, chain: Chain): String? + + suspend fun hasEthereumSecrets(metaAccount: MetaAccount): Boolean + + suspend fun hasSubstrateSecrets(metaAccount: MetaAccount): Boolean + + suspend fun hasMnemonic(metaId: Long, chainIdOrNull: String?): Boolean +} + +class RealCommonExportSecretsInteractor( + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val secretStoreV2: SecretStoreV2 +) : CommonExportSecretsInteractor { + + override suspend fun hasMnemonic(metaId: Long, chainIdOrNull: String?): Boolean = withContext(Dispatchers.Default) { + val metaAccount = accountRepository.getMetaAccount(metaId) + val chain = chainIdOrNull?.let { chainRegistry.getChain(it) } + + if (chain == null) { + getMetaAccountMnemonic(metaAccount) != null + } else { + getChainAccountMnemonic(metaAccount, chain) != null + } + } + + override suspend fun getMetaAccountMnemonic( + metaAccount: MetaAccount + ): Mnemonic? = withContext(Dispatchers.Default) { + val secrets = secretStoreV2.getMetaAccountSecrets(metaAccount.id) + + secrets?.entropy?.let { MnemonicCreator.fromEntropy(it) } + } + + override suspend fun getChainAccountMnemonic( + metaAccount: MetaAccount, + chain: Chain, + ): Mnemonic? = withContext(Dispatchers.Default) { + val chainAccountId = metaAccount.requireAccountIdIn(chain) + val secrets = secretStoreV2.getChainAccountSecrets(metaAccount.id, chainAccountId) + + secrets?.entropy?.let { MnemonicCreator.fromEntropy(it) } + } + + override suspend fun getMetaAccountSeed(metaAccount: MetaAccount): String? = withContext(Dispatchers.Default) { + val secrets = secretStoreV2.getMetaAccountSecrets(metaAccount.id) + + secrets?.seed?.toHexString(withPrefix = true) + } + + override suspend fun getMetaAccountEthereumPrivateKey(metaAccount: MetaAccount): String? = withContext(Dispatchers.Default) { + val secrets = secretStoreV2.getMetaAccountSecrets(metaAccount.id) + + secrets?.ethereumKeypair?.privateKey?.toHexString(withPrefix = true) + } + + override suspend fun getChainAccountPrivateKey(metaAccount: MetaAccount, chain: Chain): String? = withContext(Dispatchers.Default) { + val secrets = secretStoreV2.getChainAccountSecrets(metaAccount.id, metaAccount.requireAccountIdIn(chain)) + + secrets?.keypair?.privateKey?.toHexString(withPrefix = true) + } + + override suspend fun getChainAccountSeed(metaAccount: MetaAccount, chain: Chain): String? = withContext(Dispatchers.Default) { + val secrets = secretStoreV2.getChainAccountSecrets(metaAccount.id, metaAccount.requireAccountIdIn(chain)) + + secrets?.seed?.toHexString(withPrefix = true) + } + + override suspend fun getDerivationPath(metaAccount: MetaAccount, ethereum: Boolean): String? = withContext(Dispatchers.Default) { + val secrets = secretStoreV2.getMetaAccountSecrets(metaAccount.id) + + if (ethereum) { + secrets?.ethereumDerivationPath + } else { + secrets?.substrateDerivationPath + } + } + + override suspend fun getDerivationPath(metaAccount: MetaAccount, chain: Chain): String? = withContext(Dispatchers.Default) { + val secrets = secretStoreV2.getChainAccountSecrets(metaAccount.id, metaAccount.requireAccountIdIn(chain)) + + secrets?.derivationPath + } + + override suspend fun hasEthereumSecrets(metaAccount: MetaAccount): Boolean { + return secretStoreV2.getMetaAccountSecrets(metaAccount.id)?.ethereumKeypair != null + } + + override suspend fun hasSubstrateSecrets(metaAccount: MetaAccount): Boolean { + return secretStoreV2.getMetaAccountSecrets(metaAccount.id)?.substrateKeypair != null + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/ExportingSecret.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/ExportingSecret.kt new file mode 100644 index 0000000..0833440 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/ExportingSecret.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.export + +class ExportingSecret( + val derivationPath: String?, + val secret: T +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/ExportJsonInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/ExportJsonInteractor.kt new file mode 100644 index 0000000..68221e6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/ExportJsonInteractor.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.export.json + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class ExportJsonInteractor( + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, +) { + + suspend fun generateRestoreJson( + metaId: Long, + chainId: ChainId, + password: String, + ): Result { + val metaAccount = accountRepository.getMetaAccount(metaId) + val chain = chainRegistry.getChain(chainId) + + return runCatching { + accountRepository.generateRestoreJson(metaAccount, chain, password) + } + } + + suspend fun generateRestoreJson( + metaId: Long, + password: String, + ): Result { + val metaAccount = accountRepository.getMetaAccount(metaId) + + return runCatching { + accountRepository.generateRestoreJson(metaAccount, password) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/ExportJsonPasswordValidationFailure.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/ExportJsonPasswordValidationFailure.kt new file mode 100644 index 0000000..d682f12 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/ExportJsonPasswordValidationFailure.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_impl.R + +enum class ExportJsonPasswordValidationFailure { + PASSWORDS_DO_NOT_MATCH +} + +fun mapExportJsonPasswordValidationFailureToUi( + resourceManager: ResourceManager, + failure: ExportJsonPasswordValidationFailure, +): TitleAndMessage { + return when (failure) { + ExportJsonPasswordValidationFailure.PASSWORDS_DO_NOT_MATCH -> resourceManager.getString(R.string.common_error_general_title) to + resourceManager.getString(R.string.export_json_password_match_error) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/ExportJsonPasswordValidationPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/ExportJsonPasswordValidationPayload.kt new file mode 100644 index 0000000..4b5ae9f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/ExportJsonPasswordValidationPayload.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations + +class ExportJsonPasswordValidationPayload( + val password: String, + val passwordConfirmation: String, +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/ExportJsonPasswordValidationSystem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/ExportJsonPasswordValidationSystem.kt new file mode 100644 index 0000000..f232c65 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/ExportJsonPasswordValidationSystem.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem + +typealias ExportJsonPasswordValidationSystem = ValidationSystem +typealias ExportJsonPasswordValidation = Validation diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/PasswordMatchConfirmationValidation.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/PasswordMatchConfirmationValidation.kt new file mode 100644 index 0000000..ba906f0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/json/validations/PasswordMatchConfirmationValidation.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrError + +class PasswordMatchConfirmationValidation : ExportJsonPasswordValidation { + + override suspend fun validate(value: ExportJsonPasswordValidationPayload): ValidationStatus { + return validOrError(value.password == value.passwordConfirmation) { + ExportJsonPasswordValidationFailure.PASSWORDS_DO_NOT_MATCH + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/mnemonic/ExportMnemonicInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/mnemonic/ExportMnemonicInteractor.kt new file mode 100644 index 0000000..0f7906e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/mnemonic/ExportMnemonicInteractor.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.export.mnemonic + +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.feature_account_api.data.secrets.getAccountSecrets +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.mnemonic.Mnemonic +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ExportMnemonicInteractor( + private val accountRepository: AccountRepository, + private val secretStoreV2: SecretStoreV2, + private val chainRegistry: ChainRegistry, +) { + + suspend fun getMnemonic( + metaId: Long, + chainId: ChainId, + ): Mnemonic = withContext(Dispatchers.Default) { + val metaAccount = accountRepository.getMetaAccount(metaId) + val chain = chainRegistry.getChain(chainId) + + val entropy = secretStoreV2.getAccountSecrets(metaAccount, chain).entropy() + ?: error("No mnemonic found for account ${metaAccount.name} in ${chain.name}") + + MnemonicCreator.fromEntropy(entropy) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/seed/ExportPrivateKeyInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/seed/ExportPrivateKeyInteractor.kt new file mode 100644 index 0000000..40a6cd5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/export/seed/ExportPrivateKeyInteractor.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.export.seed + +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.seed +import io.novafoundation.nova.feature_account_api.data.secrets.getAccountSecrets +import io.novafoundation.nova.feature_account_api.data.secrets.keypair +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ExportPrivateKeyInteractor( + private val accountRepository: AccountRepository, + private val secretStoreV2: SecretStoreV2, + private val chainRegistry: ChainRegistry, +) { + + suspend fun isEthereumBased(chainId: ChainId): Boolean { + return chainRegistry.getChain(chainId).isEthereumBased + } + + suspend fun getAccountSeed( + metaId: Long, + chainId: ChainId, + ): String = withContext(Dispatchers.Default) { + val metaAccount = accountRepository.getMetaAccount(metaId) + val chain = chainRegistry.getChain(chainId) + + val accountSecrets = secretStoreV2.getAccountSecrets(metaAccount, chain) + + accountSecrets.seed()?.toHexString(withPrefix = true) + ?: error("No seed found for account ${metaAccount.name} in ${chain.name}") + } + + suspend fun getEthereumPrivateKey( + metaId: Long, + chainId: ChainId, + ): String = withContext(Dispatchers.Default) { + val metaAccount = accountRepository.getMetaAccount(metaId) + val chain = chainRegistry.getChain(chainId) + + require(chain.isEthereumBased) { "Chain ${chain.name} is not Ethereum-based" } + + val accountSecrets = secretStoreV2.getAccountSecrets(metaAccount, chain) + + accountSecrets.keypair(chain).privateKey.toHexString(withPrefix = true) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/identity/LocalIdentityProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/identity/LocalIdentityProvider.kt new file mode 100644 index 0000000..f621a80 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/identity/LocalIdentityProvider.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.identity + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class LocalIdentityProvider( + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry +) : IdentityProvider { + + override suspend fun identityFor(accountId: AccountId, chainId: ChainId): Identity? = withContext(Dispatchers.IO) { + val name = accountRepository.accountNameFor(accountId, chainId) + + name?.let(::Identity) + } + + override suspend fun identitiesFor(accountIds: Collection, chainId: ChainId): Map { + val chain = chainRegistry.getChain(chainId) + val metaAccountsById = accountRepository.getActiveMetaAccounts() + .associateBy { it.accountIdIn(chain)?.intoKey() } + + return accountIds.associateBy( + keySelector = { it.intoKey() }, + valueTransform = { metaAccountsById[it.intoKey()]?.name?.let(::Identity) } + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/identity/OnChainIdentityProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/identity/OnChainIdentityProvider.kt new file mode 100644 index 0000000..42c58b6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/identity/OnChainIdentityProvider.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.identity + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +class OnChainIdentityProvider( + private val onChainIdentityRepository: OnChainIdentityRepository +) : IdentityProvider { + + override suspend fun identityFor(accountId: AccountId, chainId: ChainId): Identity? { + val onChainIdentity = onChainIdentityRepository.getIdentityFromId(chainId, accountId) + + return Identity(onChainIdentity) + } + + override suspend fun identitiesFor(accountIds: Collection, chainId: ChainId): Map { + return onChainIdentityRepository.getIdentitiesFromIds(accountIds, chainId).mapValues { (_, identity) -> + Identity(identity) + } + } + + private fun Identity(onChainIdentity: OnChainIdentity?): Identity? { + return onChainIdentity?.display?.let(::Identity) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt new file mode 100644 index 0000000..170ebde --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.model + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.hasChainAccountIn +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +open class DefaultMetaAccount( + override val id: Long, + override val globallyUniqueId: String, + override val substratePublicKey: ByteArray?, + override val substrateCryptoType: CryptoType?, + override val substrateAccountId: ByteArray?, + override val ethereumAddress: ByteArray?, + override val ethereumPublicKey: ByteArray?, + override val isSelected: Boolean, + override val name: String, + override val type: LightMetaAccount.Type, + override val status: LightMetaAccount.Status, + override val chainAccounts: Map, + override val parentMetaId: Long? +) : MetaAccount { + + override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { + return true + } + + override fun hasAccountIn(chain: Chain): Boolean { + return when { + hasChainAccountIn(chain.id) -> true + chain.isEthereumBased -> ethereumAddress != null + else -> substrateAccountId != null + } + } + + override fun accountIdIn(chain: Chain): AccountId? { + return when { + hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId + chain.isEthereumBased -> ethereumAddress + else -> substrateAccountId + } + } + + override fun publicKeyIn(chain: Chain): ByteArray? { + return when { + hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey + chain.isEthereumBased -> ethereumPublicKey + else -> substratePublicKey + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt new file mode 100644 index 0000000..1ca26b4 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.model + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +class GenericLedgerMetaAccount( + id: Long, + globallyUniqueId: String, + substratePublicKey: ByteArray?, + substrateCryptoType: CryptoType?, + substrateAccountId: ByteArray?, + ethereumAddress: ByteArray?, + ethereumPublicKey: ByteArray?, + isSelected: Boolean, + name: String, + type: LightMetaAccount.Type, + status: LightMetaAccount.Status, + chainAccounts: Map, + parentMetaId: Long?, + private val supportedGenericLedgerChains: Set +) : DefaultMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = type, + status = status, + chainAccounts = chainAccounts, + parentMetaId = parentMetaId +) { + + override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { + // While Generic Ledger now provides support for both Substrate and EVM, initial version only supported Substrate + // So user might have a missing EVM account and we should allow them to add it + return isSupported(chain) + } + + override fun hasAccountIn(chain: Chain): Boolean { + return if (isSupported(chain)) { + super.hasAccountIn(chain) + } else { + false + } + } + + override fun accountIdIn(chain: Chain): AccountId? { + return if (isSupported(chain)) { + super.accountIdIn(chain) + } else { + null + } + } + + override fun publicKeyIn(chain: Chain): ByteArray? { + return if (isSupported(chain)) { + super.publicKeyIn(chain) + } else { + null + } + } + + private fun isSupported(chain: Chain) = chain.id in supportedGenericLedgerChains +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt new file mode 100644 index 0000000..e07f9f1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.model + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateApplicationConfig +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.supports +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class LegacyLedgerMetaAccount( + id: Long, + globallyUniqueId: String, + substratePublicKey: ByteArray?, + substrateCryptoType: CryptoType?, + substrateAccountId: ByteArray?, + ethereumAddress: ByteArray?, + ethereumPublicKey: ByteArray?, + isSelected: Boolean, + name: String, + type: LightMetaAccount.Type, + status: LightMetaAccount.Status, + chainAccounts: Map, + parentMetaId: Long? +) : DefaultMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = type, + status = status, + chainAccounts = chainAccounts, + parentMetaId = parentMetaId +) { + + override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { + return SubstrateApplicationConfig.supports(chain.id) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt new file mode 100644 index 0000000..a302a59 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.utils.compareTo +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigAvailability +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +class RealMultisigMetaAccount( + id: Long, + globallyUniqueId: String, + substrateAccountId: ByteArray?, + ethereumAddress: ByteArray?, + ethereumPublicKey: ByteArray?, + chainAccounts: Map, + isSelected: Boolean, + name: String, + status: LightMetaAccount.Status, + override val signatoryMetaId: Long, + override val signatoryAccountId: AccountIdKey, + private val otherSignatoriesUnsorted: List, + override val threshold: Int, + private val multisigRepository: MultisigRepository, + parentMetaId: Long? +) : DefaultMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + substratePublicKey = null, + substrateCryptoType = null, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = LightMetaAccount.Type.MULTISIG, + status = status, + chainAccounts = chainAccounts, + parentMetaId = parentMetaId +), + MultisigMetaAccount { + + override val otherSignatories by lazy(LazyThreadSafetyMode.PUBLICATION) { + otherSignatoriesUnsorted.sortedWith { a, b -> a.value.compareTo(b.value, unsigned = true) } + } + + override val availability: MultisigAvailability + get() = if (chainAccounts.isEmpty()) { + MultisigAvailability.Universal(addressScheme()) + } else { + MultisigAvailability.SingleChain(chainAccounts.keys.first()) + } + + override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { + // User cannot manually add accounts to multisig meta account + return false + } + + override fun hasAccountIn(chain: Chain): Boolean { + return if (isSupported(chain)) { + super.hasAccountIn(chain) + } else { + false + } + } + + override fun accountIdIn(chain: Chain): AccountId? { + return if (isSupported(chain)) { + super.accountIdIn(chain) + } else { + null + } + } + + override fun publicKeyIn(chain: Chain): ByteArray? { + return if (isSupported(chain)) { + super.publicKeyIn(chain) + } else { + null + } + } + + private fun isSupported(chain: Chain): Boolean { + return multisigRepository.supportsMultisigSync(chain) + } + + private fun addressScheme(): AddressScheme { + return when { + substrateAccountId != null -> AddressScheme.SUBSTRATE + else -> AddressScheme.EVM + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt new file mode 100644 index 0000000..1b2e4a6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.model + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class PolkadotVaultMetaAccount( + id: Long, + globallyUniqueId: String, + substratePublicKey: ByteArray?, + substrateCryptoType: CryptoType?, + substrateAccountId: ByteArray?, + ethereumAddress: ByteArray?, + ethereumPublicKey: ByteArray?, + isSelected: Boolean, + name: String, + type: LightMetaAccount.Type, + status: LightMetaAccount.Status, + chainAccounts: Map, + parentMetaId: Long? +) : DefaultMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = type, + status = status, + chainAccounts = chainAccounts, + parentMetaId = parentMetaId +) { + + override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { + return !chain.isEthereumBased + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt new file mode 100644 index 0000000..72b08ab --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.model + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxyAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +internal class RealProxiedMetaAccount( + id: Long, + globallyUniqueId: String, + substratePublicKey: ByteArray?, + substrateCryptoType: CryptoType?, + substrateAccountId: ByteArray?, + ethereumAddress: ByteArray?, + ethereumPublicKey: ByteArray?, + isSelected: Boolean, + name: String, + status: LightMetaAccount.Status, + override val proxy: ProxyAccount, + chainAccounts: Map, + parentMetaId: Long? +) : DefaultMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = LightMetaAccount.Type.PROXIED, + status = status, + chainAccounts = chainAccounts, + parentMetaId = parentMetaId +), + ProxiedMetaAccount { + + override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { + // User cannot manually add accounts to proxy meta account + return false + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt new file mode 100644 index 0000000..ec88239 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_account_impl.domain.account.model + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.SecretsMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.hasChainAccountIn +import io.novafoundation.nova.feature_account_api.domain.model.substrateFrom +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption + +class RealSecretsMetaAccount( + id: Long, + globallyUniqueId: String, + substratePublicKey: ByteArray?, + substrateCryptoType: CryptoType?, + substrateAccountId: ByteArray?, + ethereumAddress: ByteArray?, + ethereumPublicKey: ByteArray?, + isSelected: Boolean, + name: String, + status: LightMetaAccount.Status, + chainAccounts: Map, + parentMetaId: Long? +) : DefaultMetaAccount( + id = id, + globallyUniqueId = globallyUniqueId, + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumAddress = ethereumAddress, + ethereumPublicKey = ethereumPublicKey, + isSelected = isSelected, + name = name, + type = LightMetaAccount.Type.SECRETS, + status = status, + chainAccounts = chainAccounts, + parentMetaId = parentMetaId +), + SecretsMetaAccount { + + override fun multiChainEncryptionIn(chain: Chain): MultiChainEncryption? { + return when { + hasChainAccountIn(chain.id) -> { + val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null + + if (chain.isEthereumBased) { + MultiChainEncryption.Ethereum + } else { + MultiChainEncryption.substrateFrom(cryptoType) + } + } + + chain.isEthereumBased -> MultiChainEncryption.Ethereum + + else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/cloudBackup/createPassword/CreateCloudBackupPasswordInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/cloudBackup/createPassword/CreateCloudBackupPasswordInteractor.kt new file mode 100644 index 0000000..c241f10 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/cloudBackup/createPassword/CreateCloudBackupPasswordInteractor.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword + +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.data.cloudBackup.applyNonDestructiveCloudVersionOrThrow +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.model.PasswordErrors +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.domain.fetchAndDecryptExistingBackupWithSavedPassword +import io.novafoundation.nova.feature_cloud_backup_api.domain.initEnabledBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.WriteBackupRequest +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategy + +private const val MIN_PASSWORD_SYMBOLS = 8 + +interface CreateCloudBackupPasswordInteractor { + + fun checkPasswords(password: String, confirmPassword: String): List + + suspend fun createAndBackupAccount(accountName: String, password: String): Result + + suspend fun uploadInitialBackup(password: String): Result + + suspend fun changePassword(password: String): Result + + suspend fun signInToCloud(): Result +} + +class RealCreateCloudBackupPasswordInteractor( + private val cloudBackupService: CloudBackupService, + private val accountRepository: AccountRepository, + private val encryptionDefaults: EncryptionDefaults, + private val accountSecretsFactory: AccountSecretsFactory, + private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, + private val metaAccountDao: MetaAccountDao, + private val localAccountsCloudBackupFacade: LocalAccountsCloudBackupFacade, +) : CreateCloudBackupPasswordInteractor { + + override fun checkPasswords(password: String, confirmPassword: String): List { + return buildList { + if (password.length < MIN_PASSWORD_SYMBOLS) add(PasswordErrors.TOO_SHORT) + if (!password.any { it.isLetter() }) add(PasswordErrors.NO_LETTERS) + if (!password.any { it.isDigit() }) add(PasswordErrors.NO_DIGITS) + if (password != confirmPassword || password.isEmpty()) add(PasswordErrors.PASSWORDS_DO_NOT_MATCH) + } + } + + override suspend fun createAndBackupAccount(accountName: String, password: String): Result { + val (secrets, substrateCryptoType) = accountSecretsFactory.metaAccountSecrets( + substrateDerivationPath = encryptionDefaults.substrateDerivationPath, + ethereumDerivationPath = encryptionDefaults.ethereumDerivationPath, + accountSource = AccountSecretsFactory.AccountSource.Mnemonic( + cryptoType = encryptionDefaults.substrateCryptoType, + mnemonic = accountRepository.generateMnemonic().words + ) + ) + + val metaAccountLocal = secretsMetaAccountLocalFactory.create( + name = accountName, + substrateCryptoType = substrateCryptoType, + secrets = secrets, + accountSortPosition = metaAccountDao.nextAccountPosition() + ) + + val cloudBackup = localAccountsCloudBackupFacade.constructCloudBackupForFirstWallet( + metaAccount = metaAccountLocal, + baseSecrets = secrets, + ) + + return cloudBackupService.writeBackupToCloud(WriteBackupRequest(cloudBackup, password)).mapCatching { + cloudBackupService.session.initEnabledBackup(password) + + localAccountsCloudBackupFacade.applyNonDestructiveCloudVersionOrThrow(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + val firstSelectedMetaAccount = accountRepository.getActiveMetaAccounts().first() + accountRepository.selectMetaAccount(firstSelectedMetaAccount.id) + } + } + + override suspend fun uploadInitialBackup(password: String): Result { + val cloudBackup = localAccountsCloudBackupFacade.fullBackupInfoFromLocalSnapshot() + return cloudBackupService.writeBackupToCloud(WriteBackupRequest(cloudBackup, password)).onSuccess { + cloudBackupService.session.initEnabledBackup(password) + } + } + + override suspend fun changePassword(password: String): Result { + return cloudBackupService.fetchAndDecryptExistingBackupWithSavedPassword() + .flatMap { cloudBackup -> + cloudBackupService.writeBackupToCloud(WriteBackupRequest(cloudBackup, password)) + }.map { + cloudBackupService.session.setSavedPassword(password) + } + } + + override suspend fun signInToCloud(): Result { + return cloudBackupService.signInToCloud() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/cloudBackup/createPassword/model/PasswordErrors.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/cloudBackup/createPassword/model/PasswordErrors.kt new file mode 100644 index 0000000..38805e0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/cloudBackup/createPassword/model/PasswordErrors.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.model + +enum class PasswordErrors { + TOO_SHORT, + NO_LETTERS, + NO_DIGITS, + PASSWORDS_DO_NOT_MATCH +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/cloudBackup/enterPassword/EnterCloudBackupInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/cloudBackup/enterPassword/EnterCloudBackupInteractor.kt new file mode 100644 index 0000000..62d39c3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/cloudBackup/enterPassword/EnterCloudBackupInteractor.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword + +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.data.cloudBackup.applyNonDestructiveCloudVersionOrThrow +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.domain.fetchAndDecryptExistingBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.initEnabledBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.WriteBackupRequest +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.isEmpty +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategy +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError + +interface EnterCloudBackupInteractor { + + suspend fun restoreCloudBackup(password: String): Result + + suspend fun deleteCloudBackup(): Result + + suspend fun confirmCloudBackupPassword(password: String): Result + + suspend fun restoreCloudBackupPassword(password: String): Result +} + +class RealEnterCloudBackupInteractor( + private val cloudBackupService: CloudBackupService, + private val cloudBackupFacade: LocalAccountsCloudBackupFacade, + private val accountRepository: AccountRepository +) : EnterCloudBackupInteractor { + + override suspend fun restoreCloudBackup(password: String): Result { + return cloudBackupService.fetchAndDecryptExistingBackup(password) + .mapCatching { cloudBackup -> + // `CannotApplyNonDestructiveDiff` shouldn't actually happen here since it is a import for clean app but we should handle it anyway + val diff = cloudBackupFacade.applyNonDestructiveCloudVersionOrThrow(cloudBackup, BackupDiffStrategy.importFromCloud()) + + val firstSelectedMetaAccount = accountRepository.getActiveMetaAccounts().first() + accountRepository.selectMetaAccount(firstSelectedMetaAccount.id) + + cloudBackupService.session.initEnabledBackup(password) + + diff + }.flatMap { diff -> + // Once we successfully applied state locally, we can write new changes to backup to sync backup with other local state + if (diff.cloudChanges.isEmpty()) return Result.success(Unit) + + val request = WriteBackupRequest( + cloudBackup = cloudBackupFacade.fullBackupInfoFromLocalSnapshot(), + password = password + ) + cloudBackupService.writeBackupToCloud(request) + } + } + + override suspend fun deleteCloudBackup(): Result { + return cloudBackupService.deleteBackup() + } + + override suspend fun confirmCloudBackupPassword(password: String): Result { + return cloudBackupService.session.getSavedPassword() + .mapCatching { + if (it != password) { + throw InvalidBackupPasswordError() + } + } + } + + override suspend fun restoreCloudBackupPassword(password: String): Result { + return cloudBackupService.fetchAndDecryptExistingBackup(password) + .map { cloudBackupService.session.setSavedPassword(password) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/common/AdvancedEncryptionSelectionStore.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/common/AdvancedEncryptionSelectionStore.kt new file mode 100644 index 0000000..acdbc65 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/common/AdvancedEncryptionSelectionStore.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_impl.domain.common + +import io.novafoundation.nova.common.utils.selectionStore.MutableSelectionStore +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption + +class AdvancedEncryptionSelectionStore : MutableSelectionStore() diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/common/AdvancedEncryptionSelectionStoreProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/common/AdvancedEncryptionSelectionStoreProvider.kt new file mode 100644 index 0000000..c9af56f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/common/AdvancedEncryptionSelectionStoreProvider.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_impl.domain.common + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.utils.selectionStore.ComputationalCacheSelectionStoreProvider + +private const val KEY = "advanced_encryption_selection_store" + +class AdvancedEncryptionSelectionStoreProvider( + computationalCache: ComputationalCache +) : ComputationalCacheSelectionStoreProvider(computationalCache, KEY) { + + protected override fun initSelectionStore(): AdvancedEncryptionSelectionStore { + return AdvancedEncryptionSelectionStore() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/errors/NodeAlreadyExistsException.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/errors/NodeAlreadyExistsException.kt new file mode 100644 index 0000000..76d6d8c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/errors/NodeAlreadyExistsException.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_account_impl.domain.errors + +class NodeAlreadyExistsException : RuntimeException() diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/errors/UnsupportedNetworkException.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/errors/UnsupportedNetworkException.kt new file mode 100644 index 0000000..581f894 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/errors/UnsupportedNetworkException.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_account_impl.domain.errors + +class UnsupportedNetworkException : RuntimeException() diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/manualBackup/ManualBackupSelectAccountInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/manualBackup/ManualBackupSelectAccountInteractor.kt new file mode 100644 index 0000000..c3ae703 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/manualBackup/ManualBackupSelectAccountInteractor.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_account_impl.domain.manualBackup + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.ext.defaultComparatorFrom +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class MetaAccountChains( + val metaAccount: MetaAccount, + val defaultChains: List, + val customChains: List +) + +interface ManualBackupSelectAccountInteractor { + + fun sortedMetaAccountChains(id: Long): Flow +} + +class RealManualBackupSelectAccountInteractor( + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry +) : ManualBackupSelectAccountInteractor { + + override fun sortedMetaAccountChains(id: Long): Flow { + return chainRegistry.currentChains + .map { chains -> + val metaAccount = accountRepository.getMetaAccount(id) + val sortedChains = chains.toSortedSet(Chain.defaultComparatorFrom { it }) + MetaAccountChains( + metaAccount, + sortedChains.filter { it.id !in metaAccount.chainAccounts.keys && metaAccount.hasAccountIn(it) }, + sortedChains.filter { it.id in metaAccount.chainAccounts.keys }, + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/manualBackup/ManualBackupSelectWalletInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/manualBackup/ManualBackupSelectWalletInteractor.kt new file mode 100644 index 0000000..f4b69df --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/manualBackup/ManualBackupSelectWalletInteractor.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.domain.manualBackup + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.LEDGER +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.LEDGER_LEGACY +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.MULTISIG +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.PARITY_SIGNER +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.POLKADOT_VAULT +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.PROXIED +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.SECRETS +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type.WATCH_ONLY +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount + +interface ManualBackupSelectWalletInteractor { + + suspend fun getBackupableMetaAccounts(): List + + suspend fun getMetaAccount(id: Long): MetaAccount +} + +class RealManualBackupSelectWalletInteractor( + private val accountRepository: AccountRepository +) : ManualBackupSelectWalletInteractor { + + override suspend fun getBackupableMetaAccounts(): List { + return accountRepository.getActiveMetaAccounts() + .filter { it.canBackupManually() } + } + + override suspend fun getMetaAccount(id: Long): MetaAccount { + return accountRepository.getMetaAccount(id) + } + + private fun MetaAccount.canBackupManually(): Boolean { + return when (type) { + SECRETS -> true + + WATCH_ONLY, + PARITY_SIGNER, + LEDGER, + LEDGER_LEGACY, + POLKADOT_VAULT, + MULTISIG, + PROXIED -> false + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/RealMultisigPendingOperationsService.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/RealMultisigPendingOperationsService.kt new file mode 100644 index 0000000..8f17a34 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/RealMultisigPendingOperationsService.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_account_impl.domain.multisig + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.data.memory.SharedComputation +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.findById +import io.novafoundation.nova.common.utils.parentCancellableFlowScope +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId +import io.novafoundation.nova.feature_account_api.data.multisig.model.identifier +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.feature_account_impl.domain.multisig.calldata.MultisigCallDataWatcher +import io.novafoundation.nova.feature_account_impl.domain.multisig.calldata.MultisigCallDataWatcherFactory +import io.novafoundation.nova.feature_account_impl.domain.multisig.syncer.MultisigChainPendingOperationsSyncerFactory +import io.novafoundation.nova.feature_account_impl.domain.multisig.syncer.MultisigPendingOperationsSyncer +import io.novafoundation.nova.feature_account_impl.domain.multisig.syncer.NoOpSyncer +import io.novafoundation.nova.feature_account_impl.domain.multisig.syncer.MultiChainSyncer +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.enabledChains +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private const val OPERATIONS_SYNCER_CACHE_KEY = "OPERATIONS_SYNCER_CACHE_KEY" +private const val CALL_DATA_SYNCER_CACHE_KEY = "CALL_DATA_SYNCER_CACHE_KEY" + +@FeatureScope +internal class RealMultisigPendingOperationsService @Inject constructor( + computationalCache: ComputationalCache, + private val syncerFactory: MultisigChainPendingOperationsSyncerFactory, + private val accountRepository: AccountRepository, + private val multisigRepository: MultisigRepository, + private val chainRegistry: ChainRegistry, + private val realtimeCallDataWatcherFactory: MultisigCallDataWatcherFactory, +) : SharedComputation(computationalCache), MultisigPendingOperationsService { + + context(ComputationalScope) + override fun performMultisigOperationsSync(): Flow { + return getCachedSyncer().map { } + } + + context(ComputationalScope) + override fun pendingOperationsCountFlow(): Flow { + return getCachedSyncer().flatMapLatest { it.pendingOperationsCount } + } + + context(ComputationalScope) + override suspend fun getPendingOperationsCount(): Int { + return pendingOperationsCountFlow().first() + } + + context(ComputationalScope) + override fun pendingOperations(): Flow> { + return getCachedSyncer().flatMapLatest { it.pendingOperations } + } + + context(ComputationalScope) + override fun pendingOperationFlow(id: PendingMultisigOperationId): Flow { + return pendingOperations().map { it.findById(id.identifier()) } + } + + context(ComputationalScope) + override suspend fun pendingOperation(id: PendingMultisigOperationId): PendingMultisigOperation? { + return pendingOperationFlow(id).first() + } + + context(ComputationalScope) + private fun getCachedSyncer(): Flow { + return cachedFlow(OPERATIONS_SYNCER_CACHE_KEY) { + accountRepository.selectedMetaAccountFlow().flatMapLatest { + parentCancellableFlowScope { scope -> + createSyncer(it, getCachedRealtimeCallDataWatcher(), scope) + } + } + } + } + + context(ComputationalScope) + private suspend fun getCachedRealtimeCallDataWatcher(): MultisigCallDataWatcher { + return cachedValue(CALL_DATA_SYNCER_CACHE_KEY) { + realtimeCallDataWatcherFactory.createOnlyMultisig(coroutineScope = this) + } + } + + private suspend fun createSyncer( + account: MetaAccount, + callDataWatcher: MultisigCallDataWatcher, + scope: CoroutineScope + ): MultisigPendingOperationsSyncer { + return if (account is MultisigMetaAccount) { + createMultisigSynced(account, callDataWatcher, scope) + } else { + NoOpSyncer() + } + } + + private suspend fun createMultisigSynced( + account: MultisigMetaAccount, + callDataWatcher: MultisigCallDataWatcher, + scope: CoroutineScope + ): MultisigPendingOperationsSyncer { + val multisigChainSyncers = chainRegistry.enabledChains() + .filter { chain -> multisigRepository.supportsMultisigSync(chain) } + .associate { chain -> chain.id to syncerFactory.create(chain, account, callDataWatcher, scope) } + + return MultiChainSyncer(multisigChainSyncers, scope) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/EventsRealtimeCallDataWatcher.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/EventsRealtimeCallDataWatcher.kt new file mode 100644 index 0000000..506cb37 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/EventsRealtimeCallDataWatcher.kt @@ -0,0 +1,163 @@ +package io.novafoundation.nova.feature_account_impl.domain.multisig.calldata + +import android.util.Log +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCall +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.common.utils.isSigned +import io.novafoundation.nova.common.utils.put +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.feature_account_api.domain.multisig.bindCallHash +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.walkToList +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.enabledChains +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.events +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvents +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.toByteArray +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class EventsRealtimeCallDataWatcher( + private val eventsRepository: EventsRepository, + private val extrinsicWalk: ExtrinsicWalk, + private val chainRegistry: ChainRegistry, + private val multisigRepository: MultisigRepository, + coroutineScope: CoroutineScope, +) : MultisigCallDataWatcher, CoroutineScope by coroutineScope { + + companion object { + + private const val NEW_MULTISIG = "NewMultisig" + + private const val MULTISIG_APPROVAL = "MultisigApproval" + } + + override val callData = MutableStateFlow>(emptyMap()) + + override val newMultisigEvents = MutableSharedFlow(extraBufferCapacity = 10) + + init { + launch { + val multisigChains = chainRegistry.enabledChains() + .filter { multisigRepository.supportsMultisigSync(it) } + + multisigChains.forEach(::startDetectingNewPendingCallHashesFromEvents) + } + } + + private fun startDetectingNewPendingCallHashesFromEvents(chain: Chain) { + eventsRepository.subscribeEventRecords(chain.id).map { (eventRecords, blockHash) -> + val newMultisigEvents = eventRecords.events().findNewMultisigs(chain) + val newCallHashes = newMultisigEvents.map { it.callHash } + + newMultisigEvents.forEach { this.newMultisigEvents.emit(it) } + + tryAddNewCallDatas(newCallHashes, blockHash, chain) + } + .catch { Log.e("ChainRealtimeCallDataWatcher", "Failed to detect new pending operations from events", it) } + .launchIn(this) + } + + private suspend fun tryAddNewCallDatas( + newCallHashes: List, + blockHash: BlockHash, + chain: Chain + ): Result = runCatching { + if (newCallHashes.isEmpty()) return@runCatching + + Log.d("ChainRealtimeCallDataWatcher", "Detected multisig at block $blockHash, trying to find call-data") + + val blockWithEvents = eventsRepository.getBlockEvents(chain.id, blockHash) + + val newCallDatas = buildMap { + blockWithEvents.applyExtrinsic + .filter { it.extrinsic.isSigned() } + .forEach { extrinsicWithEvents -> extrinsicWithEvents.tryAddNewCallDatas(newCallHashes, chain) } + } + val newCallDatasWithChain = newCallDatas.mapKeys { (callHash, _) -> chain.id to callHash } + + Log.d("ChainRealtimeCallDataWatcher", "Found call-datas: ${newCallDatas.size}") + + callData.value += newCallDatasWithChain + }.onFailure { + Log.e("ChainRealtimeCallDataWatcher", "Error while detecting call-data from chain", it) + } + + context(MutableMap) + private suspend fun ExtrinsicWithEvents.tryAddNewCallDatas(newCallHashes: List, chain: Chain) { + val visitedEntries = extrinsicWalk.walkToList(source = this@tryAddNewCallDatas, chain.id) + visitedEntries.forEach { + val callHashAndData = it.tryFindCallDataInAsMulti(newCallHashes, chain) + if (callHashAndData != null) { + put(callHashAndData) + } + } + } + + private suspend fun ExtrinsicVisit.tryFindCallDataInAsMulti( + callHashes: List, + chain: Chain + ): Pair? { + if (!call.instanceOf(Modules.MULTISIG, "as_multi")) return null + + val runtime = chainRegistry.getRuntime(chain.id) + + val callData = bindGenericCall(call.arguments["call"]) + val callHash = GenericCall.toByteArray(runtime, callData).blake2b256().intoKey() + + return if (callHash in callHashes) { + callHash to callData + } else { + null + } + } + + private fun List.findNewMultisigs(chain: Chain): List { + return findEvents(Modules.MULTISIG, NEW_MULTISIG, MULTISIG_APPROVAL).mapNotNull { newMultisigEvent -> + extractCallHash(newMultisigEvent, chain) + } + } + + private fun extractCallHash( + newMultisigEvent: GenericEvent.Instance, + chain: Chain + ): MultiChainMultisigEvent? { + return runCatching { + val multisigRaw: Any? + val callHashRaw: Any? + + if (newMultisigEvent.instanceOf(Modules.MULTISIG, NEW_MULTISIG)) { + multisigRaw = newMultisigEvent.arguments[1] + callHashRaw = newMultisigEvent.arguments[2] + } else { + multisigRaw = newMultisigEvent.arguments[2] + callHashRaw = newMultisigEvent.arguments[3] + } + + val multisig = bindAccountIdKey(multisigRaw) + val callHash = bindCallHash(callHashRaw) + + MultiChainMultisigEvent(multisig, callHash, chain.id) + } + .onFailure { Log.e("RealMultisigChainPendingOperationsSyncer", "Failed to parse new NewMultisig/MultisigApproval event", it) } + .getOrNull() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/LocalMultisigCallDataWatcher.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/LocalMultisigCallDataWatcher.kt new file mode 100644 index 0000000..0cbae6f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/LocalMultisigCallDataWatcher.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_impl.domain.multisig.calldata + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map + +class LocalMultisigCallDataWatcher( + private val chainRegistry: ChainRegistry, + multisigOperationLocalCallRepository: MultisigOperationLocalCallRepository +) : MultisigCallDataWatcher { + + override val newMultisigEvents = MutableSharedFlow(extraBufferCapacity = 0) + + override val callData = multisigOperationLocalCallRepository.callsFlow().map { operations -> + operations.associateBy { it.chainId to it.callHash.intoKey() } + .mapValues { (_, value) -> + val runtime = getRuntime(value.chainId) ?: return@mapValues null + GenericCall.fromHex(runtime, value.callInstance) + }.filterNotNull() + } + + private suspend fun getRuntime(chainId: ChainId) = runCatching { chainRegistry.getRuntime(chainId) }.getOrNull() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/MultisigCallDataWatcher.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/MultisigCallDataWatcher.kt new file mode 100644 index 0000000..e42cb88 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/MultisigCallDataWatcher.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_impl.domain.multisig.calldata + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.flow.Flow + +interface MultisigCallDataWatcher { + + val newMultisigEvents: Flow + + val callData: Flow> +} + +class MultiChainMultisigEvent( + val multisig: AccountIdKey, + val callHash: CallHash, + val chainId: ChainId +) + +typealias MultiChainCallHash = Pair diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/MultisigCallDataWatcherFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/MultisigCallDataWatcherFactory.kt new file mode 100644 index 0000000..2e5a326 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/MultisigCallDataWatcherFactory.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_impl.domain.multisig.calldata + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +@FeatureScope +class MultisigCallDataWatcherFactory @Inject constructor( + private val eventsRepository: EventsRepository, + private val extrinsicWalk: ExtrinsicWalk, + private val chainRegistry: ChainRegistry, + private val multisigRepository: MultisigRepository, + private val accountRepository: AccountRepository, + private val multisigOperationLocalCallRepository: MultisigOperationLocalCallRepository, +) { + + fun createOnlyMultisig(coroutineScope: CoroutineScope): MultisigCallDataWatcher { + return MultisigOnlyCallDataWatcher( + eventsRepository = eventsRepository, + extrinsicWalk = extrinsicWalk, + chainRegistry = chainRegistry, + multisigRepository = multisigRepository, + accountRepository = accountRepository, + multisigOperationLocalCallRepository = multisigOperationLocalCallRepository, + coroutineScope = coroutineScope + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/MultisigOnlyCallDataWatcher.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/MultisigOnlyCallDataWatcher.kt new file mode 100644 index 0000000..3122b93 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/MultisigOnlyCallDataWatcher.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_account_impl.domain.multisig.calldata + +import android.util.Log +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest + +class MultisigOnlyCallDataWatcher( + private val eventsRepository: EventsRepository, + private val extrinsicWalk: ExtrinsicWalk, + private val chainRegistry: ChainRegistry, + private val multisigRepository: MultisigRepository, + private val accountRepository: AccountRepository, + private val multisigOperationLocalCallRepository: MultisigOperationLocalCallRepository, + private val coroutineScope: CoroutineScope, +) : MultisigCallDataWatcher, CoroutineScope by coroutineScope { + + private val delegate = accountRepository.hasMetaAccountsCountOfTypeFlow(Type.MULTISIG).mapLatest { hasMultisigs -> + if (hasMultisigs) { + Log.d("MultisigOnlyCallDataWatcher", "User has multisig wallets - starting to sync realtime call-data") + + val scope = CoroutineScope(coroutineContext) + val delegate = EventsRealtimeCallDataWatcher(eventsRepository, extrinsicWalk, chainRegistry, multisigRepository, scope) + delegate + } else { + Log.d("MultisigOnlyCallDataWatcher", "User has no multisig wallets - not syncing call-data") + + NoOpRealtimeCallDataWatcher + } + }.shareInBackground() + + private val localCallDataWatcher = LocalMultisigCallDataWatcher(chainRegistry, multisigOperationLocalCallRepository) + + override val callData = combine( + delegate.flatMapLatest { it.callData }, + localCallDataWatcher.callData + ) { realtimeCallData, localCallData -> + val newLocalCallData = localCallData.filter { it.key !in realtimeCallData } + + realtimeCallData + newLocalCallData + } + + override val newMultisigEvents: Flow = delegate.flatMapLatest { it.newMultisigEvents } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/NoOpRealtimeCallDataWatcher.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/NoOpRealtimeCallDataWatcher.kt new file mode 100644 index 0000000..42a70b8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/calldata/NoOpRealtimeCallDataWatcher.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_impl.domain.multisig.calldata + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow + +object NoOpRealtimeCallDataWatcher : MultisigCallDataWatcher { + override val newMultisigEvents: Flow = emptyFlow() + + override val callData: StateFlow> = MutableStateFlow(emptyMap()) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/syncer/MultiChainPendingOperationsSyncer.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/syncer/MultiChainPendingOperationsSyncer.kt new file mode 100644 index 0000000..32a7432 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/syncer/MultiChainPendingOperationsSyncer.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_impl.domain.multisig.syncer + +import io.novafoundation.nova.common.utils.accumulate +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class MultiChainSyncer( + chainDelegates: Map, + scope: CoroutineScope +) : MultisigPendingOperationsSyncer, CoroutineScope by scope { + + override val pendingOperationsCount: Flow = chainDelegates.values + .map { it.pendingOperationsCount } + .accumulate() + .map { it.sum() } + .shareInBackground() + + override val pendingOperations: Flow> = chainDelegates.values + .map { it.pendingOperations } + .accumulate() + .map { it.flatten() } + .shareInBackground() +} + +class NoOpSyncer : MultisigPendingOperationsSyncer { + override val pendingOperationsCount: Flow = flowOf(0) + + override val pendingOperations: Flow> = flowOf(emptyList()) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/syncer/MultisigPendingOperationsSyncer.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/syncer/MultisigPendingOperationsSyncer.kt new file mode 100644 index 0000000..b35aa61 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/multisig/syncer/MultisigPendingOperationsSyncer.kt @@ -0,0 +1,211 @@ +package io.novafoundation.nova.feature_account_impl.domain.multisig.syncer + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.mapNotNullToSet +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.accountIdKeyIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.feature_account_impl.data.multisig.blockhain.model.OnChainMultisig +import io.novafoundation.nova.feature_account_impl.data.multisig.model.OffChainPendingMultisigOperationInfo +import io.novafoundation.nova.feature_account_impl.domain.multisig.calldata.MultisigCallDataWatcher +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.blockDurationEstimatorFromRemote +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import kotlinx.coroutines.flow.update +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@FeatureScope +internal class MultisigChainPendingOperationsSyncerFactory @Inject constructor( + private val multisigRepository: MultisigRepository, + private val operationLocalCallRepository: MultisigOperationLocalCallRepository, + private val accountRepository: AccountRepository, + private val chainStateRepository: ChainStateRepository, +) { + + fun create( + chain: Chain, + multisig: MultisigMetaAccount, + callDataWatcher: MultisigCallDataWatcher, + scope: CoroutineScope, + ): MultisigPendingOperationsSyncer { + return RealMultisigChainPendingOperationsSyncer( + chain = chain, + multisig = multisig, + scope = scope, + multisigRepository = multisigRepository, + callDataWatcher = callDataWatcher, + operationLocalCallRepository = operationLocalCallRepository, + accountRepository = accountRepository, + chainStateRepository = chainStateRepository + ) + } +} + +interface MultisigPendingOperationsSyncer { + + val pendingOperationsCount: Flow + + val pendingOperations: Flow> +} + +internal class RealMultisigChainPendingOperationsSyncer( + private val chain: Chain, + private val multisig: MultisigMetaAccount, + private val scope: CoroutineScope, + private val callDataWatcher: MultisigCallDataWatcher, + private val multisigRepository: MultisigRepository, + private val accountRepository: AccountRepository, + private val operationLocalCallRepository: MultisigOperationLocalCallRepository, + private val chainStateRepository: ChainStateRepository, +) : CoroutineScope by scope, MultisigPendingOperationsSyncer { + + private val pendingCallHashesFlow = MutableStateFlow(emptySet()) + + private val offChainInfos = MutableStateFlow(emptyMap()) + + override val pendingOperationsCount = pendingCallHashesFlow + .map { it.size } + .onEach { + Log.d("RealMultisigChainPendingOperationsSyncer", "# of operations for ${multisig.name} in ${chain.name}: $it") + }.shareInBackground() + + override val pendingOperations = this.pendingCallHashesFlow.flatMapLatest(::observePendingOperations) + .onEach(::cleanInactiveOperations) + .map { it.values.filterNotNull() } + .catch { Log.e("RealMultisigChainPendingOperationsSyncer", "Failed to sync pendingOperations", it) } + .onEach { Log.d("RealMultisigChainPendingOperationsSyncer", "Operations for ${multisig.name} in ${chain.name}: $it") } + .shareInBackground() + + init { + observePendingCallHashes() + + startOffChainRefreshJob() + } + + private fun startOffChainRefreshJob() { + this.pendingCallHashesFlow + .mapLatest(::syncOffChainInfo) + .launchIn(this) + } + + private suspend fun syncOffChainInfo(callHashes: Set) { + if (callHashes.isEmpty()) return + + val accountId = multisig.requireAccountIdKeyIn(chain) + + multisigRepository.getOffChainPendingOperationsInfo(chain, accountId, callHashes) + .onSuccess { offChainInfos.value = it } + } + + private suspend fun cleanInactiveOperations(pendingOperations: Map) { + val inactiveOperations = pendingOperations.entries.mapNotNullToSet { (key, value) -> key.takeIf { value == null } } + this.pendingCallHashesFlow.update { pendingCallHashes -> pendingCallHashes - inactiveOperations } + } + + private fun observePendingCallHashes() = launchUnit { + val accountId = multisig.accountIdKeyIn(chain) ?: return@launchUnit + + syncInitialHashes(accountId) + startDetectingNewPendingCallHashesFromEvents(accountId) + } + + private fun startDetectingNewPendingCallHashesFromEvents(accountId: AccountIdKey) { + callDataWatcher.newMultisigEvents + .filter { it.multisig == accountId && it.chainId == chain.id } + .onEach { + this.pendingCallHashesFlow.update { pendingCallHashes -> pendingCallHashes + it.callHash } + }.launchIn(this) + } + + private suspend fun observePendingOperations(callHashes: Set): Flow> { + val accountId = multisig.accountIdKeyIn(chain) + if (accountId == null || callHashes.isEmpty()) return flowOf { emptyMap() } + + val timeEstimator = chainStateRepository.blockDurationEstimatorFromRemote(chain.id) + + return combine( + callDataWatcher.callData, + offChainInfos, + multisigRepository.subscribePendingOperations(this.chain, accountId, callHashes) + ) { realtimeCallDatas, offChainInfos, onChainOperations -> + onChainOperations.mapValues { (callHash, onChainOperation) -> + val realtimeKey = chain.id to callHash + val offChainInfo = offChainInfos[callHash] + + PendingMultisigOperation.from( + multisigMetaId = multisig.id, + onChainMultisig = onChainOperation ?: return@mapValues null, + callData = realtimeCallDatas[realtimeKey] ?: offChainInfo?.callData, + chain = chain, + timestamp = offChainInfo?.timestamp ?: timeEstimator.timestampOf(onChainOperation.timePoint.height).milliseconds + ) + } + } + } + + private fun PendingMultisigOperation.Companion.from( + multisigMetaId: Long, + onChainMultisig: OnChainMultisig, + callData: GenericCall.Instance?, + chain: Chain, + timestamp: Duration + ): PendingMultisigOperation { + return PendingMultisigOperation( + multisigMetaId = multisigMetaId, + call = callData, + callHash = onChainMultisig.callHash, + chain = chain, + approvals = onChainMultisig.approvals, + signatoryAccountId = multisig.signatoryAccountId, + signatoryMetaId = multisig.signatoryMetaId, + threshold = multisig.threshold, + depositor = onChainMultisig.depositor, + deposit = onChainMultisig.deposit, + timePoint = onChainMultisig.timePoint, + timestamp = timestamp + ) + } + + private suspend fun syncInitialHashes(accountId: AccountIdKey) { + runCatching { + val pendingOperationsHashes = multisigRepository.getPendingOperationIds(chain, accountId) + removeOutdatedLocalCallHashes(pendingOperationsHashes) + pendingCallHashesFlow.value = pendingOperationsHashes + }.onFailure { + Log.e("RealMultisigChainPendingOperationsSyncer", "Failed to load initial call hashes", it) + } + } + + private suspend fun removeOutdatedLocalCallHashes(callHashes: Set) { + val metaAccount = accountRepository.getSelectedMetaAccount() + operationLocalCallRepository.removeCallHashesExclude( + metaAccount.id, + chain.id, + callHashes + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/finish/FinishImportParitySignerInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/finish/FinishImportParitySignerInteractor.kt new file mode 100644 index 0000000..a18f662 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/finish/FinishImportParitySignerInteractor.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.finish + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.addAccountWithSingleChange +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.substrate +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.paritySigner.ParitySignerAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SubstrateKeypairAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SeedAddAccountRepository +import io.novafoundation.nova.feature_account_impl.domain.utils.ScanSecret +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.String + +interface FinishImportParitySignerInteractor { + + suspend fun createPolkadotVaultWallet( + name: String, + substrateAccountId: AccountId, + variant: PolkadotVaultVariant + ): Result + + suspend fun createSecretWallet( + name: String, + secret: ScanSecret, + variant: PolkadotVaultVariant + ): Result +} + +class RealFinishImportParitySignerInteractor( + private val paritySignerAddAccountRepository: ParitySignerAddAccountRepository, + private val substrateKeypairAddAccountRepository: SubstrateKeypairAddAccountRepository, + private val seedAddAccountRepository: SeedAddAccountRepository, + private val accountRepository: AccountRepository +) : FinishImportParitySignerInteractor { + + override suspend fun createPolkadotVaultWallet( + name: String, + substrateAccountId: AccountId, + variant: PolkadotVaultVariant + ): Result = withContext(Dispatchers.Default) { + runCatching { + val addAccountResult = paritySignerAddAccountRepository.addAccountWithSingleChange( + ParitySignerAddAccountRepository.Payload( + name, + substrateAccountId, + variant + ) + ) + + accountRepository.selectMetaAccount(addAccountResult.metaId) + } + } + + override suspend fun createSecretWallet( + name: String, + secret: ScanSecret, + variant: PolkadotVaultVariant + ): Result = withContext(Dispatchers.Default) { + runCatching { + val addAccountResult = when (secret) { + is ScanSecret.Seed -> createBySeed(secret.data, name) + is ScanSecret.EncryptedKeypair -> createByRawKey(secret.data, name) + } + + accountRepository.selectMetaAccount(addAccountResult.metaId) + } + } + + private suspend fun createBySeed( + secret: ByteArray, + name: String + ): AddAccountResult.SingleAccountChange = seedAddAccountRepository.addAccountWithSingleChange( + SeedAddAccountRepository.Payload( + seed = secret.toHexString(), + advancedEncryption = getAdvancedEncryption(), + addAccountType = AddAccountType.MetaAccount(name), + ) + ) + + private suspend fun createByRawKey( + secret: ByteArray, + name: String + ): AddAccountResult.SingleAccountChange = substrateKeypairAddAccountRepository.addAccountWithSingleChange( + SubstrateKeypairAddAccountRepository.Payload( + substrateKeypair = secret, + advancedEncryption = getAdvancedEncryption(), + addAccountType = AddAccountType.MetaAccount(name), + ) + ) + + private fun getAdvancedEncryption() = AdvancedEncryption.substrate(CryptoType.SR25519, substrateDerivationPaths = null) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/preview/PreviewImportParitySignerInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/preview/PreviewImportParitySignerInteractor.kt new file mode 100644 index 0000000..36c2427 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/preview/PreviewImportParitySignerInteractor.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.preview + +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview.model.ChainAccountPreview +import io.novafoundation.nova.runtime.ext.defaultComparator +import io.novafoundation.nova.runtime.ext.isSubstrateBased +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.enabledChains + +interface PreviewImportParitySignerInteractor { + + suspend fun deriveSubstrateChainAccounts(accountId: ByteArray): List +} + +class RealPreviewImportParitySignerInteractor( + private val chainRegistry: ChainRegistry +) : PreviewImportParitySignerInteractor { + + override suspend fun deriveSubstrateChainAccounts(accountId: ByteArray): List { + val substrateChains = chainRegistry.enabledChains() + .filter { it.isSubstrateBased } + + return substrateChains + .sortedWith(Chain.defaultComparator()) + .map { chain -> ChainAccountPreview(chain, accountId) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/scan/PolkadotVaultScanFormat.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/scan/PolkadotVaultScanFormat.kt new file mode 100644 index 0000000..16efcf8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/scan/PolkadotVaultScanFormat.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.scan + +import io.novafoundation.nova.feature_account_impl.domain.utils.ScanSecret +import io.novasama.substrate_sdk_android.encrypt.Sr25519 +import io.novafoundation.nova.feature_account_impl.domain.utils.SecretQrFormat +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.Sr25519SubstrateKeypairFactory +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.getPublicKeyFromSeed +import io.novasama.substrate_sdk_android.encrypt.qr.formats.SubstrateQrFormat +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.publicKeyToSubstrateAccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId + +class PolkadotVaultScanFormat( + private val substrateQrFormat: SubstrateQrFormat = SubstrateQrFormat(), + private val secretQrFormat: SecretQrFormat = SecretQrFormat() +) { + fun decode(scanResult: String): Result { + return runCatching { publicFormat(scanResult) } + .recoverCatching { secretFormat(scanResult) } + } + + private fun publicFormat(scanResult: String): ParitySignerAccount.Public { + val parsed = substrateQrFormat.decode(scanResult) + return ParitySignerAccount.Public(parsed.address.toAccountId()) + } + + private fun secretFormat(scanResult: String): ParitySignerAccount.Secret { + val parsed = secretQrFormat.decode(scanResult) + val publicKey = when (val secret = parsed.secret) { + is ScanSecret.EncryptedKeypair -> Sr25519.getPublicKeyFromSecret(secret.data) + is ScanSecret.Seed -> Sr25519SubstrateKeypairFactory.getPublicKeyFromSeed(secret.data) + } + + return ParitySignerAccount.Secret( + publicKey.publicKeyToSubstrateAccountId(), + parsed.secret + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/scan/ScanImportParitySignerInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/scan/ScanImportParitySignerInteractor.kt new file mode 100644 index 0000000..cdd48a8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/connect/scan/ScanImportParitySignerInteractor.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.scan + +import io.novafoundation.nova.feature_account_impl.domain.utils.ScanSecret +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +sealed class ParitySignerAccount(val accountId: AccountId) { + + class Public(accountId: AccountId) : ParitySignerAccount(accountId) + + class Secret(accountId: AccountId, val secret: ScanSecret) : ParitySignerAccount(accountId) +} + +interface ScanImportParitySignerInteractor { + + suspend fun decodeScanResult(scanResult: String): Result +} + +class RealScanImportParitySignerInteractor( + private val polkadotVaultScanFormat: PolkadotVaultScanFormat +) : ScanImportParitySignerInteractor { + + override suspend fun decodeScanResult(scanResult: String): Result { + return withContext(Dispatchers.Default) { + polkadotVaultScanFormat.decode(scanResult) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/sign/scan/ScanSignParitySignerInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/sign/scan/ScanSignParitySignerInteractor.kt new file mode 100644 index 0000000..c8d7895 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/sign/scan/ScanSignParitySignerInteractor.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.scan + +import io.novafoundation.nova.common.utils.dropBytes +import io.novafoundation.nova.feature_account_api.data.signer.SignerPayload +import io.novafoundation.nova.feature_account_api.data.signer.accountId +import io.novafoundation.nova.feature_account_api.data.signer.signaturePayload +import io.novasama.substrate_sdk_android.encrypt.SignatureVerifier +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.encrypt.Signer +import io.novasama.substrate_sdk_android.extensions.fromHex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface ScanSignParitySignerInteractor { + + suspend fun encodeAndVerifySignature(payload: SignerPayload, signature: String): Result +} + +class RealScanSignParitySignerInteractor : ScanSignParitySignerInteractor { + + override suspend fun encodeAndVerifySignature(payload: SignerPayload, signature: String) = withContext(Dispatchers.Default) { + runCatching { + val signaturePayload = payload.signaturePayload() + val signatureWrapper = payload.constructSignatureWrapper(signature) + + val valid = SignatureVerifier.verify(signatureWrapper, Signer.MessageHashing.SUBSTRATE, signaturePayload, payload.accountId()) + + if (!valid) { + throw IllegalArgumentException("Invalid signature") + } + + signatureWrapper.signature + } + } + + private fun SignerPayload.constructSignatureWrapper(signature: String): SignatureWrapper { + val allBytes = signature.fromHex() + val signatureBytes = when (this) { + // first byte indicates encryption type (aka MultiSignature) + is SignerPayload.Extrinsic -> allBytes.dropBytes(1) + + is SignerPayload.Raw -> allBytes + } + + return SignatureWrapper.Sr25519(signatureBytes) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/sign/show/ParitySignerSignMode.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/sign/show/ParitySignerSignMode.kt new file mode 100644 index 0000000..6a7114f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/sign/show/ParitySignerSignMode.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.show + +enum class ParitySignerSignMode { + + LEGACY, WITH_METADATA_PROOF +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/sign/show/ShowSignParitySignerInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/sign/show/ShowSignParitySignerInteractor.kt new file mode 100644 index 0000000..ad0969d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/paritySigner/sign/show/ShowSignParitySignerInteractor.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.show + +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.common.utils.windowed +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.data.signer.SignerPayload +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.multiFrame.LegacyMultiPart +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.transaction.paritySignerLegacyTxPayload +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.transaction.paritySignerTxPayloadWithProof +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.transaction.polkadotVaultSignRawPayload +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.uos.ParitySignerUOSContentCode +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.uos.ParitySignerUOSPayloadCode +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.uos.UOS +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.uos.paritySignerUOSCryptoType +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.extrinsic.signer.SignerPayloadRawWithChain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface ShowSignParitySignerInteractor { + + suspend fun qrCodeContent( + payload: SignerPayload, + mode: ParitySignerSignMode, + ): ParitySignerSignRequest +} + +class ParitySignerSignRequest( + val frames: List +) + +class RealShowSignParitySignerInteractor( + private val metadataShortenerService: MetadataShortenerService, +) : ShowSignParitySignerInteractor { + + override suspend fun qrCodeContent( + payload: SignerPayload, + mode: ParitySignerSignMode + ): ParitySignerSignRequest = withContext(Dispatchers.Default) { + val uosPayload = when (payload) { + is SignerPayload.Extrinsic -> mode.createUOSPayloadFor(payload) + is SignerPayload.Raw -> createRawMessagePayload(payload.raw) + } + + val windowed = uosPayload.windowed(QrCodeGenerator.MAX_PAYLOAD_LENGTH) + val multiFramePayloads = LegacyMultiPart.createMultiple(windowed) + + val frame = multiFramePayloads.map { it.toString(Charsets.ISO_8859_1) } + + ParitySignerSignRequest(frame) + } + + private fun createRawMessagePayload(payload: SignerPayloadRawWithChain): ByteArray { + val txPayload = payload.polkadotVaultSignRawPayload() + return UOS.createUOSPayload( + payload = txPayload, + contentCode = ParitySignerUOSContentCode.SUBSTRATE, + cryptoCode = CryptoType.SR25519.paritySignerUOSCryptoType(), + payloadCode = ParitySignerUOSPayloadCode.MESSAGE + ) + } + + private suspend fun ParitySignerSignMode.createUOSPayloadFor(payload: SignerPayload.Extrinsic): ByteArray { + return when (this) { + ParitySignerSignMode.LEGACY -> createLegacyUOSPayload(payload) + ParitySignerSignMode.WITH_METADATA_PROOF -> createUOSPayloadWithProof(payload) + } + } + + private fun createLegacyUOSPayload(payload: SignerPayload.Extrinsic): ByteArray { + val txPayload = payload.paritySignerLegacyTxPayload() + return UOS.createUOSPayload( + payload = txPayload, + contentCode = ParitySignerUOSContentCode.SUBSTRATE, + cryptoCode = CryptoType.SR25519.paritySignerUOSCryptoType(), + payloadCode = ParitySignerUOSPayloadCode.TRANSACTION + ) + } + + private suspend fun createUOSPayloadWithProof(payload: SignerPayload.Extrinsic): ByteArray { + val proof = metadataShortenerService.generateExtrinsicProof(payload.extrinsic) + val txPayload = payload.paritySignerTxPayloadWithProof(proof) + return UOS.createUOSPayload( + payload = txPayload, + contentCode = ParitySignerUOSContentCode.SUBSTRATE, + cryptoCode = CryptoType.SR25519.paritySignerUOSCryptoType(), + payloadCode = ParitySignerUOSPayloadCode.TRANSACTION_WITH_PROOF + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/scanSeed/ScanSeedInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/scanSeed/ScanSeedInteractor.kt new file mode 100644 index 0000000..d4c99c3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/scanSeed/ScanSeedInteractor.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_account_impl.domain.scanSeed + +import io.novafoundation.nova.feature_account_impl.domain.utils.SecretQrFormat +import io.novasama.substrate_sdk_android.extensions.toHexString + +interface ScanSeedInteractor { + fun decodeSeed(content: String): Result +} + +class RealScanSeedInteractor( + private val secretQrFormat: SecretQrFormat +) : ScanSeedInteractor { + + override fun decodeSeed(content: String): Result = runCatching { + secretQrFormat.decode(content) + .secret + .data + .toHexString(withPrefix = true) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/startCreateWallet/StartCreateWalletInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/startCreateWallet/StartCreateWalletInteractor.kt new file mode 100644 index 0000000..6b47f45 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/startCreateWallet/StartCreateWalletInteractor.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_account_impl.domain.startCreateWallet + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.PreCreateValidationStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface StartCreateWalletInteractor { + + suspend fun validateCanCreateBackup(): PreCreateValidationStatus + + suspend fun signInToCloud(): Result + + suspend fun isSyncWithCloudEnabled(): Boolean + + suspend fun createWalletAndSelect(name: String): Result +} + +class RealStartCreateWalletInteractor( + private val cloudBackupService: CloudBackupService, + private val addAccountInteractor: AddAccountInteractor, + private val accountRepository: AccountRepository +) : StartCreateWalletInteractor { + + override suspend fun validateCanCreateBackup(): PreCreateValidationStatus { + return cloudBackupService.validateCanCreateBackup() + } + + override suspend fun signInToCloud(): Result { + return cloudBackupService.signInToCloud() + } + + override suspend fun isSyncWithCloudEnabled(): Boolean { + return cloudBackupService.session.isSyncWithCloudEnabled() + } + + override suspend fun createWalletAndSelect(name: String): Result { + return withContext(Dispatchers.Default) { + addAccountInteractor.createMetaAccountWithRecommendedSettings(AddAccountType.MetaAccount(name)) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/utils/ScanSecret.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/utils/ScanSecret.kt new file mode 100644 index 0000000..2030b95 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/utils/ScanSecret.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_impl.domain.utils + +sealed class ScanSecret(val data: ByteArray) { + + class Seed(data: ByteArray) : ScanSecret(data) + + class EncryptedKeypair(data: ByteArray) : ScanSecret(data) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/utils/SecretQrFormat.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/utils/SecretQrFormat.kt new file mode 100644 index 0000000..a99d00f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/utils/SecretQrFormat.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_account_impl.domain.utils + +import io.novasama.substrate_sdk_android.encrypt.qr.QrFormat +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString + +private const val SECRET_PREFIX = "secret" +private const val SECRET_DELIMITER = ":" +private const val PARTS_MIN = 3 +private const val PARTS_MAX = 4 + +class SecretQrFormat : QrFormat { + + class Payload( + val secret: ScanSecret, + val genesisHash: String, + val name: String? = null + ) + + override fun encode(payload: Payload): String { + val secretHex = payload.secret.data.toHexString(withPrefix = true) + val genesisHash = payload.genesisHash + + val parts = listOfNotNull( + SECRET_PREFIX, + secretHex, + genesisHash, + payload.name + ) + + return parts.joinToString(SECRET_DELIMITER) + } + + override fun decode(qrContent: String): Payload { + val parts = qrContent.split(SECRET_DELIMITER) + + if (parts.size !in PARTS_MIN..PARTS_MAX) { + throw QrFormat.InvalidFormatException("Invalid parts count: ${parts.size}") + } + + val (prefix, secretEncoded, genesisHash) = parts + + if (prefix != SECRET_PREFIX) { + throw QrFormat.InvalidFormatException("Wrong prefix: $prefix") + } + + val secretBytes = secretEncoded.fromHex() + + val secret = when { + secretBytes.isSubstrateSeed() -> ScanSecret.Seed(secretBytes) + secretBytes.isSubstrateKeypair() -> ScanSecret.EncryptedKeypair(secretBytes) + else -> throw QrFormat.InvalidFormatException("Invalid secret length: ${secretBytes.size}. Expected 32 or 64 bytes.") + } + + val name = if (parts.size == PARTS_MAX) parts.last() else null + + return Payload( + secret = secret, + genesisHash = genesisHash, + name = name + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/utils/SecretUtils.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/utils/SecretUtils.kt new file mode 100644 index 0000000..ed21705 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/utils/SecretUtils.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_impl.domain.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex + +fun String.isSubstrateSeed(): Boolean { + return fromHex().isSubstrateSeed() +} + +fun ByteArray.isSubstrateSeed(): Boolean { + return size == 32 +} + +fun String.isSubstrateKeypair(): Boolean { + return fromHex().isSubstrateKeypair() +} + +fun ByteArray.isSubstrateKeypair(): Boolean { + return size == 64 +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/watchOnly/change/ChangeWatchAccountInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/watchOnly/change/ChangeWatchAccountInteractor.kt new file mode 100644 index 0000000..007f750 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/watchOnly/change/ChangeWatchAccountInteractor.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.domain.watchOnly.change + +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.watchOnly.WatchOnlyAddAccountRepository +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface ChangeWatchAccountInteractor { + + suspend fun changeChainAccount( + metaId: Long, + chain: Chain, + address: String + ): Result<*> +} + +class RealChangeWatchAccountInteractor( + private val watchOnlyAddAccountRepository: WatchOnlyAddAccountRepository +) : ChangeWatchAccountInteractor { + + override suspend fun changeChainAccount( + metaId: Long, + chain: Chain, + address: String + ): Result<*> = runCatching { + val accountId = chain.accountIdOf(address) + + watchOnlyAddAccountRepository.addAccount( + WatchOnlyAddAccountRepository.Payload.ChainAccount( + metaId = metaId, + chainId = chain.id, + accountId = accountId + ) + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/watchOnly/create/CreateWatchWalletInteractor.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/watchOnly/create/CreateWatchWalletInteractor.kt new file mode 100644 index 0000000..d8efefc --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/watchOnly/create/CreateWatchWalletInteractor.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_impl.domain.watchOnly.create + +import io.novafoundation.nova.common.utils.ethereumAddressToAccountId +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.addAccountWithSingleChange +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.WatchOnlyRepository +import io.novafoundation.nova.feature_account_impl.data.repository.WatchWalletSuggestion +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.watchOnly.WatchOnlyAddAccountRepository +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId + +interface CreateWatchWalletInteractor { + + suspend fun createWallet( + name: String, + substrateAddress: String, + evmAddress: String + ): Result<*> + + fun suggestions(): List +} + +class RealCreateWatchWalletInteractor( + private val repository: WatchOnlyRepository, + private val watchOnlyAddAccountRepository: WatchOnlyAddAccountRepository, + private val accountRepository: AccountRepository, +) : CreateWatchWalletInteractor { + + override suspend fun createWallet(name: String, substrateAddress: String, evmAddress: String) = runCatching { + val substrateAccountId = substrateAddress.toAccountId() + val evmAccountId = evmAddress.takeIf { it.isNotEmpty() }?.ethereumAddressToAccountId() + + val addAccountResult = watchOnlyAddAccountRepository.addAccountWithSingleChange( + WatchOnlyAddAccountRepository.Payload.MetaAccount( + name, + substrateAccountId, + evmAccountId + ) + ) + + accountRepository.selectMetaAccount(addAccountResult.metaId) + } + + override fun suggestions(): List { + return repository.watchWalletSuggestions() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/AccountRouter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/AccountRouter.kt new file mode 100644 index 0000000..37a40a5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/AccountRouter.kt @@ -0,0 +1,110 @@ +package io.novafoundation.nova.feature_account_impl.presentation + +import io.novafoundation.nova.common.navigation.DelayedNavigation +import io.novafoundation.nova.common.navigation.PinRequired +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.common.navigation.SecureRouter +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionModePayload +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model.ScanSignParitySignerPayload +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletPayload + +interface AccountRouter : SecureRouter, ReturnableRouter { + + fun openWelcomeScreen() + + fun openMain() + + fun openCreatePincode() + + fun openMnemonicScreen(accountName: String?, payload: AddAccountPayload) + + fun openAdvancedSettings(payload: AdvancedEncryptionModePayload) + + fun openConfirmMnemonicOnCreate(confirmMnemonicPayload: ConfirmMnemonicPayload) + + fun openWallets() + + fun openSwitchWallet() + + fun openDelegatedAccountsUpdates() + + fun openNodes() + + fun openCreateWallet(payload: StartCreateWalletPayload) + + fun openWalletDetails(metaId: Long) + + fun openNodeDetails(nodeId: Int) + + fun openAddNode() + + fun openChangeWatchAccount(payload: AddAccountPayload.ChainAccount) + + @PinRequired + fun getExportMnemonicDelayedNavigation(exportPayload: ExportPayload.ChainAccount): DelayedNavigation + + @PinRequired + fun getExportSeedDelayedNavigation(exportPayload: ExportPayload.ChainAccount): DelayedNavigation + + @PinRequired + fun getExportJsonDelayedNavigation(exportPayload: ExportPayload): DelayedNavigation + + fun exportJsonAction(exportPayload: ExportPayload) + + fun openImportAccountScreen(payload: ImportAccountPayload) + + fun openImportOptionsScreen() + + fun returnToWallet() + + fun finishExportFlow() + + fun openScanImportParitySigner(payload: ParitySignerStartPayload) + + fun openPreviewImportParitySigner(payload: ParitySignerAccountPayload) + + fun openFinishImportParitySigner(payload: ParitySignerAccountPayload) + + fun openScanParitySignerSignature(payload: ScanSignParitySignerPayload) + + fun finishParitySignerFlow() + + fun openAddLedgerChainAccountFlow(addAccountPayload: AddAccountPayload.ChainAccount) + + fun openCreateCloudBackupPassword(walletName: String) + + fun restoreCloudBackup() + + fun openSyncWalletsBackupPassword() + + fun openChangeBackupPasswordFlow() + + fun openRestoreBackupPassword() + + fun openChangeBackupPassword() + + fun openManualBackupSelectAccount(metaId: Long) + + fun openManualBackupConditions(payload: ManualBackupCommonPayload) + + fun openManualBackupSecrets(payload: ManualBackupCommonPayload) + + fun openManualBackupAdvancedSecrets(payload: ManualBackupCommonPayload) + + fun openChainAddressSelector(chainId: String, accountId: ByteArray) + + fun closeChainAddressesSelector() + + fun finishApp() + + fun openAddGenericEvmAddressSelectLedger(metaId: Long) + + fun openMainWithFinishMultisigTransaction(accountWasSwitched: Boolean) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/addressActions/RealAddressActionsMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/addressActions/RealAddressActionsMixin.kt new file mode 100644 index 0000000..2be3f19 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/addressActions/RealAddressActionsMixin.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.addressActions + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressFormat +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.CopyValueMixin +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.view.ChipLabelModel +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin.Payload +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin.Presentation +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject + +@FeatureScope +class AddressActionsMixinFactory @Inject constructor( + private val copyValueMixin: CopyValueMixin, + private val iconGenerator: AddressIconGenerator, + private val addressSchemeFormatter: AddressSchemeFormatter +) : AddressActionsMixin.Factory { + + override fun create(coroutineScope: CoroutineScope): Presentation { + return RealAddressActionsMixin( + coroutineScope = coroutineScope, + copyValueMixin = copyValueMixin, + iconGenerator = iconGenerator, + addressSchemeFormatter = addressSchemeFormatter + ) + } +} + +private class RealAddressActionsMixin( + coroutineScope: CoroutineScope, + private val copyValueMixin: CopyValueMixin, + private val iconGenerator: AddressIconGenerator, + private val addressSchemeFormatter: AddressSchemeFormatter +) : Presentation, CoroutineScope by coroutineScope { + + override val showAddressActionsEvent = MutableLiveData>() + + override fun showAddressActions(accountId: AccountId, addressFormat: AddressFormat) = launchUnit(Dispatchers.Default) { + val addressModel = iconGenerator.createAccountAddressModel(addressFormat, accountId) + val payload = Payload(addressModel, addressFormat.scheme.addressLabel()) + + showAddressActionsEvent.postValue(payload.event()) + } + + override fun copyValue(payload: Payload) { + copyValueMixin.copyValue(payload.addressModel.address) + } + + private fun AddressScheme.addressLabel(): ChipLabelModel { + val label = addressSchemeFormatter.addressLabel(this) + return ChipLabelModel(label) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/AdvancedEncryptionFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/AdvancedEncryptionFragment.kt new file mode 100644 index 0000000..60f4e6f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/AdvancedEncryptionFragment.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption + +import android.content.Context +import android.os.Bundle + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.input.Input +import io.novafoundation.nova.common.utils.onTextChanged +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.useInputValue +import io.novafoundation.nova.common.view.InputField +import io.novafoundation.nova.common.view.LabeledTextView +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentAdvancedEncryptionBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.view.advanced.encryption.EncryptionTypeChooserBottomSheetDialog +import io.novafoundation.nova.feature_account_impl.presentation.view.advanced.encryption.model.CryptoTypeModel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class AdvancedEncryptionFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "CreateAccountFragment.payload" + + fun getBundle(payload: AdvancedEncryptionModePayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentAdvancedEncryptionBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun initViews() { + binder.advancedEncryptionToolbar.setHomeButtonListener { viewModel.homeButtonClicked() } + + binder.advancedEncryptionApply.setOnClickListener { + viewModel.applyClicked() + } + + binder.advancedEncryptionSubstrateCryptoType.setOnClickListener { + viewModel.substrateEncryptionClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .advancedEncryptionComponentFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: AdvancedEncryptionViewModel) { + observeValidations(viewModel) + + binder.advancedEncryptionApply.setVisible(viewModel.applyVisible) + + viewModel.substrateCryptoTypeInput.bindTo(binder.advancedEncryptionSubstrateCryptoType) + viewModel.substrateDerivationPathInput.bindTo( + binder.advancedEncryptionSubstrateDerivationPath, + viewModel::substrateDerivationPathChanged + ) + + viewModel.ethereumCryptoTypeInput.bindTo(binder.advancedEncryptionEthereumCryptoType) + viewModel.ethereumDerivationPathInput.bindTo( + binder.advancedEncryptionEthereumDerivationPath, + viewModel::ethereumDerivationPathChanged + ) + + viewModel.showSubstrateEncryptionTypeChooserEvent.observeEvent { + showEncryptionChooser(requireContext(), it) + } + } + + private fun Flow>.bindTo(view: InputField, onTextChanged: (String) -> Unit) { + observe { input -> + with(view) { + useInputValue(input) { + if (content.text.toString() != it) { + content.setText(it) + } + } + } + } + + view.content.onTextChanged(onTextChanged) + } + + private fun Flow>.bindTo(view: LabeledTextView) { + observe { input -> + with(view) { + useInputValue(input) { setMessage(it.name) } + } + } + } + + private fun showEncryptionChooser( + context: Context, + payload: DynamicListBottomSheet.Payload, + ) { + EncryptionTypeChooserBottomSheetDialog( + context = context, + payload = payload, + onClicked = { _, item -> viewModel.substrateEncryptionChanged(item) } + ).show() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/AdvancedEncryptionModePayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/AdvancedEncryptionModePayload.kt new file mode 100644 index 0000000..849fbca --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/AdvancedEncryptionModePayload.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +sealed class AdvancedEncryptionModePayload : Parcelable { + + @Parcelize + class Change(val addAccountPayload: AddAccountPayload) : AdvancedEncryptionModePayload() + + @Parcelize + class View( + val metaAccountId: Long, + val chainId: ChainId, + val hideDerivationPaths: Boolean = false + ) : AdvancedEncryptionModePayload() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/AdvancedEncryptionViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/AdvancedEncryptionViewModel.kt new file mode 100644 index 0000000..979e33a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/AdvancedEncryptionViewModel.kt @@ -0,0 +1,152 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.input.Input +import io.novafoundation.nova.common.utils.input.ifModifiable +import io.novafoundation.nova.common.utils.input.map +import io.novafoundation.nova.common.utils.input.modifyIfNotNull +import io.novafoundation.nova.common.utils.input.modifyInput +import io.novafoundation.nova.common.utils.input.valueOrNull +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_impl.data.mappers.mapCryptoTypeToCryptoTypeModel +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.AdvancedEncryptionValidationPayload +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.AdvancedEncryptionValidationSystem +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.mapAdvancedEncryptionValidationFailureToUi +import io.novafoundation.nova.feature_account_impl.domain.common.AdvancedEncryptionSelectionStoreProvider +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.view.advanced.encryption.model.CryptoTypeModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class AdvancedEncryptionViewModel( + private val router: AccountRouter, + private val payload: AdvancedEncryptionModePayload, + private val interactor: AdvancedEncryptionInteractor, + private val resourceManager: ResourceManager, + private val validationSystem: AdvancedEncryptionValidationSystem, + private val validationExecutor: ValidationExecutor, + private val advancedEncryptionSelectionStoreProvider: AdvancedEncryptionSelectionStoreProvider +) : BaseViewModel(), + Validatable by validationExecutor { + + private val advancedEncryptionSelectionStore = async { advancedEncryptionSelectionStoreProvider.getSelectionStore(this) } + + private val encryptionTypes = getCryptoTypeModels() + + private val _substrateCryptoTypeInput = singleReplaySharedFlow>() + val substrateCryptoTypeInput: Flow> = _substrateCryptoTypeInput + + private val _substrateDerivationPathInput = singleReplaySharedFlow>() + val substrateDerivationPathInput: Flow> = _substrateDerivationPathInput + + private val _ethereumCryptoTypeInput = singleReplaySharedFlow>() + val ethereumCryptoTypeInput: Flow> = _ethereumCryptoTypeInput + + private val _ethereumDerivationPathInput = singleReplaySharedFlow>() + val ethereumDerivationPathInput: Flow> = _ethereumDerivationPathInput + + val showSubstrateEncryptionTypeChooserEvent = MutableLiveData>>() + + val applyVisible = payload is AdvancedEncryptionModePayload.Change + + init { + loadInitialState() + } + + private fun loadInitialState() = launch { + val initialState = interactor.getInitialInputState(payload) + + val latestState = advancedEncryptionSelectionStore().getCurrentSelection() + + val initialSubstrateType = initialState.substrateCryptoType.modifyIfNotNull(latestState?.substrateCryptoType) + val initialSubstrateDerivationPath = initialState.substrateDerivationPath.modifyIfNotNull(latestState?.derivationPaths?.substrate) + val initialEthereumType = initialState.ethereumCryptoType.modifyIfNotNull(latestState?.ethereumCryptoType) + val initialEthereumDerivationPath = initialState.ethereumDerivationPath.modifyIfNotNull(latestState?.derivationPaths?.ethereum) + + _substrateCryptoTypeInput.emit(initialSubstrateType.map(::encryptionTypeToUi)) + _substrateDerivationPathInput.emit(initialSubstrateDerivationPath) + _ethereumCryptoTypeInput.emit(initialEthereumType.map(::encryptionTypeToUi)) + _ethereumDerivationPathInput.emit(initialEthereumDerivationPath) + } + + fun substrateDerivationPathChanged(new: String) = _substrateDerivationPathInput.modifyInputAsync(new) + + fun ethereumDerivationPathChanged(new: String) = _ethereumDerivationPathInput.modifyInputAsync(new) + + fun substrateEncryptionClicked() = substrateCryptoTypeInput.ifModifiable { current -> + showSubstrateEncryptionTypeChooserEvent.value = Event(DynamicListBottomSheet.Payload(encryptionTypes, current)) + } + + fun substrateEncryptionChanged(newCryptoType: CryptoTypeModel) { + launch { + _substrateCryptoTypeInput.modifyInput(newCryptoType) + } + } + + fun homeButtonClicked() { + router.back() + } + + fun applyClicked() = launch { + val payload = AdvancedEncryptionValidationPayload( + substrateDerivationPathInput = substrateDerivationPathInput.first(), + ethereumDerivationPathInput = ethereumDerivationPathInput.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { mapAdvancedEncryptionValidationFailureToUi(resourceManager, it) } + ) { + respondWithCurrentState() + } + } + + private fun respondWithCurrentState() = launch { + val advancedEncryption = AdvancedEncryption( + substrateCryptoType = substrateCryptoTypeInput.first().valueOrNull?.cryptoType, + ethereumCryptoType = ethereumCryptoTypeInput.first().valueOrNull?.cryptoType, + derivationPaths = AdvancedEncryption.DerivationPaths( + substrate = substrateDerivationPathInput.first().valueOrNull, + ethereum = ethereumDerivationPathInput.first().valueOrNull + ) + ) + + advancedEncryptionSelectionStore().updateSelection(advancedEncryption) + + router.back() + } + + private fun getCryptoTypeModels(): List { + val types = interactor.getCryptoTypes() + + return types.map { mapCryptoTypeToCryptoTypeModel(resourceManager, it) } + } + + private fun Flow>.ifModifiable(action: suspend (I) -> Unit) { + launch { + first().ifModifiable { action(it) } + } + } + + private fun MutableSharedFlow>.modifyInputAsync(newValue: I) { + launch { + modifyInput(newValue) + } + } + + private fun encryptionTypeToUi(encryptionType: CryptoType): CryptoTypeModel = mapCryptoTypeToCryptoTypeModel(resourceManager, encryptionType) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/di/AdvancedEncryptionComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/di/AdvancedEncryptionComponent.kt new file mode 100644 index 0000000..5f3f854 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/di/AdvancedEncryptionComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionFragment +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionModePayload + +@Subcomponent( + modules = [ + AdvancedEncryptionModule::class + ] +) +@ScreenScope +interface AdvancedEncryptionComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AdvancedEncryptionModePayload + ): AdvancedEncryptionComponent + } + + fun inject(fragment: AdvancedEncryptionFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/di/AdvancedEncryptionModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/di/AdvancedEncryptionModule.kt new file mode 100644 index 0000000..e159641 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/di/AdvancedEncryptionModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.AdvancedEncryptionValidationSystem +import io.novafoundation.nova.feature_account_impl.domain.common.AdvancedEncryptionSelectionStoreProvider +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionModePayload +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionViewModel + +@Module(includes = [ViewModelModule::class, ValidationsModule::class]) +class AdvancedEncryptionModule { + + @Provides + @IntoMap + @ViewModelKey(AdvancedEncryptionViewModel::class) + fun provideViewModel( + router: AccountRouter, + payload: AdvancedEncryptionModePayload, + interactor: AdvancedEncryptionInteractor, + resourceManager: ResourceManager, + validationSystem: AdvancedEncryptionValidationSystem, + validationExecutor: ValidationExecutor, + advancedEncryptionSelectionStoreProvider: AdvancedEncryptionSelectionStoreProvider + ): ViewModel { + return AdvancedEncryptionViewModel( + router, + payload, + interactor, + resourceManager, + validationSystem, + validationExecutor, + advancedEncryptionSelectionStoreProvider + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AdvancedEncryptionViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AdvancedEncryptionViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/di/ValidationsModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/di/ValidationsModule.kt new file mode 100644 index 0000000..0f6c633 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/advancedEncryption/di/ValidationsModule.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.di + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.validation.from +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.AdvancedEncryptionValidation +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.AdvancedEncryptionValidationSystem +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.EthereumDerivationPathValidation +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.valiadtion.SubstrateDerivationPathValidation + +@Module +class ValidationsModule { + + @Provides + @ScreenScope + @IntoSet + fun substrateDerivationPathValidation(): AdvancedEncryptionValidation = SubstrateDerivationPathValidation() + + @Provides + @ScreenScope + @IntoSet + fun ethereumDerivationPathValidation(): AdvancedEncryptionValidation = EthereumDerivationPathValidation() + + @Provides + @ScreenScope + fun provideValidationSystem( + validations: Set<@JvmSuppressWildcards AdvancedEncryptionValidation> + ) = AdvancedEncryptionValidationSystem.from(validations) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/MetaAccountListingMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/MetaAccountListingMixin.kt new file mode 100644 index 0000000..d9d4f03 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/MetaAccountListingMixin.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing + +import kotlinx.coroutines.flow.Flow + +interface MetaAccountListingMixin { + + val metaAccountsFlow: Flow> +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/MetaAccountValidForTransactionListingMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/MetaAccountValidForTransactionListingMixin.kt new file mode 100644 index 0000000..db96bd5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/MetaAccountValidForTransactionListingMixin.kt @@ -0,0 +1,104 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing + +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Filter +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.SelectedAccountPayload +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map + +class MetaAccountValidForTransactionListingMixinFactory( + private val walletUiUseCase: WalletUiUseCase, + private val resourceManager: ResourceManager, + private val accountTypePresentationMapper: MetaAccountTypePresentationMapper, + private val chainRegistry: ChainRegistry, + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor +) { + + fun create( + coroutineScope: CoroutineScope, + chainId: ChainId, + selectedAccount: SelectedAccountPayload?, + metaAccountFilter: Filter + ): MetaAccountListingMixin { + return MetaAccountValidForTransactionListingMixin( + walletUiUseCase = walletUiUseCase, + resourceManager = resourceManager, + metaAccountGroupingInteractor = metaAccountGroupingInteractor, + chainRegistry = chainRegistry, + chainId = chainId, + selectedAccount = selectedAccount, + accountTypePresentationMapper = accountTypePresentationMapper, + metaAccountFilter = metaAccountFilter, + coroutineScope = coroutineScope + ) + } +} + +private class MetaAccountValidForTransactionListingMixin( + private val walletUiUseCase: WalletUiUseCase, + private val resourceManager: ResourceManager, + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + private val chainRegistry: ChainRegistry, + private val chainId: ChainId, + private val selectedAccount: SelectedAccountPayload?, + private val accountTypePresentationMapper: MetaAccountTypePresentationMapper, + private val metaAccountFilter: Filter, + coroutineScope: CoroutineScope, +) : MetaAccountListingMixin, WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + private val chainFlow by coroutineScope.lazyAsync { chainRegistry.getChain(chainId) } + + override val metaAccountsFlow = metaAccountGroupingInteractor.getMetaAccountsWithFilter(metaAccountFilter) + .map { list -> + list.toListWithHeaders( + keyMapper = { type, _ -> accountTypePresentationMapper.mapMetaAccountTypeToUi(type) }, + valueMapper = { mapMetaAccountToUi(it) } + ) + } + .shareInBackground() + + private suspend fun mapMetaAccountToUi(metaAccount: MetaAccount): AccountUi { + val icon = walletUiUseCase.walletIcon(metaAccount) + + val chain = chainFlow.await() + val chainAddress = metaAccount.addressIn(chain) + + return AccountUi( + id = metaAccount.id, + title = metaAccount.name, + subtitle = mapSubtitle(chainAddress, chain), + isSelected = isSelected(metaAccount, chainAddress), + isClickable = chainAddress != null, + picture = icon, + chainIcon = null, + updateIndicator = false, + subtitleIconRes = if (chainAddress == null) R.drawable.ic_warning_filled else null, + isEditable = false + ) + } + + private fun isSelected(metaAccount: MetaAccount, chainAddress: String?): Boolean { + return when (selectedAccount) { + is SelectedAccountPayload.MetaAccount -> selectedAccount.metaId == metaAccount.id + is SelectedAccountPayload.Address -> selectedAccount.address == chainAddress + null -> false + } + } + + private fun mapSubtitle(address: String?, chain: Chain): String { + return address ?: resourceManager.getString(R.string.account_chain_not_found, chain.name) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/MetaAccountWithBalanceListingMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/MetaAccountWithBalanceListingMixin.kt new file mode 100644 index 0000000..651e5bb --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/MetaAccountWithBalanceListingMixin.kt @@ -0,0 +1,131 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing + +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountListingItem +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.chain.iconOrFallback +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class MetaAccountWithBalanceListingMixinFactory( + private val walletUiUseCase: WalletUiUseCase, + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + private val accountTypePresentationMapper: MetaAccountTypePresentationMapper, + private val multisigFormatter: MultisigFormatter, + private val proxyFormatter: ProxyFormatter, +) { + + fun create( + coroutineScope: CoroutineScope, + showUpdatedMetaAccountsBadge: Boolean = true, + metaAccountSelectedFlow: Flow = flowOf { SelectedMetaAccountState.CurrentlySelected } + ): MetaAccountListingMixin { + return MetaAccountWithBalanceListingMixin( + walletUiUseCase = walletUiUseCase, + metaAccountGroupingInteractor = metaAccountGroupingInteractor, + coroutineScope = coroutineScope, + metaAccountSelectedFlow = metaAccountSelectedFlow, + accountTypePresentationMapper = accountTypePresentationMapper, + proxyFormatter = proxyFormatter, + showUpdatedMetaAccountsBadge = showUpdatedMetaAccountsBadge, + multisigFormatter = multisigFormatter + ) + } +} + +private class MetaAccountWithBalanceListingMixin( + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + private val walletUiUseCase: WalletUiUseCase, + private val metaAccountSelectedFlow: Flow, + private val accountTypePresentationMapper: MetaAccountTypePresentationMapper, + private val proxyFormatter: ProxyFormatter, + private val multisigFormatter: MultisigFormatter, + private val showUpdatedMetaAccountsBadge: Boolean, + coroutineScope: CoroutineScope, +) : MetaAccountListingMixin, CoroutineScope by coroutineScope { + + override val metaAccountsFlow = combine( + metaAccountGroupingInteractor.metaAccountsWithTotalBalanceFlow(), + metaAccountSelectedFlow + ) { groupedList, selected -> + groupedList.toListWithHeaders( + keyMapper = { type, _ -> accountTypePresentationMapper.mapMetaAccountTypeToUi(type) }, + valueMapper = { mapMetaAccountToUi(it, selected) } + ) + } + .shareInBackground() + + private suspend fun mapMetaAccountToUi(metaAccountWithBalance: MetaAccountListingItem, selected: SelectedMetaAccountState) = + with(metaAccountWithBalance) { + AccountUi( + id = metaAccount.id, + title = metaAccount.name, + subtitle = formatSubtitle(), + isSelected = selected.isSelected(metaAccount), + isEditable = metaAccount.isEditable(), + isClickable = true, + picture = walletUiUseCase.walletIcon(metaAccount), + chainIcon = chainIcon(), + updateIndicator = hasUpdates && showUpdatedMetaAccountsBadge, + subtitleIconRes = null + ) + } + + private fun MetaAccountListingItem.chainIcon(): Icon? { + return when (this) { + is MetaAccountListingItem.Proxied -> proxyChain.iconOrFallback() + + is MetaAccountListingItem.Multisig -> singleChain?.iconOrFallback() + + is MetaAccountListingItem.TotalBalance -> null + } + } + + private suspend fun MetaAccountListingItem.formatSubtitle(): CharSequence = when (this) { + is MetaAccountListingItem.Proxied -> formatSubtitle() + is MetaAccountListingItem.TotalBalance -> formatSubtitle() + is MetaAccountListingItem.Multisig -> formatSubtitle() + } + + private suspend fun MetaAccountListingItem.Proxied.formatSubtitle(): CharSequence { + return proxyFormatter.mapProxyMetaAccountSubtitle( + proxyMetaAccount.name, + proxyFormatter.makeAccountDrawable(proxyMetaAccount), + metaAccount.proxy + ) + } + + private suspend fun MetaAccountListingItem.Multisig.formatSubtitle(): CharSequence { + return multisigFormatter.formatSignatorySubtitle(signatory) + } + + private fun MetaAccountListingItem.TotalBalance.formatSubtitle(): String { + return totalBalance.formatAsCurrency(currency) + } + + private fun MetaAccount.isEditable(): Boolean { + return when (type) { + LightMetaAccount.Type.SECRETS, + LightMetaAccount.Type.WATCH_ONLY, + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.LEDGER_LEGACY, + LightMetaAccount.Type.LEDGER, + LightMetaAccount.Type.POLKADOT_VAULT -> true + + LightMetaAccount.Type.PROXIED, + LightMetaAccount.Type.MULTISIG -> false + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/RealMetaAccountTypePresentationMapper.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/RealMetaAccountTypePresentationMapper.kt new file mode 100644 index 0000000..410585b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/RealMetaAccountTypePresentationMapper.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.ChipLabelModel +import io.novafoundation.nova.common.view.TintedIcon +import io.novafoundation.nova.common.view.TintedIcon.Companion.asTintedIcon +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.asPolkadotVaultVariantOrThrow +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountChipGroupRvItem +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker + +class RealMetaAccountTypePresentationMapper( + private val resourceManager: ResourceManager, + private val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + private val ledgerMigrationTracker: LedgerMigrationTracker, +) : MetaAccountTypePresentationMapper { + + override suspend fun mapMetaAccountTypeToUi(type: LightMetaAccount.Type): AccountChipGroupRvItem? { + return mapTypeToChipLabel(type)?.let(::AccountChipGroupRvItem) + } + + override suspend fun mapTypeToChipLabel(type: LightMetaAccount.Type): ChipLabelModel? { + var ledgerGenericAvailable: Boolean? = null + + // Cache result of `ledgerMigrationTracker.anyChainSupportsMigrationApp()` in the method scope + val genericLedgerAvailabilityChecker: GenericLedgerAvailabilityChecker = { + if (ledgerGenericAvailable == null) { + ledgerGenericAvailable = ledgerMigrationTracker.anyChainSupportsMigrationApp() + } + + ledgerGenericAvailable!! + } + + val icon = iconFor(type, genericLedgerAvailabilityChecker) + + val label = when (type) { + LightMetaAccount.Type.SECRETS -> null + LightMetaAccount.Type.WATCH_ONLY -> resourceManager.getString(R.string.account_watch_only) + + LightMetaAccount.Type.PARITY_SIGNER, LightMetaAccount.Type.POLKADOT_VAULT -> { + val config = polkadotVaultVariantConfigProvider.variantConfigFor(type.asPolkadotVaultVariantOrThrow()) + resourceManager.getString(config.common.nameRes) + } + + LightMetaAccount.Type.LEDGER_LEGACY -> if (genericLedgerAvailabilityChecker()) { + resourceManager.getString(R.string.accounts_ledger_legacy) + } else { + resourceManager.getString(R.string.common_ledger) + } + + LightMetaAccount.Type.LEDGER -> resourceManager.getString(R.string.common_ledger) + + LightMetaAccount.Type.PROXIED -> resourceManager.getString(R.string.account_proxieds) + + LightMetaAccount.Type.MULTISIG -> resourceManager.getString(R.string.account_multisig_group_label) + } + + return if (icon != null && label != null) { + ChipLabelModel(label, icon) + } else { + null + } + } + + override suspend fun iconFor(type: LightMetaAccount.Type): TintedIcon? { + return iconFor(type) { ledgerMigrationTracker.anyChainSupportsMigrationApp() } + } + + private suspend fun iconFor( + type: LightMetaAccount.Type, + genericLedgerAvailable: GenericLedgerAvailabilityChecker + ): TintedIcon? { + return when (type) { + LightMetaAccount.Type.SECRETS -> null + LightMetaAccount.Type.WATCH_ONLY -> R.drawable.ic_watch_only_filled.asTintedIcon(canApplyOwnTint = true) + LightMetaAccount.Type.PARITY_SIGNER, LightMetaAccount.Type.POLKADOT_VAULT -> { + val config = polkadotVaultVariantConfigProvider.variantConfigFor(type.asPolkadotVaultVariantOrThrow()) + + config.common.iconRes.asTintedIcon(canApplyOwnTint = true) + } + + LightMetaAccount.Type.LEDGER_LEGACY -> if (genericLedgerAvailable()) { + R.drawable.ic_ledger_legacy.asTintedIcon(canApplyOwnTint = false) + } else { + R.drawable.ic_ledger.asTintedIcon(canApplyOwnTint = true) + } + + LightMetaAccount.Type.LEDGER -> R.drawable.ic_ledger.asTintedIcon(canApplyOwnTint = true) + LightMetaAccount.Type.PROXIED -> R.drawable.ic_proxy.asTintedIcon(canApplyOwnTint = true) + + LightMetaAccount.Type.MULTISIG -> R.drawable.ic_multisig.asTintedIcon(canApplyOwnTint = true) + } + } +} + +private typealias GenericLedgerAvailabilityChecker = suspend () -> Boolean diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/SelectedMetaAccountState.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/SelectedMetaAccountState.kt new file mode 100644 index 0000000..adb2adc --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/SelectedMetaAccountState.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount + +sealed interface SelectedMetaAccountState { + + fun isSelected(metaAccount: MetaAccount): Boolean + + object CurrentlySelected : SelectedMetaAccountState { + + override fun isSelected(metaAccount: MetaAccount): Boolean { + return metaAccount.isSelected + } + } + + data class Specified(val ids: Set) : SelectedMetaAccountState { + + override fun isSelected(metaAccount: MetaAccount): Boolean { + return ids.contains(metaAccount.id) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/DelegatedMetaAccountUpdatesListingMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/DelegatedMetaAccountUpdatesListingMixin.kt new file mode 100644 index 0000000..8d115d9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/DelegatedMetaAccountUpdatesListingMixin.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated + +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountListingMixin +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated.DelegatedMetaAccountUpdatesListingMixin.FilterType +import kotlinx.coroutines.flow.Flow + +interface DelegatedMetaAccountUpdatesListingMixin : MetaAccountListingMixin { + + sealed interface FilterType { + data object Proxied : FilterType + data object Multisig : FilterType + class UserIgnored(val overriddenFilter: FilterType) : FilterType + } + + val accountTypeFilter: Flow + + fun filterBy(type: FilterType) +} + +fun FilterType.filter(account: MetaAccount): Boolean = when (this) { + FilterType.Proxied -> account.type == LightMetaAccount.Type.PROXIED + FilterType.Multisig -> account.type == LightMetaAccount.Type.MULTISIG + is FilterType.UserIgnored -> overriddenFilter.filter(account) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/FormatterConstants.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/FormatterConstants.kt new file mode 100644 index 0000000..3040b55 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/FormatterConstants.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated + +const val SUBTITLE_ICON_SIZE_DP = 16 diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/RealDelegatedMetaAccountUpdatesListingMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/RealDelegatedMetaAccountUpdatesListingMixin.kt new file mode 100644 index 0000000..cb6acb0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/RealDelegatedMetaAccountUpdatesListingMixin.kt @@ -0,0 +1,160 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated + +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.filterValueList +import io.novafoundation.nova.common.utils.isAllEquals +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.common.utils.withAlphaDrawable +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.model.AccountDelegation +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.getChainOrNull +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountTitleGroupRvItem +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.chain.iconOrFallback +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated.DelegatedMetaAccountUpdatesListingMixin.FilterType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged + +class DelegatedMetaAccountUpdatesListingMixinFactory( + private val walletUiUseCase: WalletUiUseCase, + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + private val proxyFormatter: ProxyFormatter, + private val multisigFormatter: MultisigFormatter, + private val resourceManager: ResourceManager +) { + + fun create(coroutineScope: CoroutineScope): DelegatedMetaAccountUpdatesListingMixin { + return RealDelegatedMetaAccountUpdatesListingMixin( + walletUiUseCase = walletUiUseCase, + metaAccountGroupingInteractor = metaAccountGroupingInteractor, + proxyFormatter = proxyFormatter, + multisigFormatter = multisigFormatter, + resourceManager = resourceManager, + coroutineScope = coroutineScope + ) + } +} + +private const val DISABLED_ICON_ALPHA = 0.56f +private const val ENABLED_ICON_ALPHA = 1.0f + +private class RealDelegatedMetaAccountUpdatesListingMixin( + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + private val walletUiUseCase: WalletUiUseCase, + private val proxyFormatter: ProxyFormatter, + private val multisigFormatter: MultisigFormatter, + private val resourceManager: ResourceManager, + coroutineScope: CoroutineScope, +) : DelegatedMetaAccountUpdatesListingMixin, WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + private val accountsByStateFlow = metaAccountGroupingInteractor.updatedDelegates() + .shareInBackground() + + private val desiredUserTypeFilter = MutableStateFlow(FilterType.Proxied) + + override val accountTypeFilter = combine(accountsByStateFlow, desiredUserTypeFilter) { accountsByState, filterType -> + val accounts = accountsByState.values.flatten() + val shouldIgnoreUserFilter = accounts.isAllEquals { it.delegator.type } + if (shouldIgnoreUserFilter) { + val accountsMatchedFilterType = accountMatchedFilterType(accounts.first()) + FilterType.UserIgnored(overriddenFilter = accountsMatchedFilterType) + } else { + filterType + } + }.distinctUntilChanged() + .shareInBackground() + + override val metaAccountsFlow = combine( + accountsByStateFlow, + accountTypeFilter + ) { accounts, filterType -> + accounts.filterValueList { filterType.filter(it.delegator) } + .toListWithHeaders( + keyMapper = { accountStatus, _ -> mapHeader(accountStatus, filterType) }, + valueMapper = { mapProxiedToUi(it) } + ) + }.shareInBackground() + + override fun filterBy(type: FilterType) { + desiredUserTypeFilter.value = type + } + + private fun mapHeader(status: LightMetaAccount.Status, type: FilterType): AccountTitleGroupRvItem { + val text = when (status) { + LightMetaAccount.Status.ACTIVE -> mapActiveHeader(type) + + LightMetaAccount.Status.DEACTIVATED -> resourceManager.getString(R.string.delegation_updates_deactivated_title) + } + + return AccountTitleGroupRvItem(text) + } + + private fun mapActiveHeader(type: FilterType): String { + return when (type) { + FilterType.Proxied -> resourceManager.getString(R.string.accounts_update_proxieds_title) + FilterType.Multisig -> resourceManager.getString(R.string.active_multisig_title) + is FilterType.UserIgnored -> mapActiveHeader(type.overriddenFilter) + } + } + + private suspend fun mapProxiedToUi(accountDelegation: AccountDelegation) = with(accountDelegation) { + val isEnabled = delegator.status == LightMetaAccount.Status.ACTIVE + val secondaryColor = resourceManager.getColor(R.color.text_secondary) + val title = delegator.name + val subtitle = mapSubtitle(this, isEnabled) + val walletIcon = walletUiUseCase.walletIcon(delegator) + AccountUi( + id = delegator.id, + title = if (isEnabled) title else title.toSpannable(colorSpan(secondaryColor)), + subtitle = if (isEnabled) subtitle else subtitle.toSpannable(colorSpan(secondaryColor)), + isSelected = false, + isClickable = true, + picture = if (isEnabled) walletIcon else walletIcon.withAlphaDrawable(DISABLED_ICON_ALPHA), + chainIcon = getChainOrNull()?.iconOrFallback(), + updateIndicator = false, + subtitleIconRes = null, + chainIconOpacity = if (isEnabled) ENABLED_ICON_ALPHA else DISABLED_ICON_ALPHA, + isEditable = false + ) + } + + private suspend fun mapSubtitle( + accountDelegation: AccountDelegation, + isEnabled: Boolean + ): CharSequence { + val icon = when (accountDelegation) { + is AccountDelegation.Multisig -> multisigFormatter.makeAccountDrawable(accountDelegation.signatory) + is AccountDelegation.Proxy -> proxyFormatter.makeAccountDrawable(accountDelegation.proxy) + } + + val delegatorIcon = if (isEnabled) icon else icon.withAlphaDrawable(DISABLED_ICON_ALPHA) + + return when (accountDelegation) { + is AccountDelegation.Multisig -> { + multisigFormatter.formatSignatorySubtitle(accountDelegation.signatory, delegatorIcon) + } + + is AccountDelegation.Proxy -> { + val proxy = accountDelegation.proxied.proxy + return proxyFormatter.mapProxyMetaAccountSubtitle(accountDelegation.proxy.name, delegatorIcon, proxy) + } + } + } + + private fun accountMatchedFilterType(accountDelegation: AccountDelegation): FilterType { + return when (accountDelegation) { + is AccountDelegation.Multisig -> FilterType.Multisig + is AccountDelegation.Proxy -> FilterType.Proxied + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/RealMultisigFormatter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/RealMultisigFormatter.kt new file mode 100644 index 0000000..9dca69b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/RealMultisigFormatter.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated + +import android.graphics.drawable.Drawable +import android.text.SpannableStringBuilder +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressIconGenerator.Companion.BACKGROUND_TRANSPARENT +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.append +import io.novafoundation.nova.common.utils.appendEnd +import io.novafoundation.nova.common.utils.appendSpace +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.drawableSpan +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_impl.R +import javax.inject.Inject + +@FeatureScope +class RealMultisigFormatter @Inject constructor( + private val walletUiUseCase: WalletUiUseCase, + private val addressIconGenerator: AddressIconGenerator, + private val accountInteractor: AccountInteractor, + private val resourceManager: ResourceManager +) : MultisigFormatter { + + override suspend fun formatSignatorySubtitle(signatory: MetaAccount): CharSequence { + val icon = makeAccountDrawable(signatory) + return formatSignatorySubtitle(signatory, icon) + } + + override fun formatSignatorySubtitle(signatory: MetaAccount, icon: Drawable): CharSequence { + val formattedMetaAccount = formatAccount(signatory.name, icon) + + return SpannableStringBuilder(resourceManager.getString(R.string.multisig_signatory)) + .appendSpace() + .append(formattedMetaAccount) + } + + override suspend fun formatSignatory(signatory: MetaAccount): CharSequence { + val icon = makeAccountDrawable(signatory) + return formatAccount(signatory.name, icon) + } + + // TODO multisig: refactor duplication with ProxyFormatter + private fun formatAccount(proxyAccountName: String, proxyAccountIcon: Drawable): CharSequence { + return SpannableStringBuilder() + .appendEnd(drawableSpan(proxyAccountIcon)) + .appendSpace() + .append(proxyAccountName, colorSpan(resourceManager.getColor(R.color.text_primary))) + } + + override suspend fun makeAccountDrawable(metaAccount: MetaAccount): Drawable { + // TODO multisig: this does db request for each icon. We should probably batch it. Same with proxieds + return walletUiUseCase.walletIcon(metaAccount, SUBTITLE_ICON_SIZE_DP) + } + + override suspend fun makeAccountDrawable(accountId: ByteArray): Drawable { + return addressIconGenerator.createAddressIcon(accountId, SUBTITLE_ICON_SIZE_DP, backgroundColorRes = BACKGROUND_TRANSPARENT) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/RealProxyFormatter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/RealProxyFormatter.kt new file mode 100644 index 0000000..40e82fe --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/common/listing/delegated/RealProxyFormatter.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated + +import android.graphics.drawable.Drawable +import android.text.SpannableStringBuilder +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.append +import io.novafoundation.nova.common.utils.appendEnd +import io.novafoundation.nova.common.utils.appendSpace +import io.novafoundation.nova.common.utils.capitalize +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.drawableSpan +import io.novafoundation.nova.common.utils.splitCamelCase +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxyAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType + +class RealProxyFormatter( + private val walletUiUseCase: WalletUiUseCase, + private val resourceManager: ResourceManager, +) : ProxyFormatter { + + override fun mapProxyMetaAccountSubtitle( + proxyAccountName: String, + proxyAccountIcon: Drawable, + proxyAccount: ProxyAccount + ): CharSequence { + val proxyType = mapProxyTypeToString(proxyAccount.proxyType) + val formattedProxyMetaAccount = mapProxyMetaAccount(proxyAccountName, proxyAccountIcon) + + return SpannableStringBuilder(proxyType) + .append(":") + .appendSpace() + .append(formattedProxyMetaAccount) + } + + override fun mapProxyMetaAccount(proxyAccountName: String, proxyAccountIcon: Drawable): CharSequence { + return SpannableStringBuilder() + .appendEnd(drawableSpan(proxyAccountIcon)) + .appendSpace() + .append(proxyAccountName, colorSpan(resourceManager.getColor(R.color.text_primary))) + } + + override fun mapProxyTypeToString(type: ProxyType): String { + val proxyType = when (type) { + ProxyType.Any -> resourceManager.getString(R.string.account_proxy_type_any) + ProxyType.NonTransfer -> resourceManager.getString(R.string.account_proxy_type_non_transfer) + ProxyType.Governance -> resourceManager.getString(R.string.account_proxy_type_governance) + ProxyType.Staking -> resourceManager.getString(R.string.account_proxy_type_staking) + ProxyType.IdentityJudgement -> resourceManager.getString(R.string.account_proxy_type_identity_judgement) + ProxyType.CancelProxy -> resourceManager.getString(R.string.account_proxy_type_cancel_proxy) + ProxyType.Auction -> resourceManager.getString(R.string.account_proxy_type_auction) + ProxyType.NominationPools -> resourceManager.getString(R.string.account_proxy_type_nomination_pools) + is ProxyType.Other -> type.name.splitCamelCase().joinToString(separator = " ") { it.capitalize() } + } + + return resourceManager.getString(R.string.proxy_wallet_type, proxyType) + } + + override suspend fun makeAccountDrawable(metaAccount: MetaAccount): Drawable { + return walletUiUseCase.walletIcon(metaAccount, SUBTITLE_ICON_SIZE_DP) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/WalletDetailsFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/WalletDetailsFragment.kt new file mode 100644 index 0000000..3e5329e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/WalletDetailsFragment.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.setGroupedListSpacings +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.setModelOrHide +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.ChainAccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.AccountInChainUi +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.ChainAccountGroupUi +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.setupAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.setupImportTypeChooser +import io.novafoundation.nova.feature_account_impl.databinding.FragmentWalletDetailsBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.ui.setupAddAccountLauncher + +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +private const val ACCOUNT_ID_KEY = "ACCOUNT_ADDRESS_KEY" + +class WalletDetailsFragment : BaseFragment(), ChainAccountsAdapter.Handler { + + override fun createBinding() = FragmentWalletDetailsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + ChainAccountsAdapter(this, imageLoader) + } + + companion object { + + fun getBundle(metaAccountId: Long): Bundle { + return Bundle().apply { + putLong(ACCOUNT_ID_KEY, metaAccountId) + } + } + } + + override fun initViews() { + binder.accountDetailsToolbar.setHomeButtonListener { + viewModel.backClicked() + } + + binder.accountDetailsChainAccounts.setHasFixedSize(true) + binder.accountDetailsChainAccounts.adapter = adapter + binder.accountDetailsChainAccounts.setGroupedListSpacings( + groupTopSpacing = 24, + groupBottomSpacing = 12, + firstItemTopSpacing = 16, + itemBottomSpacing = 4, + ) + } + + override fun inject() { + val metaId = argument(ACCOUNT_ID_KEY) + + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .accountDetailsComponentFactory() + .create(this, metaId) + .inject(this) + } + + override fun subscribe(viewModel: WalletDetailsViewModel) { + setupExternalActions(viewModel) { context, payload -> + ChainAccountActionsSheet( + context, + payload, + onCopy = viewModel::copyValue, + onViewExternal = viewModel::viewExternalClicked, + onChange = viewModel::changeChainAccountClicked, + onExport = viewModel::exportClicked, + availableAccountActions = viewModel.availableAccountActions.first() + ) + } + + setupImportTypeChooser(viewModel) + setupAddAccountLauncher(viewModel.addAccountLauncherMixin) + + viewModel.addressActionsMixin.setupAddressActions() + + binder.accountDetailsNameField.content.bindTo(viewModel.accountNameFlow, viewLifecycleOwner.lifecycleScope) + + viewModel.chainAccountProjections.observe { adapter.submitList(it) } + + viewModel.typeAlert.observe(binder.accountDetailsTypeAlert::setModelOrHide) + } + + override fun chainAccountClicked(item: AccountInChainUi) { + viewModel.chainAccountClicked(item) + } + + override fun onGroupActionClicked(item: ChainAccountGroupUi) { + viewModel.groupActionClicked(item) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/WalletDetailsViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/WalletDetailsViewModel.kt new file mode 100644 index 0000000..e23a1bd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/WalletDetailsViewModel.kt @@ -0,0 +1,128 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.feature_account_api.presenatation.account.add.SecretType +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.AccountInChainUi +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.ChainAccountGroupUi +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserMixin +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.WalletDetailsMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.WalletDetailsMixinHost +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.AddAccountLauncherPresentationFactory +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +class WalletDetailsViewModel( + private val rootScope: RootScope, + private val interactor: WalletDetailsInteractor, + private val accountRouter: AccountRouter, + private val metaId: Long, + private val externalActions: ExternalActions.Presentation, + private val chainRegistry: ChainRegistry, + private val importTypeChooserMixin: ImportTypeChooserMixin.Presentation, + private val addAccountLauncherPresentationFactory: AddAccountLauncherPresentationFactory, + private val walletDetailsMixinFactory: WalletDetailsMixinFactory, + private val addressActionsMixinFactory: AddressActionsMixin.Factory +) : BaseViewModel(), + ExternalActions by externalActions, + ImportTypeChooserMixin by importTypeChooserMixin { + + val addressActionsMixin = addressActionsMixinFactory.create(this) + + val addAccountLauncherMixin = addAccountLauncherPresentationFactory.create(viewModelScope) + + private val detailsHost = WalletDetailsMixinHost( + externalActions = externalActions, + addressActionsMixin = addressActionsMixin + ) + + private val walletDetailsMixin = async { walletDetailsMixinFactory.create(metaId, coroutineScope = viewModelScope, detailsHost) } + + private val startAccountName = async { walletDetailsMixin().metaAccount.name } + + val accountNameFlow: MutableStateFlow = MutableStateFlow("") + + val availableAccountActions = flowOfAll { walletDetailsMixin().availableAccountActions } + .shareInBackground() + + val typeAlert = flowOfAll { walletDetailsMixin().typeAlert } + .shareInBackground() + + val chainAccountProjections = flowOfAll { walletDetailsMixin().accountProjectionsFlow() } + .shareInBackground() + + init { + launch { + accountNameFlow.emit(walletDetailsMixin().metaAccount.name) + } + } + + fun backClicked() { + accountRouter.back() + } + + fun chainAccountClicked(item: AccountInChainUi) = launch { + if (!item.actionsAvailable) return@launch + + val chain = chainRegistry.getChain(item.chainUi.id) + + externalActions.showAddressActions(item.address, chain) + } + + fun exportClicked(inChain: Chain) = launch { + viewModelScope.launch { + val sources = interactor.availableExportTypes(walletDetailsMixin().metaAccount, inChain) + + val payload = ImportTypeChooserMixin.Payload( + allowedTypes = sources, + onChosen = { exportTypeChosen(it, inChain) } + ) + importTypeChooserMixin.showChooser(payload) + } + } + + fun changeChainAccountClicked(inChain: Chain) { + launch { + addAccountLauncherMixin.initiateLaunch(inChain, walletDetailsMixin().metaAccount) + } + } + + fun groupActionClicked(groupUi: ChainAccountGroupUi) = launchUnit { + walletDetailsMixin().groupActionClicked(groupUi.id) + } + + override fun onCleared() { + // Launch it in root scope to avoid coroutine cancellation + rootScope.launch { + val newAccountName = accountNameFlow.value + if (startAccountName() != newAccountName) { + interactor.updateName(metaId, newAccountName) + } + } + } + + private fun exportTypeChosen(type: SecretType, chain: Chain) { + val exportPayload = ExportPayload.ChainAccount(metaId, chain.id) + + val navigationAction = when (type) { + SecretType.MNEMONIC -> accountRouter.getExportMnemonicDelayedNavigation(exportPayload) + SecretType.SEED -> accountRouter.getExportSeedDelayedNavigation(exportPayload) + SecretType.JSON -> accountRouter.getExportJsonDelayedNavigation(exportPayload) + } + + accountRouter.withPinCodeCheckRequired(navigationAction) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/di/AccountDetailsComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/di/AccountDetailsComponent.kt new file mode 100644 index 0000000..00c1287 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/di/AccountDetailsComponent.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.account.details.WalletDetailsFragment + +@Subcomponent( + modules = [ + AccountDetailsModule::class + ] +) +@ScreenScope +interface AccountDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance metaId: Long + ): AccountDetailsComponent + } + + fun inject(fragment: WalletDetailsFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/di/AccountDetailsModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/di/AccountDetailsModule.kt new file mode 100644 index 0000000..e7c569b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/di/AccountDetailsModule.kt @@ -0,0 +1,108 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.modules.Caching +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserMixin +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.WalletDetailsViewModel +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.WalletDetailsMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.AccountFormatterFactory +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.AddAccountLauncherPresentationFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class AccountDetailsModule { + + @Provides + fun provideAccountFormatterFactory( + resourceManager: ResourceManager, + @Caching iconGenerator: AddressIconGenerator, + ): AccountFormatterFactory { + return AccountFormatterFactory(iconGenerator, resourceManager) + } + + @Provides + fun provideWalletDetailsMixinFactory( + polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + resourceManager: ResourceManager, + accountFormatterFactory: AccountFormatterFactory, + proxyFormatter: ProxyFormatter, + interactor: WalletDetailsInteractor, + appLinksProvider: AppLinksProvider, + ledgerMigrationTracker: LedgerMigrationTracker, + multisigFormatter: MultisigFormatter, + router: AccountRouter, + addressSchemeFormatter: AddressSchemeFormatter, + chainRegistry: ChainRegistry + ): WalletDetailsMixinFactory { + return WalletDetailsMixinFactory( + polkadotVaultVariantConfigProvider = polkadotVaultVariantConfigProvider, + resourceManager = resourceManager, + accountFormatterFactory = accountFormatterFactory, + proxyFormatter = proxyFormatter, + multisigFormatter = multisigFormatter, + interactor = interactor, + appLinksProvider = appLinksProvider, + ledgerMigrationTracker = ledgerMigrationTracker, + router = router, + addressSchemeFormatter = addressSchemeFormatter, + chainRegistry = chainRegistry + ) + } + + @Provides + @IntoMap + @ViewModelKey(WalletDetailsViewModel::class) + fun provideViewModel( + rootScope: RootScope, + interactor: WalletDetailsInteractor, + router: AccountRouter, + metaId: Long, + externalActions: ExternalActions.Presentation, + chainRegistry: ChainRegistry, + importTypeChooserMixin: ImportTypeChooserMixin.Presentation, + addAccountLauncherPresentationFactory: AddAccountLauncherPresentationFactory, + walletDetailsMixinFactory: WalletDetailsMixinFactory, + addressActionsMixinFactory: AddressActionsMixin.Factory + ): ViewModel { + return WalletDetailsViewModel( + rootScope = rootScope, + interactor = interactor, + accountRouter = router, + metaId = metaId, + externalActions = externalActions, + chainRegistry = chainRegistry, + importTypeChooserMixin = importTypeChooserMixin, + addAccountLauncherPresentationFactory = addAccountLauncherPresentationFactory, + walletDetailsMixinFactory = walletDetailsMixinFactory, + addressActionsMixinFactory = addressActionsMixinFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): WalletDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(WalletDetailsViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/GenericLedgerWalletDetailsMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/GenericLedgerWalletDetailsMixin.kt new file mode 100644 index 0000000..742ad89 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/GenericLedgerWalletDetailsMixin.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin + +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.ChainAccountGroupUi +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet.AccountAction +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.details.AccountInChain +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.AccountFormatterFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.baseAccountTitleFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.hasChainAccount +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.runtime.ext.defaultComparatorFrom +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class GenericLedgerWalletDetailsMixin( + private val resourceManager: ResourceManager, + private val accountFormatterFactory: AccountFormatterFactory, + private val interactor: WalletDetailsInteractor, + private val ledgerMigrationTracker: LedgerMigrationTracker, + private val router: AccountRouter, + private val addressSchemeFormatter: AddressSchemeFormatter, + metaAccount: MetaAccount +) : WalletDetailsMixin(metaAccount) { + + private val accountFormatter = accountFormatterFactory.create(baseAccountTitleFormatter(resourceManager)) + + override val availableAccountActions: Flow> = flowOf { emptySet() } + + override val typeAlert: Flow = flowOf { + AlertModel( + style = AlertView.Style( + backgroundColorRes = R.color.block_background, + iconRes = R.drawable.ic_ledger + ), + message = resourceManager.getString(R.string.ledger_wallet_details_description) + ) + } + + override fun accountProjectionsFlow(): Flow> { + return flowOfAll { + val chains = ledgerMigrationTracker.supportedChainsByGenericApp() + + interactor.chainProjectionsByAddressSchemeFlow( + metaId = metaAccount.id, + chains = chains, + sorting = Chain.defaultComparatorFrom(AccountInChain::chain) + ).map { accounts -> + val availableActions = availableAccountActions.first() + + accounts.toListWithHeaders( + keyMapper = ::createGroupUi, + valueMapper = { chainAccount -> accountFormatter.formatChainAccountProjection(chainAccount, availableActions) } + ) + } + } + } + + override suspend fun groupActionClicked(groupId: String) { + router.openAddGenericEvmAddressSelectLedger(metaAccount.id) + } + + private fun createGroupUi(addressScheme: AddressScheme, accounts: List): ChainAccountGroupUi { + val canAdd = accounts.none { it.hasChainAccount } + val action = if (canAdd) { + ChainAccountGroupUi.Action( + name = resourceManager.getString(R.string.account_add_address), + icon = R.drawable.ic_add_circle + ) + } else { + null + } + + return ChainAccountGroupUi( + id = addressScheme.name, + title = addressSchemeFormatter.accountsLabel(addressScheme), + action = action + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/LegacyLedgerWalletDetailsMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/LegacyLedgerWalletDetailsMixin.kt new file mode 100644 index 0000000..3088218 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/LegacyLedgerWalletDetailsMixin.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin + +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet.AccountAction +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.AccountFormatterFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.baseAccountTitleFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.hasAccountComparator +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.withChainComparator +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateApplicationConfig +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class LegacyLedgerWalletDetailsMixin( + private val resourceManager: ResourceManager, + private val accountFormatterFactory: AccountFormatterFactory, + private val interactor: WalletDetailsInteractor, + private val host: WalletDetailsMixinHost, + private val appLinksProvider: AppLinksProvider, + private val ledgerMigrationTracker: LedgerMigrationTracker, + metaAccount: MetaAccount +) : WalletDetailsMixin(metaAccount) { + + private val accountFormatter = accountFormatterFactory.create(baseAccountTitleFormatter(resourceManager)) + + override val availableAccountActions: Flow> = flowOf { setOf(AccountAction.CHANGE) } + + override val typeAlert: Flow = flowOf { + if (ledgerMigrationTracker.anyChainSupportsMigrationApp()) { + AlertModel( + style = AlertView.Style.fromPreset(AlertView.StylePreset.WARNING), + message = resourceManager.getString(R.string.account_ledger_legacy_warning_title), + subMessage = resourceManager.getString(R.string.account_ledger_legacy_warning_message), + linkAction = AlertModel.ActionModel( + text = resourceManager.getString(R.string.common_find_out_more), + listener = { host.externalActions.showBrowser(appLinksProvider.ledgerMigrationArticle) } + ) + ) + } else { + AlertModel( + style = AlertView.Style( + backgroundColorRes = R.color.block_background, + iconRes = R.drawable.ic_ledger + ), + message = resourceManager.getString(R.string.ledger_wallet_details_description) + ) + } + } + + override fun accountProjectionsFlow(): Flow> = flowOfAll { + val ledgerSupportedChainIds = SubstrateApplicationConfig.all().mapToSet { it.chainId } + val chains = interactor.getAllChains() + .filter { it.id in ledgerSupportedChainIds } + + interactor.chainProjectionsBySourceFlow( + metaAccount.id, + chains, + hasAccountComparator().withChainComparator() + ).map { accounts -> + val availableActions = availableAccountActions.first() + + accounts.toListWithHeaders( + keyMapper = { _, _ -> null }, + valueMapper = { chainAccount -> accountFormatter.formatChainAccountProjection(chainAccount, availableActions) } + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/MultisigWalletDetailsMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/MultisigWalletDetailsMixin.kt new file mode 100644 index 0000000..3b58ddd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/MultisigWalletDetailsMixin.kt @@ -0,0 +1,145 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin + +import android.text.SpannableStringBuilder +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.format.AddressFormat +import io.novafoundation.nova.common.address.format.asAccountId +import io.novafoundation.nova.common.presentation.ellipsizeAddress +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.append +import io.novafoundation.nova.common.utils.appendEnd +import io.novafoundation.nova.common.utils.appendSpace +import io.novafoundation.nova.common.utils.clickableSpan +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.drawableSpan +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.setFullSpan +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_account_api.domain.model.MultisigAvailability +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.allSignatories +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet.AccountAction +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.AccountFormatterFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.baseAccountTitleFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.util.forChain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class MultisigWalletDetailsMixin( + private val resourceManager: ResourceManager, + private val accountFormatterFactory: AccountFormatterFactory, + private val interactor: WalletDetailsInteractor, + private val multisigFormatter: MultisigFormatter, + private val host: WalletDetailsMixinHost, + private val chainRegistry: ChainRegistry, + private val coroutineScope: CoroutineScope, + metaAccount: MultisigMetaAccount +) : WalletDetailsMixin(metaAccount), CoroutineScope by coroutineScope { + + private val accountFormatter = accountFormatterFactory.create(baseAccountTitleFormatter(resourceManager)) + + override val availableAccountActions: Flow> = flowOf { emptySet() } + + override val typeAlert: Flow = flowOf { + AlertModel( + style = AlertView.Style( + backgroundColorRes = R.color.block_background, + iconRes = R.drawable.ic_multisig + ), + message = mainMessage(metaAccount), + subMessages = subMessage(metaAccount) + ) + } + + override fun accountProjectionsFlow(): Flow> = interactor.allPresentChainProjections(metaAccount).map { accounts -> + val availableActions = availableAccountActions.first() + + accounts.map { + accountFormatter.formatChainAccountProjection(it, availableActions) + } + } + + private fun mainMessage(metaAccount: MultisigMetaAccount): String { + val threshold = metaAccount.threshold + val allSignatoriesQuantity = metaAccount.allSignatories().size + return resourceManager.getString(R.string.multisig_wallet_details_info_warning_title, threshold, allSignatoriesQuantity) + } + + private suspend fun subMessage(metaAccount: MultisigMetaAccount): List { + val message = resourceManager.getString(R.string.multisig_wallet_details_info_warning) + val userSignatory = getMetaAccountFormat(metaAccount.signatoryMetaId) + val otherSignatoriesTitle = resourceManager.getString(R.string.multisig_wallet_details_info_warning_other_signatories) + val otherSignatories = metaAccount.otherSignatories.map { getAccountFormat(metaAccount, it) } + + return buildList { + add(message.withPrimaryColor()) + add(userSignatory) + add(otherSignatoriesTitle) + addAll(otherSignatories) + } + } + + private fun CharSequence.withPrimaryColor() = toSpannable(colorSpan(resourceManager.getColor(R.color.text_primary))) + + private suspend fun getMetaAccountFormat(metaId: Long): CharSequence { + val signatory = interactor.getMetaAccount(metaId) + return multisigFormatter.formatSignatory(signatory) + .toSpannable(colorSpan(resourceManager.getColor(R.color.text_primary))) + } + + private suspend fun getAccountFormat(metaAccount: MultisigMetaAccount, accountIdKey: AccountIdKey): CharSequence { + val addressFormat = getAddressFormat(metaAccount) + val accountDrawable = multisigFormatter.makeAccountDrawable(accountIdKey.value) + val accountAddress = addressFormat.addressOf(accountIdKey.value.asAccountId()) + .value + .ellipsizeAddress() + + val addressColor = resourceManager.getColor(R.color.text_secondary) + val infoIcon = resourceManager.getDrawable(R.drawable.ic_info).apply { + setBounds(0, 0, intrinsicWidth, intrinsicHeight) + } + + return SpannableStringBuilder() + .appendEnd(drawableSpan(accountDrawable)) + .appendSpace() + .append(accountAddress, colorSpan(addressColor)) + .appendSpace() + .appendEnd(drawableSpan(infoIcon)) + .setFullSpan( + clickableSpan { + showAddress(metaAccount, accountIdKey, addressFormat) + } + ) + } + + private fun showAddress(metaAccount: MultisigMetaAccount, accountIdKey: AccountIdKey, addressFormat: AddressFormat) = launchUnit { + when (val availability = metaAccount.availability) { + is MultisigAvailability.Universal -> host.addressActionsMixin.showAddressActions(accountIdKey.value, addressFormat) + is MultisigAvailability.SingleChain -> { + val chain = chainRegistry.getChain(availability.chainId) + host.externalActions.showAddressActions(accountIdKey.value, chain) + } + } + } + + private suspend fun getAddressFormat(metaAccount: MultisigMetaAccount): AddressFormat { + return when (val availability = metaAccount.availability) { + is MultisigAvailability.Universal -> AddressFormat.defaultForScheme(availability.addressScheme) + + is MultisigAvailability.SingleChain -> { + val chain = chainRegistry.getChain(availability.chainId) + AddressFormat.forChain(chain) + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/PolkadotVaultWalletDetailsMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/PolkadotVaultWalletDetailsMixin.kt new file mode 100644 index 0000000..5e0da01 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/PolkadotVaultWalletDetailsMixin.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin + +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.asPolkadotVaultVariantOrThrow +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet.AccountAction +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.AccountFormatterFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.hasAccountComparator +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.polkadotVaultAccountTypeAlert +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.polkadotVaultTitle +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.withChainComparator +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class PolkadotVaultWalletDetailsMixin( + private val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + private val resourceManager: ResourceManager, + private val accountFormatterFactory: AccountFormatterFactory, + private val interactor: WalletDetailsInteractor, + metaAccount: MetaAccount +) : WalletDetailsMixin(metaAccount) { + private val accountFormatter = accountFormatterFactory.create( + accountTitleFormatter = { it.polkadotVaultTitle(resourceManager, metaAccount) } + ) + + override val availableAccountActions: Flow> = flowOf { emptySet() } + + override val typeAlert: Flow = flowOf { + val vaultVariant = metaAccount.type.asPolkadotVaultVariantOrThrow() + val variantConfig = polkadotVaultVariantConfigProvider.variantConfigFor(vaultVariant) + polkadotVaultAccountTypeAlert(vaultVariant, variantConfig, resourceManager) + } + + override fun accountProjectionsFlow(): Flow> = flowOfAll { + interactor.chainProjectionsBySourceFlow(metaAccount.id, interactor.getAllChains(), hasAccountComparator().withChainComparator()) + }.map { accounts -> + val availableActions = availableAccountActions.first() + + accounts.toListWithHeaders( + keyMapper = { _, _ -> null }, + valueMapper = { chainAccount -> accountFormatter.formatChainAccountProjection(chainAccount, availableActions) } + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/ProxiedWalletDetailsMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/ProxiedWalletDetailsMixin.kt new file mode 100644 index 0000000..ee917a7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/ProxiedWalletDetailsMixin.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin + +import android.text.SpannableStringBuilder +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.appendSpace +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet.AccountAction +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.AccountFormatterFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.baseAccountTitleFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.hasAccountComparator +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.withChainComparator +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class ProxiedWalletDetailsMixin( + private val resourceManager: ResourceManager, + private val accountFormatterFactory: AccountFormatterFactory, + private val interactor: WalletDetailsInteractor, + private val proxyFormatter: ProxyFormatter, + metaAccount: ProxiedMetaAccount +) : WalletDetailsMixin(metaAccount) { + private val accountFormatter = accountFormatterFactory.create(baseAccountTitleFormatter(resourceManager)) + + override val availableAccountActions: Flow> = flowOf { emptySet() } + + override val typeAlert: Flow = flowOf { + val proxyAccount = metaAccount.proxy + val proxyMetaAccount = interactor.getMetaAccount(proxyAccount.proxyMetaId) + + val proxyAccountWithIcon = proxyFormatter.mapProxyMetaAccount(proxyMetaAccount.name, proxyFormatter.makeAccountDrawable(proxyMetaAccount)) + AlertModel( + style = AlertView.Style( + backgroundColorRes = R.color.block_background, + iconRes = R.drawable.ic_proxy + ), + message = resourceManager.getString(R.string.proxied_wallet_details_info_warning), + subMessage = SpannableStringBuilder(proxyAccountWithIcon) + .appendSpace() + .append(proxyFormatter.mapProxyTypeToString(proxyAccount.proxyType)) + ) + } + + override fun accountProjectionsFlow(): Flow> = flowOfAll { + val proxiedChainIds = metaAccount.chainAccounts.keys + val chains = interactor.getAllChains() + .filter { it.id in proxiedChainIds } + + interactor.chainProjectionsBySourceFlow(metaAccount.id, chains, hasAccountComparator().withChainComparator()) + .map { accounts -> + val availableActions = availableAccountActions.first() + + accounts.toListWithHeaders( + keyMapper = { _, _ -> null }, + valueMapper = { chainAccount -> accountFormatter.formatChainAccountProjection(chainAccount, availableActions) } + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/SecretsWalletDetailsMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/SecretsWalletDetailsMixin.kt new file mode 100644 index 0000000..557fea0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/SecretsWalletDetailsMixin.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin + +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet.AccountAction +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.AccountFormatterFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.baseAccountTitleFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.hasAccountComparator +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.mapToAccountGroupUi +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.withChainComparator +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class SecretsWalletDetailsMixin( + private val resourceManager: ResourceManager, + private val accountFormatterFactory: AccountFormatterFactory, + private val interactor: WalletDetailsInteractor, + metaAccount: MetaAccount +) : WalletDetailsMixin(metaAccount) { + private val accountFormatter = accountFormatterFactory.create(baseAccountTitleFormatter(resourceManager)) + + override val availableAccountActions: Flow> = flowOf { setOf(AccountAction.EXPORT, AccountAction.CHANGE) } + + override val typeAlert: Flow = flowOf { null } + + override fun accountProjectionsFlow(): Flow> = flowOfAll { + interactor.chainProjectionsBySourceFlow(metaAccount.id, interactor.getAllChains(), hasAccountComparator().withChainComparator()) + .map { accounts -> + val availableActions = availableAccountActions.first() + + accounts.toListWithHeaders( + keyMapper = { from, _ -> from.mapToAccountGroupUi(resourceManager) }, + valueMapper = { chainAccount -> accountFormatter.formatChainAccountProjection(chainAccount, availableActions) } + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/WalletDetailsMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/WalletDetailsMixin.kt new file mode 100644 index 0000000..5c78c38 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/WalletDetailsMixin.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin + +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet.AccountAction +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import kotlinx.coroutines.flow.Flow + +class WalletDetailsMixinHost( + val externalActions: ExternalActions.Presentation, + val addressActionsMixin: AddressActionsMixin.Presentation +) + +abstract class WalletDetailsMixin( + val metaAccount: MetaAccount +) { + + abstract val availableAccountActions: Flow> + + abstract val typeAlert: Flow + + // List + abstract fun accountProjectionsFlow(): Flow> + + open suspend fun groupActionClicked(groupId: String) {} +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/WalletDetailsMixinFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/WalletDetailsMixinFactory.kt new file mode 100644 index 0000000..c78b0fe --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/WalletDetailsMixinFactory.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin + +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount.Type +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.AccountFormatterFactory +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.CoroutineScope + +class WalletDetailsMixinFactory( + private val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + private val resourceManager: ResourceManager, + private val accountFormatterFactory: AccountFormatterFactory, + private val proxyFormatter: ProxyFormatter, + private val multisigFormatter: MultisigFormatter, + private val interactor: WalletDetailsInteractor, + private val appLinksProvider: AppLinksProvider, + private val ledgerMigrationTracker: LedgerMigrationTracker, + private val router: AccountRouter, + private val addressSchemeFormatter: AddressSchemeFormatter, + private val chainRegistry: ChainRegistry +) { + + suspend fun create(metaId: Long, coroutineScope: CoroutineScope, host: WalletDetailsMixinHost): WalletDetailsMixin { + val metaAccount = interactor.getMetaAccount(metaId) + + return when (metaAccount.type) { + Type.SECRETS -> SecretsWalletDetailsMixin( + resourceManager = resourceManager, + accountFormatterFactory = accountFormatterFactory, + interactor = interactor, + metaAccount = metaAccount + ) + + Type.WATCH_ONLY -> WatchOnlyWalletDetailsMixin( + resourceManager = resourceManager, + accountFormatterFactory = accountFormatterFactory, + interactor = interactor, + metaAccount = metaAccount + ) + + Type.LEDGER_LEGACY -> LegacyLedgerWalletDetailsMixin( + resourceManager = resourceManager, + accountFormatterFactory = accountFormatterFactory, + interactor = interactor, + host = host, + appLinksProvider = appLinksProvider, + metaAccount = metaAccount, + ledgerMigrationTracker = ledgerMigrationTracker + ) + + Type.LEDGER -> GenericLedgerWalletDetailsMixin( + resourceManager = resourceManager, + accountFormatterFactory = accountFormatterFactory, + interactor = interactor, + ledgerMigrationTracker = ledgerMigrationTracker, + metaAccount = metaAccount, + router = router, + addressSchemeFormatter = addressSchemeFormatter + ) + + Type.PARITY_SIGNER, + Type.POLKADOT_VAULT -> PolkadotVaultWalletDetailsMixin( + polkadotVaultVariantConfigProvider = polkadotVaultVariantConfigProvider, + resourceManager = resourceManager, + accountFormatterFactory = accountFormatterFactory, + interactor = interactor, + metaAccount = metaAccount + ) + + Type.PROXIED -> ProxiedWalletDetailsMixin( + resourceManager = resourceManager, + accountFormatterFactory = accountFormatterFactory, + interactor = interactor, + proxyFormatter = proxyFormatter, + metaAccount = metaAccount as ProxiedMetaAccount + ) + + Type.MULTISIG -> MultisigWalletDetailsMixin( + resourceManager = resourceManager, + accountFormatterFactory = accountFormatterFactory, + interactor = interactor, + multisigFormatter = multisigFormatter, + metaAccount = metaAccount as MultisigMetaAccount, + host = host, + chainRegistry = chainRegistry, + coroutineScope = coroutineScope + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/WatchOnlyWalletDetailsMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/WatchOnlyWalletDetailsMixin.kt new file mode 100644 index 0000000..0cfc355 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/WatchOnlyWalletDetailsMixin.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin + +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet.AccountAction +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.details.WalletDetailsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.AccountFormatterFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.baseAccountTitleFormatter +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.hasAccountComparator +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.mapToAccountGroupUi +import io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common.withChainComparator +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class WatchOnlyWalletDetailsMixin( + private val resourceManager: ResourceManager, + private val accountFormatterFactory: AccountFormatterFactory, + private val interactor: WalletDetailsInteractor, + metaAccount: MetaAccount +) : WalletDetailsMixin(metaAccount) { + private val accountFormatter = accountFormatterFactory.create(baseAccountTitleFormatter(resourceManager)) + + override val availableAccountActions: Flow> = flowOf { setOf(AccountAction.CHANGE) } + + override val typeAlert: Flow = flowOf { + AlertModel( + style = AlertView.Style( + backgroundColorRes = R.color.block_background, + iconRes = R.drawable.ic_watch_only_filled + ), + message = resourceManager.getString(R.string.account_details_watch_only_alert) + ) + } + + override fun accountProjectionsFlow(): Flow> = flowOfAll { + interactor.chainProjectionsBySourceFlow(metaAccount.id, interactor.getAllChains(), hasAccountComparator().withChainComparator()) + .map { accounts -> + val availableActions = availableAccountActions.first() + + accounts.toListWithHeaders( + keyMapper = { from, _ -> from.mapToAccountGroupUi(resourceManager) }, + valueMapper = { chainAccount -> accountFormatter.formatChainAccountProjection(chainAccount, availableActions) } + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/common/AccountFormatter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/common/AccountFormatter.kt new file mode 100644 index 0000000..6d4abb2 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/common/AccountFormatter.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.filterToSet +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.details.ChainAccountActionsSheet.AccountAction +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.details.AccountInChain +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.AccountInChainUi + +class AccountFormatterFactory( + private val iconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager +) { + + fun create(accountTitleFormatter: suspend (AccountInChain) -> String): AccountFormatter { + return AccountFormatter( + iconGenerator, + resourceManager, + accountTitleFormatter + ) + } +} + +class AccountFormatter( + private val iconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val accountTitleFormatter: suspend (AccountInChain) -> String +) { + + suspend fun formatChainAccountProjection(accountInChain: AccountInChain, availableActions: Set): AccountInChainUi { + return with(accountInChain) { + val accountIcon = projection?.let { + iconGenerator.createAddressIcon(it.accountId, AddressIconGenerator.SIZE_SMALL, backgroundColorRes = AddressIconGenerator.BACKGROUND_TRANSPARENT) + } ?: resourceManager.getDrawable(R.drawable.ic_warning_filled) + + val availableActionsForChain = availableActionsFor(accountInChain, availableActions) + val canViewAddresses = accountInChain.projection != null + val canDoAnyActions = availableActionsForChain.isNotEmpty() || canViewAddresses + + AccountInChainUi( + chainUi = mapChainToUi(chain), + addressOrHint = accountTitleFormatter(accountInChain), + address = projection?.address, + accountIcon = accountIcon, + actionsAvailable = canDoAnyActions + ) + } + } + + private fun availableActionsFor(accountInChain: AccountInChain, availableActions: Set): Set { + return availableActions.filterToSet { action -> + when (action) { + AccountAction.CHANGE -> true + AccountAction.EXPORT -> accountInChain.projection != null + } + } + } +} + +fun baseAccountTitleFormatter(resourceManager: ResourceManager): (AccountInChain) -> String { + return { accountInChain -> + accountInChain.projection?.address ?: resourceManager.getString(R.string.account_no_chain_projection) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/common/WalletMixinCommon.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/common/WalletMixinCommon.kt new file mode 100644 index 0000000..b6a1c69 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/details/mixin/common/WalletMixinCommon.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.details.mixin.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.domain.model.asPolkadotVaultVariantOrThrow +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.ChainAccountGroupUi +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.formatWithPolkadotVaultLabel +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.details.AccountInChain +import io.novafoundation.nova.runtime.ext.defaultComparatorFrom +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun polkadotVaultAccountTypeAlert( + pokadotVaultVariant: PolkadotVaultVariant, + variantConfig: PolkadotVaultVariantConfig, + resourceManager: ResourceManager +): AlertModel { + return AlertModel( + style = AlertView.Style( + backgroundColorRes = R.color.block_background, + iconRes = variantConfig.common.iconRes + ), + message = resourceManager.formatWithPolkadotVaultLabel(R.string.account_details_parity_signer_alert, pokadotVaultVariant) + ) +} + +fun AccountInChain.polkadotVaultTitle(resourceManager: ResourceManager, metaAccount: MetaAccount): String { + val address = projection?.address + + return if (address != null) { + address + } else { + val polkadotVaultVariant = metaAccount.type.asPolkadotVaultVariantOrThrow() + resourceManager.formatWithPolkadotVaultLabel(R.string.account_details_parity_signer_not_supported, polkadotVaultVariant) + } +} + +fun AccountInChain.From.mapToAccountGroupUi(resourceManager: ResourceManager): ChainAccountGroupUi { + val resId = when (this) { + AccountInChain.From.META_ACCOUNT -> R.string.account_shared_secret + AccountInChain.From.CHAIN_ACCOUNT -> R.string.account_custom_secret + } + + return ChainAccountGroupUi( + id = name, + title = resourceManager.getString(resId), + action = null + ) +} + +fun hasAccountComparator(): Comparator { + return compareBy { it.hasChainAccount } +} + +fun Comparator.withChainComparator(): Comparator { + return then(Chain.accountInChainComparator()) +} + +fun Chain.Companion.accountInChainComparator(): Comparator { + return Chain.defaultComparatorFrom(AccountInChain::chain) +} + +val AccountInChain.hasChainAccount + get() = projection != null diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/WalletListFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/WalletListFragment.kt new file mode 100644 index 0000000..86989de --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/WalletListFragment.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list + +import android.view.View +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseBottomSheetFragment +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentWalletListBinding +import javax.inject.Inject + +abstract class WalletListFragment : + BaseBottomSheetFragment(), + AccountHolder.AccountItemHandler { + + override fun createBinding() = FragmentWalletListBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + AccountsAdapter(this, imageLoader, initialMode = viewModel.mode, chainBorderColor = R.color.bottom_sheet_background) + } + + override fun initViews() { + binder.walletListContent.adapter = adapter + } + + override fun subscribe(viewModel: T) { + viewModel.walletsListingMixin.metaAccountsFlow.observe(adapter::submitList) + } + + override fun itemClicked(accountModel: AccountUi) { + viewModel.accountClicked(accountModel) + } + + override fun deleteClicked(accountModel: AccountUi) { + // no delete possible + } + + fun setTitleRes(@StringRes titleRes: Int) { + binder.walletListTitle.setText(titleRes) + } + + fun setActionIcon(@DrawableRes drawableRes: Int) { + binder.walletListBarAction.setImageResource(drawableRes) + } + + fun setActionClickListener(listener: View.OnClickListener?) { + binder.walletListBarAction.setOnClickListener(listener) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/WalletListViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/WalletListViewModel.kt new file mode 100644 index 0000000..90e46ff --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/WalletListViewModel.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder.Mode +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountListingMixin + +abstract class WalletListViewModel : BaseViewModel() { + + abstract val walletsListingMixin: MetaAccountListingMixin + + abstract val mode: Mode + + abstract fun accountClicked(accountModel: AccountUi) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/DelegatedAccountUpdatesBottomSheet.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/DelegatedAccountUpdatesBottomSheet.kt new file mode 100644 index 0000000..6ec1454 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/DelegatedAccountUpdatesBottomSheet.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.delegationUpdates + +import androidx.core.view.isVisible +import coil.ImageLoader +import com.google.android.material.tabs.TabLayout +import io.novafoundation.nova.common.base.BaseBottomSheetFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.setTabSelectedListener +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.BottomSheetDelegatedAccountUpdatesBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import javax.inject.Inject + +class DelegatedAccountUpdatesBottomSheet : BaseBottomSheetFragment() { + + override fun createBinding() = BottomSheetDelegatedAccountUpdatesBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { DelegatedAccountsAdapter(imageLoader) } + + override fun initViews() { + binder.delegatedAccountUpdatesLink.setOnClickListener { viewModel.clickAbout() } + binder.delegatedAccountUpdatesDone.setOnClickListener { viewModel.clickDone() } + binder.delegatedAccountUpdatesList.adapter = adapter + binder.delegatedAccountUpdatesList.itemAnimator = null + + binder.delegatedAccountUpdatesMode.createTab(R.string.account_proxied) + binder.delegatedAccountUpdatesMode.createTab(R.string.account_multisig) + binder.delegatedAccountUpdatesMode.setTabSelectedListener { + when (it.position) { + 0 -> viewModel.showProxieds() + 1 -> viewModel.showMultisig() + } + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .delegatedAccountUpdatesFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: DelegatedAccountUpdatesViewModel) { + observeBrowserEvents(viewModel) + viewModel.filtersAvailableFlow.observe { binder.delegatedAccountUpdatesMode.isVisible = it } + viewModel.accounts.observe { adapter.submitList(it) } + } + + private fun TabLayout.createTab(textResId: Int) { + val tab = newTab() + tab.setText(textResId) + + addTab(tab) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/DelegatedAccountUpdatesViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/DelegatedAccountUpdatesViewModel.kt new file mode 100644 index 0000000..56c3f8a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/DelegatedAccountUpdatesViewModel.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.delegationUpdates + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated.DelegatedMetaAccountUpdatesListingMixin.FilterType +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated.DelegatedMetaAccountUpdatesListingMixinFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class DelegatedAccountUpdatesViewModel( + private val delegatedMetaAccountUpdatesListingMixinFactory: DelegatedMetaAccountUpdatesListingMixinFactory, + private val accountRouter: AccountRouter, + private val appLinksProvider: AppLinksProvider, + private val accountInteractor: AccountInteractor +) : BaseViewModel(), Browserable { + + private val listingMixin = delegatedMetaAccountUpdatesListingMixinFactory.create(viewModelScope) + + val filtersAvailableFlow = listingMixin.accountTypeFilter.map { it !is FilterType.UserIgnored } + + val accounts: Flow> = listingMixin.metaAccountsFlow + + override val openBrowserEvent = MutableLiveData>() + + fun clickAbout() { + openBrowserEvent.value = appLinksProvider.wikiProxy.event() + } + + fun showProxieds() { + listingMixin.filterBy(FilterType.Proxied) + } + + fun showMultisig() { + listingMixin.filterBy(FilterType.Multisig) + } + + fun clickDone() { + launch { + accountInteractor.switchToNotDeactivatedAccountIfNeeded() + + accountRouter.back() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/DelegatedAccountsAdapter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/DelegatedAccountsAdapter.kt new file mode 100644 index 0000000..4883a1a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/DelegatedAccountsAdapter.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.delegationUpdates + +import android.view.ViewGroup +import coil.ImageLoader +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.databinding.ItemDelegatedAccountGroupBinding +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountDiffCallback +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountGroupViewHolderFactory +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.CommonAccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountTitleHolder +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountTitleGroupRvItem +import io.novafoundation.nova.feature_account_impl.R + +class DelegatedAccountsAdapter( + private val imageLoader: ImageLoader +) : CommonAccountsAdapter( + accountItemHandler = null, + imageLoader = imageLoader, + diffCallback = AccountDiffCallback(AccountTitleGroupRvItem::class.java), + groupFactory = DelegatedAccountsGroupFactory(), + groupBinder = { holder, item -> (holder as AccountTitleHolder).bind(item) }, + chainBorderColor = R.color.bottom_sheet_background, + initialMode = AccountHolder.Mode.VIEW +) + +private class DelegatedAccountsGroupFactory : AccountGroupViewHolderFactory { + + override fun create(parent: ViewGroup): GroupedListHolder { + return AccountTitleHolder(ItemDelegatedAccountGroupBinding.inflate(parent.inflater(), parent, false)) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/di/DelegatedAccountUpdatesComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/di/DelegatedAccountUpdatesComponent.kt new file mode 100644 index 0000000..042959d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/di/DelegatedAccountUpdatesComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.delegationUpdates.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.account.list.delegationUpdates.DelegatedAccountUpdatesBottomSheet + +@Subcomponent( + modules = [ + DelegatedAccountUpdatesModule::class + ] +) +@ScreenScope +interface DelegatedAccountUpdatesComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): DelegatedAccountUpdatesComponent + } + + fun inject(bottomSheet: DelegatedAccountUpdatesBottomSheet) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/di/DelegatedAccountUpdatesModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/di/DelegatedAccountUpdatesModule.kt new file mode 100644 index 0000000..f415b84 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/delegationUpdates/di/DelegatedAccountUpdatesModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.delegationUpdates.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.delegated.DelegatedMetaAccountUpdatesListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.list.delegationUpdates.DelegatedAccountUpdatesViewModel + +@Module(includes = [ViewModelModule::class]) +class DelegatedAccountUpdatesModule { + + @Provides + @IntoMap + @ViewModelKey(DelegatedAccountUpdatesViewModel::class) + fun provideViewModel( + delegatedMetaAccountUpdatesListingMixinFactory: DelegatedMetaAccountUpdatesListingMixinFactory, + accountRouter: AccountRouter, + appLinksProvider: AppLinksProvider, + accountInteractor: AccountInteractor + ): ViewModel { + return DelegatedAccountUpdatesViewModel( + delegatedMetaAccountUpdatesListingMixinFactory, + accountRouter, + appLinksProvider, + accountInteractor + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): DelegatedAccountUpdatesViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(DelegatedAccountUpdatesViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/SelectMultipleWalletsFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/SelectMultipleWalletsFragment.kt new file mode 100644 index 0000000..3773691 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/SelectMultipleWalletsFragment.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.multipleSelecting + +import android.os.Bundle +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog +import io.novafoundation.nova.common.view.recyclerview.adapter.text.TextAdapter +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsRequester +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentSelectMultipleWalletsBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import javax.inject.Inject + +class SelectMultipleWalletsFragment : BaseFragment(), AccountHolder.AccountItemHandler { + + companion object { + private const val KEY_REQUEST = "KEY_REQUEST" + + fun getBundle(request: SelectMultipleWalletsRequester.Request): Bundle { + return Bundle().apply { + putParcelable(KEY_REQUEST, request) + } + } + } + + override fun createBinding() = FragmentSelectMultipleWalletsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val titleAdapter by lazy(LazyThreadSafetyMode.NONE) { TextAdapter() } + + private val walletsAdapter by lazy(LazyThreadSafetyMode.NONE) { + AccountsAdapter( + this, + imageLoader, + initialMode = viewModel.mode, + chainBorderColor = R.color.bottom_sheet_background + ) + } + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { ConcatAdapter(titleAdapter, walletsAdapter) } + + override fun initViews() { + binder.selectMultipleWalletsToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.selectMultipleWalletsList.adapter = adapter + binder.selectMultipleWalletsConfirm.setOnClickListener { viewModel.confirm() } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .selectMultipleWalletsComponentFactory() + .create(this, argument(KEY_REQUEST)) + .inject(this) + } + + override fun subscribe(viewModel: SelectMultipleWalletsViewModel) { + setupConfirmationDialog(R.style.AccentNegativeAlertDialogTheme_Reversed, viewModel.closeConfirmationAction) + + viewModel.titleFlow.observe(titleAdapter::setText) + viewModel.walletsListingMixin.metaAccountsFlow.observe(walletsAdapter::submitList) + viewModel.confirmButtonState.observe(binder.selectMultipleWalletsConfirm::setState) + } + + override fun itemClicked(accountModel: AccountUi) { + viewModel.accountClicked(accountModel) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/SelectMultipleWalletsViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/SelectMultipleWalletsViewModel.kt new file mode 100644 index 0000000..3ee0aa9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/SelectMultipleWalletsViewModel.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.multipleSelecting + +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.actionAwaitable.fromRes +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsRequester +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsResponder +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountWithBalanceListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.SelectedMetaAccountState +import io.novafoundation.nova.feature_account_impl.presentation.account.list.WalletListViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SelectMultipleWalletsViewModel( + private val router: AccountRouter, + private val request: SelectMultipleWalletsRequester.Request, + private val responder: SelectMultipleWalletsResponder, + private val resourceManager: ResourceManager, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + accountListingMixinFactory: MetaAccountWithBalanceListingMixinFactory, +) : WalletListViewModel() { + + val closeConfirmationAction = actionAwaitableMixinFactory.confirmingAction() + + val titleFlow = flowOf(request.titleText) + + val selectedMetaAccounts = MutableStateFlow(SelectedMetaAccountState.Specified(request.currentlySelectedMetaIds)) + + override val walletsListingMixin = accountListingMixinFactory.create( + coroutineScope = this, + showUpdatedMetaAccountsBadge = false, + metaAccountSelectedFlow = selectedMetaAccounts + ) + + override val mode: AccountHolder.Mode = AccountHolder.Mode.SELECT_MULTIPLE + + val confirmButtonState = selectedMetaAccounts.map { selectedMetaAccounts -> + if (selectedMetaAccounts.ids.size < request.min) { + val disabledText = resourceManager.getQuantityString(R.plurals.multiple_wallets_selection_min_button_text, request.min, request.min) + DescriptiveButtonState.Disabled(disabledText) + } else { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_confirm)) + } + } + + fun backClicked() { + launch { + val dataHasBeenChanged = selectedMetaAccounts.value.ids != request.currentlySelectedMetaIds + + if (dataHasBeenChanged) { + closeConfirmationAction.awaitAction( + ConfirmationDialogInfo.fromRes( + resourceManager, + R.string.common_confirmation_title, + R.string.common_close_confirmation_message, + R.string.common_close, + R.string.common_cancel, + ) + ) + } + + router.back() + } + } + + fun confirm() { + responder.respond(SelectMultipleWalletsResponder.Response(selectedMetaAccounts.value.ids)) + router.back() + } + + override fun accountClicked(accountModel: AccountUi) { + val selected = mutableSetOf(*selectedMetaAccounts.value.ids.toTypedArray()) + + if (selected.contains(accountModel.id)) { + selected.remove(accountModel.id) + } else { + if (selected.size >= request.max) { + this.showToast(resourceManager.getString(R.string.multiple_wallets_selection_max_message, request.max)) + return + } + + selected.add(accountModel.id) + } + + selectedMetaAccounts.value = SelectedMetaAccountState.Specified(selected) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/di/SelectMultipleWalletsComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/di/SelectMultipleWalletsComponent.kt new file mode 100644 index 0000000..049792c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/di/SelectMultipleWalletsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.multipleSelecting.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsRequester +import io.novafoundation.nova.feature_account_impl.presentation.account.list.multipleSelecting.SelectMultipleWalletsFragment + +@Subcomponent( + modules = [ + SelectMultipleWalletsModule::class + ] +) +@ScreenScope +interface SelectMultipleWalletsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance request: SelectMultipleWalletsRequester.Request + ): SelectMultipleWalletsComponent + } + + fun inject(fragment: SelectMultipleWalletsFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/di/SelectMultipleWalletsModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/di/SelectMultipleWalletsModule.kt new file mode 100644 index 0000000..aa691dd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/multipleSelecting/di/SelectMultipleWalletsModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.multipleSelecting.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsRequester +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountWithBalanceListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.list.multipleSelecting.SelectMultipleWalletsViewModel + +@Module(includes = [ViewModelModule::class]) +class SelectMultipleWalletsModule { + + @Provides + @IntoMap + @ViewModelKey(SelectMultipleWalletsViewModel::class) + fun provideViewModel( + accountListingMixinFactory: MetaAccountWithBalanceListingMixinFactory, + router: AccountRouter, + selectMultipleWalletsCommunicator: SelectMultipleWalletsCommunicator, + request: SelectMultipleWalletsRequester.Request, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + resourceManager: ResourceManager + ): ViewModel { + return SelectMultipleWalletsViewModel( + accountListingMixinFactory = accountListingMixinFactory, + router = router, + responder = selectMultipleWalletsCommunicator, + request = request, + resourceManager = resourceManager, + actionAwaitableMixinFactory = actionAwaitableMixinFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectMultipleWalletsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectMultipleWalletsViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/SelectAddressBottomSheet.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/SelectAddressBottomSheet.kt new file mode 100644 index 0000000..2fad81a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/SelectAddressBottomSheet.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.selectAddress + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressRequester +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.list.WalletListFragment + +class SelectAddressBottomSheet : WalletListFragment() { + + companion object { + private const val KEY_REQUEST = "KEY_REQUEST" + + fun getBundle(request: SelectAddressRequester.Request): Bundle { + return Bundle().apply { + putParcelable(KEY_REQUEST, request) + } + } + } + + override fun initViews() { + super.initViews() + setTitleRes(R.string.assets_select_send_your_wallets) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .selectAddressComponentFactory() + .create(this, argument(KEY_REQUEST)) + .inject(this) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/SelectAddressViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/SelectAddressViewModel.kt new file mode 100644 index 0000000..35cfdb6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/SelectAddressViewModel.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.selectAddress + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.SelectedAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressRequester.Request +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressResponder +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountValidForTransactionListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.list.WalletListViewModel +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.toMetaAccountsFilter +import kotlinx.coroutines.launch + +class SelectAddressViewModel( + accountListingMixinFactory: MetaAccountValidForTransactionListingMixinFactory, + private val router: AccountRouter, + private val selectAddressResponder: SelectAddressResponder, + private val accountInteractor: AccountInteractor, + private val request: Request, +) : WalletListViewModel() { + + override val walletsListingMixin = accountListingMixinFactory.create( + this, + request.chainId, + request.selectedAddress?.let { SelectedAccountPayload.Address(it) }, + request.filter.toMetaAccountsFilter() + ) + + override val mode: AccountHolder.Mode = AccountHolder.Mode.SWITCH + + override fun accountClicked(accountModel: AccountUi) { + launch { + val address = accountInteractor.getChainAddress(accountModel.id, request.chainId) + if (address != null) { + selectAddressResponder.respond(SelectAddressResponder.Response(address)) + router.back() + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/di/SelectAddressComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/di/SelectAddressComponent.kt new file mode 100644 index 0000000..1d97ae4 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/di/SelectAddressComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.selectAddress.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressRequester +import io.novafoundation.nova.feature_account_impl.presentation.account.list.selectAddress.SelectAddressBottomSheet + +@Subcomponent( + modules = [ + SelectAddressModule::class + ] +) +@ScreenScope +interface SelectAddressComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance request: SelectAddressRequester.Request + ): SelectAddressComponent + } + + fun inject(fragment: SelectAddressBottomSheet) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/di/SelectAddressModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/di/SelectAddressModule.kt new file mode 100644 index 0000000..6d31d22 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/selectAddress/di/SelectAddressModule.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.selectAddress.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressRequester +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountValidForTransactionListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.list.selectAddress.SelectAddressViewModel + +@Module(includes = [ViewModelModule::class]) +class SelectAddressModule { + @Provides + @IntoMap + @ViewModelKey(SelectAddressViewModel::class) + fun provideViewModel( + accountListingMixinFactory: MetaAccountValidForTransactionListingMixinFactory, + router: AccountRouter, + selectAddressCommunicator: SelectAddressCommunicator, + accountInteractor: AccountInteractor, + request: SelectAddressRequester.Request + ): ViewModel { + return SelectAddressViewModel( + accountListingMixinFactory = accountListingMixinFactory, + router = router, + selectAddressResponder = selectAddressCommunicator, + accountInteractor = accountInteractor, + request = request + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectAddressViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectAddressViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/SelectSingleWalletFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/SelectSingleWalletFragment.kt new file mode 100644 index 0000000..69b6c4d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/SelectSingleWalletFragment.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.singleSelecting + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletRequester +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentSelectSingleWalletBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import javax.inject.Inject + +class SelectSingleWalletFragment : BaseFragment(), AccountHolder.AccountItemHandler { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentSelectSingleWalletBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + AccountsAdapter( + this, + imageLoader, + initialMode = AccountHolder.Mode.SELECT, + chainBorderColor = R.color.secondary_screen_background + ) + } + + override fun initViews() { + binder.selectSingleWalletToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.selectSingleWalletList.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .selectSingleWalletComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: SelectSingleWalletViewModel) { + viewModel.walletsListingMixin.metaAccountsFlow.observe(adapter::submitList) + } + + override fun itemClicked(accountModel: AccountUi) { + viewModel.accountClicked(accountModel) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/SelectSingleWalletViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/SelectSingleWalletViewModel.kt new file mode 100644 index 0000000..7aa9773 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/SelectSingleWalletViewModel.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.singleSelecting + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.SelectedAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountValidForTransactionListingMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.toMetaAccountsFilter +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletRequester +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletResponder +import kotlinx.coroutines.launch + +class SelectSingleWalletViewModel( + accountListingMixinFactory: MetaAccountValidForTransactionListingMixinFactory, + private val router: AccountRouter, + private val selectSingleWalletResponder: SelectSingleWalletResponder, + private val request: SelectSingleWalletRequester.Request, +) : BaseViewModel() { + + val walletsListingMixin = accountListingMixinFactory.create( + coroutineScope = this, + chainId = request.chainId, + selectedAccount = request.selectedMetaId?.let { SelectedAccountPayload.MetaAccount(it) }, + metaAccountFilter = request.filter.toMetaAccountsFilter() + ) + + fun accountClicked(accountModel: AccountUi) { + launch { + selectSingleWalletResponder.respond(SelectSingleWalletResponder.Response(accountModel.id)) + router.back() + } + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/di/SelectSingleWalletComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/di/SelectSingleWalletComponent.kt new file mode 100644 index 0000000..6736f06 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/di/SelectSingleWalletComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.singleSelecting.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletRequester +import io.novafoundation.nova.feature_account_impl.presentation.account.list.singleSelecting.SelectSingleWalletFragment + +@Subcomponent( + modules = [ + SelectSingleWalletModule::class + ] +) +@ScreenScope +interface SelectSingleWalletComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance request: SelectSingleWalletRequester.Request + ): SelectSingleWalletComponent + } + + fun inject(fragment: SelectSingleWalletFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/di/SelectSingleWalletModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/di/SelectSingleWalletModule.kt new file mode 100644 index 0000000..8ee91dd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/singleSelecting/di/SelectSingleWalletModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.singleSelecting.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletRequester +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountValidForTransactionListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.list.singleSelecting.SelectSingleWalletViewModel + +@Module(includes = [ViewModelModule::class]) +class SelectSingleWalletModule { + + @Provides + @IntoMap + @ViewModelKey(SelectSingleWalletViewModel::class) + fun provideViewModel( + accountListingMixinFactory: MetaAccountValidForTransactionListingMixinFactory, + router: AccountRouter, + selectSingleWalletCommunicator: SelectSingleWalletCommunicator, + request: SelectSingleWalletRequester.Request + ): ViewModel { + return SelectSingleWalletViewModel( + accountListingMixinFactory = accountListingMixinFactory, + router = router, + selectSingleWalletResponder = selectSingleWalletCommunicator, + request = request + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectSingleWalletViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectSingleWalletViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/SwitchWalletFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/SwitchWalletFragment.kt new file mode 100644 index 0000000..12e9fc7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/SwitchWalletFragment.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.switching + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.list.WalletListFragment + +class SwitchWalletFragment : WalletListFragment() { + + override fun initViews() { + super.initViews() + setTitleRes(R.string.account_select_wallet) + setActionIcon(R.drawable.ic_settings_outline) + setActionClickListener { viewModel.settingsClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .switchWalletComponentFactory() + .create(this) + .inject(this) + } + + override fun onDestroy() { + super.onDestroy() + viewModel.onDestroy() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/SwitchWalletViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/SwitchWalletViewModel.kt new file mode 100644 index 0000000..9f2ba59 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/SwitchWalletViewModel.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.switching + +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountWithBalanceListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.list.WalletListViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class SwitchWalletViewModel( + private val accountInteractor: AccountInteractor, + private val router: AccountRouter, + private val metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry, + private val rootScope: RootScope, + accountListingMixinFactory: MetaAccountWithBalanceListingMixinFactory, +) : WalletListViewModel() { + + override val walletsListingMixin = accountListingMixinFactory.create(this) + + override val mode: AccountHolder.Mode = AccountHolder.Mode.SWITCH + + init { + if (metaAccountsUpdatesRegistry.hasUpdates()) { + router.openDelegatedAccountsUpdates() + } + } + + override fun accountClicked(accountModel: AccountUi) { + launch { + accountInteractor.selectMetaAccount(accountModel.id) + + router.back() + } + } + + fun settingsClicked() { + router.openWallets() + } + + fun onDestroy() { + rootScope.launch(Dispatchers.Default) { + metaAccountsUpdatesRegistry.clear() + accountInteractor.removeDeactivatedMetaAccounts() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/di/SwitchWalletComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/di/SwitchWalletComponent.kt new file mode 100644 index 0000000..13deb9b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/di/SwitchWalletComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.switching.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.account.list.switching.SwitchWalletFragment + +@Subcomponent( + modules = [ + SwitchWalletModule::class + ] +) +@ScreenScope +interface SwitchWalletComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): SwitchWalletComponent + } + + fun inject(fragment: SwitchWalletFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/di/SwitchWalletModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/di/SwitchWalletModule.kt new file mode 100644 index 0000000..3c0a237 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/list/switching/di/SwitchWalletModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.list.switching.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountWithBalanceListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.list.switching.SwitchWalletViewModel + +@Module(includes = [ViewModelModule::class]) +class SwitchWalletModule { + + @Provides + @IntoMap + @ViewModelKey(SwitchWalletViewModel::class) + fun provideViewModel( + accountInteractor: AccountInteractor, + router: AccountRouter, + accountListingMixinFactory: MetaAccountWithBalanceListingMixinFactory, + metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry, + rootScope: RootScope + ): ViewModel { + return SwitchWalletViewModel( + accountInteractor = accountInteractor, + router = router, + accountListingMixinFactory = accountListingMixinFactory, + metaAccountsUpdatesRegistry = metaAccountsUpdatesRegistry, + rootScope = rootScope + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SwitchWalletViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SwitchWalletViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/WalletManagmentFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/WalletManagmentFragment.kt new file mode 100644 index 0000000..93fc9fe --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/WalletManagmentFragment.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.management + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.common.view.input.selector.setupListSelectorMixin +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentAccountsBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.observeConfirmationAction +import javax.inject.Inject + +class WalletManagmentFragment : BaseFragment(), AccountHolder.AccountItemHandler { + + override fun createBinding() = FragmentAccountsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private lateinit var adapter: AccountsAdapter + + override fun initViews() { + adapter = AccountsAdapter( + this, + imageLoader, + initialMode = viewModel.mode.value, + chainBorderColor = R.color.secondary_screen_background + ) + + binder.accountsList.setHasFixedSize(true) + binder.accountsList.adapter = adapter + + binder.accountListToolbar.setRightActionClickListener { viewModel.editClicked() } + binder.accountListToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.addAccount.setOnClickListener { viewModel.addAccountClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .walletManagmentComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: WalletManagmentViewModel) { + observeConfirmationAction(viewModel.cloudBackupChangingWarningMixin) + setupListSelectorMixin(viewModel.listSelectorMixin) + viewModel.walletsListingMixin.metaAccountsFlow.observe(adapter::submitList) + viewModel.mode.observe(adapter::setMode) + + viewModel.toolbarAction.observe(binder.accountListToolbar::setTextRight) + + viewModel.confirmAccountDeletion.awaitableActionLiveData.observeEvent { + warningDialog( + requireContext(), + onPositiveClick = { it.onSuccess(true) }, + onNegativeClick = { it.onSuccess(false) }, + positiveTextRes = R.string.account_delete_confirm + ) { + setTitle(R.string.account_delete_confirmation_title) + setMessage(R.string.account_delete_confirmation_description) + } + } + } + + override fun itemClicked(accountModel: AccountUi) { + viewModel.accountClicked(accountModel) + } + + override fun deleteClicked(accountModel: AccountUi) { + viewModel.deleteClicked(accountModel) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/WalletManagmentViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/WalletManagmentViewModel.kt new file mode 100644 index 0000000..c76928f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/WalletManagmentViewModel.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.management + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.awaitAction +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingOrDenyingAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder.Mode +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountWithBalanceListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletPayload +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletPayload.FlowType +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class WalletManagmentViewModel( + private val accountInteractor: AccountInteractor, + private val accountRouter: AccountRouter, + private val resourceManager: ResourceManager, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val accountListingMixinFactory: MetaAccountWithBalanceListingMixinFactory, + cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory, + listSelectorMixinFactory: ListSelectorMixin.Factory, +) : BaseViewModel() { + + val cloudBackupChangingWarningMixin = cloudBackupChangingWarningMixinFactory.create(viewModelScope) + + val walletsListingMixin = accountListingMixinFactory.create(this) + + val mode = MutableStateFlow(Mode.SELECT) + + val listSelectorMixin = listSelectorMixinFactory.create(viewModelScope) + + val toolbarAction = mode.map { + if (it == Mode.SELECT) { + resourceManager.getString(R.string.common_edit) + } else { + resourceManager.getString(R.string.common_done) + } + } + .shareInBackground() + + val confirmAccountDeletion = actionAwaitableMixinFactory.confirmingOrDenyingAction() + + fun accountClicked(accountModel: AccountUi) { + accountRouter.openWalletDetails(accountModel.id) + } + + fun editClicked() { + val newMode = if (mode.value == Mode.SELECT) Mode.EDIT else Mode.SELECT + + mode.value = newMode + } + + fun deleteClicked(account: AccountUi) { + cloudBackupChangingWarningMixin.launchRemovingConfirmationIfNeeded { + launch { + val deleteConfirmed = confirmAccountDeletion.awaitAction() + + if (deleteConfirmed) { + val isAllMetaAccountsWasDeleted = accountInteractor.deleteAccount(account.id) + if (isAllMetaAccountsWasDeleted) { + accountRouter.openWelcomeScreen() + } + } + } + } + } + + fun backClicked() { + accountRouter.back() + } + + fun addAccountClicked() { + listSelectorMixin.showSelector( + R.string.wallet_management_add_account_title, + listOf(createWalletItem(), importWalletItem()) + ) + } + + private fun createWalletItem(): ListSelectorMixin.Item { + return ListSelectorMixin.Item( + R.drawable.ic_add_circle_outline, + R.color.icon_primary, + R.string.account_create_wallet, + R.color.text_primary, + ::onCreateNewWalletClicked + ) + } + + private fun importWalletItem(): ListSelectorMixin.Item { + return ListSelectorMixin.Item( + R.drawable.ic_import, + R.color.icon_primary, + R.string.account_export_existing, + R.color.text_primary, + ::onImportWalletClicked + ) + } + + private fun onImportWalletClicked() { + accountRouter.openImportOptionsScreen() + } + + private fun onCreateNewWalletClicked() { + cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded { + accountRouter.openCreateWallet(StartCreateWalletPayload(FlowType.SECOND_WALLET)) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/di/WalletManagmentComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/di/WalletManagmentComponent.kt new file mode 100644 index 0000000..c0a1e44 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/di/WalletManagmentComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.management.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.account.management.WalletManagmentFragment + +@Subcomponent( + modules = [ + WalletManagmentModule::class + ] +) +@ScreenScope +interface WalletManagmentComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): WalletManagmentComponent + } + + fun inject(fragment: WalletManagmentFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/di/WalletManagmentModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/di/WalletManagmentModule.kt new file mode 100644 index 0000000..086edeb --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/management/di/WalletManagmentModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.management.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountWithBalanceListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.management.WalletManagmentViewModel +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory + +@Module(includes = [ViewModelModule::class]) +class WalletManagmentModule { + + @Provides + @IntoMap + @ViewModelKey(WalletManagmentViewModel::class) + fun provideViewModel( + interactor: AccountInteractor, + router: AccountRouter, + resourceManager: ResourceManager, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + metaAccountListingMixinFactory: MetaAccountWithBalanceListingMixinFactory, + cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory, + listSelectorMixinFactory: ListSelectorMixin.Factory + ): ViewModel { + return WalletManagmentViewModel( + interactor, + router, + resourceManager, + actionAwaitableMixinFactory, + metaAccountListingMixinFactory, + cloudBackupChangingWarningMixinFactory, + listSelectorMixinFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): WalletManagmentViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(WalletManagmentViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/mixin/SelectAddressMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/mixin/SelectAddressMixin.kt new file mode 100644 index 0000000..d525afe --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/mixin/SelectAddressMixin.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.mixin + +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressRequester +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.toRequestFilter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +class SelectAddressMixinFactory( + private val selectAddressRequester: SelectAddressRequester, + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, +) : SelectAddressMixin.Factory { + + override fun create( + coroutineScope: CoroutineScope, + payloadFlow: Flow, + onAddressSelect: (String) -> Unit + ): SelectAddressMixin { + return RealSelectAddressMixin( + coroutineScope, + selectAddressRequester, + payloadFlow, + metaAccountGroupingInteractor, + onAddressSelect + ) + } +} + +class RealSelectAddressMixin( + private val coroutineScope: CoroutineScope, + private val selectAddressRequester: SelectAddressRequester, + private val payloadFlow: Flow, + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + private val onAddressSelect: (String) -> Unit +) : SelectAddressMixin { + + init { + selectAddressRequester.responseFlow + .onEach { onAddressSelect(it.selectedAddress) } + .launchIn(coroutineScope) + } + + override val isSelectAddressAvailableFlow: Flow = payloadFlow.map { payload -> + metaAccountGroupingInteractor.hasAvailableMetaAccountsForChain(payload.chain.id, payload.filter) + } + + override suspend fun openSelectAddress(selectedAddress: String?) { + val payload = payloadFlow.first() + val metaAccountFilter = payload.filter.toRequestFilter() + val request = SelectAddressRequester.Request(payload.chain.id, selectedAddress, metaAccountFilter) + selectAddressRequester.openRequest(request) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/mixin/SelectSingleWalletMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/mixin/SelectSingleWalletMixin.kt new file mode 100644 index 0000000..f391377 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/mixin/SelectSingleWalletMixin.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.mixin + +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.toRequestFilter +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletRequester +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +class SelectSingleWalletMixinFactory( + private val selectSingleWalletRequester: SelectSingleWalletRequester, + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, +) : SelectSingleWalletMixin.Factory { + + override fun create( + coroutineScope: CoroutineScope, + payloadFlow: Flow, + onWalletSelect: (Long) -> Unit + ): SelectSingleWalletMixin { + return RealSelectSingleWalletMixin( + coroutineScope, + selectSingleWalletRequester, + payloadFlow, + metaAccountGroupingInteractor, + onWalletSelect + ) + } +} + +class RealSelectSingleWalletMixin( + private val coroutineScope: CoroutineScope, + private val selectSingleWalletRequester: SelectSingleWalletRequester, + private val payloadFlow: Flow, + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + private val onWalletSelect: (Long) -> Unit +) : SelectSingleWalletMixin { + + init { + selectSingleWalletRequester.responseFlow + .onEach { onWalletSelect(it.metaId) } + .launchIn(coroutineScope) + } + + override val isSelectWalletAvailableFlow: Flow = payloadFlow.map { payload -> + metaAccountGroupingInteractor.hasAvailableMetaAccountsForChain(payload.chain.id, payload.filter) + } + + override suspend fun openSelectWallet(selectedWallet: Long?) { + val payload = payloadFlow.first() + val metaAccountFilter = payload.filter.toRequestFilter() + val request = SelectSingleWalletRequester.Request(payload.chain.id, selectedWallet, metaAccountFilter) + selectSingleWalletRequester.openRequest(request) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/wallet/WalletUiUseCaseImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/wallet/WalletUiUseCaseImpl.kt new file mode 100644 index 0000000..5e4668c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/account/wallet/WalletUiUseCaseImpl.kt @@ -0,0 +1,161 @@ +package io.novafoundation.nova.feature_account_impl.presentation.account.wallet + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressIconGenerator.Companion.BACKGROUND_DEFAULT +import io.novafoundation.nova.common.address.AddressIconGenerator.Companion.BACKGROUND_TRANSPARENT +import io.novafoundation.nova.common.address.AddressIconGenerator.Companion.SIZE_MEDIUM +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.utils.ByteArrayComparator +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount.ChainAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModelOrNull +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressIcon +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest + +class WalletUiUseCaseImpl( + private val accountRepository: AccountRepository, + private val addressIconGenerator: AddressIconGenerator, + private val chainRegistry: ChainRegistry +) : WalletUiUseCase { + + override fun selectedWalletUiFlow( + showAddressIcon: Boolean + ): Flow { + return accountRepository.selectedMetaAccountFlow().mapLatest { metaAccount -> + val icon = maybeGenerateIcon(accountId = metaAccount.walletIconSeed(), shouldGenerate = showAddressIcon) + + WalletModel( + metaId = metaAccount.id, + name = metaAccount.name, + icon = icon + ) + } + } + + override fun walletUiFlow(metaId: Long, showAddressIcon: Boolean): Flow { + return flowOf { + val metaAccount = accountRepository.getMetaAccount(metaId) + val icon = maybeGenerateIcon(accountId = metaAccount.walletIconSeed(), shouldGenerate = showAddressIcon) + + WalletModel( + metaId = metaId, + name = metaAccount.name, + icon = icon + ) + } + } + + override fun walletUiFlow(metaId: Long, chainId: String, showAddressIcon: Boolean): Flow { + return flowOf { + val metaAccount = accountRepository.getMetaAccount(metaId) + val chain = chainRegistry.getChain(chainId) + val icon = maybeGenerateIcon(accountId = metaAccount.accountIdIn(chain)!!, shouldGenerate = showAddressIcon) + + WalletModel( + metaId = metaId, + name = metaAccount.name, + icon = icon + ) + } + } + + override suspend fun selectedWalletUi(): WalletModel { + val metaAccount = accountRepository.getSelectedMetaAccount() + + return WalletModel( + metaId = metaAccount.id, + name = metaAccount.name, + icon = walletIcon(metaAccount, SIZE_MEDIUM) + ) + } + + override suspend fun walletIcon( + substrateAccountId: AccountId?, + ethereumAccountId: AccountId?, + chainAccountIds: List, + iconSize: Int, + transparentBackground: Boolean + ): Drawable { + val seed = walletSeed(substrateAccountId, ethereumAccountId, chainAccountIds) + + return generateWalletIcon(seed, iconSize, transparentBackground) + } + + override suspend fun walletIcon( + metaAccount: MetaAccount, + iconSize: Int, + transparentBackground: Boolean + ): Drawable { + val seed = metaAccount.walletIconSeed() + + return generateWalletIcon(seed, iconSize, transparentBackground) + } + + override suspend fun walletUiFor(metaAccount: MetaAccount): WalletModel { + return WalletModel( + metaId = metaAccount.id, + name = metaAccount.name, + icon = walletIcon(metaAccount, SIZE_MEDIUM, transparentBackground = true) + ) + } + + private suspend fun maybeGenerateIcon(accountId: AccountId, shouldGenerate: Boolean): Drawable? { + return if (shouldGenerate) { + generateWalletIcon(seed = accountId, iconSize = SIZE_MEDIUM, transparentBackground = true) + } else { + null + } + } + + private suspend fun generateWalletIcon(seed: ByteArray, iconSize: Int, transparentBackground: Boolean): Drawable { + return addressIconGenerator.createAddressIcon( + accountId = seed, + sizeInDp = iconSize, + backgroundColorRes = if (transparentBackground) BACKGROUND_TRANSPARENT else BACKGROUND_DEFAULT + ) + } + + private fun walletSeed(substrateAccountId: AccountId?, ethereumAccountId: AccountId?, chainAccountIds: List): AccountId { + return when { + substrateAccountId != null -> substrateAccountId + ethereumAccountId != null -> ethereumAccountId + + // if both default accounts are null there MUST be at least one chain account. Otherwise it's an invalid state + else -> { + chainAccountIds + .sortedWith(ByteArrayComparator()) + .first() + } + } + } + + private fun MetaAccount.walletIconSeed(): ByteArray { + return walletSeed(substrateAccountId, ethereumAddress, chainAccounts.values.map(ChainAccount::accountId)) + } + + override suspend fun walletAddressModel(metaAccount: MetaAccount, chain: Chain, iconSize: Int): AddressModel { + return addressIconGenerator.createAccountAddressModel( + chain = chain, + account = metaAccount, + name = metaAccount.name + ) + } + + override suspend fun walletAddressModelOrNull(metaAccount: MetaAccount, chain: Chain, iconSize: Int): AddressModel? { + return addressIconGenerator.createAccountAddressModelOrNull( + chain = chain, + account = metaAccount, + name = metaAccount.name + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/base/CreateBackupPasswordFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/base/CreateBackupPasswordFragment.kt new file mode 100644 index 0000000..1204ce3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/base/CreateBackupPasswordFragment.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.base + +import android.widget.TextView +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.setCompoundDrawableTintRes +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.switchPasswordInputType +import io.novafoundation.nova.common.view.bottomSheet.action.observeActionBottomSheet +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentCreateCloudBackupPasswordBinding + +abstract class CreateBackupPasswordFragment : BaseFragment() { + + override fun createBinding() = FragmentCreateCloudBackupPasswordBinding.inflate(layoutInflater) + + abstract val titleRes: Int + abstract val subtitleRes: Int + + override fun initViews() { + binder.createCloudBackupPasswordToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.createBackupPasswordTitle.setText(titleRes) + binder.createBackupPasswordSubtitle.setText(subtitleRes) + + binder.createCloudBackupPasswordContinue.setOnClickListener { viewModel.continueClicked() } + binder.createCloudBackupPasswordInput.setEndIconOnClickListener { viewModel.toggleShowPassword() } + binder.createCloudBackupPasswordConfirmInput.setEndIconOnClickListener { viewModel.toggleShowPassword() } + binder.createCloudBackupPasswordContinue.prepareForProgress(viewLifecycleOwner) + } + + override fun subscribe(viewModel: T) { + observeActionBottomSheet(viewModel) + + binder.createCloudBackupPasswordInput.content.bindTo(viewModel.passwordFlow, lifecycleScope) + binder.createCloudBackupPasswordConfirmInput.content.bindTo(viewModel.passwordConfirmFlow, lifecycleScope) + + viewModel.passwordStateFlow.observe { state -> + binder.createCloudBackupPasswordMinChars.requirementState(state.containsMinSymbols) + binder.createCloudBackupPasswordNumbers.requirementState(state.hasNumbers) + binder.createCloudBackupPasswordLetters.requirementState(state.hasLetters) + binder.createCloudBackupPasswordPasswordsMatch.requirementState(state.passwordsMatch) + } + + viewModel.continueButtonState.observe { state -> + binder.createCloudBackupPasswordContinue.setState(state) + } + + viewModel.showPasswords.observe { + binder.createCloudBackupPasswordInput.content.switchPasswordInputType(it) + binder.createCloudBackupPasswordConfirmInput.content.switchPasswordInputType(it) + } + } + + private fun TextView.requirementState(isValid: Boolean) { + if (isValid) { + setTextColorRes(R.color.text_positive) + setCompoundDrawableTintRes(R.color.icon_positive) + } else { + setTextColorRes(R.color.text_secondary) + setCompoundDrawableTintRes(R.color.icon_secondary) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/base/CreateBackupPasswordViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/base/CreateBackupPasswordViewModel.kt new file mode 100644 index 0000000..091f7b3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/base/CreateBackupPasswordViewModel.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.base + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.CreateCloudBackupPasswordInteractor +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.model.PasswordErrors +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchRememberPasswordWarning +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +class PasswordInputState( + val containsMinSymbols: Boolean, + val hasLetters: Boolean, + val hasNumbers: Boolean, + val passwordsMatch: Boolean +) { + + val isRequirementsSatisfied = containsMinSymbols && hasLetters && hasNumbers && passwordsMatch +} + +abstract class BackupCreatePasswordViewModel( + protected val router: AccountRouter, + protected val resourceManager: ResourceManager, + protected val interactor: CreateCloudBackupPasswordInteractor, + private val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory +) : BaseViewModel(), ActionBottomSheetLauncher by actionBottomSheetLauncherFactory.create() { + + val passwordFlow = MutableStateFlow("") + val passwordConfirmFlow = MutableStateFlow("") + + val _showPasswords = MutableStateFlow(false) + val showPasswords: Flow = _showPasswords + + val passwordStateFlow = combine(passwordFlow, passwordConfirmFlow) { password, confirm -> + val passwordErrors = interactor.checkPasswords(password, confirm) + PasswordInputState( + containsMinSymbols = PasswordErrors.TOO_SHORT !in passwordErrors, + hasLetters = PasswordErrors.NO_LETTERS !in passwordErrors, + hasNumbers = PasswordErrors.NO_DIGITS !in passwordErrors, + passwordsMatch = PasswordErrors.PASSWORDS_DO_NOT_MATCH !in passwordErrors + ) + }.shareInBackground() + + protected val backupInProgress = MutableStateFlow(false) + + val continueButtonState = combine(passwordStateFlow, backupInProgress) { passwordState, backupInProgress -> + when { + backupInProgress && passwordState.isRequirementsSatisfied -> DescriptiveButtonState.Loading + passwordState.isRequirementsSatisfied -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + else -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_password)) + } + }.shareInBackground() + + init { + showPasswordWarningDialog() + } + + fun continueClicked() { + launch { + backupInProgress.value = true + internalContinueClicked(passwordFlow.value) + backupInProgress.value = false + } + } + + abstract suspend fun internalContinueClicked(password: String) + + open fun backClicked() { + router.back() + } + + fun toggleShowPassword() { + _showPasswords.toggle() + } + + private fun showPasswordWarningDialog() { + launchRememberPasswordWarning(resourceManager) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/ChangeBackupPasswordFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/ChangeBackupPasswordFragment.kt new file mode 100644 index 0000000..559907f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/ChangeBackupPasswordFragment.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.changePassword + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.setupCustomDialogDisplayer +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.base.CreateBackupPasswordFragment + +class ChangeBackupPasswordFragment : CreateBackupPasswordFragment() { + + override val titleRes: Int = R.string.change_cloud_backup_password_title + override val subtitleRes: Int = R.string.change_cloud_backup_password_subtitle + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .changeBackupPasswordComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ChangeBackupPasswordViewModel) { + setupCustomDialogDisplayer(viewModel) + super.subscribe(viewModel) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/ChangeBackupPasswordViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/ChangeBackupPasswordViewModel.kt new file mode 100644 index 0000000..dcd9b54 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/ChangeBackupPasswordViewModel.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.changePassword + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.displayDialogOrNothing +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordResponder +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.CreateCloudBackupPasswordInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.base.BackupCreatePasswordViewModel +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapChangePasswordValidationStatusToUi +import kotlinx.coroutines.launch + +class ChangeBackupPasswordViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: CreateCloudBackupPasswordInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + private val changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator, + customDialogProvider: CustomDialogDisplayer.Presentation +) : BackupCreatePasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory +), + CustomDialogDisplayer.Presentation by customDialogProvider { + + override suspend fun internalContinueClicked(password: String) { + interactor.changePassword(password) + .onSuccess { + changeBackupPasswordCommunicator.respond(ChangeBackupPasswordResponder.Success) + router.back() + }.onFailure { throwable -> + val payload = mapChangePasswordValidationStatusToUi(resourceManager, throwable, ::initSignIn) + displayDialogOrNothing(payload) + } + } + + private fun initSignIn() { + launch { + interactor.signInToCloud() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/di/ChangeBackupPasswordComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/di/ChangeBackupPasswordComponent.kt new file mode 100644 index 0000000..7f537b0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/di/ChangeBackupPasswordComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.changePassword.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.changePassword.ChangeBackupPasswordFragment + +@Subcomponent( + modules = [ + ChangeBackupPasswordModule::class + ] +) +@ScreenScope +interface ChangeBackupPasswordComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): ChangeBackupPasswordComponent + } + + fun inject(fragment: ChangeBackupPasswordFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/di/ChangeBackupPasswordModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/di/ChangeBackupPasswordModule.kt new file mode 100644 index 0000000..3be14e9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/changePassword/di/ChangeBackupPasswordModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.changePassword.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.CreateCloudBackupPasswordInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.changePassword.ChangeBackupPasswordViewModel + +@Module(includes = [ViewModelModule::class]) +class ChangeBackupPasswordModule { + + @Provides + @IntoMap + @ViewModelKey(ChangeBackupPasswordViewModel::class) + fun provideViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: CreateCloudBackupPasswordInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator, + customDialogProvider: CustomDialogDisplayer.Presentation + ): ViewModel { + return ChangeBackupPasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory, + changeBackupPasswordCommunicator, + customDialogProvider + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ChangeBackupPasswordViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(ChangeBackupPasswordViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/CreateBackupPasswordPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/CreateBackupPasswordPayload.kt new file mode 100644 index 0000000..bf53cca --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/CreateBackupPasswordPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class CreateBackupPasswordPayload( + val walletName: String +) : Parcelable diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/CreateWalletBackupPasswordFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/CreateWalletBackupPasswordFragment.kt new file mode 100644 index 0000000..f107db9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/CreateWalletBackupPasswordFragment.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.base.CreateBackupPasswordFragment + +class CreateWalletBackupPasswordFragment : CreateBackupPasswordFragment() { + + companion object { + private const val KEY_PAYLOAD = "cloud_backup_password_payload" + + fun getBundle(payload: CreateBackupPasswordPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override val titleRes: Int = R.string.create_cloud_backup_password_title + override val subtitleRes: Int = R.string.create_cloud_backup_password_subtitle + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .createWalletBackupPasswordFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/CreateWalletBackupPasswordViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/CreateWalletBackupPasswordViewModel.kt new file mode 100644 index 0000000..fb35c0f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/CreateWalletBackupPasswordViewModel.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet + +import io.novafoundation.nova.common.base.showError +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.CreateCloudBackupPasswordInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.base.BackupCreatePasswordViewModel +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapWriteBackupFailureToUi + +class CreateWalletBackupPasswordViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: CreateCloudBackupPasswordInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + private val payload: CreateBackupPasswordPayload, + private val accountInteractor: AccountInteractor, +) : BackupCreatePasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory +) { + + override suspend fun internalContinueClicked(password: String) { + interactor.createAndBackupAccount(payload.walletName, password) + .onSuccess { continueBasedOnCodeStatus() } + .onFailure { + val titleAndMessage = mapWriteBackupFailureToUi(resourceManager, it) + showError(titleAndMessage) + } + } + + private suspend fun continueBasedOnCodeStatus() { + if (accountInteractor.isCodeSet()) { + router.openMain() + } else { + router.openCreatePincode() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/di/CreateWalletBackupPasswordComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/di/CreateWalletBackupPasswordComponent.kt new file mode 100644 index 0000000..68d47bd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/di/CreateWalletBackupPasswordComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet.CreateWalletBackupPasswordFragment +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet.CreateBackupPasswordPayload + +@Subcomponent( + modules = [ + CreateWalletBackupPasswordModule::class + ] +) +@ScreenScope +interface CreateWalletBackupPasswordComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: CreateBackupPasswordPayload + ): CreateWalletBackupPasswordComponent + } + + fun inject(fragment: CreateWalletBackupPasswordFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/di/CreateWalletBackupPasswordModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/di/CreateWalletBackupPasswordModule.kt new file mode 100644 index 0000000..50300bc --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/createWallet/di/CreateWalletBackupPasswordModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.CreateCloudBackupPasswordInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet.CreateBackupPasswordPayload +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.createWallet.CreateWalletBackupPasswordViewModel + +@Module(includes = [ViewModelModule::class]) +class CreateWalletBackupPasswordModule { + + @Provides + @IntoMap + @ViewModelKey(CreateWalletBackupPasswordViewModel::class) + fun provideViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: CreateCloudBackupPasswordInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + payload: CreateBackupPasswordPayload, + accountInteractor: AccountInteractor + ): ViewModel { + return CreateWalletBackupPasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory, + payload, + accountInteractor + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): CreateWalletBackupPasswordViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(CreateWalletBackupPasswordViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/SyncWalletsBackupPasswordFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/SyncWalletsBackupPasswordFragment.kt new file mode 100644 index 0000000..f46f61a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/SyncWalletsBackupPasswordFragment.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.syncWallets + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.base.CreateBackupPasswordFragment + +class SyncWalletsBackupPasswordFragment : CreateBackupPasswordFragment() { + + override val titleRes: Int = R.string.create_cloud_backup_password_title + override val subtitleRes: Int = R.string.create_cloud_backup_password_subtitle + + override fun initViews() { + super.initViews() + onBackPressed { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .syncWalletsBackupPasswordFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/SyncWalletsBackupPasswordViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/SyncWalletsBackupPasswordViewModel.kt new file mode 100644 index 0000000..fa985e3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/SyncWalletsBackupPasswordViewModel.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.syncWallets + +import io.novafoundation.nova.common.base.showError +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordResponder +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.CreateCloudBackupPasswordInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.base.BackupCreatePasswordViewModel +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapWriteBackupFailureToUi + +class SyncWalletsBackupPasswordViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: CreateCloudBackupPasswordInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + private val syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator +) : BackupCreatePasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory +) { + + override suspend fun internalContinueClicked(password: String) { + interactor.uploadInitialBackup(password) + .onSuccess { + syncWalletsBackupPasswordCommunicator.respond(SyncWalletsBackupPasswordResponder.Response(isSyncingSuccessful = true)) + router.back() + }.onFailure { throwable -> + val titleAndMessage = mapWriteBackupFailureToUi(resourceManager, throwable) + showError(titleAndMessage) + } + } + + override fun backClicked() { + syncWalletsBackupPasswordCommunicator.respond(SyncWalletsBackupPasswordResponder.Response(isSyncingSuccessful = false)) + super.backClicked() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/di/SyncWalletsBackupPasswordComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/di/SyncWalletsBackupPasswordComponent.kt new file mode 100644 index 0000000..49b98d9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/di/SyncWalletsBackupPasswordComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.syncWallets.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.syncWallets.SyncWalletsBackupPasswordFragment + +@Subcomponent( + modules = [ + SyncWalletsBackupPasswordModule::class + ] +) +@ScreenScope +interface SyncWalletsBackupPasswordComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): SyncWalletsBackupPasswordComponent + } + + fun inject(fragment: SyncWalletsBackupPasswordFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/di/SyncWalletsBackupPasswordModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/di/SyncWalletsBackupPasswordModule.kt new file mode 100644 index 0000000..b4059b8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/createPassword/syncWallets/di/SyncWalletsBackupPasswordModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.syncWallets.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.createPassword.CreateCloudBackupPasswordInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.createPassword.syncWallets.SyncWalletsBackupPasswordViewModel + +@Module(includes = [ViewModelModule::class]) +class SyncWalletsBackupPasswordModule { + + @Provides + @IntoMap + @ViewModelKey(SyncWalletsBackupPasswordViewModel::class) + fun provideViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: CreateCloudBackupPasswordInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator + ): ViewModel { + return SyncWalletsBackupPasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory, + syncWalletsBackupPasswordCommunicator + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SyncWalletsBackupPasswordViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(SyncWalletsBackupPasswordViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/base/EnterCloudBackupPasswordFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/base/EnterCloudBackupPasswordFragment.kt new file mode 100644 index 0000000..0db5220 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/base/EnterCloudBackupPasswordFragment.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.base + +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.switchPasswordInputType +import io.novafoundation.nova.common.view.bottomSheet.action.observeActionBottomSheet +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentRestoreCloudBackupBinding + +abstract class EnterCloudBackupPasswordFragment : BaseFragment() { + + override fun createBinding() = FragmentRestoreCloudBackupBinding.inflate(layoutInflater) + + abstract val titleRes: Int + abstract val subtitleRes: Int + + override fun initViews() { + binder.restoreCloudBackupToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.enterBackupPasswordTitle.setText(titleRes) + binder.enterBackupPasswordSubtitle.setText(subtitleRes) + + binder.restoreCloudBackupContinueBtn.prepareForProgress(viewLifecycleOwner) + binder.restoreCloudBackupContinueBtn.setOnClickListener { viewModel.continueClicked() } + binder.restoreCloudBackupInput.setEndIconOnClickListener { viewModel.toggleShowPassword() } + binder.restoreCloudBackupForgotPassword.setOnClickListener { viewModel.forgotPasswordClicked() } + } + + override fun subscribe(viewModel: T) { + observeActionBottomSheet(viewModel) + setupConfirmationDialog(R.style.AccentNegativeAlertDialogTheme_Reversed, viewModel.confirmationAwaitableAction) + + binder.restoreCloudBackupInput.content.bindTo(viewModel.passwordFlow, lifecycleScope) + + viewModel.continueButtonState.observe { state -> + binder.restoreCloudBackupContinueBtn.setState(state) + } + + viewModel.showPassword.observe { + binder.restoreCloudBackupInput.content.switchPasswordInputType(it) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/base/EnterCloudBackupPasswordViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/base/EnterCloudBackupPasswordViewModel.kt new file mode 100644 index 0000000..f768737 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/base/EnterCloudBackupPasswordViewModel.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.base + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.base.showError +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword.EnterCloudBackupInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchBackupLostPasswordAction +import io.novafoundation.nova.feature_cloud_backup_api.presenter.confirmation.awaitDeleteBackupConfirmation +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapDeleteBackupFailureToUi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +abstract class EnterCloudBackupPasswordViewModel( + internal val router: AccountRouter, + internal val resourceManager: ResourceManager, + internal val interactor: EnterCloudBackupInteractor, + internal val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, +) : BaseViewModel(), ActionBottomSheetLauncher by actionBottomSheetLauncherFactory.create() { + + val confirmationAwaitableAction = actionAwaitableMixinFactory.confirmingAction() + + val passwordFlow = MutableStateFlow("") + + val _showPassword = MutableStateFlow(false) + val showPassword: Flow = _showPassword + + private val _restoreBackupInProgress = MutableStateFlow(false) + private val restoreBackupInProgress: Flow = _restoreBackupInProgress + + val continueButtonState = combine(passwordFlow, restoreBackupInProgress) { password, backupInProgress -> + when { + backupInProgress -> DescriptiveButtonState.Loading + password.isNotEmpty() -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + else -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_password)) + } + }.shareInBackground() + + fun backClicked() { + router.back() + } + + fun continueClicked() { + launch { + _restoreBackupInProgress.value = true + val password = passwordFlow.value + continueInternal(password) + + _restoreBackupInProgress.value = false + } + } + + abstract suspend fun continueInternal(password: String) + + fun toggleShowPassword() { + _showPassword.toggle() + } + + fun forgotPasswordClicked() { + launchBackupLostPasswordAction(resourceManager, ::confirmCloudBackupDelete) + } + + internal fun confirmCloudBackupDelete() { + launch { + confirmationAwaitableAction.awaitDeleteBackupConfirmation(resourceManager) + + interactor.deleteCloudBackup() + .onSuccess { router.back() } + .onFailure { throwable -> + val titleAndMessage = mapDeleteBackupFailureToUi(resourceManager, throwable) + titleAndMessage?.let { showError(it) } + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/CheckCloudBackupPasswordFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/CheckCloudBackupPasswordFragment.kt new file mode 100644 index 0000000..876946d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/CheckCloudBackupPasswordFragment.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.confirmPassword + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.base.EnterCloudBackupPasswordFragment + +class CheckCloudBackupPasswordFragment : EnterCloudBackupPasswordFragment() { + + override val titleRes: Int = R.string.confirm_cloud_backup_password_title + override val subtitleRes: Int = R.string.confirm_cloud_backup_password_subtitle + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .checkCloudBackupPasswordFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/CheckCloudBackupPasswordViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/CheckCloudBackupPasswordViewModel.kt new file mode 100644 index 0000000..98a9e8d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/CheckCloudBackupPasswordViewModel.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.confirmPassword + +import io.novafoundation.nova.common.base.showError +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword.EnterCloudBackupInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.base.EnterCloudBackupPasswordViewModel +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapCheckPasswordFailureToUi + +class CheckCloudBackupPasswordViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: EnterCloudBackupInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, +) : EnterCloudBackupPasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory, + actionAwaitableMixinFactory +) { + + override suspend fun continueInternal(password: String) { + interactor.confirmCloudBackupPassword(password) + .onSuccess { + openChangePasswordScreen() + }.onFailure { throwable -> + val titleAndMessage = mapCheckPasswordFailureToUi(resourceManager, throwable) + titleAndMessage?.let { showError(it) } + } + } + + private fun openChangePasswordScreen() { + router.openChangeBackupPassword() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/di/CheckCloudBackupPasswordComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/di/CheckCloudBackupPasswordComponent.kt new file mode 100644 index 0000000..b053c77 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/di/CheckCloudBackupPasswordComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.confirmPassword.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.confirmPassword.CheckCloudBackupPasswordFragment + +@Subcomponent( + modules = [ + CheckCloudBackupPasswordModule::class + ] +) +@ScreenScope +interface CheckCloudBackupPasswordComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): CheckCloudBackupPasswordComponent + } + + fun inject(fragment: CheckCloudBackupPasswordFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/di/CheckCloudBackupPasswordModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/di/CheckCloudBackupPasswordModule.kt new file mode 100644 index 0000000..9910307 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/confirmPassword/di/CheckCloudBackupPasswordModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.confirmPassword.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword.EnterCloudBackupInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.confirmPassword.CheckCloudBackupPasswordViewModel + +@Module(includes = [ViewModelModule::class]) +class CheckCloudBackupPasswordModule { + + @Provides + @IntoMap + @ViewModelKey(CheckCloudBackupPasswordViewModel::class) + fun provideViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: EnterCloudBackupInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + ): ViewModel { + return CheckCloudBackupPasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory, + actionAwaitableMixinFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): CheckCloudBackupPasswordViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(CheckCloudBackupPasswordViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/RestoreCloudBackupFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/RestoreCloudBackupFragment.kt new file mode 100644 index 0000000..cdfdfec --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/RestoreCloudBackupFragment.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restoreBackup + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.base.EnterCloudBackupPasswordFragment + +class RestoreCloudBackupFragment : EnterCloudBackupPasswordFragment() { + + override val titleRes: Int = R.string.restore_cloud_backup_title + override val subtitleRes: Int = R.string.restore_cloud_backup_subtitle + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .restoreCloudBackupFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/RestoreCloudBackupViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/RestoreCloudBackupViewModel.kt new file mode 100644 index 0000000..5173d25 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/RestoreCloudBackupViewModel.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restoreBackup + +import io.novafoundation.nova.common.base.showError +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword.EnterCloudBackupInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.base.EnterCloudBackupPasswordViewModel +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchCorruptedBackupFoundAction +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapRestoreBackupFailureToUi + +class RestoreCloudBackupViewModel( + private val accountInteractor: AccountInteractor, + router: AccountRouter, + resourceManager: ResourceManager, + interactor: EnterCloudBackupInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, +) : EnterCloudBackupPasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory, + actionAwaitableMixinFactory +) { + + override suspend fun continueInternal(password: String) { + interactor.restoreCloudBackup(password) + .onSuccess { continueBasedOnCodeStatus() } + .onFailure { + val titleAndMessage = mapRestoreBackupFailureToUi( + resourceManager, + it, + ::corruptedBackupFound + ) + titleAndMessage?.let { showError(it) } + } + } + + private fun corruptedBackupFound() { + launchCorruptedBackupFoundAction(resourceManager, ::confirmCloudBackupDelete) + } + + private suspend fun continueBasedOnCodeStatus() { + if (accountInteractor.isCodeSet()) { + router.openMain() + } else { + router.openCreatePincode() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/di/RestoreCloudBackupComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/di/RestoreCloudBackupComponent.kt new file mode 100644 index 0000000..5c08597 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/di/RestoreCloudBackupComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restoreBackup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restoreBackup.RestoreCloudBackupFragment + +@Subcomponent( + modules = [ + RestoreCloudBackupModule::class + ] +) +@ScreenScope +interface RestoreCloudBackupComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): RestoreCloudBackupComponent + } + + fun inject(fragment: RestoreCloudBackupFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/di/RestoreCloudBackupModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/di/RestoreCloudBackupModule.kt new file mode 100644 index 0000000..f0dda24 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restoreBackup/di/RestoreCloudBackupModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restoreBackup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword.EnterCloudBackupInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restoreBackup.RestoreCloudBackupViewModel + +@Module(includes = [ViewModelModule::class]) +class RestoreCloudBackupModule { + + @Provides + @IntoMap + @ViewModelKey(RestoreCloudBackupViewModel::class) + fun provideViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: EnterCloudBackupInteractor, + accountInteractor: AccountInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + ): ViewModel { + return RestoreCloudBackupViewModel( + accountInteractor, + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory, + actionAwaitableMixinFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): RestoreCloudBackupViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(RestoreCloudBackupViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/RestoreCloudBackupPasswordFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/RestoreCloudBackupPasswordFragment.kt new file mode 100644 index 0000000..db8de21 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/RestoreCloudBackupPasswordFragment.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restorePassword + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.base.EnterCloudBackupPasswordFragment + +class RestoreCloudBackupPasswordFragment : EnterCloudBackupPasswordFragment() { + + override val titleRes: Int = R.string.restore_cloud_backup_title + override val subtitleRes: Int = R.string.restore_cloud_backup_password_title + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .restoreCloudBackupPasswordFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/RestoreCloudBackupPasswordViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/RestoreCloudBackupPasswordViewModel.kt new file mode 100644 index 0000000..eb2723f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/RestoreCloudBackupPasswordViewModel.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restorePassword + +import io.novafoundation.nova.common.base.showError +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordResponder +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword.EnterCloudBackupInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.base.EnterCloudBackupPasswordViewModel +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchCorruptedBackupFoundAction +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapRestorePasswordFailureToUi + +class RestoreCloudBackupPasswordViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: EnterCloudBackupInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator, +) : EnterCloudBackupPasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory, + actionAwaitableMixinFactory +) { + + override suspend fun continueInternal(password: String) { + interactor.restoreCloudBackupPassword(password) + .onSuccess { + restoreBackupPasswordCommunicator.respond(RestoreBackupPasswordResponder.Success) + router.back() + }.onFailure { throwable -> + val titleAndMessage = mapRestorePasswordFailureToUi(resourceManager, throwable, ::corruptedBackupFound) + titleAndMessage?.let { showError(titleAndMessage) } + } + } + + private fun corruptedBackupFound() { + launchCorruptedBackupFoundAction(resourceManager, ::confirmCloudBackupDelete) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/di/RestoreCloudBackupPasswordComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/di/RestoreCloudBackupPasswordComponent.kt new file mode 100644 index 0000000..f81d3c7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/di/RestoreCloudBackupPasswordComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restorePassword.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restorePassword.RestoreCloudBackupPasswordFragment + +@Subcomponent( + modules = [ + RestoreCloudBackupPasswordModule::class + ] +) +@ScreenScope +interface RestoreCloudBackupPasswordComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): RestoreCloudBackupPasswordComponent + } + + fun inject(fragment: RestoreCloudBackupPasswordFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/di/RestoreCloudBackupPasswordModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/di/RestoreCloudBackupPasswordModule.kt new file mode 100644 index 0000000..d03af8e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/cloudBackup/enterPassword/restorePassword/di/RestoreCloudBackupPasswordModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restorePassword.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_impl.domain.cloudBackup.enterPassword.EnterCloudBackupInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.cloudBackup.enterPassword.restorePassword.RestoreCloudBackupPasswordViewModel + +@Module(includes = [ViewModelModule::class]) +class RestoreCloudBackupPasswordModule { + + @Provides + @IntoMap + @ViewModelKey(RestoreCloudBackupPasswordViewModel::class) + fun provideViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + interactor: EnterCloudBackupInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator, + ): ViewModel { + return RestoreCloudBackupPasswordViewModel( + router, + resourceManager, + interactor, + actionBottomSheetLauncherFactory, + actionAwaitableMixinFactory, + restoreBackupPasswordCommunicator + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): RestoreCloudBackupPasswordViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(RestoreCloudBackupPasswordViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/RealSelectedAccountUseCase.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/RealSelectedAccountUseCase.kt new file mode 100644 index 0000000..2aa3f51 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/RealSelectedAccountUseCase.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.common.presentation.masking.getUnmaskedOrElse +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedWalletModel +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +internal class RealSelectedAccountUseCase( + private val accountRepository: AccountRepository, + private val walletUiUseCase: WalletUiUseCase, + private val addressIconGenerator: AddressIconGenerator, + private val metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry, + private val accountTypePresentationMapper: MetaAccountTypePresentationMapper, + private val maskableValueFormatterProvider: MaskableValueFormatterProvider, + private val resourceManager: ResourceManager +) : SelectedAccountUseCase { + + override fun selectedMetaAccountFlow(): Flow = accountRepository.selectedMetaAccountFlow() + + override fun selectedAddressModelFlow(chain: suspend () -> Chain): Flow = selectedMetaAccountFlow().map { + addressIconGenerator.createAccountAddressModel( + chain = chain(), + account = it, + name = null + ) + } + + override fun selectedWalletModelFlow(): Flow = combine( + selectedMetaAccountFlow(), + metaAccountsUpdatesRegistry.observeUpdatesExist(), + maskableValueFormatterProvider.provideFormatter() + ) { metaAccount, hasMetaAccountsUpdates, maskableValueFormatter -> + val icon = walletUiUseCase.walletIcon(metaAccount, transparentBackground = false) + val typeIcon = accountTypePresentationMapper.iconFor(metaAccount.type) + + SelectedWalletModel( + typeIcon = typeIcon, + walletIcon = maskableValueFormatter.format { icon } + .getUnmaskedOrElse { resourceManager.getDrawable(R.drawable.ic_identicon_placeholder_with_background) }, + name = metaAccount.name, + hasUpdates = hasMetaAccountsUpdates + ) + } + + override suspend fun getSelectedMetaAccount(): MetaAccount = accountRepository.getSelectedMetaAccount() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/accountSource/AccountSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/accountSource/AccountSource.kt new file mode 100644 index 0000000..7fc0dac --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/accountSource/AccountSource.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.accountSource + +import androidx.annotation.StringRes + +abstract class AccountSource(@StringRes val nameRes: Int) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/address/RealCopyAddressMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/address/RealCopyAddressMixin.kt new file mode 100644 index 0000000..95bae53 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/address/RealCopyAddressMixin.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.address + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.CopyValueMixin +import io.novafoundation.nova.feature_account_api.domain.account.common.ChainWithAccountId +import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.hasOnlyOneAddressFormat +import io.novafoundation.nova.runtime.ext.legacyAddressOfOrNull + +private const val SHOW_DIALOG_KEY = "SHOW_DIALOG_KEY" + +class RealCopyAddressMixin( + private val copyValueMixin: CopyValueMixin, + private val preferences: Preferences, + private val router: AccountRouter +) : CopyAddressMixin { + + override fun copyAddressOrOpenSelector(chainWithAccountId: ChainWithAccountId) { + val chain = chainWithAccountId.chain + + if (chain.hasOnlyOneAddressFormat() || addressSelectorDisabled()) { + copyPrimaryAddress(chainWithAccountId) + } else { + val accountId = chainWithAccountId.accountId + openAddressSelector(chain.id, accountId) + } + } + + override fun copyPrimaryAddress(chainWithAccountId: ChainWithAccountId) { + copyAddress(getPrimaryAddress(chainWithAccountId)) + } + + override fun copyLegacyAddress(chainWithAccountId: ChainWithAccountId) { + copyAddress(getLegacyAddress(chainWithAccountId)) + } + + override fun getPrimaryAddress(chainWithAccountId: ChainWithAccountId): String { + return chainWithAccountId.chain.addressOf(chainWithAccountId.accountId) + } + + override fun getLegacyAddress(chainWithAccountId: ChainWithAccountId): String? { + return chainWithAccountId.chain.legacyAddressOfOrNull(chainWithAccountId.accountId) + } + + private fun addressSelectorDisabled() = !shouldShowAddressSelector() + + override fun shouldShowAddressSelector(): Boolean { + return preferences.getBoolean(SHOW_DIALOG_KEY, true) + } + + override fun enableAddressSelector(enable: Boolean) { + preferences.putBoolean(SHOW_DIALOG_KEY, enable) + } + + override fun openAddressSelector(chainId: String, accountId: ByteArray) { + router.openChainAddressSelector(chainId, accountId) + } + + private fun copyAddress(address: String?) { + if (address == null) return + + copyValueMixin.copyValue(address) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/AddAccountLauncherMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/AddAccountLauncherMixin.kt new file mode 100644 index 0000000..53cb259 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/AddAccountLauncherMixin.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserMixin +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixin +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope + +interface AddAccountLauncherPresentationFactory { + + fun create( + scope: CoroutineScope + ): AddAccountLauncherMixin.Presentation +} + +interface AddAccountLauncherMixin { + + class AddAccountTypePayload( + val title: String, + val onCreate: () -> Unit, + val onImport: () -> Unit + ) + + val cloudBackupChangingWarningMixin: CloudBackupChangingWarningMixin + + val showAddAccountTypeChooser: LiveData> + + val showImportTypeChooser: LiveData> + + interface Presentation : AddAccountLauncherMixin { + + fun initiateLaunch( + chain: Chain, + metaAccount: MetaAccount, + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/AddAccountLauncherProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/AddAccountLauncherProvider.kt new file mode 100644 index 0000000..3f88bb3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/AddAccountLauncherProvider.kt @@ -0,0 +1,145 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.SecretType +import io.novafoundation.nova.feature_account_api.presenatation.account.add.asImportType +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserMixin +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.AddAccountLauncherMixin.Presentation +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RealAddAccountLauncherPresentationFactory( + private val cloudBackupService: CloudBackupService, + private val importTypeChooserMixin: ImportTypeChooserMixin.Presentation, + private val resourceManager: ResourceManager, + private val router: AccountRouter, + private val addAccountInteractor: AddAccountInteractor, + private val cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory +) : AddAccountLauncherPresentationFactory { + + override fun create(scope: CoroutineScope): Presentation { + return AddAccountLauncherProvider( + cloudBackupService, + importTypeChooserMixin, + resourceManager, + router, + addAccountInteractor, + scope, + cloudBackupChangingWarningMixinFactory + ) + } +} + +class AddAccountLauncherProvider( + private val cloudBackupService: CloudBackupService, + private val importTypeChooserMixin: ImportTypeChooserMixin.Presentation, + private val resourceManager: ResourceManager, + private val router: AccountRouter, + private val addAccountInteractor: AddAccountInteractor, + private val scope: CoroutineScope, + cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory +) : Presentation { + + override val cloudBackupChangingWarningMixin = cloudBackupChangingWarningMixinFactory.create(scope) + + override val showAddAccountTypeChooser = MutableLiveData>() + + override val showImportTypeChooser: LiveData> = importTypeChooserMixin.showChooserEvent + + private fun importTypeSelected(chainAccountPayload: AddAccountPayload.ChainAccount, secretType: SecretType) { + router.openImportAccountScreen(ImportAccountPayload(secretType.asImportType(), chainAccountPayload)) + } + + override fun initiateLaunch(chain: Chain, metaAccount: MetaAccount) { + when (metaAccount.type) { + LightMetaAccount.Type.SECRETS -> launchAddFromSecrets(chain, metaAccount) + LightMetaAccount.Type.WATCH_ONLY -> launchAddWatchOnly(chain, metaAccount) + LightMetaAccount.Type.LEDGER_LEGACY -> launchAddLedger(chain, metaAccount) + + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.POLKADOT_VAULT, + LightMetaAccount.Type.LEDGER, + LightMetaAccount.Type.MULTISIG, + LightMetaAccount.Type.PROXIED -> Unit + } + } + + private fun launchAddLedger(chain: Chain, metaAccount: MetaAccount) { + val chainAccountPayload = AddAccountPayload.ChainAccount(chain.id, metaAccount.id) + + router.openAddLedgerChainAccountFlow(chainAccountPayload) + } + + private fun launchAddWatchOnly(chain: Chain, metaAccount: MetaAccount) { + cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded { + val chainAccountPayload = AddAccountPayload.ChainAccount(chain.id, metaAccount.id) + + router.openChangeWatchAccount(chainAccountPayload) + } + } + + private fun launchAddFromSecrets(chain: Chain, metaAccount: MetaAccount) { + val chainAccountPayload = AddAccountPayload.ChainAccount(chain.id, metaAccount.id) + + val titleTemplate = if (metaAccount.hasAccountIn(chain)) { + R.string.accounts_change_chain_account + } else { + R.string.accounts_add_chain_account + } + val title = resourceManager.getString(titleTemplate, chain.name) + + showAddAccountTypeChooser.value = AddAccountLauncherMixin.AddAccountTypePayload( + title = title, + onCreate = { addAccountSelected(chainAccountPayload) }, + onImport = { importAccountSelected(chainAccountPayload) } + ).event() + } + + private fun addAccountSelected(payload: AddAccountPayload.ChainAccount) { + scope.launch { + if (cloudBackupService.session.isSyncWithCloudEnabled()) { + cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded { + addAccountWithRecommendedSettings(payload) + } + } else { + router.openMnemonicScreen(accountName = null, payload) + } + } + } + + private fun addAccountWithRecommendedSettings(payload: AddAccountPayload.ChainAccount) { + scope.launch { + withContext(Dispatchers.Default) { + addAccountInteractor.createMetaAccountWithRecommendedSettings(AddAccountType.ChainAccount(payload.chainId, payload.metaId)) + } + } + } + + private fun importAccountSelected(chainAccountPayload: AddAccountPayload.ChainAccount) { + val payload = ImportTypeChooserMixin.Payload( + onChosen = { + cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded { + importTypeSelected(chainAccountPayload, it) + } + } + ) + importTypeChooserMixin.showChooser(payload) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/ui/AddAccountChooserBottomSheet.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/ui/AddAccountChooserBottomSheet.kt new file mode 100644 index 0000000..5a2ecbe --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/ui/AddAccountChooserBottomSheet.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.ui + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.AddAccountLauncherMixin + +class AddAccountChooserBottomSheet( + context: Context, + private val payload: AddAccountLauncherMixin.AddAccountTypePayload +) : FixedListBottomSheet(context, viewConfiguration = ViewConfiguration.default(context)) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(payload.title) + + textItem(R.drawable.ic_add_circle_outline, R.string.account_create_account) { + payload.onCreate() + } + + textItem(R.drawable.ic_import, R.string.account_export_existing) { + payload.onImport() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/ui/Ui.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/ui/Ui.kt new file mode 100644 index 0000000..a98fcc9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/addAccountChooser/ui/Ui.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.ui + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.setupImportTypeChooser +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.addAccountChooser.AddAccountLauncherMixin +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.observeConfirmationAction + +fun BaseFragment<*, *>.setupAddAccountLauncher(mixin: AddAccountLauncherMixin) { + val asImportTypeChooser = object : ImportTypeChooserMixin { + override val showChooserEvent = mixin.showImportTypeChooser + } + setupImportTypeChooser(asImportTypeChooser) + observeConfirmationAction(mixin.cloudBackupChangingWarningMixin) + + mixin.showAddAccountTypeChooser.observeEvent { + AddAccountChooserBottomSheet( + context = requireContext(), + payload = it + ).show() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/api/AccountNameChooserMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/api/AccountNameChooserMixin.kt new file mode 100644 index 0000000..1831fa1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/api/AccountNameChooserMixin.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface AccountNameChooserMixin { + + val nameState: StateFlow + + fun nameChanged(newName: String) + + interface Presentation : AccountNameChooserMixin { + + val nameValid: Flow + } + + sealed class State { + + object NoInput : State() + + class Input(val value: String) : State() + } +} + +interface WithAccountNameChooserMixin { + + val accountNameChooser: AccountNameChooserMixin +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/impl/AccountNameChooser.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/impl/AccountNameChooser.kt new file mode 100644 index 0000000..e9ba38f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/impl/AccountNameChooser.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.mixin.impl + +import io.novafoundation.nova.common.mixin.MixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api.AccountNameChooserMixin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class AccountNameChooserFactory( + private val payload: AddAccountPayload, +) : MixinFactory { + + override fun create(scope: CoroutineScope): AccountNameChooserMixin.Presentation { + return AccountNameChooserProvider(payload) + } +} + +class AccountNameChooserProvider( + private val addAccountPayload: AddAccountPayload, +) : AccountNameChooserMixin.Presentation { + + override fun nameChanged(newName: String) { + nameState.value = maybeInputOf(newName) + } + + override val nameState = MutableStateFlow(maybeInputOf("")) + + override val nameValid: Flow = nameState.map { + when (it) { + is AccountNameChooserMixin.State.NoInput -> true + is AccountNameChooserMixin.State.Input -> it.value.isNotEmpty() + } + } + + private fun maybeInputOf(value: String) = when (addAccountPayload) { + is AddAccountPayload.MetaAccount -> AccountNameChooserMixin.State.Input(value) + is AddAccountPayload.ChainAccount -> AccountNameChooserMixin.State.NoInput + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/impl/AccountNameChooserUi.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/impl/AccountNameChooserUi.kt new file mode 100644 index 0000000..d8e8ee6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mixin/impl/AccountNameChooserUi.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.mixin.impl + +import android.view.View +import android.widget.EditText +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import io.novafoundation.nova.common.utils.observeInLifecycle +import io.novafoundation.nova.common.utils.onTextChanged +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api.AccountNameChooserMixin +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api.WithAccountNameChooserMixin + +fun setupAccountNameChooserUi( + viewModel: WithAccountNameChooserMixin, + ui: EditText, + owner: LifecycleOwner, + additionalViewsToControlVisibility: List = emptyList(), +) { + ui.onTextChanged { + viewModel.accountNameChooser.nameChanged(it) + } + + viewModel.accountNameChooser.nameState.observeInLifecycle(owner.lifecycleScope) { state -> + val isVisible = state is AccountNameChooserMixin.State.Input + + ui.setVisible(isVisible) + additionalViewsToControlVisibility.forEach { it.setVisible(isVisible) } + + if (state is AccountNameChooserMixin.State.Input) { + if (state.value != ui.text.toString()) { + ui.setText(state.value) + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mnemonic/BackupMnemonicAdapter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mnemonic/BackupMnemonicAdapter.kt new file mode 100644 index 0000000..c729834 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mnemonic/BackupMnemonicAdapter.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.mnemonic + +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_impl.databinding.ItemBackupMnemonicWordBinding +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.MnemonicWord +import kotlinx.android.extensions.LayoutContainer + +class BackupMnemonicAdapter( + private val itemHandler: ItemHandler +) : ListAdapter(DiffCallback) { + + fun interface ItemHandler { + + fun wordClicked(word: MnemonicWord) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConfirmMnemonicAdapterHolder { + return ConfirmMnemonicAdapterHolder( + ItemBackupMnemonicWordBinding.inflate(parent.inflater(), parent, false), + itemHandler + ) + } + + override fun onBindViewHolder(holder: ConfirmMnemonicAdapterHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: ConfirmMnemonicAdapterHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + + resolvePayload(holder, position, payloads) { + when (it) { + MnemonicWord::removed -> holder.bindState(item) + MnemonicWord::indexDisplay -> holder.bindIndex(item) + } + } + } + + class ConfirmMnemonicAdapterHolder( + private val binder: ItemBackupMnemonicWordBinding, + private val itemHandler: ItemHandler, + ) : RecyclerView.ViewHolder(binder.root), LayoutContainer { + + override val containerView: View = binder.root + + fun bind(item: MnemonicWord) = with(containerView) { + binder.itemConfirmMnemonicWord.text = item.content + + bindIndex(item) + bindState(item) + } + + fun bindState(item: MnemonicWord) = with(containerView) { + val hasWord = !item.removed + + setVisible(hasWord, falseState = View.INVISIBLE) + + binder.itemConfirmMnemonicWord.setVisible(hasWord, falseState = View.INVISIBLE) + + if (item.removed) { + setOnClickListener(null) + } else { + setOnClickListener { itemHandler.wordClicked(item) } + } + } + + fun bindIndex(item: MnemonicWord) { + binder.itemConfirmMnemonicIndex.setTextOrHide(item.indexDisplay) + binder.itemConfirmMnemonicWord.gravity = if (item.indexDisplay == null) Gravity.CENTER else Gravity.START + } + } + + private object MnemonicPayloadGenerator : PayloadGenerator(MnemonicWord::removed, MnemonicWord::indexDisplay) + + private object DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: MnemonicWord, newItem: MnemonicWord): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: MnemonicWord, newItem: MnemonicWord): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: MnemonicWord, newItem: MnemonicWord): Any? { + return MnemonicPayloadGenerator.diff(oldItem, newItem) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mnemonic/MnemonicExt.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mnemonic/MnemonicExt.kt new file mode 100644 index 0000000..76ad173 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/mnemonic/MnemonicExt.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.mnemonic + +import io.novasama.substrate_sdk_android.encrypt.mnemonic.Mnemonic + +fun Mnemonic.spacedWords(spacing: Int = 2) = wordList.joinToString(separator = " ".repeat(spacing)) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/sign/notSupported/AcknowledgeSigningNotSupportedBottomSheet.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/sign/notSupported/AcknowledgeSigningNotSupportedBottomSheet.kt new file mode 100644 index 0000000..bf2fabf --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/sign/notSupported/AcknowledgeSigningNotSupportedBottomSheet.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.sign.notSupported + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.utils.DialogExtensions +import io.novafoundation.nova.common.view.bottomSheet.ActionNotAllowedBottomSheet +import io.novafoundation.nova.feature_account_impl.R + +class AcknowledgeSigningNotSupportedBottomSheet( + context: Context, + private val payload: SigningNotSupportedPresentable.Payload, + private val onConfirm: () -> Unit +) : ActionNotAllowedBottomSheet( + context = context, + onSuccess = onConfirm, +), + DialogExtensions { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + titleView.setText(R.string.account_parity_signer_not_supported_title) + subtitleView.text = payload.message + + applySolidIconStyle(payload.iconRes) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/sign/notSupported/SigningNotSupportedPresentable.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/sign/notSupported/SigningNotSupportedPresentable.kt new file mode 100644 index 0000000..7ee6834 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/common/sign/notSupported/SigningNotSupportedPresentable.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_impl.presentation.common.sign.notSupported + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.feature_account_impl.presentation.common.sign.notSupported.SigningNotSupportedPresentable.Payload +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +interface SigningNotSupportedPresentable { + + class Payload( + @DrawableRes val iconRes: Int, + val message: String + ) + + suspend fun presentSigningNotSupported(payload: Payload) +} + +class RealSigningNotSupportedPresentable( + private val contextManager: ContextManager, +) : SigningNotSupportedPresentable { + + override suspend fun presentSigningNotSupported(payload: Payload): Unit = withContext(Dispatchers.Main) { + suspendCoroutine { + AcknowledgeSigningNotSupportedBottomSheet( + context = contextManager.getActivity()!!, + onConfirm = { it.resume(Unit) }, + payload = payload + ).show() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/ExportFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/ExportFragment.kt new file mode 100644 index 0000000..1223a4e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/ExportFragment.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting + +import android.app.PendingIntent +import android.content.Intent +import androidx.annotation.CallSuper +import androidx.viewbinding.ViewBinding +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.feature_account_impl.presentation.exporting.json.ShareCompletedReceiver + +abstract class ExportFragment : BaseFragment() { + + @CallSuper + override fun subscribe(viewModel: V) { + viewModel.exportEvent.observeEvent(::shareTextWithCallback) + } + + private fun shareTextWithCallback(text: String) { + val title = getString(io.novafoundation.nova.feature_account_impl.R.string.common_share) + + val intent = Intent(Intent.ACTION_SEND) + .putExtra(Intent.EXTRA_TEXT, text) + .setType("text/plain") + + val receiver = Intent(requireContext(), ShareCompletedReceiver::class.java) + + val pendingIntent = PendingIntent.getBroadcast(requireContext(), 0, receiver, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val chooser = Intent.createChooser(intent, title, pendingIntent.intentSender) + + startActivity(chooser) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/ExportPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/ExportPayload.kt new file mode 100644 index 0000000..21f0353 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/ExportPayload.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting + +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +sealed interface ExportPayload : Parcelable { + + val metaId: Long + + @Parcelize + data class MetaAccount( + override val metaId: Long + ) : ExportPayload + + @Parcelize + data class ChainAccount( + override val metaId: Long, + val chainId: ChainId, + ) : ExportPayload +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/ExportViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/ExportViewModel.kt new file mode 100644 index 0000000..080185d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/ExportViewModel.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.Event + +abstract class ExportViewModel : BaseViewModel() { + + private val _exportEvent = MutableLiveData>() + val exportEvent: LiveData> = _exportEvent + + protected fun exportText(text: String) { + _exportEvent.value = Event(text) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/ExportJsonFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/ExportJsonFragment.kt new file mode 100644 index 0000000..d3d5da4 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/ExportJsonFragment.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.json + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope + +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.switchPasswordInputType +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentExportJsonPasswordBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportFragment +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import javax.inject.Inject + +private const val PAYLOAD_KEY = "PAYLOAD_KEY" + +class ExportJsonFragment : ExportFragment() { + + override fun createBinding() = FragmentExportJsonPasswordBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + companion object { + fun getBundle(exportPayload: ExportPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, exportPayload) + } + } + } + + override fun initViews() { + binder.exportJsonPasswordToolbar.setHomeButtonListener { viewModel.back() } + + binder.exportJsonPasswordNext.setOnClickListener { viewModel.nextClicked() } + + binder.exportJsonPasswordNext.prepareForProgress(viewLifecycleOwner) + + binder.exportJsonPasswordNewField.setEndIconOnClickListener { viewModel.toggleShowPassword() } + binder.exportJsonPasswordConfirmField.setEndIconOnClickListener { viewModel.toggleShowPassword() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .exportJsonPasswordFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: ExportJsonViewModel) { + super.subscribe(viewModel) + binder.exportJsonPasswordNewField.content.bindTo(viewModel.passwordFlow, lifecycleScope) + binder.exportJsonPasswordConfirmField.content.bindTo(viewModel.passwordConfirmationFlow, lifecycleScope) + + viewModel.nextButtonState.observe(binder.exportJsonPasswordNext::setState) + + viewModel.showPasswords.observe { + binder.exportJsonPasswordNewField.content.switchPasswordInputType(it) + binder.exportJsonPasswordConfirmField.content.switchPasswordInputType(it) + } + + observeValidations(viewModel) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/ExportJsonViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/ExportJsonViewModel.kt new file mode 100644 index 0000000..d8a4108 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/ExportJsonViewModel.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.json + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.ExportJsonInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations.ExportJsonPasswordValidationPayload +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations.ExportJsonPasswordValidationSystem +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations.mapExportJsonPasswordValidationFailureToUi +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +class ExportJsonViewModel( + private val router: AccountRouter, + private val interactor: ExportJsonInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: ExportJsonPasswordValidationSystem, + private val payload: ExportPayload, +) : ExportViewModel(), + Validatable by validationExecutor { + + val passwordFlow = MutableStateFlow("") + val passwordConfirmationFlow = MutableStateFlow("") + + private val jsonGenerationInProgressFlow = MutableStateFlow(false) + + val _showPasswords = MutableStateFlow(false) + val showPasswords: Flow = _showPasswords + + val nextButtonState: Flow = combine( + passwordFlow, + passwordConfirmationFlow, + jsonGenerationInProgressFlow + ) { password, confirmation, jsonGenerationInProgress -> + when { + jsonGenerationInProgress -> DescriptiveButtonState.Loading + password.isBlank() || confirmation.isBlank() -> DescriptiveButtonState.Disabled( + resourceManager.getString(R.string.common_enter_password) + ) + + else -> DescriptiveButtonState.Enabled( + resourceManager.getString(R.string.export_json_download_btn) + ) + } + } + + fun back() { + router.back() + } + + fun nextClicked() = viewModelScope.launch { + val password = passwordFlow.value + + val validationPayload = ExportJsonPasswordValidationPayload( + password = password, + passwordConfirmation = passwordConfirmationFlow.value + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + progressConsumer = jsonGenerationInProgressFlow.progressConsumer(), + validationFailureTransformer = { mapExportJsonPasswordValidationFailureToUi(resourceManager, it) } + ) { + tryGenerateJson(password) + } + } + + fun toggleShowPassword() { + _showPasswords.toggle() + } + + private fun tryGenerateJson(password: String) = launch { + val generateRestoreJsonResult = when (payload) { + is ExportPayload.ChainAccount -> interactor.generateRestoreJson(payload.metaId, payload.chainId, password) + is ExportPayload.MetaAccount -> interactor.generateRestoreJson(payload.metaId, password) + } + + generateRestoreJsonResult + .onSuccess { exportText(it) } + .onFailure { it.message?.let(::showError) } + + jsonGenerationInProgressFlow.value = false + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/ShareCompletedReceiver.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/ShareCompletedReceiver.kt new file mode 100644 index 0000000..5d4f290 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/ShareCompletedReceiver.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.json + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import javax.inject.Inject + +class ShareCompletedReceiver : BroadcastReceiver() { + + @Inject + lateinit var router: AccountRouter + + override fun onReceive(context: Context, intent: Intent) { + FeatureUtils.getFeature(context, AccountFeatureApi::class.java) + .inject(this) + + router.finishExportFlow() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/di/ExportJsonComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/di/ExportJsonComponent.kt new file mode 100644 index 0000000..03f6c10 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/di/ExportJsonComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.json.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.exporting.json.ExportJsonFragment + +@Subcomponent( + modules = [ + ExportJsonModule::class + ] +) +@ScreenScope +interface ExportJsonComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ExportPayload, + ): ExportJsonComponent + } + + fun inject(fragment: ExportJsonFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/di/ExportJsonModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/di/ExportJsonModule.kt new file mode 100644 index 0000000..6adacf0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/di/ExportJsonModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.json.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.ExportJsonInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations.ExportJsonPasswordValidationSystem +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.exporting.json.ExportJsonViewModel + +@Module(includes = [ViewModelModule::class, ValidationsModule::class]) +class ExportJsonModule { + + @Provides + @IntoMap + @ViewModelKey(ExportJsonViewModel::class) + fun provideViewModel( + router: AccountRouter, + accountInteractor: ExportJsonInteractor, + validationExecutor: ValidationExecutor, + validationSystem: ExportJsonPasswordValidationSystem, + resourceManager: ResourceManager, + payload: ExportPayload + ): ViewModel { + return ExportJsonViewModel( + router, + accountInteractor, + resourceManager, + validationExecutor, + validationSystem, + payload + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ExportJsonViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ExportJsonViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/di/ValidationsModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/di/ValidationsModule.kt new file mode 100644 index 0000000..9eb7ce3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/json/di/ValidationsModule.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.json.di + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.validation.from +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations.ExportJsonPasswordValidation +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations.ExportJsonPasswordValidationSystem +import io.novafoundation.nova.feature_account_impl.domain.account.export.json.validations.PasswordMatchConfirmationValidation + +@Module +class ValidationsModule { + + @Provides + @ScreenScope + @IntoSet + fun passwordMatchConfirmationValidation(): ExportJsonPasswordValidation = PasswordMatchConfirmationValidation() + + @Provides + @ScreenScope + fun provideValidationSystem( + validations: Set<@JvmSuppressWildcards ExportJsonPasswordValidation> + ) = ExportJsonPasswordValidationSystem.from(validations) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/ExportSeedFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/ExportSeedFragment.kt new file mode 100644 index 0000000..7ea87d1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/ExportSeedFragment.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.seed + +import android.os.Bundle + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentExportSeedBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportFragment +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload + +private const val PAYLOAD_KEY = "PAYLOAD_KEY" + +class ExportSeedFragment : ExportFragment() { + + companion object { + fun getBundle(exportPayload: ExportPayload.ChainAccount): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, exportPayload) + } + } + } + + override fun createBinding() = FragmentExportSeedBinding.inflate(layoutInflater) + + override fun initViews() { + binder.exportSeedToolbar.setHomeButtonListener { viewModel.back() } + + binder.exportSeedToolbar.setRightActionClickListener { viewModel.optionsClicked() } + + binder.exportSeedContentContainer.background = requireContext().getRoundedCornerDrawable(fillColorRes = R.color.input_background) + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .exportSeedFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: ExportSeedViewModel) { + super.subscribe(viewModel) + + viewModel.secretFlow.observe(binder.exportSeedValue::setText) + + viewModel.secretTypeNameFlow.observe(binder.exportSeedTitle::setText) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/ExportSeedViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/ExportSeedViewModel.kt new file mode 100644 index 0000000..d66dc0d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/ExportSeedViewModel.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.seed + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.export.seed.ExportPrivateKeyInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionModePayload +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportViewModel + +class ExportSeedViewModel( + private val resourceManager: ResourceManager, + private val router: AccountRouter, + private val interactor: ExportPrivateKeyInteractor, + private val exportPayload: ExportPayload.ChainAccount, +) : ExportViewModel() { + + val secretTypeNameFlow = flowOf { + if (interactor.isEthereumBased(exportPayload.chainId)) { + resourceManager.getString(R.string.account_private_key) + } else { + resourceManager.getString(R.string.recovery_raw_seed) + } + } + + val secretFlow = flowOf { + if (interactor.isEthereumBased(exportPayload.chainId)) { + interactor.getEthereumPrivateKey(exportPayload.metaId, exportPayload.chainId) + } else { + interactor.getAccountSeed(exportPayload.metaId, exportPayload.chainId) + } + } + .inBackground() + .share() + + fun optionsClicked() { + val viewRequest = AdvancedEncryptionModePayload.View(exportPayload.metaId, exportPayload.chainId) + + router.openAdvancedSettings(viewRequest) + } + + fun back() { + router.back() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/di/ExportSeedComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/di/ExportSeedComponent.kt new file mode 100644 index 0000000..e6bca3c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/di/ExportSeedComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.seed.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.exporting.seed.ExportSeedFragment + +@Subcomponent( + modules = [ + ExportSeedModule::class + ] +) +@ScreenScope +interface ExportSeedComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ExportPayload.ChainAccount + ): ExportSeedComponent + } + + fun inject(fragment: ExportSeedFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/di/ExportSeedModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/di/ExportSeedModule.kt new file mode 100644 index 0000000..209b2b9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/exporting/seed/di/ExportSeedModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.presentation.exporting.seed.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_impl.domain.account.export.seed.ExportPrivateKeyInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.exporting.seed.ExportSeedViewModel + +@Module(includes = [ViewModelModule::class]) +class ExportSeedModule { + + @Provides + @IntoMap + @ViewModelKey(ExportSeedViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: AccountRouter, + interactor: ExportPrivateKeyInteractor, + payload: ExportPayload.ChainAccount, + ): ViewModel { + return ExportSeedViewModel( + resourceManager, + router, + interactor, + payload + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ExportSeedViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ExportSeedViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/FileReader.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/FileReader.kt new file mode 100644 index 0000000..0f2437f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/FileReader.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing + +import android.content.Context +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class FileReader(private val context: Context) { + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun readFile(uri: Uri): String? { + return withContext(Dispatchers.IO) { + val inputString = context.contentResolver.openInputStream(uri) + + inputString?.reader()?.readText() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/ImportAccountFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/ImportAccountFragment.kt new file mode 100644 index 0000000..64f77b1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/ImportAccountFragment.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing + +import android.content.Intent +import android.os.Bundle + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload +import io.novafoundation.nova.feature_account_impl.databinding.FragmentImportAccountBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.FileRequester +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.ImportSource +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.RequestCode + +import javax.inject.Inject + +class ImportAccountFragment : BaseFragment() { + + override fun createBinding() = FragmentImportAccountBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + companion object { + + private const val PAYLOAD = "ImportAccountFragment.PAYLOAD" + + fun getBundle(payload: ImportAccountPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD, payload) + } + } + } + + override fun initViews() { + binder.importAccountToolbar.setRightActionClickListener { + viewModel.optionsClicked() + } + binder.importAccountToolbar.setHomeButtonListener { viewModel.homeButtonClicked() } + + binder.importAccountContinue.setOnClickListener { viewModel.nextClicked() } + binder.importAccountContinue.prepareForProgress(viewLifecycleOwner) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .importAccountComponentFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ImportAccountViewModel) { + binder.importAccountTitle.setText(viewModel.importSource.nameRes) + + val sourceView = viewModel.importSource.initializeView(viewModel, fragment = this) + binder.importAccountSourceContainer.addView(sourceView) + + observeFeatures(viewModel.importSource) + + binder.importAccountToolbar.setRightIconVisible(viewModel.importSource.encryptionOptionsAvailable) + + viewModel.nextButtonState.observe(binder.importAccountContinue::setState) + } + + private fun observeFeatures(source: ImportSource) { + if (source is FileRequester) { + source.chooseJsonFileEvent.observeEvent { + openFilePicker(it) + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + data?.let { viewModel.systemCallResultReceived(requestCode, it) } + } + + private fun openFilePicker(it: RequestCode) { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.type = "application/json" + startActivityForResult(intent, it) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/ImportAccountViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/ImportAccountViewModel.kt new file mode 100644 index 0000000..dcb567b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/ImportAccountViewModel.kt @@ -0,0 +1,124 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing + +import android.content.Intent +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.MixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.withFlagSet +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountAlreadyExistsException +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.mappers.mapAddAccountPayloadToAddAccountType +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionModePayload +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api.AccountNameChooserMixin +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api.WithAccountNameChooserMixin +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.ImportSourceFactory +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.FileRequester +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.ImportError +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.junction.JunctionDecoder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +class ImportAccountViewModel( + private val interactor: AccountInteractor, + private val router: AccountRouter, + private val resourceManager: ResourceManager, + accountNameChooserFactory: MixinFactory, + private val payload: ImportAccountPayload, + private val importSourceFactory: ImportSourceFactory, +) : BaseViewModel(), + WithAccountNameChooserMixin { + + override val accountNameChooser: AccountNameChooserMixin.Presentation = accountNameChooserFactory.create(scope = this) + + val importSource = importSourceFactory.create( + importType = payload.importType, + scope = this, + payload = payload.addAccountPayload, + accountNameChooserMixin = accountNameChooser, + coroutineScope = viewModelScope + ) + + private val importInProgressFlow = MutableStateFlow(false) + + private val nextButtonEnabledFlow = combine( + importSource.fieldsValidFlow, + accountNameChooser.nameValid, + ) { fieldsValid, nameValid -> fieldsValid and nameValid } + + val nextButtonState = nextButtonEnabledFlow.combine(importInProgressFlow) { enabled, inProgress -> + when { + inProgress -> ButtonState.PROGRESS + enabled -> ButtonState.NORMAL + else -> ButtonState.DISABLED + } + } + + fun homeButtonClicked() { + router.back() + } + + fun optionsClicked() { + router.openAdvancedSettings(AdvancedEncryptionModePayload.Change(payload.addAccountPayload)) + } + + fun nextClicked() = launch { + importInProgressFlow.withFlagSet { + val nameState = accountNameChooser.nameState.value + val addAccountType = mapAddAccountPayloadToAddAccountType(payload.addAccountPayload, nameState) + + importSource.performImport(addAccountType) + .onSuccess { continueBasedOnCodeStatus() } + .onFailure(::handleCreateAccountError) + } + } + + fun systemCallResultReceived(requestCode: Int, intent: Intent) { + if (importSource is FileRequester) { + val currentRequestCode = importSource.chooseJsonFileEvent.value!!.peekContent() + + if (requestCode == currentRequestCode) { + importSource.fileChosen(intent.data!!) + } + } + } + + private suspend fun continueBasedOnCodeStatus() { + if (interactor.isCodeSet()) { + router.openMain() + } else { + router.openCreatePincode() + } + } + + private fun handleCreateAccountError(throwable: Throwable) { + var errorMessage = importSource.handleError(throwable) + + if (errorMessage == null) { + errorMessage = when (throwable) { + is AccountAlreadyExistsException -> ImportError( + titleRes = R.string.account_add_already_exists_message, + messageRes = R.string.account_error_try_another_one + ) + + is JunctionDecoder.DecodingError, is BIP32JunctionDecoder.DecodingError -> ImportError( + titleRes = R.string.account_invalid_derivation_path_title, + messageRes = R.string.account_invalid_derivation_path_message_v2_2_0 + ) + + else -> ImportError() + } + } + + val title = resourceManager.getString(errorMessage.titleRes) + val message = resourceManager.getString(errorMessage.messageRes) + + showError(title, message) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/deeplink/ImportMnemonicDeepLinkHandler.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/deeplink/ImportMnemonicDeepLinkHandler.kt new file mode 100644 index 0000000..80de6ef --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/deeplink/ImportMnemonicDeepLinkHandler.kt @@ -0,0 +1,118 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.deeplink + +import android.net.Uri +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException.ImportMnemonicHandlingException +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.data.derivationPath.DerivationPathDecoder +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportType +import io.novafoundation.nova.feature_account_api.presenatation.account.common.model.AdvancedEncryptionModel +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import io.novasama.substrate_sdk_android.encrypt.mnemonic.Mnemonic +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import io.novasama.substrate_sdk_android.extensions.fromHex +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +private const val IMPORT_WALLET_DEEP_LINK_PREFIX = "/create/wallet" + +class ImportMnemonicDeepLinkHandler( + private val router: AccountRouter, + private val encryptionDefaults: EncryptionDefaults, + private val accountRepository: AccountRepository, + private val automaticInteractionGate: AutomaticInteractionGate, +) : DeepLinkHandler { + + override val callbackFlow: Flow = emptyFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + + return path.startsWith(IMPORT_WALLET_DEEP_LINK_PREFIX) + } + + override suspend fun handleDeepLink(data: Uri): Result = runCatching { + if (accountRepository.hasActiveMetaAccounts()) { + automaticInteractionGate.awaitInteractionAllowed() + } + + val mnemonic = data.getMnemonic() + val substrateDP = data.getSubstrateDP() + val ethereumDerivationPath = data.getEthereumDP() + + val isDerivationPathsValid = isDerivationPathsValid(substrateDP, ethereumDerivationPath) + if (!isDerivationPathsValid) throw ImportMnemonicHandlingException.InvalidDerivationPath + + val importAccountPayload = ImportAccountPayload( + prepareMnemonicPreset( + mnemonic = mnemonic.words, + substrateCryptoType = data.getSubstrateCryptoType().asCryptoType { encryptionDefaults.substrateCryptoType }, + substrateDP = data.getSubstrateDP(), + ethereumDP = data.getEthereumDP() + ), + AddAccountPayload.MetaAccount + ) + + router.openImportAccountScreen(importAccountPayload) + } + + private fun prepareMnemonicPreset( + mnemonic: String, + substrateCryptoType: CryptoType?, + substrateDP: String?, + ethereumDP: String? + ): ImportType.Mnemonic { + return ImportType.Mnemonic( + mnemonic = mnemonic, + preset = AdvancedEncryptionModel( + substrateCryptoType = substrateCryptoType ?: encryptionDefaults.substrateCryptoType, + substrateDerivationPath = substrateDP ?: encryptionDefaults.substrateDerivationPath, + ethereumCryptoType = encryptionDefaults.ethereumCryptoType, + ethereumDerivationPath = ethereumDP ?: encryptionDefaults.ethereumDerivationPath + ) + ) + } + + private fun Uri.getMnemonic(): Mnemonic { + val mnemonicHex = getQueryParameter("mnemonic") + return runCatching { MnemonicCreator.fromEntropy(mnemonicHex!!.fromHex()) }.getOrNull() + ?: throw ImportMnemonicHandlingException.InvalidMnemonic + } + + private fun Uri.getSubstrateCryptoType(): String? { + return getQueryParameter("cryptoType") + } + + private fun Uri.getSubstrateDP(): String? { + return getQueryParameter("substrateDP") + } + + private fun Uri.getEthereumDP(): String? { + return getQueryParameter("evmDP") + } + + private fun String?.asCryptoType(fallback: () -> CryptoType): CryptoType { + val intCryptoType = this?.toIntOrNull() + + return when (intCryptoType) { + null -> fallback() + 0 -> CryptoType.SR25519 + 1 -> CryptoType.ED25519 + 2 -> CryptoType.ECDSA + else -> throw DeepLinkHandlingException.ImportMnemonicHandlingException.InvalidCryptoType + } + } + + private fun isDerivationPathsValid(substrateDP: String?, ethereumDP: String?): Boolean { + return DerivationPathDecoder.isEthereumDerivationPathValid(ethereumDP) && + DerivationPathDecoder.isSubstrateDerivationPathValid(substrateDP) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/di/ImportAccountComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/di/ImportAccountComponent.kt new file mode 100644 index 0000000..effd122 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/di/ImportAccountComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.importing.ImportAccountFragment + +@Subcomponent( + modules = [ + ImportAccountModule::class + ] +) +@ScreenScope +interface ImportAccountComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ImportAccountPayload + ): ImportAccountComponent + } + + fun inject(importAccountFragment: ImportAccountFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/di/ImportAccountModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/di/ImportAccountModule.kt new file mode 100644 index 0000000..784262e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/di/ImportAccountModule.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.di + +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.MixinFactory +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.common.AdvancedEncryptionSelectionStoreProvider +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api.AccountNameChooserMixin +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.impl.AccountNameChooserFactory +import io.novafoundation.nova.feature_account_impl.presentation.importing.FileReader +import io.novafoundation.nova.feature_account_impl.presentation.importing.ImportAccountViewModel +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.ImportSourceFactory +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedCommunicator + +@Module(includes = [ViewModelModule::class]) +class ImportAccountModule { + + @Provides + fun provideImportSourceFactory( + addAccountInteractor: AddAccountInteractor, + clipboardManager: ClipboardManager, + advancedEncryptionSelectionStoreProvider: AdvancedEncryptionSelectionStoreProvider, + fileReader: FileReader, + scanSeedRequester: ScanSeedCommunicator, + advancedEncryptionInteractor: AdvancedEncryptionInteractor, + ) = ImportSourceFactory( + addAccountInteractor = addAccountInteractor, + clipboardManager = clipboardManager, + advancedEncryptionInteractor = advancedEncryptionInteractor, + advancedEncryptionSelectionStoreProvider = advancedEncryptionSelectionStoreProvider, + scanSeedRequester = scanSeedRequester, + fileReader = fileReader + ) + + @Provides + fun provideNameChooserMixinFactory( + payload: ImportAccountPayload, + ): MixinFactory { + return AccountNameChooserFactory(payload.addAccountPayload) + } + + @Provides + @ScreenScope + fun provideFileReader(context: Context) = FileReader(context) + + @Provides + @IntoMap + @ViewModelKey(ImportAccountViewModel::class) + fun provideViewModel( + interactor: AccountInteractor, + router: AccountRouter, + resourceManager: ResourceManager, + accountNameChooserFactory: MixinFactory, + importSourceFactory: ImportSourceFactory, + payload: ImportAccountPayload, + ): ViewModel { + return ImportAccountViewModel( + interactor, + router, + resourceManager, + accountNameChooserFactory, + payload, + importSourceFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ImportAccountViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ImportAccountViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/ImportSourceFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/ImportSourceFactory.kt new file mode 100644 index 0000000..e6a1c29 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/ImportSourceFactory.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source + +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportType +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.common.AdvancedEncryptionSelectionStoreProvider +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api.AccountNameChooserMixin +import io.novafoundation.nova.feature_account_impl.presentation.importing.FileReader +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.ImportSource +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.JsonImportSource +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.MnemonicImportSource +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.RawSeedImportSource +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedRequester +import kotlinx.coroutines.CoroutineScope + +class ImportSourceFactory( + private val addAccountInteractor: AddAccountInteractor, + private val clipboardManager: ClipboardManager, + private val advancedEncryptionInteractor: AdvancedEncryptionInteractor, + private val advancedEncryptionSelectionStoreProvider: AdvancedEncryptionSelectionStoreProvider, + private val scanSeedRequester: ScanSeedRequester, + private val fileReader: FileReader, +) { + + fun create( + importType: ImportType, + scope: CoroutineScope, + payload: AddAccountPayload, + accountNameChooserMixin: AccountNameChooserMixin, + coroutineScope: CoroutineScope + ): ImportSource { + return when (importType) { + is ImportType.Mnemonic -> MnemonicImportSource( + addAccountInteractor = addAccountInteractor, + advancedEncryptionInteractor = advancedEncryptionInteractor, + advancedEncryptionSelectionStoreProvider = advancedEncryptionSelectionStoreProvider, + importType = importType, + coroutineScope = coroutineScope + ) + + ImportType.Seed -> RawSeedImportSource( + addAccountInteractor = addAccountInteractor, + advancedEncryptionInteractor = advancedEncryptionInteractor, + advancedEncryptionSelectionStoreProvider = advancedEncryptionSelectionStoreProvider, + scanSeedRequester = scanSeedRequester, + coroutineScope = coroutineScope + ) + + ImportType.Json -> JsonImportSource( + accountNameChooserMixin = accountNameChooserMixin, + addAccountInteractor = addAccountInteractor, + clipboardManager = clipboardManager, + fileReader = fileReader, + scope = scope, + payload = payload + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/FileRequester.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/FileRequester.kt new file mode 100644 index 0000000..2230780 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/FileRequester.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.source + +import android.net.Uri +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.utils.Event + +typealias RequestCode = Int + +interface FileRequester { + val chooseJsonFileEvent: LiveData> + + fun fileChosen(uri: Uri) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/ImportSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/ImportSource.kt new file mode 100644 index 0000000..93ef999 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/ImportSource.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.source + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.common.accountSource.AccountSource +import io.novafoundation.nova.feature_account_impl.presentation.importing.ImportAccountViewModel +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.view.ImportSourceView +import kotlinx.coroutines.flow.Flow + +class ImportError( + @StringRes val titleRes: Int = R.string.common_error_general_title, + @StringRes val messageRes: Int = R.string.common_undefined_error_message +) + +sealed class ImportSource(@StringRes titleRes: Int) : AccountSource(titleRes) { + + abstract val encryptionOptionsAvailable: Boolean + + abstract val fieldsValidFlow: Flow + + abstract fun initializeView(viewModel: ImportAccountViewModel, fragment: BaseFragment<*, *>): ImportSourceView<*> + + abstract suspend fun performImport(addAccountType: AddAccountType): Result + + open fun handleError(throwable: Throwable): ImportError? = null +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/JsonImportSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/JsonImportSource.kt new file mode 100644 index 0000000..4bc26f3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/JsonImportSource.kt @@ -0,0 +1,148 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.source + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.sendEvent +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_api.domain.model.ImportJsonMetaData +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.api.AccountNameChooserMixin +import io.novafoundation.nova.feature_account_impl.presentation.importing.FileReader +import io.novafoundation.nova.feature_account_impl.presentation.importing.ImportAccountViewModel +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.view.ImportSourceView +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.view.JsonImportView +import io.novasama.substrate_sdk_android.encrypt.json.JsonSeedDecodingException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +private const val PICK_FILE_RESULT_CODE = 101 + +class JsonImportSource( + private val accountNameChooserMixin: AccountNameChooserMixin, + private val addAccountInteractor: AddAccountInteractor, + private val clipboardManager: ClipboardManager, + private val fileReader: FileReader, + private val scope: CoroutineScope, + private val payload: AddAccountPayload, +) : ImportSource(R.string.account_import_json_title), + FileRequester, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(scope) { + + override val encryptionOptionsAvailable: Boolean = false + + val jsonContentFlow = singleReplaySharedFlow() + val passwordFlow = MutableStateFlow("") + + override val fieldsValidFlow: Flow = combine( + jsonContentFlow, + passwordFlow + ) { jsonContent, password -> + jsonContent.isNotEmpty() && password.isNotEmpty() + } + .onStart { emit(false) } + .share() + + private val _showJsonInputOptionsEvent = MutableLiveData>() + val showJsonInputOptionsEvent: LiveData> = _showJsonInputOptionsEvent + + private val _showNetworkWarningFlow = MutableStateFlow(false) + val showNetworkWarningFlow: Flow = _showNetworkWarningFlow + + override val chooseJsonFileEvent = MutableLiveData>() + + override fun initializeView(viewModel: ImportAccountViewModel, fragment: BaseFragment<*, *>): ImportSourceView<*> { + return JsonImportView(fragment.requireContext()).apply { + observeCommon(viewModel, fragment.viewLifecycleOwner) + observeSource(this@JsonImportSource, fragment.viewLifecycleOwner) + } + } + + override suspend fun performImport(addAccountType: AddAccountType): Result { + return addAccountInteractor.importFromJson(jsonContentFlow.first(), passwordFlow.value, addAccountType) + } + + override fun handleError(throwable: Throwable): ImportError? { + return when (throwable) { + is JsonSeedDecodingException.IncorrectPasswordException -> ImportError( + titleRes = R.string.import_json_invalid_password_title, + messageRes = R.string.import_json_invalid_password + ) + + is JsonSeedDecodingException.InvalidJsonException -> ImportError( + titleRes = R.string.import_json_invalid_format_title, + messageRes = R.string.import_json_invalid_format_message + ) + + is AccountSecretsFactory.SecretsError.NotValidEthereumCryptoType -> ImportError( + titleRes = R.string.import_json_unsupported_crypto_title, + messageRes = R.string.import_json_unsupported_ethereum_crypto_message + ) + + is AccountSecretsFactory.SecretsError.NotValidSubstrateCryptoType -> ImportError( + titleRes = R.string.import_json_unsupported_crypto_title, + messageRes = R.string.import_json_unsupported_substrate_crypto_message + ) + + else -> null + } + } + + override fun fileChosen(uri: Uri) { + scope.launch { + val content = fileReader.readFile(uri)!! + + jsonReceived(content) + } + } + + fun jsonClicked() { + _showJsonInputOptionsEvent.sendEvent() + } + + fun chooseFileClicked() { + chooseJsonFileEvent.value = Event(PICK_FILE_RESULT_CODE) + } + + fun pasteClicked() { + clipboardManager.getTextOrNull()?.let(this::jsonReceived) + } + + private fun jsonReceived(newJson: String) { + scope.launch { + jsonContentFlow.emit(newJson) + + val result = addAccountInteractor.extractJsonMetadata(newJson) + + if (result.isSuccess) { + handleParsedImportData(result.getOrThrow()) + } + } + } + + private fun handleParsedImportData(importJsonMetaData: ImportJsonMetaData) { + _showNetworkWarningFlow.value = showShowNetworkWarning(importJsonMetaData.chainId) + + importJsonMetaData.name?.let(accountNameChooserMixin::nameChanged) + } + + private fun showShowNetworkWarning(jsonChainId: String?): Boolean { + val forcedChainId = (payload as? AddAccountPayload.ChainAccount)?.chainId + + // show warning if supplied json has network different than chain which account is creating for + return jsonChainId != null && forcedChainId != null && jsonChainId != forcedChainId + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/MnemonicImportSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/MnemonicImportSource.kt new file mode 100644 index 0000000..8da919f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/MnemonicImportSource.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.source + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportType +import io.novafoundation.nova.feature_account_api.presenatation.account.common.model.toAdvancedEncryption +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.common.AdvancedEncryptionSelectionStoreProvider +import io.novafoundation.nova.feature_account_impl.presentation.importing.ImportAccountViewModel +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.view.ImportSourceView +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.view.MnemonicImportView +import io.novasama.substrate_sdk_android.exceptions.Bip39Exception +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class MnemonicImportSource( + private val addAccountInteractor: AddAccountInteractor, + private val advancedEncryptionInteractor: AdvancedEncryptionInteractor, + private val advancedEncryptionSelectionStoreProvider: AdvancedEncryptionSelectionStoreProvider, + private val importType: ImportType.Mnemonic, + private val coroutineScope: CoroutineScope +) : ImportSource(R.string.account_import_mnemonic_title), CoroutineScope by coroutineScope { + + private var advancedEncryptionSelectionStore = async { advancedEncryptionSelectionStoreProvider.getSelectionStore(coroutineScope) } + + override val encryptionOptionsAvailable: Boolean = importType.origin == ImportType.Mnemonic.Origin.DEFAULT + + val mnemonicContentFlow = MutableStateFlow("") + + override val fieldsValidFlow: Flow = mnemonicContentFlow.map { it.isNotEmpty() } + + init { + launch { + val preset = importType.preset + if (preset != null) { + val advancedSettings = preset.toAdvancedEncryption() + advancedEncryptionSelectionStore().updateSelection(advancedSettings) + } + + importType.mnemonic?.let { mnemonicContentFlow.value = it } + } + } + + override fun initializeView(viewModel: ImportAccountViewModel, fragment: BaseFragment<*, *>): ImportSourceView<*> { + return MnemonicImportView(fragment.requireContext()).apply { + observeCommon(viewModel, fragment.viewLifecycleOwner) + observeSource(this@MnemonicImportSource, fragment.viewLifecycleOwner) + } + } + + override suspend fun performImport(addAccountType: AddAccountType): Result { + return when (importType.origin) { + ImportType.Mnemonic.Origin.DEFAULT -> performImportFromDefaultOrigin(addAccountType) + ImportType.Mnemonic.Origin.TRUST_WALLET -> performImportFromTrustWallet(addAccountType) + } + } + + private suspend fun performImportFromDefaultOrigin(addAccountType: AddAccountType): Result { + val advancedEncryption = advancedEncryptionSelectionStore().getCurrentSelection() + ?: advancedEncryptionInteractor.getRecommendedAdvancedEncryption() + + return addAccountInteractor.importFromMnemonic(mnemonicContentFlow.value, advancedEncryption, addAccountType) + } + + private suspend fun performImportFromTrustWallet(addAccountType: AddAccountType): Result { + require(addAccountType is AddAccountType.MetaAccount) { "Cannot import chain account from Trust Wallet passphrase" } + + return addAccountInteractor.importFromTrustWallet(mnemonicContentFlow.value, addAccountType) + } + + override fun handleError(throwable: Throwable): ImportError? { + return when (throwable) { + is Bip39Exception -> ImportError( + titleRes = R.string.import_mnemonic_invalid_title, + messageRes = R.string.mnemonic_error_try_another_one_v2_2_0 + ) + + else -> null + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/RawSeedImportSource.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/RawSeedImportSource.kt new file mode 100644 index 0000000..8068322 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/source/RawSeedImportSource.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.source + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.common.AdvancedEncryptionSelectionStoreProvider +import io.novafoundation.nova.feature_account_impl.presentation.importing.ImportAccountViewModel +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.view.SeedImportView +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedRequester +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.bouncycastle.util.encoders.DecoderException + +class RawSeedImportSource( + private val addAccountInteractor: AddAccountInteractor, + private val advancedEncryptionInteractor: AdvancedEncryptionInteractor, + private val advancedEncryptionSelectionStoreProvider: AdvancedEncryptionSelectionStoreProvider, + private val scanSeedRequester: ScanSeedRequester, + private val coroutineScope: CoroutineScope +) : ImportSource(R.string.account_import_seed_title), CoroutineScope by coroutineScope { + + private val advancedEncryptionSelectionStore = async { advancedEncryptionSelectionStoreProvider.getSelectionStore(coroutineScope) } + + override val encryptionOptionsAvailable: Boolean = true + + val rawSeedFlow = MutableStateFlow("") + + override val fieldsValidFlow: Flow = rawSeedFlow.map { it.isNotEmpty() } + + init { + scanSeedRequester.responseFlow + .onEach { + rawSeedFlow.value = it.secret + } + .launchIn(this) + } + + override suspend fun performImport(addAccountType: AddAccountType): Result { + val advancedEncryption = advancedEncryptionSelectionStore().getCurrentSelection() + ?: advancedEncryptionInteractor.getRecommendedAdvancedEncryption() + + return addAccountInteractor.importFromSecret(rawSeedFlow.value, advancedEncryption, addAccountType) + } + + override fun initializeView(viewModel: ImportAccountViewModel, fragment: BaseFragment<*, *>): SeedImportView { + return SeedImportView(fragment.requireContext()).apply { + observeCommon(viewModel, fragment.viewLifecycleOwner) + observeSource(this@RawSeedImportSource, fragment.viewLifecycleOwner) + onScanClick { + scanSeedRequester.openRequest(ScanSeedCommunicator.Request()) + } + } + } + + override fun handleError(throwable: Throwable): ImportError? { + return when (throwable) { + is IllegalArgumentException, is DecoderException -> ImportError( + titleRes = R.string.import_seed_invalid_title, + messageRes = R.string.account_import_invalid_seed + ) + + else -> null + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/ImportSourceView.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/ImportSourceView.kt new file mode 100644 index 0000000..05595bd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/ImportSourceView.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.EditText +import android.widget.LinearLayout +import androidx.lifecycle.LifecycleOwner +import io.novafoundation.nova.feature_account_impl.presentation.common.mixin.impl.setupAccountNameChooserUi +import io.novafoundation.nova.feature_account_impl.presentation.importing.ImportAccountViewModel +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.ImportSource + +class ImportAccountNameViews( + val nameInput: EditText, + val visibilityDependent: List, +) + +abstract class ImportSourceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + protected abstract val nameInputViews: ImportAccountNameViews + + init { + orientation = VERTICAL + } + + abstract fun observeSource(source: S, lifecycleOwner: LifecycleOwner) + + fun observeCommon(viewModel: ImportAccountViewModel, lifecycleOwner: LifecycleOwner) { + setupAccountNameChooserUi(viewModel, nameInputViews.nameInput, lifecycleOwner, nameInputViews.visibilityDependent) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/JsonImportView.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/JsonImportView.kt new file mode 100644 index 0000000..e01d0d7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/JsonImportView.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.view + +import android.content.Context +import android.util.AttributeSet +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.coroutineScope +import io.novafoundation.nova.common.utils.EventObserver +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.observe +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_impl.databinding.ImportSourceJsonBinding +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.JsonImportSource + +class JsonImportView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ImportSourceView(context, attrs, defStyleAttr) { + + private val binder = ImportSourceJsonBinding.inflate(inflater(), this) + + override val nameInputViews: ImportAccountNameViews + get() = ImportAccountNameViews( + nameInput = binder.importJsonUsernameInput.content, + visibilityDependent = emptyList() + ) + + override fun observeSource(source: JsonImportSource, lifecycleOwner: LifecycleOwner) { + val scope = lifecycleOwner.lifecycle.coroutineScope + + source.jsonContentFlow.observe(scope, binder.importJsonContent::setMessage) + + binder.importJsonContent.setWholeClickListener { source.jsonClicked() } + + source.showJsonInputOptionsEvent.observe( + lifecycleOwner, + EventObserver { + showJsonInputOptionsSheet(source) + } + ) + + binder.importJsonPasswordInput.content.bindTo(source.passwordFlow, scope) + + binder.importJsonContent.setOnClickListener { + source.jsonClicked() + } + + source.showNetworkWarningFlow.observe(scope) { + binder.importJsonNoNetworkInfo.setVisible(it) + } + } + + private fun showJsonInputOptionsSheet(source: JsonImportSource) { + JsonPasteOptionsSheet(context, source::pasteClicked, source::chooseFileClicked) + .show() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/JsonPasteOptionsSheet.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/JsonPasteOptionsSheet.kt new file mode 100644 index 0000000..5b6a13a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/JsonPasteOptionsSheet.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.view + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem +import io.novafoundation.nova.feature_account_impl.R + +class JsonPasteOptionsSheet( + context: Context, + val onPaste: () -> Unit, + val onOpenFile: () -> Unit +) : FixedListBottomSheet(context, viewConfiguration = ViewConfiguration.default(context)) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.recovery_json) + + textItem(iconRes = R.drawable.ic_copy_outline, titleRes = R.string.import_json_paste) { + onPaste() + } + + textItem(iconRes = R.drawable.ic_json_file_upload_outline, titleRes = R.string.recover_json_upload) { + onOpenFile() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/MnemonicImportView.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/MnemonicImportView.kt new file mode 100644 index 0000000..a18a92c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/MnemonicImportView.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.view + +import android.content.Context +import android.util.AttributeSet +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.coroutineScope +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.getIdleDrawable +import io.novafoundation.nova.feature_account_impl.databinding.ImportSourceMnemonicBinding +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.MnemonicImportSource + +class MnemonicImportView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ImportSourceView(context, attrs, defStyleAttr) { + + private val binder = ImportSourceMnemonicBinding.inflate(inflater(), this) + + override val nameInputViews: ImportAccountNameViews + get() = ImportAccountNameViews( + nameInput = binder.importMnemonicUsernameInput.content, + visibilityDependent = listOf(binder.importMnemnonicUsernameHint) + ) + + init { + binder.importMnemonicContentContainer.background = context.getIdleDrawable() + } + + override fun observeSource(source: MnemonicImportSource, lifecycleOwner: LifecycleOwner) { + binder.importMnemonicContent.bindTo(source.mnemonicContentFlow, lifecycleOwner.lifecycle.coroutineScope) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/SeedImportView.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/SeedImportView.kt new file mode 100644 index 0000000..3ea2479 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/importing/source/view/SeedImportView.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_account_impl.presentation.importing.source.view + +import android.content.Context +import android.util.AttributeSet +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.coroutineScope +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_impl.databinding.ImportSourceSeedBinding +import io.novafoundation.nova.feature_account_impl.presentation.importing.source.source.RawSeedImportSource + +class SeedImportView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ImportSourceView(context, attrs, defStyleAttr) { + + private val binder = ImportSourceSeedBinding.inflate(inflater(), this) + + override val nameInputViews: ImportAccountNameViews + get() = ImportAccountNameViews( + nameInput = binder.importSeedUsernameInput, + visibilityDependent = listOf() + ) + + override fun observeSource(source: RawSeedImportSource, lifecycleOwner: LifecycleOwner) { + binder.importSeedInput.bindTo(source.rawSeedFlow, lifecycleOwner.lifecycle.coroutineScope) + } + + fun onScanClick(onClickListener: OnClickListener) { + binder.importSeedInput.onScanClicked(onClickListener) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/LanguagesAdapter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/LanguagesAdapter.kt new file mode 100644 index 0000000..84a401a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/LanguagesAdapter.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_account_impl.presentation.language + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageModel +import io.novafoundation.nova.feature_account_impl.databinding.ItemLanguageBinding + +class LanguagesAdapter( + private val languagesItemHandler: LanguagesItemHandler +) : ListAdapter(LanguagesDiffCallback) { + + interface LanguagesItemHandler { + + fun checkClicked(languageModel: LanguageModel) + } + + private var selectedItem: LanguageModel? = null + + fun updateSelectedLanguage(newSelection: LanguageModel) { + val positionToHide = selectedItem?.let { selected -> + currentList.indexOfFirst { selected.iso == it.iso } + } + + val positionToShow = currentList.indexOfFirst { + newSelection.iso == it.iso + } + + selectedItem = newSelection + + positionToHide?.let { notifyItemChanged(it) } + notifyItemChanged(positionToShow) + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): LanguageViewHolder { + return LanguageViewHolder(ItemLanguageBinding.inflate(viewGroup.inflater(), viewGroup, false)) + } + + override fun onBindViewHolder(languageViewHolder: LanguageViewHolder, position: Int) { + val languageModel = getItem(position) + val isChecked = languageModel.iso == selectedItem?.iso + + languageViewHolder.bind(languageModel, languagesItemHandler, isChecked) + } +} + +class LanguageViewHolder(private val binder: ItemLanguageBinding) : RecyclerView.ViewHolder(binder.root) { + + fun bind(language: LanguageModel, handler: LanguagesAdapter.LanguagesItemHandler, isChecked: Boolean) { + with(itemView) { + binder.languageNameTv.text = language.displayName + binder.languageNativeNameTv.text = language.nativeDisplayName + + binder.languageCheck.visibility = if (isChecked) View.VISIBLE else View.INVISIBLE + + setOnClickListener { handler.checkClicked(language) } + } + } +} + +object LanguagesDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: LanguageModel, newItem: LanguageModel): Boolean { + return oldItem.iso == newItem.iso + } + + override fun areContentsTheSame(oldItem: LanguageModel, newItem: LanguageModel): Boolean { + return oldItem == newItem + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/LanguagesFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/LanguagesFragment.kt new file mode 100644 index 0000000..94c98d7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/LanguagesFragment.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_impl.presentation.language + +import io.novafoundation.nova.common.base.BaseActivity +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageModel +import io.novafoundation.nova.feature_account_impl.databinding.FragmentLanguagesBinding + +class LanguagesFragment : BaseFragment(), LanguagesAdapter.LanguagesItemHandler { + + override fun createBinding() = FragmentLanguagesBinding.inflate(layoutInflater) + + private lateinit var adapter: LanguagesAdapter + + override fun initViews() { + adapter = LanguagesAdapter(this) + + binder.languagesList.setHasFixedSize(true) + binder.languagesList.adapter = adapter + + binder.novaToolbar.setHomeButtonListener { + viewModel.backClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .languagesComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: LanguagesViewModel) { + adapter.submitList(viewModel.languagesModels) + + viewModel.selectedLanguageLiveData.observe(adapter::updateSelectedLanguage) + + viewModel.languageChangedEvent.observeEvent { + (activity as BaseActivity<*, *>).changeLanguage() + } + } + + override fun checkClicked(languageModel: LanguageModel) { + viewModel.selectLanguageClicked(languageModel) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/LanguagesViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/LanguagesViewModel.kt new file mode 100644 index 0000000..f641a54 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/LanguagesViewModel.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_impl.presentation.language + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.core.model.Language +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageModel +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.language.mapper.mapLanguageToLanguageModel +import kotlinx.coroutines.launch + +class LanguagesViewModel( + private val interactor: AccountInteractor, + private val router: AccountRouter +) : BaseViewModel() { + + val languagesModels = getLanguages() + + val selectedLanguageLiveData = liveData { + val languages = interactor.getSelectedLanguage() + val mapped = mapLanguageToLanguageModel(languages) + + emit(mapped) + } + + private val _languageChangedEvent = MutableLiveData>() + val languageChangedEvent: LiveData> = _languageChangedEvent + + fun backClicked() { + router.back() + } + + fun selectLanguageClicked(languageModel: LanguageModel) { + viewModelScope.launch { + interactor.changeSelectedLanguage(Language(languageModel.iso)) + + _languageChangedEvent.value = Event(Unit) + } + } + + private fun getLanguages() = interactor.getLanguages().map(::mapLanguageToLanguageModel) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/RealLanguageUseCase.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/RealLanguageUseCase.kt new file mode 100644 index 0000000..661c416 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/RealLanguageUseCase.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_impl.presentation.language + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageModel +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageUseCase +import io.novafoundation.nova.feature_account_impl.presentation.language.mapper.mapLanguageToLanguageModel + +internal class RealLanguageUseCase( + private val accountInteractor: AccountInteractor, +) : LanguageUseCase { + + override suspend fun selectedLanguageModel(): LanguageModel { + return mapLanguageToLanguageModel(accountInteractor.getSelectedLanguage()) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/di/LanguagesComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/di/LanguagesComponent.kt new file mode 100644 index 0000000..38451eb --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/di/LanguagesComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.language.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.language.LanguagesFragment + +@Subcomponent( + modules = [ + LanguagesModule::class + ] +) +@ScreenScope +interface LanguagesComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): LanguagesComponent + } + + fun inject(fragment: LanguagesFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/di/LanguagesModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/di/LanguagesModule.kt new file mode 100644 index 0000000..4ea8bac --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/di/LanguagesModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.presentation.language.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.language.LanguagesViewModel + +@Module(includes = [ViewModelModule::class]) +class LanguagesModule { + + @Provides + @IntoMap + @ViewModelKey(LanguagesViewModel::class) + fun provideViewModel( + interactor: AccountInteractor, + router: AccountRouter + ): ViewModel { + return LanguagesViewModel(interactor, router) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): LanguagesViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(LanguagesViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/mapper/LanguageMapper.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/mapper/LanguageMapper.kt new file mode 100644 index 0000000..b45dea5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/language/mapper/LanguageMapper.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_impl.presentation.language.mapper + +import io.novafoundation.nova.common.utils.capitalize +import io.novafoundation.nova.core.model.Language +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageModel +import java.util.Locale + +fun mapLanguageToLanguageModel(language: Language): LanguageModel { + val languageLocale = Locale(language.iso639Code) + return LanguageModel( + language.iso639Code, + languageLocale.displayLanguage.capitalize(), + languageLocale.getDisplayLanguage(languageLocale).capitalize() + ) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/ChainAddressSelectorFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/ChainAddressSelectorFragment.kt new file mode 100644 index 0000000..db86054 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/ChainAddressSelectorFragment.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_account_impl.presentation.legacyAddress + +import android.os.Bundle +import android.text.method.LinkMovementMethod +import androidx.core.content.ContextCompat +import io.novafoundation.nova.common.base.BaseBottomSheetFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.clickableSpan +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.drawableSpan +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.setEndSpan +import io.novafoundation.nova.common.utils.setFullSpan +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentChainAddressSelectorBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent + +class ChainAddressSelectorFragment : BaseBottomSheetFragment() { + + companion object { + private const val KEY_PAYLOAD = "KEY_REQUEST" + + fun getBundle(request: ChainAddressSelectorPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, request) + } + } + } + + override fun createBinding() = FragmentChainAddressSelectorBinding.inflate(layoutInflater) + + override fun initViews() { + binder.selectLegacyAddressSubtitle.movementMethod = LinkMovementMethod.getInstance() + binder.selectLegacyAddressSubtitle.text = getDescriptionSpannableText() + + binder.legacyAddressCheckbox.isChecked = viewModel.addressSelectorDisabled() + binder.legacyAddressCheckbox.setOnCheckedChangeListener { _, isChecked -> viewModel.disableAddressSelector(isChecked) } + + binder.legacyAddressButton.setOnClickListener { viewModel.back() } + binder.addressNewContainer.setOnClickListener { viewModel.copyNewAddress() } + binder.addressLegacyContainer.setOnClickListener { viewModel.copyLegacyAddress() } + + binder.addressNewContainer.background = getRoundedCornerDrawable(fillColorRes = R.color.block_background, cornerSizeDp = 16).withRippleMask() + binder.addressLegacyContainer.background = getRoundedCornerDrawable(fillColorRes = R.color.block_background, cornerSizeDp = 16).withRippleMask() + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .chainAddressSelectorComponent() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ChainAddressSelectorViewModel) { + observeBrowserEvents(viewModel) + + viewModel.newAddressFlow.observe { binder.addressFormatAddress.text = it } + viewModel.legacyAddressFlow.observe { binder.addressFormatAddressLegacy.text = it } + } + + private fun getDescriptionSpannableText(): CharSequence { + val chevronIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_chevron_right_16)!! + chevronIcon.setTint(requireContext().getColor(R.color.icon_accent)) + chevronIcon.setBounds(0, 0, chevronIcon.intrinsicWidth, chevronIcon.intrinsicHeight) + + val learnMoreButton = getString(R.string.common_learn_more).toSpannable(clickableSpan { viewModel.openLearnMore() }) + .setFullSpan(colorSpan(requireContext().getColor(R.color.button_text_accent))) + .setEndSpan(drawableSpan(chevronIcon)) + + return SpannableFormatter.format( + getString(R.string.unified_address_subtitle), + learnMoreButton + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/ChainAddressSelectorPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/ChainAddressSelectorPayload.kt new file mode 100644 index 0000000..b982d80 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/ChainAddressSelectorPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_account_impl.presentation.legacyAddress + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +@Parcelize +class ChainAddressSelectorPayload(val chainId: String, val accountId: ByteArray) : Parcelable diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/ChainAddressSelectorViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/ChainAddressSelectorViewModel.kt new file mode 100644 index 0000000..15fe62a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/ChainAddressSelectorViewModel.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_account_impl.presentation.legacyAddress + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_api.domain.account.common.ChainWithAccountId +import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ChainAddressSelectorViewModel( + private val router: AccountRouter, + private val accountInteractor: AccountInteractor, + private val payload: ChainAddressSelectorPayload, + private val copyAddressMixin: CopyAddressMixin, + private val appLinksProvider: AppLinksProvider +) : BaseViewModel(), Browserable.Presentation by Browserable() { + + private val chainWithAccountIdFlow = accountInteractor.chainFlow(payload.chainId) + .map { ChainWithAccountId(it, payload.accountId) } + .shareInBackground() + + val newAddressFlow = chainWithAccountIdFlow.map { copyAddressMixin.getPrimaryAddress(it) } + + val legacyAddressFlow = chainWithAccountIdFlow.map { copyAddressMixin.getLegacyAddress(it) } + + fun back() { + router.closeChainAddressesSelector() + } + + fun copyNewAddress() { + launch { + copyAddressMixin.copyPrimaryAddress(chainWithAccountIdFlow.first()) + back() + } + } + + fun copyLegacyAddress() { + launch { + copyAddressMixin.copyLegacyAddress(chainWithAccountIdFlow.first()) + back() + } + } + + fun disableAddressSelector(disable: Boolean) { + enableAddressSelector(!disable) + } + + fun addressSelectorDisabled(): Boolean { + return !copyAddressMixin.shouldShowAddressSelector() + } + + fun openLearnMore() { + showBrowser(appLinksProvider.unifiedAddressArticle) + } + + private fun enableAddressSelector(enable: Boolean) { + copyAddressMixin.enableAddressSelector(enable) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/di/ChainAddressSelectorComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/di/ChainAddressSelectorComponent.kt new file mode 100644 index 0000000..94b0c8c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/di/ChainAddressSelectorComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.legacyAddress.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.legacyAddress.ChainAddressSelectorFragment +import io.novafoundation.nova.feature_account_impl.presentation.legacyAddress.ChainAddressSelectorPayload + +@Subcomponent( + modules = [ + ChainAddressSelectorModule::class + ] +) +@ScreenScope +interface ChainAddressSelectorComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ChainAddressSelectorPayload + ): ChainAddressSelectorComponent + } + + fun inject(fragment: ChainAddressSelectorFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/di/ChainAddressSelectorModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/di/ChainAddressSelectorModule.kt new file mode 100644 index 0000000..b2cc659 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/legacyAddress/di/ChainAddressSelectorModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.presentation.legacyAddress.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.legacyAddress.ChainAddressSelectorPayload +import io.novafoundation.nova.feature_account_impl.presentation.legacyAddress.ChainAddressSelectorViewModel + +@Module(includes = [ViewModelModule::class]) +class ChainAddressSelectorModule { + + @Provides + @IntoMap + @ViewModelKey(ChainAddressSelectorViewModel::class) + fun provideViewModel( + router: AccountRouter, + accountInteractor: AccountInteractor, + payload: ChainAddressSelectorPayload, + copyAddressMixin: CopyAddressMixin, + appLinksProvider: AppLinksProvider + ): ViewModel { + return ChainAddressSelectorViewModel( + router = router, + payload = payload, + accountInteractor = accountInteractor, + copyAddressMixin = copyAddressMixin, + appLinksProvider = appLinksProvider + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ChainAddressSelectorViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ChainAddressSelectorViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/ManualBackupSelectAccountFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/ManualBackupSelectAccountFragment.kt new file mode 100644 index 0000000..4eb70a5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/ManualBackupSelectAccountFragment.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts + +import android.os.Bundle +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.recyclerview.adapter.text.TextAdapter +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentManualBackupSelectWalletBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.adapter.ManualBackupAccountRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.adapter.ManualBackupAccountsAdapter +import javax.inject.Inject + +class ManualBackupSelectAccountFragment : + BaseFragment(), + ManualBackupAccountsAdapter.AccountHandler { + + companion object { + + private const val KEY_PAYLOAD = "payload" + + fun bundle(payload: ManualBackupSelectAccountPayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentManualBackupSelectWalletBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val headerAdapter by lazy { + TextAdapter( + requireContext().getString(R.string.manual_backup_select_account_header), + R.style.TextAppearance_NovaFoundation_Bold_Title3 + ) + } + + private val listAdapter by lazy { + ManualBackupAccountsAdapter( + imageLoader = imageLoader, + accountHandler = this + ) + } + + private val adapter by lazy { + ConcatAdapter( + headerAdapter, + listAdapter + ) + } + + override fun initViews() { + binder.manualBackupWalletsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.manualBackupWalletsList.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .manualBackupSelectAccount() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ManualBackupSelectAccountViewModel) { + viewModel.walletModel.observe { + binder.manualBackupWalletsToolbar.setTitleIcon(it.icon) + binder.manualBackupWalletsToolbar.setTitle(it.name) + } + + viewModel.accountsList.observe { + listAdapter.submitList(it) + } + } + + override fun onAccountClicked(account: ManualBackupAccountRvItem) { + viewModel.walletClicked(account) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/ManualBackupSelectAccountPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/ManualBackupSelectAccountPayload.kt new file mode 100644 index 0000000..49585a4 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/ManualBackupSelectAccountPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class ManualBackupSelectAccountPayload( + val metaId: Long +) : Parcelable diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/ManualBackupSelectAccountViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/ManualBackupSelectAccountViewModel.kt new file mode 100644 index 0000000..47d24e7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/ManualBackupSelectAccountViewModel.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.images.asIcon +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.chain.iconOrFallback +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.manualBackup.ManualBackupSelectAccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.manualBackup.MetaAccountChains +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.adapter.ManualBackupAccountGroupRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.adapter.ManualBackupAccountRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.adapter.ManualBackupRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import kotlinx.coroutines.flow.map + +class ManualBackupSelectAccountViewModel( + private val router: AccountRouter, + private val resourceManager: ResourceManager, + private val manualBackupSelectAccountInteractor: ManualBackupSelectAccountInteractor, + private val walletUiUseCase: WalletUiUseCase, + private val payload: ManualBackupSelectAccountPayload +) : BaseViewModel() { + + private val metaAccountChains = manualBackupSelectAccountInteractor.sortedMetaAccountChains(payload.metaId) + .shareInBackground() + + val walletModel = metaAccountChains.map { metaAccountChains -> + walletUiUseCase.walletUiFor(metaAccountChains.metaAccount) + } + + val accountsList = metaAccountChains.map { metaAccountChains -> + mapMetaAccountToUI(metaAccountChains) + } + + fun walletClicked(accountModel: ManualBackupAccountRvItem) { + val payload = if (accountModel.chainId == null) { + ManualBackupCommonPayload.DefaultAccount(metaId = this.payload.metaId) + } else { + ManualBackupCommonPayload.ChainAccount(metaId = this.payload.metaId, chainId = accountModel.chainId) + } + + router.openManualBackupConditions(payload) + } + + fun backClicked() { + router.back() + } + + private fun mapMetaAccountToUI( + metaAccountChains: MetaAccountChains + ): List { + return buildList { + if (metaAccountChains.defaultChains.isNotEmpty()) { + this += ManualBackupAccountGroupRvItem(resourceManager.getString(R.string.manual_backup_select_account_default_key_title)) + + val firstChains = metaAccountChains.defaultChains.take(2) + .joinToString { it.name } + val remainingChains = metaAccountChains.defaultChains.drop(2) + val subtitle = if (remainingChains.isNotEmpty()) { + resourceManager.getString(R.string.manual_backup_select_account_default_key_account_subtitle_more_chains, firstChains, remainingChains.size) + } else { + firstChains + } + + this += ManualBackupAccountRvItem( + chainId = null, // It's null for default account + icon = resourceManager.getDrawable(R.drawable.ic_pezkuwi_logo).asIcon(), + title = resourceManager.getString(R.string.manual_backup_select_account_default_key_account), + subtitle = subtitle + ) + } + + if (metaAccountChains.customChains.isNotEmpty()) { + this += ManualBackupAccountGroupRvItem(resourceManager.getString(R.string.manual_backup_select_account_custom_key_title)) + + metaAccountChains.customChains.forEach { chain -> + this += ManualBackupAccountRvItem( + chainId = chain.id, + icon = chain.iconOrFallback(), + title = chain.name, + subtitle = null + ) + } + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/adapter/ManualBackupAccountsAdapter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/adapter/ManualBackupAccountsAdapter.kt new file mode 100644 index 0000000..1fb3180 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/adapter/ManualBackupAccountsAdapter.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.adapter + +import android.view.ViewGroup +import coil.ImageLoader +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_account_impl.databinding.ItemBackupAccountBinding +import io.novafoundation.nova.feature_account_impl.databinding.ItemBackupAccountHeaderBinding + +class ManualBackupAccountsAdapter( + private val imageLoader: ImageLoader, + private val accountHandler: AccountHandler +) : GroupedListAdapter(AccountDiffCallback()) { + + interface AccountHandler { + fun onAccountClicked(account: ManualBackupAccountRvItem) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return ManualBackupGroupViewHolder(ItemBackupAccountHeaderBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return ManualBackupAccountViewHolder(ItemBackupAccountBinding.inflate(parent.inflater(), parent, false), imageLoader, accountHandler) + } + + override fun bindGroup(holder: GroupedListHolder, group: ManualBackupAccountGroupRvItem) { + (holder as ManualBackupGroupViewHolder).bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: ManualBackupAccountRvItem) { + (holder as ManualBackupAccountViewHolder).bind(child) + } +} + +class ManualBackupGroupViewHolder(private val binder: ItemBackupAccountHeaderBinding) : GroupedListHolder(binder.root) { + + fun bind(item: ManualBackupAccountGroupRvItem) { + binder.itemManualBackupGroupTitle.text = item.text + } +} + +class ManualBackupAccountViewHolder( + private val binder: ItemBackupAccountBinding, + private val imageLoader: ImageLoader, + private val accountHandler: ManualBackupAccountsAdapter.AccountHandler +) : GroupedListHolder(binder.root) { + + fun bind(item: ManualBackupAccountRvItem) { + with(binder) { + itemManualBackupAccountContainer.background = binder.root.context.addRipple(binder.root.context.getBlockDrawable()) + itemManualBackupAccountIcon.setIcon(item.icon, imageLoader) + itemManualBackupAccountTitle.text = item.title + itemManualBackupAccountSubtitle.setTextOrHide(item.subtitle) + itemManualBackupAccountContainer.setOnClickListener { accountHandler.onAccountClicked(item) } + } + } +} + +class AccountDiffCallback : BaseGroupedDiffCallback(ManualBackupAccountGroupRvItem::class.java) { + + override fun areGroupItemsTheSame(oldItem: ManualBackupAccountGroupRvItem, newItem: ManualBackupAccountGroupRvItem): Boolean { + return oldItem.text == newItem.text + } + + override fun areGroupContentsTheSame(oldItem: ManualBackupAccountGroupRvItem, newItem: ManualBackupAccountGroupRvItem): Boolean { + return true + } + + override fun areChildItemsTheSame(oldItem: ManualBackupAccountRvItem, newItem: ManualBackupAccountRvItem): Boolean { + return oldItem.title == newItem.title + } + + override fun areChildContentsTheSame(oldItem: ManualBackupAccountRvItem, newItem: ManualBackupAccountRvItem): Boolean { + return true + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/adapter/ManualBackupRvItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/adapter/ManualBackupRvItem.kt new file mode 100644 index 0000000..1b559eb --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/adapter/ManualBackupRvItem.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.adapter + +import io.novafoundation.nova.common.utils.images.Icon + +interface ManualBackupRvItem + +class ManualBackupAccountGroupRvItem( + val text: String +) : ManualBackupRvItem + +class ManualBackupAccountRvItem( + val chainId: String?, // It's null for default account + val icon: Icon, + val title: String, + val subtitle: String?, +) : ManualBackupRvItem diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/di/ManualBackupSelectAccountComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/di/ManualBackupSelectAccountComponent.kt new file mode 100644 index 0000000..7a79766 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/di/ManualBackupSelectAccountComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.ManualBackupSelectAccountFragment +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.ManualBackupSelectAccountPayload + +@Subcomponent( + modules = [ + ManualBackupSelectAccountModule::class + ] +) +@ScreenScope +interface ManualBackupSelectAccountComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ManualBackupSelectAccountPayload + ): ManualBackupSelectAccountComponent + } + + fun inject(fragment: ManualBackupSelectAccountFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/di/ManualBackupSelectAccountModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/di/ManualBackupSelectAccountModule.kt new file mode 100644 index 0000000..439f5d5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/accounts/di/ManualBackupSelectAccountModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_impl.domain.manualBackup.ManualBackupSelectAccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.ManualBackupSelectAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.accounts.ManualBackupSelectAccountViewModel + +@Module(includes = [ViewModelModule::class]) +class ManualBackupSelectAccountModule { + + @Provides + @IntoMap + @ViewModelKey(ManualBackupSelectAccountViewModel::class) + fun provideViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + manualBackupSelectAccountInteractor: ManualBackupSelectAccountInteractor, + walletUiUseCase: WalletUiUseCase, + payload: ManualBackupSelectAccountPayload + ): ViewModel { + return ManualBackupSelectAccountViewModel( + router, + resourceManager, + manualBackupSelectAccountInteractor, + walletUiUseCase, + payload + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ManualBackupSelectAccountViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(ManualBackupSelectAccountViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/common/ManualBackupCommonPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/common/ManualBackupCommonPayload.kt new file mode 100644 index 0000000..57abd69 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/common/ManualBackupCommonPayload.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_impl.presentation.exporting.ExportPayload +import kotlinx.parcelize.Parcelize + +sealed interface ManualBackupCommonPayload : Parcelable { + + val metaId: Long + + @Parcelize + class DefaultAccount( + override val metaId: Long + ) : ManualBackupCommonPayload + + @Parcelize + class ChainAccount( + override val metaId: Long, + val chainId: String + ) : ManualBackupCommonPayload +} + +fun ManualBackupCommonPayload.getChainIdOrNull(): String? { + return if (this is ManualBackupCommonPayload.ChainAccount) { + chainId + } else { + null + } +} + +fun ManualBackupCommonPayload.requireChainId(): String { + return (this as ManualBackupCommonPayload.ChainAccount).chainId +} + +fun ManualBackupCommonPayload.toExportPayload(): ExportPayload { + return when (this) { + is ManualBackupCommonPayload.DefaultAccount -> ExportPayload.MetaAccount(metaId) + is ManualBackupCommonPayload.ChainAccount -> ExportPayload.ChainAccount(metaId, chainId) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/ManualBackupAdvancedSecretsFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/ManualBackupAdvancedSecretsFragment.kt new file mode 100644 index 0000000..73a1c32 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/ManualBackupAdvancedSecretsFragment.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.advanced + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.decoration.ExtraSpaceItemDecoration +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentManualBackupAdvancedSecretsBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.ManualBackupItemHandler +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.ManualBackupSecretsAdapter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupJsonRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsVisibilityRvItem + +class ManualBackupAdvancedSecretsFragment : + BaseFragment(), + ManualBackupItemHandler { + + companion object { + + private const val KEY_PAYLOAD = "payload" + + fun bundle(payload: ManualBackupCommonPayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentManualBackupAdvancedSecretsBinding.inflate(layoutInflater) + + private val adapter = ManualBackupSecretsAdapter(this) + + override fun initViews() { + binder.manualBackupAdvancedSecretsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.manualBackupAdvancedSecretsList.adapter = adapter + binder.manualBackupAdvancedSecretsList.addItemDecoration(ExtraSpaceItemDecoration()) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .manualBackupAdvancedSecrets() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ManualBackupAdvancedSecretsViewModel) { + viewModel.exportList.observe { + adapter.submitList(it) + } + } + + override fun onExportJsonClick(item: ManualBackupJsonRvItem) { + viewModel.exportJsonClicked() + } + + override fun onTapToRevealClicked(item: ManualBackupSecretsVisibilityRvItem) { + viewModel.onTapToRevealClicked(item) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/ManualBackupAdvancedSecretsViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/ManualBackupAdvancedSecretsViewModel.kt new file mode 100644 index 0000000..64d9b1a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/ManualBackupAdvancedSecretsViewModel.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.advanced + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.getChainIdOrNull +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.toExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.ManualBackupSecretsAdapterItemFactory +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsVisibilityRvItem + +class ManualBackupAdvancedSecretsViewModel( + private val resourceManager: ResourceManager, + private val router: AccountRouter, + private val payload: ManualBackupCommonPayload, + private val secretsAdapterItemFactory: ManualBackupSecretsAdapterItemFactory +) : BaseViewModel() { + + val exportList = flowOf { buildSecrets() } + .shareInBackground() + + fun onTapToRevealClicked(item: ManualBackupSecretsVisibilityRvItem) { + // It's not necessary to update the list, because the item will play a show animation. We just need to update its state + item.makeShown() + } + + fun exportJsonClicked() { + router.exportJsonAction(payload.toExportPayload()) + } + + fun backClicked() { + router.back() + } + + private suspend fun buildSecrets(): List = buildList { + this += secretsAdapterItemFactory.createSubtitle(resourceManager.getString(R.string.manual_backup_secrets_subtitle)) + this += secretsAdapterItemFactory.createAdvancedSecrets(payload.metaId, payload.getChainIdOrNull()) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/di/ManualBackupAdvancedSecretsComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/di/ManualBackupAdvancedSecretsComponent.kt new file mode 100644 index 0000000..54fbb7c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/di/ManualBackupAdvancedSecretsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.advanced.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.advanced.ManualBackupAdvancedSecretsFragment +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload + +@Subcomponent( + modules = [ + ManualBackupAdvancedSecretsModule::class + ] +) +@ScreenScope +interface ManualBackupAdvancedSecretsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ManualBackupCommonPayload + ): ManualBackupAdvancedSecretsComponent + } + + fun inject(fragment: ManualBackupAdvancedSecretsFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/di/ManualBackupAdvancedSecretsModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/di/ManualBackupAdvancedSecretsModule.kt new file mode 100644 index 0000000..98fb0d5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/advanced/di/ManualBackupAdvancedSecretsModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.advanced.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.advanced.ManualBackupAdvancedSecretsViewModel +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.ManualBackupSecretsAdapterItemFactory + +@Module(includes = [ViewModelModule::class]) +class ManualBackupAdvancedSecretsModule { + + @Provides + @IntoMap + @ViewModelKey(ManualBackupAdvancedSecretsViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: AccountRouter, + payload: ManualBackupCommonPayload, + secretsAdapterItemFactory: ManualBackupSecretsAdapterItemFactory + ): ViewModel { + return ManualBackupAdvancedSecretsViewModel( + resourceManager, + router, + payload, + secretsAdapterItemFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ManualBackupAdvancedSecretsViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(ManualBackupAdvancedSecretsViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/ManualBackupSecretsAdapterItemFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/ManualBackupSecretsAdapterItemFactory.kt new file mode 100644 index 0000000..c7edd9e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/ManualBackupSecretsAdapterItemFactory.kt @@ -0,0 +1,171 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common + +import io.novafoundation.nova.common.data.secrets.v2.derivationPath +import io.novafoundation.nova.common.data.secrets.v2.privateKey +import io.novafoundation.nova.common.data.secrets.v2.seed +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.data.secrets.derivationPath +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.cryptoTypeIn +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.mappers.mapCryptoTypeToCryptoTypeSubtitle +import io.novafoundation.nova.feature_account_impl.data.mappers.mapCryptoTypeToCryptoTypeTitle +import io.novafoundation.nova.feature_account_impl.domain.account.export.CommonExportSecretsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupChainRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupCryptoTypeRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupJsonRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupMnemonicRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupSeedRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupSubtitleRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupTitleRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface ManualBackupSecretsAdapterItemFactory { + + suspend fun createChainItem(chainId: String): ManualBackupSecretsRvItem + + suspend fun createTitle(text: String): ManualBackupSecretsRvItem + + suspend fun createSubtitle(text: String): ManualBackupSecretsRvItem + + suspend fun createMnemonic(metaId: Long, chainId: String?): ManualBackupSecretsRvItem? + + suspend fun createAdvancedSecrets(metaId: Long, chainIdOrNull: String?): Collection +} + +class RealManualBackupSecretsAdapterItemFactory( + private val resourceManager: ResourceManager, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val exportSecretsInteractor: CommonExportSecretsInteractor, +) : ManualBackupSecretsAdapterItemFactory { + + override suspend fun createChainItem(chainId: String): ManualBackupSecretsRvItem { + val chain = chainRegistry.getChain(chainId) + return ManualBackupChainRvItem(mapChainToUi(chain)) + } + + override suspend fun createTitle(text: String): ManualBackupSecretsRvItem { + return ManualBackupTitleRvItem(text) + } + + override suspend fun createSubtitle(text: String): ManualBackupSecretsRvItem { + return ManualBackupSubtitleRvItem(text) + } + + override suspend fun createMnemonic(metaId: Long, chainId: String?): ManualBackupSecretsRvItem? { + val metaAccount = accountRepository.getMetaAccount(metaId) + val chain = chainId?.let { chainRegistry.getChain(it) } + val mnemonic = if (chain == null) { + exportSecretsInteractor.getMetaAccountMnemonic(metaAccount) + } else { + exportSecretsInteractor.getChainAccountMnemonic(metaAccount, chain) + } + return mnemonic?.let { ManualBackupMnemonicRvItem(it.wordList, isShown = false) } + } + + override suspend fun createAdvancedSecrets(metaId: Long, chainIdOrNull: String?): Collection = buildList { + val metaAccount = accountRepository.getMetaAccount(metaId) + + if (chainIdOrNull != null) { + val chain = chainRegistry.getChain(chainIdOrNull) + addChainAdditionalSecrets(metaAccount, chain) + } else { + addDefaultAccountSecrets(metaAccount) + } + } + + private suspend fun MutableList.addDefaultAccountSecrets(metaAccount: MetaAccount) { + if (exportSecretsInteractor.hasSubstrateSecrets(metaAccount)) { + addSubstrateAdditionalSecrets(metaAccount) + } + + if (exportSecretsInteractor.hasEthereumSecrets(metaAccount)) { + addEthereumAdditionalSecrets(metaAccount) + } + } + + private suspend fun MutableList.addChainAdditionalSecrets(metaAccount: MetaAccount, chain: Chain) { + val privateKey = if (chain.isEthereumBased) { + exportSecretsInteractor.getChainAccountPrivateKey(metaAccount, chain) + } else { + exportSecretsInteractor.getChainAccountSeed(metaAccount, chain) + } + + this += createAdditionalSecretsInternal( + networkName = chain.name, + isEthereumBased = chain.isEthereumBased, + privateKey = privateKey, + cryptoType = metaAccount.cryptoTypeIn(chain), + derivationPath = exportSecretsInteractor.getDerivationPath(metaAccount, chain), + showCryptoType = privateKey != null + ) + } + + private suspend fun MutableList.addSubstrateAdditionalSecrets(metaAccount: MetaAccount) { + val seed = exportSecretsInteractor.getMetaAccountSeed(metaAccount) + + this += createAdditionalSecretsInternal( + networkName = resourceManager.getString(R.string.common_network_polkadot), + isEthereumBased = false, + privateKey = seed, + cryptoType = metaAccount.substrateCryptoType, + derivationPath = exportSecretsInteractor.getDerivationPath(metaAccount, ethereum = false), + showCryptoType = seed != null + ) + } + + private suspend fun MutableList.addEthereumAdditionalSecrets(metaAccount: MetaAccount) { + val privateKey = exportSecretsInteractor.getMetaAccountEthereumPrivateKey(metaAccount) + + this += createAdditionalSecretsInternal( + networkName = resourceManager.getString(R.string.common_network_ethereum), + isEthereumBased = true, + privateKey = privateKey, + cryptoType = CryptoType.ECDSA, + derivationPath = exportSecretsInteractor.getDerivationPath(metaAccount, ethereum = true), + showCryptoType = privateKey != null + ) + } + + private suspend fun createAdditionalSecretsInternal( + networkName: String, + isEthereumBased: Boolean, + privateKey: String?, + cryptoType: CryptoType?, // We are waiting cryptoType is not null in this case but handle it since metaAccount.substrateCryptoType can be null + derivationPath: String?, + showCryptoType: Boolean + ) = buildList { + add(createTitle(networkName)) + + if (privateKey != null) { + val label = if (isEthereumBased) { + resourceManager.getString(R.string.account_private_key) + } else { + resourceManager.getString(R.string.recovery_raw_seed) + } + add(ManualBackupSeedRvItem(label = label, seed = privateKey, isShown = false)) + } + + if (!isEthereumBased) { + add(ManualBackupJsonRvItem()) + } + + if (cryptoType != null && showCryptoType) { + val cryptoTypeItem = ManualBackupCryptoTypeRvItem( + network = networkName, + cryptoTypeTitle = mapCryptoTypeToCryptoTypeTitle(resourceManager, cryptoType), + cryptoTypeSubtitle = mapCryptoTypeToCryptoTypeSubtitle(resourceManager, cryptoType), + derivationPath = derivationPath, + hideDerivationPath = derivationPath.isNullOrEmpty() + ) + + add(cryptoTypeItem) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/ManualBackupItemHandler.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/ManualBackupItemHandler.kt new file mode 100644 index 0000000..bed35b4 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/ManualBackupItemHandler.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter + +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupJsonRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsVisibilityRvItem + +interface ManualBackupItemHandler { + + fun onExportJsonClick(item: ManualBackupJsonRvItem) + + fun onTapToRevealClicked(item: ManualBackupSecretsVisibilityRvItem) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/ManualBackupSecretsAdapter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/ManualBackupSecretsAdapter.kt new file mode 100644 index 0000000..c7f22c3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/ManualBackupSecretsAdapter.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter + +import android.annotation.SuppressLint +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupChainBinding +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupCryptoTypeBinding +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupJsonBinding +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupMnemonicBinding +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupSeedBinding +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupSubtitleBinding +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupTitleBinding +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupChainRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupChainViewHolder +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupCryptoTypeRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupCryptoTypeViewHolder +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupJsonRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupJsonViewHolder +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupMnemonicRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupMnemonicViewHolder +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupSeedRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupSeedViewHolder +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupSubtitleRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupSubtitleViewHolder +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupTitleRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupTitleViewHolder +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsViewHolder + +private const val TITLE_VIEW_TYPE = 0 +private const val SUBTITLE_VIEW_TYPE = 1 +private const val CHAIN_VIEW_TYPE = 2 +private const val MNEMONIC_VIEW_TYPE = 3 +private const val SEED_VIEW_TYPE = 4 +private const val JSON_VIEW_TYPE = 5 +private const val CRYPTO_TYPE_VIEW_TYPE = 6 + +class ManualBackupSecretsAdapter( + private val itemHandler: ManualBackupItemHandler +) : ListAdapter(ManualBackupDiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ManualBackupSecretsViewHolder { + return when (viewType) { + TITLE_VIEW_TYPE -> ManualBackupTitleViewHolder(ItemManualBackupTitleBinding.inflate(parent.inflater(), parent, false)) + + SUBTITLE_VIEW_TYPE -> ManualBackupSubtitleViewHolder(ItemManualBackupSubtitleBinding.inflate(parent.inflater(), parent, false)) + + CHAIN_VIEW_TYPE -> ManualBackupChainViewHolder( + ItemManualBackupChainBinding.inflate(parent.inflater(), parent, false) + ) + + MNEMONIC_VIEW_TYPE -> ManualBackupMnemonicViewHolder( + ItemManualBackupMnemonicBinding.inflate(parent.inflater(), parent, false), + itemHandler + ) + + SEED_VIEW_TYPE -> ManualBackupSeedViewHolder( + ItemManualBackupSeedBinding.inflate(parent.inflater(), parent, false), + itemHandler + ) + + JSON_VIEW_TYPE -> ManualBackupJsonViewHolder( + ItemManualBackupJsonBinding.inflate(parent.inflater(), parent, false), + itemHandler + ) + + CRYPTO_TYPE_VIEW_TYPE -> ManualBackupCryptoTypeViewHolder(ItemManualBackupCryptoTypeBinding.inflate(parent.inflater(), parent, false)) + + else -> throw IllegalArgumentException("Unknown view type") + } + } + + override fun onBindViewHolder(viewHolder: ManualBackupSecretsViewHolder, position: Int) { + viewHolder.bind(getItem(position)) + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is ManualBackupTitleRvItem -> TITLE_VIEW_TYPE + is ManualBackupSubtitleRvItem -> SUBTITLE_VIEW_TYPE + is ManualBackupChainRvItem -> CHAIN_VIEW_TYPE + is ManualBackupMnemonicRvItem -> MNEMONIC_VIEW_TYPE + is ManualBackupSeedRvItem -> SEED_VIEW_TYPE + is ManualBackupJsonRvItem -> JSON_VIEW_TYPE + is ManualBackupCryptoTypeRvItem -> CRYPTO_TYPE_VIEW_TYPE + else -> throw IllegalArgumentException("Unknown item type") + } + } +} + +object ManualBackupDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ManualBackupSecretsRvItem, newItem: ManualBackupSecretsRvItem): Boolean { + return oldItem.isItemTheSame(newItem) + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: ManualBackupSecretsRvItem, newItem: ManualBackupSecretsRvItem): Boolean { + return oldItem == newItem + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupChainItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupChainItem.kt new file mode 100644 index 0000000..a732c3d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupChainItem.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders + +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupChainBinding +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsViewHolder + +data class ManualBackupChainRvItem( + val chainModel: ChainUi +) : ManualBackupSecretsRvItem { + + override fun isItemTheSame(other: ManualBackupSecretsRvItem): Boolean { + return other is ManualBackupChainRvItem + } +} + +class ManualBackupChainViewHolder(private val binder: ItemManualBackupChainBinding) : ManualBackupSecretsViewHolder(binder.root) { + + override fun bind(item: ManualBackupSecretsRvItem) { + require(item is ManualBackupChainRvItem) + binder.manualBackupSecretsChain.setChain(item.chainModel) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupCryptoTypeItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupCryptoTypeItem.kt new file mode 100644 index 0000000..27bd9e8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupCryptoTypeItem.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders + +import androidx.core.view.isGone +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupCryptoTypeBinding +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsViewHolder + +data class ManualBackupCryptoTypeRvItem( + val network: String, + val cryptoTypeTitle: String, + val cryptoTypeSubtitle: String, + val derivationPath: String?, + val hideDerivationPath: Boolean +) : ManualBackupSecretsRvItem { + + override fun isItemTheSame(other: ManualBackupSecretsRvItem): Boolean { + return other is ManualBackupCryptoTypeRvItem && network == other.network + } +} + +class ManualBackupCryptoTypeViewHolder(private val binder: ItemManualBackupCryptoTypeBinding) : ManualBackupSecretsViewHolder(binder.root) { + + override fun bind(item: ManualBackupSecretsRvItem) { + require(item is ManualBackupCryptoTypeRvItem) + binder.manualBackupSecretsCryptoType.setLabel(item.cryptoTypeTitle) + binder.manualBackupSecretsCryptoType.setMessage(item.cryptoTypeSubtitle) + binder.manualBackupSecretsDerivationPath.setMessage(item.derivationPath) + binder.manualBackupSecretsDerivationPathLabel.isGone = item.hideDerivationPath + binder.manualBackupSecretsDerivationPath.isGone = item.hideDerivationPath + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupJsonViewItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupJsonViewItem.kt new file mode 100644 index 0000000..ab5e97e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupJsonViewItem.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders + +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupJsonBinding +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.ManualBackupItemHandler +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsViewHolder + +class ManualBackupJsonRvItem : ManualBackupSecretsRvItem { + + override fun isItemTheSame(other: ManualBackupSecretsRvItem): Boolean { + return other is ManualBackupJsonRvItem + } + + override fun equals(other: Any?): Boolean { + return other is ManualBackupJsonRvItem + } + + override fun hashCode(): Int { + return javaClass.hashCode() + } +} + +class ManualBackupJsonViewHolder(private val binder: ItemManualBackupJsonBinding, private val itemHandler: ManualBackupItemHandler) : + ManualBackupSecretsViewHolder(binder.root) { + + override fun bind(item: ManualBackupSecretsRvItem) { + require(item is ManualBackupJsonRvItem) + binder.manualBackupSecretsJsonButton.setOnClickListener { itemHandler.onExportJsonClick(item) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupMnemonicItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupMnemonicItem.kt new file mode 100644 index 0000000..36a84e3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupMnemonicItem.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders + +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupMnemonicBinding +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.ManualBackupItemHandler +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsViewHolder +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsVisibilityRvItem + +data class ManualBackupMnemonicRvItem( + val mnemonic: List, + override var isShown: Boolean +) : ManualBackupSecretsVisibilityRvItem() { + + override fun isItemTheSame(other: ManualBackupSecretsRvItem): Boolean { + return other is ManualBackupMnemonicRvItem + } +} + +class ManualBackupMnemonicViewHolder(private val binder: ItemManualBackupMnemonicBinding, private val itemHandler: ManualBackupItemHandler) : + ManualBackupSecretsViewHolder(binder.root) { + + override fun bind(item: ManualBackupSecretsRvItem) { + require(item is ManualBackupMnemonicRvItem) + + binder.manualBackupSecretsMnemonic.setWordsString(item.mnemonic) + binder.manualBackupSecretsMnemonic.showContent(item.isShown) + binder.manualBackupSecretsMnemonic.onContentShownListener { itemHandler.onTapToRevealClicked(item) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupSeedItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupSeedItem.kt new file mode 100644 index 0000000..2a8dac9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupSeedItem.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders + +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupSeedBinding +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.ManualBackupItemHandler +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsViewHolder +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsVisibilityRvItem + +data class ManualBackupSeedRvItem( + val label: String, + val seed: String, + override var isShown: Boolean +) : ManualBackupSecretsVisibilityRvItem() { + + override fun isItemTheSame(other: ManualBackupSecretsRvItem): Boolean { + return other is ManualBackupSeedRvItem + } +} + +class ManualBackupSeedViewHolder(private val binder: ItemManualBackupSeedBinding, private val itemHandler: ManualBackupItemHandler) : + ManualBackupSecretsViewHolder(binder.root) { + + override fun bind(item: ManualBackupSecretsRvItem) { + require(item is ManualBackupSeedRvItem) + binder.manualBackupSecretsSeedLabel.text = item.label + binder.manualBackupSecretsSeedContainer.showContent(item.isShown) + binder.manualBackupSecretsSeedText.text = item.seed + + binder.manualBackupSecretsSeedContainer.onContentShownListener { itemHandler.onTapToRevealClicked(item) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupSubtitleItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupSubtitleItem.kt new file mode 100644 index 0000000..5e2722b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupSubtitleItem.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders + +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupSubtitleBinding +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsViewHolder + +data class ManualBackupSubtitleRvItem( + val text: String +) : ManualBackupSecretsRvItem { + + override fun isItemTheSame(other: ManualBackupSecretsRvItem): Boolean { + return other is ManualBackupSubtitleRvItem && text == other.text + } +} + +class ManualBackupSubtitleViewHolder(private val binder: ItemManualBackupSubtitleBinding) : ManualBackupSecretsViewHolder(binder.root) { + + override fun bind(item: ManualBackupSecretsRvItem) { + require(item is ManualBackupSubtitleRvItem) + binder.manualBackupSecretsSubtitle.text = item.text + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupTitleItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupTitleItem.kt new file mode 100644 index 0000000..c75fcfe --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/ManualBackupTitleItem.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders + +import android.graphics.Rect +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.decoration.ExtraSpaceViewHolder +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.feature_account_impl.databinding.ItemManualBackupTitleBinding +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsViewHolder + +data class ManualBackupTitleRvItem( + val title: String +) : ManualBackupSecretsRvItem { + + override fun isItemTheSame(other: ManualBackupSecretsRvItem): Boolean { + return other is ManualBackupTitleRvItem && title == other.title + } +} + +class ManualBackupTitleViewHolder(private val binder: ItemManualBackupTitleBinding) : ManualBackupSecretsViewHolder(binder.root), ExtraSpaceViewHolder { + + override fun bind(item: ManualBackupSecretsRvItem) { + require(item is ManualBackupTitleRvItem) + binder.manualBackupSecretsTitle.text = item.title + } + + override fun getExtraSpace(topViewHolder: RecyclerView.ViewHolder?, bottomViewHolder: RecyclerView.ViewHolder?): Rect? { + if (topViewHolder == null || topViewHolder is ManualBackupChainViewHolder) { + return null + } + + return Rect(0, 16.dp(itemView.context), 0, 0) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/models/ManualBackupSecretsRvItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/models/ManualBackupSecretsRvItem.kt new file mode 100644 index 0000000..6621447 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/models/ManualBackupSecretsRvItem.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models + +interface ManualBackupSecretsRvItem { + + fun isItemTheSame(other: ManualBackupSecretsRvItem): Boolean +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/models/ManualBackupSecretsViewHolder.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/models/ManualBackupSecretsViewHolder.kt new file mode 100644 index 0000000..05f4d2e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/models/ManualBackupSecretsViewHolder.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +abstract class ManualBackupSecretsViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + abstract fun bind(item: ManualBackupSecretsRvItem) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/models/ManualBackupSecretsVisibilityRvItem.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/models/ManualBackupSecretsVisibilityRvItem.kt new file mode 100644 index 0000000..4dee2b6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/common/adapter/viewHolders/models/ManualBackupSecretsVisibilityRvItem.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models + +abstract class ManualBackupSecretsVisibilityRvItem : ManualBackupSecretsRvItem { + + abstract var isShown: Boolean + protected set + + fun makeShown() { + isShown = true + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/ManualBackupSecretsFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/ManualBackupSecretsFragment.kt new file mode 100644 index 0000000..7668f25 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/ManualBackupSecretsFragment.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.main + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.decoration.ExtraSpaceItemDecoration +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentManualBackupSecretsBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.ManualBackupItemHandler +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.ManualBackupSecretsAdapter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.ManualBackupJsonRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsVisibilityRvItem + +class ManualBackupSecretsFragment : BaseFragment(), ManualBackupItemHandler { + + companion object { + + private const val KEY_PAYLOAD = "payload" + + fun bundle(payload: ManualBackupCommonPayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentManualBackupSecretsBinding.inflate(layoutInflater) + + private val adapter = ManualBackupSecretsAdapter(this) + + override fun initViews() { + binder.manualBackupSecretsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.manualBackupSecretsToolbar.setRightActionClickListener { viewModel.advancedSecretsClicked() } + + binder.manualBackupSecretsList.adapter = adapter + binder.manualBackupSecretsList.addItemDecoration(ExtraSpaceItemDecoration()) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .manualBackupSecrets() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ManualBackupSecretsViewModel) { + viewModel.walletModel.observe { + binder.manualBackupSecretsToolbar.setTitleIcon(it.icon) + binder.manualBackupSecretsToolbar.setTitle(it.name) + } + + viewModel.advancedSecretsBtnAvailable.observe { available -> + if (available) { + binder.manualBackupSecretsToolbar.setRightIconRes(R.drawable.ic_options) + } else { + binder.manualBackupSecretsToolbar.hideRightAction() + } + } + + viewModel.exportList.observe { + adapter.submitList(it) + } + } + + override fun onExportJsonClick(item: ManualBackupJsonRvItem) { + viewModel.exportJsonClicked() + } + + override fun onTapToRevealClicked(item: ManualBackupSecretsVisibilityRvItem) { + viewModel.onTapToRevealClicked(item) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/ManualBackupSecretsViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/ManualBackupSecretsViewModel.kt new file mode 100644 index 0000000..45183a1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/ManualBackupSecretsViewModel.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.main + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.walletUiFlowFor +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.export.CommonExportSecretsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.ManualBackupSecretsAdapterItemFactory +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.getChainIdOrNull +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.toExportPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsRvItem +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.adapter.viewHolders.models.ManualBackupSecretsVisibilityRvItem + +class ManualBackupSecretsViewModel( + private val resourceManager: ResourceManager, + private val router: AccountRouter, + private val payload: ManualBackupCommonPayload, + private val accountInteractor: AccountInteractor, + private val commonExportSecretsInteractor: CommonExportSecretsInteractor, + private val secretsAdapterItemFactory: ManualBackupSecretsAdapterItemFactory, + private val walletUiUseCase: WalletUiUseCase +) : BaseViewModel() { + + val walletModel = walletUiUseCase.walletUiFlowFor(payload.metaId, payload.getChainIdOrNull(), showAddressIcon = true) + + val advancedSecretsBtnAvailable = flowOf { commonExportSecretsInteractor.hasMnemonic(payload.metaId, payload.getChainIdOrNull()) } + .shareInBackground() + + val exportList = flowOf { buildSecrets() } + .shareInBackground() + + fun onTapToRevealClicked(item: ManualBackupSecretsVisibilityRvItem) { + // It's not necessary to update the list, because the item will play a show animation. We just need to update its state + item.makeShown() + } + + fun advancedSecretsClicked() { + router.openManualBackupAdvancedSecrets(payload) + } + + fun exportJsonClicked() { + router.exportJsonAction(payload.toExportPayload()) + } + + fun backClicked() { + router.back() + } + + private suspend fun buildSecrets(): List = buildList { + createHeaders() + + val mnemonicItem = secretsAdapterItemFactory.createMnemonic(payload.metaId, payload.getChainIdOrNull()) + if (mnemonicItem == null) { + this += secretsAdapterItemFactory.createSubtitle(resourceManager.getString(R.string.manual_backup_secrets_subtitle)) + this += secretsAdapterItemFactory.createAdvancedSecrets(payload.metaId, payload.getChainIdOrNull()) + } else { + this += mnemonicItem + } + } + + private suspend fun MutableList.createHeaders() { + if (payload is ManualBackupCommonPayload.ChainAccount) { + this += secretsAdapterItemFactory.createChainItem(payload.chainId) + this += secretsAdapterItemFactory.createTitle(resourceManager.getString(R.string.manual_backup_secrets_custom_key_title)) + } else { + val hasChainAccounts = accountInteractor.hasCustomChainAccounts(payload.metaId) + if (hasChainAccounts) { + this += secretsAdapterItemFactory.createTitle(resourceManager.getString(R.string.manual_backup_secrets_default_key_title)) + } else { + this += secretsAdapterItemFactory.createTitle(resourceManager.getString(R.string.common_passphrase)) + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/di/ManualBackupSecretsComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/di/ManualBackupSecretsComponent.kt new file mode 100644 index 0000000..a006f55 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/di/ManualBackupSecretsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.main.ManualBackupSecretsFragment + +@Subcomponent( + modules = [ + ManualBackupSecretsModule::class + ] +) +@ScreenScope +interface ManualBackupSecretsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ManualBackupCommonPayload + ): ManualBackupSecretsComponent + } + + fun inject(fragment: ManualBackupSecretsFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/di/ManualBackupSecretsModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/di/ManualBackupSecretsModule.kt new file mode 100644 index 0000000..3d3c2cb --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/secrets/main/di/ManualBackupSecretsModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_impl.domain.account.export.CommonExportSecretsInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.common.ManualBackupSecretsAdapterItemFactory +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.secrets.main.ManualBackupSecretsViewModel + +@Module(includes = [ViewModelModule::class]) +class ManualBackupSecretsModule { + + @Provides + @IntoMap + @ViewModelKey(ManualBackupSecretsViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: AccountRouter, + payload: ManualBackupCommonPayload, + accountInteractor: AccountInteractor, + commonExportSecretsInteractor: CommonExportSecretsInteractor, + secretsAdapterItemFactory: ManualBackupSecretsAdapterItemFactory, + walletUiUseCase: WalletUiUseCase + ): ViewModel { + return ManualBackupSecretsViewModel( + resourceManager, + router, + payload, + accountInteractor, + commonExportSecretsInteractor, + secretsAdapterItemFactory, + walletUiUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ManualBackupSecretsViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(ManualBackupSecretsViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/ManualBackupSelectWalletFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/ManualBackupSelectWalletFragment.kt new file mode 100644 index 0000000..70dd360 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/ManualBackupSelectWalletFragment.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.wallets + +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.recyclerview.adapter.text.TextAdapter +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentManualBackupSelectWalletBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import javax.inject.Inject + +class ManualBackupSelectWalletFragment : + BaseFragment(), + AccountHolder.AccountItemHandler { + + override fun createBinding() = FragmentManualBackupSelectWalletBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val headerAdapter by lazy { + TextAdapter( + requireContext().getString(R.string.manual_backup_select_wallet_header), + R.style.TextAppearance_NovaFoundation_Bold_Title3 + ) + } + + private val listAdapter by lazy { + ManualBackupAccountsAdapter( + imageLoader = imageLoader, + itemHandler = this + ) + } + + private val adapter by lazy { + ConcatAdapter( + headerAdapter, + listAdapter + ) + } + + override fun initViews() { + binder.manualBackupWalletsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.manualBackupWalletsList.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .manualBackupSelectWallet() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ManualBackupSelectWalletViewModel) { + viewModel.walletsUI.observe { + listAdapter.submitList(it) + } + } + + override fun itemClicked(accountModel: AccountUi) { + viewModel.walletClicked(accountModel) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/ManualBackupSelectWalletViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/ManualBackupSelectWalletViewModel.kt new file mode 100644 index 0000000..0d67aec --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/ManualBackupSelectWalletViewModel.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.wallets + +import io.novafoundation.nova.common.address.AddressIconGenerator.Companion.SIZE_BIG +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_impl.domain.manualBackup.ManualBackupSelectWalletInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import kotlinx.coroutines.launch + +class ManualBackupSelectWalletViewModel( + private val router: AccountRouter, + private val manualBackupSelectWalletInteractor: ManualBackupSelectWalletInteractor, + private val walletUiUseCase: WalletUiUseCase +) : BaseViewModel() { + + private val wallets = flowOf { manualBackupSelectWalletInteractor.getBackupableMetaAccounts() } + .shareInBackground() + + val walletsUI = wallets.mapList { mapMetaAccountToUI(it) } + + fun walletClicked(accountModel: AccountUi) { + launch { + val metaAccount = manualBackupSelectWalletInteractor.getMetaAccount(accountModel.id) + if (metaAccount.chainAccounts.isEmpty()) { + router.openManualBackupConditions(ManualBackupCommonPayload.DefaultAccount(metaId = accountModel.id)) + } else { + router.openManualBackupSelectAccount(accountModel.id) + } + } + } + + private suspend fun mapMetaAccountToUI(metaAccount: MetaAccount): AccountUi { + return AccountUi( + id = metaAccount.id, + title = metaAccount.name, + subtitle = null, + isSelected = false, + isClickable = true, + picture = walletUiUseCase.walletIcon(metaAccount, iconSize = SIZE_BIG), + chainIcon = null, + updateIndicator = false, + subtitleIconRes = null, + chainIconOpacity = 1f, + isEditable = false + ) + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/ManualBackupWalletsAdapter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/ManualBackupWalletsAdapter.kt new file mode 100644 index 0000000..e8dc779 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/ManualBackupWalletsAdapter.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.wallets + +import coil.ImageLoader +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountDiffCallback +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.AccountGroupRvItem +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.CommonAccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_impl.R + +class ManualBackupAccountsAdapter( + private val imageLoader: ImageLoader, + private val itemHandler: AccountHolder.AccountItemHandler +) : CommonAccountsAdapter( + accountItemHandler = itemHandler, + imageLoader = imageLoader, + diffCallback = AccountDiffCallback(EmptyGroupRVItem::class.java), + groupFactory = { throw IllegalStateException("No groups in this adapter") }, + groupBinder = { _, _ -> }, + chainBorderColor = R.color.bottom_sheet_background, + initialMode = AccountHolder.Mode.SELECT +) + +class EmptyGroupRVItem : AccountGroupRvItem { + override fun isItemTheSame(other: AccountGroupRvItem): Boolean { + return false + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/di/ManualBackupSelectWalletComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/di/ManualBackupSelectWalletComponent.kt new file mode 100644 index 0000000..bd41273 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/di/ManualBackupSelectWalletComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.wallets.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.wallets.ManualBackupSelectWalletFragment + +@Subcomponent( + modules = [ + ManualBackupSelectWalletModule::class + ] +) +@ScreenScope +interface ManualBackupSelectWalletComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): ManualBackupSelectWalletComponent + } + + fun inject(fragment: ManualBackupSelectWalletFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/di/ManualBackupSelectWalletModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/di/ManualBackupSelectWalletModule.kt new file mode 100644 index 0000000..b7c07d5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/wallets/di/ManualBackupSelectWalletModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.wallets.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_impl.domain.manualBackup.ManualBackupSelectWalletInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.wallets.ManualBackupSelectWalletViewModel + +@Module(includes = [ViewModelModule::class]) +class ManualBackupSelectWalletModule { + + @Provides + @IntoMap + @ViewModelKey(ManualBackupSelectWalletViewModel::class) + fun provideViewModel( + router: AccountRouter, + manualBackupSelectWalletInteractor: ManualBackupSelectWalletInteractor, + walletUiUseCase: WalletUiUseCase + ): ViewModel { + return ManualBackupSelectWalletViewModel( + router, + manualBackupSelectWalletInteractor, + walletUiUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ManualBackupSelectWalletViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(ManualBackupSelectWalletViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/ManualBackupWarningFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/ManualBackupWarningFragment.kt new file mode 100644 index 0000000..2d7a3b5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/ManualBackupWarningFragment.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.warning + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.condition.setupConditions +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentManualBackupWarningBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload + +class ManualBackupWarningFragment : BaseFragment() { + + override fun createBinding() = FragmentManualBackupWarningBinding.inflate(layoutInflater) + + companion object { + + private const val KEY_PAYLOAD = "payload" + + fun bundle(payload: ManualBackupCommonPayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun initViews() { + binder.manualBackupWarningToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.manualBackupWarningButtonContinue.setOnClickListener { viewModel.continueClicked() } + + buildConditions() + } + + private fun buildConditions() { + binder.manualBackupWarningCondition1.setText( + buildCondition(R.string.backup_secrets_warning_condition_1, R.string.backup_secrets_warning_condition_1_highlight) + ) + binder.manualBackupWarningCondition2.setText( + buildCondition(R.string.backup_secrets_warning_condition_2, R.string.backup_secrets_warning_condition_2_highlight) + ) + binder.manualBackupWarningCondition3.setText( + buildCondition(R.string.backup_secrets_warning_condition_3, R.string.backup_secrets_warning_condition_3_highlight) + ) + + viewModel.conditionMixin.setupConditions( + binder.manualBackupWarningCondition1, + binder.manualBackupWarningCondition2, + binder.manualBackupWarningCondition3 + ) + } + + private fun buildCondition(termBaseResId: Int, termHighlightResId: Int): CharSequence { + return SpannableFormatter.format( + getString(termBaseResId), + getString(termHighlightResId) + .toSpannable(colorSpan(requireContext().getColor(R.color.text_primary))) + ) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .manualBackupWarning() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ManualBackupWarningViewModel) { + viewModel.buttonState.observe { + binder.manualBackupWarningButtonContinue.setState(it) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/ManualBackupWarningViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/ManualBackupWarningViewModel.kt new file mode 100644 index 0000000..564f6af --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/ManualBackupWarningViewModel.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.warning + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.condition.ConditionMixinFactory +import io.novafoundation.nova.common.mixin.condition.buttonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload + +class ManualBackupWarningViewModel( + private val resourceManager: ResourceManager, + private val router: AccountRouter, + private val conditionMixinFactory: ConditionMixinFactory, + private val payload: ManualBackupCommonPayload +) : BaseViewModel() { + + val conditionMixin = conditionMixinFactory.createConditionMixin( + coroutineScope = viewModelScope, + conditionsCount = 3 + ) + + val buttonState = conditionMixin.buttonState( + enabledState = resourceManager.getString(R.string.common_confirm), + disabledState = resourceManager.getString(R.string.backup_secrets_warning_disabled_button) + ).shareInBackground() + + fun continueClicked() { + router.openManualBackupSecrets(payload) + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/di/ManualBackupWarningComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/di/ManualBackupWarningComponent.kt new file mode 100644 index 0000000..2e7377d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/di/ManualBackupWarningComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.warning.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.warning.ManualBackupWarningFragment + +@Subcomponent( + modules = [ + ManualBackupWarningModule::class + ] +) +@ScreenScope +interface ManualBackupWarningComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ManualBackupCommonPayload + ): ManualBackupWarningComponent + } + + fun inject(fragment: ManualBackupWarningFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/di/ManualBackupWarningModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/di/ManualBackupWarningModule.kt new file mode 100644 index 0000000..2307d59 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/manualBackup/warning/di/ManualBackupWarningModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.presentation.manualBackup.warning.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.condition.ConditionMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.common.ManualBackupCommonPayload +import io.novafoundation.nova.feature_account_impl.presentation.manualBackup.warning.ManualBackupWarningViewModel + +@Module(includes = [ViewModelModule::class]) +class ManualBackupWarningModule { + + @Provides + @IntoMap + @ViewModelKey(ManualBackupWarningViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: AccountRouter, + conditionMixinFactory: ConditionMixinFactory, + payload: ManualBackupCommonPayload + ): ViewModel { + return ManualBackupWarningViewModel( + resourceManager, + router, + conditionMixinFactory, + payload + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ManualBackupWarningViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(ManualBackupWarningViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/identity/RealIdentityMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/identity/RealIdentityMixin.kt new file mode 100644 index 0000000..d5120a8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/identity/RealIdentityMixin.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mixin.identity + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityModel +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.from +import kotlinx.coroutines.flow.MutableStateFlow + +class RealIdentityMixinFactory( + private val appLinksProvider: AppLinksProvider, +) : IdentityMixin.Factory { + + override fun create(): IdentityMixin.Presentation { + return RealIdentityMixin(appLinksProvider) + } +} + +private class RealIdentityMixin( + private val appLinksProvider: AppLinksProvider, +) : IdentityMixin.Presentation { + + override val openBrowserEvent = MutableLiveData>() + override val openEmailEvent = MutableLiveData>() + + override val identityFlow = MutableStateFlow(null) + + override fun setIdentity(identity: IdentityModel?) { + identityFlow.value = identity + } + + override fun setIdentity(identity: OnChainIdentity?) { + val identityModel = identity?.run(IdentityModel.Companion::from) + + setIdentity(identityModel) + } + + override fun emailClicked() = useIdentityField(IdentityModel::email) { + openEmailEvent.value = it.event() + } + + override fun twitterClicked() = useIdentityField(IdentityModel::twitter) { + val link = appLinksProvider.getTwitterAccountUrl(it) + + openBrowserEvent.value = link.event() + } + + override fun webClicked() = useIdentityField(IdentityModel::web) { + openBrowserEvent.value = it.event() + } + + private fun useIdentityField( + field: (IdentityModel) -> R?, + action: (R) -> Unit + ) { + identityFlow.value?.let(field)?.let(action) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/RealSelectWalletMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/RealSelectWalletMixin.kt new file mode 100644 index 0000000..50626da --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/RealSelectWalletMixin.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mixin.selectWallet + +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccountListingItem +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin.Factory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin.InitialSelection +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin.SelectionParams +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletRequester +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectedWalletModel +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +internal class RealRealSelectWalletMixinFactory( + private val accountRepository: AccountRepository, + private val accountGroupingInteractor: MetaAccountGroupingInteractor, + private val walletUiUseCase: WalletUiUseCase, + private val requester: SelectWalletRequester, +) : Factory { + + override fun create( + coroutineScope: CoroutineScope, + selectionParams: suspend () -> SelectionParams + ): SelectWalletMixin { + return RealSelectWalletMixin( + coroutineScope = coroutineScope, + accountRepository = accountRepository, + accountGroupingInteractor = accountGroupingInteractor, + walletUiUseCase = walletUiUseCase, + requester = requester, + selectionParamsAsync = selectionParams + ) + } +} + +internal class RealSelectWalletMixin( + coroutineScope: CoroutineScope, + private val accountRepository: AccountRepository, + private val accountGroupingInteractor: MetaAccountGroupingInteractor, + private val walletUiUseCase: WalletUiUseCase, + private val requester: SelectWalletRequester, + private val selectionParamsAsync: suspend () -> SelectionParams +) : SelectWalletMixin, + CoroutineScope by coroutineScope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + private val selectionParams = flowOf { selectionParamsAsync() } + .shareInBackground() + + private val selectableMetaId: Flow = requester.responseFlow + .map { it.newMetaId } + + private val selectedMetaId = selectionParams.flatMapLatest { selectionParams -> + selectionParams.metaIdChanges() + .onStart { emit(selectionParams.initialMetaId()) } + } + .shareInBackground() + + private fun nonSelectableMetaId(): Flow = emptyFlow() + + private val metaAccountWithBalanceFlow = selectedMetaId.flatMapLatest { metaId -> + accountGroupingInteractor.metaAccountWithTotalBalanceFlow(metaId) + }.shareInBackground() + + override val selectedMetaAccountFlow: Flow = metaAccountWithBalanceFlow + .map { it.metaAccount } + .shareInBackground() + + override val selectedWalletModelFlow: Flow = combine( + metaAccountWithBalanceFlow, + selectionParams, + ::mapSelectedMetaAccountToUi + ) + .shareInBackground() + + override fun walletSelectorClicked() { + launch { + val currentMetaId = selectedMetaId.first() + requester.openRequest(SelectWalletCommunicator.Payload(currentMetaId)) + } + } + + private suspend fun mapSelectedMetaAccountToUi( + metaAccountListingItem: MetaAccountListingItem, + selectionParams: SelectionParams + ): SelectedWalletModel { + return with(metaAccountListingItem) { + SelectedWalletModel( + title = metaAccount.name, + subtitle = totalBalance.formatAsCurrency(currency), + icon = walletUiUseCase.walletIcon(metaAccount), + selectionAllowed = selectionParams.selectionAllowed + ) + } + } + + private suspend fun SelectionParams.initialMetaId(): Long = when (val selection = initialSelection) { + InitialSelection.ActiveWallet -> accountRepository.getSelectedMetaAccount().id + is InitialSelection.SpecificWallet -> selection.metaId + } + + private fun SelectionParams.metaIdChanges() = if (selectionAllowed) { + selectableMetaId + } else { + nonSelectableMetaId() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/SelectWalletFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/SelectWalletFragment.kt new file mode 100644 index 0000000..2c8e866 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/SelectWalletFragment.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mixin.selectWallet + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.account.list.WalletListFragment + +class SelectWalletFragment : WalletListFragment() { + + override fun initViews() { + super.initViews() + setTitleRes(R.string.account_select_wallet) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .selectWalletComponentFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/SelectWalletViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/SelectWalletViewModel.kt new file mode 100644 index 0000000..ca48d36 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/SelectWalletViewModel.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mixin.selectWallet + +import io.novafoundation.nova.common.navigation.requireLastInput +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.items.AccountUi +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountHolder +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletResponder +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountWithBalanceListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.SelectedMetaAccountState +import io.novafoundation.nova.feature_account_impl.presentation.account.list.WalletListViewModel + +class SelectWalletViewModel( + private val router: AccountRouter, + private val responder: SelectWalletResponder, + accountListingMixinFactory: MetaAccountWithBalanceListingMixinFactory, +) : WalletListViewModel() { + + private val currentSelectedIdFlow = flowOf { + val selectedMetaId = responder.requireLastInput().currentMetaId + SelectedMetaAccountState.Specified(setOf(selectedMetaId)) + } + + override val walletsListingMixin = accountListingMixinFactory.create( + coroutineScope = this, + metaAccountSelectedFlow = currentSelectedIdFlow + ) + + override val mode: AccountHolder.Mode = AccountHolder.Mode.SWITCH + + override fun accountClicked(accountModel: AccountUi) { + responder.respond(SelectWalletCommunicator.Response(accountModel.id)) + router.back() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/di/SelectWalletComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/di/SelectWalletComponent.kt new file mode 100644 index 0000000..43e919d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/di/SelectWalletComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mixin.selectWallet.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.mixin.selectWallet.SelectWalletFragment + +@Subcomponent( + modules = [ + SelectWalletModule::class + ] +) +@ScreenScope +interface SelectWalletComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): SelectWalletComponent + } + + fun inject(fragment: SelectWalletFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/di/SelectWalletModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/di/SelectWalletModule.kt new file mode 100644 index 0000000..9ffc343 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mixin/selectWallet/di/SelectWalletModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mixin.selectWallet.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.common.listing.MetaAccountWithBalanceListingMixinFactory +import io.novafoundation.nova.feature_account_impl.presentation.mixin.selectWallet.SelectWalletViewModel + +@Module(includes = [ViewModelModule::class]) +class SelectWalletModule { + + @Provides + @IntoMap + @ViewModelKey(SelectWalletViewModel::class) + fun provideViewModel( + communicator: SelectWalletCommunicator, + router: AccountRouter, + accountListingMixinFactory: MetaAccountWithBalanceListingMixinFactory, + ): ViewModel { + return SelectWalletViewModel( + router = router, + accountListingMixinFactory = accountListingMixinFactory, + responder = communicator + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectWalletViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectWalletViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/BackupMnemonicFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/BackupMnemonicFragment.kt new file mode 100644 index 0000000..c6307d0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/BackupMnemonicFragment.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup + +import android.os.Bundle +import android.view.ContextThemeWrapper + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.condition.setupConditions +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.common.view.dialog.dialog +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentBackupMnemonicBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent + +class BackupMnemonicFragment : BaseFragment() { + + companion object { + + private const val KEY_ADD_ACCOUNT_PAYLOAD = "BackupMnemonicFragment.payload" + + fun getBundle(payload: BackupMnemonicPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_ADD_ACCOUNT_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentBackupMnemonicBinding.inflate(layoutInflater) + + override fun initViews() { + binder.backupMnemonicToolbar.setHomeButtonListener { viewModel.homeButtonClicked() } + binder.backupMnemonicToolbar.setRightActionClickListener { viewModel.optionsClicked() } + + buildConditions() + + binder.backupMnemonicContinue.setOnClickListener { viewModel.nextClicked() } + } + + private fun buildConditions() { + binder.backupMnemonicCondition1.setText( + buildCondition(R.string.backup_secrets_warning_condition_1, R.string.backup_secrets_warning_condition_1_highlight) + ) + binder.backupMnemonicCondition2.setText( + buildCondition(R.string.backup_secrets_warning_condition_2, R.string.backup_secrets_warning_condition_2_highlight) + ) + binder.backupMnemonicCondition3.setText( + buildCondition(R.string.backup_secrets_warning_condition_3, R.string.backup_secrets_warning_condition_3_highlight) + ) + + viewModel.conditionMixin.setupConditions( + binder.backupMnemonicCondition1, + binder.backupMnemonicCondition2, + binder.backupMnemonicCondition3 + ) + } + + private fun buildCondition(termBaseResId: Int, termHighlightResId: Int): CharSequence { + return SpannableFormatter.format( + getString(termBaseResId), + getString(termHighlightResId) + .toSpannable(colorSpan(requireContext().getColor(R.color.text_primary))) + ) + } + + override fun inject() { + FeatureUtils.getFeature(context!!, AccountFeatureApi::class.java) + .backupMnemonicComponentFactory() + .create( + fragment = this, + payload = argument(KEY_ADD_ACCOUNT_PAYLOAD) + ) + .inject(this) + } + + override fun subscribe(viewModel: BackupMnemonicViewModel) { + viewModel.buttonState.observe(binder.backupMnemonicContinue::setState) + + viewModel.showMnemonicWarningDialog.observeEvent { + showMnemonicWarning() + } + + viewModel.mnemonicDisplay.observe { + binder.backupMnemonicPassphrase.setWords(it) + } + } + + private fun showMnemonicWarning() = dialog(ContextThemeWrapper(requireContext(), R.style.AccentNegativeAlertDialogTheme)) { + setTitle(R.string.backup_mnemonic_attention_title) + setMessage(R.string.common_no_screenshot_message_v2_2_0) + + setPositiveButton(R.string.common_i_understand, null) + setNegativeButton(R.string.common_cancel) { _, _ -> viewModel.warningDeclined() } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/BackupMnemonicPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/BackupMnemonicPayload.kt new file mode 100644 index 0000000..3973fab --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/BackupMnemonicPayload.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +sealed class BackupMnemonicPayload : Parcelable { + + @Parcelize + class Create( + val newWalletName: String?, + val addAccountPayload: AddAccountPayload + ) : BackupMnemonicPayload() + + @Parcelize + class Confirm( + val chainId: ChainId, + val metaAccountId: Long + ) : BackupMnemonicPayload() +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt new file mode 100644 index 0000000..ad8073e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/BackupMnemonicViewModel.kt @@ -0,0 +1,115 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.condition.ConditionMixinFactory +import io.novafoundation.nova.common.mixin.condition.buttonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.sendEvent +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.common.model.toAdvancedEncryptionModel +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.export.mnemonic.ExportMnemonicInteractor +import io.novafoundation.nova.feature_account_impl.domain.common.AdvancedEncryptionSelectionStoreProvider +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.account.advancedEncryption.AdvancedEncryptionModePayload +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicPayload +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicPayload.CreateExtras +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.MnemonicWord +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class BackupMnemonicViewModel( + private val resourceManager: ResourceManager, + private val interactor: AccountInteractor, + private val exportMnemonicInteractor: ExportMnemonicInteractor, + private val router: AccountRouter, + private val payload: BackupMnemonicPayload, + private val advancedEncryptionInteractor: AdvancedEncryptionInteractor, + private val advancedEncryptionSelectionStoreProvider: AdvancedEncryptionSelectionStoreProvider, + private val conditionMixinFactory: ConditionMixinFactory, +) : BaseViewModel() { + + val conditionMixin = conditionMixinFactory.createConditionMixin( + coroutineScope = viewModelScope, + conditionsCount = 3 + ) + + val buttonState = conditionMixin.buttonState( + enabledState = resourceManager.getString(R.string.common_continue), + disabledState = resourceManager.getString(R.string.backup_secrets_warning_disabled_button) + ).shareInBackground() + + private val advancedEncryptionSelectionStore = async { + advancedEncryptionSelectionStoreProvider.getSelectionStore(coroutineScope) + } + + private val mnemonicFlow = flowOf { + when (payload) { + is BackupMnemonicPayload.Confirm -> exportMnemonicInteractor.getMnemonic(payload.metaAccountId, payload.chainId) + is BackupMnemonicPayload.Create -> interactor.generateMnemonic() + } + } + .inBackground() + .share() + + private val _showMnemonicWarningDialog = MutableLiveData>() + val showMnemonicWarningDialog: LiveData> = _showMnemonicWarningDialog + + val mnemonicDisplay = mnemonicFlow.map { mnemonic -> + mnemonic.wordList.mapIndexed { index, word -> + MnemonicWord(id = index, content = word, indexDisplay = index.plus(1).format(), removed = false) + } + }.shareInBackground() + + init { + _showMnemonicWarningDialog.sendEvent() + } + + fun homeButtonClicked() { + router.back() + } + + fun optionsClicked() { + val advancedEncryptionPayload = when (payload) { + is BackupMnemonicPayload.Confirm -> AdvancedEncryptionModePayload.View(payload.metaAccountId, payload.chainId) + is BackupMnemonicPayload.Create -> AdvancedEncryptionModePayload.Change(payload.addAccountPayload) + } + + router.openAdvancedSettings(advancedEncryptionPayload) + } + + fun warningDeclined() { + router.back() + } + + fun nextClicked() = launch { + val createExtras = (payload as? BackupMnemonicPayload.Create)?.let { + val advancedEncryption = advancedEncryptionSelectionStore().getCurrentSelection() + ?: advancedEncryptionInteractor.getRecommendedAdvancedEncryption() + + CreateExtras( + accountName = it.newWalletName, + addAccountPayload = it.addAccountPayload, + advancedEncryptionModel = advancedEncryption.toAdvancedEncryptionModel() + ) + } + + val payload = ConfirmMnemonicPayload( + mnemonic = mnemonicFlow.first().wordList, + createExtras = createExtras + ) + + router.openConfirmMnemonicOnCreate(payload) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/di/BackupMnemonicComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/di/BackupMnemonicComponent.kt new file mode 100644 index 0000000..46e5623 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/di/BackupMnemonicComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup.BackupMnemonicFragment +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup.BackupMnemonicPayload + +@Subcomponent( + modules = [ + BackupMnemonicModule::class + ] +) +@ScreenScope +interface BackupMnemonicComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: BackupMnemonicPayload, + ): BackupMnemonicComponent + } + + fun inject(backupMnemonicFragment: BackupMnemonicFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/di/BackupMnemonicModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/di/BackupMnemonicModule.kt new file mode 100644 index 0000000..4235904 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/backup/di/BackupMnemonicModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.condition.ConditionMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.advancedEncryption.AdvancedEncryptionInteractor +import io.novafoundation.nova.feature_account_impl.domain.account.export.mnemonic.ExportMnemonicInteractor +import io.novafoundation.nova.feature_account_impl.domain.common.AdvancedEncryptionSelectionStoreProvider +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup.BackupMnemonicPayload +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.backup.BackupMnemonicViewModel + +@Module(includes = [ViewModelModule::class]) +class BackupMnemonicModule { + + @Provides + @IntoMap + @ViewModelKey(BackupMnemonicViewModel::class) + fun provideViewModel( + interactor: AccountInteractor, + router: AccountRouter, + exportMnemonicInteractor: ExportMnemonicInteractor, + payload: BackupMnemonicPayload, + resourceManager: ResourceManager, + advancedEncryptionSelectionStoreProvider: AdvancedEncryptionSelectionStoreProvider, + advancedEncryptionInteractor: AdvancedEncryptionInteractor, + conditionMixinFactory: ConditionMixinFactory + ): ViewModel { + return BackupMnemonicViewModel( + resourceManager, + interactor, + exportMnemonicInteractor, + router, + payload, + advancedEncryptionInteractor, + advancedEncryptionSelectionStoreProvider, + conditionMixinFactory, + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): BackupMnemonicViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(BackupMnemonicViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicConfig.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicConfig.kt new file mode 100644 index 0000000..ec71dce --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicConfig.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm + +class ConfirmMnemonicConfig( + val allowShowingSkip: Boolean +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicFragment.kt new file mode 100644 index 0000000..8bce3d8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicFragment.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentConfirmMnemonicBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.common.mnemonic.BackupMnemonicAdapter + +class ConfirmMnemonicFragment : BaseFragment() { + + companion object { + private const val KEY_PAYLOAD = "confirm_payload" + + fun getBundle(payload: ConfirmMnemonicPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentConfirmMnemonicBinding.inflate(layoutInflater) + + private val sourceAdapter by lazy(LazyThreadSafetyMode.NONE) { + BackupMnemonicAdapter(itemHandler = viewModel::sourceWordClicked) + } + + override fun initViews() { + binder.confirmMnemonicToolbar.setHomeButtonListener { viewModel.homeButtonClicked() } + binder.confirmMnemonicToolbar.setRightActionClickListener { viewModel.reset() } + + binder.conformMnemonicSkip.setOnClickListener { viewModel.skipClicked() } + binder.conformMnemonicContinue.setOnClickListener { viewModel.continueClicked() } + + binder.confirmMnemonicSource.adapter = sourceAdapter + binder.confirmMnemonicDestination.setWordClickedListener(viewModel::destinationWordClicked) + } + + override fun inject() { + val payload = argument(KEY_PAYLOAD) + + FeatureUtils.getFeature(context!!, AccountFeatureApi::class.java) + .confirmMnemonicComponentFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmMnemonicViewModel) { + binder.conformMnemonicSkip.setVisible(viewModel.skipVisible) + + viewModel.sourceWords.observe { sourceAdapter.submitList(it) } + viewModel.destinationWords.observe { binder.confirmMnemonicDestination.setWords(it) } + + viewModel.nextButtonState.observe { + binder.conformMnemonicContinue.setState(it) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicPayload.kt new file mode 100644 index 0000000..1de771c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicPayload.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.common.model.AdvancedEncryptionModel +import kotlinx.parcelize.Parcelize + +@Parcelize +class ConfirmMnemonicPayload( + val mnemonic: List, + val createExtras: CreateExtras? +) : Parcelable { + @Parcelize + class CreateExtras( + val accountName: String?, + val addAccountPayload: AddAccountPayload, + val advancedEncryptionModel: AdvancedEncryptionModel + ) : Parcelable +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicViewModel.kt new file mode 100644 index 0000000..2bf48d2 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/ConfirmMnemonicViewModel.kt @@ -0,0 +1,175 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.added +import io.novafoundation.nova.common.utils.modified +import io.novafoundation.nova.common.utils.removed +import io.novafoundation.nova.common.vibration.DeviceVibrator +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.common.model.toAdvancedEncryption +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.mappers.mapAddAccountPayloadToAddAccountType +import io.novafoundation.nova.feature_account_impl.data.mappers.mapOptionalNameToNameChooserState +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.junction.JunctionDecoder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmMnemonicViewModel( + private val interactor: AccountInteractor, + private val addAccountInteractor: AddAccountInteractor, + private val router: AccountRouter, + private val deviceVibrator: DeviceVibrator, + private val resourceManager: ResourceManager, + private val config: ConfirmMnemonicConfig, + private val payload: ConfirmMnemonicPayload +) : BaseViewModel() { + + private val originMnemonic = payload.mnemonic + + private val shuffledMnemonic = originMnemonic.shuffled() + + private val _sourceWords = MutableStateFlow(initialSourceWords()) + val sourceWords: Flow> = _sourceWords + + private val _destinationWords = MutableStateFlow>(emptyList()) + val destinationWords: Flow> = _destinationWords + + val nextButtonState = destinationWords.map { + if (originMnemonic.size == it.size) { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } else { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.confirm_mnemonic_disabled_button_state)) + } + } + + val skipVisible = payload.createExtras != null && config.allowShowingSkip + + fun homeButtonClicked() { + router.back() + } + + fun sourceWordClicked(sourceWord: MnemonicWord) { + val markedAsRemoved = sourceWord.copy(removed = true) + + val destinationWordsSnapshot = _destinationWords.value + val destinationWord = sourceWord.copy( + indexDisplay = (destinationWordsSnapshot.size + 1).toString() + ) + + _sourceWords.value = _sourceWords.value.modified(markedAsRemoved, markedAsRemoved.byMyId()) + _destinationWords.value = destinationWordsSnapshot.added(destinationWord) + } + + fun destinationWordClicked(destinationWord: MnemonicWord) = launch(Dispatchers.Default) { + val sourceWord = _sourceWords.value.first { it.content == destinationWord.content } + val modifiedSourceWord = sourceWord.copy(removed = false) + + _sourceWords.value = _sourceWords.value.modified(modifiedSourceWord, modifiedSourceWord.byMyId()) + _destinationWords.value = _destinationWords.value.removed(destinationWord.byMyId()).fixIndices() + } + + fun reset() { + _destinationWords.value = emptyList() + _sourceWords.value = initialSourceWords() + } + + fun skipClicked() { + proceed() + } + + fun continueClicked() { + val mnemonicFromDestination = _destinationWords.value.map(MnemonicWord::content) + + if (mnemonicFromDestination == originMnemonic) { + proceed() + } else { + deviceVibrator.makeShortVibration() + showError( + resourceManager.getString(R.string.common_error_general_title), + resourceManager.getString(R.string.confirm_mnemonic_not_matching_error_message) + ) + } + } + + private fun List.fixIndices(): List { + return mapIndexed { index, word -> + word.copy(indexDisplay = (index + 1).toString()) + } + } + + private fun initialSourceWords(): List { + return shuffledMnemonic.mapIndexed { index, word -> + MnemonicWord( + id = index, + content = word, + indexDisplay = null, // source does not have indexing + removed = false + ) + } + } + + private fun proceed() { + val createExtras = payload.createExtras + + if (createExtras != null) { + createAccount(createExtras) + } else { + finishConfirmGame() + } + } + + private fun finishConfirmGame() { + router.back() + } + + private fun MnemonicWord.byMyId(): (MnemonicWord) -> Boolean = { it.id == id } + + private fun createAccount(extras: ConfirmMnemonicPayload.CreateExtras) { + viewModelScope.launch { + val mnemonicString = originMnemonic.joinToString(" ") + + with(extras) { + val accountNameState = mapOptionalNameToNameChooserState(accountName) + val addAccountType = mapAddAccountPayloadToAddAccountType(addAccountPayload, accountNameState) + val advancedEncryption = advancedEncryptionModel.toAdvancedEncryption() + + addAccountInteractor.createAccount(mnemonicString, advancedEncryption, addAccountType) + .onSuccess { continueBasedOnCodeStatus() } + .onFailure(::showAccountCreationError) + } + } + } + + private fun showAccountCreationError(throwable: Throwable) { + val (title, message) = when (throwable) { + is JunctionDecoder.DecodingError, is BIP32JunctionDecoder.DecodingError -> { + resourceManager.getString(R.string.account_invalid_derivation_path_title) to + resourceManager.getString(R.string.account_invalid_derivation_path_message_v2_2_0) + } + + else -> { + resourceManager.getString(R.string.common_error_general_title) to + resourceManager.getString(R.string.common_undefined_error_message) + } + } + + showError(title, message) + } + + private suspend fun continueBasedOnCodeStatus() { + if (interactor.isCodeSet()) { + router.openMain() + } else { + router.openCreatePincode() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/MnemonicWord.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/MnemonicWord.kt new file mode 100644 index 0000000..c3198c8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/MnemonicWord.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm + +data class MnemonicWord( + val id: Int, + val content: String, + val indexDisplay: String?, + val removed: Boolean +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/di/ConfirmMnemonicComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/di/ConfirmMnemonicComponent.kt new file mode 100644 index 0000000..575dd93 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/di/ConfirmMnemonicComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicFragment +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicPayload + +@Subcomponent( + modules = [ + ConfirmMnemonicModule::class + ] +) +@ScreenScope +interface ConfirmMnemonicComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmMnemonicPayload + ): ConfirmMnemonicComponent + } + + fun inject(confirmMnemonicFragment: ConfirmMnemonicFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/di/ConfirmMnemonicModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/di/ConfirmMnemonicModule.kt new file mode 100644 index 0000000..caa9b59 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/di/ConfirmMnemonicModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.vibration.DeviceVibrator +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.BuildConfig +import io.novafoundation.nova.feature_account_impl.domain.account.add.AddAccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicConfig +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicPayload +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.ConfirmMnemonicViewModel + +@Module(includes = [ViewModelModule::class]) +class ConfirmMnemonicModule { + + @Provides + @ScreenScope + fun provideConfig() = ConfirmMnemonicConfig( + allowShowingSkip = BuildConfig.DEBUG + ) + + @Provides + @IntoMap + @ViewModelKey(ConfirmMnemonicViewModel::class) + fun provideViewModel( + interactor: AccountInteractor, + addAccountInteractor: AddAccountInteractor, + router: AccountRouter, + deviceVibrator: DeviceVibrator, + resourceManager: ResourceManager, + config: ConfirmMnemonicConfig, + payload: ConfirmMnemonicPayload + ): ViewModel { + return ConfirmMnemonicViewModel(interactor, addAccountInteractor, router, deviceVibrator, resourceManager, config, payload) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ConfirmMnemonicViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmMnemonicViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/view/MnemonicContainerView.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/view/MnemonicContainerView.kt new file mode 100644 index 0000000..e6fd723 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/mnemonic/confirm/view/MnemonicContainerView.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.view + +import android.content.Context +import android.util.AttributeSet +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.GridSpacingItemDecoration +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.feature_account_impl.R +import kotlin.math.roundToInt + +private const val DEFAULT_COLUMNS = 3 + +class MnemonicContainerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) { + + private val _layoutManager = GridLayoutManager(context, DEFAULT_COLUMNS) + private var itemDecoration: ItemDecoration? = null + + init { + layoutManager = _layoutManager + + attrs?.let(::applyAttrs) + } + + fun setItemPadding(padding: Int) { + itemDecoration?.let { removeItemDecoration(it) } + itemDecoration = GridSpacingItemDecoration(_layoutManager, padding) + itemDecoration?.let { addItemDecoration(it) } + } + + private fun applyAttrs(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.MnemonicContainerView) { + if (it.hasValue(R.styleable.MnemonicContainerView_paddingBetweenItems)) { + val padding = it.getDimension(R.styleable.MnemonicContainerView_paddingBetweenItems, 0f).roundToInt() + setItemPadding(padding) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/multisig/MultisigSigningPresenter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/multisig/MultisigSigningPresenter.kt new file mode 100644 index 0000000..9a5d11c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/multisig/MultisigSigningPresenter.kt @@ -0,0 +1,133 @@ +package io.novafoundation.nova.feature_account_impl.presentation.multisig + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.amountFromPlanks +import io.novafoundation.nova.common.utils.bold +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatTokenAmount +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.formatting.spannable.format +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationFailure +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationFailure.NotEnoughSignatoryBalance +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.common.sign.notSupported.SigningNotSupportedPresentable +import io.novafoundation.nova.feature_account_impl.presentation.sign.NestedSigningPresenter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import javax.inject.Inject + +interface MultisigSigningPresenter { + + suspend fun acknowledgeMultisigOperation(multisig: MultisigMetaAccount, signatory: MetaAccount): Boolean + + suspend fun signingIsNotSupported() + + suspend fun presentValidationFailure(failure: MultisigExtrinsicValidationFailure) +} + +@FeatureScope +class RealMultisigSigningPresenter @Inject constructor( + private val nestedSigningPresenter: NestedSigningPresenter, + private val resourceManager: ResourceManager, + private val signingNotSupportedPresentable: SigningNotSupportedPresentable, +) : MultisigSigningPresenter { + + override suspend fun acknowledgeMultisigOperation(multisig: MultisigMetaAccount, signatory: MetaAccount): Boolean { + return nestedSigningPresenter.acknowledgeNestedSignOperation( + warningShowFor = multisig, + title = { resourceManager.getString(R.string.multisig_signing_warning_title) }, + subtitle = { formatSubtitleForWarning(signatory) }, + iconRes = { R.drawable.ic_multisig } + ) + } + + override suspend fun signingIsNotSupported() { + signingNotSupportedPresentable.presentSigningNotSupported( + SigningNotSupportedPresentable.Payload( + iconRes = R.drawable.ic_multisig, + message = resourceManager.getString(R.string.multisig_signing_is_not_supported_message) + ) + ) + } + + override suspend fun presentValidationFailure(failure: MultisigExtrinsicValidationFailure) { + val (title, message) = when (failure) { + is NotEnoughSignatoryBalance -> formatBalanceFailure(failure) + + is MultisigExtrinsicValidationFailure.OperationAlreadyExists -> formatOperationAlreadyExists(failure) + } + + nestedSigningPresenter.presentValidationFailure(title, message) + } + + private fun formatOperationAlreadyExists(failure: MultisigExtrinsicValidationFailure.OperationAlreadyExists): ValidationTitleAndMessage { + val title = resourceManager.getString(R.string.multisig_callhash_exists_title) + + val messageFormat = resourceManager.getString(R.string.multisig_callhash_exists_message) + val nameFormatted = formatName(failure.multisigAccount) + val message = SpannableFormatter.format(messageFormat, nameFormatted) + + return title to message + } + + private fun formatBalanceFailure(failure: NotEnoughSignatoryBalance): ValidationTitleAndMessage { + val title: String = resourceManager.getString(R.string.common_error_not_enough_tokens) + + val signatoryName = formatName(failure.signatory) + + val deposit = failure.deposit + val fee = failure.fee + + val message = when { + fee != null && deposit != null -> SpannableFormatter.format( + resourceManager, + R.string.multisig_signatory_validation_deposit_fee, + signatoryName, + formatAmount(failure.asset, fee), + formatAmount(failure.asset, deposit), + formatAmount(failure.asset, failure.balanceToAdd) + ) + + deposit != null -> SpannableFormatter.format( + resourceManager, + R.string.multisig_signatory_validation_deposit, + signatoryName, + formatAmount(failure.asset, deposit), + formatAmount(failure.asset, failure.balanceToAdd) + ) + + fee != null -> SpannableFormatter.format( + resourceManager, + R.string.multisig_signatory_validation_fee, + signatoryName, + formatAmount(failure.asset, fee), + formatAmount(failure.asset, failure.balanceToAdd) + ) + + else -> error("Fee and deposit cannot be null at the same time") + } + + return title to message + } + + private fun formatAmount(asset: Chain.Asset, amount: BalanceOf): String { + return amount.amountFromPlanks(asset.precision).formatTokenAmount(asset.symbol) + } + + private fun formatName(metaAccount: MetaAccount): CharSequence { + return metaAccount.name.bold() + } + + private fun formatSubtitleForWarning(signatory: MetaAccount): CharSequence { + val subtitle = resourceManager.getString(R.string.multisig_signing_warning_message) + val primaryColor = resourceManager.getColor(R.color.text_primary) + val proxyName = signatory.name.toSpannable(colorSpan(primaryColor)) + return SpannableFormatter.format(subtitle, proxyName) + } +} + +private typealias ValidationTitleAndMessage = Pair diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/navigation/RealExtrinsicNavigationWrapper.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/navigation/RealExtrinsicNavigationWrapper.kt new file mode 100644 index 0000000..2966850 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/navigation/RealExtrinsicNavigationWrapper.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_account_impl.presentation.navigation + +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.data.signer.isDelayed +import io.novafoundation.nova.feature_account_api.data.signer.selectedAccount +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter + +class RealExtrinsicNavigationWrapper( + private val accountRouter: AccountRouter, + private val accountUseCase: AccountInteractor +) : ExtrinsicNavigationWrapper { + + override suspend fun startNavigation( + submissionHierarchy: SubmissionHierarchy, + fallback: suspend () -> Unit + ) { + if (submissionHierarchy.isDelayed()) { + val delayedAccount = submissionHierarchy.firstDelayedAccount() + + accountUseCase.selectMetaAccount(delayedAccount.id) + + if (delayedAccount.type == LightMetaAccount.Type.MULTISIG) { + val accountWasSwitched = submissionHierarchy.selectedAccount().id != delayedAccount.id + accountRouter.openMainWithFinishMultisigTransaction(accountWasSwitched) + } else { + accountRouter.openMain() + } + } else { + fallback() + } + } + + private fun SubmissionHierarchy.firstDelayedAccount(): MetaAccount { + return path.first { it.callExecutionType == CallExecutionType.DELAYED } + .account + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/NodeDetailsRootViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/NodeDetailsRootViewModel.kt new file mode 100644 index 0000000..de8d665 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/NodeDetailsRootViewModel.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.base.errors.NovaException +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.errors.NodeAlreadyExistsException +import io.novafoundation.nova.feature_account_impl.domain.errors.UnsupportedNetworkException + +abstract class NodeDetailsRootViewModel( + private val resourceManager: ResourceManager +) : BaseViewModel() { + + protected open fun handleNodeException(throwable: Throwable) { + when (throwable) { + is NodeAlreadyExistsException -> showError(resourceManager.getString(R.string.connection_add_already_exists_error)) + is UnsupportedNetworkException -> showError(getUnsupportedNodeError()) + is NovaException -> { + if (NovaException.Kind.NETWORK == throwable.kind) { + showError(resourceManager.getString(R.string.connection_add_invalid_error)) + } else { + throwable.message?.let(::showError) + } + } + else -> throwable.message?.let(::showError) + } + } + + protected open fun getUnsupportedNodeError(): String { + val supportedNodes = Node.NetworkType.values().joinToString(", ") { it.readableName } + val unsupportedNodeErrorMsg = resourceManager.getString(R.string.connection_add_unsupported_error) + return unsupportedNodeErrorMsg.format(supportedNodes) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/AddNodeFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/AddNodeFragment.kt new file mode 100644 index 0000000..809f5ea --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/AddNodeFragment.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.add + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentNodeAddBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent + +class AddNodeFragment : BaseFragment() { + + override fun createBinding() = FragmentNodeAddBinding.inflate(layoutInflater) + + override fun initViews() { + binder.novaToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.nodeNameField.content.bindTo(viewModel.nodeNameInputLiveData) + + binder.nodeHostField.content.bindTo(viewModel.nodeHostInputLiveData) + + binder.addBtn.setOnClickListener { viewModel.addNodeClicked() } + + binder.addBtn.prepareForProgress(viewLifecycleOwner) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .addNodeComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: AddNodeViewModel) { + viewModel.addButtonState.observe(binder.addBtn::setState) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/AddNodeViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/AddNodeViewModel.kt new file mode 100644 index 0000000..fcd19af --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/AddNodeViewModel.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.add + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.combine +import io.novafoundation.nova.common.utils.requireException +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.NodeHostValidator +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.node.NodeDetailsRootViewModel +import kotlinx.coroutines.launch + +class AddNodeViewModel( + private val interactor: AccountInteractor, + private val router: AccountRouter, + private val nodeHostValidator: NodeHostValidator, + resourceManager: ResourceManager +) : NodeDetailsRootViewModel(resourceManager) { + + val nodeNameInputLiveData = MutableLiveData() + val nodeHostInputLiveData = MutableLiveData() + + private val addingInProgressLiveData = MutableLiveData(false) + + val addButtonState = combine( + nodeNameInputLiveData, + nodeHostInputLiveData, + addingInProgressLiveData + ) { (name: String, host: String, addingInProgress: Boolean) -> + when { + addingInProgress -> ButtonState.PROGRESS + name.isNotEmpty() && nodeHostValidator.hostIsValid(host) -> ButtonState.NORMAL + else -> ButtonState.DISABLED + } + } + + fun backClicked() { + router.back() + } + + fun addNodeClicked() { + val nodeName = nodeNameInputLiveData.value ?: return + val nodeHost = nodeHostInputLiveData.value ?: return + + addingInProgressLiveData.value = true + + viewModelScope.launch { + val result = interactor.addNode(nodeName, nodeHost) + + if (result.isSuccess) { + router.back() + } else { + handleNodeException(result.requireException()) + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/di/AddNodeComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/di/AddNodeComponent.kt new file mode 100644 index 0000000..c783c9e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/di/AddNodeComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.add.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.node.add.AddNodeFragment + +@Subcomponent( + modules = [ + AddNodeModule::class + ] +) +@ScreenScope +interface AddNodeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): AddNodeComponent + } + + fun inject(fragment: AddNodeFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/di/AddNodeModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/di/AddNodeModule.kt new file mode 100644 index 0000000..2c934b1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/add/di/AddNodeModule.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.add.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.NodeHostValidator +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.node.add.AddNodeViewModel + +@Module(includes = [ViewModelModule::class]) +class AddNodeModule { + + @Provides + @IntoMap + @ViewModelKey(AddNodeViewModel::class) + fun provideViewModel( + interactor: AccountInteractor, + router: AccountRouter, + nodeHostValidator: NodeHostValidator, + resourceManager: ResourceManager + ): ViewModel { + return AddNodeViewModel(interactor, router, nodeHostValidator, resourceManager) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): AddNodeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AddNodeViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/NodeDetailsFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/NodeDetailsFragment.kt new file mode 100644 index 0000000..e8bf516 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/NodeDetailsFragment.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.details + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.onTextChanged +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentNodeDetailsBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent + +class NodeDetailsFragment : BaseFragment() { + + companion object { + private const val KEY_NODE_ID = "node_id" + + fun getBundle(nodeId: Int): Bundle { + return Bundle().apply { + putInt(KEY_NODE_ID, nodeId) + } + } + } + + override fun createBinding() = FragmentNodeDetailsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.novaToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.nodeHostCopy.setOnClickListener { + viewModel.copyNodeHostClicked() + } + + binder.updateBtn.setOnClickListener { + viewModel.updateClicked(binder.nodeDetailsNameField.content.text.toString(), binder.nodeDetailsHostField.content.text.toString()) + } + } + + override fun inject() { + val nodeId = argument(KEY_NODE_ID) + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .nodeDetailsComponentFactory() + .create(this, nodeId) + .inject(this) + } + + override fun subscribe(viewModel: NodeDetailsViewModel) { + viewModel.nodeModelLiveData.observe { node -> + binder.nodeDetailsNameField.content.setText(node.name) + binder.nodeDetailsHostField.content.setText(node.link) + + with(node.networkModelType) { + binder.nodeDetailsNetworkType.text = networkType.readableName + binder.nodeDetailsNetworkType.setDrawableStart(icon) + } + } + + viewModel.nameEditEnabled.observe { editEnabled -> + binder.updateBtn.setVisible(editEnabled) + + binder.nodeDetailsNameField.content.isEnabled = editEnabled + + binder.nodeDetailsNameField.content.onTextChanged { + viewModel.nodeDetailsEdited() + } + } + + viewModel.hostEditEnabled.observe { editEnabled -> + binder.nodeDetailsHostField.content.isEnabled = editEnabled + + binder.nodeDetailsHostField.content.onTextChanged { + viewModel.nodeDetailsEdited() + } + } + + viewModel.updateButtonEnabled.observe { + binder.updateBtn.isEnabled = it + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/NodeDetailsViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/NodeDetailsViewModel.kt new file mode 100644 index 0000000..7efa24a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/NodeDetailsViewModel.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.details + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.liveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.map +import io.novafoundation.nova.common.utils.requireException +import io.novafoundation.nova.common.utils.setValueIfNew +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.mappers.mapNodeToNodeModel +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.node.NodeDetailsRootViewModel +import io.novafoundation.nova.feature_account_impl.presentation.node.model.NodeModel +import kotlinx.coroutines.launch + +class NodeDetailsViewModel( + private val interactor: AccountInteractor, + private val router: AccountRouter, + private val nodeId: Int, + private val clipboardManager: ClipboardManager, + private val resourceManager: ResourceManager +) : NodeDetailsRootViewModel(resourceManager) { + + val nodeModelLiveData = liveData { + emit(getNode(nodeId)) + } + + val nameEditEnabled = nodeModelLiveData.map(::mapNodeNameEditState) + val hostEditEnabled = nodeModelLiveData.map(::mapNodeHostEditState) + + private val _updateButtonEnabled = MutableLiveData() + val updateButtonEnabled: LiveData = _updateButtonEnabled + + fun backClicked() { + router.back() + } + + fun nodeDetailsEdited() { + _updateButtonEnabled.setValueIfNew(true) + } + + fun copyNodeHostClicked() { + nodeModelLiveData.value?.let { + clipboardManager.addToClipboard(it.link) + + showToast(resourceManager.getString(R.string.common_copied)) + } + } + + fun updateClicked(name: String, hostUrl: String) { + viewModelScope.launch { + val result = interactor.updateNode(nodeId, name, hostUrl) + + if (result.isSuccess) { + router.back() + } else { + handleNodeException(result.requireException()) + } + } + } + + private suspend fun getNode(nodeId: Int): NodeModel { + val node = interactor.getNode(nodeId) + + return mapNodeToNodeModel(node) + } + + private fun mapNodeNameEditState(node: NodeModel): Boolean { + return !node.isDefault + } + + private fun mapNodeHostEditState(node: NodeModel): Boolean { + return !node.isDefault && !node.isActive + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/di/NodeDetailsComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/di/NodeDetailsComponent.kt new file mode 100644 index 0000000..dec3f15 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/di/NodeDetailsComponent.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.details.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.node.details.NodeDetailsFragment + +@Subcomponent( + modules = [ + NodeDetailsModule::class + ] +) +@ScreenScope +interface NodeDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance nodeId: Int, + ): NodeDetailsComponent + } + + fun inject(fragment: NodeDetailsFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/di/NodeDetailsModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/di/NodeDetailsModule.kt new file mode 100644 index 0000000..efcc6af --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/details/di/NodeDetailsModule.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.details.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.node.details.NodeDetailsViewModel + +@Module(includes = [ViewModelModule::class]) +class NodeDetailsModule { + + @Provides + @IntoMap + @ViewModelKey(NodeDetailsViewModel::class) + fun provideViewModel( + interactor: AccountInteractor, + router: AccountRouter, + nodeId: Int, + clipboardManager: ClipboardManager, + resourceManager: ResourceManager + ): ViewModel { + return NodeDetailsViewModel(interactor, router, nodeId, clipboardManager, resourceManager) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): NodeDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NodeDetailsViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/NodesAdapter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/NodesAdapter.kt new file mode 100644 index 0000000..865b83d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/NodesAdapter.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.list + +import android.view.View +import android.view.ViewGroup +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_impl.databinding.ItemNodeBinding +import io.novafoundation.nova.feature_account_impl.databinding.ItemNodeGroupBinding +import io.novafoundation.nova.feature_account_impl.presentation.node.model.NodeHeaderModel +import io.novafoundation.nova.feature_account_impl.presentation.node.model.NodeModel + +class NodesAdapter( + private val nodeItemHandler: NodeItemHandler +) : GroupedListAdapter(NodesDiffCallback) { + + interface NodeItemHandler { + + fun infoClicked(nodeModel: NodeModel) + + fun checkClicked(nodeModel: NodeModel) + + fun deleteClicked(nodeModel: NodeModel) + } + + private var editMode = false + + fun switchToEdit(editable: Boolean) { + editMode = editable + + val firstCustomNodeIndex = currentList.indexOfFirst { it is NodeModel && !it.isDefault } + + if (firstCustomNodeIndex == -1) return + + val customNodesCount = currentList.size - firstCustomNodeIndex + notifyItemRangeChanged(firstCustomNodeIndex, customNodesCount) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return NodeGroupHolder(ItemNodeGroupBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return NodeHolder(ItemNodeBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: NodeHeaderModel) { + (holder as NodeGroupHolder).bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: NodeModel) { + (holder as NodeHolder).bind(child, nodeItemHandler, editMode) + } +} + +class NodeGroupHolder(private val binder: ItemNodeGroupBinding) : GroupedListHolder(binder.root) { + fun bind(nodeHeaderModel: NodeHeaderModel) { + binder.nodeGroupTitle.text = nodeHeaderModel.title + } +} + +class NodeHolder(private val binder: ItemNodeBinding) : GroupedListHolder(binder.root) { + + fun bind( + nodeModel: NodeModel, + handler: NodesAdapter.NodeItemHandler, + editMode: Boolean + ) { + with(containerView) { + binder.nodeTitle.text = nodeModel.name + binder.nodeHost.text = nodeModel.link + + val isChecked = nodeModel.isActive + + binder.nodeCheck.visibility = if (isChecked) View.VISIBLE else View.INVISIBLE + + if (!isChecked && !nodeModel.isDefault && editMode) { + binder.nodeDelete.visibility = View.VISIBLE + binder.nodeDelete.setOnClickListener { handler.deleteClicked(nodeModel) } + binder.nodeInfo.visibility = View.INVISIBLE + binder.nodeInfo.setOnClickListener(null) + isEnabled = false + setOnClickListener(null) + } else { + binder.nodeDelete.visibility = View.GONE + binder.nodeDelete.setOnClickListener(null) + binder.nodeInfo.visibility = View.VISIBLE + binder.nodeInfo.setOnClickListener { handler.infoClicked(nodeModel) } + isEnabled = true + setOnClickListener { handler.checkClicked(nodeModel) } + } + + binder.nodeIcon.setImageResource(nodeModel.networkModelType.icon) + } + } +} + +private object NodesDiffCallback : BaseGroupedDiffCallback(NodeHeaderModel::class.java) { + + override fun areGroupItemsTheSame(oldItem: NodeHeaderModel, newItem: NodeHeaderModel): Boolean { + return oldItem.title == newItem.title + } + + override fun areGroupContentsTheSame(oldItem: NodeHeaderModel, newItem: NodeHeaderModel): Boolean { + return oldItem == newItem + } + + override fun areChildItemsTheSame(oldItem: NodeModel, newItem: NodeModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areChildContentsTheSame(oldItem: NodeModel, newItem: NodeModel): Boolean { + return oldItem == newItem + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/NodesFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/NodesFragment.kt new file mode 100644 index 0000000..d9a2886 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/NodesFragment.kt @@ -0,0 +1,110 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.list + +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentNodesBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.node.list.accounts.AccountChooserBottomSheetDialog +import io.novafoundation.nova.feature_account_impl.presentation.node.model.NodeModel + +class NodesFragment : BaseFragment(), NodesAdapter.NodeItemHandler { + + override fun createBinding() = FragmentNodesBinding.inflate(layoutInflater) + + private lateinit var adapter: NodesAdapter + + override fun initViews() { + adapter = NodesAdapter(this) + + binder.connectionsList.setHasFixedSize(true) + binder.connectionsList.adapter = adapter + + binder.novaToolbar.setHomeButtonListener { + viewModel.backClicked() + } + + binder.novaToolbar.setRightActionClickListener { + viewModel.editClicked() + } + + binder.addConnectionTv.setOnClickListener { + viewModel.addNodeClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .connectionsComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: NodesViewModel) { + viewModel.groupedNodeModelsLiveData.observe(adapter::submitList) + + viewModel.noAccountsEvent.observeEvent { + showNoAccountsDialog(it) + } + + viewModel.showAccountChooserLiveData.observeEvent { + AccountChooserBottomSheetDialog(requireActivity(), it) { _, item -> + viewModel.accountSelected(item) + }.show() + } + + viewModel.editMode.observe(adapter::switchToEdit) + + viewModel.toolbarAction.observe(binder.novaToolbar::setTextRight) + + viewModel.deleteNodeEvent.observeEvent(::showDeleteNodeDialog) + } + + override fun infoClicked(nodeModel: NodeModel) { + viewModel.infoClicked(nodeModel) + } + + override fun checkClicked(nodeModel: NodeModel) { + viewModel.selectNodeClicked(nodeModel) + } + + override fun deleteClicked(nodeModel: NodeModel) { + viewModel.deleteNodeClicked(nodeModel) + } + + private fun showDeleteNodeDialog(nodeModel: NodeModel) { + val message = getString( + R.string.connection_delete_description, + nodeModel.networkModelType.networkType.readableName, + nodeModel.name + ) + + MaterialAlertDialogBuilder(context, R.style.AlertDialogTheme) + .setTitle(R.string.connection_delete_title) + .setMessage(message) + .setPositiveButton(R.string.common_delete) { dialog, _ -> + viewModel.confirmNodeDeletion(nodeModel) + dialog?.dismiss() + } + .setNegativeButton(R.string.common_cancel) { dialog, _ -> dialog?.dismiss() } + .show() + } + + private fun showNoAccountsDialog(networkType: Node.NetworkType) { + MaterialAlertDialogBuilder(context, R.style.AlertDialogTheme) + .setTitle(R.string.account_needed_title) + .setMessage(R.string.account_needed_message) + .setPositiveButton(R.string.common_proceed) { dialog, _ -> + viewModel.createAccountForNetworkType(networkType) + dialog?.dismiss() + } + .setNegativeButton(R.string.common_cancel) { dialog, _ -> dialog?.dismiss() } + .show() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/NodesViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/NodesViewModel.kt new file mode 100644 index 0000000..4d4f57e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/NodesViewModel.kt @@ -0,0 +1,147 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.list + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.createSubstrateAddressModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.map +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet.Payload +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.model.Account +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.node.list.accounts.model.AccountByNetworkModel +import io.novafoundation.nova.feature_account_impl.presentation.node.mixin.api.NodeListingMixin +import io.novafoundation.nova.feature_account_impl.presentation.node.model.NodeModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val ICON_IN_DP = 24 + +class NodesViewModel( + private val interactor: AccountInteractor, + private val router: AccountRouter, + private val nodeListingMixin: NodeListingMixin, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager +) : BaseViewModel(), NodeListingMixin by nodeListingMixin { + + private val _noAccountsEvent = MutableLiveData>() + val noAccountsEvent: LiveData> = _noAccountsEvent + + private val _showAccountChooserLiveData = MutableLiveData>>() + val showAccountChooserLiveData: LiveData>> = _showAccountChooserLiveData + + private val _editMode = MutableLiveData() + val editMode: LiveData = _editMode + + private val _deleteNodeEvent = MutableLiveData>() + val deleteNodeEvent: LiveData> = _deleteNodeEvent + + val toolbarAction = editMode.map { + if (it) { + resourceManager.getString(R.string.common_done) + } else { + resourceManager.getString(R.string.common_edit) + } + } + + fun editClicked() { + val edit = editMode.value ?: false + _editMode.value = !edit + } + + fun backClicked() { + router.back() + } + + fun infoClicked(nodeModel: NodeModel) { + router.openNodeDetails(nodeModel.id) + } + + fun selectNodeClicked(nodeModel: NodeModel) { + viewModelScope.launch { + val (accounts, selectedNode) = interactor.getAccountsByNetworkTypeWithSelectedNode(nodeModel.networkModelType.networkType) + + handleAccountsForNetwork(nodeModel, selectedNode, accounts) + } + } + + fun accountSelected(accountModel: AccountByNetworkModel) { + selectAccountForNode(accountModel.nodeId, accountModel.accountAddress) + } + + fun addNodeClicked() { + router.openAddNode() + } + + fun createAccountForNetworkType(networkType: Node.NetworkType) { +// router.createAccountForNetworkType(networkType) + } + + fun deleteNodeClicked(nodeModel: NodeModel) { + _deleteNodeEvent.value = Event(nodeModel) + } + + fun confirmNodeDeletion(nodeModel: NodeModel) { + viewModelScope.launch { + interactor.deleteNode(nodeModel.id) + } + } + + private suspend fun generateIconForAddress(account: Account): AddressModel { + return addressIconGenerator.createSubstrateAddressModel(account.address, ICON_IN_DP) + } + + private fun handleAccountsForNetwork(nodeModel: NodeModel, selectedNode: Node, accounts: List) { + when { + accounts.isEmpty() -> _noAccountsEvent.value = Event(nodeModel.networkModelType.networkType) + accounts.size == 1 -> selectAccountForNode(nodeModel.id, accounts.first().address) + selectedNode.networkType == nodeModel.networkModelType.networkType -> selectNodeWithCurrentAccount(nodeModel.id) + else -> showAccountChooser(nodeModel, accounts) + } + } + + private fun showAccountChooser(nodeModel: NodeModel, accounts: List) { + viewModelScope.launch { + val accountModels = generateAccountModels(nodeModel, accounts) + + _showAccountChooserLiveData.value = Event(Payload(accountModels)) + } + } + + private suspend fun generateAccountModels(nodeModel: NodeModel, accounts: List): List { + return withContext(Dispatchers.Default) { + accounts.map { mapAccountToAccountModel(nodeModel.id, it) } + } + } + + private suspend fun mapAccountToAccountModel(nodeId: Int, account: Account): AccountByNetworkModel { + val addressModel = generateIconForAddress(account) + + return AccountByNetworkModel(nodeId, account.address, account.name, addressModel) + } + + private fun selectAccountForNode(nodeId: Int, accountAddress: String) { + viewModelScope.launch { + interactor.selectNodeAndAccount(nodeId, accountAddress) + + router.returnToWallet() + } + } + + private fun selectNodeWithCurrentAccount(nodeId: Int) { + viewModelScope.launch { + interactor.selectNode(nodeId) + + router.returnToWallet() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/accounts/AccountChooserBottomSheetDialog.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/accounts/AccountChooserBottomSheetDialog.kt new file mode 100644 index 0000000..dee20aa --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/accounts/AccountChooserBottomSheetDialog.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.list.accounts + +import android.content.Context +import android.os.Bundle +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ClickHandler +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.ItemAccountByNetworkBinding +import io.novafoundation.nova.feature_account_impl.presentation.node.list.accounts.model.AccountByNetworkModel + +class AccountChooserBottomSheetDialog( + context: Context, + payload: Payload, + onClicked: ClickHandler +) : DynamicListBottomSheet(context, payload, AccountDiffCallback, onClicked) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.profile_accounts_title) + } + + override fun holderCreator(): HolderCreator = { + AccountHolder(ItemAccountByNetworkBinding.inflate(it.inflater(), it, false)) + } +} + +class AccountHolder( + private val binder: ItemAccountByNetworkBinding +) : DynamicListSheetAdapter.Holder(binder.root) { + + override fun bind(item: AccountByNetworkModel, isSelected: Boolean, handler: DynamicListSheetAdapter.Handler) { + super.bind(item, isSelected, handler) + + binder.accountTitle.text = item.name.orEmpty() + binder.accountIcon.setImageDrawable(item.addressModel.image) + } +} + +private object AccountDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AccountByNetworkModel, newItem: AccountByNetworkModel): Boolean { + return oldItem.accountAddress == newItem.accountAddress + } + + override fun areContentsTheSame(oldItem: AccountByNetworkModel, newItem: AccountByNetworkModel): Boolean { + return oldItem == newItem + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/accounts/model/AccountByNetworkModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/accounts/model/AccountByNetworkModel.kt new file mode 100644 index 0000000..705dba9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/accounts/model/AccountByNetworkModel.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.list.accounts.model + +import io.novafoundation.nova.common.address.AddressModel + +data class AccountByNetworkModel( + val nodeId: Int, + val accountAddress: String, + val name: String?, + val addressModel: AddressModel +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/di/NodesComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/di/NodesComponent.kt new file mode 100644 index 0000000..ba9246c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/di/NodesComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.node.list.NodesFragment + +@Subcomponent( + modules = [ + NodesModule::class + ] +) +@ScreenScope +interface NodesComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): NodesComponent + } + + fun inject(fragment: NodesFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/di/NodesModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/di/NodesModule.kt new file mode 100644 index 0000000..e3840f7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/list/di/NodesModule.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.node.list.NodesViewModel +import io.novafoundation.nova.feature_account_impl.presentation.node.mixin.api.NodeListingMixin +import io.novafoundation.nova.feature_account_impl.presentation.node.mixin.impl.NodeListingProvider + +@Module(includes = [ViewModelModule::class]) +class NodesModule { + + @Provides + fun provideNodeListingMixin( + interactor: AccountInteractor, + resourceManager: ResourceManager + ): NodeListingMixin = NodeListingProvider(interactor, resourceManager) + + @Provides + @IntoMap + @ViewModelKey(NodesViewModel::class) + fun provideViewModel( + interactor: AccountInteractor, + router: AccountRouter, + nodeListingMixin: NodeListingMixin, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager + ): ViewModel { + return NodesViewModel(interactor, router, nodeListingMixin, addressIconGenerator, resourceManager) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): NodesViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NodesViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/mixin/api/NodeListingMixin.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/mixin/api/NodeListingMixin.kt new file mode 100644 index 0000000..3bec6c2 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/mixin/api/NodeListingMixin.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.mixin.api + +import androidx.lifecycle.LiveData + +interface NodeListingMixin { + + val groupedNodeModelsLiveData: LiveData> +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/mixin/impl/NodeListingProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/mixin/impl/NodeListingProvider.kt new file mode 100644 index 0000000..0bedd30 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/mixin/impl/NodeListingProvider.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.mixin.impl + +import androidx.lifecycle.asLiveData +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.mappers.mapNodeToNodeModel +import io.novafoundation.nova.feature_account_impl.presentation.node.mixin.api.NodeListingMixin +import io.novafoundation.nova.feature_account_impl.presentation.node.model.NodeHeaderModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +class NodeListingProvider( + private val accountInteractor: AccountInteractor, + private val resourceManager: ResourceManager, +) : NodeListingMixin { + + override val groupedNodeModelsLiveData = getGroupedNodes() + .asLiveData() + + private fun getGroupedNodes() = accountInteractor.nodesFlow() + .map(::transformToModels) + .flowOn(Dispatchers.Default) + + private fun transformToModels(list: List): List { + val defaultHeader = NodeHeaderModel(resourceManager.getString(R.string.connection_management_default_title)) + val customHeader = NodeHeaderModel(resourceManager.getString(R.string.connection_management_custom_title)) + + val defaultNodes = list.filter(Node::isDefault) + val customNodes = list.filter { !it.isDefault } + + return mutableListOf().apply { + add(defaultHeader) + addAll(defaultNodes.map(::mapNodeToNodeModel)) + + if (customNodes.isNotEmpty()) { + add(customHeader) + addAll(customNodes.map(::mapNodeToNodeModel)) + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/model/NodeHeaderModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/model/NodeHeaderModel.kt new file mode 100644 index 0000000..b590655 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/model/NodeHeaderModel.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.model + +data class NodeHeaderModel( + val title: String +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/model/NodeModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/model/NodeModel.kt new file mode 100644 index 0000000..2f2aab2 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/node/model/NodeModel.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_account_impl.presentation.node.model + +import io.novafoundation.nova.feature_account_impl.presentation.view.advanced.network.model.NetworkModel + +data class NodeModel( + val id: Int, + val name: String, + val link: String, + val networkModelType: NetworkModel.NetworkTypeUI, + val isDefault: Boolean, + val isActive: Boolean +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/PolkadotVaultVariantConfigBuilder.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/PolkadotVaultVariantConfigBuilder.kt new file mode 100644 index 0000000..896703b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/PolkadotVaultVariantConfigBuilder.kt @@ -0,0 +1,193 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.config + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig.Common +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig.ConnectPage +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig.ConnectPage.Instruction +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig.Sign + +@DslMarker +annotation class VariantConfigBuilderDsl + +typealias DslBuilding = T.() -> Unit + +@VariantConfigBuilderDsl +interface PolkadotVaultVariantConfigBuilder { + + fun connectPage(builder: DslBuilding) + + fun sign(builder: DslBuilding) + + fun common(builder: DslBuilding) + + @VariantConfigBuilderDsl + interface ConnectPageBuilder { + + fun name(name: String) + + fun instructions(builder: DslBuilding) + + @VariantConfigBuilderDsl + interface InstructionsBuilder { + + fun step(@StringRes contentRes: Int) + + fun step(content: CharSequence) + + fun image(@StringRes labelRes: Int?, @DrawableRes imageRes: Int) + } + } + + interface SignBuilder { + + var troubleShootingLink: String + + var supportsProofSigning: Boolean + } + + interface CommonBuilder { + + @get:DrawableRes + var iconRes: Int + + @get:StringRes + var nameRes: Int + } +} + +internal fun BuildPolkadotVaultVariantConfig( + resourceManager: ResourceManager, + builder: DslBuilding +): PolkadotVaultVariantConfig { + return RealPolkadotVaultVariantConfigBuilder(resourceManager).apply(builder).build() +} + +private class RealPolkadotVaultVariantConfigBuilder( + private val resourceManager: ResourceManager +) : PolkadotVaultVariantConfigBuilder { + + private val pages = mutableListOf() + + private var sign: Sign? = null + private var common: Common? = null + + override fun connectPage(builder: DslBuilding) { + val page = RealConnectPageBuilder(resourceManager).apply(builder).build() + pages += page + } + + override fun sign(builder: DslBuilding) { + sign = RealSignBuilder().apply(builder).build() + } + + override fun common(builder: DslBuilding) { + common = RealCommonBuilder().apply(builder).build() + } + + fun build(): PolkadotVaultVariantConfig { + require(pages.isNotEmpty()) { "At least one connectPage { } must be defined" } + + val sign = requireNotNull(sign) { "sign { } block is required" } + val common = requireNotNull(common) { "common { } block is required" } + + return PolkadotVaultVariantConfig(pages, sign, common) + } +} + +private class RealConnectPageBuilder( + private val resourceManager: ResourceManager +) : PolkadotVaultVariantConfigBuilder.ConnectPageBuilder { + + private var name: String? = null + private var instructions: List? = null + + override fun name(name: String) { + this.name = name + } + + override fun instructions(builder: DslBuilding) { + instructions = RealInstructionsBuilder(resourceManager).apply(builder).build() + } + + fun build(): ConnectPage { + val name = requireNotNull(name) { "name must be provided for each connectPage { }" } + val instructions = requireNotNull(instructions) { "instructions { } must be provided for each connectPage { }" } + return ConnectPage(name, instructions) + } +} + +private class RealInstructionsBuilder( + private val resourceManager: ResourceManager +) : PolkadotVaultVariantConfigBuilder.ConnectPageBuilder.InstructionsBuilder { + + private var stepsCounter = 0 + private val instructions = mutableListOf() + + override fun step(contentRes: Int) { + val content = resourceManager.getText(contentRes) + step(content) + } + + override fun step(content: CharSequence) { + stepsCounter += 1 + + val stepInstruction = Instruction.Step(stepsCounter, content) + instructions.add(stepInstruction) + } + + override fun image(labelRes: Int?, imageRes: Int) { + val imageInstruction = Instruction.Image(labelRes?.let { resourceManager.getString(it) }, imageRes) + instructions.add(imageInstruction) + } + + fun build(): List { + require(instructions.isNotEmpty()) { "instructions { } must not be empty" } + return instructions + } +} + +private class RealSignBuilder : PolkadotVaultVariantConfigBuilder.SignBuilder { + + private var _troubleShootingLink: String? = null + override var troubleShootingLink: String + get() = requireNotNull(_troubleShootingLink) { "troubleShootingLink must be set" } + set(value) { + _troubleShootingLink = value + } + + private var _supportsProofSigning: Boolean? = null + override var supportsProofSigning: Boolean + get() = requireNotNull(_supportsProofSigning) { "supportsProofSigning must be set" } + set(value) { + _supportsProofSigning = value + } + + fun build(): Sign { + return Sign(troubleShootingLink, supportsProofSigning) + } +} + +private class RealCommonBuilder : PolkadotVaultVariantConfigBuilder.CommonBuilder { + + private var _iconRes: Int? = null + private var _nameRes: Int? = null + + override var iconRes: Int + get() = requireNotNull(_iconRes) { "iconRes must be set" } + set(@DrawableRes value) { + _iconRes = value + } + + override var nameRes: Int + get() = requireNotNull(_nameRes) { "nameRes must be set" } + set(@StringRes value) { + _nameRes = value + } + + fun build(): Common { + return Common(iconRes = iconRes, nameRes = nameRes) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/PolkadotVaultVariantConfigProvider.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/PolkadotVaultVariantConfigProvider.kt new file mode 100644 index 0000000..3feb92b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/PolkadotVaultVariantConfigProvider.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.config + +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.config.variants.ParitySignerConfig +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.config.variants.PolkadotVaultConfig + +class RealPolkadotVaultVariantConfigProvider( + private val resourceManager: ResourceManager, + private val appLinksProvider: AppLinksProvider, +) : PolkadotVaultVariantConfigProvider { + + private val paritySignerConfig by lazy { ParitySignerConfig(resourceManager, appLinksProvider) } + private val polkadotVaultConfig by lazy { PolkadotVaultConfig(resourceManager, appLinksProvider) } + + override fun variantConfigFor(variant: PolkadotVaultVariant): PolkadotVaultVariantConfig { + return when (variant) { + PolkadotVaultVariant.POLKADOT_VAULT -> polkadotVaultConfig + PolkadotVaultVariant.PARITY_SIGNER -> paritySignerConfig + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/variants/ParitySignerConfig.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/variants/ParitySignerConfig.kt new file mode 100644 index 0000000..9eb5b98 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/variants/ParitySignerConfig.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.config.variants + +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.spannable.highlightedText +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.config.BuildPolkadotVaultVariantConfig + +internal fun ParitySignerConfig(resourceManager: ResourceManager, appLinksProvider: AppLinksProvider): PolkadotVaultVariantConfig { + return BuildPolkadotVaultVariantConfig(resourceManager) { + sign { + troubleShootingLink = appLinksProvider.paritySignerTroubleShooting + supportsProofSigning = false + } + + connectPage { + name(resourceManager.getString(R.string.account_pair_public_key)) + + instructions { + step( + resourceManager.highlightedText( + R.string.account_parity_signer_import_start_step_1, + R.string.account_parity_signer_import_start_step_1_highlighted + ) + ) + + step( + resourceManager.highlightedText( + R.string.account_parity_signer_import_start_step_2, + R.string.account_parity_signer_import_start_step_2_highlighted + ) + ) + image(R.string.account_parity_signer_import_start_select_top, R.drawable.my_parity_signer) + + step( + resourceManager.highlightedText( + R.string.account_parity_signer_import_start_step_3, + R.string.account_parity_signer_import_start_step_3_highlighted + ) + ) + } + } + + common { + iconRes = R.drawable.ic_parity_signer_legacy + nameRes = R.string.account_parity_signer + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/variants/PolkadotVaultConfig.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/variants/PolkadotVaultConfig.kt new file mode 100644 index 0000000..b9c189e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/config/variants/PolkadotVaultConfig.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.config.variants + +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.spannable.highlightedText +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.config.BuildPolkadotVaultVariantConfig + +internal fun PolkadotVaultConfig(resourceManager: ResourceManager, appLinksProvider: AppLinksProvider): PolkadotVaultVariantConfig { + return BuildPolkadotVaultVariantConfig(resourceManager) { + sign { + troubleShootingLink = appLinksProvider.polkadotVaultTroubleShooting + supportsProofSigning = true + } + + connectPage { + name(resourceManager.getString(R.string.account_pair_public_key)) + + instructions { + step( + resourceManager.highlightedText( + R.string.account_polkadot_vault_import_start_step_1, + R.string.account_polkadot_vault_import_start_step_1_highlighted + ) + ) + + step( + resourceManager.highlightedText( + R.string.account_polkadot_vault_import_start_step_2, + R.string.account_polkadot_vault_import_start_step_2_highlighted + ) + ) + image(labelRes = null, R.drawable.polkadot_vault_account) + + step( + resourceManager.highlightedText( + R.string.account_polkadot_vault_import_start_step_3, + R.string.account_polkadot_vault_import_start_step_3_highlighted + ) + ) + } + } + + connectPage { + name(resourceManager.getString(R.string.account_import_private_key)) + + instructions { + step( + resourceManager.highlightedText( + R.string.account_polkadot_vault_import_start_step_1, + R.string.account_polkadot_vault_import_start_step_1_highlighted + ) + ) + + step( + resourceManager.highlightedText( + R.string.account_polkadot_vault_import_start_step_2, + R.string.account_polkadot_vault_import_start_step_2_highlighted + ) + ) + image(labelRes = null, R.drawable.polkadot_vault_account) + + step( + resourceManager.highlightedText( + R.string.account_polkadot_vault_import_start_step_3_private, + R.string.account_polkadot_vault_import_start_step_3_private_highlighted + ) + ) + + step( + resourceManager.highlightedText( + R.string.account_polkadot_vault_import_start_step_3, + R.string.account_polkadot_vault_import_start_step_3_highlighted + ) + ) + } + } + + common { + iconRes = R.drawable.ic_polkadot_vault + nameRes = R.string.account_polkadot_vault + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/ParitySignerAccountPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/ParitySignerAccountPayload.kt new file mode 100644 index 0000000..0513dbf --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/ParitySignerAccountPayload.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_impl.domain.utils.ScanSecret +import kotlinx.parcelize.Parcelize + +sealed interface ParitySignerAccountPayload : Parcelable { + + val accountId: ByteArray + + val variant: PolkadotVaultVariant + + @Parcelize + class AsPublic( + override val accountId: ByteArray, + override val variant: PolkadotVaultVariant + ) : ParitySignerAccountPayload + + @Parcelize + class AsSecret( + override val accountId: ByteArray, + override val variant: PolkadotVaultVariant, + val secret: ScanSecretPayload + ) : ParitySignerAccountPayload +} + +sealed interface ScanSecretPayload : Parcelable { + + val data: ByteArray + + @Parcelize + class Seed(override val data: ByteArray) : ScanSecretPayload + + @Parcelize + class EncryptedKey(override val data: ByteArray) : ScanSecretPayload +} + +fun ScanSecretPayload.toDomain(): ScanSecret = when (this) { + is ScanSecretPayload.EncryptedKey -> ScanSecret.EncryptedKeypair(data) + is ScanSecretPayload.Seed -> ScanSecret.Seed(data) +} + +fun ScanSecret.fromDomain(): ScanSecretPayload = when (this) { + is ScanSecret.EncryptedKeypair -> ScanSecretPayload.EncryptedKey(data) + is ScanSecret.Seed -> ScanSecretPayload.Seed(data) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/ParitySignerStartPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/ParitySignerStartPayload.kt new file mode 100644 index 0000000..30eab0e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/ParitySignerStartPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import kotlinx.parcelize.Parcelize + +@Parcelize +class ParitySignerStartPayload( + val variant: PolkadotVaultVariant +) : Parcelable diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/FinishImportParitySignerFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/FinishImportParitySignerFragment.kt new file mode 100644 index 0000000..2bb2c89 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/FinishImportParitySignerFragment.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.finish + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_api.presenatation.account.createName.CreateWalletNameFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload + +class FinishImportParitySignerFragment : CreateWalletNameFragment() { + + companion object { + + private const val PAYLOAD_KEY = "FinishImportParitySignerFragment.Payload" + + fun getBundle(payload: ParitySignerAccountPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .finishImportParitySignerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/FinishImportParitySignerViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/FinishImportParitySignerViewModel.kt new file mode 100644 index 0000000..9256a35 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/FinishImportParitySignerViewModel.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.finish + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.createName.CreateWalletNameViewModel +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.finish.FinishImportParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.toDomain + +class FinishImportParitySignerViewModel( + private val router: AccountRouter, + private val resourceManager: ResourceManager, + private val payload: ParitySignerAccountPayload, + private val accountInteractor: AccountInteractor, + private val interactor: FinishImportParitySignerInteractor +) : CreateWalletNameViewModel(router, resourceManager) { + + override fun proceed(name: String) = launchUnit { + val result = when (payload) { + is ParitySignerAccountPayload.AsPublic -> interactor.createPolkadotVaultWallet( + name, + payload.accountId, + payload.variant + ) + + is ParitySignerAccountPayload.AsSecret -> interactor.createSecretWallet( + name, + secret = payload.secret.toDomain(), + payload.variant + ) + } + + result.onSuccess { continueBasedOnCodeStatus() } + .onFailure(::showError) + } + + private suspend fun continueBasedOnCodeStatus() { + if (accountInteractor.isCodeSet()) { + router.openMain() + } else { + router.openCreatePincode() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/di/FinishImportParitySignerComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/di/FinishImportParitySignerComponent.kt new file mode 100644 index 0000000..857f9e8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/di/FinishImportParitySignerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.finish.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.finish.FinishImportParitySignerFragment + +@Subcomponent( + modules = [ + FinishImportParitySignerModule::class + ] +) +@ScreenScope +interface FinishImportParitySignerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ParitySignerAccountPayload, + ): FinishImportParitySignerComponent + } + + fun inject(fragment: FinishImportParitySignerFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/di/FinishImportParitySignerModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/di/FinishImportParitySignerModule.kt new file mode 100644 index 0000000..d333ae4 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/finish/di/FinishImportParitySignerModule.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.finish.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.paritySigner.ParitySignerAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SubstrateKeypairAddAccountRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.secrets.SeedAddAccountRepository +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.finish.FinishImportParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.finish.RealFinishImportParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.finish.FinishImportParitySignerViewModel + +@Module(includes = [ViewModelModule::class]) +class FinishImportParitySignerModule { + + @Provides + @ScreenScope + fun provideInteractor( + paritySignerAddAccountRepository: ParitySignerAddAccountRepository, + substrateKeypairAddAccountRepository: SubstrateKeypairAddAccountRepository, + seedAddAccountRepository: SeedAddAccountRepository, + accountRepository: AccountRepository + ): FinishImportParitySignerInteractor = RealFinishImportParitySignerInteractor( + paritySignerAddAccountRepository, + substrateKeypairAddAccountRepository, + seedAddAccountRepository, + accountRepository + ) + + @Provides + @IntoMap + @ViewModelKey(FinishImportParitySignerViewModel::class) + fun provideViewModel( + router: AccountRouter, + resourceManager: ResourceManager, + payload: ParitySignerAccountPayload, + accountInteractor: AccountInteractor, + interactor: FinishImportParitySignerInteractor + ): ViewModel { + return FinishImportParitySignerViewModel( + router = router, + resourceManager = resourceManager, + payload = payload, + accountInteractor = accountInteractor, + interactor = interactor + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): FinishImportParitySignerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(FinishImportParitySignerViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/PreviewImportParitySignerFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/PreviewImportParitySignerFragment.kt new file mode 100644 index 0000000..da336e1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/PreviewImportParitySignerFragment.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.preview + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.ChainAccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview.BaseChainAccountsPreviewFragment +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload + +class PreviewImportParitySignerFragment : BaseChainAccountsPreviewFragment(), ChainAccountsAdapter.Handler { + + companion object { + + private const val PAYLOAD_KEY = "PreviewImportParitySignerFragment.Payload" + + fun getBundle(payload: ParitySignerAccountPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountFeatureApi::class.java + ) + .previewImportParitySignerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/PreviewImportParitySignerViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/PreviewImportParitySignerViewModel.kt new file mode 100644 index 0000000..d8787b5 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/PreviewImportParitySignerViewModel.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.preview + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview.BaseChainAccountsPreviewViewModel +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.formatWithPolkadotVaultLabel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.preview.PreviewImportParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.Flow + +class PreviewImportParitySignerViewModel( + private val interactor: PreviewImportParitySignerInteractor, + private val accountRouter: AccountRouter, + private val iconGenerator: AddressIconGenerator, + private val payload: ParitySignerAccountPayload, + private val externalActions: ExternalActions.Presentation, + private val chainRegistry: ChainRegistry, + private val resourceManager: ResourceManager, +) : BaseChainAccountsPreviewViewModel(iconGenerator, externalActions, chainRegistry, accountRouter) { + + override val subtitle: String = resourceManager.formatWithPolkadotVaultLabel(R.string.account_parity_signer_import_preview_description, payload.variant) + + override val chainAccountProjections = flowOf { interactor.deriveSubstrateChainAccounts(payload.accountId) } + .mapList { mapChainAccountPreviewToUi(it) } + .shareInBackground() + + override val buttonState: Flow = flowOf { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + override fun continueClicked() { + accountRouter.openFinishImportParitySigner(payload) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/di/PreviewImportParitySignerComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/di/PreviewImportParitySignerComponent.kt new file mode 100644 index 0000000..5900250 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/di/PreviewImportParitySignerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.preview.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.preview.PreviewImportParitySignerFragment + +@Subcomponent( + modules = [ + PreviewImportParitySignerModule::class + ] +) +@ScreenScope +interface PreviewImportParitySignerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ParitySignerAccountPayload + ): PreviewImportParitySignerComponent + } + + fun inject(fragment: PreviewImportParitySignerFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/di/PreviewImportParitySignerModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/di/PreviewImportParitySignerModule.kt new file mode 100644 index 0000000..e6fad47 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/preview/di/PreviewImportParitySignerModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.preview.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.modules.Caching +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.preview.PreviewImportParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.preview.RealPreviewImportParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.preview.PreviewImportParitySignerViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class PreviewImportParitySignerModule { + + @Provides + @ScreenScope + fun provideInteractor(chainRegistry: ChainRegistry): PreviewImportParitySignerInteractor { + return RealPreviewImportParitySignerInteractor(chainRegistry) + } + + @Provides + @IntoMap + @ViewModelKey(PreviewImportParitySignerViewModel::class) + fun provideViewModel( + interactor: PreviewImportParitySignerInteractor, + accountRouter: AccountRouter, + @Caching iconGenerator: AddressIconGenerator, + payload: ParitySignerAccountPayload, + externalActions: ExternalActions.Presentation, + chainRegistry: ChainRegistry, + resourceManager: ResourceManager + ): ViewModel { + return PreviewImportParitySignerViewModel( + interactor = interactor, + accountRouter = accountRouter, + iconGenerator = iconGenerator, + payload = payload, + externalActions = externalActions, + chainRegistry = chainRegistry, + resourceManager = resourceManager + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PreviewImportParitySignerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PreviewImportParitySignerViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/ScanImportParitySignerFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/ScanImportParitySignerFragment.kt new file mode 100644 index 0000000..46f278d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/ScanImportParitySignerFragment.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.scan + +import android.os.Bundle +import android.view.View + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.scan.ScanQrFragment +import io.novafoundation.nova.common.presentation.scan.ScanView +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentImportParitySignerScanBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload + +class ScanImportParitySignerFragment : ScanQrFragment() { + + companion object { + + private const val PAYLOAD_KEY = "ScanImportParitySignerFragment.Payload" + + fun getBundle(payload: ParitySignerStartPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun createBinding() = FragmentImportParitySignerScanBinding.inflate(layoutInflater) + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .scanImportParitySignerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun applyInsets(rootView: View) { + binder.scanImportParitySignerScanToolbar.applySystemBarInsets() + } + + override fun initViews() { + super.initViews() + + binder.scanImportParitySignerScanToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.scanImportParitySignerScan.setTitle(viewModel.title) + } + + override val scanView: ScanView + get() = binder.scanImportParitySignerScan +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/ScanImportParitySignerViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/ScanImportParitySignerViewModel.kt new file mode 100644 index 0000000..38238f8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/ScanImportParitySignerViewModel.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.scan + +import io.novafoundation.nova.common.presentation.scan.ScanQrViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.formatWithPolkadotVaultLabel +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.scan.ParitySignerAccount +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.scan.ScanImportParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.fromDomain + +class ScanImportParitySignerViewModel( + private val router: AccountRouter, + permissionsAsker: PermissionsAsker.Presentation, + private val interactor: ScanImportParitySignerInteractor, + private val resourceManager: ResourceManager, + private val payload: ParitySignerStartPayload, +) : ScanQrViewModel(permissionsAsker) { + + val title = resourceManager.formatWithPolkadotVaultLabel(R.string.account_parity_signer_scan_from, payload.variant) + + fun backClicked() { + router.back() + } + + override suspend fun scanned(result: String) { + val parseResult = interactor.decodeScanResult(result) + + parseResult + .onSuccess(::openPreview) + .onFailure { + val message = resourceManager.formatWithPolkadotVaultLabel(R.string.account_parity_signer_import_scan_invalid_qr, payload.variant) + showToast(message) + + resetScanningThrottled() + } + } + + private fun openPreview(signerAccount: ParitySignerAccount) { + val payload = when (signerAccount) { + is ParitySignerAccount.Public -> ParitySignerAccountPayload.AsPublic( + accountId = signerAccount.accountId, + variant = payload.variant + ) + + is ParitySignerAccount.Secret -> ParitySignerAccountPayload.AsSecret( + accountId = signerAccount.accountId, + variant = payload.variant, + secret = signerAccount.secret.fromDomain() + ) + } + + router.openPreviewImportParitySigner(payload) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/di/ScanImportParitySignerComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/di/ScanImportParitySignerComponent.kt new file mode 100644 index 0000000..178399c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/di/ScanImportParitySignerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.scan.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.scan.ScanImportParitySignerFragment + +@Subcomponent( + modules = [ + ScanImportParitySignerModule::class + ] +) +@ScreenScope +interface ScanImportParitySignerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ParitySignerStartPayload, + ): ScanImportParitySignerComponent + } + + fun inject(fragment: ScanImportParitySignerFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/di/ScanImportParitySignerModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/di/ScanImportParitySignerModule.kt new file mode 100644 index 0000000..6c0a9ce --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/scan/di/ScanImportParitySignerModule.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.scan.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.scan.PolkadotVaultScanFormat +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.scan.RealScanImportParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.connect.scan.ScanImportParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.scan.ScanImportParitySignerViewModel +import io.novafoundation.nova.feature_account_impl.domain.utils.SecretQrFormat +import io.novasama.substrate_sdk_android.encrypt.qr.formats.SubstrateQrFormat + +@Module(includes = [ViewModelModule::class]) +class ScanImportParitySignerModule { + + @Provides + fun providePolkadotVaultScanFormat() = PolkadotVaultScanFormat( + substrateQrFormat = SubstrateQrFormat(), + secretQrFormat = SecretQrFormat() + ) + + @Provides + fun provideInteractor(polkadotVaultScanFormat: PolkadotVaultScanFormat): ScanImportParitySignerInteractor { + return RealScanImportParitySignerInteractor(polkadotVaultScanFormat) + } + + @Provides + fun providePermissionAsker( + permissionsAskerFactory: PermissionsAskerFactory, + fragment: Fragment, + router: AccountRouter + ) = permissionsAskerFactory.createReturnable(fragment, router) + + @Provides + @IntoMap + @ViewModelKey(ScanImportParitySignerViewModel::class) + fun provideViewModel( + router: AccountRouter, + permissionsAsker: PermissionsAsker.Presentation, + resourceManager: ResourceManager, + interactor: ScanImportParitySignerInteractor, + payload: ParitySignerStartPayload, + ): ViewModel { + return ScanImportParitySignerViewModel( + router = router, + permissionsAsker = permissionsAsker, + interactor = interactor, + resourceManager = resourceManager, + payload = payload + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ScanImportParitySignerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ScanImportParitySignerViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/StartImportParitySignerAdapter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/StartImportParitySignerAdapter.kt new file mode 100644 index 0000000..50443b0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/StartImportParitySignerAdapter.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.start + +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.list.instruction.InstructionAdapter +import io.novafoundation.nova.common.list.instruction.InstructionItem +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_impl.databinding.ItemImportParitySignerPageBinding + +class ParitySignerPageModel( + val modeName: String, + val guideItems: List +) + +class StartImportParitySignerPagerAdapter( + private val pages: List +) : RecyclerView.Adapter() { + + @DrawableRes + private var targetImage: Int? = null + + override fun getItemCount(): Int { + return pages.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StartImportParitySignerViewHolder { + val binder = ItemImportParitySignerPageBinding.inflate(parent.inflater(), parent, false) + return StartImportParitySignerViewHolder(binder) + } + + override fun onBindViewHolder(holder: StartImportParitySignerViewHolder, position: Int) { + holder.bind(pages[position], targetImage) + } + + fun getPageTitle(position: Int): CharSequence { + return pages[position].modeName + } + + fun setTargetImage(@DrawableRes targetImage: Int) { + this.targetImage = targetImage + } +} + +class StartImportParitySignerViewHolder( + private val binder: ItemImportParitySignerPageBinding +) : ViewHolder(binder.root) { + + private val adapter = InstructionAdapter() + + init { + binder.startImportParitySignerInstruction.adapter = adapter + } + + fun bind(page: ParitySignerPageModel, @DrawableRes targetImage: Int?) { + adapter.submitList(page.guideItems) + targetImage?.let { binder.startImportParitySignerConnectOverview.setTargetImage(it) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/StartImportParitySignerFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/StartImportParitySignerFragment.kt new file mode 100644 index 0000000..4a59079 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/StartImportParitySignerFragment.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.start + +import android.os.Bundle +import androidx.core.view.isVisible +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setupWithViewPager2 +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentImportParitySignerStartBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload + +class StartImportParitySignerFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD_KEY = "StartImportParitySignerFragment.Payload" + + fun getBundle(payload: ParitySignerStartPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + private val pageAdapter by lazy(LazyThreadSafetyMode.NONE) { StartImportParitySignerPagerAdapter(viewModel.pages) } + + override fun createBinding() = FragmentImportParitySignerStartBinding.inflate(layoutInflater) + + override fun initViews() { + binder.startImportParitySignerToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.startImportParitySignerScanQrCode.setOnClickListener { viewModel.scanQrCodeClicked() } + + binder.startImportParitySignerMode.isVisible = pageAdapter.itemCount > 1 + binder.startImportParitySignerPages.adapter = pageAdapter + binder.startImportParitySignerMode.setupWithViewPager2(binder.startImportParitySignerPages, pageAdapter::getPageTitle) + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .startImportParitySignerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: StartImportParitySignerViewModel) { + binder.startImportParitySignerTitle.text = viewModel.title + pageAdapter.setTargetImage(viewModel.polkadotVaultVariantIcon) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/StartImportParitySignerViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/StartImportParitySignerViewModel.kt new file mode 100644 index 0000000..ad13d86 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/StartImportParitySignerViewModel.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.start + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.list.instruction.InstructionItem +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfig.ConnectPage +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.formatWithPolkadotVaultLabel +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload + +class StartImportParitySignerViewModel( + private val router: AccountRouter, + private val payload: ParitySignerStartPayload, + private val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + private val resourceManager: ResourceManager +) : BaseViewModel() { + + private val variantConfig = polkadotVaultVariantConfigProvider.variantConfigFor(payload.variant) + + val pages = createPages() + + val polkadotVaultVariantIcon = variantConfig.common.iconRes + + val title = resourceManager.formatWithPolkadotVaultLabel(R.string.account_parity_signer_import_start_title, payload.variant) + + fun backClicked() { + router.back() + } + + fun scanQrCodeClicked() { + router.openScanImportParitySigner(payload) + } + + private fun createPages(): List { + return variantConfig.pages.map { + it.instructions + ParitySignerPageModel( + modeName = it.pageName, + guideItems = it.instructions.map { instruction -> + when (instruction) { + is ConnectPage.Instruction.Image -> InstructionItem.Image(instruction.imageRes, instruction.label) + is ConnectPage.Instruction.Step -> InstructionItem.Step(instruction.index, instruction.content) + } + } + ) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/di/StartImportParitySignerComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/di/StartImportParitySignerComponent.kt new file mode 100644 index 0000000..83f406f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/di/StartImportParitySignerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.start.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.start.StartImportParitySignerFragment + +@Subcomponent( + modules = [ + StartImportParitySignerModule::class + ] +) +@ScreenScope +interface StartImportParitySignerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ParitySignerStartPayload, + ): StartImportParitySignerComponent + } + + fun inject(fragment: StartImportParitySignerFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/di/StartImportParitySignerModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/di/StartImportParitySignerModule.kt new file mode 100644 index 0000000..b628217 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/connect/start/di/StartImportParitySignerModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.start.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.ParitySignerStartPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.connect.start.StartImportParitySignerViewModel + +@Module(includes = [ViewModelModule::class]) +class StartImportParitySignerModule { + + @Provides + @IntoMap + @ViewModelKey(StartImportParitySignerViewModel::class) + fun provideViewModel( + router: AccountRouter, + payload: ParitySignerStartPayload, + polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + resourceManager: ResourceManager + ): ViewModel { + return StartImportParitySignerViewModel( + router = router, + payload = payload, + polkadotVaultVariantConfigProvider = polkadotVaultVariantConfigProvider, + resourceManager = resourceManager + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): StartImportParitySignerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StartImportParitySignerViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/common/QrCodeExpiredPresentable.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/common/QrCodeExpiredPresentable.kt new file mode 100644 index 0000000..980fe78 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/common/QrCodeExpiredPresentable.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.common + +import android.widget.TextView +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.dialog.errorDialog +import io.novafoundation.nova.common.view.stopTimer +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.polkadotVaultLabelFor +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.sign.cancelled +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.runtime.extrinsic.ValidityPeriod +import io.novafoundation.nova.runtime.extrinsic.startExtrinsicValidityTimer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +interface QrCodeExpiredPresentable { + + val acknowledgeExpired: ActionAwaitableMixin + + interface Presentation : QrCodeExpiredPresentable { + + suspend fun showQrCodeExpired(validityPeriod: ValidityPeriod) + } +} + +class QrCodeExpiredPresentableFactory( + private val resourceManager: ResourceManager, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val router: AccountRouter, + private val responder: SignInterScreenCommunicator, +) { + + fun create( + request: SignInterScreenCommunicator.Request, + variant: PolkadotVaultVariant + ): QrCodeExpiredPresentable.Presentation = RealQrCodeExpiredPresentable( + resourceManager = resourceManager, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + router = router, + responder = responder, + request = request, + variant = variant + ) +} + +private class RealQrCodeExpiredPresentable( + private val resourceManager: ResourceManager, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val router: AccountRouter, + private val responder: SignInterScreenCommunicator, + private val request: SignInterScreenCommunicator.Request, + private val variant: PolkadotVaultVariant, +) : QrCodeExpiredPresentable.Presentation { + + override val acknowledgeExpired: ActionAwaitableMixin.Presentation = actionAwaitableMixinFactory.create() + + override suspend fun showQrCodeExpired(validityPeriod: ValidityPeriod) { + val message = withContext(Dispatchers.Default) { + val validityPeriodMillis = validityPeriod.period.millis + val durationFormatted = resourceManager.formatDuration(validityPeriodMillis.milliseconds, estimated = false) + val polkadotVaultVariantLabel = resourceManager.polkadotVaultLabelFor(variant) + + resourceManager.getString(R.string.account_parity_signer_sign_qr_code_expired_descrition, durationFormatted, polkadotVaultVariantLabel) + } + + acknowledgeExpired.awaitAction(message) + + responder.respond(request.cancelled()) + router.finishParitySignerFlow() + } +} + +fun BaseFragment<*, *>.setupQrCodeExpiration( + validityPeriodFlow: Flow, + qrCodeExpiredPresentable: QrCodeExpiredPresentable, + timerView: TextView, + onTimerFinished: () -> Unit +) { + validityPeriodFlow.observe { validityPeriod -> + if (validityPeriod != null) { + timerView.makeVisible() + + viewLifecycleOwner.startExtrinsicValidityTimer( + validityPeriod = validityPeriod, + timerFormat = R.string.account_parity_signer_sign_qr_code_valid_format, + timerView = timerView, + onTimerFinished = { + onTimerFinished() + + timerView.setText(R.string.account_parity_signer_sign_qr_code_expired) + } + ) + } else { + timerView.stopTimer() + timerView.makeGone() + } + } + + qrCodeExpiredPresentable.acknowledgeExpired.awaitableActionLiveData.observeEvent { + errorDialog( + context = requireContext(), + onConfirm = { it.onSuccess(Unit) } + ) { + setTitle(R.string.account_parity_signer_sign_qr_code_expired) + setMessage(it.payload) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/ScanSignParitySignerFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/ScanSignParitySignerFragment.kt new file mode 100644 index 0000000..7f4c8c6 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/ScanSignParitySignerFragment.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan + +import android.os.Bundle + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.scan.ScanQrFragment +import io.novafoundation.nova.common.presentation.scan.ScanView +import io.novafoundation.nova.common.view.dialog.errorDialog +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentSignParitySignerScanBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.common.setupQrCodeExpiration +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model.ScanSignParitySignerPayload + +class ScanSignParitySignerFragment : ScanQrFragment() { + + companion object { + + private const val PAYLOAD_KEY = "ScanSignParitySignerFragment.Payload" + + fun getBundle(payload: ScanSignParitySignerPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun createBinding() = FragmentSignParitySignerScanBinding.inflate(layoutInflater) + + override val scanView: ScanView + get() = binder.signParitySignerScanScanner + + override fun initViews() { + super.initViews() + + binder.signParitySignerScanToolbar.setHomeButtonListener { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .scanSignParitySignerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: ScanSignParitySignerViewModel) { + super.subscribe(viewModel) + + binder.signParitySignerScanToolbar.setTitle(viewModel.title) + scanView.setTitle(viewModel.scanLabel) + + setupQrCodeExpiration( + validityPeriodFlow = viewModel.validityPeriodFlow, + qrCodeExpiredPresentable = viewModel.qrCodeExpiredPresentable, + timerView = scanView.subtitle, + onTimerFinished = viewModel::timerFinished + ) + + viewModel.invalidQrConfirmation.awaitableActionLiveData.observeEvent { + errorDialog( + context = requireContext(), + onConfirm = { it.onSuccess(Unit) }, + confirmTextRes = R.string.common_try_again + ) { + setTitle(R.string.common_invalid_qr) + setMessage(R.string.account_parity_signer_sign_qr_invalid_message) + } + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/ScanSignParitySignerViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/ScanSignParitySignerViewModel.kt new file mode 100644 index 0000000..2a27a8c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/ScanSignParitySignerViewModel.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan + +import android.util.Log +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.awaitAction +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.presentation.scan.ScanQrViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.getOrThrow +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.formatWithPolkadotVaultLabel +import io.novafoundation.nova.feature_account_api.presenatation.sign.signed +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignCommunicator +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.scan.ScanSignParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.common.QrCodeExpiredPresentableFactory +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model.ScanSignParitySignerPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model.mapValidityPeriodFromParcel +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper.Sr25519 +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +class ScanSignParitySignerViewModel( + private val router: AccountRouter, + permissionsAsker: PermissionsAsker.Presentation, + private val interactor: ScanSignParitySignerInteractor, + private val signSharedState: SigningSharedState, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val responder: PolkadotVaultVariantSignCommunicator, + private val payload: ScanSignParitySignerPayload, + private val qrCodeExpiredPresentableFactory: QrCodeExpiredPresentableFactory, + private val resourceManager: ResourceManager, +) : ScanQrViewModel(permissionsAsker) { + + val invalidQrConfirmation = actionAwaitableMixinFactory.confirmingAction() + + val qrCodeExpiredPresentable = qrCodeExpiredPresentableFactory.create(payload.request, payload.variant) + + val title = resourceManager.formatWithPolkadotVaultLabel(R.string.account_parity_signer_sign_title, payload.variant) + val scanLabel = resourceManager.formatWithPolkadotVaultLabel(R.string.account_parity_signer_scan_from, payload.variant) + + private val validityPeriod = payload.validityPeriod?.let(::mapValidityPeriodFromParcel) + val validityPeriodFlow = flowOf(validityPeriod) + + fun backClicked() { + router.back() + } + + fun timerFinished() { + launch { + qrCodeExpiredPresentable.showQrCodeExpired(validityPeriod!!) + } + } + + override suspend fun scanned(result: String) { + interactor.encodeAndVerifySignature(signSharedState.getOrThrow().payload, result) + .onSuccess(::respondResult) + .onFailure { + Log.e("ScanSignParitySignerViewModel", "Failed to verify signature", it) + + invalidQrConfirmation.awaitAction() + + resetScanning() + } + } + + private fun respondResult(signature: ByteArray) { + val wrapper = Sr25519(signature) + val response = payload.request.signed(wrapper) + responder.respond(response) + + router.finishParitySignerFlow() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/di/ScanSignParitySignerComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/di/ScanSignParitySignerComponent.kt new file mode 100644 index 0000000..41741b1 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/di/ScanSignParitySignerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.ScanSignParitySignerFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model.ScanSignParitySignerPayload + +@Subcomponent( + modules = [ + ScanSignParitySignerModule::class + ] +) +@ScreenScope +interface ScanSignParitySignerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ScanSignParitySignerPayload + ): ScanSignParitySignerComponent + } + + fun inject(fragment: ScanSignParitySignerFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/di/ScanSignParitySignerModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/di/ScanSignParitySignerModule.kt new file mode 100644 index 0000000..7ec3610 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/di/ScanSignParitySignerModule.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignCommunicator +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.scan.RealScanSignParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.scan.ScanSignParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.common.QrCodeExpiredPresentableFactory +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.ScanSignParitySignerViewModel +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model.ScanSignParitySignerPayload + +@Module(includes = [ViewModelModule::class]) +class ScanSignParitySignerModule { + + @Provides + fun provideInteractor(): ScanSignParitySignerInteractor = RealScanSignParitySignerInteractor() + + @Provides + fun providePermissionAsker( + permissionsAskerFactory: PermissionsAskerFactory, + fragment: Fragment, + router: AccountRouter + ) = permissionsAskerFactory.createReturnable(fragment, router) + + @Provides + @IntoMap + @ViewModelKey(ScanSignParitySignerViewModel::class) + fun provideViewModel( + router: AccountRouter, + permissionsAsker: PermissionsAsker.Presentation, + interactor: ScanSignParitySignerInteractor, + signSharedState: SigningSharedState, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + communicator: PolkadotVaultVariantSignCommunicator, + payload: ScanSignParitySignerPayload, + qrCodeExpiredPresentableFactory: QrCodeExpiredPresentableFactory, + resourceManager: ResourceManager + ): ViewModel { + return ScanSignParitySignerViewModel( + router = router, + permissionsAsker = permissionsAsker, + interactor = interactor, + signSharedState = signSharedState, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + responder = communicator, + payload = payload, + qrCodeExpiredPresentableFactory = qrCodeExpiredPresentableFactory, + resourceManager = resourceManager + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ScanSignParitySignerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ScanSignParitySignerViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/model/ScanSignParitySignerPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/model/ScanSignParitySignerPayload.kt new file mode 100644 index 0000000..caf2d91 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/scan/model/ScanSignParitySignerPayload.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model + +import android.os.Parcelable +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator +import io.novafoundation.nova.runtime.extrinsic.ValidityPeriod +import kotlinx.parcelize.Parcelize + +@Parcelize +class ScanSignParitySignerPayload( + val request: SignInterScreenCommunicator.Request, + val validityPeriod: ValidityPeriodParcel?, + val variant: PolkadotVaultVariant, +) : Parcelable + +@Parcelize +class ValidityPeriodParcel( + val periodInMillis: Long, + val calculatedAt: Long +) : Parcelable + +fun mapValidityPeriodToParcel(validityPeriod: ValidityPeriod): ValidityPeriodParcel { + return ValidityPeriodParcel( + validityPeriod.period.millis, + validityPeriod.period.millisCalculatedAt + ) +} + +fun mapValidityPeriodFromParcel(validityPeriodParcel: ValidityPeriodParcel): ValidityPeriod { + return ValidityPeriod( + TimerValue( + millis = validityPeriodParcel.periodInMillis, + millisCalculatedAt = validityPeriodParcel.calculatedAt + ) + ) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/ShowSignParitySignerFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/ShowSignParitySignerFragment.kt new file mode 100644 index 0000000..5a7d856 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/ShowSignParitySignerFragment.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.setSequence +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentSignParitySignerShowBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.common.setupQrCodeExpiration + +class ShowSignParitySignerFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD_KEY = "ShowSignParitySignerFragment.Payload" + + fun getBundle(payload: ShowSignParitySignerPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun createBinding() = FragmentSignParitySignerShowBinding.inflate(layoutInflater) + + override fun initViews() { + setupExternalActions(viewModel) + + onBackPressed { viewModel.backClicked() } + + binder.signParitySignerShowToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.signParitySignerShowQr.background = requireContext().getRoundedCornerDrawable(fillColorRes = R.color.qr_code_background) + binder.signParitySignerShowQr.clipToOutline = true // for round corners + + binder.signParitySignerShowAddress.setWholeClickListener { viewModel.addressClicked() } + + binder.signParitySignerShowHaveError.setOnClickListener { viewModel.troublesClicked() } + binder.signParitySignerShowContinue.setOnClickListener { viewModel.continueClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .showSignParitySignerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: ShowSignParitySignerViewModel) { + setupQrCodeExpiration( + validityPeriodFlow = viewModel.validityPeriod, + qrCodeExpiredPresentable = viewModel.qrCodeExpiredPresentable, + timerView = binder.signParitySignerShowTimer, + onTimerFinished = viewModel::timerFinished + ) + + viewModel.qrCodeSequence.observe(binder.signParitySignerShowQr::setSequence) + + viewModel.addressModel.observe { + binder.signParitySignerShowAddress.setLabel(it.nameOrAddress) + binder.signParitySignerShowAddress.setMessage(it.address) + binder.signParitySignerShowAddress.setPrimaryIcon(it.image) + } + + binder.signParitySignerShowToolbar.setTitle(viewModel.title) + binder.signParitySignerShowHaveError.text = viewModel.errorButtonLabel + + setupModeSwitcher(viewModel) + } + + private fun setupModeSwitcher(viewModel: ShowSignParitySignerViewModel) { + binder.signParitySignerShowMode.setVisible(viewModel.supportsMultipleSigningModes) + + if (!viewModel.supportsMultipleSigningModes) return + + initTabs() + + binder.signParitySignerShowMode bindTo viewModel.selectedSigningModeIndex + } + + private fun initTabs() = with(binder.signParitySignerShowMode) { + addTab(newTab().setText(R.string.account_parity_signer_show_mode_new)) + addTab(newTab().setText(R.string.account_parity_signer_show_mode_legacy)) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/ShowSignParitySignerPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/ShowSignParitySignerPayload.kt new file mode 100644 index 0000000..d8310e0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/ShowSignParitySignerPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator +import kotlinx.parcelize.Parcelize + +@Parcelize +class ShowSignParitySignerPayload( + val request: SignInterScreenCommunicator.Request, + val polkadotVaultVariant: PolkadotVaultVariant, +) : Parcelable diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/ShowSignParitySignerViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/ShowSignParitySignerViewModel.kt new file mode 100644 index 0000000..b9d0bc3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/ShowSignParitySignerViewModel.kt @@ -0,0 +1,162 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.common.utils.SharedState +import io.novafoundation.nova.common.utils.cycleMultiple +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.getOrThrow +import io.novafoundation.nova.common.utils.mediatorLiveData +import io.novafoundation.nova.common.utils.reversed +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.utils.updateFrom +import io.novafoundation.nova.feature_account_api.data.signer.SeparateFlowSignerState +import io.novafoundation.nova.feature_account_api.data.signer.SignerPayload +import io.novafoundation.nova.feature_account_api.data.signer.accountId +import io.novafoundation.nova.feature_account_api.data.signer.chainId +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.formatWithPolkadotVaultLabel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.sign.cancelled +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignCommunicator +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.show.ParitySignerSignMode +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.show.ShowSignParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.common.QrCodeExpiredPresentableFactory +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model.ScanSignParitySignerPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.scan.model.mapValidityPeriodToParcel +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase +import io.novafoundation.nova.runtime.extrinsic.ValidityPeriod +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch + +private val MODE_TO_INDEX = mapOf( + ParitySignerSignMode.WITH_METADATA_PROOF to 0, + ParitySignerSignMode.LEGACY to 1 +) + +private val INDEX_TO_MODE = MODE_TO_INDEX.reversed() + +class ShowSignParitySignerViewModel( + private val router: AccountRouter, + private val interactor: ShowSignParitySignerInteractor, + private val signSharedState: SharedState, + private val qrCodeGenerator: QrCodeGenerator, + private val responder: PolkadotVaultVariantSignCommunicator, + private val payload: ShowSignParitySignerPayload, + private val chainRegistry: ChainRegistry, + private val addressIconGenerator: AddressIconGenerator, + private val addressDisplayUseCase: AddressDisplayUseCase, + private val externalActions: ExternalActions.Presentation, + private val qrCodeExpiredPresentableFactory: QrCodeExpiredPresentableFactory, + private val extrinsicValidityUseCase: ExtrinsicValidityUseCase, + private val resourceManager: ResourceManager, + private val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, +) : BaseViewModel(), ExternalActions by externalActions, Browserable { + + private val request = payload.request + + override val openBrowserEvent = mediatorLiveData { updateFrom(externalActions.openBrowserEvent) } + + val qrCodeExpiredPresentable = qrCodeExpiredPresentableFactory.create(request, payload.polkadotVaultVariant) + + val chain = flowOf { + val signPayload = signSharedState.getOrThrow() + val chainId = signPayload.payload.chainId() + + chainRegistry.getChain(chainId) + }.shareInBackground() + + val supportsMultipleSigningModes = polkadotVaultVariantConfigProvider.variantConfigFor(payload.polkadotVaultVariant) + .sign.supportsProofSigning + + val selectedSigningModeIndex = singleReplaySharedFlow() + + private val selectedSigningMode = selectedSigningModeIndex.map { INDEX_TO_MODE.getValue(it) } + .shareInBackground() + + val qrCodeSequence = selectedSigningMode.mapLatest { + val signPayload = signSharedState.getOrThrow() + + val frames = interactor.qrCodeContent(signPayload.payload, it).frames + + frames.map { qrCodeGenerator.generateQrBitmap(it) }.cycleMultiple() + }.shareInBackground() + + val addressModel = chain.map { chain -> + val signPayload = signSharedState.getOrThrow() + + addressIconGenerator.createAccountAddressModel(chain, signPayload.payload.accountId(), addressDisplayUseCase) + }.shareInBackground() + + val validityPeriod = flowOf { determineSigningValidityPeriod() } + .shareInBackground() + + val title = resourceManager.formatWithPolkadotVaultLabel(R.string.account_parity_signer_sign_title, payload.polkadotVaultVariant) + + val errorButtonLabel = resourceManager.formatWithPolkadotVaultLabel(R.string.account_parity_signer_sign_have_error, payload.polkadotVaultVariant) + + init { + setInitialSigningMode() + } + + fun backClicked() { + responder.respond(request.cancelled()) + + router.back() + } + + fun continueClicked() = launch { + val validityPeriodParcel = validityPeriod.first()?.let(::mapValidityPeriodToParcel) + val payload = ScanSignParitySignerPayload(request, validityPeriodParcel, payload.polkadotVaultVariant) + + router.openScanParitySignerSignature(payload) + } + + fun troublesClicked() = launch { + val variantConfig = polkadotVaultVariantConfigProvider.variantConfigFor(payload.polkadotVaultVariant) + openBrowserEvent.value = variantConfig.sign.troubleShootingLink.event() + } + + fun timerFinished() { + launch { + qrCodeExpiredPresentable.showQrCodeExpired(validityPeriod.first()!!) + } + } + + fun addressClicked() = launch { + val address = addressModel.first().address + val chain = chain.first() + + externalActions.showAddressActions(address, chain) + } + + private suspend fun determineSigningValidityPeriod(): ValidityPeriod? { + return when (val payload = signSharedState.getOrThrow().payload) { + is SignerPayload.Extrinsic -> extrinsicValidityUseCase.extrinsicValidityPeriod(payload.extrinsic) + is SignerPayload.Raw -> null + } + } + + private fun setInitialSigningMode() { + val mode = if (supportsMultipleSigningModes) { + ParitySignerSignMode.WITH_METADATA_PROOF + } else { + ParitySignerSignMode.LEGACY + } + val modeIndex = MODE_TO_INDEX.getValue(mode) + + launch { selectedSigningModeIndex.emit(modeIndex) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/di/ShowSignParitySignerComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/di/ShowSignParitySignerComponent.kt new file mode 100644 index 0000000..3d4a119 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/di/ShowSignParitySignerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show.ShowSignParitySignerFragment +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show.ShowSignParitySignerPayload + +@Subcomponent( + modules = [ + ShowSignParitySignerModule::class + ] +) +@ScreenScope +interface ShowSignParitySignerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ShowSignParitySignerPayload + ): ShowSignParitySignerComponent + } + + fun inject(fragment: ShowSignParitySignerFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/di/ShowSignParitySignerModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/di/ShowSignParitySignerModule.kt new file mode 100644 index 0000000..36c1bad --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/paritySigner/sign/show/di/ShowSignParitySignerModule.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.common.utils.SharedState +import io.novafoundation.nova.feature_account_api.data.signer.SeparateFlowSignerState +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_impl.data.signer.paritySigner.PolkadotVaultVariantSignCommunicator +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.show.RealShowSignParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.domain.paritySigner.sign.show.ShowSignParitySignerInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.common.QrCodeExpiredPresentableFactory +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show.ShowSignParitySignerPayload +import io.novafoundation.nova.feature_account_impl.presentation.paritySigner.sign.show.ShowSignParitySignerViewModel +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class ShowSignParitySignerModule { + + @Provides + @ScreenScope + fun provideInteractor( + shortenerService: MetadataShortenerService + ): ShowSignParitySignerInteractor = RealShowSignParitySignerInteractor(shortenerService) + + @Provides + @IntoMap + @ViewModelKey(ShowSignParitySignerViewModel::class) + fun provideViewModel( + interactor: ShowSignParitySignerInteractor, + signSharedState: SharedState, + qrCodeGenerator: QrCodeGenerator, + communicator: PolkadotVaultVariantSignCommunicator, + payload: ShowSignParitySignerPayload, + chainRegistry: ChainRegistry, + addressIconGenerator: AddressIconGenerator, + addressDisplayUseCase: AddressDisplayUseCase, + router: AccountRouter, + externalActions: ExternalActions.Presentation, + qrCodeExpiredPresentableFactory: QrCodeExpiredPresentableFactory, + extrinsicValidityUseCase: ExtrinsicValidityUseCase, + resourceManager: ResourceManager, + polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider, + ): ViewModel { + return ShowSignParitySignerViewModel( + router = router, + interactor = interactor, + signSharedState = signSharedState, + qrCodeGenerator = qrCodeGenerator, + responder = communicator, + payload = payload, + chainRegistry = chainRegistry, + addressIconGenerator = addressIconGenerator, + addressDisplayUseCase = addressDisplayUseCase, + externalActions = externalActions, + polkadotVaultVariantConfigProvider = polkadotVaultVariantConfigProvider, + qrCodeExpiredPresentableFactory = qrCodeExpiredPresentableFactory, + extrinsicValidityUseCase = extrinsicValidityUseCase, + resourceManager = resourceManager + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ShowSignParitySignerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ShowSignParitySignerViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/PinCodeAction.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/PinCodeAction.kt new file mode 100644 index 0000000..08e16e3 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/PinCodeAction.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_account_impl.presentation.pincode + +import android.os.Parcelable +import androidx.annotation.StringRes +import io.novafoundation.nova.common.navigation.DelayedNavigation +import io.novafoundation.nova.feature_account_impl.R +import kotlinx.parcelize.Parcelize + +@Parcelize +class ToolbarConfiguration(@StringRes val titleRes: Int? = null, val backVisible: Boolean = false) : Parcelable + +sealed class PinCodeAction(open val toolbarConfiguration: ToolbarConfiguration) : Parcelable { + + @Parcelize + class Create(val delayedNavigation: DelayedNavigation) : + PinCodeAction(ToolbarConfiguration(R.string.pincode_title_create, false)) + + @Parcelize + open class Check( + open val delayedNavigation: DelayedNavigation, + override val toolbarConfiguration: ToolbarConfiguration + ) : PinCodeAction(toolbarConfiguration) + + @Parcelize + class CheckAfterInactivity( + override val delayedNavigation: DelayedNavigation, + override val toolbarConfiguration: ToolbarConfiguration + ) : Check(delayedNavigation, toolbarConfiguration) + + @Parcelize + object Change : PinCodeAction(ToolbarConfiguration(R.string.profile_pincode_change_title, true)) + + @Parcelize + class TwoFactorVerification(val useBiometryIfEnabled: Boolean = true) : PinCodeAction(ToolbarConfiguration(titleRes = null, backVisible = true)) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/PinCodeViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/PinCodeViewModel.kt new file mode 100644 index 0000000..bee3c79 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/PinCodeViewModel.kt @@ -0,0 +1,241 @@ +package io.novafoundation.nova.feature_account_impl.presentation.pincode + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingOrDenyingAction +import io.novafoundation.nova.common.mixin.actionAwaitable.fromRes +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.biometry.BiometricResponse +import io.novafoundation.nova.common.sequrity.biometry.BiometricService +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationExecutor +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import io.novafoundation.nova.common.vibration.DeviceVibrator +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.common.sequrity.biometry.mapBiometricErrors +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class PinCodeViewModel( + private val interactor: AccountInteractor, + private val router: AccountRouter, + private val deviceVibrator: DeviceVibrator, + private val resourceManager: ResourceManager, + private val backgroundAccessObserver: BackgroundAccessObserver, + private val twoFactorVerificationExecutor: TwoFactorVerificationExecutor, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val biometricService: BiometricService, + val pinCodeAction: PinCodeAction +) : BaseViewModel() { + + sealed class ScreenState { + object Creating : ScreenState() + data class Confirmation(val codeToConfirm: String) : ScreenState() + data class Checking(val useBiometry: Boolean) : ScreenState() + } + + val confirmationAwaitableAction = actionAwaitableMixinFactory.confirmingOrDenyingAction() + + private val _homeButtonVisibilityLiveData = MutableLiveData(pinCodeAction.toolbarConfiguration.backVisible) + val homeButtonVisibilityLiveData: LiveData = _homeButtonVisibilityLiveData + + private val _resetInputEvent = MutableLiveData>() + val resetInputEvent: LiveData> = _resetInputEvent + + private val _matchingPincodeErrorEvent = MutableLiveData>() + val matchingPincodeErrorEvent: LiveData> = _matchingPincodeErrorEvent + + private val _showBiometryEvent = MutableLiveData>() + val showFingerPrintEvent: LiveData> = _showBiometryEvent + + val biometricEvents = biometricService.biometryServiceResponseFlow + .mapNotNull { mapBiometricErrors(resourceManager, it) } + .shareInBackground() + + private var currentState: ScreenState? = null + + val isBackRoutingBlocked: Boolean + get() = pinCodeAction is PinCodeAction.CheckAfterInactivity + + init { + handleBiometryServiceEvents() + } + + fun startAuth() { + when (pinCodeAction) { + is PinCodeAction.Create -> { + currentState = ScreenState.Creating + } + is PinCodeAction.Check, + is PinCodeAction.Change -> { + currentState = ScreenState.Checking(true) + _showBiometryEvent.value = Event(biometricService.isBiometricReady() && biometricService.isEnabled()) + } + is PinCodeAction.TwoFactorVerification -> { + currentState = ScreenState.Checking(pinCodeAction.useBiometryIfEnabled) + if (pinCodeAction.useBiometryIfEnabled) { + _showBiometryEvent.value = Event(biometricService.isBiometricReady() && biometricService.isEnabled()) + } + } + } + } + + fun pinCodeEntered(pin: String) { + when (currentState) { + is ScreenState.Creating -> tempCodeEntered(pin) + is ScreenState.Confirmation -> matchPinCodeWithCodeToConfirm(pin, (currentState as ScreenState.Confirmation).codeToConfirm) + is ScreenState.Checking -> checkPinCode(pin) + null -> {} + } + } + + private fun tempCodeEntered(pin: String) { + _resetInputEvent.value = Event(resourceManager.getString(R.string.pincode_confirm_your_pin_code)) + _homeButtonVisibilityLiveData.value = true + currentState = ScreenState.Confirmation(pin) + } + + private fun matchPinCodeWithCodeToConfirm(pinCode: String, codeToConfirm: String) { + if (codeToConfirm == pinCode) { + registerPinCode(pinCode) + } else { + deviceVibrator.makeShortVibration() + _matchingPincodeErrorEvent.value = Event(Unit) + } + } + + private fun registerPinCode(code: String) { + viewModelScope.launch { + interactor.savePin(code) + + if (biometricService.isBiometricReady() && pinCodeAction is PinCodeAction.Create) { + askForBiometry() + } + + authSuccess() + } + } + + private fun checkPinCode(code: String) { + viewModelScope.launch { + val isCorrect = interactor.isPinCorrect(code) + + if (isCorrect) { + authSuccess() + } else { + deviceVibrator.makeShortVibration() + _matchingPincodeErrorEvent.value = Event(Unit) + } + } + } + + fun backPressed() { + when (currentState) { + is ScreenState.Confirmation -> backToCreateFromConfirmation() + is ScreenState.Creating, + is ScreenState.Checking -> authCancel() + null -> {} + } + } + + private fun backToCreateFromConfirmation() { + _resetInputEvent.value = Event(resourceManager.getString(R.string.pincode_enter_pin_code_v2_2_0)) + + if (pinCodeAction is PinCodeAction.Create) { + _homeButtonVisibilityLiveData.value = pinCodeAction.toolbarConfiguration.backVisible + } + + currentState = ScreenState.Creating + } + + fun onResume() { + if (currentState is ScreenState.Checking && + (currentState as ScreenState.Checking).useBiometry && + biometricService.isEnabled() + ) { + startBiometryAuth() + } + } + + fun onPause() { + biometricService.cancel() + } + + private fun authSuccess() { + when (pinCodeAction) { + is PinCodeAction.Create -> router.openAfterPinCode(pinCodeAction.delayedNavigation) + is PinCodeAction.Check -> { + router.openAfterPinCode(pinCodeAction.delayedNavigation) + backgroundAccessObserver.checkPassed() + } + is PinCodeAction.Change -> { + when (currentState) { + is ScreenState.Checking -> { + currentState = ScreenState.Creating + _resetInputEvent.value = Event(resourceManager.getString(R.string.pincode_create_top_title)) + _homeButtonVisibilityLiveData.value = true + } + is ScreenState.Confirmation -> { + router.back() + showToast(resourceManager.getString(R.string.pincode_changed_message)) + } + else -> {} + } + } + is PinCodeAction.TwoFactorVerification -> { + twoFactorVerificationExecutor.confirm() + router.back() + } + } + } + + fun startBiometryAuth() { + biometricService.requestBiometric() + } + + private fun handleBiometryServiceEvents() { + biometricService.biometryServiceResponseFlow + .filterIsInstance() + .onEach { authSuccess() } + .launchIn(this) + } + + private suspend fun askForBiometry() { + val isSuccess = try { + confirmationAwaitableAction.awaitAction( + ConfirmationDialogInfo.fromRes( + resourceManager, + title = R.string.pincode_biometry_dialog_title, + message = R.string.pincode_biometric_switch_dialog_title, + positiveButton = R.string.common_use, + negativeButton = R.string.common_skip + ) + ) + } catch (e: CancellationException) { + false + } + + biometricService.enableBiometry(isSuccess) + } + + private fun authCancel() { + if (pinCodeAction is PinCodeAction.TwoFactorVerification) { + twoFactorVerificationExecutor.cancel() + } + router.back() + } + + fun finishApp() { + router.finishApp() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/PincodeFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/PincodeFragment.kt new file mode 100644 index 0000000..e53f804 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/PincodeFragment.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_account_impl.presentation.pincode + +import android.os.Bundle +import android.widget.Toast + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.hideKeyboard +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationOrDenyDialog +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.FragmentPincodeBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent + +class PincodeFragment : BaseFragment() { + + companion object { + const val KEY_PINCODE_ACTION = "pincode_action" + + fun getPinCodeBundle(pinCodeAction: PinCodeAction): Bundle { + return Bundle().apply { + putParcelable(KEY_PINCODE_ACTION, pinCodeAction) + } + } + } + + override fun createBinding() = FragmentPincodeBinding.inflate(layoutInflater) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + hideKeyboard() + } + + override fun inject() { + val navigationFlow = argument(KEY_PINCODE_ACTION) + + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .pincodeComponentFactory() + .create(this, navigationFlow) + .inject(this) + } + + override fun initViews() { + binder.toolbar.setHomeButtonListener { viewModel.backPressed() } + + with(binder.pinCodeNumbers) { + pinCodeEnteredListener = { viewModel.pinCodeEntered(it) } + fingerprintClickListener = { viewModel.startBiometryAuth() } + } + + onBackPressed { + if (viewModel.isBackRoutingBlocked) { + viewModel.finishApp() + } else { + viewModel.backPressed() + } + } + + binder.pinCodeNumbers.bindProgressView(binder.pincodeProgress) + } + + override fun subscribe(viewModel: PinCodeViewModel) { + setupConfirmationOrDenyDialog(R.style.AccentAlertDialogTheme, viewModel.confirmationAwaitableAction) + + viewModel.pinCodeAction.toolbarConfiguration.titleRes?.let { + binder.toolbar.setTitle(getString(it)) + } + + viewModel.showFingerPrintEvent.observeEvent { + binder.pinCodeNumbers.changeBimometricButtonVisibility(it) + } + + viewModel.biometricEvents.observe { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + + viewModel.homeButtonVisibilityLiveData.observe(binder.toolbar::setHomeButtonVisibility) + + viewModel.matchingPincodeErrorEvent.observeEvent { + binder.pinCodeNumbers.pinCodeMatchingError() + } + + viewModel.resetInputEvent.observeEvent { + binder.pinCodeNumbers.resetInput() + binder.pinCodeTitle.text = it + } + + viewModel.startAuth() + } + + override fun onPause() { + super.onPause() + viewModel.onPause() + } + + override fun onResume() { + super.onResume() + viewModel.onResume() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/di/PinCodeComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/di/PinCodeComponent.kt new file mode 100644 index 0000000..3fa3a16 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/di/PinCodeComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.pincode.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PinCodeAction +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PincodeFragment + +@Subcomponent( + modules = [ + PinCodeModule::class + ] +) +@ScreenScope +interface PinCodeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance pinCodeAction: PinCodeAction + ): PinCodeComponent + } + + fun inject(pincodeFragment: PincodeFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/di/PinCodeModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/di/PinCodeModule.kt new file mode 100644 index 0000000..72791af --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/di/PinCodeModule.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_account_impl.presentation.pincode.di + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.io.MainThreadExecutor +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationExecutor +import io.novafoundation.nova.common.sequrity.biometry.BiometricPromptFactory +import io.novafoundation.nova.common.sequrity.biometry.BiometricService +import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory +import io.novafoundation.nova.common.utils.sequrity.BackgroundAccessObserver +import io.novafoundation.nova.common.vibration.DeviceVibrator +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PinCodeAction +import io.novafoundation.nova.feature_account_impl.presentation.pincode.PinCodeViewModel + +@Module( + includes = [ + ViewModelModule::class + ] +) +class PinCodeModule { + + @Provides + @IntoMap + @ViewModelKey(PinCodeViewModel::class) + fun provideViewModel( + interactor: AccountInteractor, + router: AccountRouter, + deviceVibrator: DeviceVibrator, + resourceManager: ResourceManager, + backgroundAccessObserver: BackgroundAccessObserver, + pinCodeAction: PinCodeAction, + biometricService: BiometricService, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + twoFactorVerificationExecutor: TwoFactorVerificationExecutor + ): ViewModel { + return PinCodeViewModel( + interactor, + router, + deviceVibrator, + resourceManager, + backgroundAccessObserver, + twoFactorVerificationExecutor, + actionAwaitableMixinFactory, + biometricService, + pinCodeAction + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): PinCodeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PinCodeViewModel::class.java) + } + + @Provides + fun provideBiometricService( + fragment: Fragment, + context: Context, + resourceManager: ResourceManager, + realBiometricServiceFactory: BiometricServiceFactory + ): BiometricService { + val biometricManager = BiometricManager.from(context) + val biometricPromptFactory = BiometricPromptFactory(fragment, MainThreadExecutor()) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(resourceManager.getString(R.string.biometric_auth_title)) + .setNegativeButtonText(resourceManager.getString(R.string.common_cancel)) + .build() + + return realBiometricServiceFactory.create(biometricManager, biometricPromptFactory, promptInfo) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/fingerprint/BiometricPromptFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/fingerprint/BiometricPromptFactory.kt new file mode 100644 index 0000000..5b8144c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/fingerprint/BiometricPromptFactory.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_account_impl.presentation.pincode.fingerprint + +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.Fragment +import java.util.concurrent.Executor + +class BiometricPromptFactory(private val fragment: Fragment, private val executor: Executor) { + + fun create( + callback: BiometricPrompt.AuthenticationCallback + ): BiometricPrompt { + return BiometricPrompt(fragment, executor, callback) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/view/DotsProgressView.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/view/DotsProgressView.kt new file mode 100644 index 0000000..c5b983d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/view/DotsProgressView.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_account_impl.presentation.pincode.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import io.novafoundation.nova.feature_account_impl.R + +class DotsProgressView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : LinearLayout(context, attrs, defStyle) { + + companion object { + const val MAX_PROGRESS = 6 + } + + private var circles: Array + + private var emptyDrawable: Drawable + private var filledDrawable: Drawable + + private val completeListener: () -> Unit = {} + + init { + val itemSize = context.resources.getDimensionPixelSize(R.dimen.dot_progress_view_dot_size_default) + val itemMargin = context.resources.getDimensionPixelOffset(R.dimen.dot_progress_view_dot_margin_default) + + emptyDrawable = ContextCompat.getDrawable(context, R.drawable.ic_pincode_indicator_inactive)!! + filledDrawable = ContextCompat.getDrawable(context, R.drawable.ic_pincode_indicator_active)!! + + circles = arrayOfNulls(MAX_PROGRESS) + + for (i in 0 until MAX_PROGRESS) { + val circle = View(context) + val params = LayoutParams(itemSize, itemSize) + // divide by 2 since margins from adjacent views are combined + params.setMargins(itemMargin / 2, 0, itemMargin / 2, 0) + circle.layoutParams = params + addView(circle) + circles[i] = circle + } + + setProgress(0) + } + + fun setProgress(currentProgress: Int) { + for (circle in circles) { + circle?.background = emptyDrawable + } + if (currentProgress == 0 || currentProgress > MAX_PROGRESS) { + return + } + for (i in 0 until currentProgress) { + circles[i]?.background = filledDrawable + } + if (currentProgress >= MAX_PROGRESS) { + completeListener() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/view/PinCodeView.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/view/PinCodeView.kt new file mode 100644 index 0000000..8d81356 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/pincode/view/PinCodeView.kt @@ -0,0 +1,114 @@ +package io.novafoundation.nova.feature_account_impl.presentation.pincode.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.View.OnClickListener +import android.view.animation.AnimationUtils +import android.widget.LinearLayout +import androidx.appcompat.widget.AppCompatButton +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.PincodeViewBinding + +class PinCodeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : LinearLayout(context, attrs, defStyle) { + + var pinCodeEnteredListener: (String) -> Unit = {} + var fingerprintClickListener: () -> Unit = {} + + private val pinCodeNumberClickListener = OnClickListener { + pinNumberAdded((it as AppCompatButton).text.toString()) + } + + private val pinCodeDeleteClickListener = OnClickListener { + deleteClicked() + } + + private val pinCodeFingerprintClickListener = OnClickListener { + fingerprintClickListener() + } + + private var inputCode: String = "" + + private var progressView: DotsProgressView? = null + + private val binder = PincodeViewBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + + binder.btn1.setOnClickListener(pinCodeNumberClickListener) + binder.btn2.setOnClickListener(pinCodeNumberClickListener) + binder.btn3.setOnClickListener(pinCodeNumberClickListener) + binder.btn4.setOnClickListener(pinCodeNumberClickListener) + binder.btn5.setOnClickListener(pinCodeNumberClickListener) + binder.btn6.setOnClickListener(pinCodeNumberClickListener) + binder.btn7.setOnClickListener(pinCodeNumberClickListener) + binder.btn8.setOnClickListener(pinCodeNumberClickListener) + binder.btn9.setOnClickListener(pinCodeNumberClickListener) + binder.btn0.setOnClickListener(pinCodeNumberClickListener) + + binder.btnDelete.setOnClickListener(pinCodeDeleteClickListener) + + binder.biometricBtn.setOnClickListener(pinCodeFingerprintClickListener) + + updateProgress() + } + + fun changeBimometricButtonVisibility(isVisible: Boolean) { + binder.biometricBtn.setVisible(isVisible, falseState = View.INVISIBLE) + } + + fun resetInput() { + inputCode = "" + updateProgress() + } + + fun bindProgressView(view: DotsProgressView) { + progressView = view + + updateProgress() + } + + fun pinCodeMatchingError() { + resetInput() + shakeDotsAnimation() + } + + private fun pinNumberAdded(number: String) { + if (inputCode.length >= DotsProgressView.MAX_PROGRESS) { + return + } else { + inputCode += number + updateProgress() + } + if (inputCode.length == DotsProgressView.MAX_PROGRESS) { + pinCodeEnteredListener(inputCode) + } + } + + private fun deleteClicked() { + if (inputCode.isEmpty()) { + return + } + inputCode = inputCode.substring(0, inputCode.length - 1) + updateProgress() + } + + private fun updateProgress() { + val currentProgress = inputCode.length + progressView?.setProgress(currentProgress) + + binder.btnDelete.isEnabled = currentProgress != 0 + } + + private fun shakeDotsAnimation() { + val animation = AnimationUtils.loadAnimation(context, R.anim.shake) + progressView?.startAnimation(animation) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/proxy/sign/ProxySignNotEnoughPermissionBottomSheet.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/proxy/sign/ProxySignNotEnoughPermissionBottomSheet.kt new file mode 100644 index 0000000..22e9599 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/proxy/sign/ProxySignNotEnoughPermissionBottomSheet.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_impl.presentation.proxy.sign + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.view.bottomSheet.ActionNotAllowedBottomSheet +import io.novafoundation.nova.feature_account_impl.R + +class ProxySignNotEnoughPermissionBottomSheet( + context: Context, + private val subtitle: CharSequence, + onSuccess: () -> Unit +) : ActionNotAllowedBottomSheet(context, onSuccess) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + titleView.setText(R.string.proxy_signing_not_enough_permission_title) + subtitleView.setText(subtitle) + buttonView.setText(R.string.common_ok_back) + + applySolidIconStyle(R.drawable.ic_proxy) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/proxy/sign/RealProxySigningPresenter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/proxy/sign/RealProxySigningPresenter.kt new file mode 100644 index 0000000..895317d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/proxy/sign/RealProxySigningPresenter.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_account_impl.presentation.proxy.sign + +import android.text.SpannableStringBuilder +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.amountFromPlanks +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatTokenAmount +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.proxy.ProxySigningPresenter +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.presentation.common.sign.notSupported.SigningNotSupportedPresentable +import io.novafoundation.nova.feature_account_impl.presentation.sign.NestedSigningPresenter +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@FeatureScope +class RealProxySigningPresenter @Inject constructor( + private val contextManager: ContextManager, + private val resourceManager: ResourceManager, + private val signingNotSupportedPresentable: SigningNotSupportedPresentable, + private val nestedSigningPresenter: NestedSigningPresenter, +) : ProxySigningPresenter { + + override suspend fun acknowledgeProxyOperation(proxiedMetaAccount: ProxiedMetaAccount, proxyMetaAccount: MetaAccount): Boolean { + return nestedSigningPresenter.acknowledgeNestedSignOperation( + warningShowFor = proxiedMetaAccount, + title = { resourceManager.getString(R.string.proxy_signing_warning_title) }, + subtitle = { formatSubtitleForWarning(proxyMetaAccount) }, + iconRes = { R.drawable.ic_proxy } + ) + } + + override suspend fun notEnoughPermission( + proxiedMetaAccount: MetaAccount, + proxyMetaAccount: MetaAccount, + proxyTypes: List + ) = withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + ProxySignNotEnoughPermissionBottomSheet( + context = contextManager.getActivity()!!, + subtitle = formatNotEnoughPermissionWarning(proxiedMetaAccount, proxyMetaAccount, proxyTypes), + onSuccess = { continuation.resume(Unit) } + ).show() + } + } + + override suspend fun signingIsNotSupported() { + signingNotSupportedPresentable.presentSigningNotSupported( + SigningNotSupportedPresentable.Payload( + iconRes = R.drawable.ic_proxy, + message = resourceManager.getString(R.string.proxy_signing_is_not_supported_message) + ) + ) + } + + override suspend fun notEnoughFee( + proxy: MetaAccount, + chainAsset: Chain.Asset, + availableBalance: BigInteger, + fee: BigInteger + ) { + nestedSigningPresenter.presentValidationFailure( + title = resourceManager.getString(R.string.error_not_enough_to_pay_fee_title), + message = resourceManager.getString( + R.string.proxy_error_not_enough_to_pay_fee_message, + proxy.name, + fee.amountFromPlanks(chainAsset.precision).formatTokenAmount(chainAsset.symbol), + availableBalance.amountFromPlanks(chainAsset.precision).formatTokenAmount(chainAsset.symbol) + ) + ) + } + + private fun formatSubtitleForWarning(proxyMetaAccount: MetaAccount): CharSequence { + val subtitle = resourceManager.getString(R.string.proxy_signing_warning_message) + val primaryColor = resourceManager.getColor(R.color.text_primary) + val proxyName = proxyMetaAccount.name.toSpannable(colorSpan(primaryColor)) + return SpannableFormatter.format(subtitle, proxyName) + } + + private fun formatNotEnoughPermissionWarning( + proxiedMetaAccount: MetaAccount, + proxyMetaAccount: MetaAccount, + proxyTypes: List + ): CharSequence { + val primaryColor = resourceManager.getColor(R.color.text_primary) + + val proxiedName = proxiedMetaAccount.name.toSpannable(colorSpan(primaryColor)) + val proxyName = proxyMetaAccount.name.toSpannable(colorSpan(primaryColor)) + + return if (proxyTypes.isNotEmpty()) { + val subtitle = resourceManager.getString(R.string.proxy_signing_not_enough_permission_message) + + val proxyTypesBuffer = SpannableStringBuilder() + val proxyTypesCharSequence = proxyTypes.joinTo(proxyTypesBuffer) { it.name.toSpannable(colorSpan(primaryColor)) } + + SpannableFormatter.format(subtitle, proxiedName, proxyName, proxyTypesCharSequence) + } else { + val subtitle = resourceManager.getString(R.string.proxy_signing_none_permissions_message) + SpannableFormatter.format(subtitle, proxiedName, proxyName) + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/ScanSeedFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/ScanSeedFragment.kt new file mode 100644 index 0000000..2ad8854 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/ScanSeedFragment.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_account_impl.presentation.seedScan + +import android.view.View +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.scan.ScanQrFragment +import io.novafoundation.nova.common.presentation.scan.ScanView +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentScanSeedBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent + +class ScanSeedFragment : ScanQrFragment() { + + override val scanView: ScanView + get() = binder.scanSeedScanView + + override fun createBinding() = FragmentScanSeedBinding.inflate(layoutInflater) + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .scanSeedComponentFactory() + .create(this) + .inject(this) + } + + override fun applyInsets(rootView: View) { + binder.scanSeedToolbar.applySystemBarInsets() + } + + override fun initViews() { + super.initViews() + + binder.scanSeedToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.scanSeedScanView.setTitle(viewModel.title) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/ScanSeedViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/ScanSeedViewModel.kt new file mode 100644 index 0000000..e8fbf89 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/ScanSeedViewModel.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.presentation.seedScan + +import io.novafoundation.nova.common.presentation.scan.ScanQrViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.scanSeed.ScanSeedInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter + +class ScanSeedViewModel( + private val router: AccountRouter, + permissionsAsker: PermissionsAsker.Presentation, + private val interactor: ScanSeedInteractor, + private val resourceManager: ResourceManager, + private val scanSeedResponder: ScanSeedResponder +) : ScanQrViewModel(permissionsAsker) { + + val title = resourceManager.getString(R.string.common_scan_qr_code) + + fun backClicked() { + router.back() + } + + override suspend fun scanned(result: String) { + val parseResult = interactor.decodeSeed(result) + + parseResult + .onSuccess(::openPreview) + .onFailure { + val message = resourceManager.getString(R.string.common_invalid_qr_code) + showToast(message) + + resetScanningThrottled() + } + } + + private fun openPreview(seed: String) { + scanSeedResponder.respond(ScanSeedCommunicator.Response(seed)) + router.back() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/SignInterScreenRequester.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/SignInterScreenRequester.kt new file mode 100644 index 0000000..ae4082a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/SignInterScreenRequester.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_account_impl.presentation.seedScan + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import kotlinx.parcelize.Parcelize + +interface ScanSeedRequester : InterScreenRequester + +interface ScanSeedResponder : InterScreenResponder + +interface ScanSeedCommunicator : ScanSeedRequester, ScanSeedResponder { + + @Parcelize + class Request : Parcelable + + @Parcelize + class Response(val secret: String) : Parcelable +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/di/ScanSeedComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/di/ScanSeedComponent.kt new file mode 100644 index 0000000..04b21a9 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/di/ScanSeedComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_account_impl.presentation.seedScan.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedFragment + +@Subcomponent( + modules = [ + ScanSeedModule::class + ] +) +@ScreenScope +interface ScanSeedComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): ScanSeedComponent + } + + fun inject(fragment: ScanSeedFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/di/ScanSeedModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/di/ScanSeedModule.kt new file mode 100644 index 0000000..813e7a7 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/seedScan/di/ScanSeedModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_account_impl.presentation.seedScan.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.feature_account_impl.domain.scanSeed.RealScanSeedInteractor +import io.novafoundation.nova.feature_account_impl.domain.scanSeed.ScanSeedInteractor +import io.novafoundation.nova.feature_account_impl.domain.utils.SecretQrFormat +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedCommunicator +import io.novafoundation.nova.feature_account_impl.presentation.seedScan.ScanSeedViewModel + +@Module(includes = [ViewModelModule::class]) +class ScanSeedModule { + + @Provides + fun provideInteractor(): ScanSeedInteractor { + return RealScanSeedInteractor(SecretQrFormat()) + } + + @Provides + fun providePermissionAsker( + permissionsAskerFactory: PermissionsAskerFactory, + fragment: Fragment, + router: AccountRouter + ) = permissionsAskerFactory.createReturnable(fragment, router) + + @Provides + @IntoMap + @ViewModelKey(ScanSeedViewModel::class) + fun provideViewModel( + router: AccountRouter, + permissionsAsker: PermissionsAsker.Presentation, + resourceManager: ResourceManager, + interactor: ScanSeedInteractor, + scanSeedResponder: ScanSeedCommunicator + ): ViewModel { + return ScanSeedViewModel( + router = router, + permissionsAsker = permissionsAsker, + interactor = interactor, + resourceManager = resourceManager, + scanSeedResponder = scanSeedResponder + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ScanSeedViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ScanSeedViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/sign/NestedSignWarningBottomSheet.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/sign/NestedSignWarningBottomSheet.kt new file mode 100644 index 0000000..2e81bdc --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/sign/NestedSignWarningBottomSheet.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_account_impl.presentation.sign + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet +import io.novafoundation.nova.feature_account_impl.databinding.BottomSheetNestedWarningBinding + +class NestedSignWarningBottomSheet( + context: Context, + private val subtitle: CharSequence, + private val title: String, + @DrawableRes private val iconRes: Int, + private val onFinish: (Boolean) -> Unit, + private val dontShowAgain: () -> Unit +) : BaseBottomSheet(context, io.novafoundation.nova.common.R.style.BottomSheetDialog), + WithContextExtensions by WithContextExtensions(context) { + + override val binder: BottomSheetNestedWarningBinding = BottomSheetNestedWarningBinding.inflate(LayoutInflater.from(context)) + + private var finishWithContinue = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binder.nestedSigningWarningTitle.text = title + binder.nestedSigningWarningMessage.text = subtitle + binder.nestedSigningWarningIcon.setImageResource(iconRes) + + binder.nestedSigningWarningContinue.setDismissingClickListener { + if (binder.nestedSigningWarningDontShowAgain.isChecked) { + dontShowAgain() + } + finishWithContinue = true + } + + binder.nestedSigningWarningCancel.setDismissingClickListener { + finishWithContinue = false + } + + setOnDismissListener { onFinish(finishWithContinue) } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/sign/NestedSigningPresenter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/sign/NestedSigningPresenter.kt new file mode 100644 index 0000000..aad4ab2 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/sign/NestedSigningPresenter.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_account_impl.presentation.sign + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.requireActivity +import io.novafoundation.nova.common.utils.LazyGet +import io.novafoundation.nova.common.view.dialog.dialog +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +interface NestedSigningPresenter { + + suspend fun acknowledgeNestedSignOperation( + warningShowFor: MetaAccount, + title: LazyGet, + subtitle: LazyGet, + iconRes: LazyGet + ): Boolean + + suspend fun presentValidationFailure(title: String, message: CharSequence) +} + +@FeatureScope +class RealNestedSigningPresenter @Inject constructor( + private val contextManager: ContextManager, + private val preferences: Preferences, +) : NestedSigningPresenter { + + companion object { + + // We intentionally leave key to be the same as it was before in proxies + // so previous choices wont be reset + private const val KEY_DONT_SHOW_AGAIN = "proxy_sign_warning_dont_show_again" + } + + override suspend fun acknowledgeNestedSignOperation( + warningShowFor: MetaAccount, + title: LazyGet, + subtitle: LazyGet, + iconRes: LazyGet + ): Boolean = withContext(Dispatchers.Main) { + if (noNeedToShowWarning(warningShowFor)) { + return@withContext true + } + + val resumingAllowed = suspendCoroutine { + continuation -> + NestedSignWarningBottomSheet( + context = contextManager.requireActivity(), + title = title(), + subtitle = subtitle(), + iconRes = iconRes(), + onFinish = { continuation.resume(it) }, + dontShowAgain = { dontShowAgain(warningShowFor) } + ).show() + } + + return@withContext resumingAllowed + } + + override suspend fun presentValidationFailure(title: String, message: CharSequence) = withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + dialog(contextManager.getActivity()!!) { + setTitle(title) + setMessage(message) + + setPositiveButton(io.novafoundation.nova.common.R.string.common_close) { _, _ -> continuation.resume(Unit) } + } + } + } + + private fun noNeedToShowWarning(metaAccount: MetaAccount): Boolean { + return preferences.getBoolean(makePrefsKey(metaAccount), false) + } + + private fun dontShowAgain(metaAccount: MetaAccount) { + preferences.putBoolean(makePrefsKey(metaAccount), true) + } + + private fun makePrefsKey(metaAccount: MetaAccount): String { + return "${KEY_DONT_SHOW_AGAIN}_${metaAccount.id}" + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/StartCreateWalletFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/StartCreateWalletFragment.kt new file mode 100644 index 0000000..9a59460 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/StartCreateWalletFragment.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet + +import android.os.Bundle +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.setupCustomDialogDisplayer +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.bottomSheet.action.observeActionBottomSheet +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_impl.databinding.FragmentStartCreateWalletBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent + +class StartCreateWalletFragment : BaseFragment() { + + companion object { + private const val KEY_PAYLOAD = "display_back" + + fun bundle(payload: StartCreateWalletPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentStartCreateWalletBinding.inflate(layoutInflater) + + override fun initViews() { + binder.startCreateWalletToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.startCreateWalletConfirmName.setOnClickListener { viewModel.confirmNameClicked() } + + onBackPressed { viewModel.backClicked() } + + binder.startCreateWalletCloudBackupButton.setOnClickListener { viewModel.cloudBackupClicked() } + binder.startCreateWalletManualBackupButton.setOnClickListener { viewModel.manualBackupClicked() } + + binder.startCreateWalletCloudBackupButton.prepareForProgress(viewLifecycleOwner) + binder.startCreateWalletConfirmName.prepareForProgress(viewLifecycleOwner) + } + + override fun inject() { + FeatureUtils.getFeature(context!!, AccountFeatureApi::class.java) + .startCreateWallet() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: StartCreateWalletViewModel) { + setupCustomDialogDisplayer(viewModel) + observeActionBottomSheet(viewModel) + binder.startCreateWalletNameInput.bindTo(viewModel.nameInput, viewLifecycleOwner.lifecycleScope) + + viewModel.continueButtonState.observe { state -> + binder.startCreateWalletConfirmName.setState(state) + } + + viewModel.isSyncWithCloudEnabled.observe { + binder.startCreateWalletSyncWithCloudEnabled.isVisible = it + } + + viewModel.createWalletState.observe { + binder.startCreateWalletNameInputLayout.isEndIconVisible = it == CreateWalletState.SETUP_NAME + binder.startCreateWalletNameInput.isFocusable = it == CreateWalletState.SETUP_NAME + binder.startCreateWalletNameInput.isFocusableInTouchMode = it == CreateWalletState.SETUP_NAME + } + + viewModel.titleText.observe { + binder.startCreateWalletTitle.text = it + } + + viewModel.explanationText.observe { + binder.startCreateWalletExplanation.text = it + } + + viewModel.progressFlow.observe { + binder.startCreateWalletCloudBackupButton.showProgress(it) + binder.startCreateWalletManualBackupButton.isEnabled = !it + } + + viewModel.showCloudBackupButton.observe { + binder.startCreateWalletCloudBackupButton.isVisible = it + } + + viewModel.showManualBackupButton.observe { + binder.startCreateWalletManualBackupButton.isVisible = it + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/StartCreateWalletPayload.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/StartCreateWalletPayload.kt new file mode 100644 index 0000000..5809733 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/StartCreateWalletPayload.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class StartCreateWalletPayload( + val flowType: FlowType +) : Parcelable { + + enum class FlowType { + FIRST_WALLET, + SECOND_WALLET + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/StartCreateWalletViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/StartCreateWalletViewModel.kt new file mode 100644 index 0000000..3da14c4 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/StartCreateWalletViewModel.kt @@ -0,0 +1,148 @@ +package io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.displayDialogOrNothing +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.finally +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.startCreateWallet.StartCreateWalletInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletPayload.FlowType +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.PreCreateValidationStatus +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchExistingCloudBackupAction +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapPreCreateValidationStatusToUi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +enum class CreateWalletState { + SETUP_NAME, + CHOOSE_BACKUP_WAY +} + +class StartCreateWalletViewModel( + private val router: AccountRouter, + private val resourceManager: ResourceManager, + private val startCreateWalletInteractor: StartCreateWalletInteractor, + private val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + private val payload: StartCreateWalletPayload, + customDialogProvider: CustomDialogDisplayer.Presentation +) : BaseViewModel(), + ActionBottomSheetLauncher by actionBottomSheetLauncherFactory.create(), + CustomDialogDisplayer.Presentation by customDialogProvider { + + // Used to cancel the job when the user navigates back + private var cloudBackupValidationJob: Job? = null + + private val _progressFlow = MutableStateFlow(false) + val progressFlow: Flow = _progressFlow + + private val _createWalletState = MutableStateFlow(CreateWalletState.SETUP_NAME) + val createWalletState: Flow = _createWalletState + + val nameInput = MutableStateFlow("") + + val isSyncWithCloudEnabled = flowOf { startCreateWalletInteractor.isSyncWithCloudEnabled() } + + val continueButtonState: Flow = combine(progressFlow, nameInput, createWalletState) { progress, name, flowState -> + when { + flowState != CreateWalletState.SETUP_NAME -> DescriptiveButtonState.Gone + progress -> DescriptiveButtonState.Loading + name.isEmpty() -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.start_create_wallet_enter_wallet_name)) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + }.shareInBackground() + + val showCloudBackupButton: Flow = createWalletState.map { state -> + when (state) { + CreateWalletState.SETUP_NAME -> false + CreateWalletState.CHOOSE_BACKUP_WAY -> payload.flowType == FlowType.FIRST_WALLET + } + }.shareInBackground() + + val showManualBackupButton: Flow = createWalletState.map { state -> + state == CreateWalletState.CHOOSE_BACKUP_WAY + }.shareInBackground() + + val titleText: Flow = createWalletState.map { + when (it) { + CreateWalletState.SETUP_NAME -> resourceManager.getString(R.string.start_create_wallet_name_your_wallet) + CreateWalletState.CHOOSE_BACKUP_WAY -> resourceManager.getString(R.string.start_create_wallet_your_wallet_is_ready) + } + }.shareInBackground() + + val explanationText: Flow = createWalletState.map { + when (it) { + CreateWalletState.SETUP_NAME -> resourceManager.getString(R.string.start_create_wallet_set_wallet_name_explanation) + CreateWalletState.CHOOSE_BACKUP_WAY -> resourceManager.getString(R.string.start_create_wallet_backup_ready_explanation) + } + }.shareInBackground() + + fun backClicked() { + if (_createWalletState.value == CreateWalletState.SETUP_NAME) { + router.back() + } else { + cloudBackupValidationJob?.cancel() + _createWalletState.value = CreateWalletState.SETUP_NAME + } + } + + fun confirmNameClicked() { + launch { + if (startCreateWalletInteractor.isSyncWithCloudEnabled()) { + _progressFlow.value = true + startCreateWalletInteractor.createWalletAndSelect(nameInput.value) + .onSuccess { router.openMain() } + .onFailure { error -> showError(error) } + _progressFlow.value = false + } else { + _createWalletState.value = CreateWalletState.CHOOSE_BACKUP_WAY + } + } + } + + fun cloudBackupClicked() { + cloudBackupValidationJob = launch { + val walletName = nameInput.value + runCatching { + _progressFlow.value = true + val validationResult = startCreateWalletInteractor.validateCanCreateBackup() + if (validationResult is PreCreateValidationStatus.Ok) { + router.openCreateCloudBackupPassword(walletName) + } else { + val payload = mapPreCreateValidationStatusToUi(resourceManager, validationResult, ::userHasExistingBackup, ::initSignIn) + displayDialogOrNothing(payload) + } + }.finally { + _progressFlow.value = false + } + } + } + + fun manualBackupClicked() { + router.openMnemonicScreen(nameInput.value, AddAccountPayload.MetaAccount) + } + + private fun userHasExistingBackup() { + launchExistingCloudBackupAction(resourceManager, ::openImportCloudBackup) + } + + private fun openImportCloudBackup() { + router.restoreCloudBackup() + } + + private fun initSignIn() { + launch { + startCreateWalletInteractor.signInToCloud() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/di/StartCreateWalletComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/di/StartCreateWalletComponent.kt new file mode 100644 index 0000000..872ee94 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/di/StartCreateWalletComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletFragment +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletPayload + +@Subcomponent( + modules = [ + StartCreateWalletModule::class + ] +) +@ScreenScope +interface StartCreateWalletComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: StartCreateWalletPayload + ): StartCreateWalletComponent + } + + fun inject(fragment: StartCreateWalletFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/di/StartCreateWalletModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/di/StartCreateWalletModule.kt new file mode 100644 index 0000000..f56619a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/startCreateWallet/di/StartCreateWalletModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_impl.domain.startCreateWallet.StartCreateWalletInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletPayload +import io.novafoundation.nova.feature_account_impl.presentation.startCreateWallet.StartCreateWalletViewModel + +@Module(includes = [ViewModelModule::class]) +class StartCreateWalletModule { + + @Provides + @IntoMap + @ViewModelKey(StartCreateWalletViewModel::class) + fun provideViewModel( + accountRouter: AccountRouter, + resourceManager: ResourceManager, + startCreateWalletInteractor: StartCreateWalletInteractor, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + customDialogProvider: CustomDialogDisplayer.Presentation, + payload: StartCreateWalletPayload + ): ViewModel { + return StartCreateWalletViewModel( + accountRouter, + resourceManager, + startCreateWalletInteractor, + actionBottomSheetLauncherFactory, + payload, + customDialogProvider + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): StartCreateWalletViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StartCreateWalletViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/encryption/EncryptionTypeChooserBottomSheetDialog.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/encryption/EncryptionTypeChooserBottomSheetDialog.kt new file mode 100644 index 0000000..4f74a3f --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/encryption/EncryptionTypeChooserBottomSheetDialog.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_account_impl.presentation.view.advanced.encryption + +import android.content.Context +import android.os.Bundle +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeInvisible +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ClickHandler +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.ItemEncryptionTypeBinding +import io.novafoundation.nova.feature_account_impl.presentation.view.advanced.encryption.model.CryptoTypeModel + +class EncryptionTypeChooserBottomSheetDialog( + context: Context, + payload: Payload, + onClicked: ClickHandler +) : DynamicListBottomSheet(context, payload, CryptoModelCallback, onClicked) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.common_crypto_type) + } + + override fun holderCreator(): HolderCreator = { + EncryptionTypeViewHolder(ItemEncryptionTypeBinding.inflate(it.inflater(), it, false)) + } +} + +class EncryptionTypeViewHolder( + private val binder: ItemEncryptionTypeBinding +) : DynamicListSheetAdapter.Holder(binder.root) { + + override fun bind(item: CryptoTypeModel, isSelected: Boolean, handler: DynamicListSheetAdapter.Handler) { + super.bind(item, isSelected, handler) + + with(binder) { + if (isSelected) { + rightIcon.makeVisible() + } else { + rightIcon.makeInvisible() + } + + encryptionTv.text = item.name + } + } +} + +private object CryptoModelCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CryptoTypeModel, newItem: CryptoTypeModel): Boolean { + return oldItem.cryptoType == newItem.cryptoType + } + + override fun areContentsTheSame(oldItem: CryptoTypeModel, newItem: CryptoTypeModel): Boolean { + return oldItem == newItem + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/encryption/model/CryptoTypeModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/encryption/model/CryptoTypeModel.kt new file mode 100644 index 0000000..45b2d4c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/encryption/model/CryptoTypeModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_impl.presentation.view.advanced.encryption.model + +import io.novafoundation.nova.core.model.CryptoType + +data class CryptoTypeModel( + val name: String, + val cryptoType: CryptoType +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/encryption/model/CryptoTypeSelectedModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/encryption/model/CryptoTypeSelectedModel.kt new file mode 100644 index 0000000..3d42744 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/encryption/model/CryptoTypeSelectedModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_impl.presentation.view.advanced.encryption.model + +import io.novafoundation.nova.core.model.CryptoType + +data class CryptoTypeSelectedModel( + val name: String, + val cryptoType: CryptoType +) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/network/model/NetworkModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/network/model/NetworkModel.kt new file mode 100644 index 0000000..227a1a0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/advanced/network/model/NetworkModel.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_account_impl.presentation.view.advanced.network.model + +import io.novafoundation.nova.core.model.Node +import io.novafoundation.nova.core.model.Node.NetworkType.KUSAMA +import io.novafoundation.nova.core.model.Node.NetworkType.POLKADOT +import io.novafoundation.nova.core.model.Node.NetworkType.ROCOCO +import io.novafoundation.nova.core.model.Node.NetworkType.WESTEND +import io.novafoundation.nova.feature_account_impl.R + +data class NetworkModel( + val name: String, + val networkTypeUI: NetworkTypeUI +) { + sealed class NetworkTypeUI(val icon: Int, val networkType: Node.NetworkType) { + object Kusama : NetworkTypeUI(R.drawable.ic_ksm_24, KUSAMA) + object Polkadot : NetworkTypeUI(R.drawable.ic_polkadot_24, POLKADOT) + object Westend : NetworkTypeUI(R.drawable.ic_westend_24, WESTEND) + object Rococo : NetworkTypeUI(R.drawable.ic_polkadot_24, ROCOCO) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicCardView.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicCardView.kt new file mode 100644 index 0000000..fb7f4b0 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicCardView.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_account_impl.presentation.view.mnemonic + +import android.content.Context +import android.util.AttributeSet +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.TapToViewContainer +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.databinding.ViewMnemonicCardViewBinding +import io.novafoundation.nova.feature_account_impl.presentation.common.mnemonic.BackupMnemonicAdapter +import io.novafoundation.nova.feature_account_impl.presentation.mnemonic.confirm.MnemonicWord + +class MnemonicCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TapToViewContainer(context, attrs, defStyleAttr), + BackupMnemonicAdapter.ItemHandler, + WithContextExtensions by WithContextExtensions(context) { + + private val adapter = BackupMnemonicAdapter(this) + + private var wordClickedListener: BackupMnemonicAdapter.ItemHandler? = null + + private val binder = ViewMnemonicCardViewBinding.inflate(inflater(), this) + + init { + setTitleOrHide(context.getString(R.string.common_tap_to_reveal_title)) + setSubtitleOrHide(context.getString(R.string.mnemonic_card_reveal_subtitle)) + setBackgroundResource(R.drawable.ic_mnemonic_background) + setTapToViewBackground(R.drawable.ic_mnemonic_card_blur) + setCardCornerRadius(12.dpF) + + binder.mnemonicCardPhrase.setItemPadding(4.dp) + binder.mnemonicCardPhrase.adapter = adapter + + binder.mnemonicCardTitle.text = SpannableFormatter.format( + context.getString(R.string.mnemonic_card_title), + context.getString(R.string.mnemonic_card_title_highlight) + .toSpannable(colorSpan(context.getColor(R.color.text_primary))) + ) + + attrs?.let(::applyAttrs) + } + + override fun wordClicked(word: MnemonicWord) { + wordClickedListener?.wordClicked(word) + } + + fun setWords(words: List) { + adapter.submitList(words) + } + + fun setWordsString(list: List) { + val words = list.mapIndexed { index, item -> + MnemonicWord( + id = index, + content = item, + indexDisplay = index.plus(1).toString(), + removed = false + ) + } + setWords(words) + } + + fun setWordClickedListener(listener: BackupMnemonicAdapter.ItemHandler?) { + wordClickedListener = listener + } + + private fun applyAttrs(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.MnemonicCardView) { + val showRevealContainer = it.getBoolean(R.styleable.MnemonicCardView_showRevealContainer, false) + showContent(!showRevealContainer) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicViewer.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicViewer.kt new file mode 100644 index 0000000..3ec613d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicViewer.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_impl.presentation.view.mnemonic + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.GridLayoutManager +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_impl.databinding.ViewMnemonicBinding + +class MnemonicViewer @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val adapter = MnemonicWordsAdapter() + + private val binder = ViewMnemonicBinding.inflate(inflater(), this) + + init { + binder.mnemonicViewerList.adapter = adapter + } + + fun submitList(list: List) { + val manager = binder.mnemonicViewerList.layoutManager as GridLayoutManager + + manager.spanCount = list.size / 2 + + adapter.submitList(list) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicWordModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicWordModel.kt new file mode 100644 index 0000000..fd6787c --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicWordModel.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_account_impl.presentation.view.mnemonic + +data class MnemonicWordModel( + val numberToShow: String, + val word: String +) + +fun mapMnemonicToMnemonicWords(mnemonic: List): List { + return mnemonic.mapIndexed { index: Int, word: String -> + MnemonicWordModel( + (index + 1).toString(), + word + ) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicWordsAdapter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicWordsAdapter.kt new file mode 100644 index 0000000..a3c61a8 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/view/mnemonic/MnemonicWordsAdapter.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_impl.presentation.view.mnemonic + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_impl.databinding.ItemMnemonicWordBinding + +class MnemonicWordsAdapter : ListAdapter(DiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MnemonicWordViewHolder { + val binder = ItemMnemonicWordBinding.inflate(parent.inflater(), parent, false) + return MnemonicWordViewHolder(binder) + } + + override fun onBindViewHolder(holder: MnemonicWordViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +object DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: MnemonicWordModel, newItem: MnemonicWordModel): Boolean { + return oldItem.numberToShow == newItem.numberToShow + } + + override fun areContentsTheSame(oldItem: MnemonicWordModel, newItem: MnemonicWordModel): Boolean { + return oldItem.word == newItem.word + } +} + +class MnemonicWordViewHolder(private val binder: ItemMnemonicWordBinding) : RecyclerView.ViewHolder(binder.root) { + + fun bind(mnemonicWord: MnemonicWordModel) { + with(binder) { + numberTv.text = mnemonicWord.numberToShow + wordTv.text = mnemonicWord.word + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/ChangeWatchAccountFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/ChangeWatchAccountFragment.kt new file mode 100644 index 0000000..988d0de --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/ChangeWatchAccountFragment.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.change + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.setupAddressInput +import io.novafoundation.nova.feature_account_impl.databinding.FragmentChangeWatchWalletBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent + +class ChangeWatchAccountFragment : BaseFragment() { + + companion object { + private const val KEY_ADD_ACCOUNT_PAYLOAD = "ChangeWatchAccountFragment.add_account_payload" + + fun getBundle(payload: AddAccountPayload.ChainAccount): Bundle { + return Bundle().apply { + putParcelable(KEY_ADD_ACCOUNT_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentChangeWatchWalletBinding.inflate(layoutInflater) + + override fun initViews() { + binder.changeWatchAccountToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.changeWatchAccountContinue.setOnClickListener { viewModel.nextClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .changeWatchAccountComponentFactory() + .create(this, argument(KEY_ADD_ACCOUNT_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ChangeWatchAccountViewModel) { + setupAddressInput(viewModel.chainAddressMixin, binder.changeWatchAccountChainAddress) + + viewModel.inputHint.observe(binder.changeWatchAccountChainAddressHint::setText) + + viewModel.buttonState.observe(binder.changeWatchAccountContinue::setState) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/ChangeWatchAccountViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/ChangeWatchAccountViewModel.kt new file mode 100644 index 0000000..59b42ee --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/ChangeWatchAccountViewModel.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.change + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.isAddressValid +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.domain.watchOnly.change.ChangeWatchAccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ChangeWatchAccountViewModel( + private val router: AccountRouter, + private val addressInputMixinFactory: AddressInputMixinFactory, + private val chainRegistry: ChainRegistry, + private val interactor: ChangeWatchAccountInteractor, + private val payload: AddAccountPayload.ChainAccount, + private val resourceManager: ResourceManager +) : BaseViewModel() { + + private val chain = flowOf { chainRegistry.getChain(payload.chainId) } + .shareInBackground() + + val chainAddressMixin = with(addressInputMixinFactory) { + create( + inputSpecProvider = singleChainInputSpec(chain), + errorDisplayer = this@ChangeWatchAccountViewModel::showError, + showAccountEvent = null, + coroutineScope = this@ChangeWatchAccountViewModel, + ) + } + + val inputHint = chain.map { it.name }.map { + resourceManager.getString(R.string.account_chain_address_format, it) + }.shareInBackground() + + val buttonState = chainAddressMixin.inputFlow.map { chainAddress -> + if (chainAddressMixin.isAddressValid(chainAddress)) { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } else { + val chainName = chain.first().name + val reason = resourceManager.getString(R.string.accoount_enter_chain_address_format, chainName) + DescriptiveButtonState.Disabled(reason) + } + } + + fun nextClicked() = launch { + val result = withContext(Dispatchers.Default) { + interactor.changeChainAccount( + metaId = payload.metaId, + chain = chain.first(), + address = chainAddressMixin.inputFlow.first() + ) + } + + result + .onSuccess { router.openMain() } + .onFailure(::showError) + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/di/ChangeWatchAccountComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/di/ChangeWatchAccountComponent.kt new file mode 100644 index 0000000..3e1de28 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/di/ChangeWatchAccountComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.change.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_impl.presentation.watchOnly.change.ChangeWatchAccountFragment + +@Subcomponent( + modules = [ + ChangeWatchAccountModule::class + ] +) +@ScreenScope +interface ChangeWatchAccountComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AddAccountPayload.ChainAccount + ): ChangeWatchAccountComponent + } + + fun inject(fragment: ChangeWatchAccountFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/di/ChangeWatchAccountModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/di/ChangeWatchAccountModule.kt new file mode 100644 index 0000000..6ba93fb --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/change/di/ChangeWatchAccountModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.change.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.watchOnly.WatchOnlyAddAccountRepository +import io.novafoundation.nova.feature_account_impl.domain.watchOnly.change.ChangeWatchAccountInteractor +import io.novafoundation.nova.feature_account_impl.domain.watchOnly.change.RealChangeWatchAccountInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.watchOnly.change.ChangeWatchAccountViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class ChangeWatchAccountModule { + + @Provides + @ScreenScope + fun provideInteractor( + watchOnlyAddAccountRepository: WatchOnlyAddAccountRepository + ): ChangeWatchAccountInteractor = RealChangeWatchAccountInteractor(watchOnlyAddAccountRepository) + + @Provides + @IntoMap + @ViewModelKey(ChangeWatchAccountViewModel::class) + fun provideViewModel( + router: AccountRouter, + addressInputMixinFactory: AddressInputMixinFactory, + interactor: ChangeWatchAccountInteractor, + chainRegistry: ChainRegistry, + payload: AddAccountPayload.ChainAccount, + resourceManager: ResourceManager + ): ViewModel { + return ChangeWatchAccountViewModel( + router = router, + addressInputMixinFactory = addressInputMixinFactory, + chainRegistry = chainRegistry, + interactor = interactor, + payload = payload, + resourceManager = resourceManager + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ChangeWatchAccountViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ChangeWatchAccountViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/CreateWatchWalletFragment.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/CreateWatchWalletFragment.kt new file mode 100644 index 0000000..cef4dbf --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/CreateWatchWalletFragment.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.create + +import android.view.View +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.scrollOnFocusTo +import io.novafoundation.nova.common.view.ChipActionsAdapter +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.setupAddressInput +import io.novafoundation.nova.feature_account_impl.databinding.FragmentCreateWatchWalletBinding +import io.novafoundation.nova.feature_account_impl.di.AccountFeatureComponent + +class CreateWatchWalletFragment : BaseFragment() { + + private val suggestionsAdapter by lazy(LazyThreadSafetyMode.NONE) { + ChipActionsAdapter(viewModel::walletSuggestionClicked) + } + + override fun createBinding() = FragmentCreateWatchWalletBinding.inflate(layoutInflater) + + override fun applyInsets(rootView: View) { + binder.createWatchWalletContainer.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.createWatchWalletToolbar.setHomeButtonListener { viewModel.homeButtonClicked() } + + binder.createWatchWalletPresets.adapter = suggestionsAdapter + binder.createWatchWalletPresets.setHasFixedSize(true) + + binder.createWatchWalletContinue.setOnClickListener { viewModel.nextClicked() } + + binder.createWatchWalletScrollArea.scrollOnFocusTo( + binder.createWatchWalletName, + binder.createWatchWalletEvmAddress, + binder.createWatchWalletSubstrateAddress + ) + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), AccountFeatureApi::class.java) + .createWatchOnlyComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: CreateWatchWalletViewModel) { + setupAddressInput(viewModel.substrateAddressInput, binder.createWatchWalletSubstrateAddress) + setupAddressInput(viewModel.evmAddressInput, binder.createWatchWalletEvmAddress) + + binder.createWatchWalletName.bindTo(viewModel.nameInput, viewLifecycleOwner.lifecycleScope) + + viewModel.buttonState.observe(binder.createWatchWalletContinue::setState) + + suggestionsAdapter.submitList(viewModel.suggestionChipActionModels) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/CreateWatchWalletViewModel.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/CreateWatchWalletViewModel.kt new file mode 100644 index 0000000..da451af --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/CreateWatchWalletViewModel.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.create + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.ChipActionsModel +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.isAddressValid +import io.novafoundation.nova.feature_account_impl.R +import io.novafoundation.nova.feature_account_impl.data.repository.WatchWalletSuggestion +import io.novafoundation.nova.feature_account_impl.domain.watchOnly.create.CreateWatchWalletInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class CreateWatchWalletViewModel( + private val router: AccountRouter, + private val addressInputMixinFactory: AddressInputMixinFactory, + private val interactor: CreateWatchWalletInteractor, + private val accountInteractor: AccountInteractor, + private val resourceManager: ResourceManager +) : BaseViewModel() { + + val nameInput = MutableStateFlow("") + + val substrateAddressInput = with(addressInputMixinFactory) { + create( + inputSpecProvider = substrateInputSpec(), + errorDisplayer = this@CreateWatchWalletViewModel::showError, + showAccountEvent = null, + coroutineScope = this@CreateWatchWalletViewModel, + ) + } + + val evmAddressInput = with(addressInputMixinFactory) { + create( + inputSpecProvider = evmInputSpec(), + errorDisplayer = this@CreateWatchWalletViewModel::showError, + showAccountEvent = null, + coroutineScope = this@CreateWatchWalletViewModel, + ) + } + + val buttonState = combine( + nameInput, + substrateAddressInput.inputFlow, + evmAddressInput.inputFlow + ) { name, substrateAddress, evmAddress -> + when { + name.isEmpty() -> disabledStateFrom(R.string.account_enter_wallet_nickname) + !substrateAddressInput.isAddressValid(substrateAddress) -> disabledStateFrom(R.string.accoount_enter_substrate_address) + evmAddress.isNotEmpty() && !evmAddressInput.isAddressValid(evmAddress) -> disabledStateFrom(R.string.accoount_enter_evm_address) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + } + + private val walletSuggestions = interactor.suggestions() + + val suggestionChipActionModels = walletSuggestions.map(::mapWalletSuggestionToChipAction) + + fun homeButtonClicked() { + router.back() + } + + fun nextClicked() = launch { + val result = withContext(Dispatchers.Default) { + interactor.createWallet( + name = nameInput.value, + substrateAddress = substrateAddressInput.inputFlow.value, + evmAddress = evmAddressInput.inputFlow.value + ) + } + + result + .onSuccess { continueBasedOnCodeStatus() } + .onFailure { it.printStackTrace(); showError(it) } + } + + fun walletSuggestionClicked(index: Int) { + launch { + val suggestion = walletSuggestions[index] + + nameInput.value = suggestion.name + substrateAddressInput.inputFlow.value = suggestion.substrateAddress + evmAddressInput.inputFlow.value = suggestion.evmAddress.orEmpty() + } + } + + private fun disabledStateFrom(@StringRes reason: Int): DescriptiveButtonState { + return DescriptiveButtonState.Disabled(resourceManager.getString(reason)) + } + + private fun mapWalletSuggestionToChipAction(walletSuggestion: WatchWalletSuggestion): ChipActionsModel { + return ChipActionsModel(action = walletSuggestion.name) + } + + private suspend fun continueBasedOnCodeStatus() { + if (accountInteractor.isCodeSet()) { + router.openMain() + } else { + router.openCreatePincode() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/di/CreateWatchWalletComponent.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/di/CreateWatchWalletComponent.kt new file mode 100644 index 0000000..64e3ecd --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/di/CreateWatchWalletComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.create.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_impl.presentation.watchOnly.create.CreateWatchWalletFragment + +@Subcomponent( + modules = [ + CreateWatchWalletModule::class + ] +) +@ScreenScope +interface CreateWatchWalletComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): CreateWatchWalletComponent + } + + fun inject(fragment: CreateWatchWalletFragment) +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/di/CreateWatchWalletModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/di/CreateWatchWalletModule.kt new file mode 100644 index 0000000..58aeb7b --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/create/di/CreateWatchWalletModule.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.create.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_impl.data.repository.WatchOnlyRepository +import io.novafoundation.nova.feature_account_impl.data.repository.addAccount.watchOnly.WatchOnlyAddAccountRepository +import io.novafoundation.nova.feature_account_impl.domain.watchOnly.create.CreateWatchWalletInteractor +import io.novafoundation.nova.feature_account_impl.domain.watchOnly.create.RealCreateWatchWalletInteractor +import io.novafoundation.nova.feature_account_impl.presentation.AccountRouter +import io.novafoundation.nova.feature_account_impl.presentation.watchOnly.create.CreateWatchWalletViewModel + +@Module(includes = [ViewModelModule::class]) +class CreateWatchWalletModule { + + @Provides + @ScreenScope + fun provideInteractor( + watchOnlyRepository: WatchOnlyRepository, + watchOnlyAddAccountRepository: WatchOnlyAddAccountRepository, + accountRepository: AccountRepository + ): CreateWatchWalletInteractor = RealCreateWatchWalletInteractor(watchOnlyRepository, watchOnlyAddAccountRepository, accountRepository) + + @Provides + @IntoMap + @ViewModelKey(CreateWatchWalletViewModel::class) + fun provideViewModel( + router: AccountRouter, + addressInputMixinFactory: AddressInputMixinFactory, + interactor: CreateWatchWalletInteractor, + accountInteractor: AccountInteractor, + resourceManager: ResourceManager + ): ViewModel { + return CreateWatchWalletViewModel(router, addressInputMixinFactory, interactor, accountInteractor, resourceManager) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): CreateWatchWalletViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(CreateWatchWalletViewModel::class.java) + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/sign/RealWatchOnlyMissingKeysPresenter.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/sign/RealWatchOnlyMissingKeysPresenter.kt new file mode 100644 index 0000000..a97bd4d --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/sign/RealWatchOnlyMissingKeysPresenter.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.sign + +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class RealWatchOnlyMissingKeysPresenter( + private val contextManager: ContextManager, +) : WatchOnlyMissingKeysPresenter { + + override suspend fun presentNoKeysFound(): Unit = withContext(Dispatchers.Main) { + suspendCoroutine { + WatchOnlySignBottomSheet( + context = contextManager.getActivity()!!, + onSuccess = { it.resume(Unit) } + ).show() + } + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/sign/WatchOnlySignBottomSheet.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/sign/WatchOnlySignBottomSheet.kt new file mode 100644 index 0000000..05f39cc --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/presentation/watchOnly/sign/WatchOnlySignBottomSheet.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_account_impl.presentation.watchOnly.sign + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.view.bottomSheet.ActionNotAllowedBottomSheet +import io.novafoundation.nova.feature_account_impl.R + +class WatchOnlySignBottomSheet( + context: Context, + onSuccess: () -> Unit +) : ActionNotAllowedBottomSheet(context, onSuccess) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + titleView.setText(R.string.account_watch_key_missing_title) + subtitleView.setText(R.string.account_watch_key_missing_description) + + applyDashedIconStyle(R.drawable.ic_key_missing) + } +} diff --git a/feature-account-impl/src/main/res/color/color_delete_button.xml b/feature-account-impl/src/main/res/color/color_delete_button.xml new file mode 100644 index 0000000..9d64dba --- /dev/null +++ b/feature-account-impl/src/main/res/color/color_delete_button.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/drawable/bg_confirm_mnemonic_word.xml b/feature-account-impl/src/main/res/drawable/bg_confirm_mnemonic_word.xml new file mode 100644 index 0000000..9bd6773 --- /dev/null +++ b/feature-account-impl/src/main/res/drawable/bg_confirm_mnemonic_word.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/feature-account-impl/src/main/res/drawable/bg_pincode_control_button.xml b/feature-account-impl/src/main/res/drawable/bg_pincode_control_button.xml new file mode 100644 index 0000000..1ce8f59 --- /dev/null +++ b/feature-account-impl/src/main/res/drawable/bg_pincode_control_button.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/drawable/bg_pincode_number_button.xml b/feature-account-impl/src/main/res/drawable/bg_pincode_number_button.xml new file mode 100644 index 0000000..21da309 --- /dev/null +++ b/feature-account-impl/src/main/res/drawable/bg_pincode_number_button.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/drawable/ic_pincode_indicator_active.xml b/feature-account-impl/src/main/res/drawable/ic_pincode_indicator_active.xml new file mode 100644 index 0000000..7b6a8c5 --- /dev/null +++ b/feature-account-impl/src/main/res/drawable/ic_pincode_indicator_active.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/feature-account-impl/src/main/res/drawable/ic_pincode_indicator_inactive.xml b/feature-account-impl/src/main/res/drawable/ic_pincode_indicator_inactive.xml new file mode 100644 index 0000000..4d1e79e --- /dev/null +++ b/feature-account-impl/src/main/res/drawable/ic_pincode_indicator_inactive.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/bottom_sheet_delegated_account_updates.xml b/feature-account-impl/src/main/res/layout/bottom_sheet_delegated_account_updates.xml new file mode 100644 index 0000000..c6980ba --- /dev/null +++ b/feature-account-impl/src/main/res/layout/bottom_sheet_delegated_account_updates.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/bottom_sheet_nested_warning.xml b/feature-account-impl/src/main/res/layout/bottom_sheet_nested_warning.xml new file mode 100644 index 0000000..5a1afe7 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/bottom_sheet_nested_warning.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_accounts.xml b/feature-account-impl/src/main/res/layout/fragment_accounts.xml new file mode 100644 index 0000000..d58fd85 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_accounts.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_advanced_encryption.xml b/feature-account-impl/src/main/res/layout/fragment_advanced_encryption.xml new file mode 100644 index 0000000..b8932d4 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_advanced_encryption.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_backup_mnemonic.xml b/feature-account-impl/src/main/res/layout/fragment_backup_mnemonic.xml new file mode 100644 index 0000000..75674e3 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_backup_mnemonic.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_chain_address_selector.xml b/feature-account-impl/src/main/res/layout/fragment_chain_address_selector.xml new file mode 100644 index 0000000..88bb9a9 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_chain_address_selector.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_change_watch_wallet.xml b/feature-account-impl/src/main/res/layout/fragment_change_watch_wallet.xml new file mode 100644 index 0000000..6cdcd08 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_change_watch_wallet.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_confirm_mnemonic.xml b/feature-account-impl/src/main/res/layout/fragment_confirm_mnemonic.xml new file mode 100644 index 0000000..be9356e --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_confirm_mnemonic.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_create_cloud_backup_password.xml b/feature-account-impl/src/main/res/layout/fragment_create_cloud_backup_password.xml new file mode 100644 index 0000000..e51b42a --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_create_cloud_backup_password.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_create_watch_wallet.xml b/feature-account-impl/src/main/res/layout/fragment_create_watch_wallet.xml new file mode 100644 index 0000000..93ad61a --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_create_watch_wallet.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_edit_accounts.xml b/feature-account-impl/src/main/res/layout/fragment_edit_accounts.xml new file mode 100644 index 0000000..f584b51 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_edit_accounts.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_export_json_confirm.xml b/feature-account-impl/src/main/res/layout/fragment_export_json_confirm.xml new file mode 100644 index 0000000..9b54e93 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_export_json_confirm.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_export_json_password.xml b/feature-account-impl/src/main/res/layout/fragment_export_json_password.xml new file mode 100644 index 0000000..4782ef7 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_export_json_password.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_export_seed.xml b/feature-account-impl/src/main/res/layout/fragment_export_seed.xml new file mode 100644 index 0000000..209ab53 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_export_seed.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_import_account.xml b/feature-account-impl/src/main/res/layout/fragment_import_account.xml new file mode 100644 index 0000000..7756d14 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_import_account.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_import_parity_signer_scan.xml b/feature-account-impl/src/main/res/layout/fragment_import_parity_signer_scan.xml new file mode 100644 index 0000000..5b0e79e --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_import_parity_signer_scan.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_import_parity_signer_start.xml b/feature-account-impl/src/main/res/layout/fragment_import_parity_signer_start.xml new file mode 100644 index 0000000..63c1aa9 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_import_parity_signer_start.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_languages.xml b/feature-account-impl/src/main/res/layout/fragment_languages.xml new file mode 100644 index 0000000..a9d25f2 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_languages.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_manual_backup_advanced_secrets.xml b/feature-account-impl/src/main/res/layout/fragment_manual_backup_advanced_secrets.xml new file mode 100644 index 0000000..5abe8b0 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_manual_backup_advanced_secrets.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_manual_backup_secrets.xml b/feature-account-impl/src/main/res/layout/fragment_manual_backup_secrets.xml new file mode 100644 index 0000000..22db783 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_manual_backup_secrets.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_manual_backup_select_account.xml b/feature-account-impl/src/main/res/layout/fragment_manual_backup_select_account.xml new file mode 100644 index 0000000..596a3dd --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_manual_backup_select_account.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_manual_backup_select_wallet.xml b/feature-account-impl/src/main/res/layout/fragment_manual_backup_select_wallet.xml new file mode 100644 index 0000000..596a3dd --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_manual_backup_select_wallet.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_manual_backup_warning.xml b/feature-account-impl/src/main/res/layout/fragment_manual_backup_warning.xml new file mode 100644 index 0000000..9d79b51 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_manual_backup_warning.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_node_add.xml b/feature-account-impl/src/main/res/layout/fragment_node_add.xml new file mode 100644 index 0000000..21261a2 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_node_add.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_node_details.xml b/feature-account-impl/src/main/res/layout/fragment_node_details.xml new file mode 100644 index 0000000..39616d5 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_node_details.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_nodes.xml b/feature-account-impl/src/main/res/layout/fragment_nodes.xml new file mode 100644 index 0000000..56e82da --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_nodes.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_pincode.xml b/feature-account-impl/src/main/res/layout/fragment_pincode.xml new file mode 100644 index 0000000..92c52dd --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_pincode.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_restore_cloud_backup.xml b/feature-account-impl/src/main/res/layout/fragment_restore_cloud_backup.xml new file mode 100644 index 0000000..dde259c --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_restore_cloud_backup.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_scan_seed.xml b/feature-account-impl/src/main/res/layout/fragment_scan_seed.xml new file mode 100644 index 0000000..49ae8b8 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_scan_seed.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_select_multiple_wallets.xml b/feature-account-impl/src/main/res/layout/fragment_select_multiple_wallets.xml new file mode 100644 index 0000000..9d6fd87 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_select_multiple_wallets.xml @@ -0,0 +1,35 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_select_single_wallet.xml b/feature-account-impl/src/main/res/layout/fragment_select_single_wallet.xml new file mode 100644 index 0000000..6f70387 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_select_single_wallet.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_sign_parity_signer_scan.xml b/feature-account-impl/src/main/res/layout/fragment_sign_parity_signer_scan.xml new file mode 100644 index 0000000..6dba6c2 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_sign_parity_signer_scan.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/feature-account-impl/src/main/res/layout/fragment_sign_parity_signer_show.xml b/feature-account-impl/src/main/res/layout/fragment_sign_parity_signer_show.xml new file mode 100644 index 0000000..880644d --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_sign_parity_signer_show.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_start_create_wallet.xml b/feature-account-impl/src/main/res/layout/fragment_start_create_wallet.xml new file mode 100644 index 0000000..169bff8 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_start_create_wallet.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_wallet_details.xml b/feature-account-impl/src/main/res/layout/fragment_wallet_details.xml new file mode 100644 index 0000000..2457e49 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_wallet_details.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/fragment_wallet_list.xml b/feature-account-impl/src/main/res/layout/fragment_wallet_list.xml new file mode 100644 index 0000000..5008bb9 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/fragment_wallet_list.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/import_source_json.xml b/feature-account-impl/src/main/res/layout/import_source_json.xml new file mode 100644 index 0000000..2fb4b15 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/import_source_json.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/import_source_mnemonic.xml b/feature-account-impl/src/main/res/layout/import_source_mnemonic.xml new file mode 100644 index 0000000..4fdebc9 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/import_source_mnemonic.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/import_source_seed.xml b/feature-account-impl/src/main/res/layout/import_source_seed.xml new file mode 100644 index 0000000..732fab0 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/import_source_seed.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_account_by_network.xml b/feature-account-impl/src/main/res/layout/item_account_by_network.xml new file mode 100644 index 0000000..60bfb5f --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_account_by_network.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_backup_account.xml b/feature-account-impl/src/main/res/layout/item_backup_account.xml new file mode 100644 index 0000000..f39531f --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_backup_account.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_backup_account_header.xml b/feature-account-impl/src/main/res/layout/item_backup_account_header.xml new file mode 100644 index 0000000..dcc401d --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_backup_account_header.xml @@ -0,0 +1,16 @@ + + diff --git a/feature-account-impl/src/main/res/layout/item_backup_mnemonic_word.xml b/feature-account-impl/src/main/res/layout/item_backup_mnemonic_word.xml new file mode 100644 index 0000000..1f63603 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_backup_mnemonic_word.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_edit_account.xml b/feature-account-impl/src/main/res/layout/item_edit_account.xml new file mode 100644 index 0000000..5683f3a --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_edit_account.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_encryption_type.xml b/feature-account-impl/src/main/res/layout/item_encryption_type.xml new file mode 100644 index 0000000..43a94c3 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_encryption_type.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_import_parity_signer_page.xml b/feature-account-impl/src/main/res/layout/item_import_parity_signer_page.xml new file mode 100644 index 0000000..f19e849 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_import_parity_signer_page.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/feature-account-impl/src/main/res/layout/item_language.xml b/feature-account-impl/src/main/res/layout/item_language.xml new file mode 100644 index 0000000..6aefd33 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_language.xml @@ -0,0 +1,50 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_manual_backup_chain.xml b/feature-account-impl/src/main/res/layout/item_manual_backup_chain.xml new file mode 100644 index 0000000..75d1505 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_manual_backup_chain.xml @@ -0,0 +1,7 @@ + + diff --git a/feature-account-impl/src/main/res/layout/item_manual_backup_crypto_type.xml b/feature-account-impl/src/main/res/layout/item_manual_backup_crypto_type.xml new file mode 100644 index 0000000..4524385 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_manual_backup_crypto_type.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_manual_backup_json.xml b/feature-account-impl/src/main/res/layout/item_manual_backup_json.xml new file mode 100644 index 0000000..255a8a5 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_manual_backup_json.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_manual_backup_mnemonic.xml b/feature-account-impl/src/main/res/layout/item_manual_backup_mnemonic.xml new file mode 100644 index 0000000..15094da --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_manual_backup_mnemonic.xml @@ -0,0 +1,9 @@ + + diff --git a/feature-account-impl/src/main/res/layout/item_manual_backup_seed.xml b/feature-account-impl/src/main/res/layout/item_manual_backup_seed.xml new file mode 100644 index 0000000..971ef80 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_manual_backup_seed.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_manual_backup_subtitle.xml b/feature-account-impl/src/main/res/layout/item_manual_backup_subtitle.xml new file mode 100644 index 0000000..98a5a9a --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_manual_backup_subtitle.xml @@ -0,0 +1,9 @@ + + diff --git a/feature-account-impl/src/main/res/layout/item_manual_backup_title.xml b/feature-account-impl/src/main/res/layout/item_manual_backup_title.xml new file mode 100644 index 0000000..32c864d --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_manual_backup_title.xml @@ -0,0 +1,9 @@ + + diff --git a/feature-account-impl/src/main/res/layout/item_mnemonic_word.xml b/feature-account-impl/src/main/res/layout/item_mnemonic_word.xml new file mode 100644 index 0000000..d4e7873 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_mnemonic_word.xml @@ -0,0 +1,29 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_network.xml b/feature-account-impl/src/main/res/layout/item_network.xml new file mode 100644 index 0000000..ddca0f6 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_network.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_node.xml b/feature-account-impl/src/main/res/layout/item_node.xml new file mode 100644 index 0000000..9083dd8 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_node.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/item_node_group.xml b/feature-account-impl/src/main/res/layout/item_node_group.xml new file mode 100644 index 0000000..3b255ae --- /dev/null +++ b/feature-account-impl/src/main/res/layout/item_node_group.xml @@ -0,0 +1,18 @@ + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/pincode_view.xml b/feature-account-impl/src/main/res/layout/pincode_view.xml new file mode 100644 index 0000000..9c41618 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/pincode_view.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-account-impl/src/main/res/layout/view_mnemonic.xml b/feature-account-impl/src/main/res/layout/view_mnemonic.xml new file mode 100644 index 0000000..0850622 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/view_mnemonic.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/layout/view_mnemonic_card_view.xml b/feature-account-impl/src/main/res/layout/view_mnemonic_card_view.xml new file mode 100644 index 0000000..6d702a0 --- /dev/null +++ b/feature-account-impl/src/main/res/layout/view_mnemonic_card_view.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/values-hdpi/dimens.xml b/feature-account-impl/src/main/res/values-hdpi/dimens.xml new file mode 100644 index 0000000..ba39516 --- /dev/null +++ b/feature-account-impl/src/main/res/values-hdpi/dimens.xml @@ -0,0 +1,4 @@ + + + 60dp + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/values-xhdpi/dimens.xml b/feature-account-impl/src/main/res/values-xhdpi/dimens.xml new file mode 100644 index 0000000..e824d13 --- /dev/null +++ b/feature-account-impl/src/main/res/values-xhdpi/dimens.xml @@ -0,0 +1,4 @@ + + + 80dp + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/values/attrs.xml b/feature-account-impl/src/main/res/values/attrs.xml new file mode 100644 index 0000000..fc7af3e --- /dev/null +++ b/feature-account-impl/src/main/res/values/attrs.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/values/dimens.xml b/feature-account-impl/src/main/res/values/dimens.xml new file mode 100644 index 0000000..76cafdc --- /dev/null +++ b/feature-account-impl/src/main/res/values/dimens.xml @@ -0,0 +1,11 @@ + + + 10dp + 80dp + 16dp + 16dp + 2dp + 25sp + 2dp + 64dp + \ No newline at end of file diff --git a/feature-account-impl/src/main/res/values/styles.xml b/feature-account-impl/src/main/res/values/styles.xml new file mode 100644 index 0000000..ceb773b --- /dev/null +++ b/feature-account-impl/src/main/res/values/styles.xml @@ -0,0 +1,26 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/TrustWalletDerivationTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/TrustWalletDerivationTest.kt new file mode 100644 index 0000000..1cab880 --- /dev/null +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/TrustWalletDerivationTest.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_account_impl.data + +import io.novafoundation.nova.common.utils.DEFAULT_DERIVATION_PATH +import io.novafoundation.nova.common.utils.ethereumAddressToAccountId +import io.novafoundation.nova.common.utils.substrateAccountId +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.Bip32EcdsaKeypairFactory +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.Bip32Ed25519KeypairFactory +import io.novasama.substrate_sdk_android.encrypt.keypair.bip32.generate +import io.novasama.substrate_sdk_android.encrypt.seed.bip39.Bip39SeedFactory +import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class TrustWalletDerivationTest { + + @Test + fun `should derive substrate address from trust wallet`() { + val mnemonic = "fine engage seed popular upon round differ belt engage space author pet" + val expectedAccountId = "16PfWao1oeVQXAK6Qvj4owWVg49toh9ARbRmuCA3F2Gwxi3z".toAccountId() + + val seed = Bip39SeedFactory.deriveSeed(mnemonic, password = null) + val keypair = Bip32Ed25519KeypairFactory.generate(seed.seed, "//44//354//0//0//0") + + val actualAccountId = keypair.publicKey.substrateAccountId() + + assertArrayEquals(expectedAccountId, actualAccountId) + } + + @Test + fun `should derive evm address from trust wallet`() { + val mnemonic = "fine engage seed popular upon round differ belt engage space author pet" + val expectedAccountId = "0x23502dd7D8357eB3E218269224031EE56A6DA84D".ethereumAddressToAccountId() + + val seed = Bip39SeedFactory.deriveSeed(mnemonic, password = null) + val keypair = Bip32EcdsaKeypairFactory.generate(seed.seed, BIP32JunctionDecoder.DEFAULT_DERIVATION_PATH) + + val actualAccountId = keypair.publicKey.asEthereumPublicKey().toAccountId().value + + assertArrayEquals(expectedAccountId, actualAccountId) + } +} diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt new file mode 100644 index 0000000..864d33f --- /dev/null +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt @@ -0,0 +1,1279 @@ +package io.novafoundation.nova.feature_account_impl.data.cloudBackup + +import com.google.gson.Gson +import io.novafoundation.feature_cloud_backup_test.TEST_MODIFIED_AT +import io.novafoundation.feature_cloud_backup_test.buildTestCloudBackup +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_api.data.cloudBackup.CLOUD_BACKUP_APPLY_SOURCE +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus.Event.AccountAdded +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus.Event.AccountNameChanged +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus.Event.AccountRemoved +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus.Event.AccountStructureChanged +import io.novafoundation.nova.feature_account_api.data.events.buildChangesEvent +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers +import io.novafoundation.nova.feature_account_impl.data.multisig.MultisigRepository +import io.novafoundation.nova.feature_account_impl.mock.LocalAccountsMocker +import io.novafoundation.nova.feature_account_impl.mock.SecretStoreMocker +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.KeyPairSecrets +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.localVsCloudDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategy +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.argThat +import io.novafoundation.nova.test_shared.eq +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.encrypt.keypair.BaseKeypair +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.Sr25519Keypair +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.anyLong +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + + +@RunWith(MockitoJUnitRunner::class) +class RealLocalAccountsCloudBackupFacadeTest { + + @Mock + lateinit var multisigRepository: MultisigRepository + + @Mock + lateinit var metaAccountDao: MetaAccountDao + + @Mock + lateinit var cloudBackupAccountsModificationsTracker: CloudBackupAccountsModificationsTracker + + @Mock + lateinit var metaAccountChangesEventBus: MetaAccountChangesEventBus + + @Mock + lateinit var secretStore: SecretStoreV2 + + lateinit var facade: RealLocalAccountsCloudBackupFacade + + @Mock + lateinit var chainRegistry: ChainRegistry + + @Mock + lateinit var ledgerMigrationTracker: LedgerMigrationTracker + + @Mock + lateinit var gson: Gson + + private val ethereumDerivationPath = "//44//60//0/0/0" + + + @Before + fun setup() { + facade = RealLocalAccountsCloudBackupFacade( + secretsStoreV2 = secretStore, + accountDao = metaAccountDao, + cloudBackupAccountsModificationsTracker = cloudBackupAccountsModificationsTracker, + metaAccountChangedEvents = metaAccountChangesEventBus, + chainRegistry = chainRegistry, + accountMappers = AccountMappers(ledgerMigrationTracker, gson, multisigRepository) + + ) + + whenever(cloudBackupAccountsModificationsTracker.getAccountsLastModifiedAt()).thenReturn(TEST_MODIFIED_AT) + } + + @Test + fun fullBackupInfoForDefaultBuilderConfiguration() = runBlocking { + val uuid = "id" + + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + metaAccount(0) { + globallyUniqueId(uuid) + } + } + + SecretStoreMocker.setupMocks(secretStore) { + metaAccount(0) {} + } + + val expectedCloudBackup = buildTestCloudBackup { + publicData { + wallet(uuid) {} + } + + privateData { + wallet(uuid) {} + } + } + + val actualCloudBackup = facade.fullBackupInfoFromLocalSnapshot() + + assertEquals(expectedCloudBackup, actualCloudBackup) + } + + @Test + fun shouldConstructFullBackupOfBaseSubstrate() = runBlocking { + val uuid = "id" + + val zero32Bytes = ByteArray(32) + + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + metaAccount(0) { + globallyUniqueId(uuid) + + substrateAccountId(zero32Bytes) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(zero32Bytes) + } + } + + SecretStoreMocker.setupMocks(secretStore) { + metaAccount(0) { + entropy(zero32Bytes) + seed(zero32Bytes) + substrateKeypair(Sr25519Keypair(zero32Bytes, zero32Bytes, zero32Bytes)) + } + } + + val expectedCloudBackup = buildTestCloudBackup { + publicData { + wallet(uuid) { + substrateAccountId(zero32Bytes) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(zero32Bytes) + } + } + + privateData { + wallet(uuid) { + entropy(zero32Bytes) + + substrate { + seed(zero32Bytes) + keypair(KeyPairSecrets(zero32Bytes, zero32Bytes, zero32Bytes)) + } + } + } + } + + val actualCloudBackup = facade.fullBackupInfoFromLocalSnapshot() + + assertEquals(expectedCloudBackup, actualCloudBackup) + } + + @Test + fun shouldConstructFullBackupOfMultipleWallets() = runBlocking { + val walletsCount = 3 + + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + generateWallets(walletsCount) { walletIndex, uuid, bytes32, bytes20 -> + metaAccount(walletIndex) { + globallyUniqueId(uuid) + + substrateAccountId(bytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(bytes32) + + ethereumPublicKey(bytes32) + ethereumAddress(bytes20) + + chainAccount(chainId(walletIndex)) { + publicKey(bytes32) + accountId(bytes32) + cryptoType(CryptoType.ED25519) + } + } + } + } + + SecretStoreMocker.setupMocks(secretStore) { + generateWallets(walletsCount) { walletIndex, _, bytes32, _ -> + metaAccount(walletIndex) { + entropy(bytes32) + seed(bytes32) + substrateKeypair(Sr25519Keypair(bytes32, bytes32, bytes32)) + + ethereumKeypair(BaseKeypair(bytes32, bytes32)) + ethereumDerivationPath(ethereumDerivationPath) + + chainAccount(accountId = bytes32) { + entropy(bytes32) + seed(bytes32) + derivationPath("//${walletIndex}") + keypair(BaseKeypair(bytes32, bytes32)) + } + } + } + } + + val expectedCloudBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount) { index, uuid, bytes32, bytes20 -> + wallet(uuid) { + substrateAccountId(bytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(bytes32) + + ethereumPublicKey(bytes32) + ethereumAddress(bytes20) + + chainAccount(chainId(index)) { + publicKey(bytes32) + accountId(bytes32) + cryptoType(ChainAccountCryptoType.ED25519) + } + } + } + } + + privateData { + generateWallets(walletsCount) { index, uuid, bytes32, _ -> + wallet(uuid) { + entropy(bytes32) + + substrate { + seed(bytes32) + keypair(KeyPairSecrets(bytes32, bytes32, bytes32)) + } + + ethereum { + derivationPath(ethereumDerivationPath) + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + + chainAccount(accountId = bytes32) { + entropy(bytes32) + seed(bytes32) + derivationPath("//${index}") + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + } + } + } + } + + val actualCloudBackup = facade.fullBackupInfoFromLocalSnapshot() + + assertEquals(expectedCloudBackup, actualCloudBackup) + } + + @Test + fun shouldConstructFullBackupOfLegacyLedgerAccount() = runBlocking { + val uuid = "id" + + val zero32Bytes = ByteArray(32) + val chainId = chainId(0) + val metaId = 0L + + val ledgerDerivationPathSecretName = expectedLegacyLedgerDerivationPathKey(chainId = chainId) + + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + metaAccount(metaId) { + globallyUniqueId(uuid) + type(MetaAccountLocal.Type.LEDGER) + + chainAccount(chainId) { + accountId(zero32Bytes) + } + } + } + + SecretStoreMocker.setupMocks(secretStore) { + metaAccount(metaId) { + additional { + put(ledgerDerivationPathSecretName, ethereumDerivationPath) + } + } + } + + val expectedCloudBackup = buildTestCloudBackup { + publicData { + wallet(uuid) { + type(WalletPublicInfo.Type.LEDGER) + + chainAccount(chainId) { + accountId(zero32Bytes) + } + } + } + + privateData { + wallet(uuid) { + chainAccount(zero32Bytes) { + derivationPath(ethereumDerivationPath) + } + } + } + } + + val actualCloudBackup = facade.fullBackupInfoFromLocalSnapshot() + + assertEquals(expectedCloudBackup, actualCloudBackup) + } + + @Test + fun shouldConstructFullBackupOfGenericLedgerAccount() = runBlocking { + val uuid = "id" + + val zero32Bytes = ByteArray(32) + val metaId = 0L + + val ledgerDerivationPathSecretName = expectedGenericLedgerDerivationPathKey() + + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + metaAccount(metaId) { + globallyUniqueId(uuid) + type(MetaAccountLocal.Type.LEDGER_GENERIC) + + substrateAccountId(zero32Bytes) + substratePublicKey(zero32Bytes) + substrateCryptoType(CryptoType.ED25519) + } + } + + SecretStoreMocker.setupMocks(secretStore) { + metaAccount(metaId) { + additional { + put(ledgerDerivationPathSecretName, ethereumDerivationPath) + } + } + } + + val expectedCloudBackup = buildTestCloudBackup { + publicData { + wallet(uuid) { + type(WalletPublicInfo.Type.LEDGER_GENERIC) + + substrateAccountId(zero32Bytes) + substratePublicKey(zero32Bytes) + substrateCryptoType(CryptoType.ED25519) + } + } + + privateData { + wallet(uuid) { + substrate { + derivationPath(ethereumDerivationPath) + } + } + } + } + + val actualCloudBackup = facade.fullBackupInfoFromLocalSnapshot() + + assertEquals(expectedCloudBackup, actualCloudBackup) + } + + @Test + fun shouldConstructFullBackupOfEvmChainAccount() = runBlocking { + val uuid = "id" + + val zero32Bytes = ByteArray(32) + val chainId = chainId(0) + val metaId = 0L + + allChainsAreEvm(true) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + metaAccount(metaId) { + globallyUniqueId(uuid) + + chainAccount(chainId) { + accountId(zero32Bytes) + cryptoType(CryptoType.ECDSA) + } + } + } + + SecretStoreMocker.setupMocks(secretStore) { + metaAccount(metaId) { + chainAccount(accountId = zero32Bytes) { + keypair(BaseKeypair(zero32Bytes, zero32Bytes)) + } + } + } + + val expectedCloudBackup = buildTestCloudBackup { + publicData { + wallet(uuid) { + chainAccount(chainId) { + accountId(zero32Bytes) + cryptoType(ChainAccountCryptoType.ETHEREUM) + } + } + } + + privateData { + wallet(uuid) { + chainAccount(zero32Bytes) { + keypair(KeyPairSecrets(zero32Bytes, zero32Bytes, nonce = null)) + } + } + } + } + + val actualCloudBackup = facade.fullBackupInfoFromLocalSnapshot() + + assertEquals(expectedCloudBackup, actualCloudBackup) + } + + @Test + fun shouldApplyAddAccountDiff() = runBlocking { + LocalAccountsMocker.setupMocks(metaAccountDao) {} + SecretStoreMocker.setupMocks(secretStore) {} + + allChainsAreEvm(false) + + val localBackup = buildTestCloudBackup { + publicData { + } + + privateData { } + } + + val cloudBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, bytes20 -> + wallet(uuid) { + substrateAccountId(bytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(bytes32) + + ethereumPublicKey(bytes32) + ethereumAddress(bytes20) + + chainAccount(chainId(index)) { + publicKey(bytes32) + accountId(bytes32) + cryptoType(ChainAccountCryptoType.ED25519) + } + } + } + } + + privateData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, _ -> + wallet(uuid) { + entropy(bytes32) + + substrate { + seed(bytes32) + keypair(KeyPairSecrets(bytes32, bytes32, bytes32)) + } + + ethereum { + derivationPath(ethereumDerivationPath) + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + + chainAccount(accountId = bytes32) { + entropy(bytes32) + seed(bytes32) + derivationPath("//${index}") + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + } + } + } + } + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + facade.applyBackupDiff(diff, cloudBackup) + + val bytes32 = bytes32of(0) + + verify(metaAccountDao).insertMetaAccount(metaAccountWithUuid(walletUUid(0))) + verify(metaAccountDao).insertChainAccounts(singleChainAccountWithAccountId(bytes32)) + + verify(secretStore).putMetaAccountSecrets(eq(0), metaAccountSecretsWithEntropy(bytes32)) + verify(secretStore).putChainAccountSecrets(eq(0), byteArrayEq(bytes32), chainAccountSecretsWithEntropy(bytes32)) + + // Secrets wallet doesnt have any additional secrets + verifyNoAdditionalSecretsInserted() + + // no deletes happened + verify(secretStore, never()).clearChainAccountsSecrets(anyLong(), any()) + verify(secretStore, never()).clearMetaAccountSecrets(anyLong(), any()) + verify(metaAccountDao, never()).delete(any>()) + + // no modifications happened + verify(metaAccountDao, never()).updateMetaAccount(any()) + verify(metaAccountDao, never()).deleteChainAccounts(any()) + + val expectedEvent = buildChangesEvent { + add(AccountAdded(metaId = 0, LightMetaAccount.Type.SECRETS)) + } + verifyEvent(expectedEvent) + } + + @Test + fun shouldApplyRemoveAccountDiff(): Unit = runBlocking { + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + generateWallets(walletsCount = 1) { walletIndex, uuid, bytes32, bytes20 -> + metaAccount(walletIndex) { + globallyUniqueId(uuid) + + substrateAccountId(bytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(bytes32) + + ethereumPublicKey(bytes32) + ethereumAddress(bytes20) + + chainAccount(chainId(walletIndex)) { + publicKey(bytes32) + accountId(bytes32) + cryptoType(CryptoType.ED25519) + } + } + } + } + SecretStoreMocker.setupMocks(secretStore) { + generateWallets(walletsCount = 1) { walletIndex, _, bytes32, _ -> + metaAccount(walletIndex) { + entropy(bytes32) + seed(bytes32) + substrateKeypair(Sr25519Keypair(bytes32, bytes32, bytes32)) + + ethereumKeypair(BaseKeypair(bytes32, bytes32)) + ethereumDerivationPath(ethereumDerivationPath) + + chainAccount(accountId = bytes32) { + entropy(bytes32) + seed(bytes32) + derivationPath("//${walletIndex}") + keypair(BaseKeypair(bytes32, bytes32)) + } + } + } + } + + val localBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, bytes20 -> + wallet(uuid) { + substrateAccountId(bytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(bytes32) + + ethereumPublicKey(bytes32) + ethereumAddress(bytes20) + + chainAccount(chainId(index)) { + publicKey(bytes32) + accountId(bytes32) + cryptoType(ChainAccountCryptoType.ED25519) + } + } + } + } + + privateData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, _ -> + wallet(uuid) { + entropy(bytes32) + + substrate { + seed(bytes32) + keypair(KeyPairSecrets(bytes32, bytes32, bytes32)) + } + + ethereum { + derivationPath(ethereumDerivationPath) + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + + chainAccount(accountId = bytes32) { + entropy(bytes32) + seed(bytes32) + derivationPath("//${index}") + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + } + } + } + } + + val cloudBackup = buildTestCloudBackup { + publicData { } + + privateData { } + } + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + facade.applyBackupDiff(diff, cloudBackup) + + val bytes32 = bytes32of(0) + val chainAccountIds = listOf(bytes32) + + verify(metaAccountDao).delete(singleMetaIdListOf(0)) + verify(secretStore).clearMetaAccountSecrets(eq(0), byteArrayListEq(chainAccountIds)) + + // no additions happened + verify(secretStore, never()).putMetaAccountSecrets(anyLong(), any()) + verify(secretStore, never()).putChainAccountSecrets(anyLong(), any(), any()) + verify(metaAccountDao, never()).insertMetaAccount(any()) + verify(metaAccountDao, never()).insertChainAccounts(any()) + verifyNoAdditionalSecretsInserted() + + // no modifications happened + verify(metaAccountDao, never()).updateMetaAccount(any()) + + val expectedEvent = buildChangesEvent { + add(AccountRemoved(metaId = 0, LightMetaAccount.Type.SECRETS)) + } + verifyEvent(expectedEvent) + } + + // Tests that we will apply chan account deletion and entropy change + @Test + fun shouldApplyModifyAccountDiff(): Unit = runBlocking { + val changedBytes32 = bytes32of(3) + + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + generateWallets(walletsCount = 1) { walletIndex, uuid, bytes32, bytes20 -> + metaAccount(walletIndex) { + globallyUniqueId(uuid) + + substrateAccountId(bytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(bytes32) + + ethereumPublicKey(bytes32) + ethereumAddress(bytes20) + + chainAccount(chainId(walletIndex)) { + publicKey(bytes32) + accountId(bytes32) + cryptoType(CryptoType.ED25519) + } + } + } + } + SecretStoreMocker.setupMocks(secretStore) { + generateWallets(walletsCount = 1) { walletIndex, _, bytes32, _ -> + metaAccount(walletIndex) { + entropy(bytes32) + seed(bytes32) + substrateKeypair(Sr25519Keypair(bytes32, bytes32, bytes32)) + + ethereumKeypair(BaseKeypair(bytes32, bytes32)) + ethereumDerivationPath(ethereumDerivationPath) + + chainAccount(accountId = bytes32) { + entropy(bytes32) + seed(bytes32) + derivationPath("//${walletIndex}") + keypair(BaseKeypair(bytes32, bytes32)) + } + } + } + } + + val localBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, bytes20 -> + wallet(uuid) { + substrateAccountId(bytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(bytes32) + + ethereumPublicKey(bytes32) + ethereumAddress(bytes20) + + chainAccount(chainId(index)) { + publicKey(bytes32) + accountId(bytes32) + cryptoType(ChainAccountCryptoType.ED25519) + } + } + } + } + + privateData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, _ -> + wallet(uuid) { + entropy(bytes32) + + substrate { + seed(bytes32) + keypair(KeyPairSecrets(bytes32, bytes32, bytes32)) + } + + ethereum { + derivationPath(ethereumDerivationPath) + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + + chainAccount(accountId = bytes32) { + entropy(bytes32) + seed(bytes32) + derivationPath("//${index}") + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + } + } + } + } + + val cloudBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount = 1) { _, uuid, bytes32, bytes20 -> + wallet(uuid) { + substrateAccountId(changedBytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(changedBytes32) + + ethereumPublicKey(bytes32) + ethereumAddress(bytes20) + } + } + } + + privateData { + generateWallets(walletsCount = 1) { _, uuid, bytes32, _ -> + wallet(uuid) { + entropy(changedBytes32) + + substrate { + seed(changedBytes32) + keypair(KeyPairSecrets(changedBytes32, changedBytes32, changedBytes32)) + } + + ethereum { + derivationPath(ethereumDerivationPath) + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + } + } + } + } + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + facade.applyBackupDiff(diff, cloudBackup) + + val oldBytes32 = bytes32of(0) + val oldBytes20 = bytes20of(0) + val chainAccountIds = listOf(oldBytes32) + val uuid = walletUUid(0) + + // Meta account got updated with new accountId but ethereum address stays the same + verify(metaAccountDao).updateMetaAccount(argThat { + it.globallyUniqueId == uuid && it.substrateAccountId.contentEquals(changedBytes32) && + it.ethereumAddress.contentEquals(oldBytes20) + }) + + // Chain account was removed + verify(metaAccountDao).deleteChainAccounts(argThat { + it.size == 1 && it.single().accountId.contentEquals(oldBytes32) + }) + + // No new chain accounts were inserted + verify(metaAccountDao, never()).insertChainAccounts(any()) + + // Entropy was updated + verify(secretStore).putMetaAccountSecrets(eq(0), metaAccountSecretsWithEntropy(changedBytes32)) + + verify(secretStore).clearChainAccountsSecrets(eq(0), byteArrayListEq(chainAccountIds)) + + // No new chain account secrets were inserted + verify(secretStore, never()).putChainAccountSecrets(anyLong(), any(), any()) + + // Secrets wallet doesn't have additional secrets + verifyNoAdditionalSecretsInserted() + + // no additions happened + verify(metaAccountDao, never()).insertMetaAccount(any()) + + // no deletes happened + verify(secretStore, never()).clearMetaAccountSecrets(anyLong(), any()) + verify(metaAccountDao, never()).delete(any>()) + + val expectedEvent = buildChangesEvent { + add(AccountStructureChanged(metaId = 0, LightMetaAccount.Type.SECRETS)) + add(AccountNameChanged(metaId = 0, LightMetaAccount.Type.SECRETS)) + } + verifyEvent(expectedEvent) + } + + @Test + fun shouldSaveLegacyLedgerAccountSecrets(): Unit = runBlocking { + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + } + + SecretStoreMocker.setupMocks(secretStore) { + } + + val localBackup = buildTestCloudBackup { + publicData { + } + + privateData { + } + } + + val cloudBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, _ -> + wallet(uuid) { + type(WalletPublicInfo.Type.LEDGER) + + chainAccount(chainId(index)) { + accountId(bytes32) + } + } + } + } + + privateData { + generateWallets(walletsCount = 1) { _, uuid, bytes32, _ -> + wallet(uuid) { + chainAccount(accountId = bytes32) { + derivationPath(ethereumDerivationPath) + } + } + } + } + } + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + facade.applyBackupDiff(diff, cloudBackup) + + val bytes32 = bytes32of(0) + + val ledgerDerivationPathSecretName = expectedLegacyLedgerDerivationPathKey(chainId = chainId(0)) + + verify(metaAccountDao).insertMetaAccount(metaAccountWithUuid(walletUUid(0))) + verify(metaAccountDao).insertChainAccounts(singleChainAccountWithAccountId(bytes32)) + + // there is not base secrets for ledger accounts to there should be no attempts to store base secrets + verify(secretStore, never()).putMetaAccountSecrets(anyLong(), any()) + // the only secret for ledger is chainAccount and it is put to additional and not to chain account secrets + verify(secretStore, never()).putChainAccountSecrets(anyLong(), any(), any()) + + // we put ledger derivation path to the secret store + verify(secretStore).putAdditionalMetaAccountSecret(eq(0), eq(ledgerDerivationPathSecretName), eq(ethereumDerivationPath)) + + // no deletes happened + verify(secretStore, never()).clearChainAccountsSecrets(anyLong(), any()) + verify(secretStore, never()).clearMetaAccountSecrets(anyLong(), any()) + verify(metaAccountDao, never()).delete(any>()) + + // no modifications happened + verify(metaAccountDao, never()).updateMetaAccount(any()) + verify(metaAccountDao, never()).deleteChainAccounts(any()) + + val expectedEvent = buildChangesEvent { + add(AccountAdded(metaId = 0, LightMetaAccount.Type.LEDGER_LEGACY)) + } + verifyEvent(expectedEvent) + } + + @Test + fun shouldSaveGenericLedgerAccountSecrets(): Unit = runBlocking { + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + } + + SecretStoreMocker.setupMocks(secretStore) { + } + + val localBackup = buildTestCloudBackup { + publicData { + } + + privateData { + } + } + + val cloudBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, _ -> + wallet(uuid) { + type(WalletPublicInfo.Type.LEDGER_GENERIC) + + substrateAccountId(bytes32) + substratePublicKey(bytes32) + substrateCryptoType(CryptoType.ED25519) + } + } + } + + privateData { + generateWallets(walletsCount = 1) { _, uuid, bytes32, _ -> + wallet(uuid) { + substrate { + derivationPath(ethereumDerivationPath) + } + } + } + } + } + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + facade.applyBackupDiff(diff, cloudBackup) + + val ledgerDerivationPathSecretName = expectedGenericLedgerDerivationPathKey() + + verify(metaAccountDao).insertMetaAccount(metaAccountWithUuid(walletUUid(0))) + verify(metaAccountDao, never()).insertChainAccounts(any()) + + // there is not base secrets for ledger accounts to there should be no attempts to store base secrets + verify(secretStore, never()).putMetaAccountSecrets(anyLong(), any()) + // the only secret for ledger is chainAccount and it is put to additional and not to chain account secrets + verify(secretStore, never()).putChainAccountSecrets(anyLong(), any(), any()) + + // we put ledger derivation path to the secret store + verify(secretStore).putAdditionalMetaAccountSecret(eq(0), eq(ledgerDerivationPathSecretName), eq(ethereumDerivationPath)) + + // no deletes happened + verify(secretStore, never()).clearChainAccountsSecrets(anyLong(), any()) + verify(secretStore, never()).clearMetaAccountSecrets(anyLong(), any()) + verify(metaAccountDao, never()).delete(any>()) + + // no modifications happened + verify(metaAccountDao, never()).updateMetaAccount(any()) + verify(metaAccountDao, never()).deleteChainAccounts(any()) + + val expectedEvent = buildChangesEvent { + add(AccountAdded(metaId = 0, LightMetaAccount.Type.LEDGER)) + } + verifyEvent(expectedEvent) + } + + // Changing chain account inside ledger wallet + @Test + fun shouldModifyLedgerAccount(): Unit = runBlocking { + val chainId = chainId(0) + + val originalDerivationPath = "//$0" + + val changedBytes32 = bytes32of(3) + val changedDerivationPath = "//3" + + val additionalSecretKey = expectedLegacyLedgerDerivationPathKey(chainId) + + allChainsAreEvm(false) + + LocalAccountsMocker.setupMocks(metaAccountDao) { + generateWallets(walletsCount = 1) { walletIndex, uuid, bytes32, _ -> + metaAccount(walletIndex) { + globallyUniqueId(uuid) + type(MetaAccountLocal.Type.LEDGER) + + chainAccount(chainId) { + accountId(bytes32) + } + } + } + } + SecretStoreMocker.setupMocks(secretStore) { + generateWallets(walletsCount = 1) { walletIndex, _, bytes32, _ -> + metaAccount(walletIndex) { + additional { + put(additionalSecretKey, originalDerivationPath) + } + } + } + } + + val localBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, _ -> + wallet(uuid) { + type(WalletPublicInfo.Type.LEDGER) + + chainAccount(chainId(index)) { + accountId(bytes32) + } + } + } + } + + privateData { + generateWallets(walletsCount = 1) { _, uuid, bytes32, _ -> + wallet(uuid) { + chainAccount(accountId = bytes32) { + derivationPath(originalDerivationPath) + } + } + } + } + } + + val cloudBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount = 1) { index, uuid, _, _ -> + wallet(uuid) { + type(WalletPublicInfo.Type.LEDGER) + + chainAccount(chainId(index)) { + accountId(changedBytes32) + } + } + } + } + + privateData { + generateWallets(walletsCount = 1) { _, uuid, _, _ -> + wallet(uuid) { + chainAccount(accountId = changedBytes32) { + derivationPath(changedDerivationPath) + } + } + } + } + } + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + facade.applyBackupDiff(diff, cloudBackup) + + val oldBytes32 = bytes32of(0) + val oldChainAccountIds = listOf(oldBytes32) + val uuid = walletUUid(0) + + // Meta account got updated with new accountId but ethereum address stays the same + verify(metaAccountDao).updateMetaAccount(argThat { + it.globallyUniqueId == uuid && it.substrateAccountId == null && it.ethereumAddress == null + }) + + // Old chain account was removed + verify(metaAccountDao).deleteChainAccounts(argThat { + it.size == 1 && it.single().accountId.contentEquals(oldBytes32) + }) + + // No new chain accounts were inserted + verify(metaAccountDao).insertChainAccounts(singleChainAccountWithAccountId(changedBytes32)) + + // Base secrets were not updated since they are not present + verify(secretStore, never()).putMetaAccountSecrets(anyLong(), any()) + + verify(secretStore).clearChainAccountsSecrets(eq(0), byteArrayListEq(oldChainAccountIds)) + verify(secretStore, never()).clearMetaAccountSecrets(anyLong(), any()) + + // No new chain account secrets were inserted since its ledger account + verify(secretStore, never()).putChainAccountSecrets(anyLong(), any(), any()) + + verify(secretStore).putAdditionalMetaAccountSecret(eq(0), eq(additionalSecretKey), eq(changedDerivationPath)) + + // no additions happened + verify(metaAccountDao, never()).insertMetaAccount(any()) + + // no deletes happened + verify(secretStore, never()).clearMetaAccountSecrets(anyLong(), any()) + verify(metaAccountDao, never()).delete(any>()) + + val expectedEvent = buildChangesEvent { + add(AccountStructureChanged(metaId = 0, LightMetaAccount.Type.LEDGER_LEGACY)) + add(AccountNameChanged(metaId = 0, LightMetaAccount.Type.LEDGER_LEGACY)) + } + verifyEvent(expectedEvent) + } + + @Test + fun shouldAddEvmChainAccount() = runBlocking { + LocalAccountsMocker.setupMocks(metaAccountDao) {} + SecretStoreMocker.setupMocks(secretStore) {} + + allChainsAreEvm(true) + + val localBackup = buildTestCloudBackup { + publicData { + } + + privateData { } + } + + val cloudBackup = buildTestCloudBackup { + publicData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, _ -> + wallet(uuid) { + chainAccount(chainId(index)) { + publicKey(bytes32) + accountId(bytes32) + cryptoType(ChainAccountCryptoType.ETHEREUM) + } + } + } + } + + privateData { + generateWallets(walletsCount = 1) { index, uuid, bytes32, _ -> + wallet(uuid) { + chainAccount(accountId = bytes32) { + entropy(bytes32) + seed(bytes32) + derivationPath("//${index}") + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + } + } + } + } + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + facade.applyBackupDiff(diff, cloudBackup) + + val bytes32 = bytes32of(0) + + verify(metaAccountDao).insertMetaAccount(metaAccountWithUuid(walletUUid(0))) + verify(metaAccountDao).insertChainAccounts(argThat { + val item = it.single() + + item.accountId.contentEquals(bytes32) && item.cryptoType == CryptoType.ECDSA + }) + } + + private suspend fun verifyEvent(event: MetaAccountChangesEventBus.Event?) { + if (event == null) { + verify(metaAccountChangesEventBus, never()).notify(any(), source = eq(CLOUD_BACKUP_APPLY_SOURCE)) + } else { + verify(metaAccountChangesEventBus).notify(eq(event), source = eq(CLOUD_BACKUP_APPLY_SOURCE)) + } + } + + private fun expectedLegacyLedgerDerivationPathKey(chainId: ChainId): String { + return "LedgerChainAccount.derivationPath.${chainId}" + } + + private fun expectedGenericLedgerDerivationPathKey(): String { + return "LedgerChainAccount.derivationPath.Generic" + } + + private fun singleMetaIdListOf(id: Long): List { + return argThat { it.size == 1 && it.single() == id } + } + + private fun chainAccountSecretsWithEntropy(entropy: ByteArray): EncodableStruct { + return argThat { it.entropy.contentEquals(entropy) } + } + + private fun byteArrayEq(value: ByteArray): ByteArray = argThat { it.contentEquals(value) } + + private fun byteArrayListEq(value: List): List = argThat { + value.zip(it).all { (expected, actual) -> expected.contentEquals(actual) } + } + + private fun metaAccountSecretsWithEntropy(entropy: ByteArray): EncodableStruct { + return argThat { it.entropy.contentEquals(entropy) } + } + + private fun metaAccountWithUuid(id: String): MetaAccountLocal { + return argThat { it.globallyUniqueId == id } + } + + private suspend fun verifyNoAdditionalSecretsInserted() { + verify(secretStore, never()).putAdditionalMetaAccountSecret(anyLong(), any(), any()) + } + + private fun singleChainAccountWithAccountId(accountId: AccountId): List { + return argThat { it.size == 1 && it.single().accountId.contentEquals(accountId) } + } + + private fun multipleChainAccountsWithAccountIds(vararg expectedAccountIds: AccountId): List { + return argThat { chainAccounts -> + val sizeValid = chainAccounts.size == expectedAccountIds.size + val accountIdsMatch = chainAccounts.zip(expectedAccountIds) { actual, expected -> + expected.contentEquals(actual.accountId) + }.all { it } + + sizeValid && accountIdsMatch + } + } + + private fun generateWallets( + walletsCount: Int, + generator: (metaId: Long, uuid: String, bytes32: ByteArray, bytes20: ByteArray) -> Unit + ) { + repeat(walletsCount) { walletIndex -> + val uuid = walletUUid(walletIndex) + val bytes32 = bytes32of(walletIndex) + val bytes20 = bytes20of(walletIndex) + + generator(walletIndex.toLong(), uuid, bytes32, bytes20) + } + } + + private fun walletUUid(idx: Int) = "id${idx}" + private fun bytes32of(byte: Int) = ByteArray(32) { byte.toByte() } + private fun bytes20of(byte: Int) = ByteArray(20) { byte.toByte() } + + private fun chainId(idx: Long) = "0x${idx}" + + private suspend fun chainIsEvm(chainId: ChainId, isEvm: Boolean) { + return chainsAreEvm(mapOf(chainId to isEvm)) + } + + private suspend fun chainsAreEvm(isEvm: Map) { + val chainsById = isEvm.mapValues { (chainId, isEvm) -> + val chainMock = Mockito.mock(Chain::class.java) + whenever(chainMock.isEthereumBased).thenReturn(isEvm) + + chainMock + } + val chainsFlow = MutableSharedFlow>(replay = 1) + chainsFlow.emit(chainsById) + + whenever(chainRegistry.chainsById).thenReturn(chainsFlow) + } + + @Suppress("UNCHECKED_CAST") + private suspend fun allChainsAreEvm(isEvm: Boolean) { + val chainsById: Map = Mockito.mock(Map::class.java) as Map + whenever(chainsById.get(any())).thenAnswer { + val chainMock = Mockito.mock(Chain::class.java) + + whenever(chainMock.isEthereumBased).thenReturn(isEvm) + + chainMock + } + + val chainsFlow = MutableSharedFlow>(replay = 1) + chainsFlow.emit(chainsById) + + whenever(chainRegistry.chainsById).thenReturn(chainsFlow) + } +} diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/mock/LocalAccountsMocker.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/mock/LocalAccountsMocker.kt new file mode 100644 index 0000000..e58edf3 --- /dev/null +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/mock/LocalAccountsMocker.kt @@ -0,0 +1,190 @@ +package io.novafoundation.nova.feature_account_impl.mock + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.JoinedMetaAccountInfo +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountIdWithType +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.core_db.model.chain.account.RelationJoinedMetaAccountInfo +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.whenever +import kotlinx.coroutines.runBlocking + +@DslMarker +annotation class LocalAccountsMockerDsl + +object LocalAccountsMocker { + + @LocalAccountsMockerDsl + suspend fun setupMocks( + dao: MetaAccountDao, + mockBuilder: LocalAccountsMockerBuilder.() -> Unit + ) { + val allJoinedMetaAccountInfo = LocalAccountsMockerBuilder().apply(mockBuilder).build() + + var metaAccountCounter = allJoinedMetaAccountInfo.size + + whenever(dao.runInTransaction(any())).then { invocation -> + val txAction = invocation.arguments.first() as suspend () -> Unit + + runBlocking { txAction() } + } + + whenever(dao.nextAccountPosition()).thenReturn(0) + + whenever(dao.delete(any>())).thenReturn(listOf(MetaAccountIdWithType(0, MetaAccountLocal.Type.SECRETS))) + + whenever(dao.insertMetaAccount(any())).thenAnswer { + metaAccountCounter++ + } + + whenever(dao.getMetaAccountsByStatus(any())).thenAnswer { invocation -> + val status = invocation.arguments.first() as MetaAccountLocal.Status + + allJoinedMetaAccountInfo.filter { it.metaAccount.status == status } + } + } +} + +@LocalAccountsMockerDsl +class LocalAccountsMockerBuilder { + + private val metaAccounts = mutableListOf() + + fun metaAccount(metaId: Long, builder: LocalMetaAccountMockBuilder.() -> Unit) { + metaAccounts.add(LocalMetaAccountMockBuilder(metaId).apply(builder).build()) + } + + fun build(): List { + return metaAccounts + } +} + +@LocalAccountsMockerDsl +class LocalMetaAccountMockBuilder( + private val metaId: Long, +) { + + private val chainAccounts = mutableListOf() + + private var _substratePublicKey: ByteArray? = null + private var _substrateCryptoType: CryptoType? = null + private var _substrateAccountId: ByteArray? = null + private var _ethereumPublicKey: ByteArray? = null + private var _ethereumAddress: ByteArray? = null + private var _name: String = "" + private val _parentMetaId: Long? = null + private var _isSelected: Boolean = false + private var _position: Int = 0 + private var _type: MetaAccountLocal.Type = MetaAccountLocal.Type.SECRETS + private var _status: MetaAccountLocal.Status = MetaAccountLocal.Status.ACTIVE + private var _globallyUniqueId: String = MetaAccountLocal.generateGloballyUniqueId() + private var _typeExtras: String? = null + + + fun chainAccount(chainId: ChainId, builder: LocalChainAccountMockBuilder.() -> Unit) { + val chainAccountLocal = LocalChainAccountMockBuilder(metaId, chainId).apply(builder).build() + chainAccounts.add(chainAccountLocal) + } + + fun substratePublicKey(value: ByteArray?) { + _substratePublicKey = value + } + + fun substrateCryptoType(value: CryptoType?) { + _substrateCryptoType = value + } + + fun substrateAccountId(value: ByteArray?) { + _substrateAccountId = value + } + + fun ethereumPublicKey(value: ByteArray?) { + _ethereumPublicKey = value + } + + fun ethereumAddress(value: ByteArray?) { + _ethereumAddress = value + } + + fun name(value: String) { + _name = value + } + + fun isSelected(value: Boolean) { + _isSelected = value + } + + fun position(value: Int) { + _position = value + } + + fun type(value: MetaAccountLocal.Type) { + _type = value + } + + fun status(value: MetaAccountLocal.Status) { + _status = value + } + + fun globallyUniqueId(value: String) { + _globallyUniqueId = value + } + + fun typeExtras(typeExtras: String) { + _typeExtras = typeExtras + } + + fun build(): JoinedMetaAccountInfo { + return RelationJoinedMetaAccountInfo( + metaAccount = MetaAccountLocal( + _substratePublicKey, + _substrateCryptoType, + _substrateAccountId, + _ethereumPublicKey, + _ethereumAddress, + _name, + _parentMetaId, + _isSelected, + _position, + _type, + _status, + _globallyUniqueId, + _typeExtras + ).also { + it.id = metaId + }, + chainAccounts = chainAccounts, + proxyAccountLocal = null + ) + } +} + +@LocalAccountsMockerDsl +class LocalChainAccountMockBuilder( + private val metaId: Long, + private val chainId: ChainId, +) { + + private var _publicKey: ByteArray? = null + private var _accountId = ByteArray(32) + private var _cryptoType: CryptoType? = null + + fun publicKey(value: ByteArray) { + _publicKey = value + } + + fun accountId(value: ByteArray) { + _accountId = value + } + + fun cryptoType(cryptoType: CryptoType) { + _cryptoType = cryptoType + } + + fun build(): ChainAccountLocal { + return ChainAccountLocal(metaId, chainId, _publicKey, _accountId, _cryptoType) + } +} diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/mock/SecretStoreMocker.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/mock/SecretStoreMocker.kt new file mode 100644 index 0000000..b5f8400 --- /dev/null +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/mock/SecretStoreMocker.kt @@ -0,0 +1,183 @@ +package io.novafoundation.nova.feature_account_impl.mock + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.encrypt.keypair.Keypair +import io.novasama.substrate_sdk_android.encrypt.keypair.substrate.Sr25519Keypair +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import org.mockito.ArgumentMatchers.anyLong + +@DslMarker +annotation class SecretStoreSdl + +object SecretStoreMocker { + + @SecretStoreSdl + suspend fun setupMocks( + secretStoreV2: SecretStoreV2, + mockBuilder: SecretStoreMockerBuilder.() -> Unit + ) { + val allSecrets = SecretStoreMockerBuilder().apply(mockBuilder).build() + + whenever(secretStoreV2.getMetaAccountSecrets(anyLong())).then { invocation -> + val id = invocation.arguments.first() as Long + + allSecrets[id]?.metaAccount + } + + whenever(secretStoreV2.getChainAccountSecrets(anyLong(), any())).then { invocation -> + val id = invocation.arguments.first() as Long + val accountId = invocation.arguments[1] as AccountId + + allSecrets[id]?.chainAccounts?.get(accountId.intoKey()) + } + + whenever(secretStoreV2.getAdditionalMetaAccountSecret(anyLong(), any())).then { invocation -> + val id = invocation.arguments.first() as Long + val secretName = invocation.arguments[1] as String + + allSecrets[id]?.additional?.get(secretName) + } + } +} + +class MockMetaAccountSecrets( + val metaAccount: EncodableStruct?, + val chainAccounts: Map>, + val additional: Map +) + +@SecretStoreSdl +class SecretStoreMockerBuilder() { + + private val secrets = mutableMapOf() + + fun metaAccount(metaId: Long, builder: MetaAccountSecretsMockBuilder.() -> Unit) { + val built = MetaAccountSecretsMockBuilder(metaId).apply(builder) + + secrets[metaId] = MockMetaAccountSecrets( + metaAccount = built.buildMetaAccountSecrets(), + chainAccounts = built.buildChainAccountSecrets(), + additional = built.buildAdditional() + ) + } + + fun build(): Map { + return secrets + } +} + +@SecretStoreSdl +class MetaAccountSecretsMockBuilder( + private val metaId: Long, +) { + + private val chainAccountSecrets = mutableMapOf>() + + private var _entropy: ByteArray? = null + private var _seed: ByteArray? = null + private var _substrateDerivationPath: String? = null + private var _substrateKeypair: Keypair? = null + private var _ethereumKeypair: Keypair? = null + private var _ethereumDerivationPath: String? = null + + private var additional = mapOf() + + fun chainAccount(accountId: AccountId, builder: ChainAccountSecretsMockBuilder.() -> Unit) { + val chainAccountSecret = ChainAccountSecretsMockBuilder(metaId).apply(builder).build() + chainAccountSecrets[accountId.intoKey()] = chainAccountSecret + } + + fun additional(builder: MutableMap.() -> Unit) { + additional = buildMap(builder) + } + + fun entropy(value: ByteArray) { + _entropy = value + } + + fun seed(value: ByteArray) { + _seed = value + } + + fun substrateDerivationPath(value: String?) { + _substrateDerivationPath = value + } + + fun substrateKeypair(keypair: Keypair) { + _substrateKeypair = keypair + } + + fun ethereumKeypair(value: Keypair?) { + _ethereumKeypair = value + } + + fun ethereumDerivationPath(ethereumDerivationPath: String?) { + _ethereumDerivationPath = ethereumDerivationPath + } + + fun buildMetaAccountSecrets(): EncodableStruct? { + return _substrateKeypair?.let { + MetaAccountSecrets( + substrateKeyPair = it, + entropy = _entropy, + substrateSeed = _seed, + substrateDerivationPath = _substrateDerivationPath, + ethereumKeypair = _ethereumKeypair, + ethereumDerivationPath = _ethereumDerivationPath + ) + } + } + + fun buildChainAccountSecrets(): Map> { + return chainAccountSecrets + } + + fun buildAdditional(): Map = additional +} + +@SecretStoreSdl +class ChainAccountSecretsMockBuilder( + private val metaId: Long, +) { + + private var _keypair: Keypair = Sr25519Keypair( + privateKey = ByteArray(32), + publicKey = ByteArray(32), + nonce = ByteArray(32) + ) + private var _entropy: ByteArray? = null + private var _seed: ByteArray? = null + private var _derivationPath: String? = null + + fun entropy(value: ByteArray) { + _entropy = value + } + + fun seed(value: ByteArray) { + _seed = value + } + + fun derivationPath(value: String?) { + _derivationPath = value + } + + fun keypair(keypair: Keypair) { + _keypair = keypair + } + + fun build(): EncodableStruct { + return ChainAccountSecrets( + keyPair = _keypair, + entropy = _entropy, + seed = _seed, + derivationPath = _derivationPath + ) + } +} diff --git a/feature-account-migration/.gitignore b/feature-account-migration/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-account-migration/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-account-migration/build.gradle b/feature-account-migration/build.gradle new file mode 100644 index 0000000..a00e41a --- /dev/null +++ b/feature-account-migration/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'kotlin-parcelize' +} + +android { + namespace 'io.novafoundation.nova.feature_account_migration' + compileSdk rootProject.compileSdkVersion + + + buildFeatures { + buildConfig true + viewBinding true + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + freeCompilerArgs = ["-Xcontext-receivers"] + } +} + +dependencies { + implementation project(":common") + implementation project(":feature-account-api") + implementation project(":feature-cloud-backup-api") + implementation project(':feature-deep-linking') + + implementation daggerDep + ksp daggerCompiler + + implementation androidDep + implementation constraintDep + implementation cardViewDep + implementation recyclerViewDep + implementation materialDep + + implementation kotlinDep + + testImplementation jUnitDep +} \ No newline at end of file diff --git a/feature-account-migration/consumer-rules.pro b/feature-account-migration/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-account-migration/proguard-rules.pro b/feature-account-migration/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-account-migration/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-account-migration/src/main/AndroidManifest.xml b/feature-account-migration/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-account-migration/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureApi.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureApi.kt new file mode 100644 index 0000000..bb5a802 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureApi.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_account_migration.di + +import io.novafoundation.nova.feature_account_migration.di.deeplinks.AccountMigrationDeepLinks +import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider + +interface AccountMigrationFeatureApi { + + val accountMigrationMixinProvider: AccountMigrationMixinProvider + + val accountMigrationDeepLinks: AccountMigrationDeepLinks +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureComponent.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureComponent.kt new file mode 100644 index 0000000..d29f7dc --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureComponent.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_account_migration.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter +import io.novafoundation.nova.feature_account_migration.presentation.pairing.di.AccountMigrationPairingComponent +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi + +@Component( + dependencies = [ + AccountMigrationFeatureDependencies::class + ], + modules = [ + AccountMigrationFeatureModule::class + ] +) +@FeatureScope +interface AccountMigrationFeatureComponent : AccountMigrationFeatureApi { + + fun accountMigrationPairingComponentFactory(): AccountMigrationPairingComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + deps: AccountMigrationFeatureDependencies, + @BindsInstance router: AccountMigrationRouter + ): AccountMigrationFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + AccountFeatureApi::class, + CloudBackupFeatureApi::class + ] + ) + interface AccountMigrationFeatureDependenciesComponent : AccountMigrationFeatureDependencies +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureDependencies.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureDependencies.kt new file mode 100644 index 0000000..582260e --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureDependencies.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_account_migration.di + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.splash.SplashPassedObserver +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory + +interface AccountMigrationFeatureDependencies { + + val resourceManager: ResourceManager + + val mnemonicAddAccountRepository: MnemonicAddAccountRepository + + val encryptionDefaults: EncryptionDefaults + + val accountRepository: AccountRepository + + val cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory + + val automaticInteractionGate: AutomaticInteractionGate + + val splashPassedObserver: SplashPassedObserver +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureHolder.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureHolder.kt new file mode 100644 index 0000000..c0097fb --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureHolder.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_migration.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi +import javax.inject.Inject + +@ApplicationScope +class AccountMigrationFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: AccountMigrationRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val featureDependencies = DaggerAccountMigrationFeatureComponent_AccountMigrationFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .cloudBackupFeatureApi(getFeature(CloudBackupFeatureApi::class.java)) + .build() + + return DaggerAccountMigrationFeatureComponent.factory() + .create(featureDependencies, router) + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureModule.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureModule.kt new file mode 100644 index 0000000..400d299 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/AccountMigrationFeatureModule.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_account_migration.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_migration.di.deeplinks.DeepLinkModule +import io.novafoundation.nova.feature_account_migration.domain.AccountMigrationInteractor +import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider +import io.novafoundation.nova.feature_account_migration.utils.common.KeyExchangeUtils + +@Module(includes = [DeepLinkModule::class]) +class AccountMigrationFeatureModule { + + @Provides + @FeatureScope + fun provideKeyExchangeUtils(): KeyExchangeUtils { + return KeyExchangeUtils() + } + + @Provides + @FeatureScope + fun provideExchangeSecretsMixinProvider( + keyExchangeUtils: KeyExchangeUtils + ): AccountMigrationMixinProvider { + return AccountMigrationMixinProvider(keyExchangeUtils) + } + + @Provides + @FeatureScope + fun provideAccountMigrationInteractor( + addAccountRepository: MnemonicAddAccountRepository, + encryptionDefaults: EncryptionDefaults, + accountRepository: AccountRepository + ): AccountMigrationInteractor { + return AccountMigrationInteractor(addAccountRepository, encryptionDefaults, accountRepository) + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/deeplinks/DeepLinkModule.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/deeplinks/DeepLinkModule.kt new file mode 100644 index 0000000..2730524 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/deeplinks/DeepLinkModule.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_account_migration.di.deeplinks + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.splash.SplashPassedObserver +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter +import io.novafoundation.nova.feature_account_migration.presentation.deeplinks.MigrationCompleteDeepLinkHandler +import io.novafoundation.nova.feature_account_migration.presentation.deeplinks.RequestMigrationDeepLinkHandler +import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideRequestMigrationDeepLinkHandler( + router: AccountMigrationRouter, + automaticInteractionGate: AutomaticInteractionGate, + splashPassedObserver: SplashPassedObserver, + repository: AccountRepository + ) = RequestMigrationDeepLinkHandler( + router, + automaticInteractionGate, + splashPassedObserver, + repository + ) + + @Provides + @FeatureScope + fun provideMigrationCompleteDeepLinkHandler( + automaticInteractionGate: AutomaticInteractionGate, + accountMigrationMixinProvider: AccountMigrationMixinProvider, + repository: AccountRepository + ) = MigrationCompleteDeepLinkHandler( + automaticInteractionGate, + accountMigrationMixinProvider, + repository + ) + + @Provides + @FeatureScope + fun provideDeepLinks( + requestMigrationHandler: RequestMigrationDeepLinkHandler, + migrationCompleteHandler: MigrationCompleteDeepLinkHandler + ): AccountMigrationDeepLinks { + return AccountMigrationDeepLinks(listOf(requestMigrationHandler, migrationCompleteHandler)) + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/deeplinks/StakingDeepLinks.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/deeplinks/StakingDeepLinks.kt new file mode 100644 index 0000000..2f2f459 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/di/deeplinks/StakingDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_migration.di.deeplinks + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class AccountMigrationDeepLinks(val deepLinkHandlers: List) diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/domain/AccountMigrationInteractor.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/domain/AccountMigrationInteractor.kt new file mode 100644 index 0000000..fe6231c --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/domain/AccountMigrationInteractor.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_account_migration.domain + +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.AddAccountResult +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.secrets.MnemonicAddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.account.advancedEncryption.AdvancedEncryption +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.AddAccountType +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator + +class AccountMigrationInteractor( + private val addAccountRepository: MnemonicAddAccountRepository, + private val encryptionDefaults: EncryptionDefaults, + private val accountRepository: AccountRepository +) { + + suspend fun isPinCodeSet(): Boolean = accountRepository.isCodeSet() + + suspend fun addAccount(name: String, entropy: ByteArray) { + val mnemonic = MnemonicCreator.fromEntropy(entropy) + + val advancedEncryption = AdvancedEncryption( + substrateCryptoType = encryptionDefaults.substrateCryptoType, + ethereumCryptoType = encryptionDefaults.ethereumCryptoType, + derivationPaths = AdvancedEncryption.DerivationPaths( + substrate = encryptionDefaults.substrateDerivationPath, + ethereum = encryptionDefaults.ethereumDerivationPath + ) + ) + + val payload = MnemonicAddAccountRepository.Payload( + mnemonic.words, + advancedEncryption, + AddAccountType.MetaAccount(name) + ) + + val addAccountResult = addAccountRepository.addAccount(payload) + + require(addAccountResult is AddAccountResult.AccountAdded) + + accountRepository.selectMetaAccount(addAccountResult.metaId) + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/AccountMigrationRouter.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/AccountMigrationRouter.kt new file mode 100644 index 0000000..c1d9c32 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/AccountMigrationRouter.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_account_migration.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter + +interface AccountMigrationRouter : ReturnableRouter { + + fun openAccountMigrationPairing(scheme: String) + + fun finishMigrationFlow() + + fun openPinCodeSet() +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/deeplinks/MigrationCompleteDeepLinkHandler.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/deeplinks/MigrationCompleteDeepLinkHandler.kt new file mode 100644 index 0000000..c8902d4 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/deeplinks/MigrationCompleteDeepLinkHandler.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_account_migration.presentation.deeplinks + +import android.net.Uri +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_migration.utils.AccountExchangePayload +import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import io.novasama.substrate_sdk_android.extensions.fromHex +import kotlinx.coroutines.flow.MutableSharedFlow + +private const val MIGRATION_COMPLETE_PATH = "/migration-complete" + +class MigrationCompleteDeepLinkHandler( + private val automaticInteractionGate: AutomaticInteractionGate, + private val accountMigrationMixinProvider: AccountMigrationMixinProvider, + private val repository: AccountRepository +) : DeepLinkHandler { + + override val callbackFlow = MutableSharedFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + + return path.startsWith(MIGRATION_COMPLETE_PATH) + } + + override suspend fun handleDeepLink(data: Uri) = runCatching { + if (repository.isAccountSelected()) { + automaticInteractionGate.awaitInteractionAllowed() + } + + val mnemonic = data.getQueryParameter("mnemonic") ?: error("No secret was passed") + val peerPublicKey = data.getQueryParameter("key") ?: error("No key was passed") + val accountName = data.getQueryParameter("name") + + val mixin = accountMigrationMixinProvider.getMixin() ?: error("Migration state invalid") + mixin.onPeerSecretsReceived( + secret = mnemonic.fromHex(), + publicKey = peerPublicKey.fromHex(), + exchangePayload = AccountExchangePayload(accountName) + ) + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/deeplinks/RequestMigrationDeepLinkHandler.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/deeplinks/RequestMigrationDeepLinkHandler.kt new file mode 100644 index 0000000..ce6cf4f --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/deeplinks/RequestMigrationDeepLinkHandler.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_account_migration.presentation.deeplinks + +import android.net.Uri +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.common.utils.splash.SplashPassedObserver +import io.novafoundation.nova.common.utils.splash.awaitSplashPassed +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import kotlinx.coroutines.flow.MutableSharedFlow + +private const val ACTION_MIGRATE_PATH_REGEX = "/migrate" + +class RequestMigrationDeepLinkHandler( + private val router: AccountMigrationRouter, + private val automaticInteractionGate: AutomaticInteractionGate, + private val splashPassedObserver: SplashPassedObserver, + private val repository: AccountRepository +) : DeepLinkHandler { + + override val callbackFlow = MutableSharedFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + + return path.startsWith(ACTION_MIGRATE_PATH_REGEX) + } + + override suspend fun handleDeepLink(data: Uri): Result = runCatching { + if (repository.isAccountSelected()) { + automaticInteractionGate.awaitInteractionAllowed() + } else { + splashPassedObserver.awaitSplashPassed() + } + + val scheme = data.getQueryParameter("scheme") ?: error("No scheme was passed") + router.openAccountMigrationPairing(scheme) + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/AccountMigrationPairingFragment.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/AccountMigrationPairingFragment.kt new file mode 100644 index 0000000..9ad702e --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/AccountMigrationPairingFragment.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_account_migration.presentation.pairing + +import android.view.View +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_account_migration.R +import io.novafoundation.nova.feature_account_migration.databinding.FragmentAccountMigrationPairingBinding +import io.novafoundation.nova.feature_account_migration.di.AccountMigrationFeatureApi +import io.novafoundation.nova.feature_account_migration.di.AccountMigrationFeatureComponent +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.observeConfirmationAction + +class AccountMigrationPairingFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentAccountMigrationPairingBinding.inflate(layoutInflater) + + override fun applyInsets(rootView: View) { + binder.accountMigrationSkipContainer.applyStatusBarInsets() + binder.accountMigrationPairContainer.applyNavigationBarInsets() + } + + override fun initViews() { + binder.accountMigrationSkip.background = getRoundedCornerDrawable(R.color.button_background_secondary, cornerSizeDp = 10) + .withRippleMask(getRippleMask(cornerSizeDp = 10)) + binder.accountMigrationSkip.setOnClickListener { viewModel.close() } + + binder.accountMigrationPair.prepareForProgress(this) + binder.accountMigrationPair.setOnClickListener { viewModel.acceptMigration() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AccountMigrationFeatureApi::class.java + ) + .accountMigrationPairingComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: AccountMigrationPairingViewModel) { + observeBrowserEvents(viewModel) + observeConfirmationAction(viewModel.cloudBackupWarningMixin) + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/AccountMigrationPairingPayload.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/AccountMigrationPairingPayload.kt new file mode 100644 index 0000000..043fa01 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/AccountMigrationPairingPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_account_migration.presentation.pairing + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class AccountMigrationPairingPayload( + val scheme: String +) : Parcelable diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/AccountMigrationPairingViewModel.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/AccountMigrationPairingViewModel.kt new file mode 100644 index 0000000..994e9dd --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/AccountMigrationPairingViewModel.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_account_migration.presentation.pairing + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.capitalize +import io.novafoundation.nova.feature_account_migration.R +import io.novafoundation.nova.feature_account_migration.domain.AccountMigrationInteractor +import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter +import io.novafoundation.nova.feature_account_migration.utils.AccountExchangePayload +import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider +import io.novafoundation.nova.feature_account_migration.utils.common.ExchangeSecretsMixin +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class AccountMigrationPairingViewModel( + private val resourceManager: ResourceManager, + private val accountMigrationMixinProvider: AccountMigrationMixinProvider, + private val accountMigrationInteractor: AccountMigrationInteractor, + private val payload: AccountMigrationPairingPayload, + private val router: AccountMigrationRouter, + private val cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory +) : BaseViewModel(), Browserable { + + val cloudBackupWarningMixin = cloudBackupChangingWarningMixinFactory.create(this) + + override val openBrowserEvent = MutableLiveData>() + + private val exchangeSecretsMixin = accountMigrationMixinProvider.createAndBindWithScope(this) + + init { + handleEvents() + } + + fun acceptMigration() { + cloudBackupWarningMixin.launchChangingConfirmationIfNeeded { + exchangeSecretsMixin.acceptKeyExchange() + } + } + + private fun handleEvents() { + exchangeSecretsMixin.exchangeEvents + .onEach { handleExchangeSecretsEvent(it) } + .launchIn(this) + } + + private suspend fun handleExchangeSecretsEvent(event: ExchangeSecretsMixin.ExternalEvent) { + when (event) { + is ExchangeSecretsMixin.ExternalEvent.SendPublicKey -> { + val migrationAcceptedUrl = resourceManager.getString(R.string.account_migration_accepted_url, payload.scheme, event.publicKey.toHexString()) + openBrowserEvent.value = Event(migrationAcceptedUrl) + } + + is ExchangeSecretsMixin.ExternalEvent.PeerSecretsReceived -> { + val name = event.exchangePayload.accountName ?: fallbackAccountName() + accountMigrationInteractor.addAccount(name, event.decryptedSecret) + + finishFlow() + } + } + } + + private fun fallbackAccountName(): String { + return resourceManager.getString(R.string.account_migration_fallback_name, payload.scheme.capitalize()) + } + + private suspend fun finishFlow() { + if (accountMigrationInteractor.isPinCodeSet()) { + router.finishMigrationFlow() + } else { + router.openPinCodeSet() + } + } + + fun close() { + router.back() + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/di/AccountMigrationPairingComponent.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/di/AccountMigrationPairingComponent.kt new file mode 100644 index 0000000..83d1e91 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/di/AccountMigrationPairingComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_account_migration.presentation.pairing.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingFragment +import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingPayload + +@Subcomponent( + modules = [ + AccountMigrationPairingModule::class + ] +) +@ScreenScope +interface AccountMigrationPairingComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AccountMigrationPairingPayload + ): AccountMigrationPairingComponent + } + + fun inject(fragment: AccountMigrationPairingFragment) +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/di/AccountMigrationPairingModule.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/di/AccountMigrationPairingModule.kt new file mode 100644 index 0000000..d1feaad --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/presentation/pairing/di/AccountMigrationPairingModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_account_migration.presentation.pairing.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_migration.domain.AccountMigrationInteractor +import io.novafoundation.nova.feature_account_migration.presentation.AccountMigrationRouter +import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingPayload +import io.novafoundation.nova.feature_account_migration.presentation.pairing.AccountMigrationPairingViewModel +import io.novafoundation.nova.feature_account_migration.utils.AccountMigrationMixinProvider +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory + +@Module(includes = [ViewModelModule::class]) +class AccountMigrationPairingModule { + + @Provides + @IntoMap + @ViewModelKey(AccountMigrationPairingViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + accountMigrationMixinProvider: AccountMigrationMixinProvider, + accountMigrationInteractor: AccountMigrationInteractor, + payload: AccountMigrationPairingPayload, + router: AccountMigrationRouter, + cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory + ): ViewModel { + return AccountMigrationPairingViewModel( + resourceManager, + accountMigrationMixinProvider, + accountMigrationInteractor, + payload, + router, + cloudBackupChangingWarningMixinFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): AccountMigrationPairingViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AccountMigrationPairingViewModel::class.java) + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/AccountExchangePayload.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/AccountExchangePayload.kt new file mode 100644 index 0000000..25b4bb4 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/AccountExchangePayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_account_migration.utils + +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload + +class AccountExchangePayload( + val accountName: String? +) : ExchangePayload diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/AccountMigrationMixinProvider.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/AccountMigrationMixinProvider.kt new file mode 100644 index 0000000..7c68fb3 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/AccountMigrationMixinProvider.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_account_migration.utils + +import io.novafoundation.nova.common.utils.invokeOnCompletion +import io.novafoundation.nova.feature_account_migration.utils.common.ExchangeSecretsMixin +import io.novafoundation.nova.feature_account_migration.utils.common.KeyExchangeUtils +import io.novafoundation.nova.feature_account_migration.utils.common.RealExchangeSecretsMixin +import kotlinx.coroutines.CoroutineScope + +class AccountMigrationMixinProvider( + private val keyExchangeUtils: KeyExchangeUtils +) { + + private var exchangeSecretsMixin: ExchangeSecretsMixin? = null + + fun getMixin(): ExchangeSecretsMixin? = exchangeSecretsMixin + + fun createAndBindWithScope(coroutineScope: CoroutineScope): ExchangeSecretsMixin { + return RealExchangeSecretsMixin( + keyExchangeUtils, + secretProvider = { throw InterruptedException("No supported secret exchange") }, + exchangePayloadProvider = { throw InterruptedException("No supported secret exchange") }, + coroutineScope + ).apply { + exchangeSecretsMixin = this + + coroutineScope.invokeOnCompletion { + exchangeSecretsMixin = null + } + } + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/common/ExchangeSecretsMixin.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/common/ExchangeSecretsMixin.kt new file mode 100644 index 0000000..56fa08a --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/common/ExchangeSecretsMixin.kt @@ -0,0 +1,123 @@ +package io.novafoundation.nova.feature_account_migration.utils.common + +import io.novafoundation.nova.feature_account_migration.utils.common.ExchangeSecretsMixin.ExternalEvent +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.KeyExchangeStateMachine +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect.Receiver +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect.Sender +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +interface ExchangeSecretsMixin { + + interface ExternalEvent { + + object RequestExchangeKeys : ExternalEvent + + class SendPublicKey(val publicKey: ByteArray) : ExternalEvent + + class SendEncryptedSecret(val exchangePayload: T, val encryptedSecret: ByteArray, val publicKey: ByteArray) : ExternalEvent + + class PeerSecretsReceived(val exchangePayload: T, val decryptedSecret: ByteArray) : ExternalEvent + } + + fun interface SecretProvider { + suspend fun getSecret(): ByteArray + } + + fun interface ExchangePayloadProvider { + suspend fun getExchangePayload(): T + } + + val exchangeEvents: SharedFlow> + + fun startSharingSecrets() + + fun acceptKeyExchange() + + fun onPeerAcceptedKeyExchange(publicKey: ByteArray) + + fun onPeerSecretsReceived(secret: ByteArray, publicKey: ByteArray, exchangePayload: T) +} + +class RealExchangeSecretsMixin( + private val keyExchangeUtils: KeyExchangeUtils, + private val secretProvider: ExchangeSecretsMixin.SecretProvider, + private val exchangePayloadProvider: ExchangeSecretsMixin.ExchangePayloadProvider, + private val coroutineScope: CoroutineScope +) : ExchangeSecretsMixin, CoroutineScope by coroutineScope { + + private val stateMachine: KeyExchangeStateMachine = KeyExchangeStateMachine(coroutineScope) + + override val exchangeEvents = MutableSharedFlow>() + + init { + launch { + for (sideEffect in stateMachine.sideEffects) { + handleSideEffect(sideEffect) + } + } + } + + override fun startSharingSecrets() { + stateMachine.onEvent(KeyExchangeEvent.Sender.InitKeyExchange) + } + + override fun acceptKeyExchange() { + val keyPair = keyExchangeUtils.generateEphemeralKeyPair() + stateMachine.onEvent(KeyExchangeEvent.Receiver.AcceptKeyExchangeRequest(keyPair)) + } + + override fun onPeerAcceptedKeyExchange(publicKey: ByteArray) { + val peerPublicKey = keyExchangeUtils.mapPublicKeyFromBytes(publicKey) + stateMachine.onEvent(KeyExchangeEvent.Sender.PeerAcceptedKeyExchange(peerPublicKey)) + } + + override fun onPeerSecretsReceived(secret: ByteArray, publicKey: ByteArray, exchangePayload: T) { + val peerPublicKey = keyExchangeUtils.mapPublicKeyFromBytes(publicKey) + stateMachine.onEvent(KeyExchangeEvent.Receiver.PeerSecretsReceived(secret, peerPublicKey, exchangePayload)) + } + + private fun handleSideEffect(sideEffect: KeyExchangeSideEffect) { + when (sideEffect) { + is Sender -> handleSenderSideEffect(sideEffect) + + is Receiver -> handleReceiverSideEffect(sideEffect) + } + } + + private fun handleSenderSideEffect(sideEffect: Sender) = launch { + when (sideEffect) { + Sender.RequestPeerAcceptKeyExchange -> exchangeEvents.emit(ExternalEvent.RequestExchangeKeys) + + is Sender.SendEncryptedSecrets -> { + val secret = secretProvider.getSecret() + val exchangePayload = exchangePayloadProvider.getExchangePayload() + val keyPair = keyExchangeUtils.generateEphemeralKeyPair() + val encryptedSecret = keyExchangeUtils.encrypt(secret, keyPair, sideEffect.peerPublicKey) + + exchangeEvents.emit( + ExternalEvent.SendEncryptedSecret( + exchangePayload, + encryptedSecret, + keyPair.public.bytes() + ) + ) + } + } + } + + private fun handleReceiverSideEffect(sideEffect: Receiver) = launch { + when (sideEffect) { + is Receiver.AcceptKeyExchange -> exchangeEvents.emit(ExternalEvent.SendPublicKey(sideEffect.publicKey.bytes())) + is Receiver.PeerSecretsReceived -> { + val decryptedEntropy = keyExchangeUtils.decrypt(sideEffect.encryptedSecret, sideEffect.keyPair, sideEffect.peerPublicKey) + exchangeEvents.emit(ExternalEvent.PeerSecretsReceived(sideEffect.exchangePayload, decryptedEntropy)) + } + } + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/common/KeyExchangeUtils.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/common/KeyExchangeUtils.kt new file mode 100644 index 0000000..022a534 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/common/KeyExchangeUtils.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.feature_account_migration.utils.common + +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PublicKey +import java.security.SecureRandom +import java.security.spec.ECGenParameterSpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +private const val AES_KEY_SIZE = 32 // 256 bits +private const val AES_GCM_IV_LENGTH = 12 +private const val AES_GCM_TAG_LENGTH = 128 + +class KeyExchangeUtils { + + // EC Curve specification + private val ecSpec = ECGenParameterSpec("secp256r1") + + fun generateEphemeralKeyPair(): KeyPair { + val keyPair = KeyPairGenerator.getInstance("EC").run { + initialize(ecSpec, SecureRandom()) + generateKeyPair() + } + + return keyPair + } + + fun encrypt(encryptionData: ByteArray, keypair: KeyPair, publicKey: PublicKey): ByteArray { + val sharedSecret = getSharedSecret(keypair, publicKey) + val keySpec = deriveAESKeyFromSharedSecret(sharedSecret) + + val iv = ByteArray(AES_GCM_IV_LENGTH) + SecureRandom().nextBytes(iv) + + val cipher = getCypher() + val gcmSpec = GCMParameterSpec(AES_GCM_TAG_LENGTH, iv) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec) + + val ciphertext = cipher.doFinal(encryptionData) + return iv + ciphertext + } + + fun decrypt(encryptedData: ByteArray, keypair: KeyPair, publicKey: PublicKey): ByteArray { + val sharedSecret = getSharedSecret(keypair, publicKey) + val keySpec = deriveAESKeyFromSharedSecret(sharedSecret) + + val iv = encryptedData.copyOfRange(0, AES_GCM_IV_LENGTH) + val ciphertext = encryptedData.copyOfRange(AES_GCM_IV_LENGTH, encryptedData.size) + + val cipher = getCypher() + val gcmSpec = GCMParameterSpec(AES_GCM_TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec) + + return cipher.doFinal(ciphertext) + } + + fun mapPublicKeyFromBytes(publicKeyBytes: ByteArray): PublicKey { + val keyFactory = KeyFactory.getInstance("EC") + val keySpec = X509EncodedKeySpec(publicKeyBytes) + return keyFactory.generatePublic(keySpec) + } + + private fun getSharedSecret(keypair: KeyPair, peerPublicKey: PublicKey): ByteArray { + val keyAgreement = KeyAgreement.getInstance("ECDH") + keyAgreement.init(keypair.private) + keyAgreement.doPhase(peerPublicKey, true) + + return keyAgreement.generateSecret() + } + + private fun deriveAESKeyFromSharedSecret(sharedSecret: ByteArray): SecretKeySpec { + val mac = Mac.getInstance("HmacSHA256") + val salt = "ephemeral-salt".toByteArray() + val keySpec = SecretKeySpec(salt, "HmacSHA256") + mac.init(keySpec) + val prk = mac.doFinal(sharedSecret) + + mac.init(SecretKeySpec(prk, "HmacSHA256")) + val info = ByteArray(0) // We can set purpose of using this key and make it different for same shared secret depends on info + val t1 = mac.doFinal(info + 0x01.toByte()) + val aesKey = t1.copyOf(AES_KEY_SIZE) + + return SecretKeySpec(aesKey, "AES") + } + + private fun getCypher() = Cipher.getInstance("AES/GCM/NoPadding") +} + +fun PublicKey.bytes(): ByteArray { + return this.encoded +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/ExchangePayload.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/ExchangePayload.kt new file mode 100644 index 0000000..a05102f --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/ExchangePayload.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_account_migration.utils.stateMachine + +interface ExchangePayload + +object NoExchangePayload : ExchangePayload diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/KeyExchangeStateMachine.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/KeyExchangeStateMachine.kt new file mode 100644 index 0000000..2c19055 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/KeyExchangeStateMachine.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_account_migration.utils.stateMachine + +import io.novafoundation.nova.common.utils.stateMachine.StateMachine +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.states.InitialKeyExchangeState +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.states.KeyExchangeState +import kotlinx.coroutines.CoroutineScope + +typealias KeyExchangeTransition = StateMachine.Transition, KeyExchangeSideEffect> +typealias KeyExchangeStateMachine = StateMachine, KeyExchangeSideEffect, KeyExchangeEvent> + +fun KeyExchangeStateMachine( + coroutineScope: CoroutineScope +): StateMachine, KeyExchangeSideEffect, KeyExchangeEvent> { + return StateMachine(initialState = InitialKeyExchangeState(), coroutineScope) +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/events/KeyExchangeEvent.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/events/KeyExchangeEvent.kt new file mode 100644 index 0000000..9093c7c --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/events/KeyExchangeEvent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_account_migration.utils.stateMachine.events + +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload +import java.security.KeyPair +import java.security.PublicKey + +interface KeyExchangeEvent { + + interface Sender : KeyExchangeEvent { + data object InitKeyExchange : Sender + + data class PeerAcceptedKeyExchange(val peerPublicKey: PublicKey) : Sender + } + + interface Receiver : KeyExchangeEvent { + data class AcceptKeyExchangeRequest(val keyPair: KeyPair) : Receiver + + class PeerSecretsReceived( + val encryptedSecrets: ByteArray, + val peerPublicKey: PublicKey, + val exchangePayload: T + ) : Receiver + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/sideEffects/KeyExchangeSideEffect.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/sideEffects/KeyExchangeSideEffect.kt new file mode 100644 index 0000000..4ba2a43 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/sideEffects/KeyExchangeSideEffect.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects + +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload +import java.security.KeyPair +import java.security.PublicKey + +sealed interface KeyExchangeSideEffect { + + sealed interface Sender : KeyExchangeSideEffect { + + data object RequestPeerAcceptKeyExchange : Sender + + class SendEncryptedSecrets(val peerPublicKey: PublicKey) : Sender + } + + sealed interface Receiver : KeyExchangeSideEffect { + + class AcceptKeyExchange(val publicKey: PublicKey) : Receiver + + class PeerSecretsReceived( + val exchangePayload: T, + val encryptedSecret: ByteArray, + val keyPair: KeyPair, + val peerPublicKey: PublicKey + ) : Receiver + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/AwaitPeerAcceptKeyExchangeState.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/AwaitPeerAcceptKeyExchangeState.kt new file mode 100644 index 0000000..79687d8 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/AwaitPeerAcceptKeyExchangeState.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_account_migration.utils.stateMachine.states + +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.KeyExchangeTransition +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect + +class AwaitPeerAcceptKeyExchangeState : KeyExchangeState { + + context(KeyExchangeTransition) + override suspend fun performTransition(event: KeyExchangeEvent) { + when (event) { + is KeyExchangeEvent.Sender.InitKeyExchange -> { + emitSideEffect(KeyExchangeSideEffect.Sender.RequestPeerAcceptKeyExchange) + } + + is KeyExchangeEvent.Sender.PeerAcceptedKeyExchange -> { + emitSideEffect(KeyExchangeSideEffect.Sender.SendEncryptedSecrets(event.peerPublicKey)) + emitState(InitialKeyExchangeState()) + } + } + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/AwaitPeerSecretsKeyExchangeState.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/AwaitPeerSecretsKeyExchangeState.kt new file mode 100644 index 0000000..ef66226 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/AwaitPeerSecretsKeyExchangeState.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_account_migration.utils.stateMachine.states + +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.KeyExchangeTransition +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect +import java.security.KeyPair + +class AwaitPeerSecretsKeyExchangeState( + private val keyPair: KeyPair +) : KeyExchangeState { + + context(KeyExchangeTransition) + override suspend fun performTransition(event: KeyExchangeEvent) { + when (event) { + is KeyExchangeEvent.Receiver.PeerSecretsReceived -> { + emitSideEffect(KeyExchangeSideEffect.Receiver.PeerSecretsReceived(event.exchangePayload, event.encryptedSecrets, keyPair, event.peerPublicKey)) + emitState(InitialKeyExchangeState()) + } + + is KeyExchangeEvent.Receiver.AcceptKeyExchangeRequest -> { + emitSideEffect(KeyExchangeSideEffect.Receiver.AcceptKeyExchange(event.keyPair.public)) + emitState(AwaitPeerSecretsKeyExchangeState(event.keyPair)) + } + } + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/InitialKeyExchangeState.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/InitialKeyExchangeState.kt new file mode 100644 index 0000000..89361f8 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/InitialKeyExchangeState.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_account_migration.utils.stateMachine.states + +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.KeyExchangeTransition +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect + +class InitialKeyExchangeState : KeyExchangeState { + + context(KeyExchangeTransition) + override suspend fun performTransition(event: KeyExchangeEvent) { + when (event) { + is KeyExchangeEvent.Sender.InitKeyExchange -> { + emitSideEffect(KeyExchangeSideEffect.Sender.RequestPeerAcceptKeyExchange) + emitState(AwaitPeerAcceptKeyExchangeState()) + } + + is KeyExchangeEvent.Receiver.AcceptKeyExchangeRequest -> { + emitSideEffect(KeyExchangeSideEffect.Receiver.AcceptKeyExchange(event.keyPair.public)) + emitState(AwaitPeerSecretsKeyExchangeState(event.keyPair)) + } + } + } +} diff --git a/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/KeyExchangeState.kt b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/KeyExchangeState.kt new file mode 100644 index 0000000..795a191 --- /dev/null +++ b/feature-account-migration/src/main/java/io/novafoundation/nova/feature_account_migration/utils/stateMachine/states/KeyExchangeState.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_account_migration.utils.stateMachine.states + +import io.novafoundation.nova.common.utils.stateMachine.StateMachine +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.ExchangePayload +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.events.KeyExchangeEvent +import io.novafoundation.nova.feature_account_migration.utils.stateMachine.sideEffects.KeyExchangeSideEffect + +interface KeyExchangeState : StateMachine.State, KeyExchangeSideEffect, KeyExchangeEvent> diff --git a/feature-account-migration/src/main/res/layout/fragment_account_migration_pairing.xml b/feature-account-migration/src/main/res/layout/fragment_account_migration_pairing.xml new file mode 100644 index 0000000..7497093 --- /dev/null +++ b/feature-account-migration/src/main/res/layout/fragment_account_migration_pairing.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-account-migration/src/test/java/io/novafoundation/nova/feature_account_migration/ExchangeSecretsTest.kt b/feature-account-migration/src/test/java/io/novafoundation/nova/feature_account_migration/ExchangeSecretsTest.kt new file mode 100644 index 0000000..d5e8f50 --- /dev/null +++ b/feature-account-migration/src/test/java/io/novafoundation/nova/feature_account_migration/ExchangeSecretsTest.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_account_migration + +import io.novafoundation.nova.feature_account_migration.utils.common.KeyExchangeUtils +import io.novafoundation.nova.feature_account_migration.utils.common.bytes +import javax.crypto.AEADBadTagException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.experimental.xor + + +class ExchangeSecretsTest { + + private val keyExchangeUtils = KeyExchangeUtils() + + @Test + fun checkExchangingSecretsFlow() { + val encodingData = "PolkadotApp" + + val peerA = keyExchangeUtils.generateEphemeralKeyPair() + val peerB = keyExchangeUtils.generateEphemeralKeyPair() + + val encoded = keyExchangeUtils.encrypt( encodingData.toByteArray(), peerA, peerB.public) + + val decoded = keyExchangeUtils.decrypt(encoded, peerB, peerA.public) + + val decodedString = decoded.decodeToString() + + assertEquals(encodingData, decodedString) + } + + @Test(expected = AEADBadTagException::class) + fun checkImposterSabotageFailed() { + val encodingData = "PolkadotApp" + + val peerA = keyExchangeUtils.generateEphemeralKeyPair() + val peerB = keyExchangeUtils.generateEphemeralKeyPair() + val imposter = keyExchangeUtils.generateEphemeralKeyPair() + + val encoded = keyExchangeUtils.encrypt(encodingData.toByteArray(), peerA, peerB.public) + + val imposterDecoded = keyExchangeUtils.decrypt(encoded, imposter, peerA.public) + + imposterDecoded.decodeToString() + } + + @Test + fun checkSymmetricSecretsBothWays() { + val msgFromA = "Message from A".toByteArray() + val msgFromB = "Reply from B".toByteArray() + + val peerA = keyExchangeUtils.generateEphemeralKeyPair() + val peerB = keyExchangeUtils.generateEphemeralKeyPair() + + val aToB = keyExchangeUtils.encrypt(msgFromA, peerA, peerB.public) + val decodedByB = keyExchangeUtils.decrypt(aToB, peerB, peerA.public) + assertEquals("Message from A", decodedByB.decodeToString()) + + val bToA = keyExchangeUtils.encrypt(msgFromB, peerB, peerA.public) + val decodedByA = keyExchangeUtils.decrypt(bToA, peerA, peerB.public) + assertEquals("Reply from B", decodedByA.decodeToString()) + } + + @Test(expected = AEADBadTagException::class) + fun checkTamperedDataFails() { + val peerA = keyExchangeUtils.generateEphemeralKeyPair() + val peerB = keyExchangeUtils.generateEphemeralKeyPair() + + val encoded = keyExchangeUtils.encrypt("Hello".toByteArray(), peerA, peerB.public) + + // Change one byte + encoded[encoded.lastIndex - 1] = (encoded.last() xor 0x01) + + keyExchangeUtils.decrypt(encoded, peerB, peerA.public) + } + + @Test + fun checkPublicKeyMapping() { + val keyPair = keyExchangeUtils.generateEphemeralKeyPair() + + val bytes = keyPair.public.bytes() + val mappedPublicKey = keyExchangeUtils.mapPublicKeyFromBytes(bytes) + assertTrue(mappedPublicKey.bytes().contentEquals(keyPair.public.bytes())) + } +} diff --git a/feature-ahm-api/.gitignore b/feature-ahm-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-ahm-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-ahm-api/build.gradle b/feature-ahm-api/build.gradle new file mode 100644 index 0000000..58ce2ec --- /dev/null +++ b/feature-ahm-api/build.gradle @@ -0,0 +1,22 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_ahm_api' +} + +dependencies { + implementation coroutinesDep + implementation project(":common") + implementation project(':runtime') + implementation project(":feature-deep-linking") + + implementation cardViewDep + implementation recyclerViewDep + implementation materialDep + implementation androidDep + + implementation shimmerDep + + implementation daggerDep + ksp daggerCompiler +} \ No newline at end of file diff --git a/feature-ahm-api/consumer-rules.pro b/feature-ahm-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-ahm-api/proguard-rules.pro b/feature-ahm-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-ahm-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-ahm-api/src/main/AndroidManifest.xml b/feature-ahm-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-ahm-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/data/repository/ChainMigrationRepository.kt b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/data/repository/ChainMigrationRepository.kt new file mode 100644 index 0000000..1426270 --- /dev/null +++ b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/data/repository/ChainMigrationRepository.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_ahm_api.data.repository + +interface ChainMigrationRepository { + + suspend fun cacheBalancesForChainMigrationDetection() + + suspend fun setInfoShownForChain(chainId: String) + + fun isMigrationDetailsWasShown(chainId: String): Boolean + + fun isChainMigrationDetailsNeeded(chainId: String): Boolean +} diff --git a/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/data/repository/MigrationInfoRepository.kt b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/data/repository/MigrationInfoRepository.kt new file mode 100644 index 0000000..c9a56f5 --- /dev/null +++ b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/data/repository/MigrationInfoRepository.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_ahm_api.data.repository + +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfig + +interface MigrationInfoRepository { + + suspend fun getConfigByOriginChain(chainId: String): ChainMigrationConfig? + + suspend fun getAllConfigs(): List + + suspend fun loadConfigs() + + suspend fun getConfigByDestinationChain(chainId: String): ChainMigrationConfig? +} diff --git a/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/di/ChainMigrationFeatureApi.kt b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/di/ChainMigrationFeatureApi.kt new file mode 100644 index 0000000..a0d4796 --- /dev/null +++ b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/di/ChainMigrationFeatureApi.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_ahm_api.di + +import io.novafoundation.nova.feature_ahm_api.data.repository.ChainMigrationRepository +import io.novafoundation.nova.feature_ahm_api.data.repository.MigrationInfoRepository +import io.novafoundation.nova.feature_ahm_api.di.deeplinks.ChainMigrationDeepLinks +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationDetailsSelectToShowUseCase + +interface ChainMigrationFeatureApi { + + val chainMigrationInfoUseCase: ChainMigrationInfoUseCase + + val chainMigrationRepository: ChainMigrationRepository + + val migrationInfoRepository: MigrationInfoRepository + + val chainMigrationDeepLinks: ChainMigrationDeepLinks + + val chainMigrationDetailsSelectToShowUseCase: ChainMigrationDetailsSelectToShowUseCase +} diff --git a/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/di/deeplinks/ChainMigrationDeepLinks.kt b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/di/deeplinks/ChainMigrationDeepLinks.kt new file mode 100644 index 0000000..0e01163 --- /dev/null +++ b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/di/deeplinks/ChainMigrationDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_ahm_api.di.deeplinks + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class ChainMigrationDeepLinks(val deepLinkHandlers: List) diff --git a/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/domain/ChainMigrationDetailsSelectToShowUseCase.kt b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/domain/ChainMigrationDetailsSelectToShowUseCase.kt new file mode 100644 index 0000000..53ff6c3 --- /dev/null +++ b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/domain/ChainMigrationDetailsSelectToShowUseCase.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_ahm_api.domain + +interface ChainMigrationDetailsSelectToShowUseCase { + suspend fun getChainIdsToShowMigrationDetails(): List +} diff --git a/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/domain/ChainMigrationInfoUseCase.kt b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/domain/ChainMigrationInfoUseCase.kt new file mode 100644 index 0000000..54ef6fd --- /dev/null +++ b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/domain/ChainMigrationInfoUseCase.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_ahm_api.domain + +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfigWithChains +import kotlinx.coroutines.flow.Flow + +interface ChainMigrationInfoUseCase { + + fun observeMigrationConfigOrNull(chainId: String, assetId: Int): Flow + + fun markMigrationInfoAsHidden(key: String, chainId: String, assetId: Int) + + fun observeInfoShouldBeHidden(key: String, chainId: String, assetId: Int): Flow +} diff --git a/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/domain/model/ChainMigrationConfig.kt b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/domain/model/ChainMigrationConfig.kt new file mode 100644 index 0000000..af8931c --- /dev/null +++ b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/domain/model/ChainMigrationConfig.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_ahm_api.domain.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger +import java.util.Date + +class ChainMigrationConfig( + val originData: ChainData, + val destinationData: ChainData, + val blockNumberStartAt: BigInteger, + val timeStartAt: Date, + val newTokenNames: List, + val bannerPath: String, + val migrationInProgress: Boolean, + val wikiURL: String +) { + + class ChainData( + val chainId: String, + val assetId: Int, + val minBalance: BigInteger, + val averageFee: BigInteger + ) +} + +class ChainMigrationConfigWithChains( + val config: ChainMigrationConfig, + val originChain: Chain, + val originAsset: Chain.Asset, + val destinationChain: Chain, + val destinationAsset: Chain.Asset +) diff --git a/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/presentation/FormattingUtils.kt b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/presentation/FormattingUtils.kt new file mode 100644 index 0000000..0a3b0ce --- /dev/null +++ b/feature-ahm-api/src/main/java/io/novafoundation/nova/feature_ahm_api/presentation/FormattingUtils.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_ahm_api.presentation + +import java.text.DateFormat + +fun getChainMigrationDateFormat() = DateFormat.getDateInstance(DateFormat.LONG) diff --git a/feature-ahm-impl/.gitignore b/feature-ahm-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-ahm-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-ahm-impl/build.gradle b/feature-ahm-impl/build.gradle new file mode 100644 index 0000000..9677629 --- /dev/null +++ b/feature-ahm-impl/build.gradle @@ -0,0 +1,48 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_ahm_impl' + + defaultConfig { + buildConfigField "String", "AHM_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/migrations/asset_hub/migrations_dev.json\"" + } + + buildTypes { + debug { + + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + buildConfigField "String", "AHM_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/migrations/asset_hub/migrations.json\"" + } + } + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation coroutinesDep + implementation project(":common") + implementation project(':core-db') + implementation project(':runtime') + implementation project(":feature-ahm-api") + implementation project(":feature-banners-api") + implementation project(":feature-wallet-api") + implementation project(":feature-deep-linking") + + implementation cardViewDep + implementation materialDep + implementation androidDep + + implementation daggerDep + ksp daggerCompiler + + testImplementation project(':test-shared') +} \ No newline at end of file diff --git a/feature-ahm-impl/consumer-rules.pro b/feature-ahm-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-ahm-impl/proguard-rules.pro b/feature-ahm-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-ahm-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-ahm-impl/src/main/AndroidManifest.xml b/feature-ahm-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-ahm-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/config/ChainMigrationConfigApi.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/config/ChainMigrationConfigApi.kt new file mode 100644 index 0000000..fc61348 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/config/ChainMigrationConfigApi.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_ahm_impl.data.config + +import io.novafoundation.nova.feature_ahm_impl.BuildConfig +import retrofit2.http.GET + +interface ChainMigrationConfigApi { + + @GET(BuildConfig.AHM_CONFIG_URL) + suspend fun getConfig(): List +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/config/ChainMigrationConfigRemote.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/config/ChainMigrationConfigRemote.kt new file mode 100644 index 0000000..a7a73ac --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/config/ChainMigrationConfigRemote.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_ahm_impl.data.config + +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfig +import java.math.BigInteger +import java.util.Date +import kotlin.time.Duration.Companion.seconds + +class ChainMigrationConfigRemote( + val sourceData: ChainData, + val destinationData: ChainData, + val blockNumber: BigInteger, + val timestamp: Long, + val newTokenNames: List, + val bannerPath: String, + val migrationInProgress: Boolean, + val wikiURL: String +) { + + class ChainData( + val chainId: String, + val assetId: Int, + val minBalance: BigInteger, + val averageFee: BigInteger + ) +} + +fun ChainMigrationConfigRemote.toDomain(): ChainMigrationConfig { + return ChainMigrationConfig( + originData = sourceData.toDomain(), + destinationData = destinationData.toDomain(), + blockNumberStartAt = blockNumber, + timeStartAt = Date(timestamp.seconds.inWholeMilliseconds), + newTokenNames = newTokenNames, + bannerPath = bannerPath, + migrationInProgress = migrationInProgress, + wikiURL = wikiURL + ) +} + +fun ChainMigrationConfigRemote.ChainData.toDomain(): ChainMigrationConfig.ChainData { + return ChainMigrationConfig.ChainData( + chainId = chainId, + assetId = assetId, + minBalance = minBalance, + averageFee = averageFee + ) +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/repository/RealChainMigrationRepository.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/repository/RealChainMigrationRepository.kt new file mode 100644 index 0000000..a6390fd --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/repository/RealChainMigrationRepository.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_ahm_impl.data.repository + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.domain.balance.totalBalance +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.model.AssetLocal +import io.novafoundation.nova.feature_ahm_api.data.repository.ChainMigrationRepository +import io.novafoundation.nova.runtime.ext.UTILITY_ASSET_ID +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.hash.isPositive + +private const val CHAINS_WITH_ASSET_BALANCE = "CHAINS_WITH_ASSET_BALANCE" +private const val CHAIN_MIGRATION_INFO_SHOWN_PREFIX = "CHAIN_MIGRATION_INFO_SHOWN_PREFIX_" + +class RealChainMigrationRepository( + private val assetDao: AssetDao, + private val preferences: Preferences +) : ChainMigrationRepository { + + override suspend fun cacheBalancesForChainMigrationDetection() { + val chainsWithAssetBalance = assetDao.getAssetsById(id = UTILITY_ASSET_ID) + .filter { it.hasBalance() } + .mapToSet { it.chainId } + + saveToStorage(chainsWithAssetBalance) + } + + override fun isMigrationDetailsWasShown(chainId: String): Boolean { + return preferences.getBoolean(getChainInfoShownKey(chainId), false) + } + + override fun isChainMigrationDetailsNeeded(chainId: String): Boolean { + return chainId in getChainsWithAssetBalance() + } + + override suspend fun setInfoShownForChain(chainId: String) { + preferences.putBoolean(getChainInfoShownKey(chainId), true) + } + + private fun AssetLocal.hasBalance(): Boolean { + return totalBalance(freeInPlanks, reservedInPlanks).isPositive() + } + + private fun saveToStorage(chainsWithAssetBalance: Set) { + val currentChains = getChainsWithAssetBalance() + setChainsWithAssetBalance(currentChains + chainsWithAssetBalance) + } + + private fun getChainsWithAssetBalance() = preferences.getStringSet(CHAINS_WITH_ASSET_BALANCE) + + private fun setChainsWithAssetBalance(chains: Set) = preferences.putStringSet(CHAINS_WITH_ASSET_BALANCE, chains) + + private fun getChainInfoShownKey(chainId: String) = CHAIN_MIGRATION_INFO_SHOWN_PREFIX + chainId +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/repository/RealMigrationInfoRepository.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/repository/RealMigrationInfoRepository.kt new file mode 100644 index 0000000..fcdc4cd --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/data/repository/RealMigrationInfoRepository.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_ahm_impl.data.repository + +import io.novafoundation.nova.common.data.memory.SingleValueCache +import io.novafoundation.nova.feature_ahm_api.data.repository.MigrationInfoRepository +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfig +import io.novafoundation.nova.feature_ahm_impl.data.config.ChainMigrationConfigApi +import io.novafoundation.nova.feature_ahm_impl.data.config.toDomain + +class RealMigrationInfoRepository( + private val api: ChainMigrationConfigApi +) : MigrationInfoRepository { + + private val config = SingleValueCache { + val configResponse = api.getConfig() + configResponse.map { it.toDomain() } + } + + override suspend fun getConfigByOriginChain(chainId: String): ChainMigrationConfig? { + return getConfigsInternal().getOrNull()?.firstOrNull { it.originData.chainId == chainId } + } + + override suspend fun getConfigByDestinationChain(chainId: String): ChainMigrationConfig? { + return getConfigsInternal().getOrNull()?.firstOrNull { it.destinationData.chainId == chainId } + } + + override suspend fun getAllConfigs(): List { + return getConfigsInternal().getOrNull() ?: emptyList() + } + + override suspend fun loadConfigs() { + getConfigsInternal() + } + + private suspend fun getConfigsInternal() = runCatching { + config() + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureComponent.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureComponent.kt new file mode 100644 index 0000000..66ab479 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureComponent.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_ahm_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi +import io.novafoundation.nova.feature_ahm_impl.presentation.ChainMigrationRouter +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.di.ChainMigrationDetailsComponent +import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + ChainMigrationFeatureDependencies::class, + ], + modules = [ + ChainMigrationFeatureModule::class, + ] +) +@FeatureScope +interface ChainMigrationFeatureComponent : ChainMigrationFeatureApi { + + fun chainMigrationDetailsComponentFactory(): ChainMigrationDetailsComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: ChainMigrationRouter, + deps: ChainMigrationFeatureDependencies + ): ChainMigrationFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + RuntimeApi::class, + BannersFeatureApi::class, + WalletFeatureApi::class + ] + ) + interface AccountFeatureDependenciesComponent : ChainMigrationFeatureDependencies +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureDependencies.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureDependencies.kt new file mode 100644 index 0000000..edf1500 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureDependencies.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_ahm_impl.di + +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +interface ChainMigrationFeatureDependencies { + + val resourceManager: ResourceManager + + val promotionBannersMixinFactory: PromotionBannersMixinFactory + + val bannersSourceFactory: BannersSourceFactory + + val assetDao: AssetDao + + val preferences: Preferences + + val chainRegistry: ChainRegistry + + val apiCreator: NetworkApiCreator + + val automaticInteractionGate: AutomaticInteractionGate + + val toggleFeatureRepository: ToggleFeatureRepository + + val chainStateRepository: ChainStateRepository + + val tokenFormatter: TokenFormatter + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureHolder.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureHolder.kt new file mode 100644 index 0000000..e47caed --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureHolder.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_ahm_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_ahm_impl.presentation.ChainMigrationRouter +import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class ChainMigrationFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: ChainMigrationRouter, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerChainMigrationFeatureComponent_AccountFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .bannersFeatureApi(getFeature(BannersFeatureApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .build() + + return DaggerChainMigrationFeatureComponent.factory() + .create( + router = router, + deps = accountFeatureDependencies + ) + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureModule.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureModule.kt new file mode 100644 index 0000000..52ff112 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/ChainMigrationFeatureModule.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_ahm_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.feature_ahm_api.data.repository.ChainMigrationRepository +import io.novafoundation.nova.feature_ahm_api.data.repository.MigrationInfoRepository +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationDetailsSelectToShowUseCase +import io.novafoundation.nova.feature_ahm_impl.data.config.ChainMigrationConfigApi +import io.novafoundation.nova.feature_ahm_impl.data.repository.RealChainMigrationRepository +import io.novafoundation.nova.feature_ahm_impl.data.repository.RealMigrationInfoRepository +import io.novafoundation.nova.feature_ahm_impl.di.modules.DeepLinkModule +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase +import io.novafoundation.nova.feature_ahm_impl.domain.ChainMigrationDetailsInteractor +import io.novafoundation.nova.feature_ahm_impl.domain.RealChainMigrationInfoUseCase +import io.novafoundation.nova.feature_ahm_impl.domain.RealChainMigrationDetailsSelectToShowUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +@Module( + includes = [DeepLinkModule::class] +) +class ChainMigrationFeatureModule { + + @Provides + @FeatureScope + fun provideChainMigrationConfigApi( + apiCreator: NetworkApiCreator + ): ChainMigrationConfigApi { + return apiCreator.create(ChainMigrationConfigApi::class.java) + } + + @Provides + @FeatureScope + fun provideChainMigrationRepository( + assetDao: AssetDao, + preferences: Preferences, + ): ChainMigrationRepository { + return RealChainMigrationRepository( + assetDao, + preferences + ) + } + + @Provides + @FeatureScope + fun provideMigrationInfoRepository( + api: ChainMigrationConfigApi + ): MigrationInfoRepository { + return RealMigrationInfoRepository(api) + } + + @Provides + @FeatureScope + fun provideChainMigrationDetailsInteractor( + chainRegistry: ChainRegistry, + chainMigrationRepository: ChainMigrationRepository, + migrationInfoRepository: MigrationInfoRepository + ): ChainMigrationDetailsInteractor { + return ChainMigrationDetailsInteractor( + chainRegistry, + chainMigrationRepository, + migrationInfoRepository + ) + } + + @Provides + @FeatureScope + fun provideAssetMigrationUseCase( + migrationInfoRepository: MigrationInfoRepository, + toggleFeatureRepository: ToggleFeatureRepository, + chainRegistry: ChainRegistry, + chainStateRepository: ChainStateRepository + ): ChainMigrationInfoUseCase { + return RealChainMigrationInfoUseCase( + migrationInfoRepository, + toggleFeatureRepository, + chainRegistry, + chainStateRepository + ) + } + + @Provides + @FeatureScope + fun provideChainMigrationDetailsSelectToShowUseCase( + chainMigrationRepository: ChainMigrationRepository, + migrationInfoRepository: MigrationInfoRepository, + chainStateRepository: ChainStateRepository + ): ChainMigrationDetailsSelectToShowUseCase { + return RealChainMigrationDetailsSelectToShowUseCase( + migrationInfoRepository, + chainMigrationRepository, + chainStateRepository + ) + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/modules/DeepLinkModule.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/modules/DeepLinkModule.kt new file mode 100644 index 0000000..bf7bb24 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/di/modules/DeepLinkModule.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_ahm_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_ahm_api.di.deeplinks.ChainMigrationDeepLinks +import io.novafoundation.nova.feature_ahm_impl.presentation.ChainMigrationRouter +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.deeplink.ChainMigrationDetailsDeepLinkHandler + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideStakingDashboardDeepLinkHandler( + router: ChainMigrationRouter, + automaticInteractionGate: AutomaticInteractionGate + ) = ChainMigrationDetailsDeepLinkHandler( + router, + automaticInteractionGate + ) + + @Provides + @FeatureScope + fun provideDeepLinks(stakingDashboard: ChainMigrationDetailsDeepLinkHandler): ChainMigrationDeepLinks { + return ChainMigrationDeepLinks(listOf(stakingDashboard)) + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/ChainMigrationDetailsInteractor.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/ChainMigrationDetailsInteractor.kt new file mode 100644 index 0000000..c4654f4 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/ChainMigrationDetailsInteractor.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_ahm_impl.domain + +import io.novafoundation.nova.feature_ahm_api.data.repository.ChainMigrationRepository +import io.novafoundation.nova.feature_ahm_api.data.repository.MigrationInfoRepository +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfig +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainFlow +import kotlinx.coroutines.flow.Flow + +class ChainMigrationDetailsInteractor( + private val chainRegistry: ChainRegistry, + private val chainMigrationRepository: ChainMigrationRepository, + private val migrationInfoRepository: MigrationInfoRepository +) { + + fun chainFlow(chainId: String): Flow { + return chainRegistry.chainFlow(chainId) + } + + suspend fun getChain(chainId: String): Chain { + return chainRegistry.getChain(chainId) + } + + suspend fun getChainMigrationConfig(chainId: String): ChainMigrationConfig? { + return migrationInfoRepository.getConfigByOriginChain(chainId) + } + + suspend fun markMigrationInfoAlreadyShown(chainId: String) { + chainMigrationRepository.setInfoShownForChain(chainId) + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/RealChainMigrationDetailsSelectToShowUseCase.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/RealChainMigrationDetailsSelectToShowUseCase.kt new file mode 100644 index 0000000..f8651db --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/RealChainMigrationDetailsSelectToShowUseCase.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_ahm_impl.domain + +import io.novafoundation.nova.feature_ahm_api.data.repository.ChainMigrationRepository +import io.novafoundation.nova.feature_ahm_api.data.repository.MigrationInfoRepository +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationDetailsSelectToShowUseCase +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +class RealChainMigrationDetailsSelectToShowUseCase( + private val migrationInfoRepository: MigrationInfoRepository, + private val chainMigrationRepository: ChainMigrationRepository, + private val chainStateRepository: ChainStateRepository, +) : ChainMigrationDetailsSelectToShowUseCase { + + override suspend fun getChainIdsToShowMigrationDetails(): List { + val configs = migrationInfoRepository.getAllConfigs() + return configs + .filter { + val detailsWasNotShown = !chainMigrationRepository.isMigrationDetailsWasShown(it.originData.chainId) + val chainRequireMigrationDetails = chainMigrationRepository.isChainMigrationDetailsNeeded(it.originData.chainId) + detailsWasNotShown && chainRequireMigrationDetails && chainStateRepository.isMigrationBlockPassed(it) + } + .map { it.originData.chainId } + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/RealChainMigrationInfoUseCase.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/RealChainMigrationInfoUseCase.kt new file mode 100644 index 0000000..ac24e13 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/RealChainMigrationInfoUseCase.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_ahm_impl.domain + +import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_ahm_api.data.repository.MigrationInfoRepository +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfigWithChains +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map + +class RealChainMigrationInfoUseCase( + private val migrationInfoRepository: MigrationInfoRepository, + private val toggleFeatureRepository: ToggleFeatureRepository, + private val chainRegistry: ChainRegistry, + private val chainStateRepository: ChainStateRepository +) : ChainMigrationInfoUseCase { + + override fun observeMigrationConfigOrNull(chainId: String, assetId: Int): Flow = flowOfAll { + val config = migrationInfoRepository.getConfigByOriginChain(chainId) + ?: migrationInfoRepository.getConfigByDestinationChain(chainId) + ?: return@flowOfAll emptyFlow() + + if (chainStateRepository.isMigrationBlockNotPassed(config)) return@flowOfAll emptyFlow() + + chainRegistry.chainsById + .map { + val sourceChain = it.getValue(config.originData.chainId) + val destinationChain = it.getValue(config.destinationData.chainId) + + ChainMigrationConfigWithChains( + config = config, + originChain = sourceChain, + originAsset = sourceChain.assetsById.getValue(config.originData.assetId), + destinationChain = destinationChain, + destinationAsset = destinationChain.assetsById.getValue(config.destinationData.assetId) + ) + } + } + + override fun observeInfoShouldBeHidden(key: String, chainId: String, assetId: Int): Flow { + return toggleFeatureRepository.observe(getKeyFor(key, chainId, assetId)) + } + + override fun markMigrationInfoAsHidden(key: String, chainId: String, assetId: Int) { + toggleFeatureRepository.set(getKeyFor(key, chainId, assetId), true) + } + + private fun getKeyFor(key: String, chainId: String, assetId: Int): String { + return "$key-$chainId-$assetId" + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/Utils.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/Utils.kt new file mode 100644 index 0000000..ce0f2a5 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/domain/Utils.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_ahm_impl.domain + +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfig +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +suspend fun ChainStateRepository.isMigrationBlockPassed(config: ChainMigrationConfig): Boolean { + return currentRemoteBlock(config.originData.chainId) > config.blockNumberStartAt +} + +suspend fun ChainStateRepository.isMigrationBlockNotPassed(config: ChainMigrationConfig): Boolean { + return !isMigrationBlockPassed(config) +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/ChainMigrationRouter.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/ChainMigrationRouter.kt new file mode 100644 index 0000000..421f680 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/ChainMigrationRouter.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_ahm_impl.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter + +interface ChainMigrationRouter : ReturnableRouter { + + fun openChainMigrationDetails(chainId: String) +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ChainMigrationDetailsFragment.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ChainMigrationDetailsFragment.kt new file mode 100644 index 0000000..9fa0504 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ChainMigrationDetailsFragment.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.setHints +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi +import io.novafoundation.nova.feature_ahm_impl.di.ChainMigrationFeatureComponent +import io.novafoundation.nova.feature_ahm_impl.databinding.FragmentChainMigrationDetailsBinding +import io.novafoundation.nova.feature_banners_api.presentation.bind + +class ChainMigrationDetailsFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentChainMigrationDetailsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.chainMigrationDetailsButton.setOnClickListener { viewModel.okButtonClicked() } + + binder.chainMigrationDetailsToolbar.setRightActionClickListener { viewModel.learnMoreClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + ChainMigrationFeatureApi::class.java + ).chainMigrationDetailsComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: ChainMigrationDetailsViewModel) { + observeBrowserEvents(viewModel) + + viewModel.bannersFlow.observe { + it.bind(binder.chainMigrationDetailsBanner) + } + + viewModel.configUIFlow.observe { + binder.chainMigrationDetailsTitle.text = it.title + binder.chainMigrationDetailsMinBalance.text = it.minimalBalance + binder.chainMigrationDetailsLowerFee.text = it.lowerFee + binder.chainMigrationDetailsTokens.text = it.tokens + binder.chainMigrationDetailsAccess.text = it.unifiedAccess + binder.chainMigrationDetailsAnyTokenFee.text = it.anyTokenFee + binder.chainMigrationDetailsHints.setHints(it.hints) + } + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ChainMigrationDetailsPayload.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ChainMigrationDetailsPayload.kt new file mode 100644 index 0000000..ad4cdef --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ChainMigrationDetailsPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class ChainMigrationDetailsPayload(val chainId: String) : Parcelable diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ChainMigrationDetailsViewModel.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ChainMigrationDetailsViewModel.kt new file mode 100644 index 0000000..362ca63 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ChainMigrationDetailsViewModel.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.mixin.hints.HintModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfig +import io.novafoundation.nova.feature_ahm_api.presentation.getChainMigrationDateFormat +import io.novafoundation.nova.feature_ahm_impl.R +import io.novafoundation.nova.feature_ahm_impl.domain.ChainMigrationDetailsInteractor +import io.novafoundation.nova.feature_ahm_impl.presentation.ChainMigrationRouter +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.forDirectory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatToken +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ChainMigrationDetailsViewModel( + private val resourceManager: ResourceManager, + private val router: ChainMigrationRouter, + private val interactor: ChainMigrationDetailsInteractor, + private val payload: ChainMigrationDetailsPayload, + private val promotionBannersMixinFactory: PromotionBannersMixinFactory, + private val bannerSourceFactory: BannersSourceFactory, + private val tokenFormatter: TokenFormatter +) : BaseViewModel(), Browserable { + + override val openBrowserEvent = MutableLiveData>() + + private val dateFormatter = getChainMigrationDateFormat() + + private val chainFlow = interactor.chainFlow(payload.chainId) + .shareInBackground() + + private val configFlow = flowOf { interactor.getChainMigrationConfig(payload.chainId) } + .filterNotNull() + .shareInBackground() + + val bannersFlow = configFlow.map { + promotionBannersMixinFactory.create(bannerSourceFactory.forDirectory(it.bannerPath), this) + }.shareInBackground() + + val configUIFlow = combine(configFlow, chainFlow) { config, sourceChain -> + val destinationChain = interactor.getChain(config.destinationData.chainId) + val sourceAsset = sourceChain.assetsById.getValue(config.originData.assetId) + val destinationAsset = destinationChain.assetsById.getValue(config.destinationData.assetId) + val tokenSymbol = sourceAsset.symbol.value + val newTokens = config.newTokenNames.joinToString() + + val minimalBalanceScale = config.originData.minBalance / config.destinationData.minBalance + val lowerFeeScale = config.originData.averageFee / config.destinationData.averageFee + + ConfigModel( + title = getTitle(config, tokenSymbol, destinationChain), + minimalBalance = resourceManager.getString( + R.string.chain_migration_details_minimal_balance, + minimalBalanceScale.format(), + tokenFormatter.formatToken(config.originData.minBalance, sourceAsset), + tokenFormatter.formatToken(config.destinationData.minBalance, destinationAsset), + ), + lowerFee = resourceManager.getString( + R.string.chain_migration_details_lower_fee, + lowerFeeScale.format(), + tokenFormatter.formatToken(config.originData.averageFee, sourceAsset), + tokenFormatter.formatToken(config.destinationData.averageFee, destinationAsset) + ), + tokens = resourceManager.getString(R.string.chain_migration_details_tokens, newTokens), + unifiedAccess = resourceManager.getString(R.string.chain_migration_details_unified_access, tokenSymbol), + anyTokenFee = resourceManager.getString(R.string.chain_migration_details_fee_in_any_tokens), + hints = listOf( + HintModel(R.drawable.ic_recent_history, resourceManager.getString(R.string.chain_migration_details_hint_history, sourceChain.name)), + HintModel(R.drawable.ic_pezkuwi, resourceManager.getString(R.string.chain_migration_details_hint_auto_migration)) + ) + ) + } + + private fun getTitle(config: ChainMigrationConfig, tokenSymbol: String, destinationChain: Chain): String { + val formattedDate = dateFormatter.format(config.timeStartAt) + return if (config.migrationInProgress) { + resourceManager.getString(R.string.chain_migration_details_in_progress_title, formattedDate, tokenSymbol, destinationChain.name) + } else { + resourceManager.getString(R.string.chain_migration_details_title, formattedDate, tokenSymbol, destinationChain.name) + } + } + + fun okButtonClicked() = launchUnit { + interactor.markMigrationInfoAlreadyShown(payload.chainId) + router.back() + } + + fun learnMoreClicked() { + launch { + val config = configFlow.first() + + openBrowserEvent.value = Event(config.wikiURL) + } + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ConfigModel.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ConfigModel.kt new file mode 100644 index 0000000..b079c0f --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/ConfigModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails + +import io.novafoundation.nova.common.mixin.hints.HintModel + +class ConfigModel( + val title: String, + val minimalBalance: String, + val lowerFee: String, + val tokens: String, + val unifiedAccess: String, + val anyTokenFee: String, + val hints: List +) diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/deeplink/ChainMigrationDetailsDeepLinkHandler.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/deeplink/ChainMigrationDetailsDeepLinkHandler.kt new file mode 100644 index 0000000..efad79c --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/deeplink/ChainMigrationDetailsDeepLinkHandler.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_ahm_impl.presentation.ChainMigrationRouter +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import java.security.InvalidParameterException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +private const val STAKING_DASHBOARD_DEEP_LINK_PREFIX = "/open/ahm" + +class ChainMigrationDetailsDeepLinkHandler( + private val chainMigrationRouter: ChainMigrationRouter, + private val automaticInteractionGate: AutomaticInteractionGate +) : DeepLinkHandler { + + override val callbackFlow: Flow = emptyFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + return path.startsWith(STAKING_DASHBOARD_DEEP_LINK_PREFIX) + } + + override suspend fun handleDeepLink(data: Uri) = runCatching { + automaticInteractionGate.awaitInteractionAllowed() + + val chainId = data.getQueryParameter("chainId") ?: throw InvalidParameterException() + + chainMigrationRouter.openChainMigrationDetails(chainId) + } +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/di/ChainMigrationDetailsComponent.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/di/ChainMigrationDetailsComponent.kt new file mode 100644 index 0000000..b6e6ef2 --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/di/ChainMigrationDetailsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.ChainMigrationDetailsFragment +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.ChainMigrationDetailsPayload + +@Subcomponent( + modules = [ + ChainMigrationDetailsModule::class + ] +) +@ScreenScope +interface ChainMigrationDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ChainMigrationDetailsPayload + ): ChainMigrationDetailsComponent + } + + fun inject(fragment: ChainMigrationDetailsFragment) +} diff --git a/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/di/ChainMigrationDetailsModule.kt b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/di/ChainMigrationDetailsModule.kt new file mode 100644 index 0000000..49783cb --- /dev/null +++ b/feature-ahm-impl/src/main/java/io/novafoundation/nova/feature_ahm_impl/presentation/migrationDetails/di/ChainMigrationDetailsModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ahm_impl.domain.ChainMigrationDetailsInteractor +import io.novafoundation.nova.feature_ahm_impl.presentation.ChainMigrationRouter +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.ChainMigrationDetailsPayload +import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.ChainMigrationDetailsViewModel +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter + +@Module(includes = [ViewModelModule::class]) +class ChainMigrationDetailsModule { + + @Provides + @IntoMap + @ViewModelKey(ChainMigrationDetailsViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: ChainMigrationRouter, + interactor: ChainMigrationDetailsInteractor, + payload: ChainMigrationDetailsPayload, + promotionBannersMixinFactory: PromotionBannersMixinFactory, + bannerSourceFactory: BannersSourceFactory, + tokenFormatter: TokenFormatter + ): ViewModel { + return ChainMigrationDetailsViewModel( + resourceManager, + router, + interactor, + payload, + promotionBannersMixinFactory, + bannerSourceFactory, + tokenFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ChainMigrationDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ChainMigrationDetailsViewModel::class.java) + } +} diff --git a/feature-ahm-impl/src/main/res/layout/fragment_chain_migration_details.xml b/feature-ahm-impl/src/main/res/layout/fragment_chain_migration_details.xml new file mode 100644 index 0000000..38bee90 --- /dev/null +++ b/feature-ahm-impl/src/main/res/layout/fragment_chain_migration_details.xml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-ahm-impl/src/main/res/values/strings.xml b/feature-ahm-impl/src/main/res/values/strings.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/feature-ahm-impl/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature-assets/.gitignore b/feature-assets/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-assets/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-assets/build.gradle b/feature-assets/build.gradle new file mode 100644 index 0000000..7ad29d2 --- /dev/null +++ b/feature-assets/build.gradle @@ -0,0 +1,105 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + + defaultConfig { + + + + buildConfigField "String", "PEZKUWI_CARD_WIDGET_ID", "\"4ce98182-ed76-4933-ba1b-b85e4a51d75a\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'io.novafoundation.nova.feature_assets' + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-wallet-api') + implementation project(':feature-account-api') + implementation project(':feature-nft-api') + implementation project(':feature-currency-api') + implementation project(':feature-crowdloan-api') + implementation project(':feature-wallet-connect-api') + implementation project(':feature-staking-api') + implementation project(':feature-swap-api') + implementation project(':web3names') + implementation project(':runtime') + implementation project(':feature-buy-api') + implementation project(':feature-xcm:api') + implementation project(':feature-banners-api') + implementation project(':feature-deep-linking') + implementation project(':feature-ahm-api') + implementation project(':feature-gift-api') + + implementation kotlinDep + + implementation androidDep + implementation swipeRefershLayout + implementation materialDep + implementation cardViewDep + implementation constraintDep + + implementation permissionsDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation daggerDep + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + ksp daggerCompiler + + implementation roomDep + ksp roomCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + implementation androidxWebKit + + implementation bouncyCastleDep + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation substrateSdkDep + + implementation gsonDep + implementation retrofitDep + + implementation wsDep + + implementation zXingCoreDep + implementation zXingEmbeddedDep + + implementation insetterDep + + implementation shimmerDep + implementation flexBoxDep + + implementation chartsDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} \ No newline at end of file diff --git a/feature-assets/consumer-rules.pro b/feature-assets/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-assets/proguard-rules.pro b/feature-assets/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-assets/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-assets/src/main/AndroidManifest.xml b/feature-assets/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-assets/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/mappers/OperationMappers.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/mappers/OperationMappers.kt new file mode 100644 index 0000000..ab8e595 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/mappers/OperationMappers.kt @@ -0,0 +1,264 @@ +package io.novafoundation.nova.feature_assets.data.mappers + +import io.novafoundation.nova.core_db.model.AssetAndChainId +import io.novafoundation.nova.core_db.model.operation.DirectRewardTypeJoin +import io.novafoundation.nova.core_db.model.operation.DirectRewardTypeLocal +import io.novafoundation.nova.core_db.model.operation.ExtrinsicTypeJoin +import io.novafoundation.nova.core_db.model.operation.ExtrinsicTypeLocal +import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal +import io.novafoundation.nova.core_db.model.operation.OperationJoin +import io.novafoundation.nova.core_db.model.operation.OperationLocal +import io.novafoundation.nova.core_db.model.operation.OperationTypeLocal.OperationForeignKey +import io.novafoundation.nova.core_db.model.operation.PoolRewardTypeJoin +import io.novafoundation.nova.core_db.model.operation.PoolRewardTypeLocal +import io.novafoundation.nova.core_db.model.operation.RewardTypeLocal +import io.novafoundation.nova.core_db.model.operation.SwapTypeJoin +import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal +import io.novafoundation.nova.core_db.model.operation.TransferTypeJoin +import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapAssetWithAmountToLocal +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapOperationStatusToOperationLocalStatus +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRate +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type.Extrinsic.Content +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type.Reward.RewardKind +import io.novafoundation.nova.feature_wallet_api.domain.model.convertPlanks +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +private fun mapOperationStatusLocalToOperationStatus(status: OperationBaseLocal.Status) = when (status) { + OperationBaseLocal.Status.PENDING -> Operation.Status.PENDING + OperationBaseLocal.Status.COMPLETED -> Operation.Status.COMPLETED + OperationBaseLocal.Status.FAILED -> Operation.Status.FAILED +} + +fun mapOperationToOperationLocalDb( + operation: Operation, + source: OperationBaseLocal.Source, +): OperationLocal = with(operation) { + val localAssetId = AssetAndChainId(chainAsset.chainId, chainAsset.id) + val foreignKey = OperationForeignKey(id, address, localAssetId) + val typeLocal = when (val operationType = operation.type) { + is Type.Extrinsic -> mapExtrinsicToLocal(operationType, foreignKey) + is Type.Reward -> mapRewardToLocal(operationType, foreignKey) + is Type.Swap -> mapSwapToLocal(operationType, foreignKey) + is Type.Transfer -> mapTransferToLocal(operationType, foreignKey) + } + + val base = OperationBaseLocal( + id = id, + address = address, + time = time, + assetId = localAssetId, + hash = extrinsicHash, + status = mapOperationStatusToOperationLocalStatus(operation.status), + source = source + ) + + OperationLocal( + base = base, + type = typeLocal + ) +} + +fun mapOperationLocalToOperation( + operationLocal: OperationJoin, + chainAsset: Chain.Asset, + chain: Chain, + coinRate: CoinRate?, +): Operation? = with(operationLocal) { + val operationType = when { + operationLocal.transfer != null -> mapTransferFromLocal(operationLocal.transfer!!, chainAsset, coinRate, operationLocal.base.address) + operationLocal.directReward != null -> mapDirectRewardFromLocal(operationLocal.directReward!!, chainAsset, coinRate) + operationLocal.poolReward != null -> mapPoolRewardFromLocal(operationLocal.poolReward!!, chainAsset, coinRate) + operationLocal.extrinsic != null -> mapExtrinsicFromLocal(operationLocal.extrinsic!!, chainAsset, coinRate) + operationLocal.swap != null -> mapSwapFromLocal(operationLocal.swap!!, chainAsset, chain, coinRate) + else -> null + } ?: return@with null + + return Operation( + id = base.id, + address = base.address, + type = operationType, + time = base.time, + chainAsset = chainAsset, + extrinsicHash = base.hash, + status = mapOperationStatusLocalToOperationStatus(base.status) + ) +} + +private fun mapExtrinsicToLocal( + extrinsic: Type.Extrinsic, + foreignKey: OperationForeignKey +): ExtrinsicTypeLocal { + return when (val content = extrinsic.content) { + is Content.ContractCall -> ExtrinsicTypeLocal( + foreignKey = foreignKey, + contentType = ExtrinsicTypeLocal.ContentType.SMART_CONTRACT_CALL, + module = content.contractAddress, + call = content.function, + fee = extrinsic.fee + ) + + is Content.SubstrateCall -> ExtrinsicTypeLocal( + foreignKey = foreignKey, + contentType = ExtrinsicTypeLocal.ContentType.SUBSTRATE_CALL, + module = content.module, + call = content.call, + fee = extrinsic.fee + ) + } +} + +private fun mapTransferToLocal( + transfer: Type.Transfer, + foreignKey: OperationForeignKey +): TransferTypeLocal = with(transfer) { + TransferTypeLocal( + foreignKey = foreignKey, + amount = amount, + sender = sender, + receiver = receiver, + fee = fee + ) +} + +private fun mapRewardToLocal( + reward: Type.Reward, + foreignKey: OperationForeignKey +): RewardTypeLocal = with(reward) { + when (val kind = reward.kind) { + is RewardKind.Direct -> DirectRewardTypeLocal( + foreignKey = foreignKey, + isReward = isReward, + amount = amount, + eventId = eventId, + era = kind.era, + validator = kind.validator + ) + + is RewardKind.Pool -> PoolRewardTypeLocal( + foreignKey = foreignKey, + isReward = isReward, + amount = amount, + eventId = eventId, + poolId = kind.poolId + ) + } +} + +private fun mapSwapToLocal( + swap: Type.Swap, + foreignKey: OperationForeignKey +): SwapTypeLocal = with(swap) { + SwapTypeLocal( + foreignKey = foreignKey, + fee = mapAssetWithAmountToLocal(fee), + assetIn = mapAssetWithAmountToLocal(amountIn), + assetOut = mapAssetWithAmountToLocal(amountOut), + ) +} + +private fun mapExtrinsicFromLocal( + local: ExtrinsicTypeJoin, + chainAsset: Chain.Asset, + coinRate: CoinRate?, +): Type.Extrinsic { + val content = when (local.contentType) { + ExtrinsicTypeLocal.ContentType.SUBSTRATE_CALL -> Content.SubstrateCall( + module = local.module, + call = local.call.orEmpty() + ) + ExtrinsicTypeLocal.ContentType.SMART_CONTRACT_CALL -> Content.ContractCall( + contractAddress = local.module, + function = local.call + ) + } + + return Type.Extrinsic( + content = content, + fee = local.fee, + fiatFee = coinRate?.convertPlanks(chainAsset, local.fee) + ) +} + +private fun mapDirectRewardFromLocal( + local: DirectRewardTypeJoin, + chainAsset: Chain.Asset, + coinRate: CoinRate?, +): Type.Reward { + return Type.Reward( + amount = local.amount, + isReward = local.isReward, + eventId = local.eventId, + kind = RewardKind.Direct( + // For a null value of Int? field, Room inserts zero when this Int? is used in Join + era = local.era.takeIf { it != 0 }, + validator = local.validator + ), + fiatAmount = coinRate?.convertPlanks(chainAsset, local.amount) + ) +} + +private fun mapPoolRewardFromLocal( + local: PoolRewardTypeJoin, + chainAsset: Chain.Asset, + coinRate: CoinRate?, +): Type.Reward { + return Type.Reward( + amount = local.amount, + isReward = local.isReward, + eventId = local.eventId, + kind = RewardKind.Pool(poolId = local.poolId), + fiatAmount = coinRate?.convertPlanks(chainAsset, local.amount) + ) +} + +private fun mapTransferFromLocal( + local: TransferTypeJoin, + chainAsset: Chain.Asset, + coinRate: CoinRate?, + myAddress: String, +): Type.Transfer { + return Type.Transfer( + amount = local.amount, + myAddress = myAddress, + receiver = local.receiver, + sender = local.sender, + fiatAmount = coinRate?.convertPlanks(chainAsset, local.amount), + fee = local.fee + ) +} + +private fun mapSwapFromLocal( + local: SwapTypeJoin, + chainAsset: Chain.Asset, + chain: Chain, + coinRate: CoinRate?, +): Type.Swap? { + val amountIn = mapAssetWithAmountFromLocal(chain, local.assetIn) ?: return null + val amountOut = mapAssetWithAmountFromLocal(chain, local.assetOut) ?: return null + + val amount = if (amountIn.chainAsset.fullId == chainAsset.fullId) amountIn.amount else amountOut.amount + + return Type.Swap( + fee = mapAssetWithAmountFromLocal(chain, local.fee) ?: return null, + amountIn = amountIn, + amountOut = amountOut, + fiatAmount = coinRate?.convertPlanks(chainAsset, amount), + ) +} + +private fun mapAssetWithAmountFromLocal( + chain: Chain, + local: SwapTypeLocal.AssetWithAmount +): ChainAssetWithAmount? { + val asset = chain.assetsById[local.assetId.assetId] ?: return null + + return ChainAssetWithAmount( + chainAsset = asset, + amount = local.amount + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt new file mode 100644 index 0000000..8026562 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt @@ -0,0 +1,124 @@ +package io.novafoundation.nova.feature_assets.data.network + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.mergeIfMultiple +import io.novafoundation.nova.common.utils.transformLatestDiffed +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters.PooledBalanceUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ethereum.subscribe +import io.novafoundation.nova.runtime.ext.isDisabled +import io.novafoundation.nova.runtime.ext.isFullSync +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlin.coroutines.coroutineContext + +class BalancesUpdateSystem( + private val chainRegistry: ChainRegistry, + private val paymentUpdaterFactory: PaymentUpdaterFactory, + private val balanceLocksUpdater: BalanceLocksUpdaterFactory, + private val pooledBalanceUpdaterFactory: PooledBalanceUpdaterFactory, + private val accountUpdateScope: AccountUpdateScope, + private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : UpdateSystem { + + override fun start(): Flow { + return accountUpdateScope.invalidationFlow().flatMapLatest { metaAccount -> + chainRegistry.currentChains.transformLatestDiffed { chain -> + emitAll(balancesSync(chain, metaAccount)) + } + }.flowOn(Dispatchers.Default) + } + + private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow { + return when { + !metaAccount.hasAccountIn(chain) -> emptyFlow() + chain.connectionState.isDisabled -> emptyFlow() + chain.canPerformFullSync() -> fullBalancesSync(chain, metaAccount) + else -> lightBalancesSync(chain, metaAccount) + } + } + + private suspend fun fullBalancesSync( + chain: Chain, + metaAccount: MetaAccount, + ): Flow { + return launchChainUpdaters( + chain = chain, + metaAccount = metaAccount, + createUpdaters = { createFullSyncUpdaters(chain) } + ) + } + + private suspend fun lightBalancesSync( + chain: Chain, + metaAccount: MetaAccount, + ): Flow { + return launchChainUpdaters( + chain = chain, + metaAccount = metaAccount, + createUpdaters = { createLightSyncUpdaters(chain) } + ) + } + + private suspend fun launchChainUpdaters( + chain: Chain, + metaAccount: MetaAccount, + createUpdaters: suspend () -> List> + ): Flow { + return flow { + val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) + + val updaters = createUpdaters() + + val sideEffectFlows = updaters.map { updater -> + try { + updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) } + } catch (e: Exception) { + emptyFlow() + } + } + + subscriptionBuilder.subscribe(coroutineContext) + val resultFlow = sideEffectFlows.mergeIfMultiple() + + emitAll(resultFlow) + }.catch { logError(chain, it) } + } + + private fun Chain.canPerformFullSync(): Boolean { + return connectionState.isFullSync || !hasSubstrateRuntime + } + + private fun createFullSyncUpdaters(chain: Chain): List> { + return listOf( + paymentUpdaterFactory.createFullSync(chain), + balanceLocksUpdater.create(chain), + pooledBalanceUpdaterFactory.create(chain) + ) + } + + private fun createLightSyncUpdaters(chain: Chain): List> { + return listOf( + paymentUpdaterFactory.createLightSync(chain), + ) + } + + private fun logError(chain: Chain, error: Throwable) { + Log.e(LOG_TAG, "Failed to subscribe to balances in ${chain.name}: ${error.message}", error) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/repository/NovaCardStateRepository.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/repository/NovaCardStateRepository.kt new file mode 100644 index 0000000..81b38cd --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/repository/NovaCardStateRepository.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_assets.data.repository + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map + +interface NovaCardStateRepository { + + fun getNovaCardCreationState(): NovaCardState + + fun setNovaCardCreationState(state: NovaCardState) + + fun observeNovaCardCreationState(): Flow + + fun setLastTopUpTime(time: Long) + + fun getLastTopUpTime(): Long + + suspend fun setTopUpFinishedEvent() + + fun observeTopUpFinishedEvent(): Flow +} + +private const val PREFS_NOVA_CARD_STATE = "PREFS_NOVA_CARD_STATE" +private const val PREFS_TIME_CARD_BEING_ISSUED = "PREFS_TIME_CARD_BEING_ISSUED" + +class RealNovaCardStateRepository( + private val preferences: Preferences +) : NovaCardStateRepository { + + private val topUpFinishedEvent = MutableSharedFlow() + + override fun getNovaCardCreationState(): NovaCardState { + val novaCardState = preferences.getString(PREFS_NOVA_CARD_STATE, NovaCardState.NONE.toString()) + return NovaCardState.valueOf(novaCardState) + } + + override fun setNovaCardCreationState(state: NovaCardState) { + preferences.putString(PREFS_NOVA_CARD_STATE, state.toString()) + } + + override fun observeNovaCardCreationState(): Flow { + return preferences.keyFlow(PREFS_NOVA_CARD_STATE) + .map { getNovaCardCreationState() } + } + + override fun setLastTopUpTime(time: Long) { + preferences.putLong(PREFS_TIME_CARD_BEING_ISSUED, time) + } + + override fun getLastTopUpTime(): Long { + return preferences.getLong(PREFS_TIME_CARD_BEING_ISSUED, 0) + } + + override suspend fun setTopUpFinishedEvent() { + topUpFinishedEvent.emit(Unit) + } + + override fun observeTopUpFinishedEvent(): Flow { + return topUpFinishedEvent + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/repository/TransactionHistoryRepository.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/repository/TransactionHistoryRepository.kt new file mode 100644 index 0000000..3e19587 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/repository/TransactionHistoryRepository.kt @@ -0,0 +1,212 @@ +package io.novafoundation.nova.feature_assets.data.repository + +import io.novafoundation.nova.common.data.model.DataPage +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.common.utils.Filter +import io.novafoundation.nova.common.utils.applyFilters +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal +import io.novafoundation.nova.core_db.model.operation.OperationJoin +import io.novafoundation.nova.feature_account_api.domain.account.system.SystemAccountMatcher +import io.novafoundation.nova.feature_assets.data.mappers.mapOperationLocalToOperation +import io.novafoundation.nova.feature_assets.data.mappers.mapOperationToOperationLocalDb +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_staking_api.data.mythos.MythosMainPotMatcherFactory +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.poolRewardAccountMatcher +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.getAllCoinPriceHistory +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.findNearestCoinRate +import io.novafoundation.nova.runtime.ext.accountIdOrNull +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +interface TransactionHistoryRepository { + + suspend fun syncOperationsFirstPage( + pageSize: Int, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency + ) + + suspend fun getOperations( + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency + ): DataPage + + suspend fun operationsFirstPageFlow( + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency + ): Flow> +} + +class RealTransactionHistoryRepository( + private val assetSourceRegistry: AssetSourceRegistry, + private val operationDao: OperationDao, + private val poolAccountDerivation: PoolAccountDerivation, + private val mythosMainPotMatcherFactory: MythosMainPotMatcherFactory, + private val coinPriceRepository: CoinPriceRepository +) : TransactionHistoryRepository { + + override suspend fun syncOperationsFirstPage( + pageSize: Int, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency + ) = withContext(Dispatchers.Default) { + val historySource = historySourceFor(chainAsset) + val accountAddress = chain.addressOf(accountId) + + val dataPageResult = runCatching { + historySource.getFilteredOperations( + pageSize, + PageOffset.Loadable.FirstPage, + filters, + accountId, + chain, + chainAsset, + currency + ) + } + historySource.additionalFirstPageSync(chain, chainAsset, accountId, dataPageResult) + + val dataPage = dataPageResult.getOrThrow() + + val localOperations = dataPage.map { mapOperationToOperationLocalDb(it, OperationBaseLocal.Source.REMOTE) } + + operationDao.insertFromRemote(accountAddress, chain.id, chainAsset.id, localOperations) + } + + override suspend fun getOperations( + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency + ): DataPage = withContext(Dispatchers.Default) { + val historySource = historySourceFor(chainAsset) + + historySource.getFilteredOperations( + pageSize = pageSize, + pageOffset = pageOffset, + filters = filters, + accountId = accountId, + chain = chain, + chainAsset = chainAsset, + currency = currency + ) + } + + override suspend fun operationsFirstPageFlow( + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency + ): Flow> { + val accountAddress = chain.addressOf(accountId) + val historySource = historySourceFor(chainAsset) + + return operationDao.observe(accountAddress, chain.id, chainAsset.id) + .transform { operations -> + emit(mapOperations(operations, chainAsset, chain, emptyList())) + + runCatching { coinPriceRepository.getAllCoinPriceHistory(chainAsset.priceId!!, currency) } + .onSuccess { emit(mapOperations(operations, chainAsset, chain, it)) } + } + .mapLatest { operations -> + val pageOffset = historySource.getSyncedPageOffset(accountId, chain, chainAsset) + + DataPage(pageOffset, operations) + } + } + + private fun mapOperations( + operations: List, + chainAsset: Chain.Asset, + chain: Chain, + coinPrices: List, + ): List { + return operations.mapNotNull { operation -> + val operationTimestamp = operation.base.time.milliseconds.inWholeSeconds + val coinPrice = coinPrices.findNearestCoinRate(operationTimestamp) + mapOperationLocalToOperation(operation, chainAsset, chain, coinPrice) + } + } + + private fun historySourceFor(chainAsset: Chain.Asset): AssetHistory = assetSourceRegistry.sourceFor(chainAsset).history + + private suspend fun AssetHistory.getFilteredOperations( + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency + ): DataPage { + val nonFiltered = getOperations(pageSize, pageOffset, filters, accountId, chain, chainAsset, currency) + + val pageFilters = createTransactionFilters(chain, chainAsset) + val filtered = nonFiltered.applyFilters(pageFilters) + + return DataPage(nonFiltered.nextOffset, items = filtered) + } + + private suspend fun AssetHistory.createTransactionFilters(chain: Chain, chainAsset: Chain.Asset): List> { + val systemAccountFilterCreator = { matcher: SystemAccountMatcher? -> + matcher?.let { IgnoreTransfersFromSystemAccount(it, chain) } + } + + return listOfNotNull( + IgnoreUnsafeOperations(this), + systemAccountFilterCreator(poolAccountDerivation.poolRewardAccountMatcher(chain.id)), + systemAccountFilterCreator(mythosMainPotMatcherFactory.create(chainAsset)) + ) + } + + private class IgnoreTransfersFromSystemAccount( + private val systemAccountMatcher: SystemAccountMatcher, + private val chain: Chain + ) : Filter { + + override fun shouldInclude(model: Operation): Boolean { + val operationType = model.type as? Operation.Type.Transfer ?: return true + val accountId = chain.accountIdOrNull(operationType.sender) ?: return true + + return !systemAccountMatcher.isSystemAccount(accountId) + } + } + + private class IgnoreUnsafeOperations(private val assetsHistory: AssetHistory) : Filter { + + override fun shouldInclude(model: Operation): Boolean { + return assetsHistory.isOperationSafe(model) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/repository/assetFilters/AssetFiltersRepository.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/repository/assetFilters/AssetFiltersRepository.kt new file mode 100644 index 0000000..5c5ea11 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/repository/assetFilters/AssetFiltersRepository.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_assets.data.repository.assetFilters + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.feature_assets.domain.assets.filters.AssetFilter +import io.novafoundation.nova.feature_assets.domain.assets.filters.NonZeroBalanceFilter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface AssetFiltersRepository { + + val allFilters: List + + fun assetFiltersFlow(): Flow> + + fun updateAssetFilters(filters: List) +} + +private const val PREF_ASSET_FILTERS = "ASSET_FILTERS" + +class PreferencesAssetFiltersRepository( + private val preferences: Preferences +) : AssetFiltersRepository { + + override val allFilters: List = listOf( + NonZeroBalanceFilter + ) + + private val filterFactory = allFilters.associateBy(AssetFilter::name) + + override fun assetFiltersFlow(): Flow> { + return preferences.stringFlow(PREF_ASSET_FILTERS).map { encoded -> + encoded?.let { + encoded.split(",").mapNotNull(filterFactory::get) + } ?: emptyList() + } + } + + override fun updateAssetFilters(filters: List) { + val encoded = filters.joinToString(separator = ",") { it.name } + + preferences.putString(PREF_ASSET_FILTERS, encoded) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureApi.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureApi.kt new file mode 100644 index 0000000..cc7b406 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureApi.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_assets.di + +import io.novafoundation.nova.feature_assets.data.network.BalancesUpdateSystem +import io.novafoundation.nova.feature_assets.di.modules.deeplinks.AssetDeepLinks +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator + +interface AssetsFeatureApi { + + val updateSystem: BalancesUpdateSystem + + val coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory + + val assetDeepLinks: AssetDeepLinks + + val assetDetailsDeepLinkConfigurator: AssetDetailsDeepLinkConfigurator +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt new file mode 100644 index 0000000..fd0e43a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt @@ -0,0 +1,176 @@ +package io.novafoundation.nova.feature_assets.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.detail.di.BalanceDetailComponent +import io.novafoundation.nova.feature_assets.presentation.balance.list.di.BalanceListComponent +import io.novafoundation.nova.feature_assets.presentation.balance.list.view.GoToNftsView +import io.novafoundation.nova.feature_assets.presentation.balance.search.di.AssetSearchComponent +import io.novafoundation.nova.feature_assets.presentation.gifts.assets.di.AssetGiftsFlowComponent +import io.novafoundation.nova.feature_assets.presentation.gifts.networks.di.NetworkGiftsFlowComponent +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.di.NovaCardComponent +import io.novafoundation.nova.feature_assets.presentation.topup.di.TopUpAddressComponent +import io.novafoundation.nova.feature_assets.presentation.novacard.waiting.di.WaitingNovaCardTopUpComponent +import io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.asset.di.AssetBuyFlowComponent +import io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.network.di.NetworkBuyFlowComponent +import io.novafoundation.nova.feature_assets.presentation.receive.di.ReceiveComponent +import io.novafoundation.nova.feature_assets.presentation.receive.flow.asset.di.AssetReceiveFlowComponent +import io.novafoundation.nova.feature_assets.presentation.receive.flow.network.di.NetworkReceiveFlowComponent +import io.novafoundation.nova.feature_assets.presentation.send.amount.di.SelectSendComponent +import io.novafoundation.nova.feature_assets.presentation.send.confirm.di.ConfirmSendComponent +import io.novafoundation.nova.feature_assets.presentation.send.flow.asset.di.AssetSendFlowComponent +import io.novafoundation.nova.feature_assets.presentation.send.flow.network.di.NetworkSendFlowComponent +import io.novafoundation.nova.feature_assets.presentation.swap.asset.di.AssetSwapFlowComponent +import io.novafoundation.nova.feature_assets.presentation.swap.network.di.NetworkSwapFlowComponent +import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.di.AddTokenEnterInfoComponent +import io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain.di.AddTokenSelectChainComponent +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.di.ManageChainTokensComponent +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.di.ManageTokensComponent +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator +import io.novafoundation.nova.feature_assets.presentation.bridge.di.BridgeComponent +import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.asset.di.AssetSellFlowComponent +import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.network.di.NetworkSellFlowComponent +import io.novafoundation.nova.feature_assets.presentation.trade.provider.di.TradeProviderListComponent +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.di.TradeWebComponent +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.di.ExtrinsicDetailComponent +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.di.PoolRewardDetailComponent +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.di.RewardDetailComponent +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.di.TransactionDetailComponent +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.swap.di.SwapDetailComponent +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.di.TransactionHistoryFilterComponent +import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi +import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_nft_api.NftFeatureApi +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.web3names.di.Web3NamesApi + +@Component( + dependencies = [ + AssetsFeatureDependencies::class + ], + modules = [ + AssetsFeatureModule::class, + ] +) +@FeatureScope +interface AssetsFeatureComponent : AssetsFeatureApi { + + fun balanceListComponentFactory(): BalanceListComponent.Factory + + fun balanceDetailComponentFactory(): BalanceDetailComponent.Factory + + fun chooseAmountComponentFactory(): SelectSendComponent.Factory + + fun confirmTransferComponentFactory(): ConfirmSendComponent.Factory + + fun transactionDetailComponentFactory(): TransactionDetailComponent.Factory + + fun swapDetailComponentFactory(): SwapDetailComponent.Factory + + fun transactionHistoryComponentFactory(): TransactionHistoryFilterComponent.Factory + + fun rewardDetailComponentFactory(): RewardDetailComponent.Factory + + fun poolRewardDetailComponentFactory(): PoolRewardDetailComponent.Factory + + fun extrinsicDetailComponentFactory(): ExtrinsicDetailComponent.Factory + + fun receiveComponentFactory(): ReceiveComponent.Factory + + fun assetSearchComponentFactory(): AssetSearchComponent.Factory + + fun manageTokensComponentFactory(): ManageTokensComponent.Factory + + fun manageChainTokensComponentFactory(): ManageChainTokensComponent.Factory + + fun addTokenSelectChainComponentFactory(): AddTokenSelectChainComponent.Factory + + fun addTokenEnterInfoComponentFactory(): AddTokenEnterInfoComponent.Factory + + fun sendFlowComponent(): AssetSendFlowComponent.Factory + + fun swapFlowComponent(): AssetSwapFlowComponent.Factory + + fun receiveFlowComponent(): AssetReceiveFlowComponent.Factory + + fun buyFlowComponent(): AssetBuyFlowComponent.Factory + + fun sellFlowComponent(): AssetSellFlowComponent.Factory + + fun bridgeComponentFactory(): BridgeComponent.Factory + + fun giftsFlowComponent(): AssetGiftsFlowComponent.Factory + + fun tradeProviderListComponent(): TradeProviderListComponent.Factory + + fun tradeWebComponent(): TradeWebComponent.Factory + + fun networkBuyFlowComponent(): NetworkBuyFlowComponent.Factory + + fun networkSellFlowComponent(): NetworkSellFlowComponent.Factory + + fun networkReceiveFlowComponent(): NetworkReceiveFlowComponent.Factory + + fun networkSendFlowComponent(): NetworkSendFlowComponent.Factory + + fun networkSwapFlowComponent(): NetworkSwapFlowComponent.Factory + + fun topUpCardComponentFactory(): TopUpAddressComponent.Factory + + fun networkGiftsFlowComponent(): NetworkGiftsFlowComponent.Factory + + fun novaCardComponentFactory(): NovaCardComponent.Factory + + fun waitingNovaCardTopUpComponentFactory(): WaitingNovaCardTopUpComponent.Factory + + fun inject(view: GoToNftsView) + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance accountRouter: AssetsRouter, + @BindsInstance selectAddressCommunicator: SelectAddressCommunicator, + @BindsInstance topUpAddressCommunicator: TopUpAddressCommunicator, + deps: AssetsFeatureDependencies + ): AssetsFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + RuntimeApi::class, + NftFeatureApi::class, + WalletFeatureApi::class, + AccountFeatureApi::class, + CurrencyFeatureApi::class, + CrowdloanFeatureApi::class, + StakingFeatureApi::class, + Web3NamesApi::class, + WalletConnectFeatureApi::class, + SwapFeatureApi::class, + BuyFeatureApi::class, + BannersFeatureApi::class, + DeepLinkingFeatureApi::class, + ChainMigrationFeatureApi::class, + GiftFeatureApi::class + ] + ) + interface AssetsFeatureDependenciesComponent : AssetsFeatureDependencies +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureDependencies.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureDependencies.kt new file mode 100644 index 0000000..1b91d94 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureDependencies.kt @@ -0,0 +1,366 @@ +package io.novafoundation.nova.feature_assets.di + +import android.content.ContentResolver +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.EthereumAddressFormat +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository +import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClientFactory +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.core_db.dao.HoldsDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoSellRequestInterceptorFactory +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_staking_api.data.mythos.MythosMainPotMatcherFactory +import io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters.PooledBalanceUpdaterFactory +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayUseCase +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainWeigher +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.CoingeckoApi +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.ProxyPriceApi +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase +import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.qr.MultiChainQrSharingFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.web3names.domain.networking.Web3NamesInteractor +import io.novasama.substrate_sdk_android.encrypt.Signer +import io.novasama.substrate_sdk_android.icon.IconGenerator +import io.novasama.substrate_sdk_android.wsrpc.logging.Logger +import okhttp3.OkHttpClient +import javax.inject.Named + +interface AssetsFeatureDependencies { + + val maskingModeUseCase: MaskingModeUseCase + + val maskableValueFormatterFactory: MaskableValueFormatterFactory + + val amountFormatterProvider: MaskableValueFormatterProvider + + val fiatFormatter: FiatFormatter + + val amountFormatter: AmountFormatter + + val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory + + val assetsSourceRegistry: AssetSourceRegistry + + val addressInputMixinFactory: AddressInputMixinFactory + + val multiChainQrSharingFactory: MultiChainQrSharingFactory + + val walletUiUseCase: WalletUiUseCase + + val computationalCache: ComputationalCache + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val crossChainTraRepository: CrossChainTransfersRepository + + val crossChainWeigher: CrossChainWeigher + + val crossChainTransactor: CrossChainTransactor + + val crossChainValidationSystemProvider: CrossChainValidationSystemProvider + + val resourcesHintsMixinFactory: ResourcesHintsMixinFactory + + val parachainInfoRepository: ParachainInfoRepository + + val watchOnlyMissingKeysPresenter: WatchOnlyMissingKeysPresenter + + val balanceLocksRepository: BalanceLocksRepository + + val chainAssetRepository: ChainAssetRepository + + val erc20Standard: Erc20Standard + + val externalBalanceRepository: ExternalBalanceRepository + + val pooledBalanceUpdaterFactory: PooledBalanceUpdaterFactory + + val paymentUpdaterFactory: PaymentUpdaterFactory + + val locksUpdaterFactory: BalanceLocksUpdaterFactory + + val accountUpdateScope: AccountUpdateScope + + val storageSharedRequestBuilderFactory: StorageSharedRequestsBuilderFactory + + val poolDisplayUseCase: PoolDisplayUseCase + + val poolAccountDerivation: PoolAccountDerivation + + val operationDao: OperationDao + + val coinPriceRepository: CoinPriceRepository + + val swapSettingsStateProvider: SwapSettingsStateProvider + + val swapService: SwapService + + val swapAvailabilityInteractor: SwapAvailabilityInteractor + + val bannerVisibilityRepository: BannerVisibilityRepository + + val tradeMixinFactory: TradeMixin.Factory + + val crossChainTransfersUseCase: CrossChainTransfersUseCase + + val arbitraryTokenUseCase: ArbitraryTokenUseCase + + val swapRateFormatter: SwapRateFormatter + + val bottomSheetLauncher: DescriptionBottomSheetLauncher + + val selectAddressMixinFactory: SelectAddressMixin.Factory + + val chainStateRepository: ChainStateRepository + + val holdsRepository: BalanceHoldsRepository + + val holdsDao: HoldsDao + + val coinGeckoLinkParser: CoinGeckoLinkParser + + val assetIconProvider: AssetIconProvider + + val swapFlowScopeAggregator: SwapFlowScopeAggregator + + val okHttpClient: OkHttpClient + + val mythosMainPotMatcherFactory: MythosMainPotMatcherFactory + + val bannerSourceFactory: BannersSourceFactory + + val bannersMixinFactory: PromotionBannersMixinFactory + + val webViewPermissionAskerFactory: WebViewPermissionAskerFactory + + val webViewFileChooserFactory: WebViewFileChooserFactory + + val tradeTokenRegistry: TradeTokenRegistry + + val interceptingWebViewClientFactory: InterceptingWebViewClientFactory + + val mercuryoSellRequestInterceptorFactory: MercuryoSellRequestInterceptorFactory + + val multisigPendingOperationsService: MultisigPendingOperationsService + + val automaticInteractionGate: AutomaticInteractionGate + + val linkBuilderFactory: LinkBuilderFactory + + val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + + val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory + + val actionBottomSheetLauncher: ActionBottomSheetLauncher + + val chainMigrationInfoUseCase: ChainMigrationInfoUseCase + + val sendUseCase: SendUseCase + + val feePaymentProviderRegistry: FeePaymentProviderRegistry + + val customFeeCapabilityFacade: CustomFeeCapabilityFacade + + val availableGiftAssetsUseCase: AvailableGiftAssetsUseCase + + val giftsAccountSupportedUseCase: GiftsAccountSupportedUseCase + + fun web3NamesInteractor(): Web3NamesInteractor + + fun contributionsInteractor(): ContributionsInteractor + + fun contributionsRepository(): ContributionsRepository + + fun locksDao(): LockDao + + fun currencyInteractor(): CurrencyInteractor + + fun currencyRepository(): CurrencyRepository + + fun metaAccountGroupingInteractor(): MetaAccountGroupingInteractor + + fun accountInteractor(): AccountInteractor + + fun preferences(): Preferences + + fun encryptedPreferences(): EncryptedPreferences + + fun resourceManager(): ResourceManager + + fun iconGenerator(): IconGenerator + + fun clipboardManager(): ClipboardManager + + fun contentResolver(): ContentResolver + + fun accountRepository(): AccountRepository + + fun networkCreator(): NetworkApiCreator + + fun signer(): Signer + + fun logger(): Logger + + fun jsonMapper(): Gson + + fun addressIconGenerator(): AddressIconGenerator + + fun appLinksProvider(): AppLinksProvider + + fun qrCodeGenerator(): QrCodeGenerator + + fun fileProvider(): FileProvider + + fun externalAccountActions(): ExternalActions.Presentation + + fun httpExceptionHandler(): HttpExceptionHandler + + fun addressDisplayUseCase(): AddressDisplayUseCase + + fun chainRegistry(): ChainRegistry + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource + + @Named(LOCAL_STORAGE_SOURCE) + fun localStorageSource(): StorageDataSource + + fun extrinsicService(): ExtrinsicService + + fun imageLoader(): ImageLoader + + fun selectedAccountUseCase(): SelectedAccountUseCase + + fun validationExecutor(): ValidationExecutor + + fun eventsRepository(): EventsRepository + + fun walletRepository(): WalletRepository + + fun feeLoaderMixinFactory(): FeeLoaderMixin.Factory + + fun amountChooserFactory(): AmountChooserMixin.Factory + + fun walletConstants(): WalletConstants + + fun ethereumAddressFormat(): EthereumAddressFormat + + fun proxyPriceApi(): ProxyPriceApi + + fun coingeckoApi(): CoingeckoApi + + fun assetsViewModeRepository(): AssetsViewModeRepository + + fun walletConnectSessionsUseCase(): WalletConnectSessionsUseCase + + fun assetsIconModeRepository(): AssetsIconModeRepository + + fun nftRepository(): NftRepository + + fun systemCallExecutor(): SystemCallExecutor + + fun permissionsAskerFactory(): PermissionsAskerFactory + + fun assetViewModeInteractor(): AssetViewModeInteractor + + fun maxActionProviderFactory(): MaxActionProviderFactory + + fun copyAddressMixin(): CopyAddressMixin +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureHolder.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureHolder.kt new file mode 100644 index 0000000..2f0ac8c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureHolder.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_assets.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator +import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi +import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_nft_api.NftFeatureApi +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import io.novafoundation.nova.web3names.di.Web3NamesApi +import javax.inject.Inject + +@ApplicationScope +class AssetsFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val selectAddressCommunicator: SelectAddressCommunicator, + private val topUpAddressCommunicator: TopUpAddressCommunicator, + private val router: AssetsRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerAssetsFeatureComponent_AssetsFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .nftFeatureApi(getFeature(NftFeatureApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java)) + .crowdloanFeatureApi(getFeature(CrowdloanFeatureApi::class.java)) + .web3NamesApi(getFeature(Web3NamesApi::class.java)) + .walletConnectFeatureApi(getFeature(WalletConnectFeatureApi::class.java)) + .stakingFeatureApi(getFeature(StakingFeatureApi::class.java)) + .swapFeatureApi(getFeature(SwapFeatureApi::class.java)) + .buyFeatureApi(getFeature(BuyFeatureApi::class.java)) + .bannersFeatureApi(getFeature(BannersFeatureApi::class.java)) + .deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java)) + .chainMigrationFeatureApi(getFeature(ChainMigrationFeatureApi::class.java)) + .giftFeatureApi(getFeature(GiftFeatureApi::class.java)) + .build() + + return DaggerAssetsFeatureComponent.factory() + .create(router, selectAddressCommunicator, topUpAddressCommunicator, dependencies) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureModule.kt new file mode 100644 index 0000000..05bd899 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureModule.kt @@ -0,0 +1,376 @@ +package io.novafoundation.nova.feature_assets.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.model.MaskingMode +import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter +import io.novafoundation.nova.feature_assets.data.network.BalancesUpdateSystem +import io.novafoundation.nova.feature_assets.data.repository.NovaCardStateRepository +import io.novafoundation.nova.feature_assets.data.repository.RealNovaCardStateRepository +import io.novafoundation.nova.feature_assets.data.repository.RealTransactionHistoryRepository +import io.novafoundation.nova.feature_assets.data.repository.TransactionHistoryRepository +import io.novafoundation.nova.feature_assets.data.repository.assetFilters.AssetFiltersRepository +import io.novafoundation.nova.feature_assets.data.repository.assetFilters.PreferencesAssetFiltersRepository +import io.novafoundation.nova.feature_assets.di.modules.AddTokenModule +import io.novafoundation.nova.feature_assets.di.modules.ManageTokensCommonModule +import io.novafoundation.nova.feature_assets.di.modules.SendModule +import io.novafoundation.nova.feature_assets.di.modules.deeplinks.DeepLinkModule +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.WalletInteractorImpl +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.RealExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchUseCase +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetViewModeAssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardInteractor +import io.novafoundation.nova.feature_assets.domain.novaCard.RealNovaCardInteractor +import io.novafoundation.nova.feature_assets.domain.price.ChartsInteractor +import io.novafoundation.nova.feature_assets.domain.price.RealChartsInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.ExpandableAssetsMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellRestrictionCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatterFactory +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatterFactory +import io.novafoundation.nova.feature_assets.presentation.novacard.common.NovaCardRestrictionCheckMixin +import io.novafoundation.nova.feature_assets.presentation.swap.executor.InitialSwapFlowExecutor +import io.novafoundation.nova.feature_assets.presentation.swap.executor.SwapFlowExecutorFactory +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.HistoryFiltersProviderFactory +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_staking_api.data.mythos.MythosMainPotMatcherFactory +import io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters.PooledBalanceUpdaterFactory +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module( + includes = [ + SendModule::class, + ManageTokensCommonModule::class, + AddTokenModule::class, + DeepLinkModule::class + ] +) +class AssetsFeatureModule { + + @Provides + @FeatureScope + fun provideExternalBalancesInteractor( + accountRepository: AccountRepository, + externalBalanceRepository: ExternalBalanceRepository + ): ExternalBalancesInteractor = RealExternalBalancesInteractor(accountRepository, externalBalanceRepository) + + @Provides + @FeatureScope + fun provideAssetSearchUseCase( + walletRepository: WalletRepository, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + swapService: SwapService + ) = AssetSearchUseCase( + walletRepository = walletRepository, + accountRepository = accountRepository, + chainRegistry = chainRegistry, + swapService = swapService + ) + + @Provides + @FeatureScope + fun provideSearchInteractorFactory( + assetViewModeRepository: AssetsViewModeRepository, + assetSearchUseCase: AssetSearchUseCase, + chainRegistry: ChainRegistry, + tradeTokenRegistry: TradeTokenRegistry, + availableGiftAssetsUseCase: AvailableGiftAssetsUseCase + ): AssetSearchInteractorFactory = AssetViewModeAssetSearchInteractorFactory( + assetViewModeRepository, + assetSearchUseCase, + chainRegistry, + tradeTokenRegistry, + availableGiftAssetsUseCase + ) + + @Provides + @FeatureScope + fun provideAssetNetworksInteractor( + chainRegistry: ChainRegistry, + assetSearchUseCase: AssetSearchUseCase, + tradeTokenRegistry: TradeTokenRegistry, + giftAssetsUseCase: AvailableGiftAssetsUseCase + ) = AssetNetworksInteractor(chainRegistry, assetSearchUseCase, tradeTokenRegistry, giftAssetsUseCase) + + @Provides + @FeatureScope + fun provideAssetFiltersRepository(preferences: Preferences): AssetFiltersRepository { + return PreferencesAssetFiltersRepository(preferences) + } + + @Provides + @FeatureScope + fun provideWalletInteractor( + walletRepository: WalletRepository, + accountRepository: AccountRepository, + assetFiltersRepository: AssetFiltersRepository, + chainRegistry: ChainRegistry, + nftRepository: NftRepository, + transactionHistoryRepository: TransactionHistoryRepository, + currencyRepository: CurrencyRepository + ): WalletInteractor = WalletInteractorImpl( + walletRepository = walletRepository, + accountRepository = accountRepository, + assetFiltersRepository = assetFiltersRepository, + chainRegistry = chainRegistry, + nftRepository = nftRepository, + transactionHistoryRepository = transactionHistoryRepository, + currencyRepository = currencyRepository + ) + + @Provides + @FeatureScope + fun provideHistoryFiltersProviderFactory( + computationalCache: ComputationalCache, + assetSourceRegistry: AssetSourceRegistry, + chainRegistry: ChainRegistry, + ) = HistoryFiltersProviderFactory(computationalCache, assetSourceRegistry, chainRegistry) + + @Provides + @FeatureScope + fun provideControllableAssetCheckMixin( + missingKeysPresenter: WatchOnlyMissingKeysPresenter, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + resourceManager: ResourceManager + ): ControllableAssetCheckMixin { + return ControllableAssetCheckMixin( + missingKeysPresenter, + actionAwaitableMixinFactory, + resourceManager + ) + } + + @Provides + @FeatureScope + fun provideBalancesUpdateSystem( + chainRegistry: ChainRegistry, + paymentUpdaterFactory: PaymentUpdaterFactory, + balanceLocksUpdater: BalanceLocksUpdaterFactory, + pooledBalanceUpdaterFactory: PooledBalanceUpdaterFactory, + accountUpdateScope: AccountUpdateScope, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + ): BalancesUpdateSystem { + return BalancesUpdateSystem( + chainRegistry = chainRegistry, + paymentUpdaterFactory = paymentUpdaterFactory, + balanceLocksUpdater = balanceLocksUpdater, + pooledBalanceUpdaterFactory = pooledBalanceUpdaterFactory, + accountUpdateScope = accountUpdateScope, + storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory + ) + } + + @Provides + @FeatureScope + fun provideTransactionHistoryRepository( + assetSourceRegistry: AssetSourceRegistry, + operationsDao: OperationDao, + coinPriceRepository: CoinPriceRepository, + poolAccountDerivation: PoolAccountDerivation, + mythosMainPotMatcherFactory: MythosMainPotMatcherFactory, + ): TransactionHistoryRepository = RealTransactionHistoryRepository( + assetSourceRegistry = assetSourceRegistry, + operationDao = operationsDao, + coinPriceRepository = coinPriceRepository, + poolAccountDerivation = poolAccountDerivation, + mythosMainPotMatcherFactory = mythosMainPotMatcherFactory + ) + + @Provides + @FeatureScope + fun provideNovaCardRepository(preferences: Preferences): NovaCardStateRepository { + return RealNovaCardStateRepository(preferences) + } + + @Provides + @FeatureScope + fun provideNovaCardInteractor(repository: NovaCardStateRepository): NovaCardInteractor { + return RealNovaCardInteractor(repository) + } + + @Provides + @FeatureScope + fun provideInitialSwapFlowExecutor( + assetsRouter: AssetsRouter + ): InitialSwapFlowExecutor { + return InitialSwapFlowExecutor(assetsRouter) + } + + @Provides + @FeatureScope + fun provideSwapExecutor( + initialSwapFlowExecutor: InitialSwapFlowExecutor, + assetsRouter: AssetsRouter, + swapSettingsStateProvider: SwapSettingsStateProvider + ): SwapFlowExecutorFactory { + return SwapFlowExecutorFactory(initialSwapFlowExecutor, assetsRouter, swapSettingsStateProvider) + } + + @Provides + @FeatureScope + fun provideNetworkAssetMapperFactory( + fiatFormatter: FiatFormatter, + amountFormatter: AmountFormatter + ): NetworkAssetFormatterFactory { + return NetworkAssetFormatterFactory( + fiatFormatter, + amountFormatter + ) + } + + @Provides + @FeatureScope + fun provideTokenAssetMapperFactory(amountFormatter: AmountFormatter): TokenAssetFormatterFactory { + return TokenAssetFormatterFactory(amountFormatter) + } + + @Provides + @FeatureScope + fun provideNotMaskingNetworkAssetMapper( + networkAssetFormatterFactory: NetworkAssetFormatterFactory, + maskableValueFormatterFactory: MaskableValueFormatterFactory + ): NetworkAssetFormatter { + return networkAssetFormatterFactory.create(maskableValueFormatterFactory.create(MaskingMode.DISABLED)) + } + + @Provides + @FeatureScope + fun provideNotMaskingTokenAssetMapper( + tokenAssetFormatterFactory: TokenAssetFormatterFactory, + maskableValueFormatterFactory: MaskableValueFormatterFactory + ): TokenAssetFormatter { + return tokenAssetFormatterFactory.create(maskableValueFormatterFactory.create(MaskingMode.DISABLED)) + } + + @Provides + @FeatureScope + fun provideExpandableAssetsMixinFactory( + assetIconProvider: AssetIconProvider, + currencyInteractor: CurrencyInteractor, + assetsViewModeRepository: AssetsViewModeRepository, + amountFormatterProvider: MaskableValueFormatterProvider, + networkAssetFormatterFactory: NetworkAssetFormatterFactory, + tokenAssetFormatterFactory: TokenAssetFormatterFactory, + ): ExpandableAssetsMixinFactory { + return ExpandableAssetsMixinFactory( + assetIconProvider, + currencyInteractor, + assetsViewModeRepository, + amountFormatterProvider, + networkAssetFormatterFactory, + tokenAssetFormatterFactory + ) + } + + @Provides + @FeatureScope + fun provideChartsInteractor( + coinPriceRepository: CoinPriceRepository, + currencyRepository: CurrencyRepository + ): ChartsInteractor { + return RealChartsInteractor(coinPriceRepository, currencyRepository) + } + + @Provides + @FeatureScope + fun provideBuySellRestrictionCheckMixin( + accountUseCase: SelectedAccountUseCase, + actionLauncher: ActionBottomSheetLauncher, + resourceManager: ResourceManager + ): BuySellRestrictionCheckMixin { + return BuySellRestrictionCheckMixin( + accountUseCase, + resourceManager, + actionLauncher + ) + } + + @Provides + @FeatureScope + fun provideNovaCardRestrictionCheckMixin( + accountUseCase: SelectedAccountUseCase, + actionLauncher: ActionBottomSheetLauncher, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry + ): NovaCardRestrictionCheckMixin { + return NovaCardRestrictionCheckMixin( + accountUseCase, + resourceManager, + actionLauncher, + chainRegistry + ) + } + + @Provides + @FeatureScope + fun provideBuySellMixinFactory( + router: AssetsRouter, + tradeTokenRegistry: TradeTokenRegistry, + chainRegistry: ChainRegistry, + resourceManager: ResourceManager, + buySellRestrictionCheckMixin: BuySellRestrictionCheckMixin + ): BuySellSelectorMixinFactory { + return BuySellSelectorMixinFactory( + router, + tradeTokenRegistry, + chainRegistry, + resourceManager, + buySellRestrictionCheckMixin + ) + } + + @Provides + @FeatureScope + fun provideGiftsRestrictionCheckMixin( + accountSupportedUseCase: GiftsAccountSupportedUseCase, + resourceManager: ResourceManager, + actionLauncher: ActionBottomSheetLauncher, + ): GiftsRestrictionCheckMixin { + return GiftsRestrictionCheckMixin( + accountSupportedUseCase, + resourceManager, + actionLauncher + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/AddTokenModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/AddTokenModule.kt new file mode 100644 index 0000000..17c6b88 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/AddTokenModule.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_assets.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.format.EthereumAddressFormat +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_assets.domain.tokens.add.AddTokensInteractor +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.feature_assets.domain.tokens.add.RealAddTokensInteractor +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.CoingeckoApi +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class AddTokenModule { + + @Provides + fun coinGeckoLinkValidationFactory( + coingeckoApi: CoingeckoApi, + coinGeckoLinkParser: CoinGeckoLinkParser + ): CoinGeckoLinkValidationFactory { + return CoinGeckoLinkValidationFactory(coingeckoApi, coinGeckoLinkParser) + } + + @Provides + @FeatureScope + fun provideInteractor( + chainRegistry: ChainRegistry, + erc20Standard: Erc20Standard, + chainAssetRepository: ChainAssetRepository, + coinGeckoLinkParser: CoinGeckoLinkParser, + ethereumAddressFormat: EthereumAddressFormat, + currencyRepository: CurrencyRepository, + walletRepository: WalletRepository, + coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory + ): AddTokensInteractor { + return RealAddTokensInteractor( + chainRegistry, + erc20Standard, + chainAssetRepository, + coinGeckoLinkParser, + ethereumAddressFormat, + currencyRepository, + walletRepository, + coinGeckoLinkValidationFactory + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/ManageTokensCommonModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/ManageTokensCommonModule.kt new file mode 100644 index 0000000..d424471 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/ManageTokensCommonModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_assets.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_assets.domain.tokens.AssetsDataCleaner +import io.novafoundation.nova.feature_assets.domain.tokens.RealAssetsDataCleaner +import io.novafoundation.nova.feature_assets.domain.tokens.manage.ManageTokenInteractor +import io.novafoundation.nova.feature_assets.domain.tokens.manage.RealManageTokenInteractor +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.model.MultiChainTokenMapper +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class ManageTokensCommonModule { + + @Provides + @FeatureScope + fun provideMultiChainTokenUiMapper( + assetIconProvider: AssetIconProvider, + resourceManager: ResourceManager + ) = MultiChainTokenMapper(assetIconProvider, resourceManager) + + @Provides + @FeatureScope + fun provideAssetDataCleaner( + externalBalanceRepository: ExternalBalanceRepository, + contributionsRepository: ContributionsRepository, + walletRepository: WalletRepository, + ): AssetsDataCleaner { + return RealAssetsDataCleaner(externalBalanceRepository, contributionsRepository, walletRepository) + } + + @Provides + @FeatureScope + fun provideInteractor( + chainRegistry: ChainRegistry, + chainAssetRepository: ChainAssetRepository, + assetsDataCleaner: AssetsDataCleaner + ): ManageTokenInteractor = RealManageTokenInteractor( + chainRegistry = chainRegistry, + chainAssetRepository = chainAssetRepository, + assetsDataCleaner = assetsDataCleaner + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/SendModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/SendModule.kt new file mode 100644 index 0000000..d55b3b7 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/SendModule.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_assets.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_assets.domain.send.SendInteractor +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository + +@Module +class SendModule { + + @Provides + @FeatureScope + fun provideSendInteractor( + assetSourceRegistry: AssetSourceRegistry, + crossChainTransfersRepository: CrossChainTransfersRepository, + crossChainTransactor: CrossChainTransactor, + parachainInfoRepository: ParachainInfoRepository, + extrinsicService: ExtrinsicService, + sendUseCase: SendUseCase, + crossChainTransfersUseCase: CrossChainTransfersUseCase, + crossChainValidationProvider: CrossChainValidationSystemProvider + ) = SendInteractor( + assetSourceRegistry, + crossChainTransactor, + crossChainTransfersRepository, + parachainInfoRepository, + crossChainTransfersUseCase, + extrinsicService, + sendUseCase, + crossChainValidationProvider + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/deeplinks/AssetDeepLinks.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/deeplinks/AssetDeepLinks.kt new file mode 100644 index 0000000..7bfa27f --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/deeplinks/AssetDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_assets.di.modules.deeplinks + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class AssetDeepLinks(val deepLinkHandlers: List) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/deeplinks/DeepLinkModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/deeplinks/DeepLinkModule.kt new file mode 100644 index 0000000..68f121e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/modules/deeplinks/DeepLinkModule.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_assets.di.modules.deeplinks + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkHandler +import io.novafoundation.nova.feature_assets.presentation.novacard.common.NovaCardRestrictionCheckMixin +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.deeplink.NovaCardDeepLinkHandler +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideDeepLinkConfigurator( + linkBuilderFactory: LinkBuilderFactory + ): AssetDetailsDeepLinkConfigurator { + return AssetDetailsDeepLinkConfigurator(linkBuilderFactory) + } + + @Provides + @FeatureScope + fun provideAssetDetailsDeepLinkHandler( + router: AssetsRouter, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + automaticInteractionGate: AutomaticInteractionGate, + assetDetailsDeepLinkConfigurator: AssetDetailsDeepLinkConfigurator + ): AssetDetailsDeepLinkHandler { + return AssetDetailsDeepLinkHandler( + router, + accountRepository, + chainRegistry, + automaticInteractionGate, + assetDetailsDeepLinkConfigurator + ) + } + + @Provides + @FeatureScope + fun provideNovaCardDeepLinkHandler( + router: AssetsRouter, + automaticInteractionGate: AutomaticInteractionGate, + novaCardRestrictionCheckMixin: NovaCardRestrictionCheckMixin + ): NovaCardDeepLinkHandler { + return NovaCardDeepLinkHandler( + router, + automaticInteractionGate, + novaCardRestrictionCheckMixin + ) + } + + @Provides + @FeatureScope + fun provideDeepLinks( + assetDetails: AssetDetailsDeepLinkHandler, + novaCardDeepLink: NovaCardDeepLinkHandler + ): AssetDeepLinks { + return AssetDeepLinks(listOf(assetDetails, novaCardDeepLink)) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/WalletInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/WalletInteractor.kt new file mode 100644 index 0000000..39df716 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/WalletInteractor.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_assets.domain + +import io.novafoundation.nova.common.data.model.DataPage +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_nft_api.data.repository.NftSyncTrigger +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.OperationsPageChange +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface WalletInteractor { + + fun isFiltersEnabledFlow(): Flow + + fun filterAssets(assetsFlow: Flow>): Flow> + + fun assetsFlow(): Flow> + + suspend fun syncAssetsRates(currency: Currency) + + fun nftSyncTrigger(): Flow + + suspend fun syncAllNfts(metaAccount: MetaAccount) + + suspend fun syncChainNfts(metaAccount: MetaAccount, chain: Chain) + + fun chainFlow(chainId: ChainId): Flow + + fun assetFlow(chainId: ChainId, chainAssetId: Int): Flow + + fun assetFlow(chainAsset: Chain.Asset): Flow + + fun commissionAssetFlow(chainId: ChainId): Flow + + fun commissionAssetFlow(chain: Chain): Flow + + fun operationsFirstPageFlow(chainId: ChainId, chainAssetId: Int): Flow + + suspend fun syncOperationsFirstPage( + chainId: ChainId, + chainAssetId: Int, + pageSize: Int, + filters: Set, + ): Result<*> + + suspend fun getOperations( + chainId: ChainId, + chainAssetId: Int, + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set + ): Result> + + suspend fun groupAssetsByNetwork( + assets: List, + externalBalances: List + ): Map> + + suspend fun groupAssetsByToken( + assets: List, + externalBalances: List + ): Map> +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/WalletInteractorImpl.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/WalletInteractorImpl.kt new file mode 100644 index 0000000..76d6930 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/WalletInteractorImpl.kt @@ -0,0 +1,196 @@ +package io.novafoundation.nova.feature_assets.domain + +import io.novafoundation.nova.common.data.model.DataPage +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.common.utils.applyFilters +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_assets.data.repository.TransactionHistoryRepository +import io.novafoundation.nova.feature_assets.data.repository.assetFilters.AssetFiltersRepository +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByNetwork +import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByToken +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_nft_api.data.repository.NftSyncTrigger +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.OperationsPageChange +import io.novafoundation.nova.feature_wallet_api.domain.model.aggregatedBalanceByAsset +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.enabledChainByIdFlow +import io.novafoundation.nova.runtime.multiNetwork.enabledChains +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.withIndex +import kotlinx.coroutines.withContext + +class WalletInteractorImpl( + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, + private val assetFiltersRepository: AssetFiltersRepository, + private val chainRegistry: ChainRegistry, + private val nftRepository: NftRepository, + private val transactionHistoryRepository: TransactionHistoryRepository, + private val currencyRepository: CurrencyRepository +) : WalletInteractor { + + override fun isFiltersEnabledFlow(): Flow { + return assetFiltersRepository.assetFiltersFlow() + .map { it.isNotEmpty() } + } + + override fun filterAssets(assetsFlow: Flow>): Flow> { + return combine(assetsFlow, assetFiltersRepository.assetFiltersFlow()) { assets, filters -> + assets.applyFilters(filters) + } + } + + override fun assetsFlow(): Flow> { + val assetsFlow = accountRepository.selectedMetaAccountFlow() + .flatMapLatest { walletRepository.syncedAssetsFlow(it.id) } + + val enabledChains = chainRegistry.enabledChainByIdFlow() + + return combine(assetsFlow, enabledChains) { assets, chainsById -> + assets.filter { chainsById.containsKey(it.token.configuration.chainId) } + } + } + + override suspend fun syncAssetsRates(currency: Currency) { + runCatching { + walletRepository.syncAssetsRates(currency) + } + } + + override fun nftSyncTrigger(): Flow { + return nftRepository.initialNftSyncTrigger() + } + + override suspend fun syncAllNfts(metaAccount: MetaAccount) { + nftRepository.initialNftSync(metaAccount, forceOverwrite = false) + } + + override suspend fun syncChainNfts(metaAccount: MetaAccount, chain: Chain) { + nftRepository.initialNftSync(metaAccount, chain) + } + + override fun chainFlow(chainId: ChainId): Flow { + return chainRegistry.enabledChainByIdFlow() + .map { it.getValue(chainId) } + } + + override fun assetFlow(chainId: ChainId, chainAssetId: Int): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + val (_, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId) + + walletRepository.assetFlow(metaAccount.id, chainAsset) + } + } + + override fun assetFlow(chainAsset: Chain.Asset): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + walletRepository.assetFlow(metaAccount.id, chainAsset) + } + } + + override fun commissionAssetFlow(chainId: ChainId): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + val chain = chainRegistry.getChain(chainId) + + walletRepository.assetFlow(metaAccount.id, chain.commissionAsset) + } + } + + override fun commissionAssetFlow(chain: Chain): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + walletRepository.assetFlow(metaAccount.id, chain.commissionAsset) + } + } + + override fun operationsFirstPageFlow(chainId: ChainId, chainAssetId: Int): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId) + val accountId = metaAccount.accountIdIn(chain)!! + val currency = currencyRepository.getSelectedCurrency() + transactionHistoryRepository.operationsFirstPageFlow(accountId, chain, chainAsset, currency) + .withIndex() + .map { (index, cursorPage) -> OperationsPageChange(cursorPage, accountChanged = index == 0) } + } + } + + override suspend fun syncOperationsFirstPage( + chainId: ChainId, + chainAssetId: Int, + pageSize: Int, + filters: Set, + ) = withContext(Dispatchers.Default) { + runCatching { + val metaAccount = accountRepository.getSelectedMetaAccount() + val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId) + val accountId = metaAccount.accountIdIn(chain)!! + val currency = currencyRepository.getSelectedCurrency() + + transactionHistoryRepository.syncOperationsFirstPage(pageSize, filters, accountId, chain, chainAsset, currency) + } + } + + override suspend fun getOperations( + chainId: ChainId, + chainAssetId: Int, + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + ): Result> { + return runCatching { + val metaAccount = accountRepository.getSelectedMetaAccount() + val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId) + val accountId = metaAccount.requireAccountIdIn(chain) + val currency = currencyRepository.getSelectedCurrency() + + transactionHistoryRepository.getOperations( + pageSize = pageSize, + pageOffset = pageOffset, + filters = filters, + accountId = accountId, + chain = chain, + chainAsset = chainAsset, + currency = currency + ) + } + } + + override suspend fun groupAssetsByNetwork( + assets: List, + externalBalances: List + ): Map> { + val chains = chainRegistry.enabledChainByIdFlow().first() + + return groupAndSortAssetsByNetwork(assets, externalBalances.aggregatedBalanceByAsset(), chains) + } + + override suspend fun groupAssetsByToken( + assets: List, + externalBalances: List + ): Map> { + val chains = chainRegistry.enabledChainByIdFlow().first() + + return groupAndSortAssetsByToken(assets, externalBalances.aggregatedBalanceByAsset(), chains) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/ExternalBalancesInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/ExternalBalancesInteractor.kt new file mode 100644 index 0000000..abe0f14 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/ExternalBalancesInteractor.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_assets.domain.assets + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +interface ExternalBalancesInteractor { + + fun observeExternalBalances(): Flow> + + fun observeExternalBalances(assetId: FullChainAssetId): Flow> +} + +class RealExternalBalancesInteractor( + private val accountRepository: AccountRepository, + private val externalBalanceRepository: ExternalBalanceRepository, +) : ExternalBalancesInteractor { + + override fun observeExternalBalances(): Flow> { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + externalBalanceRepository.observeAccountExternalBalances(metaAccount.id) + } + } + + override fun observeExternalBalances(assetId: FullChainAssetId): Flow> { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + externalBalanceRepository.observeAccountChainExternalBalances(metaAccount.id, assetId) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/filters/AssetFilter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/filters/AssetFilter.kt new file mode 100644 index 0000000..70b754c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/filters/AssetFilter.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_assets.domain.assets.filters + +import io.novafoundation.nova.common.utils.NamedFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset + +typealias AssetFilter = NamedFilter diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/filters/AssetFiltersInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/filters/AssetFiltersInteractor.kt new file mode 100644 index 0000000..c8eabb7 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/filters/AssetFiltersInteractor.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_assets.domain.assets.filters + +import io.novafoundation.nova.feature_assets.data.repository.assetFilters.AssetFiltersRepository +import kotlinx.coroutines.flow.first + +class AssetFiltersInteractor( + private val assetFiltersRepository: AssetFiltersRepository +) { + + val allFilters = assetFiltersRepository.allFilters + + fun updateFilters(filters: List) { + assetFiltersRepository.updateAssetFilters(filters) + } + + suspend fun currentFilters(): Set = assetFiltersRepository.assetFiltersFlow().first().toSet() +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/filters/NonZeroBalanceFilter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/filters/NonZeroBalanceFilter.kt new file mode 100644 index 0000000..f9245b2 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/filters/NonZeroBalanceFilter.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_assets.domain.assets.filters + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import java.math.BigDecimal + +object NonZeroBalanceFilter : AssetFilter { + + override val name: String = "NonZeroBalance" + + override fun shouldInclude(model: Asset) = model.total > BigDecimal.ZERO +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/list/AssetsListInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/list/AssetsListInteractor.kt new file mode 100644 index 0000000..0dca9d4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/list/AssetsListInteractor.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_assets.domain.assets.list + +import io.novafoundation.nova.common.data.model.AssetViewMode +import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.isFullySynced +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map + +private const val PREVIEW_COUNT = 3 + +class AssetsListInteractor( + private val accountRepository: AccountRepository, + private val nftRepository: NftRepository, + private val assetsViewModeRepository: AssetsViewModeRepository +) { + + fun assetsViewModeFlow() = assetsViewModeRepository.assetsViewModeFlow() + + suspend fun setAssetViewMode(assetViewModel: AssetViewMode) { + assetsViewModeRepository.setAssetsViewMode(assetViewModel) + } + + suspend fun fullSyncNft(nft: Nft) = nftRepository.fullNftSync(nft) + + fun observeNftPreviews(): Flow { + return accountRepository.selectedMetaAccountFlow() + .flatMapLatest(nftRepository::allNftFlow) + .map { nfts -> + NftPreviews( + totalNftsCount = nfts.size, + nftPreviews = nfts.sortedBy { it.isFullySynced }.take(PREVIEW_COUNT) + ) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/list/NftPreviews.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/list/NftPreviews.kt new file mode 100644 index 0000000..e56615b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/list/NftPreviews.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_assets.domain.assets.list + +import io.novafoundation.nova.feature_nft_api.data.model.Nft + +class NftPreviews( + val totalNftsCount: Int, + val nftPreviews: List +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/models/AssetsByViewModeResult.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/models/AssetsByViewModeResult.kt new file mode 100644 index 0000000..a51611a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/models/AssetsByViewModeResult.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_assets.domain.assets.models + +import io.novafoundation.nova.common.utils.MultiMapList +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance +import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup + +sealed interface AssetsByViewModeResult { + + class ByNetworks(val assets: MultiMapList) : AssetsByViewModeResult + + class ByTokens(val tokens: MultiMapList) : AssetsByViewModeResult +} + +fun AssetsByViewModeResult.groupList(): List { + return when (this) { + is AssetsByViewModeResult.ByNetworks -> assets.keys.toList() + is AssetsByViewModeResult.ByTokens -> tokens.keys.toList() + } +} + +fun MultiMapList.byNetworks(): AssetsByViewModeResult { + return AssetsByViewModeResult.ByNetworks(this) +} + +fun MultiMapList.byTokens(): AssetsByViewModeResult { + return AssetsByViewModeResult.ByTokens(this) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/AssetSearchInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/AssetSearchInteractor.kt new file mode 100644 index 0000000..d66bee7 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/AssetSearchInteractor.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_assets.domain.assets.search + +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface AssetSearchInteractorFactory { + + fun createByAssetViewMode(): AssetSearchInteractor +} + +typealias AssetSearchFilter = suspend (Asset) -> Boolean + +interface AssetSearchInteractor { + + fun tradeAssetSearch( + queryFlow: Flow, + externalBalancesFlow: Flow>, + tradeType: TradeTokenRegistry.TradeType + ): Flow + + fun sendAssetSearch( + queryFlow: Flow, + externalBalancesFlow: Flow>, + ): Flow + + fun searchSwapAssetsFlow( + forAsset: FullChainAssetId?, + queryFlow: Flow, + externalBalancesFlow: Flow>, + coroutineScope: CoroutineScope + ): Flow + + fun searchReceiveAssetsFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + ): Flow + + fun giftAssetsSearch( + queryFlow: Flow, + externalBalancesFlow: Flow>, + coroutineScope: CoroutineScope + ): Flow + + fun searchAssetsFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + ): Flow +} + +fun Flow>.mapToAssetSearchFilter(): Flow { + return map { assetsSet -> + { asset -> + val chainAsset = asset.token.configuration + + chainAsset.fullId in assetsSet + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/AssetSearchUseCase.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/AssetSearchUseCase.kt new file mode 100644 index 0000000..677231e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/AssetSearchUseCase.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_assets.domain.assets.search + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_assets.domain.common.searchTokens +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainsById +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest + +class AssetSearchUseCase( + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val swapService: SwapService +) { + + fun filteredAssetFlow(filterFlow: Flow): Flow> { + val assetsFlow = accountRepository.selectedMetaAccountFlow() + .flatMapLatest { walletRepository.syncedAssetsFlow(it.id) } + + return combine(assetsFlow, filterFlow) { assets, filter -> + if (filter == null) { + assets + } else { + assets.filter { filter(it) } + } + } + } + + fun filterAssetsByQuery(query: String, assets: List, chainsById: ChainsById): List { + return assets.searchTokens( + query = query, + chainsById = chainsById, + tokenSymbol = { it.token.configuration.symbol.value }, + relevantToChains = { asset, chainIds -> asset.token.configuration.chainId in chainIds } + ) + } + + fun getAvailableSwapAssets(asset: FullChainAssetId?, coroutineScope: CoroutineScope): Flow> { + return flowOfAll { + val chainAsset = asset?.let { chainRegistry.asset(it) } + + if (chainAsset == null) { + swapService.assetsAvailableForSwap(coroutineScope) + } else { + swapService.availableSwapDirectionsFor(chainAsset, coroutineScope) + } + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/AssetViewModeAssetSearchInteractorFactory.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/AssetViewModeAssetSearchInteractorFactory.kt new file mode 100644 index 0000000..adde607 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/AssetViewModeAssetSearchInteractorFactory.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_assets.domain.assets.search + +import io.novafoundation.nova.common.data.model.AssetViewMode +import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class AssetViewModeAssetSearchInteractorFactory( + private val assetViewModeRepository: AssetsViewModeRepository, + private val assetSearchUseCase: AssetSearchUseCase, + private val chainRegistry: ChainRegistry, + private val tradeTokenRegistry: TradeTokenRegistry, + private val availableGiftAssetsUseCase: AvailableGiftAssetsUseCase +) : AssetSearchInteractorFactory { + + override fun createByAssetViewMode(): AssetSearchInteractor { + return when (assetViewModeRepository.getAssetViewMode()) { + AssetViewMode.TOKENS -> ByTokensAssetSearchInteractor(assetSearchUseCase, chainRegistry, tradeTokenRegistry, availableGiftAssetsUseCase) + AssetViewMode.NETWORKS -> ByNetworkAssetSearchInteractor(assetSearchUseCase, chainRegistry, tradeTokenRegistry, availableGiftAssetsUseCase) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/ByNetworkAssetSearchInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/ByNetworkAssetSearchInteractor.kt new file mode 100644 index 0000000..6d56205 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/ByNetworkAssetSearchInteractor.kt @@ -0,0 +1,120 @@ +package io.novafoundation.nova.feature_assets.domain.assets.search + +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance +import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.getAssetBaseComparator +import io.novafoundation.nova.feature_assets.domain.common.getAssetGroupBaseComparator +import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByNetwork +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.feature_wallet_api.domain.model.aggregatedBalanceByAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.enabledChainById +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class ByNetworkAssetSearchInteractor( + private val assetSearchUseCase: AssetSearchUseCase, + private val chainRegistry: ChainRegistry, + private val tradeTokenRegistry: TradeTokenRegistry, + private val availableGiftAssetsUseCase: AvailableGiftAssetsUseCase +) : AssetSearchInteractor { + + override fun tradeAssetSearch( + queryFlow: Flow, + externalBalancesFlow: Flow>, + tradeType: TradeTokenRegistry.TradeType + ): Flow { + val filter = { asset: Asset -> tradeTokenRegistry.hasProvider(asset.token.configuration, tradeType) } + + return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filter = filter) + } + + override fun sendAssetSearch( + queryFlow: Flow, + externalBalancesFlow: Flow>, + ): Flow { + val filter = { asset: Asset -> asset.transferableInPlanks.isPositive() } + + return searchAssetsByNetworksInternalFlow( + queryFlow, + externalBalancesFlow, + assetGroupComparator = getAssetGroupBaseComparator { it.groupTransferableBalanceFiat }, + assetsComparator = getAssetBaseComparator { it.balanceWithOffchain.transferable.fiat }, + filter = filter + ) + } + + override fun searchSwapAssetsFlow( + forAsset: FullChainAssetId?, + queryFlow: Flow, + externalBalancesFlow: Flow>, + coroutineScope: CoroutineScope + ): Flow { + val filterFlow = assetSearchUseCase.getAvailableSwapAssets(forAsset, coroutineScope).mapToAssetSearchFilter() + return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filterFlow = filterFlow) + } + + override fun searchReceiveAssetsFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + ): Flow { + return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filter = null) + } + + override fun giftAssetsSearch( + queryFlow: Flow, + externalBalancesFlow: Flow>, + coroutineScope: CoroutineScope + ): Flow { + val filterFlow = availableGiftAssetsUseCase.getAvailableGiftAssets(coroutineScope).mapToAssetSearchFilter() + return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filterFlow = filterFlow) + } + + override fun searchAssetsFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + ): Flow { + return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, filter = null) + } + + private fun ByNetworkAssetSearchInteractor.searchAssetsByNetworksInternalFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + assetGroupComparator: Comparator = getAssetGroupBaseComparator(), + assetsComparator: Comparator = getAssetBaseComparator(), + filter: AssetSearchFilter?, + ): Flow { + val filterFlow = flowOf(filter) + + return searchAssetsByNetworksInternalFlow(queryFlow, externalBalancesFlow, assetGroupComparator, assetsComparator, filterFlow) + } + + private fun searchAssetsByNetworksInternalFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + assetGroupComparator: Comparator = getAssetGroupBaseComparator(), + assetsComparator: Comparator = getAssetBaseComparator(), + filterFlow: Flow, + ): Flow { + val assetsFlow = assetSearchUseCase.filteredAssetFlow(filterFlow) + + val aggregatedExternalBalances = externalBalancesFlow.map { it.aggregatedBalanceByAsset() } + + return combine(assetsFlow, aggregatedExternalBalances, queryFlow) { assets, externalBalances, query -> + val chainsById = chainRegistry.enabledChainById() + val filtered = assetSearchUseCase.filterAssetsByQuery(query, assets, chainsById) + + val assetGroups = groupAndSortAssetsByNetwork(filtered, externalBalances, chainsById, assetGroupComparator, assetsComparator) + AssetsByViewModeResult.ByNetworks(assetGroups) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/ByTokensAssetSearchInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/ByTokensAssetSearchInteractor.kt new file mode 100644 index 0000000..a3e886d --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/assets/search/ByTokensAssetSearchInteractor.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.feature_assets.domain.assets.search + +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.getTokenAssetBaseComparator +import io.novafoundation.nova.feature_assets.domain.common.getTokenAssetGroupBaseComparator +import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByToken +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.feature_wallet_api.domain.model.aggregatedBalanceByAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.enabledChainById +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class ByTokensAssetSearchInteractor( + private val assetSearchUseCase: AssetSearchUseCase, + private val chainRegistry: ChainRegistry, + private val tradeTokenRegistry: TradeTokenRegistry, + private val availableGiftAssetsUseCase: AvailableGiftAssetsUseCase +) : AssetSearchInteractor { + + override fun tradeAssetSearch( + queryFlow: Flow, + externalBalancesFlow: Flow>, + tradeType: TradeTokenRegistry.TradeType + ): Flow { + val filter = { asset: Asset -> tradeTokenRegistry.hasProvider(asset.token.configuration, tradeType) } + + return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filter = filter) + } + + override fun sendAssetSearch( + queryFlow: Flow, + externalBalancesFlow: Flow>, + ): Flow { + val filter = { asset: Asset -> asset.transferableInPlanks.isPositive() } + + return searchAssetsByTokensInternalFlow( + queryFlow, + externalBalancesFlow, + assetGroupComparator = getTokenAssetGroupBaseComparator { it.groupBalance.transferable.fiat }, + assetsComparator = getTokenAssetBaseComparator { it.balanceWithOffChain.transferable.fiat }, + filter = filter + ) + } + + override fun searchSwapAssetsFlow( + forAsset: FullChainAssetId?, + queryFlow: Flow, + externalBalancesFlow: Flow>, + coroutineScope: CoroutineScope + ): Flow { + val filterFlow = assetSearchUseCase.getAvailableSwapAssets(forAsset, coroutineScope).mapToAssetSearchFilter() + return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filterFlow = filterFlow) + } + + override fun searchReceiveAssetsFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + ): Flow { + return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filter = null) + } + + override fun giftAssetsSearch( + queryFlow: Flow, + externalBalancesFlow: Flow>, + coroutineScope: CoroutineScope + ): Flow { + val filterFlow = availableGiftAssetsUseCase.getAvailableGiftAssets(coroutineScope).mapToAssetSearchFilter() + return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filterFlow = filterFlow) + } + + override fun searchAssetsFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + ): Flow { + return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, filter = null) + } + + private fun searchAssetsByTokensInternalFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + assetGroupComparator: Comparator = getTokenAssetGroupBaseComparator(), + assetsComparator: Comparator = getTokenAssetBaseComparator(), + filter: AssetSearchFilter?, + ): Flow { + val filterFlow = flowOf(filter) + + return searchAssetsByTokensInternalFlow(queryFlow, externalBalancesFlow, assetGroupComparator, assetsComparator, filterFlow) + } + + private fun searchAssetsByTokensInternalFlow( + queryFlow: Flow, + externalBalancesFlow: Flow>, + assetGroupComparator: Comparator = getTokenAssetGroupBaseComparator(), + assetsComparator: Comparator = getTokenAssetBaseComparator(), + filterFlow: Flow, + ): Flow { + val assetsFlow = assetSearchUseCase.filteredAssetFlow(filterFlow) + + val aggregatedExternalBalances = externalBalancesFlow.map { it.aggregatedBalanceByAsset() } + + return combine(assetsFlow, aggregatedExternalBalances, queryFlow) { assets, externalBalances, query -> + val chainsById = chainRegistry.enabledChainById() + val filtered = assetSearchUseCase.filterAssetsByQuery(query, assets, chainsById) + + val assetGroups = groupAndSortAssetsByToken(filtered, externalBalances, chainsById, assetGroupComparator, assetsComparator) + + AssetsByViewModeResult.ByTokens(assetGroups) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/breakdown/BalanceBreakdownInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/breakdown/BalanceBreakdownInteractor.kt new file mode 100644 index 0000000..dbc34c0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/breakdown/BalanceBreakdownInteractor.kt @@ -0,0 +1,188 @@ +package io.novafoundation.nova.feature_assets.domain.breakdown + +import io.novafoundation.nova.common.utils.formatting.ABBREVIATED_SCALE +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.percentage +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.common.utils.unite +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdown.PercentageAmount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceBreakdownIds +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.unlabeledReserves +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.balanceId +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import java.math.BigDecimal +import java.math.BigInteger + +class BalanceBreakdown( + val total: BigDecimal, + val transferableTotal: PercentageAmount, + val locksTotal: PercentageAmount, + val breakdown: List +) { + companion object { + fun empty(): BalanceBreakdown { + return BalanceBreakdown( + total = BigDecimal.ZERO, + transferableTotal = PercentageAmount(amount = BigDecimal.ZERO, percentage = BigDecimal.ZERO), + locksTotal = PercentageAmount(amount = BigDecimal.ZERO, percentage = BigDecimal.ZERO), + breakdown = emptyList() + ) + } + } + + class PercentageAmount(val amount: BigDecimal, val percentage: BigDecimal) + + class BreakdownItem(val id: String, val token: Token, val amountInPlanks: BigInteger) { + val tokenAmount by lazy { token.amountFromPlanks(amountInPlanks) } + + val fiatAmount by lazy { token.amountToFiat(tokenAmount) } + } +} + +class BalanceBreakdownInteractor( + private val accountRepository: AccountRepository, + private val balanceLocksRepository: BalanceLocksRepository, + private val balanceHoldsRepository: BalanceHoldsRepository, +) { + + private class TotalAmount( + val totalFiat: BigDecimal, + val transferableFiat: BigDecimal, + val locksFiat: BigDecimal, + ) + + fun balanceBreakdownFlow( + assetsFlow: Flow>, + externalBalancesFlow: Flow> + ): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + unite( + assetsFlow, + balanceLocksRepository.observeLocksForMetaAccount(metaAccount), + balanceHoldsRepository.observeHoldsForMetaAccount(metaAccount.id), + externalBalancesFlow + ) { assets, locks, holds, externalBalances -> + if (assets == null) { + BalanceBreakdown.empty() + } else { + val assetsByChainId = assets.associateBy { it.token.configuration.fullId } + val locksItems = mapLocks(assetsByChainId, locks.orEmpty()) + val holdsItems = mapHolds(assetsByChainId, holds.orEmpty()) + val externalBalancesItems = mapExternalBalances(assetsByChainId, externalBalances.orEmpty()) + + val holdsByAsset = holds.orEmpty() + .groupBy { it.chainAsset.fullId } + .mapValues { (_, holds) -> holds.sumByBigInteger { it.amountInPlanks } } + val reserved = getReservedBreakdown(assets, holdsByAsset) + + val breakdown = locksItems + holdsItems + externalBalancesItems + reserved + + val totalAmount = calculateTotalBalance(assets, externalBalancesItems) + val (transferablePercentage, locksPercentage) = percentage( + scale = ABBREVIATED_SCALE, + totalAmount.transferableFiat, + totalAmount.locksFiat + ) + BalanceBreakdown( + total = totalAmount.totalFiat, + transferableTotal = PercentageAmount(totalAmount.transferableFiat, transferablePercentage), + locksTotal = PercentageAmount(totalAmount.locksFiat, locksPercentage), + breakdown = breakdown.sortedByDescending { it.fiatAmount } + ) + } + } + } + } + + private fun mapLocks( + assetsByChainId: Map, + locks: List + ): List { + return locks.mapNotNull { lock -> + assetsByChainId[lock.chainAsset.fullId]?.let { asset -> + BalanceBreakdown.BreakdownItem( + id = lock.id.value, + token = asset.token, + amountInPlanks = lock.amountInPlanks, + ) + } + } + } + + private fun mapHolds( + assetsByChainId: Map, + holds: List + ): List { + return holds.mapNotNull { hold -> + assetsByChainId[hold.chainAsset.fullId]?.let { asset -> + BalanceBreakdown.BreakdownItem( + id = hold.identifier, + token = asset.token, + amountInPlanks = hold.amountInPlanks, + ) + } + } + } + + private fun mapExternalBalances( + assetsByChainId: Map, + externalBalances: List + ): List { + return externalBalances.mapNotNull { externalBalance -> + assetsByChainId[externalBalance.chainAssetId]?.let { asset -> + BalanceBreakdown.BreakdownItem( + id = externalBalance.type.balanceId, + token = asset.token, + amountInPlanks = externalBalance.amount, + ) + } + } + } + + private fun calculateTotalBalance( + assets: List, + externalBalancesItems: List + ): TotalAmount { + val externalBalancesTotal = externalBalancesItems.sumOf { it.fiatAmount } + var total = externalBalancesTotal + var transferable = BigDecimal.ZERO + var locks = externalBalancesTotal + + assets.forEach { asset -> + total += asset.token.amountToFiat(asset.total) + transferable += asset.token.amountToFiat(asset.transferable) + locks += asset.token.amountToFiat(asset.locked) + } + + return TotalAmount(total, transferable, locks) + } + + private fun getReservedBreakdown(assets: List, holds: Map): List { + return assets + .filter { it.reservedInPlanks > BigInteger.ZERO } + .mapNotNull { + val labeledReserves = holds[it.token.configuration.fullId].orZero() + val unlabeledReserves = it.unlabeledReserves(labeledReserves) + if (unlabeledReserves <= BigInteger.ZERO) return@mapNotNull null + + BalanceBreakdown.BreakdownItem( + id = BalanceBreakdownIds.RESERVED, + token = it.token, + amountInPlanks = unlabeledReserves + ) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/AssetBalance.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/AssetBalance.kt new file mode 100644 index 0000000..b926c38 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/AssetBalance.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_assets.domain.common + +import java.math.BigDecimal + +class AssetBalance( + val total: Amount, + val transferable: Amount +) { + + class Amount( + val amount: BigDecimal, + val fiat: BigDecimal + ) { + + operator fun plus(other: Amount): Amount { + return Amount( + amount + other.amount, + fiat + other.fiat + ) + } + } + + companion object { + val ZERO = AssetBalance(Amount(BigDecimal.ZERO, BigDecimal.ZERO), Amount(BigDecimal.ZERO, BigDecimal.ZERO)) + } + + operator fun plus(other: AssetBalance): AssetBalance { + return AssetBalance( + total + other.total, + transferable + other.transferable + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/NetworkAssetSorting.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/NetworkAssetSorting.kt new file mode 100644 index 0000000..b39b956 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/NetworkAssetSorting.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_assets.domain.common + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.sumByBigDecimal +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.ext.defaultComparatorFrom +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import java.math.BigDecimal + +class NetworkAssetGroup( + val chain: Chain, + val groupTotalBalanceFiat: BigDecimal, + val groupTransferableBalanceFiat: BigDecimal, + val zeroBalance: Boolean +) + +class AssetWithOffChainBalance( + val asset: Asset, + val balanceWithOffchain: AssetBalance, +) + +fun groupAndSortAssetsByNetwork( + assets: List, + externalBalances: Map, + chainsById: Map, + assetGroupComparator: Comparator = getAssetGroupBaseComparator(), + assetComparator: Comparator = getAssetBaseComparator() +): Map> { + return assets + .map { asset -> AssetWithOffChainBalance(asset, asset.totalWithOffChain(externalBalances)) } + .filter { chainsById.containsKey(it.asset.token.configuration.chainId) } + .groupBy { chainsById.getValue(it.asset.token.configuration.chainId) } + .mapValues { (_, assets) -> assets.sortedWith(assetComparator) } + .mapKeys { (chain, assets) -> + NetworkAssetGroup( + chain = chain, + groupTotalBalanceFiat = assets.sumByBigDecimal { it.balanceWithOffchain.total.fiat }, + groupTransferableBalanceFiat = assets.sumByBigDecimal { it.balanceWithOffchain.transferable.fiat }, + zeroBalance = assets.any { it.balanceWithOffchain.total.amount > BigDecimal.ZERO } + ) + }.toSortedMap(assetGroupComparator) +} + +fun getAssetBaseComparator( + comparing: (AssetWithOffChainBalance) -> Comparable<*> = { it.balanceWithOffchain.total.fiat } +): Comparator { + return compareByDescending(comparing) + .thenByDescending { it.balanceWithOffchain.total.amount } + .thenByDescending { it.asset.token.configuration.isUtilityAsset } // utility assets first + .thenBy { it.asset.token.configuration.symbol.value } +} + +fun getAssetGroupBaseComparator( + comparing: (NetworkAssetGroup) -> Comparable<*> = NetworkAssetGroup::groupTotalBalanceFiat +): Comparator { + return compareByDescending(comparing) + .thenByDescending { it.zeroBalance } // non-zero balances first + .then(Chain.defaultComparatorFrom(NetworkAssetGroup::chain)) +} + +fun Asset.totalWithOffChain(externalBalances: Map): AssetBalance { + val onChainTotal = total + val offChainTotal = externalBalances[token.configuration.fullId] + ?.let(token::amountFromPlanks) + .orZero() + + val overallTotal = onChainTotal + offChainTotal + val overallFiat = token.amountToFiat(overallTotal) + + return AssetBalance( + AssetBalance.Amount(overallTotal, overallFiat), + AssetBalance.Amount(transferable, token.amountToFiat(transferable)) + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/TokenAssetSorting.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/TokenAssetSorting.kt new file mode 100644 index 0000000..ecc8cf3 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/TokenAssetSorting.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_assets.domain.common + +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.ext.defaultComparatorFrom +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.ext.normalize +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import java.math.BigDecimal + +class TokenAssetGroup( + val tokenInfo: TokenInfo, + val groupBalance: AssetBalance, + val itemsCount: Int +) { + + val groupId: String = tokenInfo.symbol.value + + data class TokenInfo( + val icon: String?, + val token: Token + ) { + val symbol = token.configuration.symbol.normalize() + val currency = token.currency + val coinRate = token.coinRate + } +} + +class AssetWithNetwork( + val chain: Chain, + val asset: Asset, + val balanceWithOffChain: AssetBalance, +) + +fun groupAndSortAssetsByToken( + assets: List, + externalBalances: Map, + chainsById: Map, + assetGroupComparator: Comparator = getTokenAssetGroupBaseComparator(), + assetComparator: Comparator = getTokenAssetBaseComparator() +): Map> { + return assets + .filter { chainsById.containsKey(it.token.configuration.chainId) } + .map { asset -> AssetWithNetwork(chainsById.getValue(asset.token.configuration.chainId), asset, asset.totalWithOffChain(externalBalances)) } + .groupBy { mapToTokenGroup(it) } + .mapValues { (_, assets) -> assets.sortedWith(assetComparator) } + .mapKeys { (tokenWrapper, assets) -> + TokenAssetGroup( + tokenInfo = tokenWrapper.tokenInfo, + groupBalance = assets.fold(AssetBalance.ZERO) { acc, element -> acc + element.balanceWithOffChain }, + itemsCount = assets.size + ) + }.toSortedMap(assetGroupComparator) +} + +fun getTokenAssetBaseComparator( + comparing: (AssetWithNetwork) -> Comparable<*> = { it.balanceWithOffChain.total.amount } +): Comparator { + return compareByDescending(comparing) + .thenByDescending { it.asset.token.configuration.isUtilityAsset } // utility assets first + .thenBy { it.asset.token.configuration.symbol.value } + .then(Chain.defaultComparatorFrom(AssetWithNetwork::chain)) +} + +fun getTokenAssetGroupBaseComparator( + comparing: (TokenAssetGroup) -> Comparable<*> = { it.groupBalance.total.fiat } +): Comparator { + return compareByDescending(comparing) + .thenByDescending { it.groupBalance.total.amount > BigDecimal.ZERO } // non-zero balances first + .then(TokenSymbol.defaultComparatorFrom { it.tokenInfo.symbol }) +} + +private fun mapToTokenGroup(it: AssetWithNetwork) = TokenGroupWrapper( + TokenAssetGroup.TokenInfo( + it.asset.token.configuration.icon, + it.asset.token + ) +) + +// Helper class to group items by symbol only +private class TokenGroupWrapper(val tokenInfo: TokenAssetGroup.TokenInfo) { + + override fun equals(other: Any?): Boolean { + return other is TokenGroupWrapper && tokenInfo.symbol == other.tokenInfo.symbol + } + + override fun hashCode(): Int { + return tokenInfo.symbol.hashCode() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/TokenSearch.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/TokenSearch.kt new file mode 100644 index 0000000..ab4d8fc --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/common/TokenSearch.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_assets.domain.common + +import io.novafoundation.nova.common.utils.mapNotNullToSet +import io.novafoundation.nova.runtime.multiNetwork.ChainsById +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +private class SearchResult( + val item: T, + val match: Match +) + +private enum class Match { + NONE, INCLUSION, PREFIX, FULL; +} + +private val Match.matchFound + get() = this != Match.NONE +private val Match.isFullMatch + get() = this == Match.FULL + +// O(N * logN) +fun List.searchTokens( + query: String, + chainsById: ChainsById, + tokenSymbol: (T) -> String, + relevantToChains: (T, Set) -> Boolean, +): List { + if (query.isEmpty()) return this + + val searchResultsFromTokens = map { + SearchResult( + item = it, + match = tokenSymbol(it) match query + ) + } + val anyMatchFromTokens = searchResultsFromTokens.mapNotNull { searchResult -> + searchResult.item.takeIf { searchResult.match.matchFound } + } + + val allFullMatchesFromTokens = searchResultsFromTokens.filter { it.match.isFullMatch } + if (allFullMatchesFromTokens.isNotEmpty()) { + return anyMatchFromTokens + } + + val foundChainIds = chainsById.values.mapNotNullToSet { chain -> + chain.id.takeIf { chain.name inclusionMatch query } + } + + val fromChainSearch = filter { relevantToChains(it, foundChainIds) } + + return (anyMatchFromTokens + fromChainSearch).distinct() +} + +private infix fun String.match(query: String): Match = when { + fullMatch(query) -> Match.FULL + prefixMatch(prefix = query) -> Match.PREFIX + inclusionMatch(inclusion = query) -> Match.INCLUSION + else -> Match.NONE +} + +private infix fun String.fullMatch(other: String) = lowercase() == other.lowercase() + +private infix fun String.prefixMatch(prefix: String) = lowercase().startsWith(prefix.lowercase()) + +private infix fun String.inclusionMatch(inclusion: String) = inclusion.lowercase() in lowercase() diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/locks/BalanceLocksInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/locks/BalanceLocksInteractor.kt new file mode 100644 index 0000000..29784f4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/locks/BalanceLocksInteractor.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_assets.domain.locks + +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface BalanceLocksInteractor { + + fun balanceLocksFlow(chainId: ChainId, chainAssetId: Int): Flow> + + fun balanceHoldsFlow(chainId: ChainId, chainAssetId: Int): Flow> +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/locks/BalanceLocksInteractorImpl.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/locks/BalanceLocksInteractorImpl.kt new file mode 100644 index 0000000..9bf2cb8 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/locks/BalanceLocksInteractorImpl.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_assets.domain.locks + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.flow.Flow + +class BalanceLocksInteractorImpl( + private val chainRegistry: ChainRegistry, + private val balanceLocksRepository: BalanceLocksRepository, + private val balanceHoldsRepository: BalanceHoldsRepository, + private val accountRepository: AccountRepository, +) : BalanceLocksInteractor { + + override fun balanceLocksFlow(chainId: ChainId, chainAssetId: Int): Flow> { + return flowOfAll { + val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId) + val selectedAccount = accountRepository.getSelectedMetaAccount() + balanceLocksRepository.observeBalanceLocks(selectedAccount.id, chain, chainAsset) + } + } + + override fun balanceHoldsFlow(chainId: ChainId, chainAssetId: Int): Flow> { + return flowOfAll { + val chainAsset = chainRegistry.asset(chainId, chainAssetId) + val selectedAccount = accountRepository.getSelectedMetaAccount() + balanceHoldsRepository.observeBalanceHolds(selectedAccount.id, chainAsset) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/networks/AssetNetworksInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/networks/AssetNetworksInteractor.kt new file mode 100644 index 0000000..2d04233 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/networks/AssetNetworksInteractor.kt @@ -0,0 +1,128 @@ +package io.novafoundation.nova.feature_assets.domain.networks + +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.common.utils.filterList +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchFilter +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchUseCase +import io.novafoundation.nova.feature_assets.domain.assets.search.mapToAssetSearchFilter +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.getTokenAssetBaseComparator +import io.novafoundation.nova.feature_assets.domain.common.getTokenAssetGroupBaseComparator +import io.novafoundation.nova.feature_assets.domain.common.groupAndSortAssetsByToken +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.feature_wallet_api.domain.model.aggregatedBalanceByAsset +import io.novafoundation.nova.runtime.ext.normalize +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.enabledChainById +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class AssetNetworksInteractor( + private val chainRegistry: ChainRegistry, + private val assetSearchUseCase: AssetSearchUseCase, + private val tradeTokenRegistry: TradeTokenRegistry, + private val giftAssetsUseCase: AvailableGiftAssetsUseCase +) { + + fun tradeAssetFlow( + tokenSymbol: TokenSymbol, + externalBalancesFlow: Flow>, + tradeType: TradeTokenRegistry.TradeType + ): Flow> { + val filter = { asset: Asset -> tradeTokenRegistry.hasProvider(asset.token.configuration, tradeType) } + + return searchAssetsByTokenSymbolInternalFlow(tokenSymbol, externalBalancesFlow, filter = filter) + } + + fun sendAssetFlow( + tokenSymbol: TokenSymbol, + externalBalancesFlow: Flow>, + ): Flow> { + val filter = { asset: Asset -> asset.transferableInPlanks.isPositive() } + + return searchAssetsByTokenSymbolInternalFlow( + tokenSymbol, + externalBalancesFlow, + assetGroupComparator = getTokenAssetGroupBaseComparator { it.groupBalance.transferable.fiat }, + assetsComparator = getTokenAssetBaseComparator { it.balanceWithOffChain.transferable.fiat }, + filter = filter + ) + } + + fun swapAssetsFlow( + forAssetId: FullChainAssetId?, + tokenSymbol: TokenSymbol, + externalBalancesFlow: Flow>, + coroutineScope: CoroutineScope + ): Flow> { + val filterFlow = assetSearchUseCase.getAvailableSwapAssets(forAssetId, coroutineScope).mapToAssetSearchFilter() + return searchAssetsByTokenSymbolInternalFlow(tokenSymbol, externalBalancesFlow, filterFlow = filterFlow) + } + + fun receiveAssetFlow( + tokenSymbol: TokenSymbol, + externalBalancesFlow: Flow>, + ): Flow> { + return searchAssetsByTokenSymbolInternalFlow(tokenSymbol, externalBalancesFlow, filter = null) + } + + fun giftsAssetFlow( + tokenSymbol: TokenSymbol, + externalBalancesFlow: Flow>, + coroutineScope: CoroutineScope + ): Flow> { + val filterFlow = giftAssetsUseCase.getAvailableGiftAssets(coroutineScope).mapToAssetSearchFilter() + return searchAssetsByTokenSymbolInternalFlow( + tokenSymbol, + externalBalancesFlow, + assetGroupComparator = getTokenAssetGroupBaseComparator { it.groupBalance.transferable.fiat }, + assetsComparator = getTokenAssetBaseComparator { it.balanceWithOffChain.transferable.fiat }, + filterFlow = filterFlow + ) + } + + fun searchAssetsByTokenSymbolInternalFlow( + tokenSymbol: TokenSymbol, + externalBalancesFlow: Flow>, + assetGroupComparator: Comparator = getTokenAssetGroupBaseComparator(), + assetsComparator: Comparator = getTokenAssetBaseComparator(), + filterFlow: Flow, + ): Flow> { + val assetsFlow = assetSearchUseCase.filteredAssetFlow(filterFlow) + .filterList { it.token.configuration.symbol.normalize() == tokenSymbol } + + val aggregatedExternalBalances = externalBalancesFlow.map { it.aggregatedBalanceByAsset() } + + return combine(assetsFlow, aggregatedExternalBalances) { assets, externalBalances -> + val chainsById = chainRegistry.enabledChainById() + + groupAndSortAssetsByToken(assets, externalBalances, chainsById, assetGroupComparator, assetsComparator) + .flatMap { it.value } + } + } + + private fun getSwapAssetsFilter(sourceAsset: FullChainAssetId?, coroutineScope: CoroutineScope): Flow { + return assetSearchUseCase.getAvailableSwapAssets(sourceAsset, coroutineScope).mapToAssetSearchFilter() + } +} + +private fun AssetNetworksInteractor.searchAssetsByTokenSymbolInternalFlow( + tokenSymbol: TokenSymbol, + externalBalancesFlow: Flow>, + assetGroupComparator: Comparator = getTokenAssetGroupBaseComparator(), + assetsComparator: Comparator = getTokenAssetBaseComparator(), + filter: AssetSearchFilter?, +): Flow> { + val filterFlow = flowOf(filter) + + return searchAssetsByTokenSymbolInternalFlow(tokenSymbol, externalBalancesFlow, assetGroupComparator, assetsComparator, filterFlow) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/novaCard/NovaCardInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/novaCard/NovaCardInteractor.kt new file mode 100644 index 0000000..e4d1af5 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/novaCard/NovaCardInteractor.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_assets.domain.novaCard + +import io.novafoundation.nova.feature_assets.data.repository.NovaCardStateRepository +import kotlinx.coroutines.flow.Flow +import kotlin.time.Duration.Companion.minutes + +interface NovaCardInteractor { + + fun isNovaCardCreated(): Boolean + + fun getNovaCardState(): NovaCardState + + fun setNovaCardState(state: NovaCardState) + + suspend fun setTopUpFinishedEvent() + + fun observeTopUpFinishedEvent(): Flow + + fun observeNovaCardState(): Flow + + fun setLastTopUpTime(time: Long) + + fun getEstimatedTopUpDuration(): Long +} + +const val TIMER_MINUTES = 5 + +class RealNovaCardInteractor( + private val novaCardStateRepository: NovaCardStateRepository +) : NovaCardInteractor { + + override fun isNovaCardCreated(): Boolean { + return novaCardStateRepository.getNovaCardCreationState() == NovaCardState.CREATED + } + + override fun getNovaCardState(): NovaCardState { + return novaCardStateRepository.getNovaCardCreationState() + } + + override fun setNovaCardState(state: NovaCardState) { + return novaCardStateRepository.setNovaCardCreationState(state) + } + + override suspend fun setTopUpFinishedEvent() { + novaCardStateRepository.setTopUpFinishedEvent() + } + + override fun observeTopUpFinishedEvent(): Flow { + return novaCardStateRepository.observeTopUpFinishedEvent() + } + + override fun observeNovaCardState(): Flow { + return novaCardStateRepository.observeNovaCardCreationState() + } + + override fun setLastTopUpTime(time: Long) { + novaCardStateRepository.setLastTopUpTime(time) + } + + override fun getEstimatedTopUpDuration(): Long { + val lastTopUpTime = novaCardStateRepository.getLastTopUpTime() + val onTopUpFinishTime = lastTopUpTime + TIMER_MINUTES.minutes.inWholeMilliseconds + val currentTime = System.currentTimeMillis() + val estimatedDurationToFinishTopUp = onTopUpFinishTime - currentTime + + return estimatedDurationToFinishTopUp.coerceAtLeast(0) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/novaCard/NovaCardState.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/novaCard/NovaCardState.kt new file mode 100644 index 0000000..d06113e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/novaCard/NovaCardState.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_assets.domain.novaCard + +enum class NovaCardState { + NONE, + CREATION, + CREATED +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/price/AssetPriceChart.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/price/AssetPriceChart.kt new file mode 100644 index 0000000..bedee6b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/price/AssetPriceChart.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_assets.domain.price + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate + +class AssetPriceChart(val range: PricePeriod, val chart: ExtendedLoadingState>) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/price/ChartsInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/price/ChartsInteractor.kt new file mode 100644 index 0000000..1551a61 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/price/ChartsInteractor.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_assets.domain.price + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.utils.combine +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod +import io.novafoundation.nova.feature_wallet_api.data.repository.duration +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +interface ChartsInteractor { + + fun chartsFlow(priceId: String): Flow> +} + +class RealChartsInteractor( + private val coinPriceRepository: CoinPriceRepository, + private val currencyRepository: CurrencyRepository +) : ChartsInteractor { + + override fun chartsFlow(priceId: String): Flow> { + val dayChart = getChartFor(priceId, PricePeriod.DAY) + val monthChart = getChartFor(priceId, PricePeriod.MONTH) + val maxChart = getChartFor(priceId, PricePeriod.MAX) + + val weekChart = monthChart.map { it.subChartForPeriod(PricePeriod.WEEK) } + val yearChart = maxChart.map { it.subChartForPeriod(PricePeriod.YEAR) } + + return listOf(dayChart, weekChart, monthChart, yearChart, maxChart).combine() + } + + private fun getChartFor(priceId: String, range: PricePeriod): Flow { + return flow { + emit(AssetPriceChart(range, ExtendedLoadingState.Loading)) + val currency = currencyRepository.getSelectedCurrency() + + runCatching { coinPriceRepository.getLastHistoryForPeriod(priceId, currency, range) } + .onSuccess { emit(AssetPriceChart(range, ExtendedLoadingState.Loaded(it))) } + .onFailure { emit(AssetPriceChart(range, ExtendedLoadingState.Error(it))) } + } + } + + private fun AssetPriceChart.subChartForPeriod(period: PricePeriod): AssetPriceChart { + val subChartDuration = period.duration() + val chart = chart.map { historicalPoints -> + val lastPoint = historicalPoints.lastOrNull() ?: return@map emptyList() + val fromDate = lastPoint.timestamp - subChartDuration.inWholeSeconds + historicalPoints.filter { it.timestamp > fromDate } + } + return AssetPriceChart(period, chart) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/receive/ReceiveInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/receive/ReceiveInteractor.kt new file mode 100644 index 0000000..43aa06f --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/receive/ReceiveInteractor.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_assets.domain.receive + +import android.graphics.Bitmap +import android.net.Uri +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.utils.write +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val QR_FILE_NAME = "share-qr-address.png" + +class ReceiveInteractor( + private val fileProvider: FileProvider, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val assetsIconModeRepository: AssetsIconModeRepository +) { + + suspend fun getQrCodeSharingString(chainId: ChainId): String = withContext(Dispatchers.Default) { + val chain = chainRegistry.getChain(chainId) + val account = accountRepository.getSelectedMetaAccount() + + accountRepository.createQrAccountContent(chain, account) + } + + fun getAssetIconMode(): AssetIconMode = assetsIconModeRepository.getIconMode() + + suspend fun generateTempQrFile(qrCode: Bitmap): Result = withContext(Dispatchers.IO) { + runCatching { + val file = fileProvider.generateTempFile(fixedName = QR_FILE_NAME) + file.write(qrCode) + + fileProvider.uriOf(file) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/send/SendInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/send/SendInteractor.kt new file mode 100644 index 0000000..dc668df --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/send/SendInteractor.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_assets.domain.send + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_assets.domain.send.model.TransferFee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.isCrossChain +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider +import io.novafoundation.nova.feature_wallet_api.data.repository.getXcmChain +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransfersConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.transferConfiguration +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SendInteractor( + private val assetSourceRegistry: AssetSourceRegistry, + private val crossChainTransactor: CrossChainTransactor, + private val crossChainTransfersRepository: CrossChainTransfersRepository, + private val parachainInfoRepository: ParachainInfoRepository, + private val crossChainTransfersUseCase: CrossChainTransfersUseCase, + private val extrinsicService: ExtrinsicService, + private val sendUseCase: SendUseCase, + private val crossChainValidationProvider: CrossChainValidationSystemProvider +) { + + suspend fun getFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): TransferFee = withContext(Dispatchers.Default) { + if (transfer.isCrossChain) { + val fees = with(crossChainTransfersUseCase) { + extrinsicService.estimateFee(transfer, cachingScope = null) + } + + val originFee = OriginFee( + submissionFee = fees.submissionFee, + deliveryFee = fees.postSubmissionByAccount, + ) + + TransferFee(originFee, fees.postSubmissionFromAmount) + } else { + TransferFee( + originFee = getOriginFee(transfer, coroutineScope), + crossChainFee = null + ) + } + } + + suspend fun getOriginFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): OriginFee = withContext(Dispatchers.Default) { + OriginFee(getSubmissionFee(transfer, coroutineScope), null) + } + + suspend fun getSubmissionFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): SubmissionFee = withContext(Dispatchers.Default) { + getAssetTransfers(transfer).calculateFee(transfer, coroutineScope = coroutineScope) + } + + suspend fun performTransfer( + transfer: WeightedAssetTransfer, + originFee: OriginFee, + crossChainFee: FeeBase?, + coroutineScope: CoroutineScope + ): Result = withContext(Dispatchers.Default) { + if (transfer.isCrossChain) { + val config = crossChainTransfersRepository.getConfiguration().configurationFor(transfer)!! + + with(extrinsicService) { + crossChainTransactor.performTransfer(config, transfer, crossChainFee!!.amount) + } + } else { + sendUseCase.performOnChainTransfer(transfer, originFee.submissionFee, coroutineScope) + } + } + + fun validationSystemFor(transfer: AssetTransfer, coroutineScope: CoroutineScope) = if (transfer.isCrossChain) { + crossChainValidationProvider.createValidationSystem() + } else { + assetSourceRegistry.sourceFor(transfer.originChainAsset).transfers.getValidationSystem(coroutineScope) + } + + suspend fun areTransfersEnabled(asset: Chain.Asset) = assetSourceRegistry.sourceFor(asset).transfers.areTransfersEnabled(asset) + + private fun getAssetTransfers(transfer: AssetTransfer) = assetSourceRegistry.sourceFor(transfer.originChainAsset).transfers + + private suspend fun CrossChainTransfersConfiguration.configurationFor(transfer: AssetTransfer) = transferConfiguration( + originChain = parachainInfoRepository.getXcmChain(transfer.originChain), + originAsset = transfer.originChainAsset, + destinationChain = parachainInfoRepository.getXcmChain(transfer.destinationChain), + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/send/model/TransferFee.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/send/model/TransferFee.kt new file mode 100644 index 0000000..de90736 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/send/model/TransferFee.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_assets.domain.send.model + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.data.model.getAmount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +data class TransferFee( + val originFee: OriginFee, + val crossChainFee: FeeBase? +) : MaxAvailableDeduction { + + fun totalFeeByExecutingAccount(chainAsset: Chain.Asset): Balance { + val accountThatPaysFees = originFee.submissionFee.submissionOrigin.executingAccount + + val submission = originFee.submissionFee.getAmount(chainAsset, accountThatPaysFees) + val delivery = originFee.deliveryFee?.getAmount(chainAsset, accountThatPaysFees).orZero() + + return submission + delivery + } + + fun replaceSubmission(newSubmissionFee: SubmissionFee): TransferFee { + return copy(originFee = originFee.copy(newSubmissionFee)) + } + + override fun maxAmountDeductionFor(amountAsset: Chain.Asset): Balance { + // Delegate submission calculation to submission fee itself + val submission = originFee.submissionFee.maxAmountDeductionFor(amountAsset) + // Delivery is always paid from executing account + val delivery = originFee.deliveryFee?.getAmount(amountAsset).orZero() + // Execution is paid from the sending amount itself, so we subtract it as well since we later add it on top of sending amount + val execution = crossChainFee?.getAmount(amountAsset).orZero() + + return submission + delivery + execution + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/AssetsDataCleaner.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/AssetsDataCleaner.kt new file mode 100644 index 0000000..10e1ea2 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/AssetsDataCleaner.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_assets.domain.tokens + +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +interface AssetsDataCleaner { + + suspend fun clearAssetsData(assetIds: List) +} + +class RealAssetsDataCleaner( + private val externalBalanceRepository: ExternalBalanceRepository, + private val contributionsRepository: ContributionsRepository, + private val walletRepository: WalletRepository, +) : AssetsDataCleaner { + + override suspend fun clearAssetsData(assetIds: List) { + contributionsRepository.deleteContributions(assetIds) + externalBalanceRepository.deleteExternalBalances(assetIds) + walletRepository.clearAssets(assetIds) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/AddTokensInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/AddTokensInteractor.kt new file mode 100644 index 0000000..351afe5 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/AddTokensInteractor.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_assets.domain.tokens.add + +import io.novafoundation.nova.common.address.format.EthereumAddressFormat +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.utils.asPrecision +import io.novafoundation.nova.common.utils.asTokenSymbol +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.AddEvmTokenValidationSystem +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.evmAssetNotExists +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.validCoinGeckoLink +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.validErc20Contract +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.validTokenDecimals +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.ethereum.contract.base.querySingle +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Queries +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard +import io.novafoundation.nova.runtime.ext.defaultComparator +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.chainAssetIdOfErc20Token +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.enabledChainsFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface AddTokensInteractor { + + fun availableChainsToAddTokenFlow(): Flow> + + suspend fun retrieveContractMetadata( + chainId: ChainId, + contractAddress: String + ): Erc20ContractMetadata? + + suspend fun addCustomTokenAndSync(customErc20Token: CustomErc20Token): Result<*> + + fun getValidationSystem(): AddEvmTokenValidationSystem +} + +class RealAddTokensInteractor( + private val chainRegistry: ChainRegistry, + private val erc20Standard: Erc20Standard, + private val chainAssetRepository: ChainAssetRepository, + private val coinGeckoLinkParser: CoinGeckoLinkParser, + private val ethereumAddressFormat: EthereumAddressFormat, + private val currencyRepository: CurrencyRepository, + private val walletRepository: WalletRepository, + private val coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory +) : AddTokensInteractor { + + override fun availableChainsToAddTokenFlow(): Flow> { + return chainRegistry.enabledChainsFlow().map { chains -> + chains.filter { it.isEthereumBased } + .sortedWith(Chain.defaultComparator()) + } + } + + override suspend fun retrieveContractMetadata( + chainId: ChainId, + contractAddress: String, + ): Erc20ContractMetadata? { + return runCatching { + queryErc20Contract(chainId, contractAddress) { + Erc20ContractMetadata( + decimals = executeOrNull { decimals().toInt() }, + symbol = executeOrNull { symbol() } + ) + } + }.getOrNull() + } + + override suspend fun addCustomTokenAndSync(customErc20Token: CustomErc20Token): Result<*> = runCatching { + val priceId = coinGeckoLinkParser.parse(customErc20Token.priceLink).getOrNull()?.priceId + + val asset = Chain.Asset( + icon = null, + id = chainAssetIdOfErc20Token(customErc20Token.contract), + priceId = priceId, + chainId = customErc20Token.chainId, + symbol = customErc20Token.symbol.asTokenSymbol(), + precision = customErc20Token.decimals.asPrecision(), + buyProviders = emptyMap(), + sellProviders = emptyMap(), + staking = emptyList(), + type = Chain.Asset.Type.EvmErc20(customErc20Token.contract), + source = Chain.Asset.Source.MANUAL, + name = customErc20Token.symbol, + enabled = true + ) + + chainAssetRepository.insertCustomAsset(asset) + + syncTokenPrice(asset) + } + + override fun getValidationSystem(): AddEvmTokenValidationSystem { + return ValidationSystem { + evmAssetNotExists(chainRegistry) + validErc20Contract(ethereumAddressFormat, erc20Standard, chainRegistry) + validTokenDecimals() + validCoinGeckoLink(coinGeckoLinkValidationFactory) + } + } + + private suspend fun queryErc20Contract( + chainId: ChainId, + contractAddress: String, + query: suspend Erc20Queries.() -> R + ): R { + val ethereumApi = chainRegistry.getCallEthereumApiOrThrow(chainId) + val erc20Queries = erc20Standard.querySingle(contractAddress, ethereumApi) + + return query(erc20Queries) + } + + private suspend fun executeOrNull(action: suspend () -> R): R? = runCatching { action() }.getOrNull() + + private suspend fun syncTokenPrice(asset: Chain.Asset) { + val currency = currencyRepository.getSelectedCurrency() + walletRepository.syncAssetRates(asset, currency) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/CustomErc20Token.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/CustomErc20Token.kt new file mode 100644 index 0000000..7c1e238 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/CustomErc20Token.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_assets.domain.tokens.add + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class CustomErc20Token( + val contract: String, + val decimals: Int, + val symbol: String, + val priceLink: String, + val chainId: ChainId, +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/Erc20ContractMetadata.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/Erc20ContractMetadata.kt new file mode 100644 index 0000000..7e5707d --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/Erc20ContractMetadata.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_assets.domain.tokens.add + +class Erc20ContractMetadata( + val decimals: Int?, + val symbol: String?, +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/validations/AddEvmTokensValidatoins.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/validations/AddEvmTokensValidatoins.kt new file mode 100644 index 0000000..b571e94 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/validations/AddEvmTokensValidatoins.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_assets.domain.tokens.add.validations + +import io.novafoundation.nova.common.address.format.EthereumAddressFormat +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_assets.domain.tokens.add.CustomErc20Token +import io.novafoundation.nova.feature_wallet_api.domain.validation.evmAssetNotExists +import io.novafoundation.nova.feature_wallet_api.domain.validation.validErc20Contract +import io.novafoundation.nova.feature_wallet_api.domain.validation.validTokenDecimals +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +typealias AddEvmTokenValidationSystem = ValidationSystem +typealias AddEvmTokenValidationSystemBuilder = ValidationSystemBuilder + +sealed interface AddEvmTokensValidationFailure { + class InvalidTokenContractAddress(val chainName: String) : AddEvmTokensValidationFailure + + class AssetExist(val alreadyExistingSymbol: String, val canModify: Boolean) : AddEvmTokensValidationFailure + + object InvalidDecimals : AddEvmTokensValidationFailure + + object InvalidCoinGeckoLink : AddEvmTokensValidationFailure +} + +fun AddEvmTokenValidationSystemBuilder.validErc20Contract( + ethereumAddressFormat: EthereumAddressFormat, + erc20Standard: Erc20Standard, + chainRegistry: ChainRegistry, +) = validErc20Contract( + ethereumAddressFormat = ethereumAddressFormat, + erc20Standard = erc20Standard, + chainRegistry = chainRegistry, + chain = { it.chain }, + address = { it.customErc20Token.contract }, + error = { AddEvmTokensValidationFailure.InvalidTokenContractAddress(it.chain.name) } +) + +fun AddEvmTokenValidationSystemBuilder.evmAssetNotExists(chainRegistry: ChainRegistry) = evmAssetNotExists( + chainRegistry = chainRegistry, + chain = { it.chain }, + address = { it.customErc20Token.contract }, + assetNotExistError = AddEvmTokensValidationFailure::AssetExist, + addressMappingError = { AddEvmTokensValidationFailure.InvalidTokenContractAddress(it.chain.name) } +) + +fun AddEvmTokenValidationSystemBuilder.validTokenDecimals() = validTokenDecimals( + decimals = { it.customErc20Token.decimals }, + error = { AddEvmTokensValidationFailure.InvalidDecimals } +) + +fun AddEvmTokenValidationSystemBuilder.validCoinGeckoLink( + coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory +) = validCoinGeckoLink( + coinGeckoLinkValidationFactory = coinGeckoLinkValidationFactory, + optional = true, + link = { it.customErc20Token.priceLink }, + error = { AddEvmTokensValidationFailure.InvalidCoinGeckoLink } +) + +class AddEvmTokenPayload( + val customErc20Token: CustomErc20Token, + val chain: Chain +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/validations/CoinGeckoLinkValidation.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/validations/CoinGeckoLinkValidation.kt new file mode 100644 index 0000000..d8de6ff --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/add/validations/CoinGeckoLinkValidation.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_assets.domain.tokens.add.validations + +import android.text.TextUtils +import io.novafoundation.nova.common.utils.asQueryParam +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.CoingeckoApi + +class CoinGeckoLinkValidationFactory( + private val coingeckoApi: CoingeckoApi, + private val coinGeckoLinkParser: CoinGeckoLinkParser, +) { + fun create( + optional: Boolean, + link: (P) -> String?, + error: (P) -> E, + ): CoinGeckoLinkValidation { + return CoinGeckoLinkValidation( + coingeckoApi, + coinGeckoLinkParser, + optional, + link, + error, + ) + } +} + +class CoinGeckoLinkValidation( + private val coinGeckoApi: CoingeckoApi, + private val coinGeckoLinkParser: CoinGeckoLinkParser, + private val optional: Boolean, + private val link: (P) -> String?, + private val error: (P) -> E, +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + if (optional && TextUtils.isEmpty(link(value))) { + return valid() + } + + return try { + val link = link(value)!! + val coinGeckoContent = coinGeckoLinkParser.parse(link).getOrThrow() + val priceId = coinGeckoContent.priceId + val result = coinGeckoApi.getAssetPrice(setOf(priceId).asQueryParam(), "usd", false) + result.isNotEmpty().isTrueOrError { error(value) } + } catch (e: Exception) { + validationError(error(value)) + } + } +} + +fun ValidationSystemBuilder.validCoinGeckoLink( + coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory, + optional: Boolean, + link: (P) -> String?, + error: (P) -> E, +) = validate( + coinGeckoLinkValidationFactory.create( + optional, + link, + error, + ) +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/manage/ManageTokenInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/manage/ManageTokenInteractor.kt new file mode 100644 index 0000000..93f79b8 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/manage/ManageTokenInteractor.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_assets.domain.tokens.manage + +import io.novafoundation.nova.common.utils.isSubsetOf +import io.novafoundation.nova.feature_assets.domain.common.searchTokens +import io.novafoundation.nova.feature_assets.domain.tokens.AssetsDataCleaner +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository +import io.novafoundation.nova.runtime.ext.defaultComparator +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.normalizeSymbol +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import io.novafoundation.nova.runtime.multiNetwork.enabledChainsFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +interface ManageTokenInteractor { + + fun multiChainTokensFlow(queryFlow: Flow): Flow> + + fun multiChainTokenFlow(id: String): Flow + + suspend fun updateEnabledState(enabled: Boolean, assetIds: List) +} + +class RealManageTokenInteractor( + private val chainRegistry: ChainRegistry, + private val chainAssetRepository: ChainAssetRepository, + private val assetsDataCleaner: AssetsDataCleaner, +) : ManageTokenInteractor { + + private val changeTokensMutex = Mutex(false) + + override fun multiChainTokensFlow( + queryFlow: Flow + ): Flow> { + return combine(multiChainTokensFlow(), queryFlow) { tokens, query -> + tokens.searchTokens( + query = query, + chainsById = chainRegistry.chainsById(), + tokenSymbol = MultiChainToken::symbol, + relevantToChains = { multiChainToken, chainIds -> + multiChainToken.instances.any { it.chain.id in chainIds } + } + ) + } + } + + private fun multiChainTokensFlow() = chainRegistry.enabledChainsFlow().map { chains -> + constructMultiChainTokens(chains) + } + + override fun multiChainTokenFlow(id: String): Flow { + return multiChainTokensFlow().map { multiChainTokens -> + multiChainTokens.first { it.id == id } + } + } + + override suspend fun updateEnabledState(enabled: Boolean, assetIds: List) = withContext(Dispatchers.IO) { + changeTokensMutex.withLock { + if (!enabled && canNotDisableAssets(assetIds)) { + return@withLock + } + + chainAssetRepository.setAssetsEnabled(enabled, assetIds) + + if (!enabled) { + assetsDataCleaner.clearAssetsData(assetIds) + } + } + } + + private suspend fun canNotDisableAssets(assetIds: List): Boolean { + val enabledAssets = chainAssetRepository.getEnabledAssets() + .map { it.fullId } + return assetIds.containsAll(enabledAssets) + } + + private fun constructMultiChainTokens(chains: List): List { + val chainComparator = Chain.defaultComparator() + val assetsWithChains = chains.sortedWith(chainComparator).flatMap { chain -> + chain.assets.map { asset -> ChainWithAsset(chain, asset) } + } + + val enabledAssets = assetsWithChains.filter { it.asset.enabled } + .map { it.asset.fullId } + + return assetsWithChains.groupBy { (_, asset) -> asset.normalizeSymbol() } + .map { (symbol, chainsWithAssets) -> + val (_, firstAsset) = chainsWithAssets.first() + val tokenAssets = chainsWithAssets.filter { it.asset.enabled } + .map { it.asset.fullId } + val isLastTokenEnabled = enabledAssets.isSubsetOf(tokenAssets) + val isLastAssetEnabled = isLastTokenEnabled && tokenAssets.size == 1 + + MultiChainToken( + id = symbol, + symbol = symbol, + icon = firstAsset.icon, + isSwitchable = !isLastTokenEnabled, + instances = chainsWithAssets.map { (chain, asset) -> + MultiChainToken.ChainTokenInstance( + chain = chain, + chainAssetId = asset.id, + isEnabled = asset.enabled, + isSwitchable = !asset.enabled || !isLastAssetEnabled + ) + } + ) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/manage/MultiChainToken.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/manage/MultiChainToken.kt new file mode 100644 index 0000000..5785fdd --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/tokens/manage/MultiChainToken.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_assets.domain.tokens.manage + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +class MultiChainToken( + val id: String, + val symbol: String, + val icon: String?, + val isSwitchable: Boolean, + val instances: List +) { + + class ChainTokenInstance( + val chain: Chain, + val chainAssetId: ChainAssetId, + val isEnabled: Boolean, + val isSwitchable: Boolean + ) +} + +fun MultiChainToken.isEnabled(): Boolean { + return instances.any { it.isEnabled } +} + +fun MultiChainToken.allChainAssetIds(): List { + return instances.map { + FullChainAssetId(it.chain.id, it.chainAssetId) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt new file mode 100644 index 0000000..51177c6 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt @@ -0,0 +1,127 @@ +package io.novafoundation.nova.feature_assets.presentation + +import android.os.Bundle +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload +import io.novafoundation.nova.feature_assets.presentation.swap.network.NetworkSwapFlowPayload +import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.AddTokenEnterInfoPayload +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensPayload +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebPayload +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterPayload +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload + +interface AssetsRouter { + + fun openAssetDetails(assetPayload: AssetPayload) + + fun finishTradeOperation() + + fun back() + + fun openFilter(payload: TransactionHistoryFilterPayload) + + fun openSend(payload: SendPayload, initialRecipientAddress: String? = null, initialAmount: Double? = null) + + fun openConfirmTransfer(transferDraft: TransferDraft) + + fun openTransferDetail(transaction: OperationParcelizeModel.Transfer) + + fun openExtrinsicDetail(extrinsic: OperationParcelizeModel.Extrinsic) + + fun openRewardDetail(reward: OperationParcelizeModel.Reward) + + fun openPoolRewardDetail(reward: OperationParcelizeModel.PoolReward) + + fun openSwapDetail(swap: OperationParcelizeModel.Swap) + + fun openSwitchWallet() + + fun openSelectAddress(arguments: Bundle) + + fun openSelectSingleWallet(arguments: Bundle) + + fun openSelectMultipleWallets(arguments: Bundle) + + fun openReceive(assetPayload: AssetPayload) + + fun openAssetSearch() + + fun openManageTokens() + + fun openManageChainTokens(payload: ManageChainTokensPayload) + + fun openAddTokenEnterInfo(payload: AddTokenEnterInfoPayload) + + fun openAddTokenSelectChain() + + fun openSendFlow() + + fun openReceiveFlow() + + fun openBuyFlow() + + fun openSellFlow() + + fun openBridgeFlow() + + fun openBuyFlowFromSendFlow() + + fun openNfts() + + fun finishAddTokenFlow() + + fun openWalletConnectSessions(metaId: Long) + + fun openWalletConnectScan() + + fun openSwapFlow() + + fun openSwapSetupAmount(swapSettingsPayload: SwapSettingsPayload) + + fun returnToMainScreen() + + fun finishSelectAndOpenSwapSetupAmount(swapSettingsPayload: SwapSettingsPayload) + + fun closeSendFlow() + + fun openNovaCard() + + fun openAwaitingCardCreation() + + fun closeNovaCard() + + fun openSendNetworks(payload: NetworkFlowPayload) + + fun openReceiveNetworks(payload: NetworkFlowPayload) + + fun openSwapNetworks(payload: NetworkSwapFlowPayload) + + fun returnToMainSwapScreen() + + fun openBuyNetworks(payload: NetworkFlowPayload) + + fun openSellNetworks(payload: NetworkFlowPayload) + + fun openGiftsNetworks(payload: NetworkFlowPayload) + + fun openBuyProviders(chainId: String, chainAssetId: Int) + + fun openSellProviders(chainId: String, chainAssetId: Int) + + fun openTradeWebInterface(payload: TradeWebPayload) + + fun finishTopUp() + + fun openPendingMultisigOperations() + + fun openAssetDetailsFromDeepLink(payload: AssetPayload) + + fun openGifts() + + fun openGiftsByAsset(assetPayload: AssetPayload) + + fun openSelectGiftAmount(assetPayload: AssetPayload) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/assetActions/AssetActionsView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/assetActions/AssetActionsView.kt new file mode 100644 index 0000000..635727d --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/assetActions/AssetActionsView.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.assetActions + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.TextView +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_assets.databinding.ViewAssetActionsBinding + +class AssetActionsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewAssetActionsBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + + background = context.getBlockDrawable() + + updatePadding(top = 4.dp(context), bottom = 4.dp(context)) + } + + val send: TextView + get() = binder.assetActionsSend + + val receive: TextView + get() = binder.assetActionsReceive + + val swap: TextView + get() = binder.assetActionsSwap + + val buySell: TextView + get() = binder.assetActionsBuy + + val gift: TextView + get() = binder.assetActionsGift +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/BalanceBreakdownAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/BalanceBreakdownAdapter.kt new file mode 100644 index 0000000..053c674 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/BalanceBreakdownAdapter.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.breakdown + +import android.view.ViewGroup +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_assets.databinding.ItemBalanceBreakdownAmountBinding +import io.novafoundation.nova.feature_assets.databinding.ItemBalanceBreakdownTotalBinding +import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownAmount +import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownTotal +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class BalanceBreakdownAdapter : GroupedListAdapter(DiffCallback) { + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return BalanceTotalHolder(ItemBalanceBreakdownTotalBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return BalanceAmountHolder(ItemBalanceBreakdownAmountBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: BalanceBreakdownTotal) { + require(holder is BalanceTotalHolder) + holder.bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: BalanceBreakdownAmount) { + require(holder is BalanceAmountHolder) + holder.bind(child) + } +} + +class BalanceTotalHolder( + private val binder: ItemBalanceBreakdownTotalBinding, +) : GroupedListHolder(binder.root) { + + fun bind(item: BalanceBreakdownTotal) { + binder.itemBreakdownTotalIcon.setImageResource(item.iconRes) + binder.itemBreakdownTotalName.text = item.name + binder.itemBreakdownTotalPercentage.text = item.percentage + binder.itemBreakdownTotal.text = item.fiatAmount + } +} + +class BalanceAmountHolder( + private val binder: ItemBalanceBreakdownAmountBinding, +) : GroupedListHolder(binder.root) { + + fun bind(item: BalanceBreakdownAmount) { + binder.balanceBreakdownItemDetail.setTitle(item.name) + binder.balanceBreakdownItemDetail.showAmount(item.amount) + } +} + +private object DiffCallback : BaseGroupedDiffCallback(BalanceBreakdownTotal::class.java) { + + override fun areGroupItemsTheSame(oldItem: BalanceBreakdownTotal, newItem: BalanceBreakdownTotal): Boolean { + return oldItem.name == newItem.name + } + + override fun areGroupContentsTheSame(oldItem: BalanceBreakdownTotal, newItem: BalanceBreakdownTotal): Boolean { + return oldItem == newItem + } + + override fun areChildItemsTheSame(oldItem: BalanceBreakdownAmount, newItem: BalanceBreakdownAmount): Boolean { + return oldItem == newItem + } + + override fun areChildContentsTheSame(oldItem: BalanceBreakdownAmount, newItem: BalanceBreakdownAmount): Boolean { + return true + } + + override fun getGroupChangePayload(oldItem: BalanceBreakdownTotal, newItem: BalanceBreakdownTotal): Any? { + return true + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/BalanceBreakdownBottomSheet.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/BalanceBreakdownBottomSheet.kt new file mode 100644 index 0000000..8a95a39 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/BalanceBreakdownBottomSheet.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.breakdown + +import android.content.Context +import android.view.LayoutInflater +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet +import io.novafoundation.nova.feature_assets.databinding.FragmentBalanceBreakdownBinding +import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.TotalBalanceBreakdownModel + +class BalanceBreakdownBottomSheet(context: Context) : BaseBottomSheet(context) { + + override val binder: FragmentBalanceBreakdownBinding = FragmentBalanceBreakdownBinding.inflate(LayoutInflater.from(context)) + + private var totalBreakdown: TotalBalanceBreakdownModel? = null + + private val adapter = BalanceBreakdownAdapter() + + init { + binder.balanceBreakdownList.adapter = adapter + } + + fun setBalanceBreakdown(totalBreakdown: TotalBalanceBreakdownModel) { + this.totalBreakdown = totalBreakdown + binder.balanceBreakdownTotal.text = totalBreakdown.totalFiat + adapter.submitList(totalBreakdown.breakdown) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/model/BalanceBreakdownItem.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/model/BalanceBreakdownItem.kt new file mode 100644 index 0000000..8acd0de --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/model/BalanceBreakdownItem.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +interface BalanceBreakdownItem { + val name: String +} + +data class BalanceBreakdownAmount( + override val name: String, + val amount: AmountModel +) : BalanceBreakdownItem + +data class BalanceBreakdownTotal( + override val name: String, + val fiatAmount: String, + @DrawableRes val iconRes: Int, + val percentage: String +) : BalanceBreakdownItem diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/model/TotalBalanceBreakdownModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/model/TotalBalanceBreakdownModel.kt new file mode 100644 index 0000000..f1583ff --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/breakdown/model/TotalBalanceBreakdownModel.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model + +class TotalBalanceBreakdownModel( + val totalFiat: String, + val breakdown: List +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetExpandableAssetDecorationSettings.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetExpandableAssetDecorationSettings.kt new file mode 100644 index 0000000..89a2247 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetExpandableAssetDecorationSettings.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common + +import android.view.animation.AccelerateDecelerateInterpolator +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAnimationSettings + +fun ExpandableAnimationSettings.Companion.createForAssets() = ExpandableAnimationSettings(400, AccelerateDecelerateInterpolator()) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetListMixin.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetListMixin.kt new file mode 100644 index 0000000..a7f40bb --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetListMixin.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common + +import io.novafoundation.nova.common.data.model.AssetViewMode +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.common.utils.throttleLast +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.list.AssetsListInteractor +import io.novafoundation.nova.feature_assets.domain.assets.models.byNetworks +import io.novafoundation.nova.feature_assets.domain.assets.models.byTokens +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.combine +import kotlin.time.Duration.Companion.milliseconds + +class AssetListMixinFactory( + private val walletInteractor: WalletInteractor, + private val assetsListInteractor: AssetsListInteractor, + private val externalBalancesInteractor: ExternalBalancesInteractor, + private val expandableAssetsMixinFactory: ExpandableAssetsMixinFactory +) { + + fun create(coroutineScope: CoroutineScope): AssetListMixin = RealAssetListMixin( + walletInteractor, + assetsListInteractor, + externalBalancesInteractor, + expandableAssetsMixinFactory, + coroutineScope + ) +} + +interface AssetListMixin { + + val assetsViewModeFlow: Flow + + val externalBalancesFlow: SharedFlow> + + val assetsFlow: Flow> + + val assetModelsFlow: Flow> + + fun expandToken(tokenGroupUi: TokenGroupUi) + + suspend fun switchViewMode() +} + +class RealAssetListMixin( + private val walletInteractor: WalletInteractor, + private val assetsListInteractor: AssetsListInteractor, + private val externalBalancesInteractor: ExternalBalancesInteractor, + private val expandableAssetsMixinFactory: ExpandableAssetsMixinFactory, + private val coroutineScope: CoroutineScope +) : AssetListMixin, CoroutineScope by coroutineScope { + + override val assetsFlow = walletInteractor.assetsFlow() + .shareInBackground() + + private val filteredAssetsFlow = walletInteractor.filterAssets(assetsFlow) + .shareInBackground() + + override val externalBalancesFlow = externalBalancesInteractor.observeExternalBalances() + .shareInBackground() + + override val assetsViewModeFlow = assetsListInteractor.assetsViewModeFlow() + .shareInBackground() + + private val throttledBalance = combineToPair(filteredAssetsFlow, externalBalancesFlow) + .throttleLast(300.milliseconds) + + private val assetsByViewMode = combine( + throttledBalance, + assetsViewModeFlow + ) { (assets, externalBalances), viewMode -> + when (viewMode) { + AssetViewMode.NETWORKS -> walletInteractor.groupAssetsByNetwork(assets, externalBalances).byNetworks() + AssetViewMode.TOKENS -> walletInteractor.groupAssetsByToken(assets, externalBalances).byTokens() + } + }.shareInBackground() + + private val expandableAssetsMixin = expandableAssetsMixinFactory.create(assetsByViewMode) + + override val assetModelsFlow = expandableAssetsMixin.assetModelsFlow + .shareInBackground() + + override fun expandToken(tokenGroupUi: TokenGroupUi) { + expandableAssetsMixin.expandToken(tokenGroupUi) + } + + override suspend fun switchViewMode() { + expandableAssetsMixin.switchViewMode() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetTokensDecoration.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetTokensDecoration.kt new file mode 100644 index 0000000..1be9269 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetTokensDecoration.kt @@ -0,0 +1,200 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common + +import android.animation.ArgbEvaluator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF +import android.view.View +import androidx.core.graphics.toRect +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAdapter +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableItemDecoration +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimationItemState +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimator +import io.novafoundation.nova.common.utils.recyclerView.expandable.expandingFraction +import io.novafoundation.nova.common.utils.recyclerView.expandable.flippedFraction +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableParentItem +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.TokenAssetGroupViewHolder +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import kotlin.math.roundToInt + +class AssetTokensDecoration( + private val context: Context, + private val adapter: ExpandableAdapter, + animator: ExpandableAnimator +) : ExpandableItemDecoration( + adapter, + animator +) { + private val argbEvaluator = ArgbEvaluator() + + private val childrenBlockCollapsedHorizontalMargin = 16.dp(context) + private val childrenBlockCollapsedHeight = 4.dp(context) + + private val blockRadiusCollapsed = 4.dpF(context) + private val blockRadiusExpanded = 12.dpF(context) + private val blockRadiusDelta = blockRadiusExpanded - blockRadiusCollapsed + + private val blockColor = context.getColor(R.color.block_background) + private val hidedBlockColor = context.getColor(R.color.hided_networks_block_background) + private val transparentColor = Color.TRANSPARENT + private val dividerColor = context.getColor(R.color.divider) + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + + private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + } + + private var drawingPath = Path() + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val viewHolder = parent.getChildViewHolder(view) + + if (viewHolder.bindingAdapterPosition == 0) return + + if (viewHolder is TokenAssetGroupViewHolder) { + if (viewHolder.bindingAdapterPosition == adapter.getItems().size - 1) { + outRect.set(0, 12.dp(context), 0, 12.dp(context)) + } else { + outRect.set(0, 12.dp(context), 0, 0) + } + } + } + + override fun onDrawGroup( + canvas: Canvas, + animationState: ExpandableAnimationItemState, + recyclerView: RecyclerView, + parentItem: ExpandableParentItem, + parent: RecyclerView.ViewHolder?, + children: List + ) { + val expandingFraction = animationState.expandingFraction() + + val parentBounds = parentBounds(parent) + if (parentBounds != null) { + drawParentBlock(parentBounds, canvas, expandingFraction) + } + + // Don't draw children background if it's a single item + if (parentItem is TokenGroupUi && parentItem.singleItemGroup) return + + val childrenBlockBounds = getChildrenBlockBounds(animationState, recyclerView, parent, children) + drawChildrenBlock(expandingFraction, childrenBlockBounds, canvas) + clipChildren(children, childrenBlockBounds) + } + + private fun clipChildren(children: List, childrenBlockBounds: RectF) { + val childrenBlock = childrenBlockBounds.toRect() + children.forEach { + val childrenBottomClipInset = (it.itemView.bottom + it.itemView.translationY.roundToInt()) - childrenBlock.bottom + val childrenTopClipInset = childrenBlock.top - (it.itemView.top + it.itemView.translationY.roundToInt()) + if (childrenTopClipInset > 0 || childrenBottomClipInset > 0) { + it.itemView.clipBounds = Rect( + 0, + childrenTopClipInset, + it.itemView.width, + it.itemView.height - childrenBottomClipInset + ) + } else { + it.itemView.clipBounds = null + } + } + } + + private fun drawChildrenBlock(expandingFraction: Float, childrenBlockBounds: RectF, canvas: Canvas) { + val animatedBlockRadius = blockRadiusDelta * expandingFraction + childrenBlockBounds.toPath(drawingPath, topRadius = 0f, bottomRadius = blockRadiusCollapsed + animatedBlockRadius * expandingFraction) + paint.color = argbEvaluator.evaluate(expandingFraction, hidedBlockColor, blockColor) as Int + canvas.drawPath(drawingPath, paint) + } + + private fun drawParentBlock( + parentBounds: RectF, + canvas: Canvas, + expandingFraction: Float + ) { + val path = Path() + val bottomRadius = blockRadiusExpanded * expandingFraction.flippedFraction() + parentBounds.toPath(path, topRadius = blockRadiusExpanded, bottomRadius = bottomRadius) + paint.color = blockColor + canvas.drawPath(path, paint) + + drawParentDivider(expandingFraction, bottomRadius, canvas, parentBounds) + } + + private fun drawParentDivider( + expandingFraction: Float, + dividerHorizontalMargin: Float, + canvas: Canvas, + parentBounds: RectF + ) { + linePaint.color = argbEvaluator.evaluate(expandingFraction, transparentColor, dividerColor) as Int + canvas.drawLine( + parentBounds.left + dividerHorizontalMargin, + parentBounds.bottom, + parentBounds.right - dividerHorizontalMargin, + parentBounds.bottom, + linePaint + ) + } + + private fun parentBounds(parent: RecyclerView.ViewHolder?): RectF? { + if (parent == null) return null + + return parent.itemView.let { + RectF( + it.left.toFloat(), + it.top.toFloat() + it.translationY, + it.right.toFloat(), + it.bottom.toFloat() + it.translationY + ) + } + } + + private fun getChildrenBlockBounds( + animationState: ExpandableAnimationItemState, + recyclerView: RecyclerView, + parent: RecyclerView.ViewHolder?, + children: List + ): RectF { + val lastChild = children.maxByOrNull { it.itemView.bottom } + + val parentTranslationY = parent?.itemView?.translationY ?: 0f + val childTranslationY = lastChild?.itemView?.translationY ?: 0f + + val top = (parent?.itemView?.bottom ?: 0) + parentTranslationY + val bottom = (lastChild?.itemView?.bottom?.toFloat() ?: top).coerceAtLeast(top) + val left = parent?.itemView?.left ?: lastChild?.itemView?.left ?: recyclerView.left + val right = parent?.itemView?.right ?: lastChild?.itemView?.right ?: recyclerView.right + + val expandingFraction = animationState.expandingFraction() + val flippedExpandingFraction = expandingFraction.flippedFraction() + val heightDelta = (bottom - top) + return RectF( + left + childrenBlockCollapsedHorizontalMargin * flippedExpandingFraction, + top, + right - childrenBlockCollapsedHorizontalMargin * flippedExpandingFraction, + top + childrenBlockCollapsedHeight + heightDelta * expandingFraction + childTranslationY + ) + } + + private fun RectF.toPath(path: Path, topRadius: Float, bottomRadius: Float) { + path.reset() + path.addRoundRect( + this, + floatArrayOf(topRadius, topRadius, topRadius, topRadius, bottomRadius, bottomRadius, bottomRadius, bottomRadius), + Path.Direction.CW + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetTokensItemAnimator.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetTokensItemAnimator.kt new file mode 100644 index 0000000..0017662 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/AssetTokensItemAnimator.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common + +import android.view.ViewPropertyAnimator +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAnimationSettings +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableItemAnimator +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimator + +private const val REMOVE_SCALE = 0.9f + +class AssetTokensItemAnimator( + settings: ExpandableAnimationSettings, + expandableAnimator: ExpandableAnimator +) : ExpandableItemAnimator( + settings, + expandableAnimator +) { + + override fun preAddImpl(holder: RecyclerView.ViewHolder) { + holder.itemView.alpha = 0f + holder.itemView.scaleX = REMOVE_SCALE + holder.itemView.scaleY = REMOVE_SCALE + } + + override fun getAddAnimator(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return holder.itemView.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + } + + override fun preRemoveImpl(holder: RecyclerView.ViewHolder) { + resetAddState(holder) + } + + override fun getRemoveAnimator(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return holder.itemView.animate() + .alpha(0f) + .scaleX(REMOVE_SCALE) + .scaleY(REMOVE_SCALE) + } + + override fun preMoveImpl(holder: RecyclerView.ViewHolder, fromY: Int, toY: Int) { + val yDelta = toY - fromY + holder.itemView.translationY += -yDelta + } + + override fun getMoveAnimator(holder: RecyclerView.ViewHolder): ViewPropertyAnimator { + return holder.itemView.animate() + .translationY(0f) + } + + override fun endAnimation(viewHolder: RecyclerView.ViewHolder) { + super.endAnimation(viewHolder) + + viewHolder.itemView.translationY = 0f + viewHolder.itemView.alpha = 1f + viewHolder.itemView.scaleX = 1f + viewHolder.itemView.scaleY = 1f + } + + override fun resetAddState(holder: RecyclerView.ViewHolder) { + holder.itemView.alpha = 1f + holder.itemView.scaleX = 1f + holder.itemView.scaleY = 1f + } + + override fun resetRemoveState(holder: RecyclerView.ViewHolder) { + holder.itemView.alpha = 1f + holder.itemView.scaleX = 1f + holder.itemView.scaleY = 1f + } + + override fun resetMoveState(holder: RecyclerView.ViewHolder) { + holder.itemView.translationY = 0f + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/BalanceListAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/BalanceListAdapter.kt new file mode 100644 index 0000000..4c07d0e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/BalanceListAdapter.kt @@ -0,0 +1,171 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common + +import android.annotation.SuppressLint +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import coil.ImageLoader +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAdapter +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableBaseItem +import io.novafoundation.nova.feature_assets.databinding.ItemNetworkAssetBinding +import io.novafoundation.nova.feature_assets.databinding.ItemNetworkAssetGroupBinding +import io.novafoundation.nova.feature_assets.databinding.ItemTokenAssetBinding +import io.novafoundation.nova.feature_assets.databinding.ItemTokenAssetGroupBinding +import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.NetworkAssetGroupViewHolder +import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.NetworkAssetViewHolder +import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.TokenAssetGroupViewHolder +import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.TokenAssetViewHolder +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkAssetUi +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkGroupUi +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenAssetUi +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_assets.presentation.model.AssetModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +private val priceRateExtractor = { asset: AssetModel -> asset.token.rate } +private val recentChangeExtractor = { asset: AssetModel -> asset.token.recentRateChange } +private val amountExtractor = { asset: AssetModel -> asset.amount } + +private val tokenGroupPriceRateExtractor = { group: TokenGroupUi -> group.rate } +private val tokenGroupRecentChangeExtractor = { group: TokenGroupUi -> group.recentRateChange } +private val tokenGroupAmountExtractor = { group: TokenGroupUi -> group.balance } +private val tokenGroupTypeExtractor = { group: TokenGroupUi -> group.groupType } + +const val TYPE_NETWORK_GROUP = 0 +const val TYPE_NETWORK_ASSET = 1 +const val TYPE_TOKEN_GROUP = 2 +const val TYPE_TOKEN_ASSET = 3 + +class BalanceListAdapter( + private val imageLoader: ImageLoader, + private val itemHandler: ItemAssetHandler, +) : ListAdapter(DiffCallback), ExpandableAdapter { + + interface ItemAssetHandler { + fun assetClicked(asset: Chain.Asset) + + fun tokenGroupClicked(tokenGroup: TokenGroupUi) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return when (viewType) { + TYPE_NETWORK_GROUP -> NetworkAssetGroupViewHolder(ItemNetworkAssetGroupBinding.inflate(parent.inflater(), parent, false)) + TYPE_NETWORK_ASSET -> NetworkAssetViewHolder(ItemNetworkAssetBinding.inflate(parent.inflater(), parent, false), imageLoader) + TYPE_TOKEN_GROUP -> TokenAssetGroupViewHolder(ItemTokenAssetGroupBinding.inflate(parent.inflater(), parent, false), imageLoader, itemHandler) + TYPE_TOKEN_ASSET -> TokenAssetViewHolder(ItemTokenAssetBinding.inflate(parent.inflater(), parent, false), imageLoader) + else -> error("Unknown view type") + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + return when (holder) { + is NetworkAssetGroupViewHolder -> holder.bind(getItem(position) as NetworkGroupUi) + is NetworkAssetViewHolder -> holder.bind(getItem(position) as NetworkAssetUi, itemHandler) + is TokenAssetGroupViewHolder -> holder.bind(getItem(position) as TokenGroupUi) + is TokenAssetViewHolder -> holder.bind(getItem(position) as TokenAssetUi, itemHandler) + else -> error("Unknown holder") + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList) { + when (holder) { + is NetworkAssetViewHolder -> { + val item = getItem(position) as NetworkAssetUi + resolvePayload(holder, position, payloads) { + when (it) { + priceRateExtractor -> holder.bindPriceInfo(item.asset) + recentChangeExtractor -> holder.bindRecentChange(item.asset) + amountExtractor -> holder.bindTotal(item.asset) + } + } + } + + is TokenAssetViewHolder -> { + val item = getItem(position) as TokenAssetUi + holder.updateExpandableItem(item) + + resolvePayload(holder, position, payloads) { + when (it) { + amountExtractor -> holder.bindTotal(item.asset) + } + } + } + + is TokenAssetGroupViewHolder -> { + val item = getItem(position) as TokenGroupUi + holder.updateExpandableItem(item) + + resolvePayload(holder, position, payloads) { + when (it) { + tokenGroupPriceRateExtractor -> holder.bindPriceRate(item) + tokenGroupRecentChangeExtractor -> holder.bindRecentChange(item) + tokenGroupAmountExtractor -> holder.bindTotal(item) + tokenGroupTypeExtractor -> holder.bindGroupType(item) + } + } + } + + else -> super.onBindViewHolder(holder, position, payloads) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is NetworkGroupUi -> TYPE_NETWORK_GROUP + is NetworkAssetUi -> TYPE_NETWORK_ASSET + is TokenGroupUi -> TYPE_TOKEN_GROUP + is TokenAssetUi -> TYPE_TOKEN_ASSET + else -> error("Unknown item type") + } + } + + override fun getItems(): List { + return currentList + } +} + +private object DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: BalanceListRvItem, newItem: BalanceListRvItem): Boolean { + return oldItem.itemId == newItem.itemId + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: BalanceListRvItem, newItem: BalanceListRvItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: BalanceListRvItem, newItem: BalanceListRvItem): Any? { + return when { + oldItem is NetworkAssetUi && newItem is NetworkAssetUi -> NetworkAssetPayloadGenerator.diff(oldItem.asset, newItem.asset) + + oldItem is TokenAssetUi && newItem is TokenAssetUi -> TokenAssetPayloadGenerator.diff(oldItem.asset, newItem.asset) + + oldItem is TokenGroupUi && newItem is TokenGroupUi -> TokenGroupAssetPayloadGenerator.diff(oldItem, newItem) + + else -> null + } + } +} + +private object NetworkAssetPayloadGenerator : PayloadGenerator( + priceRateExtractor, + recentChangeExtractor, + amountExtractor +) + +private object TokenAssetPayloadGenerator : PayloadGenerator( + amountExtractor +) + +private object TokenGroupAssetPayloadGenerator : PayloadGenerator( + tokenGroupPriceRateExtractor, + tokenGroupRecentChangeExtractor, + tokenGroupAmountExtractor, + tokenGroupTypeExtractor +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/ControllableAssetCheckMixin.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/ControllableAssetCheckMixin.kt new file mode 100644 index 0000000..b752516 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/ControllableAssetCheckMixin.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common + +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.account.watchOnly.WatchOnlyMissingKeysPresenter +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ControllableAssetCheckMixin( + private val missingKeysPresenter: WatchOnlyMissingKeysPresenter, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val resourceManager: ResourceManager +) { + + val acknowledgeLedgerWarning = actionAwaitableMixinFactory.confirmingAction() + + suspend fun check(metaAccount: MetaAccount, chainAsset: Chain.Asset, action: () -> Unit) { + when { + metaAccount.type == LightMetaAccount.Type.LEDGER_LEGACY && chainAsset.type is Chain.Asset.Type.Orml -> showLedgerAssetNotSupportedWarning( + chainAsset + ) + metaAccount.type == LightMetaAccount.Type.WATCH_ONLY -> missingKeysPresenter.presentNoKeysFound() + else -> action() + } + } + + private suspend fun showLedgerAssetNotSupportedWarning(chainAsset: Chain.Asset) { + val assetSymbol = chainAsset.symbol + val warningMessage = resourceManager.getString(R.string.assets_receive_ledger_not_supported_message, assetSymbol, assetSymbol) + + acknowledgeLedgerWarning.awaitAction(warningMessage) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/ExpandableAssetsMixin.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/ExpandableAssetsMixin.kt new file mode 100644 index 0000000..daf4375 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/ExpandableAssetsMixin.kt @@ -0,0 +1,131 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common + +import io.novafoundation.nova.common.data.model.switch +import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.utils.combineToTuple4 +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.common.utils.updateValue +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatterFactory +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatterFactory +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenAssetUi +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest + +class ExpandableAssetsMixinFactory( + private val assetIconProvider: AssetIconProvider, + private val currencyInteractor: CurrencyInteractor, + private val assetsViewModeRepository: AssetsViewModeRepository, + private val amountFormatterProvider: MaskableValueFormatterProvider, + private val networkAssetFormatterFactory: NetworkAssetFormatterFactory, + private val tokenAssetFormatterFactory: TokenAssetFormatterFactory, +) { + + fun create(assetsFlow: Flow): ExpandableAssetsMixin { + return RealExpandableAssetsMixin( + assetsFlow, + currencyInteractor, + amountFormatterProvider, + networkAssetFormatterFactory, + tokenAssetFormatterFactory, + assetIconProvider, + assetsViewModeRepository + ) + } +} + +interface ExpandableAssetsMixin { + + val assetModelsFlow: Flow> + + fun expandToken(tokenGroupUi: TokenGroupUi) + + suspend fun switchViewMode() +} + +class RealExpandableAssetsMixin( + assetsFlow: Flow, + currencyInteractor: CurrencyInteractor, + amountFormatterProvider: MaskableValueFormatterProvider, + networkAssetFormatterFactory: NetworkAssetFormatterFactory, + tokenAssetFormatterFactory: TokenAssetFormatterFactory, + private val assetIconProvider: AssetIconProvider, + private val assetsViewModeRepository: AssetsViewModeRepository +) : ExpandableAssetsMixin { + + private val assetsFormatters = amountFormatterProvider.provideFormatter() + .map { + AssetMappers( + networkAssetFormatterFactory.create(it), + tokenAssetFormatterFactory.create(it) + ) + } + + private val selectedCurrency = currencyInteractor.observeSelectCurrency() + + private val expandedTokenIdsFlow = MutableStateFlow(setOf()) + + override val assetModelsFlow: Flow> = combineToTuple4( + assetsFlow, + expandedTokenIdsFlow, + selectedCurrency, + assetsFormatters + ).mapLatest { (assetsByViewMode, expandedTokens, currency, assetMappers) -> + when (assetsByViewMode) { + is AssetsByViewModeResult.ByNetworks -> assetMappers.networkAssetMapper.mapGroupedAssetsToUi( + groupedAssets = assetsByViewMode.assets, + assetIconProvider = assetIconProvider, + currency = currency + ) + + is AssetsByViewModeResult.ByTokens -> assetMappers.tokenAssetFormatter.mapGroupedAssetsToUi( + groupedTokens = assetsByViewMode.tokens, + assetIconProvider = assetIconProvider, + assetFilter = { groupId, assetsInGroup -> filterTokens(groupId, assetsInGroup, expandedTokens) } + ) + } + } + .distinctUntilChanged() + + override fun expandToken(tokenGroupUi: TokenGroupUi) { + expandedTokenIdsFlow.updateValue { it.toggle(tokenGroupUi.itemId) } + } + + override suspend fun switchViewMode() { + expandedTokenIdsFlow.value = emptySet() + + val assetViewMode = assetsViewModeRepository.getAssetViewMode() + assetsViewModeRepository.setAssetsViewMode(assetViewMode.switch()) + } + + private fun filterTokens(groupId: String, assets: List, expandedGroups: Set): List { + if (groupId in expandedGroups) { + return filterIfSingleItem(assets) + } + + return emptyList() + } + + private fun filterIfSingleItem(assets: List): List { + return if (assets.size <= 1) { + emptyList() + } else { + assets + } + } +} + +private class AssetMappers( + val networkAssetMapper: NetworkAssetFormatter, + val tokenAssetFormatter: TokenAssetFormatter +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/baseDecoration/AssetBaseDecoration.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/baseDecoration/AssetBaseDecoration.kt new file mode 100644 index 0000000..4365c34 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/baseDecoration/AssetBaseDecoration.kt @@ -0,0 +1,141 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.view.View +import androidx.core.view.children +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_assets.R +import kotlin.math.roundToInt + +/** + * Note - clients are required to call [RecyclerView.invalidateItemDecorations] in [ListAdapter.submitList] callback due to issues with DiffUtil. + * The issue is that this decoration does not currently support partial list updates and assumes it will be iterated over whole list + * TODO update decoration to not require this invalidation + */ +class AssetBaseDecoration( + private val background: Drawable, + private val assetsAdapter: ListAdapter<*, *>, + context: Context, + private val preferences: AssetDecorationPreferences +) : RecyclerView.ItemDecoration() { + + companion object; + + private val bounds = Rect() + + // used to hide rounded corners for the last group to simulate effect of not-closed group + private val finalGroupExtraPadding = 20.dp(context) + + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + if (assetsAdapter.itemCount == 0) return + + var groupTop: Int? = null + + parent.children.forEachIndexed { index, view -> + val viewHolder = parent.getChildViewHolder(view) + + if (shouldSkip(viewHolder)) return@forEachIndexed + + val bindingPosition = viewHolder.bindingAdapterPosition + + val nextType = assetsAdapter.getItemViewTypeOrNull(bindingPosition + 1) + + if (groupTop == null) { + parent.getDecoratedBoundsWithMargins(view, bounds) + groupTop = bounds.top + view.translationY.roundToInt() + } + + when { + // if group is finished + isFinalItemInGroup(nextType) -> { + parent.getDecoratedBoundsWithMargins(view, bounds) + bounds.set(view.left, bounds.top, view.right, bounds.bottom) + + val groupBottom = bounds.bottom + view.translationY.roundToInt() - preferences.outerGroupPadding(viewHolder) + + background.setBounds(bounds.left, groupTop!!, bounds.right, groupBottom) + background.draw(c) + + if (index + 1 < parent.childCount) { + val nextView = parent.getChildAt(index + 1) + parent.getDecoratedBoundsWithMargins(nextView, bounds) + + groupTop = bounds.top + view.translationY.roundToInt() + } + } + // draw last group + index == parent.childCount - 1 -> { + parent.getDecoratedBoundsWithMargins(view, bounds) + bounds.set(view.left, bounds.top, view.right, bounds.bottom) + + val groupBottom = bounds.bottom + view.translationY.roundToInt() + finalGroupExtraPadding + background.setBounds(bounds.left, groupTop!!, bounds.right, groupBottom) + background.draw(c) + } + } + } + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val viewHolder = parent.getChildViewHolder(view) + + if (shouldSkip(viewHolder)) { + outRect.set(0, 0, 0, 0) + + return + } + + val adapterPosition = viewHolder.bindingAdapterPosition + + val nextType = assetsAdapter.getItemViewTypeOrNull(adapterPosition + 1) + + val bottom = if (isFinalItemInGroup(nextType)) { + preferences.outerGroupPadding(viewHolder) + preferences.innerGroupPadding(viewHolder) + } else { + 0 + } + + outRect.set(0, 0, 0, bottom) + } + + private fun RecyclerView.Adapter<*>.getItemViewTypeOrNull(position: Int): Int? { + if (position < 0 || position >= itemCount) return null + + return getItemViewType(position) + } + + private fun isFinalItemInGroup(nextType: Int?): Boolean { + return nextType == null || preferences.isGroupItem(nextType) + } + + private fun shouldSkip(viewHolder: RecyclerView.ViewHolder): Boolean { + val noPosition = viewHolder.bindingAdapterPosition == RecyclerView.NO_POSITION + val unsupportedViewHolder = !preferences.shouldUseViewHolder(viewHolder) + + return noPosition || unsupportedViewHolder + } +} + +fun AssetBaseDecoration.Companion.applyDefaultTo( + recyclerView: RecyclerView, + adapter: ListAdapter<*, *>, + preferences: AssetDecorationPreferences = NetworkAssetDecorationPreferences() +) { + val groupBackground = with(recyclerView.context) { + addRipple(getRoundedCornerDrawable(R.color.block_background)) + } + val decoration = AssetBaseDecoration( + background = groupBackground, + assetsAdapter = adapter, + context = recyclerView.context, + preferences = preferences + ) + recyclerView.addItemDecoration(decoration) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/baseDecoration/AssetDecorationPreferences.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/baseDecoration/AssetDecorationPreferences.kt new file mode 100644 index 0000000..8f8ae0f --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/baseDecoration/AssetDecorationPreferences.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration + +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.feature_assets.presentation.balance.common.TYPE_NETWORK_GROUP +import io.novafoundation.nova.feature_assets.presentation.balance.common.TYPE_TOKEN_GROUP +import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.NetworkAssetGroupViewHolder +import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.NetworkAssetViewHolder +import io.novafoundation.nova.feature_assets.presentation.balance.common.holders.TokenAssetGroupViewHolder + +interface AssetDecorationPreferences { + + fun innerGroupPadding(viewHolder: ViewHolder): Int + + fun outerGroupPadding(viewHolder: ViewHolder): Int + + fun isGroupItem(viewType: Int): Boolean + + fun shouldUseViewHolder(viewHolder: ViewHolder): Boolean +} + +class NetworkAssetDecorationPreferences : AssetDecorationPreferences { + + override fun innerGroupPadding(viewHolder: ViewHolder): Int { + return 8.dp(viewHolder.itemView.context) + } + + override fun outerGroupPadding(viewHolder: ViewHolder): Int { + return 8.dp(viewHolder.itemView.context) + } + + override fun isGroupItem(viewType: Int): Boolean { + return viewType == TYPE_NETWORK_GROUP + } + + override fun shouldUseViewHolder(viewHolder: ViewHolder): Boolean { + return viewHolder is NetworkAssetViewHolder || + viewHolder is NetworkAssetGroupViewHolder + } +} + +class TokenAssetGroupDecorationPreferences : AssetDecorationPreferences { + + override fun innerGroupPadding(viewHolder: ViewHolder): Int { + return 0 + } + + override fun outerGroupPadding(viewHolder: ViewHolder): Int { + return 8.dp(viewHolder.itemView.context) + } + + override fun isGroupItem(viewType: Int): Boolean { + return viewType == TYPE_TOKEN_GROUP + } + + override fun shouldUseViewHolder(viewHolder: ViewHolder): Boolean { + return viewHolder is TokenAssetGroupViewHolder + } +} + +class CompoundAssetDecorationPreferences(private vararg val preferences: AssetDecorationPreferences) : AssetDecorationPreferences { + + override fun innerGroupPadding(viewHolder: ViewHolder): Int { + val firstPreferences = preferences.firstOrNull { it.shouldUseViewHolder(viewHolder) } + return firstPreferences?.innerGroupPadding(viewHolder) ?: 0 + } + + override fun outerGroupPadding(viewHolder: ViewHolder): Int { + val firstPreferences = preferences.firstOrNull { it.shouldUseViewHolder(viewHolder) } + return firstPreferences?.outerGroupPadding(viewHolder) ?: 0 + } + + override fun isGroupItem(viewType: Int): Boolean { + return preferences.any { it.isGroupItem(viewType) } + } + + override fun shouldUseViewHolder(viewHolder: RecyclerView.ViewHolder): Boolean { + return preferences.any { it.shouldUseViewHolder(viewHolder) } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/BuySellRestrictionCheckMixin.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/BuySellRestrictionCheckMixin.kt new file mode 100644 index 0000000..bdafc0d --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/BuySellRestrictionCheckMixin.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.buySell + +import io.novafoundation.nova.common.mixin.restrictions.RestrictionCheckMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences +import io.novafoundation.nova.common.view.bottomSheet.action.primary +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.isMultisig +import io.novafoundation.nova.feature_account_api.domain.model.isThreshold1 +import io.novafoundation.nova.feature_assets.R + +class BuySellRestrictionCheckMixin( + private val accountUseCase: SelectedAccountUseCase, + private val resourceManager: ResourceManager, + private val actionLauncher: ActionBottomSheetLauncher, +) : RestrictionCheckMixin { + + override suspend fun isRestricted(): Boolean { + val selectedAccount = accountUseCase.getSelectedMetaAccount() + return selectedAccount.isMultisig() && !selectedAccount.isThreshold1() + } + + override suspend fun checkRestrictionAndDo(action: () -> Unit) { + when { + isRestricted() -> showMultisigWarning() + else -> action() + } + } + + private fun showMultisigWarning() { + actionLauncher.launchBottomSheet( + imageRes = R.drawable.ic_multisig, + title = resourceManager.getString(R.string.multisig_sell_not_supported_title), + subtitle = resourceManager.getString(R.string.multisig_sell_not_supported_message), + actionButtonPreferences = ButtonPreferences.primary(resourceManager.getString(R.string.common_ok_back)), + neutralButtonPreferences = null + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/BuySellSelectorMixin.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/BuySellSelectorMixin.kt new file mode 100644 index 0000000..3bbbe9a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/BuySellSelectorMixin.kt @@ -0,0 +1,141 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.buySell + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.mixin.restrictions.isAllowed +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixin.SelectorType +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +interface BuySellSelectorMixin { + + sealed interface SelectorType { + + object AllAssets : SelectorType + + class Asset(val chaiId: String, val assetId: Int) : SelectorType + } + + class SelectorPayload(vararg val items: ListSelectorMixin.Item) + + val tradingEnabledFlow: Flow + val actionLiveData: LiveData> + val errorLiveData: MutableLiveData>> + + fun openSelector() +} + +class RealBuySellSelectorMixin( + private val buySellRestrictionCheckMixin: BuySellRestrictionCheckMixin, + private val router: AssetsRouter, + private val tradeTokenRegistry: TradeTokenRegistry, + private val chainRegistry: ChainRegistry, + private val resourceManager: ResourceManager, + private val selectorType: SelectorType, + private val coroutineScope: CoroutineScope +) : BuySellSelectorMixin { + + override val tradingEnabledFlow: Flow = flowOf { + when (selectorType) { + SelectorType.AllAssets -> true + is SelectorType.Asset -> { + val chainAsset = chainRegistry.asset(selectorType.chaiId, selectorType.assetId) + tradeTokenRegistry.hasProvider(chainAsset) + } + } + } + + override val actionLiveData: MutableLiveData> = MutableLiveData() + + override val errorLiveData: MutableLiveData>> = MutableLiveData() + + override fun openSelector() = coroutineScope.launchUnit { + val payload = when (selectorType) { + SelectorType.AllAssets -> openAllAssetsSelector() + + is SelectorType.Asset -> openSpecifiedAssetSelector(selectorType) + } + + if (payload != null) { + actionLiveData.value = Event(payload) + } + } + + private suspend fun openAllAssetsSelector() = BuySellSelectorMixin.SelectorPayload( + buyItem(enabled = true) { router.openBuyFlow() }, + sellItem(enabled = buySellRestrictionCheckMixin.isAllowed()) { router.openSellFlow() }, + bridgeItem(enabled = true) { router.openBridgeFlow() } + ) + + private suspend fun openSpecifiedAssetSelector(selectorType: SelectorType.Asset): BuySellSelectorMixin.SelectorPayload? { + val chainAsset = chainRegistry.asset(selectorType.chaiId, selectorType.assetId) + val buyAvailable = tradeTokenRegistry.hasProvider(chainAsset, TradeTokenRegistry.TradeType.BUY) + val sellAvailable = tradeTokenRegistry.hasProvider(chainAsset, TradeTokenRegistry.TradeType.SELL) && + buySellRestrictionCheckMixin.isAllowed() + + if (!buyAvailable && !sellAvailable) { + showErrorMessage(R.string.trade_token_not_supported_title, R.string.trade_token_not_supported_message) + return null + } + + return BuySellSelectorMixin.SelectorPayload( + buyItem(enabled = buyAvailable) { router.openBuyProviders(selectorType.chaiId, selectorType.assetId) }, + sellItem(enabled = sellAvailable) { router.openSellProviders(selectorType.chaiId, selectorType.assetId) } + ) + } + + private fun buyItem(enabled: Boolean, action: () -> Unit): ListSelectorMixin.Item { + return ListSelectorMixin.Item( + R.drawable.ic_add_circle_outline, + if (enabled) R.color.icon_primary else R.color.icon_inactive, + R.string.wallet_asset_buy_tokens, + if (enabled) R.color.text_primary else R.color.button_text_inactive, + if (enabled) action else errorAction(R.string.buy_token_not_supported_title, R.string.buy_token_not_supported_message) + ) + } + + private fun sellItem(enabled: Boolean, action: () -> Unit): ListSelectorMixin.Item { + return ListSelectorMixin.Item( + R.drawable.ic_sell_tokens, + if (enabled) R.color.icon_primary else R.color.icon_inactive, + R.string.wallet_asset_sell_tokens, + if (enabled) R.color.text_primary else R.color.button_text_inactive, + if (enabled) action else sellErrorAction() + ) + } + + private fun bridgeItem(enabled: Boolean, action: () -> Unit): ListSelectorMixin.Item { + return ListSelectorMixin.Item( + R.drawable.ic_bridge, + if (enabled) R.color.icon_primary else R.color.icon_inactive, + R.string.wallet_asset_bridge, + if (enabled) R.color.text_primary else R.color.button_text_inactive, + action + ) + } + + private fun sellErrorAction(): () -> Unit = { + coroutineScope.launch { + buySellRestrictionCheckMixin.checkRestrictionAndDo { + showErrorMessage(R.string.sell_token_not_supported_title, R.string.sell_token_not_supported_message) + } + } + } + + private fun errorAction(titleRes: Int, messageRes: Int): () -> Unit = { showErrorMessage(titleRes, messageRes) } + + private fun showErrorMessage(titleRes: Int, messageRes: Int) { + errorLiveData.value = Event(Pair(resourceManager.getString(titleRes), resourceManager.getString(messageRes))) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/BuySellSelectorMixinFactory.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/BuySellSelectorMixinFactory.kt new file mode 100644 index 0000000..fa3eef2 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/BuySellSelectorMixinFactory.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.buySell + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.CoroutineScope + +class BuySellSelectorMixinFactory( + private val router: AssetsRouter, + private val tradeTokenRegistry: TradeTokenRegistry, + private val chainRegistry: ChainRegistry, + private val resourceManager: ResourceManager, + private val buySellRestrictionCheckMixin: BuySellRestrictionCheckMixin +) { + + fun create(selectorType: BuySellSelectorMixin.SelectorType, coroutineScope: CoroutineScope): BuySellSelectorMixin { + return RealBuySellSelectorMixin( + buySellRestrictionCheckMixin, + router, + tradeTokenRegistry, + chainRegistry, + resourceManager, + selectorType, + coroutineScope + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/UI.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/UI.kt new file mode 100644 index 0000000..3eff701 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/buySell/UI.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.buySell + +import android.annotation.SuppressLint +import android.widget.TextView +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.common.utils.ViewClickGestureDetector +import io.novafoundation.nova.common.utils.setCompoundDrawableTint +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.dialog.dialog +import io.novafoundation.nova.common.view.input.selector.DynamicSelectorBottomSheet + +fun BaseFragmentMixin<*>.setupBuySellSelectorMixin( + buySellSelectorMixin: BuySellSelectorMixin +) { + buySellSelectorMixin.actionLiveData.observeEvent { action -> + DynamicSelectorBottomSheet( + context = fragment.requireContext(), + payload = DynamicSelectorBottomSheet.Payload( + titleRes = null, + subtitle = null, + data = action.items.toList() + ), + onClicked = { _, item -> item.onClick() }, + ).show() + } + + buySellSelectorMixin.errorLiveData.observeEvent { + dialog(providedContext) { + setTitle(it.first) + setMessage(it.second) + setPositiveButton(R.string.common_got_it) { _, _ -> } + } + } +} + +@SuppressLint("ClickableViewAccessibility") +fun BaseFragmentMixin<*>.setupButSellActionButton( + buySellSelectorMixin: BuySellSelectorMixin, + actionButton: TextView +) { + val clickDetector = ViewClickGestureDetector(actionButton) + actionButton.setOnTouchListener { v, event -> + clickDetector.onTouchEvent(event) + } + actionButton.setOnClickListener { buySellSelectorMixin.openSelector() } + + buySellSelectorMixin.tradingEnabledFlow.observe { + if (it) { + actionButton.setTextColorRes(R.color.actions_color) + actionButton.setCompoundDrawableTint(actionButton.context.getColor(R.color.actions_color)) + } else { + actionButton.setTextColorRes(R.color.icon_inactive) + actionButton.setCompoundDrawableTint(actionButton.context.getColor(R.color.icon_inactive)) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/gifts/GiftsRestrictionCheckMixin.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/gifts/GiftsRestrictionCheckMixin.kt new file mode 100644 index 0000000..054757b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/gifts/GiftsRestrictionCheckMixin.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.gifts + +import io.novafoundation.nova.common.mixin.restrictions.RestrictionCheckMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences +import io.novafoundation.nova.common.view.bottomSheet.action.primary +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase +import io.novafoundation.nova.feature_gift_api.domain.GiftsSupportedState + +class GiftsRestrictionCheckMixin( + private val accountSupportedUseCase: GiftsAccountSupportedUseCase, + private val resourceManager: ResourceManager, + private val actionLauncher: ActionBottomSheetLauncher, +) : RestrictionCheckMixin { + + override suspend fun isRestricted(): Boolean { + return accountSupportedUseCase.supportedState() != GiftsSupportedState.SUPPORTED + } + + override suspend fun checkRestrictionAndDo(action: () -> Unit) { + when { + isRestricted() -> showMultisigWarning() + else -> action() + } + } + + private fun showMultisigWarning() { + actionLauncher.launchBottomSheet( + imageRes = R.drawable.ic_multisig, + title = resourceManager.getString(R.string.multisig_gifts_not_supported_title), + subtitle = resourceManager.getString(R.string.multisig_gifts_not_supported_message), + actionButtonPreferences = ButtonPreferences.primary(resourceManager.getString(R.string.common_ok_back)), + neutralButtonPreferences = null + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/NetworkAssetGroupViewHolder.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/NetworkAssetGroupViewHolder.kt new file mode 100644 index 0000000..73bad65 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/NetworkAssetGroupViewHolder.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.holders + +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.feature_assets.databinding.ItemNetworkAssetGroupBinding +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkGroupUi + +class NetworkAssetGroupViewHolder( + private val binder: ItemNetworkAssetGroupBinding, +) : GroupedListHolder(binder.root) { + + fun bind(assetGroup: NetworkGroupUi) = with(binder) { + itemAssetGroupChain.setChain(assetGroup.chainUi) + itemAssetGroupBalance.setMaskableText(assetGroup.groupBalanceFiat) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/NetworkAssetViewHolder.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/NetworkAssetViewHolder.kt new file mode 100644 index 0000000..fd545e8 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/NetworkAssetViewHolder.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.holders + +import coil.ImageLoader +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_assets.databinding.ItemNetworkAssetBinding +import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkAssetUi +import io.novafoundation.nova.feature_assets.presentation.model.AssetModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableFiat +import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableToken + +class NetworkAssetViewHolder( + private val binder: ItemNetworkAssetBinding, + private val imageLoader: ImageLoader, +) : GroupedListHolder(binder.root) { + + fun bind(networkAsset: NetworkAssetUi, itemHandler: BalanceListAdapter.ItemAssetHandler) = with(containerView) { + val asset = networkAsset.asset + binder.itemAssetImage.setTokenIcon(networkAsset.icon, imageLoader) + + bindPriceInfo(asset) + + bindRecentChange(asset) + + bindTotal(asset) + + binder.itemAssetToken.text = asset.token.configuration.symbol.value + + setOnClickListener { itemHandler.assetClicked(asset.token.configuration) } + } + + fun bindTotal(asset: AssetModel) { + binder.itemAssetBalance.setMaskableText(asset.amount.maskableToken()) + binder.itemAssetPriceAmount.setMaskableText(asset.amount.maskableFiat()) + } + + fun bindRecentChange(asset: AssetModel) = with(containerView) { + binder.itemAssetRateChange.setTextColorRes(asset.token.rateChangeColorRes) + binder.itemAssetRateChange.text = asset.token.recentRateChange + } + + fun bindPriceInfo(asset: AssetModel) = with(containerView) { + binder.itemAssetRate.text = asset.token.rate + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/TokenAssetGroupViewHolder.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/TokenAssetGroupViewHolder.kt new file mode 100644 index 0000000..accad2d --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/TokenAssetGroupViewHolder.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.holders + +import coil.ImageLoader +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableParentViewHolder +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableParentItem +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_assets.databinding.ItemTokenAssetGroupBinding +import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableFiat +import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableToken + +class TokenAssetGroupViewHolder( + private val binder: ItemTokenAssetGroupBinding, + private val imageLoader: ImageLoader, + private val itemHandler: BalanceListAdapter.ItemAssetHandler, +) : GroupedListHolder(binder.root), ExpandableParentViewHolder { + + override var expandableItem: ExpandableParentItem? = null + + fun bind(tokenGroup: TokenGroupUi) = with(binder) { + updateExpandableItem(tokenGroup) + + itemTokenGroupAssetImage.setTokenIcon(tokenGroup.tokenIcon, imageLoader) + + bindPriceRateInternal(tokenGroup) + + bindRecentChangeInternal(tokenGroup) + + bindTotalInternal(tokenGroup) + + updateListener(tokenGroup) + + itemAssetTokenGroupToken.text = tokenGroup.tokenSymbol + } + + fun bindTotal(networkAsset: TokenGroupUi) { + updateListener(networkAsset) + bindTotalInternal(networkAsset) + } + + fun bindRecentChange(networkAsset: TokenGroupUi) { + updateListener(networkAsset) + bindRecentChangeInternal(networkAsset) + } + + fun bindPriceRate(networkAsset: TokenGroupUi) { + updateListener(networkAsset) + bindPriceRateInternal(networkAsset) + } + + fun bindGroupType(networkAsset: TokenGroupUi) { + updateListener(networkAsset) + } + + private fun bindTotalInternal(networkAsset: TokenGroupUi) { + val balance = networkAsset.balance + binder.itemAssetTokenGroupBalance.setMaskableText(balance.maskableToken()) + binder.itemAssetTokenGroupPriceAmount.setMaskableText(balance.maskableFiat()) + } + + private fun bindRecentChangeInternal(networkAsset: TokenGroupUi) { + with(binder) { + itemAssetTokenGroupRateChange.setTextColorRes(networkAsset.rateChangeColorRes) + itemAssetTokenGroupRateChange.text = networkAsset.recentRateChange + } + } + + private fun bindPriceRateInternal(networkAsset: TokenGroupUi) { + with(binder) { + itemAssetTokenGroupRate.text = networkAsset.rate + } + } + + private fun updateListener(tokenGroupUi: TokenGroupUi) { + containerView.setOnClickListener { itemHandler.tokenGroupClicked(tokenGroupUi) } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/TokenAssetViewHolder.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/TokenAssetViewHolder.kt new file mode 100644 index 0000000..6fa5db7 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/holders/TokenAssetViewHolder.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.holders + +import coil.ImageLoader +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableChildViewHolder +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableChildItem +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_assets.databinding.ItemTokenAssetBinding +import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenAssetUi +import io.novafoundation.nova.feature_assets.presentation.model.AssetModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableFiat +import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableToken + +class TokenAssetViewHolder( + private val binder: ItemTokenAssetBinding, + private val imageLoader: ImageLoader, +) : GroupedListHolder(binder.root), ExpandableChildViewHolder { + + override var expandableItem: ExpandableChildItem? = null + + fun bind(tokenAsset: TokenAssetUi, itemHandler: BalanceListAdapter.ItemAssetHandler) = with(containerView) { + updateExpandableItem(tokenAsset) + + val asset = tokenAsset.asset + binder.itemTokenAssetImage.setTokenIcon(tokenAsset.assetIcon, imageLoader) + binder.itemTokenAssetChainIcon.loadChainIcon(tokenAsset.chain.icon, imageLoader) + binder.itemTokenAssetChainName.text = tokenAsset.chain.name + + bindTotal(asset) + + binder.itemTokenAssetToken.text = asset.token.configuration.symbol.value + + setOnClickListener { itemHandler.assetClicked(asset.token.configuration) } + } + + fun bindTotal(asset: AssetModel) { + binder.itemTokenAssetBalance.setMaskableText(asset.amount.maskableToken()) + binder.itemTokenAssetPriceAmount.setMaskableText(asset.amount.maskableFiat()) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/CommonAssetFormatter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/CommonAssetFormatter.kt new file mode 100644 index 0000000..1f02ee5 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/CommonAssetFormatter.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.mappers + +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.model.AssetModel +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_wallet_api.presentation.model.FractionPartStyling +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel + +abstract class CommonAssetFormatter( + private val maskableValueFormatter: MaskableValueFormatter, + private val amountFormatter: AmountFormatter +) { + + protected fun mapAssetToAssetModel( + asset: Asset, + balance: AssetBalance.Amount + ): AssetModel { + return AssetModel( + token = mapTokenToTokenModel(asset.token), + amount = maskableValueFormatter.format { + amountFormatter.formatAmountToAmountModel( + amount = balance.amount, + token = asset.token, + config = AmountConfig( + includeAssetTicker = false, + tokenFractionPartStyling = FractionPartStyling.Styled(R.dimen.asset_balance_fraction_size) + ) + ) + } + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/NetworkAssetMappers.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/NetworkAssetMappers.kt new file mode 100644 index 0000000..cbf30ce --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/NetworkAssetMappers.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.mappers + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkAssetUi +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.NetworkGroupUi +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter +import java.math.BigDecimal + +class NetworkAssetFormatterFactory( + private val fiatFormatter: FiatFormatter, + private val amountFormatter: AmountFormatter +) { + fun create(maskableFormatter: MaskableValueFormatter): NetworkAssetFormatter { + return NetworkAssetFormatter(maskableFormatter, fiatFormatter, amountFormatter) + } +} + +class NetworkAssetFormatter( + private val maskableFormatter: MaskableValueFormatter, + private val fiatFormatter: FiatFormatter, + private val amountFormatter: AmountFormatter +) : CommonAssetFormatter(maskableFormatter, amountFormatter) { + + fun mapGroupedAssetsToUi( + groupedAssets: GroupedList, + assetIconProvider: AssetIconProvider, + currency: Currency, + groupBalance: (NetworkAssetGroup) -> BigDecimal = NetworkAssetGroup::groupTotalBalanceFiat, + balance: (AssetBalance) -> AssetBalance.Amount = AssetBalance::total, + ): List { + return groupedAssets.mapKeys { (assetGroup, _) -> mapAssetGroupToUi(assetGroup, currency, groupBalance) } + .mapValues { (_, assets) -> mapAssetsToAssetModels(assetIconProvider, assets, balance) } + .toListWithHeaders() + .filterIsInstance() + } + + private fun mapAssetsToAssetModels( + assetIconProvider: AssetIconProvider, + assets: List, + balance: (AssetBalance) -> AssetBalance.Amount + ): List { + return assets.map { + NetworkAssetUi( + mapAssetToAssetModel(it.asset, balance(it.balanceWithOffchain)), + assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration) + ) + } + } + + private fun mapAssetGroupToUi( + assetGroup: NetworkAssetGroup, + currency: Currency, + groupBalance: (NetworkAssetGroup) -> BigDecimal + ): NetworkGroupUi { + return NetworkGroupUi( + chainUi = mapChainToUi(assetGroup.chain), + groupBalanceFiat = maskableFormatter.format { fiatFormatter.formatFiat(groupBalance(assetGroup), currency) } + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt new file mode 100644 index 0000000..a299abf --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.mappers + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.getAssetIconOrFallback +import io.novafoundation.nova.common.utils.formatting.formatAsChange +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenAssetUi +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.model.FractionPartStyling + +class TokenAssetFormatterFactory( + private val amountFormatter: AmountFormatter +) { + fun create(maskableFormatter: MaskableValueFormatter): TokenAssetFormatter { + return TokenAssetFormatter(maskableFormatter, amountFormatter) + } +} + +class TokenAssetFormatter( + private val maskableFormatter: MaskableValueFormatter, + private val amountFormatter: AmountFormatter +) : CommonAssetFormatter(maskableFormatter, amountFormatter) { + + fun mapGroupedAssetsToUi( + groupedTokens: GroupedList, + assetIconProvider: AssetIconProvider, + assetFilter: (groupId: String, List) -> List = { _, assets -> assets }, + groupBalance: (TokenAssetGroup) -> AssetBalance.Amount = { it.groupBalance.total }, + balance: (AssetBalance) -> AssetBalance.Amount = AssetBalance::total, + ): List { + return groupedTokens.mapKeys { (group, assets) -> mapTokenAssetGroupToUi(assetIconProvider, group, assets, groupBalance) } + .mapValues { (group, assets) -> + val assetModels = mapAssetsToAssetModels(assetIconProvider, group, assets, balance) + assetFilter(group.itemId, assetModels) + } + .toListWithHeaders() + .filterIsInstance() + } + + fun mapTokenAssetGroupToUi( + assetIconProvider: AssetIconProvider, + assetGroup: TokenAssetGroup, + assets: List, + groupBalance: (TokenAssetGroup) -> AssetBalance.Amount = { it.groupBalance.total } + ): TokenGroupUi { + val balance = groupBalance(assetGroup) + return TokenGroupUi( + itemId = assetGroup.groupId, + tokenIcon = assetIconProvider.getAssetIconOrFallback(assetGroup.tokenInfo.icon), + rate = mapCoinRateChange(assetGroup.tokenInfo.coinRate, assetGroup.tokenInfo.currency), + recentRateChange = assetGroup.tokenInfo.coinRate?.recentRateChange.orZero().formatAsChange(), + rateChangeColorRes = mapCoinRateChangeColorRes(assetGroup.tokenInfo.coinRate), + tokenSymbol = assetGroup.tokenInfo.symbol.value, + singleItemGroup = assetGroup.itemsCount <= 1, + balance = maskableFormatter.format { + amountFormatter.formatAmountToAmountModel( + amount = balance.amount, + token = assetGroup.tokenInfo.token, + config = AmountConfig( + includeAssetTicker = false, + tokenFractionPartStyling = FractionPartStyling.Styled(R.dimen.asset_balance_fraction_size) + ) + ) + }, + groupType = mapType(assets) + ) + } + + private fun mapAssetsToAssetModels( + assetIconProvider: AssetIconProvider, + group: TokenGroupUi, + assets: List, + balance: (AssetBalance) -> AssetBalance.Amount + ): List { + return assets.map { + TokenAssetUi( + group.getId(), + mapAssetToAssetModel(it.asset, balance(it.balanceWithOffChain)), + assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration), + mapChainToUi(it.chain) + ) + } + } + + private fun mapType( + assets: List, + ): TokenGroupUi.GroupType { + return if (assets.size == 1) { + TokenGroupUi.GroupType.SingleItem(assets.first().asset.token.configuration) + } else { + TokenGroupUi.GroupType.Group + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/Utils.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/Utils.kt new file mode 100644 index 0000000..43e3d3d --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/Utils.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.common.mappers + +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.utils.formatting.formatAsChange +import io.novafoundation.nova.common.utils.isNonNegative +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.model.TokenModel +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRateChange +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import java.math.BigDecimal + +fun mapCoinRateChange(coinRateChange: CoinRateChange?, currency: Currency): String { + val rateChange = coinRateChange?.rate + return mapCoinRateChange(rateChange.orZero(), currency) +} + +fun mapCoinRateChange(rate: BigDecimal, currency: Currency): String { + return rate.formatAsCurrency(currency) +} + +@ColorRes +fun mapCoinRateChangeColorRes(coinRateChange: CoinRateChange?): Int { + val rateChange = coinRateChange?.recentRateChange + + return when { + rateChange == null || rateChange.isZero -> R.color.text_secondary + rateChange.isNonNegative -> R.color.text_positive + else -> R.color.text_negative + } +} + +fun mapTokenToTokenModel(token: Token): TokenModel { + return with(token) { + TokenModel( + configuration = configuration, + rate = mapCoinRateChange(token.coinRate, token.currency), + recentRateChange = (coinRate?.recentRateChange ?: BigDecimal.ZERO).formatAsChange(), + rateChangeColorRes = mapCoinRateChangeColorRes(coinRate) + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/AssetDetailBalancesView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/AssetDetailBalancesView.kt new file mode 100644 index 0000000..155f8a4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/AssetDetailBalancesView.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail + +import android.content.Context +import android.util.AttributeSet +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_wallet_api.presentation.view.BalancesView + +class AssetDetailBalancesView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : BalancesView(context, attrs, defStyle) { + + val transferable = item(R.string.wallet_balance_transferable) + + val locked = item(R.string.wallet_balance_locked).apply { + setOwnDividerVisible(false) + title.setDrawableEnd(R.drawable.ic_info, paddingInDp = 4) + } + + fun showBalanceDetails(show: Boolean) { + expandableView.setExpandable(show) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/AssetDetailsModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/AssetDetailsModel.kt new file mode 100644 index 0000000..73731ac --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/AssetDetailsModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_assets.presentation.model.TokenModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class AssetDetailsModel( + val token: TokenModel, + val assetIcon: Icon, + val total: AmountModel, + val transferable: AmountModel, + val locked: AmountModel +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/BalanceDetailFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/BalanceDetailFragment.kt new file mode 100644 index 0000000..b12c473 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/BalanceDetailFragment.kt @@ -0,0 +1,187 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail + +import android.os.Bundle +import android.view.View +import androidx.core.view.isGone +import coil.ImageLoader +import com.google.android.material.bottomsheet.BottomSheetBehavior +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.hideKeyboard +import io.novafoundation.nova.common.utils.insets.applyBarMargin +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.view.setModelOrHide +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_assets.databinding.FragmentBalanceDetailBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.setupButSellActionButton +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.setupBuySellSelectorMixin +import io.novafoundation.nova.feature_assets.presentation.model.BalanceLocksModel +import io.novafoundation.nova.feature_assets.presentation.receive.view.LedgerNotSupportedWarningBottomSheet +import io.novafoundation.nova.feature_assets.presentation.transaction.history.setBannerModelOrHide +import io.novafoundation.nova.feature_assets.presentation.transaction.history.showState +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_api.presentation.view.setTotalAmount +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount +import javax.inject.Inject + +private const val KEY_TOKEN = "KEY_TOKEN" + +class BalanceDetailFragment : BaseFragment() { + + companion object { + + fun getBundle(assetPayload: AssetPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_TOKEN, assetPayload) + } + } + } + + override fun createBinding() = FragmentBalanceDetailBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun applyInsets(rootView: View) { + binder.root.applyNavigationBarInsets(consume = false) + binder.balanceDetailBack.applyBarMargin() + } + + override fun initViews() { + hideKeyboard() + + binder.transfersContainer.initializeBehavior(anchorView = binder.balanceDetailContent) + + binder.transfersContainer.setScrollingListener(viewModel::transactionsScrolled) + + binder.transfersContainer.setSlidingStateListener(::setRefreshEnabled) + + binder.transfersContainer.setTransactionClickListener(viewModel::transactionClicked) + + binder.transfersContainer.setFilterClickListener { viewModel.filterClicked() } + + binder.transfersContainer.setBannerClickListener() { viewModel.filterClicked() } + + binder.balanceDetailContainer.setOnRefreshListener { + viewModel.sync() + } + + binder.balanceDetailBack.setOnClickListener { viewModel.backClicked() } + + binder.balanceDetailActions.send.setOnClickListener { + viewModel.sendClicked() + } + + binder.balanceDetailActions.swap.setOnClickListener { + viewModel.swapClicked() + } + + binder.balanceDetailActions.receive.setOnClickListener { + viewModel.receiveClicked() + } + + binder.balanceDetailActions.gift.setOnClickListener { + viewModel.giftClicked() + } + + binder.balanceDetailsBalances.locked.setOnClickListener { + viewModel.lockedInfoClicked() + } + + binder.balanceDetailsMigrationAlert.setOnCloseClickListener { viewModel.closeMigrationAlert() } + } + + override fun inject() { + val token = argument(KEY_TOKEN) + + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .balanceDetailComponentFactory() + .create(this, token) + .inject(this) + } + + override fun subscribe(viewModel: BalanceDetailViewModel) { + observeBrowserEvents(viewModel) + + setupBuySellSelectorMixin(viewModel.buySellSelectorMixin) + setupButSellActionButton(viewModel.buySellSelectorMixin, binder.balanceDetailActions.buySell) + + viewModel.state.observe(binder.transfersContainer::showState) + viewModel.destinationMigrationBannerFlow.observe { + binder.transfersContainer.setBannerModelOrHide(it) + } + + viewModel.assetDetailsModel.observe { asset -> + binder.balanceDetailTokenIcon.setTokenIcon(asset.assetIcon, imageLoader) + binder.balanceDetailTokenName.text = asset.token.configuration.symbol.value + + binder.balanceDetailsBalances.setTotalAmount(asset.total) + binder.balanceDetailsBalances.transferable.showAmount(asset.transferable) + binder.balanceDetailsBalances.locked.showAmount(asset.locked) + } + + viewModel.supportExpandableBalanceDetails.observe { + binder.balanceDetailsBalances.showBalanceDetails(it) + } + + viewModel.priceChartFormatters.observe { + binder.priceChartView.setTextInjectors(it.price, it.priceChange, it.date) + } + + viewModel.priceChartTitle.observe { + binder.priceChartView.setTitle(it) + } + + viewModel.priceChartModels.observe { + if (it == null) { + binder.priceChartView.isGone = true + return@observe + } + + binder.priceChartView.setCharts(it) + } + + viewModel.hideRefreshEvent.observeEvent { + binder.balanceDetailContainer.isRefreshing = false + } + + viewModel.showLockedDetailsEvent.observeEvent(::showLockedDetails) + + viewModel.sendEnabled.observe(binder.balanceDetailActions.send::setEnabled) + + viewModel.swapButtonEnabled.observe(binder.balanceDetailActions.swap::setEnabled) + + viewModel.giftsButtonEnabled.observe(binder.balanceDetailActions.gift::setEnabled) + + viewModel.acknowledgeLedgerWarning.awaitableActionLiveData.observeEvent { + LedgerNotSupportedWarningBottomSheet( + context = requireContext(), + onSuccess = { it.onSuccess(Unit) }, + message = it.payload + ).show() + } + + viewModel.chainUI.observe { + binder.balanceDetailsBalances.setChain(it) + } + + viewModel.originMigrationAlertFlow.observe { + binder.balanceDetailsMigrationAlert.setModelOrHide(it) + } + } + + private fun setRefreshEnabled(bottomSheetState: Int) { + val bottomSheetCollapsed = BottomSheetBehavior.STATE_COLLAPSED == bottomSheetState + binder.balanceDetailContainer.isEnabled = bottomSheetCollapsed + } + + private fun showLockedDetails(model: BalanceLocksModel) { + LockedTokensBottomSheet(requireContext(), model).show() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/BalanceDetailViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/BalanceDetailViewModel.kt new file mode 100644 index 0000000..b2963c2 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/BalanceDetailViewModel.kt @@ -0,0 +1,483 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfig +import io.novafoundation.nova.feature_ahm_api.presentation.getChainMigrationDateFormat +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.locks.BalanceLocksInteractor +import io.novafoundation.nova.feature_assets.domain.price.AssetPriceChart +import io.novafoundation.nova.feature_assets.domain.price.ChartsInteractor +import io.novafoundation.nova.feature_assets.domain.send.SendInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.mapTokenToTokenModel +import io.novafoundation.nova.feature_assets.presentation.model.BalanceLocksModel +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterPayload +import io.novafoundation.nova.feature_assets.presentation.transaction.history.TransactionHistoryBannerModel +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryMixin +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryUi +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.PriceChartModel +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.RealDateChartTextInjector +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.RealPriceChangeTextInjector +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.RealPricePriceTextInjector +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.unlabeledReserves +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.FiatConfig +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.balanceId +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.mapBalanceIdToUi +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_api.presentation.model.fullChainAssetId +import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG + +private const val ORIGIN_MIGRATION_ALERT = "ORIGIN_MIGRATION_ALERT" + +class BalanceDetailViewModel( + private val walletInteractor: WalletInteractor, + private val balanceLocksInteractor: BalanceLocksInteractor, + private val sendInteractor: SendInteractor, + private val router: AssetsRouter, + private val assetPayload: AssetPayload, + private val transactionHistoryMixin: TransactionHistoryMixin, + private val accountUseCase: SelectedAccountUseCase, + private val resourceManager: ResourceManager, + private val currencyInteractor: CurrencyInteractor, + private val controllableAssetCheck: ControllableAssetCheckMixin, + private val externalBalancesInteractor: ExternalBalancesInteractor, + private val swapAvailabilityInteractor: SwapAvailabilityInteractor, + private val assetIconProvider: AssetIconProvider, + private val chartsInteractor: ChartsInteractor, + private val buySellSelectorMixinFactory: BuySellSelectorMixinFactory, + private val amountFormatter: AmountFormatter, + private val chainMigrationInfoUseCase: ChainMigrationInfoUseCase, + private val giftsAvailableGiftAssetsUseCase: AvailableGiftAssetsUseCase, + private val giftsRestrictionCheckMixin: GiftsRestrictionCheckMixin +) : BaseViewModel(), TransactionHistoryUi by transactionHistoryMixin, Browserable { + + override val openBrowserEvent = MutableLiveData>() + + val acknowledgeLedgerWarning = controllableAssetCheck.acknowledgeLedgerWarning + + private val _hideRefreshEvent = MutableLiveData>() + val hideRefreshEvent: LiveData> = _hideRefreshEvent + + private val _showLockedDetailsEvent = MutableLiveData>() + val showLockedDetailsEvent: LiveData> = _showLockedDetailsEvent + + private val chainFlow = walletInteractor.chainFlow(assetPayload.chainId) + .shareInBackground() + + private val assetFlow = walletInteractor.assetFlow(assetPayload.chainId, assetPayload.chainAssetId) + .inBackground() + .share() + + private val chainAssetFlow = assetFlow.map { it.token.configuration } + .distinctUntilChangedBy { it.fullId } + + private val balanceLocksFlow = balanceLocksInteractor.balanceLocksFlow(assetPayload.chainId, assetPayload.chainAssetId) + .catch { error -> + Log.e(LOG_TAG, "Failed to load balance locks: ${error.message}") + emit(emptyList()) + } + .shareInBackground() + + private val balanceHoldsFlow = balanceLocksInteractor.balanceHoldsFlow(assetPayload.chainId, assetPayload.chainAssetId) + .catch { error -> + Log.e(LOG_TAG, "Failed to load balance holds: ${error.message}") + emit(emptyList()) + } + .shareInBackground() + + private val selectedAccountFlow = accountUseCase.selectedMetaAccountFlow() + .share() + + private val externalBalancesFlow = externalBalancesInteractor.observeExternalBalances(assetPayload.fullChainAssetId) + .onStart { emit(emptyList()) } + .shareInBackground() + + private val migrationConfigFlow = chainMigrationInfoUseCase.observeMigrationConfigOrNull(assetPayload.chainId, assetPayload.chainAssetId) + .shareInBackground() + + val assetDetailsModel = combine(assetFlow, externalBalancesFlow) { asset, externalBalances -> + mapAssetToUi(asset, externalBalances) + } + .inBackground() + .share() + + val supportExpandableBalanceDetails = assetFlow.map { it.totalInPlanks.isPositive() } + .shareInBackground() + + private val lockedBalanceModel = combine(balanceLocksFlow, balanceHoldsFlow, externalBalancesFlow, assetFlow) { locks, holds, externalBalances, asset -> + mapBalanceLocksToUi(locks, holds, externalBalances, asset) + } + .inBackground() + .share() + + val buySellSelectorMixin = buySellSelectorMixinFactory.create( + BuySellSelectorMixin.SelectorType.Asset(assetPayload.chainId, assetPayload.chainAssetId), + viewModelScope + ) + + val chainUI = chainFlow.map { mapChainToUi(it) } + + val swapButtonEnabled = chainAssetFlow.flatMapLatest { + swapAvailabilityInteractor.swapAvailableFlow(it, viewModelScope) + } + .onStart { emit(false) } + .catch { error -> + Log.e(LOG_TAG, "Failed to check swap availability: ${error.message}") + emit(false) + } + .shareInBackground() + + val giftsButtonEnabled = chainAssetFlow.map { + giftsAvailableGiftAssetsUseCase.isGiftsAvailable(it) + } + .onStart { emit(false) } + .catch { error -> + Log.e(LOG_TAG, "Failed to check gifts availability: ${error.message}") + emit(false) + } + .shareInBackground() + + val sendEnabled = assetFlow.map { + sendInteractor.areTransfersEnabled(it.token.configuration) + } + .inBackground() + .share() + + val priceChartTitle = assetFlow.map { + val tokenName = it.token.configuration.symbol.value + resourceManager.getString(R.string.price_chart_title, tokenName) + }.shareInBackground() + + val priceChartFormatters: Flow = assetFlow.map { asset -> + val lastCoinRate = asset.token.coinRate?.rate + val currency = currencyInteractor.getSelectedCurrency() + + PriceChartTextInjectors( + RealPricePriceTextInjector(currency, lastCoinRate), + RealPriceChangeTextInjector(resourceManager, currency), + RealDateChartTextInjector(resourceManager) + ) + }.shareInBackground() + + private val dateFormatter = getChainMigrationDateFormat() + + val originMigrationAlertFlow = combine( + migrationConfigFlow, + chainFlow, + selectedAccountFlow, + chainMigrationInfoUseCase.observeInfoShouldBeHidden(ORIGIN_MIGRATION_ALERT, assetPayload.chainId, assetPayload.chainAssetId) + ) { configWithChains, chain, metaAccount, shouldBeHidden -> + if (shouldBeHidden) return@combine null + if (configWithChains == null) return@combine null + if (configWithChains.originAsset.notMatchWithBalanceAsset()) return@combine null + if (!metaAccount.hasAccountIn(configWithChains.destinationChain)) return@combine null + + val config = configWithChains.config + val sourceAsset = configWithChains.originAsset + val destinationChain = configWithChains.destinationChain + val formattedDate = dateFormatter.format(config.timeStartAt) + AlertModel( + style = AlertView.Style.fromPreset(AlertView.StylePreset.INFO), + message = resourceManager.getString(R.string.asset_details_source_asset_alert_title, sourceAsset.symbol.value, destinationChain.name), + subMessage = resourceManager.getString( + R.string.asset_details_source_asset_alert_message, + formattedDate, + sourceAsset.symbol.value, + destinationChain.name + ), + linkAction = AlertModel.ActionModel(resourceManager.getString(R.string.common_learn_more)) { learnMoreMigrationClicked(config) }, + buttonAction = AlertModel.ActionModel( + resourceManager.getString(R.string.asset_details_source_asset_alert_button, destinationChain.name), + { openAssetDetails(chainData = configWithChains.config.destinationData) } + ) + ) + }.shareInBackground() + + val destinationMigrationBannerFlow = combine( + migrationConfigFlow, + chainFlow, + selectedAccountFlow, + ) { configWithChains, chain, metaAccount -> + if (configWithChains == null) return@combine null + if (configWithChains.destinationAsset.notMatchWithBalanceAsset()) return@combine null + if (!metaAccount.hasAccountIn(configWithChains.originChain)) return@combine null + + val sourceAsset = configWithChains.originAsset + val sourceChain = configWithChains.originChain + TransactionHistoryBannerModel( + resourceManager.getString(R.string.transaction_history_migration_source_message, sourceAsset.symbol.value, sourceChain.name), + { openAssetDetails(chainData = configWithChains.config.originData) } + ) + }.shareInBackground() + + private val priceCharts: Flow?> = assetFlow.map { it.token.configuration.priceId } + .distinctUntilChanged() + .flatMapLatest { + val priceId = it ?: return@flatMapLatest flowOf { null } + chartsInteractor.chartsFlow(priceId) + }.shareInBackground() + + val priceChartModels = priceCharts.map { charts -> + charts?.map { mapChartsToUi(it) } + }.shareInBackground() + + init { + sync() + } + + override fun onCleared() { + super.onCleared() + + transactionHistoryMixin.cancel() + } + + fun transactionsScrolled(index: Int) { + transactionHistoryMixin.scrolled(index) + } + + fun filterClicked() { + val payload = TransactionHistoryFilterPayload(assetPayload) + router.openFilter(payload) + } + + fun sync() { + launch { + runCatching { + swapAvailabilityInteractor.sync(viewModelScope) + + val currency = currencyInteractor.getSelectedCurrency() + val deferredAssetSync = async { walletInteractor.syncAssetsRates(currency) } + val deferredTransactionsSync = async { transactionHistoryMixin.syncFirstOperationsPage() } + + awaitAll(deferredAssetSync, deferredTransactionsSync) + }.onFailure { error -> + Log.e(LOG_TAG, "Sync failed: ${error.message}") + } + + _hideRefreshEvent.value = Event(Unit) + } + } + + fun backClicked() { + router.back() + } + + fun sendClicked() { + router.openSend(SendPayload.SpecifiedOrigin(assetPayload)) + } + + fun receiveClicked() = checkControllableAsset { + router.openReceive(assetPayload) + } + + fun swapClicked() { + launch { + val chainAsset = assetFlow.first().token.configuration + val payload = SwapSettingsPayload.DefaultFlow(chainAsset.fullId.toAssetPayload()) + router.openSwapSetupAmount(payload) + } + } + + fun giftClicked() = launchUnit { + giftsRestrictionCheckMixin.checkRestrictionAndDo { + router.openGiftsByAsset(assetPayload) + } + } + + fun lockedInfoClicked() = launch { + val balanceLocks = lockedBalanceModel.first() + _showLockedDetailsEvent.value = Event(balanceLocks) + } + + fun closeMigrationAlert() { + chainMigrationInfoUseCase.markMigrationInfoAsHidden(ORIGIN_MIGRATION_ALERT, assetPayload.chainId, assetPayload.chainAssetId) + } + + private fun checkControllableAsset(action: () -> Unit) { + launch { + val metaAccount = selectedAccountFlow.first() + val chainAsset = assetFlow.first().token.configuration + controllableAssetCheck.check(metaAccount, chainAsset) { action() } + } + } + + private fun mapAssetToUi(asset: Asset, externalBalances: List): AssetDetailsModel { + val totalContributedPlanks = externalBalances.sumByBigInteger { it.amount } + val totalContributed = asset.token.amountFromPlanks(totalContributedPlanks) + + return AssetDetailsModel( + token = mapTokenToTokenModel(asset.token), + total = amountFormatter.formatAmountToAmountModel( + asset.total + totalContributed, + asset, + AmountConfig(useTokenAbbreviation = false, fiatAbbreviation = FiatConfig.AbbreviationStyle.NO_ABBREVIATION) + ), + transferable = amountFormatter.formatAmountToAmountModel(asset.transferable, asset), + locked = amountFormatter.formatAmountToAmountModel(asset.locked + totalContributed, asset), + assetIcon = assetIconProvider.getAssetIconOrFallback(asset.token.configuration) + ) + } + + private fun openAssetDetails(chainData: ChainMigrationConfig.ChainData) { + router.openAssetDetails( + AssetPayload( + chainId = chainData.chainId, + chainAssetId = chainData.assetId + ) + ) + } + + private fun mapBalanceLocksToUi( + balanceLocks: List, + holds: List, + externalBalances: List, + asset: Asset + ): BalanceLocksModel { + val mappedLocks = balanceLocks.map { + BalanceLocksModel.Lock( + mapBalanceIdToUi(resourceManager, it.id.value), + amountFormatter.formatAmountToAmountModel(it.amountInPlanks, asset) + ) + } + + val mappedHolds = holds.map { + BalanceLocksModel.Lock( + mapBalanceIdToUi(resourceManager, it.identifier), + amountFormatter.formatAmountToAmountModel(it.amountInPlanks, asset) + ) + } + + val unlabeledReserves = asset.unlabeledReserves(holds) + + val reservedBalance = BalanceLocksModel.Lock( + resourceManager.getString(R.string.wallet_balance_reserved), + amountFormatter.formatAmountToAmountModel(unlabeledReserves, asset) + ) + + val external = externalBalances.map { externalBalance -> + BalanceLocksModel.Lock( + name = mapBalanceIdToUi(resourceManager, externalBalance.type.balanceId), + amount = amountFormatter.formatAmountToAmountModel(externalBalance.amount, asset) + ) + } + + val locks = buildList { + addAll(mappedLocks) + addAll(mappedHolds) + add(reservedBalance) + addAll(external) + } + + return BalanceLocksModel(locks) + } + + private fun mapChartsToUi(assetPriceChart: AssetPriceChart): PriceChartModel { + val buttonText = mapButtonText(assetPriceChart.range) + + return if (assetPriceChart.chart is ExtendedLoadingState.Loaded) { + val periodName = mapPeriodName(assetPriceChart.range) + val supportTimeShowing = supportTimeShowing(assetPriceChart.range) + val mappedChart = assetPriceChart.chart.data.map { PriceChartModel.Chart.Price(it.timestamp, it.rate) } + PriceChartModel.Chart(buttonText, periodName, supportTimeShowing, mappedChart) + } else { + PriceChartModel.Loading(buttonText) + } + } + + private fun mapButtonText(pricePeriod: PricePeriod): String { + val buttonTextRes = when (pricePeriod) { + PricePeriod.DAY -> R.string.price_chart_day + PricePeriod.WEEK -> R.string.price_chart_week + PricePeriod.MONTH -> R.string.price_chart_month + PricePeriod.YEAR -> R.string.price_chart_year + PricePeriod.MAX -> R.string.price_chart_max + } + + return resourceManager.getString(buttonTextRes) + } + + private fun mapPeriodName(pricePeriod: PricePeriod): String { + val periodNameRes = when (pricePeriod) { + PricePeriod.DAY -> R.string.price_charts_period_today + PricePeriod.WEEK -> R.string.price_charts_period_week + PricePeriod.MONTH -> R.string.price_charts_period_month + PricePeriod.YEAR -> R.string.price_charts_period_year + PricePeriod.MAX -> R.string.price_charts_period_all + } + + return resourceManager.getString(periodNameRes) + } + + private fun supportTimeShowing(pricePeriod: PricePeriod): Boolean { + return when (pricePeriod) { + PricePeriod.DAY, PricePeriod.WEEK, PricePeriod.MONTH -> true + PricePeriod.YEAR, PricePeriod.MAX -> false + } + } + + private fun learnMoreMigrationClicked(config: ChainMigrationConfig) { + launch { + openBrowserEvent.value = Event(config.wikiURL) + } + } + + private fun Chain.Asset.notMatchWithBalanceAsset(): Boolean { + return assetPayload.chainId != chainId || assetPayload.chainAssetId != id + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/LockedTokensBottomSheet.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/LockedTokensBottomSheet.kt new file mode 100644 index 0000000..6042e01 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/LockedTokensBottomSheet.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail + +import android.content.Context +import android.os.Bundle +import android.view.ViewGroup +import androidx.core.view.updateLayoutParams +import androidx.core.view.updateMargins +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.feature_assets.presentation.model.BalanceLocksModel +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class LockedTokensBottomSheet( + context: Context, + private val balanceLocks: BalanceLocksModel +) : FixedListBottomSheet(context, viewConfiguration = ViewConfiguration.default(context)) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.wallet_balance_locked) + val viewItems = createViewItems(balanceLocks.locks) + viewItems.forEach { addItem(it) } + } + + private fun createViewItems(locks: List): List { + return locks.map(::createViewItem) + } + + private fun createViewItem(lock: BalanceLocksModel.Lock): TableCellView { + return TableCellView.createTableCellView(context).apply { + setOwnDividerVisible(false) + setTitle(lock.name) + showAmount(lock.amount) + updateLayoutParams { + updateMargins( + left = getCommonPadding(), + right = getCommonPadding() + ) + } + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/PriceChartTextInjectors.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/PriceChartTextInjectors.kt new file mode 100644 index 0000000..3c51c98 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/PriceChartTextInjectors.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail + +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.DateChartTextInjector +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.PriceChangeTextInjector +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.PriceTextInjector + +class PriceChartTextInjectors( + val price: PriceTextInjector, + val priceChange: PriceChangeTextInjector, + val date: DateChartTextInjector +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/deeplink/AssetDetailsDeepLinkConfigurator.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/deeplink/AssetDetailsDeepLinkConfigurator.kt new file mode 100644 index 0000000..74f6ed4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/deeplink/AssetDetailsDeepLinkConfigurator.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink + +import android.net.Uri +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.addParamIfNotNull + +class AssetDetailsDeepLinkData( + val accountAddress: String?, + val chainId: String, + val assetId: Int +) + +class AssetDetailsDeepLinkConfigurator( + private val linkBuilderFactory: LinkBuilderFactory +) : DeepLinkConfigurator { + + val action = "open" + val screen = "asset" + val deepLinkPrefix = "/$action/$screen" + val addressParam = "address" + val chainIdParam = "chainId" + val assetIdParam = "assetId" + + override fun configure(payload: AssetDetailsDeepLinkData, type: DeepLinkConfigurator.Type): Uri { + return linkBuilderFactory.newLink(type) + .setAction(action) + .setScreen(screen) + .addParamIfNotNull(addressParam, payload.accountAddress) + .addParam(chainIdParam, payload.chainId) + .addParam(assetIdParam, payload.assetId.toString()) + .build() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/deeplink/AssetDetailsDeepLinkHandler.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/deeplink/AssetDetailsDeepLinkHandler.kt new file mode 100644 index 0000000..8ce48fd --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/deeplink/AssetDetailsDeepLinkHandler.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.findMetaAccountOrThrow +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.withContext + +class AssetDetailsDeepLinkHandler( + private val router: AssetsRouter, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val automaticInteractionGate: AutomaticInteractionGate, + private val assetDetailsDeepLinkConfigurator: AssetDetailsDeepLinkConfigurator +) : DeepLinkHandler { + + override val callbackFlow = MutableSharedFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + + return path.startsWith(assetDetailsDeepLinkConfigurator.deepLinkPrefix) + } + + override suspend fun handleDeepLink(data: Uri): Result = runCatching { + automaticInteractionGate.awaitInteractionAllowed() + + val address = data.getAddress() + val chainId = data.getChainIdOrPolkadot() + val assetId = data.getAssetId() ?: throw IllegalStateException() + + val chain = chainRegistry.getChain(chainId) + require(chain.isEnabled) + + address?.let { selectMetaAccount(chain, address) } + + val payload = AssetPayload(chainId, assetId) + router.openAssetDetailsFromDeepLink(payload) + } + + private suspend fun selectMetaAccount(chain: Chain, address: String) = withContext(Dispatchers.Default) { + val metaAccount = accountRepository.findMetaAccountOrThrow(chain.accountIdOf(address), chain.id) + accountRepository.selectMetaAccount(metaAccount.id) + } + + private fun Uri.getAddress(): String? { + return getQueryParameter(assetDetailsDeepLinkConfigurator.addressParam) + } + + private fun Uri.getChainIdOrPolkadot(): String { + return getQueryParameter(assetDetailsDeepLinkConfigurator.chainIdParam) ?: ChainGeneses.POLKADOT + } + + private fun Uri.getAssetId(): Int? { + return getQueryParameter(assetDetailsDeepLinkConfigurator.assetIdParam)?.toIntOrNull() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/di/BalanceDetailComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/di/BalanceDetailComponent.kt new file mode 100644 index 0000000..e54d9de --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/di/BalanceDetailComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_assets.presentation.balance.detail.BalanceDetailFragment + +@Subcomponent( + modules = [ + BalanceDetailModule::class + ] +) +@ScreenScope +interface BalanceDetailComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance assetPayload: AssetPayload, + ): BalanceDetailComponent + } + + fun inject(fragment: BalanceDetailFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/di/BalanceDetailModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/di/BalanceDetailModule.kt new file mode 100644 index 0000000..50e2f8b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/detail/di/BalanceDetailModule.kt @@ -0,0 +1,145 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.detail.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.locks.BalanceLocksInteractor +import io.novafoundation.nova.feature_assets.domain.locks.BalanceLocksInteractorImpl +import io.novafoundation.nova.feature_assets.domain.price.ChartsInteractor +import io.novafoundation.nova.feature_assets.domain.send.SendInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.detail.BalanceDetailViewModel +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.HistoryFiltersProviderFactory +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryMixin +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryProvider +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class BalanceDetailModule { + + @Provides + @ScreenScope + fun provideBalanceLocksInteractor( + chainRegistry: ChainRegistry, + balanceLocksRepository: BalanceLocksRepository, + balanceHoldsRepository: BalanceHoldsRepository, + accountRepository: AccountRepository + ): BalanceLocksInteractor { + return BalanceLocksInteractorImpl( + chainRegistry = chainRegistry, + balanceLocksRepository = balanceLocksRepository, + balanceHoldsRepository = balanceHoldsRepository, + accountRepository = accountRepository + ) + } + + @Provides + @ScreenScope + fun provideTransferHistoryMixin( + walletInteractor: WalletInteractor, + assetsRouter: AssetsRouter, + historyFiltersProviderFactory: HistoryFiltersProviderFactory, + assetSourceRegistry: AssetSourceRegistry, + resourceManager: ResourceManager, + assetPayload: AssetPayload, + addressDisplayUseCase: AddressDisplayUseCase, + chainRegistry: ChainRegistry, + currencyRepository: CurrencyRepository, + assetIconProvider: AssetIconProvider + ): TransactionHistoryMixin { + return TransactionHistoryProvider( + walletInteractor = walletInteractor, + router = assetsRouter, + historyFiltersProviderFactory = historyFiltersProviderFactory, + resourceManager = resourceManager, + addressDisplayUseCase = addressDisplayUseCase, + assetsSourceRegistry = assetSourceRegistry, + chainRegistry = chainRegistry, + chainId = assetPayload.chainId, + assetId = assetPayload.chainAssetId, + currencyRepository = currencyRepository, + assetIconProvider + ) + } + + @Provides + @IntoMap + @ViewModelKey(BalanceDetailViewModel::class) + fun provideViewModel( + walletInteractor: WalletInteractor, + balanceLocksInteractor: BalanceLocksInteractor, + sendInteractor: SendInteractor, + router: AssetsRouter, + transactionHistoryMixin: TransactionHistoryMixin, + assetPayload: AssetPayload, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + currencyInteractor: CurrencyInteractor, + controllableAssetCheckMixin: ControllableAssetCheckMixin, + externalBalancesInteractor: ExternalBalancesInteractor, + swapAvailabilityInteractor: SwapAvailabilityInteractor, + assetIconProvider: AssetIconProvider, + chartsInteractor: ChartsInteractor, + buySellSelectorMixinFactory: BuySellSelectorMixinFactory, + amountFormatter: AmountFormatter, + chainMigrationInfoUseCase: ChainMigrationInfoUseCase, + giftsAvailableGiftAssetsUseCase: AvailableGiftAssetsUseCase, + giftsRestrictionCheckMixin: GiftsRestrictionCheckMixin, + ): ViewModel { + return BalanceDetailViewModel( + walletInteractor = walletInteractor, + balanceLocksInteractor = balanceLocksInteractor, + sendInteractor = sendInteractor, + router = router, + assetPayload = assetPayload, + transactionHistoryMixin = transactionHistoryMixin, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + currencyInteractor = currencyInteractor, + controllableAssetCheck = controllableAssetCheckMixin, + externalBalancesInteractor = externalBalancesInteractor, + swapAvailabilityInteractor = swapAvailabilityInteractor, + assetIconProvider = assetIconProvider, + chartsInteractor = chartsInteractor, + buySellSelectorMixinFactory = buySellSelectorMixinFactory, + amountFormatter = amountFormatter, + chainMigrationInfoUseCase = chainMigrationInfoUseCase, + giftsRestrictionCheckMixin = giftsRestrictionCheckMixin, + giftsAvailableGiftAssetsUseCase = giftsAvailableGiftAssetsUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): BalanceDetailViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(BalanceDetailViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/BalanceListFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/BalanceListFragment.kt new file mode 100644 index 0000000..d1bc7a6 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/BalanceListFragment.kt @@ -0,0 +1,260 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list + +import android.view.View +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.EditablePlaceholderAdapter +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.hideKeyboard +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAnimationSettings +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimator +import io.novafoundation.nova.common.utils.recyclerView.space.SpaceBetween +import io.novafoundation.nova.common.utils.recyclerView.space.addSpaceItemDecoration +import io.novafoundation.nova.common.view.PlaceholderModel +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.FragmentBalanceListBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.BalanceBreakdownBottomSheet +import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.AssetBaseDecoration +import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetTokensDecoration +import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetTokensItemAnimator +import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter +import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.applyDefaultTo +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.setupBuySellSelectorMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.createForAssets +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_assets.presentation.balance.list.view.AssetsHeaderAdapter +import io.novafoundation.nova.feature_assets.presentation.balance.list.view.AssetsHeaderHolder +import io.novafoundation.nova.feature_assets.presentation.balance.list.view.ManageAssetsAdapter +import io.novafoundation.nova.feature_assets.presentation.balance.list.view.ManageAssetsHolder +import io.novafoundation.nova.feature_banners_api.presentation.BannerHolder +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannerAdapter +import io.novafoundation.nova.feature_banners_api.presentation.bindWithAdapter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import javax.inject.Inject + +class BalanceListFragment : + BaseFragment(), + BalanceListAdapter.ItemAssetHandler, + AssetsHeaderAdapter.Handler, + ManageAssetsAdapter.Handler { + + override fun createBinding() = FragmentBalanceListBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private var balanceBreakdownBottomSheet: BalanceBreakdownBottomSheet? = null + + private val headerAdapter by lazy(LazyThreadSafetyMode.NONE) { + AssetsHeaderAdapter(this) + } + + private val bannerAdapter: PromotionBannerAdapter by lazy(LazyThreadSafetyMode.NONE) { + PromotionBannerAdapter(closable = true) + } + + private val manageAssetsAdapter by lazy(LazyThreadSafetyMode.NONE) { + ManageAssetsAdapter(this) + } + + private val emptyAssetsPlaceholder by lazy(LazyThreadSafetyMode.NONE) { + EditablePlaceholderAdapter( + model = getAssetsPlaceholderModel(), + clickListener = { buySellClicked() } + ) + } + + private val assetsAdapter by lazy(LazyThreadSafetyMode.NONE) { + BalanceListAdapter(imageLoader, this) + } + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + ConcatAdapter(headerAdapter, bannerAdapter, manageAssetsAdapter, emptyAssetsPlaceholder, assetsAdapter) + } + + override fun applyInsets(rootView: View) { + binder.balanceListAssets.applyStatusBarInsets() + } + + override fun initViews() { + hideKeyboard() + + setupRecyclerView() + + binder.walletContainer.setOnRefreshListener { + viewModel.fullSync() + } + } + + private fun setupRecyclerView() { + binder.balanceListAssets.setHasFixedSize(true) + binder.balanceListAssets.adapter = adapter + + setupAssetsDecorationForRecyclerView() + setupRecyclerViewSpacing() + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .balanceListComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: BalanceListViewModel) { + setupBuySellSelectorMixin(viewModel.buySellSelectorMixin) + + viewModel.bannersMixin.bindWithAdapter(bannerAdapter) { + binder.balanceListAssets.invalidateItemDecorations() + } + + viewModel.assetListMixin.assetModelsFlow.observe { + assetsAdapter.submitList(it) { + binder.balanceListAssets.invalidateItemDecorations() + } + } + + viewModel.maskingModeEnableFlow.observe(headerAdapter::setMaskingEnabled) + viewModel.totalBalanceFlow.observe(headerAdapter::setTotalBalance) + viewModel.selectedWalletModelFlow.observe(headerAdapter::setSelectedWallet) + viewModel.shouldShowPlaceholderFlow.observe(emptyAssetsPlaceholder::show) + viewModel.nftCountFlow.observe(headerAdapter::setNftCountLabel) + viewModel.nftPreviewsUi.observe(headerAdapter::setNftPreviews) + + viewModel.hideRefreshEvent.observeEvent { + binder.walletContainer.isRefreshing = false + } + + viewModel.balanceBreakdownFlow.observe { + if (balanceBreakdownBottomSheet?.isShowing == true) { + balanceBreakdownBottomSheet?.setBalanceBreakdown(it) + } + } + + viewModel.showBalanceBreakdownEvent.observeEvent { totalBalanceBreakdown -> + if (balanceBreakdownBottomSheet == null) { + balanceBreakdownBottomSheet = BalanceBreakdownBottomSheet(requireContext()) + + balanceBreakdownBottomSheet?.setOnDismissListener { + balanceBreakdownBottomSheet = null + } + } + balanceBreakdownBottomSheet?.setOnShowListener { + balanceBreakdownBottomSheet?.setBalanceBreakdown(totalBalanceBreakdown) + } + balanceBreakdownBottomSheet?.show() + } + + viewModel.walletConnectAccountSessionsUI.observe(headerAdapter::setWalletConnectModel) + viewModel.pendingOperationsCountModel.observe(headerAdapter::setPendingOperationsCountModel) + viewModel.filtersIndicatorIcon.observe(headerAdapter::setFilterIconRes) + viewModel.assetViewModeModelFlow.observe { manageAssetsAdapter.setAssetViewModeModel(it) } + } + + override fun assetClicked(asset: Chain.Asset) { + viewModel.assetClicked(asset) + } + + override fun tokenGroupClicked(tokenGroup: TokenGroupUi) { + if (tokenGroup.groupType is TokenGroupUi.GroupType.SingleItem) { + viewModel.assetClicked(tokenGroup.groupType.asset) + } else { + val itemAnimator = binder.balanceListAssets.itemAnimator as AssetTokensItemAnimator + itemAnimator.prepareForAnimation() + + viewModel.assetListMixin.expandToken(tokenGroup) + } + } + + override fun totalBalanceClicked() { + viewModel.balanceBreakdownClicked() + } + + override fun manageClicked() { + viewModel.manageClicked() + } + + override fun searchClicked() { + viewModel.searchClicked() + } + + override fun avatarClicked() { + viewModel.avatarClicked() + } + + override fun goToNftsClicked() { + viewModel.goToNftsClicked() + } + + override fun walletConnectClicked() { + viewModel.walletConnectClicked() + } + + override fun maskClicked() { + viewModel.toggleMasking() + } + + override fun sendClicked() { + viewModel.sendClicked() + } + + override fun receiveClicked() { + viewModel.receiveClicked() + } + + override fun buySellClicked() { + viewModel.buySellClicked() + } + + override fun novaCardClick() { + viewModel.novaCardClicked() + } + + override fun pendingOperationsClicked() { + viewModel.pendingOperationsClicked() + } + + override fun assetViewModeClicked() { + viewModel.switchViewMode() + } + + override fun swapClicked() { + viewModel.swapClicked() + } + + override fun giftClicked() { + viewModel.giftClicked() + } + + private fun setupRecyclerViewSpacing() { + binder.balanceListAssets.addSpaceItemDecoration { + add(SpaceBetween(AssetsHeaderHolder, BannerHolder, spaceDp = 4)) + add(SpaceBetween(BannerHolder, ManageAssetsHolder, spaceDp = 4)) + add(SpaceBetween(AssetsHeaderHolder, ManageAssetsHolder, spaceDp = 24)) + } + } + + private fun setupAssetsDecorationForRecyclerView() { + val animationSettings = ExpandableAnimationSettings.createForAssets() + val animator = ExpandableAnimator(binder.balanceListAssets, animationSettings, assetsAdapter) + + AssetBaseDecoration.applyDefaultTo(binder.balanceListAssets, assetsAdapter) + + binder.balanceListAssets.addItemDecoration(AssetTokensDecoration(requireContext(), assetsAdapter, animator)) + binder.balanceListAssets.itemAnimator = AssetTokensItemAnimator(animationSettings, animator) + } + + private fun getAssetsPlaceholderModel() = PlaceholderModel( + text = getString(R.string.wallet_assets_empty), + imageRes = R.drawable.ic_planet_outline, + buttonText = getString(R.string.assets_buy_tokens_placeholder_button) + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/BalanceListViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/BalanceListViewModel.kt new file mode 100644 index 0000000..d42b1b5 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/BalanceListViewModel.kt @@ -0,0 +1,407 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.model.AssetViewMode +import io.novafoundation.nova.common.data.model.MaskingMode +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.formatting.formatAsPercentage +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.assets.list.AssetsListInteractor +import io.novafoundation.nova.feature_assets.domain.assets.list.NftPreviews +import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdown +import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdownInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownAmount +import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownItem +import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.BalanceBreakdownTotal +import io.novafoundation.nova.feature_assets.presentation.balance.breakdown.model.TotalBalanceBreakdownModel +import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetListMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.NftPreviewUi +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.TotalBalanceModel +import io.novafoundation.nova.feature_assets.presentation.balance.list.view.AssetViewModeModel +import io.novafoundation.nova.feature_assets.presentation.balance.list.view.PendingOperationsCountModel +import io.novafoundation.nova.feature_assets.presentation.novacard.common.NovaCardRestrictionCheckMixin +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.assetsSource +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.mapBalanceIdToUi +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.FiatConfig +import io.novafoundation.nova.feature_wallet_api.presentation.model.FractionPartStyling +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_api.presentation.mapNumberOfActiveSessionsToUi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +private typealias SyncAction = suspend (MetaAccount) -> Unit + +class BalanceListViewModel( + private val promotionBannersMixinFactory: PromotionBannersMixinFactory, + private val bannerSourceFactory: BannersSourceFactory, + private val walletInteractor: WalletInteractor, + private val assetsListInteractor: AssetsListInteractor, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val router: AssetsRouter, + private val currencyInteractor: CurrencyInteractor, + private val balanceBreakdownInteractor: BalanceBreakdownInteractor, + private val resourceManager: ResourceManager, + private val walletConnectSessionsUseCase: WalletConnectSessionsUseCase, + private val swapAvailabilityInteractor: SwapAvailabilityInteractor, + private val assetListMixinFactory: AssetListMixinFactory, + private val amountFormatter: AmountFormatter, + private val fiatFormatter: FiatFormatter, + private val maskableValueFormatterProvider: MaskableValueFormatterProvider, + private val buySellSelectorMixinFactory: BuySellSelectorMixinFactory, + private val multisigPendingOperationsService: MultisigPendingOperationsService, + private val novaCardRestrictionCheckMixin: NovaCardRestrictionCheckMixin, + private val maskingModeUseCase: MaskingModeUseCase, + private val giftsRestrictionCheckMixin: GiftsRestrictionCheckMixin +) : BaseViewModel() { + + private val maskableAmountFormatterFlow = maskableValueFormatterProvider.provideFormatter() + .shareInBackground() + + private val _hideRefreshEvent = MutableLiveData>() + val hideRefreshEvent: LiveData> = _hideRefreshEvent + + private val _showBalanceBreakdownEvent = MutableLiveData>() + val showBalanceBreakdownEvent: LiveData> = _showBalanceBreakdownEvent + + val bannersMixin = promotionBannersMixinFactory.create(bannerSourceFactory.assetsSource(), viewModelScope) + + private val selectedCurrency = currencyInteractor.observeSelectCurrency() + .inBackground() + .share() + + private val fullSyncActions: List = listOf( + { walletInteractor.syncAssetsRates(selectedCurrency.first()) }, + walletInteractor::syncAllNfts + ) + + val buySellSelectorMixin = buySellSelectorMixinFactory.create(BuySellSelectorMixin.SelectorType.AllAssets, viewModelScope) + + val assetListMixin = assetListMixinFactory.create(viewModelScope) + + private val externalBalancesFlow = assetListMixin.externalBalancesFlow + + private val isFiltersEnabledFlow = walletInteractor.isFiltersEnabledFlow() + + private val accountChangeSyncActions: List = listOf( + walletInteractor::syncAllNfts + ) + + private val selectedMetaAccount = selectedAccountUseCase.selectedMetaAccountFlow() + .share() + + val selectedWalletModelFlow = selectedAccountUseCase.selectedWalletModelFlow() + .shareInBackground() + + private val balanceBreakdown = balanceBreakdownInteractor.balanceBreakdownFlow(assetListMixin.assetsFlow, externalBalancesFlow) + .shareInBackground() + + private val nftsPreviews = assetsListInteractor.observeNftPreviews() + .inBackground() + .share() + + val nftCountFlow = nftsPreviews + .combine(maskableAmountFormatterFlow, ::formatNftCount) + .inBackground() + .share() + + val nftPreviewsUi = nftsPreviews + .combine(maskableAmountFormatterFlow, ::mapNftPreviewToUi) + .inBackground() + .share() + + val maskingModeEnableFlow = maskingModeUseCase.observeMaskingMode() + .map { it == MaskingMode.ENABLED } + .shareInBackground() + + val totalBalanceFlow = combine( + balanceBreakdown, + swapAvailabilityInteractor.anySwapAvailableFlow(), + maskableAmountFormatterFlow + ) { breakdown, swapSupported, maskableAmountFormatter -> + val currency = selectedCurrency.first() + TotalBalanceModel( + isBreakdownAvailable = breakdown.breakdown.isNotEmpty(), + totalBalanceFiat = maskableAmountFormatter.format { + fiatFormatter.formatFiat( + breakdown.total, + currency, + config = FiatConfig( + abbreviationStyle = FiatConfig.AbbreviationStyle.SIMPLE_ABBREVIATION, + fractionPartStyling = FractionPartStyling.Styled(R.dimen.total_balance_fraction_size) + ) + ) + }, + lockedBalanceFiat = maskableAmountFormatter.format { fiatFormatter.formatFiat(breakdown.locksTotal.amount, currency) }, + enableSwap = swapSupported + ) + } + .inBackground() + .share() + + val shouldShowPlaceholderFlow = assetListMixin.assetModelsFlow.map { it.isEmpty() } + + val balanceBreakdownFlow = balanceBreakdown.map { + val currency = selectedCurrency.first() + val total = it.total.formatAsCurrency(currency) + TotalBalanceBreakdownModel(total, mapBreakdownToList(it, currency)) + } + .shareInBackground() + + private val walletConnectAccountSessionCount = selectedMetaAccount.flatMapLatest { + walletConnectSessionsUseCase.activeSessionsNumberFlow(it) + } + .shareInBackground() + + val walletConnectAccountSessionsUI = walletConnectAccountSessionCount + .map(::mapNumberOfActiveSessionsToUi) + .shareInBackground() + + val filtersIndicatorIcon = isFiltersEnabledFlow + .map { if (it) R.drawable.ic_chip_filter_indicator else R.drawable.ic_chip_filter } + .shareInBackground() + + val assetViewModeModelFlow = assetListMixin.assetsViewModeFlow.map { + when (it) { + AssetViewMode.NETWORKS -> AssetViewModeModel(R.drawable.ic_asset_view_networks, R.string.asset_view_networks) + AssetViewMode.TOKENS -> AssetViewModeModel(R.drawable.ic_asset_view_tokens, R.string.asset_view_tokens) + } + }.distinctUntilChanged() + + val pendingOperationsCountModel = multisigPendingOperationsService.pendingOperationsCountFlow() + .withSafeLoading() + .combine(maskableAmountFormatterFlow, ::formatPendingOperationsCount) + .shareInBackground() + + init { + selectedCurrency + .onEach { fullSync() } + .launchIn(this) + + nftsPreviews + .debounce(1L.seconds) + .onEach { nfts -> + nfts.nftPreviews + .filter { it.details is Nft.Details.Loadable } + .forEach { assetsListInteractor.fullSyncNft(it) } + } + .inBackground() + .launchIn(this) + + selectedMetaAccount + .mapLatest { syncWith(accountChangeSyncActions, it) } + .launchIn(this) + + walletInteractor.nftSyncTrigger() + .onEach { trigger -> walletInteractor.syncChainNfts(selectedMetaAccount.first(), trigger.chain) } + .launchIn(viewModelScope) + } + + fun fullSync() { + viewModelScope.launch { + syncWith(fullSyncActions, selectedMetaAccount.first()) + + _hideRefreshEvent.value = Event(Unit) + } + } + + fun assetClicked(asset: Chain.Asset) { + val payload = AssetPayload( + chainId = asset.chainId, + chainAssetId = asset.id + ) + + router.openAssetDetails(payload) + } + + fun avatarClicked() { + router.openSwitchWallet() + } + + fun manageClicked() { + router.openManageTokens() + } + + fun goToNftsClicked() { + router.openNfts() + } + + fun searchClicked() { + router.openAssetSearch() + } + + fun walletConnectClicked() { + launch { + if (walletConnectAccountSessionCount.first() > 0) { + val metaAccount = selectedMetaAccount.first() + router.openWalletConnectSessions(metaAccount.id) + } else { + router.openWalletConnectScan() + } + } + } + + fun balanceBreakdownClicked() { + launch { + val totalBalance = totalBalanceFlow.first() + if (totalBalance.isBreakdownAvailable) { + val balanceBreakdown = balanceBreakdownFlow.first() + _showBalanceBreakdownEvent.value = Event(balanceBreakdown) + } + } + } + + private suspend fun syncWith(syncActions: List, metaAccount: MetaAccount) = if (syncActions.size == 1) { + val syncAction = syncActions.first() + syncAction(metaAccount) + } else { + val syncJobs = syncActions.map { async { it(metaAccount) } } + syncJobs.joinAll() + } + + private fun mapNftPreviewToUi(nftPreviews: NftPreviews, maskableValueFormatter: MaskableValueFormatter): MaskableModel> { + return maskableValueFormatter.format { + nftPreviews.nftPreviews.map { + when (val details = it.details) { + Nft.Details.Loadable -> LoadingState.Loading() + is Nft.Details.Loaded -> { + LoadingState.Loaded(details.media) + } + } + } + } + } + + private fun mapBreakdownToList(balanceBreakdown: BalanceBreakdown, currency: Currency): List { + return buildList { + add( + BalanceBreakdownTotal( + resourceManager.getString(R.string.wallet_balance_transferable), + balanceBreakdown.transferableTotal.amount.formatAsCurrency(currency), + R.drawable.ic_transferable, + balanceBreakdown.transferableTotal.percentage.formatAsPercentage() + ) + ) + + add( + BalanceBreakdownTotal( + resourceManager.getString(R.string.wallet_balance_locked), + balanceBreakdown.locksTotal.amount.formatAsCurrency(currency), + R.drawable.ic_lock, + balanceBreakdown.locksTotal.percentage.formatAsPercentage() + ) + ) + + val breakdown = balanceBreakdown.breakdown.map { + BalanceBreakdownAmount( + name = it.token.configuration.symbol.value + " " + mapBalanceIdToUi(resourceManager, it.id), + amount = amountFormatter.formatAmountToAmountModel(it.tokenAmount, it.token) + ) + } + + addAll(breakdown) + } + } + + private fun formatPendingOperationsCount( + operationsLoadingState: ExtendedLoadingState, + formatter: MaskableValueFormatter + ): PendingOperationsCountModel { + return when (val count = operationsLoadingState.dataOrNull) { + null, 0 -> PendingOperationsCountModel.Gone + + else -> PendingOperationsCountModel.Visible(formatter.format { count.format() }) + } + } + + fun sendClicked() { + router.openSendFlow() + } + + fun receiveClicked() { + router.openReceiveFlow() + } + + fun buySellClicked() { + buySellSelectorMixin.openSelector() + } + + fun swapClicked() { + router.openSwapFlow() + } + + fun giftClicked() = launchUnit { + giftsRestrictionCheckMixin.checkRestrictionAndDo { + router.openGifts() + } + } + + fun novaCardClicked() = launchUnit { + novaCardRestrictionCheckMixin.checkRestrictionAndDo { + router.openNovaCard() + } + } + + fun switchViewMode() { + launch { assetListMixin.switchViewMode() } + } + + fun pendingOperationsClicked() { + router.openPendingMultisigOperations() + } + + private fun formatNftCount(nftPreviews: NftPreviews, formatter: MaskableValueFormatter): MaskableModel? { + if (nftPreviews.totalNftsCount == 0) return null + + return formatter.format { nftPreviews.totalNftsCount.format() } + } + + fun toggleMasking() { + maskingModeUseCase.toggleMaskingMode() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/di/BalanceListComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/di/BalanceListComponent.kt new file mode 100644 index 0000000..3b49b9e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/di/BalanceListComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.balance.list.BalanceListFragment + +@Subcomponent( + modules = [ + BalanceListModule::class + ] +) +@ScreenScope +interface BalanceListComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): BalanceListComponent + } + + fun inject(fragment: BalanceListFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/di/BalanceListModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/di/BalanceListModule.kt new file mode 100644 index 0000000..741a769 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/di/BalanceListModule.kt @@ -0,0 +1,138 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.repository.AssetsViewModeRepository +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.list.AssetsListInteractor +import io.novafoundation.nova.feature_assets.domain.breakdown.BalanceBreakdownInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetListMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.common.ExpandableAssetsMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.common.buySell.BuySellSelectorMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.list.BalanceListViewModel +import io.novafoundation.nova.feature_assets.presentation.novacard.common.NovaCardRestrictionCheckMixin +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.feature_assets.presentation.balance.common.gifts.GiftsRestrictionCheckMixin +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase + +@Module(includes = [ViewModelModule::class]) +class BalanceListModule { + + @Provides + @ScreenScope + fun provideInteractor( + accountRepository: AccountRepository, + nftRepository: NftRepository, + assetsViewModeRepository: AssetsViewModeRepository + ) = AssetsListInteractor(accountRepository, nftRepository, assetsViewModeRepository) + + @Provides + @ScreenScope + fun provideBalanceBreakdownInteractor( + accountRepository: AccountRepository, + balanceLocksRepository: BalanceLocksRepository, + balanceHoldsRepository: BalanceHoldsRepository + ): BalanceBreakdownInteractor { + return BalanceBreakdownInteractor( + accountRepository, + balanceLocksRepository, + balanceHoldsRepository + ) + } + + @Provides + @ScreenScope + fun provideAssetListMixinFactory( + walletInteractor: WalletInteractor, + assetsListInteractor: AssetsListInteractor, + externalBalancesInteractor: ExternalBalancesInteractor, + expandableAssetsMixinFactory: ExpandableAssetsMixinFactory + ): AssetListMixinFactory { + return AssetListMixinFactory( + walletInteractor, + assetsListInteractor, + externalBalancesInteractor, + expandableAssetsMixinFactory + ) + } + + @Provides + @IntoMap + @ViewModelKey(BalanceListViewModel::class) + fun provideViewModel( + promotionBannersMixinFactory: PromotionBannersMixinFactory, + bannerSourceFactory: BannersSourceFactory, + walletInteractor: WalletInteractor, + assetsListInteractor: AssetsListInteractor, + selectedAccountUseCase: SelectedAccountUseCase, + router: AssetsRouter, + currencyInteractor: CurrencyInteractor, + balanceBreakdownInteractor: BalanceBreakdownInteractor, + resourceManager: ResourceManager, + walletConnectSessionsUseCase: WalletConnectSessionsUseCase, + swapAvailabilityInteractor: SwapAvailabilityInteractor, + assetListMixinFactory: AssetListMixinFactory, + amountFormatter: AmountFormatter, + buySellSelectorMixinFactory: BuySellSelectorMixinFactory, + multisigPendingOperationsService: MultisigPendingOperationsService, + novaCardRestrictionCheckMixin: NovaCardRestrictionCheckMixin, + maskableValueFormatterProvider: MaskableValueFormatterProvider, + maskingModeUseCase: MaskingModeUseCase, + fiatFormatter: FiatFormatter, + giftsRestrictionCheckMixin: GiftsRestrictionCheckMixin, + ): ViewModel { + return BalanceListViewModel( + promotionBannersMixinFactory = promotionBannersMixinFactory, + bannerSourceFactory = bannerSourceFactory, + walletInteractor = walletInteractor, + assetsListInteractor = assetsListInteractor, + selectedAccountUseCase = selectedAccountUseCase, + router = router, + currencyInteractor = currencyInteractor, + balanceBreakdownInteractor = balanceBreakdownInteractor, + resourceManager = resourceManager, + walletConnectSessionsUseCase = walletConnectSessionsUseCase, + swapAvailabilityInteractor = swapAvailabilityInteractor, + assetListMixinFactory = assetListMixinFactory, + amountFormatter = amountFormatter, + maskableValueFormatterProvider = maskableValueFormatterProvider, + buySellSelectorMixinFactory = buySellSelectorMixinFactory, + multisigPendingOperationsService = multisigPendingOperationsService, + novaCardRestrictionCheckMixin = novaCardRestrictionCheckMixin, + maskingModeUseCase = maskingModeUseCase, + fiatFormatter = fiatFormatter, + giftsRestrictionCheckMixin = giftsRestrictionCheckMixin + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): BalanceListViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(BalanceListViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/NftPreview.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/NftPreview.kt new file mode 100644 index 0000000..7eca584 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/NftPreview.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.model + +import io.novafoundation.nova.common.presentation.LoadingState + +typealias NftMedia = String? +typealias NftPreviewUi = LoadingState diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/TotalBalanceModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/TotalBalanceModel.kt new file mode 100644 index 0000000..42828bc --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/TotalBalanceModel.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.model + +import io.novafoundation.nova.common.presentation.masking.MaskableModel + +class TotalBalanceModel( + val isBreakdownAvailable: Boolean, + val totalBalanceFiat: MaskableModel, + val lockedBalanceFiat: MaskableModel, + val enableSwap: Boolean +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/WalletConnectModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/WalletConnectModel.kt new file mode 100644 index 0000000..468637b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/WalletConnectModel.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.model + +class WalletConnectModel(val connections: String?) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/AssetRvItem.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/AssetRvItem.kt new file mode 100644 index 0000000..8801d80 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/AssetRvItem.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.model.items + +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableBaseItem +import io.novafoundation.nova.feature_assets.presentation.model.AssetModel + +interface BalanceListRvItem : ExpandableBaseItem { + val itemId: String + + override fun getId(): String { + return itemId + } +} + +interface AssetGroupRvItem : BalanceListRvItem + +interface AssetRvItem : BalanceListRvItem { + val asset: AssetModel +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/NetworkAssetUi.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/NetworkAssetUi.kt new file mode 100644 index 0000000..e5aa6c2 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/NetworkAssetUi.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.model.items + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_assets.presentation.model.AssetModel +import io.novafoundation.nova.runtime.ext.fullId + +data class NetworkAssetUi(override val asset: AssetModel, val icon: Icon) : AssetRvItem { + override val itemId: String = "network_" + asset.token.configuration.fullId.toString() +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/NetworkGroupUi.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/NetworkGroupUi.kt new file mode 100644 index 0000000..51c564b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/NetworkGroupUi.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.model.items + +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi + +data class NetworkGroupUi( + val chainUi: ChainUi, + val groupBalanceFiat: MaskableModel +) : AssetGroupRvItem { + + override val itemId: String = chainUi.id +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/TokenAssetUi.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/TokenAssetUi.kt new file mode 100644 index 0000000..3c179a1 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/TokenAssetUi.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.model.items + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableChildItem +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_assets.presentation.model.AssetModel +import io.novafoundation.nova.runtime.ext.fullId + +data class TokenAssetUi( + override val groupId: String, + override val asset: AssetModel, + val assetIcon: Icon, + val chain: ChainUi +) : AssetRvItem, ExpandableChildItem { + + override val itemId: String = "token_" + asset.token.configuration.fullId.toString() +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/TokenGroupUi.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/TokenGroupUi.kt new file mode 100644 index 0000000..0e302c2 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/model/items/TokenGroupUi.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.model.items + +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.recyclerView.expandable.items.ExpandableParentItem +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +data class TokenGroupUi( + override val itemId: String, + val tokenIcon: Icon, + val rate: String, + val recentRateChange: String, + @ColorRes val rateChangeColorRes: Int, + val tokenSymbol: String, + val singleItemGroup: Boolean, + val balance: MaskableModel, + val groupType: GroupType +) : AssetGroupRvItem, ExpandableParentItem { + + sealed interface GroupType { + data object Group : GroupType + + data class SingleItem(val asset: Chain.Asset) : GroupType + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetViewModeView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetViewModeView.kt new file mode 100644 index 0000000..be6498d --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetViewModeView.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.view.animation.AnticipateOvershootInterpolator +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ViewAssetViewModeBinding + +class AssetViewModeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions { + + private val binder = ViewAssetViewModeBinding.inflate(inflater(), this) + + override val providedContext: Context = context + + private var slideAnimationBottom = true + + private val animationListener = object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) { + isClickable = false + } + + override fun onAnimationEnd(animation: Animation?) { + isClickable = true + } + + override fun onAnimationRepeat(animation: Animation?) { + } + } + + private val slideTopInAnimation = AnimationUtils.loadAnimation(context, R.anim.asset_mode_slide_top_in).applyInterpolator() + private val slideTopOutAnimation = AnimationUtils.loadAnimation(context, R.anim.asset_mode_slide_top_out).applyInterpolator() + private val slideBottomInAnimation = AnimationUtils.loadAnimation(context, R.anim.asset_mode_slide_bottom_in).applyInterpolator() + private val slideBottomOutAnimation = AnimationUtils.loadAnimation(context, R.anim.asset_mode_slide_bottom_out).applyInterpolator() + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + + binder.assetViewModeIcon.setFactory { + val imageView = ImageView(context, null, 0) + imageView.scaleType = ImageView.ScaleType.CENTER_INSIDE + imageView + } + + binder.assetViewModeText.setFactory { + val textView = TextView(context, null, 0, R.style.TextAppearance_NovaFoundation_SemiBold_Title3) + textView.setGravity(Gravity.CLIP_VERTICAL) + textView.setTextColorRes(R.color.text_primary) + textView + } + + slideTopInAnimation.setAnimationListener(animationListener) + slideBottomInAnimation.setAnimationListener(animationListener) + } + + fun switchTextTo(model: AssetViewModeModel) { + switchTextTo(model.iconRes, model.textRes) + } + + fun switchTextTo(@DrawableRes iconRes: Int, @StringRes textRes: Int) { + if (!shouldPlayAnimation(textRes)) return + + binder.assetViewModeIcon.setImageResource(iconRes) + + binder.assetViewModeText.currentView + + if (slideAnimationBottom) { + binder.assetViewModeText.inAnimation = slideBottomInAnimation + binder.assetViewModeText.outAnimation = slideBottomOutAnimation + } else { + binder.assetViewModeText.inAnimation = slideTopInAnimation + binder.assetViewModeText.outAnimation = slideTopOutAnimation + } + + slideAnimationBottom = !slideAnimationBottom + + binder.assetViewModeText.setText(context.getText(textRes)) + } + + private fun Animation.applyInterpolator(): Animation { + interpolator = AnticipateOvershootInterpolator(2.0f) + return this + } + + private fun shouldPlayAnimation(@StringRes textRes: Int): Boolean { + val currentTextView = binder.assetViewModeText.currentView as? TextView ?: return true + + return currentTextView.text != context.getString(textRes) + } +} + +data class AssetViewModeModel(@DrawableRes val iconRes: Int, @StringRes val textRes: Int) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetsHeaderAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetsHeaderAdapter.kt new file mode 100644 index 0000000..de00645 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetsHeaderAdapter.kt @@ -0,0 +1,232 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.view + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.recyclerView.WithViewType +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedWalletModel +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ItemAssetHeaderBinding +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.NftPreviewUi +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.TotalBalanceModel +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectSessionsModel +import kotlinx.android.extensions.LayoutContainer + +class AssetsHeaderAdapter(private val handler: Handler) : RecyclerView.Adapter() { + + interface Handler { + fun totalBalanceClicked() + + fun searchClicked() + + fun manageClicked() + + fun avatarClicked() + + fun goToNftsClicked() + + fun walletConnectClicked() + + fun maskClicked() + + fun sendClicked() + + fun receiveClicked() + + fun buySellClicked() + + fun swapClicked() + + fun giftClicked() + + fun novaCardClick() + + fun pendingOperationsClicked() + } + + private var filterIconRes: Int? = null + private var walletConnectModel: WalletConnectSessionsModel? = null + private var maskingEnabled: Boolean? = null + private var totalBalance: TotalBalanceModel? = null + private var selectedWalletModel: SelectedWalletModel? = null + private var nftCountLabel: MaskableModel? = null + private var nftPreviews: MaskableModel>? = null + private var pendingOperationsModel: PendingOperationsCountModel = PendingOperationsCountModel.Gone + + override fun getItemViewType(position: Int): Int { + return AssetsHeaderHolder.viewType + } + + fun setFilterIconRes(filterIconRes: Int) { + this.filterIconRes = filterIconRes + } + + fun setNftCountLabel(nftCount: MaskableModel?) { + this.nftCountLabel = nftCount + + notifyItemChanged(0, Payload.NFT_COUNT) + } + + fun setNftPreviews(previews: MaskableModel>) { + this.nftPreviews = previews + + notifyItemChanged(0, Payload.NFT_PREVIEWS) + } + + fun setMaskingEnabled(maskingEnabled: Boolean) { + this.maskingEnabled = maskingEnabled + + notifyItemChanged(0, Payload.MASKING_ENABLED) + } + + fun setTotalBalance(totalBalance: TotalBalanceModel) { + this.totalBalance = totalBalance + + notifyItemChanged(0, Payload.TOTAL_BALANCE) + } + + fun setSelectedWallet(walletModel: SelectedWalletModel) { + this.selectedWalletModel = walletModel + + notifyItemChanged(0, Payload.ADDRESS) + } + + fun setWalletConnectModel(walletConnectModel: WalletConnectSessionsModel) { + this.walletConnectModel = walletConnectModel + + notifyItemChanged(0, Payload.WALLET_CONNECT) + } + + fun setPendingOperationsCountModel(pendingOperationsCountModel: PendingOperationsCountModel) { + this.pendingOperationsModel = pendingOperationsCountModel + notifyItemChanged(0, Payload.PENDING_OPERATIONS_COUNT) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AssetsHeaderHolder { + return AssetsHeaderHolder(ItemAssetHeaderBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: AssetsHeaderHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + } else { + payloads.filterIsInstance().forEach { + when (it) { + Payload.TOTAL_BALANCE -> holder.bindTotalBalance(totalBalance) + Payload.MASKING_ENABLED -> holder.bindMaskingEnabled(maskingEnabled) + Payload.ADDRESS -> holder.bindAddress(selectedWalletModel) + Payload.NFT_COUNT -> holder.bindNftCount(nftCountLabel) + Payload.NFT_PREVIEWS -> holder.bindNftPreviews(nftPreviews) + Payload.WALLET_CONNECT -> holder.bindWalletConnect(walletConnectModel) + Payload.PENDING_OPERATIONS_COUNT -> holder.bindPendingOperationsModel(pendingOperationsModel) + } + } + } + } + + override fun onBindViewHolder(holder: AssetsHeaderHolder, position: Int) { + holder.bind( + totalBalance, + maskingEnabled, + selectedWalletModel, + nftCountLabel, + nftPreviews, + walletConnectModel, + pendingOperationsModel + ) + } + + override fun getItemCount(): Int { + return 1 + } +} + +private enum class Payload { + TOTAL_BALANCE, MASKING_ENABLED, ADDRESS, NFT_COUNT, NFT_PREVIEWS, WALLET_CONNECT, PENDING_OPERATIONS_COUNT +} + +class AssetsHeaderHolder( + private val viewBinding: ItemAssetHeaderBinding, + handler: AssetsHeaderAdapter.Handler, +) : RecyclerView.ViewHolder(viewBinding.root), LayoutContainer { + + override val containerView: View = viewBinding.root + + companion object : WithViewType { + override val viewType: Int = R.layout.item_asset_header + } + + init { + with(viewBinding) { + balanceListWalletConnect.setOnClickListener { handler.walletConnectClicked() } + balanceListAvatar.setOnClickListener { handler.avatarClicked() } + balanceListNfts.setOnClickListener { handler.goToNftsClicked() } + balanceListTotalBalance.setOnClickListener { handler.totalBalanceClicked() } + balanceListTotalBalance.onMaskingClick { handler.maskClicked() } + balanceListTotalBalance.onSendClick { handler.sendClicked() } + balanceListTotalBalance.onReceiveClick { handler.receiveClicked() } + balanceListTotalBalance.onBuyClick { handler.buySellClicked() } + balanceListTotalBalance.onGiftClick { handler.giftClicked() } + balanceListNovaCard.setOnClickListener { handler.novaCardClick() } + balanceListPendingOperations.setOnClickListener { handler.pendingOperationsClicked() } + + balanceListTotalBalance.onSwapClick { handler.swapClicked() } + } + } + + fun bind( + totalBalance: TotalBalanceModel?, + maskingEnabled: Boolean?, + addressModel: SelectedWalletModel?, + nftCount: MaskableModel?, + nftPreviews: MaskableModel>?, + walletConnect: WalletConnectSessionsModel?, + pendingOperationsCountModel: PendingOperationsCountModel + ) { + bindTotalBalance(totalBalance) + bindMaskingEnabled(maskingEnabled) + bindAddress(addressModel) + bindNftPreviews(nftPreviews) + bindNftCount(nftCount) + bindWalletConnect(walletConnect) + bindPendingOperationsModel(pendingOperationsCountModel) + } + + fun bindNftPreviews(nftPreviews: MaskableModel>?) = with(viewBinding) { + balanceListNfts.setPreviews(nftPreviews) + viewBinding.balanceTableView.invalidateChildrenVisibility() + } + + fun bindNftCount(nftCount: MaskableModel?) = with(viewBinding) { + balanceListNfts.setNftCount(nftCount) + } + + fun bindMaskingEnabled(maskingEnabled: Boolean?) = maskingEnabled?.let { + with(viewBinding) { + balanceListTotalBalance.setMaskingEnabled(maskingEnabled) + } + } + + fun bindTotalBalance(totalBalance: TotalBalanceModel?) = totalBalance?.let { + with(viewBinding) { + balanceListTotalBalance.showTotalBalance(totalBalance) + } + } + + fun bindAddress(walletModel: SelectedWalletModel?) = walletModel?.let { + viewBinding.balanceListTotalTitle.text = it.name + + viewBinding.balanceListAvatar.setModel(it) + } + + fun bindWalletConnect(walletConnectModel: WalletConnectSessionsModel?) = walletConnectModel?.let { + viewBinding.balanceListWalletConnect.setConnectionCount(it.connections) + } + + fun bindPendingOperationsModel(model: PendingOperationsCountModel) { + viewBinding.balanceListPendingOperations.setPendingOperationsCount(model) + viewBinding.balanceTableView.invalidateChildrenVisibility() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetsNovaCardView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetsNovaCardView.kt new file mode 100644 index 0000000..7f97687 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetsNovaCardView.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity.CENTER_VERTICAL +import android.widget.LinearLayout +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_assets.databinding.ViewAssetNovaCardBinding + +class AssetsNovaCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions { + + private val binder = ViewAssetNovaCardBinding.inflate(inflater(), this) + + override val providedContext: Context = context + + init { + orientation = HORIZONTAL + gravity = CENTER_VERTICAL + + background = addRipple(getRoundedCornerDrawable(R.color.block_background)) + } + + fun setText(text: CharSequence) { + binder.assetNovaCardText.text = text + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetsTotalBalanceView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetsTotalBalanceView.kt new file mode 100644 index 0000000..864e287 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/AssetsTotalBalanceView.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.view + +import android.content.Context +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setShimmerVisible +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.parallaxCard.ParallaxCardView +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ViewTotalBalanceBinding +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.TotalBalanceModel + +class AssetsTotalBalanceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ParallaxCardView(context, attrs, defStyleAttr), WithContextExtensions { + + override val providedContext: Context = context + + private val binder = ViewTotalBalanceBinding.inflate(inflater(), this) + + init { + setPadding( + 12.dp(context), + 0, + 12.dp(context), + 12.dp(context) + ) + + binder.viewAssetsTotalBalanceMaskingButton.isHapticFeedbackEnabled = true + } + + fun showTotalBalance(totalBalance: TotalBalanceModel) { + binder.viewAssetsTotalBalanceShimmer.setShimmerVisible(false) + binder.viewAssetsTotalBalanceTotal.setVisible(true) + binder.viewAssetsTotalBalanceTotal.setMaskableText(totalBalance.totalBalanceFiat, maskDrawableRes = R.drawable.mask_dots_big) + binder.viewAssetsTotalBalanceTotal.requestLayout() // to fix the issue when elipsing the text is working incorrectly during fast text update + + binder.viewAssetsTotalBalanceLockedContainer.setVisible(totalBalance.isBreakdownAvailable) + + binder.viewAssetsTotalBalanceLocked.setMaskableText(totalBalance.lockedBalanceFiat) + binder.viewAssetsTotalBalanceSwap.isEnabled = totalBalance.enableSwap + } + + fun onMaskingClick(clickListener: OnClickListener) { + binder.viewAssetsTotalBalanceMaskingButton.setOnClickListener { + clickListener.onClick(it) + binder.viewAssetsTotalBalanceMaskingButton.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + } + } + + fun onSendClick(clickListener: OnClickListener) { + binder.viewAssetsTotalBalanceSend.setOnClickListener(clickListener) + } + + fun onReceiveClick(clickListener: OnClickListener) { + binder.viewAssetsTotalBalanceReceive.setOnClickListener(clickListener) + } + + fun onSwapClick(clickListener: OnClickListener) { + binder.viewAssetsTotalBalanceSwap.setOnClickListener(clickListener) + } + + fun onBuyClick(clickListener: OnClickListener) { + binder.viewAssetsTotalBalanceBuy.setOnClickListener(clickListener) + } + + fun onGiftClick(clickListener: OnClickListener) { + binder.viewAssetsTotalBalanceGift.setOnClickListener(clickListener) + } + + fun setMaskingEnabled(maskingEnabled: Boolean) { + val buttonImageRes = when (maskingEnabled) { + true -> R.drawable.ic_eye_hide + false -> R.drawable.ic_eye_show + } + binder.viewAssetsTotalBalanceMaskingButton.setImageResource(buttonImageRes) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/GoToNftsView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/GoToNftsView.kt new file mode 100644 index 0000000..aef330a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/GoToNftsView.kt @@ -0,0 +1,104 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import coil.load +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.presentation.dataOrNull +import io.novafoundation.nova.common.presentation.isLoading +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ViewGoToNftsBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.NftPreviewUi + +import javax.inject.Inject + +class GoToNftsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions { + + private val binder = ViewGoToNftsBinding.inflate(inflater(), this) + + @Inject + lateinit var imageLoader: ImageLoader + + override val providedContext: Context = context + + private val previewViews by lazy(LazyThreadSafetyMode.NONE) { + listOf(binder.goToNftPreview1, binder.goToNftPreview2, binder.goToNftPreview3) + } + + private val previewHolders by lazy(LazyThreadSafetyMode.NONE) { + listOf(binder.goToNftPreviewHolder1, binder.goToNftPreviewHolder2, binder.goToNftPreviewHolder3) + } + + init { + FeatureUtils.getFeature( + context, + AssetsFeatureApi::class.java + ).inject(this) + } + + fun setNftCount(countLabel: MaskableModel?) { + if (countLabel == null) { + makeGone() + return + } else { + makeVisible() + binder.goToNftCounter.setMaskableText(countLabel) + } + } + + fun setPreviews(previewsMaskable: MaskableModel>?) { + when (previewsMaskable) { + is MaskableModel.Hidden -> maskPreviews() + is MaskableModel.Unmasked -> setPreviews(previewsMaskable.value) + null -> makeGone() + } + } + + fun setPreviews(previews: List?) { + setVisible(!previews.isNullOrEmpty()) + val shouldShowLoading = previews == null || previews.all { it is LoadingState.Loading } + + if (shouldShowLoading) { + previewHolders.forEach(View::makeGone) + binder.goToNftsShimmer.makeVisible() + } else { + binder.goToNftsShimmer.makeGone() + + previewHolders.forEachIndexed { index, view -> + val previewContent = previews!!.getOrNull(index) + + if (previewContent == null || previewContent.isLoading) { + view.makeGone() + } else { + view.makeVisible() + previewViews[index].load(previewContent.dataOrNull, imageLoader) + } + } + } + } + + private fun maskPreviews() { + val images = listOf(R.drawable.ic_blue_siri, R.drawable.ic_yellow_siri, R.drawable.ic_pink_siri) + images.forEachIndexed { index, imageRes -> + previewHolders[index].makeVisible() + previewViews[index].setImageResource(imageRes) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/ManageAssetsAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/ManageAssetsAdapter.kt new file mode 100644 index 0000000..537b68a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/ManageAssetsAdapter.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.view + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.recyclerView.WithViewType +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ItemManageAssetsBinding + +class ManageAssetsAdapter(private val handler: Handler) : RecyclerView.Adapter() { + + interface Handler { + fun searchClicked() + + fun manageClicked() + + fun assetViewModeClicked() + } + + private var assetViewModeModel: AssetViewModeModel? = null + + fun setAssetViewModeModel(assetViewModeModel: AssetViewModeModel) { + this.assetViewModeModel = assetViewModeModel + + notifyItemChanged(0) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ManageAssetsHolder { + val binder = ItemManageAssetsBinding.inflate(parent.inflater(), parent, false) + return ManageAssetsHolder(binder, handler) + } + + override fun onBindViewHolder(holder: ManageAssetsHolder, position: Int) { + holder.bind(assetViewModeModel) + } + + override fun getItemViewType(position: Int): Int { + return ManageAssetsHolder.viewType + } + + override fun getItemCount(): Int { + return 1 + } +} + +class ManageAssetsHolder( + private val binder: ItemManageAssetsBinding, + handler: ManageAssetsAdapter.Handler, +) : RecyclerView.ViewHolder(binder.root) { + + companion object : WithViewType { + override val viewType: Int = R.layout.item_manage_assets + } + + init { + with(binder) { + balanceListManage.setOnClickListener { handler.manageClicked() } + balanceListSearch.setOnClickListener { handler.searchClicked() } + balanceListAssetTitle.setOnClickListener { handler.assetViewModeClicked() } + } + } + + fun bind(assetViewModeModel: AssetViewModeModel?) { + assetViewModeModel?.let { binder.balanceListAssetTitle.switchTextTo(assetViewModeModel) } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PendingOperationsCountView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PendingOperationsCountView.kt new file mode 100644 index 0000000..5c96d58 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/list/view/PendingOperationsCountView.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.list.view + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_assets.databinding.ViewPendingOperationsCountBinding + +class PendingOperationsCountView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binding = ViewPendingOperationsCountBinding.inflate(inflater(), this) + + fun setPendingOperationsCount(model: PendingOperationsCountModel) { + when (model) { + PendingOperationsCountModel.Gone -> makeGone() + is PendingOperationsCountModel.Visible -> { + makeVisible() + binding.pendingOperationsCountCounter.setMaskableText(model.countLabel) + } + } + } +} + +sealed class PendingOperationsCountModel { + + data object Gone : PendingOperationsCountModel() + + class Visible(val countLabel: MaskableModel) : PendingOperationsCountModel() +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/AssetSearchFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/AssetSearchFragment.kt new file mode 100644 index 0000000..0e86298 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/AssetSearchFragment.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.search + +import android.view.View +import androidx.lifecycle.lifecycleScope +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard +import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard +import io.novafoundation.nova.common.utils.recyclerView.expandable.ExpandableAnimationSettings +import io.novafoundation.nova.common.utils.recyclerView.expandable.animator.ExpandableAnimator +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_assets.databinding.FragmentAssetSearchBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetTokensDecoration +import io.novafoundation.nova.feature_assets.presentation.balance.common.AssetTokensItemAnimator +import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.AssetBaseDecoration +import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter +import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.applyDefaultTo +import io.novafoundation.nova.feature_assets.presentation.balance.common.createForAssets +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import javax.inject.Inject + +class AssetSearchFragment : + BaseFragment(), + BalanceListAdapter.ItemAssetHandler { + + override fun createBinding() = FragmentAssetSearchBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val assetsAdapter by lazy(LazyThreadSafetyMode.NONE) { + BalanceListAdapter(imageLoader, this) + } + + override fun applyInsets(rootView: View) { + binder.searchAssetSearch.applyStatusBarInsets() + binder.searchAssetList.applyNavigationBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.searchAssetList.setHasFixedSize(true) + binder.searchAssetList.adapter = assetsAdapter + + val animationSettings = ExpandableAnimationSettings.createForAssets() + val animator = ExpandableAnimator(binder.searchAssetList, animationSettings, assetsAdapter) + + binder.searchAssetList.addItemDecoration(AssetTokensDecoration(requireContext(), assetsAdapter, animator)) + binder.searchAssetList.itemAnimator = AssetTokensItemAnimator(animationSettings, animator) + + AssetBaseDecoration.applyDefaultTo(binder.searchAssetList, assetsAdapter) + + binder.searchAssetSearch.cancel.setOnClickListener { + viewModel.cancelClicked() + } + onBackPressed { viewModel.cancelClicked() } + + binder.searchAssetSearch.searchInput.requestFocus() + binder.searchAssetSearch.searchInput.content.showSoftKeyboard() + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .assetSearchComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: AssetSearchViewModel) { + binder.searchAssetSearch.searchInput.content.bindTo(viewModel.query, lifecycleScope) + + viewModel.query.observe { + binder.searchAssetList.post { + binder.searchAssetList.layoutManager!!.scrollToPosition(0) + } + } + + viewModel.searchResults.observe { data -> + binder.searchAssetsPlaceholder.setVisible(data.isEmpty()) + binder.searchAssetList.setVisible(data.isNotEmpty()) + + assetsAdapter.submitList(data) { binder.searchAssetList.invalidateItemDecorations() } + } + } + + override fun onDestroyView() { + super.onDestroyView() + + binder.searchAssetSearch.searchInput.hideSoftKeyboard() + } + + override fun assetClicked(asset: Chain.Asset) { + viewModel.assetClicked(asset) + } + + override fun tokenGroupClicked(tokenGroup: TokenGroupUi) { + if (tokenGroup.groupType is TokenGroupUi.GroupType.SingleItem) { + viewModel.assetClicked(tokenGroup.groupType.asset) + } else { + val itemAnimator = binder.searchAssetList.itemAnimator as AssetTokensItemAnimator + itemAnimator.prepareForAnimation() + + viewModel.assetListMixin.expandToken(tokenGroup) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/AssetSearchViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/AssetSearchViewModel.kt new file mode 100644 index 0000000..1fcac92 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/AssetSearchViewModel.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.search + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ExpandableAssetsMixinFactory +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.MutableStateFlow + +class AssetSearchViewModel( + private val router: AssetsRouter, + interactorFactory: AssetSearchInteractorFactory, + externalBalancesInteractor: ExternalBalancesInteractor, + expandableAssetsMixinFactory: ExpandableAssetsMixinFactory +) : BaseViewModel() { + + val interactor = interactorFactory.createByAssetViewMode() + + val query = MutableStateFlow("") + + private val externalBalances = externalBalancesInteractor.observeExternalBalances() + + private val assetsFlow = interactor.searchAssetsFlow(query, externalBalances) + + val assetListMixin = expandableAssetsMixinFactory.create(assetsFlow) + + val searchResults = assetListMixin.assetModelsFlow + + fun cancelClicked() { + router.back() + } + + fun assetClicked(asset: Chain.Asset) { + val payload = AssetPayload( + chainId = asset.chainId, + chainAssetId = asset.id + ) + + router.openAssetDetails(payload) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/di/AssetSearchComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/di/AssetSearchComponent.kt new file mode 100644 index 0000000..4ef2b59 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/di/AssetSearchComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.search.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.balance.search.AssetSearchFragment + +@Subcomponent( + modules = [ + AssetSearchModule::class + ] +) +@ScreenScope +interface AssetSearchComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): AssetSearchComponent + } + + fun inject(fragment: AssetSearchFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/di/AssetSearchModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/di/AssetSearchModule.kt new file mode 100644 index 0000000..ef96d43 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/search/di/AssetSearchModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_assets.presentation.balance.search.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ExpandableAssetsMixinFactory +import io.novafoundation.nova.feature_assets.presentation.balance.search.AssetSearchViewModel + +@Module(includes = [ViewModelModule::class]) +class AssetSearchModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AssetSearchViewModel { + return ViewModelProvider(fragment, factory).get(AssetSearchViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AssetSearchViewModel::class) + fun provideViewModel( + router: AssetsRouter, + interactorFactory: AssetSearchInteractorFactory, + externalBalancesInteractor: ExternalBalancesInteractor, + expandableAssetsMixinFactory: ExpandableAssetsMixinFactory + ): ViewModel { + return AssetSearchViewModel( + router = router, + interactorFactory = interactorFactory, + externalBalancesInteractor = externalBalancesInteractor, + expandableAssetsMixinFactory = expandableAssetsMixinFactory + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt new file mode 100644 index 0000000..a9b9095 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt @@ -0,0 +1,125 @@ +package io.novafoundation.nova.feature_assets.presentation.bridge + +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.FragmentBridgeBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent + +class BridgeFragment : BaseFragment() { + + override fun createBinding() = FragmentBridgeBinding.inflate(layoutInflater) + + override fun initViews() { + binder.bridgeToolbar.setHomeButtonListener { viewModel.backClicked() } + + // Direction toggle + binder.bridgeDirectionDotToHez.setOnClickListener { + viewModel.setDirection(BridgeDirection.DOT_TO_HEZ) + } + + binder.bridgeDirectionHezToDot.setOnClickListener { + viewModel.setDirection(BridgeDirection.HEZ_TO_DOT) + } + + // Amount input + binder.bridgeFromAmount.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + val amount = s?.toString()?.toDoubleOrNull() ?: 0.0 + viewModel.setAmount(amount) + } + }) + + // Swap button + binder.bridgeSwapButton.setOnClickListener { + viewModel.swapClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .bridgeComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: BridgeViewModel) { + viewModel.direction.observe { direction -> + updateDirectionUI(direction) + } + + viewModel.outputAmount.observe { output -> + binder.bridgeToAmount.text = output + } + + viewModel.exchangeRateText.observe { rate -> + binder.bridgeRate.text = rate + } + + viewModel.minimumText.observe { minimum -> + binder.bridgeMinimum.text = minimum + } + + viewModel.buttonState.observe { state -> + binder.bridgeSwapButton.setState(state) + } + + viewModel.showHezToDotWarning.observe { show -> + binder.bridgeHezToDotWarning.visibility = if (show) View.VISIBLE else View.GONE + } + + viewModel.hezToDotBlocked.observe { blocked -> + if (blocked) { + binder.bridgeHezToDotWarning.setBackgroundColor(resources.getColor(R.color.error_block_background, null)) + } else { + binder.bridgeHezToDotWarning.setBackgroundColor(resources.getColor(R.color.warning_block_background, null)) + } + } + + viewModel.blockReason.observe { reason -> + if (reason.isNotEmpty()) { + binder.bridgeHezToDotWarning.text = reason + } else { + binder.bridgeHezToDotWarning.text = getString(R.string.bridge_hez_to_dot_warning) + } + } + } + + private fun updateDirectionUI(direction: BridgeDirection) { + when (direction) { + BridgeDirection.DOT_TO_HEZ -> { + binder.bridgeDirectionDotToHez.setBackgroundResource(R.drawable.bg_button_primary) + binder.bridgeDirectionDotToHez.setTextColor(resources.getColor(R.color.text_primary, null)) + binder.bridgeDirectionHezToDot.background = null + binder.bridgeDirectionHezToDot.setTextColor(resources.getColor(R.color.text_secondary, null)) + + binder.bridgeFromToken.text = "DOT" + binder.bridgeToToken.text = "HEZ" + } + BridgeDirection.HEZ_TO_DOT -> { + binder.bridgeDirectionHezToDot.setBackgroundResource(R.drawable.bg_button_primary) + binder.bridgeDirectionHezToDot.setTextColor(resources.getColor(R.color.text_primary, null)) + binder.bridgeDirectionDotToHez.background = null + binder.bridgeDirectionDotToHez.setTextColor(resources.getColor(R.color.text_secondary, null)) + + binder.bridgeFromToken.text = "HEZ" + binder.bridgeToToken.text = "DOT" + } + } + } +} + +enum class BridgeDirection { + DOT_TO_HEZ, + HEZ_TO_DOT +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt new file mode 100644 index 0000000..b37de7c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt @@ -0,0 +1,288 @@ +package io.novafoundation.nova.feature_assets.presentation.bridge + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.math.BigDecimal +import java.math.RoundingMode +import java.net.URL + +class BridgeViewModel( + private val router: AssetsRouter, + private val resourceManager: ResourceManager, + private val chainRegistry: ChainRegistry +) : BaseViewModel() { + + companion object { + // Bridge wallet account ID (derived from seed, same on all chains) + // Address: 5C5CW7xDmiXtCgfUCbKFF4ViJuCJJQpDZqWQ1mSTjehGzE3p (generic format) + private const val BRIDGE_ADDRESS_GENERIC = "5C5CW7xDmiXtCgfUCbKFF4ViJuCJJQpDZqWQ1mSTjehGzE3p" + + // Chain IDs + val POLKADOT_ASSET_HUB_ID = ChainGeneses.POLKADOT_ASSET_HUB + val PEZKUWI_ASSET_HUB_ID = ChainGeneses.PEZKUWI_ASSET_HUB + + // Utility asset ID (native token) + const val UTILITY_ASSET_ID = 0 + + // Fallback rate: 1 DOT = 3 HEZ (only if CoinGecko unavailable) + const val FALLBACK_RATE = 3.0 + + // Fee: 0.1% + const val FEE_PERCENT = 0.001 + + // Minimums + const val MIN_DOT = 0.1 + const val MIN_HEZ = 0.3 + + // CoinGecko API + const val COINGECKO_API = "https://api.coingecko.com/api/v3/simple/price?ids=polkadot,hezkurd&vs_currencies=usd" + + // Bridge Status API + const val BRIDGE_STATUS_API = "http://217.77.6.126:3030/status" + } + + private val _direction = MutableLiveData(BridgeDirection.DOT_TO_HEZ) + val direction: LiveData = _direction + + private val _outputAmount = MutableLiveData("0.0") + val outputAmount: LiveData = _outputAmount + + private val _exchangeRateText = MutableLiveData() + val exchangeRateText: LiveData = _exchangeRateText + + private val _minimumText = MutableLiveData() + val minimumText: LiveData = _minimumText + + private val _buttonState = MutableLiveData() + val buttonState: LiveData = _buttonState + + private val _showHezToDotWarning = MutableLiveData(false) + val showHezToDotWarning: LiveData = _showHezToDotWarning + + private val _hezToDotBlocked = MutableLiveData(false) + val hezToDotBlocked: LiveData = _hezToDotBlocked + + private val _blockReason = MutableLiveData() + val blockReason: LiveData = _blockReason + + private val _rateSource = MutableLiveData() + val rateSource: LiveData = _rateSource + + private var currentAmount: Double = 0.0 + private var dotToHezRate: Double = FALLBACK_RATE + private var isUsingFallback: Boolean = true + private var isHezToDotActive: Boolean = false + + init { + fetchExchangeRate() + fetchBridgeStatus() + } + + private fun fetchExchangeRate() { + launch { + try { + val (rate, source) = withContext(Dispatchers.IO) { + fetchRateFromCoinGecko() + } + dotToHezRate = rate + isUsingFallback = source == "fallback" + _rateSource.postValue(source) + updateUI() + calculateOutput() + } catch (e: Exception) { + // Use fallback + dotToHezRate = FALLBACK_RATE + isUsingFallback = true + _rateSource.postValue("fallback") + updateUI() + } + } + } + + private fun fetchRateFromCoinGecko(): Pair { + return try { + val response = URL(COINGECKO_API).readText() + val json = JSONObject(response) + + val dotPrice = json.optJSONObject("polkadot")?.optDouble("usd", 0.0) ?: 0.0 + val hezPrice = json.optJSONObject("hezkurd")?.optDouble("usd", 0.0) ?: 0.0 + + when { + dotPrice > 0 && hezPrice > 0 -> { + // Both prices available - calculate real rate + val rate = dotPrice / hezPrice + Pair(rate, "coingecko") + } + dotPrice > 0 -> { + // Only DOT price - use fallback for HEZ (1 DOT = 3 HEZ means HEZ = DOT/3) + Pair(FALLBACK_RATE, "coingecko+fallback") + } + else -> { + // No prices - use pure fallback + Pair(FALLBACK_RATE, "fallback") + } + } + } catch (e: Exception) { + Pair(FALLBACK_RATE, "fallback") + } + } + + private fun fetchBridgeStatus() { + launch { + try { + val active = withContext(Dispatchers.IO) { + fetchHezToDotStatus() + } + isHezToDotActive = active + updateHezToDotState() + } catch (e: Exception) { + // If API unavailable, assume not active for safety + isHezToDotActive = false + updateHezToDotState() + } + } + } + + private fun fetchHezToDotStatus(): Boolean { + return try { + val response = URL(BRIDGE_STATUS_API).readText() + val json = JSONObject(response) + json.optBoolean("hezToDotActive", false) + } catch (e: Exception) { + false + } + } + + private fun updateHezToDotState() { + val dir = _direction.value ?: return + if (dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive) { + _hezToDotBlocked.postValue(true) + _blockReason.postValue(resourceManager.getString(R.string.bridge_hez_to_dot_blocked)) + } else { + _hezToDotBlocked.postValue(false) + _blockReason.postValue("") + } + updateButtonState() + } + + fun setDirection(newDirection: BridgeDirection) { + if (_direction.value != newDirection) { + _direction.value = newDirection + _showHezToDotWarning.value = newDirection == BridgeDirection.HEZ_TO_DOT + updateHezToDotState() + updateUI() + calculateOutput() + } + } + + fun setAmount(amount: Double) { + currentAmount = amount + calculateOutput() + updateButtonState() + } + + fun swapClicked() { + val dir = _direction.value ?: return + if (currentAmount <= 0) return + + launch { + // Determine which chain and asset to send from based on direction + val chainId = when (dir) { + BridgeDirection.DOT_TO_HEZ -> POLKADOT_ASSET_HUB_ID // Send DOT from Polkadot Asset Hub + BridgeDirection.HEZ_TO_DOT -> PEZKUWI_ASSET_HUB_ID // Send HEZ from Pezkuwi Asset Hub + } + + // Get the chain to convert address to correct format + val chain = chainRegistry.getChain(chainId) + + // Convert generic address to chain-specific format + val accountId = BRIDGE_ADDRESS_GENERIC.toAccountId() + val bridgeAddress = chain.addressOf(accountId) + + // Create asset payload (utility asset = native token) + val assetPayload = AssetPayload(chainId, UTILITY_ASSET_ID) + + // Create send payload specifying the origin asset + val sendPayload = SendPayload.SpecifiedOrigin(assetPayload) + + // Open send screen with pre-filled bridge address AND amount + router.openSend(sendPayload, bridgeAddress, currentAmount) + } + } + + fun backClicked() { + router.back() + } + + private fun calculateOutput() { + val dir = _direction.value ?: return + + val grossOutput = when (dir) { + BridgeDirection.DOT_TO_HEZ -> currentAmount * dotToHezRate + BridgeDirection.HEZ_TO_DOT -> currentAmount / dotToHezRate + } + + // Apply fee + val netOutput = grossOutput * (1 - FEE_PERCENT) + + _outputAmount.value = if (netOutput > 0) { + BigDecimal(netOutput).setScale(6, RoundingMode.DOWN).stripTrailingZeros().toPlainString() + } else { + "0.0" + } + } + + private fun updateUI() { + val dir = _direction.value ?: return + + val rateFormatted = BigDecimal(dotToHezRate).setScale(4, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + val reverseRateFormatted = BigDecimal(1.0 / dotToHezRate).setScale(6, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + + when (dir) { + BridgeDirection.DOT_TO_HEZ -> { + _exchangeRateText.value = "1 DOT = $rateFormatted HEZ" + _minimumText.value = "$MIN_DOT DOT" + } + BridgeDirection.HEZ_TO_DOT -> { + _exchangeRateText.value = "1 HEZ = $reverseRateFormatted DOT" + _minimumText.value = "$MIN_HEZ HEZ" + } + } + + updateButtonState() + } + + private fun updateButtonState() { + val dir = _direction.value ?: return + val minimum = when (dir) { + BridgeDirection.DOT_TO_HEZ -> MIN_DOT + BridgeDirection.HEZ_TO_DOT -> MIN_HEZ + } + + _buttonState.value = when { + currentAmount <= 0 -> ButtonState.DISABLED + currentAmount < minimum -> ButtonState.DISABLED + dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED + else -> ButtonState.NORMAL + } + } + + fun refreshBridgeStatus() { + fetchBridgeStatus() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeComponent.kt new file mode 100644 index 0000000..584a43a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeComponent.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_assets.presentation.bridge.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeFragment + +@Subcomponent( + modules = [ + BridgeModule::class + ] +) +@ScreenScope +interface BridgeComponent { + + @Subcomponent.Factory + interface Factory { + fun create( + @BindsInstance fragment: Fragment + ): BridgeComponent + } + + fun inject(fragment: BridgeFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt new file mode 100644 index 0000000..3f297d9 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_assets.presentation.bridge.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class BridgeModule { + + @Provides + @IntoMap + @ViewModelKey(BridgeViewModel::class) + fun provideViewModel( + router: AssetsRouter, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry + ): ViewModel { + return BridgeViewModel(router, resourceManager, chainRegistry) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): BridgeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(BridgeViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/asset/AssetFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/asset/AssetFlowFragment.kt new file mode 100644 index 0000000..8d8e7a8 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/asset/AssetFlowFragment.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_assets.presentation.flow.asset + +import android.view.View +import androidx.annotation.StringRes +import androidx.lifecycle.lifecycleScope + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.hideKeyboard +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard +import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.submitListPreservingViewPoint +import io.novafoundation.nova.common.view.setModelOrHide +import io.novafoundation.nova.feature_assets.databinding.FragmentAssetFlowSearchBinding +import io.novafoundation.nova.feature_assets.presentation.balance.common.BalanceListAdapter +import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.AssetBaseDecoration +import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.CompoundAssetDecorationPreferences +import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.NetworkAssetDecorationPreferences +import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.TokenAssetGroupDecorationPreferences +import io.novafoundation.nova.feature_assets.presentation.balance.common.baseDecoration.applyDefaultTo +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_assets.presentation.receive.view.LedgerNotSupportedWarningBottomSheet +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import javax.inject.Inject + +abstract class AssetFlowFragment : + BaseFragment(), + BalanceListAdapter.ItemAssetHandler { + + override fun createBinding() = FragmentAssetFlowSearchBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val assetsAdapter by lazy(LazyThreadSafetyMode.NONE) { + BalanceListAdapter(imageLoader, this) + } + + fun setTitle(@StringRes titleRes: Int) { + binder.assetFlowToolbar.setTitle(titleRes) + } + + override fun applyInsets(rootView: View) { + binder.assetFlowToolbar.applyStatusBarInsets() + binder.assetFlowList.applyNavigationBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.assetFlowToolbar.setHomeButtonListener { + hideKeyboard() + viewModel.backClicked() + } + + with(binder.assetFlowList) { + setHasFixedSize(true) + adapter = assetsAdapter + + AssetBaseDecoration.applyDefaultTo( + this, + assetsAdapter, + CompoundAssetDecorationPreferences( + NetworkAssetDecorationPreferences(), + TokenAssetGroupDecorationPreferences() + ) + ) + itemAnimator = null + } + + binder.assetFlowToolbar.searchField.requestFocus() + binder.assetFlowToolbar.searchField.content.showSoftKeyboard() + } + + override fun subscribe(viewModel: T) { + binder.assetFlowToolbar.searchField.content.bindTo(viewModel.query, lifecycleScope) + + viewModel.searchHint.observe { + binder.assetFlowToolbar.searchField.setHint(it) + } + + viewModel.searchResults.observe { assets -> + binder.assetFlowList.setVisible(assets.isNotEmpty()) + + assetsAdapter.submitListPreservingViewPoint( + data = assets, + into = binder.assetFlowList, + extraDiffCompletedCallback = { binder.assetFlowList.invalidateItemDecorations() } + ) + } + + viewModel.placeholder.observe { placeholder -> + binder.assetFlowPlaceholder.setModelOrHide(placeholder) + } + + viewModel.acknowledgeLedgerWarning.awaitableActionLiveData.observeEvent { + LedgerNotSupportedWarningBottomSheet( + context = requireContext(), + onSuccess = { it.onSuccess(Unit) }, + message = it.payload + ).show() + } + } + + override fun assetClicked(asset: Chain.Asset) { + viewModel.assetClicked(asset) + + binder.assetFlowToolbar.searchField.hideSoftKeyboard() + } + + override fun tokenGroupClicked(tokenGroup: TokenGroupUi) { + viewModel.tokenClicked(tokenGroup) + + binder.assetFlowToolbar.searchField.hideSoftKeyboard() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/asset/AssetFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/asset/AssetFlowViewModel.kt new file mode 100644 index 0000000..ee50905 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/asset/AssetFlowViewModel.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_assets.presentation.flow.asset + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.model.AssetViewMode +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.view.PlaceholderModel +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.domain.assets.models.groupList +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +abstract class AssetFlowViewModel( + interactorFactory: AssetSearchInteractorFactory, + protected val router: AssetsRouter, + protected val currencyInteractor: CurrencyInteractor, + private val controllableAssetCheck: ControllableAssetCheckMixin, + protected val accountUseCase: SelectedAccountUseCase, + externalBalancesInteractor: ExternalBalancesInteractor, + protected val resourceManager: ResourceManager, + private val assetIconProvider: AssetIconProvider, + private val assetViewModeInteractor: AssetViewModeInteractor, + private val networkAssetMapper: NetworkAssetFormatter, + private val tokenAssetFormatter: TokenAssetFormatter +) : BaseViewModel() { + + protected val interactor = interactorFactory.createByAssetViewMode() + + val acknowledgeLedgerWarning = controllableAssetCheck.acknowledgeLedgerWarning + + val query = MutableStateFlow("") + + private val selectedCurrency = currencyInteractor.observeSelectCurrency() + .inBackground() + .share() + + protected val externalBalancesFlow = externalBalancesInteractor.observeExternalBalances() + + private val searchAssetsFlow = flowOfAll { searchAssetsFlow() } // lazy use searchAssetsFlow to let subclasses initialize self + .shareInBackground(SharingStarted.Lazily) + + val searchHint = assetViewModeInteractor.assetsViewModeFlow() + .map { + when (it) { + AssetViewMode.NETWORKS -> resourceManager.getString(R.string.assets_search_hint) + AssetViewMode.TOKENS -> resourceManager.getString(R.string.assets_search_token_hint) + } + } + + val searchResults = combine( + searchAssetsFlow, // lazy use searchAssetsFlow to let subclasses initialize self + selectedCurrency, + ) { assets, currency -> + mapAssets(assets, currency) + }.distinctUntilChanged() + .shareInBackground(SharingStarted.Lazily) + + val placeholder = searchAssetsFlow.map { getPlaceholder(query.value, it.groupList()) } + + fun backClicked() { + router.back() + } + + abstract fun searchAssetsFlow(): Flow + + abstract fun assetClicked(asset: Chain.Asset) + + abstract fun tokenClicked(tokenGroup: TokenGroupUi) + + private fun mapAssets(searchResult: AssetsByViewModeResult, currency: Currency): List { + return when (searchResult) { + is AssetsByViewModeResult.ByNetworks -> mapNetworkAssets(searchResult.assets, currency) + is AssetsByViewModeResult.ByTokens -> mapTokensAssets(searchResult.tokens) + } + } + + open fun mapNetworkAssets(assets: Map>, currency: Currency): List { + return networkAssetMapper.mapGroupedAssetsToUi(assets, assetIconProvider, currency) + } + + open fun mapTokensAssets(assets: Map>): List { + return assets.map { tokenAssetFormatter.mapTokenAssetGroupToUi(assetIconProvider, it.key, assets = it.value) } + } + + internal fun validate(asset: Chain.Asset, onAccept: (Chain.Asset) -> Unit) { + launch { + val metaAccount = accountUseCase.getSelectedMetaAccount() + controllableAssetCheck.check(metaAccount, asset) { + onAccept(asset) + } + } + } + + protected open fun getPlaceholder(query: String, assets: List): PlaceholderModel? { + return when { + assets.isEmpty() -> PlaceholderModel( + text = resourceManager.getString(R.string.assets_search_placeholder), + imageRes = R.drawable.ic_no_search_results + ) + + else -> null + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowAdapter.kt new file mode 100644 index 0000000..de6d94e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowAdapter.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_assets.presentation.flow.network + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import coil.ImageLoader +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_assets.databinding.ItemNetworkFlowBinding +import io.novafoundation.nova.feature_assets.presentation.flow.network.model.NetworkFlowRvItem + +class NetworkFlowAdapter( + private val imageLoader: ImageLoader, + private val itemHandler: ItemNetworkHandler, +) : ListAdapter(DiffCallback) { + + interface ItemNetworkHandler { + fun networkClicked(network: NetworkFlowRvItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NetworkFlowViewHolder { + return NetworkFlowViewHolder(ItemNetworkFlowBinding.inflate(parent.inflater(), parent, false), imageLoader, itemHandler) + } + + override fun onBindViewHolder(holder: NetworkFlowViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private object DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: NetworkFlowRvItem, newItem: NetworkFlowRvItem): Boolean { + return oldItem.chainId == newItem.chainId + } + + override fun areContentsTheSame(oldItem: NetworkFlowRvItem, newItem: NetworkFlowRvItem): Boolean { + return oldItem == newItem + } +} + +class NetworkFlowViewHolder( + private val binder: ItemNetworkFlowBinding, + private val imageLoader: ImageLoader, + private val itemHandler: NetworkFlowAdapter.ItemNetworkHandler, +) : GroupedListHolder(binder.root) { + + fun bind(item: NetworkFlowRvItem) = with(containerView) { + binder.itemNetworkImage.loadChainIcon(item.icon, imageLoader) + binder.itemNetworkName.text = item.networkName + binder.itemNetworkBalance.text = item.balance.token + binder.itemNetworkPriceAmount.text = item.balance.fiat + + setOnClickListener { itemHandler.networkClicked(item) } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowFragment.kt new file mode 100644 index 0000000..bd6c5be --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowFragment.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_assets.presentation.flow.network + +import android.view.View +import androidx.recyclerview.widget.ConcatAdapter +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.view.recyclerview.adapter.text.TextAdapter +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.FragmentNetworkFlowBinding +import io.novafoundation.nova.feature_assets.presentation.flow.network.model.NetworkFlowRvItem +import io.novafoundation.nova.feature_assets.presentation.receive.view.LedgerNotSupportedWarningBottomSheet +import javax.inject.Inject + +abstract class NetworkFlowFragment : + BaseFragment(), + NetworkFlowAdapter.ItemNetworkHandler { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentNetworkFlowBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val titleAdapter by lazy(LazyThreadSafetyMode.NONE) { + TextAdapter(styleRes = R.style.TextAppearance_NovaFoundation_Bold_Title3) + } + + private val networkAdapter by lazy(LazyThreadSafetyMode.NONE) { + NetworkFlowAdapter(imageLoader, this) + } + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + ConcatAdapter(titleAdapter, networkAdapter) + } + + override fun applyInsets(rootView: View) { + binder.networkFlowToolbar.applyStatusBarInsets() + binder.networkFlowList.applyNavigationBarInsets() + } + + override fun initViews() { + binder.networkFlowToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.networkFlowList.setHasFixedSize(true) + binder.networkFlowList.adapter = adapter + binder.networkFlowList.itemAnimator = null + } + + override fun subscribe(viewModel: T) { + viewModel.titleFlow.observe { + titleAdapter.setText(it) + } + + viewModel.networks.observe { + networkAdapter.submitList(it) + } + + viewModel.acknowledgeLedgerWarning.awaitableActionLiveData.observeEvent { + LedgerNotSupportedWarningBottomSheet( + context = requireContext(), + onSuccess = { it.onSuccess(Unit) }, + message = it.payload + ).show() + } + } + + override fun networkClicked(network: NetworkFlowRvItem) { + viewModel.networkClicked(network) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowPayload.kt new file mode 100644 index 0000000..4af4078 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_assets.presentation.flow.network + +import android.os.Parcelable +import io.novafoundation.nova.common.utils.TokenSymbol +import kotlinx.android.parcel.Parcelize + +@Parcelize +class NetworkFlowPayload(val tokenSymbol: String) : Parcelable + +fun NetworkFlowPayload.asTokenSymbol() = TokenSymbol(tokenSymbol) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt new file mode 100644 index 0000000..bc7da05 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_assets.presentation.flow.network + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.model.NetworkFlowRvItem +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +abstract class NetworkFlowViewModel( + protected val interactor: AssetNetworksInteractor, + protected val router: AssetsRouter, + private val controllableAssetCheck: ControllableAssetCheckMixin, + protected val accountUseCase: SelectedAccountUseCase, + externalBalancesInteractor: ExternalBalancesInteractor, + protected val resourceManager: ResourceManager, + private val networkFlowPayload: NetworkFlowPayload, + protected val chainRegistry: ChainRegistry, + protected val amountFormatter: AmountFormatter +) : BaseViewModel() { + + val acknowledgeLedgerWarning = controllableAssetCheck.acknowledgeLedgerWarning + + protected val externalBalancesFlow = externalBalancesInteractor.observeExternalBalances() + + val titleFlow: Flow = flowOf { getTitle(networkFlowPayload.asTokenSymbol()) } + + val networks: Flow> = flowOfAll { assetsFlow(networkFlowPayload.asTokenSymbol()) } + .map { mapAssets(it) } + .shareInBackground(SharingStarted.Lazily) + + abstract fun getAssetBalance(asset: AssetWithNetwork): AssetBalance.Amount + + abstract fun assetsFlow(tokenSymbol: TokenSymbol): Flow> + + abstract fun networkClicked(network: NetworkFlowRvItem) + + abstract fun getTitle(tokenSymbol: TokenSymbol): String + + fun backClicked() { + router.back() + } + + internal fun validateControllsAsset(networkFlowRvItem: NetworkFlowRvItem, onAccept: () -> Unit) { + launch { + val metaAccount = accountUseCase.getSelectedMetaAccount() + val chainAsset = chainRegistry.asset(networkFlowRvItem.chainId, networkFlowRvItem.assetId) + controllableAssetCheck.check(metaAccount, chainAsset) { + onAccept() + } + } + } + + private fun mapAssets(assetWithNetworks: List): List { + return assetWithNetworks + .map { + NetworkFlowRvItem( + it.chain.id, + it.asset.token.configuration.id, + it.chain.name, + it.chain.icon, + amountFormatter.formatAmountToAmountModel( + amount = getAssetBalance(it).amount, + asset = it.asset, + AmountConfig(includeAssetTicker = false) + ) + ) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/model/NetworkFlowRvItem.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/model/NetworkFlowRvItem.kt new file mode 100644 index 0000000..051b6cd --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/model/NetworkFlowRvItem.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_assets.presentation.flow.network.model + +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +data class NetworkFlowRvItem( + val chainId: String, + val assetId: Int, + val networkName: String, + val icon: String?, + val balance: AmountModel +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/AssetGiftsFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/AssetGiftsFlowFragment.kt new file mode 100644 index 0000000..841329c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/AssetGiftsFlowFragment.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_assets.presentation.gifts.assets + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowFragment + +class AssetGiftsFlowFragment : AssetFlowFragment() { + + override fun initViews() { + super.initViews() + binder.assetFlowToolbar.toolbar.setHomeButtonIcon(R.drawable.ic_arrow_back) + setTitle(R.string.gifts_assets_flow_title) + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .giftsFlowComponent() + .create(this) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/AssetGiftsFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/AssetGiftsFlowViewModel.kt new file mode 100644 index 0000000..7e7525e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/AssetGiftsFlowViewModel.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_assets.presentation.gifts.assets + +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class AssetGiftsFlowViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + currencyInteractor: CurrencyInteractor, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + private val assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + private val networkAssetMapper: NetworkAssetFormatter, + private val tokenAssetFormatter: TokenAssetFormatter +) : AssetFlowViewModel( + interactorFactory, + router, + currencyInteractor, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + assetIconProvider, + assetViewModeInteractor, + networkAssetMapper, + tokenAssetFormatter +) { + + override fun searchAssetsFlow(): Flow { + return interactor.giftAssetsSearch(query, externalBalancesFlow, coroutineScope) + } + + override fun assetClicked(asset: Chain.Asset) { + val assetPayload = AssetPayload(asset.chainId, asset.id) + router.openSelectGiftAmount(assetPayload) + } + + override fun tokenClicked(tokenGroup: TokenGroupUi) { + when (val type = tokenGroup.groupType) { + is TokenGroupUi.GroupType.SingleItem -> assetClicked(type.asset) + + TokenGroupUi.GroupType.Group -> router.openGiftsNetworks(NetworkFlowPayload(tokenGroup.tokenSymbol)) + } + } + + override fun mapNetworkAssets(assets: Map>, currency: Currency): List { + return networkAssetMapper.mapGroupedAssetsToUi( + assets, + assetIconProvider, + currency, + NetworkAssetGroup::groupTransferableBalanceFiat, + AssetBalance::transferable + ) + } + + override fun mapTokensAssets(assets: Map>): List { + return assets.map { (group, assets) -> + tokenAssetFormatter.mapTokenAssetGroupToUi(assetIconProvider, group, assets = assets) { it.groupBalance.transferable } + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/di/AssetGiftsFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/di/AssetGiftsFlowComponent.kt new file mode 100644 index 0000000..fcd47c0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/di/AssetGiftsFlowComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.gifts.assets.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.gifts.assets.AssetGiftsFlowFragment + +@Subcomponent( + modules = [ + AssetGiftsFlowModule::class + ] +) +@ScreenScope +interface AssetGiftsFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): AssetGiftsFlowComponent + } + + fun inject(fragment: AssetGiftsFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/di/AssetGiftsFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/di/AssetGiftsFlowModule.kt new file mode 100644 index 0000000..5c990e3 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/assets/di/AssetGiftsFlowModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_assets.presentation.gifts.assets.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.gifts.assets.AssetGiftsFlowViewModel +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor + +@Module(includes = [ViewModelModule::class]) +class AssetGiftsFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AssetGiftsFlowViewModel { + return ViewModelProvider(fragment, factory).get(AssetGiftsFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AssetGiftsFlowViewModel::class) + fun provideViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + currencyInteractor: CurrencyInteractor, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + networkAssetMapper: NetworkAssetFormatter, + tokenAssetFormatter: TokenAssetFormatter + ): ViewModel { + return AssetGiftsFlowViewModel( + interactorFactory = interactorFactory, + router = router, + currencyInteractor = currencyInteractor, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + assetIconProvider = assetIconProvider, + assetViewModeInteractor = assetViewModeInteractor, + networkAssetMapper = networkAssetMapper, + tokenAssetFormatter = tokenAssetFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/NetworkGiftsFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/NetworkGiftsFlowFragment.kt new file mode 100644 index 0000000..970916f --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/NetworkGiftsFlowFragment.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_assets.presentation.gifts.networks + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowFragment + +class NetworkGiftsFlowFragment : + NetworkFlowFragment() { + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .networkGiftsFlowComponent() + .create(this, payload()) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/NetworkGiftsFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/NetworkGiftsFlowViewModel.kt new file mode 100644 index 0000000..d81d644 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/NetworkGiftsFlowViewModel.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_assets.presentation.gifts.networks + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.model.NetworkFlowRvItem +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.Flow + +class NetworkGiftsFlowViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter +) : NetworkFlowViewModel( + interactor, + router, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + networkFlowPayload, + chainRegistry, + amountFormatter +) { + + override fun getAssetBalance(asset: AssetWithNetwork): AssetBalance.Amount { + return asset.balanceWithOffChain.transferable + } + + override fun assetsFlow(tokenSymbol: TokenSymbol): Flow> { + return interactor.giftsAssetFlow(tokenSymbol, externalBalancesFlow, viewModelScope) + } + + override fun networkClicked(network: NetworkFlowRvItem) { + router.openSelectGiftAmount(AssetPayload(network.chainId, network.assetId)) + } + + override fun getTitle(tokenSymbol: TokenSymbol): String { + return resourceManager.getString(R.string.gifts_network_flow_title, tokenSymbol.value) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/di/NetworkGiftsFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/di/NetworkGiftsFlowComponent.kt new file mode 100644 index 0000000..a4b4e68 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/di/NetworkGiftsFlowComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.gifts.networks.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.gifts.networks.NetworkGiftsFlowFragment + +@Subcomponent( + modules = [ + NetworkGiftsFlowModule::class + ] +) +@ScreenScope +interface NetworkGiftsFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance networkFlowPayload: NetworkFlowPayload + ): NetworkGiftsFlowComponent + } + + fun inject(fragment: NetworkGiftsFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/di/NetworkGiftsFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/di/NetworkGiftsFlowModule.kt new file mode 100644 index 0000000..5ae8325 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/gifts/networks/di/NetworkGiftsFlowModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_assets.presentation.gifts.networks.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.gifts.networks.NetworkGiftsFlowViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class NetworkGiftsFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NetworkGiftsFlowViewModel { + return ViewModelProvider(fragment, factory).get(NetworkGiftsFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(NetworkGiftsFlowViewModel::class) + fun provideViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter + ): ViewModel { + return NetworkGiftsFlowViewModel( + interactor = interactor, + router = router, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + networkFlowPayload = networkFlowPayload, + chainRegistry = chainRegistry, + amountFormatter = amountFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/AssetModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/AssetModel.kt new file mode 100644 index 0000000..6a5c705 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/AssetModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_assets.presentation.model + +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +data class AssetModel( + val token: TokenModel, + val amount: MaskableModel +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/BalanceLocksModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/BalanceLocksModel.kt new file mode 100644 index 0000000..b16ca61 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/BalanceLocksModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_assets.presentation.model + +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class BalanceLocksModel( + val locks: List +) { + + class Lock( + val name: String, + val amount: AmountModel + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/ExtrinsicContentParcel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/ExtrinsicContentParcel.kt new file mode 100644 index 0000000..560bcad --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/ExtrinsicContentParcel.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_assets.presentation.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class ExtrinsicContentParcel(val blocks: List) : Parcelable { + + @Parcelize + class Block(val entries: List) : Parcelable + + sealed class BlockEntry : Parcelable { + + @Parcelize + class TransactionId(val hash: String) : BlockEntry() + + @Parcelize + class Address(val label: String, val address: String) : BlockEntry() + + @Parcelize + class LabeledValue(val label: String, val value: String) : BlockEntry() + } +} + +@DslMarker +@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) +annotation class ExtrinsicContentDsl + +interface ExtrinsicContentParcelBuilder { + + interface BlockBuilder { + + fun transactionId(hash: String) + + fun address(label: String, address: String) + + fun value(label: String, value: String) + } + + fun block(building: (@ExtrinsicContentDsl BlockBuilder).() -> Unit) + + fun build(): ExtrinsicContentParcel +} + +fun ExtrinsicContentParcel(building: (@ExtrinsicContentDsl ExtrinsicContentParcelBuilder).() -> Unit): ExtrinsicContentParcel { + return RealExtrinsicContentParcelBuilder().apply(building).build() +} + +private class RealExtrinsicContentParcelBuilder : ExtrinsicContentParcelBuilder { + + private val blocks = mutableListOf() + + override fun block(building: ExtrinsicContentParcelBuilder.BlockBuilder.() -> Unit) { + blocks += BlockBuilder().apply(building).build() + } + + override fun build(): ExtrinsicContentParcel { + return ExtrinsicContentParcel(blocks) + } +} + +private class BlockBuilder : ExtrinsicContentParcelBuilder.BlockBuilder { + + private val entries = mutableListOf() + + override fun transactionId(hash: String) { + entries += ExtrinsicContentParcel.BlockEntry.TransactionId(hash) + } + + override fun address(label: String, address: String) { + entries += ExtrinsicContentParcel.BlockEntry.Address(label, address) + } + + override fun value(label: String, value: String) { + entries += ExtrinsicContentParcel.BlockEntry.LabeledValue(label, value) + } + + fun build(): ExtrinsicContentParcel.Block { + return ExtrinsicContentParcel.Block(entries) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/OperationModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/OperationModel.kt new file mode 100644 index 0000000..56417d9 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/OperationModel.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_assets.presentation.model + +import android.text.TextUtils +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.utils.images.Icon + +class OperationModel( + val id: String, + val amount: String, + val amountDetails: String?, + @ColorRes val amountColorRes: Int, + val header: String, + val statusAppearance: OperationStatusAppearance, + val operationIcon: Icon, + val subHeader: CharSequence, + val subHeaderEllipsize: TextUtils.TruncateAt +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/OperationParcelizeModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/OperationParcelizeModel.kt new file mode 100644 index 0000000..79ed930 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/OperationParcelizeModel.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_assets.presentation.model + +import android.os.Parcelable +import androidx.annotation.DrawableRes +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +sealed class OperationParcelizeModel : Parcelable { + + @Parcelize + class Reward( + val chainId: ChainId, + val eventId: String, + val address: String, + val time: Long, + val amount: AmountParcelModel, + val type: String, + val era: String?, + val validator: String?, + val statusAppearance: OperationStatusAppearance, + ) : OperationParcelizeModel() + + @Parcelize + class PoolReward( + val chainId: ChainId, + val address: String, + val time: Long, + val amount: AmountParcelModel, + val type: String, + val poolId: Int, + val eventId: String, + ) : OperationParcelizeModel() + + @Parcelize + class Extrinsic( + val chainId: ChainId, + val chainAssetId: ChainAssetId, + val time: Long, + val originAddress: String, + val content: ExtrinsicContentParcel, + val fee: String, + val fiatFee: String?, + val statusAppearance: OperationStatusAppearance, + ) : Parcelable, OperationParcelizeModel() + + @Parcelize + class Transfer( + val chainId: ChainId, + val assetId: Int, + val time: Long, + val address: String, + val hash: String?, + val isIncome: Boolean, + val amount: AmountParcelModel, + val receiver: String, + val sender: String, + val fee: BigInteger?, + val statusAppearance: OperationStatusAppearance, + @DrawableRes val transferDirectionIcon: Int + ) : Parcelable, OperationParcelizeModel() + + @Parcelize + class Swap( + val amountIsAssetIn: Boolean, + val timeMillis: Long, + val amountIn: ChainAssetWithAmountParcelModel, + val amountOut: ChainAssetWithAmountParcelModel, + val amountFee: ChainAssetWithAmountParcelModel, + val originAddress: String, + val transactionHash: String?, + val statusAppearance: OperationStatusAppearance, + ) : Parcelable, OperationParcelizeModel() +} + +@Parcelize +class ChainAssetWithAmountParcelModel( + val assetId: AssetPayload, + val amount: Balance +) : Parcelable + +@Parcelize +class AmountParcelModel( + val token: String, + val fiat: String? +) : Parcelable + +fun AmountParcelModel.toAmountModel(): AmountModel { + return AmountModel(token, fiat) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/OperationStatusAppearance.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/OperationStatusAppearance.kt new file mode 100644 index 0000000..cafde61 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/OperationStatusAppearance.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_assets.presentation.model + +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_assets.R + +enum class OperationStatusAppearance( + @DrawableRes val icon: Int, + @StringRes val labelRes: Int, + @ColorRes val statusIconTint: Int, + @ColorRes val statusTextTint: Int, + @ColorRes val amountTint: Int +) { + COMPLETED(R.drawable.ic_checkmark_circle_16, R.string.transaction_status_completed, R.color.icon_positive, R.color.text_positive, R.color.text_primary), + PENDING(R.drawable.ic_time_16, R.string.transaction_status_pending, R.color.icon_secondary, R.color.text_secondary, R.color.text_primary), + FAILED(R.drawable.ic_red_cross, R.string.transaction_status_failed, R.color.icon_negative, R.color.text_negative, R.color.text_secondary), +} + +fun TextView.showOperationStatus(statusAppearance: OperationStatusAppearance) { + setText(statusAppearance.labelRes) + setDrawableStart( + drawableRes = statusAppearance.icon, + widthInDp = 16, + paddingInDp = 4, + tint = statusAppearance.statusIconTint + ) + setTextColorRes(statusAppearance.statusTextTint) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/TokenModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/TokenModel.kt new file mode 100644 index 0000000..4ceb826 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/model/TokenModel.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_assets.presentation.model + +import androidx.annotation.ColorRes +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +data class TokenModel( + val configuration: Chain.Asset, + val rate: String, + val recentRateChange: String, + @ColorRes val rateChangeColorRes: Int +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/common/NovaCardRestrictionCheckMixin.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/common/NovaCardRestrictionCheckMixin.kt new file mode 100644 index 0000000..80c9daf --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/common/NovaCardRestrictionCheckMixin.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.common + +import io.novafoundation.nova.common.mixin.restrictions.RestrictionCheckMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences +import io.novafoundation.nova.common.view.bottomSheet.action.primary +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.asMultisig +import io.novafoundation.nova.feature_account_api.domain.model.asProxied +import io.novafoundation.nova.feature_account_api.domain.model.isMultisig +import io.novafoundation.nova.feature_account_api.domain.model.isProxied +import io.novafoundation.nova.feature_account_api.domain.model.isThreshold1 +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +private const val NOVA_CARD_AVAILABLE_CHAIN_ID = ChainGeneses.POLKADOT + +class NovaCardRestrictionCheckMixin( + private val accountUseCase: SelectedAccountUseCase, + private val resourceManager: ResourceManager, + private val actionLauncher: ActionBottomSheetLauncher, + private val chainRegistry: ChainRegistry +) : RestrictionCheckMixin { + + override suspend fun isRestricted(): Boolean { + val selectedAccount = accountUseCase.getSelectedMetaAccount() + val availableChain = chainRegistry.getChain(NOVA_CARD_AVAILABLE_CHAIN_ID) + + return when (selectedAccount.type) { + LightMetaAccount.Type.PROXIED -> selectedAccount.asProxied().isRestricted(availableChain) + LightMetaAccount.Type.MULTISIG -> selectedAccount.asMultisig().isRestricted(availableChain) + + LightMetaAccount.Type.SECRETS, + LightMetaAccount.Type.WATCH_ONLY, + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.LEDGER_LEGACY, + LightMetaAccount.Type.LEDGER, + LightMetaAccount.Type.POLKADOT_VAULT -> false + } + } + + override suspend fun checkRestrictionAndDo(action: () -> Unit) { + val selectedAccount = accountUseCase.getSelectedMetaAccount() + val availableChain = chainRegistry.getChain(NOVA_CARD_AVAILABLE_CHAIN_ID) + + when { + selectedAccount.isProxied() && selectedAccount.isRestricted(availableChain) -> showProxiedWarning() + selectedAccount.isMultisig() && selectedAccount.isRestricted(availableChain) -> showMultisigWarning() + + else -> action() + } + } + + private fun MultisigMetaAccount.isRestricted(availableChain: Chain): Boolean { + val isAllowed = isThreshold1() && hasAccountIn(availableChain) + + return !isAllowed + } + + private fun ProxiedMetaAccount.isRestricted(availableChain: Chain): Boolean { + val isAllowed = hasAccountIn(availableChain) + return !isAllowed + } + + private fun showMultisigWarning() { + actionLauncher.launchBottomSheet( + imageRes = R.drawable.ic_multisig, + title = resourceManager.getString(R.string.multisig_card_not_supported_title), + subtitle = resourceManager.getString(R.string.multisig_card_not_supported_message), + actionButtonPreferences = ButtonPreferences.primary(resourceManager.getString(R.string.common_ok_back)), + neutralButtonPreferences = null + ) + } + + private fun showProxiedWarning() { + actionLauncher.launchBottomSheet( + imageRes = R.drawable.ic_proxy, + title = resourceManager.getString(R.string.proxied_card_not_supported_title), + subtitle = resourceManager.getString(R.string.proxied_card_not_supported_message), + actionButtonPreferences = ButtonPreferences.primary(resourceManager.getString(R.string.common_ok_back)), + neutralButtonPreferences = null + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/NovaCardFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/NovaCardFragment.kt new file mode 100644 index 0000000..44244cd --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/NovaCardFragment.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.overview + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_assets.databinding.FragmentNovaCardBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent + +class NovaCardFragment : BaseFragment() { + + override fun createBinding() = FragmentNovaCardBinding.inflate(layoutInflater) + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .novaCardComponentFactory() + .create(this) + .inject(this) + } + + override fun initViews() { + binder.novaCardToolbar.setHomeButtonListener { viewModel.backClicked() } + } + + override fun subscribe(viewModel: NovaCardViewModel) { + viewModel.novaCardWebViewControllerFlow.observeFirst { controller -> + controller.setup(binder.novaCardWebView) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/NovaCardViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/NovaCardViewModel.kt new file mode 100644 index 0000000..bba59d0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/NovaCardViewModel.kt @@ -0,0 +1,153 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.overview + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardState +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.model.CardSetupConfig +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.webViewController.NovaCardWebViewControllerFactory +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.webViewController.interceptors.CardCreationInterceptor +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.webViewController.interceptors.CardCreationInterceptorFactory +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressPayload +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressRequester +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressResponder +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoSellRequestInterceptorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.math.BigDecimal +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext + +class NovaCardViewModel( + private val chainRegistry: ChainRegistry, + private val accountInteractor: AccountInteractor, + private val assetsRouter: AssetsRouter, + private val novaCardInteractor: NovaCardInteractor, + private val cardCreationInterceptorFactory: CardCreationInterceptorFactory, + private val mercuryoSellRequestInterceptorFactory: MercuryoSellRequestInterceptorFactory, + private val novaCardWebViewControllerFactory: NovaCardWebViewControllerFactory, + private val topUpRequester: TopUpAddressRequester, + private val resourceManager: ResourceManager +) : BaseViewModel(), CardCreationInterceptor.Callback, OnSellOrderCreatedListener, OnTradeOperationFinishedListener { + + private val metaAccount = flowOf { accountInteractor.selectedMetaAccount() } + + private val setupCardConfig = metaAccount.map { metaAccount -> + val topUpChain = getTopUpChain() + CardSetupConfig( + refundAddress = metaAccount.requireAddressIn(topUpChain), + spendToken = topUpChain.utilityAsset + ) + } + + val novaCardWebViewControllerFlow = setupCardConfig.map { setupConfig -> + novaCardWebViewControllerFactory.create( + interceptors = listOf( + cardCreationInterceptorFactory.create(this), + mercuryoSellRequestInterceptorFactory.create(this, this) + ), + setupConfig = setupConfig, + scope = viewModelScope + ) + } + + init { + ensureCardCreationIsBlocking() + + observeTopUp() + } + + fun backClicked() { + assetsRouter.back() + } + + override fun onSellOrderCreated(orderId: String, address: String, amount: BigDecimal) { + launch { + val asset = setupCardConfig.first().spendToken + + val payload = TopUpAddressPayload( + amount = amount, + address = address, + asset = setupCardConfig.first().spendToken.toAssetPayload(), + screenTitle = resourceManager.getString(R.string.fragment_top_up_card_title, asset.symbol.value) + ) + + topUpRequester.openRequest(payload) + } + } + + override fun onTradeOperationFinished(success: Boolean) { // Always success for mercuryo + launch { + novaCardInteractor.setTopUpFinishedEvent() + setCardStateCreated() + } + } + + override fun onCardCreated() { + setCardStateCreated() + } + + private suspend fun getTopUpChain(): Chain { + return chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB) + } + + private fun ensureCardCreationIsBlocking() { + launch { + val novaCardState = novaCardInteractor.getNovaCardState() + + if (novaCardState == NovaCardState.CREATION) { + assetsRouter.openAwaitingCardCreation() + } + } + } + + private fun setCardStateCreated() { + if (novaCardInteractor.isNovaCardCreated()) return + + novaCardInteractor.setNovaCardState(NovaCardState.CREATED) + } + + private fun observeTopUp() { + topUpRequester.responseFlow + .onEach { + when (it) { + TopUpAddressResponder.Response.Cancel -> withContext(Dispatchers.Main) { // Use withContext to fix a bug with not opening a bottomsheet. TODO: We don't understand completaly why this fix works so let's investigate this problem + assetsRouter.returnToMainScreen() + } + + TopUpAddressResponder.Response.Success -> withContext(Dispatchers.Main) { // Use withContext to fix a bug with not opening a bottomsheet. TODO: We don't understand completaly why this fix works so let's investigate this problem + updateCardState() + updateLastTopUpTime() + assetsRouter.openAwaitingCardCreation() + } + } + } + .launchIn(this) + } + + private fun updateCardState() { + if (!novaCardInteractor.isNovaCardCreated()) { + novaCardInteractor.setNovaCardState(NovaCardState.CREATION) + } + } + + private fun updateLastTopUpTime() { + novaCardInteractor.setLastTopUpTime(System.currentTimeMillis()) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/deeplink/NovaCardDeepLinkHandler.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/deeplink/NovaCardDeepLinkHandler.kt new file mode 100644 index 0000000..b2624a0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/deeplink/NovaCardDeepLinkHandler.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.overview.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.novacard.common.NovaCardRestrictionCheckMixin +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import kotlinx.coroutines.flow.MutableSharedFlow + +private const val PATH = "/open/card" + +private const val MERCURYO_PROVIDER = "mercuryo" +private const val DEFAULT_PROVIDER = MERCURYO_PROVIDER + +class NovaCardDeepLinkHandler( + private val router: AssetsRouter, + private val automaticInteractionGate: AutomaticInteractionGate, + private val novaCardRestrictionCheckMixin: NovaCardRestrictionCheckMixin +) : DeepLinkHandler { + + override val callbackFlow = MutableSharedFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + + return path.startsWith(PATH) + } + + override suspend fun handleDeepLink(data: Uri): Result = runCatching { + automaticInteractionGate.awaitInteractionAllowed() + + novaCardRestrictionCheckMixin.checkRestrictionAndDo { + openProvider(data) + } + } + + private fun openProvider(data: Uri) { + val provider = data.getQueryParameter("provider") ?: DEFAULT_PROVIDER + + when (provider) { + MERCURYO_PROVIDER -> router.openNovaCard() + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/di/NovaCardComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/di/NovaCardComponent.kt new file mode 100644 index 0000000..d083d37 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/di/NovaCardComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.overview.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.NovaCardFragment + +@Subcomponent( + modules = [ + NovaCardModule::class + ] +) +@ScreenScope +interface NovaCardComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): NovaCardComponent + } + + fun inject(fragment: NovaCardFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/di/NovaCardModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/di/NovaCardModule.kt new file mode 100644 index 0000000..5edb7d1 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/di/NovaCardModule.kt @@ -0,0 +1,104 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.overview.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooser +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAsker +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_assets.BuildConfig +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.NovaCardViewModel +import io.novafoundation.nova.common.utils.webView.BaseWebChromeClientFactory +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClientFactory +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.webViewController.NovaCardWebViewControllerFactory +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.webViewController.interceptors.CardCreationInterceptorFactory +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoSellRequestInterceptorFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import okhttp3.OkHttpClient + +@Module(includes = [ViewModelModule::class]) +class NovaCardModule { + + @Provides + fun providePermissionAsker(fragment: Fragment, factory: WebViewPermissionAskerFactory) = factory.create(fragment) + + @Provides + fun provideFileChooser(fragment: Fragment, factory: WebViewFileChooserFactory) = factory.create(fragment) + + @Provides + fun provideCardCreationInterceptorFactory( + gson: Gson, + okHttpClient: OkHttpClient + ): CardCreationInterceptorFactory = CardCreationInterceptorFactory( + gson = gson, + okHttpClient = okHttpClient + ) + + @Provides + fun provideBaseWebChromeClientFactory( + permissionsAsker: WebViewPermissionAsker, + webViewFileChooser: WebViewFileChooser + ) = BaseWebChromeClientFactory(permissionsAsker, webViewFileChooser) + + @Provides + fun provideNovaCardWebViewControllerFactory( + appLinksProvider: AppLinksProvider, + interceptingWebViewClientFactory: InterceptingWebViewClientFactory, + novaCardWebChromeClientFactory: BaseWebChromeClientFactory + ): NovaCardWebViewControllerFactory { + return NovaCardWebViewControllerFactory( + interceptingWebViewClientFactory, + novaCardWebChromeClientFactory, + appLinksProvider, + BuildConfig.PEZKUWI_CARD_WIDGET_ID + ) + } + + @Provides + @IntoMap + @ViewModelKey(NovaCardViewModel::class) + fun provideViewModel( + chainRegistry: ChainRegistry, + accountInteractor: AccountInteractor, + assetsRouter: AssetsRouter, + novaCardInteractor: NovaCardInteractor, + cardCreationInterceptorFactory: CardCreationInterceptorFactory, + mercuryoSellRequestInterceptorFactory: MercuryoSellRequestInterceptorFactory, + novaCardWebViewControllerFactory: NovaCardWebViewControllerFactory, + topUpAddressCommunicator: TopUpAddressCommunicator, + resourceManager: ResourceManager + ): ViewModel { + return NovaCardViewModel( + chainRegistry = chainRegistry, + accountInteractor = accountInteractor, + assetsRouter = assetsRouter, + novaCardInteractor = novaCardInteractor, + cardCreationInterceptorFactory = cardCreationInterceptorFactory, + mercuryoSellRequestInterceptorFactory = mercuryoSellRequestInterceptorFactory, + novaCardWebViewControllerFactory = novaCardWebViewControllerFactory, + topUpRequester = topUpAddressCommunicator, + resourceManager = resourceManager + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): NovaCardViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NovaCardViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/model/CardSetupConfig.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/model/CardSetupConfig.kt new file mode 100644 index 0000000..8d71a15 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/model/CardSetupConfig.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.overview.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class CardSetupConfig( + val refundAddress: String, + val spendToken: Chain.Asset, +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/webViewController/NovaCardWebViewController.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/webViewController/NovaCardWebViewController.kt new file mode 100644 index 0000000..7286bfd --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/webViewController/NovaCardWebViewController.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.overview.webViewController + +import android.net.Uri +import android.webkit.WebView +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.utils.webView.BaseWebChromeClient +import io.novafoundation.nova.common.utils.webView.BaseWebChromeClientFactory +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClient +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClientFactory +import io.novafoundation.nova.feature_assets.presentation.novacard.overview.model.CardSetupConfig +import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor +import kotlinx.coroutines.CoroutineScope + +class NovaCardWebViewControllerFactory( + private val interceptingWebViewClientFactory: InterceptingWebViewClientFactory, + private val novaCardWebChromeClientFactory: BaseWebChromeClientFactory, + private val appLinksProvider: AppLinksProvider, + private val widgetId: String +) { + + fun create( + interceptors: List, + setupConfig: CardSetupConfig, + scope: CoroutineScope, + ): NovaCardWebViewController { + return NovaCardWebViewController( + interceptingWebViewClient = interceptingWebViewClientFactory.create(interceptors), + novaCardWebChromeClient = novaCardWebChromeClientFactory.create(scope), + appLinksProvider = appLinksProvider, + setupConfig = setupConfig, + widgetId = widgetId + ) + } +} + +class NovaCardWebViewController( + private val interceptingWebViewClient: InterceptingWebViewClient, + private val novaCardWebChromeClient: BaseWebChromeClient, + private val appLinksProvider: AppLinksProvider, + private val setupConfig: CardSetupConfig, + private val widgetId: String +) { + + fun setup(webView: WebView) { + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = true + allowContentAccess = true + useWideViewPort = true + displayZoomControls = false + } + + webView.webViewClient = interceptingWebViewClient + webView.webChromeClient = novaCardWebChromeClient + + loadUrl(webView) + } + + private fun loadUrl(webView: WebView) { + val uri = Uri.parse(appLinksProvider.pezkuwiCardWidgetUrl).buildUpon() + .appendQueryParameter("widget_id", widgetId) + .appendQueryParameter("type", "sell") + .appendQueryParameter("currencies", setupConfig.spendToken.symbol.value) + .appendQueryParameter("theme", "nova") + .appendQueryParameter("show_spend_card_details", "true") + .appendQueryParameter("hide_refund_address", "true") + .appendQueryParameter("refund_address", setupConfig.refundAddress) + .build() + + webView.loadUrl(uri.toString()) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/webViewController/interceptors/CardCreationInterceptor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/webViewController/interceptors/CardCreationInterceptor.kt new file mode 100644 index 0000000..862cf49 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/overview/webViewController/interceptors/CardCreationInterceptor.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.overview.webViewController.interceptors + +import android.webkit.WebResourceRequest +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor +import io.novafoundation.nova.common.utils.webView.makeRequestBlocking +import io.novafoundation.nova.common.utils.webView.toOkHttpRequestBuilder +import okhttp3.OkHttpClient + +class CardCreationInterceptorFactory( + private val okHttpClient: OkHttpClient, + private val gson: Gson +) { + fun create(callback: CardCreationInterceptor.Callback): CardCreationInterceptor { + return CardCreationInterceptor(okHttpClient, gson, callback) + } +} + +class CardCreationInterceptor( + private val okHttpClient: OkHttpClient, + private val gson: Gson, + private val onCardCreatedListener: Callback +) : WebViewRequestInterceptor { + + interface Callback { + + fun onCardCreated() + } + + private val interceptionPattern = Regex("https://api\\.mercuryo\\.io/[a-zA-Z0-9.]+/cards") + + override fun intercept(request: WebResourceRequest): Boolean { + val url = request.url.toString() + + if (url.contains(interceptionPattern)) { + return performOkHttpRequest(request) + } + + return false + } + + private fun performOkHttpRequest(request: WebResourceRequest): Boolean { + val requestBuilder = request.toOkHttpRequestBuilder() + + return try { + val response = okHttpClient.makeRequestBlocking(requestBuilder) + val cardsResponse = gson.fromJson(response.body!!.string(), CardsResponse::class.java) + + if (cardsResponse.isCardCreated()) { + onCardCreatedListener.onCardCreated() + } + + true + } catch (e: Exception) { + false + } + } +} + +/** + * The full response is: {"status":200,"total":1,"next":null,"prev":null,"data":[{"id":"0c82dafa12d352193","created_at":"2024-08-23 10:12:10","payment_system":"Mastercard","card_number":"************7907","card_expiration_month":"08","card_expiration_year":"2029","bank":null,"issued_by_mercuryo":true,"fiat_card_id":"0c82dab8d9d316059","fiat_card_status":"active"}]} + */ +private class CardsResponse(val data: List) { + + class Card(@SerializedName("issued_by_mercuryo") val issuedByMercurio: Boolean) +} + +private fun CardsResponse.isCardCreated() = data.any { it.issuedByMercurio } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/WaitingNovaCardTopUpFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/WaitingNovaCardTopUpFragment.kt new file mode 100644 index 0000000..8d84d7b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/WaitingNovaCardTopUpFragment.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.waiting + +import android.content.DialogInterface +import io.novafoundation.nova.common.base.BaseBottomSheetFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.FragmentWaitingNovaCardTopUpBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent + +class WaitingNovaCardTopUpFragment : BaseBottomSheetFragment() { + + override fun createBinding() = FragmentWaitingNovaCardTopUpBinding.inflate(layoutInflater) + + override fun initViews() { + dialog?.setCanceledOnTouchOutside(false) + getBehaviour().isHideable = false + } + + override fun onCancel(dialog: DialogInterface) { + viewModel.onScreenCancelled() + + super.onCancel(dialog) + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .waitingNovaCardTopUpComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: WaitingNovaCardTopUpViewModel) { + viewModel.titleFlow.observe { + binder.topUpWaitingTitle.text = it + } + + binder.waitingTopUpCardTimer.startTimer( + value = viewModel.getTimerValue(), + customMessageFormat = R.string.waiting_top_up_card_timer, + durationFormatter = viewModel.getTimerFormatter(), + lifecycle = lifecycle, + onFinish = { viewModel.timerFinished() } + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/WaitingNovaCardTopUpViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/WaitingNovaCardTopUpViewModel.kt new file mode 100644 index 0000000..cd2241c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/WaitingNovaCardTopUpViewModel.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.waiting + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.common.utils.formatting.duration.CompoundDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.EstimatedDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.TimeDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.ZeroDurationFormatter +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.novacard.waiting.flows.TopUpCaseFactory + +class WaitingNovaCardTopUpViewModel( + private val assetsRouter: AssetsRouter, + private val novaCardInteractor: NovaCardInteractor, + private val topUpCaseFactory: TopUpCaseFactory +) : BaseViewModel() { + + private val topUpCase = topUpCaseFactory.create(novaCardInteractor.getNovaCardState()) + + val titleFlow = topUpCase.titleFlow + + init { + topUpCase.init(this) + } + + fun getTimerValue(): TimerValue { + val estimatedTopUpDuration = novaCardInteractor.getEstimatedTopUpDuration() + return TimerValue(estimatedTopUpDuration, System.currentTimeMillis()) + } + + fun getTimerFormatter(): EstimatedDurationFormatter { + val timeDurationFormatter = TimeDurationFormatter(useHours = false) + val compoundFormatter = CompoundDurationFormatter( + timeDurationFormatter, + ZeroDurationFormatter(timeDurationFormatter) + ) + + return EstimatedDurationFormatter(compoundFormatter) + } + + fun timerFinished() { + topUpCase.onTimeFinished(this) + + assetsRouter.back() + } + + fun onScreenCancelled() { + assetsRouter.closeNovaCard() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/di/WaitingNovaCardTopUpComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/di/WaitingNovaCardTopUpComponent.kt new file mode 100644 index 0000000..16def8a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/di/WaitingNovaCardTopUpComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.waiting.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.novacard.waiting.WaitingNovaCardTopUpFragment + +@Subcomponent( + modules = [ + WaitingNovaCardTopUpModule::class + ] +) +@ScreenScope +interface WaitingNovaCardTopUpComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): WaitingNovaCardTopUpComponent + } + + fun inject(fragment: WaitingNovaCardTopUpFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/di/WaitingNovaCardTopUpModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/di/WaitingNovaCardTopUpModule.kt new file mode 100644 index 0000000..7ebe294 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/di/WaitingNovaCardTopUpModule.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.waiting.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.novacard.waiting.WaitingNovaCardTopUpViewModel +import io.novafoundation.nova.feature_assets.presentation.novacard.waiting.flows.TopUpCaseFactory + +@Module(includes = [ViewModelModule::class]) +class WaitingNovaCardTopUpModule { + + @Provides + fun provideTopUpCaseFactory( + assetsRouter: AssetsRouter, + resourceManager: ResourceManager, + novaCardInteractor: NovaCardInteractor + ) = TopUpCaseFactory( + assetsRouter, + resourceManager, + novaCardInteractor + ) + + @Provides + @IntoMap + @ViewModelKey(WaitingNovaCardTopUpViewModel::class) + fun provideViewModel( + assetsRouter: AssetsRouter, + novaCardInteractor: NovaCardInteractor, + topUpCaseFactory: TopUpCaseFactory + ): ViewModel { + return WaitingNovaCardTopUpViewModel(assetsRouter, novaCardInteractor, topUpCaseFactory) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): WaitingNovaCardTopUpViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(WaitingNovaCardTopUpViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/EmptyTopUpCase.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/EmptyTopUpCase.kt new file mode 100644 index 0000000..33d5572 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/EmptyTopUpCase.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.waiting.flows + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class EmptyTopUpCase( + private val assetsRouter: AssetsRouter +) : TopUpCase { + + override val titleFlow: Flow = emptyFlow() + + override fun init(viewModel: BaseViewModel) { + assetsRouter.back() + } + + override fun onTimeFinished(viewModel: BaseViewModel) {} +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/InitialTopUpCase.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/InitialTopUpCase.kt new file mode 100644 index 0000000..3e39005 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/InitialTopUpCase.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.waiting.flows + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardInteractor +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardState +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class InitialTopUpCase( + private val assetsRouter: AssetsRouter, + private val resourceManager: ResourceManager, + private val novaCardInteractor: NovaCardInteractor +) : TopUpCase { + + override val titleFlow: Flow = flowOf { resourceManager.getString(R.string.waiting_initial_top_up_card_title) } + + override fun init(viewModel: BaseViewModel) { + novaCardInteractor.observeNovaCardState() + .onEach { novaCardState -> + if (novaCardState == NovaCardState.CREATED) { + assetsRouter.back() + } + }.launchIn(viewModel) + } + + override fun onTimeFinished(viewModel: BaseViewModel) { + novaCardInteractor.setNovaCardState(NovaCardState.NONE) + + viewModel.showError( + resourceManager.getString(R.string.fragment_waiting_top_up_time_out_error_title), + resourceManager.getString(R.string.fragment_waiting_top_up_time_out_error_message) + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/TopUpCase.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/TopUpCase.kt new file mode 100644 index 0000000..fc0b1af --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/TopUpCase.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.waiting.flows + +import io.novafoundation.nova.common.base.BaseViewModel +import kotlinx.coroutines.flow.Flow + +interface TopUpCase { + + val titleFlow: Flow + + fun init(viewModel: BaseViewModel) + + fun onTimeFinished(viewModel: BaseViewModel) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/TopUpCaseFactory.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/TopUpCaseFactory.kt new file mode 100644 index 0000000..d265100 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/TopUpCaseFactory.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.waiting.flows + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardInteractor +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardState +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter + +class TopUpCaseFactory( + private val assetsRouter: AssetsRouter, + private val resourceManager: ResourceManager, + private val novaCardInteractor: NovaCardInteractor +) { + fun create(state: NovaCardState): TopUpCase { + return when (state) { + NovaCardState.NONE -> EmptyTopUpCase(assetsRouter) + NovaCardState.CREATION -> InitialTopUpCase( + assetsRouter, + resourceManager, + novaCardInteractor + ) + + NovaCardState.CREATED -> TopUpExistingCardCase( + assetsRouter, + resourceManager, + novaCardInteractor + ) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/TopUpExistingCardCase.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/TopUpExistingCardCase.kt new file mode 100644 index 0000000..5c174fc --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/novacard/waiting/flows/TopUpExistingCardCase.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_assets.presentation.novacard.waiting.flows + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.novaCard.NovaCardInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class TopUpExistingCardCase( + private val assetsRouter: AssetsRouter, + private val resourceManager: ResourceManager, + private val novaCardInteractor: NovaCardInteractor +) : TopUpCase { + + override val titleFlow: Flow = flowOf { resourceManager.getString(R.string.waiting_existing_card_top_up_card_title) } + + override fun init(viewModel: BaseViewModel) { + novaCardInteractor.observeTopUpFinishedEvent() + .onEach { + assetsRouter.back() + } + .launchIn(viewModel) + } + + override fun onTimeFinished(viewModel: BaseViewModel) { + // Do nothing + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/ReceiveFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/ReceiveFragment.kt new file mode 100644 index 0000000..0a40cd8 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/ReceiveFragment.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_assets.presentation.receive + +import android.os.Bundle + +import androidx.core.view.drawToBitmap +import androidx.core.view.isVisible +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.share.shareImageWithText +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.FragmentReceiveBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import javax.inject.Inject + +private const val KEY_PAYLOAD = "KEY_PAYLOAD" + +class ReceiveFragment : BaseFragment() { + + override fun createBinding() = FragmentReceiveBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + companion object { + + fun getBundle(assetPayload: AssetPayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, assetPayload) + } + } + + override fun initViews() { + binder.receiveCopyButton.setOnClickListener { viewModel.copyAddressClicked() } + + binder.receiveBackButton.setOnClickListener { viewModel.backClicked() } + + binder.receiveShare.setOnClickListener { + val qrBitmap = binder.receiveQrCode.drawToBitmap() + viewModel.shareButtonClicked(qrBitmap) + } + + binder.receiveQrCodeContainer.background = requireContext().getRoundedCornerDrawable(fillColorRes = R.color.qr_code_background) + binder.receiveQrCodeContainer.clipToOutline = true + + binder.receiveAddressesButton.setOnClickListener { viewModel.chainAddressesClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .receiveComponentFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ReceiveViewModel) { + viewModel.chainFlow.observe(binder.receiveChain::setChain) + viewModel.titleFlow.observe(binder.receiveTitle::setText) + viewModel.subtitleFlow.observe(binder.receiveSubtitle::setText) + viewModel.qrCodeFlow.observe(binder.receiveQrCode::setQrModel) + viewModel.accountNameFlow.observe(binder.receiveAccount::setText) + viewModel.addressFlow.observe(binder.receiveAddress::setText) + viewModel.chainSupportsLegacyAddressFlow.observe { + binder.receiveAddressesWarning.isVisible = it + binder.receiveAddressesButton.isVisible = it + } + + viewModel.shareEvent.observeEvent { + shareImageWithText(it, getString(R.string.wallet_receive_description_v2_2_0)) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/ReceiveViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/ReceiveViewModel.kt new file mode 100644 index 0000000..7903da4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/ReceiveViewModel.kt @@ -0,0 +1,137 @@ +package io.novafoundation.nova.feature_assets.presentation.receive + +import android.graphics.Bitmap +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.share.ImageWithTextSharing +import io.novafoundation.nova.common.view.QrCodeModel +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.domain.account.common.ChainWithAccountId +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.receive.ReceiveInteractor +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.supportsLegacyAddressFormat +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +private const val COLORED_OVERLAY_PADDING_DP = 0 +private const val WHITE_OVERLAY_PADDING_DP = 8 + +class ReceiveViewModel( + private val interactor: ReceiveInteractor, + private val qrCodeGenerator: QrCodeGenerator, + private val resourceManager: ResourceManager, + private val assetPayload: AssetPayload, + private val chainRegistry: ChainRegistry, + selectedAccountUseCase: SelectedAccountUseCase, + private val router: AssetsRouter, + private val assetIconProvider: AssetIconProvider, + private val copyAddressMixin: CopyAddressMixin +) : BaseViewModel() { + + private val selectedMetaAccountFlow = selectedAccountUseCase.selectedMetaAccountFlow() + .shareInBackground() + + private val chainWithAssetAsync by lazyAsync { + chainRegistry.chainWithAsset(assetPayload.chainId, assetPayload.chainAssetId) + } + + val chainFlow = flowOf { mapChainToUi(chainWithAssetAsync().chain) } + .shareInBackground() + + val chainSupportsLegacyAddressFlow = flowOf { chainWithAssetAsync().chain.supportsLegacyAddressFormat() } + + val titleFlow = flowOf { + val (_, chainAsset) = chainWithAssetAsync() + resourceManager.getString(R.string.wallet_asset_receive_token, chainAsset.symbol) + } + + val subtitleFlow = flowOf { + val (chain, chainAsset) = chainWithAssetAsync() + resourceManager.getString(R.string.wallet_asset_receive_token_subtitle, chainAsset.symbol, chain.name) + } + + val qrCodeFlow = flowOf { + val assetIconMode = interactor.getAssetIconMode() + val qrInput = interactor.getQrCodeSharingString(assetPayload.chainId) + + val (overlayPadding, overlayBackground) = when (assetIconMode) { + AssetIconMode.COLORED -> COLORED_OVERLAY_PADDING_DP to null + AssetIconMode.WHITE -> WHITE_OVERLAY_PADDING_DP to resourceManager.getDrawable(R.drawable.bg_common_circle) + } + + QrCodeModel( + qrCodeGenerator.generateQrCode(qrInput), + overlayBackground, + overlayPadding, + assetIconProvider.getAssetIconOrFallback(chainWithAssetAsync().asset) + ) + } + + val accountNameFlow = selectedMetaAccountFlow.map { it.name } + + val addressFlow = selectedMetaAccountFlow.map { it.addressIn(chainWithAssetAsync().chain)!! } + + private val _shareEvent = MutableLiveData>() + val shareEvent: LiveData> = _shareEvent + + fun copyAddressClicked() = launch { + val chain = chainWithAssetAsync().chain + val address = addressFlow.first() + copyAddressMixin.copyAddressOrOpenSelector(ChainWithAccountId(chain, chain.accountIdOf(address))) + } + + fun backClicked() { + router.back() + } + + fun shareButtonClicked(qrBitmap: Bitmap) = launch { + val address = addressFlow.first() + val (chain, chainAsset) = chainWithAssetAsync() + + viewModelScope.launch { + interactor.generateTempQrFile(qrBitmap) + .onSuccess { fileUri -> + val message = generateShareMessage(chain, chainAsset, address) + + _shareEvent.value = Event(ImageWithTextSharing(fileUri, message)) + } + .onFailure(::showError) + } + } + + private fun generateShareMessage(chain: Chain, tokenType: Chain.Asset, address: String): String { + return resourceManager.getString(R.string.wallet_receive_share_message).format( + chain.name, + tokenType.symbol + ) + " " + address + } + + fun chainAddressesClicked() { + launch { + val chain = chainWithAssetAsync().chain + val address = addressFlow.first() + copyAddressMixin.openAddressSelector(chain.id, chain.accountIdOf(address)) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/di/ReceiveComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/di/ReceiveComponent.kt new file mode 100644 index 0000000..5b3cde0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/di/ReceiveComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_assets.presentation.receive.ReceiveFragment + +@Subcomponent( + modules = [ + ReceiveModule::class + ] +) +@ScreenScope +interface ReceiveComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AssetPayload, + ): ReceiveComponent + } + + fun inject(fragment: ReceiveFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/di/ReceiveModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/di/ReceiveModule.kt new file mode 100644 index 0000000..aa9e866 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/di/ReceiveModule.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.copyAddress.CopyAddressMixin +import io.novafoundation.nova.feature_assets.domain.receive.ReceiveInteractor +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.receive.ReceiveViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class ReceiveModule { + + @Provides + @ScreenScope + fun provideInteractor( + fileProvider: FileProvider, + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + assetsIconModeRepository: AssetsIconModeRepository + ) = ReceiveInteractor(fileProvider, chainRegistry, accountRepository, assetsIconModeRepository) + + @Provides + @IntoMap + @ViewModelKey(ReceiveViewModel::class) + fun provideViewModel( + interactor: ReceiveInteractor, + qrCodeGenerator: QrCodeGenerator, + resourceManager: ResourceManager, + router: AssetsRouter, + chainRegistry: ChainRegistry, + selectedAccountUseCase: SelectedAccountUseCase, + payload: AssetPayload, + assetIconProvider: AssetIconProvider, + copyAddressMixin: CopyAddressMixin + ): ViewModel { + return ReceiveViewModel( + interactor, + qrCodeGenerator, + resourceManager, + payload, + chainRegistry, + selectedAccountUseCase, + router, + assetIconProvider, + copyAddressMixin + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ReceiveViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ReceiveViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/AssetReceiveFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/AssetReceiveFlowFragment.kt new file mode 100644 index 0000000..2efa084 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/AssetReceiveFlowFragment.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.flow.asset + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowFragment + +class AssetReceiveFlowFragment : AssetFlowFragment() { + + override fun initViews() { + super.initViews() + setTitle(R.string.wallet_asset_receive) + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .receiveFlowComponent() + .create(this) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/AssetReceiveFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/AssetReceiveFlowViewModel.kt new file mode 100644 index 0000000..e4bc5eb --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/AssetReceiveFlowViewModel.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.flow.asset + +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class AssetReceiveFlowViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + currencyInteractor: CurrencyInteractor, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + networkAssetMapper: NetworkAssetFormatter, + tokenAssetFormatter: TokenAssetFormatter +) : AssetFlowViewModel( + interactorFactory, + router, + currencyInteractor, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + assetIconProvider, + assetViewModeInteractor, + networkAssetMapper, + tokenAssetFormatter +) { + override fun searchAssetsFlow(): Flow { + return interactor.searchReceiveAssetsFlow(query, externalBalancesFlow) + } + + override fun assetClicked(asset: Chain.Asset) { + validate(asset) { + openNextScreen(asset) + } + } + + override fun tokenClicked(tokenGroup: TokenGroupUi) { + when (val type = tokenGroup.groupType) { + is TokenGroupUi.GroupType.SingleItem -> assetClicked(type.asset) + + TokenGroupUi.GroupType.Group -> router.openReceiveNetworks(NetworkFlowPayload(tokenGroup.tokenSymbol)) + } + } + + private fun openNextScreen(asset: Chain.Asset) { + val assetPayload = AssetPayload(asset.chainId, asset.id) + router.openReceive(assetPayload) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/di/AssetReceiveFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/di/AssetReceiveFlowComponent.kt new file mode 100644 index 0000000..788b624 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/di/AssetReceiveFlowComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.flow.asset.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.receive.flow.asset.AssetReceiveFlowFragment + +@Subcomponent( + modules = [ + AssetReceiveFlowModule::class + ] +) +@ScreenScope +interface AssetReceiveFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): AssetReceiveFlowComponent + } + + fun inject(fragment: AssetReceiveFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/di/AssetReceiveFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/di/AssetReceiveFlowModule.kt new file mode 100644 index 0000000..4f7e9da --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/asset/di/AssetReceiveFlowModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.flow.asset.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.receive.flow.asset.AssetReceiveFlowViewModel +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor + +@Module(includes = [ViewModelModule::class]) +class AssetReceiveFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AssetReceiveFlowViewModel { + return ViewModelProvider(fragment, factory).get(AssetReceiveFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AssetReceiveFlowViewModel::class) + fun provideViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + currencyInteractor: CurrencyInteractor, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + networkAssetMapper: NetworkAssetFormatter, + tokenAssetFormatter: TokenAssetFormatter + ): ViewModel { + return AssetReceiveFlowViewModel( + interactorFactory = interactorFactory, + router = router, + currencyInteractor = currencyInteractor, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + assetIconProvider = assetIconProvider, + assetViewModeInteractor = assetViewModeInteractor, + networkAssetMapper = networkAssetMapper, + tokenAssetFormatter = tokenAssetFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/NetworkReceiveFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/NetworkReceiveFlowFragment.kt new file mode 100644 index 0000000..a936e0a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/NetworkReceiveFlowFragment.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.flow.network + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowFragment + +class NetworkReceiveFlowFragment : + NetworkFlowFragment() { + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .networkReceiveFlowComponent() + .create(this, payload()) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/NetworkReceiveFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/NetworkReceiveFlowViewModel.kt new file mode 100644 index 0000000..b80c992 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/NetworkReceiveFlowViewModel.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.flow.network + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.model.NetworkFlowRvItem +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.Flow + +class NetworkReceiveFlowViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter +) : NetworkFlowViewModel( + interactor, + router, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + networkFlowPayload, + chainRegistry, + amountFormatter +) { + + override fun getAssetBalance(asset: AssetWithNetwork): AssetBalance.Amount { + return asset.balanceWithOffChain.total + } + + override fun assetsFlow(tokenSymbol: TokenSymbol): Flow> { + return interactor.receiveAssetFlow(tokenSymbol, externalBalancesFlow) + } + + override fun networkClicked(network: NetworkFlowRvItem) { + validateControllsAsset(network) { + router.openReceive(AssetPayload(network.chainId, network.assetId)) + } + } + + override fun getTitle(tokenSymbol: TokenSymbol): String { + return resourceManager.getString(R.string.receive_network_flow_title, tokenSymbol.value) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/di/NetworkReceiveFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/di/NetworkReceiveFlowComponent.kt new file mode 100644 index 0000000..4f680b0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/di/NetworkReceiveFlowComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.flow.network.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.receive.flow.network.NetworkReceiveFlowFragment + +@Subcomponent( + modules = [ + NetworkReceiveFlowModule::class + ] +) +@ScreenScope +interface NetworkReceiveFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance networkFlowPayload: NetworkFlowPayload + ): NetworkReceiveFlowComponent + } + + fun inject(fragment: NetworkReceiveFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/di/NetworkReceiveFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/di/NetworkReceiveFlowModule.kt new file mode 100644 index 0000000..ecbdf0b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/flow/network/di/NetworkReceiveFlowModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.flow.network.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.receive.flow.network.NetworkReceiveFlowViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class NetworkReceiveFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NetworkReceiveFlowViewModel { + return ViewModelProvider(fragment, factory).get(NetworkReceiveFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(NetworkReceiveFlowViewModel::class) + fun provideViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter + ): ViewModel { + return NetworkReceiveFlowViewModel( + interactor = interactor, + router = router, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + networkFlowPayload = networkFlowPayload, + chainRegistry = chainRegistry, + amountFormatter = amountFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/model/TokenReceiver.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/model/TokenReceiver.kt new file mode 100644 index 0000000..a9edb9a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/model/TokenReceiver.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.model + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi + +class TokenReceiver( + val addressModel: AddressModel, + val chain: ChainUi, + val chainAssetIcon: Icon +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/view/LedgerNotSupportedWarningBottomSheet.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/view/LedgerNotSupportedWarningBottomSheet.kt new file mode 100644 index 0000000..fd7cec6 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/receive/view/LedgerNotSupportedWarningBottomSheet.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_assets.presentation.receive.view + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.view.bottomSheet.ActionNotAllowedBottomSheet +import io.novafoundation.nova.feature_assets.R + +class LedgerNotSupportedWarningBottomSheet( + context: Context, + onSuccess: () -> Unit, + private val message: String +) : ActionNotAllowedBottomSheet(context, onSuccess) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + titleView.setText(R.string.assets_receive_ledger_not_supported_title) + subtitleView.text = message + + applySolidIconStyle(R.drawable.ic_ledger) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/TransferDirectionModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/TransferDirectionModel.kt new file mode 100644 index 0000000..7c5a5ab --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/TransferDirectionModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_assets.presentation.send + +import io.novafoundation.nova.feature_account_api.view.ChainChipModel + +class TransferDirectionModel( + val originChip: ChainChipModel, + val originChainLabel: String, + val destinationChip: ChainChipModel? +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/TransferDraft.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/TransferDraft.kt new file mode 100644 index 0000000..b40acab --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/TransferDraft.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_assets.presentation.send + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.presenatation.fee.FeePaymentCurrencyParcel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class TransferDraft( + val amount: BigDecimal, + val transferringMaxAmount: Boolean, + val origin: AssetPayload, + val feePaymentCurrency: FeePaymentCurrencyParcel, + val destination: AssetPayload, + val recipientAddress: String, + val openAssetDetailsOnCompletion: Boolean +) : Parcelable + +val TransferDraft.isCrossChain + get() = origin.chainId != destination.chainId diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/SelectSendFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/SelectSendFragment.kt new file mode 100644 index 0000000..a30c86e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/SelectSendFragment.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.feature_assets.presentation.send.amount + +import android.view.View +import androidx.core.os.bundleOf + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.addInputKeyboardCallback +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.removeInputKeyboardCallback +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.setupAddressInput +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.setupExternalAccounts +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.setupYourWalletsBtn +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.FragmentSelectSendBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.send.amount.view.SelectCrossChainDestinationBottomSheet +import io.novafoundation.nova.feature_assets.presentation.send.common.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser + +private const val KEY_ADDRESS = "KEY_ADDRESS" +private const val KEY_ASSET_PAYLOAD = "KEY_ASSET_PAYLOAD" +private const val KEY_INITIAL_AMOUNT = "KEY_INITIAL_AMOUNT" + +class SelectSendFragment : BaseFragment() { + + companion object { + + fun getBundle( + payload: SendPayload, + recipientAddress: String? = null, + initialAmount: Double? = null + ) = bundleOf( + KEY_ADDRESS to recipientAddress, + KEY_ASSET_PAYLOAD to payload, + KEY_INITIAL_AMOUNT to initialAmount + ) + } + + override fun createBinding() = FragmentSelectSendBinding.inflate(layoutInflater) + + override fun applyInsets(rootView: View) { + binder.root.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.selectSendNext.prepareForProgress(viewLifecycleOwner) + binder.selectSendNext.setOnClickListener { viewModel.nextClicked() } + + binder.selectSendToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.selectSendOriginChain.setOnClickListener { viewModel.originChainClicked() } + binder.selectSendDestinationChain.setOnClickListener { viewModel.destinationChainClicked() } + + binder.selectWallet.setOnClickListener { viewModel.selectRecipientWallet() } + + binder.selectSendCrossChainFee.makeGone() // gone inititally + binder.selectSendCrossChainFee.setTitle(R.string.wallet_send_cross_chain_fee) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .chooseAmountComponentFactory() + .create( + fragment = this, + recipientAddress = argument(KEY_ADDRESS), + payload = argument(KEY_ASSET_PAYLOAD), + initialAmount = arguments?.getDouble(KEY_INITIAL_AMOUNT, 0.0)?.takeIf { it > 0 } + ) + .inject(this) + } + + override fun subscribe(viewModel: SelectSendViewModel) { + setupExternalActions(viewModel) + + observeValidations(viewModel) + + viewModel.feeMixin.setupFeeLoading(binder.selectSendOriginFee, binder.selectSendCrossChainFee) + + setupAmountChooser(viewModel.amountChooserMixin, binder.selectSendAmount) + setupAddressInput(viewModel.addressInputMixin, binder.selectSendRecipient) + setupExternalAccounts(viewModel.addressInputMixin, binder.selectSendRecipient) + setupYourWalletsBtn(binder.selectWallet, viewModel.selectAddressMixin) + + viewModel.chooseDestinationChain.awaitableActionLiveData.observeEvent { + removeInputKeyboardCallback(binder.selectSendRecipient) + val crossChainDestinationBottomSheet = SelectCrossChainDestinationBottomSheet( + context = requireContext(), + payload = it.payload, + onSelected = { _, item -> it.onSuccess(item) }, + onCancelled = it.onCancel + ) + crossChainDestinationBottomSheet.setOnDismissListener { addInputKeyboardCallback(viewModel.addressInputMixin, binder.selectSendRecipient) } + crossChainDestinationBottomSheet.show() + } + + viewModel.transferDirectionModel.observe { + binder.selectSendOriginChain.setModel(it.originChip) + binder.selectSendFromTitle.text = it.originChainLabel + + if (it.destinationChip != null) { + binder.selectSendDestinationChain.setModel(it.destinationChip) + binder.selectSendDestinationChain.makeVisible() + binder.selectSendToTitle.makeVisible() + } else { + binder.selectSendToTitle.makeGone() + binder.selectSendDestinationChain.makeGone() + } + } + + viewModel.continueButtonStateLiveData.observe(binder.selectSendNext::setState) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/SelectSendViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/SelectSendViewModel.kt new file mode 100644 index 0000000..57b2fcc --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/SelectSendViewModel.kt @@ -0,0 +1,570 @@ +package io.novafoundation.nova.feature_assets.presentation.send.amount + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.list.headers.TextHeader +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.filterList +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.domain.filter.selectAddress.SelectAccountFilter +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.fee.toParcel +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin +import io.novafoundation.nova.feature_account_api.view.ChainChipModel +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.send.SendInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.send.TransferDirectionModel +import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft +import io.novafoundation.nova.feature_assets.presentation.send.amount.view.CrossChainDestinationModel +import io.novafoundation.nova.feature_assets.presentation.send.amount.view.SelectCrossChainDestinationBottomSheet +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.autoFixSendValidationPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.buildAssetTransfer +import io.novafoundation.nova.feature_assets.presentation.send.common.fee.TransferFeeDisplayFormatter +import io.novafoundation.nova.feature_assets.presentation.send.common.fee.createForTransfer +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.mapAssetTransferValidationFailureToUI +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.isMaxAction +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.PaymentCurrencySelectionMode +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.create +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.DefaultFeeFormatter +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.math.BigDecimal + +class SelectSendViewModel( + private val chainRegistry: ChainRegistry, + private val interactor: WalletInteractor, + private val sendInteractor: SendInteractor, + private val router: AssetsRouter, + private val payload: SendPayload, + private val initialRecipientAddress: String?, + private val initialAmount: Double?, + private val validationExecutor: ValidationExecutor, + private val resourceManager: ResourceManager, + private val externalActions: ExternalActions.Presentation, + private val crossChainTransfersUseCase: CrossChainTransfersUseCase, + private val accountRepository: AccountRepository, + private val maxActionProviderFactory: MaxActionProviderFactory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + selectedAccountUseCase: SelectedAccountUseCase, + addressInputMixinFactory: AddressInputMixinFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + selectAddressMixinFactory: SelectAddressMixin.Factory, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions { + + private val originChainWithAsset = singleReplaySharedFlow() + private val destinationChainWithAsset = singleReplaySharedFlow() + + private val originChainAsset = originChainWithAsset.map { it.asset } + private val originChain = originChainWithAsset.map { it.chain } + + private val destinationAsset = destinationChainWithAsset.map { it.asset } + private val destinationChain = destinationChainWithAsset.map { it.chain } + + private val isCrossChainFlow = combine(originChain, destinationChain) { origin, destination -> + origin.id != destination.id + }.shareInBackground() + + private val selectAddressPayloadFlow = combine( + originChain, + destinationChain + ) { origin, destination -> + SelectAddressMixin.Payload( + chain = destination, + filter = getMetaAccountsFilter(origin, destination) + ) + } + + val selectAddressMixin = selectAddressMixinFactory.create( + coroutineScope = this, + payloadFlow = selectAddressPayloadFlow, + onAddressSelect = ::onAddressSelect + ) + + val addressInputMixin = with(addressInputMixinFactory) { + val destinationChain = destinationChainWithAsset.map { it.chain } + val inputSpec = singleChainInputSpec(destinationChain) + + create( + inputSpecProvider = singleChainInputSpec(destinationChain), + myselfBehaviorProvider = crossChainOnlyMyself(originChain, destinationChain), + accountIdentifierProvider = web3nIdentifiers( + destinationChainFlow = destinationChainWithAsset, + inputSpecProvider = inputSpec, + coroutineScope = this@SelectSendViewModel, + ), + errorDisplayer = this@SelectSendViewModel::showError, + showAccountEvent = this@SelectSendViewModel::showAccountDetails, + coroutineScope = this@SelectSendViewModel, + ) + } + + private val availableCrossChainDestinations = availableCrossChainDestinations() + .onStart { emit(emptyList()) } + .shareInBackground() + + val transferDirectionModel = combine( + availableCrossChainDestinations, + originChainWithAsset, + destinationChainWithAsset, + ::buildTransferDirectionModel + ).shareInBackground() + + val chooseDestinationChain = actionAwaitableMixinFactory.create() + + private val selectedAccount = selectedAccountUseCase.selectedMetaAccountFlow() + .inBackground() + .share() + + private val sendInProgressFlow = MutableStateFlow(false) + + private val originAssetFlow = originChainAsset.flatMapLatest(interactor::assetFlow) + .shareInBackground() + + private val feeFormatter = TransferFeeDisplayFormatter(componentDelegate = DefaultFeeFormatter(amountFormatter)) + val feeMixin = feeLoaderMixinFactory.createForTransfer(originChainAsset, feeFormatter) + + private val maxActionProvider = maxActionProviderFactory.create( + viewModelScope = viewModelScope, + assetInFlow = originAssetFlow, + feeLoaderMixin = feeMixin, + deductEd = isCrossChainFlow + ) + + val amountChooserMixin: AmountChooserMixin.Presentation = amountChooserMixinFactory.create( + scope = this, + assetFlow = originAssetFlow, + maxActionProvider = maxActionProvider + ) + + val continueButtonStateLiveData = combine( + sendInProgressFlow, + amountChooserMixin.inputState + ) { sending, amountState -> + when { + sending -> ButtonState.PROGRESS + amountState.value.isNotEmpty() -> ButtonState.NORMAL + else -> ButtonState.DISABLED + } + } + + init { + subscribeOnChangeDestination() + + setInitialState() + + setupFees() + } + + fun nextClicked() = launch { + sendInProgressFlow.value = true + + val fee = feeMixin.awaitFee() + val amountState = amountChooserMixin.amountState.first() + + val transfer = buildTransfer( + origin = originChainWithAsset.first(), + destination = destinationChainWithAsset.first(), + amount = amountState.value ?: return@launch, + transferringMaxAmount = amountState.inputKind.isMaxAction(), + feePaymentCurrency = feeMixin.feePaymentCurrency(), + address = addressInputMixin.getAddress(), + ) + + val payload = AssetTransferPayload( + transfer = WeightedAssetTransfer( + assetTransfer = transfer, + fee = fee.originFee, + ), + crossChainFee = fee.crossChainFee, + originFee = fee.originFee, + originCommissionAsset = feeMixin.feeAsset(), + originUsedAsset = originAssetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = sendInteractor.validationSystemFor(payload.transfer, viewModelScope), + payload = payload, + progressConsumer = sendInProgressFlow.progressConsumer(), + autoFixPayload = ::autoFixSendValidationPayload, + validationFailureTransformerCustom = { status, actions -> + viewModelScope.mapAssetTransferValidationFailureToUI( + resourceManager = resourceManager, + status = status, + actions = actions, + setFee = { feeMixin.setFee(fee.replaceSubmission(it)) } + ) + }, + ) { + sendInProgressFlow.value = false + + openConfirmScreen(it) + } + } + + fun backClicked() { + router.back() + } + + fun destinationChainClicked() = launch { + val selectedChain = destinationChain.first() + val newDestinationChain = awaitNewDirectionSelection(selectedChain) ?: return@launch + + destinationChainWithAsset.emit(newDestinationChain) + } + + fun originChainClicked() = launch { + val selectedChain = originChain.first() + val newDestinationChain = awaitNewDirectionSelection(selectedChain) ?: return@launch + + originChainWithAsset.emit(newDestinationChain) + } + + fun selectRecipientWallet() { + launch { + val selectedAddress = addressInputMixin.inputFlow.value + selectAddressMixin.openSelectAddress(selectedAddress) + } + } + + private fun showAccountDetails(address: String) { + launch { + val chain = destinationChainWithAsset.first().chain + externalActions.showAddressActions(address, chain) + } + } + + private fun subscribeOnChangeDestination() { + destinationChainWithAsset + .onEach { addressInputMixin.clearExtendedAccount() } + .launchIn(this) + } + + private fun onAddressSelect(address: String) { + addressInputMixin.inputFlow.value = address + } + + private fun setInitialState() = launch { + initialRecipientAddress?.let { addressInputMixin.inputFlow.value = it } + + // Set initial amount if provided (e.g., from bridge screen) + initialAmount?.let { amount -> + if (amount > 0) { + amountChooserMixin.setAmount(amount.toBigDecimal()) + } + } + + when (payload) { + is SendPayload.SpecifiedOrigin -> { + val origin = chainRegistry.chainWithAsset(payload.origin.chainId, payload.origin.chainAssetId) + originChainWithAsset.emit(origin) + destinationChainWithAsset.emit(origin) + } + + is SendPayload.SpecifiedDestination -> { + val destination = chainRegistry.chainWithAsset(payload.destination.chainId, payload.destination.chainAssetId) + + // When destination chain is specified we expect at least one destination to be available + val availableCrossChainDestinations = availableCrossChainDestinations.first { it.isNotEmpty() } + val origin = availableCrossChainDestinations.first().chainWithAsset + + destinationChainWithAsset.emit(destination) + originChainWithAsset.emit(origin) + } + } + } + + private suspend fun awaitNewDirectionSelection(selectedChain: Chain): ChainWithAsset? { + val destinations = availableCrossChainDestinations.first() + if (destinations.isEmpty()) return null + + val payload = withContext(Dispatchers.Default) { + SelectCrossChainDestinationBottomSheet.Payload( + destinations = buildDestinationsMap(destinations), + selectedChain = selectedChain + ) + } + + return chooseDestinationChain.awaitAction(payload) + } + + private fun setupFees() { + feeMixin.connectWith( + originChainWithAsset, + destinationChainWithAsset, + addressInputMixin.inputFlow, + amountChooserMixin.backPressuredAmountState, + ) { paymentCurrency, originAsset, destinationAsset, address, amountState -> + val assetTransfer = buildTransfer( + origin = originAsset, + destination = destinationAsset, + amount = amountState.value, + feePaymentCurrency = paymentCurrency, + address = address, + transferringMaxAmount = amountState.inputKind.isMaxAction() + ) + + sendInteractor.getFee(assetTransfer, viewModelScope) + } + + isCrossChainFlow.onEach { isCrossChain -> + val mode = determineFeeSelectionMode(isCrossChain) + + feeFormatter.crossChainFeeShown = isCrossChain + feeMixin.setPaymentCurrencySelectionMode(mode) + }.launchIn(this) + } + + private fun determineFeeSelectionMode(isCrossChain: Boolean): PaymentCurrencySelectionMode { + // Enable custom fee only for on chain transfers + return if (isCrossChain) { + PaymentCurrencySelectionMode.DISABLED + } else { + PaymentCurrencySelectionMode.ENABLED + } + } + + private fun openConfirmScreen(validPayload: AssetTransferPayload) = launch { + val transferDraft = TransferDraft( + amount = validPayload.transfer.amount, + transferringMaxAmount = validPayload.transfer.transferringMaxAmount, + origin = AssetPayload( + chainId = validPayload.transfer.originChain.id, + chainAssetId = validPayload.transfer.originChainAsset.id + ), + feePaymentCurrency = validPayload.transfer.feePaymentCurrency.toParcel(), + destination = AssetPayload( + chainId = validPayload.transfer.destinationChain.id, + chainAssetId = validPayload.transfer.destinationChainAsset.id + ), + recipientAddress = validPayload.transfer.recipient, + openAssetDetailsOnCompletion = payload is SendPayload.SpecifiedOrigin + ) + + router.openConfirmTransfer(transferDraft) + } + + private suspend fun buildTransfer( + origin: ChainWithAsset, + feePaymentCurrency: FeePaymentCurrency, + destination: ChainWithAsset, + amount: BigDecimal, + transferringMaxAmount: Boolean, + address: String, + ): AssetTransfer { + return buildAssetTransfer( + metaAccount = selectedAccount.first(), + feePaymentCurrency = feePaymentCurrency, + origin = origin, + destination = destination, + amount = amount, + transferringMaxAmount = transferringMaxAmount, + address = address + ) + } + + private fun buildTransferDirectionModel( + availableCrossChainDestinations: List, + origin: ChainWithAsset, + destination: ChainWithAsset + ): TransferDirectionModel { + return when (payload) { + is SendPayload.SpecifiedDestination -> buildInTransferDirectionModel(availableCrossChainDestinations, origin, destination) + is SendPayload.SpecifiedOrigin -> buildOutTransferDirectionModel(availableCrossChainDestinations, origin, destination) + } + } + + private fun buildInTransferDirectionModel( + availableCrossChainDestinations: List, + origin: ChainWithAsset, + destination: ChainWithAsset + ): TransferDirectionModel { + val chainSymbol = origin.asset.symbol + val destinationChip = ChainChipModel( + chainUi = mapChainToUi(destination.chain), + changeable = false // when in is specified destination is never changeable + ) + val originLabel = resourceManager.getString(R.string.wallet_send_tokens_from, chainSymbol) + + return if (availableCrossChainDestinations.size > 1) { + TransferDirectionModel( + originChip = ChainChipModel( + chainUi = mapChainToUi(origin.chain), + changeable = true + ), + originChainLabel = originLabel, + destinationChip = destinationChip + ) + } else { + TransferDirectionModel( + originChip = ChainChipModel( + chainUi = mapChainToUi(origin.chain), + changeable = false + ), + originChainLabel = originLabel, + destinationChip = destinationChip + ) + } + } + + private fun buildOutTransferDirectionModel( + availableCrossChainDestinations: List, + origin: ChainWithAsset, + destination: ChainWithAsset + ): TransferDirectionModel { + val chainSymbol = origin.asset.symbol + val originChip = ChainChipModel( + chainUi = mapChainToUi(origin.chain), + changeable = false // when out is specified origin is never changeable + ) + + return if (availableCrossChainDestinations.isNotEmpty()) { + TransferDirectionModel( + originChip = originChip, + originChainLabel = resourceManager.getString(R.string.wallet_send_tokens_from, chainSymbol), + destinationChip = ChainChipModel( + chainUi = mapChainToUi(destination.chain), + changeable = true // we can always change between at least one cross chain transfer and on-chain one + ) + ) + } else { + TransferDirectionModel( + originChip = originChip, + originChainLabel = resourceManager.getString(R.string.wallet_send_tokens_on, chainSymbol), + destinationChip = null + ) + } + } + + private suspend fun buildDestinationsMap(crossChainDestinations: List): Map> { + val crossChainDestinationModels = crossChainDestinations.map { + CrossChainDestinationModel( + chainWithAsset = it.chainWithAsset, + chainUi = mapChainToUi(it.chainWithAsset.chain), + balance = it.balances?.let { asset -> amountFormatter.formatAmountToAmountModel(asset.transferable, asset) } + ) + } + + val onChainDestinationModel = if (payload is SendPayload.SpecifiedOrigin) { + val origin = originChainWithAsset.first() + + CrossChainDestinationModel( + chainWithAsset = origin, + chainUi = mapChainToUi(origin.chain), + balance = null + ) + } else { + null + } + + return buildMap { + onChainDestinationModel?.let { + put(TextHeader(resourceManager.getString(R.string.wallet_send_on_chain)), listOf(onChainDestinationModel)) + } + + put(TextHeader(resourceManager.getString(R.string.wallet_send_cross_chain)), crossChainDestinationModels) + } + } + + private fun availableCrossChainDestinations(): Flow> { + return when (payload) { + is SendPayload.SpecifiedDestination -> availableInDirections() + is SendPayload.SpecifiedOrigin -> availableOutDirections() + } + } + + private fun availableInDirections(): Flow> { + return crossChainTransfersUseCase.incomingCrossChainDirections(destinationAsset) + .filterList { it.chain.isEnabled } + .mapList { incomingDirection -> + CrossChainDirection( + chainWithAsset = ChainWithAsset(incomingDirection.chain, incomingDirection.asset.token.configuration), + balances = incomingDirection.asset + ) + } + } + + private fun availableOutDirections(): Flow> { + return originChainAsset.flatMapLatest { + crossChainTransfersUseCase.outcomingCrossChainDirectionsFlow(it) + .filterList { it.chain.isEnabled } + .mapList { incomingDirection -> + CrossChainDirection( + chainWithAsset = ChainWithAsset(incomingDirection.chain, incomingDirection.asset), + balances = null + ) + } + } + } + + private suspend fun getMetaAccountsFilter(origin: Chain, destination: Chain): SelectAccountFilter { + val isCrossChain = origin.id != destination.id + + return if (isCrossChain) { + SelectAccountFilter.Everything() + } else { + val destinationAccountId = selectedAccount.first().requireAccountIdIn(destination) + val notOriginMetaAccounts = accountRepository.getActiveMetaAccounts() + .filter { it.accountIdIn(origin)?.intoKey() == destinationAccountId.intoKey() } + .map { it.id } + + SelectAccountFilter.ExcludeMetaAccounts( + notOriginMetaAccounts + ) + } + } + + private class CrossChainDirection( + val chainWithAsset: ChainWithAsset, + val balances: Asset? + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/SendPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/SendPayload.kt new file mode 100644 index 0000000..1101a7d --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/SendPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_assets.presentation.send.amount + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import kotlinx.parcelize.Parcelize + +sealed class SendPayload : Parcelable { + + @Parcelize + class SpecifiedOrigin(val origin: AssetPayload) : SendPayload() + + @Parcelize + class SpecifiedDestination(val destination: AssetPayload) : SendPayload() +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/di/SelectSendComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/di/SelectSendComponent.kt new file mode 100644 index 0000000..6c4b7b8 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/di/SelectSendComponent.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_assets.presentation.send.amount.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.send.amount.SelectSendFragment +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload + +@Subcomponent( + modules = [ + SelectSendModule::class + ] +) +@ScreenScope +interface SelectSendComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance recipientAddress: String?, + @BindsInstance payload: SendPayload, + @BindsInstance initialAmount: Double? + ): SelectSendComponent + } + + fun inject(fragment: SelectSendFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/di/SelectSendModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/di/SelectSendModule.kt new file mode 100644 index 0000000..2c6ba12 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/di/SelectSendModule.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_assets.presentation.send.amount.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.send.SendInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.send.amount.SelectSendViewModel +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class SelectSendModule { + + @Provides + @IntoMap + @ViewModelKey(SelectSendViewModel::class) + fun provideViewModel( + chainRegistry: ChainRegistry, + interactor: WalletInteractor, + sendInteractor: SendInteractor, + router: AssetsRouter, + payload: SendPayload, + initialRecipientAddress: String?, + initialAmount: Double?, + validationExecutor: ValidationExecutor, + resourceManager: ResourceManager, + externalActions: ExternalActions.Presentation, + crossChainTransfersUseCase: CrossChainTransfersUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + selectedAccountUseCase: SelectedAccountUseCase, + addressInputMixinFactory: AddressInputMixinFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + accountRepository: AccountRepository, + selectAddressMixinFactory: SelectAddressMixin.Factory, + maxActionProviderFactory: MaxActionProviderFactory, + amountFormatter: AmountFormatter + ): ViewModel { + return SelectSendViewModel( + chainRegistry = chainRegistry, + interactor = interactor, + sendInteractor = sendInteractor, + router = router, + payload = payload, + initialRecipientAddress = initialRecipientAddress, + initialAmount = initialAmount, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + externalActions = externalActions, + crossChainTransfersUseCase = crossChainTransfersUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + feeLoaderMixinFactory = feeLoaderMixinFactory, + selectedAccountUseCase = selectedAccountUseCase, + addressInputMixinFactory = addressInputMixinFactory, + amountChooserMixinFactory = amountChooserMixinFactory, + accountRepository = accountRepository, + selectAddressMixinFactory = selectAddressMixinFactory, + maxActionProviderFactory = maxActionProviderFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): SelectSendViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectSendViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/view/SelectCrossChainDestinationBottomSheet.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/view/SelectCrossChainDestinationBottomSheet.kt new file mode 100644 index 0000000..883ed73 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/amount/view/SelectCrossChainDestinationBottomSheet.kt @@ -0,0 +1,130 @@ +package io.novafoundation.nova.feature_assets.presentation.send.amount.view + +import android.content.Context +import android.os.Bundle +import android.view.ViewGroup +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.list.headers.TextHeader +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.BaseDynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ClickHandler +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ItemChainChooserBinding +import io.novafoundation.nova.feature_assets.databinding.ItemChainChooserGroupBinding +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class SelectCrossChainDestinationBottomSheet( + context: Context, + private val payload: Payload, + private val onSelected: ClickHandler, + private val onCancelled: () -> Unit +) : BaseDynamicListBottomSheet(context), CrossChainDestinationAdapter.Handler { + + class Payload( + val destinations: Map>, + val selectedChain: Chain + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.wallet_send_recipient_network) + + setOnCancelListener { onCancelled() } + + val adapter = CrossChainDestinationAdapter(this, payload.selectedChain) + recyclerView.adapter = adapter + + adapter.submitList(payload.destinations.toListWithHeaders()) + } + + override fun itemClicked(chainWithAsset: ChainWithAsset) { + onSelected(this, chainWithAsset) + dismiss() + } +} + +class CrossChainDestinationModel( + val chainWithAsset: ChainWithAsset, + val chainUi: ChainUi, + val balance: AmountModel? +) + +class CrossChainDestinationAdapter( + private val handler: Handler, + private val selectedChain: Chain, +) : GroupedListAdapter(DiffCallback()) { + + interface Handler { + + fun itemClicked(chainWithAsset: ChainWithAsset) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return GroupHolder(ItemChainChooserGroupBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return ItemHolder(ItemChainChooserBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: TextHeader) { + (holder as GroupHolder).bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: CrossChainDestinationModel) { + val isSelected = child.chainWithAsset.chain.id == selectedChain.id + + (holder as ItemHolder).bind(child, isSelected, handler) + } +} + +private class GroupHolder(private val binder: ItemChainChooserGroupBinding) : GroupedListHolder(binder.root) { + + fun bind(item: TextHeader) { + binder.itemChainChooserGroup.text = item.content + } +} + +private class ItemHolder(private val binder: ItemChainChooserBinding) : GroupedListHolder(binder.root) { + + fun bind( + item: CrossChainDestinationModel, + isSelected: Boolean, + handler: CrossChainDestinationAdapter.Handler + ) = with(binder) { + itemChainChooserChain.setChain(item.chainUi) + itemChainChooserCheck.isChecked = isSelected + + itemChainChooserAmountToken.setTextOrHide(item.balance?.token) + itemChainChooserAmountFiat.setTextOrHide(item.balance?.fiat) + + binder.root.setOnClickListener { handler.itemClicked(item.chainWithAsset) } + } +} + +private class DiffCallback : BaseGroupedDiffCallback(TextHeader::class.java) { + + override fun areGroupItemsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean { + return TextHeader.DIFF_CALLBACK.areItemsTheSame(oldItem, newItem) + } + + override fun areGroupContentsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean { + return TextHeader.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + } + + override fun areChildItemsTheSame(oldItem: CrossChainDestinationModel, newItem: CrossChainDestinationModel): Boolean { + return oldItem.chainUi.id == newItem.chainUi.id + } + + override fun areChildContentsTheSame(oldItem: CrossChainDestinationModel, newItem: CrossChainDestinationModel): Boolean { + return oldItem.chainUi == newItem.chainUi + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/Factory.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/Factory.kt new file mode 100644 index 0000000..46d35db --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/Factory.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.send.common.fee + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_assets.domain.send.model.TransferFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.asFeeContextFromChain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +typealias TransferFeeLoaderMixin = FeeLoaderMixinV2.Presentation + +context(BaseViewModel) +fun FeeLoaderMixinV2.Factory.createForTransfer( + originChainAsset: Flow, + formatter: TransferFeeDisplayFormatter, + configuration: FeeLoaderMixinV2.Configuration = FeeLoaderMixinV2.Configuration() +): TransferFeeLoaderMixin { + return create( + scope = viewModelScope, + feeContextFlow = originChainAsset.asFeeContextFromChain(), + feeFormatter = formatter, + feeInspector = TransferFeeInspector(), + configuration = configuration + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/TransferFeeDisplay.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/TransferFeeDisplay.kt new file mode 100644 index 0000000..2507c1e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/TransferFeeDisplay.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_assets.presentation.send.common.fee + +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay + +class TransferFeeDisplay( + val originFee: FeeDisplay, + val crossChainFee: FeeDisplay? +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/TransferFeeDisplayFormatter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/TransferFeeDisplayFormatter.kt new file mode 100644 index 0000000..3ad98d1 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/TransferFeeDisplayFormatter.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.send.common.fee + +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_assets.domain.send.model.TransferFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.FeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus + +class TransferFeeDisplayFormatter( + var crossChainFeeShown: Boolean = false, + private val componentDelegate: FeeFormatter +) : FeeFormatter { + + override suspend fun formatFee( + fee: TransferFee, + configuration: FeeFormatter.Configuration, + context: FeeFormatter.Context + ): TransferFeeDisplay { + return TransferFeeDisplay( + originFee = componentDelegate.formatFee(fee.originFee.totalInSubmissionAsset, configuration, context), + crossChainFee = fee.crossChainFee?.let { componentDelegate.formatFee(it, configuration, context) } + ) + } + + override suspend fun createLoadingStatus(): FeeStatus.Loading { + return FeeStatus.Loading(visibleDuringProgress = crossChainFeeShown) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/TransferFeeInspector.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/TransferFeeInspector.kt new file mode 100644 index 0000000..a46b814 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/TransferFeeInspector.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_assets.presentation.send.common.fee + +import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount +import io.novafoundation.nova.feature_assets.domain.send.model.TransferFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class TransferFeeInspector : FeeInspector { + + override fun getSubmissionFeeAsset(fee: TransferFee): Chain.Asset { + return fee.originFee.submissionFee.asset + } + + override fun inspectFeeAmount(fee: TransferFee): FeeInspector.InspectedFeeAmount { + val submissionFee = fee.originFee.submissionFee + + return FeeInspector.InspectedFeeAmount( + deductedFromTransferable = fee.totalFeeByExecutingAccount(submissionFee.asset), + checkedAgainstMinimumBalance = submissionFee.amountByExecutingAccount, + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/Ui.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/Ui.kt new file mode 100644 index 0000000..55f2c62 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/common/fee/Ui.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_assets.presentation.send.common.fee + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_assets.domain.send.model.TransferFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.mapDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.mapProgress +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView + +context(BaseFragment) +fun FeeLoaderMixinV2.setupFeeLoading(originFeeView: FeeView, crossChainFeeView: FeeView) { + setupFeeLoading( + setFeeStatus = { + // We only apply `visibleInProgress` for cross-chain fee. This can be handled better with generic argument for Loading payload + val originFee = it.mapDisplay(TransferFeeDisplay::originFee).mapProgress { true } + val crossChainFee = it.mapDisplay(TransferFeeDisplay::crossChainFee) + + originFeeView.setFeeStatus(originFee) + crossChainFeeView.setFeeStatus(crossChainFee) + }, + setUserCanChangeFeeAsset = { + originFeeView.setFeeEditable(it) { + changePaymentCurrencyClicked() + } + } + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/ConfirmSendFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/ConfirmSendFragment.kt new file mode 100644 index 0000000..5d2e27b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/ConfirmSendFragment.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_assets.presentation.send.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_account_api.view.showChainOrHide +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.FragmentConfirmSendBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft +import io.novafoundation.nova.feature_assets.presentation.send.common.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus + +private const val KEY_DRAFT = "KEY_DRAFT" + +class ConfirmSendFragment : BaseFragment() { + + companion object { + + fun getBundle(transferDraft: TransferDraft) = Bundle().apply { + putParcelable(KEY_DRAFT, transferDraft) + } + } + + override fun createBinding() = FragmentConfirmSendBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmSendSender.setOnClickListener { viewModel.senderAddressClicked() } + binder.confirmSendRecipient.setOnClickListener { viewModel.recipientAddressClicked() } + + binder.confirmSendToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.confirmSendConfirm.setOnClickListener { viewModel.submitClicked() } + binder.confirmSendConfirm.prepareForProgress(viewLifecycleOwner) + + binder.confirmSendCrossChainFee.setTitle(R.string.wallet_send_cross_chain_fee) + binder.confirmSendCrossChainFee.setFeeStatus(FeeStatus.NoFee) // hide by default + } + + override fun inject() { + val transferDraft = argument(KEY_DRAFT) + + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .confirmTransferComponentFactory() + .create(this, transferDraft) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmSendViewModel) { + setupExternalActions(viewModel) + observeValidations(viewModel) + + viewModel.feeMixin.setupFeeLoading(binder.confirmSendOriginFee, binder.confirmSendCrossChainFee) + + observeHints(viewModel.hintsMixin, binder.confirmSendHints) + + viewModel.recipientModel.observe(binder.confirmSendRecipient::showAddress) + viewModel.senderModel.observe(binder.confirmSendSender::showAddress) + + viewModel.sendButtonStateLiveData.observe(binder.confirmSendConfirm::setState) + + viewModel.wallet.observe(binder.confirmSendWallet::showWallet) + + viewModel.transferDirectionModel.observe { + binder.confirmSendFromNetwork.showChain(it.origin) + binder.confirmSendFromNetwork.setTitle(it.originChainLabel) + + binder.confirmSendToNetwork.showChainOrHide(it.destination) + } + + viewModel.amountModel.observe(binder.confirmSendAmount::setAmount) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/ConfirmSendViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/ConfirmSendViewModel.kt new file mode 100644 index 0000000..a2fdf01 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/ConfirmSendViewModel.kt @@ -0,0 +1,314 @@ +package io.novafoundation.nova.feature_assets.presentation.send.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_account_api.presenatation.fee.toDomain +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.send.SendInteractor +import io.novafoundation.nova.feature_assets.domain.send.model.TransferFee +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.autoFixSendValidationPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.buildAssetTransfer +import io.novafoundation.nova.feature_assets.presentation.send.common.fee.TransferFeeDisplayFormatter +import io.novafoundation.nova.feature_assets.presentation.send.common.fee.createForTransfer +import io.novafoundation.nova.feature_assets.presentation.send.confirm.hints.ConfirmSendHintsMixinFactory +import io.novafoundation.nova.feature_assets.presentation.send.isCrossChain +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.mapAssetTransferValidationFailureToUI +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2.Configuration +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountSign +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.DefaultFeeFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch + +class ConfirmSendChainsModel( + val origin: ChainUi, + val originChainLabel: String, + val destination: ChainUi? +) + +class ConfirmSendViewModel( + private val interactor: WalletInteractor, + private val sendInteractor: SendInteractor, + private val router: AssetsRouter, + private val addressIconGenerator: AddressIconGenerator, + private val externalActions: ExternalActions.Presentation, + private val chainRegistry: ChainRegistry, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val addressDisplayUseCase: AddressDisplayUseCase, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val walletUiUseCase: WalletUiUseCase, + private val hintsFactory: ConfirmSendHintsMixinFactory, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + val transferDraft: TransferDraft, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val isCrossChain = transferDraft.origin.chainId != transferDraft.destination.chainId + + private val originChain by lazyAsync { chainRegistry.getChain(transferDraft.origin.chainId) } + private val originAsset by lazyAsync { chainRegistry.asset(transferDraft.origin.chainId, transferDraft.origin.chainAssetId) } + + private val destinationChain by lazyAsync { chainRegistry.getChain(transferDraft.destination.chainId) } + private val destinationChainAsset by lazyAsync { chainRegistry.asset(transferDraft.destination.chainId, transferDraft.destination.chainAssetId) } + + private val assetFlow = interactor.assetFlow(transferDraft.origin.chainId, transferDraft.origin.chainAssetId) + .inBackground() + .share() + + private val currentAccount = selectedAccountUseCase.selectedMetaAccountFlow() + .inBackground() + .share() + + private val formatter = TransferFeeDisplayFormatter(crossChainFeeShown = isCrossChain, componentDelegate = DefaultFeeFormatter(amountFormatter)) + val feeMixin = feeLoaderMixinFactory.createForTransfer( + originChainAsset = flowOf { originAsset() }, + formatter = formatter, + configuration = Configuration( + initialState = Configuration.InitialState( + feePaymentCurrency = transferDraft.feePaymentCurrency.toDomain() + ) + ) + ) + + val hintsMixin = hintsFactory.create(this) + + val recipientModel = flowOf { + createAddressModel( + address = transferDraft.recipientAddress, + chain = destinationChain(), + resolveName = true + ) + } + .inBackground() + .share() + + val senderModel = currentAccount.mapLatest { metaAccount -> + createAddressModel( + address = metaAccount.requireAddressIn(originChain()), + chain = originChain(), + resolveName = false + ) + } + .inBackground() + .share() + + val amountModel = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(transferDraft.amount, asset, AmountConfig(tokenAmountSign = AmountSign.NEGATIVE)) + } + + val transferDirectionModel = flowOf { createTransferDirectionModel() } + .shareInBackground() + + val wallet = walletUiUseCase.selectedWalletUiFlow() + .inBackground() + .share() + + private val _transferSubmittingLiveData = MutableStateFlow(false) + + val sendButtonStateLiveData = _transferSubmittingLiveData.map { submitting -> + if (submitting) { + ButtonState.PROGRESS + } else { + ButtonState.NORMAL + } + } + + init { + setupFee() + } + + fun backClicked() { + router.back() + } + + fun recipientAddressClicked() = launch { + showExternalActions(transferDraft.recipientAddress, destinationChain) + } + + fun senderAddressClicked() = launch { + showExternalActions(senderModel.first().address, originChain) + } + + private suspend fun showExternalActions( + address: String, + chain: Deferred, + ) { + externalActions.showAddressActions(address, chain()) + } + + fun submitClicked() = launch { + val payload = buildValidationPayload() + + validationExecutor.requireValid( + validationSystem = sendInteractor.validationSystemFor(payload.transfer, viewModelScope), + payload = payload, + progressConsumer = _transferSubmittingLiveData.progressConsumer(), + autoFixPayload = ::autoFixSendValidationPayload, + validationFailureTransformerCustom = { status, actions -> + viewModelScope.mapAssetTransferValidationFailureToUI( + resourceManager = resourceManager, + status = status, + actions = actions, + setFee = { + val newOriginFee = payload.transferFee().replaceSubmission(it) + feeMixin.setFee(newOriginFee) + } + ) + }, + ) { validPayload -> + performTransfer(validPayload.transfer, validPayload.originFee, validPayload.crossChainFee) + } + } + + private fun AssetTransferPayload.transferFee(): TransferFee { + return TransferFee(originFee, crossChainFee) + } + + private fun setupFee() { + feeMixin.loadFee { + val assetTransfer = buildTransfer() + sendInteractor.getFee(assetTransfer, viewModelScope) + } + } + + private suspend fun buildTransfer(): AssetTransfer { + val originChainWithAsset = ChainWithAsset(originChain(), originAsset()) + val destinationChainWithAsset = ChainWithAsset(destinationChain(), destinationChainAsset()) + val amount = transferDraft.amount + val address = transferDraft.recipientAddress + + return buildAssetTransfer( + metaAccount = selectedAccountUseCase.getSelectedMetaAccount(), + feePaymentCurrency = transferDraft.feePaymentCurrency.toDomain(), + origin = originChainWithAsset, + destination = destinationChainWithAsset, + amount = amount, + address = address, + transferringMaxAmount = transferDraft.transferringMaxAmount + ) + } + + private suspend fun createAddressModel( + address: String, + chain: Chain, + resolveName: Boolean + ) = addressIconGenerator.createAddressModel( + chain = chain, + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + address = address, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT, + addressDisplayUseCase = addressDisplayUseCase.takeIf { resolveName } + ) + + private fun performTransfer( + transfer: WeightedAssetTransfer, + originFee: OriginFee, + crossChainFee: FeeBase? + ) = launch { + sendInteractor.performTransfer(transfer, originFee, crossChainFee, viewModelScope) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { finishSendFlow() } + }.onFailure(::showError) + + _transferSubmittingLiveData.value = false + } + + private fun finishSendFlow() = launch { + val chain = originChain() + val chainAsset = originAsset() + + if (transferDraft.openAssetDetailsOnCompletion) { + router.openAssetDetails(AssetPayload(chain.id, chainAsset.id)) + } else { + router.closeSendFlow() + } + } + + private suspend fun buildValidationPayload(): AssetTransferPayload { + val chain = originChain() + val chainAsset = originAsset() + + val fee = feeMixin.awaitFee() + + return AssetTransferPayload( + transfer = WeightedAssetTransfer( + sender = currentAccount.first(), + recipient = transferDraft.recipientAddress, + originChain = chain, + destinationChain = destinationChain(), + destinationChainAsset = destinationChainAsset(), + originChainAsset = chainAsset, + amount = transferDraft.amount, + feePaymentCurrency = transferDraft.feePaymentCurrency.toDomain(), + fee = fee.originFee, + transferringMaxAmount = transferDraft.transferringMaxAmount + ), + originFee = fee.originFee, + originCommissionAsset = feeMixin.feeAsset(), + originUsedAsset = assetFlow.first(), + crossChainFee = fee.crossChainFee + ) + } + + private suspend fun createTransferDirectionModel() = if (transferDraft.isCrossChain) { + ConfirmSendChainsModel( + origin = mapChainToUi(originChain()), + originChainLabel = resourceManager.getString(R.string.wallet_send_from_network), + destination = mapChainToUi(destinationChain()) + ) + } else { + ConfirmSendChainsModel( + origin = mapChainToUi(originChain()), + originChainLabel = resourceManager.getString(R.string.common_network), + destination = null + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/di/ConfirmSendComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/di/ConfirmSendComponent.kt new file mode 100644 index 0000000..39fc297 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/di/ConfirmSendComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.send.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft +import io.novafoundation.nova.feature_assets.presentation.send.confirm.ConfirmSendFragment + +@Subcomponent( + modules = [ + ConfirmSendModule::class + ] +) +@ScreenScope +interface ConfirmSendComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance transferDraft: TransferDraft + ): ConfirmSendComponent + } + + fun inject(fragment: ConfirmSendFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/di/ConfirmSendModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/di/ConfirmSendModule.kt new file mode 100644 index 0000000..a528c32 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/di/ConfirmSendModule.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_assets.presentation.send.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.send.SendInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft +import io.novafoundation.nova.feature_assets.presentation.send.confirm.ConfirmSendViewModel +import io.novafoundation.nova.feature_assets.presentation.send.confirm.hints.ConfirmSendHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmSendModule { + + @Provides + @ScreenScope + fun provideHintsFactory( + resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + transferDraft: TransferDraft + ) = ConfirmSendHintsMixinFactory( + resourcesHintsMixinFactory = resourcesHintsMixinFactory, + transferDraft = transferDraft + ) + + @Provides + @IntoMap + @ViewModelKey(ConfirmSendViewModel::class) + fun provideViewModel( + interactor: WalletInteractor, + sendInteractor: SendInteractor, + validationExecutor: ValidationExecutor, + router: AssetsRouter, + addressIconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + selectedAccountUseCase: SelectedAccountUseCase, + addressDisplayUseCase: AddressDisplayUseCase, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + resourceManager: ResourceManager, + transferDraft: TransferDraft, + chainRegistry: ChainRegistry, + walletUiUseCase: WalletUiUseCase, + confirmSendHintsMixinFactory: ConfirmSendHintsMixinFactory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmSendViewModel( + interactor = interactor, + sendInteractor = sendInteractor, + router = router, + addressIconGenerator = addressIconGenerator, + externalActions = externalActions, + chainRegistry = chainRegistry, + selectedAccountUseCase = selectedAccountUseCase, + addressDisplayUseCase = addressDisplayUseCase, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + walletUiUseCase = walletUiUseCase, + feeLoaderMixinFactory = feeLoaderMixinFactory, + transferDraft = transferDraft, + hintsFactory = confirmSendHintsMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmSendViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmSendViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/hints/ConfirmSendHintsMixinFactory.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/hints/ConfirmSendHintsMixinFactory.kt new file mode 100644 index 0000000..c0145ac --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/confirm/hints/ConfirmSendHintsMixinFactory.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_assets.presentation.send.confirm.hints + +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft +import io.novafoundation.nova.feature_assets.presentation.send.isCrossChain +import kotlinx.coroutines.CoroutineScope + +class ConfirmSendHintsMixinFactory( + private val resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + private val transferDraft: TransferDraft, +) { + + fun create(scope: CoroutineScope) = resourcesHintsMixinFactory.create( + coroutineScope = scope, + hintsRes = if (transferDraft.isCrossChain) { + listOf(R.string.wallet_send_confirm_hint) + } else { + emptyList() + } + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/AssetSendFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/AssetSendFlowFragment.kt new file mode 100644 index 0000000..0bc492c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/AssetSendFlowFragment.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.send.flow.asset + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowFragment + +class AssetSendFlowFragment : AssetFlowFragment() { + + override fun initViews() { + super.initViews() + setTitle(R.string.wallet_asset_send) + + binder.assetFlowPlaceholder.setButtonClickListener { + viewModel.openBuyFlow() + } + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .sendFlowComponent() + .create(this) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/AssetSendFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/AssetSendFlowViewModel.kt new file mode 100644 index 0000000..1757b1f --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/AssetSendFlowViewModel.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.feature_assets.presentation.send.flow.asset + +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.PlaceholderModel +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class AssetSendFlowViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + currencyInteractor: CurrencyInteractor, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + private val assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + private val networkAssetMapper: NetworkAssetFormatter, + private val tokenAssetFormatter: TokenAssetFormatter +) : AssetFlowViewModel( + interactorFactory, + router, + currencyInteractor, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + assetIconProvider, + assetViewModeInteractor, + networkAssetMapper, + tokenAssetFormatter +) { + + override fun searchAssetsFlow(): Flow { + return interactor.sendAssetSearch(query, externalBalancesFlow) + } + + override fun assetClicked(asset: Chain.Asset) { + val assetPayload = AssetPayload(asset.chainId, asset.id) + router.openSend(SendPayload.SpecifiedOrigin(assetPayload)) + } + + override fun tokenClicked(tokenGroup: TokenGroupUi) { + when (val type = tokenGroup.groupType) { + is TokenGroupUi.GroupType.SingleItem -> assetClicked(type.asset) + + TokenGroupUi.GroupType.Group -> router.openSendNetworks(NetworkFlowPayload(tokenGroup.tokenSymbol)) + } + } + + override fun mapNetworkAssets(assets: Map>, currency: Currency): List { + return networkAssetMapper.mapGroupedAssetsToUi( + assets, + assetIconProvider, + currency, + NetworkAssetGroup::groupTransferableBalanceFiat, + AssetBalance::transferable + ) + } + + override fun mapTokensAssets(assets: Map>): List { + return assets.map { (group, assets) -> + tokenAssetFormatter.mapTokenAssetGroupToUi(assetIconProvider, group, assets = assets) { it.groupBalance.transferable } + } + } + + override fun getPlaceholder(query: String, assets: List): PlaceholderModel? { + if (query.isEmpty() && assets.isEmpty()) { + return PlaceholderModel( + text = resourceManager.getString(R.string.assets_send_flow_placeholder), + imageRes = R.drawable.ic_no_search_results, + buttonText = resourceManager.getString(R.string.assets_buy_tokens_placeholder_button), + ) + } else { + return super.getPlaceholder(query, assets) + } + } + + fun openBuyFlow() { + router.openBuyFlowFromSendFlow() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/di/AssetSendFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/di/AssetSendFlowComponent.kt new file mode 100644 index 0000000..3f206c0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/di/AssetSendFlowComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.send.flow.asset.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.send.flow.asset.AssetSendFlowFragment + +@Subcomponent( + modules = [ + AssetSendFlowModule::class + ] +) +@ScreenScope +interface AssetSendFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): AssetSendFlowComponent + } + + fun inject(fragment: AssetSendFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/di/AssetSendFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/di/AssetSendFlowModule.kt new file mode 100644 index 0000000..9fa4674 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/asset/di/AssetSendFlowModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_assets.presentation.send.flow.asset.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.send.flow.asset.AssetSendFlowViewModel +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor + +@Module(includes = [ViewModelModule::class]) +class AssetSendFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AssetSendFlowViewModel { + return ViewModelProvider(fragment, factory).get(AssetSendFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AssetSendFlowViewModel::class) + fun provideViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + currencyInteractor: CurrencyInteractor, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + networkAssetMapper: NetworkAssetFormatter, + tokenAssetFormatter: TokenAssetFormatter + ): ViewModel { + return AssetSendFlowViewModel( + interactorFactory = interactorFactory, + router = router, + currencyInteractor = currencyInteractor, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + assetIconProvider = assetIconProvider, + assetViewModeInteractor = assetViewModeInteractor, + networkAssetMapper = networkAssetMapper, + tokenAssetFormatter = tokenAssetFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/NetworkSendFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/NetworkSendFlowFragment.kt new file mode 100644 index 0000000..cb2b8f3 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/NetworkSendFlowFragment.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_assets.presentation.send.flow.network + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowFragment + +class NetworkSendFlowFragment : + NetworkFlowFragment() { + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .networkSendFlowComponent() + .create(this, payload()) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/NetworkSendFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/NetworkSendFlowViewModel.kt new file mode 100644 index 0000000..8d181ab --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/NetworkSendFlowViewModel.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_assets.presentation.send.flow.network + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.model.NetworkFlowRvItem +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.Flow + +class NetworkSendFlowViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter +) : NetworkFlowViewModel( + interactor, + router, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + networkFlowPayload, + chainRegistry, + amountFormatter +) { + + override fun getAssetBalance(asset: AssetWithNetwork): AssetBalance.Amount { + return asset.balanceWithOffChain.transferable + } + + override fun assetsFlow(tokenSymbol: TokenSymbol): Flow> { + return interactor.sendAssetFlow(tokenSymbol, externalBalancesFlow) + } + + override fun networkClicked(network: NetworkFlowRvItem) { + val assetPayload = AssetPayload(network.chainId, network.assetId) + router.openSend(SendPayload.SpecifiedOrigin(assetPayload)) + } + + override fun getTitle(tokenSymbol: TokenSymbol): String { + return resourceManager.getString(R.string.send_network_flow_title, tokenSymbol.value) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/di/NetworkSendFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/di/NetworkSendFlowComponent.kt new file mode 100644 index 0000000..d67d7cd --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/di/NetworkSendFlowComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.send.flow.network.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.send.flow.network.NetworkSendFlowFragment + +@Subcomponent( + modules = [ + NetworkSendFlowModule::class + ] +) +@ScreenScope +interface NetworkSendFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance networkFlowPayload: NetworkFlowPayload + ): NetworkSendFlowComponent + } + + fun inject(fragment: NetworkSendFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/di/NetworkSendFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/di/NetworkSendFlowModule.kt new file mode 100644 index 0000000..bc05e28 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/send/flow/network/di/NetworkSendFlowModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_assets.presentation.send.flow.network.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.send.flow.network.NetworkSendFlowViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class NetworkSendFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NetworkSendFlowViewModel { + return ViewModelProvider(fragment, factory).get(NetworkSendFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(NetworkSendFlowViewModel::class) + fun provideViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter + ): ViewModel { + return NetworkSendFlowViewModel( + interactor = interactor, + router = router, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + networkFlowPayload = networkFlowPayload, + chainRegistry = chainRegistry, + amountFormatter = amountFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/AssetSwapFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/AssetSwapFlowFragment.kt new file mode 100644 index 0000000..7837929 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/AssetSwapFlowFragment.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.asset + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowFragment + +class AssetSwapFlowFragment : AssetFlowFragment() { + + companion object { + + private const val KEY_PAYLOAD = "AssetSwapFlowFragment.payload" + + fun getBundle(payload: SwapFlowPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun initViews() { + super.initViews() + setTitle(viewModel.getTitleRes()) + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .swapFlowComponent() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/AssetSwapFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/AssetSwapFlowViewModel.kt new file mode 100644 index 0000000..5b6851a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/AssetSwapFlowViewModel.kt @@ -0,0 +1,128 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.asset + +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.common.AssetWithOffChainBalance +import io.novafoundation.nova.feature_assets.domain.common.NetworkAssetGroup +import io.novafoundation.nova.feature_assets.domain.common.TokenAssetGroup +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.BalanceListRvItem +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.swap.executor.SwapFlowExecutor +import io.novafoundation.nova.feature_assets.presentation.swap.network.NetworkSwapFlowPayload +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_wallet_api.presentation.model.fullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +class AssetSwapFlowViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + currencyInteractor: CurrencyInteractor, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + private val swapAvailabilityInteractor: SwapAvailabilityInteractor, + private val swapFlowExecutor: SwapFlowExecutor, + private val swapPayload: SwapFlowPayload, + private val assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + private val swapFlowScopeAggregator: SwapFlowScopeAggregator, + private val networkAssetMapper: NetworkAssetFormatter, + private val tokenAssetFormatter: TokenAssetFormatter +) : AssetFlowViewModel( + interactorFactory, + router, + currencyInteractor, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + assetIconProvider, + assetViewModeInteractor, + networkAssetMapper, + tokenAssetFormatter +) { + + private val swapFlowScope = swapFlowScopeAggregator.getFlowScope(viewModelScope) + + init { + launchInitialSwapSync() + } + + @StringRes + fun getTitleRes(): Int { + return when (swapPayload) { + SwapFlowPayload.InitialSelecting, is SwapFlowPayload.ReselectAssetIn -> R.string.assets_swap_flow_pay_title + is SwapFlowPayload.ReselectAssetOut -> R.string.assets_swap_flow_receive_title + } + } + + override fun searchAssetsFlow(): Flow { + return interactor.searchSwapAssetsFlow( + forAsset = swapPayload.constraintDirectionsAsset?.fullChainAssetId, + queryFlow = query, + externalBalancesFlow = externalBalancesFlow, + coroutineScope = swapFlowScope + ) + } + + override fun assetClicked(asset: Chain.Asset) { + launch { + swapFlowExecutor.openNextScreen(swapFlowScope, asset) + } + } + + override fun tokenClicked(tokenGroup: TokenGroupUi) = launchUnit { + when (val type = tokenGroup.groupType) { + is TokenGroupUi.GroupType.SingleItem -> assetClicked(type.asset) + + TokenGroupUi.GroupType.Group -> router.openSwapNetworks(NetworkSwapFlowPayload(NetworkFlowPayload(tokenGroup.tokenSymbol), swapPayload)) + } + } + + override fun mapNetworkAssets(assets: Map>, currency: Currency): List { + return networkAssetMapper.mapGroupedAssetsToUi( + assets, + assetIconProvider, + currency, + NetworkAssetGroup::groupTransferableBalanceFiat, + AssetBalance::transferable + ) + } + + override fun mapTokensAssets(assets: Map>): List { + return assets.map { (group, assets) -> + tokenAssetFormatter.mapTokenAssetGroupToUi(assetIconProvider, group, assets) { it.groupBalance.transferable } + } + } + + private fun launchInitialSwapSync() { + if (swapPayload is SwapFlowPayload.InitialSelecting) { + launch { swapAvailabilityInteractor.warmUpCommonlyUsedChains(swapFlowScope) } + + launch { swapAvailabilityInteractor.sync(swapFlowScope) } + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/SwapFlowPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/SwapFlowPayload.kt new file mode 100644 index 0000000..157148a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/SwapFlowPayload.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.asset + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import kotlinx.parcelize.Parcelize + +sealed class SwapFlowPayload : Parcelable { + + @Parcelize + object InitialSelecting : SwapFlowPayload() + + @Parcelize + class ReselectAssetOut(val selectedAssetIn: AssetPayload?) : SwapFlowPayload() + + @Parcelize + class ReselectAssetIn(val selectedAssetOut: AssetPayload?) : SwapFlowPayload() +} + +val SwapFlowPayload.constraintDirectionsAsset: AssetPayload? + get() = when (this) { + SwapFlowPayload.InitialSelecting -> null + is SwapFlowPayload.ReselectAssetIn -> selectedAssetOut + is SwapFlowPayload.ReselectAssetOut -> selectedAssetIn + } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/di/AssetSwapFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/di/AssetSwapFlowComponent.kt new file mode 100644 index 0000000..02ad36b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/di/AssetSwapFlowComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.asset.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.swap.asset.AssetSwapFlowFragment +import io.novafoundation.nova.feature_assets.presentation.swap.asset.SwapFlowPayload + +@Subcomponent( + modules = [ + AssetSwapFlowModule::class + ] +) +@ScreenScope +interface AssetSwapFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SwapFlowPayload + ): AssetSwapFlowComponent + } + + fun inject(fragment: AssetSwapFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/di/AssetSwapFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/di/AssetSwapFlowModule.kt new file mode 100644 index 0000000..d9e6bad --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/asset/di/AssetSwapFlowModule.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.asset.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.swap.asset.AssetSwapFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.swap.asset.SwapFlowPayload +import io.novafoundation.nova.feature_assets.presentation.swap.executor.SwapFlowExecutorFactory +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator + +@Module(includes = [ViewModelModule::class]) +class AssetSwapFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AssetSwapFlowViewModel { + return ViewModelProvider(fragment, factory).get(AssetSwapFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AssetSwapFlowViewModel::class) + fun provideViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + currencyInteractor: CurrencyInteractor, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + payload: SwapFlowPayload, + executorFactory: SwapFlowExecutorFactory, + swapAvailabilityInteractor: SwapAvailabilityInteractor, + assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + networkAssetMapper: NetworkAssetFormatter, + tokenAssetFormatter: TokenAssetFormatter, + swapFlowScopeAggregator: SwapFlowScopeAggregator, + ): ViewModel { + return AssetSwapFlowViewModel( + interactorFactory = interactorFactory, + router = router, + currencyInteractor = currencyInteractor, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + swapFlowExecutor = executorFactory.create(payload), + swapPayload = payload, + swapAvailabilityInteractor = swapAvailabilityInteractor, + assetIconProvider = assetIconProvider, + assetViewModeInteractor = assetViewModeInteractor, + networkAssetMapper = networkAssetMapper, + tokenAssetFormatter = tokenAssetFormatter, + swapFlowScopeAggregator = swapFlowScopeAggregator + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/executor/InitialSwapFlowExecutor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/executor/InitialSwapFlowExecutor.kt new file mode 100644 index 0000000..2d45e6f --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/executor/InitialSwapFlowExecutor.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.executor + +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope + +class InitialSwapFlowExecutor(private val assetsRouter: AssetsRouter) : SwapFlowExecutor { + + override suspend fun openNextScreen(coroutineScope: CoroutineScope, chainAsset: Chain.Asset) { + val payload = SwapSettingsPayload.DefaultFlow(chainAsset.fullId.toAssetPayload()) + assetsRouter.finishSelectAndOpenSwapSetupAmount(payload) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/executor/ReselectSwapFlowExecutor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/executor/ReselectSwapFlowExecutor.kt new file mode 100644 index 0000000..d4b855e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/executor/ReselectSwapFlowExecutor.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.executor + +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope + +class ReselectSwapFlowExecutor( + private val assetsRouter: AssetsRouter, + private val swapSettingsStateProvider: SwapSettingsStateProvider, + private val selectingDirection: SelectingDirection +) : SwapFlowExecutor { + + enum class SelectingDirection { + IN, OUT + } + + override suspend fun openNextScreen(coroutineScope: CoroutineScope, chainAsset: Chain.Asset) { + val state = swapSettingsStateProvider.getSwapSettingsState(coroutineScope) + when (selectingDirection) { + SelectingDirection.IN -> state.setAssetIn(chainAsset) + SelectingDirection.OUT -> state.setAssetOut(chainAsset) + } + + assetsRouter.returnToMainSwapScreen() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/executor/SwapFlowExecutor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/executor/SwapFlowExecutor.kt new file mode 100644 index 0000000..4b58983 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/executor/SwapFlowExecutor.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.executor + +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.swap.asset.SwapFlowPayload +import io.novafoundation.nova.feature_assets.presentation.swap.executor.ReselectSwapFlowExecutor.SelectingDirection +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope + +class SwapFlowExecutorFactory( + private val initialSwapFlowExecutor: InitialSwapFlowExecutor, + private val assetsRouter: AssetsRouter, + private val swapSettingsStateProvider: SwapSettingsStateProvider, +) { + fun create(payload: SwapFlowPayload): SwapFlowExecutor { + return when (payload) { + SwapFlowPayload.InitialSelecting -> initialSwapFlowExecutor + is SwapFlowPayload.ReselectAssetIn -> createReselectFlowExecutor(SelectingDirection.IN) + is SwapFlowPayload.ReselectAssetOut -> createReselectFlowExecutor(SelectingDirection.OUT) + } + } + + private fun createReselectFlowExecutor(selectingDirection: SelectingDirection): ReselectSwapFlowExecutor { + return ReselectSwapFlowExecutor( + assetsRouter = assetsRouter, + swapSettingsStateProvider = swapSettingsStateProvider, + selectingDirection = selectingDirection, + ) + } +} + +interface SwapFlowExecutor { + suspend fun openNextScreen(coroutineScope: CoroutineScope, chainAsset: Chain.Asset) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/NetworkSwapFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/NetworkSwapFlowFragment.kt new file mode 100644 index 0000000..1cf3b4a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/NetworkSwapFlowFragment.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.network + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowFragment + +class NetworkSwapFlowFragment : + NetworkFlowFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .networkSwapFlowComponent() + .create(this, payload()) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/NetworkSwapFlowPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/NetworkSwapFlowPayload.kt new file mode 100644 index 0000000..76d901f --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/NetworkSwapFlowPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.network + +import android.os.Parcelable +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.swap.asset.SwapFlowPayload +import kotlinx.android.parcel.Parcelize + +@Parcelize +class NetworkSwapFlowPayload( + val networkFlowPayload: NetworkFlowPayload, + val swapFlowPayload: SwapFlowPayload +) : Parcelable diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/NetworkSwapFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/NetworkSwapFlowViewModel.kt new file mode 100644 index 0000000..f4abfe4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/NetworkSwapFlowViewModel.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.network + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.model.NetworkFlowRvItem +import io.novafoundation.nova.feature_assets.presentation.swap.asset.SwapFlowPayload +import io.novafoundation.nova.feature_assets.presentation.swap.asset.constraintDirectionsAsset +import io.novafoundation.nova.feature_assets.presentation.swap.executor.SwapFlowExecutor +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.model.fullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +class NetworkSwapFlowViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter, + private val swapFlowPayload: SwapFlowPayload, + private val swapFlowExecutor: SwapFlowExecutor, + private val swapFlowScopeAggregator: SwapFlowScopeAggregator, +) : NetworkFlowViewModel( + interactor, + router, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + networkFlowPayload, + chainRegistry, + amountFormatter +) { + + private val swapFlowScope = swapFlowScopeAggregator.getFlowScope(viewModelScope) + + override fun getAssetBalance(asset: AssetWithNetwork): AssetBalance.Amount { + return asset.balanceWithOffChain.transferable + } + + override fun assetsFlow(tokenSymbol: TokenSymbol): Flow> { + return interactor.swapAssetsFlow( + forAssetId = swapFlowPayload.constraintDirectionsAsset?.fullChainAssetId, + tokenSymbol = tokenSymbol, + externalBalancesFlow = externalBalancesFlow, + coroutineScope = swapFlowScope + ) + } + + override fun networkClicked(network: NetworkFlowRvItem) { + launch { + val chainAsset = chainRegistry.asset(network.chainId, network.assetId) + swapFlowExecutor.openNextScreen(swapFlowScope, chainAsset) + } + } + + override fun getTitle(tokenSymbol: TokenSymbol): String { + return resourceManager.getString(R.string.swap_network_flow_title, tokenSymbol.value) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/di/NetworkSwapFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/di/NetworkSwapFlowComponent.kt new file mode 100644 index 0000000..afb00b0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/di/NetworkSwapFlowComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.network.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.swap.network.NetworkSwapFlowFragment +import io.novafoundation.nova.feature_assets.presentation.swap.network.NetworkSwapFlowPayload + +@Subcomponent( + modules = [ + NetworkSwapFlowModule::class + ] +) +@ScreenScope +interface NetworkSwapFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: NetworkSwapFlowPayload + ): NetworkSwapFlowComponent + } + + fun inject(fragment: NetworkSwapFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/di/NetworkSwapFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/di/NetworkSwapFlowModule.kt new file mode 100644 index 0000000..197cb02 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/swap/network/di/NetworkSwapFlowModule.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_assets.presentation.swap.network.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.swap.executor.SwapFlowExecutorFactory +import io.novafoundation.nova.feature_assets.presentation.swap.network.NetworkSwapFlowPayload +import io.novafoundation.nova.feature_assets.presentation.swap.network.NetworkSwapFlowViewModel +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class NetworkSwapFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NetworkSwapFlowViewModel { + return ViewModelProvider(fragment, factory).get(NetworkSwapFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(NetworkSwapFlowViewModel::class) + fun provideViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + executorFactory: SwapFlowExecutorFactory, + payload: NetworkSwapFlowPayload, + chainRegistry: ChainRegistry, + swapFlowScopeAggregator: SwapFlowScopeAggregator, + amountFormatter: AmountFormatter + ): ViewModel { + return NetworkSwapFlowViewModel( + interactor = interactor, + router = router, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + networkFlowPayload = payload.networkFlowPayload, + swapFlowPayload = payload.swapFlowPayload, + chainRegistry = chainRegistry, + swapFlowExecutor = executorFactory.create(payload.swapFlowPayload), + swapFlowScopeAggregator = swapFlowScopeAggregator, + amountFormatter = amountFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/di/AddTokenSelectChainModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/di/AddTokenSelectChainModule.kt new file mode 100644 index 0000000..cb562a0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/di/AddTokenSelectChainModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_assets.domain.tokens.add.AddTokensInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain.AddTokenSelectChainViewModel + +@Module(includes = [ViewModelModule::class]) +class AddTokenSelectChainModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AddTokenSelectChainViewModel { + return ViewModelProvider(fragment, factory).get(AddTokenSelectChainViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AddTokenSelectChainViewModel::class) + fun provideViewModel( + router: AssetsRouter, + interactor: AddTokensInteractor, + ): ViewModel { + return AddTokenSelectChainViewModel( + router = router, + interactor = interactor, + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddEvmTokenValidationFailureUi.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddEvmTokenValidationFailureUi.kt new file mode 100644 index 0000000..4f89504 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddEvmTokenValidationFailureUi.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.AddEvmTokensValidationFailure + +fun mapAddEvmTokensValidationFailureToUI( + resourceManager: ResourceManager, + failure: AddEvmTokensValidationFailure +): TitleAndMessage { + return when (failure) { + is AddEvmTokensValidationFailure.AssetExist -> { + val title = resourceManager.getString(R.string.asset_add_evm_token_already_exist_title) + val message = if (failure.canModify) { + resourceManager.getString(R.string.asset_add_evm_token_already_exist_modifiable_message, failure.alreadyExistingSymbol) + } else { + resourceManager.getString(R.string.asset_add_evm_token_already_exist_message, failure.alreadyExistingSymbol) + } + + title to message + } + is AddEvmTokensValidationFailure.InvalidTokenContractAddress -> { + resourceManager.getString(R.string.asset_add_evm_token_invalid_contract_address_title) to + resourceManager.getString(R.string.asset_add_evm_token_invalid_contract_address_message, failure.chainName) + } + AddEvmTokensValidationFailure.InvalidDecimals -> { + resourceManager.getString(R.string.asset_add_evm_token_invalid_decimals_title) to + resourceManager.getString(R.string.asset_add_evm_token_invalid_decimals_message) + } + AddEvmTokensValidationFailure.InvalidCoinGeckoLink -> { + resourceManager.getString(R.string.asset_add_evm_token_invalid_coin_gecko_link_title) to + resourceManager.getString(R.string.asset_add_evm_token_invalid_coin_gecko_link_message) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddTokenEnterInfoFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddTokenEnterInfoFragment.kt new file mode 100644 index 0000000..77db182 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddTokenEnterInfoFragment.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo + +import android.view.View +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.scrollOnFocusTo +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_assets.databinding.FragmentAddTokenEnterInfoBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import javax.inject.Inject + +class AddTokenEnterInfoFragment : BaseFragment() { + + companion object { + + private const val KEY_PAYLOAD = "AddTokenEnterInfoFragment.KEY_PAYLOAD" + + fun getBundle(payload: AddTokenEnterInfoPayload) = bundleOf(KEY_PAYLOAD to payload) + } + + override fun createBinding() = FragmentAddTokenEnterInfoBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun applyInsets(rootView: View) { + binder.root.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.addTokenEnterInfoToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.addTokenEnterInfoScrollArea.scrollOnFocusTo( + binder.addTokenEnterInfoAddressInput, + binder.addTokenEnterInfoSymbolInput, + binder.addTokenEnterInfoDecimalsInput, + binder.addTokenEnterInfoPriceInput + ) + + binder.addTokenEnterInfoPriceConfirm.setOnClickListener { + viewModel.confirmClicked() + } + + binder.addTokenEnterInfoPriceConfirm.prepareForProgress(viewLifecycleOwner) + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .addTokenEnterInfoComponentFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: AddTokenEnterInfoViewModel) { + observeValidations(viewModel) + val scope = viewLifecycleOwner.lifecycleScope + + binder.addTokenEnterInfoAddressInput.bindTo(viewModel.contractAddressInput, scope) + binder.addTokenEnterInfoSymbolInput.bindTo(viewModel.symbolInput, scope) + binder.addTokenEnterInfoDecimalsInput.bindTo(viewModel.decimalsInput, scope) + binder.addTokenEnterInfoPriceInput.bindTo(viewModel.priceLinkInput, scope) + + viewModel.continueButtonState.observe(binder.addTokenEnterInfoPriceConfirm::setState) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddTokenEnterInfoPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddTokenEnterInfoPayload.kt new file mode 100644 index 0000000..5f00ad9 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddTokenEnterInfoPayload.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo + +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +@Parcelize +class AddTokenEnterInfoPayload(val chainId: ChainId) : Parcelable diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddTokenEnterInfoViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddTokenEnterInfoViewModel.kt new file mode 100644 index 0000000..ec63495 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/AddTokenEnterInfoViewModel.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState.Disabled +import io.novafoundation.nova.common.presentation.DescriptiveButtonState.Enabled +import io.novafoundation.nova.common.presentation.DescriptiveButtonState.Loading +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.tokens.add.AddTokensInteractor +import io.novafoundation.nova.feature_assets.domain.tokens.add.CustomErc20Token +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.AddEvmTokenPayload +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class AddTokenEnterInfoViewModel( + private val router: AssetsRouter, + private val interactor: AddTokensInteractor, + private val resourceManager: ResourceManager, + private val payload: AddTokenEnterInfoPayload, + private val validationExecutor: ValidationExecutor, + private val chainRegistry: ChainRegistry, +) : BaseViewModel(), Validatable by validationExecutor { + + val chain = flowOf { chainRegistry.getChain(payload.chainId) } + + val contractAddressInput = MutableStateFlow("") + val symbolInput = MutableStateFlow("") + val decimalsInput = MutableStateFlow("") + val priceLinkInput = MutableStateFlow("") + + private val intDecimals = decimalsInput + .map { it.toIntOrNull() } + + private val addingInProgressFlow = MutableStateFlow(false) + + val continueButtonState = combine( + contractAddressInput, + symbolInput, + intDecimals, + addingInProgressFlow + ) { contractAddress, symbol, decimals, addingInProgress -> + when { + addingInProgress -> Loading + contractAddress.isEmpty() -> Disabled(resourceManager.getString(R.string.asset_add_token_enter_contract_address)) + symbol.isEmpty() -> Disabled(resourceManager.getString(R.string.asset_add_token_enter_symbol)) + decimals == null -> Disabled(resourceManager.getString(R.string.asset_add_token_enter_decimals)) + else -> Enabled(resourceManager.getString(R.string.assets_add_token)) + } + } + + init { + autocompleteFieldsBasedOnContractAddress() + } + + private fun autocompleteFieldsBasedOnContractAddress() { + contractAddressInput + .mapLatest { contractAddress -> + interactor.retrieveContractMetadata(payload.chainId, contractAddress) + } + .onEach { contractMetadata -> + contractMetadata?.decimals?.let { decimalsInput.value = it.toString() } + contractMetadata?.symbol?.let { symbolInput.value = it } + } + .launchIn(viewModelScope) + } + + fun backClicked() { + router.back() + } + + fun confirmClicked() { + launch { + val customToken = CustomErc20Token( + contractAddressInput.first(), + intDecimals.first()!!, + symbolInput.first(), + priceLinkInput.first(), + payload.chainId + ) + + val payload = AddEvmTokenPayload( + customToken, + chain = chain.first(), + ) + + validationExecutor.requireValid( + validationSystem = interactor.getValidationSystem(), + payload = payload, + progressConsumer = addingInProgressFlow.progressConsumer(), + validationFailureTransformer = { mapAddEvmTokensValidationFailureToUI(resourceManager, it) } + ) { + performAddToken(it.customErc20Token) + } + } + } + + private fun performAddToken(customErc20Token: CustomErc20Token) { + launch { + runCatching { + interactor.addCustomTokenAndSync(customErc20Token) + }.onSuccess { + addingInProgressFlow.value = false + router.finishAddTokenFlow() + }.onFailure { + showError(it) + } + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/di/AddTokenEnterInfoComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/di/AddTokenEnterInfoComponent.kt new file mode 100644 index 0000000..39889ba --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/di/AddTokenEnterInfoComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.AddTokenEnterInfoFragment +import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.AddTokenEnterInfoPayload + +@Subcomponent( + modules = [ + AddTokenEnterInfoModule::class + ] +) +@ScreenScope +interface AddTokenEnterInfoComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AddTokenEnterInfoPayload, + ): AddTokenEnterInfoComponent + } + + fun inject(fragment: AddTokenEnterInfoFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/di/AddTokenEnterInfoModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/di/AddTokenEnterInfoModule.kt new file mode 100644 index 0000000..fc6b291 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/enterInfo/di/AddTokenEnterInfoModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_assets.domain.tokens.add.AddTokensInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.AddTokenEnterInfoPayload +import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.AddTokenEnterInfoViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class AddTokenEnterInfoModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AddTokenEnterInfoViewModel { + return ViewModelProvider(fragment, factory).get(AddTokenEnterInfoViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AddTokenEnterInfoViewModel::class) + fun provideViewModel( + router: AssetsRouter, + interactor: AddTokensInteractor, + resourceManager: ResourceManager, + payload: AddTokenEnterInfoPayload, + validationExecutor: ValidationExecutor, + chainRegistry: ChainRegistry + ): ViewModel { + return AddTokenEnterInfoViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + payload = payload, + validationExecutor = validationExecutor, + chainRegistry = chainRegistry, + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/AddTokenSelectChainFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/AddTokenSelectChainFragment.kt new file mode 100644 index 0000000..59b82e6 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/AddTokenSelectChainFragment.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_assets.databinding.FragmentAddTokenSelectChainBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent + +import javax.inject.Inject + +class AddTokenSelectChainFragment : + BaseFragment(), + SelectChainAdapter.ItemHandler { + + override fun createBinding() = FragmentAddTokenSelectChainBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val chainsAdapter by lazy(LazyThreadSafetyMode.NONE) { + SelectChainAdapter(imageLoader, this) + } + + override fun initViews() { + binder.addTokenSelectChainChains.setHasFixedSize(true) + binder.addTokenSelectChainChains.adapter = chainsAdapter + + binder.addTokenSelectChainToolbar.setHomeButtonListener { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .addTokenSelectChainComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: AddTokenSelectChainViewModel) { + viewModel.availableChainModels.observe(chainsAdapter::submitList) + } + + override fun itemClicked(position: Int) { + viewModel.chainClicked(position) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/AddTokenSelectChainViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/AddTokenSelectChainViewModel.kt new file mode 100644 index 0000000..fa331e7 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/AddTokenSelectChainViewModel.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_assets.domain.tokens.add.AddTokensInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.AddTokenEnterInfoPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class AddTokenSelectChainViewModel( + private val router: AssetsRouter, + private val interactor: AddTokensInteractor, +) : BaseViewModel() { + + private val availableChains = interactor.availableChainsToAddTokenFlow() + .shareInBackground() + + val availableChainModels = availableChains + .mapList(::mapChainToUi) + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun chainClicked(position: Int) = launch { + val chain = getChainAt(position) ?: return@launch + + val payload = AddTokenEnterInfoPayload(chain.id) + router.openAddTokenEnterInfo(payload) + } + + private suspend fun getChainAt(position: Int): Chain? { + return availableChains.first().getOrNull(position) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/SelectChainAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/SelectChainAdapter.kt new file mode 100644 index 0000000..17f813c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/SelectChainAdapter.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_assets.databinding.ItemSelectChainBinding + +class SelectChainAdapter( + private val imageLoader: ImageLoader, + private val handler: ItemHandler +) : BaseListAdapter(DiffCallback()) { + + interface ItemHandler { + + fun itemClicked(position: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectChainHolder { + return SelectChainHolder( + binder = ItemSelectChainBinding.inflate(parent.inflater(), parent, false), + itemHandler = handler, + imageLoader = imageLoader + ) + } + + override fun onBindViewHolder(holder: SelectChainHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +class SelectChainHolder( + private val binder: ItemSelectChainBinding, + private val itemHandler: SelectChainAdapter.ItemHandler, + private val imageLoader: ImageLoader, +) : BaseViewHolder(binder.root) { + + init { + binder.root.setOnClickListener { + itemHandler.itemClicked(bindingAdapterPosition) + } + } + + fun bind(item: ChainUi) = with(binder) { + itemSelectChainIcon.loadChainIcon(item.icon, imageLoader) + itemSelectChainName.text = item.name + } + + override fun unbind() { + binder.itemSelectChainIcon.clear() + } +} + +private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ChainUi, newItem: ChainUi): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ChainUi, newItem: ChainUi): Boolean { + return oldItem == newItem + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/di/AddTokenSelectChainComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/di/AddTokenSelectChainComponent.kt new file mode 100644 index 0000000..b9174b4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/di/AddTokenSelectChainComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain.AddTokenSelectChainFragment + +@Subcomponent( + modules = [ + AddTokenSelectChainModule::class + ] +) +@ScreenScope +interface AddTokenSelectChainComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): AddTokenSelectChainComponent + } + + fun inject(fragment: AddTokenSelectChainFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/di/AddTokenSelectChainModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/di/AddTokenSelectChainModule.kt new file mode 100644 index 0000000..3155f8a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/add/selectChain/di/AddTokenSelectChainModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_assets.domain.tokens.add.AddTokensInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.tokens.add.selectChain.AddTokenSelectChainViewModel + +@Module(includes = [ViewModelModule::class]) +class AddTokenSelectChainModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AddTokenSelectChainViewModel { + return ViewModelProvider(fragment, factory).get(AddTokenSelectChainViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AddTokenSelectChainViewModel::class) + fun provideViewModel( + router: AssetsRouter, + interactor: AddTokensInteractor, + ): ViewModel { + return AddTokenSelectChainViewModel( + router = router, + interactor = interactor, + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/ManageTokensAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/ManageTokensAdapter.kt new file mode 100644 index 0000000..a5d41e3 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/ManageTokensAdapter.kt @@ -0,0 +1,115 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ItemManageTokenMultichainBinding +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.model.MultiChainTokenModel + +private val subtitleExtractor = { model: MultiChainTokenModel -> model.header.networks } + +class ManageTokensAdapter( + private val imageLoader: ImageLoader, + private val handler: ItemHandler +) : BaseListAdapter(DiffCallback()) { + + interface ItemHandler { + + fun enableSwitched(position: Int) + + fun editClocked(position: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ManageTokensViewHolder { + return ManageTokensViewHolder( + binder = ItemManageTokenMultichainBinding.inflate(parent.inflater(), parent, false), + itemHandler = handler, + imageLoader = imageLoader + ) + } + + override fun onBindViewHolder(holder: ManageTokensViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: ManageTokensViewHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + resolvePayload(holder, position, payloads) { + when (it) { + MultiChainTokenModel::enabled -> holder.bindEnabled(item) + subtitleExtractor -> holder.bindNetworks(item) + } + } + } +} + +class ManageTokensViewHolder( + private val binder: ItemManageTokenMultichainBinding, + private val itemHandler: ManageTokensAdapter.ItemHandler, + private val imageLoader: ImageLoader, +) : BaseViewHolder(binder.root) { + + init { + with(binder) { + binder.root.setOnClickListener { + itemHandler.editClocked(bindingAdapterPosition) + } + itemManageTokenMultichainEnabled.setOnClickListener { + itemHandler.enableSwitched(bindingAdapterPosition) + } + } + } + + fun bind(item: MultiChainTokenModel) = with(binder) { + bindNetworks(item) + + bindEnabled(item) + + itemManageTokenMultichainIcon.setIcon(item.header.icon, imageLoader) + itemManageTokenMultichainSymbol.text = item.header.symbol + } + + fun bindNetworks(item: MultiChainTokenModel) { + binder.itemManageTokenMultichainNetworks.text = item.header.networks + } + + fun bindEnabled(item: MultiChainTokenModel) = with(binder) { + itemManageTokenMultichainEnabled.isChecked = item.enabled + itemManageTokenMultichainEnabled.isEnabled = item.switchable + + itemManageTokenMultichainIcon.alpha = if (item.enabled) 1f else 0.48f + + val contentColorRes = if (item.enabled) R.color.text_primary else R.color.text_secondary + itemManageTokenMultichainSymbol.setTextColorRes(contentColorRes) + } + + override fun unbind() { + binder.itemManageTokenMultichainIcon.clear() + } +} + +private class DiffCallback : DiffUtil.ItemCallback() { + + private val payloadGenerator = PayloadGenerator(MultiChainTokenModel::enabled, subtitleExtractor) + + override fun areItemsTheSame(oldItem: MultiChainTokenModel, newItem: MultiChainTokenModel): Boolean { + return oldItem.header.symbol == newItem.header.symbol + } + + override fun areContentsTheSame(oldItem: MultiChainTokenModel, newItem: MultiChainTokenModel): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: MultiChainTokenModel, newItem: MultiChainTokenModel): Any? { + return payloadGenerator.diff(oldItem, newItem) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/ManageTokensFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/ManageTokensFragment.kt new file mode 100644 index 0000000..f0593fe --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/ManageTokensFragment.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage + +import android.view.View +import androidx.lifecycle.lifecycleScope + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard +import io.novafoundation.nova.common.utils.submitListPreservingViewPoint +import io.novafoundation.nova.common.view.bindFromMap +import io.novafoundation.nova.feature_assets.databinding.FragmentManageTokensBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.domain.assets.filters.NonZeroBalanceFilter +import javax.inject.Inject + +class ManageTokensFragment : + BaseFragment(), + ManageTokensAdapter.ItemHandler { + + override fun createBinding() = FragmentManageTokensBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val tokensAdapter by lazy(LazyThreadSafetyMode.NONE) { + ManageTokensAdapter(imageLoader, this) + } + + override fun applyInsets(rootView: View) { + binder.manageTokensToolbarContainer.applyStatusBarInsets() + binder.manageTokensList.applyNavigationBarInsets(consume = false, imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.manageTokensList.setHasFixedSize(true) + binder.manageTokensList.adapter = tokensAdapter + + binder.manageTokensList.itemAnimator = null + + binder.manageTokensToolbar.setHomeButtonListener { viewModel.closeClicked() } + binder.manageTokensToolbar.setRightActionClickListener { viewModel.addClicked() } + + binder.manageTokensSearch.requestFocus() + binder.manageTokensSearch.content.showSoftKeyboard() + + binder.manageTokensSwitchZeroBalances.bindFromMap(NonZeroBalanceFilter, viewModel.filtersEnabledMap, viewLifecycleOwner.lifecycleScope) + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .manageTokensComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ManageTokensViewModel) { + binder.manageTokensSearch.content.bindTo(viewModel.query, lifecycleScope) + + viewModel.searchResults.observe { data -> + binder.manageTokensPlaceholder.setVisible(data.isEmpty()) + binder.manageTokensList.setVisible(data.isNotEmpty()) + + tokensAdapter.submitListPreservingViewPoint(data = data, into = binder.manageTokensList) + } + } + + override fun onDestroyView() { + super.onDestroyView() + + requireActivity().hideSoftKeyboard() + } + + override fun enableSwitched(position: Int) { + viewModel.enableTokenSwitchClicked(position) + } + + override fun editClocked(position: Int) { + viewModel.editClicked(position) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/ManageTokensViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/ManageTokensViewModel.kt new file mode 100644 index 0000000..f85007b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/ManageTokensViewModel.kt @@ -0,0 +1,102 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.checkEnabled +import io.novafoundation.nova.common.utils.combineIdentity +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_assets.domain.assets.filters.AssetFilter +import io.novafoundation.nova.feature_assets.domain.assets.filters.AssetFiltersInteractor +import io.novafoundation.nova.feature_assets.domain.tokens.manage.ManageTokenInteractor +import io.novafoundation.nova.feature_assets.domain.tokens.manage.MultiChainToken +import io.novafoundation.nova.feature_assets.domain.tokens.manage.allChainAssetIds +import io.novafoundation.nova.feature_assets.domain.tokens.manage.isEnabled +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensPayload +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.model.MultiChainTokenMapper +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.model.MultiChainTokenModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class ManageTokensViewModel( + private val router: AssetsRouter, + private val interactor: ManageTokenInteractor, + private val commonUiMapper: MultiChainTokenMapper, + private val assetFiltersInteractor: AssetFiltersInteractor, +) : BaseViewModel() { + + val filtersEnabledMap = assetFiltersInteractor.allFilters.associateWith { MutableStateFlow(false) } + + val query = MutableStateFlow("") + + private val multiChainTokensFlow = interactor.multiChainTokensFlow(query) + .shareInBackground() + + val searchResults = multiChainTokensFlow + .mapList(::mapMultiChainTokenToUi) + .shareInBackground() + + init { + applyFiltersInitialState() + } + + fun closeClicked() { + router.back() + } + + fun addClicked() { + router.openAddTokenSelectChain() + } + + fun editClicked(position: Int) = launch { + val token = getMultiChainTokenAt(position) ?: return@launch + + val payload = ManageChainTokensPayload(token.symbol) + router.openManageChainTokens(payload) + } + + fun enableTokenSwitchClicked(position: Int) = launch { + val token = getMultiChainTokenAt(position) ?: return@launch + + interactor.updateEnabledState( + enabled = !token.isEnabled(), + assetIds = token.allChainAssetIds() + ) + } + + private suspend fun getMultiChainTokenAt(position: Int): MultiChainToken? { + return multiChainTokensFlow.first().getOrNull(position) + } + + private fun mapMultiChainTokenToUi(multiChainToken: MultiChainToken): MultiChainTokenModel { + return MultiChainTokenModel( + header = commonUiMapper.mapHeaderToUi(multiChainToken), + enabled = multiChainToken.isEnabled(), + switchable = multiChainToken.isSwitchable + ) + } + + private fun applyFiltersInitialState() = launch { + val initialFilters = assetFiltersInteractor.currentFilters() + + filtersEnabledMap.forEach { (filter, checked) -> + checked.value = filter in initialFilters + } + + filtersEnabledMap.applyOnChange() + } + + private fun Map>.applyOnChange() { + combineIdentity(this.values) + .drop(1) + .onEach { + val enabledFilters = assetFiltersInteractor.allFilters.filter(filtersEnabledMap::checkEnabled) + assetFiltersInteractor.updateFilters(enabledFilters) + } + .launchIn(viewModelScope) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensAdapter.kt new file mode 100644 index 0000000..b66b938 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensAdapter.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ItemManageChainTokenBinding +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.model.ChainTokenInstanceModel + +class ManageChainTokensAdapter( + private val imageLoader: ImageLoader, + private val handler: ItemHandler +) : BaseListAdapter(DiffCallback()) { + + interface ItemHandler { + + fun enableSwitched(position: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ManageChainTokensViewHolder { + return ManageChainTokensViewHolder( + binder = ItemManageChainTokenBinding.inflate(parent.inflater(), parent, false), + itemHandler = handler, + imageLoader = imageLoader + ) + } + + override fun onBindViewHolder(holder: ManageChainTokensViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: ManageChainTokensViewHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + resolvePayload(holder, position, payloads) { + when (it) { + ChainTokenInstanceModel::enabled -> holder.bindEnabled(item) + } + } + } +} + +class ManageChainTokensViewHolder( + private val binder: ItemManageChainTokenBinding, + private val itemHandler: ManageChainTokensAdapter.ItemHandler, + private val imageLoader: ImageLoader, +) : BaseViewHolder(binder.root) { + + init { + with(binder) { + itemManageChainTokenEnabled.setOnClickListener { itemHandler.enableSwitched(bindingAdapterPosition) } + } + } + + fun bind(item: ChainTokenInstanceModel) = with(binder) { + bindEnabled(item) + itemManageChainTokenChainIcon.loadChainIcon(item.chainUi.icon, imageLoader) + itemManageChainTokenChainName.text = item.chainUi.name + } + + fun bindEnabled(item: ChainTokenInstanceModel) { + with(binder.itemManageChainTokenEnabled) { + isChecked = item.enabled + isEnabled = item.switchable + } + + with(binder) { + val contentColorRes = if (item.enabled) R.color.text_primary else R.color.text_secondary + itemManageChainTokenChainName.setTextColorRes(contentColorRes) + } + } + + override fun unbind() { + binder.itemManageChainTokenChainIcon.clear() + } +} + +private class DiffCallback : DiffUtil.ItemCallback() { + + private val payloadGenerator = PayloadGenerator(ChainTokenInstanceModel::enabled) + + override fun areItemsTheSame(oldItem: ChainTokenInstanceModel, newItem: ChainTokenInstanceModel): Boolean { + return oldItem.chainUi.id == newItem.chainUi.id + } + + override fun areContentsTheSame(oldItem: ChainTokenInstanceModel, newItem: ChainTokenInstanceModel): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: ChainTokenInstanceModel, newItem: ChainTokenInstanceModel): Any? { + return payloadGenerator.diff(oldItem, newItem) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensFragment.kt new file mode 100644 index 0000000..1223362 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensFragment.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain + +import androidx.core.os.bundleOf + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseBottomSheetFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_assets.databinding.FragmentManageChainTokensBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent + +import javax.inject.Inject + +class ManageChainTokensFragment : + BaseBottomSheetFragment(), + ManageChainTokensAdapter.ItemHandler { + + companion object { + + private const val KEY_PAYLOAD = "ManageChainTokensFragment.Payload" + + fun getBundle(payload: ManageChainTokensPayload) = bundleOf(KEY_PAYLOAD to payload) + } + + override fun createBinding() = FragmentManageChainTokensBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val tokensAdapter by lazy(LazyThreadSafetyMode.NONE) { + ManageChainTokensAdapter(imageLoader, this) + } + + override fun initViews() { + binder.manageChainTokenChains.setHasFixedSize(true) + binder.manageChainTokenChains.adapter = tokensAdapter + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .manageChainTokensComponentFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ManageChainTokensViewModel) { + viewModel.headerModel.observe { headerModel -> + binder.manageChainTokenIcon.setTokenIcon(headerModel.icon, imageLoader) + binder.manageChainTokenSymbol.text = headerModel.symbol + binder.manageChainTokenSubtitle.text = headerModel.networks + } + + viewModel.chainInstanceModels.observe(tokensAdapter::submitList) + } + + override fun enableSwitched(position: Int) { + viewModel.enableChainSwitchClicked(position) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensPayload.kt new file mode 100644 index 0000000..530db54 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class ManageChainTokensPayload( + val multiChainTokenId: String +) : Parcelable diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensViewModel.kt new file mode 100644 index 0000000..089cf6e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/ManageChainTokensViewModel.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_assets.domain.tokens.manage.ManageTokenInteractor +import io.novafoundation.nova.feature_assets.domain.tokens.manage.MultiChainToken +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.model.ChainTokenInstanceModel +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.model.MultiChainTokenMapper +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ManageChainTokensViewModel( + private val interactor: ManageTokenInteractor, + private val commonUiMapper: MultiChainTokenMapper, + payload: ManageChainTokensPayload, +) : BaseViewModel() { + + private val multiChainTokensFlow = interactor.multiChainTokenFlow(payload.multiChainTokenId) + .shareInBackground() + + val headerModel = multiChainTokensFlow + .map(commonUiMapper::mapHeaderToUi) + .shareInBackground() + + val chainInstanceModels = multiChainTokensFlow + .map(::mapMultiChainTokenToInstanceModels) + .shareInBackground() + + fun enableChainSwitchClicked(position: Int) = launch { + val chainTokenInstance = multiChainTokensFlow.first().instances.getOrNull(position) ?: return@launch + val assetId = FullChainAssetId(chainTokenInstance.chain.id, chainTokenInstance.chainAssetId) + + interactor.updateEnabledState(enabled = !chainTokenInstance.isEnabled, assetIds = listOf(assetId)) + } + + private fun mapMultiChainTokenToInstanceModels(multiChainToken: MultiChainToken): List { + return multiChainToken.instances.map { + ChainTokenInstanceModel( + chainUi = mapChainToUi(it.chain), + enabled = it.isEnabled, + switchable = it.isSwitchable + ) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/di/ManageChainTokensComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/di/ManageChainTokensComponent.kt new file mode 100644 index 0000000..6bd0e64 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/di/ManageChainTokensComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensFragment +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensPayload + +@Subcomponent( + modules = [ + ManageChainTokensModule::class + ] +) +@ScreenScope +interface ManageChainTokensComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ManageChainTokensPayload, + ): ManageChainTokensComponent + } + + fun inject(fragment: ManageChainTokensFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/di/ManageChainTokensModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/di/ManageChainTokensModule.kt new file mode 100644 index 0000000..ccb8b7b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/di/ManageChainTokensModule.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_assets.domain.tokens.manage.ManageTokenInteractor +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensPayload +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensViewModel +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.model.MultiChainTokenMapper + +@Module(includes = [ViewModelModule::class]) +class ManageChainTokensModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ManageChainTokensViewModel { + return ViewModelProvider(fragment, factory).get(ManageChainTokensViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(ManageChainTokensViewModel::class) + fun provideViewModel( + interactor: ManageTokenInteractor, + uiMapper: MultiChainTokenMapper, + payload: ManageChainTokensPayload, + ): ViewModel { + return ManageChainTokensViewModel( + interactor = interactor, + commonUiMapper = uiMapper, + payload = payload, + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/model/ChainTokenInstanceModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/model/ChainTokenInstanceModel.kt new file mode 100644 index 0000000..7ec5b89 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/chain/model/ChainTokenInstanceModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.model + +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi + +data class ChainTokenInstanceModel( + val chainUi: ChainUi, + val enabled: Boolean, + val switchable: Boolean, +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/di/ManageTokensComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/di/ManageTokensComponent.kt new file mode 100644 index 0000000..0a7a91e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/di/ManageTokensComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.ManageTokensFragment + +@Subcomponent( + modules = [ + ManageTokensModule::class + ] +) +@ScreenScope +interface ManageTokensComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): ManageTokensComponent + } + + fun inject(fragment: ManageTokensFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/di/ManageTokensModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/di/ManageTokensModule.kt new file mode 100644 index 0000000..7374b7e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/di/ManageTokensModule.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_assets.data.repository.assetFilters.AssetFiltersRepository +import io.novafoundation.nova.feature_assets.domain.assets.filters.AssetFiltersInteractor +import io.novafoundation.nova.feature_assets.domain.tokens.manage.ManageTokenInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.ManageTokensViewModel +import io.novafoundation.nova.feature_assets.presentation.tokens.manage.model.MultiChainTokenMapper + +@Module(includes = [ViewModelModule::class]) +class ManageTokensModule { + + @Provides + @ScreenScope + fun provideAssetFiltersInteractor( + assetFiltersRepository: AssetFiltersRepository + ) = AssetFiltersInteractor(assetFiltersRepository) + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ManageTokensViewModel { + return ViewModelProvider(fragment, factory).get(ManageTokensViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(ManageTokensViewModel::class) + fun provideViewModel( + router: AssetsRouter, + interactor: ManageTokenInteractor, + commonUiMapper: MultiChainTokenMapper, + assetFiltersInteractor: AssetFiltersInteractor + ): ViewModel { + return ManageTokensViewModel( + router = router, + interactor = interactor, + commonUiMapper = commonUiMapper, + assetFiltersInteractor = assetFiltersInteractor + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/model/MultiChainTokenModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/model/MultiChainTokenModel.kt new file mode 100644 index 0000000..e8bb740 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/tokens/manage/model/MultiChainTokenModel.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_assets.presentation.tokens.manage.model + +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.getAssetIconOrFallback +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.resources.formatListPreview +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.tokens.manage.MultiChainToken + +data class MultiChainTokenModel( + val header: HeaderModel, + val enabled: Boolean, + val switchable: Boolean +) { + + class HeaderModel( + val icon: Icon, + val symbol: String, + val networks: String, + ) +} + +class MultiChainTokenMapper( + private val assetIconProvider: AssetIconProvider, + private val resourceManager: ResourceManager +) { + + fun mapHeaderToUi(multiChainToken: MultiChainToken): MultiChainTokenModel.HeaderModel { + return MultiChainTokenModel.HeaderModel( + icon = assetIconProvider.getAssetIconOrFallback(multiChainToken.icon), + symbol = multiChainToken.symbol, + networks = constructNetworksSubtitle(multiChainToken) + ) + } + + private fun constructNetworksSubtitle(multiChainToken: MultiChainToken): String { + val enabledInstances = multiChainToken.instances + .filter { it.isEnabled } + .map { it.chain.name } + + return if (enabledInstances.size == multiChainToken.instances.size) { + resourceManager.getString(R.string.assets_manage_tokens_all_networks) + } else { + resourceManager.formatListPreview(enabledInstances, zeroLabel = R.string.common_disabled) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressCommunicator.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressCommunicator.kt new file mode 100644 index 0000000..2afdd6e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressCommunicator.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_assets.presentation.topup + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import kotlinx.android.parcel.Parcelize + +interface TopUpAddressRequester : InterScreenRequester + +interface TopUpAddressResponder : InterScreenResponder { + sealed interface Response : Parcelable { + + @Parcelize + object Success : Response + + @Parcelize + object Cancel : Response + } +} + +interface TopUpAddressCommunicator : TopUpAddressRequester, TopUpAddressResponder diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressFragment.kt new file mode 100644 index 0000000..a43f569 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressFragment.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_assets.presentation.topup + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.setupAddressInput +import io.novafoundation.nova.feature_assets.databinding.FragmentTopUpAddressBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading + +class TopUpAddressFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentTopUpAddressBinding.inflate(layoutInflater) + + override fun initViews() { + binder.topUpAddressToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.topUpAddressContinue.prepareForProgress(viewLifecycleOwner) + binder.topUpAddressContinue.setOnClickListener { viewModel.nextClicked() } + + onBackPressed { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .topUpCardComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: TopUpAddressViewModel) { + observeValidations(viewModel) + + viewModel.feeMixin.setupFeeLoading(binder.topUpCardFee) + + setupAmountChooser(viewModel.amountChooserMixin, binder.topUpAddressAmount) + setupAddressInput(viewModel.addressInputMixin, binder.topUpAddressRecipient) + + viewModel.titleFlow.observe { + binder.topUpCardTitle.text = it + } + + viewModel.continueButtonState.observe(binder.topUpAddressContinue::setState) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressPayload.kt new file mode 100644 index 0000000..e3e34ae --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_assets.presentation.topup + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import java.math.BigDecimal +import kotlinx.android.parcel.Parcelize + +@Parcelize +class TopUpAddressPayload( + val address: String, + val amount: BigDecimal, + val asset: AssetPayload, + val screenTitle: String +) : Parcelable diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressViewModel.kt new file mode 100644 index 0000000..d879a5a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/TopUpAddressViewModel.kt @@ -0,0 +1,185 @@ +package io.novafoundation.nova.feature_assets.presentation.topup + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.setAddress +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.send.SendInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.autoFixSendValidationPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.buildAssetTransfer +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.mapAssetTransferValidationFailureToUI +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.isMaxAction +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setBlockedAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class TopUpAddressViewModel( + private val chainRegistry: ChainRegistry, + private val interactor: WalletInteractor, + private val sendInteractor: SendInteractor, + private val router: AssetsRouter, + private val payload: TopUpAddressPayload, + private val validationExecutor: ValidationExecutor, + private val resourceManager: ResourceManager, + private val responder: TopUpAddressResponder, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + selectedAccountUseCase: SelectedAccountUseCase, + amountChooserMixinFactory: AmountChooserMixin.Factory, + addressInputMixinFactory: AddressInputMixinFactory, +) : BaseViewModel(), Validatable by validationExecutor { + + private val chainWithAssetFlow = flowOf { chainRegistry.chainWithAsset(payload.asset.chainId, payload.asset.chainAssetId) } + + private val chainFlow = chainWithAssetFlow.map { it.chain } + private val chainAssetFlow = chainWithAssetFlow.map { it.asset } + + private val selectedAccount = selectedAccountUseCase.selectedMetaAccountFlow() + .inBackground() + .share() + + private val sendInProgressFlow = MutableStateFlow(false) + + private val assetFlow = chainAssetFlow.flatMapLatest(interactor::assetFlow) + .shareInBackground() + + val addressInputMixin = with(addressInputMixinFactory) { + create( + inputSpecProvider = singleChainInputSpec(chainFlow), + errorDisplayer = this@TopUpAddressViewModel::showError, + showAccountEvent = null, + coroutineScope = this@TopUpAddressViewModel, + ) + } + + val feeMixin = feeLoaderMixinFactory.createDefault(this, chainAssetFlow) + + val amountChooserMixin: AmountChooserMixin.Presentation = amountChooserMixinFactory.create( + scope = this, + assetFlow = assetFlow, + maxActionProvider = null + ) + + val titleFlow = flowOf { payload.screenTitle } + .shareInBackground() + + val continueButtonState = sendInProgressFlow.map { isSending -> + when { + isSending -> ButtonState.PROGRESS + else -> ButtonState.NORMAL + } + } + + init { + addressInputMixin.setAddress(payload.address) + amountChooserMixin.setBlockedAmount(payload.amount) + + setupFees() + } + + fun nextClicked() = launch { + sendInProgressFlow.value = true + + val fee = feeMixin.awaitFee() + val originFee = OriginFee(fee, null) + + val amountState = amountChooserMixin.amountState.first() + val transfer = buildTransfer(feeMixin.feePaymentCurrency(), amountState.inputKind.isMaxAction()) + + val payload = AssetTransferPayload( + transfer = WeightedAssetTransfer( + assetTransfer = transfer, + fee = originFee, + ), + crossChainFee = null, + originFee = originFee, + originCommissionAsset = feeMixin.feeAsset(), + originUsedAsset = assetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = sendInteractor.validationSystemFor(payload.transfer, viewModelScope), + payload = payload, + progressConsumer = sendInProgressFlow.progressConsumer(), + autoFixPayload = ::autoFixSendValidationPayload, + validationFailureTransformerCustom = { status, actions -> + viewModelScope.mapAssetTransferValidationFailureToUI( + resourceManager = resourceManager, + status = status, + actions = actions, + setFee = feeMixin + ) + }, + ) { + sendInProgressFlow.value = false + transferTokensAndFinishFlow(it) + } + } + + fun backClicked() { + responder.respond(TopUpAddressResponder.Response.Cancel) + router.finishTopUp() + } + + private fun transferTokensAndFinishFlow(payload: AssetTransferPayload) = launch { + sendInteractor.performTransfer(payload.transfer, payload.originFee, null, viewModelScope) + + responder.respond(TopUpAddressResponder.Response.Success) + router.finishTopUp() + } + + private fun setupFees() { + feeMixin.connectWith( + feeMixin.feeChainAssetFlow, + amountChooserMixin.amountState + ) { feePaymentCurrency, commissionAsset, amountState -> + val assetTransfer = buildTransfer(feePaymentCurrency, amountState.inputKind.isMaxAction()) + + sendInteractor.getSubmissionFee(assetTransfer, viewModelScope) + } + } + + private suspend fun buildTransfer( + feePaymentCurrency: FeePaymentCurrency, + transferringMaxAmount: Boolean + ): AssetTransfer { + val chainWithAsset = chainWithAssetFlow.first() + val amount = amountChooserMixin.amount.first() + val address = addressInputMixin.getAddress() + return buildAssetTransfer( + metaAccount = selectedAccount.first(), + feePaymentCurrency = feePaymentCurrency, + origin = chainWithAsset, + destination = chainWithAsset, + amount = amount, + address = address, + transferringMaxAmount = transferringMaxAmount + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/di/TopUpAddressComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/di/TopUpAddressComponent.kt new file mode 100644 index 0000000..15e70ec --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/di/TopUpAddressComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.topup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressFragment +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressPayload + +@Subcomponent( + modules = [ + TopUpAddressModule::class + ] +) +@ScreenScope +interface TopUpAddressComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: TopUpAddressPayload, + ): TopUpAddressComponent + } + + fun inject(fragment: TopUpAddressFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/di/TopUpAddressModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/di/TopUpAddressModule.kt new file mode 100644 index 0000000..ef3b5b8 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/topup/di/TopUpAddressModule.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_assets.presentation.topup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.send.SendInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressPayload +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class TopUpAddressModule { + + @Provides + @IntoMap + @ViewModelKey(TopUpAddressViewModel::class) + fun provideViewModel( + chainRegistry: ChainRegistry, + interactor: WalletInteractor, + sendInteractor: SendInteractor, + router: AssetsRouter, + payload: TopUpAddressPayload, + validationExecutor: ValidationExecutor, + resourceManager: ResourceManager, + selectedAccountUseCase: SelectedAccountUseCase, + addressInputMixinFactory: AddressInputMixinFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + communicator: TopUpAddressCommunicator, + amountFormatter: AmountFormatter, + ): ViewModel { + return TopUpAddressViewModel( + chainRegistry = chainRegistry, + interactor = interactor, + sendInteractor = sendInteractor, + router = router, + payload = payload, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + feeLoaderMixinFactory = feeLoaderMixinFactory, + selectedAccountUseCase = selectedAccountUseCase, + addressInputMixinFactory = addressInputMixinFactory, + amountChooserMixinFactory = amountChooserMixinFactory, + responder = communicator, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): TopUpAddressViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(TopUpAddressViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/AssetBuyFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/AssetBuyFlowFragment.kt new file mode 100644 index 0000000..44c6244 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/AssetBuyFlowFragment.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.asset + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowFragment + +class AssetBuyFlowFragment : AssetFlowFragment() { + + override fun initViews() { + super.initViews() + setTitle(R.string.wallet_asset_buy) + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .buyFlowComponent() + .create(this) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/AssetBuyFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/AssetBuyFlowViewModel.kt new file mode 100644 index 0000000..ec564e5 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/AssetBuyFlowViewModel.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.asset + +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class AssetBuyFlowViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + currencyInteractor: CurrencyInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + networkAssetMapper: NetworkAssetFormatter, + tokenAssetFormatter: TokenAssetFormatter +) : AssetFlowViewModel( + interactorFactory, + router, + currencyInteractor, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + assetIconProvider, + assetViewModeInteractor, + networkAssetMapper, + tokenAssetFormatter +) { + + override fun searchAssetsFlow(): Flow { + return interactor.tradeAssetSearch(query, externalBalancesFlow, TradeTokenRegistry.TradeType.BUY) + } + + override fun assetClicked(asset: Chain.Asset) { + validate(asset) { + router.openBuyProviders(asset.chainId, asset.id) + } + } + + override fun tokenClicked(tokenGroup: TokenGroupUi) { + when (val type = tokenGroup.groupType) { + is TokenGroupUi.GroupType.SingleItem -> assetClicked(type.asset) + TokenGroupUi.GroupType.Group -> router.openBuyNetworks(NetworkFlowPayload(tokenGroup.tokenSymbol)) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/di/AssetBuyFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/di/AssetBuyFlowComponent.kt new file mode 100644 index 0000000..b8d5e30 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/di/AssetBuyFlowComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.asset.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.asset.AssetBuyFlowFragment + +@Subcomponent( + modules = [ + AssetBuyFlowModule::class + ] +) +@ScreenScope +interface AssetBuyFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): AssetBuyFlowComponent + } + + fun inject(fragment: AssetBuyFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/di/AssetBuyFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/di/AssetBuyFlowModule.kt new file mode 100644 index 0000000..ee0a10b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/asset/di/AssetBuyFlowModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.asset.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.asset.AssetBuyFlowViewModel +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor + +@Module(includes = [ViewModelModule::class]) +class AssetBuyFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AssetBuyFlowViewModel { + return ViewModelProvider(fragment, factory).get(AssetBuyFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AssetBuyFlowViewModel::class) + fun provideViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + currencyInteractor: CurrencyInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + networkAssetMapper: NetworkAssetFormatter, + tokenAssetFormatter: TokenAssetFormatter + ): ViewModel { + return AssetBuyFlowViewModel( + interactorFactory = interactorFactory, + router = router, + externalBalancesInteractor = externalBalancesInteractor, + currencyInteractor = currencyInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + assetIconProvider = assetIconProvider, + assetViewModeInteractor = assetViewModeInteractor, + networkAssetMapper = networkAssetMapper, + tokenAssetFormatter = tokenAssetFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/NetworkBuyFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/NetworkBuyFlowFragment.kt new file mode 100644 index 0000000..d0be976 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/NetworkBuyFlowFragment.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.network + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowFragment + +class NetworkBuyFlowFragment : NetworkFlowFragment() { + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .networkBuyFlowComponent() + .create(this, payload()) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/NetworkBuyFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/NetworkBuyFlowViewModel.kt new file mode 100644 index 0000000..5576b9b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/NetworkBuyFlowViewModel.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.network + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.model.NetworkFlowRvItem +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.Flow + +class NetworkBuyFlowViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter +) : NetworkFlowViewModel( + interactor, + router, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + networkFlowPayload, + chainRegistry, + amountFormatter +) { + + override fun getAssetBalance(asset: AssetWithNetwork): AssetBalance.Amount { + return asset.balanceWithOffChain.total + } + + override fun assetsFlow(tokenSymbol: TokenSymbol): Flow> { + return interactor.tradeAssetFlow(tokenSymbol, externalBalancesFlow, TradeTokenRegistry.TradeType.BUY) + } + + override fun networkClicked(network: NetworkFlowRvItem) { + validateControllsAsset(network) { + router.openBuyProviders(network.chainId, network.assetId) + } + } + + override fun getTitle(tokenSymbol: TokenSymbol): String { + return resourceManager.getString(R.string.buy_network_flow_title, tokenSymbol.value) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/di/NetworkBuyFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/di/NetworkBuyFlowComponent.kt new file mode 100644 index 0000000..33f86da --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/di/NetworkBuyFlowComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.network.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.network.NetworkBuyFlowFragment +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload + +@Subcomponent( + modules = [ + NetworkBuyFlowModule::class + ] +) +@ScreenScope +interface NetworkBuyFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance networkFlowPayload: NetworkFlowPayload + ): NetworkBuyFlowComponent + } + + fun inject(fragment: NetworkBuyFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/di/NetworkBuyFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/di/NetworkBuyFlowModule.kt new file mode 100644 index 0000000..9730c62 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/buy/flow/network/di/NetworkBuyFlowModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.network.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.trade.buy.flow.network.NetworkBuyFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class NetworkBuyFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NetworkBuyFlowViewModel { + return ViewModelProvider(fragment, factory).get(NetworkBuyFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(NetworkBuyFlowViewModel::class) + fun provideViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter + ): ViewModel { + return NetworkBuyFlowViewModel( + interactor = interactor, + router = router, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + networkFlowPayload = networkFlowPayload, + chainRegistry = chainRegistry, + amountFormatter = amountFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/common/TradeProviderFlowType.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/common/TradeProviderFlowType.kt new file mode 100644 index 0000000..c53927c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/common/TradeProviderFlowType.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.common + +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry + +enum class TradeProviderFlowType { + BUY, SELL +} + +fun TradeProviderFlowType.toTradeType() = when (this) { + TradeProviderFlowType.BUY -> TradeTokenRegistry.TradeType.BUY + TradeProviderFlowType.SELL -> TradeTokenRegistry.TradeType.SELL +} + +fun TradeTokenRegistry.TradeType.toModel() = when (this) { + TradeTokenRegistry.TradeType.BUY -> TradeProviderFlowType.BUY + TradeTokenRegistry.TradeType.SELL -> TradeProviderFlowType.SELL +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderAdapter.kt new file mode 100644 index 0000000..4f274a5 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderAdapter.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.provider + +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflateChild +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ItemTradeProviderBinding + +class TradeProviderAdapter( + private val itemHandler: ItemHandler, +) : ListAdapter(DiffCallback) { + + interface ItemHandler { + fun providerClicked(item: TradeProviderRvItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TradeProviderViewHolder { + val binder = ItemTradeProviderBinding.inflate(parent.inflater(), parent, false) + return TradeProviderViewHolder(binder, itemHandler) + } + + override fun onBindViewHolder(holder: TradeProviderViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private object DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: TradeProviderRvItem, newItem: TradeProviderRvItem): Boolean { + return oldItem.providerLogoRes == newItem.providerLogoRes + } + + override fun areContentsTheSame(oldItem: TradeProviderRvItem, newItem: TradeProviderRvItem): Boolean { + return oldItem == newItem + } +} + +class TradeProviderViewHolder( + private val binder: ItemTradeProviderBinding, + private val itemHandler: TradeProviderAdapter.ItemHandler, +) : GroupedListHolder(binder.root) { + + fun bind(item: TradeProviderRvItem) = with(containerView) { + containerView.setOnClickListener { itemHandler.providerClicked(item) } + + binder.itemTradeProviderLogo.setImageResource(item.providerLogoRes) + binder.itemTradeProviderDescription.text = item.description + + fillPaymentMethods(item) + } + + private fun fillPaymentMethods(item: TradeProviderRvItem) = with(containerView) { + binder.itemTradeProviderPaymentMethods.removeAllViews() + + item.paymentMethods.forEach { + val view = when (it) { + is TradeProviderRvItem.PaymentMethod.ByResId -> createImage(it.resId) + is TradeProviderRvItem.PaymentMethod.ByText -> createText(it.text) + } + + binder.itemTradeProviderPaymentMethods.addView(view) + } + } + + private fun createImage(resId: Int): View { + return binder.itemTradeProviderPaymentMethods.inflateChild(R.layout.layout_payment_method_image, false) + .apply { + require(this is ImageView) + this.setImageResource(resId) + } + } + + private fun createText(text: String): View { + return binder.itemTradeProviderPaymentMethods.inflateChild(R.layout.layout_payment_method_text, false) + .apply { + require(this is TextView) + this.text = text + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderListFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderListFragment.kt new file mode 100644 index 0000000..60031d8 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderListFragment.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.provider + +import androidx.recyclerview.widget.ConcatAdapter +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.ViewSpace +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.view.recyclerview.adapter.text.TextAdapter +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.FragmentTradeProviderListBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent + +class TradeProviderListFragment : BaseFragment(), TradeProviderAdapter.ItemHandler { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentTradeProviderListBinding.inflate(layoutInflater) + + private val titleAdapter by lazy(LazyThreadSafetyMode.NONE) { + TextAdapter(styleRes = R.style.TextAppearance_NovaFoundation_Bold_Title3) + } + + private val providersAdapter by lazy(LazyThreadSafetyMode.NONE) { + TradeProviderAdapter(this) + } + + private val footerAdapter by lazy(LazyThreadSafetyMode.NONE) { + TextAdapter( + text = getString(R.string.trade_provider_list_footer), + R.style.TextAppearance_NovaFoundation_Regular_Caption1, + textColor = R.color.text_secondary, + paddingInDp = ViewSpace(top = 12) + ) + } + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + ConcatAdapter(titleAdapter, providersAdapter, footerAdapter) + } + + override fun initViews() { + binder.tradeProviderListToolbar.setHomeButtonListener { viewModel.back() } + binder.tradeProviderList.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .tradeProviderListComponent() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: TradeProviderListViewModel) { + setupConfirmationDialog(R.style.AccentAlertDialogTheme, viewModel.confirmationAwaitableAction) + viewModel.titleFlow.observe { titleAdapter.setText(it) } + viewModel.providerModels.observe { providersAdapter.submitList(it) } + } + + override fun providerClicked(item: TradeProviderRvItem) { + viewModel.onProviderClicked(item) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderListPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderListPayload.kt new file mode 100644 index 0000000..dff8093 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderListPayload.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.provider + +import android.os.Parcelable +import io.novafoundation.nova.feature_assets.presentation.trade.common.TradeProviderFlowType +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.OnSuccessfulTradeStrategyType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.android.parcel.Parcelize + +@Parcelize +class TradeProviderListPayload( + val chainId: ChainId, + val assetId: Int, + val type: TradeProviderFlowType, + val onSuccessfulTradeStrategyType: OnSuccessfulTradeStrategyType +) : Parcelable diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderListViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderListViewModel.kt new file mode 100644 index 0000000..4256e92 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderListViewModel.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.provider + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.trade.common.TradeProviderFlowType +import io.novafoundation.nova.feature_assets.presentation.trade.common.toModel +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebPayload +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class TradeProviderListViewModel( + private val payload: TradeProviderListPayload, + private val tradeMixinFactory: TradeMixin.Factory, + private val resourceManager: ResourceManager, + private val chainRegistry: ChainRegistry, + private val router: AssetsRouter, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, +) : BaseViewModel() { + + val confirmationAwaitableAction = actionAwaitableMixinFactory.confirmingAction() + + private val tradeMixin = tradeMixinFactory.create(viewModelScope) + + private val tradeType = payload.type.toTradeFlow() + + private val chainAssetFlow = flowOf { chainRegistry.asset(payload.chainId, payload.assetId) } + .shareInBackground() + + val titleFlow = chainAssetFlow.map { + when (tradeType) { + TradeTokenRegistry.TradeType.BUY -> resourceManager.getString(R.string.trade_provider_list_buy_title, it.symbol.value) + TradeTokenRegistry.TradeType.SELL -> resourceManager.getString(R.string.trade_provider_list_sell_title, it.symbol.value) + } + } + + private val providers = chainAssetFlow.map { + tradeMixin.providersFor(it, tradeType) + }.shareInBackground() + + val providerModels = providers.mapList { provider -> + val paymentMethods = provider.getPaymentMethods(tradeType).map { it.toModel() } + TradeProviderRvItem( + provider.id, + provider.officialUrl, + provider.logoRes, + paymentMethods, + resourceManager.getString(provider.getDescriptionRes(tradeType)) + ) + } + + fun back() { + router.back() + } + + private fun TradeProviderFlowType.toTradeFlow() = when (this) { + TradeProviderFlowType.BUY -> TradeTokenRegistry.TradeType.BUY + TradeProviderFlowType.SELL -> TradeTokenRegistry.TradeType.SELL + } + + private fun TradeTokenRegistry.PaymentMethod.toModel() = when (this) { + TradeTokenRegistry.PaymentMethod.ApplePay -> TradeProviderRvItem.PaymentMethod.ByResId(R.drawable.ic_apple_pay) + TradeTokenRegistry.PaymentMethod.BankTransfer -> TradeProviderRvItem.PaymentMethod.ByResId(R.drawable.ic_bank) + TradeTokenRegistry.PaymentMethod.GooglePay -> TradeProviderRvItem.PaymentMethod.ByResId(R.drawable.ic_google_pay) + TradeTokenRegistry.PaymentMethod.MasterCard -> TradeProviderRvItem.PaymentMethod.ByResId(R.drawable.ic_mastercard) + TradeTokenRegistry.PaymentMethod.Sepa -> TradeProviderRvItem.PaymentMethod.ByResId(R.drawable.ic_sepa) + TradeTokenRegistry.PaymentMethod.Visa -> TradeProviderRvItem.PaymentMethod.ByResId(R.drawable.ic_visa) + + is TradeTokenRegistry.PaymentMethod.Other -> TradeProviderRvItem.PaymentMethod.ByText( + resourceManager.getString( + R.string.additional_payment_methods, + this.quantity + ) + ) + } + + fun onProviderClicked(item: TradeProviderRvItem) { + launch { + awaitConfirmation(item) + + val chainAsset = chainAssetFlow.first() + + router.openTradeWebInterface( + TradeWebPayload( + AssetPayload(chainAsset.chainId, chainAsset.id), + item.providerId, + tradeType.toModel(), + payload.onSuccessfulTradeStrategyType + ) + ) + } + } + + private suspend fun awaitConfirmation(item: TradeProviderRvItem) { + confirmationAwaitableAction.awaitAction( + ConfirmationDialogInfo( + resourceManager.getString(R.string.trade_provider_open_confirmation_title), + resourceManager.getString(R.string.trade_provider_open_confirmation_message, item.providerLink), + resourceManager.getString(R.string.common_continue), + resourceManager.getString(R.string.common_cancel) + ) + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderRvItem.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderRvItem.kt new file mode 100644 index 0000000..42352ed --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/TradeProviderRvItem.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.provider + +import androidx.annotation.DrawableRes + +data class TradeProviderRvItem( + val providerId: String, + val providerLink: String, + val providerLogoRes: Int, + val paymentMethods: List, + val description: String +) { + + sealed interface PaymentMethod { + class ByResId(@DrawableRes val resId: Int) : PaymentMethod + + class ByText(val text: String) : PaymentMethod + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/di/TradeProviderListComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/di/TradeProviderListComponent.kt new file mode 100644 index 0000000..0b21727 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/di/TradeProviderListComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.provider.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.trade.provider.TradeProviderListFragment +import io.novafoundation.nova.feature_assets.presentation.trade.provider.TradeProviderListPayload + +@Subcomponent( + modules = [ + TradeProviderListModule::class + ] +) +@ScreenScope +interface TradeProviderListComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: TradeProviderListPayload + ): TradeProviderListComponent + } + + fun inject(fragment: TradeProviderListFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/di/TradeProviderListModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/di/TradeProviderListModule.kt new file mode 100644 index 0000000..04253fb --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/provider/di/TradeProviderListModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.provider.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.trade.provider.TradeProviderListPayload +import io.novafoundation.nova.feature_assets.presentation.trade.provider.TradeProviderListViewModel +import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class TradeProviderListModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): TradeProviderListViewModel { + return ViewModelProvider(fragment, factory).get(TradeProviderListViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(TradeProviderListViewModel::class) + fun provideViewModel( + payload: TradeProviderListPayload, + tradeMixinFactory: TradeMixin.Factory, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + router: AssetsRouter, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + ): ViewModel { + return TradeProviderListViewModel( + payload = payload, + tradeMixinFactory = tradeMixinFactory, + resourceManager = resourceManager, + chainRegistry = chainRegistry, + router = router, + actionAwaitableMixinFactory = actionAwaitableMixinFactory + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/AssetSellFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/AssetSellFlowFragment.kt new file mode 100644 index 0000000..3f3e090 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/AssetSellFlowFragment.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.asset + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowFragment + +class AssetSellFlowFragment : AssetFlowFragment() { + + override fun initViews() { + super.initViews() + setTitle(R.string.wallet_asset_sell) + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .sellFlowComponent() + .create(this) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/AssetSellFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/AssetSellFlowViewModel.kt new file mode 100644 index 0000000..69e4af7 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/AssetSellFlowViewModel.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.asset + +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.models.AssetsByViewModeResult +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.list.model.items.TokenGroupUi +import io.novafoundation.nova.feature_assets.presentation.flow.asset.AssetFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class AssetSellFlowViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + currencyInteractor: CurrencyInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + networkAssetMapper: NetworkAssetFormatter, + tokenAssetFormatter: TokenAssetFormatter +) : AssetFlowViewModel( + interactorFactory, + router, + currencyInteractor, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + assetIconProvider, + assetViewModeInteractor, + networkAssetMapper, + tokenAssetFormatter +) { + + override fun searchAssetsFlow(): Flow { + return interactor.tradeAssetSearch(query, externalBalancesFlow, TradeTokenRegistry.TradeType.SELL) + } + + override fun assetClicked(asset: Chain.Asset) { + validate(asset) { + router.openSellProviders(asset.chainId, asset.id) + } + } + + override fun tokenClicked(tokenGroup: TokenGroupUi) { + when (val type = tokenGroup.groupType) { + is TokenGroupUi.GroupType.SingleItem -> assetClicked(type.asset) + TokenGroupUi.GroupType.Group -> router.openSellNetworks(NetworkFlowPayload(tokenGroup.tokenSymbol)) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/di/AssetSellFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/di/AssetSellFlowComponent.kt new file mode 100644 index 0000000..e55bf46 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/di/AssetSellFlowComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.asset.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.asset.AssetSellFlowFragment + +@Subcomponent( + modules = [ + AssetSellFlowModule::class + ] +) +@ScreenScope +interface AssetSellFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): AssetSellFlowComponent + } + + fun inject(fragment: AssetSellFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/di/AssetSellFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/di/AssetSellFlowModule.kt new file mode 100644 index 0000000..bd21971 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/asset/di/AssetSellFlowModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.asset.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.assets.search.AssetSearchInteractorFactory +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.NetworkAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.balance.common.mappers.TokenAssetFormatter +import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.asset.AssetSellFlowViewModel +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor + +@Module(includes = [ViewModelModule::class]) +class AssetSellFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AssetSellFlowViewModel { + return ViewModelProvider(fragment, factory).get(AssetSellFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AssetSellFlowViewModel::class) + fun provideViewModel( + interactorFactory: AssetSearchInteractorFactory, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + currencyInteractor: CurrencyInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider, + assetViewModeInteractor: AssetViewModeInteractor, + networkAssetMapper: NetworkAssetFormatter, + tokenAssetFormatter: TokenAssetFormatter + ): ViewModel { + return AssetSellFlowViewModel( + interactorFactory = interactorFactory, + router = router, + externalBalancesInteractor = externalBalancesInteractor, + currencyInteractor = currencyInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + assetIconProvider = assetIconProvider, + assetViewModeInteractor = assetViewModeInteractor, + networkAssetMapper = networkAssetMapper, + tokenAssetFormatter = tokenAssetFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/NetworkSellFlowFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/NetworkSellFlowFragment.kt new file mode 100644 index 0000000..7f65e80 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/NetworkSellFlowFragment.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.network + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowFragment + +class NetworkSellFlowFragment : NetworkFlowFragment() { + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .networkSellFlowComponent() + .create(this, payload()) + .inject(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/NetworkSellFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/NetworkSellFlowViewModel.kt new file mode 100644 index 0000000..c87b342 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/NetworkSellFlowViewModel.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.network + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.common.AssetBalance +import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowViewModel +import io.novafoundation.nova.feature_assets.presentation.flow.network.model.NetworkFlowRvItem +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.Flow + +class NetworkSellFlowViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter +) : NetworkFlowViewModel( + interactor, + router, + controllableAssetCheck, + accountUseCase, + externalBalancesInteractor, + resourceManager, + networkFlowPayload, + chainRegistry, + amountFormatter +) { + + override fun getAssetBalance(asset: AssetWithNetwork): AssetBalance.Amount { + return asset.balanceWithOffChain.total + } + + override fun assetsFlow(tokenSymbol: TokenSymbol): Flow> { + return interactor.tradeAssetFlow(tokenSymbol, externalBalancesFlow, TradeTokenRegistry.TradeType.SELL) + } + + override fun networkClicked(network: NetworkFlowRvItem) { + validateControllsAsset(network) { + router.openSellProviders(network.chainId, network.assetId) + } + } + + override fun getTitle(tokenSymbol: TokenSymbol): String { + return resourceManager.getString(R.string.sell_network_flow_title, tokenSymbol.value) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/di/NetworkSellFlowComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/di/NetworkSellFlowComponent.kt new file mode 100644 index 0000000..72bd443 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/di/NetworkSellFlowComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.network.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.network.NetworkSellFlowFragment + +@Subcomponent( + modules = [ + NetworkSellFlowModule::class + ] +) +@ScreenScope +interface NetworkSellFlowComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance networkFlowPayload: NetworkFlowPayload + ): NetworkSellFlowComponent + } + + fun inject(fragment: NetworkSellFlowFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/di/NetworkSellFlowModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/di/NetworkSellFlowModule.kt new file mode 100644 index 0000000..e501c55 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/sell/flow/network/di/NetworkSellFlowModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.network.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.assets.ExternalBalancesInteractor +import io.novafoundation.nova.feature_assets.domain.networks.AssetNetworksInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.balance.common.ControllableAssetCheckMixin +import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload +import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.network.NetworkSellFlowViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class NetworkSellFlowModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NetworkSellFlowViewModel { + return ViewModelProvider(fragment, factory).get(NetworkSellFlowViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(NetworkSellFlowViewModel::class) + fun provideViewModel( + interactor: AssetNetworksInteractor, + router: AssetsRouter, + externalBalancesInteractor: ExternalBalancesInteractor, + controllableAssetCheck: ControllableAssetCheckMixin, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + networkFlowPayload: NetworkFlowPayload, + chainRegistry: ChainRegistry, + amountFormatter: AmountFormatter + ): ViewModel { + return NetworkSellFlowViewModel( + interactor = interactor, + router = router, + externalBalancesInteractor = externalBalancesInteractor, + controllableAssetCheck = controllableAssetCheck, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + networkFlowPayload = networkFlowPayload, + chainRegistry = chainRegistry, + amountFormatter = amountFormatter + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/OnSuccessfulTradeStrategyType.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/OnSuccessfulTradeStrategyType.kt new file mode 100644 index 0000000..ac75ccd --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/OnSuccessfulTradeStrategyType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.webInterface + +enum class OnSuccessfulTradeStrategyType { + OPEN_ASSET, RETURN_BACK +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/TradeWebFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/TradeWebFragment.kt new file mode 100644 index 0000000..c0f4549 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/TradeWebFragment.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.webInterface + +import androidx.webkit.WebSettingsCompat +import androidx.webkit.WebViewFeature +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_assets.databinding.FragmentTradeWebInterfaceBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent + +class TradeWebFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentTradeWebInterfaceBinding.inflate(layoutInflater) + + override fun initViews() { + binder.tradeWebToolbar.setHomeButtonListener { viewModel.back() } + + binder.tradeWebView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = true + allowContentAccess = true + useWideViewPort = true + displayZoomControls = false + + javaScriptCanOpenWindowsAutomatically = true + setSupportMultipleWindows(true) + + if (WebViewFeature.isFeatureSupported(WebViewFeature.PAYMENT_REQUEST)) { + WebSettingsCompat.setPaymentRequestEnabled(this, true) + } + } + } + + override fun inject() { + FeatureUtils.getFeature(this, AssetsFeatureApi::class.java) + .tradeWebComponent() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: TradeWebViewModel) { + viewModel.integrator.observe { + it.run(binder.tradeWebView) + } + + viewModel.webChromeClientFlow.observeFirst { + binder.tradeWebView.webChromeClient = it + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/TradeWebPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/TradeWebPayload.kt new file mode 100644 index 0000000..fe41c42 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/TradeWebPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.webInterface + +import android.os.Parcelable +import io.novafoundation.nova.feature_assets.presentation.trade.common.TradeProviderFlowType +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import kotlinx.android.parcel.Parcelize + +@Parcelize +class TradeWebPayload( + val asset: AssetPayload, + val providerId: String, + val type: TradeProviderFlowType, + val onSuccessfulTradeStrategyType: OnSuccessfulTradeStrategyType +) : Parcelable diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/TradeWebViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/TradeWebViewModel.kt new file mode 100644 index 0000000..e3d90bb --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/TradeWebViewModel.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.webInterface + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.webView.BaseWebChromeClientFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressPayload +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressRequester +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressResponder +import io.novafoundation.nova.feature_assets.presentation.trade.common.TradeProviderFlowType +import io.novafoundation.nova.feature_assets.presentation.trade.common.toTradeType +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.providers.WebViewIntegrationProvider +import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import java.math.BigDecimal +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class TradeWebViewModel( + private val payload: TradeWebPayload, + private val tradeMixinFactory: TradeMixin.Factory, + private val resourceManager: ResourceManager, + private val chainRegistry: ChainRegistry, + private val router: AssetsRouter, + private val accountUseCase: SelectedAccountUseCase, + private val baseWebChromeClientFactory: BaseWebChromeClientFactory, + private val topUpRequester: TopUpAddressRequester, +) : BaseViewModel(), OnTradeOperationFinishedListener, OnSellOrderCreatedListener { + + private val tradeMixin = tradeMixinFactory.create(viewModelScope) + + private val tradeFlow = payload.type.toTradeType() + + private val chainFlow = flowOf { chainRegistry.getChain(payload.asset.chainId) } + + private val chainAssetFlow = flowOf { chainRegistry.asset(payload.asset.chainId, payload.asset.chainAssetId) } + .shareInBackground() + + val integrator = combine(chainFlow, chainAssetFlow) { chain, chainAsset -> + val address = accountUseCase.getSelectedMetaAccount().requireAddressIn(chain) + tradeMixin.providerFor(chainAsset, tradeFlow, payload.providerId) + .createIntegrator( + chainAsset = chainAsset, + address = address, + tradeFlow = tradeFlow, + onCloseListener = this, + onSellOrderCreatedListener = this + ) + } + .shareInBackground() + + val webChromeClientFlow = flowOf { baseWebChromeClientFactory.create(viewModelScope) } + .shareInBackground() + + fun back() { + router.back() + } + + init { + observeTopUp() + } + + override fun onTradeOperationFinished(success: Boolean) = launchUnit { + if (success) { + val messageResId = when (payload.type) { + TradeProviderFlowType.BUY -> R.string.buy_order_completed_message + TradeProviderFlowType.SELL -> R.string.sell_order_completed_message + } + showToast(resourceManager.getString(messageResId)) + + when (payload.onSuccessfulTradeStrategyType) { + OnSuccessfulTradeStrategyType.OPEN_ASSET -> router.openAssetDetails(payload.asset) + OnSuccessfulTradeStrategyType.RETURN_BACK -> router.finishTradeOperation() + } + } else { + router.finishTradeOperation() + } + } + + override fun onSellOrderCreated(orderId: String, address: String, amount: BigDecimal) = launchUnit { + val asset = chainAssetFlow.first() + + val request = TopUpAddressPayload( + address, + amount, + payload.asset, + screenTitle = resourceManager.getString(R.string.fragment_sell_token_title, asset.symbol.value) + ) + + topUpRequester.openRequest(request) + } + + private fun observeTopUp() { + topUpRequester.responseFlow + .onEach { + if (it == TopUpAddressResponder.Response.Cancel) { + router.finishTradeOperation() + } + } + .launchIn(this) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/di/TradeWebComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/di/TradeWebComponent.kt new file mode 100644 index 0000000..5f2f7ea --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/di/TradeWebComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.webInterface.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebFragment +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebPayload + +@Subcomponent( + modules = [ + TradeWebModule::class + ] +) +@ScreenScope +interface TradeWebComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: TradeWebPayload + ): TradeWebComponent + } + + fun inject(fragment: TradeWebFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/di/TradeWebModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/di/TradeWebModule.kt new file mode 100644 index 0000000..0b87bf4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/trade/webInterface/di/TradeWebModule.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_assets.presentation.trade.webInterface.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooser +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAsker +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory +import io.novafoundation.nova.common.utils.webView.BaseWebChromeClientFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebPayload +import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebViewModel +import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class TradeWebModule { + + @Provides + @ScreenScope + fun provideFileChooser( + fragment: Fragment, + webViewFileChooserFactory: WebViewFileChooserFactory + ) = webViewFileChooserFactory.create(fragment) + + @Provides + @ScreenScope + fun providePermissionAsker( + fragment: Fragment, + webViewPermissionAskerFactory: WebViewPermissionAskerFactory + ) = webViewPermissionAskerFactory.create(fragment) + + @Provides + fun provideBaseWebChromeClientFactory( + permissionsAsker: WebViewPermissionAsker, + webViewFileChooser: WebViewFileChooser + ) = BaseWebChromeClientFactory(permissionsAsker, webViewFileChooser) + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): TradeWebViewModel { + return ViewModelProvider(fragment, factory).get(TradeWebViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(TradeWebViewModel::class) + fun provideViewModel( + payload: TradeWebPayload, + tradeMixinFactory: TradeMixin.Factory, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + router: AssetsRouter, + accountUseCase: SelectedAccountUseCase, + baseWebChromeClientFactory: BaseWebChromeClientFactory, + topUpAddressCommunicator: TopUpAddressCommunicator + ): ViewModel { + return TradeWebViewModel( + payload = payload, + tradeMixinFactory = tradeMixinFactory, + resourceManager = resourceManager, + chainRegistry = chainRegistry, + router = router, + accountUseCase = accountUseCase, + baseWebChromeClientFactory = baseWebChromeClientFactory, + topUpRequester = topUpAddressCommunicator + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/ExtrinsicDetailModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/ExtrinsicDetailModule.kt new file mode 100644 index 0000000..ea43083 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/ExtrinsicDetailModule.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.extrinsic.ExtrinsicDetailViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class ExtrinsicDetailModule { + @Provides + @IntoMap + @ViewModelKey(ExtrinsicDetailViewModel::class) + fun provideViewModel( + addressDisplayUseCase: AddressDisplayUseCase, + addressIconGenerator: AddressIconGenerator, + chainRegistry: ChainRegistry, + router: AssetsRouter, + operation: OperationParcelizeModel.Extrinsic, + externalActions: ExternalActions.Presentation, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider + ): ViewModel { + return ExtrinsicDetailViewModel( + addressDisplayUseCase, + addressIconGenerator, + chainRegistry, + router, + operation, + externalActions, + resourceManager, + assetIconProvider + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ExtrinsicDetailViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ExtrinsicDetailViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/PoolRewardDetailModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/PoolRewardDetailModule.kt new file mode 100644 index 0000000..c201d67 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/PoolRewardDetailModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.pool.PoolRewardDetailViewModel +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class PoolRewardDetailModule { + + @Provides + @IntoMap + @ViewModelKey(PoolRewardDetailViewModel::class) + fun provideViewModel( + operation: OperationParcelizeModel.PoolReward, + poolDisplayUseCase: PoolDisplayUseCase, + router: AssetsRouter, + chainRegistry: ChainRegistry, + externalActions: ExternalActions.Presentation + ): ViewModel { + return PoolRewardDetailViewModel( + operation = operation, + poolDisplayUseCase = poolDisplayUseCase, + router = router, + chainRegistry = chainRegistry, + externalActions = externalActions + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PoolRewardDetailViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PoolRewardDetailViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/RewardDetailModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/RewardDetailModule.kt new file mode 100644 index 0000000..ebd9fdf --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/RewardDetailModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.direct.RewardDetailViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class RewardDetailModule { + + @Provides + @IntoMap + @ViewModelKey(RewardDetailViewModel::class) + fun provideViewModel( + operation: OperationParcelizeModel.Reward, + addressIconGenerator: AddressIconGenerator, + addressDisplayUseCase: AddressDisplayUseCase, + chainRegistry: ChainRegistry, + externalActions: ExternalActions.Presentation, + router: AssetsRouter + ): ViewModel { + return RewardDetailViewModel( + operation, + addressIconGenerator, + addressDisplayUseCase, + router, + chainRegistry, + externalActions, + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): RewardDetailViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(RewardDetailViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/TransactionDetailComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/TransactionDetailComponent.kt new file mode 100644 index 0000000..cd18d11 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/TransactionDetailComponent.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.extrinsic.ExtrinsicDetailFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.direct.RewardDetailFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.pool.PoolRewardDetailFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.transfer.TransferDetailFragment + +@Subcomponent( + modules = [ + TransactionDetailModule::class, + ] +) +@ScreenScope +interface TransactionDetailComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance operation: OperationParcelizeModel.Transfer + ): TransactionDetailComponent + } + + fun inject(fragment: TransferDetailFragment) +} + +@Subcomponent( + modules = [ + RewardDetailModule::class + ] +) +@ScreenScope +interface RewardDetailComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance operation: OperationParcelizeModel.Reward + ): RewardDetailComponent + } + + fun inject(fragment: RewardDetailFragment) +} + +@Subcomponent( + modules = [ + PoolRewardDetailModule::class + ] +) +@ScreenScope +interface PoolRewardDetailComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance operation: OperationParcelizeModel.PoolReward + ): PoolRewardDetailComponent + } + + fun inject(fragment: PoolRewardDetailFragment) +} + +@Subcomponent( + modules = [ + ExtrinsicDetailModule::class + ] +) +@ScreenScope +interface ExtrinsicDetailComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance extrinsic: OperationParcelizeModel.Extrinsic + ): ExtrinsicDetailComponent + } + + fun inject(fragment: ExtrinsicDetailFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/TransactionDetailModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/TransactionDetailModule.kt new file mode 100644 index 0000000..3f6d1e4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/di/TransactionDetailModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.transfer.TransactionDetailViewModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class TransactionDetailModule { + + @Provides + @IntoMap + @ViewModelKey(TransactionDetailViewModel::class) + fun provideViewModel( + router: AssetsRouter, + addressIconGenerator: AddressIconGenerator, + addressDisplayUseCase: AddressDisplayUseCase, + chainRegistry: ChainRegistry, + operation: OperationParcelizeModel.Transfer, + externalActions: ExternalActions.Presentation, + arbitraryTokenUseCase: ArbitraryTokenUseCase, + amountFormatter: AmountFormatter + ): ViewModel { + return TransactionDetailViewModel( + router, + addressIconGenerator, + addressDisplayUseCase, + chainRegistry, + operation, + externalActions, + arbitraryTokenUseCase, + amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): TransactionDetailViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(TransactionDetailViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/extrinsic/ExtrinsicDetailFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/extrinsic/ExtrinsicDetailFragment.kt new file mode 100644 index 0000000..64215f6 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/extrinsic/ExtrinsicDetailFragment.kt @@ -0,0 +1,160 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.extrinsic + +import android.os.Bundle +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.formatting.formatDateTime +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.common.view.TableView +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.FragmentExtrinsicDetailsBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.model.showOperationStatus +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.extrinsic.model.ExtrinsicContentModel +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.extrinsic.model.ExtrinsicContentModel.BlockEntry + +import javax.inject.Inject + +private const val KEY_EXTRINSIC = "KEY_EXTRINSIC" + +class ExtrinsicDetailFragment : BaseFragment() { + + companion object { + fun getBundle(operation: OperationParcelizeModel.Extrinsic) = Bundle().apply { + putParcelable(KEY_EXTRINSIC, operation) + } + } + + override fun createBinding() = FragmentExtrinsicDetailsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun initViews() { + binder.extrinsicDetailToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.extrinsicDetailSender.setOnClickListener { + viewModel.fromAddressClicked() + } + } + + override fun inject() { + val operation = argument(KEY_EXTRINSIC) + + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .extrinsicDetailComponentFactory() + .create(this, operation) + .inject(this) + } + + override fun subscribe(viewModel: ExtrinsicDetailViewModel) { + setupExternalActions(viewModel) + + with(viewModel.operation) { + binder.extrinsicDetailStatus.showOperationStatus(statusAppearance) + binder.extrinsicDetailAmount.setTextColorRes(statusAppearance.amountTint) + + binder.extrinsicDetailToolbar.setTitle(time.formatDateTime()) + + binder.extrinsicDetailAmount.text = fee + binder.extrinsicDetailAmountFiat.setTextOrHide(this.fiatFee) + } + + viewModel.content.observe(::showExtrinsicContent) + + viewModel.senderAddressModelFlow.observe(binder.extrinsicDetailSender::showAddress) + + viewModel.chainUi.observe(binder.extrinsicDetailNetwork::showChain) + + viewModel.operationIcon.observe { + binder.extrinsicDetailIcon.setIcon(it, imageLoader) + } + } + + private fun showExtrinsicContent(content: ExtrinsicContentModel) { + content.blocks.forEach { block -> + createBlock { + block.entries.forEach { entry -> + blockEntry(entry) + } + } + } + } + + private fun createBlock(builder: TableView.() -> Unit) { + val block = TableView(requireContext()).apply { + layoutParams = ViewGroup.MarginLayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + setMargins(16.dp, 12.dp, 16.dp, 0) + } + } + + block.apply(builder) + + binder.extrinsicContentContainer.addView(block) + } + + private fun TableView.blockEntry(entry: BlockEntry) { + when (entry) { + is BlockEntry.Address -> address(entry) + is BlockEntry.LabeledValue -> labeledValue(entry) + is BlockEntry.TransactionId -> transactionId(entry) + } + } + + private fun TableView.transactionId(transactionId: BlockEntry.TransactionId) { + createEntry { + setTitle(transactionId.label) + showValue(transactionId.hash) + + clickable { viewModel.transactionIdClicked(transactionId.hash) } + } + } + + private fun TableView.address(address: BlockEntry.Address) { + createEntry { + setTitle(address.label) + showAddress(address.addressModel) + + clickable { viewModel.addressClicked(address.addressModel.address) } + } + } + + private fun TableView.labeledValue(labeledValue: BlockEntry.LabeledValue) { + createEntry { + setTitle(labeledValue.label) + showValue(labeledValue.value) + } + } + + private fun TableView.createEntry(builder: TableCellView.() -> Unit) { + val block = TableCellView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + } + + block.apply(builder) + + addView(block) + } + + private inline fun TableCellView.clickable(crossinline onClick: () -> Unit) { + setPrimaryValueEndIcon(R.drawable.ic_info) + + setOnClickListener { onClick() } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/extrinsic/ExtrinsicDetailViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/extrinsic/ExtrinsicDetailViewModel.kt new file mode 100644 index 0000000..3c92241 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/extrinsic/ExtrinsicDetailViewModel.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.extrinsic + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.getAssetIconOrFallback +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.ExtrinsicContentParcel +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.extrinsic.model.ExtrinsicContentModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import kotlinx.coroutines.launch + +class ExtrinsicDetailViewModel( + private val addressDisplayUseCase: AddressDisplayUseCase, + private val addressIconGenerator: AddressIconGenerator, + private val chainRegistry: ChainRegistry, + private val router: AssetsRouter, + val operation: OperationParcelizeModel.Extrinsic, + private val externalActions: ExternalActions.Presentation, + private val resourceManager: ResourceManager, + private val assetIconProvider: AssetIconProvider +) : BaseViewModel(), + ExternalActions by externalActions { + + private val chain by lazyAsync { + chainRegistry.getChain(operation.chainId) + } + + private val chainAsset by lazyAsync { + chainRegistry.asset(operation.chainId, operation.chainAssetId) + } + + val senderAddressModelFlow = flowOf { + getIcon(operation.originAddress) + } + .inBackground() + .share() + + val chainUi = flowOf { + mapChainToUi(chain()) + } + .inBackground() + .share() + + val operationIcon = flowOf { + assetIconProvider.getAssetIconOrFallback(chainAsset().icon, AssetIconMode.WHITE) + }.shareInBackground() + + val content = flowOf { + mapExtrinsicContentParcelToModel(operation.content) + }.shareInBackground() + + fun transactionIdClicked(hash: String) = launch { + externalActions.showExternalActions(ExternalActions.Type.Extrinsic(hash), chain()) + } + + fun fromAddressClicked() = addressClicked(operation.originAddress) + + fun addressClicked(address: String) = launch { + externalActions.showAddressActions(address, chain()) + } + + fun backClicked() { + router.back() + } + + private suspend fun mapExtrinsicContentParcelToModel(parcel: ExtrinsicContentParcel): ExtrinsicContentModel { + val blocks = parcel.blocks.map { mapBlockFromParcel(it) } + + return ExtrinsicContentModel(blocks) + } + + private suspend fun mapBlockFromParcel(block: ExtrinsicContentParcel.Block): ExtrinsicContentModel.Block { + val entries = block.entries.map { mapBlockEntryFromParcel(it) } + + return ExtrinsicContentModel.Block(entries) + } + + private suspend fun mapBlockEntryFromParcel(blockEntry: ExtrinsicContentParcel.BlockEntry): ExtrinsicContentModel.BlockEntry { + return when (blockEntry) { + is ExtrinsicContentParcel.BlockEntry.Address -> ExtrinsicContentModel.BlockEntry.Address( + label = blockEntry.label, + addressModel = getIcon(blockEntry.address), + ) + + is ExtrinsicContentParcel.BlockEntry.LabeledValue -> ExtrinsicContentModel.BlockEntry.LabeledValue( + label = blockEntry.label, + value = blockEntry.value + ) + + is ExtrinsicContentParcel.BlockEntry.TransactionId -> ExtrinsicContentModel.BlockEntry.TransactionId( + label = resourceManager.getString(R.string.common_transaction_id), + hash = blockEntry.hash + ) + } + } + + private suspend fun getIcon(address: String) = addressIconGenerator.createAddressModel( + chain = chain(), + address = address, + sizeInDp = AddressIconGenerator.SIZE_BIG, + addressDisplayUseCase = addressDisplayUseCase, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/extrinsic/model/ExtrinsicContentParcel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/extrinsic/model/ExtrinsicContentParcel.kt new file mode 100644 index 0000000..bd508e4 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/extrinsic/model/ExtrinsicContentParcel.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.extrinsic.model + +import io.novafoundation.nova.common.address.AddressModel + +class ExtrinsicContentModel(val blocks: List) { + + class Block(val entries: List) + + sealed class BlockEntry { + + class TransactionId(val label: String, val hash: String) : BlockEntry() + + class Address(val label: String, val addressModel: AddressModel) : BlockEntry() + + class LabeledValue(val label: String, val value: String) : BlockEntry() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/direct/RewardDetailFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/direct/RewardDetailFragment.kt new file mode 100644 index 0000000..ce0d615 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/direct/RewardDetailFragment.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.direct + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.formatting.formatDateTime +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddressOrHide +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_assets.databinding.FragmentRewardSlashDetailsBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.model.showOperationStatus +import io.novafoundation.nova.feature_assets.presentation.model.toAmountModel + +class RewardDetailFragment : BaseFragment() { + + companion object { + private const val KEY_REWARD = "KEY_REWARD" + + fun getBundle(operation: OperationParcelizeModel.Reward) = Bundle().apply { + putParcelable(KEY_REWARD, operation) + } + } + + override fun createBinding() = FragmentRewardSlashDetailsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.rewardDetailToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.rewardDetailEvent.setOnClickListener { + viewModel.eventIdClicked() + } + + binder.rewardDetailValidator.setOnClickListener { + viewModel.validatorAddressClicked() + } + } + + override fun inject() { + val operation = argument(KEY_REWARD) + + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .rewardDetailComponentFactory() + .create(this, operation) + .inject(this) + } + + override fun subscribe(viewModel: RewardDetailViewModel) { + setupExternalActions(viewModel) + + with(viewModel.operation) { + binder.rewardDetailEvent.showValue(eventId) + binder.rewardDetailToolbar.setTitle(time.formatDateTime()) + binder.rewardDetailAmount.setAmount(amount.toAmountModel()) + + binder.rewardDetailEra.showValueOrHide(era) + + binder.rewardDetailStatus.showOperationStatus(statusAppearance) + + binder.rewardDetailType.showValue(type) + } + + viewModel.validatorAddressModelFlow.observe(binder.rewardDetailValidator::showAddressOrHide) + + viewModel.chainUi.observe(binder.rewardDetailNetwork::showChain) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/direct/RewardDetailViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/direct/RewardDetailViewModel.kt new file mode 100644 index 0000000..4ce7c6e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/direct/RewardDetailViewModel.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.direct + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.launch + +class RewardDetailViewModel( + val operation: OperationParcelizeModel.Reward, + private val addressIconGenerator: AddressIconGenerator, + private val addressDisplayUseCase: AddressDisplayUseCase, + private val router: AssetsRouter, + private val chainRegistry: ChainRegistry, + private val externalActions: ExternalActions.Presentation, +) : BaseViewModel(), + ExternalActions by externalActions { + + val chain by lazyAsync { + chainRegistry.getChain(operation.chainId) + } + + val validatorAddressModelFlow = flowOf { + operation.validator?.let { getIcon(it) } + } + .inBackground() + .share() + + val chainUi = flowOf { + mapChainToUi(chain()) + } + .inBackground() + .share() + + fun backClicked() { + router.back() + } + + fun eventIdClicked() = launch { + externalActions.showExternalActions(ExternalActions.Type.Event(operation.eventId), chain()) + } + + fun validatorAddressClicked() = launch { + operation.validator?.let { + externalActions.showAddressActions(it, chain()) + } + } + + private suspend fun getIcon(address: String) = addressIconGenerator.createAddressModel( + chain = chain(), + address = address, + sizeInDp = AddressIconGenerator.SIZE_BIG, + addressDisplayUseCase = addressDisplayUseCase, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/pool/PoolRewardDetailFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/pool/PoolRewardDetailFragment.kt new file mode 100644 index 0000000..7eadfb7 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/pool/PoolRewardDetailFragment.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.pool + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.formatting.formatDateTime +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_assets.databinding.FragmentPoolRewardDetailsBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.model.OperationStatusAppearance +import io.novafoundation.nova.feature_assets.presentation.model.showOperationStatus +import io.novafoundation.nova.feature_assets.presentation.model.toAmountModel +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.showPool + +class PoolRewardDetailFragment : BaseFragment() { + + companion object { + private const val PAYLOAD_KEY = "RewardDetailFragment.PAYLOAD_KEY" + + fun getBundle(operation: OperationParcelizeModel.PoolReward) = Bundle().apply { + putParcelable(PAYLOAD_KEY, operation) + } + } + + override fun createBinding() = FragmentPoolRewardDetailsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.poolRewardDetailToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.poolRewardDetailEventId.setOnClickListener { + viewModel.eventIdClicked() + } + + binder.poolRewardDetailPool.setOnClickListener { + viewModel.poolClicked() + } + + binder.poolRewardDetailStatus.showOperationStatus(OperationStatusAppearance.COMPLETED) + } + + override fun inject() { + val operation = argument(PAYLOAD_KEY) + + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .poolRewardDetailComponentFactory() + .create(this, operation) + .inject(this) + } + + override fun subscribe(viewModel: PoolRewardDetailViewModel) { + setupExternalActions(viewModel) + + with(viewModel.operation) { + binder.poolRewardDetailEventId.showValueOrHide(eventId) + binder.poolRewardDetailToolbar.setTitle(time.formatDateTime()) + binder.poolRewardDetailAmount.setAmount(amount.toAmountModel()) + + binder.poolRewardDetailType.showValue(type) + } + + viewModel.poolDisplayFlow.observe(binder.poolRewardDetailPool::showPool) + + viewModel.chainUi.observe(binder.poolRewardDetailNetwork::showChain) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/pool/PoolRewardDetailViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/pool/PoolRewardDetailViewModel.kt new file mode 100644 index 0000000..03779f5 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/reward/pool/PoolRewardDetailViewModel.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.reward.pool + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class PoolRewardDetailViewModel( + val operation: OperationParcelizeModel.PoolReward, + private val poolDisplayUseCase: PoolDisplayUseCase, + private val router: AssetsRouter, + private val chainRegistry: ChainRegistry, + private val externalActions: ExternalActions.Presentation +) : BaseViewModel(), + ExternalActions by externalActions { + + val chain by lazyAsync { + chainRegistry.getChain(operation.chainId) + } + + val poolDisplayFlow = flowOf { + poolDisplayUseCase.getPoolDisplay(operation.poolId, chain()) + } + .shareInBackground() + + val chainUi = flowOf { + mapChainToUi(chain()) + } + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun eventIdClicked() { + shoExternalActions(ExternalActions.Type.Event(operation.eventId)) + } + + fun poolClicked() = launch { + val poolStash = poolDisplayFlow.first().poolAccountId + + externalActions.showAddressActions(poolStash, chain()) + } + + private fun shoExternalActions(type: ExternalActions.Type) = launch { + externalActions.showExternalActions(type, chain()) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/SwapDetailFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/SwapDetailFragment.kt new file mode 100644 index 0000000..4d79a8a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/SwapDetailFragment.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.swap + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.formatting.formatDateTime +import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_assets.databinding.FragmentSwapDetailsBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.model.showOperationStatus +import io.novafoundation.nova.feature_wallet_api.presentation.view.showLoadingAmount + +private const val KEY_PAYLOAD = "SwapDetailFragment.Payload" + +class SwapDetailFragment : BaseFragment() { + + companion object { + fun getBundle(operation: OperationParcelizeModel.Swap) = Bundle().apply { + putParcelable(KEY_PAYLOAD, operation) + } + } + + override fun createBinding() = FragmentSwapDetailsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.swapDetailToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.swapDetailHash.setOnClickListener { + viewModel.transactionHashClicked() + } + + binder.swapDetailAccount.setOnClickListener { + viewModel.originAddressClicked() + } + + binder.swapDetailRate.setOnClickListener { + viewModel.rateClicked() + } + + binder.swapDetailFee.setOnClickListener { + viewModel.feeClicked() + } + + binder.swapDetailsRepeatOperation.setOnClickListener { + viewModel.repeatOperationClicked() + } + } + + override fun inject() { + val operation = argument(KEY_PAYLOAD) + + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .swapDetailComponentFactory() + .create(this, operation) + .inject(this) + } + + override fun subscribe(viewModel: SwapDetailViewModel) { + setupExternalActions(viewModel) + observeDescription(viewModel) + + with(viewModel.operation) { + binder.swapDetailStatus.showOperationStatus(statusAppearance) + binder.swapDetailToolbar.setTitle(timeMillis.formatDateTime()) + + binder.swapDetailAmount.setTokenAmountTextColor(statusAppearance.amountTint) + + binder.swapDetailHash.showValueOrHide(transactionHash) + } + + viewModel.amountModel.observe(binder.swapDetailAmount::setAmount) + + viewModel.assetInModel.observe(binder.swapDetailAssets::setAssetIn) + viewModel.assetOutModel.observe(binder.swapDetailAssets::setAssetOut) + + viewModel.rate.observe(binder.swapDetailRate::showValue) + viewModel.feeModel.observe(binder.swapDetailFee::showLoadingAmount) + + viewModel.walletUi.observe(binder.swapDetailWallet::showWallet) + viewModel.originAddressModelFlow.observe(binder.swapDetailAccount::showAddress) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/SwapDetailViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/SwapDetailViewModel.kt new file mode 100644 index 0000000..25360e7 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/SwapDetailViewModel.kt @@ -0,0 +1,186 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.swap + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.description.launchNetworkFeeDescription +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.ChainAssetWithAmountParcelModel +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_swap_api.domain.model.rateAgainst +import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapDirectionParcel +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetView +import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSwapRateDescription +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.TokenBase +import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountSign +import io.novafoundation.nova.feature_wallet_api.presentation.model.fullChainAssetId +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +class SwapDetailViewModel( + private val router: AssetsRouter, + private val addressIconGenerator: AddressIconGenerator, + private val chainRegistry: ChainRegistry, + private val externalActions: ExternalActions.Presentation, + private val arbitraryTokenUseCase: ArbitraryTokenUseCase, + private val walletUiUseCase: WalletUiUseCase, + private val swapRateFormatter: SwapRateFormatter, + private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + private val assetIconProvider: AssetIconProvider, + val operation: OperationParcelizeModel.Swap, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher { + + private val tokenIn = historicalTokenFlow(operation.amountIn) + private val tokenOut = historicalTokenFlow(operation.amountOut) + private val tokenFee = historicalTokenFlow(operation.amountFee) + + private val originChain by lazyAsync { + chainRegistry.getChain(operation.amountIn.assetId.chainId) + } + + val originAddressModelFlow = flowOf { + getIcon(operation.originAddress) + } + .shareInBackground() + + val walletUi = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val feeModel = tokenFee.map { + amountFormatter.formatAmountToAmountModel(operation.amountFee.amount, it) + } + .withSafeLoading() + .shareInBackground() + + val assetInModel = tokenIn.map { tokenIn -> + createAssetSwapModel(tokenIn, operation.amountIn.amount, income = false) + }.shareInBackground() + + val assetOutModel = tokenOut.map { tokenOut -> + createAssetSwapModel(tokenOut, operation.amountOut.amount, income = true) + }.shareInBackground() + + val amountModel = amountModelFlow() + .shareInBackground() + + val rate = combine(tokenIn, tokenOut) { tokenIn, tokenOut -> + val assetIn = tokenIn.configuration.withAmount(operation.amountIn.amount) + val assetOut = tokenOut.configuration.withAmount(operation.amountOut.amount) + + val rate = assetIn rateAgainst assetOut + + swapRateFormatter.format(rate, assetIn.chainAsset, assetOut.chainAsset) + }.shareInBackground() + + fun backClicked() { + router.back() + } + + private suspend fun getIcon(address: String): AddressModel { + return addressIconGenerator.createAccountAddressModel(originChain(), address) + } + + fun transactionHashClicked() = operation.transactionHash?.let { + showExternalActions(ExternalActions.Type.Extrinsic(it)) + } + + fun originAddressClicked() = launch { + externalActions.showAddressActions(operation.originAddress, originChain()) + } + + fun rateClicked() { + descriptionBottomSheetLauncher.launchSwapRateDescription() + } + + fun feeClicked() { + descriptionBottomSheetLauncher.launchNetworkFeeDescription() + } + + fun repeatOperationClicked() { + val amount = if (operation.amountIsAssetIn) operation.amountIn.amount else operation.amountOut.amount + val direction = if (operation.amountIsAssetIn) SwapDirectionParcel.SPECIFIED_IN else SwapDirectionParcel.SPECIFIED_OUT + val payload = SwapSettingsPayload.RepeatOperation( + assetIn = operation.amountIn.assetId, + assetOut = operation.amountOut.assetId, + amount = amount, + direction = direction + ) + router.openSwapSetupAmount(payload) + } + + private fun showExternalActions(type: ExternalActions.Type) = launch { + externalActions.showExternalActions(type, originChain()) + } + + private fun historicalTokenFlow(parcel: ChainAssetWithAmountParcelModel): Flow { + return flowOf { + val chainAsset = chainRegistry.asset(parcel.assetId.fullChainAssetId) + + arbitraryTokenUseCase.historicalToken(chainAsset, operation.timeMillis.milliseconds) + } + .shareInBackground() + } + + private suspend fun createAssetSwapModel( + token: TokenBase, + amount: Balance, + income: Boolean + ): SwapAssetView.Model { + return SwapAssetView.Model( + assetIcon = assetIconProvider.getAssetIconOrFallback(token.configuration), + amount = amountFormatter.formatAmountToAmountModel(amount, token, AmountConfig(estimatedFiat = true)), + chainUi = mapChainToUi(chainRegistry.getChain(token.configuration.chainId)), + amountTextColorRes = if (income) R.color.text_positive else R.color.text_primary + ) + } + + private fun amountModelFlow(): Flow { + return if (operation.amountIsAssetIn) { + tokenIn.map { + amountFormatter.formatAmountToAmountModel( + operation.amountIn.amount, + it, + AmountConfig(estimatedFiat = true, tokenAmountSign = AmountSign.NEGATIVE) + ) + } + } else { + tokenOut.map { + amountFormatter.formatAmountToAmountModel( + operation.amountOut.amount, + it, + AmountConfig(estimatedFiat = true, tokenAmountSign = AmountSign.POSITIVE) + ) + } + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/di/SwapDetailComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/di/SwapDetailComponent.kt new file mode 100644 index 0000000..9409d64 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/di/SwapDetailComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.swap.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.swap.SwapDetailFragment + +@Subcomponent( + modules = [ + SwapDetailModule::class + ] +) +@ScreenScope +interface SwapDetailComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance swap: OperationParcelizeModel.Swap + ): SwapDetailComponent + } + + fun inject(fragment: SwapDetailFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/di/SwapDetailModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/di/SwapDetailModule.kt new file mode 100644 index 0000000..f0b66fa --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/swap/di/SwapDetailModule.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.swap.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.transaction.detail.swap.SwapDetailViewModel +import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class SwapDetailModule { + + @Provides + @IntoMap + @ViewModelKey(SwapDetailViewModel::class) + fun provideViewModel( + router: AssetsRouter, + addressIconGenerator: AddressIconGenerator, + chainRegistry: ChainRegistry, + operation: OperationParcelizeModel.Swap, + externalActions: ExternalActions.Presentation, + arbitraryTokenUseCase: ArbitraryTokenUseCase, + walletUiUseCase: WalletUiUseCase, + swapRateFormatter: SwapRateFormatter, + assetIconProvider: AssetIconProvider, + descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + amountFormatter: AmountFormatter + ): ViewModel { + return SwapDetailViewModel( + router = router, + addressIconGenerator = addressIconGenerator, + chainRegistry = chainRegistry, + operation = operation, + externalActions = externalActions, + arbitraryTokenUseCase = arbitraryTokenUseCase, + walletUiUseCase = walletUiUseCase, + swapRateFormatter = swapRateFormatter, + descriptionBottomSheetLauncher = descriptionBottomSheetLauncher, + assetIconProvider = assetIconProvider, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SwapDetailViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SwapDetailViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/transfer/TransactionDetailViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/transfer/TransactionDetailViewModel.kt new file mode 100644 index 0000000..c74c31f --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/transfer/TransactionDetailViewModel.kt @@ -0,0 +1,108 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.transfer + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.OptionalAddressModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createOptionalAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +class TransactionDetailViewModel( + private val router: AssetsRouter, + private val addressIconGenerator: AddressIconGenerator, + private val addressDisplayUseCase: AddressDisplayUseCase, + private val chainRegistry: ChainRegistry, + val operation: OperationParcelizeModel.Transfer, + private val externalActions: ExternalActions.Presentation, + private val arbitraryTokenUseCase: ArbitraryTokenUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions { + + private val chain by lazyAsync { + chainRegistry.getChain(operation.chainId) + } + + val recipientAddressModelFlow = flowOf { + getIcon(operation.receiver) + } + .inBackground() + .share() + + val senderAddressModelLiveData = flowOf { + getIcon(operation.sender) + } + .inBackground() + .share() + + val chainUi = flowOf { + mapChainToUi(chain()) + } + .inBackground() + .share() + + val fee = flowOf { + val fee = operation.fee ?: return@flowOf null + val commissionAsset = chain.await().commissionAsset + + val token = arbitraryTokenUseCase.historicalToken(commissionAsset, operation.time.milliseconds) + amountFormatter.formatAmountToAmountModel(fee, token) + } + .withSafeLoading() + .shareInBackground() + + fun backClicked() { + router.back() + } + + private suspend fun getIcon(address: String): OptionalAddressModel { + return addressIconGenerator.createOptionalAddressModel( + chain = chain(), + address = address, + sizeInDp = AddressIconGenerator.SIZE_BIG, + addressDisplayUseCase = addressDisplayUseCase, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + } + + fun repeatTransaction() { + val retryAddress = if (operation.isIncome) operation.sender else operation.receiver + val assetPayload = AssetPayload(operation.chainId, operation.assetId) + + router.openSend(SendPayload.SpecifiedOrigin(assetPayload), initialRecipientAddress = retryAddress) + } + + fun transactionHashClicked() = operation.hash?.let { + showExternalActions(ExternalActions.Type.Extrinsic(it)) + } + + fun fromAddressClicked() = launch { + externalActions.showAddressActions(operation.sender, chain()) + } + + fun toAddressClicked() = launch { + externalActions.showAddressActions(operation.receiver, chain()) + } + + private fun showExternalActions(type: ExternalActions.Type) = launch { + externalActions.showExternalActions(type, chain()) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/transfer/TransferDetailFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/transfer/TransferDetailFragment.kt new file mode 100644 index 0000000..5d08ff2 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/detail/transfer/TransferDetailFragment.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.detail.transfer + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.formatting.formatDateTime +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_account_api.view.showOptionalAddress +import io.novafoundation.nova.feature_assets.databinding.FragmentTransferDetailsBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.model.showOperationStatus +import io.novafoundation.nova.feature_assets.presentation.model.toAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.view.showLoadingAmount + +private const val KEY_TRANSACTION = "KEY_DRAFT" + +class TransferDetailFragment : BaseFragment() { + + companion object { + fun getBundle(operation: OperationParcelizeModel.Transfer) = Bundle().apply { + putParcelable(KEY_TRANSACTION, operation) + } + } + + override fun createBinding() = FragmentTransferDetailsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.transactionDetailToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.transactionDetailHash.setOnClickListener { + viewModel.transactionHashClicked() + } + + binder.transactionDetailFrom.setOnClickListener { + viewModel.fromAddressClicked() + } + + binder.transactionDetailTo.setOnClickListener { + viewModel.toAddressClicked() + } + + binder.transactionDetailRepeat.setOnClickListener { + viewModel.repeatTransaction() + } + } + + override fun inject() { + val operation = argument(KEY_TRANSACTION) + + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ) + .transactionDetailComponentFactory() + .create(this, operation) + .inject(this) + } + + override fun subscribe(viewModel: TransactionDetailViewModel) { + setupExternalActions(viewModel) + + with(viewModel.operation) { + binder.transactionDetailStatus.showOperationStatus(statusAppearance) + binder.transactionDetailTransferDirection.setImageResource(transferDirectionIcon) + + binder.transactionDetailToolbar.setTitle(time.formatDateTime()) + + viewModel.fee.observe(binder.transactionDetailFee::showLoadingAmount) + + binder.transactionDetailAmount.setAmount(amount.toAmountModel()) + binder.transactionDetailAmount.setTokenAmountTextColor(statusAppearance.amountTint) + + binder.transactionDetailHash.showValueOrHide(hash) + } + + viewModel.senderAddressModelLiveData.observe(binder.transactionDetailFrom::showOptionalAddress) + viewModel.recipientAddressModelFlow.observe(binder.transactionDetailTo::showOptionalAddress) + + viewModel.chainUi.observe(binder.transactionDetailNetwork::showChain) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/HistoryFiltersProvider.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/HistoryFiltersProvider.kt new file mode 100644 index 0000000..2a1c224 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/HistoryFiltersProvider.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.filter + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +private const val FILTERS__PROVIDER_TAG = "HistoryFiltersProvider" + +/** + * Factory ensures that [HistoryFiltersProvider] scope will be limited to the current flow of screens + */ +class HistoryFiltersProviderFactory( + private val computationalCache: ComputationalCache, + private val assetSourceRegistry: AssetSourceRegistry, + private val chainRegistry: ChainRegistry, +) { + + suspend fun get( + scope: CoroutineScope, + chainId: ChainId, + chainAssetId: ChainAssetId, + ): HistoryFiltersProvider { + val key = "$FILTERS__PROVIDER_TAG:$chainId:$chainAssetId" + + return computationalCache.useCache(key, scope) { + val (chain, chainAsset) = chainRegistry.chainWithAsset(chainId, chainAssetId) + val source = assetSourceRegistry.sourceFor(chainAsset) + val allAvailableFilters = source.history.availableOperationFilters(chain, chainAsset) + + HistoryFiltersProvider(allAvailableFilters) + } + } +} + +class HistoryFiltersProvider(val allAvailableFilters: Set) { + + val defaultFilters = allAvailableFilters + + private val customFiltersFlow = MutableStateFlow(allAvailableFilters) + + fun currentFilters() = customFiltersFlow.value + + fun filtersFlow(): Flow> = customFiltersFlow + + fun setCustomFilters(filters: Set) { + customFiltersFlow.value = filters + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/TransactionHistoryFilterFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/TransactionHistoryFilterFragment.kt new file mode 100644 index 0000000..f0a09f1 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/TransactionHistoryFilterFragment.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.filter + +import android.widget.CompoundButton +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.common.view.bindFromMap +import io.novafoundation.nova.feature_assets.databinding.FragmentTransactionsFilterBinding +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter + +class TransactionHistoryFilterFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD_KEY = "TransactionHistoryFilterFragment.Payload" + fun getBundle(payload: TransactionHistoryFilterPayload) = bundleOf(PAYLOAD_KEY to payload) + } + + override fun createBinding() = FragmentTransactionsFilterBinding.inflate(layoutInflater) + + override fun initViews() { + binder.transactionsFilterToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.transactionsFilterToolbar.setRightActionClickListener { + viewModel.resetFilter() + } + + binder.transactionsFilterRewards.bindFilter(TransactionFilter.REWARD) + binder.transactionsFilterSwitchTransfers.bindFilter(TransactionFilter.TRANSFER) + binder.transactionsFilterOtherTransactions.bindFilter(TransactionFilter.EXTRINSIC) + binder.transactionsFilterSwaps.bindFilter(TransactionFilter.SWAP) + + binder.transactionFilterApplyBtn.setOnClickListener { viewModel.applyClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + AssetsFeatureApi::class.java + ).transactionHistoryComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: TransactionHistoryFilterViewModel) { + viewModel.isApplyButtonEnabled.observe { + binder.transactionFilterApplyBtn.setState(if (it) ButtonState.NORMAL else ButtonState.DISABLED) + } + } + + private fun CompoundButton.bindFilter(filter: TransactionFilter) { + lifecycleScope.launchWhenResumed { + bindFromMap(filter, viewModel.filtersEnabledMap(), viewLifecycleOwner.lifecycleScope) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/TransactionHistoryFilterPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/TransactionHistoryFilterPayload.kt new file mode 100644 index 0000000..a9fca99 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/TransactionHistoryFilterPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.filter + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import kotlinx.parcelize.Parcelize + +@Parcelize +class TransactionHistoryFilterPayload( + val assetPayload: AssetPayload, +) : Parcelable diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/TransactionHistoryFilterViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/TransactionHistoryFilterViewModel.kt new file mode 100644 index 0000000..000a3eb --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/TransactionHistoryFilterViewModel.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.filter + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.checkEnabled +import io.novafoundation.nova.common.utils.filterToSet +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch + +class TransactionHistoryFilterViewModel( + private val router: AssetsRouter, + private val historyFiltersProviderFactory: HistoryFiltersProviderFactory, + private val payload: TransactionHistoryFilterPayload, +) : BaseViewModel() { + + private val historyFiltersProvider by lazyAsync { + historyFiltersProviderFactory.get( + scope = viewModelScope, + chainId = payload.assetPayload.chainId, + chainAssetId = payload.assetPayload.chainAssetId + ) + } + + private val initialFiltersFlow = flow { emit(historyFiltersProvider().currentFilters()) } + .share() + + val filtersEnabledMap by lazyAsync { + createFilterEnabledMap() + } + + private val modifiedFilters = flow { + val inner = combine(filtersEnabledMap().values) { + historyFiltersProvider().allAvailableFilters.filterToSet { + filtersEnabledMap().checkEnabled(it) + } + } + + emitAll(inner) + } + .inBackground() + .share() + + val isApplyButtonEnabled = combine(initialFiltersFlow, modifiedFilters) { initial, modified -> + initial != modified && modified.isNotEmpty() + }.share() + + init { + viewModelScope.launch { + initFromState(initialFiltersFlow.first()) + } + } + + private fun initFromState(currentState: Set) = launch { + filtersEnabledMap().forEach { (filter, checked) -> + checked.value = filter in currentState + } + } + + fun resetFilter() { + viewModelScope.launch { + val defaultFilters = historyFiltersProvider().defaultFilters + + initFromState(defaultFilters) + } + } + + fun backClicked() { + router.back() + } + + private suspend fun createFilterEnabledMap(): Map> { + return historyFiltersProvider() + .allAvailableFilters + .associateWith { MutableStateFlow(true) } + } + + fun applyClicked() { + viewModelScope.launch { + historyFiltersProvider().setCustomFilters(modifiedFilters.first()) + + router.back() + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/di/TransactionHistoryFilterComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/di/TransactionHistoryFilterComponent.kt new file mode 100644 index 0000000..92381dc --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/di/TransactionHistoryFilterComponent.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.filter.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterFragment +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterPayload + +@Subcomponent( + modules = [ + TransactionHistoryFilterModule::class + ] +) +@ScreenScope +interface TransactionHistoryFilterComponent { + + @Subcomponent.Factory + interface Factory { + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: TransactionHistoryFilterPayload, + ): TransactionHistoryFilterComponent + } + + fun inject(fragment: TransactionHistoryFilterFragment) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/di/TransactionHistoryFilterModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/di/TransactionHistoryFilterModule.kt new file mode 100644 index 0000000..64f05aa --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/filter/di/TransactionHistoryFilterModule.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.filter.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.HistoryFiltersProviderFactory +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterPayload +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterViewModel + +@Module(includes = [ViewModelModule::class]) +class TransactionHistoryFilterModule { + + @Provides + @IntoMap + @ViewModelKey(TransactionHistoryFilterViewModel::class) + fun provideViewModel( + router: AssetsRouter, + historyFiltersProviderFactory: HistoryFiltersProviderFactory, + payload: TransactionHistoryFilterPayload, + ): ViewModel { + return TransactionHistoryFilterViewModel(router, historyFiltersProviderFactory, payload) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): TransactionHistoryFilterViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(TransactionHistoryFilterViewModel::class.java) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/Ext.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/Ext.kt new file mode 100644 index 0000000..7f261ca --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/Ext.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history + +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryUi.State + +fun TransferHistorySheet.showState(state: State) { + when (state.listState) { + is State.ListState.Empty -> showPlaceholder() + is State.ListState.EmptyProgress -> showProgress() + is State.ListState.Data -> showTransactions(state.listState.items) + } + + setFiltersVisible(state.filtersButtonVisible) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/TransactionHistoryAdapter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/TransactionHistoryAdapter.kt new file mode 100644 index 0000000..88fa944 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/TransactionHistoryAdapter.kt @@ -0,0 +1,165 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history + +import android.view.ViewGroup +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.formatting.formatDaysSinceEpoch +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.recyclerview.item.OperationListItem +import io.novafoundation.nova.feature_assets.databinding.ItemDayHeaderBinding +import io.novafoundation.nova.feature_assets.presentation.model.OperationModel +import io.novafoundation.nova.feature_assets.presentation.model.OperationStatusAppearance +import io.novafoundation.nova.feature_assets.presentation.transaction.history.model.DayHeader + +class TransactionHistoryAdapter( + private val handler: Handler, + private val imageLoader: ImageLoader, +) : GroupedListAdapter(TransactionHistoryDiffCallback) { + + interface Handler { + + fun transactionClicked(transactionId: String) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return DayHolder(ItemDayHeaderBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return TransactionHolder(OperationListItem(parent.context), imageLoader) + } + + override fun bindGroup(holder: GroupedListHolder, group: DayHeader) { + (holder as DayHolder).bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: OperationModel) { + (holder as TransactionHolder).bind(child, handler) + } + + override fun bindChild(holder: GroupedListHolder, position: Int, child: OperationModel, payloads: List) { + require(holder is TransactionHolder) + + resolvePayload(holder, position, payloads) { + when (it) { + OperationModel::statusAppearance -> holder.bindStatus(child) + OperationModel::header -> holder.bindHeader(child) + OperationModel::subHeader -> holder.bindSubHeader(child) + OperationModel::amount -> holder.bindAmount(child) + OperationModel::amountDetails -> holder.bindAmountDetails(child) + } + } + } +} + +class TransactionHolder( + override val containerView: OperationListItem, + private val imageLoader: ImageLoader +) : GroupedListHolder(containerView) { + + init { + containerView.setIconStyle(OperationListItem.IconStyle.BORDERED_CIRCLE) + } + + fun bind(item: OperationModel, handler: TransactionHistoryAdapter.Handler) { + with(containerView) { + bindHeader(item) + + bindAmount(item) + + bindAmountDetails(item) + bindSubHeader(item) + + icon.setIcon(item.operationIcon, imageLoader) + + bindStatus(item) + + setOnClickListener { handler.transactionClicked(item.id) } + } + } + + fun bindAmount(item: OperationModel) = with(containerView) { + valuePrimary.setTextColorRes(item.amountColorRes) + valuePrimary.text = item.amount + } + + fun bindHeader(item: OperationModel) { + containerView.header.text = item.header + } + + fun bindAmountDetails(item: OperationModel) { + containerView.valueSecondary.setTextOrHide(item.amountDetails) + } + + fun bindSubHeader(item: OperationModel) = with(containerView) { + subHeader.text = item.subHeader + subHeader.ellipsize = item.subHeaderEllipsize + } + + fun bindStatus(item: OperationModel) = with(containerView) { + if (item.statusAppearance != OperationStatusAppearance.COMPLETED) { + status.makeVisible() + status.setImageResource(item.statusAppearance.icon) + status.setImageTintRes(item.statusAppearance.statusIconTint) + } else { + status.makeGone() + } + } + + override fun unbind() { + containerView.icon.clear() + } +} + +class DayHolder(private val binder: ItemDayHeaderBinding) : GroupedListHolder(binder.root) { + fun bind(item: DayHeader) { + with(binder) { + itemDayHeader.text = item.daysSinceEpoch.formatDaysSinceEpoch(binder.root.context) + } + } +} + +private object TransactionPayloadGenerator : PayloadGenerator( + OperationModel::statusAppearance, + OperationModel::header, + OperationModel::subHeader, + OperationModel::amount, + OperationModel::amountDetails, +) + +object TransactionHistoryDiffCallback : BaseGroupedDiffCallback(DayHeader::class.java) { + override fun areGroupItemsTheSame(oldItem: DayHeader, newItem: DayHeader): Boolean { + return oldItem.daysSinceEpoch == newItem.daysSinceEpoch + } + + override fun areGroupContentsTheSame(oldItem: DayHeader, newItem: DayHeader): Boolean { + return true + } + + override fun areChildItemsTheSame(oldItem: OperationModel, newItem: OperationModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areChildContentsTheSame(oldItem: OperationModel, newItem: OperationModel): Boolean { + return oldItem.statusAppearance == newItem.statusAppearance && + oldItem.header == newItem.header && + oldItem.subHeader == newItem.subHeader && + oldItem.amount == newItem.amount && + oldItem.amountDetails == newItem.amountDetails + } + + override fun getChildChangePayload(oldItem: OperationModel, newItem: OperationModel): Any? { + return TransactionPayloadGenerator.diff(oldItem, newItem) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/TransactionHistoryBannerModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/TransactionHistoryBannerModel.kt new file mode 100644 index 0000000..526d4fc --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/TransactionHistoryBannerModel.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history + +class TransactionHistoryBannerModel( + val text: String, + val clickListener: () -> Unit +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/TransactionHistoryView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/TransactionHistoryView.kt new file mode 100644 index 0000000..66fc138 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/TransactionHistoryView.kt @@ -0,0 +1,293 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history + +import android.animation.ArgbEvaluator +import android.content.Context +import android.content.res.ColorStateList +import android.os.Bundle +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import android.view.ViewTreeObserver +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import com.google.android.material.bottomsheet.BottomSheetBehavior +import android.graphics.Rect +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.DrawableExtension +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.enableShowingNewlyAddedTopElements +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.updateTopMargin +import io.novafoundation.nova.common.view.bottomSheet.LockBottomSheetBehavior +import io.novafoundation.nova.common.view.shape.getTopRoundedCornerDrawable +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ViewTransferHistoryBinding +import kotlin.math.max + +typealias ScrollingListener = (position: Int) -> Unit +typealias SlidingStateListener = (Int) -> Unit +typealias TransactionClickListener = (transactionId: String) -> Unit + +private const val MIN_MARGIN = 20 // dp +private const val MAX_MARGIN = 32 // dp + +private const val PULLER_VISIBILITY_OFFSET = 0.9 + +private const val OFFSET_KEY = "OFFSET" +private const val SUPER_STATE = "SUPER_STATE" + +private const val MIN_HEIGHT_DP = 126 + +class TransferHistorySheet @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), TransactionHistoryAdapter.Handler { + + private var bottomSheetBehavior: LockBottomSheetBehavior? = null + + private var anchor: View? = null + + private val argbEvaluator = ArgbEvaluator() + + private var scrollingListener: ScrollingListener? = null + private var slidingStateListener: SlidingStateListener? = null + private var transactionClickListener: TransactionClickListener? = null + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + if (isInEditMode) { + ImageLoader.invoke(context) + } else { + FeatureUtils.getCommonApi(context).imageLoader() + } + } + + private val adapter: TransactionHistoryAdapter by lazy(LazyThreadSafetyMode.NONE) { + TransactionHistoryAdapter(this, imageLoader) + } + + private var lastOffset: Float = 0.0F + + private var adapterDataObserver: RecyclerView.AdapterDataObserver? = null + + private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { + anchor?.let { + bottomSheetBehavior?.peekHeight = max(parentView.measuredHeight - it.bottom, MIN_HEIGHT_DP.dp) + } + } + + private val collapsedBackgroundColor: Int = context.getColor(R.color.android_history_background) + private val expandedBackgroundColor: Int = context.getColor(R.color.secondary_screen_background) + + private val binder = ViewTransferHistoryBinding.inflate(inflater(), this) + + init { + val contentBackgroundDrawable = context.getTopRoundedCornerDrawable( + fillColorRes = R.color.android_history_background, + strokeColorRes = R.color.container_border, + cornerSizeInDp = 16 + ) + + // Extend background drawable from left and right to make stroke in background on sides hidden + val borderDrawable = DrawableExtension( + contentDrawable = contentBackgroundDrawable, + extensionOffset = Rect(1.dp(context), 0, 1.dp(context), 0), + ) + + background = borderDrawable + + binder.transactionHistoryList.adapter = adapter + binder.transactionHistoryList.setHasFixedSize(true) + + addScrollListener() + + updateSlidingEffects() + } + + fun setBannerClickListener(onClickListener: OnClickListener?) { + binder.transferHistoryBannerView.setOnClickListener(onClickListener) + } + + fun setBannerTextOrHide(text: String?) { + binder.transactionHistoryMigrationBanner.letOrHide(text) { + binder.transferHistoryBannerText.text = text + } + } + + fun setFiltersVisible(visible: Boolean) { + binder.transactionHistoryFilter.setVisible(visible) + } + + fun showProgress() { + binder.placeholder.makeGone() + binder.transactionHistoryProgress.makeVisible() + binder.transactionHistoryList.makeGone() + + adapter.submitList(emptyList()) + + bottomSheetBehavior?.isDraggable = false + } + + fun showPlaceholder() { + binder.placeholder.makeVisible() + binder.transactionHistoryProgress.makeGone() + binder.transactionHistoryList.makeGone() + + adapter.submitList(emptyList()) + + bottomSheetBehavior?.isDraggable = true + } + + fun showTransactions(transactions: List) { + binder.placeholder.makeGone() + binder.transactionHistoryProgress.makeGone() + binder.transactionHistoryList.makeVisible() + + bottomSheetBehavior?.isDraggable = true + + adapter.submitList(transactions) + } + + fun setScrollingListener(listener: ScrollingListener) { + scrollingListener = listener + } + + fun setSlidingStateListener(listener: SlidingStateListener) { + slidingStateListener = listener + } + + fun setTransactionClickListener(listener: TransactionClickListener) { + transactionClickListener = listener + } + + override fun onSaveInstanceState(): Parcelable? { + val superState = super.onSaveInstanceState() + + return Bundle().apply { + putParcelable(SUPER_STATE, superState) + putFloat(OFFSET_KEY, lastOffset) + } + } + + override fun onRestoreInstanceState(state: Parcelable) { + if (state is Bundle) { + super.onRestoreInstanceState(state[SUPER_STATE] as Parcelable) + + lastOffset = state.getFloat(OFFSET_KEY) + updateSlidingEffects() + } + + bottomSheetBehavior?.state?.let { + slidingStateListener?.invoke(it) + } + } + + fun setFilterClickListener(clickListener: OnClickListener) { + binder.transactionHistoryFilter.setOnClickListener(clickListener) + } + + fun initializeBehavior(anchorView: View) { + anchor = anchorView + + bottomSheetBehavior = LockBottomSheetBehavior.fromView(this) + + bottomSheetBehavior!!.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) { + lastOffset = slideOffset + + updateSlidingEffects() + } + + override fun onStateChanged(bottomSheet: View, newState: Int) { + slidingStateListener?.invoke(newState) + } + }) + + addLayoutListener() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + adapterDataObserver = binder.transactionHistoryList.enableShowingNewlyAddedTopElements() + } + + override fun onDetachedFromWindow() { + removeLayoutListener() + + adapter.unregisterAdapterDataObserver(adapterDataObserver!!) + + super.onDetachedFromWindow() + } + + override fun transactionClicked(transactionId: String) { + transactionClickListener?.invoke(transactionId) + } + + private fun addScrollListener() { + val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + + val lastVisiblePosition = (recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + + scrollingListener?.invoke(lastVisiblePosition) + } + } + + binder.transactionHistoryList.addOnScrollListener(scrollListener) + } + + private fun removeLayoutListener() { + parentView.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener) + } + + private fun addLayoutListener() { + parentView.viewTreeObserver.addOnGlobalLayoutListener(layoutListener) + } + + private fun updateSlidingEffects() { + updateBackgroundAlpha() + + updateTitleMargin() + + updatePullerVisibility() + + requestLayout() + } + + private fun updateTitleMargin() { + val newMargin = linearUpdate(MIN_MARGIN, MAX_MARGIN, lastOffset) + val newMarginPx = newMargin.dp(context) + + binder.transactionHistoryTitle.updateTopMargin(newMarginPx) + } + + private fun updatePullerVisibility() { + binder.transactionHistoryPuller.setVisible(lastOffset < PULLER_VISIBILITY_OFFSET, falseState = View.INVISIBLE) + } + + private fun updateBackgroundAlpha() { + val backgroundColor = argbEvaluator.evaluate(lastOffset, collapsedBackgroundColor, expandedBackgroundColor) as Int + + backgroundTintList = ColorStateList.valueOf(backgroundColor) + } + + private val parentView: View + get() = parent as View + + private fun linearUpdate(min: Int, max: Int, progress: Float): Int { + return (min + (max - min) * progress).toInt() + } +} + +fun TransferHistorySheet.setBannerModelOrHide(banner: TransactionHistoryBannerModel?) { + setBannerTextOrHide(banner?.text) + setBannerClickListener { banner?.clickListener?.invoke() } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/OperationMappers.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/OperationMappers.kt new file mode 100644 index 0000000..0ab9bc9 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/OperationMappers.kt @@ -0,0 +1,408 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin + +import android.os.Build +import android.text.TextUtils +import android.text.style.ImageSpan +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.buildSpannable +import io.novafoundation.nova.common.utils.images.asIcon +import io.novafoundation.nova.common.utils.splitAndCapitalizeWords +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.model.AmountParcelModel +import io.novafoundation.nova.feature_assets.presentation.model.ChainAssetWithAmountParcelModel +import io.novafoundation.nova.feature_assets.presentation.model.ExtrinsicContentParcel +import io.novafoundation.nova.feature_assets.presentation.model.OperationModel +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.model.OperationStatusAppearance +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type.Extrinsic.Content +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type.Reward.RewardKind +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +private class EllipsizedString(val value: String, val elipsize: TextUtils.TruncateAt) + +private fun Chain.Asset.formatPlanksSigned(planks: BigInteger, negative: Boolean): String { + val amount = amountFromPlanks(planks) + + val withoutSign = amount.formatTokenAmount(this) + val sign = if (negative) '-' else '+' + + return sign + withoutSign +} + +private fun Operation.Type.Transfer.isIncome(chain: Chain): Boolean = kotlin.runCatching { + val myAccountId = chain.accountIdOf(myAddress) + val receiverAccountId = chain.accountIdOf(receiver) + + myAccountId.contentEquals(receiverAccountId) +}.getOrElse { + // conversion of an address to account id failed. Try less robust direct comparison + myAddress.lowercase() == receiver.lowercase() +} + +private fun Operation.Type.Transfer.displayAddress(isIncome: Boolean) = if (isIncome) sender else receiver + +private fun formatAmount(chainAsset: Chain.Asset, isIncome: Boolean, transfer: Operation.Type.Transfer): String { + return chainAsset.formatPlanksSigned(transfer.amount, negative = !isIncome) +} + +private fun formatAmount(chainAsset: Chain.Asset, reward: Operation.Type.Reward): String { + return chainAsset.formatPlanksSigned(reward.amount, negative = !reward.isReward) +} + +private fun formatFee(chainAsset: Chain.Asset, extrinsic: Operation.Type.Extrinsic): String { + return chainAsset.formatPlanksSigned(extrinsic.fee, negative = true) +} + +private fun mapStatusToStatusAppearance(status: Operation.Status): OperationStatusAppearance { + return when (status) { + Operation.Status.COMPLETED -> OperationStatusAppearance.COMPLETED + Operation.Status.FAILED -> OperationStatusAppearance.FAILED + Operation.Status.PENDING -> OperationStatusAppearance.PENDING + } +} + +@DrawableRes +private fun transferDirectionIcon(isIncome: Boolean): Int { + return if (isIncome) R.drawable.ic_receive_history else R.drawable.ic_send_history +} + +@ColorRes +private fun incomeTextColor(isIncome: Boolean, operationStatus: Operation.Status): Int { + return when { + operationStatus == Operation.Status.FAILED -> R.color.text_secondary + isIncome -> R.color.text_positive + else -> R.color.text_primary + } +} + +private fun mapExtrinsicContentToHeaderAndSubHeader(extrinsicContent: Content, resourceManager: ResourceManager): Pair { + return when (extrinsicContent) { + is Content.ContractCall -> mapContractCallToHeaderAndSubHeader(extrinsicContent, resourceManager) + + is Content.SubstrateCall -> { + val header = extrinsicContent.call.splitAndCapitalizeWords() + val subHeader = extrinsicContent.module.splitAndCapitalizeWords() + + header to EllipsizedString(subHeader, TextUtils.TruncateAt.END) + } + } +} + +private fun mapContractCallToHeaderAndSubHeader(content: Content.ContractCall, resourceManager: ResourceManager): Pair { + val header = resourceManager.getString(R.string.ethereum_contract_call) + val functionName = formatContractFunctionName(content) + val subHeaderEllipsized = if (functionName?.contains("transfer") == true) { + EllipsizedString(functionName, TextUtils.TruncateAt.END) + } else { + val contractAddress = resourceManager.getString(R.string.transfer_history_send_to, content.contractAddress) + EllipsizedString(contractAddress, TextUtils.TruncateAt.END) + } + + return header to subHeaderEllipsized +} + +private fun formatContractFunctionName(extrinsicContent: Content.ContractCall): String? { + return extrinsicContent.function?.let { function -> + val withoutArguments = function.split("(").first() + + withoutArguments.splitAndCapitalizeWords() + } +} + +private fun mapExtrinsicContentToParcel( + extrinsic: Operation.Type.Extrinsic, + extrinsicHash: String?, + resourceManager: ResourceManager +): ExtrinsicContentParcel { + return when (val content = extrinsic.content) { + is Content.ContractCall -> contractCallUi(content, extrinsicHash, resourceManager) + is Content.SubstrateCall -> substrateCallUi(content, extrinsicHash, resourceManager) + } +} + +private fun contractCallUi( + content: Content.ContractCall, + txHash: String?, + resourceManager: ResourceManager +) = ExtrinsicContentParcel { + block { + address(resourceManager.getString(R.string.ethereum_contract), content.contractAddress) + + formatContractFunctionName(content)?.let { function -> + value(resourceManager.getString(R.string.ethereum_function), function) + } + } + + txHash?.let { + block { + transactionId(txHash) + } + } +} + +private fun substrateCallUi( + content: Content.SubstrateCall, + txHash: String?, + resourceManager: ResourceManager +) = ExtrinsicContentParcel { + block { + txHash?.let { + transactionId(txHash) + } + + value(resourceManager.getString(R.string.common_module), content.module.splitAndCapitalizeWords()) + + value(resourceManager.getString(R.string.common_call), content.call.splitAndCapitalizeWords()) + } +} + +private fun Operation.Type.Swap.isIncome(chainAsset: Chain.Asset): Boolean { + return chainAsset.fullId == amountOut.chainAsset.fullId +} + +private fun Operation.Type.Swap.formatSubHeader(resourceManager: ResourceManager): CharSequence { + val iconColor = resourceManager.getColor(R.color.chip_icon) + val chevronSize = resourceManager.measureInPx(12) + val arrowRight = resourceManager.getDrawable(R.drawable.ic_arrow_right).apply { + setBounds(0, 0, chevronSize, chevronSize) + setTint(iconColor) + } + + val imageAlignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ImageSpan.ALIGN_CENTER + } else { + ImageSpan.ALIGN_BASELINE + } + + return buildSpannable(resourceManager) { + append(amountIn.chainAsset.symbol.value) + append(" ") + appendSpan(ImageSpan(arrowRight, imageAlignment)) + append(" ") + append(amountOut.chainAsset.symbol.value) + } +} + +fun mapOperationToOperationModel( + chain: Chain, + token: Token, + operation: Operation, + nameIdentifier: AddressDisplayUseCase.Identifier, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider +): OperationModel { + val statusAppearance = mapStatusToStatusAppearance(operation.status) + val formattedTime = resourceManager.formatTime(operation.time) + + return with(operation) { + when (val operationType = type) { + is Operation.Type.Reward -> { + val headerResId = if (operationType.isReward) R.string.staking_reward else R.string.staking_slash + val subtitleRes = if (operationType.kind is RewardKind.Direct) R.string.tabbar_staking_title else R.string.setup_staking_type_pool_staking + OperationModel( + id = id, + amount = formatAmount(chainAsset, operationType), + amountDetails = mapToFiatWithTime(token, operationType.fiatAmount, formattedTime, resourceManager), + amountColorRes = if (operationType.isReward) R.color.text_positive else R.color.text_primary, + header = resourceManager.getString(headerResId), + subHeader = resourceManager.getString(subtitleRes), + subHeaderEllipsize = TextUtils.TruncateAt.END, + statusAppearance = statusAppearance, + operationIcon = resourceManager.getDrawable(R.drawable.ic_staking_history).asIcon(), + ) + } + + is Operation.Type.Transfer -> { + val isIncome = operationType.isIncome(chain) + + val amountColor = incomeTextColor(isIncome, operation.status) + + val nameOrAddress = nameIdentifier.nameOrAddress(operationType.displayAddress(isIncome)) + + val subHeader = if (isIncome) { + resourceManager.getString(R.string.transfer_history_income_from, nameOrAddress) + } else { + resourceManager.getString(R.string.transfer_history_send_to, nameOrAddress) + } + + OperationModel( + id = id, + amount = formatAmount(chainAsset, isIncome, operationType), + amountDetails = mapToFiatWithTime(token, operationType.fiatAmount, formattedTime, resourceManager), + amountColorRes = amountColor, + header = resourceManager.getString(R.string.transfer_title), + subHeader = subHeader, + subHeaderEllipsize = TextUtils.TruncateAt.MIDDLE, + statusAppearance = statusAppearance, + operationIcon = resourceManager.getDrawable(transferDirectionIcon(isIncome)).asIcon(), + ) + } + + is Operation.Type.Extrinsic -> { + val amountColor = if (operation.status == Operation.Status.FAILED) R.color.text_secondary else R.color.text_primary + val (header, subHeader) = mapExtrinsicContentToHeaderAndSubHeader(operationType.content, resourceManager) + + OperationModel( + id = id, + amount = formatFee(chainAsset, operationType), + amountDetails = mapToFiatWithTime(token, operationType.fiatFee, formattedTime, resourceManager), + amountColorRes = amountColor, + header = header, + subHeader = subHeader.value, + subHeaderEllipsize = subHeader.elipsize, + statusAppearance = statusAppearance, + operationIcon = assetIconProvider.getAssetIconOrFallback(operation.chainAsset, AssetIconMode.WHITE) + ) + } + + is Operation.Type.Swap -> { + val isIncome = operationType.isIncome(chainAsset) + val amount = if (isIncome) operationType.amountOut.amount else operationType.amountIn.amount + + OperationModel( + id = id, + amount = chainAsset.formatPlanksSigned(amount, negative = !isIncome), + amountColorRes = incomeTextColor(isIncome, operation.status), + amountDetails = mapToFiatWithTime(token, operationType.fiatAmount, formattedTime, resourceManager), + header = resourceManager.getString(R.string.operations_swap_title), + statusAppearance = statusAppearance, + subHeader = operationType.formatSubHeader(resourceManager), + subHeaderEllipsize = TextUtils.TruncateAt.END, + operationIcon = R.drawable.ic_swap_history.asIcon() + ) + } + } + } +} + +fun mapToFiatWithTime( + token: Token, + amount: BigDecimal?, + time: String, + resourceManager: ResourceManager, +): String { + val fiatAmount = amount?.formatAsCurrency(token.currency) + return if (fiatAmount == null) { + time + } else { + resourceManager.getString(R.string.transaction_history_fiat_with_time, fiatAmount, time) + } +} + +suspend fun mapOperationToParcel( + operation: Operation, + chainRegistry: ChainRegistry, + resourceManager: ResourceManager, + currency: Currency, +): OperationParcelizeModel { + with(operation) { + return when (val operationType = operation.type) { + is Operation.Type.Transfer -> { + val chain = chainRegistry.getChain(chainAsset.chainId) + + val isIncome = operationType.isIncome(chain) + + OperationParcelizeModel.Transfer( + chainId = operation.chainAsset.chainId, + assetId = operation.chainAsset.id, + time = time, + address = address, + hash = operation.extrinsicHash, + amount = AmountParcelModel( + token = formatAmount(operation.chainAsset, isIncome, operationType), + fiat = operationType.fiatAmount?.formatAsCurrency(currency) + ), + receiver = operationType.receiver, + sender = operationType.sender, + fee = operationType.fee, + isIncome = isIncome, + statusAppearance = mapStatusToStatusAppearance(operation.status), + transferDirectionIcon = transferDirectionIcon(isIncome) + ) + } + + is Operation.Type.Reward -> { + val typeRes = if (operationType.isReward) R.string.staking_reward else R.string.staking_slash + + val amount = AmountParcelModel( + token = formatAmount(chainAsset, operationType), + fiat = operationType.fiatAmount?.formatAsCurrency(currency) + ) + + when (val rewardKind = operationType.kind) { + is RewardKind.Direct -> OperationParcelizeModel.Reward( + chainId = chainAsset.chainId, + eventId = operationType.eventId, + address = address, + time = time, + amount = amount, + type = resourceManager.getString(typeRes), + era = rewardKind.era?.let { resourceManager.getString(R.string.staking_era_index_no_prefix, it) }, + validator = rewardKind.validator, + statusAppearance = OperationStatusAppearance.COMPLETED + ) + + is RewardKind.Pool -> OperationParcelizeModel.PoolReward( + chainId = chainAsset.chainId, + address = address, + time = time, + amount = amount, + type = resourceManager.getString(typeRes), + poolId = rewardKind.poolId, + eventId = operationType.eventId + ) + } + } + + is Operation.Type.Extrinsic -> { + OperationParcelizeModel.Extrinsic( + chainId = chainAsset.chainId, + chainAssetId = chainAsset.id, + time = time, + originAddress = address, + content = mapExtrinsicContentToParcel(operationType, extrinsicHash, resourceManager), + fee = formatFee(chainAsset, operationType), + fiatFee = operationType.fiatFee?.formatAsCurrency(currency), + statusAppearance = mapStatusToStatusAppearance(operation.status) + ) + } + + is Operation.Type.Swap -> OperationParcelizeModel.Swap( + amountIsAssetIn = chainAsset.fullId == operationType.amountIn.chainAsset.fullId, + amountIn = mapAssetWithAmountToParcel(operationType.amountIn), + amountOut = mapAssetWithAmountToParcel(operationType.amountOut), + amountFee = mapAssetWithAmountToParcel(operationType.fee), + originAddress = operation.address, + transactionHash = operation.extrinsicHash, + statusAppearance = mapStatusToStatusAppearance(operation.status), + timeMillis = operation.time + ) + } + } +} + +private fun mapAssetWithAmountToParcel(assetWithAmount: ChainAssetWithAmount): ChainAssetWithAmountParcelModel { + return ChainAssetWithAmountParcelModel( + assetId = assetWithAmount.chainAsset.fullId.toAssetPayload(), + amount = assetWithAmount.amount + ) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/TransactionHistoryMixin.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/TransactionHistoryMixin.kt new file mode 100644 index 0000000..abdf785 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/TransactionHistoryMixin.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface TransactionHistoryUi { + + class State( + val filtersButtonVisible: Boolean, + val listState: ListState, + ) { + + sealed class ListState { + + object Empty : ListState() + + object EmptyProgress : ListState() + + class Data(val items: List) : ListState() + } + } + + val state: Flow + + fun transactionClicked(transactionId: String) +} + +interface TransactionHistoryMixin : TransactionHistoryUi, CoroutineScope { + + suspend fun syncFirstOperationsPage(): Result<*> + + fun scrolled(currentIndex: Int) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/TransactionHistoryProvider.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/TransactionHistoryProvider.kt new file mode 100644 index 0000000..27edf9c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/TransactionHistoryProvider.kt @@ -0,0 +1,250 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin + +import android.util.Log +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.daysFromMillis +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.presentation.AssetsRouter +import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel +import io.novafoundation.nova.feature_assets.presentation.transaction.filter.HistoryFiltersProviderFactory +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.TransactionHistoryUi.State.ListState +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.Action +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.State +import io.novafoundation.nova.feature_assets.presentation.transaction.history.model.DayHeader +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class TransactionHistoryProvider( + private val walletInteractor: WalletInteractor, + private val router: AssetsRouter, + private val historyFiltersProviderFactory: HistoryFiltersProviderFactory, + private val resourceManager: ResourceManager, + private val addressDisplayUseCase: AddressDisplayUseCase, + private val assetsSourceRegistry: AssetSourceRegistry, + private val chainRegistry: ChainRegistry, + private val chainId: ChainId, + private val assetId: Int, + private val currencyRepository: CurrencyRepository, + private val assetIconProvider: AssetIconProvider +) : TransactionHistoryMixin, CoroutineScope by CoroutineScope(Dispatchers.Default) { + + private val domainState = singleReplaySharedFlow() + + private val chainAsync by lazyAsync { + chainRegistry.getChain(chainId) + } + + private val chainAssetAsync by lazyAsync { + chainRegistry.asset(chainId, assetId) + } + + private val historyFiltersProviderAsync by lazyAsync { + historyFiltersProviderFactory.get( + scope = this, + chainId = chainId, + chainAssetId = assetId + ) + } + + private val tokenFlow = walletInteractor.assetFlow(chainId, assetId) + .map { it.token } + + override val state = combine(domainState, tokenFlow) { state, token -> + mapOperationHistoryStateToUi(state, token) + } + .inBackground() + .shareIn(this, started = SharingStarted.Eagerly, replay = 1) + + private val cachedPage = walletInteractor.operationsFirstPageFlow(chainId, assetId) + .distinctUntilChangedBy { it.cursorPage } + .onEach { performTransition(Action.CachePageArrived(it.cursorPage, it.accountChanged)) } + .map { it.cursorPage } + .inBackground() + .shareIn(this, SharingStarted.Eagerly, replay = 1) + + init { + emitInitialState() + + observeFilters() + } + + override fun scrolled(currentIndex: Int) { + launch { performTransition(Action.Scrolled(currentIndex)) } + } + + override suspend fun syncFirstOperationsPage(): Result<*> { + return walletInteractor.syncOperationsFirstPage( + chainId = chainId, + chainAssetId = assetId, + pageSize = TransactionStateMachine.PAGE_SIZE, + filters = allAvailableFilters() + ).onFailure { + performTransition(Action.PageError(error = it)) + + Log.w(LOG_TAG, "Failed to sync operations page", it) + } + } + + override fun transactionClicked(transactionId: String) { + launch { + val operations = (domainState.first() as? State.WithData)?.data ?: return@launch + + val clickedOperation = operations.first { it.id == transactionId } + + val currency = currencyRepository.getSelectedCurrency() + + withContext(Dispatchers.Main) { + when (val payload = mapOperationToParcel(clickedOperation, chainRegistry, resourceManager, currency)) { + is OperationParcelizeModel.Transfer -> { + router.openTransferDetail(payload) + } + + is OperationParcelizeModel.Extrinsic -> { + router.openExtrinsicDetail(payload) + } + + is OperationParcelizeModel.Reward -> { + router.openRewardDetail(payload) + } + + is OperationParcelizeModel.PoolReward -> { + router.openPoolRewardDetail(payload) + } + + is OperationParcelizeModel.Swap -> { + router.openSwapDetail(payload) + } + } + } + } + } + + private fun observeFilters() = launch { + historyFiltersProviderAsync().filtersFlow() + .map(::ensureOnlyAvailableFiltersUsed) + .distinctUntilChanged() + .onEach { performTransition(Action.FiltersChanged(it)) } + .collect() + } + + // We cannot currently trust HistoryFiltersProvider to not allow users to select unavailable filters so ensure it here + private suspend fun ensureOnlyAvailableFiltersUsed(used: Set): Set { + val allAvailable = allAvailableFilters() + + return allAvailable.intersect(used) + } + + private fun emitInitialState() { + launch { + val initialState = State.EmptyProgress( + allAvailableFilters = allAvailableFilters(), + usedFilters = allAvailableFilters() + ) + + domainState.emit(initialState) + } + } + + private suspend fun performTransition(action: Action) = withContext(Dispatchers.Default) { + val newState = TransactionStateMachine.transition(action, domainState.first()) { sideEffect -> + when (sideEffect) { + is TransactionStateMachine.SideEffect.ErrorEvent -> { + // ignore errors here, they are bypassed to client of mixin + } + + is TransactionStateMachine.SideEffect.LoadPage -> loadNewPage(sideEffect) + TransactionStateMachine.SideEffect.TriggerCache -> triggerCache() + } + } + + domainState.emit(newState) + } + + private fun triggerCache() { + val cached = cachedPage.replayCache.firstOrNull() + + cached?.let { + launch { performTransition(Action.CachePageArrived(it, accountChanged = false)) } + } + } + + private fun loadNewPage(sideEffect: TransactionStateMachine.SideEffect.LoadPage) { + launch { + walletInteractor.getOperations(chainId, assetId, sideEffect.pageSize, sideEffect.nextPageOffset, sideEffect.filters) + .onFailure { + performTransition(Action.PageError(error = it)) + }.onSuccess { + performTransition(Action.NewPage(newPage = it, loadedWith = sideEffect.filters)) + } + } + } + + private suspend fun mapOperationHistoryStateToUi(state: State, token: Token): TransactionHistoryUi.State { + val listState = when (state) { + is State.Empty -> ListState.Empty + is State.EmptyProgress -> ListState.EmptyProgress + is State.Data -> ListState.Data(transformDataToUi(state.data, token)) + is State.FullData -> ListState.Data(transformDataToUi(state.data, token)) + is State.NewPageProgress -> ListState.Data(transformDataToUi(state.data, token)) + } + + return TransactionHistoryUi.State( + filtersButtonVisible = filterButtonVisible(), + listState = listState + ) + } + + private suspend fun filterButtonVisible(): Boolean { + // 0 or 1 filters does not need filters screen since there will be nothing to change there + return allAvailableFilters().size > 1 + } + + private suspend fun allAvailableFilters(): Set { + val assetSource = assetsSourceRegistry.sourceFor(chainAssetAsync()) + + return assetSource.history.availableOperationFilters(chainAsync(), chainAssetAsync()) + } + + private suspend fun transformDataToUi(data: List, token: Token): List { + val accountIdentifier = addressDisplayUseCase.createIdentifier() + val chain = chainAsync() + + return data.groupBy { it.time.daysFromMillis() } + .map { (daysSinceEpoch, operationsPerDay) -> + val header = DayHeader(daysSinceEpoch) + + val operationModels = operationsPerDay.map { operation -> + mapOperationToOperationModel(chain, token, operation, accountIdentifier, resourceManager, assetIconProvider) + } + + listOf(header) + operationModels + }.flatten() + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnCacheArrivedStateProvider.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnCacheArrivedStateProvider.kt new file mode 100644 index 0000000..e373783 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnCacheArrivedStateProvider.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine + +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.Action + +object OnCacheArrivedStateProvider : TransactionStateMachine.StateProvider { + + override fun getState( + action: Action.CachePageArrived, + state: TransactionStateMachine.State, + sideEffectListener: (TransactionStateMachine.SideEffect) -> Unit + ): TransactionStateMachine.State { + val nextOffset = action.newPage.nextOffset + + return when { + !canUseCache(state.allAvailableFilters, state.usedFilters) -> stateCanNotUseCache(action, sideEffectListener, state) + + nextOffset is PageOffset.Loadable -> statePageIsLoadable(action, sideEffectListener, nextOffset, state) + + action.newPage.isEmpty() -> TransactionStateMachine.State.Empty( + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + + else -> TransactionStateMachine.State.FullData( + data = action.newPage, + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + } + } + + private fun stateCanNotUseCache( + action: Action.CachePageArrived, + sideEffectListener: (TransactionStateMachine.SideEffect) -> Unit, + state: TransactionStateMachine.State + ) = if (action.accountChanged) { + // trigger cold load for new account when not able to use cache + sideEffectListener(TransactionStateMachine.SideEffect.LoadPage(nextPageOffset = PageOffset.Loadable.FirstPage, filters = state.usedFilters)) + + TransactionStateMachine.State.EmptyProgress( + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + } else { + // if account is the same - ignore new page, since cache is not used + state + } + + private fun statePageIsLoadable( + action: Action.CachePageArrived, + sideEffectListener: (TransactionStateMachine.SideEffect) -> Unit, + nextOffset: PageOffset.Loadable, + state: TransactionStateMachine.State + ) = if (action.newPage.size < TransactionStateMachine.PAGE_SIZE) { + // cache page doesn't have enough items but we can load them + sideEffectListener(TransactionStateMachine.SideEffect.LoadPage(nextPageOffset = nextOffset, state.usedFilters)) + + if (action.newPage.isEmpty()) { + TransactionStateMachine.State.EmptyProgress(state.allAvailableFilters, state.usedFilters) + } else { + TransactionStateMachine.State.NewPageProgress(nextOffset, action.newPage, state.allAvailableFilters, state.usedFilters) + } + } else { + // cache page has enough items so we wont load next page automatically + if (action.newPage.isEmpty()) { + TransactionStateMachine.State.Empty(state.allAvailableFilters, state.usedFilters) + } else { + TransactionStateMachine.State.Data(nextOffset, action.newPage, state.allAvailableFilters, state.usedFilters) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnFiltersChangedStateProvider.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnFiltersChangedStateProvider.kt new file mode 100644 index 0000000..ad1d42d --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnFiltersChangedStateProvider.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine + +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.Action + +object OnFiltersChangedStateProvider : TransactionStateMachine.StateProvider { + + override fun getState( + action: Action.FiltersChanged, + state: TransactionStateMachine.State, + sideEffectListener: (TransactionStateMachine.SideEffect) -> Unit + ): TransactionStateMachine.State { + val newFilters = action.newUsedFilters + + if (canUseCache(state.allAvailableFilters, newFilters)) { + sideEffectListener(TransactionStateMachine.SideEffect.TriggerCache) + } else { + sideEffectListener(TransactionStateMachine.SideEffect.LoadPage(nextPageOffset = PageOffset.Loadable.FirstPage, filters = newFilters)) + } + + return TransactionStateMachine.State.EmptyProgress( + allAvailableFilters = state.allAvailableFilters, + usedFilters = newFilters + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnNewPageStateProvider.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnNewPageStateProvider.kt new file mode 100644 index 0000000..503f45e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnNewPageStateProvider.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine + +import io.novafoundation.nova.common.data.model.DataPage +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.Action +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.State +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation + +object OnNewPageStateProvider : TransactionStateMachine.StateProvider { + + override fun getState( + action: Action.NewPage, + state: State, + sideEffectListener: (TransactionStateMachine.SideEffect) -> Unit + ): State { + val page = action.newPage + val nextPageOffset = page.nextOffset + + return when (state) { + is State.EmptyProgress -> onEmptyProgress(action, state, nextPageOffset, page, sideEffectListener) + + is State.NewPageProgress -> onNewPageProgress(nextPageOffset, state, page, sideEffectListener) + + else -> state + } + } + + private fun onEmptyProgress( + action: Action.NewPage, + state: State, + nextPageOffset: PageOffset, + page: DataPage, + sideEffectListener: (TransactionStateMachine.SideEffect) -> Unit + ) = when { + action.loadedWith != state.usedFilters -> state // not relevant anymore page has arrived, still loading + + nextPageOffset is PageOffset.FullData && page.isEmpty() -> State.Empty( + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + + nextPageOffset is PageOffset.FullData && page.isNotEmpty() -> State.FullData( + data = page, + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + + nextPageOffset is PageOffset.Loadable -> { + // we didn't load enough items but can load more -> trigger next page automatically + if (page.items.size < TransactionStateMachine.PAGE_SIZE) { + sideEffectListener(TransactionStateMachine.SideEffect.LoadPage(nextPageOffset, state.usedFilters)) + + if (page.items.isEmpty()) { + State.EmptyProgress( + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + } else { + State.NewPageProgress( + nextPageOffset = nextPageOffset, + data = page, + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + } + } else { + State.Data( + nextPageOffset = nextPageOffset, + data = page, + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + } + } + + else -> error("Checked all cases of sealed class PageOffset QED") + } + + private fun onNewPageProgress( + nextPageOffset: PageOffset, + state: State.NewPageProgress, + page: DataPage, + sideEffectListener: (TransactionStateMachine.SideEffect) -> Unit + ): State { + return when (nextPageOffset) { + is PageOffset.Loadable -> { + val newData = state.data + page + + // we want to load at least one complete page without user scrolling + // we also don't wont to stop loading if no relevant items were fetched + if (newData.size < TransactionStateMachine.PAGE_SIZE || page.isEmpty()) { + sideEffectListener(TransactionStateMachine.SideEffect.LoadPage(nextPageOffset, state.usedFilters)) + + State.NewPageProgress( + nextPageOffset = nextPageOffset, + data = newData, + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + } else { + State.Data( + nextPageOffset = nextPageOffset, + data = newData, + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + } + } + + PageOffset.FullData -> State.FullData( + data = state.data + page, + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnPageErrorStateProvider.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnPageErrorStateProvider.kt new file mode 100644 index 0000000..0c21c80 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnPageErrorStateProvider.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine + +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.Action +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.State + +object OnPageErrorStateProvider : TransactionStateMachine.StateProvider { + + override fun getState( + action: Action.PageError, + state: State, + sideEffectListener: (TransactionStateMachine.SideEffect) -> Unit + ): State { + sideEffectListener(TransactionStateMachine.SideEffect.ErrorEvent(action.error)) + + return when (state) { + is State.EmptyProgress -> State.Empty( + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + is State.NewPageProgress -> State.Data( + nextPageOffset = state.nextPageOffset, + data = state.data, + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + else -> state + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnScrolledStateProvider.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnScrolledStateProvider.kt new file mode 100644 index 0000000..d1c63e1 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/OnScrolledStateProvider.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine + +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.Action +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.PAGE_SIZE +import io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine.TransactionStateMachine.State + +object OnScrolledStateProvider : TransactionStateMachine.StateProvider { + + private const val SCROLL_OFFSET = PAGE_SIZE / 2 + + override fun getState( + action: Action.Scrolled, + state: State, + sideEffectListener: (TransactionStateMachine.SideEffect) -> Unit + ): State = when (state) { + is State.Data -> { + if (action.currentItemIndex >= state.data.size - SCROLL_OFFSET) { + sideEffectListener(TransactionStateMachine.SideEffect.LoadPage(state.nextPageOffset, state.usedFilters)) + + State.NewPageProgress( + nextPageOffset = state.nextPageOffset, + data = state.data, + allAvailableFilters = state.allAvailableFilters, + usedFilters = state.usedFilters + ) + } else { + state + } + } + + else -> state + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/TransactionStateMachine.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/TransactionStateMachine.kt new file mode 100644 index 0000000..527355c --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/mixin/state_machine/TransactionStateMachine.kt @@ -0,0 +1,110 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.mixin.state_machine + +import io.novafoundation.nova.common.data.model.DataPage +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation + +object TransactionStateMachine { + + const val PAGE_SIZE = 100 + + interface StateProvider { + fun getState(action: A, state: State, sideEffectListener: (SideEffect) -> Unit): State + + fun canUseCache( + allAvailableFilters: Set, + usedFilters: Set + ) = TransactionStateMachine.canUseCache(allAvailableFilters, usedFilters) + } + + sealed class State( + val allAvailableFilters: Set, + val usedFilters: Set + ) { + + interface WithData { + val data: List + } + + class Empty( + allAvailableFilters: Set, + usedFilters: Set + ) : State(allAvailableFilters, usedFilters) + + class EmptyProgress( + allAvailableFilters: Set, + usedFilters: Set + ) : State(allAvailableFilters, usedFilters) + + class Data( + val nextPageOffset: PageOffset.Loadable, + override val data: List, + allAvailableFilters: Set, + usedFilters: Set, + ) : State(allAvailableFilters, usedFilters), WithData + + class NewPageProgress( + val nextPageOffset: PageOffset.Loadable, + override val data: List, + allAvailableFilters: Set, + usedFilters: Set, + ) : State(allAvailableFilters, usedFilters), WithData + + class FullData( + override val data: List, + allAvailableFilters: Set, + usedFilters: Set, + ) : State(allAvailableFilters, usedFilters), WithData + } + + sealed class Action { + + class Scrolled(val currentItemIndex: Int) : Action() + + data class CachePageArrived( + val newPage: DataPage, + val accountChanged: Boolean + ) : Action() + + data class NewPage(val newPage: DataPage, val loadedWith: Set) : Action() + + data class PageError(val error: Throwable) : Action() + + class FiltersChanged(val newUsedFilters: Set) : Action() + } + + sealed class SideEffect { + + data class LoadPage( + val nextPageOffset: PageOffset.Loadable, + val filters: Set, + val pageSize: Int = PAGE_SIZE, + ) : SideEffect() + + data class ErrorEvent(val error: Throwable) : SideEffect() + + object TriggerCache : SideEffect() + } + + fun transition( + action: Action, + state: State, + sideEffectListener: (SideEffect) -> Unit, + ): State = when (action) { + is Action.CachePageArrived -> OnCacheArrivedStateProvider.getState(action, state, sideEffectListener) + + is Action.Scrolled -> OnScrolledStateProvider.getState(action, state, sideEffectListener) + + is Action.NewPage -> OnNewPageStateProvider.getState(action, state, sideEffectListener) + + is Action.PageError -> OnPageErrorStateProvider.getState(action, state, sideEffectListener) + + is Action.FiltersChanged -> OnFiltersChangedStateProvider.getState(action, state, sideEffectListener) + } + + private fun canUseCache( + allAvailableFilters: Set, + usedFilters: Set + ) = allAvailableFilters == usedFilters +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/model/DayHeader.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/model/DayHeader.kt new file mode 100644 index 0000000..183c777 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/transaction/history/model/DayHeader.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_assets.presentation.transaction.history.model + +data class DayHeader(val daysSinceEpoch: Long) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/ChartController.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/ChartController.kt new file mode 100644 index 0000000..06e81ac --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/ChartController.kt @@ -0,0 +1,199 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts + +import LongPressDetector +import android.annotation.SuppressLint +import android.graphics.Color +import android.graphics.Typeface +import android.view.MotionEvent +import android.view.View +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import io.novafoundation.nova.common.utils.binarySearchFloor +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.vibrate +import io.novafoundation.nova.feature_assets.R + +class ChartController(private val chart: LineChart, private val callback: Callback) : View.OnTouchListener { + + interface Callback { + fun onSelectEntry(startEntry: Entry, selectedEntry: Entry, isEntrySelected: Boolean) + } + + private val context = chart.context + + private val chartUIParams = ChartUIParams.default(context) + private val longClickDetector = LongPressDetector(cancelDistance = 5.dpF(context), timeout = 200, ::onChartsLongClick) + + private var currentEntries: List = emptyList() + private var useNeutralColor = false + + init { + setupChartUI() + } + + fun showYAxis(show: Boolean) { + chart.axisRight.setDrawLabels(show) + chart.invalidate() + } + + fun useNeutralColor(useNeutral: Boolean) { + this.useNeutralColor = useNeutral + updateChart() + } + + fun setEntries(entries: List) { + currentEntries = entries + updateChart() + } + + @SuppressLint("ClickableViewAccessibility") + private fun setupChartUI() { + chart.setBackgroundColor(Color.TRANSPARENT) + chart.setDrawGridBackground(false) + chart.setDrawBorders(false) + + chart.description.isEnabled = false + chart.legend.isEnabled = false + chart.axisLeft.isEnabled = false + chart.xAxis.isEnabled = false + chart.setScaleEnabled(false) + chart.minOffset = 0f + chart.extraTopOffset = 12f + chart.extraBottomOffset = 12f + chart.isHighlightPerTapEnabled = false + chart.isHighlightPerDragEnabled = false + chart.marker = null + + chart.renderer = PriceChartRenderer( + highlightColor = context.getColor(R.color.neutral_price_chart_line), + dotRadius = 4.dpF(context), + strokeWidth = 4.dpF(context), + strokeAlpha = 0.16f, + chart = chart + ) + + chart.axisRight.apply { + isEnabled = true + textSize = 9f + textColor = context.getColor(R.color.text_secondary) + typeface = Typeface.MONOSPACE + setLabelCount(chartUIParams.gridLines, true) + setDrawTopYLabelEntry(true) + gridLineWidth = chartUIParams.gridLineWidthDp + setDrawAxisLine(false) + setDrawGridLines(true) + gridColor = context.getColor(R.color.price_chart_grid_line) + setGridDashedLine(chartUIParams.gridLineDashEffect) + } + + chart.setOnTouchListener(this) + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + longClickDetector.onTouch(v, event) + + when (event.action) { + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + updateChart() + } + } + + handleChartsTouch(event) + + return true + } + + fun onChartsLongClick(event: MotionEvent) { + handleChartsTouch(event) + + context.vibrate(50) // Add very short vibration on long click + } + + fun isTouchIntercepted(): Boolean { + return longClickDetector.isLongClickDetected + } + + private fun handleChartsTouch(event: MotionEvent) { + if (longClickDetector.isLongClickDetected) { + val entry = chart.getEntryByTouchPoint(event) + if (entry != null) { + updateChartWithSelectedEntry(entry) + } + } + } + + private fun updateChartWithSelectedEntry(entry: Entry) { + val (entriesBefore, entriesAfter) = currentEntries.partition { it.x <= entry.x } + + val entriesColor = currentEntries.getColorResForEntries() + val datasetBefore = entriesBefore.createDataSet(entriesColor) + val datasetAfter = entriesAfter.createDataSet(R.color.neutral_price_chart_line) + + chart.priceChartRenderer().apply { + setDotPoint(entry) + setDotColor(context.getColor(entriesColor)) + } + + chart.data = LineData(datasetBefore, datasetAfter) + chart.invalidate() + + onSelectEntry(entriesBefore, isEntrySelected = true) + } + + private fun updateChart() { + chart.priceChartRenderer().apply { + setDotPoint(null) + setDotColor(null) + } + + val dataSet = currentEntries.createDataSet(currentEntries.getColorResForEntries()) + chart.data = LineData(dataSet) + chart.invalidate() + + onSelectEntry(currentEntries, isEntrySelected = false) + } + + private fun onSelectEntry(entries: List, isEntrySelected: Boolean) { + if (entries.isEmpty()) return + + callback.onSelectEntry(entries.first(), entries.last(), isEntrySelected) + } + + private fun List.createDataSet(colorRes: Int): LineDataSet { + return LineDataSet(this, "").apply { + color = context.getColor(colorRes) + setDrawCircles(false) + setDrawValues(false) + mode = LineDataSet.Mode.CUBIC_BEZIER + lineWidth = chartUIParams.chartLineWidthDp + } + } + + private fun LineChart.priceChartRenderer() = renderer as PriceChartRenderer + + private fun List.getColorResForEntries(): Int { + if (useNeutralColor) return R.color.neutral_price_chart_line + + return if (isBullish()) R.color.positive_price_chart_line else R.color.negative_price_chart_line + } + + private fun List.isBullish(): Boolean { + return last().y >= first().y + } + + private fun LineChart.getEntryByTouchPoint(event: MotionEvent): Entry? { + val xTouch = chart.getTransformer(YAxis.AxisDependency.RIGHT) + .getValuesByTouchPoint(event.x, event.y) + .x + + val foundIndex = currentEntries.binarySearchFloor { it.x.compareTo(xTouch) } + if (foundIndex >= 0 && foundIndex < currentEntries.size) { + return currentEntries[foundIndex] + } else { + return null + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/ChartsShimmeringView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/ChartsShimmeringView.kt new file mode 100644 index 0000000..a65d6af --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/ChartsShimmeringView.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.util.AttributeSet +import android.graphics.Path +import android.graphics.Paint.Style +import android.view.View +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.feature_assets.R +import kotlin.math.sin + +class ChartsShimmeringView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : View(context, attrs, defStyle) { + + private val chartUIParams = ChartUIParams.default(context) + + private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG) + .apply { + color = context.getColor(R.color.neutral_price_chart_line) + strokeWidth = chartUIParams.chartLineWidthDp.dpF(context) + style = Style.STROKE + } + + private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG) + .apply { + color = context.getColor(R.color.price_chart_grid_line) + strokeWidth = chartUIParams.gridLineWidthDp.dpF(context) + style = Style.STROKE + pathEffect = chartUIParams.gridLineDashEffect + } + + private val sinPath = Path() + private val linePoints = 200 + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + sinPath.reset() + val periods = 2 + val chartZone = measuredHeight.toFloat() - paddingTop - paddingBottom + val amplitude = (chartZone - linePaint.strokeWidth) / 2 + val centerY = paddingTop + chartZone / 2 + val pointStep = measuredWidth.toFloat() / linePoints.toFloat() + for (point in 0..linePoints) { + val x = point * pointStep + val y = centerY + amplitude * sin(periods * Math.PI * 2 * x / measuredWidth).toFloat() + if (sinPath.isEmpty) { + sinPath.moveTo(x, y) + } else { + sinPath.lineTo(x, y) + } + } + } + + override fun onDraw(canvas: Canvas) { + val gapsCountBetweenGrid = chartUIParams.gridLines - 1 + val drawingZone = height.toFloat() - paddingTop - paddingBottom + repeat(chartUIParams.gridLines) { index -> + val y = index * drawingZone / gapsCountBetweenGrid.toFloat() + paddingTop + canvas.drawLine(0f, y, width.toFloat(), y, gridPaint) + } + + canvas.drawPath(sinPath, linePaint) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/HorizontalScrollDetector.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/HorizontalScrollDetector.kt new file mode 100644 index 0000000..922329e --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/HorizontalScrollDetector.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts + +import android.view.MotionEvent +import kotlin.math.abs + +class HorizontalScrollDetector(private val maxOffset: Float) { + + private var isHorizontalScroll = false + private var startX = 0f + + fun isHorizontalScroll(event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + startX = event.x + } + + MotionEvent.ACTION_MOVE -> { + if (!isHorizontalScroll) { + val offset = event.x - startX + isHorizontalScroll = abs(offset) > maxOffset + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + isHorizontalScroll = false + } + } + + return isHorizontalScroll + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/LongClickDetector.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/LongClickDetector.kt new file mode 100644 index 0000000..cc4e89b --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/LongClickDetector.kt @@ -0,0 +1,55 @@ +import android.graphics.PointF +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import android.view.View +import androidx.core.graphics.minus + +class LongPressDetector( + private val cancelDistance: Float, + private val timeout: Long, + private val onLongPress: (MotionEvent) -> Unit +) : View.OnTouchListener { + + var isLongClickDetected = false + private set + + private val handler = Handler(Looper.getMainLooper()) + private var lastMotionEvent: MotionEvent? = null + private var startTouchPoint = PointF() + + private val longPressRunnable = Runnable { + isLongClickDetected = true + lastMotionEvent?.let { onLongPress.invoke(it) } + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + lastMotionEvent = MotionEvent.obtain(event) + when (event.action) { + MotionEvent.ACTION_DOWN -> { + startTouchPoint = PointF(event.x, event.y) + isLongClickDetected = false + handler.postDelayed(longPressRunnable, timeout) + } + + MotionEvent.ACTION_MOVE -> { + val currentTouchPoint = PointF(event.x, event.y) + val delta = currentTouchPoint.minus(startTouchPoint) + if (!isLongClickDetected && delta.length() > cancelDistance) { + cancelLongPress() + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + cancelLongPress() + } + } + return true + } + + private fun cancelLongPress() { + handler.removeCallbacks(longPressRunnable) + isLongClickDetected = false + lastMotionEvent = null + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/PriceChartGestureListener.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/PriceChartGestureListener.kt new file mode 100644 index 0000000..8cd60b1 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/PriceChartGestureListener.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts + +import android.view.MotionEvent +import com.github.mikephil.charting.listener.ChartTouchListener +import com.github.mikephil.charting.listener.OnChartGestureListener + +class PriceChartGestureListener( + private val onGestureStart: (MotionEvent) -> Unit, + private val onGestureEnd: (MotionEvent) -> Unit +) : OnChartGestureListener { + override fun onChartGestureStart(me: MotionEvent, lastPerformedGesture: ChartTouchListener.ChartGesture?) { + onGestureStart(me) + } + + override fun onChartGestureEnd(me: MotionEvent, lastPerformedGesture: ChartTouchListener.ChartGesture?) { + onGestureEnd(me) + } + + override fun onChartLongPressed(me: MotionEvent?) {} + + override fun onChartDoubleTapped(me: MotionEvent?) {} + + override fun onChartSingleTapped(me: MotionEvent?) {} + + override fun onChartFling(me1: MotionEvent?, me2: MotionEvent?, velocityX: Float, velocityY: Float) {} + + override fun onChartScale(me: MotionEvent?, scaleX: Float, scaleY: Float) {} + + override fun onChartTranslate(me: MotionEvent?, dX: Float, dY: Float) {} +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/PriceChartRenderer.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/PriceChartRenderer.kt new file mode 100644 index 0000000..30e1fd9 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/PriceChartRenderer.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PointF +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.interfaces.datasets.ILineScatterCandleRadarDataSet +import com.github.mikephil.charting.renderer.LineChartRenderer +import io.novafoundation.nova.common.utils.dpF +import kotlin.math.roundToInt + +/** + * Draw a dot on selected entry + */ +class PriceChartRenderer( + private val highlightColor: Int, + private val dotRadius: Float, + private val strokeWidth: Float, + private val strokeAlpha: Float, + private val chart: LineChart +) : LineChartRenderer(chart, chart.animator, chart.viewPortHandler) { + + private var dot: Entry? = null + private var dotColor: Int? = null + + val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + + private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = highlightColor + strokeWidth = 1.5f.dpF(chart.context) + style = Paint.Style.STROKE + } + + fun setDotPoint(entry: Entry?) { + this.dot = entry + } + + fun setDotColor(color: Int?) { + this.dotColor = color + } + + override fun drawHighlightLines(c: Canvas?, x: Float, y: Float, set: ILineScatterCandleRadarDataSet<*>?) { + // Override it to not draw highlight lines + } + + override fun drawExtras(c: Canvas) { + super.drawExtras(c) + dot?.let { + val point = it.toCanvasPoint() + c.drawLine(point.x, 0f, point.x, chart.height.toFloat(), linePaint) + dotPaint.color = getAlphaWithArgb(dotColor ?: Color.WHITE, strokeAlpha) + c.drawCircle(point.x, point.y, dotRadius + strokeWidth, dotPaint) + dotPaint.color = dotColor ?: Color.WHITE + c.drawCircle(point.x, point.y, dotRadius, dotPaint) + } + } + + private fun Entry.toCanvasPoint(): PointF { + val transformer = chart.getTransformer(YAxis.AxisDependency.RIGHT) + val values = floatArrayOf(x, y) + transformer.pointValuesToPixel(values) + return PointF(values[0], values[1]) + } + + fun getAlphaWithArgb(color: Int, alpha: Float): Int { + return Color.argb((255 * alpha).roundToInt(), Color.red(color), Color.green(color), Color.blue(color)) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/PriceChartsView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/PriceChartsView.kt new file mode 100644 index 0000000..855d58a --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/PriceChartsView.kt @@ -0,0 +1,174 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.widget.LinearLayout +import android.widget.Space +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.children +import androidx.core.view.isGone +import com.github.mikephil.charting.data.Entry +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_assets.databinding.LayoutPriceChartButtonBinding +import io.novafoundation.nova.feature_assets.databinding.ViewPriceChartsBinding +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.DateChartTextInjector +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.PriceChangeTextInjector +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters.PriceTextInjector +import java.math.BigDecimal +import kotlin.math.roundToLong + +sealed class PriceChartModel(val buttonText: String) { + + class Loading(buttonText: String) : PriceChartModel(buttonText) + + class Chart( + buttonText: String, + val periodName: String, + val supportTimeShowing: Boolean, + val priceChart: List + ) : PriceChartModel(buttonText) { + + class Price(val timestamp: Long, val price: BigDecimal) + } +} + +class PriceChartsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : ConstraintLayout(context, attrs, defStyle), ChartController.Callback { + + private val binder = ViewPriceChartsBinding.inflate(inflater(), this) + + private var charts: List = emptyList() + private var selectedChartIndex = 0 + + private val horizontalScrollDetector = HorizontalScrollDetector(5.dpF) + + private val controller: ChartController + + private var priceTextInjector: PriceTextInjector? = null + private var priceChangeTextInjector: PriceChangeTextInjector? = null + private var dateTextInjector: DateChartTextInjector? = null + + init { + controller = ChartController(binder.priceChart, this) + setEmptyState() + } + + fun setTitle(title: String) { + binder.priceChartTitle.text = title + } + + fun setCharts(charts: List) { + this.charts = charts + + binder.priceChartButtons.removeAllViews() + charts.forEachIndexed { index, priceChartModel -> + if (index > 0) { + binder.priceChartButtons.addView(createSpace()) + } + + binder.priceChartButtons.addView(createButton(priceChartModel.buttonText, index)) + } + + if (charts.isEmpty()) { + setEmptyState() + } else { + if (selectedChartIndex >= charts.size) { + selectedChartIndex = 0 + } + + selectChart(selectedChartIndex) + } + } + + fun setTextInjectors( + priceTextInjector: PriceTextInjector, + priceChangeTextInjector: PriceChangeTextInjector, + dateTextInjector: DateChartTextInjector + ) { + this.priceTextInjector = priceTextInjector + this.priceChangeTextInjector = priceChangeTextInjector + this.dateTextInjector = dateTextInjector + } + + override fun onSelectEntry(startEntry: Entry, selectedEntry: Entry, isEntrySelected: Boolean) { + priceTextInjector?.format(selectedEntry.y, binder.priceChartCurrentPrice, isEntrySelected) + priceChangeTextInjector?.format(startEntry.y, selectedEntry.y, binder.priceChartPriceChange) + dateTextInjector?.format(selectedEntry.x.roundToLong(), isEntrySelected, binder.priceChartDate, charts[selectedChartIndex]) + } + + private fun setEmptyState() { + showCharts(false) + } + + private fun showCharts(show: Boolean) { + binder.priceChart.setVisible(show, falseState = View.INVISIBLE) + binder.priceChartDate.setVisible(show, falseState = View.INVISIBLE) + binder.priceChartPriceChange.setVisible(show, falseState = View.INVISIBLE) + binder.priceChartCurrentPrice.setVisible(show, falseState = View.INVISIBLE) + binder.priceChartPriceChangeShimmering.isGone = show + binder.priceChartCurrentPriceShimmering.isGone = show + binder.priceChartShimmering.isGone = show + } + + private fun selectChart(index: Int) { + selectedChartIndex = index + + getButtons().forEachIndexed { i, view -> + view.isSelected = i == index + } + + val currentChart = charts[index] + if (currentChart is PriceChartModel.Loading) { + setEmptyState() + return + } else if (currentChart is PriceChartModel.Chart) { + controller.setEntries(currentChart.asEntries()) + + showCharts(true) + } + } + + private fun getButtons(): List { + return binder.priceChartButtons.children + .toList() + .filterIsInstance() + } + + private fun createButton(text: String, index: Int): View { + val buttonBinder = LayoutPriceChartButtonBinding.inflate(inflater()) + return buttonBinder.priceChartButtonText.apply { + setText(text) + setOnClickListener { selectChart(index) } + } + } + + private fun createSpace(): Space { + val space = Space(context) + space.layoutParams = LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f) + return space + } + + /** + * We should disallow intercept touch events by parents when we move horizontally to prevent scrolling in parent + */ + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + val isTouchIntercepted = controller.isTouchIntercepted() + if (isTouchIntercepted) { + parent.requestDisallowInterceptTouchEvent(true) + } + + return super.onInterceptTouchEvent(ev) + } + + private fun PriceChartModel.Chart.asEntries() = priceChart.map { + Entry(it.timestamp.toFloat(), it.price.toFloat()) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/Utils.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/Utils.kt new file mode 100644 index 0000000..089b674 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/Utils.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts + +import android.content.Context +import android.graphics.DashPathEffect +import io.novafoundation.nova.common.utils.dpF + +class ChartUIParams( + val gridLineWidthDp: Float, + val chartLineWidthDp: Float, + val gridLineDashEffect: DashPathEffect, + val gridLines: Int +) { + companion object { + fun default(context: Context) = ChartUIParams( + gridLineWidthDp = 1.5f, + chartLineWidthDp = 1.5f, + gridLineDashEffect = DashPathEffect(floatArrayOf(3f.dpF(context), 3f.dpF(context)), 0f), + gridLines = 4 + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/formatters/PricePriceTextInjector.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/formatters/PricePriceTextInjector.kt new file mode 100644 index 0000000..6669ea3 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/formatters/PricePriceTextInjector.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters + +import android.widget.TextView +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import java.math.BigDecimal + +interface PriceTextInjector { + fun format(value: Float, textView: TextView, isEntrySelected: Boolean) +} + +class RealPricePriceTextInjector( + private val currency: Currency, + private val lastCoinRate: BigDecimal? +) : PriceTextInjector { + + override fun format(value: Float, textView: TextView, isEntrySelected: Boolean) { + textView.text = if (isEntrySelected || lastCoinRate == null) { + value.toBigDecimal().formatAsCurrency(currency) + } else { + lastCoinRate.formatAsCurrency(currency) + } + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/formatters/RealDateChartTextInjector.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/formatters/RealDateChartTextInjector.kt new file mode 100644 index 0000000..c28f4df --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/formatters/RealDateChartTextInjector.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters + +import android.text.format.DateUtils +import android.view.View +import android.widget.TextView +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.isThisYear +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.presentation.views.priceCharts.PriceChartModel +import java.util.Date +import kotlin.time.Duration.Companion.seconds + +interface DateChartTextInjector { + fun format(timestamp: Long, isEntrySelected: Boolean, textView: TextView, priceChartModel: PriceChartModel) +} + +class RealDateChartTextInjector( + private val resourceManager: ResourceManager +) : DateChartTextInjector { + + override fun format(timestamp: Long, isEntrySelected: Boolean, textView: TextView, priceChartModel: PriceChartModel) { + val chartModel = priceChartModel as? PriceChartModel.Chart ?: return + + if (isEntrySelected) { + val date = Date(timestamp.seconds.inWholeMilliseconds) + val dateString = formatDate(textView, date) + + textView.text = if (chartModel.supportTimeShowing) { + val timeString = resourceManager.formatTime(date.time) + resourceManager.getString(R.string.price_chart_date_format, dateString, timeString) + } else { + dateString + } + } else { + textView.text = chartModel.periodName + } + } + + private fun formatDate(view: View, date: Date): String { + return if (date.isThisYear()) { + formatDateThisYear(view, date.time) + } else { + formatDateOtherYear(view, date.time) + } + } + + private fun formatDateThisYear(view: View, timestamp: Long): String { + return DateUtils.formatDateTime( + view.context, + timestamp, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR or DateUtils.FORMAT_ABBREV_MONTH + ) + } + + private fun formatDateOtherYear(view: View, timestamp: Long): String { + return DateUtils.formatDateTime( + view.context, + timestamp, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR or DateUtils.FORMAT_ABBREV_MONTH + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/formatters/RealPriceChangeTextInjector.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/formatters/RealPriceChangeTextInjector.kt new file mode 100644 index 0000000..1cfea0f --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/views/priceCharts/formatters/RealPriceChangeTextInjector.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_assets.presentation.views.priceCharts.formatters + +import android.widget.TextView +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.common.utils.formatting.formatAsPercentage +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import kotlin.math.absoluteValue + +interface PriceChangeTextInjector { + fun format(fromValue: Float, toValue: Float, textView: TextView) +} + +class RealPriceChangeTextInjector( + private val resourceManager: ResourceManager, + private val currency: Currency +) : PriceChangeTextInjector { + + override fun format(fromValue: Float, toValue: Float, textView: TextView) { + val change = toValue - fromValue + val changeInPercent = if (fromValue != 0f) { + (change / fromValue).fractions.inPercents.toFloat() + } else { + 0f + } + + textView.text = resourceManager.getString( + R.string.price_chart_price_change, + change.absoluteValue.toBigDecimal().formatAsCurrency(currency), + changeInPercent.absoluteValue.toBigDecimal().formatAsPercentage() // Always absolute value to avoid negative signs + ) + + if (change < 0f) { + textView.setTextColorRes(R.color.text_negative) + textView.setDrawableStart(R.drawable.ic_arrow_down, tint = R.color.icon_negative, widthInDp = 16, paddingInDp = 2) + } else { + textView.setTextColorRes(R.color.text_positive) + textView.setDrawableStart(R.drawable.ic_arrow_up, tint = R.color.icon_positive, widthInDp = 16, paddingInDp = 2) + } + } +} diff --git a/feature-assets/src/main/res/color/color_chart_button_text.xml b/feature-assets/src/main/res/color/color_chart_button_text.xml new file mode 100644 index 0000000..7499bfe --- /dev/null +++ b/feature-assets/src/main/res/color/color_chart_button_text.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/drawable/background_chart_button.xml b/feature-assets/src/main/res/drawable/background_chart_button.xml new file mode 100644 index 0000000..793df8e --- /dev/null +++ b/feature-assets/src/main/res/drawable/background_chart_button.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/drawable/ic_moonpay.xml b/feature-assets/src/main/res/drawable/ic_moonpay.xml new file mode 100644 index 0000000..536b88e --- /dev/null +++ b/feature-assets/src/main/res/drawable/ic_moonpay.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/feature-assets/src/main/res/drawable/ic_ramp.xml b/feature-assets/src/main/res/drawable/ic_ramp.xml new file mode 100644 index 0000000..4de263d --- /dev/null +++ b/feature-assets/src/main/res/drawable/ic_ramp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/feature-assets/src/main/res/drawable/ic_send.xml b/feature-assets/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..dd9df93 --- /dev/null +++ b/feature-assets/src/main/res/drawable/ic_send.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature-assets/src/main/res/layout/fragment_add_token_enter_info.xml b/feature-assets/src/main/res/layout/fragment_add_token_enter_info.xml new file mode 100644 index 0000000..f42d653 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_add_token_enter_info.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_add_token_select_chain.xml b/feature-assets/src/main/res/layout/fragment_add_token_select_chain.xml new file mode 100644 index 0000000..e66dec0 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_add_token_select_chain.xml @@ -0,0 +1,36 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_asset_filters.xml b/feature-assets/src/main/res/layout/fragment_asset_filters.xml new file mode 100644 index 0000000..125490b --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_asset_filters.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_asset_flow_search.xml b/feature-assets/src/main/res/layout/fragment_asset_flow_search.xml new file mode 100644 index 0000000..44ce8a3 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_asset_flow_search.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_asset_search.xml b/feature-assets/src/main/res/layout/fragment_asset_search.xml new file mode 100644 index 0000000..204fda8 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_asset_search.xml @@ -0,0 +1,37 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_balance_breakdown.xml b/feature-assets/src/main/res/layout/fragment_balance_breakdown.xml new file mode 100644 index 0000000..0acdaea --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_balance_breakdown.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_balance_detail.xml b/feature-assets/src/main/res/layout/fragment_balance_detail.xml new file mode 100644 index 0000000..e0b39a8 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_balance_detail.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/fragment_balance_list.xml b/feature-assets/src/main/res/layout/fragment_balance_list.xml new file mode 100644 index 0000000..84a46c4 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_balance_list.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/feature-assets/src/main/res/layout/fragment_bridge.xml b/feature-assets/src/main/res/layout/fragment_bridge.xml new file mode 100644 index 0000000..32a219f --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_bridge.xml @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/fragment_buy.xml b/feature-assets/src/main/res/layout/fragment_buy.xml new file mode 100644 index 0000000..60b77e0 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_buy.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_confirm_send.xml b/feature-assets/src/main/res/layout/fragment_confirm_send.xml new file mode 100644 index 0000000..633f963 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_confirm_send.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_extrinsic_details.xml b/feature-assets/src/main/res/layout/fragment_extrinsic_details.xml new file mode 100644 index 0000000..6443d4e --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_extrinsic_details.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/fragment_manage_chain_tokens.xml b/feature-assets/src/main/res/layout/fragment_manage_chain_tokens.xml new file mode 100644 index 0000000..e0de243 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_manage_chain_tokens.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_manage_tokens.xml b/feature-assets/src/main/res/layout/fragment_manage_tokens.xml new file mode 100644 index 0000000..efa5936 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_manage_tokens.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_network_flow.xml b/feature-assets/src/main/res/layout/fragment_network_flow.xml new file mode 100644 index 0000000..854f50d --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_network_flow.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_nova_card.xml b/feature-assets/src/main/res/layout/fragment_nova_card.xml new file mode 100644 index 0000000..afcaa95 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_nova_card.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_pool_reward_details.xml b/feature-assets/src/main/res/layout/fragment_pool_reward_details.xml new file mode 100644 index 0000000..5e131da --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_pool_reward_details.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/fragment_receive.xml b/feature-assets/src/main/res/layout/fragment_receive.xml new file mode 100644 index 0000000..f093a86 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_receive.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/fragment_reward_slash_details.xml b/feature-assets/src/main/res/layout/fragment_reward_slash_details.xml new file mode 100644 index 0000000..1fd004e --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_reward_slash_details.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/fragment_select_send.xml b/feature-assets/src/main/res/layout/fragment_select_send.xml new file mode 100644 index 0000000..b64d198 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_select_send.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_swap_details.xml b/feature-assets/src/main/res/layout/fragment_swap_details.xml new file mode 100644 index 0000000..7d38b04 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_swap_details.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/fragment_top_up_address.xml b/feature-assets/src/main/res/layout/fragment_top_up_address.xml new file mode 100644 index 0000000..21de791 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_top_up_address.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_trade_provider_list.xml b/feature-assets/src/main/res/layout/fragment_trade_provider_list.xml new file mode 100644 index 0000000..2108024 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_trade_provider_list.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_trade_web_interface.xml b/feature-assets/src/main/res/layout/fragment_trade_web_interface.xml new file mode 100644 index 0000000..1bb57dd --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_trade_web_interface.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_transactions_filter.xml b/feature-assets/src/main/res/layout/fragment_transactions_filter.xml new file mode 100644 index 0000000..19839fa --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_transactions_filter.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/fragment_transfer_details.xml b/feature-assets/src/main/res/layout/fragment_transfer_details.xml new file mode 100644 index 0000000..44c5c0f --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_transfer_details.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/fragment_waiting_nova_card_top_up.xml b/feature-assets/src/main/res/layout/fragment_waiting_nova_card_top_up.xml new file mode 100644 index 0000000..13d105a --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_waiting_nova_card_top_up.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_asset_header.xml b/feature-assets/src/main/res/layout/item_asset_header.xml new file mode 100644 index 0000000..16d8c25 --- /dev/null +++ b/feature-assets/src/main/res/layout/item_asset_header.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_balance_breakdown_amount.xml b/feature-assets/src/main/res/layout/item_balance_breakdown_amount.xml new file mode 100644 index 0000000..74f8bb1 --- /dev/null +++ b/feature-assets/src/main/res/layout/item_balance_breakdown_amount.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_balance_breakdown_total.xml b/feature-assets/src/main/res/layout/item_balance_breakdown_total.xml new file mode 100644 index 0000000..0fd115e --- /dev/null +++ b/feature-assets/src/main/res/layout/item_balance_breakdown_total.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/item_chain_chooser.xml b/feature-assets/src/main/res/layout/item_chain_chooser.xml new file mode 100644 index 0000000..55dd8b6 --- /dev/null +++ b/feature-assets/src/main/res/layout/item_chain_chooser.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_chain_chooser_group.xml b/feature-assets/src/main/res/layout/item_chain_chooser_group.xml new file mode 100644 index 0000000..a33f9ad --- /dev/null +++ b/feature-assets/src/main/res/layout/item_chain_chooser_group.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_contact.xml b/feature-assets/src/main/res/layout/item_contact.xml new file mode 100644 index 0000000..1d11bdb --- /dev/null +++ b/feature-assets/src/main/res/layout/item_contact.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_contact_group.xml b/feature-assets/src/main/res/layout/item_contact_group.xml new file mode 100644 index 0000000..7ea5043 --- /dev/null +++ b/feature-assets/src/main/res/layout/item_contact_group.xml @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_day_header.xml b/feature-assets/src/main/res/layout/item_day_header.xml new file mode 100644 index 0000000..2aeda85 --- /dev/null +++ b/feature-assets/src/main/res/layout/item_day_header.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_manage_assets.xml b/feature-assets/src/main/res/layout/item_manage_assets.xml new file mode 100644 index 0000000..f3c783a --- /dev/null +++ b/feature-assets/src/main/res/layout/item_manage_assets.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_manage_chain_token.xml b/feature-assets/src/main/res/layout/item_manage_chain_token.xml new file mode 100644 index 0000000..6d09a1c --- /dev/null +++ b/feature-assets/src/main/res/layout/item_manage_chain_token.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_manage_token_multichain.xml b/feature-assets/src/main/res/layout/item_manage_token_multichain.xml new file mode 100644 index 0000000..149bdb1 --- /dev/null +++ b/feature-assets/src/main/res/layout/item_manage_token_multichain.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_network_asset.xml b/feature-assets/src/main/res/layout/item_network_asset.xml new file mode 100644 index 0000000..9444dc3 --- /dev/null +++ b/feature-assets/src/main/res/layout/item_network_asset.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/item_network_asset_group.xml b/feature-assets/src/main/res/layout/item_network_asset_group.xml new file mode 100644 index 0000000..6f8ae66 --- /dev/null +++ b/feature-assets/src/main/res/layout/item_network_asset_group.xml @@ -0,0 +1,32 @@ + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_network_flow.xml b/feature-assets/src/main/res/layout/item_network_flow.xml new file mode 100644 index 0000000..1eb7c3d --- /dev/null +++ b/feature-assets/src/main/res/layout/item_network_flow.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/item_select_chain.xml b/feature-assets/src/main/res/layout/item_select_chain.xml new file mode 100644 index 0000000..2e1dbef --- /dev/null +++ b/feature-assets/src/main/res/layout/item_select_chain.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/item_token_asset.xml b/feature-assets/src/main/res/layout/item_token_asset.xml new file mode 100644 index 0000000..0abce22 --- /dev/null +++ b/feature-assets/src/main/res/layout/item_token_asset.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/item_token_asset_group.xml b/feature-assets/src/main/res/layout/item_token_asset_group.xml new file mode 100644 index 0000000..4b1842f --- /dev/null +++ b/feature-assets/src/main/res/layout/item_token_asset_group.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/item_trade_provider.xml b/feature-assets/src/main/res/layout/item_trade_provider.xml new file mode 100644 index 0000000..380354b --- /dev/null +++ b/feature-assets/src/main/res/layout/item_trade_provider.xml @@ -0,0 +1,42 @@ + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/layout_payment_method_image.xml b/feature-assets/src/main/res/layout/layout_payment_method_image.xml new file mode 100644 index 0000000..a2f4082 --- /dev/null +++ b/feature-assets/src/main/res/layout/layout_payment_method_image.xml @@ -0,0 +1,6 @@ + + diff --git a/feature-assets/src/main/res/layout/layout_payment_method_text.xml b/feature-assets/src/main/res/layout/layout_payment_method_text.xml new file mode 100644 index 0000000..c78df5c --- /dev/null +++ b/feature-assets/src/main/res/layout/layout_payment_method_text.xml @@ -0,0 +1,10 @@ + + diff --git a/feature-assets/src/main/res/layout/layout_price_chart_button.xml b/feature-assets/src/main/res/layout/layout_price_chart_button.xml new file mode 100644 index 0000000..34ab5a2 --- /dev/null +++ b/feature-assets/src/main/res/layout/layout_price_chart_button.xml @@ -0,0 +1,12 @@ + + diff --git a/feature-assets/src/main/res/layout/view_asset_actions.xml b/feature-assets/src/main/res/layout/view_asset_actions.xml new file mode 100644 index 0000000..5f48322 --- /dev/null +++ b/feature-assets/src/main/res/layout/view_asset_actions.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + diff --git a/feature-assets/src/main/res/layout/view_asset_nova_card.xml b/feature-assets/src/main/res/layout/view_asset_nova_card.xml new file mode 100644 index 0000000..1804b62 --- /dev/null +++ b/feature-assets/src/main/res/layout/view_asset_nova_card.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/view_asset_view_mode.xml b/feature-assets/src/main/res/layout/view_asset_view_mode.xml new file mode 100644 index 0000000..fb9fe92 --- /dev/null +++ b/feature-assets/src/main/res/layout/view_asset_view_mode.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/view_go_to_nfts.xml b/feature-assets/src/main/res/layout/view_go_to_nfts.xml new file mode 100644 index 0000000..2f69f86 --- /dev/null +++ b/feature-assets/src/main/res/layout/view_go_to_nfts.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/view_pending_operations_count.xml b/feature-assets/src/main/res/layout/view_pending_operations_count.xml new file mode 100644 index 0000000..33560f4 --- /dev/null +++ b/feature-assets/src/main/res/layout/view_pending_operations_count.xml @@ -0,0 +1,48 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/view_price_charts.xml b/feature-assets/src/main/res/layout/view_price_charts.xml new file mode 100644 index 0000000..9a2107d --- /dev/null +++ b/feature-assets/src/main/res/layout/view_price_charts.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/view_total_balance.xml b/feature-assets/src/main/res/layout/view_total_balance.xml new file mode 100644 index 0000000..097607b --- /dev/null +++ b/feature-assets/src/main/res/layout/view_total_balance.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/layout/view_transfer_history.xml b/feature-assets/src/main/res/layout/view_transfer_history.xml new file mode 100644 index 0000000..013eb2a --- /dev/null +++ b/feature-assets/src/main/res/layout/view_transfer_history.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/main/res/values/dimens.xml b/feature-assets/src/main/res/values/dimens.xml new file mode 100644 index 0000000..a30e9e8 --- /dev/null +++ b/feature-assets/src/main/res/values/dimens.xml @@ -0,0 +1,7 @@ + + + 64dp + 80dp + 24sp + 15sp + \ No newline at end of file diff --git a/feature-assets/src/main/res/values/styles.xml b/feature-assets/src/main/res/values/styles.xml new file mode 100644 index 0000000..da85d2f --- /dev/null +++ b/feature-assets/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/feature-assets/src/test/java/io/novafoundation/nova/ExampleUnitTest.kt b/feature-assets/src/test/java/io/novafoundation/nova/ExampleUnitTest.kt new file mode 100644 index 0000000..a26e898 --- /dev/null +++ b/feature-assets/src/test/java/io/novafoundation/nova/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/feature-banners-api/.gitignore b/feature-banners-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-banners-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-banners-api/build.gradle b/feature-banners-api/build.gradle new file mode 100644 index 0000000..d424a6f --- /dev/null +++ b/feature-banners-api/build.gradle @@ -0,0 +1,53 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_banners_api' + + defaultConfig { + buildConfigField "String", "BANNERS_BASE_DIRECTORY", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/banners/v2/content\"" + buildConfigField "String", "ASSETS_BANNERS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/banners/v2/content/assets/banners_dev.json\"" + buildConfigField "String", "DAPPS_BANNERS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/banners/v2/content/dapps/banners_dev.json\"" + buildConfigField "String", "ASSETS_BANNERS_LOCALISATION_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/banners/v2/content/assets/localized_dev\"" + buildConfigField "String", "DAPPS_BANNERS_LOCALISATION_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/banners/v2/content/dapps/localized_dev\"" + } + + buildTypes { + debug { + + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + buildConfigField "String", "ASSETS_BANNERS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/banners/v2/content/assets/banners.json\"" + buildConfigField "String", "DAPPS_BANNERS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/banners/v2/content/dapps/banners.json\"" + buildConfigField "String", "ASSETS_BANNERS_LOCALISATION_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/banners/v2/content/assets/localized\"" + buildConfigField "String", "DAPPS_BANNERS_LOCALISATION_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/banners/v2/content/dapps/localized\"" + } + } + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation coroutinesDep + implementation project(":common") + + implementation cardViewDep + implementation recyclerViewDep + implementation materialDep + implementation androidDep + implementation androidDep + + implementation shimmerDep + + implementation daggerDep + ksp daggerCompiler + + testImplementation project(':test-shared') +} \ No newline at end of file diff --git a/feature-banners-api/consumer-rules.pro b/feature-banners-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-banners-api/proguard-rules.pro b/feature-banners-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-banners-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-banners-api/src/main/AndroidManifest.xml b/feature-banners-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-banners-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/di/BannersFeatureApi.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/di/BannersFeatureApi.kt new file mode 100644 index 0000000..67a594d --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/di/BannersFeatureApi.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_banners_api.di + +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory + +interface BannersFeatureApi { + + fun sourceFactory(): BannersSourceFactory + + fun mixinFactory(): PromotionBannersMixinFactory +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/domain/PromotionBanner.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/domain/PromotionBanner.kt new file mode 100644 index 0000000..9030c26 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/domain/PromotionBanner.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_banners_api.domain + +class PromotionBanner( + val id: String, + val title: String, + val details: String, + val backgroundUrl: String, + val imageUrl: String, + val clipToBounds: Boolean, + val actionLink: String?, +) diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/BannerPageModel.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/BannerPageModel.kt new file mode 100644 index 0000000..465bed1 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/BannerPageModel.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_banners_api.presentation + +import android.graphics.drawable.Drawable + +class BannerPageModel( + val id: String, + val title: String, + val subtitle: String, + val image: ClipableImage, + val background: Drawable, + val actionUrl: String? +) + +class ClipableImage(val drawable: Drawable, val clip: Boolean) diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/PromotionBannerAdapter.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/PromotionBannerAdapter.kt new file mode 100644 index 0000000..ea44ca7 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/PromotionBannerAdapter.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_banners_api.presentation + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.SingleItemAdapter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.recyclerView.WithViewType +import io.novafoundation.nova.feature_banners_api.R +import io.novafoundation.nova.feature_banners_api.databinding.ItemPromotionBannerBinding +import io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView + +class PromotionBannerAdapter( + private val closable: Boolean +) : SingleItemAdapter(isShownByDefault = false) { + + private var banners: List = listOf() + private var bannerCallback: BannerPagerView.Callback? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BannerHolder { + return BannerHolder(ItemPromotionBannerBinding.inflate(parent.inflater(), parent, false), closable) + } + + override fun onBindViewHolder(holder: BannerHolder, position: Int) { + holder.bind(banners, bannerCallback) + } + + override fun getItemViewType(position: Int): Int { + return BannerHolder.viewType + } + + fun setBanners(banners: List) { + this.banners = banners + notifyChangedIfShown() + } + + fun setCallback(bannerCallback: BannerPagerView.Callback?) { + this.bannerCallback = bannerCallback + notifyChangedIfShown() + } +} + +class BannerHolder(private val binder: ItemPromotionBannerBinding, closable: Boolean) : RecyclerView.ViewHolder(binder.root) { + + companion object : WithViewType { + override val viewType: Int = R.layout.item_promotion_banner + } + + init { + binder.bannerPager.setClosable(closable) + } + + fun bind(banners: List, bannerCallback: BannerPagerView.Callback?) = with(binder) { + bannerPager.setCallback(bannerCallback) + showBanners(banners) + } + + fun showBanners(banners: List) = with(binder) { + bannerPager.setBanners(banners) + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/PromotionBannersMixin.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/PromotionBannersMixin.kt new file mode 100644 index 0000000..bc978d7 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/PromotionBannersMixin.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_banners_api.presentation + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import kotlinx.coroutines.flow.Flow + +interface PromotionBannersMixin { + + val bannersFlow: Flow>> + + fun closeBanner(banner: BannerPageModel) + + fun startBannerAction(page: BannerPageModel) +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/PromotionBannersMixinFactory.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/PromotionBannersMixinFactory.kt new file mode 100644 index 0000000..00b41a2 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/PromotionBannersMixinFactory.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_banners_api.presentation + +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSource +import kotlinx.coroutines.CoroutineScope + +interface PromotionBannersMixinFactory { + fun create(source: BannersSource, coroutineScope: CoroutineScope): PromotionBannersMixin +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/UiBinding.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/UiBinding.kt new file mode 100644 index 0000000..bbef9c0 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/UiBinding.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_banners_api.presentation + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView + +context(BaseFragment) +fun PromotionBannersMixin.bindWithAdapter( + adapter: PromotionBannerAdapter, + onSubmitList: () -> Unit = {} +) { + adapter.setCallback(object : BannerPagerView.Callback { + override fun onBannerClicked(page: BannerPageModel) { + this@bindWithAdapter.startBannerAction(page) + } + + override fun onBannerClosed(page: BannerPageModel) { + this@bindWithAdapter.closeBanner(page) + } + }) + + bannersFlow.observe { + adapter.show(it is ExtendedLoadingState.Loaded && it.data.isNotEmpty()) + adapter.setBanners(it.dataOrNull.orEmpty()) + onSubmitList() + } +} + +context(BaseFragment) +fun PromotionBannersMixin.bind( + view: BannerPagerView +) { + view.setClosable(false) + + bannersFlow.observe { + view.setLoadingState(it.isLoading) + view.setBanners(it.dataOrNull.orEmpty()) + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/source/BannersSource.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/source/BannersSource.kt new file mode 100644 index 0000000..ca07ff2 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/source/BannersSource.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_banners_api.presentation.source + +import io.novafoundation.nova.feature_banners_api.BuildConfig +import io.novafoundation.nova.feature_banners_api.domain.PromotionBanner +import kotlinx.coroutines.flow.Flow + +interface BannersSourceFactory { + fun create(bannersUrl: String, localisationUrl: String): BannersSource +} + +interface BannersSource { + fun observeBanners(): Flow> +} + +fun BannersSourceFactory.forDirectory(directory: String): BannersSource { + val baseDirectory = "${BuildConfig.BANNERS_BASE_DIRECTORY}/$directory" + val suffix = if (BuildConfig.DEBUG) "_dev" else "" + val bannersUrl = "$baseDirectory/banners$suffix.json" + val localisationsUrl = "$baseDirectory/localized$suffix" + + return create(bannersUrl, localisationsUrl) +} + +fun BannersSourceFactory.dappsSource() = create(BuildConfig.DAPPS_BANNERS_URL, BuildConfig.DAPPS_BANNERS_LOCALISATION_URL) + +fun BannersSourceFactory.assetsSource() = create(BuildConfig.ASSETS_BANNERS_URL, BuildConfig.ASSETS_BANNERS_LOCALISATION_URL) diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/BannerPagerScrollController.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/BannerPagerScrollController.kt new file mode 100644 index 0000000..aca443a --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/BannerPagerScrollController.kt @@ -0,0 +1,248 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view + +import android.content.Context +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.animation.DecelerateInterpolator +import android.widget.Scroller +import io.novafoundation.nova.common.utils.dp +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.min + +private const val MIN_FLING_VELOCITY = 1000 // px/s + +enum class PageOffset(val scrollDirection: Int, val pageOffset: Int) { + NEXT(-1, 1), PREVIOUS(1, -1), SAME(0, 0) +} + +/** + * BannerPagerScrollController tracks the banner scroll and calls a callback, passing a value from -1 to 1 depending on the scroll direction. + * Upon release, it animates the scroll to the selected page. + * + * - When the scroll animation ends, we replace the Scroller with a new one to reset its scroll value to 0. This is necessary to support infinite scrolling and to remain in the range from -1 to 1. + */ +class BannerPagerScrollController(private val context: Context, private val callback: ScrollCallback) { + + interface ScrollCallback { + fun onScrollToPage(pageOffset: Float, toPage: PageOffset) + + fun onScrollFinished(pageOffset: PageOffset) + + fun invalidateScroll() + } + + private val scrollTracking: ScrollTracking = ScrollTracking() + private var containerWidth = 0 + + private var isTouchable: Boolean = true + + val minimumScrollDuration = 200 + + fun setTouchable(touchable: Boolean) { + isTouchable = touchable + } + + fun setContainerWidth(width: Int) { + this.containerWidth = width + } + + fun onTouchEvent(event: MotionEvent): Boolean { + if (!isTouchable) return false + + return when (event.action) { + MotionEvent.ACTION_DOWN -> { + scrollTracking.onStartScroll(context, event.x) + scrollTracking.addMovement(event) + true + } + + MotionEvent.ACTION_MOVE -> { + scrollTracking.addMovement(event) + scrollTracking.updateLastX(event.x) + + val scrollDirection = scrollTracking.getPageOffset() + + callback.onScrollToPage(currentPageOffset(), scrollDirection) + + val isHorizontalScroll = scrollTracking.eventDx().absoluteValue > 8.dp(context) + isHorizontalScroll + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + scrollTracking.addMovement(event) + + val velocity = scrollTracking.getVelocity() + handleSwipe(scrollTracking.currentScroll(), velocity) + scrollTracking.recycle() + true + } + + else -> false + } + } + + fun computeScroll() { + if (scrollTracking.computeScroll()) { + val pageOffset = scrollTracking.getPageOffset() + callback.onScrollToPage(currentPageOffset(), pageOffset) + callback.invalidateScroll() + + if (scrollTracking.state == ScrollTracking.State.IDLE) { + callback.onScrollFinished(pageOffset) + scrollTracking.onFinishScroll() + } + } + } + + private fun handleSwipe(dx: Float, velocityX: Float) { + val isVelocityEnough = abs(velocityX) > MIN_FLING_VELOCITY + val isSwipeEnough = abs(dx) > containerWidth / 4 + + val shouldFling = isVelocityEnough || isSwipeEnough + + val pageOffset = when { + dx < 0 && shouldFling -> PageOffset.NEXT + dx > 0 && shouldFling -> PageOffset.PREVIOUS + else -> PageOffset.SAME + } + + smoothScrollToPage(pageOffset, velocityX) + } + + private fun smoothScrollToPage(page: PageOffset, velocityX: Float) { + val scrollWidth = page.scrollDirection * containerWidth + val duration = computeScrollDuration(abs(velocityX)) + scrollTracking.smoothScrollToPosition(context, scrollWidth, duration) + callback.invalidateScroll() + } + + private fun computeScrollDuration(velocityX: Float): Int { + val baseDuration = minimumScrollDuration + val maxDuration = 600 + return (baseDuration - min(velocityX / 2, baseDuration.toFloat())).toInt().coerceIn(150, maxDuration) + } + + private fun currentPageOffset(): Float { + return try { + val scrollOffset = scrollTracking.currentScroll() / containerWidth + + // We flip scroll to get page offset + -scrollOffset.coerceIn(-1f, 1f) + } catch (e: ArithmeticException) { + 0f + } + } + + fun swipeToPage(next: PageOffset) { + smoothScrollToPage(next, 0f) + } + + fun isIdle(): Boolean { + return scrollTracking.state == ScrollTracking.State.IDLE + } +} + +private class ScrollTracking { + + enum class State { + IDLE, SCROLLING, SCROLLING_RELEASED + } + + var state = State.IDLE + private set + + private var currentScroll = 0f + private var velocityTracker: VelocityTracker? = null + private var scroller: Scroller? = null + + private var eventDownX = 0f + private var eventLastX = 0f + + fun getVelocity(): Float { + velocityTracker?.computeCurrentVelocity(MIN_FLING_VELOCITY) + return velocityTracker?.xVelocity ?: return 0f + } + + fun addMovement(event: MotionEvent) { + velocityTracker?.addMovement(event) + } + + fun updateLastX(x: Float) { + val dx = x - eventLastX + currentScroll += dx + + eventLastX = x + } + + fun currentScroll(): Float { + return currentScroll + } + + fun eventDx(): Float { + return eventLastX - eventDownX + } + + fun computeScroll(): Boolean { + val scroller = scroller ?: return false + val scrollComputed = scroller.computeScrollOffset() + + currentScroll = scroller.currX.toFloat() + + if (scrollComputed && scroller.isFinished) { // Call only once when scroll is finished + state = State.IDLE + } + + return scrollComputed + } + + fun onStartScroll(context: Context, eventX: Float) { + scroller?.forceFinished(true) + + if (state == State.IDLE) { + state = State.SCROLLING + currentScroll = 0f + } + + eventDownX = eventX + eventLastX = eventX + + velocityTracker = VelocityTracker.obtain() + createScroller(context) + } + + fun onFinishScroll() { + currentScroll = 0f + scroller = null + eventDownX = 0f + eventLastX = 0f + } + + fun recycle() { + velocityTracker?.recycle() + } + + fun smoothScrollToPosition(context: Context, toPosition: Int, duration: Int) { + if (state == State.IDLE) { + createScroller(context) + } + + state = State.SCROLLING_RELEASED + val currentPosition = currentScroll.toInt() + scroller?.startScroll(currentPosition, 0, toPosition - currentPosition, 0, duration) + } + + fun getPageOffset(): PageOffset { + val currentScroll = currentScroll.toInt() + + return when { + currentScroll < 0 -> PageOffset.NEXT + currentScroll > 0 -> PageOffset.PREVIOUS + else -> PageOffset.SAME + } + } + + private fun createScroller(context: Context) { + scroller = Scroller(context, DecelerateInterpolator()) + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/BannerPagerView.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/BannerPagerView.kt new file mode 100644 index 0000000..c69177e --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/BannerPagerView.kt @@ -0,0 +1,266 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view + +import android.animation.ValueAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.animation.DecelerateInterpolator +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.animation.doOnEnd +import androidx.core.view.isVisible +import io.novafoundation.nova.common.utils.ViewClickGestureDetector +import io.novafoundation.nova.common.utils.indexOfOrNull +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_banners_api.databinding.ViewPagerBannerBinding +import io.novafoundation.nova.feature_banners_api.presentation.BannerPageModel +import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.ContentSwitchingController +import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.getContentSwitchingController +import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.getImageSwitchingController +import kotlin.math.absoluteValue +import kotlin.time.Duration.Companion.seconds + +/** + * View for viewing banner pages, supporting infinite scrolling + * BannerPagerScrollController tracks banner scrolling and triggers a callback, passing a value from -1 to 1 depending on the scroll direction + * Animates the scroll to the selected page upon release + */ +class BannerPagerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), BannerPagerScrollController.ScrollCallback { + + private val binder = ViewPagerBannerBinding.inflate(inflater(), this) + + private val scrollInterpolator = DecelerateInterpolator() + + private val scrollController = BannerPagerScrollController(context, this) + + private val gestureDetector = ViewClickGestureDetector(this) + + private var currentPage = 0 + private var pages: MutableList = mutableListOf() + + private val canScroll: Boolean + get() = !closeAnimator.isRunning && pages.size > 1 + + private val canRunScrollAnimation: Boolean + get() = canScroll && scrollController.isIdle() + + private val contentController = getContentSwitchingController(scrollInterpolator) + + private val backgroundSwitchingController = getImageSwitchingController(scrollInterpolator) + + private var autoSwipeCallbackAdded = false + private val autoSwipeDelay = 3.seconds.inWholeMilliseconds + private val autoSwipeCallback = object : Runnable { + override fun run() { + if (canRunScrollAnimation) { + scrollController.swipeToPage(PageOffset.NEXT) + handler?.postDelayed(this, autoSwipeDelay) + } + } + } + + private var callback: Callback? = null + + private val closeAnimator = ValueAnimator().apply { + interpolator = scrollInterpolator + duration = scrollController.minimumScrollDuration.toLong() + } + + val isClosable: Boolean + get() = binder.pagerBannerClose.isVisible + + init { + contentController.attachToParent(binder.pagerBannerContent) + backgroundSwitchingController.attachToParent(binder.pagerBannerBackground) + + binder.pagerBannerClose.setOnClickListener { closeCurrentPage() } + } + + fun setLoadingState(loading: Boolean) { + binder.pagerBannerShimmering.isVisible = loading + binder.pagerBannerCardView.isVisible = !loading + binder.pagerBannerIndicators.isVisible = !loading + binder.pagerBannerContent.isVisible = !loading + } + + fun setBanners(banners: List) { + val newIds = banners.mapToSet { it.id } + val currentIds = pages.mapToSet { it.id } + if (newIds == currentIds) return // Check that pages not changed + + this.pages.clear() + this.pages.addAll(banners) + binder.pagerBannerIndicators.setPagesSize(pages.size) + + contentController.setPayloads(pages.map { ContentSwitchingController.Payload(it.title, it.subtitle, it.image) }) + backgroundSwitchingController.setPayloads(pages.map { it.background }) + + if (pages.isNotEmpty()) { + selectPageImmediately(pages.first()) + rerunAutoSwipe() + } + } + + fun setClosable(closable: Boolean) { + binder.pagerBannerClose.isVisible = closable + } + + fun closeCurrentPage() { + if (pages.size == 1) { + callback?.onBannerClosed(pages.first()) + + return // Don't run animation to let close banner from outside + } + + if (isClosable && canRunScrollAnimation) { + val isLastPageAfterClose = pages.size == 2 // 2 pages befo + val nextPage = (currentPage + 1).wrapPage() + + scrollController.setTouchable(false) + stopAutoSwipe() + + closeAnimator.removeAllListeners() + closeAnimator.removeAllUpdateListeners() + + closeAnimator.setFloatValues(0f, 1f) + closeAnimator.addUpdateListener { + if (isLastPageAfterClose) { + binder.pagerBannerIndicators.alpha = 1f - it.animatedFraction + } else { + binder.pagerBannerIndicators.setCloseAnimationProgress(it.animatedFraction, currentPage, nextPage) + } + + contentController.setAnimationState(it.animatedFraction, currentPage, nextPage) + backgroundSwitchingController.setAnimationState(it.animatedFraction, currentPage, nextPage) + } + closeAnimator.doOnEnd { + closePage(currentPage) + invalidateScrolling() + startAutoSwipe() + } + closeAnimator.start() + } + } + + fun setCallback(callback: Callback?) { + this.callback = callback + } + + private fun closePage(index: Int) { + contentController.removePageAt(index) + backgroundSwitchingController.removePageAt(index) + val closedPage = pages.removeAt(index) + currentPage = index.wrapPage() + binder.pagerBannerIndicators.setPagesSize(pages.size) + binder.pagerBannerIndicators.selectIndicatorInstantly(currentPage) + contentController.showPageImmediately(currentPage) + backgroundSwitchingController.showPageImmediately(currentPage) + + callback?.onBannerClosed(closedPage) + } + + private fun invalidateScrolling() { + scrollController.setTouchable(pages.size > 1) + } + + override fun onScrollToPage(pageOffset: Float, toPage: PageOffset) { + if (!canScroll) return + + val nextPage = (currentPage + toPage.pageOffset).wrapPage() + + binder.pagerBannerIndicators.setAnimationProgress(pageOffset.absoluteValue, currentPage, nextPage) + + contentController.setAnimationState(pageOffset, currentPage, nextPage) + backgroundSwitchingController.setAnimationState(pageOffset, currentPage, nextPage) + } + + override fun onScrollFinished(pageOffset: PageOffset) { + this.currentPage = (this.currentPage + pageOffset.pageOffset).wrapPage() + } + + override fun invalidateScroll() { + invalidate() + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + gestureDetector.onTouchEvent(event) + + if (event.action == MotionEvent.ACTION_UP) { + startAutoSwipe() + } else { + stopAutoSwipe() + } + + val isScrollIntercepted = scrollController.onTouchEvent(event) + parent.requestDisallowInterceptTouchEvent(isScrollIntercepted) + return isScrollIntercepted + } + + override fun performClick(): Boolean { + callback?.onBannerClicked(pages[currentPage]) + return true + } + + override fun computeScroll() { + scrollController.computeScroll() + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + scrollController.setContainerWidth(width) + } + + private fun selectPageImmediately(page: BannerPageModel) { + val index = pages.indexOfOrNull(page) ?: return + + contentController.showPageImmediately(index) + backgroundSwitchingController.showPageImmediately(index) + + binder.pagerBannerIndicators.selectIndicatorInstantly(index) + } + + private fun rerunAutoSwipe() { + stopAutoSwipe() + startAutoSwipe() + } + + private fun stopAutoSwipe() { + if (autoSwipeCallbackAdded) { + removeCallbacks(autoSwipeCallback) + + autoSwipeCallbackAdded = false + } + } + + private fun startAutoSwipe() { + if (autoSwipeCallbackAdded) return + if (!canScroll) return + postDelayed(autoSwipeCallback, autoSwipeDelay) + + autoSwipeCallbackAdded = true + } + + private fun Int.wrapPage(): Int { + val min = 0 + val max = pages.size - 1 + if (max == 0) return 0 + if (max < 0) return this + + return when { + this > max -> 0 + this < min -> max + else -> this + } + } + + interface Callback { + + fun onBannerClicked(page: BannerPageModel) + + fun onBannerClosed(page: BannerPageModel) + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/PageIndicatorView.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/PageIndicatorView.kt new file mode 100644 index 0000000..347aa40 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/PageIndicatorView.kt @@ -0,0 +1,157 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view + +import android.animation.ArgbEvaluator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.util.AttributeSet +import android.view.View +import androidx.annotation.ColorInt +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.getFromTheEndOrNull +import io.novafoundation.nova.common.utils.isLast +import io.novafoundation.nova.common.utils.isNotLast + +private const val NO_PAGE = -1 + +private class Indicator(var size: Float, var marginToNext: Float, @ColorInt var color: Int) + +class PageIndicatorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private var pagesCount = 0 + private val indicators = mutableListOf() + + private val indicatorColor = context.getColor(R.color.icon_inactive) + private val goneIndicatorColor = Color.TRANSPARENT + private val indicatorRadius = 3.dpF + private val indicatorWidth = indicatorRadius * 2 + private val indicatorMargin = 12.dpF + private val indicatorFullLength = 14.dpF + + private var indicatorsBoxWidth = 0f + + private val argbEvaluator = ArgbEvaluator() + + private val indicatorPaint = Paint(Paint.ANTI_ALIAS_FLAG) + .apply { + style = Paint.Style.STROKE + strokeWidth = indicatorWidth + strokeCap = Paint.Cap.ROUND + } + + fun setPagesSize(size: Int) { + pagesCount = size + indicators.clear() + if (size > 0) { + indicators.addAll(List(size) { Indicator(0f, indicatorMargin, indicatorColor) }) + selectIndicatorInstantly(0) + } + + invalidate() + } + + fun selectIndicatorInstantly(pageIndex: Int) { + setAnimationProgress(1f, fromPage = NO_PAGE, toPage = pageIndex) + } + + fun setAnimationProgress(offset: Float, fromPage: Int, toPage: Int) { + setAnimationProgressInternal(offset.coerceIn(0f, 1f), fromPage, toPage, removeFrom = false) + } + + fun setCloseAnimationProgress(offset: Float, closingPage: Int, nextPage: Int) { + setAnimationProgressInternal(offset.coerceIn(0f, 1f), closingPage, nextPage, removeFrom = true) + } + + private fun setAnimationProgressInternal(offset: Float, fromIndex: Int, toIndex: Int, removeFrom: Boolean) { + if (indicators.size <= 1) { + hideIndicators() + invalidate() + return + } + + clearIndicatorSizeParams() + increaseSizeForAnimationOffset(toIndex, offset) + decreaseSizeForAnimationOffset(fromIndex, offset, removeFrom) + + calculateIndicatorsBoxWidth() + invalidate() + } + + private fun calculateIndicatorsBoxWidth() { + var newIndicatorBoxWidth = 0f + newIndicatorBoxWidth += indicatorRadius * 2 // Add start and end radius of indicator + indicators.forEach { + newIndicatorBoxWidth += it.size + it.marginToNext + } + indicatorsBoxWidth = newIndicatorBoxWidth + } + + private fun increaseSizeForAnimationOffset(indicatorIndex: Int, offset: Float) { + val indicator = indicators.getOrNull(indicatorIndex) ?: return + indicator.size = offset * indicatorFullLength + + if (indicators.isNotLast(indicator)) { + indicator.marginToNext = indicatorMargin + } + } + + private fun decreaseSizeForAnimationOffset(indicatorIndex: Int, offset: Float, isRemovingAnimation: Boolean) { + val indicator = indicators.getOrNull(indicatorIndex) ?: return + + indicator.size = indicatorFullLength - offset * indicatorFullLength + + when { + indicators.isLast(indicator) && isRemovingAnimation -> { + val nextLastIndicator = indicators.getFromTheEndOrNull(1) + nextLastIndicator?.marginToNext = indicatorMargin - offset * indicatorMargin + indicator.marginToNext = 0f + } + + indicators.isNotLast(indicator) -> { + val marginToNext = if (isRemovingAnimation) indicatorMargin - offset * indicatorMargin else indicatorMargin + indicator.marginToNext = marginToNext + } + } + + val endColor = if (isRemovingAnimation) goneIndicatorColor else indicatorColor + indicator.color = argbEvaluator.evaluate(offset, indicatorColor, endColor) as Int + } + + private fun clearIndicatorSizeParams() { + indicators.forEach { + it.size = 0f + it.marginToNext = indicatorMargin + } + indicators.lastOrNull()?.marginToNext = 0f + } + + private fun hideIndicators() { + indicators.forEach { it.color = goneIndicatorColor } + } + + override fun onDraw(canvas: Canvas) { + val startSpace = (measuredWidth - indicatorsBoxWidth) / 2 + val startMarginIndicator = indicatorRadius + var lastEnd = startSpace + startMarginIndicator + + indicators.forEachIndexed { index, indicator -> + indicatorPaint.color = indicator.color + val start = lastEnd + lastEnd = start + indicator.size + canvas.drawLine( + start, + height / 2f, + lastEnd, + height / 2f, + indicatorPaint + ) + lastEnd += indicator.marginToNext + } + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/PageView.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/PageView.kt new file mode 100644 index 0000000..6d36c15 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/PageView.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view + +import android.content.Context +import android.graphics.Rect +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.utils.RoundCornersOutlineProvider +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_banners_api.databinding.ViewPagerBannerPageBinding + +class PageView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binder = ViewPagerBannerPageBinding.inflate(inflater(), this) + + val title: TextView + get() = binder.pagerBannerTitle + + val subtitle: TextView + get() = binder.pagerBannerSubtitle + + val image: ImageView + get() = binder.pagerBannerImage + + private val roundCornersOutlineProvider = RoundCornersOutlineProvider(12.dpF) + + init { + outlineProvider = roundCornersOutlineProvider + clipToOutline = true + } + + fun setClipMargin(rect: Rect) { + roundCornersOutlineProvider.setMargin(rect) + invalidateOutline() + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/ContentSwitchingController.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/ContentSwitchingController.kt new file mode 100644 index 0000000..e182132 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/ContentSwitchingController.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view.switcher + +import android.graphics.Rect +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.feature_banners_api.presentation.ClipableImage +import io.novafoundation.nova.feature_banners_api.presentation.view.PageView + +class ContentSwitchingController( + private val clipMargin: Rect, + rightSwitchingAnimators: InOutAnimators, + leftSwitchingAnimators: InOutAnimators, + private val viewFactory: () -> PageView +) : SwitchingController( + rightSwitchingAnimators = rightSwitchingAnimators, + leftSwitchingAnimators = leftSwitchingAnimators +) { + + class Payload(val title: String, val subtitle: String, val clipableImage: ClipableImage) + + override fun setPayloadsInternal(payloads: List): List { + return payloads.map { payload -> + val view = viewFactory() + view.title.text = payload.title + view.subtitle.text = payload.subtitle + + view.layoutParams = ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.MATCH_PARENT, ConstraintLayout.LayoutParams.MATCH_PARENT) + + val imageModel = payload.clipableImage + if (view.image.drawable != imageModel.drawable) { + view.image.setImageDrawable(imageModel.drawable) + } + + if (imageModel.clip) { + view.setClipMargin(clipMargin) + } else { + view.setClipMargin(Rect()) + } + + view + } + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/ImageSwitchingController.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/ImageSwitchingController.kt new file mode 100644 index 0000000..e68ec1e --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/ImageSwitchingController.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view.switcher + +import android.graphics.drawable.Drawable +import android.widget.ImageView + +class ImageSwitchingController( + rightSwitchingAnimators: InOutAnimators, + leftSwitchingAnimators: InOutAnimators, + private val imageViewFactory: () -> ImageView +) : SwitchingController(rightSwitchingAnimators = rightSwitchingAnimators, leftSwitchingAnimators = leftSwitchingAnimators) { + + override fun setPayloadsInternal(payloads: List): List { + return payloads.map { + val image = imageViewFactory() + image.setImageDrawable(it) + image + } + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/SwitchersSetup.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/SwitchersSetup.kt new file mode 100644 index 0000000..a54b649 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/SwitchersSetup.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view.switcher + +import android.graphics.Rect +import android.view.ViewGroup +import android.view.animation.Interpolator +import android.widget.ImageView +import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.feature_banners_api.presentation.view.BannerPagerView +import io.novafoundation.nova.feature_banners_api.presentation.view.PageView +import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.AlphaInterpolatedAnimator +import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.CompoundInterpolatedAnimator +import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.FractionAnimator +import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.InterpolationRange +import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.OffsetXInterpolatedAnimator + +private const val OFFSET = 36 + +fun BannerPagerView.getImageSwitchingController(interpolator: Interpolator): ImageSwitchingController { + return ImageSwitchingController( + rightSwitchingAnimators = alphaAnimator(interpolator), + leftSwitchingAnimators = alphaAnimator(interpolator), + imageViewFactory = { + ImageView(context).apply { + scaleType = ImageView.ScaleType.FIT_XY + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + } + ) +} + +private fun alphaAnimator(interpolator: Interpolator): InOutAnimators { + return InOutAnimators( + inAnimator = AlphaInterpolatedAnimator(interpolator, InterpolationRange(0f, 1f)), + outAnimator = AlphaInterpolatedAnimator(interpolator, InterpolationRange(1f, 0f)) + ) +} + +fun BannerPagerView.getContentSwitchingController(interpolator: Interpolator): ContentSwitchingController { + return ContentSwitchingController( + clipMargin = Rect(0, 8.dp, 0, 8.dp), + rightSwitchingAnimators = getRightAnimator(interpolator), + leftSwitchingAnimators = getLeftAnimator(interpolator), + viewFactory = { + PageView(context).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + ) +} + +private fun BannerPagerView.getRightAnimator(interpolator: Interpolator) = InOutAnimators( + inAnimator = getContentAnimator( + interpolator = interpolator, + offsetRange = InterpolationRange(from = OFFSET.dpF, to = 0f), + alphaRange = InterpolationRange(from = 0f, to = 1f) + ), + outAnimator = getContentAnimator( + interpolator = interpolator, + offsetRange = InterpolationRange(from = 0f, to = -OFFSET.dpF), + alphaRange = InterpolationRange(from = 1f, to = 0f) + ) +) + +private fun BannerPagerView.getLeftAnimator(interpolator: Interpolator) = InOutAnimators( + inAnimator = getContentAnimator( + interpolator = interpolator, + offsetRange = InterpolationRange(from = -OFFSET.dpF, to = 0f), + alphaRange = InterpolationRange(from = 0f, to = 1f) + ), + outAnimator = getContentAnimator( + interpolator = interpolator, + offsetRange = InterpolationRange(from = 0f, to = OFFSET.dpF), + alphaRange = InterpolationRange(from = 1f, to = 0f) + ) +) + +private fun getContentAnimator(interpolator: Interpolator, offsetRange: InterpolationRange, alphaRange: InterpolationRange): FractionAnimator { + return CompoundInterpolatedAnimator( + OffsetXInterpolatedAnimator(interpolator, offsetRange), + AlphaInterpolatedAnimator(interpolator, alphaRange) + ) +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/SwitchingController.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/SwitchingController.kt new file mode 100644 index 0000000..f141fd3 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/SwitchingController.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view.switcher + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isInvisible +import io.novafoundation.nova.common.utils.removed +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation.FractionAnimator + +class InOutAnimators( + val inAnimator: FractionAnimator, + val outAnimator: FractionAnimator +) + +abstract class SwitchingController( + private val rightSwitchingAnimators: InOutAnimators, + private val leftSwitchingAnimators: InOutAnimators +) { + + private var parent: ViewGroup? = null + private var views: List = listOf() + + protected abstract fun setPayloadsInternal(payloads: List

): List + + fun setPayloads(payloads: List

): List { + views = setPayloadsInternal(payloads) + setViewsToParent() + return views + } + + fun attachToParent(parent: ViewGroup) { + this.parent = parent + setViewsToParent() + } + + fun setAnimationState(animationOffset: Float, from: Int, to: Int) { + if (from >= views.size || to >= views.size) return + + if (from == to) { + showPageImmediately(from) + } else { + val (currentView, nextView) = showPagesByIndex(from, to) + val animators = when { + animationOffset > 0 -> rightSwitchingAnimators + else -> leftSwitchingAnimators + } + + animators.outAnimator.animate(currentView, animationOffset) + animators.inAnimator.animate(nextView, animationOffset) + } + } + + fun showPageImmediately(index: Int) { + if (index >= views.size) return + + val (page) = showPagesByIndex(index) + rightSwitchingAnimators.outAnimator.animate(page, 0f) + } + + // Pay attention that after using this method removed view is still contains in its parents + // We do it this way to have the same size of banners after remove a page + fun removePageAt(pageToRemove: Int) { + val removedView = views[pageToRemove] + views = views.removed { removedView == it } + removedView.isInvisible = true + } + + private fun showPagesByIndex(vararg indexes: Int): List { + views.forEachIndexed { index, view -> + view.setVisible(index in indexes, falseState = View.INVISIBLE) + } + + return indexes.map { views[it] } + } + + private fun setViewsToParent() { + parent?.removeAllViews() + views.forEach { + parent?.addView(it) + } + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/AlphaInterpolatedAnimator.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/AlphaInterpolatedAnimator.kt new file mode 100644 index 0000000..fc83df4 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/AlphaInterpolatedAnimator.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation + +import android.view.View +import android.view.animation.Interpolator + +class AlphaInterpolatedAnimator( + interpolator: Interpolator, + val alphaRange: InterpolationRange +) : InterpolatedAnimator(interpolator) { + + override fun animateInternal(view: View, fraction: Float) { + view.alpha = alphaRange.getValueFor(fraction) + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/CompoundInterpolatedAnimator.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/CompoundInterpolatedAnimator.kt new file mode 100644 index 0000000..a4b5606 --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/CompoundInterpolatedAnimator.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation + +import android.view.View + +class CompoundInterpolatedAnimator( + private val animators: List +) : FractionAnimator { + + constructor(vararg animators: FractionAnimator) : this(animators.toList()) + + override fun animate(view: View, fraction: Float) { + animators.forEach { it.animate(view, fraction) } + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/InterpolatedAnimator.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/InterpolatedAnimator.kt new file mode 100644 index 0000000..f76e94f --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/InterpolatedAnimator.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation + +import android.view.View +import android.view.animation.Interpolator +import io.novafoundation.nova.common.utils.signum +import kotlin.math.absoluteValue + +interface FractionAnimator { + + fun animate(view: View, fraction: Float) +} + +abstract class InterpolatedAnimator( + private val interpolator: Interpolator +) : FractionAnimator { + + override fun animate(view: View, fraction: Float) { + val interpolatedValue = interpolator.getInterpolation(fraction.absoluteValue) * fraction.signum() + animateInternal(view, interpolatedValue) + } + + protected abstract fun animateInternal(view: View, fraction: Float) +} + +class InterpolationRange(val from: Float, val to: Float) { + + fun getValueFor(fraction: Float): Float { + return from + (to - from) * fraction.absoluteValue + } +} diff --git a/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/OffsetXInterpolatedAnimator.kt b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/OffsetXInterpolatedAnimator.kt new file mode 100644 index 0000000..1c42cfe --- /dev/null +++ b/feature-banners-api/src/main/java/io/novafoundation/nova/feature_banners_api/presentation/view/switcher/animation/OffsetXInterpolatedAnimator.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_banners_api.presentation.view.switcher.animation + +import android.view.View +import android.view.animation.Interpolator + +class OffsetXInterpolatedAnimator( + interpolator: Interpolator, + private val offsetRange: InterpolationRange, +) : InterpolatedAnimator(interpolator) { + + override fun animateInternal(view: View, fraction: Float) { + view.translationX = offsetRange.getValueFor(fraction) + } +} diff --git a/feature-banners-api/src/main/res/layout/item_promotion_banner.xml b/feature-banners-api/src/main/res/layout/item_promotion_banner.xml new file mode 100644 index 0000000..e68c410 --- /dev/null +++ b/feature-banners-api/src/main/res/layout/item_promotion_banner.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/feature-banners-api/src/main/res/layout/view_pager_banner.xml b/feature-banners-api/src/main/res/layout/view_pager_banner.xml new file mode 100644 index 0000000..280c7d9 --- /dev/null +++ b/feature-banners-api/src/main/res/layout/view_pager_banner.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-banners-api/src/main/res/layout/view_pager_banner_page.xml b/feature-banners-api/src/main/res/layout/view_pager_banner_page.xml new file mode 100644 index 0000000..5bb45bc --- /dev/null +++ b/feature-banners-api/src/main/res/layout/view_pager_banner_page.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-banners-impl/.gitignore b/feature-banners-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-banners-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-banners-impl/build.gradle b/feature-banners-impl/build.gradle new file mode 100644 index 0000000..9ec0017 --- /dev/null +++ b/feature-banners-impl/build.gradle @@ -0,0 +1,32 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_banners_impl' + + defaultConfig { + + + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation coroutinesDep + implementation project(":common") + implementation project(':feature-banners-api') + + implementation cardViewDep + implementation recyclerViewDep + implementation materialDep + implementation androidDep + + implementation daggerDep + ksp daggerCompiler + + implementation androidDep + + testImplementation project(':test-shared') +} \ No newline at end of file diff --git a/feature-banners-impl/consumer-rules.pro b/feature-banners-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-banners-impl/proguard-rules.pro b/feature-banners-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-banners-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-banners-impl/src/main/AndroidManifest.xml b/feature-banners-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-banners-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/data/BannerResponse.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/data/BannerResponse.kt new file mode 100644 index 0000000..d6577c5 --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/data/BannerResponse.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_banners_impl.data + +class BannerResponse( + val id: String, + val background: String, + val image: String, + val clipsToBounds: Boolean, + val action: String? +) + +class BannerLocalisationResponse( + val title: String, + val details: String +) diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/data/BannersApi.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/data/BannersApi.kt new file mode 100644 index 0000000..8f38881 --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/data/BannersApi.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_banners_impl.data + +import io.novafoundation.nova.core.model.Language +import retrofit2.http.GET +import retrofit2.http.Url + +interface BannersApi { + + companion object { + fun getLocalisationLink(url: String, language: Language): String { + return "$url/${language.iso639Code}.json" + } + } + + @GET + suspend fun getBanners(@Url url: String): List + + @GET + suspend fun getBannersLocalisation(@Url url: String): Map +} diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/data/BannersRepository.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/data/BannersRepository.kt new file mode 100644 index 0000000..5dac462 --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/data/BannersRepository.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_banners_impl.data + +import retrofit2.HttpException +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.resources.LanguagesHolder +import io.novafoundation.nova.common.utils.scopeAsync +import io.novafoundation.nova.feature_banners_api.domain.PromotionBanner +import io.novafoundation.nova.core.model.Language +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface BannersRepository { + suspend fun getBanners(url: String, localisationUrl: String): List + + fun closeBanner(id: String) + + fun observeClosedBannerIds(): Flow> +} + +class RealBannersRepository( + private val preferences: Preferences, + private val bannersApi: BannersApi, + private val languagesHolder: LanguagesHolder +) : BannersRepository { + + companion object { + private const val PREFS_CLOSED_BANNERS = "closed_banners" + } + + override suspend fun getBanners(url: String, localisationUrl: String): List { + val language = preferences.getCurrentLanguage()!! + val bannersDeferred = scopeAsync { bannersApi.getBanners(url) } + val localisationDeferred = scopeAsync { getLocalisation(localisationUrl, language) } + + val banners = bannersDeferred.await() + val localisation = localisationDeferred.await() + + return mapBanners(banners, localisation) + } + + override fun observeClosedBannerIds(): Flow> { + return preferences.stringSetFlow(PREFS_CLOSED_BANNERS) + .map { it.orEmpty() } + } + + private fun mapBanners( + banners: List, + localisation: Map + ) = banners.mapNotNull { + val localisationBanner = localisation[it.id] ?: return@mapNotNull null + + PromotionBanner( + id = it.id, + title = localisationBanner.title, + details = localisationBanner.details, + backgroundUrl = it.background, + imageUrl = it.image, + clipToBounds = it.clipsToBounds, + actionLink = it.action + ) + } + + private suspend fun getLocalisation(url: String, language: Language): Map { + try { + val localisationUrl = BannersApi.getLocalisationLink(url, language) + return bannersApi.getBannersLocalisation(localisationUrl) + } catch (e: HttpException) { + val fallbackLanguage = languagesHolder.getDefaultLanguage() + if (e.code() == 404 && language != fallbackLanguage) { + val fallbackUrl = BannersApi.getLocalisationLink(url, fallbackLanguage) + return bannersApi.getBannersLocalisation(fallbackUrl) + } + + throw e + } + } + + override fun closeBanner(id: String) { + val closedBannersId = preferences.getStringSet(PREFS_CLOSED_BANNERS) + preferences.putStringSet(PREFS_CLOSED_BANNERS, closedBannersId + id) + } +} diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureComponent.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureComponent.kt new file mode 100644 index 0000000..4d2b423 --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureComponent.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_banners_impl.di + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi + +@Component( + dependencies = [ + BannersFeatureDependencies::class, + ], + modules = [ + BannersFeatureModule::class + ] +) +@FeatureScope +interface BannersFeatureComponent : BannersFeatureApi { + + @Component.Factory + interface Factory { + + fun create(deps: BannersFeatureDependencies): BannersFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class + ] + ) + interface BannersFeatureDependenciesComponent : BannersFeatureDependencies +} diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureDependencies.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureDependencies.kt new file mode 100644 index 0000000..88c89db --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureDependencies.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_banners_impl.di + +import android.content.Context +import coil.ImageLoader +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.resources.LanguagesHolder +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate + +interface BannersFeatureDependencies { + + val imageLoader: ImageLoader + + val context: Context + + val preferences: Preferences + + val languagesHolder: LanguagesHolder + + val networkApiCreator: NetworkApiCreator + + val automaticInteractionGate: AutomaticInteractionGate +} diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureHolder.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureHolder.kt new file mode 100644 index 0000000..1e2d14c --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureHolder.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_banners_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope + +import javax.inject.Inject + +@ApplicationScope +class BannersFeatureHolder @Inject constructor( + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerBannersFeatureComponent_BannersFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .build() + + return DaggerBannersFeatureComponent.factory() + .create(deps = accountFeatureDependencies) + } +} diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureModule.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureModule.kt new file mode 100644 index 0000000..eebafb1 --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/di/BannersFeatureModule.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_banners_impl.di + +import android.content.Context +import coil.ImageLoader +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.LanguagesHolder +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_banners_impl.data.BannersApi +import io.novafoundation.nova.feature_banners_impl.data.BannersRepository +import io.novafoundation.nova.feature_banners_impl.data.RealBannersRepository +import io.novafoundation.nova.feature_banners_impl.domain.PromotionBannersInteractor +import io.novafoundation.nova.feature_banners_impl.domain.RealPromotionBannersInteractor +import io.novafoundation.nova.feature_banners_impl.presentation.banner.RealPromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_impl.presentation.banner.source.RealBannersSourceFactory + +@Module() +class BannersFeatureModule { + + @Provides + @FeatureScope + fun provideBannersApi(networkApiCreator: NetworkApiCreator): BannersApi { + return networkApiCreator.create(BannersApi::class.java) + } + + @Provides + @FeatureScope + fun provideBannersRepository( + preferences: Preferences, + bannersApi: BannersApi, + languagesHolder: LanguagesHolder + ): BannersRepository { + return RealBannersRepository(preferences, bannersApi, languagesHolder) + } + + @Provides + @FeatureScope + fun provideBannersInteractor( + repository: BannersRepository + ): PromotionBannersInteractor { + return RealPromotionBannersInteractor(repository) + } + + @Provides + @FeatureScope + fun sourceFactory(promotionBannersInteractor: PromotionBannersInteractor): BannersSourceFactory { + return RealBannersSourceFactory(promotionBannersInteractor) + } + + @Provides + @FeatureScope + fun providePromotionBannersMixinFactory( + promotionBannersInteractor: PromotionBannersInteractor, + imageLoader: ImageLoader, + context: Context + ): PromotionBannersMixinFactory { + return RealPromotionBannersMixinFactory(imageLoader, context, promotionBannersInteractor) + } +} diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/domain/RealPromotionBannersInteractor.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/domain/RealPromotionBannersInteractor.kt new file mode 100644 index 0000000..284ac7c --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/domain/RealPromotionBannersInteractor.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_banners_impl.domain + +import io.novafoundation.nova.feature_banners_api.domain.PromotionBanner +import io.novafoundation.nova.feature_banners_impl.data.BannersRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface PromotionBannersInteractor { + + suspend fun observeBanners(url: String, localisationUrl: String): Flow> + + fun closeBanner(id: String) +} + +class RealPromotionBannersInteractor( + private val bannersRepository: BannersRepository, +) : PromotionBannersInteractor { + + override suspend fun observeBanners(url: String, localisationUrl: String): Flow> { + val banners = bannersRepository.getBanners(url, localisationUrl) + return bannersRepository.observeClosedBannerIds() + .map { closedIds -> + banners.filter { it.id !in closedIds } + } + } + + override fun closeBanner(id: String) { + bannersRepository.closeBanner(id) + } +} diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/presentation/banner/PromotionBannersMixin.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/presentation/banner/PromotionBannersMixin.kt new file mode 100644 index 0000000..58bf9e1 --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/presentation/banner/PromotionBannersMixin.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_banners_impl.presentation.banner + +import android.content.Context +import android.graphics.drawable.Drawable +import coil.ImageLoader +import coil.request.ImageRequest +import io.novafoundation.nova.common.utils.launchDeepLink +import io.novafoundation.nova.feature_banners_api.domain.PromotionBanner +import io.novafoundation.nova.common.utils.scopeAsync +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_banners_api.presentation.BannerPageModel +import io.novafoundation.nova.feature_banners_api.presentation.ClipableImage +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixin +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSource +import io.novafoundation.nova.feature_banners_impl.domain.PromotionBannersInteractor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map + +class RealPromotionBannersMixinFactory( + private val imageLoader: ImageLoader, + private val context: Context, + private val promotionBannersInteractor: PromotionBannersInteractor +) : PromotionBannersMixinFactory { + + override fun create(source: BannersSource, coroutineScope: CoroutineScope): PromotionBannersMixin { + return RealPromotionBannersMixin( + promotionBannersInteractor, + imageLoader, + context, + source, + coroutineScope + ) + } +} + +class RealPromotionBannersMixin( + private val promotionBannersInteractor: PromotionBannersInteractor, + private val imageLoader: ImageLoader, + private val context: Context, + private val bannersSource: BannersSource, + coroutineScope: CoroutineScope +) : PromotionBannersMixin, CoroutineScope by coroutineScope { + + override val bannersFlow = bannersSource.observeBanners() + .map { banners -> + val resources = loadResources(banners) + banners.map { mapBanner(it, resources) } + }.withSafeLoading() + .shareInBackground() + + override fun closeBanner(banner: BannerPageModel) { + promotionBannersInteractor.closeBanner(banner.id) + } + + override fun startBannerAction(page: BannerPageModel) { + val url = page.actionUrl ?: return + + context.launchDeepLink(url) + } + + private suspend fun loadResources(banners: List): Map { + val imagesSet = buildSet { + addAll(banners.map { it.imageUrl }) + addAll(banners.map { it.backgroundUrl }) + } + + val loadingImagesResult = imagesSet.associateWith { + val imageRequest = ImageRequest.Builder(context) + .data(it) + .build() + + scopeAsync { imageLoader.execute(imageRequest) } + } + + return loadingImagesResult.mapValues { (_, value) -> value.await().drawable!! } + } + + private fun mapBanner( + banner: PromotionBanner, + resources: Map + ): BannerPageModel { + return BannerPageModel( + id = banner.id, + title = banner.title, + subtitle = banner.details, + image = ClipableImage( + resources.getValue(banner.imageUrl), + banner.clipToBounds + ), + background = resources.getValue(banner.backgroundUrl), + actionUrl = banner.actionLink + ) + } +} diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/presentation/banner/source/RealBannersSource.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/presentation/banner/source/RealBannersSource.kt new file mode 100644 index 0000000..aa43a81 --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/presentation/banner/source/RealBannersSource.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_banners_impl.presentation.banner.source + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_banners_api.domain.PromotionBanner +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSource +import io.novafoundation.nova.feature_banners_impl.domain.PromotionBannersInteractor +import kotlinx.coroutines.flow.Flow + +class RealBannersSource( + private val bannersUrl: String, + private val localisationUrl: String, + private val bannersInteractor: PromotionBannersInteractor +) : BannersSource { + + override fun observeBanners(): Flow> { + return flowOfAll { bannersInteractor.observeBanners(bannersUrl, localisationUrl) } + } +} diff --git a/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/presentation/banner/source/RealBannersSourceFactory.kt b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/presentation/banner/source/RealBannersSourceFactory.kt new file mode 100644 index 0000000..bd6d33c --- /dev/null +++ b/feature-banners-impl/src/main/java/io/novafoundation/nova/feature_banners_impl/presentation/banner/source/RealBannersSourceFactory.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_banners_impl.presentation.banner.source + +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSource +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_banners_impl.domain.PromotionBannersInteractor + +class RealBannersSourceFactory( + private val bannersInteractor: PromotionBannersInteractor +) : BannersSourceFactory { + + override fun create(bannersUrl: String, localisationUrl: String): BannersSource { + return RealBannersSource(bannersUrl, localisationUrl, bannersInteractor) + } +} diff --git a/feature-bridge-api/build.gradle b/feature-bridge-api/build.gradle new file mode 100644 index 0000000..39b78db --- /dev/null +++ b/feature-bridge-api/build.gradle @@ -0,0 +1,22 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_bridge_api' +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":feature-account-api") + implementation project(":feature-wallet-api") + implementation project(":common") + + implementation daggerDep + ksp daggerCompiler + + implementation substrateSdkDep + + api project(':core-api') + + testImplementation project(':test-shared') +} diff --git a/feature-bridge-api/src/main/AndroidManifest.xml b/feature-bridge-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/feature-bridge-api/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/di/BridgeFeatureApi.kt b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/di/BridgeFeatureApi.kt new file mode 100644 index 0000000..5d50190 --- /dev/null +++ b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/di/BridgeFeatureApi.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_bridge_api.di + +import io.novafoundation.nova.feature_bridge_api.domain.model.BridgeConfig + +interface BridgeFeatureApi { + + val bridgeConfig: BridgeConfig +} diff --git a/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/domain/model/BridgeConfig.kt b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/domain/model/BridgeConfig.kt new file mode 100644 index 0000000..5697cfb --- /dev/null +++ b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/domain/model/BridgeConfig.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_bridge_api.domain.model + +import java.math.BigDecimal + +/** + * wUSDT Bridge Configuration + * + * This bridge enables 1:1 backed wUSDT on Pezkuwi Asset Hub, + * backed by real USDT on Polkadot Asset Hub. + */ +data class BridgeConfig( + /** Bridge wallet address on Polkadot Asset Hub (for deposits) */ + val polkadotDepositAddress: String, + + /** Bridge wallet address on Pezkuwi Asset Hub */ + val pezkuwiAddress: String, + + /** USDT Asset ID on Polkadot Asset Hub */ + val polkadotUsdtAssetId: Int, + + /** wUSDT Asset ID on Pezkuwi Asset Hub */ + val pezkuwiWusdtAssetId: Int, + + /** Minimum deposit amount in USDT */ + val minDeposit: BigDecimal, + + /** Minimum withdrawal amount in USDT */ + val minWithdraw: BigDecimal, + + /** Bridge fee in basis points (e.g., 10 = 0.1%) */ + val feeBasisPoints: Int +) { + companion object { + val DEFAULT = BridgeConfig( + polkadotDepositAddress = "16dSTc3BexjQKiPta7yNncF8nio4YgDQiPbudHzkuh7XJi8K", + pezkuwiAddress = "5Hh9KGn7oBTvtBPNcUvNeTQyw6oQrNfGdtsRU11QMc618Rse", + polkadotUsdtAssetId = 1984, + pezkuwiWusdtAssetId = 1000, + minDeposit = BigDecimal("10"), + minWithdraw = BigDecimal("10"), + feeBasisPoints = 10 + ) + } + + /** Fee percentage as a human-readable string */ + val feePercentage: String + get() = "${feeBasisPoints.toDouble() / 100}%" + + /** Calculate fee for a given amount */ + fun calculateFee(amount: BigDecimal): BigDecimal { + return amount.multiply(BigDecimal(feeBasisPoints)).divide(BigDecimal(10000)) + } + + /** Calculate net amount after fee */ + fun calculateNetAmount(amount: BigDecimal): BigDecimal { + return amount.subtract(calculateFee(amount)) + } +} diff --git a/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/domain/model/BridgeStatus.kt b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/domain/model/BridgeStatus.kt new file mode 100644 index 0000000..f48486c --- /dev/null +++ b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/domain/model/BridgeStatus.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_bridge_api.domain.model + +import java.math.BigDecimal + +/** + * Bridge status showing backing ratio and reserves + */ +data class BridgeStatus( + /** Total USDT held in bridge wallet on Polkadot */ + val totalUsdtBacking: BigDecimal, + + /** Total wUSDT in circulation on Pezkuwi */ + val totalWusdtCirculating: BigDecimal, + + /** Bridge operational status */ + val isOperational: Boolean, + + /** Last sync timestamp */ + val lastSyncTimestamp: Long +) { + /** Backing ratio (should be >= 100%) */ + val backingRatio: BigDecimal + get() = if (totalWusdtCirculating > BigDecimal.ZERO) { + totalUsdtBacking.divide(totalWusdtCirculating, 4, java.math.RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + } else { + BigDecimal(100) + } + + /** Reserve (excess USDT in bridge, i.e., collected fees) */ + val reserve: BigDecimal + get() = totalUsdtBacking.subtract(totalWusdtCirculating).coerceAtLeast(BigDecimal.ZERO) + + /** Is the bridge fully backed? */ + val isFullyBacked: Boolean + get() = backingRatio >= BigDecimal(100) +} diff --git a/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/domain/model/BridgeTransaction.kt b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/domain/model/BridgeTransaction.kt new file mode 100644 index 0000000..6b07573 --- /dev/null +++ b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/domain/model/BridgeTransaction.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_bridge_api.domain.model + +import java.math.BigDecimal + +enum class BridgeTransactionType { + DEPOSIT, // Polkadot USDT -> Pezkuwi wUSDT + WITHDRAW // Pezkuwi wUSDT -> Polkadot USDT +} + +enum class BridgeTransactionStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED +} + +/** + * A bridge transaction (deposit or withdrawal) + */ +data class BridgeTransaction( + val id: String, + val type: BridgeTransactionType, + val status: BridgeTransactionStatus, + val amount: BigDecimal, + val fee: BigDecimal, + val netAmount: BigDecimal, + val sourceAddress: String, + val destinationAddress: String, + val sourceTxHash: String?, + val destinationTxHash: String?, + val createdAt: Long, + val completedAt: Long? +) diff --git a/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/presentation/BridgeRouter.kt b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/presentation/BridgeRouter.kt new file mode 100644 index 0000000..b87c6f7 --- /dev/null +++ b/feature-bridge-api/src/main/java/io/novafoundation/nova/feature_bridge_api/presentation/BridgeRouter.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_bridge_api.presentation + +interface BridgeRouter { + + fun openBridgeDeposit() + + fun openBridgeWithdraw() + + fun openBridgeStatus() + + fun back() +} diff --git a/feature-bridge-impl/build.gradle b/feature-bridge-impl/build.gradle new file mode 100644 index 0000000..3c3f49e --- /dev/null +++ b/feature-bridge-impl/build.gradle @@ -0,0 +1,41 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_bridge_impl' + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":feature-account-api") + implementation project(":feature-wallet-api") + implementation project(":feature-bridge-api") + implementation project(":common") + + implementation materialDep + + implementation daggerDep + ksp daggerCompiler + + implementation substrateSdkDep + + implementation androidDep + implementation constraintDep + + implementation lifeCycleKtxDep + implementation viewModelKtxDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + + // QR Code generation + implementation zXingCoreDep + + api project(':core-api') + + testImplementation project(':test-shared') +} diff --git a/feature-bridge-impl/src/main/AndroidManifest.xml b/feature-bridge-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/feature-bridge-impl/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/di/BridgeFeatureComponent.kt b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/di/BridgeFeatureComponent.kt new file mode 100644 index 0000000..a663ce4 --- /dev/null +++ b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/di/BridgeFeatureComponent.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_bridge_impl.di + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_bridge_api.di.BridgeFeatureApi +import io.novafoundation.nova.feature_bridge_impl.presentation.deposit.BridgeDepositFragment + +@Component( + dependencies = [ + BridgeFeatureDependencies::class + ], + modules = [ + BridgeFeatureModule::class + ] +) +@FeatureScope +interface BridgeFeatureComponent : BridgeFeatureApi { + + fun inject(fragment: BridgeDepositFragment) + + @Component.Factory + interface Factory { + fun create( + dependencies: BridgeFeatureDependencies + ): BridgeFeatureComponent + } + + @Component(dependencies = [CommonApi::class]) + interface BridgeFeatureDependenciesComponent : BridgeFeatureDependencies +} diff --git a/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/di/BridgeFeatureDependencies.kt b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/di/BridgeFeatureDependencies.kt new file mode 100644 index 0000000..3e0c0ca --- /dev/null +++ b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/di/BridgeFeatureDependencies.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_bridge_impl.di + +import android.content.Context + +interface BridgeFeatureDependencies { + + fun context(): Context +} diff --git a/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/di/BridgeFeatureModule.kt b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/di/BridgeFeatureModule.kt new file mode 100644 index 0000000..1501081 --- /dev/null +++ b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/di/BridgeFeatureModule.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_bridge_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_bridge_api.domain.model.BridgeConfig + +@Module +class BridgeFeatureModule { + + @Provides + @FeatureScope + fun provideBridgeConfig(): BridgeConfig = BridgeConfig.DEFAULT +} diff --git a/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/presentation/deposit/BridgeDepositFragment.kt b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/presentation/deposit/BridgeDepositFragment.kt new file mode 100644 index 0000000..baea0c8 --- /dev/null +++ b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/presentation/deposit/BridgeDepositFragment.kt @@ -0,0 +1,124 @@ +package io.novafoundation.nova.feature_bridge_impl.presentation.deposit + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Bitmap +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import io.novafoundation.nova.feature_bridge_api.domain.model.BridgeConfig +import io.novafoundation.nova.feature_bridge_impl.databinding.FragmentBridgeDepositBinding +import kotlinx.coroutines.launch + +/** + * Bridge Deposit Screen + * + * Shows the Polkadot Asset Hub address where users should send USDT + * to receive wUSDT on Pezkuwi Asset Hub. + */ +class BridgeDepositFragment : Fragment() { + + private var _binding: FragmentBridgeDepositBinding? = null + private val binding get() = _binding!! + + private val bridgeConfig = BridgeConfig.DEFAULT + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentBridgeDepositBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupUI() + generateQRCode() + } + + private fun setupUI() { + with(binding) { + // Set bridge address + bridgeAddressText.text = bridgeConfig.polkadotDepositAddress + + // Set info text + minDepositText.text = "Minimum: ${bridgeConfig.minDeposit} USDT" + feeText.text = "Fee: ${bridgeConfig.feePercentage}" + + // Copy button + copyAddressButton.setOnClickListener { + copyToClipboard(bridgeConfig.polkadotDepositAddress) + } + + // Address text click also copies + bridgeAddressText.setOnClickListener { + copyToClipboard(bridgeConfig.polkadotDepositAddress) + } + + // Back button + backButton.setOnClickListener { + requireActivity().onBackPressed() + } + } + } + + private fun generateQRCode() { + lifecycleScope.launch { + try { + val qrCodeWriter = QRCodeWriter() + val bitMatrix = qrCodeWriter.encode( + bridgeConfig.polkadotDepositAddress, + BarcodeFormat.QR_CODE, + 512, + 512 + ) + + val width = bitMatrix.width + val height = bitMatrix.height + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + + for (x in 0 until width) { + for (y in 0 until height) { + val color = if (bitMatrix.get(x, y)) { + android.graphics.Color.BLACK + } else { + android.graphics.Color.WHITE + } + bitmap.setPixel(x, y, color) + } + } + + binding.qrCodeImage.setImageBitmap(bitmap) + } catch (e: Exception) { + // QR code generation failed, hide the image + binding.qrCodeImage.visibility = View.GONE + } + } + } + + private fun copyToClipboard(text: String) { + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Bridge Address", text) + clipboard.setPrimaryClip(clip) + Toast.makeText(requireContext(), "Address copied!", Toast.LENGTH_SHORT).show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + fun newInstance() = BridgeDepositFragment() + } +} diff --git a/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/presentation/status/BridgeStatusFragment.kt b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/presentation/status/BridgeStatusFragment.kt new file mode 100644 index 0000000..c63dd36 --- /dev/null +++ b/feature-bridge-impl/src/main/java/io/novafoundation/nova/feature_bridge_impl/presentation/status/BridgeStatusFragment.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_bridge_impl.presentation.status + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import io.novafoundation.nova.feature_bridge_api.domain.model.BridgeConfig +import io.novafoundation.nova.feature_bridge_api.domain.model.BridgeStatus +import io.novafoundation.nova.feature_bridge_impl.databinding.FragmentBridgeStatusBinding +import java.math.BigDecimal + +/** + * Bridge Status Screen + * + * Shows the current bridge backing status, reserves, and transparency info. + */ +class BridgeStatusFragment : Fragment() { + + private var _binding: FragmentBridgeStatusBinding? = null + private val binding get() = _binding!! + + private val bridgeConfig = BridgeConfig.DEFAULT + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentBridgeStatusBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupUI() + loadStatus() + } + + private fun setupUI() { + with(binding) { + backButton.setOnClickListener { + requireActivity().onBackPressed() + } + } + } + + private fun loadStatus() { + // In production, this would fetch real data from the chain + // For now, show placeholder data + val status = BridgeStatus( + totalUsdtBacking = BigDecimal("0"), + totalWusdtCirculating = BigDecimal("0"), + isOperational = true, + lastSyncTimestamp = System.currentTimeMillis() + ) + + displayStatus(status) + } + + private fun displayStatus(status: BridgeStatus) { + with(binding) { + // Backing ratio + backingRatioText.text = "${status.backingRatio.setScale(2)}%" + backingRatioText.setTextColor( + if (status.isFullyBacked) { + resources.getColor(io.novafoundation.nova.feature_bridge_impl.R.color.bridge_success, null) + } else { + resources.getColor(io.novafoundation.nova.feature_bridge_impl.R.color.bridge_error, null) + } + ) + + // Backing details + usdtBackingText.text = "${status.totalUsdtBacking} USDT" + wusdtCirculatingText.text = "${status.totalWusdtCirculating} wUSDT" + reserveText.text = "${status.reserve} USDT" + + // Bridge config + polkadotAddressText.text = bridgeConfig.polkadotDepositAddress + pezkuwiAddressText.text = bridgeConfig.pezkuwiAddress + feeText.text = bridgeConfig.feePercentage + minDepositText.text = "${bridgeConfig.minDeposit} USDT" + + // Status + statusIndicator.setBackgroundColor( + if (status.isOperational) { + resources.getColor(io.novafoundation.nova.feature_bridge_impl.R.color.bridge_success, null) + } else { + resources.getColor(io.novafoundation.nova.feature_bridge_impl.R.color.bridge_error, null) + } + ) + statusText.text = if (status.isOperational) "Operational" else "Offline" + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + fun newInstance() = BridgeStatusFragment() + } +} diff --git a/feature-bridge-impl/src/main/res/drawable/bg_address_field.xml b/feature-bridge-impl/src/main/res/drawable/bg_address_field.xml new file mode 100644 index 0000000..237f2c3 --- /dev/null +++ b/feature-bridge-impl/src/main/res/drawable/bg_address_field.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/feature-bridge-impl/src/main/res/drawable/bg_status_indicator.xml b/feature-bridge-impl/src/main/res/drawable/bg_status_indicator.xml new file mode 100644 index 0000000..a66a7e4 --- /dev/null +++ b/feature-bridge-impl/src/main/res/drawable/bg_status_indicator.xml @@ -0,0 +1,5 @@ + + + + diff --git a/feature-bridge-impl/src/main/res/drawable/ic_arrow_back.xml b/feature-bridge-impl/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..4e139d4 --- /dev/null +++ b/feature-bridge-impl/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,10 @@ + + + + diff --git a/feature-bridge-impl/src/main/res/layout/fragment_bridge_deposit.xml b/feature-bridge-impl/src/main/res/layout/fragment_bridge_deposit.xml new file mode 100644 index 0000000..62bfbf8 --- /dev/null +++ b/feature-bridge-impl/src/main/res/layout/fragment_bridge_deposit.xml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-bridge-impl/src/main/res/layout/fragment_bridge_status.xml b/feature-bridge-impl/src/main/res/layout/fragment_bridge_status.xml new file mode 100644 index 0000000..64fb689 --- /dev/null +++ b/feature-bridge-impl/src/main/res/layout/fragment_bridge_status.xml @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-bridge-impl/src/main/res/values/colors.xml b/feature-bridge-impl/src/main/res/values/colors.xml new file mode 100644 index 0000000..49f3bd6 --- /dev/null +++ b/feature-bridge-impl/src/main/res/values/colors.xml @@ -0,0 +1,14 @@ + + + + #121212 + #1E1E1E + #FFFFFF + #B3B3B3 + #00D395 + #00A676 + #FFB800 + #FF4444 + #00D395 + #2A2A2A + diff --git a/feature-buy-api/.gitignore b/feature-buy-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-buy-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-buy-api/build.gradle b/feature-buy-api/build.gradle new file mode 100644 index 0000000..0c3d34b --- /dev/null +++ b/feature-buy-api/build.gradle @@ -0,0 +1,30 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_buy_api' +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":feature-account-api") + implementation project(":feature-wallet-api") + implementation project(":common") + implementation project(':feature-deep-linking') + + implementation materialDep + + implementation daggerDep + ksp daggerCompiler + + implementation substrateSdkDep + + implementation androidDep + implementation constraintDep + + implementation lifeCycleKtxDep + + api project(':core-api') + + testImplementation project(':test-shared') +} \ No newline at end of file diff --git a/feature-buy-api/consumer-rules.pro b/feature-buy-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-buy-api/proguard-rules.pro b/feature-buy-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-buy-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-buy-api/src/main/AndroidManifest.xml b/feature-buy-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-buy-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/di/BuyFeatureApi.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/di/BuyFeatureApi.kt new file mode 100644 index 0000000..db51730 --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/di/BuyFeatureApi.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_buy_api.di + +import io.novafoundation.nova.feature_buy_api.di.deeplinks.BuyDeepLinks +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoBuyRequestInterceptorFactory +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoSellRequestInterceptorFactory + +interface BuyFeatureApi { + + val buyTokenRegistry: TradeTokenRegistry + + val tradeMixinFactory: TradeMixin.Factory + + val mercuryoBuyRequestInterceptorFactory: MercuryoBuyRequestInterceptorFactory + + val mercuryoSellRequestInterceptorFactory: MercuryoSellRequestInterceptorFactory + + val buyDeepLinks: BuyDeepLinks +} diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/di/deeplinks/BuyDeepLinks.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/di/deeplinks/BuyDeepLinks.kt new file mode 100644 index 0000000..587a687 --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/di/deeplinks/BuyDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_buy_api.di.deeplinks + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class BuyDeepLinks(val deepLinkHandlers: List) diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/mixin/TradeMixin.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/mixin/TradeMixin.kt new file mode 100644 index 0000000..741e729 --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/mixin/TradeMixin.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_buy_api.presentation.mixin + +import io.novafoundation.nova.common.mixin.MixinFactory +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeProvider +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface TradeMixin { + + fun providersFor(chainAsset: Chain.Asset, tradeType: TradeTokenRegistry.TradeType): List + + fun providerFor(chainAsset: Chain.Asset, tradeFlow: TradeTokenRegistry.TradeType, providerId: String): T + + interface Presentation : TradeMixin + + interface Factory : MixinFactory +} diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/TradeTokenRegistry.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/TradeTokenRegistry.kt new file mode 100644 index 0000000..e84205e --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/TradeTokenRegistry.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_buy_api.presentation.trade + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +typealias TradeProvider = TradeTokenRegistry.Provider<*> + +interface TradeTokenRegistry { + + fun hasProvider(chainAsset: Chain.Asset): Boolean + + fun hasProvider(chainAsset: Chain.Asset, tradeType: TradeType): Boolean + + fun availableProvidersFor(chainAsset: Chain.Asset, tradeType: TradeType): List + + interface Provider> { + val id: String + + val name: String + + val officialUrl: String + + @get:DrawableRes + val logoRes: Int + + fun getPaymentMethods(tradeType: TradeType): List + + @StringRes + fun getDescriptionRes(tradeType: TradeType): Int + + fun createIntegrator( + chainAsset: Chain.Asset, + address: String, + tradeFlow: TradeType, + onCloseListener: OnTradeOperationFinishedListener, + onSellOrderCreatedListener: OnSellOrderCreatedListener + ): I + } + + interface Integrator { + + suspend fun run(using: T) + } + + enum class TradeType { + BUY, SELL + } + + sealed interface PaymentMethod { + object Visa : PaymentMethod + object MasterCard : PaymentMethod + object ApplePay : PaymentMethod + object GooglePay : PaymentMethod + object Sepa : PaymentMethod + object BankTransfer : PaymentMethod + + class Other(val quantity: Int) : PaymentMethod + } +} diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/common/OnSellOrderCreatedListener.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/common/OnSellOrderCreatedListener.kt new file mode 100644 index 0000000..d62f154 --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/common/OnSellOrderCreatedListener.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_buy_api.presentation.trade.common + +import java.math.BigDecimal + +interface OnSellOrderCreatedListener { + fun onSellOrderCreated(orderId: String, address: String, amount: BigDecimal) +} diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/common/OnTradeOperationFinishedListener.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/common/OnTradeOperationFinishedListener.kt new file mode 100644 index 0000000..6bb3b23 --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/common/OnTradeOperationFinishedListener.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_buy_api.presentation.trade.common + +interface OnTradeOperationFinishedListener { + fun onTradeOperationFinished(success: Boolean) +} diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/interceptors/mercuryo/MercuryoBuyRequestInterceptor.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/interceptors/mercuryo/MercuryoBuyRequestInterceptor.kt new file mode 100644 index 0000000..73e9176 --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/interceptors/mercuryo/MercuryoBuyRequestInterceptor.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo + +import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener + +interface MercuryoBuyRequestInterceptorFactory { + fun create(onTradeOperationFinishedListener: OnTradeOperationFinishedListener): MercuryoBuyRequestInterceptor +} + +interface MercuryoBuyRequestInterceptor : WebViewRequestInterceptor diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/interceptors/mercuryo/MercuryoSellRequestInterceptor.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/interceptors/mercuryo/MercuryoSellRequestInterceptor.kt new file mode 100644 index 0000000..6c3d0ff --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/interceptors/mercuryo/MercuryoSellRequestInterceptor.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo + +import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener + +interface MercuryoSellRequestInterceptorFactory { + fun create( + tradeSellCallback: OnSellOrderCreatedListener, + onTradeOperationFinishedListener: OnTradeOperationFinishedListener + ): MercuryoSellRequestInterceptor +} + +interface MercuryoSellRequestInterceptor : WebViewRequestInterceptor diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/providers/ProviderUtils.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/providers/ProviderUtils.kt new file mode 100644 index 0000000..61e3dc0 --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/providers/ProviderUtils.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_buy_api.presentation.trade.providers + +object ProviderUtils { + const val REDIRECT_URL_BASE = "https://www.google.com/" +} diff --git a/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/providers/WebViewIntegrationProvider.kt b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/providers/WebViewIntegrationProvider.kt new file mode 100644 index 0000000..bbb1e88 --- /dev/null +++ b/feature-buy-api/src/main/java/io/novafoundation/nova/feature_buy_api/presentation/trade/providers/WebViewIntegrationProvider.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_buy_api.presentation.trade.providers + +import android.webkit.WebView +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry + +interface WebViewIntegrationProvider : TradeTokenRegistry.Provider { + + interface Integrator : TradeTokenRegistry.Integrator +} diff --git a/feature-buy-impl/.gitignore b/feature-buy-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-buy-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-buy-impl/build.gradle b/feature-buy-impl/build.gradle new file mode 100644 index 0000000..2235274 --- /dev/null +++ b/feature-buy-impl/build.gradle @@ -0,0 +1,101 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + + namespace 'io.novafoundation.nova.feature_buy_impl' + + defaultConfig { + + + + buildConfigField "String", "RAMP_TOKEN", "\"n8ev677z3z7enckabyc249j84ajpc28o9tmsgob7\"" + buildConfigField "String", "RAMP_HOST", "\"ri-widget-staging.firebaseapp.com\"" + + buildConfigField "String", "TRANSAK_HOST", "\"pezkuwi-transak-dev.pezkuwichain.io\"" + + buildConfigField "String", "MOONPAY_PRIVATE_KEY", readStringSecret("MOONPAY_TEST_SECRET") + buildConfigField "String", "MOONPAY_HOST", "\"buy-staging.moonpay.com\"" + buildConfigField "String", "MOONPAY_PUBLIC_KEY", "\"pk_test_DMRuyL6Nf1qc9OzjPBmCFBeCGkFwiZs0\"" + + buildConfigField "String", "MERCURYO_WIDGET_ID", "\"fde83da2-2a4c-4af9-a2ca-30aead5d65a0\"" + buildConfigField "String", "MERCURYO_SECRET", readStringSecret("MERCURYO_TEST_SECRET") + buildConfigField "String", "MERCURYO_HOST", "\"sandbox-exchange.mrcr.io\"" + + buildConfigField "String", "BANXA_HOST", "\"pezkuwi.banxa-sandbox.com\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + buildConfigField "String", "RAMP_TOKEN", "\"6hrtmyabadyjf6q4jc6h45yv3k8h7s88ebgubscd\"" + buildConfigField "String", "RAMP_HOST", "\"buy.ramp.network\"" + + buildConfigField "String", "TRANSAK_HOST", "\"pezkuwi-transak.pezkuwichain.io\"" + + buildConfigField "String", "MOONPAY_PRIVATE_KEY", readStringSecret("MOONPAY_PRODUCTION_SECRET") + buildConfigField "String", "MOONPAY_PUBLIC_KEY", "\"pk_live_Boi6Rl107p7XuJWBL8GJRzGWlmUSoxbz\"" + buildConfigField "String", "MOONPAY_HOST", "\"buy.moonpay.com\"" + + buildConfigField "String", "MERCURYO_WIDGET_ID", "\"07c3ca04-f4a8-4d68-a192-83a1794ba705\"" + buildConfigField "String", "MERCURYO_SECRET", readStringSecret("MERCURYO_PRODUCTION_SECRET") + buildConfigField "String", "MERCURYO_HOST", "\"exchange.mercuryo.io\"" + + buildConfigField "String", "BANXA_HOST", "\"pezkuwi.banxa.com\"" + } + } + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-wallet-api') + implementation project(':feature-account-api') + implementation project(':feature-buy-api') + implementation project(':runtime') + implementation project(':feature-deep-linking') + + implementation kotlinDep + + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation daggerDep + ksp daggerCompiler + + implementation roomDep + ksp roomCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation insetterDep + + implementation shimmerDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} \ No newline at end of file diff --git a/feature-buy-impl/consumer-rules.pro b/feature-buy-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-buy-impl/proguard-rules.pro b/feature-buy-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-buy-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-buy-impl/src/main/AndroidManifest.xml b/feature-buy-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-buy-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/BuyFeatureComponent.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/BuyFeatureComponent.kt new file mode 100644 index 0000000..8efd6e7 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/BuyFeatureComponent.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_buy_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi +import io.novafoundation.nova.feature_buy_impl.presentation.BuyRouter +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + BuyFeatureDependencies::class + ], + modules = [ + BuyFeatureModule::class + ] +) +@FeatureScope +interface BuyFeatureComponent : BuyFeatureApi { + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: BuyRouter, + deps: BuyFeatureDependencies + ): BuyFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + AccountFeatureApi::class, + WalletFeatureApi::class, + ] + ) + interface BuyFeatureDependenciesComponent : BuyFeatureDependencies +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/BuyFeatureDependencies.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/BuyFeatureDependencies.kt new file mode 100644 index 0000000..9488640 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/BuyFeatureDependencies.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_buy_impl.di + +import android.content.Context +import com.google.gson.Gson +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.ip.IpAddressReceiver +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClientFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import okhttp3.OkHttpClient + +interface BuyFeatureDependencies { + + val context: Context + + val amountFormatter: AmountFormatter + + val chainRegistry: ChainRegistry + + val accountUseCase: SelectedAccountUseCase + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val interceptingWebViewClientFactory: InterceptingWebViewClientFactory + + val gson: Gson + + val okHttpClient: OkHttpClient + + val resourceManager: ResourceManager + + val ipAddressReceiver: IpAddressReceiver +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/BuyFeatureHolder.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/BuyFeatureHolder.kt new file mode 100644 index 0000000..496cb98 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/BuyFeatureHolder.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_buy_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_buy_impl.presentation.BuyRouter +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class BuyFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: BuyRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerBuyFeatureComponent_BuyFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .build() + + return DaggerBuyFeatureComponent.factory() + .create(router, dependencies) + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/TradeFeatureModule.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/TradeFeatureModule.kt new file mode 100644 index 0000000..1ee027b --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/TradeFeatureModule.kt @@ -0,0 +1,125 @@ +package io.novafoundation.nova.feature_buy_impl.di + +import android.content.Context +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.ip.IpAddressReceiver +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClientFactory +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin +import io.novafoundation.nova.feature_buy_impl.presentation.common.MercuryoSignatureFactory +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoBuyRequestInterceptorFactory +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoSellRequestInterceptorFactory +import io.novafoundation.nova.feature_buy_impl.BuildConfig +import io.novafoundation.nova.feature_buy_impl.di.deeplinks.DeepLinkModule +import io.novafoundation.nova.feature_buy_impl.presentation.common.RealMercuryoSignatureFactory +import io.novafoundation.nova.feature_buy_impl.presentation.trade.RealTradeTokenRegistry +import io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.banxa.BanxaProvider +import io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.mercurio.MercuryoIntegratorFactory +import io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.mercurio.MercuryoProvider +import io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.transak.TransakProvider +import io.novafoundation.nova.feature_buy_impl.presentation.mixin.TradeMixinFactory +import io.novafoundation.nova.feature_buy_impl.presentation.trade.interceptors.mercuryo.RealMercuryoBuyRequestInterceptorFactory +import io.novafoundation.nova.feature_buy_impl.presentation.trade.interceptors.mercuryo.RealMercuryoSellRequestInterceptorFactory +import okhttp3.OkHttpClient + +@Module(includes = [DeepLinkModule::class]) +class BuyFeatureModule { + + @Provides + @FeatureScope + fun provideMercuryoSellRequestInterceptorFactory( + gson: Gson, + okHttpClient: OkHttpClient + ): MercuryoSellRequestInterceptorFactory = RealMercuryoSellRequestInterceptorFactory( + gson = gson, + okHttpClient = okHttpClient + ) + + @Provides + @FeatureScope + fun provideMercuryoBuyRequestInterceptorFactory( + gson: Gson, + okHttpClient: OkHttpClient + ): MercuryoBuyRequestInterceptorFactory = RealMercuryoBuyRequestInterceptorFactory( + gson = gson, + okHttpClient = okHttpClient + ) + + @Provides + @FeatureScope + fun provideMercuryoSignatureGenerator( + ipAddressReceiver: IpAddressReceiver + ): MercuryoSignatureFactory = RealMercuryoSignatureFactory(ipAddressReceiver) + + @Provides + @FeatureScope + fun provideMercuryoIntegratorFactory( + mercuryoBuyInterceptorFactory: MercuryoBuyRequestInterceptorFactory, + mercuryoSellInterceptorFactory: MercuryoSellRequestInterceptorFactory, + interceptingWebViewClientFactory: InterceptingWebViewClientFactory, + mercuryoSignatureFactory: MercuryoSignatureFactory + ): MercuryoIntegratorFactory { + return MercuryoIntegratorFactory( + mercuryoBuyInterceptorFactory, + mercuryoSellInterceptorFactory, + interceptingWebViewClientFactory, + mercuryoSignatureFactory + ) + } + + @Provides + @FeatureScope + fun provideBanxaProvider(): BanxaProvider { + return BanxaProvider(BuildConfig.BANXA_HOST) + } + + @Provides + @FeatureScope + fun provideMercuryoProvider(integratorFactory: MercuryoIntegratorFactory): MercuryoProvider { + return MercuryoProvider( + host = BuildConfig.MERCURYO_HOST, + widgetId = BuildConfig.MERCURYO_WIDGET_ID, + secret = BuildConfig.MERCURYO_SECRET, + integratorFactory = integratorFactory + ) + } + + @Provides + @FeatureScope + fun provideTransakProvider(context: Context): TransakProvider { + val environment = if (BuildConfig.DEBUG) "STAGING" else "PRODUCTION" + + return TransakProvider( + host = BuildConfig.TRANSAK_HOST, + referrerDomain = context.packageName, + environment = environment + ) + } + + @Provides + @FeatureScope + fun provideBuyTokenIntegration( + transakProvider: TransakProvider, + mercuryoProvider: MercuryoProvider, + banxaProvider: BanxaProvider + ): TradeTokenRegistry { + return RealTradeTokenRegistry( + providers = listOf( + mercuryoProvider, + transakProvider, + banxaProvider, + ) + ) + } + + @Provides + @FeatureScope + fun provideBuyMixinFactory( + buyTokenRegistry: TradeTokenRegistry + ): TradeMixin.Factory = TradeMixinFactory( + buyTokenRegistry = buyTokenRegistry + ) +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/deeplinks/DeepLinkModule.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/deeplinks/DeepLinkModule.kt new file mode 100644 index 0000000..cb50c61 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/di/deeplinks/DeepLinkModule.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_buy_impl.di.deeplinks + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_buy_api.di.deeplinks.BuyDeepLinks +import io.novafoundation.nova.feature_buy_impl.presentation.deeplink.BuyCallbackDeepLinkHandler + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideBuyCallbackDeepLinkHandler( + resourceManager: ResourceManager + ) = BuyCallbackDeepLinkHandler(resourceManager) + + @Provides + @FeatureScope + fun provideDeepLinks(buyCallback: BuyCallbackDeepLinkHandler): BuyDeepLinks { + return BuyDeepLinks(listOf(buyCallback)) + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/BuyRouter.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/BuyRouter.kt new file mode 100644 index 0000000..b491187 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/BuyRouter.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_buy_impl.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter + +interface BuyRouter : ReturnableRouter diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/common/MercuryoSignatureFactory.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/common/MercuryoSignatureFactory.kt new file mode 100644 index 0000000..cbebb3f --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/common/MercuryoSignatureFactory.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.common + +interface MercuryoSignatureFactory { + + suspend fun createSignature(address: String, secret: String, merchantTransactionId: String): String +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/common/RealMercuryoSignatureFactory.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/common/RealMercuryoSignatureFactory.kt new file mode 100644 index 0000000..3a71564 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/common/RealMercuryoSignatureFactory.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.common + +import io.novafoundation.nova.common.utils.ip.IpAddressReceiver +import io.novafoundation.nova.common.utils.sha512 +import io.novasama.substrate_sdk_android.extensions.toHexString + +class RealMercuryoSignatureFactory( + private val ipAddressReceiver: IpAddressReceiver +) : MercuryoSignatureFactory { + + override suspend fun createSignature(address: String, secret: String, merchantTransactionId: String): String { + val ip = ipAddressReceiver.get() + val signature = "$address$secret$ip$merchantTransactionId".encodeToByteArray() + .sha512() + .toHexString() + + return "v2:$signature" + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/common/Utils.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/common/Utils.kt new file mode 100644 index 0000000..b077a33 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/common/Utils.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.common + +import java.util.UUID + +fun generateMerchantTransactionId(): String { + return UUID.randomUUID().toString() +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/deeplink/BuyCallbackDeepLinkHandler.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/deeplink/BuyCallbackDeepLinkHandler.kt new file mode 100644 index 0000000..185fe08 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/deeplink/BuyCallbackDeepLinkHandler.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.feature_buy_api.presentation.trade.providers.ProviderUtils +import io.novafoundation.nova.feature_deep_linking.R +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import kotlinx.coroutines.flow.MutableSharedFlow + +class BuyCallbackDeepLinkHandler( + private val resourceManager: ResourceManager +) : DeepLinkHandler { + + override val callbackFlow: MutableSharedFlow = singleReplaySharedFlow() + + override suspend fun matches(data: Uri): Boolean { + val link = data.toString() + return ProviderUtils.REDIRECT_URL_BASE in link + } + + override suspend fun handleDeepLink(data: Uri) = runCatching { + val message = resourceManager.getString(R.string.buy_completed) + callbackFlow.emit(CallbackEvent.Message(message)) + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/mixin/TradeProviderMixin.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/mixin/TradeProviderMixin.kt new file mode 100644 index 0000000..fb77fc5 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/mixin/TradeProviderMixin.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.mixin + +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeProvider +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope + +internal class TradeMixinFactory( + private val buyTokenRegistry: TradeTokenRegistry, +) : TradeMixin.Factory { + + override fun create(scope: CoroutineScope): TradeMixin.Presentation { + return TradeProviderMixin( + buyTokenRegistry = buyTokenRegistry, + coroutineScope = scope + ) + } +} + +private class TradeProviderMixin( + private val buyTokenRegistry: TradeTokenRegistry, + coroutineScope: CoroutineScope, +) : TradeMixin.Presentation, + CoroutineScope by coroutineScope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + override fun providersFor(chainAsset: Chain.Asset, tradeType: TradeTokenRegistry.TradeType): List { + return buyTokenRegistry.availableProvidersFor(chainAsset, tradeType) + } + + @Suppress("UNCHECKED_CAST") + override fun providerFor(chainAsset: Chain.Asset, tradeFlow: TradeTokenRegistry.TradeType, providerId: String): T { + return providersFor(chainAsset, tradeFlow) + .first { it.id == providerId } as T + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/RealTradeTokenRegistry.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/RealTradeTokenRegistry.kt new file mode 100644 index 0000000..ce5d709 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/RealTradeTokenRegistry.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.trade + +import io.novafoundation.nova.common.utils.hasIntersectionWith +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class RealTradeTokenRegistry(private val providers: List>) : TradeTokenRegistry { + + override fun hasProvider(chainAsset: Chain.Asset): Boolean { + val supportedProviderIds = providers.mapToSet { it.id } + return supportedProviderIds.hasIntersectionWith(chainAsset.buyProviders.keys) || + supportedProviderIds.hasIntersectionWith(chainAsset.sellProviders.keys) + } + + override fun hasProvider(chainAsset: Chain.Asset, tradeType: TradeTokenRegistry.TradeType): Boolean { + return availableProvidersFor(chainAsset, tradeType).isNotEmpty() + } + + override fun availableProvidersFor(chainAsset: Chain.Asset, tradeType: TradeTokenRegistry.TradeType) = providers + .filter { provider -> + val providersByType = when (tradeType) { + TradeTokenRegistry.TradeType.BUY -> chainAsset.buyProviders + TradeTokenRegistry.TradeType.SELL -> chainAsset.sellProviders + } + + provider.id in providersByType + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/interceptors/mercuryo/RealMercuryoBuyRequestInterceptor.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/interceptors/mercuryo/RealMercuryoBuyRequestInterceptor.kt new file mode 100644 index 0000000..f84e4af --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/interceptors/mercuryo/RealMercuryoBuyRequestInterceptor.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.trade.interceptors.mercuryo + +import android.webkit.WebResourceRequest +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.webView.makeRequestBlocking +import io.novafoundation.nova.common.utils.webView.toOkHttpRequestBuilder +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoBuyRequestInterceptor +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoBuyRequestInterceptorFactory +import okhttp3.OkHttpClient + +class RealMercuryoBuyRequestInterceptorFactory( + private val okHttpClient: OkHttpClient, + private val gson: Gson +) : MercuryoBuyRequestInterceptorFactory { + override fun create(onTradeOperationFinishedListener: OnTradeOperationFinishedListener): MercuryoBuyRequestInterceptor { + return RealMercuryoBuyRequestInterceptor(okHttpClient, gson, onTradeOperationFinishedListener) + } +} + +class RealMercuryoBuyRequestInterceptor( + private val okHttpClient: OkHttpClient, + private val gson: Gson, + private val onTradeOperationFinishedListener: OnTradeOperationFinishedListener +) : MercuryoBuyRequestInterceptor { + + private val interceptionPattern = Regex("https://api\\.mercuryo\\.io/[a-zA-Z0-9.]+/widget/buy/([a-zA-Z0-9]+)/status.*") + + override fun intercept(request: WebResourceRequest): Boolean { + val url = request.url.toString() + + val matches = interceptionPattern.find(url) + + if (matches != null) { + return performOkHttpRequest(request) + } + + return false + } + + private fun performOkHttpRequest(request: WebResourceRequest): Boolean { + val requestBuilder = request.toOkHttpRequestBuilder() + + return try { + val response = okHttpClient.makeRequestBlocking(requestBuilder) + val buyStatusResponse = gson.fromJson(response.body!!.string(), BuyStatusResponse::class.java) + + if (buyStatusResponse.isPaid()) { + onTradeOperationFinishedListener.onTradeOperationFinished(success = true) + } + + true + } catch (e: Exception) { + false + } + } +} + +/** + * { + * "status": 200, + * "data": { + * "id": "0da637056a0c85319", + * "status": "paid", // new, pending, paid + * "payment_status": "charged", + * "withdraw_transaction": { + * "id": "0da63727a3ce33010", + * "address": "12gkMmfdKq7aEnAXwb2NSxh9vLqKifoCaoafLrR6E6swZRmc", + * "fee": "0", + * "url": "" + * }, + * "currency": "DOT", + * "amount": "2.4742695641", + * "fiat_currency": "USD", + * "fiat_amount": "11.00", + * "address": "12gkMmfdKq7aEnAXwb2NSxh9vLqKifoCaoafLrR6E6swZRmc", + * "transaction": { + * "id": "0da637056f0821757" + * }, + * "local_fiat_currency_total": { + * "local_fiat_currency": "EUR", + * "local_fiat_amount": "10.25" + * } + * } + * } + */ +private class BuyStatusResponse(val data: Data) { + + class Data(val status: String) +} + +private fun BuyStatusResponse.isPaid() = data.status == "paid" diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/interceptors/mercuryo/RealMercuryoSellRequestInterceptor.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/interceptors/mercuryo/RealMercuryoSellRequestInterceptor.kt new file mode 100644 index 0000000..508ccc9 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/interceptors/mercuryo/RealMercuryoSellRequestInterceptor.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.trade.interceptors.mercuryo + +import android.webkit.WebResourceRequest +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.webView.makeRequestBlocking +import io.novafoundation.nova.common.utils.webView.toOkHttpRequestBuilder +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoSellRequestInterceptor +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoSellRequestInterceptorFactory +import java.math.BigDecimal +import okhttp3.OkHttpClient + +class RealMercuryoSellRequestInterceptorFactory( + private val okHttpClient: OkHttpClient, + private val gson: Gson +) : MercuryoSellRequestInterceptorFactory { + override fun create( + tradeSellCallback: OnSellOrderCreatedListener, + onTradeOperationFinishedListener: OnTradeOperationFinishedListener + ): MercuryoSellRequestInterceptor { + return RealMercuryoSellRequestInterceptor(okHttpClient, gson, tradeSellCallback, onTradeOperationFinishedListener) + } +} + +class RealMercuryoSellRequestInterceptor( + private val okHttpClient: OkHttpClient, + private val gson: Gson, + private val tradeSellCallback: OnSellOrderCreatedListener, + private val onTradeOperationFinishedListener: OnTradeOperationFinishedListener +) : MercuryoSellRequestInterceptor { + + private val openedOrderIds = mutableSetOf() + + private val interceptionPattern = Regex("https://api\\.mercuryo\\.io/[a-zA-Z0-9.]+/widget/sell-request/([a-zA-Z0-9]+)/status.*") + + override fun intercept(request: WebResourceRequest): Boolean { + val url = request.url.toString() + + val matches = interceptionPattern.find(url) + + if (matches != null) { + val orderId = matches.groupValues[1] + return performOkHttpRequest(orderId, request) + } + + return false + } + + private fun performOkHttpRequest(orderId: String, request: WebResourceRequest): Boolean { + val requestBuilder = request.toOkHttpRequestBuilder() + + return try { + val response = okHttpClient.makeRequestBlocking(requestBuilder) + val sellStatusResponse = gson.fromJson(response.body!!.string(), SellStatusResponse::class.java) + + // We should check that this data is exist in response before handling. Otherwise we will get an exception + val address = sellStatusResponse.getAddress() ?: error("Address must be not null") + val amount = sellStatusResponse.getAmount() ?: error("Amount must be not null") + + when { + sellStatusResponse.isNew() && orderId !in openedOrderIds -> { + tradeSellCallback.onSellOrderCreated(orderId, address, amount) + openedOrderIds.add(orderId) + } + + sellStatusResponse.isCompleted() -> onTradeOperationFinishedListener.onTradeOperationFinished(success = true) + } + + true + } catch (e: Exception) { + false + } + } +} + +/** + * { + * "status": 200, + * "data": { + * "status": "completed", // Status may be new, pending, completed + * "is_partially_paid": 0, + * "amounts": { + * "request": { + * "amount": "3.55", + * "currency": "DOT", + * "fiat_amount": "25.00", + * "fiat_currency": "EUR" + * }, + * "deposit": { + * "amount": "3.5544722879", + * "currency": "DOT", + * "fiat_amount": "28.00", + * "fiat_currency": "EUR" + * }, + * "payout": { + * "amount": "3.5544722879", + * "currency": "DOT", + * "fiat_amount": "24.98", + * "fiat_currency": "EUR" + * } + * }, + * "next": null, + * "deposit_transaction": { + * "id": "1gb8dnc28jds8ch", + * "address": "15AsDPtQ6rZdJgsLsEmQCahym5STRVBVaUYjWFiRRinMjYYaw", + * "url": "https://polkadot.subscan.io/extrinsic/0x178f96e1f8837a3dd75ff8b5a5d4422c5c0f7848fbf5c00e343f03b9466e408b" + * }, + * "address": "15AsDPtQ6rZdJgsLsEmQCahym5STRVBVaUYjWFiRRinMjYYaw", + * "fiat_card_id": "1gb8dnc28jds8ch" + * } + * } + */ +private class SellStatusResponse(val data: Data?) { + + class Data( + val status: String?, + val amounts: Amounts?, + val address: String? + ) + + class Amounts(val request: Request?) { + + class Request( + val amount: String?, + ) + } +} + +private fun SellStatusResponse.getAmount(): BigDecimal? { + return data?.amounts?.request?.amount?.toBigDecimal() +} + +private fun SellStatusResponse.getAddress(): String? { + return data?.address +} + +private fun SellStatusResponse.isNew() = data?.status == "new" + +private fun SellStatusResponse.isCompleted() = data?.status == "completed" diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/banxa/BanxaProvider.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/banxa/BanxaProvider.kt new file mode 100644 index 0000000..48a7ba4 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/banxa/BanxaProvider.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.banxa + +import android.net.Uri +import android.webkit.WebView +import io.novafoundation.nova.common.utils.appendNullableQueryParameter +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.providers.WebViewIntegrationProvider +import io.novafoundation.nova.feature_buy_impl.R +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +private const val COIN_KEY = "coinType" +private const val BLOCKCHAIN_KEY = "blockchain" + +class BanxaProvider( + private val host: String +) : WebViewIntegrationProvider { + + override val id: String = "banxa" + + override val name: String = "Banxa" + override val officialUrl: String = "banxa.com" + override val logoRes: Int = R.drawable.ic_banxa_provider_logo + + override fun getDescriptionRes(tradeType: TradeTokenRegistry.TradeType): Int { + return R.string.banxa_provider_description + } + + override fun getPaymentMethods(tradeType: TradeTokenRegistry.TradeType): List { + return when (tradeType) { + TradeTokenRegistry.TradeType.BUY -> listOf( + TradeTokenRegistry.PaymentMethod.Visa, + TradeTokenRegistry.PaymentMethod.MasterCard, + TradeTokenRegistry.PaymentMethod.ApplePay, + TradeTokenRegistry.PaymentMethod.GooglePay, + TradeTokenRegistry.PaymentMethod.Sepa, + TradeTokenRegistry.PaymentMethod.Other(5) + ) + + TradeTokenRegistry.TradeType.SELL -> emptyList() + } + } + + override fun createIntegrator( + chainAsset: Chain.Asset, + address: String, + tradeFlow: TradeTokenRegistry.TradeType, + onCloseListener: OnTradeOperationFinishedListener, + onSellOrderCreatedListener: OnSellOrderCreatedListener + ): WebViewIntegrationProvider.Integrator { + val providerDetails = chainAsset.buyProviders.getValue(id) + val blockchain = providerDetails[BLOCKCHAIN_KEY] as? String + val coinType = providerDetails[COIN_KEY] as? String + return BanxaIntegrator(host, blockchain, coinType, address) + } + + private class BanxaIntegrator( + private val host: String, + private val blockchain: String?, + private val coinType: String?, + private val address: String + ) : WebViewIntegrationProvider.Integrator { + + override suspend fun run(using: WebView) { + using.loadUrl(createLink()) + } + + private fun createLink(): String { + return Uri.Builder() + .scheme("https") + .authority(host) + .appendNullableQueryParameter(BLOCKCHAIN_KEY, blockchain) + .appendNullableQueryParameter(COIN_KEY, coinType) + .appendQueryParameter("walletAddress", address) + .build() + .toString() + } + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/mercurio/MercuryoIntegrator.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/mercurio/MercuryoIntegrator.kt new file mode 100644 index 0000000..01c6343 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/mercurio/MercuryoIntegrator.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.mercurio + +import android.net.Uri +import android.webkit.WebView +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.common.utils.appendNullableQueryParameter +import io.novafoundation.nova.common.utils.urlEncoded +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClient +import io.novafoundation.nova.common.utils.webView.InterceptingWebViewClientFactory +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_impl.presentation.common.MercuryoSignatureFactory +import io.novafoundation.nova.feature_buy_impl.presentation.common.generateMerchantTransactionId +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoBuyRequestInterceptorFactory +import io.novafoundation.nova.feature_buy_api.presentation.trade.interceptors.mercuryo.MercuryoSellRequestInterceptorFactory +import io.novafoundation.nova.feature_buy_api.presentation.trade.providers.WebViewIntegrationProvider +import io.novafoundation.nova.feature_buy_api.presentation.trade.providers.ProviderUtils +import io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.mercurio.MercuryoIntegrator.Payload +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class MercuryoIntegratorFactory( + private val mercuryoBuyInterceptorFactory: MercuryoBuyRequestInterceptorFactory, + private val mercuryoSellInterceptorFactory: MercuryoSellRequestInterceptorFactory, + private val interceptingWebViewClientFactory: InterceptingWebViewClientFactory, + private val signatureGenerator: MercuryoSignatureFactory +) { + + fun create( + payload: Payload, + onSellOrderCreatedListener: OnSellOrderCreatedListener, + onCloseListener: OnTradeOperationFinishedListener + ): MercuryoIntegrator { + val webViewClient = interceptingWebViewClientFactory.create( + listOf( + mercuryoBuyInterceptorFactory.create(onCloseListener), + mercuryoSellInterceptorFactory.create(onSellOrderCreatedListener, onCloseListener) + ) + ) + return MercuryoIntegrator( + payload, + webViewClient, + signatureGenerator + ) + } +} + +class MercuryoIntegrator( + private val payload: Payload, + private val webViewClient: InterceptingWebViewClient, + private val signatureGenerator: MercuryoSignatureFactory +) : WebViewIntegrationProvider.Integrator { + + class Payload( + val host: String, + val widgetId: String, + val tokenSymbol: TokenSymbol, + val network: String?, + val address: String, + val secret: String, + val tradeFlow: TradeTokenRegistry.TradeType + ) + + override suspend fun run(using: WebView) { + withContext(Dispatchers.Main) { + using.webViewClient = webViewClient + + runCatching { + val link = withContext(Dispatchers.IO) { createLink() } + using.loadUrl(link) + } + } + } + + private suspend fun createLink(): String { + // Merchant transaction id is a custom id we can provide to mercuryo to track a transaction. + // Seems useless for us now but required for signature + val merchantTransactionId = generateMerchantTransactionId() + val signature = signatureGenerator.createSignature(payload.address, payload.secret, merchantTransactionId) + + val urlBuilder = Uri.Builder() + .scheme("https") + .authority(payload.host) + .appendQueryParameter("widget_id", payload.widgetId) + .appendQueryParameter("merchant_transaction_id", merchantTransactionId) + .appendQueryParameter("type", payload.tradeFlow.getType()) + .appendNullableQueryParameter(MERCURYO_NETWORK_KEY, payload.network) + .appendQueryParameter("currency", payload.tokenSymbol.value) + .appendQueryParameter("return_url", ProviderUtils.REDIRECT_URL_BASE.urlEncoded()) + .appendQueryParameter("signature", signature) + .appendQueryParameter("fix_currency", true.toString()) + + when (payload.tradeFlow) { + TradeTokenRegistry.TradeType.BUY -> urlBuilder.appendQueryParameter("address", payload.address) + TradeTokenRegistry.TradeType.SELL -> urlBuilder.appendQueryParameter("refund_address", payload.address) + .appendQueryParameter("hide_refund_address", true.toString()) + } + + return urlBuilder.build().toString() + } + + private fun TradeTokenRegistry.TradeType.getType(): String { + return when (this) { + TradeTokenRegistry.TradeType.BUY -> "buy" + TradeTokenRegistry.TradeType.SELL -> "sell" + } + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/mercurio/MercuryoProvider.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/mercurio/MercuryoProvider.kt new file mode 100644 index 0000000..bc4e942 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/mercurio/MercuryoProvider.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.mercurio + +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.providers.WebViewIntegrationProvider +import io.novafoundation.nova.feature_buy_impl.R +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +const val MERCURYO_NETWORK_KEY = "network" + +class MercuryoProvider( + private val host: String, + private val widgetId: String, + private val secret: String, + private val integratorFactory: MercuryoIntegratorFactory +) : WebViewIntegrationProvider { + + override val id: String = "mercuryo" + + override val name: String = "Mercuryo" + override val officialUrl: String = "mercuryo.io" + override val logoRes: Int = R.drawable.ic_mercurio_provider_logo + + override fun getDescriptionRes(tradeType: TradeTokenRegistry.TradeType): Int { + return R.string.mercurio_provider_description + } + + override fun getPaymentMethods(tradeFlow: TradeTokenRegistry.TradeType): List { + return when (tradeFlow) { + TradeTokenRegistry.TradeType.BUY -> listOf( + TradeTokenRegistry.PaymentMethod.Visa, + TradeTokenRegistry.PaymentMethod.MasterCard, + TradeTokenRegistry.PaymentMethod.ApplePay, + TradeTokenRegistry.PaymentMethod.GooglePay, + TradeTokenRegistry.PaymentMethod.Sepa, + TradeTokenRegistry.PaymentMethod.Other(5) + ) + + TradeTokenRegistry.TradeType.SELL -> listOf( + TradeTokenRegistry.PaymentMethod.Visa, + TradeTokenRegistry.PaymentMethod.MasterCard, + TradeTokenRegistry.PaymentMethod.Sepa, + TradeTokenRegistry.PaymentMethod.BankTransfer + ) + } + } + + override fun createIntegrator( + chainAsset: Chain.Asset, + address: String, + tradeFlow: TradeTokenRegistry.TradeType, + onCloseListener: OnTradeOperationFinishedListener, + onSellOrderCreatedListener: OnSellOrderCreatedListener + ): WebViewIntegrationProvider.Integrator { + val network = chainAsset.buyProviders.getValue(id)[MERCURYO_NETWORK_KEY] as? String + val payload = MercuryoIntegrator.Payload(host, widgetId, chainAsset.symbol, network, address, secret, tradeFlow) + return integratorFactory.create(payload, onSellOrderCreatedListener, onCloseListener) + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/transak/TransakIntegrator.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/transak/TransakIntegrator.kt new file mode 100644 index 0000000..812ea8a --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/transak/TransakIntegrator.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.transak + +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.common.utils.appendNullableQueryParameter +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.trade.providers.WebViewIntegrationProvider +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener + +// You can find a valid implementation in https://github.com/agtransak/TransakAndroidSample/blob/events/app/src/main/java/com/transak/sample/MainActivity.kt +private const val JS_BRIDGE_NAME = "Android" + +class TransakIntegrator( + private val payload: Payload, + private val closeListener: OnTradeOperationFinishedListener, + private val sellOrderCreatedListener: OnSellOrderCreatedListener +) : WebViewIntegrationProvider.Integrator { + + class Payload( + val host: String, + val network: String?, + val referrerDomain: String, + val environment: String, + val tokenSymbol: TokenSymbol, + val address: String, + val tradeFlow: TradeTokenRegistry.TradeType + ) + + override suspend fun run(using: WebView) { + using.webViewClient = TransakWebViewClient() + using.addJavascriptInterface(TransakJsEventBridge(closeListener, sellOrderCreatedListener), JS_BRIDGE_NAME) + + using.loadUrl(createLink()) + } + + private fun createLink(): String { + val urlBuilder = Uri.Builder() + .scheme("https") + .authority(payload.host) + .appendQueryParameter("productsAvailed", payload.tradeFlow.getType()) + .appendQueryParameter("environment", payload.environment) + .appendQueryParameter("cryptoCurrencyCode", payload.tokenSymbol.value) + .appendQueryParameter("referrerDomain", payload.referrerDomain) + .appendNullableQueryParameter(TRANSAK_NETWORK_KEY, payload.network) + + if (payload.tradeFlow == TradeTokenRegistry.TradeType.BUY) { + urlBuilder.appendQueryParameter("walletAddress", payload.address) + .appendQueryParameter("disableWalletAddressForm", "true") + } + + return urlBuilder.build().toString() + } + + private fun TradeTokenRegistry.TradeType.getType(): String { + return when (this) { + TradeTokenRegistry.TradeType.BUY -> "BUY" + TradeTokenRegistry.TradeType.SELL -> "SELL" + } + } +} + +private class TransakWebViewClient : WebViewClient() { + // We use it to override base transak loading otherwise transak navigates to android native browser + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + view.loadUrl(request.url.toString()) + return true + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/transak/TransakJsEventBridge.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/transak/TransakJsEventBridge.kt new file mode 100644 index 0000000..3180427 --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/transak/TransakJsEventBridge.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.transak + +import android.util.Log +import android.webkit.JavascriptInterface +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import org.json.JSONObject + +class TransakJsEventBridge( + private val closeListener: OnTradeOperationFinishedListener, + private val tradeSellCallback: OnSellOrderCreatedListener +) { + + @JavascriptInterface + fun postMessage(eventData: String) { + val json = JSONObject(eventData) + val eventId = json.getString("event_id") + Log.d("TransakEvent", "Event: $eventId, Data: $eventData") + + val data = json.get("data") + when (eventId) { + "TRANSAK_WIDGET_CLOSE" -> { + val isOrderSuccessful = data == true // For unsuccessful order data is JSONObject + closeListener.onTradeOperationFinished(isOrderSuccessful) + } + + "TRANSAK_ORDER_CREATED" -> { + require(data is JSONObject) + if (data.getString("isBuyOrSell") == "SELL") { + tradeSellCallback.onSellOrderCreated( + data.getString("id"), + data.getJSONObject("cryptoPaymentData").getString("paymentAddress"), + data.getString("cryptoAmount").toBigDecimal() + ) + } + } + } + } +} diff --git a/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/transak/TransakProvider.kt b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/transak/TransakProvider.kt new file mode 100644 index 0000000..0f0f47a --- /dev/null +++ b/feature-buy-impl/src/main/java/io/novafoundation/nova/feature_buy_impl/presentation/trade/providers/transak/TransakProvider.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_buy_impl.presentation.trade.providers.transak + +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnTradeOperationFinishedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.common.OnSellOrderCreatedListener +import io.novafoundation.nova.feature_buy_api.presentation.trade.providers.WebViewIntegrationProvider +import io.novafoundation.nova.feature_buy_impl.R +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +const val TRANSAK_NETWORK_KEY = "network" + +class TransakProvider( + private val host: String, + private val referrerDomain: String, + private val environment: String +) : WebViewIntegrationProvider { + + override val id = "transak" + override val name = "Transak" + override val officialUrl: String = "transak.com" + override val logoRes: Int = R.drawable.ic_transak_provider_logo + + override fun getDescriptionRes(tradeType: TradeTokenRegistry.TradeType): Int { + return when (tradeType) { + TradeTokenRegistry.TradeType.BUY -> R.string.transak_provider_buy_description + TradeTokenRegistry.TradeType.SELL -> R.string.transak_provider_sell_description + } + } + + override fun getPaymentMethods(tradeType: TradeTokenRegistry.TradeType): List { + return when (tradeType) { + TradeTokenRegistry.TradeType.BUY -> listOf( + TradeTokenRegistry.PaymentMethod.Visa, + TradeTokenRegistry.PaymentMethod.MasterCard, + TradeTokenRegistry.PaymentMethod.ApplePay, + TradeTokenRegistry.PaymentMethod.GooglePay, + TradeTokenRegistry.PaymentMethod.Sepa, + TradeTokenRegistry.PaymentMethod.Other(12) + ) + + TradeTokenRegistry.TradeType.SELL -> listOf( + TradeTokenRegistry.PaymentMethod.Visa, + TradeTokenRegistry.PaymentMethod.MasterCard, + TradeTokenRegistry.PaymentMethod.Sepa, + TradeTokenRegistry.PaymentMethod.BankTransfer + ) + } + } + + override fun createIntegrator( + chainAsset: Chain.Asset, + address: String, + tradeFlow: TradeTokenRegistry.TradeType, + onCloseListener: OnTradeOperationFinishedListener, + onSellOrderCreatedListener: OnSellOrderCreatedListener + ): WebViewIntegrationProvider.Integrator { + val network = chainAsset.buyProviders.getValue(id)[TRANSAK_NETWORK_KEY] as? String + + return TransakIntegrator( + payload = TransakIntegrator.Payload( + host = host, + referrerDomain = referrerDomain, + environment = environment, + network = network, + tokenSymbol = chainAsset.symbol, + address = address, + tradeFlow = tradeFlow + ), + onCloseListener, + onSellOrderCreatedListener + ) + } +} diff --git a/feature-buy-impl/src/main/res/layout/item_sheet_buy_provider.xml b/feature-buy-impl/src/main/res/layout/item_sheet_buy_provider.xml new file mode 100644 index 0000000..167cd22 --- /dev/null +++ b/feature-buy-impl/src/main/res/layout/item_sheet_buy_provider.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-cloud-backup-api/.gitignore b/feature-cloud-backup-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-cloud-backup-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-cloud-backup-api/build.gradle b/feature-cloud-backup-api/build.gradle new file mode 100644 index 0000000..d6bfce7 --- /dev/null +++ b/feature-cloud-backup-api/build.gradle @@ -0,0 +1,21 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_cloud_backup_api' +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + + implementation androidDep + + implementation daggerDep + ksp daggerCompiler + + api project(':core-api') + api project(':core-db') + + testImplementation project(':test-shared') +} \ No newline at end of file diff --git a/feature-cloud-backup-api/consumer-rules.pro b/feature-cloud-backup-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-cloud-backup-api/proguard-rules.pro b/feature-cloud-backup-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-cloud-backup-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-cloud-backup-api/src/main/AndroidManifest.xml b/feature-cloud-backup-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-cloud-backup-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/di/CloudBackupFeatureApi.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/di/CloudBackupFeatureApi.kt new file mode 100644 index 0000000..0f361a8 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/di/CloudBackupFeatureApi.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_cloud_backup_api.di + +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory + +interface CloudBackupFeatureApi { + + val cloudBackupService: CloudBackupService + + val cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/CloudBackupService.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/CloudBackupService.kt new file mode 100644 index 0000000..cba78ea --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/CloudBackupService.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain + +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.EncryptedCloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.PreCreateValidationStatus +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.WriteBackupRequest +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.DeleteBackupError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.FetchBackupError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.PasswordNotSaved +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.WriteBackupError + +/** + * Manages cloud backup storage, serialization and encryption + * + * The storing pipeline is the following: + * + * Backup -> serialize private data -> encrypt private data -> serialize public data + encrypted private + * + * This allows to access public data without accessing password + */ +interface CloudBackupService { + + /** + * Current user preferences, state known for cloud backup related functionality + */ + val session: CloudBackupSession + + /** + * Checks conditions for creating initial backup + */ + suspend fun validateCanCreateBackup(): PreCreateValidationStatus + + /** + * Writes a backup to the cloud, overwriting already existing one + * + * @throws WriteBackupError + */ + suspend fun writeBackupToCloud(request: WriteBackupRequest): Result + + /** + * Check if backup file exists in the cloud + */ + suspend fun isCloudBackupExist(): Result + + /** + * @throws FetchBackupError + */ + suspend fun fetchBackup(): Result + + /** + * @throws DeleteBackupError + */ + suspend fun deleteBackup(): Result + + suspend fun signInToCloud(): Result +} + +/** + * @throws FetchBackupError + * @throws InvalidBackupPasswordError + */ +suspend fun CloudBackupService.fetchAndDecryptExistingBackup(password: String): Result { + return fetchBackup().flatMap { it.decrypt(password) } +} + +/** + * @throws PasswordNotSaved + * @throws FetchBackupError + * @throws InvalidBackupPasswordError + */ +suspend fun CloudBackupService.fetchAndDecryptExistingBackupWithSavedPassword(): Result { + return fetchBackup().flatMap { encryptedBackup -> + session.getSavedPassword().flatMap { password -> + encryptedBackup.decrypt(password) + } + } +} + +suspend fun CloudBackupService.writeCloudBackupWithSavedPassword(cloudBackup: CloudBackup): Result { + return session.getSavedPassword() + .flatMap { password -> + writeBackupToCloud(WriteBackupRequest(cloudBackup, password)) + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/CloudBackupSession.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/CloudBackupSession.kt new file mode 100644 index 0000000..1a873ff --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/CloudBackupSession.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.PasswordNotSaved +import kotlinx.coroutines.flow.Flow +import java.util.Date + +interface CloudBackupSession { + + /** + * Check if user enabled sync with cloud backup in the current application instance + * Enabling this means that the app should write changes of local state to backup on addition, modification or delegation of accounts + */ + suspend fun isSyncWithCloudEnabled(): Boolean + + /** + * @see isSyncWithCloudEnabled + */ + suspend fun setSyncingBackupEnabled(enable: Boolean) + + /** + * Observe the time of the latest backup sync + */ + fun lastSyncedTimeFlow(): Flow + + /** + * @see lastSyncedTimeFlow + */ + suspend fun setLastSyncedTime(date: Date) + + /** + * @throws PasswordNotSaved + */ + suspend fun getSavedPassword(): Result + + suspend fun setSavedPassword(password: String) + + fun cloudBackupWasInitialized(): Boolean + + fun setBackupWasInitialized() +} + +suspend fun CloudBackupSession.setLastSyncedTimeAsNow() { + setLastSyncedTime(Date()) +} + +suspend fun CloudBackupSession.initEnabledBackup(password: String) { + setBackupWasInitialized() + setSyncingBackupEnabled(true) + setLastSyncedTimeAsNow() + setSavedPassword(password) +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt new file mode 100644 index 0000000..579a6bf --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt @@ -0,0 +1,238 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +data class CloudBackup( + val publicData: PublicData, + val privateData: PrivateData +) { + + data class PublicData( + val modifiedAt: Long, + val wallets: List + ) + + data class WalletPublicInfo( + val walletId: String, + val substratePublicKey: ByteArray?, + val substrateAccountId: ByteArray?, + val substrateCryptoType: CryptoType?, + val ethereumAddress: ByteArray?, + val ethereumPublicKey: ByteArray?, + val name: String, + val type: Type, + val chainAccounts: Set + ) : Identifiable { + + override val identifier: String = walletId + + enum class Type { + SECRETS, WATCH_ONLY, PARITY_SIGNER, LEDGER, LEDGER_GENERIC, POLKADOT_VAULT + } + + data class ChainAccountInfo( + val chainId: ChainId, + val publicKey: ByteArray?, + val accountId: ByteArray, + val cryptoType: ChainAccountCryptoType?, + ) { + + override fun equals(other: Any?): Boolean { + if (other !is ChainAccountInfo) return false + + return chainId == other.chainId && + publicKey.contentEquals(other.publicKey) && + accountId.contentEquals(other.accountId) && + cryptoType == other.cryptoType + } + + override fun hashCode(): Int { + var result = chainId.hashCode() + result = 31 * result + (publicKey?.contentHashCode() ?: 0) + result = 31 * result + accountId.contentHashCode() + result = 31 * result + (cryptoType?.hashCode() ?: 0) + return result + } + + enum class ChainAccountCryptoType { + SR25519, ED25519, ECDSA, ETHEREUM + } + } + + override fun equals(other: Any?): Boolean { + if (other !is WalletPublicInfo) return false + + return walletId == other.walletId && + substratePublicKey.contentEquals(other.substratePublicKey) && + substrateAccountId.contentEquals(other.substrateAccountId) && + substrateCryptoType == other.substrateCryptoType && + ethereumAddress.contentEquals(other.ethereumAddress) && + ethereumPublicKey.contentEquals(other.ethereumPublicKey) && + name == other.name && + type == other.type && + chainAccounts == other.chainAccounts + } + + override fun hashCode(): Int { + var result = walletId.hashCode() + result = 31 * result + (substratePublicKey?.contentHashCode() ?: 0) + result = 31 * result + (substrateAccountId?.contentHashCode() ?: 0) + result = 31 * result + (substrateCryptoType?.hashCode() ?: 0) + result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0) + result = 31 * result + (ethereumPublicKey?.contentHashCode() ?: 0) + result = 31 * result + name.hashCode() + result = 31 * result + type.hashCode() + result = 31 * result + chainAccounts.hashCode() + result = 31 * result + identifier.hashCode() + return result + } + } + + data class PrivateData( + val wallets: List + ) + + data class WalletPrivateInfo( + val walletId: String, + val entropy: ByteArray?, + val substrate: SubstrateSecrets?, + val ethereum: EthereumSecrets?, + val chainAccounts: List, + ) : Identifiable { + + override val identifier: String = walletId + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WalletPrivateInfo + + if (walletId != other.walletId) return false + if (entropy != null) { + if (other.entropy == null) return false + if (!entropy.contentEquals(other.entropy)) return false + } else if (other.entropy != null) return false + if (substrate != other.substrate) return false + if (ethereum != other.ethereum) return false + if (chainAccounts != other.chainAccounts) return false + return identifier == other.identifier + } + + override fun hashCode(): Int { + var result = walletId.hashCode() + result = 31 * result + (entropy?.contentHashCode() ?: 0) + result = 31 * result + (substrate?.hashCode() ?: 0) + result = 31 * result + (ethereum?.hashCode() ?: 0) + result = 31 * result + chainAccounts.hashCode() + result = 31 * result + identifier.hashCode() + return result + } + + data class ChainAccountSecrets( + val accountId: ByteArray, + val entropy: ByteArray?, + val seed: ByteArray?, + val keypair: KeyPairSecrets?, + val derivationPath: String?, + ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ChainAccountSecrets + + if (!accountId.contentEquals(other.accountId)) return false + if (entropy != null) { + if (other.entropy == null) return false + if (!entropy.contentEquals(other.entropy)) return false + } else if (other.entropy != null) return false + if (seed != null) { + if (other.seed == null) return false + if (!seed.contentEquals(other.seed)) return false + } else if (other.seed != null) return false + if (keypair != other.keypair) return false + return derivationPath == other.derivationPath + } + + override fun hashCode(): Int { + var result = accountId.contentHashCode() + result = 31 * result + (entropy?.contentHashCode() ?: 0) + result = 31 * result + (seed?.contentHashCode() ?: 0) + result = 31 * result + (keypair?.hashCode() ?: 0) + result = 31 * result + (derivationPath?.hashCode() ?: 0) + return result + } + } + + data class SubstrateSecrets( + val seed: ByteArray?, + val keypair: KeyPairSecrets?, + val derivationPath: String?, + ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SubstrateSecrets + + if (seed != null) { + if (other.seed == null) return false + if (!seed.contentEquals(other.seed)) return false + } else if (other.seed != null) return false + if (keypair != other.keypair) return false + return derivationPath == other.derivationPath + } + + override fun hashCode(): Int { + var result = seed?.contentHashCode() ?: 0 + result = 31 * result + keypair.hashCode() + result = 31 * result + (derivationPath?.hashCode() ?: 0) + return result + } + } + + data class EthereumSecrets( + val keypair: KeyPairSecrets, + val derivationPath: String?, + ) + + data class KeyPairSecrets( + val publicKey: ByteArray, + val privateKey: ByteArray, + val nonce: ByteArray? + ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as KeyPairSecrets + + if (!publicKey.contentEquals(other.publicKey)) return false + if (!privateKey.contentEquals(other.privateKey)) return false + if (nonce != null) { + if (other.nonce == null) return false + if (!nonce.contentEquals(other.nonce)) return false + } else if (other.nonce != null) return false + + return true + } + + override fun hashCode(): Int { + var result = publicKey.contentHashCode() + result = 31 * result + privateKey.contentHashCode() + result = 31 * result + (nonce?.contentHashCode() ?: 0) + return result + } + } + } +} + +fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean { + return entropy == null && substrate == null && ethereum == null && chainAccounts.isEmpty() +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/EncryptedCloudBackup.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/EncryptedCloudBackup.kt new file mode 100644 index 0000000..64b1d5f --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/EncryptedCloudBackup.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError + +interface EncryptedCloudBackup { + + val publicData: CloudBackup.PublicData + + /** + * @throws InvalidBackupPasswordError + */ + suspend fun decrypt(password: String): Result +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/PreCreateValidationStatus.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/PreCreateValidationStatus.kt new file mode 100644 index 0000000..d7f462b --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/PreCreateValidationStatus.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupAuthFailed +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupExistingBackupFound +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupNotEnoughSpace +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupServiceUnavailable +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupUnknownError + +sealed class PreCreateValidationStatus { + + object Ok : PreCreateValidationStatus() + + object AuthenticationFailed : PreCreateValidationStatus(), CloudBackupAuthFailed + + object BackupServiceUnavailable : PreCreateValidationStatus(), CloudBackupServiceUnavailable + + object ExistingBackupFound : PreCreateValidationStatus(), CloudBackupExistingBackupFound + + object NotEnoughSpace : PreCreateValidationStatus(), CloudBackupNotEnoughSpace + + object OtherError : PreCreateValidationStatus(), CloudBackupUnknownError +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/WriteBackupRequest.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/WriteBackupRequest.kt new file mode 100644 index 0000000..1fbef1a --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/WriteBackupRequest.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model + +class WriteBackupRequest( + val cloudBackup: CloudBackup, + val password: String +) diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/CloudBackupDiff.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/CloudBackupDiff.kt new file mode 100644 index 0000000..6a0d76e --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/CloudBackupDiff.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff + +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.SourcedBackupChanges.WalletsFromCloud +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategyFactory +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.addToCloud +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.addToLocal +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.modifyInCloud +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.modifyLocally +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.removeFromCloud +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.removeLocally + +class CloudBackupDiff( + val localChanges: PerSourceDiff, + val cloudChanges: PerSourceDiff +) { + + class PerSourceDiff( + val added: List, + val modified: List, + val removed: List + ) +} + +fun CloudBackupDiff.PerSourceDiff.isEmpty(): Boolean = added.isEmpty() && modified.isEmpty() && removed.isEmpty() + +fun CloudBackupDiff.PerSourceDiff.isNotEmpty(): Boolean = !isEmpty() + +fun CloudBackupDiff.PerSourceDiff.isDestructive(): Boolean = removed.isNotEmpty() || modified.isNotEmpty() + +fun CloudBackupDiff.PerSourceDiff.isNotDestructive(): Boolean = !isDestructive() + +/** + * @see [CloudBackup.PublicData.localVsCloudDiff] + */ +fun CloudBackup.localVsCloudDiff( + cloudVersion: CloudBackup, + strategyFactory: BackupDiffStrategyFactory +): CloudBackupDiff { + return publicData.localVsCloudDiff(cloudVersion.publicData, strategyFactory) +} + +/** + * Finds the diff between local and cloud versions + * + * [this] - Local snapshot + * [cloudVersion] - Cloud snapshot + */ +fun CloudBackup.PublicData.localVsCloudDiff( + cloudVersion: CloudBackup.PublicData, + strategyFactory: BackupDiffStrategyFactory +): CloudBackupDiff { + val localVersion = this + val strategy = strategyFactory(localVersion, cloudVersion) + + val localToCloudDiff = CollectionDiffer.findDiff(newItems = cloudVersion.wallets, oldItems = localVersion.wallets, forceUseNewItems = false) + + val walletsOnlyPresentInCloud = localToCloudDiff.added.asCloudWallets() + val walletsModifiedByCloud = localToCloudDiff.updated.asCloudWallets() + val walletsOnlyPresentLocally = localToCloudDiff.removed.asLocalWallets() + + val cloudToLocalDiff = CollectionDiffer.findDiff(newItems = localVersion.wallets, oldItems = cloudVersion.wallets, forceUseNewItems = false) + val walletsModifiedByLocal = cloudToLocalDiff.updated.asLocalWallets() + + return CloudBackupDiff( + localChanges = CloudBackupDiff.PerSourceDiff( + added = strategy.addToLocal(walletsOnlyPresentInCloud), + removed = strategy.removeLocally(walletsOnlyPresentLocally), + modified = strategy.modifyLocally(walletsModifiedByCloud) + ), + cloudChanges = CloudBackupDiff.PerSourceDiff( + added = strategy.addToCloud(walletsOnlyPresentLocally), + removed = strategy.removeFromCloud(walletsOnlyPresentInCloud), + modified = strategy.modifyInCloud(walletsModifiedByLocal) + ) + ) +} + +private fun List.asCloudWallets(): SourcedBackupChanges { + return SourcedBackupChanges(this) +} + +private fun List.asLocalWallets(): SourcedBackupChanges { + return SourcedBackupChanges(this) +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/SourcedBackupChanges.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/SourcedBackupChanges.kt new file mode 100644 index 0000000..72493ea --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/SourcedBackupChanges.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup + +class SourcedBackupChanges(val changes: List) { + + object LocalWallets + + object WalletsFromCloud +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/BackupDiffStrategy.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/BackupDiffStrategy.kt new file mode 100644 index 0000000..8e8d5a2 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/BackupDiffStrategy.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.SourcedBackupChanges +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.SourcedBackupChanges.LocalWallets +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.SourcedBackupChanges.WalletsFromCloud + +typealias BackupDiffStrategyFactory = (localData: CloudBackup.PublicData, remoteData: CloudBackup.PublicData) -> BackupDiffStrategy + +interface BackupDiffStrategy { + + companion object { + + fun importFromCloud(): BackupDiffStrategyFactory = { _, _ -> + ImportFromCloudStrategy() + } + + fun syncWithCloud(): BackupDiffStrategyFactory = { local, remote -> + SyncWithCloudStrategy(localTimestamp = local.modifiedAt, cloudTimestamp = remote.modifiedAt) + } + + fun overwriteLocal(): BackupDiffStrategyFactory = { _, _ -> + OverwriteLocalStrategy() + } + } + + fun shouldAddLocally(walletsOnlyPresentInCloud: SourcedBackupChanges): Boolean + + fun shouldRemoveFromCloud(walletsOnlyPresentInCloud: SourcedBackupChanges): Boolean + + fun shouldRemoveLocally(walletsOnlyPresentLocally: SourcedBackupChanges): Boolean + + fun shouldAddToCloud(walletsOnlyPresentLocally: SourcedBackupChanges): Boolean + + fun shouldModifyLocally(modifiedInCloud: SourcedBackupChanges): Boolean + + fun shouldModifyInCloud(modifiedLocally: SourcedBackupChanges): Boolean +} + +fun BackupDiffStrategy.addToLocal(walletsOnlyPresentInCloud: SourcedBackupChanges): List { + return walletsOnlyPresentInCloud.takeValueOrEmpty(shouldAddLocally(walletsOnlyPresentInCloud)) +} + +fun BackupDiffStrategy.removeFromCloud(walletsOnlyPresentInCloud: SourcedBackupChanges): List { + return walletsOnlyPresentInCloud.takeValueOrEmpty(shouldRemoveFromCloud(walletsOnlyPresentInCloud)) +} + +fun BackupDiffStrategy.removeLocally(walletsOnlyPresentLocally: SourcedBackupChanges): List { + return walletsOnlyPresentLocally.takeValueOrEmpty(shouldRemoveLocally(walletsOnlyPresentLocally)) +} + +fun BackupDiffStrategy.addToCloud(walletsOnlyPresentLocally: SourcedBackupChanges): List { + return walletsOnlyPresentLocally.takeValueOrEmpty(shouldAddToCloud(walletsOnlyPresentLocally)) +} + +fun BackupDiffStrategy.modifyLocally(modifiedInCloud: SourcedBackupChanges): List { + return modifiedInCloud.takeValueOrEmpty(shouldModifyLocally(modifiedInCloud)) +} + +fun BackupDiffStrategy.modifyInCloud(modifiedLocally: SourcedBackupChanges): List { + return modifiedLocally.takeValueOrEmpty(shouldModifyInCloud(modifiedLocally)) +} + +private fun SourcedBackupChanges<*>.takeValueOrEmpty(condition: Boolean): List { + return if (condition) { + changes + } else { + emptyList() + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/ImportFromCloudStrategy.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/ImportFromCloudStrategy.kt new file mode 100644 index 0000000..4248c40 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/ImportFromCloudStrategy.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.SourcedBackupChanges + +/** + * Strategy that retains both local-exclusive and cloud-exclusive wallets + * Modified wallets are taken from local state and updated in cloud + */ +class ImportFromCloudStrategy : BackupDiffStrategy { + + override fun shouldAddLocally(walletsOnlyPresentInCloud: SourcedBackupChanges): Boolean { + return true + } + + override fun shouldRemoveFromCloud(walletsOnlyPresentInCloud: SourcedBackupChanges): Boolean { + return false + } + + override fun shouldRemoveLocally(walletsOnlyPresentLocally: SourcedBackupChanges): Boolean { + return false + } + + override fun shouldAddToCloud(walletsOnlyPresentLocally: SourcedBackupChanges): Boolean { + return true + } + + override fun shouldModifyLocally(modifiedInCloud: SourcedBackupChanges): Boolean { + return false + } + + override fun shouldModifyInCloud(modifiedLocally: SourcedBackupChanges): Boolean { + return true + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/OverwriteLocalStrategy.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/OverwriteLocalStrategy.kt new file mode 100644 index 0000000..1628076 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/OverwriteLocalStrategy.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.SourcedBackupChanges + +/** + * Strategy that considers cloud version to be the source of truth + */ +class OverwriteLocalStrategy : BackupDiffStrategy { + + override fun shouldAddLocally(walletsOnlyPresentInCloud: SourcedBackupChanges): Boolean { + return true + } + + override fun shouldRemoveFromCloud(walletsOnlyPresentInCloud: SourcedBackupChanges): Boolean { + return false + } + + override fun shouldRemoveLocally(walletsOnlyPresentLocally: SourcedBackupChanges): Boolean { + return true + } + + override fun shouldAddToCloud(walletsOnlyPresentLocally: SourcedBackupChanges): Boolean { + return false + } + + override fun shouldModifyLocally(modifiedInCloud: SourcedBackupChanges): Boolean { + return true + } + + override fun shouldModifyInCloud(modifiedLocally: SourcedBackupChanges): Boolean { + return false + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/SyncWithCloudStrategy.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/SyncWithCloudStrategy.kt new file mode 100644 index 0000000..9a37f52 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/diff/strategy/SyncWithCloudStrategy.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.SourcedBackupChanges +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.SourcedBackupChanges.LocalWallets +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.SourcedBackupChanges.WalletsFromCloud + +/** + * Strategy that uses modifiedAt timestamps to identify priority + */ +class SyncWithCloudStrategy( + localTimestamp: Long, + cloudTimestamp: Long +) : BackupDiffStrategy { + + private val cloudInPriority = cloudTimestamp > localTimestamp + private val localInPriority = localTimestamp > cloudTimestamp + + override fun shouldAddLocally(walletsOnlyPresentInCloud: SourcedBackupChanges): Boolean { + return cloudInPriority + } + + override fun shouldRemoveFromCloud(walletsOnlyPresentInCloud: SourcedBackupChanges): Boolean { + return localInPriority + } + + override fun shouldRemoveLocally(walletsOnlyPresentLocally: SourcedBackupChanges): Boolean { + return cloudInPriority + } + + override fun shouldAddToCloud(walletsOnlyPresentLocally: SourcedBackupChanges): Boolean { + return localInPriority + } + + override fun shouldModifyLocally(modifiedInCloud: SourcedBackupChanges): Boolean { + return cloudInPriority + } + + override fun shouldModifyInCloud(modifiedLocally: SourcedBackupChanges): Boolean { + return localInPriority + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/CloudBackupErrors.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/CloudBackupErrors.kt new file mode 100644 index 0000000..6c926c1 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/CloudBackupErrors.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff + +interface CloudBackupAuthFailed + +interface CloudBackupServiceUnavailable + +interface CloudBackupExistingBackupFound + +interface CloudBackupNotFound + +interface CloudBackupNotEnoughSpace + +interface CloudBackupUnknownError + +interface CorruptedBackupError + +class CannotApplyNonDestructiveDiff(val cloudBackupDiff: CloudBackupDiff, val cloudBackup: CloudBackup) : Throwable() + +class PasswordNotSaved : Throwable() + +class InvalidBackupPasswordError : Throwable() diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/DeleteBackupError.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/DeleteBackupError.kt new file mode 100644 index 0000000..694e061 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/DeleteBackupError.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors + +sealed class DeleteBackupError : Throwable() { + + object Other : DeleteBackupError(), CloudBackupUnknownError +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/FetchBackupError.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/FetchBackupError.kt new file mode 100644 index 0000000..8c13d73 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/FetchBackupError.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors + +sealed class FetchBackupError : Throwable() { + + object AuthFailed : FetchBackupError(), CloudBackupAuthFailed + + object BackupNotFound : FetchBackupError(), CloudBackupNotFound + + object CorruptedBackup : FetchBackupError(), CorruptedBackupError + + object Other : FetchBackupError(), CloudBackupUnknownError +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/WriteBackupError.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/WriteBackupError.kt new file mode 100644 index 0000000..dc98562 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/errors/WriteBackupError.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors + +sealed class WriteBackupError : Throwable() { + + object Other : WriteBackupError(), CloudBackupUnknownError +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/action/CloudBackupActions.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/action/CloudBackupActions.kt new file mode 100644 index 0000000..e5216c2 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/action/CloudBackupActions.kt @@ -0,0 +1,171 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.action + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.addColor +import io.novafoundation.nova.common.utils.formatting.spannable.spannableFormatting +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences +import io.novafoundation.nova.common.view.bottomSheet.action.negative +import io.novafoundation.nova.common.view.bottomSheet.action.primary +import io.novafoundation.nova.common.view.bottomSheet.action.secondary +import io.novafoundation.nova.feature_cloud_backup_api.R + +fun ActionBottomSheetLauncher.launchBackupLostPasswordAction(resourceManager: ResourceManager, onDeleteClicked: () -> Unit) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_password, + title = resourceManager.getString(R.string.restore_cloud_backup_delete_backup_title), + subtitle = with(resourceManager) { + val highlightedFirstPart = getString(R.string.restore_cloud_backup_delete_backup_description_highlighted_1) + .addColor(getColor(R.color.text_primary)) + + val highlightedSecondPart = getString(R.string.restore_cloud_backup_delete_backup_description_highlighted_2) + .addColor(getColor(R.color.text_primary)) + + getString(R.string.restore_cloud_backup_delete_backup_description).spannableFormatting(highlightedFirstPart, highlightedSecondPart) + }, + neutralButtonPreferences = ButtonPreferences.secondary( + resourceManager.getString( + R.string.common_cancel + ) + ), + actionButtonPreferences = ButtonPreferences.negative( + resourceManager.getString(R.string.cloud_backup_delete_button), + onDeleteClicked + ) + ) +} + +fun ActionBottomSheetLauncher.launchDeleteBackupAction(resourceManager: ResourceManager, onDeleteClicked: () -> Unit) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_delete, + title = resourceManager.getString(R.string.cloud_backup_delete_action_title), + subtitle = with(resourceManager) { + val highlightedPart = getString(R.string.cloud_backup_delete_action_subtitle_highlighted) + .addColor(getColor(R.color.text_primary)) + getString(R.string.cloud_backup_delete_action_subtitle).spannableFormatting(highlightedPart) + }, + neutralButtonPreferences = ButtonPreferences.secondary( + resourceManager.getString( + R.string.common_cancel + ) + ), + actionButtonPreferences = ButtonPreferences.negative( + resourceManager.getString(R.string.cloud_backup_delete_button), + onDeleteClicked + ) + ) +} + +fun ActionBottomSheetLauncher.launchRememberPasswordWarning(resourceManager: ResourceManager) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_lock, + title = resourceManager.getString(R.string.create_cloud_backup_password_alert_title), + subtitle = with(resourceManager) { + val highlightedPart = getString(R.string.create_cloud_backup_password_alert_subtitle_highlighted) + .addColor(getColor(R.color.text_primary)) + + getString(R.string.create_cloud_backup_password_alert_subtitle).spannableFormatting(highlightedPart) + }, + actionButtonPreferences = ButtonPreferences.primary(resourceManager.getString(R.string.common_got_it)) + ) +} + +fun ActionBottomSheetLauncher.launchCorruptedBackupFoundAction(resourceManager: ResourceManager, onDeleteClicked: () -> Unit) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_error, + title = resourceManager.getString(R.string.corrupted_backup_error_title), + subtitle = with(resourceManager) { + val highlightedPart = getString(R.string.corrupted_backup_error_subtitle_highlighted) + .addColor(getColor(R.color.text_primary)) + + getString(R.string.corrupted_backup_error_subtitle).spannableFormatting(highlightedPart) + }, + neutralButtonPreferences = ButtonPreferences.secondary(resourceManager.getString(R.string.common_cancel)), + actionButtonPreferences = ButtonPreferences.negative( + resourceManager.getString(R.string.cloud_backup_delete_button), + onDeleteClicked + ) + ) +} + +fun ActionBottomSheetLauncher.launchExistingCloudBackupAction(resourceManager: ResourceManager, onImportClicked: () -> Unit) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_sync, + title = resourceManager.getString(R.string.existing_cloud_backup_found_title), + subtitle = with(resourceManager) { + val highlightedPart = getString(R.string.existing_cloud_backup_found_subtitle_highlight) + .addColor(getColor(R.color.text_primary)) + + getString(R.string.existing_cloud_backup_found_subtitle).spannableFormatting(highlightedPart) + }, + neutralButtonPreferences = ButtonPreferences.secondary(resourceManager.getString(R.string.common_cancel)), + actionButtonPreferences = ButtonPreferences.primary( + resourceManager.getString(R.string.existing_cloud_backup_found_button), + onImportClicked + ) + ) +} + +fun ActionBottomSheetLauncher.launchDeprecatedPasswordAction(resourceManager: ResourceManager, onEnterPasswordClick: () -> Unit) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_password, + title = resourceManager.getString(R.string.deprecated_cloud_backup_password_title), + subtitle = with(resourceManager) { + val highlightedPart = getString(R.string.deprecated_cloud_backup_password_subtitle_highlight) + .addColor(getColor(R.color.text_primary)) + + getString(R.string.deprecated_cloud_backup_password_subtitle).spannableFormatting(highlightedPart) + }, + neutralButtonPreferences = ButtonPreferences.secondary(resourceManager.getString(R.string.common_not_now)), + actionButtonPreferences = ButtonPreferences.primary( + resourceManager.getString(R.string.common_enter_password), + onEnterPasswordClick + ) + ) +} + +fun ActionBottomSheetLauncher.launchCloudBackupChangesAction(resourceManager: ResourceManager, onReviewClicked: () -> Unit) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_warning, + title = resourceManager.getString(R.string.cloud_backup_destructive_changes_action_title), + subtitle = resourceManager.getString(R.string.cloud_backup_destructive_changes_action_subtitle), + neutralButtonPreferences = ButtonPreferences.secondary( + resourceManager.getString(R.string.common_not_now) + ), + actionButtonPreferences = ButtonPreferences.primary( + resourceManager.getString(R.string.cloud_backup_destructive_changes_button), + onReviewClicked + ) + ) +} + +fun ActionBottomSheetLauncher.launchCloudBackupDestructiveChangesNotApplied( + resourceManager: ResourceManager, + onReviewClicked: () -> Unit +) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_warning, + title = resourceManager.getString(R.string.cloud_backup_destructive_changes_not_applied_title), + subtitle = resourceManager.getString(R.string.cloud_backup_destructive_changes_not_applied_subtitle), + neutralButtonPreferences = ButtonPreferences.secondary( + resourceManager.getString(R.string.common_not_now) + ), + actionButtonPreferences = ButtonPreferences.negative( + resourceManager.getString(R.string.cloud_backup_destructive_changes_not_applied_button), + onReviewClicked + ) + ) +} + +fun ActionBottomSheetLauncher.launchCloudBackupDestructiveChangesNotAppliedWithoutRouting( + resourceManager: ResourceManager +) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_warning, + title = resourceManager.getString(R.string.cloud_backup_destructive_changes_not_applied_title), + subtitle = resourceManager.getString(R.string.cloud_backup_destructive_changes_not_applied_subtitle), + actionButtonPreferences = ButtonPreferences.secondary( + resourceManager.getString(R.string.common_got_it) + ) + ) +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/confirmation/CloudBackupConfirmations.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/confirmation/CloudBackupConfirmations.kt new file mode 100644 index 0000000..a6afd29 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/confirmation/CloudBackupConfirmations.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.confirmation + +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationAwaitable +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.fromRes +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_cloud_backup_api.R + +suspend fun ConfirmationAwaitable.awaitDeleteBackupConfirmation(resourceManager: ResourceManager) { + awaitAction( + ConfirmationDialogInfo.fromRes( + resourceManager, + title = R.string.cloud_backup_delete_backup_confirmation_title, + message = R.string.cloud_backup_delete_backup_confirmation_message, + positiveButton = R.string.cloud_backup_delete_button, + negativeButton = R.string.common_cancel + ) + ) +} + +suspend fun ConfirmationAwaitable.awaitBackupDestructiveChangesConfirmation(resourceManager: ResourceManager) { + awaitAction( + ConfirmationDialogInfo.fromRes( + resourceManager, + title = R.string.cloud_backup_destructive_changes_confirmation_title, + message = R.string.cloud_backup_destructive_changes_confirmation_subtitle, + positiveButton = R.string.common_apply, + negativeButton = R.string.common_cancel + ) + ) +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/ChangePasswordCloudBackupFailureToUi.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/ChangePasswordCloudBackupFailureToUi.kt new file mode 100644 index 0000000..cd443d8 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/ChangePasswordCloudBackupFailureToUi.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.toCustomDialogPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupAuthFailed +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupNotEnoughSpace +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupAuthFailed +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupInvalidPassword +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupNotEnoughSpace +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupUnknownError + +fun mapChangePasswordValidationStatusToUi( + resourceManager: ResourceManager, + status: Throwable, + initSignIn: () -> Unit +): CustomDialogDisplayer.Payload { + return when (status) { + is CloudBackupAuthFailed -> handleCloudBackupAuthFailed(resourceManager, initSignIn) + + is CloudBackupNotEnoughSpace -> handleCloudBackupNotEnoughSpace(resourceManager).toCustomDialogPayload(resourceManager) + + is InvalidBackupPasswordError -> handleCloudBackupInvalidPassword(resourceManager).toCustomDialogPayload(resourceManager) + + else -> handleCloudBackupUnknownError(resourceManager).toCustomDialogPayload(resourceManager) + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/CheckBackupAvailableFailureToUi.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/CheckBackupAvailableFailureToUi.kt new file mode 100644 index 0000000..5d2f977 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/CheckBackupAvailableFailureToUi.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.toCustomDialogPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupAuthFailed +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupServiceUnavailable +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupAuthFailed +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupServiceUnavailable +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupUnknownError + +fun mapCheckBackupAvailableFailureToUi( + resourceManager: ResourceManager, + throwable: Throwable, + initSignIn: () -> Unit +): CustomDialogDisplayer.Payload { + return when (throwable) { + is CloudBackupAuthFailed -> handleCloudBackupAuthFailed(resourceManager, initSignIn) + + is CloudBackupServiceUnavailable -> handleCloudBackupServiceUnavailable(resourceManager).toCustomDialogPayload(resourceManager) + + else -> handleCloudBackupUnknownError(resourceManager).toCustomDialogPayload(resourceManager) + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/ConnectCloudBackupFailureToUi.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/ConnectCloudBackupFailureToUi.kt new file mode 100644 index 0000000..f873149 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/ConnectCloudBackupFailureToUi.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.toCustomDialogPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.PreCreateValidationStatus +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupAuthFailed +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupNotEnoughSpace +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupServiceUnavailable +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupUnknownError + +fun mapPreCreateValidationStatusToUi( + resourceManager: ResourceManager, + status: PreCreateValidationStatus, + existingBackupFound: () -> Unit, + initSignIn: () -> Unit +): CustomDialogDisplayer.Payload? { + return when (status) { + is PreCreateValidationStatus.AuthenticationFailed -> handleCloudBackupAuthFailed(resourceManager, initSignIn) + + is PreCreateValidationStatus.BackupServiceUnavailable -> handleCloudBackupServiceUnavailable(resourceManager).toCustomDialogPayload(resourceManager) + + is PreCreateValidationStatus.ExistingBackupFound -> { + existingBackupFound() + null + } + + is PreCreateValidationStatus.NotEnoughSpace -> handleCloudBackupNotEnoughSpace(resourceManager).toCustomDialogPayload(resourceManager) + + is PreCreateValidationStatus.OtherError -> handleCloudBackupUnknownError(resourceManager).toCustomDialogPayload(resourceManager) + + is PreCreateValidationStatus.Ok -> null + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/RestoreBackupFailureToUi.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/RestoreBackupFailureToUi.kt new file mode 100644 index 0000000..072dc18 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/RestoreBackupFailureToUi.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling + +import io.novafoundation.nova.common.base.TitleAndMessage + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupNotFound +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupUnknownError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CorruptedBackupError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupInvalidPassword +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupNotFound +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupUnknownError + +fun mapRestoreBackupFailureToUi( + resourceManager: ResourceManager, + throwable: Throwable, + corruptedBackupFound: () -> Unit +): TitleAndMessage? { + return when (throwable) { + is InvalidBackupPasswordError -> handleCloudBackupInvalidPassword(resourceManager) + + is CorruptedBackupError -> { + corruptedBackupFound() + return null + } + + is CloudBackupNotFound -> handleCloudBackupNotFound(resourceManager) + + is CloudBackupUnknownError -> handleCloudBackupUnknownError(resourceManager) + + else -> null + } +} + +fun mapDeleteBackupFailureToUi( + resourceManager: ResourceManager, + throwable: Throwable +): TitleAndMessage? { + return when (throwable) { + is CloudBackupUnknownError -> handleCloudBackupUnknownError(resourceManager) + + else -> null + } +} + +fun mapCheckPasswordFailureToUi( + resourceManager: ResourceManager, + throwable: Throwable +): TitleAndMessage? { + return when (throwable) { + is InvalidBackupPasswordError -> handleCloudBackupInvalidPassword(resourceManager) + + else -> null + } +} + +fun mapRestorePasswordFailureToUi( + resourceManager: ResourceManager, + throwable: Throwable, + corruptedBackupFound: () -> Unit +): TitleAndMessage? { + return when (throwable) { + is InvalidBackupPasswordError -> handleCloudBackupInvalidPassword(resourceManager) + + is CorruptedBackupError -> { + corruptedBackupFound() + null + } + + is CloudBackupNotFound -> handleCloudBackupNotFound(resourceManager) + + else -> handleCloudBackupUnknownError(resourceManager) + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/SyncBackupFailureToUi.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/SyncBackupFailureToUi.kt new file mode 100644 index 0000000..e34b5e4 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/SyncBackupFailureToUi.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.toCustomDialogPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CannotApplyNonDestructiveDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupAuthFailed +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupUnknownError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CorruptedBackupError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.PasswordNotSaved +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupAuthFailed +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupUnknownError + +fun mapCloudBackupSyncFailed( + resourceManager: ResourceManager, + state: Throwable, + onPasswordDeprecated: () -> Unit, + onCorruptedBackup: () -> Unit, + initSignIn: () -> Unit, + onDestructiveBackupFound: (CloudBackupDiff, CloudBackup) -> Unit, +): CustomDialogDisplayer.Payload? { + return when (state) { + is CloudBackupAuthFailed -> handleCloudBackupAuthFailed(resourceManager, initSignIn) + + is CloudBackupUnknownError -> handleCloudBackupUnknownError(resourceManager) + .toCustomDialogPayload(resourceManager) + + is CannotApplyNonDestructiveDiff -> { + onDestructiveBackupFound(state.cloudBackupDiff, state.cloudBackup) + null + } + + is CorruptedBackupError -> { + onCorruptedBackup() + null + } + + is InvalidBackupPasswordError, is PasswordNotSaved -> { + onPasswordDeprecated() + null + } + + else -> null + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/WriteBackupFailureToUi.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/WriteBackupFailureToUi.kt new file mode 100644 index 0000000..18e48b2 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/WriteBackupFailureToUi.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling + +import io.novafoundation.nova.common.base.TitleAndMessage + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupNotEnoughSpace +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupNotEnoughSpace +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.handleCloudBackupUnknownError + +fun mapWriteBackupFailureToUi( + resourceManager: ResourceManager, + throwable: Throwable +): TitleAndMessage { + return when (throwable) { + is CloudBackupNotEnoughSpace -> handleCloudBackupNotEnoughSpace(resourceManager) + + else -> handleCloudBackupUnknownError(resourceManager) + } +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/handlers/CloudBackupErrorHandlers.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/handlers/CloudBackupErrorHandlers.kt new file mode 100644 index 0000000..84c1991 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/handlers/CloudBackupErrorHandlers.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer.Payload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_cloud_backup_api.R + +fun handleCloudBackupAuthFailed(resourceManager: ResourceManager, onSignInClicked: () -> Unit): Payload { + return Payload( + resourceManager.getString(R.string.cloud_backup_error_google_service_auth_failed_title), + resourceManager.getString(R.string.cloud_backup_error_google_service_auth_failed_message), + Payload.DialogAction(resourceManager.getString(R.string.common_sign_in), onSignInClicked), + Payload.DialogAction.noOp(resourceManager.getString(R.string.common_cancel)) + ) +} + +fun handleCloudBackupServiceUnavailable(resourceManager: ResourceManager): TitleAndMessage { + return TitleAndMessage( + resourceManager.getString(R.string.cloud_backup_error_google_service_not_found_title), + resourceManager.getString(R.string.cloud_backup_error_google_service_not_found_message) + ) +} + +fun handleCloudBackupNotEnoughSpace(resourceManager: ResourceManager): TitleAndMessage { + return TitleAndMessage( + resourceManager.getString(R.string.cloud_backup_error_google_service_not_enough_space_title), + resourceManager.getString(R.string.cloud_backup_error_google_service_not_enough_space_message) + ) +} + +fun handleCloudBackupNotFound(resourceManager: ResourceManager): TitleAndMessage { + return TitleAndMessage( + resourceManager.getString(R.string.cloud_backup_error_not_found_title), + resourceManager.getString(R.string.cloud_backup_error_not_found_message) + ) +} + +fun handleCloudBackupUnknownError(resourceManager: ResourceManager): TitleAndMessage { + return TitleAndMessage( + resourceManager.getString(R.string.cloud_backup_error_google_service_other_title), + resourceManager.getString(R.string.cloud_backup_error_google_service_other_message) + ) +} + +fun handleCloudBackupInvalidPassword(resourceManager: ResourceManager): TitleAndMessage { + return TitleAndMessage( + resourceManager.getString(R.string.cloud_backup_error_invalid_password_title), + resourceManager.getString(R.string.cloud_backup_error_invalid_password_message), + ) +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/handlers/CloudBackupHandlersExt.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/handlers/CloudBackupHandlersExt.kt new file mode 100644 index 0000000..99ae525 --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/errorHandling/handlers/CloudBackupHandlersExt.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.base.showError +import io.novafoundation.nova.common.resources.ResourceManager + +fun BaseViewModel.showCloudBackupUnknownError(resourceManager: ResourceManager) { + showError(handleCloudBackupUnknownError(resourceManager)) +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/mixin/CloudBackupChangingWarningMixin.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/mixin/CloudBackupChangingWarningMixin.kt new file mode 100644 index 0000000..9bfe2cd --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/mixin/CloudBackupChangingWarningMixin.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin + +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import kotlinx.coroutines.CoroutineScope + +interface CloudBackupChangingWarningMixinFactory { + + fun create(coroutineScope: CoroutineScope): CloudBackupChangingWarningMixin +} + +interface CloudBackupChangingWarningMixin { + + val actionBottomSheetLauncher: ActionBottomSheetLauncher + + fun launchChangingConfirmationIfNeeded(onConfirm: () -> Unit) + + fun launchRemovingConfirmationIfNeeded(onConfirm: () -> Unit) +} diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/mixin/CloudBackupChangingWarningMixinUI.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/mixin/CloudBackupChangingWarningMixinUI.kt new file mode 100644 index 0000000..691e19b --- /dev/null +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/presenter/mixin/CloudBackupChangingWarningMixinUI.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin + +import io.novafoundation.nova.common.base.BaseScreenMixin +import io.novafoundation.nova.common.view.bottomSheet.action.observeActionBottomSheet + +fun BaseScreenMixin<*>.observeConfirmationAction(mixinFactory: CloudBackupChangingWarningMixin) { + observeActionBottomSheet(mixinFactory.actionBottomSheetLauncher) +} diff --git a/feature-cloud-backup-impl/.gitignore b/feature-cloud-backup-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-cloud-backup-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-cloud-backup-impl/build.gradle b/feature-cloud-backup-impl/build.gradle new file mode 100644 index 0000000..4195c49 --- /dev/null +++ b/feature-cloud-backup-impl/build.gradle @@ -0,0 +1,58 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../scripts/secrets.gradle' + +android { + namespace 'io.novafoundation.nova.feature_cloud_backup_impl' + + defaultConfig { + + + + buildConfigField "String", "GOOGLE_OAUTH_ID", readStringSecret("DEBUG_GOOGLE_OAUTH_ID") + } + + buildTypes { + release { + buildConfigField "String", "GOOGLE_OAUTH_ID", readStringSecret("RELEASE_GOOGLE_OAUTH_ID") + } + } + + packagingOptions { + resources.excludes.add("META-INF/DEPENDENCIES") + } + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + + api project(":feature-cloud-backup-api") + + + implementation androidDep + + implementation daggerDep + ksp daggerCompiler + + + implementation androidDep + + api project(':core-api') + api project(':core-db') + + implementation playServicesAuthDep + implementation googleApiClientDep + implementation googleDriveDep + + testImplementation project(':test-shared') + testImplementation project(":feature-cloud-backup-test") +} \ No newline at end of file diff --git a/feature-cloud-backup-impl/consumer-rules.pro b/feature-cloud-backup-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-cloud-backup-impl/proguard-rules.pro b/feature-cloud-backup-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-cloud-backup-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-cloud-backup-impl/src/main/AndroidManifest.xml b/feature-cloud-backup-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-cloud-backup-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/BackupData.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/BackupData.kt new file mode 100644 index 0000000..2c55b2e --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/BackupData.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.data + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup + +@JvmInline +value class UnencryptedPrivateData(val unencryptedData: String) + +@JvmInline +value class EncryptedPrivateData(val encryptedData: ByteArray) + +class SerializedBackup( + val publicData: CloudBackup.PublicData, + val privateData: PRIVATE +) + +@JvmInline +value class ReadyForStorageBackup(val value: String) diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/cloudStorage/CloudBackupStorage.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/cloudStorage/CloudBackupStorage.kt new file mode 100644 index 0000000..d273c66 --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/cloudStorage/CloudBackupStorage.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage + +import io.novafoundation.nova.common.utils.InformationSize +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.FetchBackupError +import io.novafoundation.nova.feature_cloud_backup_impl.data.ReadyForStorageBackup + +internal interface CloudBackupStorage { + + suspend fun hasEnoughFreeStorage(neededSize: InformationSize): Result + + suspend fun isCloudStorageServiceAvailable(): Boolean + + suspend fun isUserAuthenticated(): Boolean + + suspend fun authenticateUser(): Result + + suspend fun checkBackupExists(): Result + + suspend fun writeBackup(backup: ReadyForStorageBackup): Result + + /** + * @throws FetchBackupError.BackupNotFound + */ + suspend fun fetchBackup(): Result + + suspend fun deleteBackup(): Result +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/cloudStorage/GoogleDriveBackupStorage.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/cloudStorage/GoogleDriveBackupStorage.kt new file mode 100644 index 0000000..66e2340 --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/cloudStorage/GoogleDriveBackupStorage.kt @@ -0,0 +1,283 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage + +import android.app.Activity.RESULT_OK +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import com.google.android.gms.auth.UserRecoverableAuthException +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.Scope +import com.google.android.gms.tasks.Task +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException +import com.google.api.client.http.ByteArrayContent +import com.google.api.client.http.HttpResponseException +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.requireActivity +import io.novafoundation.nova.common.utils.InformationSize +import io.novafoundation.nova.common.utils.InformationSize.Companion.bytes +import io.novafoundation.nova.common.utils.mapErrorNotInstance +import io.novafoundation.nova.common.utils.systemCall.SystemCall +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.FetchBackupError +import io.novafoundation.nova.feature_cloud_backup_impl.BuildConfig +import io.novafoundation.nova.feature_cloud_backup_impl.data.ReadyForStorageBackup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream + +internal class GoogleDriveBackupStorage( + private val contextManager: ContextManager, + private val systemCallExecutor: SystemCallExecutor, + private val oauthClientId: String, + private val googleApiAvailabilityProvider: GoogleApiAvailabilityProvider, + private val debug: Boolean = BuildConfig.DEBUG +) : CloudBackupStorage { + + companion object { + + private const val BACKUP_MIME_TYPE = "application/json" + } + + private val drive: Drive by lazy { + createGoogleDriveService() + } + + override suspend fun hasEnoughFreeStorage(neededSize: InformationSize): Result = withContext(Dispatchers.IO) { + runCatchingRecoveringAuthErrors { + val remainingSpaceInDrive = getRemainingSpace() + + remainingSpaceInDrive >= neededSize + } + } + + override suspend fun isCloudStorageServiceAvailable(): Boolean { + return googleApiAvailabilityProvider.isAvailable() + } + + override suspend fun isUserAuthenticated(): Boolean = withContext(Dispatchers.IO) { + val account = GoogleSignIn.getLastSignedInAccount(contextManager.getApplicationContext()) + + account != null + } + + override suspend fun authenticateUser(): Result = withContext(Dispatchers.IO) { + val systemCall = GoogleSignInSystemCall(contextManager, oauthClientId, driveScope()) + systemCallExecutor.executeSystemCall(systemCall) + } + + override suspend fun checkBackupExists(): Result = withContext(Dispatchers.IO) { + runCatchingRecoveringAuthErrors { + checkBackupExistsUnsafe() + } + } + + override suspend fun writeBackup(backup: ReadyForStorageBackup): Result = withContext(Dispatchers.IO) { + runCatchingRecoveringAuthErrors { + writeBackupFileToDrive(backup.value) + } + } + + override suspend fun fetchBackup(): Result = withContext(Dispatchers.IO) { + runCatchingRecoveringAuthErrors { + val fileContent = readBackupFileFromDrive() + + ReadyForStorageBackup(fileContent) + }.mapErrorNotInstance<_, FetchBackupError> { + when (it) { + is UserRecoverableAuthException, + is UserRecoverableAuthIOException -> FetchBackupError.AuthFailed + + else -> it + } + } + } + + override suspend fun deleteBackup(): Result = withContext(Dispatchers.IO) { + runCatchingRecoveringAuthErrors { + deleteBackupFileFromDrive() + } + } + + private suspend fun runCatchingRecoveringAuthErrors(action: suspend () -> T): Result { + return runCatching { action() } + .recoverCatching { + when (it) { + is UserRecoverableAuthException -> it.askForConsent() + is UserRecoverableAuthIOException -> it.cause?.askForConsent() + else -> throw it + } + + action() + } + } + + private fun writeBackupFileToDrive(fileContent: String) { + val contentStream = ByteArrayContent(BACKUP_MIME_TYPE, fileContent.encodeToByteArray()) + + val backupInCloud = getBackupFileFromCloud() + + if (backupInCloud != null) { + drive.files() + .update(backupInCloud.id, null, contentStream) + .execute() + } else { + val fileMetadata = File().apply { name = backupFileName() } + + drive.files().create(fileMetadata, contentStream) + .execute() + } + } + + private fun readBackupFileFromDrive(): String { + val outputStream = ByteArrayOutputStream() + + val backupFile = getBackupFileFromCloud() ?: throw FetchBackupError.BackupNotFound + + try { + drive.files() + .get(backupFile.id) + .executeMediaAndDownloadTo(outputStream) + } catch (e: HttpResponseException) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/416 + // Not handle 416 error, to handle it as corrupted backup + if (e.statusCode != 416) { + throw e + } + } + + return outputStream.toString() + } + + private fun deleteBackupFileFromDrive() { + val backupFile = getBackupFileFromCloud() ?: return + + drive.files() + .delete(backupFile.id) + .execute() + } + + private fun checkBackupExistsUnsafe(): Boolean { + return getBackupFileFromCloud() != null + } + + private fun getBackupFileFromCloud(): File? { + return drive.files().list() + .setQ(backupNameQuery()) + .setSpaces("drive") + .setFields("files(id, name)") + .execute() + .files + .firstOrNull() + } + + private fun getRemainingSpace(): InformationSize { + val about = drive.about().get().setFields("storageQuota").execute() + val totalSpace: Long = about.storageQuota.limit + val usedSpace: Long = about.storageQuota.usage + val remainingSpace = totalSpace - usedSpace + + return remainingSpace.bytes + } + + private suspend fun UserRecoverableAuthException.askForConsent() { + systemCallExecutor.executeSystemCall(RemoteConsentSystemCall(this)) + } + + private fun backupNameQuery(): String { + return "name = '" + backupFileName().replace("'", "\\'") + "' and trashed = false" + } + + private fun createGoogleDriveService(): Drive { + val context = contextManager.getApplicationContext() + val account = GoogleSignIn.getLastSignedInAccount(context) + val credential = GoogleAccountCredential.usingOAuth2(context, listOf(driveScope())) + credential.selectedAccount = account!!.account + + return Drive.Builder(NetHttpTransport(), GsonFactory(), credential) + .setApplicationName("Pezkuwi Wallet") + .build() + } + + private fun driveScope(): String = DriveScopes.DRIVE_FILE + + private fun backupFileName(): String { + return if (debug) { + "pezkuwiwallet_backup_debug.json" + } else { + "pezkuwiwallet_backup.json" + } + } +} + +private class GoogleSignInSystemCall( + private val contextManager: ContextManager, + private val oauthClientId: String, + private val scope: String +) : SystemCall { + + companion object { + + private const val REQUEST_CODE = 9001 + } + + override fun createRequest(activity: AppCompatActivity): SystemCall.Request { + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestIdToken(oauthClientId) + .requestScopes(Scope(scope)) + .build() + + val googleSignInClient = GoogleSignIn.getClient(contextManager.requireActivity(), signInOptions) + val signInIntent = googleSignInClient.signInIntent + + return SystemCall.Request( + intent = signInIntent, + requestCode = REQUEST_CODE + ) + } + + override fun parseResult(requestCode: Int, resultCode: Int, intent: Intent?): Result { + val task: Task = GoogleSignIn.getSignedInAccountFromIntent(intent) + + return try { + task.getResult(ApiException::class.java) + + Result.success(Unit) + } catch (e: ApiException) { + Result.failure(e) + } + } +} + +private class RemoteConsentSystemCall( + private val consentException: UserRecoverableAuthException, +) : SystemCall { + + companion object { + + private const val REQUEST_CODE = 9002 + } + + override fun createRequest(activity: AppCompatActivity): SystemCall.Request { + val intent = consentException.intent!! + + return SystemCall.Request(intent, REQUEST_CODE) + } + + override fun parseResult(requestCode: Int, resultCode: Int, intent: Intent?): Result { + return if (resultCode == RESULT_OK) { + Result.success(Unit) + } else { + Result.failure(consentException) + } + } +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/encryption/CloudBackupEncryption.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/encryption/CloudBackupEncryption.kt new file mode 100644 index 0000000..33b7e16 --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/encryption/CloudBackupEncryption.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.data.encryption + +import io.novafoundation.nova.common.utils.dropBytes +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError +import io.novafoundation.nova.feature_cloud_backup_impl.data.EncryptedPrivateData +import io.novafoundation.nova.feature_cloud_backup_impl.data.UnencryptedPrivateData +import io.novasama.substrate_sdk_android.encrypt.json.copyBytes +import io.novasama.substrate_sdk_android.encrypt.xsalsa20poly1305.SecretBox +import org.bouncycastle.crypto.generators.SCrypt +import java.security.SecureRandom +import java.util.Random + +interface CloudBackupEncryption { + + suspend fun encryptBackup(data: UnencryptedPrivateData, password: String): Result + + /** + * @throws InvalidBackupPasswordError + */ + suspend fun decryptBackup(data: EncryptedPrivateData, password: String): Result +} + +class ScryptCloudBackupEncryption : CloudBackupEncryption { + + private val random: Random = SecureRandom() + + companion object { + private const val SCRYPT_KEY_SIZE = 32 + private const val SALT_SIZE = 32 + private const val NONCE_SIZE = 24 + + private const val N = 16384 + private const val p = 1 + private const val r = 8 + } + + override suspend fun encryptBackup(data: UnencryptedPrivateData, password: String): Result { + return runCatching { + val salt = generateSalt() + val encryptionKey = generateScryptKey(password.encodeToByteArray(), salt) + val plaintext = data.unencryptedData.encodeToByteArray() + + val secretBox = SecretBox(encryptionKey) + val nonce = secretBox.nonce(plaintext) + + val secret = secretBox.seal(nonce, plaintext) + val encryptedData = salt + nonce + secret + + EncryptedPrivateData(encryptedData) + } + } + + override suspend fun decryptBackup(data: EncryptedPrivateData, password: String): Result { + return runCatching { + val salt = data.encryptedData.copyBytes(from = 0, size = SALT_SIZE) + val nonce = data.encryptedData.copyBytes(from = SALT_SIZE, size = NONCE_SIZE) + val encryptedContent = data.encryptedData.dropBytes(SALT_SIZE + NONCE_SIZE) + + val encryptionSecret = generateScryptKey(password.encodeToByteArray(), salt) + + val secret = SecretBox(encryptionSecret).open(nonce, encryptedContent) + + if (secret.isEmpty()) { + throw InvalidBackupPasswordError() + } + + UnencryptedPrivateData(secret.decodeToString()) + } + } + + private fun generateScryptKey(password: ByteArray, salt: ByteArray): ByteArray { + return SCrypt.generate(password, salt, N, r, p, SCRYPT_KEY_SIZE) + } + + private fun generateSalt(): ByteArray { + return ByteArray(SALT_SIZE).also { + random.nextBytes(it) + } + } +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/preferences/CloudBackupPreferences.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/preferences/CloudBackupPreferences.kt new file mode 100644 index 0000000..b32fb7e --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/preferences/CloudBackupPreferences.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.data.preferences + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.Date + +interface CloudBackupPreferences { + + suspend fun syncWithCloudEnabled(): Boolean + + suspend fun setSyncWithCloudEnabled(enabled: Boolean) + + fun observeLastSyncedTime(): Flow + + suspend fun setLastSyncedTime(date: Date) + + suspend fun setSavedPassword(password: String) + + suspend fun getSavedPassword(): String? + + fun getCloudBackupWasInitialized(): Boolean + + fun setCloudBackupWasInitialized(value: Boolean) +} + +suspend fun CloudBackupPreferences.enableSyncWithCloud() = setSyncWithCloudEnabled(true) + +internal class SharedPrefsCloudBackupPreferences( + private val preferences: Preferences, + private val encryptedPreferences: EncryptedPreferences +) : CloudBackupPreferences { + + companion object { + private const val CLOUD_BACKUP_WAS_INITIALIZED = "CloudBackupPreferences.cloud_backup_was_initialized" + private const val SYNC_WITH_CLOUD_ENABLED_KEY = "CloudBackupPreferences.sync_with_cloud_enabled" + private const val LAST_SYNC_TIME_KEY = "CloudBackupPreferences.last_sync_time" + private const val BACKUP_ENABLED_DEFAULT = false + + private const val PASSWORD_KEY = "CloudBackupPreferences.backup_password" + } + + override suspend fun syncWithCloudEnabled(): Boolean { + return preferences.getBoolean(SYNC_WITH_CLOUD_ENABLED_KEY, BACKUP_ENABLED_DEFAULT) + } + + override suspend fun setSyncWithCloudEnabled(enabled: Boolean) { + preferences.putBoolean(SYNC_WITH_CLOUD_ENABLED_KEY, enabled) + } + + override fun observeLastSyncedTime(): Flow { + return preferences.keyFlow(LAST_SYNC_TIME_KEY).map { + if (preferences.contains(it)) { + Date(preferences.getLong(it, 0)) + } else { + null + } + } + } + + override suspend fun setLastSyncedTime(date: Date) { + preferences.putLong(LAST_SYNC_TIME_KEY, date.time) + } + + override suspend fun setSavedPassword(password: String) { + encryptedPreferences.putEncryptedString(PASSWORD_KEY, password) + } + + override suspend fun getSavedPassword(): String? { + return encryptedPreferences.getDecryptedString(PASSWORD_KEY) + } + + override fun getCloudBackupWasInitialized(): Boolean { + return preferences.getBoolean(CLOUD_BACKUP_WAS_INITIALIZED, false) + } + + override fun setCloudBackupWasInitialized(value: Boolean) { + preferences.putBoolean(CLOUD_BACKUP_WAS_INITIALIZED, value) + } +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/serializer/CloudBackupSerializer.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/serializer/CloudBackupSerializer.kt new file mode 100644 index 0000000..ee32178 --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/data/serializer/CloudBackupSerializer.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.data.serializer + +import com.google.gson.GsonBuilder +import io.novafoundation.nova.common.utils.ByteArrayHexAdapter +import io.novafoundation.nova.common.utils.InformationSize +import io.novafoundation.nova.common.utils.InformationSize.Companion.megabytes +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_impl.data.EncryptedPrivateData +import io.novafoundation.nova.feature_cloud_backup_impl.data.ReadyForStorageBackup +import io.novafoundation.nova.feature_cloud_backup_impl.data.SerializedBackup +import io.novafoundation.nova.feature_cloud_backup_impl.data.UnencryptedPrivateData + +interface CloudBackupSerializer { + + suspend fun neededSizeForBackup(): InformationSize + + suspend fun serializePrivateData(backup: CloudBackup): Result> + + suspend fun serializePublicData(backup: SerializedBackup): Result + + suspend fun deserializePublicData(backup: ReadyForStorageBackup): Result> + + suspend fun deserializePrivateData(backup: SerializedBackup): Result +} + +internal class JsonCloudBackupSerializer() : CloudBackupSerializer { + + private val gson = GsonBuilder() + .registerTypeHierarchyAdapter(ByteArray::class.java, ByteArrayHexAdapter()) + .create() + + companion object { + + private val neededSizeForBackup: InformationSize = 10.megabytes + } + + override suspend fun neededSizeForBackup(): InformationSize { + return neededSizeForBackup + } + + override suspend fun serializePrivateData(backup: CloudBackup): Result> { + return runCatching { + val privateDataSerialized = gson.toJson(backup.privateData) + + SerializedBackup( + publicData = backup.publicData, + privateData = UnencryptedPrivateData(privateDataSerialized) + ) + } + } + + override suspend fun serializePublicData(backup: SerializedBackup): Result { + return runCatching { + ReadyForStorageBackup(gson.toJson(backup)) + } + } + + override suspend fun deserializePublicData(backup: ReadyForStorageBackup): Result> { + return runCatching { + gson.fromJson>(backup.value).also { + // Gson doesn't fail on missing fields so we do some preliminary checks here + requireNotNull(it.publicData) + requireNotNull(it.privateData) + + // Do not allow empty backups + require(it.publicData.wallets.isNotEmpty()) + } + } + } + + override suspend fun deserializePrivateData(backup: SerializedBackup): Result { + return runCatching { + val privateData: CloudBackup.PrivateData = gson.fromJson(backup.privateData.unencryptedData).also { + // Gson doesn't fail on missing fields so we do some preliminary checks here + requireNotNull(it.wallets) + } + + CloudBackup( + publicData = backup.publicData, + privateData = privateData + ) + } + } +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureComponent.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureComponent.kt new file mode 100644 index 0000000..4914ada --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureComponent.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi +import io.novafoundation.nova.feature_cloud_backup_impl.presentation.CloudBackupRouter +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + CloudBackupFeatureDependencies::class + ], + modules = [ + CloudBackupFeatureModule::class, + ] +) +@FeatureScope +interface CloudBackupFeatureComponent : CloudBackupFeatureApi { + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance accountRouter: CloudBackupRouter, + deps: CloudBackupFeatureDependencies + ): CloudBackupFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + RuntimeApi::class + ] + ) + interface CloudBackupFeatureDependenciesComponent : CloudBackupFeatureDependencies +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureDependencies.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureDependencies.kt new file mode 100644 index 0000000..be19764 --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureDependencies.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.di + +import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory + +interface CloudBackupFeatureDependencies { + + val contextManager: ContextManager + + val systemCallExecutor: SystemCallExecutor + + val googleApiAvailabilityProvider: GoogleApiAvailabilityProvider + + val preferences: Preferences + + val encryptedPreferences: EncryptedPreferences + + val resourceManager: ResourceManager + + val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureHolder.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureHolder.kt new file mode 100644 index 0000000..6b6093d --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureHolder.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_cloud_backup_impl.presentation.CloudBackupRouter +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class CloudBackupFeatureHolder @Inject constructor( + private val cloudBackupRouter: CloudBackupRouter, + featureContainer: FeatureContainer, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerCloudBackupFeatureComponent_CloudBackupFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .build() + return DaggerCloudBackupFeatureComponent.factory() + .create(cloudBackupRouter, dependencies) + } +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureModule.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureModule.kt new file mode 100644 index 0000000..5575b58 --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/di/CloudBackupFeatureModule.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.systemCall.SystemCallExecutor +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import io.novafoundation.nova.feature_cloud_backup_impl.BuildConfig +import io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage.CloudBackupStorage +import io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage.GoogleDriveBackupStorage +import io.novafoundation.nova.feature_cloud_backup_impl.data.encryption.CloudBackupEncryption +import io.novafoundation.nova.feature_cloud_backup_impl.data.encryption.ScryptCloudBackupEncryption +import io.novafoundation.nova.feature_cloud_backup_impl.data.preferences.CloudBackupPreferences +import io.novafoundation.nova.feature_cloud_backup_impl.data.preferences.SharedPrefsCloudBackupPreferences +import io.novafoundation.nova.feature_cloud_backup_impl.data.serializer.CloudBackupSerializer +import io.novafoundation.nova.feature_cloud_backup_impl.data.serializer.JsonCloudBackupSerializer +import io.novafoundation.nova.feature_cloud_backup_impl.domain.RealCloudBackupService +import io.novafoundation.nova.feature_cloud_backup_impl.presentation.mixin.RealCloudBackupChangingWarningMixinFactory + +@Module +internal class CloudBackupFeatureModule { + + @Provides + @FeatureScope + fun provideCloudStorage( + contextManager: ContextManager, + systemCallExecutor: SystemCallExecutor, + googleApiAvailabilityProvider: GoogleApiAvailabilityProvider, + ): CloudBackupStorage { + return GoogleDriveBackupStorage( + contextManager = contextManager, + systemCallExecutor = systemCallExecutor, + oauthClientId = BuildConfig.GOOGLE_OAUTH_ID, + googleApiAvailabilityProvider = googleApiAvailabilityProvider + ) + } + + @Provides + @FeatureScope + fun provideBackupSerializer(): CloudBackupSerializer { + return JsonCloudBackupSerializer() + } + + @Provides + @FeatureScope + fun provideBackupEncryption(): CloudBackupEncryption { + return ScryptCloudBackupEncryption() + } + + @Provides + @FeatureScope + fun provideBackupPreferences(preferences: Preferences, encryptedPreferences: EncryptedPreferences): CloudBackupPreferences { + return SharedPrefsCloudBackupPreferences(preferences, encryptedPreferences) + } + + @Provides + @FeatureScope + fun provideCloudBackupService( + cloudBackupStorage: CloudBackupStorage, + backupSerializer: CloudBackupSerializer, + encryption: CloudBackupEncryption, + backupPreferences: CloudBackupPreferences, + ): CloudBackupService { + return RealCloudBackupService( + storage = cloudBackupStorage, + serializer = backupSerializer, + encryption = encryption, + cloudBackupPreferences = backupPreferences + ) + } + + @Provides + @FeatureScope + fun provideCloudBackupChangingWarningMixinFactory( + preferences: Preferences, + resourceManager: ResourceManager, + cloudBackupService: CloudBackupService, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory + ): CloudBackupChangingWarningMixinFactory { + return RealCloudBackupChangingWarningMixinFactory( + preferences, + resourceManager, + cloudBackupService, + actionBottomSheetLauncherFactory + ) + } +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/domain/RealCloudBackupService.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/domain/RealCloudBackupService.kt new file mode 100644 index 0000000..da284ae --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/domain/RealCloudBackupService.kt @@ -0,0 +1,188 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.domain + +import android.util.Log +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.common.utils.mapError +import io.novafoundation.nova.common.utils.mapErrorNotInstance +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupSession +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.EncryptedCloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.PreCreateValidationStatus +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.WriteBackupRequest +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.DeleteBackupError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.FetchBackupError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.PasswordNotSaved +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.WriteBackupError +import io.novafoundation.nova.feature_cloud_backup_impl.data.EncryptedPrivateData +import io.novafoundation.nova.feature_cloud_backup_impl.data.ReadyForStorageBackup +import io.novafoundation.nova.feature_cloud_backup_impl.data.SerializedBackup +import io.novafoundation.nova.feature_cloud_backup_impl.data.cloudStorage.CloudBackupStorage +import io.novafoundation.nova.feature_cloud_backup_impl.data.encryption.CloudBackupEncryption +import io.novafoundation.nova.feature_cloud_backup_impl.data.preferences.CloudBackupPreferences +import io.novafoundation.nova.feature_cloud_backup_impl.data.preferences.enableSyncWithCloud +import io.novafoundation.nova.feature_cloud_backup_impl.data.serializer.CloudBackupSerializer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import java.util.Date + +internal class RealCloudBackupService( + private val storage: CloudBackupStorage, + private val serializer: CloudBackupSerializer, + private val encryption: CloudBackupEncryption, + private val cloudBackupPreferences: CloudBackupPreferences, +) : CloudBackupService { + + override val session: CloudBackupSession = RealCloudBackupSession() + + override suspend fun validateCanCreateBackup(): PreCreateValidationStatus = withContext(Dispatchers.IO) { + validateCanCreateBackupInternal() + } + + override suspend fun writeBackupToCloud(request: WriteBackupRequest): Result = withContext(Dispatchers.IO) { + storage.ensureUserAuthenticated().flatMap { + cloudBackupPreferences.enableSyncWithCloud() + + prepareBackupForSaving(request.cloudBackup, request.password) + } + .flatMap { + storage.writeBackup(it) + }.onFailure { + Log.e("CloudBackupService", "Failed to write backup to cloud", it) + }.mapErrorNotInstance<_, WriteBackupError> { + WriteBackupError.Other + } + } + + override suspend fun isCloudBackupExist(): Result = withContext(Dispatchers.IO) { + storage.ensureUserAuthenticated() + .flatMap { + storage.checkBackupExists() + } + } + + override suspend fun fetchBackup(): Result { + return withContext(Dispatchers.IO) { + storage.ensureUserAuthenticated() + .mapErrorNotInstance<_, FetchBackupError> { FetchBackupError.AuthFailed } + .flatMap { storage.fetchBackup() } + .flatMap { + serializer.deserializePublicData(it) + .mapError { FetchBackupError.CorruptedBackup } + } + .map { RealEncryptedCloudBackup(encryption, serializer, it) } + .onFailure { + Log.e("CloudBackupService", "Failed to read backup from the cloud", it) + }.mapErrorNotInstance<_, FetchBackupError> { FetchBackupError.Other } + } + } + + override suspend fun deleteBackup(): Result { + return storage.ensureUserAuthenticated().flatMap { + storage.deleteBackup() + }.onFailure { + Log.e("CloudBackupService", "Failed to delete backup from the cloud", it) + }.mapErrorNotInstance<_, DeleteBackupError> { + DeleteBackupError.Other + } + } + + override suspend fun signInToCloud(): Result { + return storage.authenticateUser() + } + + private suspend fun prepareBackupForSaving(backup: CloudBackup, password: String): Result { + return serializer.serializePrivateData(backup) + .flatMap { unencryptedBackupData -> encryption.encryptBackup(unencryptedBackupData.privateData, password) } + .map { SerializedBackup(backup.publicData, it) } + .flatMap { serializer.serializePublicData(it) } + } + + private suspend fun CloudBackupStorage.ensureUserAuthenticated(): Result { + return if (!isUserAuthenticated()) { + authenticateUser() + } else { + Result.success(Unit) + } + } + + private suspend fun validateCanCreateBackupInternal(): PreCreateValidationStatus { + if (!storage.isCloudStorageServiceAvailable()) return PreCreateValidationStatus.BackupServiceUnavailable + + storage.ensureUserAuthenticated().getOrNull() ?: return PreCreateValidationStatus.AuthenticationFailed + + val fileExists = storage.checkBackupExists().getOrNull() ?: return PreCreateValidationStatus.OtherError + if (fileExists) { + return PreCreateValidationStatus.ExistingBackupFound + } + + val hasEnoughSize = hasEnoughSizeForBackup().getOrNull() ?: return PreCreateValidationStatus.OtherError + if (!hasEnoughSize) { + return PreCreateValidationStatus.NotEnoughSpace + } + + return PreCreateValidationStatus.Ok + } + + private suspend fun hasEnoughSizeForBackup(): Result { + val neededBackupSize = serializer.neededSizeForBackup() + + return storage.hasEnoughFreeStorage(neededBackupSize) + } + + private class RealEncryptedCloudBackup( + private val encryption: CloudBackupEncryption, + private val serializer: CloudBackupSerializer, + private val encryptedBackup: SerializedBackup + ) : EncryptedCloudBackup { + + override val publicData: CloudBackup.PublicData = encryptedBackup.publicData + + override suspend fun decrypt(password: String): Result { + return encryption.decryptBackup(encryptedBackup.privateData, password).flatMap { privateData -> + val unencryptedBackup = SerializedBackup(encryptedBackup.publicData, privateData) + + serializer.deserializePrivateData(unencryptedBackup) + .mapError { FetchBackupError.CorruptedBackup } + } + } + } + + private inner class RealCloudBackupSession : CloudBackupSession { + + override suspend fun isSyncWithCloudEnabled(): Boolean { + return cloudBackupPreferences.syncWithCloudEnabled() + } + + override suspend fun setSyncingBackupEnabled(enable: Boolean) { + cloudBackupPreferences.setSyncWithCloudEnabled(enable) + } + + override fun lastSyncedTimeFlow(): Flow { + return cloudBackupPreferences.observeLastSyncedTime() + } + + override suspend fun setLastSyncedTime(date: Date) { + cloudBackupPreferences.setLastSyncedTime(date) + } + + override suspend fun getSavedPassword(): Result { + return runCatching { + cloudBackupPreferences.getSavedPassword() ?: throw PasswordNotSaved() + } + } + + override suspend fun setSavedPassword(password: String) { + cloudBackupPreferences.setSavedPassword(password) + } + + override fun cloudBackupWasInitialized(): Boolean { + return cloudBackupPreferences.getCloudBackupWasInitialized() + } + + override fun setBackupWasInitialized() { + cloudBackupPreferences.setCloudBackupWasInitialized(true) + } + } +} diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/presentation/CloudBackupRouter.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/presentation/CloudBackupRouter.kt new file mode 100644 index 0000000..497ccab --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/presentation/CloudBackupRouter.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter + +interface CloudBackupRouter : ReturnableRouter diff --git a/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/presentation/mixin/RealCloudBackupChangingWarningMixin.kt b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/presentation/mixin/RealCloudBackupChangingWarningMixin.kt new file mode 100644 index 0000000..ed171ab --- /dev/null +++ b/feature-cloud-backup-impl/src/main/java/io/novafoundation/nova/feature_cloud_backup_impl/presentation/mixin/RealCloudBackupChangingWarningMixin.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_cloud_backup_impl.presentation.mixin + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences +import io.novafoundation.nova.common.view.bottomSheet.action.CheckBoxPreferences +import io.novafoundation.nova.common.view.bottomSheet.action.negative +import io.novafoundation.nova.common.view.bottomSheet.action.primary +import io.novafoundation.nova.common.view.bottomSheet.action.secondary +import io.novafoundation.nova.feature_cloud_backup_api.R +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixin +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val KEY_CLOUD_BACKUP_CHANGE_WARNING_SHOWN = "cloud_backup_change_warning_shown" + +class RealCloudBackupChangingWarningMixinFactory( + private val preferences: Preferences, + private val resourceManager: ResourceManager, + private val cloudBackupService: CloudBackupService, + private val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory +) : CloudBackupChangingWarningMixinFactory { + + override fun create(coroutineScope: CoroutineScope): CloudBackupChangingWarningMixin { + return RealCloudBackupChangingWarningMixin( + coroutineScope, + preferences, + resourceManager, + cloudBackupService, + actionBottomSheetLauncherFactory + ) + } +} + +class RealCloudBackupChangingWarningMixin( + private val scope: CoroutineScope, + private val preferences: Preferences, + private val resourceManager: ResourceManager, + private val cloudBackupService: CloudBackupService, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory +) : CloudBackupChangingWarningMixin { + + override val actionBottomSheetLauncher: ActionBottomSheetLauncher = actionBottomSheetLauncherFactory.create() + + override fun launchChangingConfirmationIfNeeded(onConfirm: () -> Unit) { + scope.launch { + // In case if cloud backup sync is disabled, we don't need to show the warning and can confirm the action now + if (!cloudBackupService.session.isSyncWithCloudEnabled()) { + onConfirm() + return@launch + } + + if (preferences.getBoolean(KEY_CLOUD_BACKUP_CHANGE_WARNING_SHOWN, false)) { + onConfirm() + return@launch + } + + actionBottomSheetLauncher.launchCloudBackupChangingWarning(resourceManager, onConfirm) + } + } + + override fun launchRemovingConfirmationIfNeeded(onConfirm: () -> Unit) { + scope.launch { + // In case if cloud backup sync is disabled, we don't need to show the warning and can confirm the action now + if (!cloudBackupService.session.isSyncWithCloudEnabled()) { + onConfirm() + return@launch + } + + actionBottomSheetLauncher.launchCloudBackupRemovingWarning(resourceManager, onConfirm) + } + } + + private fun ActionBottomSheetLauncher.launchCloudBackupChangingWarning( + resourceManager: ResourceManager, + onConfirm: () -> Unit + ) { + var isAutoContinueChecked = false + + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_add, + title = resourceManager.getString(R.string.cloud_backup_will_be_changed_title), + subtitle = resourceManager.getString(R.string.cloud_backup_will_be_changed_subtitle), + neutralButtonPreferences = ButtonPreferences.secondary( + resourceManager.getString( + R.string.common_cancel + ) + ), + actionButtonPreferences = ButtonPreferences.primary( + resourceManager.getString(R.string.common_continue), + onClick = { + preferences.putBoolean(KEY_CLOUD_BACKUP_CHANGE_WARNING_SHOWN, isAutoContinueChecked) + onConfirm() + } + ), + checkBoxPreferences = CheckBoxPreferences( + text = resourceManager.getString(R.string.common_check_box_auto_continue), + onCheckChanged = { isChecked -> isAutoContinueChecked = isChecked } + ) + ) + } + + private fun ActionBottomSheetLauncher.launchCloudBackupRemovingWarning( + resourceManager: ResourceManager, + onConfirm: () -> Unit + ) { + launchBottomSheet( + imageRes = R.drawable.ic_cloud_backup_delete, + title = resourceManager.getString(R.string.cloud_backup_removing_warning_title), + subtitle = resourceManager.getString(R.string.cloud_backup_removing_warning_subtitle), + neutralButtonPreferences = ButtonPreferences.secondary( + resourceManager.getString( + R.string.common_cancel + ) + ), + actionButtonPreferences = ButtonPreferences.negative( + resourceManager.getString(R.string.common_remove), + onClick = onConfirm + ) + ) + } +} diff --git a/feature-cloud-backup-impl/src/test/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackupExtKtTest.kt b/feature-cloud-backup-impl/src/test/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackupExtKtTest.kt new file mode 100644 index 0000000..99ca14c --- /dev/null +++ b/feature-cloud-backup-impl/src/test/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackupExtKtTest.kt @@ -0,0 +1,358 @@ +package io.novafoundation.nova.feature_cloud_backup_api.domain.model + +import io.novafoundation.feature_cloud_backup_test.CloudBackupBuilder +import io.novafoundation.feature_cloud_backup_test.buildTestCloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.isEmpty +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.localVsCloudDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategy +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class CloudBackupExtKtTest { + + @Test + fun shouldDiffWithEmptyLocalBackup() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("1") { + } + } + } + + val walletOnlyPresentInCloud = cloudBackup.publicData.wallets.first() + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.cloudChanges.isEmpty()) + assertTrue(diff.localChanges.removed.isEmpty()) + assertTrue(diff.localChanges.modified.isEmpty()) + assertEquals(listOf(walletOnlyPresentInCloud), diff.localChanges.added) + } + + @Test + fun shouldFindAddLocalAccount() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + + wallet("1") { + substrateAccountId(ByteArray(32)) + } + } + } + + val walletOnlyPresentInCloud = cloudBackup.publicData.wallets[1] + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.cloudChanges.isEmpty()) + assertTrue(diff.localChanges.removed.isEmpty()) + assertTrue(diff.localChanges.modified.isEmpty()) + assertEquals(listOf(walletOnlyPresentInCloud), diff.localChanges.added) + } + + @Test + fun shouldFindRemoveLocalAccount() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + + wallet("1") { + substrateAccountId(ByteArray(32)) + } + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + } + } + + val walletOnlyPresentLocally = localBackup.publicData.wallets[1] + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.cloudChanges.isEmpty()) + assertTrue(diff.localChanges.added.isEmpty()) + assertTrue(diff.localChanges.modified.isEmpty()) + assertEquals(listOf(walletOnlyPresentLocally), diff.localChanges.removed) + } + + @Test + fun shouldFindModifiedLocalAccountName() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + + wallet("0") { + substrateAccountId(ByteArray(32)) + name("old") + } + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("0") { + substrateAccountId(ByteArray(32)) + name("new") + } + } + } + + val modifiedLocally = cloudBackup.publicData.wallets[0] + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.cloudChanges.isEmpty()) + assertTrue(diff.localChanges.removed.isEmpty()) + assertTrue(diff.localChanges.added.isEmpty()) + assertEquals(listOf(modifiedLocally), diff.localChanges.modified) + } + + @Test + fun shouldFindModifiedLocalBaseSubstrateAccount() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("0") { + substrateAccountId(ByteArray(32) { 1 }) + } + } + } + + val modifiedLocally = cloudBackup.publicData.wallets[0] + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.cloudChanges.isEmpty()) + assertTrue(diff.localChanges.removed.isEmpty()) + assertTrue(diff.localChanges.added.isEmpty()) + assertEquals(listOf(modifiedLocally), diff.localChanges.modified) + } + + @Test + fun shouldFindModifiedLocalBaseEthereumAccount() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + + wallet("0") { + ethereumAddress(ByteArray(20)) + } + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("0") { + ethereumAddress(ByteArray(20) { 1 }) + } + } + } + + val toModifyLocally = cloudBackup.publicData.wallets[0] + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.cloudChanges.isEmpty()) + assertTrue(diff.localChanges.removed.isEmpty()) + assertTrue(diff.localChanges.added.isEmpty()) + assertEquals(listOf(toModifyLocally), diff.localChanges.modified) + } + + @Test + fun shouldFindModifiedLocalChainAccount() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + + wallet("0") { + chainAccount(chainId = "0") { + accountId(ByteArray(32)) + } + } + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("0") { + chainAccount(chainId = "0") { + accountId(ByteArray(32) { 1 }) + } + } + } + } + + val toModifyLocally = cloudBackup.publicData.wallets[0] + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.cloudChanges.isEmpty()) + assertTrue(diff.localChanges.removed.isEmpty()) + assertTrue(diff.localChanges.added.isEmpty()) + assertEquals(listOf(toModifyLocally), diff.localChanges.modified) + } + + @Test + fun shouldFindAddCloudAccount() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + + wallet("1") { + substrateAccountId(ByteArray(32)) + } + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + } + } + + val walletOnlyPresentLocally = localBackup.publicData.wallets[1] + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.localChanges.isEmpty()) + assertTrue(diff.cloudChanges.removed.isEmpty()) + assertTrue(diff.cloudChanges.modified.isEmpty()) + assertEquals(listOf(walletOnlyPresentLocally), diff.cloudChanges.added) + } + + @Test + fun shouldFindRemoveCloudAccount() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + + wallet("1") { + substrateAccountId(ByteArray(32)) + } + } + } + + val walletOnlyPresentInCloud = cloudBackup.publicData.wallets[1] + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.localChanges.isEmpty()) + assertTrue(diff.cloudChanges.added.isEmpty()) + assertTrue(diff.cloudChanges.modified.isEmpty()) + assertEquals(listOf(walletOnlyPresentInCloud), diff.cloudChanges.removed) + } + + @Test + fun shouldFindModifiedCloudAccount() { + val localBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(1) + + wallet("0") { + substrateAccountId(ByteArray(32) { 1 }) + } + } + } + + val cloudBackup = buildTestCloudBackupWithoutPrivate { + publicData { + modifiedAt(0) + + wallet("0") { + substrateAccountId(ByteArray(32)) + } + } + } + + val toModifyInCloud = localBackup.publicData.wallets[0] + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.syncWithCloud()) + + assertTrue(diff.localChanges.isEmpty()) + assertTrue(diff.cloudChanges.removed.isEmpty()) + assertTrue(diff.cloudChanges.added.isEmpty()) + assertEquals(listOf(toModifyInCloud), diff.cloudChanges.modified) + } + + private inline fun buildTestCloudBackupWithoutPrivate(crossinline builder: CloudBackupBuilder.() -> Unit) = buildTestCloudBackup { + builder() + + privateData { + + } + } +} diff --git a/feature-cloud-backup-test/.gitignore b/feature-cloud-backup-test/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-cloud-backup-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-cloud-backup-test/build.gradle b/feature-cloud-backup-test/build.gradle new file mode 100644 index 0000000..6607522 --- /dev/null +++ b/feature-cloud-backup-test/build.gradle @@ -0,0 +1,27 @@ + +android { + namespace 'io.novafoundation.nova.feature_cloud_backup_test' + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + + +dependencies { + implementation project(":feature-cloud-backup-api") + implementation project(":runtime") + + implementation project(":common") + + implementation substrateSdkDep + + implementation kotlinDep + + api jUnitDep + api mockitoDep +} \ No newline at end of file diff --git a/feature-cloud-backup-test/consumer-rules.pro b/feature-cloud-backup-test/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-cloud-backup-test/proguard-rules.pro b/feature-cloud-backup-test/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-cloud-backup-test/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-cloud-backup-test/src/main/AndroidManifest.xml b/feature-cloud-backup-test/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-cloud-backup-test/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt b/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt new file mode 100644 index 0000000..cd7e190 --- /dev/null +++ b/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt @@ -0,0 +1,306 @@ +package io.novafoundation.feature_cloud_backup_test + +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.ChainAccountSecrets +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.EthereumSecrets +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.KeyPairSecrets +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.SubstrateSecrets +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.isCompletelyEmpty +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +const val TEST_MODIFIED_AT = 0L + +@DslMarker +annotation class CloudBackupBuildDsl + +@CloudBackupBuildDsl +fun buildTestCloudBackup(builder: CloudBackupBuilder.() -> Unit): CloudBackup { + return CloudBackupBuilder().apply(builder).build() +} + +@CloudBackupBuildDsl +class CloudBackupBuilder { + + private var privateData: CloudBackup.PrivateData? = null + private var publicData: CloudBackup.PublicData? = null + + fun publicData( + builder: CloudBackupPublicDataBuilder.() -> Unit + ) { + publicData = CloudBackupPublicDataBuilder().apply(builder).build() + } + + fun privateData( + builder: CloudBackupPrivateDataBuilder.() -> Unit + ) { + privateData = CloudBackupPrivateDataBuilder().apply(builder).build() + } + + fun build(): CloudBackup { + return CloudBackup( + publicData = requireNotNull(publicData), + privateData = requireNotNull(privateData) + ) + } +} + +@CloudBackupBuildDsl +class CloudBackupPublicDataBuilder { + + private var _modifiedAt: Long = TEST_MODIFIED_AT + + private val wallets = mutableListOf() + + fun modifiedAt(value: Long) { + _modifiedAt = value + } + + fun wallet(walletId: String, builder: WalletPublicInfoBuilder.() -> Unit): WalletPublicInfo { + val element = WalletPublicInfoBuilder(walletId).apply(builder).build() + wallets.add(element) + return element + } + + fun build(): CloudBackup.PublicData { + return CloudBackup.PublicData(_modifiedAt, wallets) + } +} + +@CloudBackupBuildDsl +class CloudBackupPrivateDataBuilder { + + private val wallets = mutableListOf() + + fun wallet(walletId: String, builder: WalletPrivateInfoBuilder.() -> Unit) { + val privateInfo = WalletPrivateInfoBuilder(walletId).apply(builder).build() + + if (!privateInfo.isCompletelyEmpty()) { + wallets.add(privateInfo) + } + } + + fun build(): CloudBackup.PrivateData { + return CloudBackup.PrivateData(wallets) + } +} + +@CloudBackupBuildDsl +class WalletPrivateInfoBuilder( + private val walletId: String, +) { + + private var _entropy: ByteArray? = null + + private var substrate: SubstrateSecrets? = null + private var ethereum: EthereumSecrets? = null + + private val chainAccounts = mutableListOf() + + fun entropy(value: ByteArray) { + _entropy = value + } + + fun substrate(builder: BackupSubstrateSecretsBuilder.() -> Unit) { + substrate = BackupSubstrateSecretsBuilder().apply(builder).build() + } + + fun ethereum(builder: BackupEthereumSecretsBuilder.() -> Unit) { + ethereum = BackupEthereumSecretsBuilder().apply(builder).build() + } + + fun chainAccount(accountId: AccountId, builder: (BackupChainAccountSecretsBuilder.() -> Unit)? = null) { + val element = BackupChainAccountSecretsBuilder(accountId).apply { builder?.invoke(this) }.build() + chainAccounts.add(element) + } + + fun build(): WalletPrivateInfo { + return WalletPrivateInfo( + entropy = _entropy, + walletId = walletId, + substrate = substrate, + ethereum = ethereum, + chainAccounts = chainAccounts, + ) + } +} + +@CloudBackupBuildDsl +class BackupSubstrateSecretsBuilder { + + private var _keypair: KeyPairSecrets? = null + private var _seed: ByteArray? = null + private var _derivationPath: String? = null + + fun seed(value: ByteArray) { + _seed = value + } + + fun derivationPath(value: String?) { + _derivationPath = value + } + + fun keypair(keypair: KeyPairSecrets) { + _keypair = keypair + } + + fun build(): SubstrateSecrets { + return SubstrateSecrets(_seed, _keypair, _derivationPath) + } +} + +@CloudBackupBuildDsl +class BackupEthereumSecretsBuilder { + + private var _keypair: KeyPairSecrets? = null + private var _derivationPath: String? = null + + fun derivationPath(value: String?) { + _derivationPath = value + } + + fun keypair(keypair: KeyPairSecrets) { + _keypair = keypair + } + + fun build(): EthereumSecrets { + return EthereumSecrets(requireNotNull(_keypair), _derivationPath) + } +} + +@CloudBackupBuildDsl +class BackupChainAccountSecretsBuilder(private val accountId: AccountId) { + + private var _entropy: ByteArray? = null + + private var _keypair: KeyPairSecrets? = null + private var _seed: ByteArray? = null + private var _derivationPath: String? = null + + fun entropy(value: ByteArray) { + _entropy = value + } + + fun seed(value: ByteArray) { + _seed = value + } + + fun derivationPath(value: String?) { + _derivationPath = value + } + + fun keypair(keypair: KeyPairSecrets) { + _keypair = keypair + } + + fun keypairFromIndex(index: Int) { + _keypair = KeyPairSecrets( + privateKey = ByteArray(32) { index.toByte() }, + publicKey = ByteArray(32) { index.toByte() }, + nonce = ByteArray(32) { index.toByte() } + ) + } + + fun build(): ChainAccountSecrets { + return ChainAccountSecrets(accountId, _entropy, _seed, _keypair, _derivationPath) + } +} + +@CloudBackupBuildDsl +class WalletPublicInfoBuilder( + private val walletId: String, +) { + + private val chainAccounts = mutableListOf() + + private var _substratePublicKey: ByteArray? = null + private var _substrateCryptoType: CryptoType? = null + private var _substrateAccountId: ByteArray? = null + private var _ethereumPublicKey: ByteArray? = null + private var _ethereumAddress: ByteArray? = null + private var _name: String = "" + private var _isSelected: Boolean = false + private var _type: WalletPublicInfo.Type = WalletPublicInfo.Type.SECRETS + + fun chainAccount(chainId: ChainId, builder: WalletChainAccountInfoBuilder.() -> Unit) { + val chainAccountLocal = WalletChainAccountInfoBuilder(chainId).apply(builder).build() + chainAccounts.add(chainAccountLocal) + } + + fun substratePublicKey(value: ByteArray?) { + _substratePublicKey = value + } + + fun substrateCryptoType(value: CryptoType?) { + _substrateCryptoType = value + } + + fun substrateAccountId(value: ByteArray?) { + _substrateAccountId = value + } + + fun ethereumPublicKey(value: ByteArray?) { + _ethereumPublicKey = value + } + + fun ethereumAddress(value: ByteArray?) { + _ethereumAddress = value + } + + fun name(value: String) { + _name = value + } + + fun isSelected(value: Boolean) { + _isSelected = value + } + + fun type(value: WalletPublicInfo.Type) { + _type = value + } + + fun build(): WalletPublicInfo { + return WalletPublicInfo( + walletId = walletId, + substratePublicKey = _substratePublicKey, + substrateAccountId = _substrateAccountId, + substrateCryptoType = _substrateCryptoType, + ethereumAddress = _ethereumAddress, + ethereumPublicKey = _ethereumPublicKey, + name = _name, + type = _type, + chainAccounts = chainAccounts.toSet() + ) + } +} + +@CloudBackupBuildDsl +class WalletChainAccountInfoBuilder( + private val chainId: ChainId, +) { + + private var _publicKey: ByteArray? = null + private var _accountId: ByteArray = ByteArray(32) + private var _cryptoType: ChainAccountCryptoType? = null + + fun publicKey(value: ByteArray) { + _publicKey = value + } + + fun accountId(value: ByteArray) { + _accountId = value + } + + fun cryptoType(cryptoType: ChainAccountCryptoType) { + _cryptoType = cryptoType + } + + fun build(): ChainAccountInfo { + return ChainAccountInfo(chainId, _publicKey, _accountId, _cryptoType) + } +} diff --git a/feature-crowdloan-api/.gitignore b/feature-crowdloan-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-crowdloan-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-crowdloan-api/build.gradle b/feature-crowdloan-api/build.gradle new file mode 100644 index 0000000..9cb32ae --- /dev/null +++ b/feature-crowdloan-api/build.gradle @@ -0,0 +1,25 @@ + +android { + namespace 'io.novafoundation.nova.feature_crowdloan_api' + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + implementation project(":feature-account-api") + implementation project(":feature-wallet-api") + + implementation daggerDep + + implementation substrateSdkDep + + implementation androidDep + + api project(':core-api') + api project(':core-db') +} \ No newline at end of file diff --git a/feature-crowdloan-api/consumer-rules.pro b/feature-crowdloan-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-crowdloan-api/proguard-rules.pro b/feature-crowdloan-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-crowdloan-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-crowdloan-api/src/main/AndroidManifest.xml b/feature-crowdloan-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/feature-crowdloan-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/DirectContribution.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/DirectContribution.kt new file mode 100644 index 0000000..63e416e --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/DirectContribution.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindString +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution.Companion.DIRECT_SOURCE_ID +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import java.math.BigInteger + +class DirectContribution( + val amount: BigInteger, + val memo: String, +) { + + val sourceId = DIRECT_SOURCE_ID +} + +fun bindContribution(scale: String, runtime: RuntimeSnapshot): DirectContribution { + val type = runtime.typeRegistry["(Balance, Vec)"] ?: incompatible() + + val dynamicInstance = type.fromHex(runtime, scale).cast>() + + return DirectContribution( + amount = bindNumber(dynamicInstance[0]), + memo = bindString(dynamicInstance[1]) + ) +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/FundInfo.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/FundInfo.kt new file mode 100644 index 0000000..826bb3a --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/FundInfo.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.fromHexOrIncompatible +import io.novafoundation.nova.common.data.network.runtime.binding.storageReturnType +import io.novafoundation.nova.common.utils.Modules +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.u32 +import io.novasama.substrate_sdk_android.runtime.definitions.types.toByteArray +import java.math.BigInteger + +class FundInfo( + val depositor: AccountId, + val deposit: BigInteger, + val raised: BigInteger, + val lastSlot: BigInteger, + val firstSlot: BigInteger, + val end: BigInteger, + val cap: BigInteger, + val verifier: Any?, + val trieIndex: TrieIndex, + val paraId: ParaId, + val bidderAccountId: AccountId, + val pre9180BidderAccountId: AccountId, +) + +fun bindFundInfo(dynamic: Any?, runtime: RuntimeSnapshot, paraId: ParaId): FundInfo { + val dynamicInstance = dynamic.castToStruct() + + val fundIndex = bindTrieIndex(dynamicInstance["fundIndex"] ?: dynamicInstance["trieIndex"]) + + return FundInfo( + depositor = bindAccountId(dynamicInstance["depositor"]), + deposit = bindNumber(dynamicInstance["deposit"]), + raised = bindNumber(dynamicInstance["raised"]), + end = bindNumber(dynamicInstance["end"]), + cap = bindNumber(dynamicInstance["cap"]), + firstSlot = bindNumber(dynamicInstance["firstPeriod"] ?: dynamicInstance["firstSlot"]), + lastSlot = bindNumber(dynamicInstance["lastPeriod"] ?: dynamicInstance["lastSlot"]), + verifier = dynamicInstance["verifier"], + trieIndex = fundIndex, + bidderAccountId = createBidderAccountId(runtime, fundIndex), + pre9180BidderAccountId = createBidderAccountId(runtime, paraId), + paraId = paraId + ) +} + +fun bindFundInfo(scale: String, runtime: RuntimeSnapshot, paraId: ParaId): FundInfo { + val type = runtime.metadata.storageReturnType(Modules.CROWDLOAN, "Funds") + + val dynamicInstance = type.fromHexOrIncompatible(scale, runtime) + + return bindFundInfo(dynamicInstance, runtime, paraId) +} + +private val ADDRESS_PADDING = ByteArray(32) +private val ADDRESS_PREFIX = "modlpy/cfund".encodeToByteArray() + +private fun createBidderAccountId(runtime: RuntimeSnapshot, index: BigInteger): AccountId { + val fullKey = ADDRESS_PREFIX + u32.toByteArray(runtime, index) + ADDRESS_PADDING + + return fullKey.copyOfRange(0, 32) +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/Leases.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/Leases.kt new file mode 100644 index 0000000..6cc43f3 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/Leases.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novasama.substrate_sdk_android.runtime.AccountId + +class LeaseEntry( + val accountId: AccountId, + val locked: BalanceOf +) + +fun bindLeases(decoded: Any?): List { + return bindList(decoded) { + it?.let { + val (accountIdRaw, balanceRaw) = it.cast>() + + LeaseEntry( + accountId = bindAccountId(accountIdRaw), + locked = bindNumber(balanceRaw) + ) + } + } +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/TrieIndex.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/TrieIndex.kt new file mode 100644 index 0000000..69e28e7 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/blockhain/binding/TrieIndex.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding + +import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import java.math.BigInteger + +typealias TrieIndex = BigInteger + +@HelperBinding +fun bindTrieIndex(dynamicInstance: Any?) = bindNumber(dynamicInstance) diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/updater/AssetBalanceScope.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/updater/AssetBalanceScope.kt new file mode 100644 index 0000000..c6f344e --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/updater/AssetBalanceScope.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.network.updater + +import io.novafoundation.nova.core.updater.UpdateScope +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.AssetBalanceScope.ScopeValue +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow + +interface AssetBalanceScope : UpdateScope { + + class ScopeValue(val metaAccount: MetaAccount, val asset: Asset) + + override fun invalidationFlow(): Flow +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/updater/ContributionsUpdateSystemFactory.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/updater/ContributionsUpdateSystemFactory.kt new file mode 100644 index 0000000..51537e5 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/updater/ContributionsUpdateSystemFactory.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.network.updater + +import io.novafoundation.nova.core.updater.UpdateSystem + +interface ContributionsUpdateSystemFactory { + fun create(): UpdateSystem +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/updater/ContributionsUpdaterFactory.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/updater/ContributionsUpdaterFactory.kt new file mode 100644 index 0000000..9af3974 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/network/updater/ContributionsUpdaterFactory.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.network.updater + +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface ContributionsUpdaterFactory { + fun create(chain: Chain, assetBalanceScope: AssetBalanceScope): Updater +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/ContributionsRepository.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/ContributionsRepository.kt new file mode 100644 index 0000000..e6fa40c --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/ContributionsRepository.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.repository + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.Flow + +interface ContributionsRepository { + + fun loadContributionsGraduallyFlow( + chain: Chain, + accountId: ByteArray, + ): Flow>>> + + fun observeContributions(metaAccount: MetaAccount): Flow> + + fun observeContributions(metaAccount: MetaAccount, chain: Chain, asset: Chain.Asset): Flow> + + suspend fun getDirectContributions(chain: Chain, asset: Chain.Asset, accountId: ByteArray): Result> + + suspend fun deleteContributions(assetIds: List) +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/CrowdloanRepository.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/CrowdloanRepository.kt new file mode 100644 index 0000000..e524cf1 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/CrowdloanRepository.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding.FundInfo +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal +import java.math.BigInteger + +interface CrowdloanRepository { + + suspend fun allFundInfos(chainId: ChainId): Map + + suspend fun getWinnerInfo(chainId: ChainId, funds: Map): Map + + suspend fun getParachainMetadata(chain: Chain): Map + + suspend fun leasePeriodToBlocksConverter(chainId: ChainId): LeasePeriodToBlocksConverter + + fun fundInfoFlow(chainId: ChainId, parachainId: ParaId): Flow + + suspend fun minContribution(chainId: ChainId): BigInteger +} + +class ParachainMetadata( + val paraId: ParaId, + val movedToParaId: ParaId?, + val iconLink: String, + val name: String, + val description: String, + val rewardRate: BigDecimal?, + val website: String, + val customFlow: String?, + val token: String, + val extras: Map, +) + +fun ParachainMetadata.getExtra(key: String) = extras[key] ?: throw IllegalArgumentException("No key $key found in parachain metadata extras for $name") diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/CrowdloanRepositoryExt.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/CrowdloanRepositoryExt.kt new file mode 100644 index 0000000..87c088b --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/CrowdloanRepositoryExt.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.repository + +import io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding.FundInfo +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +suspend fun CrowdloanRepository.hasWonAuction(chainId: ChainId, fundInfo: FundInfo): Boolean { + val paraId = fundInfo.paraId + + return getWinnerInfo(chainId, mapOf(paraId to fundInfo)).getValue(paraId) +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/LeasePeriodToBlocksConverter.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/LeasePeriodToBlocksConverter.kt new file mode 100644 index 0000000..144c640 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/repository/LeasePeriodToBlocksConverter.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import java.math.BigInteger + +class LeasePeriodToBlocksConverter( + private val blocksPerLease: BigInteger, + private val blocksOffset: BigInteger +) { + + fun startBlockFor(leasePeriod: BigInteger): BlockNumber { + return blocksPerLease * leasePeriod + blocksOffset + } + + fun leaseIndexFromBlock(blockNumber: BlockNumber): BigInteger { + return (blockNumber - blocksOffset) / blocksPerLease + } +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/source/contribution/ExternalContributionSource.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/source/contribution/ExternalContributionSource.kt new file mode 100644 index 0000000..0e421f1 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/data/source/contribution/ExternalContributionSource.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_crowdloan_api.data.source.contribution + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +interface ExternalContributionSource { + + class ExternalContribution( + val sourceId: String, + val amount: BigInteger, + val paraId: ParaId, + ) + + /** + * null in case every chain is supported + */ + val supportedChains: Set? + + val sourceId: String + + suspend fun getContributions( + chain: Chain, + accountId: AccountId, + ): Result> +} + +fun ExternalContributionSource.supports(chain: Chain) = supportedChains == null || chain.id in supportedChains!! diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/di/Crowdloan.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/di/Crowdloan.kt new file mode 100644 index 0000000..8e46b31 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/di/Crowdloan.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_crowdloan_api.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Crowdloan diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/di/CrowdloanFeatureApi.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/di/CrowdloanFeatureApi.kt new file mode 100644 index 0000000..4f21021 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/di/CrowdloanFeatureApi.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_crowdloan_api.di + +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor + +interface CrowdloanFeatureApi { + + fun repository(): CrowdloanRepository + + fun contributionsInteractor(): ContributionsInteractor + + fun contributionsRepository(): ContributionsRepository +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/domain/contributions/ContributionWithMetadata.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/domain/contributions/ContributionWithMetadata.kt new file mode 100644 index 0000000..8381160 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/domain/contributions/ContributionWithMetadata.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_crowdloan_api.domain.contributions + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.core_db.model.ContributionLocal +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.util.BlockDurationEstimator +import io.novafoundation.nova.runtime.util.timerUntil +import java.math.BigInteger + +class Contribution( + val chain: Chain, + val asset: Chain.Asset, + val amountInPlanks: BigInteger, + val paraId: ParaId, + val sourceId: String, + val unlockBlock: BlockNumber, + val leaseDepositor: AccountIdKey, +) { + + companion object { + const val DIRECT_SOURCE_ID = "direct" + const val LIQUID_SOURCE_ID = "liquid" + const val PARALLEL_SOURCE_ID = "parallel" + } +} + +class ContributionMetadata( + val claimStatus: ContributionClaimStatus, + val parachainMetadata: ParachainMetadata?, +) + +sealed class ContributionClaimStatus { + + object Claimable : ContributionClaimStatus() + + class ReturnsIn(val timer: TimerValue) : ContributionClaimStatus() +} + +class ContributionWithMetadata( + val contribution: Contribution, + val metadata: ContributionMetadata +) + +fun BlockDurationEstimator.claimStatusOf(contribution: Contribution): ContributionClaimStatus { + return if (contribution.unlockBlock > currentBlock) { + ContributionClaimStatus.ReturnsIn(timerUntil(contribution.unlockBlock)) + } else { + ContributionClaimStatus.Claimable + } +} + +class ContributionsWithTotalAmount(val totalContributed: BigInteger, val contributions: List) { + companion object { + fun empty(): ContributionsWithTotalAmount { + return ContributionsWithTotalAmount(BigInteger.ZERO, emptyList()) + } + + fun fromContributions(contributions: List): ContributionsWithTotalAmount { + return ContributionsWithTotalAmount( + totalContributed = contributions.sumOf { it.amountInPlanks }, + contributions = contributions + ) + } + } +} + +fun mapContributionToLocal(metaId: Long, contribution: Contribution): ContributionLocal { + return ContributionLocal( + metaId, + contribution.chain.id, + contribution.asset.id, + contribution.paraId, + contribution.amountInPlanks, + contribution.sourceId, + unlockBlock = contribution.unlockBlock, + leaseDepositor = contribution.leaseDepositor.value + ) +} + +fun mapContributionFromLocal( + contribution: ContributionLocal, + chain: Chain, +): Contribution { + return Contribution( + chain, + chain.utilityAsset, + contribution.amountInPlanks, + contribution.paraId, + contribution.sourceId, + unlockBlock = contribution.unlockBlock, + leaseDepositor = contribution.leaseDepositor.intoKey() + ) +} diff --git a/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/domain/contributions/ContributionsInteractor.kt b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/domain/contributions/ContributionsInteractor.kt new file mode 100644 index 0000000..44d55f2 --- /dev/null +++ b/feature-crowdloan-api/src/main/java/io/novafoundation/nova/feature_crowdloan_api/domain/contributions/ContributionsInteractor.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_crowdloan_api.domain.contributions + +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface ContributionsInteractor { + + fun runUpdate(): Flow + + fun observeSelectedChainContributionsWithMetadata(): Flow> + + fun observeChainContributions( + metaAccount: MetaAccount, + chainId: ChainId, + assetId: ChainAssetId + ): Flow> +} diff --git a/feature-crowdloan-impl/.gitignore b/feature-crowdloan-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-crowdloan-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-crowdloan-impl/build.gradle b/feature-crowdloan-impl/build.gradle new file mode 100644 index 0000000..a63e1b5 --- /dev/null +++ b/feature-crowdloan-impl/build.gradle @@ -0,0 +1,86 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + + defaultConfig { + + + + buildConfigField "String", "KARURA_NOVA_REFERRAL", "\"0x9642d0db9f3b301b44df74b63b0b930011e3f52154c5ca24b4dc67b3c7322f15\"" + buildConfigField "String", "ACALA_NOVA_REFERRAL", "\"0x08eb319467ea54784cd9edfbd03bbcc53f7a021ed8d9ed2ca97b6ae46b3f6014\"" + buildConfigField "String", "BIFROST_NOVA_REFERRAL", "\"FRLS69\"" + buildConfigField "String", "BIFROST_TERMS_LINKS", "\"https://docs.google.com/document/d/1PDpgHnIcAmaa7dEFusmLYgjlvAbk2VKtMd755bdEsf4\"" + + buildConfigField "String", "ACALA_TERMS_LINK", "\"https://acala.network/acala/terms\"" + + buildConfigField "String", "ACALA_TEST_AUTH_TOKEN", readStringSecret("ACALA_TEST_AUTH_TOKEN") + buildConfigField "String", "ACALA_PROD_AUTH_TOKEN", readStringSecret("ACALA_PROD_AUTH_TOKEN") + + buildConfigField "String", "MOONBEAM_TEST_AUTH_TOKEN", readStringSecret("MOONBEAM_TEST_AUTH_TOKEN") + buildConfigField "String", "MOONBEAM_PROD_AUTH_TOKEN", readStringSecret("MOONBEAM_PROD_AUTH_TOKEN") + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.feature_crowdloan_impl' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-crowdloan-api') + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(':feature-currency-api') + implementation project(':runtime') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation cardViewDep + implementation constraintDep + + implementation permissionsDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation insetterDep + + implementation daggerDep + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + ksp daggerCompiler + + implementation roomDep + ksp roomCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation substrateSdkDep + compileOnly wsDep + + implementation gsonDep + implementation retrofitDep + + implementation shimmerDep + + implementation coilDep +} \ No newline at end of file diff --git a/feature-crowdloan-impl/consumer-rules.pro b/feature-crowdloan-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-crowdloan-impl/proguard-rules.pro b/feature-crowdloan-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-crowdloan-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/AndroidManifest.xml b/feature-crowdloan-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/feature-crowdloan-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/CrowdloanSharedState.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/CrowdloanSharedState.kt new file mode 100644 index 0000000..cb7195f --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/CrowdloanSharedState.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.state.SelectableSingleAssetSharedState +import io.novafoundation.nova.runtime.state.NothingAdditional +import io.novafoundation.nova.runtime.state.uniqueOption + +private const val CROWDLOAN_SHARED_STATE = "CROWDLOAN_SHARED_STATE" + +class CrowdloanSharedState( + chainRegistry: ChainRegistry, + preferences: Preferences, +) : SelectableSingleAssetSharedState( + preferences = preferences, + chainRegistry = chainRegistry, + supportedOptions = uniqueOption { chain, chainAsset -> chain.hasCrowdloans and chainAsset.isUtilityAsset }, + preferencesKey = CROWDLOAN_SHARED_STATE +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaApi.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaApi.kt new file mode 100644 index 0000000..78a4f4e --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaApi.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala + +import io.novafoundation.nova.feature_crowdloan_impl.BuildConfig +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.requireGenesisHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path + +private fun authHeader(token: String) = "Bearer $token" + +interface AcalaApi { + + companion object { + private val URL_BY_GENESIS = mapOf( + Chain.Geneses.ROCOCO_ACALA to "crowdloan.aca-dev.network", + Chain.Geneses.POLKADOT to "crowdloan.aca-api.network", + Chain.Geneses.KUSAMA to "api.aca-staging.network" + ) + + private val AUTH_BY_GENESIS = mapOf( + Chain.Geneses.POLKADOT to authHeader(BuildConfig.ACALA_PROD_AUTH_TOKEN), + Chain.Geneses.ROCOCO_ACALA to authHeader(BuildConfig.ACALA_TEST_AUTH_TOKEN) + ) + + fun getAuthHeader(chain: Chain) = AUTH_BY_GENESIS[chain.requireGenesisHash()] + ?: notSupportedChain(chain) + + fun getBaseUrl(chain: Chain) = URL_BY_GENESIS[chain.requireGenesisHash()] + ?: notSupportedChain(chain) + + private fun notSupportedChain(chain: Chain): Nothing { + throw UnsupportedOperationException("Chain ${chain.name} is not supported for Acala/Karura crowdloans") + } + } + + @GET("//{baseUrl}/referral/{referral}") + suspend fun isReferralValid( + @Header("Authorization") authHeader: String, + @Path("baseUrl") baseUrl: String, + @Path("referral") referral: String, + ): ReferralCheck + + @GET("//{baseUrl}/statement") + suspend fun getStatement( + @Header("Authorization") authHeader: String, + @Path("baseUrl") baseUrl: String, + ): AcalaStatement + + @POST("//{baseUrl}/contribute") + suspend fun directContribute( + @Header("Authorization") authHeader: String, + @Path("baseUrl") baseUrl: String, + @Body body: AcalaDirectContributeRequest, + ): Any? + + @POST("//{baseUrl}/transfer") + suspend fun liquidContribute( + @Header("Authorization") authHeader: String, + @Path("baseUrl") baseUrl: String, + @Body body: AcalaLiquidContributeRequest, + ): Any? + + @GET("//{baseUrl}/contribution/{address}") + suspend fun getContributions( + @Path("baseUrl") baseUrl: String, + @Path("address") address: String, + ): AcalaContribution +} + +suspend fun AcalaApi.getContributions(chain: Chain, accountId: AccountId) = getContributions(AcalaApi.getBaseUrl(chain), chain.addressOf(accountId)) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaContribution.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaContribution.kt new file mode 100644 index 0000000..5aeb163 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaContribution.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala + +import java.math.BigInteger + +class AcalaContribution( + val proxyAmount: BigInteger?, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaDirectContributeRequest.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaDirectContributeRequest.kt new file mode 100644 index 0000000..7037fe1 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaDirectContributeRequest.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala + +import java.math.BigInteger + +class AcalaDirectContributeRequest( + val address: String, + val amount: BigInteger, + val referral: String?, + val signature: String, +) + +class AcalaLiquidContributeRequest( + val address: String, + val amount: BigInteger, + val referral: String?, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaStatement.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaStatement.kt new file mode 100644 index 0000000..7e65c60 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/AcalaStatement.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala + +class AcalaStatement( + val statement: String, + val proxyAddress: String, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/ReferralCheck.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/ReferralCheck.kt new file mode 100644 index 0000000..b942bb6 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/acala/ReferralCheck.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala + +class ReferralCheck( + val result: Boolean +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/bifrost/BifrostApi.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/bifrost/BifrostApi.kt new file mode 100644 index 0000000..c1826f6 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/bifrost/BifrostApi.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.bifrost + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface BifrostApi { + + companion object { + const val BASE_URL = "https://salp-api.bifrost.finance" + } + + @POST("/") + suspend fun getAccountByReferralCode(@Body body: BifrostReferralCheckRequest): SubQueryResponse +} + +suspend fun BifrostApi.getAccountByReferralCode(code: String) = getAccountByReferralCode(BifrostReferralCheckRequest(code)) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/bifrost/BifrostReferralCheckRequest.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/bifrost/BifrostReferralCheckRequest.kt new file mode 100644 index 0000000..add4985 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/bifrost/BifrostReferralCheckRequest.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.bifrost + +class BifrostReferralCheckRequest(code: String) { + val query = """ + { + getAccountByInvitationCode(code: "$code") { + account + } + } + """.trimIndent() +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/bifrost/GetAccountByReferralCodeResponse.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/bifrost/GetAccountByReferralCodeResponse.kt new file mode 100644 index 0000000..46ca4bb --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/bifrost/GetAccountByReferralCodeResponse.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.bifrost + +class GetAccountByReferralCodeResponse( + val getAccountByInvitationCode: GetAccountByReferralCode +) { + + class GetAccountByReferralCode(val account: String?) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/AgreeRemark.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/AgreeRemark.kt new file mode 100644 index 0000000..c737614 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/AgreeRemark.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam + +import com.google.gson.annotations.SerializedName + +class AgreeRemarkRequest( + val address: String, + @SerializedName("signed-message") + val signedMessage: String, +) + +class AgreeRemarkResponse( + val remark: String, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/CheckRemarkResponse.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/CheckRemarkResponse.kt new file mode 100644 index 0000000..06b3143 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/CheckRemarkResponse.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam + +class CheckRemarkResponse( + val verified: Boolean, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/MakeSignature.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/MakeSignature.kt new file mode 100644 index 0000000..23be678 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/MakeSignature.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam + +import com.google.gson.annotations.SerializedName +import java.util.UUID + +class MakeSignatureRequest( + val address: String, + @SerializedName("previous-total-contribution") + val previousTotalContribution: String, + val contribution: String, + val guid: String = UUID.randomUUID().toString(), +) + +class MakeSignatureResponse( + val signature: String, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/MoonbeamApi.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/MoonbeamApi.kt new file mode 100644 index 0000000..ab91945 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/MoonbeamApi.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam + +import io.novafoundation.nova.common.data.network.TimeHeaderInterceptor +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_api.data.repository.getExtra +import io.novafoundation.nova.feature_crowdloan_impl.BuildConfig +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Path + +private val AUTH_TOKENS = mapOf( + "MOONBEAM_TEST_AUTH_TOKEN" to BuildConfig.MOONBEAM_TEST_AUTH_TOKEN, + "MOONBEAM_PROD_AUTH_TOKEN" to BuildConfig.MOONBEAM_PROD_AUTH_TOKEN +) + +interface MoonbeamApi { + + @GET("https://raw.githubusercontent.com/moonbeam-foundation/crowdloan-self-attestation/main/moonbeam/README.md") + suspend fun getLegalText(): String + + @GET("//{baseUrl}/check-remark/{address}") + suspend fun checkRemark( + @Path("baseUrl") baseUrl: String, + @Header("x-api-key") apiToken: String?, + @Path("address") address: String, + ): CheckRemarkResponse + + @POST("//{baseUrl}/agree-remark") + suspend fun agreeRemark( + @Path("baseUrl") baseUrl: String, + @Header("x-api-key") apiToken: String?, + @Body body: AgreeRemarkRequest, + ): AgreeRemarkResponse + + @POST("//{baseUrl}/verify-remark") + @Headers(TimeHeaderInterceptor.LONG_CONNECT, TimeHeaderInterceptor.LONG_READ, TimeHeaderInterceptor.LONG_WRITE) + suspend fun verifyRemark( + @Path("baseUrl") baseUrl: String, + @Header("x-api-key") apiToken: String?, + @Body body: VerifyRemarkRequest, + ): VerifyRemarkResponse + + @POST("//{baseUrl}/make-signature") + suspend fun makeSignature( + @Path("baseUrl") baseUrl: String, + @Header("x-api-key") apiToken: String?, + @Body body: MakeSignatureRequest, + ): MakeSignatureResponse +} + +fun ParachainMetadata.moonbeamChainId() = getExtra("paraId") + +private fun ParachainMetadata.apiBaseUrl() = getExtra("apiLink") +private fun ParachainMetadata.apiToken() = AUTH_TOKENS[getExtra("apiTokenName")] + +suspend fun MoonbeamApi.checkRemark(chainMetadata: ParachainMetadata, address: String): CheckRemarkResponse { + return checkRemark(chainMetadata.apiBaseUrl(), chainMetadata.apiToken(), address) +} + +suspend fun MoonbeamApi.agreeRemark(chainMetadata: ParachainMetadata, body: AgreeRemarkRequest): AgreeRemarkResponse { + return agreeRemark(chainMetadata.apiBaseUrl(), chainMetadata.apiToken(), body) +} + +suspend fun MoonbeamApi.verifyRemark(chainMetadata: ParachainMetadata, body: VerifyRemarkRequest): VerifyRemarkResponse { + return verifyRemark(chainMetadata.apiBaseUrl(), chainMetadata.apiToken(), body) +} + +suspend fun MoonbeamApi.makeSignature(chainMetadata: ParachainMetadata, body: MakeSignatureRequest): MakeSignatureResponse { + return makeSignature(chainMetadata.apiBaseUrl(), chainMetadata.apiToken(), body) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/VerifyRemark.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/VerifyRemark.kt new file mode 100644 index 0000000..d9bd701 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/moonbeam/VerifyRemark.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam + +import com.google.gson.annotations.SerializedName + +class VerifyRemarkRequest( + val address: String, + @SerializedName("extrinsic-hash") + val extrinsicHash: String, + @SerializedName("block-hash") + val blockHash: String, +) + +class VerifyRemarkResponse( + val verified: Boolean, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parachain/ParachainMetadata.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parachain/ParachainMetadata.kt new file mode 100644 index 0000000..05b0f72 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parachain/ParachainMetadata.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parachain + +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata + +fun mapParachainMetadataRemoteToParachainMetadata(parachainMetadata: ParachainMetadataRemote) = + with(parachainMetadata) { + ParachainMetadata( + paraId = paraid, + movedToParaId = movedToParaId, + iconLink = icon, + name = name, + description = description, + rewardRate = rewardRate?.toBigDecimal(), + website = website, + customFlow = customFlow, + token = token, + extras = extras.orEmpty() + ) + } diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parachain/ParachainMetadataApi.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parachain/ParachainMetadataApi.kt new file mode 100644 index 0000000..c683de7 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parachain/ParachainMetadataApi.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parachain + +import retrofit2.http.GET +import retrofit2.http.Url + +interface ParachainMetadataApi { + + @GET() + suspend fun getParachainMetadata( + @Url url: String + ): List +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parachain/ParachainMetadataRemote.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parachain/ParachainMetadataRemote.kt new file mode 100644 index 0000000..d545eab --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parachain/ParachainMetadataRemote.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parachain + +import java.math.BigInteger + +class ParachainMetadataRemote( + val description: String, + val icon: String, + val name: String, + val paraid: BigInteger, + val token: String, + val rewardRate: Double?, + val customFlow: String?, + val website: String, + val extras: Map?, + val movedToParaId: BigInteger?, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parallel/ParallelApi.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parallel/ParallelApi.kt new file mode 100644 index 0000000..7edf929 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parallel/ParallelApi.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parallel + +import retrofit2.http.GET +import retrofit2.http.Path + +interface ParallelApi { + + companion object { + const val BASE_URL = "https://auction-service-prod.parallel.fi/crowdloan/rewards/" + } + + @GET("{network}/{address}") + suspend fun getContributions( + @Path("network") network: String, + @Path("address") address: String, + ): List +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parallel/ParallelContribution.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parallel/ParallelContribution.kt new file mode 100644 index 0000000..f866bb5 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/api/parallel/ParallelContribution.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parallel + +import java.math.BigInteger + +class ParallelContribution( + val paraId: BigInteger, + val amount: BigInteger, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/blockhain/extrinsic/ExtrinsicBuilderExt.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/blockhain/extrinsic/ExtrinsicBuilderExt.kt new file mode 100644 index 0000000..108975a --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/blockhain/extrinsic/ExtrinsicBuilderExt.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.blockhain.extrinsic + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import java.math.BigInteger + +fun ExtrinsicBuilder.contribute( + parachainId: ParaId, + contribution: BigInteger, + signature: Any?, +): ExtrinsicBuilder { + return call( + moduleName = "Crowdloan", + callName = "contribute", + arguments = mapOf( + "index" to parachainId, + "value" to contribution, + "signature" to signature + ) + ) +} + +fun ExtrinsicBuilder.addMemo(parachainId: ParaId, memo: String): ExtrinsicBuilder { + return addMemo(parachainId, memo.toByteArray()) +} + +fun ExtrinsicBuilder.addMemo(parachainId: ParaId, memo: ByteArray): ExtrinsicBuilder { + return call( + moduleName = "Crowdloan", + callName = "add_memo", + arguments = mapOf( + "index" to parachainId, + "memo" to memo + ) + ) +} + +fun ExtrinsicBuilder.claimContribution(parachainId: ParaId, block: BlockNumber, depositor: AccountId) { + call( + moduleName = "AhOps", + callName = "withdraw_crowdloan_contribution", + arguments = mapOf( + "block" to block, + "para_id" to parachainId, + "depositor" to depositor + ) + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/updater/ContributionsUpdateSystem.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/updater/ContributionsUpdateSystem.kt new file mode 100644 index 0000000..90b5d94 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/updater/ContributionsUpdateSystem.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.updater + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.transformLatestDiffed +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.ContributionsUpdateSystemFactory +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.ContributionsUpdaterFactory +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ext.isFullSync +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.transformLatest + +class RealContributionsUpdateSystemFactory( + private val chainRegistry: ChainRegistry, + private val contributionsUpdaterFactory: ContributionsUpdaterFactory, + private val assetBalanceScopeFactory: AssetBalanceScopeFactory, + private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : ContributionsUpdateSystemFactory { + + override fun create(): UpdateSystem { + return ContributionsUpdateSystem( + chainRegistry, + contributionsUpdaterFactory, + assetBalanceScopeFactory, + storageSharedRequestsBuilderFactory + ) + } +} + +class ContributionsUpdateSystem( + private val chainRegistry: ChainRegistry, + private val contributionsUpdaterFactory: ContributionsUpdaterFactory, + private val assetBalanceScopeFactory: AssetBalanceScopeFactory, + private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : UpdateSystem { + + override fun start(): Flow { + return flowOfAll { + chainRegistry.currentChains.mapLatest { chains -> + chains.filter { it.connectionState.isFullSync && it.hasCrowdloans } + }.transformLatestDiffed { + emitAll(run(it)) + } + }.flowOn(Dispatchers.Default) + } + + private fun run(chain: Chain): Flow { + return flowOfAll { + // we do not start subscription builder since it is not needed for contributions + val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) + val invalidationScope = assetBalanceScopeFactory.create(chain, chain.utilityAsset) + val updater = contributionsUpdaterFactory.create(chain, invalidationScope) + + invalidationScope.invalidationFlow().transformLatest { + kotlin.runCatching { + updater.listenForUpdates(subscriptionBuilder, it) + .catch { logError(chain, it) } + }.onSuccess { updaterFlow -> + emitAll(updaterFlow) + } + } + }.catch { logError(chain, it) } + } + + private fun logError(chain: Chain, exception: Throwable) { + Log.e(LOG_TAG, "Failed to run contributions updater for ${chain.name}", exception) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/updater/ContributionsUpdater.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/updater/ContributionsUpdater.kt new file mode 100644 index 0000000..f702fc1 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/updater/ContributionsUpdater.kt @@ -0,0 +1,104 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.updater + +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.dao.ContributionDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.dao.updateExternalBalance +import io.novafoundation.nova.core_db.model.ContributionLocal +import io.novafoundation.nova.core_db.model.ExternalBalanceLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.AssetBalanceScope +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.AssetBalanceScope.ScopeValue +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.ContributionsUpdaterFactory +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.mapContributionToLocal +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.onEach + +class RealContributionsUpdaterFactory( + private val contributionsRepository: ContributionsRepository, + private val contributionDao: ContributionDao, + private val externalBalanceDao: ExternalBalanceDao, +) : ContributionsUpdaterFactory { + + override fun create(chain: Chain, assetBalanceScope: AssetBalanceScope): Updater { + return ContributionsUpdater( + assetBalanceScope, + chain, + contributionsRepository, + contributionDao, + externalBalanceDao, + ) + } +} + +class ContributionsUpdater( + override val scope: AssetBalanceScope, + private val chain: Chain, + private val contributionsRepository: ContributionsRepository, + private val contributionDao: ContributionDao, + private val externalBalanceDao: ExternalBalanceDao, +) : Updater { + + override val requiredModules: List = emptyList() + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: ScopeValue, + ): Flow { + return flowOfAll { + if (scopeValue.asset.token.configuration.enabled) { + sync(scopeValue) + } else { + emptyFlow() + } + }.noSideAffects() + } + + private suspend fun sync(scopeValue: ScopeValue): Flow { + val metaAccount = scopeValue.metaAccount + val chainAsset = chain.utilityAsset + val accountId = metaAccount.accountIdIn(chain) ?: return emptyFlow() + + return contributionsRepository.loadContributionsGraduallyFlow( + chain = chain, + accountId = accountId, + ).onEach { (sourceId, contributionsResult) -> + contributionsResult.onSuccess { contributions -> + val newContributions = contributions.map { mapContributionToLocal(metaAccount.id, it) } + val oldContributions = contributionDao.getContributions(metaAccount.id, chain.id, chainAsset.id, sourceId) + + val collectionDiffer = CollectionDiffer.findDiff(newContributions, oldContributions, false) + contributionDao.updateContributions(collectionDiffer) + insertExternalBalance(newContributions, sourceId, chainAsset, metaAccount) + } + } + } + + private suspend fun insertExternalBalance( + contributions: List, + sourceId: String, + chainAsset: Chain.Asset, + metaAccount: MetaAccount + ) { + val totalSourceContributions = contributions.sumByBigInteger { it.amountInPlanks } + + val externalBalance = ExternalBalanceLocal( + metaId = metaAccount.id, + chainId = chain.id, + assetId = chainAsset.id, + type = ExternalBalanceLocal.Type.CROWDLOAN, + subtype = sourceId, + amount = totalSourceContributions + ) + + externalBalanceDao.updateExternalBalance(externalBalance) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/updater/RealAssetBalanceScope.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/updater/RealAssetBalanceScope.kt new file mode 100644 index 0000000..8ce25eb --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/network/updater/RealAssetBalanceScope.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.network.updater + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.AssetBalanceScope +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.AssetBalanceScope.ScopeValue +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map + +class AssetBalanceScopeFactory( + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, +) { + + fun create(chain: Chain, asset: Chain.Asset): AssetBalanceScope { + return RealAssetBalanceScope(chain, asset, walletRepository, accountRepository) + } +} + +class RealAssetBalanceScope( + private val chain: Chain, + private val asset: Chain.Asset, + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, +) : AssetBalanceScope { + + override fun invalidationFlow(): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + walletRepository.assetFlow(metaAccount.id, asset).map { asset -> + ScopeValue(metaAccount, asset) + } + } + .distinctUntilChanged { old, new -> + old.asset.totalInPlanks == new.asset.totalInPlanks && + old.metaAccount.id == new.metaAccount.id && + old.metaAccount.accountIdIn(chain).contentEquals(new.metaAccount.accountIdIn(chain)) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/CrowdloanRepositoryImpl.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/CrowdloanRepositoryImpl.kt new file mode 100644 index 0000000..2f0bbd4 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/CrowdloanRepositoryImpl.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.utils.crowdloan +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.slots +import io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding.FundInfo +import io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding.LeaseEntry +import io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding.bindFundInfo +import io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding.bindLeases +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.LeasePeriodToBlocksConverter +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parachain.ParachainMetadataApi +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parachain.mapParachainMetadataRemoteToParachainMetadata +import io.novafoundation.nova.runtime.ext.externalApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class CrowdloanRepositoryImpl( + private val remoteStorage: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val parachainMetadataApi: ParachainMetadataApi +) : CrowdloanRepository { + + override suspend fun allFundInfos(chainId: ChainId): Map { + return remoteStorage.query(chainId) { + runtime.metadata.crowdloan().storage("Funds").entries( + keyExtractor = { (paraId: BigInteger) -> paraId }, + binding = { instance, paraId -> bindFundInfo(instance, runtime, paraId) } + ) + } + } + + override suspend fun getWinnerInfo(chainId: ChainId, funds: Map): Map { + return remoteStorage.query(chainId) { + runtime.metadata.slots().storage("Leases").singleArgumentEntries( + keysArguments = funds.keys, + binding = { decoded, paraId -> + val leases = decoded?.let { bindLeases(it) } + val fund = funds.getValue(paraId) + + leases?.let { isWinner(leases, fund) } ?: false + } + ) + } + } + + private fun isWinner(leases: List, fundInfo: FundInfo): Boolean { + return leases.any { it.isOwnedBy(fundInfo.bidderAccountId) || it.isOwnedBy(fundInfo.pre9180BidderAccountId) } + } + + private fun LeaseEntry?.isOwnedBy(accountId: AccountId): Boolean = this?.accountId.contentEquals(accountId) + + override suspend fun getParachainMetadata(chain: Chain): Map { + return withContext(Dispatchers.Default) { + chain.externalApi()?.let { section -> + parachainMetadataApi.getParachainMetadata(section.url) + .associateBy { it.paraid } + .mapValues { (_, remoteMetadata) -> mapParachainMetadataRemoteToParachainMetadata(remoteMetadata) } + } ?: emptyMap() + } + } + + override suspend fun leasePeriodToBlocksConverter(chainId: ChainId): LeasePeriodToBlocksConverter { + val runtime = runtimeFor(chainId) + val slots = runtime.metadata.slots() + + return LeasePeriodToBlocksConverter( + blocksPerLease = slots.numberConstant("LeasePeriod", runtime), + blocksOffset = slots.numberConstant("LeaseOffset", runtime) + ) + } + + override fun fundInfoFlow(chainId: ChainId, parachainId: ParaId): Flow { + return remoteStorage.observe( + keyBuilder = { it.metadata.crowdloan().storage("Funds").storageKey(it, parachainId) }, + binder = { scale, runtime -> bindFundInfo(scale!!, runtime, parachainId) }, + chainId = chainId + ) + } + + override suspend fun minContribution(chainId: ChainId): BigInteger { + val runtime = runtimeFor(chainId) + + return runtime.metadata.crowdloan().numberConstant("MinContribution", runtime) + } + + private suspend fun runtimeFor(chainId: String) = chainRegistry.getRuntime(chainId) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/contributions/network/AhOpsApi.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/contributions/network/AhOpsApi.kt new file mode 100644 index 0000000..6d7b7f5 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/contributions/network/AhOpsApi.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.repository.contributions.network + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry3 +import io.novafoundation.nova.runtime.storage.source.query.api.storage3 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class AhOpsApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.ahOps: AhOpsApi + get() = AhOpsApi(module("AhOps")) + +context(StorageQueryContext) +val AhOpsApi.rcCrowdloanReserve: QueryableStorageEntry3 + get() = storage3( + name = "RcCrowdloanReserve", + binding = { _, _, _, decoded -> decoded }, + key3ToInternalConverter = { it.value }, + key3FromInternalConverter = ::bindAccountIdKey + ) + +context(StorageQueryContext) +val AhOpsApi.rcCrowdloanContribution: QueryableStorageEntry3 + get() = storage3( + name = "RcCrowdloanContribution", + binding = { decoded, _, _, _ -> bindContribution(decoded) }, + key3ToInternalConverter = { it.value }, + key3FromInternalConverter = ::bindAccountIdKey + ) + +private fun bindContribution(decoded: Any?): Balance { + val (_, balance) = decoded.castToList() // Tuple + + return bindNumber(balance) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/contributions/source/LiquidAcalaContributionSource.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/contributions/source/LiquidAcalaContributionSource.kt new file mode 100644 index 0000000..d01f2f1 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/contributions/source/LiquidAcalaContributionSource.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.repository.contributions.source + +import io.novafoundation.nova.feature_crowdloan_api.data.source.contribution.ExternalContributionSource +import io.novafoundation.nova.feature_crowdloan_api.data.source.contribution.ExternalContributionSource.ExternalContribution +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala.AcalaApi +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import io.novasama.substrate_sdk_android.runtime.AccountId + +class LiquidAcalaContributionSource( + private val acalaApi: AcalaApi, + private val parachainInfoRepository: ParachainInfoRepository, +) : ExternalContributionSource { + + override val supportedChains = setOf(Chain.Geneses.POLKADOT) + + override val sourceId: String = Contribution.LIQUID_SOURCE_ID + + override suspend fun getContributions( + chain: Chain, + accountId: AccountId, + ): Result> { + return Result.success(emptyList()) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/contributions/source/ParallelContributionSource.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/contributions/source/ParallelContributionSource.kt new file mode 100644 index 0000000..cb839ea --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/data/repository/contributions/source/ParallelContributionSource.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_crowdloan_impl.data.repository.contributions.source + +import io.novafoundation.nova.feature_crowdloan_api.data.source.contribution.ExternalContributionSource +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parallel.ParallelApi +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class ParallelContributionSource( + private val parallelApi: ParallelApi, +) : ExternalContributionSource { + + override val supportedChains = setOf(Chain.Geneses.POLKADOT) + + override val sourceId: String = Contribution.PARALLEL_SOURCE_ID + + override suspend fun getContributions( + chain: Chain, + accountId: AccountId, + ): Result> { + return Result.success(emptyList()) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureComponent.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureComponent.kt new file mode 100644 index 0000000..c00040b --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureComponent.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.di.contributions.ContributionsModule +import io.novafoundation.nova.feature_crowdloan_impl.di.validations.CrowdloansValidationsModule +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.claimControbution.di.ClaimContributionComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.di.ConfirmContributeComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.di.CustomContributeComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.terms.di.MoonbeamCrowdloanTermsComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralContributeView +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.di.CrowdloanContributeComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions.di.UserContributionsComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.main.di.CrowdloanComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + CrowdloanFeatureDependencies::class + ], + modules = [ + CrowdloanFeatureModule::class, + CrowdloanUpdatersModule::class, + CrowdloansValidationsModule::class, + ContributionsModule::class + ] +) +@FeatureScope +interface CrowdloanFeatureComponent : CrowdloanFeatureApi { + + fun crowdloansFactory(): CrowdloanComponent.Factory + + fun userContributionsFactory(): UserContributionsComponent.Factory + + fun selectContributeFactory(): CrowdloanContributeComponent.Factory + + fun confirmContributeFactory(): ConfirmContributeComponent.Factory + + fun customContributeFactory(): CustomContributeComponent.Factory + + fun moonbeamTermsFactory(): MoonbeamCrowdloanTermsComponent.Factory + + fun claimContributions(): ClaimContributionComponent.Factory + + fun inject(view: ReferralContributeView) + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: CrowdloanRouter, + deps: CrowdloanFeatureDependencies, + ): CrowdloanFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + RuntimeApi::class, + AccountFeatureApi::class, + WalletFeatureApi::class + ] + ) + interface CrowdloanFeatureDependenciesComponent : CrowdloanFeatureDependencies +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureDependencies.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureDependencies.kt new file mode 100644 index 0000000..84f77c0 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureDependencies.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di + +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.ContributionDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.AssetModelFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +interface CrowdloanFeatureDependencies { + + val maskableValueFormatterFactory: MaskableValueFormatterFactory + + val maskableValueFormatterProvider: MaskableValueFormatterProvider + + val amountFormatter: AmountFormatter + + val parachainInfoRepository: ParachainInfoRepository + + val signerProvider: SignerProvider + + val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory + + val externalBalanceDao: ExternalBalanceDao + + val assetModelFormatter: AssetModelFormatter + + val assetIconProvider: AssetIconProvider + + val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + + val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory + + val walletUIUseCase: WalletUiUseCase + + fun contributionDao(): ContributionDao + + fun accountUpdaterScope(): AccountUpdateScope + + fun selectedAccountUseCase(): SelectedAccountUseCase + + fun walletConstants(): WalletConstants + + fun storageCache(): StorageCache + + fun imageLoader(): ImageLoader + + fun accountRepository(): AccountRepository + + fun addressIconGenerator(): AddressIconGenerator + + fun appLinksProvider(): AppLinksProvider + + fun walletRepository(): WalletRepository + + fun tokenRepository(): TokenRepository + + fun resourceManager(): ResourceManager + + fun externalAccountActions(): ExternalActions.Presentation + + fun networkApiCreator(): NetworkApiCreator + + fun httpExceptionHandler(): HttpExceptionHandler + + fun gson(): Gson + + fun addressxDisplayUseCase(): AddressDisplayUseCase + + fun extrinsicService(): ExtrinsicService + + fun validationExecutor(): ValidationExecutor + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource + + @Named(LOCAL_STORAGE_SOURCE) + fun localStorageSource(): StorageDataSource + + fun chainStateRepository(): ChainStateRepository + + fun chainRegistry(): ChainRegistry + + fun preferences(): Preferences + + fun secretStoreV2(): SecretStoreV2 + + fun customDialogDisplayer(): CustomDialogDisplayer.Presentation + + fun feeLoaderMixinFactory(): FeeLoaderMixin.Factory +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureHolder.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureHolder.kt new file mode 100644 index 0000000..c637f53 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureHolder.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class CrowdloanFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: CrowdloanRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerCrowdloanFeatureComponent_CrowdloanFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .dbApi(getFeature(DbApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .build() + + return DaggerCrowdloanFeatureComponent.factory() + .create(router, dependencies) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureModule.kt new file mode 100644 index 0000000..43eda74 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanFeatureModule.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parachain.ParachainMetadataApi +import io.novafoundation.nova.feature_crowdloan_impl.data.repository.CrowdloanRepositoryImpl +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeModule +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.CrowdloanContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.CrowdloanInteractor +import io.novafoundation.nova.feature_wallet_api.di.common.SelectableAssetUseCaseModule +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.state.SelectableSingleAssetSharedState +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module( + includes = [ + CustomContributeModule::class, + SelectableAssetUseCaseModule::class, + ] +) +class CrowdloanFeatureModule { + + @Provides + @FeatureScope + fun provideCrowdloanSharedState( + chainRegistry: ChainRegistry, + preferences: Preferences, + ) = CrowdloanSharedState(chainRegistry, preferences) + + @Provides + @FeatureScope + fun provideSelectableSharedState(crowdloanSharedState: CrowdloanSharedState): SelectableSingleAssetSharedState<*> = crowdloanSharedState + + @Provides + @FeatureScope + fun provideFeeLoaderMixin( + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + tokenUseCase: TokenUseCase, + ): FeeLoaderMixin.Presentation = feeLoaderMixinFactory.create(tokenUseCase) + + @Provides + @FeatureScope + fun crowdloanRepository( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + crowdloanMetadataApi: ParachainMetadataApi, + chainRegistry: ChainRegistry, + ): CrowdloanRepository = CrowdloanRepositoryImpl( + remoteStorageSource, + chainRegistry, + crowdloanMetadataApi + ) + + @Provides + @FeatureScope + fun provideCrowdloanInteractor( + crowdloanRepository: CrowdloanRepository, + chainStateRepository: ChainStateRepository, + contributionsRepository: ContributionsRepository + ) = CrowdloanInteractor( + crowdloanRepository, + chainStateRepository, + contributionsRepository + ) + + @Provides + @FeatureScope + fun provideCrowdloanMetadataApi(networkApiCreator: NetworkApiCreator): ParachainMetadataApi { + return networkApiCreator.create(ParachainMetadataApi::class.java) + } + + @Provides + @FeatureScope + fun provideCrowdloanContributeInteractor( + extrinsicService: ExtrinsicService, + accountRepository: AccountRepository, + chainStateRepository: ChainStateRepository, + sharedState: CrowdloanSharedState, + crowdloanRepository: CrowdloanRepository, + customContributeManager: CustomContributeManager, + contributionsRepository: ContributionsRepository + ) = CrowdloanContributeInteractor( + extrinsicService, + accountRepository, + chainStateRepository, + customContributeManager, + sharedState, + crowdloanRepository, + contributionsRepository + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanUpdatersModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanUpdatersModule.kt new file mode 100644 index 0000000..b3044d2 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/CrowdloanUpdatersModule.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SharedAssetBlockNumberUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimeLineChainUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder +import io.novafoundation.nova.runtime.network.updaters.multiChain.GroupBySyncChainMultiChainUpdateSystem + +@Module +class CrowdloanUpdatersModule { + + @Provides + @FeatureScope + fun provideTimelineDelegatingHolder(sharedState: CrowdloanSharedState) = DelegateToTimelineChainIdHolder(sharedState) + + @Provides + @FeatureScope + fun provideBlockNumberUpdater( + chainRegistry: ChainRegistry, + chainIdHolder: DelegateToTimelineChainIdHolder, + storageCache: StorageCache, + ) = SharedAssetBlockNumberUpdater(chainRegistry, chainIdHolder, storageCache) + + @Provides + @FeatureScope + fun provideCrowdloanUpdateSystem( + chainRegistry: ChainRegistry, + crowdloanSharedState: CrowdloanSharedState, + blockNumberUpdater: SharedAssetBlockNumberUpdater, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + ): UpdateSystem = GroupBySyncChainMultiChainUpdateSystem( + updaters = listOf( + DelegateToTimeLineChainUpdater(blockNumberUpdater) + ), + chainRegistry = chainRegistry, + singleAssetSharedState = crowdloanSharedState, + storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/contributions/ContributionsModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/contributions/ContributionsModule.kt new file mode 100644 index 0000000..a8a981a --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/contributions/ContributionsModule.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.contributions + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.ContributionDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.ContributionsUpdateSystemFactory +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.ContributionsUpdaterFactory +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.feature_crowdloan_api.data.source.contribution.ExternalContributionSource +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala.AcalaApi +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parallel.ParallelApi +import io.novafoundation.nova.feature_crowdloan_impl.data.network.updater.AssetBalanceScopeFactory +import io.novafoundation.nova.feature_crowdloan_impl.data.network.updater.RealContributionsUpdateSystemFactory +import io.novafoundation.nova.feature_crowdloan_impl.data.network.updater.RealContributionsUpdaterFactory +import io.novafoundation.nova.feature_crowdloan_impl.data.repository.contributions.source.LiquidAcalaContributionSource +import io.novafoundation.nova.feature_crowdloan_impl.data.repository.contributions.source.ParallelContributionSource +import io.novafoundation.nova.feature_crowdloan_impl.domain.contributions.RealContributionsInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.contributions.RealContributionsRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class ContributionsModule { + + @Provides + @FeatureScope + @IntoSet + fun acalaLiquidSource( + acalaApi: AcalaApi, + parachainInfoRepository: ParachainInfoRepository + ): ExternalContributionSource = LiquidAcalaContributionSource(acalaApi, parachainInfoRepository) + + @Provides + @FeatureScope + @IntoSet + fun parallelSource( + parallelApi: ParallelApi, + ): ExternalContributionSource = ParallelContributionSource(parallelApi) + + @Provides + @FeatureScope + fun provideContributionsInteractor( + crowdloanRepository: CrowdloanRepository, + accountRepository: AccountRepository, + crowdloanSharedState: CrowdloanSharedState, + chainStateRepository: ChainStateRepository, + contributionsRepository: ContributionsRepository, + chainRegistry: ChainRegistry, + contributionsUpdateSystemFactory: ContributionsUpdateSystemFactory + ): ContributionsInteractor = RealContributionsInteractor( + crowdloanRepository = crowdloanRepository, + accountRepository = accountRepository, + selectedAssetCrowdloanState = crowdloanSharedState, + chainStateRepository = chainStateRepository, + contributionsRepository = contributionsRepository, + chainRegistry = chainRegistry, + contributionsUpdateSystemFactory = contributionsUpdateSystemFactory + ) + + @Provides + @FeatureScope + fun provideContributionsRepository( + externalContributionsSources: Set<@JvmSuppressWildcards ExternalContributionSource>, + chainRegistry: ChainRegistry, + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + contributionDao: ContributionDao + ): ContributionsRepository { + return RealContributionsRepository( + externalContributionsSources.toList(), + chainRegistry, + remoteStorageSource, + contributionDao + ) + } + + @Provides + @FeatureScope + fun provideContributionsUpdaterFactory( + contributionsRepository: ContributionsRepository, + contributionDao: ContributionDao, + externalBalanceDao: ExternalBalanceDao + ): ContributionsUpdaterFactory = RealContributionsUpdaterFactory( + contributionsRepository, + contributionDao, + externalBalanceDao + ) + + @Provides + @FeatureScope + fun provideContributionUpdateSystemFactory( + contributionsUpdaterFactory: ContributionsUpdaterFactory, + chainRegistry: ChainRegistry, + assetBalanceScopeFactory: AssetBalanceScopeFactory, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + ): ContributionsUpdateSystemFactory = RealContributionsUpdateSystemFactory( + chainRegistry = chainRegistry, + contributionsUpdaterFactory = contributionsUpdaterFactory, + assetBalanceScopeFactory = assetBalanceScopeFactory, + storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory + ) + + @Provides + @FeatureScope + fun provideAssetBalanceScopeFactory( + walletRepository: WalletRepository, + accountRepository: AccountRepository + ) = AssetBalanceScopeFactory(walletRepository, accountRepository) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/CustomContributeFactory.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/CustomContributeFactory.kt new file mode 100644 index 0000000..a70c5bd --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/CustomContributeFactory.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan + +import android.content.Context +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.PrivateCrowdloanSignatureProvider +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.ConfirmContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeView +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeViewState +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.SelectContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.StartFlowInterceptor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import kotlinx.coroutines.CoroutineScope + +interface CustomContributeFactory { + + val flowType: String + + val privateCrowdloanSignatureProvider: PrivateCrowdloanSignatureProvider? + get() = null + + val submitter: CustomContributeSubmitter + + val startFlowInterceptor: StartFlowInterceptor? + get() = null + + val extraBonusFlow: ExtraBonusFlow? + get() = null + + val selectContributeCustomization: SelectContributeCustomization? + get() = null + + val confirmContributeCustomization: ConfirmContributeCustomization? + get() = null +} + +interface ExtraBonusFlow { + + fun createViewState(scope: CoroutineScope, payload: CustomContributePayload): CustomContributeViewState + + fun createView(context: Context): CustomContributeView +} + +fun CustomContributeFactory.supports(otherFlowType: String): Boolean { + return otherFlowType == flowType +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/CustomContributeManager.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/CustomContributeManager.kt new file mode 100644 index 0000000..c33e1b0 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/CustomContributeManager.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan + +class CustomContributeManager( + private val factories: Set +) { + + fun getFactoryOrNull(flowType: String): CustomContributeFactory? = relevantFactoryOrNull(flowType) + + private fun relevantFactory(flowType: String) = relevantFactoryOrNull(flowType) ?: noFactoryFound(flowType) + + private fun relevantFactoryOrNull( + flowType: String, + ): CustomContributeFactory? { + return factories.firstOrNull { it.supports(flowType) } + } + + fun relevantExtraBonusFlow(flowType: String): ExtraBonusFlow { + val factory = relevantFactory(flowType) + + return factory.extraBonusFlow ?: unexpectedBonusFlow(flowType) + } + + private fun noFactoryFound(flowType: String): Nothing = throw NoSuchElementException("Factory for $flowType was not found") + + private fun unexpectedBonusFlow(flowType: String): Nothing = throw IllegalStateException("No extra bonus flow found for flow $flowType") +} + +fun CustomContributeManager.hasExtraBonusFlow(flowType: String) = getFactoryOrNull(flowType)?.extraBonusFlow != null + +fun CustomContributeManager.supportsPrivateCrowdloans(flowType: String) = getFactoryOrNull(flowType)?.privateCrowdloanSignatureProvider != null diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/CustomContributeModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/CustomContributeModule.kt new file mode 100644 index 0000000..f497efd --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/CustomContributeModule.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.acala.AcalaContributionModule +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.astar.AstarContributionModule +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.bifrost.BifrostContributionModule +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.moonbeam.MoonbeamContributionModule +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.parallel.ParallelContributionModule + +@Module( + includes = [ + AcalaContributionModule::class, + BifrostContributionModule::class, + MoonbeamContributionModule::class, + AstarContributionModule::class, + ParallelContributionModule::class + ] +) +class CustomContributeModule { + + @Provides + @FeatureScope + fun provideCustomContributionManager( + factories: @JvmSuppressWildcards Set, + ) = CustomContributeManager(factories) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/acala/AcalaBasedContributeFactory.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/acala/AcalaBasedContributeFactory.kt new file mode 100644 index 0000000..d8e66d0 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/acala/AcalaBasedContributeFactory.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.acala + +import android.content.Context +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_impl.BuildConfig +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeFactory +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.ExtraBonusFlow +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala.AcalaContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeViewState +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.bonus.AcalaContributeSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.bonus.AcalaContributeViewState +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.confirm.AcalaConfirmContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.select.AcalaSelectContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralContributeView +import kotlinx.coroutines.CoroutineScope +import java.math.BigDecimal + +abstract class AcalaBasedContributeFactory( + override val submitter: AcalaContributeSubmitter, + override val extraBonusFlow: AcalaBasedExtraBonusFlow, +) : CustomContributeFactory + +abstract class AcalaBasedExtraBonusFlow( + private val interactor: AcalaContributeInteractor, + private val resourceManager: ResourceManager, + private val defaultReferralCode: String, +) : ExtraBonusFlow { + + protected open val bonusMultiplier: BigDecimal = 0.05.toBigDecimal() + + override fun createViewState(scope: CoroutineScope, payload: CustomContributePayload): CustomContributeViewState { + return AcalaContributeViewState(interactor, payload, resourceManager, defaultReferralCode, bonusMultiplier) + } + + override fun createView(context: Context) = ReferralContributeView(context) +} + +class AcalaContributeFactory( + submitter: AcalaContributeSubmitter, + extraBonusFlow: AcalaExtraBonusFlow, + override val selectContributeCustomization: AcalaSelectContributeCustomization, + override val confirmContributeCustomization: AcalaConfirmContributeCustomization, +) : AcalaBasedContributeFactory( + submitter = submitter, + extraBonusFlow = extraBonusFlow +) { + + override val flowType: String = "Acala" +} + +class AcalaExtraBonusFlow( + interactor: AcalaContributeInteractor, + resourceManager: ResourceManager, +) : AcalaBasedExtraBonusFlow( + interactor = interactor, + resourceManager = resourceManager, + defaultReferralCode = BuildConfig.ACALA_NOVA_REFERRAL +) + +class KaruraContributeFactory( + submitter: AcalaContributeSubmitter, + extraBonusFlow: KaruraExtraBonusFlow, +) : AcalaBasedContributeFactory( + submitter = submitter, + extraBonusFlow = extraBonusFlow +) { + + override val flowType: String = "Karura" +} + +class KaruraExtraBonusFlow( + interactor: AcalaContributeInteractor, + resourceManager: ResourceManager, +) : AcalaBasedExtraBonusFlow( + interactor = interactor, + resourceManager = resourceManager, + defaultReferralCode = BuildConfig.KARURA_NOVA_REFERRAL +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/acala/AcalaContributionModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/acala/AcalaContributionModule.kt new file mode 100644 index 0000000..9169e7d --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/acala/AcalaContributionModule.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.acala + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala.AcalaApi +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeFactory +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala.AcalaContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.bonus.AcalaContributeSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.confirm.AcalaConfirmContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.confirm.AcalaConfirmContributeViewStateFactory +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.select.AcalaSelectContributeCustomization +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class AcalaContributionModule { + + @Provides + @FeatureScope + fun provideAcalaApi( + networkApiCreator: NetworkApiCreator, + ) = networkApiCreator.create(AcalaApi::class.java) + + @Provides + @FeatureScope + fun provideAcalaInteractor( + acalaApi: AcalaApi, + httpExceptionHandler: HttpExceptionHandler, + selectAssetSharedState: CrowdloanSharedState, + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + signerProvider: SignerProvider + ) = AcalaContributeInteractor( + acalaApi = acalaApi, + httpExceptionHandler = httpExceptionHandler, + accountRepository = accountRepository, + chainRegistry = chainRegistry, + selectedAssetState = selectAssetSharedState, + signerProvider = signerProvider + ) + + @Provides + @FeatureScope + fun provideAcalaSubmitter( + interactor: AcalaContributeInteractor, + ) = AcalaContributeSubmitter(interactor) + + @Provides + @FeatureScope + fun provideAcalaExtraBonusFlow( + acalaInteractor: AcalaContributeInteractor, + resourceManager: ResourceManager, + ): AcalaExtraBonusFlow = AcalaExtraBonusFlow( + interactor = acalaInteractor, + resourceManager = resourceManager, + ) + + @Provides + @FeatureScope + fun provideKaruraExtraBonusFlow( + acalaInteractor: AcalaContributeInteractor, + resourceManager: ResourceManager, + ): KaruraExtraBonusFlow = KaruraExtraBonusFlow( + interactor = acalaInteractor, + resourceManager = resourceManager, + ) + + @Provides + @FeatureScope + fun provideAcalaSelectContributeCustomization(): AcalaSelectContributeCustomization = AcalaSelectContributeCustomization() + + @Provides + @FeatureScope + fun provideAcalaConfirmContributeViewStateFactory( + resourceManager: ResourceManager, + ) = AcalaConfirmContributeViewStateFactory(resourceManager) + + @Provides + @FeatureScope + fun provideAcalaConfirmContributeCustomization( + viewStateFactory: AcalaConfirmContributeViewStateFactory, + ): AcalaConfirmContributeCustomization = AcalaConfirmContributeCustomization(viewStateFactory) + + @Provides + @FeatureScope + @IntoSet + fun provideAcalaFactory( + submitter: AcalaContributeSubmitter, + acalaExtraBonusFlow: AcalaExtraBonusFlow, + acalaSelectContributeCustomization: AcalaSelectContributeCustomization, + acalaConfirmContributeCustomization: AcalaConfirmContributeCustomization, + ): CustomContributeFactory = AcalaContributeFactory( + submitter = submitter, + extraBonusFlow = acalaExtraBonusFlow, + selectContributeCustomization = acalaSelectContributeCustomization, + confirmContributeCustomization = acalaConfirmContributeCustomization + ) + + @Provides + @FeatureScope + @IntoSet + fun provideKaruraFactory( + submitter: AcalaContributeSubmitter, + karuraExtraBonusFlow: KaruraExtraBonusFlow, + ): CustomContributeFactory = KaruraContributeFactory( + submitter = submitter, + extraBonusFlow = karuraExtraBonusFlow + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/astar/AstarContributeFactory.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/astar/AstarContributeFactory.kt new file mode 100644 index 0000000..6a96aa3 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/astar/AstarContributeFactory.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.astar + +import android.content.Context +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeFactory +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.ExtraBonusFlow +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.astar.AstarContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.astar.AstarContributeSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.astar.AstarContributeViewState +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralContributeView +import kotlinx.coroutines.CoroutineScope +import java.math.BigDecimal + +private const val ASTAR_TERMS_LINK = "https://docs.google.com/document/d/1vKZrDqSdh706hg0cqJ_NnxfRSlXR2EThVHwoRl0nAkk" +private const val NOVA_REFERRAL_CODE = "1ChFWeNRLarAPRCTM3bfJmncJbSAbSS9yqjueWz7jX7iTVZ" +private val ASTAR_BONUS = 0.01.toBigDecimal() // 1% + +class AstarExtraBonusFlow( + private val interactor: AstarContributeInteractor, + private val resourceManager: ResourceManager, + private val termsLink: String = ASTAR_TERMS_LINK, + private val novaReferralCode: String = NOVA_REFERRAL_CODE, + private val bonusPercentage: BigDecimal = ASTAR_BONUS, +) : ExtraBonusFlow { + + override fun createViewState(scope: CoroutineScope, payload: CustomContributePayload): AstarContributeViewState { + return AstarContributeViewState( + interactor = interactor, + customContributePayload = payload, + resourceManager = resourceManager, + defaultReferralCode = novaReferralCode, + bonusPercentage = bonusPercentage, + termsLink = termsLink + ) + } + + override fun createView(context: Context) = ReferralContributeView(context) +} + +class AstarContributeFactory( + override val submitter: AstarContributeSubmitter, + override val extraBonusFlow: AstarExtraBonusFlow, +) : CustomContributeFactory { + + override val flowType = "Astar" +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/astar/AstarContributionModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/astar/AstarContributionModule.kt new file mode 100644 index 0000000..de471c3 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/astar/AstarContributionModule.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.astar + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeFactory +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.astar.AstarContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.astar.AstarContributeSubmitter + +@Module +class AstarContributionModule { + + @Provides + @FeatureScope + fun provideAstarInteractor( + selectedAssetSharedState: CrowdloanSharedState, + ) = AstarContributeInteractor(selectedAssetSharedState) + + @Provides + @FeatureScope + fun provideAstarSubmitter( + interactor: AstarContributeInteractor, + ) = AstarContributeSubmitter(interactor) + + @Provides + @FeatureScope + fun provideAstarExtraFlow( + interactor: AstarContributeInteractor, + resourceManager: ResourceManager, + ) = AstarExtraBonusFlow( + interactor = interactor, + resourceManager = resourceManager + ) + + @Provides + @FeatureScope + @IntoSet + fun provideAstarFactory( + submitter: AstarContributeSubmitter, + astarExtraBonusFlow: AstarExtraBonusFlow, + ): CustomContributeFactory = AstarContributeFactory( + submitter = submitter, + extraBonusFlow = astarExtraBonusFlow + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/bifrost/BifrostContributeFactory.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/bifrost/BifrostContributeFactory.kt new file mode 100644 index 0000000..bb172bc --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/bifrost/BifrostContributeFactory.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.bifrost + +import android.content.Context +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_impl.BuildConfig +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeFactory +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.ExtraBonusFlow +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.bifrost.BifrostContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.bifrost.BifrostContributeSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.bifrost.BifrostContributeViewState +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralContributeView +import kotlinx.coroutines.CoroutineScope + +private val BIFROST_BONUS_MULTIPLIER = 0.05.toBigDecimal() // 5% + +class BifrostExtraBonusFlow( + private val interactor: BifrostContributeInteractor, + private val resourceManager: ResourceManager, + private val termsLink: String = BuildConfig.BIFROST_TERMS_LINKS, +) : ExtraBonusFlow { + + override fun createViewState(scope: CoroutineScope, payload: CustomContributePayload): BifrostContributeViewState { + return BifrostContributeViewState( + interactor = interactor, + customContributePayload = payload, + resourceManager = resourceManager, + termsLink = termsLink, + bonusPercentage = BIFROST_BONUS_MULTIPLIER, + bifrostInteractor = interactor + ) + } + + override fun createView(context: Context) = ReferralContributeView(context) +} + +class BifrostContributeFactory( + override val submitter: BifrostContributeSubmitter, + override val extraBonusFlow: BifrostExtraBonusFlow, +) : CustomContributeFactory { + + override val flowType = "Bifrost" +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/bifrost/BifrostContributionModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/bifrost/BifrostContributionModule.kt new file mode 100644 index 0000000..c618027 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/bifrost/BifrostContributionModule.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.bifrost + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_impl.BuildConfig +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.bifrost.BifrostApi +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeFactory +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.bifrost.BifrostContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.bifrost.BifrostContributeSubmitter + +@Module +class BifrostContributionModule { + + @Provides + @FeatureScope + fun provideBifrostApi(networkApiCreator: NetworkApiCreator): BifrostApi { + return networkApiCreator.create(BifrostApi::class.java, customBaseUrl = BifrostApi.BASE_URL) + } + + @Provides + @FeatureScope + fun provideBifrostInteractor( + bifrostApi: BifrostApi, + httpExceptionHandler: HttpExceptionHandler, + ) = BifrostContributeInteractor(BuildConfig.BIFROST_NOVA_REFERRAL, bifrostApi, httpExceptionHandler) + + @Provides + @FeatureScope + fun provideBifrostSubmitter( + interactor: BifrostContributeInteractor, + ) = BifrostContributeSubmitter(interactor) + + @Provides + @FeatureScope + fun provideBifrostExtraFlow( + interactor: BifrostContributeInteractor, + resourceManager: ResourceManager, + ) = BifrostExtraBonusFlow(interactor, resourceManager) + + @Provides + @FeatureScope + @IntoSet + fun provideBifrostFactory( + submitter: BifrostContributeSubmitter, + bifrostExtraBonusFlow: BifrostExtraBonusFlow, + ): CustomContributeFactory = BifrostContributeFactory( + submitter, + bifrostExtraBonusFlow + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/moonbeam/MoonbeamContributeFactory.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/moonbeam/MoonbeamContributeFactory.kt new file mode 100644 index 0000000..7833978 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/moonbeam/MoonbeamContributeFactory.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.moonbeam + +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeFactory +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.MoonbeamPrivateSignatureProvider +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.MoonbeamStartFlowInterceptor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.main.ConfirmContributeMoonbeamCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.main.SelectContributeMoonbeamCustomization + +class MoonbeamContributeFactory( + override val submitter: CustomContributeSubmitter, + override val startFlowInterceptor: MoonbeamStartFlowInterceptor, + override val privateCrowdloanSignatureProvider: MoonbeamPrivateSignatureProvider, + override val selectContributeCustomization: SelectContributeMoonbeamCustomization, + override val confirmContributeCustomization: ConfirmContributeMoonbeamCustomization, +) : CustomContributeFactory { + + override val flowType: String = "Moonbeam" +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/moonbeam/MoonbeamContributionModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/moonbeam/MoonbeamContributionModule.kt new file mode 100644 index 0000000..3fb4111 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/moonbeam/MoonbeamContributionModule.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.moonbeam + +import coil.ImageLoader +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.MoonbeamApi +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeFactory +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.MoonbeamCrowdloanInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.MoonbeamPrivateSignatureProvider +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.MoonbeamCrowdloanSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.MoonbeamStartFlowInterceptor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.main.ConfirmContributeMoonbeamCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.main.MoonbeamMainFlowCustomViewStateFactory +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.main.SelectContributeMoonbeamCustomization +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class MoonbeamContributionModule { + + @Provides + @FeatureScope + fun provideMoonbeamApi( + networkApiCreator: NetworkApiCreator, + ) = networkApiCreator.create(MoonbeamApi::class.java) + + @Provides + @FeatureScope + fun provideMoonbeamInteractor( + accountRepository: AccountRepository, + extrinsicService: ExtrinsicService, + moonbeamApi: MoonbeamApi, + selectedAssetSharedState: CrowdloanSharedState, + httpExceptionHandler: HttpExceptionHandler, + chainRegistry: ChainRegistry, + signerProvider: SignerProvider + ) = MoonbeamCrowdloanInteractor( + accountRepository = accountRepository, + extrinsicService = extrinsicService, + moonbeamApi = moonbeamApi, + selectedChainAssetState = selectedAssetSharedState, + chainRegistry = chainRegistry, + httpExceptionHandler = httpExceptionHandler, + signerProvider = signerProvider + ) + + @Provides + @FeatureScope + fun provideMoonbeamSubmitter(interactor: MoonbeamCrowdloanInteractor) = MoonbeamCrowdloanSubmitter(interactor) + + @Provides + @FeatureScope + fun provideMoonbeamStartFlowInterceptor( + router: CrowdloanRouter, + resourceManager: ResourceManager, + interactor: MoonbeamCrowdloanInteractor, + customDialogDisplayer: CustomDialogDisplayer.Presentation, + ) = MoonbeamStartFlowInterceptor( + crowdloanRouter = router, + resourceManager = resourceManager, + moonbeamInteractor = interactor, + customDialogDisplayer = customDialogDisplayer, + ) + + @Provides + @FeatureScope + fun provideMoonbeamPrivateSignatureProvider( + moonbeamApi: MoonbeamApi, + httpExceptionHandler: HttpExceptionHandler, + ) = MoonbeamPrivateSignatureProvider(moonbeamApi, httpExceptionHandler) + + @Provides + @FeatureScope + fun provideSelectContributeMoonbeamViewStateFactory( + interactor: MoonbeamCrowdloanInteractor, + resourceManager: ResourceManager, + iconGenerator: AddressIconGenerator, + ) = MoonbeamMainFlowCustomViewStateFactory(interactor, resourceManager, iconGenerator) + + @Provides + @FeatureScope + fun provideSelectContributeMoonbeamCustomization( + viewStateFactory: MoonbeamMainFlowCustomViewStateFactory, + imageLoader: ImageLoader, + ) = SelectContributeMoonbeamCustomization(viewStateFactory, imageLoader) + + @Provides + @FeatureScope + fun provideConfirmContributeMoonbeamCustomization( + viewStateFactory: MoonbeamMainFlowCustomViewStateFactory, + imageLoader: ImageLoader, + ) = ConfirmContributeMoonbeamCustomization(viewStateFactory, imageLoader) + + @Provides + @FeatureScope + @IntoSet + fun provideMoonbeamFactory( + submitter: MoonbeamCrowdloanSubmitter, + moonbeamStartFlowInterceptor: MoonbeamStartFlowInterceptor, + privateSignatureProvider: MoonbeamPrivateSignatureProvider, + selectContributeMoonbeamCustomization: SelectContributeMoonbeamCustomization, + confirmContributeMoonbeamCustomization: ConfirmContributeMoonbeamCustomization, + ): CustomContributeFactory = MoonbeamContributeFactory( + submitter = submitter, + startFlowInterceptor = moonbeamStartFlowInterceptor, + privateCrowdloanSignatureProvider = privateSignatureProvider, + selectContributeCustomization = selectContributeMoonbeamCustomization, + confirmContributeCustomization = confirmContributeMoonbeamCustomization + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/parallel/ParallelContributionModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/parallel/ParallelContributionModule.kt new file mode 100644 index 0000000..34a54f6 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/customCrowdloan/parallel/ParallelContributionModule.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.parallel + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.parallel.ParallelApi + +@Module +class ParallelContributionModule { + + @Provides + @FeatureScope + fun provideParallelApi( + networkApiCreator: NetworkApiCreator, + ) = networkApiCreator.create(ParallelApi::class.java, customBaseUrl = ParallelApi.BASE_URL) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/Confirm.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/Confirm.kt new file mode 100644 index 0000000..10783d1 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/Confirm.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.validations + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Select + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Confirm diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/ContributeValidationsModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/ContributeValidationsModule.kt new file mode 100644 index 0000000..8f119d0 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/ContributeValidationsModule.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.BonusAppliedValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.CapExceededValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeEnoughToPayFeesValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeExistentialDepositValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidationFailure +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.CrowdloanNotEndedValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.DefaultMinContributionValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.PublicCrowdloanValidation +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidationGeneric +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +@Module +class ContributeValidationsModule { + + @Provides + @FeatureScope + fun provideFeesValidation(): ContributeEnoughToPayFeesValidation = EnoughAmountToTransferValidationGeneric( + feeExtractor = { it.fee }, + availableBalanceProducer = { it.asset.transferable }, + extraAmountExtractor = { it.contributionAmount }, + errorProducer = { ContributeValidationFailure.CannotPayFees } + ) + + @Provides + @FeatureScope + fun provideMinContributionValidation( + crowdloanRepository: CrowdloanRepository, + ) = DefaultMinContributionValidation(crowdloanRepository) + + @Provides + @FeatureScope + fun provideCapExceededValidation() = CapExceededValidation() + + @Provides + @FeatureScope + fun provideCrowdloanNotEndedValidation( + chainStateRepository: ChainStateRepository, + crowdloanRepository: CrowdloanRepository, + ) = CrowdloanNotEndedValidation(chainStateRepository, crowdloanRepository) + + @Provides + @FeatureScope + fun provideExistentialWarningValidation( + walletConstants: WalletConstants, + ) = ContributeExistentialDepositValidation( + countableTowardsEdBalance = { it.asset.balanceCountedTowardsED() }, + feeProducer = { listOf(it.fee) }, + extraAmountProducer = { it.contributionAmount }, + existentialDeposit = { + val inPlanks = walletConstants.existentialDeposit(it.asset.token.configuration.chainId) + + it.asset.token.amountFromPlanks(inPlanks) + }, + errorProducer = { _, _ -> ContributeValidationFailure.ExistentialDepositCrossed }, + ) + + @Provides + @FeatureScope + fun providePublicCrowdloanValidation( + customContributeManager: CustomContributeManager, + ) = PublicCrowdloanValidation(customContributeManager) + + @Provides + @FeatureScope + fun provideBonusAppliedValidation( + customContributeManager: CustomContributeManager, + ) = BonusAppliedValidation(customContributeManager) + + @Provides + @Select + @FeatureScope + fun provideSelectContributeValidationSet( + feesValidation: ContributeEnoughToPayFeesValidation, + minContributionValidation: DefaultMinContributionValidation, + capExceededValidation: CapExceededValidation, + crowdloanNotEndedValidation: CrowdloanNotEndedValidation, + contributeExistentialDepositValidation: ContributeExistentialDepositValidation, + publicCrowdloanValidation: PublicCrowdloanValidation, + bonusAppliedValidation: BonusAppliedValidation, + ): Set = setOf( + feesValidation, + minContributionValidation, + capExceededValidation, + crowdloanNotEndedValidation, + contributeExistentialDepositValidation, + publicCrowdloanValidation, + bonusAppliedValidation + ) + + @Provides + @Confirm + @FeatureScope + fun provideConfirmContributeValidationSet( + feesValidation: ContributeEnoughToPayFeesValidation, + minContributionValidation: DefaultMinContributionValidation, + capExceededValidation: CapExceededValidation, + crowdloanNotEndedValidation: CrowdloanNotEndedValidation, + contributeExistentialDepositValidation: ContributeExistentialDepositValidation, + publicCrowdloanValidation: PublicCrowdloanValidation, + ): Set = setOf( + feesValidation, + minContributionValidation, + capExceededValidation, + crowdloanNotEndedValidation, + contributeExistentialDepositValidation, + publicCrowdloanValidation, + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/CrowdloansValidationsModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/CrowdloansValidationsModule.kt new file mode 100644 index 0000000..54bca15 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/CrowdloansValidationsModule.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.validations + +import dagger.Module + +@Module( + includes = [ + ContributeValidationsModule::class, + MoonbeamTermsValidationsModule::class + ] +) +class CrowdloansValidationsModule diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/MoonbeamTermsValidationsModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/MoonbeamTermsValidationsModule.kt new file mode 100644 index 0000000..6acf409 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/di/validations/MoonbeamTermsValidationsModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_crowdloan_impl.di.validations + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam.MoonbeamTermsValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam.MoonbeamTermsValidationFailure +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam.MoonbeamTermsValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidationGeneric + +@Module +class MoonbeamTermsValidationsModule { + + @Provides + @IntoSet + @FeatureScope + fun provideFeesValidation(): MoonbeamTermsValidation = EnoughAmountToTransferValidationGeneric( + feeExtractor = { it.fee }, + availableBalanceProducer = { it.asset.transferable }, + errorProducer = { MoonbeamTermsValidationFailure.CANNOT_PAY_FEES } + ) + + @Provides + @FeatureScope + fun provideValidationSystem( + contributeValidations: @JvmSuppressWildcards Set + ) = MoonbeamTermsValidationSystem( + validation = CompositeValidation(contributeValidations.toList()) + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/ClaimContributionsInteractor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/ClaimContributionsInteractor.kt new file mode 100644 index 0000000..1f782d1 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/ClaimContributionsInteractor.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions + +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionClaimStatus +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsWithTotalAmount +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.claimStatusOf +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.data.network.blockhain.extrinsic.claimContribution +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.blockDurationEstimatorFlow +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAndAsset +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +@ScreenScope +class ClaimContributionsInteractor @Inject constructor( + private val accountRepository: AccountRepository, + private val crowdloanState: CrowdloanSharedState, + private val chainStateRepository: ChainStateRepository, + private val contributionsRepository: ContributionsRepository, + private val extrinsicService: ExtrinsicService, +) { + + fun claimableContributions(): Flow> { + return flowOfAll { + val account = accountRepository.getSelectedMetaAccount() + val (chain, asset) = crowdloanState.chainAndAsset() + + combine( + chainStateRepository.blockDurationEstimatorFlow(chain.timelineChainIdOrSelf()), + contributionsRepository.observeContributions(account, chain, asset) + ) { blockDurationEstimator, contributions -> + contributions.filter { + val claimStatus = blockDurationEstimator.claimStatusOf(it) + claimStatus is ContributionClaimStatus.Claimable + } + } + .map { claimableContributions -> ContributionsWithTotalAmount.fromContributions(claimableContributions) } + } + } + + suspend fun estimateFee(contributions: List): SubmissionFee { + val chain = crowdloanState.chain() + + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { context -> + val depositor = context.submissionOrigin.executingAccount + claim(contributions, depositor) + } + } + + suspend fun claim(contributions: List): Result { + val chain = crowdloanState.chain() + + return extrinsicService.submitExtrinsicAndAwaitExecution(chain, TransactionOrigin.SelectedWallet) { context -> + val depositor = context.submissionOrigin.executingAccount + claim(contributions, depositor) + } + .requireOk() + } + + private fun ExtrinsicBuilder.claim(contributions: List, depositor: AccountId) { + contributions.forEach { contribution -> + claimContribution(contribution.paraId, contribution.unlockBlock, depositor) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/validation/ClaimContributionValidationFailure.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/validation/ClaimContributionValidationFailure.kt new file mode 100644 index 0000000..84832b3 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/validation/ClaimContributionValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.validation + +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class ClaimContributionValidationFailure { + + class NotEnoughBalanceToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : ClaimContributionValidationFailure(), NotEnoughToPayFeesError +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/validation/ClaimContributionValidationPayload.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/validation/ClaimContributionValidationPayload.kt new file mode 100644 index 0000000..6c3d68e --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/validation/ClaimContributionValidationPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.validation + +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class ClaimContributionValidationPayload( + val fee: Fee, + val asset: Asset, +) + +val ClaimContributionValidationPayload.chainId: ChainId + get() = asset.token.configuration.chainId diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/validation/ClaimContributionValidationSystem.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/validation/ClaimContributionValidationSystem.kt new file mode 100644 index 0000000..fbb123b --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/claimContributions/validation/ClaimContributionValidationSystem.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.validation + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias ClaimContributionValidationSystem = ValidationSystem +typealias ClaimContributionValidationSystemBuilder = ValidationSystemBuilder + +fun ValidationSystem.Companion.claimContribution(): ClaimContributionValidationSystem = ValidationSystem { + enoughToPayFees() +} + +private fun ClaimContributionValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { + ClaimContributionValidationFailure.NotEnoughBalanceToPayFees( + chainAsset = it.payload.asset.token.configuration, + maxUsable = it.maxUsable, + fee = it.fee + ) + } + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/CrowdloanContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/CrowdloanContributeInteractor.kt new file mode 100644 index 0000000..8b0a285 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/CrowdloanContributeInteractor.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute + +import android.os.Parcelable +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.data.network.blockhain.extrinsic.contribute +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.PrivateCrowdloanSignatureProvider.Mode +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.state.chainAndAsset +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.withContext +import java.math.BigDecimal +import java.math.BigInteger + +typealias OnChainSubmission = suspend ExtrinsicBuilder.() -> Unit + +class CrowdloanContributeInteractor( + private val extrinsicService: ExtrinsicService, + private val accountRepository: AccountRepository, + private val chainStateRepository: ChainStateRepository, + private val customContributeManager: CustomContributeManager, + private val crowdloanSharedState: CrowdloanSharedState, + private val crowdloanRepository: CrowdloanRepository, + private val contributionsRepository: ContributionsRepository +) { + + fun crowdloanStateFlow( + parachainId: ParaId, + parachainMetadata: ParachainMetadata?, + ): Flow = emptyFlow() // this flow is no longer accessible and deprecated. We will remove entire crowdloan feature soon + + suspend fun estimateFee( + crowdloan: Crowdloan, + contribution: BigDecimal, + bonusPayload: BonusPayload?, + customizationPayload: Parcelable?, + ): Fee = formingSubmission( + crowdloan = crowdloan, + contribution = contribution, + bonusPayload = bonusPayload, + customizationPayload = customizationPayload, + toCalculateFee = true + ) { submission, chain, _ -> + extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet, formExtrinsic = { submission() }) + } + + suspend fun contribute( + crowdloan: Crowdloan, + contribution: BigDecimal, + bonusPayload: BonusPayload?, + customizationPayload: Parcelable?, + ): Result = runCatching { + crowdloan.parachainMetadata?.customFlow?.let { + customContributeManager.getFactoryOrNull(it)?.submitter?.submitOffChain(customizationPayload, bonusPayload, contribution) + } + + formingSubmission( + crowdloan = crowdloan, + contribution = contribution, + bonusPayload = bonusPayload, + toCalculateFee = false, + customizationPayload = customizationPayload + ) { submission, chain, account -> + extrinsicService.submitExtrinsic(chain, TransactionOrigin.Wallet(account)) { submission() } + }.getOrThrow() + } + + private suspend fun formingSubmission( + crowdloan: Crowdloan, + contribution: BigDecimal, + bonusPayload: BonusPayload?, + customizationPayload: Parcelable?, + toCalculateFee: Boolean, + finalAction: suspend (OnChainSubmission, Chain, MetaAccount) -> T, + ): T = withContext(Dispatchers.Default) { + val (chain, chainAsset) = crowdloanSharedState.chainAndAsset() + val contributionInPlanks = chainAsset.planksFromAmount(contribution) + val account = accountRepository.getSelectedMetaAccount() + + val privateSignature = crowdloan.parachainMetadata?.customFlow?.let { + val previousContribution = crowdloan.myContribution?.amountInPlanks ?: BigInteger.ZERO + + val signatureProvider = customContributeManager.getFactoryOrNull(it)?.privateCrowdloanSignatureProvider + val address = account.addressIn(chain)!! + + signatureProvider?.provideSignature( + chainMetadata = crowdloan.parachainMetadata, + previousContribution = previousContribution, + newContribution = contributionInPlanks, + address = address, + mode = if (toCalculateFee) Mode.FEE else Mode.SUBMIT + ) + } + + val submitter = crowdloan.parachainMetadata?.customFlow?.let { + customContributeManager.getFactoryOrNull(it)?.submitter + } + + val submission: OnChainSubmission = { + contribute(crowdloan.parachainId, contributionInPlanks, privateSignature) + + submitter?.let { + val injection = if (toCalculateFee) submitter::injectFeeCalculation else submitter::injectOnChainSubmission + + injection(crowdloan, customizationPayload, bonusPayload, contribution, this) + } + } + + finalAction(submission, chain, account) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/CrowdloanExt.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/CrowdloanExt.kt new file mode 100644 index 0000000..0532fe2 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/CrowdloanExt.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding.FundInfo +import io.novafoundation.nova.feature_crowdloan_api.data.repository.LeasePeriodToBlocksConverter +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import java.math.BigInteger +import java.math.MathContext + +fun mapFundInfoToCrowdloan( + fundInfo: FundInfo, + parachainMetadata: ParachainMetadata?, + parachainId: BigInteger, + currentBlockNumber: BlockNumber, + expectedBlockTimeInMillis: BigInteger, + leasePeriodToBlocksConverter: LeasePeriodToBlocksConverter, + contribution: Contribution?, + hasWonAuction: Boolean, +): Crowdloan { + val leasePeriodInMillis = leasePeriodInMillis(leasePeriodToBlocksConverter, currentBlockNumber, fundInfo.lastSlot, expectedBlockTimeInMillis) + + val state = if (isCrowdloanActive(fundInfo, currentBlockNumber, leasePeriodToBlocksConverter, hasWonAuction)) { + val remainingTime = expectedRemainingTime(currentBlockNumber, fundInfo.end, expectedBlockTimeInMillis) + + Crowdloan.State.Active(remainingTime) + } else { + Crowdloan.State.Finished + } + + return Crowdloan( + parachainMetadata = parachainMetadata, + raisedFraction = fundInfo.raised.toBigDecimal().divide(fundInfo.cap.toBigDecimal(), MathContext.DECIMAL32), + parachainId = parachainId, + leasePeriodInMillis = leasePeriodInMillis, + leasedUntilInMillis = System.currentTimeMillis() + leasePeriodInMillis, + state = state, + fundInfo = fundInfo, + myContribution = contribution + ) +} + +private fun isCrowdloanActive( + fundInfo: FundInfo, + currentBlockNumber: BigInteger, + leasePeriodToBlocksConverter: LeasePeriodToBlocksConverter, + hasWonAuction: Boolean, +): Boolean { + return currentBlockNumber < fundInfo.end && // crowdloan is not ended + // first slot is not yet passed + leasePeriodToBlocksConverter.leaseIndexFromBlock(currentBlockNumber) <= fundInfo.firstSlot && + // cap is not reached + fundInfo.raised < fundInfo.cap && + // crowdloan considered closed if parachain already won auction + !hasWonAuction +} + +fun leasePeriodInMillis( + leasePeriodToBlocksConverter: LeasePeriodToBlocksConverter, + currentBlockNumber: BigInteger, + endingLeasePeriod: BigInteger, + expectedBlockTimeInMillis: BigInteger +): Long { + val unlockedAtPeriod = endingLeasePeriod + BigInteger.ONE // next period after end one + val unlockedAtBlock = leasePeriodToBlocksConverter.startBlockFor(unlockedAtPeriod) + + return expectedRemainingTime( + currentBlockNumber, + unlockedAtBlock, + expectedBlockTimeInMillis + ) +} + +private fun expectedRemainingTime( + currentBlock: BlockNumber, + targetBlock: BlockNumber, + expectedBlockTimeInMillis: BigInteger, +): Long { + val blockDifference = targetBlock - currentBlock + val expectedTimeDifference = blockDifference * expectedBlockTimeInMillis + + return expectedTimeDifference.toLong() +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/PrivateCrowdloanSignatureProvider.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/PrivateCrowdloanSignatureProvider.kt new file mode 100644 index 0000000..a7096fd --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/PrivateCrowdloanSignatureProvider.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom + +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import java.math.BigInteger + +interface PrivateCrowdloanSignatureProvider { + + enum class Mode { + FEE, SUBMIT + } + + suspend fun provideSignature( + chainMetadata: ParachainMetadata, + previousContribution: BigInteger, + newContribution: BigInteger, + address: String, + mode: Mode, + ): Any? +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/acala/AcalaContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/acala/AcalaContributeInteractor.kt new file mode 100644 index 0000000..9eef2f1 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/acala/AcalaContributeInteractor.kt @@ -0,0 +1,159 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala + +import io.novafoundation.nova.common.base.BaseException +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.utils.asHexString +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala.AcalaApi +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala.AcalaDirectContributeRequest +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.acala.AcalaLiquidContributeRequest +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.nativeTransfer +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.extrinsic.systemRemarkWithEvent +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.SingleAssetSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAndAsset +import io.novafoundation.nova.runtime.state.chainAsset +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.fromUtf8 +import java.math.BigDecimal + +class AcalaContributeInteractor( + private val acalaApi: AcalaApi, + private val httpExceptionHandler: HttpExceptionHandler, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val selectedAssetState: SingleAssetSharedState, + private val signerProvider: SignerProvider, +) { + + suspend fun registerContributionOffChain( + amount: BigDecimal, + contributionType: ContributionType, + referralCode: String?, + ): Result = runCatching { + httpExceptionHandler.wrap { + val selectedMetaAccount = accountRepository.getSelectedMetaAccount() + val signer = signerProvider.rootSignerFor(selectedMetaAccount) + + val (chain, chainAsset) = selectedAssetState.chainAndAsset() + + val accountIdInCurrentChain = selectedMetaAccount.accountIdIn(chain)!! + // api requires polkadot address even in rococo testnet + val addressInPolkadot = chainRegistry.getChain(ChainGeneses.POLKADOT).addressOf(accountIdInCurrentChain) + val amountInPlanks = chainAsset.planksFromAmount(amount) + + val statement = getStatement(chain).statement + + val signerPayload = SignerPayloadRaw.fromUtf8(statement, accountIdInCurrentChain) + + when (contributionType) { + ContributionType.DIRECT -> { + val request = AcalaDirectContributeRequest( + address = addressInPolkadot, + amount = amountInPlanks, + referral = referralCode, + signature = signer.signRaw(signerPayload).asHexString() + ) + + acalaApi.directContribute( + baseUrl = AcalaApi.getBaseUrl(chain), + authHeader = AcalaApi.getAuthHeader(chain), + body = request + ) + } + + ContributionType.LIQUID -> { + val request = AcalaLiquidContributeRequest( + address = addressInPolkadot, + amount = amountInPlanks, + referral = referralCode + ) + + acalaApi.liquidContribute( + baseUrl = AcalaApi.getBaseUrl(chain), + authHeader = AcalaApi.getAuthHeader(chain), + body = request + ) + } + } + } + } + + suspend fun isReferralValid(referralCode: String) = try { + val chain = selectedAssetState.chain() + + httpExceptionHandler.wrap { + acalaApi.isReferralValid( + baseUrl = AcalaApi.getBaseUrl(chain), + authHeader = AcalaApi.getAuthHeader(chain), + referral = referralCode + ).result + } + } catch (e: BaseException) { + if (e.kind == BaseException.Kind.HTTP) { + false // acala api return an error http code for some invalid codes, so catch it here + } else { + throw e + } + } + + suspend fun injectOnChainSubmission( + contributionType: ContributionType, + referralCode: String?, + amount: BigDecimal, + extrinsicBuilder: ExtrinsicBuilder, + ) = with(extrinsicBuilder) { + if (contributionType == ContributionType.LIQUID) { + resetCalls() + + val (chain, chainAsset) = selectedAssetState.chainAndAsset() + val amountInPlanks = chainAsset.planksFromAmount(amount) + + val statement = httpExceptionHandler.wrap { getStatement(chain) } + val proxyAccountId = chain.accountIdOf(statement.proxyAddress) + + nativeTransfer(proxyAccountId, amountInPlanks) + systemRemarkWithEvent(statement.statement) + referralCode?.let { systemRemarkWithEvent(referralRemark(it)) } + } + } + + suspend fun injectFeeCalculation( + contributionType: ContributionType, + referralCode: String?, + amount: BigDecimal, + extrinsicBuilder: ExtrinsicBuilder, + ) = with(extrinsicBuilder) { + if (contributionType == ContributionType.LIQUID) { + resetCalls() + + val chainAsset = selectedAssetState.chainAsset() + val amountInPlanks = chainAsset.planksFromAmount(amount) + + val fakeDestination = ByteArray(32) + nativeTransfer(accountId = fakeDestination, amount = amountInPlanks) + + val fakeAgreementRemark = ByteArray(185) // acala agreement is 185 bytes + systemRemarkWithEvent(fakeAgreementRemark) + + referralCode?.let { systemRemarkWithEvent(referralRemark(referralCode)) } + } + } + + private suspend fun getStatement( + chain: Chain, + ) = acalaApi.getStatement( + baseUrl = AcalaApi.getBaseUrl(chain), + authHeader = AcalaApi.getAuthHeader(chain) + ) + + private fun referralRemark(referralCode: String) = "referrer:$referralCode" +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/acala/ContributionType.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/acala/ContributionType.kt new file mode 100644 index 0000000..75bd73e --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/acala/ContributionType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala + +enum class ContributionType { + DIRECT, LIQUID +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/astar/AstarContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/astar/AstarContributeInteractor.kt new file mode 100644 index 0000000..bddcb82 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/astar/AstarContributeInteractor.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.astar + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.feature_crowdloan_impl.data.network.blockhain.extrinsic.addMemo +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.isValidAddress +import io.novafoundation.nova.runtime.state.SingleAssetSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder + +class AstarContributeInteractor( + private val selectedAssetSharedState: SingleAssetSharedState, +) { + + suspend fun isReferralCodeValid(code: String): Boolean { + val currentChain = selectedAssetSharedState.chain() + + return currentChain.isValidAddress(code) + } + + suspend fun submitOnChain( + paraId: ParaId, + referralCode: String, + extrinsicBuilder: ExtrinsicBuilder, + ) { + val currentChain = selectedAssetSharedState.chain() + val referralAccountId = currentChain.accountIdOf(referralCode) + + extrinsicBuilder.addMemo(paraId, referralAccountId) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/bifrost/BifrostContributeInteractor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/bifrost/BifrostContributeInteractor.kt new file mode 100644 index 0000000..98d80be --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/bifrost/BifrostContributeInteractor.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.bifrost + +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.bifrost.BifrostApi +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.bifrost.getAccountByReferralCode +import io.novafoundation.nova.feature_crowdloan_impl.data.network.blockhain.extrinsic.addMemo +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder + +class BifrostContributeInteractor( + val novaReferralCode: String, + private val bifrostApi: BifrostApi, + private val httpExceptionHandler: HttpExceptionHandler, +) { + + suspend fun isCodeValid(code: String): Boolean { + val response = httpExceptionHandler.wrap { bifrostApi.getAccountByReferralCode(code) } + + return response.data.getAccountByInvitationCode.account.isNullOrEmpty().not() + } + + fun submitOnChain( + paraId: ParaId, + referralCode: String, + extrinsicBuilder: ExtrinsicBuilder, + ) = extrinsicBuilder.addMemo(paraId, referralCode) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/CrossChainRewardDestination.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/CrossChainRewardDestination.kt new file mode 100644 index 0000000..a2ef3de --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/CrossChainRewardDestination.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class CrossChainRewardDestination( + val addressInDestination: String, + val destination: Chain, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/MoonbeamCrowdloanInteractor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/MoonbeamCrowdloanInteractor.kt new file mode 100644 index 0000000..f27d383 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/MoonbeamCrowdloanInteractor.kt @@ -0,0 +1,171 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam + +import android.util.Log +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.asHexString +import io.novafoundation.nova.common.utils.sha256 +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitStatus +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.domain.model.cryptoTypeIn +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.AgreeRemarkRequest +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.MoonbeamApi +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.VerifyRemarkRequest +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.agreeRemark +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.checkRemark +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.moonbeamChainId +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.verifyRemark +import io.novafoundation.nova.feature_crowdloan_impl.data.network.blockhain.extrinsic.addMemo +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.extrinsic.systemRemark +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.state.SingleAssetSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.fromUtf8 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import retrofit2.HttpException + +class VerificationError : Exception() + +private val SUPPORTED_CRYPTO_TYPES = setOf(CryptoType.SR25519, CryptoType.ED25519) + +class MoonbeamCrowdloanInteractor( + private val accountRepository: AccountRepository, + private val extrinsicService: ExtrinsicService, + private val moonbeamApi: MoonbeamApi, + private val selectedChainAssetState: SingleAssetSharedState, + private val chainRegistry: ChainRegistry, + private val httpExceptionHandler: HttpExceptionHandler, + private val signerProvider: SignerProvider, +) { + + fun getTermsLink() = "https://github.com/moonbeam-foundation/crowdloan-self-attestation/blob/main/moonbeam/README.md" + + suspend fun getMoonbeamRewardDestination(parachainMetadata: ParachainMetadata): CrossChainRewardDestination { + val currentAccount = accountRepository.getSelectedMetaAccount() + val moonbeamChain = chainRegistry.getChain(parachainMetadata.moonbeamChainId()) + + return CrossChainRewardDestination( + addressInDestination = currentAccount.addressIn(moonbeamChain)!!, + destination = moonbeamChain + ) + } + + suspend fun additionalSubmission( + crowdloan: Crowdloan, + extrinsicBuilder: ExtrinsicBuilder, + ) { + val rewardDestination = getMoonbeamRewardDestination(crowdloan.parachainMetadata!!) + + extrinsicBuilder.addMemo( + parachainId = crowdloan.parachainId, + memo = rewardDestination.addressInDestination.fromHex() + ) + } + + suspend fun flowStatus(parachainMetadata: ParachainMetadata): Result = withContext(Dispatchers.Default) { + runCatching { + val metaAccount = accountRepository.getSelectedMetaAccount() + + val moonbeamChainId = parachainMetadata.moonbeamChainId() + val moonbeamChain = chainRegistry.getChain(moonbeamChainId) + + val currentChain = selectedChainAssetState.chain() + val currentAddress = metaAccount.addressIn(currentChain)!! + + when { + !metaAccount.hasAccountIn(moonbeamChain) -> MoonbeamFlowStatus.NeedsChainAccount( + chainId = moonbeamChainId, + metaId = metaAccount.id + ) + + metaAccount.cryptoTypeIn(currentChain) !in SUPPORTED_CRYPTO_TYPES -> MoonbeamFlowStatus.UnsupportedAccountEncryption + + else -> when (checkRemark(parachainMetadata, currentAddress)) { + null -> MoonbeamFlowStatus.RegionNotSupported + true -> MoonbeamFlowStatus.Completed + false -> MoonbeamFlowStatus.ReadyToComplete + } + } + } + } + + suspend fun calculateTermsFee(): Fee = withContext(Dispatchers.Default) { + val chain = selectedChainAssetState.chain() + + extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + systemRemark(fakeRemark()) + } + } + + suspend fun submitAgreement(parachainMetadata: ParachainMetadata): Result> = withContext(Dispatchers.Default) { + runCatching { + val chain = selectedChainAssetState.chain() + val metaAccount = accountRepository.getSelectedMetaAccount() + + val currentAddress = metaAccount.addressIn(chain)!! + val accountId = metaAccount.accountIdIn(chain)!! + + val legalText = httpExceptionHandler.wrap { moonbeamApi.getLegalText() } + val legalHash = legalText.encodeToByteArray().sha256().toHexString(withPrefix = false) + + val signer = signerProvider.rootSignerFor(metaAccount) + val signerPayload = SignerPayloadRaw.fromUtf8(legalHash, accountId) + + val signedHash = signer.signRaw(signerPayload).asHexString() + + val agreeRemarkRequest = AgreeRemarkRequest(currentAddress, signedHash) + val remark = httpExceptionHandler.wrap { moonbeamApi.agreeRemark(parachainMetadata, agreeRemarkRequest) }.remark + + val result = extrinsicService.submitAndWatchExtrinsic(chain, TransactionOrigin.SelectedWallet) { + systemRemark(remark.encodeToByteArray()) + } + .getOrThrow() + .awaitStatus() + + Log.d(this@MoonbeamCrowdloanInteractor.LOG_TAG, "Finalized ${result.status.extrinsicHash} in block ${result.status.blockHash}") + + val verificationRequest = VerifyRemarkRequest( + address = currentAddress, + extrinsicHash = result.status.extrinsicHash, + blockHash = result.status.blockHash + ) + val verificationResponse = httpExceptionHandler.wrap { moonbeamApi.verifyRemark(parachainMetadata, verificationRequest) } + + if (!verificationResponse.verified) throw VerificationError() + + result + } + } + + private fun fakeRemark() = ByteArray(32) + + /** + * @return null if Geo-fenced or application unavailable. True if user already agreed with terms. False otherwise + */ + private suspend fun checkRemark(parachainMetadata: ParachainMetadata, address: String): Boolean? = try { + moonbeamApi.checkRemark(parachainMetadata, address).verified + } catch (e: HttpException) { + if (e.code() == 403) { // Moonbeam answers with 403 in case geo-fenced or application unavailable + null + } else { + throw httpExceptionHandler.transformException(e) + } + } catch (e: Exception) { + throw httpExceptionHandler.transformException(e) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/MoonbeamFlowStatus.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/MoonbeamFlowStatus.kt new file mode 100644 index 0000000..4874df8 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/MoonbeamFlowStatus.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +sealed class MoonbeamFlowStatus { + + object RegionNotSupported : MoonbeamFlowStatus() + + object Completed : MoonbeamFlowStatus() + + class NeedsChainAccount(val chainId: ChainId, val metaId: Long) : MoonbeamFlowStatus() + + object UnsupportedAccountEncryption : MoonbeamFlowStatus() + + object ReadyToComplete : MoonbeamFlowStatus() +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/MoonbeamPrivateSignatureProvider.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/MoonbeamPrivateSignatureProvider.kt new file mode 100644 index 0000000..c88ec87 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/custom/moonbeam/MoonbeamPrivateSignatureProvider.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam + +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.MakeSignatureRequest +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.MoonbeamApi +import io.novafoundation.nova.feature_crowdloan_impl.data.network.api.moonbeam.makeSignature +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.PrivateCrowdloanSignatureProvider +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.PrivateCrowdloanSignatureProvider.Mode +import io.novasama.substrate_sdk_android.encrypt.EncryptionType +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.MultiSignature +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.prepareForEncoding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class MoonbeamPrivateSignatureProvider( + private val moonbeamApi: MoonbeamApi, + private val httpExceptionHandler: HttpExceptionHandler, +) : PrivateCrowdloanSignatureProvider { + + override suspend fun provideSignature( + chainMetadata: ParachainMetadata, + previousContribution: BigInteger, + newContribution: BigInteger, + address: String, + mode: Mode, + ): Any = withContext(Dispatchers.Default) { + when (mode) { + Mode.FEE -> sr25519SignatureOf(ByteArray(64)) // sr25519 is 65 bytes + Mode.SUBMIT -> { + val request = MakeSignatureRequest(address, previousContribution.toString(), newContribution.toString()) + val response = httpExceptionHandler.wrap { moonbeamApi.makeSignature(chainMetadata, request) } + + sr25519SignatureOf(response.signature.fromHex()) + } + } + } + + private fun sr25519SignatureOf(bytes: ByteArray): Any { + return MultiSignature(EncryptionType.SR25519, bytes).prepareForEncoding() + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/BonusAppliedValidation.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/BonusAppliedValidation.kt new file mode 100644 index 0000000..bef8d95 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/BonusAppliedValidation.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations + +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager + +class BonusAppliedValidation( + private val customContributeManager: CustomContributeManager, +) : ContributeValidation { + + override suspend fun validate(value: ContributeValidationPayload): ValidationStatus { + val factory = value.crowdloan.parachainMetadata?.customFlow?.let { + customContributeManager.getFactoryOrNull(it) + } + + val shouldHaveBonusPayload = factory?.extraBonusFlow != null + + return if (shouldHaveBonusPayload && value.bonusPayload == null) { + ValidationStatus.NotValid(DefaultFailureLevel.WARNING, ContributeValidationFailure.BonusNotApplied) + } else { + ValidationStatus.Valid() + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/CapExceededValidation.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/CapExceededValidation.kt new file mode 100644 index 0000000..439c993 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/CapExceededValidation.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations + +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks + +class CapExceededValidation : ContributeValidation { + + override suspend fun validate(value: ContributeValidationPayload): ValidationStatus { + val token = value.asset.token + + return with(value.crowdloan.fundInfo) { + val raisedAmount = token.amountFromPlanks(raised) + val capAmount = token.amountFromPlanks(cap) + + when { + raisedAmount >= capAmount -> ValidationStatus.NotValid(DefaultFailureLevel.ERROR, ContributeValidationFailure.CapExceeded.FromRaised) + raisedAmount + value.contributionAmount > capAmount -> { + val maxAllowedContribution = capAmount - raisedAmount + + val reason = ContributeValidationFailure.CapExceeded.FromAmount(maxAllowedContribution, token.configuration) + + ValidationStatus.NotValid(DefaultFailureLevel.ERROR, reason) + } + else -> ValidationStatus.Valid() + } + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/ContributeValidationFailure.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/ContributeValidationFailure.kt new file mode 100644 index 0000000..4491866 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/ContributeValidationFailure.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class ContributeValidationFailure { + + class LessThanMinContribution( + val minContribution: BigDecimal, + val chainAsset: Chain.Asset + ) : ContributeValidationFailure() + + sealed class CapExceeded : ContributeValidationFailure() { + + class FromAmount( + val maxAllowedContribution: BigDecimal, + val chainAsset: Chain.Asset + ) : CapExceeded() + + object FromRaised : CapExceeded() + } + + object CrowdloanEnded : ContributeValidationFailure() + + object CannotPayFees : ContributeValidationFailure() + + object ExistentialDepositCrossed : ContributeValidationFailure() + + object BonusNotApplied : ContributeValidationFailure() + + object PrivateCrowdloanNotSupported : ContributeValidationFailure() +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/ContributeValidationPayload.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/ContributeValidationPayload.kt new file mode 100644 index 0000000..0fe5d2e --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/ContributeValidationPayload.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations + +import android.os.Parcelable +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal + +class ContributeValidationPayload( + val crowdloan: Crowdloan, + val customizationPayload: Parcelable?, + val asset: Asset, + val fee: Fee, + val bonusPayload: BonusPayload?, + val contributionAmount: BigDecimal, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/CrowdloanNotEndedValidation.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/CrowdloanNotEndedValidation.kt new file mode 100644 index 0000000..b167cf2 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/CrowdloanNotEndedValidation.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations + +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +class CrowdloanNotEndedValidation( + private val chainStateRepository: ChainStateRepository, + private val crowdloanRepository: CrowdloanRepository +) : ContributeValidation { + + override suspend fun validate(value: ContributeValidationPayload): ValidationStatus { + val chainId = value.asset.token.configuration.chainId + val currentBlock = chainStateRepository.currentBlock(chainId) + + val leasePeriodToBlocksConverter = crowdloanRepository.leasePeriodToBlocksConverter(chainId) + + val currentLeaseIndex = leasePeriodToBlocksConverter.leaseIndexFromBlock(currentBlock) + + return when { + currentBlock >= value.crowdloan.fundInfo.end -> crowdloanEndedFailure() + currentLeaseIndex > value.crowdloan.fundInfo.firstSlot -> crowdloanEndedFailure() + else -> ValidationStatus.Valid() + } + } + + private fun crowdloanEndedFailure(): ValidationStatus.NotValid = + ValidationStatus.NotValid(DefaultFailureLevel.ERROR, ContributeValidationFailure.CrowdloanEnded) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/Definitions.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/Definitions.kt new file mode 100644 index 0000000..b6c9edc --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/Definitions.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.ExistentialDepositValidation + +typealias ContributeValidation = Validation + +typealias ContributeEnoughToPayFeesValidation = EnoughAmountToTransferValidation +typealias ContributeExistentialDepositValidation = ExistentialDepositValidation diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/MinContributionValidation.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/MinContributionValidation.kt new file mode 100644 index 0000000..a526d68 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/MinContributionValidation.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import java.math.BigInteger + +class DefaultMinContributionValidation( + private val crowdloanRepository: CrowdloanRepository, +) : MinContributionValidation() { + + override suspend fun minContribution(payload: ContributeValidationPayload): BigInteger { + val chainAsset = payload.asset.token.configuration + + return crowdloanRepository.minContribution(chainAsset.chainId) + } +} + +abstract class MinContributionValidation : ContributeValidation { + + abstract suspend fun minContribution(payload: ContributeValidationPayload): BigInteger + + override suspend fun validate(value: ContributeValidationPayload): ValidationStatus { + val chainAsset = value.asset.token.configuration + + val minContributionInPlanks = minContribution(value) + val minContribution = chainAsset.amountFromPlanks(minContributionInPlanks) + + return validOrError(value.contributionAmount >= minContribution) { + ContributeValidationFailure.LessThanMinContribution(minContribution, chainAsset) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/PublicCrowdloanValidation.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/PublicCrowdloanValidation.kt new file mode 100644 index 0000000..664588f --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/PublicCrowdloanValidation.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.supportsPrivateCrowdloans + +class PublicCrowdloanValidation( + private val customContributeManager: CustomContributeManager, +) : ContributeValidation { + + override suspend fun validate(value: ContributeValidationPayload): ValidationStatus { + val isPublic = value.crowdloan.fundInfo.verifier == null + + val flowType = value.crowdloan.parachainMetadata?.customFlow + val supportsPrivate = flowType?.let(customContributeManager::supportsPrivateCrowdloans) ?: false + + return validOrError(isPublic || supportsPrivate) { + ContributeValidationFailure.PrivateCrowdloanNotSupported + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/acala/AcalaEnoughBalanceValidation.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/acala/AcalaEnoughBalanceValidation.kt new file mode 100644 index 0000000..b26f7bd --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/acala/AcalaEnoughBalanceValidation.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.acala + +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala.ContributionType +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidationPayload +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.MinContributionValidation +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.AcalaCustomizationPayload +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import java.math.BigInteger + +private val ACALA_MIN_CONTRIBUTION = 1.toBigDecimal() + +class AcalaMinContributionValidation( + private val fallback: MinContributionValidation, +) : MinContributionValidation() { + + override suspend fun minContribution(payload: ContributeValidationPayload): BigInteger { + val customization = payload.customizationPayload + require(customization is AcalaCustomizationPayload) + + return when (customization.contributionType) { + ContributionType.DIRECT -> fallback.minContribution(payload) + ContributionType.LIQUID -> { + val asset = payload.asset.token.configuration + + return asset.planksFromAmount(ACALA_MIN_CONTRIBUTION) + } + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/moonbeam/MoonbeamTermsPayload.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/moonbeam/MoonbeamTermsPayload.kt new file mode 100644 index 0000000..d96fdd2 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/moonbeam/MoonbeamTermsPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee + +class MoonbeamTermsPayload( + val fee: Fee, + val asset: Asset +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/moonbeam/MoonbeamTermsValidationFailure.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/moonbeam/MoonbeamTermsValidationFailure.kt new file mode 100644 index 0000000..cc78677 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/moonbeam/MoonbeamTermsValidationFailure.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam + +enum class MoonbeamTermsValidationFailure { + CANNOT_PAY_FEES +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/moonbeam/MoonbeamTermsValidations.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/moonbeam/MoonbeamTermsValidations.kt new file mode 100644 index 0000000..8e72bbf --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contribute/validations/custom/moonbeam/MoonbeamTermsValidations.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidation + +typealias MoonbeamTermsValidationSystem = ValidationSystem +typealias MoonbeamTermsValidation = Validation + +typealias MoonbeamTermsFeeValidation = EnoughAmountToTransferValidation diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contributions/RealContributionsInteractor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contributions/RealContributionsInteractor.kt new file mode 100644 index 0000000..51d097b --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contributions/RealContributionsInteractor.kt @@ -0,0 +1,120 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contributions + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_crowdloan_api.data.network.updater.ContributionsUpdateSystemFactory +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionMetadata +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionWithMetadata +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsWithTotalAmount +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.claimStatusOf +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.blockDurationEstimatorFlow +import io.novafoundation.nova.runtime.state.SingleAssetSharedState +import io.novafoundation.nova.runtime.state.selectedChainFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class RealContributionsInteractor( + private val crowdloanRepository: CrowdloanRepository, + private val accountRepository: AccountRepository, + private val selectedAssetCrowdloanState: SingleAssetSharedState, + private val chainStateRepository: ChainStateRepository, + private val contributionsRepository: ContributionsRepository, + private val contributionsUpdateSystemFactory: ContributionsUpdateSystemFactory, + private val chainRegistry: ChainRegistry, +) : ContributionsInteractor { + + override fun runUpdate(): Flow { + return contributionsUpdateSystemFactory.create() + .start() + } + + override fun observeSelectedChainContributionsWithMetadata(): Flow> { + val metaAccountFlow = accountRepository.selectedMetaAccountFlow() + val chainFlow = selectedAssetCrowdloanState.selectedChainFlow() + return combineToPair(metaAccountFlow, chainFlow) + .flatMapLatest { (metaAccount, chain) -> + observeChainContributionsWithMetadata(metaAccount, chain, chain.utilityAsset) + } + } + + override fun observeChainContributions( + metaAccount: MetaAccount, + chainId: ChainId, + assetId: ChainAssetId + ): Flow> { + return flow { + val (chain, asset) = chainRegistry.chainWithAsset(chainId, assetId) + + emitAll(contributionsRepository.observeContributions(metaAccount, chain, asset)) + }.map { contributions -> + contributions.totalContributions { it.amountInPlanks } + } + } + + private suspend fun getParachainMetadata(chain: Chain): Map { + return runCatching { + crowdloanRepository.getParachainMetadata(chain) + }.getOrDefault(emptyMap()) + } + + private suspend fun observeChainContributionsWithMetadata( + metaAccount: MetaAccount, + chain: Chain, + asset: Chain.Asset + ): Flow> { + val parachainMetadatas = getParachainMetadata(chain) + + return combine( + chainStateRepository.blockDurationEstimatorFlow(chain.timelineChainIdOrSelf()), + contributionsRepository.observeContributions(metaAccount, chain, asset) + ) { blockDurationEstimator, contributions -> + contributions.map { contribution -> + val parachainMetadata = parachainMetadatas[contribution.paraId] + + val claimStatus = blockDurationEstimator.claimStatusOf(contribution) + + ContributionWithMetadata( + contribution = contribution, + metadata = ContributionMetadata( + claimStatus = claimStatus, + parachainMetadata = parachainMetadata, + ) + ) + } + .sortedWith( + compareBy { it.contribution.unlockBlock } + .thenBy { it.contribution.paraId } + ) + .totalContributions { it.contribution.amountInPlanks } + } + } + + private fun List.totalContributions(amount: (T) -> BigInteger): ContributionsWithTotalAmount { + return ContributionsWithTotalAmount( + totalContributed = sumByBigInteger(amount), + contributions = this + ) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contributions/RealContributionsRepository.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contributions/RealContributionsRepository.kt new file mode 100644 index 0000000..0e45874 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/contributions/RealContributionsRepository.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.contributions + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.core_db.dao.ContributionDao +import io.novafoundation.nova.core_db.dao.DeleteAssetContributionsParams +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.data.source.contribution.ExternalContributionSource +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution.Companion.DIRECT_SOURCE_ID +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.mapContributionFromLocal +import io.novafoundation.nova.feature_crowdloan_impl.data.repository.contributions.network.ahOps +import io.novafoundation.nova.feature_crowdloan_impl.data.repository.contributions.network.rcCrowdloanContribution +import io.novafoundation.nova.feature_crowdloan_impl.data.repository.contributions.network.rcCrowdloanReserve +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.queryCatching +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +class RealContributionsRepository( + private val externalContributionsSources: List, + private val chainRegistry: ChainRegistry, + private val remoteStorage: StorageDataSource, + private val contributionDao: ContributionDao +) : ContributionsRepository { + + override fun observeContributions(metaAccount: MetaAccount): Flow> { + val contributionsFlow = contributionDao.observeContributions(metaAccount.id) + val chainsFlow = chainRegistry.chainsById + return combine(contributionsFlow, chainsFlow) { contributions, chains -> + contributions.map { + mapContributionFromLocal(it, chains.getValue(it.chainId)) + } + } + } + + override fun observeContributions(metaAccount: MetaAccount, chain: Chain, asset: Chain.Asset): Flow> { + return contributionDao.observeContributions(metaAccount.id, chain.id, asset.id) + .mapList { mapContributionFromLocal(it, chain) } + } + + override fun loadContributionsGraduallyFlow( + chain: Chain, + accountId: ByteArray, + ): Flow>>> = flow { + if (!chain.hasCrowdloans) { + return@flow + } + + val directContributions = getDirectContributions(chain, chain.utilityAsset, accountId) + .onFailure { Log.e("RealContributionsRepository", "Failed to fetch direct contributions on ${chain.name}", it) } + emit(DIRECT_SOURCE_ID to directContributions) + } + + override suspend fun getDirectContributions( + chain: Chain, + asset: Chain.Asset, + accountId: ByteArray, + ): Result> { + return withContext(Dispatchers.Default) { + remoteStorage.queryCatching(chain.id) { + val reserves = metadata.ahOps.rcCrowdloanReserve.keys() + val contributionKeys = reserves.map { (unlockBlock, paraId, _) -> Triple(unlockBlock, paraId, accountId.intoKey()) } + + val contributionEntries = metadata.ahOps.rcCrowdloanContribution.entries(contributionKeys) + + contributionEntries.map { (key, balance) -> + val (unlockBlock, paraId) = key + Contribution( + chain = chain, + asset = asset, + amountInPlanks = balance, + paraId = paraId, + sourceId = DIRECT_SOURCE_ID, + unlockBlock = unlockBlock, + leaseDepositor = reserves.getLeaseDepositor(paraId) + ) + } + } + } + } + + private fun LeaseReserves.getLeaseDepositor(paraId: ParaId): AccountIdKey { + return first { it.second == paraId }.third + } + + override suspend fun deleteContributions(assetIds: List) { + val params = assetIds.map { DeleteAssetContributionsParams(it.chainId, it.assetId) } + + contributionDao.deleteAssetContributions(params) + } +} +private typealias LeaseReserves = Set> diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/Crowdloan.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/Crowdloan.kt new file mode 100644 index 0000000..7aabd8d --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/Crowdloan.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.main + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.feature_crowdloan_api.data.network.blockhain.binding.FundInfo +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import java.math.BigDecimal +import kotlin.reflect.KClass + +class Crowdloan( + val parachainMetadata: ParachainMetadata?, + val parachainId: ParaId, + val raisedFraction: BigDecimal, + val state: State, + val leasePeriodInMillis: Long, + val leasedUntilInMillis: Long, + val fundInfo: FundInfo, + val myContribution: Contribution?, +) { + + sealed class State { + + companion object { + + val STATE_CLASS_COMPARATOR = Comparator> { first, _ -> + when (first) { + Active::class -> -1 + Finished::class -> 1 + else -> 0 + } + } + } + + object Finished : State() + + class Active(val remainingTimeInMillis: Long) : State() + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/CrowdloanInteractor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/CrowdloanInteractor.kt new file mode 100644 index 0000000..dc7e399 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/CrowdloanInteractor.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.main + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ContributionsRepository +import io.novafoundation.nova.feature_crowdloan_api.data.repository.CrowdloanRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlin.reflect.KClass + +typealias GroupedCrowdloans = GroupedList, Crowdloan> + +class CrowdloanInteractor( + private val crowdloanRepository: CrowdloanRepository, + private val chainStateRepository: ChainStateRepository, + private val contributionsRepository: ContributionsRepository +) { + + fun groupedCrowdloansFlow(chain: Chain, account: MetaAccount): Flow { + return crowdloansFlow(chain, account) + .map { groupCrowdloans(it) } + } + + private fun crowdloansFlow(chain: Chain, account: MetaAccount): Flow> { + return flow { + val accountId = account.accountIdIn(chain) + + emitAll(crowdloanListFlow(chain, accountId)) + } + } + + private fun groupCrowdloans(crowdloans: List): GroupedCrowdloans { + return crowdloans.groupBy { it.state::class } + .toSortedMap(Crowdloan.State.STATE_CLASS_COMPARATOR) + } + + private suspend fun crowdloanListFlow( + chain: Chain, + contributor: AccountId?, + ): Flow> { + // Crowdloans are no longer accessible and are deprecated. We will remove entire crowdloan feature soon + return flowOf(emptyList()) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/statefull/StatefulCrowdloanMixin.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/statefull/StatefulCrowdloanMixin.kt new file mode 100644 index 0000000..7c829a1 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/statefull/StatefulCrowdloanMixin.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.main.statefull + +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.GroupedCrowdloans +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface StatefulCrowdloanMixin { + + interface Factory { + + fun create(scope: CoroutineScope): StatefulCrowdloanMixin + } + + class ContributionsInfo( + val contributionsCount: Int, + val isUserHasContributions: Boolean, + val totalContributed: AmountModel + ) + + val selectedAccount: Flow + val selectedChain: Flow + + val contributionsInfoFlow: Flow> + + val groupedCrowdloansFlow: Flow> +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/statefull/StatefulCrowdloanProvider.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/statefull/StatefulCrowdloanProvider.kt new file mode 100644 index 0000000..79d1028 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/statefull/StatefulCrowdloanProvider.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.main.statefull + +import io.novafoundation.nova.common.presentation.mapLoading +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.CrowdloanInteractor +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.getCurrentAsset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.state.SingleAssetSharedState +import io.novafoundation.nova.runtime.state.selectedChainFlow +import kotlinx.coroutines.CoroutineScope + +class StatefulCrowdloanProviderFactory( + private val singleAssetSharedState: SingleAssetSharedState, + private val crowdloanInteractor: CrowdloanInteractor, + private val contributionsInteractor: ContributionsInteractor, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val amountFormatter: AmountFormatter, + private val assetUseCase: AssetUseCase, +) : StatefulCrowdloanMixin.Factory { + + override fun create(scope: CoroutineScope): StatefulCrowdloanMixin { + return StatefulCrowdloanProvider( + singleAssetSharedState = singleAssetSharedState, + crowdloanInteractor = crowdloanInteractor, + contributionsInteractor = contributionsInteractor, + selectedAccountUseCase = selectedAccountUseCase, + assetUseCase = assetUseCase, + amountFormatter = amountFormatter, + coroutineScope = scope + ) + } +} + +class StatefulCrowdloanProvider( + singleAssetSharedState: SingleAssetSharedState, + selectedAccountUseCase: SelectedAccountUseCase, + private val crowdloanInteractor: CrowdloanInteractor, + private val contributionsInteractor: ContributionsInteractor, + private val assetUseCase: AssetUseCase, + coroutineScope: CoroutineScope, + private val amountFormatter: AmountFormatter, +) : StatefulCrowdloanMixin, + CoroutineScope by coroutineScope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + override val selectedChain = singleAssetSharedState.selectedChainFlow() + .shareInBackground() + + override val selectedAccount = selectedAccountUseCase.selectedMetaAccountFlow() + .shareInBackground() + + private val chainAndAccount = combineToPair(selectedChain, selectedAccount) + .shareInBackground() + + override val groupedCrowdloansFlow = chainAndAccount.withLoading { (chain, account) -> + crowdloanInteractor.groupedCrowdloansFlow(chain, account) + } + .shareInBackground() + + override val contributionsInfoFlow = chainAndAccount.withLoading { (chain, account) -> + contributionsInteractor.observeChainContributions(account, chain.id, chain.utilityAsset.id) + } + .mapLoading { + val amountModel = amountFormatter.formatAmountToAmountModel( + it.totalContributed, + assetUseCase.getCurrentAsset() + ) + + StatefulCrowdloanMixin.ContributionsInfo( + contributionsCount = it.contributions.size, + isUserHasContributions = it.contributions.isNotEmpty(), + totalContributed = amountModel + ) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/validations/MainCrowdloanValidationFailure.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/validations/MainCrowdloanValidationFailure.kt new file mode 100644 index 0000000..d486b6d --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/validations/MainCrowdloanValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.main.validations + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.validation.NoChainAccountFoundError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed class MainCrowdloanValidationFailure { + + class NoRelaychainAccount( + override val chain: Chain, + override val account: MetaAccount, + override val addAccountState: NoChainAccountFoundError.AddAccountState + ) : MainCrowdloanValidationFailure(), NoChainAccountFoundError +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/validations/MainCrowdloanValidationPayload.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/validations/MainCrowdloanValidationPayload.kt new file mode 100644 index 0000000..5f406e6 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/validations/MainCrowdloanValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.main.validations + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class MainCrowdloanValidationPayload( + val metaAccount: MetaAccount, + val chain: Chain +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/validations/MainCrowdloanValidationSystem.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/validations/MainCrowdloanValidationSystem.kt new file mode 100644 index 0000000..7d741cd --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/domain/main/validations/MainCrowdloanValidationSystem.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_crowdloan_impl.domain.main.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.validation.hasChainAccount +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.validations.MainCrowdloanValidationFailure.NoRelaychainAccount + +typealias MainCrowdloanValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.mainCrowdloan(): MainCrowdloanValidationSystem = ValidationSystem { + hasChainAccount( + chain = MainCrowdloanValidationPayload::chain, + metaAccount = MainCrowdloanValidationPayload::metaAccount, + error = ::NoRelaychainAccount + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/CrowdloanRouter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/CrowdloanRouter.kt new file mode 100644 index 0000000..a060009 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/CrowdloanRouter.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation + +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.parcel.ConfirmContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload +import kotlinx.coroutines.flow.Flow + +interface CrowdloanRouter { + + fun openContribute(payload: ContributePayload) + + val customBonusFlow: Flow + + val latestCustomBonus: BonusPayload? + + fun openCustomContribute(payload: CustomContributePayload) + + fun setCustomBonus(payload: BonusPayload) + + fun openConfirmContribute(payload: ConfirmContributePayload) + + fun back() + + fun returnToMain() + + fun openMoonbeamFlow(payload: ContributePayload) + fun openAddAccount(payload: AddAccountPayload) + + fun openUserContributions() + + fun openSwitchWallet() + + fun openWalletDetails(metaId: Long) + + fun openClaimContribution() +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/ClaimContributionFragment.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/ClaimContributionFragment.kt new file mode 100644 index 0000000..9e6c64d --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/ClaimContributionFragment.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.claimControbution + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.presentation.showLoadingState +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.databinding.FragmentClaimContributionsBinding +import io.novafoundation.nova.feature_crowdloan_impl.di.CrowdloanFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class ClaimContributionFragment : BaseFragment() { + + override fun createBinding() = FragmentClaimContributionsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.crowdloanClaimContributionsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.crowdloanClaimContributionsExtrinsicInfo.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.crowdloanClaimContributionsConfirm.prepareForProgress(viewLifecycleOwner) + binder.crowdloanClaimContributionsConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + CrowdloanFeatureApi::class.java + ) + .claimContributions() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ClaimContributionViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel.originFeeMixin, binder.crowdloanClaimContributionsExtrinsicInfo.fee) + + viewModel.showNextProgress.observe(binder.crowdloanClaimContributionsConfirm::setProgressState) + + viewModel.currentAccountModelFlow.observe(binder.crowdloanClaimContributionsExtrinsicInfo::setAccount) + viewModel.walletFlow.observe(binder.crowdloanClaimContributionsExtrinsicInfo::setWallet) + + viewModel.redeemableAmountModelFlow.observe(binder.crowdloanClaimContributionsAmount::showLoadingState) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/ClaimContributionViewModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/ClaimContributionViewModel.kt new file mode 100644 index 0000000..d7b268e --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/ClaimContributionViewModel.kt @@ -0,0 +1,139 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.claimControbution + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.ClaimContributionsInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.validation.ClaimContributionValidationFailure +import io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.validation.ClaimContributionValidationPayload +import io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.validation.ClaimContributionValidationSystem +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class ClaimContributionViewModel( + private val router: CrowdloanRouter, + private val resourceManager: ResourceManager, + private val validationSystem: ClaimContributionValidationSystem, + private val interactor: ClaimContributionsInteractor, + private val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val claimableContributionsFlow = interactor.claimableContributions() + .shareInBackground() + + val redeemableAmountModelFlow = combine(claimableContributionsFlow, assetFlow) { claimableContributions, asset -> + amountFormatter.formatAmountToAmountModel(claimableContributions.totalContributed, asset) + } + .withSafeLoading() + .shareInBackground() + + val currentAccountModelFlow = selectedAccountUseCase.selectedAddressModelFlow { selectedAssetState.chain() } + .shareInBackground() + + val walletFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val originFeeMixin = feeLoaderMixinV2Factory.createDefault(viewModelScope, selectedAssetState.selectedAssetFlow()) + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: StateFlow = _showNextProgress + + init { + originFeeMixin.connectWith(claimableContributionsFlow) { _, claimableContributions -> + interactor.estimateFee(claimableContributions.contributions) + } + } + + fun confirmClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = currentAccountModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + private fun sendTransactionIfValid() = launchUnit { + _showNextProgress.value = true + + val payload = ClaimContributionValidationPayload( + fee = originFeeMixin.awaitFee(), + asset = assetFlow.first() + ) + + val claimableContributions = claimableContributionsFlow.first() + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = ::formatRedeemFailure, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction(claimableContributions.contributions) + } + } + + private fun sendTransaction(redeemableContributions: List) = launch { + interactor.claim(redeemableContributions) + .onFailure(::showError) + .onSuccess { submissionResult -> + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + startNavigation(submissionResult.submissionHierarchy) { router.back() } + } + + _showNextProgress.value = false + } + + private fun formatRedeemFailure(failure: ClaimContributionValidationFailure): TitleAndMessage { + return when (failure) { + is ClaimContributionValidationFailure.NotEnoughBalanceToPayFees -> handleNotEnoughFeeError(failure, resourceManager) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/di/ClaimContributionComponent.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/di/ClaimContributionComponent.kt new file mode 100644 index 0000000..99d2c5c --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/di/ClaimContributionComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.claimControbution.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_crowdloan_impl.presentation.claimControbution.ClaimContributionFragment + +@Subcomponent( + modules = [ + ClaimContributionModule::class + ] +) +@ScreenScope +interface ClaimContributionComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): ClaimContributionComponent + } + + fun inject(fragment: ClaimContributionFragment) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/di/ClaimContributionModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/di/ClaimContributionModule.kt new file mode 100644 index 0000000..f1b73eb --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/claimControbution/di/ClaimContributionModule.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.claimControbution.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.ClaimContributionsInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.validation.ClaimContributionValidationSystem +import io.novafoundation.nova.feature_crowdloan_impl.domain.claimContributions.validation.claimContribution +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.claimControbution.ClaimContributionViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState + +@Module(includes = [ViewModelModule::class]) +class ClaimContributionModule { + + @ScreenScope + @Provides + fun provideValidationSystem() = ValidationSystem.claimContribution() + + @Provides + @IntoMap + @ViewModelKey(ClaimContributionViewModel::class) + fun provideViewModel( + router: CrowdloanRouter, + resourceManager: ResourceManager, + validationSystem: ClaimContributionValidationSystem, + interactor: ClaimContributionsInteractor, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + externalActions: ExternalActions.Presentation, + selectedAssetState: AnySelectedAssetOptionSharedState, + validationExecutor: ValidationExecutor, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + amountFormatter: AmountFormatter + ): ViewModel { + return ClaimContributionViewModel( + router = router, + resourceManager = resourceManager, + validationSystem = validationSystem, + interactor = interactor, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + selectedAccountUseCase = selectedAccountUseCase, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ClaimContributionViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ClaimContributionViewModel::class.java) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/ValidationFailure.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/ValidationFailure.kt new file mode 100644 index 0000000..5805960 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/ValidationFailure.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute + +import io.novafoundation.nova.common.mixin.api.Action +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidationFailure +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount + +fun contributeValidationFailure( + reason: ContributeValidationFailure, + validationFlowActions: ValidationFlowActions<*>, + resourceManager: ResourceManager, + onOpenCustomContribute: Action?, +): TransformedFailure { + return when (reason) { + ContributeValidationFailure.CannotPayFees -> { + TransformedFailure.Default( + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.common_not_enough_funds_message) + ) + } + + ContributeValidationFailure.ExistentialDepositCrossed -> { + TransformedFailure.Default( + resourceManager.getString(R.string.common_existential_warning_title) to + resourceManager.getString(R.string.common_existential_warning_message_v2_2_0) + ) + } + + ContributeValidationFailure.CrowdloanEnded -> { + TransformedFailure.Default( + resourceManager.getString(R.string.crowdloan_ended_title) to + resourceManager.getString(R.string.crowdloan_ended_message) + ) + } + + ContributeValidationFailure.CapExceeded.FromRaised -> { + TransformedFailure.Default( + resourceManager.getString(R.string.crowdloan_cap_reached_title) to + resourceManager.getString(R.string.crowdloan_cap_reached_raised_message) + ) + } + + is ContributeValidationFailure.CapExceeded.FromAmount -> { + val formattedAmount = with(reason) { + maxAllowedContribution.formatTokenAmount(chainAsset) + } + + TransformedFailure.Default( + resourceManager.getString(R.string.crowdloan_cap_reached_title) to + resourceManager.getString(R.string.crowdloan_cap_reached_amount_message, formattedAmount) + ) + } + + is ContributeValidationFailure.LessThanMinContribution -> { + val formattedAmount = with(reason) { + minContribution.formatTokenAmount(chainAsset) + } + + TransformedFailure.Default( + resourceManager.getString(R.string.crowdloan_too_small_contribution_title) to + resourceManager.getString(R.string.crowdloan_too_small_contribution_message, formattedAmount) + ) + } + + ContributeValidationFailure.PrivateCrowdloanNotSupported -> { + TransformedFailure.Default( + resourceManager.getString(R.string.crodloan_private_crowdloan_title) to + resourceManager.getString(R.string.crodloan_private_crowdloan_message) + ) + } + ContributeValidationFailure.BonusNotApplied -> { + TransformedFailure.Custom( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.crowdloan_missing_bonus_title), + message = resourceManager.getString(R.string.crowdloan_missing_bonus_message), + okAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.crowdloan_missing_bonus_action), + action = { onOpenCustomContribute?.invoke() } + ), + cancelAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_skip), + action = { validationFlowActions.resumeFlow() } + ), + customStyle = R.style.AccentNegativeAlertDialogTheme + ) + ) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/ConfirmContributeFragment.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/ConfirmContributeFragment.kt new file mode 100644 index 0000000..caeb434 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/ConfirmContributeFragment.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.presentation.masking.dataOrNull +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.databinding.FragmentContributeConfirmBinding +import io.novafoundation.nova.feature_crowdloan_impl.di.CrowdloanFeatureComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.parcel.ConfirmContributePayload + +import kotlinx.coroutines.flow.filterNotNull +import javax.inject.Inject + +private const val KEY_PAYLOAD = "KEY_PAYLOAD" + +class ConfirmContributeFragment : BaseFragment() { + + @Inject + lateinit var imageLoader: ImageLoader + + companion object { + + fun getBundle(payload: ConfirmContributePayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentContributeConfirmBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmContributeToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.confirmContributeConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmContributeConfirm.setOnClickListener { viewModel.nextClicked() } + + binder.confirmContributeOriginAcount.setWholeClickListener { viewModel.originAccountClicked() } + } + + override fun inject() { + val payload = argument("KEY_PAYLOAD") + + FeatureUtils.getFeature( + requireContext(), + CrowdloanFeatureApi::class.java + ) + .confirmContributeFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmContributeViewModel) { + observeBrowserEvents(viewModel) + observeValidations(viewModel) + setupExternalActions(viewModel) + + viewModel.showNextProgress.observe(binder.confirmContributeConfirm::setProgressState) + + viewModel.assetModelFlow.observe { + binder.confirmContributeAmount.setAssetBalance(it.assetBalance.dataOrNull() ?: "") + binder.confirmContributeAmount.setAssetName(it.tokenSymbol) + binder.confirmContributeAmount.loadAssetImage(it.icon) + } + + binder.confirmContributeAmount.amountInput.setText(viewModel.selectedAmount) + + viewModel.enteredFiatAmountFlow.observe { + it.let(binder.confirmContributeAmount::setFiatAmount) + } + + viewModel.feeFlow.observe(binder.confirmContributeFee::setFeeStatus) + + with(binder.confirmContributeReward) { + val reward = viewModel.estimatedReward + + setVisible(reward != null) + + reward?.let { showValue(it) } + } + + viewModel.crowdloanInfoFlow.observe { + binder.confirmContributeLeasingPeriod.showValue(it.leasePeriod, it.leasedUntil) + } + + viewModel.selectedAddressModelFlow.observe { + binder.confirmContributeOriginAcount.setMessage(it.nameOrAddress) + binder.confirmContributeOriginAcount.setTextIcon(it.image) + } + + viewModel.bonusFlow.observe { + binder.confirmContributeBonus.setVisible(it != null) + + it?.let(binder.confirmContributeBonus::showValue) + } + + viewModel.customizationConfiguration.filterNotNull().observe { (customization, customViewState) -> + customization.injectViews(binder.confirmContributeContainer, customViewState, viewLifecycleOwner.lifecycleScope) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/ConfirmContributeViewModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/ConfirmContributeViewModel.kt new file mode 100644 index 0000000..8a462ac --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/ConfirmContributeViewModel.kt @@ -0,0 +1,228 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.CrowdloanContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidationPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.model.LeasePeriodModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.parcel.ConfirmContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.contributeValidationFailure +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.ConfirmContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.mapParachainMetadataFromParcel +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapAssetToAssetModel +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapFeeToFeeModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.runtime.state.SingleAssetSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmContributeViewModel( + private val assetIconProvider: AssetIconProvider, + private val router: CrowdloanRouter, + private val contributionInteractor: CrowdloanContributeInteractor, + private val resourceManager: ResourceManager, + assetUseCase: AssetUseCase, + accountUseCase: SelectedAccountUseCase, + addressModelGenerator: AddressIconGenerator, + private val validationExecutor: ValidationExecutor, + private val payload: ConfirmContributePayload, + private val validations: Collection, + private val customContributeManager: CustomContributeManager, + private val externalActions: ExternalActions.Presentation, + private val assetSharedState: SingleAssetSharedState, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val decimalFee = mapFeeFromParcel(payload.fee) + + private val chain by lazyAsync { assetSharedState.chain() } + + override val openBrowserEvent = MutableLiveData>() + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + private val assetFlow = assetUseCase.currentAssetFlow() + .share() + + val assetModelFlow = assetFlow + .map { + mapAssetToAssetModel( + assetIconProvider, + it, + resourceManager, + // Very rude way to show transferable balance but we don't support contributions so I don't se a reason for deeper refactoring + MaskableModel.Unmasked(it.transferableInPlanks) + ) + } + .inBackground() + .share() + + val selectedAddressModelFlow = accountUseCase.selectedMetaAccountFlow() + .map { metaAccount -> + addressModelGenerator.createAccountAddressModel(chain.await(), metaAccount) + } + .shareInBackground() + + val selectedAmount = payload.amount.toString() + + val feeFlow = assetFlow.map { asset -> + val feeModel = mapFeeToFeeModel(decimalFee, asset.token, amountFormatter = amountFormatter) + + FeeStatus.Loaded(feeModel) + } + .inBackground() + .share() + + val enteredFiatAmountFlow = assetFlow.map { asset -> + asset.token.amountToFiat(payload.amount).formatAsCurrency(asset.token.currency) + } + .inBackground() + .share() + + private val parachainMetadata = payload.metadata?.let(::mapParachainMetadataFromParcel) + + private val relevantCustomFlowFactory = parachainMetadata?.customFlow?.let { + customContributeManager.getFactoryOrNull(it) + } + + val customizationConfiguration: Flow?> = flowOf { + relevantCustomFlowFactory?.confirmContributeCustomization?.let { + it to it.createViewState(coroutineScope = this, parachainMetadata!!, payload.customizationPayload) + } + } + .inBackground() + .share() + + val estimatedReward = payload.estimatedRewardDisplay + + private val crowdloanFlow = contributionInteractor.crowdloanStateFlow(payload.paraId, parachainMetadata) + .inBackground() + .share() + + val crowdloanInfoFlow = crowdloanFlow.map { crowdloan -> + LeasePeriodModel( + leasePeriod = resourceManager.formatDuration(crowdloan.leasePeriodInMillis), + leasedUntil = resourceManager.formatDateTime(crowdloan.leasedUntilInMillis) + ) + } + .inBackground() + .share() + + val bonusFlow = flow { + val bonusDisplay = payload.bonusPayload?.bonusText(payload.amount) + + emit(bonusDisplay) + } + .inBackground() + .share() + + private val customizedValidationSystem = flowOf { + val validations = relevantCustomFlowFactory?.confirmContributeCustomization?.modifyValidations(validations) + ?: validations + + ValidationSystem(CompositeValidation(validations)) + } + .inBackground() + .share() + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() { + launch { + val accountAddress = selectedAddressModelFlow.first().address + val chain = assetSharedState.chain() + + externalActions.showAddressActions(accountAddress, chain) + } + } + + private fun maybeGoToNext() = launch { + val validationPayload = ContributeValidationPayload( + crowdloan = crowdloanFlow.first(), + fee = decimalFee, + asset = assetFlow.first(), + customizationPayload = payload.customizationPayload, + bonusPayload = payload.bonusPayload, + contributionAmount = payload.amount + ) + + validationExecutor.requireValid( + validationSystem = customizedValidationSystem.first(), + payload = validationPayload, + progressConsumer = _showNextProgress.progressConsumer(), + validationFailureTransformerCustom = { status, actions -> + contributeValidationFailure( + reason = status.reason, + validationFlowActions = actions, + resourceManager = resourceManager, + onOpenCustomContribute = null + ) + } + ) { + sendTransaction() + } + } + + private fun sendTransaction() { + launch { + val crowdloan = crowdloanFlow.first() + + contributionInteractor.contribute( + crowdloan = crowdloan, + contribution = payload.amount, + bonusPayload = payload.bonusPayload, + customizationPayload = payload.customizationPayload + ) + .onFailure(::showError) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToMain() } + } + + _showNextProgress.value = false + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/di/ConfirmContributeComponent.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/di/ConfirmContributeComponent.kt new file mode 100644 index 0000000..bb73d77 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/di/ConfirmContributeComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.ConfirmContributeFragment +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.parcel.ConfirmContributePayload + +@Subcomponent( + modules = [ + ConfirmContributeModule::class + ] +) +@ScreenScope +interface ConfirmContributeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmContributePayload + ): ConfirmContributeComponent + } + + fun inject(fragment: ConfirmContributeFragment) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/di/ConfirmContributeModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/di/ConfirmContributeModule.kt new file mode 100644 index 0000000..47c2424 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/di/ConfirmContributeModule.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.di.validations.Confirm +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.CrowdloanContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidation +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.ConfirmContributeViewModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.parcel.ConfirmContributePayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmContributeModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmContributeViewModel::class) + fun provideViewModel( + assetIconProvider: AssetIconProvider, + interactor: CrowdloanContributeInteractor, + router: CrowdloanRouter, + resourceManager: ResourceManager, + assetUseCase: AssetUseCase, + validationExecutor: ValidationExecutor, + payload: ConfirmContributePayload, + accountUseCase: SelectedAccountUseCase, + addressIconGenerator: AddressIconGenerator, + @Confirm contributeValidations: @JvmSuppressWildcards Set, + externalActions: ExternalActions.Presentation, + customContributeManager: CustomContributeManager, + singleAssetSharedState: CrowdloanSharedState, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmContributeViewModel( + assetIconProvider, + router, + interactor, + resourceManager, + assetUseCase, + accountUseCase, + addressIconGenerator, + validationExecutor, + payload, + contributeValidations, + customContributeManager, + externalActions, + singleAssetSharedState, + extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmContributeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmContributeViewModel::class.java) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/model/LeasePeriodModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/model/LeasePeriodModel.kt new file mode 100644 index 0000000..8de16f8 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/model/LeasePeriodModel.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.model + +class LeasePeriodModel( + val leasePeriod: String, + val leasedUntil: String, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/parcel/ConfirmContributePayload.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/parcel/ConfirmContributePayload.kt new file mode 100644 index 0000000..029c72a --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/confirm/parcel/ConfirmContributePayload.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.parcel + +import android.os.Parcelable +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ParachainMetadataParcelModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class ConfirmContributePayload( + val paraId: ParaId, + val fee: FeeParcelModel, + val amount: BigDecimal, + val bonusPayload: BonusPayload?, + val customizationPayload: Parcelable?, + val metadata: ParachainMetadataParcelModel?, + val estimatedRewardDisplay: String?, +) : Parcelable diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContribute.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContribute.kt new file mode 100644 index 0000000..43c2149 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContribute.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom + +import android.content.Context +import android.os.Parcelable +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.lifecycle.LifecycleCoroutineScope +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal + +sealed class ApplyActionState { + object Available : ApplyActionState() + + class Unavailable(val reason: String) : ApplyActionState() +} + +interface CustomContributeViewState { + + suspend fun generatePayload(): Result + + val applyActionState: Flow +} + +abstract class CustomContributeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : ConstraintLayout(context, attrs, defStyle) { + + abstract fun bind(viewState: CustomContributeViewState, scope: LifecycleCoroutineScope) +} + +interface BonusPayload : Parcelable { + + fun bonusText(amount: BigDecimal): String +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContributeFragment.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContributeFragment.kt new file mode 100644 index 0000000..81b2689 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContributeFragment.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.databinding.FragmentCustomContributeBinding +import io.novafoundation.nova.feature_crowdloan_impl.di.CrowdloanFeatureComponent +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload + +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +private const val KEY_PAYLOAD = "KEY_PAYLOAD" + +class CustomContributeFragment : BaseFragment() { + + @Inject + lateinit var contributionManager: CustomContributeManager + + companion object { + + fun getBundle(payload: CustomContributePayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentCustomContributeBinding.inflate(layoutInflater) + + override fun initViews() { + binder.customContributeToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.customContributeApply.prepareForProgress(viewLifecycleOwner) + binder.customContributeApply.setOnClickListener { viewModel.applyClicked() } + } + + override fun inject() { + val payload = argument(KEY_PAYLOAD) + + FeatureUtils.getFeature( + requireContext(), + CrowdloanFeatureApi::class.java + ) + .customContributeFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: CustomContributeViewModel) { + lifecycleScope.launchWhenResumed { + viewModel.applyButtonState.combine(viewModel.applyingInProgress) { state, inProgress -> + when { + inProgress -> binder.customContributeApply.setState(ButtonState.PROGRESS) + state is ApplyActionState.Unavailable -> { + binder.customContributeApply.setState(ButtonState.DISABLED) + binder.customContributeApply.text = state.reason + } + + state is ApplyActionState.Available -> { + binder.customContributeApply.setState(ButtonState.NORMAL) + binder.customContributeApply.setText(R.string.common_apply) + } + } + }.collect() + } + + viewModel.viewStateFlow.observe { viewState -> + binder.customFlowContainer.removeAllViews() + + val newView = contributionManager.relevantExtraBonusFlow(viewModel.customFlowType).createView(requireContext()) + + binder.customFlowContainer.addView(newView) + + newView.bind(viewState, lifecycleScope) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContributeSubmitter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContributeSubmitter.kt new file mode 100644 index 0000000..f41a1c6 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContributeSubmitter.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom + +import android.os.Parcelable +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import java.math.BigDecimal + +interface CustomContributeSubmitter { + + suspend fun injectOnChainSubmission( + crowdloan: Crowdloan, + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + extrinsicBuilder: ExtrinsicBuilder, + ) + + suspend fun injectFeeCalculation( + crowdloan: Crowdloan, + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + extrinsicBuilder: ExtrinsicBuilder, + ) = injectOnChainSubmission( + crowdloan, + customizationPayload, + bonusPayload, + amount, + extrinsicBuilder + ) + + suspend fun submitOffChain( + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContributeViewModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContributeViewModel.kt new file mode 100644 index 0000000..6ea71a2 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/CustomContributeViewModel.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch + +class CustomContributeViewModel( + private val customContributeManager: CustomContributeManager, + val payload: CustomContributePayload, + private val router: CrowdloanRouter +) : BaseViewModel() { + + val customFlowType = payload.parachainMetadata.customFlow!! + + val viewStateFlow = flow { + emit(customContributeManager.relevantExtraBonusFlow(customFlowType).createViewState(viewModelScope, payload)) + }.inBackground() + .share() + + val applyButtonState = viewStateFlow + .flatMapLatest { it.applyActionState } + .share() + + private val _applyingInProgress = MutableStateFlow(false) + val applyingInProgress: Flow = _applyingInProgress + + fun backClicked() { + router.back() + } + + fun applyClicked() { + launch { + _applyingInProgress.value = true + + viewStateFlow.first().generatePayload() + .onSuccess { + router.setCustomBonus(it) + router.back() + } + .onFailure(::showError) + + _applyingInProgress.value = false + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/SelectContributeCustomization.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/SelectContributeCustomization.kt new file mode 100644 index 0000000..f16a112 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/SelectContributeCustomization.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom + +import android.content.Context +import android.os.Parcelable +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.lifecycle.LifecycleCoroutineScope +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface ContributeCustomization { + + fun modifyValidations(validations: Collection): Collection + + fun injectViews(into: ViewGroup, state: V, scope: LifecycleCoroutineScope) +} + +interface SelectContributeCustomization : ContributeCustomization { + + interface ViewState { + + val customizationPayloadFlow: Flow + } + + fun createViewState( + features: CrowdloanMainFlowFeatures, + parachainMetadata: ParachainMetadata, + ): ViewState +} + +interface ConfirmContributeCustomization : ContributeCustomization { + + interface ViewState + + fun createViewState( + coroutineScope: CoroutineScope, + parachainMetadata: ParachainMetadata, + customPayload: Parcelable?, + ): ViewState +} + +class CrowdloanMainFlowFeatures( + val coroutineScope: CoroutineScope, + val browserable: Browserable.Presentation, +) + +fun injectionLayoutParams( + context: Context, + topMarginDp: Int, +): LinearLayout.LayoutParams { + val horizontalMargin = 16.dp(context) + + return LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + setMargins(horizontalMargin, topMarginDp.dp(context), horizontalMargin, 0) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/StartFlowInterceptor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/StartFlowInterceptor.kt new file mode 100644 index 0000000..280553d --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/StartFlowInterceptor.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom + +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload + +interface StartFlowInterceptor { + + suspend fun startFlow(payload: ContributePayload) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/bonus/AcalaContributeSubmitter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/bonus/AcalaContributeSubmitter.kt new file mode 100644 index 0000000..55c304a --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/bonus/AcalaContributeSubmitter.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.bonus + +import android.os.Parcelable +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala.AcalaContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.AcalaCustomizationPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.DefaultReferralCodePayload +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import java.math.BigDecimal + +class AcalaContributeSubmitter( + private val interactor: AcalaContributeInteractor +) : CustomContributeSubmitter { + + override suspend fun injectOnChainSubmission( + crowdloan: Crowdloan, + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + extrinsicBuilder: ExtrinsicBuilder, + ) { + require(customizationPayload is AcalaCustomizationPayload) + require(bonusPayload is DefaultReferralCodePayload?) + + interactor.injectOnChainSubmission( + contributionType = customizationPayload.contributionType, + referralCode = bonusPayload?.referralCode, + amount = amount, + extrinsicBuilder = extrinsicBuilder + ) + } + + override suspend fun injectFeeCalculation( + crowdloan: Crowdloan, + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + extrinsicBuilder: ExtrinsicBuilder, + ) { + require(customizationPayload is AcalaCustomizationPayload) + require(bonusPayload is DefaultReferralCodePayload?) + + interactor.injectFeeCalculation( + contributionType = customizationPayload.contributionType, + referralCode = bonusPayload?.referralCode, + amount = amount, + extrinsicBuilder = extrinsicBuilder + ) + } + + override suspend fun submitOffChain(customizationPayload: Parcelable?, bonusPayload: BonusPayload?, amount: BigDecimal) { + require(bonusPayload is DefaultReferralCodePayload?) + require(customizationPayload is AcalaCustomizationPayload) + + interactor.registerContributionOffChain( + amount = amount, + contributionType = customizationPayload.contributionType, + referralCode = bonusPayload?.referralCode + ) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/bonus/AcalaContributeViewState.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/bonus/AcalaContributeViewState.kt new file mode 100644 index 0000000..42da120 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/bonus/AcalaContributeViewState.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.bonus + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_impl.BuildConfig +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala.AcalaContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.DefaultReferralCodePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralCodePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralContributeViewState +import java.math.BigDecimal + +class AcalaContributeViewState( + private val interactor: AcalaContributeInteractor, + customContributePayload: CustomContributePayload, + resourceManager: ResourceManager, + defaultReferralCode: String, + private val bonusPercentage: BigDecimal, +) : ReferralContributeViewState( + customContributePayload = customContributePayload, + resourceManager = resourceManager, + defaultReferralCode = defaultReferralCode, + bonusPercentage = bonusPercentage, + termsUrl = BuildConfig.ACALA_TERMS_LINK +) { + + override fun createBonusPayload(referralCode: String): ReferralCodePayload { + return DefaultReferralCodePayload( + rewardTokenSymbol = customContributePayload.parachainMetadata.token, + referralCode = referralCode, + rewardRate = customContributePayload.parachainMetadata.rewardRate, + referralBonus = bonusPercentage + ) + } + + override suspend fun validatePayload(payload: ReferralCodePayload) { + val isReferralValid = interactor.isReferralValid(payload.referralCode) + + if (!isReferralValid) throw IllegalArgumentException(resourceManager.getString(R.string.crowdloan_referral_code_invalid)) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/AcalaCustomizationPayload.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/AcalaCustomizationPayload.kt new file mode 100644 index 0000000..0ea9be0 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/AcalaCustomizationPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main + +import android.os.Parcelable +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala.ContributionType +import kotlinx.parcelize.Parcelize + +@Parcelize +class AcalaCustomizationPayload( + val contributionType: ContributionType, +) : Parcelable diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/base/AcalaMainFlowCustomization.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/base/AcalaMainFlowCustomization.kt new file mode 100644 index 0000000..c64e1f3 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/base/AcalaMainFlowCustomization.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.base + +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.MinContributionValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.acala.AcalaMinContributionValidation +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.ContributeCustomization + +abstract class AcalaMainFlowCustomization : ContributeCustomization { + + override fun modifyValidations(validations: Collection): Collection { + return validations.map { + when (it) { + is MinContributionValidation -> AcalaMinContributionValidation(fallback = it) + else -> it + } + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/confirm/AcalaConfirmContributeCustomization.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/confirm/AcalaConfirmContributeCustomization.kt new file mode 100644 index 0000000..38ba02d --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/confirm/AcalaConfirmContributeCustomization.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.confirm + +import android.os.Parcelable +import android.view.ViewGroup +import androidx.lifecycle.LifecycleCoroutineScope +import io.novafoundation.nova.common.utils.addAfter +import io.novafoundation.nova.common.utils.observeInLifecycle +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.ConfirmContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.AcalaCustomizationPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.base.AcalaMainFlowCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.injectionLayoutParams + +import kotlinx.coroutines.CoroutineScope + +class AcalaConfirmContributeCustomization( + private val viewStateFactory: AcalaConfirmContributeViewStateFactory, +) : AcalaMainFlowCustomization(), + ConfirmContributeCustomization { + + override fun injectViews( + into: ViewGroup, + state: ConfirmContributeCustomization.ViewState, + scope: LifecycleCoroutineScope, + ) { + require(state is AcalaConfirmContributeViewState) + + val confirmContributeInjectionParent = into.findViewById(R.id.confirmContributeInjectionParent) + val confirmContributeAmountBottomMargin = into.findViewById(R.id.confirmContributeAmountBottomMargin) + + val contributionCell = TableCellView(into.context).apply { + layoutParams = injectionLayoutParams(context, topMarginDp = 0) + + setTitle(R.string.crowdloan_contribution) + } + + state.contributionTypeFlow.observeInLifecycle(scope) { + contributionCell.showValue(it) + } + + confirmContributeInjectionParent.addAfter( + anchor = confirmContributeAmountBottomMargin, + newViews = listOf(contributionCell) + ) + } + + override fun createViewState( + coroutineScope: CoroutineScope, + parachainMetadata: ParachainMetadata, + customPayload: Parcelable?, + ): ConfirmContributeCustomization.ViewState { + require(customPayload is AcalaCustomizationPayload) + + return viewStateFactory.create(coroutineScope, customPayload) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/confirm/AcalaConfirmContributeViewState.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/confirm/AcalaConfirmContributeViewState.kt new file mode 100644 index 0000000..fdb8bdd --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/confirm/AcalaConfirmContributeViewState.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.confirm + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala.ContributionType +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.ConfirmContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.AcalaCustomizationPayload +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.shareIn + +class AcalaConfirmContributeViewStateFactory( + private val resourceManager: ResourceManager, +) { + + fun create( + scope: CoroutineScope, + payload: AcalaCustomizationPayload, + ) = AcalaConfirmContributeViewState( + payload = payload, + resourceManager = resourceManager, + scope = scope + ) +} + +class AcalaConfirmContributeViewState( + private val payload: AcalaCustomizationPayload, + private val resourceManager: ResourceManager, + scope: CoroutineScope, +) : ConfirmContributeCustomization.ViewState, CoroutineScope by scope { + + val contributionTypeFlow = flowOf { + val stringRes = when (payload.contributionType) { + ContributionType.LIQUID -> R.string.crowdloan_acala_liquid + ContributionType.DIRECT -> R.string.crowdloan_acala_direct + } + + resourceManager.getString(stringRes) + }.inBackground() + .shareIn(this, SharingStarted.Eagerly, replay = 1) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/select/AcalaSelectContributeCustomization.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/select/AcalaSelectContributeCustomization.kt new file mode 100644 index 0000000..faa73cd --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/select/AcalaSelectContributeCustomization.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.select + +import android.view.ViewGroup +import android.widget.RadioGroup +import android.widget.TextView +import androidx.lifecycle.LifecycleCoroutineScope +import io.novafoundation.nova.common.utils.addAfter +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.inflateChild +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CrowdloanMainFlowFeatures +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.SelectContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.base.AcalaMainFlowCustomization + +private const val LEARN_TYPES_LINK = "https://wiki.acala.network/acala/acala-crowdloan/crowdloan-event#3.2-ways-to-participate" + +class AcalaSelectContributeCustomization : + AcalaMainFlowCustomization(), + SelectContributeCustomization { + + override fun injectViews( + into: ViewGroup, + state: SelectContributeCustomization.ViewState, + scope: LifecycleCoroutineScope, + ) { + require(state is AcalaSelectContributeViewState) + + val crowdloanContributeScrollableContent = into.findViewById(R.id.crowdloanContributeScrollableContent) + val crowdloanContributeTitle = into.findViewById(R.id.crowdloanContributeTitle) + + val typeSelector = crowdloanContributeScrollableContent.inflateChild(R.layout.view_acala_contribution_type) as RadioGroup + typeSelector.bindTo(state.selectedContributionTypeIdFlow, scope) + + val learnMoreText = crowdloanContributeScrollableContent.inflateChild(R.layout.view_acala_learn_contributions) as TextView + learnMoreText.setOnClickListener { state.learnContributionTypesClicked() } + + crowdloanContributeScrollableContent.addAfter( + anchor = crowdloanContributeTitle, + newViews = listOf(typeSelector, learnMoreText) + ) + } + + override fun createViewState( + features: CrowdloanMainFlowFeatures, + parachainMetadata: ParachainMetadata, + ): SelectContributeCustomization.ViewState { + return AcalaSelectContributeViewState( + browserable = features.browserable, + scope = features.coroutineScope, + acalaContributionsInfoLink = LEARN_TYPES_LINK + ) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/select/AcalaSelectContributeViewState.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/select/AcalaSelectContributeViewState.kt new file mode 100644 index 0000000..afa2c00 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/acala/main/select/AcalaSelectContributeViewState.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.select + +import android.os.Parcelable +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.acala.ContributionType +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.SelectContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.acala.main.AcalaCustomizationPayload +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn + +private val CONTRIBUTION_TYPE_BY_ID = mapOf( + R.id.acalaContributionTypeDirect to ContributionType.DIRECT, + R.id.acalaContributionTypeLiquid to ContributionType.LIQUID +) + +private val DEFAULT_CONTRIBUTION_TYPE_ID = R.id.acalaContributionTypeDirect + +class AcalaSelectContributeViewState( + private val acalaContributionsInfoLink: String, + private val browserable: Browserable.Presentation, + scope: CoroutineScope, +) : SelectContributeCustomization.ViewState, CoroutineScope by scope { + + val selectedContributionTypeIdFlow = MutableStateFlow(DEFAULT_CONTRIBUTION_TYPE_ID) + + fun learnContributionTypesClicked() { + browserable.showBrowser(acalaContributionsInfoLink) + } + + override val customizationPayloadFlow: Flow = selectedContributionTypeIdFlow + .map { selectedContributionTypeId -> + val contributionType = CONTRIBUTION_TYPE_BY_ID.getValue(selectedContributionTypeId) + + AcalaCustomizationPayload(contributionType) + } + .inBackground() + .shareIn(scope, SharingStarted.Eagerly, replay = 1) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/astar/AstarContributeSubmitter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/astar/AstarContributeSubmitter.kt new file mode 100644 index 0000000..dc76a1f --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/astar/AstarContributeSubmitter.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.astar + +import android.os.Parcelable +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.astar.AstarContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.DefaultReferralCodePayload +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import java.math.BigDecimal + +class AstarContributeSubmitter( + private val interactor: AstarContributeInteractor, +) : CustomContributeSubmitter { + + override suspend fun injectOnChainSubmission( + crowdloan: Crowdloan, + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + extrinsicBuilder: ExtrinsicBuilder, + ) { + require(bonusPayload is DefaultReferralCodePayload?) + + bonusPayload?.let { + interactor.submitOnChain(crowdloan.parachainId, bonusPayload.referralCode, extrinsicBuilder) + } + } + + override suspend fun submitOffChain( + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + ) { + // Do nothing + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/astar/AstarContributeViewState.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/astar/AstarContributeViewState.kt new file mode 100644 index 0000000..a35a33e --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/astar/AstarContributeViewState.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.astar + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.astar.AstarContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.DefaultReferralCodePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralCodePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralContributeViewState +import java.math.BigDecimal + +class AstarContributeViewState( + private val interactor: AstarContributeInteractor, + customContributePayload: CustomContributePayload, + resourceManager: ResourceManager, + defaultReferralCode: String, + private val bonusPercentage: BigDecimal, + termsLink: String, +) : ReferralContributeViewState( + customContributePayload = customContributePayload, + resourceManager = resourceManager, + defaultReferralCode = defaultReferralCode, + bonusPercentage = bonusPercentage, + termsUrl = termsLink +) { + + override fun createBonusPayload(referralCode: String): ReferralCodePayload { + return DefaultReferralCodePayload( + rewardTokenSymbol = customContributePayload.parachainMetadata.token, + referralCode = referralCode, + rewardRate = customContributePayload.parachainMetadata.rewardRate, + referralBonus = bonusPercentage + ) + } + + override suspend fun validatePayload(payload: ReferralCodePayload) { + val isReferralValid = interactor.isReferralCodeValid(payload.referralCode) + + if (!isReferralValid) throw IllegalArgumentException(resourceManager.getString(R.string.crowdloan_astar_wrong_referral)) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/bifrost/BifrostContributeSubmitter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/bifrost/BifrostContributeSubmitter.kt new file mode 100644 index 0000000..64c2954 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/bifrost/BifrostContributeSubmitter.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.bifrost + +import android.os.Parcelable +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.bifrost.BifrostContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeSubmitter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.DefaultReferralCodePayload +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import java.math.BigDecimal + +class BifrostContributeSubmitter( + private val interactor: BifrostContributeInteractor +) : CustomContributeSubmitter { + + override suspend fun injectOnChainSubmission( + crowdloan: Crowdloan, + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + extrinsicBuilder: ExtrinsicBuilder, + ) { + require(bonusPayload is DefaultReferralCodePayload?) + + bonusPayload?.let { + interactor.submitOnChain(crowdloan.parachainId, bonusPayload.referralCode, extrinsicBuilder) + } + } + + override suspend fun submitOffChain( + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + ) { + // Do nothing + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/bifrost/BifrostContributeViewState.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/bifrost/BifrostContributeViewState.kt new file mode 100644 index 0000000..08d56ae --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/bifrost/BifrostContributeViewState.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.bifrost + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.bifrost.BifrostContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.DefaultReferralCodePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralCodePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral.ReferralContributeViewState +import java.math.BigDecimal + +class BifrostContributeViewState( + interactor: BifrostContributeInteractor, + customContributePayload: CustomContributePayload, + resourceManager: ResourceManager, + termsLink: String, + private val bonusPercentage: BigDecimal, + private val bifrostInteractor: BifrostContributeInteractor, +) : ReferralContributeViewState( + customContributePayload = customContributePayload, + resourceManager = resourceManager, + defaultReferralCode = interactor.novaReferralCode, + bonusPercentage = bonusPercentage, + termsUrl = termsLink +) { + + override fun createBonusPayload(referralCode: String): ReferralCodePayload { + return DefaultReferralCodePayload( + rewardTokenSymbol = customContributePayload.parachainMetadata.token, + referralCode = referralCode, + referralBonus = bonusPercentage, + rewardRate = customContributePayload.parachainMetadata.rewardRate + ) + } + + override suspend fun validatePayload(payload: ReferralCodePayload) { + if (bifrostInteractor.isCodeValid(payload.referralCode).not()) { + throw IllegalArgumentException(resourceManager.getString(R.string.crowdloan_referral_code_invalid)) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/di/CustomContributeComponent.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/di/CustomContributeComponent.kt new file mode 100644 index 0000000..4b19a3d --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/di/CustomContributeComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeFragment +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload + +@Subcomponent( + modules = [ + CustomContributeModule::class + ] +) +@ScreenScope +interface CustomContributeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: CustomContributePayload + ): CustomContributeComponent + } + + fun inject(fragment: CustomContributeFragment) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/di/CustomContributeModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/di/CustomContributeModule.kt new file mode 100644 index 0000000..851472a --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/di/CustomContributeModule.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeViewModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload + +@Module(includes = [ViewModelModule::class]) +class CustomContributeModule { + + @Provides + @IntoMap + @ViewModelKey(CustomContributeViewModel::class) + fun provideViewModel( + customContributeManager: CustomContributeManager, + payload: CustomContributePayload, + router: CrowdloanRouter, + ): ViewModel { + return CustomContributeViewModel(customContributeManager, payload, router) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): CustomContributeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(CustomContributeViewModel::class.java) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/model/CustomContributePayload.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/model/CustomContributePayload.kt new file mode 100644 index 0000000..dbe763f --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/model/CustomContributePayload.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model + +import android.os.Parcelable +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ParachainMetadataParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class CustomContributePayload( + val paraId: ParaId, + val parachainMetadata: ParachainMetadataParcelModel, + val amount: BigDecimal, + val previousBonusPayload: BonusPayload? +) : Parcelable diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/MoonbeamCrowdloanSubmitter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/MoonbeamCrowdloanSubmitter.kt new file mode 100644 index 0000000..2babc40 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/MoonbeamCrowdloanSubmitter.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam + +import android.os.Parcelable +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.MoonbeamCrowdloanInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeSubmitter +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import java.math.BigDecimal + +class MoonbeamCrowdloanSubmitter( + private val interactor: MoonbeamCrowdloanInteractor, +) : CustomContributeSubmitter { + + override suspend fun injectOnChainSubmission( + crowdloan: Crowdloan, + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + extrinsicBuilder: ExtrinsicBuilder, + ) { + interactor.additionalSubmission(crowdloan, extrinsicBuilder) + } + + override suspend fun submitOffChain( + customizationPayload: Parcelable?, + bonusPayload: BonusPayload?, + amount: BigDecimal, + ) { + // Do nothing + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/MoonbeamStartFlowInterceptor.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/MoonbeamStartFlowInterceptor.kt new file mode 100644 index 0000000..6feecd6 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/MoonbeamStartFlowInterceptor.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer.Payload.DialogAction +import io.novafoundation.nova.common.mixin.api.displayError +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.MoonbeamCrowdloanInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.MoonbeamFlowStatus +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.StartFlowInterceptor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.mapParachainMetadataFromParcel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class MoonbeamStartFlowInterceptor( + private val crowdloanRouter: CrowdloanRouter, + private val resourceManager: ResourceManager, + private val moonbeamInteractor: MoonbeamCrowdloanInteractor, + private val customDialogDisplayer: CustomDialogDisplayer.Presentation, +) : StartFlowInterceptor { + + override suspend fun startFlow(payload: ContributePayload) { + withContext(Dispatchers.Default) { + moonbeamInteractor.flowStatus(mapParachainMetadataFromParcel(payload.parachainMetadata!!)) + } + .onSuccess { handleMoonbeamStatus(it, payload) } + .onFailure { customDialogDisplayer.displayError(resourceManager, it) } + } + + private fun handleMoonbeamStatus(status: MoonbeamFlowStatus, payload: ContributePayload) { + when (status) { + MoonbeamFlowStatus.Completed -> crowdloanRouter.openContribute(payload) + + is MoonbeamFlowStatus.NeedsChainAccount -> { + customDialogDisplayer.displayDialog( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.crowdloan_moonbeam_missing_account_title), + message = resourceManager.getString(R.string.crowdloan_moonbeam_missing_account_message), + okAction = DialogAction( + title = resourceManager.getString(R.string.common_add), + action = { crowdloanRouter.openAddAccount(AddAccountPayload.ChainAccount(status.chainId, status.metaId)) } + ), + cancelAction = DialogAction.noOp(resourceManager.getString(R.string.common_cancel)) + ) + ) + } + + MoonbeamFlowStatus.ReadyToComplete -> crowdloanRouter.openMoonbeamFlow(payload) + + MoonbeamFlowStatus.RegionNotSupported -> { + customDialogDisplayer.displayDialog( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.crowdloan_moonbeam_region_restriction_title), + message = resourceManager.getString(R.string.crowdloan_moonbeam_region_restriction_message), + okAction = DialogAction.noOp(resourceManager.getString(R.string.common_ok)), + cancelAction = null + ) + ) + } + MoonbeamFlowStatus.UnsupportedAccountEncryption -> customDialogDisplayer.displayDialog( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.crowdloan_moonbeam_encryption_not_supported_title), + message = resourceManager.getString(R.string.crowdloan_moonbeam_encryption_not_supported_message), + okAction = DialogAction.noOp(resourceManager.getString(R.string.common_ok)), + cancelAction = null + ) + ) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/main/MainFlowMoonbeamCustomization.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/main/MainFlowMoonbeamCustomization.kt new file mode 100644 index 0000000..e500b6c --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/main/MainFlowMoonbeamCustomization.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.main + +import android.os.Parcelable +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.lifecycle.LifecycleCoroutineScope +import coil.ImageLoader +import io.novafoundation.nova.common.utils.addAfter +import io.novafoundation.nova.common.utils.observeInLifecycle +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.LabeledTextView +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidation +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.ConfirmContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CrowdloanMainFlowFeatures +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.SelectContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.injectionLayoutParams + +import kotlinx.coroutines.CoroutineScope + +abstract class MainFlowMoonbeamCustomization( + private val imageLoader: ImageLoader, +) { + + protected fun injectViews( + state: MoonbeamMainFlowCustomViewState, + scope: LifecycleCoroutineScope, + injectionContainer: ViewGroup, + anchor: View, + titleView: TextView, + ) { + with(injectionContainer) { + val rewardDestinationView = LabeledTextView(context).apply { + setActionIcon(null) + + layoutParams = injectionLayoutParams(context, topMarginDp = 10) + } + + injectionContainer.addAfter( + anchor = anchor, + newViews = listOf(titleView, rewardDestinationView) + ) + + state.moonbeamRewardDestination.observeInLifecycle(scope) { + titleView.text = it.title + + rewardDestinationView.primaryIcon.setVisible(true) + rewardDestinationView.primaryIcon.loadChainIcon(it.chain.icon, imageLoader) + rewardDestinationView.setTextIcon(it.addressModel.image) + rewardDestinationView.setMessage(it.addressModel.address) + rewardDestinationView.setLabel(it.chain.name) + } + } + } +} + +class SelectContributeMoonbeamCustomization( + private val viewStateFactory: MoonbeamMainFlowCustomViewStateFactory, + imageLoader: ImageLoader, +) : MainFlowMoonbeamCustomization(imageLoader), SelectContributeCustomization { + + override fun injectViews(into: ViewGroup, state: SelectContributeCustomization.ViewState, scope: LifecycleCoroutineScope) { + require(state is MoonbeamMainFlowCustomViewState) + + injectViews( + state = state, + scope = scope, + injectionContainer = into.findViewById(R.id.crowdloanContributeScrollableContent), + anchor = into.findViewById(R.id.crowdloanContributeDescription), + titleView = TextView(into.context, null, 0, R.style.TextAppearance_NovaFoundation_Header4).apply { + layoutParams = injectionLayoutParams(context, topMarginDp = 22) + } + ) + } + + override fun createViewState(features: CrowdloanMainFlowFeatures, parachainMetadata: ParachainMetadata): SelectContributeCustomization.ViewState { + return viewStateFactory.create(features.coroutineScope, parachainMetadata) + } + + override fun modifyValidations(validations: Collection): Collection { + return validations + } +} + +class ConfirmContributeMoonbeamCustomization( + private val viewStateFactory: MoonbeamMainFlowCustomViewStateFactory, + imageLoader: ImageLoader, +) : MainFlowMoonbeamCustomization(imageLoader), ConfirmContributeCustomization { + + override fun injectViews(into: ViewGroup, state: ConfirmContributeCustomization.ViewState, scope: LifecycleCoroutineScope) { + require(state is MoonbeamMainFlowCustomViewState) + + injectViews( + state = state, + scope = scope, + injectionContainer = into.findViewById(R.id.confirmContributeInjectionParent), + anchor = into.findViewById(R.id.confirmContributeAmount), + titleView = TextView(into.context, null, 0, R.style.TextAppearance_NovaFoundation_Body1).apply { + layoutParams = injectionLayoutParams(context, topMarginDp = 12) + + setTextColorRes(R.color.text_secondary) + } + ) + } + + override fun createViewState( + coroutineScope: CoroutineScope, + parachainMetadata: ParachainMetadata, + customPayload: Parcelable?, + ): ConfirmContributeCustomization.ViewState { + return viewStateFactory.create(coroutineScope, parachainMetadata) + } + + override fun modifyValidations(validations: Collection): Collection { + return validations + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/main/MoonbeamMainFlowCustomViewState.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/main/MoonbeamMainFlowCustomViewState.kt new file mode 100644 index 0000000..1a5c39e --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/main/MoonbeamMainFlowCustomViewState.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.main + +import android.os.Parcelable +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.CrossChainRewardDestination +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.MoonbeamCrowdloanInteractor +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.ConfirmContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.SelectContributeCustomization +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn + +class MoonbeamRewardDestinationUi( + val addressModel: AddressModel, + val chain: ChainUi, + val title: String, +) + +class MoonbeamMainFlowCustomViewStateFactory( + private val interactor: MoonbeamCrowdloanInteractor, + private val resourceManager: ResourceManager, + private val iconGenerator: AddressIconGenerator, +) { + + fun create(scope: CoroutineScope, parachainMetadata: ParachainMetadata): MoonbeamMainFlowCustomViewState { + return MoonbeamMainFlowCustomViewState(scope, parachainMetadata, interactor, resourceManager, iconGenerator) + } +} + +class MoonbeamMainFlowCustomViewState( + coroutineScope: CoroutineScope, + private val parachainMetadata: ParachainMetadata, + interactor: MoonbeamCrowdloanInteractor, + private val resourceManager: ResourceManager, + private val iconGenerator: AddressIconGenerator, +) : + SelectContributeCustomization.ViewState, + ConfirmContributeCustomization.ViewState, + CoroutineScope by coroutineScope { + + val moonbeamRewardDestination = flowOf { interactor.getMoonbeamRewardDestination(parachainMetadata) } + .map(::mapMoonbeamChainDestinationToUi) + .inBackground() + .shareIn(this, started = SharingStarted.Eagerly, replay = 1) + + private suspend fun mapMoonbeamChainDestinationToUi(crossChainRewardDestination: CrossChainRewardDestination): MoonbeamRewardDestinationUi { + return MoonbeamRewardDestinationUi( + addressModel = iconGenerator.createAddressModel( + chain = crossChainRewardDestination.destination, + address = crossChainRewardDestination.addressInDestination, + sizeInDp = AddressIconGenerator.SIZE_SMALL, + accountName = null + ), + chain = mapChainToUi(crossChainRewardDestination.destination), + title = resourceManager.getString(R.string.crowdloan_moonbeam_reward_destination, parachainMetadata.token) + ) + } + + override val customizationPayloadFlow: Flow = kotlinx.coroutines.flow.flowOf(null) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/MoonbeamCrowdloanTermsFragment.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/MoonbeamCrowdloanTermsFragment.kt new file mode 100644 index 0000000..85e6675 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/MoonbeamCrowdloanTermsFragment.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.terms + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope + +import coil.ImageLoader +import coil.load +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.databinding.FragmentMoonbeamTermsBinding +import io.novafoundation.nova.feature_crowdloan_impl.di.CrowdloanFeatureComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +import javax.inject.Inject + +private const val KEY_PAYLOAD = "KEY_PAYLOAD" + +class MoonbeamCrowdloanTermsFragment : BaseFragment() { + + @Inject + lateinit var imageLoader: ImageLoader + + companion object { + + fun getBundle(payload: ContributePayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentMoonbeamTermsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.moonbeamTermsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.moonbeamTermsConfirm.prepareForProgress(viewLifecycleOwner) + binder.moonbeamTermsConfirm.setOnClickListener { viewModel.submitClicked() } + + binder.moonbeamTermsLink.setOnClickListener { viewModel.termsLinkClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + CrowdloanFeatureApi::class.java + ) + .moonbeamTermsFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: MoonbeamCrowdloanTermsViewModel) { + setupFeeLoading(viewModel, binder.moonbeamTermsFee) + observeBrowserEvents(viewModel) + observeValidations(viewModel) + + binder.moonbeamTermsLink.title.text = viewModel.termsLinkContent.title + binder.moonbeamTermsLink.icon.load(viewModel.termsLinkContent.iconUrl, imageLoader) + + binder.moonbeamTermsCheckbox.bindTo(viewModel.termsCheckedFlow, viewLifecycleOwner.lifecycleScope) + + viewModel.submitButtonState.observe { + when (it) { + is SubmitActionState.Loading -> binder.moonbeamTermsConfirm.setState(ButtonState.PROGRESS) + is SubmitActionState.Unavailable -> { + binder.moonbeamTermsConfirm.setState(ButtonState.DISABLED) + binder.moonbeamTermsConfirm.text = it.reason + } + + is SubmitActionState.Available -> { + binder.moonbeamTermsConfirm.setState(ButtonState.NORMAL) + binder.moonbeamTermsConfirm.setText(R.string.common_apply) + } + } + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/MoonbeamCrowdloanTermsViewModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/MoonbeamCrowdloanTermsViewModel.kt new file mode 100644 index 0000000..f57237d --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/MoonbeamCrowdloanTermsViewModel.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.terms + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.MoonbeamCrowdloanInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam.MoonbeamTermsPayload +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam.MoonbeamTermsValidationSystem +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.mapParachainMetadataFromParcel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.getCurrentAsset +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +sealed class SubmitActionState { + object Loading : SubmitActionState() + + object Available : SubmitActionState() + + class Unavailable(val reason: String) : SubmitActionState() +} + +class TermsLinkContent( + val title: String, + val iconUrl: String, +) + +class MoonbeamCrowdloanTermsViewModel( + private val interactor: MoonbeamCrowdloanInteractor, + val payload: ContributePayload, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val resourceManager: ResourceManager, + private val router: CrowdloanRouter, + private val assetUseCase: AssetUseCase, + private val validationExecutor: ValidationExecutor, + private val validationSystem: MoonbeamTermsValidationSystem, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper +) : BaseViewModel(), + FeeLoaderMixin by feeLoaderMixin, + Validatable by validationExecutor, + Browserable, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + init { + loadFee() + } + + val termsLinkContent = TermsLinkContent( + title = resourceManager.getString(R.string.crowdloan_terms_conditions_named, payload.parachainMetadata!!.name), + iconUrl = payload.parachainMetadata.iconLink + ) + + override val openBrowserEvent = MutableLiveData>() + + val termsCheckedFlow = MutableStateFlow(false) + + private val submittingInProgressFlow = MutableStateFlow(false) + + private val parachainMetadata = flowOf { mapParachainMetadataFromParcel(payload.parachainMetadata!!) } + .inBackground() + .share() + + val submitButtonState = combine( + termsCheckedFlow, + submittingInProgressFlow + ) { termsChecked, submitInProgress -> + when { + submitInProgress -> SubmitActionState.Loading + termsChecked -> SubmitActionState.Available + else -> SubmitActionState.Unavailable( + reason = resourceManager.getString(R.string.crowdloan_agree_with_policy) + ) + } + } + + fun backClicked() { + router.back() + } + + fun termsLinkClicked() { + openBrowserEvent.value = Event(interactor.getTermsLink()) + } + + fun submitClicked() = launch { + submittingInProgressFlow.value = true + + val fee = feeLoaderMixin.awaitFee() + submitAfterValidation(fee) + } + + private fun submitAfterValidation(fee: Fee) = launch { + val validationPayload = MoonbeamTermsPayload( + fee = fee, + asset = assetUseCase.getCurrentAsset() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + validationFailureTransformer = { moonbeamTermsValidationFailure(it, resourceManager) }, + progressConsumer = submittingInProgressFlow.progressConsumer() + ) { + submit() + } + } + + private fun submit() = launch { + interactor.submitAgreement(parachainMetadata.first()) + .onFailure(::showError) + .onSuccess { + startNavigation(it.submissionHierarchy) { router.openContribute(payload) } + } + + submittingInProgressFlow.value = false + } + + private fun loadFee() = launch { + feeLoaderMixin.loadFee( + coroutineScope = this, + feeConstructor = { interactor.calculateTermsFee() }, + onRetryCancelled = ::backClicked + ) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/MoonbeamValidationFailure.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/MoonbeamValidationFailure.kt new file mode 100644 index 0000000..9350807 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/MoonbeamValidationFailure.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.terms + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam.MoonbeamTermsValidationFailure + +fun moonbeamTermsValidationFailure( + reason: MoonbeamTermsValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage { + return when (reason) { + MoonbeamTermsValidationFailure.CANNOT_PAY_FEES -> resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.common_not_enough_funds_message) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/di/MoonbeamCrowdloanTermsComponent.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/di/MoonbeamCrowdloanTermsComponent.kt new file mode 100644 index 0000000..195540a --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/di/MoonbeamCrowdloanTermsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.terms.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.terms.MoonbeamCrowdloanTermsFragment +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload + +@Subcomponent( + modules = [ + MoonbeamCrowdloanTermsModule::class + ] +) +@ScreenScope +interface MoonbeamCrowdloanTermsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ContributePayload, + ): MoonbeamCrowdloanTermsComponent + } + + fun inject(fragment: MoonbeamCrowdloanTermsFragment) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/di/MoonbeamCrowdloanTermsModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/di/MoonbeamCrowdloanTermsModule.kt new file mode 100644 index 0000000..d60bd9b --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/moonbeam/terms/di/MoonbeamCrowdloanTermsModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.terms.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.custom.moonbeam.MoonbeamCrowdloanInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.custom.moonbeam.MoonbeamTermsValidationSystem +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.moonbeam.terms.MoonbeamCrowdloanTermsViewModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin + +@Module(includes = [ViewModelModule::class]) +class MoonbeamCrowdloanTermsModule { + + @Provides + @IntoMap + @ViewModelKey(MoonbeamCrowdloanTermsViewModel::class) + fun provideViewModel( + interactor: MoonbeamCrowdloanInteractor, + payload: ContributePayload, + feeLoaderMixin: FeeLoaderMixin.Presentation, + resourceManager: ResourceManager, + router: CrowdloanRouter, + assetUseCase: AssetUseCase, + validationSystem: MoonbeamTermsValidationSystem, + validationExecutor: ValidationExecutor, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + ): ViewModel { + return MoonbeamCrowdloanTermsViewModel( + interactor, + payload, + feeLoaderMixin, + resourceManager, + router, + assetUseCase, + validationExecutor, + validationSystem, + extrinsicNavigationWrapper + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): MoonbeamCrowdloanTermsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(MoonbeamCrowdloanTermsViewModel::class.java) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/referral/ReferralCodePayload.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/referral/ReferralCodePayload.kt new file mode 100644 index 0000000..fa1eb87 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/referral/ReferralCodePayload.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral + +import io.novafoundation.nova.common.utils.formatting.formatAsPercentage +import io.novafoundation.nova.common.utils.fractionToPercentage +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +interface ReferralCodePayload : BonusPayload { + + val referralCode: String +} + +@Parcelize +class DefaultReferralCodePayload( + override val referralCode: String, + private val referralBonus: BigDecimal, + private val rewardTokenSymbol: String, + private val rewardRate: BigDecimal?, +) : ReferralCodePayload { + + override fun bonusText(amount: BigDecimal): String { + return if (rewardRate == null) { + referralBonus.fractionToPercentage().formatAsPercentage() + } else { + val bonusReward = amount * rewardRate * referralBonus + + bonusReward.formatTokenAmount(rewardTokenSymbol) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/referral/ReferralContributeView.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/referral/ReferralContributeView.kt new file mode 100644 index 0000000..1cef842 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/referral/ReferralContributeView.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral + +import android.content.Context +import android.text.method.LinkMovementMethod +import android.util.AttributeSet +import androidx.lifecycle.LifecycleCoroutineScope +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.styleText +import io.novafoundation.nova.common.utils.observe +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.showBrowser +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.databinding.ViewReferralFlowBinding +import io.novafoundation.nova.feature_crowdloan_impl.di.CrowdloanFeatureComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeView +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeViewState + +import javax.inject.Inject + +class ReferralContributeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : CustomContributeView(context, attrs, defStyle) { + + @Inject + lateinit var imageLoader: ImageLoader + + private val binder = ViewReferralFlowBinding.inflate(inflater(), this) + + init { + FeatureUtils.getFeature( + context, + CrowdloanFeatureApi::class.java + ).inject(this) + + binder.referralPrivacyText.movementMethod = LinkMovementMethod.getInstance() + } + + override fun bind( + viewState: CustomContributeViewState, + scope: LifecycleCoroutineScope + ) { + require(viewState is ReferralContributeViewState) + + binder.referralReferralCodeInput.content.bindTo(viewState.enteredReferralCodeFlow, scope) + binder.referralPrivacySwitch.bindTo(viewState.privacyAcceptedFlow, scope) + + binder.referralNovaBonusTitle.text = viewState.applyNovaTitle + + viewState.applyNovaCodeEnabledFlow.observe(scope) { enabled -> + binder.referralNovaBonusApply.isEnabled = enabled + + val applyBonusButtonText = if (enabled) R.string.common_apply else R.string.common_applied + binder.referralNovaBonusApply.setText(applyBonusButtonText) + } + + viewState.bonusFlow.observe(scope) { bonus -> + binder.referralBonus.setVisible(bonus != null) + + binder.referralBonus.showValue(bonus) + } + + with(viewState.learnBonusesTitle) { + binder.referralLearnMore.loadIcon(iconLink, imageLoader) + binder.referralLearnMore.title.text = text + + binder.referralLearnMore.setOnClickListener { viewState.learnMoreClicked() } + } + + binder.referralNovaBonusApply.setOnClickListener { viewState.applyNovaCode() } + + binder.referralPrivacyText.text = styleText(context.getString(R.string.onboarding_terms_and_conditions_1_v2_2_1)) { + clickable(context.getString(R.string.onboarding_terms_and_conditions_2)) { + viewState.termsClicked() + } + } + + viewState.openBrowserFlow.observe(scope) { + context.showBrowser(it) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/referral/ReferralContributeViewState.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/referral/ReferralContributeViewState.kt new file mode 100644 index 0000000..99c5ea5 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/custom/referral/ReferralContributeViewState.kt @@ -0,0 +1,101 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.referral + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.formatAsPercentage +import io.novafoundation.nova.common.utils.fractionToPercentage +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.ApplyActionState +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CustomContributeViewState +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.model.LearnMoreModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.math.BigDecimal + +abstract class ReferralContributeViewState( + protected val customContributePayload: CustomContributePayload, + protected val resourceManager: ResourceManager, + private val defaultReferralCode: String, + private val bonusPercentage: BigDecimal, + private val termsUrl: String = customContributePayload.parachainMetadata.website, + private val learnMoreUrl: String = customContributePayload.parachainMetadata.website, +) : CustomContributeViewState { + + abstract fun createBonusPayload(referralCode: String): ReferralCodePayload + + abstract suspend fun validatePayload(payload: ReferralCodePayload) + + private val _openBrowserFlow = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val openBrowserFlow: Flow = _openBrowserFlow + + val enteredReferralCodeFlow = MutableStateFlow("") + + val privacyAcceptedFlow = MutableStateFlow(false) + + val applyNovaTitle = createNovaBonusTitle() + + val applyNovaCodeEnabledFlow = enteredReferralCodeFlow.map { + it != defaultReferralCode + } + + val learnBonusesTitle = LearnMoreModel( + iconLink = customContributePayload.parachainMetadata.iconLink, + text = resourceManager.getString(R.string.crowdloan_learn_v2_2_0, customContributePayload.parachainMetadata.name) + ) + + private val bonusPayloadFlow = enteredReferralCodeFlow.map { + createBonusPayload(it) + } + + val bonusFlow = bonusPayloadFlow.map { + it.bonusText(customContributePayload.amount) + } + + init { + previousPayload()?.let { + enteredReferralCodeFlow.value = it.referralCode + privacyAcceptedFlow.value = true + } + } + + fun applyNovaCode() { + enteredReferralCodeFlow.value = defaultReferralCode + } + + fun termsClicked() { + _openBrowserFlow.tryEmit(termsUrl) + } + + fun learnMoreClicked() { + _openBrowserFlow.tryEmit(learnMoreUrl) + } + + override val applyActionState = enteredReferralCodeFlow.combine(privacyAcceptedFlow) { referral, privacyAccepted -> + when { + referral.isEmpty() -> ApplyActionState.Unavailable(reason = resourceManager.getString(R.string.crowdloan_enter_referral)) + privacyAccepted.not() -> ApplyActionState.Unavailable(reason = resourceManager.getString(R.string.crowdloan_agree_with_policy)) + else -> ApplyActionState.Available + } + } + + override suspend fun generatePayload(): Result = runCatching { + val payload = bonusPayloadFlow.first() + + validatePayload(payload) + + payload + } + + private fun createNovaBonusTitle(): String { + val percentage = bonusPercentage.fractionToPercentage().formatAsPercentage() + + return resourceManager.getString(R.string.crowdloan_app_bonus_format, percentage) + } + + protected fun previousPayload() = customContributePayload.previousBonusPayload as? ReferralCodePayload +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/CrowdloanContributeFragment.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/CrowdloanContributeFragment.kt new file mode 100644 index 0000000..99ab873 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/CrowdloanContributeFragment.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.presentation.masking.dataOrNull +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.databinding.FragmentContributeBinding +import io.novafoundation.nova.feature_crowdloan_impl.di.CrowdloanFeatureComponent +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload + +import kotlinx.coroutines.flow.filterNotNull +import javax.inject.Inject + +private const val KEY_PAYLOAD = "KEY_PAYLOAD" + +class CrowdloanContributeFragment : BaseFragment() { + + @Inject + lateinit var imageLoader: ImageLoader + + companion object { + + const val KEY_BONUS_LIVE_DATA = "KEY_BONUS_LIVE_DATA" + + fun getBundle(payload: ContributePayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentContributeBinding.inflate(layoutInflater) + + override fun initViews() { + binder.crowdloanContributeToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.crowdloanContributeContinue.prepareForProgress(viewLifecycleOwner) + binder.crowdloanContributeContinue.setOnClickListener { viewModel.nextClicked() } + + binder.crowdloanContributeLearnMore.setOnClickListener { viewModel.learnMoreClicked() } + + binder.crowdloanContributeBonus.setOnClickListener { viewModel.bonusClicked() } + } + + override fun inject() { + val payload = argument(KEY_PAYLOAD) + + FeatureUtils.getFeature( + requireContext(), + CrowdloanFeatureApi::class.java + ) + .selectContributeFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: CrowdloanContributeViewModel) { + observeRetries(viewModel) + observeBrowserEvents(viewModel) + observeValidations(viewModel) + + viewModel.showNextProgress.observe(binder.crowdloanContributeContinue::setProgressState) + + viewModel.assetModelFlow.observe { + // Very rude way to set balance but we not needed to refactor it since we don't support this feature + binder.crowdloanContributeAmount.setAssetBalance(it.assetBalance.dataOrNull() ?: "") + binder.crowdloanContributeAmount.setAssetName(it.tokenSymbol) + binder.crowdloanContributeAmount.loadAssetImage(it.icon) + } + + binder.crowdloanContributeAmount.amountInput.bindTo(viewModel.enteredAmountFlow, lifecycleScope) + + viewModel.enteredFiatAmountFlow.observe { + it.let(binder.crowdloanContributeAmount::setFiatAmount) + } + + viewModel.feeLiveData.observe(binder.crowdloanContributeFee::setFeeStatus) + + viewModel.estimatedRewardFlow.observe { reward -> + binder.crowdloanContributeReward.setVisible(reward != null) + + reward?.let { + binder.crowdloanContributeReward.showValue(reward) + } + } + + viewModel.unlockHintFlow.observe(binder.crowdloanContributeUnlockHint::setText) + + viewModel.crowdloanDetailModelFlow.observe { + binder.crowdloanContributeLeasingPeriod.showValue(it.leasePeriod, it.leasedUntil) + } + + binder.crowdloanContributeToolbar.setTitle(viewModel.title) + + binder.crowdloanContributeLearnMore.setVisible(viewModel.learnCrowdloanModel != null) + + viewModel.learnCrowdloanModel?.let { + binder.crowdloanContributeLearnMore.title.text = it.text + binder.crowdloanContributeLearnMore.loadIcon(it.iconLink, imageLoader) + } + + viewModel.bonusDisplayFlow.observe { + binder.crowdloanContributeBonus.setVisible(it != null) + + binder.crowdloanContributeBonusReward.text = it + } + + viewModel.customizationConfiguration.filterNotNull().observe { (customization, customViewState) -> + customization.injectViews(binder.crowdloanContributeContainer, customViewState, viewLifecycleOwner.lifecycleScope) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/CrowdloanContributeViewModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/CrowdloanContributeViewModel.kt new file mode 100644 index 0000000..da64507 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/CrowdloanContributeViewModel.kt @@ -0,0 +1,347 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select + +import android.os.Parcelable +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.api.of +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.hasExtraBonusFlow +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.CrowdloanContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidation +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidationPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.confirm.parcel.ConfirmContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.contributeValidationFailure +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.BonusPayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.CrowdloanMainFlowFeatures +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.SelectContributeCustomization +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.custom.model.CustomContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.model.CrowdloanDetailsModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.model.LearnMoreModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.mapParachainMetadataFromParcel +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapAssetToAssetModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import java.math.BigDecimal +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime + +private const val DEBOUNCE_DURATION_MILLIS = 500 + +sealed class ExtraBonusState { + + object NotSupported : ExtraBonusState() + + class Active(val customFlow: String, val payload: BonusPayload, val tokenName: String) + + object Inactive : ExtraBonusState() +} + +class CrowdloanContributeViewModel( + private val assetIconProvider: AssetIconProvider, + private val router: CrowdloanRouter, + private val contributionInteractor: CrowdloanContributeInteractor, + private val resourceManager: ResourceManager, + assetUseCase: AssetUseCase, + private val validationExecutor: ValidationExecutor, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val payload: ContributePayload, + private val validations: Set, + private val customContributeManager: CustomContributeManager, +) : BaseViewModel(), + Validatable by validationExecutor, + Browserable, + FeeLoaderMixin by feeLoaderMixin { + + override val openBrowserEvent = MutableLiveData>() + + private val parachainMetadata = payload.parachainMetadata?.let(::mapParachainMetadataFromParcel) + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + private val assetFlow = assetUseCase.currentAssetFlow() + .inBackground() + .share() + + val assetModelFlow = assetFlow + .map { mapAssetToAssetModel(assetIconProvider, it, resourceManager, MaskableModel.Unmasked(it.transferableInPlanks)) } + .inBackground() + .share() + + private val relevantCustomFlowFactory = payload.parachainMetadata?.customFlow?.let { + customContributeManager.getFactoryOrNull(it) + } + + val customizationConfiguration: Flow?> = flowOf { + relevantCustomFlowFactory?.selectContributeCustomization?.let { + it to it.createViewState( + features = CrowdloanMainFlowFeatures( + coroutineScope = this, + browserable = Browserable.Presentation.of(openBrowserEvent) + ), + parachainMetadata = parachainMetadata!! + ) + } + } + .inBackground() + .share() + + private val customizedValidationSystem = flowOf { + val validations = relevantCustomFlowFactory?.selectContributeCustomization?.modifyValidations(validations) + ?: validations + + ValidationSystem(CompositeValidation(validations)) + } + .inBackground() + .share() + + private val customizationPayloadFlow: Flow = customizationConfiguration.flatMapLatest { + it?.let { (_, viewState) -> viewState.customizationPayloadFlow } + ?: kotlinx.coroutines.flow.flowOf(null) + } + + val enteredAmountFlow = MutableStateFlow("") + + private val parsedAmountFlow = enteredAmountFlow.mapNotNull { it.toBigDecimalOrNull() ?: BigDecimal.ZERO } + + private val extraBonusFlow = flow { + val customFlow = payload.parachainMetadata?.customFlow + + if ( + customFlow != null && + customContributeManager.hasExtraBonusFlow(customFlow) + ) { + emit(ExtraBonusState.Inactive) + + val source = router.customBonusFlow.map { + if (it != null) { + ExtraBonusState.Active(customFlow, it, parachainMetadata!!.token) + } else { + ExtraBonusState.Inactive + } + } + + emitAll(source) + } else { + emit(ExtraBonusState.NotSupported) + } + } + .share() + + val bonusDisplayFlow = combine( + extraBonusFlow, + parsedAmountFlow + ) { contributionState, amount -> + when (contributionState) { + is ExtraBonusState.Active -> { + contributionState.payload.bonusText(amount) + } + + is ExtraBonusState.Inactive -> resourceManager.getString(R.string.crowdloan_empty_bonus_title) + + else -> null + } + } + .inBackground() + .share() + + val unlockHintFlow = assetFlow.map { + resourceManager.getString(R.string.crowdloan_unlock_hint, it.token.configuration.symbol) + } + .inBackground() + .share() + + val enteredFiatAmountFlow = assetFlow.combine(parsedAmountFlow) { asset, amount -> + asset.token.amountToFiat(amount).formatAsCurrency(asset.token.currency) + } + .inBackground() + .asLiveData() + + val title = payload.parachainMetadata?.let { + "${it.name} (${it.token})" + } ?: payload.paraId.toString() + + val learnCrowdloanModel = payload.parachainMetadata?.let { + LearnMoreModel( + text = resourceManager.getString(R.string.crowdloan_learn_v2_2_0, it.name), + iconLink = it.iconLink + ) + } + + val estimatedRewardFlow = parsedAmountFlow.map { amount -> + payload.parachainMetadata?.let { metadata -> + val estimatedReward = metadata.rewardRate?.let { amount * it } + + estimatedReward?.formatTokenAmount(metadata.token) + } + }.share() + + private val crowdloanFlow = contributionInteractor.crowdloanStateFlow(payload.paraId, parachainMetadata) + .inBackground() + .share() + + val crowdloanDetailModelFlow = crowdloanFlow.map { crowdloan -> + CrowdloanDetailsModel( + leasePeriod = resourceManager.formatDuration(crowdloan.leasePeriodInMillis), + leasedUntil = resourceManager.formatDateTime(crowdloan.leasedUntilInMillis) + ) + } + .inBackground() + .share() + + init { + listenFee() + } + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun bonusClicked() { + launch { + val customContributePayload = CustomContributePayload( + paraId = payload.paraId, + parachainMetadata = payload.parachainMetadata!!, + amount = parsedAmountFlow.first(), + previousBonusPayload = router.latestCustomBonus + ) + + router.openCustomContribute(customContributePayload) + } + } + + @OptIn(ExperimentalTime::class) + private fun listenFee() { + combine( + parsedAmountFlow.debounce(DEBOUNCE_DURATION_MILLIS.milliseconds), + extraBonusFlow, + customizationPayloadFlow, + ::Triple + ).mapLatest { (amount, bonusState, customization) -> + loadFee(amount, bonusState as? ExtraBonusState.Active, customization) + } + .launchIn(viewModelScope) + } + + private suspend fun loadFee( + amount: BigDecimal, + bonusActiveState: ExtraBonusState.Active?, + customizationPayload: Parcelable?, + ) { + feeLoaderMixin.loadFeeSuspending( + retryScope = viewModelScope, + feeConstructor = { + val crowdloan = crowdloanFlow.first() + + contributionInteractor.estimateFee( + crowdloan, + amount, + bonusActiveState?.payload, + customizationPayload, + ) + }, + onRetryCancelled = ::backClicked + ) + } + + private fun maybeGoToNext() = launch { + _showNextProgress.value = true + + val contributionAmount = parsedAmountFlow.firstOrNull() ?: return@launch + + val customizationPayload = customizationConfiguration.first()?.let { + val (_, customViewState) = it + + customViewState.customizationPayloadFlow.first() + } + + val validationPayload = ContributeValidationPayload( + crowdloan = crowdloanFlow.first(), + customizationPayload = customizationPayload, + fee = feeLoaderMixin.awaitFee(), + asset = assetFlow.first(), + bonusPayload = router.latestCustomBonus, + contributionAmount = contributionAmount + ) + + validationExecutor.requireValid( + validationSystem = customizedValidationSystem.first(), + payload = validationPayload, + validationFailureTransformerCustom = { status, actions -> + contributeValidationFailure( + reason = status.reason, + validationFlowActions = actions, + resourceManager = resourceManager, + onOpenCustomContribute = ::bonusClicked + ) + }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + _showNextProgress.value = false + + openConfirmScreen(it, customizationPayload) + } + } + + private fun openConfirmScreen( + validationPayload: ContributeValidationPayload, + customizationPayload: Parcelable?, + ) = launch { + val confirmContributePayload = ConfirmContributePayload( + paraId = payload.paraId, + fee = mapFeeToParcel(validationPayload.fee), + amount = validationPayload.contributionAmount, + estimatedRewardDisplay = estimatedRewardFlow.first(), + bonusPayload = router.latestCustomBonus, + metadata = payload.parachainMetadata, + customizationPayload = customizationPayload + ) + + router.openConfirmContribute(confirmContributePayload) + } + + fun learnMoreClicked() { + val parachainLink = parachainMetadata?.website ?: return + + openBrowserEvent.value = Event(parachainLink) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/di/CrowdloanContributeComponent.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/di/CrowdloanContributeComponent.kt new file mode 100644 index 0000000..5c386f1 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/di/CrowdloanContributeComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.CrowdloanContributeFragment +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload + +@Subcomponent( + modules = [ + CrowdloanContributeModule::class + ] +) +@ScreenScope +interface CrowdloanContributeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ContributePayload + ): CrowdloanContributeComponent + } + + fun inject(fragment: CrowdloanContributeFragment) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/di/CrowdloanContributeModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/di/CrowdloanContributeModule.kt new file mode 100644 index 0000000..19d820c --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/di/CrowdloanContributeModule.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.di.validations.Select +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.CrowdloanContributeInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.contribute.validations.ContributeValidation +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.CrowdloanContributeViewModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin + +@Module(includes = [ViewModelModule::class]) +class CrowdloanContributeModule { + + @Provides + @IntoMap + @ViewModelKey(CrowdloanContributeViewModel::class) + fun provideViewModel( + assetIconProvider: AssetIconProvider, + interactor: CrowdloanContributeInteractor, + router: CrowdloanRouter, + resourceManager: ResourceManager, + assetUseCase: AssetUseCase, + validationExecutor: ValidationExecutor, + feeLoaderMixin: FeeLoaderMixin.Presentation, + payload: ContributePayload, + @Select contributeValidations: @JvmSuppressWildcards Set, + customContributeManager: CustomContributeManager, + ): ViewModel { + return CrowdloanContributeViewModel( + assetIconProvider, + router, + interactor, + resourceManager, + assetUseCase, + validationExecutor, + feeLoaderMixin, + payload, + contributeValidations, + customContributeManager + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): CrowdloanContributeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(CrowdloanContributeViewModel::class.java) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/model/CrowdloanDetailsModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/model/CrowdloanDetailsModel.kt new file mode 100644 index 0000000..d642b19 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/model/CrowdloanDetailsModel.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.model + +class CrowdloanDetailsModel( + val leasePeriod: String, + val leasedUntil: String, +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/model/LearnMoreModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/model/LearnMoreModel.kt new file mode 100644 index 0000000..9aca660 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/model/LearnMoreModel.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.model + +class LearnMoreModel( + val iconLink: String, + val text: String +) diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/parcel/ContributePayload.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/parcel/ContributePayload.kt new file mode 100644 index 0000000..6ee62b0 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/parcel/ContributePayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel + +import android.os.Parcelable +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import kotlinx.parcelize.Parcelize + +@Parcelize +class ContributePayload( + val paraId: ParaId, + val parachainMetadata: ParachainMetadataParcelModel? +) : Parcelable diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/parcel/ParachainMetadataParcelModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/parcel/ParachainMetadataParcelModel.kt new file mode 100644 index 0000000..36c2793 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contribute/select/parcel/ParachainMetadataParcelModel.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel + +import android.os.Parcelable +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal +import java.math.BigInteger + +@Parcelize +class ParachainMetadataParcelModel( + val paraId: BigInteger, + val movedToParaId: BigInteger?, + val iconLink: String, + val name: String, + val description: String, + val rewardRate: BigDecimal?, + val website: String, + val customFlow: String?, + val token: String, + val extras: Map, +) : Parcelable + +fun mapParachainMetadataToParcel( + parachainMetadata: ParachainMetadata, +) = with(parachainMetadata) { + ParachainMetadataParcelModel( + paraId = paraId, + movedToParaId = movedToParaId, + iconLink = iconLink, + name = name, + description = description, + rewardRate = rewardRate, + website = website, + token = token, + customFlow = customFlow, + extras = parachainMetadata.extras + ) +} + +fun mapParachainMetadataFromParcel( + parcelModel: ParachainMetadataParcelModel, +) = with(parcelModel) { + ParachainMetadata( + paraId = paraId, + movedToParaId = movedToParaId, + iconLink = iconLink, + name = name, + description = description, + rewardRate = rewardRate, + customFlow = customFlow, + website = website, + token = token, + extras = extras + ) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/TotalContributionsHeaderAdapter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/TotalContributionsHeaderAdapter.kt new file mode 100644 index 0000000..b58ebd3 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/TotalContributionsHeaderAdapter.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.databinding.ItemContributionsHeaderBinding +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class TotalContributionsHeaderAdapter : RecyclerView.Adapter() { + + private var amount: AmountModel? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderHolder { + return HeaderHolder(ItemContributionsHeaderBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: HeaderHolder, position: Int) { + holder.bind(amount) + } + + override fun getItemCount(): Int { + return 1 + } + + fun setAmount(amountModel: AmountModel) { + this.amount = amountModel + notifyItemChanged(0, true) + } + + inner class HeaderHolder(private val binder: ItemContributionsHeaderBinding) : RecyclerView.ViewHolder(binder.root) { + init { + binder.root.background = binder.root.context.getRoundedCornerDrawable(R.color.block_background) + } + + fun bind(amount: AmountModel?) { + binder.totalContributedAmount.setAmount(amount) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/UserContributionsAdapter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/UserContributionsAdapter.kt new file mode 100644 index 0000000..4292bb2 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/UserContributionsAdapter.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.recyclerview.item.OperationListItem +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.common.view.stopTimer +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions.model.ContributionModel +import kotlinx.android.extensions.LayoutContainer + +class UserContributionsAdapter( + private val imageLoader: ImageLoader, +) : ListAdapter(ContributionCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContributionHolder { + return ContributionHolder(imageLoader, OperationListItem(parent.context)) + } + + override fun onBindViewHolder(holder: ContributionHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: ContributionHolder, position: Int, payloads: MutableList) { + resolvePayload(holder, position, payloads) { + when (it) { + ContributionModel::amount -> holder.bindAmount(getItem(position)) + } + } + } + + override fun onViewRecycled(holder: ContributionHolder) { + holder.unbind() + } +} + +private object ContributionPayloadGenerator : PayloadGenerator( + ContributionModel::amount +) + +private object ContributionCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ContributionModel, newItem: ContributionModel): Boolean { + return oldItem.title == newItem.title + } + + override fun areContentsTheSame(oldItem: ContributionModel, newItem: ContributionModel): Boolean { + return true + } + + override fun getChangePayload(oldItem: ContributionModel, newItem: ContributionModel): Any? { + return ContributionPayloadGenerator.diff(oldItem, newItem) + } +} + +class ContributionHolder( + private val imageLoader: ImageLoader, + override val containerView: OperationListItem, +) : RecyclerView.ViewHolder(containerView), LayoutContainer { + + init { + containerView.setIconStyle(OperationListItem.IconStyle.DEFAULT) + } + + fun bind( + item: ContributionModel, + ) = with(containerView) { + icon.setIcon(item.icon, imageLoader) + + header.text = item.title + + subHeader.setTextColorRes(item.claimStatusColorRes) + when (val status = item.claimStatus) { + is ContributionModel.ClaimStatus.Text -> { + subHeader.stopTimer() + subHeader.text = status.text + } + + is ContributionModel.ClaimStatus.Timer -> subHeader.startTimer( + value = status.timer, + customMessageFormat = R.string.crowdloan_contributions_returns_in, + onFinish = { subHeader.setText(R.string.referendum_unlock_unlockable) } + ) + } + + bindAmount(item) + } + + fun unbind() { + containerView.icon.clear() + } + + fun bindAmount(item: ContributionModel) { + containerView.valuePrimary.text = item.amount.token + containerView.valueSecondary.setTextOrHide(item.amount.fiat) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/UserContributionsFragment.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/UserContributionsFragment.kt new file mode 100644 index 0000000..ff1c946 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/UserContributionsFragment.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions + +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.databinding.FragmentMyContributionsBinding +import io.novafoundation.nova.feature_crowdloan_impl.di.CrowdloanFeatureComponent + +import javax.inject.Inject + +class UserContributionsFragment : BaseFragment() { + + override fun createBinding() = FragmentMyContributionsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val headerAdapter = TotalContributionsHeaderAdapter() + + private val listAdapter by lazy(LazyThreadSafetyMode.NONE) { + UserContributionsAdapter(imageLoader) + } + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + ConcatAdapter(headerAdapter, listAdapter) + } + + override fun initViews() { + binder.myContributionsList.adapter = adapter + binder.myContributionsList.setHasFixedSize(true) + + binder.myContributionsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.myContributionsClaim.setOnClickListener { viewModel.claimClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + CrowdloanFeatureApi::class.java + ) + .userContributionsFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: UserContributionsViewModel) { + viewModel.totalContributedAmountFlow.observe { + headerAdapter.setAmount(it) + } + + viewModel.contributionModelsFlow.observe { loadingState -> + binder.myContributionsList.setVisible(loadingState is LoadingState.Loaded && loadingState.data.isNotEmpty()) + binder.myContributionsPlaceholder.setVisible(loadingState is LoadingState.Loaded && loadingState.data.isEmpty()) + binder.myContributionsProgress.setVisible(loadingState is LoadingState.Loading) + + if (loadingState is LoadingState.Loaded) { + listAdapter.submitList(loadingState.data) + } + } + + viewModel.claimContributionsVisible.observe(binder.myContributionsClaim::setVisible) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/UserContributionsViewModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/UserContributionsViewModel.kt new file mode 100644 index 0000000..c55b182 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/UserContributionsViewModel.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.capitalize +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.Contribution +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionClaimStatus +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionWithMetadata +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions.model.ContributionModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.model.generateCrowdloanIcon +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.SingleAssetSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +class UserContributionsViewModel( + private val interactor: ContributionsInteractor, + private val iconGenerator: AddressIconGenerator, + private val selectedAssetState: SingleAssetSharedState, + private val resourceManager: ResourceManager, + private val router: CrowdloanRouter, + private val tokenUseCase: TokenUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel() { + + private val tokenFlow = tokenUseCase.currentTokenFlow() + .shareInBackground() + + private val contributionsWitTotalAmountFlow = interactor.observeSelectedChainContributionsWithMetadata() + .shareInBackground() + + private val contributionsFlow = contributionsWitTotalAmountFlow + .map { it.contributions } + .shareInBackground() + + val contributionModelsFlow = combine(tokenFlow, contributionsFlow) { token, contributions -> + val chain = selectedAssetState.chain() + contributions.map { mapCrowdloanToContributionModel(it, chain, token) } + } + .withLoading() + .shareInBackground() + + val totalContributedAmountFlow = combine(contributionsWitTotalAmountFlow, tokenFlow) { contributionsWitTotalAmount, token -> + amountFormatter.formatAmountToAmountModel(contributionsWitTotalAmount.totalContributed, token) + } + .shareInBackground() + + val claimContributionsVisible = contributionsWitTotalAmountFlow.map { + it.contributions.any { it.metadata.claimStatus is ContributionClaimStatus.Claimable } + } + .onStart { emit(false) } + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun claimClicked() { + router.openClaimContribution() + } + + private suspend fun mapCrowdloanToContributionModel( + contributionWithMetadata: ContributionWithMetadata, + chain: Chain, + token: Token, + ): ContributionModel { + val depositorAddress = chain.addressOf(contributionWithMetadata.contribution.leaseDepositor) + val contributionTitle = mapContributionTitle(contributionWithMetadata) + + val claimStatus: ContributionModel.ClaimStatus + val claimStatusColorRes: Int + + when (val status = contributionWithMetadata.metadata.claimStatus) { + ContributionClaimStatus.Claimable -> { + claimStatus = ContributionModel.ClaimStatus.Text(resourceManager.getString(R.string.referendum_unlock_unlockable)) + claimStatusColorRes = R.color.text_positive + } + is ContributionClaimStatus.ReturnsIn -> { + claimStatus = ContributionModel.ClaimStatus.Timer(status.timer) + claimStatusColorRes = R.color.text_secondary + } + } + + return ContributionModel( + title = contributionTitle, + icon = generateCrowdloanIcon(contributionWithMetadata.metadata.parachainMetadata, depositorAddress, iconGenerator), + amount = amountFormatter.formatAmountToAmountModel(contributionWithMetadata.contribution.amountInPlanks, token), + claimStatus = claimStatus, + claimStatusColorRes = claimStatusColorRes + ) + } + + private fun mapContributionTitle(contributionWithMetadata: ContributionWithMetadata): String { + val parachainName = contributionWithMetadata.metadata.parachainMetadata?.name + ?: contributionWithMetadata.contribution.paraId.toString() + + val sourceName = when (contributionWithMetadata.contribution.sourceId) { + Contribution.DIRECT_SOURCE_ID -> null + Contribution.LIQUID_SOURCE_ID -> resourceManager.getString(R.string.crowdloan_contributions_liquid_source) + Contribution.PARALLEL_SOURCE_ID -> resourceManager.getString(R.string.crowdloan_contributions_parallel_source) + else -> contributionWithMetadata.contribution.sourceId.capitalize() + } + + return if (sourceName == null) { + parachainName + } else { + resourceManager.getString( + R.string.crowdloan_contributions_with_source, + parachainName, + sourceName + ) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/di/UserContributionsComponent.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/di/UserContributionsComponent.kt new file mode 100644 index 0000000..de1809c --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/di/UserContributionsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions.UserContributionsFragment + +@Subcomponent( + modules = [ + UserContributionsModule::class + ] +) +@ScreenScope +interface UserContributionsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): UserContributionsComponent + } + + fun inject(fragment: UserContributionsFragment) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/di/UserContributionsModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/di/UserContributionsModule.kt new file mode 100644 index 0000000..222b40d --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/di/UserContributionsModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions.UserContributionsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class UserContributionsModule { + + @Provides + @IntoMap + @ViewModelKey(UserContributionsViewModel::class) + fun provideViewModel( + interactor: ContributionsInteractor, + iconGenerator: AddressIconGenerator, + crowdloanSharedState: CrowdloanSharedState, + resourceManager: ResourceManager, + router: CrowdloanRouter, + tokenUseCase: TokenUseCase, + amountFormatter: AmountFormatter + ): ViewModel { + return UserContributionsViewModel( + interactor, + iconGenerator, + crowdloanSharedState, + resourceManager, + router, + tokenUseCase, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): UserContributionsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(UserContributionsViewModel::class.java) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/model/ContributionModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/model/ContributionModel.kt new file mode 100644 index 0000000..b463c94 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/contributions/model/ContributionModel.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.contributions.model + +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +data class ContributionModel( + val title: String, + val amount: AmountModel, + val icon: Icon, + val claimStatus: ClaimStatus, + @ColorRes val claimStatusColorRes: Int +) { + + sealed class ClaimStatus { + + class Timer(val timer: TimerValue) : ClaimStatus() + + class Text(val text: String) : ClaimStatus() + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanAdapter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanAdapter.kt new file mode 100644 index 0000000..fc8b8f5 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanAdapter.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.main + +import android.view.ViewGroup +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.databinding.ItemCrowdloanBinding +import io.novafoundation.nova.feature_crowdloan_impl.databinding.ItemCrowdloanGroupBinding +import io.novafoundation.nova.feature_crowdloan_impl.presentation.main.model.CrowdloanModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.main.model.CrowdloanStatusModel + +class CrowdloanAdapter( + private val imageLoader: ImageLoader, + private val handler: Handler, +) : GroupedListAdapter(CrowdloanDiffCallback) { + + interface Handler { + + fun crowdloanClicked(paraId: ParaId) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return CrowdloanGroupHolder(ItemCrowdloanGroupBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return CrowdloanChildHolder(imageLoader, ItemCrowdloanBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: CrowdloanStatusModel) { + (holder as CrowdloanGroupHolder).bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: CrowdloanModel) { + (holder as CrowdloanChildHolder).bind(child, handler) + } + + override fun bindChild(holder: GroupedListHolder, position: Int, child: CrowdloanModel, payloads: List) { + resolvePayload(holder, position, payloads) { + when (it) { + CrowdloanModel::state -> (holder as CrowdloanChildHolder).bindState(child, handler) + CrowdloanModel::raised -> (holder as CrowdloanChildHolder).bindRaised(child) + } + } + } +} + +private object CrowdloanDiffCallback : BaseGroupedDiffCallback(CrowdloanStatusModel::class.java) { + + override fun getChildChangePayload(oldItem: CrowdloanModel, newItem: CrowdloanModel): Any? { + return CrowdloanPayloadGenerator.diff(oldItem, newItem) + } + + override fun areGroupItemsTheSame(oldItem: CrowdloanStatusModel, newItem: CrowdloanStatusModel): Boolean { + return oldItem == newItem + } + + override fun areGroupContentsTheSame(oldItem: CrowdloanStatusModel, newItem: CrowdloanStatusModel): Boolean { + return true + } + + override fun areChildItemsTheSame(oldItem: CrowdloanModel, newItem: CrowdloanModel): Boolean { + return oldItem.parachainId == newItem.parachainId && oldItem.relaychainId == newItem.relaychainId + } + + override fun areChildContentsTheSame(oldItem: CrowdloanModel, newItem: CrowdloanModel): Boolean { + return oldItem.relaychainId == newItem.relaychainId && + oldItem.parachainId == newItem.parachainId && + oldItem.title == newItem.title && + oldItem.description == newItem.description && + oldItem.raised == newItem.raised && + oldItem.state == newItem.state + } +} + +private object CrowdloanPayloadGenerator : PayloadGenerator( + CrowdloanModel::state, + CrowdloanModel::raised +) + +private class CrowdloanGroupHolder(private val binder: ItemCrowdloanGroupBinding) : GroupedListHolder(binder.root) { + + fun bind(item: CrowdloanStatusModel) = with(binder) { + itemCrowdloanGroupStatus.text = item.status + itemCrowdloanGroupCounter.text = item.count + } +} + +private class CrowdloanChildHolder( + private val imageLoader: ImageLoader, + private val binder: ItemCrowdloanBinding, +) : GroupedListHolder(binder.root) { + + init { + with(containerView.context) { + containerView.background = addRipple(getBlockDrawable()) + } + } + + fun bind( + item: CrowdloanModel, + handler: CrowdloanAdapter.Handler, + ) = with(binder) { + itemCrowdloanParaDescription.text = item.description + itemCrowdloanParaName.text = item.title + + bindRaised(item) + + itemCrowdloanIcon.setIcon(item.icon, imageLoader) + + bindState(item, handler) + } + + fun bindState(item: CrowdloanModel, handler: CrowdloanAdapter.Handler) = with(binder) { + if (item.state is CrowdloanModel.State.Active) { + itemCrowdloanTimeRemaining.makeVisible() + itemCrowdloanTimeRemaining.text = item.state.timeRemaining + + itemCrowdloanParaName.setTextColorRes(R.color.text_primary) + itemCrowdloanParaRaisedPercentage.setTextColorRes(R.color.progress_bar_text) + + itemCrowdloanArrow.makeVisible() + + root.setOnClickListener { handler.crowdloanClicked(item.parachainId) } + + itemCrowdloanParaRaisedProgress.isEnabled = true + } else { + itemCrowdloanTimeRemaining.makeGone() + itemCrowdloanArrow.makeGone() + + itemCrowdloanParaName.setTextColorRes(R.color.text_secondary) + itemCrowdloanParaRaisedPercentage.setTextColorRes(R.color.text_secondary) + + itemCrowdloanParaRaisedProgress.isEnabled = false + + root.setOnClickListener(null) + } + } + + override fun unbind() { + with(binder) { + itemCrowdloanIcon.clear() + } + } + + fun bindRaised(item: CrowdloanModel) = with(binder) { + itemCrowdloanParaRaised.text = item.raised.value + itemCrowdloanParaRaisedProgress.progress = item.raised.percentage + itemCrowdloanParaRaisedPercentage.text = item.raised.percentageDisplay + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanFragment.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanFragment.kt new file mode 100644 index 0000000..086f1a8 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanFragment.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.main + +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.mixin.impl.setupCustomDialogDisplayer +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.databinding.FragmentCrowdloansBinding +import io.novafoundation.nova.feature_crowdloan_impl.di.CrowdloanFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.subscribeOnAssetChange +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.subscribeOnAssetClick + +import javax.inject.Inject + +class CrowdloanFragment : BaseFragment(), CrowdloanAdapter.Handler, CrowdloanHeaderAdapter.Handler { + + override fun createBinding() = FragmentCrowdloansBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val headerAdapter by lazy(LazyThreadSafetyMode.NONE) { CrowdloanHeaderAdapter(imageLoader, this) } + + private val shimmeringAdapter by lazy(LazyThreadSafetyMode.NONE) { CustomPlaceholderAdapter(R.layout.item_crowdloans_shimmering) } + + private val placeholderAdapter by lazy(LazyThreadSafetyMode.NONE) { CustomPlaceholderAdapter(R.layout.item_crowdloans_placeholder) } + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + CrowdloanAdapter(imageLoader, this) + } + + override fun initViews() { + binder.crowdloanList.itemAnimator = null + binder.crowdloanList.adapter = ConcatAdapter(headerAdapter, shimmeringAdapter, placeholderAdapter, adapter) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + CrowdloanFeatureApi::class.java + ) + .crowdloansFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: CrowdloanViewModel) { + subscribeOnAssetClick(getString(R.string.common_select_token), viewModel.assetSelectorMixin, imageLoader) + subscribeOnAssetChange(viewModel.assetSelectorMixin) { + headerAdapter.setAsset(it) + } + setupCustomDialogDisplayer(viewModel) + observeValidations(viewModel) + + viewModel.crowdloanModelsFlow.observe { loadingState -> + // GONE state does not trigger re-render on data change (i.e. when we want to drop outdated list) + shimmeringAdapter.show(loadingState is LoadingState.Loading) + + if (loadingState is LoadingState.Loaded) { + adapter.submitList(loadingState.data) + placeholderAdapter.show(loadingState.data.isEmpty()) + } else { + // to prevent outdated information appear for a moment between next chunk submitted and rendered + adapter.submitList(emptyList()) + placeholderAdapter.show(false) + } + } + + viewModel.contributionsInfo.observe { + if (it is LoadingState.Loaded) { + headerAdapter.setContributionsInfo(it.data, false) + } else { + headerAdapter.setContributionsInfo(null, true) + } + } + + viewModel.mainDescription.observe(headerAdapter::setAboutDescription) + } + + override fun crowdloanClicked(paraId: ParaId) { + viewModel.crowdloanClicked(paraId) + } + + override fun onClickAssetSelector() { + viewModel.assetSelectorMixin.assetSelectorClicked() + } + + override fun onClickContributionsInfo() { + viewModel.myContributionsClicked() + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanHeaderAdapter.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanHeaderAdapter.kt new file mode 100644 index 0000000..8628bfb --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanHeaderAdapter.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.main + +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_crowdloan_impl.databinding.ItemCrowdloanHeaderBinding +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.statefull.StatefulCrowdloanMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.AssetSelectorModel + +class CrowdloanHeaderAdapter( + private val imageLoader: ImageLoader, + private val handler: Handler +) : RecyclerView.Adapter() { + + interface Handler { + fun onClickAssetSelector() + + fun onClickContributionsInfo() + } + + private var assetModel: AssetSelectorModel? = null + private var showShimmering: Boolean = false + private var contributionsInfo: StatefulCrowdloanMixin.ContributionsInfo? = null + private var aboutDescription: String? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderHolder { + return HeaderHolder(imageLoader, ItemCrowdloanHeaderBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: HeaderHolder, position: Int) { + holder.bind(assetModel, contributionsInfo, showShimmering) + } + + override fun onBindViewHolder(holder: HeaderHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + } else { + payloads.filterIsInstance().forEach { + when (it) { + Payload.ASSET -> holder.bindAsset(assetModel) + Payload.CONTRIBUTIONS -> holder.bindContributionsInfo(contributionsInfo, showShimmering) + Payload.ABOUT -> holder.bindAbout(aboutDescription) + } + } + } + } + + override fun getItemCount(): Int { + return 1 + } + + fun setAsset(assetModel: AssetSelectorModel) { + this.assetModel = assetModel + notifyItemChanged(0, Payload.ASSET) + } + + fun setContributionsInfo(contributionsInfo: StatefulCrowdloanMixin.ContributionsInfo?, showShimmering: Boolean) { + this.contributionsInfo = contributionsInfo + this.showShimmering = showShimmering + notifyItemChanged(0, Payload.CONTRIBUTIONS) + } + + fun setAboutDescription(about: String?) { + this.aboutDescription = about + notifyItemChanged(0, Payload.ABOUT) + } +} + +private enum class Payload { + ASSET, CONTRIBUTIONS, ABOUT +} + +class HeaderHolder( + private val imageLoader: ImageLoader, + private val binder: ItemCrowdloanHeaderBinding, + handler: CrowdloanHeaderAdapter.Handler +) : RecyclerView.ViewHolder(binder.root) { + + init { + binder.crowdloanAssetSelector.setOnClickListener { handler.onClickAssetSelector() } + binder.crowdloanTotalContributedContainer.setOnClickListener { handler.onClickContributionsInfo() } + + with(binder) { + crowdloanAbout.background = binder.root.context.getBlockDrawable() + crowdloanTotalContributedContainer.background = binder.root.context.addRipple(binder.root.context.getBlockDrawable()) + crowdloanTotalContributedShimmering.background = binder.root.context.getBlockDrawable() + } + } + + fun bind(assetModel: AssetSelectorModel?, contributionsInfo: StatefulCrowdloanMixin.ContributionsInfo?, showShimmering: Boolean) { + bindAsset(assetModel) + bindContributionsInfo(contributionsInfo, showShimmering) + } + + fun bindAsset(assetModel: AssetSelectorModel?) { + assetModel?.let { binder.crowdloanAssetSelector.setState(imageLoader, assetModel) } + } + + fun bindContributionsInfo(contributionsInfo: StatefulCrowdloanMixin.ContributionsInfo?, showShimmering: Boolean) = with(binder) { + crowdloanTotalContributedShimmering.isVisible = showShimmering + crowdloanAbout.isGone = showShimmering + crowdloanTotalContributedContainer.isGone = showShimmering + + if (contributionsInfo != null && !showShimmering) { + crowdloanTotalContributedContainer.isVisible = contributionsInfo.isUserHasContributions + crowdloanAbout.isGone = contributionsInfo.isUserHasContributions + crowdloanTotalContributionsCount.text = contributionsInfo.contributionsCount.format() + crowdloanTotalContributedValue.text = contributionsInfo.totalContributed.token + crowdloanTotalContributedFiat.text = contributionsInfo.totalContributed.fiat + } + } + + fun bindAbout(about: String?) { + binder.crowdloanMainDescription.text = about + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanViewModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanViewModel.kt new file mode 100644 index 0000000..45f89e6 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/CrowdloanViewModel.kt @@ -0,0 +1,209 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.main + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.list.toValueList +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.presentation.mapLoading +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.resources.formatTimeLeft +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.formatting.formatAsPercentage +import io.novafoundation.nova.common.utils.fractionToPercentage +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_account_api.domain.validation.handleChainAccountNotFound +import io.novafoundation.nova.feature_crowdloan_impl.R +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.Crowdloan +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.statefull.StatefulCrowdloanMixin +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.validations.MainCrowdloanValidationFailure +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.validations.MainCrowdloanValidationPayload +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.validations.MainCrowdloanValidationSystem +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.ContributePayload +import io.novafoundation.nova.feature_crowdloan_impl.presentation.contribute.select.parcel.mapParachainMetadataToParcel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.main.model.CrowdloanModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.main.model.CrowdloanStatusModel +import io.novafoundation.nova.feature_crowdloan_impl.presentation.model.generateCrowdloanIcon +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.AssetSelectorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.WithAssetSelector +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +class CrowdloanViewModel( + private val iconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val crowdloanSharedState: CrowdloanSharedState, + private val router: CrowdloanRouter, + private val customContributeManager: CustomContributeManager, + private val validationSystem: MainCrowdloanValidationSystem, + private val validationExecutor: ValidationExecutor, + crowdloanUpdateSystem: UpdateSystem, + assetSelectorFactory: AssetSelectorFactory, + statefulCrowdloanMixinFactory: StatefulCrowdloanMixin.Factory, + customDialogDisplayer: CustomDialogDisplayer +) : BaseViewModel(), + Validatable by validationExecutor, + WithAssetSelector, + CustomDialogDisplayer by customDialogDisplayer { + + override val assetSelectorMixin = assetSelectorFactory.create( + scope = this, + amountProvider = { option -> option.asset.transferableInPlanks }, + ) + + val mainDescription = assetSelectorMixin.selectedAssetFlow.map { + resourceManager.getString(R.string.crowdloan_main_description_v2_2_0, it.token.configuration.symbol) + } + + private val crowdloansMixin = statefulCrowdloanMixinFactory.create(scope = this) + + private val crowdloansListFlow = crowdloansMixin.groupedCrowdloansFlow + .mapLoading { it.toValueList() } + .inBackground() + .share() + + val crowdloanModelsFlow = crowdloansMixin.groupedCrowdloansFlow.mapLoading { groupedCrowdloans -> + val asset = assetSelectorMixin.selectedAssetFlow.first() + val chain = crowdloanSharedState.chain() + + groupedCrowdloans + .mapKeys { (statusClass, values) -> mapCrowdloanStatusToUi(statusClass, values.size) } + .mapValues { (_, crowdloans) -> crowdloans.map { mapCrowdloanToCrowdloanModel(chain, it, asset) } } + .toListWithHeaders() + } + .inBackground() + .share() + + val contributionsInfo = crowdloansMixin.contributionsInfoFlow + .shareInBackground() + + init { + crowdloanUpdateSystem.start() + .launchIn(this) + } + + private fun mapCrowdloanStatusToUi(statusClass: KClass, statusCount: Int): CrowdloanStatusModel { + return when (statusClass) { + Crowdloan.State.Finished::class -> CrowdloanStatusModel( + status = resourceManager.getString(R.string.common_completed), + count = statusCount.toString() + ) + + Crowdloan.State.Active::class -> CrowdloanStatusModel( + status = resourceManager.getString(R.string.common_active), + count = statusCount.toString() + ) + + else -> throw IllegalArgumentException("Unsupported crowdloan status type: ${statusClass.simpleName}") + } + } + + private suspend fun mapCrowdloanToCrowdloanModel( + chain: Chain, + crowdloan: Crowdloan, + asset: Asset, + ): CrowdloanModel { + val token = asset.token + + val raisedDisplay = token.amountFromPlanks(crowdloan.fundInfo.raised).format() + val capDisplay = token.amountFromPlanks(crowdloan.fundInfo.cap).formatTokenAmount(token.configuration) + + val depositorAddress = chain.addressOf(crowdloan.fundInfo.depositor) + + val stateFormatted = when (val state = crowdloan.state) { + Crowdloan.State.Finished -> CrowdloanModel.State.Finished + + is Crowdloan.State.Active -> { + CrowdloanModel.State.Active( + timeRemaining = resourceManager.formatTimeLeft(state.remainingTimeInMillis) + ) + } + } + + val raisedPercentage = crowdloan.raisedFraction.fractionToPercentage() + + return CrowdloanModel( + relaychainId = chain.id, + parachainId = crowdloan.parachainId, + title = crowdloan.parachainMetadata?.name ?: crowdloan.parachainId.toString(), + description = crowdloan.parachainMetadata?.description ?: depositorAddress, + icon = generateCrowdloanIcon(crowdloan.parachainMetadata, depositorAddress, iconGenerator), + raised = CrowdloanModel.Raised( + value = resourceManager.getString(R.string.crownloans_raised_format, raisedDisplay, capDisplay), + percentage = raisedPercentage.toInt(), + percentageDisplay = raisedPercentage.formatAsPercentage() + ), + state = stateFormatted, + ) + } + + fun crowdloanClicked(paraId: ParaId) { + launch { + val crowdloans = crowdloansListFlow.first() as? LoadingState.Loaded ?: return@launch + val crowdloan = crowdloans.data.firstOrNull { it.parachainId == paraId } ?: return@launch + + val payload = ContributePayload( + paraId = crowdloan.parachainId, + parachainMetadata = crowdloan.parachainMetadata?.let(::mapParachainMetadataToParcel) + ) + + val startFlowInterceptor = crowdloan.parachainMetadata?.customFlow?.let { customFlow -> + customContributeManager.getFactoryOrNull(customFlow)?.startFlowInterceptor + } + + if (startFlowInterceptor != null) { + startFlowInterceptor.startFlow(payload) + } else { + openStandardContributionFlow(payload) + } + } + } + + fun myContributionsClicked() { + router.openUserContributions() + } + + private suspend fun openStandardContributionFlow(contributionPayload: ContributePayload) { + val validationPayload = MainCrowdloanValidationPayload( + metaAccount = crowdloansMixin.selectedAccount.first(), + chain = crowdloansMixin.selectedChain.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + validationFailureTransformerCustom = { status, _ -> mapValidationFailureToUi(status.reason) }, + ) { + router.openContribute(contributionPayload) + } + } + + private fun mapValidationFailureToUi(failure: MainCrowdloanValidationFailure): TransformedFailure { + return when (failure) { + is MainCrowdloanValidationFailure.NoRelaychainAccount -> handleChainAccountNotFound( + failure = failure, + resourceManager = resourceManager, + goToWalletDetails = { router.openWalletDetails(failure.account.id) }, + addAccountDescriptionRes = R.string.crowdloan_missing_account_message + ) + } + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/di/CrowdloanComponent.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/di/CrowdloanComponent.kt new file mode 100644 index 0000000..b3c7240 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/di/CrowdloanComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_crowdloan_impl.presentation.main.CrowdloanFragment + +@Subcomponent( + modules = [ + CrowdloanModule::class + ] +) +@ScreenScope +interface CrowdloanComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): CrowdloanComponent + } + + fun inject(fragment: CrowdloanFragment) +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/di/CrowdloanModule.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/di/CrowdloanModule.kt new file mode 100644 index 0000000..e6766f6 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/di/CrowdloanModule.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_crowdloan_api.domain.contributions.ContributionsInteractor +import io.novafoundation.nova.feature_crowdloan_impl.data.CrowdloanSharedState +import io.novafoundation.nova.feature_crowdloan_impl.di.customCrowdloan.CustomContributeManager +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.CrowdloanInteractor +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.statefull.StatefulCrowdloanMixin +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.statefull.StatefulCrowdloanProviderFactory +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.validations.MainCrowdloanValidationSystem +import io.novafoundation.nova.feature_crowdloan_impl.domain.main.validations.mainCrowdloan +import io.novafoundation.nova.feature_crowdloan_impl.presentation.CrowdloanRouter +import io.novafoundation.nova.feature_crowdloan_impl.presentation.main.CrowdloanViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.AssetSelectorFactory + +@Module(includes = [ViewModelModule::class]) +class CrowdloanModule { + + @Provides + @ScreenScope + fun provideValidationSystem(): MainCrowdloanValidationSystem { + return ValidationSystem.mainCrowdloan() + } + + @Provides + @ScreenScope + fun provideCrowdloanMixinFactory( + crowdloanSharedState: CrowdloanSharedState, + crowdloanInteractor: CrowdloanInteractor, + contributionsInteractor: ContributionsInteractor, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + amountFormatter: AmountFormatter + ): StatefulCrowdloanMixin.Factory { + return StatefulCrowdloanProviderFactory( + singleAssetSharedState = crowdloanSharedState, + crowdloanInteractor = crowdloanInteractor, + contributionsInteractor = contributionsInteractor, + selectedAccountUseCase = selectedAccountUseCase, + assetUseCase = assetUseCase, + amountFormatter = amountFormatter + ) + } + + @Provides + @IntoMap + @ViewModelKey(CrowdloanViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + iconGenerator: AddressIconGenerator, + crowdloanSharedState: CrowdloanSharedState, + router: CrowdloanRouter, + crowdloanUpdateSystem: UpdateSystem, + assetSelectorFactory: AssetSelectorFactory, + customDialogDisplayer: CustomDialogDisplayer.Presentation, + customContributeManager: CustomContributeManager, + statefulCrowdloanMixinFactory: StatefulCrowdloanMixin.Factory, + validationExecutor: ValidationExecutor, + validationSystem: MainCrowdloanValidationSystem, + ): ViewModel { + return CrowdloanViewModel( + iconGenerator = iconGenerator, + resourceManager = resourceManager, + crowdloanSharedState = crowdloanSharedState, + router = router, + customContributeManager = customContributeManager, + validationSystem = validationSystem, + validationExecutor = validationExecutor, + crowdloanUpdateSystem = crowdloanUpdateSystem, + assetSelectorFactory = assetSelectorFactory, + statefulCrowdloanMixinFactory = statefulCrowdloanMixinFactory, + customDialogDisplayer = customDialogDisplayer + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): CrowdloanViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(CrowdloanViewModel::class.java) + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/model/CrowdloanModel.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/model/CrowdloanModel.kt new file mode 100644 index 0000000..c4cc187 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/main/model/CrowdloanModel.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.main.model + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +data class CrowdloanStatusModel( + val status: String, + val count: String, +) + +data class CrowdloanModel( + val relaychainId: ChainId, + val parachainId: ParaId, + val title: String, + val description: String, + val icon: Icon, + val raised: Raised, + val state: State, +) { + + data class Raised( + val value: String, + val percentage: Int, // 0..100 + val percentageDisplay: String, + ) + + sealed class State { + object Finished : State() + + data class Active(val timeRemaining: String) : State() + } +} diff --git a/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/model/Icon.kt b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/model/Icon.kt new file mode 100644 index 0000000..95ba936 --- /dev/null +++ b/feature-crowdloan-impl/src/main/java/io/novafoundation/nova/feature_crowdloan_impl/presentation/model/Icon.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_crowdloan_impl.presentation.model + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.createSubstrateAddressIcon +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_crowdloan_api.data.repository.ParachainMetadata + +suspend fun generateCrowdloanIcon( + parachainMetadata: ParachainMetadata?, + depositorAddress: String, + iconGenerator: AddressIconGenerator, +): Icon { + return if (parachainMetadata != null) { + Icon.FromLink(parachainMetadata.iconLink) + } else { + val icon = iconGenerator.createSubstrateAddressIcon(depositorAddress, AddressIconGenerator.SIZE_BIG) + + Icon.FromDrawable(icon) + } +} diff --git a/feature-crowdloan-impl/src/main/res/drawable/bg_acala_contribution_option.xml b/feature-crowdloan-impl/src/main/res/drawable/bg_acala_contribution_option.xml new file mode 100644 index 0000000..69fb893 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/drawable/bg_acala_contribution_option.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/drawable/toggle_acala_contribution.xml b/feature-crowdloan-impl/src/main/res/drawable/toggle_acala_contribution.xml new file mode 100644 index 0000000..021912a --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/drawable/toggle_acala_contribution.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/fragment_claim_contributions.xml b/feature-crowdloan-impl/src/main/res/layout/fragment_claim_contributions.xml new file mode 100644 index 0000000..c3b0516 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/fragment_claim_contributions.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/fragment_contribute.xml b/feature-crowdloan-impl/src/main/res/layout/fragment_contribute.xml new file mode 100644 index 0000000..2e9c6a3 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/fragment_contribute.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/fragment_contribute_confirm.xml b/feature-crowdloan-impl/src/main/res/layout/fragment_contribute_confirm.xml new file mode 100644 index 0000000..346f0ab --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/fragment_contribute_confirm.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/fragment_crowdloans.xml b/feature-crowdloan-impl/src/main/res/layout/fragment_crowdloans.xml new file mode 100644 index 0000000..bc16a06 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/fragment_crowdloans.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/fragment_custom_contribute.xml b/feature-crowdloan-impl/src/main/res/layout/fragment_custom_contribute.xml new file mode 100644 index 0000000..16b26d3 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/fragment_custom_contribute.xml @@ -0,0 +1,34 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/fragment_moonbeam_terms.xml b/feature-crowdloan-impl/src/main/res/layout/fragment_moonbeam_terms.xml new file mode 100644 index 0000000..8ea013d --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/fragment_moonbeam_terms.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/fragment_my_contributions.xml b/feature-crowdloan-impl/src/main/res/layout/fragment_my_contributions.xml new file mode 100644 index 0000000..553657b --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/fragment_my_contributions.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/item_contributions_header.xml b/feature-crowdloan-impl/src/main/res/layout/item_contributions_header.xml new file mode 100644 index 0000000..85fd3d2 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/item_contributions_header.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/item_crowdloan.xml b/feature-crowdloan-impl/src/main/res/layout/item_crowdloan.xml new file mode 100644 index 0000000..47dba83 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/item_crowdloan.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/item_crowdloan_group.xml b/feature-crowdloan-impl/src/main/res/layout/item_crowdloan_group.xml new file mode 100644 index 0000000..a5f9c70 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/item_crowdloan_group.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/feature-crowdloan-impl/src/main/res/layout/item_crowdloan_header.xml b/feature-crowdloan-impl/src/main/res/layout/item_crowdloan_header.xml new file mode 100644 index 0000000..1ae89e5 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/item_crowdloan_header.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-crowdloan-impl/src/main/res/layout/item_crowdloan_shimmering.xml b/feature-crowdloan-impl/src/main/res/layout/item_crowdloan_shimmering.xml new file mode 100644 index 0000000..3b7a466 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/item_crowdloan_shimmering.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/item_crowdloans_placeholder.xml b/feature-crowdloan-impl/src/main/res/layout/item_crowdloans_placeholder.xml new file mode 100644 index 0000000..cc4a923 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/item_crowdloans_placeholder.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/item_crowdloans_shimmering.xml b/feature-crowdloan-impl/src/main/res/layout/item_crowdloans_shimmering.xml new file mode 100644 index 0000000..f2a9abf --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/item_crowdloans_shimmering.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + diff --git a/feature-crowdloan-impl/src/main/res/layout/view_acala_contribution_type.xml b/feature-crowdloan-impl/src/main/res/layout/view_acala_contribution_type.xml new file mode 100644 index 0000000..91b5843 --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/view_acala_contribution_type.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/view_acala_learn_contributions.xml b/feature-crowdloan-impl/src/main/res/layout/view_acala_learn_contributions.xml new file mode 100644 index 0000000..a6da0ef --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/view_acala_learn_contributions.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/main/res/layout/view_referral_flow.xml b/feature-crowdloan-impl/src/main/res/layout/view_referral_flow.xml new file mode 100644 index 0000000..324507b --- /dev/null +++ b/feature-crowdloan-impl/src/main/res/layout/view_referral_flow.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-crowdloan-impl/src/test/java/io/novafoundation/nova/feature_crowdloan_impl/ExampleUnitTest.kt b/feature-crowdloan-impl/src/test/java/io/novafoundation/nova/feature_crowdloan_impl/ExampleUnitTest.kt new file mode 100644 index 0000000..42a6c02 --- /dev/null +++ b/feature-crowdloan-impl/src/test/java/io/novafoundation/nova/feature_crowdloan_impl/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_crowdloan_impl + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/feature-currency-api/.gitignore b/feature-currency-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-currency-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-currency-api/build.gradle b/feature-currency-api/build.gradle new file mode 100644 index 0000000..e7cd2ed --- /dev/null +++ b/feature-currency-api/build.gradle @@ -0,0 +1,19 @@ + +android { + namespace 'io.novafoundation.nova.feature_currency_api' +} + +dependencies { + implementation coroutinesDep + implementation project(':core-db') + implementation project(':runtime') + implementation project(":common") + + implementation daggerDep + + implementation substrateSdkDep + + implementation androidDep + + api project(':core-api') +} \ No newline at end of file diff --git a/feature-currency-api/consumer-rules.pro b/feature-currency-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-currency-api/proguard-rules.pro b/feature-currency-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-currency-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-currency-api/src/main/AndroidManifest.xml b/feature-currency-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/feature-currency-api/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/di/CurrencyFeatureApi.kt b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/di/CurrencyFeatureApi.kt new file mode 100644 index 0000000..0f12c41 --- /dev/null +++ b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/di/CurrencyFeatureApi.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_currency_api.di + +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository + +interface CurrencyFeatureApi { + + fun currencyInteractor(): CurrencyInteractor + + fun currencyRepository(): CurrencyRepository +} diff --git a/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/CurrencyCategory.kt b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/CurrencyCategory.kt new file mode 100644 index 0000000..6b440cb --- /dev/null +++ b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/CurrencyCategory.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_currency_api.domain + +enum class CurrencyCategory { + FIAT, FIAT_POPULAR, CRYPTO +} diff --git a/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/CurrencyInteractor.kt b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/CurrencyInteractor.kt new file mode 100644 index 0000000..85a4811 --- /dev/null +++ b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/CurrencyInteractor.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_currency_api.domain + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import kotlinx.coroutines.flow.Flow + +interface CurrencyInteractor { + + suspend fun syncCurrencies() + + fun observeCurrencies(): Flow> + + fun observeSelectCurrency(): Flow + + suspend fun getSelectedCurrency(): Currency + + suspend fun selectCurrency(currencyId: Int) +} diff --git a/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/interfaces/CurrencyRepository.kt b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/interfaces/CurrencyRepository.kt new file mode 100644 index 0000000..f50bae9 --- /dev/null +++ b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/interfaces/CurrencyRepository.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_currency_api.domain.interfaces + +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import kotlinx.coroutines.flow.Flow + +interface CurrencyRepository { + + suspend fun syncCurrencies() + + fun observeCurrencies(): Flow> + + fun observeSelectCurrency(): Flow + + suspend fun selectCurrency(currencyId: Int) + + suspend fun getSelectedCurrency(): Currency +} diff --git a/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/model/Currency.kt b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/model/Currency.kt new file mode 100644 index 0000000..cf17875 --- /dev/null +++ b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/domain/model/Currency.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_currency_api.domain.model + +data class Currency( + val code: String, + val name: String, + val symbol: String?, + val category: Category, + val popular: Boolean, + val id: Int, + val coingeckoId: String, + val selected: Boolean, +) { + + enum class Category { + FIAT, CRYPTO + } +} diff --git a/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/CurrencyRouter.kt b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/CurrencyRouter.kt new file mode 100644 index 0000000..64387b2 --- /dev/null +++ b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/CurrencyRouter.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_currency_api.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter + +interface CurrencyRouter : ReturnableRouter { + + fun returnToWallet() +} diff --git a/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/formatters/CurrencyFormatters.kt b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/formatters/CurrencyFormatters.kt new file mode 100644 index 0000000..dc9e22a --- /dev/null +++ b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/formatters/CurrencyFormatters.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_currency_api.presentation.formatters + +import io.novafoundation.nova.common.utils.formatting.currencyFormatter +import io.novafoundation.nova.common.utils.formatting.formatWithFullAmount +import io.novafoundation.nova.common.utils.formatting.simpleCurrencyFormatter +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import java.math.BigDecimal +import java.math.RoundingMode + +private val currencyFormatter = currencyFormatter() +private val simpleCurrencyFormatter = simpleCurrencyFormatter() + +@Deprecated("Use FiatFormatter instead") +fun BigDecimal.formatAsCurrencyNoAbbreviation(currency: Currency): String { + return formatCurrencySymbol(currency.symbol, currency.code) + this.formatWithFullAmount() +} + +@Deprecated("Use FiatFormatter instead") +fun BigDecimal.formatAsCurrency(currency: Currency, roundingMode: RoundingMode = RoundingMode.FLOOR): String { + return formatAsCurrency(currency.symbol, currency.code, roundingMode) +} + +@Deprecated("Use FiatFormatter instead") +fun BigDecimal.simpleFormatAsCurrency(currency: Currency, roundingMode: RoundingMode = RoundingMode.FLOOR): String { + return simpleFormatAsCurrency(currency.symbol, currency.code, roundingMode) +} + +@Deprecated("Use FiatFormatter instead") +fun BigDecimal.formatAsCurrency(symbol: String?, code: String, roundingMode: RoundingMode = RoundingMode.FLOOR): String { + return formatCurrencySymbol(symbol, code) + currencyFormatter.format(this, roundingMode) +} + +@Deprecated("Use FiatFormatter instead") +fun BigDecimal.simpleFormatAsCurrency(symbol: String?, code: String, roundingMode: RoundingMode = RoundingMode.FLOOR): String { + return formatCurrencySymbol(symbol, code) + simpleCurrencyFormatter.format(this, roundingMode) +} + +@Deprecated("Use FiatFormatter instead") +private fun formatCurrencySymbol(symbol: String?, code: String): String { + return symbol ?: "$code " +} diff --git a/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/mapper/CurrencyMapper.kt b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/mapper/CurrencyMapper.kt new file mode 100644 index 0000000..7653fb1 --- /dev/null +++ b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/mapper/CurrencyMapper.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_currency_api.presentation.mapper + +import io.novafoundation.nova.core_db.model.CurrencyLocal +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_currency_api.presentation.model.CurrencyModel + +fun mapCurrencyToUI(currency: Currency): CurrencyModel { + return CurrencyModel( + id = currency.id, + displayCode = currency.symbol ?: currency.code, + code = currency.code, + name = currency.name, + isSelected = currency.selected + ) +} + +fun mapCurrencyFromLocal(local: CurrencyLocal): Currency { + return with(local) { + Currency( + code = code, + name = name, + symbol = symbol, + category = mapCurrencyCategoryFromLocal(category), + popular = popular, + id = id, + coingeckoId = coingeckoId, + selected = selected + ) + } +} + +private fun mapCurrencyCategoryFromLocal(local: CurrencyLocal.Category): Currency.Category { + return when (local) { + CurrencyLocal.Category.CRYPTO -> Currency.Category.CRYPTO + CurrencyLocal.Category.FIAT -> Currency.Category.FIAT + } +} diff --git a/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/model/CurrencyModel.kt b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/model/CurrencyModel.kt new file mode 100644 index 0000000..b52ebff --- /dev/null +++ b/feature-currency-api/src/main/java/io/novafoundation/nova/feature_currency_api/presentation/model/CurrencyModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_currency_api.presentation.model + +data class CurrencyModel( + val id: Int, + val displayCode: String, + val code: String, + val name: String, + val isSelected: Boolean +) diff --git a/feature-currency-impl/.gitignore b/feature-currency-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-currency-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-currency-impl/build.gradle b/feature-currency-impl/build.gradle new file mode 100644 index 0000000..e932927 --- /dev/null +++ b/feature-currency-impl/build.gradle @@ -0,0 +1,44 @@ +apply plugin: 'kotlin-parcelize' + +android { + + defaultConfig { + + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.feature_currency_impl' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(":feature-currency-api") + implementation project(":common") + implementation project(":runtime") + implementation project(':core-db') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + + implementation gsonDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation daggerDep + ksp daggerCompiler + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-currency-impl/consumer-rules.pro b/feature-currency-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-currency-impl/proguard-rules.pro b/feature-currency-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-currency-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-currency-impl/src/main/AndroidManifest.xml b/feature-currency-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/feature-currency-impl/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/datasource/AssetsCurrencyRemoteDataSource.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/datasource/AssetsCurrencyRemoteDataSource.kt new file mode 100644 index 0000000..684e909 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/datasource/AssetsCurrencyRemoteDataSource.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_currency_impl.data.datasource + +import com.google.gson.Gson +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.feature_currency_impl.R + +class AssetsCurrencyRemoteDataSource( + private val resourceManager: ResourceManager, + private val gson: Gson +) : CurrencyRemoteDataSource { + + override suspend fun getCurrenciesRemote(): List { + val rawCurrencies = resourceManager.loadRawString(R.raw.currencies) + + return gson.fromJson(rawCurrencies) + } +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/datasource/CurrencyRemote.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/datasource/CurrencyRemote.kt new file mode 100644 index 0000000..8f48121 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/datasource/CurrencyRemote.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_currency_impl.data.datasource + +import io.novafoundation.nova.core_db.model.CurrencyLocal + +class CurrencyRemote( + val code: String, + val name: String, + val symbol: String?, + val category: String, + val popular: Boolean, + val id: Int, + val coingeckoId: String, +) + +fun mapRemoteCurrencyToLocal(remote: CurrencyRemote, selected: Boolean): CurrencyLocal { + return with(remote) { + CurrencyLocal( + code = code, + name = name, + symbol = symbol, + category = mapRemoteCurrencyCategoryToLocal(category), + popular = popular, + id = id, + coingeckoId = coingeckoId, + selected = selected, + ) + } +} + +private fun mapRemoteCurrencyCategoryToLocal(remote: String): CurrencyLocal.Category { + return when (remote) { + "fiat" -> CurrencyLocal.Category.FIAT + "crypto" -> CurrencyLocal.Category.CRYPTO + else -> throw IllegalArgumentException("Unknown currency category: $remote") + } +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/datasource/CurrencyRemoteDataSource.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/datasource/CurrencyRemoteDataSource.kt new file mode 100644 index 0000000..0e2d2e1 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/datasource/CurrencyRemoteDataSource.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_currency_impl.data.datasource + +interface CurrencyRemoteDataSource { + + suspend fun getCurrenciesRemote(): List +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/repository/RealCurrencyRepository.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/repository/RealCurrencyRepository.kt new file mode 100644 index 0000000..f5f6499 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/data/repository/RealCurrencyRepository.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_currency_impl.data.repository + +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.dao.CurrencyDao +import io.novafoundation.nova.feature_currency_impl.data.datasource.CurrencyRemoteDataSource +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_currency_api.presentation.mapper.mapCurrencyFromLocal +import io.novafoundation.nova.feature_currency_impl.data.datasource.mapRemoteCurrencyToLocal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RealCurrencyRepository( + private val currencyDao: CurrencyDao, + private val currencyRemoteDataSource: CurrencyRemoteDataSource, +) : CurrencyRepository { + + override suspend fun syncCurrencies() { + val selectedCurrency = currencyDao.getSelectedCurrency() + val remoteCurrencies = currencyRemoteDataSource.getCurrenciesRemote() + val newCurrencies = remoteCurrencies.map { mapRemoteCurrencyToLocal(it, selectedCurrency?.id == it.id) } + val oldCurrencies = currencyDao.getCurrencies() + val resultCurrencies = CollectionDiffer.findDiff(newCurrencies, oldCurrencies, false) + currencyDao.updateCurrencies(resultCurrencies) + } + + override fun observeCurrencies(): Flow> { + return currencyDao.observeCurrencies() + .map { currencyList -> currencyList.map { mapCurrencyFromLocal(it) } } + } + + override fun observeSelectCurrency(): Flow { + return currencyDao.observeSelectCurrency() + .map { mapCurrencyFromLocal(it) } + } + + override suspend fun selectCurrency(currencyId: Int) { + currencyDao.selectCurrency(currencyId) + } + + override suspend fun getSelectedCurrency(): Currency { + val selectedCurrency = currencyDao.getSelectedCurrency()?.let { mapCurrencyFromLocal(it) } + return selectedCurrency ?: throw IllegalArgumentException("No currency selected") + } +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureComponent.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureComponent.kt new file mode 100644 index 0000000..10e08de --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureComponent.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_currency_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_currency_api.presentation.CurrencyRouter +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + CurrencyFeatureDependencies::class + ], + modules = [ + CurrencyFeatureModule::class + ] +) +@FeatureScope +interface CurrencyFeatureComponent : CurrencyFeatureApi { + + fun selectCurrencyComponentFactory(): SelectCurrencyComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance walletRouter: CurrencyRouter, + deps: CurrencyFeatureDependencies + ): CurrencyFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + RuntimeApi::class + ] + ) + interface CurrencyFeatureDependenciesComponent : CurrencyFeatureDependencies +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureDependencies.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureDependencies.kt new file mode 100644 index 0000000..9bcfe64 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureDependencies.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_currency_impl.di + +import com.google.gson.Gson +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.core_db.dao.CurrencyDao + +interface CurrencyFeatureDependencies { + fun resourceManager(): ResourceManager + + fun currencyDao(): CurrencyDao + + fun jsonMapper(): Gson +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureHolder.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureHolder.kt new file mode 100644 index 0000000..41275b2 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureHolder.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_currency_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_currency_api.presentation.CurrencyRouter +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class CurrencyFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + val currencyRouter: CurrencyRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerCurrencyFeatureComponent_CurrencyFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .build() + return DaggerCurrencyFeatureComponent.factory() + .create(currencyRouter, dependencies) + } +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureModule.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureModule.kt new file mode 100644 index 0000000..a5f3843 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/CurrencyFeatureModule.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_currency_impl.di + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.core_db.dao.CurrencyDao +import io.novafoundation.nova.feature_currency_impl.data.datasource.CurrencyRemoteDataSource +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_currency_impl.data.datasource.AssetsCurrencyRemoteDataSource +import io.novafoundation.nova.feature_currency_impl.data.repository.RealCurrencyRepository +import io.novafoundation.nova.feature_currency_impl.domain.CurrencyInteractorImpl + +@Module +class CurrencyFeatureModule { + + @Provides + @FeatureScope + fun provideCurrencyRemoteDataSource( + resourceManager: ResourceManager, + gson: Gson + ): CurrencyRemoteDataSource { + return AssetsCurrencyRemoteDataSource(resourceManager, gson) + } + + @Provides + @FeatureScope + fun provideCurrencyRepository( + currencyDao: CurrencyDao, + currencyRemoteDataSource: CurrencyRemoteDataSource + ): CurrencyRepository { + return RealCurrencyRepository(currencyDao, currencyRemoteDataSource) + } + + @Provides + @FeatureScope + fun provideCurrencyInteractor(currencyRepository: CurrencyRepository): CurrencyInteractor { + return CurrencyInteractorImpl(currencyRepository) + } +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/SelectCurrencyComponent.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/SelectCurrencyComponent.kt new file mode 100644 index 0000000..bd7346a --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/SelectCurrencyComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_currency_impl.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_currency_impl.presentation.currency.SelectCurrencyFragment + +@Subcomponent( + modules = [ + SelectCurrencyModule::class + ] +) +@ScreenScope +interface SelectCurrencyComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): SelectCurrencyComponent + } + + fun inject(fragment: SelectCurrencyFragment) +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/SelectCurrencyModule.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/SelectCurrencyModule.kt new file mode 100644 index 0000000..22700f1 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/di/SelectCurrencyModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_currency_impl.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.presentation.CurrencyRouter +import io.novafoundation.nova.feature_currency_impl.presentation.currency.SelectCurrencyViewModel + +@Module(includes = [ViewModelModule::class]) +class SelectCurrencyModule { + + @Provides + @IntoMap + @ViewModelKey(SelectCurrencyViewModel::class) + fun provideViewModel( + currencyInteractor: CurrencyInteractor, + resourceManager: ResourceManager, + walletRouter: CurrencyRouter + ): ViewModel { + return SelectCurrencyViewModel( + currencyInteractor, + resourceManager, + walletRouter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectCurrencyViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectCurrencyViewModel::class.java) + } +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/domain/CurrencyInteractorImpl.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/domain/CurrencyInteractorImpl.kt new file mode 100644 index 0000000..fb6f14c --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/domain/CurrencyInteractorImpl.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_currency_impl.domain + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.feature_currency_api.domain.CurrencyCategory +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class CurrencyInteractorImpl( + private val currencyRepository: CurrencyRepository +) : CurrencyInteractor { + override suspend fun syncCurrencies() { + currencyRepository.syncCurrencies() + } + + override fun observeCurrencies(): Flow> { + return currencyRepository.observeCurrencies().map { list -> + list.groupBy { mapCurrencyCategory(it) } + .toSortedMap(getCurrencyCategoryComparator()) + } + } + + override fun observeSelectCurrency(): Flow { + return currencyRepository.observeSelectCurrency() + } + + override suspend fun getSelectedCurrency(): Currency { + return currencyRepository.getSelectedCurrency() + } + + override suspend fun selectCurrency(currencyId: Int) { + currencyRepository.selectCurrency(currencyId) + } + + private fun getCurrencyCategoryComparator() = compareBy { + when (it) { + CurrencyCategory.CRYPTO -> 0 + CurrencyCategory.FIAT_POPULAR -> 1 + CurrencyCategory.FIAT -> 2 + } + } + + private fun mapCurrencyCategory(category: Currency): CurrencyCategory { + return when (category.category) { + Currency.Category.CRYPTO -> CurrencyCategory.CRYPTO + Currency.Category.FIAT -> { + if (category.popular) { + CurrencyCategory.FIAT_POPULAR + } else { + CurrencyCategory.FIAT + } + } + } + } +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/presentation/currency/CurrencyAdapter.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/presentation/currency/CurrencyAdapter.kt new file mode 100644 index 0000000..c8a7517 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/presentation/currency/CurrencyAdapter.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_currency_impl.presentation.currency + +import android.view.ViewGroup +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.list.headers.TextHeader +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_currency_api.presentation.model.CurrencyModel +import io.novafoundation.nova.feature_currency_impl.databinding.ItemCurrencyBinding +import io.novafoundation.nova.feature_currency_impl.databinding.ItemCurrencyTypeBinding + +class CurrencyAdapter( + private val handler: Handler +) : GroupedListAdapter(DiffCallback) { + + interface Handler { + fun itemClicked(currency: CurrencyModel) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return CurrencyTypeHolder(ItemCurrencyTypeBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return CurrencyHolder(ItemCurrencyBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun bindGroup(holder: GroupedListHolder, group: TextHeader) { + (holder as CurrencyTypeHolder).bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: CurrencyModel) { + (holder as CurrencyHolder).bind(child) + } +} + +private object DiffCallback : BaseGroupedDiffCallback(TextHeader::class.java) { + + override fun areGroupItemsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean { + return TextHeader.DIFF_CALLBACK.areItemsTheSame(oldItem, newItem) + } + + override fun areGroupContentsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean { + return TextHeader.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + } + + override fun areChildItemsTheSame(oldItem: CurrencyModel, newItem: CurrencyModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areChildContentsTheSame(oldItem: CurrencyModel, newItem: CurrencyModel): Boolean { + return oldItem == newItem + } +} + +private class CurrencyTypeHolder(private val binder: ItemCurrencyTypeBinding) : GroupedListHolder(binder.root) { + + fun bind(item: TextHeader) { + binder.itemCurrencyType.text = item.content + } +} + +private class CurrencyHolder( + private val binder: ItemCurrencyBinding, + private val itemHandler: CurrencyAdapter.Handler +) : GroupedListHolder(binder.root) { + + fun bind(item: CurrencyModel) = with(binder) { + itemCurrencySign.text = item.displayCode + + bindTitle(item) + itemCurrencyAbbreviation.text = item.code + + itemCurrencyName.text = item.name + itemCurrencyCheck.isChecked = item.isSelected + bindClick(item) + } + + fun bindTitle(item: CurrencyModel) = with(containerView) { + binder.itemCurrencyName.text = item.name + } + + private fun bindClick(item: CurrencyModel) = with(containerView) { + setOnClickListener { itemHandler.itemClicked(item) } + } +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/presentation/currency/SelectCurrencyFragment.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/presentation/currency/SelectCurrencyFragment.kt new file mode 100644 index 0000000..b3bb2f4 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/presentation/currency/SelectCurrencyFragment.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_currency_impl.presentation.currency + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_currency_impl.databinding.FragmentSelectCurrencyBinding +import io.novafoundation.nova.feature_currency_impl.di.CurrencyFeatureComponent + +class SelectCurrencyFragment : BaseFragment(), CurrencyAdapter.Handler { + + override fun createBinding() = FragmentSelectCurrencyBinding.inflate(layoutInflater) + + private val adapter = CurrencyAdapter(this) + + override fun initViews() { + binder.currencyList.adapter = adapter + + binder.currencyToolbar.setHomeButtonListener { + viewModel.backClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + CurrencyFeatureApi::class.java + ).selectCurrencyComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SelectCurrencyViewModel) { + viewModel.currencyModels.observe { + adapter.submitList(it) + } + } + + override fun itemClicked(currency: io.novafoundation.nova.feature_currency_api.presentation.model.CurrencyModel) { + viewModel.selectCurrency(currency) + } +} diff --git a/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/presentation/currency/SelectCurrencyViewModel.kt b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/presentation/currency/SelectCurrencyViewModel.kt new file mode 100644 index 0000000..dcb9769 --- /dev/null +++ b/feature-currency-impl/src/main/java/io/novafoundation/nova/feature_currency_impl/presentation/currency/SelectCurrencyViewModel.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_currency_impl.presentation.currency + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.list.headers.TextHeader +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_currency_api.domain.CurrencyCategory +import io.novafoundation.nova.feature_currency_api.presentation.CurrencyRouter +import io.novafoundation.nova.feature_currency_api.presentation.mapper.mapCurrencyToUI +import io.novafoundation.nova.feature_currency_api.presentation.model.CurrencyModel +import io.novafoundation.nova.feature_currency_impl.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SelectCurrencyViewModel( + private val currencyInteractor: io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor, + private val resourceManager: ResourceManager, + private val router: CurrencyRouter, +) : BaseViewModel() { + + private val currencies = currencyInteractor.observeCurrencies() + .inBackground() + .share() + + val currencyModels = currencies.map { groupedList -> + groupedList.toListWithHeaders( + keyMapper = { category, _ -> mapCurrencyCategoryToUI(category) }, + valueMapper = { mapCurrencyToUI(it) } + ) + } + .inBackground() + .share() + + private fun mapCurrencyCategoryToUI(category: CurrencyCategory): TextHeader { + return TextHeader( + when (category) { + CurrencyCategory.CRYPTO -> resourceManager.getString(R.string.wallet_currency_category_cryptocurrencies) + CurrencyCategory.FIAT -> resourceManager.getString(R.string.wallet_currency_category_fiat) + CurrencyCategory.FIAT_POPULAR -> resourceManager.getString(R.string.wallet_currency_category_popular_fiat) + } + ) + } + + fun selectCurrency(currency: CurrencyModel) { + launch { + withContext(Dispatchers.IO) { currencyInteractor.selectCurrency(currency.id) } + router.returnToWallet() + } + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-currency-impl/src/main/res/layout/fragment_select_currency.xml b/feature-currency-impl/src/main/res/layout/fragment_select_currency.xml new file mode 100644 index 0000000..f4d44e2 --- /dev/null +++ b/feature-currency-impl/src/main/res/layout/fragment_select_currency.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/feature-currency-impl/src/main/res/layout/item_currency.xml b/feature-currency-impl/src/main/res/layout/item_currency.xml new file mode 100644 index 0000000..ccc6cd0 --- /dev/null +++ b/feature-currency-impl/src/main/res/layout/item_currency.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + diff --git a/feature-currency-impl/src/main/res/layout/item_currency_type.xml b/feature-currency-impl/src/main/res/layout/item_currency_type.xml new file mode 100644 index 0000000..b113eda --- /dev/null +++ b/feature-currency-impl/src/main/res/layout/item_currency_type.xml @@ -0,0 +1,16 @@ + + + diff --git a/feature-currency-impl/src/main/res/raw/currencies.json b/feature-currency-impl/src/main/res/raw/currencies.json new file mode 100644 index 0000000..4a0ee34 --- /dev/null +++ b/feature-currency-impl/src/main/res/raw/currencies.json @@ -0,0 +1,416 @@ +[ + { + "code": "USD", + "name": "United States Dollar", + "symbol": "$", + "category": "fiat", + "popular": true, + "id": 0, + "coingeckoId": "usd" + }, + { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "category": "fiat", + "popular": true, + "id": 1, + "coingeckoId": "eur" + }, + { + "code": "JPY", + "name": "Japanese Yen", + "symbol": "¥", + "category": "fiat", + "popular": true, + "id": 2, + "coingeckoId": "jpy" + }, + { + "code": "CNY", + "name": "Chinese Yuan", + "symbol": "¥", + "category": "fiat", + "popular": true, + "id": 3, + "coingeckoId": "cny" + }, + { + "code": "TWD", + "name": "New Taiwan dollar", + "symbol": "$", + "category": "fiat", + "popular": true, + "id": 4, + "coingeckoId": "twd" + }, + { + "code": "RUB", + "name": "Russian Ruble", + "symbol": "₽", + "category": "fiat", + "popular": true, + "id": 5, + "coingeckoId": "rub" + }, + { + "code": "AED", + "name": "United Arab Emirates dirham", + "category": "fiat", + "popular": true, + "id": 6, + "coingeckoId": "aed" + }, + { + "code": "IDR", + "name": "Indonesian Rupiah", + "category": "fiat", + "popular": true, + "id": 7, + "coingeckoId": "idr" + }, + { + "code": "KRW", + "name": "South Korean won", + "symbol": "₩", + "category": "fiat", + "popular": true, + "id": 8, + "coingeckoId": "krw" + }, + { + "code": "ARS", + "name": "Argentine Peso", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 9, + "coingeckoId": "ars" + }, + { + "code": "AUD", + "name": "Australian Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 10, + "coingeckoId": "aud" + }, + { + "code": "BDT", + "name": "Bangladeshi Taka", + "category": "fiat", + "popular": false, + "id": 11, + "coingeckoId": "bdt" + }, + { + "code": "BHD", + "name": "Bahraini Dinar", + "category": "fiat", + "popular": false, + "id": 12, + "coingeckoId": "bhd" + }, + { + "code": "BMD", + "name": "Bermudan Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 13, + "coingeckoId": "bmd" + }, + { + "code": "BRL", + "name": "Brazilian Real", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 14, + "coingeckoId": "brl" + }, + { + "code": "CAD", + "name": "Canadian Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 15, + "coingeckoId": "cad" + }, + { + "code": "CHF", + "name": "Swiss Franc", + "category": "fiat", + "popular": false, + "id": 16, + "coingeckoId": "chf" + }, + { + "code": "CLP", + "name": "Chilean Peso", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 17, + "coingeckoId": "clp" + }, + { + "code": "CZK", + "name": "Czech Koruna", + "symbol": "Kč", + "category": "fiat", + "popular": false, + "id": 18, + "coingeckoId": "czk" + }, + { + "code": "DKK", + "name": "Danish Krone", + "category": "fiat", + "popular": false, + "id": 19, + "coingeckoId": "dkk" + }, + { + "code": "GBP", + "name": "British Pound Sterling", + "symbol": "£", + "category": "fiat", + "popular": false, + "id": 20, + "coingeckoId": "gbp" + }, + { + "code": "HKD", + "name": "Hong Kong Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 21, + "coingeckoId": "hkd" + }, + { + "code": "HUF", + "name": "Hungarian Forint", + "category": "fiat", + "popular": false, + "id": 22, + "coingeckoId": "huf" + }, + { + "code": "ILS", + "name": "Israeli New Shekel", + "symbol": "₪", + "category": "fiat", + "popular": false, + "id": 23, + "coingeckoId": "ils" + }, + { + "code": "INR", + "name": "Indian Rupee", + "symbol": "₹", + "category": "fiat", + "popular": false, + "id": 24, + "coingeckoId": "inr" + }, + { + "code": "LKR", + "name": "Sri Lankan Rupee", + "category": "fiat", + "popular": false, + "id": 26, + "coingeckoId": "lkr" + }, + { + "code": "MMK", + "name": "Myanmar Kyat", + "category": "fiat", + "popular": false, + "id": 27, + "coingeckoId": "mmk" + }, + { + "code": "MXN", + "name": "Mexican Peso", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 28, + "coingeckoId": "mxn" + }, + { + "code": "MYR", + "name": "Malaysian Ringgit", + "category": "fiat", + "popular": false, + "id": 29, + "coingeckoId": "myr" + }, + { + "code": "NGN", + "name": "Nigerian Naira", + "symbol": "₦", + "category": "fiat", + "popular": false, + "id": 30, + "coingeckoId": "ngn" + }, + { + "code": "NOK", + "name": "Norwegian Krone", + "category": "fiat", + "popular": false, + "id": 31, + "coingeckoId": "nok" + }, + { + "code": "NZD", + "name": "New Zealand Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 32, + "coingeckoId": "nzd" + }, + { + "code": "PHP", + "name": "Philippine peso", + "symbol": "₱", + "category": "fiat", + "popular": false, + "id": 33, + "coingeckoId": "php" + }, + { + "code": "PKR", + "name": "Pakistani Rupee", + "category": "fiat", + "popular": false, + "id": 34, + "coingeckoId": "pkr" + }, + { + "code": "PLN", + "name": "Poland złoty", + "symbol": "zł", + "category": "fiat", + "popular": false, + "id": 35, + "coingeckoId": "pln" + }, + { + "code": "SAR", + "name": "Saudi Riyal", + "category": "fiat", + "popular": false, + "id": 36, + "coingeckoId": "sar" + }, + { + "code": "SEK", + "name": "Swedish Krona", + "category": "fiat", + "popular": false, + "id": 37, + "coingeckoId": "sek" + }, + { + "code": "SGD", + "name": "Singapore Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 38, + "coingeckoId": "sgd" + }, + { + "code": "THB", + "name": "Thai Baht", + "symbol": "฿", + "category": "fiat", + "popular": false, + "id": 39, + "coingeckoId": "thb" + }, + { + "code": "TRY", + "name": "Turkish lira", + "symbol": "₺", + "category": "fiat", + "popular": false, + "id": 40, + "coingeckoId": "try" + }, + { + "code": "UAH", + "name": "Ukrainian hryvnia", + "symbol": "₴", + "category": "fiat", + "popular": false, + "id": 41, + "coingeckoId": "uah" + }, + { + "code": "VEF", + "name": "Venezuelan bolívar", + "category": "fiat", + "popular": false, + "id": 42, + "coingeckoId": "vef" + }, + { + "code": "VND", + "name": "Vietnamese dong", + "symbol": "₫", + "category": "fiat", + "popular": false, + "id": 43, + "coingeckoId": "vnd" + }, + { + "code": "ZAR", + "name": "South African rand", + "category": "fiat", + "popular": false, + "id": 44, + "coingeckoId": "zar" + }, + { + "code": "XDR", + "name": "IMF Special Drawing Rights", + "category": "fiat", + "popular": false, + "id": 45, + "coingeckoId": "xdr" + }, + { + "code": "DOT", + "name": "Polkadot", + "category": "crypto", + "popular": true, + "id": 46, + "coingeckoId": "dot" + }, + { + "code": "BTC", + "name": "Bitcoin", + "symbol": "₿", + "category": "crypto", + "popular": true, + "id": 47, + "coingeckoId": "btc" + }, + { + "code": "ETH", + "name": "Ether", + "symbol": "Ξ", + "category": "crypto", + "popular": true, + "id": 48, + "coingeckoId": "eth" + } +] \ No newline at end of file diff --git a/feature-dapp-api/.gitignore b/feature-dapp-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-dapp-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-dapp-api/build.gradle b/feature-dapp-api/build.gradle new file mode 100644 index 0000000..906452b --- /dev/null +++ b/feature-dapp-api/build.gradle @@ -0,0 +1,23 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_dapp_api' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':feature-account-api') + implementation project(":feature-external-sign-api") + implementation project(':common') + implementation project(':feature-deep-linking') + + implementation shimmerDep + + implementation coroutinesDep + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-dapp-api/consumer-rules.pro b/feature-dapp-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-dapp-api/proguard-rules.pro b/feature-dapp-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-dapp-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-dapp-api/src/main/AndroidManifest.xml b/feature-dapp-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-dapp-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/BrowserHostSettings.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/BrowserHostSettings.kt new file mode 100644 index 0000000..2d960d1 --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/BrowserHostSettings.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_dapp_api.data.model + +class BrowserHostSettings( + val hostUrl: String, + val isDesktopModeEnabled: Boolean +) diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/DApp.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/DApp.kt new file mode 100644 index 0000000..19c7572 --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/DApp.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_dapp_api.data.model + +import io.novafoundation.nova.common.list.GroupedList + +class DApp( + val name: String, + val description: String, + val iconLink: String?, + val url: String, + val isFavourite: Boolean, + val favoriteIndex: Int? +) + +data class DAppGroupedCatalog( + val popular: List, + val categoriesWithDApps: GroupedList +) diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/DappMetadata.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/DappMetadata.kt new file mode 100644 index 0000000..a1371aa --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/DappMetadata.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_dapp_api.data.model + +typealias DAppUrl = String + +class DappCatalog( + val popular: List, + val categories: List, + val dApps: List +) + +class DappMetadata( + val name: String, + val iconLink: String, + val url: DAppUrl, + val baseUrl: String, + val categories: Set +) + +data class DappCategory( + val iconUrl: String?, + val name: String, + val id: String +) + +private const val STAKING_CATEGORY_ID = "staking" + +fun DappCategory.isStaking(): Boolean { + return id == STAKING_CATEGORY_ID +} diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/SimpleTabModel.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/SimpleTabModel.kt new file mode 100644 index 0000000..7f9c2a7 --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/model/SimpleTabModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_dapp_api.data.model + +class SimpleTabModel( + val tabId: String, + val title: String?, + val knownDAppIconUrl: String?, + val faviconPath: String? +) diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/repository/BrowserHostSettingsRepository.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/repository/BrowserHostSettingsRepository.kt new file mode 100644 index 0000000..c62fcc7 --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/repository/BrowserHostSettingsRepository.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_dapp_api.data.repository + +import io.novafoundation.nova.feature_dapp_api.data.model.BrowserHostSettings + +interface BrowserHostSettingsRepository { + + suspend fun getBrowserHostSettings(url: String): BrowserHostSettings? + + suspend fun saveBrowserHostSettings(settings: BrowserHostSettings) +} diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/repository/BrowserTabExternalRepository.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/repository/BrowserTabExternalRepository.kt new file mode 100644 index 0000000..2321355 --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/repository/BrowserTabExternalRepository.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_dapp_api.data.repository + +import io.novafoundation.nova.feature_dapp_api.data.model.SimpleTabModel +import kotlinx.coroutines.flow.Flow + +interface BrowserTabExternalRepository { + + fun observeTabsWithNames(metaId: Long): Flow> + + suspend fun removeTabsForMetaAccount(metaId: Long): List +} diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/repository/DAppMetadataRepository.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/repository/DAppMetadataRepository.kt new file mode 100644 index 0000000..33ae1eb --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/data/repository/DAppMetadataRepository.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_dapp_api.data.repository + +import io.novafoundation.nova.feature_dapp_api.data.model.DappCatalog +import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata +import kotlinx.coroutines.flow.Flow + +interface DAppMetadataRepository { + + suspend fun isDAppsSynced(): Boolean + + suspend fun syncDAppMetadatas() + + suspend fun syncAndGetDapp(baseUrl: String): DappMetadata? + + suspend fun getDAppMetadata(baseUrl: String): DappMetadata? + + suspend fun findDAppMetadataByExactUrlMatch(fullUrl: String): DappMetadata? + + suspend fun findDAppMetadatasByBaseUrlMatch(baseUrl: String): List + + suspend fun getDAppCatalog(): DappCatalog + + fun observeDAppCatalog(): Flow +} + +suspend fun DAppMetadataRepository.getDAppIfSyncedOrSync(baseUrl: String): DappMetadata? { + return if (isDAppsSynced()) { + getDAppMetadata(baseUrl) + } else { + syncAndGetDapp(baseUrl) + } +} diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/di/DAppFeatureApi.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/di/DAppFeatureApi.kt new file mode 100644 index 0000000..5fdf10e --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/di/DAppFeatureApi.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_dapp_api.di + +import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserTabExternalRepository +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_api.di.deeplinks.DAppDeepLinks + +interface DAppFeatureApi { + + val dappMetadataRepository: DAppMetadataRepository + + val browserTabsRepository: BrowserTabExternalRepository + + val dAppDeepLinks: DAppDeepLinks +} diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/di/deeplinks/DAppDeepLinks.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/di/deeplinks/DAppDeepLinks.kt new file mode 100644 index 0000000..c190189 --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/di/deeplinks/DAppDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_dapp_api.di.deeplinks + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class DAppDeepLinks(val deepLinkHandlers: List) diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/presentation/addToFavorites/AddToFavouritesPayload.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/presentation/addToFavorites/AddToFavouritesPayload.kt new file mode 100644 index 0000000..694f1a1 --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/presentation/addToFavorites/AddToFavouritesPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class AddToFavouritesPayload( + val url: String, + val label: String?, + val iconLink: String? +) : Parcelable diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/presentation/browser/main/DAppBrowserPayload.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/presentation/browser/main/DAppBrowserPayload.kt new file mode 100644 index 0000000..49bb9dc --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/presentation/browser/main/DAppBrowserPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_dapp_api.presentation.browser.main + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +interface DAppBrowserPayload : Parcelable { + + @Parcelize + class Tab(val id: String) : DAppBrowserPayload + + @Parcelize + class Address(val address: String) : DAppBrowserPayload +} diff --git a/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/presentation/view/DAppView.kt b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/presentation/view/DAppView.kt new file mode 100644 index 0000000..8e044a0 --- /dev/null +++ b/feature-dapp-api/src/main/java/io/novafoundation/nova/feature_dapp_api/presentation/view/DAppView.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_dapp_api.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.ImageView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.feature_dapp_api.R +import io.novafoundation.nova.feature_dapp_api.databinding.ViewDappBinding +import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon + +class DAppView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + private val binder = ViewDappBinding.inflate(inflater(), this) + + companion object { + fun createUsingMathParentWidth(context: Context): DAppView { + return DAppView(context).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + } + } + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + setBackgroundResource(R.drawable.bg_primary_list_item) + } + + fun setTitle(name: String?) { + binder.itemDAppTitle.text = name + } + + fun showTitle(show: Boolean) { + binder.itemDAppTitle.setVisible(show) + } + + fun setSubtitle(url: String?) { + binder.itemDAppSubtitle.text = url + } + + fun showSubtitle(show: Boolean) { + binder.itemDAppSubtitle.setVisible(show) + } + + fun setIconUrl(iconUrl: String?) { + binder.itemDAppIcon.showDAppIcon(iconUrl, imageLoader) + } + + fun setFavoriteIconVisible(visible: Boolean) { + binder.itemDappFavorite.setVisible(visible) + } + + fun enableSubtitleIcon(): ImageView { + return binder.itemDAppSubtitleIcon.also { icon -> icon.makeVisible() } + } + + fun setOnActionClickListener(listener: OnClickListener?) { + binder.itemDappAction.setOnClickListener(listener) + } + + fun setActionResource(@DrawableRes iconRes: Int?, @ColorRes colorRes: Int? = null) { + if (iconRes == null) { + binder.itemDappAction.setImageDrawable(null) + } else { + binder.itemDappAction.setImageResource(iconRes) + binder.itemDappAction.setImageTintRes(colorRes) + } + } + + fun setActionTintRes(@ColorRes color: Int?) { + binder.itemDappAction.setImageTintRes(color) + } + + fun setActionEndPadding(rightPadding: Int) { + binder.itemDappAction.updatePadding(end = rightPadding) + } + + fun clearIcon() { + binder.itemDAppIcon.clear() + } +} diff --git a/feature-dapp-api/src/main/res/color/dapp_category_text_tint.xml b/feature-dapp-api/src/main/res/color/dapp_category_text_tint.xml new file mode 100644 index 0000000..219acec --- /dev/null +++ b/feature-dapp-api/src/main/res/color/dapp_category_text_tint.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/feature-dapp-api/src/main/res/drawable/dapp_icon_background.xml b/feature-dapp-api/src/main/res/drawable/dapp_icon_background.xml new file mode 100644 index 0000000..95da366 --- /dev/null +++ b/feature-dapp-api/src/main/res/drawable/dapp_icon_background.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-api/src/main/res/drawable/dapp_tab_background_default.xml b/feature-dapp-api/src/main/res/drawable/dapp_tab_background_default.xml new file mode 100644 index 0000000..b1bf228 --- /dev/null +++ b/feature-dapp-api/src/main/res/drawable/dapp_tab_background_default.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/feature-dapp-api/src/main/res/drawable/dapp_tab_background_selected.xml b/feature-dapp-api/src/main/res/drawable/dapp_tab_background_selected.xml new file mode 100644 index 0000000..d87e2db --- /dev/null +++ b/feature-dapp-api/src/main/res/drawable/dapp_tab_background_selected.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/feature-dapp-api/src/main/res/drawable/ic_sub_id.xml b/feature-dapp-api/src/main/res/drawable/ic_sub_id.xml new file mode 100644 index 0000000..8679cd4 --- /dev/null +++ b/feature-dapp-api/src/main/res/drawable/ic_sub_id.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/feature-dapp-api/src/main/res/layout/item_dapp_shimmering.xml b/feature-dapp-api/src/main/res/layout/item_dapp_shimmering.xml new file mode 100644 index 0000000..759c10b --- /dev/null +++ b/feature-dapp-api/src/main/res/layout/item_dapp_shimmering.xml @@ -0,0 +1,41 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-api/src/main/res/layout/layout_dapps_shimmering.xml b/feature-dapp-api/src/main/res/layout/layout_dapps_shimmering.xml new file mode 100644 index 0000000..d2bf0fa --- /dev/null +++ b/feature-dapp-api/src/main/res/layout/layout_dapps_shimmering.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-api/src/main/res/layout/view_dapp.xml b/feature-dapp-api/src/main/res/layout/view_dapp.xml new file mode 100644 index 0000000..c69f149 --- /dev/null +++ b/feature-dapp-api/src/main/res/layout/view_dapp.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-api/src/main/res/values/styles.xml b/feature-dapp-api/src/main/res/values/styles.xml new file mode 100644 index 0000000..3224d9f --- /dev/null +++ b/feature-dapp-api/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/feature-dapp-impl/.gitignore b/feature-dapp-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-dapp-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-dapp-impl/build.gradle b/feature-dapp-impl/build.gradle new file mode 100644 index 0000000..030dda3 --- /dev/null +++ b/feature-dapp-impl/build.gradle @@ -0,0 +1,98 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: "../scripts/secrets.gradle" + +android { + + defaultConfig { + + + + buildConfigField "String", "DAPP_METADATAS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/dapps/dapps_dev.json\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + buildConfigField "String", "DAPP_METADATAS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/dapps/dapps_full.json\"" + } + } + namespace 'io.novafoundation.nova.feature_dapp_impl' + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +task actualizeJsScripts(type: Exec) { + workingDir "$rootDir/nova-wallet-dapp-js" + + commandLine "yarn", "build" + + doLast { + copy { + from "$rootDir/nova-wallet-dapp-js/dist/nova_min.js" + into "$rootDir/feature-dapp-impl/src/main/res/raw" + + rename('nova_min.js', 'polkadotjs_min.js') + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-onboarding-api') + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(':feature-wallet-connect-api') + implementation project(':feature-dapp-api') + implementation project(':feature-currency-api') + implementation project(':feature-external-sign-api') + implementation project(':feature-banners-api') + implementation project(':runtime') + implementation project(':feature-deep-linking') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation shimmerDep + + implementation coroutinesDep + + implementation gsonDep + + implementation daggerDep + + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation navigationFragmentDep + + implementation retrofitDep + + implementation web3jDep + implementation coroutinesFutureDep + + implementation walletConnectCoreDep, withoutTransitiveAndroidX + implementation walletConnectWalletDep, withoutTransitiveAndroidX + + testImplementation jUnitDep + testImplementation mockitoDep +} diff --git a/feature-dapp-impl/consumer-rules.pro b/feature-dapp-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-dapp-impl/proguard-rules.pro b/feature-dapp-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-dapp-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-dapp-impl/src/main/AndroidManifest.xml b/feature-dapp-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-dapp-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/mappers/BrowserHostSettingsMappers.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/mappers/BrowserHostSettingsMappers.kt new file mode 100644 index 0000000..98965bb --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/mappers/BrowserHostSettingsMappers.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_dapp_impl.data.mappers + +import io.novafoundation.nova.core_db.model.BrowserHostSettingsLocal +import io.novafoundation.nova.feature_dapp_api.data.model.BrowserHostSettings + +fun mapBrowserHostSettingsToLocal(settings: BrowserHostSettings): BrowserHostSettingsLocal { + return BrowserHostSettingsLocal( + settings.hostUrl, + settings.isDesktopModeEnabled + ) +} + +fun mapBrowserHostSettingsFromLocal(settings: BrowserHostSettingsLocal): BrowserHostSettings { + return BrowserHostSettings( + settings.hostUrl, + settings.isDesktopModeEnabled + ) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/mappers/DappMetadata.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/mappers/DappMetadata.kt new file mode 100644 index 0000000..4103f1b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/mappers/DappMetadata.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_dapp_impl.data.mappers + +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.feature_dapp_api.data.model.DappCatalog +import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory +import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata +import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataResponse + +fun mapDAppMetadataResponseToDAppMetadatas( + response: DappMetadataResponse +): DappCatalog { + val categories = response.categories.map { + DappCategory( + iconUrl = it.icon, + name = it.name, + id = it.id + ) + } + + val categoriesAssociatedById = categories.associateBy { it.id } + + val metadata = response.dapps.map { + DappMetadata( + name = it.name, + iconLink = it.icon, + url = it.url, + baseUrl = Urls.normalizeUrl(it.url), + categories = it.categories.mapNotNullTo(mutableSetOf(), categoriesAssociatedById::get), + ) + } + + return DappCatalog(response.popular.map { it.url }, categories, metadata) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/mappers/FavouriteDapps.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/mappers/FavouriteDapps.kt new file mode 100644 index 0000000..098cb14 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/mappers/FavouriteDapps.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_dapp_impl.data.mappers + +import io.novafoundation.nova.core_db.model.FavouriteDAppLocal +import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp + +fun mapFavouriteDAppLocalToFavouriteDApp(favouriteDAppLocal: FavouriteDAppLocal): FavouriteDApp { + return with(favouriteDAppLocal) { + FavouriteDApp( + url = url, + label = label, + icon = icon, + orderingIndex = orderingIndex + ) + } +} + +fun mapFavouriteDAppToFavouriteDAppLocal(favouriteDApp: FavouriteDApp): FavouriteDAppLocal { + return with(favouriteDApp) { + FavouriteDAppLocal( + url = url, + label = label, + icon = icon, + orderingIndex = orderingIndex + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/model/FavouriteDApp.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/model/FavouriteDApp.kt new file mode 100644 index 0000000..fed9491 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/model/FavouriteDApp.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_dapp_impl.data.model + +data class FavouriteDApp( + val url: String, + val label: String, + val icon: String?, + val orderingIndex: Int +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/metadata/DappMetadataApi.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/metadata/DappMetadataApi.kt new file mode 100644 index 0000000..4fdf2dd --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/metadata/DappMetadataApi.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_dapp_impl.data.network.metadata + +import retrofit2.http.GET +import retrofit2.http.Url + +interface DappMetadataApi { + + @GET + suspend fun getParachainMetadata(@Url url: String): DappMetadataResponse +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/metadata/DappMetadataRemote.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/metadata/DappMetadataRemote.kt new file mode 100644 index 0000000..102a91d --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/metadata/DappMetadataRemote.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_dapp_impl.data.network.metadata + +class DappMetadataResponse( + val popular: List, + val categories: List, + val dapps: List +) + +class DappMetadataRemote( + val name: String, + val icon: String, + val url: String, + val categories: List, + val desktopOnly: Boolean? +) + +class DappCategoryRemote( + val icon: String?, + val name: String, + val id: String +) + +class DappPopularRemote(val url: String) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/phishing/PhishingSitesApi.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/phishing/PhishingSitesApi.kt new file mode 100644 index 0000000..1a56831 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/phishing/PhishingSitesApi.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_dapp_impl.data.network.phishing + +import retrofit2.http.GET + +interface PhishingSitesApi { + + @GET("https://raw.githubusercontent.com/polkadot-js/phishing/master/all.json") + suspend fun getPhishingSites(): PhishingSitesRemote +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/phishing/PhishingSitesRemote.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/phishing/PhishingSitesRemote.kt new file mode 100644 index 0000000..c4717b3 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/network/phishing/PhishingSitesRemote.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_dapp_impl.data.network.phishing + +class PhishingSitesRemote( + val deny: List +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/phisning/PhishingDetectingService.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/phisning/PhishingDetectingService.kt new file mode 100644 index 0000000..8a69372 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/phisning/PhishingDetectingService.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_dapp_impl.data.phisning + +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.core_db.dao.PhishingSitesDao + +interface PhishingDetectingService { + + suspend fun isPhishing(url: String): Boolean +} + +class CompoundPhishingDetectingService( + private val services: List +) : PhishingDetectingService { + + override suspend fun isPhishing(url: String): Boolean { + return services.any { it.isPhishing(url) } + } +} + +class BlackListPhishingDetectingService( + private val phishingSitesDao: PhishingSitesDao, +) : PhishingDetectingService { + + override suspend fun isPhishing(url: String): Boolean { + val host = Urls.hostOf(url) + val hostSuffixes = extractAllPossibleSubDomains(host) + + return phishingSitesDao.isPhishing(hostSuffixes) + } + + private fun extractAllPossibleSubDomains(host: String): List { + val separator = "." + + val segments = host.split(separator) + + val suffixes = (2..segments.size).map { suffixLength -> + segments.takeLast(suffixLength).joinToString(separator = ".") + } + + return suffixes + } +} + +class DomainListPhishingDetectingService( + private val blackListDomains: List // top +) : PhishingDetectingService { + + override suspend fun isPhishing(url: String): Boolean { + val host = Urls.hostOf(url) + val urlTopLevelDomain = extractTopLevelDomain(host) + + return blackListDomains.any { urlTopLevelDomain == it } + } + + private fun extractTopLevelDomain(host: String): String { + val separator = "." + + val segments = host.split(separator) + + return segments.last() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/DefaultMetamaskChainRepository.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/DefaultMetamaskChainRepository.kt new file mode 100644 index 0000000..f795c4b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/DefaultMetamaskChainRepository.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_dapp_impl.data.repository + +import com.google.gson.Gson +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain +import javax.inject.Inject + +interface DefaultMetamaskChainRepository { + + fun getDefaultMetamaskChain(): MetamaskChain? + + fun saveDefaultMetamaskChain(chain: MetamaskChain) +} + +private const val PREFERENCES_KEY = "RealDefaultMetamaskChainRepository.DefaultMetamaskChain" + +@FeatureScope +class RealDefaultMetamaskChainRepository @Inject constructor( + private val preferences: Preferences, + private val gson: Gson, +) : DefaultMetamaskChainRepository { + + override fun getDefaultMetamaskChain(): MetamaskChain? { + val raw = preferences.getString(PREFERENCES_KEY) ?: return null + return runCatching { gson.fromJson(raw) }.getOrNull() + } + + override fun saveDefaultMetamaskChain(chain: MetamaskChain) { + val raw = gson.toJson(chain) + preferences.putString(PREFERENCES_KEY, raw) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/FavouritesDAppRepository.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/FavouritesDAppRepository.kt new file mode 100644 index 0000000..c0fb1ea --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/FavouritesDAppRepository.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_dapp_impl.data.repository + +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.FavouriteDAppsDao +import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapFavouriteDAppLocalToFavouriteDApp +import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapFavouriteDAppToFavouriteDAppLocal +import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp +import kotlinx.coroutines.flow.Flow + +interface FavouritesDAppRepository { + + fun observeFavourites(): Flow> + + suspend fun getFavourites(): List + + suspend fun addFavourite(favouriteDApp: FavouriteDApp) + + fun observeIsFavourite(url: String): Flow + + suspend fun removeFavourite(dAppUrl: String) + + suspend fun updateFavoriteDapps(favoriteDapps: List) + + suspend fun getNextOrderingIndex(): Int +} + +class DbFavouritesDAppRepository( + private val favouriteDAppsDao: FavouriteDAppsDao +) : FavouritesDAppRepository { + + override fun observeFavourites(): Flow> { + return favouriteDAppsDao.observeFavouriteDApps() + .mapList(::mapFavouriteDAppLocalToFavouriteDApp) + } + + override suspend fun getFavourites(): List { + return favouriteDAppsDao.getFavouriteDApps() + .map(::mapFavouriteDAppLocalToFavouriteDApp) + } + + override suspend fun addFavourite(favouriteDApp: FavouriteDApp) { + val local = mapFavouriteDAppToFavouriteDAppLocal(favouriteDApp) + + favouriteDAppsDao.insertFavouriteDApp(local) + } + + override fun observeIsFavourite(url: String): Flow { + return favouriteDAppsDao.observeIsFavourite(url) + } + + override suspend fun removeFavourite(dAppUrl: String) { + favouriteDAppsDao.deleteFavouriteDApp(dAppUrl) + } + + override suspend fun updateFavoriteDapps(favoriteDapps: List) { + val newDapps = favoriteDapps.map { mapFavouriteDAppToFavouriteDAppLocal(it) } + val currentDapps = favouriteDAppsDao.getFavouriteDApps() + val diff = CollectionDiffer.findDiff(newDapps, currentDapps, false) + favouriteDAppsDao.updateFavourites(diff.updated) + } + + override suspend fun getNextOrderingIndex(): Int { + return try { + favouriteDAppsDao.getMaxOrderingIndex() + 1 + } catch (e: NullPointerException) { // For case we don't have added favorite dapps + 0 + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/PhishingSitesRepository.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/PhishingSitesRepository.kt new file mode 100644 index 0000000..0ecc00b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/PhishingSitesRepository.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_dapp_impl.data.repository + +import io.novafoundation.nova.common.utils.retryUntilDone +import io.novafoundation.nova.core_db.dao.PhishingSitesDao +import io.novafoundation.nova.core_db.model.PhishingSiteLocal +import io.novafoundation.nova.feature_dapp_impl.data.network.phishing.PhishingSitesApi +import io.novafoundation.nova.feature_dapp_impl.data.phisning.PhishingDetectingService + +interface PhishingSitesRepository { + + suspend fun syncPhishingSites() + + suspend fun isPhishing(url: String): Boolean +} + +class PhishingSitesRepositoryImpl( + private val phishingSitesDao: PhishingSitesDao, + private val phishingSitesApi: PhishingSitesApi, + private val phishingDetectingService: PhishingDetectingService +) : PhishingSitesRepository { + + override suspend fun syncPhishingSites() { + val remotePhishingSites = retryUntilDone { phishingSitesApi.getPhishingSites() } + val toInsert = remotePhishingSites.deny.map(::PhishingSiteLocal) + + phishingSitesDao.updatePhishingSites(toInsert) + } + + override suspend fun isPhishing(url: String): Boolean { + return phishingDetectingService.isPhishing(url) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealBrowserHostSettingsRepository.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealBrowserHostSettingsRepository.kt new file mode 100644 index 0000000..cf5e2be --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealBrowserHostSettingsRepository.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_dapp_impl.data.repository + +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao +import io.novafoundation.nova.feature_dapp_api.data.model.BrowserHostSettings +import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserHostSettingsRepository +import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapBrowserHostSettingsFromLocal +import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapBrowserHostSettingsToLocal + +class RealBrowserHostSettingsRepository( + private val browserHostSettingsDao: BrowserHostSettingsDao +) : BrowserHostSettingsRepository { + override suspend fun getBrowserHostSettings(url: String): BrowserHostSettings? { + val hostUrl = Urls.normalizeUrl(url) + val localSettings = browserHostSettingsDao.getBrowserHostSettings(hostUrl) + return localSettings?.let { mapBrowserHostSettingsFromLocal(it) } + } + + override suspend fun saveBrowserHostSettings(settings: BrowserHostSettings) { + val localSettings = mapBrowserHostSettingsToLocal(settings) + browserHostSettingsDao.insertBrowserHostSettings(localSettings) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepository.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepository.kt new file mode 100644 index 0000000..8c999fc --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/RealDAppMetadataRepository.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_dapp_impl.data.repository + +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.utils.retryUntilDone +import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao +import io.novafoundation.nova.core_db.model.BrowserHostSettingsLocal +import io.novafoundation.nova.feature_dapp_api.data.model.DappCatalog +import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.data.mappers.mapDAppMetadataResponseToDAppMetadatas +import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataApi +import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataRemote +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first + +class RealDAppMetadataRepository( + private val dappMetadataApi: DappMetadataApi, + private val remoteApiUrl: String, + private val browserHostSettingsDao: BrowserHostSettingsDao +) : DAppMetadataRepository { + + private val dappMetadatasFlow = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + override suspend fun isDAppsSynced(): Boolean { + return dappMetadatasFlow.replayCache.isNotEmpty() + } + + override suspend fun syncDAppMetadatas() { + val response = retryUntilDone { dappMetadataApi.getParachainMetadata(remoteApiUrl) } + val dappMetadatas = mapDAppMetadataResponseToDAppMetadatas(response) + syncHostSettings(response.dapps) + dappMetadatasFlow.emit(dappMetadatas) + } + + override suspend fun syncAndGetDapp(baseUrl: String): DappMetadata? { + syncDAppMetadatas() + + return getDAppMetadata(baseUrl) + } + + override suspend fun getDAppMetadata(baseUrl: String): DappMetadata? { + return dappMetadatasFlow.first() + .dApps + .find { it.baseUrl == baseUrl } + } + + override suspend fun findDAppMetadataByExactUrlMatch(fullUrl: String): DappMetadata? { + return dappMetadatasFlow.first() + .dApps + .find { it.url == fullUrl } + } + + override suspend fun findDAppMetadatasByBaseUrlMatch(baseUrl: String): List { + return dappMetadatasFlow.first() + .dApps + .filter { it.baseUrl == baseUrl } + } + + override suspend fun getDAppCatalog(): DappCatalog { + return dappMetadatasFlow.first() + } + + override fun observeDAppCatalog(): Flow { + return dappMetadatasFlow + } + + private suspend fun syncHostSettings(dappMetadatas: List) { + val oldSettings = browserHostSettingsDao.getBrowserAllHostSettings() + val newSettings = dappMetadatas + .filter { it.desktopOnly != null } + .map { BrowserHostSettingsLocal(Urls.normalizeUrl(it.url), it.desktopOnly!!) } + val differ = CollectionDiffer.findDiff(newSettings, oldSettings, false) + browserHostSettingsDao.insertBrowserHostSettings(differ.added) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/tabs/BrowserTabInternalRepository.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/tabs/BrowserTabInternalRepository.kt new file mode 100644 index 0000000..e2871a4 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/tabs/BrowserTabInternalRepository.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_dapp_impl.data.repository.tabs + +import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserTabExternalRepository +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTab +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.PageSnapshot +import kotlinx.coroutines.flow.Flow + +interface BrowserTabInternalRepository : BrowserTabExternalRepository { + + suspend fun saveTab(tab: BrowserTab) + + suspend fun removeTab(tabId: String) + + suspend fun savePageSnapshot(tabId: String, snapshot: PageSnapshot) + + fun observeTabs(metaId: Long): Flow> + + suspend fun changeCurrentUrl(tabId: String, url: String) + + suspend fun changeKnownDAppMetadata(tabId: String, dappIconUrl: String?) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/tabs/RealBrowserTabRepository.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/tabs/RealBrowserTabRepository.kt new file mode 100644 index 0000000..f64c320 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/data/repository/tabs/RealBrowserTabRepository.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_dapp_impl.data.repository.tabs + +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.BrowserTabsDao +import io.novafoundation.nova.core_db.model.BrowserTabLocal +import io.novafoundation.nova.feature_dapp_api.data.model.SimpleTabModel +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTab +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.PageSnapshot +import java.util.Date +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +class RealBrowserTabRepository( + private val browserTabsDao: BrowserTabsDao +) : BrowserTabInternalRepository { + + override suspend fun saveTab(tab: BrowserTab) { + browserTabsDao.insertTab(tab.toLocal()) + } + + override suspend fun removeTab(tabId: String) { + browserTabsDao.removeTab(tabId) + } + + override fun observeTabsWithNames(metaId: Long): Flow> { + return browserTabsDao.observeTabsByMetaId(metaId) + .mapList { + SimpleTabModel(it.id, it.pageName, it.knownDAppMetadata?.iconLink, it.pageIconPath) + } + } + + override suspend fun removeTabsForMetaAccount(metaId: Long): List { + return withContext(Dispatchers.Default) { + browserTabsDao.removeTabsByMetaId(metaId) + } + } + + override suspend fun savePageSnapshot(tabId: String, snapshot: PageSnapshot) { + browserTabsDao.updatePageSnapshot( + tabId = tabId, + pageName = snapshot.pageName, + pageIconPath = snapshot.pageIconPath, + pagePicturePath = snapshot.pagePicturePath + ) + } + + override fun observeTabs(metaId: Long): Flow> { + return browserTabsDao.observeTabsByMetaId(metaId).mapList { tab -> + tab.fromLocal() + } + } + + override suspend fun changeCurrentUrl(tabId: String, url: String) { + withContext(Dispatchers.Default) { browserTabsDao.updateCurrentUrl(tabId, url) } + } + + override suspend fun changeKnownDAppMetadata(tabId: String, dappIconUrl: String?) { + withContext(Dispatchers.Default) { browserTabsDao.updateKnownDAppMetadata(tabId, dappIconUrl) } + } +} + +private fun BrowserTabLocal.fromLocal(): BrowserTab { + return BrowserTab( + id = id, + metaId = metaId, + currentUrl = currentUrl, + pageSnapshot = PageSnapshot( + pageName = pageName, + pageIconPath = pageIconPath, + pagePicturePath = pagePicturePath + ), + knownDAppMetadata = knownDAppMetadata?.let { BrowserTab.KnownDAppMetadata(it.iconLink) }, + creationTime = Date(creationTime), + ) +} + +private fun BrowserTab.toLocal(): BrowserTabLocal { + return BrowserTabLocal( + id = id, + metaId = metaId, + currentUrl = currentUrl, + creationTime = creationTime.time, + pageName = pageSnapshot.pageName, + pageIconPath = pageSnapshot.pageIconPath, + knownDAppMetadata = knownDAppMetadata?.let { BrowserTabLocal.KnownDAppMetadata(it.iconLink) }, + pagePicturePath = pageSnapshot.pagePicturePath + ) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DAppFeatureComponent.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DAppFeatureComponent.kt new file mode 100644 index 0000000..ceca271 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DAppFeatureComponent.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_dapp_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.di.AddToFavouritesComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.di.AuthorizedDAppsComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.di.DAppBrowserComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.favorites.di.DAppFavoritesComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.main.di.MainDAppComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator +import io.novafoundation.nova.feature_dapp_impl.presentation.search.di.DAppSearchComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.tab.di.BrowserTabsComponent +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + DAppFeatureDependencies::class + ], + modules = [ + DappFeatureModule::class + ] +) +@FeatureScope +interface DAppFeatureComponent : DAppFeatureApi { + + fun mainComponentFactory(): MainDAppComponent.Factory + + fun browserComponentFactory(): DAppBrowserComponent.Factory + + fun browserTabsComponentFactory(): BrowserTabsComponent.Factory + + fun dAppSearchComponentFactory(): DAppSearchComponent.Factory + + fun dAppFavoritesComponentFactory(): DAppFavoritesComponent.Factory + + fun addToFavouritesComponentFactory(): AddToFavouritesComponent.Factory + + fun authorizedDAppsComponentFactory(): AuthorizedDAppsComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: DAppRouter, + @BindsInstance signCommunicator: ExternalSignCommunicator, + @BindsInstance searchCommunicator: DAppSearchCommunicator, + deps: DAppFeatureDependencies + ): DAppFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + AccountFeatureApi::class, + WalletFeatureApi::class, + RuntimeApi::class, + CurrencyFeatureApi::class, + BannersFeatureApi::class, + WalletConnectFeatureApi::class + ] + ) + interface DAppFeatureDependenciesComponent : DAppFeatureDependencies +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DAppFeatureDependencies.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DAppFeatureDependencies.kt new file mode 100644 index 0000000..a07968b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DAppFeatureDependencies.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_dapp_impl.di + +import android.content.Context +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.IntegrityService +import io.novafoundation.nova.common.utils.ToastMessageManager +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao +import io.novafoundation.nova.core_db.dao.BrowserTabsDao +import io.novafoundation.nova.core_db.dao.DappAuthorizationDao +import io.novafoundation.nova.core_db.dao.FavouriteDAppsDao +import io.novafoundation.nova.core_db.dao.PhishingSitesDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.runtime.di.ExtrinsicSerialization +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository + +interface DAppFeatureDependencies { + + val amountFormatter: AmountFormatter + + val context: Context + + val browserTabsDao: BrowserTabsDao + + val phishingSitesDao: PhishingSitesDao + + val favouriteDAppsDao: FavouriteDAppsDao + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val walletUiUseCase: WalletUiUseCase + + val walletRepository: WalletRepository + + val fileProvider: FileProvider + + val contextManager: ContextManager + + val rootScope: RootScope + + val permissionsAskerFactory: PermissionsAskerFactory + + val bannerSourceFactory: BannersSourceFactory + + val bannersMixinFactory: PromotionBannersMixinFactory + + val webViewPermissionAskerFactory: WebViewPermissionAskerFactory + + val webViewFileChooserFactory: WebViewFileChooserFactory + + val preferences: Preferences + + val walletConnectService: WalletConnectService + + val automaticInteractionGate: AutomaticInteractionGate + + val integrityService: IntegrityService + + val deviceIdProvider: DeviceIdProvider + + fun currencyRepository(): CurrencyRepository + + fun accountRepository(): AccountRepository + + fun resourceManager(): ResourceManager + + fun appLinksProvider(): AppLinksProvider + + fun selectedAccountUseCase(): SelectedAccountUseCase + + fun addressIconGenerator(): AddressIconGenerator + + fun gson(): Gson + + fun chainRegistry(): ChainRegistry + + fun imageLoader(): ImageLoader + + fun feeLoaderMixinFactory(): FeeLoaderMixin.Factory + + fun extrinsicService(): ExtrinsicService + + fun tokenRepository(): TokenRepository + + fun secretStoreV2(): SecretStoreV2 + + @ExtrinsicSerialization + fun extrinsicGson(): Gson + + fun apiCreator(): NetworkApiCreator + + fun runtimeVersionsRepository(): RuntimeVersionsRepository + + fun dappAuthorizationDao(): DappAuthorizationDao + + fun browserHostSettingsDao(): BrowserHostSettingsDao + + fun toastMessageManager(): ToastMessageManager +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DAppFeatureHolder.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DAppFeatureHolder.kt new file mode 100644 index 0000000..1eed44d --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DAppFeatureHolder.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_dapp_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_banners_api.di.BannersFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class DAppFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: DAppRouter, + private val signCommunicator: ExternalSignCommunicator, + private val searchCommunicator: DAppSearchCommunicator, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dApp = DaggerDAppFeatureComponent_DAppFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .bannersFeatureApi(getFeature(BannersFeatureApi::class.java)) + .currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java)) + .walletConnectFeatureApi(getFeature(WalletConnectFeatureApi::class.java)) + .build() + + return DaggerDAppFeatureComponent.factory() + .create(router, signCommunicator, searchCommunicator, dApp) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DappFeatureModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DappFeatureModule.kt new file mode 100644 index 0000000..944999f --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/DappFeatureModule.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_dapp_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository +import io.novafoundation.nova.feature_dapp_impl.di.modules.BrowserTabsModule +import io.novafoundation.nova.feature_dapp_impl.di.modules.DappMetadataModule +import io.novafoundation.nova.feature_dapp_impl.di.modules.FavouritesDAppModule +import io.novafoundation.nova.feature_dapp_impl.di.modules.PhishingSitesModule +import io.novafoundation.nova.feature_dapp_impl.di.modules.Web3Module +import io.novafoundation.nova.feature_dapp_impl.di.modules.deeplinks.DeepLinkModule +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor + +@Module( + includes = [ + Web3Module::class, + DappMetadataModule::class, + PhishingSitesModule::class, + FavouritesDAppModule::class, + BrowserTabsModule::class, + DeepLinkModule::class + ] +) +class DappFeatureModule { + + @Provides + @FeatureScope + fun provideCommonInteractor( + dAppMetadataRepository: DAppMetadataRepository, + favouritesDAppRepository: FavouritesDAppRepository, + phishingSitesRepository: PhishingSitesRepository + ) = DappInteractor( + dAppMetadataRepository = dAppMetadataRepository, + favouritesDAppRepository = favouritesDAppRepository, + phishingSitesRepository = phishingSitesRepository + ) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/BrowserTabsModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/BrowserTabsModule.kt new file mode 100644 index 0000000..7b9fe5b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/BrowserTabsModule.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_dapp_impl.di.modules + +import android.content.Context +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.utils.IntegrityService +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.core_db.dao.BrowserTabsDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserTabExternalRepository +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService +import io.novafoundation.nova.feature_dapp_impl.data.repository.tabs.BrowserTabInternalRepository +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.RealPageSnapshotBuilder +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.RealBrowserTabService +import io.novafoundation.nova.feature_dapp_impl.data.repository.tabs.RealBrowserTabRepository +import io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck.IntegrityCheckJSBridgeFactory +import io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck.IntegrityCheckSessionFactory +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.PageSnapshotBuilder +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.RealTabMemoryRestrictionService +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.TabMemoryRestrictionService +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTabSessionFactory +import io.novafoundation.nova.feature_dapp_impl.web3.webview.CompoundWeb3Injector + +@Module +class BrowserTabsModule { + + @FeatureScope + @Provides + fun provideBrowserTabStorage( + browserTabsDao: BrowserTabsDao, + ): BrowserTabInternalRepository { + return RealBrowserTabRepository(browserTabsDao = browserTabsDao) + } + + @FeatureScope + @Provides + fun provideBrowserTabRepository( + repository: BrowserTabInternalRepository, + ): BrowserTabExternalRepository { + return repository + } + + @FeatureScope + @Provides + fun providePageSnapshotBuilder(fileProvider: FileProvider, rootScope: RootScope): PageSnapshotBuilder { + return RealPageSnapshotBuilder(fileProvider, rootScope) + } + + @FeatureScope + @Provides + fun provideTabMemoryRestrictionService(context: Context): TabMemoryRestrictionService { + return RealTabMemoryRestrictionService(context) + } + + @FeatureScope + @Provides + fun provideIntegrityCheckSessionFactory( + apiCreator: NetworkApiCreator, + preferences: Preferences, + integrityService: IntegrityService, + deviceIdProvider: DeviceIdProvider + ) = IntegrityCheckSessionFactory( + apiCreator, + preferences, + integrityService, + deviceIdProvider + ) + + @FeatureScope + @Provides + fun provideIntegrityCheckProviderFactory( + integrityCheckSessionFactory: IntegrityCheckSessionFactory + ) = IntegrityCheckJSBridgeFactory(integrityCheckSessionFactory) + + @FeatureScope + @Provides + fun providePageSessionFactory( + compoundWeb3Injector: CompoundWeb3Injector, + contextManager: ContextManager, + integrityCheckJSBridgeFactory: IntegrityCheckJSBridgeFactory + ): BrowserTabSessionFactory { + return BrowserTabSessionFactory(compoundWeb3Injector, contextManager, integrityCheckJSBridgeFactory) + } + + @FeatureScope + @Provides + fun provideBrowserTabPoolService( + accountRepository: AccountRepository, + dAppMetadataRepository: DAppMetadataRepository, + browserTabInternalRepository: BrowserTabInternalRepository, + pageSnapshotBuilder: PageSnapshotBuilder, + tabMemoryRestrictionService: TabMemoryRestrictionService, + browserTabSessionFactory: BrowserTabSessionFactory, + rootScope: RootScope + ): BrowserTabService { + return RealBrowserTabService( + browserTabInternalRepository = browserTabInternalRepository, + pageSnapshotBuilder = pageSnapshotBuilder, + tabMemoryRestrictionService = tabMemoryRestrictionService, + browserTabSessionFactory = browserTabSessionFactory, + accountRepository = accountRepository, + dAppMetadataRepository = dAppMetadataRepository, + rootScope = rootScope + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/DappMetadataModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/DappMetadataModule.kt new file mode 100644 index 0000000..941da9c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/DappMetadataModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_dapp_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.BuildConfig +import io.novafoundation.nova.feature_dapp_impl.data.network.metadata.DappMetadataApi +import io.novafoundation.nova.feature_dapp_impl.data.repository.RealDAppMetadataRepository + +@Module +class DappMetadataModule { + + @Provides + @FeatureScope + fun provideApi( + apiCreator: NetworkApiCreator + ) = apiCreator.create(DappMetadataApi::class.java) + + @Provides + @FeatureScope + fun provideDRepository( + api: DappMetadataApi, + dappHostSettingsDao: BrowserHostSettingsDao + ): DAppMetadataRepository = RealDAppMetadataRepository( + dappMetadataApi = api, + remoteApiUrl = BuildConfig.DAPP_METADATAS_URL, + browserHostSettingsDao = dappHostSettingsDao + ) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/FavouritesDAppModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/FavouritesDAppModule.kt new file mode 100644 index 0000000..81d1b9a --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/FavouritesDAppModule.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_dapp_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.FavouriteDAppsDao +import io.novafoundation.nova.feature_dapp_impl.data.repository.DbFavouritesDAppRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository + +@Module +class FavouritesDAppModule { + + @Provides + @FeatureScope + fun provideFavouritesDAppRepository( + dao: FavouriteDAppsDao + ): FavouritesDAppRepository = DbFavouritesDAppRepository(dao) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/PhishingSitesModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/PhishingSitesModule.kt new file mode 100644 index 0000000..561498b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/PhishingSitesModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_dapp_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.PhishingSitesDao +import io.novafoundation.nova.feature_dapp_impl.data.network.phishing.PhishingSitesApi +import io.novafoundation.nova.feature_dapp_impl.data.phisning.BlackListPhishingDetectingService +import io.novafoundation.nova.feature_dapp_impl.data.phisning.CompoundPhishingDetectingService +import io.novafoundation.nova.feature_dapp_impl.data.phisning.DomainListPhishingDetectingService +import io.novafoundation.nova.feature_dapp_impl.data.phisning.PhishingDetectingService +import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepositoryImpl + +private val PHISHING_DOMAINS = listOf("top") + +@Module +class PhishingSitesModule { + + @Provides + @FeatureScope + fun providePhishingDetectingService( + phishingSitesDao: PhishingSitesDao + ): PhishingDetectingService { + return CompoundPhishingDetectingService( + listOf( + BlackListPhishingDetectingService(phishingSitesDao), + DomainListPhishingDetectingService(PHISHING_DOMAINS) + ) + ) + } + + @Provides + @FeatureScope + fun providePhishingSitesApi(networkApiCreator: NetworkApiCreator): PhishingSitesApi { + return networkApiCreator.create(PhishingSitesApi::class.java) + } + + @Provides + @FeatureScope + fun providePhishingSitesRepository( + api: PhishingSitesApi, + phishingSitesDao: PhishingSitesDao, + phishingDetectingService: PhishingDetectingService + ): PhishingSitesRepository = PhishingSitesRepositoryImpl(phishingSitesDao, api, phishingDetectingService) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/Web3Module.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/Web3Module.kt new file mode 100644 index 0000000..936c635 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/Web3Module.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_dapp_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.core_db.dao.DappAuthorizationDao +import io.novafoundation.nova.feature_dapp_impl.di.modules.web3.MetamaskModule +import io.novafoundation.nova.feature_dapp_impl.di.modules.web3.PolkadotJsModule +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.states.MetamaskStateFactory +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskInjector +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskTransportFactory +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsInjector +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsTransportFactory +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states.PolkadotJsStateFactory +import io.novafoundation.nova.feature_dapp_impl.web3.session.DbWeb3Session +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_dapp_impl.web3.states.ExtensionStoreFactory +import io.novafoundation.nova.feature_dapp_impl.web3.webview.CompoundWeb3Injector +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewScriptInjector + +@Module(includes = [PolkadotJsModule::class, MetamaskModule::class]) +class Web3Module { + + @Provides + @FeatureScope + fun provideWebViewHolder() = WebViewHolder() + + @Provides + @FeatureScope + fun provideScriptInjector( + resourceManager: ResourceManager, + ) = WebViewScriptInjector(resourceManager) + + @Provides + @FeatureScope + fun provideWeb3InjectorPool( + polkadotJsInjector: PolkadotJsInjector, + metamaskInjector: MetamaskInjector, + ) = CompoundWeb3Injector( + injectors = listOf( + polkadotJsInjector, + metamaskInjector + ) + ) + + @Provides + @FeatureScope + fun provideWeb3Session( + dappAuthorizationDao: DappAuthorizationDao + ): Web3Session = DbWeb3Session(dappAuthorizationDao) + + @Provides + @FeatureScope + fun provideExtensionStoreFactory( + polkadotJsStateFactory: PolkadotJsStateFactory, + polkadotJsTransportFactory: PolkadotJsTransportFactory, + metamaskStateFactory: MetamaskStateFactory, + metamaskTransportFactory: MetamaskTransportFactory, + ) = ExtensionStoreFactory( + polkadotJsStateFactory = polkadotJsStateFactory, + polkadotJsTransportFactory = polkadotJsTransportFactory, + metamaskStateFactory = metamaskStateFactory, + metamaskTransportFactory = metamaskTransportFactory + ) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/deeplinks/DeepLinkModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/deeplinks/DeepLinkModule.kt new file mode 100644 index 0000000..7c8146f --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/deeplinks/DeepLinkModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_dapp_impl.di.modules.deeplinks + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_api.di.deeplinks.DAppDeepLinks +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.deeplink.DAppDeepLinkHandler +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideDappDeepLinkHandler( + dAppMetadataRepository: DAppMetadataRepository, + router: DAppRouter, + automaticInteractionGate: AutomaticInteractionGate, + browserTabService: BrowserTabService + ) = DAppDeepLinkHandler( + dAppMetadataRepository, + router, + automaticInteractionGate, + browserTabService + ) + + @Provides + @FeatureScope + fun provideDeepLinks(dapp: DAppDeepLinkHandler): DAppDeepLinks { + return DAppDeepLinks(listOf(dapp)) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/web3/MetamaskModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/web3/MetamaskModule.kt new file mode 100644 index 0000000..df98a3f --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/web3/MetamaskModule.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.feature_dapp_impl.di.modules.web3 + +import com.google.gson.Gson +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_dapp_impl.BuildConfig +import io.novafoundation.nova.feature_dapp_impl.data.repository.DefaultMetamaskChainRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.RealDefaultMetamaskChainRepository +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.browser.metamask.MetamaskInteractor +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.di.Metamask +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.states.MetamaskStateFactory +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskInjector +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskResponder +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskTransportFactory +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewScriptInjector +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3JavaScriptInterface +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [MetamaskModule.BindsModule::class]) +class MetamaskModule { + + @Module + interface BindsModule { + + @Binds + fun bindDefaultMetamaskChainRepository(implementation: RealDefaultMetamaskChainRepository): DefaultMetamaskChainRepository + } + + @Provides + @Metamask + @FeatureScope + fun provideWeb3JavaScriptInterface() = WebViewWeb3JavaScriptInterface() + + @Provides + @FeatureScope + fun provideInjector( + gson: Gson, + webViewScriptInjector: WebViewScriptInjector, + @Metamask web3JavaScriptInterface: WebViewWeb3JavaScriptInterface, + ) = MetamaskInjector( + isDebug = BuildConfig.DEBUG, + gson = gson, + jsInterface = web3JavaScriptInterface, + webViewScriptInjector = webViewScriptInjector + ) + + @Provides + @FeatureScope + fun provideResponder(webViewHolder: WebViewHolder): MetamaskResponder { + return MetamaskResponder(webViewHolder) + } + + @Provides + @FeatureScope + fun provideTransportFactory( + responder: MetamaskResponder, + @Metamask web3JavaScriptInterface: WebViewWeb3JavaScriptInterface, + gson: Gson + ): MetamaskTransportFactory { + return MetamaskTransportFactory( + webViewWeb3JavaScriptInterface = web3JavaScriptInterface, + gson = gson, + responder = responder, + ) + } + + @Provides + @FeatureScope + fun provideInteractor( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + defaultMetamaskChainRepository: DefaultMetamaskChainRepository, + ) = MetamaskInteractor(accountRepository, chainRegistry, defaultMetamaskChainRepository) + + @Provides + @FeatureScope + fun provideStateFactory( + interactor: MetamaskInteractor, + commonInteractor: DappInteractor, + resourceManager: ResourceManager, + addressIconGenerator: AddressIconGenerator, + web3Session: Web3Session, + walletUiUseCase: WalletUiUseCase, + ): MetamaskStateFactory { + return MetamaskStateFactory( + interactor = interactor, + commonInteractor = commonInteractor, + resourceManager = resourceManager, + addressIconGenerator = addressIconGenerator, + web3Session = web3Session, + walletUiUseCase = walletUiUseCase + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/web3/PolkadotJsModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/web3/PolkadotJsModule.kt new file mode 100644 index 0000000..e777534 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/di/modules/web3/PolkadotJsModule.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_dapp_impl.di.modules.web3 + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.browser.polkadotJs.PolkadotJsExtensionInteractor +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsInjector +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsResponder +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsTransportFactory +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.di.PolkadotJs +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states.PolkadotJsStateFactory +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewScriptInjector +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3JavaScriptInterface +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository + +@Module +class PolkadotJsModule { + + @Provides + @PolkadotJs + @FeatureScope + fun provideWeb3JavaScriptInterface() = WebViewWeb3JavaScriptInterface() + + @Provides + @FeatureScope + fun providePolkadotJsInjector( + webViewScriptInjector: WebViewScriptInjector, + @PolkadotJs web3JavaScriptInterface: WebViewWeb3JavaScriptInterface, + ) = PolkadotJsInjector(web3JavaScriptInterface, webViewScriptInjector) + + @Provides + @FeatureScope + fun provideResponder(webViewHolder: WebViewHolder): PolkadotJsResponder { + return PolkadotJsResponder(webViewHolder) + } + + @Provides + @FeatureScope + fun providePolkadotJsTransportFactory( + web3Responder: PolkadotJsResponder, + @PolkadotJs web3JavaScriptInterface: WebViewWeb3JavaScriptInterface, + gson: Gson + ): PolkadotJsTransportFactory { + return PolkadotJsTransportFactory( + webViewWeb3JavaScriptInterface = web3JavaScriptInterface, + gson = gson, + web3Responder = web3Responder, + ) + } + + @Provides + @FeatureScope + fun providePolkadotJsInteractor( + chainRegistry: ChainRegistry, + runtimeVersionsRepository: RuntimeVersionsRepository, + accountRepository: AccountRepository + ) = PolkadotJsExtensionInteractor(chainRegistry, accountRepository, runtimeVersionsRepository) + + @Provides + @FeatureScope + fun providePolkadotJsStateFactory( + interactor: PolkadotJsExtensionInteractor, + commonInteractor: DappInteractor, + resourceManager: ResourceManager, + addressIconGenerator: AddressIconGenerator, + web3Session: Web3Session, + walletUiUseCase: WalletUiUseCase, + ): PolkadotJsStateFactory { + return PolkadotJsStateFactory( + interactor = interactor, + commonInteractor = commonInteractor, + resourceManager = resourceManager, + addressIconGenerator = addressIconGenerator, + web3Session = web3Session, + walletUiUseCase = walletUiUseCase + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/DappInteractor.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/DappInteractor.kt new file mode 100644 index 0000000..9297162 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/DappInteractor.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_dapp_impl.domain + +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.feature_dapp_api.data.model.DApp +import io.novafoundation.nova.feature_dapp_api.data.model.DAppGroupedCatalog +import io.novafoundation.nova.feature_dapp_api.data.model.DAppUrl +import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory +import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp +import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository +import io.novafoundation.nova.feature_dapp_impl.domain.browser.DAppInfo +import io.novafoundation.nova.feature_dapp_impl.domain.common.buildUrlToDappMapping +import io.novafoundation.nova.feature_dapp_impl.domain.common.createDAppComparator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.withContext +import kotlin.random.Random + +class DappInteractor( + private val dAppMetadataRepository: DAppMetadataRepository, + private val favouritesDAppRepository: FavouritesDAppRepository, + private val phishingSitesRepository: PhishingSitesRepository, +) { + + private val dAppComparator by lazy { + createDAppComparator() + } + + suspend fun dAppsSync() = withContext(Dispatchers.IO) { + val metadataSyncing = runSync { dAppMetadataRepository.syncDAppMetadatas() } + val phishingSitesSyncing = runSync { phishingSitesRepository.syncPhishingSites() } + + joinAll(metadataSyncing, phishingSitesSyncing) + } + + suspend fun removeDAppFromFavourites(dAppUrl: String) { + favouritesDAppRepository.removeFavourite(dAppUrl) + } + + suspend fun getFavoriteDApps(): List { + return favouritesDAppRepository.getFavourites().sortDApps() + } + + fun observeFavoriteDApps(): Flow> { + return favouritesDAppRepository.observeFavourites() + .map { it.sortDApps() } + } + + fun observeDAppsByCategory(): Flow { + val shufflingSeed = Random.nextInt() + + return combine( + dAppMetadataRepository.observeDAppCatalog(), + favouritesDAppRepository.observeFavourites() + ) { dAppCatalog, favourites -> + // We use random with seed to shuffle dapps in categories the same way during updates + val random = Random(shufflingSeed) + + val categories = dAppCatalog.categories + val dapps = dAppCatalog.dApps + + val urlToDAppMapping = buildUrlToDappMapping(dapps, favourites) + + val popular = dAppCatalog.popular.mapNotNull { urlToDAppMapping[it] } + val catalog = categories.associateWith { getShuffledDAppsInCategory(it, dapps, urlToDAppMapping, dAppCatalog.popular.toSet(), random) } + + DAppGroupedCatalog(popular, catalog) + } + } + + private fun getShuffledDAppsInCategory( + category: DappCategory, + dapps: List, + urlToDAppMapping: Map, + popular: Set, + shufflingSeed: Random + ): List { + val categoryDApps = dapps.filter { category in it.categories } + .map { urlToDAppMapping.getValue(it.url) } + + val popularDAppsInCategory = categoryDApps.filter { it.url in popular } + val otherDAppsInCategory = categoryDApps.filterNot { it.url in popular } + + return popularDAppsInCategory.shuffled(shufflingSeed) + otherDAppsInCategory.shuffled(shufflingSeed) + } + + suspend fun getDAppInfo(dAppUrl: String): DAppInfo { + val baseUrl = Urls.normalizeUrl(dAppUrl) + + return withContext(Dispatchers.Default) { + DAppInfo( + baseUrl = baseUrl, + metadata = dAppMetadataRepository.getDAppMetadata(baseUrl) + ) + } + } + + private inline fun CoroutineScope.runSync(crossinline sync: suspend () -> Unit): Job { + return async { runCatching { sync() } } + } + + suspend fun updateFavoriteDapps(favoriteDapps: List) { + favouritesDAppRepository.updateFavoriteDapps(favoriteDapps) + } + + private fun List.sortDApps(): List { + return sortedBy { it.orderingIndex } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/authorizedDApps/AuthorizedDApp.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/authorizedDApps/AuthorizedDApp.kt new file mode 100644 index 0000000..548970e --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/authorizedDApps/AuthorizedDApp.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps + +class AuthorizedDApp( + val baseUrl: String, + val name: String?, + val iconLink: String? +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/authorizedDApps/AuthorizedDAppsInteractor.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/authorizedDApps/AuthorizedDAppsInteractor.kt new file mode 100644 index 0000000..2eff322 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/authorizedDApps/AuthorizedDAppsInteractor.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps + +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session.Authorization +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map + +class AuthorizedDAppsInteractor( + private val accountRepository: AccountRepository, + private val metadataRepository: DAppMetadataRepository, + private val web3Session: Web3Session +) { + + suspend fun revokeAuthorization(url: String) { + val currentAccount = accountRepository.getSelectedMetaAccount() + + web3Session.revokeAuthorization(url, currentAccount.id) + } + + fun observeAuthorizedDApps(): Flow> { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + val catalog = metadataRepository.getDAppCatalog() + val dApps = catalog.dApps.associateBy(DappMetadata::baseUrl) + + web3Session.observeAuthorizationsFor(metaAccount.id) + .map { authorizations -> authorizations.filter { it.state == Authorization.State.ALLOWED } } + .mapList { authorization -> + val metadata = dApps[authorization.baseUrl] + + AuthorizedDApp( + baseUrl = authorization.baseUrl, + name = metadata?.name ?: authorization.dAppTitle, + iconLink = metadata?.iconLink + ) + } + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/BrowserPage.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/BrowserPage.kt new file mode 100644 index 0000000..e846edd --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/BrowserPage.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.browser + +class BrowserPage( + val url: String, + val title: String?, + val synchronizedWithBrowser: Boolean +) + +class BrowserPageAnalyzed( + val display: String, + val title: String?, + val url: String, + val synchronizedWithBrowser: Boolean, + val isFavourite: Boolean, + val security: Security +) { + + enum class Security { + SECURE, DANGEROUS, UNKNOWN + } +} + +val BrowserPageAnalyzed.isSecure + get() = security == BrowserPageAnalyzed.Security.SECURE + +val BrowserPageAnalyzed.isDangerous + get() = security == BrowserPageAnalyzed.Security.DANGEROUS diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/DAppInfo.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/DAppInfo.kt new file mode 100644 index 0000000..9b3fddf --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/DAppInfo.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.browser + +import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata + +class DAppInfo( + val baseUrl: String, + val metadata: DappMetadata? +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/DappBrowserInteractor.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/DappBrowserInteractor.kt new file mode 100644 index 0000000..cc2c25c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/DappBrowserInteractor.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.browser + +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.utils.isSecure +import io.novafoundation.nova.feature_dapp_api.data.model.BrowserHostSettings +import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserHostSettingsRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.net.URL + +class DappBrowserInteractor( + private val phishingSitesRepository: PhishingSitesRepository, + private val favouritesDAppRepository: FavouritesDAppRepository, + private val browserHostSettingsRepository: BrowserHostSettingsRepository +) { + + suspend fun getHostSettings(url: String): BrowserHostSettings? { + return browserHostSettingsRepository.getBrowserHostSettings(url) + } + + suspend fun saveHostSettings(settings: BrowserHostSettings) { + browserHostSettingsRepository.saveBrowserHostSettings(settings) + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun observeBrowserPageFor(browserPage: BrowserPage): Flow { + return favouritesDAppRepository.observeIsFavourite(browserPage.url).map { isFavourite -> + runCatching { + val security = when { + phishingSitesRepository.isPhishing(browserPage.url) -> BrowserPageAnalyzed.Security.DANGEROUS + URL(browserPage.url).isSecure -> BrowserPageAnalyzed.Security.SECURE + else -> BrowserPageAnalyzed.Security.UNKNOWN + } + BrowserPageAnalyzed( + display = Urls.hostOf(browserPage.url), + title = browserPage.title, + url = browserPage.url, + security = security, + isFavourite = isFavourite, + synchronizedWithBrowser = browserPage.synchronizedWithBrowser + ) + }.getOrElse { + BrowserPageAnalyzed( + display = browserPage.url, + title = browserPage.title, + url = browserPage.url, + isFavourite = isFavourite, + security = BrowserPageAnalyzed.Security.UNKNOWN, + synchronizedWithBrowser = browserPage.synchronizedWithBrowser + ) + } + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/addToFavourites/AddToFavouritesInteractor.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/addToFavourites/AddToFavouritesInteractor.kt new file mode 100644 index 0000000..9b6e05a --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/addToFavourites/AddToFavouritesInteractor.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.browser.addToFavourites + +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp +import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AddToFavouritesInteractor( + private val favouritesDAppRepository: FavouritesDAppRepository, + private val dAppMetadataRepository: DAppMetadataRepository, +) { + + suspend fun addToFavourites(url: String, label: String, icon: String?) = withContext(Dispatchers.Default) { + val nextOrderingIndex = favouritesDAppRepository.getNextOrderingIndex() + + val favorite = FavouriteDApp( + url = url, + label = label, + icon = icon, + orderingIndex = nextOrderingIndex + ) + favouritesDAppRepository.addFavourite(favorite) + } + + suspend fun addToFavourites(favouriteDApp: FavouriteDApp) = withContext(Dispatchers.Default) { + favouritesDAppRepository.addFavourite(favouriteDApp) + } + + suspend fun resolveFavouriteDAppDisplay(url: String, suppliedLabel: String?) = withContext(Dispatchers.Default) { + val dAppMetadataExactMatch = dAppMetadataRepository.findDAppMetadataByExactUrlMatch(url) + val dAppMetadataBaseUrlMatches = dAppMetadataRepository.findDAppMetadatasByBaseUrlMatch(baseUrl = Urls.normalizeUrl(url)) + + // we don't want to use base url match if there more than one candidate + val dAppMetadataBaseUrlSingleMatch = dAppMetadataBaseUrlMatches.singleOrNull() + + FavouriteDApp( + url = url, + label = dAppMetadataExactMatch?.name ?: suppliedLabel ?: Urls.hostOf(url), + icon = dAppMetadataExactMatch?.iconLink ?: dAppMetadataBaseUrlSingleMatch?.iconLink, + orderingIndex = 0 + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/metamask/MetamaskInteractor.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/metamask/MetamaskInteractor.kt new file mode 100644 index 0000000..5eba063 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/metamask/MetamaskInteractor.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.browser.metamask + +import android.util.Log +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.domain.model.mainEthereumAddress +import io.novafoundation.nova.feature_dapp_impl.data.repository.DefaultMetamaskChainRepository +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.EthereumAddress +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.findEvmChainFromHexId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +class MetamaskInteractor( + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val defaultMetamaskChainRepository: DefaultMetamaskChainRepository, +) { + + fun getDefaultMetamaskChain(): MetamaskChain { + val defaultChain = defaultMetamaskChainRepository.getDefaultMetamaskChain() ?: MetamaskChain.ETHEREUM + return defaultChain.also { + Log.d("MetamaskInteractor", "Returned default chain: ${defaultChain.chainName}") + } + } + + fun setDefaultMetamaskChain(chain: MetamaskChain) { + Log.d("MetamaskInteractor", "Saved default chain: ${chain.chainName}") + defaultMetamaskChainRepository.saveDefaultMetamaskChain(chain) + } + + suspend fun getAddresses(ethereumChainId: String): List = withContext(Dispatchers.Default) { + val selectedAccount = accountRepository.getSelectedMetaAccount() + val maybeChain = chainRegistry.findEvmChainFromHexId(ethereumChainId) + + val chainsById = chainRegistry.chainsById.first() + + val selectedAddress = maybeChain?.let { selectedAccount.addressIn(it) } + + val mainAddress = selectedAccount.mainEthereumAddress() + + val chainAccountAddresses = selectedAccount.chainAccounts + .mapNotNull { (chainId, chainAccount) -> + val chain = chainsById[chainId] + + chain?.addressOf(chainAccount.accountId)?.takeIf { + chain.isEthereumBased && chainAccount.cryptoType == CryptoType.ECDSA + } + } + + buildList { + selectedAddress?.let { add(it) } + mainAddress?.let { add(it) } + addAll(chainAccountAddresses) + }.distinct() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/polkadotJs/PolkadotJsExtensionInteractor.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/polkadotJs/PolkadotJsExtensionInteractor.kt new file mode 100644 index 0000000..e980c82 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/browser/polkadotJs/PolkadotJsExtensionInteractor.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.browser.polkadotJs + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.defaultSubstrateAddress +import io.novafoundation.nova.feature_account_api.domain.model.substrateFrom +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.InjectedAccount +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.InjectedMetadataKnown +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.genesisHash +import io.novafoundation.nova.runtime.ext.toEthereumAddress +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository +import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption +import io.novasama.substrate_sdk_android.extensions.requireHexPrefix + +class PolkadotJsExtensionInteractor( + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val runtimeVersionsRepository: RuntimeVersionsRepository, +) { + + suspend fun getInjectedAccounts(): List { + val metaAccount = accountRepository.getSelectedMetaAccount() + + val defaultSubstrateAccount = metaAccount.defaultSubstrateAddress?.let { address -> + InjectedAccount( + address = address, + genesisHash = null, + name = metaAccount.name, + encryption = metaAccount.substrateCryptoType?.let { MultiChainEncryption.substrateFrom(it) } + ) + } + + val defaultEthereumAccount = metaAccount.ethereumAddress?.let { adddressBytes -> + InjectedAccount( + address = adddressBytes.toEthereumAddress(), + genesisHash = null, + name = metaAccount.name, + encryption = MultiChainEncryption.Ethereum + ) + } + + val customAccounts = metaAccount.chainAccounts.mapNotNull { (chainId, chainAccount) -> + val chain = chainRegistry.getChain(chainId) + // Ignore non-substrate chains since they don't have chainId=genesisHash + val genesisHash = chain.genesisHash?.requireHexPrefix() ?: return@mapNotNull null + + InjectedAccount( + address = chain.addressOf(chainAccount.accountId), + genesisHash = genesisHash, + name = "${metaAccount.name} (${chain.name})", + encryption = chainAccount.multiChainEncryption(chain) + ) + } + + return buildList { + defaultSubstrateAccount?.let(::add) + defaultEthereumAccount?.let(::add) + addAll(customAccounts) + } + } + + suspend fun getKnownInjectedMetadatas(): List { + return runtimeVersionsRepository.getAllRuntimeVersions().map { + InjectedMetadataKnown( + genesisHash = it.chainId.requireHexPrefix(), + specVersion = it.specVersion + ) + } + } + + private fun MetaAccount.ChainAccount.multiChainEncryption(chain: Chain): MultiChainEncryption? { + return if (chain.isEthereumBased) { + MultiChainEncryption.Ethereum + } else { + cryptoType?.let { MultiChainEncryption.substrateFrom(it) } + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/common/DAppLists.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/common/DAppLists.kt new file mode 100644 index 0000000..85606cd --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/common/DAppLists.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.common + +import io.novafoundation.nova.feature_dapp_api.data.model.DApp +import io.novafoundation.nova.feature_dapp_api.data.model.DappMetadata +import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp +import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapDappCategoriesToDescription + +fun createDAppComparator() = compareByDescending { it.isFavourite } + .thenBy { it.favoriteIndex } + .thenBy { it.name } + +// Build mapping in O(Metadatas + Favourites) in case of HashMap. It allows constant time access later +internal fun buildUrlToDappMapping( + dAppMetadatas: Collection, + favourites: Collection +): Map { + val favouritesByUrl = favourites.associateBy { it.url } + + return buildMap { + val fromFavourites = favouritesByUrl.mapValues { favouriteToDApp(it.value) } + putAll(fromFavourites) + + // overlapping metadata urls will override favourites in the map and thus use metadata for display + val fromMetadatas = dAppMetadatas.associateBy( + keySelector = { it.url }, + valueTransform = { dAppMetadataToDApp(it, favoriteModel = favouritesByUrl[it.url]) } + ) + putAll(fromMetadatas) + } +} + +fun dappToFavorite(dapp: DApp, orderingIndex: Int): FavouriteDApp { + return FavouriteDApp( + label = dapp.name, + icon = dapp.iconLink, + url = dapp.url, + orderingIndex = orderingIndex + ) +} + +fun favouriteToDApp(favouriteDApp: FavouriteDApp): DApp { + return DApp( + name = favouriteDApp.label, + description = favouriteDApp.url, + iconLink = favouriteDApp.icon, + url = favouriteDApp.url, + isFavourite = true, + favoriteIndex = favouriteDApp.orderingIndex + ) +} + +private fun dAppMetadataToDApp(metadata: DappMetadata, favoriteModel: FavouriteDApp?): DApp { + return DApp( + name = metadata.name, + description = mapDappCategoriesToDescription(metadata.categories), + iconLink = metadata.iconLink, + url = metadata.url, + isFavourite = favoriteModel != null, + favoriteIndex = favoriteModel?.orderingIndex + ) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/search/DappSearchGroup.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/search/DappSearchGroup.kt new file mode 100644 index 0000000..1009fd2 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/search/DappSearchGroup.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.search + +enum class DappSearchGroup { + DAPPS, SEARCH +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/search/DappSearchResult.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/search/DappSearchResult.kt new file mode 100644 index 0000000..ea634ff --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/search/DappSearchResult.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.search + +import io.novafoundation.nova.feature_dapp_api.data.model.DApp + +sealed interface DappSearchResult { + + val isTrustedByNova: Boolean + + class Url(val url: String, override val isTrustedByNova: Boolean) : DappSearchResult + + class Search(val query: String, val searchUrl: String) : DappSearchResult { + override val isTrustedByNova: Boolean = false + } + + class Dapp(val dapp: DApp) : DappSearchResult { + override val isTrustedByNova: Boolean = true + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/search/SearchDappInteractor.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/search/SearchDappInteractor.kt new file mode 100644 index 0000000..4c96be0 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/domain/search/SearchDappInteractor.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_dapp_impl.domain.search + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.feature_dapp_api.data.model.DApp +import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository +import io.novafoundation.nova.feature_dapp_impl.domain.common.buildUrlToDappMapping +import io.novafoundation.nova.feature_dapp_impl.domain.common.createDAppComparator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class SearchDappInteractor( + private val dAppMetadataRepository: DAppMetadataRepository, + private val favouritesDAppRepository: FavouritesDAppRepository, +) { + + fun categories(): Flow> { + return dAppMetadataRepository.observeDAppCatalog() + .map { it.categories } + } + + suspend fun searchDapps(query: String, categoryId: String?): GroupedList = withContext(Dispatchers.Default) { + val dapps = getDapps(categoryId) + + val dappsGroupContent = dapps + .filter { query.isEmpty() || query.lowercase() in it.name.lowercase() } + .sortedWith(createDAppComparator()) + .map(DappSearchResult::Dapp) + + val searchGroupContent = when { + query.isEmpty() -> null + Urls.isValidWebUrl(query) -> { + val searchUrl = Urls.ensureHttpsProtocol(query) + val searchUrlDomain = Urls.domainOf(searchUrl) + val trusting = dapps.any { Urls.domainOf(it.url) == searchUrlDomain } + DappSearchResult.Url(searchUrl, trusting) + } + + else -> DappSearchResult.Search(query, searchUrlFor(query)) + } + + buildMap { + searchGroupContent?.let { + put(DappSearchGroup.SEARCH, listOf(searchGroupContent)) + } + + if (dappsGroupContent.isNotEmpty()) { + put(DappSearchGroup.DAPPS, dappsGroupContent) + } + } + } + + private fun searchUrlFor(query: String): String = "https://duckduckgo.com/?q=$query" + + private suspend fun getDapps(categoryId: String?): Collection { + val dApps = dAppMetadataRepository.getDAppCatalog() + .dApps + .filter { dapp -> categoryId == null || dapp.categories.any { it.id == categoryId } } + .associateBy { it.url } + val favouriteDApps = favouritesDAppRepository.getFavourites() + .filter { categoryId == null || it.url in dApps.keys } + + val dAppByUrlMapping = buildUrlToDappMapping(dApps.values, favouriteDApps) + return dAppByUrlMapping.values + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/DAppRouter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/DAppRouter.kt new file mode 100644 index 0000000..936c1e7 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/DAppRouter.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation + +import androidx.navigation.fragment.FragmentNavigator +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload + +interface DAppRouter : ReturnableRouter { + + fun openChangeAccount() + + fun openDAppBrowser(payload: DAppBrowserPayload, extras: FragmentNavigator.Extras? = null) + + fun openDappSearch() + + fun openDappSearchWithCategory(categoryId: String?) + + fun finishDappSearch() + + fun openAddToFavourites(payload: AddToFavouritesPayload) + + fun openAuthorizedDApps() + + fun openTabs() + + fun closeTabsScreen() + + fun openDAppFavorites() +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/AddToFavouritesFragment.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/AddToFavouritesFragment.kt new file mode 100644 index 0000000..b54da9d --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/AddToFavouritesFragment.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites + +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.hideKeyboard +import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard +import io.novafoundation.nova.common.utils.moveCursorToTheEnd +import io.novafoundation.nova.common.utils.postToSelf +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload +import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentAddToFavouritesBinding +import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent +import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon + +import javax.inject.Inject + +private const val PAYLOAD_KEY = "DAppSignExtrinsicFragment.Payload" + +class AddToFavouritesFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: AddToFavouritesPayload) = bundleOf(PAYLOAD_KEY to payload) + } + + override fun createBinding() = FragmentAddToFavouritesBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun initViews() { + binder.addToFavouritesToolbar.setHomeButtonListener { + viewModel.backClicked() + } + + binder.addToFavouritesToolbar.setRightActionClickListener { viewModel.saveClicked() } + } + + override fun onDestroyView() { + super.onDestroyView() + + hideKeyboard() + } + + override fun inject() { + FeatureUtils.getFeature(this, DAppFeatureApi::class.java) + .addToFavouritesComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + @Suppress("UNCHECKED_CAST") + override fun subscribe(viewModel: AddToFavouritesViewModel) { + binder.addToFavouritesTitleInput.bindTo(viewModel.labelFlow, lifecycleScope) + binder.addToFavouritesAddressInput.bindTo(viewModel.urlFlow, lifecycleScope) + + viewModel.iconLink.observe { + binder.addToFavouritesIcon.showDAppIcon(it, imageLoader) + } + + viewModel.focusOnAddressFieldEvent.observeEvent { + binder.addToFavouritesTitleInput.postToSelf { + showSoftKeyboard() + + moveCursorToTheEnd() + } + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/AddToFavouritesViewModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/AddToFavouritesViewModel.kt new file mode 100644 index 0000000..6ac4ea8 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/AddToFavouritesViewModel.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.sendEvent +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload +import io.novafoundation.nova.feature_dapp_impl.domain.browser.addToFavourites.AddToFavouritesInteractor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class AddToFavouritesViewModel( + private val interactor: AddToFavouritesInteractor, + private val payload: AddToFavouritesPayload, + private val router: DAppRouter, +) : BaseViewModel() { + + val urlFlow = MutableStateFlow(payload.url) + val labelFlow = singleReplaySharedFlow() + + val iconLink = singleReplaySharedFlow() + + private val _focusOnUrlFieldEvent = MutableLiveData>() + val focusOnAddressFieldEvent: LiveData> = _focusOnUrlFieldEvent + + init { + setInitialValues() + } + + fun saveClicked() = launch { + interactor.addToFavourites(urlFlow.value, labelFlow.first(), iconLink.first()) + + router.back() + } + + private fun setInitialValues() = launch { + val resolvedDAppDisplay = interactor.resolveFavouriteDAppDisplay(url = payload.url, suppliedLabel = payload.label) + + labelFlow.emit(resolvedDAppDisplay.label) + iconLink.emit(resolvedDAppDisplay.icon) + + _focusOnUrlFieldEvent.sendEvent() + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/di/AddToFavouritesComponent.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/di/AddToFavouritesComponent.kt new file mode 100644 index 0000000..2c567a6 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/di/AddToFavouritesComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.AddToFavouritesFragment +import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload + +@Subcomponent( + modules = [ + AddToFavouritesModule::class + ] +) +@ScreenScope +interface AddToFavouritesComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AddToFavouritesPayload, + ): AddToFavouritesComponent + } + + fun inject(fragment: AddToFavouritesFragment) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/di/AddToFavouritesModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/di/AddToFavouritesModule.kt new file mode 100644 index 0000000..126b4fb --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/addToFavourites/di/AddToFavouritesModule.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository +import io.novafoundation.nova.feature_dapp_impl.domain.browser.addToFavourites.AddToFavouritesInteractor +import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload +import io.novafoundation.nova.feature_dapp_impl.presentation.addToFavourites.AddToFavouritesViewModel + +@Module(includes = [ViewModelModule::class]) +class AddToFavouritesModule { + + @Provides + @ScreenScope + fun provideInteractor( + favouritesDAppRepository: FavouritesDAppRepository, + dAppMetadataRepository: DAppMetadataRepository + ) = AddToFavouritesInteractor(favouritesDAppRepository, dAppMetadataRepository) + + @Provides + @ScreenScope + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AddToFavouritesViewModel { + return ViewModelProvider(fragment, factory).get(AddToFavouritesViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AddToFavouritesViewModel::class) + fun provideViewModel( + router: DAppRouter, + interactor: AddToFavouritesInteractor, + payload: AddToFavouritesPayload + ): ViewModel { + return AddToFavouritesViewModel( + router = router, + interactor = interactor, + payload = payload + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/AuthorizedDAppAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/AuthorizedDAppAdapter.kt new file mode 100644 index 0000000..ff3bcb8 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/AuthorizedDAppAdapter.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.model.AuthorizedDAppModel +import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView + +class AuthorizedDAppAdapter( + private val handler: Handler +) : BaseListAdapter(DiffCallback) { + + interface Handler { + + fun onRevokeClicked(item: AuthorizedDAppModel) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AuthorizedDAppViewHolder { + return AuthorizedDAppViewHolder(DAppView.createUsingMathParentWidth(parent.context), handler) + } + + override fun onBindViewHolder(holder: AuthorizedDAppViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private object DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: AuthorizedDAppModel, newItem: AuthorizedDAppModel): Boolean { + return oldItem.url == newItem.url + } + + override fun areContentsTheSame(oldItem: AuthorizedDAppModel, newItem: AuthorizedDAppModel): Boolean { + return oldItem == newItem + } +} + +class AuthorizedDAppViewHolder( + private val dAppView: DAppView, + private val itemHandler: AuthorizedDAppAdapter.Handler, +) : BaseViewHolder(dAppView) { + + init { + dAppView.setActionResource(R.drawable.ic_close) + dAppView.setActionTintRes(R.color.icon_secondary) + } + + fun bind(item: AuthorizedDAppModel) = with(dAppView) { + this.setTitle(item.title) + this.showTitle(item.title != null) + this.setSubtitle(item.url) + this.setIconUrl(item.iconLink) + + setOnActionClickListener { itemHandler.onRevokeClicked(item) } + } + + override fun unbind() { + dAppView.clearIcon() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/AuthorizedDAppsFragment.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/AuthorizedDAppsFragment.kt new file mode 100644 index 0000000..7fa9ce7 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/AuthorizedDAppsFragment.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentAuthorizedDappsBinding +import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.model.AuthorizedDAppModel + +class AuthorizedDAppsFragment : BaseFragment(), AuthorizedDAppAdapter.Handler { + + override fun createBinding() = FragmentAuthorizedDappsBinding.inflate(layoutInflater) + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + AuthorizedDAppAdapter(this) + } + + private val placeholderViews by lazy(LazyThreadSafetyMode.NONE) { + listOf(binder.authorizedPlaceholderSpacerTop, binder.authorizedPlaceholder, binder.authorizedPlaceholderSpacerBottom) + } + + override fun initViews() { + binder.authorizedDAppsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.authorizedDAppsList.setHasFixedSize(true) + binder.authorizedDAppsList.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature(this, DAppFeatureApi::class.java) + .authorizedDAppsComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: AuthorizedDAppsViewModel) { + viewModel.authorizedDApps.observe { + val showPlaceholder = it.isEmpty() + + binder.authorizedDAppsList.setVisible(!showPlaceholder) + placeholderViews.forEach { view -> view.setVisible(showPlaceholder) } + + adapter.submitList(it) + } + + viewModel.walletUi.observe { + binder.authorizedDAppsWallet.showWallet(it) + } + + viewModel.revokeAuthorizationConfirmation.awaitableActionLiveData.observeEvent { + warningDialog( + context = requireContext(), + onPositiveClick = { it.onSuccess(Unit) }, + onNegativeClick = it.onCancel, + positiveTextRes = R.string.common_remove + ) { + setTitle(R.string.dapp_authorized_remove_title) + setMessage(getString(R.string.dapp_authorized_remove_description, it.payload)) + } + } + } + + override fun onRevokeClicked(item: AuthorizedDAppModel) { + viewModel.revokeClicked(item) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/AuthorizedDAppsViewModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/AuthorizedDAppsViewModel.kt new file mode 100644 index 0000000..bafc6b1 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/AuthorizedDAppsViewModel.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps.AuthorizedDApp +import io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps.AuthorizedDAppsInteractor +import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.model.AuthorizedDAppModel +import kotlinx.coroutines.launch + +typealias RevokeAuthorizationPayload = String // dApp name + +class AuthorizedDAppsViewModel( + private val interactor: AuthorizedDAppsInteractor, + private val router: DAppRouter, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + walletUiUseCase: WalletUiUseCase, +) : BaseViewModel() { + + val revokeAuthorizationConfirmation = actionAwaitableMixinFactory.confirmingAction() + + val walletUi = walletUiUseCase.selectedWalletUiFlow(showAddressIcon = true) + .shareInBackground() + + val authorizedDApps = interactor.observeAuthorizedDApps() + .mapList(::mapAuthorizedDAppToModel) + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun revokeClicked(item: AuthorizedDAppModel) = launch { + val dAppTitle = item.title ?: item.url + revokeAuthorizationConfirmation.awaitAction(dAppTitle) + + interactor.revokeAuthorization(item.url) + } + + private fun mapAuthorizedDAppToModel( + authorizedDApp: AuthorizedDApp + ): AuthorizedDAppModel { + return AuthorizedDAppModel( + title = authorizedDApp.name, + url = authorizedDApp.baseUrl, + iconLink = authorizedDApp.iconLink + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/di/AuthorizedDAppsComponent.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/di/AuthorizedDAppsComponent.kt new file mode 100644 index 0000000..3beae5b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/di/AuthorizedDAppsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.AuthorizedDAppsFragment + +@Subcomponent( + modules = [ + AuthorizedDAppsModule::class + ] +) +@ScreenScope +interface AuthorizedDAppsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): AuthorizedDAppsComponent + } + + fun inject(fragment: AuthorizedDAppsFragment) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/di/AuthorizedDAppsModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/di/AuthorizedDAppsModule.kt new file mode 100644 index 0000000..008ceea --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/di/AuthorizedDAppsModule.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.domain.authorizedDApps.AuthorizedDAppsInteractor +import io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.AuthorizedDAppsViewModel +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session + +@Module(includes = [ViewModelModule::class]) +class AuthorizedDAppsModule { + + @Provides + @ScreenScope + fun provideInteractor( + accountRepository: AccountRepository, + metadataRepository: DAppMetadataRepository, + web3Session: Web3Session + ) = AuthorizedDAppsInteractor( + accountRepository = accountRepository, + metadataRepository = metadataRepository, + web3Session = web3Session + ) + + @Provides + @ScreenScope + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): AuthorizedDAppsViewModel { + return ViewModelProvider(fragment, factory).get(AuthorizedDAppsViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(AuthorizedDAppsViewModel::class) + fun provideViewModel( + router: DAppRouter, + interactor: AuthorizedDAppsInteractor, + walletUiUseCase: WalletUiUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + ): ViewModel { + return AuthorizedDAppsViewModel( + router = router, + interactor = interactor, + walletUiUseCase = walletUiUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/model/AuthorizedDAppModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/model/AuthorizedDAppModel.kt new file mode 100644 index 0000000..8b163a9 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/authorizedDApps/model/AuthorizedDAppModel.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.authorizedDApps.model + +data class AuthorizedDAppModel( + val title: String?, + val url: String, + val iconLink: String? +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/BrowserCommand.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/BrowserCommand.kt new file mode 100644 index 0000000..d9f1ed8 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/BrowserCommand.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main + +sealed class BrowserCommand { + + object Reload : BrowserCommand() + + object GoBack : BrowserCommand() + + class OpenUrl(val url: String) : BrowserCommand() + + class ChangeDesktopMode(val enabled: Boolean) : BrowserCommand() +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/DAppBrowserFragment.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/DAppBrowserFragment.kt new file mode 100644 index 0000000..10ebd6b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/DAppBrowserFragment.kt @@ -0,0 +1,338 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main + +import android.content.pm.ActivityInfo +import android.graphics.Bitmap +import android.os.Bundle +import android.transition.TransitionInflater +import android.view.View +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.widget.ImageView +import androidx.activity.OnBackPressedCallback +import androidx.core.app.SharedElementCallback +import androidx.core.os.bundleOf +import androidx.core.transition.addListener +import androidx.lifecycle.viewModelScope +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentDappBrowserBinding +import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent +import io.novafoundation.nova.feature_dapp_impl.domain.browser.isSecure +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DappPendingConfirmation.Action +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.sheets.AcknowledgePhishingBottomSheet +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.options.OptionsBottomSheetDialog +import io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites.setupRemoveFavouritesConfirmation +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTabSession +import io.novafoundation.nova.feature_dapp_impl.web3.webview.Web3ChromeClient +import io.novafoundation.nova.feature_dapp_impl.web3.webview.CompoundWeb3Injector +import io.novafoundation.nova.feature_dapp_impl.web3.webview.Web3WebViewClient +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooser +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAsker +import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor +import io.novafoundation.nova.common.view.dialog.infoDialog +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.SessionCallback +import io.novafoundation.nova.feature_external_sign_api.presentation.externalSign.AuthorizeDappBottomSheet +import javax.inject.Inject + +private const val OVERFLOW_TABS_COUNT = 100 + +const val DAPP_SHARED_ELEMENT_ID_IMAGE_TAB = "DAPP_SHARED_ELEMENT_ID_IMAGE_TAB" + +class DAppBrowserFragment : BaseFragment(), OptionsBottomSheetDialog.Callback, SessionCallback { + + companion object { + + private const val PAYLOAD = "DAppBrowserFragment.Payload" + + fun getBundle(payload: DAppBrowserPayload) = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentDappBrowserBinding.inflate(layoutInflater) + + @Inject + lateinit var compoundWeb3Injector: CompoundWeb3Injector + + @Inject + lateinit var webViewHolder: WebViewHolder + + @Inject + lateinit var fileChooser: WebViewFileChooser + + @Inject + lateinit var permissionAsker: WebViewPermissionAsker + + @Inject + lateinit var webViewRequestInterceptor: WebViewRequestInterceptor + + @Inject + lateinit var imageLoader: ImageLoader + + private var webViewClient: Web3WebViewClient? = null + + var backCallback: OnBackPressedCallback? = null + + private val dappBrowserWebView: WebView? + get() { + return binder.dappBrowserWebViewContainer.getChildAt(0) as? WebView + } + + override fun onCreate(savedInstanceState: Bundle?) { + WebView.enableSlowWholeDocumentDraw() + super.onCreate(savedInstanceState) + + sharedElementEnterTransition = TransitionInflater.from(requireContext()) + .inflateTransition(android.R.transition.move).apply { + addListener( + onStart = { binder.dappBrowserWebViewContainer.makeGone() }, // Hide WebView during transition animation + onEnd = { + binder.dappBrowserWebViewContainer.makeVisible() + binder.dappBrowserTransitionImage.animate() + .setDuration(300) + .alpha(0f) + .withEndAction { binder.dappBrowserTransitionImage.makeGone() } + .start() + } + ) + } + } + + override fun applyInsets(rootView: View) { + binder.dappBrowserAddressBarGroup.applyStatusBarInsets() + binder.dappBrowserBottomNavigation.applyNavigationBarInsets() + } + + override fun initViews() { + binder.dappBrowserHide.setOnClickListener { viewModel.closeClicked() } + + binder.dappBrowserBack.setOnClickListener { backClicked() } + + binder.dappBrowserAddressBar.setOnClickListener { + viewModel.openSearch() + } + + binder.dappBrowserForward.setOnClickListener { forwardClicked() } + binder.dappBrowserTabs.setOnClickListener { viewModel.openTabs() } + binder.dappBrowserRefresh.setOnClickListener { refreshClicked() } + binder.dappBrowserFavorite.setOnClickListener { viewModel.onFavoriteClick() } + binder.dappBrowserMore.setOnClickListener { moreClicked() } + + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + + binder.dappBrowserTransitionImage.transitionName = DAPP_SHARED_ELEMENT_ID_IMAGE_TAB + + setEnterSharedElementCallback(object : SharedElementCallback() { + override fun onSharedElementStart( + sharedElementNames: MutableList?, + sharedElements: MutableList?, + sharedElementSnapshots: MutableList? + ) { + val sharedView = sharedElements?.firstOrNull { it.transitionName == DAPP_SHARED_ELEMENT_ID_IMAGE_TAB } + val sharedImageView = sharedView as? ImageView + binder.dappBrowserTransitionImage.setImageDrawable(sharedImageView?.drawable) // Set image from shared element + } + }) + } + + override fun onDestroyView() { + binder.dappBrowserWebViewContainer.removeAllViews() + viewModel.detachCurrentSession() + super.onDestroyView() + + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + + override fun onPause() { + super.onPause() + viewModel.makePageSnapshot() + + detachBackCallback() + } + + override fun onResume() { + super.onResume() + attachBackCallback() + } + + override fun onHiddenChanged(hidden: Boolean) { + if (hidden) { + detachBackCallback() + } else { + attachBackCallback() + } + } + + override fun inject() { + FeatureUtils.getFeature(this, DAppFeatureApi::class.java) + .browserComponentFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + @Suppress("UNCHECKED_CAST") + override fun subscribe(viewModel: DAppBrowserViewModel) { + setupRemoveFavouritesConfirmation(viewModel.removeFromFavouritesConfirmation) + + viewModel.currentTabFlow.observe { currentTab -> + attachSession(currentTab.browserTabSession) + } + + viewModel.desktopModeChangedModel.observe { + webViewClient?.desktopMode = it.desktopModeEnabled + } + + viewModel.showConfirmationSheet.observeEvent { + when (it.action) { + is Action.Authorize -> { + showConfirmAuthorizeSheet(it as DappPendingConfirmation) + } + + Action.AcknowledgePhishingAlert -> { + AcknowledgePhishingBottomSheet(requireContext(), it) + .show() + } + } + } + + viewModel.browserCommandEvent.observeEvent { + when (it) { + BrowserCommand.Reload -> dappBrowserWebView?.reload() + BrowserCommand.GoBack -> backClicked() + is BrowserCommand.OpenUrl -> dappBrowserWebView?.loadUrl(it.url) + is BrowserCommand.ChangeDesktopMode -> { + webViewClient?.desktopMode = it.enabled + dappBrowserWebView?.reload() + } + } + } + + viewModel.openBrowserOptionsEvent.observeEvent { + val optionsBottomSheet = OptionsBottomSheetDialog(requireContext(), this, it) + optionsBottomSheet.show() + } + + viewModel.currentPageAnalyzed.observe { + binder.dappBrowserAddressBar.setAddress(it.display) + binder.dappBrowserAddressBar.showSecure(it.isSecure) + binder.dappBrowserFavorite.setImageResource(favoriteIcon(it.isFavourite)) + + updateButtonsState() + } + + viewModel.tabsCountFlow.observe { + if (it >= OVERFLOW_TABS_COUNT) { + binder.dappBrowserTabsIcon.makeVisible() + binder.dappBrowserTabsContent.text = null + } else { + binder.dappBrowserTabsIcon.makeGone() + binder.dappBrowserTabsContent.text = it.toString() + } + } + } + + private fun attachSession(session: BrowserTabSession) { + clearProgress() + session.attachToHost(createChromeClient(), this) + webViewHolder.set(session.webView) + webViewClient = session.webViewClient + + binder.dappBrowserWebViewContainer.removeAllViews() + binder.dappBrowserWebViewContainer.addView(session.webView) + } + + private fun clearProgress() { + binder.dappBrowserProgress.makeGone() + binder.dappBrowserProgress.progress = 0 + } + + private fun createChromeClient() = Web3ChromeClient(permissionAsker, fileChooser, viewModel.viewModelScope, binder.dappBrowserProgress) + + private fun updateButtonsState() { + binder.dappBrowserForward.isEnabled = dappBrowserWebView?.canGoForward() ?: false + binder.dappBrowserBack.isEnabled = dappBrowserWebView?.canGoBack() ?: false + } + + private fun showConfirmAuthorizeSheet(pendingConfirmation: DappPendingConfirmation) { + AuthorizeDappBottomSheet( + context = requireContext(), + onConfirm = pendingConfirmation.onConfirm, + onDeny = pendingConfirmation.onDeny, + payload = pendingConfirmation.action.content, + ).show() + } + + private fun backClicked() { + if (dappBrowserWebView?.canGoBack() == true) { + dappBrowserWebView?.goBack() + } else { + viewModel.closeClicked() + } + } + + private fun forwardClicked() { + dappBrowserWebView?.goForward() + } + + private fun refreshClicked() { + dappBrowserWebView?.reload() + } + + private fun attachBackCallback() { + if (backCallback == null) { + backCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + backClicked() + } + } + requireActivity().onBackPressedDispatcher.addCallback(backCallback!!) + } + } + + private fun moreClicked() { + viewModel.onMoreClicked() + } + + private fun detachBackCallback() { + backCallback?.remove() + backCallback = null + } + + override fun onDesktopModeClick() { + viewModel.onDesktopClick() + } + + override fun onPageStarted(webView: WebView, url: String, favicon: Bitmap?) { + compoundWeb3Injector.injectForPage(webView, viewModel.extensionsStore) + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return webViewRequestInterceptor.intercept(request) + } + + override fun onPageChanged(webView: WebView, url: String?, title: String?) { + viewModel.onPageChanged(url, title) + } + + override fun onPageError(error: String) { + infoDialog(requireContext()) { + setTitle(R.string.common_error_general_title) + setMessage(error) + } + } + + private fun favoriteIcon(isFavorite: Boolean): Int { + return if (isFavorite) { + R.drawable.ic_favorite_heart_filled + } else { + R.drawable.ic_favorite_heart_outline + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/DAppBrowserViewModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/DAppBrowserViewModel.kt new file mode 100644 index 0000000..381b44e --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/DAppBrowserViewModel.kt @@ -0,0 +1,323 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_dapp_api.data.model.BrowserHostSettings +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.browser.BrowserPage +import io.novafoundation.nova.feature_dapp_impl.domain.browser.BrowserPageAnalyzed +import io.novafoundation.nova.feature_dapp_impl.domain.browser.DappBrowserInteractor +import io.novafoundation.nova.feature_dapp_api.presentation.addToFavorites.AddToFavouritesPayload +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.options.DAppOptionsPayload +import io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites.RemoveFavouritesPayload +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchRequester +import io.novafoundation.nova.feature_dapp_impl.presentation.search.SearchPayload +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.createAndSelectTab +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.CurrentTabState +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.stateId +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session.Authorization.State +import io.novafoundation.nova.feature_dapp_impl.web3.states.ExtensionStoreFactory +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.ExternalEvent +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3StateMachineHost +import io.novafoundation.nova.feature_dapp_impl.web3.states.hostApi.ConfirmTxResponse +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignRequester +import io.novafoundation.nova.feature_external_sign_api.model.awaitConfirmation +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.SigningDappMetadata +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.genesisHash +import io.novafoundation.nova.feature_external_sign_api.presentation.externalSign.AuthorizeDappBottomSheet +import io.novafoundation.nova.runtime.ext.isDisabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +enum class ConfirmationState { + ALLOWED, REJECTED, CANCELLED +} + +data class DesktopModeChangedEvent(val desktopModeEnabled: Boolean, val url: String) + +class DAppBrowserViewModel( + private val router: DAppRouter, + private val signRequester: ExternalSignRequester, + private val extensionStoreFactory: ExtensionStoreFactory, + private val dAppInteractor: DappInteractor, + private val interactor: DappBrowserInteractor, + private val dAppSearchRequester: DAppSearchRequester, + private val payload: DAppBrowserPayload, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val chainRegistry: ChainRegistry, + private val browserTabService: BrowserTabService +) : BaseViewModel(), Web3StateMachineHost { + + val removeFromFavouritesConfirmation = actionAwaitableMixinFactory.confirmingAction() + + private val _showConfirmationDialog = MutableLiveData>>() + val showConfirmationSheet = _showConfirmationDialog + + override val selectedAccount = selectedAccountUseCase.selectedMetaAccountFlow() + .share() + + private val currentPage = singleReplaySharedFlow() + + override val currentPageAnalyzed = currentPage.flatMapLatest { + interactor.observeBrowserPageFor(it) + }.shareInBackground() + + override val externalEvents = singleReplaySharedFlow() + + private val _browserCommandEvent = MutableLiveData>() + val browserCommandEvent: LiveData> = _browserCommandEvent + + private val _openBrowserOptionsEvent = MutableLiveData>() + val openBrowserOptionsEvent: LiveData> = _openBrowserOptionsEvent + + val extensionsStore = extensionStoreFactory.create(hostApi = this, coroutineScope = this) + + private val isDesktopModeEnabledFlow = MutableStateFlow(false) + + val desktopModeChangedModel = currentPageAnalyzed + .map { currentPage -> + val hostSettings = interactor.getHostSettings(currentPage.url) + val isDesktopModeEnabled = hostSettings?.isDesktopModeEnabled ?: isDesktopModeEnabledFlow.first() + DesktopModeChangedEvent(isDesktopModeEnabled, currentPage.url) + } + .distinctUntilChanged() + .shareInBackground() + + private val tabsState = browserTabService.tabStateFlow + .distinctUntilChangedBy { it.stateId() } + .shareInBackground() + + val currentTabFlow = tabsState.map { it.selectedTab } + .distinctUntilChangedBy { it.stateId() } + .filterIsInstance() + .shareInBackground() + + val tabsCountFlow = tabsState.map { it.tabs.size } + .shareInBackground() + + init { + dAppSearchRequester.responseFlow + .filterIsInstance() + .onEach { forceLoad(it.url) } + .launchIn(this) + + watchDangerousWebsites() + + launch { + when (payload) { + is DAppBrowserPayload.Tab -> browserTabService.selectTab(payload.id) + + is DAppBrowserPayload.Address -> browserTabService.createAndSelectTab(payload.address) + } + } + } + + override suspend fun authorizeDApp(payload: AuthorizeDappBottomSheet.Payload): State { + val confirmationState = awaitConfirmation(DappPendingConfirmation.Action.Authorize(payload)) + + return mapConfirmationStateToAuthorizationState(confirmationState) + } + + override suspend fun confirmTx(request: ExternalSignRequest): ConfirmTxResponse { + val chainId = request.extractChainId() + val chain = chainRegistry.chainsById()[chainId] + + if (chain != null && chain.isDisabled) { + return ConfirmTxResponse.ChainIsDisabled(request.id, chain.name) + } + + val response = withContext(Dispatchers.Main) { + signRequester.awaitConfirmation(mapSignExtrinsicRequestToPayload(request)) + } + + return when (response) { + is ExternalSignCommunicator.Response.Rejected -> ConfirmTxResponse.Rejected(response.requestId) + is ExternalSignCommunicator.Response.Signed -> ConfirmTxResponse.Signed(response.requestId, response.signature, response.modifiedTransaction) + is ExternalSignCommunicator.Response.SigningFailed -> ConfirmTxResponse.SigningFailed(response.requestId, response.shouldPresent) + is ExternalSignCommunicator.Response.Sent -> ConfirmTxResponse.Sent(response.requestId, response.txHash) + } + } + + override fun reloadPage() { + _browserCommandEvent.postValue(BrowserCommand.Reload.event()) + } + + fun detachCurrentSession() { + browserTabService.detachCurrentSession() + } + + fun onPageChanged(url: String?, title: String?) { + updateCurrentPage(url ?: "", title, synchronizedWithBrowser = true) + } + + fun closeClicked() = launch { + exitBrowser() + } + + fun openSearch() = launch { + val currentPage = currentPage.first() + + dAppSearchRequester.openRequest(SearchPayload(initialUrl = currentPage.url, SearchPayload.Request.GO_TO_URL)) + } + + fun onMoreClicked() { + launch { + val payload = getCurrentPageOptionsPayload() + _openBrowserOptionsEvent.value = Event(payload) + } + } + + fun onFavoriteClick() { + launch { + val page = currentPageAnalyzed.first() + val currentPageTitle = page.title ?: page.display + val isCurrentPageFavorite = page.isFavourite + + if (isCurrentPageFavorite) { + removeFromFavouritesConfirmation.awaitAction(currentPageTitle) + + dAppInteractor.removeDAppFromFavourites(page.url) + } else { + val payload = AddToFavouritesPayload( + url = page.url, + label = currentPageTitle, + iconLink = null + ) + + router.openAddToFavourites(payload) + } + } + } + + fun onDesktopClick() { + launch { + val desktopModeChangedEvent = desktopModeChangedModel.first() + val newDesktopMode = !desktopModeChangedEvent.desktopModeEnabled + val settings = BrowserHostSettings(Urls.normalizeUrl(desktopModeChangedEvent.url), newDesktopMode) + interactor.saveHostSettings(settings) + isDesktopModeEnabledFlow.value = newDesktopMode + _browserCommandEvent.postValue(BrowserCommand.ChangeDesktopMode(newDesktopMode).event()) + } + } + + fun openTabs() { + router.openTabs() + } + + fun makePageSnapshot() { + browserTabService.makeCurrentTabSnapshot() + } + + private fun watchDangerousWebsites() { + currentPageAnalyzed + .filter { it.synchronizedWithBrowser && it.security == BrowserPageAnalyzed.Security.DANGEROUS } + .distinctUntilChanged() + .onEach { + externalEvents.emit(ExternalEvent.PhishingDetected) + + awaitConfirmation(DappPendingConfirmation.Action.AcknowledgePhishingAlert) + + exitBrowser() + } + .launchIn(this) + } + + private fun forceLoad(url: String) { + _browserCommandEvent.value = BrowserCommand.OpenUrl(url).event() + + updateCurrentPage(url, title = null, synchronizedWithBrowser = false) + } + + private suspend fun getCurrentPageOptionsPayload(): DAppOptionsPayload { + return DAppOptionsPayload( + isDesktopModeEnabled = desktopModeChangedModel.first().desktopModeEnabled + ) + } + + private suspend fun awaitConfirmation(action: DappPendingConfirmation.Action) = suspendCoroutine { + val confirmation = DappPendingConfirmation( + onConfirm = { it.resume(ConfirmationState.ALLOWED) }, + onDeny = { it.resume(ConfirmationState.REJECTED) }, + onCancel = { it.resume(ConfirmationState.CANCELLED) }, + action = action + ) + + _showConfirmationDialog.postValue(confirmation.event()) + } + + private fun mapConfirmationStateToAuthorizationState( + confirmationState: ConfirmationState + ): State = when (confirmationState) { + ConfirmationState.ALLOWED -> State.ALLOWED + ConfirmationState.REJECTED -> State.REJECTED + ConfirmationState.CANCELLED -> State.NONE + } + + private fun exitBrowser() = router.back() + + private fun updateCurrentPage( + url: String, + title: String?, + synchronizedWithBrowser: Boolean + ) = launch { + currentPage.emit(BrowserPage(url, title, synchronizedWithBrowser)) + } + + private suspend fun mapSignExtrinsicRequestToPayload(request: ExternalSignRequest): ExternalSignPayload { + return ExternalSignPayload( + signRequest = request, + dappMetadata = getDAppSignMetadata(currentPageAnalyzed.first().url), + wallet = ExternalSignWallet.Current + ) + } + + private suspend fun getDAppSignMetadata(dAppUrl: String): SigningDappMetadata { + val dappMetadata = dAppInteractor.getDAppInfo(dAppUrl) + + return SigningDappMetadata( + icon = dappMetadata.metadata?.iconLink, + name = dappMetadata.metadata?.name, + url = dappMetadata.baseUrl, + ) + } + + private fun ExternalSignRequest.extractChainId(): String? { + return when (this) { + is ExternalSignRequest.Evm -> null + is ExternalSignRequest.Polkadot -> payload.genesisHash()?.removeHexPrefix() + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/DappPendingConfirmation.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/DappPendingConfirmation.kt new file mode 100644 index 0000000..60040fd --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/DappPendingConfirmation.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main + +import io.novafoundation.nova.feature_external_sign_api.presentation.externalSign.AuthorizeDappBottomSheet + +class DappPendingConfirmation( + val onConfirm: () -> Unit, + val onDeny: () -> Unit, + val onCancel: () -> Unit, + val action: A +) { + + sealed class Action { + class Authorize(val content: AuthorizeDappBottomSheet.Payload) : Action() + + object AcknowledgePhishingAlert : Action() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/deeplink/DAppDeepLinkHandler.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/deeplink/DAppDeepLinkHandler.kt new file mode 100644 index 0000000..67fa925 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/deeplink/DAppDeepLinkHandler.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.deeplink + +import android.net.Uri +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException.DAppHandlingException +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.createAndSelectTab +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.hasSelectedTab +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +private const val DAPP_DEEP_LINK_PREFIX = "/open/dapp" + +class DAppDeepLinkHandler( + private val dappRepository: DAppMetadataRepository, + private val router: DAppRouter, + private val automaticInteractionGate: AutomaticInteractionGate, + private val browserTabService: BrowserTabService +) : DeepLinkHandler { + + override val callbackFlow: Flow = emptyFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + return path.startsWith(DAPP_DEEP_LINK_PREFIX) + } + + override suspend fun handleDeepLink(data: Uri) = runCatching { + automaticInteractionGate.awaitInteractionAllowed() + + val url = data.getDappUrl() ?: throw DAppHandlingException.UrlIsInvalid + val normalizedUrl = runCatching { Urls.normalizeUrl(url) }.getOrNull() ?: throw DAppHandlingException.UrlIsInvalid + + ensureDAppInCatalog(normalizedUrl) + + if (browserTabService.hasSelectedTab()) { + browserTabService.createAndSelectTab(normalizedUrl) + } else { + router.openDAppBrowser(DAppBrowserPayload.Address(url)) + } + } + + private suspend fun ensureDAppInCatalog(normalizedUrl: String) { + dappRepository.syncAndGetDapp(normalizedUrl) + ?: throw DAppHandlingException.DomainIsNotMatched(normalizedUrl) + } + + private fun Uri.getDappUrl(): String? { + return getQueryParameter("url") + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/di/DAppBrowserComponent.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/di/DAppBrowserComponent.kt new file mode 100644 index 0000000..a53cfdc --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/di/DAppBrowserComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DAppBrowserFragment +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload + +@Subcomponent( + modules = [ + DAppBrowserModule::class + ] +) +@ScreenScope +interface DAppBrowserComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: DAppBrowserPayload + ): DAppBrowserComponent + } + + fun inject(fragment: DAppBrowserFragment) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/di/DAppBrowserModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/di/DAppBrowserModule.kt new file mode 100644 index 0000000..4f555dd --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/di/DAppBrowserModule.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.utils.ToastMessageManager +import io.novafoundation.nova.core_db.dao.BrowserHostSettingsDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_dapp_api.data.repository.BrowserHostSettingsRepository +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.PhishingSitesRepository +import io.novafoundation.nova.feature_dapp_impl.data.repository.RealBrowserHostSettingsRepository +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.browser.DappBrowserInteractor +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DAppBrowserViewModel +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService +import io.novafoundation.nova.feature_dapp_impl.web3.states.ExtensionStoreFactory +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooserFactory +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAskerFactory +import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor +import io.novafoundation.nova.common.utils.webView.interceptors.CompoundWebViewRequestInterceptor +import io.novafoundation.nova.feature_dapp_impl.web3.webview.interceptors.WalletConnectPairingInterceptor +import io.novafoundation.nova.feature_dapp_impl.web3.webview.interceptors.Web3FallbackInterceptor +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class DAppBrowserModule { + + @Provides + @ScreenScope + fun provideBrowserHostSettingsRepository( + browserHostSettingsDao: BrowserHostSettingsDao + ): BrowserHostSettingsRepository = RealBrowserHostSettingsRepository(browserHostSettingsDao) + + @Provides + @ScreenScope + fun provideInteractor( + phishingSitesRepository: PhishingSitesRepository, + favouritesDAppRepository: FavouritesDAppRepository, + browserHostSettingsRepository: BrowserHostSettingsRepository + ) = DappBrowserInteractor( + phishingSitesRepository = phishingSitesRepository, + favouritesDAppRepository = favouritesDAppRepository, + browserHostSettingsRepository = browserHostSettingsRepository + ) + + @Provides + @ScreenScope + fun provideFileChooser( + fragment: Fragment, + webViewFileChooserFactory: WebViewFileChooserFactory + ) = webViewFileChooserFactory.create(fragment) + + @Provides + @ScreenScope + fun providePermissionAsker( + fragment: Fragment, + webViewPermissionAskerFactory: WebViewPermissionAskerFactory + ) = webViewPermissionAskerFactory.create(fragment) + + @Provides + @ScreenScope + fun provideWebViewClientInterceptor( + toastMessageManager: ToastMessageManager, + contextManager: ContextManager, + walletConnectService: WalletConnectService + ): WebViewRequestInterceptor { + return CompoundWebViewRequestInterceptor( + WalletConnectPairingInterceptor(walletConnectService), + Web3FallbackInterceptor(toastMessageManager, contextManager) + ) + } + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): DAppBrowserViewModel { + return ViewModelProvider(fragment, factory).get(DAppBrowserViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(DAppBrowserViewModel::class) + fun provideViewModel( + router: DAppRouter, + interactor: DappBrowserInteractor, + selectedAccountUseCase: SelectedAccountUseCase, + signRequester: ExternalSignCommunicator, + searchRequester: DAppSearchCommunicator, + payload: DAppBrowserPayload, + extensionStoreFactory: ExtensionStoreFactory, + dAppInteractor: DappInteractor, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + chainRegistry: ChainRegistry, + browserTabService: BrowserTabService + ): ViewModel { + return DAppBrowserViewModel( + router = router, + interactor = interactor, + dAppInteractor = dAppInteractor, + selectedAccountUseCase = selectedAccountUseCase, + signRequester = signRequester, + dAppSearchRequester = searchRequester, + payload = payload, + extensionStoreFactory = extensionStoreFactory, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + chainRegistry = chainRegistry, + browserTabService = browserTabService + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/sheets/AcknowledgePhishingBottomSheet.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/sheets/AcknowledgePhishingBottomSheet.kt new file mode 100644 index 0000000..d3fc3d2 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/sheets/AcknowledgePhishingBottomSheet.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.sheets + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.view.bottomSheet.ActionNotAllowedBottomSheet +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DappPendingConfirmation + +class AcknowledgePhishingBottomSheet( + context: Context, + private val confirmation: DappPendingConfirmation<*>, +) : ActionNotAllowedBottomSheet( + context = context, + onSuccess = { confirmation.onConfirm() } +) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + titleView.setText(R.string.dapp_phishing_title) + subtitleView.setText(R.string.dapp_phishing_subtitle) + + applySolidIconStyle(R.drawable.ic_warning_filled, tint = null) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/view/AddressBarView.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/view/AddressBarView.kt new file mode 100644 index 0000000..427fa25 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/main/view/AddressBarView.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.databinding.ViewAddressBarBinding + +class AddressBarView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions { + + private val binder = ViewAddressBarBinding.inflate(inflater(), this) + + override val providedContext: Context = context + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER + + background = addRipple(getRoundedCornerDrawable(R.color.dapp_blur_navigation_background, cornerSizeDp = 10), mask = getRippleMask(cornerSizeDp = 10)) + } + + fun setAddress(address: String) { + binder.addressBarUrl.text = address + } + + fun showSecureIcon(shouldShow: Boolean) { + binder.addressBarIcon.setVisible(shouldShow) + } + + fun showSecure(shouldShow: Boolean) { + binder.addressBarIcon.setVisible(shouldShow) + + if (shouldShow) { + binder.addressBarUrl.setTextColorRes(R.color.text_positive) + binder.addressBarIcon.setImageTintRes(R.color.icon_positive) + } else { + binder.addressBarUrl.setTextColorRes(R.color.text_primary) + binder.addressBarIcon.setImageTintRes(R.color.icon_primary) + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/options/DAppOptionsPayload.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/options/DAppOptionsPayload.kt new file mode 100644 index 0000000..080b9ea --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/options/DAppOptionsPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.options + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class DAppOptionsPayload( + val isDesktopModeEnabled: Boolean +) : Parcelable diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/options/OptionsBottomSheetDialog.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/options/OptionsBottomSheetDialog.kt new file mode 100644 index 0000000..045861f --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/browser/options/OptionsBottomSheetDialog.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.browser.options + +import android.content.Context +import android.view.View +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.switcherItem +import io.novafoundation.nova.feature_dapp_impl.R + +class OptionsBottomSheetDialog( + context: Context, + private val callback: Callback, + private val payload: DAppOptionsPayload +) : FixedListBottomSheet(context, viewConfiguration = ViewConfiguration.default(context)) { + + init { + setTitle(R.string.dapp_options_title) + + switcherItem( + R.drawable.ic_desktop, + R.string.dapp_options_desktop_mode, + payload.isDesktopModeEnabled, + ::toggleDesktopMode + ) + } + + private fun toggleDesktopMode(view: View) { + callback.onDesktopModeClick() + } + + interface Callback { + fun onDesktopModeClick() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DAppClickHandler.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DAppClickHandler.kt new file mode 100644 index 0000000..bed4402 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DAppClickHandler.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.common + +interface DAppClickHandler { + fun onDAppClicked(item: DappModel) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappCategoryListAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappCategoryListAdapter.kt new file mode 100644 index 0000000..0612920 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappCategoryListAdapter.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.common + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearSnapHelper +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.recyclerView.WithViewType +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappGroupBinding + +class DappCategoryListAdapter( + private val handler: DAppClickHandler +) : BaseListAdapter(DappCategoryDiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DappCategoryViewHolder { + return DappCategoryViewHolder(ItemDappGroupBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: DappCategoryViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun getItemViewType(position: Int): Int { + return DappCategoryViewHolder.viewType + } +} + +private object DappCategoryDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: DappCategoryModel, newItem: DappCategoryModel): Boolean { + return oldItem.categoryName == newItem.categoryName + } + + override fun areContentsTheSame(oldItem: DappCategoryModel, newItem: DappCategoryModel): Boolean { + return oldItem == newItem + } +} + +class DappCategoryViewHolder( + private val binder: ItemDappGroupBinding, + itemHandler: DAppClickHandler, +) : BaseViewHolder(binder.root) { + + companion object : WithViewType { + override val viewType: Int = R.layout.item_dapp_group + } + + private val adapter = DappListAdapter(itemHandler) + + init { + binder.dappRecyclerView.layoutManager = GridLayoutManager(itemView.context, 3, GridLayoutManager.HORIZONTAL, false) + binder.dappRecyclerView.adapter = adapter + binder.dappRecyclerView.itemAnimator = null + val snapHelper = LinearSnapHelper() + snapHelper.attachToRecyclerView(binder.dappRecyclerView) + } + + fun bind(item: DappCategoryModel) = with(binder) { + itemDAppCategoryTitle.text = item.categoryName + adapter.submitList(item.items) + } + + override fun unbind() {} +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappCategoryModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappCategoryModel.kt new file mode 100644 index 0000000..cdf6f0a --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappCategoryModel.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.common + +data class DappCategoryModel( + val categoryName: String, + val items: List +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappListAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappListAdapter.kt new file mode 100644 index 0000000..3934dfb --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappListAdapter.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.common + +import android.view.ViewGroup +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView + +class DappListAdapter( + private val handler: DAppClickHandler +) : BaseListAdapter(DappModelDiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DappViewHolder { + return DappViewHolder(DAppView.createUsingMathParentWidth(parent.context), handler) + } + + override fun onBindViewHolder(holder: DappViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +class DappViewHolder( + private val dAppView: DAppView, + private val itemHandler: DAppClickHandler, +) : BaseViewHolder(dAppView) { + + fun bind(item: DappModel) = with(dAppView) { + setTitle(item.name) + setSubtitle(item.description) + setIconUrl(item.iconUrl) + setFavoriteIconVisible(item.isFavourite) + + setOnClickListener { itemHandler.onDAppClicked(item) } + } + + override fun unbind() { + dAppView.clearIcon() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappModel.kt new file mode 100644 index 0000000..943de34 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappModel.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_dapp_api.data.model.DApp +import io.novafoundation.nova.feature_dapp_api.data.model.DAppGroupedCatalog +import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp +import io.novafoundation.nova.feature_dapp_impl.domain.common.dappToFavorite +import io.novafoundation.nova.feature_dapp_impl.domain.common.favouriteToDApp + +data class DappModel( + val name: String, + val description: String, + val iconUrl: String?, + val isFavourite: Boolean, + val favoriteIndex: Int?, + val url: String +) + +fun mapDappCategoriesToDescription(categories: Collection) = categories.joinToString { it.name } + +fun mapDAppCatalogToDAppCategoryModels(resourceManager: ResourceManager, dappCatalog: DAppGroupedCatalog): List { + val popular = mapDappCategoryToDappCategoryModel(resourceManager.getString(R.string.popular_dapps_title), dappCatalog.popular) + val categories = dappCatalog.categoriesWithDApps.map { (category, dapps) -> mapDappCategoryToDappCategoryModel(category.name, dapps) } + + return listOf(popular) + categories +} + +fun mapDappCategoryToDappCategoryModel(categoryName: String, dApps: List) = DappCategoryModel( + categoryName = categoryName, + items = dApps.map { mapDappToDappModel(it) } +) + +fun mapDappToDappModel(dApp: DApp) = with(dApp) { + DappModel( + name = name, + description = description, + iconUrl = iconLink, + url = url, + isFavourite = isFavourite, + favoriteIndex = favoriteIndex + ) +} + +fun mapDappModelToDApp(dApp: DappModel) = with(dApp) { + DApp( + name = name, + description = description, + iconLink = iconUrl, + url = url, + isFavourite = isFavourite, + favoriteIndex = favoriteIndex + ) +} + +fun mapFavoriteDappToDappModel(favoriteDapp: FavouriteDApp): DappModel { + val dapp = favouriteToDApp(favoriteDapp) + return mapDappToDappModel(dapp) +} + +fun mapDAppModelToFavorite(model: DappModel, orderingIndex: Int): FavouriteDApp { + val dapp = mapDappModelToDApp(model) + return dappToFavorite(dapp, orderingIndex) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappModelDiffCallback.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappModelDiffCallback.kt new file mode 100644 index 0000000..64babac --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappModelDiffCallback.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.common + +import androidx.recyclerview.widget.DiffUtil + +object DappModelDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: DappModel, newItem: DappModel): Boolean { + return oldItem.url == newItem.url + } + + override fun areContentsTheSame(oldItem: DappModel, newItem: DappModel): Boolean { + return oldItem == newItem + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappModelMapper.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappModelMapper.kt new file mode 100644 index 0000000..91cca77 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/DappModelMapper.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.common + +import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory +import io.novafoundation.nova.feature_dapp_impl.presentation.main.model.DAppCategoryModel + +fun dappCategoryToUi(dappCategory: DappCategory, isSelected: Boolean): DAppCategoryModel { + return DAppCategoryModel( + id = dappCategory.id, + name = dappCategory.name, + selected = isSelected, + iconUrl = dappCategory.iconUrl + ) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/favourites/FavouritesUi.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/favourites/FavouritesUi.kt new file mode 100644 index 0000000..3a1a275 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/common/favourites/FavouritesUi.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites + +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationAwaitable +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.feature_dapp_impl.R + +typealias RemoveFavouritesPayload = String // dApp title + +fun BaseFragmentMixin<*>.setupRemoveFavouritesConfirmation(awaitableMixin: ConfirmationAwaitable) { + awaitableMixin.awaitableActionLiveData.observeEvent { + warningDialog( + context = providedContext, + onPositiveClick = { it.onSuccess(Unit) }, + positiveTextRes = R.string.common_remove, + onNegativeClick = it.onCancel + ) { + setTitle(R.string.dapp_favourites_remove_title) + + setMessage(providedContext.getString(R.string.dapp_favourites_remove_description, it.payload)) + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/DAppFavoritesViewModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/DAppFavoritesViewModel.kt new file mode 100644 index 0000000..8ee0a56 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/DAppFavoritesViewModel.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.favorites + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.data.model.FavouriteDApp +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel +import io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites.RemoveFavouritesPayload +import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapDAppModelToFavorite +import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapFavoriteDappToDappModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class DAppFavoritesViewModel( + private val router: DAppRouter, + private val interactor: DappInteractor, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, +) : BaseViewModel() { + + val removeFavouriteConfirmationAwaitable = actionAwaitableMixinFactory.confirmingAction() + + private val favoriteDAppsFlow = MutableStateFlow>(emptyList()) + + val favoriteDAppsUIFlow = favoriteDAppsFlow + .map { dapps -> dapps.map { mapFavoriteDappToDappModel(it) } } + .shareInBackground() + + init { + launch { + updateDApps() + } + } + + fun backClicked() { + router.back() + } + + fun openDApp(dapp: DappModel) { + router.openDAppBrowser(DAppBrowserPayload.Address(dapp.url)) + } + + fun onFavoriteClicked(dapp: DappModel) = launch { + removeFavouriteConfirmationAwaitable.awaitAction(dapp.name) + + interactor.removeDAppFromFavourites(dapp.url) + + // Update list, since item was removed + updateDApps() + } + + fun changeDAppOrdering(newOrdering: List) = launch { + val favoriteItems = newOrdering.mapIndexed { index, dappModel -> + mapDAppModelToFavorite(dappModel, index) + } + + interactor.updateFavoriteDapps(favoriteItems) + } + + private suspend fun updateDApps() { + val dapps = interactor.getFavoriteDApps() + favoriteDAppsFlow.value = dapps + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/DappDraggableFavoritesAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/DappDraggableFavoritesAdapter.kt new file mode 100644 index 0000000..6f7569b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/DappDraggableFavoritesAdapter.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.favorites + +import android.annotation.SuppressLint +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import coil.ImageLoader +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.recyclerView.dragging.OnItemDragCallback +import io.novafoundation.nova.common.utils.recyclerView.dragging.StartDragListener +import io.novafoundation.nova.common.utils.recyclerView.dragging.prepareForDragging +import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappFavoriteDragableBinding +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel +import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon +import java.util.Collections + +class DappDraggableFavoritesAdapter( + private val imageLoader: ImageLoader, + private val handler: Handler, + private val startDragListener: StartDragListener +) : RecyclerView.Adapter(), OnItemDragCallback { + + interface Handler { + fun onDAppClicked(dapp: DappModel) + + fun onDAppFavoriteClicked(dapp: DappModel) + + fun onItemOrderingChanged(dapps: List) + } + + private val dapps = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DappDraggableFavoritesViewHolder { + return DappDraggableFavoritesViewHolder( + ItemDappFavoriteDragableBinding.inflate(parent.inflater(), parent, false), + imageLoader, + handler, + startDragListener + ) + } + + override fun getItemCount(): Int { + return dapps.size + } + + override fun onBindViewHolder(holder: DappDraggableFavoritesViewHolder, position: Int) { + holder.bind(dapps[position]) + } + + override fun onItemMove(fromPosition: Int, toPosition: Int) { + Collections.swap(dapps, fromPosition, toPosition) + notifyItemMoved(toPosition, fromPosition) + handler.onItemOrderingChanged(dapps) + } + + @SuppressLint("NotifyDataSetChanged") + fun submitList(dapps: List) { + this.dapps.clear() + this.dapps.addAll(dapps) + notifyDataSetChanged() + } +} + +class DappDraggableFavoritesViewHolder( + private val binder: ItemDappFavoriteDragableBinding, + private val imageLoader: ImageLoader, + private val itemHandler: DappDraggableFavoritesAdapter.Handler, + private val startDragListener: StartDragListener +) : ViewHolder(binder.root) { + + @SuppressLint("ClickableViewAccessibility") + fun bind(item: DappModel) = with(itemView) { + binder.itemDraggableFavoriteDAppIcon.showDAppIcon(item.iconUrl, imageLoader) + binder.itemDraggableFavoriteDAppTitle.text = item.name + binder.itemDraggableFavoriteDAppSubtitle.text = item.description + + binder.itemDraggableFavoriteDappDragHandle.prepareForDragging(this@DappDraggableFavoritesViewHolder, startDragListener) + + binder.itemDraggableFavoriteDappFavoriteIcon.setOnClickListener { itemHandler.onDAppFavoriteClicked(item) } + setOnClickListener { itemHandler.onDAppClicked(item) } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/DappFavoritesFragment.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/DappFavoritesFragment.kt new file mode 100644 index 0000000..7bd9e6c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/DappFavoritesFragment.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.favorites + +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseBottomSheetFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.recyclerView.dragging.SimpleItemDragHelperCallback +import io.novafoundation.nova.common.utils.recyclerView.dragging.StartDragListener +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentFavoritesDappBinding +import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel +import io.novafoundation.nova.feature_dapp_impl.presentation.common.favourites.setupRemoveFavouritesConfirmation +import javax.inject.Inject + +class DappFavoritesFragment : + BaseBottomSheetFragment(), + DappDraggableFavoritesAdapter.Handler, + StartDragListener { + + override fun createBinding() = FragmentFavoritesDappBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { DappDraggableFavoritesAdapter(imageLoader, this, this) } + + private val itemDragHelper by lazy(LazyThreadSafetyMode.NONE) { ItemTouchHelper(SimpleItemDragHelperCallback(adapter)) } + + override fun initViews() { + binder.favoritesDappToolbar.applyStatusBarInsets() + binder.favoritesDappToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.favoritesDappList.adapter = adapter + itemDragHelper.attachToRecyclerView(binder.favoritesDappList) + } + + override fun inject() { + FeatureUtils.getFeature(this, DAppFeatureApi::class.java) + .dAppFavoritesComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: DAppFavoritesViewModel) { + setupRemoveFavouritesConfirmation(viewModel.removeFavouriteConfirmationAwaitable) + + viewModel.favoriteDAppsUIFlow.observe { + adapter.submitList(it) + } + } + + override fun onDAppClicked(dapp: DappModel) { + viewModel.openDApp(dapp) + } + + override fun onDAppFavoriteClicked(dapp: DappModel) { + viewModel.onFavoriteClicked(dapp) + } + + override fun onItemOrderingChanged(dapps: List) { + viewModel.changeDAppOrdering(dapps) + } + + override fun requestDrag(viewHolder: RecyclerView.ViewHolder) { + itemDragHelper.startDrag(viewHolder) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/di/DAppFavoritesComponent.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/di/DAppFavoritesComponent.kt new file mode 100644 index 0000000..51564ec --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/di/DAppFavoritesComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.favorites.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_dapp_impl.presentation.favorites.DappFavoritesFragment + +@Subcomponent( + modules = [ + DAppFavoritesModule::class + ] +) +@ScreenScope +interface DAppFavoritesComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): DAppFavoritesComponent + } + + fun inject(fragment: DappFavoritesFragment) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/di/DAppFavoritesModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/di/DAppFavoritesModule.kt new file mode 100644 index 0000000..bd9a5da --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/favorites/di/DAppFavoritesModule.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.favorites.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.presentation.favorites.DAppFavoritesViewModel + +@Module(includes = [ViewModelModule::class]) +class DAppFavoritesModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): DAppFavoritesViewModel { + return ViewModelProvider(fragment, factory).get(DAppFavoritesViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(DAppFavoritesViewModel::class) + fun provideViewModel( + router: DAppRouter, + interactor: DappInteractor, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + ): ViewModel { + return DAppFavoritesViewModel( + router, + interactor, + actionAwaitableMixinFactory + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/DAppHeaderAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/DAppHeaderAdapter.kt new file mode 100644 index 0000000..75aed6f --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/DAppHeaderAdapter.kt @@ -0,0 +1,134 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedWalletModel +import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappHeaderBinding +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel +import io.novafoundation.nova.feature_dapp_impl.presentation.main.model.DAppCategoryModel + +class DAppHeaderAdapter( + val imageLoader: ImageLoader, + val headerHandler: Handler, + val categoriesHandler: DappCategoriesAdapter.Handler +) : RecyclerView.Adapter() { + + private var walletModel: SelectedWalletModel? = null + private var categories: List = emptyList() + private var favoritesDApps: List = emptyList() + private var showCategoriesShimmering: Boolean = false + + interface Handler { + + fun onWalletClick() + + fun onSearchClick() + + fun onManageClick() + + fun onManageFavoritesClick() + + fun onCategoryClicked(id: String) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderHolder { + return HeaderHolder( + imageLoader, + ItemDappHeaderBinding.inflate(parent.inflater(), parent, false), + headerHandler, + categoriesHandler + ) + } + + override fun onBindViewHolder(holder: HeaderHolder, position: Int) { + holder.bind( + walletModel, + categories, + favoritesDApps, + showCategoriesShimmering + ) + } + + override fun onBindViewHolder(holder: HeaderHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + } else { + payloads.filterIsInstance().forEach { + when (it) { + Payload.WALLET -> holder.bindWallet(walletModel) + Payload.CATEGORIES -> holder.bindCategories(categories) + Payload.CATEGORIES_SHIMMERING -> holder.bindCategoriesShimmering(showCategoriesShimmering) + } + } + } + } + + override fun getItemCount(): Int { + return 1 + } + + fun setWallet(walletModel: SelectedWalletModel) { + this.walletModel = walletModel + notifyItemChanged(0, Payload.WALLET) + } + + fun setCategories(categories: List) { + this.categories = categories + notifyItemChanged(0, Payload.CATEGORIES) + } + + fun showCategoriesShimmering(show: Boolean) { + showCategoriesShimmering = show + notifyItemChanged(0, Payload.CATEGORIES_SHIMMERING) + } +} + +class HeaderHolder( + private val imageLoader: ImageLoader, + private val binder: ItemDappHeaderBinding, + headerHandler: DAppHeaderAdapter.Handler, + categoriesHandler: DappCategoriesAdapter.Handler +) : RecyclerView.ViewHolder(binder.root) { + + private val categoriesAdapter = DappCategoriesAdapter(imageLoader, categoriesHandler) + + init { + binder.dappMainSelectedWallet.setOnClickListener { headerHandler.onWalletClick() } + binder.dappMainSearch.setOnClickListener { headerHandler.onSearchClick() } + binder.dappMainManage.setOnClickListener { headerHandler.onManageClick() } + binder.mainDappCategories.adapter = categoriesAdapter + } + + fun bind( + walletModel: SelectedWalletModel?, + categoriesState: List, + favoritesDApps: List, + showCategoriesShimmering: Boolean + ) { + bindWallet(walletModel) + bindCategories(categoriesState) + bindCategoriesShimmering(showCategoriesShimmering) + } + + fun bindWallet(walletModel: SelectedWalletModel?) = with(binder) { + walletModel?.let { dappMainSelectedWallet.setModel(walletModel) } + } + + fun bindCategories(categoriesState: List) = with(binder) { + categoriesAdapter.submitList(categoriesState) + } + + fun bindCategoriesShimmering(showCategoriesShimmering: Boolean) = with(itemView) { + binder.categorizedDappsCategoriesShimmering.setVisible(showCategoriesShimmering, falseState = View.INVISIBLE) + binder.mainDappCategories.isInvisible = showCategoriesShimmering + } +} + +private enum class Payload { + WALLET, CATEGORIES, CATEGORIES_SHIMMERING +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/DappCategoriesAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/DappCategoriesAdapter.kt new file mode 100644 index 0000000..cdb1b30 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/DappCategoriesAdapter.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main + +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.loadOrHide +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappCategoryBinding +import io.novafoundation.nova.feature_dapp_impl.presentation.main.model.DAppCategoryModel + +class DappCategoriesAdapter( + private val imageLoader: ImageLoader, + private val handler: Handler, +) : ListAdapter(DappDiffCallback) { + + interface Handler { + + fun onCategoryClicked(id: String) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DappCategoryViewHolder { + return DappCategoryViewHolder(ItemDappCategoryBinding.inflate(parent.inflater(), parent, false), imageLoader, handler) + } + + override fun onBindViewHolder(holder: DappCategoryViewHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + + resolvePayload(holder, position, payloads) { + when (it) { + DAppCategoryModel::selected -> holder.bindSelected(item.selected) + } + } + } + + override fun onBindViewHolder(holder: DappCategoryViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private val dAppCategoryPayloadGenerator = PayloadGenerator(DAppCategoryModel::selected) + +private object DappDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: DAppCategoryModel, newItem: DAppCategoryModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: DAppCategoryModel, newItem: DAppCategoryModel): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: DAppCategoryModel, newItem: DAppCategoryModel): Any? { + return dAppCategoryPayloadGenerator.diff(oldItem, newItem) + } +} + +class DappCategoryViewHolder( + private val binder: ItemDappCategoryBinding, + private val imageLoader: ImageLoader, + private val itemHandler: DappCategoriesAdapter.Handler, +) : RecyclerView.ViewHolder(binder.root) { + + fun bind(item: DAppCategoryModel) = with(binder) { + itemDappCategoryIcon.loadOrHide(item.iconUrl, imageLoader) + itemDappCategoryText.text = item.name + + bindSelected(item.selected) + + binder.root.setOnClickListener { itemHandler.onCategoryClicked(item.id) } + } + + fun bindSelected(isSelected: Boolean) = with(binder) { + root.isSelected = isSelected + + // We must set tint to image view programmatically since we can't specify the state for default color in state list + if (isSelected) { + itemDappCategoryIcon.setColorFilter(ContextCompat.getColor(itemView.context, R.color.icon_primary_on_content)) + } else { + itemDappCategoryIcon.clearColorFilter() + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/DappFavoritesAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/DappFavoritesAdapter.kt new file mode 100644 index 0000000..d0cc3e4 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/DappFavoritesAdapter.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main + +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import coil.ImageLoader +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_dapp_impl.databinding.ItemFavoriteDappBinding +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DAppClickHandler +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModelDiffCallback +import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon + +class DappFavoritesAdapter( + private val imageLoader: ImageLoader, + private val handler: DAppClickHandler +) : ListAdapter(DappModelDiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoriteDappViewHolder { + return FavoriteDappViewHolder(ItemFavoriteDappBinding.inflate(parent.inflater(), parent, false), imageLoader, handler) + } + + override fun onBindViewHolder(holder: FavoriteDappViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +class FavoriteDappViewHolder( + private val binder: ItemFavoriteDappBinding, + private val imageLoader: ImageLoader, + private val itemHandler: DAppClickHandler, +) : ViewHolder(binder.root) { + + fun bind(item: DappModel) = with(binder) { + itemFavoriteDAppIcon.showDAppIcon(item.iconUrl, imageLoader) + itemFavoriteDAppTitle.text = item.name + + binder.root.setOnClickListener { itemHandler.onDAppClicked(item) } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/MainDAppFragment.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/MainDAppFragment.kt new file mode 100644 index 0000000..bf7b33d --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/MainDAppFragment.kt @@ -0,0 +1,132 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main + +import android.view.View +import androidx.recyclerview.widget.ConcatAdapter +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.recyclerView.space.SpaceBetween +import io.novafoundation.nova.common.utils.recyclerView.space.addSpaceItemDecoration +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannerAdapter +import io.novafoundation.nova.feature_banners_api.presentation.bindWithAdapter +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentDappMainBinding +import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DAppClickHandler +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappCategoryListAdapter +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappCategoryViewHolder +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel +import javax.inject.Inject + +class MainDAppFragment : + BaseFragment(), + DAppClickHandler, + DAppHeaderAdapter.Handler, + DappCategoriesAdapter.Handler, + MainFavoriteDAppsAdapter.Handler { + + override fun createBinding() = FragmentDappMainBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val headerAdapter by lazy(LazyThreadSafetyMode.NONE) { DAppHeaderAdapter(imageLoader, this, this) } + + private val bannerAdapter: PromotionBannerAdapter by lazy(LazyThreadSafetyMode.NONE) { PromotionBannerAdapter(closable = false) } + + private val favoritesAdapter: MainFavoriteDAppsAdapter by lazy(LazyThreadSafetyMode.NONE) { MainFavoriteDAppsAdapter(this, this, imageLoader) } + + private val dappsShimmering by lazy(LazyThreadSafetyMode.NONE) { CustomPlaceholderAdapter(R.layout.layout_dapps_shimmering) } + + private val dappCategoriesListAdapter by lazy(LazyThreadSafetyMode.NONE) { DappCategoryListAdapter(this) } + + override fun applyInsets(rootView: View) { + binder.dappRecyclerViewCatalog.applyStatusBarInsets() + } + + override fun initViews() { + binder.dappRecyclerViewCatalog.adapter = ConcatAdapter(headerAdapter, bannerAdapter, favoritesAdapter, dappsShimmering, dappCategoriesListAdapter) + binder.dappRecyclerViewCatalog.itemAnimator = null + setupRecyclerViewSpacing() + } + + override fun inject() { + FeatureUtils.getFeature(this, DAppFeatureApi::class.java) + .mainComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: MainDAppViewModel) { + observeBrowserEvents(viewModel) + viewModel.bannersMixin.bindWithAdapter(bannerAdapter) { + binder.dappRecyclerViewCatalog?.invalidateItemDecorations() + } + + viewModel.selectedWalletFlow.observe(headerAdapter::setWallet) + + viewModel.shownDAppsStateFlow.observe { state -> + when (state) { + is LoadingState.Loaded -> { + dappsShimmering.show(false) + dappCategoriesListAdapter.submitList(state.data) + } + + is LoadingState.Loading -> { + dappsShimmering.show(true) + dappCategoriesListAdapter.submitList(listOf()) + } + + else -> {} + } + } + + viewModel.categoriesStateFlow.observe { state -> + headerAdapter.showCategoriesShimmering(state is LoadingState.Loading) + if (state is LoadingState.Loaded) { + headerAdapter.setCategories(state.data.categories) + } + } + + viewModel.favoriteDAppsUIFlow.observe { + favoritesAdapter.show(it.isNotEmpty()) + favoritesAdapter.setDApps(it) + } + } + + override fun onCategoryClicked(id: String) { + viewModel.openCategory(id) + } + + override fun onDAppClicked(item: DappModel) { + viewModel.dappClicked(item) + } + + override fun onWalletClick() { + viewModel.accountIconClicked() + } + + override fun onSearchClick() { + viewModel.searchClicked() + } + + override fun onManageClick() { + viewModel.manageClicked() + } + + override fun onManageFavoritesClick() { + viewModel.openFavorites() + } + + private fun setupRecyclerViewSpacing() { + binder.dappRecyclerViewCatalog.addSpaceItemDecoration { + // Add extra space between items + add(SpaceBetween(DappCategoryViewHolder, spaceDp = 8)) + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/MainDAppViewModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/MainDAppViewModel.kt new file mode 100644 index 0000000..f0fc2c6 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/MainDAppViewModel.kt @@ -0,0 +1,110 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.indexOfFirstOrNull +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.dappsSource +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel +import io.novafoundation.nova.feature_dapp_impl.presentation.common.dappCategoryToUi +import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapDAppCatalogToDAppCategoryModels +import io.novafoundation.nova.feature_dapp_impl.presentation.common.mapFavoriteDappToDappModel +import io.novafoundation.nova.feature_dapp_impl.presentation.main.model.DAppCategoryState +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class MainDAppViewModel( + private val promotionBannersMixinFactory: PromotionBannersMixinFactory, + private val bannerSourceFactory: BannersSourceFactory, + private val router: DAppRouter, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val dappInteractor: DappInteractor, + private val resourceManager: ResourceManager +) : BaseViewModel(), Browserable { + + override val openBrowserEvent = MutableLiveData>() + + val selectedWalletFlow = selectedAccountUseCase.selectedWalletModelFlow() + .shareInBackground() + + val bannersMixin = promotionBannersMixinFactory.create(bannerSourceFactory.dappsSource(), viewModelScope) + + private val favoriteDAppsFlow = dappInteractor.observeFavoriteDApps() + .shareInBackground() + + private val groupedDAppsFlow = dappInteractor.observeDAppsByCategory() + .inBackground() + .share() + + private val groupedDAppsUiFlow = groupedDAppsFlow + .map { mapDAppCatalogToDAppCategoryModels(resourceManager, it) } + .inBackground() + .share() + + val favoriteDAppsUIFlow = favoriteDAppsFlow + .map { dapps -> dapps.map { mapFavoriteDappToDappModel(it) } } + .shareInBackground() + + val shownDAppsStateFlow = groupedDAppsUiFlow + .filterNotNull() + .withLoading() + .share() + + val categoriesStateFlow = groupedDAppsFlow + .map { catalog -> catalog.categoriesWithDApps.keys.map { dappCategoryToUi(it, isSelected = false) } } + .map { categories -> + DAppCategoryState( + categories = categories, + selectedIndex = categories.indexOfFirstOrNull { it.selected } + ) + } + .inBackground() + .withLoading() + .share() + + init { + syncDApps() + } + + fun openCategory(categoryId: String) { + router.openDappSearchWithCategory(categoryId) + } + + fun accountIconClicked() { + router.openChangeAccount() + } + + fun dappClicked(dapp: DappModel) { + router.openDAppBrowser(DAppBrowserPayload.Address(dapp.url)) + } + + fun searchClicked() { + router.openDappSearch() + } + + fun manageClicked() { + router.openAuthorizedDApps() + } + + private fun syncDApps() = launch { + dappInteractor.dAppsSync() + } + + fun openFavorites() { + router.openDAppFavorites() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/MainFavoriteDAppsAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/MainFavoriteDAppsAdapter.kt new file mode 100644 index 0000000..2778fe3 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/MainFavoriteDAppsAdapter.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main + +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import io.novafoundation.nova.common.list.SingleItemAdapter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_dapp_impl.databinding.ItemMainFavoriteDappsBinding +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DAppClickHandler +import io.novafoundation.nova.feature_dapp_impl.presentation.common.DappModel + +class MainFavoriteDAppsAdapter( + private val dappClickHandler: DAppClickHandler, + private val handler: Handler, + private val imageLoader: ImageLoader +) : SingleItemAdapter(isShownByDefault = true) { + + interface Handler { + fun onManageFavoritesClick() + } + + private var favoritesDApps: List = emptyList() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavoriteDAppHolder { + val binder = ItemMainFavoriteDappsBinding.inflate(parent.inflater(), parent, false) + return FavoriteDAppHolder(binder, imageLoader, dappClickHandler, handler) + } + + override fun onBindViewHolder(holder: FavoriteDAppHolder, position: Int) { + holder.bind(favoritesDApps) + } + + fun setDApps(dapps: List) { + favoritesDApps = dapps + notifyChangedIfShown() + } +} + +class FavoriteDAppHolder( + private val binder: ItemMainFavoriteDappsBinding, + imageLoader: ImageLoader, + dAppClickHandler: DAppClickHandler, + handler: MainFavoriteDAppsAdapter.Handler +) : RecyclerView.ViewHolder(binder.root) { + + private val favoritesAdapter = DappFavoritesAdapter(imageLoader, dAppClickHandler) + + init { + binder.dAppMainFavoriteDAppList.adapter = favoritesAdapter + binder.dAppMainFavoriteDAppsShow.setOnClickListener { handler.onManageFavoritesClick() } + } + + fun bind(dapps: List) = with(binder) { + dAppMainFavoriteDAppList.isGone = dapps.isEmpty() + dAppMainFavoriteDAppTitle.isGone = dapps.isEmpty() + dAppMainFavoriteDAppsShow.isGone = dapps.isEmpty() + + favoritesAdapter.submitList(dapps) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/di/MainDAppComponent.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/di/MainDAppComponent.kt new file mode 100644 index 0000000..bb5bd57 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/di/MainDAppComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_dapp_impl.presentation.main.MainDAppFragment + +@Subcomponent( + modules = [ + MainDAppModule::class + ] +) +@ScreenScope +interface MainDAppComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): MainDAppComponent + } + + fun inject(fragment: MainDAppFragment) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/di/MainDAppModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/di/MainDAppModule.kt new file mode 100644 index 0000000..1e3766f --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/di/MainDAppModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_banners_api.presentation.PromotionBannersMixinFactory +import io.novafoundation.nova.feature_banners_api.presentation.source.BannersSourceFactory +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.presentation.main.MainDAppViewModel + +@Module(includes = [ViewModelModule::class]) +class MainDAppModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): MainDAppViewModel { + return ViewModelProvider(fragment, factory).get(MainDAppViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(MainDAppViewModel::class) + fun provideViewModel( + promotionBannersMixinFactory: PromotionBannersMixinFactory, + bannerSourceFactory: BannersSourceFactory, + selectedAccountUseCase: SelectedAccountUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + router: DAppRouter, + dappInteractor: DappInteractor, + resourceManager: ResourceManager + ): ViewModel { + return MainDAppViewModel( + promotionBannersMixinFactory = promotionBannersMixinFactory, + bannerSourceFactory = bannerSourceFactory, + router = router, + selectedAccountUseCase = selectedAccountUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + dappInteractor = dappInteractor, + resourceManager = resourceManager + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/model/DAppCategoryModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/model/DAppCategoryModel.kt new file mode 100644 index 0000000..9ce73b7 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/model/DAppCategoryModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main.model + +data class DAppCategoryModel( + val id: String, + val iconUrl: String?, + val name: String, + val selected: Boolean +) + +class DAppCategoryState( + val categories: List, + val selectedIndex: Int? +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/view/TapToSearchView.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/view/TapToSearchView.kt new file mode 100644 index 0000000..2c4558c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/main/view/TapToSearchView.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.main.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.view.ContextThemeWrapper +import androidx.appcompat.widget.AppCompatTextView +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_dapp_impl.R + +class TapToSearchView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : AppCompatTextView(ContextThemeWrapper(context, R.style.TextAppearance_NovaFoundation_Regular_SubHeadline), attrs, defStyleAttr), + WithContextExtensions { + + override val providedContext: Context + get() = context + + init { + setPaddingRelative(12.dp, 0.dp, 12.dp, 0.dp) + + gravity = android.view.Gravity.CENTER_VERTICAL + + setDrawableStart( + drawableRes = R.drawable.ic_search, + widthInDp = 16, + heightInDp = 16, + paddingInDp = 6, + tint = R.color.icon_secondary + ) + + text = context.getString(R.string.dapp_search_hint) + setTextColorRes(R.color.hint_text) + + background = addRipple(getRoundedCornerDrawable(R.color.block_background)) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DAppSearchCommunicator.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DAppSearchCommunicator.kt new file mode 100644 index 0000000..a49e36c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DAppSearchCommunicator.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.search + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator.Response +import kotlinx.parcelize.Parcelize + +interface DAppSearchRequester : InterScreenRequester + +interface DAppSearchResponder : InterScreenResponder + +interface DAppSearchCommunicator : DAppSearchRequester, DAppSearchResponder { + + sealed interface Response : Parcelable { + + @Parcelize + class NewUrl(val url: String) : Response + + @Parcelize + object Cancel : Response + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DAppSearchViewModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DAppSearchViewModel.kt new file mode 100644 index 0000000..9825ca4 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DAppSearchViewModel.kt @@ -0,0 +1,159 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.search + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.list.headers.TextHeader +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.sendEvent +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchGroup +import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchResult +import io.novafoundation.nova.feature_dapp_impl.domain.search.SearchDappInteractor +import io.novafoundation.nova.feature_dapp_impl.presentation.common.dappCategoryToUi +import io.novafoundation.nova.feature_dapp_impl.presentation.search.model.DappSearchModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class DAppSearchViewModel( + private val router: DAppRouter, + private val resourceManager: ResourceManager, + private val interactor: SearchDappInteractor, + private val dappInteractor: DappInteractor, + private val payload: SearchPayload, + private val dAppSearchResponder: DAppSearchResponder, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val appLinksProvider: AppLinksProvider +) : BaseViewModel() { + + val dAppNotInCatalogWarning = actionAwaitableMixinFactory.confirmingAction() + + val query = MutableStateFlow(payload.initialUrl.orEmpty()) + + private val _selectQueryTextEvent = MutableLiveData>() + val selectQueryTextEvent: LiveData> = _selectQueryTextEvent + + private val selectedCategoryId = MutableStateFlow(payload.preselectedCategoryId) + + val categoriesFlow = combine( + interactor.categories(), + selectedCategoryId + ) { categories, categoryId -> + categories.map { dappCategoryToUi(it, isSelected = it.id == categoryId) } + } + .distinctUntilChanged() + .withSafeLoading() + .inBackground() + .share() + + val searchResults = combine(query, selectedCategoryId) { query, categoryId -> + interactor.searchDapps(query, categoryId) + .mapKeys { (searchGroup, _) -> mapSearchGroupToTextHeader(searchGroup) } + .mapValues { (_, groupItems) -> groupItems.map(::mapSearchResultToSearchModel) } + .toListWithHeaders() + } + .inBackground() + .share() + + init { + if (!payload.initialUrl.isNullOrEmpty()) { + _selectQueryTextEvent.sendEvent() + } + + launch { + dappInteractor.dAppsSync() + } + } + + fun cancelClicked() { + if (shouldReportResult()) { + dAppSearchResponder.respond(DAppSearchCommunicator.Response.Cancel) + } + + router.back() + } + + private fun mapSearchGroupToTextHeader(searchGroup: DappSearchGroup): TextHeader { + val content = when (searchGroup) { + DappSearchGroup.DAPPS -> resourceManager.getString(R.string.dapp_dapps) + DappSearchGroup.SEARCH -> resourceManager.getString(R.string.common_search) + } + + return TextHeader(content) + } + + private fun mapSearchResultToSearchModel(searchResult: DappSearchResult): DappSearchModel { + return when (searchResult) { + is DappSearchResult.Dapp -> DappSearchModel( + title = searchResult.dapp.name, + description = searchResult.dapp.description, + icon = searchResult.dapp.iconLink, + searchResult = searchResult, + actionIcon = R.drawable.ic_favorite_heart_filled_20.takeIf { searchResult.dapp.isFavourite } + ) + + is DappSearchResult.Search -> DappSearchModel( + title = searchResult.query, + searchResult = searchResult, + actionIcon = null + ) + + is DappSearchResult.Url -> DappSearchModel( + title = searchResult.url, + searchResult = searchResult, + actionIcon = null + ) + } + } + + fun searchResultClicked(searchResult: DappSearchResult) { + launch { + val newUrl = when (searchResult) { + is DappSearchResult.Dapp -> searchResult.dapp.url + is DappSearchResult.Search -> searchResult.searchUrl + is DappSearchResult.Url -> searchResult.url + } + + if (!searchResult.isTrustedByNova) { + dAppNotInCatalogWarning.awaitAction(DappUnknownWarningModel(appLinksProvider.email)) + } + + when (payload.request) { + SearchPayload.Request.GO_TO_URL -> { + dAppSearchResponder.respond(DAppSearchCommunicator.Response.NewUrl(newUrl)) + router.finishDappSearch() + } + + SearchPayload.Request.OPEN_NEW_URL -> router.openDAppBrowser(DAppBrowserPayload.Address(newUrl)) + } + } + } + + private fun shouldReportResult() = when (payload.request) { + SearchPayload.Request.GO_TO_URL -> true + + SearchPayload.Request.OPEN_NEW_URL -> false + } + + fun onCategoryClicked(id: String) { + if (selectedCategoryId.value == id) { + selectedCategoryId.value = null + } else { + selectedCategoryId.value = id + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DappSearchFragment.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DappSearchFragment.kt new file mode 100644 index 0000000..79d6c3c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DappSearchFragment.kt @@ -0,0 +1,130 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.search + +import android.view.View +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.isLoaded +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard +import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentSearchDappBinding +import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent +import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchResult +import io.novafoundation.nova.feature_dapp_impl.presentation.main.DappCategoriesAdapter +import javax.inject.Inject + +class DappSearchFragment : BaseFragment(), SearchDappAdapter.Handler, DappCategoriesAdapter.Handler { + + companion object { + + private const val PAYLOAD = "DappSearchFragment.PAYLOAD" + + fun getBundle(payload: SearchPayload) = bundleOf( + PAYLOAD to payload + ) + } + + override fun createBinding() = FragmentSearchDappBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val categoriesAdapter by lazy(LazyThreadSafetyMode.NONE) { DappCategoriesAdapter(imageLoader, this) } + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { SearchDappAdapter(this) } + + override fun applyInsets(rootView: View) { + binder.searchDappSearch.applyStatusBarInsets() + binder.searchDappSearhContainer.applyNavigationBarInsets(consume = false, imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.searchDappCategories.adapter = categoriesAdapter + binder.searchDappList.adapter = adapter + binder.searchDappList.setHasFixedSize(true) + + binder.searchDappSearch.cancel.setOnClickListener { + viewModel.cancelClicked() + + hideKeyboard() + } + + binder.searchDappSearch.searchInput.requestFocus() + binder.searchDappSearch.searchInput.content.showSoftKeyboard() + } + + override fun inject() { + FeatureUtils.getFeature(this, DAppFeatureApi::class.java) + .dAppSearchComponentFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: DAppSearchViewModel) { + setupDAppNotInCatalogWarning() + binder.searchDappSearch.searchInput.content.bindTo(viewModel.query, lifecycleScope) + + viewModel.searchResults.observe(::submitListPreservingViewPoint) + + viewModel.selectQueryTextEvent.observeEvent { + binder.searchDappSearch.searchInput.content.selectAll() + } + + viewModel.categoriesFlow.observe { + binder.searchDappCategoriesShimmering.isVisible = it.isLoading() + binder.searchDappCategories.isVisible = it.isLoaded() + it.onLoaded { categoriesAdapter.submitList(it) } + } + } + + override fun itemClicked(searchResult: DappSearchResult) { + hideKeyboard() + + viewModel.searchResultClicked(searchResult) + } + + private fun hideKeyboard() { + binder.searchDappSearch.searchInput.hideSoftKeyboard() + } + + private fun submitListPreservingViewPoint(data: List) { + val recyclerViewState = binder.searchDappList.layoutManager!!.onSaveInstanceState() + + adapter.submitList(data) { + binder.searchDappList.layoutManager!!.onRestoreInstanceState(recyclerViewState) + } + } + + private fun setupDAppNotInCatalogWarning() { + viewModel.dAppNotInCatalogWarning.awaitableActionLiveData.observeEvent { event -> + warningDialog( + context = providedContext, + onPositiveClick = { event.onCancel() }, + positiveTextRes = R.string.common_close, + negativeTextRes = R.string.dapp_url_warning_open_anyway, + onNegativeClick = { event.onSuccess(Unit) }, + styleRes = R.style.AccentNegativeAlertDialogTheme + ) { + setTitle(R.string.dapp_url_warning_title) + + setMessage(requireContext().getString(R.string.dapp_url_warning_subtitle, event.payload.supportEmail)) + } + } + } + + override fun onCategoryClicked(id: String) { + viewModel.onCategoryClicked(id) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DappUnknownWarningModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DappUnknownWarningModel.kt new file mode 100644 index 0000000..0163f49 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/DappUnknownWarningModel.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.search + +class DappUnknownWarningModel( + val supportEmail: String +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/SearchDappAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/SearchDappAdapter.kt new file mode 100644 index 0000000..6add1b4 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/SearchDappAdapter.kt @@ -0,0 +1,127 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.search + +import android.view.ViewGroup +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.headers.TextHeader +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchResult +import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView +import io.novafoundation.nova.feature_dapp_impl.databinding.ItemDappSearchCategoryBinding +import io.novafoundation.nova.feature_dapp_impl.presentation.search.model.DappSearchModel + +class SearchDappAdapter( + private val handler: Handler +) : GroupedListAdapter(DiffCallback) { + + interface Handler { + + fun itemClicked(searchResult: DappSearchResult) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return CategoryHolder(ItemDappSearchCategoryBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return SearchHolder(DAppView.createUsingMathParentWidth(parent.context), handler) + } + + override fun bindGroup(holder: GroupedListHolder, group: TextHeader) { + (holder as CategoryHolder).bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: DappSearchModel) { + (holder as SearchHolder).bind(child) + } + + override fun bindChild(holder: GroupedListHolder, position: Int, child: DappSearchModel, payloads: List) { + resolvePayload(holder, position, payloads) { + (holder as SearchHolder).rebind(child) { + when (it) { + DappSearchModel::title -> bindTitle(child) + } + } + } + } +} + +private object DiffCallback : BaseGroupedDiffCallback(TextHeader::class.java) { + + override fun areGroupItemsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean { + return TextHeader.DIFF_CALLBACK.areItemsTheSame(oldItem, newItem) + } + + override fun areGroupContentsTheSame(oldItem: TextHeader, newItem: TextHeader): Boolean { + return TextHeader.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + } + + override fun areChildItemsTheSame(oldItem: DappSearchModel, newItem: DappSearchModel): Boolean { + return when { + isSingletonItem(oldItem) && isSingletonItem(newItem) -> true + else -> oldItem.title == newItem.title + } + } + + override fun areChildContentsTheSame(oldItem: DappSearchModel, newItem: DappSearchModel): Boolean { + return oldItem.title == newItem.title && oldItem.description == newItem.description && oldItem.icon == newItem.icon + } + + override fun getChildChangePayload(oldItem: DappSearchModel, newItem: DappSearchModel): Any? { + return SearchDappPayloadGenerator.diff(oldItem, newItem) + } + + private fun isSingletonItem(item: DappSearchModel) = when (item.searchResult) { + is DappSearchResult.Search -> true + is DappSearchResult.Url -> true + is DappSearchResult.Dapp -> false + } +} + +private object SearchDappPayloadGenerator : PayloadGenerator(DappSearchModel::title) + +private class CategoryHolder(private val binder: ItemDappSearchCategoryBinding) : GroupedListHolder(binder.root) { + + fun bind(item: TextHeader) { + binder.searchCategory.text = item.content + } +} + +private class SearchHolder( + private val dAppView: DAppView, + private val itemHandler: SearchDappAdapter.Handler +) : GroupedListHolder(dAppView) { + + override fun unbind() { + dAppView.clearIcon() + } + + fun bind(item: DappSearchModel) = with(dAppView) { + setIconUrl(item.icon) + + bindTitle(item) + setSubtitle(item.description) + showSubtitle(item.description != null) + + setActionResource(item.actionIcon) + + bindClick(item) + } + + fun bindTitle(item: DappSearchModel) { + dAppView.setTitle(item.title) + } + + fun rebind(item: DappSearchModel, action: SearchHolder.() -> Unit) = with(containerView) { + bindClick(item) + + action() + } + + private fun bindClick(item: DappSearchModel) = with(dAppView) { + setOnClickListener { itemHandler.itemClicked(item.searchResult) } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/SearchPayload.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/SearchPayload.kt new file mode 100644 index 0000000..73999d6 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/SearchPayload.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.search + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class SearchPayload( + val initialUrl: String?, + val request: Request, + val preselectedCategoryId: String? = null +) : Parcelable { + + enum class Request { + GO_TO_URL, + OPEN_NEW_URL, + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/di/DAppSearchComponent.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/di/DAppSearchComponent.kt new file mode 100644 index 0000000..8a31fb2 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/di/DAppSearchComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.search.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DappSearchFragment +import io.novafoundation.nova.feature_dapp_impl.presentation.search.SearchPayload + +@Subcomponent( + modules = [ + DAppSearchModule::class + ] +) +@ScreenScope +interface DAppSearchComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SearchPayload, + ): DAppSearchComponent + } + + fun inject(fragment: DappSearchFragment) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/di/DAppSearchModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/di/DAppSearchModule.kt new file mode 100644 index 0000000..3e9ebe8 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/di/DAppSearchModule.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.search.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.data.repository.FavouritesDAppRepository +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.search.SearchDappInteractor +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchCommunicator +import io.novafoundation.nova.feature_dapp_impl.presentation.search.DAppSearchViewModel +import io.novafoundation.nova.feature_dapp_impl.presentation.search.SearchPayload + +@Module(includes = [ViewModelModule::class]) +class DAppSearchModule { + + @Provides + @ScreenScope + fun provideInteractor( + dAppMetadataRepository: DAppMetadataRepository, + favouritesDAppRepository: FavouritesDAppRepository + ) = SearchDappInteractor(dAppMetadataRepository, favouritesDAppRepository) + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): DAppSearchViewModel { + return ViewModelProvider(fragment, factory).get(DAppSearchViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(DAppSearchViewModel::class) + fun provideViewModel( + router: DAppRouter, + resourceManager: ResourceManager, + interactor: SearchDappInteractor, + dappInteractor: DappInteractor, + searchResponder: DAppSearchCommunicator, + payload: SearchPayload, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + appLinksProvider: AppLinksProvider + ): ViewModel { + return DAppSearchViewModel( + router = router, + resourceManager = resourceManager, + interactor = interactor, + dAppSearchResponder = searchResponder, + payload = payload, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + appLinksProvider = appLinksProvider, + dappInteractor = dappInteractor + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/model/DappSearchModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/model/DappSearchModel.kt new file mode 100644 index 0000000..3cf5ce0 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/search/model/DappSearchModel.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.search.model + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.feature_dapp_impl.domain.search.DappSearchResult + +class DappSearchModel( + val title: String, + val description: String? = null, + val icon: String? = null, + @DrawableRes val actionIcon: Int?, + val searchResult: DappSearchResult +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabRvItem.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabRvItem.kt new file mode 100644 index 0000000..c31ca5e --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabRvItem.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.tab + +import io.novafoundation.nova.common.utils.images.Icon + +data class BrowserTabRvItem( + val tabId: String, + val tabName: String?, + val icon: Icon?, + val tabScreenshotPath: String?, +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabsAdapter.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabsAdapter.kt new file mode 100644 index 0000000..1a4e0f1 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabsAdapter.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.tab + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import coil.ImageLoader +import coil.clear +import coil.load +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.ImageMonitor +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIconOrMakeGone +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setPathOrStopWatching +import io.novafoundation.nova.feature_dapp_impl.databinding.ItemBrowserTabBinding +import java.io.File + +class BrowserTabsAdapter( + private val imageLoader: ImageLoader, + private val handler: Handler +) : ListAdapter(DiffCallback) { + + interface Handler { + + fun tabClicked(item: BrowserTabRvItem, view: View) + + fun tabCloseClicked(item: BrowserTabRvItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BrowserTabViewHolder { + return BrowserTabViewHolder(ItemBrowserTabBinding.inflate(parent.inflater(), parent, false), imageLoader, handler) + } + + override fun onBindViewHolder(holder: BrowserTabViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private object DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: BrowserTabRvItem, newItem: BrowserTabRvItem): Boolean { + return oldItem.tabId == newItem.tabId + } + + override fun areContentsTheSame(oldItem: BrowserTabRvItem, newItem: BrowserTabRvItem): Boolean { + return oldItem == newItem + } +} + +class BrowserTabViewHolder( + private val binder: ItemBrowserTabBinding, + private val imageLoader: ImageLoader, + private val itemHandler: BrowserTabsAdapter.Handler, +) : BaseViewHolder(binder.root) { + + private val screenshotImageMonitor = ImageMonitor( + imageView = binder.browserTabScreenshot, + imageLoader = imageLoader + ) + + private val tabIconImageMonitor = ImageMonitor( + imageView = binder.browserTabFavicon, + imageLoader = imageLoader + ) + + fun bind(item: BrowserTabRvItem) = with(binder) { + browserTabCard.setOnClickListener { itemHandler.tabClicked(item, browserTabScreenshot) } + browserTabClose.setOnClickListener { itemHandler.tabCloseClicked(item) } + browserTabSiteName.text = item.tabName + + browserTabScreenshot.load(item.tabScreenshotPath?.asFile(), imageLoader) + screenshotImageMonitor.setPathOrStopWatching(item.tabScreenshotPath) + + browserTabFavicon.setIconOrMakeGone(item.icon, imageLoader) + + if (item.icon is Icon.FromFile) { + tabIconImageMonitor.setPathOrStopWatching(item.icon.data.absolutePath) + } else { + tabIconImageMonitor.stopMonitoring() + } + } + + override fun unbind() { + screenshotImageMonitor.stopMonitoring() + tabIconImageMonitor.stopMonitoring() + + with(binder) { + browserTabScreenshot.clear() + browserTabFavicon.clear() + } + } + + private fun String.asFile() = File(this) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabsFragment.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabsFragment.kt new file mode 100644 index 0000000..abbc967 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabsFragment.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.tab + +import android.view.View +import androidx.navigation.fragment.FragmentNavigatorExtras +import androidx.recyclerview.widget.GridLayoutManager +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_dapp_impl.databinding.FragmentBrowserTabsBinding +import io.novafoundation.nova.feature_dapp_impl.di.DAppFeatureComponent +import io.novafoundation.nova.feature_dapp_impl.presentation.browser.main.DAPP_SHARED_ELEMENT_ID_IMAGE_TAB +import javax.inject.Inject + +class BrowserTabsFragment : BaseFragment(), BrowserTabsAdapter.Handler { + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + BrowserTabsAdapter(imageLoader, this) + } + + override fun createBinding() = FragmentBrowserTabsBinding.inflate(layoutInflater) + + override fun initViews() { + onBackPressed { viewModel.done() } + + binder.browserTabsList.layoutManager = GridLayoutManager(requireContext(), 2) + binder.browserTabsList.adapter = adapter + + binder.browserTabsCloseTabs.setOnClickListener { viewModel.closeAllTabs() } + binder.browserTabsAddTab.setOnClickListener { viewModel.addTab() } + binder.browserTabsDone.setOnClickListener { viewModel.done() } + } + + override fun inject() { + FeatureUtils.getFeature(this, DAppFeatureApi::class.java) + .browserTabsComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: BrowserTabsViewModel) { + setupCloseAllDappTabsDialogue(viewModel.closeAllTabsConfirmation) + + viewModel.tabsFlow.observe { + adapter.submitList(it) + binder.browserTabsList.scrollToPosition(it.size - 1) + } + } + + override fun tabClicked(item: BrowserTabRvItem, view: View) { + view.transitionName = DAPP_SHARED_ELEMENT_ID_IMAGE_TAB + + val extras = FragmentNavigatorExtras( + view to DAPP_SHARED_ELEMENT_ID_IMAGE_TAB + ) + viewModel.openTab(item, extras) + } + + override fun tabCloseClicked(item: BrowserTabRvItem) { + viewModel.closeTab(item.tabId) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabsViewModel.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabsViewModel.kt new file mode 100644 index 0000000..88cc6e2 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/BrowserTabsViewModel.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.tab + +import androidx.navigation.fragment.FragmentNavigator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.awaitAction +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.utils.images.asFileIcon +import io.novafoundation.nova.common.utils.images.asUrlIcon +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_api.presentation.browser.main.DAppBrowserPayload +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTab +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class BrowserTabsViewModel( + private val router: DAppRouter, + private val browserTabService: BrowserTabService, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val accountUseCase: SelectedAccountUseCase +) : BaseViewModel() { + + val closeAllTabsConfirmation = actionAwaitableMixinFactory.confirmingAction() + + val tabsFlow = browserTabService.tabStateFlow + .map { it.tabs } + .mapList { + mapBrowserTab(it) + }.shareInBackground() + + private fun mapBrowserTab(it: BrowserTab) = BrowserTabRvItem( + tabId = it.id, + tabName = it.pageSnapshot.pageName ?: Urls.domainOf(it.currentUrl), + icon = it.knownDAppMetadata?.iconLink?.asUrlIcon() ?: it.pageSnapshot.pageIconPath?.asFileIcon(), + tabScreenshotPath = it.pageSnapshot.pagePicturePath + ) + + fun openTab(tab: BrowserTabRvItem, extras: FragmentNavigator.Extras) = launch { + router.openDAppBrowser(DAppBrowserPayload.Tab(tab.tabId), extras) + } + + fun closeTab(tabId: String) = launch { + browserTabService.removeTab(tabId) + } + + fun closeAllTabs() = launch { + closeAllTabsConfirmation.awaitAction() + + val metaAccount = accountUseCase.getSelectedMetaAccount() + browserTabService.removeTabsForMetaAccount(metaAccount.id) + router.closeTabsScreen() + } + + fun addTab() { + router.openDappSearch() + } + + fun done() { + router.closeTabsScreen() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/Dialogs.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/Dialogs.kt new file mode 100644 index 0000000..70f125f --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/Dialogs.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.tab + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationAwaitable +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.feature_dapp_impl.R + +fun BaseFragment<*, *>.setupCloseAllDappTabsDialogue(mixin: ConfirmationAwaitable) { + mixin.awaitableActionLiveData.observeEvent { event -> + warningDialog( + context = providedContext, + onPositiveClick = { event.onSuccess(Unit) }, + positiveTextRes = R.string.browser_tabs_close_all, + negativeTextRes = R.string.common_cancel, + onNegativeClick = { event.onCancel() }, + styleRes = R.style.AccentNegativeAlertDialogTheme_Reversed + ) { + setTitle(R.string.close_dapp_tabs_title) + + setMessage(R.string.close_dapp_tabs_message) + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/di/BrowserTabsComponent.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/di/BrowserTabsComponent.kt new file mode 100644 index 0000000..890998d --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/di/BrowserTabsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.tab.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_dapp_impl.presentation.tab.BrowserTabsFragment + +@Subcomponent( + modules = [ + BrowserTabsModule::class + ] +) +@ScreenScope +interface BrowserTabsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): BrowserTabsComponent + } + + fun inject(fragment: BrowserTabsFragment) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/di/BrowserTabsModule.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/di/BrowserTabsModule.kt new file mode 100644 index 0000000..ef1bfa4 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/presentation/tab/di/BrowserTabsModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_dapp_impl.presentation.tab.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_dapp_impl.presentation.DAppRouter +import io.novafoundation.nova.feature_dapp_impl.presentation.tab.BrowserTabsViewModel +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.BrowserTabService + +@Module(includes = [ViewModelModule::class]) +class BrowserTabsModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): BrowserTabsViewModel { + return ViewModelProvider(fragment, factory).get(BrowserTabsViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(BrowserTabsViewModel::class) + fun provideViewModel( + router: DAppRouter, + browserTabService: BrowserTabService, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + accountUseCase: SelectedAccountUseCase + ): ViewModel { + return BrowserTabsViewModel( + router = router, + browserTabService = browserTabService, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + accountUseCase = accountUseCase + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckApi.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckApi.kt new file mode 100644 index 0000000..6415804 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckApi.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck + +import retrofit2.http.Body +import retrofit2.http.POST + +interface IntegrityCheckApi { + + @POST("challenges") + suspend fun getChallenge(): ChallengeResponse + + @POST("app-integrity/attestations") + suspend fun attest(@Body request: AttestRequest) +} + +class ChallengeResponse(val challenge: String) + +class AttestRequest( + val appIntegrityId: String, + val publicKey: String, + val integrityToken: String, + val challenge: String, + val deviceIdHash: String +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckJSBridge.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckJSBridge.kt new file mode 100644 index 0000000..87a68c3 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckJSBridge.kt @@ -0,0 +1,118 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck + +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.launchUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import retrofit2.HttpException + +class IntegrityCheckJSBridgeFactory( + private val integrityCheckSessionFactory: IntegrityCheckSessionFactory +) { + + fun create(webView: WebView): IntegrityCheckJSBridge { + return IntegrityCheckJSBridge( + integrityCheckSessionFactory, + webView + ) + } +} + +private const val JAVASCRIPT_INTERFACE_NAME = "IntegrityProvider" +private const val APP_INTEGRITY_ID_NOT_FOUND_CODE = 400 + +class IntegrityCheckJSBridge( + private val integrityCheckSessionFactory: IntegrityCheckSessionFactory, + private val webView: WebView +) : IntegrityCheckSession.Callback, CoroutineScope by CoroutineScope(Dispatchers.Main) { + + val errorFlow = MutableSharedFlow() + + private var session: IntegrityCheckSession? = null + private val providerJsCallback = IntegrityProviderJsCallback() + + init { + webView.addJavascriptInterface(providerJsCallback, JAVASCRIPT_INTERFACE_NAME) + } + + fun onRequestIntegrityCheck(baseUrl: String) = launchUnit { + log("Android client: request integrity check. BaseUrl: $baseUrl") + session = integrityCheckSessionFactory.createSession(baseUrl, this@IntegrityCheckJSBridge) + runCatching { session?.startIntegrityCheck() } + .onFailure { onVerificationFailedOnClient(it) } + } + + fun onDAppSignatureVerificationError(code: Int, error: String) = launchUnit { + if (session == null) return@launchUnit + + log("Android client: onSignatureVerificationError: $error") + + if (code == APP_INTEGRITY_ID_NOT_FOUND_CODE) { + log("Android client: restart integrity check") + runCatching { session?.restartIntegrityCheck() } + .onFailure { onVerificationFailedOnClient(it) } + } else { + errorFlow.emit(error) + } + } + + fun onPageFinished() { + session?.let { log("clear integrity check session") } + session = null + } + + fun onDestroy() { + cancel() // Cancel coroutine scope + } + + override fun sendVerificationRequest(challenge: String, appIntegrityId: String, signature: String) { + val jsCode = """ + window.verifySignature({ + challenge: "$challenge", + appIntegrityId: "$appIntegrityId", + signature: "$signature", + platform: "ANDROID" + }); + """.trimIndent() + + webView.evaluateJavascript(jsCode, null) + } + + private suspend fun onVerificationFailedOnClient(exception: Throwable) { + log("Android client: error: ${exception.message}") + exception.message?.let { errorFlow.emit(it) } + + if (exception is HttpException) { + val jsCode = """ + window.verificationFailedOnClient({ + error: ${exception.code()}, + message: ${exception.message()} + }); + """.trimIndent() + + webView.evaluateJavascript(jsCode, null) + } + } + + private fun log(message: String) = launchUnit(Dispatchers.Main) { + Log.e(LOG_TAG, message) + webView.evaluateJavascript("console.log('$message')", null) + } + + inner class IntegrityProviderJsCallback { + @JavascriptInterface + fun requestIntegrityCheck(baseUrl: String) { + onRequestIntegrityCheck(baseUrl) + } + + @JavascriptInterface + fun signatureVerificationError(code: Int, error: String) { + onDAppSignatureVerificationError(code, error) + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckKeyPairService.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckKeyPairService.kt new file mode 100644 index 0000000..072cd2c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckKeyPairService.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.security.spec.X509EncodedKeySpec + +object IntegrityCheckKeyPairService { + + fun isKeyPairGenerated(alias: String): Boolean { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + return keyStore.containsAlias(alias) + } + + fun generateKeyPair(alias: String) { + val keyPairGenerator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + "AndroidKeyStore" + ) + + val parameterSpec = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY + ).run { + setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + setUserAuthenticationRequired(false) + build() + } + + keyPairGenerator.initialize(parameterSpec) + + keyPairGenerator.generateKeyPair() + } + + fun getPublicKey(alias: String): ByteArray { + ensureKeyPairGenerated(alias) + + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + return keyStore.getCertificate(alias).publicKey.encoded + } + + fun signData(alias: String, data: ByteArray): ByteArray { + ensureKeyPairGenerated(alias) + + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val privateKey = keyStore.getKey(alias, null) as PrivateKey + + val signature = Signature.getInstance("SHA256withECDSA") + signature.initSign(privateKey) + signature.update(data) + + return signature.sign() + } + + fun verifySignature(alias: String, data: ByteArray, signatureBytes: ByteArray): Boolean { + ensureKeyPairGenerated(alias) + + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val publicKey = keyStore.getCertificate(alias).publicKey + + val signature = Signature.getInstance("SHA256withECDSA") + signature.initVerify(publicKey) + signature.update(data) + + return signature.verify(signatureBytes) + } + + fun verifySignature(publicKeyBytes: ByteArray, data: ByteArray, signatureBytes: ByteArray): Boolean { + val keyFactory = KeyFactory.getInstance("EC") + val publicKeySpec = X509EncodedKeySpec(publicKeyBytes) + val publicKey = keyFactory.generatePublic(publicKeySpec) + + val signature = Signature.getInstance("SHA256withECDSA") + signature.initVerify(publicKey) + signature.update(data) + + return signature.verify(signatureBytes) + } + + private fun ensureKeyPairGenerated(alias: String) { + if (!isKeyPairGenerated(alias)) { + generateKeyPair(alias) + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckSession.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckSession.kt new file mode 100644 index 0000000..1270859 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/integrityCheck/IntegrityCheckSession.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck + +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.IntegrityService +import io.novafoundation.nova.common.utils.ensureSuffix +import io.novafoundation.nova.common.utils.sha256 +import io.novafoundation.nova.common.utils.toBase64 +import io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck.IntegrityCheckSession.Callback +import java.util.UUID + +class IntegrityCheckSessionFactory( + private val apiCreator: NetworkApiCreator, + private val preferences: Preferences, + private val integrityService: IntegrityService, + private val deviceIdProvider: DeviceIdProvider +) { + + fun createSession( + baseUrl: String, + callback: Callback + ) = IntegrityCheckSession( + baseUrl, + apiCreator.create(IntegrityCheckApi::class.java, baseUrl.ensureSuffix("/")), + preferences, + integrityService, + deviceIdProvider, + callback + ) +} + +private const val PREFS_APP_INTEGRITY_ID = "PREFS_APP_INTEGRITY_ID" +private const val PREFS_ATTESTATION_PASSED = "PREFS_ATTESTATION_PASSED" + +class IntegrityCheckSession( + private val baseUrl: String, + private val integrityCheckApi: IntegrityCheckApi, + private val preferences: Preferences, + private val integrityService: IntegrityService, + private val deviceIdProvider: DeviceIdProvider, + private val callback: Callback +) { + + interface Callback { + fun sendVerificationRequest(challenge: String, appIntegrityId: String, signature: String) + } + + suspend fun startIntegrityCheck() { + if (!isAttestationPassed()) { + runAttestation() + } + + runVerifying() + } + + suspend fun restartIntegrityCheck() { + runAttestation() + + runVerifying() + } + + private suspend fun runAttestation() { + val challengeResponse = integrityCheckApi.getChallenge() + val appIntegrityId = getAppIntegrityId() + val deviceIdHash = deviceIdProvider.getDeviceId().toByteArray().sha256().toString() + val publicKey = IntegrityCheckKeyPairService.getPublicKey(appIntegrityId).toBase64() + + val requestHash = createRequestHash(challengeResponse.challenge + appIntegrityId + publicKey + deviceIdHash) + val integrityToken = integrityService.getIntegrityToken(requestHash = requestHash.toBase64()) + + integrityCheckApi.attest( + AttestRequest( + appIntegrityId = appIntegrityId, + publicKey = publicKey, + integrityToken = integrityToken, + challenge = challengeResponse.challenge, + deviceIdHash = deviceIdHash + ) + ) + + setAttestationPassed() + } + + private suspend fun runVerifying() { + val challengeResponse = integrityCheckApi.getChallenge() + val appIntegrityId = getAppIntegrityId() + val requestHash = createRequestHash(challengeResponse.challenge + appIntegrityId) + val signature = IntegrityCheckKeyPairService.signData(appIntegrityId, requestHash) + + callback.sendVerificationRequest( + appIntegrityId = appIntegrityId, + challenge = challengeResponse.challenge, + signature = signature.toBase64() + ) + } + + private fun setAttestationPassed() { + preferences.putBoolean(getKey(), true) + } + + private fun isAttestationPassed(): Boolean { + return preferences.getBoolean(getKey(), false) + } + + private fun getKey() = "$PREFS_ATTESTATION_PASSED:$baseUrl" + + private fun createRequestHash(value: String): ByteArray { + return value.toByteArray() + .sha256() + } + + private fun getAppIntegrityId(): String { + var id = preferences.getString(PREFS_APP_INTEGRITY_ID) + if (id == null) { + id = UUID.randomUUID().toString() + preferences.putString(PREFS_APP_INTEGRITY_ID, id) + } + + return id + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/BrowserTabService.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/BrowserTabService.kt new file mode 100644 index 0000000..471e11e --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/BrowserTabService.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs + +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTab +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.CurrentTabState +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.TabsState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +interface BrowserTabService { + + val tabStateFlow: Flow + + fun selectTab(tabId: String?) + + fun detachCurrentSession() + + fun makeCurrentTabSnapshot() + + suspend fun createNewTab(url: String): BrowserTab + + suspend fun removeTab(tabId: String) + + suspend fun removeTabsForMetaAccount(metaId: Long) +} + +suspend fun BrowserTabService.createAndSelectTab(url: String) { + val tab = createNewTab(url) + selectTab(tab.id) +} + +suspend fun BrowserTabService.hasSelectedTab(): Boolean { + return tabStateFlow.first().selectedTab is CurrentTabState.Selected +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/RealBrowserTabService.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/RealBrowserTabService.kt new file mode 100644 index 0000000..aed2647 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/RealBrowserTabService.kt @@ -0,0 +1,183 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs + +import io.novafoundation.nova.common.utils.CallbackLruCache +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_dapp_api.data.repository.getDAppIfSyncedOrSync +import io.novafoundation.nova.feature_dapp_impl.data.repository.tabs.BrowserTabInternalRepository +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTab +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.CurrentTabState +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTabSession +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTabSessionFactory +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.OnPageChangedCallback +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.PageSnapshot +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.TabsState +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.fromName +import java.util.Date +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class RealBrowserTabService( + private val dAppMetadataRepository: DAppMetadataRepository, + private val browserTabInternalRepository: BrowserTabInternalRepository, + private val accountRepository: AccountRepository, + private val pageSnapshotBuilder: PageSnapshotBuilder, + private val tabMemoryRestrictionService: TabMemoryRestrictionService, + private val browserTabSessionFactory: BrowserTabSessionFactory, + private val rootScope: RootScope +) : BrowserTabService, CoroutineScope by rootScope, OnPageChangedCallback { + + private val availableSessionsCount = tabMemoryRestrictionService.getMaximumActiveSessions() + + private val selectedTabIdFlow = MutableStateFlow(null) + + private val allTabsFlow = accountRepository.selectedMetaAccountFlow() + .flatMapLatest { browserTabInternalRepository.observeTabs(it.id) } + .map { tabs -> tabs.associateBy { it.id } } + + private val activeSessions = CallbackLruCache(availableSessionsCount) + + override val tabStateFlow = combine( + selectedTabIdFlow, + allTabsFlow + ) { selectedTabId, allTabs -> + TabsState( + tabs = allTabs.values.toList(), + selectedTab = currentTabState(selectedTabId, allTabs) + ) + }.shareInBackground() + + init { + activeSessions.setOnEntryRemovedCallback { + launch(Dispatchers.Main) { + it.detachFromHost() + it.destroy() + } + } + } + + override fun selectTab(tabId: String?) { + val oldTabId = selectedTabIdFlow.value + detachSession(oldTabId) + + selectedTabIdFlow.value = tabId + } + + override fun detachCurrentSession() { + selectTab(null) + } + + // Create a new browser tab and save it to persistent storage + // Then we sync dapp metadata for this tab asynchronously to not block tab creation + override suspend fun createNewTab(url: String): BrowserTab { + val tab = BrowserTab( + id = UUID.randomUUID().toString(), + metaId = accountRepository.getSelectedMetaAccount().id, + pageSnapshot = PageSnapshot.fromName(Urls.domainOf(url)), + currentUrl = url, + knownDAppMetadata = null, + creationTime = Date() + ) + + browserTabInternalRepository.saveTab(tab) + + deferredSyncKnownDAppMetadata(tab.id, url) + + return tab + } + + override suspend fun removeTab(tabId: String) { + if (tabId == selectedTabIdFlow.value) { + selectTab(null) + } + + browserTabInternalRepository.removeTab(tabId) + activeSessions.remove(tabId) + } + + override suspend fun removeTabsForMetaAccount(metaId: Long) { + selectTab(null) + + val removedTabs = browserTabInternalRepository.removeTabsForMetaAccount(metaId) + removedTabs.forEach { + activeSessions.remove(it) + } + } + + override fun makeCurrentTabSnapshot() { + val currentTab = selectedTabIdFlow.value + + currentTab?.let { makeTabSnapshot(currentTab) } + } + + private suspend fun addNewSession(tab: BrowserTab): BrowserTabSession { + val session = browserTabSessionFactory.create(tabId = tab.id, startUrl = tab.currentUrl, onPageChangedCallback = this) + activeSessions.put(tab.id, session) + return session + } + + private fun detachSession(tabId: String?) { + if (tabId == null) return + + val sessionToDetach = activeSessions[tabId] + sessionToDetach?.detachFromHost() + } + + private suspend fun currentTabState(selectedTabId: String?, allTabs: Map): CurrentTabState { + val tabId = selectedTabId ?: return CurrentTabState.NotSelected + val tab = allTabs[tabId] ?: return CurrentTabState.NotSelected + return CurrentTabState.Selected(tab, getOrCreateSession(tab)) + } + + private suspend fun getOrCreateSession(tab: BrowserTab): BrowserTabSession { + return activeSessions[tab.id] ?: addNewSession(tab) + } + + /* + We should update page title and url each time page is changed to have correct tab state in persistent storage + */ + override fun onPageChanged(tabId: String, url: String?, title: String?) { + if (url == null) return + + launch { + activeSessions[tabId].currentUrl = url + browserTabInternalRepository.changeCurrentUrl(tabId, url) + } + + deferredSyncKnownDAppMetadata(tabId, url) + } + + private fun makeTabSnapshot(tabId: String) { + val pageSession = activeSessions.get(tabId) + + if (pageSession != null) { + val snapshot = pageSnapshotBuilder.getPageSnapshot(pageSession) + + launch(Dispatchers.Default) { + browserTabInternalRepository.savePageSnapshot(pageSession.tabId, snapshot) + } + } + } + + private fun deferredSyncKnownDAppMetadata(tabId: String, url: String) { + launch { + val knownDAppMetadata = getKnownDappMetadata(url) + browserTabInternalRepository.changeKnownDAppMetadata(tabId, knownDAppMetadata?.iconLink) + } + } + + private suspend fun getKnownDappMetadata(url: String): BrowserTab.KnownDAppMetadata? { + return dAppMetadataRepository.getDAppIfSyncedOrSync(Urls.normalizeUrl(url))?.let { + BrowserTab.KnownDAppMetadata(it.iconLink) + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/RealPageSnapshotBuilder.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/RealPageSnapshotBuilder.kt new file mode 100644 index 0000000..bcedada --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/RealPageSnapshotBuilder.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs + +import android.graphics.Bitmap +import androidx.core.view.drawToBitmap +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.nullIfBlank +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.BrowserTabSession +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.PageSnapshot +import io.novafoundation.nova.feature_dapp_impl.utils.tabs.models.fromName +import java.io.FileOutputStream +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +interface PageSnapshotBuilder { + + fun getPageSnapshot(browserTabSession: BrowserTabSession): PageSnapshot +} + +class RealPageSnapshotBuilder( + private val fileProvider: FileProvider, + private val rootScope: RootScope +) : PageSnapshotBuilder { + + override fun getPageSnapshot(browserTabSession: BrowserTabSession): PageSnapshot { + val webView = browserTabSession.webView + if (!webView.isLaidOut) { + return PageSnapshot.fromName(Urls.domainOf(browserTabSession.currentUrl)) + } + + val pageName = webView.title.nullIfBlank() ?: Urls.domainOf(browserTabSession.currentUrl) + val icon = webView.favicon + val pageBitmap = webView.drawToBitmap() + + val pageIconPath = saveBitmap(browserTabSession, icon, "icon", 100) + val pagePicturePath = saveBitmap(browserTabSession, pageBitmap, "page", 40) + + return PageSnapshot( + pageName = pageName, + pageIconPath = pageIconPath, + pagePicturePath = pagePicturePath + ) + } + + private fun saveBitmap(browserTabSession: BrowserTabSession, bitmap: Bitmap?, filePrefix: String, quality: Int): String? { + if (bitmap == null) return null + + // Use this pattern to don't create a new image everytime when we rewrite the page snapshot + val fileName = "tab_${browserTabSession.tabId}_$filePrefix.jpeg" + val file = fileProvider.getFileInExternalCacheStorage(fileName) + + try { + rootScope.launch(Dispatchers.IO) { + val outputStream = FileOutputStream(file) + bitmap.compress(Bitmap.CompressFormat.PNG, quality, outputStream) + outputStream.close() + } + + return file.absolutePath + } catch (e: IOException) { + return null + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/TabMemoryRestrictionService.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/TabMemoryRestrictionService.kt new file mode 100644 index 0000000..bbbf637 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/TabMemoryRestrictionService.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs + +import android.app.ActivityManager +import android.content.Context +import io.novafoundation.nova.common.utils.InformationSize.Companion.megabytes + +private const val MIN_TABS = 3 +private val MEMORY_STEP = 100.megabytes.inWholeBytes + +interface TabMemoryRestrictionService { + fun getMaximumActiveSessions(): Int +} + +class RealTabMemoryRestrictionService(val context: Context) : TabMemoryRestrictionService { + + // The linear function that starts from 3 and adds 1 tab each MEMORY_STEP of available memory + override fun getMaximumActiveSessions(): Int { + val availableMemory = getAvailableMemory() + return MIN_TABS + (availableMemory / MEMORY_STEP).toInt() + } + + private fun getAvailableMemory(): Long { + val activityManager = context.getSystemService(ActivityManager::class.java) + val memoryInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memoryInfo) + return memoryInfo.availMem + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/BrowserTab.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/BrowserTab.kt new file mode 100644 index 0000000..36c0555 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/BrowserTab.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs.models + +import java.util.Date + +class BrowserTab( + val id: String, + val metaId: Long, + val pageSnapshot: PageSnapshot, + val knownDAppMetadata: KnownDAppMetadata?, + val currentUrl: String, + val creationTime: Date +) { + + class KnownDAppMetadata(val iconLink: String) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/BrowserTabSession.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/BrowserTabSession.kt new file mode 100644 index 0000000..3212bbd --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/BrowserTabSession.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs.models + +import android.graphics.Bitmap +import android.webkit.WebResourceRequest +import android.webkit.WebView +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck.IntegrityCheckJSBridge +import io.novafoundation.nova.feature_dapp_impl.utils.integrityCheck.IntegrityCheckJSBridgeFactory +import io.novafoundation.nova.feature_dapp_impl.web3.webview.PageCallback +import io.novafoundation.nova.feature_dapp_impl.web3.webview.Web3ChromeClient +import io.novafoundation.nova.feature_dapp_impl.web3.webview.CompoundWeb3Injector +import io.novafoundation.nova.feature_dapp_impl.web3.webview.Web3WebViewClient +import io.novafoundation.nova.feature_dapp_impl.web3.webview.injectWeb3 +import io.novafoundation.nova.feature_dapp_impl.web3.webview.uninjectWeb3 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext + +class BrowserTabSessionFactory( + private val compoundWeb3Injector: CompoundWeb3Injector, + private val contextManager: ContextManager, + private val integrityCheckJSBridgeFactory: IntegrityCheckJSBridgeFactory +) { + + suspend fun create(tabId: String, startUrl: String, onPageChangedCallback: OnPageChangedCallback): BrowserTabSession { + return withContext(Dispatchers.Main) { + val context = contextManager.getActivity()!! + val webView = WebView(context) + val integrityCheckProvider = integrityCheckJSBridgeFactory.create(webView) + + BrowserTabSession( + tabId = tabId, + startUrl = startUrl, + webView = webView, + integrityCheckJSBridge = integrityCheckProvider, + compoundWeb3Injector = compoundWeb3Injector, + onPageChangedCallback = onPageChangedCallback + ) + } + } +} + +class BrowserTabSession( + val tabId: String, + val startUrl: String, + val webView: WebView, + compoundWeb3Injector: CompoundWeb3Injector, + private val onPageChangedCallback: OnPageChangedCallback, + private val integrityCheckJSBridge: IntegrityCheckJSBridge +) : PageCallback, CoroutineScope by CoroutineScope(Dispatchers.Main) { + + val webViewClient: Web3WebViewClient = Web3WebViewClient( + webView = webView, + pageCallback = this + ) + + var currentUrl: String = startUrl + + private var sessionCallback: SessionCallback? = null + + init { + webView.injectWeb3(webViewClient) + webView.loadUrl(startUrl) + compoundWeb3Injector.initialInject(webView) + + integrityCheckJSBridge.errorFlow + .onEach { sessionCallback?.onPageError(it) } + .launchIn(this) + } + + fun attachToHost( + chromeClient: Web3ChromeClient, + pageCallback: SessionCallback + ) { + webView.webChromeClient = chromeClient + this.sessionCallback = pageCallback + + // To provide initial state + pageCallback.onPageChanged(webView, webView.url, webView.title) + } + + fun detachFromHost() { + sessionCallback = null + webView.webChromeClient = null + } + + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + sessionCallback?.onPageStarted(view, url, favicon) + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return sessionCallback?.shouldOverrideUrlLoading(view, request) ?: false + } + + override fun onPageChanged(view: WebView, url: String?, title: String?) { + sessionCallback?.onPageChanged(view, url, title) + onPageChangedCallback.onPageChanged(tabId, url, title) + } + + override fun onPageFinished(view: WebView, url: String?) { + integrityCheckJSBridge.onPageFinished() + } + + fun destroy() { + cancel() // Cancel coroutine scope + integrityCheckJSBridge.onDestroy() + webView.uninjectWeb3() + webView.destroy() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/CurrentTabState.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/CurrentTabState.kt new file mode 100644 index 0000000..caf935b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/CurrentTabState.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs.models + +sealed interface CurrentTabState { + + object NotSelected : CurrentTabState + + data class Selected(val tab: BrowserTab, val browserTabSession: BrowserTabSession) : CurrentTabState +} + +fun CurrentTabState.stateId() = when (this) { + CurrentTabState.NotSelected -> null + is CurrentTabState.Selected -> tab.id +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/OnPageChangedCallback.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/OnPageChangedCallback.kt new file mode 100644 index 0000000..44860b2 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/OnPageChangedCallback.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs.models + +interface OnPageChangedCallback { + + fun onPageChanged(tabId: String, url: String?, title: String?) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/PageSnapshot.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/PageSnapshot.kt new file mode 100644 index 0000000..5bf765a --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/PageSnapshot.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs.models + +class PageSnapshot( + val pageName: String?, + val pageIconPath: String?, + val pagePicturePath: String? +) { + + companion object; +} + +fun PageSnapshot.Companion.fromName(name: String) = PageSnapshot( + pageName = name, + pageIconPath = null, + pagePicturePath = null +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/SessionCallback.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/SessionCallback.kt new file mode 100644 index 0000000..11639c0 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/SessionCallback.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs.models + +import android.graphics.Bitmap +import android.webkit.WebResourceRequest +import android.webkit.WebView + +interface SessionCallback { + + fun onPageStarted(webView: WebView, url: String, favicon: Bitmap?) + + fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean + + fun onPageChanged(webView: WebView, url: String?, title: String?) + + fun onPageError(error: String) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/TabsState.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/TabsState.kt new file mode 100644 index 0000000..ec7e8a7 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/utils/tabs/models/TabsState.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_dapp_impl.utils.tabs.models + +class TabsState( + val tabs: List, + val selectedTab: CurrentTabState +) { + + fun stateId(): String { + return tabs.joinToString { it.id } + "_" + selectedTab.stateId() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/Web3Responder.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/Web3Responder.kt new file mode 100644 index 0000000..99da69a --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/Web3Responder.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_dapp_impl.web3 + +interface Web3Responder { + + fun respondResult(id: String, result: String) + + fun respondSubscription(id: String, result: String) + + fun respondError(id: String, error: Throwable) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/Web3Transport.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/Web3Transport.kt new file mode 100644 index 0000000..e28d99e --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/Web3Transport.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_dapp_impl.web3 + +import kotlinx.coroutines.flow.Flow + +interface Web3Transport> { + + val requestsFlow: Flow + + interface Request { + + fun accept(response: RESPONSE) + + fun reject(error: Throwable) + } +} + +fun Web3Transport.Request.accept() = accept(Unit) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/di/Metamask.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/di/Metamask.kt new file mode 100644 index 0000000..4bad7ed --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/di/Metamask.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class Metamask diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/EthereumAddress.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/EthereumAddress.kt new file mode 100644 index 0000000..558153b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/EthereumAddress.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.model + +typealias EthereumAddress = String diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/MetamaskChain.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/MetamaskChain.kt new file mode 100644 index 0000000..95e5801 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/MetamaskChain.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.model + +import android.os.Parcelable +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.runtime.BuildConfig +import kotlinx.parcelize.Parcelize + +@Parcelize +data class MetamaskChain( + val chainId: String, + val chainName: String, + val nativeCurrency: NativeCurrency, + val rpcUrls: List, + val blockExplorerUrls: List?, + val iconUrls: List? +) : Parcelable { + + companion object { + + val ETHEREUM = MetamaskChain( + chainId = "0x1", + chainName = "Ethereum Mainnet", + nativeCurrency = NativeCurrency(name = "Ether", symbol = "ETH", decimals = 18), + rpcUrls = listOf("https://mainnet.infura.io/v3/${BuildConfig.INFURA_API_KEY}"), + blockExplorerUrls = null, + iconUrls = null + ) + } + + @Parcelize + class NativeCurrency( + val name: String, + val symbol: String, + val decimals: Int + ) : Parcelable +} + +fun MetamaskChain.chainIdInt(): Int = chainId.removeHexPrefix().toInt(16) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/MetamaskTransaction.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/MetamaskTransaction.kt new file mode 100644 index 0000000..e8d8586 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/MetamaskTransaction.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.model + +class MetamaskTransaction( + val gas: String?, + val gasPrice: String?, + val from: String, + val to: String, + val data: String?, + val value: String?, + val nonce: String?, +) + +class MetamaskTypedMessage( + val data: String, + val raw: String? +) + +class MetamaskPersonalSignMessage( + val data: String +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/Signing.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/Signing.kt new file mode 100644 index 0000000..a21b01b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/Signing.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.model + +typealias SignedMessage = String diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/SwitchChainRequest.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/SwitchChainRequest.kt new file mode 100644 index 0000000..da00ef8 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/model/SwitchChainRequest.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.model + +class SwitchChainRequest( + val chainId: String +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/DefaultMetamaskState.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/DefaultMetamaskState.kt new file mode 100644 index 0000000..616475d --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/DefaultMetamaskState.kt @@ -0,0 +1,299 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.states + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.asPrecision +import io.novafoundation.nova.common.utils.asTokenSymbol +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.browser.metamask.MetamaskInteractor +import io.novafoundation.nova.feature_dapp_impl.web3.accept +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskPersonalSignMessage +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskTransaction +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskTypedMessage +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.chainIdInt +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskError +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskTransportRequest +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session.Authorization +import io.novafoundation.nova.feature_dapp_impl.web3.states.BaseState +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.ExternalEvent +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.StateMachineTransition +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3StateMachineHost +import io.novafoundation.nova.feature_dapp_impl.web3.states.hostApi.ConfirmTxResponse +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChain +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource.UnknownChainOptions +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmPersonalSignMessage +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTransaction +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage +import io.novasama.substrate_sdk_android.extensions.asEthereumAddress +import io.novasama.substrate_sdk_android.extensions.toAccountId + +class DefaultMetamaskState( + commonInteractor: DappInteractor, + resourceManager: ResourceManager, + addressIconGenerator: AddressIconGenerator, + web3Session: Web3Session, + hostApi: Web3StateMachineHost, + walletUiUseCase: WalletUiUseCase, + private val stateFactory: MetamaskStateFactory, + private val interactor: MetamaskInteractor, + override val chain: MetamaskChain, + override val selectedAccountAddress: String? +) : BaseState, MetamaskState>( + commonInteractor = commonInteractor, + resourceManager = resourceManager, + addressIconGenerator = addressIconGenerator, + web3Session = web3Session, + hostApi = hostApi, + walletUiUseCase = walletUiUseCase +), + MetamaskState { + + private val knownChains = mapOf(MetamaskChain.ETHEREUM.chainId to MetamaskChain.ETHEREUM) + + override suspend fun acceptRequest(request: MetamaskTransportRequest<*>, transition: StateMachineTransition) { + when (request) { + is MetamaskTransportRequest.RequestAccounts -> handleRequestAccounts(request, transition) + is MetamaskTransportRequest.AddEthereumChain -> handleAddEthereumChain(request, transition) + is MetamaskTransportRequest.SwitchEthereumChain -> handleSwitchEthereumChain(request, transition) + is MetamaskTransportRequest.SendTransaction -> handleOperation(request, ::sendTransactionWithConfirmation) + is MetamaskTransportRequest.SignTypedMessage -> handleOperation(request, ::signTypedMessageWithConfirmation) + is MetamaskTransportRequest.PersonalSign -> handleOperation(request, ::signPersonalSignWithConfirmation) + } + } + + override suspend fun acceptEvent(event: ExternalEvent, transition: StateMachineTransition) { + when (event) { + ExternalEvent.PhishingDetected -> transition.emitState(PhishingDetectedMetamaskState(chain)) + } + } + + private suspend fun handleSwitchEthereumChain( + request: MetamaskTransportRequest.SwitchEthereumChain, + transition: StateMachineTransition + ) = respondIfAllowed( + ifAllowed = { + // already on this chain + if (request.chainId == chain.chainId) { + request.accept() + return@respondIfAllowed + } + + val knownChain = knownChains[request.chainId] + + if (knownChain != null) { + interactor.setDefaultMetamaskChain(knownChain) + val nextState = stateFactory.default(hostApi, knownChain, selectedAccountAddress) + transition.emitState(nextState) + + request.accept() + + hostApi.reloadPage() + } else { + request.reject(MetamaskError.SwitchChainNotFound(request.chainId)) + } + }, + ifDenied = { + request.reject(MetamaskError.Rejected()) + } + ) + + private suspend fun signPersonalSignWithConfirmation( + request: MetamaskTransportRequest.PersonalSign, + selectedAddress: String + ) { + val hostApiConfirmRequest = ExternalSignRequest.Evm( + id = request.id, + payload = EvmSignPayload.PersonalSign( + message = mapMetamaskPersonalSignMessageToEvm(request.message), + originAddress = selectedAddress + ) + ) + + confirmOperation(request, hostApiConfirmRequest) + } + + private suspend fun signTypedMessageWithConfirmation( + request: MetamaskTransportRequest.SignTypedMessage, + selectedAddress: String + ) { + val hostApiConfirmRequest = ExternalSignRequest.Evm( + id = request.id, + payload = EvmSignPayload.SignTypedMessage( + message = mapMetamaskTypedMessageToEvm(request.message), + originAddress = selectedAddress + ) + ) + + confirmOperation(request, hostApiConfirmRequest) + } + + private suspend fun sendTransactionWithConfirmation( + request: MetamaskTransportRequest.SendTransaction, + selectedAddress: String, + ) { + val selectedAccountId = selectedAddress.asEthereumAddress().toAccountId().value + val txOriginAccountId = request.transaction.from.asEthereumAddress().toAccountId().value + + if (!selectedAccountId.contentEquals(txOriginAccountId)) { + request.reject(MetamaskError.AccountsMismatch()) + return + } + + val hostApiConfirmRequest = ExternalSignRequest.Evm( + id = request.id, + payload = EvmSignPayload.ConfirmTx( + transaction = mapMetamaskTransactionToEvm(request.transaction), + chainSource = EvmChainSource( + evmChainId = chain.chainIdInt(), + unknownChainOptions = UnknownChainOptions.WithFallBack(mapMetamaskChainToEvmChain(chain)) + ), + originAddress = selectedAddress, + action = EvmSignPayload.ConfirmTx.Action.SEND, + ) + ) + + confirmOperation(request, hostApiConfirmRequest) + } + + private suspend fun > handleOperation( + request: T, + onAllowed: suspend (request: T, originAddress: String) -> Unit + ) { + val authorizationState = getAuthorizationStateForCurrentPage() + + if (authorizationState == Authorization.State.ALLOWED && selectedAccountAddress != null) { + // request user confirmation if dapp is authorized + onAllowed(request, selectedAccountAddress) + } else { + // reject otherwise + request.reject(MetamaskError.Rejected()) + } + } + + private suspend fun confirmOperation( + metamaskRequest: MetamaskTransportRequest, + hostApiConfirmRequest: ExternalSignRequest.Evm + ) { + when (val response = hostApi.confirmTx(hostApiConfirmRequest)) { + is ConfirmTxResponse.Rejected -> metamaskRequest.reject(MetamaskError.Rejected()) + is ConfirmTxResponse.Signed -> metamaskRequest.accept(response.signature) + is ConfirmTxResponse.Sent -> metamaskRequest.accept(response.txHash) + is ConfirmTxResponse.SigningFailed -> { + if (response.shouldPresent) hostApi.showError(resourceManager.getString(R.string.dapp_sign_extrinsic_failed)) + + metamaskRequest.reject(MetamaskError.TxSendingFailed()) + } + + is ConfirmTxResponse.ChainIsDisabled -> { + hostApi.showError( + resourceManager.getString(R.string.disabled_chain_error_title, response.chainName), + resourceManager.getString(R.string.disabled_chain_error_message, response.chainName) + ) + + metamaskRequest.reject(MetamaskError.TxSendingFailed()) + } + } + } + + private suspend fun handleAddEthereumChain( + request: MetamaskTransportRequest.AddEthereumChain, + transition: StateMachineTransition, + ) = respondIfAllowed( + ifAllowed = { + if (chain.chainId == request.chain.chainId) { + request.accept() + } else { + interactor.setDefaultMetamaskChain(request.chain) + val nextState = stateFactory.default(hostApi, request.chain, selectedAccountAddress) + transition.emitState(nextState) + + request.accept() + + hostApi.reloadPage() + } + }, + ifDenied = { + request.reject(MetamaskError.Rejected()) + } + ) + + private suspend fun handleRequestAccounts( + request: MetamaskTransportRequest.RequestAccounts, + transition: StateMachineTransition, + ) { + val authorized = authorizeDapp() + + if (authorized) { + val addresses = interactor.getAddresses(chain.chainId) + + if (addresses.isEmpty()) { + request.reject(MetamaskError.NoAccounts()) + return + } + + val selectedAddress = addresses.first() + + val newState = stateFactory.default(hostApi, chain, selectedAddress) + transition.emitState(newState) + + request.accept(addresses) + + if (selectedAddress != selectedAccountAddress) { + hostApi.reloadPage() + } + } else { + request.reject(MetamaskError.Rejected()) + } + } + + private fun mapMetamaskChainToEvmChain(metamaskChain: MetamaskChain): EvmChain { + return with(metamaskChain) { + EvmChain( + chainId = chainId, + chainName = chainName, + nativeCurrency = with(nativeCurrency) { + EvmChain.NativeCurrency( + name = name, + symbol = symbol.asTokenSymbol(), + decimals = decimals.asPrecision() + ) + }, + rpcUrl = rpcUrls.first(), + iconUrl = iconUrls?.firstOrNull() + ) + } + } + + private fun mapMetamaskTransactionToEvm(metamaskTransaction: MetamaskTransaction): EvmTransaction { + return with(metamaskTransaction) { + EvmTransaction.Struct( + gas = gas, + gasPrice = gasPrice, + from = from, + to = to, + data = data, + value = value, + nonce = nonce + ) + } + } + + private fun mapMetamaskPersonalSignMessageToEvm(personalSignMessage: MetamaskPersonalSignMessage): EvmPersonalSignMessage { + return with(personalSignMessage) { + EvmPersonalSignMessage(data = data) + } + } + + private fun mapMetamaskTypedMessageToEvm(typedMessage: MetamaskTypedMessage): EvmTypedMessage { + return with(typedMessage) { + EvmTypedMessage(data = data, raw = raw) + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/MetamaskStateFactory.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/MetamaskStateFactory.kt new file mode 100644 index 0000000..4d0d2fa --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/MetamaskStateFactory.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.states + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.browser.metamask.MetamaskInteractor +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3StateMachineHost + +class MetamaskStateFactory( + private val interactor: MetamaskInteractor, + private val commonInteractor: DappInteractor, + private val resourceManager: ResourceManager, + private val addressIconGenerator: AddressIconGenerator, + private val web3Session: Web3Session, + private val walletUiUseCase: WalletUiUseCase, +) { + + fun default( + hostApi: Web3StateMachineHost, + chain: MetamaskChain? = null, + selectedAddress: String? = null + ): DefaultMetamaskState { + val usedChain = chain ?: interactor.getDefaultMetamaskChain() + + return DefaultMetamaskState( + interactor = interactor, + commonInteractor = commonInteractor, + resourceManager = resourceManager, + addressIconGenerator = addressIconGenerator, + web3Session = web3Session, + walletUiUseCase = walletUiUseCase, + hostApi = hostApi, + chain = usedChain, + selectedAccountAddress = selectedAddress, + stateFactory = this + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/MetamaskStateMachine.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/MetamaskStateMachine.kt new file mode 100644 index 0000000..02968ef --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/MetamaskStateMachine.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.states + +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskTransportRequest +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine + +typealias MetamaskStateMachine = Web3ExtensionStateMachine + +interface MetamaskState : Web3ExtensionStateMachine.State, MetamaskState> { + + val chain: MetamaskChain + + val selectedAccountAddress: String? +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/PhishingDetectedMetamaskState.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/PhishingDetectedMetamaskState.kt new file mode 100644 index 0000000..d4a1622 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/states/PhishingDetectedMetamaskState.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.states + +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskError +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskTransportRequest +import io.novafoundation.nova.feature_dapp_impl.web3.states.PhishingDetectedState + +class PhishingDetectedMetamaskState(override val chain: MetamaskChain) : + PhishingDetectedState, MetamaskState>(), MetamaskState { + + override val selectedAccountAddress: String? = null + + override fun rejectError(): Throwable { + return MetamaskError.Rejected() + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskInjector.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskInjector.kt new file mode 100644 index 0000000..a55917a --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskInjector.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport + +import android.webkit.WebView +import com.google.gson.Gson +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.web3.states.ExtensionsStore +import io.novafoundation.nova.feature_dapp_impl.web3.webview.Web3Injector +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewScriptInjector +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3JavaScriptInterface + +private const val JS_INTERFACE_NAME = "Metamask" + +private class ProviderConfig( + val chainId: String, + val rpcUrl: String?, + val isDebug: Boolean, + val address: String? +) + +class MetamaskInjector( + private val isDebug: Boolean, + private val gson: Gson, + private val jsInterface: WebViewWeb3JavaScriptInterface, + private val webViewScriptInjector: WebViewScriptInjector +) : Web3Injector { + + override fun initialInject(into: WebView) { + webViewScriptInjector.injectJsInterface(into, jsInterface, JS_INTERFACE_NAME) + } + + override fun injectForPage(into: WebView, extensionStore: ExtensionsStore) { + webViewScriptInjector.injectScript(R.raw.metamask_min, into, scriptId = "pezkuwi-metamask-bundle") + injectProvider(extensionStore, into) + } + + private fun injectProvider(extensionStore: ExtensionsStore, into: WebView) { + val state = extensionStore.metamask.state.value + val chain = state.chain + + val rpcUrl = chain.rpcUrls.firstOrNull() + val providerConfig = ProviderConfig(chain.chainId, rpcUrl, isDebug, state.selectedAccountAddress) + val providerConfigJson = gson.toJson(providerConfig) + + val content = """ + window.ethereum = new pezkuwi.Provider($providerConfigJson); + window.web3 = new pezkuwi.Web3(window.ethereum); + pezkuwi.postMessage = (jsonString) => { + Pezkuwi_Metamask.onNewMessage(JSON.stringify(jsonString)) + }; + """.trimIndent() + + webViewScriptInjector.injectScript(content, into, scriptId = "pezkuwi-metamask-provider") + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskResponder.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskResponder.kt new file mode 100644 index 0000000..709292a --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskResponder.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +sealed class MetamaskError(val errorCode: Int, message: String) : Throwable(message) { + + class Rejected : MetamaskError(4001, "Action rejected") + + class TxSendingFailed : MetamaskError(0, "Failed to sign and send transaction") + + class NoAccounts : MetamaskError(0, "No Ethereum accounts found in selected wallet") + + class SwitchChainNotFound(chainId: ChainId) : MetamaskError(4902, "Chain $chainId not found") + + class AccountsMismatch : MetamaskError(0, "Selected account does not match with the transaction origin") +} + +class MetamaskResponder(private val webViewHolder: WebViewHolder) { + + fun respondResult(messageId: String, result: String) { + val js = "window.ethereum.sendResponse($messageId, $result);" + + evaluateJs(js) + } + + fun respondNullResult(message: String) { + val js = "window.ethereum.sendNullResponse($message)" + + evaluateJs(js) + } + + fun respondError(messageId: String, error: MetamaskError) { + val js = "window.ethereum.sendRpcError($messageId, ${error.errorCode}, \"${error.message}\")" + + evaluateJs(js) + } + + private fun evaluateJs(js: String) = webViewHolder.webView?.post { + webViewHolder.webView?.evaluateJavascript(js, null) + + log(js) + } + + private fun log(message: String) { + Log.d(LOG_TAG, message) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskTransport.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskTransport.kt new file mode 100644 index 0000000..2f5c997 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskTransport.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.common.utils.fromParsedHierarchy +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskPersonalSignMessage +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskTransaction +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskTypedMessage +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.SwitchChainRequest +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3JavaScriptInterface +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3Transport +import kotlinx.coroutines.CoroutineScope + +class MetamaskTransportFactory( + private val responder: MetamaskResponder, + private val webViewWeb3JavaScriptInterface: WebViewWeb3JavaScriptInterface, + private val gson: Gson, +) { + + fun create(scope: CoroutineScope): MetamaskTransport { + return MetamaskTransport( + webViewWeb3JavaScriptInterface = webViewWeb3JavaScriptInterface, + scope = scope, + gson = gson, + responder = responder, + ) + } +} + +private class MetamaskRequest( + val id: String, + @SerializedName("name") + val identifier: String, + @SerializedName("object") + val payload: Any? +) + +class MetamaskTransport( + private val gson: Gson, + private val responder: MetamaskResponder, + webViewWeb3JavaScriptInterface: WebViewWeb3JavaScriptInterface, + scope: CoroutineScope, +) : WebViewWeb3Transport>(scope, webViewWeb3JavaScriptInterface) { + + override suspend fun messageToRequest(message: String): MetamaskTransportRequest<*>? = runCatching { + val request = gson.fromJson(message) + + when (request.identifier) { + MetamaskTransportRequest.Identifier.REQUEST_ACCOUNTS.id -> { + MetamaskTransportRequest.RequestAccounts(request.id, gson, responder) + } + MetamaskTransportRequest.Identifier.ADD_ETHEREUM_CHAIN.id -> { + val chain = gson.fromParsedHierarchy(request.payload) + + MetamaskTransportRequest.AddEthereumChain(request.id, gson, responder, chain) + } + MetamaskTransportRequest.Identifier.SWITCH_ETHEREUM_CHAIN.id -> { + val switchChainRequest = gson.fromParsedHierarchy(request.payload) + + MetamaskTransportRequest.SwitchEthereumChain(request.id, gson, responder, switchChainRequest.chainId) + } + MetamaskTransportRequest.Identifier.SIGN_TRANSACTION.id -> { + val transaction = gson.fromParsedHierarchy(request.payload) + + MetamaskTransportRequest.SendTransaction(request.id, gson, responder, transaction) + } + MetamaskTransportRequest.Identifier.SIGN_TYPED_MESSAGE.id -> { + val typedMessage = gson.fromParsedHierarchy(request.payload) + + MetamaskTransportRequest.SignTypedMessage(request.id, gson, responder, typedMessage) + } + MetamaskTransportRequest.Identifier.PERSONAL_SIGN.id -> { + val personalSignMessage = gson.fromParsedHierarchy(request.payload) + + MetamaskTransportRequest.PersonalSign(request.id, gson, responder, personalSignMessage) + } + else -> null + } + } + .onFailure { Log.e(LOG_TAG, "Failed to parse dApp message: $message", it) } + .getOrNull() +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskTransportRequest.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskTransportRequest.kt new file mode 100644 index 0000000..ef1cf6e --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/metamask/transport/MetamaskTransportRequest.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport + +import com.google.gson.Gson +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionHash +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Transport +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.EthereumAddress +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskChain +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskPersonalSignMessage +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskTransaction +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.MetamaskTypedMessage +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.model.SignedMessage + +sealed class MetamaskTransportRequest( + val id: String, + private val gson: Gson, + protected val responder: MetamaskResponder, +) : Web3Transport.Request { + + override fun reject(error: Throwable) { + require(error is MetamaskError) { + "Metamask transport allows only instances of MetamaskError as errors" + } + + responder.respondError(id, error) + } + + override fun accept(response: R) { + if (response is Unit) { + responder.respondNullResult(id) + } else { + responder.respondResult(id, gson.toJson(response)) + } + } + + enum class Identifier(val id: String) { + REQUEST_ACCOUNTS("requestAccounts"), + ADD_ETHEREUM_CHAIN("addEthereumChain"), + SWITCH_ETHEREUM_CHAIN("switchEthereumChain"), + SIGN_TRANSACTION("signTransaction"), + SIGN_TYPED_MESSAGE("signTypedMessage"), + PERSONAL_SIGN("signPersonalMessage") + } + + class RequestAccounts( + id: String, + gson: Gson, + responder: MetamaskResponder + ) : MetamaskTransportRequest>(id, gson, responder) + + class AddEthereumChain( + id: String, + gson: Gson, + responder: MetamaskResponder, + val chain: MetamaskChain, + ) : MetamaskTransportRequest(id, gson, responder) + + class SwitchEthereumChain( + id: String, + gson: Gson, + responder: MetamaskResponder, + val chainId: String + ) : MetamaskTransportRequest(id, gson, responder) + + class SendTransaction( + id: String, + gson: Gson, + responder: MetamaskResponder, + val transaction: MetamaskTransaction + ) : MetamaskTransportRequest(id, gson, responder) + + class SignTypedMessage( + id: String, + gson: Gson, + responder: MetamaskResponder, + val message: MetamaskTypedMessage + ) : MetamaskTransportRequest(id, gson, responder) + + class PersonalSign( + id: String, + gson: Gson, + responder: MetamaskResponder, + val message: MetamaskPersonalSignMessage + ) : MetamaskTransportRequest(id, gson, responder) +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsInjector.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsInjector.kt new file mode 100644 index 0000000..4309268 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsInjector.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs + +import android.webkit.WebView +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.web3.states.ExtensionsStore +import io.novafoundation.nova.feature_dapp_impl.web3.webview.Web3Injector +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewScriptInjector +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3JavaScriptInterface + +// should be in tact with javascript_interface_bridge.js +private const val JS_INTERFACE_NAME = "PolkadotJs" + +class PolkadotJsInjector( + private val jsInterface: WebViewWeb3JavaScriptInterface, + private val webViewScriptInjector: WebViewScriptInjector +) : Web3Injector { + + override fun initialInject(into: WebView) { + webViewScriptInjector.injectJsInterface(into, jsInterface, JS_INTERFACE_NAME) + } + + override fun injectForPage(into: WebView, extensionStore: ExtensionsStore) { + webViewScriptInjector.injectScript(R.raw.polkadotjs_min, into, scriptId = "pezkuwi-polkadotjs-bundle") + webViewScriptInjector.injectScript(R.raw.javascript_interface_bridge, into, scriptId = "pezkuwi-polkadotjs-provider") + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsResponder.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsResponder.kt new file mode 100644 index 0000000..38ed539 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsResponder.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Responder +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewHolder + +class PolkadotJsResponder( + private val webViewHolder: WebViewHolder +) : Web3Responder { + + override fun respondResult(id: String, result: String) { + evaluateJs(successResponse(id, result)) + } + + override fun respondSubscription(id: String, result: String) { + evaluateJs(successSubscription(id, result)) + } + + override fun respondError(id: String, error: Throwable) { + evaluateJs(failure(id, error)) + } + + private fun evaluateJs(js: String) = webViewHolder.webView?.post { + webViewHolder.webView?.evaluateJavascript(js, null) + + log(js) + } + + private fun log(message: String) { + Log.d(LOG_TAG, message) + } + + private fun successResponse(id: String, result: String) = "window.walletExtension.onAppResponse(\"$id\", $result, null)" + + private fun successSubscription(id: String, result: String) = "window.walletExtension.onAppSubscription(\"$id\", $result)" + + private fun failure(id: String, error: Throwable) = "window.walletExtension.onAppResponse(\"$id\", null, new Error(\"${error.message.orEmpty()}\"))" +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsTransport.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsTransport.kt new file mode 100644 index 0000000..250e4b6 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsTransport.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs + +import android.util.Log +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.parseArbitraryObject +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Responder +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.mapRawPayloadToSignerPayloadJSON +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.mapRawPayloadToSignerPayloadRaw +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3JavaScriptInterface +import io.novafoundation.nova.feature_dapp_impl.web3.webview.WebViewWeb3Transport +import kotlinx.coroutines.CoroutineScope + +class PolkadotJsTransportFactory( + private val web3Responder: Web3Responder, + private val webViewWeb3JavaScriptInterface: WebViewWeb3JavaScriptInterface, + private val gson: Gson, +) { + + fun create(scope: CoroutineScope): PolkadotJsTransport { + return PolkadotJsTransport( + webViewWeb3JavaScriptInterface = webViewWeb3JavaScriptInterface, + scope = scope, + gson = gson, + web3Responder = web3Responder, + ) + } +} + +class PolkadotJsTransport( + private val gson: Gson, + private val web3Responder: Web3Responder, + webViewWeb3JavaScriptInterface: WebViewWeb3JavaScriptInterface, + scope: CoroutineScope, +) : WebViewWeb3Transport>(scope, webViewWeb3JavaScriptInterface) { + + override suspend fun messageToRequest(message: String): PolkadotJsTransportRequest<*>? { + Log.d(LOG_TAG, message) + + val parsedMessage = gson.parseArbitraryObject(message)!! + + val url = parsedMessage["url"] as? String ?: return null + val requestId = parsedMessage["id"] as? String ?: return null + + return when (parsedMessage["msgType"]) { + PolkadotJsTransportRequest.Identifier.AUTHORIZE_TAB.id -> + PolkadotJsTransportRequest.Single.AuthorizeTab(web3Responder, url, requestId) + + PolkadotJsTransportRequest.Identifier.LIST_ACCOUNTS.id -> + PolkadotJsTransportRequest.Single.ListAccounts(web3Responder, url, gson, requestId) + + PolkadotJsTransportRequest.Identifier.SIGN_EXTRINSIC.id -> { + val maybePayload = mapRawPayloadToSignerPayloadJSON(parsedMessage["request"], gson) + + maybePayload?.let { + PolkadotJsTransportRequest.Single.Sign.Extrinsic(web3Responder, url, requestId, maybePayload, gson) + } + } + + PolkadotJsTransportRequest.Identifier.SIGN_BYTES.id -> { + val maybePayload = mapRawPayloadToSignerPayloadRaw(parsedMessage["request"], gson) + + maybePayload?.let { + PolkadotJsTransportRequest.Single.Sign.Bytes(web3Responder, url, requestId, maybePayload, gson) + } + } + + PolkadotJsTransportRequest.Identifier.SUBSCRIBE_ACCOUNTS.id -> + PolkadotJsTransportRequest.Subscription.SubscribeAccounts(scope = this, requestId, web3Responder, url, gson) + + PolkadotJsTransportRequest.Identifier.LIST_METADATA.id -> + PolkadotJsTransportRequest.Single.ListMetadata(web3Responder, url, gson, requestId) + + PolkadotJsTransportRequest.Identifier.PROVIDE_METADATA.id -> + PolkadotJsTransportRequest.Single.ProvideMetadata(web3Responder, url, requestId) + + else -> null + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsTransportRequest.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsTransportRequest.kt new file mode 100644 index 0000000..cd0635e --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/PolkadotJsTransportRequest.kt @@ -0,0 +1,162 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs + +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Responder +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Transport +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.InjectedAccount +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.InjectedMetadataKnown +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.SignerPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignerResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +sealed class PolkadotJsTransportRequest( + protected val web3Responder: Web3Responder, + protected val identifier: Identifier, + val url: String, + val requestId: String, +) : Web3Transport.Request { + + override fun reject(error: Throwable) { + web3Responder.respondError(requestId, error) + } + + enum class Identifier(val id: String) { + AUTHORIZE_TAB("pub(authorize.tab)"), + LIST_ACCOUNTS("pub(accounts.list)"), + SIGN_EXTRINSIC("pub(extrinsic.sign)"), + SUBSCRIBE_ACCOUNTS("pub(accounts.subscribe)"), + LIST_METADATA("pub(metadata.list)"), + PROVIDE_METADATA("pub(metadata.provide)"), + SIGN_BYTES("pub(bytes.sign)"), + } + + sealed class Single( + web3Responder: Web3Responder, + url: String, + identifier: Identifier, + requestId: String + ) : PolkadotJsTransportRequest(web3Responder, identifier, url, requestId) { + + abstract fun serializeResponse(response: R): String + + override fun accept(response: R) { + web3Responder.respondResult(requestId, serializeResponse(response)) + } + + class AuthorizeTab( + web3Responder: Web3Responder, + url: String, + requestId: String + ) : Single(web3Responder, url, Identifier.AUTHORIZE_TAB, requestId) { + + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + override fun serializeResponse(authorized: Boolean): String { + return authorized.toString() + } + } + + class ListAccounts( + web3Responder: Web3Responder, + url: String, + private val gson: Gson, + requestId: String + ) : Single>(web3Responder, url, Identifier.LIST_ACCOUNTS, requestId) { + + override fun serializeResponse(response: List): String { + return gson.toJson(response) + } + } + + sealed class Sign( + web3Responder: Web3Responder, + url: String, + requestId: String, + val signerPayload: SignerPayload, + private val gson: Gson, + identifier: Identifier, + ) : Single(web3Responder, url, identifier, requestId) { + + override fun serializeResponse(response: PolkadotSignerResult): String { + return gson.toJson(response) + } + + class Extrinsic( + web3Responder: Web3Responder, + url: String, + requestId: String, + signerPayload: SignerPayload.Json, + gson: Gson, + ) : Sign(web3Responder, url, requestId, signerPayload, gson, Identifier.SIGN_EXTRINSIC) + + class Bytes( + web3Responder: Web3Responder, + url: String, + requestId: String, + signerPayload: SignerPayload.Raw, + gson: Gson, + ) : Sign(web3Responder, url, requestId, signerPayload, gson, Identifier.SIGN_BYTES) + } + + class ListMetadata( + web3Responder: Web3Responder, + url: String, + private val gson: Gson, + requestId: String + ) : Single>(web3Responder, url, Identifier.LIST_METADATA, requestId) { + + override fun serializeResponse(response: List): String { + return gson.toJson(response) + } + } + + class ProvideMetadata( + web3Responder: Web3Responder, + url: String, + requestId: String + ) : Single(web3Responder, url, Identifier.PROVIDE_METADATA, requestId) { + + override fun serializeResponse(response: Boolean): String { + return response.toString() + } + } + } + + sealed class Subscription( + private val scope: CoroutineScope, + requestId: String, + web3Responder: Web3Responder, + url: String, + identifier: Identifier + ) : PolkadotJsTransportRequest>(web3Responder, identifier, url, requestId) { + + abstract fun serializeSubscriptionResponse(response: R): String + + override fun accept(response: Flow) { + web3Responder.respondResult(requestId, "true") + + response + .map(::serializeSubscriptionResponse) + .onEach { web3Responder.respondSubscription(requestId, it) } + .inBackground() + .launchIn(scope) + } + + class SubscribeAccounts( + scope: CoroutineScope, + requestId: String, + web3Responder: Web3Responder, + url: String, + private val gson: Gson, + ) : Subscription>(scope, requestId, web3Responder, url, Identifier.SUBSCRIBE_ACCOUNTS) { + + override fun serializeSubscriptionResponse(response: List): String { + return gson.toJson(response) + } + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/di/PolkadotJs.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/di/PolkadotJs.kt new file mode 100644 index 0000000..12bb304 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/di/PolkadotJs.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class PolkadotJs diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/model/InjectedAccount.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/model/InjectedAccount.kt new file mode 100644 index 0000000..249dc8b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/model/InjectedAccount.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model + +import io.novasama.substrate_sdk_android.encrypt.MultiChainEncryption + +class InjectedAccount internal constructor( + val address: String, + val genesisHash: String?, + val name: String?, + val type: String? +) + +fun InjectedAccount( + address: String, + genesisHash: String?, + name: String?, + encryption: MultiChainEncryption?, +) = InjectedAccount(address, genesisHash, name, encryption?.injectedType()) + +private fun MultiChainEncryption.injectedType(): String { + return when (this) { + is MultiChainEncryption.Substrate -> encryptionType.rawName + MultiChainEncryption.Ethereum -> "ethereum" + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/model/InjectedMetadataKnown.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/model/InjectedMetadataKnown.kt new file mode 100644 index 0000000..334ab96 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/model/InjectedMetadataKnown.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model + +class InjectedMetadataKnown( + val genesisHash: String, + val specVersion: Int +) diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/model/SignerPayload.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/model/SignerPayload.kt new file mode 100644 index 0000000..c3eab15 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/model/SignerPayload.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model + +import com.google.gson.Gson +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignPayload + +sealed class SignerPayload { + + abstract val address: String + + class Json( + override val address: String, + val blockHash: String, + val blockNumber: String, + val era: String, + val genesisHash: String, + val method: String, + val nonce: String, + val specVersion: String, + val tip: String, + val transactionVersion: String, + val metadataHash: String?, + val withSignedTransaction: Boolean?, + val assetId: String, + val signedExtensions: List, + val version: Int + ) : SignerPayload() + + class Raw( + val data: String, + override val address: String, + val type: String? + ) : SignerPayload() +} + +fun mapRawPayloadToSignerPayloadJSON( + raw: Any?, + gson: Gson +): SignerPayload.Json? { + val tree = gson.toJsonTree(raw) + + return runCatching { + gson.fromJson(tree, SignerPayload.Json::class.java) + }.getOrNull() +} + +fun mapRawPayloadToSignerPayloadRaw( + raw: Any?, + gson: Gson +): SignerPayload.Raw? { + val tree = gson.toJsonTree(raw) + + return runCatching { + gson.fromJson(tree, SignerPayload.Raw::class.java) + }.getOrNull() +} + +fun mapPolkadotJsSignerPayloadToPolkadotPayload(signerPayload: SignerPayload): PolkadotSignPayload { + return when (signerPayload) { + is SignerPayload.Json -> with(signerPayload) { + PolkadotSignPayload.Json( + address = address, + blockHash = blockHash, + blockNumber = blockNumber, + era = era, + genesisHash = genesisHash, + metadataHash = metadataHash, + method = method, + nonce = nonce, + specVersion = specVersion, + tip = tip, + transactionVersion = transactionVersion, + signedExtensions = signedExtensions, + withSignedTransaction = withSignedTransaction, + version = version, + assetId = assetId + ) + } + is SignerPayload.Raw -> with(signerPayload) { + PolkadotSignPayload.Raw( + data = data, + address = address, + type = type + ) + } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/DefaultPolkadotJsState.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/DefaultPolkadotJsState.kt new file mode 100644 index 0000000..5a190e2 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/DefaultPolkadotJsState.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_dapp_impl.R +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.browser.polkadotJs.PolkadotJsExtensionInteractor +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsTransportRequest +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.model.mapPolkadotJsSignerPayloadToPolkadotPayload +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_dapp_impl.web3.states.BaseState +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.ExternalEvent +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.StateMachineTransition +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3StateMachineHost +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3StateMachineHost.NotAuthorizedException +import io.novafoundation.nova.feature_dapp_impl.web3.states.hostApi.ConfirmTxResponse +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignerResult +import kotlinx.coroutines.flow.flowOf + +class DefaultPolkadotJsState( + private val interactor: PolkadotJsExtensionInteractor, + commonInteractor: DappInteractor, + resourceManager: ResourceManager, + addressIconGenerator: AddressIconGenerator, + web3Session: Web3Session, + hostApi: Web3StateMachineHost, + walletUiUseCase: WalletUiUseCase, +) : BaseState, PolkadotJsState>( + commonInteractor = commonInteractor, + resourceManager = resourceManager, + addressIconGenerator = addressIconGenerator, + web3Session = web3Session, + hostApi = hostApi, + walletUiUseCase = walletUiUseCase +), + PolkadotJsState { + + override suspend fun acceptRequest(request: PolkadotJsTransportRequest<*>, transition: StateMachineTransition) { + when (request) { + is PolkadotJsTransportRequest.Single.AuthorizeTab -> authorizeTab(request) + is PolkadotJsTransportRequest.Single.ListAccounts -> supplyAccountList(request) + is PolkadotJsTransportRequest.Single.Sign -> signExtrinsicIfAllowed(request, getAuthorizationStateForCurrentPage()) + is PolkadotJsTransportRequest.Subscription.SubscribeAccounts -> supplyAccountListSubscription(request) + is PolkadotJsTransportRequest.Single.ListMetadata -> suppleKnownMetadatas(request) + is PolkadotJsTransportRequest.Single.ProvideMetadata -> handleProvideMetadata(request) + } + } + + override suspend fun acceptEvent(event: ExternalEvent, transition: StateMachineTransition) { + when (event) { + ExternalEvent.PhishingDetected -> transition.emitState(PhishingDetectedPolkadotJsState()) + } + } + + private suspend fun authorizeTab(request: PolkadotJsTransportRequest.Single.AuthorizeTab) { + val authorized = authorizeDapp() + + request.accept(authorized) + } + + private suspend fun signExtrinsicIfAllowed(request: PolkadotJsTransportRequest.Single.Sign, state: Web3Session.Authorization.State) { + when (state) { + // request user confirmation if dapp is authorized + Web3Session.Authorization.State.ALLOWED -> signExtrinsicWithConfirmation(request) + // reject otherwise + else -> request.reject(NotAuthorizedException) + } + } + + private suspend fun signExtrinsicWithConfirmation(request: PolkadotJsTransportRequest.Single.Sign) { + val signRequest = ExternalSignRequest.Polkadot(request.requestId, mapPolkadotJsSignerPayloadToPolkadotPayload(request.signerPayload)) + + when (val response = hostApi.confirmTx(signRequest)) { + is ConfirmTxResponse.Rejected -> request.reject(NotAuthorizedException) + is ConfirmTxResponse.Sent -> throw IllegalStateException("Unexpected 'Sent' response for PolkadotJs extension") + is ConfirmTxResponse.Signed -> request.accept(PolkadotSignerResult(response.requestId, response.signature, response.modifiedTransaction)) + is ConfirmTxResponse.SigningFailed -> { + if (response.shouldPresent) hostApi.showError(resourceManager.getString(R.string.dapp_sign_extrinsic_failed)) + + request.reject(Web3StateMachineHost.SigningFailedException) + } + + is ConfirmTxResponse.ChainIsDisabled -> { + hostApi.showError( + resourceManager.getString(R.string.disabled_chain_error_title, response.chainName), + resourceManager.getString(R.string.disabled_chain_error_message, response.chainName) + ) + + request.reject(Web3StateMachineHost.SigningFailedException) + } + } + } + + private suspend fun handleProvideMetadata(request: PolkadotJsTransportRequest.Single.ProvideMetadata) = request.respondIfAllowed { + false // we do not accept provided metadata since app handles metadata sync by its own + } + + private suspend fun suppleKnownMetadatas(request: PolkadotJsTransportRequest.Single.ListMetadata) = request.respondIfAllowed { + interactor.getKnownInjectedMetadatas() + } + + private suspend fun supplyAccountList(request: PolkadotJsTransportRequest.Single.ListAccounts) = request.respondIfAllowed { + interactor.getInjectedAccounts() + } + + private suspend fun supplyAccountListSubscription(request: PolkadotJsTransportRequest.Subscription.SubscribeAccounts) { + request.respondIfAllowed { + flowOf(interactor.getInjectedAccounts()) + } + } + + private suspend fun PolkadotJsTransportRequest.respondIfAllowed( + responseConstructor: suspend () -> T + ) = respondIfAllowed( + ifAllowed = { accept(responseConstructor()) } + ) { reject(NotAuthorizedException) } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/PhishingDetectedPolkadotJsState.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/PhishingDetectedPolkadotJsState.kt new file mode 100644 index 0000000..bd5bcae --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/PhishingDetectedPolkadotJsState.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states + +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsTransportRequest +import io.novafoundation.nova.feature_dapp_impl.web3.states.PhishingDetectedState + +class PhishingDetectedPolkadotJsState : PhishingDetectedState, PolkadotJsState>(), PolkadotJsState { + + override fun rejectError(): Throwable { + return IllegalAccessException("Phishing detected!") + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/PolkadotJsStateFactory.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/PolkadotJsStateFactory.kt new file mode 100644 index 0000000..5d828f4 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/PolkadotJsStateFactory.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.domain.browser.polkadotJs.PolkadotJsExtensionInteractor +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3StateMachineHost + +class PolkadotJsStateFactory( + private val interactor: PolkadotJsExtensionInteractor, + private val commonInteractor: DappInteractor, + private val resourceManager: ResourceManager, + private val addressIconGenerator: AddressIconGenerator, + private val web3Session: Web3Session, + private val walletUiUseCase: WalletUiUseCase, +) { + + fun default(hostApi: Web3StateMachineHost): DefaultPolkadotJsState { + return DefaultPolkadotJsState( + interactor = interactor, + commonInteractor = commonInteractor, + resourceManager = resourceManager, + addressIconGenerator = addressIconGenerator, + web3Session = web3Session, + hostApi = hostApi, + walletUiUseCase = walletUiUseCase + ) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/PolkadotJsStateMachine.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/PolkadotJsStateMachine.kt new file mode 100644 index 0000000..ff38e4c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/polkadotJs/states/PolkadotJsStateMachine.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states + +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsTransportRequest +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine + +typealias PolkadotJsStateMachine = Web3ExtensionStateMachine + +interface PolkadotJsState : Web3ExtensionStateMachine.State, PolkadotJsState> diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/session/DbWeb3Session.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/session/DbWeb3Session.kt new file mode 100644 index 0000000..ab0d7de --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/session/DbWeb3Session.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.session + +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.DappAuthorizationDao +import io.novafoundation.nova.core_db.model.DappAuthorizationLocal +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session.Authorization.State +import kotlinx.coroutines.flow.Flow + +class DbWeb3Session( + private val authorizationDao: DappAuthorizationDao +) : Web3Session { + + override suspend fun authorizationStateFor(url: String, metaId: Long): State { + val authorization = authorizationDao.getAuthorization(Urls.normalizeUrl(url), metaId) + + return mapAuthorizedFlagToAuthorizationState(authorization?.authorized) + } + + override suspend fun updateAuthorization(state: State, fullUrl: String, dAppTitle: String, metaId: Long) { + val authorization = DappAuthorizationLocal( + baseUrl = Urls.normalizeUrl(fullUrl), + authorized = mapAuthorizationStateToAuthorizedFlag(state), + dAppTitle = dAppTitle, + metaId = metaId + ) + + authorizationDao.updateAuthorization(authorization) + } + + override suspend fun revokeAuthorization(url: String, metaId: Long) { + authorizationDao.removeAuthorization(baseUrl = Urls.normalizeUrl(url), metaId) + } + + override fun observeAuthorizationsFor(metaId: Long): Flow> { + return authorizationDao.observeAuthorizations(metaId) + .mapList(::mapAuthorizationFromLocal) + } + + private fun mapAuthorizationFromLocal(authorization: DappAuthorizationLocal): Web3Session.Authorization { + return Web3Session.Authorization( + baseUrl = authorization.baseUrl, + metaId = authorization.metaId, + dAppTitle = authorization.dAppTitle, + state = mapAuthorizedFlagToAuthorizationState(authorization.authorized) + ) + } + + private fun mapAuthorizationStateToAuthorizedFlag( + state: State + ) = when (state) { + State.ALLOWED -> true + State.REJECTED -> false + State.NONE -> null + } + + private fun mapAuthorizedFlagToAuthorizationState( + authorized: Boolean? + ) = when (authorized) { + null -> State.NONE + true -> State.ALLOWED + false -> State.REJECTED + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/session/Web3Session.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/session/Web3Session.kt new file mode 100644 index 0000000..f4bf5be --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/session/Web3Session.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.session + +import kotlinx.coroutines.flow.Flow + +interface Web3Session { + + class Authorization( + val state: State, + val baseUrl: String, + val dAppTitle: String?, + val metaId: Long + ) { + enum class State { + ALLOWED, REJECTED, NONE + } + } + + suspend fun authorizationStateFor(url: String, metaId: Long): Authorization.State + + suspend fun updateAuthorization( + state: Authorization.State, + fullUrl: String, + dAppTitle: String, + metaId: Long + ) + + suspend fun revokeAuthorization( + url: String, + metaId: Long + ) + + fun observeAuthorizationsFor(metaId: Long): Flow> +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/BaseState.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/BaseState.kt new file mode 100644 index 0000000..9047556 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/BaseState.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.states + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_dapp_impl.domain.DappInteractor +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Transport +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_external_sign_api.presentation.externalSign.AuthorizeDappBottomSheet +import kotlinx.coroutines.flow.first + +abstract class BaseState, S>( + protected val commonInteractor: DappInteractor, + protected val resourceManager: ResourceManager, + protected val addressIconGenerator: AddressIconGenerator, + protected val web3Session: Web3Session, + protected val hostApi: Web3StateMachineHost, + private val walletUiUseCase: WalletUiUseCase, +) : Web3ExtensionStateMachine.State { + + suspend fun respondIfAllowed( + ifAllowed: suspend () -> Unit, + ifDenied: suspend () -> Unit + ) = if (getAuthorizationStateForCurrentPage() == Web3Session.Authorization.State.ALLOWED) { + ifAllowed() + } else { + ifDenied() + } + + protected suspend fun authorizeDapp(): Boolean { + return when (getAuthorizationStateForCurrentPage()) { + // user already accepted - no need to ask second time + Web3Session.Authorization.State.ALLOWED -> true + // first time dapp request authorization during this session + Web3Session.Authorization.State.NONE -> authorizePageWithConfirmation() + // user rejected this dapp previously - ask for authorization one more time + Web3Session.Authorization.State.REJECTED -> authorizePageWithConfirmation() + } + } + + protected suspend fun getAuthorizationStateForCurrentPage(): Web3Session.Authorization.State { + return web3Session.authorizationStateFor(hostApi.currentPageAnalyzed.first().url, hostApi.selectedMetaAccountId()) + } + + /** + * @return whether authorization request was approved or not + */ + private suspend fun authorizePageWithConfirmation(): Boolean { + val currentPage = hostApi.currentPageAnalyzed.first() + val selectedAccount = hostApi.selectedAccount.first() + // use url got from browser instead of url got from dApp to prevent dApp supplying wrong URL + val dAppInfo = commonInteractor.getDAppInfo(dAppUrl = currentPage.url) + + val dAppIdentifier = dAppInfo.metadata?.name ?: dAppInfo.baseUrl + + val action = AuthorizeDappBottomSheet.Payload( + title = resourceManager.getString( + io.novafoundation.nova.feature_dapp_impl.R.string.dapp_confirm_authorize_title_format, + dAppIdentifier + ), + dAppIconUrl = dAppInfo.metadata?.iconLink, + dAppUrl = dAppInfo.baseUrl, + walletModel = walletUiUseCase.selectedWalletUi() + ) + + val authorizationState = hostApi.authorizeDApp(action) + + web3Session.updateAuthorization( + state = authorizationState, + fullUrl = currentPage.url, + dAppTitle = dAppIdentifier, + metaId = selectedAccount.id + ) + + return authorizationState == Web3Session.Authorization.State.ALLOWED + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/DefaultWeb3ExtensionStateMachine.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/DefaultWeb3ExtensionStateMachine.kt new file mode 100644 index 0000000..eba9762 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/DefaultWeb3ExtensionStateMachine.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.states + +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.StateMachineTransition +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class DefaultWeb3ExtensionStateMachine( + initialState: S +) : Web3ExtensionStateMachine, StateMachineTransition { + + override val state = MutableStateFlow(initialState) + + private val mutex = Mutex() + + override suspend fun transition(transition: suspend StateMachineTransition.(state: S) -> Unit) = mutex.withLock { + transition(this, state.value) + } + + override fun emitState(newState: S) { + state.value = newState + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/ExtensionsStore.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/ExtensionsStore.kt new file mode 100644 index 0000000..3099d5c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/ExtensionsStore.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.states + +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Transport +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.states.MetamaskStateFactory +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.states.MetamaskStateMachine +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskTransport +import io.novafoundation.nova.feature_dapp_impl.web3.metamask.transport.MetamaskTransportFactory +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsTransport +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.PolkadotJsTransportFactory +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states.PolkadotJsStateFactory +import io.novafoundation.nova.feature_dapp_impl.web3.polkadotJs.states.PolkadotJsStateMachine +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.ExternalEvent +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.State +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +interface ExtensionsStore { + + val polkadotJs: PolkadotJsStateMachine + + val metamask: MetamaskStateMachine +} + +class ExtensionStoreFactory( + private val polkadotJsStateFactory: PolkadotJsStateFactory, + private val polkadotJsTransportFactory: PolkadotJsTransportFactory, + + private val metamaskStateFactory: MetamaskStateFactory, + private val metamaskTransportFactory: MetamaskTransportFactory, +) { + + fun create( + hostApi: Web3StateMachineHost, + coroutineScope: CoroutineScope + ): ExtensionsStore { + val initialPolkadotJsState = polkadotJsStateFactory.default(hostApi) + val polkadotJsStateMachine: PolkadotJsStateMachine = DefaultWeb3ExtensionStateMachine(initialPolkadotJsState) + val polkadotJTransport = polkadotJsTransportFactory.create(coroutineScope) + + val initialMetamaskState = metamaskStateFactory.default(hostApi) + val metamaskStateMachine: MetamaskStateMachine = DefaultWeb3ExtensionStateMachine(initialMetamaskState) + val metamaskTransport = metamaskTransportFactory.create(coroutineScope) + + return DefaultExtensionsStore( + polkadotJs = polkadotJsStateMachine, + polkadotJsTransport = polkadotJTransport, + + metamask = metamaskStateMachine, + metamaskTransport = metamaskTransport, + + externalEvents = hostApi.externalEvents, + coroutineScope = coroutineScope + ) + } +} + +private class DefaultExtensionsStore( + override val polkadotJs: PolkadotJsStateMachine, + private val polkadotJsTransport: PolkadotJsTransport, + + override val metamask: MetamaskStateMachine, + private val metamaskTransport: MetamaskTransport, + + private val externalEvents: Flow, + private val coroutineScope: CoroutineScope +) : ExtensionsStore { + + init { + polkadotJs wireWith polkadotJsTransport + metamask wireWith metamaskTransport + } + + private infix fun , S : State> Web3ExtensionStateMachine.wireWith(transport: Web3Transport) { + transport.requestsFlow + .onEach { request -> transition { it.acceptRequest(request) } } + .inBackground() + .launchIn(coroutineScope) + + externalEvents.onEach { event -> transition { it.acceptEvent(event) } } + .inBackground() + .launchIn(coroutineScope) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/PhishingDetectedState.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/PhishingDetectedState.kt new file mode 100644 index 0000000..5aa3e0b --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/PhishingDetectedState.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.states + +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Transport +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.ExternalEvent +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.State +import io.novafoundation.nova.feature_dapp_impl.web3.states.Web3ExtensionStateMachine.StateMachineTransition + +abstract class PhishingDetectedState, S> : State { + + abstract fun rejectError(): Throwable + + override suspend fun acceptRequest(request: R, transition: StateMachineTransition) { + request.reject(rejectError()) + } + + override suspend fun acceptEvent(event: ExternalEvent, transition: StateMachineTransition) { + // do nothing + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/Web3ExtensionStateMachine.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/Web3ExtensionStateMachine.kt new file mode 100644 index 0000000..bd68f70 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/Web3ExtensionStateMachine.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.states + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_dapp_impl.domain.browser.BrowserPageAnalyzed +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Transport +import io.novafoundation.nova.feature_dapp_impl.web3.session.Web3Session +import io.novafoundation.nova.feature_dapp_impl.web3.states.hostApi.ConfirmTxResponse +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.presentation.externalSign.AuthorizeDappBottomSheet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first + +interface Web3ExtensionStateMachine { + + val state: StateFlow + + suspend fun transition(transition: suspend (StateMachineTransition.(state: S) -> Unit)) + + interface State, S> { + + suspend fun acceptRequest(request: R, transition: StateMachineTransition) + + suspend fun acceptEvent(event: ExternalEvent, transition: StateMachineTransition) + } + + sealed class ExternalEvent { + + object PhishingDetected : ExternalEvent() + } + + interface StateMachineTransition { + + fun emitState(newState: S) + + suspend fun > State.acceptRequest(request: R) = acceptRequest(request, this@StateMachineTransition) + suspend fun State<*, S>.acceptEvent(event: ExternalEvent) = acceptEvent(event, this@StateMachineTransition) + } +} + +interface Web3StateMachineHost { + + object NotAuthorizedException : Exception("Rejected by user") + object SigningFailedException : Exception("Signing failed") + + val selectedAccount: Flow + val currentPageAnalyzed: Flow + + val externalEvents: Flow + + suspend fun authorizeDApp(payload: AuthorizeDappBottomSheet.Payload): Web3Session.Authorization.State + suspend fun confirmTx(request: ExternalSignRequest): ConfirmTxResponse + + fun showError(text: String) + + fun showError(title: String, message: CharSequence) + + fun reloadPage() +} + +suspend fun Web3StateMachineHost.selectedMetaAccountId() = selectedAccount.first().id diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/hostApi/ConfirmTxResponse.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/hostApi/ConfirmTxResponse.kt new file mode 100644 index 0000000..50bcd2a --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/states/hostApi/ConfirmTxResponse.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.states.hostApi + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class ConfirmTxResponse : Parcelable { + + abstract val requestId: String + + @Parcelize + class Rejected(override val requestId: String) : ConfirmTxResponse() + + @Parcelize + class Signed(override val requestId: String, val signature: String, val modifiedTransaction: String?) : ConfirmTxResponse() + + @Parcelize + class Sent(override val requestId: String, val txHash: String) : ConfirmTxResponse() + + @Parcelize + class SigningFailed(override val requestId: String, val shouldPresent: Boolean) : ConfirmTxResponse() + + @Parcelize + class ChainIsDisabled(override val requestId: String, val chainName: String) : ConfirmTxResponse() +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3ChromeClient.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3ChromeClient.kt new file mode 100644 index 0000000..b1ce6a0 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3ChromeClient.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview + +import android.webkit.ConsoleMessage +import android.webkit.WebView +import android.widget.ProgressBar +import io.novafoundation.nova.common.utils.browser.fileChoosing.WebViewFileChooser +import io.novafoundation.nova.common.utils.browser.permissions.WebViewPermissionAsker +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.webView.BaseWebChromeClient +import kotlinx.coroutines.CoroutineScope + +private const val MAX_PROGRESS = 100 + +class Web3ChromeClient( + permissionAsker: WebViewPermissionAsker, + fileChooser: WebViewFileChooser, + coroutineScope: CoroutineScope, + private val progressBar: ProgressBar +) : BaseWebChromeClient(permissionAsker, fileChooser, coroutineScope) { + + override fun onProgressChanged(view: WebView, newProgress: Int) { + progressBar.progress = newProgress + + progressBar.setVisible(newProgress < MAX_PROGRESS) + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + return true + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3Injector.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3Injector.kt new file mode 100644 index 0000000..79663f3 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3Injector.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview + +import android.webkit.WebView +import io.novafoundation.nova.feature_dapp_impl.web3.states.ExtensionsStore + +interface Web3Injector { + + fun initialInject(into: WebView) + + fun injectForPage(into: WebView, extensionStore: ExtensionsStore) +} + +class CompoundWeb3Injector(val injectors: List) : Web3Injector { + + override fun initialInject(into: WebView) { + injectors.forEach { it.initialInject(into) } + } + + override fun injectForPage(into: WebView, extensionStore: ExtensionsStore) { + injectors.forEach { it.injectForPage(into, extensionStore) } + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3WebView.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3WebView.kt new file mode 100644 index 0000000..667ea76 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3WebView.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview + +import android.annotation.SuppressLint +import android.webkit.WebSettings +import android.webkit.WebView +import io.novafoundation.nova.common.BuildConfig + +@SuppressLint("SetJavaScriptEnabled") +fun WebView.injectWeb3(web3Client: Web3WebViewClient) { + settings.javaScriptEnabled = true + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.builtInZoomControls = true + settings.displayZoomControls = false + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + settings.domStorageEnabled = true + settings.javaScriptCanOpenWindowsAutomatically = true + settings.mediaPlaybackRequiresUserGesture = false + + this.webViewClient = web3Client + + WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) +} + +fun WebView.uninjectWeb3() { + settings.javaScriptEnabled = false + + webChromeClient = null +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3WebViewClient.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3WebViewClient.kt new file mode 100644 index 0000000..a3b7b19 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/Web3WebViewClient.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview + +import android.graphics.Bitmap +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient + +interface PageCallback { + + fun onPageStarted(webView: WebView, url: String, favicon: Bitmap?) + + fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean + + fun onPageChanged(webView: WebView, url: String?, title: String?) + + fun onPageFinished(view: WebView, url: String?) +} + +class Web3WebViewClient( + private val webView: WebView, + private val pageCallback: PageCallback +) : WebViewClient() { + + var desktopMode: Boolean = false + set(value) { + if (value) { + setDesktopViewport(webView) + } + desktopModeChanged = field != value + field = value + } + private var desktopModeChanged = false + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + if (pageCallback.shouldOverrideUrlLoading(view, request)) { + return true + } + + return super.shouldOverrideUrlLoading(view, request) + } + + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + pageCallback.onPageStarted(view, url, favicon) + if (desktopMode) { + setDesktopViewport(view) + } + if (desktopModeChanged) { + webView.changeUserAgentByDesktopMode(desktopMode) + desktopModeChanged = false + } + } + + override fun onPageFinished(view: WebView, url: String?) { + pageCallback.onPageFinished(view, url) + } + + override fun doUpdateVisitedHistory(view: WebView, url: String, isReload: Boolean) { + pageCallback.onPageChanged(view, url, view.title) + } + + private fun setDesktopViewport(webView: WebView) { + val density = webView.context.resources.displayMetrics.density + val deviceWidth = webView.measuredWidth + val scale = (deviceWidth / density) / 1100 + webView.evaluateJavascript( + "document.querySelector('meta[name=\"viewport\"]').setAttribute('content', 'width=device-width, initial-scale=$scale');", + null + ) + } +} + +private fun WebView.changeUserAgentByDesktopMode(desktopMode: Boolean) { + val defaultUserAgent = WebSettings.getDefaultUserAgent(context) + + settings.userAgentString = if (desktopMode) { + "Mozilla/5.0 (X11; CrOS x86_64 10066.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" + } else { + defaultUserAgent + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewHolder.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewHolder.kt new file mode 100644 index 0000000..043bb62 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewHolder.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview + +import android.webkit.WebView + +class WebViewHolder { + + var webView: WebView? = null + private set + + fun set(new: WebView?) { + webView = new + } + + fun release() { + webView = null + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewScriptInjector.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewScriptInjector.kt new file mode 100644 index 0000000..7cea2a6 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewScriptInjector.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview + +import android.webkit.WebView +import androidx.annotation.RawRes +import io.novafoundation.nova.common.resources.ResourceManager + +private const val JAVASCRIPT_INTERFACE_PREFIX = "Pezkuwi" + +class WebViewScriptInjector( + private val resourceManager: ResourceManager +) { + + private enum class InjectionPosition { + START, END + } + + private val scriptCache: MutableMap = mutableMapOf() + + fun injectJsInterface( + into: WebView, + jsInterface: WebViewWeb3JavaScriptInterface, + interfaceName: String + ) { + val fullName = "${JAVASCRIPT_INTERFACE_PREFIX}_$interfaceName" + + into.addJavascriptInterface(jsInterface, fullName) + } + + fun injectScript( + scriptContent: String, + into: WebView, + scriptId: String = scriptContent.hashCode().toString(), + ) { + addScriptToDomIfNotExists(scriptContent, scriptId, into) + } + + fun injectScript( + @RawRes scriptRes: Int, + into: WebView, + scriptId: String = scriptRes.toString() + ) { + val script = loadScript(scriptRes) + + addScriptToDomIfNotExists(script, scriptId, into) + } + + private fun loadScript(@RawRes scriptRes: Int) = scriptCache.getOrPut(scriptRes) { + resourceManager.loadRawString(scriptRes) + } + + private fun addScriptToDomIfNotExists( + js: String, + scriptId: String, + into: WebView, + ) { + val wrappedScript = """ + (function() { + $js + })(); + """.trimIndent() + + into.evaluateJavascript(wrappedScript, null) + } + + private val InjectionPosition.addMethodName + get() = when (this) { + InjectionPosition.START -> "prepend" + InjectionPosition.END -> "appendChild" + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewWeb3JavaScriptInterface.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewWeb3JavaScriptInterface.kt new file mode 100644 index 0000000..ef0f02e --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewWeb3JavaScriptInterface.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview + +import android.util.Log +import android.webkit.JavascriptInterface +import io.novafoundation.nova.common.utils.LOG_TAG +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +class WebViewWeb3JavaScriptInterface { + + private val _messages = MutableSharedFlow(extraBufferCapacity = 3) + val messages: Flow = _messages + + @JavascriptInterface + fun onNewMessage(message: String) { + Log.d(LOG_TAG, message) + + _messages.tryEmit(message) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewWeb3Transport.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewWeb3Transport.kt new file mode 100644 index 0000000..639ef81 --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/WebViewWeb3Transport.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview + +import io.novafoundation.nova.feature_dapp_impl.web3.Web3Transport +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.shareIn + +abstract class WebViewWeb3Transport>( + scope: CoroutineScope, + webViewWeb3JavaScriptInterface: WebViewWeb3JavaScriptInterface, +) : Web3Transport, + CoroutineScope by scope { + + override val requestsFlow = webViewWeb3JavaScriptInterface.messages + .mapNotNull(::messageToRequest) + .shareIn(this, started = SharingStarted.Eagerly) + + protected abstract suspend fun messageToRequest(message: String): R? +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/interceptors/WalletConnectPairingInterceptor.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/interceptors/WalletConnectPairingInterceptor.kt new file mode 100644 index 0000000..64fc34c --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/interceptors/WalletConnectPairingInterceptor.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview.interceptors + +import android.net.Uri +import android.webkit.WebResourceRequest +import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.feature_wallet_connect_api.presentation.utils.WalletConnectUtils + +class WalletConnectPairingInterceptor( + private val walletConnectService: WalletConnectService +) : WebViewRequestInterceptor { + + override fun intercept(request: WebResourceRequest): Boolean { + if (WalletConnectUtils.isWalletConnectPairingLink(request.url)) { + pairWithWalletConnect(request.url) + return true + } + + return false + } + + private fun pairWithWalletConnect(url: Uri) { + walletConnectService.pair(url.toString()) + } +} diff --git a/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/interceptors/Web3FallbackInterceptor.kt b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/interceptors/Web3FallbackInterceptor.kt new file mode 100644 index 0000000..1f0794e --- /dev/null +++ b/feature-dapp-impl/src/main/java/io/novafoundation/nova/feature_dapp_impl/web3/webview/interceptors/Web3FallbackInterceptor.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_dapp_impl.web3.webview.interceptors + +import android.content.Intent +import android.net.Uri +import android.webkit.WebResourceRequest +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.utils.ToastMessageManager +import io.novafoundation.nova.common.utils.webView.WebViewRequestInterceptor +import io.novafoundation.nova.feature_dapp_impl.R + +class Web3FallbackInterceptor( + private val toastMessageManager: ToastMessageManager, + private val contextManager: ContextManager +) : WebViewRequestInterceptor { + + override fun intercept(request: WebResourceRequest): Boolean { + val url = request.url + + if (url.scheme != "http" && url.scheme != "https") { + startIntent(url) + return true + } + + return false + } + + private fun startIntent(url: Uri) { + try { + val intent = Intent(Intent.ACTION_VIEW, url) + contextManager.getActivity()?.startActivity(intent) + } catch (e: Exception) { + val toastText = contextManager.getActivity()?.getString(R.string.common_no_app_to_handle_intent) + toastText?.let { toastMessageManager.showToast(it) } + } + } +} diff --git a/feature-dapp-impl/src/main/res/color/dapp_tab_text_color.xml b/feature-dapp-impl/src/main/res/color/dapp_tab_text_color.xml new file mode 100644 index 0000000..36f64e8 --- /dev/null +++ b/feature-dapp-impl/src/main/res/color/dapp_tab_text_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/drawable-hdpi/bg_dapp_welcome.png b/feature-dapp-impl/src/main/res/drawable-hdpi/bg_dapp_welcome.png new file mode 100644 index 0000000..7a2e627 Binary files /dev/null and b/feature-dapp-impl/src/main/res/drawable-hdpi/bg_dapp_welcome.png differ diff --git a/feature-dapp-impl/src/main/res/drawable-mdpi/bg_dapp_welcome.png b/feature-dapp-impl/src/main/res/drawable-mdpi/bg_dapp_welcome.png new file mode 100644 index 0000000..d6b0d69 Binary files /dev/null and b/feature-dapp-impl/src/main/res/drawable-mdpi/bg_dapp_welcome.png differ diff --git a/feature-dapp-impl/src/main/res/drawable-xhdpi/bg_dapp_welcome.png b/feature-dapp-impl/src/main/res/drawable-xhdpi/bg_dapp_welcome.png new file mode 100644 index 0000000..03bb760 Binary files /dev/null and b/feature-dapp-impl/src/main/res/drawable-xhdpi/bg_dapp_welcome.png differ diff --git a/feature-dapp-impl/src/main/res/drawable-xxhdpi/bg_dapp_welcome.png b/feature-dapp-impl/src/main/res/drawable-xxhdpi/bg_dapp_welcome.png new file mode 100644 index 0000000..0e54652 Binary files /dev/null and b/feature-dapp-impl/src/main/res/drawable-xxhdpi/bg_dapp_welcome.png differ diff --git a/feature-dapp-impl/src/main/res/drawable-xxxhdpi/bg_dapp_welcome.png b/feature-dapp-impl/src/main/res/drawable-xxxhdpi/bg_dapp_welcome.png new file mode 100644 index 0000000..51006ce Binary files /dev/null and b/feature-dapp-impl/src/main/res/drawable-xxxhdpi/bg_dapp_welcome.png differ diff --git a/feature-dapp-impl/src/main/res/drawable/dapp_tab_background.xml b/feature-dapp-impl/src/main/res/drawable/dapp_tab_background.xml new file mode 100644 index 0000000..0300ccd --- /dev/null +++ b/feature-dapp-impl/src/main/res/drawable/dapp_tab_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/fragment_add_to_favourites.xml b/feature-dapp-impl/src/main/res/layout/fragment_add_to_favourites.xml new file mode 100644 index 0000000..bcd025b --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/fragment_add_to_favourites.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/fragment_authorized_dapps.xml b/feature-dapp-impl/src/main/res/layout/fragment_authorized_dapps.xml new file mode 100644 index 0000000..468b38a --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/fragment_authorized_dapps.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/fragment_browser_tabs.xml b/feature-dapp-impl/src/main/res/layout/fragment_browser_tabs.xml new file mode 100644 index 0000000..56193db --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/fragment_browser_tabs.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/fragment_dapp_browser.xml b/feature-dapp-impl/src/main/res/layout/fragment_dapp_browser.xml new file mode 100644 index 0000000..cbebfc8 --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/fragment_dapp_browser.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/fragment_dapp_main.xml b/feature-dapp-impl/src/main/res/layout/fragment_dapp_main.xml new file mode 100644 index 0000000..125087b --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/fragment_dapp_main.xml @@ -0,0 +1,13 @@ + + diff --git a/feature-dapp-impl/src/main/res/layout/fragment_favorites_dapp.xml b/feature-dapp-impl/src/main/res/layout/fragment_favorites_dapp.xml new file mode 100644 index 0000000..36fede7 --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/fragment_favorites_dapp.xml @@ -0,0 +1,27 @@ + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/fragment_search_dapp.xml b/feature-dapp-impl/src/main/res/layout/fragment_search_dapp.xml new file mode 100644 index 0000000..b9adf82 --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/fragment_search_dapp.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/item_browser_tab.xml b/feature-dapp-impl/src/main/res/layout/item_browser_tab.xml new file mode 100644 index 0000000..40e6708 --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_browser_tab.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/item_dapp_categories_shimmering.xml b/feature-dapp-impl/src/main/res/layout/item_dapp_categories_shimmering.xml new file mode 100644 index 0000000..09d6eda --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_dapp_categories_shimmering.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/item_dapp_category.xml b/feature-dapp-impl/src/main/res/layout/item_dapp_category.xml new file mode 100644 index 0000000..03aa7b1 --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_dapp_category.xml @@ -0,0 +1,35 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/item_dapp_category_shimmering.xml b/feature-dapp-impl/src/main/res/layout/item_dapp_category_shimmering.xml new file mode 100644 index 0000000..15e4d4b --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_dapp_category_shimmering.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/item_dapp_favorite_dragable.xml b/feature-dapp-impl/src/main/res/layout/item_dapp_favorite_dragable.xml new file mode 100644 index 0000000..8960935 --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_dapp_favorite_dragable.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/item_dapp_group.xml b/feature-dapp-impl/src/main/res/layout/item_dapp_group.xml new file mode 100644 index 0000000..fce7804 --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_dapp_group.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/item_dapp_header.xml b/feature-dapp-impl/src/main/res/layout/item_dapp_header.xml new file mode 100644 index 0000000..b7f4be7 --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_dapp_header.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-dapp-impl/src/main/res/layout/item_dapp_search_category.xml b/feature-dapp-impl/src/main/res/layout/item_dapp_search_category.xml new file mode 100644 index 0000000..659ffcf --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_dapp_search_category.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/item_favorite_dapp.xml b/feature-dapp-impl/src/main/res/layout/item_favorite_dapp.xml new file mode 100644 index 0000000..a7c73ff --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_favorite_dapp.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/item_main_favorite_dapps.xml b/feature-dapp-impl/src/main/res/layout/item_main_favorite_dapps.xml new file mode 100644 index 0000000..a9e043c --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/item_main_favorite_dapps.xml @@ -0,0 +1,50 @@ + + +ё + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/layout/view_address_bar.xml b/feature-dapp-impl/src/main/res/layout/view_address_bar.xml new file mode 100644 index 0000000..60f9e77 --- /dev/null +++ b/feature-dapp-impl/src/main/res/layout/view_address_bar.xml @@ -0,0 +1,34 @@ + + + + + + + \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/raw/javascript_interface_bridge.js b/feature-dapp-impl/src/main/res/raw/javascript_interface_bridge.js new file mode 100644 index 0000000..1938dcc --- /dev/null +++ b/feature-dapp-impl/src/main/res/raw/javascript_interface_bridge.js @@ -0,0 +1,14 @@ +window.addEventListener("message", ({ data, source }) => { + // only allow messages from our window, by the loader + if (source !== window) { + return; + } + + let dataJson = JSON.stringify(data) + console.log(`Got message: ${dataJson}`) + + if (data.origin === "dapp-request") { + // should be in tact with PolkadotJsExtension.kt + Nova_PolkadotJs.onNewMessage(dataJson) + } + }); \ No newline at end of file diff --git a/feature-dapp-impl/src/main/res/raw/metamask_min.js b/feature-dapp-impl/src/main/res/raw/metamask_min.js new file mode 100644 index 0000000..19060d6 --- /dev/null +++ b/feature-dapp-impl/src/main/res/raw/metamask_min.js @@ -0,0 +1,869 @@ +parcelRequire=function(e,r,t,n){var i,o="function"==typeof parcelRequire&&parcelRequire,u="function"==typeof require&&require;function f(t,n){if(!r[t]){if(!e[t]){var i="function"==typeof parcelRequire&&parcelRequire;if(!n&&i)return i(t,!0);if(o)return o(t,!0);if(u&&"string"==typeof t)return u(t);var c=new Error("Cannot find module '"+t+"'");throw c.code="MODULE_NOT_FOUND",c}p.resolve=function(r){return e[t][1][r]||r},p.cache={};var l=r[t]=new f.Module(t);e[t][0].call(l.exports,p,l,l.exports,this)}return r[t].exports;function p(e){return f(p.resolve(e))}}f.isParcelRequire=!0,f.Module=function(e){this.id=e,this.bundle=f,this.exports={}},f.modules=e,f.cache=r,f.parent=o,f.register=function(r,t){e[r]=[function(e,r){r.exports=t},{}]};for(var c=0;c0)throw new Error("Invalid string. Length must be a multiple of 4");var e=r.indexOf("=");return-1===e&&(e=t),[e,e===t?0:4-e%4]}function u(r){var t=h(r),e=t[0],n=t[1];return 3*(e+n)/4-n}function c(r,t,e){return 3*(t+e)/4-e}function i(r){var n,o,a=h(r),u=a[0],i=a[1],f=new e(c(r,u,i)),A=0,d=i>0?u-4:u;for(o=0;o>16&255,f[A++]=n>>8&255,f[A++]=255&n;return 2===i&&(n=t[r.charCodeAt(o)]<<2|t[r.charCodeAt(o+1)]>>4,f[A++]=255&n),1===i&&(n=t[r.charCodeAt(o)]<<10|t[r.charCodeAt(o+1)]<<4|t[r.charCodeAt(o+2)]>>2,f[A++]=n>>8&255,f[A++]=255&n),f}function f(t){return r[t>>18&63]+r[t>>12&63]+r[t>>6&63]+r[63&t]}function A(r,t,e){for(var n,o=[],a=t;au?u:h+16383));return 1===o?(e=t[n-1],a.push(r[e>>2]+r[e<<4&63]+"==")):2===o&&(e=(t[n-2]<<8)+t[n-1],a.push(r[e>>10]+r[e>>4&63]+r[e<<2&63]+"=")),a.join("")}t["-".charCodeAt(0)]=62,t["_".charCodeAt(0)]=63; +},{}],"Quj6":[function(require,module,exports) { +exports.read=function(a,o,t,r,h){var M,p,w=8*h-r-1,f=(1<>1,i=-7,N=t?h-1:0,n=t?-1:1,s=a[o+N];for(N+=n,M=s&(1<<-i)-1,s>>=-i,i+=w;i>0;M=256*M+a[o+N],N+=n,i-=8);for(p=M&(1<<-i)-1,M>>=-i,i+=r;i>0;p=256*p+a[o+N],N+=n,i-=8);if(0===M)M=1-e;else{if(M===f)return p?NaN:1/0*(s?-1:1);p+=Math.pow(2,r),M-=e}return(s?-1:1)*p*Math.pow(2,M-r)},exports.write=function(a,o,t,r,h,M){var p,w,f,e=8*M-h-1,i=(1<>1,n=23===h?Math.pow(2,-24)-Math.pow(2,-77):0,s=r?0:M-1,u=r?1:-1,l=o<0||0===o&&1/o<0?1:0;for(o=Math.abs(o),isNaN(o)||o===1/0?(w=isNaN(o)?1:0,p=i):(p=Math.floor(Math.log(o)/Math.LN2),o*(f=Math.pow(2,-p))<1&&(p--,f*=2),(o+=p+N>=1?n/f:n*Math.pow(2,1-N))*f>=2&&(p++,f/=2),p+N>=i?(w=0,p=i):p+N>=1?(w=(o*f-1)*Math.pow(2,h),p+=N):(w=o*Math.pow(2,N-1)*Math.pow(2,h),p=0));h>=8;a[t+s]=255&w,s+=u,w/=256,h-=8);for(p=p<0;a[t+s]=255&p,s+=u,p/=256,e-=8);a[t+s-u]|=128*l}; +},{}],"aqZJ":[function(require,module,exports) { +var r={}.toString;module.exports=Array.isArray||function(t){return"[object Array]"==r.call(t)}; +},{}],"z1tx":[function(require,module,exports) { + +var global = arguments[3]; +var t=arguments[3],r=require("base64-js"),e=require("ieee754"),n=require("isarray");function i(){try{var t=new Uint8Array(1);return t.__proto__={__proto__:Uint8Array.prototype,foo:function(){return 42}},42===t.foo()&&"function"==typeof t.subarray&&0===t.subarray(1,1).byteLength}catch(r){return!1}}function o(){return f.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function u(t,r){if(o()=o())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+o().toString(16)+" bytes");return 0|t}function d(t){return+t!=t&&(t=0),f.alloc(+t)}function v(t,r){if(f.isBuffer(t))return t.length;if("undefined"!=typeof ArrayBuffer&&"function"==typeof ArrayBuffer.isView&&(ArrayBuffer.isView(t)||t instanceof ArrayBuffer))return t.byteLength;"string"!=typeof t&&(t=""+t);var e=t.length;if(0===e)return 0;for(var n=!1;;)switch(r){case"ascii":case"latin1":case"binary":return e;case"utf8":case"utf-8":case void 0:return $(t).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*e;case"hex":return e>>>1;case"base64":return K(t).length;default:if(n)return $(t).length;r=(""+r).toLowerCase(),n=!0}}function E(t,r,e){var n=!1;if((void 0===r||r<0)&&(r=0),r>this.length)return"";if((void 0===e||e>this.length)&&(e=this.length),e<=0)return"";if((e>>>=0)<=(r>>>=0))return"";for(t||(t="utf8");;)switch(t){case"hex":return x(this,r,e);case"utf8":case"utf-8":return Y(this,r,e);case"ascii":return L(this,r,e);case"latin1":case"binary":return D(this,r,e);case"base64":return S(this,r,e);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return C(this,r,e);default:if(n)throw new TypeError("Unknown encoding: "+t);t=(t+"").toLowerCase(),n=!0}}function b(t,r,e){var n=t[r];t[r]=t[e],t[e]=n}function R(t,r,e,n,i){if(0===t.length)return-1;if("string"==typeof e?(n=e,e=0):e>2147483647?e=2147483647:e<-2147483648&&(e=-2147483648),e=+e,isNaN(e)&&(e=i?0:t.length-1),e<0&&(e=t.length+e),e>=t.length){if(i)return-1;e=t.length-1}else if(e<0){if(!i)return-1;e=0}if("string"==typeof r&&(r=f.from(r,n)),f.isBuffer(r))return 0===r.length?-1:_(t,r,e,n,i);if("number"==typeof r)return r&=255,f.TYPED_ARRAY_SUPPORT&&"function"==typeof Uint8Array.prototype.indexOf?i?Uint8Array.prototype.indexOf.call(t,r,e):Uint8Array.prototype.lastIndexOf.call(t,r,e):_(t,[r],e,n,i);throw new TypeError("val must be string, number or Buffer")}function _(t,r,e,n,i){var o,u=1,f=t.length,s=r.length;if(void 0!==n&&("ucs2"===(n=String(n).toLowerCase())||"ucs-2"===n||"utf16le"===n||"utf-16le"===n)){if(t.length<2||r.length<2)return-1;u=2,f/=2,s/=2,e/=2}function h(t,r){return 1===u?t[r]:t.readUInt16BE(r*u)}if(i){var a=-1;for(o=e;of&&(e=f-s),o=e;o>=0;o--){for(var c=!0,l=0;li&&(n=i):n=i;var o=r.length;if(o%2!=0)throw new TypeError("Invalid hex string");n>o/2&&(n=o/2);for(var u=0;u239?4:h>223?3:h>191?2:1;if(i+c<=e)switch(c){case 1:h<128&&(a=h);break;case 2:128==(192&(o=t[i+1]))&&(s=(31&h)<<6|63&o)>127&&(a=s);break;case 3:o=t[i+1],u=t[i+2],128==(192&o)&&128==(192&u)&&(s=(15&h)<<12|(63&o)<<6|63&u)>2047&&(s<55296||s>57343)&&(a=s);break;case 4:o=t[i+1],u=t[i+2],f=t[i+3],128==(192&o)&&128==(192&u)&&128==(192&f)&&(s=(15&h)<<18|(63&o)<<12|(63&u)<<6|63&f)>65535&&s<1114112&&(a=s)}null===a?(a=65533,c=1):a>65535&&(a-=65536,n.push(a>>>10&1023|55296),a=56320|1023&a),n.push(a),i+=c}return O(n)}exports.Buffer=f,exports.SlowBuffer=d,exports.INSPECT_MAX_BYTES=50,f.TYPED_ARRAY_SUPPORT=void 0!==t.TYPED_ARRAY_SUPPORT?t.TYPED_ARRAY_SUPPORT:i(),exports.kMaxLength=o(),f.poolSize=8192,f._augment=function(t){return t.__proto__=f.prototype,t},f.from=function(t,r,e){return s(null,t,r,e)},f.TYPED_ARRAY_SUPPORT&&(f.prototype.__proto__=Uint8Array.prototype,f.__proto__=Uint8Array,"undefined"!=typeof Symbol&&Symbol.species&&f[Symbol.species]===f&&Object.defineProperty(f,Symbol.species,{value:null,configurable:!0})),f.alloc=function(t,r,e){return a(null,t,r,e)},f.allocUnsafe=function(t){return c(null,t)},f.allocUnsafeSlow=function(t){return c(null,t)},f.isBuffer=function(t){return!(null==t||!t._isBuffer)},f.compare=function(t,r){if(!f.isBuffer(t)||!f.isBuffer(r))throw new TypeError("Arguments must be Buffers");if(t===r)return 0;for(var e=t.length,n=r.length,i=0,o=Math.min(e,n);i0&&(t=this.toString("hex",0,r).match(/.{2}/g).join(" "),this.length>r&&(t+=" ... ")),""},f.prototype.compare=function(t,r,e,n,i){if(!f.isBuffer(t))throw new TypeError("Argument must be a Buffer");if(void 0===r&&(r=0),void 0===e&&(e=t?t.length:0),void 0===n&&(n=0),void 0===i&&(i=this.length),r<0||e>t.length||n<0||i>this.length)throw new RangeError("out of range index");if(n>=i&&r>=e)return 0;if(n>=i)return-1;if(r>=e)return 1;if(this===t)return 0;for(var o=(i>>>=0)-(n>>>=0),u=(e>>>=0)-(r>>>=0),s=Math.min(o,u),h=this.slice(n,i),a=t.slice(r,e),c=0;ci)&&(e=i),t.length>0&&(e<0||r<0)||r>this.length)throw new RangeError("Attempt to write outside buffer bounds");n||(n="utf8");for(var o=!1;;)switch(n){case"hex":return A(this,t,r,e);case"utf8":case"utf-8":return m(this,t,r,e);case"ascii":return P(this,t,r,e);case"latin1":case"binary":return T(this,t,r,e);case"base64":return B(this,t,r,e);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return U(this,t,r,e);default:if(o)throw new TypeError("Unknown encoding: "+n);n=(""+n).toLowerCase(),o=!0}},f.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var I=4096;function O(t){var r=t.length;if(r<=I)return String.fromCharCode.apply(String,t);for(var e="",n=0;nn)&&(e=n);for(var i="",o=r;oe)throw new RangeError("Trying to access beyond buffer length")}function k(t,r,e,n,i,o){if(!f.isBuffer(t))throw new TypeError('"buffer" argument must be a Buffer instance');if(r>i||rt.length)throw new RangeError("Index out of range")}function N(t,r,e,n){r<0&&(r=65535+r+1);for(var i=0,o=Math.min(t.length-e,2);i>>8*(n?i:1-i)}function z(t,r,e,n){r<0&&(r=4294967295+r+1);for(var i=0,o=Math.min(t.length-e,4);i>>8*(n?i:3-i)&255}function F(t,r,e,n,i,o){if(e+n>t.length)throw new RangeError("Index out of range");if(e<0)throw new RangeError("Index out of range")}function j(t,r,n,i,o){return o||F(t,r,n,4,3.4028234663852886e38,-3.4028234663852886e38),e.write(t,r,n,i,23,4),n+4}function q(t,r,n,i,o){return o||F(t,r,n,8,1.7976931348623157e308,-1.7976931348623157e308),e.write(t,r,n,i,52,8),n+8}f.prototype.slice=function(t,r){var e,n=this.length;if((t=~~t)<0?(t+=n)<0&&(t=0):t>n&&(t=n),(r=void 0===r?n:~~r)<0?(r+=n)<0&&(r=0):r>n&&(r=n),r0&&(i*=256);)n+=this[t+--r]*i;return n},f.prototype.readUInt8=function(t,r){return r||M(t,1,this.length),this[t]},f.prototype.readUInt16LE=function(t,r){return r||M(t,2,this.length),this[t]|this[t+1]<<8},f.prototype.readUInt16BE=function(t,r){return r||M(t,2,this.length),this[t]<<8|this[t+1]},f.prototype.readUInt32LE=function(t,r){return r||M(t,4,this.length),(this[t]|this[t+1]<<8|this[t+2]<<16)+16777216*this[t+3]},f.prototype.readUInt32BE=function(t,r){return r||M(t,4,this.length),16777216*this[t]+(this[t+1]<<16|this[t+2]<<8|this[t+3])},f.prototype.readIntLE=function(t,r,e){t|=0,r|=0,e||M(t,r,this.length);for(var n=this[t],i=1,o=0;++o=(i*=128)&&(n-=Math.pow(2,8*r)),n},f.prototype.readIntBE=function(t,r,e){t|=0,r|=0,e||M(t,r,this.length);for(var n=r,i=1,o=this[t+--n];n>0&&(i*=256);)o+=this[t+--n]*i;return o>=(i*=128)&&(o-=Math.pow(2,8*r)),o},f.prototype.readInt8=function(t,r){return r||M(t,1,this.length),128&this[t]?-1*(255-this[t]+1):this[t]},f.prototype.readInt16LE=function(t,r){r||M(t,2,this.length);var e=this[t]|this[t+1]<<8;return 32768&e?4294901760|e:e},f.prototype.readInt16BE=function(t,r){r||M(t,2,this.length);var e=this[t+1]|this[t]<<8;return 32768&e?4294901760|e:e},f.prototype.readInt32LE=function(t,r){return r||M(t,4,this.length),this[t]|this[t+1]<<8|this[t+2]<<16|this[t+3]<<24},f.prototype.readInt32BE=function(t,r){return r||M(t,4,this.length),this[t]<<24|this[t+1]<<16|this[t+2]<<8|this[t+3]},f.prototype.readFloatLE=function(t,r){return r||M(t,4,this.length),e.read(this,t,!0,23,4)},f.prototype.readFloatBE=function(t,r){return r||M(t,4,this.length),e.read(this,t,!1,23,4)},f.prototype.readDoubleLE=function(t,r){return r||M(t,8,this.length),e.read(this,t,!0,52,8)},f.prototype.readDoubleBE=function(t,r){return r||M(t,8,this.length),e.read(this,t,!1,52,8)},f.prototype.writeUIntLE=function(t,r,e,n){(t=+t,r|=0,e|=0,n)||k(this,t,r,e,Math.pow(2,8*e)-1,0);var i=1,o=0;for(this[r]=255&t;++o=0&&(o*=256);)this[r+i]=t/o&255;return r+e},f.prototype.writeUInt8=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,1,255,0),f.TYPED_ARRAY_SUPPORT||(t=Math.floor(t)),this[r]=255&t,r+1},f.prototype.writeUInt16LE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,2,65535,0),f.TYPED_ARRAY_SUPPORT?(this[r]=255&t,this[r+1]=t>>>8):N(this,t,r,!0),r+2},f.prototype.writeUInt16BE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,2,65535,0),f.TYPED_ARRAY_SUPPORT?(this[r]=t>>>8,this[r+1]=255&t):N(this,t,r,!1),r+2},f.prototype.writeUInt32LE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,4,4294967295,0),f.TYPED_ARRAY_SUPPORT?(this[r+3]=t>>>24,this[r+2]=t>>>16,this[r+1]=t>>>8,this[r]=255&t):z(this,t,r,!0),r+4},f.prototype.writeUInt32BE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,4,4294967295,0),f.TYPED_ARRAY_SUPPORT?(this[r]=t>>>24,this[r+1]=t>>>16,this[r+2]=t>>>8,this[r+3]=255&t):z(this,t,r,!1),r+4},f.prototype.writeIntLE=function(t,r,e,n){if(t=+t,r|=0,!n){var i=Math.pow(2,8*e-1);k(this,t,r,e,i-1,-i)}var o=0,u=1,f=0;for(this[r]=255&t;++o>0)-f&255;return r+e},f.prototype.writeIntBE=function(t,r,e,n){if(t=+t,r|=0,!n){var i=Math.pow(2,8*e-1);k(this,t,r,e,i-1,-i)}var o=e-1,u=1,f=0;for(this[r+o]=255&t;--o>=0&&(u*=256);)t<0&&0===f&&0!==this[r+o+1]&&(f=1),this[r+o]=(t/u>>0)-f&255;return r+e},f.prototype.writeInt8=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,1,127,-128),f.TYPED_ARRAY_SUPPORT||(t=Math.floor(t)),t<0&&(t=255+t+1),this[r]=255&t,r+1},f.prototype.writeInt16LE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,2,32767,-32768),f.TYPED_ARRAY_SUPPORT?(this[r]=255&t,this[r+1]=t>>>8):N(this,t,r,!0),r+2},f.prototype.writeInt16BE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,2,32767,-32768),f.TYPED_ARRAY_SUPPORT?(this[r]=t>>>8,this[r+1]=255&t):N(this,t,r,!1),r+2},f.prototype.writeInt32LE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,4,2147483647,-2147483648),f.TYPED_ARRAY_SUPPORT?(this[r]=255&t,this[r+1]=t>>>8,this[r+2]=t>>>16,this[r+3]=t>>>24):z(this,t,r,!0),r+4},f.prototype.writeInt32BE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,4,2147483647,-2147483648),t<0&&(t=4294967295+t+1),f.TYPED_ARRAY_SUPPORT?(this[r]=t>>>24,this[r+1]=t>>>16,this[r+2]=t>>>8,this[r+3]=255&t):z(this,t,r,!1),r+4},f.prototype.writeFloatLE=function(t,r,e){return j(this,t,r,!0,e)},f.prototype.writeFloatBE=function(t,r,e){return j(this,t,r,!1,e)},f.prototype.writeDoubleLE=function(t,r,e){return q(this,t,r,!0,e)},f.prototype.writeDoubleBE=function(t,r,e){return q(this,t,r,!1,e)},f.prototype.copy=function(t,r,e,n){if(e||(e=0),n||0===n||(n=this.length),r>=t.length&&(r=t.length),r||(r=0),n>0&&n=this.length)throw new RangeError("sourceStart out of bounds");if(n<0)throw new RangeError("sourceEnd out of bounds");n>this.length&&(n=this.length),t.length-r=0;--i)t[i+r]=this[i+e];else if(o<1e3||!f.TYPED_ARRAY_SUPPORT)for(i=0;i>>=0,e=void 0===e?this.length:e>>>0,t||(t=0),"number"==typeof t)for(o=r;o55295&&e<57344){if(!i){if(e>56319){(r-=3)>-1&&o.push(239,191,189);continue}if(u+1===n){(r-=3)>-1&&o.push(239,191,189);continue}i=e;continue}if(e<56320){(r-=3)>-1&&o.push(239,191,189),i=e;continue}e=65536+(i-55296<<10|e-56320)}else i&&(r-=3)>-1&&o.push(239,191,189);if(i=null,e<128){if((r-=1)<0)break;o.push(e)}else if(e<2048){if((r-=2)<0)break;o.push(e>>6|192,63&e|128)}else if(e<65536){if((r-=3)<0)break;o.push(e>>12|224,e>>6&63|128,63&e|128)}else{if(!(e<1114112))throw new Error("Invalid code point");if((r-=4)<0)break;o.push(e>>18|240,e>>12&63|128,e>>6&63|128,63&e|128)}}return o}function G(t){for(var r=[],e=0;e>8,i=e%256,o.push(i),o.push(n);return o}function K(t){return r.toByteArray(X(t))}function Q(t,r,e,n){for(var i=0;i=r.length||i>=t.length);++i)r[i+e]=t[i];return i}function W(t){return t!=t} +},{"base64-js":"FRly","ieee754":"Quj6","isarray":"aqZJ","buffer":"z1tx"}],"gIYa":[function(require,module,exports) { + +var r=require("buffer"),e=r.Buffer;function o(r,e){for(var o in r)e[o]=r[o]}function n(r,o,n){return e(r,o,n)}e.from&&e.alloc&&e.allocUnsafe&&e.allocUnsafeSlow?module.exports=r:(o(r,exports),exports.Buffer=n),n.prototype=Object.create(e.prototype),o(e,n),n.from=function(r,o,n){if("number"==typeof r)throw new TypeError("Argument must not be a number");return e(r,o,n)},n.alloc=function(r,o,n){if("number"!=typeof r)throw new TypeError("Argument must be a number");var t=e(r);return void 0!==o?"string"==typeof n?t.fill(o,n):t.fill(o):t.fill(0),t},n.allocUnsafe=function(r){if("number"!=typeof r)throw new TypeError("Argument must be a number");return e(r)},n.allocUnsafeSlow=function(e){if("number"!=typeof e)throw new TypeError("Argument must be a number");return r.SlowBuffer(e)}; +},{"buffer":"z1tx"}],"g5IB":[function(require,module,exports) { + +var t,e,n=module.exports={};function r(){throw new Error("setTimeout has not been defined")}function o(){throw new Error("clearTimeout has not been defined")}function i(e){if(t===setTimeout)return setTimeout(e,0);if((t===r||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(n){try{return t.call(null,e,0)}catch(n){return t.call(this,e,0)}}}function u(t){if(e===clearTimeout)return clearTimeout(t);if((e===o||!e)&&clearTimeout)return e=clearTimeout,clearTimeout(t);try{return e(t)}catch(n){try{return e.call(null,t)}catch(n){return e.call(this,t)}}}!function(){try{t="function"==typeof setTimeout?setTimeout:r}catch(n){t=r}try{e="function"==typeof clearTimeout?clearTimeout:o}catch(n){e=o}}();var c,s=[],l=!1,a=-1;function f(){l&&c&&(l=!1,c.length?s=c.concat(s):a=-1,s.length&&h())}function h(){if(!l){var t=i(f);l=!0;for(var e=s.length;e;){for(c=s,s=[];++a1)for(var n=1;nn)throw new RangeError("requested too many random bytes");var a=s.allocUnsafe(e);if(e>0)if(e>o)for(var f=0;f0&&c.length>o&&!c.warned){c.warned=!0;var v=new Error("Possible EventEmitter memory leak detected. "+c.length+" "+String(t)+" listeners added. Use emitter.setMaxListeners() to increase limit");v.name="MaxListenersExceededWarning",v.emitter=e,v.type=t,v.count=c.length,r(v)}return e}function v(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,0===arguments.length?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function l(e,t,n){var r={fired:!1,wrapFn:void 0,target:e,type:t,listener:n},i=v.bind(r);return i.listener=n,r.wrapFn=i,i}function p(e,t,n){var r=e._events;if(void 0===r)return[];var i=r[t];return void 0===i?[]:"function"==typeof i?n?[i.listener||i]:[i]:n?d(i):h(i,i.length)}function a(e){var t=this._events;if(void 0!==t){var n=t[e];if("function"==typeof n)return 1;if(void 0!==n)return n.length}return 0}function h(e,t){for(var n=new Array(t),r=0;r0&&(s=t[0]),s instanceof Error)throw s;var u=new Error("Unhandled error."+(s?" ("+s.message+")":""));throw u.context=s,u}var f=o[e];if(void 0===f)return!1;if("function"==typeof f)n(f,this,t);else{var c=f.length,v=h(f,c);for(r=0;r=0;o--)if(n[o]===t||n[o].listener===t){s=n[o].listener,i=o;break}if(i<0)return this;0===i?n.shift():y(n,i),1===n.length&&(r[e]=n[0]),void 0!==r.removeListener&&this.emit("removeListener",e,s||t)}return this},o.prototype.off=o.prototype.removeListener,o.prototype.removeAllListeners=function(e){var t,n,r;if(void 0===(n=this._events))return this;if(void 0===n.removeListener)return 0===arguments.length?(this._events=Object.create(null),this._eventsCount=0):void 0!==n[e]&&(0==--this._eventsCount?this._events=Object.create(null):delete n[e]),this;if(0===arguments.length){var i,o=Object.keys(n);for(r=0;r=0;r--)this.removeListener(e,t[r]);return this},o.prototype.listeners=function(e){return p(this,e,!0)},o.prototype.rawListeners=function(e){return p(this,e,!1)},o.listenerCount=function(e,t){return"function"==typeof e.listenerCount?e.listenerCount(t):a.call(e,t)},o.prototype.listenerCount=a,o.prototype.eventNames=function(){return this._eventsCount>0?e(this._events):[]}; +},{}],"XrGN":[function(require,module,exports) { +module.exports=require("events").EventEmitter; +},{"events":"wIHY"}],"sC8V":[function(require,module,exports) { + +},{}],"nfI5":[function(require,module,exports) { + +"use strict";function t(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(t);e&&(a=a.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),n.push.apply(n,a)}return n}function e(e){for(var a=1;a0?this.tail.next=e:this.head=e,this.tail=e,++this.length}},{key:"unshift",value:function(t){var e={data:t,next:this.head};0===this.length&&(this.tail=e),this.head=e,++this.length}},{key:"shift",value:function(){if(0!==this.length){var t=this.head.data;return 1===this.length?this.head=this.tail=null:this.head=this.head.next,--this.length,t}}},{key:"clear",value:function(){this.head=this.tail=null,this.length=0}},{key:"join",value:function(t){if(0===this.length)return"";for(var e=this.head,n=""+e.data;e=e.next;)n+=t+e.data;return n}},{key:"concat",value:function(t){if(0===this.length)return l.alloc(0);for(var e=l.allocUnsafe(t>>>0),n=this.head,a=0;n;)c(n.data,e,a),a+=n.data.length,n=n.next;return e}},{key:"consume",value:function(t,e){var n;return ti.length?i.length:t;if(r===i.length?a+=i:a+=i.slice(0,t),0===(t-=r)){r===i.length?(++n,e.next?this.head=e.next:this.head=this.tail=null):(this.head=e,e.data=i.slice(r));break}++n}return this.length-=n,a}},{key:"_getBuffer",value:function(t){var e=l.allocUnsafe(t),n=this.head,a=1;for(n.data.copy(e),t-=n.data.length;n=n.next;){var i=n.data,r=t>i.length?i.length:t;if(i.copy(e,e.length-t,0,r),0===(t-=r)){r===i.length?(++a,n.next?this.head=n.next:this.head=this.tail=null):(this.head=n,n.data=i.slice(r));break}++a}return this.length-=a,e}},{key:o,value:function(t,n){return u(this,e({},n,{depth:0,customInspect:!1}))}}]),t}(); +},{"buffer":"z1tx","util":"sC8V"}],"VglT":[function(require,module,exports) { +var process = require("process"); +var t=require("process");function e(e,r){var d=this,l=this._readableState&&this._readableState.destroyed,o=this._writableState&&this._writableState.destroyed;return l||o?(r?r(e):e&&(this._writableState?this._writableState.errorEmitted||(this._writableState.errorEmitted=!0,t.nextTick(s,this,e)):t.nextTick(s,this,e)),this):(this._readableState&&(this._readableState.destroyed=!0),this._writableState&&(this._writableState.destroyed=!0),this._destroy(e||null,function(e){!r&&e?d._writableState?d._writableState.errorEmitted?t.nextTick(i,d):(d._writableState.errorEmitted=!0,t.nextTick(a,d,e)):t.nextTick(a,d,e):r?(t.nextTick(i,d),r(e)):t.nextTick(i,d)}),this)}function a(t,e){s(t,e),i(t)}function i(t){t._writableState&&!t._writableState.emitClose||t._readableState&&!t._readableState.emitClose||t.emit("close")}function r(){this._readableState&&(this._readableState.destroyed=!1,this._readableState.reading=!1,this._readableState.ended=!1,this._readableState.endEmitted=!1),this._writableState&&(this._writableState.destroyed=!1,this._writableState.ended=!1,this._writableState.ending=!1,this._writableState.finalCalled=!1,this._writableState.prefinished=!1,this._writableState.finished=!1,this._writableState.errorEmitted=!1)}function s(t,e){t.emit("error",e)}function d(t,e){var a=t._readableState,i=t._writableState;a&&a.autoDestroy||i&&i.autoDestroy?t.destroy(e):t.emit("error",e)}module.exports={destroy:e,undestroy:r,errorOrDestroy:d}; +},{"process":"g5IB"}],"S4np":[function(require,module,exports) { +"use strict";function t(n){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(n)}function n(t,n){t.prototype=Object.create(n.prototype),t.prototype.constructor=t,t.__proto__=n}var e={};function o(t,o,r){r||(r=Error);var c=function(t){function e(n,e,r){return t.call(this,function(t,n,e){return"string"==typeof o?o:o(t,n,e)}(n,e,r))||this}return n(e,t),e}(r);c.prototype.name=r.name,c.prototype.code=t,e[t]=c}function r(t,n){if(Array.isArray(t)){var e=t.length;return t=t.map(function(t){return String(t)}),e>2?"one of ".concat(n," ").concat(t.slice(0,e-1).join(", "),", or ")+t[e-1]:2===e?"one of ".concat(n," ").concat(t[0]," or ").concat(t[1]):"of ".concat(n," ").concat(t[0])}return"of ".concat(n," ").concat(String(t))}function c(t,n,e){return t.substr(!e||e<0?0:+e,n.length)===n}function a(t,n,e){return(void 0===e||e>t.length)&&(e=t.length),t.substring(e-n.length,e)===n}function u(t,n,e){return"number"!=typeof e&&(e=0),!(e+n.length>t.length)&&-1!==t.indexOf(n,e)}o("ERR_INVALID_OPT_VALUE",function(t,n){return'The value "'+n+'" is invalid for option "'+t+'"'},TypeError),o("ERR_INVALID_ARG_TYPE",function(n,e,o){var i,E;if("string"==typeof e&&c(e,"not ")?(i="must not be",e=e.replace(/^not /,"")):i="must be",a(n," argument"))E="The ".concat(n," ").concat(i," ").concat(r(e,"type"));else{var f=u(n,".")?"property":"argument";E='The "'.concat(n,'" ').concat(f," ").concat(i," ").concat(r(e,"type"))}return E+=". Received type ".concat(t(o))},TypeError),o("ERR_STREAM_PUSH_AFTER_EOF","stream.push() after EOF"),o("ERR_METHOD_NOT_IMPLEMENTED",function(t){return"The "+t+" method is not implemented"}),o("ERR_STREAM_PREMATURE_CLOSE","Premature close"),o("ERR_STREAM_DESTROYED",function(t){return"Cannot call "+t+" after a stream was destroyed"}),o("ERR_MULTIPLE_CALLBACK","Callback called multiple times"),o("ERR_STREAM_CANNOT_PIPE","Cannot pipe, not readable"),o("ERR_STREAM_WRITE_AFTER_END","write after end"),o("ERR_STREAM_NULL_VALUES","May not write null values to stream",TypeError),o("ERR_UNKNOWN_ENCODING",function(t){return"Unknown encoding: "+t},TypeError),o("ERR_STREAM_UNSHIFT_AFTER_END_EVENT","stream.unshift() after end event"),module.exports.codes=e; +},{}],"FQAg":[function(require,module,exports) { +"use strict";var r=require("../../../errors").codes.ERR_INVALID_OPT_VALUE;function e(r,e,t){return null!=r.highWaterMark?r.highWaterMark:e?r[t]:null}function t(t,i,o,a){var n=e(i,a,o);if(null!=n){if(!isFinite(n)||Math.floor(n)!==n||n<0)throw new r(a?o:"highWaterMark",n);return Math.floor(n)}return t.objectMode?16:16384}module.exports={getHighWaterMark:t}; +},{"../../../errors":"S4np"}],"hQaz":[function(require,module,exports) { +var global = arguments[3]; +var r=arguments[3];function t(r,t){if(e("noDeprecation"))return r;var n=!1;return function(){if(!n){if(e("throwDeprecation"))throw new Error(t);e("traceDeprecation")?console.trace(t):console.warn(t),n=!0}return r.apply(this,arguments)}}function e(t){try{if(!r.localStorage)return!1}catch(n){return!1}var e=r.localStorage[t];return null!=e&&"true"===String(e).toLowerCase()}module.exports=t; +},{}],"JX87":[function(require,module,exports) { + +var global = arguments[3]; +var process = require("process"); +var e,t=arguments[3],n=require("process");function r(e,t,n){this.chunk=e,this.encoding=t,this.callback=n,this.next=null}function i(e){var t=this;this.next=null,this.entry=null,this.finish=function(){G(t,e)}}module.exports=x,x.WritableState=m;var o={deprecate:require("util-deprecate")},s=require("./internal/streams/stream"),u=require("buffer").Buffer,f=t.Uint8Array||function(){};function a(e){return u.from(e)}function c(e){return u.isBuffer(e)||e instanceof f}var l,d=require("./internal/streams/destroy"),h=require("./internal/streams/state"),b=h.getHighWaterMark,p=require("../errors").codes,y=p.ERR_INVALID_ARG_TYPE,w=p.ERR_METHOD_NOT_IMPLEMENTED,g=p.ERR_MULTIPLE_CALLBACK,_=p.ERR_STREAM_CANNOT_PIPE,R=p.ERR_STREAM_DESTROYED,k=p.ERR_STREAM_NULL_VALUES,E=p.ERR_STREAM_WRITE_AFTER_END,S=p.ERR_UNKNOWN_ENCODING,q=d.errorOrDestroy;function v(){}function m(t,n,r){e=e||require("./_stream_duplex"),t=t||{},"boolean"!=typeof r&&(r=n instanceof e),this.objectMode=!!t.objectMode,r&&(this.objectMode=this.objectMode||!!t.writableObjectMode),this.highWaterMark=b(this,t,"writableHighWaterMark",r),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var o=!1===t.decodeStrings;this.decodeStrings=!o,this.defaultEncoding=t.defaultEncoding||"utf8",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(e){O(n,e)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.emitClose=!1!==t.emitClose,this.autoDestroy=!!t.autoDestroy,this.bufferedRequestCount=0,this.corkedRequestsFree=new i(this)}function x(t){var n=this instanceof(e=e||require("./_stream_duplex"));if(!n&&!l.call(x,this))return new x(t);this._writableState=new m(t,this,n),this.writable=!0,t&&("function"==typeof t.write&&(this._write=t.write),"function"==typeof t.writev&&(this._writev=t.writev),"function"==typeof t.destroy&&(this._destroy=t.destroy),"function"==typeof t.final&&(this._final=t.final)),s.call(this)}function M(e,t){var r=new E;q(e,r),n.nextTick(t,r)}function B(e,t,r,i){var o;return null===r?o=new k:"string"==typeof r||t.objectMode||(o=new y("chunk",["string","Buffer"],r)),!o||(q(e,o),n.nextTick(i,o),!1)}function T(e,t,n){return e.objectMode||!1===e.decodeStrings||"string"!=typeof t||(t=u.from(t,n)),t}function D(e,t,n,r,i,o){if(!n){var s=T(t,r,i);r!==s&&(n=!0,i="buffer",r=s)}var u=t.objectMode?1:r.length;t.length+=u;var f=t.length-1))throw new S(e);return this._writableState.defaultEncoding=e,this},Object.defineProperty(x.prototype,"writableBuffer",{enumerable:!1,get:function(){return this._writableState&&this._writableState.getBuffer()}}),Object.defineProperty(x.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}}),x.prototype._write=function(e,t,n){n(new w("_write()"))},x.prototype._writev=null,x.prototype.end=function(e,t,n){var r=this._writableState;return"function"==typeof e?(n=e,e=null,t=null):"function"==typeof t&&(n=t,t=null),null!=e&&this.write(e,t),r.corked&&(r.corked=1,this.uncork()),r.ending||H(this,r,n),this},Object.defineProperty(x.prototype,"writableLength",{enumerable:!1,get:function(){return this._writableState.length}}),Object.defineProperty(x.prototype,"destroyed",{enumerable:!1,get:function(){return void 0!==this._writableState&&this._writableState.destroyed},set:function(e){this._writableState&&(this._writableState.destroyed=e)}}),x.prototype.destroy=d.destroy,x.prototype._undestroy=d.undestroy,x.prototype._destroy=function(e,t){t(e)}; +},{"util-deprecate":"hQaz","./internal/streams/stream":"XrGN","buffer":"z1tx","./internal/streams/destroy":"VglT","./internal/streams/state":"FQAg","../errors":"S4np","inherits":"oxwV","./_stream_duplex":"KKw9","process":"g5IB"}],"KKw9":[function(require,module,exports) { +var process = require("process"); +var e=require("process"),t=Object.keys||function(e){var t=[];for(var r in e)t.push(r);return t};module.exports=l;var r=require("./_stream_readable"),a=require("./_stream_writable");require("inherits")(l,r);for(var i=t(a.prototype),n=0;n>5==6?2:t>>4==14?3:t>>3==30?4:t>>6==2?-1:-2}function n(t,e,s){var i=e.length-1;if(i=0?(a>0&&(t.lastNeed=a-1),a):--i=0?(a>0&&(t.lastNeed=a-2),a):--i=0?(a>0&&(2===a?a=0:t.lastNeed=a-3),a):0}function h(t,e,s){if(128!=(192&e[0]))return t.lastNeed=0,"�";if(t.lastNeed>1&&e.length>1){if(128!=(192&e[1]))return t.lastNeed=1,"�";if(t.lastNeed>2&&e.length>2&&128!=(192&e[2]))return t.lastNeed=2,"�"}}function l(t){var e=this.lastTotal-this.lastNeed,s=h(this,t,e);return void 0!==s?s:this.lastNeed<=t.length?(t.copy(this.lastChar,e,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal)):(t.copy(this.lastChar,e,0,t.length),void(this.lastNeed-=t.length))}function u(t,e){var s=n(this,t,e);if(!this.lastNeed)return t.toString("utf8",e);this.lastTotal=s;var i=t.length-(s-this.lastNeed);return t.copy(this.lastChar,0,i),t.toString("utf8",e,i)}function o(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+"�":e}function c(t,e){if((t.length-e)%2==0){var s=t.toString("utf16le",e);if(s){var i=s.charCodeAt(s.length-1);if(i>=55296&&i<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1],s.slice(0,-1)}return s}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=t[t.length-1],t.toString("utf16le",e,t.length-1)}function f(t){var e=t&&t.length?this.write(t):"";if(this.lastNeed){var s=this.lastTotal-this.lastNeed;return e+this.lastChar.toString("utf16le",0,s)}return e}function d(t,e){var s=(t.length-e)%3;return 0===s?t.toString("base64",e):(this.lastNeed=3-s,this.lastTotal=3,1===s?this.lastChar[0]=t[t.length-1]:(this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1]),t.toString("base64",e,t.length-s))}function g(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+this.lastChar.toString("base64",0,3-this.lastNeed):e}function N(t){return t.toString(this.encoding)}function v(t){return t&&t.length?this.write(t):""}exports.StringDecoder=a,a.prototype.write=function(t){if(0===t.length)return"";var e,s;if(this.lastNeed){if(void 0===(e=this.fillLast(t)))return"";s=this.lastNeed,this.lastNeed=0}else s=0;return s0)if("string"==typeof t||o.objectMode||Object.getPrototypeOf(t)===d.prototype||(t=s(t)),r)o.endEmitted?M(e,new R):C(e,o,t,!0);else if(o.ended)M(e,new w);else{if(o.destroyed)return!1;o.reading=!1,o.decoder&&!n?(t=o.decoder.write(t),o.objectMode||0!==t.length?C(e,o,t,!1):U(e,o)):C(e,o,t,!1)}else r||(o.reading=!1,U(e,o));return!o.ended&&(o.length=q?e=q:(e--,e|=e>>>1,e|=e>>>2,e|=e>>>4,e|=e>>>8,e|=e>>>16,e++),e}function x(e,t){return e<=0||0===t.length&&t.ended?0:t.objectMode?1:e!=e?t.flowing&&t.length?t.buffer.head.data.length:t.length:(e>t.highWaterMark&&(t.highWaterMark=W(e)),e<=t.length?e:t.ended?t.length:(t.needReadable=!0,0))}function A(e,t){if(u("onEofChunk"),!t.ended){if(t.decoder){var n=t.decoder.end();n&&n.length&&(t.buffer.push(n),t.length+=t.objectMode?1:n.length)}t.ended=!0,t.sync?O(e):(t.needReadable=!1,t.emittedReadable||(t.emittedReadable=!0,P(e)))}}function O(e){var t=e._readableState;u("emitReadable",t.needReadable,t.emittedReadable),t.needReadable=!1,t.emittedReadable||(u("emitReadable",t.flowing),t.emittedReadable=!0,n.nextTick(P,e))}function P(e){var t=e._readableState;u("emitReadable_",t.destroyed,t.length,t.ended),t.destroyed||!t.length&&!t.ended||(e.emit("readable"),t.emittedReadable=!1),t.needReadable=!t.flowing&&!t.ended&&t.length<=t.highWaterMark,G(e)}function U(e,t){t.readingMore||(t.readingMore=!0,n.nextTick(N,e,t))}function N(e,t){for(;!t.reading&&!t.ended&&(t.length0,t.resumeScheduled&&!t.paused?t.flowing=!0:e.listenerCount("data")>0&&e.resume()}function F(e){u("readable nexttick read 0"),e.read(0)}function B(e,t){t.resumeScheduled||(t.resumeScheduled=!0,n.nextTick(V,e,t))}function V(e,t){u("resume",t.reading),t.reading||e.read(0),t.resumeScheduled=!1,e.emit("resume"),G(e),t.flowing&&!t.reading&&e.read(0)}function G(e){var t=e._readableState;for(u("flow",t.flowing);t.flowing&&null!==e.read(););}function Y(e,t){return 0===t.length?null:(t.objectMode?n=t.buffer.shift():!e||e>=t.length?(n=t.decoder?t.buffer.join(""):1===t.buffer.length?t.buffer.first():t.buffer.concat(t.length),t.buffer.clear()):n=t.buffer.consume(e,t.decoder),n);var n}function z(e){var t=e._readableState;u("endReadable",t.endEmitted),t.endEmitted||(t.ended=!0,n.nextTick(J,t,e))}function J(e,t){if(u("endReadableNT",e.endEmitted,e.length),!e.endEmitted&&0===e.length&&(e.endEmitted=!0,t.readable=!1,t.emit("end"),e.autoDestroy)){var n=t._writableState;(!n||n.autoDestroy&&n.finished)&&t.destroy()}}function K(e,t){for(var n=0,r=e.length;n=t.highWaterMark:t.length>0)||t.ended))return u("read: emitReadable",t.length,t.ended),0===t.length&&t.ended?z(this):O(this),null;if(0===(e=x(e,t))&&t.ended)return 0===t.length&&z(this),null;var r,i=t.needReadable;return u("need readable",i),(0===t.length||t.length-e0?Y(e,t):null)?(t.needReadable=t.length<=t.highWaterMark,e=0):(t.length-=e,t.awaitDrain=0),0===t.length&&(t.ended||(t.needReadable=!0),n!==e&&t.ended&&z(this)),null!==r&&this.emit("data",r),r},j.prototype._read=function(e){M(this,new S("_read()"))},j.prototype.pipe=function(e,t){var r=this,a=this._readableState;switch(a.pipesCount){case 0:a.pipes=e;break;case 1:a.pipes=[a.pipes,e];break;default:a.pipes.push(e)}a.pipesCount+=1,u("pipe count=%d opts=%j",a.pipesCount,t);var d=(!t||!1!==t.end)&&e!==n.stdout&&e!==n.stderr?s:g;function o(t,n){u("onunpipe"),t===r&&n&&!1===n.hasUnpiped&&(n.hasUnpiped=!0,u("cleanup"),e.removeListener("close",c),e.removeListener("finish",b),e.removeListener("drain",l),e.removeListener("error",f),e.removeListener("unpipe",o),r.removeListener("end",s),r.removeListener("end",g),r.removeListener("data",p),h=!0,!a.awaitDrain||e._writableState&&!e._writableState.needDrain||l())}function s(){u("onend"),e.end()}a.endEmitted?n.nextTick(d):r.once("end",d),e.on("unpipe",o);var l=H(r);e.on("drain",l);var h=!1;function p(t){u("ondata");var n=e.write(t);u("dest.write",n),!1===n&&((1===a.pipesCount&&a.pipes===e||a.pipesCount>1&&-1!==K(a.pipes,e))&&!h&&(u("false write response, pause",a.awaitDrain),a.awaitDrain++),r.pause())}function f(t){u("onerror",t),g(),e.removeListener("error",f),0===i(e,"error")&&M(e,t)}function c(){e.removeListener("finish",b),g()}function b(){u("onfinish"),e.removeListener("close",c),g()}function g(){u("unpipe"),r.unpipe(e)}return r.on("data",p),k(e,"error",f),e.once("close",c),e.once("finish",b),e.emit("pipe",r),a.flowing||(u("pipe resume"),r.resume()),e},j.prototype.unpipe=function(e){var t=this._readableState,n={hasUnpiped:!1};if(0===t.pipesCount)return this;if(1===t.pipesCount)return e&&e!==t.pipes?this:(e||(e=t.pipes),t.pipes=null,t.pipesCount=0,t.flowing=!1,e&&e.emit("unpipe",this,n),this);if(!e){var r=t.pipes,i=t.pipesCount;t.pipes=null,t.pipesCount=0,t.flowing=!1;for(var a=0;a0,!1!==i.flowing&&this.resume()):"readable"===e&&(i.endEmitted||i.readableListening||(i.readableListening=i.needReadable=!0,i.flowing=!1,i.emittedReadable=!1,u("on readable",i.length,i.reading),i.length?O(this):i.reading||n.nextTick(F,this))),r},j.prototype.addListener=j.prototype.on,j.prototype.removeListener=function(e,t){var r=a.prototype.removeListener.call(this,e,t);return"readable"===e&&n.nextTick(I,this),r},j.prototype.removeAllListeners=function(e){var t=a.prototype.removeAllListeners.apply(this,arguments);return"readable"!==e&&void 0!==e||n.nextTick(I,this),t},j.prototype.resume=function(){var e=this._readableState;return e.flowing||(u("resume"),e.flowing=!e.readableListening,B(this,e)),e.paused=!1,this},j.prototype.pause=function(){return u("call pause flowing=%j",this._readableState.flowing),!1!==this._readableState.flowing&&(u("pause"),this._readableState.flowing=!1,this.emit("pause")),this._readableState.paused=!0,this},j.prototype.wrap=function(e){var t=this,n=this._readableState,r=!1;for(var i in e.on("end",function(){if(u("wrapped end"),n.decoder&&!n.ended){var e=n.decoder.end();e&&e.length&&t.push(e)}t.push(null)}),e.on("data",function(i){(u("wrapped data"),n.decoder&&(i=n.decoder.write(i)),n.objectMode&&null==i)||(n.objectMode||i&&i.length)&&(t.push(i)||(r=!0,e.pause()))}),e)void 0===this[i]&&"function"==typeof e[i]&&(this[i]=function(t){return function(){return e[t].apply(e,arguments)}}(i));for(var a=0;a0,function(r){o||(o=r),r&&u.forEach(a),e||(u.forEach(a),i(o))})});return n.reduce(c)}module.exports=s; +},{"../../../errors":"S4np","./end-of-stream":"tOhx"}],"tUuk":[function(require,module,exports) { +exports=module.exports=require("./lib/_stream_readable.js"),exports.Stream=exports,exports.Readable=exports,exports.Writable=require("./lib/_stream_writable.js"),exports.Duplex=require("./lib/_stream_duplex.js"),exports.Transform=require("./lib/_stream_transform.js"),exports.PassThrough=require("./lib/_stream_passthrough.js"),exports.finished=require("./lib/internal/streams/end-of-stream.js"),exports.pipeline=require("./lib/internal/streams/pipeline.js"); +},{"./lib/_stream_readable.js":"KkzJ","./lib/_stream_writable.js":"JX87","./lib/_stream_duplex.js":"KKw9","./lib/_stream_transform.js":"M1RH","./lib/_stream_passthrough.js":"WkHr","./lib/internal/streams/end-of-stream.js":"tOhx","./lib/internal/streams/pipeline.js":"t9fV"}],"AZ76":[function(require,module,exports) { + +"use strict";var t=require("safe-buffer").Buffer,e=require("readable-stream").Transform,i=require("inherits");function r(e,i){if(!t.isBuffer(e)&&"string"!=typeof e)throw new TypeError(i+" must be a string or a buffer")}function o(i){e.call(this),this._block=t.allocUnsafe(i),this._blockSize=i,this._blockOffset=0,this._length=[0,0,0,0],this._finalized=!1}i(o,e),o.prototype._transform=function(t,e,i){var r=null;try{this.update(t,e)}catch(o){r=o}i(r)},o.prototype._flush=function(t){var e=null;try{this.push(this.digest())}catch(i){e=i}t(e)},o.prototype.update=function(e,i){if(r(e,"Data"),this._finalized)throw new Error("Digest already called");t.isBuffer(e)||(e=t.from(e,i));for(var o=this._block,s=0;this._blockOffset+e.length-s>=this._blockSize;){for(var f=this._blockOffset;f0;++n)this._length[n]+=h,(h=this._length[n]/4294967296|0)>0&&(this._length[n]-=4294967296*h);return this},o.prototype._update=function(){throw new Error("_update is not implemented")},o.prototype.digest=function(t){if(this._finalized)throw new Error("Digest already called");this._finalized=!0;var e=this._digest();void 0!==t&&(e=e.toString(t)),this._block.fill(0),this._blockOffset=0;for(var i=0;i<4;++i)this._length[i]=0;return e},o.prototype._digest=function(){throw new Error("_digest is not implemented")},module.exports=o; +},{"safe-buffer":"gIYa","readable-stream":"tUuk","inherits":"oxwV"}],"CYub":[function(require,module,exports) { + +"use strict";var t=require("inherits"),i=require("hash-base"),s=require("safe-buffer").Buffer,e=new Array(16);function h(){i.call(this,64),this._a=1732584193,this._b=4023233417,this._c=2562383102,this._d=271733878}function r(t,i){return t<>>32-i}function _(t,i,s,e,h,_,n){return r(t+(i&s|~i&e)+h+_|0,n)+i|0}function n(t,i,s,e,h,_,n){return r(t+(i&e|s&~e)+h+_|0,n)+i|0}function c(t,i,s,e,h,_,n){return r(t+(i^s^e)+h+_|0,n)+i|0}function f(t,i,s,e,h,_,n){return r(t+(s^(i|~e))+h+_|0,n)+i|0}t(h,i),h.prototype._update=function(){for(var t=e,i=0;i<16;++i)t[i]=this._block.readInt32LE(4*i);var s=this._a,h=this._b,r=this._c,o=this._d;s=_(s,h,r,o,t[0],3614090360,7),o=_(o,s,h,r,t[1],3905402710,12),r=_(r,o,s,h,t[2],606105819,17),h=_(h,r,o,s,t[3],3250441966,22),s=_(s,h,r,o,t[4],4118548399,7),o=_(o,s,h,r,t[5],1200080426,12),r=_(r,o,s,h,t[6],2821735955,17),h=_(h,r,o,s,t[7],4249261313,22),s=_(s,h,r,o,t[8],1770035416,7),o=_(o,s,h,r,t[9],2336552879,12),r=_(r,o,s,h,t[10],4294925233,17),h=_(h,r,o,s,t[11],2304563134,22),s=_(s,h,r,o,t[12],1804603682,7),o=_(o,s,h,r,t[13],4254626195,12),r=_(r,o,s,h,t[14],2792965006,17),s=n(s,h=_(h,r,o,s,t[15],1236535329,22),r,o,t[1],4129170786,5),o=n(o,s,h,r,t[6],3225465664,9),r=n(r,o,s,h,t[11],643717713,14),h=n(h,r,o,s,t[0],3921069994,20),s=n(s,h,r,o,t[5],3593408605,5),o=n(o,s,h,r,t[10],38016083,9),r=n(r,o,s,h,t[15],3634488961,14),h=n(h,r,o,s,t[4],3889429448,20),s=n(s,h,r,o,t[9],568446438,5),o=n(o,s,h,r,t[14],3275163606,9),r=n(r,o,s,h,t[3],4107603335,14),h=n(h,r,o,s,t[8],1163531501,20),s=n(s,h,r,o,t[13],2850285829,5),o=n(o,s,h,r,t[2],4243563512,9),r=n(r,o,s,h,t[7],1735328473,14),s=c(s,h=n(h,r,o,s,t[12],2368359562,20),r,o,t[5],4294588738,4),o=c(o,s,h,r,t[8],2272392833,11),r=c(r,o,s,h,t[11],1839030562,16),h=c(h,r,o,s,t[14],4259657740,23),s=c(s,h,r,o,t[1],2763975236,4),o=c(o,s,h,r,t[4],1272893353,11),r=c(r,o,s,h,t[7],4139469664,16),h=c(h,r,o,s,t[10],3200236656,23),s=c(s,h,r,o,t[13],681279174,4),o=c(o,s,h,r,t[0],3936430074,11),r=c(r,o,s,h,t[3],3572445317,16),h=c(h,r,o,s,t[6],76029189,23),s=c(s,h,r,o,t[9],3654602809,4),o=c(o,s,h,r,t[12],3873151461,11),r=c(r,o,s,h,t[15],530742520,16),s=f(s,h=c(h,r,o,s,t[2],3299628645,23),r,o,t[0],4096336452,6),o=f(o,s,h,r,t[7],1126891415,10),r=f(r,o,s,h,t[14],2878612391,15),h=f(h,r,o,s,t[5],4237533241,21),s=f(s,h,r,o,t[12],1700485571,6),o=f(o,s,h,r,t[3],2399980690,10),r=f(r,o,s,h,t[10],4293915773,15),h=f(h,r,o,s,t[1],2240044497,21),s=f(s,h,r,o,t[8],1873313359,6),o=f(o,s,h,r,t[15],4264355552,10),r=f(r,o,s,h,t[6],2734768916,15),h=f(h,r,o,s,t[13],1309151649,21),s=f(s,h,r,o,t[4],4149444226,6),o=f(o,s,h,r,t[11],3174756917,10),r=f(r,o,s,h,t[2],718787259,15),h=f(h,r,o,s,t[9],3951481745,21),this._a=this._a+s|0,this._b=this._b+h|0,this._c=this._c+r|0,this._d=this._d+o|0},h.prototype._digest=function(){this._block[this._blockOffset++]=128,this._blockOffset>56&&(this._block.fill(0,this._blockOffset,64),this._update(),this._blockOffset=0),this._block.fill(0,this._blockOffset,56),this._block.writeUInt32LE(this._length[0],56),this._block.writeUInt32LE(this._length[1],60),this._update();var t=s.allocUnsafe(16);return t.writeInt32LE(this._a,0),t.writeInt32LE(this._b,4),t.writeInt32LE(this._c,8),t.writeInt32LE(this._d,12),t},module.exports=h; +},{"inherits":"oxwV","hash-base":"AZ76","safe-buffer":"gIYa"}],"DubT":[function(require,module,exports) { + +"use strict";var t=require("buffer").Buffer,i=require("inherits"),s=require("hash-base"),h=new Array(16),e=[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13],_=[5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11],r=[11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6],n=[8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11],c=[0,1518500249,1859775393,2400959708,2840853838],o=[1352829926,1548603684,1836072691,2053994217,0];function f(){s.call(this,64),this._a=1732584193,this._b=4023233417,this._c=2562383102,this._d=271733878,this._e=3285377520}function u(t,i){return t<>>32-i}function l(t,i,s,h,e,_,r,n){return u(t+(i^s^h)+_+r|0,n)+e|0}function a(t,i,s,h,e,_,r,n){return u(t+(i&s|~i&h)+_+r|0,n)+e|0}function b(t,i,s,h,e,_,r,n){return u(t+((i|~s)^h)+_+r|0,n)+e|0}function d(t,i,s,h,e,_,r,n){return u(t+(i&h|s&~h)+_+r|0,n)+e|0}function k(t,i,s,h,e,_,r,n){return u(t+(i^(s|~h))+_+r|0,n)+e|0}i(f,s),f.prototype._update=function(){for(var t=h,i=0;i<16;++i)t[i]=this._block.readInt32LE(4*i);for(var s=0|this._a,f=0|this._b,w=0|this._c,p=0|this._d,E=0|this._e,I=0|this._a,L=0|this._b,v=0|this._c,O=0|this._d,g=0|this._e,q=0;q<80;q+=1){var y,U;q<16?(y=l(s,f,w,p,E,t[e[q]],c[0],r[q]),U=k(I,L,v,O,g,t[_[q]],o[0],n[q])):q<32?(y=a(s,f,w,p,E,t[e[q]],c[1],r[q]),U=d(I,L,v,O,g,t[_[q]],o[1],n[q])):q<48?(y=b(s,f,w,p,E,t[e[q]],c[2],r[q]),U=b(I,L,v,O,g,t[_[q]],o[2],n[q])):q<64?(y=d(s,f,w,p,E,t[e[q]],c[3],r[q]),U=a(I,L,v,O,g,t[_[q]],o[3],n[q])):(y=k(s,f,w,p,E,t[e[q]],c[4],r[q]),U=l(I,L,v,O,g,t[_[q]],o[4],n[q])),s=E,E=p,p=u(w,10),w=f,f=y,I=g,g=O,O=u(v,10),v=L,L=U}var m=this._b+w+O|0;this._b=this._c+p+g|0,this._c=this._d+E+I|0,this._d=this._e+s+L|0,this._e=this._a+f+v|0,this._a=m},f.prototype._digest=function(){this._block[this._blockOffset++]=128,this._blockOffset>56&&(this._block.fill(0,this._blockOffset,64),this._update(),this._blockOffset=0),this._block.fill(0,this._blockOffset,56),this._block.writeUInt32LE(this._length[0],56),this._block.writeUInt32LE(this._length[1],60),this._update();var i=t.alloc?t.alloc(20):new t(20);return i.writeInt32LE(this._a,0),i.writeInt32LE(this._b,4),i.writeInt32LE(this._c,8),i.writeInt32LE(this._d,12),i.writeInt32LE(this._e,16),i},module.exports=f; +},{"buffer":"z1tx","inherits":"oxwV","hash-base":"AZ76"}],"yzxE":[function(require,module,exports) { + +var t=require("safe-buffer").Buffer;function i(i,e){this._block=t.alloc(i),this._finalSize=e,this._blockSize=i,this._len=0}i.prototype.update=function(i,e){"string"==typeof i&&(e=e||"utf8",i=t.from(i,e));for(var s=this._block,o=this._blockSize,l=i.length,h=this._len,r=0;r=this._finalSize&&(this._update(this._block),this._block.fill(0));var e=8*this._len;if(e<=4294967295)this._block.writeUInt32BE(e,this._blockSize-4);else{var s=(4294967295&e)>>>0,o=(e-s)/4294967296;this._block.writeUInt32BE(o,this._blockSize-8),this._block.writeUInt32BE(s,this._blockSize-4)}this._update(this._block);var l=this._hash();return t?l.toString(t):l},i.prototype._update=function(){throw new Error("_update must be implemented by subclass")},module.exports=i; +},{"safe-buffer":"gIYa"}],"pTHz":[function(require,module,exports) { + +var t=require("inherits"),i=require("./hash"),r=require("safe-buffer").Buffer,s=[1518500249,1859775393,-1894007588,-899497514],h=new Array(80);function e(){this.init(),this._w=h,i.call(this,64,56)}function n(t){return t<<5|t>>>27}function _(t){return t<<30|t>>>2}function a(t,i,r,s){return 0===t?i&r|~i&s:2===t?i&r|i&s|r&s:i^r^s}t(e,i),e.prototype.init=function(){return this._a=1732584193,this._b=4023233417,this._c=2562383102,this._d=271733878,this._e=3285377520,this},e.prototype._update=function(t){for(var i=this._w,r=0|this._a,h=0|this._b,e=0|this._c,o=0|this._d,u=0|this._e,f=0;f<16;++f)i[f]=t.readInt32BE(4*f);for(;f<80;++f)i[f]=i[f-3]^i[f-8]^i[f-14]^i[f-16];for(var c=0;c<80;++c){var d=~~(c/20),p=n(r)+a(d,h,e,o)+u+i[c]+s[d]|0;u=o,o=e,e=_(h),h=r,r=p}this._a=r+this._a|0,this._b=h+this._b|0,this._c=e+this._c|0,this._d=o+this._d|0,this._e=u+this._e|0},e.prototype._hash=function(){var t=r.allocUnsafe(20);return t.writeInt32BE(0|this._a,0),t.writeInt32BE(0|this._b,4),t.writeInt32BE(0|this._c,8),t.writeInt32BE(0|this._d,12),t.writeInt32BE(0|this._e,16),t},module.exports=e; +},{"inherits":"oxwV","./hash":"yzxE","safe-buffer":"gIYa"}],"iGWE":[function(require,module,exports) { + +var t=require("inherits"),i=require("./hash"),r=require("safe-buffer").Buffer,s=[1518500249,1859775393,-1894007588,-899497514],e=new Array(80);function h(){this.init(),this._w=e,i.call(this,64,56)}function n(t){return t<<1|t>>>31}function _(t){return t<<5|t>>>27}function u(t){return t<<30|t>>>2}function o(t,i,r,s){return 0===t?i&r|~i&s:2===t?i&r|i&s|r&s:i^r^s}t(h,i),h.prototype.init=function(){return this._a=1732584193,this._b=4023233417,this._c=2562383102,this._d=271733878,this._e=3285377520,this},h.prototype._update=function(t){for(var i=this._w,r=0|this._a,e=0|this._b,h=0|this._c,a=0|this._d,f=0|this._e,c=0;c<16;++c)i[c]=t.readInt32BE(4*c);for(;c<80;++c)i[c]=n(i[c-3]^i[c-8]^i[c-14]^i[c-16]);for(var d=0;d<80;++d){var p=~~(d/20),w=_(r)+o(p,e,h,a)+f+i[d]+s[p]|0;f=a,a=h,h=u(e),e=r,r=w}this._a=r+this._a|0,this._b=e+this._b|0,this._c=h+this._c|0,this._d=a+this._d|0,this._e=f+this._e|0},h.prototype._hash=function(){var t=r.allocUnsafe(20);return t.writeInt32BE(0|this._a,0),t.writeInt32BE(0|this._b,4),t.writeInt32BE(0|this._c,8),t.writeInt32BE(0|this._d,12),t.writeInt32BE(0|this._e,16),t},module.exports=h; +},{"inherits":"oxwV","./hash":"yzxE","safe-buffer":"gIYa"}],"fGKM":[function(require,module,exports) { + +var t=require("inherits"),i=require("./hash"),h=require("safe-buffer").Buffer,s=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298],r=new Array(64);function _(){this.init(),this._w=r,i.call(this,64,56)}function n(t,i,h){return h^t&(i^h)}function e(t,i,h){return t&i|h&(t|i)}function u(t){return(t>>>2|t<<30)^(t>>>13|t<<19)^(t>>>22|t<<10)}function f(t){return(t>>>6|t<<26)^(t>>>11|t<<21)^(t>>>25|t<<7)}function o(t){return(t>>>7|t<<25)^(t>>>18|t<<14)^t>>>3}function a(t){return(t>>>17|t<<15)^(t>>>19|t<<13)^t>>>10}t(_,i),_.prototype.init=function(){return this._a=1779033703,this._b=3144134277,this._c=1013904242,this._d=2773480762,this._e=1359893119,this._f=2600822924,this._g=528734635,this._h=1541459225,this},_.prototype._update=function(t){for(var i=this._w,h=0|this._a,r=0|this._b,_=0|this._c,c=0|this._d,w=0|this._e,B=0|this._f,E=0|this._g,I=0|this._h,d=0;d<16;++d)i[d]=t.readInt32BE(4*d);for(;d<64;++d)i[d]=a(i[d-2])+i[d-7]+o(i[d-15])+i[d-16]|0;for(var p=0;p<64;++p){var b=I+f(w)+n(w,B,E)+s[p]+i[p]|0,g=u(h)+e(h,r,_)|0;I=E,E=B,B=w,w=c+b|0,c=_,_=r,r=h,h=b+g|0}this._a=h+this._a|0,this._b=r+this._b|0,this._c=_+this._c|0,this._d=c+this._d|0,this._e=w+this._e|0,this._f=B+this._f|0,this._g=E+this._g|0,this._h=I+this._h|0},_.prototype._hash=function(){var t=h.allocUnsafe(32);return t.writeInt32BE(this._a,0),t.writeInt32BE(this._b,4),t.writeInt32BE(this._c,8),t.writeInt32BE(this._d,12),t.writeInt32BE(this._e,16),t.writeInt32BE(this._f,20),t.writeInt32BE(this._g,24),t.writeInt32BE(this._h,28),t},module.exports=_; +},{"inherits":"oxwV","./hash":"yzxE","safe-buffer":"gIYa"}],"LPeK":[function(require,module,exports) { + +var t=require("inherits"),i=require("./sha256"),e=require("./hash"),r=require("safe-buffer").Buffer,h=new Array(64);function s(){this.init(),this._w=h,e.call(this,64,56)}t(s,i),s.prototype.init=function(){return this._a=3238371032,this._b=914150663,this._c=812702999,this._d=4144912697,this._e=4290775857,this._f=1750603025,this._g=1694076839,this._h=3204075428,this},s.prototype._hash=function(){var t=r.allocUnsafe(28);return t.writeInt32BE(this._a,0),t.writeInt32BE(this._b,4),t.writeInt32BE(this._c,8),t.writeInt32BE(this._d,12),t.writeInt32BE(this._e,16),t.writeInt32BE(this._f,20),t.writeInt32BE(this._g,24),t},module.exports=s; +},{"inherits":"oxwV","./sha256":"fGKM","./hash":"yzxE","safe-buffer":"gIYa"}],"Ncel":[function(require,module,exports) { + +var h=require("inherits"),t=require("./hash"),i=require("safe-buffer").Buffer,s=[1116352408,3609767458,1899447441,602891725,3049323471,3964484399,3921009573,2173295548,961987163,4081628472,1508970993,3053834265,2453635748,2937671579,2870763221,3664609560,3624381080,2734883394,310598401,1164996542,607225278,1323610764,1426881987,3590304994,1925078388,4068182383,2162078206,991336113,2614888103,633803317,3248222580,3479774868,3835390401,2666613458,4022224774,944711139,264347078,2341262773,604807628,2007800933,770255983,1495990901,1249150122,1856431235,1555081692,3175218132,1996064986,2198950837,2554220882,3999719339,2821834349,766784016,2952996808,2566594879,3210313671,3203337956,3336571891,1034457026,3584528711,2466948901,113926993,3758326383,338241895,168717936,666307205,1188179964,773529912,1546045734,1294757372,1522805485,1396182291,2643833823,1695183700,2343527390,1986661051,1014477480,2177026350,1206759142,2456956037,344077627,2730485921,1290863460,2820302411,3158454273,3259730800,3505952657,3345764771,106217008,3516065817,3606008344,3600352804,1432725776,4094571909,1467031594,275423344,851169720,430227734,3100823752,506948616,1363258195,659060556,3750685593,883997877,3785050280,958139571,3318307427,1322822218,3812723403,1537002063,2003034995,1747873779,3602036899,1955562222,1575990012,2024104815,1125592928,2227730452,2716904306,2361852424,442776044,2428436474,593698344,2756734187,3733110249,3204031479,2999351573,3329325298,3815920427,3391569614,3928383900,3515267271,566280711,3940187606,3454069534,4118630271,4000239992,116418474,1914138554,174292421,2731055270,289380356,3203993006,460393269,320620315,685471733,587496836,852142971,1086792851,1017036298,365543100,1126000580,2618297676,1288033470,3409855158,1501505948,4234509866,1607167915,987167468,1816402316,1246189591],_=new Array(160);function l(){this.init(),this._w=_,t.call(this,128,112)}function r(h,t,i){return i^h&(t^i)}function n(h,t,i){return h&t|i&(h|t)}function e(h,t){return(h>>>28|t<<4)^(t>>>2|h<<30)^(t>>>7|h<<25)}function f(h,t){return(h>>>14|t<<18)^(h>>>18|t<<14)^(t>>>9|h<<23)}function u(h,t){return(h>>>1|t<<31)^(h>>>8|t<<24)^h>>>7}function a(h,t){return(h>>>1|t<<31)^(h>>>8|t<<24)^(h>>>7|t<<25)}function c(h,t){return(h>>>19|t<<13)^(t>>>29|h<<3)^h>>>6}function o(h,t){return(h>>>19|t<<13)^(t>>>29|h<<3)^(h>>>6|t<<26)}function d(h,t){return h>>>0>>0?1:0}h(l,t),l.prototype.init=function(){return this._ah=1779033703,this._bh=3144134277,this._ch=1013904242,this._dh=2773480762,this._eh=1359893119,this._fh=2600822924,this._gh=528734635,this._hh=1541459225,this._al=4089235720,this._bl=2227873595,this._cl=4271175723,this._dl=1595750129,this._el=2917565137,this._fl=725511199,this._gl=4215389547,this._hl=327033209,this},l.prototype._update=function(h){for(var t=this._w,i=0|this._ah,_=0|this._bh,l=0|this._ch,b=0|this._dh,g=0|this._eh,p=0|this._fh,v=0|this._gh,w=0|this._hh,B=0|this._al,y=0|this._bl,E=0|this._cl,I=0|this._dl,q=0|this._el,m=0|this._fl,x=0|this._gl,A=0|this._hl,U=0;U<32;U+=2)t[U]=h.readInt32BE(4*U),t[U+1]=h.readInt32BE(4*U+4);for(;U<160;U+=2){var j=t[U-30],k=t[U-30+1],z=u(j,k),C=a(k,j),D=c(j=t[U-4],k=t[U-4+1]),F=o(k,j),G=t[U-14],H=t[U-14+1],J=t[U-32],K=t[U-32+1],L=C+H|0,M=z+G+d(L,C)|0;M=(M=M+D+d(L=L+F|0,F)|0)+J+d(L=L+K|0,K)|0,t[U]=M,t[U+1]=L}for(var N=0;N<160;N+=2){M=t[N],L=t[N+1];var O=n(i,_,l),P=n(B,y,E),Q=e(i,B),R=e(B,i),S=f(g,q),T=f(q,g),V=s[N],W=s[N+1],X=r(g,p,v),Y=r(q,m,x),Z=A+T|0,$=w+S+d(Z,A)|0;$=($=($=$+X+d(Z=Z+Y|0,Y)|0)+V+d(Z=Z+W|0,W)|0)+M+d(Z=Z+L|0,L)|0;var hh=R+P|0,th=Q+O+d(hh,R)|0;w=v,A=x,v=p,x=m,p=g,m=q,g=b+$+d(q=I+Z|0,I)|0,b=l,I=E,l=_,E=y,_=i,y=B,i=$+th+d(B=Z+hh|0,Z)|0}this._al=this._al+B|0,this._bl=this._bl+y|0,this._cl=this._cl+E|0,this._dl=this._dl+I|0,this._el=this._el+q|0,this._fl=this._fl+m|0,this._gl=this._gl+x|0,this._hl=this._hl+A|0,this._ah=this._ah+i+d(this._al,B)|0,this._bh=this._bh+_+d(this._bl,y)|0,this._ch=this._ch+l+d(this._cl,E)|0,this._dh=this._dh+b+d(this._dl,I)|0,this._eh=this._eh+g+d(this._el,q)|0,this._fh=this._fh+p+d(this._fl,m)|0,this._gh=this._gh+v+d(this._gl,x)|0,this._hh=this._hh+w+d(this._hl,A)|0},l.prototype._hash=function(){var h=i.allocUnsafe(64);function t(t,i,s){h.writeInt32BE(t,s),h.writeInt32BE(i,s+4)}return t(this._ah,this._al,0),t(this._bh,this._bl,8),t(this._ch,this._cl,16),t(this._dh,this._dl,24),t(this._eh,this._el,32),t(this._fh,this._fl,40),t(this._gh,this._gl,48),t(this._hh,this._hl,56),h},module.exports=l; +},{"inherits":"oxwV","./hash":"yzxE","safe-buffer":"gIYa"}],"alxw":[function(require,module,exports) { + +var h=require("inherits"),t=require("./sha512"),i=require("./hash"),s=require("safe-buffer").Buffer,_=new Array(160);function e(){this.init(),this._w=_,i.call(this,128,112)}h(e,t),e.prototype.init=function(){return this._ah=3418070365,this._bh=1654270250,this._ch=2438529370,this._dh=355462360,this._eh=1731405415,this._fh=2394180231,this._gh=3675008525,this._hh=1203062813,this._al=3238371032,this._bl=914150663,this._cl=812702999,this._dl=4144912697,this._el=4290775857,this._fl=1750603025,this._gl=1694076839,this._hl=3204075428,this},e.prototype._hash=function(){var h=s.allocUnsafe(48);function t(t,i,s){h.writeInt32BE(t,s),h.writeInt32BE(i,s+4)}return t(this._ah,this._al,0),t(this._bh,this._bl,8),t(this._ch,this._cl,16),t(this._dh,this._dl,24),t(this._eh,this._el,32),t(this._fh,this._fl,40),h},module.exports=e; +},{"inherits":"oxwV","./sha512":"Ncel","./hash":"yzxE","safe-buffer":"gIYa"}],"IaNs":[function(require,module,exports) { +var e=module.exports=function(r){r=r.toLowerCase();var s=e[r];if(!s)throw new Error(r+" is not supported (we accept pull requests)");return new s};e.sha=require("./sha"),e.sha1=require("./sha1"),e.sha224=require("./sha224"),e.sha256=require("./sha256"),e.sha384=require("./sha384"),e.sha512=require("./sha512"); +},{"./sha":"pTHz","./sha1":"iGWE","./sha224":"LPeK","./sha256":"fGKM","./sha384":"alxw","./sha512":"Ncel"}],"iFTO":[function(require,module,exports) { +var process = require("process"); +var n=require("process");function e(e,r,t,c){if("function"!=typeof e)throw new TypeError('"callback" argument must be a function');var i,l,u=arguments.length;switch(u){case 0:case 1:return n.nextTick(e);case 2:return n.nextTick(function(){e.call(null,r)});case 3:return n.nextTick(function(){e.call(null,r,t)});case 4:return n.nextTick(function(){e.call(null,r,t,c)});default:for(i=new Array(u-1),l=0;l0?this.tail.next=n:this.head=n,this.tail=n,++this.length},e.prototype.unshift=function(t){var n={data:t,next:this.head};0===this.length&&(this.tail=n),this.head=n,++this.length},e.prototype.shift=function(){if(0!==this.length){var t=this.head.data;return 1===this.length?this.head=this.tail=null:this.head=this.head.next,--this.length,t}},e.prototype.clear=function(){this.head=this.tail=null,this.length=0},e.prototype.join=function(t){if(0===this.length)return"";for(var n=this.head,e=""+n.data;n=n.next;)e+=t+n.data;return e},e.prototype.concat=function(t){if(0===this.length)return n.alloc(0);if(1===this.length)return this.head.data;for(var e=n.allocUnsafe(t>>>0),h=this.head,a=0;h;)i(h.data,e,a),a+=h.data.length,h=h.next;return e},e}(),e&&e.inspect&&e.inspect.custom&&(module.exports.prototype[e.inspect.custom]=function(){var t=e.inspect({length:this.length});return this.constructor.name+" "+t}); +},{"safe-buffer":"nFKF","util":"sC8V"}],"Umu5":[function(require,module,exports) { +"use strict";var t=require("process-nextick-args");function e(e,a){var r=this,s=this._readableState&&this._readableState.destroyed,d=this._writableState&&this._writableState.destroyed;return s||d?(a?a(e):!e||this._writableState&&this._writableState.errorEmitted||t.nextTick(i,this,e),this):(this._readableState&&(this._readableState.destroyed=!0),this._writableState&&(this._writableState.destroyed=!0),this._destroy(e||null,function(e){!a&&e?(t.nextTick(i,r,e),r._writableState&&(r._writableState.errorEmitted=!0)):a&&a(e)}),this)}function a(){this._readableState&&(this._readableState.destroyed=!1,this._readableState.reading=!1,this._readableState.ended=!1,this._readableState.endEmitted=!1),this._writableState&&(this._writableState.destroyed=!1,this._writableState.ended=!1,this._writableState.ending=!1,this._writableState.finished=!1,this._writableState.errorEmitted=!1)}function i(t,e){t.emit("error",e)}module.exports={destroy:e,undestroy:a}; +},{"process-nextick-args":"iFTO"}],"pX9p":[function(require,module,exports) { +var process = require("process"); + +var global = arguments[3]; +var e=require("process"),t=arguments[3],n=require("process-nextick-args");function r(e,t,n){this.chunk=e,this.encoding=t,this.callback=n,this.next=null}function i(e){var t=this;this.next=null,this.entry=null,this.finish=function(){W(t,e)}}module.exports=g;var o,s=n.nextTick;g.WritableState=y;var f=Object.create(require("core-util-is"));f.inherits=require("inherits");var u={deprecate:require("util-deprecate")},a=require("./internal/streams/stream"),c=require("safe-buffer").Buffer,l=t.Uint8Array||function(){};function d(e){return c.from(e)}function h(e){return c.isBuffer(e)||e instanceof l}var b,p=require("./internal/streams/destroy");function w(){}function y(e,t){o=o||require("./_stream_duplex"),e=e||{};var n=t instanceof o;this.objectMode=!!e.objectMode,n&&(this.objectMode=this.objectMode||!!e.writableObjectMode);var r=e.highWaterMark,s=e.writableHighWaterMark,f=this.objectMode?16:16384;this.highWaterMark=r||0===r?r:n&&(s||0===s)?s:f,this.highWaterMark=Math.floor(this.highWaterMark),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var u=!1===e.decodeStrings;this.decodeStrings=!u,this.defaultEncoding=e.defaultEncoding||"utf8",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(e){S(t,e)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.bufferedRequestCount=0,this.corkedRequestsFree=new i(this)}function g(e){if(o=o||require("./_stream_duplex"),!(b.call(g,this)||this instanceof o))return new g(e);this._writableState=new y(e,this),this.writable=!0,e&&("function"==typeof e.write&&(this._write=e.write),"function"==typeof e.writev&&(this._writev=e.writev),"function"==typeof e.destroy&&(this._destroy=e.destroy),"function"==typeof e.final&&(this._final=e.final)),a.call(this)}function k(e,t){var r=new Error("write after end");e.emit("error",r),n.nextTick(t,r)}function v(e,t,r,i){var o=!0,s=!1;return null===r?s=new TypeError("May not write null values to stream"):"string"==typeof r||void 0===r||t.objectMode||(s=new TypeError("Invalid non-string/buffer chunk")),s&&(e.emit("error",s),n.nextTick(i,s),o=!1),o}function q(e,t,n){return e.objectMode||!1===e.decodeStrings||"string"!=typeof t||(t=c.from(t,n)),t}function _(e,t,n,r,i,o){if(!n){var s=q(t,r,i);r!==s&&(n=!0,i="buffer",r=s)}var f=t.objectMode?1:r.length;t.length+=f;var u=t.length-1))throw new TypeError("Unknown encoding: "+e);return this._writableState.defaultEncoding=e,this},Object.defineProperty(g.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}}),g.prototype._write=function(e,t,n){n(new Error("_write() is not implemented"))},g.prototype._writev=null,g.prototype.end=function(e,t,n){var r=this._writableState;"function"==typeof e?(n=e,e=null,t=null):"function"==typeof t&&(n=t,t=null),null!=e&&this.write(e,t),r.corked&&(r.corked=1,this.uncork()),r.ending||r.finished||F(this,r,n)},Object.defineProperty(g.prototype,"destroyed",{get:function(){return void 0!==this._writableState&&this._writableState.destroyed},set:function(e){this._writableState&&(this._writableState.destroyed=e)}}),g.prototype.destroy=p.destroy,g.prototype._undestroy=p.undestroy,g.prototype._destroy=function(e,t){this.end(),t(e)}; +},{"process-nextick-args":"iFTO","core-util-is":"kj8s","inherits":"oxwV","util-deprecate":"hQaz","./internal/streams/stream":"XrGN","safe-buffer":"nFKF","./internal/streams/destroy":"Umu5","./_stream_duplex":"gYn1","process":"g5IB"}],"gYn1":[function(require,module,exports) { +"use strict";var e=require("process-nextick-args"),t=Object.keys||function(e){var t=[];for(var r in e)t.push(r);return t};module.exports=l;var r=Object.create(require("core-util-is"));r.inherits=require("inherits");var i=require("./_stream_readable"),a=require("./_stream_writable");r.inherits(l,i);for(var o=t(a.prototype),s=0;s>5==6?2:t>>4==14?3:t>>3==30?4:t>>6==2?-1:-2}function n(t,e,s){var i=e.length-1;if(i=0?(a>0&&(t.lastNeed=a-1),a):--i=0?(a>0&&(t.lastNeed=a-2),a):--i=0?(a>0&&(2===a?a=0:t.lastNeed=a-3),a):0}function h(t,e,s){if(128!=(192&e[0]))return t.lastNeed=0,"�";if(t.lastNeed>1&&e.length>1){if(128!=(192&e[1]))return t.lastNeed=1,"�";if(t.lastNeed>2&&e.length>2&&128!=(192&e[2]))return t.lastNeed=2,"�"}}function l(t){var e=this.lastTotal-this.lastNeed,s=h(this,t,e);return void 0!==s?s:this.lastNeed<=t.length?(t.copy(this.lastChar,e,0,this.lastNeed),this.lastChar.toString(this.encoding,0,this.lastTotal)):(t.copy(this.lastChar,e,0,t.length),void(this.lastNeed-=t.length))}function u(t,e){var s=n(this,t,e);if(!this.lastNeed)return t.toString("utf8",e);this.lastTotal=s;var i=t.length-(s-this.lastNeed);return t.copy(this.lastChar,0,i),t.toString("utf8",e,i)}function o(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+"�":e}function c(t,e){if((t.length-e)%2==0){var s=t.toString("utf16le",e);if(s){var i=s.charCodeAt(s.length-1);if(i>=55296&&i<=56319)return this.lastNeed=2,this.lastTotal=4,this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1],s.slice(0,-1)}return s}return this.lastNeed=1,this.lastTotal=2,this.lastChar[0]=t[t.length-1],t.toString("utf16le",e,t.length-1)}function f(t){var e=t&&t.length?this.write(t):"";if(this.lastNeed){var s=this.lastTotal-this.lastNeed;return e+this.lastChar.toString("utf16le",0,s)}return e}function d(t,e){var s=(t.length-e)%3;return 0===s?t.toString("base64",e):(this.lastNeed=3-s,this.lastTotal=3,1===s?this.lastChar[0]=t[t.length-1]:(this.lastChar[0]=t[t.length-2],this.lastChar[1]=t[t.length-1]),t.toString("base64",e,t.length-s))}function g(t){var e=t&&t.length?this.write(t):"";return this.lastNeed?e+this.lastChar.toString("base64",0,3-this.lastNeed):e}function N(t){return t.toString(this.encoding)}function v(t){return t&&t.length?this.write(t):""}exports.StringDecoder=a,a.prototype.write=function(t){if(0===t.length)return"";var e,s;if(this.lastNeed){if(void 0===(e=this.fillLast(t)))return"";s=this.lastNeed,this.lastNeed=0}else s=0;return s0?("string"==typeof t||d.objectMode||Object.getPrototypeOf(t)===s.prototype||(t=l(t)),r?d.endEmitted?e.emit("error",new Error("stream.unshift() after end event")):S(e,d,t,!0):d.ended?e.emit("error",new Error("stream.push() after EOF")):(d.reading=!1,d.decoder&&!n?(t=d.decoder.write(t),d.objectMode||0!==t.length?S(e,d,t,!1):C(e,d)):S(e,d,t,!1))):r||(d.reading=!1));return j(d)}function S(e,t,n,r){t.flowing&&0===t.length&&!t.sync?(e.emit("data",n),e.read(0)):(t.length+=t.objectMode?1:n.length,r?t.buffer.unshift(n):t.buffer.push(n),t.needReadable&&q(e)),C(e,t)}function k(e,t){var n;return h(t)||"string"==typeof t||void 0===t||e.objectMode||(n=new TypeError("Invalid non-string/buffer chunk")),n}function j(e){return!e.ended&&(e.needReadable||e.length=R?e=R:(e--,e|=e>>>1,e|=e>>>2,e|=e>>>4,e|=e>>>8,e|=e>>>16,e++),e}function L(e,t){return e<=0||0===t.length&&t.ended?0:t.objectMode?1:e!=e?t.flowing&&t.length?t.buffer.head.data.length:t.length:(e>t.highWaterMark&&(t.highWaterMark=E(e)),e<=t.length?e:t.ended?t.length:(t.needReadable=!0,0))}function x(e,t){if(!t.ended){if(t.decoder){var n=t.decoder.end();n&&n.length&&(t.buffer.push(n),t.length+=t.objectMode?1:n.length)}t.ended=!0,q(e)}}function q(e){var t=e._readableState;t.needReadable=!1,t.emittedReadable||(c("emitReadable",t.flowing),t.emittedReadable=!0,t.sync?n.nextTick(W,e):W(e))}function W(e){c("emit readable"),e.emit("readable"),B(e)}function C(e,t){t.readingMore||(t.readingMore=!0,n.nextTick(D,e,t))}function D(e,t){for(var n=t.length;!t.reading&&!t.flowing&&!t.ended&&t.length=t.length?(n=t.decoder?t.buffer.join(""):1===t.buffer.length?t.buffer.head.data:t.buffer.concat(t.length),t.buffer.clear()):n=I(e,t.buffer,t.decoder),n);var n}function I(e,t,n){var r;return ea.length?a.length:e;if(d===a.length?i+=a:i+=a.slice(0,e),0===(e-=d)){d===a.length?(++r,n.next?t.head=n.next:t.head=t.tail=null):(t.head=n,n.data=a.slice(d));break}++r}return t.length-=r,i}function F(e,t){var n=s.allocUnsafe(e),r=t.head,i=1;for(r.data.copy(n),e-=r.data.length;r=r.next;){var a=r.data,d=e>a.length?a.length:e;if(a.copy(n,n.length-e,0,d),0===(e-=d)){d===a.length?(++i,r.next?t.head=r.next:t.head=t.tail=null):(t.head=r,r.data=a.slice(d));break}++i}return t.length-=i,n}function z(e){var t=e._readableState;if(t.length>0)throw new Error('"endReadable()" called on non-empty stream');t.endEmitted||(t.ended=!0,n.nextTick(G,t,e))}function G(e,t){e.endEmitted||0!==e.length||(e.endEmitted=!0,t.readable=!1,t.emit("end"))}function J(e,t){for(var n=0,r=e.length;n=t.highWaterMark||t.ended))return c("read: emitReadable",t.length,t.ended),0===t.length&&t.ended?z(this):q(this),null;if(0===(e=L(e,t))&&t.ended)return 0===t.length&&z(this),null;var r,i=t.needReadable;return c("need readable",i),(0===t.length||t.length-e0?H(e,t):null)?(t.needReadable=!0,e=0):t.length-=e,0===t.length&&(t.ended||(t.needReadable=!0),n!==e&&t.ended&&z(this)),null!==r&&this.emit("data",r),r},_.prototype._read=function(e){this.emit("error",new Error("_read() is not implemented"))},_.prototype.pipe=function(e,r){var i=this,a=this._readableState;switch(a.pipesCount){case 0:a.pipes=e;break;case 1:a.pipes=[a.pipes,e];break;default:a.pipes.push(e)}a.pipesCount+=1,c("pipe count=%d opts=%j",a.pipesCount,r);var o=(!r||!1!==r.end)&&e!==t.stdout&&e!==t.stderr?u:v;function s(t,n){c("onunpipe"),t===i&&n&&!1===n.hasUnpiped&&(n.hasUnpiped=!0,c("cleanup"),e.removeListener("close",b),e.removeListener("finish",m),e.removeListener("drain",l),e.removeListener("error",g),e.removeListener("unpipe",s),i.removeListener("end",u),i.removeListener("end",v),i.removeListener("data",f),h=!0,!a.awaitDrain||e._writableState&&!e._writableState.needDrain||l())}function u(){c("onend"),e.end()}a.endEmitted?n.nextTick(o):i.once("end",o),e.on("unpipe",s);var l=O(i);e.on("drain",l);var h=!1;var p=!1;function f(t){c("ondata"),p=!1,!1!==e.write(t)||p||((1===a.pipesCount&&a.pipes===e||a.pipesCount>1&&-1!==J(a.pipes,e))&&!h&&(c("false write response, pause",i._readableState.awaitDrain),i._readableState.awaitDrain++,p=!0),i.pause())}function g(t){c("onerror",t),v(),e.removeListener("error",g),0===d(e,"error")&&e.emit("error",t)}function b(){e.removeListener("finish",m),v()}function m(){c("onfinish"),e.removeListener("close",b),v()}function v(){c("unpipe"),i.unpipe(e)}return i.on("data",f),y(e,"error",g),e.once("close",b),e.once("finish",m),e.emit("pipe",i),a.flowing||(c("pipe resume"),i.resume()),e},_.prototype.unpipe=function(e){var t=this._readableState,n={hasUnpiped:!1};if(0===t.pipesCount)return this;if(1===t.pipesCount)return e&&e!==t.pipes?this:(e||(e=t.pipes),t.pipes=null,t.pipesCount=0,t.flowing=!1,e&&e.emit("unpipe",this,n),this);if(!e){var r=t.pipes,i=t.pipesCount;t.pipes=null,t.pipesCount=0,t.flowing=!1;for(var a=0;as?h=t(h):h.lengthi)?t=("rmd160"===e?new s:h(e)).update(t).digest():t.lengthr||o!=o)throw new TypeError("Bad key length")}; +},{}],"dDDZ":[function(require,module,exports) { +var global = arguments[3]; +var process = require("process"); +var s,e=arguments[3],r=require("process");if(e.process&&e.process.browser)s="utf-8";else if(e.process&&e.process.version){var o=parseInt(r.version.split(".")[0].slice(1),10);s=o>=6?"utf-8":"binary"}else s="utf-8";module.exports=s; +},{"process":"g5IB"}],"gx8A":[function(require,module,exports) { + +var r=require("safe-buffer").Buffer;module.exports=function(e,f,u){if(r.isBuffer(e))return e;if("string"==typeof e)return r.from(e,f);if(ArrayBuffer.isView(e))return r.from(e.buffer);throw new TypeError(u+" must be a string, a Buffer, a typed array or a DataView")}; +},{"safe-buffer":"gIYa"}],"uqMe":[function(require,module,exports) { + +var e=require("create-hash/md5"),r=require("ripemd160"),a=require("sha.js"),t=require("safe-buffer").Buffer,i=require("./precondition"),s=require("./default-encoding"),n=require("./to-buffer"),h=t.alloc(128),o={md5:16,sha1:20,sha224:28,sha256:32,sha384:48,sha512:64,rmd160:20,ripemd160:20};function u(e,r,a){var i=c(e),s="sha512"===e||"sha384"===e?128:64;r.length>s?r=i(r):r.length>>0},exports.writeUInt32BE=function(r,o,t){r[0+t]=o>>>24,r[1+t]=o>>>16&255,r[2+t]=o>>>8&255,r[3+t]=255&o},exports.ip=function(r,o,t,f){for(var n=0,e=0,u=6;u>=0;u-=2){for(var i=0;i<=24;i+=8)n<<=1,n|=o>>>i+u&1;for(i=0;i<=24;i+=8)n<<=1,n|=r>>>i+u&1}for(u=6;u>=0;u-=2){for(i=1;i<=25;i+=8)e<<=1,e|=o>>>i+u&1;for(i=1;i<=25;i+=8)e<<=1,e|=r>>>i+u&1}t[f+0]=n>>>0,t[f+1]=e>>>0},exports.rip=function(r,o,t,f){for(var n=0,e=0,u=0;u<4;u++)for(var i=24;i>=0;i-=8)n<<=1,n|=o>>>i+u&1,n<<=1,n|=r>>>i+u&1;for(u=4;u<8;u++)for(i=24;i>=0;i-=8)e<<=1,e|=o>>>i+u&1,e<<=1,e|=r>>>i+u&1;t[f+0]=n>>>0,t[f+1]=e>>>0},exports.pc1=function(r,o,t,f){for(var n=0,e=0,u=7;u>=5;u--){for(var i=0;i<=24;i+=8)n<<=1,n|=o>>i+u&1;for(i=0;i<=24;i+=8)n<<=1,n|=r>>i+u&1}for(i=0;i<=24;i+=8)n<<=1,n|=o>>i+u&1;for(u=1;u<=3;u++){for(i=0;i<=24;i+=8)e<<=1,e|=o>>i+u&1;for(i=0;i<=24;i+=8)e<<=1,e|=r>>i+u&1}for(i=0;i<=24;i+=8)e<<=1,e|=r>>i+u&1;t[f+0]=n>>>0,t[f+1]=e>>>0},exports.r28shl=function(r,o){return r<>>28-o};var r=[14,11,17,4,27,23,25,0,13,22,7,18,5,9,16,24,2,20,12,21,1,8,15,26,15,4,25,19,9,1,26,16,5,11,23,8,12,7,17,0,22,3,10,14,6,20,27,24];exports.pc2=function(o,t,f,n){for(var e=0,u=0,i=r.length>>>1,p=0;p>>r[p]&1;for(p=i;p>>r[p]&1;f[n+0]=e>>>0,f[n+1]=u>>>0},exports.expand=function(r,o,t){var f=0,n=0;f=(1&r)<<5|r>>>27;for(var e=23;e>=15;e-=4)f<<=6,f|=r>>>e&63;for(e=11;e>=3;e-=4)n|=r>>>e&63,n<<=6;n|=(31&r)<<1|r>>>31,o[t+0]=f>>>0,o[t+1]=n>>>0};var o=[14,0,4,15,13,7,1,4,2,14,15,2,11,13,8,1,3,10,10,6,6,12,12,11,5,9,9,5,0,3,7,8,4,15,1,12,14,8,8,2,13,4,6,9,2,1,11,7,15,5,12,11,9,3,7,14,3,10,10,0,5,6,0,13,15,3,1,13,8,4,14,7,6,15,11,2,3,8,4,14,9,12,7,0,2,1,13,10,12,6,0,9,5,11,10,5,0,13,14,8,7,10,11,1,10,3,4,15,13,4,1,2,5,11,8,6,12,7,6,12,9,0,3,5,2,14,15,9,10,13,0,7,9,0,14,9,6,3,3,4,15,6,5,10,1,2,13,8,12,5,7,14,11,12,4,11,2,15,8,1,13,1,6,10,4,13,9,0,8,6,15,9,3,8,0,7,11,4,1,15,2,14,12,3,5,11,10,5,14,2,7,12,7,13,13,8,14,11,3,5,0,6,6,15,9,0,10,3,1,4,2,7,8,2,5,12,11,1,12,10,4,14,15,9,10,3,6,15,9,0,0,6,12,10,11,1,7,13,13,8,15,9,1,4,3,5,14,11,5,12,2,7,8,2,4,14,2,14,12,11,4,2,1,12,7,4,10,7,11,13,6,1,8,5,5,0,3,15,15,10,13,3,0,9,14,8,9,6,4,11,2,8,1,12,11,7,10,1,13,14,7,2,8,13,15,6,9,15,12,0,5,9,6,10,3,4,0,5,14,3,12,10,1,15,10,4,15,2,9,7,2,12,6,9,8,5,0,6,13,1,3,13,4,14,14,0,7,11,5,3,11,8,9,4,14,3,15,2,5,12,2,9,8,5,12,15,3,10,7,11,0,14,4,1,10,7,1,6,13,0,11,8,6,13,4,13,11,0,2,11,14,7,15,4,0,9,8,1,13,10,3,14,12,3,9,5,7,12,5,2,10,15,6,8,1,6,1,6,4,11,11,13,13,8,12,1,3,4,7,10,14,7,10,9,15,5,6,0,8,15,0,14,5,2,9,3,2,12,13,1,2,15,8,13,4,8,6,10,15,3,11,7,1,4,10,12,9,5,3,6,14,11,5,0,0,14,12,9,7,2,7,2,11,1,4,14,1,7,9,4,12,10,14,8,2,13,0,15,6,12,10,9,13,0,15,3,3,5,5,6,8,11];exports.substitute=function(r,t){for(var f=0,n=0;n<4;n++){f<<=4,f|=o[64*n+(r>>>18-6*n&63)]}for(n=0;n<4;n++){f<<=4,f|=o[256+64*n+(t>>>18-6*n&63)]}return f>>>0};var t=[16,25,12,11,3,20,4,15,31,17,9,6,27,14,1,22,30,24,8,18,0,5,29,23,13,19,2,26,10,21,28,7];exports.permute=function(r){for(var o=0,f=0;f>>t[f]&1;return o>>>0},exports.padSplit=function(r,o,t){for(var f=r.toString(2);f.length0;r--)f+=this._buffer(t,f),e+=this._flushBuffer(i,e);return f+=this._buffer(t,f),i},f.prototype.final=function(t){var f,e;return t&&(f=this.update(t)),e="encrypt"===this.type?this._finalEncrypt():this._finalDecrypt(),f?f.concat(e):e},f.prototype._pad=function(t,f){if(0===f)return!1;for(;f>>1];p=r.r28shl(p,u),i=r.r28shl(i,u),r.pc2(p,i,e.keys,a)}},i.prototype._update=function(t,e,n,p){var i=this._desState,s=r.readUInt32BE(t,e),a=r.readUInt32BE(t,e+4);r.ip(s,a,i.tmp,0),s=i.tmp[0],a=i.tmp[1],"encrypt"===this.type?this._encrypt(i,s,a,i.tmp,0):this._decrypt(i,s,a,i.tmp,0),s=i.tmp[0],a=i.tmp[1],r.writeUInt32BE(n,s,p),r.writeUInt32BE(n,a,p+4)},i.prototype._pad=function(t,e){for(var r=t.length-e,n=e;n>>0,s=l}r.rip(a,s,p,i)},i.prototype._decrypt=function(t,e,n,p,i){for(var s=n,a=e,u=t.keys.length-2;u>=0;u-=2){var o=t.keys[u],y=t.keys[u+1];r.expand(s,t.tmp,0),o^=t.tmp[0],y^=t.tmp[1];var h=r.substitute(o,y),l=s;s=(a^r.permute(h))>>>0,a=l}r.rip(s,a,p,i)}; +},{"minimalistic-assert":"PhA8","inherits":"oxwV","./utils":"PKi5","./cipher":"IsNc"}],"En4C":[function(require,module,exports) { +"use strict";var t=require("minimalistic-assert"),i=require("inherits"),e={};function r(i){t.equal(i.length,8,"Invalid IV length"),this.iv=new Array(8);for(var e=0;e>c%8,r._prev=n(r._prev,f?t:o);return a}function n(e,n){var f=e.length,t=-1,o=r.allocUnsafe(e.length);for(e=r.concat([e,r.from([n])]);++t>7;return o}exports.encrypt=function(n,f,t){for(var o=f.length,c=r.allocUnsafe(o),a=-1;++a>>24]^_[y>>>16&255]^f[I>>>8&255]^a[255&X]^t[h++],B=c[y>>>24]^_[I>>>16&255]^f[X>>>8&255]^a[255&s]^t[h++],S=c[I>>>24]^_[X>>>16&255]^f[s>>>8&255]^a[255&y]^t[h++],u=c[X>>>24]^_[s>>>16&255]^f[y>>>8&255]^a[255&I]^t[h++],s=i,y=B,I=S,X=u;return i=(n[s>>>24]<<24|n[y>>>16&255]<<16|n[I>>>8&255]<<8|n[255&X])^t[h++],B=(n[y>>>24]<<24|n[I>>>16&255]<<16|n[X>>>8&255]<<8|n[255&s])^t[h++],S=(n[I>>>24]<<24|n[X>>>16&255]<<16|n[s>>>8&255]<<8|n[255&y])^t[h++],u=(n[X>>>24]<<24|n[s>>>16&255]<<16|n[y>>>8&255]<<8|n[255&I])^t[h++],[i>>>=0,B>>>=0,S>>>=0,u>>>=0]}var o=[0,1,2,4,8,16,32,64,128,27,54],i=function(){for(var e=new Array(256),t=0;t<256;t++)e[t]=t<128?t<<1:t<<1^283;for(var r=[],n=[],o=[[],[],[],[]],i=[[],[],[],[]],B=0,S=0,u=0;u<256;++u){var c=S^S<<1^S<<2^S<<3^S<<4;c=c>>>8^255&c^99,r[B]=c,n[c]=B;var _=e[B],f=e[_],a=e[f],s=257*e[c]^16843008*c;o[0][B]=s<<24|s>>>8,o[1][B]=s<<16|s>>>16,o[2][B]=s<<8|s>>>24,o[3][B]=s,s=16843009*a^65537*f^257*_^16843008*B,i[0][c]=s<<24|s>>>8,i[1][c]=s<<16|s>>>16,i[2][c]=s<<8|s>>>24,i[3][c]=s,0===B?B=S=1:(B=_^e[e[e[a^_]]],S^=e[e[S]])}return{SBOX:r,INV_SBOX:n,SUB_MIX:o,INV_SUB_MIX:i}}();function B(e){this._key=t(e),this._reset()}B.blockSize=16,B.keySize=32,B.prototype.blockSize=B.blockSize,B.prototype.keySize=B.keySize,B.prototype._reset=function(){for(var e=this._key,t=e.length,r=t+6,n=4*(r+1),B=[],S=0;S>>24,u=i.SBOX[u>>>24]<<24|i.SBOX[u>>>16&255]<<16|i.SBOX[u>>>8&255]<<8|i.SBOX[255&u],u^=o[S/t|0]<<24):t>6&&S%t==4&&(u=i.SBOX[u>>>24]<<24|i.SBOX[u>>>16&255]<<16|i.SBOX[u>>>8&255]<<8|i.SBOX[255&u]),B[S]=B[S-t]^u}for(var c=[],_=0;_>>24]]^i.INV_SUB_MIX[1][i.SBOX[a>>>16&255]]^i.INV_SUB_MIX[2][i.SBOX[a>>>8&255]]^i.INV_SUB_MIX[3][i.SBOX[255&a]]}this._nRounds=r,this._keySchedule=B,this._invKeySchedule=c},B.prototype.encryptBlockRaw=function(e){return n(e=t(e),this._keySchedule,i.SUB_MIX,i.SBOX,this._nRounds)},B.prototype.encryptBlock=function(t){var r=this.encryptBlockRaw(t),n=e.allocUnsafe(16);return n.writeUInt32BE(r[0],0),n.writeUInt32BE(r[1],4),n.writeUInt32BE(r[2],8),n.writeUInt32BE(r[3],12),n},B.prototype.decryptBlock=function(r){var o=(r=t(r))[1];r[1]=r[3],r[3]=o;var B=n(r,this._invKeySchedule,i.INV_SUB_MIX,i.INV_SBOX,this._nRounds),S=e.allocUnsafe(16);return S.writeUInt32BE(B[0],0),S.writeUInt32BE(B[3],4),S.writeUInt32BE(B[2],8),S.writeUInt32BE(B[1],12),S},B.prototype.scrub=function(){r(this._keySchedule),r(this._invKeySchedule),r(this._key)},module.exports.AES=B; +},{"safe-buffer":"gIYa"}],"vz55":[function(require,module,exports) { + +var t=require("safe-buffer").Buffer,e=t.alloc(16,0);function h(t){return[t.readUInt32BE(0),t.readUInt32BE(4),t.readUInt32BE(8),t.readUInt32BE(12)]}function a(e){var h=t.allocUnsafe(16);return h.writeUInt32BE(e[0]>>>0,0),h.writeUInt32BE(e[1]>>>0,4),h.writeUInt32BE(e[2]>>>0,8),h.writeUInt32BE(e[3]>>>0,12),h}function i(e){this.h=e,this.state=t.alloc(16,0),this.cache=t.allocUnsafe(0)}i.prototype.ghash=function(t){for(var e=-1;++e0;t--)i[t]=i[t]>>>1|(1&i[t-1])<<31;i[0]=i[0]>>>1,e&&(i[0]=i[0]^225<<24)}this.state=a(c)},i.prototype.update=function(e){var h;for(this.cache=t.concat([this.cache,e]);this.cache.length>=16;)h=this.cache.slice(0,16),this.cache=this.cache.slice(16),this.ghash(h)},i.prototype.final=function(h,i){return this.cache.length&&this.ghash(t.concat([this.cache,e],16)),this.ghash(a([0,h,0,i])),this.state},module.exports=i; +},{"safe-buffer":"gIYa"}],"zyhX":[function(require,module,exports) { + +var t=require("./aes"),e=require("safe-buffer").Buffer,r=require("cipher-base"),h=require("inherits"),a=require("./ghash"),i=require("buffer-xor"),n=require("./incr32");function s(t,e){var r=0;t.length!==e.length&&r++;for(var h=Math.min(t.length,e.length),a=0;a0||l>0;){var h=new r;h.update(u),h.update(t),a&&h.update(a),u=h.digest();var g=0;if(n>0){var s=i.length-n;g=Math.min(n,u.length),u.copy(i,s,0,g),n-=g}if(g0){var d=o.length-l,v=Math.min(l,u.length-g);u.copy(o,d,g,g+v),l-=v}}return u.fill(0),{key:i,iv:o}}module.exports=t; +},{"safe-buffer":"gIYa","md5.js":"CYub"}],"OEHI":[function(require,module,exports) { + +var e=require("./modes"),t=require("./authCipher"),r=require("safe-buffer").Buffer,i=require("./streamCipher"),n=require("cipher-base"),h=require("./aes"),o=require("evp_bytestokey"),a=require("inherits");function c(e,t,i){n.call(this),this._cache=new u,this._cipher=new h.AES(t),this._prev=r.from(i),this._mode=e,this._autopadding=!0}a(c,n),c.prototype._update=function(e){var t,i;this._cache.add(e);for(var n=[];t=this._cache.get();)i=this._mode.encrypt(this,t),n.push(i);return r.concat(n)};var s=r.alloc(16,16);function u(){this.cache=r.allocUnsafe(0)}function p(n,h,o){var a=e[n.toLowerCase()];if(!a)throw new TypeError("invalid suite type");if("string"==typeof h&&(h=r.from(h)),h.length!==a.key/8)throw new TypeError("invalid key length "+h.length);if("string"==typeof o&&(o=r.from(o)),"GCM"!==a.mode&&o.length!==a.iv)throw new TypeError("invalid iv length "+o.length);return"stream"===a.type?new i(a.module,h,o):"auth"===a.type?new t(a.module,h,o):new c(a.module,h,o)}function f(t,r){var i=e[t.toLowerCase()];if(!i)throw new TypeError("invalid suite type");var n=o(r,!1,i.key,i.iv);return p(t,n.key,n.iv)}c.prototype._final=function(){var e=this._cache.flush();if(this._autopadding)return e=this._mode.encrypt(this,e),this._cipher.scrub(),e;if(!e.equals(s))throw this._cipher.scrub(),new Error("data not multiple of block length")},c.prototype.setAutoPadding=function(e){return this._autopadding=!!e,this},u.prototype.add=function(e){this.cache=r.concat([this.cache,e])},u.prototype.get=function(){if(this.cache.length>15){var e=this.cache.slice(0,16);return this.cache=this.cache.slice(16),e}return null},u.prototype.flush=function(){for(var e=16-this.cache.length,t=r.allocUnsafe(e),i=-1;++i16)throw new Error("unable to decrypt data");for(var r=-1;++r16)return t=this.cache.slice(0,16),this.cache=this.cache.slice(16),t}else if(this.cache.length>=16)return t=this.cache.slice(0,16),this.cache=this.cache.slice(16),t;return null},s.prototype.flush=function(){if(this.cache.length)return this.cache},exports.createDecipher=f,exports.createDecipheriv=p; +},{"./authCipher":"zyhX","safe-buffer":"gIYa","./modes":"YE6O","./streamCipher":"sqT5","cipher-base":"YX7l","./aes":"syH2","evp_bytestokey":"id7t","inherits":"oxwV"}],"aV4Z":[function(require,module,exports) { +var e=require("./encrypter"),r=require("./decrypter"),i=require("./modes/list.json");function p(){return Object.keys(i)}exports.createCipher=exports.Cipher=e.createCipher,exports.createCipheriv=exports.Cipheriv=e.createCipheriv,exports.createDecipher=exports.Decipher=r.createDecipher,exports.createDecipheriv=exports.Decipheriv=r.createDecipheriv,exports.listCiphers=exports.getCiphers=p; +},{"./encrypter":"OEHI","./decrypter":"qsP2","./modes/list.json":"E8oM"}],"G3pN":[function(require,module,exports) { +exports["des-ecb"]={key:8,iv:0},exports["des-cbc"]=exports.des={key:8,iv:8},exports["des-ede3-cbc"]=exports.des3={key:24,iv:8},exports["des-ede3"]={key:24,iv:0},exports["des-ede-cbc"]={key:16,iv:8},exports["des-ede"]={key:16,iv:0}; +},{}],"po04":[function(require,module,exports) { +var e=require("browserify-des"),r=require("browserify-aes/browser"),i=require("browserify-aes/modes"),t=require("browserify-des/modes"),o=require("evp_bytestokey");function s(e,r){var s,p;if(e=e.toLowerCase(),i[e])s=i[e].key,p=i[e].iv;else{if(!t[e])throw new TypeError("invalid suite type");s=8*t[e].key,p=t[e].iv}var v=o(r,!1,s,p);return n(e,v.key,v.iv)}function p(e,r){var s,p;if(e=e.toLowerCase(),i[e])s=i[e].key,p=i[e].iv;else{if(!t[e])throw new TypeError("invalid suite type");s=8*t[e].key,p=t[e].iv}var n=o(r,!1,s,p);return v(e,n.key,n.iv)}function n(o,s,p){if(o=o.toLowerCase(),i[o])return r.createCipheriv(o,s,p);if(t[o])return new e({key:s,iv:p,mode:o});throw new TypeError("invalid suite type")}function v(o,s,p){if(o=o.toLowerCase(),i[o])return r.createDecipheriv(o,s,p);if(t[o])return new e({key:s,iv:p,mode:o,decrypt:!0});throw new TypeError("invalid suite type")}function y(){return Object.keys(t).concat(r.getCiphers())}exports.createCipher=exports.Cipher=s,exports.createCipheriv=exports.Cipheriv=n,exports.createDecipher=exports.Decipher=p,exports.createDecipheriv=exports.Decipheriv=v,exports.listCiphers=exports.getCiphers=y; +},{"browserify-des":"EWqc","browserify-aes/browser":"aV4Z","browserify-aes/modes":"YE6O","browserify-des/modes":"G3pN","evp_bytestokey":"id7t"}],"o7RX":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var t=require("buffer").Buffer;!function(t,i){"use strict";function r(t,i){if(!t)throw new Error(i||"Assertion failed")}function n(t,i){t.super_=i;var r=function(){};r.prototype=i.prototype,t.prototype=new r,t.prototype.constructor=t}function h(t,i,r){if(h.isBN(t))return t;this.negative=0,this.words=null,this.length=0,this.red=null,null!==t&&("le"!==i&&"be"!==i||(r=i,i=10),this._init(t||0,i||10,r||"be"))}var e;"object"==typeof t?t.exports=h:i.BN=h,h.BN=h,h.wordSize=26;try{e="undefined"!=typeof window&&void 0!==window.Buffer?window.Buffer:require("buffer").Buffer}catch(A){}function o(t,i){var r=t.charCodeAt(i);return r>=65&&r<=70?r-55:r>=97&&r<=102?r-87:r-48&15}function s(t,i,r){var n=o(t,r);return r-1>=i&&(n|=o(t,r-1)<<4),n}function u(t,i,r,n){for(var h=0,e=Math.min(t.length,r),o=i;o=49?s-49+10:s>=17?s-17+10:s}return h}h.isBN=function(t){return t instanceof h||null!==t&&"object"==typeof t&&t.constructor.wordSize===h.wordSize&&Array.isArray(t.words)},h.max=function(t,i){return t.cmp(i)>0?t:i},h.min=function(t,i){return t.cmp(i)<0?t:i},h.prototype._init=function(t,i,n){if("number"==typeof t)return this._initNumber(t,i,n);if("object"==typeof t)return this._initArray(t,i,n);"hex"===i&&(i=16),r(i===(0|i)&&i>=2&&i<=36);var h=0;"-"===(t=t.toString().replace(/\s+/g,""))[0]&&(h++,this.negative=1),h=0;h-=3)o=t[h]|t[h-1]<<8|t[h-2]<<16,this.words[e]|=o<>>26-s&67108863,(s+=24)>=26&&(s-=26,e++);else if("le"===n)for(h=0,e=0;h>>26-s&67108863,(s+=24)>=26&&(s-=26,e++);return this.strip()},h.prototype._parseHex=function(t,i,r){this.length=Math.ceil((t.length-i)/6),this.words=new Array(this.length);for(var n=0;n=i;n-=2)h=s(t,i,n)<=18?(e-=18,o+=1,this.words[o]|=h>>>26):e+=8;else for(n=(t.length-i)%2==0?i+1:i;n=18?(e-=18,o+=1,this.words[o]|=h>>>26):e+=8;this.strip()},h.prototype._parseBase=function(t,i,r){this.words=[0],this.length=1;for(var n=0,h=1;h<=67108863;h*=i)n++;n--,h=h/i|0;for(var e=t.length-r,o=e%n,s=Math.min(e,e-o)+r,a=0,l=r;l1&&0===this.words[this.length-1];)this.length--;return this._normSign()},h.prototype._normSign=function(){return 1===this.length&&0===this.words[0]&&(this.negative=0),this},h.prototype.inspect=function(){return(this.red?""};var a=["","0","00","000","0000","00000","000000","0000000","00000000","000000000","0000000000","00000000000","000000000000","0000000000000","00000000000000","000000000000000","0000000000000000","00000000000000000","000000000000000000","0000000000000000000","00000000000000000000","000000000000000000000","0000000000000000000000","00000000000000000000000","000000000000000000000000","0000000000000000000000000"],l=[0,0,25,16,12,11,10,9,8,8,7,7,7,7,6,6,6,6,6,6,6,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],m=[0,0,33554432,43046721,16777216,48828125,60466176,40353607,16777216,43046721,1e7,19487171,35831808,62748517,7529536,11390625,16777216,24137569,34012224,47045881,64e6,4084101,5153632,6436343,7962624,9765625,11881376,14348907,17210368,20511149,243e5,28629151,33554432,39135393,45435424,52521875,60466176];function f(t,i,r){r.negative=i.negative^t.negative;var n=t.length+i.length|0;r.length=n,n=n-1|0;var h=0|t.words[0],e=0|i.words[0],o=h*e,s=67108863&o,u=o/67108864|0;r.words[0]=s;for(var a=1;a>>26,m=67108863&u,f=Math.min(a,i.length-1),d=Math.max(0,a-t.length+1);d<=f;d++){var p=a-d|0;l+=(o=(h=0|t.words[p])*(e=0|i.words[d])+m)/67108864|0,m=67108863&o}r.words[a]=0|m,u=0|l}return 0!==u?r.words[a]=0|u:r.length--,r.strip()}h.prototype.toString=function(t,i){var n;if(i=0|i||1,16===(t=t||10)||"hex"===t){n="";for(var h=0,e=0,o=0;o>>24-h&16777215)||o!==this.length-1?a[6-u.length]+u+n:u+n,(h+=2)>=26&&(h-=26,o--)}for(0!==e&&(n=e.toString(16)+n);n.length%i!=0;)n="0"+n;return 0!==this.negative&&(n="-"+n),n}if(t===(0|t)&&t>=2&&t<=36){var f=l[t],d=m[t];n="";var p=this.clone();for(p.negative=0;!p.isZero();){var M=p.modn(d).toString(t);n=(p=p.idivn(d)).isZero()?M+n:a[f-M.length]+M+n}for(this.isZero()&&(n="0"+n);n.length%i!=0;)n="0"+n;return 0!==this.negative&&(n="-"+n),n}r(!1,"Base should be between 2 and 36")},h.prototype.toNumber=function(){var t=this.words[0];return 2===this.length?t+=67108864*this.words[1]:3===this.length&&1===this.words[2]?t+=4503599627370496+67108864*this.words[1]:this.length>2&&r(!1,"Number can only safely store up to 53 bits"),0!==this.negative?-t:t},h.prototype.toJSON=function(){return this.toString(16)},h.prototype.toBuffer=function(t,i){return r(void 0!==e),this.toArrayLike(e,t,i)},h.prototype.toArray=function(t,i){return this.toArrayLike(Array,t,i)},h.prototype.toArrayLike=function(t,i,n){var h=this.byteLength(),e=n||Math.max(1,h);r(h<=e,"byte array longer than desired length"),r(e>0,"Requested array length <= 0"),this.strip();var o,s,u="le"===i,a=new t(e),l=this.clone();if(u){for(s=0;!l.isZero();s++)o=l.andln(255),l.iushrn(8),a[s]=o;for(;s=4096&&(r+=13,i>>>=13),i>=64&&(r+=7,i>>>=7),i>=8&&(r+=4,i>>>=4),i>=2&&(r+=2,i>>>=2),r+i},h.prototype._zeroBits=function(t){if(0===t)return 26;var i=t,r=0;return 0==(8191&i)&&(r+=13,i>>>=13),0==(127&i)&&(r+=7,i>>>=7),0==(15&i)&&(r+=4,i>>>=4),0==(3&i)&&(r+=2,i>>>=2),0==(1&i)&&r++,r},h.prototype.bitLength=function(){var t=this.words[this.length-1],i=this._countBits(t);return 26*(this.length-1)+i},h.prototype.zeroBits=function(){if(this.isZero())return 0;for(var t=0,i=0;it.length?this.clone().ior(t):t.clone().ior(this)},h.prototype.uor=function(t){return this.length>t.length?this.clone().iuor(t):t.clone().iuor(this)},h.prototype.iuand=function(t){var i;i=this.length>t.length?t:this;for(var r=0;rt.length?this.clone().iand(t):t.clone().iand(this)},h.prototype.uand=function(t){return this.length>t.length?this.clone().iuand(t):t.clone().iuand(this)},h.prototype.iuxor=function(t){var i,r;this.length>t.length?(i=this,r=t):(i=t,r=this);for(var n=0;nt.length?this.clone().ixor(t):t.clone().ixor(this)},h.prototype.uxor=function(t){return this.length>t.length?this.clone().iuxor(t):t.clone().iuxor(this)},h.prototype.inotn=function(t){r("number"==typeof t&&t>=0);var i=0|Math.ceil(t/26),n=t%26;this._expand(i),n>0&&i--;for(var h=0;h0&&(this.words[h]=~this.words[h]&67108863>>26-n),this.strip()},h.prototype.notn=function(t){return this.clone().inotn(t)},h.prototype.setn=function(t,i){r("number"==typeof t&&t>=0);var n=t/26|0,h=t%26;return this._expand(n+1),this.words[n]=i?this.words[n]|1<t.length?(r=this,n=t):(r=t,n=this);for(var h=0,e=0;e>>26;for(;0!==h&&e>>26;if(this.length=r.length,0!==h)this.words[this.length]=h,this.length++;else if(r!==this)for(;et.length?this.clone().iadd(t):t.clone().iadd(this)},h.prototype.isub=function(t){if(0!==t.negative){t.negative=0;var i=this.iadd(t);return t.negative=1,i._normSign()}if(0!==this.negative)return this.negative=0,this.iadd(t),this.negative=1,this._normSign();var r,n,h=this.cmp(t);if(0===h)return this.negative=0,this.length=1,this.words[0]=0,this;h>0?(r=this,n=t):(r=t,n=this);for(var e=0,o=0;o>26,this.words[o]=67108863&i;for(;0!==e&&o>26,this.words[o]=67108863&i;if(0===e&&o>>13,d=0|o[1],p=8191&d,M=d>>>13,v=0|o[2],g=8191&v,c=v>>>13,w=0|o[3],y=8191&w,b=w>>>13,_=0|o[4],k=8191&_,A=_>>>13,x=0|o[5],S=8191&x,q=x>>>13,B=0|o[6],Z=8191&B,R=B>>>13,N=0|o[7],L=8191&N,I=N>>>13,z=0|o[8],T=8191&z,E=z>>>13,O=0|o[9],j=8191&O,K=O>>>13,P=0|s[0],F=8191&P,C=P>>>13,D=0|s[1],H=8191&D,J=D>>>13,U=0|s[2],G=8191&U,Q=U>>>13,V=0|s[3],W=8191&V,X=V>>>13,Y=0|s[4],$=8191&Y,tt=Y>>>13,it=0|s[5],rt=8191&it,nt=it>>>13,ht=0|s[6],et=8191&ht,ot=ht>>>13,st=0|s[7],ut=8191&st,at=st>>>13,lt=0|s[8],mt=8191<,ft=lt>>>13,dt=0|s[9],pt=8191&dt,Mt=dt>>>13;r.negative=t.negative^i.negative,r.length=19;var vt=(a+(n=Math.imul(m,F))|0)+((8191&(h=(h=Math.imul(m,C))+Math.imul(f,F)|0))<<13)|0;a=((e=Math.imul(f,C))+(h>>>13)|0)+(vt>>>26)|0,vt&=67108863,n=Math.imul(p,F),h=(h=Math.imul(p,C))+Math.imul(M,F)|0,e=Math.imul(M,C);var gt=(a+(n=n+Math.imul(m,H)|0)|0)+((8191&(h=(h=h+Math.imul(m,J)|0)+Math.imul(f,H)|0))<<13)|0;a=((e=e+Math.imul(f,J)|0)+(h>>>13)|0)+(gt>>>26)|0,gt&=67108863,n=Math.imul(g,F),h=(h=Math.imul(g,C))+Math.imul(c,F)|0,e=Math.imul(c,C),n=n+Math.imul(p,H)|0,h=(h=h+Math.imul(p,J)|0)+Math.imul(M,H)|0,e=e+Math.imul(M,J)|0;var ct=(a+(n=n+Math.imul(m,G)|0)|0)+((8191&(h=(h=h+Math.imul(m,Q)|0)+Math.imul(f,G)|0))<<13)|0;a=((e=e+Math.imul(f,Q)|0)+(h>>>13)|0)+(ct>>>26)|0,ct&=67108863,n=Math.imul(y,F),h=(h=Math.imul(y,C))+Math.imul(b,F)|0,e=Math.imul(b,C),n=n+Math.imul(g,H)|0,h=(h=h+Math.imul(g,J)|0)+Math.imul(c,H)|0,e=e+Math.imul(c,J)|0,n=n+Math.imul(p,G)|0,h=(h=h+Math.imul(p,Q)|0)+Math.imul(M,G)|0,e=e+Math.imul(M,Q)|0;var wt=(a+(n=n+Math.imul(m,W)|0)|0)+((8191&(h=(h=h+Math.imul(m,X)|0)+Math.imul(f,W)|0))<<13)|0;a=((e=e+Math.imul(f,X)|0)+(h>>>13)|0)+(wt>>>26)|0,wt&=67108863,n=Math.imul(k,F),h=(h=Math.imul(k,C))+Math.imul(A,F)|0,e=Math.imul(A,C),n=n+Math.imul(y,H)|0,h=(h=h+Math.imul(y,J)|0)+Math.imul(b,H)|0,e=e+Math.imul(b,J)|0,n=n+Math.imul(g,G)|0,h=(h=h+Math.imul(g,Q)|0)+Math.imul(c,G)|0,e=e+Math.imul(c,Q)|0,n=n+Math.imul(p,W)|0,h=(h=h+Math.imul(p,X)|0)+Math.imul(M,W)|0,e=e+Math.imul(M,X)|0;var yt=(a+(n=n+Math.imul(m,$)|0)|0)+((8191&(h=(h=h+Math.imul(m,tt)|0)+Math.imul(f,$)|0))<<13)|0;a=((e=e+Math.imul(f,tt)|0)+(h>>>13)|0)+(yt>>>26)|0,yt&=67108863,n=Math.imul(S,F),h=(h=Math.imul(S,C))+Math.imul(q,F)|0,e=Math.imul(q,C),n=n+Math.imul(k,H)|0,h=(h=h+Math.imul(k,J)|0)+Math.imul(A,H)|0,e=e+Math.imul(A,J)|0,n=n+Math.imul(y,G)|0,h=(h=h+Math.imul(y,Q)|0)+Math.imul(b,G)|0,e=e+Math.imul(b,Q)|0,n=n+Math.imul(g,W)|0,h=(h=h+Math.imul(g,X)|0)+Math.imul(c,W)|0,e=e+Math.imul(c,X)|0,n=n+Math.imul(p,$)|0,h=(h=h+Math.imul(p,tt)|0)+Math.imul(M,$)|0,e=e+Math.imul(M,tt)|0;var bt=(a+(n=n+Math.imul(m,rt)|0)|0)+((8191&(h=(h=h+Math.imul(m,nt)|0)+Math.imul(f,rt)|0))<<13)|0;a=((e=e+Math.imul(f,nt)|0)+(h>>>13)|0)+(bt>>>26)|0,bt&=67108863,n=Math.imul(Z,F),h=(h=Math.imul(Z,C))+Math.imul(R,F)|0,e=Math.imul(R,C),n=n+Math.imul(S,H)|0,h=(h=h+Math.imul(S,J)|0)+Math.imul(q,H)|0,e=e+Math.imul(q,J)|0,n=n+Math.imul(k,G)|0,h=(h=h+Math.imul(k,Q)|0)+Math.imul(A,G)|0,e=e+Math.imul(A,Q)|0,n=n+Math.imul(y,W)|0,h=(h=h+Math.imul(y,X)|0)+Math.imul(b,W)|0,e=e+Math.imul(b,X)|0,n=n+Math.imul(g,$)|0,h=(h=h+Math.imul(g,tt)|0)+Math.imul(c,$)|0,e=e+Math.imul(c,tt)|0,n=n+Math.imul(p,rt)|0,h=(h=h+Math.imul(p,nt)|0)+Math.imul(M,rt)|0,e=e+Math.imul(M,nt)|0;var _t=(a+(n=n+Math.imul(m,et)|0)|0)+((8191&(h=(h=h+Math.imul(m,ot)|0)+Math.imul(f,et)|0))<<13)|0;a=((e=e+Math.imul(f,ot)|0)+(h>>>13)|0)+(_t>>>26)|0,_t&=67108863,n=Math.imul(L,F),h=(h=Math.imul(L,C))+Math.imul(I,F)|0,e=Math.imul(I,C),n=n+Math.imul(Z,H)|0,h=(h=h+Math.imul(Z,J)|0)+Math.imul(R,H)|0,e=e+Math.imul(R,J)|0,n=n+Math.imul(S,G)|0,h=(h=h+Math.imul(S,Q)|0)+Math.imul(q,G)|0,e=e+Math.imul(q,Q)|0,n=n+Math.imul(k,W)|0,h=(h=h+Math.imul(k,X)|0)+Math.imul(A,W)|0,e=e+Math.imul(A,X)|0,n=n+Math.imul(y,$)|0,h=(h=h+Math.imul(y,tt)|0)+Math.imul(b,$)|0,e=e+Math.imul(b,tt)|0,n=n+Math.imul(g,rt)|0,h=(h=h+Math.imul(g,nt)|0)+Math.imul(c,rt)|0,e=e+Math.imul(c,nt)|0,n=n+Math.imul(p,et)|0,h=(h=h+Math.imul(p,ot)|0)+Math.imul(M,et)|0,e=e+Math.imul(M,ot)|0;var kt=(a+(n=n+Math.imul(m,ut)|0)|0)+((8191&(h=(h=h+Math.imul(m,at)|0)+Math.imul(f,ut)|0))<<13)|0;a=((e=e+Math.imul(f,at)|0)+(h>>>13)|0)+(kt>>>26)|0,kt&=67108863,n=Math.imul(T,F),h=(h=Math.imul(T,C))+Math.imul(E,F)|0,e=Math.imul(E,C),n=n+Math.imul(L,H)|0,h=(h=h+Math.imul(L,J)|0)+Math.imul(I,H)|0,e=e+Math.imul(I,J)|0,n=n+Math.imul(Z,G)|0,h=(h=h+Math.imul(Z,Q)|0)+Math.imul(R,G)|0,e=e+Math.imul(R,Q)|0,n=n+Math.imul(S,W)|0,h=(h=h+Math.imul(S,X)|0)+Math.imul(q,W)|0,e=e+Math.imul(q,X)|0,n=n+Math.imul(k,$)|0,h=(h=h+Math.imul(k,tt)|0)+Math.imul(A,$)|0,e=e+Math.imul(A,tt)|0,n=n+Math.imul(y,rt)|0,h=(h=h+Math.imul(y,nt)|0)+Math.imul(b,rt)|0,e=e+Math.imul(b,nt)|0,n=n+Math.imul(g,et)|0,h=(h=h+Math.imul(g,ot)|0)+Math.imul(c,et)|0,e=e+Math.imul(c,ot)|0,n=n+Math.imul(p,ut)|0,h=(h=h+Math.imul(p,at)|0)+Math.imul(M,ut)|0,e=e+Math.imul(M,at)|0;var At=(a+(n=n+Math.imul(m,mt)|0)|0)+((8191&(h=(h=h+Math.imul(m,ft)|0)+Math.imul(f,mt)|0))<<13)|0;a=((e=e+Math.imul(f,ft)|0)+(h>>>13)|0)+(At>>>26)|0,At&=67108863,n=Math.imul(j,F),h=(h=Math.imul(j,C))+Math.imul(K,F)|0,e=Math.imul(K,C),n=n+Math.imul(T,H)|0,h=(h=h+Math.imul(T,J)|0)+Math.imul(E,H)|0,e=e+Math.imul(E,J)|0,n=n+Math.imul(L,G)|0,h=(h=h+Math.imul(L,Q)|0)+Math.imul(I,G)|0,e=e+Math.imul(I,Q)|0,n=n+Math.imul(Z,W)|0,h=(h=h+Math.imul(Z,X)|0)+Math.imul(R,W)|0,e=e+Math.imul(R,X)|0,n=n+Math.imul(S,$)|0,h=(h=h+Math.imul(S,tt)|0)+Math.imul(q,$)|0,e=e+Math.imul(q,tt)|0,n=n+Math.imul(k,rt)|0,h=(h=h+Math.imul(k,nt)|0)+Math.imul(A,rt)|0,e=e+Math.imul(A,nt)|0,n=n+Math.imul(y,et)|0,h=(h=h+Math.imul(y,ot)|0)+Math.imul(b,et)|0,e=e+Math.imul(b,ot)|0,n=n+Math.imul(g,ut)|0,h=(h=h+Math.imul(g,at)|0)+Math.imul(c,ut)|0,e=e+Math.imul(c,at)|0,n=n+Math.imul(p,mt)|0,h=(h=h+Math.imul(p,ft)|0)+Math.imul(M,mt)|0,e=e+Math.imul(M,ft)|0;var xt=(a+(n=n+Math.imul(m,pt)|0)|0)+((8191&(h=(h=h+Math.imul(m,Mt)|0)+Math.imul(f,pt)|0))<<13)|0;a=((e=e+Math.imul(f,Mt)|0)+(h>>>13)|0)+(xt>>>26)|0,xt&=67108863,n=Math.imul(j,H),h=(h=Math.imul(j,J))+Math.imul(K,H)|0,e=Math.imul(K,J),n=n+Math.imul(T,G)|0,h=(h=h+Math.imul(T,Q)|0)+Math.imul(E,G)|0,e=e+Math.imul(E,Q)|0,n=n+Math.imul(L,W)|0,h=(h=h+Math.imul(L,X)|0)+Math.imul(I,W)|0,e=e+Math.imul(I,X)|0,n=n+Math.imul(Z,$)|0,h=(h=h+Math.imul(Z,tt)|0)+Math.imul(R,$)|0,e=e+Math.imul(R,tt)|0,n=n+Math.imul(S,rt)|0,h=(h=h+Math.imul(S,nt)|0)+Math.imul(q,rt)|0,e=e+Math.imul(q,nt)|0,n=n+Math.imul(k,et)|0,h=(h=h+Math.imul(k,ot)|0)+Math.imul(A,et)|0,e=e+Math.imul(A,ot)|0,n=n+Math.imul(y,ut)|0,h=(h=h+Math.imul(y,at)|0)+Math.imul(b,ut)|0,e=e+Math.imul(b,at)|0,n=n+Math.imul(g,mt)|0,h=(h=h+Math.imul(g,ft)|0)+Math.imul(c,mt)|0,e=e+Math.imul(c,ft)|0;var St=(a+(n=n+Math.imul(p,pt)|0)|0)+((8191&(h=(h=h+Math.imul(p,Mt)|0)+Math.imul(M,pt)|0))<<13)|0;a=((e=e+Math.imul(M,Mt)|0)+(h>>>13)|0)+(St>>>26)|0,St&=67108863,n=Math.imul(j,G),h=(h=Math.imul(j,Q))+Math.imul(K,G)|0,e=Math.imul(K,Q),n=n+Math.imul(T,W)|0,h=(h=h+Math.imul(T,X)|0)+Math.imul(E,W)|0,e=e+Math.imul(E,X)|0,n=n+Math.imul(L,$)|0,h=(h=h+Math.imul(L,tt)|0)+Math.imul(I,$)|0,e=e+Math.imul(I,tt)|0,n=n+Math.imul(Z,rt)|0,h=(h=h+Math.imul(Z,nt)|0)+Math.imul(R,rt)|0,e=e+Math.imul(R,nt)|0,n=n+Math.imul(S,et)|0,h=(h=h+Math.imul(S,ot)|0)+Math.imul(q,et)|0,e=e+Math.imul(q,ot)|0,n=n+Math.imul(k,ut)|0,h=(h=h+Math.imul(k,at)|0)+Math.imul(A,ut)|0,e=e+Math.imul(A,at)|0,n=n+Math.imul(y,mt)|0,h=(h=h+Math.imul(y,ft)|0)+Math.imul(b,mt)|0,e=e+Math.imul(b,ft)|0;var qt=(a+(n=n+Math.imul(g,pt)|0)|0)+((8191&(h=(h=h+Math.imul(g,Mt)|0)+Math.imul(c,pt)|0))<<13)|0;a=((e=e+Math.imul(c,Mt)|0)+(h>>>13)|0)+(qt>>>26)|0,qt&=67108863,n=Math.imul(j,W),h=(h=Math.imul(j,X))+Math.imul(K,W)|0,e=Math.imul(K,X),n=n+Math.imul(T,$)|0,h=(h=h+Math.imul(T,tt)|0)+Math.imul(E,$)|0,e=e+Math.imul(E,tt)|0,n=n+Math.imul(L,rt)|0,h=(h=h+Math.imul(L,nt)|0)+Math.imul(I,rt)|0,e=e+Math.imul(I,nt)|0,n=n+Math.imul(Z,et)|0,h=(h=h+Math.imul(Z,ot)|0)+Math.imul(R,et)|0,e=e+Math.imul(R,ot)|0,n=n+Math.imul(S,ut)|0,h=(h=h+Math.imul(S,at)|0)+Math.imul(q,ut)|0,e=e+Math.imul(q,at)|0,n=n+Math.imul(k,mt)|0,h=(h=h+Math.imul(k,ft)|0)+Math.imul(A,mt)|0,e=e+Math.imul(A,ft)|0;var Bt=(a+(n=n+Math.imul(y,pt)|0)|0)+((8191&(h=(h=h+Math.imul(y,Mt)|0)+Math.imul(b,pt)|0))<<13)|0;a=((e=e+Math.imul(b,Mt)|0)+(h>>>13)|0)+(Bt>>>26)|0,Bt&=67108863,n=Math.imul(j,$),h=(h=Math.imul(j,tt))+Math.imul(K,$)|0,e=Math.imul(K,tt),n=n+Math.imul(T,rt)|0,h=(h=h+Math.imul(T,nt)|0)+Math.imul(E,rt)|0,e=e+Math.imul(E,nt)|0,n=n+Math.imul(L,et)|0,h=(h=h+Math.imul(L,ot)|0)+Math.imul(I,et)|0,e=e+Math.imul(I,ot)|0,n=n+Math.imul(Z,ut)|0,h=(h=h+Math.imul(Z,at)|0)+Math.imul(R,ut)|0,e=e+Math.imul(R,at)|0,n=n+Math.imul(S,mt)|0,h=(h=h+Math.imul(S,ft)|0)+Math.imul(q,mt)|0,e=e+Math.imul(q,ft)|0;var Zt=(a+(n=n+Math.imul(k,pt)|0)|0)+((8191&(h=(h=h+Math.imul(k,Mt)|0)+Math.imul(A,pt)|0))<<13)|0;a=((e=e+Math.imul(A,Mt)|0)+(h>>>13)|0)+(Zt>>>26)|0,Zt&=67108863,n=Math.imul(j,rt),h=(h=Math.imul(j,nt))+Math.imul(K,rt)|0,e=Math.imul(K,nt),n=n+Math.imul(T,et)|0,h=(h=h+Math.imul(T,ot)|0)+Math.imul(E,et)|0,e=e+Math.imul(E,ot)|0,n=n+Math.imul(L,ut)|0,h=(h=h+Math.imul(L,at)|0)+Math.imul(I,ut)|0,e=e+Math.imul(I,at)|0,n=n+Math.imul(Z,mt)|0,h=(h=h+Math.imul(Z,ft)|0)+Math.imul(R,mt)|0,e=e+Math.imul(R,ft)|0;var Rt=(a+(n=n+Math.imul(S,pt)|0)|0)+((8191&(h=(h=h+Math.imul(S,Mt)|0)+Math.imul(q,pt)|0))<<13)|0;a=((e=e+Math.imul(q,Mt)|0)+(h>>>13)|0)+(Rt>>>26)|0,Rt&=67108863,n=Math.imul(j,et),h=(h=Math.imul(j,ot))+Math.imul(K,et)|0,e=Math.imul(K,ot),n=n+Math.imul(T,ut)|0,h=(h=h+Math.imul(T,at)|0)+Math.imul(E,ut)|0,e=e+Math.imul(E,at)|0,n=n+Math.imul(L,mt)|0,h=(h=h+Math.imul(L,ft)|0)+Math.imul(I,mt)|0,e=e+Math.imul(I,ft)|0;var Nt=(a+(n=n+Math.imul(Z,pt)|0)|0)+((8191&(h=(h=h+Math.imul(Z,Mt)|0)+Math.imul(R,pt)|0))<<13)|0;a=((e=e+Math.imul(R,Mt)|0)+(h>>>13)|0)+(Nt>>>26)|0,Nt&=67108863,n=Math.imul(j,ut),h=(h=Math.imul(j,at))+Math.imul(K,ut)|0,e=Math.imul(K,at),n=n+Math.imul(T,mt)|0,h=(h=h+Math.imul(T,ft)|0)+Math.imul(E,mt)|0,e=e+Math.imul(E,ft)|0;var Lt=(a+(n=n+Math.imul(L,pt)|0)|0)+((8191&(h=(h=h+Math.imul(L,Mt)|0)+Math.imul(I,pt)|0))<<13)|0;a=((e=e+Math.imul(I,Mt)|0)+(h>>>13)|0)+(Lt>>>26)|0,Lt&=67108863,n=Math.imul(j,mt),h=(h=Math.imul(j,ft))+Math.imul(K,mt)|0,e=Math.imul(K,ft);var It=(a+(n=n+Math.imul(T,pt)|0)|0)+((8191&(h=(h=h+Math.imul(T,Mt)|0)+Math.imul(E,pt)|0))<<13)|0;a=((e=e+Math.imul(E,Mt)|0)+(h>>>13)|0)+(It>>>26)|0,It&=67108863;var zt=(a+(n=Math.imul(j,pt))|0)+((8191&(h=(h=Math.imul(j,Mt))+Math.imul(K,pt)|0))<<13)|0;return a=((e=Math.imul(K,Mt))+(h>>>13)|0)+(zt>>>26)|0,zt&=67108863,u[0]=vt,u[1]=gt,u[2]=ct,u[3]=wt,u[4]=yt,u[5]=bt,u[6]=_t,u[7]=kt,u[8]=At,u[9]=xt,u[10]=St,u[11]=qt,u[12]=Bt,u[13]=Zt,u[14]=Rt,u[15]=Nt,u[16]=Lt,u[17]=It,u[18]=zt,0!==a&&(u[19]=a,r.length++),r};function p(t,i,r){return(new M).mulp(t,i,r)}function M(t,i){this.x=t,this.y=i}Math.imul||(d=f),h.prototype.mulTo=function(t,i){var r=this.length+t.length;return 10===this.length&&10===t.length?d(this,t,i):r<63?f(this,t,i):r<1024?function(t,i,r){r.negative=i.negative^t.negative,r.length=t.length+i.length;for(var n=0,h=0,e=0;e>>26)|0)>>>26,o&=67108863}r.words[e]=s,n=o,o=h}return 0!==n?r.words[e]=n:r.length--,r.strip()}(this,t,i):p(this,t,i)},M.prototype.makeRBT=function(t){for(var i=new Array(t),r=h.prototype._countBits(t)-1,n=0;n>=1;return n},M.prototype.permute=function(t,i,r,n,h,e){for(var o=0;o>>=1)h++;return 1<>>=13,n[2*o+1]=8191&e,e>>>=13;for(o=2*i;o>=26,i+=h/67108864|0,i+=e>>>26,this.words[n]=67108863&e}return 0!==i&&(this.words[n]=i,this.length++),this},h.prototype.muln=function(t){return this.clone().imuln(t)},h.prototype.sqr=function(){return this.mul(this)},h.prototype.isqr=function(){return this.imul(this.clone())},h.prototype.pow=function(t){var i=function(t){for(var i=new Array(t.bitLength()),r=0;r>>h}return i}(t);if(0===i.length)return new h(1);for(var r=this,n=0;n=0);var i,n=t%26,h=(t-n)/26,e=67108863>>>26-n<<26-n;if(0!==n){var o=0;for(i=0;i>>26-n}o&&(this.words[i]=o,this.length++)}if(0!==h){for(i=this.length-1;i>=0;i--)this.words[i+h]=this.words[i];for(i=0;i=0),h=i?(i-i%26)/26:0;var e=t%26,o=Math.min((t-e)/26,this.length),s=67108863^67108863>>>e<o)for(this.length-=o,a=0;a=0&&(0!==l||a>=h);a--){var m=0|this.words[a];this.words[a]=l<<26-e|m>>>e,l=m&s}return u&&0!==l&&(u.words[u.length++]=l),0===this.length&&(this.words[0]=0,this.length=1),this.strip()},h.prototype.ishrn=function(t,i,n){return r(0===this.negative),this.iushrn(t,i,n)},h.prototype.shln=function(t){return this.clone().ishln(t)},h.prototype.ushln=function(t){return this.clone().iushln(t)},h.prototype.shrn=function(t){return this.clone().ishrn(t)},h.prototype.ushrn=function(t){return this.clone().iushrn(t)},h.prototype.testn=function(t){r("number"==typeof t&&t>=0);var i=t%26,n=(t-i)/26,h=1<=0);var i=t%26,n=(t-i)/26;if(r(0===this.negative,"imaskn works only with positive numbers"),this.length<=n)return this;if(0!==i&&n++,this.length=Math.min(n,this.length),0!==i){var h=67108863^67108863>>>i<=67108864;i++)this.words[i]-=67108864,i===this.length-1?this.words[i+1]=1:this.words[i+1]++;return this.length=Math.max(this.length,i+1),this},h.prototype.isubn=function(t){if(r("number"==typeof t),r(t<67108864),t<0)return this.iaddn(-t);if(0!==this.negative)return this.negative=0,this.iaddn(t),this.negative=1,this;if(this.words[0]-=t,1===this.length&&this.words[0]<0)this.words[0]=-this.words[0],this.negative=1;else for(var i=0;i>26)-(u/67108864|0),this.words[h+n]=67108863&e}for(;h>26,this.words[h+n]=67108863&e;if(0===s)return this.strip();for(r(-1===s),s=0,h=0;h>26,this.words[h]=67108863&e;return this.negative=1,this.strip()},h.prototype._wordDiv=function(t,i){var r=(this.length,t.length),n=this.clone(),e=t,o=0|e.words[e.length-1];0!==(r=26-this._countBits(o))&&(e=e.ushln(r),n.iushln(r),o=0|e.words[e.length-1]);var s,u=n.length-e.length;if("mod"!==i){(s=new h(null)).length=u+1,s.words=new Array(s.length);for(var a=0;a=0;m--){var f=67108864*(0|n.words[e.length+m])+(0|n.words[e.length+m-1]);for(f=Math.min(f/o|0,67108863),n._ishlnsubmul(e,f,m);0!==n.negative;)f--,n.negative=0,n._ishlnsubmul(e,1,m),n.isZero()||(n.negative^=1);s&&(s.words[m]=f)}return s&&s.strip(),n.strip(),"div"!==i&&0!==r&&n.iushrn(r),{div:s||null,mod:n}},h.prototype.divmod=function(t,i,n){return r(!t.isZero()),this.isZero()?{div:new h(0),mod:new h(0)}:0!==this.negative&&0===t.negative?(s=this.neg().divmod(t,i),"mod"!==i&&(e=s.div.neg()),"div"!==i&&(o=s.mod.neg(),n&&0!==o.negative&&o.iadd(t)),{div:e,mod:o}):0===this.negative&&0!==t.negative?(s=this.divmod(t.neg(),i),"mod"!==i&&(e=s.div.neg()),{div:e,mod:s.mod}):0!=(this.negative&t.negative)?(s=this.neg().divmod(t.neg(),i),"div"!==i&&(o=s.mod.neg(),n&&0!==o.negative&&o.isub(t)),{div:s.div,mod:o}):t.length>this.length||this.cmp(t)<0?{div:new h(0),mod:this}:1===t.length?"div"===i?{div:this.divn(t.words[0]),mod:null}:"mod"===i?{div:null,mod:new h(this.modn(t.words[0]))}:{div:this.divn(t.words[0]),mod:new h(this.modn(t.words[0]))}:this._wordDiv(t,i);var e,o,s},h.prototype.div=function(t){return this.divmod(t,"div",!1).div},h.prototype.mod=function(t){return this.divmod(t,"mod",!1).mod},h.prototype.umod=function(t){return this.divmod(t,"mod",!0).mod},h.prototype.divRound=function(t){var i=this.divmod(t);if(i.mod.isZero())return i.div;var r=0!==i.div.negative?i.mod.isub(t):i.mod,n=t.ushrn(1),h=t.andln(1),e=r.cmp(n);return e<0||1===h&&0===e?i.div:0!==i.div.negative?i.div.isubn(1):i.div.iaddn(1)},h.prototype.modn=function(t){r(t<=67108863);for(var i=(1<<26)%t,n=0,h=this.length-1;h>=0;h--)n=(i*n+(0|this.words[h]))%t;return n},h.prototype.idivn=function(t){r(t<=67108863);for(var i=0,n=this.length-1;n>=0;n--){var h=(0|this.words[n])+67108864*i;this.words[n]=h/t|0,i=h%t}return this.strip()},h.prototype.divn=function(t){return this.clone().idivn(t)},h.prototype.egcd=function(t){r(0===t.negative),r(!t.isZero());var i=this,n=t.clone();i=0!==i.negative?i.umod(t):i.clone();for(var e=new h(1),o=new h(0),s=new h(0),u=new h(1),a=0;i.isEven()&&n.isEven();)i.iushrn(1),n.iushrn(1),++a;for(var l=n.clone(),m=i.clone();!i.isZero();){for(var f=0,d=1;0==(i.words[0]&d)&&f<26;++f,d<<=1);if(f>0)for(i.iushrn(f);f-- >0;)(e.isOdd()||o.isOdd())&&(e.iadd(l),o.isub(m)),e.iushrn(1),o.iushrn(1);for(var p=0,M=1;0==(n.words[0]&M)&&p<26;++p,M<<=1);if(p>0)for(n.iushrn(p);p-- >0;)(s.isOdd()||u.isOdd())&&(s.iadd(l),u.isub(m)),s.iushrn(1),u.iushrn(1);i.cmp(n)>=0?(i.isub(n),e.isub(s),o.isub(u)):(n.isub(i),s.isub(e),u.isub(o))}return{a:s,b:u,gcd:n.iushln(a)}},h.prototype._invmp=function(t){r(0===t.negative),r(!t.isZero());var i=this,n=t.clone();i=0!==i.negative?i.umod(t):i.clone();for(var e,o=new h(1),s=new h(0),u=n.clone();i.cmpn(1)>0&&n.cmpn(1)>0;){for(var a=0,l=1;0==(i.words[0]&l)&&a<26;++a,l<<=1);if(a>0)for(i.iushrn(a);a-- >0;)o.isOdd()&&o.iadd(u),o.iushrn(1);for(var m=0,f=1;0==(n.words[0]&f)&&m<26;++m,f<<=1);if(m>0)for(n.iushrn(m);m-- >0;)s.isOdd()&&s.iadd(u),s.iushrn(1);i.cmp(n)>=0?(i.isub(n),o.isub(s)):(n.isub(i),s.isub(o))}return(e=0===i.cmpn(1)?o:s).cmpn(0)<0&&e.iadd(t),e},h.prototype.gcd=function(t){if(this.isZero())return t.abs();if(t.isZero())return this.abs();var i=this.clone(),r=t.clone();i.negative=0,r.negative=0;for(var n=0;i.isEven()&&r.isEven();n++)i.iushrn(1),r.iushrn(1);for(;;){for(;i.isEven();)i.iushrn(1);for(;r.isEven();)r.iushrn(1);var h=i.cmp(r);if(h<0){var e=i;i=r,r=e}else if(0===h||0===r.cmpn(1))break;i.isub(r)}return r.iushln(n)},h.prototype.invm=function(t){return this.egcd(t).a.umod(t)},h.prototype.isEven=function(){return 0==(1&this.words[0])},h.prototype.isOdd=function(){return 1==(1&this.words[0])},h.prototype.andln=function(t){return this.words[0]&t},h.prototype.bincn=function(t){r("number"==typeof t);var i=t%26,n=(t-i)/26,h=1<>>26,s&=67108863,this.words[o]=s}return 0!==e&&(this.words[o]=e,this.length++),this},h.prototype.isZero=function(){return 1===this.length&&0===this.words[0]},h.prototype.cmpn=function(t){var i,n=t<0;if(0!==this.negative&&!n)return-1;if(0===this.negative&&n)return 1;if(this.strip(),this.length>1)i=1;else{n&&(t=-t),r(t<=67108863,"Number is too big");var h=0|this.words[0];i=h===t?0:ht.length)return 1;if(this.length=0;r--){var n=0|this.words[r],h=0|t.words[r];if(n!==h){nh&&(i=1);break}}return i},h.prototype.gtn=function(t){return 1===this.cmpn(t)},h.prototype.gt=function(t){return 1===this.cmp(t)},h.prototype.gten=function(t){return this.cmpn(t)>=0},h.prototype.gte=function(t){return this.cmp(t)>=0},h.prototype.ltn=function(t){return-1===this.cmpn(t)},h.prototype.lt=function(t){return-1===this.cmp(t)},h.prototype.lten=function(t){return this.cmpn(t)<=0},h.prototype.lte=function(t){return this.cmp(t)<=0},h.prototype.eqn=function(t){return 0===this.cmpn(t)},h.prototype.eq=function(t){return 0===this.cmp(t)},h.red=function(t){return new _(t)},h.prototype.toRed=function(t){return r(!this.red,"Already a number in reduction context"),r(0===this.negative,"red works only with positives"),t.convertTo(this)._forceRed(t)},h.prototype.fromRed=function(){return r(this.red,"fromRed works only with numbers in reduction context"),this.red.convertFrom(this)},h.prototype._forceRed=function(t){return this.red=t,this},h.prototype.forceRed=function(t){return r(!this.red,"Already a number in reduction context"),this._forceRed(t)},h.prototype.redAdd=function(t){return r(this.red,"redAdd works only with red numbers"),this.red.add(this,t)},h.prototype.redIAdd=function(t){return r(this.red,"redIAdd works only with red numbers"),this.red.iadd(this,t)},h.prototype.redSub=function(t){return r(this.red,"redSub works only with red numbers"),this.red.sub(this,t)},h.prototype.redISub=function(t){return r(this.red,"redISub works only with red numbers"),this.red.isub(this,t)},h.prototype.redShl=function(t){return r(this.red,"redShl works only with red numbers"),this.red.shl(this,t)},h.prototype.redMul=function(t){return r(this.red,"redMul works only with red numbers"),this.red._verify2(this,t),this.red.mul(this,t)},h.prototype.redIMul=function(t){return r(this.red,"redMul works only with red numbers"),this.red._verify2(this,t),this.red.imul(this,t)},h.prototype.redSqr=function(){return r(this.red,"redSqr works only with red numbers"),this.red._verify1(this),this.red.sqr(this)},h.prototype.redISqr=function(){return r(this.red,"redISqr works only with red numbers"),this.red._verify1(this),this.red.isqr(this)},h.prototype.redSqrt=function(){return r(this.red,"redSqrt works only with red numbers"),this.red._verify1(this),this.red.sqrt(this)},h.prototype.redInvm=function(){return r(this.red,"redInvm works only with red numbers"),this.red._verify1(this),this.red.invm(this)},h.prototype.redNeg=function(){return r(this.red,"redNeg works only with red numbers"),this.red._verify1(this),this.red.neg(this)},h.prototype.redPow=function(t){return r(this.red&&!t.red,"redPow(normalNum)"),this.red._verify1(this),this.red.pow(this,t)};var v={k256:null,p224:null,p192:null,p25519:null};function g(t,i){this.name=t,this.p=new h(i,16),this.n=this.p.bitLength(),this.k=new h(1).iushln(this.n).isub(this.p),this.tmp=this._tmp()}function c(){g.call(this,"k256","ffffffff ffffffff ffffffff ffffffff ffffffff ffffffff fffffffe fffffc2f")}function w(){g.call(this,"p224","ffffffff ffffffff ffffffff ffffffff 00000000 00000000 00000001")}function y(){g.call(this,"p192","ffffffff ffffffff ffffffff fffffffe ffffffff ffffffff")}function b(){g.call(this,"25519","7fffffffffffffff ffffffffffffffff ffffffffffffffff ffffffffffffffed")}function _(t){if("string"==typeof t){var i=h._prime(t);this.m=i.p,this.prime=i}else r(t.gtn(1),"modulus must be greater than 1"),this.m=t,this.prime=null}function k(t){_.call(this,t),this.shift=this.m.bitLength(),this.shift%26!=0&&(this.shift+=26-this.shift%26),this.r=new h(1).iushln(this.shift),this.r2=this.imod(this.r.sqr()),this.rinv=this.r._invmp(this.m),this.minv=this.rinv.mul(this.r).isubn(1).div(this.m),this.minv=this.minv.umod(this.r),this.minv=this.r.sub(this.minv)}g.prototype._tmp=function(){var t=new h(null);return t.words=new Array(Math.ceil(this.n/13)),t},g.prototype.ireduce=function(t){var i,r=t;do{this.split(r,this.tmp),i=(r=(r=this.imulK(r)).iadd(this.tmp)).bitLength()}while(i>this.n);var n=i0?r.isub(this.p):void 0!==r.strip?r.strip():r._strip(),r},g.prototype.split=function(t,i){t.iushrn(this.n,0,i)},g.prototype.imulK=function(t){return t.imul(this.k)},n(c,g),c.prototype.split=function(t,i){for(var r=Math.min(t.length,9),n=0;n>>22,h=e}h>>>=22,t.words[n-10]=h,0===h&&t.length>10?t.length-=10:t.length-=9},c.prototype.imulK=function(t){t.words[t.length]=0,t.words[t.length+1]=0,t.length+=2;for(var i=0,r=0;r>>=26,t.words[r]=h,i=n}return 0!==i&&(t.words[t.length++]=i),t},h._prime=function(t){if(v[t])return v[t];var i;if("k256"===t)i=new c;else if("p224"===t)i=new w;else if("p192"===t)i=new y;else{if("p25519"!==t)throw new Error("Unknown prime "+t);i=new b}return v[t]=i,i},_.prototype._verify1=function(t){r(0===t.negative,"red works only with positives"),r(t.red,"red works only with red numbers")},_.prototype._verify2=function(t,i){r(0==(t.negative|i.negative),"red works only with positives"),r(t.red&&t.red===i.red,"red works only with red numbers")},_.prototype.imod=function(t){return this.prime?this.prime.ireduce(t)._forceRed(this):t.umod(this.m)._forceRed(this)},_.prototype.neg=function(t){return t.isZero()?t.clone():this.m.sub(t)._forceRed(this)},_.prototype.add=function(t,i){this._verify2(t,i);var r=t.add(i);return r.cmp(this.m)>=0&&r.isub(this.m),r._forceRed(this)},_.prototype.iadd=function(t,i){this._verify2(t,i);var r=t.iadd(i);return r.cmp(this.m)>=0&&r.isub(this.m),r},_.prototype.sub=function(t,i){this._verify2(t,i);var r=t.sub(i);return r.cmpn(0)<0&&r.iadd(this.m),r._forceRed(this)},_.prototype.isub=function(t,i){this._verify2(t,i);var r=t.isub(i);return r.cmpn(0)<0&&r.iadd(this.m),r},_.prototype.shl=function(t,i){return this._verify1(t),this.imod(t.ushln(i))},_.prototype.imul=function(t,i){return this._verify2(t,i),this.imod(t.imul(i))},_.prototype.mul=function(t,i){return this._verify2(t,i),this.imod(t.mul(i))},_.prototype.isqr=function(t){return this.imul(t,t.clone())},_.prototype.sqr=function(t){return this.mul(t,t)},_.prototype.sqrt=function(t){if(t.isZero())return t.clone();var i=this.m.andln(3);if(r(i%2==1),3===i){var n=this.m.add(new h(1)).iushrn(2);return this.pow(t,n)}for(var e=this.m.subn(1),o=0;!e.isZero()&&0===e.andln(1);)o++,e.iushrn(1);r(!e.isZero());var s=new h(1).toRed(this),u=s.redNeg(),a=this.m.subn(1).iushrn(1),l=this.m.bitLength();for(l=new h(2*l*l).toRed(this);0!==this.pow(l,a).cmp(u);)l.redIAdd(u);for(var m=this.pow(l,e),f=this.pow(t,e.addn(1).iushrn(1)),d=this.pow(t,e),p=o;0!==d.cmp(s);){for(var M=d,v=0;0!==M.cmp(s);v++)M=M.redSqr();r(v=0;n--){for(var a=i.words[n],l=u-1;l>=0;l--){var m=a>>l&1;e!==r[0]&&(e=this.sqr(e)),0!==m||0!==o?(o<<=1,o|=m,(4===++s||0===n&&0===l)&&(e=this.mul(e,r[o]),s=0,o=0)):s=0}u=26}return e},_.prototype.convertTo=function(t){var i=t.umod(this.m);return i===t?i.clone():i},_.prototype.convertFrom=function(t){var i=t.clone();return i.red=null,i},h.mont=function(t){return new k(t)},n(k,_),k.prototype.convertTo=function(t){return this.imod(t.ushln(this.shift))},k.prototype.convertFrom=function(t){var i=this.imod(t.mul(this.rinv));return i.red=null,i},k.prototype.imul=function(t,i){if(t.isZero()||i.isZero())return t.words[0]=0,t.length=1,t;var r=t.imul(i),n=r.maskn(this.shift).mul(this.minv).imaskn(this.shift).mul(this.m),h=r.isub(n).iushrn(this.shift),e=h;return h.cmp(this.m)>=0?e=h.isub(this.m):h.cmpn(0)<0&&(e=h.iadd(this.m)),e._forceRed(this)},k.prototype.mul=function(t,i){if(t.isZero()||i.isZero())return new h(0)._forceRed(this);var r=t.mul(i),n=r.maskn(this.shift).mul(this.minv).imaskn(this.shift).mul(this.m),e=r.isub(n).iushrn(this.shift),o=e;return e.cmp(this.m)>=0?o=e.isub(this.m):e.cmpn(0)<0&&(o=e.iadd(this.m)),o._forceRed(this)},k.prototype.invm=function(t){return this.imod(t._invmp(this.m).mul(this.r2))._forceRed(this)}}("undefined"==typeof module||module,this); +},{"buffer":"sC8V"}],"En1q":[function(require,module,exports) { +var t;function e(t){this.rand=t}if(module.exports=function(r){return t||(t=new e(null)),t.generate(r)},module.exports.Rand=e,e.prototype.generate=function(t){return this._rand(t)},e.prototype._rand=function(t){if(this.rand.getBytes)return this.rand.getBytes(t);for(var e=new Uint8Array(t),r=0;r=0);return o},n.prototype._randrange=function(r,e){var n=e.sub(r);return r.add(this._randbelow(n))},n.prototype.test=function(e,n,t){var o=e.bitLength(),a=r.mont(e),d=new r(1).toRed(a);n||(n=Math.max(1,o/48|0));for(var i=e.subn(1),u=0;!i.testn(u);u++);for(var f=e.shrn(u),c=i.toRed(a);n>0;n--){var p=this._randrange(new r(2),i);t&&t(p);var s=p.toRed(a).redPow(f);if(0!==s.cmp(d)&&0!==s.cmp(c)){for(var m=1;m0;n--){var c=this._randrange(new r(2),d),p=e.gcd(c);if(0!==p.cmpn(1))return p;var s=c.toRed(o).redPow(u);if(0!==s.cmp(a)&&0!==s.cmp(f)){for(var m=1;mt;)w.ishrn(1);if(w.isEven()&&w.iadd(o),w.testn(1)||w.iadd(f),u.cmp(f)){if(!u.cmp(a))for(;w.mod(d).cmp(m);)w.iadd(l)}else for(;w.mod(r).cmp(c);)w.iadd(l);if(b(s=w.shrn(1))&&b(w)&&q(s)&&q(w)&&i.test(s)&&i.test(w))return w}} +},{"randombytes":"pXr2","bn.js":"o7RX","miller-rabin":"vItx"}],"qZTb":[function(require,module,exports) { +module.exports={modp1:{gen:"02",prime:"ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a63a3620ffffffffffffffff"},modp2:{gen:"02",prime:"ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece65381ffffffffffffffff"},modp5:{gen:"02",prime:"ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca237327ffffffffffffffff"},modp14:{gen:"02",prime:"ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff"},modp15:{gen:"02",prime:"ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aaac42dad33170d04507a33a85521abdf1cba64ecfb850458dbef0a8aea71575d060c7db3970f85a6e1e4c7abf5ae8cdb0933d71e8c94e04a25619dcee3d2261ad2ee6bf12ffa06d98a0864d87602733ec86a64521f2b18177b200cbbe117577a615d6c770988c0bad946e208e24fa074e5ab3143db5bfce0fd108e4b82d120a93ad2caffffffffffffffff"},modp16:{gen:"02",prime:"ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aaac42dad33170d04507a33a85521abdf1cba64ecfb850458dbef0a8aea71575d060c7db3970f85a6e1e4c7abf5ae8cdb0933d71e8c94e04a25619dcee3d2261ad2ee6bf12ffa06d98a0864d87602733ec86a64521f2b18177b200cbbe117577a615d6c770988c0bad946e208e24fa074e5ab3143db5bfce0fd108e4b82d120a92108011a723c12a787e6d788719a10bdba5b2699c327186af4e23c1a946834b6150bda2583e9ca2ad44ce8dbbbc2db04de8ef92e8efc141fbecaa6287c59474e6bc05d99b2964fa090c3a2233ba186515be7ed1f612970cee2d7afb81bdd762170481cd0069127d5b05aa993b4ea988d8fddc186ffb7dc90a6c08f4df435c934063199ffffffffffffffff"},modp17:{gen:"02",prime:"ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aaac42dad33170d04507a33a85521abdf1cba64ecfb850458dbef0a8aea71575d060c7db3970f85a6e1e4c7abf5ae8cdb0933d71e8c94e04a25619dcee3d2261ad2ee6bf12ffa06d98a0864d87602733ec86a64521f2b18177b200cbbe117577a615d6c770988c0bad946e208e24fa074e5ab3143db5bfce0fd108e4b82d120a92108011a723c12a787e6d788719a10bdba5b2699c327186af4e23c1a946834b6150bda2583e9ca2ad44ce8dbbbc2db04de8ef92e8efc141fbecaa6287c59474e6bc05d99b2964fa090c3a2233ba186515be7ed1f612970cee2d7afb81bdd762170481cd0069127d5b05aa993b4ea988d8fddc186ffb7dc90a6c08f4df435c93402849236c3fab4d27c7026c1d4dcb2602646dec9751e763dba37bdf8ff9406ad9e530ee5db382f413001aeb06a53ed9027d831179727b0865a8918da3edbebcf9b14ed44ce6cbaced4bb1bdb7f1447e6cc254b332051512bd7af426fb8f401378cd2bf5983ca01c64b92ecf032ea15d1721d03f482d7ce6e74fef6d55e702f46980c82b5a84031900b1c9e59e7c97fbec7e8f323a97a7e36cc88be0f1d45b7ff585ac54bd407b22b4154aacc8f6d7ebf48e1d814cc5ed20f8037e0a79715eef29be32806a1d58bb7c5da76f550aa3d8a1fbff0eb19ccb1a313d55cda56c9ec2ef29632387fe8d76e3c0468043e8f663f4860ee12bf2d5b0b7474d6e694f91e6dcc4024ffffffffffffffff"},modp18:{gen:"02",prime:"ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aaac42dad33170d04507a33a85521abdf1cba64ecfb850458dbef0a8aea71575d060c7db3970f85a6e1e4c7abf5ae8cdb0933d71e8c94e04a25619dcee3d2261ad2ee6bf12ffa06d98a0864d87602733ec86a64521f2b18177b200cbbe117577a615d6c770988c0bad946e208e24fa074e5ab3143db5bfce0fd108e4b82d120a92108011a723c12a787e6d788719a10bdba5b2699c327186af4e23c1a946834b6150bda2583e9ca2ad44ce8dbbbc2db04de8ef92e8efc141fbecaa6287c59474e6bc05d99b2964fa090c3a2233ba186515be7ed1f612970cee2d7afb81bdd762170481cd0069127d5b05aa993b4ea988d8fddc186ffb7dc90a6c08f4df435c93402849236c3fab4d27c7026c1d4dcb2602646dec9751e763dba37bdf8ff9406ad9e530ee5db382f413001aeb06a53ed9027d831179727b0865a8918da3edbebcf9b14ed44ce6cbaced4bb1bdb7f1447e6cc254b332051512bd7af426fb8f401378cd2bf5983ca01c64b92ecf032ea15d1721d03f482d7ce6e74fef6d55e702f46980c82b5a84031900b1c9e59e7c97fbec7e8f323a97a7e36cc88be0f1d45b7ff585ac54bd407b22b4154aacc8f6d7ebf48e1d814cc5ed20f8037e0a79715eef29be32806a1d58bb7c5da76f550aa3d8a1fbff0eb19ccb1a313d55cda56c9ec2ef29632387fe8d76e3c0468043e8f663f4860ee12bf2d5b0b7474d6e694f91e6dbe115974a3926f12fee5e438777cb6a932df8cd8bec4d073b931ba3bc832b68d9dd300741fa7bf8afc47ed2576f6936ba424663aab639c5ae4f5683423b4742bf1c978238f16cbe39d652de3fdb8befc848ad922222e04a4037c0713eb57a81a23f0c73473fc646cea306b4bcbc8862f8385ddfa9d4b7fa2c087e879683303ed5bdd3a062b3cf5b3a278a66d2a13f83f44f82ddf310ee074ab6a364597e899a0255dc164f31cc50846851df9ab48195ded7ea1b1d510bd7ee74d73faf36bc31ecfa268359046f4eb879f924009438b481c6cd7889a002ed5ee382bc9190da6fc026e479558e4475677e9aa9e3050e2765694dfc81f56e880b96e7160c980dd98edd3dfffffffffffffffff"}}; +},{}],"ikIr":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var e=require("buffer").Buffer,t=require("bn.js"),r=require("miller-rabin"),i=new r,n=new t(24),o=new t(11),s=new t(10),u=new t(3),p=new t(7),h=require("./generatePrime"),f=require("randombytes");function _(r,i){return i=i||"utf8",e.isBuffer(r)||(r=new e(r,i)),this._pub=new t(r),this}function m(r,i){return i=i||"utf8",e.isBuffer(r)||(r=new e(r,i)),this._priv=new t(r),this}module.exports=g;var c={};function a(e,t){var r=t.toString("hex"),f=[r,e.toString(16)].join("_");if(f in c)return c[f];var _,m=0;if(e.isEven()||!h.simpleSieve||!h.fermatTest(e)||!i.test(e))return m+=1,m+="02"===r||"05"===r?8:4,c[f]=m,m;switch(i.test(e.shrn(1))||(m+=2),r){case"02":e.mod(n).cmp(o)&&(m+=8);break;case"05":(_=e.mod(s)).cmp(u)&&_.cmp(p)&&(m+=8);break;default:m+=4}return c[f]=m,m}function g(e,r,i){this.setGenerator(r),this.__prime=new t(e),this._prime=t.mont(this.__prime),this._primeLen=e.length,this._pub=void 0,this._priv=void 0,this._primeCode=void 0,i?(this.setPublicKey=_,this.setPrivateKey=m):this._primeCode=8}function v(t,r){var i=new e(t.toArray());return r?i.toString(r):i}Object.defineProperty(g.prototype,"verifyError",{enumerable:!0,get:function(){return"number"!=typeof this._primeCode&&(this._primeCode=a(this.__prime,this.__gen)),this._primeCode}}),g.prototype.generateKeys=function(){return this._priv||(this._priv=new t(f(this._primeLen))),this._pub=this._gen.toRed(this._prime).redPow(this._priv).fromRed(),this.getPublicKey()},g.prototype.computeSecret=function(r){var i=(r=(r=new t(r)).toRed(this._prime)).redPow(this._priv).fromRed(),n=new e(i.toArray()),o=this.getPrime();if(n.length-1))throw new S(e);return this._writableState.defaultEncoding=e,this},Object.defineProperty(x.prototype,"writableBuffer",{enumerable:!1,get:function(){return this._writableState&&this._writableState.getBuffer()}}),Object.defineProperty(x.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}}),x.prototype._write=function(e,t,n){n(new w("_write()"))},x.prototype._writev=null,x.prototype.end=function(e,t,n){var r=this._writableState;return"function"==typeof e?(n=e,e=null,t=null):"function"==typeof t&&(n=t,t=null),null!=e&&this.write(e,t),r.corked&&(r.corked=1,this.uncork()),r.ending||H(this,r,n),this},Object.defineProperty(x.prototype,"writableLength",{enumerable:!1,get:function(){return this._writableState.length}}),Object.defineProperty(x.prototype,"destroyed",{enumerable:!1,get:function(){return void 0!==this._writableState&&this._writableState.destroyed},set:function(e){this._writableState&&(this._writableState.destroyed=e)}}),x.prototype.destroy=d.destroy,x.prototype._undestroy=d.undestroy,x.prototype._destroy=function(e,t){t(e)}; +},{"util-deprecate":"hQaz","./internal/streams/stream":"XrGN","buffer":"z1tx","./internal/streams/destroy":"VglT","./internal/streams/state":"YVfc","../errors":"S4np","inherits":"oxwV","./_stream_duplex":"wHK7","process":"g5IB"}],"wHK7":[function(require,module,exports) { +var process = require("process"); +var e=require("process"),t=Object.keys||function(e){var t=[];for(var r in e)t.push(r);return t};module.exports=l;var r=require("./_stream_readable"),a=require("./_stream_writable");require("inherits")(l,r);for(var i=t(a.prototype),n=0;n0)if("string"==typeof t||o.objectMode||Object.getPrototypeOf(t)===d.prototype||(t=s(t)),r)o.endEmitted?M(e,new R):C(e,o,t,!0);else if(o.ended)M(e,new w);else{if(o.destroyed)return!1;o.reading=!1,o.decoder&&!n?(t=o.decoder.write(t),o.objectMode||0!==t.length?C(e,o,t,!1):U(e,o)):C(e,o,t,!1)}else r||(o.reading=!1,U(e,o));return!o.ended&&(o.length=q?e=q:(e--,e|=e>>>1,e|=e>>>2,e|=e>>>4,e|=e>>>8,e|=e>>>16,e++),e}function x(e,t){return e<=0||0===t.length&&t.ended?0:t.objectMode?1:e!=e?t.flowing&&t.length?t.buffer.head.data.length:t.length:(e>t.highWaterMark&&(t.highWaterMark=W(e)),e<=t.length?e:t.ended?t.length:(t.needReadable=!0,0))}function A(e,t){if(u("onEofChunk"),!t.ended){if(t.decoder){var n=t.decoder.end();n&&n.length&&(t.buffer.push(n),t.length+=t.objectMode?1:n.length)}t.ended=!0,t.sync?O(e):(t.needReadable=!1,t.emittedReadable||(t.emittedReadable=!0,P(e)))}}function O(e){var t=e._readableState;u("emitReadable",t.needReadable,t.emittedReadable),t.needReadable=!1,t.emittedReadable||(u("emitReadable",t.flowing),t.emittedReadable=!0,n.nextTick(P,e))}function P(e){var t=e._readableState;u("emitReadable_",t.destroyed,t.length,t.ended),t.destroyed||!t.length&&!t.ended||(e.emit("readable"),t.emittedReadable=!1),t.needReadable=!t.flowing&&!t.ended&&t.length<=t.highWaterMark,G(e)}function U(e,t){t.readingMore||(t.readingMore=!0,n.nextTick(N,e,t))}function N(e,t){for(;!t.reading&&!t.ended&&(t.length0,t.resumeScheduled&&!t.paused?t.flowing=!0:e.listenerCount("data")>0&&e.resume()}function F(e){u("readable nexttick read 0"),e.read(0)}function B(e,t){t.resumeScheduled||(t.resumeScheduled=!0,n.nextTick(V,e,t))}function V(e,t){u("resume",t.reading),t.reading||e.read(0),t.resumeScheduled=!1,e.emit("resume"),G(e),t.flowing&&!t.reading&&e.read(0)}function G(e){var t=e._readableState;for(u("flow",t.flowing);t.flowing&&null!==e.read(););}function Y(e,t){return 0===t.length?null:(t.objectMode?n=t.buffer.shift():!e||e>=t.length?(n=t.decoder?t.buffer.join(""):1===t.buffer.length?t.buffer.first():t.buffer.concat(t.length),t.buffer.clear()):n=t.buffer.consume(e,t.decoder),n);var n}function z(e){var t=e._readableState;u("endReadable",t.endEmitted),t.endEmitted||(t.ended=!0,n.nextTick(J,t,e))}function J(e,t){if(u("endReadableNT",e.endEmitted,e.length),!e.endEmitted&&0===e.length&&(e.endEmitted=!0,t.readable=!1,t.emit("end"),e.autoDestroy)){var n=t._writableState;(!n||n.autoDestroy&&n.finished)&&t.destroy()}}function K(e,t){for(var n=0,r=e.length;n=t.highWaterMark:t.length>0)||t.ended))return u("read: emitReadable",t.length,t.ended),0===t.length&&t.ended?z(this):O(this),null;if(0===(e=x(e,t))&&t.ended)return 0===t.length&&z(this),null;var r,i=t.needReadable;return u("need readable",i),(0===t.length||t.length-e0?Y(e,t):null)?(t.needReadable=t.length<=t.highWaterMark,e=0):(t.length-=e,t.awaitDrain=0),0===t.length&&(t.ended||(t.needReadable=!0),n!==e&&t.ended&&z(this)),null!==r&&this.emit("data",r),r},j.prototype._read=function(e){M(this,new S("_read()"))},j.prototype.pipe=function(e,t){var r=this,a=this._readableState;switch(a.pipesCount){case 0:a.pipes=e;break;case 1:a.pipes=[a.pipes,e];break;default:a.pipes.push(e)}a.pipesCount+=1,u("pipe count=%d opts=%j",a.pipesCount,t);var d=(!t||!1!==t.end)&&e!==n.stdout&&e!==n.stderr?s:g;function o(t,n){u("onunpipe"),t===r&&n&&!1===n.hasUnpiped&&(n.hasUnpiped=!0,u("cleanup"),e.removeListener("close",c),e.removeListener("finish",b),e.removeListener("drain",l),e.removeListener("error",f),e.removeListener("unpipe",o),r.removeListener("end",s),r.removeListener("end",g),r.removeListener("data",p),h=!0,!a.awaitDrain||e._writableState&&!e._writableState.needDrain||l())}function s(){u("onend"),e.end()}a.endEmitted?n.nextTick(d):r.once("end",d),e.on("unpipe",o);var l=H(r);e.on("drain",l);var h=!1;function p(t){u("ondata");var n=e.write(t);u("dest.write",n),!1===n&&((1===a.pipesCount&&a.pipes===e||a.pipesCount>1&&-1!==K(a.pipes,e))&&!h&&(u("false write response, pause",a.awaitDrain),a.awaitDrain++),r.pause())}function f(t){u("onerror",t),g(),e.removeListener("error",f),0===i(e,"error")&&M(e,t)}function c(){e.removeListener("finish",b),g()}function b(){u("onfinish"),e.removeListener("close",c),g()}function g(){u("unpipe"),r.unpipe(e)}return r.on("data",p),k(e,"error",f),e.once("close",c),e.once("finish",b),e.emit("pipe",r),a.flowing||(u("pipe resume"),r.resume()),e},j.prototype.unpipe=function(e){var t=this._readableState,n={hasUnpiped:!1};if(0===t.pipesCount)return this;if(1===t.pipesCount)return e&&e!==t.pipes?this:(e||(e=t.pipes),t.pipes=null,t.pipesCount=0,t.flowing=!1,e&&e.emit("unpipe",this,n),this);if(!e){var r=t.pipes,i=t.pipesCount;t.pipes=null,t.pipesCount=0,t.flowing=!1;for(var a=0;a0,!1!==i.flowing&&this.resume()):"readable"===e&&(i.endEmitted||i.readableListening||(i.readableListening=i.needReadable=!0,i.flowing=!1,i.emittedReadable=!1,u("on readable",i.length,i.reading),i.length?O(this):i.reading||n.nextTick(F,this))),r},j.prototype.addListener=j.prototype.on,j.prototype.removeListener=function(e,t){var r=a.prototype.removeListener.call(this,e,t);return"readable"===e&&n.nextTick(I,this),r},j.prototype.removeAllListeners=function(e){var t=a.prototype.removeAllListeners.apply(this,arguments);return"readable"!==e&&void 0!==e||n.nextTick(I,this),t},j.prototype.resume=function(){var e=this._readableState;return e.flowing||(u("resume"),e.flowing=!e.readableListening,B(this,e)),e.paused=!1,this},j.prototype.pause=function(){return u("call pause flowing=%j",this._readableState.flowing),!1!==this._readableState.flowing&&(u("pause"),this._readableState.flowing=!1,this.emit("pause")),this._readableState.paused=!0,this},j.prototype.wrap=function(e){var t=this,n=this._readableState,r=!1;for(var i in e.on("end",function(){if(u("wrapped end"),n.decoder&&!n.ended){var e=n.decoder.end();e&&e.length&&t.push(e)}t.push(null)}),e.on("data",function(i){(u("wrapped data"),n.decoder&&(i=n.decoder.write(i)),n.objectMode&&null==i)||(n.objectMode||i&&i.length)&&(t.push(i)||(r=!0,e.pause()))}),e)void 0===this[i]&&"function"==typeof e[i]&&(this[i]=function(t){return function(){return e[t].apply(e,arguments)}}(i));for(var a=0;a0,function(r){o||(o=r),r&&u.forEach(a),e||(u.forEach(a),i(o))})});return n.reduce(c)}module.exports=s; +},{"../../../errors":"S4np","./end-of-stream":"d4QQ"}],"R738":[function(require,module,exports) { +exports=module.exports=require("./lib/_stream_readable.js"),exports.Stream=exports,exports.Readable=exports,exports.Writable=require("./lib/_stream_writable.js"),exports.Duplex=require("./lib/_stream_duplex.js"),exports.Transform=require("./lib/_stream_transform.js"),exports.PassThrough=require("./lib/_stream_passthrough.js"),exports.finished=require("./lib/internal/streams/end-of-stream.js"),exports.pipeline=require("./lib/internal/streams/pipeline.js"); +},{"./lib/_stream_readable.js":"psWw","./lib/_stream_writable.js":"XwTe","./lib/_stream_duplex.js":"wHK7","./lib/_stream_transform.js":"ZVdB","./lib/_stream_passthrough.js":"NgZz","./lib/internal/streams/end-of-stream.js":"d4QQ","./lib/internal/streams/pipeline.js":"rtLt"}],"EOUW":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var t=require("buffer").Buffer;!function(t,i){"use strict";function r(t,i){if(!t)throw new Error(i||"Assertion failed")}function n(t,i){t.super_=i;var r=function(){};r.prototype=i.prototype,t.prototype=new r,t.prototype.constructor=t}function h(t,i,r){if(h.isBN(t))return t;this.negative=0,this.words=null,this.length=0,this.red=null,null!==t&&("le"!==i&&"be"!==i||(r=i,i=10),this._init(t||0,i||10,r||"be"))}var e;"object"==typeof t?t.exports=h:i.BN=h,h.BN=h,h.wordSize=26;try{e="undefined"!=typeof window&&void 0!==window.Buffer?window.Buffer:require("buffer").Buffer}catch(B){}function o(t,i){var n=t.charCodeAt(i);return n>=48&&n<=57?n-48:n>=65&&n<=70?n-55:n>=97&&n<=102?n-87:void r(!1,"Invalid character in "+t)}function s(t,i,r){var n=o(t,r);return r-1>=i&&(n|=o(t,r-1)<<4),n}function u(t,i,n,h){for(var e=0,o=0,s=Math.min(t.length,n),u=i;u=49?a-49+10:a>=17?a-17+10:a,r(a>=0&&o0?t:i},h.min=function(t,i){return t.cmp(i)<0?t:i},h.prototype._init=function(t,i,n){if("number"==typeof t)return this._initNumber(t,i,n);if("object"==typeof t)return this._initArray(t,i,n);"hex"===i&&(i=16),r(i===(0|i)&&i>=2&&i<=36);var h=0;"-"===(t=t.toString().replace(/\s+/g,""))[0]&&(h++,this.negative=1),h=0;h-=3)o=t[h]|t[h-1]<<8|t[h-2]<<16,this.words[e]|=o<>>26-s&67108863,(s+=24)>=26&&(s-=26,e++);else if("le"===n)for(h=0,e=0;h>>26-s&67108863,(s+=24)>=26&&(s-=26,e++);return this._strip()},h.prototype._parseHex=function(t,i,r){this.length=Math.ceil((t.length-i)/6),this.words=new Array(this.length);for(var n=0;n=i;n-=2)h=s(t,i,n)<=18?(e-=18,o+=1,this.words[o]|=h>>>26):e+=8;else for(n=(t.length-i)%2==0?i+1:i;n=18?(e-=18,o+=1,this.words[o]|=h>>>26):e+=8;this._strip()},h.prototype._parseBase=function(t,i,r){this.words=[0],this.length=1;for(var n=0,h=1;h<=67108863;h*=i)n++;n--,h=h/i|0;for(var e=t.length-r,o=e%n,s=Math.min(e,e-o)+r,a=0,l=r;l1&&0===this.words[this.length-1];)this.length--;return this._normSign()},h.prototype._normSign=function(){return 1===this.length&&0===this.words[0]&&(this.negative=0),this},"undefined"!=typeof Symbol&&"function"==typeof Symbol.for)try{h.prototype[Symbol.for("nodejs.util.inspect.custom")]=l}catch(B){h.prototype.inspect=l}else h.prototype.inspect=l;function l(){return(this.red?""}var m=["","0","00","000","0000","00000","000000","0000000","00000000","000000000","0000000000","00000000000","000000000000","0000000000000","00000000000000","000000000000000","0000000000000000","00000000000000000","000000000000000000","0000000000000000000","00000000000000000000","000000000000000000000","0000000000000000000000","00000000000000000000000","000000000000000000000000","0000000000000000000000000"],f=[0,0,25,16,12,11,10,9,8,8,7,7,7,7,6,6,6,6,6,6,6,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],d=[0,0,33554432,43046721,16777216,48828125,60466176,40353607,16777216,43046721,1e7,19487171,35831808,62748517,7529536,11390625,16777216,24137569,34012224,47045881,64e6,4084101,5153632,6436343,7962624,9765625,11881376,14348907,17210368,20511149,243e5,28629151,33554432,39135393,45435424,52521875,60466176];h.prototype.toString=function(t,i){var n;if(i=0|i||1,16===(t=t||10)||"hex"===t){n="";for(var h=0,e=0,o=0;o>>24-h&16777215)||o!==this.length-1?m[6-u.length]+u+n:u+n,(h+=2)>=26&&(h-=26,o--)}for(0!==e&&(n=e.toString(16)+n);n.length%i!=0;)n="0"+n;return 0!==this.negative&&(n="-"+n),n}if(t===(0|t)&&t>=2&&t<=36){var a=f[t],l=d[t];n="";var p=this.clone();for(p.negative=0;!p.isZero();){var M=p.modrn(l).toString(t);n=(p=p.idivn(l)).isZero()?M+n:m[a-M.length]+M+n}for(this.isZero()&&(n="0"+n);n.length%i!=0;)n="0"+n;return 0!==this.negative&&(n="-"+n),n}r(!1,"Base should be between 2 and 36")},h.prototype.toNumber=function(){var t=this.words[0];return 2===this.length?t+=67108864*this.words[1]:3===this.length&&1===this.words[2]?t+=4503599627370496+67108864*this.words[1]:this.length>2&&r(!1,"Number can only safely store up to 53 bits"),0!==this.negative?-t:t},h.prototype.toJSON=function(){return this.toString(16,2)},e&&(h.prototype.toBuffer=function(t,i){return this.toArrayLike(e,t,i)}),h.prototype.toArray=function(t,i){return this.toArrayLike(Array,t,i)};function p(t,i,r){r.negative=i.negative^t.negative;var n=t.length+i.length|0;r.length=n,n=n-1|0;var h=0|t.words[0],e=0|i.words[0],o=h*e,s=67108863&o,u=o/67108864|0;r.words[0]=s;for(var a=1;a>>26,m=67108863&u,f=Math.min(a,i.length-1),d=Math.max(0,a-t.length+1);d<=f;d++){var p=a-d|0;l+=(o=(h=0|t.words[p])*(e=0|i.words[d])+m)/67108864|0,m=67108863&o}r.words[a]=0|m,u=0|l}return 0!==u?r.words[a]=0|u:r.length--,r._strip()}h.prototype.toArrayLike=function(t,i,n){this._strip();var h=this.byteLength(),e=n||Math.max(1,h);r(h<=e,"byte array longer than desired length"),r(e>0,"Requested array length <= 0");var o=function(t,i){return t.allocUnsafe?t.allocUnsafe(i):new t(i)}(t,e);return this["_toArrayLike"+("le"===i?"LE":"BE")](o,h),o},h.prototype._toArrayLikeLE=function(t,i){for(var r=0,n=0,h=0,e=0;h>8&255),r>16&255),6===e?(r>24&255),n=0,e=0):(n=o>>>24,e+=2)}if(r=0&&(t[r--]=o>>8&255),r>=0&&(t[r--]=o>>16&255),6===e?(r>=0&&(t[r--]=o>>24&255),n=0,e=0):(n=o>>>24,e+=2)}if(r>=0)for(t[r--]=n;r>=0;)t[r--]=0},Math.clz32?h.prototype._countBits=function(t){return 32-Math.clz32(t)}:h.prototype._countBits=function(t){var i=t,r=0;return i>=4096&&(r+=13,i>>>=13),i>=64&&(r+=7,i>>>=7),i>=8&&(r+=4,i>>>=4),i>=2&&(r+=2,i>>>=2),r+i},h.prototype._zeroBits=function(t){if(0===t)return 26;var i=t,r=0;return 0==(8191&i)&&(r+=13,i>>>=13),0==(127&i)&&(r+=7,i>>>=7),0==(15&i)&&(r+=4,i>>>=4),0==(3&i)&&(r+=2,i>>>=2),0==(1&i)&&r++,r},h.prototype.bitLength=function(){var t=this.words[this.length-1],i=this._countBits(t);return 26*(this.length-1)+i},h.prototype.zeroBits=function(){if(this.isZero())return 0;for(var t=0,i=0;it.length?this.clone().ior(t):t.clone().ior(this)},h.prototype.uor=function(t){return this.length>t.length?this.clone().iuor(t):t.clone().iuor(this)},h.prototype.iuand=function(t){var i;i=this.length>t.length?t:this;for(var r=0;rt.length?this.clone().iand(t):t.clone().iand(this)},h.prototype.uand=function(t){return this.length>t.length?this.clone().iuand(t):t.clone().iuand(this)},h.prototype.iuxor=function(t){var i,r;this.length>t.length?(i=this,r=t):(i=t,r=this);for(var n=0;nt.length?this.clone().ixor(t):t.clone().ixor(this)},h.prototype.uxor=function(t){return this.length>t.length?this.clone().iuxor(t):t.clone().iuxor(this)},h.prototype.inotn=function(t){r("number"==typeof t&&t>=0);var i=0|Math.ceil(t/26),n=t%26;this._expand(i),n>0&&i--;for(var h=0;h0&&(this.words[h]=~this.words[h]&67108863>>26-n),this._strip()},h.prototype.notn=function(t){return this.clone().inotn(t)},h.prototype.setn=function(t,i){r("number"==typeof t&&t>=0);var n=t/26|0,h=t%26;return this._expand(n+1),this.words[n]=i?this.words[n]|1<t.length?(r=this,n=t):(r=t,n=this);for(var h=0,e=0;e>>26;for(;0!==h&&e>>26;if(this.length=r.length,0!==h)this.words[this.length]=h,this.length++;else if(r!==this)for(;et.length?this.clone().iadd(t):t.clone().iadd(this)},h.prototype.isub=function(t){if(0!==t.negative){t.negative=0;var i=this.iadd(t);return t.negative=1,i._normSign()}if(0!==this.negative)return this.negative=0,this.iadd(t),this.negative=1,this._normSign();var r,n,h=this.cmp(t);if(0===h)return this.negative=0,this.length=1,this.words[0]=0,this;h>0?(r=this,n=t):(r=t,n=this);for(var e=0,o=0;o>26,this.words[o]=67108863&i;for(;0!==e&&o>26,this.words[o]=67108863&i;if(0===e&&o>>13,d=0|o[1],p=8191&d,M=d>>>13,v=0|o[2],g=8191&v,c=v>>>13,w=0|o[3],y=8191&w,b=w>>>13,_=0|o[4],k=8191&_,A=_>>>13,S=0|o[5],x=8191&S,B=S>>>13,q=0|o[6],R=8191&q,Z=q>>>13,L=0|o[7],N=8191&L,I=L>>>13,E=0|o[8],z=8191&E,T=E>>>13,O=0|o[9],j=8191&O,K=O>>>13,P=0|s[0],F=8191&P,U=P>>>13,C=0|s[1],D=8191&C,H=C>>>13,J=0|s[2],G=8191&J,Q=J>>>13,V=0|s[3],W=8191&V,X=V>>>13,Y=0|s[4],$=8191&Y,tt=Y>>>13,it=0|s[5],rt=8191&it,nt=it>>>13,ht=0|s[6],et=8191&ht,ot=ht>>>13,st=0|s[7],ut=8191&st,at=st>>>13,lt=0|s[8],mt=8191<,ft=lt>>>13,dt=0|s[9],pt=8191&dt,Mt=dt>>>13;r.negative=t.negative^i.negative,r.length=19;var vt=(a+(n=Math.imul(m,F))|0)+((8191&(h=(h=Math.imul(m,U))+Math.imul(f,F)|0))<<13)|0;a=((e=Math.imul(f,U))+(h>>>13)|0)+(vt>>>26)|0,vt&=67108863,n=Math.imul(p,F),h=(h=Math.imul(p,U))+Math.imul(M,F)|0,e=Math.imul(M,U);var gt=(a+(n=n+Math.imul(m,D)|0)|0)+((8191&(h=(h=h+Math.imul(m,H)|0)+Math.imul(f,D)|0))<<13)|0;a=((e=e+Math.imul(f,H)|0)+(h>>>13)|0)+(gt>>>26)|0,gt&=67108863,n=Math.imul(g,F),h=(h=Math.imul(g,U))+Math.imul(c,F)|0,e=Math.imul(c,U),n=n+Math.imul(p,D)|0,h=(h=h+Math.imul(p,H)|0)+Math.imul(M,D)|0,e=e+Math.imul(M,H)|0;var ct=(a+(n=n+Math.imul(m,G)|0)|0)+((8191&(h=(h=h+Math.imul(m,Q)|0)+Math.imul(f,G)|0))<<13)|0;a=((e=e+Math.imul(f,Q)|0)+(h>>>13)|0)+(ct>>>26)|0,ct&=67108863,n=Math.imul(y,F),h=(h=Math.imul(y,U))+Math.imul(b,F)|0,e=Math.imul(b,U),n=n+Math.imul(g,D)|0,h=(h=h+Math.imul(g,H)|0)+Math.imul(c,D)|0,e=e+Math.imul(c,H)|0,n=n+Math.imul(p,G)|0,h=(h=h+Math.imul(p,Q)|0)+Math.imul(M,G)|0,e=e+Math.imul(M,Q)|0;var wt=(a+(n=n+Math.imul(m,W)|0)|0)+((8191&(h=(h=h+Math.imul(m,X)|0)+Math.imul(f,W)|0))<<13)|0;a=((e=e+Math.imul(f,X)|0)+(h>>>13)|0)+(wt>>>26)|0,wt&=67108863,n=Math.imul(k,F),h=(h=Math.imul(k,U))+Math.imul(A,F)|0,e=Math.imul(A,U),n=n+Math.imul(y,D)|0,h=(h=h+Math.imul(y,H)|0)+Math.imul(b,D)|0,e=e+Math.imul(b,H)|0,n=n+Math.imul(g,G)|0,h=(h=h+Math.imul(g,Q)|0)+Math.imul(c,G)|0,e=e+Math.imul(c,Q)|0,n=n+Math.imul(p,W)|0,h=(h=h+Math.imul(p,X)|0)+Math.imul(M,W)|0,e=e+Math.imul(M,X)|0;var yt=(a+(n=n+Math.imul(m,$)|0)|0)+((8191&(h=(h=h+Math.imul(m,tt)|0)+Math.imul(f,$)|0))<<13)|0;a=((e=e+Math.imul(f,tt)|0)+(h>>>13)|0)+(yt>>>26)|0,yt&=67108863,n=Math.imul(x,F),h=(h=Math.imul(x,U))+Math.imul(B,F)|0,e=Math.imul(B,U),n=n+Math.imul(k,D)|0,h=(h=h+Math.imul(k,H)|0)+Math.imul(A,D)|0,e=e+Math.imul(A,H)|0,n=n+Math.imul(y,G)|0,h=(h=h+Math.imul(y,Q)|0)+Math.imul(b,G)|0,e=e+Math.imul(b,Q)|0,n=n+Math.imul(g,W)|0,h=(h=h+Math.imul(g,X)|0)+Math.imul(c,W)|0,e=e+Math.imul(c,X)|0,n=n+Math.imul(p,$)|0,h=(h=h+Math.imul(p,tt)|0)+Math.imul(M,$)|0,e=e+Math.imul(M,tt)|0;var bt=(a+(n=n+Math.imul(m,rt)|0)|0)+((8191&(h=(h=h+Math.imul(m,nt)|0)+Math.imul(f,rt)|0))<<13)|0;a=((e=e+Math.imul(f,nt)|0)+(h>>>13)|0)+(bt>>>26)|0,bt&=67108863,n=Math.imul(R,F),h=(h=Math.imul(R,U))+Math.imul(Z,F)|0,e=Math.imul(Z,U),n=n+Math.imul(x,D)|0,h=(h=h+Math.imul(x,H)|0)+Math.imul(B,D)|0,e=e+Math.imul(B,H)|0,n=n+Math.imul(k,G)|0,h=(h=h+Math.imul(k,Q)|0)+Math.imul(A,G)|0,e=e+Math.imul(A,Q)|0,n=n+Math.imul(y,W)|0,h=(h=h+Math.imul(y,X)|0)+Math.imul(b,W)|0,e=e+Math.imul(b,X)|0,n=n+Math.imul(g,$)|0,h=(h=h+Math.imul(g,tt)|0)+Math.imul(c,$)|0,e=e+Math.imul(c,tt)|0,n=n+Math.imul(p,rt)|0,h=(h=h+Math.imul(p,nt)|0)+Math.imul(M,rt)|0,e=e+Math.imul(M,nt)|0;var _t=(a+(n=n+Math.imul(m,et)|0)|0)+((8191&(h=(h=h+Math.imul(m,ot)|0)+Math.imul(f,et)|0))<<13)|0;a=((e=e+Math.imul(f,ot)|0)+(h>>>13)|0)+(_t>>>26)|0,_t&=67108863,n=Math.imul(N,F),h=(h=Math.imul(N,U))+Math.imul(I,F)|0,e=Math.imul(I,U),n=n+Math.imul(R,D)|0,h=(h=h+Math.imul(R,H)|0)+Math.imul(Z,D)|0,e=e+Math.imul(Z,H)|0,n=n+Math.imul(x,G)|0,h=(h=h+Math.imul(x,Q)|0)+Math.imul(B,G)|0,e=e+Math.imul(B,Q)|0,n=n+Math.imul(k,W)|0,h=(h=h+Math.imul(k,X)|0)+Math.imul(A,W)|0,e=e+Math.imul(A,X)|0,n=n+Math.imul(y,$)|0,h=(h=h+Math.imul(y,tt)|0)+Math.imul(b,$)|0,e=e+Math.imul(b,tt)|0,n=n+Math.imul(g,rt)|0,h=(h=h+Math.imul(g,nt)|0)+Math.imul(c,rt)|0,e=e+Math.imul(c,nt)|0,n=n+Math.imul(p,et)|0,h=(h=h+Math.imul(p,ot)|0)+Math.imul(M,et)|0,e=e+Math.imul(M,ot)|0;var kt=(a+(n=n+Math.imul(m,ut)|0)|0)+((8191&(h=(h=h+Math.imul(m,at)|0)+Math.imul(f,ut)|0))<<13)|0;a=((e=e+Math.imul(f,at)|0)+(h>>>13)|0)+(kt>>>26)|0,kt&=67108863,n=Math.imul(z,F),h=(h=Math.imul(z,U))+Math.imul(T,F)|0,e=Math.imul(T,U),n=n+Math.imul(N,D)|0,h=(h=h+Math.imul(N,H)|0)+Math.imul(I,D)|0,e=e+Math.imul(I,H)|0,n=n+Math.imul(R,G)|0,h=(h=h+Math.imul(R,Q)|0)+Math.imul(Z,G)|0,e=e+Math.imul(Z,Q)|0,n=n+Math.imul(x,W)|0,h=(h=h+Math.imul(x,X)|0)+Math.imul(B,W)|0,e=e+Math.imul(B,X)|0,n=n+Math.imul(k,$)|0,h=(h=h+Math.imul(k,tt)|0)+Math.imul(A,$)|0,e=e+Math.imul(A,tt)|0,n=n+Math.imul(y,rt)|0,h=(h=h+Math.imul(y,nt)|0)+Math.imul(b,rt)|0,e=e+Math.imul(b,nt)|0,n=n+Math.imul(g,et)|0,h=(h=h+Math.imul(g,ot)|0)+Math.imul(c,et)|0,e=e+Math.imul(c,ot)|0,n=n+Math.imul(p,ut)|0,h=(h=h+Math.imul(p,at)|0)+Math.imul(M,ut)|0,e=e+Math.imul(M,at)|0;var At=(a+(n=n+Math.imul(m,mt)|0)|0)+((8191&(h=(h=h+Math.imul(m,ft)|0)+Math.imul(f,mt)|0))<<13)|0;a=((e=e+Math.imul(f,ft)|0)+(h>>>13)|0)+(At>>>26)|0,At&=67108863,n=Math.imul(j,F),h=(h=Math.imul(j,U))+Math.imul(K,F)|0,e=Math.imul(K,U),n=n+Math.imul(z,D)|0,h=(h=h+Math.imul(z,H)|0)+Math.imul(T,D)|0,e=e+Math.imul(T,H)|0,n=n+Math.imul(N,G)|0,h=(h=h+Math.imul(N,Q)|0)+Math.imul(I,G)|0,e=e+Math.imul(I,Q)|0,n=n+Math.imul(R,W)|0,h=(h=h+Math.imul(R,X)|0)+Math.imul(Z,W)|0,e=e+Math.imul(Z,X)|0,n=n+Math.imul(x,$)|0,h=(h=h+Math.imul(x,tt)|0)+Math.imul(B,$)|0,e=e+Math.imul(B,tt)|0,n=n+Math.imul(k,rt)|0,h=(h=h+Math.imul(k,nt)|0)+Math.imul(A,rt)|0,e=e+Math.imul(A,nt)|0,n=n+Math.imul(y,et)|0,h=(h=h+Math.imul(y,ot)|0)+Math.imul(b,et)|0,e=e+Math.imul(b,ot)|0,n=n+Math.imul(g,ut)|0,h=(h=h+Math.imul(g,at)|0)+Math.imul(c,ut)|0,e=e+Math.imul(c,at)|0,n=n+Math.imul(p,mt)|0,h=(h=h+Math.imul(p,ft)|0)+Math.imul(M,mt)|0,e=e+Math.imul(M,ft)|0;var St=(a+(n=n+Math.imul(m,pt)|0)|0)+((8191&(h=(h=h+Math.imul(m,Mt)|0)+Math.imul(f,pt)|0))<<13)|0;a=((e=e+Math.imul(f,Mt)|0)+(h>>>13)|0)+(St>>>26)|0,St&=67108863,n=Math.imul(j,D),h=(h=Math.imul(j,H))+Math.imul(K,D)|0,e=Math.imul(K,H),n=n+Math.imul(z,G)|0,h=(h=h+Math.imul(z,Q)|0)+Math.imul(T,G)|0,e=e+Math.imul(T,Q)|0,n=n+Math.imul(N,W)|0,h=(h=h+Math.imul(N,X)|0)+Math.imul(I,W)|0,e=e+Math.imul(I,X)|0,n=n+Math.imul(R,$)|0,h=(h=h+Math.imul(R,tt)|0)+Math.imul(Z,$)|0,e=e+Math.imul(Z,tt)|0,n=n+Math.imul(x,rt)|0,h=(h=h+Math.imul(x,nt)|0)+Math.imul(B,rt)|0,e=e+Math.imul(B,nt)|0,n=n+Math.imul(k,et)|0,h=(h=h+Math.imul(k,ot)|0)+Math.imul(A,et)|0,e=e+Math.imul(A,ot)|0,n=n+Math.imul(y,ut)|0,h=(h=h+Math.imul(y,at)|0)+Math.imul(b,ut)|0,e=e+Math.imul(b,at)|0,n=n+Math.imul(g,mt)|0,h=(h=h+Math.imul(g,ft)|0)+Math.imul(c,mt)|0,e=e+Math.imul(c,ft)|0;var xt=(a+(n=n+Math.imul(p,pt)|0)|0)+((8191&(h=(h=h+Math.imul(p,Mt)|0)+Math.imul(M,pt)|0))<<13)|0;a=((e=e+Math.imul(M,Mt)|0)+(h>>>13)|0)+(xt>>>26)|0,xt&=67108863,n=Math.imul(j,G),h=(h=Math.imul(j,Q))+Math.imul(K,G)|0,e=Math.imul(K,Q),n=n+Math.imul(z,W)|0,h=(h=h+Math.imul(z,X)|0)+Math.imul(T,W)|0,e=e+Math.imul(T,X)|0,n=n+Math.imul(N,$)|0,h=(h=h+Math.imul(N,tt)|0)+Math.imul(I,$)|0,e=e+Math.imul(I,tt)|0,n=n+Math.imul(R,rt)|0,h=(h=h+Math.imul(R,nt)|0)+Math.imul(Z,rt)|0,e=e+Math.imul(Z,nt)|0,n=n+Math.imul(x,et)|0,h=(h=h+Math.imul(x,ot)|0)+Math.imul(B,et)|0,e=e+Math.imul(B,ot)|0,n=n+Math.imul(k,ut)|0,h=(h=h+Math.imul(k,at)|0)+Math.imul(A,ut)|0,e=e+Math.imul(A,at)|0,n=n+Math.imul(y,mt)|0,h=(h=h+Math.imul(y,ft)|0)+Math.imul(b,mt)|0,e=e+Math.imul(b,ft)|0;var Bt=(a+(n=n+Math.imul(g,pt)|0)|0)+((8191&(h=(h=h+Math.imul(g,Mt)|0)+Math.imul(c,pt)|0))<<13)|0;a=((e=e+Math.imul(c,Mt)|0)+(h>>>13)|0)+(Bt>>>26)|0,Bt&=67108863,n=Math.imul(j,W),h=(h=Math.imul(j,X))+Math.imul(K,W)|0,e=Math.imul(K,X),n=n+Math.imul(z,$)|0,h=(h=h+Math.imul(z,tt)|0)+Math.imul(T,$)|0,e=e+Math.imul(T,tt)|0,n=n+Math.imul(N,rt)|0,h=(h=h+Math.imul(N,nt)|0)+Math.imul(I,rt)|0,e=e+Math.imul(I,nt)|0,n=n+Math.imul(R,et)|0,h=(h=h+Math.imul(R,ot)|0)+Math.imul(Z,et)|0,e=e+Math.imul(Z,ot)|0,n=n+Math.imul(x,ut)|0,h=(h=h+Math.imul(x,at)|0)+Math.imul(B,ut)|0,e=e+Math.imul(B,at)|0,n=n+Math.imul(k,mt)|0,h=(h=h+Math.imul(k,ft)|0)+Math.imul(A,mt)|0,e=e+Math.imul(A,ft)|0;var qt=(a+(n=n+Math.imul(y,pt)|0)|0)+((8191&(h=(h=h+Math.imul(y,Mt)|0)+Math.imul(b,pt)|0))<<13)|0;a=((e=e+Math.imul(b,Mt)|0)+(h>>>13)|0)+(qt>>>26)|0,qt&=67108863,n=Math.imul(j,$),h=(h=Math.imul(j,tt))+Math.imul(K,$)|0,e=Math.imul(K,tt),n=n+Math.imul(z,rt)|0,h=(h=h+Math.imul(z,nt)|0)+Math.imul(T,rt)|0,e=e+Math.imul(T,nt)|0,n=n+Math.imul(N,et)|0,h=(h=h+Math.imul(N,ot)|0)+Math.imul(I,et)|0,e=e+Math.imul(I,ot)|0,n=n+Math.imul(R,ut)|0,h=(h=h+Math.imul(R,at)|0)+Math.imul(Z,ut)|0,e=e+Math.imul(Z,at)|0,n=n+Math.imul(x,mt)|0,h=(h=h+Math.imul(x,ft)|0)+Math.imul(B,mt)|0,e=e+Math.imul(B,ft)|0;var Rt=(a+(n=n+Math.imul(k,pt)|0)|0)+((8191&(h=(h=h+Math.imul(k,Mt)|0)+Math.imul(A,pt)|0))<<13)|0;a=((e=e+Math.imul(A,Mt)|0)+(h>>>13)|0)+(Rt>>>26)|0,Rt&=67108863,n=Math.imul(j,rt),h=(h=Math.imul(j,nt))+Math.imul(K,rt)|0,e=Math.imul(K,nt),n=n+Math.imul(z,et)|0,h=(h=h+Math.imul(z,ot)|0)+Math.imul(T,et)|0,e=e+Math.imul(T,ot)|0,n=n+Math.imul(N,ut)|0,h=(h=h+Math.imul(N,at)|0)+Math.imul(I,ut)|0,e=e+Math.imul(I,at)|0,n=n+Math.imul(R,mt)|0,h=(h=h+Math.imul(R,ft)|0)+Math.imul(Z,mt)|0,e=e+Math.imul(Z,ft)|0;var Zt=(a+(n=n+Math.imul(x,pt)|0)|0)+((8191&(h=(h=h+Math.imul(x,Mt)|0)+Math.imul(B,pt)|0))<<13)|0;a=((e=e+Math.imul(B,Mt)|0)+(h>>>13)|0)+(Zt>>>26)|0,Zt&=67108863,n=Math.imul(j,et),h=(h=Math.imul(j,ot))+Math.imul(K,et)|0,e=Math.imul(K,ot),n=n+Math.imul(z,ut)|0,h=(h=h+Math.imul(z,at)|0)+Math.imul(T,ut)|0,e=e+Math.imul(T,at)|0,n=n+Math.imul(N,mt)|0,h=(h=h+Math.imul(N,ft)|0)+Math.imul(I,mt)|0,e=e+Math.imul(I,ft)|0;var Lt=(a+(n=n+Math.imul(R,pt)|0)|0)+((8191&(h=(h=h+Math.imul(R,Mt)|0)+Math.imul(Z,pt)|0))<<13)|0;a=((e=e+Math.imul(Z,Mt)|0)+(h>>>13)|0)+(Lt>>>26)|0,Lt&=67108863,n=Math.imul(j,ut),h=(h=Math.imul(j,at))+Math.imul(K,ut)|0,e=Math.imul(K,at),n=n+Math.imul(z,mt)|0,h=(h=h+Math.imul(z,ft)|0)+Math.imul(T,mt)|0,e=e+Math.imul(T,ft)|0;var Nt=(a+(n=n+Math.imul(N,pt)|0)|0)+((8191&(h=(h=h+Math.imul(N,Mt)|0)+Math.imul(I,pt)|0))<<13)|0;a=((e=e+Math.imul(I,Mt)|0)+(h>>>13)|0)+(Nt>>>26)|0,Nt&=67108863,n=Math.imul(j,mt),h=(h=Math.imul(j,ft))+Math.imul(K,mt)|0,e=Math.imul(K,ft);var It=(a+(n=n+Math.imul(z,pt)|0)|0)+((8191&(h=(h=h+Math.imul(z,Mt)|0)+Math.imul(T,pt)|0))<<13)|0;a=((e=e+Math.imul(T,Mt)|0)+(h>>>13)|0)+(It>>>26)|0,It&=67108863;var Et=(a+(n=Math.imul(j,pt))|0)+((8191&(h=(h=Math.imul(j,Mt))+Math.imul(K,pt)|0))<<13)|0;return a=((e=Math.imul(K,Mt))+(h>>>13)|0)+(Et>>>26)|0,Et&=67108863,u[0]=vt,u[1]=gt,u[2]=ct,u[3]=wt,u[4]=yt,u[5]=bt,u[6]=_t,u[7]=kt,u[8]=At,u[9]=St,u[10]=xt,u[11]=Bt,u[12]=qt,u[13]=Rt,u[14]=Zt,u[15]=Lt,u[16]=Nt,u[17]=It,u[18]=Et,0!==a&&(u[19]=a,r.length++),r};function v(t,i,r){r.negative=i.negative^t.negative,r.length=t.length+i.length;for(var n=0,h=0,e=0;e>>26)|0)>>>26,o&=67108863}r.words[e]=s,n=o,o=h}return 0!==n?r.words[e]=n:r.length--,r._strip()}function g(t,i,r){return v(t,i,r)}function c(t,i){this.x=t,this.y=i}Math.imul||(M=p),h.prototype.mulTo=function(t,i){var r=this.length+t.length;return 10===this.length&&10===t.length?M(this,t,i):r<63?p(this,t,i):r<1024?v(this,t,i):g(this,t,i)},c.prototype.makeRBT=function(t){for(var i=new Array(t),r=h.prototype._countBits(t)-1,n=0;n>=1;return n},c.prototype.permute=function(t,i,r,n,h,e){for(var o=0;o>>=1)h++;return 1<>>=13,n[2*o+1]=8191&e,e>>>=13;for(o=2*i;o>=26,n+=e/67108864|0,n+=o>>>26,this.words[h]=67108863&o}return 0!==n&&(this.words[h]=n,this.length++),i?this.ineg():this},h.prototype.muln=function(t){return this.clone().imuln(t)},h.prototype.sqr=function(){return this.mul(this)},h.prototype.isqr=function(){return this.imul(this.clone())},h.prototype.pow=function(t){var i=function(t){for(var i=new Array(t.bitLength()),r=0;r>>h&1}return i}(t);if(0===i.length)return new h(1);for(var r=this,n=0;n=0);var i,n=t%26,h=(t-n)/26,e=67108863>>>26-n<<26-n;if(0!==n){var o=0;for(i=0;i>>26-n}o&&(this.words[i]=o,this.length++)}if(0!==h){for(i=this.length-1;i>=0;i--)this.words[i+h]=this.words[i];for(i=0;i=0),h=i?(i-i%26)/26:0;var e=t%26,o=Math.min((t-e)/26,this.length),s=67108863^67108863>>>e<o)for(this.length-=o,a=0;a=0&&(0!==l||a>=h);a--){var m=0|this.words[a];this.words[a]=l<<26-e|m>>>e,l=m&s}return u&&0!==l&&(u.words[u.length++]=l),0===this.length&&(this.words[0]=0,this.length=1),this._strip()},h.prototype.ishrn=function(t,i,n){return r(0===this.negative),this.iushrn(t,i,n)},h.prototype.shln=function(t){return this.clone().ishln(t)},h.prototype.ushln=function(t){return this.clone().iushln(t)},h.prototype.shrn=function(t){return this.clone().ishrn(t)},h.prototype.ushrn=function(t){return this.clone().iushrn(t)},h.prototype.testn=function(t){r("number"==typeof t&&t>=0);var i=t%26,n=(t-i)/26,h=1<=0);var i=t%26,n=(t-i)/26;if(r(0===this.negative,"imaskn works only with positive numbers"),this.length<=n)return this;if(0!==i&&n++,this.length=Math.min(n,this.length),0!==i){var h=67108863^67108863>>>i<=67108864;i++)this.words[i]-=67108864,i===this.length-1?this.words[i+1]=1:this.words[i+1]++;return this.length=Math.max(this.length,i+1),this},h.prototype.isubn=function(t){if(r("number"==typeof t),r(t<67108864),t<0)return this.iaddn(-t);if(0!==this.negative)return this.negative=0,this.iaddn(t),this.negative=1,this;if(this.words[0]-=t,1===this.length&&this.words[0]<0)this.words[0]=-this.words[0],this.negative=1;else for(var i=0;i>26)-(u/67108864|0),this.words[h+n]=67108863&e}for(;h>26,this.words[h+n]=67108863&e;if(0===s)return this._strip();for(r(-1===s),s=0,h=0;h>26,this.words[h]=67108863&e;return this.negative=1,this._strip()},h.prototype._wordDiv=function(t,i){var r=(this.length,t.length),n=this.clone(),e=t,o=0|e.words[e.length-1];0!==(r=26-this._countBits(o))&&(e=e.ushln(r),n.iushln(r),o=0|e.words[e.length-1]);var s,u=n.length-e.length;if("mod"!==i){(s=new h(null)).length=u+1,s.words=new Array(s.length);for(var a=0;a=0;m--){var f=67108864*(0|n.words[e.length+m])+(0|n.words[e.length+m-1]);for(f=Math.min(f/o|0,67108863),n._ishlnsubmul(e,f,m);0!==n.negative;)f--,n.negative=0,n._ishlnsubmul(e,1,m),n.isZero()||(n.negative^=1);s&&(s.words[m]=f)}return s&&s._strip(),n._strip(),"div"!==i&&0!==r&&n.iushrn(r),{div:s||null,mod:n}},h.prototype.divmod=function(t,i,n){return r(!t.isZero()),this.isZero()?{div:new h(0),mod:new h(0)}:0!==this.negative&&0===t.negative?(s=this.neg().divmod(t,i),"mod"!==i&&(e=s.div.neg()),"div"!==i&&(o=s.mod.neg(),n&&0!==o.negative&&o.iadd(t)),{div:e,mod:o}):0===this.negative&&0!==t.negative?(s=this.divmod(t.neg(),i),"mod"!==i&&(e=s.div.neg()),{div:e,mod:s.mod}):0!=(this.negative&t.negative)?(s=this.neg().divmod(t.neg(),i),"div"!==i&&(o=s.mod.neg(),n&&0!==o.negative&&o.isub(t)),{div:s.div,mod:o}):t.length>this.length||this.cmp(t)<0?{div:new h(0),mod:this}:1===t.length?"div"===i?{div:this.divn(t.words[0]),mod:null}:"mod"===i?{div:null,mod:new h(this.modrn(t.words[0]))}:{div:this.divn(t.words[0]),mod:new h(this.modrn(t.words[0]))}:this._wordDiv(t,i);var e,o,s},h.prototype.div=function(t){return this.divmod(t,"div",!1).div},h.prototype.mod=function(t){return this.divmod(t,"mod",!1).mod},h.prototype.umod=function(t){return this.divmod(t,"mod",!0).mod},h.prototype.divRound=function(t){var i=this.divmod(t);if(i.mod.isZero())return i.div;var r=0!==i.div.negative?i.mod.isub(t):i.mod,n=t.ushrn(1),h=t.andln(1),e=r.cmp(n);return e<0||1===h&&0===e?i.div:0!==i.div.negative?i.div.isubn(1):i.div.iaddn(1)},h.prototype.modrn=function(t){var i=t<0;i&&(t=-t),r(t<=67108863);for(var n=(1<<26)%t,h=0,e=this.length-1;e>=0;e--)h=(n*h+(0|this.words[e]))%t;return i?-h:h},h.prototype.modn=function(t){return this.modrn(t)},h.prototype.idivn=function(t){var i=t<0;i&&(t=-t),r(t<=67108863);for(var n=0,h=this.length-1;h>=0;h--){var e=(0|this.words[h])+67108864*n;this.words[h]=e/t|0,n=e%t}return this._strip(),i?this.ineg():this},h.prototype.divn=function(t){return this.clone().idivn(t)},h.prototype.egcd=function(t){r(0===t.negative),r(!t.isZero());var i=this,n=t.clone();i=0!==i.negative?i.umod(t):i.clone();for(var e=new h(1),o=new h(0),s=new h(0),u=new h(1),a=0;i.isEven()&&n.isEven();)i.iushrn(1),n.iushrn(1),++a;for(var l=n.clone(),m=i.clone();!i.isZero();){for(var f=0,d=1;0==(i.words[0]&d)&&f<26;++f,d<<=1);if(f>0)for(i.iushrn(f);f-- >0;)(e.isOdd()||o.isOdd())&&(e.iadd(l),o.isub(m)),e.iushrn(1),o.iushrn(1);for(var p=0,M=1;0==(n.words[0]&M)&&p<26;++p,M<<=1);if(p>0)for(n.iushrn(p);p-- >0;)(s.isOdd()||u.isOdd())&&(s.iadd(l),u.isub(m)),s.iushrn(1),u.iushrn(1);i.cmp(n)>=0?(i.isub(n),e.isub(s),o.isub(u)):(n.isub(i),s.isub(e),u.isub(o))}return{a:s,b:u,gcd:n.iushln(a)}},h.prototype._invmp=function(t){r(0===t.negative),r(!t.isZero());var i=this,n=t.clone();i=0!==i.negative?i.umod(t):i.clone();for(var e,o=new h(1),s=new h(0),u=n.clone();i.cmpn(1)>0&&n.cmpn(1)>0;){for(var a=0,l=1;0==(i.words[0]&l)&&a<26;++a,l<<=1);if(a>0)for(i.iushrn(a);a-- >0;)o.isOdd()&&o.iadd(u),o.iushrn(1);for(var m=0,f=1;0==(n.words[0]&f)&&m<26;++m,f<<=1);if(m>0)for(n.iushrn(m);m-- >0;)s.isOdd()&&s.iadd(u),s.iushrn(1);i.cmp(n)>=0?(i.isub(n),o.isub(s)):(n.isub(i),s.isub(o))}return(e=0===i.cmpn(1)?o:s).cmpn(0)<0&&e.iadd(t),e},h.prototype.gcd=function(t){if(this.isZero())return t.abs();if(t.isZero())return this.abs();var i=this.clone(),r=t.clone();i.negative=0,r.negative=0;for(var n=0;i.isEven()&&r.isEven();n++)i.iushrn(1),r.iushrn(1);for(;;){for(;i.isEven();)i.iushrn(1);for(;r.isEven();)r.iushrn(1);var h=i.cmp(r);if(h<0){var e=i;i=r,r=e}else if(0===h||0===r.cmpn(1))break;i.isub(r)}return r.iushln(n)},h.prototype.invm=function(t){return this.egcd(t).a.umod(t)},h.prototype.isEven=function(){return 0==(1&this.words[0])},h.prototype.isOdd=function(){return 1==(1&this.words[0])},h.prototype.andln=function(t){return this.words[0]&t},h.prototype.bincn=function(t){r("number"==typeof t);var i=t%26,n=(t-i)/26,h=1<>>26,s&=67108863,this.words[o]=s}return 0!==e&&(this.words[o]=e,this.length++),this},h.prototype.isZero=function(){return 1===this.length&&0===this.words[0]},h.prototype.cmpn=function(t){var i,n=t<0;if(0!==this.negative&&!n)return-1;if(0===this.negative&&n)return 1;if(this._strip(),this.length>1)i=1;else{n&&(t=-t),r(t<=67108863,"Number is too big");var h=0|this.words[0];i=h===t?0:ht.length)return 1;if(this.length=0;r--){var n=0|this.words[r],h=0|t.words[r];if(n!==h){nh&&(i=1);break}}return i},h.prototype.gtn=function(t){return 1===this.cmpn(t)},h.prototype.gt=function(t){return 1===this.cmp(t)},h.prototype.gten=function(t){return this.cmpn(t)>=0},h.prototype.gte=function(t){return this.cmp(t)>=0},h.prototype.ltn=function(t){return-1===this.cmpn(t)},h.prototype.lt=function(t){return-1===this.cmp(t)},h.prototype.lten=function(t){return this.cmpn(t)<=0},h.prototype.lte=function(t){return this.cmp(t)<=0},h.prototype.eqn=function(t){return 0===this.cmpn(t)},h.prototype.eq=function(t){return 0===this.cmp(t)},h.red=function(t){return new S(t)},h.prototype.toRed=function(t){return r(!this.red,"Already a number in reduction context"),r(0===this.negative,"red works only with positives"),t.convertTo(this)._forceRed(t)},h.prototype.fromRed=function(){return r(this.red,"fromRed works only with numbers in reduction context"),this.red.convertFrom(this)},h.prototype._forceRed=function(t){return this.red=t,this},h.prototype.forceRed=function(t){return r(!this.red,"Already a number in reduction context"),this._forceRed(t)},h.prototype.redAdd=function(t){return r(this.red,"redAdd works only with red numbers"),this.red.add(this,t)},h.prototype.redIAdd=function(t){return r(this.red,"redIAdd works only with red numbers"),this.red.iadd(this,t)},h.prototype.redSub=function(t){return r(this.red,"redSub works only with red numbers"),this.red.sub(this,t)},h.prototype.redISub=function(t){return r(this.red,"redISub works only with red numbers"),this.red.isub(this,t)},h.prototype.redShl=function(t){return r(this.red,"redShl works only with red numbers"),this.red.shl(this,t)},h.prototype.redMul=function(t){return r(this.red,"redMul works only with red numbers"),this.red._verify2(this,t),this.red.mul(this,t)},h.prototype.redIMul=function(t){return r(this.red,"redMul works only with red numbers"),this.red._verify2(this,t),this.red.imul(this,t)},h.prototype.redSqr=function(){return r(this.red,"redSqr works only with red numbers"),this.red._verify1(this),this.red.sqr(this)},h.prototype.redISqr=function(){return r(this.red,"redISqr works only with red numbers"),this.red._verify1(this),this.red.isqr(this)},h.prototype.redSqrt=function(){return r(this.red,"redSqrt works only with red numbers"),this.red._verify1(this),this.red.sqrt(this)},h.prototype.redInvm=function(){return r(this.red,"redInvm works only with red numbers"),this.red._verify1(this),this.red.invm(this)},h.prototype.redNeg=function(){return r(this.red,"redNeg works only with red numbers"),this.red._verify1(this),this.red.neg(this)},h.prototype.redPow=function(t){return r(this.red&&!t.red,"redPow(normalNum)"),this.red._verify1(this),this.red.pow(this,t)};var w={k256:null,p224:null,p192:null,p25519:null};function y(t,i){this.name=t,this.p=new h(i,16),this.n=this.p.bitLength(),this.k=new h(1).iushln(this.n).isub(this.p),this.tmp=this._tmp()}function b(){y.call(this,"k256","ffffffff ffffffff ffffffff ffffffff ffffffff ffffffff fffffffe fffffc2f")}function _(){y.call(this,"p224","ffffffff ffffffff ffffffff ffffffff 00000000 00000000 00000001")}function k(){y.call(this,"p192","ffffffff ffffffff ffffffff fffffffe ffffffff ffffffff")}function A(){y.call(this,"25519","7fffffffffffffff ffffffffffffffff ffffffffffffffff ffffffffffffffed")}function S(t){if("string"==typeof t){var i=h._prime(t);this.m=i.p,this.prime=i}else r(t.gtn(1),"modulus must be greater than 1"),this.m=t,this.prime=null}function x(t){S.call(this,t),this.shift=this.m.bitLength(),this.shift%26!=0&&(this.shift+=26-this.shift%26),this.r=new h(1).iushln(this.shift),this.r2=this.imod(this.r.sqr()),this.rinv=this.r._invmp(this.m),this.minv=this.rinv.mul(this.r).isubn(1).div(this.m),this.minv=this.minv.umod(this.r),this.minv=this.r.sub(this.minv)}y.prototype._tmp=function(){var t=new h(null);return t.words=new Array(Math.ceil(this.n/13)),t},y.prototype.ireduce=function(t){var i,r=t;do{this.split(r,this.tmp),i=(r=(r=this.imulK(r)).iadd(this.tmp)).bitLength()}while(i>this.n);var n=i0?r.isub(this.p):void 0!==r.strip?r.strip():r._strip(),r},y.prototype.split=function(t,i){t.iushrn(this.n,0,i)},y.prototype.imulK=function(t){return t.imul(this.k)},n(b,y),b.prototype.split=function(t,i){for(var r=Math.min(t.length,9),n=0;n>>22,h=e}h>>>=22,t.words[n-10]=h,0===h&&t.length>10?t.length-=10:t.length-=9},b.prototype.imulK=function(t){t.words[t.length]=0,t.words[t.length+1]=0,t.length+=2;for(var i=0,r=0;r>>=26,t.words[r]=h,i=n}return 0!==i&&(t.words[t.length++]=i),t},h._prime=function(t){if(w[t])return w[t];var i;if("k256"===t)i=new b;else if("p224"===t)i=new _;else if("p192"===t)i=new k;else{if("p25519"!==t)throw new Error("Unknown prime "+t);i=new A}return w[t]=i,i},S.prototype._verify1=function(t){r(0===t.negative,"red works only with positives"),r(t.red,"red works only with red numbers")},S.prototype._verify2=function(t,i){r(0==(t.negative|i.negative),"red works only with positives"),r(t.red&&t.red===i.red,"red works only with red numbers")},S.prototype.imod=function(t){return this.prime?this.prime.ireduce(t)._forceRed(this):(a(t,t.umod(this.m)._forceRed(this)),t)},S.prototype.neg=function(t){return t.isZero()?t.clone():this.m.sub(t)._forceRed(this)},S.prototype.add=function(t,i){this._verify2(t,i);var r=t.add(i);return r.cmp(this.m)>=0&&r.isub(this.m),r._forceRed(this)},S.prototype.iadd=function(t,i){this._verify2(t,i);var r=t.iadd(i);return r.cmp(this.m)>=0&&r.isub(this.m),r},S.prototype.sub=function(t,i){this._verify2(t,i);var r=t.sub(i);return r.cmpn(0)<0&&r.iadd(this.m),r._forceRed(this)},S.prototype.isub=function(t,i){this._verify2(t,i);var r=t.isub(i);return r.cmpn(0)<0&&r.iadd(this.m),r},S.prototype.shl=function(t,i){return this._verify1(t),this.imod(t.ushln(i))},S.prototype.imul=function(t,i){return this._verify2(t,i),this.imod(t.imul(i))},S.prototype.mul=function(t,i){return this._verify2(t,i),this.imod(t.mul(i))},S.prototype.isqr=function(t){return this.imul(t,t.clone())},S.prototype.sqr=function(t){return this.mul(t,t)},S.prototype.sqrt=function(t){if(t.isZero())return t.clone();var i=this.m.andln(3);if(r(i%2==1),3===i){var n=this.m.add(new h(1)).iushrn(2);return this.pow(t,n)}for(var e=this.m.subn(1),o=0;!e.isZero()&&0===e.andln(1);)o++,e.iushrn(1);r(!e.isZero());var s=new h(1).toRed(this),u=s.redNeg(),a=this.m.subn(1).iushrn(1),l=this.m.bitLength();for(l=new h(2*l*l).toRed(this);0!==this.pow(l,a).cmp(u);)l.redIAdd(u);for(var m=this.pow(l,e),f=this.pow(t,e.addn(1).iushrn(1)),d=this.pow(t,e),p=o;0!==d.cmp(s);){for(var M=d,v=0;0!==M.cmp(s);v++)M=M.redSqr();r(v=0;n--){for(var a=i.words[n],l=u-1;l>=0;l--){var m=a>>l&1;e!==r[0]&&(e=this.sqr(e)),0!==m||0!==o?(o<<=1,o|=m,(4===++s||0===n&&0===l)&&(e=this.mul(e,r[o]),s=0,o=0)):s=0}u=26}return e},S.prototype.convertTo=function(t){var i=t.umod(this.m);return i===t?i.clone():i},S.prototype.convertFrom=function(t){var i=t.clone();return i.red=null,i},h.mont=function(t){return new x(t)},n(x,S),x.prototype.convertTo=function(t){return this.imod(t.ushln(this.shift))},x.prototype.convertFrom=function(t){var i=this.imod(t.mul(this.rinv));return i.red=null,i},x.prototype.imul=function(t,i){if(t.isZero()||i.isZero())return t.words[0]=0,t.length=1,t;var r=t.imul(i),n=r.maskn(this.shift).mul(this.minv).imaskn(this.shift).mul(this.m),h=r.isub(n).iushrn(this.shift),e=h;return h.cmp(this.m)>=0?e=h.isub(this.m):h.cmpn(0)<0&&(e=h.iadd(this.m)),e._forceRed(this)},x.prototype.mul=function(t,i){if(t.isZero()||i.isZero())return new h(0)._forceRed(this);var r=t.mul(i),n=r.maskn(this.shift).mul(this.minv).imaskn(this.shift).mul(this.m),e=r.isub(n).iushrn(this.shift),o=e;return e.cmp(this.m)>=0?o=e.isub(this.m):e.cmpn(0)<0&&(o=e.iadd(this.m)),o._forceRed(this)},x.prototype.invm=function(t){return this.imod(t._invmp(this.m).mul(this.r2))._forceRed(this)}}("undefined"==typeof module||module,this); +},{"buffer":"sC8V"}],"hH2J":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var e=require("buffer").Buffer,r=require("bn.js"),u=require("randombytes");function o(e){var u=n(e);return{blinder:u.toRed(r.mont(e.modulus)).redPow(new r(e.publicExponent)).fromRed(),unblinder:u.invm(e.modulus)}}function n(e){var o,n=e.modulus.byteLength();do{o=new r(u(n))}while(o.cmp(e.modulus)>=0||!o.umod(e.prime1)||!o.umod(e.prime2));return o}function m(u,n){var m=o(n),d=n.modulus.byteLength(),i=new r(u).mul(m.blinder).umod(n.modulus),t=i.toRed(r.mont(n.prime1)),l=i.toRed(r.mont(n.prime2)),b=n.coefficient,f=n.prime1,p=n.prime2,s=t.redPow(n.exponent1).fromRed(),a=l.redPow(n.exponent2).fromRed(),c=s.isub(a).imul(b).umod(f).imul(p);return a.iadd(c).imul(m.unblinder).umod(n.modulus).toArrayLike(e,"be",d)}m.getr=n,module.exports=m; +},{"bn.js":"EOUW","randombytes":"pXr2","buffer":"z1tx"}],"Y4Tp":[function(require,module,exports) { +module.exports={name:"elliptic",version:"6.5.4",description:"EC cryptography",main:"lib/elliptic.js",files:["lib"],scripts:{lint:"eslint lib test","lint:fix":"npm run lint -- --fix",unit:"istanbul test _mocha --reporter=spec test/index.js",test:"npm run lint && npm run unit",version:"grunt dist && git add dist/"},repository:{type:"git",url:"git@github.com:indutny/elliptic"},keywords:["EC","Elliptic","curve","Cryptography"],author:"Fedor Indutny ",license:"MIT",bugs:{url:"https://github.com/indutny/elliptic/issues"},homepage:"https://github.com/indutny/elliptic",devDependencies:{brfs:"^2.0.2",coveralls:"^3.1.0",eslint:"^7.6.0",grunt:"^1.2.1","grunt-browserify":"^5.3.0","grunt-cli":"^1.3.2","grunt-contrib-connect":"^3.0.0","grunt-contrib-copy":"^1.0.0","grunt-contrib-uglify":"^5.0.0","grunt-mocha-istanbul":"^5.0.2","grunt-saucelabs":"^9.0.1",istanbul:"^0.4.5",mocha:"^8.0.1"},dependencies:{"bn.js":"^4.11.9",brorand:"^1.1.0","hash.js":"^1.0.0","hmac-drbg":"^1.0.1",inherits:"^2.0.4","minimalistic-assert":"^1.0.1","minimalistic-crypto-utils":"^1.0.1"}}; +},{}],"ubVI":[function(require,module,exports) { +"use strict";var r=exports;function e(r,e){if(Array.isArray(r))return r.slice();if(!r)return[];var t=[];if("string"!=typeof r){for(var n=0;n>8,i=255&o;u?t.push(u,i):t.push(i)}return t}function t(r){return 1===r.length?"0"+r:r}function n(r){for(var e="",n=0;n(i>>1)-1?(i>>1)-u:u,o.isubn(s)):s=0,e[a]=s,o.iushrn(1)}return e}function o(r,n){var t=[[],[]];r=r.clone(),n=n.clone();for(var e,i=0,o=0;r.cmpn(-i)>0||n.cmpn(-o)>0;){var a,s,u=r.andln(3)+i&3,c=n.andln(3)+o&3;3===u&&(u=-1),3===c&&(c=-1),a=0==(1&u)?0:3!==(e=r.andln(7)+i&7)&&5!==e||2!==c?u:-u,t[0].push(a),s=0==(1&c)?0:3!==(e=n.andln(7)+o&7)&&5!==e||2!==u?c:-c,t[1].push(s),2*i===a+1&&(i=1-i),2*o===s+1&&(o=1-o),r.iushrn(1),n.iushrn(1)}return t}function a(r,n,t){var e="_"+n;r.prototype[n]=function(){return void 0!==this[e]?this[e]:this[e]=t.call(this)}}function s(n){return"string"==typeof n?r.toArray(n,"hex"):n}function u(r){return new n(r,"hex","le")}r.assert=t,r.toArray=e.toArray,r.zero2=e.zero2,r.toHex=e.toHex,r.encode=e.encode,r.getNAF=i,r.getJSF=o,r.cachedProperty=a,r.parseBytes=s,r.intFromLE=u; +},{"bn.js":"o7RX","minimalistic-assert":"PhA8","minimalistic-crypto-utils":"ubVI"}],"Qo8X":[function(require,module,exports) { +"use strict";var t=require("bn.js"),e=require("../utils"),n=e.getNAF,r=e.getJSF,i=e.assert;function o(e,n){this.type=e,this.p=new t(n.p,16),this.red=n.prime?t.red(n.prime):t.mont(this.p),this.zero=new t(0).toRed(this.red),this.one=new t(1).toRed(this.red),this.two=new t(2).toRed(this.red),this.n=n.n&&new t(n.n,16),this.g=n.g&&this.pointFromJSON(n.g,n.gRed),this._wnafT1=new Array(4),this._wnafT2=new Array(4),this._wnafT3=new Array(4),this._wnafT4=new Array(4),this._bitLength=this.n?this.n.bitLength():0;var r=this.n&&this.p.div(this.n);!r||r.cmpn(100)>0?this.redN=null:(this._maxwellTrick=!0,this.redN=this.n.toRed(this.red))}function s(t,e){this.curve=t,this.type=e,this.precomputed=null}module.exports=o,o.prototype.point=function(){throw new Error("Not implemented")},o.prototype.validate=function(){throw new Error("Not implemented")},o.prototype._fixedNafMul=function(t,e){i(t.precomputed);var r=t._getDoubles(),o=n(e,1,this._bitLength),s=(1<=p;u--)h=(h<<1)+o[u];d.push(h)}for(var a=this.jpoint(null,null,null),l=this.jpoint(null,null,null),f=s;f>0;f--){for(p=0;p=0;d--){for(var u=0;d>=0&&0===p[d];d--)u++;if(d>=0&&u++,h=h.dblp(u),d<0)break;var a=p[d];i(0!==a),h="affine"===t.type?a>0?h.mixedAdd(s[a-1>>1]):h.mixedAdd(s[-a-1>>1].neg()):a>0?h.add(s[a-1>>1]):h.add(s[-a-1>>1].neg())}return"affine"===t.type?h.toP():h},o.prototype._wnafMulAdd=function(t,e,i,o,s){var p,h,d,u=this._wnafT1,a=this._wnafT2,l=this._wnafT3,f=0;for(p=0;p=1;p-=2){var g=p-1,m=p;if(1===u[g]&&1===u[m]){var y=[e[g],null,null,e[m]];0===e[g].y.cmp(e[m].y)?(y[1]=e[g].add(e[m]),y[2]=e[g].toJ().mixedAdd(e[m].neg())):0===e[g].y.cmp(e[m].y.redNeg())?(y[1]=e[g].toJ().mixedAdd(e[m]),y[2]=e[g].add(e[m].neg())):(y[1]=e[g].toJ().mixedAdd(e[m]),y[2]=e[g].toJ().mixedAdd(e[m].neg()));var v=[-3,-1,-5,-7,0,7,5,1,3],w=r(i[g],i[m]);for(f=Math.max(w[0].length,f),l[g]=new Array(f),l[m]=new Array(f),h=0;h=0;p--){for(var N=0;p>=0;){var L=!0;for(h=0;h=0&&N++,A=A.dblp(N),p<0)break;for(h=0;h0?d=a[h][P-1>>1]:P<0&&(d=a[h][-P-1>>1].neg()),A="affine"===d.type?A.mixedAdd(d):A.add(d))}}for(p=0;p=Math.ceil((t.bitLength()+1)/e.step)},s.prototype._getDoubles=function(t,e){if(this.precomputed&&this.precomputed.doubles)return this.precomputed.doubles;for(var n=[this],r=this,i=0;i=0&&(u=t,s=d),i.negative&&(i=i.neg(),n=n.neg()),u.negative&&(u=u.neg(),s=s.neg()),[{a:i,b:n},{a:u,b:s}]},n.prototype._endoSplit=function(r){var e=this.endo.basis,t=e[0],d=e[1],i=d.b.mul(r).divRound(this.n),n=t.b.neg().mul(r).divRound(this.n),u=i.mul(t.a),s=n.mul(d.a),o=i.mul(t.b),h=n.mul(d.b);return{k1:r.sub(u).sub(s),k2:o.add(h).neg()}},n.prototype.pointFromX=function(r,t){(r=new e(r,16)).red||(r=r.toRed(this.red));var d=r.redSqr().redMul(r).redIAdd(r.redMul(this.a)).redIAdd(this.b),i=d.redSqrt();if(0!==i.redSqr().redSub(d).cmp(this.zero))throw new Error("invalid point");var n=i.fromRed().isOdd();return(t&&!n||!t&&n)&&(i=i.redNeg()),this.point(r,i)},n.prototype.validate=function(r){if(r.inf)return!0;var e=r.x,t=r.y,d=this.a.redMul(e),i=e.redSqr().redMul(e).redIAdd(d).redIAdd(this.b);return 0===t.redSqr().redISub(i).cmpn(0)},n.prototype._endoWnafMulAdd=function(r,e,t){for(var d=this._endoWnafT1,i=this._endoWnafT2,n=0;n":""},u.prototype.isInfinity=function(){return this.inf},u.prototype.add=function(r){if(this.inf)return r;if(r.inf)return this;if(this.eq(r))return this.dbl();if(this.neg().eq(r))return this.curve.point(null,null);if(0===this.x.cmp(r.x))return this.curve.point(null,null);var e=this.y.redSub(r.y);0!==e.cmpn(0)&&(e=e.redMul(this.x.redSub(r.x).redInvm()));var t=e.redSqr().redISub(this.x).redISub(r.x),d=e.redMul(this.x.redSub(t)).redISub(this.y);return this.curve.point(t,d)},u.prototype.dbl=function(){if(this.inf)return this;var r=this.y.redAdd(this.y);if(0===r.cmpn(0))return this.curve.point(null,null);var e=this.curve.a,t=this.x.redSqr(),d=r.redInvm(),i=t.redAdd(t).redIAdd(t).redIAdd(e).redMul(d),n=i.redSqr().redISub(this.x.redAdd(this.x)),u=i.redMul(this.x.redSub(n)).redISub(this.y);return this.curve.point(n,u)},u.prototype.getX=function(){return this.x.fromRed()},u.prototype.getY=function(){return this.y.fromRed()},u.prototype.mul=function(r){return r=new e(r,16),this.isInfinity()?this:this._hasDoubles(r)?this.curve._fixedNafMul(this,r):this.curve.endo?this.curve._endoWnafMulAdd([this],[r]):this.curve._wnafMul(this,r)},u.prototype.mulAdd=function(r,e,t){var d=[this,e],i=[r,t];return this.curve.endo?this.curve._endoWnafMulAdd(d,i):this.curve._wnafMulAdd(1,d,i,2)},u.prototype.jmulAdd=function(r,e,t){var d=[this,e],i=[r,t];return this.curve.endo?this.curve._endoWnafMulAdd(d,i,!0):this.curve._wnafMulAdd(1,d,i,2,!0)},u.prototype.eq=function(r){return this===r||this.inf===r.inf&&(this.inf||0===this.x.cmp(r.x)&&0===this.y.cmp(r.y))},u.prototype.neg=function(r){if(this.inf)return this;var e=this.curve.point(this.x,this.y.redNeg());if(r&&this.precomputed){var t=this.precomputed,d=function(r){return r.neg()};e.precomputed={naf:t.naf&&{wnd:t.naf.wnd,points:t.naf.points.map(d)},doubles:t.doubles&&{step:t.doubles.step,points:t.doubles.points.map(d)}}}return e},u.prototype.toJ=function(){return this.inf?this.curve.jpoint(null,null,null):this.curve.jpoint(this.x,this.y,this.curve.one)},t(s,d.BasePoint),n.prototype.jpoint=function(r,e,t){return new s(this,r,e,t)},s.prototype.toP=function(){if(this.isInfinity())return this.curve.point(null,null);var r=this.z.redInvm(),e=r.redSqr(),t=this.x.redMul(e),d=this.y.redMul(e).redMul(r);return this.curve.point(t,d)},s.prototype.neg=function(){return this.curve.jpoint(this.x,this.y.redNeg(),this.z)},s.prototype.add=function(r){if(this.isInfinity())return r;if(r.isInfinity())return this;var e=r.z.redSqr(),t=this.z.redSqr(),d=this.x.redMul(e),i=r.x.redMul(t),n=this.y.redMul(e.redMul(r.z)),u=r.y.redMul(t.redMul(this.z)),s=d.redSub(i),o=n.redSub(u);if(0===s.cmpn(0))return 0!==o.cmpn(0)?this.curve.jpoint(null,null,null):this.dbl();var h=s.redSqr(),p=h.redMul(s),l=d.redMul(h),a=o.redSqr().redIAdd(p).redISub(l).redISub(l),f=o.redMul(l.redISub(a)).redISub(n.redMul(p)),c=this.z.redMul(r.z).redMul(s);return this.curve.jpoint(a,f,c)},s.prototype.mixedAdd=function(r){if(this.isInfinity())return r.toJ();if(r.isInfinity())return this;var e=this.z.redSqr(),t=this.x,d=r.x.redMul(e),i=this.y,n=r.y.redMul(e).redMul(this.z),u=t.redSub(d),s=i.redSub(n);if(0===u.cmpn(0))return 0!==s.cmpn(0)?this.curve.jpoint(null,null,null):this.dbl();var o=u.redSqr(),h=o.redMul(u),p=t.redMul(o),l=s.redSqr().redIAdd(h).redISub(p).redISub(p),a=s.redMul(p.redISub(l)).redISub(i.redMul(h)),f=this.z.redMul(u);return this.curve.jpoint(l,a,f)},s.prototype.dblp=function(r){if(0===r)return this;if(this.isInfinity())return this;if(!r)return this.dbl();var e;if(this.curve.zeroA||this.curve.threeA){var t=this;for(e=0;e=0)return!1;if(t.redIAdd(i),0===this.x.cmp(t))return!0}},s.prototype.inspect=function(){return this.isInfinity()?"":""},s.prototype.isInfinity=function(){return 0===this.z.cmpn(0)}; +},{"../utils":"hLmj","bn.js":"o7RX","inherits":"oxwV","./base":"Qo8X"}],"iBD7":[function(require,module,exports) { +"use strict";var t=require("bn.js"),r=require("inherits"),e=require("./base"),i=require("../utils");function o(r){e.call(this,"mont",r),this.a=new t(r.a,16).toRed(this.red),this.b=new t(r.b,16).toRed(this.red),this.i4=new t(4).toRed(this.red).redInvm(),this.two=new t(2).toRed(this.red),this.a24=this.i4.redMul(this.a.redAdd(this.two))}function n(r,i,o){e.BasePoint.call(this,r,"projective"),null===i&&null===o?(this.x=this.curve.one,this.z=this.curve.zero):(this.x=new t(i,16),this.z=new t(o,16),this.x.red||(this.x=this.x.toRed(this.curve.red)),this.z.red||(this.z=this.z.toRed(this.curve.red)))}r(o,e),module.exports=o,o.prototype.validate=function(t){var r=t.normalize().x,e=r.redSqr(),i=e.redMul(r).redAdd(e.redMul(this.a)).redAdd(r);return 0===i.redSqrt().redSqr().cmp(i)},r(n,e.BasePoint),o.prototype.decodePoint=function(t,r){return this.point(i.toArray(t,r),1)},o.prototype.point=function(t,r){return new n(this,t,r)},o.prototype.pointFromJSON=function(t){return n.fromJSON(this,t)},n.prototype.precompute=function(){},n.prototype._encode=function(){return this.getX().toArray("be",this.curve.p.byteLength())},n.fromJSON=function(t,r){return new n(t,r[0],r[1]||t.one)},n.prototype.inspect=function(){return this.isInfinity()?"":""},n.prototype.isInfinity=function(){return 0===this.z.cmpn(0)},n.prototype.dbl=function(){var t=this.x.redAdd(this.z).redSqr(),r=this.x.redSub(this.z).redSqr(),e=t.redSub(r),i=t.redMul(r),o=e.redMul(r.redAdd(this.curve.a24.redMul(e)));return this.curve.point(i,o)},n.prototype.add=function(){throw new Error("Not supported on Montgomery curve")},n.prototype.diffAdd=function(t,r){var e=this.x.redAdd(this.z),i=this.x.redSub(this.z),o=t.x.redAdd(t.z),n=t.x.redSub(t.z).redMul(e),d=o.redMul(i),u=r.z.redMul(n.redAdd(d).redSqr()),s=r.x.redMul(n.redISub(d).redSqr());return this.curve.point(u,s)},n.prototype.mul=function(t){for(var r=t.clone(),e=this,i=this.curve.point(null,null),o=[];0!==r.cmpn(0);r.iushrn(1))o.push(r.andln(1));for(var n=o.length-1;n>=0;n--)0===o[n]?(e=e.diffAdd(i,this),i=i.dbl()):(i=e.diffAdd(i,this),e=e.dbl());return i},n.prototype.mulAdd=function(){throw new Error("Not supported on Montgomery curve")},n.prototype.jumlAdd=function(){throw new Error("Not supported on Montgomery curve")},n.prototype.eq=function(t){return 0===this.getX().cmp(t.getX())},n.prototype.normalize=function(){return this.x=this.x.redMul(this.z.redInvm()),this.z=this.curve.one,this},n.prototype.getX=function(){return this.normalize(),this.x.fromRed()}; +},{"bn.js":"o7RX","inherits":"oxwV","./base":"Qo8X","../utils":"hLmj"}],"zADK":[function(require,module,exports) { +"use strict";var t=require("../utils"),e=require("bn.js"),r=require("inherits"),i=require("./base"),d=t.assert;function s(t){this.twisted=1!=(0|t.a),this.mOneA=this.twisted&&-1==(0|t.a),this.extended=this.mOneA,i.call(this,"edwards",t),this.a=new e(t.a,16).umod(this.red.m),this.a=this.a.toRed(this.red),this.c=new e(t.c,16).toRed(this.red),this.c2=this.c.redSqr(),this.d=new e(t.d,16).toRed(this.red),this.dd=this.d.redAdd(this.d),d(!this.twisted||0===this.c.fromRed().cmpn(1)),this.oneC=1==(0|t.c)}function u(t,r,d,s,u){i.BasePoint.call(this,t,"projective"),null===r&&null===d&&null===s?(this.x=this.curve.zero,this.y=this.curve.one,this.z=this.curve.one,this.t=this.curve.zero,this.zOne=!0):(this.x=new e(r,16),this.y=new e(d,16),this.z=s?new e(s,16):this.curve.one,this.t=u&&new e(u,16),this.x.red||(this.x=this.x.toRed(this.curve.red)),this.y.red||(this.y=this.y.toRed(this.curve.red)),this.z.red||(this.z=this.z.toRed(this.curve.red)),this.t&&!this.t.red&&(this.t=this.t.toRed(this.curve.red)),this.zOne=this.z===this.curve.one,this.curve.extended&&!this.t&&(this.t=this.x.redMul(this.y),this.zOne||(this.t=this.t.redMul(this.z.redInvm()))))}r(s,i),module.exports=s,s.prototype._mulA=function(t){return this.mOneA?t.redNeg():this.a.redMul(t)},s.prototype._mulC=function(t){return this.oneC?t:this.c.redMul(t)},s.prototype.jpoint=function(t,e,r,i){return this.point(t,e,r,i)},s.prototype.pointFromX=function(t,r){(t=new e(t,16)).red||(t=t.toRed(this.red));var i=t.redSqr(),d=this.c2.redSub(this.a.redMul(i)),s=this.one.redSub(this.c2.redMul(this.d).redMul(i)),u=d.redMul(s.redInvm()),h=u.redSqrt();if(0!==h.redSqr().redSub(u).cmp(this.zero))throw new Error("invalid point");var n=h.fromRed().isOdd();return(r&&!n||!r&&n)&&(h=h.redNeg()),this.point(t,h)},s.prototype.pointFromY=function(t,r){(t=new e(t,16)).red||(t=t.toRed(this.red));var i=t.redSqr(),d=i.redSub(this.c2),s=i.redMul(this.d).redMul(this.c2).redSub(this.a),u=d.redMul(s.redInvm());if(0===u.cmp(this.zero)){if(r)throw new Error("invalid point");return this.point(this.zero,t)}var h=u.redSqrt();if(0!==h.redSqr().redSub(u).cmp(this.zero))throw new Error("invalid point");return h.fromRed().isOdd()!==r&&(h=h.redNeg()),this.point(h,t)},s.prototype.validate=function(t){if(t.isInfinity())return!0;t.normalize();var e=t.x.redSqr(),r=t.y.redSqr(),i=e.redMul(this.a).redAdd(r),d=this.c2.redMul(this.one.redAdd(this.d.redMul(e).redMul(r)));return 0===i.cmp(d)},r(u,i.BasePoint),s.prototype.pointFromJSON=function(t){return u.fromJSON(this,t)},s.prototype.point=function(t,e,r,i){return new u(this,t,e,r,i)},u.fromJSON=function(t,e){return new u(t,e[0],e[1],e[2])},u.prototype.inspect=function(){return this.isInfinity()?"":""},u.prototype.isInfinity=function(){return 0===this.x.cmpn(0)&&(0===this.y.cmp(this.z)||this.zOne&&0===this.y.cmp(this.curve.c))},u.prototype._extDbl=function(){var t=this.x.redSqr(),e=this.y.redSqr(),r=this.z.redSqr();r=r.redIAdd(r);var i=this.curve._mulA(t),d=this.x.redAdd(this.y).redSqr().redISub(t).redISub(e),s=i.redAdd(e),u=s.redSub(r),h=i.redSub(e),n=d.redMul(u),o=s.redMul(h),c=d.redMul(h),l=u.redMul(s);return this.curve.point(n,o,l,c)},u.prototype._projDbl=function(){var t,e,r,i,d,s,u=this.x.redAdd(this.y).redSqr(),h=this.x.redSqr(),n=this.y.redSqr();if(this.curve.twisted){var o=(i=this.curve._mulA(h)).redAdd(n);this.zOne?(t=u.redSub(h).redSub(n).redMul(o.redSub(this.curve.two)),e=o.redMul(i.redSub(n)),r=o.redSqr().redSub(o).redSub(o)):(d=this.z.redSqr(),s=o.redSub(d).redISub(d),t=u.redSub(h).redISub(n).redMul(s),e=o.redMul(i.redSub(n)),r=o.redMul(s))}else i=h.redAdd(n),d=this.curve._mulC(this.z).redSqr(),s=i.redSub(d).redSub(d),t=this.curve._mulC(u.redISub(i)).redMul(s),e=this.curve._mulC(i).redMul(h.redISub(n)),r=i.redMul(s);return this.curve.point(t,e,r)},u.prototype.dbl=function(){return this.isInfinity()?this:this.curve.extended?this._extDbl():this._projDbl()},u.prototype._extAdd=function(t){var e=this.y.redSub(this.x).redMul(t.y.redSub(t.x)),r=this.y.redAdd(this.x).redMul(t.y.redAdd(t.x)),i=this.t.redMul(this.curve.dd).redMul(t.t),d=this.z.redMul(t.z.redAdd(t.z)),s=r.redSub(e),u=d.redSub(i),h=d.redAdd(i),n=r.redAdd(e),o=s.redMul(u),c=h.redMul(n),l=s.redMul(n),p=u.redMul(h);return this.curve.point(o,c,p,l)},u.prototype._projAdd=function(t){var e,r,i=this.z.redMul(t.z),d=i.redSqr(),s=this.x.redMul(t.x),u=this.y.redMul(t.y),h=this.curve.d.redMul(s).redMul(u),n=d.redSub(h),o=d.redAdd(h),c=this.x.redAdd(this.y).redMul(t.x.redAdd(t.y)).redISub(s).redISub(u),l=i.redMul(n).redMul(c);return this.curve.twisted?(e=i.redMul(o).redMul(u.redSub(this.curve._mulA(s))),r=n.redMul(o)):(e=i.redMul(o).redMul(u.redSub(s)),r=this.curve._mulC(n).redMul(o)),this.curve.point(l,e,r)},u.prototype.add=function(t){return this.isInfinity()?t:t.isInfinity()?this:this.curve.extended?this._extAdd(t):this._projAdd(t)},u.prototype.mul=function(t){return this._hasDoubles(t)?this.curve._fixedNafMul(this,t):this.curve._wnafMul(this,t)},u.prototype.mulAdd=function(t,e,r){return this.curve._wnafMulAdd(1,[this,e],[t,r],2,!1)},u.prototype.jmulAdd=function(t,e,r){return this.curve._wnafMulAdd(1,[this,e],[t,r],2,!0)},u.prototype.normalize=function(){if(this.zOne)return this;var t=this.z.redInvm();return this.x=this.x.redMul(t),this.y=this.y.redMul(t),this.t&&(this.t=this.t.redMul(t)),this.z=this.curve.one,this.zOne=!0,this},u.prototype.neg=function(){return this.curve.point(this.x.redNeg(),this.y,this.z,this.t&&this.t.redNeg())},u.prototype.getX=function(){return this.normalize(),this.x.fromRed()},u.prototype.getY=function(){return this.normalize(),this.y.fromRed()},u.prototype.eq=function(t){return this===t||0===this.getX().cmp(t.getX())&&0===this.getY().cmp(t.getY())},u.prototype.eqXToP=function(t){var e=t.toRed(this.curve.red).redMul(this.z);if(0===this.x.cmp(e))return!0;for(var r=t.clone(),i=this.curve.redN.redMul(this.z);;){if(r.iadd(this.curve.n),r.cmp(this.curve.p)>=0)return!1;if(e.redIAdd(i),0===this.x.cmp(e))return!0}},u.prototype.toP=u.prototype.normalize,u.prototype.mixedAdd=u.prototype.add; +},{"../utils":"hLmj","bn.js":"o7RX","inherits":"oxwV","./base":"Qo8X"}],"fmno":[function(require,module,exports) { +"use strict";var r=exports;r.base=require("./base"),r.short=require("./short"),r.mont=require("./mont"),r.edwards=require("./edwards"); +},{"./base":"Qo8X","./short":"JBz3","./mont":"iBD7","./edwards":"zADK"}],"nkOw":[function(require,module,exports) { +"use strict";var r=require("minimalistic-assert"),t=require("inherits");function n(r,t){return 55296==(64512&r.charCodeAt(t))&&(!(t<0||t+1>=r.length)&&56320==(64512&r.charCodeAt(t+1)))}function e(r,t){if(Array.isArray(r))return r.slice();if(!r)return[];var e=[];if("string"==typeof r)if(t){if("hex"===t)for((r=r.replace(/[^a-z0-9]+/gi,"")).length%2!=0&&(r="0"+r),u=0;u>6|192,e[o++]=63&i|128):n(r,u)?(i=65536+((1023&i)<<10)+(1023&r.charCodeAt(++u)),e[o++]=i>>18|240,e[o++]=i>>12&63|128,e[o++]=i>>6&63|128,e[o++]=63&i|128):(e[o++]=i>>12|224,e[o++]=i>>6&63|128,e[o++]=63&i|128)}else for(u=0;u>>24|r>>>8&65280|r<<8&16711680|(255&r)<<24)>>>0}function i(r,t){for(var n="",e=0;e>>0}return i}function h(r,t){for(var n=new Array(4*r.length),e=0,o=0;e>>24,n[o+1]=u>>>16&255,n[o+2]=u>>>8&255,n[o+3]=255&u):(n[o+3]=u>>>24,n[o+2]=u>>>16&255,n[o+1]=u>>>8&255,n[o]=255&u)}return n}function l(r,t){return r>>>t|r<<32-t}function p(r,t){return r<>>32-t}function a(r,t){return r+t>>>0}function x(r,t,n){return r+t+n>>>0}function g(r,t,n,e){return r+t+n+e>>>0}function _(r,t,n,e,o){return r+t+n+e+o>>>0}function v(r,t,n,e){var o=r[t],u=e+r[t+1]>>>0,i=(u>>0,r[t+1]=u}function m(r,t,n,e){return(t+e>>>0>>0}function A(r,t,n,e){return t+e>>>0}function y(r,t,n,e,o,u,i,s){var f=0,c=t;return f+=(c=c+e>>>0)>>0)>>0)>>0}function d(r,t,n,e,o,u,i,s){return t+e+u+s>>>0}function C(r,t,n,e,o,u,i,s,f,c){var h=0,l=t;return h+=(l=l+e>>>0)>>0)>>0)>>0)>>0}function z(r,t,n,e,o,u,i,s,f,c){return t+e+u+s+c>>>0}function b(r,t,n){return(t<<32-n|r>>>n)>>>0}function q(r,t,n){return(r<<32-n|t>>>n)>>>0}function w(r,t,n){return r>>>n}function H(r,t,n){return(r<<32-n|t>>>n)>>>0}exports.inherits=t,exports.toArray=e,exports.toHex=o,exports.htonl=u,exports.toHex32=i,exports.zero2=s,exports.zero8=f,exports.join32=c,exports.split32=h,exports.rotr32=l,exports.rotl32=p,exports.sum32=a,exports.sum32_3=x,exports.sum32_4=g,exports.sum32_5=_,exports.sum64=v,exports.sum64_hi=m,exports.sum64_lo=A,exports.sum64_4_hi=y,exports.sum64_4_lo=d,exports.sum64_5_hi=C,exports.sum64_5_lo=z,exports.rotr64_hi=b,exports.rotr64_lo=q,exports.shr64_hi=w,exports.shr64_lo=H; +},{"minimalistic-assert":"PhA8","inherits":"oxwV"}],"d5ks":[function(require,module,exports) { +"use strict";var t=require("./utils"),i=require("minimalistic-assert");function n(){this.pending=null,this.pendingTotal=0,this.blockSize=this.constructor.blockSize,this.outSize=this.constructor.outSize,this.hmacStrength=this.constructor.hmacStrength,this.padLength=this.constructor.padLength/8,this.endian="big",this._delta8=this.blockSize/8,this._delta32=this.blockSize/32}exports.BlockHash=n,n.prototype.update=function(i,n){if(i=t.toArray(i,n),this.pending?this.pending=this.pending.concat(i):this.pending=i,this.pendingTotal+=i.length,this.pending.length>=this._delta8){var e=(i=this.pending).length%this._delta8;this.pending=i.slice(i.length-e,i.length),0===this.pending.length&&(this.pending=null),i=t.join32(i,0,i.length-e,this.endian);for(var h=0;h>>24&255,e[h++]=t>>>16&255,e[h++]=t>>>8&255,e[h++]=255&t}else for(e[h++]=255&t,e[h++]=t>>>8&255,e[h++]=t>>>16&255,e[h++]=t>>>24&255,e[h++]=0,e[h++]=0,e[h++]=0,e[h++]=0,s=8;s>>3}function f(r){return t(r,17)^t(r,19)^r>>>10}exports.ft_1=n,exports.ch32=e,exports.maj32=u,exports.p32=o,exports.s0_256=s,exports.s1_256=i,exports.g0_256=c,exports.g1_256=f; +},{"../utils":"nkOw"}],"CO9T":[function(require,module,exports) { +"use strict";var t=require("../utils"),h=require("../common"),i=require("./common"),s=t.rotl32,e=t.sum32,r=t.sum32_5,o=i.ft_1,n=h.BlockHash,u=[1518500249,1859775393,2400959708,3395469782];function a(){if(!(this instanceof a))return new a;n.call(this),this.h=[1732584193,4023233417,2562383102,271733878,3285377520],this.W=new Array(80)}t.inherits(a,n),module.exports=a,a.blockSize=512,a.outSize=160,a.hmacStrength=80,a.padLength=64,a.prototype._update=function(t,h){for(var i=this.W,n=0;n<16;n++)i[n]=t[h+n];for(;nthis.blockSize&&(t=(new this.Hash).update(t).digest()),i(t.length<=this.blockSize);for(var e=t.length;e=this.minEntropy/8,"Not enough entropy. Minimum is: "+this.minEntropy+" bits"),this._init(h,r,n)}module.exports=s,s.prototype._init=function(t,e,i){var s=t.concat(e).concat(i);this.K=new Array(this.outLen/8),this.V=new Array(this.outLen/8);for(var h=0;h=this.minEntropy/8,"Not enough entropy. Minimum is: "+this.minEntropy+" bits"),this._update(t.concat(h||[])),this._reseed=1},s.prototype.generate=function(t,i,s,h){if(this._reseed>this.reseedInterval)throw new Error("Reseed is required");"string"!=typeof i&&(h=s,s=i,i=null),s&&(s=e.toArray(s,h||"hex"),this._update(s));for(var r=[];r.length"}; +},{"bn.js":"o7RX","../utils":"hLmj"}],"vm1O":[function(require,module,exports) { +"use strict";var r=require("bn.js"),e=require("../utils"),t=e.assert;function n(e,a){if(e instanceof n)return e;this._importDER(e,a)||(t(e.r&&e.s,"Signature without r or s"),this.r=new r(e.r,16),this.s=new r(e.s,16),void 0===e.recoveryParam?this.recoveryParam=null:this.recoveryParam=e.recoveryParam)}function a(){this.place=0}function i(r,e){var t=r[e.place++];if(!(128&t))return t;var n=15&t;if(0===n||n>4)return!1;for(var a=0,i=0,c=e.place;i>>=0;return!(a<=127)&&(e.place=c,a)}function c(r){for(var e=0,t=r.length-1;!r[e]&&!(128&r[e+1])&&e>>3);for(r.push(128|t);--t;)r.push(e>>>(t<<3)&255);r.push(e)}}module.exports=n,n.prototype._importDER=function(t,n){t=e.toArray(t,n);var c=new a;if(48!==t[c.place++])return!1;var o=i(t,c);if(!1===o)return!1;if(o+c.place!==t.length)return!1;if(2!==t[c.place++])return!1;var u=i(t,c);if(!1===u)return!1;var s=t.slice(c.place,u+c.place);if(c.place+=u,2!==t[c.place++])return!1;var l=i(t,c);if(!1===l)return!1;if(t.length!==l+c.place)return!1;var f=t.slice(c.place,l+c.place);if(0===s[0]){if(!(128&s[1]))return!1;s=s.slice(1)}if(0===f[0]){if(!(128&f[1]))return!1;f=f.slice(1)}return this.r=new r(s),this.s=new r(f),this.recoveryParam=null,!0},n.prototype.toDER=function(r){var t=this.r.toArray(),n=this.s.toArray();for(128&t[0]&&(t=[0].concat(t)),128&n[0]&&(n=[0].concat(n)),t=c(t),n=c(n);!(n[0]||128&n[1]);)n=n.slice(1);var a=[2];o(a,t.length),(a=a.concat(t)).push(2),o(a,n.length);var i=a.concat(n),u=[48];return o(u,i.length),u=u.concat(i),e.encode(u,r)}; +},{"bn.js":"o7RX","../utils":"hLmj"}],"Tbty":[function(require,module,exports) { +"use strict";var e=require("bn.js"),r=require("hmac-drbg"),t=require("../utils"),n=require("../curves"),i=require("brorand"),s=t.assert,o=require("./key"),u=require("./signature");function h(e){if(!(this instanceof h))return new h(e);"string"==typeof e&&(s(Object.prototype.hasOwnProperty.call(n,e),"Unknown curve "+e),e=n[e]),e instanceof n.PresetCurve&&(e={curve:e}),this.curve=e.curve.curve,this.n=this.curve.n,this.nh=this.n.ushrn(1),this.g=this.curve.g,this.g=e.curve.g,this.g.precompute(e.curve.n.bitLength()+1),this.hash=e.hash||e.curve.hash}module.exports=h,h.prototype.keyPair=function(e){return new o(this,e)},h.prototype.keyFromPrivate=function(e,r){return o.fromPrivate(this,e,r)},h.prototype.keyFromPublic=function(e,r){return o.fromPublic(this,e,r)},h.prototype.genKeyPair=function(t){t||(t={});for(var n=new r({hash:this.hash,pers:t.pers,persEnc:t.persEnc||"utf8",entropy:t.entropy||i(this.hash.hmacStrength),entropyEnc:t.entropy&&t.entropyEnc||"utf8",nonce:this.n.toArray()}),s=this.n.byteLength(),o=this.n.sub(new e(2));;){var u=new e(n.generate(s));if(!(u.cmp(o)>0))return u.iaddn(1),this.keyFromPrivate(u)}},h.prototype._truncateToN=function(e,r){var t=8*e.byteLength()-this.n.bitLength();return t>0&&(e=e.ushrn(t)),!r&&e.cmp(this.n)>=0?e.sub(this.n):e},h.prototype.sign=function(t,n,i,s){"object"==typeof i&&(s=i,i=null),s||(s={}),n=this.keyFromPrivate(n,i),t=this._truncateToN(new e(t,16));for(var o=this.n.byteLength(),h=n.getPrivate().toArray("be",o),c=t.toArray("be",o),a=new r({hash:this.hash,entropy:h,nonce:c,pers:s.pers,persEnc:s.persEnc||"utf8"}),p=this.n.sub(new e(1)),m=0;;m++){var v=s.k?s.k(m):new e(a.generate(this.n.byteLength()));if(!((v=this._truncateToN(v,!0)).cmpn(1)<=0||v.cmp(p)>=0)){var y=this.g.mul(v);if(!y.isInfinity()){var f=y.getX(),g=f.umod(this.n);if(0!==g.cmpn(0)){var d=v.invm(this.n).mul(g.mul(n.getPrivate()).iadd(t));if(0!==(d=d.umod(this.n)).cmpn(0)){var l=(y.getY().isOdd()?1:0)|(0!==f.cmp(g)?2:0);return s.canonical&&d.cmp(this.nh)>0&&(d=this.n.sub(d),l^=1),new u({r:g,s:d,recoveryParam:l})}}}}}},h.prototype.verify=function(r,t,n,i){r=this._truncateToN(new e(r,16)),n=this.keyFromPublic(n,i);var s=(t=new u(t,"hex")).r,o=t.s;if(s.cmpn(1)<0||s.cmp(this.n)>=0)return!1;if(o.cmpn(1)<0||o.cmp(this.n)>=0)return!1;var h,c=o.invm(this.n),a=c.mul(r).umod(this.n),p=c.mul(s).umod(this.n);return this.curve._maxwellTrick?!(h=this.g.jmulAdd(a,n.getPublic(),p)).isInfinity()&&h.eqXToP(s):!(h=this.g.mulAdd(a,n.getPublic(),p)).isInfinity()&&0===h.getX().umod(this.n).cmp(s)},h.prototype.recoverPubKey=function(r,t,n,i){s((3&n)===n,"The recovery param is more than two bits"),t=new u(t,i);var o=this.n,h=new e(r),c=t.r,a=t.s,p=1&n,m=n>>1;if(c.cmp(this.curve.p.umod(this.curve.n))>=0&&m)throw new Error("Unable to find sencond key candinate");c=m?this.curve.pointFromX(c.add(this.curve.n),p):this.curve.pointFromX(c,p);var v=t.r.invm(o),y=o.sub(h).mul(v).umod(o),f=a.mul(v).umod(o);return this.g.mulAdd(y,c,f)},h.prototype.getKeyRecoveryParam=function(e,r,t,n){if(null!==(r=new u(r,n)).recoveryParam)return r.recoveryParam;for(var i=0;i<4;i++){var s;try{s=this.recoverPubKey(e,r,i)}catch(e){continue}if(s.eq(t))return i}throw new Error("Unable to find valid recovery factor")}; +},{"bn.js":"o7RX","hmac-drbg":"kwdl","../utils":"hLmj","../curves":"YeyX","brorand":"En1q","./key":"FvtJ","./signature":"vm1O"}],"uUCk":[function(require,module,exports) { +"use strict";var t=require("../utils"),e=t.assert,s=t.parseBytes,i=t.cachedProperty;function n(t,e){this.eddsa=t,this._secret=s(e.secret),t.isPoint(e.pub)?this._pub=e.pub:this._pubBytes=s(e.pub)}n.fromPublic=function(t,e){return e instanceof n?e:new n(t,{pub:e})},n.fromSecret=function(t,e){return e instanceof n?e:new n(t,{secret:e})},n.prototype.secret=function(){return this._secret},i(n,"pubBytes",function(){return this.eddsa.encodePoint(this.pub())}),i(n,"pub",function(){return this._pubBytes?this.eddsa.decodePoint(this._pubBytes):this.eddsa.g.mul(this.priv())}),i(n,"privBytes",function(){var t=this.eddsa,e=this.hash(),s=t.encodingLength-1,i=e.slice(0,t.encodingLength);return i[0]&=248,i[s]&=127,i[s]|=64,i}),i(n,"priv",function(){return this.eddsa.decodeInt(this.privBytes())}),i(n,"hash",function(){return this.eddsa.hash().update(this.secret()).digest()}),i(n,"messagePrefix",function(){return this.hash().slice(this.eddsa.encodingLength)}),n.prototype.sign=function(t){return e(this._secret,"KeyPair can only verify"),this.eddsa.sign(t,this)},n.prototype.verify=function(t,e){return this.eddsa.verify(t,e,this)},n.prototype.getSecret=function(s){return e(this._secret,"KeyPair is public only"),t.encode(this.secret(),s)},n.prototype.getPublic=function(e){return t.encode(this.pubBytes(),e)},module.exports=n; +},{"../utils":"hLmj"}],"Fvj4":[function(require,module,exports) { +"use strict";var e=require("bn.js"),t=require("../utils"),n=t.assert,o=t.cachedProperty,d=t.parseBytes;function i(t,o){this.eddsa=t,"object"!=typeof o&&(o=d(o)),Array.isArray(o)&&(o={R:o.slice(0,t.encodingLength),S:o.slice(t.encodingLength)}),n(o.R&&o.S,"Signature without R or S"),t.isPoint(o.R)&&(this._R=o.R),o.S instanceof e&&(this._S=o.S),this._Rencoded=Array.isArray(o.R)?o.R:o.Rencoded,this._Sencoded=Array.isArray(o.S)?o.S:o.Sencoded}o(i,"S",function(){return this.eddsa.decodeInt(this.Sencoded())}),o(i,"R",function(){return this.eddsa.decodePoint(this.Rencoded())}),o(i,"Rencoded",function(){return this.eddsa.encodePoint(this.R())}),o(i,"Sencoded",function(){return this.eddsa.encodeInt(this.S())}),i.prototype.toBytes=function(){return this.Rencoded().concat(this.Sencoded())},i.prototype.toHex=function(){return t.encode(this.toBytes(),"hex").toUpperCase()},module.exports=i; +},{"bn.js":"o7RX","../utils":"hLmj"}],"td2I":[function(require,module,exports) { +"use strict";var t=require("hash.js"),e=require("../curves"),n=require("../utils"),r=n.assert,i=n.parseBytes,o=require("./key"),s=require("./signature");function u(n){if(r("ed25519"===n,"only tested with ed25519 so far"),!(this instanceof u))return new u(n);n=e[n].curve,this.curve=n,this.g=n.g,this.g.precompute(n.n.bitLength()+1),this.pointClass=n.point().constructor,this.encodingLength=Math.ceil(n.n.bitLength()/8),this.hash=t.sha512}module.exports=u,u.prototype.sign=function(t,e){t=i(t);var n=this.keyFromSecret(e),r=this.hashInt(n.messagePrefix(),t),o=this.g.mul(r),s=this.encodePoint(o),u=this.hashInt(s,n.pubBytes(),t).mul(n.priv()),h=r.add(u).umod(this.curve.n);return this.makeSignature({R:o,S:h,Rencoded:s})},u.prototype.verify=function(t,e,n){t=i(t),e=this.makeSignature(e);var r=this.keyFromPublic(n),o=this.hashInt(e.Rencoded(),r.pubBytes(),t),s=this.g.mul(e.S());return e.R().add(r.pub().mul(o)).eq(s)},u.prototype.hashInt=function(){for(var t=this.hash(),e=0;e=2*(1<<30))throw new RangeError('The value "'+e+'" is invalid for option "size"');var o=n(e);return r&&0!==r.length?"string"==typeof t?o.fill(r,t):o.fill(r):o.fill(0),o}),!o.kStringMaxLength)try{o.kStringMaxLength=r.binding("buffer").kStringMaxLength}catch(i){}o.constants||(o.constants={MAX_LENGTH:o.kMaxLength},o.kStringMaxLength&&(o.constants.MAX_STRING_LENGTH=o.kStringMaxLength)),module.exports=o; +},{"buffer":"z1tx","process":"g5IB"}],"AW2j":[function(require,module,exports) { +"use strict";const t=require("inherits");function r(t){this._reporterState={obj:null,path:[],options:t||{},errors:[]}}function e(t,r){this.path=t,this.rethrow(r)}exports.Reporter=r,r.prototype.isError=function(t){return t instanceof e},r.prototype.save=function(){const t=this._reporterState;return{obj:t.obj,pathLen:t.path.length}},r.prototype.restore=function(t){const r=this._reporterState;r.obj=t.obj,r.path=r.path.slice(0,t.pathLen)},r.prototype.enterKey=function(t){return this._reporterState.path.push(t)},r.prototype.exitKey=function(t){const r=this._reporterState;r.path=r.path.slice(0,t-1)},r.prototype.leaveKey=function(t,r,e){const o=this._reporterState;this.exitKey(t),null!==o.obj&&(o.obj[r]=e)},r.prototype.path=function(){return this._reporterState.path.join("/")},r.prototype.enterObject=function(){const t=this._reporterState,r=t.obj;return t.obj={},r},r.prototype.leaveObject=function(t){const r=this._reporterState,e=r.obj;return r.obj=t,e},r.prototype.error=function(t){let r;const o=this._reporterState,n=t instanceof e;if(r=n?t:new e(o.path.map(function(t){return"["+JSON.stringify(t)+"]"}).join(""),t.message||t,t.stack),!o.options.partial)throw r;return n||o.errors.push(r),r},r.prototype.wrapResult=function(t){const r=this._reporterState;return r.options.partial?{result:this.isError(t)?null:t,errors:r.errors}:t},t(e,Error),e.prototype.rethrow=function(t){if(this.message=t+" at: "+(this.path||"(shallow)"),Error.captureStackTrace&&Error.captureStackTrace(this,e),!this.stack)try{throw new Error(this.message)}catch(r){this.stack=r.stack}return this}; +},{"inherits":"oxwV"}],"nnAu":[function(require,module,exports) { + +"use strict";const e=require("inherits"),t=require("../base/reporter").Reporter,r=require("safer-buffer").Buffer;function o(e,o){t.call(this,o),r.isBuffer(e)?(this.base=e,this.offset=0,this.length=e.length):this.error("Input not Buffer")}function f(e,t){if(Array.isArray(e))this.length=0,this.value=e.map(function(e){return f.isEncoderBuffer(e)||(e=new f(e,t)),this.length+=e.length,e},this);else if("number"==typeof e){if(!(0<=e&&e<=255))return t.error("non-byte EncoderBuffer value");this.value=e,this.length=1}else if("string"==typeof e)this.value=e,this.length=r.byteLength(e);else{if(!r.isBuffer(e))return t.error("Unsupported type: "+typeof e);this.value=e,this.length=e.length}}e(o,t),exports.DecoderBuffer=o,o.isDecoderBuffer=function(e){if(e instanceof o)return!0;return"object"==typeof e&&r.isBuffer(e.base)&&"DecoderBuffer"===e.constructor.name&&"number"==typeof e.offset&&"number"==typeof e.length&&"function"==typeof e.save&&"function"==typeof e.restore&&"function"==typeof e.isEmpty&&"function"==typeof e.readUInt8&&"function"==typeof e.skip&&"function"==typeof e.raw},o.prototype.save=function(){return{offset:this.offset,reporter:t.prototype.save.call(this)}},o.prototype.restore=function(e){const r=new o(this.base);return r.offset=e.offset,r.length=this.offset,this.offset=e.offset,t.prototype.restore.call(this,e.reporter),r},o.prototype.isEmpty=function(){return this.offset===this.length},o.prototype.readUInt8=function(e){return this.offset+1<=this.length?this.base.readUInt8(this.offset++,!0):this.error(e||"DecoderBuffer overrun")},o.prototype.skip=function(e,t){if(!(this.offset+e<=this.length))return this.error(t||"DecoderBuffer overrun");const r=new o(this.base);return r._reporterState=this._reporterState,r.offset=this.offset,r.length=this.offset+e,this.offset+=e,r},o.prototype.raw=function(e){return this.base.slice(e?e.offset:this.offset,this.length)},exports.EncoderBuffer=f,f.isEncoderBuffer=function(e){if(e instanceof f)return!0;return"object"==typeof e&&"EncoderBuffer"===e.constructor.name&&"number"==typeof e.length&&"function"==typeof e.join},f.prototype.join=function(e,t){return e||(e=r.alloc(this.length)),t||(t=0),0===this.length?e:(Array.isArray(this.value)?this.value.forEach(function(r){r.join(e,t),t+=r.length}):("number"==typeof this.value?e[t]=this.value:"string"==typeof this.value?e.write(this.value,t):r.isBuffer(this.value)&&this.value.copy(e,t),t+=this.length),e)}; +},{"inherits":"oxwV","../base/reporter":"AW2j","safer-buffer":"IVbQ"}],"dLZ9":[function(require,module,exports) { +"use strict";const e=require("../base/reporter").Reporter,t=require("../base/buffer").EncoderBuffer,n=require("../base/buffer").DecoderBuffer,i=require("minimalistic-assert"),o=["seq","seqof","set","setof","objid","bool","gentime","utctime","null_","enum","int","objDesc","bitstr","bmpstr","charstr","genstr","graphstr","ia5str","iso646str","numstr","octstr","printstr","t61str","unistr","utf8str","videostr"],r=["key","obj","use","optional","explicit","implicit","def","choice","any","contains"].concat(o),s=["_peekTag","_decodeTag","_use","_decodeStr","_decodeObjid","_decodeTime","_decodeNull","_decodeInt","_decodeBool","_decodeList","_encodeComposite","_encodeStr","_encodeObjid","_encodeTime","_encodeNull","_encodeInt","_encodeBool"];function c(e,t,n){const i={};this._baseState=i,i.name=n,i.enc=e,i.parent=t||null,i.children=null,i.tag=null,i.args=null,i.reverseArgs=null,i.choice=null,i.optional=!1,i.any=!1,i.obj=!1,i.use=null,i.useDecoder=null,i.key=null,i.default=null,i.explicit=null,i.implicit=null,i.contains=null,i.parent||(i.children=[],this._wrap())}module.exports=c;const l=["enc","parent","children","tag","args","reverseArgs","choice","optional","any","obj","use","alteredUse","key","default","explicit","implicit","contains"];c.prototype.clone=function(){const e=this._baseState,t={};l.forEach(function(n){t[n]=e[n]});const n=new this.constructor(t.parent);return n._baseState=t,n},c.prototype._wrap=function(){const e=this._baseState;r.forEach(function(t){this[t]=function(){const n=new this.constructor(this);return e.children.push(n),n[t].apply(n,arguments)}},this)},c.prototype._init=function(e){const t=this._baseState;i(null===t.parent),e.call(this),t.children=t.children.filter(function(e){return e._baseState.parent===this},this),i.equal(t.children.length,1,"Root node can have only one child")},c.prototype._useArgs=function(e){const t=this._baseState,n=e.filter(function(e){return e instanceof this.constructor},this);e=e.filter(function(e){return!(e instanceof this.constructor)},this),0!==n.length&&(i(null===t.children),t.children=n,n.forEach(function(e){e._baseState.parent=this},this)),0!==e.length&&(i(null===t.args),t.args=e,t.reverseArgs=e.map(function(e){if("object"!=typeof e||e.constructor!==Object)return e;const t={};return Object.keys(e).forEach(function(n){n==(0|n)&&(n|=0);const i=e[n];t[i]=n}),t}))},s.forEach(function(e){c.prototype[e]=function(){const t=this._baseState;throw new Error(e+" not implemented for encoding: "+t.enc)}}),o.forEach(function(e){c.prototype[e]=function(){const t=this._baseState,n=Array.prototype.slice.call(arguments);return i(null===t.tag),t.tag=e,this._useArgs(n),this}}),c.prototype.use=function(e){i(e);const t=this._baseState;return i(null===t.use),t.use=e,this},c.prototype.optional=function(){return this._baseState.optional=!0,this},c.prototype.def=function(e){const t=this._baseState;return i(null===t.default),t.default=e,t.optional=!0,this},c.prototype.explicit=function(e){const t=this._baseState;return i(null===t.explicit&&null===t.implicit),t.explicit=e,this},c.prototype.implicit=function(e){const t=this._baseState;return i(null===t.explicit&&null===t.implicit),t.implicit=e,this},c.prototype.obj=function(){const e=this._baseState,t=Array.prototype.slice.call(arguments);return e.obj=!0,0!==t.length&&this._useArgs(t),this},c.prototype.key=function(e){const t=this._baseState;return i(null===t.key),t.key=e,this},c.prototype.any=function(){return this._baseState.any=!0,this},c.prototype.choice=function(e){const t=this._baseState;return i(null===t.choice),t.choice=e,this._useArgs(Object.keys(e).map(function(t){return e[t]})),this},c.prototype.contains=function(e){const t=this._baseState;return i(null===t.use),t.contains=e,this},c.prototype._decode=function(e,t){const i=this._baseState;if(null===i.parent)return e.wrapResult(i.children[0]._decode(e,t));let o,r=i.default,s=!0,c=null;if(null!==i.key&&(c=e.enterKey(i.key)),i.optional){let n=null;if(null!==i.explicit?n=i.explicit:null!==i.implicit?n=i.implicit:null!==i.tag&&(n=i.tag),null!==n||i.any){if(s=this._peekTag(e,n,i.any),e.isError(s))return s}else{const n=e.save();try{null===i.choice?this._decodeGeneric(i.tag,e,t):this._decodeChoice(e,t),s=!0}catch(l){s=!1}e.restore(n)}}if(i.obj&&s&&(o=e.enterObject()),s){if(null!==i.explicit){const t=this._decodeTag(e,i.explicit);if(e.isError(t))return t;e=t}const o=e.offset;if(null===i.use&&null===i.choice){let t;i.any&&(t=e.save());const n=this._decodeTag(e,null!==i.implicit?i.implicit:i.tag,i.any);if(e.isError(n))return n;i.any?r=e.raw(t):e=n}if(t&&t.track&&null!==i.tag&&t.track(e.path(),o,e.length,"tagged"),t&&t.track&&null!==i.tag&&t.track(e.path(),e.offset,e.length,"content"),i.any||(r=null===i.choice?this._decodeGeneric(i.tag,e,t):this._decodeChoice(e,t)),e.isError(r))return r;if(i.any||null!==i.choice||null===i.children||i.children.forEach(function(n){n._decode(e,t)}),i.contains&&("octstr"===i.tag||"bitstr"===i.tag)){const o=new n(r);r=this._getUse(i.contains,e._reporterState.obj)._decode(o,t)}}return i.obj&&s&&(r=e.leaveObject(o)),null===i.key||null===r&&!0!==s?null!==c&&e.exitKey(c):e.leaveKey(c,i.key,r),r},c.prototype._decodeGeneric=function(e,t,n){const i=this._baseState;return"seq"===e||"set"===e?null:"seqof"===e||"setof"===e?this._decodeList(t,e,i.args[0],n):/str$/.test(e)?this._decodeStr(t,e,n):"objid"===e&&i.args?this._decodeObjid(t,i.args[0],i.args[1],n):"objid"===e?this._decodeObjid(t,null,null,n):"gentime"===e||"utctime"===e?this._decodeTime(t,e,n):"null_"===e?this._decodeNull(t,n):"bool"===e?this._decodeBool(t,n):"objDesc"===e?this._decodeStr(t,e,n):"int"===e||"enum"===e?this._decodeInt(t,i.args&&i.args[0],n):null!==i.use?this._getUse(i.use,t._reporterState.obj)._decode(t,n):t.error("unknown tag: "+e)},c.prototype._getUse=function(e,t){const n=this._baseState;return n.useDecoder=this._use(e,t),i(null===n.useDecoder._baseState.parent),n.useDecoder=n.useDecoder._baseState.children[0],n.implicit!==n.useDecoder._baseState.implicit&&(n.useDecoder=n.useDecoder.clone(),n.useDecoder._baseState.implicit=n.implicit),n.useDecoder},c.prototype._decodeChoice=function(e,t){const n=this._baseState;let i=null,o=!1;return Object.keys(n.choice).some(function(r){const s=e.save(),c=n.choice[r];try{const n=c._decode(e,t);if(e.isError(n))return!1;i={type:r,value:n},o=!0}catch(l){return e.restore(s),!1}return!0},this),o?i:e.error("Choice not matched")},c.prototype._createEncoderBuffer=function(e){return new t(e,this.reporter)},c.prototype._encode=function(e,t,n){const i=this._baseState;if(null!==i.default&&i.default===e)return;const o=this._encodeValue(e,t,n);return void 0===o||this._skipDefault(o,t,n)?void 0:o},c.prototype._encodeValue=function(t,n,i){const o=this._baseState;if(null===o.parent)return o.children[0]._encode(t,n||new e);let r=null;if(this.reporter=n,o.optional&&void 0===t){if(null===o.default)return;t=o.default}let s=null,c=!1;if(o.any)r=this._createEncoderBuffer(t);else if(o.choice)r=this._encodeChoice(t,n);else if(o.contains)s=this._getUse(o.contains,i)._encode(t,n),c=!0;else if(o.children)s=o.children.map(function(e){if("null_"===e._baseState.tag)return e._encode(null,n,t);if(null===e._baseState.key)return n.error("Child should have a key");const i=n.enterKey(e._baseState.key);if("object"!=typeof t)return n.error("Child expected, but input is not object");const o=e._encode(t[e._baseState.key],n,t);return n.leaveKey(i),o},this).filter(function(e){return e}),s=this._createEncoderBuffer(s);else if("seqof"===o.tag||"setof"===o.tag){if(!o.args||1!==o.args.length)return n.error("Too many args for : "+o.tag);if(!Array.isArray(t))return n.error("seqof/setof, but data is not Array");const e=this.clone();e._baseState.implicit=null,s=this._createEncoderBuffer(t.map(function(e){const i=this._baseState;return this._getUse(i.args[0],t)._encode(e,n)},e))}else null!==o.use?r=this._getUse(o.use,i)._encode(t,n):(s=this._encodePrimitive(o.tag,t),c=!0);if(!o.any&&null===o.choice){const e=null!==o.implicit?o.implicit:o.tag,t=null===o.implicit?"universal":"context";null===e?null===o.use&&n.error("Tag could be omitted only for .use()"):null===o.use&&(r=this._encodeComposite(e,c,t,s))}return null!==o.explicit&&(r=this._encodeComposite(o.explicit,!1,"context",r)),r},c.prototype._encodeChoice=function(e,t){const n=this._baseState,o=n.choice[e.type];return o||i(!1,e.type+" not found in "+JSON.stringify(Object.keys(n.choice))),o._encode(e.value,t)},c.prototype._encodePrimitive=function(e,t){const n=this._baseState;if(/str$/.test(e))return this._encodeStr(t,e);if("objid"===e&&n.args)return this._encodeObjid(t,n.reverseArgs[0],n.args[1]);if("objid"===e)return this._encodeObjid(t,null,null);if("gentime"===e||"utctime"===e)return this._encodeTime(t,e);if("null_"===e)return this._encodeNull();if("int"===e||"enum"===e)return this._encodeInt(t,n.args&&n.reverseArgs[0]);if("bool"===e)return this._encodeBool(t);if("objDesc"===e)return this._encodeStr(t,e);throw new Error("Unsupported tag: "+e)},c.prototype._isNumstr=function(e){return/^[0-9 ]*$/.test(e)},c.prototype._isPrintstr=function(e){return/^[A-Za-z0-9 '()+,-./:=?]*$/.test(e)}; +},{"../base/reporter":"AW2j","../base/buffer":"nnAu","minimalistic-assert":"PhA8"}],"kV6m":[function(require,module,exports) { +"use strict";function t(t){const s={};return Object.keys(t).forEach(function(e){(0|e)==e&&(e|=0);const r=t[e];s[r]=e}),s}exports.tagClass={0:"universal",1:"application",2:"context",3:"private"},exports.tagClassByName=t(exports.tagClass),exports.tag={0:"end",1:"bool",2:"int",3:"bitstr",4:"octstr",5:"null_",6:"objid",7:"objDesc",8:"external",9:"real",10:"enum",11:"embed",12:"utf8str",13:"relativeOid",16:"seq",17:"set",18:"numstr",19:"printstr",20:"t61str",21:"videostr",22:"ia5str",23:"utctime",24:"gentime",25:"graphstr",26:"iso646str",27:"genstr",28:"unistr",29:"charstr",30:"bmpstr"},exports.tagByName=t(exports.tag); +},{}],"Q8XQ":[function(require,module,exports) { + +"use strict";const e=require("inherits"),t=require("safer-buffer").Buffer,r=require("../base/node"),n=require("../constants/der");function o(e){this.enc="der",this.name=e.name,this.entity=e,this.tree=new i,this.tree._init(e.body)}function i(e){r.call(this,"der",e)}function s(e){return e<10?"0"+e:e}function f(e,t,r,o){let i;if("seqof"===e?e="seq":"setof"===e&&(e="set"),n.tagByName.hasOwnProperty(e))i=n.tagByName[e];else{if("number"!=typeof e||(0|e)!==e)return o.error("Unknown tag: "+e);i=e}return i>=31?o.error("Multi-octet tag encoding unsupported"):(t||(i|=32),i|=n.tagClassByName[r||"universal"]<<6)}module.exports=o,o.prototype.encode=function(e,t){return this.tree._encode(e,t).join()},e(i,r),i.prototype._encodeComposite=function(e,r,n,o){const i=f(e,r,n,this.reporter);if(o.length<128){const e=t.alloc(2);return e[0]=i,e[1]=o.length,this._createEncoderBuffer([e,o])}let s=1;for(let t=o.length;t>=256;t>>=8)s++;const u=t.alloc(2+s);u[0]=i,u[1]=128|s;for(let t=1+s,f=o.length;f>0;t--,f>>=8)u[t]=255&f;return this._createEncoderBuffer([u,o])},i.prototype._encodeStr=function(e,r){if("bitstr"===r)return this._createEncoderBuffer([0|e.unused,e.data]);if("bmpstr"===r){const r=t.alloc(2*e.length);for(let t=0;t=40)return this.reporter.error("Second objid identifier OOB");e.splice(0,2,40*e[0]+e[1])}let o=0;for(let t=0;t=128;r>>=7)o++}const i=t.alloc(o);let s=i.length-1;for(let t=e.length-1;t>=0;t--){let r=e[t];for(i[s--]=127&r;(r>>=7)>0;)i[s--]=128|127&r}return this._createEncoderBuffer(i)},i.prototype._encodeTime=function(e,t){let r;const n=new Date(e);return"gentime"===t?r=[s(n.getUTCFullYear()),s(n.getUTCMonth()+1),s(n.getUTCDate()),s(n.getUTCHours()),s(n.getUTCMinutes()),s(n.getUTCSeconds()),"Z"].join(""):"utctime"===t?r=[s(n.getUTCFullYear()%100),s(n.getUTCMonth()+1),s(n.getUTCDate()),s(n.getUTCHours()),s(n.getUTCMinutes()),s(n.getUTCSeconds()),"Z"].join(""):this.reporter.error("Encoding "+t+" time is not supported yet"),this._encodeStr(r,"octstr")},i.prototype._encodeNull=function(){return this._createEncoderBuffer("")},i.prototype._encodeInt=function(e,r){if("string"==typeof e){if(!r)return this.reporter.error("String int or enum given, but no values map");if(!r.hasOwnProperty(e))return this.reporter.error("Values map doesn't contain: "+JSON.stringify(e));e=r[e]}if("number"!=typeof e&&!t.isBuffer(e)){const r=e.toArray();!e.sign&&128&r[0]&&r.unshift(0),e=t.from(r)}if(t.isBuffer(e)){let r=e.length;0===e.length&&r++;const n=t.alloc(r);return e.copy(n),0===e.length&&(n[0]=0),this._createEncoderBuffer(n)}if(e<128)return this._createEncoderBuffer(e);if(e<256)return this._createEncoderBuffer([0,e]);let n=1;for(let t=e;t>=256;t>>=8)n++;const o=new Array(n);for(let t=o.length-1;t>=0;t--)o[t]=255&e,e>>=8;return 128&o[0]&&o.unshift(0),this._createEncoderBuffer(t.from(o))},i.prototype._encodeBool=function(e){return this._createEncoderBuffer(e?255:0)},i.prototype._use=function(e,t){return"function"==typeof e&&(e=e(t)),e._getEncoder("der").tree},i.prototype._skipDefault=function(e,t,r){const n=this._baseState;let o;if(null===n.default)return!1;const i=e.join();if(void 0===n.defaultBuffer&&(n.defaultBuffer=this._encodeValue(n.default,t,r).join()),i.length!==n.defaultBuffer.length)return!1;for(o=0;o>6],n=0==(32&e);if(31==(31&e)){let i=e;for(e=0;128==(128&i);){if(i=t.readUInt8(r),t.isError(i))return i;e<<=7,e|=127&i}}else e&=31;return{cls:i,primitive:n,tag:e,tagStr:o.tag[e]}}function u(t,r,e){let i=t.readUInt8(e);if(t.isError(i))return i;if(!r&&128===i)return null;if(0==(128&i))return i;const o=127&i;if(o>4)return t.error("length octect is too long");i=0;for(let n=0;n0&&t.ishrn(n),t}function l(r,t){r=(r=s(r,t)).mod(t);var n=e.from(r.toArray());if(n.length=e)throw new Error("invalid sig")}module.exports=a; +},{"safe-buffer":"gIYa","bn.js":"EOUW","elliptic":"G54Y","parse-asn1":"vlJd","./curves.json":"maRY"}],"VebA":[function(require,module,exports) { + +var t=require("safe-buffer").Buffer,e=require("create-hash"),i=require("readable-stream"),r=require("inherits"),s=require("./sign"),h=require("./verify"),n=require("./algorithms.json");function a(t){i.Writable.call(this);var r=n[t];if(!r)throw new Error("Unknown message digest");this._hashType=r.hash,this._hash=e(r.hash),this._tag=r.id,this._signType=r.sign}function o(t){i.Writable.call(this);var r=n[t];if(!r)throw new Error("Unknown message digest");this._hash=e(r.hash),this._tag=r.id,this._signType=r.sign}function u(t){return new a(t)}function f(t){return new o(t)}Object.keys(n).forEach(function(e){n[e].id=t.from(n[e].id,"hex"),n[e.toLowerCase()]=n[e]}),r(a,i.Writable),a.prototype._write=function(t,e,i){this._hash.update(t),i()},a.prototype.update=function(e,i){return"string"==typeof e&&(e=t.from(e,i)),this._hash.update(e),this},a.prototype.sign=function(t,e){this.end();var i=this._hash.digest(),r=s(i,t,this._hashType,this._signType,this._tag);return e?r.toString(e):r},r(o,i.Writable),o.prototype._write=function(t,e,i){this._hash.update(t),i()},o.prototype.update=function(e,i){return"string"==typeof e&&(e=t.from(e,i)),this._hash.update(e),this},o.prototype.verify=function(e,i,r){"string"==typeof i&&(i=t.from(i,r)),this.end();var s=this._hash.digest();return h(i,s,e,this._signType,this._tag)},module.exports={Sign:u,Verify:f,createSign:u,createVerify:f}; +},{"safe-buffer":"gIYa","create-hash":"CBfM","readable-stream":"R738","inherits":"oxwV","./sign":"vQ8q","./verify":"SWON","./algorithms.json":"X0Jx"}],"Qs7t":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var e=require("buffer").Buffer,t=require("elliptic"),r=require("bn.js");module.exports=function(e){return new n(e)};var i={secp256k1:{name:"secp256k1",byteLength:32},secp224r1:{name:"p224",byteLength:28},prime256v1:{name:"p256",byteLength:32},prime192v1:{name:"p192",byteLength:24},ed25519:{name:"ed25519",byteLength:32},secp384r1:{name:"p384",byteLength:48},secp521r1:{name:"p521",byteLength:66}};function n(e){this.curveType=i[e],this.curveType||(this.curveType={name:e}),this.curve=new t.ec(this.curveType.name),this.keys=void 0}function s(t,r,i){Array.isArray(t)||(t=t.toArray());var n=new e(t);if(i&&n.lengthl-g-2)throw new Error("message too long");var d=i.alloc(l-s-g-2),h=l-c-1,w=e(c),m=a(i.concat([f,d,i.alloc(1,1),u],h),n(w,h)),q=a(w,n(m,c));return new t(i.concat([i.alloc(1),q,m],l))}function f(r,e,o){var n,a=e.length,u=r.modulus.byteLength();if(a>u-11)throw new Error("message too long");return n=o?i.alloc(u-a-3,255):c(u-a-3),new t(i.concat([i.from([0,o?1:2]),n,i.alloc(1),e],u))}function c(r){for(var o,n=i.allocUnsafe(r),a=0,t=e(2*r),u=0;a=0)throw new Error("data too long for modulus")}return n?l(i,c):u(i,c)}; +},{"parse-asn1":"vlJd","randombytes":"pXr2","create-hash":"CBfM","./mgf":"YSbm","./xor":"KvhV","bn.js":"o7RX","./withPublic":"TOWt","browserify-rsa":"hH2J","safe-buffer":"gIYa"}],"JjUB":[function(require,module,exports) { + +var r=require("parse-asn1"),e=require("./mgf"),n=require("./xor"),t=require("bn.js"),o=require("browserify-rsa"),i=require("create-hash"),u=require("./withPublic"),a=require("safe-buffer").Buffer;function l(r,t){var o=r.modulus.byteLength(),u=i("sha1").update(a.alloc(0)).digest(),l=u.length;if(0!==t[0])throw new Error("decryption error");var f=t.slice(1,l+1),c=t.slice(l+1),s=n(f,e(c,l)),g=n(c,e(s,o-l-1));if(h(u,g.slice(0,l)))throw new Error("decryption error");for(var d=l;0===g[d];)d++;if(1!==g[d++])throw new Error("decryption error");return g.slice(d)}function f(r,e,n){for(var t=e.slice(0,2),o=2,i=0;0!==e[o++];)if(o>=e.length){i++;break}var u=e.slice(2,o-1);if(("0002"!==t.toString("hex")&&!n||"0001"!==t.toString("hex")&&n)&&i++,u.length<8&&i++,i)throw new Error("decryption error");return e.slice(o)}function h(r,e){r=a.from(r),e=a.from(e);var n=0,t=r.length;r.length!==e.length&&(n++,t=Math.min(r.length,e.length));for(var o=-1;++og||new t(n).cmp(s.modulus)>=0)throw new Error("decryption error");c=i?u(new t(n),s):o(n,s);var d=a.alloc(g-c.length);if(c=a.concat([d,c],g),4===h)return l(s,c);if(1===h)return f(s,c,i);if(3===h)return c;throw new Error("unknown padding")}; +},{"parse-asn1":"vlJd","./mgf":"YSbm","./xor":"KvhV","bn.js":"o7RX","browserify-rsa":"hH2J","create-hash":"CBfM","./withPublic":"TOWt","safe-buffer":"gIYa"}],"KGZW":[function(require,module,exports) { +exports.publicEncrypt=require("./publicEncrypt"),exports.privateDecrypt=require("./privateDecrypt"),exports.privateEncrypt=function(r,p){return exports.publicEncrypt(r,p,!0)},exports.publicDecrypt=function(r,p){return exports.privateDecrypt(r,p,!0)}; +},{"./publicEncrypt":"Hv4l","./privateDecrypt":"JjUB"}],"EJeA":[function(require,module,exports) { + +var global = arguments[3]; +var process = require("process"); +var r=arguments[3],e=require("process");function n(){throw new Error("secure random number generation not supported by this browser\nuse chrome, FireFox or Internet Explorer 11")}var t=require("safe-buffer"),o=require("randombytes"),f=t.Buffer,u=t.kMaxLength,i=r.crypto||r.msCrypto,a=Math.pow(2,32)-1;function s(r,e){if("number"!=typeof r||r!=r)throw new TypeError("offset must be a number");if(r>a||r<0)throw new TypeError("offset must be a uint32");if(r>u||r>e)throw new RangeError("offset out of range")}function m(r,e,n){if("number"!=typeof r||r!=r)throw new TypeError("size must be a number");if(r>a||r<0)throw new TypeError("size must be a uint32");if(r+e>n||r>u)throw new RangeError("buffer too small")}function l(e,n,t,o){if(!(f.isBuffer(e)||e instanceof r.Uint8Array))throw new TypeError('"buf" argument must be a Buffer or Uint8Array');if("function"==typeof n)o=n,n=0,t=e.length;else if("function"==typeof t)o=t,t=e.length-n;else if("function"!=typeof o)throw new TypeError('"cb" argument must be a function');return s(n,e.length),m(t,n,e.length),p(e,n,t,o)}function p(r,n,t,o){var f=r.buffer,u=new Uint8Array(f,n,t);return i.getRandomValues(u),o?void e.nextTick(function(){o(null,r)}):r}function w(e,n,t){if(void 0===n&&(n=0),!(f.isBuffer(e)||e instanceof r.Uint8Array))throw new TypeError('"buf" argument must be a Buffer or Uint8Array');return s(n,e.length),void 0===t&&(t=e.length-n),m(t,n,e.length),p(e,n,t)}i&&i.getRandomValues?(exports.randomFill=l,exports.randomFillSync=w):(exports.randomFill=n,exports.randomFillSync=n); +},{"safe-buffer":"gIYa","randombytes":"pXr2","process":"g5IB"}],"WnIQ":[function(require,module,exports) { +"use strict";exports.randomBytes=exports.rng=exports.pseudoRandomBytes=exports.prng=require("randombytes"),exports.createHash=exports.Hash=require("create-hash"),exports.createHmac=exports.Hmac=require("create-hmac");var e=require("browserify-sign/algos"),r=Object.keys(e),t=["sha1","sha224","sha256","sha384","sha512","md5","rmd160"].concat(r);exports.getHashes=function(){return t};var i=require("pbkdf2");exports.pbkdf2=i.pbkdf2,exports.pbkdf2Sync=i.pbkdf2Sync;var p=require("browserify-cipher");exports.Cipher=p.Cipher,exports.createCipher=p.createCipher,exports.Cipheriv=p.Cipheriv,exports.createCipheriv=p.createCipheriv,exports.Decipher=p.Decipher,exports.createDecipher=p.createDecipher,exports.Decipheriv=p.Decipheriv,exports.createDecipheriv=p.createDecipheriv,exports.getCiphers=p.getCiphers,exports.listCiphers=p.listCiphers;var s=require("diffie-hellman");exports.DiffieHellmanGroup=s.DiffieHellmanGroup,exports.createDiffieHellmanGroup=s.createDiffieHellmanGroup,exports.getDiffieHellman=s.getDiffieHellman,exports.createDiffieHellman=s.createDiffieHellman,exports.DiffieHellman=s.DiffieHellman;var a=require("browserify-sign");exports.createSign=a.createSign,exports.Sign=a.Sign,exports.createVerify=a.createVerify,exports.Verify=a.Verify,exports.createECDH=require("create-ecdh");var o=require("public-encrypt");exports.publicEncrypt=o.publicEncrypt,exports.privateEncrypt=o.privateEncrypt,exports.publicDecrypt=o.publicDecrypt,exports.privateDecrypt=o.privateDecrypt;var c=require("randomfill");exports.randomFill=c.randomFill,exports.randomFillSync=c.randomFillSync,exports.createCredentials=function(){throw new Error(["sorry, createCredentials is not implemented yet","we accept pull requests","https://github.com/crypto-browserify/crypto-browserify"].join("\n"))},exports.constants={DH_CHECK_P_NOT_SAFE_PRIME:2,DH_CHECK_P_NOT_PRIME:1,DH_UNABLE_TO_CHECK_GENERATOR:4,DH_NOT_SUITABLE_GENERATOR:8,NPN_ENABLED:1,ALPN_ENABLED:1,RSA_PKCS1_PADDING:1,RSA_SSLV23_PADDING:2,RSA_NO_PADDING:3,RSA_PKCS1_OAEP_PADDING:4,RSA_X931_PADDING:5,RSA_PKCS1_PSS_PADDING:6,POINT_CONVERSION_COMPRESSED:2,POINT_CONVERSION_UNCOMPRESSED:4,POINT_CONVERSION_HYBRID:6}; +},{"randombytes":"pXr2","create-hash":"CBfM","create-hmac":"Jc9P","browserify-sign/algos":"Iwnn","pbkdf2":"WuSv","browserify-cipher":"po04","diffie-hellman":"Ej94","browserify-sign":"VebA","create-ecdh":"Qs7t","public-encrypt":"KGZW","randomfill":"EJeA"}],"LdGf":[function(require,module,exports) { +var define; +var global = arguments[3]; +var e,n=arguments[3];!function(n){"use strict";var t,r,i,o=/^-?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i,u=Math.ceil,s=Math.floor,f=" not a boolean or binary digit",l="rounding mode",c="number type has more than 15 significant digits",a="0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_",h=1e14,g=14,p=9007199254740991,m=[1,10,100,1e3,1e4,1e5,1e6,1e7,1e8,1e9,1e10,1e11,1e12,1e13],d=1e7,w=1e9;function v(e){var n=0|e;return e>0||e===n?n:n-1}function N(e){for(var n,t,r=1,i=e.length,o=e[0]+"";rl^t?1:-1;for(s=(f=i.length)<(l=o.length)?f:l,u=0;uo[u]^t?1:-1;return f==l?0:f>l^t?1:-1}function O(e,n,t){return(e=E(e))>=n&&e<=t}function y(e){return"[object Array]"==Object.prototype.toString.call(e)}function S(e,n,t){for(var r,i,o=[0],u=0,s=e.length;ut-1&&(null==o[r+1]&&(o[r+1]=0),o[r+1]+=o[r]/t|0,o[r]%=t)}return o.reverse()}function R(e,n){return(e.length>1?e.charAt(0)+"."+e.slice(1):e)+(n<0?"e":"e+")+n}function A(e,n){var t,r;if(n<0){for(r="0.";++n;r+="0");e=r+e}else if(++n>(t=e.length)){for(r="0",n-=t;--n;r+="0");e+=r}else n15&&ee(U,c,e),s=!1):h.s=45===l.charCodeAt(0)?(l=l.slice(1),-1):1,l=X(l,10,n,h.s)}else{if(e instanceof J)return h.s=e.s,h.e=e.e,h.c=(e=e.c)?e.slice():e,void(U=0);if((s="number"==typeof e)&&0*e==0){if(h.s=1/e<0?(e=-e,-1):1,e===~~e){for(r=0,u=e;u>=10;u/=10,r++);return h.e=r,h.c=[e],void(U=0)}l=e+""}else{if(!o.test(l=e+""))return i(h,l,s);h.s=45===l.charCodeAt(0)?(l=l.slice(1),-1):1}}for((r=l.indexOf("."))>-1&&(l=l.replace(".","")),(u=l.search(/e/i))>0?(r<0&&(r=u),r+=+l.slice(u+1),l=l.substring(0,u)):r<0&&(r=l.length),u=0;48===l.charCodeAt(u);u++);for(f=l.length;48===l.charCodeAt(--f););if(l=l.slice(u,f+1))if(f=l.length,s&&G&&f>15&&ee(U,c,h.s*e),(r=r-u-1)>$)h.c=h.e=null;else if(r=0&&(s=V,V=0,e=e.replace(".",""),l=(h=new J(r)).pow(e.length-g),V=s,h.c=S(A(N(l.c),l.e),10,n),h.e=h.c.length),u=s=(c=S(e,r,n)).length;0==c[--s];c.pop());if(!c[0])return"0";if(g<0?--u:(l.c=c,l.e=u,l.s=i,c=(l=t(l,h,p,m,n)).c,f=l.r,u=l.e),g=c[o=u+p+1],s=n/2,f=f||o<0||null!=c[o+1],f=m<4?(null!=g||f)&&(0==m||m==(l.s<0?3:2)):g>s||g==s&&(4==m||f||6==m&&1&c[o-1]||m==(l.s<0?8:7)),o<1||!c[0])e=f?A("1",-p):"0";else{if(c.length=o,f)for(--n;++c[--o]>n;)c[o]=0,o||(++u,c.unshift(1));for(s=c.length;!c[--s];);for(g=0,e="";g<=s;e+=a.charAt(c[g++]));e=A(e,u)}return e}function Y(e,n,t,r){var i,o,u,s,f;if(t=null!=t&&z(t,0,8,r,l)?0|t:q,!e.c)return e.toString();if(i=e.c[0],u=e.e,null==n)f=N(e.c),f=19==r||24==r&&u<=P?R(f,u):A(f,u);else if(o=(e=ne(new J(e),n,t)).e,s=(f=N(e.c)).length,19==r||24==r&&(n<=o||o<=P)){for(;ss){if(--n>0)for(f+=".";n--;f+="0");}else if((n+=o-s)>0)for(o+1==s&&(f+=".");n--;f+="0");return e.s<0&&i?"-"+f:f}function Z(e,n){var t,r,i=0;for(y(e[0])&&(e=e[0]),t=new J(e[0]);++it||e!=E(e))&&ee(r,(i||"decimal places")+(et?" out of range":" not an integer"),e),!0}function Q(e,n,t){for(var r=1,i=n.length;!n[--i];n.pop());for(i=n[0];i>=10;i/=10,r++);return(t=r+t*g-1)>$?e.c=e.e=null:t=10;l/=10,i++);if((o=n-i)<0)o+=g,f=n,p=(c=d[a=0])/w[i-f-1]%10|0;else if((a=u((o+1)/g))>=d.length){if(!r)break e;for(;d.length<=a;d.push(0));c=p=0,i=1,f=(o%=g)-g+1}else{for(c=l=d[a],i=1;l>=10;l/=10,i++);p=(f=(o%=g)-g+i)<0?0:c/w[i-f-1]%10|0}if(r=r||n<0||null!=d[a+1]||(f<0?c:c%w[i-f-1]),r=t<4?(p||r)&&(0==t||t==(e.s<0?3:2)):p>5||5==p&&(4==t||r||6==t&&(o>0?f>0?c/w[i-f]:0:d[a-1])%10&1||t==(e.s<0?8:7)),n<1||!d[0])return d.length=0,r?(n-=e.e+1,d[0]=w[n%g],e.e=-n||0):d[0]=e.e=0,e;if(0==o?(d.length=a,l=1,a--):(d.length=a+1,l=w[g-o],d[a]=f>0?s(c/w[i-f]%w[f])*l:0),r)for(;;){if(0==a){for(o=1,f=d[0];f>=10;f/=10,o++);for(f=d[0]+=l,l=1;f>=10;f/=10,l++);o!=l&&(e.e++,d[0]==h&&(d[0]=1));break}if(d[a]+=l,d[a]!=h)break;d[a--]=0,l=1}for(o=d.length;0===d[--o];d.pop());}e.e>$?e.c=e.e=null:e.et)return null!=(e=o[t++])};return s(n="DECIMAL_PLACES")&&z(e,0,w,2,n)&&(T=0|e),i[n]=T,s(n="ROUNDING_MODE")&&z(e,0,8,2,n)&&(q=0|e),i[n]=q,s(n="EXPONENTIAL_AT")&&(y(e)?z(e[0],-w,0,2,n)&&z(e[1],0,w,2,n)&&(P=0|e[0],k=0|e[1]):z(e,-w,w,2,n)&&(P=-(k=0|(e<0?-e:e)))),i[n]=[P,k],s(n="RANGE")&&(y(e)?z(e[0],-w,-1,2,n)&&z(e[1],1,w,2,n)&&(B=0|e[0],$=0|e[1]):z(e,-w,w,2,n)&&(0|e?B=-($=0|(e<0?-e:e)):G&&ee(2,n+" cannot be zero",e))),i[n]=[B,$],s(n="ERRORS")&&(e===!!e||1===e||0===e?(U=0,z=(G=!!e)?K:O):G&&ee(2,n+f,e)),i[n]=G,s(n="CRYPTO")&&(e===!!e||1===e||0===e?(j=!(!e||!r||"object"!=typeof r),e&&!j&&G&&ee(2,"crypto unavailable",r)):G&&ee(2,n+f,e)),i[n]=j,s(n="MODULO_MODE")&&z(e,0,9,2,n)&&(H=0|e),i[n]=H,s(n="POW_PRECISION")&&z(e,0,w,2,n)&&(V=0|e),i[n]=V,s(n="FORMAT")&&("object"==typeof e?W=e:G&&ee(2,n+" not an object",e)),i[n]=W,i},J.max=function(){return Z(arguments,C.lt)},J.min=function(){return Z(arguments,C.gt)},J.random=(D=9007199254740992*Math.random()&2097151?function(){return s(9007199254740992*Math.random())}:function(){return 8388608*(1073741824*Math.random()|0)+(8388608*Math.random()|0)},function(e){var n,t,i,o,f,l=0,c=[],a=new J(M);if(e=null!=e&&z(e,0,w,14)?0|e:T,o=u(e/g),j)if(r&&r.getRandomValues){for(n=r.getRandomValues(new Uint32Array(o*=2));l>>11))>=9e15?(t=r.getRandomValues(new Uint32Array(2)),n[l]=t[0],n[l+1]=t[1]):(c.push(f%1e14),l+=2);l=o/2}else if(r&&r.randomBytes){for(n=r.randomBytes(o*=7);l=9e15?r.randomBytes(7).copy(n,l):(c.push(f%1e14),l+=7);l=o/7}else G&&ee(14,"crypto unavailable",r);if(!l)for(;l=10;f/=10,l++);lr?1:-1;else for(i=o=0;in[i]?1:-1;break}return o}function t(e,n,t,r){for(var i=0;t--;)e[t]-=i,i=e[t]1;e.shift());}return function(r,i,o,u,f){var l,c,a,p,m,d,w,N,b,O,y,S,R,A,E,D,_,x=r.s==i.s?1:-1,F=r.c,I=i.c;if(!(F&&F[0]&&I&&I[0]))return new J(r.s&&i.s&&(F?!I||F[0]!=I[0]:I)?F&&0==F[0]||!I?0*x:x/0:NaN);for(b=(N=new J(x)).c=[],x=o+(c=r.e-i.e)+1,f||(f=h,c=v(r.e/g)-v(i.e/g),x=x/g|0),a=0;I[a]==(F[a]||0);a++);if(I[a]>(F[a]||0)&&c--,x<0)b.push(1),p=!0;else{for(A=F.length,D=I.length,a=0,x+=2,(m=s(f/(I[0]+1)))>1&&(I=e(I,m,f),F=e(F,m,f),D=I.length,A=F.length),R=D,y=(O=F.slice(0,D)).length;y=f/2&&E++;do{if(m=0,(l=n(I,O,D,y))<0){if(S=O[0],D!=y&&(S=S*f+(O[1]||0)),(m=s(S/E))>1)for(m>=f&&(m=f-1),w=(d=e(I,m,f)).length,y=O.length;1==n(d,O,w,y);)m--,t(d,D=10;x/=10,a++);ne(N,o+(N.e=a+c*g-1)+1,u,p)}else N.e=c,N.r=+p;return N}}(),_=/^(-?)0([xbo])/i,x=/^([^.]+)\.$/,F=/^\.([^.]+)$/,I=/^-?(Infinity|NaN)$/,L=/^\s*\+|^\s+|\s+$/g,i=function(e,n,t,r){var i,o=t?n:n.replace(L,"");if(I.test(o))e.s=isNaN(o)?null:o<0?-1:1;else{if(!t&&(o=o.replace(_,function(e,n,t){return i="x"==(t=t.toLowerCase())?16:"b"==t?2:8,r&&r!=i?e:n}),r&&(i=r,o=o.replace(x,"$1").replace(F,"0.$1")),n!=o))return new J(o,i);G&&ee(U,"not a"+(r?" base "+r:"")+" number",n),e.s=null}e.c=e.e=null,U=0},C.absoluteValue=C.abs=function(){var e=new J(this);return e.s<0&&(e.s=1),e},C.ceil=function(){return ne(new J(this),this.e+1,2)},C.comparedTo=C.cmp=function(e,n){return U=1,b(this,new J(e,n))},C.decimalPlaces=C.dp=function(){var e,n,t=this.c;if(!t)return null;if(e=((n=t.length-1)-v(this.e/g))*g,n=t[n])for(;n%10==0;n/=10,e--);return e<0&&(e=0),e},C.dividedBy=C.div=function(e,n){return U=3,t(this,new J(e,n),T,q)},C.dividedToIntegerBy=C.divToInt=function(e,n){return U=4,t(this,new J(e,n),0,1)},C.equals=C.eq=function(e,n){return U=5,0===b(this,new J(e,n))},C.floor=function(){return ne(new J(this),this.e+1,3)},C.greaterThan=C.gt=function(e,n){return U=6,b(this,new J(e,n))>0},C.greaterThanOrEqualTo=C.gte=function(e,n){return U=7,1===(n=b(this,new J(e,n)))||0===n},C.isFinite=function(){return!!this.c},C.isInteger=C.isInt=function(){return!!this.c&&v(this.e/g)>this.c.length-2},C.isNaN=function(){return!this.s},C.isNegative=C.isNeg=function(){return this.s<0},C.isZero=function(){return!!this.c&&0==this.c[0]},C.lessThan=C.lt=function(e,n){return U=8,b(this,new J(e,n))<0},C.lessThanOrEqualTo=C.lte=function(e,n){return U=9,-1===(n=b(this,new J(e,n)))||0===n},C.minus=C.sub=function(e,n){var t,r,i,o,u=this,s=u.s;if(U=10,n=(e=new J(e,n)).s,!s||!n)return new J(NaN);if(s!=n)return e.s=-n,u.plus(e);var f=u.e/g,l=e.e/g,c=u.c,a=e.c;if(!f||!l){if(!c||!a)return c?(e.s=-n,e):new J(a?u:NaN);if(!c[0]||!a[0])return a[0]?(e.s=-n,e):new J(c[0]?u:3==q?-0:0)}if(f=v(f),l=v(l),c=c.slice(),s=f-l){for((o=s<0)?(s=-s,i=c):(l=f,i=a),i.reverse(),n=s;n--;i.push(0));i.reverse()}else for(r=(o=(s=c.length)<(n=a.length))?s:n,s=n=0;n0)for(;n--;c[t++]=0);for(n=h-1;r>s;){if(c[--r]0?(u=o,t=f):(i=-i,t=s),t.reverse();i--;t.push(0));t.reverse()}for((i=s.length)-(n=f.length)<0&&(t=f,f=s,s=t,n=i),i=0;n;)i=(s[--n]=s[n]+f[n]+i)/h|0,s[n]%=h;return i&&(s.unshift(i),++u),Q(e,s,u)},C.precision=C.sd=function(e){var n,t,r=this,i=r.c;if(null!=e&&e!==!!e&&1!==e&&0!==e&&(G&&ee(13,"argument"+f,e),e!=!!e&&(e=null)),!i)return null;if(n=(t=i.length-1)*g+1,t=i[t]){for(;t%10==0;t/=10,n--);for(t=i[0];t>=10;t/=10,n++);}return e&&r.e+1>n&&(n=r.e+1),n},C.round=function(e,n){var t=new J(this);return(null==e||z(e,0,w,15))&&ne(t,~~e+this.e+1,null!=n&&z(n,0,8,15,l)?0|n:q),t},C.shift=function(e){var n=this;return z(e,-p,p,16,"argument")?n.times("1e"+E(e)):new J(n.c&&n.c[0]&&(e<-p||e>p)?n.s*(e<0?0:1/0):n)},C.squareRoot=C.sqrt=function(){var e,n,r,i,o,u=this,s=u.c,f=u.s,l=u.e,c=T+4,a=new J("0.5");if(1!==f||!s||!s[0])return new J(!f||f<0&&(!s||s[0])?NaN:s?u:1/0);if(0==(f=Math.sqrt(+u))||f==1/0?(((n=N(s)).length+l)%2==0&&(n+="0"),f=Math.sqrt(n),l=v((l+1)/2)-(l<0||l%2),r=new J(n=f==1/0?"1e"+l:(n=f.toExponential()).slice(0,n.indexOf("e")+1)+l)):r=new J(f+""),r.c[0])for((f=(l=r.e)+c)<3&&(f=0);;)if(o=r,r=a.times(o.plus(t(u,o,c,1))),N(o.c).slice(0,f)===(n=N(r.c)).slice(0,f)){if(r.e=0;){for(t=0,p=S[i]%b,m=S[i]/b|0,o=i+(u=f);o>i;)t=((l=p*(l=y[--u]%b)+(s=m*l+(c=y[u]/b|0)*p)%b*b+w[o]+t)/N|0)+(s/b|0)+m*c,w[o--]=l%N;w[o]=t}return t?++r:w.shift(),Q(e,w,r)},C.toDigits=function(e,n){var t=new J(this);return e=null!=e&&z(e,1,w,18,"precision")?0|e:null,n=null!=n&&z(n,0,8,18,l)?0|n:q,e?ne(t,e,n):t},C.toExponential=function(e,n){return Y(this,null!=e&&z(e,0,w,19)?1+~~e:null,n,19)},C.toFixed=function(e,n){return Y(this,null!=e&&z(e,0,w,20)?~~e+this.e+1:null,n,20)},C.toFormat=function(e,n){var t=Y(this,null!=e&&z(e,0,w,21)?~~e+this.e+1:null,n,21);if(this.c){var r,i=t.split("."),o=+W.groupSize,u=+W.secondaryGroupSize,s=W.groupSeparator,f=i[0],l=i[1],c=this.s<0,a=c?f.slice(1):f,h=a.length;if(u&&(r=o,o=u,u=r,h-=r),o>0&&h>0){for(r=h%o||o,f=a.substr(0,r);r0&&(f+=s+a.slice(r)),c&&(f="-"+f)}t=l?f+W.decimalSeparator+((u=+W.fractionGroupSize)?l.replace(new RegExp("\\d{"+u+"}\\B","g"),"$&"+W.fractionGroupSeparator):l):f}return t},C.toFraction=function(e){var n,r,i,o,u,s,f,l,c,a=G,h=this,p=h.c,d=new J(M),w=r=new J(M),v=f=new J(M);if(null!=e&&(G=!1,s=new J(e),G=a,(a=s.isInt())&&!s.lt(M)||(G&&ee(22,"max denominator "+(a?"out of range":"not an integer"),e),e=!a&&s.c&&ne(s,s.e+1,1).gte(M)?s:null)),!p)return h.toString();for(c=N(p),o=d.e=c.length-h.e-1,d.c[0]=m[(u=o%g)<0?g+u:u],e=!e||s.cmp(d)>0?o>0?d:w:s,u=$,$=1/0,s=new J(c),f.c[0]=0;l=t(s,d,0,1),1!=(i=r.plus(l.times(v))).cmp(e);)r=v,v=i,w=f.plus(l.times(i=w)),f=i,d=s.minus(l.times(i=d)),s=i;return i=t(e.minus(r),v,0,1),f=f.plus(i.times(w)),r=r.plus(i.times(v)),f.s=w.s=h.s,n=t(w,v,o*=2,q).minus(h).abs().cmp(t(f,r,o,q).minus(h).abs())<1?[w.toString(),v.toString()]:[f.toString(),r.toString()],$=u,n},C.toNumber=function(){var e=this;return+e||(e.s?0*e.s:NaN)},C.toPower=C.pow=function(e){var n,t,r=s(e<0?-e:+e),i=this;if(!z(e,-p,p,23,"exponent")&&(!isFinite(e)||r>p&&(e/=0)||parseFloat(e)!=e&&!(e=NaN)))return new J(Math.pow(+i,e));for(n=V?u(V/g+2):0,t=new J(M);;){if(r%2){if(!(t=t.times(i)).c)break;n&&t.c.length>n&&(t.c.length=n)}if(!(r=s(r/2)))break;i=i.times(i),n&&i.c&&i.c.length>n&&(i.c.length=n)}return e<0&&(t=M.div(t)),n?ne(t,V,q):t},C.toPrecision=function(e,n){return Y(this,null!=e&&z(e,1,w,24,"precision")?0|e:null,n,24)},C.toString=function(e){var n,t=this,r=t.s,i=t.e;return null===i?r?(n="Infinity",r<0&&(n="-"+n)):n="NaN":(n=N(t.c),n=null!=e&&z(e,2,64,25,"base")?X(A(n,i),0|e,10,r):i<=P||i>=k?R(n,i):A(n,i),r<0&&t.c[0]&&(n="-"+n)),n},C.truncated=C.trunc=function(){return ne(new J(this),this.e+1,1)},C.valueOf=C.toJSON=function(){return this.toString()},null!=n&&J.config(n),J}(),"function"==typeof e&&e.amd)e(function(){return t});else if("undefined"!=typeof module&&module.exports){if(module.exports=t,!r)try{r=require("crypto")}catch(D){}}else n.BigNumber=t}(this); +},{"crypto":"WnIQ"}],"eUTO":[function(require,module,exports) { +var define; +var t;!function(n,i){"object"==typeof exports?module.exports=exports=i():"function"==typeof t&&t.amd?t([],i):n.CryptoJS=i()}(this,function(){var t=t||function(t,n){var i=Object.create||function(){function t(){}return function(n){var i;return t.prototype=n,i=new t,t.prototype=null,i}}(),r={},e=r.lib={},o=e.Base={extend:function(t){var n=i(this);return t&&n.mixIn(t),n.hasOwnProperty("init")&&this.init!==n.init||(n.init=function(){n.$super.init.apply(this,arguments)}),n.init.prototype=n,n.$super=this,n},create:function(){var t=this.extend();return t.init.apply(t,arguments),t},init:function(){},mixIn:function(t){for(var n in t)t.hasOwnProperty(n)&&(this[n]=t[n]);t.hasOwnProperty("toString")&&(this.toString=t.toString)},clone:function(){return this.init.prototype.extend(this)}},s=e.WordArray=o.extend({init:function(t,n){t=this.words=t||[],this.sigBytes=null!=n?n:4*t.length},toString:function(t){return(t||c).stringify(this)},concat:function(t){var n=this.words,i=t.words,r=this.sigBytes,e=t.sigBytes;if(this.clamp(),r%4)for(var o=0;o>>2]>>>24-o%4*8&255;n[r+o>>>2]|=s<<24-(r+o)%4*8}else for(o=0;o>>2]=i[o>>>2];return this.sigBytes+=e,this},clamp:function(){var n=this.words,i=this.sigBytes;n[i>>>2]&=4294967295<<32-i%4*8,n.length=t.ceil(i/4)},clone:function(){var t=o.clone.call(this);return t.words=this.words.slice(0),t},random:function(n){for(var i,r=[],e=function(n){n=n;var i=987654321,r=4294967295;return function(){var e=((i=36969*(65535&i)+(i>>16)&r)<<16)+(n=18e3*(65535&n)+(n>>16)&r)&r;return e/=4294967296,(e+=.5)*(t.random()>.5?1:-1)}},o=0;o>>2]>>>24-e%4*8&255;r.push((o>>>4).toString(16)),r.push((15&o).toString(16))}return r.join("")},parse:function(t){for(var n=t.length,i=[],r=0;r>>3]|=parseInt(t.substr(r,2),16)<<24-r%8*4;return new s.init(i,n/2)}},u=a.Latin1={stringify:function(t){for(var n=t.words,i=t.sigBytes,r=[],e=0;e>>2]>>>24-e%4*8&255;r.push(String.fromCharCode(o))}return r.join("")},parse:function(t){for(var n=t.length,i=[],r=0;r>>2]|=(255&t.charCodeAt(r))<<24-r%4*8;return new s.init(i,n)}},f=a.Utf8={stringify:function(t){try{return decodeURIComponent(escape(u.stringify(t)))}catch(n){throw new Error("Malformed UTF-8 data")}},parse:function(t){return u.parse(unescape(encodeURIComponent(t)))}},h=e.BufferedBlockAlgorithm=o.extend({reset:function(){this._data=new s.init,this._nDataBytes=0},_append:function(t){"string"==typeof t&&(t=f.parse(t)),this._data.concat(t),this._nDataBytes+=t.sigBytes},_process:function(n){var i=this._data,r=i.words,e=i.sigBytes,o=this.blockSize,a=e/(4*o),c=(a=n?t.ceil(a):t.max((0|a)-this._minBufferSize,0))*o,u=t.min(4*c,e);if(c){for(var f=0;f>>2]|=r[a]<<24-a%4*8;t.call(this,e,n)}else t.apply(this,arguments)}).prototype=n}}(),r.lib.WordArray}); +},{"./core":"eUTO"}],"xZKj":[function(require,module,exports) { +var define; +var r;!function(t,n){"object"==typeof exports?module.exports=exports=n(require("./core")):"function"==typeof r&&r.amd?r(["./core"],n):n(t.CryptoJS)}(this,function(r){return function(){var t=r,n=t.lib.WordArray,o=t.enc;o.Utf16=o.Utf16BE={stringify:function(r){for(var t=r.words,n=r.sigBytes,o=[],e=0;e>>2]>>>16-e%4*8&65535;o.push(String.fromCharCode(f))}return o.join("")},parse:function(r){for(var t=r.length,o=[],e=0;e>>1]|=r.charCodeAt(e)<<16-e%2*16;return n.create(o,2*t)}};function e(r){return r<<8&4278255360|r>>>8&16711935}o.Utf16LE={stringify:function(r){for(var t=r.words,n=r.sigBytes,o=[],f=0;f>>2]>>>16-f%4*8&65535);o.push(String.fromCharCode(i))}return o.join("")},parse:function(r){for(var t=r.length,o=[],f=0;f>>1]|=e(r.charCodeAt(f)<<16-f%2*16);return n.create(o,2*t)}}}(),r.enc.Utf16}); +},{"./core":"eUTO"}],"pJaz":[function(require,module,exports) { +var define; +var r;!function(e,t){"object"==typeof exports?module.exports=exports=t(require("./core")):"function"==typeof r&&r.amd?r(["./core"],t):t(e.CryptoJS)}(this,function(r){return function(){var e=r,t=e.lib.WordArray;e.enc.Base64={stringify:function(r){var e=r.words,t=r.sigBytes,a=this._map;r.clamp();for(var o=[],n=0;n>>2]>>>24-n%4*8&255)<<16|(e[n+1>>>2]>>>24-(n+1)%4*8&255)<<8|e[n+2>>>2]>>>24-(n+2)%4*8&255,c=0;c<4&&n+.75*c>>6*(3-c)&63));var f=a.charAt(64);if(f)for(;o.length%4;)o.push(f);return o.join("")},parse:function(r){var e=r.length,a=this._map,o=this._reverseMap;if(!o){o=this._reverseMap=[];for(var n=0;n>>6-i%4*2;o[n>>>2]|=(c|f)<<24-n%4*8,n++}return t.create(o,n)}(r,e,o)},_map:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="}}(),r.enc.Base64}); +},{"./core":"eUTO"}],"GVDV":[function(require,module,exports) { +var define; +var r;!function(t,n){"object"==typeof exports?module.exports=exports=n(require("./core")):"function"==typeof r&&r.amd?r(["./core"],n):n(t.CryptoJS)}(this,function(r){return function(t){var n=r,e=n.lib,o=e.WordArray,a=e.Hasher,s=n.algo,i=[];!function(){for(var r=0;r<64;r++)i[r]=4294967296*t.abs(t.sin(r+1))|0}();var c=s.MD5=a.extend({_doReset:function(){this._hash=new o.init([1732584193,4023233417,2562383102,271733878])},_doProcessBlock:function(r,t){for(var n=0;n<16;n++){var e=t+n,o=r[e];r[e]=16711935&(o<<8|o>>>24)|4278255360&(o<<24|o>>>8)}var a=this._hash.words,s=r[t+0],c=r[t+1],l=r[t+2],_=r[t+3],d=r[t+4],p=r[t+5],y=r[t+6],D=r[t+7],H=r[t+8],M=r[t+9],g=r[t+10],m=r[t+11],w=r[t+12],x=r[t+13],B=r[t+14],b=r[t+15],j=a[0],k=a[1],q=a[2],z=a[3];j=h(j,k,q,z,s,7,i[0]),z=h(z,j,k,q,c,12,i[1]),q=h(q,z,j,k,l,17,i[2]),k=h(k,q,z,j,_,22,i[3]),j=h(j,k,q,z,d,7,i[4]),z=h(z,j,k,q,p,12,i[5]),q=h(q,z,j,k,y,17,i[6]),k=h(k,q,z,j,D,22,i[7]),j=h(j,k,q,z,H,7,i[8]),z=h(z,j,k,q,M,12,i[9]),q=h(q,z,j,k,g,17,i[10]),k=h(k,q,z,j,m,22,i[11]),j=h(j,k,q,z,w,7,i[12]),z=h(z,j,k,q,x,12,i[13]),q=h(q,z,j,k,B,17,i[14]),j=u(j,k=h(k,q,z,j,b,22,i[15]),q,z,c,5,i[16]),z=u(z,j,k,q,y,9,i[17]),q=u(q,z,j,k,m,14,i[18]),k=u(k,q,z,j,s,20,i[19]),j=u(j,k,q,z,p,5,i[20]),z=u(z,j,k,q,g,9,i[21]),q=u(q,z,j,k,b,14,i[22]),k=u(k,q,z,j,d,20,i[23]),j=u(j,k,q,z,M,5,i[24]),z=u(z,j,k,q,B,9,i[25]),q=u(q,z,j,k,_,14,i[26]),k=u(k,q,z,j,H,20,i[27]),j=u(j,k,q,z,x,5,i[28]),z=u(z,j,k,q,l,9,i[29]),q=u(q,z,j,k,D,14,i[30]),j=f(j,k=u(k,q,z,j,w,20,i[31]),q,z,p,4,i[32]),z=f(z,j,k,q,H,11,i[33]),q=f(q,z,j,k,m,16,i[34]),k=f(k,q,z,j,B,23,i[35]),j=f(j,k,q,z,c,4,i[36]),z=f(z,j,k,q,d,11,i[37]),q=f(q,z,j,k,D,16,i[38]),k=f(k,q,z,j,g,23,i[39]),j=f(j,k,q,z,x,4,i[40]),z=f(z,j,k,q,s,11,i[41]),q=f(q,z,j,k,_,16,i[42]),k=f(k,q,z,j,y,23,i[43]),j=f(j,k,q,z,M,4,i[44]),z=f(z,j,k,q,w,11,i[45]),q=f(q,z,j,k,b,16,i[46]),j=v(j,k=f(k,q,z,j,l,23,i[47]),q,z,s,6,i[48]),z=v(z,j,k,q,D,10,i[49]),q=v(q,z,j,k,B,15,i[50]),k=v(k,q,z,j,p,21,i[51]),j=v(j,k,q,z,w,6,i[52]),z=v(z,j,k,q,_,10,i[53]),q=v(q,z,j,k,g,15,i[54]),k=v(k,q,z,j,c,21,i[55]),j=v(j,k,q,z,H,6,i[56]),z=v(z,j,k,q,b,10,i[57]),q=v(q,z,j,k,y,15,i[58]),k=v(k,q,z,j,x,21,i[59]),j=v(j,k,q,z,d,6,i[60]),z=v(z,j,k,q,m,10,i[61]),q=v(q,z,j,k,l,15,i[62]),k=v(k,q,z,j,M,21,i[63]),a[0]=a[0]+j|0,a[1]=a[1]+k|0,a[2]=a[2]+q|0,a[3]=a[3]+z|0},_doFinalize:function(){var r=this._data,n=r.words,e=8*this._nDataBytes,o=8*r.sigBytes;n[o>>>5]|=128<<24-o%32;var a=t.floor(e/4294967296),s=e;n[15+(o+64>>>9<<4)]=16711935&(a<<8|a>>>24)|4278255360&(a<<24|a>>>8),n[14+(o+64>>>9<<4)]=16711935&(s<<8|s>>>24)|4278255360&(s<<24|s>>>8),r.sigBytes=4*(n.length+1),this._process();for(var i=this._hash,c=i.words,h=0;h<4;h++){var u=c[h];c[h]=16711935&(u<<8|u>>>24)|4278255360&(u<<24|u>>>8)}return i},clone:function(){var r=a.clone.call(this);return r._hash=this._hash.clone(),r}});function h(r,t,n,e,o,a,s){var i=r+(t&n|~t&e)+o+s;return(i<>>32-a)+t}function u(r,t,n,e,o,a,s){var i=r+(t&e|n&~e)+o+s;return(i<>>32-a)+t}function f(r,t,n,e,o,a,s){var i=r+(t^n^e)+o+s;return(i<>>32-a)+t}function v(r,t,n,e,o,a,s){var i=r+(n^(t|~e))+o+s;return(i<>>32-a)+t}n.MD5=a._createHelper(c),n.HmacMD5=a._createHmacHelper(c)}(Math),r.MD5}); +},{"./core":"eUTO"}],"yxyM":[function(require,module,exports) { +var define; +var e;!function(t,r){"object"==typeof exports?module.exports=exports=r(require("./core")):"function"==typeof e&&e.amd?e(["./core"],r):r(t.CryptoJS)}(this,function(e){var t,r,o,s,a,n,i;return r=(t=e).lib,o=r.WordArray,s=r.Hasher,a=t.algo,n=[],i=a.SHA1=s.extend({_doReset:function(){this._hash=new o.init([1732584193,4023233417,2562383102,271733878,3285377520])},_doProcessBlock:function(e,t){for(var r=this._hash.words,o=r[0],s=r[1],a=r[2],i=r[3],h=r[4],c=0;c<80;c++){if(c<16)n[c]=0|e[t+c];else{var l=n[c-3]^n[c-8]^n[c-14]^n[c-16];n[c]=l<<1|l>>>31}var _=(o<<5|o>>>27)+h+n[c];_+=c<20?1518500249+(s&a|~s&i):c<40?1859775393+(s^a^i):c<60?(s&a|s&i|a&i)-1894007588:(s^a^i)-899497514,h=i,i=a,a=s<<30|s>>>2,s=o,o=_}r[0]=r[0]+o|0,r[1]=r[1]+s|0,r[2]=r[2]+a|0,r[3]=r[3]+i|0,r[4]=r[4]+h|0},_doFinalize:function(){var e=this._data,t=e.words,r=8*this._nDataBytes,o=8*e.sigBytes;return t[o>>>5]|=128<<24-o%32,t[14+(o+64>>>9<<4)]=Math.floor(r/4294967296),t[15+(o+64>>>9<<4)]=r,e.sigBytes=4*t.length,this._process(),this._hash},clone:function(){var e=s.clone.call(this);return e._hash=this._hash.clone(),e}}),t.SHA1=s._createHelper(i),t.HmacSHA1=s._createHmacHelper(i),e.SHA1}); +},{"./core":"eUTO"}],"MS2N":[function(require,module,exports) { +var define; +var r;!function(t,e){"object"==typeof exports?module.exports=exports=e(require("./core")):"function"==typeof r&&r.amd?r(["./core"],e):e(t.CryptoJS)}(this,function(r){return function(t){var e=r,o=e.lib,n=o.WordArray,s=o.Hasher,i=e.algo,a=[],c=[];!function(){function r(r){for(var e=t.sqrt(r),o=2;o<=e;o++)if(!(r%o))return!1;return!0}function e(r){return 4294967296*(r-(0|r))|0}for(var o=2,n=0;n<64;)r(o)&&(n<8&&(a[n]=e(t.pow(o,.5))),c[n]=e(t.pow(o,1/3)),n++),o++}();var h=[],f=i.SHA256=s.extend({_doReset:function(){this._hash=new n.init(a.slice(0))},_doProcessBlock:function(r,t){for(var e=this._hash.words,o=e[0],n=e[1],s=e[2],i=e[3],a=e[4],f=e[5],u=e[6],l=e[7],_=0;_<64;_++){if(_<16)h[_]=0|r[t+_];else{var p=h[_-15],d=(p<<25|p>>>7)^(p<<14|p>>>18)^p>>>3,v=h[_-2],H=(v<<15|v>>>17)^(v<<13|v>>>19)^v>>>10;h[_]=d+h[_-7]+H+h[_-16]}var y=o&n^o&s^n&s,w=(o<<30|o>>>2)^(o<<19|o>>>13)^(o<<10|o>>>22),A=l+((a<<26|a>>>6)^(a<<21|a>>>11)^(a<<7|a>>>25))+(a&f^~a&u)+c[_]+h[_];l=u,u=f,f=a,a=i+A|0,i=s,s=n,n=o,o=A+(w+y)|0}e[0]=e[0]+o|0,e[1]=e[1]+n|0,e[2]=e[2]+s|0,e[3]=e[3]+i|0,e[4]=e[4]+a|0,e[5]=e[5]+f|0,e[6]=e[6]+u|0,e[7]=e[7]+l|0},_doFinalize:function(){var r=this._data,e=r.words,o=8*this._nDataBytes,n=8*r.sigBytes;return e[n>>>5]|=128<<24-n%32,e[14+(n+64>>>9<<4)]=t.floor(o/4294967296),e[15+(n+64>>>9<<4)]=o,r.sigBytes=4*e.length,this._process(),this._hash},clone:function(){var r=s.clone.call(this);return r._hash=this._hash.clone(),r}});e.SHA256=s._createHelper(f),e.HmacSHA256=s._createHmacHelper(f)}(Math),r.SHA256}); +},{"./core":"eUTO"}],"OEnX":[function(require,module,exports) { +var define; +var e;!function(r,t,o){"object"==typeof exports?module.exports=exports=t(require("./core"),require("./sha256")):"function"==typeof e&&e.amd?e(["./core","./sha256"],t):t(r.CryptoJS)}(this,function(e){var r,t,o,i,n;return t=(r=e).lib.WordArray,o=r.algo,i=o.SHA256,n=o.SHA224=i.extend({_doReset:function(){this._hash=new t.init([3238371032,914150663,812702999,4144912697,4290775857,1750603025,1694076839,3204075428])},_doFinalize:function(){var e=i._doFinalize.call(this);return e.sigBytes-=4,e}}),r.SHA224=i._createHelper(n),r.HmacSHA224=i._createHmacHelper(n),e.SHA224}); +},{"./core":"eUTO","./sha256":"MS2N"}],"xA62":[function(require,module,exports) { +var define; +var i;!function(h,o,e){"object"==typeof exports?module.exports=exports=o(require("./core"),require("./x64-core")):"function"==typeof i&&i.amd?i(["./core","./x64-core"],o):o(h.CryptoJS)}(this,function(i){return function(){var h=i,o=h.lib.Hasher,e=h.x64,t=e.Word,n=e.WordArray,r=h.algo;function l(){return t.create.apply(t,arguments)}var a=[l(1116352408,3609767458),l(1899447441,602891725),l(3049323471,3964484399),l(3921009573,2173295548),l(961987163,4081628472),l(1508970993,3053834265),l(2453635748,2937671579),l(2870763221,3664609560),l(3624381080,2734883394),l(310598401,1164996542),l(607225278,1323610764),l(1426881987,3590304994),l(1925078388,4068182383),l(2162078206,991336113),l(2614888103,633803317),l(3248222580,3479774868),l(3835390401,2666613458),l(4022224774,944711139),l(264347078,2341262773),l(604807628,2007800933),l(770255983,1495990901),l(1249150122,1856431235),l(1555081692,3175218132),l(1996064986,2198950837),l(2554220882,3999719339),l(2821834349,766784016),l(2952996808,2566594879),l(3210313671,3203337956),l(3336571891,1034457026),l(3584528711,2466948901),l(113926993,3758326383),l(338241895,168717936),l(666307205,1188179964),l(773529912,1546045734),l(1294757372,1522805485),l(1396182291,2643833823),l(1695183700,2343527390),l(1986661051,1014477480),l(2177026350,1206759142),l(2456956037,344077627),l(2730485921,1290863460),l(2820302411,3158454273),l(3259730800,3505952657),l(3345764771,106217008),l(3516065817,3606008344),l(3600352804,1432725776),l(4094571909,1467031594),l(275423344,851169720),l(430227734,3100823752),l(506948616,1363258195),l(659060556,3750685593),l(883997877,3785050280),l(958139571,3318307427),l(1322822218,3812723403),l(1537002063,2003034995),l(1747873779,3602036899),l(1955562222,1575990012),l(2024104815,1125592928),l(2227730452,2716904306),l(2361852424,442776044),l(2428436474,593698344),l(2756734187,3733110249),l(3204031479,2999351573),l(3329325298,3815920427),l(3391569614,3928383900),l(3515267271,566280711),l(3940187606,3454069534),l(4118630271,4000239992),l(116418474,1914138554),l(174292421,2731055270),l(289380356,3203993006),l(460393269,320620315),l(685471733,587496836),l(852142971,1086792851),l(1017036298,365543100),l(1126000580,2618297676),l(1288033470,3409855158),l(1501505948,4234509866),l(1607167915,987167468),l(1816402316,1246189591)],w=[];!function(){for(var i=0;i<80;i++)w[i]=l()}();var s=r.SHA512=o.extend({_doReset:function(){this._hash=new n.init([new t.init(1779033703,4089235720),new t.init(3144134277,2227873595),new t.init(1013904242,4271175723),new t.init(2773480762,1595750129),new t.init(1359893119,2917565137),new t.init(2600822924,725511199),new t.init(528734635,4215389547),new t.init(1541459225,327033209)])},_doProcessBlock:function(i,h){for(var o=this._hash.words,e=o[0],t=o[1],n=o[2],r=o[3],l=o[4],s=o[5],c=o[6],g=o[7],u=e.high,f=e.low,_=t.high,v=t.low,d=n.high,p=n.low,H=r.high,y=r.low,x=l.high,S=l.low,A=s.high,m=s.low,B=c.high,b=c.low,k=g.high,q=g.low,z=u,W=f,j=_,C=v,D=d,F=p,J=H,M=y,P=x,R=S,X=A,E=m,G=B,I=b,K=k,L=q,N=0;N<80;N++){var O=w[N];if(N<16)var Q=O.high=0|i[h+2*N],T=O.low=0|i[h+2*N+1];else{var U=w[N-15],V=U.high,Y=U.low,Z=(V>>>1|Y<<31)^(V>>>8|Y<<24)^V>>>7,$=(Y>>>1|V<<31)^(Y>>>8|V<<24)^(Y>>>7|V<<25),ii=w[N-2],hi=ii.high,oi=ii.low,ei=(hi>>>19|oi<<13)^(hi<<3|oi>>>29)^hi>>>6,ti=(oi>>>19|hi<<13)^(oi<<3|hi>>>29)^(oi>>>6|hi<<26),ni=w[N-7],ri=ni.high,li=ni.low,ai=w[N-16],wi=ai.high,si=ai.low;Q=(Q=(Q=Z+ri+((T=$+li)>>>0<$>>>0?1:0))+ei+((T=T+ti)>>>0>>0?1:0))+wi+((T=T+si)>>>0>>0?1:0);O.high=Q,O.low=T}var ci,gi=P&X^~P&G,ui=R&E^~R&I,fi=z&j^z&D^j&D,_i=W&C^W&F^C&F,vi=(z>>>28|W<<4)^(z<<30|W>>>2)^(z<<25|W>>>7),di=(W>>>28|z<<4)^(W<<30|z>>>2)^(W<<25|z>>>7),pi=(P>>>14|R<<18)^(P>>>18|R<<14)^(P<<23|R>>>9),Hi=(R>>>14|P<<18)^(R>>>18|P<<14)^(R<<23|P>>>9),yi=a[N],xi=yi.high,Si=yi.low,Ai=K+pi+((ci=L+Hi)>>>0>>0?1:0),mi=di+_i;K=G,L=I,G=X,I=E,X=P,E=R,P=J+(Ai=(Ai=(Ai=Ai+gi+((ci=ci+ui)>>>0>>0?1:0))+xi+((ci=ci+Si)>>>0>>0?1:0))+Q+((ci=ci+T)>>>0>>0?1:0))+((R=M+ci|0)>>>0>>0?1:0)|0,J=D,M=F,D=j,F=C,j=z,C=W,z=Ai+(vi+fi+(mi>>>0>>0?1:0))+((W=ci+mi|0)>>>0>>0?1:0)|0}f=e.low=f+W,e.high=u+z+(f>>>0>>0?1:0),v=t.low=v+C,t.high=_+j+(v>>>0>>0?1:0),p=n.low=p+F,n.high=d+D+(p>>>0>>0?1:0),y=r.low=y+M,r.high=H+J+(y>>>0>>0?1:0),S=l.low=S+R,l.high=x+P+(S>>>0>>0?1:0),m=s.low=m+E,s.high=A+X+(m>>>0>>0?1:0),b=c.low=b+I,c.high=B+G+(b>>>0>>0?1:0),q=g.low=q+L,g.high=k+K+(q>>>0>>0?1:0)},_doFinalize:function(){var i=this._data,h=i.words,o=8*this._nDataBytes,e=8*i.sigBytes;return h[e>>>5]|=128<<24-e%32,h[30+(e+128>>>10<<5)]=Math.floor(o/4294967296),h[31+(e+128>>>10<<5)]=o,i.sigBytes=4*h.length,this._process(),this._hash.toX32()},clone:function(){var i=o.clone.call(this);return i._hash=this._hash.clone(),i},blockSize:32});h.SHA512=o._createHelper(s),h.HmacSHA512=o._createHmacHelper(s)}(),i.SHA512}); +},{"./core":"eUTO","./x64-core":"M95N"}],"YkB8":[function(require,module,exports) { +var define; +var e;!function(i,n,t){"object"==typeof exports?module.exports=exports=n(require("./core"),require("./x64-core"),require("./sha512")):"function"==typeof e&&e.amd?e(["./core","./x64-core","./sha512"],n):n(i.CryptoJS)}(this,function(e){var i,n,t,r,o,a,c;return n=(i=e).x64,t=n.Word,r=n.WordArray,o=i.algo,a=o.SHA512,c=o.SHA384=a.extend({_doReset:function(){this._hash=new r.init([new t.init(3418070365,3238371032),new t.init(1654270250,914150663),new t.init(2438529370,812702999),new t.init(355462360,4144912697),new t.init(1731405415,4290775857),new t.init(2394180231,1750603025),new t.init(3675008525,1694076839),new t.init(1203062813,3204075428)])},_doFinalize:function(){var e=a._doFinalize.call(this);return e.sigBytes-=16,e}}),i.SHA384=a._createHelper(c),i.HmacSHA384=a._createHmacHelper(c),e.SHA384}); +},{"./core":"eUTO","./x64-core":"M95N","./sha512":"xA62"}],"F6e3":[function(require,module,exports) { +var define; +var r;!function(o,t,e){"object"==typeof exports?module.exports=exports=t(require("./core"),require("./x64-core")):"function"==typeof r&&r.amd?r(["./core","./x64-core"],t):t(o.CryptoJS)}(this,function(r){return function(o){var t=r,e=t.lib,i=e.WordArray,h=e.Hasher,a=t.x64.Word,n=t.algo,s=[],c=[],f=[];!function(){for(var r=1,o=0,t=0;t<24;t++){s[r+5*o]=(t+1)*(t+2)/2%64;var e=(2*r+3*o)%5;r=o%5,o=e}for(r=0;r<5;r++)for(o=0;o<5;o++)c[r+5*o]=o+(2*r+3*o)%5*5;for(var i=1,h=0;h<24;h++){for(var n=0,l=0,g=0;g<7;g++){if(1&i){var v=(1<>>24)|4278255360&(h<<24|h>>>8),a=16711935&(a<<8|a>>>24)|4278255360&(a<<24|a>>>8),(B=t[i]).high^=a,B.low^=h}for(var n=0;n<24;n++){for(var g=0;g<5;g++){for(var v=0,u=0,w=0;w<5;w++){v^=(B=t[g+5*w]).high,u^=B.low}var p=l[g];p.high=v,p.low=u}for(g=0;g<5;g++){var _=l[(g+4)%5],d=l[(g+1)%5],H=d.high,x=d.low;for(v=_.high^(H<<1|x>>>31),u=_.low^(x<<1|H>>>31),w=0;w<5;w++){(B=t[g+5*w]).high^=v,B.low^=u}}for(var S=1;S<25;S++){var y=(B=t[S]).high,b=B.low,A=s[S];if(A<32)v=y<>>32-A,u=b<>>32-A;else v=b<>>64-A,u=y<>>64-A;var k=l[c[S]];k.high=v,k.low=u}var m=l[0],z=t[0];m.high=z.high,m.low=z.low;for(g=0;g<5;g++)for(w=0;w<5;w++){var B=t[S=g+5*w],L=l[S],q=l[(g+1)%5+5*w],W=l[(g+2)%5+5*w];B.high=L.high^~q.high&W.high,B.low=L.low^~q.low&W.low}B=t[0];var j=f[n];B.high^=j.high,B.low^=j.low}},_doFinalize:function(){var r=this._data,t=r.words,e=(this._nDataBytes,8*r.sigBytes),h=32*this.blockSize;t[e>>>5]|=1<<24-e%32,t[(o.ceil((e+1)/h)*h>>>5)-1]|=128,r.sigBytes=4*t.length,this._process();for(var a=this._state,n=this.cfg.outputLength/8,s=n/8,c=[],f=0;f>>24)|4278255360&(g<<24|g>>>8),v=16711935&(v<<8|v>>>24)|4278255360&(v<<24|v>>>8),c.push(v),c.push(g)}return new i.init(c,n)},clone:function(){for(var r=h.clone.call(this),o=r._state=this._state.slice(0),t=0;t<25;t++)o[t]=o[t].clone();return r}});t.SHA3=h._createHelper(g),t.HmacSHA3=h._createHmacHelper(g)}(Math),r.SHA3}); +},{"./core":"eUTO","./x64-core":"M95N"}],"Y8cR":[function(require,module,exports) { +var define; +var r;!function(e,t){"object"==typeof exports?module.exports=exports=t(require("./core")):"function"==typeof r&&r.amd?r(["./core"],t):t(e.CryptoJS)}(this,function(r){return function(e){var t=r,o=t.lib,n=o.WordArray,s=o.Hasher,a=t.algo,c=n.create([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13]),i=n.create([5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11]),u=n.create([11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6]),h=n.create([8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11]),f=n.create([0,1518500249,1859775393,2400959708,2840853838]),d=n.create([1352829926,1548603684,1836072691,2053994217,0]),l=a.RIPEMD160=s.extend({_doReset:function(){this._hash=n.create([1732584193,4023233417,2562383102,271733878,3285377520])},_doProcessBlock:function(r,e){for(var t=0;t<16;t++){var o=e+t,n=r[o];r[o]=16711935&(n<<8|n>>>24)|4278255360&(n<<24|n>>>8)}var s,a,l,H,M,P,R,g,m,x,B,E=this._hash.words,I=f.words,b=d.words,j=c.words,k=i.words,q=u.words,z=h.words;P=s=E[0],R=a=E[1],g=l=E[2],m=H=E[3],x=M=E[4];for(t=0;t<80;t+=1)B=s+r[e+j[t]]|0,B+=t<16?_(a,l,H)+I[0]:t<32?p(a,l,H)+I[1]:t<48?v(a,l,H)+I[2]:t<64?w(a,l,H)+I[3]:y(a,l,H)+I[4],B=(B=D(B|=0,q[t]))+M|0,s=M,M=H,H=D(l,10),l=a,a=B,B=P+r[e+k[t]]|0,B+=t<16?y(R,g,m)+b[0]:t<32?w(R,g,m)+b[1]:t<48?v(R,g,m)+b[2]:t<64?p(R,g,m)+b[3]:_(R,g,m)+b[4],B=(B=D(B|=0,z[t]))+x|0,P=x,x=m,m=D(g,10),g=R,R=B;B=E[1]+l+m|0,E[1]=E[2]+H+x|0,E[2]=E[3]+M+P|0,E[3]=E[4]+s+R|0,E[4]=E[0]+a+g|0,E[0]=B},_doFinalize:function(){var r=this._data,e=r.words,t=8*this._nDataBytes,o=8*r.sigBytes;e[o>>>5]|=128<<24-o%32,e[14+(o+64>>>9<<4)]=16711935&(t<<8|t>>>24)|4278255360&(t<<24|t>>>8),r.sigBytes=4*(e.length+1),this._process();for(var n=this._hash,s=n.words,a=0;a<5;a++){var c=s[a];s[a]=16711935&(c<<8|c>>>24)|4278255360&(c<<24|c>>>8)}return n},clone:function(){var r=s.clone.call(this);return r._hash=this._hash.clone(),r}});function _(r,e,t){return r^e^t}function p(r,e,t){return r&e|~r&t}function v(r,e,t){return(r|~e)^t}function w(r,e,t){return r&t|e&~t}function y(r,e,t){return r^(e|~t)}function D(r,e){return r<>>32-e}t.RIPEMD160=s._createHelper(l),t.HmacRIPEMD160=s._createHmacHelper(l)}(Math),r.RIPEMD160}); +},{"./core":"eUTO"}],"IKo8":[function(require,module,exports) { +var define; +var e;!function(t,i){"object"==typeof exports?module.exports=exports=i(require("./core")):"function"==typeof e&&e.amd?e(["./core"],i):i(t.CryptoJS)}(this,function(e){var t,i,s;i=(t=e).lib.Base,s=t.enc.Utf8,t.algo.HMAC=i.extend({init:function(e,t){e=this._hasher=new e.init,"string"==typeof t&&(t=s.parse(t));var i=e.blockSize,r=4*i;t.sigBytes>r&&(t=e.finalize(t)),t.clamp();for(var n=this._oKey=t.clone(),o=this._iKey=t.clone(),a=n.words,h=o.words,c=0;c>>2];e.sigBytes-=t}},_=(i.BlockCipher=p.extend({cfg:p.cfg.extend({mode:u,padding:l}),reset:function(){p.reset.call(this);var e=this.cfg,t=e.iv,r=e.mode;if(this._xformMode==this._ENC_XFORM_MODE)var i=r.createEncryptor;else{i=r.createDecryptor;this._minBufferSize=1}this._mode&&this._mode.__creator==i?this._mode.init(this,t&&t.words):(this._mode=i.call(r,this,t&&t.words),this._mode.__creator=i)},_doProcessBlock:function(e,t){this._mode.processBlock(e,t)},_doFinalize:function(){var e=this.cfg.padding;if(this._xformMode==this._ENC_XFORM_MODE){e.pad(this._data,this.blockSize);var t=this._process(!0)}else{t=this._process(!0);e.unpad(t)}return t},blockSize:4}),i.CipherParams=n.extend({init:function(e){this.mixIn(e)},toString:function(e){return(e||this.formatter).stringify(this)}})),y=(r.format={}).OpenSSL={stringify:function(e){var t=e.ciphertext,r=e.salt;if(r)var i=c.create([1398893684,1701076831]).concat(r).concat(t);else i=t;return i.toString(a)},parse:function(e){var t=a.parse(e),r=t.words;if(1398893684==r[0]&&1701076831==r[1]){var i=c.create(r.slice(2,4));r.splice(0,4),t.sigBytes-=16}return _.create({ciphertext:t,salt:i})}},v=i.SerializableCipher=n.extend({cfg:n.extend({format:y}),encrypt:function(e,t,r,i){i=this.cfg.extend(i);var n=e.createEncryptor(r,i),c=n.finalize(t),o=n.cfg;return _.create({ciphertext:c,key:r,iv:o.iv,algorithm:e,mode:o.mode,padding:o.padding,blockSize:e.blockSize,formatter:i.format})},decrypt:function(e,t,r,i){return i=this.cfg.extend(i),t=this._parse(t,i.format),e.createDecryptor(r,i).finalize(t.ciphertext)},_parse:function(e,t){return"string"==typeof e?t.parse(e,this):e}}),k=(r.kdf={}).OpenSSL={execute:function(e,t,r,i){i||(i=c.random(8));var n=f.create({keySize:t+r}).compute(e,i),o=c.create(n.words.slice(t),4*r);return n.sigBytes=4*t,_.create({key:n,iv:o,salt:i})}},x=i.PasswordBasedCipher=v.extend({cfg:v.cfg.extend({kdf:k}),encrypt:function(e,t,r,i){var n=(i=this.cfg.extend(i)).kdf.execute(r,e.keySize,e.ivSize);i.iv=n.iv;var c=v.encrypt.call(this,e,t,n.key,i);return c.mixIn(n),c},decrypt:function(e,t,r,i){i=this.cfg.extend(i),t=this._parse(t,i.format);var n=i.kdf.execute(r,e.keySize,e.ivSize,t.salt);return i.iv=n.iv,v.decrypt.call(this,e,t,n.key,i)}})}()}); +},{"./core":"eUTO","./evpkdf":"W9aa"}],"dnNm":[function(require,module,exports) { +var define; +var e;!function(r,o,c){"object"==typeof exports?module.exports=exports=o(require("./core"),require("./cipher-core")):"function"==typeof e&&e.amd?e(["./core","./cipher-core"],o):o(r.CryptoJS)}(this,function(e){return e.mode.CFB=function(){var r=e.lib.BlockCipherMode.extend();function o(e,r,o,c){var i=this._iv;if(i){var t=i.slice(0);this._iv=void 0}else t=this._prevBlock;c.encryptBlock(t,0);for(var n=0;n>24&255)){var r=e>>16&255,o=e>>8&255,t=255&e;255===r?(r=0,255===o?(o=0,255===t?t=0:++t):++o):++r,e=0,e+=r<<16,e+=o<<8,e+=t}else e+=1<<24;return e}var t=r.Encryptor=r.extend({processBlock:function(e,r){var t=this._cipher,c=t.blockSize,i=this._iv,n=this._counter;i&&(n=this._counter=i.slice(0),this._iv=void 0),function(e){0===(e[0]=o(e[0]))&&(e[1]=o(e[1]))}(n);var u=n.slice(0);t.encryptBlock(u,0);for(var p=0;p>>2]|=i<<24-s%4*8,e.sigBytes+=i},unpad:function(e){var r=255&e.words[e.sigBytes-1>>>2];e.sigBytes-=r}},e.pad.Ansix923}); +},{"./core":"eUTO","./cipher-core":"uCLB"}],"HttL":[function(require,module,exports) { +var define; +var r;!function(o,e,t){"object"==typeof exports?module.exports=exports=e(require("./core"),require("./cipher-core")):"function"==typeof r&&r.amd?r(["./core","./cipher-core"],e):e(o.CryptoJS)}(this,function(r){return r.pad.Iso10126={pad:function(o,e){var t=4*e,c=t-o.sigBytes%t;o.concat(r.lib.WordArray.random(c-1)).concat(r.lib.WordArray.create([c<<24],1))},unpad:function(r){var o=255&r.words[r.sigBytes-1>>>2];r.sigBytes-=o}},r.pad.Iso10126}); +},{"./core":"eUTO","./cipher-core":"uCLB"}],"letQ":[function(require,module,exports) { +var define; +var e;!function(o,r,t){"object"==typeof exports?module.exports=exports=r(require("./core"),require("./cipher-core")):"function"==typeof e&&e.amd?e(["./core","./cipher-core"],r):r(o.CryptoJS)}(this,function(e){return e.pad.Iso97971={pad:function(o,r){o.concat(e.lib.WordArray.create([2147483648],1)),e.pad.ZeroPadding.pad(o,r)},unpad:function(o){e.pad.ZeroPadding.unpad(o),o.sigBytes--}},e.pad.Iso97971}); +},{"./core":"eUTO","./cipher-core":"uCLB"}],"aieV":[function(require,module,exports) { +var define; +var e;!function(r,o,t){"object"==typeof exports?module.exports=exports=o(require("./core"),require("./cipher-core")):"function"==typeof e&&e.amd?e(["./core","./cipher-core"],o):o(r.CryptoJS)}(this,function(e){return e.pad.ZeroPadding={pad:function(e,r){var o=4*r;e.clamp(),e.sigBytes+=o-(e.sigBytes%o||o)},unpad:function(e){for(var r=e.words,o=e.sigBytes-1;!(r[o>>>2]>>>24-o%4*8&255);)o--;e.sigBytes=o+1}},e.pad.ZeroPadding}); +},{"./core":"eUTO","./cipher-core":"uCLB"}],"GO8Y":[function(require,module,exports) { +var define; +var o;!function(e,r,n){"object"==typeof exports?module.exports=exports=r(require("./core"),require("./cipher-core")):"function"==typeof o&&o.amd?o(["./core","./cipher-core"],r):r(e.CryptoJS)}(this,function(o){return o.pad.NoPadding={pad:function(){},unpad:function(){}},o.pad.NoPadding}); +},{"./core":"eUTO","./cipher-core":"uCLB"}],"vtW7":[function(require,module,exports) { +var define; +var r;!function(e,t,o){"object"==typeof exports?module.exports=exports=t(require("./core"),require("./cipher-core")):"function"==typeof r&&r.amd?r(["./core","./cipher-core"],t):t(e.CryptoJS)}(this,function(r){var e,t,o;return t=(e=r).lib.CipherParams,o=e.enc.Hex,e.format.Hex={stringify:function(r){return r.ciphertext.toString(o)},parse:function(r){var e=o.parse(r);return t.create({ciphertext:e})}},r.format.Hex}); +},{"./core":"eUTO","./cipher-core":"uCLB"}],"Srb3":[function(require,module,exports) { +var define; +var e;!function(r,o,t){"object"==typeof exports?module.exports=exports=o(require("./core"),require("./enc-base64"),require("./md5"),require("./evpkdf"),require("./cipher-core")):"function"==typeof e&&e.amd?e(["./core","./enc-base64","./md5","./evpkdf","./cipher-core"],o):o(r.CryptoJS)}(this,function(e){return function(){var r=e,o=r.lib.BlockCipher,t=r.algo,i=[],c=[],n=[],s=[],u=[],f=[],h=[],d=[],a=[],y=[];!function(){for(var e=[],r=0;r<256;r++)e[r]=r<128?r<<1:r<<1^283;var o=0,t=0;for(r=0;r<256;r++){var p=t^t<<1^t<<2^t<<3^t<<4;p=p>>>8^255&p^99,i[o]=p,c[p]=o;var v=e[o],l=e[v],_=e[l],k=257*e[p]^16843008*p;n[o]=k<<24|k>>>8,s[o]=k<<16|k>>>16,u[o]=k<<8|k>>>24,f[o]=k;k=16843009*_^65537*l^257*v^16843008*o;h[p]=k<<24|k>>>8,d[p]=k<<16|k>>>16,a[p]=k<<8|k>>>24,y[p]=k,o?(o=v^e[e[e[_^v]]],t^=e[e[t]]):o=t=1}}();var p=[0,1,2,4,8,16,32,64,128,27,54],v=t.AES=o.extend({_doReset:function(){if(!this._nRounds||this._keyPriorReset!==this._key){for(var e=this._keyPriorReset=this._key,r=e.words,o=e.sigBytes/4,t=4*((this._nRounds=o+6)+1),c=this._keySchedule=[],n=0;n6&&n%o==4&&(s=i[s>>>24]<<24|i[s>>>16&255]<<16|i[s>>>8&255]<<8|i[255&s]):(s=i[(s=s<<8|s>>>24)>>>24]<<24|i[s>>>16&255]<<16|i[s>>>8&255]<<8|i[255&s],s^=p[n/o|0]<<24),c[n]=c[n-o]^s}for(var u=this._invKeySchedule=[],f=0;f>>24]]^d[i[s>>>16&255]]^a[i[s>>>8&255]]^y[i[255&s]]}}},encryptBlock:function(e,r){this._doCryptBlock(e,r,this._keySchedule,n,s,u,f,i)},decryptBlock:function(e,r){var o=e[r+1];e[r+1]=e[r+3],e[r+3]=o,this._doCryptBlock(e,r,this._invKeySchedule,h,d,a,y,c);o=e[r+1];e[r+1]=e[r+3],e[r+3]=o},_doCryptBlock:function(e,r,o,t,i,c,n,s){for(var u=this._nRounds,f=e[r]^o[0],h=e[r+1]^o[1],d=e[r+2]^o[2],a=e[r+3]^o[3],y=4,p=1;p>>24]^i[h>>>16&255]^c[d>>>8&255]^n[255&a]^o[y++],l=t[h>>>24]^i[d>>>16&255]^c[a>>>8&255]^n[255&f]^o[y++],_=t[d>>>24]^i[a>>>16&255]^c[f>>>8&255]^n[255&h]^o[y++],k=t[a>>>24]^i[f>>>16&255]^c[h>>>8&255]^n[255&d]^o[y++];f=v,h=l,d=_,a=k}v=(s[f>>>24]<<24|s[h>>>16&255]<<16|s[d>>>8&255]<<8|s[255&a])^o[y++],l=(s[h>>>24]<<24|s[d>>>16&255]<<16|s[a>>>8&255]<<8|s[255&f])^o[y++],_=(s[d>>>24]<<24|s[a>>>16&255]<<16|s[f>>>8&255]<<8|s[255&h])^o[y++],k=(s[a>>>24]<<24|s[f>>>16&255]<<16|s[h>>>8&255]<<8|s[255&d])^o[y++];e[r]=v,e[r+1]=l,e[r+2]=_,e[r+3]=k},keySize:8});r.AES=o._createHelper(v)}(),e.AES}); +},{"./core":"eUTO","./enc-base64":"pJaz","./md5":"GVDV","./evpkdf":"W9aa","./cipher-core":"uCLB"}],"ySCI":[function(require,module,exports) { +var define; +var e;!function(t,c,r){"object"==typeof exports?module.exports=exports=c(require("./core"),require("./enc-base64"),require("./md5"),require("./evpkdf"),require("./cipher-core")):"function"==typeof e&&e.amd?e(["./core","./enc-base64","./md5","./evpkdf","./cipher-core"],c):c(t.CryptoJS)}(this,function(e){return function(){var t=e,c=t.lib,r=c.WordArray,i=c.BlockCipher,o=t.algo,l=[57,49,41,33,25,17,9,1,58,50,42,34,26,18,10,2,59,51,43,35,27,19,11,3,60,52,44,36,63,55,47,39,31,23,15,7,62,54,46,38,30,22,14,6,61,53,45,37,29,21,13,5,28,20,12,4],s=[14,17,11,24,1,5,3,28,15,6,21,10,23,19,12,4,26,8,16,7,27,20,13,2,41,52,31,37,47,55,30,40,51,45,33,48,44,49,39,56,34,53,46,42,50,36,29,32],h=[1,2,4,6,8,10,12,14,15,17,19,21,23,25,27,28],k=[{0:8421888,268435456:32768,536870912:8421378,805306368:2,1073741824:512,1342177280:8421890,1610612736:8389122,1879048192:8388608,2147483648:514,2415919104:8389120,2684354560:33280,2952790016:8421376,3221225472:32770,3489660928:8388610,3758096384:0,4026531840:33282,134217728:0,402653184:8421890,671088640:33282,939524096:32768,1207959552:8421888,1476395008:512,1744830464:8421378,2013265920:2,2281701376:8389120,2550136832:33280,2818572288:8421376,3087007744:8389122,3355443200:8388610,3623878656:32770,3892314112:514,4160749568:8388608,1:32768,268435457:2,536870913:8421888,805306369:8388608,1073741825:8421378,1342177281:33280,1610612737:512,1879048193:8389122,2147483649:8421890,2415919105:8421376,2684354561:8388610,2952790017:33282,3221225473:514,3489660929:8389120,3758096385:32770,4026531841:0,134217729:8421890,402653185:8421376,671088641:8388608,939524097:512,1207959553:32768,1476395009:8388610,1744830465:2,2013265921:33282,2281701377:32770,2550136833:8389122,2818572289:514,3087007745:8421888,3355443201:8389120,3623878657:0,3892314113:33280,4160749569:8421378},{0:1074282512,16777216:16384,33554432:524288,50331648:1074266128,67108864:1073741840,83886080:1074282496,100663296:1073758208,117440512:16,134217728:540672,150994944:1073758224,167772160:1073741824,184549376:540688,201326592:524304,218103808:0,234881024:16400,251658240:1074266112,8388608:1073758208,25165824:540688,41943040:16,58720256:1073758224,75497472:1074282512,92274688:1073741824,109051904:524288,125829120:1074266128,142606336:524304,159383552:0,176160768:16384,192937984:1074266112,209715200:1073741840,226492416:540672,243269632:1074282496,260046848:16400,268435456:0,285212672:1074266128,301989888:1073758224,318767104:1074282496,335544320:1074266112,352321536:16,369098752:540688,385875968:16384,402653184:16400,419430400:524288,436207616:524304,452984832:1073741840,469762048:540672,486539264:1073758208,503316480:1073741824,520093696:1074282512,276824064:540688,293601280:524288,310378496:1074266112,327155712:16384,343932928:1073758208,360710144:1074282512,377487360:16,394264576:1073741824,411041792:1074282496,427819008:1073741840,444596224:1073758224,461373440:524304,478150656:0,494927872:16400,511705088:1074266128,528482304:540672},{0:260,1048576:0,2097152:67109120,3145728:65796,4194304:65540,5242880:67108868,6291456:67174660,7340032:67174400,8388608:67108864,9437184:67174656,10485760:65792,11534336:67174404,12582912:67109124,13631488:65536,14680064:4,15728640:256,524288:67174656,1572864:67174404,2621440:0,3670016:67109120,4718592:67108868,5767168:65536,6815744:65540,7864320:260,8912896:4,9961472:256,11010048:67174400,12058624:65796,13107200:65792,14155776:67109124,15204352:67174660,16252928:67108864,16777216:67174656,17825792:65540,18874368:65536,19922944:67109120,20971520:256,22020096:67174660,23068672:67108868,24117248:0,25165824:67109124,26214400:67108864,27262976:4,28311552:65792,29360128:67174400,30408704:260,31457280:65796,32505856:67174404,17301504:67108864,18350080:260,19398656:67174656,20447232:0,21495808:65540,22544384:67109120,23592960:256,24641536:67174404,25690112:65536,26738688:67174660,27787264:65796,28835840:67108868,29884416:67109124,30932992:67174400,31981568:4,33030144:65792},{0:2151682048,65536:2147487808,131072:4198464,196608:2151677952,262144:0,327680:4198400,393216:2147483712,458752:4194368,524288:2147483648,589824:4194304,655360:64,720896:2147487744,786432:2151678016,851968:4160,917504:4096,983040:2151682112,32768:2147487808,98304:64,163840:2151678016,229376:2147487744,294912:4198400,360448:2151682112,425984:0,491520:2151677952,557056:4096,622592:2151682048,688128:4194304,753664:4160,819200:2147483648,884736:4194368,950272:4198464,1015808:2147483712,1048576:4194368,1114112:4198400,1179648:2147483712,1245184:0,1310720:4160,1376256:2151678016,1441792:2151682048,1507328:2147487808,1572864:2151682112,1638400:2147483648,1703936:2151677952,1769472:4198464,1835008:2147487744,1900544:4194304,1966080:64,2031616:4096,1081344:2151677952,1146880:2151682112,1212416:0,1277952:4198400,1343488:4194368,1409024:2147483648,1474560:2147487808,1540096:64,1605632:2147483712,1671168:4096,1736704:2147487744,1802240:2151678016,1867776:4160,1933312:2151682048,1998848:4194304,2064384:4198464},{0:128,4096:17039360,8192:262144,12288:536870912,16384:537133184,20480:16777344,24576:553648256,28672:262272,32768:16777216,36864:537133056,40960:536871040,45056:553910400,49152:553910272,53248:0,57344:17039488,61440:553648128,2048:17039488,6144:553648256,10240:128,14336:17039360,18432:262144,22528:537133184,26624:553910272,30720:536870912,34816:537133056,38912:0,43008:553910400,47104:16777344,51200:536871040,55296:553648128,59392:16777216,63488:262272,65536:262144,69632:128,73728:536870912,77824:553648256,81920:16777344,86016:553910272,90112:537133184,94208:16777216,98304:553910400,102400:553648128,106496:17039360,110592:537133056,114688:262272,118784:536871040,122880:0,126976:17039488,67584:553648256,71680:16777216,75776:17039360,79872:537133184,83968:536870912,88064:17039488,92160:128,96256:553910272,100352:262272,104448:553910400,108544:0,112640:553648128,116736:16777344,120832:262144,124928:537133056,129024:536871040},{0:268435464,256:8192,512:270532608,768:270540808,1024:268443648,1280:2097152,1536:2097160,1792:268435456,2048:0,2304:268443656,2560:2105344,2816:8,3072:270532616,3328:2105352,3584:8200,3840:270540800,128:270532608,384:270540808,640:8,896:2097152,1152:2105352,1408:268435464,1664:268443648,1920:8200,2176:2097160,2432:8192,2688:268443656,2944:270532616,3200:0,3456:270540800,3712:2105344,3968:268435456,4096:268443648,4352:270532616,4608:270540808,4864:8200,5120:2097152,5376:268435456,5632:268435464,5888:2105344,6144:2105352,6400:0,6656:8,6912:270532608,7168:8192,7424:268443656,7680:270540800,7936:2097160,4224:8,4480:2105344,4736:2097152,4992:268435464,5248:268443648,5504:8200,5760:270540808,6016:270532608,6272:270540800,6528:270532616,6784:8192,7040:2105352,7296:2097160,7552:0,7808:268435456,8064:268443656},{0:1048576,16:33555457,32:1024,48:1049601,64:34604033,80:0,96:1,112:34603009,128:33555456,144:1048577,160:33554433,176:34604032,192:34603008,208:1025,224:1049600,240:33554432,8:34603009,24:0,40:33555457,56:34604032,72:1048576,88:33554433,104:33554432,120:1025,136:1049601,152:33555456,168:34603008,184:1048577,200:1024,216:34604033,232:1,248:1049600,256:33554432,272:1048576,288:33555457,304:34603009,320:1048577,336:33555456,352:34604032,368:1049601,384:1025,400:34604033,416:1049600,432:1,448:0,464:34603008,480:33554433,496:1024,264:1049600,280:33555457,296:34603009,312:1,328:33554432,344:1048576,360:1025,376:34604032,392:33554433,408:34603008,424:0,440:34604033,456:1049601,472:1024,488:33555456,504:1048577},{0:134219808,1:131072,2:134217728,3:32,4:131104,5:134350880,6:134350848,7:2048,8:134348800,9:134219776,10:133120,11:134348832,12:2080,13:0,14:134217760,15:133152,2147483648:2048,2147483649:134350880,2147483650:134219808,2147483651:134217728,2147483652:134348800,2147483653:133120,2147483654:133152,2147483655:32,2147483656:134217760,2147483657:2080,2147483658:131104,2147483659:134350848,2147483660:0,2147483661:134348832,2147483662:134219776,2147483663:131072,16:133152,17:134350848,18:32,19:2048,20:134219776,21:134217760,22:134348832,23:131072,24:0,25:131104,26:134348800,27:134219808,28:134350880,29:133120,30:2080,31:134217728,2147483664:131072,2147483665:2048,2147483666:134348832,2147483667:133152,2147483668:32,2147483669:134348800,2147483670:134217728,2147483671:134219808,2147483672:134350880,2147483673:134217760,2147483674:134219776,2147483675:0,2147483676:133120,2147483677:2080,2147483678:131104,2147483679:134350848}],_=[4160749569,528482304,33030144,2064384,129024,8064,504,2147483679],n=o.DES=i.extend({_doReset:function(){for(var e=this._key.words,t=[],c=0;c<56;c++){var r=l[c]-1;t[c]=e[r>>>5]>>>31-r%32&1}for(var i=this._subKeys=[],o=0;o<16;o++){var k=i[o]=[],_=h[o];for(c=0;c<24;c++)k[c/6|0]|=t[(s[c]-1+_)%28]<<31-c%6,k[4+(c/6|0)]|=t[28+(s[c+24]-1+_)%28]<<31-c%6;k[0]=k[0]<<1|k[0]>>>31;for(c=1;c<7;c++)k[c]=k[c]>>>4*(c-1)+3;k[7]=k[7]<<5|k[7]>>>27}var n=this._invSubKeys=[];for(c=0;c<16;c++)n[c]=i[15-c]},encryptBlock:function(e,t){this._doCryptBlock(e,t,this._subKeys)},decryptBlock:function(e,t){this._doCryptBlock(e,t,this._invSubKeys)},_doCryptBlock:function(e,t,c){this._lBlock=e[t],this._rBlock=e[t+1],a.call(this,4,252645135),a.call(this,16,65535),B.call(this,2,858993459),B.call(this,8,16711935),a.call(this,1,1431655765);for(var r=0;r<16;r++){for(var i=c[r],o=this._lBlock,l=this._rBlock,s=0,h=0;h<8;h++)s|=k[h][((l^i[h])&_[h])>>>0];this._lBlock=l,this._rBlock=o^s}var n=this._lBlock;this._lBlock=this._rBlock,this._rBlock=n,a.call(this,1,1431655765),B.call(this,8,16711935),B.call(this,2,858993459),a.call(this,16,65535),a.call(this,4,252645135),e[t]=this._lBlock,e[t+1]=this._rBlock},keySize:2,ivSize:2,blockSize:2});function a(e,t){var c=(this._lBlock>>>e^this._rBlock)&t;this._rBlock^=c,this._lBlock^=c<>>e^this._lBlock)&t;this._lBlock^=c,this._rBlock^=c<>>2]>>>24-s%4*8&255;c=(c+i[o]+n)%256;var a=i[o];i[o]=i[c],i[c]=a}this._i=this._j=0},_doProcessBlock:function(e,r){e[r]^=c.call(this)},keySize:8,ivSize:0});function c(){for(var e=this._S,r=this._i,t=this._j,i=0,o=0;o<4;o++){t=(t+e[r=(r+1)%256])%256;var c=e[r];e[r]=e[t],e[t]=c,i|=e[(e[r]+e[t])%256]<<24-8*o}return this._i=r,this._j=t,i}r.RC4=t._createHelper(o);var s=i.RC4Drop=o.extend({cfg:o.cfg.extend({drop:192}),_doReset:function(){o._doReset.call(this);for(var e=this.cfg.drop;e>0;e--)c.call(this)}});r.RC4Drop=t._createHelper(s)}(),e.RC4}); +},{"./core":"eUTO","./enc-base64":"pJaz","./md5":"GVDV","./evpkdf":"W9aa","./cipher-core":"uCLB"}],"f1HY":[function(require,module,exports) { +var define; +var r;!function(e,i,t){"object"==typeof exports?module.exports=exports=i(require("./core"),require("./enc-base64"),require("./md5"),require("./evpkdf"),require("./cipher-core")):"function"==typeof r&&r.amd?r(["./core","./enc-base64","./md5","./evpkdf","./cipher-core"],i):i(e.CryptoJS)}(this,function(r){return function(){var e=r,i=e.lib.StreamCipher,t=e.algo,o=[],c=[],s=[],a=t.Rabbit=i.extend({_doReset:function(){for(var r=this._key.words,e=this.cfg.iv,i=0;i<4;i++)r[i]=16711935&(r[i]<<8|r[i]>>>24)|4278255360&(r[i]<<24|r[i]>>>8);var t=this._X=[r[0],r[3]<<16|r[2]>>>16,r[1],r[0]<<16|r[3]>>>16,r[2],r[1]<<16|r[0]>>>16,r[3],r[2]<<16|r[1]>>>16],o=this._C=[r[2]<<16|r[2]>>>16,4294901760&r[0]|65535&r[1],r[3]<<16|r[3]>>>16,4294901760&r[1]|65535&r[2],r[0]<<16|r[0]>>>16,4294901760&r[2]|65535&r[3],r[1]<<16|r[1]>>>16,4294901760&r[3]|65535&r[0]];this._b=0;for(i=0;i<4;i++)f.call(this);for(i=0;i<8;i++)o[i]^=t[i+4&7];if(e){var c=e.words,s=c[0],a=c[1],n=16711935&(s<<8|s>>>24)|4278255360&(s<<24|s>>>8),h=16711935&(a<<8|a>>>24)|4278255360&(a<<24|a>>>8),b=n>>>16|4294901760&h,u=h<<16|65535&n;o[0]^=n,o[1]^=b,o[2]^=h,o[3]^=u,o[4]^=n,o[5]^=b,o[6]^=h,o[7]^=u;for(i=0;i<4;i++)f.call(this)}},_doProcessBlock:function(r,e){var i=this._X;f.call(this),o[0]=i[0]^i[5]>>>16^i[3]<<16,o[1]=i[2]^i[7]>>>16^i[5]<<16,o[2]=i[4]^i[1]>>>16^i[7]<<16,o[3]=i[6]^i[3]>>>16^i[1]<<16;for(var t=0;t<4;t++)o[t]=16711935&(o[t]<<8|o[t]>>>24)|4278255360&(o[t]<<24|o[t]>>>8),r[e+t]^=o[t]},blockSize:4,ivSize:2});function f(){for(var r=this._X,e=this._C,i=0;i<8;i++)c[i]=e[i];e[0]=e[0]+1295307597+this._b|0,e[1]=e[1]+3545052371+(e[0]>>>0>>0?1:0)|0,e[2]=e[2]+886263092+(e[1]>>>0>>0?1:0)|0,e[3]=e[3]+1295307597+(e[2]>>>0>>0?1:0)|0,e[4]=e[4]+3545052371+(e[3]>>>0>>0?1:0)|0,e[5]=e[5]+886263092+(e[4]>>>0>>0?1:0)|0,e[6]=e[6]+1295307597+(e[5]>>>0>>0?1:0)|0,e[7]=e[7]+3545052371+(e[6]>>>0>>0?1:0)|0,this._b=e[7]>>>0>>0?1:0;for(i=0;i<8;i++){var t=r[i]+e[i],o=65535&t,a=t>>>16,f=((o*o>>>17)+o*a>>>15)+a*a,n=((4294901760&t)*t|0)+((65535&t)*t|0);s[i]=f^n}r[0]=s[0]+(s[7]<<16|s[7]>>>16)+(s[6]<<16|s[6]>>>16)|0,r[1]=s[1]+(s[0]<<8|s[0]>>>24)+s[7]|0,r[2]=s[2]+(s[1]<<16|s[1]>>>16)+(s[0]<<16|s[0]>>>16)|0,r[3]=s[3]+(s[2]<<8|s[2]>>>24)+s[1]|0,r[4]=s[4]+(s[3]<<16|s[3]>>>16)+(s[2]<<16|s[2]>>>16)|0,r[5]=s[5]+(s[4]<<8|s[4]>>>24)+s[3]|0,r[6]=s[6]+(s[5]<<16|s[5]>>>16)+(s[4]<<16|s[4]>>>16)|0,r[7]=s[7]+(s[6]<<8|s[6]>>>24)+s[5]|0}e.Rabbit=i._createHelper(a)}(),r.Rabbit}); +},{"./core":"eUTO","./enc-base64":"pJaz","./md5":"GVDV","./evpkdf":"W9aa","./cipher-core":"uCLB"}],"vtgx":[function(require,module,exports) { +var define; +var e;!function(r,i,t){"object"==typeof exports?module.exports=exports=i(require("./core"),require("./enc-base64"),require("./md5"),require("./evpkdf"),require("./cipher-core")):"function"==typeof e&&e.amd?e(["./core","./enc-base64","./md5","./evpkdf","./cipher-core"],i):i(r.CryptoJS)}(this,function(e){return function(){var r=e,i=r.lib.StreamCipher,t=r.algo,o=[],c=[],a=[],s=t.RabbitLegacy=i.extend({_doReset:function(){var e=this._key.words,r=this.cfg.iv,i=this._X=[e[0],e[3]<<16|e[2]>>>16,e[1],e[0]<<16|e[3]>>>16,e[2],e[1]<<16|e[0]>>>16,e[3],e[2]<<16|e[1]>>>16],t=this._C=[e[2]<<16|e[2]>>>16,4294901760&e[0]|65535&e[1],e[3]<<16|e[3]>>>16,4294901760&e[1]|65535&e[2],e[0]<<16|e[0]>>>16,4294901760&e[2]|65535&e[3],e[1]<<16|e[1]>>>16,4294901760&e[3]|65535&e[0]];this._b=0;for(var o=0;o<4;o++)f.call(this);for(o=0;o<8;o++)t[o]^=i[o+4&7];if(r){var c=r.words,a=c[0],s=c[1],n=16711935&(a<<8|a>>>24)|4278255360&(a<<24|a>>>8),h=16711935&(s<<8|s>>>24)|4278255360&(s<<24|s>>>8),b=n>>>16|4294901760&h,u=h<<16|65535&n;t[0]^=n,t[1]^=b,t[2]^=h,t[3]^=u,t[4]^=n,t[5]^=b,t[6]^=h,t[7]^=u;for(o=0;o<4;o++)f.call(this)}},_doProcessBlock:function(e,r){var i=this._X;f.call(this),o[0]=i[0]^i[5]>>>16^i[3]<<16,o[1]=i[2]^i[7]>>>16^i[5]<<16,o[2]=i[4]^i[1]>>>16^i[7]<<16,o[3]=i[6]^i[3]>>>16^i[1]<<16;for(var t=0;t<4;t++)o[t]=16711935&(o[t]<<8|o[t]>>>24)|4278255360&(o[t]<<24|o[t]>>>8),e[r+t]^=o[t]},blockSize:4,ivSize:2});function f(){for(var e=this._X,r=this._C,i=0;i<8;i++)c[i]=r[i];r[0]=r[0]+1295307597+this._b|0,r[1]=r[1]+3545052371+(r[0]>>>0>>0?1:0)|0,r[2]=r[2]+886263092+(r[1]>>>0>>0?1:0)|0,r[3]=r[3]+1295307597+(r[2]>>>0>>0?1:0)|0,r[4]=r[4]+3545052371+(r[3]>>>0>>0?1:0)|0,r[5]=r[5]+886263092+(r[4]>>>0>>0?1:0)|0,r[6]=r[6]+1295307597+(r[5]>>>0>>0?1:0)|0,r[7]=r[7]+3545052371+(r[6]>>>0>>0?1:0)|0,this._b=r[7]>>>0>>0?1:0;for(i=0;i<8;i++){var t=e[i]+r[i],o=65535&t,s=t>>>16,f=((o*o>>>17)+o*s>>>15)+s*s,n=((4294901760&t)*t|0)+((65535&t)*t|0);a[i]=f^n}e[0]=a[0]+(a[7]<<16|a[7]>>>16)+(a[6]<<16|a[6]>>>16)|0,e[1]=a[1]+(a[0]<<8|a[0]>>>24)+a[7]|0,e[2]=a[2]+(a[1]<<16|a[1]>>>16)+(a[0]<<16|a[0]>>>16)|0,e[3]=a[3]+(a[2]<<8|a[2]>>>24)+a[1]|0,e[4]=a[4]+(a[3]<<16|a[3]>>>16)+(a[2]<<16|a[2]>>>16)|0,e[5]=a[5]+(a[4]<<8|a[4]>>>24)+a[3]|0,e[6]=a[6]+(a[5]<<16|a[5]>>>16)+(a[4]<<16|a[4]>>>16)|0,e[7]=a[7]+(a[6]<<8|a[6]>>>24)+a[5]|0}r.RabbitLegacy=i._createHelper(s)}(),e.RabbitLegacy}); +},{"./core":"eUTO","./enc-base64":"pJaz","./md5":"GVDV","./evpkdf":"W9aa","./cipher-core":"uCLB"}],"M4FG":[function(require,module,exports) { +var define; +var e;!function(r,i,a){"object"==typeof exports?module.exports=exports=i(require("./core"),require("./x64-core"),require("./lib-typedarrays"),require("./enc-utf16"),require("./enc-base64"),require("./md5"),require("./sha1"),require("./sha256"),require("./sha224"),require("./sha512"),require("./sha384"),require("./sha3"),require("./ripemd160"),require("./hmac"),require("./pbkdf2"),require("./evpkdf"),require("./cipher-core"),require("./mode-cfb"),require("./mode-ctr"),require("./mode-ctr-gladman"),require("./mode-ofb"),require("./mode-ecb"),require("./pad-ansix923"),require("./pad-iso10126"),require("./pad-iso97971"),require("./pad-zeropadding"),require("./pad-nopadding"),require("./format-hex"),require("./aes"),require("./tripledes"),require("./rc4"),require("./rabbit"),require("./rabbit-legacy")):"function"==typeof e&&e.amd?e(["./core","./x64-core","./lib-typedarrays","./enc-utf16","./enc-base64","./md5","./sha1","./sha256","./sha224","./sha512","./sha384","./sha3","./ripemd160","./hmac","./pbkdf2","./evpkdf","./cipher-core","./mode-cfb","./mode-ctr","./mode-ctr-gladman","./mode-ofb","./mode-ecb","./pad-ansix923","./pad-iso10126","./pad-iso97971","./pad-zeropadding","./pad-nopadding","./format-hex","./aes","./tripledes","./rc4","./rabbit","./rabbit-legacy"],i):r.CryptoJS=i(r.CryptoJS)}(this,function(e){return e}); +},{"./core":"eUTO","./x64-core":"M95N","./lib-typedarrays":"X5QY","./enc-utf16":"xZKj","./enc-base64":"pJaz","./md5":"GVDV","./sha1":"yxyM","./sha256":"MS2N","./sha224":"OEnX","./sha512":"xA62","./sha384":"YkB8","./sha3":"F6e3","./ripemd160":"Y8cR","./hmac":"IKo8","./pbkdf2":"NfQY","./evpkdf":"W9aa","./cipher-core":"uCLB","./mode-cfb":"dnNm","./mode-ctr":"iAFA","./mode-ctr-gladman":"Oy1Y","./mode-ofb":"HXdk","./mode-ecb":"QDS2","./pad-ansix923":"Hi7U","./pad-iso10126":"HttL","./pad-iso97971":"letQ","./pad-zeropadding":"aieV","./pad-nopadding":"GO8Y","./format-hex":"vtW7","./aes":"Srb3","./tripledes":"ySCI","./rc4":"pOMX","./rabbit":"f1HY","./rabbit-legacy":"vtgx"}],"jJsm":[function(require,module,exports) { +var e=require("crypto-js"),r=require("crypto-js/sha3");module.exports=function(t,n){return n&&"hex"===n.encoding&&(t.length>2&&"0x"===t.substr(0,2)&&(t=t.substr(2)),t=e.enc.Hex.parse(t)),r(t,{outputLength:256}).toString()}; +},{"crypto-js":"M4FG","crypto-js/sha3":"F6e3"}],"RkzP":[function(require,module,exports) { +var global = arguments[3]; +var define; +var r,t=arguments[3];!function(n){var o="object"==typeof exports&&exports,e="object"==typeof module&&module&&module.exports==o&&module,i="object"==typeof t&&t;i.global!==i&&i.window!==i||(n=i);var u,f,a,c=String.fromCharCode;function d(r){for(var t,n,o=[],e=0,i=r.length;e=55296&&t<=56319&&e=55296&&r<=57343)throw Error("Lone surrogate U+"+r.toString(16).toUpperCase()+" is not a scalar value")}function v(r,t){return c(r>>t&63|128)}function h(r){if(0==(4294967168&r))return c(r);var t="";return 0==(4294965248&r)?t=c(r>>6&31|192):0==(4294901760&r)?(l(r),t=c(r>>12&15|224),t+=v(r,6)):0==(4292870144&r)&&(t=c(r>>18&7|240),t+=v(r,12),t+=v(r,6)),t+=c(63&r|128)}function s(){if(a>=f)throw Error("Invalid byte index");var r=255&u[a];if(a++,128==(192&r))return 63&r;throw Error("Invalid continuation byte")}function p(){var r,t;if(a>f)throw Error("Invalid byte index");if(a==f)return!1;if(r=255&u[a],a++,0==(128&r))return r;if(192==(224&r)){if((t=(31&r)<<6|s())>=128)return t;throw Error("Invalid continuation byte")}if(224==(240&r)){if((t=(15&r)<<12|s()<<6|s())>=2048)return l(t),t;throw Error("Invalid continuation byte")}if(240==(248&r)&&(t=(7&r)<<18|s()<<12|s()<<6|s())>=65536&&t<=1114111)return t;throw Error("Invalid UTF-8 detected")}var y={version:"2.1.2",encode:function(r){for(var t=d(r),n=t.length,o=-1,e="";++o65535&&(e+=c((t-=65536)>>>10&1023|55296),t=56320|1023&t),e+=c(t);return e}(n)}};if("function"==typeof r&&"object"==typeof r.amd&&r.amd)r(function(){return y});else if(o&&!o.nodeType)if(e)e.exports=y;else{var b={}.hasOwnProperty;for(var w in y)b.call(y,w)&&(o[w]=y[w])}else n.utf8=y}(this); +},{}],"Fh47":[function(require,module,exports) { +var r=require("bignumber.js"),e=require("./sha3.js"),t=require("utf8"),n={noether:"0",wei:"1",kwei:"1000",Kwei:"1000",babbage:"1000",femtoether:"1000",mwei:"1000000",Mwei:"1000000",lovelace:"1000000",picoether:"1000000",gwei:"1000000000",Gwei:"1000000000",shannon:"1000000000",nanoether:"1000000000",nano:"1000000000",szabo:"1000000000000",microether:"1000000000000",micro:"1000000000000",finney:"1000000000000000",milliether:"1000000000000000",milli:"1000000000000000",ether:"1000000000000000000",kether:"1000000000000000000000",grand:"1000000000000000000000",mether:"1000000000000000000000000",gether:"1000000000000000000000000000",tether:"1000000000000000000000000000000"},f=function(r,e,t){return new Array(e-r.length+1).join(t||"0")+r},i=function(r,e,t){return r+new Array(e-r.length+1).join(t||"0")},o=function(r){var e="",n=0,f=r.length;for("0x"===r.substring(0,2)&&(n=2);n7&&r[n].toUpperCase()!==r[n]||parseInt(t[n],16)<=7&&r[n].toLowerCase()!==r[n])return!1;return!0},S=function(r){if(void 0===r)return"";r=r.toLowerCase().replace("0x","");for(var t=e(r),n="0x",f=0;f7?n+=r[f].toUpperCase():n+=r[f];return n},O=function(r){return y(r)?r:/^[0-9a-f]{40}$/.test(r)?"0x"+r:"0x"+f(m(r).substr(2),40)},$=function(e){return e instanceof r||e&&e.constructor&&"BigNumber"===e.constructor.name},N=function(r){return"string"==typeof r||r&&r.constructor&&"String"===r.constructor.name},j=function(r){return"function"==typeof r},T=function(r){return null!==r&&!Array.isArray(r)&&"object"==typeof r},k=function(r){return"boolean"==typeof r},B=function(r){return Array.isArray(r)},F=function(r){try{return!!JSON.parse(r)}catch(e){return!1}},I=function(r){return!!/^(0x)?[0-9a-f]{512}$/i.test(r)&&!(!/^(0x)?[0-9a-f]{512}$/.test(r)&&!/^(0x)?[0-9A-F]{512}$/.test(r))},L=function(r){return!!/^(0x)?[0-9a-f]{64}$/i.test(r)&&!(!/^(0x)?[0-9a-f]{64}$/.test(r)&&!/^(0x)?[0-9A-F]{64}$/.test(r))};module.exports={padLeft:f,padRight:i,toHex:m,toDecimal:d,fromDecimal:h,toUtf8:o,toAscii:u,fromUtf8:s,fromAscii:a,transformToFullName:c,extractDisplayName:l,extractTypeName:x,toWei:b,fromWei:p,toBigNumber:v,toTwosComplement:w,toAddress:O,isBigNumber:$,isStrictAddress:y,isAddress:A,isChecksumAddress:C,toChecksumAddress:S,isFunction:j,isString:N,isObject:T,isBoolean:k,isArray:B,isJson:F,isBloom:I,isTopic:L}; +},{"bignumber.js":"LdGf","./sha3.js":"jJsm","utf8":"RkzP"}],"hHtK":[function(require,module,exports) { +var e=require("bignumber.js"),r=["wei","kwei","Mwei","Gwei","szabo","finney","femtoether","picoether","nanoether","microether","milliether","nano","micro","milli","ether","grand","Mether","Gether","Tether","Pether","Eether","Zether","Yether","Nether","Dether","Vether","Uether"];module.exports={ETH_PADDING:32,ETH_SIGNATURE_LENGTH:4,ETH_UNITS:r,ETH_BIGNUMBER_ROUNDING_MODE:{ROUNDING_MODE:e.ROUND_DOWN},ETH_POLLING_TIMEOUT:500,defaultBlock:"latest",defaultAccount:void 0}; +},{"bignumber.js":"LdGf"}],"NewK":[function(require,module,exports) { +module.exports={InvalidNumberOfSolidityArgs:function(){return new Error("Invalid number of arguments to Solidity function")},InvalidNumberOfRPCParams:function(){return new Error("Invalid number of input parameters to RPC method")},InvalidConnection:function(r){return new Error("CONNECTION ERROR: Couldn't connect to node "+r+".")},InvalidProvider:function(){return new Error("Provider not set or invalid")},InvalidResponse:function(r){var n=r&&r.error&&r.error.message?r.error.message:"Invalid JSON RPC response: "+JSON.stringify(r);return new Error(n)},ConnectionTimeout:function(r){return new Error("CONNECTION TIMEOUT: timeout of "+r+" ms achived")}}; +},{}],"XRH2":[function(require,module,exports) { +var t=require("./jsonrpc"),i=require("../utils/utils"),o=require("../utils/config"),e=require("./errors"),r=function(t){this.provider=t,this.polls={},this.timeout=null};r.prototype.send=function(i){if(!this.provider)return console.error(e.InvalidProvider()),null;var o=t.toPayload(i.method,i.params),r=this.provider.send(o);if(!t.isValidResponse(r))throw e.InvalidResponse(r);return r.result},r.prototype.sendAsync=function(i,o){if(!this.provider)return o(e.InvalidProvider());var r=t.toPayload(i.method,i.params);this.provider.sendAsync(r,function(i,r){return i?o(i):t.isValidResponse(r)?void o(null,r.result):o(e.InvalidResponse(r))})},r.prototype.sendBatch=function(o,r){if(!this.provider)return r(e.InvalidProvider());var s=t.toBatchPayload(o);this.provider.sendAsync(s,function(t,o){return t?r(t):i.isArray(o)?void r(t,o):r(e.InvalidResponse(o))})},r.prototype.setProvider=function(t){this.provider=t},r.prototype.startPolling=function(t,i,o,e){this.polls[i]={data:t,id:i,callback:o,uninstall:e},this.timeout||this.poll()},r.prototype.stopPolling=function(t){delete this.polls[t],0===Object.keys(this.polls).length&&this.timeout&&(clearTimeout(this.timeout),this.timeout=null)},r.prototype.reset=function(t){for(var i in this.polls)t&&-1!==i.indexOf("syncPoll_")||(this.polls[i].uninstall(),delete this.polls[i]);0===Object.keys(this.polls).length&&this.timeout&&(clearTimeout(this.timeout),this.timeout=null)},r.prototype.poll=function(){if(this.timeout=setTimeout(this.poll.bind(this),o.ETH_POLLING_TIMEOUT),0!==Object.keys(this.polls).length)if(this.provider){var r=[],s=[];for(var l in this.polls)r.push(this.polls[l].data),s.push(l);if(0!==r.length){var n=t.toBatchPayload(r),a={};n.forEach(function(t,i){a[t.id]=s[i]});var u=this;this.provider.sendAsync(n,function(o,r){if(!o){if(!i.isArray(r))throw e.InvalidResponse(r);r.map(function(t){var i=a[t.id];return!!u.polls[i]&&(t.callback=u.polls[i].callback,t)}).filter(function(t){return!!t}).filter(function(i){var o=t.isValidResponse(i);return o||i.callback(e.InvalidResponse(i)),o}).forEach(function(t){t.callback(null,t.result)})}})}}else console.error(e.InvalidProvider())},module.exports=r; +},{"./jsonrpc":"Fylb","../utils/utils":"Fh47","../utils/config":"hHtK","./errors":"NewK"}],"JSHq":[function(require,module,exports) { +var t=require("bignumber.js"),n=function(t,n){for(var r=t;r.length<2*n;)r="0"+r;return r},r=function(t){var n="A".charCodeAt(0),r="Z".charCodeAt(0);return(t=(t=t.toUpperCase()).substr(4)+t.substr(0,4)).split("").map(function(t){var i=t.charCodeAt(0);return i>=n&&i<=r?i-n+10:t}).join("")},i=function(t){for(var n,r=t;r.length>2;)n=r.slice(0,9),r=parseInt(n,10)%97+r.slice(n.length);return parseInt(r,10)%97},e=function(t){this._iban=t};e.fromAddress=function(r){var i=new t(r,16).toString(36),o=n(i,15);return e.fromBban(o.toUpperCase())},e.fromBban=function(t){var n=("0"+(98-i(r("XE00"+t)))).slice(-2);return new e("XE"+n+t)},e.createIndirect=function(t){return e.fromBban("ETH"+t.institution+t.identifier)},e.isValid=function(t){return new e(t).isValid()},e.prototype.isValid=function(){return/^XE[0-9]{2}(ETH[0-9A-Z]{13}|[0-9A-Z]{30,31})$/.test(this._iban)&&1===i(r(this._iban))},e.prototype.isDirect=function(){return 34===this._iban.length||35===this._iban.length},e.prototype.isIndirect=function(){return 20===this._iban.length},e.prototype.checksum=function(){return this._iban.substr(2,2)},e.prototype.institution=function(){return this.isIndirect()?this._iban.substr(7,4):""},e.prototype.client=function(){return this.isIndirect()?this._iban.substr(11):""},e.prototype.address=function(){if(this.isDirect()){var r=this._iban.substr(4),i=new t(r,36);return n(i.toString(16),20)}return""},e.prototype.toString=function(){return this._iban},module.exports=e; +},{"bignumber.js":"LdGf"}],"ra15":[function(require,module,exports) { +"use strict";var t=require("../utils/utils"),o=require("../utils/config"),r=require("./iban"),e=function(o){return t.toBigNumber(o)},i=function(t){return"latest"===t||"pending"===t||"earliest"===t},n=function(t){return void 0===t?o.defaultBlock:a(t)},a=function(o){if(void 0!==o)return i(o)?o:t.toHex(o)},c=function(r){return r.from=r.from||o.defaultAccount,r.from&&(r.from=g(r.from)),r.to&&(r.to=g(r.to)),["gasPrice","gas","value","nonce"].filter(function(t){return void 0!==r[t]}).forEach(function(o){r[o]=t.fromDecimal(r[o])}),r},u=function(r){return r.from=r.from||o.defaultAccount,r.from=g(r.from),r.to&&(r.to=g(r.to)),["gasPrice","gas","value","nonce"].filter(function(t){return void 0!==r[t]}).forEach(function(o){r[o]=t.fromDecimal(r[o])}),r},l=function(o){return null!==o.blockNumber&&(o.blockNumber=t.toDecimal(o.blockNumber)),null!==o.transactionIndex&&(o.transactionIndex=t.toDecimal(o.transactionIndex)),o.nonce=t.toDecimal(o.nonce),o.gas=t.toDecimal(o.gas),o.gasPrice=t.toBigNumber(o.gasPrice),o.value=t.toBigNumber(o.value),o},s=function(o){return null!==o.blockNumber&&(o.blockNumber=t.toDecimal(o.blockNumber)),null!==o.transactionIndex&&(o.transactionIndex=t.toDecimal(o.transactionIndex)),o.cumulativeGasUsed=t.toDecimal(o.cumulativeGasUsed),o.gasUsed=t.toDecimal(o.gasUsed),t.isArray(o.logs)&&(o.logs=o.logs.map(function(t){return f(t)})),o},m=function(o){return o.gasLimit=t.toDecimal(o.gasLimit),o.gasUsed=t.toDecimal(o.gasUsed),o.size=t.toDecimal(o.size),o.timestamp=t.toDecimal(o.timestamp),null!==o.number&&(o.number=t.toDecimal(o.number)),o.difficulty=t.toBigNumber(o.difficulty),o.totalDifficulty=t.toBigNumber(o.totalDifficulty),t.isArray(o.transactions)&&o.transactions.forEach(function(o){if(!t.isString(o))return l(o)}),o},f=function(o){return o.blockNumber&&(o.blockNumber=t.toDecimal(o.blockNumber)),o.transactionIndex&&(o.transactionIndex=t.toDecimal(o.transactionIndex)),o.logIndex&&(o.logIndex=t.toDecimal(o.logIndex)),o},d=function(o){return o.ttl=t.fromDecimal(o.ttl),o.workToProve=t.fromDecimal(o.workToProve),o.priority=t.fromDecimal(o.priority),t.isArray(o.topics)||(o.topics=o.topics?[o.topics]:[]),o.topics=o.topics.map(function(o){return 0===o.indexOf("0x")?o:t.fromUtf8(o)}),o},p=function(o){return o.expiry=t.toDecimal(o.expiry),o.sent=t.toDecimal(o.sent),o.ttl=t.toDecimal(o.ttl),o.workProved=t.toDecimal(o.workProved),o.topics||(o.topics=[]),o.topics=o.topics.map(function(o){return t.toAscii(o)}),o},g=function(o){var e=new r(o);if(e.isValid()&&e.isDirect())return"0x"+e.address();if(t.isStrictAddress(o))return o;if(t.isAddress(o))return"0x"+o;throw new Error("invalid address")},D=function(o){return o?(o.startingBlock=t.toDecimal(o.startingBlock),o.currentBlock=t.toDecimal(o.currentBlock),o.highestBlock=t.toDecimal(o.highestBlock),o.knownStates&&(o.knownStates=t.toDecimal(o.knownStates),o.pulledStates=t.toDecimal(o.pulledStates)),o):o};module.exports={inputDefaultBlockNumberFormatter:n,inputBlockNumberFormatter:a,inputCallFormatter:c,inputTransactionFormatter:u,inputAddressFormatter:g,inputPostFormatter:d,outputBigNumberFormatter:e,outputTransactionFormatter:l,outputTransactionReceiptFormatter:s,outputBlockFormatter:m,outputLogFormatter:f,outputPostFormatter:p,outputSyncingFormatter:D}; +},{"../utils/utils":"Fh47","../utils/config":"hHtK","./iban":"JSHq"}],"Gyz6":[function(require,module,exports) { +var t=require("../utils/utils"),r=require("./errors"),a=function(t){this.name=t.name,this.call=t.call,this.params=t.params||0,this.inputFormatter=t.inputFormatter,this.outputFormatter=t.outputFormatter,this.requestManager=null};a.prototype.setRequestManager=function(t){this.requestManager=t},a.prototype.getCall=function(r){return t.isFunction(this.call)?this.call(r):this.call},a.prototype.extractCallback=function(r){if(t.isFunction(r[r.length-1]))return r.pop()},a.prototype.validateArgs=function(t){if(t.length!==this.params)throw r.InvalidNumberOfRPCParams()},a.prototype.formatInput=function(t){return this.inputFormatter?this.inputFormatter.map(function(r,a){return r?r(t[a]):t[a]}):t},a.prototype.formatOutput=function(t){return this.outputFormatter&&t?this.outputFormatter(t):t},a.prototype.toPayload=function(t){var r=this.getCall(t),a=this.extractCallback(t),e=this.formatInput(t);return this.validateArgs(e),{method:r,params:e,callback:a}},a.prototype.attachToObject=function(t){var r=this.buildCall();r.call=this.call;var a=this.name.split(".");a.length>1?(t[a[0]]=t[a[0]]||{},t[a[0]][a[1]]=r):t[a[0]]=r},a.prototype.buildCall=function(){var t=this,r=function(){var r=t.toPayload(Array.prototype.slice.call(arguments));return r.callback?t.requestManager.sendAsync(r,function(a,e){r.callback(a,t.formatOutput(e))}):t.formatOutput(t.requestManager.send(r))};return r.request=this.request.bind(this),r},a.prototype.request=function(){var t=this.toPayload(Array.prototype.slice.call(arguments));return t.format=this.formatOutput.bind(this),t},module.exports=a; +},{"../utils/utils":"Fh47","./errors":"NewK"}],"H1O0":[function(require,module,exports) { +var t=require("../utils/utils"),e=function(t){this.name=t.name,this.getter=t.getter,this.setter=t.setter,this.outputFormatter=t.outputFormatter,this.inputFormatter=t.inputFormatter,this.requestManager=null};e.prototype.setRequestManager=function(t){this.requestManager=t},e.prototype.formatInput=function(t){return this.inputFormatter?this.inputFormatter(t):t},e.prototype.formatOutput=function(t){return this.outputFormatter&&null!=t?this.outputFormatter(t):t},e.prototype.extractCallback=function(e){if(t.isFunction(e[e.length-1]))return e.pop()},e.prototype.attachToObject=function(t){var e={get:this.buildGet(),enumerable:!0},n=this.name.split("."),u=n[0];n.length>1&&(t[n[0]]=t[n[0]]||{},t=t[n[0]],u=n[1]),Object.defineProperty(t,u,e),t[r(u)]=this.buildAsyncGet()};var r=function(t){return"get"+t.charAt(0).toUpperCase()+t.slice(1)};e.prototype.buildGet=function(){var t=this;return function(){return t.formatOutput(t.requestManager.send({method:t.getter}))}},e.prototype.buildAsyncGet=function(){var t=this,e=function(e){t.requestManager.sendAsync({method:t.getter},function(r,n){e(r,t.formatOutput(n))})};return e.request=this.request.bind(this),e},e.prototype.request=function(){var t={method:this.getter,params:[],callback:this.extractCallback(Array.prototype.slice.call(arguments))};return t.format=this.formatOutput.bind(this),t},module.exports=e; +},{"../utils/utils":"Fh47"}],"dIvh":[function(require,module,exports) { +var t=require("../utils/utils"),n=function(t,n){this.value=t||"",this.offset=n};n.prototype.dynamicPartLength=function(){return this.dynamicPart().length/2},n.prototype.withOffset=function(t){return new n(this.value,t)},n.prototype.combine=function(t){return new n(this.value+t.value)},n.prototype.isDynamic=function(){return void 0!==this.offset},n.prototype.offsetAsBytes=function(){return this.isDynamic()?t.padLeft(t.toTwosComplement(this.offset).toString(16),64):""},n.prototype.staticPart=function(){return this.isDynamic()?this.offsetAsBytes():this.value},n.prototype.dynamicPart=function(){return this.isDynamic()?this.value:""},n.prototype.encode=function(){return this.staticPart()+this.dynamicPart()},n.encodeList=function(t){var n=32*t.length,e=t.map(function(t){if(!t.isDynamic())return t;var e=n;return n+=t.dynamicPartLength(),t.withOffset(e)});return e.reduce(function(t,n){return t+n.dynamicPart()},e.reduce(function(t,n){return t+n.staticPart()},""))},module.exports=n; +},{"../utils/utils":"Fh47"}],"lYBp":[function(require,module,exports) { +var t=require("bignumber.js"),f=require("../utils/utils"),n=require("../utils/config"),r=require("./param"),u=function(u){t.config(n.ETH_BIGNUMBER_ROUNDING_MODE);var e=f.padLeft(f.toTwosComplement(u).toString(16),64);return new r(e)},e=function(t){var n=f.toHex(t).substr(2),u=Math.floor((n.length+63)/64);return n=f.padRight(n,64*u),new r(n)},a=function(t){var n=f.toHex(t).substr(2),e=n.length/2,a=Math.floor((n.length+63)/64);return n=f.padRight(n,64*a),new r(u(e).value+n)},o=function(t){var n=f.fromUtf8(t).substr(2),e=n.length/2,a=Math.floor((n.length+63)/64);return n=f.padRight(n,64*a),new r(u(e).value+n)},i=function(t){return new r("000000000000000000000000000000000000000000000000000000000000000"+(t?"1":"0"))},s=function(f){return u(new t(f).times(new t(2).pow(128)))},c=function(f){return"1"===new t(f.substr(0,1),16).toString(2).substr(0,1)},m=function(f){var n=f.staticPart()||"0";return c(n)?new t(n,16).minus(new t("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",16)).minus(1):new t(n,16)},l=function(f){var n=f.staticPart()||"0";return new t(n,16)},p=function(f){return m(f).dividedBy(new t(2).pow(128))},w=function(f){return l(f).dividedBy(new t(2).pow(128))},d=function(t){return"0000000000000000000000000000000000000000000000000000000000000001"===t.staticPart()},g=function(t,f){var n=f.match(/^bytes([0-9]*)/),r=parseInt(n[1]);return"0x"+t.staticPart().slice(0,2*r)},v=function(f){var n=2*new t(f.dynamicPart().slice(0,64),16).toNumber();return"0x"+f.dynamicPart().substr(64,n)},h=function(n){var r=2*new t(n.dynamicPart().slice(0,64),16).toNumber();return f.toUtf8(n.dynamicPart().substr(64,r))},y=function(t){var f=t.staticPart();return"0x"+f.slice(f.length-40,f.length)};module.exports={formatInputInt:u,formatInputBytes:e,formatInputDynamicBytes:a,formatInputString:o,formatInputBool:i,formatInputReal:s,formatOutputInt:m,formatOutputUInt:l,formatOutputReal:p,formatOutputUReal:w,formatOutputBool:d,formatOutputBytes:g,formatOutputDynamicBytes:v,formatOutputString:h,formatOutputAddress:y}; +},{"bignumber.js":"LdGf","../utils/utils":"Fh47","../utils/config":"hHtK","./param":"dIvh"}],"KKSW":[function(require,module,exports) { +var t=require("./formatters"),r=require("./param"),e=function(t){this._inputFormatter=t.inputFormatter,this._outputFormatter=t.outputFormatter};e.prototype.isType=function(t){throw"this method should be overrwritten for type "+t},e.prototype.staticPartLength=function(t){return(this.nestedTypes(t)||["[1]"]).map(function(t){return parseInt(t.slice(1,-1),10)||1}).reduce(function(t,r){return t*r},32)},e.prototype.isDynamicArray=function(t){var r=this.nestedTypes(t);return!!r&&!r[r.length-1].match(/[0-9]{1,}/g)},e.prototype.isStaticArray=function(t){var r=this.nestedTypes(t);return!!r&&!!r[r.length-1].match(/[0-9]{1,}/g)},e.prototype.staticArrayLength=function(t){var r=this.nestedTypes(t);return r?parseInt(r[r.length-1].match(/[0-9]{1,}/g)||1):1},e.prototype.nestedName=function(t){var r=this.nestedTypes(t);return r?t.substr(0,t.length-r[r.length-1].length):t},e.prototype.isDynamicType=function(){return!1},e.prototype.nestedTypes=function(t){return t.match(/(\[[0-9]*\])/g)},e.prototype.encode=function(r,e){var n,s,o,a=this;return this.isDynamicArray(e)?(n=r.length,s=a.nestedName(e),(o=[]).push(t.formatInputInt(n).encode()),r.forEach(function(t){o.push(a.encode(t,s))}),o):this.isStaticArray(e)?function(){for(var t=a.staticArrayLength(e),n=a.nestedName(e),s=[],o=0;o0&&o(f),"function"==typeof a)return f.watch(a)}),this};s.prototype.watch=function(t){return this.callbacks.push(t),this.filterId&&(e(this,t),o(this)),this},s.prototype.stopWatching=function(t){if(this.requestManager.stopPolling(this.filterId),this.callbacks=[],!t)return this.implementation.uninstallFilter(this.filterId);this.implementation.uninstallFilter(this.filterId,t)},s.prototype.get=function(t){var r=this;if(!i.isFunction(t)){if(null===this.filterId)throw new Error("Filter ID Error: filter().get() can't be chained synchronous, please provide a callback for the get() method.");return this.implementation.getLogs(this.filterId).map(function(t){return r.formatter?r.formatter(t):t})}return null===this.filterId?this.getLogsCallbacks.push(t):this.implementation.getLogs(this.filterId,function(i,n){i?t(i):t(null,n.map(function(t){return r.formatter?r.formatter(t):t}))}),this},module.exports=s; +},{"./formatters":"ra15","../utils/utils":"Fh47"}],"Xvr8":[function(require,module,exports) { +var e=require("../method"),a=function(){return[new e({name:"newFilter",call:function(e){switch(e[0]){case"latest":return e.shift(),this.params=0,"eth_newBlockFilter";case"pending":return e.shift(),this.params=0,"eth_newPendingTransactionFilter";default:return"eth_newFilter"}},params:1}),new e({name:"uninstallFilter",call:"eth_uninstallFilter",params:1}),new e({name:"getLogs",call:"eth_getFilterLogs",params:1}),new e({name:"poll",call:"eth_getFilterChanges",params:1})]},t=function(){return[new e({name:"newFilter",call:"shh_newMessageFilter",params:1}),new e({name:"uninstallFilter",call:"shh_deleteMessageFilter",params:1}),new e({name:"getLogs",call:"shh_getFilterMessages",params:1}),new e({name:"poll",call:"shh_getFilterMessages",params:1})]};module.exports={eth:a,shh:t}; +},{"../method":"Gyz6"}],"N4No":[function(require,module,exports) { +var t=require("../utils/utils"),e=require("../solidity/coder"),r=require("./formatters"),n=require("../utils/sha3"),i=require("./filter"),s=require("./methods/watches"),a=function(e,r,n){this._requestManager=e,this._params=r.inputs,this._name=t.transformToFullName(r),this._address=n,this._anonymous=r.anonymous};a.prototype.types=function(t){return this._params.filter(function(e){return e.indexed===t}).map(function(t){return t.type})},a.prototype.displayName=function(){return t.extractDisplayName(this._name)},a.prototype.typeName=function(){return t.extractTypeName(this._name)},a.prototype.signature=function(){return n(this._name)},a.prototype.encode=function(n,i){n=n||{},i=i||{};var s={};["fromBlock","toBlock"].filter(function(t){return void 0!==i[t]}).forEach(function(t){s[t]=r.inputBlockNumberFormatter(i[t])}),s.topics=[],s.address=this._address,this._anonymous||s.topics.push("0x"+this.signature());var a=this._params.filter(function(t){return!0===t.indexed}).map(function(r){var i=n[r.name];return null==i?null:t.isArray(i)?i.map(function(t){return"0x"+e.encodeParam(r.type,t)}):"0x"+e.encodeParam(r.type,i)});return s.topics=s.topics.concat(a),s},a.prototype.decode=function(t){t.data=t.data||"",t.topics=t.topics||[];var n=(this._anonymous?t.topics:t.topics.slice(1)).map(function(t){return t.slice(2)}).join(""),i=e.decodeParams(this.types(!0),n),s=t.data.slice(2),a=e.decodeParams(this.types(!1),s),o=r.outputLogFormatter(t);return o.event=this.displayName(),o.address=t.address,o.args=this._params.reduce(function(t,e){return t[e.name]=e.indexed?i.shift():a.shift(),t},{}),delete o.data,delete o.topics,o},a.prototype.execute=function(e,r,n){t.isFunction(arguments[arguments.length-1])&&(n=arguments[arguments.length-1],2===arguments.length&&(r=null),1===arguments.length&&(r=null,e={}));var a=this.encode(e,r),o=this.decode.bind(this);return new i(a,"eth",this._requestManager,s.eth(),o,n)},a.prototype.attachToContract=function(t){var e=this.execute.bind(this),r=this.displayName();t[r]||(t[r]=e),t[r][this.typeName()]=this.execute.bind(this,t)},module.exports=a; +},{"../utils/utils":"Fh47","../solidity/coder":"YtyA","./formatters":"ra15","../utils/sha3":"jJsm","./filter":"kB3J","./methods/watches":"Xvr8"}],"fhPu":[function(require,module,exports) { +var t=require("../solidity/coder"),e=require("../utils/utils"),a=require("./errors"),i=require("./formatters"),r=require("../utils/sha3"),n=function(t,a,i){this._eth=t,this._inputTypes=a.inputs.map(function(t){return t.type}),this._outputTypes=a.outputs.map(function(t){return t.type}),this._constant="view"===a.stateMutability||"pure"===a.stateMutability||a.constant,this._payable="payable"===a.stateMutability||a.payable,this._name=e.transformToFullName(a),this._address=i};n.prototype.extractCallback=function(t){if(e.isFunction(t[t.length-1]))return t.pop()},n.prototype.extractDefaultBlock=function(t){if(t.length>this._inputTypes.length&&!e.isObject(t[t.length-1]))return i.inputDefaultBlockNumberFormatter(t.pop())},n.prototype.validateArgs=function(t){if(t.filter(function(t){return!(!0===e.isObject(t)&&!1===e.isArray(t)&&!1===e.isBigNumber(t))}).length!==this._inputTypes.length)throw a.InvalidNumberOfSolidityArgs()},n.prototype.toPayload=function(a){var i={};return a.length>this._inputTypes.length&&e.isObject(a[a.length-1])&&(i=a[a.length-1]),this.validateArgs(a),i.to=this._address,i.data="0x"+this.signature()+t.encodeParams(this._inputTypes,a),i},n.prototype.signature=function(){return r(this._name).slice(0,8)},n.prototype.unpackOutput=function(e){if(e){e=e.length>=2?e.slice(2):e;var a=t.decodeParams(this._outputTypes,e);return 1===a.length?a[0]:a}},n.prototype.call=function(){var t=Array.prototype.slice.call(arguments).filter(function(t){return void 0!==t}),e=this.extractCallback(t),a=this.extractDefaultBlock(t),i=this.toPayload(t);if(!e){var r=this._eth.call(i,a);return this.unpackOutput(r)}var n=this;this._eth.call(i,a,function(t,a){if(t)return e(t,null);var i=null;try{i=n.unpackOutput(a)}catch(r){t=r}e(t,i)})},n.prototype.sendTransaction=function(){var t=Array.prototype.slice.call(arguments).filter(function(t){return void 0!==t}),e=this.extractCallback(t),a=this.toPayload(t);if(a.value>0&&!this._payable)throw new Error("Cannot send value to non-payable function");if(!e)return this._eth.sendTransaction(a);this._eth.sendTransaction(a,e)},n.prototype.estimateGas=function(){var t=Array.prototype.slice.call(arguments),e=this.extractCallback(t),a=this.toPayload(t);if(!e)return this._eth.estimateGas(a);this._eth.estimateGas(a,e)},n.prototype.getData=function(){var t=Array.prototype.slice.call(arguments);return this.toPayload(t).data},n.prototype.displayName=function(){return e.extractDisplayName(this._name)},n.prototype.typeName=function(){return e.extractTypeName(this._name)},n.prototype.request=function(){var t=Array.prototype.slice.call(arguments),e=this.extractCallback(t),a=this.toPayload(t),i=this.unpackOutput.bind(this);return{method:this._constant?"eth_call":"eth_sendTransaction",callback:e,params:[a],format:i}},n.prototype.execute=function(){return!this._constant?this.sendTransaction.apply(this,Array.prototype.slice.call(arguments)):this.call.apply(this,Array.prototype.slice.call(arguments))},n.prototype.attachToContract=function(t){var e=this.execute.bind(this);e.request=this.request.bind(this),e.call=this.call.bind(this),e.sendTransaction=this.sendTransaction.bind(this),e.estimateGas=this.estimateGas.bind(this),e.getData=this.getData.bind(this);var a=this.displayName();t[a]||(t[a]=e),t[a][this.typeName()]=e},module.exports=n; +},{"../solidity/coder":"YtyA","../utils/utils":"Fh47","./errors":"NewK","./formatters":"ra15","../utils/sha3":"jJsm"}],"HDvc":[function(require,module,exports) { +var t=require("../utils/sha3"),e=require("./event"),r=require("./formatters"),i=require("../utils/utils"),o=require("./filter"),n=require("./methods/watches"),s=function(t,e,r){this._requestManager=t,this._json=e,this._address=r};s.prototype.encode=function(t){t=t||{};var e={};return["fromBlock","toBlock"].filter(function(e){return void 0!==t[e]}).forEach(function(i){e[i]=r.inputBlockNumberFormatter(t[i])}),e.address=this._address,e},s.prototype.decode=function(o){o.data=o.data||"";var n=i.isArray(o.topics)&&i.isString(o.topics[0])?o.topics[0].slice(2):"",s=this._json.filter(function(e){return n===t(i.transformToFullName(e))})[0];return s?new e(this._requestManager,s,this._address).decode(o):r.outputLogFormatter(o)},s.prototype.execute=function(t,e){i.isFunction(arguments[arguments.length-1])&&(e=arguments[arguments.length-1],1===arguments.length&&(t=null));var r=this.encode(t),s=this.decode.bind(this);return new o(r,"eth",this._requestManager,n.eth(),s,e)},s.prototype.attachToContract=function(t){var e=this.execute.bind(this);t.allEvents=e},module.exports=s; +},{"../utils/sha3":"jJsm","./event":"N4No","./formatters":"ra15","../utils/utils":"Fh47","./filter":"kB3J","./methods/watches":"Xvr8"}],"lrYx":[function(require,module,exports) { +var t=require("../utils/utils"),n=require("../solidity/coder"),e=require("./event"),r=require("./function"),a=require("./allevents"),o=function(t,e){return t.filter(function(t){return"constructor"===t.type&&t.inputs.length===e.length}).map(function(t){return t.inputs.map(function(t){return t.type})}).map(function(t){return n.encodeParams(t,e)})[0]||""},i=function(t){t.abi.filter(function(t){return"function"===t.type}).map(function(n){return new r(t._eth,n,t.address)}).forEach(function(n){n.attachToContract(t)})},c=function(t){var n=t.abi.filter(function(t){return"event"===t.type});new a(t._eth._requestManager,n,t.address).attachToContract(t),n.map(function(n){return new e(t._eth._requestManager,n,t.address)}).forEach(function(n){n.attachToContract(t)})},s=function(t,n){var e=0,r=!1,a=t._eth.filter("latest",function(o){if(!o&&!r)if(++e>50){if(a.stopWatching(function(){}),r=!0,!n)throw new Error("Contract transaction couldn't be found after 50 blocks");n(new Error("Contract transaction couldn't be found after 50 blocks"))}else t._eth.getTransactionReceipt(t.transactionHash,function(e,o){o&&o.blockHash&&!r&&t._eth.getCode(o.contractAddress,function(e,s){if(!r&&s)if(a.stopWatching(function(){}),r=!0,s.length>3)t.address=o.contractAddress,i(t),c(t),n&&n(null,t);else{if(!n)throw new Error("The contract code couldn't be stored, please check your gas amount.");n(new Error("The contract code couldn't be stored, please check your gas amount."))}})})})},u=function(n,e){this.eth=n,this.abi=e,this.new=function(){var n,r=new h(this.eth,this.abi),a={},i=Array.prototype.slice.call(arguments);t.isFunction(i[i.length-1])&&(n=i.pop());var c=i[i.length-1];if((t.isObject(c)&&!t.isArray(c)&&(a=i.pop()),a.value>0)&&!(e.filter(function(t){return"constructor"===t.type&&t.inputs.length===i.length})[0]||{}).payable)throw new Error("Cannot send value to non-payable constructor");var u=o(this.abi,i);if(a.data+=u,n)this.eth.sendTransaction(a,function(t,e){t?n(t):(r.transactionHash=e,n(null,r),s(r,n))});else{var l=this.eth.sendTransaction(a);r.transactionHash=l,s(r)}return r},this.new.getData=this.getData.bind(this)};u.prototype.at=function(t,n){var e=new h(this.eth,this.abi,t);return i(e),c(e),n&&n(null,e),e},u.prototype.getData=function(){var n={},e=Array.prototype.slice.call(arguments),r=e[e.length-1];t.isObject(r)&&!t.isArray(r)&&(n=e.pop());var a=o(this.abi,e);return n.data+=a,n.data};var h=function(t,n,e){this._eth=t,this.transactionHash=null,this.address=e,this.abi=n};module.exports=u; +},{"../utils/utils":"Fh47","../solidity/coder":"YtyA","./event":"N4No","./function":"fhPu","./allevents":"HDvc"}],"R3EG":[function(require,module,exports) { +var t=require("./formatters"),a=require("../utils/utils"),s=1,n=function(s){s.requestManager.startPolling({method:"eth_syncing",params:[]},s.pollId,function(n,l){if(n)return s.callbacks.forEach(function(t){t(n)});a.isObject(l)&&l.startingBlock&&(l=t.outputSyncingFormatter(l)),s.callbacks.forEach(function(t){s.lastSyncState!==l&&(!s.lastSyncState&&a.isObject(l)&&t(null,!0),setTimeout(function(){t(null,l)},0),s.lastSyncState=l)})},s.stopWatching.bind(s))},l=function(t,a){return this.requestManager=t,this.pollId="syncPoll_"+s++,this.callbacks=[],this.addCallback(a),this.lastSyncState=!1,n(this),this};l.prototype.addCallback=function(t){return t&&this.callbacks.push(t),this},l.prototype.stopWatching=function(){this.requestManager.stopPolling(this.pollId),this.callbacks=[]},module.exports=l; +},{"./formatters":"ra15","../utils/utils":"Fh47"}],"K6wu":[function(require,module,exports) { +module.exports=[{constant:!0,inputs:[{name:"_owner",type:"address"}],name:"name",outputs:[{name:"o_name",type:"bytes32"}],type:"function"},{constant:!0,inputs:[{name:"_name",type:"bytes32"}],name:"owner",outputs:[{name:"",type:"address"}],type:"function"},{constant:!0,inputs:[{name:"_name",type:"bytes32"}],name:"content",outputs:[{name:"",type:"bytes32"}],type:"function"},{constant:!0,inputs:[{name:"_name",type:"bytes32"}],name:"addr",outputs:[{name:"",type:"address"}],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"}],name:"reserve",outputs:[],type:"function"},{constant:!0,inputs:[{name:"_name",type:"bytes32"}],name:"subRegistrar",outputs:[{name:"",type:"address"}],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"},{name:"_newOwner",type:"address"}],name:"transfer",outputs:[],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"},{name:"_registrar",type:"address"}],name:"setSubRegistrar",outputs:[],type:"function"},{constant:!1,inputs:[],name:"Registrar",outputs:[],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"},{name:"_a",type:"address"},{name:"_primary",type:"bool"}],name:"setAddress",outputs:[],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"},{name:"_content",type:"bytes32"}],name:"setContent",outputs:[],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"}],name:"disown",outputs:[],type:"function"},{anonymous:!1,inputs:[{indexed:!0,name:"_name",type:"bytes32"},{indexed:!1,name:"_winner",type:"address"}],name:"AuctionEnded",type:"event"},{anonymous:!1,inputs:[{indexed:!0,name:"_name",type:"bytes32"},{indexed:!1,name:"_bidder",type:"address"},{indexed:!1,name:"_value",type:"uint256"}],name:"NewBid",type:"event"},{anonymous:!1,inputs:[{indexed:!0,name:"name",type:"bytes32"}],name:"Changed",type:"event"},{anonymous:!1,inputs:[{indexed:!0,name:"name",type:"bytes32"},{indexed:!0,name:"addr",type:"address"}],name:"PrimaryChanged",type:"event"}]; +},{}],"OzEt":[function(require,module,exports) { +module.exports=[{constant:!0,inputs:[{name:"_name",type:"bytes32"}],name:"owner",outputs:[{name:"",type:"address"}],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"},{name:"_refund",type:"address"}],name:"disown",outputs:[],type:"function"},{constant:!0,inputs:[{name:"_name",type:"bytes32"}],name:"addr",outputs:[{name:"",type:"address"}],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"}],name:"reserve",outputs:[],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"},{name:"_newOwner",type:"address"}],name:"transfer",outputs:[],type:"function"},{constant:!1,inputs:[{name:"_name",type:"bytes32"},{name:"_a",type:"address"}],name:"setAddr",outputs:[],type:"function"},{anonymous:!1,inputs:[{indexed:!0,name:"name",type:"bytes32"}],name:"Changed",type:"event"}]; +},{}],"DSD0":[function(require,module,exports) { +var a=require("../contracts/GlobalRegistrar.json"),c=require("../contracts/ICAPRegistrar.json"),r="0xc6d9d2cd449a754c494264e1809c50e34d64562b",e="0xa1a111bc074c9cfa781f0c38e63bd51c91b8af00";module.exports={global:{abi:a,address:r},icap:{abi:c,address:e}}; +},{"../contracts/GlobalRegistrar.json":"K6wu","../contracts/ICAPRegistrar.json":"OzEt"}],"iGcT":[function(require,module,exports) { +module.exports=[{constant:!1,inputs:[{name:"from",type:"bytes32"},{name:"to",type:"address"},{name:"value",type:"uint256"}],name:"transfer",outputs:[],type:"function"},{constant:!1,inputs:[{name:"from",type:"bytes32"},{name:"to",type:"address"},{name:"indirectId",type:"bytes32"},{name:"value",type:"uint256"}],name:"icapTransfer",outputs:[],type:"function"},{constant:!1,inputs:[{name:"to",type:"bytes32"}],name:"deposit",outputs:[],payable:!0,type:"function"},{anonymous:!1,inputs:[{indexed:!0,name:"from",type:"address"},{indexed:!1,name:"value",type:"uint256"}],name:"AnonymousDeposit",type:"event"},{anonymous:!1,inputs:[{indexed:!0,name:"from",type:"address"},{indexed:!0,name:"to",type:"bytes32"},{indexed:!1,name:"value",type:"uint256"}],name:"Deposit",type:"event"},{anonymous:!1,inputs:[{indexed:!0,name:"from",type:"bytes32"},{indexed:!0,name:"to",type:"address"},{indexed:!1,name:"value",type:"uint256"}],name:"Transfer",type:"event"},{anonymous:!1,inputs:[{indexed:!0,name:"from",type:"bytes32"},{indexed:!0,name:"to",type:"address"},{indexed:!1,name:"indirectId",type:"bytes32"},{indexed:!1,name:"value",type:"uint256"}],name:"IcapTransfer",type:"event"}]; +},{}],"JAe5":[function(require,module,exports) { +var r=require("./iban"),n=require("../contracts/SmartExchange.json"),i=function(n,i,a,o,s){var u=new r(a);if(!u.isValid())throw new Error("invalid iban address");if(u.isDirect())return e(n,i,u.address(),o,s);if(!s){var c=n.icapNamereg().addr(u.institution());return t(n,i,c,o,u.client())}n.icapNamereg().addr(u.institution(),function(r,e){return t(n,i,e,o,u.client(),s)})},e=function(r,n,i,e,t){return r.sendTransaction({address:i,from:n,value:e},t)},t=function(r,i,e,t,a,o){var s=n;return r.contract(s).at(e).deposit(a,{from:i,value:t},o)};module.exports=i; +},{"./iban":"JSHq","../contracts/SmartExchange.json":"iGcT"}],"mNYm":[function(require,module,exports) { +"use strict";var t=require("../formatters"),e=require("../../utils/utils"),r=require("../method"),a=require("../property"),n=require("../../utils/config"),o=require("../contract"),u=require("./watches"),i=require("../filter"),c=require("../syncing"),m=require("../namereg"),l=require("../iban"),s=require("../transfer"),p=function(t){return e.isString(t[0])&&0===t[0].indexOf("0x")?"eth_getBlockByHash":"eth_getBlockByNumber"},g=function(t){return e.isString(t[0])&&0===t[0].indexOf("0x")?"eth_getTransactionByBlockHashAndIndex":"eth_getTransactionByBlockNumberAndIndex"},h=function(t){return e.isString(t[0])&&0===t[0].indexOf("0x")?"eth_getUncleByBlockHashAndIndex":"eth_getUncleByBlockNumberAndIndex"},F=function(t){return e.isString(t[0])&&0===t[0].indexOf("0x")?"eth_getBlockTransactionCountByHash":"eth_getBlockTransactionCountByNumber"},f=function(t){return e.isString(t[0])&&0===t[0].indexOf("0x")?"eth_getUncleCountByBlockHash":"eth_getUncleCountByBlockNumber"};function B(t){this._requestManager=t._requestManager;var e=this;d().forEach(function(t){t.attachToObject(e),t.setRequestManager(e._requestManager)}),_().forEach(function(t){t.attachToObject(e),t.setRequestManager(e._requestManager)}),this.iban=l,this.sendIBANTransaction=s.bind(null,this)}Object.defineProperty(B.prototype,"defaultBlock",{get:function(){return n.defaultBlock},set:function(t){return n.defaultBlock=t,t}}),Object.defineProperty(B.prototype,"defaultAccount",{get:function(){return n.defaultAccount},set:function(t){return n.defaultAccount=t,t}});var d=function(){var a=new r({name:"getBalance",call:"eth_getBalance",params:2,inputFormatter:[t.inputAddressFormatter,t.inputDefaultBlockNumberFormatter],outputFormatter:t.outputBigNumberFormatter}),n=new r({name:"getStorageAt",call:"eth_getStorageAt",params:3,inputFormatter:[null,e.toHex,t.inputDefaultBlockNumberFormatter]}),o=new r({name:"getCode",call:"eth_getCode",params:2,inputFormatter:[t.inputAddressFormatter,t.inputDefaultBlockNumberFormatter]}),u=new r({name:"getBlock",call:p,params:2,inputFormatter:[t.inputBlockNumberFormatter,function(t){return!!t}],outputFormatter:t.outputBlockFormatter}),i=new r({name:"getUncle",call:h,params:2,inputFormatter:[t.inputBlockNumberFormatter,e.toHex],outputFormatter:t.outputBlockFormatter}),c=new r({name:"getCompilers",call:"eth_getCompilers",params:0}),m=new r({name:"getBlockTransactionCount",call:F,params:1,inputFormatter:[t.inputBlockNumberFormatter],outputFormatter:e.toDecimal}),l=new r({name:"getBlockUncleCount",call:f,params:1,inputFormatter:[t.inputBlockNumberFormatter],outputFormatter:e.toDecimal}),s=new r({name:"getTransaction",call:"eth_getTransactionByHash",params:1,outputFormatter:t.outputTransactionFormatter}),B=new r({name:"getTransactionFromBlock",call:g,params:2,inputFormatter:[t.inputBlockNumberFormatter,e.toHex],outputFormatter:t.outputTransactionFormatter}),d=new r({name:"getTransactionReceipt",call:"eth_getTransactionReceipt",params:1,outputFormatter:t.outputTransactionReceiptFormatter}),_=new r({name:"getTransactionCount",call:"eth_getTransactionCount",params:2,inputFormatter:[null,t.inputDefaultBlockNumberFormatter],outputFormatter:e.toDecimal}),w=new r({name:"sendRawTransaction",call:"eth_sendRawTransaction",params:1,inputFormatter:[null]}),b=new r({name:"sendTransaction",call:"eth_sendTransaction",params:1,inputFormatter:[t.inputTransactionFormatter]}),k=new r({name:"signTransaction",call:"eth_signTransaction",params:1,inputFormatter:[t.inputTransactionFormatter]}),y=new r({name:"sign",call:"eth_sign",params:2,inputFormatter:[t.inputAddressFormatter,null]});return[a,n,o,u,i,c,m,l,s,B,d,_,new r({name:"call",call:"eth_call",params:2,inputFormatter:[t.inputCallFormatter,t.inputDefaultBlockNumberFormatter]}),new r({name:"estimateGas",call:"eth_estimateGas",params:1,inputFormatter:[t.inputCallFormatter],outputFormatter:e.toDecimal}),w,k,b,y,new r({name:"compile.solidity",call:"eth_compileSolidity",params:1}),new r({name:"compile.lll",call:"eth_compileLLL",params:1}),new r({name:"compile.serpent",call:"eth_compileSerpent",params:1}),new r({name:"submitWork",call:"eth_submitWork",params:3}),new r({name:"getWork",call:"eth_getWork",params:0})]},_=function(){return[new a({name:"coinbase",getter:"eth_coinbase"}),new a({name:"mining",getter:"eth_mining"}),new a({name:"hashrate",getter:"eth_hashrate",outputFormatter:e.toDecimal}),new a({name:"syncing",getter:"eth_syncing",outputFormatter:t.outputSyncingFormatter}),new a({name:"gasPrice",getter:"eth_gasPrice",outputFormatter:t.outputBigNumberFormatter}),new a({name:"accounts",getter:"eth_accounts"}),new a({name:"blockNumber",getter:"eth_blockNumber",outputFormatter:e.toDecimal}),new a({name:"protocolVersion",getter:"eth_protocolVersion"})]};B.prototype.contract=function(t){return new o(this,t)},B.prototype.filter=function(e,r,a){return new i(e,"eth",this._requestManager,u.eth(),t.outputLogFormatter,r,a)},B.prototype.namereg=function(){return this.contract(m.global.abi).at(m.global.address)},B.prototype.icapNamereg=function(){return this.contract(m.icap.abi).at(m.icap.address)},B.prototype.isSyncing=function(t){return new c(this._requestManager,t)},module.exports=B; +},{"../formatters":"ra15","../../utils/utils":"Fh47","../method":"Gyz6","../property":"H1O0","../../utils/config":"hHtK","../contract":"lrYx","./watches":"Xvr8","../filter":"kB3J","../syncing":"R3EG","../namereg":"DSD0","../iban":"JSHq","../transfer":"JAe5"}],"wvNc":[function(require,module,exports) { +var e=require("../method"),a=function(e){this._requestManager=e._requestManager;var a=this;t().forEach(function(t){t.attachToObject(a),t.setRequestManager(e._requestManager)})},t=function(){return[new e({name:"putString",call:"db_putString",params:3}),new e({name:"getString",call:"db_getString",params:2}),new e({name:"putHex",call:"db_putHex",params:3}),new e({name:"getHex",call:"db_getHex",params:2})]};module.exports=a; +},{"../method":"Gyz6"}],"QZdx":[function(require,module,exports) { +var e=require("../method"),a=require("../filter"),s=require("./watches"),r=function(e){this._requestManager=e._requestManager;var a=this;n().forEach(function(e){e.attachToObject(a),e.setRequestManager(a._requestManager)})};r.prototype.newMessageFilter=function(e,r,n){return new a(e,"shh",this._requestManager,s.shh(),null,r,n)};var n=function(){return[new e({name:"version",call:"shh_version",params:0}),new e({name:"info",call:"shh_info",params:0}),new e({name:"setMaxMessageSize",call:"shh_setMaxMessageSize",params:1}),new e({name:"setMinPoW",call:"shh_setMinPoW",params:1}),new e({name:"markTrustedPeer",call:"shh_markTrustedPeer",params:1}),new e({name:"newKeyPair",call:"shh_newKeyPair",params:0}),new e({name:"addPrivateKey",call:"shh_addPrivateKey",params:1}),new e({name:"deleteKeyPair",call:"shh_deleteKeyPair",params:1}),new e({name:"hasKeyPair",call:"shh_hasKeyPair",params:1}),new e({name:"getPublicKey",call:"shh_getPublicKey",params:1}),new e({name:"getPrivateKey",call:"shh_getPrivateKey",params:1}),new e({name:"newSymKey",call:"shh_newSymKey",params:0}),new e({name:"addSymKey",call:"shh_addSymKey",params:1}),new e({name:"generateSymKeyFromPassword",call:"shh_generateSymKeyFromPassword",params:1}),new e({name:"hasSymKey",call:"shh_hasSymKey",params:1}),new e({name:"getSymKey",call:"shh_getSymKey",params:1}),new e({name:"deleteSymKey",call:"shh_deleteSymKey",params:1}),new e({name:"post",call:"shh_post",params:1,inputFormatter:[null]})]};module.exports=r; +},{"../method":"Gyz6","../filter":"kB3J","./watches":"Xvr8"}],"NpaX":[function(require,module,exports) { +var e=require("../../utils/utils"),t=require("../property"),r=function(e){this._requestManager=e._requestManager;var t=this;n().forEach(function(r){r.attachToObject(t),r.setRequestManager(e._requestManager)})},n=function(){return[new t({name:"listening",getter:"net_listening"}),new t({name:"peerCount",getter:"net_peerCount",outputFormatter:e.toDecimal})]};module.exports=r; +},{"../../utils/utils":"Fh47","../property":"H1O0"}],"T1z5":[function(require,module,exports) { +"use strict";var e=require("../method"),n=require("../property"),a=require("../formatters");function r(e){this._requestManager=e._requestManager;var n=this;t().forEach(function(e){e.attachToObject(n),e.setRequestManager(n._requestManager)}),o().forEach(function(e){e.attachToObject(n),e.setRequestManager(n._requestManager)})}var t=function(){var n=new e({name:"newAccount",call:"personal_newAccount",params:1,inputFormatter:[null]}),r=new e({name:"importRawKey",call:"personal_importRawKey",params:2}),t=new e({name:"sign",call:"personal_sign",params:3,inputFormatter:[null,a.inputAddressFormatter,null]}),o=new e({name:"ecRecover",call:"personal_ecRecover",params:2});return[n,r,new e({name:"unlockAccount",call:"personal_unlockAccount",params:3,inputFormatter:[a.inputAddressFormatter,null,null]}),o,t,new e({name:"sendTransaction",call:"personal_sendTransaction",params:2,inputFormatter:[a.inputTransactionFormatter,null]}),new e({name:"lockAccount",call:"personal_lockAccount",params:1,inputFormatter:[a.inputAddressFormatter]})]},o=function(){return[new n({name:"listAccounts",getter:"personal_listAccounts"})]};module.exports=r; +},{"../method":"Gyz6","../property":"H1O0","../formatters":"ra15"}],"bPYj":[function(require,module,exports) { +"use strict";var e=require("../method"),a=require("../property");function n(e){this._requestManager=e._requestManager;var a=this;t().forEach(function(e){e.attachToObject(a),e.setRequestManager(a._requestManager)}),r().forEach(function(e){e.attachToObject(a),e.setRequestManager(a._requestManager)})}var t=function(){return[new e({name:"blockNetworkRead",call:"bzz_blockNetworkRead",params:1,inputFormatter:[null]}),new e({name:"syncEnabled",call:"bzz_syncEnabled",params:1,inputFormatter:[null]}),new e({name:"swapEnabled",call:"bzz_swapEnabled",params:1,inputFormatter:[null]}),new e({name:"download",call:"bzz_download",params:2,inputFormatter:[null,null]}),new e({name:"upload",call:"bzz_upload",params:2,inputFormatter:[null,null]}),new e({name:"retrieve",call:"bzz_retrieve",params:1,inputFormatter:[null]}),new e({name:"store",call:"bzz_store",params:2,inputFormatter:[null,null]}),new e({name:"get",call:"bzz_get",params:1,inputFormatter:[null]}),new e({name:"put",call:"bzz_put",params:2,inputFormatter:[null,null]}),new e({name:"modify",call:"bzz_modify",params:4,inputFormatter:[null,null,null,null]})]},r=function(){return[new a({name:"hive",getter:"bzz_hive"}),new a({name:"info",getter:"bzz_info"})]};module.exports=n; +},{"../method":"Gyz6","../property":"H1O0"}],"vHQl":[function(require,module,exports) { +var t=function(){this.defaultBlock="latest",this.defaultAccount=void 0};module.exports=t; +},{}],"Epl9":[function(require,module,exports) { +module.exports={version:"0.20.7"}; +},{}],"JnL1":[function(require,module,exports) { +var e=require("./formatters"),r=require("./../utils/utils"),t=require("./method"),o=require("./property"),a=function(a){var u=function(e){var r;e.property?(a[e.property]||(a[e.property]={}),r=a[e.property]):r=a,e.methods&&e.methods.forEach(function(e){e.attachToObject(r),e.setRequestManager(a._requestManager)}),e.properties&&e.properties.forEach(function(e){e.attachToObject(r),e.setRequestManager(a._requestManager)})};return u.formatters=e,u.utils=r,u.Method=t,u.Property=o,u};module.exports=a; +},{"./formatters":"ra15","./../utils/utils":"Fh47","./method":"Gyz6","./property":"H1O0"}],"Gofy":[function(require,module,exports) { +var e=require("./jsonrpc"),r=require("./errors"),t=function(e){this.requestManager=e._requestManager,this.requests=[]};t.prototype.add=function(e){this.requests.push(e)},t.prototype.execute=function(){var t=this.requests;this.requestManager.sendBatch(t,function(s,n){n=n||[],t.map(function(e,r){return n[r]||{}}).forEach(function(s,n){if(t[n].callback){if(!e.isValidResponse(s))return t[n].callback(r.InvalidResponse(s));t[n].callback(null,t[n].format?t[n].format(s.result):s.result)}})})},module.exports=t; +},{"./jsonrpc":"Fylb","./errors":"NewK"}],"HLYo":[function(require,module,exports) { +"use strict";"undefined"==typeof XMLHttpRequest?exports.XMLHttpRequest={}:exports.XMLHttpRequest=XMLHttpRequest; +},{}],"MtmI":[function(require,module,exports) { +var global = arguments[3]; +var r,e=arguments[3];exports.fetch=s(e.fetch)&&s(e.ReadableStream),exports.writableStream=s(e.WritableStream),exports.abortController=s(e.AbortController),exports.blobConstructor=!1;try{new Blob([new ArrayBuffer(1)]),exports.blobConstructor=!0}catch(f){}function t(){if(void 0!==r)return r;if(e.XMLHttpRequest){r=new e.XMLHttpRequest;try{r.open("GET",e.XDomainRequest?"/":"https://example.com")}catch(f){r=null}}else r=null;return r}function o(r){var e=t();if(!e)return!1;try{return e.responseType=r,e.responseType===r}catch(f){}return!1}var a=void 0!==e.ArrayBuffer,n=a&&s(e.ArrayBuffer.prototype.slice);function s(r){return"function"==typeof r}exports.arraybuffer=exports.fetch||a&&o("arraybuffer"),exports.msstream=!exports.fetch&&n&&o("ms-stream"),exports.mozchunkedarraybuffer=!exports.fetch&&a&&o("moz-chunked-arraybuffer"),exports.overrideMimeType=exports.fetch||!!t()&&s(t().overrideMimeType),exports.vbArray=s(e.VBArray),r=null; +},{}],"K8Qe":[function(require,module,exports) { +var process = require("process"); +var Buffer = require("buffer").Buffer; +var global = arguments[3]; +var e=require("process"),r=require("buffer").Buffer,t=arguments[3],s=require("./capability"),a=require("inherits"),o=require("readable-stream"),n=exports.readyStates={UNSENT:0,OPENED:1,HEADERS_RECEIVED:2,LOADING:3,DONE:4},u=exports.IncomingMessage=function(a,n,u,i){var c=this;if(o.Readable.call(c),c._mode=u,c.headers={},c.rawHeaders=[],c.trailers={},c.rawTrailers=[],c.on("end",function(){e.nextTick(function(){c.emit("close")})}),"fetch"===u){if(c._fetchResponse=n,c.url=n.url,c.statusCode=n.status,c.statusMessage=n.statusText,n.headers.forEach(function(e,r){c.headers[r.toLowerCase()]=e,c.rawHeaders.push(r,e)}),s.writableStream){var d=new WritableStream({write:function(e){return new Promise(function(t,s){c._destroyed?s():c.push(new r(e))?t():c._resumeFetch=t})},close:function(){t.clearTimeout(i),c._destroyed||c.push(null)},abort:function(e){c._destroyed||c.emit("error",e)}});try{return void n.body.pipeTo(d).catch(function(e){t.clearTimeout(i),c._destroyed||c.emit("error",e)})}catch(p){}}var h=n.body.getReader();!function e(){h.read().then(function(s){if(!c._destroyed){if(s.done)return t.clearTimeout(i),void c.push(null);c.push(new r(s.value)),e()}}).catch(function(e){t.clearTimeout(i),c._destroyed||c.emit("error",e)})}()}else{if(c._xhr=a,c._pos=0,c.url=a.responseURL,c.statusCode=a.status,c.statusMessage=a.statusText,a.getAllResponseHeaders().split(/\r?\n/).forEach(function(e){var r=e.match(/^([^:]+):\s*(.*)/);if(r){var t=r[1].toLowerCase();"set-cookie"===t?(void 0===c.headers[t]&&(c.headers[t]=[]),c.headers[t].push(r[2])):void 0!==c.headers[t]?c.headers[t]+=", "+r[2]:c.headers[t]=r[2],c.rawHeaders.push(r[1],r[2])}}),c._charset="x-user-defined",!s.overrideMimeType){var f=c.rawHeaders["mime-type"];if(f){var l=f.match(/;\s*charset=([^;])(;|$)/);l&&(c._charset=l[1].toLowerCase())}c._charset||(c._charset="utf-8")}}};a(u,o.Readable),u.prototype._read=function(){var e=this._resumeFetch;e&&(this._resumeFetch=null,e())},u.prototype._onXHRProgress=function(){var e=this,s=e._xhr,a=null;switch(e._mode){case"text:vbarray":if(s.readyState!==n.DONE)break;try{a=new t.VBArray(s.responseBody).toArray()}catch(d){}if(null!==a){e.push(new r(a));break}case"text":try{a=s.responseText}catch(d){e._mode="text:vbarray";break}if(a.length>e._pos){var o=a.substr(e._pos);if("x-user-defined"===e._charset){for(var u=new r(o.length),i=0;ie._pos&&(e.push(new r(new Uint8Array(c.result.slice(e._pos)))),e._pos=c.result.byteLength)},c.onload=function(){e.push(null)},c.readAsArrayBuffer(a)}e._xhr.readyState===n.DONE&&"ms-stream"!==e._mode&&e.push(null)}; +},{"./capability":"MtmI","inherits":"oxwV","readable-stream":"YvpY","process":"g5IB","buffer":"z1tx"}],"xHkA":[function(require,module,exports) { + +var e=require("buffer").Buffer;module.exports=function(f){if(f instanceof Uint8Array){if(0===f.byteOffset&&f.byteLength===f.buffer.byteLength)return f.buffer;if("function"==typeof f.buffer.slice)return f.buffer.slice(f.byteOffset,f.byteOffset+f.byteLength)}if(e.isBuffer(f)){for(var r=new Uint8Array(f.length),t=f.length,n=0;n= 0x80 (not a basic code point)","invalid-input":"Invalid input"},C=l-s,b=Math.floor,j=String.fromCharCode;function A(o){throw new RangeError(m[o])}function I(o,e){for(var n=o.length,r=[];n--;)r[n]=e(o[n]);return r}function E(o,e){var n=o.split("@"),r="";return n.length>1&&(r=n[0]+"@",o=n[1]),r+I((o=o.replace(y,".")).split("."),e).join(".")}function F(o){for(var e,n,r=[],t=0,u=o.length;t=55296&&e<=56319&&t65535&&(e+=j((o-=65536)>>>10&1023|55296),o=56320|1023&o),e+=j(o)}).join("")}function S(o,e){return o+22+75*(o<26)-((0!=e)<<5)}function T(o,e,n){var r=0;for(o=n?b(o/d):o>>1,o+=b(o/e);o>C*p>>1;r+=l)o=b(o/C);return b(r+(C+1)*o/(o+a))}function L(o){var e,n,r,t,u,i,f,a,d,w,x,y=[],m=o.length,C=0,j=v,I=h;for((n=o.lastIndexOf(g))<0&&(n=0),r=0;r=128&&A("not-basic"),y.push(o.charCodeAt(r));for(t=n>0?n+1:0;t=m&&A("invalid-input"),((a=(x=o.charCodeAt(t++))-48<10?x-22:x-65<26?x-65:x-97<26?x-97:l)>=l||a>b((c-C)/i))&&A("overflow"),C+=a*i,!(a<(d=f<=I?s:f>=I+p?p:f-I));f+=l)i>b(c/(w=l-d))&&A("overflow"),i*=w;I=T(C-u,e=y.length+1,0==u),b(C/e)>c-j&&A("overflow"),j+=b(C/e),C%=e,y.splice(C++,0,j)}return O(y)}function M(o){var e,n,r,t,u,i,f,a,d,w,x,y,m,C,I,E=[];for(y=(o=F(o)).length,e=v,n=0,u=h,i=0;i=e&&xb((c-n)/(m=r+1))&&A("overflow"),n+=(f-e)*m,e=f,i=0;ic&&A("overflow"),x==e){for(a=n,d=l;!(a<(w=d<=u?s:d>=u+p?p:d-u));d+=l)I=a-w,C=l-w,E.push(j(S(w+I%C,0))),a=b(I/C);E.push(j(S(a,0))),u=T(n,m,r==t),n=0,++r}++n,++e}return E.join("")}if(i={version:"1.4.1",ucs2:{decode:F,encode:O},decode:L,encode:M,toASCII:function(o){return E(o,function(o){return x.test(o)?"xn--"+M(o):o})},toUnicode:function(o){return E(o,function(o){return w.test(o)?L(o.slice(4).toLowerCase()):o})}},"function"==typeof o&&"object"==typeof o.amd&&o.amd)o("punycode",function(){return i});else if(r&&t)if(module.exports==r)t.exports=i;else for(f in i)i.hasOwnProperty(f)&&(r[f]=i[f]);else n.punycode=i}(this); +},{}],"oHuK":[function(require,module,exports) { +"use strict";module.exports={isString:function(n){return"string"==typeof n},isObject:function(n){return"object"==typeof n&&null!==n},isNull:function(n){return null===n},isNullOrUndefined:function(n){return null==n}}; +},{}],"ObZ4":[function(require,module,exports) { +"use strict";function r(r,e){return Object.prototype.hasOwnProperty.call(r,e)}module.exports=function(t,n,o,a){n=n||"&",o=o||"=";var s={};if("string"!=typeof t||0===t.length)return s;var p=/\+/g;t=t.split(n);var u=1e3;a&&"number"==typeof a.maxKeys&&(u=a.maxKeys);var c=t.length;u>0&&c>u&&(c=u);for(var i=0;i=0?(y=b.substr(0,d),l=b.substr(d+1)):(y=b,l=""),f=decodeURIComponent(y),v=decodeURIComponent(l),r(s,f)?e(s[f])?s[f].push(v):s[f]=[s[f],v]:s[f]=v}return s};var e=Array.isArray||function(r){return"[object Array]"===Object.prototype.toString.call(r)}; +},{}],"XK8a":[function(require,module,exports) { +"use strict";var n=function(n){switch(typeof n){case"string":return n;case"boolean":return n?"true":"false";case"number":return isFinite(n)?n:"";default:return""}};module.exports=function(o,u,c,a){return u=u||"&",c=c||"=",null===o&&(o=void 0),"object"==typeof o?r(t(o),function(t){var a=encodeURIComponent(n(t))+c;return e(o[t])?r(o[t],function(e){return a+encodeURIComponent(n(e))}).join(u):a+encodeURIComponent(n(o[t]))}).join(u):a?encodeURIComponent(n(a))+c+encodeURIComponent(n(o)):""};var e=Array.isArray||function(n){return"[object Array]"===Object.prototype.toString.call(n)};function r(n,e){if(n.map)return n.map(e);for(var r=[],t=0;t",'"',"`"," ","\r","\n","\t"],n=["{","}","|","\\","^","`"].concat(o),i=["'"].concat(n),l=["%","/","?",";","#"].concat(i),p=["/","?","#"],c=255,u=/^[+a-z0-9A-Z_-]{0,63}$/,f=/^([+a-z0-9A-Z_-]{0,63})(.*)$/,m={javascript:!0,"javascript:":!0},v={javascript:!0,"javascript:":!0},g={http:!0,https:!0,ftp:!0,gopher:!0,file:!0,"http:":!0,"https:":!0,"ftp:":!0,"gopher:":!0,"file:":!0},y=require("querystring");function b(t,e,a){if(t&&s.isObject(t)&&t instanceof h)return t;var r=new h;return r.parse(t,e,a),r}function q(t){return s.isString(t)&&(t=b(t)),t instanceof h?t.format():h.prototype.format.call(t)}function O(t,s){return b(t,!1,!0).resolve(s)}function d(t,s){return t?b(t,!1,!0).resolveObject(s):s}h.prototype.parse=function(h,a,o){if(!s.isString(h))throw new TypeError("Parameter 'url' must be a string, not "+typeof h);var n=h.indexOf("?"),b=-1!==n&&n127?z+="x":z+=$[H];if(!z.match(u)){var Z=R.slice(0,U),_=R.slice(U+1),E=$.match(f);E&&(Z.push(E[1]),_.unshift(E[2])),_.length&&(O="/"+_.join(".")+O),this.hostname=Z.join(".");break}}}this.hostname.length>c?this.hostname="":this.hostname=this.hostname.toLowerCase(),N||(this.hostname=t.toASCII(this.hostname));var P=this.port?":"+this.port:"",T=this.hostname||"";this.host=T+P,this.href+=this.host,N&&(this.hostname=this.hostname.substr(1,this.hostname.length-2),"/"!==O[0]&&(O="/"+O))}if(!m[x])for(U=0,S=i.length;U0)&&a.host.split("@"))&&(a.auth=k.shift(),a.host=a.hostname=k.shift());return a.search=t.search,a.query=t.query,s.isNull(a.pathname)&&s.isNull(a.search)||(a.path=(a.pathname?a.pathname:"")+(a.search?a.search:"")),a.href=a.format(),a}if(!x.length)return a.pathname=null,a.search?a.path="/"+a.search:a.path=null,a.href=a.format(),a;for(var C=x.slice(-1)[0],I=(a.host||t.host||x.length>1)&&("."===C||".."===C)||""===C,w=0,U=x.length;U>=0;U--)"."===(C=x[U])?x.splice(U,1):".."===C?(x.splice(U,1),w++):w&&(x.splice(U,1),w--);if(!d&&!j)for(;w--;w)x.unshift("..");!d||""===x[0]||x[0]&&"/"===x[0].charAt(0)||x.unshift(""),I&&"/"!==x.join("/").substr(-1)&&x.push("");var k,N=""===x[0]||x[0]&&"/"===x[0].charAt(0);A&&(a.hostname=a.host=N?"":x.length?x.shift():"",(k=!!(a.host&&a.host.indexOf("@")>0)&&a.host.split("@"))&&(a.auth=k.shift(),a.host=a.hostname=k.shift()));return(d=d||a.host&&x.length)&&!N&&x.unshift(""),x.length?a.pathname=x.join("/"):(a.pathname=null,a.path=null),s.isNull(a.pathname)&&s.isNull(a.search)||(a.path=(a.pathname?a.pathname:"")+(a.search?a.search:"")),a.auth=t.auth||a.auth,a.slashes=a.slashes||t.slashes,a.href=a.format(),a},h.prototype.parseHost=function(){var t=this.host,s=a.exec(t);s&&(":"!==(s=s[0])&&(this.port=s.substr(1)),t=t.substr(0,t.length-s.length)),t&&(this.hostname=t)}; +},{"punycode":"GVK8","./util":"oHuK","querystring":"SF0R"}],"f2Kk":[function(require,module,exports) { +var global = arguments[3]; +var e=arguments[3],t=require("./lib/request"),r=require("./lib/response"),n=require("xtend"),o=require("builtin-status-codes"),s=require("url"),u=exports;u.request=function(r,o){r="string"==typeof r?s.parse(r):n(r);var u=-1===e.location.protocol.search(/^https?:$/)?"http:":"",E=r.protocol||u,a=r.hostname||r.host,C=r.port,i=r.path||"/";a&&-1!==a.indexOf(":")&&(a="["+a+"]"),r.url=(a?E+"//"+a:"")+(C?":"+C:"")+i,r.method=(r.method||"GET").toUpperCase(),r.headers=r.headers||{};var T=new t(r);return o&&T.on("response",o),T},u.get=function(e,t){var r=u.request(e,t);return r.end(),r},u.ClientRequest=t,u.IncomingMessage=r.IncomingMessage,u.Agent=function(){},u.Agent.defaultMaxSockets=4,u.globalAgent=new u.Agent,u.STATUS_CODES=o,u.METHODS=["CHECKOUT","CONNECT","COPY","DELETE","GET","HEAD","LOCK","M-SEARCH","MERGE","MKACTIVITY","MKCOL","MOVE","NOTIFY","OPTIONS","PATCH","POST","PROPFIND","PROPPATCH","PURGE","PUT","REPORT","SEARCH","SUBSCRIBE","TRACE","UNLOCK","UNSUBSCRIBE"]; +},{"./lib/request":"p71p","./lib/response":"K8Qe","xtend":"YxsI","builtin-status-codes":"XmSl","url":"j37I"}],"AHs6":[function(require,module,exports) { +var t=require("http"),r=require("url"),o=module.exports;for(var e in t)t.hasOwnProperty(e)&&(o[e]=t[e]);function p(t){if("string"==typeof t&&(t=r.parse(t)),t.protocol||(t.protocol="https:"),"https:"!==t.protocol)throw new Error('Protocol "'+t.protocol+'" not supported. Expected "https:"');return t}o.request=function(r,o){return r=p(r),t.request.call(this,r,o)},o.get=function(r,o){return r=p(r),t.get.call(this,r,o)}; +},{"http":"f2Kk","url":"j37I"}],"war4":[function(require,module,exports) { +exports.endianness=function(){return"LE"},exports.hostname=function(){return"undefined"!=typeof location?location.hostname:""},exports.loadavg=function(){return[]},exports.uptime=function(){return 0},exports.freemem=function(){return Number.MAX_VALUE},exports.totalmem=function(){return Number.MAX_VALUE},exports.cpus=function(){return[]},exports.type=function(){return"Browser"},exports.release=function(){return"undefined"!=typeof navigator?navigator.appVersion:""},exports.networkInterfaces=exports.getNetworkInterfaces=function(){return{}},exports.arch=function(){return"javascript"},exports.platform=function(){return"browser"},exports.tmpdir=exports.tmpDir=function(){return"/tmp"},exports.EOL="\n",exports.homedir=function(){return"/"}; +},{}],"qJpg":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var t=function(){return function(t){this.type=t,this.bubbles=!1,this.cancelable=!1,this.loaded=0,this.lengthComputable=!1,this.total=0}}();exports.ProgressEvent=t; +},{}],"sM29":[function(require,module,exports) { +"use strict";var r=this&&this.__extends||function(){var r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(r,t){r.__proto__=t}||function(r,t){for(var n in t)t.hasOwnProperty(n)&&(r[n]=t[n])};return function(t,n){function o(){this.constructor=t}r(t,n),t.prototype=null===n?Object.create(n):(o.prototype=n.prototype,new o)}}();Object.defineProperty(exports,"__esModule",{value:!0});var t=function(t){function n(){return null!==t&&t.apply(this,arguments)||this}return r(n,t),n}(Error);exports.SecurityError=t;var n=function(t){function n(){return null!==t&&t.apply(this,arguments)||this}return r(n,t),n}(Error);exports.InvalidStateError=n;var o=function(t){function n(){return null!==t&&t.apply(this,arguments)||this}return r(n,t),n}(Error);exports.NetworkError=o;var e=function(t){function n(){return null!==t&&t.apply(this,arguments)||this}return r(n,t),n}(Error);exports.SyntaxError=e; +},{}],"s4kV":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=function(){function e(){this.listeners={}}return e.prototype.addEventListener=function(e,t){e=e.toLowerCase(),this.listeners[e]=this.listeners[e]||[],this.listeners[e].push(t.handleEvent||t)},e.prototype.removeEventListener=function(e,t){if(e=e.toLowerCase(),this.listeners[e]){var s=this.listeners[e].indexOf(t.handleEvent||t);s<0||this.listeners[e].splice(s,1)}},e.prototype.dispatchEvent=function(e){var t=e.type.toLowerCase();if(e.target=this,this.listeners[t])for(var s=0,i=this.listeners[t];s=0)return this._url=this._parseUrl(t.headers.location),this._method="GET",this._loweredHeaders["content-type"]&&(delete this._headers[this._loweredHeaders["content-type"]],delete this._loweredHeaders["content-type"]),null!=this._headers["Content-Type"]&&delete this._headers["Content-Type"],delete this._headers["Content-Length"],this.upload._reset(),this._finalizeHeaders(),void this._sendHxxpRequest();this._response=t,this._response.on("data",function(e){return s._onHttpResponseData(t,e)}),this._response.on("end",function(){return s._onHttpResponseEnd(t)}),this._response.on("close",function(){return s._onHttpResponseClose(t)}),this.responseUrl=this._url.href.split("#")[0],this.status=t.statusCode,this.statusText=o.STATUS_CODES[this.status],this._parseResponseHeaders(t);var r=this._responseHeaders["content-length"]||"";this._totalBytes=+r,this._lengthComputable=!!r,this._setReadyState(_.HEADERS_RECEIVED)}},_.prototype._onHttpResponseData=function(e,s){this._response===e&&(this._responseParts.push(new t(s)),this._loadedBytes+=s.length,this.readyState!==_.LOADING&&this._setReadyState(_.LOADING),this._dispatchProgress("progress"))},_.prototype._onHttpResponseEnd=function(e){this._response===e&&(this._parseResponse(),this._request=null,this._response=null,this._setReadyState(_.DONE),this._dispatchProgress("load"),this._dispatchProgress("loadend"))},_.prototype._onHttpResponseClose=function(e){if(this._response===e){var t=this._request;this._setError(),t.abort(),this._setReadyState(_.DONE),this._dispatchProgress("error"),this._dispatchProgress("loadend")}},_.prototype._onHttpTimeout=function(e){this._request===e&&(this._setError(),e.abort(),this._setReadyState(_.DONE),this._dispatchProgress("timeout"),this._dispatchProgress("loadend"))},_.prototype._onHttpRequestError=function(e,t){this._request===e&&(this._setError(),e.abort(),this._setReadyState(_.DONE),this._dispatchProgress("error"),this._dispatchProgress("loadend"))},_.prototype._dispatchProgress=function(e){var t=new _.ProgressEvent(e);t.lengthComputable=this._lengthComputable,t.loaded=this._loadedBytes,t.total=this._totalBytes,this.dispatchEvent(t)},_.prototype._setError=function(){this._request=null,this._response=null,this._responseHeaders=null,this._responseParts=null},_.prototype._parseUrl=function(e,t,s){var r=null==this.nodejsBaseUrl?e:a.resolve(this.nodejsBaseUrl,e),o=a.parse(r,!1,!0);o.hash=null;var n=(o.auth||"").split(":"),i=n[0],h=n[1];return(i||h||t||s)&&(o.auth=(t||i||"")+":"+(s||h||"")),o},_.prototype._parseResponseHeaders=function(e){for(var t in this._responseHeaders={},e.headers){var s=t.toLowerCase();this._privateHeaders[s]||(this._responseHeaders[s]=e.headers[t])}null!=this._mimeOverride&&(this._responseHeaders["content-type"]=this._mimeOverride)},_.prototype._parseResponse=function(){var e=t.concat(this._responseParts);switch(this._responseParts=null,this.responseType){case"json":this.responseText=null;try{this.response=JSON.parse(e.toString("utf-8"))}catch(n){this.response=null}return;case"buffer":return this.responseText=null,void(this.response=e);case"arraybuffer":this.responseText=null;for(var s=new ArrayBuffer(e.length),r=new Uint8Array(s),o=0;or.length)&&(e=r.length);for(var n=0,t=new Array(e);n=e?[]:new Array(e-r).fill().map(function(e,n){return n+r})}},{key:"hexToInt",value:function(r){return null==r?r:Number.parseInt(r,16)}},{key:"intToHex",value:function(r){return null==r?r:"0x"+r.toString(16)}},{key:"messageToBuffer",value:function(e){var n=r.Buffer.from([]);try{n="string"==typeof e?r.Buffer.from(e.replace("0x",""),"hex"):r.Buffer.from(e)}catch(t){console.log("messageToBuffer error: ".concat(t))}return n}},{key:"bufferToHex",value:function(e){return"0x"+r.Buffer.from(e).toString("hex")}}]),n}();module.exports=c; +},{"buffer":"z1tx"}],"sD6q":[function(require,module,exports) { +"use strict";var e=t(require("./utils"));function t(e){return e&&e.__esModule?e:{default:e}}function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){for(var n=0;n=194&&e[r]<=223){if(e[r+1]>>6==2){r+=2;continue}return!1}if((224===e[r]&&e[r+1]>=160&&e[r+1]<=191||237===e[r]&&e[r+1]>=128&&e[r+1]<=159)&&e[r+2]>>6==2)r+=3;else if((e[r]>=225&&e[r]<=236||e[r]>=238&&e[r]<=239)&&e[r+1]>>6==2&&e[r+2]>>6==2)r+=3;else{if(!(240===e[r]&&e[r+1]>=144&&e[r+1]<=191||e[r]>=241&&e[r]<=243&&e[r+1]>>6==2||244===e[r]&&e[r+1]>=128&&e[r+1]<=143)||e[r+2]>>6!=2||e[r+3]>>6!=2)return!1;r+=4}}return!0}Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=void 0;var r=e;exports.default=r; +},{}],"tsla":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var e=require("buffer").Buffer;function r(r){return function(t){var u=r();return u.update(t),e.from(u.digest())}}Object.defineProperty(exports,"__esModule",{value:!0}),exports.createHashFunction=r; +},{"buffer":"z1tx"}],"okQ5":[function(require,module,exports) { +"use strict";var r=require("../../../errors").codes.ERR_INVALID_OPT_VALUE;function e(r,e,t){return null!=r.highWaterMark?r.highWaterMark:e?r[t]:null}function t(t,i,o,a){var n=e(i,a,o);if(null!=n){if(!isFinite(n)||Math.floor(n)!==n||n<0)throw new r(a?o:"highWaterMark",n);return Math.floor(n)}return t.objectMode?16:16384}module.exports={getHighWaterMark:t}; +},{"../../../errors":"S4np"}],"h5fF":[function(require,module,exports) { + +var global = arguments[3]; +var process = require("process"); +var e,t=arguments[3],n=require("process");function r(e,t,n){this.chunk=e,this.encoding=t,this.callback=n,this.next=null}function i(e){var t=this;this.next=null,this.entry=null,this.finish=function(){G(t,e)}}module.exports=x,x.WritableState=m;var o={deprecate:require("util-deprecate")},s=require("./internal/streams/stream"),u=require("buffer").Buffer,f=t.Uint8Array||function(){};function a(e){return u.from(e)}function c(e){return u.isBuffer(e)||e instanceof f}var l,d=require("./internal/streams/destroy"),h=require("./internal/streams/state"),b=h.getHighWaterMark,p=require("../errors").codes,y=p.ERR_INVALID_ARG_TYPE,w=p.ERR_METHOD_NOT_IMPLEMENTED,g=p.ERR_MULTIPLE_CALLBACK,_=p.ERR_STREAM_CANNOT_PIPE,R=p.ERR_STREAM_DESTROYED,k=p.ERR_STREAM_NULL_VALUES,E=p.ERR_STREAM_WRITE_AFTER_END,S=p.ERR_UNKNOWN_ENCODING,q=d.errorOrDestroy;function v(){}function m(t,n,r){e=e||require("./_stream_duplex"),t=t||{},"boolean"!=typeof r&&(r=n instanceof e),this.objectMode=!!t.objectMode,r&&(this.objectMode=this.objectMode||!!t.writableObjectMode),this.highWaterMark=b(this,t,"writableHighWaterMark",r),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var o=!1===t.decodeStrings;this.decodeStrings=!o,this.defaultEncoding=t.defaultEncoding||"utf8",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(e){O(n,e)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.emitClose=!1!==t.emitClose,this.autoDestroy=!!t.autoDestroy,this.bufferedRequestCount=0,this.corkedRequestsFree=new i(this)}function x(t){var n=this instanceof(e=e||require("./_stream_duplex"));if(!n&&!l.call(x,this))return new x(t);this._writableState=new m(t,this,n),this.writable=!0,t&&("function"==typeof t.write&&(this._write=t.write),"function"==typeof t.writev&&(this._writev=t.writev),"function"==typeof t.destroy&&(this._destroy=t.destroy),"function"==typeof t.final&&(this._final=t.final)),s.call(this)}function M(e,t){var r=new E;q(e,r),n.nextTick(t,r)}function B(e,t,r,i){var o;return null===r?o=new k:"string"==typeof r||t.objectMode||(o=new y("chunk",["string","Buffer"],r)),!o||(q(e,o),n.nextTick(i,o),!1)}function T(e,t,n){return e.objectMode||!1===e.decodeStrings||"string"!=typeof t||(t=u.from(t,n)),t}function D(e,t,n,r,i,o){if(!n){var s=T(t,r,i);r!==s&&(n=!0,i="buffer",r=s)}var u=t.objectMode?1:r.length;t.length+=u;var f=t.length-1))throw new S(e);return this._writableState.defaultEncoding=e,this},Object.defineProperty(x.prototype,"writableBuffer",{enumerable:!1,get:function(){return this._writableState&&this._writableState.getBuffer()}}),Object.defineProperty(x.prototype,"writableHighWaterMark",{enumerable:!1,get:function(){return this._writableState.highWaterMark}}),x.prototype._write=function(e,t,n){n(new w("_write()"))},x.prototype._writev=null,x.prototype.end=function(e,t,n){var r=this._writableState;return"function"==typeof e?(n=e,e=null,t=null):"function"==typeof t&&(n=t,t=null),null!=e&&this.write(e,t),r.corked&&(r.corked=1,this.uncork()),r.ending||H(this,r,n),this},Object.defineProperty(x.prototype,"writableLength",{enumerable:!1,get:function(){return this._writableState.length}}),Object.defineProperty(x.prototype,"destroyed",{enumerable:!1,get:function(){return void 0!==this._writableState&&this._writableState.destroyed},set:function(e){this._writableState&&(this._writableState.destroyed=e)}}),x.prototype.destroy=d.destroy,x.prototype._undestroy=d.undestroy,x.prototype._destroy=function(e,t){t(e)}; +},{"util-deprecate":"hQaz","./internal/streams/stream":"XrGN","buffer":"z1tx","./internal/streams/destroy":"VglT","./internal/streams/state":"okQ5","../errors":"S4np","inherits":"oxwV","./_stream_duplex":"BJej","process":"g5IB"}],"BJej":[function(require,module,exports) { +var process = require("process"); +var e=require("process"),t=Object.keys||function(e){var t=[];for(var r in e)t.push(r);return t};module.exports=l;var r=require("./_stream_readable"),a=require("./_stream_writable");require("inherits")(l,r);for(var i=t(a.prototype),n=0;n0)if("string"==typeof t||o.objectMode||Object.getPrototypeOf(t)===d.prototype||(t=s(t)),r)o.endEmitted?M(e,new R):C(e,o,t,!0);else if(o.ended)M(e,new w);else{if(o.destroyed)return!1;o.reading=!1,o.decoder&&!n?(t=o.decoder.write(t),o.objectMode||0!==t.length?C(e,o,t,!1):U(e,o)):C(e,o,t,!1)}else r||(o.reading=!1,U(e,o));return!o.ended&&(o.length=q?e=q:(e--,e|=e>>>1,e|=e>>>2,e|=e>>>4,e|=e>>>8,e|=e>>>16,e++),e}function x(e,t){return e<=0||0===t.length&&t.ended?0:t.objectMode?1:e!=e?t.flowing&&t.length?t.buffer.head.data.length:t.length:(e>t.highWaterMark&&(t.highWaterMark=W(e)),e<=t.length?e:t.ended?t.length:(t.needReadable=!0,0))}function A(e,t){if(u("onEofChunk"),!t.ended){if(t.decoder){var n=t.decoder.end();n&&n.length&&(t.buffer.push(n),t.length+=t.objectMode?1:n.length)}t.ended=!0,t.sync?O(e):(t.needReadable=!1,t.emittedReadable||(t.emittedReadable=!0,P(e)))}}function O(e){var t=e._readableState;u("emitReadable",t.needReadable,t.emittedReadable),t.needReadable=!1,t.emittedReadable||(u("emitReadable",t.flowing),t.emittedReadable=!0,n.nextTick(P,e))}function P(e){var t=e._readableState;u("emitReadable_",t.destroyed,t.length,t.ended),t.destroyed||!t.length&&!t.ended||(e.emit("readable"),t.emittedReadable=!1),t.needReadable=!t.flowing&&!t.ended&&t.length<=t.highWaterMark,G(e)}function U(e,t){t.readingMore||(t.readingMore=!0,n.nextTick(N,e,t))}function N(e,t){for(;!t.reading&&!t.ended&&(t.length0,t.resumeScheduled&&!t.paused?t.flowing=!0:e.listenerCount("data")>0&&e.resume()}function F(e){u("readable nexttick read 0"),e.read(0)}function B(e,t){t.resumeScheduled||(t.resumeScheduled=!0,n.nextTick(V,e,t))}function V(e,t){u("resume",t.reading),t.reading||e.read(0),t.resumeScheduled=!1,e.emit("resume"),G(e),t.flowing&&!t.reading&&e.read(0)}function G(e){var t=e._readableState;for(u("flow",t.flowing);t.flowing&&null!==e.read(););}function Y(e,t){return 0===t.length?null:(t.objectMode?n=t.buffer.shift():!e||e>=t.length?(n=t.decoder?t.buffer.join(""):1===t.buffer.length?t.buffer.first():t.buffer.concat(t.length),t.buffer.clear()):n=t.buffer.consume(e,t.decoder),n);var n}function z(e){var t=e._readableState;u("endReadable",t.endEmitted),t.endEmitted||(t.ended=!0,n.nextTick(J,t,e))}function J(e,t){if(u("endReadableNT",e.endEmitted,e.length),!e.endEmitted&&0===e.length&&(e.endEmitted=!0,t.readable=!1,t.emit("end"),e.autoDestroy)){var n=t._writableState;(!n||n.autoDestroy&&n.finished)&&t.destroy()}}function K(e,t){for(var n=0,r=e.length;n=t.highWaterMark:t.length>0)||t.ended))return u("read: emitReadable",t.length,t.ended),0===t.length&&t.ended?z(this):O(this),null;if(0===(e=x(e,t))&&t.ended)return 0===t.length&&z(this),null;var r,i=t.needReadable;return u("need readable",i),(0===t.length||t.length-e0?Y(e,t):null)?(t.needReadable=t.length<=t.highWaterMark,e=0):(t.length-=e,t.awaitDrain=0),0===t.length&&(t.ended||(t.needReadable=!0),n!==e&&t.ended&&z(this)),null!==r&&this.emit("data",r),r},j.prototype._read=function(e){M(this,new S("_read()"))},j.prototype.pipe=function(e,t){var r=this,a=this._readableState;switch(a.pipesCount){case 0:a.pipes=e;break;case 1:a.pipes=[a.pipes,e];break;default:a.pipes.push(e)}a.pipesCount+=1,u("pipe count=%d opts=%j",a.pipesCount,t);var d=(!t||!1!==t.end)&&e!==n.stdout&&e!==n.stderr?s:g;function o(t,n){u("onunpipe"),t===r&&n&&!1===n.hasUnpiped&&(n.hasUnpiped=!0,u("cleanup"),e.removeListener("close",c),e.removeListener("finish",b),e.removeListener("drain",l),e.removeListener("error",f),e.removeListener("unpipe",o),r.removeListener("end",s),r.removeListener("end",g),r.removeListener("data",p),h=!0,!a.awaitDrain||e._writableState&&!e._writableState.needDrain||l())}function s(){u("onend"),e.end()}a.endEmitted?n.nextTick(d):r.once("end",d),e.on("unpipe",o);var l=H(r);e.on("drain",l);var h=!1;function p(t){u("ondata");var n=e.write(t);u("dest.write",n),!1===n&&((1===a.pipesCount&&a.pipes===e||a.pipesCount>1&&-1!==K(a.pipes,e))&&!h&&(u("false write response, pause",a.awaitDrain),a.awaitDrain++),r.pause())}function f(t){u("onerror",t),g(),e.removeListener("error",f),0===i(e,"error")&&M(e,t)}function c(){e.removeListener("finish",b),g()}function b(){u("onfinish"),e.removeListener("close",c),g()}function g(){u("unpipe"),r.unpipe(e)}return r.on("data",p),k(e,"error",f),e.once("close",c),e.once("finish",b),e.emit("pipe",r),a.flowing||(u("pipe resume"),r.resume()),e},j.prototype.unpipe=function(e){var t=this._readableState,n={hasUnpiped:!1};if(0===t.pipesCount)return this;if(1===t.pipesCount)return e&&e!==t.pipes?this:(e||(e=t.pipes),t.pipes=null,t.pipesCount=0,t.flowing=!1,e&&e.emit("unpipe",this,n),this);if(!e){var r=t.pipes,i=t.pipesCount;t.pipes=null,t.pipesCount=0,t.flowing=!1;for(var a=0;a0,!1!==i.flowing&&this.resume()):"readable"===e&&(i.endEmitted||i.readableListening||(i.readableListening=i.needReadable=!0,i.flowing=!1,i.emittedReadable=!1,u("on readable",i.length,i.reading),i.length?O(this):i.reading||n.nextTick(F,this))),r},j.prototype.addListener=j.prototype.on,j.prototype.removeListener=function(e,t){var r=a.prototype.removeListener.call(this,e,t);return"readable"===e&&n.nextTick(I,this),r},j.prototype.removeAllListeners=function(e){var t=a.prototype.removeAllListeners.apply(this,arguments);return"readable"!==e&&void 0!==e||n.nextTick(I,this),t},j.prototype.resume=function(){var e=this._readableState;return e.flowing||(u("resume"),e.flowing=!e.readableListening,B(this,e)),e.paused=!1,this},j.prototype.pause=function(){return u("call pause flowing=%j",this._readableState.flowing),!1!==this._readableState.flowing&&(u("pause"),this._readableState.flowing=!1,this.emit("pause")),this._readableState.paused=!0,this},j.prototype.wrap=function(e){var t=this,n=this._readableState,r=!1;for(var i in e.on("end",function(){if(u("wrapped end"),n.decoder&&!n.ended){var e=n.decoder.end();e&&e.length&&t.push(e)}t.push(null)}),e.on("data",function(i){(u("wrapped data"),n.decoder&&(i=n.decoder.write(i)),n.objectMode&&null==i)||(n.objectMode||i&&i.length)&&(t.push(i)||(r=!0,e.pause()))}),e)void 0===this[i]&&"function"==typeof e[i]&&(this[i]=function(t){return function(){return e[t].apply(e,arguments)}}(i));for(var a=0;a0,function(r){o||(o=r),r&&u.forEach(a),e||(u.forEach(a),i(o))})});return n.reduce(c)}module.exports=s; +},{"../../../errors":"S4np","./end-of-stream":"PGSG"}],"y2xL":[function(require,module,exports) { +exports=module.exports=require("./lib/_stream_readable.js"),exports.Stream=exports,exports.Readable=exports,exports.Writable=require("./lib/_stream_writable.js"),exports.Duplex=require("./lib/_stream_duplex.js"),exports.Transform=require("./lib/_stream_transform.js"),exports.PassThrough=require("./lib/_stream_passthrough.js"),exports.finished=require("./lib/internal/streams/end-of-stream.js"),exports.pipeline=require("./lib/internal/streams/pipeline.js"); +},{"./lib/_stream_readable.js":"Utsh","./lib/_stream_writable.js":"h5fF","./lib/_stream_duplex.js":"BJej","./lib/_stream_transform.js":"qyih","./lib/_stream_passthrough.js":"dJik","./lib/internal/streams/end-of-stream.js":"PGSG","./lib/internal/streams/pipeline.js":"o2uJ"}],"T5sL":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var t=require("buffer").Buffer;function e(t){return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function r(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function n(t,e){for(var r=0;r>>31),b=u^(p<<1|n>>>31),d=a[0]^x,g=a[1]^b,h=a[10]^x,j=a[11]^b,k=a[20]^x,l=a[21]^b,m=a[30]^x,q=a[31]^b,w=a[40]^x,y=a[41]^b;x=o^(t<<1|c>>>31),b=f^(c<<1|t>>>31);var z=a[2]^x,A=a[3]^b,B=a[12]^x,C=a[13]^b,D=a[22]^x,E=a[23]^b,F=a[32]^x,G=a[33]^b,H=a[42]^x,I=a[43]^b;x=n^(e<<1|i>>>31),b=p^(i<<1|e>>>31);var J=a[4]^x,K=a[5]^b,L=a[14]^x,M=a[15]^b,N=a[24]^x,O=a[25]^b,P=a[34]^x,Q=a[35]^b,R=a[44]^x,S=a[45]^b;x=t^(s<<1|u>>>31),b=c^(u<<1|s>>>31);var T=a[6]^x,U=a[7]^b,V=a[16]^x,W=a[17]^b,X=a[26]^x,Y=a[27]^b,Z=a[36]^x,$=a[37]^b,_=a[46]^x,rr=a[47]^b;x=e^(o<<1|f>>>31),b=i^(f<<1|o>>>31);var ar=a[8]^x,vr=a[9]^b,or=a[18]^x,fr=a[19]^b,nr=a[28]^x,pr=a[29]^b,tr=a[38]^x,cr=a[39]^b,er=a[48]^x,ir=a[49]^b,sr=d,ur=g,xr=j<<4|h>>>28,br=h<<4|j>>>28,dr=k<<3|l>>>29,gr=l<<3|k>>>29,hr=q<<9|m>>>23,jr=m<<9|q>>>23,kr=w<<18|y>>>14,lr=y<<18|w>>>14,mr=z<<1|A>>>31,qr=A<<1|z>>>31,wr=C<<12|B>>>20,yr=B<<12|C>>>20,zr=D<<10|E>>>22,Ar=E<<10|D>>>22,Br=G<<13|F>>>19,Cr=F<<13|G>>>19,Dr=H<<2|I>>>30,Er=I<<2|H>>>30,Fr=K<<30|J>>>2,Gr=J<<30|K>>>2,Hr=L<<6|M>>>26,Ir=M<<6|L>>>26,Jr=O<<11|N>>>21,Kr=N<<11|O>>>21,Lr=P<<15|Q>>>17,Mr=Q<<15|P>>>17,Nr=S<<29|R>>>3,Or=R<<29|S>>>3,Pr=T<<28|U>>>4,Qr=U<<28|T>>>4,Rr=W<<23|V>>>9,Sr=V<<23|W>>>9,Tr=X<<25|Y>>>7,Ur=Y<<25|X>>>7,Vr=Z<<21|$>>>11,Wr=$<<21|Z>>>11,Xr=rr<<24|_>>>8,Yr=_<<24|rr>>>8,Zr=ar<<27|vr>>>5,$r=vr<<27|ar>>>5,_r=or<<20|fr>>>12,ra=fr<<20|or>>>12,aa=pr<<7|nr>>>25,va=nr<<7|pr>>>25,oa=tr<<8|cr>>>24,fa=cr<<8|tr>>>24,na=er<<14|ir>>>18,pa=ir<<14|er>>>18;a[0]=sr^~wr&Jr,a[1]=ur^~yr&Kr,a[10]=Pr^~_r&dr,a[11]=Qr^~ra&gr,a[20]=mr^~Hr&Tr,a[21]=qr^~Ir&Ur,a[30]=Zr^~xr&zr,a[31]=$r^~br&Ar,a[40]=Fr^~Rr&aa,a[41]=Gr^~Sr&va,a[2]=wr^~Jr&Vr,a[3]=yr^~Kr&Wr,a[12]=_r^~dr&Br,a[13]=ra^~gr&Cr,a[22]=Hr^~Tr&oa,a[23]=Ir^~Ur&fa,a[32]=xr^~zr&Lr,a[33]=br^~Ar&Mr,a[42]=Rr^~aa&hr,a[43]=Sr^~va&jr,a[4]=Jr^~Vr&na,a[5]=Kr^~Wr&pa,a[14]=dr^~Br&Nr,a[15]=gr^~Cr&Or,a[24]=Tr^~oa&kr,a[25]=Ur^~fa&lr,a[34]=zr^~Lr&Xr,a[35]=Ar^~Mr&Yr,a[44]=aa^~hr&Dr,a[45]=va^~jr&Er,a[6]=Vr^~na&sr,a[7]=Wr^~pa&ur,a[16]=Br^~Nr&Pr,a[17]=Cr^~Or&Qr,a[26]=oa^~kr&mr,a[27]=fa^~lr&qr,a[36]=Lr^~Xr&Zr,a[37]=Mr^~Yr&$r,a[46]=hr^~Dr&Fr,a[47]=jr^~Er&Gr,a[8]=na^~sr&wr,a[9]=pa^~ur&yr,a[18]=Nr^~Pr&_r,a[19]=Or^~Qr&ra,a[28]=kr^~mr&Hr,a[29]=lr^~qr&Ir,a[38]=Xr^~Zr&xr,a[39]=Yr^~$r&br,a[48]=Dr^~Fr&Rr,a[49]=Er^~Gr&Sr,a[0]^=r[2*v],a[1]^=r[2*v+1]}}; +},{}],"qMHe":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var t=require("buffer").Buffer,i=require("./keccak-state-unroll");function s(){this.state=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],this.blockSize=null,this.count=0,this.squeezing=!1}s.prototype.initialize=function(t,i){for(var s=0;s<50;++s)this.state[s]=0;this.blockSize=t/8,this.count=0,this.squeezing=!1},s.prototype.absorb=function(t){for(var s=0;s>>this.count%4*8&255,this.count+=1,this.count===this.blockSize&&(i.p1600(this.state),this.count=0);return e},s.prototype.copy=function(t){for(var i=0;i<50;++i)t.state[i]=this.state[i];t.blockSize=this.blockSize,t.count=this.count,t.squeezing=this.squeezing},module.exports=s; +},{"./keccak-state-unroll":"ClpW","buffer":"z1tx"}],"zD2i":[function(require,module,exports) { +module.exports=require("./lib/api")(require("./lib/keccak")); +},{"./lib/api":"TZ9Y","./lib/keccak":"qMHe"}],"ZmXY":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=require("./hash-utils"),c=require("keccak");exports.keccak224=e.createHashFunction(function(){return c("keccak224")}),exports.keccak256=e.createHashFunction(function(){return c("keccak256")}),exports.keccak384=e.createHashFunction(function(){return c("keccak384")}),exports.keccak512=e.createHashFunction(function(){return c("keccak512")}); +},{"./hash-utils":"tsla","keccak":"zD2i"}],"z4RK":[function(require,module,exports) { +function e(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=r(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var o=0,a=function(){};return{s:a,n:function(){return o>=e.length?{done:!0}:{done:!1,value:e[o++]}},e:function(e){throw e},f:a}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,c=!0,u=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return c=e.done,e},e:function(e){u=!0,i=e},f:function(){try{c||null==n.return||n.return()}finally{if(u)throw i}}}}function r(e,r){if(e){if("string"==typeof e)return t(e,r);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?t(e,r):void 0}}function t(e,r){(null==r||r>e.length)&&(r=e.length);for(var t=0,n=new Array(r);t0&&void 0!==arguments[0]?arguments[0]:function(e){return new Uint8Array(e)},r=arguments.length>1?arguments[1]:void 0;return"function"==typeof e&&(e=e(r)),a("output",e,r),e}function u(e){return Object.prototype.toString.call(e).slice(8,-1)}module.exports=function(r){return{contextRandomize:function(e){switch(o(null===e||e instanceof Uint8Array,"Expected seed to be an Uint8Array or null"),null!==e&&a("seed",e,32),r.contextRandomize(e)){case 1:throw new Error(n.CONTEXT_RANDOMIZE_UNKNOW)}},privateKeyVerify:function(e){return a("private key",e,32),0===r.privateKeyVerify(e)},privateKeyNegate:function(e){switch(a("private key",e,32),r.privateKeyNegate(e)){case 0:return e;case 1:throw new Error(n.IMPOSSIBLE_CASE)}},privateKeyTweakAdd:function(e,t){switch(a("private key",e,32),a("tweak",t,32),r.privateKeyTweakAdd(e,t)){case 0:return e;case 1:throw new Error(n.TWEAK_ADD)}},privateKeyTweakMul:function(e,t){switch(a("private key",e,32),a("tweak",t,32),r.privateKeyTweakMul(e,t)){case 0:return e;case 1:throw new Error(n.TWEAK_MUL)}},publicKeyVerify:function(e){return a("public key",e,[33,65]),0===r.publicKeyVerify(e)},publicKeyCreate:function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],o=arguments.length>2?arguments[2]:void 0;switch(a("private key",e,32),i(t),o=c(o,t?33:65),r.publicKeyCreate(o,e)){case 0:return o;case 1:throw new Error(n.SECKEY_INVALID);case 2:throw new Error(n.PUBKEY_SERIALIZE)}},publicKeyConvert:function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],o=arguments.length>2?arguments[2]:void 0;switch(a("public key",e,[33,65]),i(t),o=c(o,t?33:65),r.publicKeyConvert(o,e)){case 0:return o;case 1:throw new Error(n.PUBKEY_PARSE);case 2:throw new Error(n.PUBKEY_SERIALIZE)}},publicKeyNegate:function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],o=arguments.length>2?arguments[2]:void 0;switch(a("public key",e,[33,65]),i(t),o=c(o,t?33:65),r.publicKeyNegate(o,e)){case 0:return o;case 1:throw new Error(n.PUBKEY_PARSE);case 2:throw new Error(n.IMPOSSIBLE_CASE);case 3:throw new Error(n.PUBKEY_SERIALIZE)}},publicKeyCombine:function(t){var u=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],s=arguments.length>2?arguments[2]:void 0;o(Array.isArray(t),"Expected public keys to be an Array"),o(t.length>0,"Expected public keys array will have more than zero items");var l,E=e(t);try{for(E.s();!(l=E.n()).done;){a("public key",l.value,[33,65])}}catch(w){E.e(w)}finally{E.f()}switch(i(u),s=c(s,u?33:65),r.publicKeyCombine(s,t)){case 0:return s;case 1:throw new Error(n.PUBKEY_PARSE);case 2:throw new Error(n.PUBKEY_COMBINE);case 3:throw new Error(n.PUBKEY_SERIALIZE)}},publicKeyTweakAdd:function(e,t){var o=!(arguments.length>2&&void 0!==arguments[2])||arguments[2],u=arguments.length>3?arguments[3]:void 0;switch(a("public key",e,[33,65]),a("tweak",t,32),i(o),u=c(u,o?33:65),r.publicKeyTweakAdd(u,e,t)){case 0:return u;case 1:throw new Error(n.PUBKEY_PARSE);case 2:throw new Error(n.TWEAK_ADD)}},publicKeyTweakMul:function(e,t){var o=!(arguments.length>2&&void 0!==arguments[2])||arguments[2],u=arguments.length>3?arguments[3]:void 0;switch(a("public key",e,[33,65]),a("tweak",t,32),i(o),u=c(u,o?33:65),r.publicKeyTweakMul(u,e,t)){case 0:return u;case 1:throw new Error(n.PUBKEY_PARSE);case 2:throw new Error(n.TWEAK_MUL)}},signatureNormalize:function(e){switch(a("signature",e,64),r.signatureNormalize(e)){case 0:return e;case 1:throw new Error(n.SIG_PARSE)}},signatureExport:function(e,t){a("signature",e,64);var o={output:t=c(t,72),outputlen:72};switch(r.signatureExport(o,e)){case 0:return t.slice(0,o.outputlen);case 1:throw new Error(n.SIG_PARSE);case 2:throw new Error(n.IMPOSSIBLE_CASE)}},signatureImport:function(e,t){switch(a("signature",e),t=c(t,64),r.signatureImport(t,e)){case 0:return t;case 1:throw new Error(n.SIG_PARSE);case 2:throw new Error(n.IMPOSSIBLE_CASE)}},ecdsaSign:function(e,t){var i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},s=arguments.length>3?arguments[3]:void 0;a("message",e,32),a("private key",t,32),o("Object"===u(i),"Expected options to be an Object"),void 0!==i.data&&a("options.data",i.data),void 0!==i.noncefn&&o("Function"===u(i.noncefn),"Expected options.noncefn to be a Function");var l={signature:s=c(s,64),recid:null};switch(r.ecdsaSign(l,e,t,i.data,i.noncefn)){case 0:return l;case 1:throw new Error(n.SIGN);case 2:throw new Error(n.IMPOSSIBLE_CASE)}},ecdsaVerify:function(e,t,o){switch(a("signature",e,64),a("message",t,32),a("public key",o,[33,65]),r.ecdsaVerify(e,t,o)){case 0:return!0;case 3:return!1;case 1:throw new Error(n.SIG_PARSE);case 2:throw new Error(n.PUBKEY_PARSE)}},ecdsaRecover:function(e,t,s){var l=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],E=arguments.length>4?arguments[4]:void 0;switch(a("signature",e,64),o("Number"===u(t)&&t>=0&&t<=3,"Expected recovery id to be a Number within interval [0, 3]"),a("message",s,32),i(l),E=c(E,l?33:65),r.ecdsaRecover(E,e,t,s)){case 0:return E;case 1:throw new Error(n.SIG_PARSE);case 2:throw new Error(n.RECOVER);case 3:throw new Error(n.IMPOSSIBLE_CASE)}},ecdh:function(e,t){var i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},s=arguments.length>3?arguments[3]:void 0;switch(a("public key",e,[33,65]),a("private key",t,32),o("Object"===u(i),"Expected options to be an Object"),void 0!==i.data&&a("options.data",i.data),void 0!==i.hashfn?(o("Function"===u(i.hashfn),"Expected options.hashfn to be a Function"),void 0!==i.xbuf&&a("options.xbuf",i.xbuf,32),void 0!==i.ybuf&&a("options.ybuf",i.ybuf,32),a("output",s)):s=c(s,32),r.ecdh(s,e,t,i.data,i.hashfn,i.xbuf,i.ybuf)){case 0:return s;case 1:throw new Error(n.PUBKEY_PARSE);case 2:throw new Error(n.ECDH)}}}}; +},{}],"IAXI":[function(require,module,exports) { +var r=require("elliptic").ec,e=new r("secp256k1"),n=e.curve,t=n.n.constructor;function u(r,u){var i=new t(u);if(i.cmp(n.p)>=0)return null;var a=(i=i.toRed(n.red)).redSqr().redIMul(i).redIAdd(n.b).redSqrt();return 3===r!==a.isOdd()&&(a=a.redNeg()),e.keyPair({pub:{x:i,y:a}})}function i(r,u,i){var a=new t(u),c=new t(i);if(a.cmp(n.p)>=0||c.cmp(n.p)>=0)return null;if(a=a.toRed(n.red),c=c.toRed(n.red),(6===r||7===r)&&c.isOdd()!==(7===r))return null;var l=a.redSqr().redIMul(a);return c.redSqr().redISub(l.redIAdd(n.b)).isZero()?e.keyPair({pub:{x:a,y:c}}):null}function a(r){var e=r[0];switch(e){case 2:case 3:return 33!==r.length?null:u(e,r.subarray(1,33));case 4:case 6:case 7:return 65!==r.length?null:i(e,r.subarray(1,33),r.subarray(33,65));default:return null}}function c(r,e){for(var n=e.encode(null,33===r.length),t=0;t=0)return 1;if(u.iadd(new t(r)),u.cmp(n.n)>=0&&u.isub(n.n),u.isZero())return 1;var i=u.toArrayLike(Uint8Array,"be",32);return r.set(i),0},privateKeyTweakMul:function(r,e){var u=new t(e);if(u.cmp(n.n)>=0||u.isZero())return 1;u.imul(new t(r)),u.cmp(n.n)>=0&&(u=u.umod(n.n));var i=u.toArrayLike(Uint8Array,"be",32);return r.set(i),0},publicKeyVerify:function(r){return null===a(r)?1:0},publicKeyCreate:function(r,u){var i=new t(u);return i.cmp(n.n)>=0||i.isZero()?1:(c(r,e.keyFromPrivate(u).getPublic()),0)},publicKeyConvert:function(r,e){var n=a(e);return null===n?1:(c(r,n.getPublic()),0)},publicKeyNegate:function(r,e){var n=a(e);if(null===n)return 1;var t=n.getPublic();return t.y=t.y.redNeg(),c(r,t),0},publicKeyCombine:function(r,e){for(var n=new Array(e.length),t=0;t=0)return 2;var l=i.getPublic().add(n.g.mul(u));return l.isInfinity()?2:(c(r,l),0)},publicKeyTweakMul:function(r,e,u){var i=a(e);return null===i?1:(u=new t(u)).cmp(n.n)>=0||u.isZero()?2:(c(r,i.getPublic().mul(u)),0)},signatureNormalize:function(r){var u=new t(r.subarray(0,32)),i=new t(r.subarray(32,64));return u.cmp(n.n)>=0||i.cmp(n.n)>=0?1:(1===i.cmp(e.nh)&&r.set(n.n.sub(i).toArrayLike(Uint8Array,"be",32),32),0)},signatureExport:function(r,e){var u=e.subarray(0,32),i=e.subarray(32,64);if(new t(u).cmp(n.n)>=0)return 1;if(new t(i).cmp(n.n)>=0)return 1;var a=r.output,c=a.subarray(4,37);c[0]=0,c.set(u,1);for(var l=33,o=0;l>1&&0===c[o]&&!(128&c[o+1]);--l,++o);if(128&(c=c.subarray(o))[0])return 1;if(l>1&&0===c[0]&&!(128&c[1]))return 1;var s=a.subarray(39,72);s[0]=0,s.set(i,1);for(var f=33,y=0;f>1&&0===s[y]&&!(128&s[y+1]);--f,++y);return 128&(s=s.subarray(y))[0]?1:f>1&&0===s[0]&&!(128&s[1])?1:(r.outputlen=6+l+f,a[0]=48,a[1]=r.outputlen-2,a[2]=2,a[3]=c.length,a.set(c,4),a[4+l]=2,a[5+l]=s.length,a.set(s,6+l),0)},signatureImport:function(r,e){if(e.length<8)return 1;if(e.length>72)return 1;if(48!==e[0])return 1;if(e[1]!==e.length-2)return 1;if(2!==e[2])return 1;var u=e[3];if(0===u)return 1;if(5+u>=e.length)return 1;if(2!==e[4+u])return 1;var i=e[5+u];if(0===i)return 1;if(6+u+i!==e.length)return 1;if(128&e[4])return 1;if(u>1&&0===e[4]&&!(128&e[5]))return 1;if(128&e[u+6])return 1;if(i>1&&0===e[u+6]&&!(128&e[u+7]))return 1;var a=e.subarray(4,4+u);if(33===a.length&&0===a[0]&&(a=a.subarray(1)),a.length>32)return 1;var c=e.subarray(6+u);if(33===c.length&&0===c[0]&&(c=c.slice(1)),c.length>32)throw new Error("S length is too long");var l=new t(a);l.cmp(n.n)>=0&&(l=new t(0));var o=new t(e.subarray(6+u));return o.cmp(n.n)>=0&&(o=new t(0)),r.set(l.toArrayLike(Uint8Array,"be",32),0),r.set(o.toArrayLike(Uint8Array,"be",32),32),0},ecdsaSign:function(r,u,i,a,c){if(c){var l=c;c=function(r){var e=l(u,i,null,a,r);if(!(e instanceof Uint8Array&&32===e.length))throw new Error("This is the way");return new t(e)}}var o,s=new t(i);if(s.cmp(n.n)>=0||s.isZero())return 1;try{o=e.sign(u,i,{canonical:!0,k:c,pers:a})}catch(f){return 1}return r.signature.set(o.r.toArrayLike(Uint8Array,"be",32),0),r.signature.set(o.s.toArrayLike(Uint8Array,"be",32),32),r.recid=o.recoveryParam,0},ecdsaVerify:function(r,u,i){var c={r:r.subarray(0,32),s:r.subarray(32,64)},l=new t(c.r),o=new t(c.s);if(l.cmp(n.n)>=0||o.cmp(n.n)>=0)return 1;if(1===o.cmp(e.nh)||l.isZero()||o.isZero())return 3;var s=a(i);if(null===s)return 2;var f=s.getPublic();return e.verify(u,c,f)?0:3},ecdsaRecover:function(r,u,i,a){var l,o={r:u.slice(0,32),s:u.slice(32,64)},s=new t(o.r),f=new t(o.s);if(s.cmp(n.n)>=0||f.cmp(n.n)>=0)return 1;if(s.isZero()||f.isZero())return 2;try{l=e.recoverPubKey(a,o,i)}catch(y){return 2}return c(r,l),0},ecdh:function(r,u,i,c,l,o,s){var f=a(u);if(null===f)return 1;var y=new t(i);if(y.cmp(n.n)>=0||y.isZero())return 2;var v=f.getPublic().mul(y);if(void 0===l)for(var b=v.encode(null,!0),p=e.hash().update(b).digest(),d=0;d<32;++d)r[d]=p[d];else{o||(o=new Uint8Array(32));for(var g=v.getX().toArray("be",32),w=0;w<32;++w)o[w]=g[w];s||(s=new Uint8Array(32));for(var m=v.getY().toArray("be",32),h=0;h<32;++h)s[h]=m[h];var A=l(o,s,c);if(!(A instanceof Uint8Array&&A.length===r.length))return 2;r.set(A)}return 0}}; +},{"elliptic":"G54Y"}],"KY6C":[function(require,module,exports) { +module.exports=require("./lib")(require("./lib/elliptic")); +},{"./lib":"z4RK","./lib/elliptic":"IAXI"}],"amO1":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=require("randombytes");function t(t){return new Promise(function(n,r){e(t,function(e,t){e?r(e):n(t)})})}function n(t){return e(t)}exports.getRandomBytes=t,exports.getRandomBytesSync=n; +},{"randombytes":"pXr2"}],"rvN4":[function(require,module,exports) { +"use strict";var e=this&&this.__awaiter||function(e,t,r,n){return new(r||(r=Promise))(function(o,i){function a(e){try{c(n.next(e))}catch(t){i(t)}}function u(e){try{c(n.throw(e))}catch(t){i(t)}}function c(e){var t;e.done?o(e.value):(t=e.value,t instanceof r?t:new r(function(e){e(t)})).then(a,u)}c((n=n.apply(e,t||[])).next())})},t=this&&this.__generator||function(e,t){var r,n,o,i,a={label:0,sent:function(){if(1&o[0])throw o[1];return o[1]},trys:[],ops:[]};return i={next:u(0),throw:u(1),return:u(2)},"function"==typeof Symbol&&(i[Symbol.iterator]=function(){return this}),i;function u(i){return function(u){return function(i){if(r)throw new TypeError("Generator is already executing.");for(;a;)try{if(r=1,n&&(o=2&i[0]?n.return:i[0]?n.throw||((o=n.return)&&o.call(n),0):n.next)&&!(o=o.call(n,i[1])).done)return o;switch(n=0,o&&(i=[2&i[0],o.value]),i[0]){case 0:case 1:o=i;break;case 4:return a.label++,{value:i[1],done:!1};case 5:a.label++,n=i[1],i=[0];continue;case 7:i=a.ops.pop(),a.trys.pop();continue;default:if(!(o=(o=a.trys).length>0&&o[o.length-1])&&(6===i[0]||2===i[0])){a=0;continue}if(3===i[0]&&(!o||i[1]>o[0]&&i[1]=0)throw new Error("couldn't export to DER format");var u=o.g.mul(a);return i(u.getX(),u.getY(),n)},exports.privateKeyModInverse=function(n){var o=new e(n);if(o.ucmp(t.n)>=0||o.isZero())throw new Error("private key range is invalid");return o.invm(t.n).toArrayLike(r,"be",32)},exports.signatureImport=function(n){var o=new e(n.r);o.ucmp(t.n)>=0&&(o=new e(0));var i=new e(n.s);return i.ucmp(t.n)>=0&&(i=new e(0)),r.concat([o.toArrayLike(r,"be",32),i.toArrayLike(r,"be",32)])},exports.ecdhUnsafe=function(r,n,a){var u=o.keyFromPublic(r),c=new e(n);if(c.ucmp(t.n)>=0||c.isZero())throw new Error("scalar was invalid (zero or overflow)");var p=u.pub.mul(c);return i(p.getX(),p.getY(),a)};var i=function(e,n,o){var t=void 0;return o?((t=r.alloc(33))[0]=n.isOdd()?3:2,e.toArrayLike(r,"be",32).copy(t,1)):((t=r.alloc(65))[0]=4,e.toArrayLike(r,"be",32).copy(t,1),n.toArrayLike(r,"be",32).copy(t,33)),t}; +},{"bn.js":"o7RX","elliptic":"G54Y","buffer":"z1tx"}],"L1F8":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var r=require("buffer").Buffer,l=r.from([48,129,211,2,1,1,4,32,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,160,129,133,48,129,130,2,1,1,48,44,6,7,42,134,72,206,61,1,1,2,33,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,254,255,255,252,47,48,6,4,1,0,4,1,7,4,33,2,121,190,102,126,249,220,187,172,85,160,98,149,206,135,11,7,2,155,252,219,45,206,40,217,89,242,129,91,22,248,23,152,2,33,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,254,186,174,220,230,175,72,160,59,191,210,94,140,208,54,65,65,2,1,1,161,36,3,34,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]),n=r.from([48,130,1,19,2,1,1,4,32,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,160,129,165,48,129,162,2,1,1,48,44,6,7,42,134,72,206,61,1,1,2,33,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,254,255,255,252,47,48,6,4,1,0,4,1,7,4,65,4,121,190,102,126,249,220,187,172,85,160,98,149,206,135,11,7,2,155,252,219,45,206,40,217,89,242,129,91,22,248,23,152,72,58,218,119,38,163,196,101,93,164,251,252,14,17,8,168,253,23,180,72,166,133,84,25,156,71,208,143,251,16,212,184,2,33,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,254,186,174,220,230,175,72,160,59,191,210,94,140,208,54,65,65,2,1,1,161,68,3,66,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);exports.privateKeyExport=function(u,e,t){var f=r.from(t?l:n);return u.copy(f,t?8:9),e.copy(f,t?181:214),f},exports.privateKeyImport=function(r){var l=r.length,n=0;if(l2)return null;if(l<(n+=1)+u)return null;var e=r[n+u-1]|(u>1?r[n+u-2]<<8:0);return l<(n+=u)+e?null:l32||le)return null;if(2!==l[t++])return null;var i=l[t++];if(128&i){if(t+(f=i-128)>e)return null;for(;f>0&&0===l[t];t+=1,f-=1);for(i=0;f>0;t+=1,f-=1)i=(i<<8)+l[t]}if(i>e-t)return null;var o=t;if(t+=i,2!==l[t++])return null;var a=l[t++];if(128&a){if(t+(f=a-128)>e)return null;for(;f>0&&0===l[t];t+=1,f-=1);for(a=0;f>0;t+=1,f-=1)a=(a<<8)+l[t]}if(a>e-t)return null;var v=t;for(t+=a;i>0&&0===l[o];i-=1,o+=1);if(i>32)return null;var c=l.slice(o,o+i);for(c.copy(n,32-c.length);a>0&&0===l[v];a-=1,v+=1);if(a>32)return null;var p=l.slice(v,v+a);return p.copy(u,32-p.length),{r:n,s:u}}; +},{"buffer":"z1tx"}],"Uazd":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var r=require("buffer").Buffer,e=require("ethereum-cryptography/secp256k1"),n=require("./secp256k1-lib/index"),t=require("./secp256k1-lib/der"),i=function(r){return 32===r.length&&e.privateKeyVerify(Uint8Array.from(r))},o=function(r,e){if(32!==r.length)throw new RangeError("private key length is invalid");var i=n.privateKeyExport(r,e);return t.privateKeyExport(r,i,e)},a=function(r){if(null!==(r=t.privateKeyImport(r))&&32===r.length&&i(r))return r;throw new Error("couldn't import from DER format")},f=function(n){return r.from(e.privateKeyNegate(Uint8Array.from(n)))},u=function(e){if(32!==e.length)throw new Error("private key length is invalid");return r.from(n.privateKeyModInverse(Uint8Array.from(e)))},y=function(n,t){return r.from(e.privateKeyTweakAdd(Uint8Array.from(n),t))},l=function(n,t){return r.from(e.privateKeyTweakMul(Uint8Array.from(n),Uint8Array.from(t)))},m=function(n,t){return r.from(e.publicKeyCreate(Uint8Array.from(n),t))},c=function(n,t){return r.from(e.publicKeyConvert(Uint8Array.from(n),t))},p=function(r){return(33===r.length||65===r.length)&&e.publicKeyVerify(Uint8Array.from(r))},d=function(n,t,i){return r.from(e.publicKeyTweakAdd(Uint8Array.from(n),Uint8Array.from(t),i))},g=function(n,t,i){return r.from(e.publicKeyTweakMul(Uint8Array.from(n),Uint8Array.from(t),i))},s=function(n,t){var i=[];return n.forEach(function(r){i.push(Uint8Array.from(r))}),r.from(e.publicKeyCombine(i,t))},v=function(n){return r.from(e.signatureNormalize(Uint8Array.from(n)))},h=function(n){return r.from(e.signatureExport(Uint8Array.from(n)))},A=function(n){return r.from(e.signatureImport(Uint8Array.from(n)))},w=function(r){if(0===r.length)throw new RangeError("signature length is invalid");var e=t.signatureImportLax(r);if(null===e)throw new Error("couldn't parse DER signature");return n.signatureImport(e)},U=function(n,t,i){if(null===i)throw new TypeError("options should be an Object");var o=void 0;if(i){if(o={},null===i.data)throw new TypeError("options.data should be a Buffer");if(i.data){if(32!==i.data.length)throw new RangeError("options.data length is invalid");o.data=new Uint8Array(i.data)}if(null===i.noncefn)throw new TypeError("options.noncefn should be a Function");i.noncefn&&(o.noncefn=function(e,n,t,o,a){var f=null!=t?r.from(t):null,u=null!=o?r.from(o):null,y=r.from("");return i.noncefn&&(y=i.noncefn(r.from(e),r.from(n),f,u,a)),Uint8Array.from(y)})}var a=e.ecdsaSign(Uint8Array.from(n),Uint8Array.from(t),o);return{signature:r.from(a.signature),recovery:a.recid}},K=function(r,n,t){return e.ecdsaVerify(Uint8Array.from(n),Uint8Array.from(r),t)},b=function(n,t,i,o){return r.from(e.ecdsaRecover(Uint8Array.from(t),i,Uint8Array.from(n),o))},E=function(n,t){return r.from(e.ecdh(Uint8Array.from(n),Uint8Array.from(t),{}))},k=function(e,t,i){if(33!==e.length&&65!==e.length)throw new RangeError("public key length is invalid");if(32!==t.length)throw new RangeError("private key length is invalid");return r.from(n.ecdhUnsafe(Uint8Array.from(e),Uint8Array.from(t),i))};module.exports={privateKeyVerify:i,privateKeyExport:o,privateKeyImport:a,privateKeyNegate:f,privateKeyModInverse:u,privateKeyTweakAdd:y,privateKeyTweakMul:l,publicKeyCreate:m,publicKeyConvert:c,publicKeyVerify:p,publicKeyTweakAdd:d,publicKeyTweakMul:g,publicKeyCombine:s,signatureNormalize:v,signatureExport:h,signatureImport:A,signatureImportLax:w,sign:U,verify:K,recover:b,ecdh:E,ecdhUnsafe:k}; +},{"ethereum-cryptography/secp256k1":"rvN4","./secp256k1-lib/index":"hYjB","./secp256k1-lib/der":"L1F8","buffer":"z1tx"}],"YOwE":[function(require,module,exports) { +"use strict";var r=Object.getOwnPropertySymbols,t=Object.prototype.hasOwnProperty,e=Object.prototype.propertyIsEnumerable;function n(r){if(null==r)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(r)}function o(){try{if(!Object.assign)return!1;var r=new String("abc");if(r[5]="de","5"===Object.getOwnPropertyNames(r)[0])return!1;for(var t={},e=0;e<10;e++)t["_"+String.fromCharCode(e)]=e;if("0123456789"!==Object.getOwnPropertyNames(t).map(function(r){return t[r]}).join(""))return!1;var n={};return"abcdefghijklmnopqrst".split("").forEach(function(r){n[r]=r}),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},n)).join("")}catch(o){return!1}}module.exports=o()?Object.assign:function(o,c){for(var a,i,s=n(o),f=1;f=s)return e;switch(e){case"%s":return String(o[n++]);case"%d":return Number(o[n++]);case"%j":try{return JSON.stringify(o[n++])}catch(t){return"[Circular]"}default:return e}}),c=o[n];n=3&&(r.depth=arguments[2]),arguments.length>=4&&(r.colors=arguments[3]),x(t)?r.showHidden=t:t&&exports._extend(r,t),j(r.showHidden)&&(r.showHidden=!1),j(r.depth)&&(r.depth=2),j(r.colors)&&(r.colors=!1),j(r.customInspect)&&(r.customInspect=!0),r.colors&&(r.stylize=s),p(r,e,r.depth)}function s(e,t){var r=i.styles[t];return r?"["+i.colors[r][0]+"m"+e+"["+i.colors[r][1]+"m":e}function u(e,t){return e}function c(e){var t={};return e.forEach(function(e,r){t[e]=!0}),t}function p(e,t,r){if(e.customInspect&&t&&D(t.inspect)&&t.inspect!==exports.inspect&&(!t.constructor||t.constructor.prototype!==t)){var n=t.inspect(r,e);return v(n)||(n=p(e,n,r)),n}var o=l(e,t);if(o)return o;var i=Object.keys(t),s=c(i);if(e.showHidden&&(i=Object.getOwnPropertyNames(t)),E(t)&&(i.indexOf("message")>=0||i.indexOf("description")>=0))return a(t);if(0===i.length){if(D(t)){var u=t.name?": "+t.name:"";return e.stylize("[Function"+u+"]","special")}if(O(t))return e.stylize(RegExp.prototype.toString.call(t),"regexp");if(w(t))return e.stylize(Date.prototype.toString.call(t),"date");if(E(t))return a(t)}var x,h="",b=!1,m=["{","}"];(d(t)&&(b=!0,m=["[","]"]),D(t))&&(h=" [Function"+(t.name?": "+t.name:"")+"]");return O(t)&&(h=" "+RegExp.prototype.toString.call(t)),w(t)&&(h=" "+Date.prototype.toUTCString.call(t)),E(t)&&(h=" "+a(t)),0!==i.length||b&&0!=t.length?r<0?O(t)?e.stylize(RegExp.prototype.toString.call(t),"regexp"):e.stylize("[Object]","special"):(e.seen.push(t),x=b?f(e,t,r,s,i):i.map(function(n){return g(e,t,r,s,n,b)}),e.seen.pop(),y(x,h,m)):m[0]+h+m[1]}function l(e,t){if(j(t))return e.stylize("undefined","undefined");if(v(t)){var r="'"+JSON.stringify(t).replace(/^"|"$/g,"").replace(/'/g,"\\'").replace(/\\"/g,'"')+"'";return e.stylize(r,"string")}return m(t)?e.stylize(""+t,"number"):x(t)?e.stylize(""+t,"boolean"):h(t)?e.stylize("null","null"):void 0}function a(e){return"["+Error.prototype.toString.call(e)+"]"}function f(e,t,r,n,o){for(var i=[],s=0,u=t.length;s-1&&(u=i?u.split("\n").map(function(e){return" "+e}).join("\n").substr(2):"\n"+u.split("\n").map(function(e){return" "+e}).join("\n")):u=e.stylize("[Circular]","special")),j(s)){if(i&&o.match(/^\d+$/))return u;(s=JSON.stringify(""+o)).match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)?(s=s.substr(1,s.length-2),s=e.stylize(s,"name")):(s=s.replace(/'/g,"\\'").replace(/\\"/g,'"').replace(/(^"|"$)/g,"'"),s=e.stylize(s,"string"))}return s+": "+u}function y(e,t,r){return e.reduce(function(e,t){return 0,t.indexOf("\n")>=0&&0,e+t.replace(/\u001b\[\d\d?m/g,"").length+1},0)>60?r[0]+(""===t?"":t+"\n ")+" "+e.join(",\n ")+" "+r[1]:r[0]+t+" "+e.join(", ")+" "+r[1]}function d(e){return Array.isArray(e)}function x(e){return"boolean"==typeof e}function h(e){return null===e}function b(e){return null==e}function m(e){return"number"==typeof e}function v(e){return"string"==typeof e}function S(e){return"symbol"==typeof e}function j(e){return void 0===e}function O(e){return z(e)&&"[object RegExp]"===A(e)}function z(e){return"object"==typeof e&&null!==e}function w(e){return z(e)&&"[object Date]"===A(e)}function E(e){return z(e)&&("[object Error]"===A(e)||e instanceof Error)}function D(e){return"function"==typeof e}function N(e){return null===e||"boolean"==typeof e||"number"==typeof e||"string"==typeof e||"symbol"==typeof e||void 0===e}function A(e){return Object.prototype.toString.call(e)}function J(e){return e<10?"0"+e.toString(10):e.toString(10)}exports.debuglog=function(e){if(j(n)&&(n=""),e=e.toUpperCase(),!o[e])if(new RegExp("\\b"+e+"\\b","i").test(n)){var r=t.pid;o[e]=function(){var t=exports.format.apply(exports,arguments);console.error("%s %d: %s",e,r,t)}}else o[e]=function(){};return o[e]},exports.inspect=i,i.colors={bold:[1,22],italic:[3,23],underline:[4,24],inverse:[7,27],white:[37,39],grey:[90,39],black:[30,39],blue:[34,39],cyan:[36,39],green:[32,39],magenta:[35,39],red:[31,39],yellow:[33,39]},i.styles={special:"cyan",number:"yellow",boolean:"yellow",undefined:"grey",null:"bold",string:"green",date:"magenta",regexp:"red"},exports.isArray=d,exports.isBoolean=x,exports.isNull=h,exports.isNullOrUndefined=b,exports.isNumber=m,exports.isString=v,exports.isSymbol=S,exports.isUndefined=j,exports.isRegExp=O,exports.isObject=z,exports.isDate=w,exports.isError=E,exports.isFunction=D,exports.isPrimitive=N,exports.isBuffer=require("./support/isBuffer");var R=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function H(){var e=new Date,t=[J(e.getHours()),J(e.getMinutes()),J(e.getSeconds())].join(":");return[e.getDate(),R[e.getMonth()],t].join(" ")}function $(e,t){return Object.prototype.hasOwnProperty.call(e,t)}exports.log=function(){console.log("%s - %s",H(),exports.format.apply(exports,arguments))},exports.inherits=require("inherits"),exports._extend=function(e,t){if(!t||!z(t))return e;for(var r=Object.keys(t),n=r.length;n--;)e[r[n]]=t[r[n]];return e}; +},{"./support/isBuffer":"ebtb","inherits":"Zvxt","process":"g5IB"}],"g2FE":[function(require,module,exports) { +var global = arguments[3]; +var t=arguments[3],e=require("object-assign");function r(t,e){if(t===e)return 0;for(var r=t.length,n=e.length,i=0,o=Math.min(r,n);i=0;f--)if(s[f]!==l[f])return!1;for(f=s.length-1;f>=0;f--)if(!d(t[c=s[f]],e[c],r,n))return!1;return!0}function v(t,e,r){d(t,e,!0)&&y(t,e,r,"notDeepStrictEqual",v)}function x(t,e){if(!t||!e)return!1;if("[object RegExp]"==Object.prototype.toString.call(e))return e.test(t);try{if(t instanceof e)return!0}catch(r){}return!Error.isPrototypeOf(e)&&!0===e.call({},t)}function S(t){var e;try{t()}catch(r){e=r}return e}function w(t,e,r,n){var o;if("function"!=typeof e)throw new TypeError('"block" argument must be a function');"string"==typeof r&&(n=r,r=null),o=S(e),n=(r&&r.name?" ("+r.name+").":".")+(n?" "+n:"."),t&&!o&&y(o,r,"Missing expected exception"+n);var u="string"==typeof n,a=!t&&o&&!r;if((!t&&i.isError(o)&&u&&x(o,r)||a)&&y(o,r,"Got unwanted exception"+n),t&&o&&r&&!x(o,r)||!t&&o)throw o}function O(t,e){t||y(t,!0,e,"==",O)}s.AssertionError=function(t){this.name="AssertionError",this.actual=t.actual,this.expected=t.expected,this.operator=t.operator,t.message?(this.message=t.message,this.generatedMessage=!1):(this.message=h(this),this.generatedMessage=!0);var e=t.stackStartFunction||y;if(Error.captureStackTrace)Error.captureStackTrace(this,e);else{var r=new Error;if(r.stack){var n=r.stack,i=p(e),o=n.indexOf("\n"+i);if(o>=0){var u=n.indexOf("\n",o+1);n=n.substring(u+1)}this.stack=n}}},i.inherits(s.AssertionError,Error),s.fail=y,s.ok=q,s.equal=function(t,e,r){t!=e&&y(t,e,r,"==",s.equal)},s.notEqual=function(t,e,r){t==e&&y(t,e,r,"!=",s.notEqual)},s.deepEqual=function(t,e,r){d(t,e,!1)||y(t,e,r,"deepEqual",s.deepEqual)},s.deepStrictEqual=function(t,e,r){d(t,e,!0)||y(t,e,r,"deepStrictEqual",s.deepStrictEqual)},s.notDeepEqual=function(t,e,r){d(t,e,!1)&&y(t,e,r,"notDeepEqual",s.notDeepEqual)},s.notDeepStrictEqual=v,s.strictEqual=function(t,e,r){t!==e&&y(t,e,r,"===",s.strictEqual)},s.notStrictEqual=function(t,e,r){t===e&&y(t,e,r,"!==",s.notStrictEqual)},s.throws=function(t,e,r){w(!0,t,e,r)},s.doesNotThrow=function(t,e,r){w(!1,t,e,r)},s.ifError=function(t){if(t)throw t},s.strict=e(O,s,{equal:s.strictEqual,deepEqual:s.deepStrictEqual,notEqual:s.notStrictEqual,notDeepEqual:s.notDeepStrictEqual}),s.strict.strict=s.strict;var A=Object.keys||function(t){var e=[];for(var r in t)o.call(t,r)&&e.push(r);return e}; +},{"object-assign":"YOwE","util/":"KpDW"}],"jGda":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var r=require("buffer").Buffer,e=this&&this.__importDefault||function(r){return r&&r.__esModule?r:{default:r}};Object.defineProperty(exports,"__esModule",{value:!0}),exports.getLength=exports.decode=exports.encode=void 0;var t=e(require("bn.js"));function n(e){if(Array.isArray(e)){for(var t=[],i=0;ie.length)throw new Error("invalid rlp: total length is larger than the data");if(0===(a=e.slice(n,h)).length)throw new Error("invalid rlp, List has a invalid length");for(;a.length;)f=u(a),l.push(f.data),a=f.remainder;return{data:l,remainder:e.slice(h)}}function l(r){return"0x"===r.slice(0,2)}function s(r){return"string"!=typeof r?r:l(r)?r.slice(2):r}function h(r){if(r<0)throw new Error("Invalid integer as argument, must be unsigned!");var e=r.toString(16);return e.length%2?"0"+e:e}function d(r){return r.length%2?"0"+r:r}function g(e){var t=h(e);return r.from(t,"hex")}function c(e){if(!r.isBuffer(e)){if("string"==typeof e)return l(e)?r.from(d(s(e)),"hex"):r.from(e);if("number"==typeof e||"bigint"==typeof e)return e?g(e):r.from([]);if(null==e)return r.from([]);if(e instanceof Uint8Array)return r.from(e);if(t.default.isBN(e))return r.from(e.toArray());throw new Error("invalid type")}return e}exports.encode=n,exports.decode=a,exports.getLength=f; +},{"bn.js":"EOUW","buffer":"z1tx"}],"XYhr":[function(require,module,exports) { +function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}module.exports=function(e){if("string"!=typeof e)throw new Error("[is-hex-prefixed] value must be type 'string', is currently type "+t(e)+", while checking isHexPrefixed.");return"0x"===e.slice(0,2)}; +},{}],"BxjE":[function(require,module,exports) { +var e=require("is-hex-prefixed");module.exports=function(r){return"string"!=typeof r?r:e(r)?r.slice(2):r}; +},{"is-hex-prefixed":"XYhr"}],"VqbN":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var r=require("buffer").Buffer;function t(r){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(r){return typeof r}:function(r){return r&&"function"==typeof Symbol&&r.constructor===Symbol&&r!==Symbol.prototype?"symbol":typeof r})(r)}var e=require("is-hex-prefixed"),n=require("strip-hex-prefix");function i(r){var e=r;if("string"!=typeof e)throw new Error("[ethjs-util] while padding to even, value must be string, is currently "+t(e)+", while padToEven.");return e.length%2&&(e="0"+e),e}function o(r){return"0x"+r.toString(16)}function u(t){var e=o(t);return new r(i(e.slice(2)),"hex")}function f(e){if("string"!=typeof e)throw new Error("[ethjs-util] while getting binary size, method getBinarySize requires input 'str' to be type String, got '"+t(e)+"'.");return r.byteLength(e,"utf8")}function a(r,e,n){if(!0!==Array.isArray(r))throw new Error("[ethjs-util] method arrayContainsArray requires input 'superset' to be an array got type '"+t(r)+"'");if(!0!==Array.isArray(e))throw new Error("[ethjs-util] method arrayContainsArray requires input 'subset' to be an array got type '"+t(e)+"'");return e[Boolean(n)?"some":"every"](function(t){return r.indexOf(t)>=0})}function s(t){return new r(i(n(t).replace(/^0+|0+$/g,"")),"hex").toString("utf8")}function y(r){var t="",e=0,n=r.length;for("0x"===r.substring(0,2)&&(e=2);e0&&"0"===r.toString();)r=(e=e.slice(1))[0];return e},exports.toBuffer=function(e){if(!c.isBuffer(e))if(Array.isArray(e))e=c.from(e);else if("string"==typeof e)e=exports.isHexString(e)?c.from(exports.padToEven(exports.stripHexPrefix(e)),"hex"):c.from(e);else if("number"==typeof e)e=exports.intToBuffer(e);else if(null==e)e=c.allocUnsafe(0);else if(u.isBN(e))e=e.toArrayLike(c);else{if(!e.toArray)throw new Error("invalid type");e=c.from(e.toArray())}return e},exports.bufferToInt=function(e){return new u(exports.toBuffer(e)).toNumber()},exports.bufferToHex=function(e){return"0x"+(e=exports.toBuffer(e)).toString("hex")},exports.fromSigned=function(e){return new u(e).fromTwos(256)},exports.toUnsigned=function(e){return c.from(e.toTwos(256).toArray())},exports.keccak=function(e,r){switch(e=exports.toBuffer(e),r||(r=256),r){case 224:return t(e);case 256:return o(e);case 384:return f(e);case 512:return s(e);default:throw new Error("Invald algorithm: keccak"+r)}},exports.keccak256=function(e){return exports.keccak(e)},exports.sha3=exports.keccak,exports.sha256=function(e){return e=exports.toBuffer(e),a("sha256").update(e).digest()},exports.ripemd160=function(e,r){e=exports.toBuffer(e);var t=a("rmd160").update(e).digest();return!0===r?exports.setLength(t,32):t},exports.rlphash=function(e){return exports.keccak(p.encode(e))},exports.isValidPrivate=function(e){return n.privateKeyVerify(e)},exports.isValidPublic=function(e,r){return 64===e.length?n.publicKeyVerify(c.concat([c.from([4]),e])):!!r&&n.publicKeyVerify(e)},exports.pubToAddress=exports.publicToAddress=function(e,r){return e=exports.toBuffer(e),r&&64!==e.length&&(e=n.publicKeyConvert(e,!1).slice(1)),i(64===e.length),exports.keccak(e).slice(-20)};var x=exports.privateToPublic=function(e){return e=exports.toBuffer(e),n.publicKeyCreate(e,!1).slice(1)};exports.importPublic=function(e){return 64!==(e=exports.toBuffer(e)).length&&(e=n.publicKeyConvert(e,!1).slice(1)),e},exports.ecsign=function(e,r){var t=n.sign(e,r),f={};return f.r=t.signature.slice(0,32),f.s=t.signature.slice(32,64),f.v=t.recovery+27,f},exports.hashPersonalMessage=function(e){var r=exports.toBuffer("Ethereum Signed Message:\n"+e.length.toString());return exports.keccak(c.concat([r,e]))},exports.ecrecover=function(e,r,t,f){var o=c.concat([exports.setLength(t,32),exports.setLength(f,32)],64),s=r-27;if(0!==s&&1!==s)throw new Error("Invalid signature v value");var i=n.recover(e,o,s);return n.publicKeyConvert(i,!1).slice(1)},exports.toRpcSig=function(e,r,t){if(27!==e&&28!==e)throw new Error("Invalid recovery id");return exports.bufferToHex(c.concat([exports.setLengthLeft(r,32),exports.setLengthLeft(t,32),exports.toBuffer(e-27)]))},exports.fromRpcSig=function(e){if(65!==(e=exports.toBuffer(e)).length)throw new Error("Invalid signature length");var r=e[64];return r<27&&(r+=27),{v:r,r:e.slice(0,32),s:e.slice(32,64)}},exports.privateToAddress=function(e){return exports.publicToAddress(x(e))},exports.isValidAddress=function(e){return/^0x[0-9a-fA-F]{40}$/.test(e)},exports.isZeroAddress=function(e){return exports.zeroAddress()===exports.addHexPrefix(e)},exports.toChecksumAddress=function(e){e=exports.stripHexPrefix(e).toLowerCase();for(var r=exports.keccak(e).toString("hex"),t="0x",f=0;f=8?t+=e[f].toUpperCase():t+=e[f];return t},exports.isValidChecksumAddress=function(e){return exports.isValidAddress(e)&&exports.toChecksumAddress(e)===e},exports.generateAddress=function(e,r){return e=exports.toBuffer(e),r=(r=new u(r)).isZero()?null:c.from(r.toArray()),exports.rlphash([e,r]).slice(-20)},exports.isPrecompiled=function(e){var r=exports.unpad(e);return 1===r.length&&r[0]>=1&&r[0]<=8},exports.addHexPrefix=function(e){return"string"!=typeof e?e:exports.isHexPrefixed(e)?e:"0x"+e},exports.isValidSignature=function(e,r,t,f){var o=new u("7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0",16),s=new u("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",16);return 32===r.length&&32===t.length&&((27===e||28===e)&&(r=new u(r),t=new u(t),!(r.isZero()||r.gt(s)||t.isZero()||t.gt(s))&&(!1!==f||1!==new u(t).cmp(o))))},exports.baToJSON=function(e){if(c.isBuffer(e))return"0x"+e.toString("hex");if(e instanceof Array){for(var r=[],t=0;t=f.length,"The field "+e.name+" must not have more "+e.length+" bytes")):e.allowZero&&0===f.length||!e.length||i(e.length===f.length,"The field "+e.name+" must have byte length of "+e.length),r.raw[t]=f}r._fields.push(e.name),Object.defineProperty(r,e.name,{enumerable:!0,configurable:!0,get:f,set:o}),e.default&&(r[e.name]=e.default),e.alias&&Object.defineProperty(r,e.alias,{enumerable:!1,configurable:!0,set:o,get:f})}),f)if("string"==typeof f&&(f=c.from(exports.stripHexPrefix(f),"hex")),c.isBuffer(f)&&(f=p.decode(f)),Array.isArray(f)){if(f.length>r._fields.length)throw new Error("wrong number of fields in data");f.forEach(function(e,t){r[r._fields[t]]=exports.toBuffer(e)})}else{if("object"!==(void 0===f?"undefined":e(f)))throw new Error("invalid data");var o=Object.keys(f);t.forEach(function(e){-1!==o.indexOf(e.name)&&(r[e.name]=f[e.name]),-1!==o.indexOf(e.alias)&&(r[e.alias]=f[e.alias])})}}; +},{"ethereum-cryptography/keccak":"ZmXY","./secp256k1-adapter":"Uazd","assert":"g2FE","rlp":"jGda","bn.js":"o7RX","create-hash":"CBfM","safe-buffer":"gIYa","ethjs-util":"VqbN"}],"SZ47":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var r=require("buffer").Buffer;Object.defineProperty(exports,"__esModule",{value:!0});var e=require("bn.js"),o=require("elliptic").ec,n=new o("secp256k1"),t=n.curve;exports.privateKeyExport=function(r,o){void 0===o&&(o=!0);var a=new e(r);if(a.ucmp(t.n)>=0)throw new Error("couldn't export to DER format");var u=n.g.mul(a);return i(u.getX(),u.getY(),o)},exports.privateKeyModInverse=function(o){var n=new e(o);if(n.ucmp(t.n)>=0||n.isZero())throw new Error("private key range is invalid");return n.invm(t.n).toArrayLike(r,"be",32)},exports.signatureImport=function(o){var n=new e(o.r);n.ucmp(t.n)>=0&&(n=new e(0));var i=new e(o.s);return i.ucmp(t.n)>=0&&(i=new e(0)),r.concat([n.toArrayLike(r,"be",32),i.toArrayLike(r,"be",32)])},exports.ecdhUnsafe=function(r,o,a){void 0===a&&(a=!0);var u=n.keyFromPublic(r),c=new e(o);if(c.ucmp(t.n)>=0||c.isZero())throw new Error("scalar was invalid (zero or overflow)");var p=u.pub.mul(c);return i(p.getX(),p.getY(),a)};var i=function(e,o,n){var t;return n?((t=r.alloc(33))[0]=o.isOdd()?3:2,e.toArrayLike(r,"be",32).copy(t,1)):((t=r.alloc(65))[0]=4,e.toArrayLike(r,"be",32).copy(t,1),o.toArrayLike(r,"be",32).copy(t,33)),t}; +},{"bn.js":"o7RX","elliptic":"G54Y","buffer":"z1tx"}],"rhQx":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var r=require("buffer").Buffer;Object.defineProperty(exports,"__esModule",{value:!0});var l=r.from([48,129,211,2,1,1,4,32,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,160,129,133,48,129,130,2,1,1,48,44,6,7,42,134,72,206,61,1,1,2,33,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,254,255,255,252,47,48,6,4,1,0,4,1,7,4,33,2,121,190,102,126,249,220,187,172,85,160,98,149,206,135,11,7,2,155,252,219,45,206,40,217,89,242,129,91,22,248,23,152,2,33,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,254,186,174,220,230,175,72,160,59,191,210,94,140,208,54,65,65,2,1,1,161,36,3,34,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]),n=r.from([48,130,1,19,2,1,1,4,32,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,160,129,165,48,129,162,2,1,1,48,44,6,7,42,134,72,206,61,1,1,2,33,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,254,255,255,252,47,48,6,4,1,0,4,1,7,4,65,4,121,190,102,126,249,220,187,172,85,160,98,149,206,135,11,7,2,155,252,219,45,206,40,217,89,242,129,91,22,248,23,152,72,58,218,119,38,163,196,101,93,164,251,252,14,17,8,168,253,23,180,72,166,133,84,25,156,71,208,143,251,16,212,184,2,33,0,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,254,186,174,220,230,175,72,160,59,191,210,94,140,208,54,65,65,2,1,1,161,68,3,66,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);exports.privateKeyExport=function(e,u,t){void 0===t&&(t=!0);var f=r.from(t?l:n);return e.copy(f,t?8:9),u.copy(f,t?181:214),f},exports.privateKeyImport=function(r){var l=r.length,n=0;if(l2)return null;if(l<(n+=1)+e)return null;var u=r[n+e-1]|(e>1?r[n+e-2]<<8:0);return l<(n+=e)+u?null:l32||lu)return null;if(2!==l[t++])return null;var i=l[t++];if(128&i){if(t+(f=i-128)>u)return null;for(;f>0&&0===l[t];t+=1,f-=1);for(i=0;f>0;t+=1,f-=1)i=(i<<8)+l[t]}if(i>u-t)return null;var o=t;if(t+=i,2!==l[t++])return null;var a=l[t++];if(128&a){if(t+(f=a-128)>u)return null;for(;f>0&&0===l[t];t+=1,f-=1);for(a=0;f>0;t+=1,f-=1)a=(a<<8)+l[t]}if(a>u-t)return null;var v=t;for(t+=a;i>0&&0===l[o];i-=1,o+=1);if(i>32)return null;var p=l.slice(o,o+i);for(p.copy(n,32-p.length);a>0&&0===l[v];a-=1,v+=1);if(a>32)return null;var c=l.slice(v,v+a);return c.copy(e,32-c.length),{r:n,s:e}}; +},{"buffer":"z1tx"}],"XKv4":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var r=require("buffer").Buffer;Object.defineProperty(exports,"__esModule",{value:!0}),exports.ecdhUnsafe=exports.ecdh=exports.recover=exports.verify=exports.sign=exports.signatureImportLax=exports.signatureImport=exports.signatureExport=exports.signatureNormalize=exports.publicKeyCombine=exports.publicKeyTweakMul=exports.publicKeyTweakAdd=exports.publicKeyVerify=exports.publicKeyConvert=exports.publicKeyCreate=exports.privateKeyTweakMul=exports.privateKeyTweakAdd=exports.privateKeyModInverse=exports.privateKeyNegate=exports.privateKeyImport=exports.privateKeyExport=exports.privateKeyVerify=void 0;var e=require("ethereum-cryptography/secp256k1"),t=require("./secp256k1v3-lib/index"),n=require("./secp256k1v3-lib/der");exports.privateKeyVerify=function(r){return 32===r.length&&e.privateKeyVerify(Uint8Array.from(r))},exports.privateKeyExport=function(r,e){if(32!==r.length)throw new RangeError("private key length is invalid");var o=t.privateKeyExport(r,e);return n.privateKeyExport(r,o,e)},exports.privateKeyImport=function(r){if(null!==(r=n.privateKeyImport(r))&&32===r.length&&exports.privateKeyVerify(r))return r;throw new Error("couldn't import from DER format")},exports.privateKeyNegate=function(t){return r.from(e.privateKeyNegate(Uint8Array.from(t)))},exports.privateKeyModInverse=function(e){if(32!==e.length)throw new Error("private key length is invalid");return r.from(t.privateKeyModInverse(Uint8Array.from(e)))},exports.privateKeyTweakAdd=function(t,n){return r.from(e.privateKeyTweakAdd(Uint8Array.from(t),n))},exports.privateKeyTweakMul=function(t,n){return r.from(e.privateKeyTweakMul(Uint8Array.from(t),Uint8Array.from(n)))},exports.publicKeyCreate=function(t,n){return r.from(e.publicKeyCreate(Uint8Array.from(t),n))},exports.publicKeyConvert=function(t,n){return r.from(e.publicKeyConvert(Uint8Array.from(t),n))},exports.publicKeyVerify=function(r){return(33===r.length||65===r.length)&&e.publicKeyVerify(Uint8Array.from(r))},exports.publicKeyTweakAdd=function(t,n,o){return r.from(e.publicKeyTweakAdd(Uint8Array.from(t),Uint8Array.from(n),o))},exports.publicKeyTweakMul=function(t,n,o){return r.from(e.publicKeyTweakMul(Uint8Array.from(t),Uint8Array.from(n),o))},exports.publicKeyCombine=function(t,n){var o=[];return t.forEach(function(r){o.push(Uint8Array.from(r))}),r.from(e.publicKeyCombine(o,n))},exports.signatureNormalize=function(t){return r.from(e.signatureNormalize(Uint8Array.from(t)))},exports.signatureExport=function(t){return r.from(e.signatureExport(Uint8Array.from(t)))},exports.signatureImport=function(t){return r.from(e.signatureImport(Uint8Array.from(t)))},exports.signatureImportLax=function(r){if(0===r.length)throw new RangeError("signature length is invalid");var e=n.signatureImportLax(r);if(null===e)throw new Error("couldn't parse DER signature");return t.signatureImport(e)},exports.sign=function(t,n,o){if(null===o)throw new TypeError("options should be an Object");var i=void 0;if(o){if(i={},null===o.data)throw new TypeError("options.data should be a Buffer");if(o.data){if(32!=o.data.length)throw new RangeError("options.data length is invalid");i.data=new Uint8Array(o.data)}if(null===o.noncefn)throw new TypeError("options.noncefn should be a Function");o.noncefn&&(i.noncefn=function(e,t,n,i,a){var p=null!=n?r.from(n):null,u=null!=i?r.from(i):null,f=r.from("");return o.noncefn&&(f=o.noncefn(r.from(e),r.from(t),p,u,a)),new Uint8Array(f)})}var a=e.ecdsaSign(Uint8Array.from(t),Uint8Array.from(n),i);return{signature:r.from(a.signature),recovery:a.recid}},exports.verify=function(r,t,n){return e.ecdsaVerify(Uint8Array.from(t),Uint8Array.from(r),n)},exports.recover=function(t,n,o,i){return r.from(e.ecdsaRecover(Uint8Array.from(n),o,Uint8Array.from(t),i))},exports.ecdh=function(t,n){return r.from(e.ecdh(Uint8Array.from(t),Uint8Array.from(n),{}))},exports.ecdhUnsafe=function(e,n,o){if(33!==e.length&&65!==e.length)throw new RangeError("public key length is invalid");if(32!==n.length)throw new RangeError("private key length is invalid");return r.from(t.ecdhUnsafe(Uint8Array.from(e),Uint8Array.from(n),o))}; +},{"ethereum-cryptography/secp256k1":"rvN4","./secp256k1v3-lib/index":"SZ47","./secp256k1v3-lib/der":"rhQx","buffer":"z1tx"}],"v3Aw":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var f=require("buffer").Buffer;Object.defineProperty(exports,"__esModule",{value:!0}),exports.KECCAK256_RLP=exports.KECCAK256_RLP_S=exports.KECCAK256_RLP_ARRAY=exports.KECCAK256_RLP_ARRAY_S=exports.KECCAK256_NULL=exports.KECCAK256_NULL_S=exports.TWO_POW256=exports.MAX_INTEGER=void 0;var e=require("bn.js");exports.MAX_INTEGER=new e("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",16),exports.TWO_POW256=new e("10000000000000000000000000000000000000000000000000000000000000000",16),exports.KECCAK256_NULL_S="c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470",exports.KECCAK256_NULL=f.from(exports.KECCAK256_NULL_S,"hex"),exports.KECCAK256_RLP_ARRAY_S="1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",exports.KECCAK256_RLP_ARRAY=f.from(exports.KECCAK256_RLP_ARRAY_S,"hex"),exports.KECCAK256_RLP_S="56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",exports.KECCAK256_RLP=f.from(exports.KECCAK256_RLP_S,"hex"); +},{"bn.js":"o7RX","buffer":"z1tx"}],"WX1z":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var e=require("buffer").Buffer;Object.defineProperty(exports,"__esModule",{value:!0}),exports.baToJSON=exports.addHexPrefix=exports.toUnsigned=exports.fromSigned=exports.bufferToHex=exports.bufferToInt=exports.toBuffer=exports.stripZeros=exports.unpad=exports.setLengthRight=exports.setLength=exports.setLengthLeft=exports.zeros=void 0;var r=require("ethjs-util"),t=require("bn.js");exports.zeros=function(r){return e.allocUnsafe(r).fill(0)},exports.setLengthLeft=function(e,r,t){void 0===t&&(t=!1);var o=exports.zeros(r);return e=exports.toBuffer(e),t?e.length0&&"0"===t.toString();)t=(e=e.slice(1))[0];return e},exports.stripZeros=exports.unpad,exports.toBuffer=function(o){if(!e.isBuffer(o))if(Array.isArray(o))o=e.from(o);else if("string"==typeof o){if(!r.isHexString(o))throw new Error("Cannot convert string to buffer. toBuffer only supports 0x-prefixed hex strings and this string was given: "+o);o=e.from(r.padToEven(r.stripHexPrefix(o)),"hex")}else if("number"==typeof o)o=r.intToBuffer(o);else if(null==o)o=e.allocUnsafe(0);else if(t.isBN(o))o=o.toArrayLike(e);else{if(!o.toArray)throw new Error("invalid type");o=e.from(o.toArray())}return o},exports.bufferToInt=function(e){return new t(exports.toBuffer(e)).toNumber()},exports.bufferToHex=function(e){return"0x"+(e=exports.toBuffer(e)).toString("hex")},exports.fromSigned=function(e){return new t(e).fromTwos(256)},exports.toUnsigned=function(r){return e.from(r.toTwos(256).toArray())},exports.addHexPrefix=function(e){return"string"!=typeof e?e:r.isHexPrefixed(e)?e:"0x"+e},exports.baToJSON=function(r){if(e.isBuffer(r))return"0x"+r.toString("hex");if(r instanceof Array){for(var t=[],o=0;o=8?i+=e[d].toUpperCase():i+=e[d];return i},exports.isValidChecksumAddress=function(e,r){return exports.isValidAddress(e)&&exports.toChecksumAddress(e,r)===e},exports.generateAddress=function(r,s){r=i.toBuffer(r);var t=new o(s);return t.isZero()?u.rlphash([r,null]).slice(-20):u.rlphash([r,e.from(t.toArray())]).slice(-20)},exports.generateAddress2=function(s,t,o){var d=i.toBuffer(s),p=i.toBuffer(t),n=i.toBuffer(o);return r(20===d.length),r(32===p.length),u.keccak256(e.concat([e.from("ff","hex"),d,p,u.keccak256(n)])).slice(-20)},exports.isPrecompiled=function(e){var r=i.unpad(e);return 1===r.length&&r[0]>=1&&r[0]<=8},exports.isValidPrivate=function(e){return t.privateKeyVerify(e)},exports.isValidPublic=function(r,s){return void 0===s&&(s=!1),64===r.length?t.publicKeyVerify(e.concat([e.from([4]),r])):!!s&&t.publicKeyVerify(r)},exports.pubToAddress=function(e,s){return void 0===s&&(s=!1),e=i.toBuffer(e),s&&64!==e.length&&(e=t.publicKeyConvert(e,!1).slice(1)),r(64===e.length),u.keccak(e).slice(-20)},exports.publicToAddress=exports.pubToAddress,exports.privateToAddress=function(e){return exports.publicToAddress(exports.privateToPublic(e))},exports.privateToPublic=function(e){return e=i.toBuffer(e),t.publicKeyCreate(e,!1).slice(1)},exports.importPublic=function(e){return 64!==(e=i.toBuffer(e)).length&&(e=t.publicKeyConvert(e,!1).slice(1)),e}; +},{"assert":"g2FE","ethjs-util":"VqbN","./secp256k1v3-adapter":"XKv4","bn.js":"o7RX","./bytes":"WX1z","./hash":"UV5i","buffer":"z1tx"}],"lXcm":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var e=require("buffer").Buffer;Object.defineProperty(exports,"__esModule",{value:!0}),exports.hashPersonalMessage=exports.isValidSignature=exports.fromRpcSig=exports.toRpcSig=exports.ecrecover=exports.ecsign=void 0;var r=require("./secp256k1v3-adapter"),f=require("bn.js"),t=require("./bytes"),n=require("./hash");function i(e,r){return r?e-(2*r+35):e-27}function o(e){return 0===e||1===e}exports.ecsign=function(e,f,t){var n=r.sign(e,f),i=n.recovery;return{r:n.signature.slice(0,32),s:n.signature.slice(32,64),v:t?i+(2*t+35):i+27}},exports.ecrecover=function(f,n,s,a,u){var c=e.concat([t.setLength(s,32),t.setLength(a,32)],64),g=i(n,u);if(!o(g))throw new Error("Invalid signature v value");var v=r.recover(f,c,g);return r.publicKeyConvert(v,!1).slice(1)},exports.toRpcSig=function(r,f,n,s){if(!o(i(r,s)))throw new Error("Invalid signature v value");return t.bufferToHex(e.concat([t.setLengthLeft(f,32),t.setLengthLeft(n,32),t.toBuffer(r)]))},exports.fromRpcSig=function(e){var r=t.toBuffer(e);if(65!==r.length)throw new Error("Invalid signature length");var f=r[64];return f<27&&(f+=27),{v:f,r:r.slice(0,32),s:r.slice(32,64)}},exports.isValidSignature=function(e,r,t,n,s){void 0===n&&(n=!0);var a=new f("7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0",16),u=new f("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",16);if(32!==r.length||32!==t.length)return!1;if(!o(i(e,s)))return!1;var c=new f(r),g=new f(t);return!(c.isZero()||c.gt(u)||g.isZero()||g.gt(u))&&(!n||1!==g.cmp(a))},exports.hashPersonalMessage=function(r){var f=e.from("Ethereum Signed Message:\n"+r.length.toString(),"utf-8");return n.keccak(e.concat([f,r]))}; +},{"./secp256k1v3-adapter":"XKv4","bn.js":"o7RX","./bytes":"WX1z","./hash":"UV5i","buffer":"z1tx"}],"KySG":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var e=require("buffer").Buffer;Object.defineProperty(exports,"__esModule",{value:!0}),exports.defineProperties=void 0;var r=require("assert"),t=require("ethjs-util"),n=require("rlp"),i=require("./bytes");exports.defineProperties=function(a,f,o){if(a.raw=[],a._fields=[],a.toJSON=function(e){if(void 0===e&&(e=!1),e){var r={};return a._fields.forEach(function(e){r[e]="0x"+a[e].toString("hex")}),r}return i.baToJSON(a.raw)},a.serialize=function(){return n.encode(a.raw)},f.forEach(function(t,n){function f(){return a.raw[n]}function o(f){"00"!==(f=i.toBuffer(f)).toString("hex")||t.allowZero||(f=e.allocUnsafe(0)),t.allowLess&&t.length?(f=i.stripZeros(f),r(t.length>=f.length,"The field "+t.name+" must not have more "+t.length+" bytes")):t.allowZero&&0===f.length||!t.length||r(t.length===f.length,"The field "+t.name+" must have byte length of "+t.length),a.raw[n]=f}a._fields.push(t.name),Object.defineProperty(a,t.name,{enumerable:!0,configurable:!0,get:f,set:o}),t.default&&(a[t.name]=t.default),t.alias&&Object.defineProperty(a,t.alias,{enumerable:!1,configurable:!0,set:o,get:f})}),o)if("string"==typeof o&&(o=e.from(t.stripHexPrefix(o),"hex")),e.isBuffer(o)&&(o=n.decode(o)),Array.isArray(o)){if(o.length>a._fields.length)throw new Error("wrong number of fields in data");o.forEach(function(e,r){a[a._fields[r]]=i.toBuffer(e)})}else{if("object"!=typeof o)throw new Error("invalid data");var l=Object.keys(o);f.forEach(function(e){-1!==l.indexOf(e.name)&&(a[e.name]=o[e.name]),-1!==l.indexOf(e.alias)&&(a[e.alias]=o[e.alias])})}}; +},{"assert":"g2FE","ethjs-util":"VqbN","rlp":"jGda","./bytes":"WX1z","buffer":"z1tx"}],"YoQ7":[function(require,module,exports) { +"use strict";var e=this&&this.__createBinding||(Object.create?function(e,r,t,s){void 0===s&&(s=t),Object.defineProperty(e,s,{enumerable:!0,get:function(){return r[t]}})}:function(e,r,t,s){void 0===s&&(s=t),e[s]=r[t]}),r=this&&this.__exportStar||function(r,t){for(var s in r)"default"===s||t.hasOwnProperty(s)||e(t,r,s)};Object.defineProperty(exports,"__esModule",{value:!0}),exports.secp256k1=exports.rlp=exports.BN=void 0;var t=require("./secp256k1v3-adapter");exports.secp256k1=t;var s=require("ethjs-util"),i=require("bn.js");exports.BN=i;var o=require("rlp");exports.rlp=o,Object.assign(exports,s),r(require("./constants"),exports),r(require("./account"),exports),r(require("./hash"),exports),r(require("./signature"),exports),r(require("./bytes"),exports),r(require("./object"),exports); +},{"./secp256k1v3-adapter":"XKv4","ethjs-util":"VqbN","bn.js":"o7RX","rlp":"jGda","./constants":"v3Aw","./account":"LkR7","./hash":"UV5i","./signature":"lXcm","./bytes":"WX1z","./object":"KySG"}],"NDcM":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var t=require("buffer").Buffer;const r=require("ethereumjs-util"),e=require("bn.js");var i=function(){};function n(t){return t.startsWith("int[")?"int256"+t.slice(3):"int"===t?"int256":t.startsWith("uint[")?"uint256"+t.slice(4):"uint"===t?"uint256":t.startsWith("fixed[")?"fixed128x128"+t.slice(5):"fixed"===t?"fixed128x128":t.startsWith("ufixed[")?"ufixed128x128"+t.slice(6):"ufixed"===t?"ufixed128x128":t}function s(t){return parseInt(/^\D+(\d+)$/.exec(t)[1],10)}function o(t){var r=/^\D+(\d+)x(\d+)$/.exec(t);return[parseInt(r[1],10),parseInt(r[2],10)]}function a(t){var r=t.match(/(.*)\[(.*?)\]$/);return r?""===r[2]?"dynamic":parseInt(r[2],10):null}function u(t){var i=typeof t;if("string"===i)return r.isHexPrefixed(t)?new e(r.stripHexPrefix(t),16):new e(t,10);if("number"===i)return new e(t);if(t.toArray)return t;throw new Error("Argument is not a number")}function f(t){var r=/^(\w+)\((.*)\)$/.exec(t);if(3!==r.length)throw new Error("Invalid method signature");var e=/^(.+)\):\((.+)$/.exec(r[2]);if(null!==e&&3===e.length)return{method:r[1],args:e[1].split(","),retargs:e[2].split(",")};var i=r[2].split(",");return 1===i.length&&""===i[0]&&(i=[]),{method:r[1],args:i}}function h(i,n){var f,d,c,w;if("address"===i)return h("uint160",u(n));if("bool"===i)return h("uint8",n?1:0);if("string"===i)return h("bytes",t.from(n,"utf8"));if(l(i)){if(void 0===n.length)throw new Error("Not an array?");if("dynamic"!==(f=a(i))&&0!==f&&n.length>f)throw new Error("Elements exceed array size: "+f);for(w in c=[],i=i.slice(0,i.lastIndexOf("[")),"string"==typeof n&&(n=JSON.parse(n)),n)c.push(h(i,n[w]));if("dynamic"===f){var p=h("uint256",n.length);c.unshift(p)}return t.concat(c)}if("bytes"===i)return n=t.from(n),c=t.concat([h("uint256",n.length),n]),n.length%32!=0&&(c=t.concat([c,r.zeros(32-n.length%32)])),c;if(i.startsWith("bytes")){if((f=s(i))<1||f>32)throw new Error("Invalid bytes width: "+f);return r.setLengthRight(n,32)}if(i.startsWith("uint")){if((f=s(i))%8||f<8||f>256)throw new Error("Invalid uint width: "+f);if((d=u(n)).bitLength()>f)throw new Error("Supplied uint exceeds width: "+f+" vs "+d.bitLength());if(d<0)throw new Error("Supplied uint is negative");return d.toArrayLike(t,"be",32)}if(i.startsWith("int")){if((f=s(i))%8||f<8||f>256)throw new Error("Invalid int width: "+f);if((d=u(n)).bitLength()>f)throw new Error("Supplied int exceeds width: "+f+" vs "+d.bitLength());return d.toTwos(256).toArrayLike(t,"be",32)}if(i.startsWith("ufixed")){if(f=o(i),(d=u(n))<0)throw new Error("Supplied ufixed is negative");return h("uint256",d.mul(new e(2).pow(new e(f[1]))))}if(i.startsWith("fixed"))return f=o(i),h("int256",u(n).mul(new e(2).pow(new e(f[1]))));throw new Error("Unsupported or invalid type: "+i)}function d(r,i,n){var s,o,a,u;if("string"==typeof r&&(r=c(r)),"address"===r.name)return d(r.rawType,i,n).toArrayLike(t,"be",20).toString("hex");if("bool"===r.name)return d(r.rawType,i,n).toString()===new e(1).toString();if("string"===r.name){var f=d(r.rawType,i,n);return t.from(f,"utf8").toString()}if(r.isArray){for(a=[],s=r.size,"dynamic"===r.size&&(n=d("uint256",i,n).toNumber(),s=d("uint256",i,n).toNumber(),n+=32),u=0;ur.size)throw new Error("Decoded int exceeds width: "+r.size+" vs "+o.bitLength());return o}if(r.name.startsWith("int")){if((o=new e(i.slice(n,n+32),16,"be").fromTwos(256)).bitLength()>r.size)throw new Error("Decoded uint exceeds width: "+r.size+" vs "+o.bitLength());return o}if(r.name.startsWith("ufixed")){if(s=new e(2).pow(new e(r.size[1])),!(o=d("uint256",i,n)).mod(s).isZero())throw new Error("Decimals not supported yet");return o.div(s)}if(r.name.startsWith("fixed")){if(s=new e(2).pow(new e(r.size[1])),!(o=d("int256",i,n)).mod(s).isZero())throw new Error("Decimals not supported yet");return o.div(s)}throw new Error("Unsupported or invalid type: "+r.name)}function c(t){var r,e,i;if(l(t)){r=a(t);var n=t.slice(0,t.lastIndexOf("["));return n=c(n),e={isArray:!0,name:t,size:r,memoryUsage:"dynamic"===r?32:n.memoryUsage*r,subArray:n}}switch(t){case"address":i="uint160";break;case"bool":i="uint8";break;case"string":i="bytes"}if(e={rawType:i,name:t,memoryUsage:32},t.startsWith("bytes")&&"bytes"!==t||t.startsWith("uint")||t.startsWith("int")?e.size=s(t):(t.startsWith("ufixed")||t.startsWith("fixed"))&&(e.size=o(t)),t.startsWith("bytes")&&"bytes"!==t&&(e.size<1||e.size>32))throw new Error("Invalid bytes width: "+e.size);if((t.startsWith("uint")||t.startsWith("int"))&&(e.size%8||e.size<8||e.size>256))throw new Error("Invalid int/uint width: "+e.size);return e}function w(t){return"string"===t||"bytes"===t||"dynamic"===a(t)}function l(t){return t.lastIndexOf("]")===t.length-1}function p(t,r){return t.startsWith("address")||t.startsWith("bytes")?"0x"+r.toString("hex"):r.toString()}function y(t){return t>="0"&&t<="9"}i.eventID=function(e,i){var s=e+"("+i.map(n).join(",")+")";return r.keccak256(t.from(s))},i.methodID=function(t,r){return i.eventID(t,r).slice(0,4)},i.rawEncode=function(r,e){var i=[],s=[],o=0;r.forEach(function(t){if(l(t)){var r=a(t);o+="dynamic"!==r?32*r:32}else o+=32});for(var u=0;uc)throw new Error("Elements exceed array size: "+c)}var w=n.map(function(t){return i.solidityHexValue(d,t,256)});return t.concat(w)}if("bytes"===e)return n;if("string"===e)return t.from(n,"utf8");if("bool"===e){o=o||8;var p=Array(o/4).join("0");return t.from(n?p+"1":p+"0","hex")}if("address"===e){var y=20;return o&&(y=o/8),r.setLengthLeft(n,y)}if(e.startsWith("bytes")){if((f=s(e))<1||f>32)throw new Error("Invalid bytes width: "+f);return r.setLengthRight(n,f)}if(e.startsWith("uint")){if((f=s(e))%8||f<8||f>256)throw new Error("Invalid uint width: "+f);if((h=u(n)).bitLength()>f)throw new Error("Supplied uint exceeds width: "+f+" vs "+h.bitLength());return o=o||f,h.toArrayLike(t,"be",o/8)}if(e.startsWith("int")){if((f=s(e))%8||f<8||f>256)throw new Error("Invalid int width: "+f);if((h=u(n)).bitLength()>f)throw new Error("Supplied int exceeds width: "+f+" vs "+h.bitLength());return o=o||f,h.toTwos(f).toArrayLike(t,"be",o/8)}throw new Error("Unsupported or invalid type: "+e)},i.solidityPack=function(r,e){if(r.length!==e.length)throw new Error("Number of types are not matching the values");for(var s=[],o=0;o>24&255,r[t+1]=n>>16&255,r[t+2]=n>>8&255,r[t+3]=255&n,r[t+4]=e>>24&255,r[t+5]=e>>16&255,r[t+6]=e>>8&255,r[t+7]=255&e}function w(r,t,n,e,o){var i,h=0;for(i=0;i>>8)-1}function v(r,t,n,e){return w(r,t,n,e,16)}function p(r,t,n,e){return w(r,t,n,e,32)}function b(r,t,n,e){!function(r,t,n,e){for(var o,i=255&e[0]|(255&e[1])<<8|(255&e[2])<<16|(255&e[3])<<24,h=255&n[0]|(255&n[1])<<8|(255&n[2])<<16|(255&n[3])<<24,a=255&n[4]|(255&n[5])<<8|(255&n[6])<<16|(255&n[7])<<24,f=255&n[8]|(255&n[9])<<8|(255&n[10])<<16|(255&n[11])<<24,s=255&n[12]|(255&n[13])<<8|(255&n[14])<<16|(255&n[15])<<24,u=255&e[4]|(255&e[5])<<8|(255&e[6])<<16|(255&e[7])<<24,c=255&t[0]|(255&t[1])<<8|(255&t[2])<<16|(255&t[3])<<24,y=255&t[4]|(255&t[5])<<8|(255&t[6])<<16|(255&t[7])<<24,l=255&t[8]|(255&t[9])<<8|(255&t[10])<<16|(255&t[11])<<24,w=255&t[12]|(255&t[13])<<8|(255&t[14])<<16|(255&t[15])<<24,v=255&e[8]|(255&e[9])<<8|(255&e[10])<<16|(255&e[11])<<24,p=255&n[16]|(255&n[17])<<8|(255&n[18])<<16|(255&n[19])<<24,b=255&n[20]|(255&n[21])<<8|(255&n[22])<<16|(255&n[23])<<24,g=255&n[24]|(255&n[25])<<8|(255&n[26])<<16|(255&n[27])<<24,A=255&n[28]|(255&n[29])<<8|(255&n[30])<<16|(255&n[31])<<24,_=255&e[12]|(255&e[13])<<8|(255&e[14])<<16|(255&e[15])<<24,U=i,d=h,E=a,x=f,M=s,m=u,B=c,S=y,k=l,K=w,Y=v,L=p,T=b,z=g,R=A,P=_,N=0;N<20;N+=2)U^=(o=(T^=(o=(k^=(o=(M^=(o=U+T|0)<<7|o>>>25)+U|0)<<9|o>>>23)+M|0)<<13|o>>>19)+k|0)<<18|o>>>14,m^=(o=(d^=(o=(z^=(o=(K^=(o=m+d|0)<<7|o>>>25)+m|0)<<9|o>>>23)+K|0)<<13|o>>>19)+z|0)<<18|o>>>14,Y^=(o=(B^=(o=(E^=(o=(R^=(o=Y+B|0)<<7|o>>>25)+Y|0)<<9|o>>>23)+R|0)<<13|o>>>19)+E|0)<<18|o>>>14,P^=(o=(L^=(o=(S^=(o=(x^=(o=P+L|0)<<7|o>>>25)+P|0)<<9|o>>>23)+x|0)<<13|o>>>19)+S|0)<<18|o>>>14,U^=(o=(x^=(o=(E^=(o=(d^=(o=U+x|0)<<7|o>>>25)+U|0)<<9|o>>>23)+d|0)<<13|o>>>19)+E|0)<<18|o>>>14,m^=(o=(M^=(o=(S^=(o=(B^=(o=m+M|0)<<7|o>>>25)+m|0)<<9|o>>>23)+B|0)<<13|o>>>19)+S|0)<<18|o>>>14,Y^=(o=(K^=(o=(k^=(o=(L^=(o=Y+K|0)<<7|o>>>25)+Y|0)<<9|o>>>23)+L|0)<<13|o>>>19)+k|0)<<18|o>>>14,P^=(o=(R^=(o=(z^=(o=(T^=(o=P+R|0)<<7|o>>>25)+P|0)<<9|o>>>23)+T|0)<<13|o>>>19)+z|0)<<18|o>>>14;U=U+i|0,d=d+h|0,E=E+a|0,x=x+f|0,M=M+s|0,m=m+u|0,B=B+c|0,S=S+y|0,k=k+l|0,K=K+w|0,Y=Y+v|0,L=L+p|0,T=T+b|0,z=z+g|0,R=R+A|0,P=P+_|0,r[0]=U>>>0&255,r[1]=U>>>8&255,r[2]=U>>>16&255,r[3]=U>>>24&255,r[4]=d>>>0&255,r[5]=d>>>8&255,r[6]=d>>>16&255,r[7]=d>>>24&255,r[8]=E>>>0&255,r[9]=E>>>8&255,r[10]=E>>>16&255,r[11]=E>>>24&255,r[12]=x>>>0&255,r[13]=x>>>8&255,r[14]=x>>>16&255,r[15]=x>>>24&255,r[16]=M>>>0&255,r[17]=M>>>8&255,r[18]=M>>>16&255,r[19]=M>>>24&255,r[20]=m>>>0&255,r[21]=m>>>8&255,r[22]=m>>>16&255,r[23]=m>>>24&255,r[24]=B>>>0&255,r[25]=B>>>8&255,r[26]=B>>>16&255,r[27]=B>>>24&255,r[28]=S>>>0&255,r[29]=S>>>8&255,r[30]=S>>>16&255,r[31]=S>>>24&255,r[32]=k>>>0&255,r[33]=k>>>8&255,r[34]=k>>>16&255,r[35]=k>>>24&255,r[36]=K>>>0&255,r[37]=K>>>8&255,r[38]=K>>>16&255,r[39]=K>>>24&255,r[40]=Y>>>0&255,r[41]=Y>>>8&255,r[42]=Y>>>16&255,r[43]=Y>>>24&255,r[44]=L>>>0&255,r[45]=L>>>8&255,r[46]=L>>>16&255,r[47]=L>>>24&255,r[48]=T>>>0&255,r[49]=T>>>8&255,r[50]=T>>>16&255,r[51]=T>>>24&255,r[52]=z>>>0&255,r[53]=z>>>8&255,r[54]=z>>>16&255,r[55]=z>>>24&255,r[56]=R>>>0&255,r[57]=R>>>8&255,r[58]=R>>>16&255,r[59]=R>>>24&255,r[60]=P>>>0&255,r[61]=P>>>8&255,r[62]=P>>>16&255,r[63]=P>>>24&255}(r,t,n,e)}function g(r,t,n,e){!function(r,t,n,e){for(var o,i=255&e[0]|(255&e[1])<<8|(255&e[2])<<16|(255&e[3])<<24,h=255&n[0]|(255&n[1])<<8|(255&n[2])<<16|(255&n[3])<<24,a=255&n[4]|(255&n[5])<<8|(255&n[6])<<16|(255&n[7])<<24,f=255&n[8]|(255&n[9])<<8|(255&n[10])<<16|(255&n[11])<<24,s=255&n[12]|(255&n[13])<<8|(255&n[14])<<16|(255&n[15])<<24,u=255&e[4]|(255&e[5])<<8|(255&e[6])<<16|(255&e[7])<<24,c=255&t[0]|(255&t[1])<<8|(255&t[2])<<16|(255&t[3])<<24,y=255&t[4]|(255&t[5])<<8|(255&t[6])<<16|(255&t[7])<<24,l=255&t[8]|(255&t[9])<<8|(255&t[10])<<16|(255&t[11])<<24,w=255&t[12]|(255&t[13])<<8|(255&t[14])<<16|(255&t[15])<<24,v=255&e[8]|(255&e[9])<<8|(255&e[10])<<16|(255&e[11])<<24,p=255&n[16]|(255&n[17])<<8|(255&n[18])<<16|(255&n[19])<<24,b=255&n[20]|(255&n[21])<<8|(255&n[22])<<16|(255&n[23])<<24,g=255&n[24]|(255&n[25])<<8|(255&n[26])<<16|(255&n[27])<<24,A=255&n[28]|(255&n[29])<<8|(255&n[30])<<16|(255&n[31])<<24,_=255&e[12]|(255&e[13])<<8|(255&e[14])<<16|(255&e[15])<<24,U=0;U<20;U+=2)i^=(o=(b^=(o=(l^=(o=(s^=(o=i+b|0)<<7|o>>>25)+i|0)<<9|o>>>23)+s|0)<<13|o>>>19)+l|0)<<18|o>>>14,u^=(o=(h^=(o=(g^=(o=(w^=(o=u+h|0)<<7|o>>>25)+u|0)<<9|o>>>23)+w|0)<<13|o>>>19)+g|0)<<18|o>>>14,v^=(o=(c^=(o=(a^=(o=(A^=(o=v+c|0)<<7|o>>>25)+v|0)<<9|o>>>23)+A|0)<<13|o>>>19)+a|0)<<18|o>>>14,_^=(o=(p^=(o=(y^=(o=(f^=(o=_+p|0)<<7|o>>>25)+_|0)<<9|o>>>23)+f|0)<<13|o>>>19)+y|0)<<18|o>>>14,i^=(o=(f^=(o=(a^=(o=(h^=(o=i+f|0)<<7|o>>>25)+i|0)<<9|o>>>23)+h|0)<<13|o>>>19)+a|0)<<18|o>>>14,u^=(o=(s^=(o=(y^=(o=(c^=(o=u+s|0)<<7|o>>>25)+u|0)<<9|o>>>23)+c|0)<<13|o>>>19)+y|0)<<18|o>>>14,v^=(o=(w^=(o=(l^=(o=(p^=(o=v+w|0)<<7|o>>>25)+v|0)<<9|o>>>23)+p|0)<<13|o>>>19)+l|0)<<18|o>>>14,_^=(o=(A^=(o=(g^=(o=(b^=(o=_+A|0)<<7|o>>>25)+_|0)<<9|o>>>23)+b|0)<<13|o>>>19)+g|0)<<18|o>>>14;r[0]=i>>>0&255,r[1]=i>>>8&255,r[2]=i>>>16&255,r[3]=i>>>24&255,r[4]=u>>>0&255,r[5]=u>>>8&255,r[6]=u>>>16&255,r[7]=u>>>24&255,r[8]=v>>>0&255,r[9]=v>>>8&255,r[10]=v>>>16&255,r[11]=v>>>24&255,r[12]=_>>>0&255,r[13]=_>>>8&255,r[14]=_>>>16&255,r[15]=_>>>24&255,r[16]=c>>>0&255,r[17]=c>>>8&255,r[18]=c>>>16&255,r[19]=c>>>24&255,r[20]=y>>>0&255,r[21]=y>>>8&255,r[22]=y>>>16&255,r[23]=y>>>24&255,r[24]=l>>>0&255,r[25]=l>>>8&255,r[26]=l>>>16&255,r[27]=l>>>24&255,r[28]=w>>>0&255,r[29]=w>>>8&255,r[30]=w>>>16&255,r[31]=w>>>24&255}(r,t,n,e)}var A=new Uint8Array([101,120,112,97,110,100,32,51,50,45,98,121,116,101,32,107]);function _(r,t,n,e,o,i,h){var a,f,s=new Uint8Array(16),u=new Uint8Array(64);for(f=0;f<16;f++)s[f]=0;for(f=0;f<8;f++)s[f]=i[f];for(;o>=64;){for(b(u,s,h,A),f=0;f<64;f++)r[t+f]=n[e+f]^u[f];for(a=1,f=8;f<16;f++)a=a+(255&s[f])|0,s[f]=255&a,a>>>=8;o-=64,t+=64,e+=64}if(o>0)for(b(u,s,h,A),f=0;f=64;){for(b(f,a,o,A),h=0;h<64;h++)r[t+h]=f[h];for(i=1,h=8;h<16;h++)i=i+(255&a[h])|0,a[h]=255&i,i>>>=8;n-=64,t+=64}if(n>0)for(b(f,a,o,A),h=0;h>>13|n<<3),e=255&r[4]|(255&r[5])<<8,this.r[2]=7939&(n>>>10|e<<6),o=255&r[6]|(255&r[7])<<8,this.r[3]=8191&(e>>>7|o<<9),i=255&r[8]|(255&r[9])<<8,this.r[4]=255&(o>>>4|i<<12),this.r[5]=i>>>1&8190,h=255&r[10]|(255&r[11])<<8,this.r[6]=8191&(i>>>14|h<<2),a=255&r[12]|(255&r[13])<<8,this.r[7]=8065&(h>>>11|a<<5),f=255&r[14]|(255&r[15])<<8,this.r[8]=8191&(a>>>8|f<<8),this.r[9]=f>>>5&127,this.pad[0]=255&r[16]|(255&r[17])<<8,this.pad[1]=255&r[18]|(255&r[19])<<8,this.pad[2]=255&r[20]|(255&r[21])<<8,this.pad[3]=255&r[22]|(255&r[23])<<8,this.pad[4]=255&r[24]|(255&r[25])<<8,this.pad[5]=255&r[26]|(255&r[27])<<8,this.pad[6]=255&r[28]|(255&r[29])<<8,this.pad[7]=255&r[30]|(255&r[31])<<8};function M(r,t,n,e,o,i){var h=new x(i);return h.update(n,e,o),h.finish(r,t),0}function m(r,t,n,e,o,i){var h=new Uint8Array(16);return M(h,0,n,e,o,i),v(r,t,h,0)}function B(r,t,n,e,o){var i;if(n<32)return-1;for(E(r,0,t,0,n,e,o),M(r,16,r,32,n-32,r),i=0;i<16;i++)r[i]=0;return 0}function S(r,t,n,e,o){var i,h=new Uint8Array(32);if(n<32)return-1;if(d(h,0,32,e,o),0!==m(t,16,t,32,n-32,h))return-1;for(E(r,0,t,0,n,e,o),i=0;i<32;i++)r[i]=0;return 0}function k(r,t){var n;for(n=0;n<16;n++)r[n]=0|t[n]}function K(r){var t,n,e=1;for(t=0;t<16;t++)n=r[t]+e+65535,e=Math.floor(n/65536),r[t]=n-65536*e;r[0]+=e-1+37*(e-1)}function Y(r,t,n){for(var e,o=~(n-1),i=0;i<16;i++)e=o&(r[i]^t[i]),r[i]^=e,t[i]^=e}function L(r,n){var e,o,i,h=t(),a=t();for(e=0;e<16;e++)a[e]=n[e];for(K(a),K(a),K(a),o=0;o<2;o++){for(h[0]=a[0]-65517,e=1;e<15;e++)h[e]=a[e]-65535-(h[e-1]>>16&1),h[e-1]&=65535;h[15]=a[15]-32767-(h[14]>>16&1),i=h[15]>>16&1,h[14]&=65535,Y(a,h,1-i)}for(e=0;e<16;e++)r[2*e]=255&a[e],r[2*e+1]=a[e]>>8}function T(r,t){var n=new Uint8Array(32),e=new Uint8Array(32);return L(n,r),L(e,t),p(n,0,e,0)}function z(r){var t=new Uint8Array(32);return L(t,r),1&t[0]}function R(r,t){var n;for(n=0;n<16;n++)r[n]=t[2*n]+(t[2*n+1]<<8);r[15]&=32767}function P(r,t,n){for(var e=0;e<16;e++)r[e]=t[e]+n[e]}function N(r,t,n){for(var e=0;e<16;e++)r[e]=t[e]-n[e]}function O(r,t,n){var e,o,i=0,h=0,a=0,f=0,s=0,u=0,c=0,y=0,l=0,w=0,v=0,p=0,b=0,g=0,A=0,_=0,U=0,d=0,E=0,x=0,M=0,m=0,B=0,S=0,k=0,K=0,Y=0,L=0,T=0,z=0,R=0,P=n[0],N=n[1],O=n[2],C=n[3],F=n[4],I=n[5],Z=n[6],G=n[7],q=n[8],D=n[9],V=n[10],X=n[11],j=n[12],H=n[13],J=n[14],Q=n[15];i+=(e=t[0])*P,h+=e*N,a+=e*O,f+=e*C,s+=e*F,u+=e*I,c+=e*Z,y+=e*G,l+=e*q,w+=e*D,v+=e*V,p+=e*X,b+=e*j,g+=e*H,A+=e*J,_+=e*Q,h+=(e=t[1])*P,a+=e*N,f+=e*O,s+=e*C,u+=e*F,c+=e*I,y+=e*Z,l+=e*G,w+=e*q,v+=e*D,p+=e*V,b+=e*X,g+=e*j,A+=e*H,_+=e*J,U+=e*Q,a+=(e=t[2])*P,f+=e*N,s+=e*O,u+=e*C,c+=e*F,y+=e*I,l+=e*Z,w+=e*G,v+=e*q,p+=e*D,b+=e*V,g+=e*X,A+=e*j,_+=e*H,U+=e*J,d+=e*Q,f+=(e=t[3])*P,s+=e*N,u+=e*O,c+=e*C,y+=e*F,l+=e*I,w+=e*Z,v+=e*G,p+=e*q,b+=e*D,g+=e*V,A+=e*X,_+=e*j,U+=e*H,d+=e*J,E+=e*Q,s+=(e=t[4])*P,u+=e*N,c+=e*O,y+=e*C,l+=e*F,w+=e*I,v+=e*Z,p+=e*G,b+=e*q,g+=e*D,A+=e*V,_+=e*X,U+=e*j,d+=e*H,E+=e*J,x+=e*Q,u+=(e=t[5])*P,c+=e*N,y+=e*O,l+=e*C,w+=e*F,v+=e*I,p+=e*Z,b+=e*G,g+=e*q,A+=e*D,_+=e*V,U+=e*X,d+=e*j,E+=e*H,x+=e*J,M+=e*Q,c+=(e=t[6])*P,y+=e*N,l+=e*O,w+=e*C,v+=e*F,p+=e*I,b+=e*Z,g+=e*G,A+=e*q,_+=e*D,U+=e*V,d+=e*X,E+=e*j,x+=e*H,M+=e*J,m+=e*Q,y+=(e=t[7])*P,l+=e*N,w+=e*O,v+=e*C,p+=e*F,b+=e*I,g+=e*Z,A+=e*G,_+=e*q,U+=e*D,d+=e*V,E+=e*X,x+=e*j,M+=e*H,m+=e*J,B+=e*Q,l+=(e=t[8])*P,w+=e*N,v+=e*O,p+=e*C,b+=e*F,g+=e*I,A+=e*Z,_+=e*G,U+=e*q,d+=e*D,E+=e*V,x+=e*X,M+=e*j,m+=e*H,B+=e*J,S+=e*Q,w+=(e=t[9])*P,v+=e*N,p+=e*O,b+=e*C,g+=e*F,A+=e*I,_+=e*Z,U+=e*G,d+=e*q,E+=e*D,x+=e*V,M+=e*X,m+=e*j,B+=e*H,S+=e*J,k+=e*Q,v+=(e=t[10])*P,p+=e*N,b+=e*O,g+=e*C,A+=e*F,_+=e*I,U+=e*Z,d+=e*G,E+=e*q,x+=e*D,M+=e*V,m+=e*X,B+=e*j,S+=e*H,k+=e*J,K+=e*Q,p+=(e=t[11])*P,b+=e*N,g+=e*O,A+=e*C,_+=e*F,U+=e*I,d+=e*Z,E+=e*G,x+=e*q,M+=e*D,m+=e*V,B+=e*X,S+=e*j,k+=e*H,K+=e*J,Y+=e*Q,b+=(e=t[12])*P,g+=e*N,A+=e*O,_+=e*C,U+=e*F,d+=e*I,E+=e*Z,x+=e*G,M+=e*q,m+=e*D,B+=e*V,S+=e*X,k+=e*j,K+=e*H,Y+=e*J,L+=e*Q,g+=(e=t[13])*P,A+=e*N,_+=e*O,U+=e*C,d+=e*F,E+=e*I,x+=e*Z,M+=e*G,m+=e*q,B+=e*D,S+=e*V,k+=e*X,K+=e*j,Y+=e*H,L+=e*J,T+=e*Q,A+=(e=t[14])*P,_+=e*N,U+=e*O,d+=e*C,E+=e*F,x+=e*I,M+=e*Z,m+=e*G,B+=e*q,S+=e*D,k+=e*V,K+=e*X,Y+=e*j,L+=e*H,T+=e*J,z+=e*Q,_+=(e=t[15])*P,h+=38*(d+=e*O),a+=38*(E+=e*C),f+=38*(x+=e*F),s+=38*(M+=e*I),u+=38*(m+=e*Z),c+=38*(B+=e*G),y+=38*(S+=e*q),l+=38*(k+=e*D),w+=38*(K+=e*V),v+=38*(Y+=e*X),p+=38*(L+=e*j),b+=38*(T+=e*H),g+=38*(z+=e*J),A+=38*(R+=e*Q),i=(e=(i+=38*(U+=e*N))+(o=1)+65535)-65536*(o=Math.floor(e/65536)),h=(e=h+o+65535)-65536*(o=Math.floor(e/65536)),a=(e=a+o+65535)-65536*(o=Math.floor(e/65536)),f=(e=f+o+65535)-65536*(o=Math.floor(e/65536)),s=(e=s+o+65535)-65536*(o=Math.floor(e/65536)),u=(e=u+o+65535)-65536*(o=Math.floor(e/65536)),c=(e=c+o+65535)-65536*(o=Math.floor(e/65536)),y=(e=y+o+65535)-65536*(o=Math.floor(e/65536)),l=(e=l+o+65535)-65536*(o=Math.floor(e/65536)),w=(e=w+o+65535)-65536*(o=Math.floor(e/65536)),v=(e=v+o+65535)-65536*(o=Math.floor(e/65536)),p=(e=p+o+65535)-65536*(o=Math.floor(e/65536)),b=(e=b+o+65535)-65536*(o=Math.floor(e/65536)),g=(e=g+o+65535)-65536*(o=Math.floor(e/65536)),A=(e=A+o+65535)-65536*(o=Math.floor(e/65536)),_=(e=_+o+65535)-65536*(o=Math.floor(e/65536)),i=(e=(i+=o-1+37*(o-1))+(o=1)+65535)-65536*(o=Math.floor(e/65536)),h=(e=h+o+65535)-65536*(o=Math.floor(e/65536)),a=(e=a+o+65535)-65536*(o=Math.floor(e/65536)),f=(e=f+o+65535)-65536*(o=Math.floor(e/65536)),s=(e=s+o+65535)-65536*(o=Math.floor(e/65536)),u=(e=u+o+65535)-65536*(o=Math.floor(e/65536)),c=(e=c+o+65535)-65536*(o=Math.floor(e/65536)),y=(e=y+o+65535)-65536*(o=Math.floor(e/65536)),l=(e=l+o+65535)-65536*(o=Math.floor(e/65536)),w=(e=w+o+65535)-65536*(o=Math.floor(e/65536)),v=(e=v+o+65535)-65536*(o=Math.floor(e/65536)),p=(e=p+o+65535)-65536*(o=Math.floor(e/65536)),b=(e=b+o+65535)-65536*(o=Math.floor(e/65536)),g=(e=g+o+65535)-65536*(o=Math.floor(e/65536)),A=(e=A+o+65535)-65536*(o=Math.floor(e/65536)),_=(e=_+o+65535)-65536*(o=Math.floor(e/65536)),i+=o-1+37*(o-1),r[0]=i,r[1]=h,r[2]=a,r[3]=f,r[4]=s,r[5]=u,r[6]=c,r[7]=y,r[8]=l,r[9]=w,r[10]=v,r[11]=p,r[12]=b,r[13]=g,r[14]=A,r[15]=_}function C(r,t){O(r,t,t)}function F(r,n){var e,o=t();for(e=0;e<16;e++)o[e]=n[e];for(e=253;e>=0;e--)C(o,o),2!==e&&4!==e&&O(o,o,n);for(e=0;e<16;e++)r[e]=o[e]}function I(r,n){var e,o=t();for(e=0;e<16;e++)o[e]=n[e];for(e=250;e>=0;e--)C(o,o),1!==e&&O(o,o,n);for(e=0;e<16;e++)r[e]=o[e]}function Z(r,n,e){var o,i,h=new Uint8Array(32),f=new Float64Array(80),s=t(),u=t(),c=t(),y=t(),l=t(),w=t();for(i=0;i<31;i++)h[i]=n[i];for(h[31]=127&n[31]|64,h[0]&=248,R(f,e),i=0;i<16;i++)u[i]=f[i],y[i]=s[i]=c[i]=0;for(s[0]=y[0]=1,i=254;i>=0;--i)Y(s,u,o=h[i>>>3]>>>(7&i)&1),Y(c,y,o),P(l,s,c),N(s,s,c),P(c,u,y),N(u,u,y),C(y,l),C(w,s),O(s,c,s),O(c,u,l),P(l,s,c),N(s,s,c),C(u,s),N(c,y,w),O(s,c,a),P(s,s,y),O(c,c,s),O(s,y,w),O(y,u,f),C(u,l),Y(s,u,o),Y(c,y,o);for(i=0;i<16;i++)f[i+16]=s[i],f[i+32]=c[i],f[i+48]=u[i],f[i+64]=y[i];var v=f.subarray(32),p=f.subarray(16);return F(v,v),O(p,p,v),L(r,p),0}function G(r,t){return Z(r,t,o)}function q(r,t){return n(t,32),G(r,t)}function D(r,t,n){var o=new Uint8Array(32);return Z(o,n,t),g(r,e,o,A)}x.prototype.blocks=function(r,t,n){for(var e,o,i,h,a,f,s,u,c,y,l,w,v,p,b,g,A,_,U,d=this.fin?0:2048,E=this.h[0],x=this.h[1],M=this.h[2],m=this.h[3],B=this.h[4],S=this.h[5],k=this.h[6],K=this.h[7],Y=this.h[8],L=this.h[9],T=this.r[0],z=this.r[1],R=this.r[2],P=this.r[3],N=this.r[4],O=this.r[5],C=this.r[6],F=this.r[7],I=this.r[8],Z=this.r[9];n>=16;)y=c=0,y+=(E+=8191&(e=255&r[t+0]|(255&r[t+1])<<8))*T,y+=(x+=8191&(e>>>13|(o=255&r[t+2]|(255&r[t+3])<<8)<<3))*(5*Z),y+=(M+=8191&(o>>>10|(i=255&r[t+4]|(255&r[t+5])<<8)<<6))*(5*I),y+=(m+=8191&(i>>>7|(h=255&r[t+6]|(255&r[t+7])<<8)<<9))*(5*F),c=(y+=(B+=8191&(h>>>4|(a=255&r[t+8]|(255&r[t+9])<<8)<<12))*(5*C))>>>13,y&=8191,y+=(S+=a>>>1&8191)*(5*O),y+=(k+=8191&(a>>>14|(f=255&r[t+10]|(255&r[t+11])<<8)<<2))*(5*N),y+=(K+=8191&(f>>>11|(s=255&r[t+12]|(255&r[t+13])<<8)<<5))*(5*P),y+=(Y+=8191&(s>>>8|(u=255&r[t+14]|(255&r[t+15])<<8)<<8))*(5*R),l=c+=(y+=(L+=u>>>5|d)*(5*z))>>>13,l+=E*z,l+=x*T,l+=M*(5*Z),l+=m*(5*I),c=(l+=B*(5*F))>>>13,l&=8191,l+=S*(5*C),l+=k*(5*O),l+=K*(5*N),l+=Y*(5*P),c+=(l+=L*(5*R))>>>13,l&=8191,w=c,w+=E*R,w+=x*z,w+=M*T,w+=m*(5*Z),c=(w+=B*(5*I))>>>13,w&=8191,w+=S*(5*F),w+=k*(5*C),w+=K*(5*O),w+=Y*(5*N),v=c+=(w+=L*(5*P))>>>13,v+=E*P,v+=x*R,v+=M*z,v+=m*T,c=(v+=B*(5*Z))>>>13,v&=8191,v+=S*(5*I),v+=k*(5*F),v+=K*(5*C),v+=Y*(5*O),p=c+=(v+=L*(5*N))>>>13,p+=E*N,p+=x*P,p+=M*R,p+=m*z,c=(p+=B*T)>>>13,p&=8191,p+=S*(5*Z),p+=k*(5*I),p+=K*(5*F),p+=Y*(5*C),b=c+=(p+=L*(5*O))>>>13,b+=E*O,b+=x*N,b+=M*P,b+=m*R,c=(b+=B*z)>>>13,b&=8191,b+=S*T,b+=k*(5*Z),b+=K*(5*I),b+=Y*(5*F),g=c+=(b+=L*(5*C))>>>13,g+=E*C,g+=x*O,g+=M*N,g+=m*P,c=(g+=B*R)>>>13,g&=8191,g+=S*z,g+=k*T,g+=K*(5*Z),g+=Y*(5*I),A=c+=(g+=L*(5*F))>>>13,A+=E*F,A+=x*C,A+=M*O,A+=m*N,c=(A+=B*P)>>>13,A&=8191,A+=S*R,A+=k*z,A+=K*T,A+=Y*(5*Z),_=c+=(A+=L*(5*I))>>>13,_+=E*I,_+=x*F,_+=M*C,_+=m*O,c=(_+=B*N)>>>13,_&=8191,_+=S*P,_+=k*R,_+=K*z,_+=Y*T,U=c+=(_+=L*(5*Z))>>>13,U+=E*Z,U+=x*I,U+=M*F,U+=m*C,c=(U+=B*O)>>>13,U&=8191,U+=S*N,U+=k*P,U+=K*R,U+=Y*z,E=y=8191&(c=(c=((c+=(U+=L*T)>>>13)<<2)+c|0)+(y&=8191)|0),x=l+=c>>>=13,M=w&=8191,m=v&=8191,B=p&=8191,S=b&=8191,k=g&=8191,K=A&=8191,Y=_&=8191,L=U&=8191,t+=16,n-=16;this.h[0]=E,this.h[1]=x,this.h[2]=M,this.h[3]=m,this.h[4]=B,this.h[5]=S,this.h[6]=k,this.h[7]=K,this.h[8]=Y,this.h[9]=L},x.prototype.finish=function(r,t){var n,e,o,i,h=new Uint16Array(10);if(this.leftover){for(i=this.leftover,this.buffer[i++]=1;i<16;i++)this.buffer[i]=0;this.fin=1,this.blocks(this.buffer,0,16)}for(n=this.h[1]>>>13,this.h[1]&=8191,i=2;i<10;i++)this.h[i]+=n,n=this.h[i]>>>13,this.h[i]&=8191;for(this.h[0]+=5*n,n=this.h[0]>>>13,this.h[0]&=8191,this.h[1]+=n,n=this.h[1]>>>13,this.h[1]&=8191,this.h[2]+=n,h[0]=this.h[0]+5,n=h[0]>>>13,h[0]&=8191,i=1;i<10;i++)h[i]=this.h[i]+n,n=h[i]>>>13,h[i]&=8191;for(h[9]-=8192,e=(1^n)-1,i=0;i<10;i++)h[i]&=e;for(e=~e,i=0;i<10;i++)this.h[i]=this.h[i]&e|h[i];for(this.h[0]=65535&(this.h[0]|this.h[1]<<13),this.h[1]=65535&(this.h[1]>>>3|this.h[2]<<10),this.h[2]=65535&(this.h[2]>>>6|this.h[3]<<7),this.h[3]=65535&(this.h[3]>>>9|this.h[4]<<4),this.h[4]=65535&(this.h[4]>>>12|this.h[5]<<1|this.h[6]<<14),this.h[5]=65535&(this.h[6]>>>2|this.h[7]<<11),this.h[6]=65535&(this.h[7]>>>5|this.h[8]<<8),this.h[7]=65535&(this.h[8]>>>8|this.h[9]<<5),o=this.h[0]+this.pad[0],this.h[0]=65535&o,i=1;i<8;i++)o=(this.h[i]+this.pad[i]|0)+(o>>>16)|0,this.h[i]=65535&o;r[t+0]=this.h[0]>>>0&255,r[t+1]=this.h[0]>>>8&255,r[t+2]=this.h[1]>>>0&255,r[t+3]=this.h[1]>>>8&255,r[t+4]=this.h[2]>>>0&255,r[t+5]=this.h[2]>>>8&255,r[t+6]=this.h[3]>>>0&255,r[t+7]=this.h[3]>>>8&255,r[t+8]=this.h[4]>>>0&255,r[t+9]=this.h[4]>>>8&255,r[t+10]=this.h[5]>>>0&255,r[t+11]=this.h[5]>>>8&255,r[t+12]=this.h[6]>>>0&255,r[t+13]=this.h[6]>>>8&255,r[t+14]=this.h[7]>>>0&255,r[t+15]=this.h[7]>>>8&255},x.prototype.update=function(r,t,n){var e,o;if(this.leftover){for((o=16-this.leftover)>n&&(o=n),e=0;e=16&&(o=n-n%16,this.blocks(r,t,o),t+=o,n-=o),n){for(e=0;e=128;){for(d=0;d<16;d++)E=8*d+H,K[d]=n[E+0]<<24|n[E+1]<<16|n[E+2]<<8|n[E+3],Y[d]=n[E+4]<<24|n[E+5]<<16|n[E+6]<<8|n[E+7];for(d=0;d<80;d++)if(o=L,i=T,h=z,a=R,f=P,s=N,u=O,C,y=F,l=I,w=Z,v=G,p=q,b=D,g=V,X,m=65535&(M=X),B=M>>>16,S=65535&(x=C),k=x>>>16,m+=65535&(M=(q>>>14|P<<18)^(q>>>18|P<<14)^(P>>>9|q<<23)),B+=M>>>16,S+=65535&(x=(P>>>14|q<<18)^(P>>>18|q<<14)^(q>>>9|P<<23)),k+=x>>>16,m+=65535&(M=q&D^~q&V),B+=M>>>16,S+=65535&(x=P&N^~P&O),k+=x>>>16,x=j[2*d],m+=65535&(M=j[2*d+1]),B+=M>>>16,S+=65535&x,k+=x>>>16,x=K[d%16],B+=(M=Y[d%16])>>>16,S+=65535&x,k+=x>>>16,S+=(B+=(m+=65535&M)>>>16)>>>16,m=65535&(M=U=65535&m|B<<16),B=M>>>16,S=65535&(x=_=65535&S|(k+=S>>>16)<<16),k=x>>>16,m+=65535&(M=(F>>>28|L<<4)^(L>>>2|F<<30)^(L>>>7|F<<25)),B+=M>>>16,S+=65535&(x=(L>>>28|F<<4)^(F>>>2|L<<30)^(F>>>7|L<<25)),k+=x>>>16,B+=(M=F&I^F&Z^I&Z)>>>16,S+=65535&(x=L&T^L&z^T&z),k+=x>>>16,c=65535&(S+=(B+=(m+=65535&M)>>>16)>>>16)|(k+=S>>>16)<<16,A=65535&m|B<<16,m=65535&(M=v),B=M>>>16,S=65535&(x=a),k=x>>>16,B+=(M=U)>>>16,S+=65535&(x=_),k+=x>>>16,T=o,z=i,R=h,P=a=65535&(S+=(B+=(m+=65535&M)>>>16)>>>16)|(k+=S>>>16)<<16,N=f,O=s,C=u,L=c,I=y,Z=l,G=w,q=v=65535&m|B<<16,D=p,V=b,X=g,F=A,d%16==15)for(E=0;E<16;E++)x=K[E],m=65535&(M=Y[E]),B=M>>>16,S=65535&x,k=x>>>16,x=K[(E+9)%16],m+=65535&(M=Y[(E+9)%16]),B+=M>>>16,S+=65535&x,k+=x>>>16,_=K[(E+1)%16],m+=65535&(M=((U=Y[(E+1)%16])>>>1|_<<31)^(U>>>8|_<<24)^(U>>>7|_<<25)),B+=M>>>16,S+=65535&(x=(_>>>1|U<<31)^(_>>>8|U<<24)^_>>>7),k+=x>>>16,_=K[(E+14)%16],B+=(M=((U=Y[(E+14)%16])>>>19|_<<13)^(_>>>29|U<<3)^(U>>>6|_<<26))>>>16,S+=65535&(x=(_>>>19|U<<13)^(U>>>29|_<<3)^_>>>6),k+=x>>>16,k+=(S+=(B+=(m+=65535&M)>>>16)>>>16)>>>16,K[E]=65535&S|k<<16,Y[E]=65535&m|B<<16;m=65535&(M=F),B=M>>>16,S=65535&(x=L),k=x>>>16,x=r[0],B+=(M=t[0])>>>16,S+=65535&x,k+=x>>>16,k+=(S+=(B+=(m+=65535&M)>>>16)>>>16)>>>16,r[0]=L=65535&S|k<<16,t[0]=F=65535&m|B<<16,m=65535&(M=I),B=M>>>16,S=65535&(x=T),k=x>>>16,x=r[1],B+=(M=t[1])>>>16,S+=65535&x,k+=x>>>16,k+=(S+=(B+=(m+=65535&M)>>>16)>>>16)>>>16,r[1]=T=65535&S|k<<16,t[1]=I=65535&m|B<<16,m=65535&(M=Z),B=M>>>16,S=65535&(x=z),k=x>>>16,x=r[2],B+=(M=t[2])>>>16,S+=65535&x,k+=x>>>16,k+=(S+=(B+=(m+=65535&M)>>>16)>>>16)>>>16,r[2]=z=65535&S|k<<16,t[2]=Z=65535&m|B<<16,m=65535&(M=G),B=M>>>16,S=65535&(x=R),k=x>>>16,x=r[3],B+=(M=t[3])>>>16,S+=65535&x,k+=x>>>16,k+=(S+=(B+=(m+=65535&M)>>>16)>>>16)>>>16,r[3]=R=65535&S|k<<16,t[3]=G=65535&m|B<<16,m=65535&(M=q),B=M>>>16,S=65535&(x=P),k=x>>>16,x=r[4],B+=(M=t[4])>>>16,S+=65535&x,k+=x>>>16,k+=(S+=(B+=(m+=65535&M)>>>16)>>>16)>>>16,r[4]=P=65535&S|k<<16,t[4]=q=65535&m|B<<16,m=65535&(M=D),B=M>>>16,S=65535&(x=N),k=x>>>16,x=r[5],B+=(M=t[5])>>>16,S+=65535&x,k+=x>>>16,k+=(S+=(B+=(m+=65535&M)>>>16)>>>16)>>>16,r[5]=N=65535&S|k<<16,t[5]=D=65535&m|B<<16,m=65535&(M=V),B=M>>>16,S=65535&(x=O),k=x>>>16,x=r[6],B+=(M=t[6])>>>16,S+=65535&x,k+=x>>>16,k+=(S+=(B+=(m+=65535&M)>>>16)>>>16)>>>16,r[6]=O=65535&S|k<<16,t[6]=V=65535&m|B<<16,m=65535&(M=X),B=M>>>16,S=65535&(x=C),k=x>>>16,x=r[7],B+=(M=t[7])>>>16,S+=65535&x,k+=x>>>16,k+=(S+=(B+=(m+=65535&M)>>>16)>>>16)>>>16,r[7]=C=65535&S|k<<16,t[7]=X=65535&m|B<<16,H+=128,e-=128}return e}function J(r,t,n){var e,o=new Int32Array(8),i=new Int32Array(8),h=new Uint8Array(256),a=n;for(o[0]=1779033703,o[1]=3144134277,o[2]=1013904242,o[3]=2773480762,o[4]=1359893119,o[5]=2600822924,o[6]=528734635,o[7]=1541459225,i[0]=4089235720,i[1]=2227873595,i[2]=4271175723,i[3]=1595750129,i[4]=2917565137,i[5]=725511199,i[6]=4215389547,i[7]=327033209,H(o,i,t,n),n%=128,e=0;e=0;--o)W(r,t,e=n[o/8|0]>>(7&o)&1),Q(t,r),Q(r,r),W(r,t,e)}function tr(r,n){var e=[t(),t(),t(),t()];k(e[0],u),k(e[1],c),k(e[2],h),O(e[3],u,c),rr(r,e,n)}function nr(r,e,o){var i,h=new Uint8Array(64),a=[t(),t(),t(),t()];for(o||n(e,32),J(h,e,32),h[0]&=248,h[31]&=127,h[31]|=64,tr(a,h),$(r,a),i=0;i<32;i++)e[i+32]=r[i];return 0}var er=new Float64Array([237,211,245,92,26,99,18,88,214,156,247,162,222,249,222,20,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,16]);function or(r,t){var n,e,o,i;for(e=63;e>=32;--e){for(n=0,o=e-32,i=e-12;o>4)*er[o],n=t[o]>>8,t[o]&=255;for(o=0;o<32;o++)t[o]-=n*er[o];for(e=0;e<32;e++)t[e+1]+=t[e]>>8,r[e]=255&t[e]}function ir(r){var t,n=new Float64Array(64);for(t=0;t<64;t++)n[t]=r[t];for(t=0;t<64;t++)r[t]=0;or(r,n)}function hr(r,n,e,o){var i,h,a=new Uint8Array(64),f=new Uint8Array(64),s=new Uint8Array(64),u=new Float64Array(64),c=[t(),t(),t(),t()];J(a,o,32),a[0]&=248,a[31]&=127,a[31]|=64;var y=e+64;for(i=0;i>7&&N(r[0],i,r[0]),O(r[3],r[0],r[1]),0)}(l,o))return-1;for(a=0;a=0},r.sign.keyPair=function(){var r=new Uint8Array(32),t=new Uint8Array(64);return nr(r,t),{publicKey:r,secretKey:t}},r.sign.keyPair.fromSecretKey=function(r){if(wr(r),64!==r.length)throw new Error("bad secret key size");for(var t=new Uint8Array(32),n=0;n{if(void 0!==n[r])return["bytes32",null==a?"0x0000000000000000000000000000000000000000000000000000000000000000":o.keccak(this.encodeData(r,a,n,i))];if(void 0===a)throw new Error(`missing value for field ${t} of type ${r}`);if("bytes"===r)return["bytes32",o.keccak(a)];if("string"===r)return"string"==typeof a&&(a=e.from(a,"utf8")),["bytes32",o.keccak(a)];if(r.lastIndexOf("]")===r.length-1){const e=r.slice(0,r.lastIndexOf("[")),n=a.map(r=>p(t,e,r));return["bytes32",o.keccak(s.rawEncode(n.map(([e])=>e),n.map(([,e])=>e)))]}return[r,a]};for(const e of n[t]){const[t,n]=p(e.name,e.type,r[e.name]);a.push(t),c.push(n)}}else for(const s of n[t]){let t=r[s.name];if(void 0!==t)if("bytes"===s.type)a.push("bytes32"),t=o.keccak(t),c.push(t);else if("string"===s.type)a.push("bytes32"),"string"==typeof t&&(t=e.from(t,"utf8")),t=o.keccak(t),c.push(t);else if(void 0!==n[s.type])a.push("bytes32"),t=o.keccak(this.encodeData(s.type,t,n,i)),c.push(t);else{if(s.type.lastIndexOf("]")===s.type.length-1)throw new Error("Arrays are unimplemented in encodeData; use V4 extension");a.push(s.type),c.push(t)}}return s.rawEncode(a,c)},encodeType(e,t){let r="",n=this.findTypeDependencies(e,t).filter(t=>t!==e);n=[e].concat(n.sort());for(const o of n){if(!t[o])throw new Error(`No type definition specified: ${o}`);r+=`${o}(${t[o].map(({name:e,type:t})=>`${t} ${e}`).join(",")})`}return r},findTypeDependencies(e,t,r=[]){if([e]=e.match(/^\w*/u),r.includes(e)||void 0===t[e])return r;r.push(e);for(const n of t[e])for(const e of this.findTypeDependencies(n.type,t,r))!r.includes(e)&&r.push(e);return r},hashStruct(e,t,r,n=!0){return o.keccak(this.encodeData(e,t,r,n))},hashType(e,t){return o.keccak(this.encodeType(e,t))},sanitizeData(e){const t={};for(const r in c.properties)e[r]&&(t[r]=e[r]);return"types"in t&&(t.types=Object.assign({EIP712Domain:[]},t.types)),t},sign(t,r=!0){const n=this.sanitizeData(t),s=[e.from("1901","hex")];return s.push(this.hashStruct("EIP712Domain",n.domain,n.types,r)),"EIP712Domain"!==n.primaryType&&s.push(this.hashStruct(n.primaryType,n.message,n.types,r)),o.keccak(e.concat(s))}};function u(e,t,r){const n=o.fromSigned(t),s=o.fromSigned(r),i=o.bufferToInt(e),a=O(o.toUnsigned(n).toString("hex"),64),c=O(o.toUnsigned(s).toString("hex"),64),p=o.stripHexPrefix(o.intToHex(i));return o.addHexPrefix(a.concat(c,p)).toString("hex")}function f(e){if(e){if("number"==typeof e){const t=o.toBuffer(e);e=o.bufferToHex(t)}if("string"!=typeof e){let t="eth-sig-util.normalize() requires hex string or integer input.";throw new Error(t+=` received ${typeof e}: ${e}`)}return o.addHexPrefix(e.toLowerCase())}}function y(e,t){const r=o.toBuffer(t.data),n=o.hashPersonalMessage(r),s=o.ecsign(n,e);return o.bufferToHex(u(s.v,s.r,s.s))}function d(e){const t=j(e),r=o.publicToAddress(t);return o.bufferToHex(r)}function l(e){return`0x${j(e).toString("hex")}`}function g(e){const t=B(e);return o.bufferToHex(t)}function h(e,t){const r=B(t.data),n=o.ecsign(r,e);return o.bufferToHex(u(n.v,n.r,n.s))}function x(e){const t=_(B(e.data),e.sig),r=o.publicToAddress(t);return o.bufferToHex(r)}function b(e,t,r){switch(r){case"x25519-xsalsa20-poly1305":{if("string"!=typeof t.data)throw new Error('Cannot detect secret message, message params should be of the form {data: "secret message"} ');const r=i.box.keyPair();let o;try{o=a.decodeBase64(e)}catch(n){throw new Error("Bad public key")}const s=a.decodeUTF8(t.data),c=i.randomBytes(i.box.nonceLength),p=i.box(s,c,o,r.secretKey);return{version:"x25519-xsalsa20-poly1305",nonce:a.encodeBase64(c),ephemPublicKey:a.encodeBase64(r.publicKey),ciphertext:a.encodeBase64(p)}}default:throw new Error("Encryption type/version not supported")}}function m(t,r,n){const{data:o}=r;if(!o)throw new Error("Cannot encrypt empty msg.data");if("object"==typeof o&&"toJSON"in o)throw new Error("Cannot encrypt with toJSON property. Please remove toJSON property");const s={data:o,padding:""},i=e.byteLength(JSON.stringify(s),"utf-8")%2048;let a=0;return i>0&&(a=2048-i-16),s.padding="0".repeat(a),b(t,{data:JSON.stringify(s)},n)}function T(e,t){switch(e.version){case"x25519-xsalsa20-poly1305":{const n=$(t),o=i.box.keyPair.fromSecretKey(n).secretKey,s=a.decodeBase64(e.nonce),c=a.decodeBase64(e.ciphertext),p=a.decodeBase64(e.ephemPublicKey),u=i.box.open(c,s,p,o);let f;try{f=a.encodeUTF8(u)}catch(r){throw new Error("Decryption failed.")}if(f)return f;throw new Error("Decryption failed.")}default:throw new Error("Encryption type/version not supported.")}}function S(e,t){return JSON.parse(T(e,t)).data}function v(e){const t=$(e),r=i.box.keyPair.fromSecretKey(t).publicKey;return a.encodeBase64(r)}function w(e,t,r="V4"){switch(r){case"V1":return h(e,t);case"V3":return P(e,t);case"V4":default:return D(e,t)}}function E(e,t="V4"){switch(t){case"V1":return x(e);case"V3":return k(e);case"V4":default:return H(e)}}function P(e,t){const r=p.sign(t.data,!1),n=o.ecsign(r,e);return o.bufferToHex(u(n.v,n.r,n.s))}function D(e,t){const r=p.sign(t.data),n=o.ecsign(r,e);return o.bufferToHex(u(n.v,n.r,n.s))}function k(e){const t=_(p.sign(e.data,!1),e.sig),r=o.publicToAddress(t);return o.bufferToHex(r)}function H(e){const t=_(p.sign(e.data),e.sig),r=o.publicToAddress(t);return o.bufferToHex(r)}function B(e){const t=new Error("Expect argument to be non-empty array");if(!("object"==typeof e&&"length"in e&&e.length))throw t;const r=e.map(function(e){return"bytes"===e.type?o.toBuffer(e.value):e.value}),n=e.map(function(e){return e.type}),i=e.map(function(e){if(!e.name)throw t;return`${e.type} ${e.name}`});return s.soliditySHA3(["bytes32","bytes32"],[s.soliditySHA3(new Array(e.length).fill("string"),i),s.soliditySHA3(n,r)])}function _(e,t){const r=o.toBuffer(t),n=o.fromRpcSig(r);return o.ecrecover(e,n.v,n.r,n.s)}function j(e){const t=o.toBuffer(e.data);return _(o.hashPersonalMessage(t),e.sig)}function O(e,t){let r=`${e}`;for(;r.length1&&void 0!==arguments[1])||arguments[1];return this.idMapping.tryIntifyId(e),this.isDebug&&console.log("==> _request payload ".concat(JSON.stringify(e))),new Promise(function(a,o){switch(e.id||(e.id=s.default.genId()),t.callbacks.set(e.id,function(e,t){e?o(e):a(t)}),t.wrapResults.set(e.id,r),e.method){case"eth_accounts":return t.eth_requestAccounts(e);case"eth_coinbase":return t.sendResponse(e.id,t.eth_coinbase());case"net_version":return t.sendResponse(e.id,t.net_version());case"eth_chainId":return t.sendResponse(e.id,t.chainId);case"eth_sign":return t.eth_sign(e);case"personal_sign":return t.personal_sign(e);case"personal_ecRecover":return t.personal_ecRecover(e);case"eth_signTypedData_v3":return t.eth_signTypedData(e,!1);case"eth_signTypedData":case"eth_signTypedData_v4":return t.eth_signTypedData(e,!0);case"eth_sendTransaction":return t.eth_sendTransaction(e);case"eth_requestAccounts":return t.eth_requestAccounts(e);case"wallet_watchAsset":return t.wallet_watchAsset(e);case"wallet_switchEthereumChain":return t.wallet_switchEthereumChain(e);case"wallet_addEthereumChain":return t.wallet_addEthereumChain(e);case"eth_newFilter":case"eth_newBlockFilter":case"eth_newPendingTransactionFilter":case"eth_uninstallFilter":case"eth_subscribe":throw new n.default(4200,"Nova does not support calling ".concat(e.method,". Please use your own solution"));default:return t.callbacks.delete(e.id),t.wrapResults.delete(e.id),e.jsonrpc="2.0",t.rpc.call(e).then(function(e){t.isDebug&&console.log("<== rpc response ".concat(JSON.stringify(e))),a(r?e:e.result)}).catch(o)}})}},{key:"emitConnect",value:function(e){this.emit("connect",{chainId:e})}},{key:"eth_accounts",value:function(){return this.address?[this.address]:[]}},{key:"eth_coinbase",value:function(){return this.address}},{key:"net_version",value:function(){return this.chainId.toString(10)||null}},{key:"eth_sign",value:function(e){var t=s.default.messageToBuffer(e.params[1]),n=s.default.bufferToHex(t);(0,o.default)(t)?this.postMessage("signPersonalMessage",e.id,{data:n}):this.postMessage("signMessage",e.id,{data:n})}},{key:"personal_sign",value:function(e){var t=e.params[0];if(0===s.default.messageToBuffer(t).length){var n=s.default.bufferToHex(t);this.postMessage("signPersonalMessage",e.id,{data:n})}else this.postMessage("signPersonalMessage",e.id,{data:t})}},{key:"personal_ecRecover",value:function(e){this.postMessage("ecRecover",e.id,{signature:e.params[1],message:e.params[0]})}},{key:"eth_signTypedData",value:function(e,t){var n=JSON.parse(e.params[1]),s=i.TypedDataUtils.sign(n,t);this.postMessage("signTypedMessage",e.id,{data:"0x"+s.toString("hex"),raw:e.params[1]})}},{key:"eth_sendTransaction",value:function(e){this.postMessage("signTransaction",e.id,e.params[0])}},{key:"eth_requestAccounts",value:function(e){this.postMessage("requestAccounts",e.id,{})}},{key:"wallet_watchAsset",value:function(e){var t=e.params.options;this.postMessage("watchAsset",e.id,{type:e.type,contract:t.address,symbol:t.symbol,decimals:t.decimals||0})}},{key:"wallet_addEthereumChain",value:function(e){this.postMessage("addEthereumChain",e.id,e.params[0])}},{key:"wallet_switchEthereumChain",value:function(e){this.postMessage("switchEthereumChain",e.id,e.params[0])}},{key:"postMessage",value:function(e,t,n){var s={id:t,name:e,object:n};window.pezkuwi.postMessage?window.pezkuwi.postMessage(s):window.webkit.messageHandlers[e].postMessage(s)}},{key:"sendResponse",value:function(e,t){var n=this.idMapping.tryPopId(e)||e,s=this.callbacks.get(e),r=this.wrapResults.get(e),a={jsonrpc:"2.0",id:n};if("object"===u(t)&&t.jsonrpc&&t.result?a.result=t.result:a.result=t,this.isDebug&&console.log("<== sendResponse id: ".concat(e,", result: ").concat(JSON.stringify(t),", data: ").concat(JSON.stringify(a))),s)s(null,r?a:t),this.callbacks.delete(e);else{console.log("callback id: ".concat(e," not found"));for(var o=0;oconsole.error(e)),()=>{}}}exports.default=t; +},{}],"adMO":[function(require,module,exports) { +"use strict";let e;Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=void 0;class t{constructor(t){e=t}get(){return e("pub(metadata.list)")}provide(t){return e("pub(metadata.provide)",t)}}exports.default=t; +},{}],"VCN6":[function(require,module,exports) { +"use strict";function e(e,t){if(!Object.prototype.hasOwnProperty.call(e,t))throw new TypeError("attempted to use private field on non-instance");return e}Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=e; +},{}],"HsZ4":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=t;var e=0;function t(t){return"__private_"+e+++"_"+t} +},{}],"JJlS":[function(require,module,exports) { +"use strict";var e=Object.prototype.hasOwnProperty,t="~";function n(){}function r(e,t,n){this.fn=e,this.context=t,this.once=n||!1}function o(e,n,o,s,i){if("function"!=typeof o)throw new TypeError("The listener must be a function");var c=new r(o,s||e,i),f=t?t+n:n;return e._events[f]?e._events[f].fn?e._events[f]=[e._events[f],c]:e._events[f].push(c):(e._events[f]=c,e._eventsCount++),e}function s(e,t){0==--e._eventsCount?e._events=new n:delete e._events[t]}function i(){this._events=new n,this._eventsCount=0}Object.create&&(n.prototype=Object.create(null),(new n).__proto__||(t=!1)),i.prototype.eventNames=function(){var n,r,o=[];if(0===this._eventsCount)return o;for(r in n=this._events)e.call(n,r)&&o.push(t?r.slice(1):r);return Object.getOwnPropertySymbols?o.concat(Object.getOwnPropertySymbols(n)):o},i.prototype.listeners=function(e){var n=t?t+e:e,r=this._events[n];if(!r)return[];if(r.fn)return[r.fn];for(var o=0,s=r.length,i=new Array(s);o":t)}).join("\n")}function u(n){if((0,t.isFunction)(n))try{return n()||""}catch(e){return""}return n||""}function l(n,t){var e=n.name,i=n.version,l=arguments.length>2&&void 0!==arguments[2]?arguments[2]:[];(0,r.assert)(e.startsWith("@polkadot"),function(){return"Invalid package descriptor ".concat(e)});var d=a(e);if(d.push({path:u(t),version:i}),1!==d.length)console.warn("".concat(e," has multiple versions, ensure that there is only one installed.\n").concat(o,"\n").concat(s(d)));else{var p=l.filter(function(n){return n&&n.version!==i});p.length&&console.warn("".concat(e," requires direct dependencies exactly matching version ").concat(i,".\n").concat(o,"\n").concat(c(p)))}} +},{"@polkadot/x-global":"c2D8","./is/function.js":"XXEF","./is/string.js":"zz6S","./assert.js":"ICoQ"}],"Hxmh":[function(require,module,exports) { +var __dirname = "/Users/valentun/StudioProjects/pezkuwi-wallet-android/pezkuwi-wallet-dapp-js/node_modules/@polkadot/util"; +var e="/Users/valentun/StudioProjects/pezkuwi-wallet-android/pezkuwi-wallet-dapp-js/node_modules/@polkadot/util",a=require("@polkadot/x-textdecoder"),o=require("@polkadot/x-textencoder"),t=require("./packageInfo.js"),r=require("./versionDetect.js");(0,r.detectPackage)(t.packageInfo,void 0!==e&&e,[a.packageInfo,o.packageInfo]); +},{"@polkadot/x-textdecoder":"BOVF","@polkadot/x-textencoder":"wmao","./packageInfo.js":"WiBo","./versionDetect.js":"AgAp"}],"r9p2":[function(require,module,exports) { +"use strict";function e(e,r){for(var t=Math.ceil(e.length/r),a=Array(t),n=0;n1&&void 0!==arguments[1])||arguments[1];return i.filter(function(i){return!(0,r.isUndefined)(i)&&(n||!(0,e.isNull)(i))})} +},{"../is/null.js":"RBCU","../is/undefined.js":"QCwi"}],"Glt8":[function(require,module,exports) { +"use strict";function e(e){for(var r=new Array(e.reduce(function(e,r){return e+r.length},0)),t=-1,n=0;n1&&void 0!==arguments[1]?arguments[1]:0;return(0,e.assert)(r>0,"Expected non-zero, positive number as a range size"),new Array(r).fill(0).map(function(e,r){return r+t})} +},{"../assert.js":"ICoQ"}],"uc3G":[function(require,module,exports) { +"use strict";function r(r){return o(r)||n(r)||e(r)||t()}function t(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function e(r,t){if(r){if("string"==typeof r)return a(r,t);var e=Object.prototype.toString.call(r).slice(8,-1);return"Object"===e&&r.constructor&&(e=r.constructor.name),"Map"===e||"Set"===e?Array.from(r):"Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e)?a(r,t):void 0}}function n(r){if("undefined"!=typeof Symbol&&null!=r[Symbol.iterator]||null!=r["@@iterator"])return Array.from(r)}function o(r){if(Array.isArray(r))return a(r)}function a(r,t){(null==t||t>r.length)&&(t=r.length);for(var e=0,n=new Array(t);e=65&&r<=70?r-55:r>=97&&r<=102?r-87:r-48&15}function s(t,i,r){var n=o(t,r);return r-1>=i&&(n|=o(t,r-1)<<4),n}function u(t,i,r,n){for(var h=0,e=Math.min(t.length,r),o=i;o=49?s-49+10:s>=17?s-17+10:s}return h}h.isBN=function(t){return t instanceof h||null!==t&&"object"==typeof t&&t.constructor.wordSize===h.wordSize&&Array.isArray(t.words)},h.max=function(t,i){return t.cmp(i)>0?t:i},h.min=function(t,i){return t.cmp(i)<0?t:i},h.prototype._init=function(t,i,n){if("number"==typeof t)return this._initNumber(t,i,n);if("object"==typeof t)return this._initArray(t,i,n);"hex"===i&&(i=16),r(i===(0|i)&&i>=2&&i<=36);var h=0;"-"===(t=t.toString().replace(/\s+/g,""))[0]&&(h++,this.negative=1),h=0;h-=3)o=t[h]|t[h-1]<<8|t[h-2]<<16,this.words[e]|=o<>>26-s&67108863,(s+=24)>=26&&(s-=26,e++);else if("le"===n)for(h=0,e=0;h>>26-s&67108863,(s+=24)>=26&&(s-=26,e++);return this.strip()},h.prototype._parseHex=function(t,i,r){this.length=Math.ceil((t.length-i)/6),this.words=new Array(this.length);for(var n=0;n=i;n-=2)h=s(t,i,n)<=18?(e-=18,o+=1,this.words[o]|=h>>>26):e+=8;else for(n=(t.length-i)%2==0?i+1:i;n=18?(e-=18,o+=1,this.words[o]|=h>>>26):e+=8;this.strip()},h.prototype._parseBase=function(t,i,r){this.words=[0],this.length=1;for(var n=0,h=1;h<=67108863;h*=i)n++;n--,h=h/i|0;for(var e=t.length-r,o=e%n,s=Math.min(e,e-o)+r,a=0,l=r;l1&&0===this.words[this.length-1];)this.length--;return this._normSign()},h.prototype._normSign=function(){return 1===this.length&&0===this.words[0]&&(this.negative=0),this},h.prototype.inspect=function(){return(this.red?""};var a=["","0","00","000","0000","00000","000000","0000000","00000000","000000000","0000000000","00000000000","000000000000","0000000000000","00000000000000","000000000000000","0000000000000000","00000000000000000","000000000000000000","0000000000000000000","00000000000000000000","000000000000000000000","0000000000000000000000","00000000000000000000000","000000000000000000000000","0000000000000000000000000"],l=[0,0,25,16,12,11,10,9,8,8,7,7,7,7,6,6,6,6,6,6,6,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],m=[0,0,33554432,43046721,16777216,48828125,60466176,40353607,16777216,43046721,1e7,19487171,35831808,62748517,7529536,11390625,16777216,24137569,34012224,47045881,64e6,4084101,5153632,6436343,7962624,9765625,11881376,14348907,17210368,20511149,243e5,28629151,33554432,39135393,45435424,52521875,60466176];function f(t,i,r){r.negative=i.negative^t.negative;var n=t.length+i.length|0;r.length=n,n=n-1|0;var h=0|t.words[0],e=0|i.words[0],o=h*e,s=67108863&o,u=o/67108864|0;r.words[0]=s;for(var a=1;a>>26,m=67108863&u,f=Math.min(a,i.length-1),d=Math.max(0,a-t.length+1);d<=f;d++){var p=a-d|0;l+=(o=(h=0|t.words[p])*(e=0|i.words[d])+m)/67108864|0,m=67108863&o}r.words[a]=0|m,u=0|l}return 0!==u?r.words[a]=0|u:r.length--,r.strip()}h.prototype.toString=function(t,i){var n;if(i=0|i||1,16===(t=t||10)||"hex"===t){n="";for(var h=0,e=0,o=0;o>>24-h&16777215)||o!==this.length-1?a[6-u.length]+u+n:u+n,(h+=2)>=26&&(h-=26,o--)}for(0!==e&&(n=e.toString(16)+n);n.length%i!=0;)n="0"+n;return 0!==this.negative&&(n="-"+n),n}if(t===(0|t)&&t>=2&&t<=36){var f=l[t],d=m[t];n="";var p=this.clone();for(p.negative=0;!p.isZero();){var M=p.modn(d).toString(t);n=(p=p.idivn(d)).isZero()?M+n:a[f-M.length]+M+n}for(this.isZero()&&(n="0"+n);n.length%i!=0;)n="0"+n;return 0!==this.negative&&(n="-"+n),n}r(!1,"Base should be between 2 and 36")},h.prototype.toNumber=function(){var t=this.words[0];return 2===this.length?t+=67108864*this.words[1]:3===this.length&&1===this.words[2]?t+=4503599627370496+67108864*this.words[1]:this.length>2&&r(!1,"Number can only safely store up to 53 bits"),0!==this.negative?-t:t},h.prototype.toJSON=function(){return this.toString(16)},h.prototype.toBuffer=function(t,i){return r(void 0!==e),this.toArrayLike(e,t,i)},h.prototype.toArray=function(t,i){return this.toArrayLike(Array,t,i)},h.prototype.toArrayLike=function(t,i,n){var h=this.byteLength(),e=n||Math.max(1,h);r(h<=e,"byte array longer than desired length"),r(e>0,"Requested array length <= 0"),this.strip();var o,s,u="le"===i,a=new t(e),l=this.clone();if(u){for(s=0;!l.isZero();s++)o=l.andln(255),l.iushrn(8),a[s]=o;for(;s=4096&&(r+=13,i>>>=13),i>=64&&(r+=7,i>>>=7),i>=8&&(r+=4,i>>>=4),i>=2&&(r+=2,i>>>=2),r+i},h.prototype._zeroBits=function(t){if(0===t)return 26;var i=t,r=0;return 0==(8191&i)&&(r+=13,i>>>=13),0==(127&i)&&(r+=7,i>>>=7),0==(15&i)&&(r+=4,i>>>=4),0==(3&i)&&(r+=2,i>>>=2),0==(1&i)&&r++,r},h.prototype.bitLength=function(){var t=this.words[this.length-1],i=this._countBits(t);return 26*(this.length-1)+i},h.prototype.zeroBits=function(){if(this.isZero())return 0;for(var t=0,i=0;it.length?this.clone().ior(t):t.clone().ior(this)},h.prototype.uor=function(t){return this.length>t.length?this.clone().iuor(t):t.clone().iuor(this)},h.prototype.iuand=function(t){var i;i=this.length>t.length?t:this;for(var r=0;rt.length?this.clone().iand(t):t.clone().iand(this)},h.prototype.uand=function(t){return this.length>t.length?this.clone().iuand(t):t.clone().iuand(this)},h.prototype.iuxor=function(t){var i,r;this.length>t.length?(i=this,r=t):(i=t,r=this);for(var n=0;nt.length?this.clone().ixor(t):t.clone().ixor(this)},h.prototype.uxor=function(t){return this.length>t.length?this.clone().iuxor(t):t.clone().iuxor(this)},h.prototype.inotn=function(t){r("number"==typeof t&&t>=0);var i=0|Math.ceil(t/26),n=t%26;this._expand(i),n>0&&i--;for(var h=0;h0&&(this.words[h]=~this.words[h]&67108863>>26-n),this.strip()},h.prototype.notn=function(t){return this.clone().inotn(t)},h.prototype.setn=function(t,i){r("number"==typeof t&&t>=0);var n=t/26|0,h=t%26;return this._expand(n+1),this.words[n]=i?this.words[n]|1<t.length?(r=this,n=t):(r=t,n=this);for(var h=0,e=0;e>>26;for(;0!==h&&e>>26;if(this.length=r.length,0!==h)this.words[this.length]=h,this.length++;else if(r!==this)for(;et.length?this.clone().iadd(t):t.clone().iadd(this)},h.prototype.isub=function(t){if(0!==t.negative){t.negative=0;var i=this.iadd(t);return t.negative=1,i._normSign()}if(0!==this.negative)return this.negative=0,this.iadd(t),this.negative=1,this._normSign();var r,n,h=this.cmp(t);if(0===h)return this.negative=0,this.length=1,this.words[0]=0,this;h>0?(r=this,n=t):(r=t,n=this);for(var e=0,o=0;o>26,this.words[o]=67108863&i;for(;0!==e&&o>26,this.words[o]=67108863&i;if(0===e&&o>>13,d=0|o[1],p=8191&d,M=d>>>13,v=0|o[2],g=8191&v,c=v>>>13,w=0|o[3],y=8191&w,b=w>>>13,_=0|o[4],k=8191&_,A=_>>>13,x=0|o[5],S=8191&x,q=x>>>13,B=0|o[6],Z=8191&B,R=B>>>13,N=0|o[7],L=8191&N,I=N>>>13,z=0|o[8],T=8191&z,E=z>>>13,O=0|o[9],j=8191&O,K=O>>>13,P=0|s[0],F=8191&P,C=P>>>13,D=0|s[1],H=8191&D,J=D>>>13,U=0|s[2],G=8191&U,Q=U>>>13,V=0|s[3],W=8191&V,X=V>>>13,Y=0|s[4],$=8191&Y,tt=Y>>>13,it=0|s[5],rt=8191&it,nt=it>>>13,ht=0|s[6],et=8191&ht,ot=ht>>>13,st=0|s[7],ut=8191&st,at=st>>>13,lt=0|s[8],mt=8191<,ft=lt>>>13,dt=0|s[9],pt=8191&dt,Mt=dt>>>13;r.negative=t.negative^i.negative,r.length=19;var vt=(a+(n=Math.imul(m,F))|0)+((8191&(h=(h=Math.imul(m,C))+Math.imul(f,F)|0))<<13)|0;a=((e=Math.imul(f,C))+(h>>>13)|0)+(vt>>>26)|0,vt&=67108863,n=Math.imul(p,F),h=(h=Math.imul(p,C))+Math.imul(M,F)|0,e=Math.imul(M,C);var gt=(a+(n=n+Math.imul(m,H)|0)|0)+((8191&(h=(h=h+Math.imul(m,J)|0)+Math.imul(f,H)|0))<<13)|0;a=((e=e+Math.imul(f,J)|0)+(h>>>13)|0)+(gt>>>26)|0,gt&=67108863,n=Math.imul(g,F),h=(h=Math.imul(g,C))+Math.imul(c,F)|0,e=Math.imul(c,C),n=n+Math.imul(p,H)|0,h=(h=h+Math.imul(p,J)|0)+Math.imul(M,H)|0,e=e+Math.imul(M,J)|0;var ct=(a+(n=n+Math.imul(m,G)|0)|0)+((8191&(h=(h=h+Math.imul(m,Q)|0)+Math.imul(f,G)|0))<<13)|0;a=((e=e+Math.imul(f,Q)|0)+(h>>>13)|0)+(ct>>>26)|0,ct&=67108863,n=Math.imul(y,F),h=(h=Math.imul(y,C))+Math.imul(b,F)|0,e=Math.imul(b,C),n=n+Math.imul(g,H)|0,h=(h=h+Math.imul(g,J)|0)+Math.imul(c,H)|0,e=e+Math.imul(c,J)|0,n=n+Math.imul(p,G)|0,h=(h=h+Math.imul(p,Q)|0)+Math.imul(M,G)|0,e=e+Math.imul(M,Q)|0;var wt=(a+(n=n+Math.imul(m,W)|0)|0)+((8191&(h=(h=h+Math.imul(m,X)|0)+Math.imul(f,W)|0))<<13)|0;a=((e=e+Math.imul(f,X)|0)+(h>>>13)|0)+(wt>>>26)|0,wt&=67108863,n=Math.imul(k,F),h=(h=Math.imul(k,C))+Math.imul(A,F)|0,e=Math.imul(A,C),n=n+Math.imul(y,H)|0,h=(h=h+Math.imul(y,J)|0)+Math.imul(b,H)|0,e=e+Math.imul(b,J)|0,n=n+Math.imul(g,G)|0,h=(h=h+Math.imul(g,Q)|0)+Math.imul(c,G)|0,e=e+Math.imul(c,Q)|0,n=n+Math.imul(p,W)|0,h=(h=h+Math.imul(p,X)|0)+Math.imul(M,W)|0,e=e+Math.imul(M,X)|0;var yt=(a+(n=n+Math.imul(m,$)|0)|0)+((8191&(h=(h=h+Math.imul(m,tt)|0)+Math.imul(f,$)|0))<<13)|0;a=((e=e+Math.imul(f,tt)|0)+(h>>>13)|0)+(yt>>>26)|0,yt&=67108863,n=Math.imul(S,F),h=(h=Math.imul(S,C))+Math.imul(q,F)|0,e=Math.imul(q,C),n=n+Math.imul(k,H)|0,h=(h=h+Math.imul(k,J)|0)+Math.imul(A,H)|0,e=e+Math.imul(A,J)|0,n=n+Math.imul(y,G)|0,h=(h=h+Math.imul(y,Q)|0)+Math.imul(b,G)|0,e=e+Math.imul(b,Q)|0,n=n+Math.imul(g,W)|0,h=(h=h+Math.imul(g,X)|0)+Math.imul(c,W)|0,e=e+Math.imul(c,X)|0,n=n+Math.imul(p,$)|0,h=(h=h+Math.imul(p,tt)|0)+Math.imul(M,$)|0,e=e+Math.imul(M,tt)|0;var bt=(a+(n=n+Math.imul(m,rt)|0)|0)+((8191&(h=(h=h+Math.imul(m,nt)|0)+Math.imul(f,rt)|0))<<13)|0;a=((e=e+Math.imul(f,nt)|0)+(h>>>13)|0)+(bt>>>26)|0,bt&=67108863,n=Math.imul(Z,F),h=(h=Math.imul(Z,C))+Math.imul(R,F)|0,e=Math.imul(R,C),n=n+Math.imul(S,H)|0,h=(h=h+Math.imul(S,J)|0)+Math.imul(q,H)|0,e=e+Math.imul(q,J)|0,n=n+Math.imul(k,G)|0,h=(h=h+Math.imul(k,Q)|0)+Math.imul(A,G)|0,e=e+Math.imul(A,Q)|0,n=n+Math.imul(y,W)|0,h=(h=h+Math.imul(y,X)|0)+Math.imul(b,W)|0,e=e+Math.imul(b,X)|0,n=n+Math.imul(g,$)|0,h=(h=h+Math.imul(g,tt)|0)+Math.imul(c,$)|0,e=e+Math.imul(c,tt)|0,n=n+Math.imul(p,rt)|0,h=(h=h+Math.imul(p,nt)|0)+Math.imul(M,rt)|0,e=e+Math.imul(M,nt)|0;var _t=(a+(n=n+Math.imul(m,et)|0)|0)+((8191&(h=(h=h+Math.imul(m,ot)|0)+Math.imul(f,et)|0))<<13)|0;a=((e=e+Math.imul(f,ot)|0)+(h>>>13)|0)+(_t>>>26)|0,_t&=67108863,n=Math.imul(L,F),h=(h=Math.imul(L,C))+Math.imul(I,F)|0,e=Math.imul(I,C),n=n+Math.imul(Z,H)|0,h=(h=h+Math.imul(Z,J)|0)+Math.imul(R,H)|0,e=e+Math.imul(R,J)|0,n=n+Math.imul(S,G)|0,h=(h=h+Math.imul(S,Q)|0)+Math.imul(q,G)|0,e=e+Math.imul(q,Q)|0,n=n+Math.imul(k,W)|0,h=(h=h+Math.imul(k,X)|0)+Math.imul(A,W)|0,e=e+Math.imul(A,X)|0,n=n+Math.imul(y,$)|0,h=(h=h+Math.imul(y,tt)|0)+Math.imul(b,$)|0,e=e+Math.imul(b,tt)|0,n=n+Math.imul(g,rt)|0,h=(h=h+Math.imul(g,nt)|0)+Math.imul(c,rt)|0,e=e+Math.imul(c,nt)|0,n=n+Math.imul(p,et)|0,h=(h=h+Math.imul(p,ot)|0)+Math.imul(M,et)|0,e=e+Math.imul(M,ot)|0;var kt=(a+(n=n+Math.imul(m,ut)|0)|0)+((8191&(h=(h=h+Math.imul(m,at)|0)+Math.imul(f,ut)|0))<<13)|0;a=((e=e+Math.imul(f,at)|0)+(h>>>13)|0)+(kt>>>26)|0,kt&=67108863,n=Math.imul(T,F),h=(h=Math.imul(T,C))+Math.imul(E,F)|0,e=Math.imul(E,C),n=n+Math.imul(L,H)|0,h=(h=h+Math.imul(L,J)|0)+Math.imul(I,H)|0,e=e+Math.imul(I,J)|0,n=n+Math.imul(Z,G)|0,h=(h=h+Math.imul(Z,Q)|0)+Math.imul(R,G)|0,e=e+Math.imul(R,Q)|0,n=n+Math.imul(S,W)|0,h=(h=h+Math.imul(S,X)|0)+Math.imul(q,W)|0,e=e+Math.imul(q,X)|0,n=n+Math.imul(k,$)|0,h=(h=h+Math.imul(k,tt)|0)+Math.imul(A,$)|0,e=e+Math.imul(A,tt)|0,n=n+Math.imul(y,rt)|0,h=(h=h+Math.imul(y,nt)|0)+Math.imul(b,rt)|0,e=e+Math.imul(b,nt)|0,n=n+Math.imul(g,et)|0,h=(h=h+Math.imul(g,ot)|0)+Math.imul(c,et)|0,e=e+Math.imul(c,ot)|0,n=n+Math.imul(p,ut)|0,h=(h=h+Math.imul(p,at)|0)+Math.imul(M,ut)|0,e=e+Math.imul(M,at)|0;var At=(a+(n=n+Math.imul(m,mt)|0)|0)+((8191&(h=(h=h+Math.imul(m,ft)|0)+Math.imul(f,mt)|0))<<13)|0;a=((e=e+Math.imul(f,ft)|0)+(h>>>13)|0)+(At>>>26)|0,At&=67108863,n=Math.imul(j,F),h=(h=Math.imul(j,C))+Math.imul(K,F)|0,e=Math.imul(K,C),n=n+Math.imul(T,H)|0,h=(h=h+Math.imul(T,J)|0)+Math.imul(E,H)|0,e=e+Math.imul(E,J)|0,n=n+Math.imul(L,G)|0,h=(h=h+Math.imul(L,Q)|0)+Math.imul(I,G)|0,e=e+Math.imul(I,Q)|0,n=n+Math.imul(Z,W)|0,h=(h=h+Math.imul(Z,X)|0)+Math.imul(R,W)|0,e=e+Math.imul(R,X)|0,n=n+Math.imul(S,$)|0,h=(h=h+Math.imul(S,tt)|0)+Math.imul(q,$)|0,e=e+Math.imul(q,tt)|0,n=n+Math.imul(k,rt)|0,h=(h=h+Math.imul(k,nt)|0)+Math.imul(A,rt)|0,e=e+Math.imul(A,nt)|0,n=n+Math.imul(y,et)|0,h=(h=h+Math.imul(y,ot)|0)+Math.imul(b,et)|0,e=e+Math.imul(b,ot)|0,n=n+Math.imul(g,ut)|0,h=(h=h+Math.imul(g,at)|0)+Math.imul(c,ut)|0,e=e+Math.imul(c,at)|0,n=n+Math.imul(p,mt)|0,h=(h=h+Math.imul(p,ft)|0)+Math.imul(M,mt)|0,e=e+Math.imul(M,ft)|0;var xt=(a+(n=n+Math.imul(m,pt)|0)|0)+((8191&(h=(h=h+Math.imul(m,Mt)|0)+Math.imul(f,pt)|0))<<13)|0;a=((e=e+Math.imul(f,Mt)|0)+(h>>>13)|0)+(xt>>>26)|0,xt&=67108863,n=Math.imul(j,H),h=(h=Math.imul(j,J))+Math.imul(K,H)|0,e=Math.imul(K,J),n=n+Math.imul(T,G)|0,h=(h=h+Math.imul(T,Q)|0)+Math.imul(E,G)|0,e=e+Math.imul(E,Q)|0,n=n+Math.imul(L,W)|0,h=(h=h+Math.imul(L,X)|0)+Math.imul(I,W)|0,e=e+Math.imul(I,X)|0,n=n+Math.imul(Z,$)|0,h=(h=h+Math.imul(Z,tt)|0)+Math.imul(R,$)|0,e=e+Math.imul(R,tt)|0,n=n+Math.imul(S,rt)|0,h=(h=h+Math.imul(S,nt)|0)+Math.imul(q,rt)|0,e=e+Math.imul(q,nt)|0,n=n+Math.imul(k,et)|0,h=(h=h+Math.imul(k,ot)|0)+Math.imul(A,et)|0,e=e+Math.imul(A,ot)|0,n=n+Math.imul(y,ut)|0,h=(h=h+Math.imul(y,at)|0)+Math.imul(b,ut)|0,e=e+Math.imul(b,at)|0,n=n+Math.imul(g,mt)|0,h=(h=h+Math.imul(g,ft)|0)+Math.imul(c,mt)|0,e=e+Math.imul(c,ft)|0;var St=(a+(n=n+Math.imul(p,pt)|0)|0)+((8191&(h=(h=h+Math.imul(p,Mt)|0)+Math.imul(M,pt)|0))<<13)|0;a=((e=e+Math.imul(M,Mt)|0)+(h>>>13)|0)+(St>>>26)|0,St&=67108863,n=Math.imul(j,G),h=(h=Math.imul(j,Q))+Math.imul(K,G)|0,e=Math.imul(K,Q),n=n+Math.imul(T,W)|0,h=(h=h+Math.imul(T,X)|0)+Math.imul(E,W)|0,e=e+Math.imul(E,X)|0,n=n+Math.imul(L,$)|0,h=(h=h+Math.imul(L,tt)|0)+Math.imul(I,$)|0,e=e+Math.imul(I,tt)|0,n=n+Math.imul(Z,rt)|0,h=(h=h+Math.imul(Z,nt)|0)+Math.imul(R,rt)|0,e=e+Math.imul(R,nt)|0,n=n+Math.imul(S,et)|0,h=(h=h+Math.imul(S,ot)|0)+Math.imul(q,et)|0,e=e+Math.imul(q,ot)|0,n=n+Math.imul(k,ut)|0,h=(h=h+Math.imul(k,at)|0)+Math.imul(A,ut)|0,e=e+Math.imul(A,at)|0,n=n+Math.imul(y,mt)|0,h=(h=h+Math.imul(y,ft)|0)+Math.imul(b,mt)|0,e=e+Math.imul(b,ft)|0;var qt=(a+(n=n+Math.imul(g,pt)|0)|0)+((8191&(h=(h=h+Math.imul(g,Mt)|0)+Math.imul(c,pt)|0))<<13)|0;a=((e=e+Math.imul(c,Mt)|0)+(h>>>13)|0)+(qt>>>26)|0,qt&=67108863,n=Math.imul(j,W),h=(h=Math.imul(j,X))+Math.imul(K,W)|0,e=Math.imul(K,X),n=n+Math.imul(T,$)|0,h=(h=h+Math.imul(T,tt)|0)+Math.imul(E,$)|0,e=e+Math.imul(E,tt)|0,n=n+Math.imul(L,rt)|0,h=(h=h+Math.imul(L,nt)|0)+Math.imul(I,rt)|0,e=e+Math.imul(I,nt)|0,n=n+Math.imul(Z,et)|0,h=(h=h+Math.imul(Z,ot)|0)+Math.imul(R,et)|0,e=e+Math.imul(R,ot)|0,n=n+Math.imul(S,ut)|0,h=(h=h+Math.imul(S,at)|0)+Math.imul(q,ut)|0,e=e+Math.imul(q,at)|0,n=n+Math.imul(k,mt)|0,h=(h=h+Math.imul(k,ft)|0)+Math.imul(A,mt)|0,e=e+Math.imul(A,ft)|0;var Bt=(a+(n=n+Math.imul(y,pt)|0)|0)+((8191&(h=(h=h+Math.imul(y,Mt)|0)+Math.imul(b,pt)|0))<<13)|0;a=((e=e+Math.imul(b,Mt)|0)+(h>>>13)|0)+(Bt>>>26)|0,Bt&=67108863,n=Math.imul(j,$),h=(h=Math.imul(j,tt))+Math.imul(K,$)|0,e=Math.imul(K,tt),n=n+Math.imul(T,rt)|0,h=(h=h+Math.imul(T,nt)|0)+Math.imul(E,rt)|0,e=e+Math.imul(E,nt)|0,n=n+Math.imul(L,et)|0,h=(h=h+Math.imul(L,ot)|0)+Math.imul(I,et)|0,e=e+Math.imul(I,ot)|0,n=n+Math.imul(Z,ut)|0,h=(h=h+Math.imul(Z,at)|0)+Math.imul(R,ut)|0,e=e+Math.imul(R,at)|0,n=n+Math.imul(S,mt)|0,h=(h=h+Math.imul(S,ft)|0)+Math.imul(q,mt)|0,e=e+Math.imul(q,ft)|0;var Zt=(a+(n=n+Math.imul(k,pt)|0)|0)+((8191&(h=(h=h+Math.imul(k,Mt)|0)+Math.imul(A,pt)|0))<<13)|0;a=((e=e+Math.imul(A,Mt)|0)+(h>>>13)|0)+(Zt>>>26)|0,Zt&=67108863,n=Math.imul(j,rt),h=(h=Math.imul(j,nt))+Math.imul(K,rt)|0,e=Math.imul(K,nt),n=n+Math.imul(T,et)|0,h=(h=h+Math.imul(T,ot)|0)+Math.imul(E,et)|0,e=e+Math.imul(E,ot)|0,n=n+Math.imul(L,ut)|0,h=(h=h+Math.imul(L,at)|0)+Math.imul(I,ut)|0,e=e+Math.imul(I,at)|0,n=n+Math.imul(Z,mt)|0,h=(h=h+Math.imul(Z,ft)|0)+Math.imul(R,mt)|0,e=e+Math.imul(R,ft)|0;var Rt=(a+(n=n+Math.imul(S,pt)|0)|0)+((8191&(h=(h=h+Math.imul(S,Mt)|0)+Math.imul(q,pt)|0))<<13)|0;a=((e=e+Math.imul(q,Mt)|0)+(h>>>13)|0)+(Rt>>>26)|0,Rt&=67108863,n=Math.imul(j,et),h=(h=Math.imul(j,ot))+Math.imul(K,et)|0,e=Math.imul(K,ot),n=n+Math.imul(T,ut)|0,h=(h=h+Math.imul(T,at)|0)+Math.imul(E,ut)|0,e=e+Math.imul(E,at)|0,n=n+Math.imul(L,mt)|0,h=(h=h+Math.imul(L,ft)|0)+Math.imul(I,mt)|0,e=e+Math.imul(I,ft)|0;var Nt=(a+(n=n+Math.imul(Z,pt)|0)|0)+((8191&(h=(h=h+Math.imul(Z,Mt)|0)+Math.imul(R,pt)|0))<<13)|0;a=((e=e+Math.imul(R,Mt)|0)+(h>>>13)|0)+(Nt>>>26)|0,Nt&=67108863,n=Math.imul(j,ut),h=(h=Math.imul(j,at))+Math.imul(K,ut)|0,e=Math.imul(K,at),n=n+Math.imul(T,mt)|0,h=(h=h+Math.imul(T,ft)|0)+Math.imul(E,mt)|0,e=e+Math.imul(E,ft)|0;var Lt=(a+(n=n+Math.imul(L,pt)|0)|0)+((8191&(h=(h=h+Math.imul(L,Mt)|0)+Math.imul(I,pt)|0))<<13)|0;a=((e=e+Math.imul(I,Mt)|0)+(h>>>13)|0)+(Lt>>>26)|0,Lt&=67108863,n=Math.imul(j,mt),h=(h=Math.imul(j,ft))+Math.imul(K,mt)|0,e=Math.imul(K,ft);var It=(a+(n=n+Math.imul(T,pt)|0)|0)+((8191&(h=(h=h+Math.imul(T,Mt)|0)+Math.imul(E,pt)|0))<<13)|0;a=((e=e+Math.imul(E,Mt)|0)+(h>>>13)|0)+(It>>>26)|0,It&=67108863;var zt=(a+(n=Math.imul(j,pt))|0)+((8191&(h=(h=Math.imul(j,Mt))+Math.imul(K,pt)|0))<<13)|0;return a=((e=Math.imul(K,Mt))+(h>>>13)|0)+(zt>>>26)|0,zt&=67108863,u[0]=vt,u[1]=gt,u[2]=ct,u[3]=wt,u[4]=yt,u[5]=bt,u[6]=_t,u[7]=kt,u[8]=At,u[9]=xt,u[10]=St,u[11]=qt,u[12]=Bt,u[13]=Zt,u[14]=Rt,u[15]=Nt,u[16]=Lt,u[17]=It,u[18]=zt,0!==a&&(u[19]=a,r.length++),r};function p(t,i,r){return(new M).mulp(t,i,r)}function M(t,i){this.x=t,this.y=i}Math.imul||(d=f),h.prototype.mulTo=function(t,i){var r=this.length+t.length;return 10===this.length&&10===t.length?d(this,t,i):r<63?f(this,t,i):r<1024?function(t,i,r){r.negative=i.negative^t.negative,r.length=t.length+i.length;for(var n=0,h=0,e=0;e>>26)|0)>>>26,o&=67108863}r.words[e]=s,n=o,o=h}return 0!==n?r.words[e]=n:r.length--,r.strip()}(this,t,i):p(this,t,i)},M.prototype.makeRBT=function(t){for(var i=new Array(t),r=h.prototype._countBits(t)-1,n=0;n>=1;return n},M.prototype.permute=function(t,i,r,n,h,e){for(var o=0;o>>=1)h++;return 1<>>=13,n[2*o+1]=8191&e,e>>>=13;for(o=2*i;o>=26,i+=h/67108864|0,i+=e>>>26,this.words[n]=67108863&e}return 0!==i&&(this.words[n]=i,this.length++),this},h.prototype.muln=function(t){return this.clone().imuln(t)},h.prototype.sqr=function(){return this.mul(this)},h.prototype.isqr=function(){return this.imul(this.clone())},h.prototype.pow=function(t){var i=function(t){for(var i=new Array(t.bitLength()),r=0;r>>h}return i}(t);if(0===i.length)return new h(1);for(var r=this,n=0;n=0);var i,n=t%26,h=(t-n)/26,e=67108863>>>26-n<<26-n;if(0!==n){var o=0;for(i=0;i>>26-n}o&&(this.words[i]=o,this.length++)}if(0!==h){for(i=this.length-1;i>=0;i--)this.words[i+h]=this.words[i];for(i=0;i=0),h=i?(i-i%26)/26:0;var e=t%26,o=Math.min((t-e)/26,this.length),s=67108863^67108863>>>e<o)for(this.length-=o,a=0;a=0&&(0!==l||a>=h);a--){var m=0|this.words[a];this.words[a]=l<<26-e|m>>>e,l=m&s}return u&&0!==l&&(u.words[u.length++]=l),0===this.length&&(this.words[0]=0,this.length=1),this.strip()},h.prototype.ishrn=function(t,i,n){return r(0===this.negative),this.iushrn(t,i,n)},h.prototype.shln=function(t){return this.clone().ishln(t)},h.prototype.ushln=function(t){return this.clone().iushln(t)},h.prototype.shrn=function(t){return this.clone().ishrn(t)},h.prototype.ushrn=function(t){return this.clone().iushrn(t)},h.prototype.testn=function(t){r("number"==typeof t&&t>=0);var i=t%26,n=(t-i)/26,h=1<=0);var i=t%26,n=(t-i)/26;if(r(0===this.negative,"imaskn works only with positive numbers"),this.length<=n)return this;if(0!==i&&n++,this.length=Math.min(n,this.length),0!==i){var h=67108863^67108863>>>i<=67108864;i++)this.words[i]-=67108864,i===this.length-1?this.words[i+1]=1:this.words[i+1]++;return this.length=Math.max(this.length,i+1),this},h.prototype.isubn=function(t){if(r("number"==typeof t),r(t<67108864),t<0)return this.iaddn(-t);if(0!==this.negative)return this.negative=0,this.iaddn(t),this.negative=1,this;if(this.words[0]-=t,1===this.length&&this.words[0]<0)this.words[0]=-this.words[0],this.negative=1;else for(var i=0;i>26)-(u/67108864|0),this.words[h+n]=67108863&e}for(;h>26,this.words[h+n]=67108863&e;if(0===s)return this.strip();for(r(-1===s),s=0,h=0;h>26,this.words[h]=67108863&e;return this.negative=1,this.strip()},h.prototype._wordDiv=function(t,i){var r=(this.length,t.length),n=this.clone(),e=t,o=0|e.words[e.length-1];0!==(r=26-this._countBits(o))&&(e=e.ushln(r),n.iushln(r),o=0|e.words[e.length-1]);var s,u=n.length-e.length;if("mod"!==i){(s=new h(null)).length=u+1,s.words=new Array(s.length);for(var a=0;a=0;m--){var f=67108864*(0|n.words[e.length+m])+(0|n.words[e.length+m-1]);for(f=Math.min(f/o|0,67108863),n._ishlnsubmul(e,f,m);0!==n.negative;)f--,n.negative=0,n._ishlnsubmul(e,1,m),n.isZero()||(n.negative^=1);s&&(s.words[m]=f)}return s&&s.strip(),n.strip(),"div"!==i&&0!==r&&n.iushrn(r),{div:s||null,mod:n}},h.prototype.divmod=function(t,i,n){return r(!t.isZero()),this.isZero()?{div:new h(0),mod:new h(0)}:0!==this.negative&&0===t.negative?(s=this.neg().divmod(t,i),"mod"!==i&&(e=s.div.neg()),"div"!==i&&(o=s.mod.neg(),n&&0!==o.negative&&o.iadd(t)),{div:e,mod:o}):0===this.negative&&0!==t.negative?(s=this.divmod(t.neg(),i),"mod"!==i&&(e=s.div.neg()),{div:e,mod:s.mod}):0!=(this.negative&t.negative)?(s=this.neg().divmod(t.neg(),i),"div"!==i&&(o=s.mod.neg(),n&&0!==o.negative&&o.isub(t)),{div:s.div,mod:o}):t.length>this.length||this.cmp(t)<0?{div:new h(0),mod:this}:1===t.length?"div"===i?{div:this.divn(t.words[0]),mod:null}:"mod"===i?{div:null,mod:new h(this.modn(t.words[0]))}:{div:this.divn(t.words[0]),mod:new h(this.modn(t.words[0]))}:this._wordDiv(t,i);var e,o,s},h.prototype.div=function(t){return this.divmod(t,"div",!1).div},h.prototype.mod=function(t){return this.divmod(t,"mod",!1).mod},h.prototype.umod=function(t){return this.divmod(t,"mod",!0).mod},h.prototype.divRound=function(t){var i=this.divmod(t);if(i.mod.isZero())return i.div;var r=0!==i.div.negative?i.mod.isub(t):i.mod,n=t.ushrn(1),h=t.andln(1),e=r.cmp(n);return e<0||1===h&&0===e?i.div:0!==i.div.negative?i.div.isubn(1):i.div.iaddn(1)},h.prototype.modn=function(t){r(t<=67108863);for(var i=(1<<26)%t,n=0,h=this.length-1;h>=0;h--)n=(i*n+(0|this.words[h]))%t;return n},h.prototype.idivn=function(t){r(t<=67108863);for(var i=0,n=this.length-1;n>=0;n--){var h=(0|this.words[n])+67108864*i;this.words[n]=h/t|0,i=h%t}return this.strip()},h.prototype.divn=function(t){return this.clone().idivn(t)},h.prototype.egcd=function(t){r(0===t.negative),r(!t.isZero());var i=this,n=t.clone();i=0!==i.negative?i.umod(t):i.clone();for(var e=new h(1),o=new h(0),s=new h(0),u=new h(1),a=0;i.isEven()&&n.isEven();)i.iushrn(1),n.iushrn(1),++a;for(var l=n.clone(),m=i.clone();!i.isZero();){for(var f=0,d=1;0==(i.words[0]&d)&&f<26;++f,d<<=1);if(f>0)for(i.iushrn(f);f-- >0;)(e.isOdd()||o.isOdd())&&(e.iadd(l),o.isub(m)),e.iushrn(1),o.iushrn(1);for(var p=0,M=1;0==(n.words[0]&M)&&p<26;++p,M<<=1);if(p>0)for(n.iushrn(p);p-- >0;)(s.isOdd()||u.isOdd())&&(s.iadd(l),u.isub(m)),s.iushrn(1),u.iushrn(1);i.cmp(n)>=0?(i.isub(n),e.isub(s),o.isub(u)):(n.isub(i),s.isub(e),u.isub(o))}return{a:s,b:u,gcd:n.iushln(a)}},h.prototype._invmp=function(t){r(0===t.negative),r(!t.isZero());var i=this,n=t.clone();i=0!==i.negative?i.umod(t):i.clone();for(var e,o=new h(1),s=new h(0),u=n.clone();i.cmpn(1)>0&&n.cmpn(1)>0;){for(var a=0,l=1;0==(i.words[0]&l)&&a<26;++a,l<<=1);if(a>0)for(i.iushrn(a);a-- >0;)o.isOdd()&&o.iadd(u),o.iushrn(1);for(var m=0,f=1;0==(n.words[0]&f)&&m<26;++m,f<<=1);if(m>0)for(n.iushrn(m);m-- >0;)s.isOdd()&&s.iadd(u),s.iushrn(1);i.cmp(n)>=0?(i.isub(n),o.isub(s)):(n.isub(i),s.isub(o))}return(e=0===i.cmpn(1)?o:s).cmpn(0)<0&&e.iadd(t),e},h.prototype.gcd=function(t){if(this.isZero())return t.abs();if(t.isZero())return this.abs();var i=this.clone(),r=t.clone();i.negative=0,r.negative=0;for(var n=0;i.isEven()&&r.isEven();n++)i.iushrn(1),r.iushrn(1);for(;;){for(;i.isEven();)i.iushrn(1);for(;r.isEven();)r.iushrn(1);var h=i.cmp(r);if(h<0){var e=i;i=r,r=e}else if(0===h||0===r.cmpn(1))break;i.isub(r)}return r.iushln(n)},h.prototype.invm=function(t){return this.egcd(t).a.umod(t)},h.prototype.isEven=function(){return 0==(1&this.words[0])},h.prototype.isOdd=function(){return 1==(1&this.words[0])},h.prototype.andln=function(t){return this.words[0]&t},h.prototype.bincn=function(t){r("number"==typeof t);var i=t%26,n=(t-i)/26,h=1<>>26,s&=67108863,this.words[o]=s}return 0!==e&&(this.words[o]=e,this.length++),this},h.prototype.isZero=function(){return 1===this.length&&0===this.words[0]},h.prototype.cmpn=function(t){var i,n=t<0;if(0!==this.negative&&!n)return-1;if(0===this.negative&&n)return 1;if(this.strip(),this.length>1)i=1;else{n&&(t=-t),r(t<=67108863,"Number is too big");var h=0|this.words[0];i=h===t?0:ht.length)return 1;if(this.length=0;r--){var n=0|this.words[r],h=0|t.words[r];if(n!==h){nh&&(i=1);break}}return i},h.prototype.gtn=function(t){return 1===this.cmpn(t)},h.prototype.gt=function(t){return 1===this.cmp(t)},h.prototype.gten=function(t){return this.cmpn(t)>=0},h.prototype.gte=function(t){return this.cmp(t)>=0},h.prototype.ltn=function(t){return-1===this.cmpn(t)},h.prototype.lt=function(t){return-1===this.cmp(t)},h.prototype.lten=function(t){return this.cmpn(t)<=0},h.prototype.lte=function(t){return this.cmp(t)<=0},h.prototype.eqn=function(t){return 0===this.cmpn(t)},h.prototype.eq=function(t){return 0===this.cmp(t)},h.red=function(t){return new _(t)},h.prototype.toRed=function(t){return r(!this.red,"Already a number in reduction context"),r(0===this.negative,"red works only with positives"),t.convertTo(this)._forceRed(t)},h.prototype.fromRed=function(){return r(this.red,"fromRed works only with numbers in reduction context"),this.red.convertFrom(this)},h.prototype._forceRed=function(t){return this.red=t,this},h.prototype.forceRed=function(t){return r(!this.red,"Already a number in reduction context"),this._forceRed(t)},h.prototype.redAdd=function(t){return r(this.red,"redAdd works only with red numbers"),this.red.add(this,t)},h.prototype.redIAdd=function(t){return r(this.red,"redIAdd works only with red numbers"),this.red.iadd(this,t)},h.prototype.redSub=function(t){return r(this.red,"redSub works only with red numbers"),this.red.sub(this,t)},h.prototype.redISub=function(t){return r(this.red,"redISub works only with red numbers"),this.red.isub(this,t)},h.prototype.redShl=function(t){return r(this.red,"redShl works only with red numbers"),this.red.shl(this,t)},h.prototype.redMul=function(t){return r(this.red,"redMul works only with red numbers"),this.red._verify2(this,t),this.red.mul(this,t)},h.prototype.redIMul=function(t){return r(this.red,"redMul works only with red numbers"),this.red._verify2(this,t),this.red.imul(this,t)},h.prototype.redSqr=function(){return r(this.red,"redSqr works only with red numbers"),this.red._verify1(this),this.red.sqr(this)},h.prototype.redISqr=function(){return r(this.red,"redISqr works only with red numbers"),this.red._verify1(this),this.red.isqr(this)},h.prototype.redSqrt=function(){return r(this.red,"redSqrt works only with red numbers"),this.red._verify1(this),this.red.sqrt(this)},h.prototype.redInvm=function(){return r(this.red,"redInvm works only with red numbers"),this.red._verify1(this),this.red.invm(this)},h.prototype.redNeg=function(){return r(this.red,"redNeg works only with red numbers"),this.red._verify1(this),this.red.neg(this)},h.prototype.redPow=function(t){return r(this.red&&!t.red,"redPow(normalNum)"),this.red._verify1(this),this.red.pow(this,t)};var v={k256:null,p224:null,p192:null,p25519:null};function g(t,i){this.name=t,this.p=new h(i,16),this.n=this.p.bitLength(),this.k=new h(1).iushln(this.n).isub(this.p),this.tmp=this._tmp()}function c(){g.call(this,"k256","ffffffff ffffffff ffffffff ffffffff ffffffff ffffffff fffffffe fffffc2f")}function w(){g.call(this,"p224","ffffffff ffffffff ffffffff ffffffff 00000000 00000000 00000001")}function y(){g.call(this,"p192","ffffffff ffffffff ffffffff fffffffe ffffffff ffffffff")}function b(){g.call(this,"25519","7fffffffffffffff ffffffffffffffff ffffffffffffffff ffffffffffffffed")}function _(t){if("string"==typeof t){var i=h._prime(t);this.m=i.p,this.prime=i}else r(t.gtn(1),"modulus must be greater than 1"),this.m=t,this.prime=null}function k(t){_.call(this,t),this.shift=this.m.bitLength(),this.shift%26!=0&&(this.shift+=26-this.shift%26),this.r=new h(1).iushln(this.shift),this.r2=this.imod(this.r.sqr()),this.rinv=this.r._invmp(this.m),this.minv=this.rinv.mul(this.r).isubn(1).div(this.m),this.minv=this.minv.umod(this.r),this.minv=this.r.sub(this.minv)}g.prototype._tmp=function(){var t=new h(null);return t.words=new Array(Math.ceil(this.n/13)),t},g.prototype.ireduce=function(t){var i,r=t;do{this.split(r,this.tmp),i=(r=(r=this.imulK(r)).iadd(this.tmp)).bitLength()}while(i>this.n);var n=i0?r.isub(this.p):void 0!==r.strip?r.strip():r._strip(),r},g.prototype.split=function(t,i){t.iushrn(this.n,0,i)},g.prototype.imulK=function(t){return t.imul(this.k)},n(c,g),c.prototype.split=function(t,i){for(var r=Math.min(t.length,9),n=0;n>>22,h=e}h>>>=22,t.words[n-10]=h,0===h&&t.length>10?t.length-=10:t.length-=9},c.prototype.imulK=function(t){t.words[t.length]=0,t.words[t.length+1]=0,t.length+=2;for(var i=0,r=0;r>>=26,t.words[r]=h,i=n}return 0!==i&&(t.words[t.length++]=i),t},h._prime=function(t){if(v[t])return v[t];var i;if("k256"===t)i=new c;else if("p224"===t)i=new w;else if("p192"===t)i=new y;else{if("p25519"!==t)throw new Error("Unknown prime "+t);i=new b}return v[t]=i,i},_.prototype._verify1=function(t){r(0===t.negative,"red works only with positives"),r(t.red,"red works only with red numbers")},_.prototype._verify2=function(t,i){r(0==(t.negative|i.negative),"red works only with positives"),r(t.red&&t.red===i.red,"red works only with red numbers")},_.prototype.imod=function(t){return this.prime?this.prime.ireduce(t)._forceRed(this):t.umod(this.m)._forceRed(this)},_.prototype.neg=function(t){return t.isZero()?t.clone():this.m.sub(t)._forceRed(this)},_.prototype.add=function(t,i){this._verify2(t,i);var r=t.add(i);return r.cmp(this.m)>=0&&r.isub(this.m),r._forceRed(this)},_.prototype.iadd=function(t,i){this._verify2(t,i);var r=t.iadd(i);return r.cmp(this.m)>=0&&r.isub(this.m),r},_.prototype.sub=function(t,i){this._verify2(t,i);var r=t.sub(i);return r.cmpn(0)<0&&r.iadd(this.m),r._forceRed(this)},_.prototype.isub=function(t,i){this._verify2(t,i);var r=t.isub(i);return r.cmpn(0)<0&&r.iadd(this.m),r},_.prototype.shl=function(t,i){return this._verify1(t),this.imod(t.ushln(i))},_.prototype.imul=function(t,i){return this._verify2(t,i),this.imod(t.imul(i))},_.prototype.mul=function(t,i){return this._verify2(t,i),this.imod(t.mul(i))},_.prototype.isqr=function(t){return this.imul(t,t.clone())},_.prototype.sqr=function(t){return this.mul(t,t)},_.prototype.sqrt=function(t){if(t.isZero())return t.clone();var i=this.m.andln(3);if(r(i%2==1),3===i){var n=this.m.add(new h(1)).iushrn(2);return this.pow(t,n)}for(var e=this.m.subn(1),o=0;!e.isZero()&&0===e.andln(1);)o++,e.iushrn(1);r(!e.isZero());var s=new h(1).toRed(this),u=s.redNeg(),a=this.m.subn(1).iushrn(1),l=this.m.bitLength();for(l=new h(2*l*l).toRed(this);0!==this.pow(l,a).cmp(u);)l.redIAdd(u);for(var m=this.pow(l,e),f=this.pow(t,e.addn(1).iushrn(1)),d=this.pow(t,e),p=o;0!==d.cmp(s);){for(var M=d,v=0;0!==M.cmp(s);v++)M=M.redSqr();r(v=0;n--){for(var a=i.words[n],l=u-1;l>=0;l--){var m=a>>l&1;e!==r[0]&&(e=this.sqr(e)),0!==m||0!==o?(o<<=1,o|=m,(4===++s||0===n&&0===l)&&(e=this.mul(e,r[o]),s=0,o=0)):s=0}u=26}return e},_.prototype.convertTo=function(t){var i=t.umod(this.m);return i===t?i.clone():i},_.prototype.convertFrom=function(t){var i=t.clone();return i.red=null,i},h.mont=function(t){return new k(t)},n(k,_),k.prototype.convertTo=function(t){return this.imod(t.ushln(this.shift))},k.prototype.convertFrom=function(t){var i=this.imod(t.mul(this.rinv));return i.red=null,i},k.prototype.imul=function(t,i){if(t.isZero()||i.isZero())return t.words[0]=0,t.length=1,t;var r=t.imul(i),n=r.maskn(this.shift).mul(this.minv).imaskn(this.shift).mul(this.m),h=r.isub(n).iushrn(this.shift),e=h;return h.cmp(this.m)>=0?e=h.isub(this.m):h.cmpn(0)<0&&(e=h.iadd(this.m)),e._forceRed(this)},k.prototype.mul=function(t,i){if(t.isZero()||i.isZero())return new h(0)._forceRed(this);var r=t.mul(i),n=r.maskn(this.shift).mul(this.minv).imaskn(this.shift).mul(this.m),e=r.isub(n).iushrn(this.shift),o=e;return e.cmp(this.m)>=0?o=e.isub(this.m):e.cmpn(0)<0&&(o=e.iadd(this.m)),o._forceRed(this)},k.prototype.invm=function(t){return this.imod(t._invmp(this.m).mul(this.r2))._forceRed(this)}}("undefined"==typeof module||module,this); +},{"buffer":"rDCW"}],"SLso":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"BN",{enumerable:!0,get:function(){return e.default}});var e=r(require("bn.js"));function r(e){return e&&e.__esModule?e:{default:e}} +},{"bn.js":"BOxy"}],"HNIg":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.BN_ZERO=exports.BN_TWO=exports.BN_THREE=exports.BN_THOUSAND=exports.BN_TEN=exports.BN_SIX=exports.BN_SEVEN=exports.BN_QUINTILL=exports.BN_ONE=exports.BN_NINE=exports.BN_MILLION=exports.BN_MAX_INTEGER=exports.BN_HUNDRED=exports.BN_FOUR=exports.BN_FIVE=exports.BN_EIGHT=exports.BN_BILLION=void 0;var N=require("./bn.js"),e=new N.BN(0);exports.BN_ZERO=e;var r=new N.BN(1);exports.BN_ONE=r;var B=new N.BN(2);exports.BN_TWO=B;var _=new N.BN(3);exports.BN_THREE=_;var s=new N.BN(4);exports.BN_FOUR=s;var t=new N.BN(5);exports.BN_FIVE=t;var o=new N.BN(6);exports.BN_SIX=o;var p=new N.BN(7);exports.BN_SEVEN=p;var x=new N.BN(8);exports.BN_EIGHT=x;var E=new N.BN(9);exports.BN_NINE=E;var I=new N.BN(10);exports.BN_TEN=I;var v=new N.BN(100);exports.BN_HUNDRED=v;var a=new N.BN(1e3);exports.BN_THOUSAND=a;var n=new N.BN(1e6);exports.BN_MILLION=n;var w=new N.BN(1e9);exports.BN_BILLION=w;var O=w.mul(w);exports.BN_QUINTILL=O;var T=new N.BN(Number.MAX_SAFE_INTEGER);exports.BN_MAX_INTEGER=T; +},{"./bn.js":"SLso"}],"Glli":[function(require,module,exports) { +"use strict";function e(e,r,t){return r in e?Object.defineProperty(e,r,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[r]=t,e}Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=e; +},{}],"Eabu":[function(require,module,exports) { +"use strict";function e(e){return"boolean"==typeof e}Object.defineProperty(exports,"__esModule",{value:!0}),exports.isBoolean=e; +},{}],"vZtg":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isHex=t;var e=/^0x[a-fA-F0-9]+$/;function t(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:-1,n=arguments.length>2&&void 0!==arguments[2]&&arguments[2];return!("string"!=typeof t||"0x"!==t&&!e.test(t))&&(-1===r?t.length%2==0||n:t.length===2+Math.ceil(r/4))} +},{}],"fYf8":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.hexHasPrefix=r;var e=require("../is/hex.js");function r(r){return!(!r||!(0,e.isHex)(r,-1,!0)||"0x"!==r.substr(0,2))} +},{"../is/hex.js":"vZtg"}],"XjK7":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.hexStripPrefix=t;var e=require("./hasPrefix.js"),r=/^[a-fA-F0-9]+$/;function t(t){if(!t)return"";if((0,e.hexHasPrefix)(t))return t.substr(2);if(r.test(t))return t;throw new Error("Invalid hex ".concat(t," passed to hexStripPrefix"))} +},{"./hasPrefix.js":"fYf8"}],"DJg4":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.hexToBn=c;var e=i(require("@babel/runtime/helpers/esm/defineProperty")),r=require("../bn/bn.js"),t=require("../is/boolean.js"),n=require("./stripPrefix.js");function i(e){return e&&e.__esModule?e:{default:e}}function o(e,r){var t=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);r&&(n=n.filter(function(r){return Object.getOwnPropertyDescriptor(e,r).enumerable})),t.push.apply(t,n)}return t}function s(r){for(var t=1;t1&&void 0!==arguments[1]?arguments[1]:{isLe:!1,isNegative:!1};if(!e)return new r.BN(0);var o=s({isLe:!1,isNegative:!1},(0,t.isBoolean)(i)?{isLe:i}:i),c=(0,n.hexStripPrefix)(e),f=new r.BN((o.isLe?u(c):c)||"00",16);return o.isNegative?f.fromTwos(4*c.length):f} +},{"@babel/runtime/helpers/esm/defineProperty":"Glli","../bn/bn.js":"SLso","../is/boolean.js":"Eabu","./stripPrefix.js":"XjK7"}],"y3fw":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"bnFromHex",{enumerable:!0,get:function(){return e.hexToBn}});var e=require("../hex/toBn.js"); +},{"../hex/toBn.js":"DJg4"}],"AZ7P":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.checkMaxMin=t;var e=require("../assert.js"),r=require("./bn.js");function t(t,s){return(0,e.assert)(s.length>=1,"Must provide one or more BN arguments"),s.reduce(function(e,s){return r.BN[t](e,s)},s[0])} +},{"../assert.js":"ICoQ","./bn.js":"SLso"}],"FOwf":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.bnMax=r;var e=require("./util.js");function r(){for(var r=arguments.length,t=new Array(r),n=0;n1&&void 0!==arguments[1]?arguments[1]:-1;if(!i)return new Uint8Array;(0,e.assert)((0,r.isHex)(i),function(){return"Expected hex value to convert, found '".concat(i,"'")});for(var s=(0,t.hexStripPrefix)(i),a=s.length/2,u=Math.ceil(-1===n?a:n/8),o=new Uint8Array(u),x=Math.max(0,u-a),c=0;c0)throw new Error("Invalid string. Length must be a multiple of 4");var e=r.indexOf("=");return-1===e&&(e=t),[e,e===t?0:4-e%4]}function u(r){var t=h(r),e=t[0],n=t[1];return 3*(e+n)/4-n}function c(r,t,e){return 3*(t+e)/4-e}function i(r){var n,o,a=h(r),u=a[0],i=a[1],f=new e(c(r,u,i)),A=0,d=i>0?u-4:u;for(o=0;o>16&255,f[A++]=n>>8&255,f[A++]=255&n;return 2===i&&(n=t[r.charCodeAt(o)]<<2|t[r.charCodeAt(o+1)]>>4,f[A++]=255&n),1===i&&(n=t[r.charCodeAt(o)]<<10|t[r.charCodeAt(o+1)]<<4|t[r.charCodeAt(o+2)]>>2,f[A++]=n>>8&255,f[A++]=255&n),f}function f(t){return r[t>>18&63]+r[t>>12&63]+r[t>>6&63]+r[63&t]}function A(r,t,e){for(var n,o=[],a=t;au?u:h+16383));return 1===o?(e=t[n-1],a.push(r[e>>2]+r[e<<4&63]+"==")):2===o&&(e=(t[n-2]<<8)+t[n-1],a.push(r[e>>10]+r[e>>4&63]+r[e<<2&63]+"=")),a.join("")}t["-".charCodeAt(0)]=62,t["_".charCodeAt(0)]=63; +},{}],"JgNJ":[function(require,module,exports) { +exports.read=function(a,o,t,r,h){var M,p,w=8*h-r-1,f=(1<>1,i=-7,N=t?h-1:0,n=t?-1:1,s=a[o+N];for(N+=n,M=s&(1<<-i)-1,s>>=-i,i+=w;i>0;M=256*M+a[o+N],N+=n,i-=8);for(p=M&(1<<-i)-1,M>>=-i,i+=r;i>0;p=256*p+a[o+N],N+=n,i-=8);if(0===M)M=1-e;else{if(M===f)return p?NaN:1/0*(s?-1:1);p+=Math.pow(2,r),M-=e}return(s?-1:1)*p*Math.pow(2,M-r)},exports.write=function(a,o,t,r,h,M){var p,w,f,e=8*M-h-1,i=(1<>1,n=23===h?Math.pow(2,-24)-Math.pow(2,-77):0,s=r?0:M-1,u=r?1:-1,l=o<0||0===o&&1/o<0?1:0;for(o=Math.abs(o),isNaN(o)||o===1/0?(w=isNaN(o)?1:0,p=i):(p=Math.floor(Math.log(o)/Math.LN2),o*(f=Math.pow(2,-p))<1&&(p--,f*=2),(o+=p+N>=1?n/f:n*Math.pow(2,1-N))*f>=2&&(p++,f/=2),p+N>=i?(w=0,p=i):p+N>=1?(w=(o*f-1)*Math.pow(2,h),p+=N):(w=o*Math.pow(2,N-1)*Math.pow(2,h),p=0));h>=8;a[t+s]=255&w,s+=u,w/=256,h-=8);for(p=p<0;a[t+s]=255&p,s+=u,p/=256,e-=8);a[t+s-u]|=128*l}; +},{}],"REa7":[function(require,module,exports) { +var r={}.toString;module.exports=Array.isArray||function(t){return"[object Array]"==r.call(t)}; +},{}],"dskh":[function(require,module,exports) { + +var global = arguments[3]; +var t=arguments[3],r=require("base64-js"),e=require("ieee754"),n=require("isarray");function i(){try{var t=new Uint8Array(1);return t.__proto__={__proto__:Uint8Array.prototype,foo:function(){return 42}},42===t.foo()&&"function"==typeof t.subarray&&0===t.subarray(1,1).byteLength}catch(r){return!1}}function o(){return f.TYPED_ARRAY_SUPPORT?2147483647:1073741823}function u(t,r){if(o()=o())throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+o().toString(16)+" bytes");return 0|t}function d(t){return+t!=t&&(t=0),f.alloc(+t)}function v(t,r){if(f.isBuffer(t))return t.length;if("undefined"!=typeof ArrayBuffer&&"function"==typeof ArrayBuffer.isView&&(ArrayBuffer.isView(t)||t instanceof ArrayBuffer))return t.byteLength;"string"!=typeof t&&(t=""+t);var e=t.length;if(0===e)return 0;for(var n=!1;;)switch(r){case"ascii":case"latin1":case"binary":return e;case"utf8":case"utf-8":case void 0:return $(t).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*e;case"hex":return e>>>1;case"base64":return K(t).length;default:if(n)return $(t).length;r=(""+r).toLowerCase(),n=!0}}function E(t,r,e){var n=!1;if((void 0===r||r<0)&&(r=0),r>this.length)return"";if((void 0===e||e>this.length)&&(e=this.length),e<=0)return"";if((e>>>=0)<=(r>>>=0))return"";for(t||(t="utf8");;)switch(t){case"hex":return x(this,r,e);case"utf8":case"utf-8":return Y(this,r,e);case"ascii":return L(this,r,e);case"latin1":case"binary":return D(this,r,e);case"base64":return S(this,r,e);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return C(this,r,e);default:if(n)throw new TypeError("Unknown encoding: "+t);t=(t+"").toLowerCase(),n=!0}}function b(t,r,e){var n=t[r];t[r]=t[e],t[e]=n}function R(t,r,e,n,i){if(0===t.length)return-1;if("string"==typeof e?(n=e,e=0):e>2147483647?e=2147483647:e<-2147483648&&(e=-2147483648),e=+e,isNaN(e)&&(e=i?0:t.length-1),e<0&&(e=t.length+e),e>=t.length){if(i)return-1;e=t.length-1}else if(e<0){if(!i)return-1;e=0}if("string"==typeof r&&(r=f.from(r,n)),f.isBuffer(r))return 0===r.length?-1:_(t,r,e,n,i);if("number"==typeof r)return r&=255,f.TYPED_ARRAY_SUPPORT&&"function"==typeof Uint8Array.prototype.indexOf?i?Uint8Array.prototype.indexOf.call(t,r,e):Uint8Array.prototype.lastIndexOf.call(t,r,e):_(t,[r],e,n,i);throw new TypeError("val must be string, number or Buffer")}function _(t,r,e,n,i){var o,u=1,f=t.length,s=r.length;if(void 0!==n&&("ucs2"===(n=String(n).toLowerCase())||"ucs-2"===n||"utf16le"===n||"utf-16le"===n)){if(t.length<2||r.length<2)return-1;u=2,f/=2,s/=2,e/=2}function h(t,r){return 1===u?t[r]:t.readUInt16BE(r*u)}if(i){var a=-1;for(o=e;of&&(e=f-s),o=e;o>=0;o--){for(var c=!0,l=0;li&&(n=i):n=i;var o=r.length;if(o%2!=0)throw new TypeError("Invalid hex string");n>o/2&&(n=o/2);for(var u=0;u239?4:h>223?3:h>191?2:1;if(i+c<=e)switch(c){case 1:h<128&&(a=h);break;case 2:128==(192&(o=t[i+1]))&&(s=(31&h)<<6|63&o)>127&&(a=s);break;case 3:o=t[i+1],u=t[i+2],128==(192&o)&&128==(192&u)&&(s=(15&h)<<12|(63&o)<<6|63&u)>2047&&(s<55296||s>57343)&&(a=s);break;case 4:o=t[i+1],u=t[i+2],f=t[i+3],128==(192&o)&&128==(192&u)&&128==(192&f)&&(s=(15&h)<<18|(63&o)<<12|(63&u)<<6|63&f)>65535&&s<1114112&&(a=s)}null===a?(a=65533,c=1):a>65535&&(a-=65536,n.push(a>>>10&1023|55296),a=56320|1023&a),n.push(a),i+=c}return O(n)}exports.Buffer=f,exports.SlowBuffer=d,exports.INSPECT_MAX_BYTES=50,f.TYPED_ARRAY_SUPPORT=void 0!==t.TYPED_ARRAY_SUPPORT?t.TYPED_ARRAY_SUPPORT:i(),exports.kMaxLength=o(),f.poolSize=8192,f._augment=function(t){return t.__proto__=f.prototype,t},f.from=function(t,r,e){return s(null,t,r,e)},f.TYPED_ARRAY_SUPPORT&&(f.prototype.__proto__=Uint8Array.prototype,f.__proto__=Uint8Array,"undefined"!=typeof Symbol&&Symbol.species&&f[Symbol.species]===f&&Object.defineProperty(f,Symbol.species,{value:null,configurable:!0})),f.alloc=function(t,r,e){return a(null,t,r,e)},f.allocUnsafe=function(t){return c(null,t)},f.allocUnsafeSlow=function(t){return c(null,t)},f.isBuffer=function(t){return!(null==t||!t._isBuffer)},f.compare=function(t,r){if(!f.isBuffer(t)||!f.isBuffer(r))throw new TypeError("Arguments must be Buffers");if(t===r)return 0;for(var e=t.length,n=r.length,i=0,o=Math.min(e,n);i0&&(t=this.toString("hex",0,r).match(/.{2}/g).join(" "),this.length>r&&(t+=" ... ")),""},f.prototype.compare=function(t,r,e,n,i){if(!f.isBuffer(t))throw new TypeError("Argument must be a Buffer");if(void 0===r&&(r=0),void 0===e&&(e=t?t.length:0),void 0===n&&(n=0),void 0===i&&(i=this.length),r<0||e>t.length||n<0||i>this.length)throw new RangeError("out of range index");if(n>=i&&r>=e)return 0;if(n>=i)return-1;if(r>=e)return 1;if(this===t)return 0;for(var o=(i>>>=0)-(n>>>=0),u=(e>>>=0)-(r>>>=0),s=Math.min(o,u),h=this.slice(n,i),a=t.slice(r,e),c=0;ci)&&(e=i),t.length>0&&(e<0||r<0)||r>this.length)throw new RangeError("Attempt to write outside buffer bounds");n||(n="utf8");for(var o=!1;;)switch(n){case"hex":return A(this,t,r,e);case"utf8":case"utf-8":return m(this,t,r,e);case"ascii":return P(this,t,r,e);case"latin1":case"binary":return T(this,t,r,e);case"base64":return B(this,t,r,e);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return U(this,t,r,e);default:if(o)throw new TypeError("Unknown encoding: "+n);n=(""+n).toLowerCase(),o=!0}},f.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var I=4096;function O(t){var r=t.length;if(r<=I)return String.fromCharCode.apply(String,t);for(var e="",n=0;nn)&&(e=n);for(var i="",o=r;oe)throw new RangeError("Trying to access beyond buffer length")}function k(t,r,e,n,i,o){if(!f.isBuffer(t))throw new TypeError('"buffer" argument must be a Buffer instance');if(r>i||rt.length)throw new RangeError("Index out of range")}function N(t,r,e,n){r<0&&(r=65535+r+1);for(var i=0,o=Math.min(t.length-e,2);i>>8*(n?i:1-i)}function z(t,r,e,n){r<0&&(r=4294967295+r+1);for(var i=0,o=Math.min(t.length-e,4);i>>8*(n?i:3-i)&255}function F(t,r,e,n,i,o){if(e+n>t.length)throw new RangeError("Index out of range");if(e<0)throw new RangeError("Index out of range")}function j(t,r,n,i,o){return o||F(t,r,n,4,3.4028234663852886e38,-3.4028234663852886e38),e.write(t,r,n,i,23,4),n+4}function q(t,r,n,i,o){return o||F(t,r,n,8,1.7976931348623157e308,-1.7976931348623157e308),e.write(t,r,n,i,52,8),n+8}f.prototype.slice=function(t,r){var e,n=this.length;if((t=~~t)<0?(t+=n)<0&&(t=0):t>n&&(t=n),(r=void 0===r?n:~~r)<0?(r+=n)<0&&(r=0):r>n&&(r=n),r0&&(i*=256);)n+=this[t+--r]*i;return n},f.prototype.readUInt8=function(t,r){return r||M(t,1,this.length),this[t]},f.prototype.readUInt16LE=function(t,r){return r||M(t,2,this.length),this[t]|this[t+1]<<8},f.prototype.readUInt16BE=function(t,r){return r||M(t,2,this.length),this[t]<<8|this[t+1]},f.prototype.readUInt32LE=function(t,r){return r||M(t,4,this.length),(this[t]|this[t+1]<<8|this[t+2]<<16)+16777216*this[t+3]},f.prototype.readUInt32BE=function(t,r){return r||M(t,4,this.length),16777216*this[t]+(this[t+1]<<16|this[t+2]<<8|this[t+3])},f.prototype.readIntLE=function(t,r,e){t|=0,r|=0,e||M(t,r,this.length);for(var n=this[t],i=1,o=0;++o=(i*=128)&&(n-=Math.pow(2,8*r)),n},f.prototype.readIntBE=function(t,r,e){t|=0,r|=0,e||M(t,r,this.length);for(var n=r,i=1,o=this[t+--n];n>0&&(i*=256);)o+=this[t+--n]*i;return o>=(i*=128)&&(o-=Math.pow(2,8*r)),o},f.prototype.readInt8=function(t,r){return r||M(t,1,this.length),128&this[t]?-1*(255-this[t]+1):this[t]},f.prototype.readInt16LE=function(t,r){r||M(t,2,this.length);var e=this[t]|this[t+1]<<8;return 32768&e?4294901760|e:e},f.prototype.readInt16BE=function(t,r){r||M(t,2,this.length);var e=this[t+1]|this[t]<<8;return 32768&e?4294901760|e:e},f.prototype.readInt32LE=function(t,r){return r||M(t,4,this.length),this[t]|this[t+1]<<8|this[t+2]<<16|this[t+3]<<24},f.prototype.readInt32BE=function(t,r){return r||M(t,4,this.length),this[t]<<24|this[t+1]<<16|this[t+2]<<8|this[t+3]},f.prototype.readFloatLE=function(t,r){return r||M(t,4,this.length),e.read(this,t,!0,23,4)},f.prototype.readFloatBE=function(t,r){return r||M(t,4,this.length),e.read(this,t,!1,23,4)},f.prototype.readDoubleLE=function(t,r){return r||M(t,8,this.length),e.read(this,t,!0,52,8)},f.prototype.readDoubleBE=function(t,r){return r||M(t,8,this.length),e.read(this,t,!1,52,8)},f.prototype.writeUIntLE=function(t,r,e,n){(t=+t,r|=0,e|=0,n)||k(this,t,r,e,Math.pow(2,8*e)-1,0);var i=1,o=0;for(this[r]=255&t;++o=0&&(o*=256);)this[r+i]=t/o&255;return r+e},f.prototype.writeUInt8=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,1,255,0),f.TYPED_ARRAY_SUPPORT||(t=Math.floor(t)),this[r]=255&t,r+1},f.prototype.writeUInt16LE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,2,65535,0),f.TYPED_ARRAY_SUPPORT?(this[r]=255&t,this[r+1]=t>>>8):N(this,t,r,!0),r+2},f.prototype.writeUInt16BE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,2,65535,0),f.TYPED_ARRAY_SUPPORT?(this[r]=t>>>8,this[r+1]=255&t):N(this,t,r,!1),r+2},f.prototype.writeUInt32LE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,4,4294967295,0),f.TYPED_ARRAY_SUPPORT?(this[r+3]=t>>>24,this[r+2]=t>>>16,this[r+1]=t>>>8,this[r]=255&t):z(this,t,r,!0),r+4},f.prototype.writeUInt32BE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,4,4294967295,0),f.TYPED_ARRAY_SUPPORT?(this[r]=t>>>24,this[r+1]=t>>>16,this[r+2]=t>>>8,this[r+3]=255&t):z(this,t,r,!1),r+4},f.prototype.writeIntLE=function(t,r,e,n){if(t=+t,r|=0,!n){var i=Math.pow(2,8*e-1);k(this,t,r,e,i-1,-i)}var o=0,u=1,f=0;for(this[r]=255&t;++o>0)-f&255;return r+e},f.prototype.writeIntBE=function(t,r,e,n){if(t=+t,r|=0,!n){var i=Math.pow(2,8*e-1);k(this,t,r,e,i-1,-i)}var o=e-1,u=1,f=0;for(this[r+o]=255&t;--o>=0&&(u*=256);)t<0&&0===f&&0!==this[r+o+1]&&(f=1),this[r+o]=(t/u>>0)-f&255;return r+e},f.prototype.writeInt8=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,1,127,-128),f.TYPED_ARRAY_SUPPORT||(t=Math.floor(t)),t<0&&(t=255+t+1),this[r]=255&t,r+1},f.prototype.writeInt16LE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,2,32767,-32768),f.TYPED_ARRAY_SUPPORT?(this[r]=255&t,this[r+1]=t>>>8):N(this,t,r,!0),r+2},f.prototype.writeInt16BE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,2,32767,-32768),f.TYPED_ARRAY_SUPPORT?(this[r]=t>>>8,this[r+1]=255&t):N(this,t,r,!1),r+2},f.prototype.writeInt32LE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,4,2147483647,-2147483648),f.TYPED_ARRAY_SUPPORT?(this[r]=255&t,this[r+1]=t>>>8,this[r+2]=t>>>16,this[r+3]=t>>>24):z(this,t,r,!0),r+4},f.prototype.writeInt32BE=function(t,r,e){return t=+t,r|=0,e||k(this,t,r,4,2147483647,-2147483648),t<0&&(t=4294967295+t+1),f.TYPED_ARRAY_SUPPORT?(this[r]=t>>>24,this[r+1]=t>>>16,this[r+2]=t>>>8,this[r+3]=255&t):z(this,t,r,!1),r+4},f.prototype.writeFloatLE=function(t,r,e){return j(this,t,r,!0,e)},f.prototype.writeFloatBE=function(t,r,e){return j(this,t,r,!1,e)},f.prototype.writeDoubleLE=function(t,r,e){return q(this,t,r,!0,e)},f.prototype.writeDoubleBE=function(t,r,e){return q(this,t,r,!1,e)},f.prototype.copy=function(t,r,e,n){if(e||(e=0),n||0===n||(n=this.length),r>=t.length&&(r=t.length),r||(r=0),n>0&&n=this.length)throw new RangeError("sourceStart out of bounds");if(n<0)throw new RangeError("sourceEnd out of bounds");n>this.length&&(n=this.length),t.length-r=0;--i)t[i+r]=this[i+e];else if(o<1e3||!f.TYPED_ARRAY_SUPPORT)for(i=0;i>>=0,e=void 0===e?this.length:e>>>0,t||(t=0),"number"==typeof t)for(o=r;o55295&&e<57344){if(!i){if(e>56319){(r-=3)>-1&&o.push(239,191,189);continue}if(u+1===n){(r-=3)>-1&&o.push(239,191,189);continue}i=e;continue}if(e<56320){(r-=3)>-1&&o.push(239,191,189),i=e;continue}e=65536+(i-55296<<10|e-56320)}else i&&(r-=3)>-1&&o.push(239,191,189);if(i=null,e<128){if((r-=1)<0)break;o.push(e)}else if(e<2048){if((r-=2)<0)break;o.push(e>>6|192,63&e|128)}else if(e<65536){if((r-=3)<0)break;o.push(e>>12|224,e>>6&63|128,63&e|128)}else{if(!(e<1114112))throw new Error("Invalid code point");if((r-=4)<0)break;o.push(e>>18|240,e>>12&63|128,e>>6&63|128,63&e|128)}}return o}function G(t){for(var r=[],e=0;e>8,i=e%256,o.push(i),o.push(n);return o}function K(t){return r.toByteArray(X(t))}function Q(t,r,e,n){for(var i=0;i=r.length||i>=t.length);++i)r[i+e]=t[i];return i}function W(t){return t!=t} +},{"base64-js":"yh9p","ieee754":"JgNJ","isarray":"REa7","buffer":"dskh"}],"af3X":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var e=require("buffer").Buffer;function r(r){return e.isBuffer(r)}Object.defineProperty(exports,"__esModule",{value:!0}),exports.isBuffer=r; +},{"buffer":"dskh"}],"oQUU":[function(require,module,exports) { +"use strict";function e(e,t){return e instanceof t}Object.defineProperty(exports,"__esModule",{value:!0}),exports.isInstanceOf=e; +},{}],"FEok":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isU8a=r;var e=require("./instanceOf.js");function r(r){return(0,e.isInstanceOf)(r,Uint8Array)} +},{"./instanceOf.js":"oQUU"}],"BrUJ":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.stringToU8a=t;var e=require("@polkadot/x-textencoder"),r=new e.TextEncoder;function t(e){return e?r.encode(e.toString()):new Uint8Array} +},{"@polkadot/x-textencoder":"wmao"}],"WdNx":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.u8aToU8a=a;var r=require("../assert.js"),e=require("../hex/toU8a.js"),t=require("../is/buffer.js"),o=require("../is/hex.js"),n=require("../is/string.js"),i=require("../is/u8a.js"),s=require("../string/toU8a.js");function u(r){return(u="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(r){return typeof r}:function(r){return r&&"function"==typeof Symbol&&r.constructor===Symbol&&r!==Symbol.prototype?"symbol":typeof r})(r)}function a(a){return a?(0,o.isHex)(a)?(0,e.hexToU8a)(a):(0,n.isString)(a)?(0,s.stringToU8a)(a):Array.isArray(a)||(0,t.isBuffer)(a)?new Uint8Array(a):((0,r.assert)((0,i.isU8a)(a),function(){return"Unable to convert ".concat(a.toString()," (typeof ").concat(u(a),") to a Uint8Array")}),a):new Uint8Array} +},{"../assert.js":"ICoQ","../hex/toU8a.js":"fXDs","../is/buffer.js":"af3X","../is/hex.js":"vZtg","../is/string.js":"zz6S","../is/u8a.js":"FEok","../string/toU8a.js":"BrUJ"}],"OzYD":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.u8aCmp=t;var r=require("./toU8a.js");function e(r,e){for(var t=0;;){var u=t>=r.length,n=t>=e.length;if(u&&n)return 0;if(u)return-1;if(n)return 1;if(r[t]!==e[t])return r[t]>e[t]?1:-1;t++}}function t(t,u){return e((0,r.u8aToU8a)(t),(0,r.u8aToU8a)(u))} +},{"./toU8a.js":"WdNx"}],"IlAx":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.u8aConcat=r;var e=require("./toU8a.js");function r(){for(var r=0,t=0,n=new Array(arguments.length),a=0;a1&&void 0!==arguments[1]?arguments[1]:-1,r=arguments.length>2&&void 0!==arguments[2]&&arguments[2],n=Math.ceil(t/8);if(-1===t||e.length===n)return e;if(e.length>n)return e.subarray(0,n);var i=new Uint8Array(n);return r?i.set(e,0):i.set(e,n-e.length),i}Object.defineProperty(exports,"__esModule",{value:!0}),exports.u8aFixLength=e; +},{}],"KDN7":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.u8aSorted=r;var e=require("./cmp.js");function r(r){return r.sort(e.u8aCmp)} +},{"./cmp.js":"OzYD"}],"wFuH":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.u8aToHex=a;var r=require("../array/index.js"),e=(0,r.arrayRange)(256).map(function(r){return r.toString(16).padStart(2,"0")});function t(r){for(var t=new Array(r.length),n=0;n1&&void 0!==arguments[1]?arguments[1]:-1,a=!(arguments.length>2&&void 0!==arguments[2])||arguments[2]?"0x":"";if(null==r||!r.length)return a;var u=Math.ceil(e/8);return a+(u>0&&r.length>u?n(r,Math.ceil(u/2)):t(r))} +},{"../array/index.js":"BQEL"}],"R761":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.u8aToBn=t;var e=require("../hex/toBn.js"),r=require("./toHex.js");function t(t){var o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{isLe:!0,isNegative:!1};return(0,e.hexToBn)((0,r.u8aToHex)(t),o)} +},{"../hex/toBn.js":"DJg4","./toHex.js":"wFuH"}],"wz28":[function(require,module,exports) { +var Buffer = require("buffer").Buffer; +var e=require("buffer").Buffer;function r(r){return e.from(r||[])}Object.defineProperty(exports,"__esModule",{value:!0}),exports.u8aToBuffer=r; +},{"buffer":"dskh"}],"p6zv":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.u8aToString=r;var e=require("@polkadot/x-textdecoder"),t=new e.TextDecoder("utf-8");function r(e){return null!=e&&e.length?t.decode(e):""} +},{"@polkadot/x-textdecoder":"BOVF"}],"WDYB":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"u8aCmp",{enumerable:!0,get:function(){return e.u8aCmp}}),Object.defineProperty(exports,"u8aConcat",{enumerable:!0,get:function(){return r.u8aConcat}}),Object.defineProperty(exports,"u8aEq",{enumerable:!0,get:function(){return t.u8aEq}}),Object.defineProperty(exports,"u8aFixLength",{enumerable:!0,get:function(){return u.u8aFixLength}}),Object.defineProperty(exports,"u8aSorted",{enumerable:!0,get:function(){return n.u8aSorted}}),Object.defineProperty(exports,"u8aToBn",{enumerable:!0,get:function(){return o.u8aToBn}}),Object.defineProperty(exports,"u8aToBuffer",{enumerable:!0,get:function(){return a.u8aToBuffer}}),Object.defineProperty(exports,"u8aToHex",{enumerable:!0,get:function(){return i.u8aToHex}}),Object.defineProperty(exports,"u8aToString",{enumerable:!0,get:function(){return f.u8aToString}}),Object.defineProperty(exports,"u8aToU8a",{enumerable:!0,get:function(){return c.u8aToU8a}});var e=require("./cmp.js"),r=require("./concat.js"),t=require("./eq.js"),u=require("./fixLength.js"),n=require("./sorted.js"),o=require("./toBn.js"),a=require("./toBuffer.js"),i=require("./toHex.js"),f=require("./toString.js"),c=require("./toU8a.js"); +},{"./cmp.js":"OzYD","./concat.js":"IlAx","./eq.js":"skdh","./fixLength.js":"lb65","./sorted.js":"KDN7","./toBn.js":"R761","./toBuffer.js":"wz28","./toHex.js":"wFuH","./toString.js":"p6zv","./toU8a.js":"WdNx"}],"Tpcg":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.bnToU8a=c;var e=n(require("@babel/runtime/helpers/esm/defineProperty")),t=require("../is/number.js"),r=require("./toBn.js");function n(e){return e&&e.__esModule?e:{default:e}}function i(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),r.push.apply(r,n)}return r}function o(t){for(var r=1;r1&&void 0!==arguments[1]?arguments[1]:{bitLength:-1,isLe:!0,isNegative:!1},i=arguments.length>2?arguments[2]:void 0,c=o({bitLength:-1,isLe:!0,isNegative:!1},(0,t.isNumber)(n)?{bitLength:n,isLe:i}:n),b=(0,r.bnToBn)(e),a=-1===c.bitLength?Math.ceil(b.bitLength()/8):Math.ceil((c.bitLength||0)/8);return e?u(b,a,c):s(a,c)} +},{"@babel/runtime/helpers/esm/defineProperty":"Glli","../is/number.js":"Bfwc","./toBn.js":"FiF3"}],"Slop":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.bnToHex=c;var e=i(require("@babel/runtime/helpers/esm/defineProperty")),r=require("../is/number.js"),t=require("../u8a/index.js"),n=require("./toU8a.js");function i(e){return e&&e.__esModule?e:{default:e}}function o(e,r){var t=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);r&&(n=n.filter(function(r){return Object.getOwnPropertyDescriptor(e,r).enumerable})),t.push.apply(t,n)}return t}function u(r){for(var t=1;t1&&void 0!==arguments[1]?arguments[1]:{bitLength:-1,isLe:!1,isNegative:!1},o=arguments.length>2?arguments[2]:void 0;if(!e)return s;var c=u({isLe:!1,isNegative:!1},(0,r.isNumber)(i)?{bitLength:i,isLe:o}:i);return(0,t.u8aToHex)((0,n.bnToU8a)(e,c))} +},{"@babel/runtime/helpers/esm/defineProperty":"Glli","../is/number.js":"Bfwc","../u8a/index.js":"WDYB","./toU8a.js":"Tpcg"}],"DIBQ":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e={BN:!0,bnFromHex:!0,bnMax:!0,bnMin:!0,bnSqrt:!0,bnToBn:!0,bnToHex:!0,bnToU8a:!0};Object.defineProperty(exports,"BN",{enumerable:!0,get:function(){return n.BN}}),Object.defineProperty(exports,"bnFromHex",{enumerable:!0,get:function(){return t.bnFromHex}}),Object.defineProperty(exports,"bnMax",{enumerable:!0,get:function(){return o.bnMax}}),Object.defineProperty(exports,"bnMin",{enumerable:!0,get:function(){return b.bnMin}}),Object.defineProperty(exports,"bnSqrt",{enumerable:!0,get:function(){return u.bnSqrt}}),Object.defineProperty(exports,"bnToBn",{enumerable:!0,get:function(){return i.bnToBn}}),Object.defineProperty(exports,"bnToHex",{enumerable:!0,get:function(){return s.bnToHex}}),Object.defineProperty(exports,"bnToU8a",{enumerable:!0,get:function(){return c.bnToU8a}});var r=require("./consts.js");Object.keys(r).forEach(function(n){"default"!==n&&"__esModule"!==n&&(Object.prototype.hasOwnProperty.call(e,n)||n in exports&&exports[n]===r[n]||Object.defineProperty(exports,n,{enumerable:!0,get:function(){return r[n]}}))});var n=require("./bn.js"),t=require("./fromHex.js"),o=require("./max.js"),b=require("./min.js"),u=require("./sqrt.js"),i=require("./toBn.js"),s=require("./toHex.js"),c=require("./toU8a.js"); +},{"./consts.js":"HNIg","./bn.js":"SLso","./fromHex.js":"y3fw","./max.js":"FOwf","./min.js":"x612","./sqrt.js":"Mnhm","./toBn.js":"FiF3","./toHex.js":"Slop","./toU8a.js":"Tpcg"}],"c1aJ":[function(require,module,exports) { +"use strict";function e(e){return new Uint8Array(e||[])}Object.defineProperty(exports,"__esModule",{value:!0}),exports.bufferToU8a=e; +},{}],"ekuN":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"bufferToU8a",{enumerable:!0,get:function(){return e.bufferToU8a}});var e=require("./toU8a.js"); +},{"./toU8a.js":"c1aJ"}],"fzOr":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.compactToU8a=u;var e=require("../assert.js"),n=require("../bn/index.js"),r=require("../u8a/index.js"),t=new n.BN(2).pow(new n.BN(6)).subn(1),a=new n.BN(2).pow(new n.BN(14)).subn(1),s=new n.BN(2).pow(new n.BN(30)).subn(1);function u(u){var o=(0,n.bnToBn)(u);if(o.lte(t))return new Uint8Array([o.toNumber()<<2]);if(o.lte(a))return(0,n.bnToU8a)(o.shln(2).iadd(n.BN_ONE),16,!0);if(o.lte(s))return(0,n.bnToU8a)(o.shln(2).iadd(n.BN_TWO),32,!0);for(var i=(0,n.bnToU8a)(o),b=i.length;0===i[b-1];)b--;return(0,e.assert)(b>=4,"Invalid length, previous checks match anything less than 2^30"),(0,r.u8aConcat)([3+(b-4<<2)],i.subarray(0,b))} +},{"../assert.js":"ICoQ","../bn/index.js":"DIBQ","../u8a/index.js":"WDYB"}],"qop7":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.compactAddLength=r;var e=require("../u8a/index.js"),t=require("./toU8a.js");function r(r){return(0,e.u8aConcat)((0,t.compactToU8a)(r.length),r)} +},{"../u8a/index.js":"WDYB","./toU8a.js":"fzOr"}],"H6CZ":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.compactFromU8a=n;var r=require("../bn/index.js"),e=require("../u8a/index.js");function n(n){var i=(0,e.u8aToU8a)(n),u=3&i[0];if(0===u)return[1,new r.BN(i[0]).ishrn(2)];if(1===u)return[2,(0,e.u8aToBn)(i.slice(0,2),!0).ishrn(2)];if(2===u)return[4,(0,e.u8aToBn)(i.slice(0,4),!0).ishrn(2)];var a=1+new r.BN(i[0]).ishrn(2).iadd(r.BN_FOUR).toNumber();return[a,(0,e.u8aToBn)(i.subarray(1,a),!0)]} +},{"../bn/index.js":"DIBQ","../u8a/index.js":"WDYB"}],"P2jl":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.compactStripLength=i;var r=require("./fromU8a.js");function t(r,t){return u(r)||a(r,t)||n(r,t)||e()}function e(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function n(r,t){if(r){if("string"==typeof r)return o(r,t);var e=Object.prototype.toString.call(r).slice(8,-1);return"Object"===e&&r.constructor&&(e=r.constructor.name),"Map"===e||"Set"===e?Array.from(r):"Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e)?o(r,t):void 0}}function o(r,t){(null==t||t>r.length)&&(t=r.length);for(var e=0,n=new Array(t);e1&&void 0!==arguments[1])||arguments[1],a=arguments.length>2&&void 0!==arguments[2]?arguments[2]:c,s=(0,t.bnToBn)(i).toString();if(0===s.length||"0"===s)return"0";var u=s[0].startsWith("-");u&&(s=s.substr(1));var l=(0,e.isBoolean)(o)?{withSi:o}:o,d=l.decimals,v=void 0===d?a:d,f=l.forceUnit,S=void 0===f?void 0:f,h=l.withSi,I=void 0===h||h,g=l.withSiFull,m=void 0!==g&&g,b=l.withUnit,x=void 0===b||b,j=(0,r.calcSi)(s,v,S),p=s.length-(v+j.power),w=s.substr(0,p),B="".concat("".concat(new Array((p<0?0-p:0)+1).join("0")).concat(s).substr(p<0?0:p),"0000").substr(0,4),D=I||m?"-"===j.value?x?" ".concat((0,e.isBoolean)(x)?j.text:x):"":" ".concat(m?j.text:j.value).concat(x?"".concat(m?" ":"").concat((0,e.isBoolean)(x)?r.SI[r.SI_MID].text:x):""):"";return"".concat(u?"-":"").concat((0,n.formatDecimal)(w||"0"),".").concat(B).concat(D)}var l=u;exports.formatBalance=l,l.calcSi=function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:c;return(0,r.calcSi)(t,e)},l.findSi=r.findSi,l.getDefaults=function(){return{decimals:c,unit:s}},l.getOptions=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:c;return r.SI.filter(function(e){var i=e.power;return!(i<0)||t+i>=0})},l.setDefaults=function(t){var e=t.decimals,n=t.unit;c=(0,i.isUndefined)(e)?c:Array.isArray(e)?e[0]:e,s=(0,i.isUndefined)(n)?s:Array.isArray(n)?n[0]:n,r.SI[r.SI_MID].text=s}; +},{"../bn/toBn.js":"FiF3","../is/boolean.js":"Eabu","../is/undefined.js":"QCwi","./formatDecimal.js":"KAR8","./si.js":"BzLf"}],"Eb3q":[function(require,module,exports) { +"use strict";function t(t){return t.toString().padStart(2,"0")}function e(e){var n=e.getFullYear().toString(),o=t(e.getMonth()+1),c=t(e.getDate()),r=t(e.getHours()),a=t(e.getMinutes()),u=t(e.getSeconds());return"".concat(n,"-").concat(o,"-").concat(c," ").concat(r,":").concat(a,":").concat(u)}Object.defineProperty(exports,"__esModule",{value:!0}),exports.formatDate=e; +},{}],"Yq1t":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.formatElapsed=n;var e=require("../bn/toBn.js");function t(e){return e<15?"".concat(e.toFixed(1),"s"):e<60?"".concat(0|e,"s"):e<3600?"".concat(e/60|0,"m"):"".concat(e/3600|0,"h")}function n(n,o){var a=n&&n.getTime()||0,r=o instanceof Date?o.getTime():(0,e.bnToBn)(o).toNumber();return a&&r?t(Math.max(Math.abs(a-r),0)/1e3):"0.0s"} +},{"../bn/toBn.js":"FiF3"}],"KO6u":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.formatNumber=t;var e=require("../bn/toBn.js"),r=require("./formatDecimal.js");function t(t){return(0,r.formatDecimal)((0,e.bnToBn)(t).toString())} +},{"../bn/toBn.js":"FiF3","./formatDecimal.js":"KAR8"}],"tBzw":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"calcSi",{enumerable:!0,get:function(){return o.calcSi}}),Object.defineProperty(exports,"findSi",{enumerable:!0,get:function(){return o.findSi}}),Object.defineProperty(exports,"formatBalance",{enumerable:!0,get:function(){return e.formatBalance}}),Object.defineProperty(exports,"formatDate",{enumerable:!0,get:function(){return r.formatDate}}),Object.defineProperty(exports,"formatDecimal",{enumerable:!0,get:function(){return t.formatDecimal}}),Object.defineProperty(exports,"formatElapsed",{enumerable:!0,get:function(){return a.formatElapsed}}),Object.defineProperty(exports,"formatNumber",{enumerable:!0,get:function(){return n.formatNumber}});var e=require("./formatBalance.js"),r=require("./formatDate.js"),t=require("./formatDecimal.js"),a=require("./formatElapsed.js"),n=require("./formatNumber.js"),o=require("./si.js"); +},{"./formatBalance.js":"VArF","./formatDate.js":"Eb3q","./formatDecimal.js":"KAR8","./formatElapsed.js":"Yq1t","./formatNumber.js":"KO6u","./si.js":"BzLf"}],"hBNt":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.hexAddPrefix=r;var e=require("./hasPrefix.js");function r(r){return r&&(0,e.hexHasPrefix)(r)?r:"0x".concat(r&&r.length%2==1?"0":"").concat(r||"")} +},{"./hasPrefix.js":"fYf8"}],"fwEw":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.hexFixLength=t;var e=require("./addPrefix.js"),r=require("./stripPrefix.js");function t(t){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:-1,x=arguments.length>2&&void 0!==arguments[2]&&arguments[2],h=Math.ceil(i/4),n=h+2;return(0,e.hexAddPrefix)(-1===i||t.length===n||!x&&t.lengthn?(0,r.hexStripPrefix)(t).slice(-1*h):"".concat("0".repeat(h)).concat((0,r.hexStripPrefix)(t)).slice(-1*h))} +},{"./addPrefix.js":"hBNt","./stripPrefix.js":"XjK7"}],"KVKy":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.hexToNumber=r;var e=require("./toBn.js");function r(r){return r?(0,e.hexToBn)(r).toNumber():NaN} +},{"./toBn.js":"DJg4"}],"gOOL":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.hexToString=t;var e=require("../u8a/toString.js"),r=require("./toU8a.js");function t(t){return(0,e.u8aToString)((0,r.hexToU8a)(t))} +},{"../u8a/toString.js":"p6zv","./toU8a.js":"fXDs"}],"csge":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"hexAddPrefix",{enumerable:!0,get:function(){return e.hexAddPrefix}}),Object.defineProperty(exports,"hexFixLength",{enumerable:!0,get:function(){return r.hexFixLength}}),Object.defineProperty(exports,"hexHasPrefix",{enumerable:!0,get:function(){return t.hexHasPrefix}}),Object.defineProperty(exports,"hexStripPrefix",{enumerable:!0,get:function(){return n.hexStripPrefix}}),Object.defineProperty(exports,"hexToBn",{enumerable:!0,get:function(){return i.hexToBn}}),Object.defineProperty(exports,"hexToNumber",{enumerable:!0,get:function(){return o.hexToNumber}}),Object.defineProperty(exports,"hexToString",{enumerable:!0,get:function(){return u.hexToString}}),Object.defineProperty(exports,"hexToU8a",{enumerable:!0,get:function(){return x.hexToU8a}});var e=require("./addPrefix.js"),r=require("./fixLength.js"),t=require("./hasPrefix.js"),n=require("./stripPrefix.js"),i=require("./toBn.js"),o=require("./toNumber.js"),u=require("./toString.js"),x=require("./toU8a.js"); +},{"./addPrefix.js":"hBNt","./fixLength.js":"fwEw","./hasPrefix.js":"fYf8","./stripPrefix.js":"XjK7","./toBn.js":"DJg4","./toNumber.js":"KVKy","./toString.js":"gOOL","./toU8a.js":"fXDs"}],"PgmC":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isAscii=s;var e=require("../u8a/toU8a.js"),r=require("./string.js"),i=[9,10,13];function s(s){return s?!(0,e.u8aToU8a)(s).some(function(e){return e>=127||e<32&&!i.includes(e)}):(0,r.isString)(s)} +},{"../u8a/toU8a.js":"WdNx","./string.js":"zz6S"}],"HDq7":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isBn=r;var e=require("../bn/bn.js");function r(r){return e.BN.isBN(r)} +},{"../bn/bn.js":"SLso"}],"qfag":[function(require,module,exports) { +"use strict";function e(e,t){return!!t&&(e===t||e.isPrototypeOf(t))}Object.defineProperty(exports,"__esModule",{value:!0}),exports.isChildClass=e; +},{}],"MZrA":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isError=e;var r=require("./instanceOf.js");function e(e){return(0,r.isInstanceOf)(e,Error)} +},{"./instanceOf.js":"oQUU"}],"IxS2":[function(require,module,exports) { +"use strict";var c="[a-fA-F\\d:]",n=function(n){return n&&n.includeBoundaries?"(?:(?<=\\s|^)(?=".concat(c,")|(?<=").concat(c,")(?=\\s|$))"):""},t="(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}",a="[a-fA-F\\d]{1,4}",o="\n(?:\n(?:".concat(a,":){7}(?:").concat(a,"|:)| // 1:2:3:4:5:6:7:: 1:2:3:4:5:6:7:8\n(?:").concat(a,":){6}(?:").concat(t,"|:").concat(a,"|:)| // 1:2:3:4:5:6:: 1:2:3:4:5:6::8 1:2:3:4:5:6::8 1:2:3:4:5:6::1.2.3.4\n(?:").concat(a,":){5}(?::").concat(t,"|(?::").concat(a,"){1,2}|:)| // 1:2:3:4:5:: 1:2:3:4:5::7:8 1:2:3:4:5::8 1:2:3:4:5::7:1.2.3.4\n(?:").concat(a,":){4}(?:(?::").concat(a,"){0,1}:").concat(t,"|(?::").concat(a,"){1,3}|:)| // 1:2:3:4:: 1:2:3:4::6:7:8 1:2:3:4::8 1:2:3:4::6:7:1.2.3.4\n(?:").concat(a,":){3}(?:(?::").concat(a,"){0,2}:").concat(t,"|(?::").concat(a,"){1,4}|:)| // 1:2:3:: 1:2:3::5:6:7:8 1:2:3::8 1:2:3::5:6:7:1.2.3.4\n(?:").concat(a,":){2}(?:(?::").concat(a,"){0,3}:").concat(t,"|(?::").concat(a,"){1,5}|:)| // 1:2:: 1:2::4:5:6:7:8 1:2::8 1:2::4:5:6:7:1.2.3.4\n(?:").concat(a,":){1}(?:(?::").concat(a,"){0,4}:").concat(t,"|(?::").concat(a,"){1,6}|:)| // 1:: 1::3:4:5:6:7:8 1::8 1::3:4:5:6:7:1.2.3.4\n(?::(?:(?::").concat(a,"){0,5}:").concat(t,"|(?::").concat(a,"){1,7}|:)) // ::2:3:4:5:6:7:8 ::2:3:4:5:6:7:8 ::8 ::1.2.3.4\n)(?:%[0-9a-zA-Z]{1,})? // %eth0 %1\n").replace(/\s*\/\/.*$/gm,"").replace(/\n/g,"").trim(),e=new RegExp("(?:^".concat(t,"$)|(?:^").concat(o,"$)")),d=new RegExp("^".concat(t,"$")),r=new RegExp("^".concat(o,"$")),u=function(c){return c&&c.exact?e:new RegExp("(?:".concat(n(c)).concat(t).concat(n(c),")|(?:").concat(n(c)).concat(o).concat(n(c),")"),"g")};u.v4=function(c){return c&&c.exact?d:new RegExp("".concat(n(c)).concat(t).concat(n(c)),"g")},u.v6=function(c){return c&&c.exact?r:new RegExp("".concat(n(c)).concat(o).concat(n(c)),"g")},module.exports=u; +},{}],"UnXv":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isIp=r;var e=t(require("ip-regex"));function t(e){return e&&e.__esModule?e:{default:e}}function r(t,r){return"v4"===r?e.default.v4({exact:!0}).test(t):"v6"===r?e.default.v6({exact:!0}).test(t):(0,e.default)({exact:!0}).test(t)} +},{"ip-regex":"IxS2"}],"AbVj":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.stringify=r;var t=require("./is/bigInt.js");function r(r,e){return JSON.stringify(r,function(r,e){return(0,t.isBigInt)(e)?e.toString():e},e)} +},{"./is/bigInt.js":"vLlJ"}],"pOta":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isJsonObject=r;var t=require("../stringify.js");function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function r(r){var e="string"!=typeof r?(0,t.stringify)(r):r;try{var n=JSON.parse(e);return"object"===o(n)&&null!==n}catch(y){return!1}} +},{"../stringify.js":"AbVj"}],"fT4F":[function(require,module,exports) { +"use strict";function t(o){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(o)}function o(o){return"object"===t(o)}Object.defineProperty(exports,"__esModule",{value:!0}),exports.isObject=o; +},{}],"X33k":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isObservable=t;var e=require("./function.js"),r=require("./object.js");function t(t){return(0,r.isObject)(t)&&(0,e.isFunction)(t.next)} +},{"./function.js":"XXEF","./object.js":"fT4F"}],"eI5u":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isTestChain=t;var e=/(Development|Local Testnet)$/;function t(t){return!!t&&!!e.test(t.toString())} +},{}],"wkhl":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isUtf8=i;var r=require("../u8a/toU8a.js"),e=require("./string.js");function i(i){if(!i)return(0,e.isString)(i);for(var t=(0,r.u8aToU8a)(i),f=t.length,u=0;u=194&&t[u]<=223){if(!(u+1191)return!1;u+=2}else if(224===t[u]){if(!(u+2191)return!1;if(t[u+2]<128||t[u+2]>191)return!1;u+=3}else if(t[u]>=225&&t[u]<=236){if(!(u+2191)return!1;if(t[u+2]<128||t[u+2]>191)return!1;u+=3}else if(237===t[u]){if(!(u+2159)return!1;if(t[u+2]<128||t[u+2]>191)return!1;u+=3}else if(t[u]>=238&&t[u]<=239){if(!(u+2191)return!1;if(t[u+2]<128||t[u+2]>191)return!1;u+=3}else if(240===t[u]){if(!(u+3191)return!1;if(t[u+2]<128||t[u+2]>191)return!1;if(t[u+3]<128||t[u+3]>191)return!1;u+=4}else if(t[u]>=241&&t[u]<=243){if(!(u+3191)return!1;if(t[u+2]<128||t[u+2]>191)return!1;if(t[u+3]<128||t[u+3]>191)return!1;u+=4}else{if(244!==t[u])return!1;if(!(u+3143)return!1;if(t[u+2]<128||t[u+2]>191)return!1;if(t[u+3]<128||t[u+3]>191)return!1;u+=4}return!0} +},{"../u8a/toU8a.js":"WdNx","./string.js":"zz6S"}],"wIe6":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.isWasm=s;var e=require("../u8a/eq.js"),r=new Uint8Array([0,97,115,109]);function s(s){return!!s&&(0,e.u8aEq)(s.subarray(0,4),r)} +},{"../u8a/eq.js":"skdh"}],"PVwn":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"isAscii",{enumerable:!0,get:function(){return e.isAscii}}),Object.defineProperty(exports,"isBigInt",{enumerable:!0,get:function(){return r.isBigInt}}),Object.defineProperty(exports,"isBn",{enumerable:!0,get:function(){return t.isBn}}),Object.defineProperty(exports,"isBoolean",{enumerable:!0,get:function(){return i.isBoolean}}),Object.defineProperty(exports,"isBuffer",{enumerable:!0,get:function(){return n.isBuffer}}),Object.defineProperty(exports,"isChildClass",{enumerable:!0,get:function(){return s.isChildClass}}),Object.defineProperty(exports,"isError",{enumerable:!0,get:function(){return u.isError}}),Object.defineProperty(exports,"isFunction",{enumerable:!0,get:function(){return o.isFunction}}),Object.defineProperty(exports,"isHex",{enumerable:!0,get:function(){return b.isHex}}),Object.defineProperty(exports,"isInstanceOf",{enumerable:!0,get:function(){return c.isInstanceOf}}),Object.defineProperty(exports,"isIp",{enumerable:!0,get:function(){return f.isIp}}),Object.defineProperty(exports,"isJsonObject",{enumerable:!0,get:function(){return j.isJsonObject}}),Object.defineProperty(exports,"isNull",{enumerable:!0,get:function(){return p.isNull}}),Object.defineProperty(exports,"isNumber",{enumerable:!0,get:function(){return a.isNumber}}),Object.defineProperty(exports,"isObject",{enumerable:!0,get:function(){return l.isObject}}),Object.defineProperty(exports,"isObservable",{enumerable:!0,get:function(){return d.isObservable}}),Object.defineProperty(exports,"isString",{enumerable:!0,get:function(){return O.isString}}),Object.defineProperty(exports,"isTestChain",{enumerable:!0,get:function(){return g.isTestChain}}),Object.defineProperty(exports,"isToBn",{enumerable:!0,get:function(){return m.isToBn}}),Object.defineProperty(exports,"isU8a",{enumerable:!0,get:function(){return x.isU8a}}),Object.defineProperty(exports,"isUndefined",{enumerable:!0,get:function(){return y.isUndefined}}),Object.defineProperty(exports,"isUtf8",{enumerable:!0,get:function(){return P.isUtf8}}),Object.defineProperty(exports,"isWasm",{enumerable:!0,get:function(){return q.isWasm}});var e=require("./ascii.js"),r=require("./bigInt.js"),t=require("./bn.js"),n=require("./buffer.js"),i=require("./boolean.js"),s=require("./childClass.js"),u=require("./error.js"),o=require("./function.js"),b=require("./hex.js"),c=require("./instanceOf.js"),f=require("./ip.js"),j=require("./jsonObject.js"),p=require("./null.js"),a=require("./number.js"),l=require("./object.js"),d=require("./observable.js"),O=require("./string.js"),g=require("./testChain.js"),m=require("./toBn.js"),x=require("./u8a.js"),y=require("./undefined.js"),P=require("./utf8.js"),q=require("./wasm.js"); +},{"./ascii.js":"PgmC","./bigInt.js":"vLlJ","./bn.js":"HDq7","./buffer.js":"af3X","./boolean.js":"Eabu","./childClass.js":"qfag","./error.js":"MZrA","./function.js":"XXEF","./hex.js":"vZtg","./instanceOf.js":"oQUU","./ip.js":"UnXv","./jsonObject.js":"pOta","./null.js":"RBCU","./number.js":"Bfwc","./object.js":"fT4F","./observable.js":"X33k","./string.js":"zz6S","./testChain.js":"eI5u","./toBn.js":"jl98","./u8a.js":"FEok","./undefined.js":"QCwi","./utf8.js":"wkhl","./wasm.js":"wIe6"}],"pBGv":[function(require,module,exports) { + +var t,e,n=module.exports={};function r(){throw new Error("setTimeout has not been defined")}function o(){throw new Error("clearTimeout has not been defined")}function i(e){if(t===setTimeout)return setTimeout(e,0);if((t===r||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(n){try{return t.call(null,e,0)}catch(n){return t.call(this,e,0)}}}function u(t){if(e===clearTimeout)return clearTimeout(t);if((e===o||!e)&&clearTimeout)return e=clearTimeout,clearTimeout(t);try{return e(t)}catch(n){try{return e.call(null,t)}catch(n){return e.call(this,t)}}}!function(){try{t="function"==typeof setTimeout?setTimeout:r}catch(n){t=r}try{e="function"==typeof clearTimeout?clearTimeout:o}catch(n){e=o}}();var c,s=[],l=!1,a=-1;function f(){l&&c&&(l=!1,c.length?s=c.concat(s):a=-1,s.length&&h())}function h(){if(!l){var t=i(f);l=!0;for(var e=s.length;e;){for(c=s,s=[];++a1)for(var n=1;nr.length)&&(t=r.length);for(var e=0,n=new Array(t);e3&&void 0!==arguments[3]?arguments[3]:-1;if(1===n.length&&(0,o.isFunction)(n[0])){var u=n[0]();return S(r,e,Array.isArray(u)?u:[u],i)}(a=console)[j[r]].apply(a,[(0,t.formatDate)(new Date),e].concat(b(n.map(w).map(function(r){if(i<=0)return r;var t="".concat(r);return t.length1&&void 0!==arguments[1]?arguments[1]:{}).getInstanceId,o=void 0===t?r:t,s={},u=function(){for(var r=arguments.length,t=new Array(r),u=0;u1&&void 0!==arguments[1]?arguments[1]:-1;return(0,r.isUndefined)(n)||(0,i.isNull)(n)||isNaN(n)?"0x":(0,e.hexFixLength)(n.toString(16),s,!0)} +},{"../hex/fixLength.js":"fwEw","../is/null.js":"RBCU","../is/undefined.js":"QCwi"}],"Zyxl":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.numberToU8a=s;var e=require("../hex/toU8a.js"),r=require("../is/null.js"),i=require("../is/undefined.js"),n=require("./toHex.js");function s(s){var u=arguments.length>1&&void 0!==arguments[1]?arguments[1]:-1;return(0,i.isUndefined)(s)||(0,r.isNull)(s)||isNaN(s)?new Uint8Array:(0,e.hexToU8a)((0,n.numberToHex)(s,u))} +},{"../hex/toU8a.js":"fXDs","../is/null.js":"RBCU","../is/undefined.js":"QCwi","./toHex.js":"uwVQ"}],"mPG8":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"numberToHex",{enumerable:!0,get:function(){return e.numberToHex}}),Object.defineProperty(exports,"numberToU8a",{enumerable:!0,get:function(){return r.numberToU8a}});var e=require("./toHex.js"),r=require("./toU8a.js"); +},{"./toHex.js":"uwVQ","./toU8a.js":"Zyxl"}],"DzOR":[function(require,module,exports) { +"use strict";function e(e,r){for(var n=arguments.length,t=new Array(n>2?n-2:0),o=2;o1&&void 0!==arguments[1]?arguments[1]:6;return t.length<=2+2*e?t.toString():"".concat(t.substr(0,e),"…").concat(t.slice(-e))}Object.defineProperty(exports,"__esModule",{value:!0}),exports.stringShorten=t; +},{}],"zhUw":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.stringToHex=t;var e=require("../u8a/toHex.js"),r=require("./toU8a.js");function t(t){return(0,e.u8aToHex)((0,r.stringToU8a)(t))} +},{"../u8a/toHex.js":"wFuH","./toU8a.js":"BrUJ"}],"u8uV":[function(require,module,exports) { +"use strict";function e(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}Object.defineProperty(exports,"__esModule",{value:!0}),exports.stringUpperFirst=e; +},{}],"N5b4":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),Object.defineProperty(exports,"stringCamelCase",{enumerable:!0,get:function(){return e.stringCamelCase}}),Object.defineProperty(exports,"stringLowerFirst",{enumerable:!0,get:function(){return r.stringLowerFirst}}),Object.defineProperty(exports,"stringShorten",{enumerable:!0,get:function(){return t.stringShorten}}),Object.defineProperty(exports,"stringToHex",{enumerable:!0,get:function(){return n.stringToHex}}),Object.defineProperty(exports,"stringToU8a",{enumerable:!0,get:function(){return i.stringToU8a}}),Object.defineProperty(exports,"stringUpperFirst",{enumerable:!0,get:function(){return s.stringUpperFirst}});var e=require("./camelCase.js"),r=require("./lowerFirst.js"),t=require("./shorten.js"),n=require("./toHex.js"),i=require("./toU8a.js"),s=require("./upperFirst.js"); +},{"./camelCase.js":"VIH3","./lowerFirst.js":"JDz0","./shorten.js":"Ovop","./toHex.js":"zhUw","./toU8a.js":"BrUJ","./upperFirst.js":"u8uV"}],"mm3z":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e={packageInfo:!0};Object.defineProperty(exports,"packageInfo",{enumerable:!0,get:function(){return r.packageInfo}}),require("./detectPackage.js");var r=require("./packageInfo.js"),t=require("./array/index.js");Object.keys(t).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===t[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return t[r]}}))});var o=require("./assert.js");Object.keys(o).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===o[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return o[r]}}))});var n=require("./bn/index.js");Object.keys(n).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===n[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return n[r]}}))});var c=require("./buffer/index.js");Object.keys(c).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===c[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return c[r]}}))});var s=require("./compact/index.js");Object.keys(s).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===s[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return s[r]}}))});var u=require("./extractTime.js");Object.keys(u).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===u[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return u[r]}}))});var p=require("./format/index.js");Object.keys(p).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===p[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return p[r]}}))});var a=require("./hex/index.js");Object.keys(a).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===a[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return a[r]}}))});var i=require("./is/index.js");Object.keys(i).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===i[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return i[r]}}))});var f=require("./logger.js");Object.keys(f).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===f[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return f[r]}}))});var l=require("./memoize.js");Object.keys(l).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===l[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return l[r]}}))});var b=require("./number/index.js");Object.keys(b).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===b[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return b[r]}}))});var y=require("./promisify.js");Object.keys(y).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===y[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return y[r]}}))});var j=require("./string/index.js");Object.keys(j).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===j[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return j[r]}}))});var O=require("./stringify.js");Object.keys(O).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===O[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return O[r]}}))});var d=require("./u8a/index.js");Object.keys(d).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===d[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return d[r]}}))});var x=require("./versionDetect.js");Object.keys(x).forEach(function(r){"default"!==r&&"__esModule"!==r&&(Object.prototype.hasOwnProperty.call(e,r)||r in exports&&exports[r]===x[r]||Object.defineProperty(exports,r,{enumerable:!0,get:function(){return x[r]}}))}); +},{"./detectPackage.js":"Hxmh","./packageInfo.js":"WiBo","./array/index.js":"BQEL","./assert.js":"ICoQ","./bn/index.js":"DIBQ","./buffer/index.js":"ekuN","./compact/index.js":"BXdb","./extractTime.js":"bCFD","./format/index.js":"tBzw","./hex/index.js":"csge","./is/index.js":"PVwn","./logger.js":"v4Ne","./memoize.js":"hmMX","./number/index.js":"mPG8","./promisify.js":"DzOR","./string/index.js":"N5b4","./stringify.js":"AbVj","./u8a/index.js":"WDYB","./versionDetect.js":"AgAp"}],"Jwvg":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=void 0;var e=i(require("@babel/runtime/helpers/esm/classPrivateFieldLooseBase")),t=i(require("@babel/runtime/helpers/esm/classPrivateFieldLooseKey")),s=i(require("eventemitter3")),r=require("@polkadot/util");function i(e){return e&&e.__esModule?e:{default:e}}const n=(0,r.logger)("PostMessageProvider");let a;var o=(0,t.default)("eventemitter"),d=(0,t.default)("isConnected"),u=(0,t.default)("subscriptions");class l{constructor(t){Object.defineProperty(this,o,{writable:!0,value:void 0}),Object.defineProperty(this,d,{writable:!0,value:!1}),Object.defineProperty(this,u,{writable:!0,value:{}}),(0,e.default)(this,o)[o]=new s.default,a=t}clone(){return new l(a)}async connect(){console.error("PostMessageProvider.disconnect() is not implemented.")}async disconnect(){console.error("PostMessageProvider.disconnect() is not implemented.")}get hasSubscriptions(){return!0}get isConnected(){return(0,e.default)(this,d)[d]}listProviders(){return a("pub(rpc.listProviders)",void 0)}on(t,s){return(0,e.default)(this,o)[o].on(t,s),()=>{(0,e.default)(this,o)[o].removeListener(t,s)}}async send(t,s,r){if(r){const{callback:i,type:n}=r,o=await a("pub(rpc.subscribe)",{method:t,params:s,type:n},e=>{r.callback(null,e)});return(0,e.default)(this,u)[u][`${n}::${o}`]=i,o}return a("pub(rpc.send)",{method:t,params:s})}async startProvider(t){(0,e.default)(this,d)[d]=!1,(0,e.default)(this,o)[o].emit("disconnected");const s=await a("pub(rpc.startProvider)",t);return a("pub(rpc.subscribeConnected)",null,t=>((0,e.default)(this,d)[d]=t,t?(0,e.default)(this,o)[o].emit("connected"):(0,e.default)(this,o)[o].emit("disconnected"),!0)),s}subscribe(e,t,s,r){return this.send(t,s,{callback:r,type:e})}async unsubscribe(t,s,i){const a=`${t}::${i}`;return(0,r.isUndefined)((0,e.default)(this,u)[u][a])?(n.debug(()=>`Unable to find active subscription=${a}`),!1):(delete(0,e.default)(this,u)[u][a],this.send(s,[i]))}}exports.default=l; +},{"@babel/runtime/helpers/esm/classPrivateFieldLooseBase":"VCN6","@babel/runtime/helpers/esm/classPrivateFieldLooseKey":"HsZ4","eventemitter3":"JJlS","@polkadot/util":"mm3z"}],"j58w":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=void 0;var e=t(require("@babel/runtime/helpers/esm/defineProperty"));function t(e){return e&&e.__esModule?e:{default:e}}function r(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),r.push.apply(r,n)}return r}function n(t){for(var n=1;n{const u=`${Date.now()}.${++n}`;s[u]={reject:i,resolve:o,subscriber:t};const c={id:u,message:e,origin:"page",request:r||null};window.postMessage(c,"*")})}async function o(r){return await t("pub(authorize.tab)",{origin:r}),new e.default(t)}async function i(){return await t("pub(phishing.redirectIfDenied)")}function u(e){const r=s[e.id];r?(r.subscriber||delete s[e.id],e.subscription?r.subscriber(e.subscription):e.error?r.reject(new Error(e.error)):r.resolve(e.response)):console.error(`Unknown response: ${JSON.stringify(e)}`)} +},{"./Injected.js":"vUJC"}],"ZSaT":[function(require,module,exports) { +"use strict";function e(e,{name:n,version:t}){const i=window;i.injectedWeb3=i.injectedWeb3||{},i.injectedWeb3[n]={enable:n=>e(n),version:t}}Object.defineProperty(exports,"__esModule",{value:!0}),exports.injectExtension=e; +},{}],"GzV5":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=function(){return function(){var e=this;this.addHandler=function(t,s,r){e._messageHandlers.set(t,{resolve:s,reject:r})},this.getHandler=function(t){return e._messageHandlers.get(t)},this._messageHandlers=new Map}}();exports.default=e; +},{}],"EHrm":[function(require,module,exports) { +module.exports={name:"@pezkuwi/extension",description:"A Novawallet signer for the @polkadot/api",version:"0.1.0",author:"Ruslan Rezin ",license:"Apache-2",scripts:{clean:"rm -rf dist .cache",build:"rm -rf dist && parcel build src/index.ts --no-source-maps -o nova_min.js -d dist",lint:"prettier --write 'src/**/*.ts'",test:"env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register -r jsdom-global/register 'src/test/**/*.ts' --timeout 20000","jest-init":"jest --init"},dependencies:{"@babel/plugin-transform-modules-commonjs":"^7.16.5","@babel/preset-typescript":"^7.16.5","@babel/runtime":"^7.11.2","@polkadot/extension-base":"^0.38.4","@polkadot/extension-dapp":"^0.38.4","@polkadot/extension-inject":"^0.38.4","@types/chai":"^4.3.0","@types/mocha":"^9.0.0","babel-jest":"^27.4.5",chai:"^4.3.4","jest-cli":"^27.4.5","jsdom-global":"^3.0.2",mocha:"^9.1.3",parcel:"^2.14.4","ts-jest":"^27.1.2","ts-node":"^10.4.0"},devDependencies:{"@babel/core":"^7.8.3","@babel/preset-env":"^7.8.3","babel-loader":"^8.0.6",jest:"^27.4.5","parcel-bundler":"^1.12.4","parcel-plugin-static-files-copy":"^2.6.0",prettier:"^1.19.1",typescript:"^4.5.4"},staticFiles:{staticPath:["extension"]},jest:{transform:{"^.+\\.(t|j)s?$":"ts-jest"}}}; +},{}],"QCba":[function(require,module,exports) { +"use strict";var e=this&&this.__assign||function(){return(e=Object.assign||function(e){for(var t,n=1,s=arguments.length;n0&&o[o.length-1])&&(6===r[0]||2===r[0])){i=0;continue}if(3===r[0]&&(!o||r[1]>o[0]&&r[1] + \ No newline at end of file diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureApi.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureApi.kt new file mode 100644 index 0000000..0230390 --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureApi.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_deep_linking.di + +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_deep_linking.presentation.handling.PendingDeepLinkProvider +import io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo.BranchIoLinkConverter +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkingPreferences + +interface DeepLinkingFeatureApi { + val deepLinkingPreferences: DeepLinkingPreferences + + val pendingDeepLinkProvider: PendingDeepLinkProvider + + val branchIoLinkConverter: BranchIoLinkConverter + + val linkBuilderFactory: LinkBuilderFactory +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureComponent.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureComponent.kt new file mode 100644 index 0000000..c031f25 --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureComponent.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_deep_linking.di + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope + +@Component( + dependencies = [ + DeepLinkingFeatureDependencies::class + ], + modules = [ + DeepLinkingFeatureModule::class + ] +) +@FeatureScope +interface DeepLinkingFeatureComponent : DeepLinkingFeatureApi { + + @Component.Factory + interface Factory { + + fun create( + deps: DeepLinkingFeatureDependencies + ): DeepLinkingFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class + ] + ) + interface DeepLinkingFeatureDependenciesComponent : DeepLinkingFeatureDependencies +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureDependencies.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureDependencies.kt new file mode 100644 index 0000000..36731aa --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureDependencies.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_deep_linking.di + +import android.content.Context +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate + +interface DeepLinkingFeatureDependencies { + + val rootScope: RootScope + + val preferences: Preferences + + val context: Context + + val contextManager: ContextManager + + val permissionsAskerFactory: PermissionsAskerFactory + + val resourceManager: ResourceManager + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val imageLoader: ImageLoader + + val gson: Gson + + val automaticInteractionGate: AutomaticInteractionGate +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureHolder.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureHolder.kt new file mode 100644 index 0000000..b5fde1e --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureHolder.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_deep_linking.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import javax.inject.Inject + +class DeepLinkingFeatureHolder @Inject constructor( + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerDeepLinkingFeatureComponent_DeepLinkingFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .build() + + return DaggerDeepLinkingFeatureComponent.factory() + .create( + deps = dependencies + ) + } +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureModule.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureModule.kt new file mode 100644 index 0000000..67bff2a --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/di/DeepLinkingFeatureModule.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_deep_linking.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_deep_linking.R +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_deep_linking.presentation.handling.PendingDeepLinkProvider +import io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo.BranchIoLinkConverter +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkingPreferences + +@Module +class DeepLinkingFeatureModule { + + @Provides + @FeatureScope + fun provideDeepLinkPreferences( + resourceManager: ResourceManager + ) = DeepLinkingPreferences( + deepLinkScheme = resourceManager.getString(R.string.deep_linking_scheme), + deepLinkHost = resourceManager.getString(R.string.deep_linking_host), + appLinkHost = resourceManager.getString(R.string.app_link_host), + branchIoLinkHosts = listOf( + resourceManager.getString(R.string.branch_io_link_host), + resourceManager.getString(R.string.branch_io_link_host_alternate) + ) + ) + + @Provides + @FeatureScope + fun provideLinkBuilderFactory(preferences: DeepLinkingPreferences) = LinkBuilderFactory(preferences) + + @Provides + @FeatureScope + fun providePendingDeepLinkProvider(preferences: Preferences): PendingDeepLinkProvider { + return PendingDeepLinkProvider(preferences) + } + + @Provides + @FeatureScope + fun provideBranchIoLinkConverter( + deepLinkingPreferences: DeepLinkingPreferences + ) = BranchIoLinkConverter(deepLinkingPreferences) +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/configuring/DeepLinkConfigurator.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/configuring/DeepLinkConfigurator.kt new file mode 100644 index 0000000..d76e9d9 --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/configuring/DeepLinkConfigurator.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.configuring + +import android.net.Uri + +interface DeepLinkConfigurator { + + enum class Type { + APP_LINK, DEEP_LINK + } + + fun configure(payload: T, type: Type = Type.DEEP_LINK): Uri +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/configuring/DeepLinkIntentExt.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/configuring/DeepLinkIntentExt.kt new file mode 100644 index 0000000..908d27c --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/configuring/DeepLinkIntentExt.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.configuring + +import android.content.Intent + +fun Intent.applyDeepLink(configurator: DeepLinkConfigurator, payload: T): Intent { + data = configurator.configure(payload) + return this +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/configuring/LinkBuilder.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/configuring/LinkBuilder.kt new file mode 100644 index 0000000..26f31da --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/configuring/LinkBuilder.kt @@ -0,0 +1,110 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.configuring + +import android.net.Uri +import io.novafoundation.nova.common.utils.appendPathOrSkip +import io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo.BranchIOConstants +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkingPreferences + +interface LinkBuilder { + + fun setAction(action: String): LinkBuilder + + fun setEntity(entity: String): LinkBuilder + + fun setScreen(screen: String): LinkBuilder + + fun addParam(key: String, value: String): LinkBuilder + + fun build(): Uri +} + +class LinkBuilderFactory(private val deepLinkingPreferences: DeepLinkingPreferences) { + + fun newLink(type: DeepLinkConfigurator.Type): LinkBuilder { + return when (type) { + DeepLinkConfigurator.Type.APP_LINK -> AppLinkBuilderType(deepLinkingPreferences) + DeepLinkConfigurator.Type.DEEP_LINK -> DeepLinkBuilderType(deepLinkingPreferences) + } + } +} + +class DeepLinkBuilderType( + deepLinkingPreferences: DeepLinkingPreferences +) : LinkBuilder { + + private var action: String? = null + private var entity: String? = null + private var screen: String? = null + + private val urlBuilder = Uri.Builder() + .scheme(deepLinkingPreferences.deepLinkScheme) + .authority(deepLinkingPreferences.deepLinkHost) + + override fun setAction(action: String): LinkBuilder { + this.action = action + return this + } + + override fun setEntity(entity: String): LinkBuilder { + this.entity = entity + return this + } + + override fun setScreen(screen: String): LinkBuilder { + this.screen = screen + return this + } + + override fun addParam(key: String, value: String): LinkBuilder { + urlBuilder.appendQueryParameter(key, value) + return this + } + + override fun build(): Uri { + val finalPath = Uri.Builder() + .appendPathOrSkip(action) + .appendPathOrSkip(entity) + .appendPathOrSkip(screen) + .build() + .path + + return urlBuilder.path(finalPath).build() + } +} + +class AppLinkBuilderType( + private val deepLinkingPreferences: DeepLinkingPreferences +) : LinkBuilder { + + private val urlBuilder = Uri.Builder() + .scheme("https") + .authority(deepLinkingPreferences.branchIoLinkHosts.first()) + + override fun setAction(action: String): LinkBuilder { + urlBuilder.appendQueryParameter(BranchIOConstants.ACTION_QUERY, action) + return this + } + + override fun setEntity(entity: String): LinkBuilder { + urlBuilder.appendQueryParameter(BranchIOConstants.ENTITY_QUERY, entity) + return this + } + + override fun setScreen(screen: String): LinkBuilder { + urlBuilder.appendQueryParameter(BranchIOConstants.SCREEN_QUERY, screen) + return this + } + + override fun addParam(key: String, value: String): LinkBuilder { + urlBuilder.appendQueryParameter(key, value) + return this + } + + override fun build(): Uri { + return urlBuilder.build() + } +} + +fun LinkBuilder.addParamIfNotNull(name: String, value: String?) = apply { + value?.let { addParam(name, value) } +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/DeepLinkHandler.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/DeepLinkHandler.kt new file mode 100644 index 0000000..e997593 --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/DeepLinkHandler.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling + +import android.net.Uri +import kotlinx.coroutines.flow.Flow + +interface DeepLinkHandler { + + val callbackFlow: Flow + + suspend fun matches(data: Uri): Boolean + + suspend fun handleDeepLink(data: Uri): Result +} + +sealed interface CallbackEvent { + data class Message(val message: String) : CallbackEvent +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/PendingDeepLinkProvider.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/PendingDeepLinkProvider.kt new file mode 100644 index 0000000..9a1ce4e --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/PendingDeepLinkProvider.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling + +import android.net.Uri +import io.novafoundation.nova.common.data.storage.Preferences + +private const val PREFS_PENDING_DEEP_LINK = "pending_deep_link" + +class PendingDeepLinkProvider( + private val preferences: Preferences +) { + + fun save(data: Uri) { + preferences.putString(PREFS_PENDING_DEEP_LINK, data.toString()) + } + + fun get(): Uri? { + val deepLink = preferences.getString(PREFS_PENDING_DEEP_LINK) ?: return null + return Uri.parse(deepLink) + } + + fun clear() { + preferences.removeField(PREFS_PENDING_DEEP_LINK) + } +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandler.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandler.kt new file mode 100644 index 0000000..30f58e1 --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/RootDeepLinkHandler.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling + +import android.net.Uri +import io.novafoundation.nova.common.utils.onFailureInstance +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.merge + +class RootDeepLinkHandler( + private val pendingDeepLinkProvider: PendingDeepLinkProvider, + private val nestedHandlers: Collection +) : DeepLinkHandler { + + override val callbackFlow: Flow = nestedHandlers + .mapNotNull { it.callbackFlow } + .merge() + + override suspend fun matches(data: Uri): Boolean { + return nestedHandlers.any { it.matches(data) } + } + + suspend fun checkAndHandlePendingDeepLink(): Result { + val pendingDeepLink = pendingDeepLinkProvider.get() ?: return Result.failure(IllegalStateException("No pending deep link found")) + + return handleDeepLinkInternal(pendingDeepLink) + .onSuccess { pendingDeepLinkProvider.clear() } + } + + override suspend fun handleDeepLink(data: Uri): Result { + pendingDeepLinkProvider.save(data) + return handleDeepLinkInternal(data) + .onSuccess { pendingDeepLinkProvider.clear() } + .onFailureInstance { pendingDeepLinkProvider.clear() } // If we haven't find any handler - no need to save deep link + } + + private suspend fun handleDeepLinkInternal(data: Uri): Result { + val firstHandler = nestedHandlers.find { it.canHandle(data) } ?: return Result.failure(HandlerNotFoundException()) + + return firstHandler.handleDeepLink(data) + } + + private suspend fun DeepLinkHandler.canHandle(data: Uri) = runCatching { this.matches(data) } + .getOrDefault(false) +} + +private class HandlerNotFoundException : Exception() diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/branchIo/BranchIOConstants.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/branchIo/BranchIOConstants.kt new file mode 100644 index 0000000..a4c5459 --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/branchIo/BranchIOConstants.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo + +object BranchIOConstants { + const val ACTION_QUERY = "action" + const val SCREEN_QUERY = "screen" + const val ENTITY_QUERY = "entity" +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/branchIo/BranchIOLinkHandler.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/branchIo/BranchIOLinkHandler.kt new file mode 100644 index 0000000..77f7981 --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/branchIo/BranchIOLinkHandler.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import io.branch.referral.Branch +import io.branch.referral.Defines +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.feature_deep_linking.BuildConfig + +class BranchIOLinkHandler( + private val deepLinkFactory: BranchIoLinkConverter +) { + + object Initializer { + fun init(context: Context) { + if (BuildConfig.DEBUG) { + Branch.enableLogging() + } + + val branchInstance = Branch.getAutoInstance(context) + branchInstance.setConsumerProtectionAttributionLevel(Defines.BranchAttributionLevel.REDUCED) + } + } + + fun onActivityStart(activity: Activity, deepLinkCallback: (Uri) -> Unit) { + Branch.sessionBuilder(activity) + .withCallback { branchUniversalObject, _, error -> + if (error != null) { + Log.e(LOG_TAG, error.toString()) + } + + if (branchUniversalObject != null) { + val deepLink = deepLinkFactory.formatToDeepLink(branchUniversalObject) + deepLinkCallback(deepLink) + } + } + .withData(activity.intent.data) + .init() + } + + fun onActivityNewIntent(activity: Activity, intent: Intent?) { + if (intent != null && intent.getBooleanExtra("branch_force_new_session", false)) { + Branch.sessionBuilder(activity) + .withCallback { _, error -> + if (error != null) { + Log.e(LOG_TAG, error.toString()) + } + } + .withData(intent.data) + .reInit() + } + } +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/branchIo/BranchIoLinkConverter.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/branchIo/BranchIoLinkConverter.kt new file mode 100644 index 0000000..d0a8e51 --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/branchIo/BranchIoLinkConverter.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling.branchIo + +import android.net.Uri +import io.branch.indexing.BranchUniversalObject +import io.novafoundation.nova.common.utils.appendPathOrSkip +import io.novafoundation.nova.common.utils.appendQueries +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkingPreferences + +private val BRANCH_PARAMS_PREFIX = listOf("~", "$", "+") + +class BranchIoLinkConverter( + private val deepLinkingPreferences: DeepLinkingPreferences +) { + + fun formatToDeepLink(data: BranchUniversalObject): Uri { + val queries = data.contentMetadata.customMetadata + .excludeInternalIOQueries() + .toMutableMap() + + return Uri.Builder() + .scheme(deepLinkingPreferences.deepLinkScheme) + .authority(deepLinkingPreferences.deepLinkHost) + .appendPathOrSkip(queries.extractAction()) + .appendPathOrSkip(queries.extractSubject()) + .appendQueries(queries) + .build() + } + + private fun Map.excludeInternalIOQueries(): Map { + return filterKeys { key -> + val isBranchIOQuery = BRANCH_PARAMS_PREFIX.any { prefix -> key.startsWith(prefix) } + !isBranchIOQuery + } + } + + private fun MutableMap.extractAction(): String? { + return remove(BranchIOConstants.ACTION_QUERY) + } + + private fun MutableMap.extractSubject(): String? { + return remove(BranchIOConstants.SCREEN_QUERY) + ?: remove(BranchIOConstants.ENTITY_QUERY) + } +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/common/DeepLinkErrorFormatter.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/common/DeepLinkErrorFormatter.kt new file mode 100644 index 0000000..66646bd --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/common/DeepLinkErrorFormatter.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling.common + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException.DAppHandlingException +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException.ImportMnemonicHandlingException +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException.ReferendumHandlingException +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_deep_linking.R + +fun formatDeepLinkHandlingException(resourceManager: ResourceManager, exception: DeepLinkHandlingException): String { + return when (exception) { + is ReferendumHandlingException -> handleReferendumException(resourceManager, exception) + + is DAppHandlingException -> handleDAppException(resourceManager, exception) + + is ImportMnemonicHandlingException -> handleImportMnemonicException(resourceManager, exception) + } +} + +private fun handleReferendumException(resourceManager: ResourceManager, exception: ReferendumHandlingException): String { + return when (exception) { + ReferendumHandlingException.ReferendumIsNotSpecified -> resourceManager.getString(R.string.referendim_details_not_found_title) + + ReferendumHandlingException.ChainIsNotFound -> resourceManager.getString(R.string.deep_linking_chain_id_is_not_found) + + ReferendumHandlingException.GovernanceTypeIsNotSpecified -> resourceManager.getString(R.string.deep_linking_governance_type_is_not_specified) + + ReferendumHandlingException.GovernanceTypeIsNotSupported -> resourceManager.getString(R.string.deep_linking_governance_type_is_not_supported) + } +} + +fun handleDAppException(resourceManager: ResourceManager, exception: DAppHandlingException): String { + return when (exception) { + DAppHandlingException.UrlIsInvalid -> resourceManager.getString(R.string.deep_linking_url_is_invalid) + + is DAppHandlingException.DomainIsNotMatched -> resourceManager.getString(R.string.deep_linking_domain_is_not_matched, exception.domain) + } +} + +fun handleImportMnemonicException(resourceManager: ResourceManager, exception: ImportMnemonicHandlingException): String { + return when (exception) { + ImportMnemonicHandlingException.InvalidMnemonic -> resourceManager.getString(R.string.deep_linking_invalid_mnemonic) + + ImportMnemonicHandlingException.InvalidCryptoType -> resourceManager.getString(R.string.deep_linking_invalid_crypto_type) + + ImportMnemonicHandlingException.InvalidDerivationPath -> resourceManager.getString(R.string.deep_linking_invalid_derivation_path) + } +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/common/DeepLinkHandlingException.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/common/DeepLinkHandlingException.kt new file mode 100644 index 0000000..556cf38 --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/common/DeepLinkHandlingException.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling.common + +sealed class DeepLinkHandlingException : Exception() { + + sealed class ReferendumHandlingException : DeepLinkHandlingException() { + + object ReferendumIsNotSpecified : ReferendumHandlingException() + + object ChainIsNotFound : ReferendumHandlingException() + + object GovernanceTypeIsNotSpecified : ReferendumHandlingException() + + object GovernanceTypeIsNotSupported : ReferendumHandlingException() + } + + sealed class DAppHandlingException : DeepLinkHandlingException() { + + object UrlIsInvalid : DAppHandlingException() + + class DomainIsNotMatched(val domain: String) : DAppHandlingException() + } + + sealed class ImportMnemonicHandlingException : DeepLinkHandlingException() { + + object InvalidMnemonic : ImportMnemonicHandlingException() + + object InvalidCryptoType : ImportMnemonicHandlingException() + + object InvalidDerivationPath : ImportMnemonicHandlingException() + } +} diff --git a/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/common/DeepLinkingPreferences.kt b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/common/DeepLinkingPreferences.kt new file mode 100644 index 0000000..f443b8c --- /dev/null +++ b/feature-deep-linking/src/main/java/io/novafoundation/nova/feature_deep_linking/presentation/handling/common/DeepLinkingPreferences.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_deep_linking.presentation.handling.common + +import android.net.Uri + +class DeepLinkingPreferences( + val deepLinkScheme: String, + val deepLinkHost: String, + val appLinkHost: String, + val branchIoLinkHosts: List +) + +fun Uri.isDeepLink(preferences: DeepLinkingPreferences): Boolean { + return scheme == preferences.deepLinkScheme && host == preferences.deepLinkHost +} diff --git a/feature-external-sign-api/.gitignore b/feature-external-sign-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-external-sign-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-external-sign-api/build.gradle b/feature-external-sign-api/build.gradle new file mode 100644 index 0000000..c7c8cc1 --- /dev/null +++ b/feature-external-sign-api/build.gradle @@ -0,0 +1,24 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_external_sign_api' + + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':feature-account-api') + implementation project(':common') + + implementation coroutinesDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-external-sign-api/consumer-rules.pro b/feature-external-sign-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-external-sign-api/proguard-rules.pro b/feature-external-sign-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-external-sign-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-external-sign-api/src/main/AndroidManifest.xml b/feature-external-sign-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-external-sign-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/di/ExternalSignFeatureApi.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/di/ExternalSignFeatureApi.kt new file mode 100644 index 0000000..6da8388 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/di/ExternalSignFeatureApi.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_external_sign_api.di + +import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser + +interface ExternalSignFeatureApi { + + val evmTypedMessageParser: EvmTypedMessageParser +} diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/domain/sign/evm/EvmTypedMessageParser.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/domain/sign/evm/EvmTypedMessageParser.kt new file mode 100644 index 0000000..23bf6e8 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/domain/sign/evm/EvmTypedMessageParser.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_external_sign_api.domain.sign.evm + +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage + +interface EvmTypedMessageParser { + + fun parseEvmTypedMessage(message: String): EvmTypedMessage +} diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/ExternalSignCommunicator.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/ExternalSignCommunicator.kt new file mode 100644 index 0000000..8a6d983 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/ExternalSignCommunicator.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_external_sign_api.model + +import android.os.Parcelable +import io.novafoundation.nova.common.base.errors.SigningCancelledException +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator.Response +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload +import kotlinx.parcelize.Parcelize +import kotlinx.coroutines.flow.first + +interface ExternalSignRequester : InterScreenRequester + +interface ExternalSignResponder : InterScreenResponder + +interface ExternalSignCommunicator : ExternalSignRequester, ExternalSignResponder { + + sealed class Response : Parcelable { + + abstract val requestId: String + + @Parcelize + class Rejected(override val requestId: String) : Response() + + @Parcelize + class Signed(override val requestId: String, val signature: String, val modifiedTransaction: String? = null) : Response() + + @Parcelize + class Sent(override val requestId: String, val txHash: String) : Response() + + @Parcelize + class SigningFailed(override val requestId: String, val shouldPresent: Boolean = true) : Response() + } +} + +suspend fun ExternalSignRequester.awaitConfirmation(request: ExternalSignPayload): Response { + openRequest(request) + + return responseFlow.first { it.requestId == request.signRequest.id } +} + +fun Throwable.failedSigningIfNotCancelled(requestId: String) = if (this is SigningCancelledException) { + null +} else { + Response.SigningFailed(requestId) +} diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/ExternalSignPayload.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/ExternalSignPayload.kt new file mode 100644 index 0000000..a3272d3 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/ExternalSignPayload.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_external_sign_api.model.signPayload + +import android.os.Parcelable +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignPayload +import kotlinx.parcelize.Parcelize + +@Parcelize +class ExternalSignPayload( + val signRequest: ExternalSignRequest, + val dappMetadata: SigningDappMetadata?, + val wallet: ExternalSignWallet +) : Parcelable + +@Parcelize +class SigningDappMetadata( + val icon: String?, + val name: String?, + val url: String +) : Parcelable + +sealed class ExternalSignWallet : Parcelable { + + @Parcelize + object Current : ExternalSignWallet() + + @Parcelize + class WithId(val metaId: Long) : ExternalSignWallet() +} + +sealed interface ExternalSignRequest : Parcelable { + + val id: String + + @Parcelize + class Polkadot(override val id: String, val payload: PolkadotSignPayload) : ExternalSignRequest + + @Parcelize + class Evm(override val id: String, val payload: EvmSignPayload) : ExternalSignRequest +} diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmChain.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmChain.kt new file mode 100644 index 0000000..e05ab0d --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmChain.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm + +import android.os.Parcelable +import io.novafoundation.nova.common.utils.Precision +import io.novafoundation.nova.common.utils.TokenSymbol +import kotlinx.parcelize.Parcelize + +@Parcelize +data class EvmChain( + val chainId: String, + val chainName: String, + val nativeCurrency: NativeCurrency, + val rpcUrl: String, + val iconUrl: String? +) : Parcelable { + + @Parcelize + class NativeCurrency( + val name: String, + val symbol: TokenSymbol, + val decimals: Precision + ) : Parcelable +} diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmPersonalSignMessage.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmPersonalSignMessage.kt new file mode 100644 index 0000000..2dd5f7f --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmPersonalSignMessage.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class EvmPersonalSignMessage( + val data: String +) : Parcelable diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmSignPayload.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmSignPayload.kt new file mode 100644 index 0000000..db0a328 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmSignPayload.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class EvmSignPayload : Parcelable { + + abstract val originAddress: String + + @Parcelize + class ConfirmTx( + val transaction: EvmTransaction, + override val originAddress: String, + val chainSource: EvmChainSource, + val action: Action, + ) : EvmSignPayload() { + + enum class Action { + SIGN, SEND + } + } + + @Parcelize + class SignTypedMessage( + val message: EvmTypedMessage, + override val originAddress: String, + ) : EvmSignPayload() + + @Parcelize + class PersonalSign( + val message: EvmPersonalSignMessage, + override val originAddress: String, + ) : EvmSignPayload() +} + +@Parcelize +class EvmChainSource(val evmChainId: Int, val unknownChainOptions: UnknownChainOptions) : Parcelable { + + sealed class UnknownChainOptions : Parcelable { + + @Parcelize + object MustBeKnown : UnknownChainOptions() + + @Parcelize + class WithFallBack(val evmChain: EvmChain) : UnknownChainOptions() + } +} diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmTransaction.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmTransaction.kt new file mode 100644 index 0000000..8c1ec51 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmTransaction.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class EvmTransaction : Parcelable { + @Parcelize + class Struct( + val gas: String?, + val gasPrice: String?, + val from: String, + val to: String, + val data: String?, + val value: String?, + val nonce: String?, + ) : EvmTransaction() + + @Parcelize + class Raw(val rawContent: String) : EvmTransaction() +} diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmTypedMessage.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmTypedMessage.kt new file mode 100644 index 0000000..ab373e4 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/evm/EvmTypedMessage.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class EvmTypedMessage( + val data: String, + val raw: String? +) : Parcelable diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/polkadot/PolkadotSignPayload.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/polkadot/PolkadotSignPayload.kt new file mode 100644 index 0000000..49d5a6d --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/polkadot/PolkadotSignPayload.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class PolkadotSignPayload : Parcelable { + + abstract val address: String + + @Parcelize + class Json( + override val address: String, + val blockHash: String, + val blockNumber: String, + val era: String, + val genesisHash: String, + val method: String, + val nonce: String, + val specVersion: String, + val tip: String, + val transactionVersion: String, + val metadataHash: String?, + val withSignedTransaction: Boolean?, + val signedExtensions: List, + val assetId: String?, + val version: Int + ) : PolkadotSignPayload() + + @Parcelize + class Raw( + val data: String, + override val address: String, + val type: String? + ) : PolkadotSignPayload() +} + +fun PolkadotSignPayload.maybeSignExtrinsic(): PolkadotSignPayload.Json? = this as? PolkadotSignPayload.Json + +fun PolkadotSignPayload.genesisHash(): String? = (this as? PolkadotSignPayload.Json)?.genesisHash diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/polkadot/PolkadotSignerResult.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/polkadot/PolkadotSignerResult.kt new file mode 100644 index 0000000..ea5b460 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/model/signPayload/polkadot/PolkadotSignerResult.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot + +class PolkadotSignerResult(val id: String, val signature: String, val signedTransaction: String?) diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/presentation/dapp/Icon.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/presentation/dapp/Icon.kt new file mode 100644 index 0000000..4e8ddd2 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/presentation/dapp/Icon.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_external_sign_api.presentation.dapp + +import android.widget.ImageView +import coil.ImageLoader +import coil.load +import io.novafoundation.nova.feature_external_sign_api.R + +fun ImageView.showDAppIcon( + url: String?, + imageLoader: ImageLoader +) { + load(url, imageLoader) { + fallback(R.drawable.ic_earth) + error(R.drawable.ic_earth) + } +} diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/presentation/externalSign/AuthorizeDappBottomSheet.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/presentation/externalSign/AuthorizeDappBottomSheet.kt new file mode 100644 index 0000000..44cded9 --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/presentation/externalSign/AuthorizeDappBottomSheet.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_external_sign_api.presentation.externalSign + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.postToSelf +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_external_sign_api.databinding.BottomSheetConfirmAuthorizeBinding +import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon + +import kotlinx.coroutines.launch + +class AuthorizeDappBottomSheet( + context: Context, + private val payload: Payload, + onConfirm: () -> Unit, + onDeny: () -> Unit, +) : ConfirmDAppActionBottomSheet( + context = context, + onConfirm = onConfirm, + onDeny = onDeny +) { + + class Payload( + val dAppUrl: String, + val title: String, + val dAppIconUrl: String?, + val walletModel: WalletModel, + ) + + private val interactionGate: AutomaticInteractionGate + + private val imageLoader: ImageLoader + + override val contentBinder = BottomSheetConfirmAuthorizeBinding.inflate(LayoutInflater.from(context)) + + init { + FeatureUtils.getCommonApi(context).let { api -> + interactionGate = api.automaticInteractionGate + imageLoader = api.imageLoader() + } + } + + override fun show() { + launch { + interactionGate.awaitInteractionAllowed() + + super.show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + contentBinder.confirmAuthorizeDappIcon.showDAppIcon(payload.dAppIconUrl, imageLoader) + contentBinder.confirmAuthorizeDappWallet.postToSelf { showWallet(payload.walletModel) } + + contentBinder.confirmAuthorizeDappTitle.text = payload.title + contentBinder.confirmAuthorizeDappDApp.postToSelf { showValue(payload.dAppUrl) } + } +} diff --git a/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/presentation/externalSign/ConfirmDAppActionBottomSheet.kt b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/presentation/externalSign/ConfirmDAppActionBottomSheet.kt new file mode 100644 index 0000000..470384b --- /dev/null +++ b/feature-external-sign-api/src/main/java/io/novafoundation/nova/feature_external_sign_api/presentation/externalSign/ConfirmDAppActionBottomSheet.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_external_sign_api.presentation.externalSign + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import androidx.annotation.CallSuper +import androidx.viewbinding.ViewBinding +import io.novafoundation.nova.common.utils.DialogExtensions +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet +import io.novafoundation.nova.feature_external_sign_api.databinding.BottomSheetConfirmDappActionBinding + +abstract class ConfirmDAppActionBottomSheet( + context: Context, + private val onConfirm: () -> Unit, + private val onDeny: () -> Unit +) : BaseBottomSheet(context), DialogExtensions { + + override val binder: BottomSheetConfirmDappActionBinding = BottomSheetConfirmDappActionBinding.inflate(LayoutInflater.from(context)) + + abstract val contentBinder: ViewBinding + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setCancelable(false) + + binder.confirmInnerContent.addView(contentBinder.root) + + binder.confirmDAppActionAllow.setDismissingClickListener { onConfirm() } + binder.confirmDAppActionReject.setDismissingClickListener { onDeny() } + } +} diff --git a/feature-external-sign-api/src/main/res/layout/bottom_sheet_confirm_authorize.xml b/feature-external-sign-api/src/main/res/layout/bottom_sheet_confirm_authorize.xml new file mode 100644 index 0000000..f54d83c --- /dev/null +++ b/feature-external-sign-api/src/main/res/layout/bottom_sheet_confirm_authorize.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-external-sign-api/src/main/res/layout/bottom_sheet_confirm_dapp_action.xml b/feature-external-sign-api/src/main/res/layout/bottom_sheet_confirm_dapp_action.xml new file mode 100644 index 0000000..9e2b99c --- /dev/null +++ b/feature-external-sign-api/src/main/res/layout/bottom_sheet_confirm_dapp_action.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-external-sign-impl/.gitignore b/feature-external-sign-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-external-sign-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-external-sign-impl/build.gradle b/feature-external-sign-impl/build.gradle new file mode 100644 index 0000000..794ccd8 --- /dev/null +++ b/feature-external-sign-impl/build.gradle @@ -0,0 +1,75 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: "../scripts/secrets.gradle" + +android { + namespace 'io.novafoundation.nova.feature_external_sign_impl' + + + defaultConfig { + + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(':feature-external-sign-api') + implementation project(':feature-currency-api') + implementation project(':runtime') + + implementation kotlinDep + + + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation shimmerDep + + implementation coroutinesDep + + implementation gsonDep + + implementation daggerDep + + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation retrofitDep + + implementation web3jDep + implementation coroutinesFutureDep + + implementation walletConnectCoreDep, withoutTransitiveAndroidX + implementation walletConnectWalletDep, withoutTransitiveAndroidX + + testImplementation jUnitDep + testImplementation mockitoDep +} diff --git a/feature-external-sign-impl/consumer-rules.pro b/feature-external-sign-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-external-sign-impl/proguard-rules.pro b/feature-external-sign-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-external-sign-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-external-sign-impl/src/main/AndroidManifest.xml b/feature-external-sign-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-external-sign-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/ExternalSignRouter.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/ExternalSignRouter.kt new file mode 100644 index 0000000..3e48529 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/ExternalSignRouter.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_external_sign_impl + +import io.novafoundation.nova.common.navigation.ReturnableRouter + +interface ExternalSignRouter : ReturnableRouter { + + fun openExtrinsicDetails(extrinsicContent: String) +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/data/evmApi/EvmApi.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/data/evmApi/EvmApi.kt new file mode 100644 index 0000000..c778b04 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/data/evmApi/EvmApi.kt @@ -0,0 +1,203 @@ +package io.novafoundation.nova.feature_external_sign_impl.data.evmApi + +import io.novafoundation.nova.common.utils.toEcdsaSignatureData +import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProvider +import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource.UnknownChainOptions +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.findEvmCallApi +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.Signer +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import okhttp3.OkHttpClient +import org.web3j.crypto.RawTransaction +import org.web3j.crypto.Sign.SignatureData +import org.web3j.crypto.TransactionEncoder +import org.web3j.protocol.Web3j +import org.web3j.protocol.core.DefaultBlockParameterName +import org.web3j.protocol.core.methods.request.Transaction +import org.web3j.protocol.http.HttpService +import org.web3j.rlp.RlpEncoder +import org.web3j.rlp.RlpList +import org.web3j.tx.RawTransactionManager +import java.math.BigInteger + +interface EvmApi { + + suspend fun formTransaction( + fromAddress: String, + toAddress: String, + data: String?, + value: BigInteger?, + nonce: BigInteger? = null, + gasLimit: BigInteger? = null, + gasPrice: BigInteger? = null, + ): RawTransaction + + /** + * @return hash of submitted transaction + */ + suspend fun sendTransaction( + transaction: RawTransaction, + signer: Signer, + accountId: AccountId, + ethereumChainId: Long, + ): String + + /** + * @return signed transaction, ready to be send by eth_sendRawTransaction + */ + suspend fun signTransaction( + transaction: RawTransaction, + signer: Signer, + accountId: AccountId, + ethereumChainId: Long, + ): String + + suspend fun getAccountBalance(address: String): BigInteger + + fun shutdown() +} + +class EvmApiFactory( + private val okHttpClient: OkHttpClient, + private val chainRegistry: ChainRegistry, + private val gasPriceProviderFactory: GasPriceProviderFactory, +) { + + suspend fun create(chainSource: EvmChainSource): EvmApi? { + val knownWeb3jApi = chainRegistry.findEvmCallApi(chainSource.evmChainId) + val unknownChainOptions = chainSource.unknownChainOptions + + return when { + knownWeb3jApi != null -> { + Web3JEvmApi( + web3 = knownWeb3jApi, + shouldShutdown = false, + gasPriceProvider = gasPriceProviderFactory.create(knownWeb3jApi) + ) + } + + unknownChainOptions is UnknownChainOptions.WithFallBack -> { + val web3Api = createWeb3j(unknownChainOptions.evmChain.rpcUrl) + + Web3JEvmApi( + web3 = web3Api, + shouldShutdown = true, + gasPriceProvider = gasPriceProviderFactory.create(web3Api) + ) + } + + else -> null + } + } + + private fun createWeb3j(url: String): Web3j { + return Web3j.build(HttpService(url, okHttpClient)) + } +} + +private class Web3JEvmApi( + private val web3: Web3j, + private val shouldShutdown: Boolean, + private val gasPriceProvider: GasPriceProvider, +) : EvmApi { + + override suspend fun formTransaction( + fromAddress: String, + toAddress: String, + data: String?, + value: BigInteger?, + nonce: BigInteger?, + gasLimit: BigInteger?, + gasPrice: BigInteger?, + ): RawTransaction { + val finalNonce = nonce ?: getNonce(fromAddress) + val finalGasPrice = gasPrice ?: gasPriceProvider.getGasPrice() + + val dataOrDefault = data.orEmpty() + + val finalGasLimit = gasLimit ?: run { + val forFeeEstimatesTx = Transaction.createFunctionCallTransaction( + fromAddress, + finalNonce, + null, + null, + toAddress, + value, + dataOrDefault + ) + + estimateGasLimit(forFeeEstimatesTx) + } + + return RawTransaction.createTransaction( + finalNonce, + finalGasPrice, + finalGasLimit, + toAddress, + value, + dataOrDefault + ) + } + + /** + * Ethereum signing is adopted from [TransactionEncoder.signMessage] and [RawTransactionManager.sign] + */ + override suspend fun sendTransaction( + transaction: RawTransaction, + signer: Signer, + accountId: AccountId, + ethereumChainId: Long, + ): String { + val signedRawTransaction = signTransaction(transaction, signer, accountId, ethereumChainId) + + return sendTransaction(signedRawTransaction) + } + + override suspend fun signTransaction( + transaction: RawTransaction, + signer: Signer, + accountId: AccountId, + ethereumChainId: Long + ): String { + val encodedTx = TransactionEncoder.encode(transaction, ethereumChainId) + val signerPayload = SignerPayloadRaw(encodedTx, accountId) + val signatureData = signer.signRaw(signerPayload).toEcdsaSignatureData() + + val eip155SignatureData: SignatureData = TransactionEncoder.createEip155SignatureData(signatureData, ethereumChainId) + + return transaction.encodeWith(eip155SignatureData).toHexString(withPrefix = true) + } + + override suspend fun getAccountBalance(address: String): BigInteger { + return web3.ethGetBalance(address, DefaultBlockParameterName.LATEST).sendSuspend().balance + } + + override fun shutdown() { + if (shouldShutdown) web3.shutdown() + } + + private suspend fun sendTransaction(transactionData: String): String { + return web3.ethSendRawTransaction(transactionData).sendSuspend().transactionHash + } + + private suspend fun getNonce(address: String): BigInteger { + return web3.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING) + .sendSuspend() + .transactionCount + } + + private suspend fun estimateGasLimit(tx: Transaction): BigInteger { + return web3.ethEstimateGas(tx).sendSuspend().amountUsed + } + + private fun RawTransaction.encodeWith(signatureData: SignatureData): ByteArray { + val values = TransactionEncoder.asRlpValues(this, signatureData) + val rlpList = RlpList(values) + return RlpEncoder.encode(rlpList) + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureComponent.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureComponent.kt new file mode 100644 index 0000000..f9be78c --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureComponent.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_external_sign_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter +import io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.di.ExternalExtrinsicDetailsComponent +import io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.di.ExternalSignComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + ExternalSignFeatureDependencies::class + ], + modules = [ + ExternalSignFeatureModule::class + ] +) +@FeatureScope +interface ExternalSignFeatureComponent : ExternalSignFeatureApi { + + fun signExtrinsicComponentFactory(): ExternalSignComponent.Factory + + fun extrinsicDetailsComponentFactory(): ExternalExtrinsicDetailsComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: ExternalSignRouter, + @BindsInstance signCommunicator: ExternalSignCommunicator, + deps: ExternalSignFeatureDependencies + ): ExternalSignFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + AccountFeatureApi::class, + WalletFeatureApi::class, + RuntimeApi::class, + CurrencyFeatureApi::class, + ] + ) + interface ExternalSignFeatureDependenciesComponent : ExternalSignFeatureDependencies +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureDependencies.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureDependencies.kt new file mode 100644 index 0000000..5f0e7ac --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureDependencies.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_external_sign_impl.di + +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.di.ExtrinsicSerialization +import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import okhttp3.OkHttpClient + +interface ExternalSignFeatureDependencies { + + val amountFormatter: AmountFormatter + + fun currencyRepository(): CurrencyRepository + + fun accountRepository(): AccountRepository + + fun resourceManager(): ResourceManager + + fun selectedAccountUseCase(): SelectedAccountUseCase + + fun addressIconGenerator(): AddressIconGenerator + + fun chainRegistry(): ChainRegistry + + fun imageLoader(): ImageLoader + + fun extrinsicService(): ExtrinsicService + + fun tokenRepository(): TokenRepository + + fun secretStoreV2(): SecretStoreV2 + + @ExtrinsicSerialization + fun extrinsicGson(): Gson + + val feeLoaderMixinFactory: FeeLoaderMixinV2.Factory + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val walletUiUseCase: WalletUiUseCase + + val okHttpClient: OkHttpClient + + val walletRepository: WalletRepository + + val validationExecutor: ValidationExecutor + + val signerProvider: SignerProvider + + val gasPriceProviderFactory: GasPriceProviderFactory + + val rpcCalls: RpcCalls + + val metadataShortenerService: MetadataShortenerService + + val signingContextFactory: SigningContext.Factory +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureHolder.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureHolder.kt new file mode 100644 index 0000000..84ef4ba --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureHolder.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_external_sign_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class ExternalSignFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: ExternalSignRouter, + private val signCommunicator: ExternalSignCommunicator, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val deps = DaggerExternalSignFeatureComponent_ExternalSignFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java)) + .build() + + return DaggerExternalSignFeatureComponent.factory() + .create(router, signCommunicator, deps) + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureModule.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureModule.kt new file mode 100644 index 0000000..33fcc3a --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/ExternalSignFeatureModule.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_external_sign_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser +import io.novafoundation.nova.feature_external_sign_impl.di.modules.sign.EvmSignModule +import io.novafoundation.nova.feature_external_sign_impl.di.modules.sign.PolkadotSignModule +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm.RealEvmTypedMessageParser + +@Module(includes = [EvmSignModule::class, PolkadotSignModule::class]) +class ExternalSignFeatureModule { + + @Provides + @FeatureScope + fun provideEvmTypedMessageParser(): EvmTypedMessageParser = RealEvmTypedMessageParser() +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/modules/sign/EvmSignModule.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/modules/sign/EvmSignModule.kt new file mode 100644 index 0000000..07f76cc --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/modules/sign/EvmSignModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_external_sign_impl.di.modules.sign + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_external_sign_impl.data.evmApi.EvmApiFactory +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm.EvmSignInteractorFactory +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.runtime.di.ExtrinsicSerialization +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import okhttp3.OkHttpClient + +@Module +class EvmSignModule { + + @Provides + @FeatureScope + fun provideEthereumApiFactory( + okHttpClient: OkHttpClient, + chainRegistry: ChainRegistry, + gasPriceProviderFactory: GasPriceProviderFactory, + ): EvmApiFactory { + return EvmApiFactory(okHttpClient, chainRegistry, gasPriceProviderFactory) + } + + @Provides + @FeatureScope + fun provideSignInteractorFactory( + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + tokenRepository: TokenRepository, + @ExtrinsicSerialization extrinsicGson: Gson, + addressIconGenerator: AddressIconGenerator, + evmApiFactory: EvmApiFactory, + signerProvider: SignerProvider, + currencyRepository: CurrencyRepository + ) = EvmSignInteractorFactory( + chainRegistry = chainRegistry, + accountRepository = accountRepository, + signerProvider = signerProvider, + tokenRepository = tokenRepository, + currencyRepository = currencyRepository, + extrinsicGson = extrinsicGson, + addressIconGenerator = addressIconGenerator, + evmApiFactory = evmApiFactory + ) +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/modules/sign/PolkadotSignModule.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/modules/sign/PolkadotSignModule.kt new file mode 100644 index 0000000..9f28106 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/di/modules/sign/PolkadotSignModule.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_external_sign_impl.di.modules.sign + +import com.google.gson.Gson +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_external_sign_impl.di.modules.sign.PolkadotSignModule.BindsModule +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot.PolkadotSignInteractorFactory +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot.RealSignBytesChainResolver +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot.SignBytesChainResolver +import io.novafoundation.nova.runtime.di.ExtrinsicSerialization +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [BindsModule::class]) +class PolkadotSignModule { + + @Module + interface BindsModule { + + @Binds + fun bindSignBytesResolver(real: RealSignBytesChainResolver): SignBytesChainResolver + } + + @Provides + @FeatureScope + fun provideSignInteractorFactory( + extrinsicService: ExtrinsicService, + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + @ExtrinsicSerialization extrinsicGson: Gson, + addressIconGenerator: AddressIconGenerator, + signerProvider: SignerProvider, + metadataShortenerService: MetadataShortenerService, + signingContextFactory: SigningContext.Factory, + signBytesChainResolver: SignBytesChainResolver + ) = PolkadotSignInteractorFactory( + extrinsicService = extrinsicService, + chainRegistry = chainRegistry, + accountRepository = accountRepository, + extrinsicGson = extrinsicGson, + addressIconGenerator = addressIconGenerator, + signerProvider = signerProvider, + metadataShortenerService = metadataShortenerService, + signingContextFactory = signingContextFactory, + signBytesChainResolver = signBytesChainResolver + ) +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/BaseExternalSignInteractor.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/BaseExternalSignInteractor.kt new file mode 100644 index 0000000..e15f76f --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/BaseExternalSignInteractor.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign + +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner + +abstract class BaseExternalSignInteractor( + private val accountRepository: AccountRepository, + private val wallet: ExternalSignWallet, + private val signerProvider: SignerProvider, +) : ExternalSignInteractor { + + protected suspend fun resolveWalletSigner(): NovaSigner { + val metaAccount = resolveMetaAccount() + return signerProvider.rootSignerFor(metaAccount) + } + + protected suspend fun resolveMetaAccount(): MetaAccount { + return when (wallet) { + ExternalSignWallet.Current -> accountRepository.getSelectedMetaAccount() + is ExternalSignWallet.WithId -> accountRepository.getMetaAccount(wallet.metaId) + } + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/Decoding.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/Decoding.kt new file mode 100644 index 0000000..a77761b --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/Decoding.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign + +import io.novasama.substrate_sdk_android.extensions.fromHex + +fun String.tryConvertHexToUtf8(): String { + return runCatching { fromHex().decodeToString(throwOnInvalidSequence = true) } + .getOrDefault(this) +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/ExternalSignInteractor.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/ExternalSignInteractor.kt new file mode 100644 index 0000000..f753534 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/ExternalSignInteractor.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +interface ExternalSignInteractor { + + sealed class Error : Throwable() { + class UnsupportedChain(val chainId: String) : Error() + } + + val validationSystem: ConfirmDAppOperationValidationSystem + + suspend fun createAccountAddressModel(): AddressModel + + suspend fun chainUi(): Result + + fun utilityAssetFlow(): Flow? + + suspend fun calculateFee(): Fee? + + suspend fun performOperation(upToDateFee: Fee?): ExternalSignCommunicator.Response? + + suspend fun readableOperationContent(): String + + suspend fun shutdown() {} +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/Validations.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/Validations.kt new file mode 100644 index 0000000..50e39b6 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/Validations.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.domain.validation.FeeChangeDetectedFailure + +sealed class ConfirmDAppOperationValidationFailure { + + class FeeSpikeDetected(override val payload: FeeChangeDetectedFailure.Payload) : + ConfirmDAppOperationValidationFailure(), + FeeChangeDetectedFailure +} + +data class ConfirmDAppOperationValidationPayload( + val fee: Fee? +) + +typealias ConfirmDAppOperationValidationSystem = ValidationSystem diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/EvmSignInteractor.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/EvmSignInteractor.kt new file mode 100644 index 0000000..e1e6a7f --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/EvmSignInteractor.kt @@ -0,0 +1,342 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm + +import android.util.Log +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.asHexString +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.decodeEvmQuantity +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.parseArbitraryObject +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.data.model.EvmFee +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_api.model.failedSigningIfNotCancelled +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChain +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmPersonalSignMessage +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload.ConfirmTx +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload.PersonalSign +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload.SignTypedMessage +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTransaction +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage +import io.novafoundation.nova.feature_external_sign_impl.data.evmApi.EvmApi +import io.novafoundation.nova.feature_external_sign_impl.data.evmApi.EvmApiFactory +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.BaseExternalSignInteractor +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationFailure +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationSystem +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ExternalSignInteractor +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.tryConvertHexToUtf8 +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.validation.checkForFeeChanges +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.findChain +import io.novafoundation.nova.runtime.multiNetwork.findEvmChain +import io.novasama.substrate_sdk_android.extensions.asEthereumAddress +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.web3j.crypto.RawTransaction +import org.web3j.crypto.TransactionDecoder + +class EvmSignInteractorFactory( + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, + private val currencyRepository: CurrencyRepository, + private val extrinsicGson: Gson, + private val addressIconGenerator: AddressIconGenerator, + private val evmApiFactory: EvmApiFactory, + private val signerProvider: SignerProvider +) { + + fun create(request: ExternalSignRequest.Evm, wallet: ExternalSignWallet) = EvmSignInteractor( + chainRegistry = chainRegistry, + tokenRepository = tokenRepository, + currencyRepository = currencyRepository, + extrinsicGson = extrinsicGson, + addressIconGenerator = addressIconGenerator, + request = request, + evmApiFactory = evmApiFactory, + accountRepository = accountRepository, + signerProvider = signerProvider, + wallet = wallet + ) +} + +class EvmSignInteractor( + private val evmApiFactory: EvmApiFactory, + private val request: ExternalSignRequest.Evm, + private val addressIconGenerator: AddressIconGenerator, + private val tokenRepository: TokenRepository, + private val currencyRepository: CurrencyRepository, + private val chainRegistry: ChainRegistry, + private val extrinsicGson: Gson, + private val signerProvider: SignerProvider, + accountRepository: AccountRepository, + wallet: ExternalSignWallet, +) : BaseExternalSignInteractor(accountRepository, wallet, signerProvider), + CoroutineScope by CoroutineScope(Dispatchers.Default) { + + private val mostRecentFormedTx = singleReplaySharedFlow() + + private val payload = request.payload + + @OptIn(DelicateCoroutinesApi::class) + private val ethereumApi by GlobalScope.lazyAsync { + payload.castOrNull()?.chainSource?.let { chainSource -> + evmApiFactory.create(chainSource) + } + } + + override val validationSystem: ConfirmDAppOperationValidationSystem = ValidationSystem { + if (payload is ConfirmTx) { + checkForFeeChanges( + calculateFee = { calculateFee()!! }, + currentFee = { it.fee }, + chainAsset = { it.fee!!.asset }, + error = ConfirmDAppOperationValidationFailure::FeeSpikeDetected + ) + } + } + + override suspend fun createAccountAddressModel(): AddressModel = withContext(Dispatchers.Default) { + val address = request.payload.originAddress + val someEthereumChain = chainRegistry.findChain { it.isEthereumBased }!! // always have at least one ethereum chain in the app + + addressIconGenerator.createAccountAddressModel(someEthereumChain, address) + } + + override suspend fun chainUi(): Result = withContext(Dispatchers.Default) { + runCatching { + if (payload is ConfirmTx) { + chainRegistry.findEvmChain(payload.chainSource.evmChainId)?.let(::mapChainToUi) + ?: payload.chainSource.fallbackChain?.let(::mapEvmChainToUi) + ?: throw ExternalSignInteractor.Error.UnsupportedChain(payload.chainSource.evmChainId.toString()) + } else { + null + } + } + } + + override fun utilityAssetFlow(): Flow? { + if (payload !is ConfirmTx) return null + + return flowOf { + chainRegistry.findEvmChain(payload.chainSource.evmChainId)?.utilityAsset + ?: createAssetFrom(payload.chainSource.unknownChainOptions) + }.filterNotNull() + } + + override suspend fun calculateFee(): Fee? = withContext(Dispatchers.Default) { + if (payload !is ConfirmTx) return@withContext null + + resolveWalletSigner() + + val api = ethereumApi() ?: return@withContext null + val chain = chainRegistry.findEvmChain(payload.chainSource.evmChainId) + + // Commission asset for evm + val chainAsset = chain?.utilityAsset ?: createAssetFrom(payload.chainSource.unknownChainOptions) ?: return@withContext null + + val tx = api.formTransaction(payload.transaction, feeOverride = null) + mostRecentFormedTx.emit(tx) + + tx.fee(chainAsset) + } + + override suspend fun performOperation(upToDateFee: Fee?): ExternalSignCommunicator.Response? = withContext(Dispatchers.Default) { + runCatching { + when (payload) { + is ConfirmTx -> confirmTx(payload.transaction, upToDateFee, payload.chainSource.evmChainId.toLong(), payload.action) + is SignTypedMessage -> signTypedMessage(payload.message) + is PersonalSign -> personalSign(payload.message) + } + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to sign evm tx", error) + + error.failedSigningIfNotCancelled(request.id) + } + } + + override suspend fun readableOperationContent(): String = withContext(Dispatchers.Default) { + when (payload) { + is ConfirmTx -> extrinsicGson.toJson(mostRecentFormedTx.first()) + is SignTypedMessage -> signTypedMessageReadableContent(payload) + is PersonalSign -> personalSignReadableContent(payload) + } + } + + override suspend fun shutdown() { + ethereumApi()?.shutdown() + } + + private suspend fun confirmTx(basedOn: EvmTransaction, upToDateFee: Fee?, evmChainId: Long, action: ConfirmTx.Action): ExternalSignCommunicator.Response { + val api = requireNotNull(ethereumApi()) + + val tx = api.formTransaction(basedOn, upToDateFee) + + val originAccountId = originAccountId() + val signer = resolveWalletSigner() + + return when (action) { + ConfirmTx.Action.SIGN -> { + val signedTx = api.signTransaction(tx, signer, originAccountId, evmChainId) + ExternalSignCommunicator.Response.Signed(request.id, signedTx) + } + + ConfirmTx.Action.SEND -> { + val txHash = api.sendTransaction(tx, signer, originAccountId, evmChainId) + ExternalSignCommunicator.Response.Sent(request.id, txHash) + } + } + } + + private suspend fun signTypedMessage(message: EvmTypedMessage): ExternalSignCommunicator.Response.Signed { + val signature = signMessage(message.data.fromHex()) + + return ExternalSignCommunicator.Response.Signed(request.id, signature) + } + + private suspend fun personalSign(message: EvmPersonalSignMessage): ExternalSignCommunicator.Response.Signed { + val personalSignMessage = message.data.fromHex().asEthereumPersonalSignMessage() + val payload = SignerPayloadRaw(personalSignMessage, originAccountId(), skipMessageHashing = true) + + val signature = resolveWalletSigner().signRaw(payload).asHexString() + + return ExternalSignCommunicator.Response.Signed(request.id, signature) + } + + private suspend fun signMessage(message: ByteArray): String { + val signerPayload = SignerPayloadRaw(message, originAccountId(), skipMessageHashing = true) + + return resolveWalletSigner().signRaw(signerPayload).asHexString() + } + + private fun personalSignReadableContent(payload: PersonalSign): String { + val data = payload.message.data + return data.tryConvertHexToUtf8() + } + + private fun signTypedMessageReadableContent(payload: SignTypedMessage): String { + return runCatching { + val parsedRaw = extrinsicGson.parseArbitraryObject(payload.message.raw!!) + + val wrapped = mapOf( + "data" to payload.message.data, + "raw" to parsedRaw + ) + + extrinsicGson.toJson(wrapped) + }.getOrElse { + extrinsicGson.toJson(payload.message) + } + } + + private fun mapEvmChainToUi(metamaskChain: EvmChain): ChainUi { + return ChainUi( + id = metamaskChain.chainId, + name = metamaskChain.chainName, + icon = metamaskChain.iconUrl + ) + } + + private suspend fun EvmApi.formTransaction(basedOn: EvmTransaction, feeOverride: Fee?): RawTransaction { + return when (basedOn) { + is EvmTransaction.Raw -> TransactionDecoder.decode(basedOn.rawContent) + + is EvmTransaction.Struct -> { + val evmFee = feeOverride.castOrNull() + + formTransaction( + fromAddress = basedOn.from, + toAddress = basedOn.to, + data = basedOn.data, + value = basedOn.value?.decodeEvmQuantity(), + nonce = basedOn.nonce?.decodeEvmQuantity(), + gasLimit = evmFee?.gasLimit ?: basedOn.gas?.decodeEvmQuantity(), + gasPrice = evmFee?.gasPrice ?: basedOn.gasPrice?.decodeEvmQuantity() + ) + } + } + } + + private suspend fun createTokenFrom(unknownChainOptions: EvmChainSource.UnknownChainOptions): Token? { + if (unknownChainOptions !is EvmChainSource.UnknownChainOptions.WithFallBack) return null + val asset = createAssetFrom(unknownChainOptions) ?: return null + + return Token( + configuration = asset, + coinRate = null, + currency = currencyRepository.getSelectedCurrency() + ) + } + + private fun createAssetFrom(unknownChainOptions: EvmChainSource.UnknownChainOptions): Chain.Asset? { + if (unknownChainOptions !is EvmChainSource.UnknownChainOptions.WithFallBack) return null + + val evmChain = unknownChainOptions.evmChain + val chainCurrency = evmChain.nativeCurrency + + return Chain.Asset( + icon = null, + id = 0, + priceId = null, + chainId = evmChain.chainId, + symbol = chainCurrency.symbol, + precision = chainCurrency.decimals, + buyProviders = emptyMap(), + sellProviders = emptyMap(), + staking = emptyList(), + type = Chain.Asset.Type.EvmNative, + name = chainCurrency.name, + source = Chain.Asset.Source.ERC20, + enabled = true + ) + } + + private fun RawTransaction.fee(chainAsset: Chain.Asset): Fee = EvmFee( + gasLimit = gasLimit, + gasPrice = gasPrice, + submissionOrigin = submissionOrigin(), + chainAsset + ) + + private fun submissionOrigin() = SubmissionOrigin.singleOrigin(originAccountId()) + + private fun originAccountId() = payload.originAddress.asEthereumAddress().toAccountId().value + + private val EvmChainSource.fallbackChain: EvmChain? + get() = when (val options = unknownChainOptions) { + + EvmChainSource.UnknownChainOptions.MustBeKnown -> null + + is EvmChainSource.UnknownChainOptions.WithFallBack -> options.evmChain + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/PersonalSign.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/PersonalSign.kt new file mode 100644 index 0000000..56d0062 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/PersonalSign.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm + +import org.web3j.crypto.Hash +import org.web3j.crypto.Sign + +private const val MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n" + +private fun getEthereumMessagePrefix(messageLength: Int): ByteArray { + return (MESSAGE_PREFIX + messageLength.toString()).encodeToByteArray() +} + +/** + * Adopted from [Sign.getEthereumMessageHash] since the former method is package-private and cannot be directly accessed by the calling code + */ +fun ByteArray.asEthereumPersonalSignMessage(): ByteArray { + val prefix = getEthereumMessagePrefix(size) + + val result = ByteArray(prefix.size + size) + + System.arraycopy(prefix, 0, result, 0, prefix.size) + System.arraycopy(this, 0, result, prefix.size, size) + + return Hash.sha3(result) +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/RealEvmTypedMessageParser.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/RealEvmTypedMessageParser.kt new file mode 100644 index 0000000..1de1149 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/RealEvmTypedMessageParser.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm + +import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.web3j.crypto.StructuredDataEncoder + +internal class RealEvmTypedMessageParser : EvmTypedMessageParser { + + override fun parseEvmTypedMessage(message: String): EvmTypedMessage { + val encoder = StructuredDataEncoder(message) + + return EvmTypedMessage( + data = encoder.hashStructuredData().toHexString(withPrefix = true), + raw = message + ) + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/polkadot/DAppParsedExtrinsic.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/polkadot/DAppParsedExtrinsic.kt new file mode 100644 index 0000000..ab41d9e --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/polkadot/DAppParsedExtrinsic.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Era +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import java.math.BigInteger + +data class DAppParsedExtrinsic( + val address: String, + val nonce: BigInteger, + val specVersion: Int, + val transactionVersion: Int, + val genesisHash: ByteArray, + val era: Era, + val blockHash: ByteArray, + val tip: BigInteger, + val metadataHash: ByteArray?, + val call: GenericCall.Instance, + val assetId: Any?, +) diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/polkadot/PolkadotExternalSignInteractor.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/polkadot/PolkadotExternalSignInteractor.kt new file mode 100644 index 0000000..0dea7a6 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/polkadot/PolkadotExternalSignInteractor.kt @@ -0,0 +1,364 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot + +import android.util.Log +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.utils.asHexString +import io.novafoundation.nova.common.utils.bigIntegerFromHex +import io.novafoundation.nova.common.utils.convertToExternalCompatibleFormat +import io.novafoundation.nova.common.utils.endsWith +import io.novafoundation.nova.common.utils.intFromHex +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.utils.startsWith +import io.novafoundation.nova.common.validation.EmptyValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.fee.types.assetHub.decodeCustomTxPaymentId +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SigningContext +import io.novafoundation.nova.feature_account_api.data.signer.SigningMode +import io.novafoundation.nova.feature_account_api.data.signer.setSignerData +import io.novafoundation.nova.feature_account_api.data.signer.signRaw +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_api.model.failedSigningIfNotCancelled +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.maybeSignExtrinsic +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.BaseExternalSignInteractor +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationSystem +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ExternalSignInteractor +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.tryConvertHexToUtf8 +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.anyAddressToAccountId +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.extrinsic.CustomTransactionExtensions +import io.novafoundation.nova.runtime.extrinsic.extensions.ChargeAssetTxPayment.Companion.chargeAssetTxPayment +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getChainOrNull +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.EraType +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode +import io.novasama.substrate_sdk_android.runtime.extrinsic.ExtrinsicVersion +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.ChargeTransactionPayment +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckGenesis +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckMortality +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckSpecVersion +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckTxVersion +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHash +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHashMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +class PolkadotSignInteractorFactory( + private val extrinsicService: ExtrinsicService, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val extrinsicGson: Gson, + private val addressIconGenerator: AddressIconGenerator, + private val metadataShortenerService: MetadataShortenerService, + private val signBytesChainResolver: SignBytesChainResolver, + private val signerProvider: SignerProvider, + private val signingContextFactory: SigningContext.Factory, +) { + + fun create(request: ExternalSignRequest.Polkadot, wallet: ExternalSignWallet) = PolkadotExternalSignInteractor( + extrinsicService = extrinsicService, + chainRegistry = chainRegistry, + accountRepository = accountRepository, + extrinsicGson = extrinsicGson, + addressIconGenerator = addressIconGenerator, + request = request, + wallet = wallet, + signerProvider = signerProvider, + metadataShortenerService = metadataShortenerService, + signingContextFactory = signingContextFactory, + signBytesChainResolver = signBytesChainResolver + ) +} + +class PolkadotExternalSignInteractor( + private val extrinsicService: ExtrinsicService, + private val chainRegistry: ChainRegistry, + private val extrinsicGson: Gson, + private val addressIconGenerator: AddressIconGenerator, + private val request: ExternalSignRequest.Polkadot, + private val signerProvider: SignerProvider, + private val metadataShortenerService: MetadataShortenerService, + private val signingContextFactory: SigningContext.Factory, + private val signBytesChainResolver: SignBytesChainResolver, + wallet: ExternalSignWallet, + accountRepository: AccountRepository +) : BaseExternalSignInteractor(accountRepository, wallet, signerProvider) { + + private val signPayload = request.payload + + private val actualParsedExtrinsic = singleReplaySharedFlow() + + override val validationSystem: ConfirmDAppOperationValidationSystem = EmptyValidationSystem() + + override suspend fun createAccountAddressModel(): AddressModel { + val icon = addressIconGenerator.createAddressIcon( + accountId = signPayload.accountId(), + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + backgroundColorRes = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + + return AddressModel(signPayload.address, icon, name = null) + } + + override suspend fun chainUi(): Result { + return runCatching { + signPayload.maybeSignExtrinsic()?.let { + mapChainToUi(it.chain()) + } + } + } + + override fun utilityAssetFlow(): Flow? { + val chainId = signPayload.maybeSignExtrinsic()?.genesisHash ?: return null + + return flow { + val chain = chainRegistry.getChainOrNull(chainId) ?: return@flow + emit(chain.utilityAsset) + } + } + + override suspend fun performOperation(upToDateFee: Fee?): ExternalSignCommunicator.Response? = withContext(Dispatchers.Default) { + runCatching { + when (signPayload) { + is PolkadotSignPayload.Json -> signExtrinsic(signPayload) + is PolkadotSignPayload.Raw -> signBytes(signPayload) + } + } + .onFailure { Log.e("PolkadotExternalSignInteractor", "Failed to sign", it) } + .fold( + onSuccess = { signedResult -> + ExternalSignCommunicator.Response.Signed(request.id, signedResult.signature, signedResult.modifiedTransaction) + }, + onFailure = { error -> + error.failedSigningIfNotCancelled(request.id) + } + ) + } + + override suspend fun readableOperationContent(): String = withContext(Dispatchers.Default) { + when (signPayload) { + is PolkadotSignPayload.Json -> readableExtrinsicContent() + is PolkadotSignPayload.Raw -> readableBytesContent(signPayload) + } + } + + override suspend fun calculateFee(): Fee? = withContext(Dispatchers.Default) { + require(signPayload is PolkadotSignPayload.Json) + + val chain = signPayload.chainOrNull() ?: return@withContext null + + val signer = resolveWalletSigner() + val (extrinsic, _, parsedExtrinsic) = signPayload.analyzeAndSign(signer, SigningMode.FEE) + + actualParsedExtrinsic.emit(parsedExtrinsic) + + extrinsicService.estimateFee(chain, extrinsic.extrinsicHex, signer) + } + + private fun readableBytesContent(signBytesPayload: PolkadotSignPayload.Raw): String { + return signBytesPayload.data.tryConvertHexToUtf8() + } + + private suspend fun readableExtrinsicContent(): String { + return extrinsicGson.toJson(actualParsedExtrinsic.first()) + } + + private suspend fun signBytes(signBytesPayload: PolkadotSignPayload.Raw): SignedResult { + val accountId = signBytesPayload.address.anyAddressToAccountId() + + val signer = resolveWalletSigner() + val payload = SignerPayloadRaw.fromUnsafeString(signBytesPayload.data, accountId) + + val chainId = signBytesChainResolver.resolveChainId(signBytesPayload.address) + val signature = signer.signRaw(payload, chainId).signatureWrapper.convertToExternalCompatibleFormat() + + return SignedResult(signature.asHexString(), modifiedTransaction = null) + } + + private suspend fun signExtrinsic(extrinsicPayload: PolkadotSignPayload.Json): SignedResult { + val signer = resolveWalletSigner() + val (extrinsic, modifiedOriginal) = extrinsicPayload.analyzeAndSign(signer, SigningMode.SUBMISSION) + + val modifiedTx = if (modifiedOriginal) extrinsic.extrinsicHex else null + + return SignedResult(extrinsic.signatureHex, modifiedTx) + } + + private suspend fun PolkadotSignPayload.Json.analyzeAndSign( + signer: NovaSigner, + signingMode: SigningMode + ): ActualExtrinsic { + val chain = chain() + val runtime = chainRegistry.getRuntime(genesisHash) + val parsedExtrinsic = parseDAppExtrinsic(runtime, this) + + val actualMetadataHash = actualMetadataHash(chain, signer) + + val signingContext = signingContextFactory.default(chain) + + val extrinsic = with(parsedExtrinsic) { + ExtrinsicBuilder(runtime, ExtrinsicVersion.V4, BatchMode.BATCH_ALL).apply { + setTransactionExtension(CheckMortality(era, blockHash)) + setTransactionExtension(CheckGenesis(genesisHash)) + setTransactionExtension(ChargeTransactionPayment(tip)) + setTransactionExtension(CheckMetadataHash(actualMetadataHash.checkMetadataHash)) + setTransactionExtension(CheckSpecVersion(specVersion)) + setTransactionExtension(CheckTxVersion(transactionVersion)) + + call(parsedExtrinsic.call) + CustomTransactionExtensions.applyDefaultValues(builder = this, runtime = runtime) + applyCustomSignedExtensions(parsedExtrinsic) + + signer.setSignerData(signingContext, signingMode) + } + }.buildExtrinsic() + + val actualParsedExtrinsic = parsedExtrinsic.copy( + metadataHash = actualMetadataHash.checkMetadataHash.metadataHash + ) + + return ActualExtrinsic( + signedExtrinsic = extrinsic, + modifiedOriginal = actualMetadataHash.modifiedOriginal, + actualParsedExtrinsic = actualParsedExtrinsic + ) + } + + private fun SignerPayloadRaw.Companion.fromUnsafeString(data: String, signer: AccountId): SignerPayloadRaw { + val unsafeMessage = decodeSigningMessage(data) + val safeMessage = protectSigningMessage(unsafeMessage) + + return SignerPayloadRaw(safeMessage, signer) + } + + private fun decodeSigningMessage(data: String): ByteArray { + return kotlin.runCatching { data.fromHex() }.getOrElse { data.encodeToByteArray() } + } + + private fun protectSigningMessage(message: ByteArray): ByteArray { + val prefix = "".encodeToByteArray() + val suffix = "".encodeToByteArray() + + if (message.startsWith(prefix) && message.endsWith(suffix)) return message + + return prefix + message + suffix + } + + private suspend fun PolkadotSignPayload.Json.actualMetadataHash(chain: Chain, signer: NovaSigner): ActualMetadataHash { + // If a dapp haven't declared a permission to modify extrinsic - return whatever metadataHash present in payload + if (withSignedTransaction != true) { + return ActualMetadataHash(modifiedOriginal = false, hexHash = metadataHash) + } + + // If a dapp have specified metadata hash explicitly - use it + if (metadataHash != null) { + return ActualMetadataHash(modifiedOriginal = false, hexHash = metadataHash) + } + + // Else generate and use our own proof + val metadataProof = metadataShortenerService.generateMetadataProof(chain.id) + return ActualMetadataHash(modifiedOriginal = true, checkMetadataHash = metadataProof.checkMetadataHash) + } + + private fun PolkadotSignPayload.Json.decodedCall(runtime: RuntimeSnapshot): GenericCall.Instance { + return GenericCall.fromHex(runtime, method) + } + + private suspend fun PolkadotSignPayload.Json.chain(): Chain { + return chainRegistry.getChainOrNull(genesisHash) ?: throw ExternalSignInteractor.Error.UnsupportedChain(genesisHash) + } + + private suspend fun PolkadotSignPayload.Json.chainOrNull(): Chain? { + return chainRegistry.getChainOrNull(genesisHash) + } + + private fun parseDAppExtrinsic(runtime: RuntimeSnapshot, payloadJSON: PolkadotSignPayload.Json): DAppParsedExtrinsic { + return with(payloadJSON) { + DAppParsedExtrinsic( + address = address, + nonce = nonce.bigIntegerFromHex(), + specVersion = specVersion.intFromHex(), + transactionVersion = transactionVersion.intFromHex(), + genesisHash = genesisHash.fromHex(), + blockHash = blockHash.fromHex(), + era = EraType.fromHex(runtime, era), + tip = tip.bigIntegerFromHex(), + call = decodedCall(runtime), + metadataHash = metadataHash?.fromHex(), + assetId = payloadJSON.tryDecodeAssetId(runtime) + ) + } + } + + private suspend fun PolkadotSignPayload.accountId(): AccountId { + return when (this) { + is PolkadotSignPayload.Json -> { + val chain = chainOrNull() + + chain?.accountIdOf(address) ?: address.anyAddressToAccountId() + } + + is PolkadotSignPayload.Raw -> address.anyAddressToAccountId() + } + } + + private class ActualMetadataHash(val modifiedOriginal: Boolean, val checkMetadataHash: CheckMetadataHashMode) { + constructor(modifiedOriginal: Boolean, hash: ByteArray?) : this(modifiedOriginal, CheckMetadataHashMode(hash)) + + constructor(modifiedOriginal: Boolean, hexHash: String?) : this(modifiedOriginal, hexHash?.fromHex()) + } + + private data class ActualExtrinsic( + val signedExtrinsic: SendableExtrinsic, + val modifiedOriginal: Boolean, + val actualParsedExtrinsic: DAppParsedExtrinsic + ) + + private data class SignedResult(val signature: String, val modifiedTransaction: String?) + + private fun ExtrinsicBuilder.applyCustomSignedExtensions(parsedExtrinsic: DAppParsedExtrinsic): ExtrinsicBuilder { + parsedExtrinsic.assetId?.let { chargeAssetTxPayment(it) } + + return this + } + + private fun PolkadotSignPayload.Json.tryDecodeAssetId(runtime: RuntimeSnapshot): Any? { + return assetId?.let(runtime::decodeCustomTxPaymentId) + } +} + +private fun CheckMetadataHashMode(hash: ByteArray?): CheckMetadataHashMode { + return if (hash != null) { + CheckMetadataHashMode.Enabled(hash) + } else { + CheckMetadataHashMode.Disabled + } +} + +private val CheckMetadataHashMode.metadataHash: ByteArray? + get() = if (this is CheckMetadataHashMode.Enabled) hash else null diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/polkadot/SignBytesChainResolver.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/polkadot/SignBytesChainResolver.kt new file mode 100644 index 0000000..2eb7783 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/polkadot/SignBytesChainResolver.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.findChainIds +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.addressPrefix +import javax.inject.Inject + +interface SignBytesChainResolver { + + suspend fun resolveChainId(address: String): ChainId? +} + +@FeatureScope +class RealSignBytesChainResolver @Inject constructor( + private val chainRegistry: ChainRegistry, +) : SignBytesChainResolver { + + override suspend fun resolveChainId(address: String): ChainId? { + return runCatching { + val ss58Prefix = address.addressPrefix() + detectChainIdFromSs58Prefix(ss58Prefix.toInt()) + }.getOrNull() + } + + private suspend fun detectChainIdFromSs58Prefix(prefix: Int): ChainId? { + val chains = chainRegistry.findChainIds { it.addressPrefix == prefix } + + // This mapping is targeted to provide better detection for UAF and Polkadot Vault derivations (PV has Polkadot and Kusama derivations by default) + return when { + chains.isEmpty() -> null + + Chain.Geneses.POLKADOT in chains -> Chain.Geneses.POLKADOT + Chain.Geneses.KUSAMA in chains -> Chain.Geneses.KUSAMA + + chains.size == 1 -> chains.single() + + else -> Chain.Geneses.POLKADOT + } + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/ExternalExtrinsicDetailsFragment.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/ExternalExtrinsicDetailsFragment.kt new file mode 100644 index 0000000..47bf0f1 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/ExternalExtrinsicDetailsFragment.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails + +import androidx.core.os.bundleOf + +import io.novafoundation.nova.common.base.BaseBottomSheetFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi +import io.novafoundation.nova.feature_external_sign_impl.databinding.FragmentDappExtrinsicDetailsBinding +import io.novafoundation.nova.feature_external_sign_impl.di.ExternalSignFeatureComponent + +class ExternalExtrinsicDetailsFragment : BaseBottomSheetFragment() { + + companion object { + + private const val PAYLOAD_KEY = "PAYLOAD_KEY" + + fun getBundle(extrinsicContent: String) = bundleOf(PAYLOAD_KEY to extrinsicContent) + } + + override fun createBinding() = FragmentDappExtrinsicDetailsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.signExtrinsicToolbar.setHomeButtonListener { viewModel.closeClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(this, ExternalSignFeatureApi::class.java) + .extrinsicDetailsComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: ExternalExtrinsicDetailsViewModel) { + binder.extrinsicDetailsContent.text = viewModel.extrinsicContent + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/ExternalExtrinsicDetailsViewModel.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/ExternalExtrinsicDetailsViewModel.kt new file mode 100644 index 0000000..ad5d66d --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/ExternalExtrinsicDetailsViewModel.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter + +class ExternalExtrinsicDetailsViewModel( + private val router: ExternalSignRouter, + val extrinsicContent: String +) : BaseViewModel() { + + fun closeClicked() { + router.back() + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/di/ExternalExtrinsicDetailsComponent.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/di/ExternalExtrinsicDetailsComponent.kt new file mode 100644 index 0000000..1a27def --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/di/ExternalExtrinsicDetailsComponent.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.ExternalExtrinsicDetailsFragment + +@Subcomponent( + modules = [ + ExternalExtrinsicDetailsModule::class + ] +) +@ScreenScope +interface ExternalExtrinsicDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance extrinsicContent: String, + ): ExternalExtrinsicDetailsComponent + } + + fun inject(fragment: ExternalExtrinsicDetailsFragment) +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/di/ExternalExtrinsicDetailsModule.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/di/ExternalExtrinsicDetailsModule.kt new file mode 100644 index 0000000..1cb0b53 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/extrinsicDetails/di/ExternalExtrinsicDetailsModule.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter +import io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.ExternalExtrinsicDetailsViewModel + +@Module(includes = [ViewModelModule::class]) +class ExternalExtrinsicDetailsModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ExternalExtrinsicDetailsViewModel { + return ViewModelProvider(fragment, factory).get(ExternalExtrinsicDetailsViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(ExternalExtrinsicDetailsViewModel::class) + fun provideViewModel( + router: ExternalSignRouter, + extrinsicContent: String + ): ViewModel { + return ExternalExtrinsicDetailsViewModel( + router = router, + extrinsicContent = extrinsicContent + ) + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/ExternalSignFragment.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/ExternalSignFragment.kt new file mode 100644 index 0000000..154ac08 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/ExternalSignFragment.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic + +import androidx.core.os.bundleOf + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.blockBackPressing +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.postToSelf +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.dialog.errorDialog +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload +import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon +import io.novafoundation.nova.feature_external_sign_impl.R +import io.novafoundation.nova.feature_external_sign_impl.databinding.FragmentConfirmSignExtrinsicBinding +import io.novafoundation.nova.feature_external_sign_impl.di.ExternalSignFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView + +import javax.inject.Inject + +private const val PAYLOAD_KEY = "DAppSignExtrinsicFragment.Payload" + +class ExternalSignFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: ExternalSignPayload) = bundleOf(PAYLOAD_KEY to payload) + } + + override fun createBinding() = FragmentConfirmSignExtrinsicBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun initViews() { + blockBackPressing() + + binder.confirmDAppActionAllow.prepareForProgress(viewLifecycleOwner) + + binder.confirmDAppActionAllow.setOnClickListener { viewModel.acceptClicked() } + binder.confirmDAppActionAllow.setText(R.string.common_confirm) + binder.confirmDAppActionReject.setOnClickListener { viewModel.rejectClicked() } + + binder.confirmSignExtinsicDetails.setOnClickListener { viewModel.detailsClicked() } + binder.confirmSignExtinsicDetails.background = with(requireContext()) { + addRipple(getBlockDrawable()) + } + } + + override fun inject() { + FeatureUtils.getFeature(this, ExternalSignFeatureApi::class.java) + .signExtrinsicComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + @Suppress("UNCHECKED_CAST") + override fun subscribe(viewModel: ExternalSignViewModel) { + setupFeeLoading(viewModel, binder.confirmSignExtinsicFee) + observeValidations(viewModel) + + viewModel.maybeChainUi.observe { chainUi -> + binder.confirmSignExtinsicNetwork.postToSelf { + if (chainUi != null) { + showChain(chainUi) + } else { + makeGone() + } + } + } + + viewModel.requestedAccountModel.observe { + binder.confirmSignExtinsicAccount.postToSelf { showAddress(it) } + } + + viewModel.walletUi.observe { + binder.confirmSignExtinsicWallet.postToSelf { showWallet(it) } + } + + binder.confirmSignExtinsicIcon.showDAppIcon(viewModel.dAppInfo?.icon, imageLoader) + binder.confirmSignExtinsicDappUrl.showValueOrHide(viewModel.dAppInfo?.url) + + viewModel.performingOperationInProgress.observe { operationInProgress -> + val actionsAllowed = !operationInProgress + + binder.confirmDAppActionReject.isEnabled = actionsAllowed + binder.confirmDAppActionAllow.setProgressState(show = operationInProgress) + } + + viewModel.confirmUnrecoverableError.awaitableActionLiveData.observeEvent { + errorDialog( + context = requireContext(), + onConfirm = { it.onSuccess(Unit) } + ) { + setMessage(it.payload) + } + } + } + + private fun setupFeeLoading(viewModel: ExternalSignViewModel, feeView: FeeView) { + val mixin = viewModel.originFeeMixin + feeView.setVisible(mixin != null) + + mixin?.let { setupFeeLoading(it, feeView) } + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/ExternalSignViewModel.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/ExternalSignViewModel.kt new file mode 100644 index 0000000..5b04d48 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/ExternalSignViewModel.kt @@ -0,0 +1,209 @@ +package io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator.Response +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignResponder +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet +import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter +import io.novafoundation.nova.feature_external_sign_impl.R +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationFailure +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationPayload +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ExternalSignInteractor +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleFeeSpikeDetected +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.PaymentCurrencySelectionMode +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.asFeeContextFromSelf +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitOptionalFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefaultBy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private typealias SigningError = String + +class ExternalSignViewModel( + private val router: ExternalSignRouter, + private val responder: ExternalSignResponder, + private val interactor: ExternalSignInteractor, + private val payload: ExternalSignPayload, + private val validationExecutor: ValidationExecutor, + private val resourceManager: ResourceManager, + walletUiUseCase: WalletUiUseCase, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory +) : BaseViewModel(), + Validatable by validationExecutor { + + val confirmUnrecoverableError = actionAwaitableMixinFactory.confirmingAction() + + private val commissionTokenFlow = interactor.utilityAssetFlow() + ?.shareInBackground() + + val originFeeMixin = commissionTokenFlow?.let { + feeLoaderMixinV2Factory.createDefaultBy( + scope = viewModelScope, + feeContext = it.asFeeContextFromSelf(), + configuration = FeeLoaderMixinV2.Configuration( + showZeroFiat = false, + initialState = FeeLoaderMixinV2.Configuration.InitialState( + paymentCurrencySelectionMode = PaymentCurrencySelectionMode.DETECT_FROM_FEE + ) + ) + ) + } + + private val _performingOperationInProgress = MutableStateFlow(false) + val performingOperationInProgress: StateFlow = _performingOperationInProgress + + val walletUi = walletUiUseCase.walletUiFor(payload.wallet) + .shareInBackground() + + val requestedAccountModel = flowOf { + interactor.createAccountAddressModel() + } + .shareInBackground() + + val maybeChainUi = flowOf { + interactor.chainUi() + } + .finishOnFailure() + .shareInBackground() + + val dAppInfo = payload.dappMetadata + + init { + maybeLoadFee() + } + + fun rejectClicked() { + responder.respond(Response.Rejected(payload.signRequest.id)) + + exit() + } + + fun acceptClicked() = launch { + _performingOperationInProgress.value = true + + val validationPayload = ConfirmDAppOperationValidationPayload( + fee = originFeeMixin?.awaitOptionalFee() + ) + + validationExecutor.requireValid( + validationSystem = interactor.validationSystem, + payload = validationPayload, + validationFailureTransformerCustom = ::validationFailureToUi, + autoFixPayload = ::autoFixPayload, + progressConsumer = _performingOperationInProgress.progressConsumer() + ) { + performOperation(it.fee) + } + } + + private fun performOperation(upToDateFee: Fee?) = launch { + interactor.performOperation(upToDateFee)?.let { response -> + responder.respond(response) + + exit() + } + + _performingOperationInProgress.value = false + } + + private fun maybeLoadFee() { + originFeeMixin?.loadFee { interactor.calculateFee() } + } + + private suspend fun respondError(errorMessage: String?) = withContext(Dispatchers.Main) { + val shouldPresent = if (errorMessage != null) { + confirmUnrecoverableError.awaitAction(errorMessage) + false + } else { + true + } + + val response = Response.SigningFailed(payload.signRequest.id, shouldPresent) + + responder.respond(response) + exit() + } + + private suspend fun respondError(error: Throwable) { + val errorMessage = when (error) { + is ExternalSignInteractor.Error.UnsupportedChain -> resourceManager.getString(R.string.dapp_sign_error_unsupported_chain, error.chainId) + else -> null + } + + respondError(errorMessage) + } + + fun detailsClicked() { + launch { + val extrinsicContent = interactor.readableOperationContent() + + router.openExtrinsicDetails(extrinsicContent) + } + } + + private fun exit() = launch { + interactor.shutdown() + + router.back() + } + + private fun validationFailureToUi( + failure: ValidationStatus.NotValid, + actions: ValidationFlowActions<*> + ): TransformedFailure? { + return when (val reason = failure.reason) { + is ConfirmDAppOperationValidationFailure.FeeSpikeDetected -> originFeeMixin?.let { + handleFeeSpikeDetected( + error = reason, + resourceManager = resourceManager, + setFee = originFeeMixin, + actions = actions + ) + } + } + } + + private fun autoFixPayload( + payload: ConfirmDAppOperationValidationPayload, + failure: ConfirmDAppOperationValidationFailure + ): ConfirmDAppOperationValidationPayload { + return when (failure) { + is ConfirmDAppOperationValidationFailure.FeeSpikeDetected -> payload.copy(fee = failure.payload.newFee) + } + } + + private fun Flow>.finishOnFailure(): Flow { + return onEach { result -> result.onFailure { respondError(it) } } + .map { it.getOrNull() } + } + + private fun WalletUiUseCase.walletUiFor(externalSignWallet: ExternalSignWallet): Flow { + return when (externalSignWallet) { + ExternalSignWallet.Current -> selectedWalletUiFlow(showAddressIcon = true) + is ExternalSignWallet.WithId -> walletUiFlow(externalSignWallet.metaId, showAddressIcon = true) + } + } +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/di/ExternalSignComponent.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/di/ExternalSignComponent.kt new file mode 100644 index 0000000..9290ebc --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/di/ExternalSignComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload +import io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.ExternalSignFragment + +@Subcomponent( + modules = [ + ExternalSignModule::class + ] +) +@ScreenScope +interface ExternalSignComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ExternalSignPayload, + ): ExternalSignComponent + } + + fun inject(fragment: ExternalSignFragment) +} diff --git a/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/di/ExternalSignModule.kt b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/di/ExternalSignModule.kt new file mode 100644 index 0000000..168ff58 --- /dev/null +++ b/feature-external-sign-impl/src/main/java/io/novafoundation/nova/feature_external_sign_impl/presentation/signExtrinsic/di/ExternalSignModule.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ExternalSignInteractor +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm.EvmSignInteractorFactory +import io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot.PolkadotSignInteractorFactory +import io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.ExternalSignViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 + +@Module(includes = [ViewModelModule::class]) +class ExternalSignModule { + + @Provides + @ScreenScope + fun provideInteractor( + polkadotSignInteractorFactory: PolkadotSignInteractorFactory, + metamaskSignInteractorFactory: EvmSignInteractorFactory, + payload: ExternalSignPayload + ): ExternalSignInteractor = when (val request = payload.signRequest) { + is ExternalSignRequest.Polkadot -> polkadotSignInteractorFactory.create(request, payload.wallet) + is ExternalSignRequest.Evm -> metamaskSignInteractorFactory.create(request, payload.wallet) + } + + @Provides + @ScreenScope + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ExternalSignViewModel { + return ViewModelProvider(fragment, factory).get(ExternalSignViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(ExternalSignViewModel::class) + fun provideViewModel( + router: ExternalSignRouter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + interactor: ExternalSignInteractor, + payload: ExternalSignPayload, + communicator: ExternalSignCommunicator, + walletUiUseCase: WalletUiUseCase, + validationExecutor: ValidationExecutor, + resourceManager: ResourceManager, + actionAwaitableMixin: ActionAwaitableMixin.Factory + ): ViewModel { + return ExternalSignViewModel( + router = router, + interactor = interactor, + feeLoaderMixinV2Factory = feeLoaderMixinFactory, + payload = payload, + responder = communicator, + walletUiUseCase = walletUiUseCase, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + actionAwaitableMixinFactory = actionAwaitableMixin + ) + } +} diff --git a/feature-external-sign-impl/src/main/res/layout/fragment_confirm_sign_extrinsic.xml b/feature-external-sign-impl/src/main/res/layout/fragment_confirm_sign_extrinsic.xml new file mode 100644 index 0000000..fab8520 --- /dev/null +++ b/feature-external-sign-impl/src/main/res/layout/fragment_confirm_sign_extrinsic.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-external-sign-impl/src/main/res/layout/fragment_dapp_extrinsic_details.xml b/feature-external-sign-impl/src/main/res/layout/fragment_dapp_extrinsic_details.xml new file mode 100644 index 0000000..21dbe79 --- /dev/null +++ b/feature-external-sign-impl/src/main/res/layout/fragment_dapp_extrinsic_details.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-external-sign-impl/src/test/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/PersonalSignKtTest.kt b/feature-external-sign-impl/src/test/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/PersonalSignKtTest.kt new file mode 100644 index 0000000..da18329 --- /dev/null +++ b/feature-external-sign-impl/src/test/java/io/novafoundation/nova/feature_external_sign_impl/domain/sign/evm/PersonalSignKtTest.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm + +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import org.web3j.crypto.Sign + +class PersonalSignKtTest { + + @Test + fun `should perform personal sign`() { + val message = "Test".encodeToByteArray() + val expected = expectedSign(message) + val actual = message.asEthereumPersonalSignMessage() + + assertArrayEquals(expected, actual) + } + + // call to reference package-private implementation via reflection. + // It is not good approach to use this as our actual implementation since reflection is unsafe and slow + private fun expectedSign(message: ByteArray): ByteArray { + val method = Sign::class.java.getDeclaredMethod("getEthereumMessageHash", ByteArray::class.java) + method.isAccessible = true + + return method.invoke(null, message) as ByteArray + } +} diff --git a/feature-gift-api/.gitignore b/feature-gift-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-gift-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-gift-api/build.gradle b/feature-gift-api/build.gradle new file mode 100644 index 0000000..3ace0a2 --- /dev/null +++ b/feature-gift-api/build.gradle @@ -0,0 +1,26 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_gift_api' + + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':feature-account-api') + implementation project(':feature-deep-linking') + implementation project(':common') + implementation project(':runtime') + + implementation coroutinesDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-gift-api/consumer-rules.pro b/feature-gift-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-gift-api/proguard-rules.pro b/feature-gift-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-gift-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-gift-api/src/main/AndroidManifest.xml b/feature-gift-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-gift-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/di/GiftDeepLinks.kt b/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/di/GiftDeepLinks.kt new file mode 100644 index 0000000..c1a8c46 --- /dev/null +++ b/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/di/GiftDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_gift_api.di + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class GiftDeepLinks(val deepLinkHandlers: List) diff --git a/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/di/GiftFeatureApi.kt b/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/di/GiftFeatureApi.kt new file mode 100644 index 0000000..e24e288 --- /dev/null +++ b/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/di/GiftFeatureApi.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_gift_api.di + +import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase + +interface GiftFeatureApi { + + val giftDeepLinks: GiftDeepLinks + + val availableGiftAssetsUseCase: AvailableGiftAssetsUseCase + + val giftsAccountSupportedUseCase: GiftsAccountSupportedUseCase +} diff --git a/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/domain/AvailableGiftAssetsUseCase.kt b/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/domain/AvailableGiftAssetsUseCase.kt new file mode 100644 index 0000000..65cc792 --- /dev/null +++ b/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/domain/AvailableGiftAssetsUseCase.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_gift_api.domain + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface AvailableGiftAssetsUseCase { + suspend fun isGiftsAvailable(chainAsset: Chain.Asset): Boolean + + fun getAvailableGiftAssets(coroutineScope: CoroutineScope): Flow> +} diff --git a/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/domain/GiftsAccountSupportedUseCase.kt b/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/domain/GiftsAccountSupportedUseCase.kt new file mode 100644 index 0000000..04b2ad6 --- /dev/null +++ b/feature-gift-api/src/main/java/io/novafoundation/nova/feature_gift_api/domain/GiftsAccountSupportedUseCase.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_gift_api.domain + +import kotlinx.coroutines.flow.Flow + +enum class GiftsSupportedState { + SUPPORTED, + UNSUPPORTED_MULTISIG_ACCOUNTS +} + +interface GiftsAccountSupportedUseCase { + suspend fun supportedState(): GiftsSupportedState + + fun areGiftsSupportedFlow(): Flow +} diff --git a/feature-gift-impl/.gitignore b/feature-gift-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-gift-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-gift-impl/build.gradle b/feature-gift-impl/build.gradle new file mode 100644 index 0000000..db4809e --- /dev/null +++ b/feature-gift-impl/build.gradle @@ -0,0 +1,66 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: "../scripts/secrets.gradle" + +android { + namespace 'io.novafoundation.nova.feature_gift_impl' + + + defaultConfig { + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-wallet-api') + implementation project(':feature-account-api') + implementation project(':feature-deep-linking') + implementation project(':feature-gift-api') + implementation project(':runtime') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation constraintDep + implementation flexBoxDep + + implementation lottie, withoutTransitiveAndroidX + + implementation coroutinesDep + + implementation daggerDep + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation retrofitDep + + implementation coroutinesFutureDep + + testImplementation jUnitDep + testImplementation mockitoDep +} diff --git a/feature-gift-impl/consumer-rules.pro b/feature-gift-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-gift-impl/proguard-rules.pro b/feature-gift-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-gift-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-gift-impl/src/main/AndroidManifest.xml b/feature-gift-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-gift-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/data/GiftSecretsRepository.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/data/GiftSecretsRepository.kt new file mode 100644 index 0000000..03d2511 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/data/GiftSecretsRepository.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_gift_impl.data + +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val GIFT_SECRETS = "GIFT_SECRETS" + +interface GiftSecretsRepository { + + suspend fun putGiftAccountSeed(accountId: ByteArray, seed: ByteArray) + + suspend fun getGiftAccountSeed(accountId: ByteArray): ByteArray? +} + +class RealGiftSecretsRepository( + private val encryptedPreferences: EncryptedPreferences, +) : GiftSecretsRepository { + + override suspend fun putGiftAccountSeed(accountId: ByteArray, seed: ByteArray) = withContext(Dispatchers.IO) { + encryptedPreferences.putEncryptedString(giftAccountKey(accountId), seed.toHexString()) + } + + override suspend fun getGiftAccountSeed(accountId: ByteArray): ByteArray? = withContext(Dispatchers.IO) { + encryptedPreferences.getDecryptedString(giftAccountKey(accountId))?.fromHex() + } + + private fun giftAccountKey(accountId: ByteArray) = "${accountId.toHexString()}:$GIFT_SECRETS" +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/data/GiftsRepository.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/data/GiftsRepository.kt new file mode 100644 index 0000000..b1fff6d --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/data/GiftsRepository.kt @@ -0,0 +1,101 @@ +package io.novafoundation.nova.feature_gift_impl.data + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.GiftsDao +import io.novafoundation.nova.core_db.model.GiftLocal +import io.novafoundation.nova.feature_gift_impl.domain.models.Gift +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import java.math.BigInteger +import java.util.Date +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface GiftsRepository { + + suspend fun getGift(giftId: Long): Gift + + suspend fun getGifts(): List + + fun observeGift(giftId: Long): Flow + + fun observeGifts(): Flow> + + suspend fun saveNewGift( + accountIdKey: AccountIdKey, + amount: BigInteger, + creatorMetaId: Long, + fullChainAssetId: FullChainAssetId + ): Long + + suspend fun setGiftState(id: Long, status: Gift.Status) +} + +class RealGiftsRepository( + private val giftsDao: GiftsDao +) : GiftsRepository { + + override suspend fun getGift(giftId: Long): Gift { + return giftsDao.getGiftById(giftId).toDomain() + } + + override suspend fun getGifts(): List { + return giftsDao.getAllGifts().map { it.toDomain() } + } + + override fun observeGift(giftId: Long): Flow { + return giftsDao.observeGiftById(giftId) + .map { it.toDomain() } + } + + override fun observeGifts(): Flow> { + return giftsDao.observeAllGifts() + .mapList { it.toDomain() } + } + + override suspend fun saveNewGift( + accountIdKey: AccountIdKey, + amount: BigInteger, + creatorMetaId: Long, + fullChainAssetId: FullChainAssetId + ): Long { + return giftsDao.createNewGift( + GiftLocal( + amount = amount, + giftAccountId = accountIdKey.value, + creatorMetaId = creatorMetaId, + chainId = fullChainAssetId.chainId, + assetId = fullChainAssetId.assetId, + status = GiftLocal.Status.PENDING, + creationDate = Date().time + ) + ) + } + + override suspend fun setGiftState(id: Long, status: Gift.Status) { + giftsDao.setGiftState(id, status.toDomain()) + } + + private fun GiftLocal.toDomain() = Gift( + id = id, + creatorMetaId = creatorMetaId, + chainId = chainId, + giftAccountId = giftAccountId, + assetId = assetId, + amount = amount, + status = when (status) { + GiftLocal.Status.PENDING -> Gift.Status.PENDING + GiftLocal.Status.CLAIMED -> Gift.Status.CLAIMED + GiftLocal.Status.RECLAIMED -> Gift.Status.RECLAIMED + }, + creationDate = Date(this.creationDate) + ) + + private fun Gift.Status.toDomain(): GiftLocal.Status { + return when (this) { + Gift.Status.PENDING -> GiftLocal.Status.PENDING + Gift.Status.CLAIMED -> GiftLocal.Status.CLAIMED + Gift.Status.RECLAIMED -> GiftLocal.Status.RECLAIMED + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureComponent.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureComponent.kt new file mode 100644 index 0000000..30cb301 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureComponent.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_gift_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_gift_impl.di.modules.deeplinks.DeepLinkModule +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.amount.di.SelectGiftAmountComponent +import io.novafoundation.nova.feature_gift_impl.presentation.claim.di.ClaimGiftComponent +import io.novafoundation.nova.feature_gift_impl.presentation.confirm.di.CreateGiftConfirmComponent +import io.novafoundation.nova.feature_gift_impl.presentation.gifts.di.GiftsComponent +import io.novafoundation.nova.feature_gift_impl.presentation.share.di.ShareGiftComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + GiftFeatureDependencies::class + ], + modules = [ + GiftFeatureModule::class, + DeepLinkModule::class + ] +) +@FeatureScope +interface GiftFeatureComponent : GiftFeatureApi { + + fun giftsComponentFactory(): GiftsComponent.Factory + + fun selectGiftAmountComponentFactory(): SelectGiftAmountComponent.Factory + + fun createGiftConfirmComponentFactory(): CreateGiftConfirmComponent.Factory + + fun shareGiftComponentFactory(): ShareGiftComponent.Factory + + fun claimGiftComponentFactory(): ClaimGiftComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: GiftRouter, + deps: GiftFeatureDependencies + ): GiftFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + AccountFeatureApi::class, + DbApi::class, + WalletFeatureApi::class, + RuntimeApi::class, + DeepLinkingFeatureApi::class + ] + ) + interface GiftFeatureDependenciesComponent : GiftFeatureDependencies +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureDependencies.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureDependencies.kt new file mode 100644 index 0000000..b2e1650 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureDependencies.kt @@ -0,0 +1,134 @@ +package io.novafoundation.nova.feature_gift_impl.di + +import android.content.Context +import coil.ImageLoader +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.IntegrityService +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.core_db.dao.GiftsDao +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.data.repository.CreateSecretsRepository +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.CreateGiftMetaAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletMixin +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +interface GiftFeatureDependencies { + + val amountFormatter: AmountFormatter + + val context: Context + + val preferences: Preferences + + val integrityService: IntegrityService + + val getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory + + val assetSourceRegistry: AssetSourceRegistry + + val validationExecutor: ValidationExecutor + + val maxActionProviderFactory: MaxActionProviderFactory + + val selectedAccountUseCase: SelectedAccountUseCase + + val enoughAmountValidatorFactory: EnoughAmountValidatorFactory + + val minAmountFieldValidatorFactory: MinAmountFieldValidatorFactory + + val amountChooserMixinFactory: AmountChooserMixin.Factory + + val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory + + val assetUseCase: ArbitraryAssetUseCase + + val createSecretsRepository: CreateSecretsRepository + + val sendUseCase: SendUseCase + + val walletUiUseCase: WalletUiUseCase + + val externalAccountActions: ExternalActions.Presentation + + val addressIconGenerator: AddressIconGenerator + + val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + + val encryptionDefaults: EncryptionDefaults + + val encryptedPreferences: EncryptedPreferences + + val linkBuilderFactory: LinkBuilderFactory + + val assetIconProvider: AssetIconProvider + + val tokenFormatter: TokenFormatter + + val fileProvider: FileProvider + + val createGiftMetaAccountUseCase: CreateGiftMetaAccountUseCase + + val automaticInteractionGate: AutomaticInteractionGate + + val dialogMessageManager: DialogMessageManager + + val accountRepository: AccountRepository + + val selectSingleWalletMixin: SelectSingleWalletMixin.Factory + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val accountInteractor: AccountInteractor + + val computationalCache: ComputationalCache + + val feePaymentRegistry: FeePaymentProviderRegistry + + val feePaymentFacade: CustomFeeCapabilityFacade + + fun giftsDao(): GiftsDao + + fun resourceManager(): ResourceManager + + fun appLinksProvider(): AppLinksProvider + + fun chainRegistry(): ChainRegistry + + fun imageLoader(): ImageLoader + + fun secretStoreV2(): SecretStoreV2 + + fun apiCreator(): NetworkApiCreator +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureHolder.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureHolder.kt new file mode 100644 index 0000000..e2a154c --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureHolder.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_gift_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class GiftFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: GiftRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerGiftFeatureComponent_GiftFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java)) + .build() + + return DaggerGiftFeatureComponent.factory() + .create(router, dependencies) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureModule.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureModule.kt new file mode 100644 index 0000000..7728e08 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/GiftFeatureModule.kt @@ -0,0 +1,180 @@ +package io.novafoundation.nova.feature_gift_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.GiftsDao +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.data.repository.CreateSecretsRepository +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.CreateGiftMetaAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_gift_impl.data.GiftSecretsRepository +import io.novafoundation.nova.feature_gift_impl.data.GiftsRepository +import io.novafoundation.nova.feature_gift_impl.data.RealGiftSecretsRepository +import io.novafoundation.nova.feature_gift_impl.data.RealGiftsRepository +import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.GiftsInteractor +import io.novafoundation.nova.feature_gift_impl.domain.RealGiftsInteractor +import io.novafoundation.nova.feature_gift_impl.domain.RealCreateGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.GiftSecretsUseCase +import io.novafoundation.nova.feature_gift_impl.domain.RealGiftsAccountSupportedUseCase +import io.novafoundation.nova.feature_gift_impl.domain.RealAvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_gift_impl.domain.RealClaimGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.RealShareGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.ShareGiftInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.amount.GiftMinAmountProviderFactory +import io.novafoundation.nova.feature_gift_impl.presentation.common.PackingGiftAnimationFactory +import io.novafoundation.nova.feature_gift_impl.presentation.common.UnpackingGiftAnimationFactory +import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module() +class GiftFeatureModule { + + @Provides + @FeatureScope + fun providesGiftsRepository( + giftsDao: GiftsDao + ): GiftsRepository { + return RealGiftsRepository(giftsDao) + } + + @Provides + @FeatureScope + fun providesGiftsInteractor( + repository: GiftsRepository, + assetSourceRegistry: AssetSourceRegistry, + chainRegistry: ChainRegistry, + selectedAccountUseCase: SelectedAccountUseCase + ): GiftsInteractor { + return RealGiftsInteractor(repository, assetSourceRegistry, chainRegistry, selectedAccountUseCase) + } + + @Provides + @FeatureScope + fun providesGiftSecretsRepository(encryptedPreferences: EncryptedPreferences): GiftSecretsRepository { + return RealGiftSecretsRepository(encryptedPreferences) + } + + @Provides + @FeatureScope + fun provideSelectGiftAmountInteractor( + assetSourceRegistry: AssetSourceRegistry, + chainRegistry: ChainRegistry, + giftSecretsRepository: GiftSecretsRepository, + giftsRepository: GiftsRepository, + sendUseCase: SendUseCase, + giftSecretsUseCase: GiftSecretsUseCase + ): CreateGiftInteractor { + return RealCreateGiftInteractor( + assetSourceRegistry, + chainRegistry, + giftSecretsRepository, + giftsRepository, + sendUseCase, + giftSecretsUseCase + ) + } + + @Provides + @FeatureScope + fun provideGiftMinAmountProviderFactory( + createGiftInteractor: CreateGiftInteractor + ): GiftMinAmountProviderFactory { + return GiftMinAmountProviderFactory(createGiftInteractor) + } + + @Provides + @FeatureScope + fun providesShareGiftInteractor( + giftsRepository: GiftsRepository, + giftSecretsRepository: GiftSecretsRepository + ): ShareGiftInteractor { + return RealShareGiftInteractor(giftsRepository, giftSecretsRepository) + } + + @Provides + @FeatureScope + fun providesPackingGiftAnimationFactory(): PackingGiftAnimationFactory { + return PackingGiftAnimationFactory() + } + + @Provides + @FeatureScope + fun providesUnpackingGiftAnimationFactory(): UnpackingGiftAnimationFactory { + return UnpackingGiftAnimationFactory() + } + + @Provides + @FeatureScope + fun provideClaimGiftInteractor( + giftSecretsUseCase: GiftSecretsUseCase, + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + sendUseCase: SendUseCase, + createGiftMetaAccountUseCase: CreateGiftMetaAccountUseCase, + secretStoreV2: SecretStoreV2, + accountRepository: AccountRepository + ): ClaimGiftInteractor { + return RealClaimGiftInteractor( + giftSecretsUseCase, + chainRegistry, + assetSourceRegistry, + sendUseCase, + createGiftMetaAccountUseCase, + secretStoreV2, + accountRepository + ) + } + + @Provides + @FeatureScope + fun provideGiftSecretsUseCase( + createSecretsRepository: CreateSecretsRepository, + encryptionDefaults: EncryptionDefaults + ): GiftSecretsUseCase { + return GiftSecretsUseCase( + createSecretsRepository, + encryptionDefaults + ) + } + + @Provides + @FeatureScope + fun provideClaimGiftMixinFactory(claimGiftInteractor: ClaimGiftInteractor) = ClaimGiftMixinFactory(claimGiftInteractor) + + @Provides + @FeatureScope + fun provideAvailableGiftAssetsUseCase( + chainRegistry: ChainRegistry, + computationalCache: ComputationalCache, + feePaymentRegistry: FeePaymentProviderRegistry, + feePaymentFacade: CustomFeeCapabilityFacade, + assetSourceRegistry: AssetSourceRegistry, + ): AvailableGiftAssetsUseCase { + return RealAvailableGiftAssetsUseCase( + chainRegistry, + computationalCache, + feePaymentRegistry, + feePaymentFacade, + assetSourceRegistry + ) + } + + @Provides + @FeatureScope + fun provideAreGiftsSupportedUseCase(selectedAccountUseCase: SelectedAccountUseCase): GiftsAccountSupportedUseCase { + return RealGiftsAccountSupportedUseCase(selectedAccountUseCase) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/modules/deeplinks/DeepLinkModule.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/modules/deeplinks/DeepLinkModule.kt new file mode 100644 index 0000000..5936752 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/di/modules/deeplinks/DeepLinkModule.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_gift_impl.di.modules.deeplinks + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_gift_api.di.GiftDeepLinks +import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkConfigurator +import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkHandler +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideDeepLinkConfigurator( + linkBuilderFactory: LinkBuilderFactory + ): ClaimGiftDeepLinkConfigurator { + return ClaimGiftDeepLinkConfigurator(linkBuilderFactory) + } + + @Provides + @FeatureScope + fun provideClaimGiftDeepLinkHandler( + router: GiftRouter, + chainRegistry: ChainRegistry, + automaticInteractionGate: AutomaticInteractionGate, + dialogMessageManager: DialogMessageManager, + claimGiftDeepLinkConfigurator: ClaimGiftDeepLinkConfigurator, + claimGiftInteractor: ClaimGiftInteractor + ): ClaimGiftDeepLinkHandler { + return ClaimGiftDeepLinkHandler( + router, + chainRegistry, + automaticInteractionGate, + dialogMessageManager, + claimGiftDeepLinkConfigurator, + claimGiftInteractor + ) + } + + @Provides + @FeatureScope + fun provideDeepLinks( + claimGiftDeepLinkInteractor: ClaimGiftDeepLinkHandler + ): GiftDeepLinks { + return GiftDeepLinks(listOf(claimGiftDeepLinkInteractor)) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/ClaimGiftInteractor.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/ClaimGiftInteractor.kt new file mode 100644 index 0000000..64ba3d6 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/ClaimGiftInteractor.kt @@ -0,0 +1,193 @@ +package io.novafoundation.nova.feature_gift_impl.domain + +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.keypair +import io.novafoundation.nova.common.data.secrets.v2.publicKey +import io.novafoundation.nova.common.utils.coerceToUnit +import io.novafoundation.nova.common.utils.finally +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.model.decimalAmount +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.CreateGiftMetaAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.isControllableWallet +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_gift_impl.domain.models.ClaimableGift +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftAmountWithFee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.BaseAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.asWeighted +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.emptyAccountId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import java.math.BigDecimal +import java.math.BigInteger + +interface ClaimGiftInteractor { + + suspend fun getClaimableGift(secret: ByteArray, chainId: String, assetId: Int): ClaimableGift + + suspend fun getGiftAmountWithFee( + claimableGift: ClaimableGift, + giftMetaAccount: MetaAccount, + coroutineScope: CoroutineScope + ): GiftAmountWithFee + + suspend fun createTempMetaAccount(claimableGift: ClaimableGift): MetaAccount + + suspend fun isGiftAlreadyClaimed(claimableGift: ClaimableGift): Boolean + + suspend fun claimGift( + claimableGift: ClaimableGift, + giftAmountWithFee: GiftAmountWithFee, + giftMetaAccount: MetaAccount, + giftRecipient: MetaAccount, + coroutineScope: CoroutineScope + ): Result + + suspend fun getMetaAccount(metaId: Long): MetaAccount + + suspend fun getMetaAccountToClaimGift(): MetaAccount +} + +class RealClaimGiftInteractor( + private val giftSecretsUseCase: GiftSecretsUseCase, + private val chainRegistry: ChainRegistry, + private val assetSourceRegistry: AssetSourceRegistry, + private val sendUseCase: SendUseCase, + private val createGiftMetaAccountUseCase: CreateGiftMetaAccountUseCase, + private val secretStoreV2: SecretStoreV2, + private val accountRepository: AccountRepository +) : ClaimGiftInteractor { + + override suspend fun getClaimableGift(secret: ByteArray, chainId: String, assetId: Int): ClaimableGift { + val chain = chainRegistry.getChain(chainId) + val giftSecrets = giftSecretsUseCase.createGiftSecrets(chain, secret) + return ClaimableGift( + accountId = chain.accountIdOf(giftSecrets.keypair.publicKey), + chain = chain, + chainAsset = chain.assetsById.getValue(assetId), + secrets = giftSecrets + ) + } + + override suspend fun createTempMetaAccount(claimableGift: ClaimableGift): MetaAccount { + return createGiftMetaAccountUseCase.createTemporaryGiftMetaAccount(claimableGift.chain, claimableGift.secrets) + } + + override suspend fun isGiftAlreadyClaimed(claimableGift: ClaimableGift): Boolean { + val giftBalance = getGiftAccountBalance(claimableGift) + return giftBalance.isZero + } + + override suspend fun getGiftAmountWithFee( + claimableGift: ClaimableGift, + giftMetaAccount: MetaAccount, + coroutineScope: CoroutineScope + ): GiftAmountWithFee { + val accountBalance = claimableGift.chainAsset.amountFromPlanks(getGiftAccountBalance(claimableGift)) + val transferModel = createTransfer( + claimableGift, + giftMetaAccount, + recipientAccountId = claimableGift.chain.emptyAccountId(), + accountBalance + ) + val claimFee = assetSourceRegistry.sourceFor(claimableGift.chainAsset) + .transfers + .calculateFee(transferModel, coroutineScope) + + return GiftAmountWithFee(accountBalance - claimFee.decimalAmount, claimFee) + } + + override suspend fun claimGift( + claimableGift: ClaimableGift, + giftAmountWithFee: GiftAmountWithFee, + giftMetaAccount: MetaAccount, + giftRecipient: MetaAccount, + coroutineScope: CoroutineScope + ): Result { + // Put secrets for temporary meta account in storage but for this operation only since signer logic requires secrets in secret storage + secretStoreV2.putChainAccountSecrets(giftMetaAccount.id, claimableGift.accountId, claimableGift.secrets) + + return claimGiftInternal( + giftModel = claimableGift, + giftMetaAccount = giftMetaAccount, + giftRecipient = giftRecipient, + giftAmountWithFee = giftAmountWithFee, + coroutineScope = coroutineScope + ) + .finally { + // Remove secrets for temporary meta account from storage after claim or failure + secretStoreV2.clearChainAccountsSecrets(giftMetaAccount.id, listOf(claimableGift.accountId)) + } + } + + override suspend fun getMetaAccount(metaId: Long): MetaAccount { + return accountRepository.getMetaAccount(metaId) + } + + override suspend fun getMetaAccountToClaimGift(): MetaAccount { + val selectedMetaAccount = accountRepository.getSelectedMetaAccount() + if (selectedMetaAccount.type.isControllableWallet()) return selectedMetaAccount + + val firstControllableWallet = accountRepository.getActiveMetaAccounts() + .firstOrNull { it.type.isControllableWallet() } + + return firstControllableWallet ?: selectedMetaAccount + } + + private suspend fun claimGiftInternal( + giftModel: ClaimableGift, + giftMetaAccount: MetaAccount, + giftRecipient: MetaAccount, + giftAmountWithFee: GiftAmountWithFee, + coroutineScope: CoroutineScope + ): Result { + val originFee = OriginFee(submissionFee = giftAmountWithFee.fee, deliveryFee = null) + val giftTransfer = createTransfer( + giftModel, + giftMetaAccount, + recipientAccountId = giftRecipient.requireAccountIdIn(giftModel.chain), + amount = giftAmountWithFee.amount + ).asWeighted(originFee) + + return sendUseCase.performOnChainTransferAndAwaitExecution(giftTransfer, originFee.submissionFee, coroutineScope) + .coerceToUnit() + } + + private suspend fun getGiftAccountBalance(claimableGift: ClaimableGift): BigInteger { + val assetBalanceSource = assetSourceRegistry.sourceFor(claimableGift.chainAsset).balance + + return assetBalanceSource.queryAccountBalance( + claimableGift.chain, + claimableGift.chainAsset, + claimableGift.accountId + ).total + } + + private fun createTransfer( + giftModel: ClaimableGift, + giftMetaAccount: MetaAccount, + recipientAccountId: AccountId, + amount: BigDecimal + ): BaseAssetTransfer { + return BaseAssetTransfer( + sender = giftMetaAccount, + recipient = giftModel.chain.addressOf(recipientAccountId), + originChain = giftModel.chain, + originChainAsset = giftModel.chainAsset, + destinationChain = giftModel.chain, + destinationChainAsset = giftModel.chainAsset, + feePaymentCurrency = giftModel.chainAsset.toFeePaymentCurrency(), + amount = amount, + transferringMaxAmount = true + ) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/CreateGiftInteractor.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/CreateGiftInteractor.kt new file mode 100644 index 0000000..da0d363 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/CreateGiftInteractor.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.feature_gift_impl.domain + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.secrets.v2.keypair +import io.novafoundation.nova.common.data.secrets.v2.publicKey +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.model.EvmFee +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_gift_impl.data.GiftSecretsRepository +import io.novafoundation.nova.feature_gift_impl.data.GiftsRepository +import io.novafoundation.nova.feature_gift_impl.domain.models.CreateGiftModel +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDeposit +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystem +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.BaseAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.emptyAccountIdKey +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigDecimal + +typealias GiftId = Long + +interface CreateGiftInteractor { + fun validationSystemFor(chainAsset: Chain.Asset, coroutineScope: CoroutineScope): AssetTransfersValidationSystem + + suspend fun getFee( + model: CreateGiftModel, + transferAllToCreateGift: Boolean, + coroutineScope: CoroutineScope + ): GiftFee + + suspend fun getExistentialDeposit(chainAsset: Chain.Asset): BigDecimal + + suspend fun createAndSaveGift( + giftModel: CreateGiftModel, + transfer: WeightedAssetTransfer, + fee: SubmissionFee, + coroutineScope: CoroutineScope + ): Result +} + +class RealCreateGiftInteractor( + private val assetSourceRegistry: AssetSourceRegistry, + private val chainRegistry: ChainRegistry, + private val giftSecretsRepository: GiftSecretsRepository, + private val giftsRepository: GiftsRepository, + private val sendUseCase: SendUseCase, + private val giftSecretsUseCase: GiftSecretsUseCase +) : CreateGiftInteractor { + + override fun validationSystemFor(chainAsset: Chain.Asset, coroutineScope: CoroutineScope): AssetTransfersValidationSystem { + return getAssetTransfers(chainAsset) + .getValidationSystem(coroutineScope) + } + + override suspend fun getFee( + model: CreateGiftModel, + transferAllToCreateGift: Boolean, + coroutineScope: CoroutineScope + ): GiftFee = withContext(Dispatchers.Default) { + val claimGiftFee = getSubmissionFee( + model = model, + transferMax = true, + giftAccountId = model.chain.emptyAccountIdKey(), + coroutineScope = coroutineScope + ).doubleFeeForEvm() + val claimFeeAmount = model.chainAsset.amountFromPlanks(claimGiftFee.amount) + + val createGiftFee = getSubmissionFee( + model = model.copy(amount = model.amount + claimFeeAmount), + transferMax = transferAllToCreateGift, + giftAccountId = model.chain.emptyAccountIdKey(), + coroutineScope = coroutineScope + ) + GiftFee( + createGiftFee = createGiftFee, + claimGiftFee = claimGiftFee + ) + } + + override suspend fun getExistentialDeposit(chainAsset: Chain.Asset): BigDecimal { + return assetSourceRegistry.existentialDeposit(chainAsset) + } + + override suspend fun createAndSaveGift( + giftModel: CreateGiftModel, + transfer: WeightedAssetTransfer, + fee: SubmissionFee, + coroutineScope: CoroutineScope + ): Result { + val giftAccountId = createAndStoreRandomGiftAccount(giftModel.chain.id) + val gitAddress = giftModel.chain.addressOf(giftAccountId) + val giftTransfer = transfer.copy(recipient = gitAddress) + return sendUseCase.performOnChainTransferAndAwaitExecution(giftTransfer, fee, coroutineScope) + .map { + Log.d(LOG_TAG, "Gift was created successfully. Address in ${giftModel.chain.name}: $gitAddress") + + giftsRepository.saveNewGift( + accountIdKey = giftAccountId, + amount = giftModel.chainAsset.planksFromAmount(giftModel.amount), + creatorMetaId = giftModel.senderMetaAccount.id, + fullChainAssetId = giftModel.chainAsset.fullId + ) + } + } + + private suspend fun getSubmissionFee( + model: CreateGiftModel, + transferMax: Boolean, + giftAccountId: AccountIdKey, + coroutineScope: CoroutineScope + ): SubmissionFee { + return withContext(Dispatchers.Default) { + val transfer = model.mapToAssetTransfer(giftAccountId, transferMax) + getAssetTransfers(model.chainAsset).calculateFee(transfer, coroutineScope = coroutineScope) + } + } + + private fun getAssetTransfers(chainAsset: Chain.Asset) = assetSourceRegistry.sourceFor(chainAsset).transfers + + private fun CreateGiftModel.mapToAssetTransfer(giftAccountId: AccountIdKey, transferMax: Boolean) = BaseAssetTransfer( + sender = senderMetaAccount, + recipient = chain.addressOf(giftAccountId), + originChain = chain, + originChainAsset = chainAsset, + destinationChain = chain, + destinationChainAsset = chainAsset, + feePaymentCurrency = chainAsset.toFeePaymentCurrency(), + amount = amount, + transferringMaxAmount = transferMax + ) + + private suspend fun createAndStoreRandomGiftAccount(chainId: String): AccountIdKey { + val chain = chainRegistry.getChain(chainId) + val giftSeed = giftSecretsUseCase.createRandomGiftSeed() + val giftSecrets = giftSecretsUseCase.createGiftSecrets(chain, giftSeed) + + val accountId = chain.accountIdOf(giftSecrets.keypair.publicKey) + giftSecretsRepository.putGiftAccountSeed(accountId, giftSeed) + + return accountId.intoKey() + } + + private fun SubmissionFee.doubleFeeForEvm(): SubmissionFee { + return when (this) { + is EvmFee -> copy(gasLimit = gasLimit * 2.toBigInteger()) + else -> this + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/GiftSecretsUseCase.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/GiftSecretsUseCase.kt new file mode 100644 index 0000000..853e79f --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/GiftSecretsUseCase.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_gift_impl.domain + +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.feature_account_api.data.repository.CreateSecretsRepository +import io.novafoundation.nova.feature_account_api.domain.account.common.EncryptionDefaults +import io.novafoundation.nova.feature_account_api.domain.account.common.forChain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.encrypt.seed.SeedCreator +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import org.bouncycastle.crypto.generators.SCrypt + +private const val GIFT_SEED_SIZE_BYTES = 10 + +class GiftSecretsUseCase( + private val createSecretsRepository: CreateSecretsRepository, + private val encryptionDefaults: EncryptionDefaults +) { + + companion object { + private const val GIFT_SALT = "gift" + private const val SCRYPT_KEY_SIZE = 32 + private const val N = 16384 + private const val p = 1 + private const val r = 8 + } + + suspend fun createGiftSecrets(chain: Chain, seed: ByteArray): EncodableStruct { + val encryption = encryptionDefaults.forChain(chain) + + return createSecretsRepository.createSecretsWithSeed( + seed = getGiftSeedHash(seed), + cryptoType = encryption.cryptoType, + derivationPath = encryption.derivationPath, + isEthereum = chain.isEthereumBased + ) + } + + fun createRandomGiftSeed(): ByteArray { + return SeedCreator.randomSeed(sizeBytes = GIFT_SEED_SIZE_BYTES) + } + + private fun getGiftSeedHash(seed: ByteArray): ByteArray { + val saltBytes = GIFT_SALT.toByteArray() + return SCrypt.generate(seed, saltBytes, N, r, p, SCRYPT_KEY_SIZE) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/GiftsInteractor.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/GiftsInteractor.kt new file mode 100644 index 0000000..801276b --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/GiftsInteractor.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_gift_impl.domain + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.onEachAsync +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_gift_impl.data.GiftsRepository +import io.novafoundation.nova.feature_gift_impl.domain.models.Gift +import io.novafoundation.nova.feature_gift_impl.domain.models.isClaimed +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.collections.sortedBy + +interface GiftsInteractor { + fun observeGifts(fullChainAssetId: FullChainAssetId?): Flow> + + suspend fun syncGiftsState() +} + +class RealGiftsInteractor( + private val giftsRepository: GiftsRepository, + private val assetSourceRegistry: AssetSourceRegistry, + private val chainRegistry: ChainRegistry, + private val selectedAccountUseCase: SelectedAccountUseCase +) : GiftsInteractor { + + override fun observeGifts(fullChainAssetId: FullChainAssetId?): Flow> = flowOfAll { + val selectedMetaAccount = selectedAccountUseCase.getSelectedMetaAccount() + + giftsRepository.observeGifts() + .map { gifts -> + gifts.filter { it.creatorMetaId == selectedMetaAccount.id } + .filter { it.filterByAsset(fullChainAssetId) } + .sortedByDescending { it.creationDate.time } + .sortedBy { it.status.isClaimed() } + } + } + + private fun Gift.filterByAsset(fullChainAssetId: FullChainAssetId?): Boolean { + if (fullChainAssetId == null) return true + + return chainId == fullChainAssetId.chainId && assetId == fullChainAssetId.assetId + } + + override suspend fun syncGiftsState() { + giftsRepository.getGifts() + .filter { it.status == Gift.Status.PENDING } + .onEachAsync { + val (chain, chainAsset) = chainRegistry.chainWithAsset(it.chainId, it.assetId) + val balanceSource = assetSourceRegistry.sourceFor(chainAsset).balance + val giftBalance = balanceSource.queryAccountBalance(chain, chainAsset, it.giftAccountId) + + if (giftBalance.total.isZero) { + giftsRepository.setGiftState(it.id, Gift.Status.CLAIMED) + } + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/RealAvailableGiftAssetsUseCase.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/RealAvailableGiftAssetsUseCase.kt new file mode 100644 index 0000000..2705b49 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/RealAvailableGiftAssetsUseCase.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_gift_impl.domain + +import android.util.Log +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.mapNotNullToSet +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.data.fee.fastLookupCustomFeeCapabilityOrDefault +import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.isSelfSufficientAsset +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.getAssetOrThrow +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.withContext + +class RealAvailableGiftAssetsUseCase( + private val chainRegistry: ChainRegistry, + private val computationalCache: ComputationalCache, + private val feePaymentRegistry: FeePaymentProviderRegistry, + private val feePaymentFacade: CustomFeeCapabilityFacade, + private val assetSourceRegistry: AssetSourceRegistry, +) : AvailableGiftAssetsUseCase { + + companion object { + + private const val GIFT_ASSETS_CACHE = "AssetSearchUseCase.GIFT_ASSETS_CACHE" + } + + override suspend fun isGiftsAvailable(chainAsset: Chain.Asset): Boolean { + return withContext(Dispatchers.Default) { + val canPayFee = feePaymentFacade.canPayFeeInCurrency(chainAsset.toFeePaymentCurrency()) + val isSelfSufficient = assetSourceRegistry.isSelfSufficientAsset(chainAsset) + + canPayFee && isSelfSufficient + } + } + + override fun getAvailableGiftAssets(coroutineScope: CoroutineScope): Flow> { + return computationalCache.useSharedFlow(GIFT_ASSETS_CACHE, coroutineScope) { + flow { + // Fast first emission - show all native assets + emit(chainRegistry.allNativeAssetIds()) + + if (feePaymentFacade.hasGlobalFeePaymentRestrictions()) return@flow + + // Then do the full scan - via slower fee capability check + emitPerChainAvailableAssets() + } + .runningFold(emptySet()) { acc, newAssets -> if (newAssets.isEmpty()) acc else acc + newAssets } + .distinctUntilChangedBy { it.size } // we are only adding so deduplication by size is enough + .onEach { Log.d("AssetSearchUseCase", "# of assets available for gifts: ${it.size}") } + } + } + + private suspend fun ChainRegistry.allNativeAssetIds(): Set { + return currentChains.first().mapToSet { it.utilityAsset.fullId } + } + + context(FlowCollector>) + private suspend fun emitPerChainAvailableAssets() { + val chains = chainRegistry.currentChains.first() + chains.map { chain -> flowOf { collectAllAssetsAllowedForGiftsInChain(chain) } } + .merge() + .collect { emit(it) } + } + + private suspend fun collectAllAssetsAllowedForGiftsInChain(chain: Chain): Set { + val canBeUsedForFeePayment = feePaymentRegistry + .providerFor(chain.id) + .fastLookupCustomFeeCapabilityOrDefault() + .nonUtilityFeeCapableTokens + + return canBeUsedForFeePayment.mapNotNullToSet { + val asset = chain.getAssetOrThrow(it) + val isSufficient = assetSourceRegistry.isSelfSufficientAsset(asset) + if (isSufficient) { + asset.fullId + } else { + null + } + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/RealGiftsAccountSupportedUseCase.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/RealGiftsAccountSupportedUseCase.kt new file mode 100644 index 0000000..7b1ea3f --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/RealGiftsAccountSupportedUseCase.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_gift_impl.domain + +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.asMultisig +import io.novafoundation.nova.feature_account_api.domain.model.isThreshold1 +import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase +import io.novafoundation.nova.feature_gift_api.domain.GiftsSupportedState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RealGiftsAccountSupportedUseCase( + private val selectedAccountUseCase: SelectedAccountUseCase, +) : GiftsAccountSupportedUseCase { + + override suspend fun supportedState(): GiftsSupportedState { + val selectedAccount = selectedAccountUseCase.getSelectedMetaAccount() + return selectedAccount.supportsGifts() + } + + override fun areGiftsSupportedFlow(): Flow { + return selectedAccountUseCase.selectedMetaAccountFlow() + .map { it.supportsGifts() } + } + + private fun MetaAccount.supportsGifts(): GiftsSupportedState { + return when (type) { + LightMetaAccount.Type.SECRETS, + LightMetaAccount.Type.WATCH_ONLY, + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.LEDGER_LEGACY, + LightMetaAccount.Type.LEDGER, + LightMetaAccount.Type.PROXIED, + LightMetaAccount.Type.POLKADOT_VAULT -> GiftsSupportedState.SUPPORTED + + LightMetaAccount.Type.MULTISIG -> if (asMultisig().isThreshold1()) { + GiftsSupportedState.SUPPORTED + } else { + GiftsSupportedState.UNSUPPORTED_MULTISIG_ACCOUNTS + } + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/ShareGiftInteractor.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/ShareGiftInteractor.kt new file mode 100644 index 0000000..5e0c13e --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/ShareGiftInteractor.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_gift_impl.domain + +import io.novafoundation.nova.feature_gift_impl.data.GiftSecretsRepository +import io.novafoundation.nova.feature_gift_impl.data.GiftsRepository +import io.novafoundation.nova.feature_gift_impl.domain.models.Gift +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.flow.Flow + +interface ShareGiftInteractor { + + fun observeGift(giftId: Long): Flow + + suspend fun getGiftSeed(giftId: Long): String + + suspend fun setGiftStateAsReclaimed(id: Long) +} + +class RealShareGiftInteractor( + private val giftsRepository: GiftsRepository, + private val giftSecretsRepository: GiftSecretsRepository, +) : ShareGiftInteractor { + + override fun observeGift(giftId: Long): Flow { + return giftsRepository.observeGift(giftId) + } + + override suspend fun getGiftSeed(giftId: Long): String { + val gift = giftsRepository.getGift(giftId) + val seed = giftSecretsRepository.getGiftAccountSeed(gift.giftAccountId) ?: error("No secrets for gift found") + return seed.toHexString() + } + + override suspend fun setGiftStateAsReclaimed(id: Long) { + giftsRepository.setGiftState(id, Gift.Status.RECLAIMED) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/ClaimableGift.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/ClaimableGift.kt new file mode 100644 index 0000000..5b3d5c8 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/ClaimableGift.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_gift_impl.domain.models + +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +class ClaimableGift( + val accountId: AccountId, + val chain: Chain, + val chainAsset: Chain.Asset, + val secrets: EncodableStruct +) diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/CreateGiftModel.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/CreateGiftModel.kt new file mode 100644 index 0000000..695b4c3 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/CreateGiftModel.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_gift_impl.domain.models + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +data class CreateGiftModel( + val senderMetaAccount: MetaAccount, + val chain: Chain, + val chainAsset: Chain.Asset, + val amount: BigDecimal +) diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/Gift.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/Gift.kt new file mode 100644 index 0000000..c2fcbe9 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/Gift.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_gift_impl.domain.models + +import io.novafoundation.nova.feature_gift_impl.domain.models.Gift.Status.PENDING +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger +import java.util.Date + +class Gift( + val id: Long, + val amount: BigInteger, + val creatorMetaId: Long, + val chainId: ChainId, + val assetId: ChainAssetId, + val status: Status, + val giftAccountId: ByteArray, + val creationDate: Date +) { + enum class Status { + PENDING, + CLAIMED, + RECLAIMED + } +} + +fun Gift.Status.isClaimed() = this != PENDING diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/GiftAmountWithFee.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/GiftAmountWithFee.kt new file mode 100644 index 0000000..15a43bd --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/GiftAmountWithFee.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_gift_impl.domain.models + +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import java.math.BigDecimal + +class GiftAmountWithFee( + val amount: BigDecimal, + val fee: SubmissionFee +) diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/GiftFee.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/GiftFee.kt new file mode 100644 index 0000000..2f1cdfb --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/domain/models/GiftFee.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_gift_impl.domain.models + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.data.model.getAmount +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +data class GiftFee( + val createGiftFee: SubmissionFee, + val claimGiftFee: SubmissionFee +) : FeeBase, MaxAvailableDeduction { + + fun replaceSubmission(newSubmissionFee: SubmissionFee): GiftFee { + return copy(createGiftFee = newSubmissionFee) + } + + override fun maxAmountDeductionFor(amountAsset: Chain.Asset): BigInteger { + val createFeeAmount = createGiftFee.getAmount(amountAsset).orZero() + val claimFeeAmount = claimGiftFee.getAmount(amountAsset).orZero() + + return createFeeAmount + claimFeeAmount + } + + override val amount: BigInteger = createGiftFee.amount + claimGiftFee.amount + + override val asset: Chain.Asset = createGiftFee.asset +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/GiftRouter.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/GiftRouter.kt new file mode 100644 index 0000000..b242c54 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/GiftRouter.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_gift_impl.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_gift_impl.domain.GiftId +import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftPayload +import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmPayload +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload + +interface GiftRouter : ReturnableRouter { + + fun finishCreateGift() + + fun openGiftsFlow() + + fun openSelectGiftAmount(assetPayload: AssetPayload) + + fun openConfirmCreateGift(payload: CreateGiftConfirmPayload) + + fun openGiftSharing(giftId: GiftId, isSecondOpen: Boolean = false) + + fun openMainScreen() + + fun openClaimGift(claimGiftPayload: ClaimGiftPayload) + + fun openManageWallets() +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/GiftMinAmountProvider.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/GiftMinAmountProvider.kt new file mode 100644 index 0000000..3523740 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/GiftMinAmountProvider.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount + +import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor +import io.novafoundation.nova.feature_wallet_api.presentation.common.MinAmountProvider +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GiftMinAmountProviderFactory( + private val createGiftInteractor: CreateGiftInteractor, +) { + fun create( + chainAssetFlow: Flow + ): MinAmountProvider { + return GiftMinAmountProvider( + createGiftInteractor, + chainAssetFlow + ) + } +} + +class GiftMinAmountProvider( + private val createGiftInteractor: CreateGiftInteractor, + private val chainAssetFlow: Flow +) : MinAmountProvider { + + override fun provideMinAmount(): Flow { + return chainAssetFlow + .map { createGiftInteractor.getExistentialDeposit(it) } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/SelectGiftAmountFragment.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/SelectGiftAmountFragment.kt new file mode 100644 index 0000000..b06606c --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/SelectGiftAmountFragment.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount + +import android.view.View +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.hideKeyboard +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_gift_impl.databinding.FragmentSelectGiftAmountBinding +import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.bindGetAsset + +class SelectGiftAmountFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentSelectGiftAmountBinding.inflate(layoutInflater) + + override fun applyInsets(rootView: View) { + binder.root.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.giftAmountToolbar.setHomeButtonListener { + hideKeyboard() + viewModel.back() + } + + binder.giftAmountContinue.prepareForProgress(this) + binder.giftAmountContinue.setOnClickListener { viewModel.nextClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(this, GiftFeatureApi::class.java) + .selectGiftAmountComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: SelectGiftAmountViewModel) { + observeValidations(viewModel) + setupAmountChooser(viewModel.amountChooserMixin, binder.giftsAmount) + + viewModel.feeMixin.setupFeeLoading(binder.giftAmountFee) + + viewModel.getAssetOptionsMixin.bindGetAsset(binder.giftAmountGetTokens) + + viewModel.chainModelFlow.observe { binder.giftAmountChain.setModel(it) } + + viewModel.continueButtonStateFlow.observe(binder.giftAmountContinue::setState) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/SelectGiftAmountPayload.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/SelectGiftAmountPayload.kt new file mode 100644 index 0000000..c7a3ff3 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/SelectGiftAmountPayload.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SelectGiftAmountPayload(val assetPayload: AssetPayload) : Parcelable diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/SelectGiftAmountViewModel.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/SelectGiftAmountViewModel.kt new file mode 100644 index 0000000..6949187 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/SelectGiftAmountViewModel.kt @@ -0,0 +1,227 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.isPositive +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.CompoundFieldValidator +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.isErrorWithTag +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.view.ChainChipModel +import io.novafoundation.nova.feature_gift_impl.R +import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.models.CreateGiftModel +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.amount.fee.createForGiftsWithDefaultDisplay +import io.novafoundation.nova.feature_gift_impl.presentation.common.buildGiftValidationPayload +import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountFieldValidator +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.isMaxAction +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.DefaultFeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.create +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.autoFixSendValidationPayload +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.mapAssetTransferValidationFailureToUI +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chainFlow +import java.math.BigDecimal +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +class SelectGiftAmountViewModel( + private val router: GiftRouter, + private val chainRegistry: ChainRegistry, + private val validationExecutor: ValidationExecutor, + private val assetUseCase: ArbitraryAssetUseCase, + private val payload: SelectGiftAmountPayload, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val amountFormatter: AmountFormatter, + private val resourceManager: ResourceManager, + private val getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory, + private val createGiftInteractor: CreateGiftInteractor, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val enoughAmountValidatorFactory: EnoughAmountValidatorFactory, + private val minAmountFieldValidatorFactory: MinAmountFieldValidatorFactory, + private val giftMinAmountProviderFactory: GiftMinAmountProviderFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, +) : BaseViewModel(), + Validatable by validationExecutor { + + private val chainFlow = chainRegistry.chainFlow(payload.assetPayload.chainId) + private val chainAssetFlow = chainFlow.map { it.assetsById.getValue(payload.assetPayload.chainAssetId) } + + private val metaAccountFlow = selectedAccountUseCase.selectedMetaAccountFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + private val assetFlow = chainAssetFlow.flatMapLatest(assetUseCase::assetFlow) + .shareInBackground() + + val chainModelFlow = chainFlow.map { + ChainChipModel( + chainUi = mapChainToUi(it), + changeable = false + ) + }.shareInBackground() + + private val feeFormatter = DefaultFeeFormatter(amountFormatter) + val feeMixin = feeLoaderMixinFactory.createForGiftsWithDefaultDisplay( + chainAssetFlow, + feeFormatter + ) + + private val maxActionProvider = maxActionProviderFactory.create( + viewModelScope = viewModelScope, + assetInFlow = assetFlow, + feeLoaderMixin = feeMixin + ) + + val amountChooserMixin: AmountChooserMixin.Presentation = amountChooserMixinFactory.create( + scope = this, + assetFlow = assetFlow, + maxActionProvider = maxActionProvider, + fieldValidator = getAmountValidator() + ) + + private val notEnoughAmountErrorFlow = combine(assetFlow, amountChooserMixin.fieldError) { asset, fieldError -> + asset.transferable.isZero || fieldError.isErrorWithTag(EnoughAmountFieldValidator.ERROR_TAG) + } + val getAssetOptionsMixin = getAssetOptionsMixinFactory.create( + assetFlow = chainAssetFlow, + additionalButtonFilter = notEnoughAmountErrorFlow, + scope = viewModelScope, + ) + + private val validationInProgressFlow = MutableStateFlow(false) + + val continueButtonStateFlow = combine( + validationInProgressFlow, + amountChooserMixin.fieldError, + amountChooserMixin.amountState + ) { validating, fieldError, amountState -> + when { + validating -> DescriptiveButtonState.Loading + fieldError is FieldValidationResult.Error -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_other_amount)) + amountState.value.orZero().isPositive -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + else -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.gift_enter_amount_disabled_button_state)) + } + }.onStart { emit(DescriptiveButtonState.Disabled(resourceManager.getString(R.string.gift_enter_amount_disabled_button_state))) } + + init { + setupFees() + } + + fun back() { + router.back() + } + + fun nextClicked() = launchUnit { + validationInProgressFlow.value = true + val fee = feeMixin.awaitFee() + val amountState = amountChooserMixin.amountState.first() + val giftAmount = amountState.value ?: return@launchUnit + val chain = chainFlow.first() + + val giftModel = CreateGiftModel( + senderMetaAccount = selectedAccountUseCase.getSelectedMetaAccount(), + chain = chain, + chainAsset = chainAssetFlow.first(), + amount = giftAmount, + ) + + val payload = buildGiftValidationPayload( + giftModel, + asset = assetFlow.first(), + amountState.inputKind.isMaxAction(), + feeMixin.feePaymentCurrency(), + fee + ) + + validationExecutor.requireValid( + validationSystem = createGiftInteractor.validationSystemFor(giftModel.chainAsset, viewModelScope), + payload = payload, + progressConsumer = validationInProgressFlow.progressConsumer(), + autoFixPayload = ::autoFixSendValidationPayload, + validationFailureTransformerCustom = { status, actions -> + viewModelScope.mapAssetTransferValidationFailureToUI( + resourceManager = resourceManager, + status = status, + actions = actions, + setFee = { feeMixin.setFee(fee.replaceSubmission(it)) } + ) + }, + ) { + validationInProgressFlow.value = false + + openConfirmScreen(it, giftAmount) + } + } + + private fun openConfirmScreen(validPayload: AssetTransferPayload, giftAmount: BigDecimal) = launch { + val payload = CreateGiftConfirmPayload( + amount = giftAmount, + transferringMaxAmount = validPayload.transfer.transferringMaxAmount, + assetPayload = payload.assetPayload + ) + + router.openConfirmCreateGift(payload) + } + + private fun setupFees() { + feeMixin.connectWith( + chainFlow, + chainAssetFlow, + amountChooserMixin.amountState, + ) { feePaymentCurrency, chain, chainAsset, amountState -> + val metaAccount = metaAccountFlow.first() + val createGiftModel = CreateGiftModel( + senderMetaAccount = metaAccount, + chain = chain, + chainAsset = chainAsset, + amount = amountState.value.orZero(), + ) + + createGiftInteractor.getFee( + createGiftModel, + amountState.inputKind.isMaxAction(), + viewModelScope + ) + } + } + + private fun getAmountValidator(): FieldValidator { + val minAmountProvider = giftMinAmountProviderFactory.create(chainAssetFlow) + + return CompoundFieldValidator( + enoughAmountValidatorFactory.create(maxActionProvider), + minAmountFieldValidatorFactory.create(chainAssetFlow, minAmountProvider, R.string.gift_min_balance_validation_message) + ) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/di/SelectGiftAmountComponent.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/di/SelectGiftAmountComponent.kt new file mode 100644 index 0000000..d28a01e --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/di/SelectGiftAmountComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_gift_impl.presentation.amount.SelectGiftAmountFragment +import io.novafoundation.nova.feature_gift_impl.presentation.amount.SelectGiftAmountPayload + +@Subcomponent( + modules = [ + SelectGiftAmountModule::class + ] +) +@ScreenScope +interface SelectGiftAmountComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SelectGiftAmountPayload + ): SelectGiftAmountComponent + } + + fun inject(fragment: SelectGiftAmountFragment) +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/di/SelectGiftAmountModule.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/di/SelectGiftAmountModule.kt new file mode 100644 index 0000000..48b8123 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/di/SelectGiftAmountModule.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.amount.GiftMinAmountProviderFactory +import io.novafoundation.nova.feature_gift_impl.presentation.amount.SelectGiftAmountPayload +import io.novafoundation.nova.feature_gift_impl.presentation.amount.SelectGiftAmountViewModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SelectGiftAmountModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): SelectGiftAmountViewModel { + return ViewModelProvider(fragment, factory).get(SelectGiftAmountViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(SelectGiftAmountViewModel::class) + fun provideViewModel( + router: GiftRouter, + chainRegistry: ChainRegistry, + validationExecutor: ValidationExecutor, + assetUseCase: ArbitraryAssetUseCase, + payload: SelectGiftAmountPayload, + maxActionProviderFactory: MaxActionProviderFactory, + amountFormatter: AmountFormatter, + resourceManager: ResourceManager, + getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory, + createGiftInteractor: CreateGiftInteractor, + selectedAccountUseCase: SelectedAccountUseCase, + enoughAmountValidatorFactory: EnoughAmountValidatorFactory, + minAmountFieldValidatorFactory: MinAmountFieldValidatorFactory, + giftMinAmountProviderFactory: GiftMinAmountProviderFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + ): ViewModel { + return SelectGiftAmountViewModel( + router = router, + chainRegistry = chainRegistry, + validationExecutor = validationExecutor, + assetUseCase = assetUseCase, + payload = payload, + maxActionProviderFactory = maxActionProviderFactory, + amountFormatter = amountFormatter, + resourceManager = resourceManager, + getAssetOptionsMixinFactory = getAssetOptionsMixinFactory, + createGiftInteractor = createGiftInteractor, + selectedAccountUseCase = selectedAccountUseCase, + enoughAmountValidatorFactory = enoughAmountValidatorFactory, + minAmountFieldValidatorFactory = minAmountFieldValidatorFactory, + giftMinAmountProviderFactory = giftMinAmountProviderFactory, + amountChooserMixinFactory = amountChooserMixinFactory, + feeLoaderMixinFactory = feeLoaderMixinFactory, + ) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeDisplay.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeDisplay.kt new file mode 100644 index 0000000..73498af --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeDisplay.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount.fee + +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay + +class GiftFeeDisplay( + val networkFee: FeeDisplay, + val claimGiftFee: FeeDisplay, +) diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeDisplayFormatter.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeDisplayFormatter.kt new file mode 100644 index 0000000..960fec6 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeDisplayFormatter.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount.fee + +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.FeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.toFeeDisplay + +class GiftFeeDisplayFormatter( + private val amountFormatter: AmountFormatter +) : FeeFormatter { + + override suspend fun formatFee( + fee: GiftFee, + configuration: FeeFormatter.Configuration, + context: FeeFormatter.Context + ): GiftFeeDisplay { + val networkFee = amountFormatter.formatAmountToAmountModel( + amountInPlanks = fee.createGiftFee.amount, + token = context.token(fee.asset), + AmountConfig(includeZeroFiat = configuration.showZeroFiat) + ).toFeeDisplay() + + val claimFee = amountFormatter.formatAmountToAmountModel( + amountInPlanks = fee.claimGiftFee.amount, + token = context.token(fee.asset), + AmountConfig(includeZeroFiat = configuration.showZeroFiat) + ).toFeeDisplay() + + return GiftFeeDisplay( + networkFee = networkFee, + claimGiftFee = claimFee + ) + } + + override suspend fun createLoadingStatus(): FeeStatus.Loading { + return FeeStatus.Loading(visibleDuringProgress = true) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeInspector.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeInspector.kt new file mode 100644 index 0000000..8245d25 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeInspector.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount.fee + +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class GiftFeeInspector : FeeInspector { + + override fun inspectFeeAmount(fee: GiftFee): FeeInspector.InspectedFeeAmount { + return FeeInspector.InspectedFeeAmount( + checkedAgainstMinimumBalance = fee.amount, + deductedFromTransferable = fee.amount + ) + } + + override fun getSubmissionFeeAsset(fee: GiftFee): Chain.Asset { + return fee.createGiftFee.asset + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeLoaderMixin.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeLoaderMixin.kt new file mode 100644 index 0000000..1348c59 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/amount/fee/GiftFeeLoaderMixin.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.amount.fee + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.DefaultFeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.asFeeContextFromSelf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +context(BaseViewModel) +fun FeeLoaderMixinV2.Factory.createForGiftsWithDefaultDisplay( + originChainAsset: Flow, + formatter: DefaultFeeFormatter +): FeeLoaderMixinV2.Presentation { + return create( + scope = viewModelScope, + feeContextFlow = originChainAsset.asFeeContextFromSelf(), + feeFormatter = formatter, + feeInspector = GiftFeeInspector(), + configuration = FeeLoaderMixinV2.Configuration() + ) +} + +context(BaseViewModel) +fun FeeLoaderMixinV2.Factory.createForGiftsWithGiftFeeDisplay( + originChainAsset: Flow, + formatter: GiftFeeDisplayFormatter +): FeeLoaderMixinV2.Presentation { + return create( + scope = viewModelScope, + feeContextFlow = originChainAsset.asFeeContextFromSelf(), + feeFormatter = formatter, + feeInspector = GiftFeeInspector(), + configuration = FeeLoaderMixinV2.Configuration() + ) +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/ClaimGiftFragment.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/ClaimGiftFragment.kt new file mode 100644 index 0000000..491e12d --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/ClaimGiftFragment.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.claim + +import android.animation.Animator +import android.view.View +import androidx.core.view.postDelayed +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.makeInvisible +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.view.setModelOrHide +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.R +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.bindSelectWallet +import io.novafoundation.nova.feature_account_api.view.setSelectable +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_gift_impl.databinding.FragmentClaimGiftBinding +import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent +import javax.inject.Inject + +private const val HIDE_ANIMATION_DURATION = 400L +private const val UNPACKING_START_FRAME = 180 + +class ClaimGiftFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + private val giftAnimationListener = object : Animator.AnimatorListener { + override fun onAnimationCancel(animation: Animator) {} + override fun onAnimationRepeat(animation: Animator) {} + override fun onAnimationStart(animation: Animator) {} + + override fun onAnimationEnd(animation: Animator) { + viewModel.onGiftClaimAnimationFinished() + } + } + + @Inject + lateinit var imageLoader: ImageLoader + + override fun createBinding() = FragmentClaimGiftBinding.inflate(layoutInflater) + + override fun initViews() { + binder.claimGiftToolbar.setHomeButtonListener { viewModel.back() } + + binder.claimGiftButton.setOnClickListener { viewModel.claimGift() } + binder.claimGiftButton.prepareForProgress(this) + } + + override fun inject() { + FeatureUtils.getFeature(this, GiftFeatureApi::class.java) + .claimGiftComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: ClaimGiftViewModel) { + bindSelectWallet(viewModel.selectWalletMixin) { isAvailableToSelect -> + binder.claimGiftAccount.setSelectable(isAvailableToSelect) { + viewModel.selectWalletToClaim() + } + binder.claimGiftAccount.setActionTint(R.color.icon_secondary) + } + + viewModel.giftAnimationRes.observe { + binder.claimGiftAnimation.setMinAndMaxFrame(0, UNPACKING_START_FRAME) + binder.claimGiftAnimation.setAnimation(it) + binder.claimGiftAnimation.playAnimation() + } + + viewModel.amountModel.observe { + binder.claimGiftTokenIcon.setTokenIcon(it.tokenIcon, imageLoader) + binder.claimGiftAmount.text = it.amount + } + + viewModel.giftClaimedEvent.observeEvent { + hideAllViewsWithAnimation() + } + + viewModel.selectedWalletModel.observe { + binder.claimGiftAccount.setModel(it) + } + + viewModel.confirmButtonStateFlow.observe { + binder.claimGiftButton.setState(it) + } + + viewModel.alertModelFlow.observe { + binder.claimGiftAlert.setModelOrHide(it) + } + } + + private fun hideAllViewsWithAnimation() { + binder.claimGiftToolbar.hideWithAnimation() + binder.claimGiftTokenIcon.hideWithAnimation() + binder.claimGiftAmount.hideWithAnimation() + binder.claimGiftButton.hideWithAnimation() + binder.claimGiftTitle.hideWithAnimation() + binder.claimGiftAccountTitle.hideWithAnimation() + binder.claimGiftAccount.hideWithAnimation() + + binder.root.postDelayed(HIDE_ANIMATION_DURATION) { + val maxFrame = binder.claimGiftAnimation.composition?.endFrame?.toInt() ?: 0 + binder.claimGiftAnimation.setMinAndMaxFrame(UNPACKING_START_FRAME, maxFrame) + binder.claimGiftAnimation.addAnimatorListener(giftAnimationListener) + binder.claimGiftAnimation.playAnimation() + } + } + + private fun View.hideWithAnimation() { + animate().alpha(0f) + .setDuration(HIDE_ANIMATION_DURATION) + .withEndAction { makeInvisible() } + .start() + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/ClaimGiftPayload.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/ClaimGiftPayload.kt new file mode 100644 index 0000000..4e00be8 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/ClaimGiftPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.claim + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class ClaimGiftPayload(val secret: ByteArray, val chainId: String, val assetId: Int) : Parcelable diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/ClaimGiftViewModel.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/ClaimGiftViewModel.kt new file mode 100644 index 0000000..1c77aac --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/ClaimGiftViewModel.kt @@ -0,0 +1,235 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.claim + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_account_api.domain.filter.selectAddress.SelectAccountFilter +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.isControllableWallet +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_account_api.presenatation.common.mapMetaAccountTypeToNameRes +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletMixin +import io.novafoundation.nova.feature_account_api.view.AccountView +import io.novafoundation.nova.feature_gift_impl.R +import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory +import io.novafoundation.nova.feature_gift_impl.presentation.common.UnpackingGiftAnimationFactory +import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftException +import io.novafoundation.nova.feature_gift_impl.presentation.share.model.GiftAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ClaimGiftViewModel( + private val router: GiftRouter, + private val payload: ClaimGiftPayload, + private val claimGiftInteractor: ClaimGiftInteractor, + private val chainRegistry: ChainRegistry, + private val unpackingGiftAnimationFactory: UnpackingGiftAnimationFactory, + private val assetIconProvider: AssetIconProvider, + private val tokenFormatter: TokenFormatter, + private val resourceManager: ResourceManager, + private val walletUiUseCase: WalletUiUseCase, + private val claimGiftMixinFactory: ClaimGiftMixinFactory, + private val accountInteractor: AccountInteractor, + selectSingleWalletMixin: SelectSingleWalletMixin.Factory, +) : BaseViewModel() { + + private val giftFlow = flowOf { claimGiftInteractor.getClaimableGift(payload.secret, payload.chainId, payload.assetId) } + .shareInBackground() + + private val metaIdToClaimGiftFlow = MutableStateFlow(null) + + private val metaAccountToClaimGiftFlow = metaIdToClaimGiftFlow.filterNotNull() + .map { claimGiftInteractor.getMetaAccount(it) } + .shareInBackground() + + private val tempMetaAccountFlow = giftFlow.map { claimGiftInteractor.createTempMetaAccount(it) } + .shareInBackground() + + private val giftAmountWithFee = combine(giftFlow, tempMetaAccountFlow) { gift, metaAccount -> + claimGiftInteractor.getGiftAmountWithFee(gift, metaAccount, coroutineScope) + }.shareInBackground() + + val selectedWalletModel = combine(giftFlow, metaAccountToClaimGiftFlow) { gift, metaAccountToClaimGift -> + val addressModel = walletUiUseCase.walletAddressModelOrNull( + metaAccountToClaimGift, + gift.chain, + AddressIconGenerator.SIZE_MEDIUM + ) + + addressModel.asAccountViewModelOrNoAddress(metaAccountToClaimGift, gift.chain) + } + + val amountModel = combine(giftFlow, giftAmountWithFee) { gift, giftAmountWithFee -> + val tokenIcon = assetIconProvider.getAssetIconOrFallback(gift.chainAsset) + val giftAmount = tokenFormatter.formatToken(giftAmountWithFee.amount, gift.chainAsset.symbol) + GiftAmountModel(tokenIcon, giftAmount) + } + + private val _giftClaimedEvent = MutableLiveData>() + val giftClaimedEvent: LiveData> = _giftClaimedEvent + + val giftAnimationRes = giftFlow.map { + val chainAsset = chainRegistry.asset(it.chain.id, it.chainAsset.id) + unpackingGiftAnimationFactory.getAnimationForAsset(chainAsset.symbol) + }.distinctUntilChanged() + .shareInBackground() + + private val selectWalletPayloadFlow = giftFlow.map { + SelectSingleWalletMixin.Payload( + chain = it.chain, + filter = SelectAccountFilter.ControllableWallets() + ) + } + val selectWalletMixin = selectSingleWalletMixin.create( + coroutineScope = this, + payloadFlow = selectWalletPayloadFlow, + onWalletSelect = ::onWalletSelect + ) + + val alertModelFlow = combine(giftFlow, metaAccountToClaimGiftFlow) { gift, metaAccount -> + when { + !metaAccount.type.isControllableWallet() -> { + val metaAccountTypeName = resourceManager.getString(metaAccount.type.mapMetaAccountTypeToNameRes()) + AlertModel( + style = AlertView.Style.fromPreset(AlertView.StylePreset.WARNING), + message = resourceManager.getString( + R.string.claim_gift_uncontrollable_wallet_title, + metaAccountTypeName.lowercase() + ), + subMessages = listOf(resourceManager.getString(R.string.claim_gift_uncontrollable_wallet_message)), + linkAction = AlertModel.ActionModel( + text = resourceManager.getString(R.string.common_manage_wallets), + listener = ::manageWallets + ), + ) + } + + !metaAccount.hasAccountIn(gift.chain) -> AlertModel( + style = AlertView.Style.fromPreset(AlertView.StylePreset.WARNING), + message = resourceManager.getString(R.string.claim_gift_no_account_alert_title, gift.chain.name), + subMessages = listOf(), + ) + + else -> null + } + } + + private val claimGiftMixin = claimGiftMixinFactory.create(this) + + val confirmButtonStateFlow = combine( + claimGiftMixin.claimingInProgressFlow, + giftFlow, + metaAccountToClaimGiftFlow + ) { claimingInProgress, giftFlow, claimMetaAccount -> + when { + claimingInProgress -> DescriptiveButtonState.Loading + + !claimMetaAccount.type.isControllableWallet() -> DescriptiveButtonState.Gone + + !claimMetaAccount.hasAccountIn(giftFlow.chain) -> { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.account_select_wallet)) + } + + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.claim_gift_button)) + } + } + + init { + launch { + val metaAccount = claimGiftInteractor.getMetaAccountToClaimGift() + metaIdToClaimGiftFlow.value = metaAccount.id + } + } + + fun back() { + router.back() + } + + fun claimGift() = launchUnit { + val gift = giftFlow.first() + val amountWithFee = giftAmountWithFee.first() + val tempMetaAccount = tempMetaAccountFlow.first() + val metaAccountToClaimGift = metaAccountToClaimGiftFlow.first() + + claimGiftMixin.claimGift( + gift = gift, + amountWithFee = amountWithFee, + giftMetaAccount = tempMetaAccount, + giftRecipient = metaAccountToClaimGift + ) + .onSuccess { + val metaAccountToClaimGift = metaAccountToClaimGiftFlow.first() + accountInteractor.selectMetaAccount(metaAccountToClaimGift.id) + _giftClaimedEvent.value = Unit.event() + } + .onFailure { + when (it as ClaimGiftException) { + is ClaimGiftException.GiftAlreadyClaimed -> showError( + resourceManager.getString(R.string.claim_gift_already_claimed_title), + resourceManager.getString(R.string.claim_gift_already_claimed_message) + ) + + is ClaimGiftException.UnknownError -> showError( + resourceManager.getString(R.string.claim_gift_default_error_title), + resourceManager.getString(R.string.claim_gift_default_error_message) + ) + } + } + } + + fun onGiftClaimAnimationFinished() = launchUnit { + showToast(resourceManager.getString(R.string.claim_gift_success_message)) + + router.openMainScreen() + } + + fun selectWalletToClaim() { + launch { + val selectedMetaAccount = metaAccountToClaimGiftFlow.first() + selectWalletMixin.openSelectWallet(selectedMetaAccount.id) + } + } + + private fun onWalletSelect(metaId: Long) { + metaIdToClaimGiftFlow.value = metaId + } + + private fun manageWallets() = launchUnit { + router.openManageWallets() + } + + private fun AddressModel?.asAccountViewModelOrNoAddress( + metaAccountToClaimGift: MetaAccount, + chain: Chain + ): AccountView.Model { + return this?.let { AccountView.Model.Address(it) } + ?: AccountView.Model.NoAddress( + metaAccountToClaimGift.name, + resourceManager.getString(R.string.account_chain_not_found, chain.name) + ) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/deeplink/ClaimGiftDeepLinkConfigurator.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/deeplink/ClaimGiftDeepLinkConfigurator.kt new file mode 100644 index 0000000..1acde89 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/deeplink/ClaimGiftDeepLinkConfigurator.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.addParamIfNotNull +import io.novafoundation.nova.runtime.ext.ChainGeneses +import kotlin.math.min + +class ClaimGiftDeepLinkData( + val seed: String, + val chainId: String, + val symbol: TokenSymbol +) + +class ClaimGiftPayloadParams( + val seed: String, + val chainIdPrefix: String?, + val symbol: TokenSymbol? +) + +class ClaimGiftDeepLinkConfigurator( + private val linkBuilderFactory: LinkBuilderFactory +) : DeepLinkConfigurator { + + val action = "open" + val screen = "gift" + val deepLinkPrefix = "/$action/$screen" + val payloadParam = "payload" + val chainIdLength = 6 + + private val defaultChainId = ChainGeneses.POLKADOT_ASSET_HUB + private val defaultAssetId = "DOT" + + override fun configure(payload: ClaimGiftDeepLinkData, type: DeepLinkConfigurator.Type): Uri { + val data = if (payload.chainId == defaultChainId) { + if (payload.symbol.value == defaultAssetId) { + payload.seed + } else { + makePayload(payload.seed, payload.symbol.value) + } + } else { + val normalizedChainId = normaliseChainId(payload.chainId) + makePayload(payload.seed, payload.symbol.value, normalizedChainId) + } + + return linkBuilderFactory.newLink(type) + .setAction(action) + .setScreen(screen) + .addParamIfNotNull(payloadParam, data) + .build() + } + + fun normaliseChainId(chainId: String): String { + val noPrefixChainId = chainId.removePrefix("eip155:") + val substringEnd = min(noPrefixChainId.length, chainIdLength) + return noPrefixChainId.substring(startIndex = 0, endIndex = substringEnd) + } + + fun fromPayload(payload: String): ClaimGiftPayloadParams { + val payloadParams = payload.split("_") + val seed = payloadParams[0] + val symbol = payloadParams.getOrNull(1) + val chainIdPrefix = payloadParams.getOrNull(2) + + return ClaimGiftPayloadParams( + seed = seed, + symbol = symbol?.let { TokenSymbol(it) }, + chainIdPrefix = chainIdPrefix + ) + } + + private fun makePayload(vararg params: String): String { + return params.joinToString("_") + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/deeplink/ClaimGiftDeepLinkHandler.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/deeplink/ClaimGiftDeepLinkHandler.kt new file mode 100644 index 0000000..cb1616c --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/deeplink/ClaimGiftDeepLinkHandler.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.MutableSharedFlow +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import io.novafoundation.nova.feature_gift_impl.R +import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftPayload +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.findChain +import io.novasama.substrate_sdk_android.extensions.fromHex +import java.security.InvalidParameterException + +class ClaimGiftDeepLinkHandler( + private val router: GiftRouter, + private val chainRegistry: ChainRegistry, + private val automaticInteractionGate: AutomaticInteractionGate, + private val dialogMessageManager: DialogMessageManager, + private val claimGiftDeepLinkConfigurator: ClaimGiftDeepLinkConfigurator, + private val claimGiftInteractor: ClaimGiftInteractor +) : DeepLinkHandler { + + override val callbackFlow = MutableSharedFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + + return path.startsWith(claimGiftDeepLinkConfigurator.deepLinkPrefix) + } + + override suspend fun handleDeepLink(uri: Uri) = runCatching { + automaticInteractionGate.awaitInteractionAllowed() + + val data = uri.getQueryParameter(claimGiftDeepLinkConfigurator.payloadParam) ?: throw InvalidParameterException() + val payloadParams = claimGiftDeepLinkConfigurator.fromPayload(data) + val secretBytes = payloadParams.seed.fromHex() + val chain = findChain(payloadParams.chainIdPrefix) ?: throw InvalidParameterException() + val chainAsset = findAsset(chain, payloadParams.symbol) + require(chain.isEnabled) + + val claimableGift = claimGiftInteractor.getClaimableGift(secretBytes, chain.id, chainAsset.id) + val isGiftAlreadyClaimed = claimGiftInteractor.isGiftAlreadyClaimed(claimableGift) + if (isGiftAlreadyClaimed) { + dialogMessageManager.showDialog { + setTitle(R.string.claim_gift_already_claimed_title) + setMessage(R.string.claim_gift_already_claimed_message) + setPositiveButton(R.string.common_got_it, null) + } + + return@runCatching + } + + router.openClaimGift(ClaimGiftPayload(secretBytes, chain.id, chainAsset.id)) + } + + private suspend fun findChain(chainIdPrefix: String?): Chain? { + if (chainIdPrefix == null) return chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB) + + return chainRegistry.findChain { chainIdPrefix == claimGiftDeepLinkConfigurator.normaliseChainId(it.id) } + } + + private fun findAsset(chain: Chain, tokenSymbol: TokenSymbol?): Chain.Asset { + if (tokenSymbol == null) return chain.utilityAsset + + return chain.assets.first { it.symbol.value == tokenSymbol.value } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/di/ClaimGiftComponent.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/di/ClaimGiftComponent.kt new file mode 100644 index 0000000..8d1bbb0 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/di/ClaimGiftComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.claim.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftFragment +import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftPayload + +@Subcomponent( + modules = [ + ClaimGiftModule::class + ] +) +@ScreenScope +interface ClaimGiftComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ClaimGiftPayload + ): ClaimGiftComponent + } + + fun inject(fragment: ClaimGiftFragment) +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/di/ClaimGiftModule.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/di/ClaimGiftModule.kt new file mode 100644 index 0000000..068fb0d --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/claim/di/ClaimGiftModule.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.claim.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSingleWallet.SelectSingleWalletMixin +import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftPayload +import io.novafoundation.nova.feature_gift_impl.presentation.claim.ClaimGiftViewModel +import io.novafoundation.nova.feature_gift_impl.presentation.common.UnpackingGiftAnimationFactory +import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class ClaimGiftModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ClaimGiftViewModel { + return ViewModelProvider(fragment, factory).get(ClaimGiftViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(ClaimGiftViewModel::class) + fun provideViewModel( + router: GiftRouter, + payload: ClaimGiftPayload, + claimGiftInteractor: ClaimGiftInteractor, + chainRegistry: ChainRegistry, + unpackingGiftAnimationFactory: UnpackingGiftAnimationFactory, + assetIconProvider: AssetIconProvider, + tokenFormatter: TokenFormatter, + resourceManager: ResourceManager, + walletUiUseCase: WalletUiUseCase, + claimGiftMixinFactory: ClaimGiftMixinFactory, + accountInteractor: AccountInteractor, + selectSingleWalletMixin: SelectSingleWalletMixin.Factory, + ): ViewModel { + return ClaimGiftViewModel( + router = router, + payload = payload, + claimGiftInteractor = claimGiftInteractor, + chainRegistry = chainRegistry, + unpackingGiftAnimationFactory = unpackingGiftAnimationFactory, + assetIconProvider = assetIconProvider, + tokenFormatter = tokenFormatter, + resourceManager = resourceManager, + walletUiUseCase = walletUiUseCase, + claimGiftMixinFactory = claimGiftMixinFactory, + selectSingleWalletMixin = selectSingleWalletMixin, + accountInteractor = accountInteractor + ) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/GiftAnimationFactory.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/GiftAnimationFactory.kt new file mode 100644 index 0000000..aa0252b --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/GiftAnimationFactory.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.common + +import androidx.annotation.RawRes +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_gift_impl.R + +private enum class GiftKnownTicker { + DOT, KSM, USDT, HDX, AZERO, ASTR, UNKNOW; + + companion object { + fun tickerOrUnknown(symbol: TokenSymbol): GiftKnownTicker { + return GiftKnownTicker.entries.firstOrNull { it.name == symbol.value } ?: UNKNOW + } + } +} + +interface GiftAnimationFactory { + + @RawRes + fun getAnimationForAsset(symbol: TokenSymbol): Int +} + +class PackingGiftAnimationFactory : GiftAnimationFactory { + + override fun getAnimationForAsset(symbol: TokenSymbol): Int { + val ticker = GiftKnownTicker.tickerOrUnknown(symbol) + return when (ticker) { + GiftKnownTicker.DOT -> R.raw.dot_packing + GiftKnownTicker.KSM -> R.raw.ksm_packing + GiftKnownTicker.USDT -> R.raw.usdt_packing + GiftKnownTicker.HDX -> R.raw.hdx_packing + GiftKnownTicker.AZERO -> R.raw.azero_packing + GiftKnownTicker.ASTR -> R.raw.astr_packing + GiftKnownTicker.UNKNOW -> R.raw.default_packing + } + } +} + +class UnpackingGiftAnimationFactory : GiftAnimationFactory { + + override fun getAnimationForAsset(symbol: TokenSymbol): Int { + val ticker = GiftKnownTicker.tickerOrUnknown(symbol) + return when (ticker) { + GiftKnownTicker.DOT -> R.raw.dot_upacking + GiftKnownTicker.KSM -> R.raw.ksm_unpacking + GiftKnownTicker.USDT -> R.raw.usdt_unpacking + GiftKnownTicker.HDX -> R.raw.hdx_unpacking + GiftKnownTicker.AZERO -> R.raw.azero_unpacking + GiftKnownTicker.ASTR -> R.raw.astr_unpacking + GiftKnownTicker.UNKNOW -> R.raw.default_unpacking + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/GiftValidation.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/GiftValidation.kt new file mode 100644 index 0000000..e76346e --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/GiftValidation.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.common + +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_gift_impl.domain.models.CreateGiftModel +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.buildAssetTransfer +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.emptyAccountIdKey +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +fun buildGiftValidationPayload( + createGiftModel: CreateGiftModel, + asset: Asset, + transferMax: Boolean, + feePaymentCurrency: FeePaymentCurrency, + fee: GiftFee, +): AssetTransferPayload { + val transferAmount = createGiftModel.amount + createGiftModel.chainAsset.amountFromPlanks(fee.claimGiftFee.amount) + + val transfer = buildTransfer( + metaAccount = createGiftModel.senderMetaAccount, + chain = createGiftModel.chain, + chainAsset = createGiftModel.chainAsset, + amount = transferAmount, + transferringMaxAmount = transferMax, + feePaymentCurrency = feePaymentCurrency, + address = createGiftModel.chain.addressOf(createGiftModel.chain.emptyAccountIdKey()), + ) + + val originFee = OriginFee( + submissionFee = fee.createGiftFee, + deliveryFee = null + ) + + return AssetTransferPayload( + transfer = WeightedAssetTransfer( + assetTransfer = transfer, + fee = originFee + ), + crossChainFee = null, + originFee = originFee, + originCommissionAsset = asset, + originUsedAsset = asset + ) +} + +private fun buildTransfer( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + feePaymentCurrency: FeePaymentCurrency, + amount: BigDecimal, + transferringMaxAmount: Boolean, + address: String, +): AssetTransfer { + val chainWithAsset = ChainWithAsset(chain, chainAsset) + return buildAssetTransfer( + metaAccount = metaAccount, + feePaymentCurrency = feePaymentCurrency, + origin = chainWithAsset, + destination = chainWithAsset, + amount = amount, + transferringMaxAmount = transferringMaxAmount, + address = address + ) +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/claim/ClaimGiftMixin.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/claim/ClaimGiftMixin.kt new file mode 100644 index 0000000..6433e5b --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/claim/ClaimGiftMixin.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.common.claim + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_gift_impl.domain.models.ClaimableGift +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftAmountWithFee +import kotlinx.coroutines.flow.MutableStateFlow + +interface ClaimGiftMixin { + + val claimingInProgressFlow: MutableStateFlow + + suspend fun claimGift( + gift: ClaimableGift, + amountWithFee: GiftAmountWithFee, + giftMetaAccount: MetaAccount, + giftRecipient: MetaAccount + ): Result +} + +sealed class ClaimGiftException : Exception() { + class GiftAlreadyClaimed : ClaimGiftException() + + class UnknownError(throwable: Throwable) : ClaimGiftException() +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/claim/RealClaimGiftMixin.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/claim/RealClaimGiftMixin.kt new file mode 100644 index 0000000..f6b3b2d --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/common/claim/RealClaimGiftMixin.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.common.claim + +import io.novafoundation.nova.common.utils.mapFailure +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.models.ClaimableGift +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftAmountWithFee +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow + +class ClaimGiftMixinFactory(private val claimGiftInteractor: ClaimGiftInteractor) { + + fun create( + coroutineScope: CoroutineScope + ): ClaimGiftMixin { + return RealClaimGiftMixin( + claimGiftInteractor, + coroutineScope + ) + } +} + +class RealClaimGiftMixin( + private val claimGiftInteractor: ClaimGiftInteractor, + private val coroutineScope: CoroutineScope, +) : ClaimGiftMixin { + + override val claimingInProgressFlow = MutableStateFlow(false) + + override suspend fun claimGift( + gift: ClaimableGift, + amountWithFee: GiftAmountWithFee, + giftMetaAccount: MetaAccount, + giftRecipient: MetaAccount + ): Result { + claimingInProgressFlow.value = true + + if (claimGiftInteractor.isGiftAlreadyClaimed(gift)) { + claimingInProgressFlow.value = false + + return Result.failure(ClaimGiftException.GiftAlreadyClaimed()) + } + + return claimGiftInteractor.claimGift( + claimableGift = gift, + giftAmountWithFee = amountWithFee, + giftMetaAccount = giftMetaAccount, + giftRecipient = giftRecipient, + coroutineScope = coroutineScope + ) + .mapFailure { + claimingInProgressFlow.value = false + + ClaimGiftException.UnknownError(it) + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/CreateGiftConfirmFragment.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/CreateGiftConfirmFragment.kt new file mode 100644 index 0000000..244a86a --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/CreateGiftConfirmFragment.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.confirm + +import android.view.View +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.presentation.showLoadingState +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_gift_impl.databinding.FragmentCreateGiftConfirmBinding +import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class CreateGiftConfirmFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentCreateGiftConfirmBinding.inflate(layoutInflater) + + override fun applyInsets(rootView: View) { + binder.root.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.confirmCreateGiftToolbar.setHomeButtonListener { viewModel.back() } + + binder.confirmCreateGiftAccount.setOnClickListener { viewModel.accountClicked() } + + binder.confirmCreateGiftButton.prepareForProgress(this) + binder.confirmCreateGiftButton.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(this, GiftFeatureApi::class.java) + .createGiftConfirmComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: CreateGiftConfirmViewModel) { + setupExternalActions(viewModel) + observeValidations(viewModel) + viewModel.feeMixin.setupGiftFeeLoading(binder.confirmCreateGiftNetworkFee, binder.confirmCreateGiftClaimFee) + + viewModel.senderGiftAccount.observe(binder.confirmCreateGiftAccount::showAddress) + + viewModel.confirmButtonStateLiveData.observe(binder.confirmCreateGiftButton::setState) + + viewModel.wallet.observe(binder.confirmCreateGiftWallet::showWallet) + + viewModel.chainModelFlow.observe { binder.confirmCreateGiftNetwork.showChain(it) } + + viewModel.totalAmountModel.observe(binder.confirmCreateGiftTotalAmount::showLoadingState) + + viewModel.giftAmountModel.observe(binder.confirmCreateGiftAmount::showAmount) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/CreateGiftConfirmPayload.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/CreateGiftConfirmPayload.kt new file mode 100644 index 0000000..bbf9b02 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/CreateGiftConfirmPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import java.math.BigDecimal +import kotlinx.parcelize.Parcelize + +@Parcelize +class CreateGiftConfirmPayload( + val amount: BigDecimal, + val transferringMaxAmount: Boolean, + val assetPayload: AssetPayload +) : Parcelable diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/CreateGiftConfirmViewModel.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/CreateGiftConfirmViewModel.kt new file mode 100644 index 0000000..fabf922 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/CreateGiftConfirmViewModel.kt @@ -0,0 +1,218 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.asLoaded +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_gift_impl.R +import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.GiftId +import io.novafoundation.nova.feature_gift_impl.domain.models.CreateGiftModel +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.amount.fee.GiftFeeDisplayFormatter +import io.novafoundation.nova.feature_gift_impl.presentation.amount.fee.createForGiftsWithGiftFeeDisplay +import io.novafoundation.nova.feature_gift_impl.presentation.common.buildGiftValidationPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountSign +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.autoFixSendValidationPayload +import io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers.mapAssetTransferValidationFailureToUI +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class CreateGiftConfirmViewModel( + private val router: GiftRouter, + private val chainRegistry: ChainRegistry, + private val validationExecutor: ValidationExecutor, + private val assetUseCase: ArbitraryAssetUseCase, + private val walletUiUseCase: WalletUiUseCase, + private val payload: CreateGiftConfirmPayload, + private val amountFormatter: AmountFormatter, + private val resourceManager: ResourceManager, + private val externalActions: ExternalActions.Presentation, + private val createGiftInteractor: CreateGiftInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val selectedAccountUseCase: SelectedAccountUseCase, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor { + + private val chainFlow = chainRegistry.chainFlow(payload.assetPayload.chainId) + private val chainAssetFlow = chainFlow.map { it.assetsById.getValue(payload.assetPayload.chainAssetId) } + + private val metaAccountFlow = selectedAccountUseCase.selectedMetaAccountFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + private val assetFlow = chainAssetFlow.flatMapLatest(assetUseCase::assetFlow) + .shareInBackground() + + val chainModelFlow = chainFlow.map { + mapChainToUi(it) + }.shareInBackground() + + val wallet = walletUiUseCase.selectedWalletUiFlow() + .inBackground() + .share() + + val senderGiftAccount = combine(metaAccountFlow, chainFlow) { metaAccount, chain -> + createAddressModel( + address = metaAccount.requireAddressIn(chain), + chain = chain + ) + } + .inBackground() + .share() + + private val feeFormatter = GiftFeeDisplayFormatter(amountFormatter) + val feeMixin = feeLoaderMixinFactory.createForGiftsWithGiftFeeDisplay( + chainAssetFlow, + feeFormatter + ) + + val totalAmountModel = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.amount, asset, AmountConfig(tokenAmountSign = AmountSign.NEGATIVE)) + .asLoaded() + } + + val giftAmountModel = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.amount, asset) + } + + private val validationInProgressFlow = MutableStateFlow(false) + val confirmButtonStateLiveData = validationInProgressFlow.map { submitting -> + if (submitting) { + DescriptiveButtonState.Loading + } else { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_confirm)) + } + } + + init { + setupFees() + } + + fun back() { + router.back() + } + + fun accountClicked() = launchUnit { + val chain = chainFlow.first() + val address = senderGiftAccount.first() + externalActions.showAddressActions(address.address, chain) + } + + fun confirmClicked() = launchUnit { + validationInProgressFlow.value = true + val fee = feeMixin.awaitFee() + val chain = chainFlow.first() + + val giftModel = CreateGiftModel( + senderMetaAccount = selectedAccountUseCase.getSelectedMetaAccount(), + chain = chain, + chainAsset = chainAssetFlow.first(), + amount = payload.amount, + ) + + val payload = buildGiftValidationPayload( + giftModel, + asset = assetFlow.first(), + payload.transferringMaxAmount, + feeMixin.feePaymentCurrency(), + fee + ) + + validationExecutor.requireValid( + validationSystem = createGiftInteractor.validationSystemFor(giftModel.chainAsset, viewModelScope), + payload = payload, + progressConsumer = validationInProgressFlow.progressConsumer(), + autoFixPayload = ::autoFixSendValidationPayload, + validationFailureTransformerCustom = { status, actions -> + viewModelScope.mapAssetTransferValidationFailureToUI( + resourceManager = resourceManager, + status = status, + actions = actions, + setFee = { feeMixin.setFee(fee.replaceSubmission(it)) } + ) + }, + ) { validPayload -> + performTransfer(giftModel, validPayload.transfer, validPayload.originFee.submissionFee) + } + } + + private fun performTransfer( + giftModel: CreateGiftModel, + transfer: WeightedAssetTransfer, + fee: SubmissionFee + ) = launch { + createGiftInteractor.createAndSaveGift(giftModel, transfer, fee, viewModelScope) + .onSuccess { + finishCreateGift(giftId = it) + }.onFailure(::showError) + + validationInProgressFlow.value = false + } + + private fun finishCreateGift(giftId: GiftId) { + router.openGiftSharing(giftId) + } + + private fun setupFees() { + feeMixin.loadFee { + val metaAccount = metaAccountFlow.first() + val chain = chainFlow.first() + + val createGiftModel = CreateGiftModel( + senderMetaAccount = metaAccount, + chain = chain, + chainAsset = chainAssetFlow.first(), + amount = payload.amount, + ) + + createGiftInteractor.getFee( + createGiftModel, + payload.transferringMaxAmount, + viewModelScope + ) + } + } + + private suspend fun createAddressModel( + address: String, + chain: Chain + ) = addressIconGenerator.createAddressModel( + chain = chain, + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + address = address, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT, + addressDisplayUseCase = null + ) +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/di/CreateGiftConfirmComponent.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/di/CreateGiftConfirmComponent.kt new file mode 100644 index 0000000..07fdec7 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/di/CreateGiftConfirmComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmFragment +import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmPayload + +@Subcomponent( + modules = [ + CreateGiftConfirmModule::class + ] +) +@ScreenScope +interface CreateGiftConfirmComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: CreateGiftConfirmPayload + ): CreateGiftConfirmComponent + } + + fun inject(fragment: CreateGiftConfirmFragment) +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/di/CreateGiftConfirmModule.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/di/CreateGiftConfirmModule.kt new file mode 100644 index 0000000..8a80b94 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/di/CreateGiftConfirmModule.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_gift_impl.domain.CreateGiftInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmPayload +import io.novafoundation.nova.feature_gift_impl.presentation.confirm.CreateGiftConfirmViewModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class CreateGiftConfirmModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): CreateGiftConfirmViewModel { + return ViewModelProvider(fragment, factory).get(CreateGiftConfirmViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(CreateGiftConfirmViewModel::class) + fun provideViewModel( + router: GiftRouter, + chainRegistry: ChainRegistry, + validationExecutor: ValidationExecutor, + assetUseCase: ArbitraryAssetUseCase, + walletUiUseCase: WalletUiUseCase, + payload: CreateGiftConfirmPayload, + amountFormatter: AmountFormatter, + resourceManager: ResourceManager, + externalActions: ExternalActions.Presentation, + createGiftInteractor: CreateGiftInteractor, + addressIconGenerator: AddressIconGenerator, + selectedAccountUseCase: SelectedAccountUseCase, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + ): ViewModel { + return CreateGiftConfirmViewModel( + router = router, + chainRegistry = chainRegistry, + validationExecutor = validationExecutor, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + payload = payload, + amountFormatter = amountFormatter, + resourceManager = resourceManager, + externalActions = externalActions, + createGiftInteractor = createGiftInteractor, + addressIconGenerator = addressIconGenerator, + selectedAccountUseCase = selectedAccountUseCase, + feeLoaderMixinFactory = feeLoaderMixinFactory + ) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/setupFeeLoading.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/setupFeeLoading.kt new file mode 100644 index 0000000..1224afc --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/confirm/setupFeeLoading.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.confirm + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_gift_impl.domain.models.GiftFee +import io.novafoundation.nova.feature_gift_impl.presentation.amount.fee.GiftFeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.mapDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView + +context(BaseFragment) +fun FeeLoaderMixinV2.setupGiftFeeLoading(networkFee: FeeView, claimFee: FeeView) { + setupFeeLoading( + setFeeStatus = { + val originFee = it.mapDisplay(GiftFeeDisplay::networkFee) + val crossChainFee = it.mapDisplay(GiftFeeDisplay::claimGiftFee) + + networkFee.setFeeStatus(originFee) + claimFee.setFeeStatus(crossChainFee) + }, + setUserCanChangeFeeAsset = { + networkFee.setFeeEditable(it) { + changePaymentCurrencyClicked() + } + } + ) +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftRVItem.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftRVItem.kt new file mode 100644 index 0000000..0a04a70 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftRVItem.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.gifts + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.utils.images.Icon + +data class GiftRVItem( + val id: Long, + val isClaimed: Boolean, + val amount: CharSequence, + val assetIcon: Icon, + val subtitle: String, + @DrawableRes val imageRes: Int +) diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsFragment.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsFragment.kt new file mode 100644 index 0000000..ec4d16b --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsFragment.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.gifts + +import androidx.recyclerview.widget.ConcatAdapter +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_gift_impl.R +import io.novafoundation.nova.feature_gift_impl.databinding.FragmentGiftsBinding +import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent +import io.novafoundation.nova.feature_gift_impl.presentation.gifts.list.GiftsHeaderAdapter +import io.novafoundation.nova.feature_gift_impl.presentation.gifts.list.GiftsInstructionsAdapter +import javax.inject.Inject + +class GiftsFragment : BaseFragment(), GiftsHeaderAdapter.ItemHandler, GiftsListAdapter.Handler { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentGiftsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val headerAdapter = GiftsHeaderAdapter(this) + + private val instructionsAdapter = GiftsInstructionsAdapter() + + private val giftsTitleAdapter = CustomPlaceholderAdapter(R.layout.item_gifts_title) + + private val giftsAdapter by lazy(LazyThreadSafetyMode.NONE) { GiftsListAdapter(this, imageLoader) } + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + ConcatAdapter( + headerAdapter, + giftsTitleAdapter, + giftsAdapter, + instructionsAdapter + ) + } + + override fun initViews() { + binder.giftsToolbar.setHomeButtonListener { viewModel.back() } + binder.giftsList.adapter = adapter + + binder.giftsCreate.setOnClickListener { viewModel.createGiftClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(this, GiftFeatureApi::class.java) + .giftsComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: GiftsViewModel) { + observeBrowserEvents(viewModel) + + viewModel.gifts.observe { + instructionsAdapter.show(it.isEmpty()) + giftsTitleAdapter.show(it.isNotEmpty()) + giftsAdapter.submitList(it) + } + } + + override fun onLearnMoreClicked() { + viewModel.learnMoreClicked() + } + + override fun onGiftClicked(referendum: GiftRVItem) { + viewModel.giftClicked(referendum) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsListAdapter.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsListAdapter.kt new file mode 100644 index 0000000..1b01889 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsListAdapter.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.gifts + +import android.graphics.drawable.Drawable +import android.view.ViewGroup +import androidx.annotation.ColorRes +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import coil.ImageLoader +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.AlphaColorFilter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawableWithRipple +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_gift_impl.R +import io.novafoundation.nova.feature_gift_impl.databinding.ItemGiftBinding + +class GiftsListAdapter( + private val handler: Handler, + private val imageLoader: ImageLoader +) : ListAdapter(GiftsDiffCallback) { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): GiftViewHolder { + return GiftViewHolder( + ItemGiftBinding.inflate(parent.inflater(), parent, false), + handler, + imageLoader + ) + } + + override fun onBindViewHolder(holder: GiftViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + interface Handler { + + fun onGiftClicked(referendum: GiftRVItem) + } +} + +private object GiftsDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: GiftRVItem, + newItem: GiftRVItem + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: GiftRVItem, + newItem: GiftRVItem + ): Boolean { + return oldItem == newItem + } +} + +class GiftViewHolder( + private val binder: ItemGiftBinding, + private val handler: GiftsListAdapter.Handler, + private val imageLoader: ImageLoader +) : BaseViewHolder(binder.root) { + + fun bind(item: GiftRVItem) = with(binder) { + // Set content + giftAmount.text = item.amount + giftAssetIcon.setTokenIcon(item.assetIcon, imageLoader) + giftCreationDate.text = item.subtitle + giftImage.setImageResource(item.imageRes) + + root.setOnClickListener { handler.onGiftClicked(item) } + + val amountColor = if (item.isClaimed) R.color.text_secondary else R.color.text_primary + val assetIconAlpha = if (item.isClaimed) 0.56f else 1f + + giftAmount.setTextColorRes(amountColor) + giftAssetIcon.colorFilter = AlphaColorFilter(assetIconAlpha) + + giftAmountChevron.isVisible = !item.isClaimed + root.background = if (item.isClaimed) background(R.color.block_background) else background(R.color.gift_block_background) + } + + private fun background(@ColorRes colorRes: Int): Drawable { + return context.getRoundedCornerDrawableWithRipple(fillColorRes = colorRes, cornerSizeInDp = 12) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsPayload.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsPayload.kt new file mode 100644 index 0000000..e83a473 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.gifts + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import kotlinx.parcelize.Parcelize + +sealed interface GiftsPayload : Parcelable { + + @Parcelize + object AllAssets : GiftsPayload + + @Parcelize + data class ByAsset(val assetPayload: AssetPayload) : GiftsPayload +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsViewModel.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsViewModel.kt new file mode 100644 index 0000000..9ff2988 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/GiftsViewModel.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.gifts + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_gift_impl.R +import io.novafoundation.nova.feature_gift_impl.domain.GiftsInteractor +import io.novafoundation.nova.feature_gift_impl.domain.models.Gift +import io.novafoundation.nova.feature_gift_impl.domain.models.isClaimed +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatToken +import io.novafoundation.nova.feature_wallet_api.presentation.model.fullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import java.text.SimpleDateFormat +import java.util.Locale +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +class GiftsViewModel( + private val router: GiftRouter, + private val appLinksProvider: AppLinksProvider, + private val giftsInteractor: GiftsInteractor, + private val chainRegistry: ChainRegistry, + private val giftsPayload: GiftsPayload, + private val tokenFormatter: TokenFormatter, + private val assetIconProvider: AssetIconProvider, + private val resourceManager: ResourceManager +) : BaseViewModel(), Browserable { + + private val dateFormatter: SimpleDateFormat = SimpleDateFormat("d.M.yyyy", Locale.getDefault()) + + override val openBrowserEvent = MutableLiveData>() + + private val chains = chainRegistry.chainsById + + val gifts = combine( + giftsInteractor.observeGifts(giftsPayload.fullChainAssetIdOrNull()), + chains + ) { gifts, chains -> + gifts.mapNotNull { mapGift(it, chains) } + } + + init { + launch { + giftsInteractor.syncGiftsState() + } + } + + fun back() { + router.back() + } + + fun learnMoreClicked() { + openBrowserEvent.value = Event(appLinksProvider.giftsWikiUrl) + } + + fun createGiftClicked() { + when (giftsPayload) { + GiftsPayload.AllAssets -> router.openGiftsFlow() + is GiftsPayload.ByAsset -> router.openSelectGiftAmount(giftsPayload.assetPayload) + } + } + + fun giftClicked(gift: GiftRVItem) { + if (gift.isClaimed) return + + router.openGiftSharing(gift.id, isSecondOpen = true) + } + + private fun mapGift(gift: Gift, chains: Map): GiftRVItem? { + val asset = chains[gift.chainId]?.assetsById[gift.assetId] ?: return null + val isClaimed = gift.status.isClaimed() + val subtitle = when (gift.status) { + Gift.Status.CLAIMED -> resourceManager.getString(R.string.gift_claimed_subtitle) + Gift.Status.RECLAIMED -> resourceManager.getString(R.string.gift_reclaimed_subtitle) + Gift.Status.PENDING -> resourceManager.getString( + R.string.gift_created_subtitle, + dateFormatter.format(gift.creationDate) + ) + } + + return GiftRVItem( + id = gift.id, + isClaimed = isClaimed, + amount = tokenFormatter.formatToken(gift.amount, asset), + assetIcon = assetIconProvider.getAssetIconOrFallback(asset), + subtitle = subtitle, + imageRes = if (isClaimed) R.drawable.ic_gift_unpacked else R.drawable.ic_gift_packed, + ) + } +} + +private fun GiftsPayload.fullChainAssetIdOrNull(): FullChainAssetId? { + return when (this) { + is GiftsPayload.ByAsset -> assetPayload.fullChainAssetId + GiftsPayload.AllAssets -> null + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/di/GiftsComponent.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/di/GiftsComponent.kt new file mode 100644 index 0000000..fa21db9 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/di/GiftsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.gifts.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_gift_impl.presentation.gifts.GiftsFragment +import io.novafoundation.nova.feature_gift_impl.presentation.gifts.GiftsPayload + +@Subcomponent( + modules = [ + GiftsModule::class + ] +) +@ScreenScope +interface GiftsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: GiftsPayload + ): GiftsComponent + } + + fun inject(fragment: GiftsFragment) +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/di/GiftsModule.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/di/GiftsModule.kt new file mode 100644 index 0000000..719af09 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/di/GiftsModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.gifts.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_gift_impl.domain.GiftsInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.gifts.GiftsPayload +import io.novafoundation.nova.feature_gift_impl.presentation.gifts.GiftsViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class GiftsModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): GiftsViewModel { + return ViewModelProvider(fragment, factory).get(GiftsViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(GiftsViewModel::class) + fun provideViewModel( + router: GiftRouter, + appLinksProvider: AppLinksProvider, + giftsInteractor: GiftsInteractor, + giftsPayload: GiftsPayload, + chainRegistry: ChainRegistry, + tokenFormatter: TokenFormatter, + assetIconProvider: AssetIconProvider, + resourceManager: ResourceManager + ): ViewModel { + return GiftsViewModel( + router = router, + appLinksProvider = appLinksProvider, + giftsInteractor = giftsInteractor, + giftsPayload = giftsPayload, + tokenFormatter = tokenFormatter, + assetIconProvider = assetIconProvider, + chainRegistry = chainRegistry, + resourceManager = resourceManager + ) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/list/GiftsHeaderAdapter.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/list/GiftsHeaderAdapter.kt new file mode 100644 index 0000000..050938a --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/list/GiftsHeaderAdapter.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.gifts.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.SingleItemAdapter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_gift_impl.databinding.ItemGiftsHeaderBinding + +class GiftsHeaderAdapter( + private val handler: ItemHandler +) : SingleItemAdapter(isShownByDefault = true) { + + interface ItemHandler { + fun onLearnMoreClicked() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GiftsHeaderHolder { + return GiftsHeaderHolder( + ItemGiftsHeaderBinding.inflate(parent.inflater(), parent, false), + handler + ) + } + + override fun onBindViewHolder(holder: GiftsHeaderHolder, position: Int) { + } +} + +class GiftsHeaderHolder(binder: ItemGiftsHeaderBinding, handler: GiftsHeaderAdapter.ItemHandler) : RecyclerView.ViewHolder(binder.root) { + + init { + binder.giftsHeaderLearnMore.setOnClickListener { handler.onLearnMoreClicked() } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/list/GiftsInsstructionsAdapter.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/list/GiftsInsstructionsAdapter.kt new file mode 100644 index 0000000..8893765 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/gifts/list/GiftsInsstructionsAdapter.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.gifts.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.SingleItemAdapter +import io.novafoundation.nova.common.utils.addColor +import io.novafoundation.nova.common.utils.formatting.spannable.spannableFormatting +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_gift_impl.R +import io.novafoundation.nova.feature_gift_impl.databinding.ItemGiftsInstructionPlaceholderBinding + +class GiftsInstructionsAdapter() : SingleItemAdapter(isShownByDefault = false) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GiftsInstructionsHolder { + return GiftsInstructionsHolder(ItemGiftsInstructionPlaceholderBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: GiftsInstructionsHolder, position: Int) { + } +} + +class GiftsInstructionsHolder(binder: ItemGiftsInstructionPlaceholderBinding) : RecyclerView.ViewHolder(binder.root) { + + init { + with(binder.root.context) { + val highlightColor = getColor(R.color.text_primary) + binder.giftsInstructionStep1.setStepText(getString(R.string.gifts_placeholder_step_1).addColor(highlightColor)) + binder.giftsInstructionStep2.setStepText( + getString(R.string.gifts_placeholder_step_2) + .spannableFormatting(getString(R.string.gifts_placeholder_step_2_highlight).addColor(highlightColor)) + ) + binder.giftsInstructionStep3.setStepText( + getString(R.string.gifts_placeholder_step_3) + .spannableFormatting(getString(R.string.gifts_placeholder_step_3_highlight).addColor(highlightColor)) + ) + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftAnimationState.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftAnimationState.kt new file mode 100644 index 0000000..d7e3d97 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftAnimationState.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.share + +import androidx.annotation.RawRes + +class ShareGiftAnimationState( + @RawRes val res: Int, + val state: State +) { + enum class State { + START, + IDLE_END + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftFragment.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftFragment.kt new file mode 100644 index 0000000..40326da --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftFragment.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.share + +import android.animation.Animator +import android.view.View +import coil.ImageLoader +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.utils.share.shareImageWithText +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi +import io.novafoundation.nova.feature_gift_impl.databinding.FragmentShareGiftBinding +import io.novafoundation.nova.feature_gift_impl.di.GiftFeatureComponent +import javax.inject.Inject + +class ShareGiftFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + @Inject + lateinit var imageLoader: ImageLoader + + private val giftAnimationListener = object : Animator.AnimatorListener { + override fun onAnimationCancel(animation: Animator) {} + override fun onAnimationRepeat(animation: Animator) {} + override fun onAnimationStart(animation: Animator) {} + + override fun onAnimationEnd(animation: Animator) { + showAllViews() + } + } + + override fun createBinding() = FragmentShareGiftBinding.inflate(layoutInflater) + + override fun initViews() { + binder.shareGiftToolbar.setHomeButtonListener { viewModel.back() } + binder.shareGiftToolbar.setRightActionClickListener { viewModel.reclaimClicked() } + + binder.shareGiftButton.setOnClickListener { viewModel.shareDeepLinkClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(this, GiftFeatureApi::class.java) + .shareGiftComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: ShareGiftViewModel) { + viewModel.giftAnimationRes.observe { + binder.shareGiftAnimation.setAnimation(it.res) + when (it.state) { + ShareGiftAnimationState.State.START -> { + binder.shareGiftAnimation.playAnimation() + binder.shareGiftAnimation.addAnimatorListener(giftAnimationListener) + } + + ShareGiftAnimationState.State.IDLE_END -> { + binder.shareGiftAnimation.progress = 1f + showAllViews(withAnimation = false) + } + } + } + + viewModel.amountModel.observe { + binder.shareGiftTokenIcon.setTokenIcon(it.tokenIcon, imageLoader) + binder.shareGiftAmount.text = it.amount + } + + viewModel.shareEvent.observeEvent { + shareImageWithText(sharingData = it, chooserTitle = null) + } + + viewModel.isReclaimInProgress.observe { + binder.shareGiftToolbar.showProgress(it) + } + + viewModel.reclaimButtonVisible.observe { + binder.shareGiftToolbar.setRightTextVisible(it) + } + + viewModel.confirmReclaimGiftAction.awaitableActionLiveData.observeEvent { event -> + warningDialog( + context = providedContext, + onPositiveClick = { event.onSuccess(Unit) }, + positiveTextRes = R.string.common_continue, + negativeTextRes = R.string.common_cancel, + onNegativeClick = { event.onCancel() }, + styleRes = R.style.AccentAlertDialogTheme + ) { + setTitle(getString(R.string.reclaim_gift_confirmation_title, event.payload.amount)) + + setMessage(R.string.reclaim_gift_confirmation_message) + } + } + } + + private fun showAllViews(withAnimation: Boolean = true) { + binder.shareGiftToolbar.show(withAnimation) + binder.shareGiftTokenIcon.show(withAnimation) + binder.shareGiftAmount.show(withAnimation) + binder.shareGiftButton.show(withAnimation) + binder.shareGiftTitle.show(withAnimation) + } + + private fun View.show(withAnimation: Boolean) { + if (withAnimation) { + animate().alpha(1f) + .setDuration(400) + .start() + } else { + alpha = 1f + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftPayload.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftPayload.kt new file mode 100644 index 0000000..d2c6692 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.share + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class ShareGiftPayload(val giftId: Long, val isSecondOpen: Boolean) : Parcelable diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftViewModel.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftViewModel.kt new file mode 100644 index 0000000..1eaf52f --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/ShareGiftViewModel.kt @@ -0,0 +1,162 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.share + +import android.net.Uri +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.share.ImageWithTextSharing +import io.novafoundation.nova.common.utils.write +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator +import io.novafoundation.nova.feature_gift_impl.R +import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.ShareGiftInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.common.PackingGiftAnimationFactory +import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkConfigurator +import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkData +import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftException +import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory +import io.novafoundation.nova.feature_gift_impl.presentation.share.model.GiftAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatToken +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novasama.substrate_sdk_android.extensions.fromHex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +private const val GIFT_FILE_NAME = "share-gift.png" + +class ShareGiftViewModel( + private val router: GiftRouter, + private val payload: ShareGiftPayload, + private val shareGiftInteractor: ShareGiftInteractor, + private val chainRegistry: ChainRegistry, + private val packingGiftAnimationFactory: PackingGiftAnimationFactory, + private val assetIconProvider: AssetIconProvider, + private val tokenFormatter: TokenFormatter, + private val claimGiftDeepLinkConfigurator: ClaimGiftDeepLinkConfigurator, + private val fileProvider: FileProvider, + private val claimGiftMixinFactory: ClaimGiftMixinFactory, + private val claimGiftInteractor: ClaimGiftInteractor, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val resourceManager: ResourceManager +) : BaseViewModel() { + + private val giftFlow = shareGiftInteractor.observeGift(payload.giftId) + .shareInBackground() + + private val chainAssetFlow = giftFlow.map { + chainRegistry.asset(it.chainId, it.assetId) + }.shareInBackground() + + val giftAnimationRes = giftFlow.map { + val chainAsset = chainRegistry.asset(it.chainId, it.assetId) + val animationRes = packingGiftAnimationFactory.getAnimationForAsset(chainAsset.symbol) + + when (payload.isSecondOpen) { + true -> ShareGiftAnimationState(animationRes, ShareGiftAnimationState.State.IDLE_END) + false -> ShareGiftAnimationState(animationRes, ShareGiftAnimationState.State.START) + } + }.distinctUntilChanged() + .shareInBackground() + + val amountModel = giftFlow.map { + val chainAsset = chainRegistry.asset(it.chainId, it.assetId) + val tokenIcon = assetIconProvider.getAssetIconOrFallback(chainAsset) + val giftAmount = tokenFormatter.formatToken(it.amount, chainAsset) + GiftAmountModel(tokenIcon, giftAmount) + } + + private val _shareEvent = MutableLiveData>() + val shareEvent: LiveData> = _shareEvent + + private val claimGiftMixin = claimGiftMixinFactory.create(this) + + private val _isReclaimInProgress = MutableStateFlow(false) + val isReclaimInProgress: Flow = _isReclaimInProgress + + val reclaimButtonVisible = flowOf { payload.isSecondOpen } + + val confirmReclaimGiftAction = actionAwaitableMixinFactory.confirmingAction() + + fun back() { + router.finishCreateGift() + } + + fun shareDeepLinkClicked() = launchUnit { + runCatching { + val giftAmount = amountModel.first().amount + val sharingLink = getSharingLink() + val sharingText = resourceManager.getString(R.string.share_gift_text, giftAmount, sharingLink.toString()) + val giftImageFile = generateShareImageFile() + _shareEvent.value = Event(ImageWithTextSharing(giftImageFile, sharingText)) + }.onFailure(::showError) + } + + private suspend fun getSharingLink(): Uri { + val chainAsset = chainAssetFlow.first() + val giftSeed = shareGiftInteractor.getGiftSeed(payload.giftId) + val sharingPayload = ClaimGiftDeepLinkData(giftSeed, chainAsset.chainId, chainAsset.symbol) + return claimGiftDeepLinkConfigurator.configure(sharingPayload, type = DeepLinkConfigurator.Type.APP_LINK) + } + + suspend fun generateShareImageFile(): Uri = withContext(Dispatchers.IO) { + val shareBitmap = resourceManager.getDrawable(R.drawable.ic_pezkuwi_gift).toBitmap() + val file = fileProvider.generateTempFile(fixedName = GIFT_FILE_NAME) + file.write(shareBitmap) + + fileProvider.uriOf(file) + } + + fun reclaimClicked() = launchUnit { + val giftAmount = amountModel.first() + confirmReclaimGiftAction.awaitAction(giftAmount) + + _isReclaimInProgress.value = true + val giftModel = giftFlow.first() + val giftSeed = shareGiftInteractor.getGiftSeed(payload.giftId) + val claimableGift = claimGiftInteractor.getClaimableGift(giftSeed.fromHex(), giftModel.chainId, giftModel.assetId) + val tempMetaAccount = claimGiftInteractor.createTempMetaAccount(claimableGift) + val amountWithFee = claimGiftInteractor.getGiftAmountWithFee(claimableGift, tempMetaAccount, coroutineScope) + val recipientMetaAccount = selectedAccountUseCase.getSelectedMetaAccount() + + claimGiftMixin.claimGift(gift = claimableGift, amountWithFee = amountWithFee, giftMetaAccount = tempMetaAccount, giftRecipient = recipientMetaAccount) + .onSuccess { + shareGiftInteractor.setGiftStateAsReclaimed(giftModel.id) + showToast(resourceManager.getString(R.string.reclaim_gift_success)) + router.back() + } + .onFailure { + _isReclaimInProgress.value = false + when (it as ClaimGiftException) { + is ClaimGiftException.GiftAlreadyClaimed -> showError( + resourceManager.getString(R.string.claim_gift_already_claimed_title), + resourceManager.getString(R.string.claim_gift_already_claimed_message) + ) + + is ClaimGiftException.UnknownError -> showError( + resourceManager.getString(R.string.claim_gift_default_error_title), + resourceManager.getString(R.string.claim_gift_default_error_message) + ) + } + } + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/di/ShareGiftComponent.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/di/ShareGiftComponent.kt new file mode 100644 index 0000000..d3d817b --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/di/ShareGiftComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.share.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_gift_impl.presentation.share.ShareGiftFragment +import io.novafoundation.nova.feature_gift_impl.presentation.share.ShareGiftPayload + +@Subcomponent( + modules = [ + ShareGiftModule::class + ] +) +@ScreenScope +interface ShareGiftComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ShareGiftPayload + ): ShareGiftComponent + } + + fun inject(fragment: ShareGiftFragment) +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/di/ShareGiftModule.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/di/ShareGiftModule.kt new file mode 100644 index 0000000..b494cf8 --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/di/ShareGiftModule.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.share.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_gift_impl.domain.ClaimGiftInteractor +import io.novafoundation.nova.feature_gift_impl.domain.ShareGiftInteractor +import io.novafoundation.nova.feature_gift_impl.presentation.GiftRouter +import io.novafoundation.nova.feature_gift_impl.presentation.common.PackingGiftAnimationFactory +import io.novafoundation.nova.feature_gift_impl.presentation.share.ShareGiftPayload +import io.novafoundation.nova.feature_gift_impl.presentation.share.ShareGiftViewModel +import io.novafoundation.nova.feature_gift_impl.presentation.claim.deeplink.ClaimGiftDeepLinkConfigurator +import io.novafoundation.nova.feature_gift_impl.presentation.common.claim.ClaimGiftMixinFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class ShareGiftModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ShareGiftViewModel { + return ViewModelProvider(fragment, factory).get(ShareGiftViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(ShareGiftViewModel::class) + fun provideViewModel( + router: GiftRouter, + payload: ShareGiftPayload, + shareGiftInteractor: ShareGiftInteractor, + chainRegistry: ChainRegistry, + packingGiftAnimationFactory: PackingGiftAnimationFactory, + assetIconProvider: AssetIconProvider, + tokenFormatter: TokenFormatter, + claimGiftDeepLinkConfigurator: ClaimGiftDeepLinkConfigurator, + fileProvider: FileProvider, + claimGiftMixinFactory: ClaimGiftMixinFactory, + claimGiftInteractor: ClaimGiftInteractor, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + selectedAccountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager + ): ViewModel { + return ShareGiftViewModel( + router = router, + payload = payload, + shareGiftInteractor = shareGiftInteractor, + chainRegistry = chainRegistry, + packingGiftAnimationFactory = packingGiftAnimationFactory, + assetIconProvider = assetIconProvider, + tokenFormatter = tokenFormatter, + claimGiftDeepLinkConfigurator = claimGiftDeepLinkConfigurator, + fileProvider = fileProvider, + claimGiftMixinFactory = claimGiftMixinFactory, + claimGiftInteractor = claimGiftInteractor, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + selectedAccountUseCase = selectedAccountUseCase, + resourceManager = resourceManager + ) + } +} diff --git a/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/model/GiftAmountModel.kt b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/model/GiftAmountModel.kt new file mode 100644 index 0000000..500590d --- /dev/null +++ b/feature-gift-impl/src/main/java/io/novafoundation/nova/feature_gift_impl/presentation/share/model/GiftAmountModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_gift_impl.presentation.share.model + +import io.novafoundation.nova.common.utils.images.Icon + +class GiftAmountModel( + val tokenIcon: Icon, + val amount: CharSequence +) diff --git a/feature-gift-impl/src/main/res/layout/fragment_claim_gift.xml b/feature-gift-impl/src/main/res/layout/fragment_claim_gift.xml new file mode 100644 index 0000000..01b7234 --- /dev/null +++ b/feature-gift-impl/src/main/res/layout/fragment_claim_gift.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-gift-impl/src/main/res/layout/fragment_create_gift_confirm.xml b/feature-gift-impl/src/main/res/layout/fragment_create_gift_confirm.xml new file mode 100644 index 0000000..70fa3fa --- /dev/null +++ b/feature-gift-impl/src/main/res/layout/fragment_create_gift_confirm.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-gift-impl/src/main/res/layout/fragment_gifts.xml b/feature-gift-impl/src/main/res/layout/fragment_gifts.xml new file mode 100644 index 0000000..cbd32da --- /dev/null +++ b/feature-gift-impl/src/main/res/layout/fragment_gifts.xml @@ -0,0 +1,38 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-gift-impl/src/main/res/layout/fragment_select_gift_amount.xml b/feature-gift-impl/src/main/res/layout/fragment_select_gift_amount.xml new file mode 100644 index 0000000..42e92ee --- /dev/null +++ b/feature-gift-impl/src/main/res/layout/fragment_select_gift_amount.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-gift-impl/src/main/res/layout/fragment_share_gift.xml b/feature-gift-impl/src/main/res/layout/fragment_share_gift.xml new file mode 100644 index 0000000..b30d3d3 --- /dev/null +++ b/feature-gift-impl/src/main/res/layout/fragment_share_gift.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-gift-impl/src/main/res/layout/item_gift.xml b/feature-gift-impl/src/main/res/layout/item_gift.xml new file mode 100644 index 0000000..f1a1cd8 --- /dev/null +++ b/feature-gift-impl/src/main/res/layout/item_gift.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-gift-impl/src/main/res/layout/item_gifts_header.xml b/feature-gift-impl/src/main/res/layout/item_gifts_header.xml new file mode 100644 index 0000000..1b4420d --- /dev/null +++ b/feature-gift-impl/src/main/res/layout/item_gifts_header.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-gift-impl/src/main/res/layout/item_gifts_instruction_placeholder.xml b/feature-gift-impl/src/main/res/layout/item_gifts_instruction_placeholder.xml new file mode 100644 index 0000000..660fe7d --- /dev/null +++ b/feature-gift-impl/src/main/res/layout/item_gifts_instruction_placeholder.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-gift-impl/src/main/res/layout/item_gifts_title.xml b/feature-gift-impl/src/main/res/layout/item_gifts_title.xml new file mode 100644 index 0000000..7e1a743 --- /dev/null +++ b/feature-gift-impl/src/main/res/layout/item_gifts_title.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/feature-governance-api/.gitignore b/feature-governance-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-governance-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-governance-api/build.gradle b/feature-governance-api/build.gradle new file mode 100644 index 0000000..39324dd --- /dev/null +++ b/feature-governance-api/build.gradle @@ -0,0 +1,32 @@ +apply plugin: 'kotlin-parcelize' + +android { + + defaultConfig { + + + } + + namespace 'io.novafoundation.nova.feature_governance_api' +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + implementation project(':feature-deep-linking') + + api project(":feature-wallet-api") + api project(":feature-account-api") + api project(":feature-dapp-api") + + implementation markwonDep + + implementation daggerDep + + implementation substrateSdkDep + + implementation androidDep + + api project(':core-api') +} \ No newline at end of file diff --git a/feature-governance-api/consumer-rules.pro b/feature-governance-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-governance-api/proguard-rules.pro b/feature-governance-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-governance-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-governance-api/src/main/AndroidManifest.xml b/feature-governance-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/feature-governance-api/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/MutableGovernanceState.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/MutableGovernanceState.kt new file mode 100644 index 0000000..269b312 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/MutableGovernanceState.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_governance_api.data + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface MutableGovernanceState { + fun update(chainId: String, assetId: Int, governanceType: Chain.Governance) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/model/TinderGovBasketItem.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/model/TinderGovBasketItem.kt new file mode 100644 index 0000000..c5e5854 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/model/TinderGovBasketItem.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_governance_api.data.model + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.constructAccountVote +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import java.math.BigInteger + +data class TinderGovBasketItem( + val metaId: Long, + val chainId: String, + val referendumId: ReferendumId, + val voteType: VoteType, + val conviction: Conviction, + val amount: BigInteger +) + +fun TinderGovBasketItem.accountVote(): AccountVote { + return AccountVote.constructAccountVote(amount, conviction, voteType) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/model/VotingPower.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/model/VotingPower.kt new file mode 100644 index 0000000..50a5aca --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/model/VotingPower.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_governance_api.data.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import java.math.BigInteger + +class VotingPower( + val metaId: Long, + val chainId: ChainId, + val amount: BigInteger, + val conviction: Conviction +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/Delegation.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/Delegation.kt new file mode 100644 index 0000000..c412e22 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/Delegation.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novasama.substrate_sdk_android.runtime.AccountId + +data class Delegation( + val vote: Vote, + val delegator: AccountId, + val delegate: AccountId, +) { + + data class Vote( + val amount: Balance, + val conviction: Conviction + ) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/OnChainReferendum.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/OnChainReferendum.kt new file mode 100644 index 0000000..d7e716d --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/OnChainReferendum.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import java.math.BigInteger + +@JvmInline +value class TrackId(val value: BigInteger) { + + override fun toString(): String { + return value.toString() + } +} + +@JvmInline +value class ReferendumId(val value: BigInteger) { + + override fun toString(): String { + return value.toString() + } +} + +class OnChainReferendum( + val status: OnChainReferendumStatus, + val id: ReferendumId, +) + +sealed class OnChainReferendumStatus { + + class Ongoing( + val track: TrackId, + val proposal: Proposal, + val submitted: BlockNumber, + val submissionDeposit: ReferendumDeposit?, + val decisionDeposit: ReferendumDeposit?, + val deciding: DecidingStatus?, + val tally: Tally, + val inQueue: Boolean, + val threshold: VotingThreshold + ) : OnChainReferendumStatus() + + class Approved(override val since: BlockNumber) : OnChainReferendumStatus(), TimeSinceStatus + + class Rejected(override val since: BlockNumber) : OnChainReferendumStatus(), TimeSinceStatus + + class Cancelled(override val since: BlockNumber) : OnChainReferendumStatus(), TimeSinceStatus + + class TimedOut(override val since: BlockNumber) : OnChainReferendumStatus(), TimeSinceStatus + + class Killed(override val since: BlockNumber) : OnChainReferendumStatus(), TimeSinceStatus + + interface TimeSinceStatus { + val since: BlockNumber + } +} + +sealed class Proposal { + + class Legacy(val hash: ByteArray) : Proposal() + + class Inline(val encodedCall: ByteArray, val call: GenericCall.Instance) : Proposal() + + class Lookup(val hash: ByteArray, val callLength: BigInteger) : Proposal() +} + +class DecidingStatus( + val since: BlockNumber, + val confirming: ConfirmingSource +) + +sealed class ConfirmingSource { + + class FromThreshold(val end: BlockNumber) : ConfirmingSource() + + class OnChain(val status: ConfirmingStatus?) : ConfirmingSource() +} + +class ConfirmingStatus(val till: BlockNumber) + +class Tally( + // post-conviction + val ayes: Balance, + // post-conviction + val nays: Balance, + // pre-conviction + val support: Balance +) + +class ReferendumDeposit( + val who: AccountId, + val amount: Balance +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/OnChainReferendumExt.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/OnChainReferendumExt.kt new file mode 100644 index 0000000..b8cd0aa --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/OnChainReferendumExt.kt @@ -0,0 +1,149 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.divideToDecimal +import io.novafoundation.nova.common.utils.filterValuesIsInstance +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumVoting.Approval +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 +import io.novasama.substrate_sdk_android.runtime.AccountId + +fun OnChainReferendum.isFinished(): Boolean { + return status !is OnChainReferendumStatus.Ongoing +} + +fun OnChainReferendum.proposal(): Proposal? { + return status.asOngoingOrNull()?.proposal +} + +fun Proposal.hash(): ByteArray { + return when (this) { + is Proposal.Inline -> encodedCall.blake2b256() + is Proposal.Legacy -> hash + is Proposal.Lookup -> hash + } +} + +fun OnChainReferendum.track(): TrackId? { + return status.asOngoingOrNull()?.track +} + +fun OnChainReferendum.submissionDeposit(): ReferendumDeposit? { + return when (status) { + is OnChainReferendumStatus.Ongoing -> status.submissionDeposit + else -> null + } +} + +fun OnChainReferendumStatus.inQueue(): Boolean { + return this is OnChainReferendumStatus.Ongoing && inQueue +} + +fun OnChainReferendumStatus.asOngoing(): OnChainReferendumStatus.Ongoing { + return asOngoingOrNull() ?: error("Referendum is not ongoing") +} + +fun OnChainReferendumStatus.sinceOrThrow(): BlockNumber { + return asTimeSinceStatusOrNull()?.since ?: error("Status doesn't have since field") +} + +fun OnChainReferendumStatus.asOngoingOrNull(): OnChainReferendumStatus.Ongoing? { + return castOrNull() +} + +fun OnChainReferendumStatus.asTimeSinceStatusOrNull(): OnChainReferendumStatus.TimeSinceStatus? { + return castOrNull() +} + +fun Tally.ayeVotes(): Approval.Votes { + return votesOf(Tally::ayes) +} + +fun Tally.nayVotes(): Approval.Votes { + return votesOf(Tally::nays) +} + +fun Map.flattenCastingVotes(): Map { + return flatMap { (_, voting) -> + when (voting) { + is Voting.Casting -> voting.votes.toList() + is Voting.Delegating -> emptyList() + } + }.toMap() +} + +fun Map.delegations(to: AccountId? = null): Map { + val onlyDelegations = filterValuesIsInstance() + + return if (to != null) { + onlyDelegations.filterValues { it.target.contentEquals(to) } + } else { + onlyDelegations + } +} + +val OnChainReferendumStatus.Ongoing.proposer: AccountId? + get() = submissionDeposit?.who + +fun OnChainReferendumStatus.Ongoing.proposerDeposit(): Balance? { + return proposer?.let(::depositBy) +} + +fun OnChainReferendumStatus.Ongoing.depositBy(accountId: AccountId): Balance { + return submissionDeposit.amountBy(accountId) + decisionDeposit.amountBy(accountId) +} + +fun ReferendumDeposit?.amountBy(accountId: AccountId): Balance { + if (this == null) return Balance.ZERO + + return amount.takeIf { who.contentEquals(accountId) }.orZero() +} + +@Suppress("FunctionName") +private fun EmptyVotes() = Approval.Votes( + amount = Balance.ZERO, + fraction = Perbill.ZERO +) + +private inline fun Tally.votesOf(field: (Tally) -> Balance): Approval.Votes { + val totalVotes = ayes + nays + + if (totalVotes == Balance.ZERO) return EmptyVotes() + + val amount = field(this) + val fraction = amount.divideToDecimal(totalVotes) + + return Approval.Votes( + amount = amount, + fraction = fraction + ) +} + +fun Set.toTrackIds(): Set { + return mapToSet { TrackId(it) } +} + +fun Set.fromTrackIds(): Set { + return mapToSet { it.value } +} + +fun ConfirmingSource.asOnChain(): ConfirmingSource.OnChain { + return this as ConfirmingSource.OnChain +} + +fun ConfirmingSource.asOnChainOrNull(): ConfirmingSource.OnChain? { + return this as? ConfirmingSource.OnChain +} + +fun ConfirmingSource.till(): BlockNumber { + return asOnChain().status!!.till +} + +fun ConfirmingSource.tillOrNull(): BlockNumber? { + return asOnChainOrNull()?.status?.till +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/PreImage.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/PreImage.kt new file mode 100644 index 0000000..13de60f --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/PreImage.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class PreImage(val encodedCall: ByteArray, val call: GenericCall.Instance) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/ReferendumVoter.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/ReferendumVoter.kt new file mode 100644 index 0000000..b1fcaa1 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/ReferendumVoter.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +import io.novasama.substrate_sdk_android.runtime.AccountId + +class ReferendumVoter( + val accountId: AccountId, + val vote: AccountVote, + val delegators: List +) + +fun ReferendumVoter.getAllAccountIds(): List { + return buildList { + add(accountId) + addAll(delegators.map { it.delegator }) + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/TrackInfo.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/TrackInfo.kt new file mode 100644 index 0000000..3889f8f --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/TrackInfo.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill + +data class TrackInfo( + val id: TrackId, + val name: String, + val preparePeriod: BlockNumber, + val decisionPeriod: BlockNumber, + val confirmPeriod: BlockNumber, + val minApproval: VotingCurve?, + val minSupport: VotingCurve? +) + +interface VotingCurve { + + val name: String + + fun threshold(x: Perbill): Perbill + + fun delay(y: Perbill): Perbill +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/TrackQueue.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/TrackQueue.kt new file mode 100644 index 0000000..6dbeeec --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/TrackQueue.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +class TrackQueue(val referenda: List) { + + class Position(val index: Int, val maxSize: Int) + + companion object; +} + +fun TrackQueue.Companion.empty(): TrackQueue = TrackQueue(emptyList()) + +fun TrackQueue.positionOf(referendumId: ReferendumId): TrackQueue.Position { + return TrackQueue.Position( + index = referenda.indexOf(referendumId) + 1, + maxSize = referenda.size + ) +} + +fun TrackQueue?.orEmpty(): TrackQueue { + return this ?: TrackQueue.empty() +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/TreasuryProposal.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/TreasuryProposal.kt new file mode 100644 index 0000000..26ed613 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/TreasuryProposal.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +class TreasuryProposal( + val id: Id, + val proposer: AccountId, + val amount: Balance, + val beneficiary: AccountId, + val bond: Balance +) { + + @JvmInline + value class Id(val value: BigInteger) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/Voting.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/Voting.kt new file mode 100644 index 0000000..9aa50bf --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/Voting.kt @@ -0,0 +1,255 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.GenericVoter +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Vote +import io.novasama.substrate_sdk_android.hash.isPositive +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigDecimal +import java.math.BigInteger + +sealed class Voting { + + data class Casting( + val votes: Map, + val prior: PriorLock + ) : Voting() + + class Delegating( + val amount: Balance, + val target: AccountId, + val conviction: Conviction, + val prior: PriorLock + ) : Voting() +} + +sealed class AccountVote { + + data class Standard( + val vote: Vote, + val balance: Balance + ) : AccountVote() + + class Split( + val aye: Balance, + val nay: Balance, + ) : AccountVote() + + class SplitAbstain( + val aye: Balance, + val nay: Balance, + val abstain: Balance, + ) : AccountVote() + + object Unsupported : AccountVote() + + companion object +} + +data class PriorLock( + val unlockAt: BlockNumber, + val amount: Balance, +) + +enum class VoteType { + AYE, NAY, ABSTAIN +} + +fun VoteType.isAye(): Boolean { + return this == VoteType.AYE +} + +fun Voting.trackVotesNumber(): Int { + return when (this) { + is Voting.Casting -> votes.size + is Voting.Delegating -> 0 + } +} + +fun Voting.votedReferenda(): Collection { + return when (this) { + is Voting.Casting -> votes.keys + is Voting.Delegating -> emptyList() + } +} + +fun AyeVote(amount: Balance, conviction: Conviction) = AccountVote.Standard( + vote = Vote( + aye = true, + conviction = conviction + ), + balance = amount +) + +fun AccountVote.amount(): Balance { + return when (this) { + is AccountVote.Standard -> balance + is AccountVote.Split -> aye + nay + is AccountVote.SplitAbstain -> aye + nay + abstain + AccountVote.Unsupported -> Balance.ZERO + } +} + +fun AccountVote.conviction(): Conviction? { + return when (this) { + is AccountVote.Standard -> vote.conviction + is AccountVote.Split -> Conviction.None + is AccountVote.SplitAbstain -> Conviction.None + AccountVote.Unsupported -> null + } +} + +fun AccountVote.votedFor(type: VoteType): Boolean { + return when (this) { + // we still want to show zero votes since it might have delegators + is AccountVote.Standard -> voteType == type + + is AccountVote.Split -> hasPositiveAmountFor(type) + + is AccountVote.SplitAbstain -> hasPositiveAmountFor(type) + + AccountVote.Unsupported -> false + } +} + +fun AccountVote.hasPositiveAmountFor(type: VoteType): Boolean { + val amount = amountFor(type) + + return amount != null && amount.isPositive() +} + +fun AccountVote.amountFor(type: VoteType): Balance? { + return when (this) { + is AccountVote.Standard -> { + if (voteType == type) balance else Balance.ZERO + } + + is AccountVote.Split -> when (type) { + VoteType.AYE -> aye + VoteType.NAY -> nay + VoteType.ABSTAIN -> Balance.ZERO + } + + is AccountVote.SplitAbstain -> when (type) { + VoteType.AYE -> aye + VoteType.NAY -> nay + VoteType.ABSTAIN -> abstain + } + + AccountVote.Unsupported -> null + } +} + +private val AccountVote.Standard.voteType: VoteType + get() = if (vote.aye) VoteType.AYE else VoteType.NAY + +fun Voting.votes(): Map { + return when (this) { + is Voting.Casting -> votes + is Voting.Delegating -> emptyMap() + } +} + +fun Voting.totalLock(): Balance { + return when (this) { + is Voting.Casting -> { + val fromVotes = votes.maxOfOrNull { it.value.amount() }.orZero() + + fromVotes.max(prior.amount) + } + + is Voting.Delegating -> amount.max(prior.amount) + } +} + +fun AccountVote.completedReferendumLockDuration(referendumOutcome: VoteType, lockPeriod: BlockNumber): BlockNumber { + return when (this) { + AccountVote.Unsupported -> BlockNumber.ZERO + + is AccountVote.Standard -> { + val approved = referendumOutcome == VoteType.AYE + + // vote has the same direction as outcome + if (approved == vote.aye) { + vote.conviction.lockDuration(lockPeriod) + } else { + BlockNumber.ZERO + } + } + + is AccountVote.Split -> BlockNumber.ZERO + is AccountVote.SplitAbstain -> BlockNumber.ZERO + } +} + +fun AccountVote.maxLockDuration(lockPeriod: BlockNumber): BlockNumber { + return when (this) { + is AccountVote.Standard -> vote.conviction.lockDuration(lockPeriod) + AccountVote.Unsupported -> BlockNumber.ZERO + is AccountVote.Split -> BlockNumber.ZERO + is AccountVote.SplitAbstain -> BlockNumber.ZERO + } +} + +fun Conviction.lockDuration(lockPeriod: BlockNumber): BlockNumber { + return lockPeriods() * lockPeriod +} + +fun Conviction.lockPeriods(): BigInteger { + val multiplier = when (this) { + Conviction.None -> 0 + Conviction.Locked1x -> 1 + Conviction.Locked2x -> 2 + Conviction.Locked3x -> 4 + Conviction.Locked4x -> 8 + Conviction.Locked5x -> 16 + Conviction.Locked6x -> 32 + } + + return multiplier.toBigInteger() +} + +fun Conviction.votesFor(amount: BigDecimal): BigDecimal { + return amountMultiplier() * amount +} + +fun Conviction.amountMultiplier(): BigDecimal { + val multiplier: Double = when (this) { + Conviction.None -> 0.1 + Conviction.Locked1x -> 1.0 + Conviction.Locked2x -> 2.0 + Conviction.Locked3x -> 3.0 + Conviction.Locked4x -> 4.0 + Conviction.Locked5x -> 5.0 + Conviction.Locked6x -> 6.0 + } + + return multiplier.toBigDecimal() +} + +fun Voting.Delegating.getConvictionVote(chainAsset: Chain.Asset): GenericVoter.ConvictionVote { + return GenericVoter.ConvictionVote(chainAsset.amountFromPlanks(amount), conviction) +} + +fun AccountVote.Companion.constructAccountVote(amount: BigInteger, conviction: Conviction, voteType: VoteType): AccountVote { + return if (voteType == VoteType.ABSTAIN) { + AccountVote.SplitAbstain( + aye = BigInteger.ZERO, + nay = BigInteger.ZERO, + abstain = amount + ) + } else { + AccountVote.Standard( + vote = Vote( + aye = voteType == VoteType.AYE, + conviction = conviction + ), + balance = amount + ) + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/VotingThreshold.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/VotingThreshold.kt new file mode 100644 index 0000000..1b69eae --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/blockhain/model/VotingThreshold.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_governance_api.data.network.blockhain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigDecimal + +interface VotingThreshold { + + class Threshold(val value: T, val currentlyPassing: Boolean, val projectedPassing: ProjectedPassing) { + companion object; + } + + class ProjectedPassing(val delayFraction: Perbill, val passingInFuture: Boolean) + + fun supportThreshold(tally: Tally, totalIssuance: Balance, passedSinceDecidingFraction: Perbill): Threshold + + fun ayesFractionThreshold(tally: Tally, totalIssuance: Balance, passedSinceDecidingFraction: Perbill): Threshold +} + +fun VotingThreshold.Threshold.Companion.simple(value: BigDecimal, currentlyPassing: Boolean) = + VotingThreshold.Threshold(value, currentlyPassing = true, VotingThreshold.ProjectedPassing(value, currentlyPassing)) + +fun VotingThreshold.Threshold.Companion.passing(value: T) = + VotingThreshold.Threshold(value, currentlyPassing = true, VotingThreshold.ProjectedPassing(BigDecimal.ZERO, passingInFuture = true)) + +fun VotingThreshold.Threshold.Companion.notPassing(value: T) = + VotingThreshold.Threshold(value, currentlyPassing = false, VotingThreshold.ProjectedPassing(BigDecimal.ONE, passingInFuture = false)) + +fun VotingThreshold.ProjectedPassing.merge(another: VotingThreshold.ProjectedPassing): VotingThreshold.ProjectedPassing { + return VotingThreshold.ProjectedPassing( + delayFraction = delayFraction.coerceAtLeast(another.delayFraction), + passingInFuture = passingInFuture && another.passingInFuture + ) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/delegation/DelegateDetailedStats.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/delegation/DelegateDetailedStats.kt new file mode 100644 index 0000000..3cb8573 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/delegation/DelegateDetailedStats.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId + +class DelegateDetailedStats( + val accountId: AccountId, + val delegationsCount: Int, + val delegatedVotes: Balance, + val recentVotes: Int, + val allVotes: Int +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/delegation/DelegateMetadata.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/delegation/DelegateMetadata.kt new file mode 100644 index 0000000..ffa3ff1 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/delegation/DelegateMetadata.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation + +import io.novasama.substrate_sdk_android.runtime.AccountId + +class DelegateMetadata( + val accountId: AccountId, + val shortDescription: String, + val longDescription: String?, + val profileImageUrl: String?, + val isOrganization: Boolean, + val name: String, +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/delegation/DelegateStats.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/delegation/DelegateStats.kt new file mode 100644 index 0000000..4608c57 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/delegation/DelegateStats.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId + +class DelegateStats( + val accountId: AccountId, + val delegationsCount: Int, + val delegatedVotes: Balance, + val recentVotes: Int +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/referendum/OffChainReferendumDetails.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/referendum/OffChainReferendumDetails.kt new file mode 100644 index 0000000..5cb241f --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/referendum/OffChainReferendumDetails.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum + +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline + +class OffChainReferendumDetails( + val title: String?, + val description: String?, + val proposerName: String?, + val proposerAddress: String?, + val timeLine: List? +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/referendum/OffChainReferendumPreview.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/referendum/OffChainReferendumPreview.kt new file mode 100644 index 0000000..97c4bb5 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/referendum/OffChainReferendumPreview.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId + +class OffChainReferendumPreview( + val title: String?, + val referendumId: ReferendumId +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/referendum/OffChainReferendumVotingDetails.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/referendum/OffChainReferendumVotingDetails.kt new file mode 100644 index 0000000..0201614 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/referendum/OffChainReferendumVotingDetails.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Tally +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumVotingDetails.VotingInfo +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigDecimal + +class OffChainReferendumVotingDetails( + val trackId: TrackId, + val votingInfo: VotingInfo +) { + + sealed interface VotingInfo { + + class Abstain(val abstain: BigDecimal) : VotingInfo + + class Full( + val aye: BigDecimal, + val nay: BigDecimal, + val abstain: BigDecimal, + val support: Balance + ) : VotingInfo { + companion object + } + } +} + +fun VotingInfo.Full.Companion.empty() = VotingInfo.Full( + aye = BigDecimal.ZERO, + nay = BigDecimal.ZERO, + abstain = BigDecimal.ZERO, + support = Balance.ZERO +) + +operator fun VotingInfo.Full.plus(other: VotingInfo.Full): VotingInfo.Full { + return VotingInfo.Full( + aye = this.aye + other.aye, + nay = this.nay + other.nay, + abstain = this.abstain + other.abstain, + support = this.support + other.support + ) +} + +fun VotingInfo.Full.toTally(): Tally = Tally( + ayes = this.aye.toBigInteger(), + nays = this.nay.toBigInteger(), + support = this.support +) + +fun VotingInfo.getAbstain(): BigDecimal { + return when (this) { + is VotingInfo.Abstain -> this.abstain + is VotingInfo.Full -> this.abstain + } +} + +fun VotingInfo.toTallyOrNull(): Tally? { + return when (this) { + is VotingInfo.Full -> toTally() + is VotingInfo.Abstain -> null + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/vote/UserVote.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/vote/UserVote.kt new file mode 100644 index 0000000..5e706bb --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/network/offchain/model/vote/UserVote.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_api.data.network.offchain.model.vote + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novasama.substrate_sdk_android.runtime.AccountId + +sealed class UserVote { + + class Direct(val vote: AccountVote) : UserVote() + + class Delegated(val delegate: AccountId, val vote: AccountVote) : UserVote() +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/ConvictionVotingRepository.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/ConvictionVotingRepository.kt new file mode 100644 index 0000000..e9cdec1 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/ConvictionVotingRepository.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_governance_api.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumVoter +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.delegations +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumVotingDetails +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +interface ConvictionVotingRepository { + + val voteLockId: BalanceLockId + + suspend fun maxAvailableForVote(asset: Asset): Balance + + suspend fun voteLockingPeriod(chainId: ChainId): BlockNumber + + suspend fun maxTrackVotes(chainId: ChainId): BigInteger + + fun trackLocksFlow(accountId: AccountId, chainAssetId: FullChainAssetId): Flow> + + suspend fun votingFor(accountId: AccountId, chainId: ChainId): Map + + suspend fun votingFor(accountId: AccountId, chainId: ChainId, trackId: TrackId): Voting? + + suspend fun votingFor(accountId: AccountId, chainId: ChainId, trackIds: Collection): Map + + suspend fun votersOf(referendumId: ReferendumId, chain: Chain, type: VoteType): List + + suspend fun delegatingFor(accountId: AccountId, chainId: ChainId): Map { + return votingFor(accountId, chainId).delegations() + } + + fun ExtrinsicBuilder.unlock(accountId: AccountId, claimable: ClaimSchedule.UnlockChunk.Claimable) + + fun ExtrinsicBuilder.vote(referendumId: ReferendumId, vote: AccountVote) + + fun CallBuilder.vote(referendumId: ReferendumId, vote: AccountVote) + fun ExtrinsicBuilder.removeVote(trackId: TrackId, referendumId: ReferendumId) + + fun isAbstainVotingAvailable(): Boolean + + suspend fun abstainVotingDetails(referendumId: ReferendumId, chain: Chain): OffChainReferendumVotingDetails? + + suspend fun fullVotingDetails(referendumId: ReferendumId, chain: Chain): OffChainReferendumVotingDetails? +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/DelegationsRepository.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/DelegationsRepository.kt new file mode 100644 index 0000000..7214f15 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/DelegationsRepository.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_governance_api.data.repository + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Delegation +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateDetailedStats +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateMetadata +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateStats +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.vote.UserVote +import io.novafoundation.nova.feature_governance_api.data.repository.common.RecentVotesDateThreshold +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface DelegationsRepository { + + suspend fun isDelegationSupported(chain: Chain): Boolean + + suspend fun getDelegatesStats( + recentVotesDateThreshold: RecentVotesDateThreshold, + chain: Chain + ): List + + suspend fun getDelegatesStatsByAccountIds( + recentVotesDateThreshold: RecentVotesDateThreshold, + accountIds: List, + chain: Chain + ): List + + suspend fun getDetailedDelegateStats( + delegateAddress: String, + recentVotesDateThreshold: RecentVotesDateThreshold, + chain: Chain, + ): DelegateDetailedStats? + + suspend fun getDelegatesMetadata(chain: Chain): List + + suspend fun getDelegateMetadata(chain: Chain, delegate: AccountId): DelegateMetadata? + + suspend fun getDelegationsTo(delegate: AccountId, chain: Chain): List + + suspend fun allHistoricalVotesOf(user: AccountId, chain: Chain): Map? + + suspend fun historicalVoteOf(user: AccountId, referendumId: ReferendumId, chain: Chain): UserVote? + + suspend fun directHistoricalVotesOf( + user: AccountId, + chain: Chain, + recentVotesDateThreshold: RecentVotesDateThreshold? + ): Map? + + suspend fun CallBuilder.delegate( + delegate: AccountId, + trackId: TrackId, + amount: Balance, + conviction: Conviction + ) + + suspend fun CallBuilder.undelegate(trackId: TrackId) +} + +suspend fun DelegationsRepository.getDelegatesMetadataOrEmpty(chain: Chain): List { + return runCatching { getDelegatesMetadata(chain) } + .onFailure { Log.e(LOG_TAG, "Failed to fetch delegate metadatas", it) } + .getOrDefault(emptyList()) +} + +suspend fun DelegationsRepository.getDelegateMetadataOrNull(chain: Chain, delegate: AccountId): DelegateMetadata? { + return runCatching { getDelegateMetadata(chain, delegate) } + .onFailure { Log.e(LOG_TAG, "Failed to fetch delegate metadata", it) } + .getOrNull() +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/GovernanceDAppsRepository.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/GovernanceDAppsRepository.kt new file mode 100644 index 0000000..7863561 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/GovernanceDAppsRepository.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_api.data.repository + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDApp +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface GovernanceDAppsRepository { + + fun observeReferendumDApps(chainId: ChainId, referendumId: ReferendumId): Flow> +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/OffChainReferendaInfoRepository.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/OffChainReferendaInfoRepository.kt new file mode 100644 index 0000000..4f3abdf --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/OffChainReferendaInfoRepository.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_governance_api.data.repository + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumPreview +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface OffChainReferendaInfoRepository { + + suspend fun referendumPreviews(chain: Chain): List + + suspend fun referendumDetails(referendumId: ReferendumId, chain: Chain): OffChainReferendumDetails? +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/OnChainReferendaRepository.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/OnChainReferendaRepository.kt new file mode 100644 index 0000000..85f5818 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/OnChainReferendaRepository.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_governance_api.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackQueue +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface OnChainReferendaRepository { + + suspend fun electorate(chainId: ChainId): Balance + + suspend fun undecidingTimeout(chainId: ChainId): BlockNumber + + suspend fun getTracks(chainId: ChainId): Collection + + suspend fun getTrackQueues(trackIds: Set, chainId: ChainId): Map + + suspend fun getAllOnChainReferenda(chainId: ChainId): Collection + + suspend fun getOnChainReferenda(chainId: ChainId, referendaIds: Collection): Map + + suspend fun onChainReferendumFlow(chainId: ChainId, referendumId: ReferendumId): Flow + + suspend fun getReferendaExecutionBlocks(chainId: ChainId, approvedReferendaIds: Collection): Map +} + +suspend fun OnChainReferendaRepository.getTracksById(chainId: ChainId): Map { + return getTracks(chainId).associateBy(TrackInfo::id) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/PreimageRepository.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/PreimageRepository.kt new file mode 100644 index 0000000..497e909 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/PreimageRepository.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_governance_api.data.repository + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PreImage +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Proposal +import io.novafoundation.nova.feature_governance_api.data.repository.PreImageRequest.FetchCondition.ALWAYS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.toHexString +import java.math.BigInteger + +typealias HexHash = String + +class PreImageRequest( + val hash: ByteArray, + val knownSize: BigInteger?, + val fetchIf: FetchCondition, +) { + + val hashHex: HexHash = hash.toHexString() + + enum class FetchCondition { + ALWAYS, SMALL_SIZE + } +} + +interface PreImageRepository { + + suspend fun getPreimageFor(request: PreImageRequest, chainId: ChainId): PreImage? + + suspend fun getPreimagesFor(requests: Collection, chainId: ChainId): Map +} + +suspend fun PreImageRepository.preImageOf( + proposal: Proposal?, + chainId: ChainId, +): PreImage? { + return when (proposal) { + is Proposal.Inline -> { + PreImage(encodedCall = proposal.encodedCall, call = proposal.call) + } + + is Proposal.Legacy -> { + val request = PreImageRequest(proposal.hash, knownSize = null, fetchIf = ALWAYS) + getPreimageFor(request, chainId) + } + + is Proposal.Lookup -> { + val request = PreImageRequest(proposal.hash, knownSize = proposal.callLength, fetchIf = ALWAYS) + getPreimageFor(request, chainId) + } + + null -> null + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/TreasuryRepository.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/TreasuryRepository.kt new file mode 100644 index 0000000..cf82eb1 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/TreasuryRepository.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_api.data.repository + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TreasuryProposal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface TreasuryRepository { + + suspend fun getTreasuryProposal(chainId: ChainId, id: TreasuryProposal.Id): TreasuryProposal? +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/common/RecentVotesDateThreshold.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/common/RecentVotesDateThreshold.kt new file mode 100644 index 0000000..6d3b6ee --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/repository/common/RecentVotesDateThreshold.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_governance_api.data.repository.common + +import java.math.BigInteger + +sealed interface RecentVotesDateThreshold { + + companion object; + + class BlockNumber(val number: BigInteger) : RecentVotesDateThreshold + class Timestamp(val timestampMs: Long) : RecentVotesDateThreshold +} + +fun RecentVotesDateThreshold.Companion.zeroPoint() = RecentVotesDateThreshold.BlockNumber(BigInteger.ZERO) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/source/GovernanceSource.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/source/GovernanceSource.kt new file mode 100644 index 0000000..b3d7260 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/source/GovernanceSource.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_governance_api.data.source + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.repository.ConvictionVotingRepository +import io.novafoundation.nova.feature_governance_api.data.repository.DelegationsRepository +import io.novafoundation.nova.feature_governance_api.data.repository.GovernanceDAppsRepository +import io.novafoundation.nova.feature_governance_api.data.repository.OffChainReferendaInfoRepository +import io.novafoundation.nova.feature_governance_api.data.repository.OnChainReferendaRepository +import io.novafoundation.nova.feature_governance_api.data.repository.PreImageRepository +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +interface GovernanceSource { + + val referenda: OnChainReferendaRepository + + val convictionVoting: ConvictionVotingRepository + + val offChainInfo: OffChainReferendaInfoRepository + + val dappsRepository: GovernanceDAppsRepository + + val preImageRepository: PreImageRepository + + val delegationsRepository: DelegationsRepository +} + +fun ConvictionVotingRepository.trackLocksFlowOrEmpty(voterAccountId: AccountId?, chainAssetId: FullChainAssetId): Flow> { + return if (voterAccountId != null) { + trackLocksFlow(voterAccountId, chainAssetId) + } else { + flowOf(emptyMap()) + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/source/GovernanceSourceRegistry.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/source/GovernanceSourceRegistry.kt new file mode 100644 index 0000000..1269414 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/source/GovernanceSourceRegistry.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_api.data.source + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.SelectableAssetAdditionalData +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState + +interface GovernanceSourceRegistry { + + suspend fun sourceFor(option: SupportedGovernanceOption): GovernanceSource + + suspend fun sourceFor(option: Chain.Governance): GovernanceSource +} + +typealias SupportedGovernanceOption = SelectedAssetOptionSharedState.SupportedAssetOption + +interface GovernanceAdditionalState : SelectableAssetAdditionalData { + + val governanceType: Chain.Governance +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov1/Gov1VotingThreshold.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov1/Gov1VotingThreshold.kt new file mode 100644 index 0000000..de2c0b6 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov1/Gov1VotingThreshold.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_governance_api.data.thresold.gov1 + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.utils.divideToDecimal +import io.novafoundation.nova.common.utils.intSqrt +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Tally +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingThreshold +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingThreshold.Threshold +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ayeVotes +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.notPassing +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.simple +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.passing +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger + +enum class Gov1VotingThreshold(val readableName: String) : VotingThreshold { + + SUPER_MAJORITY_APPROVE("SimpleMajorityApprove") { + + // https://github.com/paritytech/substrate/blob/5ae005c244295d23586c93da43148b2bc826b137/frame/democracy/src/vote_threshold.rs#L101 + // nays / sqrt(turnout) < ayes / sqrt(total_issuance) => + // ayes > nays * sqrt(total_issuance) / sqrt(turnout) => + // ayes / (ayes + nays) > [nays / (ayes + nays)] * [sqrt(total_issuance) / sqrt(turnout)] + // let a = ayes / (ayes + nays), to = sqrt(total_issuance), tu = sqrt(turnout) + // a > (1 - a) * to / tu + // a > to / tu - a * to / tu => a > (to / tu) / (1 + to / tu) + // a > to / (tu + to) + override fun ayesFractionThreshold(tally: Tally, totalIssuance: Balance, passedSinceDecidingFraction: Perbill): Threshold { + if (totalIssuance == Balance.ZERO || tally.support == Balance.ZERO) return Threshold.notPassing(Perbill.ONE) + + val sqrtTurnout = tally.support.intSqrt() + val sqrtTotalIssuance = totalIssuance.intSqrt() + + val aysFraction = tally.ayeVotes().fraction + + val threshold = sqrtTotalIssuance.divideToDecimal(sqrtTurnout + sqrtTotalIssuance) + + return Threshold.simple( + value = threshold, + currentlyPassing = aysFraction > threshold + ) + } + }, + + SUPER_MAJORITY_AGAINST("SimpleMajority") { + + // https://github.com/paritytech/substrate/blob/5ae005c244295d23586c93da43148b2bc826b137/frame/democracy/src/vote_threshold.rs#L103 + // nays / sqrt(total_issuance) < ayes / sqrt(turnout) => + // ayes > nays * sqrt(turnout) / sqrt(total_issuance) => + // ayes / (ayes + nays) > [nays / (ayes + nays)] * [sqrt(turnout) / sqrt(total_issuance)] + // let a = ayes / (ayes + nays), to = sqrt(total_issuance), tu = sqrt(turnout) + // a > tu / (tu + to) + override fun ayesFractionThreshold(tally: Tally, totalIssuance: Balance, passedSinceDecidingFraction: Perbill): Threshold { + if (totalIssuance == Balance.ZERO || tally.support == Balance.ZERO) return Threshold.notPassing(Perbill.ONE) + + val sqrtTurnout = tally.support.intSqrt() + val sqrtTotalIssuance = totalIssuance.intSqrt() + + val aysFraction = tally.ayeVotes().fraction + + val threshold = sqrtTurnout.divideToDecimal(sqrtTurnout + sqrtTotalIssuance) + + return Threshold.simple( + value = threshold, + currentlyPassing = aysFraction > threshold + ) + } + }, + + SIMPLE_MAJORITY("SimpleMajority") { + + // https://github.com/paritytech/substrate/blob/5ae005c244295d23586c93da43148b2bc826b137/frame/democracy/src/vote_threshold.rs#L105 + // ayes > nays => + // ayes / (ayes + nays) > 0.5 + override fun ayesFractionThreshold(tally: Tally, totalIssuance: Balance, passedSinceDecidingFraction: Perbill): Threshold { + val threshold = 0.5.toBigDecimal() + + val aysFraction = tally.ayeVotes().fraction + + return Threshold.simple( + value = threshold, + currentlyPassing = aysFraction > threshold + ) + } + }; + + override fun supportThreshold(tally: Tally, totalIssuance: Balance, passedSinceDecidingFraction: Perbill): Threshold { + return Threshold.passing(BigInteger.ZERO) + } +} + +fun VotingThreshold.asGovV1VotingThresholdOrNull(): Gov1VotingThreshold? { + return this as? Gov1VotingThreshold +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/Gov2VotingThreshold.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/Gov2VotingThreshold.kt new file mode 100644 index 0000000..5fff9c6 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/Gov2VotingThreshold.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_governance_api.data.thresold.gov2 + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.utils.divideOrNull +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Tally +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingCurve +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingThreshold +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingThreshold.Threshold +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ayeVotes +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.MathContext + +class Gov2VotingThreshold( + val supportCurve: VotingCurve, + val approvalCurve: VotingCurve +) : VotingThreshold { + + override fun supportThreshold(tally: Tally, totalIssuance: Balance, passedSinceDecidingFraction: Perbill): Threshold { + val supportNeeded = supportCurve.threshold(passedSinceDecidingFraction) * totalIssuance.toBigDecimal() + val supportNeededIntegral = supportNeeded.toBigInteger() + + val currentSupport = tally.support.toBigDecimal() + val totalSupport = totalIssuance.toBigDecimal() + val supportFraction = currentSupport.divideOrNull(totalSupport, MathContext.DECIMAL64) ?: Perbill.ZERO + + return Threshold( + value = supportNeededIntegral, + currentlyPassing = tally.support >= supportNeededIntegral, + getProjectedPassing(supportCurve, supportFraction) + ) + } + + override fun ayesFractionThreshold(tally: Tally, totalIssuance: Balance, passedSinceDecidingFraction: Perbill): Threshold { + val approvalThreshold = approvalCurve.threshold(passedSinceDecidingFraction) + val ayeFraction = tally.ayeVotes().fraction + + return Threshold( + value = approvalThreshold, + currentlyPassing = ayeFraction >= approvalThreshold, + getProjectedPassing(approvalCurve, ayeFraction) + ) + } + + private fun getProjectedPassing(curve: VotingCurve, fraction: Perbill): VotingThreshold.ProjectedPassing { + val delay = curve.delay(fraction) + val threshold = curve.threshold(delay) + + return VotingThreshold.ProjectedPassing( + delayFraction = delay, + passingInFuture = fraction >= threshold + ) + } +} + +fun Gov2VotingThreshold(trackInfo: TrackInfo): Gov2VotingThreshold { + return Gov2VotingThreshold( + supportCurve = trackInfo.minSupport!!, + approvalCurve = trackInfo.minApproval!! + ) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/curve/LinearDecreasingCurve.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/curve/LinearDecreasingCurve.kt new file mode 100644 index 0000000..6fbb0a2 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/curve/LinearDecreasingCurve.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingCurve +import java.math.RoundingMode + +/** + * Linear curve starting at `(0, ceil)`, proceeding linearly to `(length, floor)`, then + * remaining at `floor` until the end of the period. + * + * @see Source + */ +class LinearDecreasingCurve( + private val length: Perbill, + private val floor: Perbill, + private val ceil: Perbill +) : VotingCurve { + + override val name: String = "LinearDecreasing" + + override fun threshold(x: Perbill): Perbill { + return ceil - x.coerceAtMost(length).divide(length, RoundingMode.DOWN) * (ceil - floor) + } + + override fun delay(y: Perbill): Perbill { + return when { + y < floor -> Perbill.ONE + y > ceil -> Perbill.ZERO + else -> (ceil - y).divide(ceil - floor, RoundingMode.UP).multiply(length) + } + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/curve/ReciprocalCurve.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/curve/ReciprocalCurve.kt new file mode 100644 index 0000000..7b0e91d --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/curve/ReciprocalCurve.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve + +import io.novafoundation.nova.common.data.network.runtime.binding.FixedI64 +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.utils.coerceInOrNull +import io.novafoundation.nova.common.utils.divideOrNull +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingCurve +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode + +/** + * A reciprocal (`K/(x+S)-T`) curve: `factor` is `K` and `xOffset` is `S`, `yOffset` is `T`. + * + * @see Source + */ +class ReciprocalCurve( + private val factor: FixedI64, + private val xOffset: FixedI64, + private val yOffset: FixedI64 +) : VotingCurve { + + override val name: String = "Reciprocal" + + override fun threshold(x: Perbill): Perbill { + return factor.divide(x + xOffset, MathContext(MathContext.DECIMAL64.precision, RoundingMode.DOWN)) + yOffset + } + + override fun delay(y: Perbill): Perbill { + val maybeTerm = factor.divideOrNull(y - yOffset, MathContext(MathContext.DECIMAL64.precision, RoundingMode.UP)) + + return maybeTerm?.let { it - xOffset } + ?.coerceInOrNull(BigDecimal.ZERO, BigDecimal.ONE) + ?: Perbill.ONE + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/curve/SteppedDecreasingCurve.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/curve/SteppedDecreasingCurve.kt new file mode 100644 index 0000000..6ad2975 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/data/thresold/gov2/curve/SteppedDecreasingCurve.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.utils.lessEpsilon +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingCurve + +/** + * Stepped curve, beginning at `(0, begin)`, then remaining constant for `period`, at which + * point it steps down to `(period, begin - step)`. It then remains constant for another + * `period` before stepping down to `(period * 2, begin - step * 2)`. This pattern continues + * but the `y` component has a lower limit of `end`. + * + * @see Source + */ +class SteppedDecreasingCurve( + private val begin: Perbill, + private val end: Perbill, + private val step: Perbill, + private val period: Perbill +) : VotingCurve { + + override val name: String = "SteppedDecreasing" + + override fun threshold(x: Perbill): Perbill { + val passedPeriods = x.divideToIntegralValue(period) + val decrease = passedPeriods * step + + return (begin - decrease).coerceIn(end, begin) + } + + override fun delay(y: Perbill): Perbill { + return when { + y < end -> Perbill.ONE + else -> period.multiply((begin - y.coerceAtMost(begin) + step.lessEpsilon()).divideToIntegralValue(step)) + } + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/di/GovernanceFeatureApi.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/di/GovernanceFeatureApi.kt new file mode 100644 index 0000000..247863d --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/di/GovernanceFeatureApi.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_governance_api.di + +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_governance_api.data.MutableGovernanceState +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.di.deeplinks.GovernanceDeepLinks +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.DelegateDelegatorsInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.DelegateDetailsInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.DelegateListInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetailsInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.ReferendumVotersInteractor +import io.novafoundation.nova.feature_governance_api.presentation.referenda.common.ReferendaStatusFormatter +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator + +interface GovernanceFeatureApi { + + val governanceSourceRegistry: GovernanceSourceRegistry + + val referendaListInteractor: ReferendaListInteractor + + val referendumDetailsInteractor: ReferendumDetailsInteractor + + val referendumVotersInteractor: ReferendumVotersInteractor + + val governanceUpdateSystem: UpdateSystem + + val delegateListInteractor: DelegateListInteractor + + val delegateDetailsInteractor: DelegateDetailsInteractor + + val newDelegationChooseTrackInteractor: ChooseTrackInteractor + + val delegateDelegatorsInteractor: DelegateDelegatorsInteractor + + val mutableGovernanceState: MutableGovernanceState + + val referendaStatusFormatter: ReferendaStatusFormatter + + val governanceDeepLinks: GovernanceDeepLinks + + val referendumDetailsDeepLinkConfigurator: ReferendumDetailsDeepLinkConfigurator +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/di/deeplinks/GovernanceDeepLinks.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/di/deeplinks/GovernanceDeepLinks.kt new file mode 100644 index 0000000..27153ae --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/di/deeplinks/GovernanceDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_api.di.deeplinks + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class GovernanceDeepLinks(val deepLinkHandlers: List) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/Delegate.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/Delegate.kt new file mode 100644 index 0000000..bfce12f --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/Delegate.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate + +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface Delegate { + + val accountId: AccountId + + val metadata: Metadata? + + val onChainIdentity: OnChainIdentity? + + interface Metadata { + + val name: String? + + val iconUrl: String? + + val accountType: DelegateAccountType + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/DelegateAccountType.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/DelegateAccountType.kt new file mode 100644 index 0000000..2dc1f7e --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/DelegateAccountType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate + +enum class DelegateAccountType { + INDIVIDUAL, ORGANIZATION +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/delegators/DelegateDelegatorsInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/delegators/DelegateDelegatorsInteractor.kt new file mode 100644 index 0000000..1173c81 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/delegators/DelegateDelegatorsInteractor.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators + +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.model.Delegator +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface DelegateDelegatorsInteractor { + + fun delegatorsFlow(delegateId: AccountId): Flow> +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/delegators/model/Delegator.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/delegators/model/Delegator.kt new file mode 100644 index 0000000..a38b2d5 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/delegators/model/Delegator.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.model + +import io.novafoundation.nova.common.utils.sumByBigDecimal +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Delegation +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.getConvictionVote +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.GenericVoter +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.GenericVoter.ConvictionVote +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigDecimal + +class Delegator( + override val accountId: AccountId, + override val identity: Identity?, + override val vote: Vote +) : GenericVoter { + + sealed class Vote(override val totalVotes: BigDecimal) : GenericVoter.Vote { + + class SingleTrack(val delegation: ConvictionVote) : Vote(delegation.totalVotes) + + class MultiTrack(val trackCount: Int, totalVotes: BigDecimal) : Vote(totalVotes) + } +} + +fun Delegator( + accountId: AccountId, + identity: Identity?, + delegatorTrackDelegations: List, + chainAsset: Chain.Asset, +): Delegator { + val vote = requireNotNull(DelegatorVote(delegatorTrackDelegations, chainAsset)) + + return Delegator(accountId, identity, vote) +} + +fun DelegatorVote(delegatorTrackDelegations: List, chainAsset: Chain.Asset): Delegator.Vote? { + val simpleVotes = delegatorTrackDelegations.map { + ConvictionVote(chainAsset.amountFromPlanks(it.amount), it.conviction) + } + + return DelegatorVote(simpleVotes) +} + +@JvmName("DelegatorVoteFromDelegating") +fun DelegatorVote(delegations: Collection, chainAsset: Chain.Asset): Delegator.Vote? { + val simpleVotes = delegations.map { it.getConvictionVote(chainAsset) } + + return DelegatorVote(simpleVotes) +} + +@JvmName("DelegatorVoteFromConvictionVote") +fun DelegatorVote(votes: Collection): Delegator.Vote? { + return when (votes.size) { + 0 -> null + 1 -> Delegator.Vote.SingleTrack(votes.single()) + else -> { + val totalVotes = votes.sumByBigDecimal(ConvictionVote::totalVotes) + Delegator.Vote.MultiTrack(trackCount = votes.size, totalVotes = totalVotes) + } + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/details/model/AddDelegationValidation.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/details/model/AddDelegationValidation.kt new file mode 100644 index 0000000..a0d0d1b --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/details/model/AddDelegationValidation.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.validation.NoChainAccountFoundError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +typealias AddDelegationValidationSystem = ValidationSystem + +sealed interface AddDelegationValidationFailure { + class NoChainAccountFailure( + override val chain: Chain, + override val account: MetaAccount, + override val addAccountState: NoChainAccountFoundError.AddAccountState + ) : AddDelegationValidationFailure, NoChainAccountFoundError +} + +data class AddDelegationValidationPayload( + val chain: Chain, + val metaAccount: MetaAccount +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/details/model/DelegateDetails.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/details/model/DelegateDetails.kt new file mode 100644 index 0000000..26d7bff --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/details/model/DelegateDetails.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model + +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.Delegate +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.DelegateAccountType +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId + +data class DelegateDetails( + override val accountId: AccountId, + val stats: Stats?, + override val metadata: Metadata?, + override val onChainIdentity: OnChainIdentity?, + val userDelegations: Map +) : Delegate { + + data class Metadata( + val shortDescription: String, + val longDescription: String?, + override val iconUrl: String?, + override val accountType: DelegateAccountType, + override val name: String? + ) : Delegate.Metadata + + data class Stats(val delegationsCount: Int, val delegatedVotes: Balance, val recentVotes: Int, val allVotes: Int) +} + +val DelegateDetails.Metadata.description: String + get() = longDescription ?: shortDescription diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/details/model/DelegateDetailsInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/details/model/DelegateDetailsInteractor.kt new file mode 100644 index 0000000..6c10360 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/details/model/DelegateDetailsInteractor.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model + +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface DelegateDetailsInteractor { + + fun delegateDetailsFlow( + delegateAccountId: AccountId, + ): Flow + + fun validationSystemFor(): AddDelegationValidationSystem +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/label/DelegateLabel.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/label/DelegateLabel.kt new file mode 100644 index 0000000..9f5162c --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/label/DelegateLabel.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label + +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.Delegate +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.DelegateAccountType +import io.novasama.substrate_sdk_android.runtime.AccountId + +class DelegateLabel( + override val accountId: AccountId, + override val metadata: Delegate.Metadata?, + override val onChainIdentity: OnChainIdentity? +) : Delegate { + + class Metadata( + override val name: String?, + override val iconUrl: String?, + override val accountType: DelegateAccountType + ) : Delegate.Metadata +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/label/DelegateLabelUseCase.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/label/DelegateLabelUseCase.kt new file mode 100644 index 0000000..1ddb9cb --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/label/DelegateLabelUseCase.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label + +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface DelegateLabelUseCase { + + suspend fun getDelegateLabel(delegate: AccountId): DelegateLabel +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/DelegateListInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/DelegateListInteractor.kt new file mode 100644 index 0000000..b9aea9a --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/DelegateListInteractor.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list + +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegateFiltering +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegatePreview +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegateSorting +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface DelegateListInteractor { + + fun shouldShowDelegationBanner(): Flow + + suspend fun hideDelegationBanner() + + suspend fun getDelegates( + governanceOption: SupportedGovernanceOption, + scope: CoroutineScope + ): Flow> + + suspend fun getUserDelegates( + governanceOption: SupportedGovernanceOption, + scope: CoroutineScope + ): Flow> + + suspend fun applySortingAndFiltering( + sorting: DelegateSorting, + filtering: DelegateFiltering, + delegates: List + ): List + + suspend fun applySorting( + sorting: DelegateSorting, + delegates: List + ): List +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/model/DelegateFiltering.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/model/DelegateFiltering.kt new file mode 100644 index 0000000..0af07d7 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/model/DelegateFiltering.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model + +import io.novafoundation.nova.common.utils.Filter +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.Delegate +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.DelegateAccountType + +enum class DelegateFiltering : Filter { + + ALL_ACCOUNTS { + override fun shouldInclude(model: Delegate): Boolean { + return true + } + }, + + ORGANIZATIONS { + override fun shouldInclude(model: Delegate): Boolean { + val accountType = model.metadata?.accountType ?: return false + + return accountType == DelegateAccountType.ORGANIZATION + } + }, + + INDIVIDUALS { + override fun shouldInclude(model: Delegate): Boolean { + val accountType = model.metadata?.accountType ?: return false + + return accountType == DelegateAccountType.INDIVIDUAL + } + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/model/DelegatePreview.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/model/DelegatePreview.kt new file mode 100644 index 0000000..4c1341e --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/model/DelegatePreview.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model + +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.Delegate +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.DelegateAccountType +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId + +data class DelegatePreview( + override val accountId: AccountId, + val stats: Stats?, + override val metadata: Metadata?, + override val onChainIdentity: OnChainIdentity?, + val userDelegations: Map +) : Delegate { + + data class Metadata( + val shortDescription: String, + override val iconUrl: String?, + override val accountType: DelegateAccountType, + override val name: String? + ) : Delegate.Metadata + + data class Stats(val delegationsCount: Int, val delegatedVotes: Balance, val recentVotes: Int) +} + +fun DelegatePreview.hasMetadata(): Boolean { + return metadata != null +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/model/DelegateSorting.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/model/DelegateSorting.kt new file mode 100644 index 0000000..07509b1 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/list/model/DelegateSorting.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model + +enum class DelegateSorting { + DELEGATIONS, DELEGATED_VOTES, VOTING_ACTIVITY +} + +fun DelegateSorting.delegateComparator(): Comparator { + return when (this) { + DelegateSorting.DELEGATIONS -> compareByDescending { it.stats?.delegationsCount } + DelegateSorting.DELEGATED_VOTES -> compareByDescending { it.stats?.delegatedVotes } + DelegateSorting.VOTING_ACTIVITY -> compareByDescending { it.stats?.recentVotes } + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/search/DelegateSearchInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/search/DelegateSearchInteractor.kt new file mode 100644 index 0000000..0752036 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegate/search/DelegateSearchInteractor.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.search + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceAdditionalState +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegatePreview +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +class DelegateSearchResult( + val delegates: List, + val query: String +) + +interface DelegateSearchInteractor { + + suspend fun searchDelegates( + queryFlow: Flow, + selectedOption: SelectedAssetOptionSharedState.SupportedAssetOption, + scope: CoroutineScope + ): Flow>> +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/ChooseTrackInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/ChooseTrackInteractor.kt new file mode 100644 index 0000000..f6188ac --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/ChooseTrackInteractor.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack + +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.ChooseTrackData +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface ChooseTrackInteractor { + + suspend fun isAllowedToShowRemoveVotesSuggestion(): Boolean + + suspend fun disallowShowRemoveVotesSuggestion() + + fun observeTracksByChain(chainId: ChainId, govType: Chain.Governance): Flow + + fun observeNewDelegationTrackData(): Flow + + fun observeEditDelegationTrackData(delegateId: AccountId): Flow + + fun observeRevokeDelegationTrackData(delegateId: AccountId): Flow +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/model/ChooseTrackData.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/model/ChooseTrackData.kt new file mode 100644 index 0000000..07fce7e --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/model/ChooseTrackData.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model + +data class ChooseTrackData( + val trackPartition: TrackPartition, + val presets: List +) { + + companion object { + + fun empty(): ChooseTrackData { + return ChooseTrackData( + TrackPartition(emptySet(), emptyList(), emptyList(), emptyList()), + emptyList() + ) + } + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/model/TrackPartition.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/model/TrackPartition.kt new file mode 100644 index 0000000..8e83085 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/model/TrackPartition.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.domain.track.Track + +data class TrackPartition( + val preCheckedTrackIds: Set, + val available: List, + val alreadyVoted: List, + val alreadyDelegated: List +) + +fun TrackPartition.hasUnavailableTracks(): Boolean { + return alreadyVoted.isNotEmpty() || alreadyDelegated.isNotEmpty() +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/model/TrackPreset.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/model/TrackPreset.kt new file mode 100644 index 0000000..e919145 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/common/chooseTrack/model/TrackPreset.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.domain.track.Track + +data class TrackPreset( + val type: Type, + val trackIds: List +) { + + companion object; + + enum class Type { + + ALL, TREASURY, FELLOWSHIP, GOVERNANCE + } +} + +fun TrackPreset.Companion.all(trackIds: List): TrackPreset { + return TrackPreset( + type = TrackPreset.Type.ALL, + trackIds = trackIds + ) +} + +@JvmName("allFromTrackInfo") +fun TrackPreset.Companion.all(trackInfos: List): TrackPreset { + return all(trackInfos.map(Track::id)) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/create/chooseAmount/DelegateAssistant.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/create/chooseAmount/DelegateAssistant.kt new file mode 100644 index 0000000..5cdee78 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/create/chooseAmount/DelegateAssistant.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount + +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.LocksChange +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.ReusableLock +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction + +interface DelegateAssistant { + + suspend fun estimateLocksAfterDelegating(amount: Balance, conviction: Conviction, asset: Asset): LocksChange + + suspend fun reusableLocks(): List +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountInteractor.kt new file mode 100644 index 0000000..2bd60dc --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountInteractor.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount + +import io.novafoundation.nova.common.utils.multiResult.RetriableMultiResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface NewDelegationChooseAmountInteractor { + + suspend fun maxAvailableBalanceToDelegate(asset: Asset): Balance + + fun delegateAssistantFlow( + coroutineScope: CoroutineScope + ): Flow + + suspend fun estimateFee( + amount: Balance, + conviction: Conviction, + delegate: AccountId, + tracks: Collection, + shouldRemoveOtherTracks: Boolean, + ): Fee + + suspend fun delegate( + amount: Balance, + conviction: Conviction, + delegate: AccountId, + tracks: Collection, + shouldRemoveOtherTracks: Boolean, + ): RetriableMultiResult> +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/removeVotes/RemoveTrackVotesInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/removeVotes/RemoveTrackVotesInteractor.kt new file mode 100644 index 0000000..3fc6459 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/delegation/delegation/removeVotes/RemoveTrackVotesInteractor.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.removeVotes + +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus + +interface RemoveTrackVotesInteractor { + + suspend fun calculateFee(trackIds: Collection): Fee + + suspend fun removeTrackVotes(trackIds: Collection): Result> +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/ClaimSchedule.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/ClaimSchedule.kt new file mode 100644 index 0000000..f9234f4 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/ClaimSchedule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_governance_api.domain.locks + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castOrNull +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule.UnlockChunk +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +@JvmInline +value class ClaimSchedule(val chunks: List) { + + sealed class UnlockChunk { + + data class Claimable(val amount: Balance, val actions: List) : UnlockChunk() + + data class Pending(val amount: Balance, val claimableAt: ClaimTime) : UnlockChunk() + } + + sealed class ClaimAction { + + data class Unlock(val trackId: TrackId) : ClaimAction() + + data class RemoveVote(val trackId: TrackId, val referendumId: ReferendumId) : ClaimAction() + } +} + +sealed class ClaimTime : Comparable { + + object UntilAction : ClaimTime() + + data class At(val block: BlockNumber) : ClaimTime() + + override operator fun compareTo(other: ClaimTime): Int { + return when { + this is At && other is At -> block.compareTo(other.block) + this is UntilAction && other is UntilAction -> 0 + this is UntilAction -> 1 + else -> -1 + } + } +} + +fun ClaimSchedule.claimableChunk(): UnlockChunk.Claimable? { + return chunks.firstOrNull().castOrNull() +} + +fun ClaimSchedule.hasClaimableLocks(): Boolean { + return claimableChunk() != null +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/ClaimScheduleCalculator.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/ClaimScheduleCalculator.kt new file mode 100644 index 0000000..55d68c1 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/ClaimScheduleCalculator.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_governance_api.domain.locks + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +interface ClaimScheduleCalculator { + + fun totalGovernanceLock(): Balance + + fun maxConvictionEndOf(vote: AccountVote, referendumId: ReferendumId): BlockNumber + + fun estimateClaimSchedule(): ClaimSchedule +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/RealClaimScheduleCalculator.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/RealClaimScheduleCalculator.kt new file mode 100644 index 0000000..efdca16 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/RealClaimScheduleCalculator.kt @@ -0,0 +1,437 @@ +package io.novafoundation.nova.feature_governance_api.domain.locks + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ConfirmingSource +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendumStatus +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amount +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.completedReferendumLockDuration +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.maxLockDuration +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.totalLock +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule.ClaimAction +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule.ClaimAction.RemoveVote +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule.ClaimAction.Unlock +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule.UnlockChunk +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.hash.isPositive + +private data class ClaimableLock( + val claimAt: ClaimTime, + val amount: Balance, + val affected: Set, +) + +private sealed class ClaimAffect(open val trackId: TrackId) { + + data class Track(override val trackId: TrackId) : ClaimAffect(trackId) + + data class Vote(override val trackId: TrackId, val referendumId: ReferendumId) : ClaimAffect(trackId) +} + +private class GroupedClaimAffects( + val trackId: TrackId, + val hasPriorAffect: Boolean, + val votes: List +) + +typealias UnlockGap = Map + +class RealClaimScheduleCalculator( + private val votingByTrack: Map, + private val currentBlockNumber: BlockNumber, + private val referenda: Map, + private val tracks: Map, + private val undecidingTimeout: BlockNumber, + private val voteLockingPeriod: BlockNumber, + private val trackLocks: Map, +) : ClaimScheduleCalculator { + + override fun totalGovernanceLock(): Balance { + return trackLocks.values.maxOrNull().orZero() + } + + @Suppress("IfThenToElvis") + override fun maxConvictionEndOf(vote: AccountVote, referendumId: ReferendumId): BlockNumber { + val referendum = referenda[referendumId] + + return if (referendum != null) { + referendum.maxConvictionEnd(vote) + } else { + // referendum is not in the map, which means it is cancelled and votes can be unlocked immediately + currentBlockNumber + } + } + + /** + * Given the information about Voting (priors + active votes), statuses of referenda and TrackLocks + * Constructs the estimated claiming schedule. + * The schedule is exact when all involved referenda are completed. Only ongoing referenda' end time is estimateted + * + * The claiming schedule shows how much tokens will be unlocked and when. + * Schedule may consist of zero or one [ClaimSchedule.UnlockChunk.Claimable] chunk + * and zero or more [ClaimSchedule.UnlockChunk.Pending] chunks. + * + * [ClaimSchedule.UnlockChunk.Pending] chunks also provides a set of [ClaimSchedule.ClaimAction] actions + * needed to claim whole chunk. + * + * The algorithm itself consists of several parts + * + * 1. Determine individual unlocks + * This step is based on prior [Voting.Casting.prior] and [AccountVote.Standard] standard votes + * a. Each non-zero prior has a single individual unlock + * b. Each non-zero vote has a single individual unlock. + * However, unlock time for votes is at least unlock time of corresponding prior. + * c. Find a gap between [votingByTrack] and [trackLocks], which indicates an extra claimable amount + * To provide additive effect of gap, we add total voting lock on top of it: + if [votingByTrack] has some pending locks - they gonna delay their amount but always leaving trackGap untouched & claimable + On the other hand, if other tracks have locks bigger than [votingByTrack]'s total lock, + trackGap will be partially or full delayed by them + * + * During this step we also determine the list of [ClaimAffect], + * which later gets translated to [ClaimSchedule.ClaimAction]. + * + * 2. Combine all locks with the same unlock time into single lock + * a. Result's amount is the maximum between combined locks + * b. Result's affects is a concatenation of all affects from combined locks + * + * 3. Construct preliminary unlock schedule based on the following algorithm + * a. Sort pairs from step (2) by descending [ClaimableLock.claimAt] order + * b. For each item in the sorted list, find the difference between the biggest currently processed lock and item's amount + * c. Since we start from the most far locks in the future, finding a positive difference means that + * this difference is actually an entry in desired unlock schedule. Negative difference means that this unlock is + * completely covered by future's unlock with bigger amount. Thus, we should discard it from the schedule and move its affects + * to the currently known maximum lock in order to not to loose its actions when unlocking maximum lock. + * + * 4. Check which if unlocks are claimable and which are not by constructing [ClaimSchedule.UnlockChunk] based on [currentBlockNumber] + * 5. Fold all [ClaimSchedule.UnlockChunk] into single chunk. + * 6. If gap exists, then we should add it to claimable chunk. We should also check if we should perform extra [ClaimSchedule.ClaimAction.Unlock] + * for each track that is included in the gap. We do that by finding by checking which [ClaimSchedule.ClaimAction.Unlock] unlocks are already present + * in claimable chunk's actions in order to not to do them twice. + */ + override fun estimateClaimSchedule(): ClaimSchedule { + // step 1 - determine/estimate individual unlocks for all priors and votes + // result example: [(1500, 1 KSM), (1200, 2 KSM), (1000, 1 KSM)] + val claimableLocks = individualClaimableLocks() + + // step 2 - fold all locks with same lockAt + // { 1500: 1 KSM, 1200: 2 KSM, 1000: 1 KSM } + val maxUnlockedByTime = combineSameUnlockAt(claimableLocks) + + // step 3 - convert individual schedule to global + // [(1500, 1 KSM), (1200, 1 KSM)] + val unlockSchedule = constructUnlockSchedule(maxUnlockedByTime) + + // step 4 - convert locks affects to claim actions + val chunks = unlockSchedule.toUnlockChunks() + + return ClaimSchedule(chunks) + } + + private fun individualClaimableLocks(): List { + val gapBetweenVotingAndLocked = votingByTrack.gapWith(trackLocks) + + return votingByTrack.flatMap { (trackId, voting) -> + buildList { + gapClaimableLock(trackId, voting, gapBetweenVotingAndLocked) + + when (voting) { + is Voting.Casting -> castingClaimableLocks(trackId, voting) + is Voting.Delegating -> delegatingClaimableLocks(trackId, voting) + } + } + } + } + + private fun MutableList.gapClaimableLock(trackId: TrackId, voting: Voting, gap: UnlockGap) { + val trackGap = gap[trackId].orZero() + + if (trackGap.isPositive()) { + val lock = ClaimableLock( + claimAt = ClaimTime.At(currentBlockNumber), + amount = trackGap + voting.totalLock(), + affected = setOf(ClaimAffect.Track(trackId)) + ) + + add(lock) + } + } + + private fun MutableList.delegatingClaimableLocks(trackId: TrackId, voting: Voting.Delegating) { + val delegationLock = ClaimableLock( + claimAt = ClaimTime.UntilAction, + amount = voting.amount, + affected = emptySet() + ) + val priorLock = ClaimableLock( + claimAt = ClaimTime.At(voting.prior.unlockAt), + amount = voting.prior.amount, + affected = setOf(ClaimAffect.Track(trackId)) + ) + + add(delegationLock) + if (priorLock.reasonableToClaim()) add(priorLock) + } + + private fun MutableList.castingClaimableLocks(trackId: TrackId, voting: Voting.Casting) { + val priorLock = ClaimableLock( + claimAt = ClaimTime.At(voting.prior.unlockAt), + amount = voting.prior.amount, + affected = setOf(ClaimAffect.Track(trackId)) + ) + + val standardVoteLocks = voting.votes.map { (referendumId, standardVote) -> + val estimatedEnd = maxConvictionEndOf(standardVote, referendumId) + val lock = ClaimableLock( + claimAt = ClaimTime.At(estimatedEnd), + amount = standardVote.amount(), + affected = setOf(ClaimAffect.Vote(trackId, referendumId)) + ) + + // we estimate whether prior will affect the vote when performing `removeVote` + lock.timeAtLeast(priorLock.claimAt) + } + + if (priorLock.reasonableToClaim()) add(priorLock) + addAll(standardVoteLocks) + } + + private fun combineSameUnlockAt(claimableLocks: List) = + claimableLocks.groupBy(ClaimableLock::claimAt) + .mapValues { (_, locks) -> + locks.reduce { current, next -> current.foldSameTime(next) } + } + + private fun constructUnlockSchedule(maxUnlockedByTime: Map): List { + var currentMaxLock = Balance.ZERO + var currentMaxLockAt: ClaimTime? = null + + val result = maxUnlockedByTime.toMutableMap() + + maxUnlockedByTime.entries.sortedByDescending { it.key } + .forEach { (at, lock) -> + val newMaxLock = currentMaxLock.max(lock.amount) + val unlockedAmount = lock.amount - currentMaxLock + + val shouldSetNewMax = currentMaxLockAt == null || currentMaxLock < newMaxLock + if (shouldSetNewMax) { + currentMaxLock = newMaxLock + currentMaxLockAt = at + } + + if (unlockedAmount.isPositive()) { + // there is something to unlock at this point + result[at] = lock.copy(amount = unlockedAmount) + } else { + // this lock is completely shadowed by later (in time) lock with greater value + result.remove(at) + + // but we want to keep its actions so we move it to the current known maximum that goes later in time + result.computeIfPresent(currentMaxLockAt!!) { _, maxLock -> + maxLock.copy(affected = maxLock.affected + lock.affected) + } + } + } + + return result.toSortedMap().values.toList() + } + + @Suppress("UNCHECKED_CAST") + private fun List.toUnlockChunks(): List { + val chunks = map { it.toUnlockChunk(currentBlockNumber) } + val (claimable, nonClaimable) = chunks.partition { it is UnlockChunk.Claimable } + + // fold all claimable chunks to single one + val initialClaimable = Balance.ZERO to emptyList() + + val (claimableAmount, claimableActions) = (claimable as List).fold(initialClaimable) { (amount, actions), unlockChunk -> + val nextAmount = amount + unlockChunk.amount + val nextActions = actions + unlockChunk.actions + nextAmount to nextActions + } + val claimableChunk = constructClaimableChunk(claimableAmount, claimableActions) + + return buildList { + if (claimableChunk.amount.isPositive()) { + add(claimableChunk) + } + + addAll(nonClaimable) + } + } + + private fun constructClaimableChunk( + claimableAmount: Balance, + claimableActions: List + ): UnlockChunk.Claimable { + return UnlockChunk.Claimable(claimableAmount, claimableActions.dedublicateUnlocks()) + } + + // We want to avoid doing multiple unlocks for the same track + // For that we also need to move unlock() calls to the end + private fun List.dedublicateUnlocks(): List { + return distinct().sortedBy { it is Unlock } + } + + private fun OnChainReferendum.maxConvictionEnd(vote: AccountVote): BlockNumber { + return when (val status = status) { + is OnChainReferendumStatus.Ongoing -> status.maxConvictionEnd(vote) + + is OnChainReferendumStatus.Approved -> maxCompletedConvictionEnd( + vote = vote, + referendumOutcome = VoteType.AYE, + completedSince = status.since + ) + + is OnChainReferendumStatus.Rejected -> maxCompletedConvictionEnd( + vote = vote, + referendumOutcome = VoteType.NAY, + completedSince = status.since + ) + + is OnChainReferendumStatus.Cancelled -> status.since + is OnChainReferendumStatus.Killed -> status.since + is OnChainReferendumStatus.TimedOut -> status.since + } + } + + private fun maxCompletedConvictionEnd( + vote: AccountVote, + referendumOutcome: VoteType, + completedSince: BlockNumber + ): BlockNumber { + val convictionPart = vote.completedReferendumLockDuration(referendumOutcome, voteLockingPeriod) + + return completedSince + convictionPart + } + + private fun OnChainReferendumStatus.Ongoing.maxConvictionEnd(vote: AccountVote): BlockNumber { + val trackInfo = tracks.getValue(track) + val decisionPeriod = trackInfo.decisionPeriod + + val blocksAfterCompleted = vote.maxLockDuration(voteLockingPeriod) + + val maxCompletedAt = when { + inQueue -> { + val maxDecideSince = submitted + undecidingTimeout + + maxDecideSince + decisionPeriod + } + + deciding != null -> { + when (val source = deciding.confirming) { + is ConfirmingSource.FromThreshold -> source.end + + is ConfirmingSource.OnChain -> if (source.status != null) { + // confirming + val approveBlock = source.status.till + val rejectBlock = deciding.since + decisionPeriod + + approveBlock.max(rejectBlock) + } else { + // rejecting + val rejectBlock = deciding.since + decisionPeriod + + rejectBlock + } + } + } + + // preparing + else -> { + val maxDecideSince = submitted + undecidingTimeout.max(trackInfo.preparePeriod) + + maxDecideSince + decisionPeriod + } + } + + return maxCompletedAt + blocksAfterCompleted + } +} + +private fun ClaimableLock.foldSameTime(another: ClaimableLock): ClaimableLock { + require(claimAt == another.claimAt) + + return ClaimableLock( + claimAt = claimAt, + amount = amount.max(another.amount), + affected = affected + another.affected + ) +} + +private fun ClaimableLock.reasonableToClaim(): Boolean { + return amount.isPositive() +} + +private infix fun ClaimableLock.timeAtLeast(time: ClaimTime): ClaimableLock { + val newClaimAt = maxOf(claimAt, time) + + return copy(claimAt = newClaimAt) +} + +private fun ClaimableLock.claimableAt(at: BlockNumber): Boolean { + return when (claimAt) { + is ClaimTime.At -> claimAt.block <= at + ClaimTime.UntilAction -> false + } +} + +private fun ClaimableLock.toUnlockChunk(currentBlockNumber: BlockNumber): UnlockChunk { + return if (claimableAt(currentBlockNumber)) { + UnlockChunk.Claimable( + amount = amount, + actions = affected.toClaimActions() + ) + } else { + UnlockChunk.Pending(amount, claimAt) + } +} + +private fun Map.gapWith(locksByTrackId: Map): UnlockGap { + val gapByTrack = mapValues { (trackId, voting) -> + val trackLock = locksByTrackId[trackId].orZero() + val gap = (trackLock - voting.totalLock()).coerceAtLeast(Balance.ZERO) + + gap + } + + return gapByTrack +} + +private fun Collection.toClaimActions(): List { + return groupByTrack().flatMap { trackAffects -> + buildList { + if (trackAffects.hasPriorAffect) { + val requiresStandaloneUnlock = trackAffects.votes.isEmpty() + + if (requiresStandaloneUnlock) { + add(Unlock(trackAffects.trackId)) + } + } + + if (trackAffects.votes.isNotEmpty()) { + trackAffects.votes.forEach { voteAffect -> + add(RemoveVote(voteAffect.trackId, voteAffect.referendumId)) + } + + add(Unlock(trackAffects.votes.first().trackId)) + } + } + } +} + +private fun Collection.groupByTrack(): List { + return groupBy(ClaimAffect::trackId).entries.map { (trackId, trackAffects) -> + GroupedClaimAffects( + trackId = trackId, + hasPriorAffect = trackAffects.any { it is ClaimAffect.Track }, + votes = trackAffects.filterIsInstance() + ) + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/reusable/LocksChange.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/reusable/LocksChange.kt new file mode 100644 index 0000000..a81fe6a --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/locks/reusable/LocksChange.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_api.domain.locks.reusable + +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.Change +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlin.time.Duration + +class LocksChange( + val lockedAmountChange: Change, + val lockedPeriodChange: Change, + val transferableChange: Change +) + +class ReusableLock(val type: Type, val amount: Balance) { + enum class Type { + GOVERNANCE, ALL + } +} + +fun MutableList.addIfPositive(type: ReusableLock.Type, amount: Balance) { + if (amount.isPositive()) { + add(ReusableLock(type, amount)) + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/Change.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/Change.kt new file mode 100644 index 0000000..cbb28a8 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/Change.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.common + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger +import kotlin.time.Duration + +sealed class Change(open val previousValue: T, open val newValue: T) { + + data class Changed( + override val previousValue: T, + override val newValue: T, + val absoluteDifference: T, + val positive: Boolean + ) : Change(previousValue, newValue) + + data class Same(val value: T) : Change(previousValue = value, newValue = value) +} + +fun Change.absoluteDifference(): Balance { + return when (this) { + is Change.Changed -> absoluteDifference + is Change.Same -> Balance.ZERO + } +} + +fun Change.absoluteDifference(): Duration { + return when (this) { + is Change.Changed -> absoluteDifference + is Change.Same -> Duration.ZERO + } +} + +fun > Change( + previousValue: T, + newValue: T, + absoluteDifference: T +): Change { + return if (previousValue == newValue) { + Change.Same(newValue) + } else { + Change.Changed( + previousValue = previousValue, + newValue = newValue, + absoluteDifference = absoluteDifference, + positive = newValue > previousValue + ) + } +} + +fun Change( + previousValue: BigInteger, + newValue: BigInteger, +): Change { + val absoluteDifference = (newValue - previousValue).abs() + + return Change(previousValue, newValue, absoluteDifference) +} + +fun Change( + previousValue: Duration, + newValue: Duration, +): Change { + val absoluteDifference = (newValue - previousValue).absoluteValue + + return Change(previousValue, newValue, absoluteDifference) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/Proposer.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/Proposer.kt new file mode 100644 index 0000000..4399edd --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/Proposer.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.common + +import io.novasama.substrate_sdk_android.runtime.AccountId + +data class ReferendumProposer(val accountId: AccountId, val offChainNickname: String?) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/ReferendumThreshold.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/ReferendumThreshold.kt new file mode 100644 index 0000000..dc4b9cb --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/ReferendumThreshold.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.common + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingThreshold +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingThreshold.Threshold +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.merge +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +data class ReferendumThreshold( + val support: Threshold, + val approval: Threshold +) + +fun Threshold<*>.currentlyPassing(): Boolean { + return currentlyPassing +} + +fun ReferendumThreshold.currentlyPassing(): Boolean { + return support.currentlyPassing() && approval.currentlyPassing() +} + +fun ReferendumThreshold.projectedPassing(): VotingThreshold.ProjectedPassing { + return support.projectedPassing.merge(approval.projectedPassing) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/ReferendumTrack.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/ReferendumTrack.kt new file mode 100644 index 0000000..60239c4 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/ReferendumTrack.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.common + +import io.novafoundation.nova.feature_governance_api.domain.track.Track + +data class ReferendumTrack(val track: Track, val sameWithOther: Boolean) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/ReferendumVoting.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/ReferendumVoting.kt new file mode 100644 index 0000000..2389e90 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/common/ReferendumVoting.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.common + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger + +data class ReferendumVoting( + val support: ExtendedLoadingState, + val approval: ExtendedLoadingState, + val abstainVotes: ExtendedLoadingState +) { + + data class Support( + val turnout: Balance, + val electorate: Balance, + ) + + data class Approval( + val ayeVotes: Votes, + val nayVotes: Votes, + ) { + + // post-conviction + data class Votes( + val amount: Balance, + val fraction: Perbill + ) + } +} + +fun ReferendumVoting.Approval.totalVotes(): Balance { + return ayeVotes.amount + nayVotes.amount +} + +fun ReferendumVoting.Approval.ayeVotesIfNotEmpty(): ReferendumVoting.Approval.Votes? { + return ayeVotes.takeIf { totalVotes() != Balance.ZERO } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/PreimagePreview.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/PreimagePreview.kt new file mode 100644 index 0000000..d7f4341 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/PreimagePreview.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.details + +sealed class PreimagePreview { + + object TooLong : PreimagePreview() + + class Display(val value: String) : PreimagePreview() +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/Referendum.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/Referendum.kt new file mode 100644 index 0000000..ed898e0 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/Referendum.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.details + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PreImage +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingCurve +import io.novafoundation.nova.feature_governance_api.data.thresold.gov1.Gov1VotingThreshold +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumProposer +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumThreshold +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumTrack +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumVoting +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumVote +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +data class ReferendumDetails( + val id: ReferendumId, + val offChainMetadata: OffChainMetadata?, + val onChainMetadata: OnChainMetadata?, + val proposer: ReferendumProposer?, + val track: ReferendumTrack?, + val voting: ReferendumVoting?, + val threshold: ReferendumThreshold?, + val userVote: ReferendumVote?, + val timeline: ReferendumTimeline, + val fullDetails: FullDetails +) { + + data class FullDetails( + val deposit: Balance?, + val voteThreshold: Gov1VotingThreshold?, + val approvalCurve: VotingCurve?, + val supportCurve: VotingCurve?, + ) + + data class OffChainMetadata(val title: String?, val description: String?) + + data class OnChainMetadata(val preImage: PreImage?, val preImageHash: ByteArray) +} + +data class ReferendumTimeline(val currentStatus: ReferendumStatus, val pastEntries: List) { + + data class Entry(val state: State, val at: Long?) { + companion object // extensions + } + + enum class State { + CREATED, APPROVED, REJECTED, EXECUTED, CANCELLED, KILLED, TIMED_OUT + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumCall.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumCall.kt new file mode 100644 index 0000000..5f09908 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumCall.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.details + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +sealed class ReferendumCall { + + open fun combineWith(other: ReferendumCall): ReferendumCall = this + + data class TreasuryRequest( + val amount: Balance, + val beneficiary: AccountId, + val chainAsset: Chain.Asset, + ) : ReferendumCall() { + + override fun combineWith(other: ReferendumCall): ReferendumCall { + if (other is TreasuryRequest && other.chainAsset.fullId == chainAsset.fullId) { + return copy(amount = amount + other.amount) + } + + return super.combineWith(other) + } + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumDApp.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumDApp.kt new file mode 100644 index 0000000..bd2fce9 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumDApp.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.details + +class ReferendumDApp( + val chainId: String, + val name: String, + val referendumUrl: String, + val iconUrl: String, + val details: String +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumDetailsInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumDetailsInteractor.kt new file mode 100644 index 0000000..fa984b1 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumDetailsInteractor.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.details + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PreImage +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface ReferendumDetailsInteractor { + + fun referendumDetailsFlow( + referendumId: ReferendumId, + selectedGovernanceOption: SupportedGovernanceOption, + voterAccountId: AccountId?, + coroutineScope: CoroutineScope + ): Flow + + suspend fun detailsFor( + preImage: PreImage, + chain: Chain, + ): ReferendumCall? + + suspend fun previewFor(preImage: PreImage): PreimagePreview + + suspend fun isSupportAbstainVoting(selectedGovernanceOption: SupportedGovernanceOption): Boolean +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumExt.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumExt.kt new file mode 100644 index 0000000..4a535aa --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/ReferendumExt.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.details + +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumVote + +fun ReferendumDetails.isUserDelegatedVote() = userVote is ReferendumVote.UserDelegated + +fun ReferendumDetails.isUserDirectVote() = userVote is ReferendumVote.UserDirect + +fun ReferendumDetails.noVote() = userVote == null + +fun ReferendumStatus.isOngoing(): Boolean { + return this is ReferendumStatus.Ongoing +} + +fun ReferendumDetails.isFinished() = !timeline.currentStatus.isOngoing() diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/valiadtions/ReferendumPreVoteValidationFailure.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/valiadtions/ReferendumPreVoteValidationFailure.kt new file mode 100644 index 0000000..72cc0ad --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/valiadtions/ReferendumPreVoteValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.details.valiadtions + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.validation.NoChainAccountFoundError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed class ReferendumPreVoteValidationFailure { + + class NoRelaychainAccount( + override val chain: Chain, + override val account: MetaAccount, + override val addAccountState: NoChainAccountFoundError.AddAccountState + ) : ReferendumPreVoteValidationFailure(), NoChainAccountFoundError +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/valiadtions/ReferendumPreVoteValidationPayload.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/valiadtions/ReferendumPreVoteValidationPayload.kt new file mode 100644 index 0000000..e94d5c5 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/valiadtions/ReferendumPreVoteValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.details.valiadtions + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ReferendumPreVoteValidationPayload( + val metaAccount: MetaAccount, + val chain: Chain +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/valiadtions/ReferendumPreVoteValidationSystem.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/valiadtions/ReferendumPreVoteValidationSystem.kt new file mode 100644 index 0000000..b8edec3 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/details/valiadtions/ReferendumPreVoteValidationSystem.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.details.valiadtions + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.validation.hasChainAccount +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.valiadtions.ReferendumPreVoteValidationFailure.NoRelaychainAccount + +typealias ReferendumPreVoteValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.referendumPreVote(): ReferendumPreVoteValidationSystem = ValidationSystem { + hasChainAccount( + chain = ReferendumPreVoteValidationPayload::chain, + metaAccount = ReferendumPreVoteValidationPayload::metaAccount, + error = ::NoRelaychainAccount + ) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/filters/ReferendumType.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/filters/ReferendumType.kt new file mode 100644 index 0000000..f84cb21 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/filters/ReferendumType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.filters + +enum class ReferendumType { + ALL, NOT_VOTED, VOTED +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/filters/ReferendumTypeFilter.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/filters/ReferendumTypeFilter.kt new file mode 100644 index 0000000..152332f --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/filters/ReferendumTypeFilter.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.filters + +import io.novafoundation.nova.common.utils.OptionsFilter +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview + +class ReferendumTypeFilter(val selectedType: ReferendumType) : OptionsFilter { + + override val options: List + get() = ReferendumType.values().toList() + + override fun shouldInclude(model: ReferendumPreview): Boolean { + val vote = model.referendumVote?.vote + return when (selectedType) { + ReferendumType.ALL -> true + ReferendumType.VOTED -> vote != null + ReferendumType.NOT_VOTED -> vote == null + } + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendaListInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendaListInteractor.kt new file mode 100644 index 0000000..b13a922 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendaListInteractor.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.list + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumTypeFilter +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.SelectableAssetAndOption +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface ReferendaListInteractor { + + suspend fun availableVoteAmount(option: SelectableAssetAndOption): Balance + + fun searchReferendaListStateFlow( + metaAccount: MetaAccount, + queryFlow: Flow, + voterAccountId: AccountId?, + selectedGovernanceOption: SupportedGovernanceOption, + coroutineScope: CoroutineScope + ): Flow>> + + fun referendaListStateFlow( + metaAccount: MetaAccount, + voterAccountId: AccountId?, + selectedGovernanceOption: SupportedGovernanceOption, + coroutineScope: CoroutineScope, + referendumTypeFilterFlow: Flow + ): Flow> + + fun votedReferendaListFlow( + voter: Voter, + onlyRecentVotes: Boolean + ): Flow> +} + +class Voter(val accountId: AccountId, val type: Type) { + + companion object; + + enum class Type { + USER, ACCOUNT + } +} + +fun Voter.Companion.user(accountId: AccountId) = Voter(accountId, Voter.Type.USER) + +fun Voter.Companion.account(accountId: AccountId) = Voter(accountId, Voter.Type.ACCOUNT) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendaListState.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendaListState.kt new file mode 100644 index 0000000..6202286 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendaListState.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.list + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class ReferendaListState( + val groupedReferenda: GroupedList, + val locksOverview: GovernanceLocksOverview?, + val delegated: DelegatedState, + val availableToVoteReferenda: List +) + +class GovernanceLocksOverview( + val locked: Balance, + val hasClaimableLocks: Boolean +) + +sealed class DelegatedState { + + object DelegationNotSupported : DelegatedState() + + object NotDelegated : DelegatedState() + + class Delegated(val amount: Balance) : DelegatedState() +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendaState.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendaState.kt new file mode 100644 index 0000000..4ef247f --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendaState.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.list + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting + +class ReferendaState( + val voting: Map, + val currentBlockNumber: BlockNumber, + val onChainReferenda: Map, + val referenda: List, + val tracksById: Map, +) diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/Referendum.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/Referendum.kt new file mode 100644 index 0000000..fcb84fb --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/Referendum.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.list + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumThreshold +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumTrack +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumVoting + +enum class ReferendumGroup { + ONGOING, COMPLETED +} + +data class ReferendumPreview( + val id: ReferendumId, + val status: ReferendumStatus, + val offChainMetadata: OffChainMetadata?, + val onChainMetadata: OnChainMetadata?, + val track: ReferendumTrack?, + val voting: ReferendumVoting?, + val threshold: ReferendumThreshold?, + val referendumVote: ReferendumVote?, +) { + + data class OffChainMetadata(val title: String) + + data class OnChainMetadata(val proposal: ReferendumProposal) +} + +fun ReferendumPreview.getName(): String? { + return offChainMetadata?.title + ?: getOnChainName() +} + +private fun ReferendumPreview.getOnChainName(): String? { + return when (val proposal = onChainMetadata?.proposal) { + is ReferendumProposal.Call -> "${proposal.call.module.name}.${proposal.call.function.name}" + else -> null + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendumProposal.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendumProposal.kt new file mode 100644 index 0000000..d182faa --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendumProposal.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.list + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +sealed class ReferendumProposal { + + class Hash(val callHash: String) : ReferendumProposal() + + class Call(val call: GenericCall.Instance) : ReferendumProposal() +} + +fun ReferendumProposal.toCallOrNull() = this as? ReferendumProposal.Call diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendumStatus.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendumStatus.kt new file mode 100644 index 0000000..7137fbc --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendumStatus.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.list + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackQueue + +enum class ReferendumStatusType { + WAITING_DEPOSIT, + PREPARING, + IN_QUEUE, + DECIDING, + CONFIRMING, + APPROVED, + EXECUTED, + TIMED_OUT, + KILLED, + CANCELLED, + REJECTED; + + companion object +} + +sealed class ReferendumStatus { + + abstract val type: ReferendumStatusType + + sealed class Ongoing : ReferendumStatus() { + data class Preparing(val reason: PreparingReason, val timeOutIn: TimerValue) : Ongoing() { + override val type = when (reason) { + is PreparingReason.WaitingForDeposit -> ReferendumStatusType.WAITING_DEPOSIT + is PreparingReason.DecidingIn -> ReferendumStatusType.PREPARING + } + } + + data class InQueue(val timeOutIn: TimerValue, val position: TrackQueue.Position) : Ongoing() { + override val type = ReferendumStatusType.IN_QUEUE + } + + data class DecidingReject(val rejectIn: TimerValue) : Ongoing() { + override val type = ReferendumStatusType.DECIDING + } + + data class DecidingApprove(val approveIn: TimerValue) : Ongoing() { + override val type = ReferendumStatusType.DECIDING + } + + data class Confirming(val approveIn: TimerValue) : Ongoing() { + override val type = ReferendumStatusType.CONFIRMING + } + } + + data class Approved(val since: BlockNumber, val executeIn: TimerValue) : ReferendumStatus() { + override val type = ReferendumStatusType.APPROVED + } + + object Executed : ReferendumStatus() { + override val type = ReferendumStatusType.EXECUTED + } + + sealed class NotExecuted : ReferendumStatus() { + + object TimedOut : NotExecuted() { + override val type = ReferendumStatusType.TIMED_OUT + } + + object Killed : NotExecuted() { + override val type = ReferendumStatusType.KILLED + } + + object Cancelled : NotExecuted() { + override val type = ReferendumStatusType.CANCELLED + } + + object Rejected : NotExecuted() { + override val type = ReferendumStatusType.REJECTED + } + } +} + +sealed class PreparingReason { + + object WaitingForDeposit : PreparingReason() + + data class DecidingIn(val timeLeft: TimerValue) : PreparingReason() +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendumVote.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendumVote.kt new file mode 100644 index 0000000..d023470 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/list/ReferendumVote.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.list + +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novasama.substrate_sdk_android.runtime.AccountId + +sealed class ReferendumVote(val vote: AccountVote) { + + class UserDirect(vote: AccountVote) : ReferendumVote(vote) + + class UserDelegated( + override val who: AccountId, + override val whoIdentity: Identity?, + vote: AccountVote + ) : ReferendumVote(vote), WithDifferentVoter + + class OtherAccount( + override val who: AccountId, + override val whoIdentity: Identity?, + vote: AccountVote + ) : ReferendumVote(vote), WithDifferentVoter +} + +interface WithDifferentVoter { + + val who: AccountId + + val whoIdentity: Identity? +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/track/category/TrackCategory.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/track/category/TrackCategory.kt new file mode 100644 index 0000000..9ae7b06 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/track/category/TrackCategory.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.track.category + +enum class TrackCategory { + + TREASURY, GOVERNANCE, FELLOWSHIP, OTHER +} + +enum class TrackType { + ROOT, + WHITELISTED_CALLER, FELLOWSHIP_ADMIN, + STAKING_ADMIN, LEASE_ADMIN, AUCTION_ADMIN, + GENERAL_ADMIN, REFERENDUM_CANCELLER, REFERENDUM_KILLER, + TREASURER, SMALL_TIPPER, BIG_TIPPER, SMALL_SPEND, MEDIUM_SPEND, BIG_SPEND, + OTHER +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/vote/GovernanceVoteAssistant.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/vote/GovernanceVoteAssistant.kt new file mode 100644 index 0000000..97d3557 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/vote/GovernanceVoteAssistant.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.vote + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.LocksChange +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.ReusableLock +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset + +interface GovernanceVoteAssistant { + + val onChainReferenda: List + + val trackVoting: List + + suspend fun estimateLocksAfterVoting(votes: Map, asset: Asset): LocksChange + + suspend fun reusableLocks(): List +} + +suspend fun GovernanceVoteAssistant.estimateLocksAfterVoting(referendumId: ReferendumId, accountVote: AccountVote, asset: Asset): LocksChange { + return estimateLocksAfterVoting(mapOf(referendumId to accountVote), asset) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/vote/VoteReferendumInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/vote/VoteReferendumInteractor.kt new file mode 100644 index 0000000..d9521f3 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/vote/VoteReferendumInteractor.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.vote + +import io.novafoundation.nova.common.utils.multiResult.RetriableMultiResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface VoteReferendumInteractor { + + suspend fun maxAvailableForVote(asset: Asset): Balance + + fun voteAssistantFlow(referendumId: ReferendumId, scope: CoroutineScope): Flow + + fun voteAssistantFlow(referendaIds: List, scope: CoroutineScope): Flow + + suspend fun estimateFee(referendumId: ReferendumId, vote: AccountVote): Fee + + suspend fun estimateFee(votes: Map): Fee + + suspend fun voteReferendum(referendumId: ReferendumId, vote: AccountVote): Result + + suspend fun voteReferenda(votes: Map): RetriableMultiResult> + + suspend fun isAbstainSupported(): Boolean +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/voters/GenericVoter.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/voters/GenericVoter.kt new file mode 100644 index 0000000..ba8d9e2 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/voters/GenericVoter.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.voters + +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amountMultiplier +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigDecimal + +interface GenericVoter { + + val vote: V + + val identity: Identity? + + val accountId: AccountId + + interface Vote { + + val totalVotes: BigDecimal + } + + class ConvictionVote(val amount: BigDecimal, val conviction: Conviction) : Vote { + override val totalVotes = amount * conviction.amountMultiplier() + } +} + +fun SplitVote(amount: BigDecimal): GenericVoter.ConvictionVote = GenericVoter.ConvictionVote(amount, Conviction.None) +fun SplitVote(planks: Balance, chainAsset: Chain.Asset): GenericVoter.ConvictionVote { + return GenericVoter.ConvictionVote(chainAsset.amountFromPlanks(planks), Conviction.None) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/voters/ReferendumVoter.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/voters/ReferendumVoter.kt new file mode 100644 index 0000000..2d68cbc --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/voters/ReferendumVoter.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.voters + +import io.novafoundation.nova.common.utils.sumByBigDecimal +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amountFor +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.conviction +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabel +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigDecimal + +class ReferendumVoter( + override val vote: Vote, + override val identity: Identity?, + override val accountId: AccountId, + val metadata: DelegateLabel.Metadata?, +) : GenericVoter { + + sealed class Vote : GenericVoter.Vote { + + class OnlySelf(val selfVote: GenericVoter.ConvictionVote) : Vote(), GenericVoter.Vote by selfVote + + class WithDelegators(override val totalVotes: BigDecimal, val delegators: List) : Vote() + } +} + +class ReferendumVoterDelegator( + override val accountId: AccountId, + override val vote: GenericVoter.ConvictionVote, + val metadata: DelegateLabel.Metadata?, + override val identity: Identity?, +) : GenericVoter + +fun ReferendumVoter( + accountVote: AccountVote, + voteType: VoteType, + identity: Identity?, + accountId: AccountId, + chainAsset: Chain.Asset, + metadata: DelegateLabel.Metadata?, + delegators: List +): ReferendumVoter { + val selfVote = ConvictionVote(accountVote, chainAsset, voteType) + + val referendumVote = if (delegators.isNotEmpty()) { + val totalVotes = delegators.sumByBigDecimal { it.vote.totalVotes } + selfVote.totalVotes + + val selfAsDelegator = ReferendumVoterDelegator(accountId, selfVote, metadata, identity) + val sortedDelegators = delegators.sortedByDescending { it.vote.totalVotes } + val delegatorsPlusSelf = sortedDelegators + selfAsDelegator + + ReferendumVoter.Vote.WithDelegators(totalVotes, delegatorsPlusSelf) + } else { + ReferendumVoter.Vote.OnlySelf(selfVote) + } + + return ReferendumVoter( + vote = referendumVote, + identity = identity, + accountId = accountId, + metadata = metadata, + ) +} + +private fun ConvictionVote(accountVote: AccountVote, chainAsset: Chain.Asset, voteType: VoteType): GenericVoter.ConvictionVote { + val amount = accountVote.amountFor(voteType) + val conviction = accountVote.conviction() + + return if (amount != null && conviction != null) { + GenericVoter.ConvictionVote(chainAsset.amountFromPlanks(amount), conviction) + } else { + error("Expected $accountVote to contain vote of type $voteType") + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/voters/ReferendumVotersInteractor.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/voters/ReferendumVotersInteractor.kt new file mode 100644 index 0000000..2eb963f --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/referendum/voters/ReferendumVotersInteractor.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_governance_api.domain.referendum.voters + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import kotlinx.coroutines.flow.Flow + +interface ReferendumVotersInteractor { + + fun votersFlow( + referendumId: ReferendumId, + type: VoteType + ): Flow> +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/track/Track.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/track/Track.kt new file mode 100644 index 0000000..5fcd7dc --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/domain/track/Track.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_api.domain.track + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId + +class Track(val id: TrackId, val name: String) + +fun Map.matchWith(tracks: Map): Map { + return mapKeys { (trackId, _) -> tracks.getValue(trackId) } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/referenda/common/ReferendaStatusFormatter.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/referenda/common/ReferendaStatusFormatter.kt new file mode 100644 index 0000000..027129b --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/referenda/common/ReferendaStatusFormatter.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_governance_api.presentation.referenda.common + +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatusType + +interface ReferendaStatusFormatter { + fun formatStatus(status: ReferendumStatusType): String +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/referenda/details/ReferendumDetailsPayload.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/referenda/details/ReferendumDetailsPayload.kt new file mode 100644 index 0000000..3a9fccc --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/referenda/details/ReferendumDetailsPayload.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_governance_api.presentation.referenda.details + +import android.os.Parcelable +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import java.math.BigInteger +import kotlinx.parcelize.Parcelize + +@Parcelize +class ReferendumDetailsPayload( + val referendumId: BigInteger, + val allowVoting: Boolean, + val prefilledData: PrefilledData? +) : Parcelable { + + @Parcelize + class PrefilledData( + val referendumNumber: String, + val title: String, + val status: StatusData, + val voting: VotingData? + ) : Parcelable + + @Parcelize + class StatusData( + val statusName: String, + val statusColor: Int + ) : Parcelable + + @Parcelize + class VotingData( + val positiveFraction: Float?, + val thresholdFraction: Float?, + @DrawableRes val votingResultIcon: Int, + @ColorRes val votingResultIconColor: Int, + val thresholdInfo: String?, + val thresholdInfoVisible: Boolean, + val positivePercentage: String, + val negativePercentage: String, + val thresholdPercentage: String?, + ) : Parcelable +} + +fun ReferendumDetailsPayload.toReferendumId(): ReferendumId { + return ReferendumId(referendumId) +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/referenda/details/deeplink/configurators/ReferendumDetailsDeepLinkConfigurator.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/referenda/details/deeplink/configurators/ReferendumDetailsDeepLinkConfigurator.kt new file mode 100644 index 0000000..d14cc20 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/referenda/details/deeplink/configurators/ReferendumDetailsDeepLinkConfigurator.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators + +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +class ReferendumDeepLinkData( + val chainId: String, + val referendumId: BigInteger, + val governanceType: Chain.Governance +) + +interface ReferendumDetailsDeepLinkConfigurator : DeepLinkConfigurator { + + companion object { + const val ACTION = "open" + const val SCREEN = "gov" + const val PREFIX = "/$ACTION/$SCREEN" + const val CHAIN_ID_PARAM = "chainId" + const val REFERENDUM_ID_PARAM = "id" + const val GOVERNANCE_TYPE_PARAM = "type" + } +} diff --git a/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/tracks/select/governanceTracks/SelectTracksCommunicator.kt b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/tracks/select/governanceTracks/SelectTracksCommunicator.kt new file mode 100644 index 0000000..3337947 --- /dev/null +++ b/feature-governance-api/src/main/java/io/novafoundation/nova/feature_governance_api/presentation/tracks/select/governanceTracks/SelectTracksCommunicator.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger +import kotlinx.parcelize.Parcelize + +interface SelectTracksRequester : InterScreenRequester { + + @Parcelize + class Request( + val chainId: ChainId, + val governanceType: Chain.Governance, + val selectedTracks: Set, + val minTracks: Int, + ) : Parcelable +} + +interface SelectTracksResponder : InterScreenResponder { + + @Parcelize + class Response( + val chainId: ChainId, + val governanceType: Chain.Governance, + val selectedTracks: Set + ) : Parcelable +} + +interface SelectTracksCommunicator : SelectTracksRequester, SelectTracksResponder diff --git a/feature-governance-impl/.gitignore b/feature-governance-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-governance-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-governance-impl/build.gradle b/feature-governance-impl/build.gradle new file mode 100644 index 0000000..2e2cdf6 --- /dev/null +++ b/feature-governance-impl/build.gradle @@ -0,0 +1,68 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../scripts/secrets.gradle' + +android { + namespace 'io.novafoundation.nova.feature_governance_impl' + + + defaultConfig { + buildConfigField "String", "GOVERNANCE_DAPPS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/governance/v2/dapps_dev.json\"" + + buildConfigField "String", "DELEGATION_TUTORIAL_URL", "\"https://docs.pezkuwichain.io/pezkuwi-wallet-wiki/governance/add-delegate-information\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + buildConfigField "String", "GOVERNANCE_DAPPS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/governance/v2/dapps.json\"" + } + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(':feature-governance-api') + implementation project(':feature-dapp-api') + implementation project(':feature-xcm:api') + implementation project(':feature-deep-linking') + + implementation project(":common") + implementation project(":runtime") + + implementation markwonDep + + implementation materialDep + + implementation substrateSdkDep + + implementation kotlinDep + + + implementation androidDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation lifeCycleKtxDep + implementation flexBoxDep + + implementation project(":core-db") + + implementation viewModelKtxDep + + implementation shimmerDep + + implementation cardStackView + + implementation daggerDep + ksp daggerCompiler + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-governance-impl/consumer-rules.pro b/feature-governance-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-governance-impl/proguard-rules.pro b/feature-governance-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-governance-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-governance-impl/src/main/AndroidManifest.xml b/feature-governance-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/feature-governance-impl/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/GovernanceSharedState.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/GovernanceSharedState.kt new file mode 100644 index 0000000..07312c2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/GovernanceSharedState.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_governance_impl.data + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_api.data.MutableGovernanceState +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceAdditionalState +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.state.SelectableSingleAssetSharedState + +private const val GOVERNANCE_SHARED_STATE = "GOVERNANCE_SHARED_STATE" + +class GovernanceSharedState( + chainRegistry: ChainRegistry, + preferences: Preferences, +) : SelectableSingleAssetSharedState( + preferences = preferences, + chainRegistry = chainRegistry, + supportedOptions = { chain, asset -> + if (asset.isUtilityAsset) { + val multipleGovernanceTypesPresent = chain.governance.size > 1 + chain.governance.map { RealGovernanceAdditionalState(it, multipleGovernanceTypesPresent) } + } else { + emptyList() + } + }, + preferencesKey = GOVERNANCE_SHARED_STATE +), + MutableGovernanceState { + + override fun update(chainId: ChainId, assetId: Int, governanceType: Chain.Governance) { + update(chainId, assetId, governanceType.name) + } +} + +class RealGovernanceAdditionalState( + override val governanceType: Chain.Governance, + private val multipleGovernanceTypesPresent: Boolean +) : GovernanceAdditionalState { + + override val identifier: String = governanceType.name + + override fun format(resourceManager: ResourceManager): String? { + val shouldShowSuffix = multipleGovernanceTypesPresent || governanceType == Chain.Governance.V2 + if (!shouldShowSuffix) return null + + return when (governanceType) { + Chain.Governance.V1 -> resourceManager.getString(R.string.assets_balance_details_locks_democrac_v1) + Chain.Governance.V2 -> resourceManager.getString(R.string.assets_balance_details_locks_democrac_v2) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/dapps/GovernanceDAppsSyncService.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/dapps/GovernanceDAppsSyncService.kt new file mode 100644 index 0000000..400db82 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/dapps/GovernanceDAppsSyncService.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_governance_impl.data.dapps + +import io.novafoundation.nova.common.utils.retryUntilDone +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.core_db.model.GovernanceDAppLocal +import io.novafoundation.nova.feature_governance_impl.data.dapps.remote.GovernanceDappsFetcher +import io.novafoundation.nova.feature_governance_impl.data.dapps.remote.model.GovernanceChainDappsRemote +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class GovernanceDAppsSyncService( + private val dao: GovernanceDAppsDao, + private val dappsFetcher: GovernanceDappsFetcher +) { + + suspend fun syncDapps() = withContext(Dispatchers.Default) { + val newDapps = retryUntilDone { + mapRemoteDappsToLocal(dappsFetcher.getDapps()) + } + dao.update(newDapps) + } + + private fun mapRemoteDappsToLocal( + chainDapps: List + ): List { + return chainDapps.flatMap { chainWithDapp -> + chainWithDapp.dapps.map { + GovernanceDAppLocal( + chainWithDapp.chainId, + it.title, + it.urlV1, + it.urlV2, + it.icon, + it.details + ) + } + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/dapps/remote/GovernanceDappsFetcher.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/dapps/remote/GovernanceDappsFetcher.kt new file mode 100644 index 0000000..e6def87 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/dapps/remote/GovernanceDappsFetcher.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_impl.data.dapps.remote + +import io.novafoundation.nova.feature_governance_impl.BuildConfig +import io.novafoundation.nova.feature_governance_impl.data.dapps.remote.model.GovernanceChainDappsRemote +import retrofit2.http.GET + +interface GovernanceDappsFetcher { + + @GET(BuildConfig.GOVERNANCE_DAPPS_URL) + suspend fun getDapps(): List +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/dapps/remote/model/GovernanceDappRemote.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/dapps/remote/model/GovernanceDappRemote.kt new file mode 100644 index 0000000..b1f22a3 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/dapps/remote/model/GovernanceDappRemote.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.data.dapps.remote.model + +class GovernanceChainDappsRemote( + val chainId: String, + val dapps: List +) + +class GovernanceDappRemote( + val title: String, + val urlV1: String?, + val urlV2: String?, + val icon: String, + val details: String +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/network/blockchain/extrinsic/ExtrinsicBuilderExt.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/network/blockchain/extrinsic/ExtrinsicBuilderExt.kt new file mode 100644 index 0000000..32d1ed9 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/network/blockchain/extrinsic/ExtrinsicBuilderExt.kt @@ -0,0 +1,184 @@ +package io.novafoundation.nova.feature_governance_impl.data.network.blockchain.extrinsic + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.argumentType +import io.novafoundation.nova.common.utils.democracy +import io.novafoundation.nova.common.utils.structOf +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novafoundation.nova.runtime.util.constructAccountLookupInstance +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import io.novasama.substrate_sdk_android.runtime.metadata.call + +fun ExtrinsicBuilder.convictionVotingVote( + referendumId: ReferendumId, + vote: AccountVote +): ExtrinsicBuilder { + return call( + moduleName = Modules.CONVICTION_VOTING, + callName = "vote", + arguments = mapOf( + "poll_index" to referendumId.value, + "vote" to vote.prepareForEncoding() + ) + ) +} + +fun CallBuilder.convictionVotingVote( + referendumId: ReferendumId, + vote: AccountVote +): CallBuilder { + return addCall( + moduleName = Modules.CONVICTION_VOTING, + callName = "vote", + arguments = mapOf( + "poll_index" to referendumId.value, + "vote" to vote.prepareForEncoding() + ) + ) +} + +fun ExtrinsicBuilder.convictionVotingUnlock( + trackId: TrackId, + accountId: AccountId +): ExtrinsicBuilder { + return call( + moduleName = Modules.CONVICTION_VOTING, + callName = "unlock", + arguments = mapOf( + "class" to trackId.value, + "target" to PezkuwiAddressConstructor.constructInstance(runtime.typeRegistry, accountId) + ) + ) +} + +fun ExtrinsicBuilder.convictionVotingRemoveVote( + trackId: TrackId, + referendumId: ReferendumId, +): ExtrinsicBuilder { + return call( + moduleName = Modules.CONVICTION_VOTING, + callName = "remove_vote", + arguments = mapOf( + "class" to trackId.value, + "index" to referendumId.value + ) + ) +} + +fun ExtrinsicBuilder.democracyVote( + referendumId: ReferendumId, + vote: AccountVote +): ExtrinsicBuilder { + return call( + moduleName = Modules.DEMOCRACY, + callName = "vote", + arguments = mapOf( + "ref_index" to referendumId.value, + "vote" to vote.prepareForEncoding() + ) + ) +} + +fun CallBuilder.democracyVote( + referendumId: ReferendumId, + vote: AccountVote +): CallBuilder { + return addCall( + moduleName = Modules.DEMOCRACY, + callName = "vote", + arguments = mapOf( + "ref_index" to referendumId.value, + "vote" to vote.prepareForEncoding() + ) + ) +} + +fun ExtrinsicBuilder.democracyUnlock(accountId: AccountId): ExtrinsicBuilder { + val accountLookupType = runtime.metadata.democracy().call("unlock").argumentType("target") + + return call( + moduleName = Modules.DEMOCRACY, + callName = "unlock", + arguments = mapOf( + "target" to accountLookupType.constructAccountLookupInstance(accountId) + ) + ) +} + +fun ExtrinsicBuilder.democracyRemoveVote( + referendumId: ReferendumId, +): ExtrinsicBuilder { + return call( + moduleName = Modules.DEMOCRACY, + callName = "remove_vote", + arguments = mapOf( + "index" to referendumId.value + ) + ) +} + +fun CallBuilder.convictionVotingDelegate( + delegate: AccountId, + trackId: TrackId, + amount: Balance, + conviction: Conviction +): CallBuilder { + return addCall( + moduleName = Modules.CONVICTION_VOTING, + callName = "delegate", + arguments = mapOf( + "class" to trackId.value, + "to" to PezkuwiAddressConstructor.constructInstance(runtime.typeRegistry, delegate), + "conviction" to conviction.prepareForEncoding(), + "balance" to amount + ) + ) +} + +fun CallBuilder.convictionVotingUndelegate(trackId: TrackId): CallBuilder { + return addCall( + moduleName = Modules.CONVICTION_VOTING, + callName = "undelegate", + arguments = mapOf( + "class" to trackId.value, + ) + ) +} + +private fun Conviction.prepareForEncoding(): Any { + return DictEnum.Entry(name, null) +} + +private fun AccountVote.prepareForEncoding(): Any { + return when (this) { + AccountVote.Unsupported -> error("Not yet supported") + + is AccountVote.Standard -> DictEnum.Entry( + name = "Standard", + value = structOf( + "vote" to vote, + "balance" to balance + ) + ) + + is AccountVote.SplitAbstain -> DictEnum.Entry( + name = "SplitAbstain", + value = structOf( + "aye" to this.aye, + "nay" to this.nay, + "abstain" to this.abstain + ) + ) + + else -> error("Not supported yet") + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/OffChainReferendaDataSource.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/OffChainReferendaDataSource.kt new file mode 100644 index 0000000..172abd7 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/OffChainReferendaDataSource.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumPreview + +interface OffChainReferendaDataSource { + + suspend fun referendumPreviews(baseUrl: String, options: O): List + + suspend fun referendumDetails(referendumId: ReferendumId, baseUrl: String, options: O): OffChainReferendumDetails? +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/metadata/DelegateMetadataApi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/metadata/DelegateMetadataApi.kt new file mode 100644 index 0000000..d199003 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/metadata/DelegateMetadataApi.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.metadata + +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.metadata.response.DelegateMetadataRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import retrofit2.http.GET +import retrofit2.http.Path + +interface DelegateMetadataApi { + + companion object { + const val BASE_URL = "https://raw.githubusercontent.com/pezkuwichain/opengov-delegate-registry/master/registry/" + } + + @GET("{fileName}") + suspend fun getDelegatesMetadata( + @Path("fileName") fileName: String, + ): List +} + +suspend fun DelegateMetadataApi.getDelegatesMetadata(chain: Chain): List { + return getDelegatesMetadata(fileNameFor(chain)) +} + +private fun fileNameFor(chain: Chain): String { + val withoutExtension = chain.name.lowercase() + + return "$withoutExtension.json" +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/metadata/response/DelegateMetadataRemote.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/metadata/response/DelegateMetadataRemote.kt new file mode 100644 index 0000000..7ce8938 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/metadata/response/DelegateMetadataRemote.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.metadata.response + +class DelegateMetadataRemote( + val address: String, + val name: String, + val image: String, + val shortDescription: String, + val longDescription: String?, + val isOrganization: Boolean +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/DelegationsSubqueryApi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/DelegationsSubqueryApi.kt new file mode 100644 index 0000000..62344b4 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/DelegationsSubqueryApi.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.AllHistoricalVotesRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.ReferendumVotersRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DelegateDelegatorsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DelegateDetailedStatsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DelegateStatsByAddressesRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DelegateStatsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DirectHistoricalVotesRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.ReferendumSplitAbstainVotersRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.ReferendumVotesRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.AllVotesResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.DelegateDelegatorsResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.DelegateDetailedStatsResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.DelegateStatsResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.DirectVotesResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.ReferendumSplitAbstainVotersResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.ReferendumVotersResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.ReferendumVotesResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface DelegationsSubqueryApi { + + @POST + suspend fun getDelegateStats( + @Url url: String, + @Body body: DelegateStatsRequest + ): SubQueryResponse + + @POST + suspend fun getDelegateStats( + @Url url: String, + @Body body: DelegateStatsByAddressesRequest + ): SubQueryResponse + + @POST + suspend fun getDetailedDelegateStats( + @Url url: String, + @Body body: DelegateDetailedStatsRequest + ): SubQueryResponse + + @POST + suspend fun getDelegateDelegators( + @Url url: String, + @Body body: DelegateDelegatorsRequest + ): SubQueryResponse + + @POST + suspend fun getAllHistoricalVotes( + @Url url: String, + @Body body: AllHistoricalVotesRequest + ): SubQueryResponse + + @POST + suspend fun getDirectHistoricalVotes( + @Url url: String, + @Body body: DirectHistoricalVotesRequest + ): SubQueryResponse + + @POST + suspend fun getReferendumVoters( + @Url url: String, + @Body body: ReferendumVotersRequest + ): SubQueryResponse + + @POST + suspend fun getReferendumVotes( + @Url url: String, + @Body body: ReferendumVotesRequest + ): SubQueryResponse + + @POST + suspend fun getReferendumAbstainVoters( + @Url url: String, + @Body body: ReferendumSplitAbstainVotersRequest + ): SubQueryResponse +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/AllHistoricalVotesRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/AllHistoricalVotesRequest.kt new file mode 100644 index 0000000..9cbd91c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/AllHistoricalVotesRequest.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request + +import io.novafoundation.nova.feature_governance_api.data.repository.common.RecentVotesDateThreshold +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.common.createSubqueryFilter + +class AllHistoricalVotesRequest(address: String) { + + val query = """ + query { + castingVotings(filter: { voter: {equalTo: "$address"}}) { + nodes { + referendumId + standardVote + splitVote + splitAbstainVote + } + } + + delegatorVotings(filter: {delegator: {equalTo: "$address"}}) { + nodes { + vote + parent { + referendumId + delegateId + standardVote + } + } + } + } + """.trimIndent() +} + +class DirectHistoricalVotesRequest(address: String, recentVotesDateThreshold: RecentVotesDateThreshold) { + + val query = """ + query { + castingVotings(filter: { and: { voter: {equalTo: "$address"}, ${recentVotesDateThreshold.createSubqueryFilter()}}}) { + nodes { + referendumId + standardVote + splitVote + splitAbstainVote + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateDelegatorsRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateDelegatorsRequest.kt new file mode 100644 index 0000000..26fea6b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateDelegatorsRequest.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request + +class DelegateDelegatorsRequest(delegateAddress: String) { + val query = """ + query { + delegations(filter: {delegateId: {equalTo: "$delegateAddress" }}) { + nodes { + delegator + delegation + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateDetailedStatsRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateDetailedStatsRequest.kt new file mode 100644 index 0000000..8c9bd32 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateDetailedStatsRequest.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request + +import io.novafoundation.nova.feature_governance_api.data.repository.common.RecentVotesDateThreshold +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.common.createSubqueryFilter + +class DelegateDetailedStatsRequest(delegateAddress: String, recentVotesDateThreshold: RecentVotesDateThreshold) { + val query = """ + query { + delegates(filter: {accountId: {equalTo: "$delegateAddress"}}) { + nodes { + accountId + delegators + delegatorVotes + allVotes: delegateVotes { + totalCount + } + recentVotes: delegateVotes(filter: {${recentVotesDateThreshold.createSubqueryFilter()}}) { + totalCount + } + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateStatsByAddressesRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateStatsByAddressesRequest.kt new file mode 100644 index 0000000..4e9717b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateStatsByAddressesRequest.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request + +import io.novafoundation.nova.feature_governance_api.data.repository.common.RecentVotesDateThreshold +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.common.createSubqueryFilter + +class DelegateStatsByAddressesRequest(recentVotesDateThreshold: RecentVotesDateThreshold, val addresses: List) { + val query = """ + query { + delegates(filter:{accountId:{in:[${getAddresses()}]}}) { + totalCount + nodes { + accountId + delegators + delegatorVotes + delegateVotes(filter: {${recentVotesDateThreshold.createSubqueryFilter()}}) { + totalCount + } + } + } + } + """.trimIndent() + + private fun getAddresses(): String { + return addresses.joinToString { "\"$it\"" } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateStatsRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateStatsRequest.kt new file mode 100644 index 0000000..8ed896d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/DelegateStatsRequest.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request + +import io.novafoundation.nova.feature_governance_api.data.repository.common.RecentVotesDateThreshold +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.common.createSubqueryFilter + +class DelegateStatsRequest(recentVotesDateThreshold: RecentVotesDateThreshold) { + val query = """ + query { + delegates { + totalCount + nodes { + accountId + delegators + delegatorVotes + delegateVotes(filter: {${recentVotesDateThreshold.createSubqueryFilter()}}) { + totalCount + } + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/ReferendumSplitAbstainVotersRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/ReferendumSplitAbstainVotersRequest.kt new file mode 100644 index 0000000..8ab9096 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/ReferendumSplitAbstainVotersRequest.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request + +import java.math.BigInteger + +class ReferendumSplitAbstainVotersRequest(referendumId: BigInteger) { + val query = """ + query { + referendum(id:"$referendumId") { + trackId + castingVotings(filter: {splitAbstainVote: {isNull: false}}) { + nodes { + splitAbstainVote + } + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/ReferendumVotersRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/ReferendumVotersRequest.kt new file mode 100644 index 0000000..03cf470 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/ReferendumVotersRequest.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request + +import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.anyOf +import java.math.BigInteger + +class ReferendumVotersRequest(referendumId: BigInteger, isAye: Boolean) { + val query = """ + query { + castingVotings(filter:{referendumId:{equalTo:"$referendumId"}, ${voteTypeFilter(isAye)}}) { + nodes { + voter + standardVote + splitVote + splitAbstainVote + delegateId + delegatorVotes { + nodes { + delegator + vote + } + } + } + } + } + """.trimIndent() + + private fun voteTypeFilter(isAye: Boolean): String { + return anyOf(standardVoteFilter(isAye), splitVoteFilter(), splitAbstainVote()) + } + + // we cannot filter JSON field by checking splitVote.ayeAmount > 0 so it should be done after request + private fun splitVoteFilter(): String { + return "splitVote: {isNull: false}" + } + + private fun splitAbstainVote(): String { + return "splitAbstainVote: {isNull: false}" + } + + private fun standardVoteFilter(isAye: Boolean): String { + return "standardVote: {contains: {aye: $isAye}}" + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/ReferendumVotesRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/ReferendumVotesRequest.kt new file mode 100644 index 0000000..fb909ef --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/ReferendumVotesRequest.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request + +import java.math.BigInteger + +class ReferendumVotesRequest(referendumId: BigInteger) { + val query = """ + query { + referendum(id:"$referendumId") { + trackId + castingVotings { + nodes { + splitVote + splitAbstainVote + standardVote + delegatorVotes { + nodes { + vote + } + } + } + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/common/Ext.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/common/Ext.kt new file mode 100644 index 0000000..73cfa33 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/request/common/Ext.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.common + +import io.novafoundation.nova.feature_governance_api.data.repository.common.RecentVotesDateThreshold +import kotlin.time.Duration.Companion.milliseconds + +fun RecentVotesDateThreshold.createSubqueryFilter(): String { + return when (this) { + is RecentVotesDateThreshold.BlockNumber -> "at: {greaterThanOrEqualTo: ${number.toLong()}}" + is RecentVotesDateThreshold.Timestamp -> "timestamp: {greaterThanOrEqualTo: ${timestampMs.milliseconds.inWholeSeconds}}" + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/AllVotesResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/AllVotesResponse.kt new file mode 100644 index 0000000..29e1b76 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/AllVotesResponse.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response + +import com.google.gson.annotations.SerializedName +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import java.math.BigInteger + +class AllVotesResponse( + @SerializedName("castingVotings") val direct: SubQueryNodes, + @SerializedName("delegatorVotings") val delegated: SubQueryNodes +) + +class DirectVotesResponse( + @SerializedName("castingVotings") val direct: SubQueryNodes, +) + +class DirectVoteRemote( + val referendumId: BigInteger, + override val standardVote: StandardVoteRemote?, + override val splitVote: SplitVoteRemote?, + override val splitAbstainVote: SplitAbstainVoteRemote? +) : MultiVoteRemote + +class DelegatedVoteRemote( + val vote: VoteRemote, + val parent: Parent +) { + + class Parent( + val referendumId: BigInteger, + val delegateId: String, + val standardVote: StandardVoteRemote? + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/DelegateDelegatorsResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/DelegateDelegatorsResponse.kt new file mode 100644 index 0000000..7e0cd6c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/DelegateDelegatorsResponse.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response + +import com.google.gson.annotations.SerializedName +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes + +class DelegateDelegatorsResponse( + val delegations: SubQueryNodes +) { + + class DelegatorRemote( + @SerializedName("delegator") val address: String, + val delegation: VoteRemote + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/DelegateDetailedStatsResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/DelegateDetailedStatsResponse.kt new file mode 100644 index 0000000..7ca006f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/DelegateDetailedStatsResponse.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response + +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import io.novafoundation.nova.common.data.network.subquery.SubQueryTotalCount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class DelegateDetailedStatsResponse( + val delegates: SubQueryNodes +) { + + class Delegate( + val delegators: Int, + val delegatorVotes: Balance, + val recentVotes: SubQueryTotalCount, + val allVotes: SubQueryTotalCount + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/DelegateStatsResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/DelegateStatsResponse.kt new file mode 100644 index 0000000..adc46c7 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/DelegateStatsResponse.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response + +import com.google.gson.annotations.SerializedName +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import io.novafoundation.nova.common.data.network.subquery.SubQueryTotalCount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class DelegateStatsResponse( + val delegates: SubQueryNodes +) { + + class Delegate( + @SerializedName("accountId") val address: String, + val delegators: Int, + val delegatorVotes: Balance, + val delegateVotes: SubQueryTotalCount + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/ReferendumSplitAbstainVotersResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/ReferendumSplitAbstainVotersResponse.kt new file mode 100644 index 0000000..eda0ce1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/ReferendumSplitAbstainVotersResponse.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response + +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import java.math.BigInteger + +class ReferendumSplitAbstainVotersResponse( + val referendum: Referendum +) { + + class Referendum(val trackId: BigInteger, val castingVotings: SubQueryNodes) + + class Voter( + val splitAbstainVote: SplitAbstainVoteRemote? + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/ReferendumVotersResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/ReferendumVotersResponse.kt new file mode 100644 index 0000000..a7a4b27 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/ReferendumVotersResponse.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response + +import com.google.gson.annotations.SerializedName +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes + +class ReferendumVotersResponse( + @SerializedName("castingVotings") val voters: SubQueryNodes +) + +class ReferendumVoterRemote( + @SerializedName("voter") val voterId: String, + val delegateId: String, + override val standardVote: StandardVoteRemote?, + override val splitVote: SplitVoteRemote?, + override val splitAbstainVote: SplitAbstainVoteRemote?, + val delegatorVotes: SubQueryNodes +) : MultiVoteRemote + +class ReferendumDelegatorVoteRemote( + @SerializedName("delegator") val delegatorId: String, + val vote: VoteRemote +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/ReferendumVotesResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/ReferendumVotesResponse.kt new file mode 100644 index 0000000..0c66d78 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/ReferendumVotesResponse.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response + +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import java.math.BigInteger + +class ReferendumVotesResponse( + val referendum: Referendum +) { + + class Referendum(val trackId: BigInteger, val castingVotings: SubQueryNodes) + + class Vote( + override val standardVote: StandardVoteRemote?, + override val splitVote: SplitVoteRemote?, + override val splitAbstainVote: SplitAbstainVoteRemote?, + val delegatorVotes: SubQueryNodes + ) : MultiVoteRemote + + class DelegatorVote( + val vote: VoteRemote + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/VoteRemote.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/VoteRemote.kt new file mode 100644 index 0000000..3076e92 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/delegation/v2/stats/response/VoteRemote.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Vote +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.mapConvictionFromString +import java.math.BigInteger + +class StandardVoteRemote(val aye: Boolean, val vote: VoteRemote) + +class SplitVoteRemote(val ayeAmount: Balance, val nayAmount: Balance) + +class SplitAbstainVoteRemote(val ayeAmount: Balance, val nayAmount: Balance, val abstainAmount: Balance) + +interface MultiVoteRemote { + + val standardVote: StandardVoteRemote? + + val splitVote: SplitVoteRemote? + + val splitAbstainVote: SplitAbstainVoteRemote? +} + +fun mapMultiVoteRemoteToAccountVote(vote: MultiVoteRemote): AccountVote { + val standard = vote.standardVote + val split = vote.splitVote + val splitAbstain = vote.splitAbstainVote + + return when { + standard != null -> AccountVote.Standard( + balance = standard.vote.amount, + vote = Vote( + aye = standard.aye, + conviction = mapConvictionFromString(standard.vote.conviction) + ) + ) + split != null -> AccountVote.Split( + aye = split.ayeAmount, + nay = split.nayAmount + ) + splitAbstain != null -> AccountVote.SplitAbstain( + aye = splitAbstain.ayeAmount, + nay = splitAbstain.nayAmount, + abstain = splitAbstain.abstainAmount + ) + else -> AccountVote.Unsupported + } +} + +class VoteRemote(val amount: BigInteger, val conviction: String) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/PolkassemblyV1Api.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/PolkassemblyV1Api.kt new file mode 100644 index 0000000..8efb58b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/PolkassemblyV1Api.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1 + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request.ParachainReferendumDetailsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request.ReferendumDetailsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request.ParachainReferendumPreviewRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request.ReferendumPreviewRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response.ParachainReferendaPreviewResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response.ReferendaPreviewResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response.ReferendumDetailsResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface PolkassemblyV1Api { + + @POST + suspend fun getReferendumPreviews( + @Url url: String, + @Body body: ReferendumPreviewRequest + ): SubQueryResponse + + @POST + suspend fun getParachainReferendumPreviews( + @Url url: String, + @Body body: ParachainReferendumPreviewRequest + ): SubQueryResponse + + @POST + suspend fun getReferendumDetails( + @Url url: String, + @Body body: ReferendumDetailsRequest + ): SubQueryResponse + + @POST + suspend fun getParachainReferendumDetails( + @Url url: String, + @Body body: ParachainReferendumDetailsRequest + ): SubQueryResponse +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/PolkassemblyV1ReferendaDataSource.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/PolkassemblyV1ReferendaDataSource.kt new file mode 100644 index 0000000..2de37aa --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/PolkassemblyV1ReferendaDataSource.kt @@ -0,0 +1,118 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1 + +import io.novafoundation.nova.common.utils.formatting.parseDateISO_8601 +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline +import io.novafoundation.nova.feature_governance_impl.data.offchain.OffChainReferendaDataSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request.ParachainReferendumDetailsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request.ParachainReferendumPreviewRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request.ReferendumDetailsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request.ReferendumPreviewRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response.ParachainReferendaPreviewResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response.ReferendaPreviewResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response.ReferendumDetailsResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response.getId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ExternalApi.GovernanceReferenda.Source + +class PolkassemblyV1ReferendaDataSource( + private val polkassemblyApi: PolkassemblyV1Api, +) : OffChainReferendaDataSource { + + override suspend fun referendumPreviews(baseUrl: String, options: Source.Polkassembly): List { + val polkassemblyNetwork = options.network + + return if (polkassemblyNetwork == null) { + referendaRelaychainRequest(baseUrl) + } else { + referendaParachainRequest(baseUrl, polkassemblyNetwork) + } + } + + override suspend fun referendumDetails(referendumId: ReferendumId, baseUrl: String, options: Source.Polkassembly): OffChainReferendumDetails? { + val polkassemblyNetwork = options.network + + val referendumDetails = if (polkassemblyNetwork == null) { + detailsRelaychain(baseUrl, referendumId) + } else { + detailsParachain(baseUrl, polkassemblyNetwork, referendumId) + } + + return referendumDetails?.let(::mapPolkassemblyPostToDetails) + } + + private suspend fun referendaRelaychainRequest(url: String): List { + val request = ReferendumPreviewRequest() + val response = polkassemblyApi.getReferendumPreviews(url, request) + return response.data.posts.map { + mapPolkassemblyPostToPreview(it) + } + } + + private suspend fun referendaParachainRequest(url: String, network: String): List { + val request = ParachainReferendumPreviewRequest(network) + val response = polkassemblyApi.getParachainReferendumPreviews(url, request) + return response.data.posts.map { + mapParachainPolkassemblyPostToPreview(it) + } + } + + private suspend fun detailsRelaychain(url: String, referendumId: ReferendumId): ReferendumDetailsResponse.Post? { + val request = ReferendumDetailsRequest(referendumId.value) + val response = polkassemblyApi.getReferendumDetails(url, request) + return response.data.posts.firstOrNull() + } + + private suspend fun detailsParachain(url: String, network: String, referendumId: ReferendumId): ReferendumDetailsResponse.Post? { + val request = ParachainReferendumDetailsRequest(network, referendumId.value) + val response = polkassemblyApi.getParachainReferendumDetails(url, request) + return response.data.posts.firstOrNull() + } + + private fun mapPolkassemblyPostToPreview(post: ReferendaPreviewResponse.Post): OffChainReferendumPreview { + return OffChainReferendumPreview( + post.title, + ReferendumId(post.getId()), + ) + } + + private fun mapParachainPolkassemblyPostToPreview(post: ParachainReferendaPreviewResponse.Post): OffChainReferendumPreview { + return OffChainReferendumPreview( + post.title, + ReferendumId(post.getId()), + ) + } + + private fun mapPolkassemblyPostToDetails(post: ReferendumDetailsResponse.Post): OffChainReferendumDetails { + val timeline = post.onchainLink + ?.onchainReferendum + ?.getOrNull(0) + ?.referendumStatus + ?.map { + mapReferendumStatusToTimelineEntry(it) + } + + return OffChainReferendumDetails( + title = post.title, + description = post.content, + proposerName = null, // author of the post on PA might not be equal to on-chain submitter so we want to be safe here + proposerAddress = post.onchainLink?.proposerAddress, + timeLine = timeline + ) + } + + private fun mapReferendumStatusToTimelineEntry(status: ReferendumDetailsResponse.Status): ReferendumTimeline.Entry { + val timelineState = when (status.status) { + "Started" -> ReferendumTimeline.State.CREATED + "Passed" -> ReferendumTimeline.State.APPROVED + "NotPassed" -> ReferendumTimeline.State.REJECTED + "Executed" -> ReferendumTimeline.State.EXECUTED + else -> error("Unkonown referendum status") + } + + val statusDate = parseDateISO_8601(status.blockNumber.startDateTime) + + return ReferendumTimeline.Entry(timelineState, statusDate?.time) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ParachainReferendumDetailsRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ParachainReferendumDetailsRequest.kt new file mode 100644 index 0000000..abdd449 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ParachainReferendumDetailsRequest.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request + +import java.math.BigInteger + +class ParachainReferendumDetailsRequest(network: String, id: BigInteger) { + val query = """ + query { + posts( + where: {onchain_link: {onchain_network_referendum_id: {_eq: "${network}_$id"}}} + ) { + title + content + author { + username + } + onchain_link { + onchain_referendum { + referendumStatus { + blockNumber { + startDateTime + number + } + status + } + } + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ParachainReferendumPreviewRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ParachainReferendumPreviewRequest.kt new file mode 100644 index 0000000..886cabd --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ParachainReferendumPreviewRequest.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request + +class ParachainReferendumPreviewRequest(networkName: String?) { + val query = """ + query { + posts( + where: {type: {id: {_eq: 2}}, network: {_eq: $networkName}, onchain_link: {onchain_network_referendum_id: {_is_null: false}}} + ) { + title + onchain_link { + onchain_referendum { + referendumId + } + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ReferendumDetailsRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ReferendumDetailsRequest.kt new file mode 100644 index 0000000..2550e75 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ReferendumDetailsRequest.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request + +import java.math.BigInteger + +class ReferendumDetailsRequest(id: BigInteger) { + val query = """ + query { + posts( + where: {onchain_link: {onchain_referendum_id: {_eq: $id}}} + ) { + title + content + author { + username + } + onchain_link { + onchain_referendum { + referendumStatus { + blockNumber { + startDateTime + number + } + status + } + } + proposer_address + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ReferendumPreviewRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ReferendumPreviewRequest.kt new file mode 100644 index 0000000..50e3ba7 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/request/ReferendumPreviewRequest.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.request + +class ReferendumPreviewRequest { + val query = """ + query { + posts( + where: {type: {id: {_eq: 2}}, onchain_link: {onchain_referendum_id: {_is_null: false}}} + ) { + title + onchain_link { + onchain_referendum_id + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ParachainReferendaPreviewResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ParachainReferendaPreviewResponse.kt new file mode 100644 index 0000000..d2396b4 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ParachainReferendaPreviewResponse.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response + +import com.google.gson.annotations.SerializedName +import java.math.BigInteger + +class ParachainReferendaPreviewResponse( + val posts: List +) { + + class Post( + val title: String?, + @SerializedName("onchain_link") val onChainLink: OnChainLink + ) + + class OnChainLink( + @SerializedName("onchain_referendum") val onChainReferendum: List + ) + + class OnChainReferendum( + val referendumId: BigInteger + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ReferendaPreviewResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ReferendaPreviewResponse.kt new file mode 100644 index 0000000..971ebc5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ReferendaPreviewResponse.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response + +import com.google.gson.annotations.SerializedName +import java.math.BigInteger + +class ReferendaPreviewResponse( + val posts: List +) { + + class Post( + val title: String?, + @SerializedName("onchain_link") val onChainLink: OnChainLink + ) + + class OnChainLink( + @SerializedName("onchain_referendum_id") val onChainReferendumId: BigInteger, + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ReferendumApiExt.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ReferendumApiExt.kt new file mode 100644 index 0000000..32714e0 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ReferendumApiExt.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response + +import java.math.BigInteger + +fun ParachainReferendaPreviewResponse.Post.getId(): BigInteger { + return onChainLink.onChainReferendum[0].referendumId +} + +fun ReferendaPreviewResponse.Post.getId(): BigInteger { + return onChainLink.onChainReferendumId +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ReferendumDetailsResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ReferendumDetailsResponse.kt new file mode 100644 index 0000000..96a3672 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v1/response/ReferendumDetailsResponse.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.response + +import com.google.gson.annotations.SerializedName +import java.math.BigInteger + +class ReferendumDetailsResponse( + val posts: List +) { + + class Post( + val title: String?, + val content: String, + val author: Author, + @SerializedName("onchain_link") val onchainLink: OnChainLink? + ) + + class Author(val username: String) + + class OnChainLink( + @SerializedName("onchain_referendum") val onchainReferendum: List?, + @SerializedName("proposer_address") val proposerAddress: String, + ) + + class OnChainReferendum( + val referendumStatus: List + ) + + class Status( + val blockNumber: BlockNumber, + val status: String + ) + + class BlockNumber( + val startDateTime: String, + val number: BigInteger + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/PolkassemblyV2Api.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/PolkassemblyV2Api.kt new file mode 100644 index 0000000..46283fe --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/PolkassemblyV2Api.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2 + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.request.ReferendumDetailsV2Request +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.request.ReferendumPreviewV2Request +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.response.ReferendaPreviewV2Response +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.response.ReferendumDetailsV2Response +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface PolkassemblyV2Api { + + @POST + suspend fun getReferendumPreviews( + @Url url: String, + @Body body: ReferendumPreviewV2Request + ): SubQueryResponse + + @POST + suspend fun getReferendumDetails( + @Url url: String, + @Body body: ReferendumDetailsV2Request + ): SubQueryResponse +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/PolkassemblyV2ReferendaDataSource.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/PolkassemblyV2ReferendaDataSource.kt new file mode 100644 index 0000000..1365348 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/PolkassemblyV2ReferendaDataSource.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2 + +import io.novafoundation.nova.common.utils.formatting.parseDateISO_8601 +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline +import io.novafoundation.nova.feature_governance_impl.data.offchain.OffChainReferendaDataSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.request.ReferendumDetailsV2Request +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.request.ReferendumPreviewV2Request +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.response.ReferendaPreviewV2Response +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.response.ReferendumDetailsV2Response +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ExternalApi.GovernanceReferenda.Source + +class PolkassemblyV2ReferendaDataSource( + private val polkassemblyApi: PolkassemblyV2Api +) : OffChainReferendaDataSource { + + override suspend fun referendumPreviews(baseUrl: String, options: Source.Polkassembly): List { + val request = ReferendumPreviewV2Request() + val response = polkassemblyApi.getReferendumPreviews(baseUrl, request) + + return response.data.posts.map(::mapPolkassemblyPostToPreview) + } + + override suspend fun referendumDetails(referendumId: ReferendumId, baseUrl: String, options: Source.Polkassembly): OffChainReferendumDetails? { + val request = ReferendumDetailsV2Request(referendumId.value) + val response = polkassemblyApi.getReferendumDetails(baseUrl, request) + val referendumDetails = response.data.posts.firstOrNull() + + return referendumDetails?.let(::mapPolkassemblyPostToDetails) + } + + private fun mapPolkassemblyPostToPreview(post: ReferendaPreviewV2Response.Post): OffChainReferendumPreview { + return OffChainReferendumPreview( + title = post.title, + referendumId = ReferendumId(post.onChainLink.onChainReferendumId), + ) + } + + private fun mapPolkassemblyPostToDetails(post: ReferendumDetailsV2Response.Post): OffChainReferendumDetails { + val timeline = post.onchainLink.onchainReferendum + .firstOrNull() + ?.referendumStatus + ?.mapNotNull { + mapReferendumStatusToTimelineEntry(it) + } + + return OffChainReferendumDetails( + title = post.title, + description = post.content, + proposerAddress = null, + proposerName = post.author.username, + timeLine = timeline + ) + } + + private fun mapReferendumStatusToTimelineEntry(status: ReferendumDetailsV2Response.Status): ReferendumTimeline.Entry? { + val timelineState = when (status.status) { + "Submitted" -> ReferendumTimeline.State.CREATED + "Ongoing" -> ReferendumTimeline.State.CREATED + "Approved" -> ReferendumTimeline.State.APPROVED + "Rejected" -> ReferendumTimeline.State.REJECTED + "Cancelled" -> ReferendumTimeline.State.CANCELLED + "TimedOut" -> ReferendumTimeline.State.TIMED_OUT + "Killed" -> ReferendumTimeline.State.KILLED + "Executed" -> ReferendumTimeline.State.EXECUTED + else -> null + } + + val statusDate = parseDateISO_8601(status.blockNumber.startDateTime) + + return timelineState?.let { + ReferendumTimeline.Entry(timelineState, statusDate?.time) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/request/ReferendumDetailsV2Request.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/request/ReferendumDetailsV2Request.kt new file mode 100644 index 0000000..d7c20f6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/request/ReferendumDetailsV2Request.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.request + +import java.math.BigInteger + +class ReferendumDetailsV2Request(id: BigInteger) { + val query = """ + query { + posts( + where: {onchain_link: {onchain_referendumv2_id: {_eq: $id}}} + ) { + title + content + author { + username + } + onchain_link { + onchain_referendumv2 { + referendumStatus { + blockNumber { + startDateTime + number + } + status + } + } + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/request/ReferendumPreviewV2Request.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/request/ReferendumPreviewV2Request.kt new file mode 100644 index 0000000..e4af471 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/request/ReferendumPreviewV2Request.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.request + +class ReferendumPreviewV2Request { + val query = """ + query { + posts( + where: {onchain_link: {onchain_referendumv2_id: {_is_null: false}}} + ) { + title + onchain_link { + onchain_referendumv2_id + } + } + } + """.trimIndent() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/response/ReferendaPreviewV2Response.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/response/ReferendaPreviewV2Response.kt new file mode 100644 index 0000000..35bccbc --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/response/ReferendaPreviewV2Response.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.response + +import com.google.gson.annotations.SerializedName +import java.math.BigInteger + +class ReferendaPreviewV2Response( + val posts: List +) { + + class Post( + val title: String?, + @SerializedName("onchain_link") val onChainLink: OnChainLink + ) + + class OnChainLink(@SerializedName("onchain_referendumv2_id") val onChainReferendumId: BigInteger) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/response/ReferendumDetailsV2Response.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/response/ReferendumDetailsV2Response.kt new file mode 100644 index 0000000..414ae5e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/polkassembly/v2/response/ReferendumDetailsV2Response.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.response + +import com.google.gson.annotations.SerializedName +import java.math.BigInteger + +class ReferendumDetailsV2Response( + val posts: List +) { + + class Post( + val title: String?, + val content: String, + val author: Author, + @SerializedName("onchain_link") val onchainLink: OnChainLink + ) + + class Author(val username: String) + + class OnChainLink( + @SerializedName("onchain_referendumv2") val onchainReferendum: List + ) + + class OnChainReferendum( + val referendumStatus: List + ) + + class Status( + val blockNumber: BlockNumber, + val status: String + ) + + class BlockNumber( + val startDateTime: String, + val number: BigInteger + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/SubSquareV1Api.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/SubSquareV1Api.kt new file mode 100644 index 0000000..d54b32f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/SubSquareV1Api.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1 + +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1.response.ReferendaPreviewV1Response +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1.response.ReferendumDetailsV1Response +import retrofit2.http.GET +import retrofit2.http.Query +import retrofit2.http.Url + +interface SubSquareV1Api { + + @GET + suspend fun getReferendumPreviews( + @Url url: String, + @Query("page_size") pageSize: Int = 1000 + ): ReferendaPreviewV1Response + + @GET + suspend fun getReferendumDetails(@Url url: String): ReferendumDetailsV1Response +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/SubSquareV1ReferendaDataSource.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/SubSquareV1ReferendaDataSource.kt new file mode 100644 index 0000000..a46d51c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/SubSquareV1ReferendaDataSource.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1 + +import io.novafoundation.nova.common.utils.ensureSuffix +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline +import io.novafoundation.nova.feature_governance_impl.data.offchain.OffChainReferendaDataSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1.response.ReferendaPreviewV1Response +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1.response.ReferendumDetailsV1Response +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ExternalApi.GovernanceReferenda.Source + +class SubSquareV1ReferendaDataSource( + private val subSquareApi: SubSquareV1Api, +) : OffChainReferendaDataSource { + + override suspend fun referendumPreviews(baseUrl: String, options: Source.SubSquare): List { + val fullUrl = previewsUrlOf(baseUrl) + + val response = subSquareApi.getReferendumPreviews(fullUrl) + + return response.items.map(::mapReferendumPreviewResponseToPreview) + } + + override suspend fun referendumDetails(referendumId: ReferendumId, baseUrl: String, options: Source.SubSquare): OffChainReferendumDetails? { + val fullUrl = detailsUrlOf(baseUrl, referendumId) + + val response = subSquareApi.getReferendumDetails(fullUrl) + + return mapReferendumDetailsResponseToDetails(response) + } + + private fun mapReferendumPreviewResponseToPreview(post: ReferendaPreviewV1Response.Referendum): OffChainReferendumPreview { + return OffChainReferendumPreview( + title = post.title, + referendumId = ReferendumId(post.referendumIndex), + ) + } + + private fun mapReferendumDetailsResponseToDetails(referendum: ReferendumDetailsV1Response): OffChainReferendumDetails { + val timeline = referendum.onchainData.timeline.mapNotNull(::mapReferendumStatusToTimelineEntry) + + return OffChainReferendumDetails( + title = referendum.title, + description = referendum.content, + proposerAddress = referendum.author?.address, + proposerName = referendum.author?.username, + timeLine = timeline + ) + } + + private fun mapReferendumStatusToTimelineEntry(status: ReferendumDetailsV1Response.Status): ReferendumTimeline.Entry? { + val timelineState = when (status.method) { + "Started" -> ReferendumTimeline.State.CREATED + "Passed" -> ReferendumTimeline.State.APPROVED + "NotPassed" -> ReferendumTimeline.State.REJECTED + "Executed" -> ReferendumTimeline.State.EXECUTED + else -> null + } + + return timelineState?.let { + ReferendumTimeline.Entry(timelineState, status.indexer.blockTime) + } + } + + private fun previewsUrlOf(baseUrl: String) = baseUrl.ensureSuffix("/") + "democracy/referendums" + + private fun detailsUrlOf(baseUrl: String, referendumId: ReferendumId) = baseUrl.ensureSuffix("/") + "democracy/referendums/${referendumId.value}" +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/response/ReferendaPreviewV1Response.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/response/ReferendaPreviewV1Response.kt new file mode 100644 index 0000000..eac03da --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/response/ReferendaPreviewV1Response.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1.response + +import java.math.BigInteger + +class ReferendaPreviewV1Response( + val items: List +) { + + class Referendum( + val title: String?, + val referendumIndex: BigInteger + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/response/ReferendumDetailsV1Response.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/response/ReferendumDetailsV1Response.kt new file mode 100644 index 0000000..078e2c6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v1/response/ReferendumDetailsV1Response.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1.response + +class ReferendumDetailsV1Response( + val title: String?, + val content: String?, + val author: Author?, + val onchainData: OnChainData +) { + + class Author(val username: String?, val address: String?) + + class OnChainData(val timeline: List) + + class Status( + val indexer: IndexerState, + val method: String + ) + + class IndexerState( + val blockTime: Long + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/SubSquareV2Api.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/SubSquareV2Api.kt new file mode 100644 index 0000000..b8388a9 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/SubSquareV2Api.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2 + +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.response.ReferendaPreviewV2Response +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.response.ReferendumDetailsV2Response +import retrofit2.http.GET +import retrofit2.http.Query +import retrofit2.http.Url + +interface SubSquareV2Api { + + @GET + suspend fun getReferendumPreviews( + @Url url: String, + @Query("page_size") pageSize: Int = 100 + ): ReferendaPreviewV2Response + + @GET + suspend fun getReferendumDetails(@Url url: String): ReferendumDetailsV2Response +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/SubSquareV2ReferendaDataSource.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/SubSquareV2ReferendaDataSource.kt new file mode 100644 index 0000000..b363064 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/SubSquareV2ReferendaDataSource.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2 + +import io.novafoundation.nova.common.utils.ensureSuffix +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline +import io.novafoundation.nova.feature_governance_impl.data.offchain.OffChainReferendaDataSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.response.ReferendaPreviewV2Response +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.response.ReferendumDetailsV2Response +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ExternalApi.GovernanceReferenda.Source + +class SubSquareV2ReferendaDataSource( + private val subSquareApi: SubSquareV2Api +) : OffChainReferendaDataSource { + + override suspend fun referendumPreviews(baseUrl: String, options: Source.SubSquare): List { + val fullUrl = previewsUrlOf(baseUrl) + + val response = subSquareApi.getReferendumPreviews(fullUrl) + + return response.items.map(::mapPolkassemblyPostToPreview) + } + + override suspend fun referendumDetails(referendumId: ReferendumId, baseUrl: String, options: Source.SubSquare): OffChainReferendumDetails { + val detailsUrl = detailsUrlOf(baseUrl, referendumId) + val referendaDetails = subSquareApi.getReferendumDetails(detailsUrl) + + return mapPolkassemblyPostToDetails(referendaDetails) + } + + private fun mapPolkassemblyPostToPreview(post: ReferendaPreviewV2Response.Referendum): OffChainReferendumPreview { + return OffChainReferendumPreview( + title = post.title, + referendumId = ReferendumId(post.referendumIndex), + ) + } + + private fun mapPolkassemblyPostToDetails( + referendum: ReferendumDetailsV2Response + ): OffChainReferendumDetails { + val timeline = referendum.onchainData.timeline.mapNotNull(::mapReferendumStatusToTimelineEntry) + + return OffChainReferendumDetails( + title = referendum.title, + description = referendum.content, + proposerAddress = referendum.author?.address, + proposerName = referendum.author?.username, + timeLine = timeline + ) + } + + private fun mapReferendumStatusToTimelineEntry(status: ReferendumDetailsV2Response.Status): ReferendumTimeline.Entry? { + val timelineState = when (status.name) { + "Submitted" -> ReferendumTimeline.State.CREATED + "Confirmed" -> ReferendumTimeline.State.APPROVED + "Rejected" -> ReferendumTimeline.State.REJECTED + "Cancelled" -> ReferendumTimeline.State.CANCELLED + "TimedOut" -> ReferendumTimeline.State.TIMED_OUT + "Killed" -> ReferendumTimeline.State.KILLED + else -> null + } + + return timelineState?.let { + ReferendumTimeline.Entry(timelineState, status.indexer.blockTime) + } + } + + private fun previewsUrlOf(baseUrl: String) = baseUrl.ensureSuffix("/") + "gov2/referendums" + + private fun detailsUrlOf(baseUrl: String, referendumId: ReferendumId) = baseUrl.ensureSuffix("/") + "gov2/referendums/${referendumId.value}" +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/response/ReferendaPreviewV2Response.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/response/ReferendaPreviewV2Response.kt new file mode 100644 index 0000000..7645d3a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/response/ReferendaPreviewV2Response.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.response + +import java.math.BigInteger + +class ReferendaPreviewV2Response( + val items: List +) { + + class Referendum( + val title: String?, + val referendumIndex: BigInteger + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/response/ReferendumDetailsV2Response.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/response/ReferendumDetailsV2Response.kt new file mode 100644 index 0000000..169a51d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/response/ReferendumDetailsV2Response.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.response + +class ReferendumDetailsV2Response( + val title: String?, + val content: String?, + val author: Author?, + val onchainData: OnChainData +) { + + class Author(val username: String?, val address: String?) + + class OnChainData(val timeline: List) + + class Status( + val indexer: IndexerState, + val name: String + ) + + class IndexerState( + val blockTime: Long + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/response/ReferendumVoteV2Response.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/response/ReferendumVoteV2Response.kt new file mode 100644 index 0000000..248025d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/subsquare/v2/response/ReferendumVoteV2Response.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.response + +import java.math.BigInteger + +class ReferendumVoteV2Response( + val isSplitAbstain: Boolean, + val abstainVotes: BigInteger? +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/ReferendumSummaryApi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/ReferendumSummaryApi.kt new file mode 100644 index 0000000..a205863 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/ReferendumSummaryApi.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2 + +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2.request.ReferendumSummariesRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2.response.ReferendumSummaryResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface ReferendumSummaryApi { + + @POST + suspend fun getReferendumSummaries( + @Url url: String, + @Body body: ReferendumSummariesRequest + ): List +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/ReferendumSummaryDataSource.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/ReferendumSummaryDataSource.kt new file mode 100644 index 0000000..3db1a4f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/ReferendumSummaryDataSource.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2 + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2.request.ReferendumSummariesRequest +import io.novafoundation.nova.runtime.ext.summaryApiOrNull +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface ReferendumSummaryDataSource { + + suspend fun loadSummaries(chain: Chain, ids: List, languageCode: String): Map? +} + +class RealReferendumSummaryDataSource( + val api: ReferendumSummaryApi +) : ReferendumSummaryDataSource { + + override suspend fun loadSummaries(chain: Chain, ids: List, languageCode: String): Map? { + val summaryApi = chain.summaryApiOrNull() ?: return null + + val response = api.getReferendumSummaries( + summaryApi.url, + ReferendumSummariesRequest( + chainId = chain.id, + languageIsoCode = languageCode, + referendumIds = ids.map { it.value.toString() } + ) + ) + + return response.associateBy { ReferendumId(it.referendumId.toBigInteger()) } + .mapValues { it.value.summary } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/request/ReferendumSummariesRequest.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/request/ReferendumSummariesRequest.kt new file mode 100644 index 0000000..ac7acfa --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/request/ReferendumSummariesRequest.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2.request + +class ReferendumSummariesRequest( + val chainId: String, + val languageIsoCode: String, + val referendumIds: List +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/response/ReferendumSummaryResponse.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/response/ReferendumSummaryResponse.kt new file mode 100644 index 0000000..d1fcb1e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/offchain/referendum/summary/v2/response/ReferendumSummaryResponse.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2.response + +class ReferendumSummaryResponse(val referendumId: Int, val summary: String) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/preimage/PreImageSizer.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/preimage/PreImageSizer.kt new file mode 100644 index 0000000..0398af6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/preimage/PreImageSizer.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_governance_impl.data.preimage + +import io.novafoundation.nova.common.utils.kilobytes +import java.math.BigInteger + +interface PreImageSizer { + + enum class SizeConstraint { + SMALL + } + + fun satisfiesSizeConstraint(preImageSize: BigInteger, constraint: SizeConstraint): Boolean +} + +class RealPreImageSizer : PreImageSizer { + + override fun satisfiesSizeConstraint(preImageSize: BigInteger, constraint: PreImageSizer.SizeConstraint): Boolean { + return preImageSize < constraint.threshold + } + + private val PreImageSizer.SizeConstraint.threshold: BigInteger + get() = when (this) { + PreImageSizer.SizeConstraint.SMALL -> 1.kilobytes + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/MultiSourceOffChainReferendaInfoRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/MultiSourceOffChainReferendaInfoRepository.kt new file mode 100644 index 0000000..2f58ca1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/MultiSourceOffChainReferendaInfoRepository.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumPreview +import io.novafoundation.nova.feature_governance_api.data.repository.OffChainReferendaInfoRepository +import io.novafoundation.nova.feature_governance_impl.data.offchain.OffChainReferendaDataSource +import io.novafoundation.nova.runtime.ext.externalApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ExternalApi.GovernanceReferenda.Source + +class MultiSourceOffChainReferendaInfoRepository( + private val subSquareReferendaDataSource: OffChainReferendaDataSource, + private val polkassemblyReferendaDataSource: OffChainReferendaDataSource +) : OffChainReferendaInfoRepository { + + override suspend fun referendumPreviews(chain: Chain): List { + return runCatching { + val dataSource = chain.carriedGovernanceDataSource() ?: return emptyList() + + dataSource.referendumPreviews() + }.getOrDefault(emptyList()) + } + + override suspend fun referendumDetails(referendumId: ReferendumId, chain: Chain): OffChainReferendumDetails? { + return runCatching { + val dataSource = chain.carriedGovernanceDataSource() ?: return null + + dataSource.referendumDetails(referendumId) + }.getOrNull() + } + + private fun Chain.carriedGovernanceDataSource(): OffChainReferendaDataSourceCarried<*>? { + val governanceApi = externalApi() ?: return null + val baseUrl = governanceApi.url + + return when (val source = governanceApi.source) { + is Source.Polkassembly -> OffChainReferendaDataSourceCarried(polkassemblyReferendaDataSource, baseUrl, source) + is Source.SubSquare -> OffChainReferendaDataSourceCarried(subSquareReferendaDataSource, baseUrl, source) + } + } + + private class OffChainReferendaDataSourceCarried( + private val dataSource: OffChainReferendaDataSource, + private val baseUrl: String, + private val options: O + ) { + suspend fun referendumPreviews(): List = dataSource.referendumPreviews(baseUrl, options) + + suspend fun referendumDetails(referendumId: ReferendumId): OffChainReferendumDetails? = dataSource.referendumDetails(referendumId, baseUrl, options) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/RealTreasuryRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/RealTreasuryRepository.kt new file mode 100644 index 0000000..672118e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/RealTreasuryRepository.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStructOrNull +import io.novafoundation.nova.common.utils.treasury +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TreasuryProposal +import io.novafoundation.nova.feature_governance_api.data.repository.TreasuryRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +class RealTreasuryRepository( + private val remoteSource: StorageDataSource +) : TreasuryRepository { + + override suspend fun getTreasuryProposal(chainId: ChainId, id: TreasuryProposal.Id): TreasuryProposal? { + return remoteSource.query(chainId) { + runtime.metadata.treasury().storage("Proposals").query( + id.value, + binding = { bindProposal(id, it) } + ) + } + } + + private fun bindProposal(id: TreasuryProposal.Id, decoded: Any?): TreasuryProposal? { + val asStruct = decoded.castToStructOrNull() ?: return null + + return TreasuryProposal( + id = id, + proposer = bindAccountId(asStruct["proposer"]), + amount = bindNumber(asStruct["value"]), + beneficiary = bindAccountId(asStruct["beneficiary"]), + bond = bindNumber(asStruct["bond"]) + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/RemoveVotesSuggestionRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/RemoveVotesSuggestionRepository.kt new file mode 100644 index 0000000..ea28f42 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/RemoveVotesSuggestionRepository.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository + +import io.novafoundation.nova.common.data.storage.Preferences + +interface RemoveVotesSuggestionRepository { + + fun isAllowedToShowRemoveVotesSuggestion(): Boolean + + suspend fun disallowShowRemoveVotesSuggestion() +} + +class RealRemoveVotesSuggestionRepository( + private val preferences: Preferences +) : RemoveVotesSuggestionRepository { + + companion object { + private const val PREFS_KEY = "RemoveVotesSuggestionRepository.ShouldShowSuggestion" + + private const val ALLOWED_DEFAULT = true + } + + override fun isAllowedToShowRemoveVotesSuggestion(): Boolean { + return preferences.getBoolean(PREFS_KEY, ALLOWED_DEFAULT) + } + + override suspend fun disallowShowRemoveVotesSuggestion() { + preferences.putBoolean(PREFS_KEY, value = false) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/UnsupportedDelegationsRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/UnsupportedDelegationsRepository.kt new file mode 100644 index 0000000..4bc4475 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/UnsupportedDelegationsRepository.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Delegation +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateDetailedStats +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateMetadata +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateStats +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.vote.UserVote +import io.novafoundation.nova.feature_governance_api.data.repository.DelegationsRepository +import io.novafoundation.nova.feature_governance_api.data.repository.common.RecentVotesDateThreshold +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novasama.substrate_sdk_android.runtime.AccountId + +class UnsupportedDelegationsRepository : DelegationsRepository { + + override suspend fun isDelegationSupported(chain: Chain): Boolean { + return false + } + + override suspend fun getDelegatesStats(recentVotesBlockThreshold: RecentVotesDateThreshold, chain: Chain): List { + return emptyList() + } + + override suspend fun getDelegatesStatsByAccountIds( + recentVotesBlockThreshold: RecentVotesDateThreshold, + accountIds: List, + chain: Chain + ): List { + return emptyList() + } + + override suspend fun getDetailedDelegateStats( + delegateAddress: String, + recentVotesDateThreshold: RecentVotesDateThreshold, + chain: Chain + ): DelegateDetailedStats? { + return null + } + + override suspend fun getDelegatesMetadata(chain: Chain): List { + return emptyList() + } + + override suspend fun getDelegateMetadata(chain: Chain, delegate: AccountId): DelegateMetadata? { + return null + } + + override suspend fun getDelegationsTo(delegate: AccountId, chain: Chain): List { + return emptyList() + } + + override suspend fun allHistoricalVotesOf(user: AccountId, chain: Chain): Map? { + return null + } + + override suspend fun historicalVoteOf(user: AccountId, referendumId: ReferendumId, chain: Chain): UserVote? { + return null + } + + override suspend fun directHistoricalVotesOf( + user: AccountId, + chain: Chain, + recentVotesDateThreshold: RecentVotesDateThreshold? + ): Map? { + return null + } + + override suspend fun CallBuilder.delegate(delegate: AccountId, trackId: TrackId, amount: Balance, conviction: Conviction) { + error("Unsupported") + } + + override suspend fun CallBuilder.undelegate(trackId: TrackId) { + error("Unsupported") + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/common/Bindings.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/common/Bindings.kt new file mode 100644 index 0000000..78ae654 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/common/Bindings.kt @@ -0,0 +1,174 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.common + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindBlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindByteArray +import io.novafoundation.nova.common.data.network.runtime.binding.bindCollectionEnum +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PriorLock +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Proposal +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Tally +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Vote +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +fun bindProposal(decoded: Any?, runtimeSnapshot: RuntimeSnapshot): Proposal { + return when (decoded) { + is ByteArray -> bindProposalLegacy(decoded) + is DictEnum.Entry<*> -> bindProposalBound(decoded, runtimeSnapshot) + else -> incompatible() + } +} + +private fun bindProposalLegacy(decoded: ByteArray): Proposal { + return Proposal.Legacy(decoded) +} + +private fun bindProposalBound(decoded: DictEnum.Entry<*>, runtime: RuntimeSnapshot): Proposal { + return when (decoded.name) { + "Legacy" -> { + val valueAsStruct = decoded.value.castToStruct() + Proposal.Legacy(bindByteArray(valueAsStruct["hash"])) + } + + "Inline" -> { + val bytes = bindByteArray(decoded.value) + val call = GenericCall.fromByteArray(runtime, bytes) + + Proposal.Inline(bytes, call) + } + + "Lookup" -> { + val valueAsStruct = decoded.value.castToStruct() + + Proposal.Lookup( + hash = bindByteArray(valueAsStruct["hash"]), + callLength = bindNumber(valueAsStruct["len"]) + ) + } + + else -> incompatible() + } +} + +fun bindTally(decoded: Struct.Instance): Tally { + return Tally( + ayes = bindNumber(decoded["ayes"]), + nays = bindNumber(decoded["nays"]), + support = bindNumber(decoded["support"] ?: decoded["turnout"]) + ) +} + +fun bindVoting(decoded: Any): Voting { + decoded.castToDictEnum() + + return when (decoded.name) { + "Casting", "Direct" -> { + val casting = decoded.value.castToStruct() + + val votes = bindVotes(casting["votes"]) + val prior = bindPriorLock(casting["prior"]) + + Voting.Casting(votes, prior) + } + + "Delegating" -> { + val delegating = decoded.value.castToStruct() + + val balance = bindNumber(delegating["balance"]) + val target = bindAccountId(delegating["target"]) + val conviction = bindConvictionEnum(delegating["conviction"]) + val prior = bindPriorLock(delegating["prior"]) + + Voting.Delegating(balance, target, conviction, prior) + } + + else -> incompatible() + } +} + +fun bindConvictionEnum(decoded: Any?): Conviction { + return bindCollectionEnum(decoded) { name -> + when (name) { + "None" -> Conviction.None + "Locked1x" -> Conviction.Locked1x + "Locked2x" -> Conviction.Locked2x + "Locked3x" -> Conviction.Locked3x + "Locked4x" -> Conviction.Locked4x + "Locked5x" -> Conviction.Locked5x + "Locked6x" -> Conviction.Locked6x + else -> incompatible() + } + } +} + +private fun bindVotes(decoded: Any?): Map { + return bindList(decoded) { item -> + val (referendumId, accountVote) = item.castToList() + + ReferendumId(bindNumber(referendumId)) to bindAccountVote(accountVote) + }.toMap() +} + +private fun bindAccountVote(decoded: Any?): AccountVote { + decoded.castToDictEnum() + + return when (decoded.name) { + "Standard" -> { + val standardVote = decoded.value.castToStruct() + + AccountVote.Standard( + vote = bindVote(standardVote["vote"]), + balance = bindNumber(standardVote["balance"]) + ) + } + + "Split" -> { + val splitVote = decoded.value.castToStruct() + + AccountVote.Split( + aye = bindNumber(splitVote["aye"]), + nay = bindNumber(splitVote["nay"]) + ) + } + + "SplitAbstain" -> { + val splitVote = decoded.value.castToStruct() + + AccountVote.SplitAbstain( + aye = bindNumber(splitVote["aye"]), + nay = bindNumber(splitVote["nay"]), + abstain = bindNumber(splitVote["abstain"]) + ) + } + + else -> AccountVote.Unsupported + } +} + +private fun bindPriorLock(decoded: Any?): PriorLock { + // 2-tuple + val (unlockAt, amount) = decoded.castToList() + + return PriorLock( + unlockAt = bindBlockNumber(unlockAt), + amount = bindNumber(amount) + ) +} + +private fun bindVote(decoded: Any?): Vote { + return decoded.cast() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/common/OffchainVotesExt.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/common/OffchainVotesExt.kt new file mode 100644 index 0000000..644050a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/common/OffchainVotesExt.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.common + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amountMultiplier +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumVotingDetails.VotingInfo +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.empty +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.plus +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.ReferendumVotesResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.SplitAbstainVoteRemote +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.SplitVoteRemote +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.StandardVoteRemote +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.mapConvictionFromString +import java.math.BigDecimal + +fun StandardVoteRemote?.toOffChainVotes(): VotingInfo.Full { + if (this == null) return VotingInfo.Full.empty() + + val conviction = mapConvictionFromString(this.vote.conviction) + val amount = this.vote.amount.toBigDecimal() * conviction.amountMultiplier() + return VotingInfo.Full( + aye = if (this.aye) amount else BigDecimal.ZERO, + nay = if (!this.aye) amount else BigDecimal.ZERO, + abstain = BigDecimal.ZERO, + support = this.vote.amount + ) +} + +fun SplitVoteRemote?.toOffChainVotes(): VotingInfo.Full { + if (this == null) return VotingInfo.Full.empty() + + val amountMultiplier = Conviction.None.amountMultiplier() + + return VotingInfo.Full( + aye = this.ayeAmount.toBigDecimal() * amountMultiplier, + nay = this.nayAmount.toBigDecimal() * amountMultiplier, + abstain = BigDecimal.ZERO, + support = this.ayeAmount + this.nayAmount + ) +} + +fun SplitAbstainVoteRemote?.toOffChainVotes(): VotingInfo.Full { + if (this == null) return VotingInfo.Full.empty() + + val amountMultiplier = Conviction.None.amountMultiplier() + + return VotingInfo.Full( + aye = this.ayeAmount.toBigDecimal() * amountMultiplier, + nay = this.nayAmount.toBigDecimal() * amountMultiplier, + abstain = this.abstainAmount.toBigDecimal() * amountMultiplier, + support = this.ayeAmount + this.nayAmount + this.abstainAmount + ) +} + +fun ReferendumVotesResponse.Vote.toOffChainVotes(): VotingInfo.Full { + var delegatorsVoteSum = BigDecimal.ZERO + var delegatorSupportSum = Balance.ZERO + + this.delegatorVotes.nodes.forEach { delegatorVote -> + val conviction = mapConvictionFromString(delegatorVote.vote.conviction) + delegatorsVoteSum += delegatorVote.vote.amount.toBigDecimal() * conviction.amountMultiplier() + delegatorSupportSum += delegatorVote.vote.amount + } + + return standardVote.toOffChainVotes() + splitVote.toOffChainVotes() + splitAbstainVote.toOffChainVotes() + getDelegationVotes() +} + +private fun ReferendumVotesResponse.Vote.getDelegationVotes(): VotingInfo.Full { + return if (standardVote != null) { + var delegatorsVoteSum = BigDecimal.ZERO + var delegatorSupportSum = Balance.ZERO + + this.delegatorVotes.nodes.forEach { delegatorVote -> + val conviction = mapConvictionFromString(delegatorVote.vote.conviction) + delegatorsVoteSum += delegatorVote.vote.amount.toBigDecimal() * conviction.amountMultiplier() + delegatorSupportSum += delegatorVote.vote.amount + } + + return VotingInfo.Full( + aye = if (standardVote.aye) delegatorsVoteSum else BigDecimal.ZERO, + nay = if (!standardVote.aye) delegatorsVoteSum else BigDecimal.ZERO, + abstain = BigDecimal.ZERO, + support = delegatorSupportSum + ) + } else { + VotingInfo.Full.empty() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/common/Queries.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/common/Queries.kt new file mode 100644 index 0000000..ecea4d2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/common/Queries.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.common + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumVoter +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.votes +import io.novafoundation.nova.runtime.storage.source.query.StorageKeyComponents +import io.novasama.substrate_sdk_android.runtime.AccountId + +fun Map.votersFor(referendumId: ReferendumId): List { + return mapNotNull { (keyComponents, voting) -> + val voterId = keyComponents.component1() + val votes = voting?.votes() + + votes?.get(referendumId)?.let { accountVote -> + ReferendumVoter( + accountId = voterId, + vote = accountVote, + delegators = emptyList() + ) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/filters/ReferendaFiltersRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/filters/ReferendaFiltersRepository.kt new file mode 100644 index 0000000..73db7b2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/filters/ReferendaFiltersRepository.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.filters + +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumType +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumTypeFilter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +private const val PREF_REFERENDUM_TYPE_FILTER = "PREF_REFERENDUM_TYPE_FILTER" + +interface ReferendaFiltersRepository { + + fun getReferendumTypeFilter(): ReferendumTypeFilter + + fun observeReferendumTypeFilter(): Flow + + fun updateReferendumTypeFilter(filter: ReferendumTypeFilter) +} + +class PreferencesReferendaFiltersRepository : ReferendaFiltersRepository { + + private var referendumTypeFilter = MutableStateFlow(getDefaultReferendaTypeFilter()) + + override fun getReferendumTypeFilter(): ReferendumTypeFilter { + return referendumTypeFilter.value + } + + override fun observeReferendumTypeFilter(): Flow { + return referendumTypeFilter + } + + override fun updateReferendumTypeFilter(filter: ReferendumTypeFilter) { + referendumTypeFilter.value = filter + } + + private fun getDefaultReferendaTypeFilter(): ReferendumTypeFilter { + return ReferendumTypeFilter(ReferendumType.ALL) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/tindergov/Mapper.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/tindergov/Mapper.kt new file mode 100644 index 0000000..2e9ce04 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/tindergov/Mapper.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.tindergov + +import io.novafoundation.nova.core_db.model.common.ConvictionLocal +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction + +fun Conviction.toLocal() = when (this) { + Conviction.None -> ConvictionLocal.NONE + Conviction.Locked1x -> ConvictionLocal.LOCKED_1X + Conviction.Locked2x -> ConvictionLocal.LOCKED_2X + Conviction.Locked3x -> ConvictionLocal.LOCKED_3X + Conviction.Locked4x -> ConvictionLocal.LOCKED_4X + Conviction.Locked5x -> ConvictionLocal.LOCKED_5X + Conviction.Locked6x -> ConvictionLocal.LOCKED_6X +} + +fun ConvictionLocal.toDomain() = when (this) { + ConvictionLocal.NONE -> Conviction.None + ConvictionLocal.LOCKED_1X -> Conviction.Locked1x + ConvictionLocal.LOCKED_2X -> Conviction.Locked2x + ConvictionLocal.LOCKED_3X -> Conviction.Locked3x + ConvictionLocal.LOCKED_4X -> Conviction.Locked4x + ConvictionLocal.LOCKED_5X -> Conviction.Locked5x + ConvictionLocal.LOCKED_6X -> Conviction.Locked6x +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/tindergov/TinderGovBasketRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/tindergov/TinderGovBasketRepository.kt new file mode 100644 index 0000000..95858d8 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/tindergov/TinderGovBasketRepository.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.tindergov + +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.TinderGovDao +import io.novafoundation.nova.core_db.model.TinderGovBasketItemLocal +import io.novafoundation.nova.feature_governance_api.data.model.TinderGovBasketItem +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.core_db.model.TinderGovBasketItemLocal.VoteType as LocalVoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +interface TinderGovBasketRepository { + + suspend fun add(item: TinderGovBasketItem) + + suspend fun remove(item: TinderGovBasketItem) + + suspend fun remove(items: Collection) + + suspend fun isBasketEmpty(metaId: Long, chainId: ChainId): Boolean + + suspend fun clearBasket(metaId: Long, chainId: ChainId) + + suspend fun getBasket(metaId: Long, chainId: ChainId): List + + fun observeBasket(metaId: Long, chainId: String): Flow> +} + +class RealTinderGovBasketRepository(private val dao: TinderGovDao) : TinderGovBasketRepository { + + override suspend fun add(item: TinderGovBasketItem) { + dao.addToBasket(item.toLocal()) + } + + override suspend fun remove(item: TinderGovBasketItem) { + withContext(Dispatchers.Default) { dao.removeFromBasket(item.toLocal()) } + } + + override suspend fun remove(items: Collection) { + withContext(Dispatchers.Default) { dao.removeFromBasket(items.map { it.toLocal() }) } + } + + override fun observeBasket(metaId: Long, chainId: String): Flow> { + return dao.observeBasket(metaId, chainId) + .mapList { it.toDomain() } + } + + override suspend fun getBasket(metaId: Long, chainId: ChainId): List { + return withContext(Dispatchers.Default) { dao.getBasket(metaId, chainId).map { it.toDomain() } } + } + + override suspend fun isBasketEmpty(metaId: Long, chainId: ChainId): Boolean { + return withContext(Dispatchers.Default) { dao.basketSize(metaId, chainId) == 0 } + } + + override suspend fun clearBasket(metaId: Long, chainId: ChainId) { + withContext(Dispatchers.Default) { dao.clearBasket(metaId, chainId) } + } + + private fun TinderGovBasketItem.toLocal(): TinderGovBasketItemLocal { + return TinderGovBasketItemLocal( + metaId = this.metaId, + chainId = this.chainId, + referendumId = this.referendumId.value, + voteType = this.voteTypeToLocal(), + conviction = this.conviction.toLocal(), + amount = this.amount + ) + } + + private fun TinderGovBasketItem.voteTypeToLocal() = when (this.voteType) { + VoteType.AYE -> LocalVoteType.AYE + VoteType.NAY -> LocalVoteType.NAY + VoteType.ABSTAIN -> LocalVoteType.ABSTAIN + } + + private fun TinderGovBasketItemLocal.toDomain(): TinderGovBasketItem { + return TinderGovBasketItem( + metaId = this.metaId, + chainId = this.chainId, + referendumId = ReferendumId(this.referendumId), + voteType = this.voteTypeToDomain(), + conviction = this.conviction.toDomain(), + amount = this.amount + ) + } + + private fun TinderGovBasketItemLocal.voteTypeToDomain() = when (this.voteType) { + LocalVoteType.AYE -> VoteType.AYE + LocalVoteType.NAY -> VoteType.NAY + LocalVoteType.ABSTAIN -> VoteType.ABSTAIN + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/tindergov/TinderGovVotingPowerRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/tindergov/TinderGovVotingPowerRepository.kt new file mode 100644 index 0000000..5746662 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/tindergov/TinderGovVotingPowerRepository.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.tindergov + +import io.novafoundation.nova.core_db.dao.TinderGovDao +import io.novafoundation.nova.core_db.model.TinderGovVotingPowerLocal +import io.novafoundation.nova.feature_governance_api.data.model.VotingPower +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface TinderGovVotingPowerRepository { + + suspend fun setVotingPower(votingPower: VotingPower) + + suspend fun getVotingPower(metaId: Long, chainId: ChainId): VotingPower? +} + +class RealTinderGovVotingPowerRepository( + private val tinderGovDao: TinderGovDao +) : TinderGovVotingPowerRepository { + + override suspend fun setVotingPower(votingPower: VotingPower) { + val local = TinderGovVotingPowerLocal( + metaId = votingPower.metaId, + chainId = votingPower.chainId, + amount = votingPower.amount, + conviction = votingPower.conviction.toLocal() + ) + + tinderGovDao.setVotingPower(local) + } + + override suspend fun getVotingPower(metaId: Long, chainId: ChainId): VotingPower? { + val local = tinderGovDao.getVotingPower(metaId, chainId) ?: return null + return VotingPower( + metaId = local.metaId, + chainId = local.chainId, + amount = local.amount, + conviction = local.conviction.toDomain() + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/Common.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/Common.kt new file mode 100644 index 0000000..ad7e223 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/Common.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v1 + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import java.math.BigInteger + +val DemocracyTrackId = TrackId(BigInteger.ZERO) +val DEMOCRACY_ID = BalanceLockId.fromFullId("democrac") diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1ConvictionVotingRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1ConvictionVotingRepository.kt new file mode 100644 index 0000000..6639d78 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1ConvictionVotingRepository.kt @@ -0,0 +1,153 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v1 + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.democracy +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumVoter +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.votedFor +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumVotingDetails +import io.novafoundation.nova.feature_governance_api.data.repository.ConvictionVotingRepository +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule +import io.novafoundation.nova.feature_governance_impl.data.network.blockchain.extrinsic.democracyRemoveVote +import io.novafoundation.nova.feature_governance_impl.data.network.blockchain.extrinsic.democracyUnlock +import io.novafoundation.nova.feature_governance_impl.data.network.blockchain.extrinsic.democracyVote +import io.novafoundation.nova.feature_governance_impl.data.repository.common.bindVoting +import io.novafoundation.nova.feature_governance_impl.data.repository.common.votersFor +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class GovV1ConvictionVotingRepository( + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val balanceLocksRepository: BalanceLocksRepository, +) : ConvictionVotingRepository { + + override val voteLockId = DEMOCRACY_ID + + override suspend fun maxAvailableForVote(asset: Asset): Balance { + return asset.freeInPlanks + } + + override suspend fun voteLockingPeriod(chainId: ChainId): BlockNumber { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.democracy().numberConstant("VoteLockingPeriod", runtime) + } + + override suspend fun maxTrackVotes(chainId: ChainId): BigInteger { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.democracy().numberConstant("MaxVotes", runtime) + } + + override fun trackLocksFlow(accountId: AccountId, chainAssetId: FullChainAssetId): Flow> { + return flowOfAll { + val chainAsset = chainRegistry.asset(chainAssetId) + + balanceLocksRepository.observeBalanceLock(chainAsset, voteLockId) + .map { lock -> lock?.amountInPlanks.associatedWithTrack() } + } + } + + override suspend fun votingFor(accountId: AccountId, chainId: ChainId): Map { + return votingFor(accountId, chainId, DemocracyTrackId).associatedWithTrack() + } + + override suspend fun votingFor(accountId: AccountId, chainId: ChainId, trackId: TrackId): Voting? { + if (trackId != DemocracyTrackId) return null + + return remoteStorageSource.query(chainId) { + runtime.metadata.democracy().storage("VotingOf").query( + accountId, + binding = { decoded -> decoded?.let(::bindVoting) } + ) + } + } + + override suspend fun votingFor(accountId: AccountId, chainId: ChainId, trackIds: Collection): Map { + unsupported() + } + + override suspend fun votersOf(referendumId: ReferendumId, chain: Chain, type: VoteType): List { + val allVotings = remoteStorageSource.query(chain.id) { + runtime.metadata.democracy().storage("VotingOf").entries( + keyExtractor = { it }, + binding = { decoded, _ -> bindVoting(decoded!!) } + ) + } + + return allVotings.votersFor(referendumId) + .filter { it.vote.votedFor(type) } + } + + override suspend fun abstainVotingDetails(referendumId: ReferendumId, chain: Chain): OffChainReferendumVotingDetails? { + return null + } + + override fun ExtrinsicBuilder.unlock(accountId: AccountId, claimable: ClaimSchedule.UnlockChunk.Claimable) { + claimable.actions.forEach { claimAction -> + when (claimAction) { + is ClaimSchedule.ClaimAction.RemoveVote -> { + removeVote(claimAction.trackId, claimAction.referendumId) + } + + is ClaimSchedule.ClaimAction.Unlock -> { + democracyUnlock(accountId) + } + } + } + } + + override fun ExtrinsicBuilder.vote(referendumId: ReferendumId, vote: AccountVote) { + democracyVote(referendumId, vote) + } + + override fun CallBuilder.vote(referendumId: ReferendumId, vote: AccountVote) { + democracyVote(referendumId, vote) + } + + override fun ExtrinsicBuilder.removeVote(trackId: TrackId, referendumId: ReferendumId) { + democracyRemoveVote(referendumId) + } + + override fun isAbstainVotingAvailable(): Boolean { + return false + } + + override suspend fun fullVotingDetails(referendumId: ReferendumId, chain: Chain): OffChainReferendumVotingDetails? { + return null + } + + private fun T?.associatedWithTrack(): Map { + return if (this != null) { + mapOf(DemocracyTrackId to this) + } else { + emptyMap() + } + } + + private fun unsupported(): Nothing { + error("Unsupported operation for Governance 1 voting") + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1DAppsRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1DAppsRepository.kt new file mode 100644 index 0000000..85d6f27 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1DAppsRepository.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v1 + +import io.novafoundation.nova.common.utils.formatNamed +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.core_db.model.GovernanceDAppLocal +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.repository.GovernanceDAppsRepository +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDApp +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GovV1DAppsRepository( + private val governanceDAppsDao: GovernanceDAppsDao +) : GovernanceDAppsRepository { + + override fun observeReferendumDApps(chainId: ChainId, referendumId: ReferendumId): Flow> { + return governanceDAppsDao.observeChainDapps(chainId) + .map { dapps -> + val v1Dapps = dapps.filter { it.referendumUrlV1 != null } + mapV1DappsLocalToDomain(referendumId, v1Dapps) + } + } +} + +private fun mapV1DappsLocalToDomain(referendumId: ReferendumId, dapps: List): List { + return dapps.map { + ReferendumDApp( + it.chainId, + it.name, + it.referendumUrlV1?.formatNamed("referendumId" to referendumId.value.toString())!!, + it.iconUrl, + it.details + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1OnChainReferendaRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1OnChainReferendaRepository.kt new file mode 100644 index 0000000..7b6dfec --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1OnChainReferendaRepository.kt @@ -0,0 +1,268 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v1 + +import android.util.Log +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindBlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindBoolean +import io.novafoundation.nova.common.data.network.runtime.binding.bindCollectionEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.democracy +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.padEnd +import io.novafoundation.nova.common.utils.scheduler +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ConfirmingSource +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.DecidingStatus +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendumStatus +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackQueue +import io.novafoundation.nova.feature_governance_api.data.repository.OnChainReferendaRepository +import io.novafoundation.nova.feature_governance_api.data.thresold.gov1.Gov1VotingThreshold +import io.novafoundation.nova.feature_governance_impl.data.repository.common.bindProposal +import io.novafoundation.nova.feature_governance_impl.data.repository.common.bindTally +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.extensions.pad +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.FixedByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.u32 +import io.novasama.substrate_sdk_android.runtime.definitions.types.toByteArray +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.keys +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +private const val SCHEDULER_KEY_BOUND = 32 + +private enum class SchedulerVersion { + V3, V4 +} + +class GovV1OnChainReferendaRepository( + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val totalIssuanceRepository: TotalIssuanceRepository, +) : OnChainReferendaRepository { + + override suspend fun electorate(chainId: ChainId): Balance { + return totalIssuanceRepository.getTotalIssuance(chainId) + } + + override suspend fun undecidingTimeout(chainId: ChainId): BlockNumber { + // we do not support `in queue` status for gov v1 yet + return Balance.ZERO + } + + override suspend fun getTracks(chainId: ChainId): Collection { + val runtime = chainRegistry.getRuntime(chainId) + + val track = TrackInfo( + id = DemocracyTrackId, + name = "root", + preparePeriod = Balance.ZERO, + decisionPeriod = runtime.votingPeriod(), + confirmPeriod = Balance.ZERO, + minSupport = null, + minApproval = null + ) + + return listOf(track) + } + + override suspend fun getTrackQueues(trackIds: Set, chainId: ChainId): Map { + // we do not support `in queue` status for gov v1 yet + return emptyMap() + } + + override suspend fun getAllOnChainReferenda(chainId: ChainId): Collection { + return remoteStorageSource.query(chainId) { + val votingPeriod = runtime.votingPeriod() + + runtime.metadata.democracy().storage("ReferendumInfoOf").entries( + keyExtractor = { (id: BigInteger) -> ReferendumId(id) }, + binding = { decoded, id -> bindReferendum(decoded, id, votingPeriod, runtime) } + ) + }.values.filterNotNull() + } + + override suspend fun getOnChainReferenda(chainId: ChainId, referendaIds: Collection): Map { + return remoteStorageSource.query(chainId) { + val votingPeriod = runtime.votingPeriod() + + runtime.metadata.democracy().storage("ReferendumInfoOf").entries( + keysArguments = referendaIds.map { listOf(it.value) }, + keyExtractor = { (id: BigInteger) -> ReferendumId(id) }, + binding = { decoded, id -> bindReferendum(decoded, id, votingPeriod, runtime) } + ) + }.filterNotNull() + } + + override suspend fun onChainReferendumFlow(chainId: ChainId, referendumId: ReferendumId): Flow { + return remoteStorageSource.subscribe(chainId) { + val votingPeriod = runtime.votingPeriod() + + runtime.metadata.democracy().storage("ReferendumInfoOf").observe( + referendumId.value, + binding = { bindReferendum(it, referendumId, votingPeriod, runtime) } + ) + } + } + + override suspend fun getReferendaExecutionBlocks( + chainId: ChainId, + approvedReferendaIds: Collection + ): Map { + if (approvedReferendaIds.isEmpty()) return emptyMap() + + return remoteStorageSource.query(chainId) { + val schedulerVersion = runtime.metadata.schedulerVersion() + + val referendaIdBySchedulerId = approvedReferendaIds.flatMap { referendumId -> + referendumId.versionedEnactmentSchedulerIdVariants(runtime, schedulerVersion).map { enactmentKeyVariant -> + enactmentKeyVariant.intoKey() to referendumId + } + }.toMap() + + // We do not extract referendumId as a key here since we request multiple keys per each referendumId (pre- and post- v4 migration) + // Thus, we should firstly filter out null values from map. + // Otherwise key candidate that resulted in null value may shadow another which resulted in value and we will loose that value + val schedulerKeysBySchedulerKey = runtime.metadata.scheduler().storage("Lookup").entries( + keysArguments = referendaIdBySchedulerId.keys.map { schedulerIdKey -> listOf(schedulerIdKey.value) }, + keyExtractor = { (schedulerId: ByteArray) -> schedulerId.intoKey() }, + binding = { decoded, _ -> + decoded?.let { + val (blockNumber, _) = decoded.castToList() + + bindBlockNumber(blockNumber) + } + } + ).filterNotNull() + + schedulerKeysBySchedulerKey.mapKeys { (schedulerKey, _) -> referendaIdBySchedulerId.getValue(schedulerKey) } + } + } + + private fun bindReferendum( + decoded: Any?, + id: ReferendumId, + votingPeriod: BlockNumber, + runtime: RuntimeSnapshot, + ): OnChainReferendum? = runCatching { + val asDictEnum = decoded.castToDictEnum() + + val referendumStatus = when (asDictEnum.name) { + "Ongoing" -> { + val status = asDictEnum.value.castToStruct() + val end = bindBlockNumber(status["end"]) + val submittedIn = end - votingPeriod + + val threshold = bindThreshold(status["threshold"]) + + OnChainReferendumStatus.Ongoing( + track = DemocracyTrackId, + proposal = bindProposal(status["proposalHash"] ?: status["proposal"], runtime), + submitted = submittedIn, + submissionDeposit = null, + decisionDeposit = null, + deciding = DecidingStatus( + since = submittedIn, + confirming = ConfirmingSource.FromThreshold(end = end) + ), + tally = bindTally(status.getTyped("tally")), + inQueue = false, + threshold = threshold + ) + } + + "Finished" -> { + val status = asDictEnum.value.castToStruct() + val approved = bindBoolean(status["approved"]) + val end = bindBlockNumber(status["end"]) + + if (approved) { + OnChainReferendumStatus.Approved(end) + } else { + OnChainReferendumStatus.Rejected(end) + } + } + + else -> throw IllegalArgumentException("Unsupported referendum status") + } + + OnChainReferendum( + id = id, + status = referendumStatus + ) + } + .onFailure { Log.e(this.LOG_TAG, "Failed to decode on-chain referendum", it) } + .getOrNull() + + private fun bindThreshold(decoded: Any?) = bindCollectionEnum(decoded) { name -> + when (name) { + "SimpleMajority" -> Gov1VotingThreshold.SIMPLE_MAJORITY + "SuperMajorityApprove" -> Gov1VotingThreshold.SUPER_MAJORITY_APPROVE + "SuperMajorityAgainst" -> Gov1VotingThreshold.SUPER_MAJORITY_AGAINST + else -> incompatible() + } + } + + private fun RuntimeSnapshot.votingPeriod(): BlockNumber { + return metadata.democracy().numberConstant("VotingPeriod", this) + } + + private fun ReferendumId.versionedEnactmentSchedulerIdVariants(runtime: RuntimeSnapshot, schedulerVersion: SchedulerVersion): List { + return when (schedulerVersion) { + SchedulerVersion.V3 -> listOf(v3EnactmentSchedulerId(runtime)) + SchedulerVersion.V4 -> listOf(v4EnactmentSchedulerId(runtime), v4MigratedEnactmentSchedulerId(runtime)) + } + } + + // https:github.com/paritytech/substrate/blob/0d64ba4268106fffe430d41b541c1aeedd4f8da5/frame/democracy/src/lib.rs#L1476 + private fun ReferendumId.v3EnactmentSchedulerId(runtime: RuntimeSnapshot): ByteArray { + val encodedAssemblyId = DEMOCRACY_ID.value.encodeToByteArray() // 'const bytes' in rust + val encodedIndex = u32.toByteArray(runtime, value) + + return encodedAssemblyId + encodedIndex + } + + private fun ReferendumId.v4EnactmentSchedulerId(runtime: RuntimeSnapshot): ByteArray { + val oldId = v3EnactmentSchedulerId(runtime) + + return if (oldId.size > SCHEDULER_KEY_BOUND) { + oldId.blake2b256().pad(SCHEDULER_KEY_BOUND, padding = 0) + } else { + oldId.padEnd(SCHEDULER_KEY_BOUND, padding = 0) + } + } + + private fun ReferendumId.v4MigratedEnactmentSchedulerId(runtime: RuntimeSnapshot): ByteArray { + return v3EnactmentSchedulerId(runtime).blake2b256() + } + + private fun RuntimeMetadata.schedulerVersion(): SchedulerVersion { + val lookupStorage = scheduler().storage("Lookup") + val lookupArgumentType = lookupStorage.keys.first() + + return if (lookupArgumentType is FixedByteArray) { + // bounded type is used + SchedulerVersion.V4 + } else { + SchedulerVersion.V3 + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1PreImageRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1PreImageRepository.kt new file mode 100644 index 0000000..86692d1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v1/GovV1PreImageRepository.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v1 + +import io.novafoundation.nova.common.data.network.runtime.binding.bindByteArray +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.democracy +import io.novafoundation.nova.common.utils.hasStorage +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PreImage +import io.novafoundation.nova.feature_governance_api.data.repository.HexHash +import io.novafoundation.nova.feature_governance_api.data.repository.PreImageRepository +import io.novafoundation.nova.feature_governance_api.data.repository.PreImageRequest +import io.novafoundation.nova.feature_governance_impl.data.repository.v2.Gov2PreImageRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +class GovV1PreImageRepository( + private val remoteStorageSource: StorageDataSource, + private val v2Delegate: Gov2PreImageRepository, +) : PreImageRepository { + + override suspend fun getPreimageFor(request: PreImageRequest, chainId: ChainId): PreImage? { + return remoteStorageSource.query(chainId) { + // The most recent democracy pallet version stores preimages the same way as gov v2 does + // However, those changes are not yet live on Kusama & Polkadot + if (runtime.metadata.democracy().hasStorage("Preimages")) { + runtime.metadata.democracy().storage("Preimages").query( + request.hash, + binding = { bindPreimage(it, runtime) } + ) + } else { + // just in case something will fail during v2 delegate execution + runCatching { v2Delegate.getPreimageFor(request, chainId) }.getOrNull() + } + } + } + + override suspend fun getPreimagesFor(requests: Collection, chainId: ChainId): Map { + return remoteStorageSource.query(chainId) { + if (runtime.metadata.democracy().hasStorage("Preimages")) { + // Since it democracy pallet preimages stored un-sized - we do not implement bulk fetch due to possible big calls being stored + // We cant efficiently use `state_getStorageSize` since it only allows to query one key at the time + emptyMap() + } else { + runCatching { v2Delegate.getPreimagesFor(requests, chainId) }.getOrDefault(emptyMap()) + } + } + } + + private fun bindPreimage( + decoded: Any?, + runtime: RuntimeSnapshot, + ): PreImage? = runCatching { + val asDictEnum = decoded.castToDictEnum() + + when (asDictEnum.name) { + "Available" -> { + val valueStruct = asDictEnum.value.castToStruct() + val callData = bindByteArray(valueStruct["data"]) + + val runtimeCall = GenericCall.fromByteArray(runtime, callData) + + PreImage(encodedCall = callData, call = runtimeCall) + } + + else -> null + } + }.getOrNull() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/Gov2DelegationsRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/Gov2DelegationsRepository.kt new file mode 100644 index 0000000..1c15611 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/Gov2DelegationsRepository.kt @@ -0,0 +1,243 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v2 + +import android.util.Log +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Delegation +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateDetailedStats +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateMetadata +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateStats +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.vote.UserVote +import io.novafoundation.nova.feature_governance_api.data.repository.DelegationsRepository +import io.novafoundation.nova.feature_governance_impl.data.network.blockchain.extrinsic.convictionVotingDelegate +import io.novafoundation.nova.feature_governance_impl.data.network.blockchain.extrinsic.convictionVotingUndelegate +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.metadata.DelegateMetadataApi +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.metadata.getDelegatesMetadata +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.DelegationsSubqueryApi +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.AllHistoricalVotesRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DelegateDelegatorsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DelegateDetailedStatsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DelegateStatsByAddressesRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DelegateStatsRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.DirectHistoricalVotesRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.DelegateDelegatorsResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.DelegateStatsResponse +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.DelegatedVoteRemote +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.DirectVoteRemote +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.mapMultiVoteRemoteToAccountVote +import io.novafoundation.nova.feature_governance_api.data.repository.common.RecentVotesDateThreshold +import io.novafoundation.nova.feature_governance_api.data.repository.common.zeroPoint +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.accountIdOrNull +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.externalApi +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ExternalApi.GovernanceDelegations +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Vote +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.mapConvictionFromString +import io.novasama.substrate_sdk_android.runtime.AccountId + +class Gov2DelegationsRepository( + private val delegationsSubqueryApi: DelegationsSubqueryApi, + private val delegateMetadataApi: DelegateMetadataApi, +) : DelegationsRepository { + + override suspend fun isDelegationSupported(chain: Chain): Boolean { + // we heavy rely on SubQuery API for delegations so we require it to be present + return chain.externalApi() != null + } + + override suspend fun getDelegatesStats( + recentVotesDateThreshold: RecentVotesDateThreshold, + chain: Chain + ): List { + return runCatching { + val externalApiLink = chain.externalApi()?.url ?: return emptyList() + val request = DelegateStatsRequest(recentVotesDateThreshold) + val response = delegationsSubqueryApi.getDelegateStats(externalApiLink, request) + val delegateStats = response.data.delegates.nodes + + mapDelegateStats(delegateStats, chain) + }.getOrNull() + .orEmpty() + } + + override suspend fun getDelegatesStatsByAccountIds( + recentVotesDateThreshold: RecentVotesDateThreshold, + accountIds: List, + chain: Chain + ): List { + return runCatching { + val externalApiLink = chain.externalApi()?.url ?: return emptyList() + val addresses = accountIds.map { chain.addressOf(it) } + val request = DelegateStatsByAddressesRequest(recentVotesDateThreshold, addresses = addresses) + val response = delegationsSubqueryApi.getDelegateStats(externalApiLink, request) + val delegateStats = response.data.delegates.nodes + + mapDelegateStats(delegateStats, chain) + }.getOrNull() + .orEmpty() + } + + override suspend fun getDetailedDelegateStats( + delegateAddress: String, + recentVotesDateThreshold: RecentVotesDateThreshold, + chain: Chain + ): DelegateDetailedStats? { + val externalApiLink = chain.externalApi()?.url ?: return null + val request = DelegateDetailedStatsRequest(delegateAddress, recentVotesDateThreshold) + + val response = delegationsSubqueryApi.getDetailedDelegateStats(externalApiLink, request) + val delegateStats = response.data.delegates.nodes.firstOrNull() ?: return null + + return DelegateDetailedStats( + accountId = chain.accountIdOf(delegateAddress), + delegationsCount = delegateStats.delegators, + delegatedVotes = delegateStats.delegatorVotes, + recentVotes = delegateStats.recentVotes.totalCount, + allVotes = delegateStats.allVotes.totalCount + ) + } + + override suspend fun getDelegatesMetadata(chain: Chain): List { + return delegateMetadataApi.getDelegatesMetadata(chain).mapNotNull { + val accountId = chain.accountIdOrNull(it.address) ?: return@mapNotNull null + + DelegateMetadata( + accountId = accountId, + shortDescription = it.shortDescription, + longDescription = it.longDescription, + profileImageUrl = it.image, + isOrganization = it.isOrganization, + name = it.name + ) + } + } + + override suspend fun getDelegateMetadata(chain: Chain, delegate: AccountId): DelegateMetadata? { + return getDelegatesMetadata(chain) + .find { it.accountId.contentEquals(delegate) } + } + + override suspend fun getDelegationsTo(delegate: AccountId, chain: Chain): List { + return accountSubQueryRequest(delegate, chain) { externalApiLink, delegateAddress -> + val request = DelegateDelegatorsRequest(delegateAddress) + val response = delegationsSubqueryApi.getDelegateDelegators(externalApiLink, request) + response.data.delegations.nodes.map { mapDelegationFromRemote(it, chain, delegate) } + }.orEmpty() + } + + override suspend fun allHistoricalVotesOf(user: AccountId, chain: Chain): Map? { + return accountSubQueryRequest(user, chain) { externalApiLink, userAddress -> + val request = AllHistoricalVotesRequest(userAddress) + val response = delegationsSubqueryApi.getAllHistoricalVotes(externalApiLink, request) + + val direct = response.data.direct.toUserVoteMap() + val delegated = response.data.delegated.toUserVoteMap(chain) + + (direct + delegated).filterNotNull() + } + } + + override suspend fun historicalVoteOf(user: AccountId, referendumId: ReferendumId, chain: Chain): UserVote? { + return allHistoricalVotesOf(user, chain)?.get(referendumId) + } + + override suspend fun directHistoricalVotesOf( + user: AccountId, + chain: Chain, + recentVotesDateThreshold: RecentVotesDateThreshold? + ): Map? { + val timePointThreshold = recentVotesDateThreshold ?: RecentVotesDateThreshold.zeroPoint() + + return accountSubQueryRequest(user, chain) { externalApiLink, userAddress -> + val request = DirectHistoricalVotesRequest(userAddress, timePointThreshold) + val response = delegationsSubqueryApi.getDirectHistoricalVotes(externalApiLink, request) + + response.data.direct.toUserVoteMap().filterNotNull() + } + } + + override suspend fun CallBuilder.delegate(delegate: AccountId, trackId: TrackId, amount: Balance, conviction: Conviction) { + convictionVotingDelegate(delegate, trackId, amount, conviction) + } + + override suspend fun CallBuilder.undelegate(trackId: TrackId) { + convictionVotingUndelegate(trackId) + } + + private fun SubQueryNodes.toUserVoteMap(): Map { + return nodes.associateBy( + keySelector = { ReferendumId(it.referendumId) }, + valueTransform = { directVoteRemote -> UserVote.Direct(mapMultiVoteRemoteToAccountVote(directVoteRemote)) } + ) + } + + private fun SubQueryNodes.toUserVoteMap(chain: Chain): Map { + return nodes.associateBy( + keySelector = { ReferendumId(it.parent.referendumId) }, + valueTransform = { delegatedVoteRemote -> + // delegated votes do not participate in any vote rather than standard + val aye = delegatedVoteRemote.parent.standardVote?.aye ?: return@associateBy null + val standardVote = delegatedVoteRemote.vote + + UserVote.Delegated( + delegate = chain.accountIdOf(delegatedVoteRemote.parent.delegateId), + vote = AccountVote.Standard( + balance = standardVote.amount, + vote = Vote( + aye = aye, + conviction = mapConvictionFromString(delegatedVoteRemote.vote.conviction) + ) + ), + ) + } + ) + } + + private fun mapDelegationFromRemote( + delegation: DelegateDelegatorsResponse.DelegatorRemote, + chain: Chain, + delegate: AccountId + ): Delegation { + return Delegation( + vote = Delegation.Vote( + amount = delegation.delegation.amount, + conviction = mapConvictionFromString(delegation.delegation.conviction) + ), + delegator = chain.accountIdOf(delegation.address), + delegate = delegate + ) + } + + private inline fun accountSubQueryRequest( + accountId: AccountId, + chain: Chain, + action: (url: String, address: String) -> R + ): R? { + val externalApiLink = chain.externalApi()?.url ?: return null + val address = chain.addressOf(accountId) + + return runCatching { action(externalApiLink, address) } + .onFailure { Log.e(LOG_TAG, "Failed to execute subquery request", it) } + .getOrNull() + } + + private fun mapDelegateStats(delegateStats: List, chain: Chain): List { + return delegateStats.map { delegate -> + DelegateStats( + accountId = chain.accountIdOf(delegate.address), + delegationsCount = delegate.delegators, + delegatedVotes = delegate.delegatorVotes, + recentVotes = delegate.delegateVotes.totalCount + ) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/Gov2PreImageRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/Gov2PreImageRepository.kt new file mode 100644 index 0000000..05b70bc --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/Gov2PreImageRepository.kt @@ -0,0 +1,178 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v2 + +import io.novafoundation.nova.common.data.network.runtime.binding.bindByteArray +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.preImage +import io.novafoundation.nova.common.utils.storageOrFallback +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PreImage +import io.novafoundation.nova.feature_governance_api.data.repository.HexHash +import io.novafoundation.nova.feature_governance_api.data.repository.PreImageRepository +import io.novafoundation.nova.feature_governance_api.data.repository.PreImageRequest +import io.novafoundation.nova.feature_governance_api.data.repository.PreImageRequest.FetchCondition +import io.novafoundation.nova.feature_governance_impl.data.preimage.PreImageSizer +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.wrapSingleArgumentKeys +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Tuple +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntryType +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import java.math.BigInteger + +class Gov2PreImageRepository( + private val remoteSource: StorageDataSource, + private val preImageSizer: PreImageSizer, +) : PreImageRepository { + + override suspend fun getPreimageFor(request: PreImageRequest, chainId: ChainId): PreImage? { + return remoteSource.query(chainId) { + val storage = runtime.metadata.preImageForStorage() + val shouldKnowSize = storage.shouldKnowSizeExecuting(request) + + val preImageSize = if (shouldKnowSize) { + request.knownSize ?: fetchPreImageLength(request.hash) + } else { + request.knownSize + } + + val shouldFetch = request.fetchIf.shouldFetch(actualPreImageSize = preImageSize) + + val canFetchPreimage = if (storage.requiresSize()) preImageSize != null else true + val canReturnValue = shouldFetch && canFetchPreimage + + if (!canReturnValue) return@query null + + val key = storage.preImageStorageKey(request.hash, preImageSize) + storage.query(key, binding = { bindPreimage(it, runtime) }) + } + } + + override suspend fun getPreimagesFor(requests: Collection, chainId: ChainId): Map { + return remoteSource.query(chainId) { + val storage = runtime.metadata.preImageForStorage() + val shouldKnowSizes = requests.associateBy( + keySelector = { it.hashHex }, + valueTransform = { request -> storage.shouldKnowSizeExecuting(request) } + ) + val hashesToFetchSize = requests.mapNotNull { + if (it.hashHex in shouldKnowSizes && it.knownSize == null) { + it.hash + } else { + null + } + } + val fetchedSizes = fetchPreImagesLength(hashesToFetchSize) + + val preKnownSizes = requests.associateBy( + keySelector = { it.hashHex }, + valueTransform = { it.knownSize } + ) + val allKnownSizes = preKnownSizes + fetchedSizes + + val keysToFetch = requests.mapNotNull { request -> + val preImageSize = allKnownSizes[request.hashHex] + + val shouldFetch = request.fetchIf.shouldFetch(actualPreImageSize = preImageSize) + val canFetchPreimage = if (storage.requiresSize()) preImageSize != null else true + + if (shouldFetch && canFetchPreimage) { + storage.preImageStorageKey(request.hash, preImageSize) + } else { + null + } + } + storage.entries( + keysArguments = keysToFetch.wrapSingleArgumentKeys(), + keyExtractor = { (hashAndLen: List<*>) -> bindByteArray(hashAndLen.first()).toHexString() }, + binding = { decoded, _ -> bindPreimage(decoded, runtime) } + ) + } + } + + private fun StorageEntry.shouldKnowSizeExecuting(request: PreImageRequest): Boolean { + return requiresSize() || request.fetchIf == FetchCondition.SMALL_SIZE + } + + private fun StorageEntry.preImageStorageKey(hash: ByteArray, preImageSize: BigInteger?): Any { + return if (requiresSize()) { + listOf(hash, preImageSize!!) + } else { + hash + } + } + + private fun FetchCondition.shouldFetch(actualPreImageSize: BigInteger?): Boolean { + return when (this) { + FetchCondition.ALWAYS -> true + FetchCondition.SMALL_SIZE -> + actualPreImageSize != null && + preImageSizer.satisfiesSizeConstraint(actualPreImageSize, PreImageSizer.SizeConstraint.SMALL) + } + } + + private suspend fun StorageQueryContext.fetchPreImageLength(callHash: ByteArray): BigInteger? { + return runtime.metadata.preImage().storageOrFallback("RequestStatusFor", "StatusFor") + .query( + callHash, + binding = ::bindPreImageLength + ) + } + + private suspend fun StorageQueryContext.fetchPreImagesLength(callHashes: Collection): Map { + return runtime.metadata.preImage().storageOrFallback("RequestStatusFor", "StatusFor") + .entries( + keysArguments = callHashes.wrapSingleArgumentKeys(), + keyExtractor = { (callHash: ByteArray) -> callHash.toHexString() }, + binding = { decoded, _ -> bindPreImageLength(decoded) } + ) + } + + private fun bindPreImageLength(decoded: Any?): BigInteger? = runCatching { + val asDictEnum = decoded.castToDictEnum() + // every variant of RequestStatus is struct that has len field + val valueStruct = asDictEnum.value.castToStruct() + + bindNumber(valueStruct["len"]) + } + .getOrNull() + + private fun bindPreimage( + decoded: Any?, + runtime: RuntimeSnapshot, + ): PreImage? { + val asByteArray = decoded.castOrNull() ?: return null + + val runtimeCall = runCatching { + GenericCall.fromByteArray(runtime, asByteArray) + }.getOrNull() + + return runtimeCall?.let { + PreImage( + encodedCall = asByteArray, + call = it, + ) + } + } + + private fun RuntimeMetadata.preImageForStorage(): StorageEntry { + return preImage().storage("PreimageFor") + } + + private fun StorageEntry.requiresSize(): Boolean { + val keys = type.cast().keys + val argument = keys.first() + + // for newer version of the pallet key is (CallHash, Length) + return argument is Tuple + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/GovV2ConvictionVotingRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/GovV2ConvictionVotingRepository.kt new file mode 100644 index 0000000..3399ccf --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/GovV2ConvictionVotingRepository.kt @@ -0,0 +1,254 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v2 + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.utils.convictionVoting +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.sum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Delegation +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumVoter +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amountMultiplier +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.isAye +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.votedFor +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumVotingDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumVotingDetails.VotingInfo +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.empty +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.plus +import io.novafoundation.nova.feature_governance_api.data.repository.ConvictionVotingRepository +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule +import io.novafoundation.nova.feature_governance_impl.data.network.blockchain.extrinsic.convictionVotingRemoveVote +import io.novafoundation.nova.feature_governance_impl.data.network.blockchain.extrinsic.convictionVotingUnlock +import io.novafoundation.nova.feature_governance_impl.data.network.blockchain.extrinsic.convictionVotingVote +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.DelegationsSubqueryApi +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.ReferendumSplitAbstainVotersRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.ReferendumVotersRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.request.ReferendumVotesRequest +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.ReferendumVoterRemote +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.response.mapMultiVoteRemoteToAccountVote +import io.novafoundation.nova.feature_governance_impl.data.repository.common.bindVoting +import io.novafoundation.nova.feature_governance_impl.data.repository.common.toOffChainVotes +import io.novafoundation.nova.feature_governance_impl.data.repository.common.votersFor +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.externalApi +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.mapConvictionFromString +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class GovV2ConvictionVotingRepository( + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val delegateSubqueryApi: DelegationsSubqueryApi +) : ConvictionVotingRepository { + + override val voteLockId = BalanceLockId.fromFullId("pyconvot") + + override suspend fun maxAvailableForVote(asset: Asset): Balance { + return asset.totalInPlanks + } + + override suspend fun voteLockingPeriod(chainId: ChainId): BlockNumber { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.convictionVoting().numberConstant("VoteLockingPeriod", runtime) + } + + override suspend fun maxTrackVotes(chainId: ChainId): BigInteger { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.convictionVoting().numberConstant("MaxVotes", runtime) + } + + override fun trackLocksFlow(accountId: AccountId, chainAssetId: FullChainAssetId): Flow> { + return remoteStorageSource.subscribe(chainAssetId.chainId) { + runtime.metadata.convictionVoting().storage("ClassLocksFor").observe(accountId, binding = ::bindTrackLocks) + .map { it.toMap() } + } + } + + override suspend fun votingFor(accountId: AccountId, chainId: ChainId): Map { + return remoteStorageSource.query(chainId) { + runtime.metadata.convictionVoting().storage("VotingFor").entries( + accountId, + keyExtractor = { (_: AccountId, trackId: BigInteger) -> TrackId(trackId) }, + binding = { decoded, _ -> bindVoting(decoded!!) } + ) + } + } + + override suspend fun votingFor(accountId: AccountId, chainId: ChainId, trackId: TrackId): Voting? { + return remoteStorageSource.query(chainId) { + runtime.metadata.convictionVoting().storage("VotingFor").query( + accountId, + trackId.value, + binding = { decoded -> decoded?.let(::bindVoting) } + ) + } + } + + override suspend fun votingFor(accountId: AccountId, chainId: ChainId, trackIds: Collection): Map { + val keys = trackIds.map { listOf(accountId, it.value) } + + return remoteStorageSource.query(chainId) { + runtime.metadata.convictionVoting().storage("VotingFor").entries( + keysArguments = keys, + keyExtractor = { (_: AccountId, trackId: BigInteger) -> TrackId(trackId) }, + binding = { decoded, _ -> decoded?.let(::bindVoting) } + ) + }.filterNotNull() + } + + override suspend fun votersOf(referendumId: ReferendumId, chain: Chain, type: VoteType): List { + val governanceDelegationsExternalApi = chain.externalApi() + return if (governanceDelegationsExternalApi != null) { + runCatching { getVotersFromIndexer(referendumId, chain, governanceDelegationsExternalApi, type) } + .getOrElse { getVotersFromChain(referendumId, chain, type) } + } else { + getVotersFromChain(referendumId, chain, type) + } + } + + override suspend fun abstainVotingDetails(referendumId: ReferendumId, chain: Chain): OffChainReferendumVotingDetails? { + val api = chain.externalApi() ?: return null + + return runCatching { + val request = ReferendumSplitAbstainVotersRequest(referendumId.value) + val response = delegateSubqueryApi.getReferendumAbstainVoters(api.url, request) + val trackId = TrackId(response.data.referendum.trackId) + val abstainAmountSum = response.data + .referendum + .castingVotings + .nodes + .mapNotNull { it.splitAbstainVote?.abstainAmount } + .sum() + + val abstainVotes = abstainAmountSum.toBigDecimal() * Conviction.None.amountMultiplier() + OffChainReferendumVotingDetails(trackId, VotingInfo.Abstain(abstainVotes)) + }.getOrNull() + } + + private suspend fun getVotersFromIndexer( + referendumId: ReferendumId, + chain: Chain, + api: Chain.ExternalApi.GovernanceDelegations, + type: VoteType + ): List { + val request = ReferendumVotersRequest(referendumId.value, type.isAye()) + val response = delegateSubqueryApi.getReferendumVoters(api.url, request) + return response.data + .voters + .nodes + .mapNotNull { mapVoterFromRemote(it, chain, type) } + } + + private suspend fun getVotersFromChain(referendumId: ReferendumId, chain: Chain, type: VoteType): List { + val allVotings = remoteStorageSource.query(chain.id) { + runtime.metadata.convictionVoting().storage("VotingFor").entries( + keyExtractor = { it }, + binding = { decoded, _ -> bindVoting(decoded!!) } + ) + } + + return allVotings.votersFor(referendumId) + .filter { it.vote.votedFor(type) } + } + + override fun ExtrinsicBuilder.unlock(accountId: AccountId, claimable: ClaimSchedule.UnlockChunk.Claimable) { + claimable.actions.forEach { claimAction -> + when (claimAction) { + is ClaimSchedule.ClaimAction.RemoveVote -> { + removeVote(claimAction.trackId, claimAction.referendumId) + } + + is ClaimSchedule.ClaimAction.Unlock -> { + convictionVotingUnlock(claimAction.trackId, accountId) + } + } + } + } + + override fun ExtrinsicBuilder.vote(referendumId: ReferendumId, vote: AccountVote) { + convictionVotingVote(referendumId, vote) + } + + override fun CallBuilder.vote(referendumId: ReferendumId, vote: AccountVote) { + convictionVotingVote(referendumId, vote) + } + + override fun ExtrinsicBuilder.removeVote(trackId: TrackId, referendumId: ReferendumId) { + convictionVotingRemoveVote(trackId, referendumId) + } + + override fun isAbstainVotingAvailable(): Boolean { + return true + } + + override suspend fun fullVotingDetails(referendumId: ReferendumId, chain: Chain): OffChainReferendumVotingDetails? { + val api = chain.externalApi() ?: return null + + return runCatching { + val referendum = delegateSubqueryApi.getReferendumVotes(api.url, ReferendumVotesRequest(referendumId.value)) + .data + .referendum + + val voters = referendum.castingVotings.nodes + + var totalVoting = VotingInfo.Full.empty() + + voters.forEach { + totalVoting += it.toOffChainVotes() + } + + OffChainReferendumVotingDetails(TrackId(referendum.trackId), totalVoting) + }.getOrNull() + } + + private fun bindTrackLocks(decoded: Any?): List> { + return bindList(decoded) { item -> + val (trackId, balance) = item.castToList() + + TrackId(bindNumber(trackId)) to bindNumber(balance) + } + } + + private fun mapVoterFromRemote(voter: ReferendumVoterRemote, chain: Chain, expectedType: VoteType): ReferendumVoter? { + val accountVote = mapMultiVoteRemoteToAccountVote(voter) + if (!accountVote.votedFor(expectedType)) return null + + val delegators = voter.delegatorVotes.nodes + + return ReferendumVoter( + accountId = chain.accountIdOf(voter.voterId), + vote = accountVote, + delegators = delegators.map { + Delegation( + vote = Delegation.Vote(it.vote.amount, mapConvictionFromString(it.vote.conviction)), + delegator = chain.accountIdOf(it.delegatorId), + delegate = chain.accountIdOf(voter.voterId), + ) + } + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/GovV2DAppsRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/GovV2DAppsRepository.kt new file mode 100644 index 0000000..280321d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/GovV2DAppsRepository.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v2 + +import io.novafoundation.nova.common.utils.formatNamed +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.core_db.model.GovernanceDAppLocal +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.repository.GovernanceDAppsRepository +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDApp +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GovV2DAppsRepository( + private val governanceDAppsDao: GovernanceDAppsDao +) : GovernanceDAppsRepository { + + override fun observeReferendumDApps(chainId: ChainId, referendumId: ReferendumId): Flow> { + return governanceDAppsDao.observeChainDapps(chainId) + .map { dapps -> + val v1Dapps = dapps.filter { it.referendumUrlV2 != null } + mapV2DappsLocalToDomain(referendumId, v1Dapps) + } + } +} + +private fun mapV2DappsLocalToDomain(referendumId: ReferendumId, dapps: List): List { + return dapps.map { + ReferendumDApp( + it.chainId, + it.name, + it.referendumUrlV2?.formatNamed("referendumId" to referendumId.value.toString())!!, + it.iconUrl, + it.details + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/GovV2OnChainReferendaRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/GovV2OnChainReferendaRepository.kt new file mode 100644 index 0000000..6f62803 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/repository/v2/GovV2OnChainReferendaRepository.kt @@ -0,0 +1,306 @@ +package io.novafoundation.nova.feature_governance_impl.data.repository.v2 + +import android.util.Log +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindBlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindBoolean +import io.novafoundation.nova.common.data.network.runtime.binding.bindFixedI64 +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindPerbill +import io.novafoundation.nova.common.data.network.runtime.binding.bindString +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.constant +import io.novafoundation.nova.common.utils.decodedValue +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.referenda +import io.novafoundation.nova.common.utils.scheduler +import io.novafoundation.nova.common.utils.toByteArray +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ConfirmingSource +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ConfirmingStatus +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.DecidingStatus +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendumStatus +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumDeposit +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackQueue +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingCurve +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.empty +import io.novafoundation.nova.feature_governance_api.data.repository.OnChainReferendaRepository +import io.novafoundation.nova.feature_governance_api.data.repository.getTracksById +import io.novafoundation.nova.feature_governance_api.data.thresold.gov2.Gov2VotingThreshold +import io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve.LinearDecreasingCurve +import io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve.ReciprocalCurve +import io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve.SteppedDecreasingCurve +import io.novafoundation.nova.feature_governance_impl.data.repository.common.bindProposal +import io.novafoundation.nova.feature_governance_impl.data.repository.common.bindTally +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.repository.getActiveIssuance +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.u32 +import io.novasama.substrate_sdk_android.runtime.definitions.types.toByteArray +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.scale.dataType.string +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +private const val ASSEMBLY_ID = "assembly" + +class GovV2OnChainReferendaRepository( + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val totalIssuanceRepository: TotalIssuanceRepository, +) : OnChainReferendaRepository { + + override suspend fun electorate(chainId: ChainId): Balance { + return totalIssuanceRepository.getActiveIssuance(chainId) + } + + override suspend fun undecidingTimeout(chainId: ChainId): BlockNumber { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.referenda().numberConstant("UndecidingTimeout", runtime) + } + + override suspend fun getTracks(chainId: ChainId): Collection { + val runtime = chainRegistry.getRuntime(chainId) + val tracksConstant = runtime.metadata.referenda().constant("Tracks").decodedValue(runtime) + + return bindTracks(tracksConstant) + } + + override suspend fun getTrackQueues(trackIds: Set, chainId: ChainId): Map { + if (trackIds.isEmpty()) return emptyMap() + return remoteStorageSource.query(chainId) { + runtime.metadata.referenda().storage("TrackQueue").entries( + keysArguments = trackIds.map { listOf(it.value) }, + keyExtractor = { (trackIdRaw: BigInteger) -> TrackId(trackIdRaw) }, + binding = { decoded, _ -> bindTrackQueue(decoded) } + ) + } + } + + override suspend fun getAllOnChainReferenda(chainId: ChainId): Collection { + return remoteStorageSource.query(chainId) { + val allTracks = getTracksById(chainId) + + runtime.metadata.referenda().storage("ReferendumInfoFor").entries( + prefixArgs = emptyArray(), + keyExtractor = { (id: BigInteger) -> ReferendumId(id) }, + binding = { decoded, id -> bindReferendum(decoded, id, allTracks, runtime) } + ).values.filterNotNull() + } + } + + override suspend fun getOnChainReferenda(chainId: ChainId, referendaIds: Collection): Map { + return remoteStorageSource.query(chainId) { + val allTracks = getTracksById(chainId) + + runtime.metadata.referenda().storage("ReferendumInfoFor").entries( + keysArguments = referendaIds.map { id -> listOf(id.value) }, + keyExtractor = { (id: BigInteger) -> ReferendumId(id) }, + binding = { decoded, id -> bindReferendum(decoded, id, allTracks, runtime) } + ) + }.filterNotNull() + } + + override suspend fun onChainReferendumFlow(chainId: ChainId, referendumId: ReferendumId): Flow { + return remoteStorageSource.subscribe(chainId) { + val allTracks = getTracksById(chainId) + + runtime.metadata.referenda().storage("ReferendumInfoFor").observe( + referendumId.value, + binding = { bindReferendum(it, referendumId, allTracks, runtime) } + ) + } + } + + override suspend fun getReferendaExecutionBlocks( + chainId: ChainId, + approvedReferendaIds: Collection + ): Map { + if (approvedReferendaIds.isEmpty()) return emptyMap() + + return remoteStorageSource.query(chainId) { + val referendaIdBySchedulerId = approvedReferendaIds.associateBy { it.enactmentSchedulerId(runtime).toHexString() } + + runtime.metadata.scheduler().storage("Lookup").entries( + keysArguments = referendaIdBySchedulerId.keys.map { schedulerIdHex -> listOf(schedulerIdHex.fromHex()) }, + keyExtractor = { (schedulerId: ByteArray) -> referendaIdBySchedulerId.getValue(schedulerId.toHexString()) }, + binding = { decoded, _ -> + decoded?.let { + val (blockNumber, _) = decoded.castToList() + + bindBlockNumber(blockNumber) + } + } + ).filterNotNull() + } + } + + private fun bindReferendum( + decoded: Any?, + id: ReferendumId, + tracksById: Map, + runtime: RuntimeSnapshot + ): OnChainReferendum? = runCatching { + val asDictEnum = decoded.castToDictEnum() + + val referendumStatus = when (asDictEnum.name) { + "Ongoing" -> { + val status = asDictEnum.value.castToStruct() + val trackId = TrackId(bindNumber(status["track"])) + val track = tracksById.getValue(trackId) + + OnChainReferendumStatus.Ongoing( + track = trackId, + proposal = bindProposal(status["proposal"], runtime), + submitted = bindBlockNumber(status["submitted"]), + submissionDeposit = bindReferendumDeposit(status["submissionDeposit"])!!, + decisionDeposit = bindReferendumDeposit(status["decisionDeposit"]), + deciding = bindDecidingStatus(status["deciding"]), + tally = bindTally(status.getTyped("tally")), + inQueue = bindBoolean(status["inQueue"]), + threshold = Gov2VotingThreshold(track), + ) + } + + "Approved" -> OnChainReferendumStatus.Approved(bindCompletedReferendumSince(asDictEnum.value)) + "Rejected" -> OnChainReferendumStatus.Rejected(bindCompletedReferendumSince(asDictEnum.value)) + "Cancelled" -> OnChainReferendumStatus.Cancelled(bindCompletedReferendumSince(asDictEnum.value)) + "TimedOut" -> OnChainReferendumStatus.TimedOut(bindCompletedReferendumSince(asDictEnum.value)) + "Killed" -> OnChainReferendumStatus.Killed(bindNumber(asDictEnum.value)) + else -> throw IllegalArgumentException("Unsupported referendum status") + } + + OnChainReferendum( + id = id, + status = referendumStatus + ) + } + .onFailure { Log.e(this.LOG_TAG, "Failed to decode on-chain referendum $id", it) } + .getOrNull() + + private fun bindDecidingStatus(decoded: Any?): DecidingStatus? { + if (decoded == null) return null + val decodedStruct = decoded.castToStruct() + + val confirming = decodedStruct.get("confirming")?.let { + ConfirmingStatus( + till = bindBlockNumber(it) + ) + } + return DecidingStatus( + since = bindBlockNumber(decodedStruct["since"]), + confirming = ConfirmingSource.OnChain(confirming) + ) + } + + private fun bindCompletedReferendumSince(decoded: Any?): BlockNumber { + // first element in tuple + val since = decoded.castToList().first() + + return bindNumber(since) + } + + private fun bindReferendumDeposit(decoded: Struct.Instance?): ReferendumDeposit? { + return decoded?.let { + ReferendumDeposit( + who = bindAccountId(it["who"]), + amount = bindNumber(it["amount"]) + ) + } + } + + private fun bindTracks(decoded: Any?): List { + return bindList(decoded) { + val (id, content) = it.castToList() + val trackInfoStruct = content.castToStruct() + + TrackInfo( + id = TrackId(bindNumber(id)), + name = bindString(trackInfoStruct["name"]), + preparePeriod = bindBlockNumber(trackInfoStruct["preparePeriod"]), + decisionPeriod = bindBlockNumber(trackInfoStruct["decisionPeriod"]), + confirmPeriod = bindBlockNumber(trackInfoStruct["confirmPeriod"]), + minApproval = bindCurve(trackInfoStruct.getTyped("minApproval")), + minSupport = bindCurve(trackInfoStruct.getTyped("minSupport")) + ) + } + } + + private fun bindCurve(decoded: DictEnum.Entry<*>): VotingCurve { + val valueStruct = decoded.value.castToStruct() + + return when (decoded.name) { + "Reciprocal" -> { + ReciprocalCurve( + factor = bindFixedI64(valueStruct["factor"]), + xOffset = bindFixedI64(valueStruct["x_offset"]), + yOffset = bindFixedI64(valueStruct["y_offset"]) + ) + } + + "LinearDecreasing" -> { + LinearDecreasingCurve( + length = bindPerbill(valueStruct["length"]), + floor = bindPerbill(valueStruct["floor"]), + ceil = bindPerbill(valueStruct["ceil"]) + ) + } + + "SteppedDecreasing" -> { + SteppedDecreasingCurve( + begin = bindPerbill(valueStruct["begin"]), + end = bindPerbill(valueStruct["end"]), + step = bindPerbill(valueStruct["step"]), + period = bindPerbill(valueStruct["period"]) + ) + } + + else -> incompatible() + } + } + + private fun bindTrackQueue(decoded: Any?): TrackQueue { + if (decoded == null) return TrackQueue.empty() + + val referendumIds = bindList(decoded) { + val (referendumIndex, _) = it.castToList() + + ReferendumId(bindNumber(referendumIndex)) + } + + return TrackQueue(referendumIds) + } + + // https://github.com/paritytech/substrate/blob/fc67cbb66d8c484bc7b7506fc1300344d12ecbad/frame/referenda/src/lib.rs#L716 + private fun ReferendumId.enactmentSchedulerId(runtime: RuntimeSnapshot): ByteArray { + val encodedAssemblyId = ASSEMBLY_ID.encodeToByteArray() // 'const bytes' in rust + val encodedEnactment = string.toByteArray("enactment") // 'string' in rust + val encodedIndex = u32.toByteArray(runtime, value) + + val toHash = encodedAssemblyId + encodedEnactment + encodedIndex + + return toHash.blake2b256() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/source/GovernanceSourceRegistry.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/source/GovernanceSourceRegistry.kt new file mode 100644 index 0000000..859bb2e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/source/GovernanceSourceRegistry.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_governance_impl.data.source + +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +internal class RealGovernanceSourceRegistry( + private val governanceV2Source: GovernanceSource, + private val governanceV1Source: GovernanceSource, +) : GovernanceSourceRegistry { + + override suspend fun sourceFor(option: SupportedGovernanceOption): GovernanceSource { + return sourceFor(option.additional.governanceType) + } + + override suspend fun sourceFor(option: Chain.Governance): GovernanceSource { + return when (option) { + Chain.Governance.V1 -> governanceV1Source + Chain.Governance.V2 -> governanceV2Source + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/source/StaticGovernanceSource.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/source/StaticGovernanceSource.kt new file mode 100644 index 0000000..d3abb73 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/data/source/StaticGovernanceSource.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_governance_impl.data.source + +import io.novafoundation.nova.feature_governance_api.data.repository.ConvictionVotingRepository +import io.novafoundation.nova.feature_governance_api.data.repository.DelegationsRepository +import io.novafoundation.nova.feature_governance_api.data.repository.GovernanceDAppsRepository +import io.novafoundation.nova.feature_governance_api.data.repository.OffChainReferendaInfoRepository +import io.novafoundation.nova.feature_governance_api.data.repository.OnChainReferendaRepository +import io.novafoundation.nova.feature_governance_api.data.repository.PreImageRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource + +internal class StaticGovernanceSource( + override val referenda: OnChainReferendaRepository, + override val convictionVoting: ConvictionVotingRepository, + override val offChainInfo: OffChainReferendaInfoRepository, + override val preImageRepository: PreImageRepository, + override val dappsRepository: GovernanceDAppsRepository, + override val delegationsRepository: DelegationsRepository +) : GovernanceSource diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureComponent.kt new file mode 100644 index 0000000..384f09b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureComponent.kt @@ -0,0 +1,141 @@ +package io.novafoundation.nova.feature_governance_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksCommunicator +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.di.DescriptionComponent +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.di.ReferendumInfoComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.di.DelegateDelegatorsComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.di.DelegateDetailsComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.di.VotedReferendaComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.list.di.DelegateListComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.search.di.DelegateSearchComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegated.di.YourDelegationsComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.di.NewDelegationChooseAmountComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.di.NewDelegationChooseTracksComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.di.NewDelegationConfirmComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.di.RemoveVotesComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.di.RevokeDelegationChooseTracksComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.di.RevokeDelegationConfirmComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.di.ReferendumDetailsComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.filters.di.ReferendaFiltersComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.di.ReferendumFullDetailsComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.di.ReferendaListComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.search.di.ReferendaSearchComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.di.ConfirmReferendumVoteComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.referenda.di.SetupReferendumVoteComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.di.SetupTinderGovVoteComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.TinderGovVoteCommunicator +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.di.ReferendumVotersComponent +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.di.TinderGovBasketComponent +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.di.TinderGovCardsComponent +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.confirm.di.ConfirmTinderGovVoteComponent +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.governanceTracks.di.SelectGovernanceTracksComponent +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm.di.ConfirmGovernanceUnlockComponent +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.di.GovernanceLocksOverviewComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + GovernanceFeatureDependencies::class, + ], + modules = [ + GovernanceFeatureModule::class, + ] +) +@FeatureScope +interface GovernanceFeatureComponent : GovernanceFeatureApi { + + fun referendaListFactory(): ReferendaListComponent.Factory + + fun referendaSearchFactory(): ReferendaSearchComponent.Factory + + fun referendumDetailsFactory(): ReferendumDetailsComponent.Factory + + fun descriptionFactory(): DescriptionComponent.Factory + + fun referendumInfoFactory(): ReferendumInfoComponent.Factory + + fun referendumFullDetailsFactory(): ReferendumFullDetailsComponent.Factory + + fun setupReferendumVoteFactory(): SetupReferendumVoteComponent.Factory + + fun setupTinderGovVoteFactory(): SetupTinderGovVoteComponent.Factory + + fun confirmReferendumVoteFactory(): ConfirmReferendumVoteComponent.Factory + + fun confirmTinderGovVoteFactory(): ConfirmTinderGovVoteComponent.Factory + + fun referendumVotersFactory(): ReferendumVotersComponent.Factory + + fun confirmGovernanceUnlockFactory(): ConfirmGovernanceUnlockComponent.Factory + + fun governanceLocksOverviewFactory(): GovernanceLocksOverviewComponent.Factory + + fun delegateListFactory(): DelegateListComponent.Factory + + fun delegateSearchFactory(): DelegateSearchComponent.Factory + + fun delegateDetailsFactory(): DelegateDetailsComponent.Factory + + fun votedReferendaFactory(): VotedReferendaComponent.Factory + + fun removeVoteFactory(): RemoveVotesComponent.Factory + + fun delegateDelegatorsFactory(): DelegateDelegatorsComponent.Factory + + fun yourDelegationsFactory(): YourDelegationsComponent.Factory + + fun newDelegationChooseTracks(): NewDelegationChooseTracksComponent.Factory + + fun selectGovernanceTracks(): SelectGovernanceTracksComponent.Factory + + fun newDelegationChooseAmountFactory(): NewDelegationChooseAmountComponent.Factory + + fun newDelegationConfirmFactory(): NewDelegationConfirmComponent.Factory + + fun revokeDelegationChooseTracksFactory(): RevokeDelegationChooseTracksComponent.Factory + + fun revokeDelegationConfirmFactory(): RevokeDelegationConfirmComponent.Factory + + fun referendaFiltersFactory(): ReferendaFiltersComponent.Factory + + fun tinderGovCardsFactory(): TinderGovCardsComponent.Factory + + fun tinderGovBasketFactory(): TinderGovBasketComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + deps: GovernanceFeatureDependencies, + @BindsInstance router: GovernanceRouter, + @BindsInstance selectTracksCommunicator: SelectTracksCommunicator, + @BindsInstance tinderGovVoteCommunicator: TinderGovVoteCommunicator + ): GovernanceFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + WalletFeatureApi::class, + AccountFeatureApi::class, + DAppFeatureApi::class, + DbApi::class, + XcmFeatureApi::class, + DeepLinkingFeatureApi::class + ] + ) + interface GovernanceFeatureDependenciesComponent : GovernanceFeatureDependencies +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureDependencies.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureDependencies.kt new file mode 100644 index 0000000..c51e31d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureDependencies.kt @@ -0,0 +1,173 @@ +package io.novafoundation.nova.feature_governance_impl.di + +import android.content.Context +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.modules.Caching +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.input.chooser.ListChooserMixin +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.core_db.dao.TinderGovDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.OnChainIdentity +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.AssetModelFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverterFactory +import io.novafoundation.nova.runtime.di.ExtrinsicSerialization +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +interface GovernanceFeatureDependencies { + + val maskableValueFormatterFactory: MaskableValueFormatterFactory + + val maskableValueFormatterProvider: MaskableValueFormatterProvider + + val amountFormatter: AmountFormatter + + val tokenFormatter: TokenFormatter + + val onChainIdentityRepository: OnChainIdentityRepository + + val listChooserMixinFactory: ListChooserMixin.Factory + + val identityMixinFactory: IdentityMixin.Factory + + val partialRetriableMixinFactory: PartialRetriableMixin.Factory + + val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory + + val bannerVisibilityRepository: BannerVisibilityRepository + + val assetModelFormatter: AssetModelFormatter + + val chainMultiLocationConverterFactory: ChainMultiLocationConverterFactory + + val assetMultiLocationConverterFactory: MultiLocationConverterFactory + + val assetIconProvider: AssetIconProvider + + val feeLoaderMixinFactory: FeeLoaderMixin.Factory + + val validationExecutor: ValidationExecutor + + val preferences: Preferences + + val walletRepository: WalletRepository + + val chainRegistry: ChainRegistry + + val imageLoader: ImageLoader + + val addressIconGenerator: AddressIconGenerator + + val resourceManager: ResourceManager + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val tokenRepository: TokenRepository + + val accountRepository: AccountRepository + + val selectedAccountUseCase: SelectedAccountUseCase + + val chainStateRepository: ChainStateRepository + + val totalIssuanceRepository: TotalIssuanceRepository + + val storageCache: StorageCache + + val sampledBlockTimeStorage: SampledBlockTimeStorage + + val dAppMetadataRepository: DAppMetadataRepository + + val externalAccountActions: ExternalActions.Presentation + + val context: Context + + val amountMixinFactory: AmountChooserMixin.Factory + + val extrinsicService: ExtrinsicService + + val resourceHintsMixinFactory: ResourcesHintsMixinFactory + + val walletUiUseCase: WalletUiUseCase + + val balanceLocksRepository: BalanceLocksRepository + + val computationalCache: ComputationalCache + + val governanceDAppsDao: GovernanceDAppsDao + + val tinderGovDao: TinderGovDao + + val networkApiCreator: NetworkApiCreator + + val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory + + val maxActionProviderFactory: MaxActionProviderFactory + + val automaticInteractionGate: AutomaticInteractionGate + + val linkBuilderFactory: LinkBuilderFactory + + val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + + val copyTextLauncher: CopyTextLauncher.Presentation + + @Caching + fun cachingIconGenerator(): AddressIconGenerator + + @ExtrinsicSerialization + fun extrinsicGson(): Gson + + @LocalIdentity + fun localIdentityProvider(): IdentityProvider + + @OnChainIdentity + fun onChainIdentityProvider(): IdentityProvider + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageDataSource(): StorageDataSource +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureHolder.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureHolder.kt new file mode 100644 index 0000000..847d3bd --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureHolder.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_governance_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksCommunicator +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.TinderGovVoteCommunicator +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class GovernanceFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: GovernanceRouter, + private val selectTracksCommunicator: SelectTracksCommunicator, + private val tinderGovVoteCommunicator: TinderGovVoteCommunicator +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerGovernanceFeatureComponent_GovernanceFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .dAppFeatureApi(getFeature(DAppFeatureApi::class.java)) + .xcmFeatureApi(getFeature(XcmFeatureApi::class.java)) + .deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java)) + .build() + + return DaggerGovernanceFeatureComponent.factory() + .create( + accountFeatureDependencies, + router, + selectTracksCommunicator = selectTracksCommunicator, + tinderGovVoteCommunicator = tinderGovVoteCommunicator + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureModule.kt new file mode 100644 index 0000000..ef6e07c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/GovernanceFeatureModule.kt @@ -0,0 +1,234 @@ +package io.novafoundation.nova.feature_governance_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.model.MaskingMode +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.OnChainIdentity +import io.novafoundation.nova.feature_governance_api.data.MutableGovernanceState +import io.novafoundation.nova.feature_governance_api.data.repository.TreasuryRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.presentation.referenda.common.ReferendaStatusFormatter +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.data.preimage.PreImageSizer +import io.novafoundation.nova.feature_governance_impl.data.preimage.RealPreImageSizer +import io.novafoundation.nova.feature_governance_impl.data.repository.RealTreasuryRepository +import io.novafoundation.nova.feature_governance_impl.data.source.RealGovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_impl.di.modules.GovernanceDAppsModule +import io.novafoundation.nova.feature_governance_impl.di.modules.GovernanceUpdatersModule +import io.novafoundation.nova.feature_governance_impl.di.modules.deeplink.DeepLinkModule +import io.novafoundation.nova.feature_governance_impl.di.modules.screens.DelegateModule +import io.novafoundation.nova.feature_governance_impl.di.modules.screens.ReferendumDetailsModule +import io.novafoundation.nova.feature_governance_impl.di.modules.screens.ReferendumListModule +import io.novafoundation.nova.feature_governance_impl.di.modules.screens.ReferendumUnlockModule +import io.novafoundation.nova.feature_governance_impl.di.modules.screens.ReferendumVoteModule +import io.novafoundation.nova.feature_governance_impl.di.modules.screens.ReferendumVotersModule +import io.novafoundation.nova.feature_governance_impl.di.modules.screens.TinderGovModule +import io.novafoundation.nova.feature_governance_impl.di.modules.v1.GovernanceV1 +import io.novafoundation.nova.feature_governance_impl.di.modules.v1.GovernanceV1Module +import io.novafoundation.nova.feature_governance_impl.di.modules.v2.GovernanceV2 +import io.novafoundation.nova.feature_governance_impl.di.modules.v2.GovernanceV2Module +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.repository.DelegateCommonRepository +import io.novafoundation.nova.feature_governance_impl.domain.identity.GovernanceIdentityProviderFactory +import io.novafoundation.nova.feature_governance_impl.domain.referendum.common.RealReferendaConstructor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.common.ReferendaConstructor +import io.novafoundation.nova.feature_governance_impl.domain.track.RealTracksUseCase +import io.novafoundation.nova.feature_governance_impl.domain.track.TracksUseCase +import io.novafoundation.nova.feature_governance_impl.domain.track.category.RealTrackCategorizer +import io.novafoundation.nova.feature_governance_impl.domain.track.category.TrackCategorizer +import io.novafoundation.nova.feature_governance_impl.presentation.common.conviction.ConvictionValuesProvider +import io.novafoundation.nova.feature_governance_impl.presentation.common.conviction.RealConvictionValuesProvider +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.LocksFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.RealLocksFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.common.share.RealShareReferendumMixin +import io.novafoundation.nova.feature_governance_impl.presentation.common.share.ShareReferendumMixin +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.RealVotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.DelegatesSharedComputation +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.RealReferendaStatusFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatterFactory +import io.novafoundation.nova.feature_governance_impl.presentation.track.RealTrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_wallet_api.di.common.SelectableAssetUseCaseModule +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.state.SelectableSingleAssetSharedState +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module( + includes = [ + SelectableAssetUseCaseModule::class, + GovernanceV2Module::class, + GovernanceV1Module::class, + GovernanceUpdatersModule::class, + ReferendumDetailsModule::class, + ReferendumListModule::class, + ReferendumVotersModule::class, + ReferendumVoteModule::class, + ReferendumUnlockModule::class, + DelegateModule::class, + GovernanceDAppsModule::class, + TinderGovModule::class, + DeepLinkModule::class + ] +) +class GovernanceFeatureModule { + + @Provides + @FeatureScope + fun provideTimelineDelegatingHolder(stakingSharedState: GovernanceSharedState) = DelegateToTimelineChainIdHolder(stakingSharedState) + + @Provides + @FeatureScope + fun provideDelegatesSharedComputation( + computationalCache: ComputationalCache, + delegateCommonRepository: DelegateCommonRepository, + chainStateRepository: ChainStateRepository, + identityRepository: OnChainIdentityRepository + ) = DelegatesSharedComputation( + computationalCache, + delegateCommonRepository, + chainStateRepository, + identityRepository + ) + + @Provides + @FeatureScope + fun provideAssetSharedState( + chainRegistry: ChainRegistry, + preferences: Preferences, + ) = GovernanceSharedState(chainRegistry, preferences) + + @Provides + @FeatureScope + fun provideGovernanceStateUpdater( + governanceSharedState: GovernanceSharedState + ): MutableGovernanceState = governanceSharedState + + @Provides + @FeatureScope + fun provideSelectableSharedState(governanceSharedState: GovernanceSharedState): SelectableSingleAssetSharedState<*> = governanceSharedState + + @Provides + @FeatureScope + fun provideGovernanceSourceRegistry( + @GovernanceV2 governanceV2Source: GovernanceSource, + @GovernanceV1 governanceV1Source: GovernanceSource, + ): GovernanceSourceRegistry = RealGovernanceSourceRegistry( + governanceV2Source = governanceV2Source, + governanceV1Source = governanceV1Source + ) + + @Provides + @FeatureScope + fun provideTreasuryRepository( + @Named(REMOTE_STORAGE_SOURCE) storageSource: StorageDataSource + ): TreasuryRepository = RealTreasuryRepository(storageSource) + + @Provides + @FeatureScope + fun provideReferendumConstructor( + governanceSourceRegistry: GovernanceSourceRegistry, + chainStateRepository: ChainStateRepository + ): ReferendaConstructor = RealReferendaConstructor(governanceSourceRegistry, chainStateRepository) + + @Provides + @FeatureScope + fun provideGovernanceIdentityProviderFactory( + @LocalIdentity localProvider: IdentityProvider, + @OnChainIdentity onChainProvider: IdentityProvider + ): GovernanceIdentityProviderFactory = GovernanceIdentityProviderFactory( + localProvider = localProvider, + onChainProvider = onChainProvider + ) + + @Provides + @FeatureScope + fun providePreImageSizer(): PreImageSizer = RealPreImageSizer() + + @Provides + @FeatureScope + fun provideTrackCategorizer(): TrackCategorizer = RealTrackCategorizer() + + @Provides + @FeatureScope + fun provideTracksFormatter( + trackCategorizer: TrackCategorizer, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider + ): TrackFormatter = RealTrackFormatter(trackCategorizer, resourceManager, assetIconProvider) + + @Provides + @FeatureScope + fun provideReferendaStatusFormatter( + resourceManager: ResourceManager + ): ReferendaStatusFormatter = RealReferendaStatusFormatter(resourceManager) + + @Provides + @FeatureScope + fun provideReferendumFormatterFactory( + resourceManager: ResourceManager, + trackFormatter: TrackFormatter, + referendaStatusFormatter: ReferendaStatusFormatter, + amountFormatter: AmountFormatter + ) = ReferendumFormatterFactory( + resourceManager, + trackFormatter, + referendaStatusFormatter, + amountFormatter + ) + + @Provides + @FeatureScope + fun provideDefaultReferendumFormatter( + referendumFormatterFactory: ReferendumFormatterFactory, + maskableValueFormatterFactory: MaskableValueFormatterFactory + ): ReferendumFormatter = referendumFormatterFactory.create(maskableValueFormatterFactory.create(MaskingMode.DISABLED)) + + @Provides + @FeatureScope + fun provideVotersFormatter( + resourceManager: ResourceManager, + addressIconGenerator: AddressIconGenerator + ): VotersFormatter = RealVotersFormatter(addressIconGenerator, resourceManager) + + @Provides + @FeatureScope + fun provideLocksFormatter( + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ): LocksFormatter = RealLocksFormatter(resourceManager, amountFormatter) + + @Provides + @FeatureScope + fun provideConvictionValuesProvider(): ConvictionValuesProvider = RealConvictionValuesProvider() + + @Provides + @FeatureScope + fun provideTracksUseCase( + governanceSharedState: GovernanceSharedState, + governanceSourceRegistry: GovernanceSourceRegistry, + ): TracksUseCase = RealTracksUseCase(governanceSharedState, governanceSourceRegistry) + + @Provides + @FeatureScope + fun provideShareReferendumMixin( + referendumLinkConfigurator: ReferendumDetailsDeepLinkConfigurator + ): ShareReferendumMixin = RealShareReferendumMixin(referendumLinkConfigurator) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/GovernanceDAppsModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/GovernanceDAppsModule.kt new file mode 100644 index 0000000..56a06d6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/GovernanceDAppsModule.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_impl.data.dapps.GovernanceDAppsSyncService +import io.novafoundation.nova.feature_governance_impl.data.dapps.remote.GovernanceDappsFetcher +import io.novafoundation.nova.feature_governance_impl.domain.dapp.GovernanceDAppsInteractor + +@Module +class GovernanceDAppsModule { + + @Provides + @FeatureScope + fun provideGovernanceDappsFetcher(apiCreator: NetworkApiCreator) = apiCreator.create(GovernanceDappsFetcher::class.java) + + @Provides + @FeatureScope + fun provideGovernanceSyncService( + dao: GovernanceDAppsDao, + chainFetcher: GovernanceDappsFetcher + ) = GovernanceDAppsSyncService(dao, chainFetcher) + + @Provides + @FeatureScope + fun provideGovernanceDAppInteractor( + governanceDAppsSyncService: GovernanceDAppsSyncService, + governanceSourceRegistry: GovernanceSourceRegistry + ): GovernanceDAppsInteractor = GovernanceDAppsInteractor( + governanceDAppsSyncService = governanceDAppsSyncService, + governanceSourceRegistry = governanceSourceRegistry + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/GovernanceUpdatersModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/GovernanceUpdatersModule.kt new file mode 100644 index 0000000..1fe2360 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/GovernanceUpdatersModule.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.BlockTimeUpdater +import io.novafoundation.nova.runtime.network.updaters.InactiveIssuanceUpdater +import io.novafoundation.nova.runtime.network.updaters.SharedAssetBlockNumberUpdater +import io.novafoundation.nova.runtime.network.updaters.TotalIssuanceUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.AsSharedStateUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimeLineChainUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder +import io.novafoundation.nova.runtime.network.updaters.multiChain.GroupBySyncChainMultiChainUpdateSystem +import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class GovernanceUpdatersModule { + + @Provides + @FeatureScope + fun provideUpdateSystem( + totalIssuanceUpdater: TotalIssuanceUpdater, + inactiveIssuanceUpdater: InactiveIssuanceUpdater, + blockNumberUpdater: SharedAssetBlockNumberUpdater, + blockTimeUpdater: BlockTimeUpdater, + chainRegistry: ChainRegistry, + singleAssetSharedState: GovernanceSharedState, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + ): UpdateSystem = GroupBySyncChainMultiChainUpdateSystem( + updaters = listOf( + AsSharedStateUpdater(totalIssuanceUpdater), + AsSharedStateUpdater(inactiveIssuanceUpdater), + DelegateToTimeLineChainUpdater(blockNumberUpdater), + DelegateToTimeLineChainUpdater(blockTimeUpdater), + ), + chainRegistry = chainRegistry, + singleAssetSharedState = singleAssetSharedState, + storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory, + ) + + @Provides + @FeatureScope + fun blockTimeUpdater( + chainIdHolder: DelegateToTimelineChainIdHolder, + chainRegistry: ChainRegistry, + sampledBlockTimeStorage: SampledBlockTimeStorage, + @Named(REMOTE_STORAGE_SOURCE) remoteStorage: StorageDataSource, + ) = BlockTimeUpdater(chainIdHolder, chainRegistry, sampledBlockTimeStorage, remoteStorage) + + @Provides + @FeatureScope + fun provideBlockNumberUpdater( + chainRegistry: ChainRegistry, + chainIdHolder: DelegateToTimelineChainIdHolder, + storageCache: StorageCache, + ) = SharedAssetBlockNumberUpdater(chainRegistry, chainIdHolder, storageCache) + + @Provides + @FeatureScope + fun provideTotalInsuranceUpdater( + sharedState: GovernanceSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = TotalIssuanceUpdater( + sharedState, + storageCache, + chainRegistry + ) + + @Provides + @FeatureScope + fun provideInactiveInsuranceUpdater( + sharedState: GovernanceSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = InactiveIssuanceUpdater( + sharedState, + storageCache, + chainRegistry + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/deeplink/DeepLinkModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/deeplink/DeepLinkModule.kt new file mode 100644 index 0000000..b27e08a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/deeplink/DeepLinkModule.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.deeplink + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_governance_api.data.MutableGovernanceState +import io.novafoundation.nova.feature_governance_api.di.deeplinks.GovernanceDeepLinks +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.deeplink.ReferendumDeepLinkHandler +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.deeplink.RealReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideDeepLinkConfigurator( + linkBuilderFactory: LinkBuilderFactory + ): ReferendumDetailsDeepLinkConfigurator { + return RealReferendumDetailsDeepLinkConfigurator(linkBuilderFactory) + } + + @Provides + @FeatureScope + fun provideReferendumDeepLinkHandler( + router: GovernanceRouter, + chainRegistry: ChainRegistry, + mutableGovernanceState: MutableGovernanceState, + automaticInteractionGate: AutomaticInteractionGate + ): ReferendumDeepLinkHandler { + return ReferendumDeepLinkHandler( + router, + chainRegistry, + mutableGovernanceState, + automaticInteractionGate + ) + } + + @Provides + @FeatureScope + fun provideDeepLinks(referendum: ReferendumDeepLinkHandler): GovernanceDeepLinks { + return GovernanceDeepLinks(listOf(referendum)) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/DelegateModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/DelegateModule.kt new file mode 100644 index 0000000..def2915 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/DelegateModule.kt @@ -0,0 +1,180 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.screens + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.DelegateDelegatorsInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.DelegateDetailsInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabelUseCase +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.DelegateListInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.data.repository.RealRemoveVotesSuggestionRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.RemoveVotesSuggestionRepository +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.RecentVotesTimePointProvider +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.repository.DelegateCommonRepository +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.repository.RealDelegateCommonRepository +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.delegators.RealDelegateDelegatorsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.details.RealDelegateDetailsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.label.RealDelegateLabelUseCase +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.list.RealDelegateListInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.RealNewDelegationChooseAmountInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.ChooseDelegationAmountValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.chooseDelegationAmount +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseTrack.RealChooseTrackInteractor +import io.novafoundation.nova.feature_governance_impl.domain.track.category.TrackCategorizer +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.RealDelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.DelegatesSharedComputation +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +@Module +class DelegateModule { + + @Provides + @FeatureScope + fun provideRecentVotesTimePointProvider( + chainStateRepository: ChainStateRepository + ): RecentVotesTimePointProvider { + return RecentVotesTimePointProvider(chainStateRepository) + } + + @Provides + @FeatureScope + fun provideDelegateCommonRepository( + governanceSourceRegistry: GovernanceSourceRegistry, + accountRepository: AccountRepository, + recentVotesTimePointProvider: RecentVotesTimePointProvider + ): DelegateCommonRepository = RealDelegateCommonRepository( + governanceSourceRegistry = governanceSourceRegistry, + accountRepository = accountRepository, + recentVotesTimePointProvider = recentVotesTimePointProvider + ) + + @Provides + @FeatureScope + fun provideDelegateListInteractor( + bannerVisibilityRepository: BannerVisibilityRepository, + delegatesSharedComputation: DelegatesSharedComputation + ): DelegateListInteractor = RealDelegateListInteractor( + bannerVisibilityRepository = bannerVisibilityRepository, + delegatesSharedComputation = delegatesSharedComputation + ) + + @Provides + @FeatureScope + fun provideDelegateDetailsInteractor( + governanceSourceRegistry: GovernanceSourceRegistry, + chainStateRepository: ChainStateRepository, + identityRepository: OnChainIdentityRepository, + governanceSharedState: GovernanceSharedState, + accountRepository: AccountRepository, + recentVotesTimePointProvider: RecentVotesTimePointProvider + ): DelegateDetailsInteractor = RealDelegateDetailsInteractor( + governanceSourceRegistry = governanceSourceRegistry, + chainStateRepository = chainStateRepository, + identityRepository = identityRepository, + governanceSharedState = governanceSharedState, + accountRepository = accountRepository, + recentVotesTimePointProvider = recentVotesTimePointProvider + ) + + @Provides + @FeatureScope + fun provideNewDelegationChooseTrackInteractor( + governanceSharedState: GovernanceSharedState, + governanceSourceRegistry: GovernanceSourceRegistry, + chainStateRepository: ChainStateRepository, + accountRepository: AccountRepository, + trackCategorizer: TrackCategorizer, + removeVotesSuggestionRepository: RemoveVotesSuggestionRepository, + chainRegistry: ChainRegistry + ): ChooseTrackInteractor = RealChooseTrackInteractor( + governanceSharedState = governanceSharedState, + governanceSourceRegistry = governanceSourceRegistry, + chainStateRepository = chainStateRepository, + accountRepository = accountRepository, + trackCategorizer = trackCategorizer, + removeVotesSuggestionRepository = removeVotesSuggestionRepository, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideDelegateDelegatorsInteractor( + governanceSharedState: GovernanceSharedState, + governanceSourceRegistry: GovernanceSourceRegistry, + identityRepository: OnChainIdentityRepository, + ): DelegateDelegatorsInteractor = RealDelegateDelegatorsInteractor( + governanceSharedState = governanceSharedState, + governanceSourceRegistry = governanceSourceRegistry, + identityRepository = identityRepository + ) + + @Provides + @FeatureScope + fun provideDelegateMappers( + resourceManager: ResourceManager, + addressIconGenerator: AddressIconGenerator, + trackFormatter: TrackFormatter, + votersFormatter: VotersFormatter + ): DelegateMappers = RealDelegateMappers(resourceManager, addressIconGenerator, trackFormatter, votersFormatter) + + @Provides + @FeatureScope + fun provideNewDelegationChooseAmountInteractor( + governanceSourceRegistry: GovernanceSourceRegistry, + chainStateRepository: ChainStateRepository, + selectedChainState: GovernanceSharedState, + extrinsicService: ExtrinsicService, + locksRepository: BalanceLocksRepository, + computationalCache: ComputationalCache, + accountRepository: AccountRepository, + ): NewDelegationChooseAmountInteractor { + return RealNewDelegationChooseAmountInteractor( + governanceSourceRegistry = governanceSourceRegistry, + chainStateRepository = chainStateRepository, + selectedChainState = selectedChainState, + extrinsicService = extrinsicService, + locksRepository = locksRepository, + computationalCache = computationalCache, + accountRepository = accountRepository + ) + } + + @Provides + @FeatureScope + fun provideNewDelegationChooseAmountValidationSystem( + governanceSharedState: GovernanceSharedState, + accountRepository: AccountRepository + ): ChooseDelegationAmountValidationSystem { + return ValidationSystem.chooseDelegationAmount(governanceSharedState, accountRepository) + } + + @Provides + @FeatureScope + fun provideDelegateLabelUseCase( + governanceSharedState: GovernanceSharedState, + governanceSourceRegistry: GovernanceSourceRegistry, + identityRepository: OnChainIdentityRepository, + ): DelegateLabelUseCase = RealDelegateLabelUseCase(governanceSharedState, governanceSourceRegistry, identityRepository) + + @Provides + @FeatureScope + fun provideRemoveVotesSuggestionRepository(preferences: Preferences): RemoveVotesSuggestionRepository = RealRemoveVotesSuggestionRepository(preferences) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumDetailsModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumDetailsModule.kt new file mode 100644 index 0000000..b54d14a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumDetailsModule.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.screens + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_governance_api.data.repository.TreasuryRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetailsInteractor +import io.novafoundation.nova.feature_governance_impl.data.preimage.PreImageSizer +import io.novafoundation.nova.feature_governance_impl.domain.referendum.common.ReferendaConstructor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.OffChainReferendumVotingSharedComputation +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.RealReferendumDetailsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.RealReferendumPreImageParser +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumCallAdapter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumPreImageParser +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.batch.BatchAdapter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.treasury.TreasuryApproveProposalAdapter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.treasury.TreasurySpendAdapter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.treasury.TreasurySpendLocalAdapter +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverterFactory +import io.novafoundation.nova.runtime.di.ExtrinsicSerialization +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +@Module +class ReferendumDetailsModule { + + @Provides + @FeatureScope + @IntoSet + fun provideTreasuryApproveParser( + treasuryRepository: TreasuryRepository + ): ReferendumCallAdapter = TreasuryApproveProposalAdapter(treasuryRepository) + + @Provides + @FeatureScope + @IntoSet + fun provideTreasurySpendParser( + chainLocationConverter: ChainMultiLocationConverterFactory, + assetLocationConverter: MultiLocationConverterFactory + ): ReferendumCallAdapter = TreasurySpendAdapter(chainLocationConverter, assetLocationConverter) + + @Provides + @FeatureScope + @IntoSet + fun provideTreasurySpendLocalParser(): ReferendumCallAdapter = TreasurySpendLocalAdapter() + + @Provides + @FeatureScope + @IntoSet + fun provideBatchAdapter(): ReferendumCallAdapter = BatchAdapter() + + @Provides + @FeatureScope + fun providePreImageParser( + callAdapters: Set<@JvmSuppressWildcards ReferendumCallAdapter> + ): ReferendumPreImageParser { + return RealReferendumPreImageParser(callAdapters) + } + + @Provides + @FeatureScope + fun provideReferendumDetailsInteractor( + preImageParser: ReferendumPreImageParser, + governanceSourceRegistry: GovernanceSourceRegistry, + chainStateRepository: ChainStateRepository, + referendaConstructor: ReferendaConstructor, + preImageSizer: PreImageSizer, + @ExtrinsicSerialization callFormatter: Gson, + identityRepository: OnChainIdentityRepository, + offChainReferendumVotingSharedComputation: OffChainReferendumVotingSharedComputation + ): ReferendumDetailsInteractor = RealReferendumDetailsInteractor( + preImageParser = preImageParser, + governanceSourceRegistry = governanceSourceRegistry, + chainStateRepository = chainStateRepository, + referendaConstructor = referendaConstructor, + preImageSizer = preImageSizer, + callFormatter = callFormatter, + identityRepository = identityRepository, + offChainReferendumVotingSharedComputation = offChainReferendumVotingSharedComputation + ) + + @Provides + @FeatureScope + fun provideOffChainReferendumVotingSharedComputation( + computationalCache: ComputationalCache, + governanceSourceRegistry: GovernanceSourceRegistry, + ) = OffChainReferendumVotingSharedComputation(computationalCache, governanceSourceRegistry) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumListModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumListModule.kt new file mode 100644 index 0000000..2a171dd --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumListModule.kt @@ -0,0 +1,102 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.screens + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.data.repository.filters.PreferencesReferendaFiltersRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.filters.ReferendaFiltersRepository +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.RecentVotesTimePointProvider +import io.novafoundation.nova.feature_governance_impl.domain.filters.RealReferendaFiltersInteractor +import io.novafoundation.nova.feature_governance_impl.domain.filters.ReferendaFiltersInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.common.ReferendaConstructor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.RealReferendaListInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.ReferendaSharedComputation +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.filtering.RealReferendaFilteringProvider +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.filtering.ReferendaFilteringProvider +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.repository.RealReferendaCommonRepository +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.repository.ReferendaCommonRepository +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.sorting.RealReferendaSortingProvider +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.sorting.ReferendaSortingProvider +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +@Module +class ReferendumListModule { + + @Provides + @FeatureScope + fun provideReferendaFiltersRepository(): ReferendaFiltersRepository { + return PreferencesReferendaFiltersRepository() + } + + @Provides + @FeatureScope + fun provideReferendaFiltersInteractor( + referendaFiltersRepository: ReferendaFiltersRepository + ): ReferendaFiltersInteractor { + return RealReferendaFiltersInteractor(referendaFiltersRepository) + } + + @Provides + @FeatureScope + fun provideReferendaSortingProvider(): ReferendaSortingProvider { + return RealReferendaSortingProvider() + } + + @Provides + @FeatureScope + fun provideReferendaFilteringProvider(): ReferendaFilteringProvider { + return RealReferendaFilteringProvider() + } + + @Provides + @FeatureScope + fun provideReferendaCommonRepository( + chainStateRepository: ChainStateRepository, + governanceSourceRegistry: GovernanceSourceRegistry, + referendaConstructor: ReferendaConstructor, + referendaSortingProvider: ReferendaSortingProvider, + identityRepository: OnChainIdentityRepository, + recentVotesTimePointProvider: RecentVotesTimePointProvider + ): ReferendaCommonRepository { + return RealReferendaCommonRepository( + chainStateRepository = chainStateRepository, + governanceSourceRegistry = governanceSourceRegistry, + referendaConstructor = referendaConstructor, + referendaSortingProvider = referendaSortingProvider, + identityRepository = identityRepository, + recentVotesTimePointProvider = recentVotesTimePointProvider + ) + } + + @Provides + @FeatureScope + fun provideReferendaSharedComputation( + computationalCache: ComputationalCache, + referendaCommonRepository: ReferendaCommonRepository + ): ReferendaSharedComputation { + return ReferendaSharedComputation(computationalCache, referendaCommonRepository) + } + + @Provides + @FeatureScope + fun provideReferendaListInteractor( + referendaCommonRepository: ReferendaCommonRepository, + governanceSharedState: GovernanceSharedState, + referendaSharedComputation: ReferendaSharedComputation, + governanceSourceRegistry: GovernanceSourceRegistry, + referendaSortingProvider: ReferendaSortingProvider, + referendaFilteringProvider: ReferendaFilteringProvider + ): ReferendaListInteractor = RealReferendaListInteractor( + referendaCommonRepository = referendaCommonRepository, + governanceSharedState = governanceSharedState, + referendaSharedComputation = referendaSharedComputation, + governanceSourceRegistry = governanceSourceRegistry, + referendaSortingProvider = referendaSortingProvider, + referendaFilteringProvider = referendaFilteringProvider + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumUnlockModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumUnlockModule.kt new file mode 100644 index 0000000..51541d9 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumUnlockModule.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.screens + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceUnlockInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.RealGovernanceUnlockInteractor +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +@Module +class ReferendumUnlockModule { + + @Provides + @FeatureScope + fun provideGovernanceLocksOverviewInteractor( + selectedAssetState: GovernanceSharedState, + governanceSourceRegistry: GovernanceSourceRegistry, + chainStateRepository: ChainStateRepository, + computationalCache: ComputationalCache, + accountRepository: AccountRepository, + balanceLocksRepository: BalanceLocksRepository, + extrinsicService: ExtrinsicService, + ): GovernanceUnlockInteractor { + return RealGovernanceUnlockInteractor( + selectedAssetState = selectedAssetState, + governanceSourceRegistry = governanceSourceRegistry, + chainStateRepository = chainStateRepository, + computationalCache = computationalCache, + accountRepository = accountRepository, + balanceLocksRepository = balanceLocksRepository, + extrinsicService = extrinsicService + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumVoteModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumVoteModule.kt new file mode 100644 index 0000000..ac16a30 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumVoteModule.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.screens + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.RealVoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.voteReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.RealLocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.hints.ReferendumVoteHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +@Module +class ReferendumVoteModule { + + @Provides + @FeatureScope + fun provideReferendumVoteInteractor( + governanceSourceRegistry: GovernanceSourceRegistry, + chainStateRepository: ChainStateRepository, + selectedChainState: GovernanceSharedState, + accountRepository: AccountRepository, + locksRepository: BalanceLocksRepository, + extrinsicService: ExtrinsicService, + computationalCache: ComputationalCache + ): VoteReferendumInteractor { + return RealVoteReferendumInteractor( + governanceSourceRegistry = governanceSourceRegistry, + chainStateRepository = chainStateRepository, + selectedChainState = selectedChainState, + accountRepository = accountRepository, + extrinsicService = extrinsicService, + locksRepository = locksRepository, + computationalCache = computationalCache + ) + } + + @Provides + @FeatureScope + fun provideHintsMixinFactory( + resHintsMixinFactory: ResourcesHintsMixinFactory + ): ReferendumVoteHintsMixinFactory = ReferendumVoteHintsMixinFactory(resHintsMixinFactory) + + @Provides + @FeatureScope + fun provideLocksChangeFormatter( + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ): LocksChangeFormatter = RealLocksChangeFormatter(resourceManager, amountFormatter) + + @Provides + @FeatureScope + fun provideValidationSystem( + governanceSourceRegistry: GovernanceSourceRegistry, + governanceSharedState: GovernanceSharedState, + ): VoteReferendumValidationSystem = ValidationSystem.voteReferendumValidationSystem(governanceSourceRegistry, governanceSharedState) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumVotersModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumVotersModule.kt new file mode 100644 index 0000000..9e3c33a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/ReferendumVotersModule.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.screens + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.OnChainIdentity +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.ReferendumVotersInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.voters.RealReferendumVotersInteractor + +@Module +class ReferendumVotersModule { + + @Provides + @FeatureScope + fun provideReferendaVotersInteractor( + governanceSourceRegistry: GovernanceSourceRegistry, + @OnChainIdentity identityProvider: IdentityProvider, + governanceSharedState: GovernanceSharedState, + ): ReferendumVotersInteractor = RealReferendumVotersInteractor( + governanceSourceRegistry = governanceSourceRegistry, + identityProvider = identityProvider, + governanceSharedState = governanceSharedState, + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/TinderGovModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/TinderGovModule.kt new file mode 100644 index 0000000..70411a6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/screens/TinderGovModule.kt @@ -0,0 +1,135 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.screens + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.TinderGovDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_impl.domain.summary.ReferendaSummaryInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovBasketInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2.RealReferendumSummaryDataSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2.ReferendumSummaryApi +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2.ReferendumSummaryDataSource +import io.novafoundation.nova.feature_governance_impl.data.repository.tindergov.RealTinderGovBasketRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.tindergov.RealTinderGovVotingPowerRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.tindergov.TinderGovBasketRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.tindergov.TinderGovVotingPowerRepository +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.RealReferendumDetailsRepository +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.ReferendumDetailsRepository +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumPreImageParser +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.ReferendaSharedComputation +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.filtering.ReferendaFilteringProvider +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.RealTinderGovBasketInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.RealTinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.domain.summary.RealReferendaSummaryInteractor +import io.novafoundation.nova.feature_governance_impl.domain.summary.ReferendaSummarySharedComputation +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase + +@Module +class TinderGovModule { + + @Provides + @FeatureScope + fun provideReferendumSummaryApi(apiCreator: NetworkApiCreator): ReferendumSummaryApi { + return apiCreator.create(ReferendumSummaryApi::class.java) + } + + @Provides + @FeatureScope + fun provideSummaryDataSource( + referendumSummaryApi: ReferendumSummaryApi + ): ReferendumSummaryDataSource { + return RealReferendumSummaryDataSource(referendumSummaryApi) + } + + @Provides + @FeatureScope + fun provideTinderGovBasketRepository(dao: TinderGovDao): TinderGovBasketRepository { + return RealTinderGovBasketRepository(dao) + } + + @Provides + @FeatureScope + fun provideTinderGovVotingPowerRepository( + tinderGovDao: TinderGovDao + ): TinderGovVotingPowerRepository { + return RealTinderGovVotingPowerRepository(tinderGovDao) + } + + @Provides + @FeatureScope + fun provideReferendumDetailsRepository( + dataSource: ReferendumSummaryDataSource + ): ReferendumDetailsRepository { + return RealReferendumDetailsRepository(dataSource) + } + + @Provides + @FeatureScope + fun provideTinderGovInteractor( + governanceSharedState: GovernanceSharedState, + referendaSharedComputation: ReferendaSharedComputation, + accountRepository: AccountRepository, + preImageParser: ReferendumPreImageParser, + tinderGovVotingPowerRepository: TinderGovVotingPowerRepository, + referendaFilteringProvider: ReferendaFilteringProvider, + assetUseCase: AssetUseCase, + governanceSourceRegistry: GovernanceSourceRegistry, + ): TinderGovInteractor = RealTinderGovInteractor( + governanceSharedState, + referendaSharedComputation, + accountRepository, + preImageParser, + tinderGovVotingPowerRepository, + referendaFilteringProvider, + governanceSourceRegistry, + assetUseCase, + ) + + @Provides + @FeatureScope + fun provideReferendaSummarySharedComputation( + computationalCache: ComputationalCache, + referendumDetailsRepository: ReferendumDetailsRepository, + accountRepository: AccountRepository + ) = ReferendaSummarySharedComputation( + computationalCache, + referendumDetailsRepository, + accountRepository + ) + + @Provides + @FeatureScope + fun provideReferendaSummaryInteractor( + governanceSharedState: GovernanceSharedState, + referendaSummarySharedComputation: ReferendaSummarySharedComputation + ): ReferendaSummaryInteractor = RealReferendaSummaryInteractor( + governanceSharedState, + referendaSummarySharedComputation + ) + + @Provides + @FeatureScope + fun provideTinderGovBasketInteractor( + governanceSharedState: GovernanceSharedState, + accountRepository: AccountRepository, + tinderGovBasketRepository: TinderGovBasketRepository, + tinderGovVotingPowerRepository: TinderGovVotingPowerRepository, + assetUseCase: AssetUseCase, + tinderGovInteractor: TinderGovInteractor, + governanceSourceRegistry: GovernanceSourceRegistry, + ): TinderGovBasketInteractor = RealTinderGovBasketInteractor( + governanceSharedState = governanceSharedState, + accountRepository = accountRepository, + tinderGovBasketRepository = tinderGovBasketRepository, + tinderGovVotingPowerRepository = tinderGovVotingPowerRepository, + assetUseCase = assetUseCase, + tinderGovInteractor = tinderGovInteractor, + governanceSourceRegistry = governanceSourceRegistry + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v1/GovernanceV1Module.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v1/GovernanceV1Module.kt new file mode 100644 index 0000000..02f7d37 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v1/GovernanceV1Module.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.v1 + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.PolkassemblyV1ReferendaDataSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1.SubSquareV1ReferendaDataSource +import io.novafoundation.nova.feature_governance_impl.data.repository.MultiSourceOffChainReferendaInfoRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.UnsupportedDelegationsRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v1.GovV1ConvictionVotingRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v1.GovV1DAppsRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v1.GovV1OnChainReferendaRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v1.GovV1PreImageRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v2.Gov2PreImageRepository +import io.novafoundation.nova.feature_governance_impl.data.source.StaticGovernanceSource +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named +import javax.inject.Qualifier + +@Qualifier +annotation class GovernanceV1 + +@Module(includes = [PolkassemblyV1Module::class, SubSquareV1Module::class]) +class GovernanceV1Module { + + @Provides + @FeatureScope + fun provideOnChainReferendaRepository( + @Named(REMOTE_STORAGE_SOURCE) storageSource: StorageDataSource, + chainRegistry: ChainRegistry, + totalIssuanceRepository: TotalIssuanceRepository, + ) = GovV1OnChainReferendaRepository(storageSource, chainRegistry, totalIssuanceRepository) + + @Provides + @FeatureScope + fun provideConvictionVotingRepository( + @Named(REMOTE_STORAGE_SOURCE) storageSource: StorageDataSource, + chainRegistry: ChainRegistry, + balanceLocksRepository: BalanceLocksRepository + ) = GovV1ConvictionVotingRepository(storageSource, chainRegistry, balanceLocksRepository) + + @Provides + @GovernanceV1 + @FeatureScope + fun provideOffChainInfoRepository( + polkassembly: PolkassemblyV1ReferendaDataSource, + subSquare: SubSquareV1ReferendaDataSource, + ) = MultiSourceOffChainReferendaInfoRepository( + subSquareReferendaDataSource = subSquare, + polkassemblyReferendaDataSource = polkassembly + ) + + @Provides + @FeatureScope + fun providePreImageRepository( + @Named(REMOTE_STORAGE_SOURCE) storageSource: StorageDataSource, + v2Delegate: Gov2PreImageRepository, + ) = GovV1PreImageRepository(storageSource, v2Delegate) + + @Provides + @FeatureScope + fun provideDappsRepository(governanceDAppsDao: GovernanceDAppsDao): GovV1DAppsRepository { + return GovV1DAppsRepository(governanceDAppsDao) + } + + @Provides + @FeatureScope + @GovernanceV1 + fun provideGovernanceSource( + referendaRepository: GovV1OnChainReferendaRepository, + convictionVotingRepository: GovV1ConvictionVotingRepository, + @GovernanceV1 offChainInfoRepository: MultiSourceOffChainReferendaInfoRepository, + preImageRepository: GovV1PreImageRepository, + governanceV1DAppsRepository: GovV1DAppsRepository + ): GovernanceSource = StaticGovernanceSource( + referenda = referendaRepository, + convictionVoting = convictionVotingRepository, + offChainInfo = offChainInfoRepository, + preImageRepository = preImageRepository, + dappsRepository = governanceV1DAppsRepository, + delegationsRepository = UnsupportedDelegationsRepository() + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v1/PolkassemblyV1Module.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v1/PolkassemblyV1Module.kt new file mode 100644 index 0000000..a67f50f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v1/PolkassemblyV1Module.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.v1 + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.PolkassemblyV1Api +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v1.PolkassemblyV1ReferendaDataSource + +@Module +class PolkassemblyV1Module { + + @Provides + @FeatureScope + fun provideApi(apiCreator: NetworkApiCreator): PolkassemblyV1Api = apiCreator.create(PolkassemblyV1Api::class.java) + + @Provides + @FeatureScope + fun provideDataSource(api: PolkassemblyV1Api): PolkassemblyV1ReferendaDataSource = PolkassemblyV1ReferendaDataSource(api) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v1/SubSquareV1Module.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v1/SubSquareV1Module.kt new file mode 100644 index 0000000..723c641 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v1/SubSquareV1Module.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.v1 + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1.SubSquareV1Api +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v1.SubSquareV1ReferendaDataSource + +@Module +class SubSquareV1Module { + + @Provides + @FeatureScope + fun provideApi(apiCreator: NetworkApiCreator): SubSquareV1Api = apiCreator.create(SubSquareV1Api::class.java) + + @Provides + @FeatureScope + fun provideDataSource(api: SubSquareV1Api): SubSquareV1ReferendaDataSource = SubSquareV1ReferendaDataSource(api) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v2/GovernanceV2Module.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v2/GovernanceV2Module.kt new file mode 100644 index 0000000..5b2060a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v2/GovernanceV2Module.kt @@ -0,0 +1,111 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.v2 + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.GovernanceDAppsDao +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.PolkassemblyV2ReferendaDataSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.SubSquareV2ReferendaDataSource +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.metadata.DelegateMetadataApi +import io.novafoundation.nova.feature_governance_impl.data.offchain.delegation.v2.stats.DelegationsSubqueryApi +import io.novafoundation.nova.feature_governance_impl.data.preimage.PreImageSizer +import io.novafoundation.nova.feature_governance_impl.data.repository.MultiSourceOffChainReferendaInfoRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v2.Gov2DelegationsRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v2.Gov2PreImageRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v2.GovV2ConvictionVotingRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v2.GovV2DAppsRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.v2.GovV2OnChainReferendaRepository +import io.novafoundation.nova.feature_governance_impl.data.source.StaticGovernanceSource +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named +import javax.inject.Qualifier + +@Qualifier +annotation class GovernanceV2 + +@Module(includes = [PolkassemblyV2Module::class, SubSquareV2Module::class]) +class GovernanceV2Module { + + @Provides + @FeatureScope + fun provideOnChainReferendaRepository( + @Named(REMOTE_STORAGE_SOURCE) storageSource: StorageDataSource, + chainRegistry: ChainRegistry, + totalIssuanceRepository: TotalIssuanceRepository + ) = GovV2OnChainReferendaRepository(storageSource, chainRegistry, totalIssuanceRepository) + + @Provides + @FeatureScope + fun provideConvictionVotingRepository( + @Named(REMOTE_STORAGE_SOURCE) storageSource: StorageDataSource, + chainRegistry: ChainRegistry, + delegateSubqueryApi: DelegationsSubqueryApi + ) = GovV2ConvictionVotingRepository(storageSource, chainRegistry, delegateSubqueryApi) + + @Provides + @GovernanceV2 + @FeatureScope + fun provideOffChainInfoRepository( + polkassemblyV2ReferendaDataSource: PolkassemblyV2ReferendaDataSource, + subSquareV2ReferendaDataSource: SubSquareV2ReferendaDataSource + ) = MultiSourceOffChainReferendaInfoRepository( + subSquareReferendaDataSource = subSquareV2ReferendaDataSource, + polkassemblyReferendaDataSource = polkassemblyV2ReferendaDataSource + ) + + @Provides + @FeatureScope + fun providePreImageRepository( + @Named(REMOTE_STORAGE_SOURCE) storageSource: StorageDataSource, + preImageSizer: PreImageSizer, + ) = Gov2PreImageRepository(storageSource, preImageSizer) + + @Provides + @FeatureScope + fun provideDappsRepository(governanceDAppsDao: GovernanceDAppsDao): GovV2DAppsRepository { + return GovV2DAppsRepository(governanceDAppsDao) + } + + @Provides + @FeatureScope + fun provideDelegationStatsApi(apiCreator: NetworkApiCreator): DelegationsSubqueryApi { + return apiCreator.create(DelegationsSubqueryApi::class.java) + } + + @Provides + @FeatureScope + fun provideDelegateMetadataApi(apiCreator: NetworkApiCreator): DelegateMetadataApi { + return apiCreator.create(DelegateMetadataApi::class.java, DelegateMetadataApi.BASE_URL) + } + + @Provides + @FeatureScope + fun provideDelegationsRepository( + delegationStatsApi: DelegationsSubqueryApi, + delegateMetadataApi: DelegateMetadataApi + ) = Gov2DelegationsRepository(delegationStatsApi, delegateMetadataApi) + + @Provides + @FeatureScope + @GovernanceV2 + fun provideGovernanceSource( + referendaRepository: GovV2OnChainReferendaRepository, + convictionVotingRepository: GovV2ConvictionVotingRepository, + @GovernanceV2 offChainInfoRepository: MultiSourceOffChainReferendaInfoRepository, + preImageRepository: Gov2PreImageRepository, + governanceV2DappsRepository: GovV2DAppsRepository, + delegationsRepository: Gov2DelegationsRepository, + ): GovernanceSource = StaticGovernanceSource( + referenda = referendaRepository, + convictionVoting = convictionVotingRepository, + offChainInfo = offChainInfoRepository, + preImageRepository = preImageRepository, + dappsRepository = governanceV2DappsRepository, + delegationsRepository = delegationsRepository, + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v2/PolkassemblyV2Module.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v2/PolkassemblyV2Module.kt new file mode 100644 index 0000000..0ce7107 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v2/PolkassemblyV2Module.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.v2 + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.PolkassemblyV2Api +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.polkassembly.v2.PolkassemblyV2ReferendaDataSource + +@Module +class PolkassemblyV2Module { + + @Provides + @FeatureScope + fun provideApi(apiCreator: NetworkApiCreator): PolkassemblyV2Api = apiCreator.create(PolkassemblyV2Api::class.java) + + @Provides + @FeatureScope + fun provideDataSource(api: PolkassemblyV2Api): PolkassemblyV2ReferendaDataSource = PolkassemblyV2ReferendaDataSource(api) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v2/SubSquareV2Module.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v2/SubSquareV2Module.kt new file mode 100644 index 0000000..69c7767 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/di/modules/v2/SubSquareV2Module.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_governance_impl.di.modules.v2 + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.SubSquareV2Api +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.subsquare.v2.SubSquareV2ReferendaDataSource + +@Module +class SubSquareV2Module { + + @Provides + @FeatureScope + fun provideApi(apiCreator: NetworkApiCreator): SubSquareV2Api = apiCreator.create(SubSquareV2Api::class.java) + + @Provides + @FeatureScope + fun provideDataSource(api: SubSquareV2Api): SubSquareV2ReferendaDataSource = SubSquareV2ReferendaDataSource(api) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/dapp/GovernanceDAppsInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/dapp/GovernanceDAppsInteractor.kt new file mode 100644 index 0000000..8ac7eb0 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/dapp/GovernanceDAppsInteractor.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_impl.domain.dapp + +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_impl.data.dapps.GovernanceDAppsSyncService +import kotlinx.coroutines.flow.Flow + +class GovernanceDAppsInteractor( + private val governanceDAppsSyncService: GovernanceDAppsSyncService, + private val governanceSourceRegistry: GovernanceSourceRegistry, +) { + + fun syncGovernanceDapps(): Flow<*> = flowOf { + governanceDAppsSyncService.syncDapps() + } + + fun observeReferendumDapps(referendumId: ReferendumId, option: SupportedGovernanceOption) = flowOfAll { + val govSource = governanceSourceRegistry.sourceFor(option) + govSource.dappsRepository.observeReferendumDApps(option.assetWithChain.chain.id, referendumId) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/Consts.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/Consts.kt new file mode 100644 index 0000000..7dc8125 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/Consts.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common + +import kotlin.time.Duration.Companion.days + +val RECENT_VOTES_PERIOD = 30.days diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/Mappers.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/Mappers.kt new file mode 100644 index 0000000..c10bee3 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/Mappers.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateMetadata +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateStats +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.DelegateAccountType +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegatePreview +import io.novafoundation.nova.feature_governance_api.domain.track.Track + +fun mapAccountTypeToDomain(isOrganization: Boolean): DelegateAccountType { + return if (isOrganization) DelegateAccountType.ORGANIZATION else DelegateAccountType.INDIVIDUAL +} + +fun mapDelegateStatsToPreviews( + delegateStats: List, + delegateMetadata: AccountIdKeyMap, + identities: AccountIdKeyMap, + userDelegations: AccountIdKeyMap>>, +): List { + val delegateStatsById = delegateStats.associateBy { it.accountId.intoKey() } + val allIds = delegateStatsById.keys + delegateMetadata.keys + userDelegations.keys + + return allIds.map { accountId -> + val stats = delegateStatsById[accountId] + val metadata = delegateMetadata[accountId] + val identity = identities[accountId] + + DelegatePreview( + accountId = accountId.value, + stats = stats?.let { mapStatsToDomain(it) }, + metadata = mapMetadataToDomain(metadata), + onChainIdentity = identity, + userDelegations = userDelegations[accountId]?.toMap().orEmpty() + ) + } +} + +private fun mapStatsToDomain(stats: DelegateStats): DelegatePreview.Stats { + return DelegatePreview.Stats( + delegatedVotes = stats.delegatedVotes, + delegationsCount = stats.delegationsCount, + recentVotes = stats.recentVotes + ) +} + +private fun mapMetadataToDomain(metadata: DelegateMetadata?): DelegatePreview.Metadata? { + if (metadata == null) return null + + return DelegatePreview.Metadata( + shortDescription = metadata.shortDescription, + accountType = mapAccountTypeToDomain(metadata.isOrganization), + iconUrl = metadata.profileImageUrl, + name = metadata.name + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/RecentVotesTimePointProvider.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/RecentVotesTimePointProvider.kt new file mode 100644 index 0000000..655bc8d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/RecentVotesTimePointProvider.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common + +import io.novafoundation.nova.feature_governance_api.data.repository.common.RecentVotesDateThreshold +import io.novafoundation.nova.runtime.ext.hasTimelineChain +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.blockDurationEstimator +import io.novafoundation.nova.runtime.util.blockInPast + +class RecentVotesTimePointProvider( + private val chainStateRepository: ChainStateRepository +) { + + suspend fun getTimePointThresholdForChain(chain: Chain): RecentVotesDateThreshold { + return if (chain.hasTimelineChain()) { + val timestampMs = System.currentTimeMillis() - RECENT_VOTES_PERIOD.inWholeMilliseconds + RecentVotesDateThreshold.Timestamp(timestampMs) + } else { + val blockDurationEstimator = chainStateRepository.blockDurationEstimator(chain.timelineChainIdOrSelf()) + val recentVotesBlockThreshold = blockDurationEstimator.blockInPast(RECENT_VOTES_PERIOD) + RecentVotesDateThreshold.BlockNumber(recentVotesBlockThreshold) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/repository/DelegateCommonRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/repository/DelegateCommonRepository.kt new file mode 100644 index 0000000..e226e7a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/common/repository/DelegateCommonRepository.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.repository + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.getIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateMetadata +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateStats +import io.novafoundation.nova.feature_governance_api.data.repository.getDelegatesMetadataOrEmpty +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.RecentVotesTimePointProvider +import io.novafoundation.nova.feature_governance_impl.domain.track.mapTrackInfoToTrack +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface DelegateCommonRepository { + suspend fun getDelegatesStats(governanceOption: SupportedGovernanceOption, accountIds: List? = null): List + + suspend fun getMetadata(governanceOption: SupportedGovernanceOption): AccountIdKeyMap + + suspend fun getTracks(governanceOption: SupportedGovernanceOption): Map + + suspend fun getUserDelegationsOrEmpty( + governanceOption: SupportedGovernanceOption, + tracks: Map + ): AccountIdKeyMap>> +} + +class RealDelegateCommonRepository( + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val accountRepository: AccountRepository, + private val recentVotesTimePointProvider: RecentVotesTimePointProvider +) : DelegateCommonRepository { + + override suspend fun getDelegatesStats( + governanceOption: SupportedGovernanceOption, + accountIds: List? + ): List { + val chain = governanceOption.assetWithChain.chain + val delegationsRepository = governanceSourceRegistry.sourceFor(governanceOption).delegationsRepository + val timePointThreshold = recentVotesTimePointProvider.getTimePointThresholdForChain(chain) + + return if (accountIds == null) { + delegationsRepository.getDelegatesStats(timePointThreshold, chain) + } else { + delegationsRepository.getDelegatesStatsByAccountIds(timePointThreshold, accountIds, chain) + } + } + + override suspend fun getMetadata(governanceOption: SupportedGovernanceOption): AccountIdKeyMap { + val chain = governanceOption.assetWithChain.chain + val delegationsRepository = governanceSourceRegistry.sourceFor(governanceOption) + .delegationsRepository + return delegationsRepository.getDelegatesMetadataOrEmpty(chain) + .associateBy { AccountIdKey(it.accountId) } + } + + override suspend fun getTracks(governanceOption: SupportedGovernanceOption): Map { + val chain = governanceOption.assetWithChain.chain + val referendaRepository = governanceSourceRegistry.sourceFor(governanceOption).referenda + return referendaRepository.getTracks(chain.id) + .map { mapTrackInfoToTrack(it) } + .associateBy { it.id } + } + + override suspend fun getUserDelegationsOrEmpty( + governanceOption: SupportedGovernanceOption, + tracks: Map + ): AccountIdKeyMap>> { + val chain = governanceOption.assetWithChain.chain + val convictionVotingRepository = governanceSourceRegistry.sourceFor(governanceOption).convictionVoting + + val accountId = accountRepository.getIdOfSelectedMetaAccountIn(chain) ?: return emptyMap() + + val delegatingDeferred = convictionVotingRepository.delegatingFor(accountId, chain.id) + + return delegatingDeferred + .mapKeys { tracks.getValue(it.key) } + .toList() + .groupBy { it.second.target.intoKey() } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/delegators/RealDelegateDelegatorsInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/delegators/RealDelegateDelegatorsInteractor.kt new file mode 100644 index 0000000..61249f9 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/delegators/RealDelegateDelegatorsInteractor.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.delegators + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Delegation +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.DelegateDelegatorsInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.model.Delegator +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +class RealDelegateDelegatorsInteractor( + private val identityRepository: OnChainIdentityRepository, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val governanceSharedState: GovernanceSharedState, +) : DelegateDelegatorsInteractor { + + override fun delegatorsFlow(delegateId: AccountId): Flow> { + return flowOf { delegatorsOf(delegateId) } + } + + private suspend fun delegatorsOf(delegateId: AccountId): List { + val governanceOption = governanceSharedState.selectedOption() + val chain = governanceOption.assetWithChain.chain + val chainAsset = governanceOption.assetWithChain.asset + + val delegationRepository = governanceSourceRegistry.sourceFor(governanceOption).delegationsRepository + val delegations = delegationRepository.getDelegationsTo(delegateId, chain) + + val delegatorIds = delegations.map(Delegation::delegator) + val identities = identityRepository.getIdentitiesFromIds(delegatorIds, chain.id) + + return delegations.groupBy { it.delegator.intoKey() } + .map { (accountIdKey, delegations) -> + Delegator( + accountId = accountIdKey.value, + identity = identities[accountIdKey]?.let(::Identity), + delegatorTrackDelegations = delegations.map { it.vote }, + chainAsset = chainAsset + ) + }.sortedByDescending { it.vote.totalVotes } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/details/RealDelegateDetailsInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/details/RealDelegateDetailsInteractor.kt new file mode 100644 index 0000000..c9002bb --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/details/RealDelegateDetailsInteractor.kt @@ -0,0 +1,134 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.details + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.getIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_account_api.domain.validation.hasChainAccount +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateDetailedStats +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateMetadata +import io.novafoundation.nova.feature_governance_api.data.repository.ConvictionVotingRepository +import io.novafoundation.nova.feature_governance_api.data.repository.getDelegateMetadataOrNull +import io.novafoundation.nova.feature_governance_api.data.repository.getTracksById +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.AddDelegationValidationFailure.NoChainAccountFailure +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.AddDelegationValidationSystem +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.DelegateDetails +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.DelegateDetailsInteractor +import io.novafoundation.nova.feature_governance_api.domain.track.matchWith +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.RecentVotesTimePointProvider +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.mapAccountTypeToDomain +import io.novafoundation.nova.feature_governance_impl.domain.track.mapTrackInfoToTrack +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RealDelegateDetailsInteractor( + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val chainStateRepository: ChainStateRepository, + private val identityRepository: OnChainIdentityRepository, + private val governanceSharedState: GovernanceSharedState, + private val accountRepository: AccountRepository, + private val recentVotesTimePointProvider: RecentVotesTimePointProvider +) : DelegateDetailsInteractor { + + override fun delegateDetailsFlow(delegateAccountId: AccountId): Flow { + return flowOfAll { + delegateDetailsFlowInternal(delegateAccountId) + } + } + + override fun validationSystemFor(): AddDelegationValidationSystem = ValidationSystem { + hasChainAccount( + chain = { it.chain }, + metaAccount = { it.metaAccount }, + error = ::NoChainAccountFailure + ) + } + + private suspend fun delegateDetailsFlowInternal( + delegateAccountId: AccountId, + ): Flow { + val governanceOption = governanceSharedState.selectedOption() + + val chain = governanceOption.assetWithChain.chain + val delegateAddress = chain.addressOf(delegateAccountId) + + val governanceSource = governanceSourceRegistry.sourceFor(governanceOption) + val delegationsRepository = governanceSource.delegationsRepository + + val userAccountId = accountRepository.getIdOfSelectedMetaAccountIn(chain) + + val tracks = governanceSource.referenda.getTracksById(chain.id) + .mapValues { (_, trackInfo) -> mapTrackInfoToTrack(trackInfo) } + + val (metadata, identity) = coroutineScope { + val delegateMetadatasDeferred = async { delegationsRepository.getDelegateMetadataOrNull(chain, delegateAccountId) } + val identity = async { identityRepository.getIdentityFromId(chain.id, delegateAccountId) } + + delegateMetadatasDeferred.await() to identity.await() + } + + return chainStateRepository.currentBlockNumberFlow(chain.timelineChainIdOrSelf()).map { + coroutineScope { + val recentVotesTimePointThreshold = recentVotesTimePointProvider.getTimePointThresholdForChain(chain) + + val delegatesStatsDeferred = async { + delegationsRepository.getDetailedDelegateStats(delegateAddress, recentVotesTimePointThreshold, chain) + } + val delegationsDeferred = async { + userAccountId?.let { governanceSource.convictionVoting.delegationsOf(it, delegateAccountId, chain.id) } + .orEmpty() + .matchWith(tracks) + } + + DelegateDetails( + accountId = delegateAccountId, + stats = delegatesStatsDeferred.await()?.let(::mapStatsToDomain), + metadata = metadata?.let(::mapMetadataToDomain), + onChainIdentity = identity, + userDelegations = delegationsDeferred.await() + ) + } + } + } + + private suspend fun ConvictionVotingRepository.delegationsOf( + userAccountId: AccountId, + delegate: AccountId, + chainId: ChainId + ): Map { + return delegatingFor(userAccountId, chainId) + .filterValues { it.target.contentEquals(delegate) } + } + + private fun mapStatsToDomain(detailedStats: DelegateDetailedStats): DelegateDetails.Stats { + return DelegateDetails.Stats( + delegationsCount = detailedStats.delegationsCount, + delegatedVotes = detailedStats.delegatedVotes, + recentVotes = detailedStats.recentVotes, + allVotes = detailedStats.allVotes + ) + } + + private fun mapMetadataToDomain(metadata: DelegateMetadata): DelegateDetails.Metadata { + return DelegateDetails.Metadata( + shortDescription = metadata.shortDescription, + longDescription = metadata.longDescription, + accountType = mapAccountTypeToDomain(metadata.isOrganization), + iconUrl = metadata.profileImageUrl, + name = metadata.name + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/label/RealDelegateLabelUseCase.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/label/RealDelegateLabelUseCase.kt new file mode 100644 index 0000000..62d1a7f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/label/RealDelegateLabelUseCase.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.label + +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_governance_api.data.repository.getDelegateMetadataOrNull +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabel +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabelUseCase +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.mapAccountTypeToDomain +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class RealDelegateLabelUseCase( + private val governanceSharedState: GovernanceSharedState, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val identityRepository: OnChainIdentityRepository, +) : DelegateLabelUseCase { + + override suspend fun getDelegateLabel(delegate: AccountId): DelegateLabel = withContext(Dispatchers.Default) { + val option = governanceSharedState.selectedOption() + val chain = option.assetWithChain.chain + val delegationsRepository = governanceSourceRegistry.sourceFor(option).delegationsRepository + + val metadata = delegationsRepository.getDelegateMetadataOrNull(chain, delegate) + val identity = identityRepository.getIdentityFromId(chain.id, delegate) + + DelegateLabel( + accountId = delegate, + onChainIdentity = identity, + metadata = metadata?.let { + DelegateLabel.Metadata( + name = metadata.name, + iconUrl = metadata.profileImageUrl, + accountType = mapAccountTypeToDomain(metadata.isOrganization) + ) + } + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/list/RealDelegateListInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/list/RealDelegateListInteractor.kt new file mode 100644 index 0000000..b0deac7 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/list/RealDelegateListInteractor.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.list + +import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository +import io.novafoundation.nova.common.utils.applyFilter +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.DelegateListInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegateFiltering +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegatePreview +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegateSorting +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.delegateComparator +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.hasMetadata +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.DelegatesSharedComputation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private const val DELEGATION_BANNER_TAG = "DELEGATION_BANNER" + +class RealDelegateListInteractor( + private val bannerVisibilityRepository: BannerVisibilityRepository, + private val delegatesSharedComputation: DelegatesSharedComputation +) : DelegateListInteractor { + + override fun shouldShowDelegationBanner(): Flow { + return bannerVisibilityRepository.shouldShowBannerFlow(DELEGATION_BANNER_TAG) + } + + override suspend fun hideDelegationBanner() { + bannerVisibilityRepository.hideBanner(DELEGATION_BANNER_TAG) + } + + override suspend fun getDelegates( + governanceOption: SupportedGovernanceOption, + scope: CoroutineScope + ): Flow> { + return delegatesSharedComputation.delegates(governanceOption, scope) + } + + override suspend fun getUserDelegates(governanceOption: SupportedGovernanceOption, scope: CoroutineScope): Flow> { + return delegatesSharedComputation.delegates(governanceOption, scope).map { delegates -> + delegates.filter { it.userDelegations.isNotEmpty() } + } + } + + override suspend fun applySortingAndFiltering( + sorting: DelegateSorting, + filtering: DelegateFiltering, + delegates: List + ): List { + val filteredDelegates = delegates.applyFilter(filtering) + return applySorting(sorting, filteredDelegates) + } + + override suspend fun applySorting( + sorting: DelegateSorting, + delegates: List + ): List { + val comparator = getMetadataComparator() + .thenComparing(sorting.delegateComparator()) + + return delegates.sortedWith(comparator) + } + + private fun getMetadataComparator(): Comparator { + return compareByDescending { it.hasMetadata() } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/search/RealDelegateSearchInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/search/RealDelegateSearchInteractor.kt new file mode 100644 index 0000000..26713cc --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegate/search/RealDelegateSearchInteractor.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.search + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.emitError +import io.novafoundation.nova.common.domain.emitLoaded +import io.novafoundation.nova.common.domain.emitLoading +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceAdditionalState +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegatePreview +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.search.DelegateSearchInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.repository.DelegateCommonRepository +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.DelegatesSharedComputation +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.isValidAddress +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.onStart + +class RealDelegateSearchInteractor( + private val identityRepository: OnChainIdentityRepository, + private val delegateCommonRepository: DelegateCommonRepository, + private val delegatesSharedComputation: DelegatesSharedComputation +) : DelegateSearchInteractor { + + override suspend fun searchDelegates( + queryFlow: Flow, + selectedOption: SelectedAssetOptionSharedState.SupportedAssetOption, + scope: CoroutineScope + ): Flow>> { + val chain = selectedOption.assetWithChain.chain + return combineTransform(queryFlow, delegatesSharedComputation.delegates(selectedOption, scope)) { query, delegates -> + if (query.isEmpty()) { + emitLoaded(emptyList()) + return@combineTransform + } + + var searchResult = filterDelegates(chain, query, delegates) + + if (searchResult.isEmpty() && chain.isValidAddress(query)) { + emitLoading>() + val searchedAccountId = chain.accountIdOf(query) + searchResult = listOf(loadDelegateById(chain, searchedAccountId)) + } + + emitLoaded(searchResult) + }.onStart { emitLoading() } + .catch { emitError(it) } + } + + private fun filterDelegates(chain: Chain, query: String, delegates: List): List { + val lowercaseQuery = query.lowercase() + + return delegates.filter { + val metadataName = it.metadata?.name?.lowercase().orEmpty() + val identityName = it.onChainIdentity?.display?.lowercase().orEmpty() + lowercaseQuery in metadataName || + lowercaseQuery in identityName || + chain.addressOf(it.accountId).startsWith(query) + } + } + + private suspend fun loadDelegateById(chain: Chain, accountId: AccountId): DelegatePreview { + val identity = identityRepository.getIdentityFromId(chain.id, accountId) + + return DelegatePreview( + accountId = accountId, + stats = null, + metadata = null, + onChainIdentity = identity, + userDelegations = emptyMap() + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/RealDelegateAssistant.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/RealDelegateAssistant.kt new file mode 100644 index 0000000..d825825 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/RealDelegateAssistant.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.findById +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.lockDuration +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount.DelegateAssistant +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.LocksChange +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.ReusableLock +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.addIfPositive +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.Change +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.feature_wallet_api.domain.model.maxLockReplacing +import io.novafoundation.nova.feature_wallet_api.domain.model.transferableReplacingFrozen +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novafoundation.nova.runtime.util.BlockDurationEstimator + +class RealDelegateAssistant( + private val voteLockingPeriod: BlockNumber, + private val balanceLocks: List, + private val blockDurationEstimator: BlockDurationEstimator, + private val votingLockId: BalanceLockId, +) : DelegateAssistant { + + private val currentMaxGovernanceLocked = balanceLocks.findById(votingLockId)?.amountInPlanks.orZero() + private val allMaxLocked = balanceLocks.maxOfOrNull { it.amountInPlanks }.orZero() + private val otherMaxLocked = balanceLocks.maxLockReplacing(votingLockId, replaceWith = Balance.ZERO) + + override suspend fun estimateLocksAfterDelegating(amount: Balance, conviction: Conviction, asset: Asset): LocksChange { + val unlockBlocks = conviction.lockDuration(voteLockingPeriod) + val unlockDuration = blockDurationEstimator.durationOf(unlockBlocks) + + val newGovernanceLocked = currentMaxGovernanceLocked.max(amount) + + val currentTransferablePlanks = asset.transferableInPlanks + val newLocked = otherMaxLocked.max(newGovernanceLocked) + val newTransferablePlanks = asset.transferableReplacingFrozen(newLocked) + + return LocksChange( + lockedAmountChange = Change( + previousValue = currentMaxGovernanceLocked, + newValue = newGovernanceLocked, + ), + lockedPeriodChange = Change.Same(unlockDuration), + transferableChange = Change( + previousValue = currentTransferablePlanks, + newValue = newTransferablePlanks, + ) + ) + } + + override suspend fun reusableLocks(): List { + return buildList { + addIfPositive(ReusableLock.Type.ALL, allMaxLocked) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/RealNewDelegationChooseAmountInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/RealNewDelegationChooseAmountInteractor.kt new file mode 100644 index 0000000..5282ca3 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/RealNewDelegationChooseAmountInteractor.kt @@ -0,0 +1,175 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.utils.multiResult.RetriableMultiResult +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.delegations +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount.DelegateAssistant +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.blockDurationEstimator +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private const val DELEGATION_ASSISTANT_CACHE_KEY = "RealNewDelegationChooseAmountInteractor.DelegationAssistant" + +class RealNewDelegationChooseAmountInteractor( + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val chainStateRepository: ChainStateRepository, + private val selectedChainState: GovernanceSharedState, + private val extrinsicService: ExtrinsicService, + private val locksRepository: BalanceLocksRepository, + private val computationalCache: ComputationalCache, + private val accountRepository: AccountRepository, +) : NewDelegationChooseAmountInteractor { + + override suspend fun maxAvailableBalanceToDelegate(asset: Asset): Balance { + val (_, source) = useSelectedGovernance() + return source.convictionVoting.maxAvailableForVote(asset) + } + + override fun delegateAssistantFlow(coroutineScope: CoroutineScope): Flow { + return computationalCache.useSharedFlow(DELEGATION_ASSISTANT_CACHE_KEY, coroutineScope) { + val governanceOption = selectedChainState.selectedOption() + + voteAssistantFlowSuspend(governanceOption) + } + } + + override suspend fun estimateFee( + amount: Balance, + conviction: Conviction, + delegate: AccountId, + tracks: Collection, + shouldRemoveOtherTracks: Boolean, + ): Fee { + val (chain, governanceSource) = useSelectedGovernance() + val origin = accountRepository.requireIdOfSelectedMetaAccountIn(chain) + + return extrinsicService.estimateMultiFee(chain, TransactionOrigin.SelectedWallet) { + delegate(governanceSource, amount, conviction, delegate, origin, chain, tracks, shouldRemoveOtherTracks) + } + } + + override suspend fun delegate( + amount: Balance, + conviction: Conviction, + delegate: AccountId, + tracks: Collection, + shouldRemoveOtherTracks: Boolean, + ): RetriableMultiResult> { + val (chain, governanceSource) = useSelectedGovernance() + + return extrinsicService.submitMultiExtrinsicAwaitingInclusion(chain, TransactionOrigin.SelectedWallet) { buildingContext -> + delegate( + governanceSource = governanceSource, + amount = amount, + conviction = conviction, + delegate = delegate, + user = buildingContext.submissionOrigin.executingAccount, + chain = chain, + tracks = tracks, + shouldRemoveOtherTracks = shouldRemoveOtherTracks + ) + } + } + + private suspend fun CallBuilder.delegate( + governanceSource: GovernanceSource, + amount: Balance, + conviction: Conviction, + delegate: AccountId, + user: AccountId, + chain: Chain, + tracks: Collection, + shouldRemoveOtherTracks: Boolean, + ) { + val tracksSet = tracks.toSet() + val delegations = governanceSource.convictionVoting.votingFor(user, chain.id).delegations(to = delegate) + val alreadyDelegatedTracks = delegations.keys + val tracksToRemove = if (shouldRemoveOtherTracks) alreadyDelegatedTracks - tracksSet else emptySet() + + with(governanceSource.delegationsRepository) { + tracksToRemove.forEach { track -> + undelegate(track) + } + + tracks.forEach { trackId -> + val delegationInTrack = delegations[trackId] + + when { + // replace existing delegation with new one + delegationInTrack != null && !delegationInTrack.sameAs(amount, conviction) -> { + undelegate(trackId) + delegate(delegate, trackId, amount, conviction) + } + + // do nothing - delegation is the same + delegationInTrack != null && delegationInTrack.sameAs(amount, conviction) -> {} + + // its a new delegation - just delegate + delegationInTrack == null -> delegate(delegate, trackId, amount, conviction) + } + } + } + } + + private fun Voting.Delegating.sameAs(amount: Balance, conviction: Conviction): Boolean { + return amount == this.amount && conviction == this.conviction + } + + private suspend fun voteAssistantFlowSuspend( + selectedGovernanceOption: SupportedGovernanceOption, + ): Flow { + val chain = selectedGovernanceOption.assetWithChain.chain + val chainAsset = selectedGovernanceOption.assetWithChain.asset + + val governanceSource = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + val voteLockingPeriod = governanceSource.convictionVoting.voteLockingPeriod(chain.id) + + val blockDurationEstimator = chainStateRepository.blockDurationEstimator(chain.timelineChainIdOrSelf()) + + val metaId = accountRepository.getSelectedMetaAccount().id + + val balanceLocksFlow = locksRepository.observeBalanceLocks(metaId, chain, chainAsset) + + return balanceLocksFlow.map { locks -> + RealDelegateAssistant( + balanceLocks = locks, + blockDurationEstimator = blockDurationEstimator, + voteLockingPeriod = voteLockingPeriod, + votingLockId = governanceSource.convictionVoting.voteLockId + ) + } + } + + private suspend fun useSelectedGovernance(): Pair { + val option = selectedChainState.selectedOption() + val source = governanceSourceRegistry.sourceFor(option) + val chain = option.assetWithChain.chain + + return chain to source + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ChooseDelegationAmountValidationFailure.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ChooseDelegationAmountValidationFailure.kt new file mode 100644 index 0000000..9d8220f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ChooseDelegationAmountValidationFailure.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation + +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughFreeBalanceError +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class ChooseDelegationAmountValidationFailure { + + class NotEnoughToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : ChooseDelegationAmountValidationFailure(), NotEnoughToPayFeesError + + object CannotDelegateToSelf : ChooseDelegationAmountValidationFailure() + + class AmountIsTooBig( + override val chainAsset: Chain.Asset, + override val freeAfterFees: BigDecimal, + ) : ChooseDelegationAmountValidationFailure(), NotEnoughFreeBalanceError +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ChooseDelegationAmountValidationPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ChooseDelegationAmountValidationPayload.kt new file mode 100644 index 0000000..d45be12 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ChooseDelegationAmountValidationPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigDecimal + +class ChooseDelegationAmountValidationPayload( + val fee: Fee, + val asset: Asset, + val amount: BigDecimal, + val maxAvailableAmount: BigDecimal, + val delegate: AccountId +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ChooseDelegationAmountValidationSystem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ChooseDelegationAmountValidationSystem.kt new file mode 100644 index 0000000..531e9c8 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ChooseDelegationAmountValidationSystem.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_wallet_api.domain.validation.hasEnoughBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias ChooseDelegationAmountValidationSystem = ValidationSystem +typealias ChooseDelegationAmountValidation = Validation +typealias ChooseDelegationAmountValidationSystemBuilder = + ValidationSystemBuilder + +fun ValidationSystem.Companion.chooseDelegationAmount( + governanceSharedState: GovernanceSharedState, + accountRepository: AccountRepository, +): ChooseDelegationAmountValidationSystem = ValidationSystem { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + ChooseDelegationAmountValidationFailure.NotEnoughToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) + + hasEnoughBalance( + availableBalance = { it.maxAvailableAmount }, + chainAsset = { it.asset.token.configuration }, + requestedAmount = { it.amount }, + error = ChooseDelegationAmountValidationFailure::AmountIsTooBig + ) + + notSelfDelegation(governanceSharedState, accountRepository) + + // we do not validate for track availability since logic should take care of undelegate calls in case some delegation is being changed +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/NotSelfDelegationValidation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/NotSelfDelegationValidation.kt new file mode 100644 index 0000000..bdae319 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/NotSelfDelegationValidation.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isFalseOrError +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.runtime.state.chain + +class NotSelfDelegationValidation( + private val governanceSharedState: GovernanceSharedState, + private val accountRepository: AccountRepository, +) : ChooseDelegationAmountValidation { + + override suspend fun validate(value: ChooseDelegationAmountValidationPayload): ValidationStatus { + val chain = governanceSharedState.chain() + val origin = accountRepository.requireIdOfSelectedMetaAccountIn(chain) + + return origin.contentEquals(value.delegate) isFalseOrError { + ChooseDelegationAmountValidationFailure.CannotDelegateToSelf + } + } +} + +fun ChooseDelegationAmountValidationSystemBuilder.notSelfDelegation( + governanceSharedState: GovernanceSharedState, + accountRepository: AccountRepository +) { + validate(NotSelfDelegationValidation(governanceSharedState, accountRepository)) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ValidationFailureUi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ValidationFailureUi.kt new file mode 100644 index 0000000..3c9217e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseAmount/validation/ValidationFailureUi.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFreeBalanceError + +fun chooseChooseDelegationAmountValidationFailure( + failure: ChooseDelegationAmountValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage { + return when (failure) { + is ChooseDelegationAmountValidationFailure.NotEnoughToPayFees -> handleNotEnoughFeeError(failure, resourceManager) + is ChooseDelegationAmountValidationFailure.AmountIsTooBig -> handleNotEnoughFreeBalanceError( + error = failure, + resourceManager = resourceManager, + descriptionFormat = R.string.refrendum_vote_not_enough_available_message + ) + ChooseDelegationAmountValidationFailure.CannotDelegateToSelf -> { + resourceManager.getString(R.string.delegation_error_self_delegate_title) to + resourceManager.getString(R.string.delegation_error_self_delegate_message) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseTrack/RealChooseTrackInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseTrack/RealChooseTrackInteractor.kt new file mode 100644 index 0000000..6756c5b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/create/chooseTrack/RealChooseTrackInteractor.kt @@ -0,0 +1,193 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseTrack + +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.delegations +import io.novafoundation.nova.feature_governance_api.data.repository.getTracksById +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.ChooseTrackData +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.TrackPartition +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.TrackPreset +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.all +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackCategory +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.data.repository.RemoveVotesSuggestionRepository +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseTrack.TrackAvailability.ALREADY_DELEGATED +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseTrack.TrackAvailability.ALREADY_VOTED +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseTrack.TrackAvailability.AVAILABLE +import io.novafoundation.nova.feature_governance_impl.domain.track.category.TrackCategorizer +import io.novafoundation.nova.feature_governance_impl.domain.track.mapTrackInfoToTrack +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private enum class TrackAvailability { + AVAILABLE, ALREADY_VOTED, ALREADY_DELEGATED +} + +class RealChooseTrackInteractor( + private val governanceSharedState: GovernanceSharedState, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val chainStateRepository: ChainStateRepository, + private val accountRepository: AccountRepository, + private val trackCategorizer: TrackCategorizer, + private val removeVotesSuggestionRepository: RemoveVotesSuggestionRepository, + private val chainRegistry: ChainRegistry +) : ChooseTrackInteractor { + + override suspend fun isAllowedToShowRemoveVotesSuggestion(): Boolean { + return removeVotesSuggestionRepository.isAllowedToShowRemoveVotesSuggestion() + } + + override suspend fun disallowShowRemoveVotesSuggestion() { + removeVotesSuggestionRepository.disallowShowRemoveVotesSuggestion() + } + + override fun observeTracksByChain(chainId: ChainId, govType: Chain.Governance): Flow = flowOf { + val chain = chainRegistry.getChain(chainId) + + val chainSupportGovType = chain.governance.any { it == govType } + if (!chainSupportGovType) { + return@flowOf ChooseTrackData.empty() + } + + val governanceSource = governanceSourceRegistry.sourceFor(govType) + val allTracks = governanceSource.referenda.getTracksById(chain.id) + .mapValues { (_, track) -> mapTrackInfoToTrack(track) } + .values + .toList() + + ChooseTrackData( + trackPartition = TrackPartition( + available = allTracks, + alreadyVoted = emptyList(), + alreadyDelegated = emptyList(), + preCheckedTrackIds = emptySet() + ), + presets = buildPresets(allTracks) + ) + } + + override fun observeNewDelegationTrackData(): Flow { + return observeChooseTrackData { voting, allTracks -> + val tracksByAvailability = allTracks.values.groupBy { voting.newDelegationAvailabilityOf(it.id) } + + TrackPartition( + available = tracksByAvailability.tracksThatAre(AVAILABLE), + alreadyVoted = tracksByAvailability.tracksThatAre(ALREADY_VOTED), + alreadyDelegated = tracksByAvailability.tracksThatAre(ALREADY_DELEGATED), + preCheckedTrackIds = emptySet() + ) + } + } + + override fun observeEditDelegationTrackData(delegateId: AccountId): Flow { + return observeChooseTrackData { voting, allTracks -> + val tracksByAvailability = allTracks.values.groupBy { voting.editDelegationAvailabilityOf(it.id, delegateId) } + + TrackPartition( + available = tracksByAvailability.tracksThatAre(AVAILABLE), + alreadyVoted = tracksByAvailability.tracksThatAre(ALREADY_VOTED), + alreadyDelegated = tracksByAvailability.tracksThatAre(ALREADY_DELEGATED), + preCheckedTrackIds = voting.delegations(to = delegateId).keys + ) + } + } + + override fun observeRevokeDelegationTrackData(delegateId: AccountId): Flow { + return observeChooseTrackData { voting, allTracks -> + val revokableTrackIds = voting.delegations(to = delegateId).keys + + TrackPartition( + available = revokableTrackIds.map(allTracks::getValue), + alreadyVoted = emptyList(), + alreadyDelegated = emptyList(), + preCheckedTrackIds = emptySet() + ) + } + } + + private fun observeChooseTrackData( + partitionConstructor: suspend (voting: Map, allTracks: Map) -> TrackPartition, + ): Flow { + return flowOfAll { + val governanceOption = governanceSharedState.selectedOption() + val chain = governanceOption.assetWithChain.chain + val governanceSource = governanceSourceRegistry.sourceFor(governanceOption) + + val allTracks = governanceSource.referenda.getTracksById(chain.id) + .mapValues { (_, track) -> mapTrackInfoToTrack(track) } + + val userAccountId = accountRepository.requireIdOfSelectedMetaAccountIn(chain) + + chainStateRepository.currentBlockNumberFlow(chain.timelineChainIdOrSelf()).map { + val userVotings = governanceSource.convictionVoting.votingFor(userAccountId, chain.id) + + val partition = partitionConstructor(userVotings, allTracks) + + val presets = buildPresets(partition.available) + + ChooseTrackData(partition, presets) + } + } + } + + private fun buildPresets(tracks: List): List { + val all = if (tracks.isNotEmpty()) TrackPreset.all(tracks) else null + + val categorized = tracks.groupBy { trackCategorizer.categoryOf(it.name) } + .mapNotNull { (trackCategory, tracks) -> + val presetType = mapTrackCategoryToPresetType(trackCategory) + + presetType?.let { + TrackPreset( + type = it, + trackIds = tracks.map(Track::id) + ) + } + } + + return listOfNotNull(all) + categorized + } + + private fun mapTrackCategoryToPresetType(trackCategory: TrackCategory): TrackPreset.Type? { + return when (trackCategory) { + TrackCategory.TREASURY -> TrackPreset.Type.TREASURY + TrackCategory.GOVERNANCE -> TrackPreset.Type.GOVERNANCE + TrackCategory.FELLOWSHIP -> TrackPreset.Type.FELLOWSHIP + TrackCategory.OTHER -> null + } + } + + private fun Map.newDelegationAvailabilityOf(trackId: TrackId): TrackAvailability { + return when (val voting = get(trackId)) { + is Voting.Casting -> if (voting.votes.isEmpty()) AVAILABLE else ALREADY_VOTED + is Voting.Delegating -> ALREADY_DELEGATED + null -> AVAILABLE + } + } + + private fun Map>.tracksThatAre(availability: TrackAvailability): List { + return get(availability).orEmpty() + } + + private fun Map.editDelegationAvailabilityOf(trackId: TrackId, delegateId: AccountId): TrackAvailability { + return when (val voting = get(trackId)) { + is Voting.Casting -> if (voting.votes.isEmpty()) AVAILABLE else ALREADY_VOTED + is Voting.Delegating -> if (voting.target.contentEquals(delegateId)) AVAILABLE else ALREADY_DELEGATED + null -> AVAILABLE + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/RealRemoveTrackVotesInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/RealRemoveTrackVotesInteractor.kt new file mode 100644 index 0000000..9f3f60d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/RealRemoveTrackVotesInteractor.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.votedReferenda +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.removeVotes.RemoveTrackVotesInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class RealRemoveTrackVotesInteractor( + private val extrinsicService: ExtrinsicService, + private val governanceSharedState: GovernanceSharedState, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val accountRepository: AccountRepository, +) : RemoveTrackVotesInteractor { + + override suspend fun calculateFee(trackIds: Collection): Fee = withContext(Dispatchers.IO) { + val (chain, governance) = useSelectedGovernance() + val accountId = accountRepository.requireIdOfSelectedMetaAccountIn(chain) + + extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + governance.removeVotes(trackIds, extrinsicBuilder = this, chain.id, accountId) + } + } + + override suspend fun removeTrackVotes(trackIds: Collection): Result> = withContext(Dispatchers.IO) { + val (chain, governance) = useSelectedGovernance() + + extrinsicService.submitAndWatchExtrinsic(chain, TransactionOrigin.SelectedWallet) { buildingContext -> + governance.removeVotes(trackIds, extrinsicBuilder = this, chain.id, accountIdToRemoveVotes = buildingContext.submissionOrigin.executingAccount) + }.awaitInBlock() + } + + private suspend fun GovernanceSource.removeVotes( + trackIds: Collection, + extrinsicBuilder: ExtrinsicBuilder, + chainId: ChainId, + accountIdToRemoveVotes: AccountId + ) { + val votings = convictionVoting.votingFor(accountIdToRemoveVotes, chainId, trackIds) + + votings.entries.onEach { (trackId, voting) -> + voting.votedReferenda().onEach { referendumId -> + with(convictionVoting) { + extrinsicBuilder.removeVote(trackId, referendumId) + } + } + } + } + + private suspend fun useSelectedGovernance(): Pair { + val option = governanceSharedState.selectedOption() + val source = governanceSourceRegistry.sourceFor(option) + val chain = option.assetWithChain.chain + + return chain to source + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/RemoveVotesValidationFailure.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/RemoveVotesValidationFailure.kt new file mode 100644 index 0000000..7781789 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/RemoveVotesValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class RemoveVotesValidationFailure { + + class NotEnoughToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : RemoveVotesValidationFailure(), NotEnoughToPayFeesError +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/RemoveVotesValidationPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/RemoveVotesValidationPayload.kt new file mode 100644 index 0000000..1518a55 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/RemoveVotesValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.validations + +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset + +class RemoveVotesValidationPayload( + val fee: Fee, + val asset: Asset, +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/RemoveVotesValidationSystem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/RemoveVotesValidationSystem.kt new file mode 100644 index 0000000..7b94f54 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/RemoveVotesValidationSystem.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias RemoteVotesValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.removeVotesValidationSystem(): RemoteVotesValidationSystem = ValidationSystem { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + RemoveVotesValidationFailure.NotEnoughToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/ValidationFailureUi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/ValidationFailureUi.kt new file mode 100644 index 0000000..1c7d023 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/removeVotes/validations/ValidationFailureUi.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError + +fun handleRemoveVotesValidationFailure(failure: RemoveVotesValidationFailure, resourceManager: ResourceManager): TitleAndMessage { + return when (failure) { + is RemoveVotesValidationFailure.NotEnoughToPayFees -> handleNotEnoughFeeError(failure, resourceManager) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/RevokeDelegationData.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/RevokeDelegationData.kt new file mode 100644 index 0000000..06fe7dc --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/RevokeDelegationData.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.model.Delegator +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import kotlin.time.Duration + +class RevokeDelegationData( + val undelegatingPeriod: Duration, + val delegationsOverview: Delegator.Vote?, + val delegations: Map +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/RevokeDelegationsInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/RevokeDelegationsInteractor.kt new file mode 100644 index 0000000..629f90d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/RevokeDelegationsInteractor.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke + +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.multiResult.RetriableMultiResult +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.delegations +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.lockDuration +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.model.DelegatorVote +import io.novafoundation.nova.feature_governance_api.domain.track.matchWith +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.track.TracksUseCase +import io.novafoundation.nova.feature_governance_impl.domain.track.tracksByIdOf +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.blockDurationEstimator +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.flow.Flow + +interface RevokeDelegationsInteractor { + + suspend fun calculateFee(trackIds: Collection): Fee + + suspend fun revokeDelegations(trackIds: Collection): RetriableMultiResult> + + fun revokeDelegationDataFlow(trackIds: Collection): Flow +} + +class RealRevokeDelegationsInteractor( + private val extrinsicService: ExtrinsicService, + private val governanceSharedState: GovernanceSharedState, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val chainStateRepository: ChainStateRepository, + private val accountRepository: AccountRepository, + private val tracksUseCase: TracksUseCase, +) : RevokeDelegationsInteractor { + + override suspend fun calculateFee(trackIds: Collection): Fee { + val (chain, source) = useSelectedGovernance() + + return extrinsicService.estimateMultiFee(chain, TransactionOrigin.SelectedWallet) { + revokeDelegations(source, trackIds) + } + } + + override suspend fun revokeDelegations(trackIds: Collection): RetriableMultiResult> { + val (chain, source) = useSelectedGovernance() + + return extrinsicService.submitMultiExtrinsicAwaitingInclusion(chain, TransactionOrigin.SelectedWallet) { + revokeDelegations(source, trackIds) + } + } + + override fun revokeDelegationDataFlow(trackIds: Collection): Flow { + return flowOf { + val (chain, source, chainAsset) = useSelectedGovernance() + val accountId = accountRepository.requireIdOfSelectedMetaAccountIn(chain) + + val blockDurationEstimator = chainStateRepository.blockDurationEstimator(chain.timelineChainIdOrSelf()) + val tracks = tracksUseCase.tracksByIdOf(trackIds) + + val delegations = source.convictionVoting.votingFor(accountId, chain.id, trackIds) + .delegations() + .matchWith(tracks) + + val undelegatingPeriod = source.convictionVoting.voteLockingPeriod(chain.id) + + val maxUndelegateDurationInBlocks = delegations + .maxOfOrNull { (_, delegation) -> delegation.conviction.lockDuration(undelegatingPeriod) } + .orZero() + + val maxUndelegateDuration = blockDurationEstimator.durationOf(maxUndelegateDurationInBlocks) + + val delegationsOverview = DelegatorVote(delegations.values, chainAsset) + + RevokeDelegationData(maxUndelegateDuration, delegationsOverview, delegations) + } + } + + private suspend fun CallBuilder.revokeDelegations( + source: GovernanceSource, + trackIds: Collection + ) { + trackIds.forEach { trackId -> + with(source.delegationsRepository) { undelegate(trackId) } + } + } + + private suspend fun useSelectedGovernance(): Triple { + val option = governanceSharedState.selectedOption() + val source = governanceSourceRegistry.sourceFor(option) + val (chain, chainAsset) = option.assetWithChain + + return Triple(chain, source, chainAsset) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/RevokeDelegationValidationFailure.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/RevokeDelegationValidationFailure.kt new file mode 100644 index 0000000..97e3814 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/RevokeDelegationValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class RevokeDelegationValidationFailure { + + class NotEnoughToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : RevokeDelegationValidationFailure(), NotEnoughToPayFeesError +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/RevokeDelegationValidationPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/RevokeDelegationValidationPayload.kt new file mode 100644 index 0000000..50fe4b3 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/RevokeDelegationValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.validations + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee + +class RevokeDelegationValidationPayload( + val fee: Fee, + val asset: Asset, +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/RevokeDelegationValidationSystem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/RevokeDelegationValidationSystem.kt new file mode 100644 index 0000000..9f8ea83 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/RevokeDelegationValidationSystem.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias RevokeDelegationValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.revokeDelegationValidationSystem(): RevokeDelegationValidationSystem = ValidationSystem { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + RevokeDelegationValidationFailure.NotEnoughToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/ValidationFailureUi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/ValidationFailureUi.kt new file mode 100644 index 0000000..59ccc77 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/delegation/delegation/revoke/validations/ValidationFailureUi.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError + +fun handleRevokeDelegationValidationFailure(failure: RevokeDelegationValidationFailure, resourceManager: ResourceManager): TitleAndMessage { + return when (failure) { + is RevokeDelegationValidationFailure.NotEnoughToPayFees -> handleNotEnoughFeeError(failure, resourceManager) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/filters/ReferendaFiltersInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/filters/ReferendaFiltersInteractor.kt new file mode 100644 index 0000000..6aa3a32 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/filters/ReferendaFiltersInteractor.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_governance_impl.domain.filters + +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumTypeFilter +import io.novafoundation.nova.feature_governance_impl.data.repository.filters.ReferendaFiltersRepository +import kotlinx.coroutines.flow.Flow + +interface ReferendaFiltersInteractor { + + fun getReferendumTypeFilter(): ReferendumTypeFilter + + fun observeReferendumTypeFilter(): Flow + + fun updateReferendumTypeFilter(filter: ReferendumTypeFilter) +} + +class RealReferendaFiltersInteractor( + private val referendaFiltersRepository: ReferendaFiltersRepository +) : ReferendaFiltersInteractor { + + override fun getReferendumTypeFilter(): ReferendumTypeFilter { + return referendaFiltersRepository.getReferendumTypeFilter() + } + + override fun observeReferendumTypeFilter(): Flow { + return referendaFiltersRepository.observeReferendumTypeFilter() + } + + override fun updateReferendumTypeFilter(filter: ReferendumTypeFilter) { + referendaFiltersRepository.updateReferendumTypeFilter(filter) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/filters/ReferendumType.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/filters/ReferendumType.kt new file mode 100644 index 0000000..586baa0 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/filters/ReferendumType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_impl.domain.filters + +enum class ReferendumType { + ALL, NOT_VOTED, VOTED +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/filters/ReferendumTypeFilter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/filters/ReferendumTypeFilter.kt new file mode 100644 index 0000000..921ba19 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/filters/ReferendumTypeFilter.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_impl.domain.filters + +import io.novafoundation.nova.common.utils.OptionsFilter +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview + +class ReferendumTypeFilter(val selected: ReferendumType) : OptionsFilter { + + override val options: List + get() = ReferendumType.values().toList() + + override fun shouldInclude(model: ReferendumPreview): Boolean { + val vote = model.referendumVote?.vote + return when (selected) { + ReferendumType.ALL -> true + ReferendumType.VOTED -> vote != null + ReferendumType.NOT_VOTED -> vote == null + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/identity/GovernanceIdentityProviderFactory.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/identity/GovernanceIdentityProviderFactory.kt new file mode 100644 index 0000000..81ad3c6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/identity/GovernanceIdentityProviderFactory.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_impl.domain.identity + +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.oneOf +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumProposer +import kotlinx.coroutines.flow.Flow + +class GovernanceIdentityProviderFactory( + private val localProvider: IdentityProvider, + private val onChainProvider: IdentityProvider, +) { + + fun proposerProvider( + proposerFlow: Flow + ): IdentityProvider { + val referendumProposerProvider = ReferendumProposerIdentityProvider(proposerFlow) + + return IdentityProvider.oneOf(onChainProvider, referendumProposerProvider, localProvider) + } + + fun defaultProvider(): IdentityProvider { + return IdentityProvider.oneOf(onChainProvider, localProvider) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/identity/ReferendumProposerIdentityProvider.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/identity/ReferendumProposerIdentityProvider.kt new file mode 100644 index 0000000..f1441a5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/identity/ReferendumProposerIdentityProvider.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.domain.identity + +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumProposer +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +class ReferendumProposerIdentityProvider( + private val proposerFlow: Flow +) : IdentityProvider { + + override suspend fun identityFor(accountId: AccountId, chainId: ChainId): Identity? = withContext(Dispatchers.IO) { + val proposer = proposerFlow.first() + + val maybeName = proposer?.offChainNickname?.takeIf { + accountId.contentEquals(proposer.accountId) + } + + maybeName?.let(::Identity) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/common/RealReferendaConstructor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/common/RealReferendaConstructor.kt new file mode 100644 index 0000000..98092d3 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/common/RealReferendaConstructor.kt @@ -0,0 +1,415 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.common + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.asLoaded +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.utils.divideToDecimal +import io.novafoundation.nova.common.utils.orFalse +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ConfirmingSource +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendumStatus +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Tally +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackQueue +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.asOngoing +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ayeVotes +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.inQueue +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.nayVotes +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.orEmpty +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.positionOf +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.sinceOrThrow +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.till +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.tillOrNull +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.track +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumVotingDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.getAbstain +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.toTallyOrNull +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumThreshold +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumVoting +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.currentlyPassing +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.projectedPassing +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline.State +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.PreparingReason +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.util.BlockDurationEstimator +import io.novafoundation.nova.runtime.util.timerUntil +import java.math.BigInteger + +interface ReferendaConstructor { + + fun constructReferendumVoting( + tally: Tally?, + currentBlockNumber: BlockNumber, + electorate: Balance?, + offChainVotingDetails: ExtendedLoadingState + ): ReferendumVoting + + fun constructReferendumThreshold( + referendum: OnChainReferendum, + tracksById: Map?, + currentBlockNumber: BlockNumber, + electorate: Balance? + ): ReferendumThreshold? + + suspend fun constructReferendaStatuses( + selectedGovernanceOption: SupportedGovernanceOption, + onChainReferenda: Collection, + votingByReferenda: Map, + thresholdByReferenda: Map, + tracksById: Map, + currentBlockNumber: BlockNumber, + ): Map + + suspend fun constructPastTimeline( + chain: Chain, + onChainReferendum: OnChainReferendum, + calculatedStatus: ReferendumStatus, + currentBlockNumber: BlockNumber, + ): List +} + +suspend fun ReferendaConstructor.constructReferendumStatus( + selectedGovernanceOption: SupportedGovernanceOption, + onChainReferendum: OnChainReferendum, + tracksById: Map, + votingByReferenda: Map, + thresholdByReferenda: Map, + currentBlockNumber: BlockNumber, +): ReferendumStatus = constructReferendaStatuses( + selectedGovernanceOption = selectedGovernanceOption, + onChainReferenda = listOf(onChainReferendum), + tracksById = tracksById, + currentBlockNumber = currentBlockNumber, + votingByReferenda = votingByReferenda, + thresholdByReferenda = thresholdByReferenda +).values.first() + +class RealReferendaConstructor( + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val chainStateRepository: ChainStateRepository, +) : ReferendaConstructor { + + override fun constructReferendumVoting( + tally: Tally?, + currentBlockNumber: BlockNumber, + electorate: Balance?, + offChainVotingDetails: ExtendedLoadingState + ): ReferendumVoting { + return ReferendumVoting( + support = votingLoadingState(tally, electorate, offChainVotingDetails) { _tally, _electorate -> + ReferendumVoting.Support( + turnout = _tally.support, + electorate = _electorate + ) + }, + approval = votingLoadingState(tally, electorate, offChainVotingDetails) { _tally, _ -> + ReferendumVoting.Approval( + ayeVotes = _tally.ayeVotes(), + nayVotes = _tally.nayVotes(), + ) + }, + abstainVotes = offChainVotingDetails.map { it?.votingInfo?.getAbstain()?.toBigInteger() } + ) + } + + private fun votingLoadingState( + onChainTally: Tally?, + electorate: Balance?, + offChainVotingDetails: ExtendedLoadingState, + onLoaded: (Tally, Balance) -> T + ): ExtendedLoadingState { + val tallyOrNull = onChainTally ?: offChainVotingDetails.dataOrNull?.votingInfo?.toTallyOrNull() + return when { + tallyOrNull != null && electorate != null -> onLoaded(tallyOrNull, electorate).asLoaded() + offChainVotingDetails.isLoading() || electorate == null -> ExtendedLoadingState.Loading + else -> ExtendedLoadingState.Loaded(null) + } + } + + override fun constructReferendumThreshold( + referendum: OnChainReferendum, + tracksById: Map?, + currentBlockNumber: BlockNumber, + electorate: Balance? + ): ReferendumThreshold? { + val status = referendum.status + if (status !is OnChainReferendumStatus.Ongoing) return null + if (tracksById == null || electorate == null) return null + + val track = tracksById.getValue(status.track) + + val elapsedSinceDecidingFraction = if (status.deciding != null) { + val since = status.deciding!!.since + val elapsed = (currentBlockNumber - since).coerceAtLeast(BigInteger.ZERO) + + elapsed.divideToDecimal(track.decisionPeriod) + } else { + Perbill.ZERO + } + + return ReferendumThreshold( + support = status.threshold.supportThreshold(status.tally, electorate, elapsedSinceDecidingFraction), + approval = status.threshold.ayesFractionThreshold(status.tally, electorate, elapsedSinceDecidingFraction) + ) + } + + override suspend fun constructReferendaStatuses( + selectedGovernanceOption: SupportedGovernanceOption, + onChainReferenda: Collection, + votingByReferenda: Map, + thresholdByReferenda: Map, + tracksById: Map, + currentBlockNumber: BlockNumber, + ): Map { + val chain = selectedGovernanceOption.assetWithChain.chain + val governanceSource = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + + val blockTime = chainStateRepository.predictedBlockTime(chain.timelineChainIdOrSelf()) + val blockDurationEstimator = BlockDurationEstimator(currentBlock = currentBlockNumber, blockTimeMillis = blockTime) + + val undecidingTimeout = governanceSource.referenda.undecidingTimeout(chain.id) + + val approvedReferendaExecutionBlocks = governanceSource.fetchExecutionBlocks(onChainReferenda, chain) + val queuePositions = governanceSource.fetchQueuePositions(onChainReferenda, chain.id) + + return onChainReferenda.associateBy( + keySelector = { it.id }, + valueTransform = { referendum -> + when (val status = referendum.status) { + is OnChainReferendumStatus.Ongoing -> constructOngoingStatus( + status = status, + blockDurationEstimator = blockDurationEstimator, + undecidingTimeout = undecidingTimeout, + track = tracksById.getValue(status.track), + referendumId = referendum.id, + votingByReferenda = votingByReferenda, + thresholdByReferenda = thresholdByReferenda, + queuePositions = queuePositions, + ) + + is OnChainReferendumStatus.Approved -> { + val executionBlock = approvedReferendaExecutionBlocks[referendum.id] + + if (executionBlock != null) { + val executeIn = blockDurationEstimator.timerUntil(executionBlock) + ReferendumStatus.Approved(executeIn = executeIn, since = status.since) + } else { + ReferendumStatus.Executed + } + } + + is OnChainReferendumStatus.Rejected -> ReferendumStatus.NotExecuted.Rejected + + is OnChainReferendumStatus.Cancelled -> ReferendumStatus.NotExecuted.Cancelled + is OnChainReferendumStatus.Killed -> ReferendumStatus.NotExecuted.Killed + is OnChainReferendumStatus.TimedOut -> ReferendumStatus.NotExecuted.TimedOut + } + } + ) + } + + override suspend fun constructPastTimeline( + chain: Chain, + onChainReferendum: OnChainReferendum, + calculatedStatus: ReferendumStatus, + currentBlockNumber: BlockNumber, + ): List { + val blockTime = chainStateRepository.predictedBlockTime(chain.timelineChainIdOrSelf()) + val blockDurationEstimator = BlockDurationEstimator(currentBlock = currentBlockNumber, blockTimeMillis = blockTime) + + fun MutableList.add(state: State, at: BlockNumber?) { + val entry = ReferendumTimeline.Entry( + state = state, + at = at?.let(blockDurationEstimator::timestampOf) + ) + + add(entry) + } + + return buildList { + when (calculatedStatus) { + // for ongoing referenda, we have access to some timestamps on-chain + is ReferendumStatus.Ongoing -> { + add(State.CREATED, at = onChainReferendum.status.asOngoing().submitted) + } + + // for other kind of referenda, there is not historic timestamps on-chain + ReferendumStatus.NotExecuted.Cancelled -> { + add(State.CREATED, at = null) + add(State.CANCELLED, at = onChainReferendum.status.sinceOrThrow()) + } + + ReferendumStatus.NotExecuted.Killed -> { + add(State.CREATED, at = null) + add(State.KILLED, at = onChainReferendum.status.sinceOrThrow()) + } + + ReferendumStatus.NotExecuted.Rejected -> { + add(State.CREATED, at = null) + add(State.REJECTED, at = onChainReferendum.status.sinceOrThrow()) + } + + ReferendumStatus.NotExecuted.TimedOut -> { + add(State.CREATED, at = null) + add(State.TIMED_OUT, at = onChainReferendum.status.sinceOrThrow()) + } + + is ReferendumStatus.Approved -> { + add(State.CREATED, at = null) + // Approved status will be added in another place because this is a historical status but approved is an active status + } + + ReferendumStatus.Executed -> { + add(State.CREATED, at = null) + add(State.APPROVED, at = onChainReferendum.status.sinceOrThrow()) + add(State.EXECUTED, at = onChainReferendum.status.sinceOrThrow()) + } + } + } + } + + private fun constructOngoingStatus( + status: OnChainReferendumStatus.Ongoing, + blockDurationEstimator: BlockDurationEstimator, + undecidingTimeout: BlockNumber, + track: TrackInfo, + referendumId: ReferendumId, + votingByReferenda: Map, + thresholdByReferenda: Map, + queuePositions: Map + ): ReferendumStatus { + return when { + status.inQueue -> { + val timeoutBlock = status.submitted + undecidingTimeout + val timeOutIn = blockDurationEstimator.timerUntil(timeoutBlock) + val positionInQueue = queuePositions.getValue(referendumId) + + ReferendumStatus.Ongoing.InQueue(timeOutIn, positionInQueue) + } + + // confirming status from on-chain + status.deciding?.confirming is ConfirmingSource.OnChain -> { + confirmingStatusFromOnChain(status, blockDurationEstimator, track, referendumId, thresholdByReferenda) + } + + // confirming status from threshold + status.deciding?.confirming is ConfirmingSource.FromThreshold -> { + val end = status.deciding!!.confirming.cast().end + val finishIn = blockDurationEstimator.timerUntil(end) + + val passing = thresholdByReferenda[referendumId]?.currentlyPassing() ?: false + + if (passing) { + ReferendumStatus.Ongoing.DecidingApprove(approveIn = finishIn) + } else { + ReferendumStatus.Ongoing.DecidingReject(rejectIn = finishIn) + } + } + + // preparing + else -> { + val timeoutBlock = status.submitted + undecidingTimeout + val timeOutIn = blockDurationEstimator.timerUntil(timeoutBlock) + + if (status.decisionDeposit != null) { + val preparedBlock = status.submitted + track.preparePeriod + val preparedIn = blockDurationEstimator.timerUntil(preparedBlock) + + ReferendumStatus.Ongoing.Preparing(PreparingReason.DecidingIn(preparedIn), timeOutIn) + } else { + ReferendumStatus.Ongoing.Preparing(PreparingReason.WaitingForDeposit, timeOutIn) + } + } + } + } + + private fun confirmingStatusFromOnChain( + status: OnChainReferendumStatus.Ongoing, + blockDurationEstimator: BlockDurationEstimator, + track: TrackInfo, + referendumId: ReferendumId, + thresholdByReferenda: Map, + ): ReferendumStatus.Ongoing { + val decidingStatus = status.deciding!! + val referendumThreshold = thresholdByReferenda[referendumId] + val delayedPassing = referendumThreshold?.projectedPassing() + + val isCurrentlyPassing = referendumThreshold?.currentlyPassing().orFalse() + val isPassingAfterDelay = delayedPassing != null && delayedPassing.passingInFuture + + return when { + // Confirmation period started block + isCurrentlyPassing && decidingStatus.confirming.tillOrNull() != null -> { + val approveBlock = decidingStatus.confirming.till() + val approveIn = blockDurationEstimator.timerUntil(approveBlock) + + ReferendumStatus.Ongoing.Confirming(approveIn = approveIn) + } + + // Deciding period that will be approved in delay + confirmation period block + isPassingAfterDelay -> { + val delay = delayedPassing!!.delayFraction + val blocksToConfirmationPeriod = (delay * track.decisionPeriod.toBigDecimal()).toBigInteger() + + val approveBlock = decidingStatus.since + blocksToConfirmationPeriod + track.confirmPeriod + val approveIn = blockDurationEstimator.timerUntil(approveBlock) + + ReferendumStatus.Ongoing.DecidingApprove(approveIn = approveIn) + } + + // Reject block + else -> { + val rejectBlock = decidingStatus.since + track.decisionPeriod + val rejectIn = blockDurationEstimator.timerUntil(rejectBlock) + + ReferendumStatus.Ongoing.DecidingReject(rejectIn) + } + } + } + + private suspend fun GovernanceSource.fetchExecutionBlocks( + onChainReferenda: Collection, + chain: Chain + ): Map { + val approvedReferendaIds = onChainReferenda.mapNotNull { referendum -> + referendum.id.takeIf { referendum.status is OnChainReferendumStatus.Approved } + } + return referenda.getReferendaExecutionBlocks(chain.id, approvedReferendaIds) + } + + private suspend fun GovernanceSource.fetchQueuePositions( + onChainReferenda: Collection, + chainId: ChainId, + ): Map { + val queuedReferenda = onChainReferenda.filter { it.status.inQueue() } + val tracksIdsToFetchQueues = queuedReferenda.mapNotNullTo(mutableSetOf(), OnChainReferendum::track) + + val queues = referenda.getTrackQueues(tracksIdsToFetchQueues, chainId) + + return queuedReferenda.associateBy( + keySelector = OnChainReferendum::id, + valueTransform = { + val track = it.track()!! // safe since referendum is ongoing + val queue = queues[track].orEmpty() + + queue.positionOf(it.id) + } + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/OffChainReferendumVotingSharedComputation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/OffChainReferendumVotingSharedComputation.kt new file mode 100644 index 0000000..ea69ac8 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/OffChainReferendumVotingSharedComputation.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.details + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.isFinished +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumVotingDetails +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import kotlinx.coroutines.CoroutineScope + +class OffChainReferendumVotingSharedComputation( + private val computationalCache: ComputationalCache, + private val governanceSourceRegistry: GovernanceSourceRegistry +) { + + suspend fun votingDetails( + onChainReferendum: OnChainReferendum, + governanceOption: SupportedGovernanceOption, + scope: CoroutineScope + ): OffChainReferendumVotingDetails? { + val referendumId = onChainReferendum.id + val isReferendumFinished = onChainReferendum.isFinished() + val key = buildKey(onChainReferendum, governanceOption) + return computationalCache.useCache(key, scope) { + if (isReferendumFinished) { + votingStatusForFinishedReferendum(governanceOption, referendumId) + } else { + votingStatusForOngoingReferendum(governanceOption, referendumId) + } + } + } + + private suspend fun votingStatusForOngoingReferendum( + governanceOption: SupportedGovernanceOption, + referendumId: ReferendumId + ): OffChainReferendumVotingDetails? { + return governanceSourceRegistry.sourceFor(governanceOption) + .convictionVoting + .abstainVotingDetails(referendumId, governanceOption.assetWithChain.chain) + } + + private suspend fun votingStatusForFinishedReferendum( + governanceOption: SupportedGovernanceOption, + referendumId: ReferendumId + ): OffChainReferendumVotingDetails? { + return governanceSourceRegistry.sourceFor(governanceOption) + .convictionVoting + .fullVotingDetails(referendumId, governanceOption.assetWithChain.chain) + } + + private fun buildKey( + onChainReferendum: OnChainReferendum, + governanceOption: SupportedGovernanceOption + ): String { + val referendumId = onChainReferendum.id + val isReferendumFinished = onChainReferendum.isFinished() + val chainId = governanceOption.assetWithChain.chain.id + val assetId = governanceOption.assetWithChain.asset.id + val version = governanceOption.additional.governanceType.name + return "REFERENDUM_VOTING:$referendumId:$isReferendumFinished:$chainId:$assetId:$version" + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/RealReferendumDetailsInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/RealReferendumDetailsInteractor.kt new file mode 100644 index 0000000..8247d81 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/RealReferendumDetailsInteractor.kt @@ -0,0 +1,316 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.details + +import com.google.gson.Gson +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.asLoaded +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PreImage +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.asOngoingOrNull +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.flattenCastingVotes +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.hash +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.proposal +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.proposerDeposit +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.submissionDeposit +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.track +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumVotingDetails +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.toTallyOrNull +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.vote.UserVote +import io.novafoundation.nova.feature_governance_api.data.repository.getTracksById +import io.novafoundation.nova.feature_governance_api.data.repository.preImageOf +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.data.thresold.gov1.asGovV1VotingThresholdOrNull +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumProposer +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumTrack +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.PreimagePreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumCall +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetails +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetailsInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline.State +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumVote +import io.novafoundation.nova.feature_governance_impl.data.preimage.PreImageSizer +import io.novafoundation.nova.feature_governance_impl.domain.referendum.common.ReferendaConstructor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.common.constructReferendumStatus +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumPreImageParser +import io.novafoundation.nova.feature_governance_impl.domain.track.mapTrackInfoToTrack +import io.novafoundation.nova.runtime.ext.accountIdOrNull +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.withIndex + +class RealReferendumDetailsInteractor( + private val preImageParser: ReferendumPreImageParser, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val chainStateRepository: ChainStateRepository, + private val referendaConstructor: ReferendaConstructor, + private val preImageSizer: PreImageSizer, + private val callFormatter: Gson, + private val identityRepository: OnChainIdentityRepository, + private val offChainReferendumVotingSharedComputation: OffChainReferendumVotingSharedComputation +) : ReferendumDetailsInteractor { + + override fun referendumDetailsFlow( + referendumId: ReferendumId, + selectedGovernanceOption: SupportedGovernanceOption, + voterAccountId: AccountId?, + coroutineScope: CoroutineScope + ): Flow { + return flowOfAll { referendumDetailsFlowSuspend(referendumId, selectedGovernanceOption, voterAccountId, coroutineScope) } + } + + override suspend fun detailsFor(preImage: PreImage, chain: Chain): ReferendumCall? { + return preImageParser.parse(preImage, chain) + } + + override suspend fun previewFor(preImage: PreImage): PreimagePreview { + val shouldDisplay = preImageSizer.satisfiesSizeConstraint( + preImageSize = preImage.encodedCall.size.toBigInteger(), + constraint = PreImageSizer.SizeConstraint.SMALL + ) + + return if (shouldDisplay) { + val formatted = callFormatter.toJson(preImage.call) + + PreimagePreview.Display(formatted) + } else { + PreimagePreview.TooLong + } + } + + override suspend fun isSupportAbstainVoting(selectedGovernanceOption: SupportedGovernanceOption): Boolean { + return governanceSourceRegistry.sourceFor(selectedGovernanceOption) + .convictionVoting + .isAbstainVotingAvailable() + } + + /** + * Emmit null if referendum is not exist + */ + private suspend fun referendumDetailsFlowSuspend( + referendumId: ReferendumId, + selectedGovernanceOption: SupportedGovernanceOption, + voterAccountId: AccountId?, + coroutineScope: CoroutineScope + ): Flow { + val chain = selectedGovernanceOption.assetWithChain.chain + + val governanceSource = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + + val offChainInfoDeferred = coroutineScope.async { governanceSource.offChainInfo.referendumDetails(referendumId, chain) } + val electorateDeferred = coroutineScope.async { governanceSource.referenda.electorate(chain.id) } + val tracksByIdDeferred = coroutineScope.async { governanceSource.referenda.getTracksById(chain.id) } + + return combineTransform( + referendumFlow(governanceSource, selectedGovernanceOption, referendumId, coroutineScope), + chainStateRepository.currentBlockNumberFlow(chain.timelineChainIdOrSelf()) + ) { referendumWithVotingDetails, currentBlockNumber -> + // If null it means that referendum with given id doesn't exist + if (referendumWithVotingDetails == null) { + emit(null) + return@combineTransform + } + + val onChainReferendum = referendumWithVotingDetails.onChainReferendum + val offChainVotingDetails = referendumWithVotingDetails.votingDetails + val offChainInfo = offChainInfoDeferred.await() + val electorate = electorateDeferred.await() + val tracksById = tracksByIdDeferred.await() + + val preImage = governanceSource.preImageRepository.preImageOf(onChainReferendum.proposal(), chain.id) + + val track = (onChainReferendum.track() ?: offChainVotingDetails.dataOrNull?.trackId)?.let(tracksById::get) + + val vote = voterAccountId?.let { + val voteByReferendumId = governanceSource.convictionVoting.votingFor(voterAccountId, chain.id) + .flattenCastingVotes() + + val onChainVote = voteByReferendumId[onChainReferendum.id] + + governanceSource.constructVoterVote(it, chain, referendumId, onChainVote) + } + + val voting = referendaConstructor.constructReferendumVoting( + tally = onChainReferendum.status.asOngoingOrNull()?.tally ?: offChainVotingDetails.dataOrNull?.votingInfo?.toTallyOrNull(), + offChainVotingDetails = offChainVotingDetails, + currentBlockNumber = currentBlockNumber, + electorate = electorate, + ) + + val threshold = referendaConstructor.constructReferendumThreshold( + referendum = onChainReferendum, + tracksById = tracksById, + currentBlockNumber = currentBlockNumber, + electorate = electorate + ) + + val currentStatus = referendaConstructor.constructReferendumStatus( + selectedGovernanceOption = selectedGovernanceOption, + onChainReferendum = onChainReferendum, + tracksById = tracksById, + currentBlockNumber = currentBlockNumber, + votingByReferenda = mapOf(referendumId to voting), + thresholdByReferenda = mapOf(referendumId to threshold) + ) + + val referendumDetails = ReferendumDetails( + id = onChainReferendum.id, + offChainMetadata = offChainInfo?.let { + ReferendumDetails.OffChainMetadata( + title = it.title, + description = it.description, + ) + }, + proposer = constructProposer(onChainReferendum, offChainInfo, chain), + onChainMetadata = onChainReferendum.proposal()?.hash()?.let { hash -> + ReferendumDetails.OnChainMetadata( + preImage = preImage, + preImageHash = hash + ) + }, + track = track?.let { ReferendumTrack(mapTrackInfoToTrack(it), sameWithOther = tracksById.size == 1) }, + voting = voting, + threshold = threshold, + timeline = ReferendumTimeline( + currentStatus = currentStatus, + pastEntries = offChainInfo.pastTimeLine(currentStatus) mergeWith referendaConstructor.constructPastTimeline( + chain = chain, + onChainReferendum = onChainReferendum, + calculatedStatus = currentStatus, + currentBlockNumber = currentBlockNumber + ) + ), + userVote = vote, + fullDetails = ReferendumDetails.FullDetails( + deposit = onChainReferendum.status.asOngoingOrNull()?.proposerDeposit(), + voteThreshold = onChainReferendum.status.asOngoingOrNull()?.threshold?.asGovV1VotingThresholdOrNull(), + approvalCurve = track?.minApproval, + supportCurve = track?.minSupport, + ) + ) + + emit(referendumDetails) + } + } + + @Suppress("MoveVariableDeclarationIntoWhen") + private suspend fun GovernanceSource.constructVoterVote( + voter: AccountId, + chain: Chain, + referendumId: ReferendumId, + onChainVote: AccountVote?, + ): ReferendumVote? { + val historicalVote = delegationsRepository.historicalVoteOf(voter, referendumId, chain) + + val offChainReferendumVote = when (historicalVote) { + is UserVote.Delegated -> { + val identity = identityRepository.getIdentityFromId(chain.id, historicalVote.delegate) + + ReferendumVote.UserDelegated( + who = historicalVote.delegate, + whoIdentity = identity?.let(::Identity), + vote = historicalVote.vote + ) + } + + is UserVote.Direct -> ReferendumVote.UserDirect(historicalVote.vote) + + null -> null + } + + val onChainReferendumVote = onChainVote?.let(ReferendumVote::UserDirect) + + // priority is for on chain data + return offChainReferendumVote ?: onChainReferendumVote + } + + private fun constructProposer( + onChainReferendum: OnChainReferendum, + offChainReferendumDetails: OffChainReferendumDetails?, + chain: Chain, + ): ReferendumProposer? { + val submissionDeposit = onChainReferendum.submissionDeposit() + val offChainProposerAddress = offChainReferendumDetails?.proposerAddress + + val proposerAccountId = when { + submissionDeposit != null -> submissionDeposit.who + offChainProposerAddress != null -> chain.accountIdOrNull(offChainProposerAddress) + else -> null + } + + return proposerAccountId?.let { + ReferendumProposer( + accountId = it, + offChainNickname = offChainReferendumDetails?.proposerName + ) + } + } + + private infix fun List.mergeWith(another: List): List { + return (this + another).distinctBy { it.state } + } + + private fun OffChainReferendumDetails?.pastTimeLine(currentStatus: ReferendumStatus): List { + return this?.timeLine?.let { timeLine -> + timeLine.filter { it.state.isPastStateAt(currentStatus) } + }.orEmpty() + } + + private fun State.isPastStateAt(currentStatus: ReferendumStatus): Boolean { + return when (this) { + // When currentStatus is Approved we will display it as a current status so no extra entry should be present in the past timeline + State.APPROVED -> currentStatus is ReferendumStatus.Executed + else -> true + } + } + + private suspend fun referendumFlow( + governanceSource: GovernanceSource, + governanceOption: SupportedGovernanceOption, + referendumId: ReferendumId, + coroutineScope: CoroutineScope + ): Flow { + return governanceSource.referenda.onChainReferendumFlow(governanceOption.assetWithChain.chain.id, referendumId) + .withIndex() + .transformLatest { (index, onChainReferendum) -> + if (onChainReferendum == null) { + emit(null) + return@transformLatest + } + + // First time emmit without voting details + if (index == 0) { + emit(OnChainReferendaWithVotingDetails(onChainReferendum, votingDetails = ExtendedLoadingState.Loading)) + } + + emit( + OnChainReferendaWithVotingDetails( + onChainReferendum, + votingDetails = offChainReferendumVotingSharedComputation.votingDetails(onChainReferendum, governanceOption, coroutineScope) + .asLoaded() + ) + ) + } + } +} + +private class OnChainReferendaWithVotingDetails( + val onChainReferendum: OnChainReferendum, + val votingDetails: ExtendedLoadingState +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/ReferendumDetailsRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/ReferendumDetailsRepository.kt new file mode 100644 index 0000000..92d366a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/ReferendumDetailsRepository.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.details + +import io.novafoundation.nova.core.model.Language +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_impl.data.offchain.referendum.summary.v2.ReferendumSummaryDataSource +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface ReferendumDetailsRepository { + + suspend fun loadSummaries(chain: Chain, ids: List, selectedLanguage: Language): Map? +} + +class RealReferendumDetailsRepository( + private val referendumSummaryDataSource: ReferendumSummaryDataSource +) : ReferendumDetailsRepository { + + override suspend fun loadSummaries(chain: Chain, ids: List, selectedLanguage: Language): Map? { + return referendumSummaryDataSource.loadSummaries(chain, ids, selectedLanguage.iso639Code) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/ReferendumPreImageParser.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/ReferendumPreImageParser.kt new file mode 100644 index 0000000..57eb02f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/ReferendumPreImageParser.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call + +import android.util.Log +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PreImage +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumCall +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface ReferendumPreImageParser { + + suspend fun parse(preImage: PreImage, chain: Chain): ReferendumCall? + + suspend fun parsePreimageCall(call: GenericCall.Instance, chain: Chain): ReferendumCall? +} + +interface ReferendumCallAdapter { + + suspend fun fromCall(call: GenericCall.Instance, context: ReferendumCallParseContext): ReferendumCall? +} + +interface ReferendumCallParseContext { + + val chain: Chain + + /** + * Can be used to resolve nested calls in compound calls like batch or proxy calls + * Do not pass the same call you're processing otherwise you'll get a stack overflow + */ + suspend fun parse(call: GenericCall.Instance): ReferendumCall? +} + +class RealReferendumPreImageParser( + private val knownAdapters: Collection, +) : ReferendumPreImageParser { + + override suspend fun parse(preImage: PreImage, chain: Chain): ReferendumCall? { + return parsePreimageCall(preImage.call, chain) + } + + override suspend fun parsePreimageCall(call: GenericCall.Instance, chain: Chain): ReferendumCall? { + val context = RealReferendumCallParseContext(chain, knownAdapters) + + return withContext(Dispatchers.IO) { + context.parse(call) + } + } + + private inner class RealReferendumCallParseContext( + override val chain: Chain, + private val knownAdapters: Collection, + ) : ReferendumCallParseContext { + + override suspend fun parse(call: GenericCall.Instance): ReferendumCall? { + return knownAdapters.tryFindNonNull { adapter -> + runCatching { adapter.fromCall(call, context = this) } + .onFailure { Log.e("ReferendumPreImageParser", "Adapter ${adapter::class.simpleName} failed to parse call", it) } + .getOrNull() + } + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/batch/BatchAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/batch/BatchAdapter.kt new file mode 100644 index 0000000..1deb2fa --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/batch/BatchAdapter.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.batch + +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumCall +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumCallAdapter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumCallParseContext +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class BatchAdapter : ReferendumCallAdapter { + + override suspend fun fromCall(call: GenericCall.Instance, context: ReferendumCallParseContext): ReferendumCall? { + if (!call.instanceOf(Modules.UTILITY, "batch", "batch_all", "force_batch")) return null + + val innerCalls = call.arguments["calls"].cast>() + + return innerCalls.mapNotNull { context.parse(it) } + .reduceOrNull { acc, referendumCall -> acc.combineWith(referendumCall) } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/treasury/TreasuryApproveProposalAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/treasury/TreasuryApproveProposalAdapter.kt new file mode 100644 index 0000000..f8e9ccb --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/treasury/TreasuryApproveProposalAdapter.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.treasury + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TreasuryProposal +import io.novafoundation.nova.feature_governance_api.data.repository.TreasuryRepository +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumCall +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumCallAdapter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumCallParseContext +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class TreasuryApproveProposalAdapter( + private val treasuryRepository: TreasuryRepository +) : ReferendumCallAdapter { + + override suspend fun fromCall(call: GenericCall.Instance, context: ReferendumCallParseContext): ReferendumCall? { + if (!call.instanceOf(Modules.TREASURY, "approve_proposal")) return null + + val id = bindNumber(call.arguments["proposal_id"]) + + val proposal = treasuryRepository.getTreasuryProposal(context.chain.id, TreasuryProposal.Id(id)) ?: return null + + return ReferendumCall.TreasuryRequest( + amount = proposal.amount, + chainAsset = context.chain.utilityAsset, + beneficiary = proposal.beneficiary + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/treasury/TreasurySpendAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/treasury/TreasurySpendAdapter.kt new file mode 100644 index 0000000..c25e516 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/treasury/TreasurySpendAdapter.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.treasury + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNonce +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumCall +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumCallAdapter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumCallParseContext +import io.novafoundation.nova.feature_xcm_api.asset.LocatableMultiAsset +import io.novafoundation.nova.feature_xcm_api.asset.bindVersionedLocatableMultiAsset +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.multiLocation.accountId +import io.novafoundation.nova.feature_xcm_api.multiLocation.bindVersionedMultiLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class TreasurySpendAdapter( + private val chainLocationConverterFactory: ChainMultiLocationConverterFactory, + private val assetLocationConverterFactory: MultiLocationConverterFactory +) : ReferendumCallAdapter { + + override suspend fun fromCall( + call: GenericCall.Instance, + context: ReferendumCallParseContext + ): ReferendumCall? { + if (!call.instanceOf(Modules.TREASURY, "spend")) return null + + val amount = bindNonce(call.arguments["amount"]) + val beneficiaryLocation = bindVersionedMultiLocation(call.arguments["beneficiary"]).xcm + val asset = bindVersionedLocatableMultiAsset(call.arguments["asset_kind"]) + + return ReferendumCall.TreasuryRequest( + amount = amount, + beneficiary = beneficiaryLocation.accountId()?.value ?: return null, + chainAsset = resolveChainAsset(asset.xcm, context.chain) ?: return null + ) + } + + private suspend fun resolveChainAsset(locatableMultiAsset: LocatableMultiAsset, chain: Chain): Chain.Asset? { + val chainLocationConverter = chainLocationConverterFactory.resolveSelfAndChildrenParachains(chain) + val resolvedChain = chainLocationConverter.toChain(locatableMultiAsset.location) ?: return null + + val assetLocationConverter = assetLocationConverterFactory.resolveLocalAssets(resolvedChain) + return assetLocationConverter.toChainAsset(locatableMultiAsset.assetId.multiLocation) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/treasury/TreasurySpendLocalAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/treasury/TreasurySpendLocalAdapter.kt new file mode 100644 index 0000000..5fc0fe1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/details/call/treasury/TreasurySpendLocalAdapter.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.treasury + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindNonce +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumCall +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumCallAdapter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumCallParseContext +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class TreasurySpendLocalAdapter : ReferendumCallAdapter { + + override suspend fun fromCall( + call: GenericCall.Instance, + context: ReferendumCallParseContext + ): ReferendumCall? { + if (!call.instanceOf(Modules.TREASURY, "spend_local")) return null + + val amount = bindNonce(call.arguments["amount"]) + val beneficiary = bindAccountIdentifier(call.arguments["beneficiary"]) + + return ReferendumCall.TreasuryRequest( + amount = amount, + beneficiary = beneficiary, + chainAsset = context.chain.utilityAsset + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/RealReferendaListInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/RealReferendaListInteractor.kt new file mode 100644 index 0000000..ac7c653 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/RealReferendaListInteractor.kt @@ -0,0 +1,205 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.list + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.utils.search.SearchComparator +import io.novafoundation.nova.common.utils.search.SearchFilter +import io.novafoundation.nova.common.utils.applyFilter +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.search.filterWith +import io.novafoundation.nova.common.utils.search.CachedPhraseSearch +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceAdditionalState +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.data.source.trackLocksFlowOrEmpty +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimScheduleCalculator +import io.novafoundation.nova.feature_governance_api.domain.locks.RealClaimScheduleCalculator +import io.novafoundation.nova.feature_governance_api.domain.locks.hasClaimableLocks +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.DelegatedState +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.GovernanceLocksOverview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListState +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumGroup +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.Voter +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.getName +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.user +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumTypeFilter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.filtering.ReferendaFilteringProvider +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.repository.ReferendaCommonRepository +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.sorting.ReferendaSortingProvider +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.SelectableAssetAndOption +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.hash.isPositive +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class RealReferendaListInteractor( + private val governanceSharedState: GovernanceSharedState, + private val referendaCommonRepository: ReferendaCommonRepository, + private val referendaSharedComputation: ReferendaSharedComputation, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val referendaSortingProvider: ReferendaSortingProvider, + private val referendaFilteringProvider: ReferendaFilteringProvider, +) : ReferendaListInteractor { + + override suspend fun availableVoteAmount(option: SelectableAssetAndOption): Balance { + val additional = option.option.additional + require(additional is GovernanceAdditionalState) { + "Not a governance state: ${option.option}" + } + val governanceSource = governanceSourceRegistry.sourceFor(additional.governanceType) + return governanceSource.convictionVoting.maxAvailableForVote(option.asset) + } + + override fun searchReferendaListStateFlow( + metaAccount: MetaAccount, + queryFlow: Flow, + voterAccountId: AccountId?, + selectedGovernanceOption: SupportedGovernanceOption, + coroutineScope: CoroutineScope + ): Flow>> { + return flowOfAll { + combine( + queryFlow, + referendaSharedComputation.referenda(metaAccount, voterAccountId?.let(Voter.Companion::user), selectedGovernanceOption, coroutineScope) + ) { query, referendaLoadingState -> + referendaLoadingState.map { referendaState -> + val referenda = referendaState.referenda + if (query.isEmpty()) { + val sorting = referendaSortingProvider.getReferendumSorting() + + return@map referenda.sortedWith(sorting) + } + + val lowercaseQuery = query.lowercase() + + val phraseSearch = CachedPhraseSearch(lowercaseQuery) + + val searchFilter = SearchFilter.Builder(lowercaseQuery) { it.getName()?.lowercase() } + .addPhraseSearch(phraseSearch) + .or { it.id.toString() } + .build() + + val searchComparator = SearchComparator.Builder(lowercaseQuery) { it.getName()?.lowercase() } + .addPhraseSearch(phraseSearch) + .and { it.id.toString() } + .build() + + referenda.filterWith(searchFilter) + .sortedWith(searchComparator) + } + } + } + } + + override fun referendaListStateFlow( + metaAccount: MetaAccount, + voterAccountId: AccountId?, + selectedGovernanceOption: SupportedGovernanceOption, + coroutineScope: CoroutineScope, + referendumTypeFilterFlow: Flow + ): Flow> { + return flowOfAll { + val voter = voterAccountId?.let(Voter.Companion::user) + val referendaStateFlow = referendaSharedComputation.referenda( + metaAccount, + voter, + selectedGovernanceOption, + coroutineScope + ) + + val chain = selectedGovernanceOption.assetWithChain.chain + val asset = selectedGovernanceOption.assetWithChain.asset + val governanceSource = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + val undecidingTimeout = governanceSource.referenda.undecidingTimeout(chain.id) + val voteLockingPeriod = governanceSource.convictionVoting.voteLockingPeriod(chain.id) + val delegationSupported = governanceSource.delegationsRepository.isDelegationSupported(chain) + + val trackLocksFlow = governanceSource.convictionVoting.trackLocksFlowOrEmpty(voter?.accountId, asset.fullId) + + combine(referendaStateFlow, trackLocksFlow, referendumTypeFilterFlow) { referendaLoadingState, trackLocks, referendumFilter -> + referendaLoadingState.map { referendaState -> + val claimScheduleCalculator = with(referendaState) { + RealClaimScheduleCalculator(voting, currentBlockNumber, onChainReferenda, tracksById, undecidingTimeout, voteLockingPeriod, trackLocks) + } + val locksOverview = claimScheduleCalculator.governanceLocksOverview() + + val filteredReferenda = referendaState.referenda.applyFilter(referendumFilter) + + val availableToVoteReferenda = referendaFilteringProvider.filterAvailableToVoteReferenda(referendaState.referenda, referendaState.voting) + + ReferendaListState( + groupedReferenda = sortReferendaPreviews(filteredReferenda), + availableToVoteReferenda = availableToVoteReferenda, + locksOverview = locksOverview, + delegated = determineDelegatedState(referendaState.voting, delegationSupported), + ) + } + } + } + } + + private fun ReferendumPreview.group(): ReferendumGroup { + return when (status) { + is ReferendumStatus.Executed, + is ReferendumStatus.Approved, + is ReferendumStatus.NotExecuted -> ReferendumGroup.COMPLETED + + else -> ReferendumGroup.ONGOING + } + } + + private suspend fun sortReferendaPreviews(referenda: List) = + referenda.groupBy { it.group() } + .mapValues { (group, referenda) -> + val sorting = referendaSortingProvider.getReferendumSorting(group) + + referenda.sortedWith(sorting) + }.toSortedMap(referendaSortingProvider.getGroupSorting()) + + private fun ClaimScheduleCalculator.governanceLocksOverview(): GovernanceLocksOverview? { + val totalLock = totalGovernanceLock() + + return if (totalLock.isPositive()) { + val claimableSchedule = estimateClaimSchedule() + + GovernanceLocksOverview( + locked = totalLock, + hasClaimableLocks = claimableSchedule.hasClaimableLocks() + ) + } else { + null + } + } + + private fun determineDelegatedState(voting: Map, delegationsSupported: Boolean): DelegatedState { + if (!delegationsSupported) return DelegatedState.DelegationNotSupported + + val delegatedAmount = voting.values.filterIsInstance() + .maxOfOrNull { it.amount } + + return if (delegatedAmount != null) { + DelegatedState.Delegated(delegatedAmount) + } else { + DelegatedState.NotDelegated + } + } + + override fun votedReferendaListFlow(voter: Voter, onlyRecentVotes: Boolean): Flow> { + return flowOfAll { + val selectedGovernanceOption = governanceSharedState.selectedOption() + + referendaCommonRepository.referendaListFlow(voter, selectedGovernanceOption, onlyRecentVotes) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/ReferendaSharedComputation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/ReferendaSharedComputation.kt new file mode 100644 index 0000000..5ceb2ac --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/ReferendaSharedComputation.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.list + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.Voter +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.repository.ReferendaCommonRepository +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +class ReferendaSharedComputation( + private val computationalCache: ComputationalCache, + private val referendaCommonRepository: ReferendaCommonRepository +) { + + suspend fun referenda( + metaAccount: MetaAccount, + voter: Voter?, + governanceOption: SupportedGovernanceOption, + scope: CoroutineScope + ): Flow> { + val metaId = metaAccount.id + val chainId = governanceOption.assetWithChain.chain.id + val assetId = governanceOption.assetWithChain.asset.id + val version = governanceOption.additional.governanceType.name + val key = "REFERENDA:$metaId:$chainId:$assetId:$version" + + return computationalCache.useSharedFlow(key, scope) { + referendaCommonRepository.referendaStateFlow(voter, governanceOption) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/filtering/ReferendaFilteringProvider.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/filtering/ReferendaFilteringProvider.kt new file mode 100644 index 0000000..41604c6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/filtering/ReferendaFilteringProvider.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.list.filtering + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus + +interface ReferendaFilteringProvider { + + fun filterAvailableToVoteReferenda(referenda: List, voting: Map): List +} + +class RealReferendaFilteringProvider : ReferendaFilteringProvider { + + override fun filterAvailableToVoteReferenda(referenda: List, voting: Map): List { + val delegationTracks = voting.filterValues { it is Voting.Delegating } + .keys + + return referenda.filter { + it.status is ReferendumStatus.Ongoing && + it.referendumVote == null && + it.track?.track?.id !in delegationTracks + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/repository/ReferendaCommonRepository.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/repository/ReferendaCommonRepository.kt new file mode 100644 index 0000000..f58a8cd --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/repository/ReferendaCommonRepository.kt @@ -0,0 +1,328 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.list.repository + +import io.novafoundation.nova.common.address.get +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Proposal +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.asOngoingOrNull +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.flattenCastingVotes +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.hash +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.proposal +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.track +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.referendum.OffChainReferendumPreview +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.vote.UserVote +import io.novafoundation.nova.feature_governance_api.data.repository.PreImageRequest +import io.novafoundation.nova.feature_governance_api.data.repository.getTracksById +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumTrack +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaState +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumProposal +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumVote +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.Voter +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.RecentVotesTimePointProvider +import io.novafoundation.nova.feature_governance_impl.domain.referendum.common.ReferendaConstructor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.sorting.ReferendaSortingProvider +import io.novafoundation.nova.feature_governance_impl.domain.track.mapTrackInfoToTrack +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novasama.substrate_sdk_android.extensions.requireHexPrefix +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import java.math.BigInteger + +interface ReferendaCommonRepository { + + suspend fun referendaStateFlow(voter: Voter?, governanceOption: SupportedGovernanceOption): Flow> + + suspend fun referendaListFlow( + voter: Voter, + selectedGovernanceOption: SupportedGovernanceOption, + onlyRecentVotes: Boolean + ): Flow> +} + +class RealReferendaCommonRepository( + private val chainStateRepository: ChainStateRepository, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val referendaConstructor: ReferendaConstructor, + private val referendaSortingProvider: ReferendaSortingProvider, + private val identityRepository: OnChainIdentityRepository, + private val recentVotesTimePointProvider: RecentVotesTimePointProvider +) : ReferendaCommonRepository { + + override suspend fun referendaStateFlow(voter: Voter?, governanceOption: SupportedGovernanceOption): Flow> { + val chain = governanceOption.assetWithChain.chain + + val governanceSource = governanceSourceRegistry.sourceFor(governanceOption) + val tracksById = governanceSource.referenda.getTracksById(chain.id) + + return combine( + governanceSource.referendaOffChainInfoFlow(chain), + chainStateRepository.currentBlockNumberFlow(chain.timelineChainIdOrSelf()) + ) { offChainInfo, currentBlockNumber -> + coroutineScope { + val onChainReferenda = async { governanceSource.referenda.getAllOnChainReferenda(chain.id) } + val electorate = async { governanceSource.referenda.electorate(chain.id) } + + val onChainVoting = async { voter?.accountId?.let { governanceSource.convictionVoting.votingFor(it, chain.id) }.orEmpty() } + + val voterVotes = governanceSource.constructVoterVotes(voter, onChainVoting.await(), chain, onlyRecentVotes = false) + + val referenda = governanceSource.constructReferendumPreviews( + voting = voterVotes, + onChainReferenda = onChainReferenda.await(), + selectedGovernanceOption = governanceOption, + tracksById = tracksById, + currentBlockNumber = currentBlockNumber, + offChainInfo = offChainInfo, + electorate = electorate.await() + ) + + val onChainReferendaById = onChainReferenda.await().associateBy(OnChainReferendum::id) + + ReferendaState(onChainVoting.await(), currentBlockNumber, onChainReferendaById, referenda, tracksById) + } + }.withSafeLoading() + } + + override suspend fun referendaListFlow( + voter: Voter, + selectedGovernanceOption: SupportedGovernanceOption, + onlyRecentVotes: Boolean + ): Flow> { + val chain = selectedGovernanceOption.assetWithChain.chain + + val governanceSource = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + val tracksById = governanceSource.referenda.getTracksById(chain.id) + + return combine( + governanceSource.referendaOffChainInfoFlow(chain), + chainStateRepository.currentBlockNumberFlow(chain.timelineChainIdOrSelf()) + ) { offChainInfo, currentBlockNumber -> + coroutineScope { + val onChainReferenda = async { governanceSource.referenda.getAllOnChainReferenda(chain.id) } + val electorate = async { governanceSource.referenda.electorate(chain.id) } + + val onChainVoting = async { governanceSource.convictionVoting.votingFor(voter.accountId, chain.id) } + + val voterVotes = governanceSource.constructVoterVotes(voter, onChainVoting.await(), chain, onlyRecentVotes) + + val referenda = governanceSource.constructReferendumPreviews( + voting = voterVotes, + onChainReferenda = onChainReferenda.await(), + selectedGovernanceOption = selectedGovernanceOption, + tracksById = tracksById, + currentBlockNumber = currentBlockNumber, + offChainInfo = offChainInfo, + electorate = electorate.await() + ) + + val sorting = referendaSortingProvider.getReferendumSorting() + referenda.onlyVoted().sortedWith(sorting) + } + } + } + + private fun GovernanceSource.referendaOffChainInfoFlow(chain: Chain): Flow> { + return flow { + emit(emptyMap()) + + val offChainInfo = offChainInfo.referendumPreviews(chain) + .associateBy(OffChainReferendumPreview::referendumId) + + emit(offChainInfo) + } + } + + private fun List.onlyVoted(): List { + return filter { it.referendumVote != null } + } + + private suspend fun GovernanceSource.constructReferendumPreviews( + voting: Map, + onChainReferenda: Collection, + selectedGovernanceOption: SupportedGovernanceOption, + tracksById: Map, + currentBlockNumber: BlockNumber, + offChainInfo: Map, + electorate: BigInteger + ): List { + val proposals = constructReferendaProposals(onChainReferenda, selectedGovernanceOption.assetWithChain.chain) + + val votingsById = onChainReferenda.associateBy( + keySelector = { it.id }, + valueTransform = { + referendaConstructor.constructReferendumVoting( + tally = it.status.asOngoingOrNull()?.tally, + currentBlockNumber = currentBlockNumber, + electorate = electorate, + offChainVotingDetails = ExtendedLoadingState.Loaded(null) + ) + } + ) + + val thresholdById = onChainReferenda.associateBy( + keySelector = { it.id }, + valueTransform = { + referendaConstructor.constructReferendumThreshold( + referendum = it, + tracksById = tracksById, + currentBlockNumber = currentBlockNumber, + electorate = electorate + ) + } + ) + + val statuses = referendaConstructor.constructReferendaStatuses( + selectedGovernanceOption = selectedGovernanceOption, + onChainReferenda = onChainReferenda, + tracksById = tracksById, + currentBlockNumber = currentBlockNumber, + votingByReferenda = votingsById, + thresholdByReferenda = thresholdById + ) + + val referenda = onChainReferenda.map { onChainReferendum -> + ReferendumPreview( + id = onChainReferendum.id, + offChainMetadata = offChainInfo[onChainReferendum.id]?.title?.let { + ReferendumPreview.OffChainMetadata(it) + }, + onChainMetadata = proposals[onChainReferendum.id] + ?.let(ReferendumPreview::OnChainMetadata), + track = onChainReferendum.track()?.let { trackId -> + tracksById[trackId]?.let { trackInfo -> + ReferendumTrack( + track = mapTrackInfoToTrack(trackInfo), + sameWithOther = tracksById.size == 1 + ) + } + }, + status = statuses.getValue(onChainReferendum.id), + voting = votingsById[onChainReferendum.id], + threshold = thresholdById[onChainReferendum.id], + referendumVote = voting[onChainReferendum.id] + ) + } + return referenda + } + + private suspend fun GovernanceSource.constructVoterVotes( + voter: Voter?, + onChainVoting: Map, + chain: Chain, + onlyRecentVotes: Boolean + ): Map { + return when (voter?.type) { + null -> emptyMap() + + Voter.Type.USER -> { + val historicalVoting = delegationsRepository.allHistoricalVotesOf(voter.accountId, chain).orEmpty() + val delegatesAccountIds = historicalVoting.values.mapNotNull { + it.castOrNull()?.delegate + } + val identities = identityRepository.getIdentitiesFromIds(delegatesAccountIds, chain.id) + + val offChainVotesHistory = historicalVoting.mapValues { (_, userVote) -> + when (userVote) { + is UserVote.Delegated -> ReferendumVote.UserDelegated( + who = userVote.delegate, + whoIdentity = identities[userVote.delegate]?.display?.let(::Identity), + vote = userVote.vote + ) + + is UserVote.Direct -> ReferendumVote.UserDirect(userVote.vote) + } + } + + val onChainVotesHistory = onChainVoting.flattenCastingVotes().mapValues { (_, accountVote) -> + ReferendumVote.UserDirect(accountVote) + } + + // priority is for on chain data + offChainVotesHistory + onChainVotesHistory + } + + Voter.Type.ACCOUNT -> { + val recentVotesTimePointThreshold = if (onlyRecentVotes) { + recentVotesTimePointProvider.getTimePointThresholdForChain(chain) + } else { + null + } + + val historicalVoting = delegationsRepository.directHistoricalVotesOf(voter.accountId, chain, recentVotesTimePointThreshold) + val identity = identityRepository.getIdentityFromId(chain.id, voter.accountId) + ?.display?.let(::Identity) + + val offChainVotes = historicalVoting.orEmpty().mapValues { (_, directVote) -> + ReferendumVote.OtherAccount(voter.accountId, identity, directVote.vote) + } + + val onChainVotes = onChainVoting.flattenCastingVotes().mapValues { (_, accountVote) -> + ReferendumVote.OtherAccount(voter.accountId, identity, accountVote) + } + + if (onlyRecentVotes) { + // we do not take on-chain votes with recent votes limitations since we cannot filter on-chain votes by time + offChainVotes + } else { + offChainVotes + onChainVotes + } + } + } + } + + // Attempts to use call-based by proposals by either taking inlined call or fetching preimage if it's size does not exceed threshold + // Otherwise uses hash-based proposals + private suspend fun GovernanceSource.constructReferendaProposals( + onChainReferenda: Collection, + chain: Chain, + ): Map { + val preImageRequestsToFetch = onChainReferenda.mapNotNull { + when (val proposal = it.proposal()) { + is Proposal.Lookup -> PreImageRequest(proposal.hash, knownSize = proposal.callLength, fetchIf = PreImageRequest.FetchCondition.SMALL_SIZE) + is Proposal.Legacy -> PreImageRequest(proposal.hash, knownSize = null, fetchIf = PreImageRequest.FetchCondition.SMALL_SIZE) + else -> null + } + } + val preImages = preImageRepository.getPreimagesFor(preImageRequestsToFetch, chain.id) + + return onChainReferenda.associateBy( + keySelector = { it.id }, + valueTransform = { + when (val proposal = it.proposal()) { + is Proposal.Inline -> ReferendumProposal.Call(proposal.call) + is Proposal.Lookup, is Proposal.Legacy -> { + val hashHex = proposal.hash().toHexString() + val preImage = preImages[hashHex] + + if (preImage != null) { + ReferendumProposal.Call(preImage.call) + } else { + ReferendumProposal.Hash(hashHex.requireHexPrefix()) + } + } + + null -> null + } + } + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/sorting/ReferendaSortingProvider.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/sorting/ReferendaSortingProvider.kt new file mode 100644 index 0000000..7838361 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/sorting/ReferendaSortingProvider.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.list.sorting + +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumGroup +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview + +interface ReferendaSortingProvider { + + suspend fun getReferendumSorting(group: ReferendumGroup): Comparator + + suspend fun getGroupSorting(): Comparator + + suspend fun getReferendumSorting(): Comparator +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/sorting/TimeLeftSortingProvider.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/sorting/TimeLeftSortingProvider.kt new file mode 100644 index 0000000..af5c4cc --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/list/sorting/TimeLeftSortingProvider.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.list.sorting + +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumGroup +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus + +class RealReferendaSortingProvider : ReferendaSortingProvider { + + override suspend fun getReferendumSorting(group: ReferendumGroup): Comparator { + return when (group) { + ReferendumGroup.ONGOING -> getOngoingSorting() + ReferendumGroup.COMPLETED -> getCompletedSorting() + } + } + + override suspend fun getGroupSorting(): Comparator { + return compareBy { + when (it) { + ReferendumGroup.ONGOING -> 0 + ReferendumGroup.COMPLETED -> 1 + } + } + } + + override suspend fun getReferendumSorting(): Comparator { + return compareByDescending { it.id.value } + } + + private fun getOngoingSorting(): Comparator { + return compareBy { + when (val status = it.status) { + is ReferendumStatus.Ongoing.InQueue -> status.timeOutIn.millis + is ReferendumStatus.Ongoing.Preparing -> status.timeOutIn.millis + is ReferendumStatus.Ongoing.DecidingReject -> status.rejectIn.millis + is ReferendumStatus.Ongoing.DecidingApprove -> status.approveIn.millis + is ReferendumStatus.Ongoing.Confirming -> status.approveIn.millis + + is ReferendumStatus.Approved, + ReferendumStatus.Executed, + ReferendumStatus.NotExecuted.Cancelled, + ReferendumStatus.NotExecuted.Killed, + ReferendumStatus.NotExecuted.Rejected, + ReferendumStatus.NotExecuted.TimedOut -> Long.MAX_VALUE + } + } + } + + private fun getCompletedSorting(): Comparator { + return compareBy { + when (val status = it.status) { + is ReferendumStatus.Approved -> status.executeIn.millis + + // approved at the end + else -> Long.MAX_VALUE + } + }.thenByDescending { it.id.value } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/TinderGovBasketInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/TinderGovBasketInteractor.kt new file mode 100644 index 0000000..a18b1aa --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/TinderGovBasketInteractor.kt @@ -0,0 +1,145 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.model.TinderGovBasketItem +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.data.repository.tindergov.TinderGovBasketRepository +import io.novafoundation.nova.feature_governance_impl.data.repository.tindergov.TinderGovVotingPowerRepository +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.getCurrentAsset +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first + +interface TinderGovBasketInteractor { + + fun observeTinderGovBasket(): Flow> + + suspend fun getTinderGovBasket(): List + + suspend fun addItemToBasket(referendumId: ReferendumId, voteType: VoteType) + + suspend fun removeReferendumFromBasket(item: TinderGovBasketItem) + + suspend fun removeBasketItems(items: Collection) + + suspend fun isBasketEmpty(): Boolean + + suspend fun clearBasket() + + suspend fun getBasketItemsToRemove(coroutineScope: CoroutineScope): List + + suspend fun awaitAllItemsVoted(coroutineScope: CoroutineScope, basket: List) +} + +class RealTinderGovBasketInteractor( + private val governanceSharedState: GovernanceSharedState, + private val accountRepository: AccountRepository, + private val tinderGovBasketRepository: TinderGovBasketRepository, + private val tinderGovVotingPowerRepository: TinderGovVotingPowerRepository, + private val assetUseCase: AssetUseCase, + private val tinderGovInteractor: TinderGovInteractor, + private val governanceSourceRegistry: GovernanceSourceRegistry, +) : TinderGovBasketInteractor { + + override fun observeTinderGovBasket(): Flow> { + return flowOfAll { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = governanceSharedState.chain() + + tinderGovBasketRepository.observeBasket(metaAccount.id, chain.id) + } + } + + override suspend fun getTinderGovBasket(): List { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = governanceSharedState.chain() + + return tinderGovBasketRepository.getBasket(metaAccount.id, chain.id) + } + + override suspend fun addItemToBasket(referendumId: ReferendumId, voteType: VoteType) { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = governanceSharedState.chain() + + val votingPower = tinderGovVotingPowerRepository.getVotingPower(metaAccount.id, chain.id)!! + + tinderGovBasketRepository.add( + TinderGovBasketItem( + metaId = metaAccount.id, + chainId = chain.id, + referendumId = referendumId, + voteType = voteType, + conviction = votingPower.conviction, + amount = votingPower.amount + ) + ) + } + + override suspend fun removeReferendumFromBasket(item: TinderGovBasketItem) { + tinderGovBasketRepository.remove(item) + } + + override suspend fun removeBasketItems(items: Collection) { + tinderGovBasketRepository.remove(items) + } + + override suspend fun isBasketEmpty(): Boolean { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = governanceSharedState.chain() + + return tinderGovBasketRepository.isBasketEmpty(metaAccount.id, chain.id) + } + + override suspend fun clearBasket() { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = governanceSharedState.chain() + + tinderGovBasketRepository.clearBasket(metaAccount.id, chain.id) + } + + override suspend fun getBasketItemsToRemove(coroutineScope: CoroutineScope): List { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = governanceSharedState.chain() + val asset = assetUseCase.getCurrentAsset() + val basket = tinderGovBasketRepository.getBasket(metaAccount.id, chain.id) + + val availableToVoteReferenda = tinderGovInteractor.observeReferendaAvailableToVote(coroutineScope).first() + .mapToSet { it.id } + + val governanceSource = governanceSourceRegistry.sourceFor(governanceSharedState.selectedOption()) + return basket.filter { it.isItemNotAvailableToVote(availableToVoteReferenda, asset, governanceSource) } + } + + override suspend fun awaitAllItemsVoted(coroutineScope: CoroutineScope, basket: List) { + tinderGovInteractor.observeReferendaState(coroutineScope) + .filter { referendaState -> + val referenda = referendaState.referenda.associateBy { it.id } + val allBasketItemsVoted = basket.all { + val referendum = referenda[it.referendumId] + referendum?.referendumVote != null + } + + allBasketItemsVoted + }.first() + } + + private suspend fun TinderGovBasketItem.isItemNotAvailableToVote( + availableToVoteReferenda: Set, + asset: Asset, + governanceSource: GovernanceSource, + ): Boolean { + val notEnoughBalance = this.amount > governanceSource.convictionVoting.maxAvailableForVote(asset) + return (this.referendumId !in availableToVoteReferenda) || notEnoughBalance + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/TinderGovInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/TinderGovInteractor.kt new file mode 100644 index 0000000..a5a0993 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/TinderGovInteractor.kt @@ -0,0 +1,152 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov + +import io.novafoundation.nova.common.domain.filterLoaded +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.model.TinderGovBasketItem +import io.novafoundation.nova.feature_governance_api.data.model.VotingPower +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumCall +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaState +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.Voter +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.toCallOrNull +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.user +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.data.repository.tindergov.TinderGovVotingPowerRepository +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.call.ReferendumPreImageParser +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.ReferendaSharedComputation +import io.novafoundation.nova.feature_governance_impl.domain.referendum.list.filtering.ReferendaFilteringProvider +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.validation.StartStakingLandingValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.validation.startSwipeGovValidation +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.getCurrentAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +sealed interface VotingPowerState { + + object Empty : VotingPowerState + + class InsufficientAmount(val votingPower: VotingPower) : VotingPowerState + + object SufficientAmount : VotingPowerState +} + +interface TinderGovInteractor { + + fun observeReferendaState(coroutineScope: CoroutineScope): Flow + + fun observeReferendaAvailableToVote(coroutineScope: CoroutineScope): Flow> + + suspend fun getReferendumAmount(referendumPreview: ReferendumPreview): ReferendumCall.TreasuryRequest? + + suspend fun setVotingPower(votingPower: VotingPower) + + suspend fun getVotingPower(metaId: Long, chainId: ChainId): VotingPower? + + suspend fun getVotingPowerState(): VotingPowerState + + suspend fun awaitAllItemsVoted(coroutineScope: CoroutineScope, basket: List) + + fun startSwipeGovValidationSystem(): StartStakingLandingValidationSystem +} + +class RealTinderGovInteractor( + private val governanceSharedState: GovernanceSharedState, + private val referendaSharedComputation: ReferendaSharedComputation, + private val accountRepository: AccountRepository, + private val preImageParser: ReferendumPreImageParser, + private val tinderGovVotingPowerRepository: TinderGovVotingPowerRepository, + private val referendaFilteringProvider: ReferendaFilteringProvider, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val assetUseCase: AssetUseCase +) : TinderGovInteractor { + + override fun observeReferendaState(coroutineScope: CoroutineScope): Flow = flowOfAll { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = governanceSharedState.chain() + val accountId = metaAccount.accountIdIn(chain) + val voter = accountId?.let { Voter.user(accountId) } + + referendaSharedComputation.referenda( + metaAccount, + voter, + governanceSharedState.selectedOption(), + coroutineScope + ).filterLoaded() + } + + override fun observeReferendaAvailableToVote(coroutineScope: CoroutineScope): Flow> { + return observeReferendaState(coroutineScope) + .map { filterAvailableToVoteReferenda(it) } + } + + override suspend fun getReferendumAmount(referendumPreview: ReferendumPreview): ReferendumCall.TreasuryRequest? { + val selectedGovernanceOption = governanceSharedState.selectedOption() + val chain = selectedGovernanceOption.assetWithChain.chain + + val referendumProposal = referendumPreview.onChainMetadata?.proposal + val referendumProposalCall = referendumProposal?.toCallOrNull() + val referendumCall = referendumProposalCall?.let { preImageParser.parsePreimageCall(it.call, chain) } + + return referendumCall as? ReferendumCall.TreasuryRequest + } + + override suspend fun setVotingPower(votingPower: VotingPower) { + tinderGovVotingPowerRepository.setVotingPower(votingPower) + } + + override suspend fun getVotingPower(metaId: Long, chainId: ChainId): VotingPower? { + return tinderGovVotingPowerRepository.getVotingPower(metaId, chainId) + } + + override suspend fun getVotingPowerState(): VotingPowerState { + return withContext(Dispatchers.Default) { + val metaAccount = accountRepository.getSelectedMetaAccount() + val selectedOption = governanceSharedState.selectedOption() + val chain = selectedOption.assetWithChain.chain + val votingPower = getVotingPower(metaAccount.id, chain.id) ?: return@withContext VotingPowerState.Empty + + val asset = assetUseCase.getCurrentAsset() + val governanceSource = governanceSourceRegistry.sourceFor(selectedOption) + val maxAvailableForVote = governanceSource.convictionVoting.maxAvailableForVote(asset) + + if (maxAvailableForVote >= votingPower.amount) { + VotingPowerState.SufficientAmount + } else { + VotingPowerState.InsufficientAmount(votingPower) + } + } + } + + override suspend fun awaitAllItemsVoted(coroutineScope: CoroutineScope, basket: List) { + observeReferendaState(coroutineScope) + .filter { referendaState -> + val referenda = referendaState.referenda.associateBy { it.id } + val allBasketItemsVoted = basket.all { + val referendum = referenda[it.referendumId] + referendum?.referendumVote != null + } + + allBasketItemsVoted + }.first() + } + + override fun startSwipeGovValidationSystem(): StartStakingLandingValidationSystem { + return ValidationSystem.startSwipeGovValidation() + } + + private fun filterAvailableToVoteReferenda(referendaState: ReferendaState): List { + return referendaFilteringProvider.filterAvailableToVoteReferenda(referendaState.referenda, referendaState.voting) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/validation/StartStakingLandingValidationSystem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/validation/StartStakingLandingValidationSystem.kt new file mode 100644 index 0000000..abb109b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/validation/StartStakingLandingValidationSystem.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.validation + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.validation.hasChainAccount + +typealias StartStakingLandingValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.startSwipeGovValidation(): StartStakingLandingValidationSystem = ValidationSystem { + hasChainAccount( + chain = { it.chain }, + metaAccount = { it.metaAccount }, + error = StartSwipeGovValidationFailure::NoChainAccountFound + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/validation/StartSwipeGovValidationFailure.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/validation/StartSwipeGovValidationFailure.kt new file mode 100644 index 0000000..dc782e4 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/validation/StartSwipeGovValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.validation + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.validation.NoChainAccountFoundError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed class StartSwipeGovValidationFailure { + + class NoChainAccountFound( + override val chain: Chain, + override val account: MetaAccount, + override val addAccountState: NoChainAccountFoundError.AddAccountState + ) : StartSwipeGovValidationFailure(), NoChainAccountFoundError +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/validation/StartSwipeGovValidationPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/validation/StartSwipeGovValidationPayload.kt new file mode 100644 index 0000000..d27cff5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/tindergov/validation/StartSwipeGovValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.validation + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class StartSwipeGovValidationPayload( + val chain: Chain, + val metaAccount: MetaAccount +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/GovernanceLockSchedule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/GovernanceLockSchedule.kt new file mode 100644 index 0000000..e403b5d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/GovernanceLockSchedule.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock + +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule +import io.novafoundation.nova.feature_governance_api.domain.locks.hasClaimableLocks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class GovernanceLocksOverview( + val totalLocked: Balance, + val locks: List, + val claimSchedule: ClaimSchedule, +) { + + sealed class Lock { + + class Claimable(val amount: Balance, val actions: List) : Lock() + + class Pending(val amount: Balance, val claimTime: ClaimTime) : Lock() + } + + sealed class ClaimTime { + + class At(val timer: TimerValue) : ClaimTime() + + object UntilAction : ClaimTime() + } +} + +fun GovernanceLocksOverview.canClaimTokens(): Boolean { + return claimSchedule.hasClaimableLocks() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/GovernanceUnlockAffects.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/GovernanceUnlockAffects.kt new file mode 100644 index 0000000..eb6bade --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/GovernanceUnlockAffects.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock + +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.Change +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId + +data class GovernanceUnlockAffects( + val transferableChange: Change, + val governanceLockChange: Change, + val claimableChunk: ClaimSchedule.UnlockChunk.Claimable?, + val remainsLockedInfo: RemainsLockedInfo? +) { + + data class RemainsLockedInfo( + val amount: Balance, + val lockedInIds: List + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/GovernanceUnlockInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/GovernanceUnlockInteractor.kt new file mode 100644 index 0000000..183ac5f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/GovernanceUnlockInteractor.kt @@ -0,0 +1,269 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.repository.getTracksById +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.data.source.trackLocksFlowOrEmpty +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule.UnlockChunk +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimTime +import io.novafoundation.nova.feature_governance_api.domain.locks.RealClaimScheduleCalculator +import io.novafoundation.nova.feature_governance_api.domain.locks.claimableChunk +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.Change +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceUnlockAffects.RemainsLockedInfo +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.feature_wallet_api.domain.model.maxLockReplacing +import io.novafoundation.nova.feature_wallet_api.domain.model.transferableReplacingFrozen +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.state.selectedOption +import io.novafoundation.nova.runtime.util.BlockDurationEstimator +import io.novafoundation.nova.runtime.util.timerUntil +import io.novasama.substrate_sdk_android.hash.isPositive +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +interface GovernanceUnlockInteractor { + + suspend fun calculateFee(claimable: UnlockChunk.Claimable?): Fee? + + suspend fun unlock(claimable: UnlockChunk.Claimable?): Result> + + fun locksOverviewFlow(scope: CoroutineScope): Flow + + fun unlockAffectsFlow(scope: CoroutineScope, assetFlow: Flow): Flow +} + +private class IntermediateData( + val voting: Map, + val currentBlockNumber: BlockNumber, + val onChainReferenda: Map, + val durationEstimator: BlockDurationEstimator, +) + +private const val LOCKS_OVERVIEW_KEY = "RealGovernanceUnlockInteractor.LOCKS_OVERVIEW_KEY" + +class RealGovernanceUnlockInteractor( + private val selectedAssetState: GovernanceSharedState, + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val chainStateRepository: ChainStateRepository, + private val computationalCache: ComputationalCache, + private val accountRepository: AccountRepository, + private val balanceLocksRepository: BalanceLocksRepository, + private val extrinsicService: ExtrinsicService, +) : GovernanceUnlockInteractor { + + override suspend fun calculateFee(claimable: UnlockChunk.Claimable?): Fee? { + val governanceSelectedOption = selectedAssetState.selectedOption() + val chain = governanceSelectedOption.assetWithChain.chain + + if (claimable == null) return null + + val metaAccount = accountRepository.getSelectedMetaAccount() + val origin = metaAccount.accountIdIn(chain)!! + + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + executeUnlock(origin, governanceSelectedOption, claimable) + } + } + + override suspend fun unlock(claimable: UnlockChunk.Claimable?) = withContext(Dispatchers.Default) { + val governanceSelectedOption = selectedAssetState.selectedOption() + val chain = governanceSelectedOption.assetWithChain.chain + + extrinsicService.submitAndWatchExtrinsic(chain, TransactionOrigin.SelectedWallet) { buildingContext -> + if (claimable == null) error("Nothing to claim") + + executeUnlock(accountIdToUnlock = buildingContext.submissionOrigin.executingAccount, governanceSelectedOption, claimable) + }.awaitInBlock() + } + + private suspend fun ExtrinsicBuilder.executeUnlock( + accountIdToUnlock: AccountId, + selectedGovernanceOption: SupportedGovernanceOption, + claimable: UnlockChunk.Claimable + ) { + val governanceSource = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + + with(governanceSource.convictionVoting) { + unlock(accountIdToUnlock, claimable) + } + } + + override fun locksOverviewFlow(scope: CoroutineScope): Flow { + return computationalCache.useSharedFlow(LOCKS_OVERVIEW_KEY, scope) { + val governanceSelectedOption = selectedAssetState.selectedOption() + + val metaAccount = accountRepository.getSelectedMetaAccount() + val voterAccountId = metaAccount.accountIdIn(governanceSelectedOption.assetWithChain.chain) + + locksOverviewFlowSuspend(voterAccountId, governanceSelectedOption) + } + } + + override fun unlockAffectsFlow(scope: CoroutineScope, assetFlow: Flow): Flow { + return flowOfAll { + val governanceSelectedOption = selectedAssetState.selectedOption() + val chain = governanceSelectedOption.assetWithChain.chain + val chainAsset = governanceSelectedOption.assetWithChain.asset + + val governanceSource = governanceSourceRegistry.sourceFor(governanceSelectedOption) + + val metaAccount = accountRepository.getSelectedMetaAccount() + + combine( + assetFlow, + balanceLocksRepository.observeBalanceLocks(metaAccount.id, chain, chainAsset), + locksOverviewFlow(scope) + ) { assetFlow, balanceLocks, locksOverview -> + governanceSource.constructGovernanceUnlockAffects(assetFlow, balanceLocks, locksOverview) + } + }.distinctUntilChanged() + } + + private suspend fun locksOverviewFlowSuspend( + voterAccountId: AccountId?, + selectedGovernanceOption: SupportedGovernanceOption + ): Flow { + val chain = selectedGovernanceOption.assetWithChain.chain + val asset = selectedGovernanceOption.assetWithChain.asset + + val governanceSource = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + val tracksById = governanceSource.referenda.getTracksById(chain.id) + val undecidingTimeout = governanceSource.referenda.undecidingTimeout(chain.id) + val voteLockingPeriod = governanceSource.convictionVoting.voteLockingPeriod(chain.id) + + val trackLocksFlow = governanceSource.convictionVoting.trackLocksFlowOrEmpty(voterAccountId, asset.fullId) + + val intermediateFlow = chainStateRepository.currentBlockNumberFlow(chain.timelineChainIdOrSelf()).map { currentBlockNumber -> + val onChainReferenda = governanceSource.referenda.getAllOnChainReferenda(chain.id).associateBy(OnChainReferendum::id) + val voting = voterAccountId?.let { governanceSource.convictionVoting.votingFor(voterAccountId, chain.id) }.orEmpty() + val blockTime = chainStateRepository.predictedBlockTime(chain.timelineChainIdOrSelf()) + val durationEstimator = BlockDurationEstimator(currentBlockNumber, blockTime) + + IntermediateData(voting, currentBlockNumber, onChainReferenda, durationEstimator) + } + + return combine(intermediateFlow, trackLocksFlow) { intermediateData, trackLocks -> + val claimScheduleCalculator = with(intermediateData) { + RealClaimScheduleCalculator(voting, currentBlockNumber, onChainReferenda, tracksById, undecidingTimeout, voteLockingPeriod, trackLocks) + } + + val claimSchedule = claimScheduleCalculator.estimateClaimSchedule() + val locks = claimSchedule.toOverviewLocks(intermediateData.durationEstimator) + + GovernanceLocksOverview( + totalLocked = claimScheduleCalculator.totalGovernanceLock(), + locks = locks, + claimSchedule = claimSchedule + ) + } + } + + private fun ClaimSchedule.toOverviewLocks(durationEstimator: BlockDurationEstimator): List { + return chunks.map { chunk -> + when (chunk) { + is UnlockChunk.Claimable -> GovernanceLocksOverview.Lock.Claimable(chunk.amount, chunk.actions) + + is UnlockChunk.Pending -> GovernanceLocksOverview.Lock.Pending( + amount = chunk.amount, + claimTime = when (val claimTime = chunk.claimableAt) { + is ClaimTime.At -> GovernanceLocksOverview.ClaimTime.At(durationEstimator.timerUntil(claimTime.block)) + ClaimTime.UntilAction -> GovernanceLocksOverview.ClaimTime.UntilAction + } + ) + } + } + } + + private fun GovernanceSource.constructGovernanceUnlockAffects( + asset: Asset, + balanceLocks: List, + locksOverview: GovernanceLocksOverview, + ): GovernanceUnlockAffects { + val claimable = locksOverview.claimSchedule.claimableChunk() + + return if (claimable != null) { + val newGovernanceLock = locksOverview.totalLocked - claimable.amount + + val transferableCurrent = asset.transferableInPlanks + val newTotalLocked = balanceLocks.maxLockReplacing(convictionVoting.voteLockId, replaceWith = newGovernanceLock) + val newTransferable = asset.transferableReplacingFrozen(newTotalLocked) + + val governanceLockChange = claimable.amount + val transferableChange = (newTransferable - transferableCurrent).abs() + + val remainsLocked = governanceLockChange - transferableChange + + val remainsLockedInfo = if (remainsLocked.isPositive()) { + RemainsLockedInfo( + amount = remainsLocked, + lockedInIds = balanceLocks.otherLocksPreventingLockBeingLessThan(newGovernanceLock, thisLockId = convictionVoting.voteLockId) + ) + } else { + null + } + + GovernanceUnlockAffects( + transferableChange = Change( + previousValue = transferableCurrent, + newValue = newTransferable, + ), + governanceLockChange = Change( + previousValue = locksOverview.totalLocked, + newValue = newGovernanceLock, + ), + claimableChunk = claimable, + remainsLockedInfo = remainsLockedInfo, + ) + } else { + constructEmptyUnlockAffects(asset, locksOverview.totalLocked) + } + } + + private fun List.otherLocksPreventingLockBeingLessThan(amount: Balance, thisLockId: BalanceLockId): List { + return filter { it.id != thisLockId }.mapNotNull { lock -> + lock.id.takeIf { lock.amountInPlanks > amount } + } + } + + private fun constructEmptyUnlockAffects( + asset: Asset, + totalGovernanceLock: Balance, + ): GovernanceUnlockAffects { + return GovernanceUnlockAffects( + transferableChange = Change.Same(asset.transferableInPlanks), + governanceLockChange = Change.Same(totalGovernanceLock), + claimableChunk = null, + remainsLockedInfo = null, + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/UnlockGovernanceValidationFailure.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/UnlockGovernanceValidationFailure.kt new file mode 100644 index 0000000..8c3dcac --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/UnlockGovernanceValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class UnlockGovernanceValidationFailure { + + class NotEnoughToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : UnlockGovernanceValidationFailure(), NotEnoughToPayFeesError +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/UnlockReferendumValidationPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/UnlockReferendumValidationPayload.kt new file mode 100644 index 0000000..48b0f66 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/UnlockReferendumValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.validations + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee + +class UnlockReferendumValidationPayload( + val fee: Fee, + val asset: Asset, +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/ValidationFailureUi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/ValidationFailureUi.kt new file mode 100644 index 0000000..844100b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/ValidationFailureUi.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError + +fun handleUnlockReferendumValidationFailure(failure: UnlockGovernanceValidationFailure, resourceManager: ResourceManager): TitleAndMessage { + return when (failure) { + is UnlockGovernanceValidationFailure.NotEnoughToPayFees -> handleNotEnoughFeeError(failure, resourceManager) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/VoteReferendumValidationSystem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/VoteReferendumValidationSystem.kt new file mode 100644 index 0000000..2eeb2ae --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/unlock/validations/VoteReferendumValidationSystem.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias UnlockReferendumValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.unlockReferendumValidationSystem(): UnlockReferendumValidationSystem = ValidationSystem { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + UnlockGovernanceValidationFailure.NotEnoughToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/RealGovernanceLocksEstimator.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/RealGovernanceLocksEstimator.kt new file mode 100644 index 0000000..aa1721a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/RealGovernanceLocksEstimator.kt @@ -0,0 +1,170 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amount +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.asOngoingOrNull +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.flattenCastingVotes +import io.novafoundation.nova.feature_governance_api.domain.locks.RealClaimScheduleCalculator +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.LocksChange +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.ReusableLock +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.addIfPositive +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.Change +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.absoluteDifference +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.GovernanceVoteAssistant +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.feature_wallet_api.domain.model.maxLockReplacing +import io.novafoundation.nova.feature_wallet_api.domain.model.transferableReplacingFrozen +import io.novafoundation.nova.runtime.util.BlockDurationEstimator +import java.math.BigInteger +import kotlin.time.Duration + +internal class RealGovernanceLocksEstimator( + override val onChainReferenda: List, + private val voting: Map, + private val votedReferenda: Map, + private val blockDurationEstimator: BlockDurationEstimator, + private val tracks: Map, + private val votingLockId: BalanceLockId, + undecidingTimeout: BlockNumber, + voteLockingPeriod: BlockNumber, + balanceLocks: List, + governanceLocksByTrack: Map, +) : GovernanceVoteAssistant { + + private val referendaById = onChainReferenda.associateBy { it.id } + + private val claimScheduleCalculator = RealClaimScheduleCalculator( + votingByTrack = voting, + currentBlockNumber = blockDurationEstimator.currentBlock, + // votedReferenda might not contain selected referenda so we add it manually + referenda = votedReferenda + referendaById, + tracks = tracks, + undecidingTimeout = undecidingTimeout, + voteLockingPeriod = voteLockingPeriod, + trackLocks = governanceLocksByTrack, + ) + + private val flattenedVotes = voting.flattenCastingVotes() + + private val currentMaxGovernanceLocked = governanceLocksByTrack.values.maxOrNull().orZero() + private val currentMaxUnlocksAtForReferenda = referendaById.mapValues { estimateUnlocksAt(it.key, null) } + + private val otherMaxLocked = balanceLocks.maxLockReplacing(votingLockId, replaceWith = Balance.ZERO) + + private val allMaxLocked = balanceLocks.maxOfOrNull { it.amountInPlanks } + .orZero() + + override val trackVoting: List = voting.findVotingFor(onChainReferenda) + + override suspend fun estimateLocksAfterVoting(votes: Map, asset: Asset): LocksChange { + return votes.map { estimateForVote(it.key, it.value, asset) } + .maximize() + } + + private fun estimateForVote(referendumId: ReferendumId, vote: AccountVote, asset: Asset): LocksChange { + val newGovernanceLocked = currentMaxGovernanceLocked.max(vote.amount()) + val newMaxUnlocksAt = estimateUnlocksAt(referendumId, vote) + + val previousLockDuration = blockDurationEstimator.durationUntil(currentMaxUnlocksAtForReferenda.getValue(referendumId)) + val newLockDuration = blockDurationEstimator.durationUntil(newMaxUnlocksAt) + + val currentTransferablePlanks = asset.transferableInPlanks + val newLocked = otherMaxLocked.max(newGovernanceLocked) + val newTransferablePlanks = asset.transferableReplacingFrozen(newLocked) + + return LocksChange( + lockedAmountChange = Change( + previousValue = currentMaxGovernanceLocked, + newValue = newGovernanceLocked, + ), + lockedPeriodChange = Change( + previousValue = previousLockDuration, + newValue = newLockDuration, + ), + transferableChange = Change( + previousValue = currentTransferablePlanks, + newValue = newTransferablePlanks, + ) + ) + } + + override suspend fun reusableLocks(): List { + return buildList { + addIfPositive(ReusableLock.Type.GOVERNANCE, currentMaxGovernanceLocked) + addIfPositive(ReusableLock.Type.ALL, allMaxLocked) + } + } + + private fun estimateUnlocksAt(referendumId: ReferendumId, vote: AccountVote?): BlockNumber { + val priorUnlocksAt = priorUnlocksAt() + val votesEstimatedUnlocksAt = if (vote != null) { + votesEstimatedUnlocksAt(referendumId, vote) + } else { + votesEstimatedUnlocksAt() + } + + return priorUnlocksAt.max(votesEstimatedUnlocksAt) + } + + private fun priorUnlocksAt(): BlockNumber { + return voting.values.filterIsInstance() + .maxOfOrNull { it.prior.unlockAt }.orZero() + } + + private fun votesEstimatedUnlocksAt(referendumId: ReferendumId, changedVote: AccountVote): BlockNumber { + val changedVoteMaxLock = claimScheduleCalculator.maxConvictionEndOf(changedVote, referendumId) + + val currentVotesExceptChanged = votedReferenda.keys - referendumId + val currentVotesExceptChangedMaxUnlock = currentVotesExceptChanged.maxOfOrNull { + val vote = flattenedVotes.getValue(it) + + claimScheduleCalculator.maxConvictionEndOf(vote, it) + }.orZero() + + return changedVoteMaxLock.max(currentVotesExceptChangedMaxUnlock) + } + + private fun votesEstimatedUnlocksAt(): BlockNumber { + return flattenedVotes.maxOfOrNull { (referendumId, vote) -> + claimScheduleCalculator.maxConvictionEndOf(vote, referendumId) + }.orZero() + } + + private fun Map.findVotingFor(onChainReferenda: List): List { + return onChainReferenda.mapNotNull { referendum -> + val asOngoing = referendum.status.asOngoingOrNull() + + if (asOngoing != null) { + // fast-path - we have direct access to trackId + get(asOngoing.track) + } else { + // slow path - referendum is completed so have to find by referendumId + values.firstOrNull { + it is Voting.Casting && referendum.id in it.votes.keys + } + } + } + } + + fun List.maximize(): LocksChange { + val maxLockedAmountChange = this.maxByOrNull { it.lockedAmountChange.absoluteDifference() } + val maxLockedPeriodChange = this.maxByOrNull { it.lockedPeriodChange.absoluteDifference() } + val maxTransferableChange = this.maxByOrNull { it.transferableChange.absoluteDifference() } + + return LocksChange( + maxLockedAmountChange?.lockedAmountChange ?: Change(BigInteger.ZERO, BigInteger.ZERO), + maxLockedPeriodChange?.lockedPeriodChange ?: Change(Duration.ZERO, Duration.ZERO), + maxTransferableChange?.transferableChange ?: Change(BigInteger.ZERO, BigInteger.ZERO) + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/RealVoteReferendumInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/RealVoteReferendumInteractor.kt new file mode 100644 index 0000000..6ddb4e7 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/RealVoteReferendumInteractor.kt @@ -0,0 +1,196 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.multiResult.RetriableMultiResult +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.flattenCastingVotes +import io.novafoundation.nova.feature_governance_api.data.repository.getTracksById +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSource +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.GovernanceVoteAssistant +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.blockDurationEstimator +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map + +private const val VOTE_ASSISTANT_CACHE_KEY = "RealVoteReferendumInteractor.VoteAssistant" + +class RealVoteReferendumInteractor( + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val chainStateRepository: ChainStateRepository, + private val selectedChainState: GovernanceSharedState, + private val accountRepository: AccountRepository, + private val extrinsicService: ExtrinsicService, + private val locksRepository: BalanceLocksRepository, + private val computationalCache: ComputationalCache, +) : VoteReferendumInteractor { + + override suspend fun maxAvailableForVote(asset: Asset): Balance { + val governanceOption = selectedChainState.selectedOption() + val governanceSource = governanceSourceRegistry.sourceFor(governanceOption) + + return governanceSource.convictionVoting.maxAvailableForVote(asset) + } + + override fun voteAssistantFlow(referendumId: ReferendumId, scope: CoroutineScope): Flow { + return voteAssistantFlow(listOf(referendumId), scope) + } + + override fun voteAssistantFlow(referendaIds: List, scope: CoroutineScope): Flow { + return computationalCache.useSharedFlow(VOTE_ASSISTANT_CACHE_KEY, scope) { + val governanceOption = selectedChainState.selectedOption() + val metaAccount = accountRepository.getSelectedMetaAccount() + + val voterAccountId = metaAccount.accountIdIn(governanceOption.assetWithChain.chain)!! + + voteAssistantFlowSuspend(governanceOption, voterAccountId, metaAccount.id, referendaIds) + } + } + + override suspend fun estimateFee(votes: Map): Fee { + val governanceOption = selectedChainState.selectedOption() + val chain = governanceOption.assetWithChain.chain + + val governanceSource = governanceSourceRegistry.sourceFor(governanceOption) + + return extrinsicService.estimateMultiFee(chain, TransactionOrigin.SelectedWallet) { + with(governanceSource.convictionVoting) { + votes.forEach { (referendumId, vote) -> vote(referendumId, vote) } + } + } + } + + override suspend fun estimateFee(referendumId: ReferendumId, vote: AccountVote): Fee { + val governanceOption = selectedChainState.selectedOption() + val chain = governanceOption.assetWithChain.chain + + val governanceSource = governanceSourceRegistry.sourceFor(governanceOption) + + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + with(governanceSource.convictionVoting) { + vote(referendumId, vote) + } + } + } + + override suspend fun voteReferenda(votes: Map): RetriableMultiResult> { + val governanceOption = selectedChainState.selectedOption() + val chain = governanceOption.assetWithChain.chain + + val governanceSource = governanceSourceRegistry.sourceFor(governanceOption) + + return extrinsicService.submitMultiExtrinsicAwaitingInclusion( + chain = chain, + origin = TransactionOrigin.SelectedWallet, + submissionOptions = ExtrinsicService.SubmissionOptions(batchMode = BatchMode.BATCH_ALL) + ) { + with(governanceSource.convictionVoting) { + votes.forEach { (referendumId, vote) -> vote(referendumId, vote) } + } + } + } + + override suspend fun voteReferendum(referendumId: ReferendumId, vote: AccountVote): Result { + val governanceOption = selectedChainState.selectedOption() + val chain = governanceOption.assetWithChain.chain + + val governanceSource = governanceSourceRegistry.sourceFor(governanceOption) + + return extrinsicService.submitExtrinsic(chain, TransactionOrigin.SelectedWallet) { + with(governanceSource.convictionVoting) { + vote(referendumId, vote) + } + } + } + + override suspend fun isAbstainSupported(): Boolean { + val governanceSelectedOption = selectedChainState.selectedOption() + val governanceSource = governanceSourceRegistry.sourceFor(governanceSelectedOption) + + return governanceSource.convictionVoting.isAbstainVotingAvailable() + } + + private suspend fun voteAssistantFlowSuspend( + selectedGovernanceOption: SupportedGovernanceOption, + voterAccountId: AccountId, + metaId: Long, + referendaIds: List + ): Flow { + val chain = selectedGovernanceOption.assetWithChain.chain + val chainAsset = selectedGovernanceOption.assetWithChain.asset + + val governanceSource = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + val tracks = governanceSource.referenda.getTracksById(chain.id) + val undecidingTimeout = governanceSource.referenda.undecidingTimeout(chain.id) + val voteLockingPeriod = governanceSource.convictionVoting.voteLockingPeriod(chain.id) + + val votingInformation = governanceSource.convictionVoting.trackLocksFlow(voterAccountId, chainAsset.fullId).map { locksByTrack -> + val voting = governanceSource.convictionVoting.votingFor(voterAccountId, chain.id) + val accountVotesByReferendumId = voting.flattenCastingVotes() + val votedReferenda = governanceSource.referenda.getOnChainReferenda(chain.id, accountVotesByReferendumId.keys) + + Triple(locksByTrack, voting, votedReferenda) + } + + val selectedReferendaFlow = getOnChainReferendaFlow(governanceSource, chain, referendaIds) + + val balanceLocksFlow = locksRepository.observeBalanceLocks(metaId, chain, chainAsset) + + return combine(votingInformation, selectedReferendaFlow, balanceLocksFlow) { (locksByTrack, voting, votedReferenda), selectedReferenda, locks -> + val blockDurationEstimator = chainStateRepository.blockDurationEstimator(chain.timelineChainIdOrSelf()) + + RealGovernanceLocksEstimator( + onChainReferenda = selectedReferenda, + balanceLocks = locks, + governanceLocksByTrack = locksByTrack, + voting = voting, + votedReferenda = votedReferenda, + blockDurationEstimator = blockDurationEstimator, + tracks = tracks, + undecidingTimeout = undecidingTimeout, + voteLockingPeriod = voteLockingPeriod, + votingLockId = governanceSource.convictionVoting.voteLockId + ) + } + } + + private suspend fun getOnChainReferendaFlow( + governanceSource: GovernanceSource, + chain: Chain, + referendaIds: List + ): Flow> { + return if (referendaIds.size == 1) { + governanceSource.referenda.onChainReferendumFlow(chain.id, referendaIds.first()) + .filterNotNull() + .map { listOf(it) } + } else { + flowOf { governanceSource.referenda.getOnChainReferenda(chain.id, referendaIds) } + .map { it.values.toList() } + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/MaximumTrackVotesNotReachedValidation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/MaximumTrackVotesNotReachedValidation.kt new file mode 100644 index 0000000..741553e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/MaximumTrackVotesNotReachedValidation.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isFalseOrError +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.trackVotesNumber +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.runtime.state.selectedOption +import java.math.BigInteger + +class MaximumTrackVotesNotReachedValidation

( + private val governanceSourceRegistry: GovernanceSourceRegistry, + private val governanceSharedState: GovernanceSharedState, + private val failure: (BigInteger) -> F +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val selectedGovernanceOption = governanceSharedState.selectedOption() + val source = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + val chainId = selectedGovernanceOption.assetWithChain.chain.id + + val maxTrackVotes = source.convictionVoting.maxTrackVotes(chainId) + val reachedMaxVotes = value.trackVoting.any { it.trackVotesNumber() >= maxTrackVotes.toInt() } + + return reachedMaxVotes isFalseOrError { + failure(maxTrackVotes) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/NotDelegatingInTrackValidation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/NotDelegatingInTrackValidation.kt new file mode 100644 index 0000000..75e1b07 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/NotDelegatingInTrackValidation.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isFalseOrError +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting + +class NotDelegatingInTrackValidation

( + private val failure: () -> F +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val isDelegating = value.trackVoting.any { it is Voting.Delegating } + + return isDelegating isFalseOrError { + failure() + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/ReferendumIsOngoingValidation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/ReferendumIsOngoingValidation.kt new file mode 100644 index 0000000..a9b4967 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/ReferendumIsOngoingValidation.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendumStatus +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId + +class ReferendumIsOngoingValidation

( + private val failure: (ReferendumId) -> F +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val finishedReferendum = value.onChainReferenda.firstOrNull { it.status !is OnChainReferendumStatus.Ongoing } + + if (finishedReferendum == null) { + return valid() + } else { + return validationError(failure(finishedReferendum.id)) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/VoteValidationFailure.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/VoteValidationFailure.kt new file mode 100644 index 0000000..4b14bcb --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/VoteValidationFailure.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughFreeBalanceError +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import java.math.BigInteger + +interface VoteValidationFailure { + + interface NotEnoughToPayFees : VoteValidationFailure, NotEnoughToPayFeesError + + interface AmountIsTooBig : VoteValidationFailure, NotEnoughFreeBalanceError + + interface ReferendumCompleted : VoteValidationFailure { + val referendumId: ReferendumId + } + + interface AlreadyDelegatingVotes : VoteValidationFailure + + interface MaxTrackVotesReached : VoteValidationFailure { + val max: BigInteger + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/VoteValidationFailureUi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/VoteValidationFailureUi.kt new file mode 100644 index 0000000..d243664 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/VoteValidationFailureUi.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.common.validation.TransformedFailure.Default +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFreeBalanceError + +fun handleAlreadyDelegatingVotes( + resourceManager: ResourceManager +): TransformedFailure { + return Default( + resourceManager.getString(R.string.refrendum_vote_already_delegating_title) to + resourceManager.getString(R.string.refrendum_vote_already_delegating_message) + ) +} + +fun handleAmountIsTooBig( + resourceManager: ResourceManager, + failure: VoteValidationFailure.AmountIsTooBig +): Default { + return Default( + handleNotEnoughFreeBalanceError( + error = failure, + resourceManager = resourceManager, + descriptionFormat = R.string.refrendum_vote_not_enough_available_message + ) + ) +} + +fun handleMaxTrackVotesReached( + resourceManager: ResourceManager, + failure: VoteValidationFailure.MaxTrackVotesReached +): TransformedFailure { + return Default( + resourceManager.getString(R.string.refrendum_vote_max_votes_reached_title) to + resourceManager.getString(R.string.refrendum_vote_max_votes_reached_message, failure.max.format()) + ) +} + +fun handleReferendumCompleted( + resourceManager: ResourceManager, + failure: VoteValidationFailure.ReferendumCompleted +): TransformedFailure { + return Default( + resourceManager.getString(R.string.refrendum_vote_already_completed_title) to + resourceManager.getString(R.string.refrendum_vote_already_completed_message) + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/VoteValidationPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/VoteValidationPayload.kt new file mode 100644 index 0000000..d0bcf0a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/common/VoteValidationPayload.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal + +interface VoteValidationPayload { + + val onChainReferenda: List + + val asset: Asset + + val trackVoting: List + + val amount: BigDecimal + + val maxAvailableAmount: BigDecimal + + val fee: Fee +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/AbstainConvictionValidation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/AbstainConvictionValidation.kt new file mode 100644 index 0000000..810a94a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/AbstainConvictionValidation.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction + +class AbstainConvictionValidation : VoteReferendumValidation { + + override suspend fun validate(value: VoteReferendaValidationPayload): ValidationStatus { + if (value.voteType == null && value.conviction == null) return valid() + + val isAbstainVote = value.voteType == VoteType.ABSTAIN + val isConvictionNone = value.conviction == Conviction.None + + if (isAbstainVote && !isConvictionNone) { + return validationError(VoteReferendumValidationFailure.AbstainInvalidConviction) + } + + return valid() + } +} + +fun VoteReferendumValidationSystemBuilder.abstainConvictionValid() { + validate(AbstainConvictionValidation()) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/ValidationFailureUi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/ValidationFailureUi.kt new file mode 100644 index 0000000..d5a0c20 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/ValidationFailureUi.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.common.validation.TransformedFailure.Default +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.handleAmountIsTooBig +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.handleMaxTrackVotesReached +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.handleReferendumCompleted +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction + +fun handleVoteReferendumValidationFailure( + failure: VoteReferendumValidationFailure, + actions: ValidationFlowActions, + resourceManager: ResourceManager +): TransformedFailure { + return when (failure) { + is VoteReferendumValidationFailure.NotEnoughToPayFees -> Default(handleNotEnoughFeeError(failure, resourceManager)) + + VoteReferendumValidationFailure.AlreadyDelegatingVotes -> Default( + resourceManager.getString(R.string.refrendum_vote_already_delegating_title) to + resourceManager.getString(R.string.refrendum_vote_already_delegating_message) + ) + + is VoteReferendumValidationFailure.AmountIsTooBig -> handleAmountIsTooBig(resourceManager, failure) + + is VoteReferendumValidationFailure.MaxTrackVotesReached -> handleMaxTrackVotesReached(resourceManager, failure) + + is VoteReferendumValidationFailure.ReferendumCompleted -> handleReferendumCompleted(resourceManager, failure) + + VoteReferendumValidationFailure.AbstainInvalidConviction -> { + TransformedFailure.Custom( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.referendum_abstain_vote_invalid_conviction_title), + message = resourceManager.getString(R.string.referendum_abstain_vote_invalid_conviction_subtitle), + cancelAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_cancel), + action = { } + ), + okAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_continue), + action = { + actions.resumeFlow { payload -> + payload.copy(conviction = Conviction.None) + } + } + ), + customStyle = R.style.AccentAlertDialogTheme + ) + ) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/VoteReferendaValidationPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/VoteReferendaValidationPayload.kt new file mode 100644 index 0000000..d0740f4 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/VoteReferendaValidationPayload.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.VoteValidationPayload +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import java.math.BigDecimal + +data class VoteReferendaValidationPayload( + override val onChainReferenda: List, + override val asset: Asset, + override val trackVoting: List, + override val amount: BigDecimal, + val voteType: VoteType?, + val conviction: Conviction?, + override val fee: Fee, + override val maxAvailableAmount: BigDecimal +) : VoteValidationPayload diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/VoteReferendumValidationFailure.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/VoteReferendumValidationFailure.kt new file mode 100644 index 0000000..8e64485 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/VoteReferendumValidationFailure.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.VoteValidationFailure +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +sealed class VoteReferendumValidationFailure : VoteValidationFailure { + + class NotEnoughToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : VoteReferendumValidationFailure(), VoteValidationFailure.NotEnoughToPayFees + + class AmountIsTooBig( + override val chainAsset: Chain.Asset, + override val freeAfterFees: BigDecimal, + ) : VoteReferendumValidationFailure(), VoteValidationFailure.AmountIsTooBig + + class ReferendumCompleted(override val referendumId: ReferendumId) : VoteReferendumValidationFailure(), VoteValidationFailure.ReferendumCompleted + + object AlreadyDelegatingVotes : VoteReferendumValidationFailure(), VoteValidationFailure.AlreadyDelegatingVotes + + class MaxTrackVotesReached(override val max: BigInteger) : VoteReferendumValidationFailure(), VoteValidationFailure.MaxTrackVotesReached + + object AbstainInvalidConviction : VoteReferendumValidationFailure() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/VoteReferendumValidationSystem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/VoteReferendumValidationSystem.kt new file mode 100644 index 0000000..50c2c68 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/referendum/VoteReferendumValidationSystem.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.MaximumTrackVotesNotReachedValidation +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.NotDelegatingInTrackValidation +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.ReferendumIsOngoingValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.hasEnoughBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias VoteReferendumValidationSystem = ValidationSystem +typealias VoteReferendumValidation = Validation +typealias VoteReferendumValidationSystemBuilder = ValidationSystemBuilder + +fun ValidationSystem.Companion.voteReferendumValidationSystem( + governanceSourceRegistry: GovernanceSourceRegistry, + governanceSharedState: GovernanceSharedState, +): VoteReferendumValidationSystem = ValidationSystem { + hasEnoughBalance( + availableBalance = { it.maxAvailableAmount }, + requestedAmount = { it.amount }, + chainAsset = { it.asset.token.configuration }, + error = VoteReferendumValidationFailure::AmountIsTooBig + ) + + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + VoteReferendumValidationFailure.NotEnoughToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) + + referendumIsOngoing() + + notDelegatingInTrack() + + maximumTrackVotesNotReached(governanceSourceRegistry, governanceSharedState) + + abstainConvictionValid() +} + +fun VoteReferendumValidationSystemBuilder.maximumTrackVotesNotReached( + governanceSourceRegistry: GovernanceSourceRegistry, + governanceSharedState: GovernanceSharedState, +) { + validate( + MaximumTrackVotesNotReachedValidation( + governanceSourceRegistry, + governanceSharedState, + VoteReferendumValidationFailure::MaxTrackVotesReached + ) + ) +} + +fun VoteReferendumValidationSystemBuilder.notDelegatingInTrack() { + validate( + NotDelegatingInTrackValidation { VoteReferendumValidationFailure.AlreadyDelegatingVotes } + ) +} + +fun VoteReferendumValidationSystemBuilder.referendumIsOngoing() { + validate(ReferendumIsOngoingValidation(VoteReferendumValidationFailure::ReferendumCompleted)) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/ValidationFailureUi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/ValidationFailureUi.kt new file mode 100644 index 0000000..8da6f9c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/ValidationFailureUi.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.tindergov + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.TransformedFailure.Default +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.handleAlreadyDelegatingVotes +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.handleAmountIsTooBig +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.handleMaxTrackVotesReached +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.handleReferendumCompleted +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import java.math.BigDecimal + +fun handleVoteTinderGovValidationFailure( + failure: VoteTinderGovValidationFailure, + actions: ValidationFlowActions, + resourceManager: ResourceManager +): TransformedFailure { + return when (failure) { + is VoteTinderGovValidationFailure.NotEnoughToPayFees -> Default(handleNotEnoughFeeError(failure, resourceManager)) + + VoteTinderGovValidationFailure.AlreadyDelegatingVotes -> handleAlreadyDelegatingVotes(resourceManager) + + is VoteTinderGovValidationFailure.AmountIsTooBig -> { + val titleAndMessage = handleAmountIsTooBig(resourceManager, failure).titleAndMessage + TransformedFailure.Custom( + CustomDialogDisplayer.Payload( + title = titleAndMessage.first, + message = titleAndMessage.second, + cancelAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_cancel), + action = { } + ), + okAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_use_max), + action = { + actions.revalidate { payload -> + cutBasketAmount(payload, failure.freeAfterFees) + } + } + ), + customStyle = R.style.AccentAlertDialogTheme + ) + ) + } + + is VoteTinderGovValidationFailure.MaxTrackVotesReached -> handleMaxTrackVotesReached(resourceManager, failure) + + is VoteTinderGovValidationFailure.ReferendumCompleted -> handleReferendumCompleted(resourceManager, failure) + } +} + +private fun cutBasketAmount(payload: VoteTinderGovValidationPayload, maxAvailable: BigDecimal): VoteTinderGovValidationPayload { + val maxAvailablePlanks = payload.asset.token.planksFromAmount(maxAvailable) + val newBasket = payload.basket.map { + if (it.amount > maxAvailablePlanks) { + it.copy(amount = maxAvailablePlanks) + } else { + it + } + } + + return payload.copy(basket = newBasket) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/VoteTinderGovValidationFailure.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/VoteTinderGovValidationFailure.kt new file mode 100644 index 0000000..cb3bdc6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/VoteTinderGovValidationFailure.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.tindergov + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.VoteValidationFailure +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +sealed class VoteTinderGovValidationFailure : VoteValidationFailure { + + class NotEnoughToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : VoteTinderGovValidationFailure(), VoteValidationFailure.NotEnoughToPayFees + + class AmountIsTooBig( + override val chainAsset: Chain.Asset, + override val freeAfterFees: BigDecimal, + ) : VoteTinderGovValidationFailure(), VoteValidationFailure.AmountIsTooBig + + class ReferendumCompleted(override val referendumId: ReferendumId) : VoteTinderGovValidationFailure(), VoteValidationFailure.ReferendumCompleted + + object AlreadyDelegatingVotes : VoteTinderGovValidationFailure(), VoteValidationFailure.AlreadyDelegatingVotes + + class MaxTrackVotesReached(override val max: BigInteger) : VoteTinderGovValidationFailure(), VoteValidationFailure.MaxTrackVotesReached +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/VoteTinderGovValidationPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/VoteTinderGovValidationPayload.kt new file mode 100644 index 0000000..740ecc1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/VoteTinderGovValidationPayload.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.tindergov + +import io.novafoundation.nova.feature_governance_api.data.model.TinderGovBasketItem +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.VoteValidationPayload +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal + +data class VoteTinderGovValidationPayload( + override val onChainReferenda: List, + override val asset: Asset, + override val trackVoting: List, + override val fee: Fee, + override val maxAvailableAmount: BigDecimal, + val basket: List +) : VoteValidationPayload { + + override val amount: BigDecimal + get() { + val amount = basket.maxOf { it.amount } + return asset.token.amountFromPlanks(amount) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/VoteTinderGovValidationSystem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/VoteTinderGovValidationSystem.kt new file mode 100644 index 0000000..48af987 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/vote/validations/tindergov/VoteTinderGovValidationSystem.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.tindergov + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.MaximumTrackVotesNotReachedValidation +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.NotDelegatingInTrackValidation +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.common.ReferendumIsOngoingValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.hasEnoughFreeBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias VoteTinderGovValidationSystem = ValidationSystem +typealias VoteTinderGovValidationSystemBuilder = ValidationSystemBuilder + +fun ValidationSystem.Companion.voteTinderGovValidationSystem( + governanceSourceRegistry: GovernanceSourceRegistry, + governanceSharedState: GovernanceSharedState, +): VoteTinderGovValidationSystem = ValidationSystem { + hasEnoughFreeBalance( + asset = { it.asset }, + fee = { it.fee }, + requestedAmount = { it.amount }, + error = VoteTinderGovValidationFailure::AmountIsTooBig + ) + + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + VoteTinderGovValidationFailure.NotEnoughToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) + + referendumIsOngoing() + + notDelegatingInTrack() + + maximumTrackVotesNotReached(governanceSourceRegistry, governanceSharedState) +} + +fun VoteTinderGovValidationSystemBuilder.maximumTrackVotesNotReached( + governanceSourceRegistry: GovernanceSourceRegistry, + governanceSharedState: GovernanceSharedState, +) { + validate( + MaximumTrackVotesNotReachedValidation( + governanceSourceRegistry, + governanceSharedState, + VoteTinderGovValidationFailure::MaxTrackVotesReached + ) + ) +} + +fun VoteTinderGovValidationSystemBuilder.notDelegatingInTrack() { + validate( + NotDelegatingInTrackValidation { VoteTinderGovValidationFailure.AlreadyDelegatingVotes } + ) +} + +fun VoteTinderGovValidationSystemBuilder.referendumIsOngoing() { + validate(ReferendumIsOngoingValidation(VoteTinderGovValidationFailure::ReferendumCompleted)) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/voters/RealReferendumVotersInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/voters/RealReferendumVotersInteractor.kt new file mode 100644 index 0000000..2105cc5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/referendum/voters/RealReferendumVotersInteractor.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_governance_impl.domain.referendum.voters + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.get +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.OnChainIdentity +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Delegation +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.getAllAccountIds +import io.novafoundation.nova.feature_governance_api.data.network.offchain.model.delegation.DelegateMetadata +import io.novafoundation.nova.feature_governance_api.data.repository.getDelegatesMetadataOrEmpty +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabel +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.GenericVoter +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.ReferendumVoter +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.ReferendumVoterDelegator +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.ReferendumVotersInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.mapAccountTypeToDomain +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow + +class RealReferendumVotersInteractor( + private val governanceSourceRegistry: GovernanceSourceRegistry, + @OnChainIdentity private val identityProvider: IdentityProvider, + private val governanceSharedState: GovernanceSharedState, +) : ReferendumVotersInteractor { + + override fun votersFlow(referendumId: ReferendumId, type: VoteType): Flow> { + return flowOf { votersOf(referendumId, type) } + } + + private suspend fun votersOf( + referendumId: ReferendumId, + type: VoteType + ): List = coroutineScope { + val selectedGovernanceOption = governanceSharedState.selectedOption() + val chain = selectedGovernanceOption.assetWithChain.chain + val chainAsset = selectedGovernanceOption.assetWithChain.asset + + val source = governanceSourceRegistry.sourceFor(selectedGovernanceOption) + + val metadatasDeferred = async { + source.delegationsRepository.getDelegatesMetadataOrEmpty(chain) + .associateBy { it.accountId.intoKey() } + } + + val votersDeferred = async { source.convictionVoting.votersOf(referendumId, chain, type) } + + val metadatas = metadatasDeferred.await() + val voters = votersDeferred.await() + + val votersAccountIds = voters.flatMap { it.getAllAccountIds() } + + val identities = identityProvider.identitiesFor(votersAccountIds, chainAsset.chainId) + + voters.map { voter -> + ReferendumVoter( + accountVote = voter.vote, + voteType = type, + accountId = voter.accountId, + identity = identities[voter.accountId], + chainAsset = chainAsset, + metadata = metadatas[voter.accountId]?.let { mapDelegateMetadata(it) }, + delegators = voter.delegators.map { mapDelegator(it, metadatas, identities, chainAsset) } + ) + }.sortedByDescending { it.vote.totalVotes } + } + + private fun mapDelegator( + delegation: Delegation, + metadatas: Map, + identities: Map, + chainAsset: Chain.Asset + ): ReferendumVoterDelegator { + val delegatorId = delegation.delegator + return ReferendumVoterDelegator( + accountId = delegatorId, + vote = GenericVoter.ConvictionVote(chainAsset.amountFromPlanks(delegation.vote.amount), delegation.vote.conviction), + identity = identities[delegatorId], + metadata = metadatas[delegatorId]?.let { mapDelegateMetadata(it) } + ) + } + + private fun mapDelegateMetadata(metadata: DelegateMetadata): DelegateLabel.Metadata { + return DelegateLabel.Metadata( + accountType = mapAccountTypeToDomain(metadata.isOrganization), + iconUrl = metadata.profileImageUrl, + name = metadata.name + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/summary/ReferendaSummaryInteractor.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/summary/ReferendaSummaryInteractor.kt new file mode 100644 index 0000000..7f8bc87 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/summary/ReferendaSummaryInteractor.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.domain.summary + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.CoroutineScope + +interface ReferendaSummaryInteractor { + + suspend fun getReferendaSummaries(ids: List, coroutineScope: CoroutineScope): Map +} + +class RealReferendaSummaryInteractor( + private val governanceSharedState: GovernanceSharedState, + private val referendaSummarySharedComputation: ReferendaSummarySharedComputation +) : ReferendaSummaryInteractor { + + override suspend fun getReferendaSummaries(ids: List, coroutineScope: CoroutineScope): Map { + return runCatching { + referendaSummarySharedComputation.summaries( + governanceSharedState.selectedOption(), + ids, + coroutineScope + ) + }.getOrNull() + .orEmpty() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/summary/ReferendaSummarySharedComputation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/summary/ReferendaSummarySharedComputation.kt new file mode 100644 index 0000000..8237e87 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/summary/ReferendaSummarySharedComputation.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_governance_impl.domain.summary + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_impl.domain.referendum.details.ReferendumDetailsRepository +import kotlinx.coroutines.CoroutineScope + +class ReferendaSummarySharedComputation( + private val computationalCache: ComputationalCache, + private val referendumDetailsRepository: ReferendumDetailsRepository, + private val accountRepository: AccountRepository +) { + + suspend fun summaries( + governanceOption: SupportedGovernanceOption, + referendaIds: List, + scope: CoroutineScope + ): Map? { + val chainId = governanceOption.assetWithChain.chain.id + val referendaHashCode = referendaIds.toSet().hashCode() + val selectedLanguage = accountRepository.selectedLanguage() + val key = "REFERENDA_SUMMARIES:$chainId:$referendaHashCode:${selectedLanguage.iso639Code}" + + return computationalCache.useCache(key, scope) { + referendumDetailsRepository.loadSummaries(governanceOption.assetWithChain.chain, referendaIds, selectedLanguage) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/track/Mappers.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/track/Mappers.kt new file mode 100644 index 0000000..501c877 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/track/Mappers.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.domain.track + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackInfo +import io.novafoundation.nova.feature_governance_api.domain.track.Track + +fun mapTrackInfoToTrack(trackInfo: TrackInfo): Track { + return Track(trackInfo.id, trackInfo.name) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/track/TracksUseCase.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/track/TracksUseCase.kt new file mode 100644 index 0000000..798cdab --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/track/TracksUseCase.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_governance_impl.domain.track + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.runtime.state.selectedOption + +interface TracksUseCase { + + suspend fun tracksOf(trackIds: Collection): List +} + +suspend fun TracksUseCase.tracksByIdOf(trackIds: Collection) = tracksOf(trackIds).associateBy { it.id } + +class RealTracksUseCase( + private val governanceSharedState: GovernanceSharedState, + private val governanceSourceRegistry: GovernanceSourceRegistry, +) : TracksUseCase { + + override suspend fun tracksOf(trackIds: Collection): List { + val option = governanceSharedState.selectedOption() + val source = governanceSourceRegistry.sourceFor(option) + val chain = option.assetWithChain.chain + + val trackIdsSet = trackIds.toSet() + + return source.referenda.getTracks(chain.id) + .filter { it.id in trackIdsSet } + .map(::mapTrackInfoToTrack) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/track/category/TrackCategorizer.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/track/category/TrackCategorizer.kt new file mode 100644 index 0000000..1b6a542 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/domain/track/category/TrackCategorizer.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_governance_impl.domain.track.category + +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackCategory +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.AUCTION_ADMIN +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.BIG_SPEND +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.BIG_TIPPER +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.FELLOWSHIP_ADMIN +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.GENERAL_ADMIN +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.LEASE_ADMIN +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.MEDIUM_SPEND +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.OTHER +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.REFERENDUM_CANCELLER +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.REFERENDUM_KILLER +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.ROOT +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.SMALL_SPEND +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.SMALL_TIPPER +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.STAKING_ADMIN +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.TREASURER +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType.WHITELISTED_CALLER + +interface TrackCategorizer { + + fun categoryOf(trackName: String): TrackCategory + + fun typeOf(trackName: String): TrackType +} + +class RealTrackCategorizer : TrackCategorizer { + + override fun categoryOf(trackName: String): TrackCategory { + return when (typeOf(trackName)) { + FELLOWSHIP_ADMIN, WHITELISTED_CALLER -> TrackCategory.FELLOWSHIP + + TREASURER, + SMALL_TIPPER, BIG_TIPPER, + SMALL_SPEND, MEDIUM_SPEND, BIG_SPEND -> TrackCategory.TREASURY + + LEASE_ADMIN, GENERAL_ADMIN, + REFERENDUM_KILLER, REFERENDUM_CANCELLER -> TrackCategory.GOVERNANCE + + else -> TrackCategory.OTHER + } + } + + override fun typeOf(trackName: String): TrackType { + return when (trackName) { + "root" -> ROOT + "whitelisted_caller" -> WHITELISTED_CALLER + "staking_admin" -> STAKING_ADMIN + "treasurer" -> TREASURER + "lease_admin" -> LEASE_ADMIN + "fellowship_admin" -> FELLOWSHIP_ADMIN + "general_admin" -> GENERAL_ADMIN + "auction_admin" -> AUCTION_ADMIN + "referendum_canceller" -> REFERENDUM_CANCELLER + "referendum_killer" -> REFERENDUM_KILLER + "small_tipper" -> SMALL_TIPPER + "big_tipper" -> BIG_TIPPER + "small_spender" -> SMALL_SPEND + "medium_spender" -> MEDIUM_SPEND + "big_spender" -> BIG_SPEND + else -> OTHER + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/GovernanceRouter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/GovernanceRouter.kt new file mode 100644 index 0000000..eb3ac34 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/GovernanceRouter.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.feature_governance_impl.presentation + +import android.os.Bundle +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.DescriptionPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.DelegateDelegatorsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.VotedReferendaPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.NewDelegationChooseTracksPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.NewDelegationConfirmPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.RemoveVotesPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.RevokeDelegationChooseTracksPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.RevokeDelegationConfirmPayload +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.ReferendumInfoPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumFullDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.ConfirmVoteReferendumPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.ReferendumVotersPayload + +interface GovernanceRouter : ReturnableRouter { + + fun openReferendum(payload: ReferendumDetailsPayload) + + fun openReferendaSearch() + + fun openReferendaFilters() + + fun openDAppBrowser(url: String) + + fun openReferendumDescription(payload: DescriptionPayload) + + fun openReferendumFullDetails(payload: ReferendumFullDetailsPayload) + + fun openReferendumVoters(payload: ReferendumVotersPayload) + + fun openSetupReferendumVote(payload: SetupVotePayload) + + fun openSetupTinderGovVote(payload: SetupVotePayload) + + fun openConfirmGovernanceUnlock() + + fun openConfirmVoteReferendum(payload: ConfirmVoteReferendumPayload) + + fun openGovernanceLocksOverview() + + fun backToReferendumDetails() + + fun finishUnlockFlow(shouldCloseLocksScreen: Boolean) + + fun openWalletDetails(id: Long) + + fun openAddDelegation() + + fun openYourDelegations() + + fun openDelegateDetails(payload: DelegateDetailsPayload) + + fun openVotedReferenda(payload: VotedReferendaPayload) + + fun openDelegateFullDescription(payload: DescriptionPayload) + + fun openBecomingDelegateTutorial() + + fun openRemoveVotes(payload: RemoveVotesPayload) + + fun openDelegateDelegators(payload: DelegateDelegatorsPayload) + + fun openNewDelegationChooseTracks(payload: NewDelegationChooseTracksPayload) + + fun openNewDelegationChooseAmount(payload: NewDelegationChooseAmountPayload) + + fun openNewDelegationConfirm(payload: NewDelegationConfirmPayload) + + fun backToYourDelegations() + + fun openRevokeDelegationChooseTracks(payload: RevokeDelegationChooseTracksPayload) + + fun openRevokeDelegationsConfirm(payload: RevokeDelegationConfirmPayload) + + fun openDelegateSearch() + + fun openSelectGovernanceTracks(bundle: Bundle) + + fun openTinderGovCards() + + fun openTinderGovBasket() + + fun openConfirmTinderGovVote() + + fun backToTinderGovCards() + + fun openReferendumInfo(payload: ReferendumInfoPayload) + + fun openReferendumFromDeepLink(payload: ReferendumDetailsPayload) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/confirmVote/ConfirmVoteFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/confirmVote/ConfirmVoteFragment.kt new file mode 100644 index 0000000..0829c13 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/confirmVote/ConfirmVoteFragment.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.confirmVote + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentReferendumConfirmVoteBinding +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.view.setAmountChangeModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +abstract class ConfirmVoteFragment : BaseFragment() { + + override fun createBinding() = FragmentReferendumConfirmVoteBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmReferendumVoteToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.confirmReferendumVoteConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmReferendumVoteConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.confirmReferendumVoteInformation.setOnAccountClickedListener { viewModel.accountClicked() } + } + + override fun subscribe(viewModel: T) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.confirmReferendumVoteHints) + + setupFeeLoading(viewModel, binder.confirmReferendumVoteInformation.fee) + + viewModel.currentAddressModelFlow.observe(binder.confirmReferendumVoteInformation::setAccount) + viewModel.walletModel.observe(binder.confirmReferendumVoteInformation::setWallet) + + viewModel.amountModelFlow.observe(binder.confirmReferendumVoteAmount::setAmount) + + viewModel.accountVoteUi.observe(binder.confirmReferendumVoteResult::setModel) + + viewModel.titleFlow.observe(binder.confirmReferendumVoteToolbar::setTitle) + + viewModel.locksChangeUiFlow.observe { + binder.confirmReferendumVoteLockedAmountChanges.setAmountChangeModel(it.amountChange) + binder.confirmReferendumVoteLockedPeriodChanges.setAmountChangeModel(it.periodChange) + binder.confirmReferendumVoteTransferableAmountChanges.setAmountChangeModel(it.transferableChange) + } + + viewModel.showNextProgress.observe(binder.confirmReferendumVoteConfirm::setProgressState) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/confirmVote/ConfirmVoteViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/confirmVote/ConfirmVoteViewModel.kt new file mode 100644 index 0000000..fff8b21 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/confirmVote/ConfirmVoteViewModel.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.confirmVote + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.hints.ReferendumVoteHintsMixinFactory +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.model.LocksChangeModel +import io.novafoundation.nova.feature_governance_impl.presentation.view.YourMultiVoteModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.WithFeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +abstract class ConfirmVoteViewModel( + private val router: GovernanceRouter, + private val feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val externalActions: ExternalActions.Presentation, + private val governanceSharedState: GovernanceSharedState, + private val hintsMixinFactory: ReferendumVoteHintsMixinFactory, + private val walletUiUseCase: WalletUiUseCase, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val addressIconGenerator: AddressIconGenerator, + private val assetUseCase: AssetUseCase, + private val validationExecutor: ValidationExecutor +) : BaseViewModel(), + Validatable by validationExecutor, + WithFeeLoaderMixin, + ExternalActions by externalActions { + + abstract val titleFlow: Flow + + abstract val amountModelFlow: Flow + + abstract val locksChangeUiFlow: Flow + + abstract val accountVoteUi: Flow + + protected val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + override val originFeeMixin: FeeLoaderMixin.Presentation = feeLoaderMixinFactory.create(assetFlow) + + val hintsMixin = hintsMixinFactory.create(scope = this) + + val walletModel: Flow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val currentAddressModelFlow = selectedAccountUseCase.selectedMetaAccountFlow().map { metaAccount -> + val chain = governanceSharedState.chain() + + addressIconGenerator.createAccountAddressModel(chain, metaAccount) + }.shareInBackground() + + protected val _showNextProgress = MutableStateFlow(false) + + val showNextProgress: Flow = _showNextProgress + + fun accountClicked() = launch { + val addressModel = currentAddressModelFlow.first() + + externalActions.showAddressActions(addressModel.address, governanceSharedState.chain()) + } + + abstract fun confirmClicked() + + fun backClicked() { + router.back() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/conviction/ConvictionValuesProvider.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/conviction/ConvictionValuesProvider.kt new file mode 100644 index 0000000..b2d9b8d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/conviction/ConvictionValuesProvider.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.conviction + +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.view.input.seekbar.SeekbarValue +import io.novafoundation.nova.common.view.input.seekbar.SeekbarValues +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amountMultiplier +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction + +interface ConvictionValuesProvider { + + fun convictionValues(): SeekbarValues +} + +class RealConvictionValuesProvider : ConvictionValuesProvider { + + override fun convictionValues(): SeekbarValues { + val colors = listOf( + R.color.conviction_slider_text_01x, + R.color.conviction_slider_text_1x, + R.color.conviction_slider_text_2x, + R.color.conviction_slider_text_3x, + R.color.conviction_slider_text_4x, + R.color.conviction_slider_text_5x, + R.color.conviction_slider_text_6x + ) + + val values = Conviction.values().mapIndexed { index, conviction -> + SeekbarValue( + value = conviction, + label = "${conviction.amountMultiplier().format()}x", + labelColorRes = colors[index] + ) + } + + return SeekbarValues(values) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/DescriptionFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/DescriptionFragment.kt new file mode 100644 index 0000000..c5c043f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/DescriptionFragment.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.description + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentDescriptionBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent + +class DescriptionFragment : BaseFragment() { + + companion object { + private const val KEY_PAYLOAD = "KEY_PAYLOAD" + + fun getBundle(descriptionPayload: DescriptionPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, descriptionPayload) + } + } + } + + override fun createBinding() = FragmentDescriptionBinding.inflate(layoutInflater) + + override fun initViews() { + binder.descriptionToolbar.setHomeButtonListener { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(this, GovernanceFeatureApi::class.java) + .descriptionFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: DescriptionViewModel) { + viewModel.markdownDescription.observe { + viewModel.markwon.setParsedMarkdown(binder.descriptionFullDescription, it) + } + + binder.descriptionTitle.setTextOrHide(viewModel.title) + binder.descriptionToolbar.setTitle(viewModel.toolbarTitle) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/DescriptionPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/DescriptionPayload.kt new file mode 100644 index 0000000..e2bf9f4 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/DescriptionPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.description + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class DescriptionPayload( + val description: String, + val toolbarTitle: String? = null, + val title: String? = null +) : Parcelable diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/DescriptionViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/DescriptionViewModel.kt new file mode 100644 index 0000000..c6c5356 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/DescriptionViewModel.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.description + +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter + +class DescriptionViewModel( + private val router: GovernanceRouter, + private val payload: DescriptionPayload, + val markwon: Markwon, +) : BaseViewModel() { + + val title = payload.title + val toolbarTitle = payload.toolbarTitle + + val markdownDescription = flowOf { markwon.toMarkdown(payload.description) } + .shareInBackground() + + fun backClicked() { + router.back() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/di/DescriptionComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/di/DescriptionComponent.kt new file mode 100644 index 0000000..9ecf3c5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/di/DescriptionComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.description.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.DescriptionFragment +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.DescriptionPayload + +@Subcomponent( + modules = [ + DescriptionModule::class + ] +) +@ScreenScope +interface DescriptionComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: DescriptionPayload, + ): DescriptionComponent + } + + fun inject(fragment: DescriptionFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/di/DescriptionModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/di/DescriptionModule.kt new file mode 100644 index 0000000..65a04d1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/description/di/DescriptionModule.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.description.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.di.modules.shared.MarkdownFullModule +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.DescriptionPayload +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.DescriptionViewModel + +@Module(includes = [ViewModelModule::class, MarkdownFullModule::class]) +class DescriptionModule { + + @Provides + @IntoMap + @ViewModelKey(DescriptionViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + payload: DescriptionPayload, + markwon: Markwon + ): ViewModel { + return DescriptionViewModel( + router = router, + payload = payload, + markwon = markwon + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): DescriptionViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(DescriptionViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/ReferendumInfoFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/ReferendumInfoFragment.kt new file mode 100644 index 0000000..cf07492 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/ReferendumInfoFragment.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.info + +import android.os.Bundle +import android.view.Gravity +import androidx.core.view.isGone +import androidx.core.view.isVisible + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.setAddressOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentReferendumInfoBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.common.share.setupReferendumSharing +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.setReferendumTimeEstimation +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.setReferendumTrackModel + +class ReferendumInfoFragment : BaseFragment() { + + companion object { + private const val KEY_PAYLOAD = "KEY_PAYLOAD" + + fun getBundle(descriptionPayload: ReferendumInfoPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, descriptionPayload) + } + } + } + + override fun createBinding() = FragmentReferendumInfoBinding.inflate(layoutInflater) + + override fun initViews() { + binder.referendumInfoToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.referendumInfoToolbar.setRightActionClickListener { viewModel.shareButtonClicked() } + + binder.referendumInfoProposer.setOnClickListener { + viewModel.proposerClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature(this, GovernanceFeatureApi::class.java) + .referendumInfoFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ReferendumInfoViewModel) { + setupExternalActions(viewModel) + setupReferendumSharing(viewModel.shareReferendumMixin) + + viewModel.titleFlow.observe { binder.referendumInfoTitle.text = it } + viewModel.subtitleFlow.observe { binder.referendumInfoDescription.text = it } + viewModel.idFlow.observe { binder.referendumInfoNumber.setText(it) } + viewModel.trackFlow.observe { binder.referendumInfoTrack.setReferendumTrackModel(it) } + viewModel.timeEstimation.observe { binder.referendumInfoTime.setReferendumTimeEstimation(it, Gravity.END) } + viewModel.proposerAddressModel.observeWhenVisible(binder.referendumInfoProposer::setAddressOrHide) + viewModel.isLoadingState.observe { + binder.referendumInfoContainer.isGone = it + binder.referendumInfoProgress.isVisible = it + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/ReferendumInfoPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/ReferendumInfoPayload.kt new file mode 100644 index 0000000..e978047 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/ReferendumInfoPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.info + +import android.os.Parcelable +import java.math.BigInteger +import kotlinx.parcelize.Parcelize + +@Parcelize +class ReferendumInfoPayload( + val referendumId: BigInteger +) : Parcelable diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/ReferendumInfoViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/ReferendumInfoViewModel.kt new file mode 100644 index 0000000..109727f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/ReferendumInfoViewModel.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.info + +import androidx.lifecycle.viewModelScope +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.filterLoaded +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createIdentityAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetailsInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.identity.GovernanceIdentityProviderFactory +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.share.ShareReferendumMixin +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAsset +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ReferendumInfoViewModel( + private val router: GovernanceRouter, + private val payload: ReferendumInfoPayload, + private val interactor: ReferendumDetailsInteractor, + private val selectedAssetSharedState: GovernanceSharedState, + private val referendumFormatter: ReferendumFormatter, + private val resourceManager: ResourceManager, + private val governanceIdentityProviderFactory: GovernanceIdentityProviderFactory, + private val addressIconGenerator: AddressIconGenerator, + private val externalActions: ExternalActions.Presentation, + val shareReferendumMixin: ShareReferendumMixin, + val markwon: Markwon, +) : BaseViewModel(), ExternalActions by externalActions { + + val referendumDetailsFlow = flowOfAll { + val governanceOption = selectedAssetSharedState.selectedOption() + interactor.referendumDetailsFlow(ReferendumId(payload.referendumId), governanceOption, voterAccountId = null, viewModelScope) + } + .filterNotNull() + .withSafeLoading() + .shareInBackground() + + val titleFlow = referendumDetailsFlow.filterLoaded() + .map { referendumFormatter.formatReferendumName(it) } + + val subtitleFlow = referendumDetailsFlow.filterLoaded() + .map { + val subtitle = it.offChainMetadata?.description ?: resourceManager.getString(R.string.referendum_description_fallback) + markwon.toMarkdown(subtitle) + } + + val idFlow = referendumDetailsFlow.filterLoaded() + .map { referendumFormatter.formatId(it.id) } + + val trackFlow = referendumDetailsFlow.filterLoaded() + .map { + val chainAsset = selectedAssetSharedState.chainAsset() + it.track?.let { referendumFormatter.formatReferendumTrack(it, chainAsset) } + } + + private val proposerFlow = referendumDetailsFlow.mapLoading { it.proposer } + .filterLoaded() + + private val proposerIdentityProvider = governanceIdentityProviderFactory.proposerProvider(proposerFlow) + + val proposerAddressModel = referendumDetailsFlow.filterLoaded() + .map { + it.proposer?.let { proposer -> + addressIconGenerator.createIdentityAddressModel( + chain = selectedAssetSharedState.chain(), + accountId = proposer.accountId, + identityProvider = proposerIdentityProvider + ) + } + }.inBackground() + .shareWhileSubscribed() + + val timeEstimation = referendumDetailsFlow.mapLoading { + referendumFormatter.formatTimeEstimation(it.timeline.currentStatus) + }.filterLoaded() + + val isLoadingState = referendumDetailsFlow.map { it.isLoading() } + + fun backClicked() { + router.back() + } + + fun shareButtonClicked() { + launch { + val selectedOption = selectedAssetSharedState.selectedOption() + shareReferendumMixin.shareReferendum( + payload.referendumId, + selectedOption.assetWithChain.chain, + selectedOption.additional.governanceType + ) + } + } + + fun proposerClicked() = launch { + val proposer = proposerAddressModel.first()?.address ?: return@launch + + externalActions.showAddressActions(proposer, selectedAssetSharedState.chain()) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/di/DescriptionComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/di/DescriptionComponent.kt new file mode 100644 index 0000000..059aad5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/di/DescriptionComponent.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.description.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.ReferendumInfoFragment +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.ReferendumInfoPayload +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.di.ReferendumInfoModule + +@Subcomponent( + modules = [ + ReferendumInfoModule::class + ] +) +@ScreenScope +interface ReferendumInfoComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ReferendumInfoPayload, + ): ReferendumInfoComponent + } + + fun inject(fragment: ReferendumInfoFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/di/DescriptionModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/di/DescriptionModule.kt new file mode 100644 index 0000000..3d41cde --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/info/di/DescriptionModule.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.info.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.modules.shared.MarkdownFullModule +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetailsInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.identity.GovernanceIdentityProviderFactory +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.ReferendumInfoPayload +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.ReferendumInfoViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.share.ShareReferendumMixin +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter + +@Module(includes = [ViewModelModule::class, MarkdownFullModule::class]) +class ReferendumInfoModule { + + @Provides + @IntoMap + @ViewModelKey(ReferendumInfoViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + payload: ReferendumInfoPayload, + interactor: ReferendumDetailsInteractor, + selectedAssetSharedState: GovernanceSharedState, + referendumFormatter: ReferendumFormatter, + resourceManager: ResourceManager, + governanceIdentityProviderFactory: GovernanceIdentityProviderFactory, + addressIconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + shareReferendumMixin: ShareReferendumMixin, + markwon: Markwon + ): ViewModel { + return ReferendumInfoViewModel( + router = router, + payload = payload, + interactor = interactor, + selectedAssetSharedState = selectedAssetSharedState, + referendumFormatter = referendumFormatter, + resourceManager = resourceManager, + governanceIdentityProviderFactory = governanceIdentityProviderFactory, + addressIconGenerator = addressIconGenerator, + externalActions = externalActions, + shareReferendumMixin = shareReferendumMixin, + markwon = markwon + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ReferendumInfoViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ReferendumInfoViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/locks/AmountChipModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/locks/AmountChipModel.kt new file mode 100644 index 0000000..2e4306d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/locks/AmountChipModel.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.locks + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.themed +import io.novafoundation.nova.feature_governance_impl.R + +class AmountChipModel( + val amountInput: String, + val label: String +) + +fun LinearLayout.setChips( + newChips: List, + onClicked: (AmountChipModel) -> Unit, + scrollingParent: View +) { + scrollingParent.setVisible(newChips.isNotEmpty()) + removeAllViews() + + newChips.forEach { chipModel -> + val view = ChipView(context).apply { + text = chipModel.label + + setOnClickListener { onClicked(chipModel) } + } + + addView(view) + } +} + +private fun ChipView(context: Context) = TextView(context.themed(R.style.Widget_Nova_Action_Secondary)).apply { + layoutParams = ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + marginEnd = 8.dp(context) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/locks/LocksFormatter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/locks/LocksFormatter.kt new file mode 100644 index 0000000..cf9a395 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/locks/LocksFormatter.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.locks + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.toAmountInput +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.ReusableLock +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel + +interface LocksFormatter { + + fun formatReusableLock(reusableLock: ReusableLock, asset: Asset): AmountChipModel +} + +class RealLocksFormatter( + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : LocksFormatter { + + override fun formatReusableLock( + reusableLock: ReusableLock, + asset: Asset + ): AmountChipModel { + val labelFormat = when (reusableLock.type) { + ReusableLock.Type.GOVERNANCE -> R.string.referendum_vote_chip_governance_lock + ReusableLock.Type.ALL -> R.string.referendum_vote_chip_all_locks + } + + val amount = asset.token.amountFromPlanks(reusableLock.amount) + val amountModel = amountFormatter.formatAmountToAmountModel(amount, asset) + + return AmountChipModel( + amountInput = amount.toAmountInput(), + label = resourceManager.getString(labelFormat, amountModel.token) + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/share/ShareReferendumMixin.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/share/ShareReferendumMixin.kt new file mode 100644 index 0000000..4c8efc2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/share/ShareReferendumMixin.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.share + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDeepLinkData +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +interface ShareReferendumMixin { + val shareEvent: LiveData> + + fun shareReferendum(referendumId: BigInteger, chain: Chain, governanceType: Chain.Governance) +} + +class RealShareReferendumMixin( + private val referendumLinkConfigurator: ReferendumDetailsDeepLinkConfigurator +) : ShareReferendumMixin { + + private val _shareEvent = MutableLiveData>() + override val shareEvent: LiveData> = _shareEvent + + override fun shareReferendum(referendumId: BigInteger, chain: Chain, governanceType: Chain.Governance) { + val payload = ReferendumDeepLinkData(chain.id, referendumId, governanceType) + + val uri = referendumLinkConfigurator.configure(payload, type = DeepLinkConfigurator.Type.APP_LINK) + _shareEvent.value = uri.event() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/share/ShareReferendumUI.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/share/ShareReferendumUI.kt new file mode 100644 index 0000000..b218a37 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/share/ShareReferendumUI.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.share + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.shareText + +fun BaseFragment<*, *>.setupReferendumSharing(mixin: ShareReferendumMixin) { + mixin.shareEvent.observeEvent { referendumLink -> + requireContext().shareText(referendumLink.toString()) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/voters/VoterModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/voters/VoterModel.kt new file mode 100644 index 0000000..42bdb1d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/voters/VoterModel.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.voters + +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.common.view.showValueOrHide + +class VoterModel( + val addressModel: AddressModel, + val vote: VoteModel +) +data class VoteModel( + val votesCount: String, + val votesCountDetails: String? +) + +data class VoteDirectionModel(val text: String, @ColorRes val textColor: Int) + +fun TableCellView.setVoteModel(model: VoteModel) { + showValue(model.votesCount, model.votesCountDetails) +} + +fun TableCellView.setVoteModelOrHide(model: VoteModel?) { + showValueOrHide(model?.votesCount, model?.votesCountDetails) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/voters/VotersFormatter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/voters/VotersFormatter.kt new file mode 100644 index 0000000..299c8c4 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/common/voters/VotersFormatter.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.common.voters + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amountMultiplier +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.GenericVoter +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.GenericVoter.ConvictionVote +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import java.math.BigDecimal + +interface VotersFormatter { + + suspend fun formatVoter( + voter: GenericVoter<*>, + chain: Chain, + chainAsset: Chain.Asset, + voteCountDetails: String, + ): VoterModel + + suspend fun formatVoter( + voter: GenericVoter<*>, + chain: Chain, + chainAsset: Chain.Asset, + voteModel: VoteModel + ): VoterModel + + fun formatConvictionVoteDetails( + convictionVote: ConvictionVote, + chainAsset: Chain.Asset + ): String + + fun formatTotalVotes(vote: GenericVoter.Vote?): String + + fun formatVoteType(voteType: VoteType): VoteDirectionModel + + fun formatVotes( + amount: BigDecimal, + voteType: VoteType, + conviction: Conviction + ): String +} + +suspend fun VotersFormatter.formatConvictionVote(convictionVote: ConvictionVote, chainAsset: Chain.Asset): VoteModel { + return VoteModel( + votesCount = formatTotalVotes(convictionVote), + votesCountDetails = formatConvictionVoteDetails(convictionVote, chainAsset) + ) +} + +suspend fun VotersFormatter.formatConvictionVoter( + voter: GenericVoter, + chain: Chain, + chainAsset: Chain.Asset, +): VoterModel { + val formattedVote = voter.vote?.let { formatConvictionVoteDetails(it, chainAsset) }.orEmpty() + return formatVoter(voter, chain, chainAsset, formattedVote) +} + +class RealVotersFormatter( + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager +) : VotersFormatter { + + override suspend fun formatVoter(voter: GenericVoter<*>, chain: Chain, chainAsset: Chain.Asset, voteCountDetails: String): VoterModel { + return formatVoter( + voter = voter, + chain = chain, + chainAsset = chainAsset, + voteModel = VoteModel( + votesCount = formatTotalVotes(voter.vote), + votesCountDetails = voteCountDetails + ) + ) + } + + override suspend fun formatVoter(voter: GenericVoter<*>, chain: Chain, chainAsset: Chain.Asset, voteModel: VoteModel): VoterModel { + val addressModel = addressIconGenerator.createAccountAddressModel(chain, voter.accountId, voter.identity?.name) + + return VoterModel( + addressModel = addressModel, + vote = voteModel + ) + } + + override fun formatConvictionVoteDetails(convictionVote: ConvictionVote, chainAsset: Chain.Asset): String { + val preConvictionAmountFormatted = convictionVote.amount.formatTokenAmount(chainAsset) + val multiplierFormatted = convictionVote.conviction.amountMultiplier().format() + + return resourceManager.getString( + R.string.referendum_voter_vote_details, + preConvictionAmountFormatted, + multiplierFormatted + ) + } + + override fun formatVotes(amount: BigDecimal, voteType: VoteType, conviction: Conviction): String { + val votes = if (voteType == VoteType.ABSTAIN) { + amount * Conviction.None.amountMultiplier() + } else { + amount * conviction.amountMultiplier() + } + + return resourceManager.getString(R.string.referendum_voter_vote, votes.format()) + } + + override fun formatVoteType(voteType: VoteType): VoteDirectionModel { + return when (voteType) { + VoteType.AYE -> VoteDirectionModel(resourceManager.getString(R.string.referendum_vote_aye), R.color.text_positive) + VoteType.NAY -> VoteDirectionModel(resourceManager.getString(R.string.referendum_vote_nay), R.color.text_negative) + VoteType.ABSTAIN -> VoteDirectionModel(resourceManager.getString(R.string.referendum_vote_abstain), R.color.text_secondary) + } + } + + override fun formatTotalVotes(vote: GenericVoter.Vote?): String { + val formattedAmount = vote?.totalVotes.orZero().format() + + return resourceManager.getString(R.string.referendum_voter_vote, formattedAmount) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/DelegateMappers.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/DelegateMappers.kt new file mode 100644 index 0000000..5316662 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/DelegateMappers.kt @@ -0,0 +1,228 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.getConvictionVote +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.Delegate +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.DelegateAccountType +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.model.Delegator +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.model.DelegatorVote +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegatePreview +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.RECENT_VOTES_PERIOD +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.DelegateIcon +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.DelegateLabelModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.DelegateListModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.DelegateStatsModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.DelegateTypeModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.RecentVotes +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackDelegationModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.formatConvictionVote +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface DelegateMappers { + + suspend fun mapDelegatePreviewToUi(delegatePreview: DelegatePreview, chainWithAsset: ChainWithAsset): DelegateListModel + + suspend fun formatDelegationsOverview(votes: Delegator.Vote, chainAsset: Chain.Asset): VoteModel + + suspend fun formatDelegation(delegation: Voting.Delegating, chainAsset: Chain.Asset): VoteModel + + suspend fun formatTrackDelegation(delegation: Voting.Delegating, track: Track, chainAsset: Chain.Asset): TrackDelegationModel + + fun mapDelegateTypeToUi(delegateType: DelegateAccountType?): DelegateTypeModel? + + suspend fun mapDelegateIconToUi(accountId: AccountId, metadata: Delegate.Metadata?): DelegateIcon + + suspend fun formatDelegateName(metadata: Delegate.Metadata?, identityName: String?, accountId: AccountId, chain: Chain): String + + suspend fun formatDelegationStats(stats: DelegatePreview.Stats, chainAsset: Chain.Asset): DelegateStatsModel + + fun formattedRecentVotesPeriod(@StringRes stringRes: Int): String + + suspend fun formatDelegateLabel( + accountId: AccountId, + metadata: Delegate.Metadata?, + identityName: String?, + chain: Chain + ): DelegateLabelModel +} + +suspend fun DelegateMappers.formatDelegationsOverviewOrNull(votes: Delegator.Vote?, chainAsset: Chain.Asset): VoteModel? { + return votes?.let { formatDelegationsOverview(votes, chainAsset) } +} + +class RealDelegateMappers( + private val resourceManager: ResourceManager, + private val addressIconGenerator: AddressIconGenerator, + private val trackFormatter: TrackFormatter, + private val votersFormatter: VotersFormatter +) : DelegateMappers { + + override suspend fun mapDelegatePreviewToUi( + delegatePreview: DelegatePreview, + chainWithAsset: ChainWithAsset, + ): DelegateListModel { + return DelegateListModel( + icon = mapDelegateIconToUi(delegatePreview.accountId, delegatePreview.metadata), + accountId = delegatePreview.accountId, + name = formatDelegateName( + metadata = delegatePreview.metadata, + identityName = delegatePreview.onChainIdentity?.display, + accountId = delegatePreview.accountId, + chain = chainWithAsset.chain + ), + type = mapDelegateTypeToUi(delegatePreview.metadata?.accountType), + description = delegatePreview.metadata?.shortDescription, + stats = delegatePreview.stats?.let { formatDelegationStats(it, chainWithAsset.asset) }, + delegation = delegatePreview.userDelegations?.let { mapDelegation(it, chainWithAsset.asset) } + ) + } + + override suspend fun formatDelegationsOverview(votes: Delegator.Vote, chainAsset: Chain.Asset): VoteModel { + val voteDetails = when (votes) { + is Delegator.Vote.MultiTrack -> { + resourceManager.getString(R.string.delegation_multi_track_format, votes.trackCount) + } + is Delegator.Vote.SingleTrack -> { + votersFormatter.formatConvictionVoteDetails(votes.delegation, chainAsset) + } + } + + val totalVotes = votersFormatter.formatTotalVotes(votes) + + return VoteModel(totalVotes, voteDetails) + } + + override suspend fun formatDelegation(delegation: Voting.Delegating, chainAsset: Chain.Asset): VoteModel { + val convictionVote = delegation.getConvictionVote(chainAsset) + + return votersFormatter.formatConvictionVote(convictionVote, chainAsset) + } + + override suspend fun formatTrackDelegation(delegation: Voting.Delegating, track: Track, chainAsset: Chain.Asset): TrackDelegationModel { + return TrackDelegationModel( + track = trackFormatter.formatTrack(track, chainAsset), + delegation = formatDelegation(delegation, chainAsset) + ) + } + + override fun mapDelegateTypeToUi(delegateType: DelegateAccountType?): DelegateTypeModel? { + return when (delegateType) { + DelegateAccountType.INDIVIDUAL -> DelegateTypeModel( + text = resourceManager.getString(R.string.delegation_delegate_type_individual), + iconRes = R.drawable.ic_individual, + textColorRes = R.color.individual_chip_text, + backgroundColorRes = R.color.individual_chip_background, + iconColorRes = R.color.individual_chip_icon, + ) + + DelegateAccountType.ORGANIZATION -> DelegateTypeModel( + text = resourceManager.getString(R.string.delegation_delegate_type_organization), + iconRes = R.drawable.ic_organization, + iconColorRes = R.color.organization_chip_icon, + textColorRes = R.color.organization_chip_icon, + backgroundColorRes = R.color.organization_chip_background, + ) + + null -> null + } + } + + override suspend fun mapDelegateIconToUi(accountId: AccountId, metadata: Delegate.Metadata?): DelegateIcon { + val iconUrl = metadata?.iconUrl + val accountType = metadata?.accountType + + return if (iconUrl != null) { + val icon = Icon.FromLink(iconUrl) + + DelegateIcon(accountType.iconShape(), icon) + } else { + val addressIcon = addressIconGenerator.createAddressIcon( + accountId, + AddressIconGenerator.SIZE_BIG, + AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + val icon = Icon.FromDrawable(addressIcon) + + DelegateIcon(DelegateIcon.IconShape.NONE, icon) + } + } + + private fun DelegateAccountType?.iconShape(): DelegateIcon.IconShape { + return when (this) { + DelegateAccountType.INDIVIDUAL -> DelegateIcon.IconShape.ROUND + DelegateAccountType.ORGANIZATION -> DelegateIcon.IconShape.SQUARE + null -> DelegateIcon.IconShape.NONE + } + } + + override suspend fun formatDelegateName(metadata: Delegate.Metadata?, identityName: String?, accountId: AccountId, chain: Chain): String { + return identityName ?: metadata?.name ?: chain.addressOf(accountId) + } + + override suspend fun formatDelegationStats(stats: DelegatePreview.Stats, chainAsset: Chain.Asset): DelegateStatsModel { + return DelegateStatsModel( + delegations = stats.delegationsCount.format(), + delegatedVotes = chainAsset.amountFromPlanks(stats.delegatedVotes).format(), + recentVotes = RecentVotes( + label = formattedRecentVotesPeriod(R.string.delegation_recent_votes_format), + value = stats.recentVotes.format() + ) + ) + } + + override fun formattedRecentVotesPeriod(@StringRes stringRes: Int): String { + return resourceManager.getString( + stringRes, + resourceManager.formatDuration(RECENT_VOTES_PERIOD, estimated = false) + ) + } + + override suspend fun formatDelegateLabel( + accountId: AccountId, + metadata: Delegate.Metadata?, + identityName: String?, + chain: Chain + ): DelegateLabelModel { + return DelegateLabelModel( + icon = mapDelegateIconToUi(accountId, metadata), + address = chain.addressOf(accountId), + name = formatDelegateName( + metadata = metadata, + identityName = identityName, + accountId = accountId, + chain = chain + ), + type = mapDelegateTypeToUi(metadata?.accountType) + ) + } + + private suspend fun mapDelegation(votes: Map, chainAsset: Chain.Asset): DelegateListModel.YourDelegationInfo? { + if (votes.isEmpty()) return null + + val firstTrack = trackFormatter.formatTrack(votes.keys.first(), chainAsset) + val otherTracksCount = votes.size - 1 + val otherTracksCountStr = if (otherTracksCount > 0) resourceManager.getString(R.string.delegate_more_tracks, otherTracksCount) else null + + val delegatorVotes = DelegatorVote(votes.values, chainAsset) + + return DelegateListModel.YourDelegationInfo( + firstTrack = firstTrack, + otherTracksCount = otherTracksCountStr, + votes = formatDelegationsOverviewOrNull(delegatorVotes, chainAsset) + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/adapter/DelegateListAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/adapter/DelegateListAdapter.kt new file mode 100644 index 0000000..a57ef03 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/adapter/DelegateListAdapter.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.adapter + +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil.ItemCallback +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getRippleMask +import io.novafoundation.nova.feature_governance_impl.databinding.ItemDelegateBinding +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.setDelegateIcon +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.setDelegateTypeModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.DelegateListModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.setTrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.view.setTextOrHide + +class DelegateListAdapter( + private val imageLoader: ImageLoader, + private val handler: Handler +) : BaseListAdapter(DelegateDiffCallback()) { + + interface Handler { + + fun itemClicked(position: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DelegateViewHolder { + return DelegateViewHolder(ItemDelegateBinding.inflate(parent.inflater(), parent, false), imageLoader, handler) + } + + override fun onBindViewHolder(holder: DelegateViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private class DelegateDiffCallback : ItemCallback() { + + override fun areItemsTheSame(oldItem: DelegateListModel, newItem: DelegateListModel): Boolean { + return oldItem.accountId.contentEquals(newItem.accountId) + } + + override fun areContentsTheSame(oldItem: DelegateListModel, newItem: DelegateListModel): Boolean { + return oldItem.stats == newItem.stats && + oldItem.delegation == newItem.delegation + } +} + +class DelegateViewHolder( + private val binder: ItemDelegateBinding, + private val imageLoader: ImageLoader, + handler: DelegateListAdapter.Handler +) : BaseViewHolder(binder.root) { + + init { + with(binder) { + binder.root.setOnClickListener { handler.itemClicked(bindingAdapterPosition) } + + itemDelegateCardView.foreground = with(context) { addRipple(mask = getRippleMask(0)) } + } + } + + fun bind(model: DelegateListModel) = with(binder) { + itemDelegateIcon.setDelegateIcon(icon = model.icon, imageLoader = imageLoader, squareCornerRadiusDp = 8) + itemDelegateTitle.text = model.name + itemDelegateDescription.setTextOrHide(model.description) + itemDelegateStatsGroup.isVisible = model.stats != null + if (model.stats != null) { + itemDelegateDelegations.text = model.stats.delegations + itemDelegateDelegatedVotes.text = model.stats.delegatedVotes + itemDelegateRecentVotes.text = model.stats.recentVotes.value + itemDelegateRecentVotesLabel.text = model.stats.recentVotes.label + } + itemDelegateType.setDelegateTypeModel(model.type) + + val delegation = model.delegation + itemDelegateVotedBlock.isVisible = delegation != null + if (delegation != null) { + itemVotedTrack.setTrackModel(delegation.firstTrack) + itemVotedTracksCount.setTextOrHide(delegation.otherTracksCount) + itemDelegateVotesDetails.isVisible = delegation.votes != null + if (delegation.votes != null) { + itemDelegateVotes.text = delegation.votes.votesCount + itemDelegateConvictionAmount.text = delegation.votes.votesCountDetails + } + } + } + + override fun unbind() = with(binder) { + itemDelegateIcon.clear() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateLabelModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateLabelModel.kt new file mode 100644 index 0000000..56ecda6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateLabelModel.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.common.view.showLoadingState + +class DelegateLabelModel( + val icon: DelegateIcon, + val address: String, + val name: String?, + val type: DelegateTypeModel? +) + +fun DelegateLabelModel.nameOrAddress(): String { + return name ?: address +} + +fun TableCellView.setDelegateLabelModel(model: DelegateLabelModel) { + image.makeVisible() + image.setDelegateIcon(icon = model.icon, imageLoader = imageLoader, squareCornerRadiusDp = 4) + + showValue(model.nameOrAddress()) +} + +fun TableCellView.setDelegateLabelState(state: ExtendedLoadingState) { + showLoadingState(state, ::setDelegateLabelModel) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateListModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateListModel.kt new file mode 100644 index 0000000..e345d52 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateListModel.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model + +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteModel +import io.novasama.substrate_sdk_android.runtime.AccountId + +class DelegateListModel( + val icon: DelegateIcon, + val accountId: AccountId, + val name: String, + val type: DelegateTypeModel?, + val description: String?, + val stats: DelegateStatsModel?, + val delegation: YourDelegationInfo? +) { + + data class YourDelegationInfo( + val firstTrack: TrackModel, + val otherTracksCount: String?, + val votes: VoteModel?, + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateStatsModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateStatsModel.kt new file mode 100644 index 0000000..72532b0 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateStatsModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model + +data class DelegateStatsModel( + val delegations: String, + val delegatedVotes: String, + val recentVotes: RecentVotes +) + +data class RecentVotes(val label: String, val value: String) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateTypeModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateTypeModel.kt new file mode 100644 index 0000000..6fadc5d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/common/model/DelegateTypeModel.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model + +import android.widget.ImageView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.core.view.setPadding +import coil.ImageLoader +import coil.transform.CircleCropTransformation +import coil.transform.RoundedCornersTransformation +import coil.transform.Transformation +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.view.NovaChipView + +class DelegateTypeModel( + val text: String, + @DrawableRes val iconRes: Int, + @ColorRes val textColorRes: Int, + @ColorRes val iconColorRes: Int, + @ColorRes val backgroundColorRes: Int, +) + +class DelegateIcon(val shape: IconShape, val icon: Icon) { + + enum class IconShape { + ROUND, SQUARE, NONE + } +} + +fun NovaChipView.setDelegateTypeModel(model: DelegateTypeModel?) { + setVisible(model != null) + if (model == null) return + + setText(model.text) + setIcon(model.iconRes) + setStyle(model.backgroundColorRes, model.textColorRes, model.iconColorRes) +} + +fun NovaChipView.setDelegateTypeModelIcon(model: DelegateTypeModel?) { + setVisible(model != null) + if (model == null) return + + setIcon(model.iconRes) + setStyle(model.backgroundColorRes, model.textColorRes, model.iconColorRes) +} + +fun ImageView.setDelegateIcon( + icon: DelegateIcon, + imageLoader: ImageLoader, + squareCornerRadiusDp: Int +) { + val strokeWidthDp = 0.5f + val borderPadding = strokeWidthDp.dp(context) + .coerceAtLeast(1) + setPadding(borderPadding) + + if (icon.shape == DelegateIcon.IconShape.SQUARE) { + background = context.getRoundedCornerDrawable( + null, + R.color.container_border, + cornerSizeInDp = squareCornerRadiusDp, + strokeSizeInDp = strokeWidthDp + ) + } else { + setBackgroundResource(R.drawable.bg_induvidual_circle_border) + } + + setIcon(icon.icon, imageLoader) { + icon.shape.coilTransformation(squareCornerRadiusDp.dpF(context))?.let { + transformations(it) + } + } +} + +private fun DelegateIcon.IconShape.coilTransformation(cornerRadiusPx: Float): Transformation? { + return when (this) { + DelegateIcon.IconShape.ROUND -> CircleCropTransformation() + DelegateIcon.IconShape.SQUARE -> RoundedCornersTransformation(cornerRadiusPx) + DelegateIcon.IconShape.NONE -> null + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/DelegateDelegatorsFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/DelegateDelegatorsFragment.kt new file mode 100644 index 0000000..2fe57fb --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/DelegateDelegatorsFragment.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators + +import android.os.Bundle +import android.view.View + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentDelegateDelegatorsBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoterModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.list.DelegatorsAdapter + +class DelegateDelegatorsFragment : BaseFragment(), DelegatorsAdapter.Handler { + + companion object { + private const val KEY_PAYLOAD = "payload" + + fun getBundle(payload: DelegateDelegatorsPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentDelegateDelegatorsBinding.inflate(layoutInflater) + + private val delegatorsAdapter = DelegatorsAdapter(this) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initViews() + } + + override fun initViews() { + binder.delegateDelegatorsList.setHasFixedSize(true) + binder.delegateDelegatorsList.adapter = delegatorsAdapter + + binder.delegateDelegatorsToolbar.setHomeButtonListener { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .delegateDelegatorsFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: DelegateDelegatorsViewModel) { + setupExternalActions(viewModel) + + viewModel.delegatorModels.observe { state -> + when (state) { + is ExtendedLoadingState.Error -> {} + is ExtendedLoadingState.Loaded -> { + delegatorsAdapter.submitList(state.data) + binder.delegateDelegatorsList.makeVisible() + binder.delegateDelegatorsProgress.makeGone() + } + ExtendedLoadingState.Loading -> { + binder.delegateDelegatorsList.makeGone() + binder.delegateDelegatorsProgress.makeVisible() + } + } + } + + viewModel.delegatorsCount.observe { state -> + if (state is ExtendedLoadingState.Loaded) { + binder.delegateDelegatorsCount.text = state.data + binder.delegateDelegatorsCount.makeVisible() + } else { + binder.delegateDelegatorsCount.makeGone() + } + } + } + + override fun onVoterClick(voter: VoterModel) { + viewModel.delegatorClicked(voter) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/DelegateDelegatorsPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/DelegateDelegatorsPayload.kt new file mode 100644 index 0000000..346cbf0 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/DelegateDelegatorsPayload.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators + +import android.os.Parcelable +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize + +@Parcelize +class DelegateDelegatorsPayload(val delegateId: AccountId) : Parcelable diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/DelegateDelegatorsViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/DelegateDelegatorsViewModel.kt new file mode 100644 index 0000000..32ed829 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/DelegateDelegatorsViewModel.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.DelegateDelegatorsInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.model.Delegator +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoterModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAsset +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class DelegateDelegatorsViewModel( + payload: DelegateDelegatorsPayload, + interactor: DelegateDelegatorsInteractor, + private val router: GovernanceRouter, + private val governanceSharedState: GovernanceSharedState, + private val externalActions: ExternalActions.Presentation, + private val votersFormatter: VotersFormatter, + private val delegateMappers: DelegateMappers, +) : BaseViewModel(), ExternalActions by externalActions { + + private val chainFlow = flowOf { governanceSharedState.chain() } + private val chainAssetFlow = flowOf { governanceSharedState.chainAsset() } + + private val delegatorsList = interactor.delegatorsFlow(payload.delegateId) + .withSafeLoading() + .shareInBackground() + + val delegatorsCount = delegatorsList.mapLoading { + it.size.format() + }.shareInBackground() + + val delegatorModels = delegatorsList.mapLoading { delegators -> + val chain = chainFlow.first() + val chainAsset = chainAssetFlow.first() + mapDelegatorsToDelegatorModels(chain, chainAsset, delegators) + } + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun delegatorClicked(voter: VoterModel) = launch { + val chain = chainFlow.first() + externalActions.showAddressActions(voter.addressModel.address, chain) + } + + private suspend fun mapDelegatorsToDelegatorModels(chain: Chain, chainAsset: Chain.Asset, voters: List): List { + return voters.map { delegator -> + val delegationModel = delegateMappers.formatDelegationsOverview(delegator.vote, chainAsset) + votersFormatter.formatVoter(delegator, chain, chainAsset, delegationModel) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/di/DelegateDelegatorsComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/di/DelegateDelegatorsComponent.kt new file mode 100644 index 0000000..8010154 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/di/DelegateDelegatorsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.DelegateDelegatorsFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.DelegateDelegatorsPayload + +@Subcomponent( + modules = [ + DelegateDelegatorsModule::class + ] +) +@ScreenScope +interface DelegateDelegatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance parcelable: DelegateDelegatorsPayload, + ): DelegateDelegatorsComponent + } + + fun inject(fragment: DelegateDelegatorsFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/di/DelegateDelegatorsModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/di/DelegateDelegatorsModule.kt new file mode 100644 index 0000000..3a50605 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/di/DelegateDelegatorsModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.DelegateDelegatorsInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.DelegateDelegatorsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.DelegateDelegatorsViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter + +@Module(includes = [ViewModelModule::class]) +class DelegateDelegatorsModule { + + @Provides + @IntoMap + @ViewModelKey(DelegateDelegatorsViewModel::class) + fun provideViewModel( + payload: DelegateDelegatorsPayload, + router: GovernanceRouter, + governanceSharedState: GovernanceSharedState, + externalActions: ExternalActions.Presentation, + interactor: DelegateDelegatorsInteractor, + votersFormatter: VotersFormatter, + delegateMappers: DelegateMappers, + ): ViewModel { + return DelegateDelegatorsViewModel( + payload = payload, + router = router, + governanceSharedState = governanceSharedState, + externalActions = externalActions, + interactor = interactor, + votersFormatter = votersFormatter, + delegateMappers = delegateMappers + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): DelegateDelegatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(DelegateDelegatorsViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/list/DelegatorsAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/list/DelegatorsAdapter.kt new file mode 100644 index 0000000..ec026f3 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/delegators/list/DelegatorsAdapter.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ItemDelegatorBinding +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoterModel + +class DelegatorsAdapter( + private val handler: Handler, +) : ListAdapter(DiffCallback()) { + + interface Handler { + + fun onVoterClick(voter: VoterModel) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DelegatorHolder { + return DelegatorHolder(ItemDelegatorBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: DelegatorHolder, position: Int) { + val voter = getItem(position) + holder.bind(voter) + } +} + +private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: VoterModel, newItem: VoterModel): Boolean { + return oldItem.addressModel.address == newItem.addressModel.address + } + + override fun areContentsTheSame(oldItem: VoterModel, newItem: VoterModel): Boolean { + return true + } +} + +class DelegatorHolder( + private val binder: ItemDelegatorBinding, + private val eventHandler: DelegatorsAdapter.Handler +) : GroupedListHolder(binder.root) { + + init { + containerView.setBackgroundResource(R.drawable.bg_primary_list_item) + } + + fun bind(item: VoterModel) = with(binder) { + binder.root.setOnClickListener { eventHandler.onVoterClick(item) } + itemDelegatorImage.setImageDrawable(item.addressModel.image) + itemDelegatorAddress.text = item.addressModel.nameOrAddress + itemDelegatorVotesCount.text = item.vote.votesCount + itemDelegatorVotesCountDetails.text = item.vote.votesCountDetails + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/DelegatesSharedComputation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/DelegatesSharedComputation.kt new file mode 100644 index 0000000..395babe --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/DelegatesSharedComputation.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_governance_api.data.source.SupportedGovernanceOption +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegatePreview +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.mapDelegateStatsToPreviews +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.repository.DelegateCommonRepository +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class DelegatesSharedComputation( + private val computationalCache: ComputationalCache, + private val delegateCommonRepository: DelegateCommonRepository, + private val chainStateRepository: ChainStateRepository, + private val identityRepository: OnChainIdentityRepository +) { + + suspend fun delegates( + governanceOption: SupportedGovernanceOption, + scope: CoroutineScope + ): Flow> { + val chainId = governanceOption.assetWithChain.chain.id + val key = "DELEGATES:$chainId" + + return computationalCache.useSharedFlow(key, scope) { + val chain = governanceOption.assetWithChain.chain + val delegateMetadataDeferred = scope.async { delegateCommonRepository.getMetadata(governanceOption) } + val delegatesStatsDeferred = scope.async { delegateCommonRepository.getDelegatesStats(governanceOption) } + val tracksDeferred = scope.async { delegateCommonRepository.getTracks(governanceOption) } + + chainStateRepository.currentBlockNumberFlow(chain.timelineChainIdOrSelf()).map { + val userDelegates = delegateCommonRepository.getUserDelegationsOrEmpty(governanceOption, tracksDeferred.await()) + val userDelegateIds = userDelegates.keys.map { it.value } + + val identities = identityRepository.getIdentitiesFromIds(userDelegateIds, chain.id) + + mapDelegateStatsToPreviews( + delegatesStatsDeferred.await(), + delegateMetadataDeferred.await(), + identities, + userDelegates + ) + } + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsFragment.kt new file mode 100644 index 0000000..df345a8 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsFragment.kt @@ -0,0 +1,143 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main + +import android.os.Bundle + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.common.view.setExtraInfoAvailable +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.setupIdentityMixin +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentDelegateDetailsBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.setDelegateIcon +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.setDelegateTypeModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.view.setModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.ShortenedTextModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.applyTo +import io.novafoundation.nova.feature_governance_impl.presentation.track.list.TrackDelegationListBottomSheet + +import javax.inject.Inject + +class DelegateDetailsFragment : BaseFragment() { + + companion object { + private const val PAYLOAD = "DelegateDetailsFragment.Payload" + + fun getBundle(payload: DelegateDetailsPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentDelegateDetailsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun initViews() { + binder.delegateDetailsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.delegateDetailsDelegations.setOnClickListener { viewModel.delegationsClicked() } + binder.delegateDetailsVotedRecently.setOnClickListener { viewModel.recentVotesClicked() } + binder.delegateDetailsVotedOverall.setOnClickListener { viewModel.allVotesClicked() } + + binder.delegateDetailsAccount.setOnClickListener { viewModel.accountActionsClicked() } + + binder.delegateDetailsDescriptionReadMore.setOnClickListener { viewModel.readMoreClicked() } + + binder.delegateDetailsAddDelegation.setOnClickListener { viewModel.addDelegationClicked() } + + binder.delegateDetailsYourDelegation.onTracksClicked { viewModel.tracksClicked() } + binder.delegateDetailsYourDelegation.onEditClicked { viewModel.editDelegationClicked() } + binder.delegateDetailsYourDelegation.onRevokeClicked { viewModel.revokeDelegationClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .delegateDetailsFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: DelegateDetailsViewModel) { + setupExternalActions(viewModel) + setupIdentityMixin(viewModel.identityMixin, binder.delegateDetailsIdentity) + observeValidations(viewModel) + + viewModel.delegateDetailsLoadingState.observeWhenVisible { loadingState -> + when (loadingState) { + is ExtendedLoadingState.Error -> {} + is ExtendedLoadingState.Loaded -> { + binder.delegateDetailsContent.makeVisible() + binder.delegateDetailsProgress.makeGone() + + setContent(loadingState.data) + } + ExtendedLoadingState.Loading -> { + binder.delegateDetailsContent.makeGone() + binder.delegateDetailsProgress.makeVisible() + } + } + } + + viewModel.showTracksEvent.observeEvent { tracksDelegations -> + TrackDelegationListBottomSheet(requireContext(), tracksDelegations) + .show() + } + + viewModel.addDelegationButtonState.observeWhenVisible(binder.delegateDetailsAddDelegation::setState) + } + + private fun setContent(delegate: DelegateDetailsModel) { + val stats = delegate.stats + + binder.delegateDetailsDelegatedVotes.showValueOrHide(stats?.delegatedVotes) + + binder.delegateDetailsDelegations.setVotesModel(stats?.delegations) + binder.delegateDetailsVotedOverall.setVotesModel(stats?.allVotes) + binder.delegateDetailsVotedRecently.setVotesModel(stats?.recentVotes) + + if (delegate.metadata.description != null) { + binder.delegateDetailsMetadataGroup.makeVisible() + + with(delegate.metadata) { + binder.delegateDetailsIcon.setDelegateIcon(icon, imageLoader, 12) + binder.delegateDetailsTitle.text = name + binder.delegateDetailsType.setDelegateTypeModel(accountType) + setDescription(description) + } + } else { + binder.delegateDetailsMetadataGroup.makeGone() + } + + binder.delegateDetailsAccount.setAddressModel(delegate.addressModel) + + binder.delegateDetailsYourDelegation.setModel(delegate.userDelegation) + } + + private fun TableCellView.setVotesModel(model: DelegateDetailsModel.VotesModel?) = letOrHide(model) { votesModel -> + showValue(votesModel.votes) + + setExtraInfoAvailable(votesModel.extraInfoAvalable) + + votesModel.customLabel?.let(::setTitle) + } + + private fun setDescription(maybeModel: ShortenedTextModel?) { + maybeModel.applyTo(binder.delegateDetailsDescription, binder.delegateDetailsDescriptionReadMore, viewModel.markwon) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsModel.kt new file mode 100644 index 0000000..4a445dd --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsModel.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.DelegateIcon +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.DelegateTypeModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.view.YourDelegationModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.ShortenedTextModel + +class DelegateDetailsModel( + val addressModel: AddressModel, + val metadata: Metadata, + val stats: Stats?, + val userDelegation: YourDelegationModel? +) { + + class Stats( + val delegations: VotesModel, + val delegatedVotes: String, + val recentVotes: VotesModel, + val allVotes: VotesModel, + ) + + class VotesModel( + val extraInfoAvalable: Boolean, + val votes: String, + val customLabel: String? + ) + + class Metadata( + val name: String?, + val icon: DelegateIcon, + val accountType: DelegateTypeModel?, + val description: ShortenedTextModel?, + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsPayload.kt new file mode 100644 index 0000000..35d5533 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main + +import android.os.Parcelable +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize + +@Parcelize +class DelegateDetailsPayload( + val accountId: AccountId +) : Parcelable diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsViewModel.kt new file mode 100644 index 0000000..c648052 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/DelegateDetailsViewModel.kt @@ -0,0 +1,279 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.firstLoaded +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.withLoadingShared +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.validation.handleChainAccountNotFound +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.delegators.model.DelegatorVote +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.AddDelegationValidationFailure +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.AddDelegationValidationPayload +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.DelegateDetails +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.DelegateDetailsInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.description +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.DescriptionPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.formatDelegationsOverviewOrNull +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.delegators.DelegateDelegatorsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsModel.Metadata +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsModel.Stats +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsModel.VotesModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.view.YourDelegationModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.VotedReferendaPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.NewDelegationChooseTracksPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.RevokeDelegationChooseTracksPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.DefaultCharacterLimit +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.ShortenedTextModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackDelegationModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAndAsset +import io.novafoundation.nova.runtime.state.chainAsset +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class DelegateDetailsViewModel( + private val interactor: DelegateDetailsInteractor, + private val payload: DelegateDetailsPayload, + private val iconGenerator: AddressIconGenerator, + private val externalActions: ExternalActions.Presentation, + private val identityMixinFactory: IdentityMixin.Factory, + private val router: GovernanceRouter, + private val delegateMappers: DelegateMappers, + private val governanceSharedState: GovernanceSharedState, + private val trackFormatter: TrackFormatter, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val selectedAccountUseCase: SelectedAccountUseCase, + val markwon: Markwon, +) : BaseViewModel(), ExternalActions.Presentation by externalActions, Validatable by validationExecutor { + + val identityMixin = identityMixinFactory.create() + + private val _showTracksEvent = MutableLiveData>>() + val showTracksEvent: LiveData>> = _showTracksEvent + + private val delegateDetailsFlow = interactor.delegateDetailsFlow(payload.accountId) + .withLoadingShared() + .shareWhileSubscribed() + + val delegateDetailsLoadingState = delegateDetailsFlow.mapLoading { delegateDetails -> + val (chain, chainAsset) = governanceSharedState.chainAndAsset() + + mapDelegateDetailsToUi(delegateDetails, chain, chainAsset) + } + .shareWhileSubscribed() + + private val trackDelegationModels = delegateDetailsFlow.mapLoading { + val chainAsset = governanceSharedState.chainAsset() + + it.userDelegations.map { (track, delegation) -> + delegateMappers.formatTrackDelegation(delegation, track, chainAsset) + } + }.shareWhileSubscribed() + + val addDelegationButtonState = delegateDetailsFlow.map { state -> + val data = state.dataOrNull + + when { + data == null -> DescriptiveButtonState.Gone + data.userDelegations.isNotEmpty() -> DescriptiveButtonState.Gone + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_add_delegation)) + } + }.shareWhileSubscribed() + + init { + useIdentity() + } + + fun backClicked() { + router.back() + } + + fun tracksClicked() = launch { + val trackDelegationModels = trackDelegationModels.firstLoaded() + + _showTracksEvent.value = trackDelegationModels.event() + } + + fun editDelegationClicked() { + openNewDelegation(editMode = true) + } + + fun revokeDelegationClicked() { + val nextPayload = RevokeDelegationChooseTracksPayload(payload.accountId) + router.openRevokeDelegationChooseTracks(nextPayload) + } + + fun accountActionsClicked() = launch { + val address = delegateDetailsLoadingState.firstLoaded().addressModel.address + val chain = governanceSharedState.chain() + + externalActions.showAddressActions(address, chain) + } + + fun delegationsClicked() { + router.openDelegateDelegators(DelegateDelegatorsPayload(payload.accountId)) + } + + fun recentVotesClicked() { + openVotedReferenda(onlyRecentVotes = true, title = delegateMappers.formattedRecentVotesPeriod(R.string.delegation_recent_votes_format)) + } + + fun allVotesClicked() { + openVotedReferenda(onlyRecentVotes = false) + } + + fun addDelegationClicked() { + openNewDelegation(editMode = false) + } + + fun readMoreClicked() = launch { + val delegateMetadata = delegateDetailsFlow.first().dataOrNull?.metadata + val description = delegateMetadata?.description ?: return@launch + + val descriptionPayload = DescriptionPayload( + description = description, + toolbarTitle = delegateMetadata.name + ) + + router.openDelegateFullDescription(descriptionPayload) + } + + private fun useIdentity() = launch { + val identity = delegateDetailsFlow.firstLoaded().onChainIdentity + identityMixin.setIdentity(identity) + } + + private suspend fun mapDelegateDetailsToUi( + delegateDetails: DelegateDetails, + chain: Chain, + chainAsset: Chain.Asset, + ): DelegateDetailsModel { + return DelegateDetailsModel( + addressModel = createDelegateAddressModel(delegateDetails, chain), + metadata = createDelegateMetadata(delegateDetails, chain), + stats = formatDelegationStats(delegateDetails.stats, chainAsset), + userDelegation = formatYourDelegation(delegateDetails.userDelegations, chainAsset) + ) + } + + private suspend fun createDelegateAddressModel(delegateDetails: DelegateDetails, chain: Chain): AddressModel { + val willShowNameOnTop = delegateDetails.metadata != null + + val addressModelName = if (willShowNameOnTop) { + null + } else { + delegateDetails.onChainIdentity?.display + } + + return iconGenerator.createAccountAddressModel(chain, delegateDetails.accountId, addressModelName) + } + + private suspend fun createDelegateMetadata(delegateDetails: DelegateDetails, chain: Chain): Metadata { + return Metadata( + name = delegateMappers.formatDelegateName(delegateDetails.metadata, delegateDetails.onChainIdentity?.display, delegateDetails.accountId, chain), + icon = delegateMappers.mapDelegateIconToUi(delegateDetails.accountId, delegateDetails.metadata), + accountType = delegateMappers.mapDelegateTypeToUi(delegateDetails.metadata?.accountType), + description = createDelegateDescription(delegateDetails.metadata) + ) + } + + private suspend fun formatYourDelegation(votes: Map, chainAsset: Chain.Asset): YourDelegationModel? { + if (votes.isEmpty()) return null + + val delegatorVote = DelegatorVote(votes.values, chainAsset) + + return YourDelegationModel( + trackSummary = trackFormatter.formatTracksSummary(votes.keys, chainAsset), + vote = delegateMappers.formatDelegationsOverviewOrNull(delegatorVote, chainAsset) + ) + } + + private suspend fun formatDelegationStats(stats: DelegateDetails.Stats?, chainAsset: Chain.Asset): Stats? { + if (stats == null) return null + + return Stats( + delegations = VotesModel( + votes = stats.delegationsCount.format(), + extraInfoAvalable = stats.delegationsCount > 0, + customLabel = null + ), + delegatedVotes = chainAsset.amountFromPlanks(stats.delegatedVotes).format(), + recentVotes = VotesModel( + votes = stats.recentVotes.format(), + extraInfoAvalable = stats.recentVotes > 0, + customLabel = delegateMappers.formattedRecentVotesPeriod(R.string.delegation_recent_votes_format), + ), + allVotes = VotesModel( + votes = stats.allVotes.format(), + extraInfoAvalable = stats.allVotes > 0, + customLabel = null + ) + ) + } + + private fun createDelegateDescription(metadata: DelegateDetails.Metadata?): ShortenedTextModel? { + val description = metadata?.description ?: return null + val markdownParsed = markwon.toMarkdown(description) + return ShortenedTextModel.from(markdownParsed, DefaultCharacterLimit.SHORT_PARAGRAPH) + } + + private fun openNewDelegation(editMode: Boolean) = launch { + val chain = governanceSharedState.chain() + val metaAccount = selectedAccountUseCase.getSelectedMetaAccount() + val validationPayload = AddDelegationValidationPayload(chain, metaAccount) + + validationExecutor.requireValid( + validationSystem = interactor.validationSystemFor(), + payload = validationPayload, + validationFailureTransformerCustom = { status, _ -> mapValidationFailureToUi(status.reason) } + ) { + val nextPayload = NewDelegationChooseTracksPayload(payload.accountId, editMode) + router.openNewDelegationChooseTracks(nextPayload) + } + } + + private fun openVotedReferenda(onlyRecentVotes: Boolean, title: String? = null) { + val votedReferendaPayload = VotedReferendaPayload(payload.accountId, onlyRecentVotes, overriddenTitle = title) + router.openVotedReferenda(votedReferendaPayload) + } + + private fun mapValidationFailureToUi(failure: AddDelegationValidationFailure): TransformedFailure { + return when (failure) { + is AddDelegationValidationFailure.NoChainAccountFailure -> handleChainAccountNotFound( + failure = failure, + resourceManager = resourceManager, + goToWalletDetails = { router.openWalletDetails(failure.account.id) }, + addAccountDescriptionRes = R.string.add_delegation_missing_account_message + ) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/di/DelegateDetailsComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/di/DelegateDetailsComponent.kt new file mode 100644 index 0000000..93b032e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/di/DelegateDetailsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsPayload + +@Subcomponent( + modules = [ + DelegateDetailsModule::class + ] +) +@ScreenScope +interface DelegateDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: DelegateDetailsPayload + ): DelegateDetailsComponent + } + + fun inject(fragment: DelegateDetailsFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/di/DelegateDetailsModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/di/DelegateDetailsModule.kt new file mode 100644 index 0000000..b623876 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/di/DelegateDetailsModule.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.modules.shared.MarkdownShortModule +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.details.model.DelegateDetailsInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter + +@Module(includes = [ViewModelModule::class, MarkdownShortModule::class]) +class DelegateDetailsModule { + + @Provides + @IntoMap + @ViewModelKey(DelegateDetailsViewModel::class) + fun provideViewModel( + interactor: DelegateDetailsInteractor, + payload: DelegateDetailsPayload, + iconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + identityMixinFactory: IdentityMixin.Factory, + router: GovernanceRouter, + delegateMappers: DelegateMappers, + governanceSharedState: GovernanceSharedState, + markwon: Markwon, + trackFormatter: TrackFormatter, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + selectedAccountUseCase: SelectedAccountUseCase + ): ViewModel { + return DelegateDetailsViewModel( + interactor = interactor, + payload = payload, + iconGenerator = iconGenerator, + externalActions = externalActions, + identityMixinFactory = identityMixinFactory, + router = router, + delegateMappers = delegateMappers, + governanceSharedState = governanceSharedState, + trackFormatter = trackFormatter, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + selectedAccountUseCase = selectedAccountUseCase, + markwon = markwon, + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): DelegateDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(DelegateDetailsViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/view/YourDelegationView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/view/YourDelegationView.kt new file mode 100644 index 0000000..385449e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/main/view/YourDelegationView.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ViewYourDelegationBinding +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteModel + +class YourDelegationModel( + val trackSummary: String, + val vote: VoteModel? +) + +class YourDelegationView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewYourDelegationBinding.inflate(inflater(), this) + + init { + background = getRoundedCornerDrawable(R.color.block_background) + + orientation = VERTICAL + } + + fun onTracksClicked(listener: () -> Unit) { + binder.viewYourDelegationTracks.setOnClickListener { listener() } + } + + fun onRevokeClicked(listener: () -> Unit) { + binder.viewYourDelegationRemove.setOnClickListener { listener() } + } + + fun onEditClicked(listener: () -> Unit) { + binder.viewYourDelegationEdit.setOnClickListener { listener() } + } + + fun setTrackSummary(summary: String) { + binder.viewYourDelegationTracks.showValue(summary) + } + + fun setVote(vote: VoteModel?) { + binder.viewYourDelegationDelegation.showValueOrHide(vote?.votesCount, vote?.votesCountDetails) + } +} + +fun YourDelegationView.setModel(maybeModel: YourDelegationModel?) = letOrHide(maybeModel) { model -> + setTrackSummary(model.trackSummary) + setVote(model.vote) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/VotedReferendaFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/VotedReferendaFragment.kt new file mode 100644 index 0000000..ae3db9a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/VotedReferendaFragment.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda + +import android.os.Bundle +import androidx.recyclerview.widget.ConcatAdapter + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentVotedReferendaBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.list.BaseReferendaListFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.ReferendaListAdapter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendumModel + +class VotedReferendaFragment : BaseReferendaListFragment(), ReferendaListAdapter.Handler { + + companion object { + private const val KEY_PAYLOAD = "VotedReferendaFragment.Payload" + + fun getBundle(payload: VotedReferendaPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentVotedReferendaBinding.inflate(layoutInflater) + + override fun initViews() { + viewModel.payload.overriddenTitle?.let { binder.votedReferendaToolbar.setTitle(it) } + binder.votedReferendaToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.votedReferendaList.itemAnimator = null + binder.votedReferendaList.adapter = ConcatAdapter(shimmeringAdapter, placeholderAdapter, referendaListAdapter) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .votedReferendaFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: VotedReferendaViewModel) { + viewModel.referendaUiFlow.observeReferendaList() + + viewModel.votedReferendaCount.observeWhenVisible { + binder.votedReferendaCount.makeVisible() + binder.votedReferendaCount.text = it + } + } + + override fun onReferendaClick(referendum: ReferendumModel) { + viewModel.openReferendum(referendum) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/VotedReferendaPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/VotedReferendaPayload.kt new file mode 100644 index 0000000..6f0bacf --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/VotedReferendaPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda + +import android.os.Parcelable +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize + +@Parcelize +class VotedReferendaPayload( + val accountId: AccountId, + val onlyRecentVotes: Boolean, + val overriddenTitle: String? +) : Parcelable diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/VotedReferendaViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/VotedReferendaViewModel.kt new file mode 100644 index 0000000..06fd68a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/VotedReferendaViewModel.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.withLoadingShared +import io.novafoundation.nova.common.view.PlaceholderModel +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.Voter +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.account +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.list.ReferendaListStateModel +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendumModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.toReferendumDetailsPrefilledData +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.map + +class VotedReferendaViewModel( + private val interactor: ReferendaListInteractor, + private val governanceSharedState: GovernanceSharedState, + private val selectedTokenUseCase: TokenUseCase, + private val governanceRouter: GovernanceRouter, + private val referendumFormatter: ReferendumFormatter, + private val resourceManager: ResourceManager, + val payload: VotedReferendaPayload, +) : BaseViewModel() { + + private val voter = Voter.account(payload.accountId) + + private val referendaListFlow = interactor.votedReferendaListFlow(voter, payload.onlyRecentVotes) + .inBackground() + .shareWhileSubscribed() + + val referendaUiFlow = referendaListFlow.map { referenda -> + mapReferendaListToStateList(referenda) + } + .inBackground() + .withLoadingShared() + .shareWhileSubscribed() + + val votedReferendaCount = referendaListFlow.map { + it.size.format() + } + .inBackground() + .shareWhileSubscribed() + + private suspend fun mapReferendaListToStateList(referenda: List): ReferendaListStateModel { + val token = selectedTokenUseCase.currentToken() + val chain = governanceSharedState.chain() + + val placeholder = if (referenda.isEmpty()) { + PlaceholderModel( + resourceManager.getString(R.string.referenda_list_placeholder), + R.drawable.ic_placeholder + ) + } else { + null + } + + val referendaUI = referenda.map { referendumFormatter.formatReferendumPreview(it, token, chain) } + + return ReferendaListStateModel(placeholder, referendaUI) + } + + fun openReferendum(referendum: ReferendumModel) { + val payload = ReferendumDetailsPayload(referendum.id.value, allowVoting = false, prefilledData = referendum.toReferendumDetailsPrefilledData()) + governanceRouter.openReferendum(payload) + } + + fun backClicked() { + governanceRouter.back() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/di/VotedReferendaComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/di/VotedReferendaComponent.kt new file mode 100644 index 0000000..91c8ee6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/di/VotedReferendaComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.VotedReferendaFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.VotedReferendaPayload + +@Subcomponent( + modules = [ + VotedReferendaModule::class + ] +) +@ScreenScope +interface VotedReferendaComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: VotedReferendaPayload + ): VotedReferendaComponent + } + + fun inject(fragment: VotedReferendaFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/di/VotedReferendaModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/di/VotedReferendaModule.kt new file mode 100644 index 0000000..ac1da3e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/detail/votedReferenda/di/VotedReferendaModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.VotedReferendaPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.votedReferenda.VotedReferendaViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase + +@Module(includes = [ViewModelModule::class]) +class VotedReferendaModule { + + @Provides + @IntoMap + @ViewModelKey(VotedReferendaViewModel::class) + fun provideViewModel( + interactor: ReferendaListInteractor, + governanceSharedState: GovernanceSharedState, + selectedTokenUseCase: TokenUseCase, + governanceRouter: GovernanceRouter, + referendumFormatter: ReferendumFormatter, + payload: VotedReferendaPayload, + resourceManager: ResourceManager + ): ViewModel { + return VotedReferendaViewModel( + interactor = interactor, + governanceSharedState = governanceSharedState, + selectedTokenUseCase = selectedTokenUseCase, + governanceRouter = governanceRouter, + referendumFormatter = referendumFormatter, + resourceManager = resourceManager, + payload = payload + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): VotedReferendaViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(VotedReferendaViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateBannerAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateBannerAdapter.kt new file mode 100644 index 0000000..aaa3305 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateBannerAdapter.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_governance_impl.databinding.ItemDelegationsHeaderBinding + +class DelegateBannerAdapter( + private val handler: Handler +) : RecyclerView.Adapter() { + + interface Handler { + fun closeBanner() + + fun describeYourselfClicked() + } + + private var showBanner: Boolean = false + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DelegationsHeaderViewHolder { + return DelegationsHeaderViewHolder(ItemDelegationsHeaderBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun getItemCount(): Int { + return if (showBanner) 1 else 0 + } + + override fun onBindViewHolder(holder: DelegationsHeaderViewHolder, position: Int) {} + + fun showBanner(show: Boolean) { + if (showBanner != show) { + showBanner = show + if (show) { + notifyItemInserted(0) + } else { + notifyItemRemoved(0) + } + } + } +} + +class DelegationsHeaderViewHolder( + binder: ItemDelegationsHeaderBinding, + handler: DelegateBannerAdapter.Handler +) : ViewHolder(binder.root) { + + init { + with(binder) { + itemDelegationBanner.setOnCloseClickListener { + handler.closeBanner() + } + delegateBannerMoreContent.setOnClickListener { + handler.describeYourselfClicked() + } + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateListFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateListFragment.kt new file mode 100644 index 0000000..fe55258 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateListFragment.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.list + +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.submitListPreservingViewPoint +import io.novafoundation.nova.common.view.input.chooser.setupListChooserMixinBottomSheet +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentDelegateListBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.adapter.DelegateListAdapter + +import javax.inject.Inject + +class DelegateListFragment : + BaseFragment(), + DelegateListAdapter.Handler, + DelegateBannerAdapter.Handler, + DelegateSortAndFilterAdapter.Handler { + + override fun createBinding() = FragmentDelegateListBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val bannerAdapter by lazy(LazyThreadSafetyMode.NONE) { DelegateBannerAdapter(this) } + private val sortAndFilterAdapter by lazy(LazyThreadSafetyMode.NONE) { DelegateSortAndFilterAdapter(this) } + private val placeholderAdapter by lazy(LazyThreadSafetyMode.NONE) { CustomPlaceholderAdapter(R.layout.item_delegates_shimmering) } + private val delegateListAdapter by lazy(LazyThreadSafetyMode.NONE) { DelegateListAdapter(imageLoader, this) } + private val adapter by lazy(LazyThreadSafetyMode.NONE) { ConcatAdapter(bannerAdapter, sortAndFilterAdapter, placeholderAdapter, delegateListAdapter) } + + override fun initViews() { + binder.delegateListList.itemAnimator = null + binder.delegateListList.setHasFixedSize(true) + binder.delegateListList.adapter = adapter + + binder.delegateListToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.delegateListToolbar.setRightActionClickListener { viewModel.openSearch() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .delegateListFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: DelegateListViewModel) { + setupListChooserMixinBottomSheet(viewModel.sortingMixin) + setupListChooserMixinBottomSheet(viewModel.filteringMixin) + + viewModel.sortingMixin.selectedOption.observe { + sortAndFilterAdapter.setSortingValue(it.display) + } + + viewModel.filteringMixin.selectedOption.observe { + sortAndFilterAdapter.setFilteringMixin(it.display) + } + + viewModel.shouldShowBannerFlow.observe { + bannerAdapter.showBanner(it) + } + + viewModel.delegateModels.observeWhenVisible { + when (it) { + is ExtendedLoadingState.Error -> {} + is ExtendedLoadingState.Loaded -> { + placeholderAdapter.show(false) + delegateListAdapter.submitListPreservingViewPoint(it.data, binder.delegateListList) + } + ExtendedLoadingState.Loading -> { + placeholderAdapter.show(true) + delegateListAdapter.submitList(emptyList()) + } + } + } + } + + override fun closeBanner() { + viewModel.closeBanner() + } + + override fun describeYourselfClicked() { + viewModel.openBecomingDelegateTutorial() + } + + override fun itemClicked(position: Int) { + viewModel.delegateClicked(position) + } + + override fun filteringClicked() { + viewModel.filteringMixin.selectorClicked() + } + + override fun sortingClicked() { + viewModel.sortingMixin.selectorClicked() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateListViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateListViewModel.kt new file mode 100644 index 0000000..ad26df5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateListViewModel.kt @@ -0,0 +1,108 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.list + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.withLoadingShared +import io.novafoundation.nova.common.view.input.chooser.ListChooserMixin +import io.novafoundation.nova.common.view.input.chooser.createFromEnum +import io.novafoundation.nova.common.view.input.chooser.selectedValue +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.DelegateListInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegateFiltering +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegateSorting +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsPayload +import io.novafoundation.nova.runtime.state.chainAndAsset +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class DelegateListViewModel( + private val interactor: DelegateListInteractor, + private val governanceSharedState: GovernanceSharedState, + private val delegateMappers: DelegateMappers, + private val listChooserMixinFactory: ListChooserMixin.Factory, + private val resourceManager: ResourceManager, + private val router: GovernanceRouter, +) : BaseViewModel() { + + val sortingMixin = listChooserMixinFactory.createFromEnum( + coroutineScope = viewModelScope, + displayOf = ::sortingDisplay, + initial = DelegateSorting.DELEGATIONS, + selectorTitleRes = R.string.common_sort_by + ) + + val filteringMixin = listChooserMixinFactory.createFromEnum( + coroutineScope = viewModelScope, + displayOf = ::filteringDisplay, + initial = DelegateFiltering.ALL_ACCOUNTS, + selectorTitleRes = R.string.wallet_filters_header + ) + + val shouldShowBannerFlow = interactor.shouldShowDelegationBanner() + .shareInBackground() + + private val delegatesFlow = governanceSharedState.selectedOption + .withLoadingShared { interactor.getDelegates(it, this) } + + private val sortedAndFilteredDelegates = combine( + sortingMixin.selectedValue, + filteringMixin.selectedValue, + delegatesFlow + ) { sorting, filtering, delegates -> + delegates.map { interactor.applySortingAndFiltering(sorting, filtering, it) } + } + + val delegateModels = sortedAndFilteredDelegates.mapLoading { delegates -> + val chainWithAsset = governanceSharedState.chainAndAsset() + delegates.map { delegateMappers.mapDelegatePreviewToUi(it, chainWithAsset) } + }.shareWhileSubscribed() + + fun delegateClicked(position: Int) = launch { + val delegate = delegateModels.first().dataOrNull?.getOrNull(position) ?: return@launch + + val payload = DelegateDetailsPayload(delegate.accountId) + router.openDelegateDetails(payload) + } + + fun backClicked() { + router.back() + } + + private suspend fun sortingDisplay(sorting: DelegateSorting): String { + return when (sorting) { + DelegateSorting.DELEGATIONS -> resourceManager.getString(R.string.delegation_sorting_delegations) + DelegateSorting.DELEGATED_VOTES -> resourceManager.getString(R.string.delegation_sorting_delegated_votes) + DelegateSorting.VOTING_ACTIVITY -> delegateMappers.formattedRecentVotesPeriod(R.string.delegation_sorting_recent_votes_format) + } + } + + private fun filteringDisplay(filtering: DelegateFiltering): String { + val resourceId = when (filtering) { + DelegateFiltering.ALL_ACCOUNTS -> R.string.delegation_delegate_filter_all + DelegateFiltering.ORGANIZATIONS -> R.string.delegation_delegate_filter_organizations + DelegateFiltering.INDIVIDUALS -> R.string.delegation_delegate_filter_individuals + } + + return resourceManager.getString(resourceId) + } + + fun openBecomingDelegateTutorial() { + router.openBecomingDelegateTutorial() + } + + fun openSearch() { + router.openDelegateSearch() + } + + fun closeBanner() = launch { + interactor.hideDelegationBanner() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateSortAndFilterAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateSortAndFilterAdapter.kt new file mode 100644 index 0000000..9b8a12f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/DelegateSortAndFilterAdapter.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_governance_impl.databinding.ItemDelegationsSortAndFilterBinding + +class DelegateSortAndFilterAdapter( + private val handler: Handler +) : RecyclerView.Adapter() { + + interface Handler { + fun filteringClicked() + + fun sortingClicked() + } + + private var sortingValue: String? = null + private var filteringValue: String? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DelegationSortAndFilterHolder { + return DelegationSortAndFilterHolder(ItemDelegationsSortAndFilterBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun getItemCount(): Int { + return 1 + } + + override fun onBindViewHolder(holder: DelegationSortAndFilterHolder, position: Int) { + holder.bind(sortingValue, filteringValue) + } + + fun setFilteringMixin(filtering: String) { + filteringValue = filtering + notifyItemChanged(0) + } + + fun setSortingValue(sorting: String) { + sortingValue = sorting + notifyItemChanged(0) + } +} + +class DelegationSortAndFilterHolder( + private val binder: ItemDelegationsSortAndFilterBinding, + handler: DelegateSortAndFilterAdapter.Handler +) : ViewHolder(binder.root) { + + init { + with(binder) { + itemDelegateListSorting.setOnClickListener { + handler.sortingClicked() + } + itemDelegateListFilters.setOnClickListener { + handler.filteringClicked() + } + } + } + + fun bind(sortingValue: String?, filteringValue: String?) { + with(binder) { + itemDelegateListSorting.setValueDisplay(sortingValue) + itemDelegateListFilters.setValueDisplay(filteringValue) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/di/DelegateListComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/di/DelegateListComponent.kt new file mode 100644 index 0000000..fa3dbfb --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/di/DelegateListComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.list.DelegateListFragment + +@Subcomponent( + modules = [ + DelegateListModule::class + ] +) +@ScreenScope +interface DelegateListComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): DelegateListComponent + } + + fun inject(fragment: DelegateListFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/di/DelegateListModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/di/DelegateListModule.kt new file mode 100644 index 0000000..8ef8b19 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/list/di/DelegateListModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.input.chooser.ListChooserMixin +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.DelegateListInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.list.DelegateListViewModel + +@Module(includes = [ViewModelModule::class]) +class DelegateListModule { + + @Provides + @IntoMap + @ViewModelKey(DelegateListViewModel::class) + fun provideViewModel( + delegateMappers: DelegateMappers, + governanceSharedState: GovernanceSharedState, + interactor: DelegateListInteractor, + listChooserMixinFactory: ListChooserMixin.Factory, + resourceManager: ResourceManager, + router: GovernanceRouter + ): ViewModel { + return DelegateListViewModel( + interactor = interactor, + governanceSharedState = governanceSharedState, + delegateMappers = delegateMappers, + listChooserMixinFactory = listChooserMixinFactory, + resourceManager = resourceManager, + router = router + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): DelegateListViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(DelegateListViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/DelegateSearchCountResultAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/DelegateSearchCountResultAdapter.kt new file mode 100644 index 0000000..dc5ead2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/DelegateSearchCountResultAdapter.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.search + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_governance_impl.databinding.ItemDelegationsSearchResultCountBinding + +class DelegateSearchCountResultAdapter : RecyclerView.Adapter() { + + private var countString: String? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DelegationSearchCountViewHolder { + return DelegationSearchCountViewHolder(ItemDelegationsSearchResultCountBinding.inflate(parent.inflater(), parent, false)) + } + + override fun getItemCount(): Int { + return 1 + } + + override fun onBindViewHolder(holder: DelegationSearchCountViewHolder, position: Int) { + holder.bind(countString) + } + + fun setSearchResultCount(countString: String?) { + if (this.countString != countString) { + this.countString = countString + notifyItemChanged(0) + } + } +} + +class DelegationSearchCountViewHolder( + private val binder: ItemDelegationsSearchResultCountBinding +) : ViewHolder(binder.root) { + + fun bind(countString: String?) { + binder.itemDelegationSearchCount.text = countString + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/DelegateSearchFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/DelegateSearchFragment.kt new file mode 100644 index 0000000..52e4353 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/DelegateSearchFragment.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.search + +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.hideKeyboard +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentDelegateSearchBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.adapter.DelegateListAdapter +import javax.inject.Inject + +class DelegateSearchFragment : + BaseFragment(), + DelegateListAdapter.Handler { + + override fun createBinding() = FragmentDelegateSearchBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val delegateSearchCountResultAdapter = DelegateSearchCountResultAdapter() + private val delegateListAdapter by lazy(LazyThreadSafetyMode.NONE) { DelegateListAdapter(imageLoader, this) } + private val adapter by lazy(LazyThreadSafetyMode.NONE) { ConcatAdapter(delegateSearchCountResultAdapter, delegateListAdapter) } + + override fun initViews() { + binder.delegateSearchList.itemAnimator = null + binder.delegateSearchList.setHasFixedSize(true) + binder.delegateSearchList.adapter = adapter + + binder.delegateSearchToolbar.setHomeButtonListener { + viewModel.backClicked() + hideKeyboard() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .delegateSearchFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: DelegateSearchViewModel) { + binder.delegateSearchField.content.bindTo(viewModel.query, lifecycleScope) + + viewModel.searchPlaceholderModel.observeWhenVisible { + binder.delegateSearchPlaceholder.isGone = it == null + if (it != null) { + binder.delegateSearchPlaceholder.setImage(it.drawableRes) + binder.delegateSearchPlaceholder.setText(it.textRes) + } + } + + viewModel.searchResultCount.observeWhenVisible { + delegateSearchCountResultAdapter.setSearchResultCount(it) + } + + viewModel.delegateModels.observeWhenVisible { + binder.delegateSearchList.isGone = it is ExtendedLoadingState.Loading + binder.delegateSearchProgressBar.isVisible = it is ExtendedLoadingState.Loading + + if (it is ExtendedLoadingState.Loaded) { + delegateListAdapter.submitList(it.data) + } + } + } + + override fun itemClicked(position: Int) { + viewModel.delegateClicked(position) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/DelegateSearchViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/DelegateSearchViewModel.kt new file mode 100644 index 0000000..8803e4e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/DelegateSearchViewModel.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.search + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.search.DelegateSearchInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsPayload +import io.novafoundation.nova.runtime.state.chainAndAsset +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SearchPlaceholderModel(@StringRes val textRes: Int, @DrawableRes val drawableRes: Int) + +class DelegateSearchViewModel( + private val interactor: DelegateSearchInteractor, + private val governanceSharedState: GovernanceSharedState, + private val delegateMappers: DelegateMappers, + private val resourceManager: ResourceManager, + private val router: GovernanceRouter, +) : BaseViewModel() { + + val query = MutableStateFlow("") + + private val searchResult = governanceSharedState.selectedOption + .flatMapLatest { interactor.searchDelegates(query, it, this) } + .distinctUntilChanged() + .inBackground() + .shareWhileSubscribed() + + val searchResultCount = searchResult + .map { + val delegates = it.dataOrNull + if (delegates.isNullOrEmpty()) { + null + } else { + resourceManager.getString(R.string.delegate_search_result_count, delegates.size) + } + }.inBackground() + .shareWhileSubscribed() + + val searchPlaceholderModel = combine(query, searchResult) { query, searchResultLoading -> + val delegates = searchResultLoading.dataOrNull + when { + searchResultLoading.isLoading() -> null + query.isNotEmpty() && delegates != null && delegates.isEmpty() -> { + SearchPlaceholderModel( + R.string.delegate_search_placeholder_empty, + R.drawable.ic_no_search_results + ) + } + delegates?.isEmpty() == true -> SearchPlaceholderModel( + R.string.common_search_placeholder_default, + R.drawable.ic_placeholder + ) + else -> null + } + } + + val delegateModels = searchResult.mapLoading { delegates -> + val chainWithAsset = governanceSharedState.chainAndAsset() + delegates.map { delegateMappers.mapDelegatePreviewToUi(it, chainWithAsset) } + }.shareWhileSubscribed() + + fun delegateClicked(position: Int) = launch { + val delegate = delegateModels.first().dataOrNull?.getOrNull(position) ?: return@launch + + val payload = DelegateDetailsPayload(delegate.accountId) + router.openDelegateDetails(payload) + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/di/DelegateSearchComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/di/DelegateSearchComponent.kt new file mode 100644 index 0000000..c871733 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/di/DelegateSearchComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.search.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.search.DelegateSearchFragment + +@Subcomponent( + modules = [ + DelegateSearchModule::class + ] +) +@ScreenScope +interface DelegateSearchComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): DelegateSearchComponent + } + + fun inject(fragment: DelegateSearchFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/di/DelegateSearchModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/di/DelegateSearchModule.kt new file mode 100644 index 0000000..4339e4a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegate/search/di/DelegateSearchModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.search.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.search.DelegateSearchInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.common.repository.DelegateCommonRepository +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegate.search.RealDelegateSearchInteractor +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.DelegatesSharedComputation +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.search.DelegateSearchViewModel + +@Module(includes = [ViewModelModule::class]) +class DelegateSearchModule { + + @Provides + fun provideDelegateListInteractor( + delegateCommonRepository: DelegateCommonRepository, + identityRepository: OnChainIdentityRepository, + delegatesSharedComputation: DelegatesSharedComputation + ): DelegateSearchInteractor = RealDelegateSearchInteractor( + identityRepository = identityRepository, + delegateCommonRepository = delegateCommonRepository, + delegatesSharedComputation = delegatesSharedComputation + ) + + @Provides + @IntoMap + @ViewModelKey(DelegateSearchViewModel::class) + fun provideViewModel( + delegateMappers: DelegateMappers, + governanceSharedState: GovernanceSharedState, + interactor: DelegateSearchInteractor, + resourceManager: ResourceManager, + router: GovernanceRouter + ): ViewModel { + return DelegateSearchViewModel( + interactor = interactor, + governanceSharedState = governanceSharedState, + delegateMappers = delegateMappers, + resourceManager = resourceManager, + router = router + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): DelegateSearchViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(DelegateSearchViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/YourDelegationsFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/YourDelegationsFragment.kt new file mode 100644 index 0000000..55e8d2f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/YourDelegationsFragment.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegated + +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.submitListPreservingViewPoint +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentYourDelegationsBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.adapter.DelegateListAdapter +import javax.inject.Inject + +class YourDelegationsFragment : + BaseFragment(), + DelegateListAdapter.Handler { + + override fun createBinding() = FragmentYourDelegationsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val placeholderAdapter by lazy(LazyThreadSafetyMode.NONE) { CustomPlaceholderAdapter(R.layout.item_delegates_shimmering) } + private val delegateListAdapter by lazy(LazyThreadSafetyMode.NONE) { DelegateListAdapter(imageLoader, this) } + private val adapter by lazy(LazyThreadSafetyMode.NONE) { ConcatAdapter(placeholderAdapter, delegateListAdapter) } + + override fun initViews() { + binder.yourDelegationsList.itemAnimator = null + binder.yourDelegationsList.adapter = adapter + + binder.yourDelegationsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.yourDelegationsAddDelegationButton.setOnClickListener { viewModel.addDelegationClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .yourDelegationsFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: YourDelegationsViewModel) { + viewModel.delegateModels.observeWhenVisible { + when (it) { + is ExtendedLoadingState.Error -> {} + is ExtendedLoadingState.Loaded -> { + placeholderAdapter.show(false) + delegateListAdapter.submitListPreservingViewPoint(it.data, binder.yourDelegationsList) + } + ExtendedLoadingState.Loading -> { + placeholderAdapter.show(true) + delegateListAdapter.submitList(emptyList()) + } + } + } + } + + override fun itemClicked(position: Int) { + viewModel.delegateClicked(position) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/YourDelegationsViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/YourDelegationsViewModel.kt new file mode 100644 index 0000000..da0c3a6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/YourDelegationsViewModel.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegated + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.utils.withLoadingShared +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.DelegateListInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.model.DelegateSorting +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.detail.main.DelegateDetailsPayload +import io.novafoundation.nova.runtime.state.chainAndAsset +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class YourDelegationsViewModel( + private val interactor: DelegateListInteractor, + private val governanceSharedState: GovernanceSharedState, + private val delegateMappers: DelegateMappers, + private val router: GovernanceRouter +) : BaseViewModel() { + + private val delegatesFlow = governanceSharedState.selectedOption + .withLoadingShared { interactor.getUserDelegates(it, this) } + .mapLoading { interactor.applySorting(DelegateSorting.DELEGATIONS, it) } + + val delegateModels = delegatesFlow.mapLoading { delegates -> + val chainWithAsset = governanceSharedState.chainAndAsset() + delegates.map { delegateMappers.mapDelegatePreviewToUi(it, chainWithAsset) } + }.shareWhileSubscribed() + + fun delegateClicked(position: Int) = launch { + val delegate = delegateModels.first().dataOrNull?.getOrNull(position) ?: return@launch + + val payload = DelegateDetailsPayload(delegate.accountId) + router.openDelegateDetails(payload) + } + + fun backClicked() { + router.back() + } + + fun addDelegationClicked() { + router.openAddDelegation() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/di/YourDelegationsComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/di/YourDelegationsComponent.kt new file mode 100644 index 0000000..fafd4ff --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/di/YourDelegationsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegated.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegated.YourDelegationsFragment + +@Subcomponent( + modules = [ + YourDelegationsModule::class + ] +) +@ScreenScope +interface YourDelegationsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): YourDelegationsComponent + } + + fun inject(fragment: YourDelegationsFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/di/YourDelegationsModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/di/YourDelegationsModule.kt new file mode 100644 index 0000000..f47438f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegated/di/YourDelegationsModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegated.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.list.DelegateListInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegated.YourDelegationsViewModel + +@Module(includes = [ViewModelModule::class]) +class YourDelegationsModule { + + @Provides + @IntoMap + @ViewModelKey(YourDelegationsViewModel::class) + fun provideViewModel( + delegateMappers: DelegateMappers, + governanceSharedState: GovernanceSharedState, + interactor: DelegateListInteractor, + router: GovernanceRouter, + ): ViewModel { + return YourDelegationsViewModel( + interactor = interactor, + governanceSharedState = governanceSharedState, + delegateMappers = delegateMappers, + router = router + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): YourDelegationsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(YourDelegationsViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountFragment.kt new file mode 100644 index 0000000..bd71c2e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountFragment.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount + +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentNewDelegationChooseAmountBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.AmountChipModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.setChips +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.view.setAmountChangeModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser + +class NewDelegationChooseAmountFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "NewDelegationChooseAmountFragment.Payload" + + fun getBundle(payload: NewDelegationChooseAmountPayload): Bundle = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentNewDelegationChooseAmountBinding.inflate(layoutInflater) + + override fun initViews() { + binder.newDelegationChooseAmountToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.newDelegationChooseAmountConfirm.prepareForProgress(viewLifecycleOwner) + binder.newDelegationChooseAmountConfirm.setOnClickListener { viewModel.continueClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .newDelegationChooseAmountFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: NewDelegationChooseAmountViewModel) { + setupAmountChooser(viewModel.amountChooserMixin, binder.newDelegationChooseAmountAmount) + observeValidations(viewModel) + observeHints(viewModel.hintsMixin, binder.newDelegationChooseAmountHints) + + viewModel.title.observe(binder.newDelegationChooseAmountToolbar::setTitle) + + binder.newDelegationChooseAmountVotePower.votePowerSeekbar.setValues(viewModel.convictionValues) + binder.newDelegationChooseAmountVotePower.votePowerSeekbar.bindTo(viewModel.selectedConvictionIndex, viewLifecycleOwner.lifecycleScope) + + viewModel.locksChangeUiFlow.observe { + binder.newDelegationChooseAmountLockedAmountChanges.setAmountChangeModel(it.amountChange) + binder.newDelegationChooseAmountLockedPeriodChanges.setAmountChangeModel(it.periodChange) + } + + viewModel.amountChips.observe(::setChips) + + viewModel.votesFormattedFlow.observe { + binder.newDelegationChooseAmountVotePower.votePowerVotesText.text = it + } + + viewModel.buttonState.observe(binder.newDelegationChooseAmountConfirm::setState) + } + + private fun setChips(newChips: List) { + binder.newDelegationChooseAmountAmountChipsContainer.setChips( + newChips = newChips, + onClicked = viewModel::amountChipClicked, + scrollingParent = binder.newDelegationChooseAmountAmountChipsScroll + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountPayload.kt new file mode 100644 index 0000000..a402ae5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountPayload.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount + +import android.os.Parcelable +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +@Parcelize +class NewDelegationChooseAmountPayload( + val delegate: AccountId, + @Suppress("CanBeParameter") // val is required for Parcelize to work + val trackIdsRaw: List, + val isEditMode: Boolean, +) : Parcelable { + + val trackIds = trackIdsRaw.map(::TrackId) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountViewModel.kt new file mode 100644 index 0000000..65b127d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/NewDelegationChooseAmountViewModel.kt @@ -0,0 +1,207 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.votesFor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.ChooseDelegationAmountValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.ChooseDelegationAmountValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.chooseChooseDelegationAmountValidationFailure +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.conviction.ConvictionValuesProvider +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.AmountChipModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.LocksFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.common.newDelegationHints +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.common.newDelegationTitle +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.NewDelegationConfirmPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.actualAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setAmountInput +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch + +class NewDelegationChooseAmountViewModel( + private val assetUseCase: AssetUseCase, + private val amountChooserMixinFactory: AmountChooserMixin.Factory, + private val interactor: NewDelegationChooseAmountInteractor, + private val payload: NewDelegationChooseAmountPayload, + private val resourceManager: ResourceManager, + private val router: GovernanceRouter, + private val validationSystem: ChooseDelegationAmountValidationSystem, + private val validationExecutor: ValidationExecutor, + private val locksChangeFormatter: LocksChangeFormatter, + private val convictionValuesProvider: ConvictionValuesProvider, + private val locksFormatter: LocksFormatter, + private val resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, +) : BaseViewModel(), + Validatable by validationExecutor { + + val title = flowOf { + resourceManager.newDelegationTitle(isEditMode = payload.isEditMode) + }.shareInBackground() + + private val assetWithOption = assetUseCase.currentAssetAndOptionFlow() + .shareInBackground() + + private val selectedAsset = assetWithOption.map { it.asset } + .shareInBackground() + + private val selectedChainAsset = selectedAsset.map { it.token.configuration } + .shareInBackground() + + private val delegateAssistantFlow = interactor.delegateAssistantFlow(viewModelScope) + + private val originFeeMixin = feeLoaderMixinFactory.createDefault(this, selectedChainAsset) + + private val maxActionProvider = maxActionProviderFactory.create( + viewModelScope = viewModelScope, + assetInFlow = selectedAsset, + feeLoaderMixin = originFeeMixin, + balance = interactor::maxAvailableBalanceToDelegate + ) + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = selectedAsset, + maxActionProvider = maxActionProvider + ) + + val hintsMixin = resourcesHintsMixinFactory.newDelegationHints(viewModelScope) + + val convictionValues = convictionValuesProvider.convictionValues() + val selectedConvictionIndex = MutableStateFlow(0) + + private val selectedConvictionFlow = selectedConvictionIndex.mapNotNull(convictionValues::valueAt) + + private val locksChangeFlow = combine( + amountChooserMixin.amount, + selectedConvictionFlow, + delegateAssistantFlow + ) { amount, conviction, delegateAssistant -> + val amountPlanks = selectedAsset.first().token.planksFromAmount(amount) + + delegateAssistant.estimateLocksAfterDelegating(amountPlanks, conviction, selectedAsset.first()) + } + .shareInBackground() + + val votesFormattedFlow = combine( + amountChooserMixin.amount, + selectedConvictionFlow + ) { amount, conviction -> + val votes = conviction.votesFor(amount) + + resourceManager.getString(R.string.referendum_votes_format, votes.format()) + }.shareInBackground() + + val locksChangeUiFlow = locksChangeFlow.map { + locksChangeFormatter.mapLocksChangeToUi(it, selectedAsset.first(), displayPeriodFromWhenSame = false) + } + .shareInBackground() + + val amountChips = delegateAssistantFlow.map { voteAssistant -> + val asset = selectedAsset.first() + + voteAssistant.reusableLocks().map { locksFormatter.formatReusableLock(it, asset) } + } + .shareInBackground() + + private val validationInProgressFlow = MutableStateFlow(false) + + val buttonState = combine(validationInProgressFlow, amountChooserMixin.amountInput) { inProgress, amountRaw -> + when { + inProgress -> DescriptiveButtonState.Loading + amountRaw.isEmpty() -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_amount)) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + } + + init { + originFeeMixin.connectWith( + inputSource1 = amountChooserMixin.backPressuredPlanks, + inputSource2 = selectedConvictionFlow, + feeConstructor = { _, amount, conviction -> + interactor.estimateFee( + amount = amount, + conviction = conviction, + delegate = payload.delegate, + tracks = payload.trackIds, + shouldRemoveOtherTracks = payload.isEditMode + ) + } + ) + } + + fun continueClicked() { + openConfirmIfValid() + } + + fun backClicked() { + router.back() + } + + fun amountChipClicked(chipModel: AmountChipModel) { + amountChooserMixin.setAmountInput(chipModel.amountInput) + } + + private fun openConfirmIfValid() = launch { + validationInProgressFlow.value = true + + val payload = ChooseDelegationAmountValidationPayload( + asset = selectedAsset.first(), + fee = originFeeMixin.awaitFee(), + amount = amountChooserMixin.amount.first(), + delegate = payload.delegate, + maxAvailableAmount = maxActionProvider.maxAvailableBalance.first().actualAmount + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { chooseChooseDelegationAmountValidationFailure(it, resourceManager) }, + progressConsumer = validationInProgressFlow.progressConsumer(), + ) { + validationInProgressFlow.value = false + + openConfirm(it) + } + } + + private fun openConfirm(validationPayload: ChooseDelegationAmountValidationPayload) = launch { + val payload = NewDelegationConfirmPayload( + delegate = validationPayload.delegate, + trackIdsRaw = payload.trackIdsRaw, + amount = validationPayload.amount, + conviction = selectedConvictionFlow.first(), + fee = mapFeeToParcel(validationPayload.fee), + isEditMode = payload.isEditMode + ) + + router.openNewDelegationConfirm(payload) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/di/NewDelegationChooseAmountComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/di/NewDelegationChooseAmountComponent.kt new file mode 100644 index 0000000..e5235f8 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/di/NewDelegationChooseAmountComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountPayload + +@Subcomponent( + modules = [ + NewDelegationChooseAmountModule::class + ] +) +@ScreenScope +interface NewDelegationChooseAmountComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: NewDelegationChooseAmountPayload, + ): NewDelegationChooseAmountComponent + } + + fun inject(fragment: NewDelegationChooseAmountFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/di/NewDelegationChooseAmountModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/di/NewDelegationChooseAmountModule.kt new file mode 100644 index 0000000..1e44624 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseAmount/di/NewDelegationChooseAmountModule.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.ChooseDelegationAmountValidationSystem +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.conviction.ConvictionValuesProvider +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.LocksFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class NewDelegationChooseAmountModule { + + @Provides + @IntoMap + @ViewModelKey(NewDelegationChooseAmountViewModel::class) + fun provideViewModel( + assetUseCase: AssetUseCase, + amountChooserMixinFactory: AmountChooserMixin.Factory, + interactor: NewDelegationChooseAmountInteractor, + payload: NewDelegationChooseAmountPayload, + resourceManager: ResourceManager, + router: GovernanceRouter, + validationExecutor: ValidationExecutor, + locksChangeFormatter: LocksChangeFormatter, + convictionValuesProvider: ConvictionValuesProvider, + locksFormatter: LocksFormatter, + validationSystem: ChooseDelegationAmountValidationSystem, + resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + maxActionProviderFactory: MaxActionProviderFactory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + amountFormatter: AmountFormatter, + ): ViewModel { + return NewDelegationChooseAmountViewModel( + assetUseCase = assetUseCase, + amountChooserMixinFactory = amountChooserMixinFactory, + interactor = interactor, + payload = payload, + resourceManager = resourceManager, + router = router, + validationExecutor = validationExecutor, + locksChangeFormatter = locksChangeFormatter, + convictionValuesProvider = convictionValuesProvider, + locksFormatter = locksFormatter, + validationSystem = validationSystem, + maxActionProviderFactory = maxActionProviderFactory, + feeLoaderMixinFactory = feeLoaderMixinFactory, + resourcesHintsMixinFactory = resourcesHintsMixinFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): NewDelegationChooseAmountViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NewDelegationChooseAmountViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/NewDelegationChooseTracksFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/NewDelegationChooseTracksFragment.kt new file mode 100644 index 0000000..987962b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/NewDelegationChooseTracksFragment.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks.SelectDelegationTracksFragment + +class NewDelegationChooseTracksFragment : SelectDelegationTracksFragment() { + + companion object { + private const val EXTRA_PAYLOAD = "EXTRA_PAYLOAD" + + fun getBundle(payload: NewDelegationChooseTracksPayload): Bundle { + return Bundle().apply { + putParcelable(EXTRA_PAYLOAD, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ).newDelegationChooseTracks() + .create(this, argument(EXTRA_PAYLOAD)) + .inject(this) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/NewDelegationChooseTracksPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/NewDelegationChooseTracksPayload.kt new file mode 100644 index 0000000..1afa33b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/NewDelegationChooseTracksPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack + +import android.os.Parcelable +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize + +@Parcelize +class NewDelegationChooseTracksPayload( + val delegateId: AccountId, + val isEditMode: Boolean +) : Parcelable diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/NewDelegationChooseTracksViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/NewDelegationChooseTracksViewModel.kt new file mode 100644 index 0000000..f988ca2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/NewDelegationChooseTracksViewModel.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.ChooseTrackData +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountPayload +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks.SelectDelegationTracksViewModel +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +class NewDelegationChooseTracksViewModel( + interactor: ChooseTrackInteractor, + trackFormatter: TrackFormatter, + governanceSharedState: GovernanceSharedState, + resourceManager: ResourceManager, + private val router: GovernanceRouter, + private val payload: NewDelegationChooseTracksPayload +) : SelectDelegationTracksViewModel( + interactor = interactor, + trackFormatter = trackFormatter, + governanceSharedState = governanceSharedState, + resourceManager = resourceManager, + router = router, + chooseTrackDataFlow = interactor.chooseTrackDataFlowFor(payload) +) { + + override val title: Flow = flowOf { + val titleRes = if (payload.isEditMode) R.string.select_delegation_tracks_edit_title else R.string.select_delegation_tracks_add_title + resourceManager.getString(titleRes) + }.shareInBackground() + + override val showDescription = true + + override fun nextClicked(trackIds: List) { + val nextPayload = NewDelegationChooseAmountPayload(payload.delegateId, trackIds, isEditMode = payload.isEditMode) + router.openNewDelegationChooseAmount(nextPayload) + } +} + +private fun ChooseTrackInteractor.chooseTrackDataFlowFor(payload: NewDelegationChooseTracksPayload): Flow { + return if (payload.isEditMode) { + observeEditDelegationTrackData(payload.delegateId) + } else { + observeNewDelegationTrackData() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/di/NewDelegationChooseTracksComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/di/NewDelegationChooseTracksComponent.kt new file mode 100644 index 0000000..7b71ca5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/di/NewDelegationChooseTracksComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.NewDelegationChooseTracksFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.NewDelegationChooseTracksPayload + +@Subcomponent( + modules = [ + NewDelegationChooseTracksModule::class + ] +) +@ScreenScope +interface NewDelegationChooseTracksComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: NewDelegationChooseTracksPayload + ): NewDelegationChooseTracksComponent + } + + fun inject(fragment: NewDelegationChooseTracksFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/di/NewDelegationChooseTracksModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/di/NewDelegationChooseTracksModule.kt new file mode 100644 index 0000000..27e7221 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/chooseTrack/di/NewDelegationChooseTracksModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.NewDelegationChooseTracksPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.chooseTrack.NewDelegationChooseTracksViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter + +@Module(includes = [ViewModelModule::class]) +class NewDelegationChooseTracksModule { + + @Provides + @IntoMap + @ViewModelKey(NewDelegationChooseTracksViewModel::class) + fun provideViewModel( + interactor: ChooseTrackInteractor, + trackFormatter: TrackFormatter, + governanceSharedState: GovernanceSharedState, + resourceManager: ResourceManager, + router: GovernanceRouter, + payload: NewDelegationChooseTracksPayload + ): ViewModel { + return NewDelegationChooseTracksViewModel( + interactor = interactor, + trackFormatter = trackFormatter, + governanceSharedState = governanceSharedState, + resourceManager = resourceManager, + router = router, + payload = payload + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): NewDelegationChooseTracksViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NewDelegationChooseTracksViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/common/Hints.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/common/Hints.kt new file mode 100644 index 0000000..d7fb1a0 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/common/Hints.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.common + +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.feature_governance_impl.R +import kotlinx.coroutines.CoroutineScope + +fun ResourcesHintsMixinFactory.newDelegationHints(coroutineScope: CoroutineScope) = create( + coroutineScope = coroutineScope, + hintsRes = listOf(R.string.delegation_delegate_hint_vote, R.string.delegation_delegate_hint_unlock) +) + +fun ResourcesHintsMixinFactory.revokeDelegationHints(coroutineScope: CoroutineScope) = create( + coroutineScope = coroutineScope, + hintsRes = listOf(R.string.delegation_revoke_hint_unlock) +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/common/NewDelegationTitle.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/common/NewDelegationTitle.kt new file mode 100644 index 0000000..1e0df70 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/common/NewDelegationTitle.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_impl.R + +internal fun ResourceManager.newDelegationTitle(isEditMode: Boolean): String { + val resId = if (isEditMode) R.string.delegation_edit_delegation else R.string.common_add_delegation + + return getString(resId) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/NewDelegationConfirmFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/NewDelegationConfirmFragment.kt new file mode 100644 index 0000000..7e44bda --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/NewDelegationConfirmFragment.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm + +import android.os.Bundle +import androidx.core.os.bundleOf + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentNewDelegationConfirmBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.setVoteModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.setDelegateLabelState +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.view.setAmountChangeModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.list.TrackListBottomSheet +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class NewDelegationConfirmFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "NewDelegationConfirmFragment.Payload" + + fun getBundle(payload: NewDelegationConfirmPayload): Bundle = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentNewDelegationConfirmBinding.inflate(layoutInflater) + + override fun initViews() { + binder.newDelegationConfirmToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.newDelegationConfirmConfirm.prepareForProgress(viewLifecycleOwner) + binder.newDelegationConfirmConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.newDelegationConfirmDelegate.setOnClickListener { viewModel.delegateClicked() } + + binder.newDelegationConfirmInformation.setOnAccountClickedListener { viewModel.accountClicked() } + + binder.newDelegationConfirmTracks.setOnClickListener { viewModel.tracksClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .newDelegationConfirmFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: NewDelegationConfirmViewModel) { + observeRetries(viewModel.partialRetriableMixin) + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.newDelegationConfirmHints) + + viewModel.title.observe(binder.newDelegationConfirmToolbar::setTitle) + + viewModel.amountModelFlow.observe(binder.newDelegationConfirmAmount::setAmount) + + setupFeeLoading(viewModel, binder.newDelegationConfirmInformation.fee) + viewModel.currentAddressModelFlow.observe(binder.newDelegationConfirmInformation::setAccount) + viewModel.walletModel.observe(binder.newDelegationConfirmInformation::setWallet) + + viewModel.delegateLabelModel.observe(binder.newDelegationConfirmDelegate::setDelegateLabelState) + viewModel.tracksModelFlow.observe { binder.newDelegationConfirmTracks.showValue(it.overview) } + viewModel.delegationModel.observe(binder.newDelegationConfirmDelegation::setVoteModel) + + viewModel.locksChangeUiFlow.observe { + binder.newDelegationConfirmLockedAmountChanges.setAmountChangeModel(it.amountChange) + binder.newDelegationConfirmLockedPeriodChanges.setAmountChangeModel(it.periodChange) + binder.newDelegationConfirmTransferableAmountChanges.setAmountChangeModel(it.transferableChange) + } + + viewModel.showNextProgress.observe(binder.newDelegationConfirmConfirm::setProgressState) + + viewModel.showTracksEvent.observeEvent { tracks -> + TrackListBottomSheet(requireContext(), tracks).show() + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/NewDelegationConfirmPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/NewDelegationConfirmPayload.kt new file mode 100644 index 0000000..5d65062 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/NewDelegationConfirmPayload.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.GenericVoter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal +import java.math.BigInteger + +@Parcelize +class NewDelegationConfirmPayload( + val delegate: AccountId, + @Suppress("CanBeParameter") val trackIdsRaw: List, + val amount: BigDecimal, + val conviction: Conviction, + val fee: FeeParcelModel, + val isEditMode: Boolean, +) : Parcelable { + + val trackIds = trackIdsRaw.map(::TrackId) +} + +val NewDelegationConfirmPayload.convictionVote: GenericVoter.ConvictionVote + get() { + return GenericVoter.ConvictionVote(amount, conviction) + } diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/NewDelegationConfirmViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/NewDelegationConfirmViewModel.kt new file mode 100644 index 0000000..db0eac1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/NewDelegationConfirmViewModel.kt @@ -0,0 +1,227 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.firstLoaded +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.submissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabelUseCase +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.ChooseDelegationAmountValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.ChooseDelegationAmountValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.chooseChooseDelegationAmountValidationFailure +import io.novafoundation.nova.feature_governance_impl.domain.track.TracksUseCase +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.formatConvictionVote +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.common.newDelegationHints +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.common.newDelegationTitle +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.formatTracks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.WithFeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class NewDelegationConfirmViewModel( + private val router: GovernanceRouter, + private val feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val externalActions: ExternalActions.Presentation, + private val governanceSharedState: GovernanceSharedState, + private val walletUiUseCase: WalletUiUseCase, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val interactor: NewDelegationChooseAmountInteractor, + private val trackFormatter: TrackFormatter, + private val assetUseCase: AssetUseCase, + private val payload: NewDelegationConfirmPayload, + private val validationSystem: ChooseDelegationAmountValidationSystem, + private val validationExecutor: ValidationExecutor, + private val resourceManager: ResourceManager, + private val locksChangeFormatter: LocksChangeFormatter, + private val resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + private val votersFormatter: VotersFormatter, + private val tracksUseCase: TracksUseCase, + private val delegateFormatters: DelegateMappers, + private val delegateLabelUseCase: DelegateLabelUseCase, + private val partialRetriableMixinFactory: PartialRetriableMixin.Factory, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor, + WithFeeLoaderMixin, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + val partialRetriableMixin = partialRetriableMixinFactory.create(this) + + val title = flowOf { + resourceManager.newDelegationTitle(isEditMode = payload.isEditMode) + }.shareInBackground() + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + override val originFeeMixin: FeeLoaderMixin.Presentation = feeLoaderMixinFactory.create(assetFlow) + + val hintsMixin = resourcesHintsMixinFactory.newDelegationHints(viewModelScope) + + val walletModel: Flow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val amountModelFlow = assetFlow.map { + amountFormatter.formatAmountToAmountModel(payload.amount, it) + }.shareInBackground() + + val currentAddressModelFlow = selectedAccountUseCase.selectedAddressModelFlow { governanceSharedState.chain() } + .shareInBackground() + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + private val delegateAssistantFlow = interactor.delegateAssistantFlow(viewModelScope) + + private val convictionVote = payload.convictionVote + + val delegationModel = flowOf { + votersFormatter.formatConvictionVote(convictionVote, governanceSharedState.chainAsset()) + }.shareInBackground() + + val locksChangeUiFlow = combine(delegateAssistantFlow, assetFlow) { delegateAssistant, asset -> + val amountPlanks = asset.token.planksFromAmount(payload.amount) + val locksChange = delegateAssistant.estimateLocksAfterDelegating(amountPlanks, payload.conviction, asset) + + locksChangeFormatter.mapLocksChangeToUi(locksChange, asset, displayPeriodFromWhenSame = false) + } + .shareInBackground() + + val tracksModelFlow = flowOf { tracksUseCase.tracksOf(payload.trackIds) } + .map { tracks -> + val chainAsset = governanceSharedState.chainAsset() + trackFormatter.formatTracks(tracks, chainAsset) + } + .shareInBackground() + + val delegateLabelModel = flowOf { delegateLabelUseCase.getDelegateLabel(payload.delegate) } + .map { delegateFormatters.formatDelegateLabel(it.accountId, it.metadata, it.onChainIdentity?.display, governanceSharedState.chain()) } + .withSafeLoading() + .shareInBackground() + + private val _showTracksEvent = MutableLiveData>>() + val showTracksEvent: LiveData>> = _showTracksEvent + + private val decimalFee = mapFeeFromParcel(payload.fee) + + init { + setFee() + } + + fun accountClicked() = launch { + val addressModel = currentAddressModelFlow.first() + + externalActions.showAddressActions(addressModel.address, governanceSharedState.chain()) + } + + fun delegateClicked() = launch { + val address = delegateLabelModel.firstLoaded().address + + externalActions.showAddressActions(address, governanceSharedState.chain()) + } + + fun tracksClicked() = launch { + val trackModels = tracksModelFlow.first() + _showTracksEvent.value = trackModels.tracks.event() + } + + fun confirmClicked() = launch { + val asset = assetFlow.first() + val amountPlanks = asset.token.planksFromAmount(payload.amount) + val maxAmount = interactor.maxAvailableBalanceToDelegate(asset) + val maxPlanks = asset.token.amountFromPlanks(maxAmount) + val validationPayload = ChooseDelegationAmountValidationPayload( + asset = asset, + fee = decimalFee, + amount = payload.amount, + delegate = payload.delegate, + maxAvailableAmount = maxPlanks + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + validationFailureTransformer = { chooseChooseDelegationAmountValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer(), + ) { + performDelegate(amountPlanks) + } + } + + fun backClicked() { + router.back() + } + + private fun setFee() = launch { + originFeeMixin.setFee(decimalFee) + } + + private fun performDelegate(amountPlanks: Balance) = launch { + val result = withContext(Dispatchers.Default) { + interactor.delegate( + amount = amountPlanks, + conviction = payload.conviction, + delegate = payload.delegate, + tracks = payload.trackIds, + shouldRemoveOtherTracks = payload.isEditMode + ) + } + + partialRetriableMixin.handleMultiResult( + multiResult = result, + onSuccess = { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy()) { router.backToYourDelegations() } + }, + progressConsumer = _showNextProgress.progressConsumer(), + onRetryCancelled = { router.backToYourDelegations() } + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/di/NewDelegationConfirmComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/di/NewDelegationConfirmComponent.kt new file mode 100644 index 0000000..935e126 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/di/NewDelegationConfirmComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.NewDelegationConfirmFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.NewDelegationConfirmPayload + +@Subcomponent( + modules = [ + NewDelegationConfirmModule::class + ] +) +@ScreenScope +interface NewDelegationConfirmComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: NewDelegationConfirmPayload, + ): NewDelegationConfirmComponent + } + + fun inject(fragment: NewDelegationConfirmFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/di/NewDelegationConfirmModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/di/NewDelegationConfirmModule.kt new file mode 100644 index 0000000..c159ae9 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/create/confirm/di/NewDelegationConfirmModule.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabelUseCase +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.create.chooseAmount.NewDelegationChooseAmountInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.create.chooseAmount.validation.ChooseDelegationAmountValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.track.TracksUseCase +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.NewDelegationConfirmPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.confirm.NewDelegationConfirmViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class NewDelegationConfirmModule { + + @Provides + @IntoMap + @ViewModelKey(NewDelegationConfirmViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + externalActions: ExternalActions.Presentation, + governanceSharedState: GovernanceSharedState, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + interactor: NewDelegationChooseAmountInteractor, + trackFormatter: TrackFormatter, + assetUseCase: AssetUseCase, + payload: NewDelegationConfirmPayload, + validationSystem: ChooseDelegationAmountValidationSystem, + validationExecutor: ValidationExecutor, + resourceManager: ResourceManager, + locksChangeFormatter: LocksChangeFormatter, + resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + votersFormatter: VotersFormatter, + tracksUseCase: TracksUseCase, + delegateFormatters: DelegateMappers, + delegateLabelUseCase: DelegateLabelUseCase, + partialRetriableMixinFactory: PartialRetriableMixin.Factory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return NewDelegationConfirmViewModel( + router = router, + feeLoaderMixinFactory = feeLoaderMixinFactory, + externalActions = externalActions, + governanceSharedState = governanceSharedState, + walletUiUseCase = walletUiUseCase, + selectedAccountUseCase = selectedAccountUseCase, + interactor = interactor, + trackFormatter = trackFormatter, + assetUseCase = assetUseCase, + payload = payload, + validationSystem = validationSystem, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + locksChangeFormatter = locksChangeFormatter, + resourcesHintsMixinFactory = resourcesHintsMixinFactory, + votersFormatter = votersFormatter, + tracksUseCase = tracksUseCase, + delegateFormatters = delegateFormatters, + delegateLabelUseCase = delegateLabelUseCase, + partialRetriableMixinFactory = partialRetriableMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): NewDelegationConfirmViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NewDelegationConfirmViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesFragment.kt new file mode 100644 index 0000000..26f7fc4 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesFragment.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes + +import android.os.Bundle +import androidx.core.os.bundleOf + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentRemoveVotesBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.track.list.TrackListBottomSheet +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class RemoveVotesFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "ConfirmReferendumVoteFragment.Payload" + + fun getBundle(payload: RemoveVotesPayload): Bundle = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentRemoveVotesBinding.inflate(layoutInflater) + + override fun initViews() { + binder.removeVoteToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.removeVoteConfirm.prepareForProgress(viewLifecycleOwner) + binder.removeVoteConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.removeVoteExtrinsicInfo.setOnAccountClickedListener { viewModel.accountClicked() } + + binder.removeVoteTracks.setOnClickListener { viewModel.tracksClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .removeVoteFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: RemoveVotesViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + + setupFeeLoading(viewModel, binder.removeVoteExtrinsicInfo.fee) + viewModel.selectedAccount.observe(binder.removeVoteExtrinsicInfo::setAccount) + viewModel.walletModel.observe(binder.removeVoteExtrinsicInfo::setWallet) + + viewModel.tracksModelFlow.observe { + binder.removeVoteTracks.showValue(it.overview) + } + + viewModel.showNextProgress.observe(binder.removeVoteConfirm::setProgressState) + + viewModel.showTracksEvent.observeEvent { tracks -> + TrackListBottomSheet( + context = requireContext(), + data = tracks + ).show() + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesPayload.kt new file mode 100644 index 0000000..8e1c87a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes + +import android.os.Parcelable +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +@Parcelize +class RemoveVotesPayload( + private val _trackIdsRaw: List +) : Parcelable { + + val trackIds = _trackIdsRaw.map(::TrackId) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesSuggestionBottomSheet.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesSuggestionBottomSheet.kt new file mode 100644 index 0000000..5733788 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesSuggestionBottomSheet.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.BottomRemoveVotesSuggestionBinding + +class RemoveVotesSuggestionBottomSheet( + context: Context, + private val votesCount: Int, + private val onApply: () -> Unit, + private val onSkip: () -> Unit, +) : BaseBottomSheet(context) { + + override val binder: BottomRemoveVotesSuggestionBinding = BottomRemoveVotesSuggestionBinding.inflate(LayoutInflater.from(context)) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binder.removeVotesSuggestionDescription.text = context.resources.getQuantityString( + R.plurals.remove_votes_suggestion_description, + votesCount, + votesCount + ) + binder.removeVotesSuggestionSkip.setDismissingClickListener { + onSkip() + } + binder.removeVotesSuggestionApply.setDismissingClickListener { + onApply() + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesViewModel.kt new file mode 100644 index 0000000..3a92ef6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/RemoveVotesViewModel.kt @@ -0,0 +1,154 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.removeVotes.RemoveTrackVotesInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.validations.RemoteVotesValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.validations.RemoveVotesValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.validations.handleRemoveVotesValidationFailure +import io.novafoundation.nova.feature_governance_impl.domain.track.TracksUseCase +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.formatTracks +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.WithFeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAsset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class RemoveVotesViewModel( + private val interactor: RemoveTrackVotesInteractor, + private val trackFormatter: TrackFormatter, + private val payload: RemoveVotesPayload, + private val governanceSharedState: GovernanceSharedState, + private val feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val assetUseCase: AssetUseCase, + private val walletUiUseCase: WalletUiUseCase, + private val accountUseCase: SelectedAccountUseCase, + private val router: GovernanceRouter, + private val externalActions: ExternalActions.Presentation, + private val validationExecutor: ValidationExecutor, + private val validationSystem: RemoteVotesValidationSystem, + private val resourceManager: ResourceManager, + private val tracksUseCase: TracksUseCase, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper +) : BaseViewModel(), + WithFeeLoaderMixin, + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + override val originFeeMixin = feeLoaderMixinFactory.create(assetFlow) + + private val tracksFlow = flowOf { tracksUseCase.tracksOf(payload.trackIds) } + .shareInBackground() + + val tracksModelFlow = tracksFlow + .map { tracks -> + val chainAsset = governanceSharedState.chainAsset() + trackFormatter.formatTracks(tracks, chainAsset) + } + .shareInBackground() + + val walletModel = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val selectedAccount = accountUseCase.selectedAddressModelFlow { governanceSharedState.chain() } + .shareInBackground() + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + private val _showTracksEvent = MutableLiveData>>() + val showTracksEvent: LiveData>> = _showTracksEvent + + init { + loadFee() + } + + fun confirmClicked() { + removeVotesIfValid() + } + + fun backClicked() { + router.back() + } + + fun accountClicked() = launch { + val addressModel = selectedAccount.first() + + externalActions.showAddressActions(addressModel.address, governanceSharedState.chain()) + } + + private fun removeVotesIfValid() = launch { + _showNextProgress.value = true + + val validationPayload = RemoveVotesValidationPayload( + fee = originFeeMixin.awaitFee(), + asset = assetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + progressConsumer = _showNextProgress.progressConsumer(), + validationFailureTransformer = { handleRemoveVotesValidationFailure(it, resourceManager) } + ) { + removeVotes() + } + } + + private fun removeVotes() = launch { + interactor.removeTrackVotes(payload.trackIds) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.back() } + } + .onFailure(::showError) + + _showNextProgress.value = false + } + + private fun loadFee() { + launch { + originFeeMixin.loadFee( + coroutineScope = this, + feeConstructor = { interactor.calculateFee(payload.trackIds) }, + onRetryCancelled = {} + ) + } + } + + fun tracksClicked() = launch { + val trackModels = tracksModelFlow.first() + _showTracksEvent.value = trackModels.tracks.event() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/di/RemoveVotesComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/di/RemoveVotesComponent.kt new file mode 100644 index 0000000..271c8aa --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/di/RemoveVotesComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.RemoveVotesFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.RemoveVotesPayload + +@Subcomponent( + modules = [ + RemoveVotesModule::class + ] +) +@ScreenScope +interface RemoveVotesComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: RemoveVotesPayload, + ): RemoveVotesComponent + } + + fun inject(fragment: RemoveVotesFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/di/RemoveVotesModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/di/RemoveVotesModule.kt new file mode 100644 index 0000000..ca7b278 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/removeVotes/di/RemoveVotesModule.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.removeVotes.RemoveTrackVotesInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.RealRemoveTrackVotesInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.validations.RemoteVotesValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.removeVotes.validations.removeVotesValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.track.TracksUseCase +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.RemoveVotesPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.RemoveVotesViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin + +@Module(includes = [ViewModelModule::class]) +class RemoveVotesModule { + + @Provides + @ScreenScope + fun provideValidationSystem() = ValidationSystem.removeVotesValidationSystem() + + @Provides + @ScreenScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + governanceSharedState: GovernanceSharedState, + governanceSourceRegistry: GovernanceSourceRegistry, + accountRepository: AccountRepository, + ): RemoveTrackVotesInteractor { + return RealRemoveTrackVotesInteractor(extrinsicService, governanceSharedState, governanceSourceRegistry, accountRepository) + } + + @Provides + @IntoMap + @ViewModelKey(RemoveVotesViewModel::class) + fun provideViewModel( + interactor: RemoveTrackVotesInteractor, + trackFormatter: TrackFormatter, + payload: RemoveVotesPayload, + governanceSharedState: GovernanceSharedState, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + accountUseCase: SelectedAccountUseCase, + router: GovernanceRouter, + externalActions: ExternalActions.Presentation, + validationExecutor: ValidationExecutor, + validationSystem: RemoteVotesValidationSystem, + resourceManager: ResourceManager, + tracksUseCase: TracksUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + ): ViewModel { + return RemoveVotesViewModel( + interactor = interactor, + trackFormatter = trackFormatter, + payload = payload, + governanceSharedState = governanceSharedState, + feeLoaderMixinFactory = feeLoaderMixinFactory, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + accountUseCase = accountUseCase, + router = router, + externalActions = externalActions, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + resourceManager = resourceManager, + tracksUseCase = tracksUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): RemoveVotesViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(RemoveVotesViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/RevokeDelegationChooseTracksFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/RevokeDelegationChooseTracksFragment.kt new file mode 100644 index 0000000..b4e824d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/RevokeDelegationChooseTracksFragment.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks.SelectDelegationTracksFragment + +class RevokeDelegationChooseTracksFragment : SelectDelegationTracksFragment() { + + companion object { + private const val EXTRA_PAYLOAD = "EXTRA_PAYLOAD" + + fun getBundle(payload: RevokeDelegationChooseTracksPayload): Bundle { + return Bundle().apply { + putParcelable(EXTRA_PAYLOAD, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .revokeDelegationChooseTracksFactory() + .create(this, argument(EXTRA_PAYLOAD)) + .inject(this) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/RevokeDelegationChooseTracksPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/RevokeDelegationChooseTracksPayload.kt new file mode 100644 index 0000000..527e143 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/RevokeDelegationChooseTracksPayload.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks + +import android.os.Parcelable +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize + +@Parcelize +class RevokeDelegationChooseTracksPayload(val delegateId: AccountId) : Parcelable diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/RevokeDelegationChooseTracksViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/RevokeDelegationChooseTracksViewModel.kt new file mode 100644 index 0000000..b3fb158 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/RevokeDelegationChooseTracksViewModel.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.RevokeDelegationConfirmPayload +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks.SelectDelegationTracksViewModel +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +class RevokeDelegationChooseTracksViewModel( + interactor: ChooseTrackInteractor, + trackFormatter: TrackFormatter, + governanceSharedState: GovernanceSharedState, + resourceManager: ResourceManager, + private val router: GovernanceRouter, + private val payload: RevokeDelegationChooseTracksPayload +) : SelectDelegationTracksViewModel( + interactor = interactor, + trackFormatter = trackFormatter, + governanceSharedState = governanceSharedState, + resourceManager = resourceManager, + router = router, + chooseTrackDataFlow = interactor.observeRevokeDelegationTrackData(payload.delegateId) +) { + + override val showDescription = false + + override val title: Flow = flowOf { + resourceManager.getString(R.string.select_delegation_tracks_revoke_title) + }.shareInBackground() + + override fun nextClicked(trackIds: List) { + val nextPayload = RevokeDelegationConfirmPayload(payload.delegateId, trackIds) + router.openRevokeDelegationsConfirm(nextPayload) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/di/RevokeDelegationChooseTracksComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/di/RevokeDelegationChooseTracksComponent.kt new file mode 100644 index 0000000..6bf942e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/di/RevokeDelegationChooseTracksComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.RevokeDelegationChooseTracksFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.RevokeDelegationChooseTracksPayload + +@Subcomponent( + modules = [ + RevokeDelegationChooseTracksModule::class + ] +) +@ScreenScope +interface RevokeDelegationChooseTracksComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: RevokeDelegationChooseTracksPayload + ): RevokeDelegationChooseTracksComponent + } + + fun inject(fragment: RevokeDelegationChooseTracksFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/di/RevokeDelegationChooseTracksModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/di/RevokeDelegationChooseTracksModule.kt new file mode 100644 index 0000000..7f020a1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/chooseTracks/di/RevokeDelegationChooseTracksModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.RevokeDelegationChooseTracksPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.chooseTracks.RevokeDelegationChooseTracksViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter + +@Module(includes = [ViewModelModule::class]) +class RevokeDelegationChooseTracksModule { + + @Provides + @IntoMap + @ViewModelKey(RevokeDelegationChooseTracksViewModel::class) + fun provideViewModel( + interactor: ChooseTrackInteractor, + trackFormatter: TrackFormatter, + governanceSharedState: GovernanceSharedState, + resourceManager: ResourceManager, + router: GovernanceRouter, + payload: RevokeDelegationChooseTracksPayload + ): ViewModel { + return RevokeDelegationChooseTracksViewModel( + interactor = interactor, + trackFormatter = trackFormatter, + governanceSharedState = governanceSharedState, + resourceManager = resourceManager, + router = router, + payload = payload + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): RevokeDelegationChooseTracksViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(RevokeDelegationChooseTracksViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/RevokeDelegationConfirmFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/RevokeDelegationConfirmFragment.kt new file mode 100644 index 0000000..52f2f62 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/RevokeDelegationConfirmFragment.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm + +import android.os.Bundle +import androidx.core.os.bundleOf + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.common.view.showLoadingValue +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentRevokeDelegationConfirmBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.setVoteModelOrHide +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.setDelegateLabelState +import io.novafoundation.nova.feature_governance_impl.presentation.track.list.TrackDelegationListBottomSheet +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class RevokeDelegationConfirmFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "RevokeDelegationConfirmFragment.Payload" + + fun getBundle(payload: RevokeDelegationConfirmPayload): Bundle = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentRevokeDelegationConfirmBinding.inflate(layoutInflater) + + override fun initViews() { + binder.revokeDelegationConfirmToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.revokeDelegationConfirmConfirm.prepareForProgress(viewLifecycleOwner) + binder.revokeDelegationConfirmConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.revokeDelegationConfirmDelegate.setOnClickListener { viewModel.delegateClicked() } + + binder.revokeDelegationConfirmInformation.setOnAccountClickedListener { viewModel.accountClicked() } + + binder.revokeDelegationConfirmTracks.setOnClickListener { viewModel.tracksClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .revokeDelegationConfirmFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: RevokeDelegationConfirmViewModel) { + observeRetries(viewModel.partialRetriableMixin) + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.revokeDelegationConfirmHints) + + setupFeeLoading(viewModel, binder.revokeDelegationConfirmInformation.fee) + viewModel.currentAddressModelFlow.observe(binder.revokeDelegationConfirmInformation::setAccount) + viewModel.walletModel.observe(binder.revokeDelegationConfirmInformation::setWallet) + + viewModel.delegateLabelModel.observe(binder.revokeDelegationConfirmDelegate::setDelegateLabelState) + viewModel.tracksSummary.observe(binder.revokeDelegationConfirmTracks::showValue) + viewModel.userDelegation.observe(binder.revokeDelegationConfirmDelegation::setVoteModelOrHide) + + viewModel.undelegatingPeriod.observe(binder.revokeDelegationConfirmUndelegatingPeriod::showLoadingValue) + + viewModel.showNextProgress.observe(binder.revokeDelegationConfirmConfirm::setProgressState) + + viewModel.showTracksEvent.observeEvent { tracks -> + TrackDelegationListBottomSheet(requireContext(), tracks).show() + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/RevokeDelegationConfirmPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/RevokeDelegationConfirmPayload.kt new file mode 100644 index 0000000..66fbcad --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/RevokeDelegationConfirmPayload.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +@Parcelize +class RevokeDelegationConfirmPayload( + val delegateId: AccountId, + @Suppress("CanBeParameter") val trackIdsRaw: List, +) : Parcelable { + + val trackIds = trackIdsRaw.map(::TrackId) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/RevokeDelegationConfirmViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/RevokeDelegationConfirmViewModel.kt new file mode 100644 index 0000000..419d03f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/RevokeDelegationConfirmViewModel.kt @@ -0,0 +1,199 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.firstLoaded +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.submissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabelUseCase +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.RevokeDelegationsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.validations.RevokeDelegationValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.validations.RevokeDelegationValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.validations.handleRevokeDelegationValidationFailure +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.formatDelegationsOverviewOrNull +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.create.common.revokeDelegationHints +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackDelegationModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.WithFeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RevokeDelegationConfirmViewModel( + private val router: GovernanceRouter, + private val feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val externalActions: ExternalActions.Presentation, + private val governanceSharedState: GovernanceSharedState, + private val walletUiUseCase: WalletUiUseCase, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val interactor: RevokeDelegationsInteractor, + private val trackFormatter: TrackFormatter, + private val assetUseCase: AssetUseCase, + private val payload: RevokeDelegationConfirmPayload, + private val validationSystem: RevokeDelegationValidationSystem, + private val validationExecutor: ValidationExecutor, + private val resourceManager: ResourceManager, + private val resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + private val delegateFormatters: DelegateMappers, + private val delegateLabelUseCase: DelegateLabelUseCase, + private val partialRetriableMixinFactory: PartialRetriableMixin.Factory, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper +) : BaseViewModel(), + Validatable by validationExecutor, + WithFeeLoaderMixin, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + val partialRetriableMixin = partialRetriableMixinFactory.create(this) + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + override val originFeeMixin: FeeLoaderMixin.Presentation = feeLoaderMixinFactory.create(assetFlow) + + val hintsMixin = resourcesHintsMixinFactory.revokeDelegationHints(viewModelScope) + + val walletModel: Flow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val currentAddressModelFlow = selectedAccountUseCase.selectedAddressModelFlow { governanceSharedState.chain() } + .shareInBackground() + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + val delegateLabelModel = flowOf { delegateLabelUseCase.getDelegateLabel(payload.delegateId) } + .map { delegateFormatters.formatDelegateLabel(it.accountId, it.metadata, it.onChainIdentity?.display, governanceSharedState.chain()) } + .withSafeLoading() + .shareInBackground() + + private val _showTracksEvent = MutableLiveData>>() + val showTracksEvent: LiveData>> = _showTracksEvent + + private val revokeDelegationData = interactor.revokeDelegationDataFlow(payload.trackIds) + .shareInBackground() + + private val trackDelegationModelsFlow = revokeDelegationData + .map { data -> + val chainAsset = governanceSharedState.chainAsset() + data.delegations.map { (track, delegation) -> + delegateFormatters.formatTrackDelegation(delegation, track, chainAsset) + } + } + .shareInBackground() + + val tracksSummary = revokeDelegationData.map { + val chainAsset = governanceSharedState.chainAsset() + trackFormatter.formatTracksSummary(it.delegations.keys, chainAsset) + }.shareInBackground() + + val undelegatingPeriod = revokeDelegationData + .map { resourceManager.formatDuration(it.undelegatingPeriod, estimated = false) } + .withSafeLoading() + .shareInBackground() + + val userDelegation = revokeDelegationData.map { + val chainAsset = governanceSharedState.chainAsset() + delegateFormatters.formatDelegationsOverviewOrNull(it.delegationsOverview, chainAsset) + }.shareInBackground() + + init { + loadFee() + } + + fun accountClicked() = launch { + val addressModel = currentAddressModelFlow.first() + + externalActions.showAddressActions(addressModel.address, governanceSharedState.chain()) + } + + fun delegateClicked() = launch { + val address = delegateLabelModel.firstLoaded().address + + externalActions.showAddressActions(address, governanceSharedState.chain()) + } + + fun tracksClicked() = launch { + val trackModels = trackDelegationModelsFlow.first() + _showTracksEvent.value = trackModels.event() + } + + fun confirmClicked() = launch { + _showNextProgress.value = true + + val validationPayload = RevokeDelegationValidationPayload( + fee = originFeeMixin.awaitFee(), + asset = assetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + validationFailureTransformer = { handleRevokeDelegationValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer(), + ) { + performDelegate() + } + } + + fun backClicked() { + router.back() + } + + private fun loadFee() = launch { + originFeeMixin.loadFee( + coroutineScope = coroutineScope, + feeConstructor = { interactor.calculateFee(payload.trackIds) }, + onRetryCancelled = {} + ) + } + + private fun performDelegate() = launch { + val result = withContext(Dispatchers.Default) { + interactor.revokeDelegations(payload.trackIds) + } + + partialRetriableMixin.handleMultiResult( + multiResult = result, + onSuccess = { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy()) { router.backToYourDelegations() } + }, + progressConsumer = _showNextProgress.progressConsumer(), + onRetryCancelled = { router.backToYourDelegations() } + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/di/RevokeDelegationConfirmComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/di/RevokeDelegationConfirmComponent.kt new file mode 100644 index 0000000..7a04c81 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/di/RevokeDelegationConfirmComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.RevokeDelegationConfirmFragment +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.RevokeDelegationConfirmPayload + +@Subcomponent( + modules = [ + RevokeDelegationConfirmModule::class + ] +) +@ScreenScope +interface RevokeDelegationConfirmComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: RevokeDelegationConfirmPayload, + ): RevokeDelegationConfirmComponent + } + + fun inject(fragment: RevokeDelegationConfirmFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/di/RevokeDelegationConfirmModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/di/RevokeDelegationConfirmModule.kt new file mode 100644 index 0000000..2075938 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/delegation/delegation/revoke/confirm/di/RevokeDelegationConfirmModule.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabelUseCase +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.RealRevokeDelegationsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.RevokeDelegationsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.validations.RevokeDelegationValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.delegation.delegation.revoke.validations.revokeDelegationValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.track.TracksUseCase +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.RevokeDelegationConfirmPayload +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.revoke.confirm.RevokeDelegationConfirmViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +@Module(includes = [ViewModelModule::class]) +class RevokeDelegationConfirmModule { + + @Provides + @ScreenScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + governanceSharedState: GovernanceSharedState, + governanceSourceRegistry: GovernanceSourceRegistry, + chainStateRepository: ChainStateRepository, + accountRepository: AccountRepository, + tracksUseCase: TracksUseCase, + ): RevokeDelegationsInteractor = RealRevokeDelegationsInteractor( + extrinsicService = extrinsicService, + governanceSharedState = governanceSharedState, + governanceSourceRegistry = governanceSourceRegistry, + chainStateRepository = chainStateRepository, + accountRepository = accountRepository, + tracksUseCase = tracksUseCase + ) + + @Provides + @ScreenScope + fun provideValidationSystem() = ValidationSystem.revokeDelegationValidationSystem() + + @Provides + @IntoMap + @ViewModelKey(RevokeDelegationConfirmViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + externalActions: ExternalActions.Presentation, + governanceSharedState: GovernanceSharedState, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + interactor: RevokeDelegationsInteractor, + trackFormatter: TrackFormatter, + assetUseCase: AssetUseCase, + payload: RevokeDelegationConfirmPayload, + validationSystem: RevokeDelegationValidationSystem, + validationExecutor: ValidationExecutor, + resourceManager: ResourceManager, + resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + delegateFormatters: DelegateMappers, + delegateLabelUseCase: DelegateLabelUseCase, + partialRetriableMixinFactory: PartialRetriableMixin.Factory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + ): ViewModel { + return RevokeDelegationConfirmViewModel( + router = router, + feeLoaderMixinFactory = feeLoaderMixinFactory, + externalActions = externalActions, + governanceSharedState = governanceSharedState, + walletUiUseCase = walletUiUseCase, + selectedAccountUseCase = selectedAccountUseCase, + interactor = interactor, + trackFormatter = trackFormatter, + assetUseCase = assetUseCase, + payload = payload, + validationSystem = validationSystem, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + resourcesHintsMixinFactory = resourcesHintsMixinFactory, + partialRetriableMixinFactory = partialRetriableMixinFactory, + delegateFormatters = delegateFormatters, + delegateLabelUseCase = delegateLabelUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): RevokeDelegationConfirmViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(RevokeDelegationConfirmViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/RealReferendaStatusFormatter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/RealReferendaStatusFormatter.kt new file mode 100644 index 0000000..714ae94 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/RealReferendaStatusFormatter.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatusType +import io.novafoundation.nova.feature_governance_api.presentation.referenda.common.ReferendaStatusFormatter +import io.novafoundation.nova.feature_governance_impl.R + +class RealReferendaStatusFormatter( + private val resourceManager: ResourceManager, +) : ReferendaStatusFormatter { + + override fun formatStatus(status: ReferendumStatusType): String { + return when (status) { + ReferendumStatusType.WAITING_DEPOSIT -> resourceManager.getString(R.string.referendum_status_waiting_deposit) + ReferendumStatusType.PREPARING -> resourceManager.getString(R.string.referendum_status_preparing) + ReferendumStatusType.IN_QUEUE -> resourceManager.getString(R.string.referendum_status_in_queue) + ReferendumStatusType.DECIDING -> resourceManager.getString(R.string.referendum_status_deciding) + ReferendumStatusType.CONFIRMING -> resourceManager.getString(R.string.referendum_status_passing) + ReferendumStatusType.APPROVED -> resourceManager.getString(R.string.referendum_status_approved) + ReferendumStatusType.EXECUTED -> resourceManager.getString(R.string.referendum_status_executed) + ReferendumStatusType.TIMED_OUT -> resourceManager.getString(R.string.referendum_status_timeout) + ReferendumStatusType.KILLED -> resourceManager.getString(R.string.referendum_status_killed) + ReferendumStatusType.CANCELLED -> resourceManager.getString(R.string.referendum_status_cancelled) + ReferendumStatusType.REJECTED -> resourceManager.getString(R.string.referendum_status_rejected) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/RealReferendumFormatter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/RealReferendumFormatter.kt new file mode 100644 index 0000000..2100130 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/RealReferendumFormatter.kt @@ -0,0 +1,448 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common + +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.formatting.formatFractionAsPercentage +import io.novafoundation.nova.common.utils.formatting.remainingTime +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.orTrue +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amountMultiplier +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumThreshold +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumTrack +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumVoting +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ayeVotesIfNotEmpty +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.currentlyPassing +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetails +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.isOngoing +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.PreparingReason +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumVote +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.WithDifferentVoter +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.getName +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.GenericVoter +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.SplitVote +import io.novafoundation.nova.feature_governance_api.presentation.referenda.common.ReferendaStatusFormatter +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteDirectionModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumStatusModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTimeEstimation +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTimeEstimationStyleRefresher +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumVotingModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendumModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.YourMultiVotePreviewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.YourVotePreviewModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.view.YourMultiVoteModel +import io.novafoundation.nova.feature_governance_impl.presentation.view.YourVoteModel +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds + +class ReferendumFormatterFactory( + private val resourceManager: ResourceManager, + private val trackFormatter: TrackFormatter, + private val referendaStatusFormatter: ReferendaStatusFormatter, + private val amountFormatter: AmountFormatter +) { + fun create(maskableValueFormatter: MaskableValueFormatter): ReferendumFormatter { + return RealReferendumFormatter( + resourceManager, + trackFormatter, + referendaStatusFormatter, + amountFormatter, + maskableValueFormatter + ) + } +} + +private val oneDay = 1.days + +private data class AccountVoteFormatComponent( + val direction: VoteDirectionModel, + val amount: String, + val votes: String, + val multiplier: String +) + +class RealReferendumFormatter( + private val resourceManager: ResourceManager, + private val trackFormatter: TrackFormatter, + private val referendaStatusFormatter: ReferendaStatusFormatter, + private val amountFormatter: AmountFormatter, + private val maskableValueFormatter: MaskableValueFormatter +) : ReferendumFormatter { + + override fun formatVoting(voting: ReferendumVoting, threshold: ReferendumThreshold?, token: Token): ReferendumVotingModel? { + val approval = voting.approval.dataOrNull ?: return null + val support = voting.support.dataOrNull ?: return null + + return ReferendumVotingModel( + positiveFraction = approval.ayeVotesIfNotEmpty()?.fraction?.toFloat(), + thresholdFraction = threshold?.approval?.value?.toFloat(), + votingResultIcon = R.drawable.ic_close, + votingResultIconColor = R.color.icon_negative, + thresholdInfo = formatThresholdInfo(support, threshold, token), + thresholdInfoVisible = !threshold?.support?.currentlyPassing().orTrue(), + positivePercentage = resourceManager.getString( + R.string.referendum_aye_format, + approval.ayeVotes.fraction.formatFractionAsPercentage() + ), + negativePercentage = resourceManager.getString( + R.string.referendum_nay_format, + approval.nayVotes.fraction.formatFractionAsPercentage() + ), + thresholdPercentage = threshold?.let { + resourceManager.getString( + R.string.referendum_to_pass_format, + it.approval.value.formatFractionAsPercentage() + ) + } + ) + } + + override fun formatReferendumTrack(track: ReferendumTrack, asset: Chain.Asset): ReferendumTrackModel { + val trackModel = trackFormatter.formatTrack(track.track, asset) + + return ReferendumTrackModel(trackModel, sameWithOther = track.sameWithOther) + } + + override fun formatOnChainName(call: GenericCall.Instance): String { + return "${call.module.name}.${call.function.name}" + } + + override fun formatUnknownReferendumTitle(referendumId: ReferendumId): String { + return resourceManager.getString(R.string.referendum_name_unknown, formatId(referendumId)) + } + + private fun formatThresholdInfo( + support: ReferendumVoting.Support, + threshold: ReferendumThreshold?, + token: Token + ): String? { + if (threshold == null) return null + + val thresholdFormatted = amountFormatter.formatAmountToAmountModel(threshold.support.value, token).token + val turnoutFormatted = token.amountFromPlanks(support.turnout).format() + + return resourceManager.getString(R.string.referendum_support_threshold_format, turnoutFormatted, thresholdFormatted) + } + + override fun formatStatus(status: ReferendumStatus): ReferendumStatusModel { + val statusName = referendaStatusFormatter.formatStatus(status.type) + return when (status) { + is ReferendumStatus.Ongoing.Preparing -> { + ReferendumStatusModel( + name = referendaStatusFormatter.formatStatus(status.type), + colorRes = R.color.text_secondary + ) + } + + is ReferendumStatus.Ongoing.InQueue -> ReferendumStatusModel( + name = resourceManager.getString( + R.string.referendum_status_in_queue_format, + status.position.index, + status.position.maxSize + ), + colorRes = R.color.text_secondary + ) + + is ReferendumStatus.Ongoing.DecidingApprove -> ReferendumStatusModel( + name = statusName, + colorRes = R.color.text_secondary + ) + + is ReferendumStatus.Ongoing.DecidingReject -> ReferendumStatusModel( + name = statusName, + colorRes = R.color.text_secondary + ) + + is ReferendumStatus.Ongoing.Confirming -> ReferendumStatusModel( + name = statusName, + colorRes = R.color.text_positive + ) + + is ReferendumStatus.Approved -> ReferendumStatusModel( + name = statusName, + colorRes = R.color.text_positive + ) + + ReferendumStatus.Executed -> ReferendumStatusModel( + name = statusName, + colorRes = R.color.text_positive + ) + + ReferendumStatus.NotExecuted.Rejected -> ReferendumStatusModel( + name = statusName, + colorRes = R.color.text_negative + ) + + ReferendumStatus.NotExecuted.Cancelled -> ReferendumStatusModel( + name = statusName, + colorRes = R.color.text_secondary + ) + + ReferendumStatus.NotExecuted.TimedOut -> ReferendumStatusModel( + name = statusName, + colorRes = R.color.text_secondary + ) + + ReferendumStatus.NotExecuted.Killed -> ReferendumStatusModel( + name = statusName, + colorRes = R.color.text_negative + ) + } + } + + override fun formatTimeEstimation(status: ReferendumStatus): ReferendumTimeEstimation? { + return when (status) { + is ReferendumStatus.Ongoing.Preparing -> { + when (val reason = status.reason) { + is PreparingReason.DecidingIn -> ReferendumTimeEstimation.Timer( + time = reason.timeLeft, + timeFormat = R.string.referendum_status_deciding_in, + textStyleRefresher = reason.timeLeft.referendumStatusStyleRefresher() + ) + + PreparingReason.WaitingForDeposit -> ReferendumTimeEstimation.Timer( + time = status.timeOutIn, + timeFormat = R.string.referendum_status_time_out_in, + textStyleRefresher = status.timeOutIn.referendumStatusStyleRefresher() + ) + } + } + + is ReferendumStatus.Ongoing.InQueue -> { + ReferendumTimeEstimation.Timer( + time = status.timeOutIn, + timeFormat = R.string.referendum_status_time_out_in, + textStyleRefresher = status.timeOutIn.referendumStatusStyleRefresher() + ) + } + + is ReferendumStatus.Ongoing.DecidingReject -> ReferendumTimeEstimation.Timer( + time = status.rejectIn, + timeFormat = R.string.referendum_status_time_reject_in, + textStyleRefresher = status.rejectIn.referendumStatusStyleRefresher() + ) + + is ReferendumStatus.Ongoing.DecidingApprove -> ReferendumTimeEstimation.Timer( + time = status.approveIn, + timeFormat = R.string.referendum_status_time_approve_in, + textStyleRefresher = status.approveIn.referendumStatusStyleRefresher() + ) + + is ReferendumStatus.Ongoing.Confirming -> ReferendumTimeEstimation.Timer( + time = status.approveIn, + timeFormat = R.string.referendum_status_time_approve_in, + textStyleRefresher = status.approveIn.referendumStatusStyleRefresher() + ) + + is ReferendumStatus.Approved -> ReferendumTimeEstimation.Timer( + time = status.executeIn, + timeFormat = R.string.referendum_status_time_execute_in, + textStyleRefresher = status.executeIn.referendumStatusStyleRefresher() + ) + + ReferendumStatus.Executed -> null + ReferendumStatus.NotExecuted.Rejected -> null + ReferendumStatus.NotExecuted.Cancelled -> null + ReferendumStatus.NotExecuted.TimedOut -> null + ReferendumStatus.NotExecuted.Killed -> null + } + } + + override fun formatId(referendumId: ReferendumId): String { + return "#${referendumId.value.format()}" + } + + override fun formatUserVote(referendumVote: ReferendumVote, chain: Chain, chainAsset: Chain.Asset): YourMultiVoteModel { + val title = when (referendumVote) { + is ReferendumVote.UserDelegated -> { + val accountFormatted = referendumVote.voterDisplayIn(chain) + + resourceManager.getString(R.string.delegation_referendum_details_vote, accountFormatted) + } + + is ReferendumVote.UserDirect -> resourceManager.getString(R.string.referendum_details_your_vote) + + is ReferendumVote.OtherAccount -> error("Not yet supported") + } + + val yourVoteModels = formatAccountVote(referendumVote.vote, chainAsset).map { formattedVoteComponent -> + val votesDetails = "${formattedVoteComponent.amount} × ${formattedVoteComponent.multiplier}x" + val votesCount = resourceManager.getString(R.string.referendum_votes_format, formattedVoteComponent.votes) + + YourVoteModel( + voteDirection = formattedVoteComponent.direction, + vote = VoteModel(votesCount, votesDetails), + voteTitle = title + ) + } + + return YourMultiVoteModel(yourVoteModels) + } + + override fun formatReferendumPreview( + referendum: ReferendumPreview, + token: Token, + chain: Chain + ): ReferendumModel { + return ReferendumModel( + id = referendum.id, + status = formatStatus(referendum.status), + name = formatReferendumName(referendum), + timeEstimation = formatTimeEstimation(referendum.status), + track = referendum.track?.let { formatReferendumTrack(it, token.configuration) }, + number = formatId(referendum.id), + voting = referendum.voting?.let { formatVoting(it, referendum.threshold, token) }, + yourVote = referendum.referendumVote?.let { + maskableValueFormatter.format { mapReferendumVoteToUi(it, token.configuration, chain) } + }, + isOngoing = referendum.status.isOngoing() + ) + } + + override fun formatReferendumName(referendum: ReferendumPreview): String { + return referendum.getName() ?: formatUnknownReferendumTitle(referendum.id) + } + + override fun formatReferendumName(referendum: ReferendumDetails): String { + return referendum.offChainMetadata?.title + ?: referendum.onChainMetadata?.preImage?.let { formatOnChainName(it.call) } + ?: formatUnknownReferendumTitle(referendum.id) + } + + private fun mapReferendumVoteToUi( + referendumVote: ReferendumVote, + chainAsset: Chain.Asset, + chain: Chain + ): YourMultiVotePreviewModel { + val voteComponents = formatAccountVote(referendumVote.vote, chainAsset).map { formattedComponent -> + val details = when (referendumVote) { + is ReferendumVote.UserDirect -> { + resourceManager.getString(R.string.referendum_your_vote_format, formattedComponent.votes) + } + + is ReferendumVote.UserDelegated -> { + val accountFormatted = referendumVote.voterDisplayIn(chain) + + resourceManager.getString(R.string.delegation_referendum_vote, formattedComponent.votes, accountFormatted) + } + + is ReferendumVote.OtherAccount -> { + val accountFormatted = referendumVote.voterDisplayIn(chain) + + resourceManager.getString(R.string.referendum_other_votes, formattedComponent.votes, accountFormatted) + } + } + + YourVotePreviewModel( + voteDirection = formattedComponent.direction, + details = details + ) + } + + return YourMultiVotePreviewModel(voteComponents) + } + + private fun WithDifferentVoter.voterDisplayIn(chain: Chain): String { + return whoIdentity?.name ?: chain.addressOf(who) + } + + private fun TimerValue.referendumStatusStyleRefresher(): ReferendumTimeEstimationStyleRefresher = { + if (referendumStatusIsHot()) { + ReferendumTimeEstimation.TextStyle.hot() + } else { + ReferendumTimeEstimation.TextStyle.regular() + } + } + + private fun ReferendumTimeEstimation.TextStyle.Companion.hot() = ReferendumTimeEstimation.TextStyle( + iconRes = R.drawable.ic_fire, + textColorRes = R.color.text_warning, + iconColorRes = R.color.icon_warning, + ) + + private fun ReferendumTimeEstimation.TextStyle.Companion.regular() = ReferendumTimeEstimation.TextStyle( + iconRes = R.drawable.ic_time_16, + textColorRes = R.color.text_secondary, + iconColorRes = R.color.icon_secondary, + ) + + private fun TimerValue.referendumStatusIsHot(): Boolean { + return remainingTime().milliseconds < oneDay + } + + private fun formatAccountVote(vote: AccountVote, chainAsset: Chain.Asset): List { + return when (vote) { + is AccountVote.Standard -> { + val voteDirection = if (vote.vote.aye) VoteType.AYE else VoteType.NAY + val convictionVote = GenericVoter.ConvictionVote(chainAsset.amountFromPlanks(vote.balance), vote.vote.conviction) + + val formattedComponent = formatDirectedConvictionVote(voteDirection, convictionVote, chainAsset) + listOf(formattedComponent) + } + + is AccountVote.Split -> formatNonZeroConvictionVote( + VoteType.AYE to SplitVote(vote.aye, chainAsset), + VoteType.NAY to SplitVote(vote.nay, chainAsset), + chainAsset = chainAsset + ) + + is AccountVote.SplitAbstain -> formatNonZeroConvictionVote( + VoteType.AYE to SplitVote(vote.aye, chainAsset), + VoteType.NAY to SplitVote(vote.nay, chainAsset), + VoteType.ABSTAIN to SplitVote(vote.abstain, chainAsset), + chainAsset = chainAsset + ) + + AccountVote.Unsupported -> emptyList() + } + } + + private fun formatNonZeroConvictionVote( + vararg directedVotes: Pair, + chainAsset: Chain.Asset + ): List { + return directedVotes.mapNotNull { (direction, convictionVote) -> + if (convictionVote.amount.isZero) return@mapNotNull null + + formatDirectedConvictionVote(direction, convictionVote, chainAsset) + } + } + + private fun formatDirectedConvictionVote( + direction: VoteType, + convictionVote: GenericVoter.ConvictionVote, + chainAsset: Chain.Asset + ): AccountVoteFormatComponent = AccountVoteFormatComponent( + direction = formatVoteType(direction), + amount = convictionVote.amount.formatTokenAmount(chainAsset), + votes = convictionVote.totalVotes.format(), + multiplier = convictionVote.conviction.amountMultiplier().format() + ) + + private fun formatVoteType(voteDirection: VoteType): VoteDirectionModel { + return when (voteDirection) { + VoteType.AYE -> VoteDirectionModel(resourceManager.getString(R.string.referendum_vote_aye), R.color.text_positive) + VoteType.NAY -> VoteDirectionModel(resourceManager.getString(R.string.referendum_vote_nay), R.color.text_negative) + VoteType.ABSTAIN -> VoteDirectionModel(resourceManager.getString(R.string.referendum_vote_abstain), R.color.text_secondary) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/ReferendumFormatter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/ReferendumFormatter.kt new file mode 100644 index 0000000..3310876 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/ReferendumFormatter.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumThreshold +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumTrack +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumVoting +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetails +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumVote +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumStatusModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTimeEstimation +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumVotingModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendumModel +import io.novafoundation.nova.feature_governance_impl.presentation.view.YourMultiVoteModel +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +interface ReferendumFormatter { + + fun formatVoting(voting: ReferendumVoting, threshold: ReferendumThreshold?, token: Token): ReferendumVotingModel? + + fun formatReferendumTrack(track: ReferendumTrack, asset: Chain.Asset): ReferendumTrackModel + + fun formatOnChainName(call: GenericCall.Instance): String + + fun formatUnknownReferendumTitle(referendumId: ReferendumId): String + + fun formatStatus(status: ReferendumStatus): ReferendumStatusModel + + fun formatTimeEstimation(status: ReferendumStatus): ReferendumTimeEstimation? + + fun formatId(referendumId: ReferendumId): String + + fun formatUserVote(referendumVote: ReferendumVote, chain: Chain, chainAsset: Chain.Asset): YourMultiVoteModel + + fun formatReferendumPreview( + referendum: ReferendumPreview, + token: Token, + chain: Chain + ): ReferendumModel + + fun formatReferendumName(referendum: ReferendumPreview): String + + fun formatReferendumName(referendum: ReferendumDetails): String +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/list/BaseReferendaListFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/list/BaseReferendaListFragment.kt new file mode 100644 index 0000000..05a8df3 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/list/BaseReferendaListFragment.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.list + +import androidx.viewbinding.ViewBinding +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.list.EditablePlaceholderAdapter +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.ReferendaListAdapter +import kotlinx.coroutines.flow.Flow + +abstract class BaseReferendaListFragment : BaseFragment(), ReferendaListAdapter.Handler { + + protected open val shimmeringAdapter by lazy(LazyThreadSafetyMode.NONE) { CustomPlaceholderAdapter(R.layout.item_referenda_shimmering) } + protected open val placeholderAdapter by lazy(LazyThreadSafetyMode.NONE) { EditablePlaceholderAdapter() } + protected val referendaListAdapter by lazy(LazyThreadSafetyMode.NONE) { ReferendaListAdapter(this) } + + protected fun Flow>.observeReferendaList() { + observeWhenVisible { loadingState -> + when (loadingState) { + is ExtendedLoadingState.Loaded -> { + shimmeringAdapter.show(false) + submitReferenda(loadingState.data.referenda) + placeholderAdapter.show(loadingState.data.placeholderModel != null) + loadingState.data.placeholderModel?.let { placeholderAdapter.setPlaceholderData(it) } + } + is ExtendedLoadingState.Loading, is ExtendedLoadingState.Error -> { + shimmeringAdapter.show(true) + submitReferenda(emptyList()) + } + } + } + } + + protected open fun submitReferenda(data: List) { + referendaListAdapter.submitList(data) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/list/ReferendaListStateModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/list/ReferendaListStateModel.kt new file mode 100644 index 0000000..ee2bc48 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/list/ReferendaListStateModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.list + +import io.novafoundation.nova.common.view.PlaceholderModel + +class ReferendaListStateModel( + val placeholderModel: PlaceholderModel?, + val referenda: List +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumCallModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumCallModel.kt new file mode 100644 index 0000000..9f62f9c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumCallModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model + +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +sealed class ReferendumCallModel { + + class GovernanceRequest(val amount: AmountModel) : ReferendumCallModel() +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumStatusModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumStatusModel.kt new file mode 100644 index 0000000..ef69344 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumStatusModel.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model + +import androidx.annotation.ColorRes + +data class ReferendumStatusModel(val name: String, @ColorRes val colorRes: Int) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumTimeEstimation.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumTimeEstimation.kt new file mode 100644 index 0000000..dfe638e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumTimeEstimation.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model + +import android.view.Gravity +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.common.view.stopTimer + +typealias ReferendumTimeEstimationStyleRefresher = () -> ReferendumTimeEstimation.TextStyle + +sealed class ReferendumTimeEstimation { + + data class TextStyle( + @DrawableRes val iconRes: Int, + @ColorRes val textColorRes: Int, + @ColorRes val iconColorRes: Int + ) { + + companion object + } + + class Timer( + val time: TimerValue, + @StringRes val timeFormat: Int, + val textStyleRefresher: ReferendumTimeEstimationStyleRefresher, + ) : ReferendumTimeEstimation() { + + override fun hashCode(): Int { + var result = time.millis.hashCode() + result = 31 * result + timeFormat + return result + } + + override fun equals(other: Any?): Boolean { + if (other !is Timer) return false + return time.millis == other.time.millis && + timeFormat == other.timeFormat + } + } + + data class Text( + val text: String, + val textStyle: TextStyle + ) : ReferendumTimeEstimation() +} + +fun TextView.setReferendumTimeEstimation(maybeTimeEstimation: ReferendumTimeEstimation?, iconGravity: Int) = letOrHide(maybeTimeEstimation) { timeEstimation -> + when (timeEstimation) { + is ReferendumTimeEstimation.Text -> { + stopTimer() + + text = timeEstimation.text + setReferendumTextStyle(timeEstimation.textStyle, iconGravity) + } + + is ReferendumTimeEstimation.Timer -> { + setReferendumTextStyle(timeEstimation.textStyleRefresher(), iconGravity) + + startTimer( + value = timeEstimation.time, + customMessageFormat = timeEstimation.timeFormat, + onTick = { view, _ -> + view.setReferendumTextStyle(timeEstimation.textStyleRefresher(), iconGravity) + } + ) + } + } +} + +private fun TextView.setReferendumTextStyle(textStyle: ReferendumTimeEstimation.TextStyle, iconGravity: Int) { + setTextColorRes(textStyle.textColorRes) + when (iconGravity) { + Gravity.START -> { + setDrawableStart(textStyle.iconRes, widthInDp = 16, paddingInDp = 4, tint = textStyle.iconColorRes) + } + Gravity.END -> { + setDrawableEnd(textStyle.iconRes, widthInDp = 16, paddingInDp = 4, tint = textStyle.iconColorRes) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumTrackModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumTrackModel.kt new file mode 100644 index 0000000..5ec80c6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumTrackModel.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model + +import androidx.core.view.isGone +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.setTrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.view.NovaChipView + +data class ReferendumTrackModel(val track: TrackModel, val sameWithOther: Boolean) + +fun NovaChipView.setReferendumTrackModel(maybeTrack: ReferendumTrackModel?) { + setTrackModel(maybeTrack?.track) + + maybeTrack?.sameWithOther?.let { + isGone = it + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumVotingModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumVotingModel.kt new file mode 100644 index 0000000..21f85cb --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/common/model/ReferendumVotingModel.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload + +data class ReferendumVotingModel( + val positiveFraction: Float?, + val thresholdFraction: Float?, + @DrawableRes val votingResultIcon: Int, + @ColorRes val votingResultIconColor: Int, + val thresholdInfo: String?, + val thresholdInfoVisible: Boolean, + val positivePercentage: String, + val negativePercentage: String, + val thresholdPercentage: String?, +) + +fun ReferendumVotingModel.toDetailsPayload(): ReferendumDetailsPayload.VotingData { + return ReferendumDetailsPayload.VotingData( + positiveFraction = positiveFraction, + thresholdFraction = thresholdFraction, + votingResultIcon = votingResultIcon, + votingResultIconColor = votingResultIconColor, + thresholdInfo = thresholdInfo, + thresholdInfoVisible = thresholdInfoVisible, + positivePercentage = positivePercentage, + negativePercentage = negativePercentage, + thresholdPercentage = thresholdPercentage, + ) +} + +fun ReferendumDetailsPayload.VotingData.toModel(): ReferendumVotingModel { + return ReferendumVotingModel( + positiveFraction = positiveFraction, + thresholdFraction = thresholdFraction, + votingResultIcon = votingResultIcon, + votingResultIconColor = votingResultIconColor, + thresholdInfo = thresholdInfo, + thresholdInfoVisible = thresholdInfoVisible, + positivePercentage = positivePercentage, + negativePercentage = negativePercentage, + thresholdPercentage = thresholdPercentage, + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/ReferendumDetailsFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/ReferendumDetailsFragment.kt new file mode 100644 index 0000000..a3c9442 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/ReferendumDetailsFragment.kt @@ -0,0 +1,176 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details + +import android.content.Context +import android.os.Bundle +import androidx.core.view.isVisible + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.isLoaded +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.setAddressOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentReferendumDetailsBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.common.share.setupReferendumSharing +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumCallModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.setReferendumTrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.ReferendumDetailsModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.ShortenedTextModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.applyTo + +class ReferendumDetailsFragment : BaseFragment(), WithContextExtensions { + + companion object { + private const val KEY_PAYLOAD = "payload" + + fun getBundle(payload: ReferendumDetailsPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentReferendumDetailsBinding.inflate(layoutInflater) + + override val providedContext: Context + get() = requireContext() + + override fun initViews() { + binder.referendumDetailsToolbar.setHomeButtonListener { + viewModel.backClicked() + } + + binder.referendumDetailsToolbar.setRightActionClickListener { viewModel.shareButtonClicked() } + + binder.referendumDetailsRequestedAmountContainer.background = getRoundedCornerDrawable(R.color.block_background) + binder.referendumDetailsTrack.background = getRoundedCornerDrawable(R.color.chips_background, cornerSizeDp = 8) + .withRippleMask(getRippleMask(cornerSizeDp = 8)) + binder.referendumDetailsNumber.background = getRoundedCornerDrawable(R.color.chips_background, cornerSizeDp = 8) + .withRippleMask(getRippleMask(cornerSizeDp = 8)) + binder.referendumFullDetails.background = getRoundedCornerDrawable(R.color.block_background) + .withRippleMask() + binder.referendumTimelineContainer.background = getRoundedCornerDrawable(R.color.block_background) + + binder.referendumDetailsReadMore.setOnClickListener { + viewModel.readMoreClicked() + } + + binder.referendumDetailsVotingStatus.setPositiveVotersClickListener { + viewModel.positiveVotesClicked() + } + + binder.referendumDetailsVotingStatus.setNegativeVotersClickListener { + viewModel.negativeVotesClicked() + } + + binder.referendumDetailsVotingStatus.setAbstainVotersClickListener { + viewModel.abstainVotesClicked() + } + + binder.referendumDetailsDappList.onDAppClicked(viewModel::dAppClicked) + + binder.referendumFullDetails.setOnClickListener { + viewModel.fullDetailsClicked() + } + + binder.referendumDetailsVotingStatus.setStartVoteOnClickListener { + viewModel.voteClicked() + } + + binder.referendumDetailsProposer.setOnClickListener { + viewModel.proposerClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .referendumDetailsFactory() + .create(this, requireArguments().getParcelable(KEY_PAYLOAD)!!) + .inject(this) + } + + override fun subscribe(viewModel: ReferendumDetailsViewModel) { + setupExternalActions(viewModel) + setupReferendumSharing(viewModel.shareReferendumMixin) + observeValidations(viewModel) + setupConfirmationDialog(R.style.AccentNegativeAlertDialogTheme_Reversed, viewModel.referendumNotAwaitableAction) + + viewModel.referendumDetailsModelFlow.observeWhenVisible { loadingState -> + setContentVisible(loadingState.isLoaded()) + binder.referendumDetailsProgress.isVisible = loadingState.isLoading() + loadingState.dataOrNull?.let { setReferendumState(it) } + } + + viewModel.proposerAddressModel.observeWhenVisible(binder.referendumDetailsProposer::setAddressOrHide) + + viewModel.referendumCallModelFlow.observeWhenVisible(::setReferendumCall) + + viewModel.referendumDApps.observeWhenVisible(binder.referendumDetailsDappList::setDAppsOrHide) + + viewModel.voteButtonState.observeWhenVisible(binder.referendumDetailsVotingStatus::setVoteButtonState) + + viewModel.showFullDetails.observeWhenVisible(binder.referendumFullDetails::setVisible) + } + + private fun setReferendumState(model: ReferendumDetailsModel) { + binder.referendumDetailsTrack.setReferendumTrackModel(model.track) + binder.referendumDetailsNumber.setText(model.number) + + binder.referendumDetailsTitle.text = model.title + setDescription(model.description) + + binder.referendumDetailsYourVote.setModel(model.yourVote) + + binder.referendumDetailsVotingStatus.letOrHide(model.statusModel) { + binder.referendumDetailsVotingStatus.setStatus(it) + } + binder.referendumDetailsVotingStatus.setTimeEstimation(model.timeEstimation) + binder.referendumDetailsVotingStatus.setVotingModel(model.voting) + binder.referendumDetailsVotingStatus.setPositiveVoters(model.ayeVoters) + binder.referendumDetailsVotingStatus.setNegativeVoters(model.nayVoters) + binder.referendumDetailsVotingStatus.setAbstainVoters(model.abstainVoters) + + binder.referendumTimelineContainer.letOrHide(model.timeline) { + binder.referendumDetailsTimeline.setTimeline(it) + } + } + + // TODO we need a better way of managing views for specific calls when multiple calls will be supported + private fun setReferendumCall(model: ReferendumCallModel?) { + when (model) { + is ReferendumCallModel.GovernanceRequest -> { + binder.referendumDetailsRequestedAmountContainer.makeVisible() + binder.referendumDetailsRequestedAmount.text = model.amount.token + binder.referendumDetailsRequestedAmountFiat.text = model.amount.fiat + } + + null -> { + binder.referendumDetailsRequestedAmountContainer.makeGone() + } + } + } + + private fun setContentVisible(visible: Boolean) { + binder.referendumDetailsToolbarChips.setVisible(visible) + binder.referendumDetailsScrollView.setVisible(visible) + } + + private fun setDescription(model: ShortenedTextModel?) { + model.applyTo(binder.referendumDetailsDescription, binder.referendumDetailsReadMore, viewModel.markwon) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/ReferendumDetailsViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/ReferendumDetailsViewModel.kt new file mode 100644 index 0000000..c9cc109 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/ReferendumDetailsViewModel.kt @@ -0,0 +1,551 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details + +import androidx.lifecycle.viewModelScope +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.asLoaded +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.filterLoaded +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.domain.loadedAndEmpty +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.actionAwaitable.titleAndButton +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.firstIfLoaded +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.utils.mapNullable +import io.novafoundation.nova.common.utils.withLoadingShared +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.validation.handleChainAccountNotFound +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createIdentityAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PreImage +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumVoting +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumCall +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDApp +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetails +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetailsInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumTimeline +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.isFinished +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.isUserDelegatedVote +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.isUserDirectVote +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.noVote +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.valiadtions.ReferendumPreVoteValidationFailure +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.valiadtions.ReferendumPreVoteValidationPayload +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.valiadtions.ReferendumPreVoteValidationSystem +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.PreparingReason +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatus +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.toReferendumId +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.dapp.GovernanceDAppsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.identity.GovernanceIdentityProviderFactory +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.description.DescriptionPayload +import io.novafoundation.nova.feature_governance_impl.presentation.common.share.ShareReferendumMixin +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumCallModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumStatusModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTimeEstimation +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.toModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.DefaultCharacterLimit +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.ReferendumDAppModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.ReferendumDetailsModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.ShortenedTextModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.PreImagePreviewPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumCallPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumFullDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumProposerPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.timeline.TimelineLayout +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.ReferendumVotersPayload +import io.novafoundation.nova.feature_governance_impl.presentation.view.VotersModel +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chainAndAsset +import io.novafoundation.nova.runtime.state.selectedChainFlow +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ReferendumDetailsViewModel( + private val router: GovernanceRouter, + private val payload: ReferendumDetailsPayload, + private val interactor: ReferendumDetailsInteractor, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val selectedAssetSharedState: GovernanceSharedState, + private val governanceIdentityProviderFactory: GovernanceIdentityProviderFactory, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val tokenUseCase: TokenUseCase, + private val referendumFormatter: ReferendumFormatter, + private val externalActions: ExternalActions.Presentation, + private val governanceDAppsInteractor: GovernanceDAppsInteractor, + val markwon: Markwon, + private val validationSystem: ReferendumPreVoteValidationSystem, + private val validationExecutor: ValidationExecutor, + private val updateSystem: UpdateSystem, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + val shareReferendumMixin: ShareReferendumMixin, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor { + + private val selectedAccount = selectedAccountUseCase.selectedMetaAccountFlow() + private val selectedChainFlow = selectedAssetSharedState.selectedChainFlow() + + private val optionalReferendumDetailsFlow = flowOfAll { + val account = selectedAccount.first() + val selectedGovernanceOption = selectedAssetSharedState.selectedOption() + val voterAccountId = account.accountIdIn(selectedGovernanceOption.assetWithChain.chain) + + interactor.referendumDetailsFlow(payload.toReferendumId(), selectedGovernanceOption, voterAccountId, viewModelScope) + }.inBackground() + .shareWhileSubscribed() + + private val referendumDetailsFlow = optionalReferendumDetailsFlow + .filterNotNull() + .withLoadingShared() + .shareInBackground() + + private val abstainVotingSupported = flowOf { interactor.isSupportAbstainVoting(selectedAssetSharedState.selectedOption()) } + + private val proposerFlow = referendumDetailsFlow.mapLoading { it.proposer } + .filterLoaded() + + private val proposerIdentityProvider = governanceIdentityProviderFactory.proposerProvider(proposerFlow) + + private val tokenFlow = tokenUseCase.currentTokenFlow() + .shareInBackground() + + val proposerAddressModel = referendumDetailsFlow.mapLoading { + it.proposer?.let { proposer -> + addressIconGenerator.createIdentityAddressModel( + chain = selectedChainFlow.first(), + accountId = proposer.accountId, + identityProvider = proposerIdentityProvider + ) + } + }.filterLoaded() + .inBackground() + .shareWhileSubscribed() + + val referendumDetailsModelFlow = combine(referendumDetailsFlow, abstainVotingSupported) { referendumDetailsLoadingState, abstainSupported -> + mapReferendumDetailsToUiLoadingState(referendumDetailsLoadingState, abstainSupported) + }.inBackground() + .shareWhileSubscribed() + + val voteButtonState = referendumDetailsFlow.map { + val referendumDetails = it.dataOrNull + + when { + !payload.allowVoting -> DescriptiveButtonState.Gone + // If referendum is still loading and allow voting is enabled - show vote button to don't block voting flow + referendumDetails == null -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.vote_vote)) + // Supported all other cases when referendum is loaded + referendumDetails.isFinished() -> DescriptiveButtonState.Gone + referendumDetails.noVote() || referendumDetails.isUserDelegatedVote() -> DescriptiveButtonState.Enabled( + resourceManager.getString(R.string.vote_vote) + ) + + referendumDetails.isUserDirectVote() -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.vote_revote)) + else -> DescriptiveButtonState.Gone + } + } + + private val referendumCallFlow = referendumDetailsFlow.mapLoading { details -> + details.onChainMetadata?.preImage?.let { preImage -> + interactor.detailsFor(preImage, selectedChainFlow.first()) + } + }.filterLoaded() + .inBackground() + .shareWhileSubscribed() + + val showFullDetails = combine(referendumDetailsFlow.filterLoaded(), referendumCallFlow) { details, call -> + fullDetailsAccessible(details, call) + } + + val referendumCallModelFlow = referendumCallFlow.mapNullable(::mapReferendumCallToUi) + .inBackground() + .shareWhileSubscribed() + + val referendumDApps = flowOfAll { + val selectedGovernanceOption = selectedAssetSharedState.selectedOption() + governanceDAppsInteractor.observeReferendumDapps(payload.toReferendumId(), selectedGovernanceOption) + } + .mapList(::mapGovernanceDAppToUi) + .shareInBackground() + + val referendumNotAwaitableAction = actionAwaitableMixinFactory.confirmingAction() + + init { + optionalReferendumDetailsFlow + .onEach { + if (it == null) { + showErrorAndCloseScreen() + } + }.launchIn(this) + + updateSystem.start() + .launchIn(this) + } + + fun backClicked() { + router.back() + } + + fun proposerClicked() = launch { + val proposer = proposerAddressModel.first()?.address ?: return@launch + + externalActions.showAddressActions(proposer, selectedChainFlow.first()) + } + + fun readMoreClicked() = launch { + val referendumDetails = referendumDetailsFlow.firstIfLoaded() ?: return@launch + val referendumDetailsModel = referendumDetailsModelFlow.firstIfLoaded() ?: return@launch + val referendumDescription = mapReferendumDescriptionToUi(referendumDetails) + val payload = DescriptionPayload(description = referendumDescription, title = referendumDetailsModel.title) + router.openReferendumDescription(payload) + } + + fun positiveVotesClicked() { + val votersPayload = ReferendumVotersPayload( + payload.referendumId, + VoteType.AYE + ) + router.openReferendumVoters(votersPayload) + } + + fun negativeVotesClicked() { + val votersPayload = ReferendumVotersPayload( + payload.referendumId, + VoteType.NAY + ) + router.openReferendumVoters(votersPayload) + } + + fun abstainVotesClicked() { + val votersPayload = ReferendumVotersPayload( + payload.referendumId, + VoteType.ABSTAIN + ) + router.openReferendumVoters(votersPayload) + } + + fun dAppClicked(dAppModel: ReferendumDAppModel) { + router.openDAppBrowser(dAppModel.referendumUrl) + } + + fun fullDetailsClicked() = launch { + val referendumDetails = referendumDetailsFlow.firstIfLoaded() ?: return@launch + val payload = constructFullDetailsPayload(referendumDetails) + router.openReferendumFullDetails(payload) + } + + fun voteClicked() = launch { + val validationPayload = ReferendumPreVoteValidationPayload( + metaAccount = selectedAccount.first(), + chain = selectedChainFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + validationFailureTransformerCustom = { status, _ -> mapValidationFailureToUi(status.reason) }, + ) { + val votePayload = SetupVotePayload(payload.referendumId) + router.openSetupReferendumVote(votePayload) + } + } + + fun shareButtonClicked() { + launch { + val selectedOption = selectedAssetSharedState.selectedOption() + shareReferendumMixin.shareReferendum( + payload.referendumId, + selectedOption.assetWithChain.chain, + selectedOption.additional.governanceType + ) + } + } + + private suspend fun mapReferendumDetailsToUiLoadingState( + referendumLoadingState: ExtendedLoadingState, + abstainVotingSupported: Boolean + ): ExtendedLoadingState { + val referendum = referendumLoadingState.dataOrNull + val prefilledData = payload.prefilledData + + // If we can't show prefilled data we will show loading state + if (referendum == null && prefilledData == null) return ExtendedLoadingState.Loading + + val timeEstimation = referendum?.timeline?.currentStatus?.let { referendumFormatter.formatTimeEstimation(it) } + val (chain, chainAsset) = selectedAssetSharedState.chainAndAsset() + val token = tokenFlow.first() + + val statusModel = referendum?.timeline?.currentStatus?.let { referendumFormatter.formatStatus(it) } + ?: prefilledData?.status?.let { ReferendumStatusModel(it.statusName, it.statusColor) } + + return ReferendumDetailsModel( + track = referendum?.track?.let { referendumFormatter.formatReferendumTrack(it, chainAsset) }, + number = referendum?.id?.let { referendumFormatter.formatId(it) } ?: prefilledData?.referendumNumber, + title = referendum?.let { mapReferendumTitleToUi(it) } ?: prefilledData?.title, + description = referendum?.let { mapShortenedMarkdownDescription(it) }, + voting = referendum?.voting?.let { referendumFormatter.formatVoting(it, referendum.threshold, token) } ?: prefilledData?.voting?.toModel(), + statusModel = statusModel, + yourVote = referendum?.userVote?.let { referendumFormatter.formatUserVote(it, chain, chainAsset) }, + ayeVoters = mapVotersToUi(referendum?.voting, VoteType.AYE, chainAsset, abstainVotingSupported), + nayVoters = mapVotersToUi(referendum?.voting, VoteType.NAY, chainAsset, abstainVotingSupported), + abstainVoters = mapVotersToUi(referendum?.voting, VoteType.ABSTAIN, chainAsset, abstainVotingSupported), + timeEstimation = timeEstimation, + timeline = referendum?.timeline?.let { mapTimelineToUi(it, timeEstimation) } + ).asLoaded() + } + + private fun mapTimelineToUi( + timeline: ReferendumTimeline, + currentTimeEstimation: ReferendumTimeEstimation? + ): TimelineLayout.Timeline { + val states = buildList { + val historical = timeline.pastEntries.map(::mapHistoricalTimelineEntryToUi) + addAll(historical) + + currentTimeEstimation?.let { + val current = mapCurrentStatusToTimelineEntry(timeline.currentStatus, it) + add(current) + } + } + + return TimelineLayout.Timeline( + states = states, + finished = currentTimeEstimation == null + ) + } + + private fun mapCurrentStatusToTimelineEntry( + currentStatus: ReferendumStatus, + currentTimeEstimation: ReferendumTimeEstimation + ): TimelineLayout.TimelineState { + val titleRes = when (currentStatus) { + is ReferendumStatus.Ongoing.Preparing -> { + when (currentStatus.reason) { + is PreparingReason.DecidingIn -> R.string.referendum_timeline_state_preparing + PreparingReason.WaitingForDeposit -> R.string.referendum_timeline_state_waiting_deposit + } + } + + is ReferendumStatus.Ongoing.DecidingApprove, + is ReferendumStatus.Ongoing.DecidingReject -> R.string.referendum_timeline_state_deciding + + is ReferendumStatus.Ongoing.Confirming -> R.string.referendum_timeline_state_passing + + is ReferendumStatus.Ongoing.InQueue -> R.string.referendum_timeline_state_in_queue + is ReferendumStatus.Approved -> R.string.referendum_timeline_state_approved + + ReferendumStatus.Executed, + ReferendumStatus.NotExecuted.Cancelled, + ReferendumStatus.NotExecuted.Killed, + ReferendumStatus.NotExecuted.Rejected, + ReferendumStatus.NotExecuted.TimedOut -> null + } + + return TimelineLayout.TimelineState.Current( + title = titleRes?.let(resourceManager::getString).orEmpty(), + subtitle = currentTimeEstimation + ) + } + + private fun mapHistoricalTimelineEntryToUi(entry: ReferendumTimeline.Entry): TimelineLayout.TimelineState { + val formattedData = entry.at?.let(resourceManager::formatDateTime) + val stateLabelRes = when (entry.state) { + ReferendumTimeline.State.CREATED -> R.string.referendum_timeline_state_created + ReferendumTimeline.State.APPROVED -> R.string.referendum_timeline_state_approved + ReferendumTimeline.State.REJECTED -> R.string.referendum_timeline_state_rejected + ReferendumTimeline.State.EXECUTED -> R.string.referendum_timeline_state_executed + ReferendumTimeline.State.CANCELLED -> R.string.referendum_timeline_state_cancelled + ReferendumTimeline.State.KILLED -> R.string.referendum_timeline_state_killed + ReferendumTimeline.State.TIMED_OUT -> R.string.referendum_timeline_state_timed_out + } + + return TimelineLayout.TimelineState.Historical( + title = resourceManager.getString(stateLabelRes), + subtitle = formattedData + ) + } + + private fun mapVotersToUi( + voting: ReferendumVoting?, + type: VoteType, + chainAsset: Chain.Asset, + abstainVotingSupported: Boolean + ): VotersModel? { + return when (type) { + VoteType.AYE -> { + if (voting?.approval?.loadedAndEmpty() == true) return null + + VotersModel( + voteTypeColorRes = R.color.aye_indicator, + voteTypeRes = R.string.referendum_vote_aye, + votesValue = voting?.approval?.dataOrNull?.let { formatVotesAmount(it.ayeVotes.amount, chainAsset) }, + loading = voting == null || voting.approval.isLoading() + ) + } + + VoteType.NAY -> { + if (voting?.approval?.loadedAndEmpty() == true) return null + + VotersModel( + voteTypeColorRes = R.color.nay_indicator, + voteTypeRes = R.string.referendum_vote_nay, + votesValue = voting?.approval?.dataOrNull?.let { formatVotesAmount(it.nayVotes.amount, chainAsset) }, + loading = voting == null || voting.approval.isLoading() + ) + } + + VoteType.ABSTAIN -> { + if (!abstainVotingSupported || voting?.abstainVotes?.loadedAndEmpty() == true) return null + + VotersModel( + voteTypeColorRes = R.color.icon_secondary, + voteTypeRes = R.string.referendum_vote_abstain, + votesValue = voting?.abstainVotes?.dataOrNull?.let { formatVotesAmount(it, chainAsset) }, + loading = voting == null || voting.abstainVotes.isLoading() + ) + } + } + } + + private fun formatVotesAmount(planks: Balance, chainAsset: Chain.Asset): String { + val amount = chainAsset.amountFromPlanks(planks) + + return resourceManager.getString(R.string.referendum_votes_format, amount.format()) + } + + private fun mapReferendumDescriptionToUi(referendumDetails: ReferendumDetails): String { + return referendumDetails.offChainMetadata?.description + ?: resourceManager.getString(R.string.referendum_description_fallback) + } + + private fun mapShortenedMarkdownDescription(referendumDetails: ReferendumDetails): ShortenedTextModel { + val referendumDescription = mapReferendumDescriptionToUi(referendumDetails) + val markdownDescription = markwon.toMarkdown(referendumDescription) + return ShortenedTextModel.from(markdownDescription, DefaultCharacterLimit.SHORT_PARAGRAPH) + } + + private fun mapReferendumTitleToUi(referendumDetails: ReferendumDetails): String { + return referendumFormatter.formatReferendumName(referendumDetails) + } + + private suspend fun mapReferendumCallToUi(referendumCall: ReferendumCall): ReferendumCallModel { + return when (referendumCall) { + is ReferendumCall.TreasuryRequest -> { + val token = tokenUseCase.getToken(referendumCall.chainAsset.fullId) + + ReferendumCallModel.GovernanceRequest( + amount = amountFormatter.formatAmountToAmountModel(referendumCall.amount, token) + ) + } + } + } + + private fun mapGovernanceDAppToUi(referendumDApp: ReferendumDApp): ReferendumDAppModel { + return ReferendumDAppModel( + name = referendumDApp.name, + iconUrl = referendumDApp.iconUrl, + description = referendumDApp.details, + referendumUrl = referendumDApp.referendumUrl + ) + } + + private suspend fun constructFullDetailsPayload(referendumDetails: ReferendumDetails): ReferendumFullDetailsPayload = withContext(Dispatchers.Default) { + val referendumCall = referendumCallFlow.first() + + ReferendumFullDetailsPayload( + proposer = referendumDetails.proposer?.let { + ReferendumProposerPayload(it.accountId, it.offChainNickname) + }, + voteThreshold = referendumDetails.fullDetails.voteThreshold?.readableName, + approveThreshold = referendumDetails.fullDetails.approvalCurve?.name, + supportThreshold = referendumDetails.fullDetails.supportCurve?.name, + hash = referendumDetails.onChainMetadata?.preImageHash, + deposit = referendumDetails.fullDetails.deposit, + turnout = referendumDetails.voting?.support?.dataOrNull?.turnout, + electorate = referendumDetails.voting?.support?.dataOrNull?.electorate, + referendumCall = ReferendumCallPayload(referendumCall), + preImage = constructPreimagePreviewPayload(referendumDetails.onChainMetadata?.preImage), + ) + } + + private fun fullDetailsAccessible(referendumDetails: ReferendumDetails, referendumCall: ReferendumCall?): Boolean { + return checkAnyNonNull( + referendumDetails.fullDetails.voteThreshold, + referendumDetails.fullDetails.approvalCurve, + referendumDetails.fullDetails.supportCurve, + referendumDetails.fullDetails.deposit, + referendumDetails.onChainMetadata, + referendumDetails.voting, + referendumCall + ) + } + + private fun checkAnyNonNull(vararg args: Any?): Boolean { + return args.any { it != null } + } + + private suspend fun constructPreimagePreviewPayload(preImage: PreImage?): PreImagePreviewPayload? { + return preImage?.let { + PreImagePreviewPayload(interactor.previewFor(preImage)) + } + } + + private fun mapValidationFailureToUi(failure: ReferendumPreVoteValidationFailure): TransformedFailure { + return when (failure) { + is ReferendumPreVoteValidationFailure.NoRelaychainAccount -> handleChainAccountNotFound( + failure = failure, + resourceManager = resourceManager, + goToWalletDetails = { router.openWalletDetails(failure.account.id) }, + addAccountDescriptionRes = R.string.referendum_missing_account_message + ) + } + } + + private suspend fun showErrorAndCloseScreen() { + val confirmationInfo = ConfirmationDialogInfo.titleAndButton( + resourceManager, + title = R.string.referendim_details_not_found_title, + button = R.string.common_ok, + ) + referendumNotAwaitableAction.awaitAction(confirmationInfo) + router.back() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/RealReferendumDetailsDeepLinkConfigurator.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/RealReferendumDetailsDeepLinkConfigurator.kt new file mode 100644 index 0000000..95ab49f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/RealReferendumDetailsDeepLinkConfigurator.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.doIf +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.addParamIfNotNull +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDeepLinkData +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator.Companion.ACTION +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator.Companion.CHAIN_ID_PARAM +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator.Companion.GOVERNANCE_TYPE_PARAM +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator.Companion.REFERENDUM_ID_PARAM +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator.Companion.SCREEN +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class RealReferendumDetailsDeepLinkConfigurator( + private val linkBuilderFactory: LinkBuilderFactory +) : ReferendumDetailsDeepLinkConfigurator { + + override fun configure(payload: ReferendumDeepLinkData, type: DeepLinkConfigurator.Type): Uri { + // We not add Polkadot chain id to simplify deep link + val appendChainIdParam = payload.chainId != ChainGeneses.POLKADOT + + return linkBuilderFactory.newLink(type) + .setAction(ACTION) + .setScreen(SCREEN) + .doIf(appendChainIdParam) { addParam(CHAIN_ID_PARAM, payload.chainId) } + .addParam(REFERENDUM_ID_PARAM, payload.referendumId.toString()) + .addParamIfNotNull(GOVERNANCE_TYPE_PARAM, payload.governanceType.let(::mapGovTypeToParams)) + .build() + } + + private fun mapGovTypeToParams(govType: Chain.Governance): String? { + return when (govType) { + Chain.Governance.V1 -> 1.toString() + Chain.Governance.V2 -> null // We handle null case as Gov2 by default and it's not necessary to put it in link + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandler.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandler.kt new file mode 100644 index 0000000..1c13551 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/deeplink/ReferendumDeepLinkHandler.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.deeplink + +import android.net.Uri +import io.novafoundation.nova.feature_deep_linking.presentation.handling.common.DeepLinkHandlingException.ReferendumHandlingException +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import io.novafoundation.nova.feature_governance_api.data.MutableGovernanceState +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getChainOrNull +import kotlinx.coroutines.flow.MutableSharedFlow +import java.math.BigInteger + +class ReferendumDeepLinkHandler( + private val router: GovernanceRouter, + private val chainRegistry: ChainRegistry, + private val mutableGovernanceState: MutableGovernanceState, + private val automaticInteractionGate: AutomaticInteractionGate +) : DeepLinkHandler { + + override val callbackFlow = MutableSharedFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + + return path.startsWith(ReferendumDetailsDeepLinkConfigurator.PREFIX) + } + + override suspend fun handleDeepLink(data: Uri) = runCatching { + automaticInteractionGate.awaitInteractionAllowed() + + val chainId = data.getChainIdOrPolkadot() + val referendumId = data.getReferendumId() ?: throw ReferendumHandlingException.ReferendumIsNotSpecified + + val chain = chainRegistry.getChainOrNull(chainId) ?: throw ReferendumHandlingException.ChainIsNotFound + require(chain.isEnabled) + + val governanceType = data.getGovernanceType(chain) + val payload = ReferendumDetailsPayload(referendumId, allowVoting = true, prefilledData = null) + + mutableGovernanceState.update(chain.id, chain.utilityAsset.id, governanceType) + router.openReferendumFromDeepLink(payload) + } + + private fun Uri.getChainIdOrPolkadot(): String { + return getQueryParameter(ReferendumDetailsDeepLinkConfigurator.CHAIN_ID_PARAM) ?: ChainGeneses.POLKADOT + } + + private fun Uri.getReferendumId(): BigInteger? { + return getQueryParameter(ReferendumDetailsDeepLinkConfigurator.REFERENDUM_ID_PARAM) + ?.toBigIntegerOrNull() + } + + private fun Uri.getGovernanceType(chain: Chain): Chain.Governance { + val supportedGov = chain.governance + val govType = getQueryParameter(ReferendumDetailsDeepLinkConfigurator.GOVERNANCE_TYPE_PARAM) + ?.toIntOrNull() + + val cantSelectGovType = govType == null && supportedGov.size > 1 && !supportedGov.contains(Chain.Governance.V2) + + return when { + cantSelectGovType -> throw ReferendumHandlingException.GovernanceTypeIsNotSpecified + govType == null && supportedGov.contains(Chain.Governance.V2) -> Chain.Governance.V2 + govType == null && supportedGov.size == 1 -> supportedGov.first() + govType == 0 && supportedGov.contains(Chain.Governance.V2) -> Chain.Governance.V2 + govType == 1 && supportedGov.contains(Chain.Governance.V1) -> Chain.Governance.V1 + govType !in 0..1 -> throw ReferendumHandlingException.GovernanceTypeIsNotSupported + else -> throw ReferendumHandlingException.GovernanceTypeIsNotSupported + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/di/ReferendumDetailsComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/di/ReferendumDetailsComponent.kt new file mode 100644 index 0000000..a2114e2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/di/ReferendumDetailsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.ReferendumDetailsFragment +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload + +@Subcomponent( + modules = [ + ReferendumDetailsModule::class + ] +) +@ScreenScope +interface ReferendumDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ReferendumDetailsPayload, + ): ReferendumDetailsComponent + } + + fun inject(fragment: ReferendumDetailsFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/di/ReferendumDetailsModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/di/ReferendumDetailsModule.kt new file mode 100644 index 0000000..bb101d7 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/di/ReferendumDetailsModule.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.modules.shared.MarkdownShortModule +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumDetailsInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.valiadtions.ReferendumPreVoteValidationSystem +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.valiadtions.referendumPreVote +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.dapp.GovernanceDAppsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.identity.GovernanceIdentityProviderFactory +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.common.share.ShareReferendumMixin +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.ReferendumDetailsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, MarkdownShortModule::class]) +class ReferendumDetailsModule { + + @Provides + @ScreenScope + fun provideValidationSystem(): ReferendumPreVoteValidationSystem = ValidationSystem.referendumPreVote() + + @Provides + @IntoMap + @ViewModelKey(ReferendumDetailsViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + payload: ReferendumDetailsPayload, + interactor: ReferendumDetailsInteractor, + selectedAccountUseCase: SelectedAccountUseCase, + selectedAssetSharedState: GovernanceSharedState, + governanceIdentityProviderFactory: GovernanceIdentityProviderFactory, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + tokenUseCase: TokenUseCase, + referendumFormatter: ReferendumFormatter, + externalActions: ExternalActions.Presentation, + markwon: Markwon, + governanceDAppsInteractor: GovernanceDAppsInteractor, + validationSystem: ReferendumPreVoteValidationSystem, + validationExecutor: ValidationExecutor, + updateSystem: UpdateSystem, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + shareReferendumMixin: ShareReferendumMixin, + amountFormatter: AmountFormatter + ): ViewModel { + return ReferendumDetailsViewModel( + router = router, + payload = payload, + interactor = interactor, + selectedAccountUseCase = selectedAccountUseCase, + selectedAssetSharedState = selectedAssetSharedState, + governanceIdentityProviderFactory = governanceIdentityProviderFactory, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + tokenUseCase = tokenUseCase, + referendumFormatter = referendumFormatter, + externalActions = externalActions, + markwon = markwon, + governanceDAppsInteractor = governanceDAppsInteractor, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + updateSystem = updateSystem, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + shareReferendumMixin = shareReferendumMixin, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ReferendumDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ReferendumDetailsViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/model/ReferendumDAppModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/model/ReferendumDAppModel.kt new file mode 100644 index 0000000..4fabb9d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/model/ReferendumDAppModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model + +class ReferendumDAppModel( + val name: String, + val iconUrl: String?, + val description: String, + val referendumUrl: String +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/model/ReferendumDetailsModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/model/ReferendumDetailsModel.kt new file mode 100644 index 0000000..1a55177 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/model/ReferendumDetailsModel.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model + +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumStatusModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTimeEstimation +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumVotingModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.timeline.TimelineLayout +import io.novafoundation.nova.feature_governance_impl.presentation.view.VotersModel +import io.novafoundation.nova.feature_governance_impl.presentation.view.YourMultiVoteModel + +class ReferendumDetailsModel( + val track: ReferendumTrackModel?, + val number: String?, + val title: String?, + val description: ShortenedTextModel?, + val voting: ReferendumVotingModel?, + val statusModel: ReferendumStatusModel?, + val yourVote: YourMultiVoteModel?, + val ayeVoters: VotersModel?, + val nayVoters: VotersModel?, + val abstainVoters: VotersModel?, + val timeEstimation: ReferendumTimeEstimation?, + val timeline: TimelineLayout.Timeline? +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/model/ShortenedTextModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/model/ShortenedTextModel.kt new file mode 100644 index 0000000..7217cba --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/details/model/ShortenedTextModel.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model + +import android.text.Spanned +import android.text.TextUtils +import android.widget.TextView +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.ReadMoreView + +private const val ELLIPSIS = "..." + +interface CharacterLimit { + + val limit: Int +} + +enum class DefaultCharacterLimit(override val limit: Int) : CharacterLimit { + + SHORT_PARAGRAPH(180) +} + +class ShortenedTextModel private constructor(val shortenedText: CharSequence, val hasMore: Boolean) { + + companion object { + fun from(text: CharSequence, characterLimit: CharacterLimit): ShortenedTextModel { + require(characterLimit.limit >= ELLIPSIS.length) + + return if (text.length > characterLimit.limit) { + val shortened = text.subSequence(0, characterLimit.limit - ELLIPSIS.length) + val shortenedEllipsized = TextUtils.concat(shortened, ELLIPSIS) + ShortenedTextModel(shortenedText = shortenedEllipsized, hasMore = true) + } else { + ShortenedTextModel(shortenedText = text, hasMore = false) + } + } + } +} + +fun ShortenedTextModel?.applyTo(textView: TextView, readMoreView: ReadMoreView, markwon: Markwon) { + if (this == null) { + textView.makeGone() + readMoreView.makeGone() + return + } + + if (shortenedText is Spanned) { + markwon.setParsedMarkdown(textView, shortenedText) + } else { + textView.text = shortenedText + } + + textView.setVisible(true) + readMoreView.setVisible(hasMore) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/ReferendaFiltersFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/ReferendaFiltersFragment.kt new file mode 100644 index 0000000..bbd2e9a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/ReferendaFiltersFragment.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.filters + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentReferendaFiltersBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent + +class ReferendaFiltersFragment : BaseFragment() { + + override fun createBinding() = FragmentReferendaFiltersBinding.inflate(layoutInflater) + + override fun initViews() { + binder.referendaFiltersToolbar.setHomeButtonListener { viewModel.homeButtonClicked() } + binder.referendaFiltersToolbar.setRightActionClickListener { binder.referendaFilterAll.isChecked = true } + + binder.referendaFiltersApplyButton.setOnClickListener { + viewModel.onApplyFilters() + } + + binder.referendaFiltersTypeGroup.check(viewModel.getReferendumTypeSelectedOption()) + binder.referendaFiltersTypeGroup.setOnCheckedChangeListener { _, checkedId -> + viewModel.onFilterTypeChanged(checkedId) + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .referendaFiltersFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ReferendaFiltersViewModel) { + viewModel.isApplyButtonAvailableFlow.observe { + binder.referendaFiltersApplyButton.isEnabled = it + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/ReferendaFiltersViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/ReferendaFiltersViewModel.kt new file mode 100644 index 0000000..f4d2f8c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/ReferendaFiltersViewModel.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.filters + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.reversed +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.filters.ReferendaFiltersInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumType +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumTypeFilter +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +private val REFERENDUM_TYPE_FILTERS = mapOf( + R.id.referendaFilterAll to ReferendumType.ALL, + R.id.referendaFilterNotVoted to ReferendumType.NOT_VOTED, + R.id.referendaFilterVoted to ReferendumType.VOTED +) + +private val REFERENDUM_TYPE_FILTERS_REVERSE = REFERENDUM_TYPE_FILTERS.reversed() + +class ReferendaFiltersViewModel( + private val interactor: ReferendaFiltersInteractor, + private val governanceRouter: GovernanceRouter +) : BaseViewModel() { + + private var selectedFilterFlow = MutableStateFlow(interactor.getReferendumTypeFilter().selectedType) + + private val initialTypeFilter = interactor.getReferendumTypeFilter() + + val isApplyButtonAvailableFlow = selectedFilterFlow.map { selectedFilter -> + selectedFilter != interactor.getReferendumTypeFilter().selectedType + }.inBackground() + .share() + + fun getReferendumTypeSelectedOption(): Int { + return REFERENDUM_TYPE_FILTERS_REVERSE.getValue(initialTypeFilter.selectedType) + } + + fun onFilterTypeChanged(checkedId: Int) { + selectedFilterFlow.value = REFERENDUM_TYPE_FILTERS.getValue(checkedId) + } + + fun onApplyFilters() { + interactor.updateReferendumTypeFilter(ReferendumTypeFilter(selectedFilterFlow.value)) + governanceRouter.back() + } + + fun homeButtonClicked() { + governanceRouter.back() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/di/ReferendaFiltersComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/di/ReferendaFiltersComponent.kt new file mode 100644 index 0000000..d2405fd --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/di/ReferendaFiltersComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.filters.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.filters.ReferendaFiltersFragment + +@Subcomponent( + modules = [ + ReferendaFiltersModule::class + ] +) +@ScreenScope +interface ReferendaFiltersComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): ReferendaFiltersComponent + } + + fun inject(fragment: ReferendaFiltersFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/di/ReferendaFiltersModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/di/ReferendaFiltersModule.kt new file mode 100644 index 0000000..74aa0c2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/filters/di/ReferendaFiltersModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.filters.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_governance_impl.domain.filters.ReferendaFiltersInteractor +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.filters.ReferendaFiltersViewModel + +@Module(includes = [ViewModelModule::class]) +class ReferendaFiltersModule { + + @Provides + @IntoMap + @ViewModelKey(ReferendaFiltersViewModel::class) + fun provideViewModel( + referendaFiltersInteractor: ReferendaFiltersInteractor, + governanceRouter: GovernanceRouter + ): ViewModel { + return ReferendaFiltersViewModel(referendaFiltersInteractor, governanceRouter) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ReferendaFiltersViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ReferendaFiltersViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/ReferendumFullDetailsFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/ReferendumFullDetailsFragment.kt new file mode 100644 index 0000000..cbe200f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/ReferendumFullDetailsFragment.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.full + +import android.os.Bundle +import androidx.core.view.isVisible + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.copy.setupCopyText +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentReferendumFullDetailsBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.model.AddressAndAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmountOrHide + +import javax.inject.Inject + +class ReferendumFullDetailsFragment : BaseFragment() { + + companion object { + private const val KEY_PAYLOAD = "payload" + + fun getBundle(payload: ReferendumFullDetailsPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentReferendumFullDetailsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun initViews() { + binder.referendumFullDetailsToolbar.setHomeButtonListener { + viewModel.backClicked() + } + binder.referendumFullDetailsPreImage.background = getRoundedCornerDrawable(R.color.block_background) + + binder.referendumFullDetailsApproveThreshold.showValueOrHide(primary = viewModel.approveThreshold) + binder.referendumFullDetailsSupportThreshold.showValueOrHide(primary = viewModel.supportThreshold) + binder.referendumFullDetailsVoteThreshold.showValueOrHide(primary = viewModel.voteThreshold) + + binder.referendumFullDetailsCallHash.showValueOrHide(primary = viewModel.callHash) + viewModel.callHash?.let { hash -> + binder.referendumFullDetailsCallHash.setOnClickListener { viewModel.copyCallHash() } + } + + binder.referendumFullDetailsPreimageTitle.isVisible = viewModel.hasPreimage + binder.referendumFullDetailsPlaceholder.isVisible = viewModel.isPreimageTooLong + binder.referendumFullDetailsPreImage.isVisible = viewModel.isPreviewAvailable + binder.referendumFullDetailsPreImage.text = viewModel.preImage + + binder.referendumFullDetailsProposal.setOnClickListener { viewModel.openProposal() } + binder.referendumFullDetailsBeneficiary.setOnClickListener { viewModel.openBeneficiary() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .referendumFullDetailsFactory() + .create(this, requireArguments().getParcelable(KEY_PAYLOAD)!!) + .inject(this) + } + + override fun subscribe(viewModel: ReferendumFullDetailsViewModel) { + setupExternalActions(viewModel) + setupCopyText(viewModel) + + viewModel.proposerModel.observe { state -> + updateProposerState(state) + } + + viewModel.beneficiaryModel.observe { state -> + updateBeneficiaryState(state) + } + + viewModel.turnoutAmount.observe(binder.referendumFullDetailsTurnout::showAmountOrHide) + + viewModel.electorateAmount.observe(binder.referendumFullDetailsElectorate::showAmountOrHide) + } + + private fun updateProposerState(state: LoadingState) { + if (state is LoadingState.Loaded) { + val addressAndAmount = state.data + if (addressAndAmount == null) { + binder.referendumFullDetailsProposalContainer.makeGone() + } else { + binder.referendumFullDetailsProposal.makeVisible() + binder.referendumFullDetailsProposal.showAddress(addressAndAmount.addressModel) + + binder.referendumFullDetailsDeposit.setVisible(addressAndAmount.amountModel != null) + addressAndAmount.amountModel?.let { binder.referendumFullDetailsDeposit.showAmount(it) } + } + } else { + binder.referendumFullDetailsProposal.showProgress() + binder.referendumFullDetailsDeposit.showProgress() + } + } + + private fun updateBeneficiaryState(state: LoadingState) { + if (state is LoadingState.Loaded) { + val addressAndAmount = state.data + + if (addressAndAmount == null) { + binder.referendumFullDetailsBeneficiaryContainer.makeGone() + } else { + binder.referendumFullDetailsBeneficiary.makeVisible() + binder.referendumFullDetailsBeneficiary.showAddress(addressAndAmount.addressModel) + addressAndAmount.amountModel?.let { binder.referendumFullDetailsRequestedAmount.showAmount(it) } + } + } else { + binder.referendumFullDetailsBeneficiary.showProgress() + binder.referendumFullDetailsRequestedAmount.showProgress() + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/ReferendumFullDetailsPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/ReferendumFullDetailsPayload.kt new file mode 100644 index 0000000..3e45149 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/ReferendumFullDetailsPayload.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.full + +import android.os.Parcelable +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.PreimagePreview +import io.novafoundation.nova.feature_governance_api.domain.referendum.details.ReferendumCall +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload +import io.novafoundation.nova.runtime.ext.fullId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize + +@Parcelize +class ReferendumFullDetailsPayload( + val proposer: ReferendumProposerPayload?, + val voteThreshold: String?, + val approveThreshold: String?, + val supportThreshold: String?, + val hash: ByteArray?, + val deposit: Balance?, + val turnout: Balance?, + val electorate: Balance?, + val referendumCall: ReferendumCallPayload?, + val preImage: PreImagePreviewPayload? +) : Parcelable + +sealed class ReferendumCallPayload : Parcelable { + + @Parcelize + class TreasuryRequest(val amount: Balance, val beneficiary: AccountId, val asset: AssetPayload) : ReferendumCallPayload() +} + +sealed class PreImagePreviewPayload : Parcelable { + + @Parcelize + object TooLong : PreImagePreviewPayload() + + @Parcelize + class Preview(val preview: String) : PreImagePreviewPayload() +} + +@Parcelize +class ReferendumProposerPayload(val accountId: AccountId, val offChainName: String?) : Parcelable + +fun ReferendumCallPayload(referendumCall: ReferendumCall?): ReferendumCallPayload? { + return when (referendumCall) { + is ReferendumCall.TreasuryRequest -> ReferendumCallPayload.TreasuryRequest( + amount = referendumCall.amount, + beneficiary = referendumCall.beneficiary, + asset = referendumCall.chainAsset.fullId.toAssetPayload() + ) + null -> null + } +} + +fun PreImagePreviewPayload(preimagePreview: PreimagePreview): PreImagePreviewPayload { + return when (preimagePreview) { + is PreimagePreview.Display -> PreImagePreviewPayload.Preview(preimagePreview.value) + PreimagePreview.TooLong -> PreImagePreviewPayload.TooLong + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/ReferendumFullDetailsViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/ReferendumFullDetailsViewModel.kt new file mode 100644 index 0000000..07688d7 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/ReferendumFullDetailsViewModel.kt @@ -0,0 +1,156 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.full + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher +import io.novafoundation.nova.common.mixin.copy.showCopyCallHash +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createIdentityAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.ReferendumProposer +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.identity.GovernanceIdentityProviderFactory +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.model.AddressAndAmountModel +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.model.fullChainAssetId +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ReferendumFullDetailsViewModel( + private val router: GovernanceRouter, + private val payload: ReferendumFullDetailsPayload, + private val identityProviderFactory: GovernanceIdentityProviderFactory, + private val addressIconGenerator: AddressIconGenerator, + private val governanceSharedState: GovernanceSharedState, + private val tokenUseCase: TokenUseCase, + private val externalActions: ExternalActions.Presentation, + private val copyTextLauncher: CopyTextLauncher.Presentation, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + CopyTextLauncher by copyTextLauncher { + + private val payloadFlow = flowOf { payload } + + private val referendumProposerFlow = payloadFlow.map { + payload.proposer?.let { ReferendumProposer(it.accountId, it.offChainName) } + } + + val proposerIdentityProvider = identityProviderFactory.proposerProvider(referendumProposerFlow) + val defaultIdentityProvider = identityProviderFactory.defaultProvider() + + val proposerModel = payloadFlow + .map { createProposerAddressModel(it.proposer, it.deposit) } + .withLoading() + .shareInBackground() + + val beneficiaryModel = payloadFlow + .map { createBeneficiaryAddressModel(it.referendumCall) } + .withLoading() + .shareInBackground() + + val hasPreimage = payload.preImage != null + val isPreimageTooLong = payload.preImage is PreImagePreviewPayload.TooLong + val isPreviewAvailable = payload.preImage is PreImagePreviewPayload.Preview + + val voteThreshold = payload.voteThreshold + val approveThreshold = payload.approveThreshold + val supportThreshold = payload.supportThreshold + val preImage = mapPreimage(payload.preImage) + val callHash = payload.hash?.toHexString(withPrefix = true) + + val turnoutAmount = payloadFlow + .map { payload -> payload.turnout?.let { amountFormatter.formatAmountToAmountModel(it, getToken()) } } + .shareInBackground() + + val electorateAmount = payloadFlow + .map { payload -> payload.electorate?.let { amountFormatter.formatAmountToAmountModel(it, getToken()) } } + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun openProposal() { + payload.proposer?.let { + openAddressInfo(it.accountId) + } + } + + fun openBeneficiary() { + payload.referendumCall?.let { + if (it !is ReferendumCallPayload.TreasuryRequest) return + openAddressInfo(it.beneficiary) + } + } + + private suspend fun createProposerAddressModel(referendumProposer: ReferendumProposerPayload?, deposit: Balance?): AddressAndAmountModel? { + if (referendumProposer == null) return null + + val addressModel = addressIconGenerator.createIdentityAddressModel( + getChain(), + referendumProposer.accountId, + proposerIdentityProvider + ) + + val amountModel = deposit?.let { amountFormatter.formatAmountToAmountModel(deposit, getToken()) } + + return AddressAndAmountModel(addressModel, amountModel) + } + + private suspend fun createBeneficiaryAddressModel(referendumCall: ReferendumCallPayload?): AddressAndAmountModel? { + if (referendumCall == null || referendumCall !is ReferendumCallPayload.TreasuryRequest) return null + + val addressModel = addressIconGenerator.createIdentityAddressModel( + getChain(), + referendumCall.beneficiary, + defaultIdentityProvider + ) + val token = tokenUseCase.getToken(referendumCall.asset.fullChainAssetId) + val amountModel = amountFormatter.formatAmountToAmountModel(referendumCall.amount, token) + + return AddressAndAmountModel(addressModel, amountModel) + } + + private suspend fun getChain(): Chain { + return governanceSharedState.chain() + } + + private suspend fun getToken(): Token { + return tokenUseCase.currentToken() + } + + private fun mapPreimage(preImage: PreImagePreviewPayload?): String? { + return when (preImage) { + is PreImagePreviewPayload.Preview -> preImage.preview + else -> null + } + } + + private fun openAddressInfo(accountId: ByteArray) = launch { + val chain = getChain() + val address = chain.addressOf(accountId) + + externalActions.showAddressActions(address, chain) + } + + fun copyCallHash() = launchUnit { + val callHash = callHash ?: return@launchUnit + + copyTextLauncher.showCopyCallHash(resourceManager, callHash) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/di/ReferendumFullDetailsComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/di/ReferendumFullDetailsComponent.kt new file mode 100644 index 0000000..1352cd4 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/di/ReferendumFullDetailsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumFullDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumFullDetailsFragment + +@Subcomponent( + modules = [ + ReferendumFullDetailsModule::class + ] +) +@ScreenScope +interface ReferendumFullDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ReferendumFullDetailsPayload, + ): ReferendumFullDetailsComponent + } + + fun inject(fragment: ReferendumFullDetailsFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/di/ReferendumFullDetailsModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/di/ReferendumFullDetailsModule.kt new file mode 100644 index 0000000..05c64a2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/di/ReferendumFullDetailsModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.identity.GovernanceIdentityProviderFactory +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumFullDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.ReferendumFullDetailsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ReferendumFullDetailsModule { + + @Provides + @IntoMap + @ViewModelKey(ReferendumFullDetailsViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + payload: ReferendumFullDetailsPayload, + identityProviderFactory: GovernanceIdentityProviderFactory, + addressIconGenerator: AddressIconGenerator, + governanceSharedState: GovernanceSharedState, + tokenUseCase: TokenUseCase, + externalAction: ExternalActions.Presentation, + copyTextLauncher: CopyTextLauncher.Presentation, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ): ViewModel { + return ReferendumFullDetailsViewModel( + router, + payload, + identityProviderFactory, + addressIconGenerator, + governanceSharedState, + tokenUseCase, + externalAction, + copyTextLauncher, + resourceManager, + amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ReferendumFullDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ReferendumFullDetailsViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/model/AddressAndAmountModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/model/AddressAndAmountModel.kt new file mode 100644 index 0000000..a0dde46 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/full/model/AddressAndAmountModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.full.model + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class AddressAndAmountModel( + val addressModel: AddressModel, + val amountModel: AmountModel? +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListAdapter.kt new file mode 100644 index 0000000..b7205e9 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListAdapter.kt @@ -0,0 +1,182 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list + +import android.view.Gravity +import android.view.ViewGroup +import androidx.core.view.isGone +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.presentation.masking.dataOrNull +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.common.view.shape.getRippleMask +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ItemReferendaGroupBinding +import io.novafoundation.nova.feature_governance_impl.databinding.ItemReferendumBinding +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.setReferendumTimeEstimation +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.setReferendumTrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendaGroupModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendumModel + +class ReferendaListAdapter( + private val handler: Handler, +) : GroupedListAdapter(ReferendaDiffCallback) { + + interface Handler { + + fun onReferendaClick(referendum: ReferendumModel) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return ReferendaGroupHolder(ItemReferendaGroupBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return ReferendumChildHolder(handler, ItemReferendumBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: ReferendaGroupModel) { + (holder as ReferendaGroupHolder).bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: ReferendumModel) { + (holder as ReferendumChildHolder).bind(child) + } + + override fun bindGroup(holder: GroupedListHolder, position: Int, group: ReferendaGroupModel, payloads: List) { + bindGroup(holder, group) + } + + override fun bindChild(holder: GroupedListHolder, position: Int, child: ReferendumModel, payloads: List) { + if (holder !is ReferendumChildHolder) return + + resolvePayload(holder, position, payloads) { + when (it) { + ReferendumModel::name -> holder.bindName(child) + ReferendumModel::voting -> holder.bindVoting(child) + ReferendumModel::status -> holder.bindStatus(child) + ReferendumModel::timeEstimation -> holder.bindTimeEstimation(child) + ReferendumModel::yourVote -> holder.bindYourVote(child) + ReferendumModel::track -> holder.bindTrack(child) + ReferendumModel::number -> holder.bindNumber(child) + } + } + } +} + +private object ReferendaPayloadGenerator : PayloadGenerator( + ReferendumModel::name, + ReferendumModel::voting, + ReferendumModel::status, + ReferendumModel::timeEstimation, + ReferendumModel::yourVote, + ReferendumModel::track, + ReferendumModel::number +) + +private object ReferendaDiffCallback : BaseGroupedDiffCallback(ReferendaGroupModel::class.java) { + + override fun getGroupChangePayload(oldItem: ReferendaGroupModel, newItem: ReferendaGroupModel): Any? { + return if (oldItem == newItem) null else true + } + + override fun getChildChangePayload(oldItem: ReferendumModel, newItem: ReferendumModel): Any? { + return ReferendaPayloadGenerator.diff(oldItem, newItem) + } + + override fun areGroupItemsTheSame(oldItem: ReferendaGroupModel, newItem: ReferendaGroupModel): Boolean { + return oldItem.name == newItem.name + } + + override fun areGroupContentsTheSame(oldItem: ReferendaGroupModel, newItem: ReferendaGroupModel): Boolean { + return oldItem == newItem + } + + override fun areChildItemsTheSame(oldItem: ReferendumModel, newItem: ReferendumModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areChildContentsTheSame(oldItem: ReferendumModel, newItem: ReferendumModel): Boolean { + return oldItem == newItem + } +} + +private class ReferendaGroupHolder(private val binder: ItemReferendaGroupBinding) : GroupedListHolder(binder.root) { + + fun bind(item: ReferendaGroupModel) = with(binder) { + itemReferendaGroupStatus.text = item.name + itemReferendaGroupCounter.setMaskableText(item.badge) + } +} + +private class ReferendumChildHolder( + private val eventHandler: ReferendaListAdapter.Handler, + private val binder: ItemReferendumBinding, +) : GroupedListHolder(binder.root) { + + init { + with(containerView.context) { + containerView.background = addRipple(getBlockDrawable()) + binder.itemReferendumTrack.background = addRipple( + getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 8), + mask = getRippleMask(cornerSizeDp = 12) + ) + binder.itemReferendumNumber.background = addRipple( + getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 8), + mask = getRippleMask(cornerSizeDp = 12) + ) + } + } + + fun bind(item: ReferendumModel) = with(containerView) { + bindName(item) + bindTrack(item) + bindNumber(item) + bindStatus(item) + bindTimeEstimation(item) + bindVoting(item) + bindYourVote(item) + + itemView.setOnClickListener { eventHandler.onReferendaClick(item) } + } + + fun bindName(item: ReferendumModel) = with(binder) { + itemReferendumName.text = item.name + } + + fun bindStatus(item: ReferendumModel) = with(binder) { + itemReferendumStatus.text = item.status.name + itemReferendumStatus.setTextColorRes(item.status.colorRes) + } + + fun bindTimeEstimation(item: ReferendumModel) = with(binder) { + itemReferendumTimeEstimate.setReferendumTimeEstimation(item.timeEstimation, Gravity.END) + } + + fun bindNumber(item: ReferendumModel) = with(binder) { + itemReferendumNumber.setText(item.number) + } + + fun bindTrack(item: ReferendumModel) = with(binder) { + itemReferendumTrack.setReferendumTrackModel(item.track) + } + + fun bindYourVote(item: ReferendumModel) = with(binder) { + itemReferendumYourVote.setModel(item.yourVote?.dataOrNull()) + itermReferendumDivider.isGone = item.yourVote?.dataOrNull()?.votes.isNullOrEmpty() + } + + fun bindVoting(child: ReferendumModel) = with(binder) { + itemReferendumThreshold.setThresholdModel(child.voting) + } + + override fun unbind() { + binder.itemReferendumTrack.clearIcon() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListFragment.kt new file mode 100644 index 0000000..2399673 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListFragment.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list + +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentReferendaListBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.list.BaseReferendaListFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendumModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.subscribeOnAssetChange +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.subscribeOnAssetClick +import javax.inject.Inject + +class ReferendaListFragment : BaseReferendaListFragment(), ReferendaListHeaderAdapter.Handler { + + override fun createBinding() = FragmentReferendaListBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val referendaHeaderAdapter by lazy(LazyThreadSafetyMode.NONE) { ReferendaListHeaderAdapter(imageLoader, this) } + + override fun initViews() { + binder.referendaList.itemAnimator = null + binder.referendaList.adapter = ConcatAdapter(referendaHeaderAdapter, shimmeringAdapter, placeholderAdapter, referendaListAdapter) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .referendaListFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ReferendaListViewModel) { + subscribeOnAssetClick(getString(R.string.select_token_to_vote), viewModel.assetSelectorMixin, imageLoader) + observeValidations(viewModel) + + subscribeOnAssetChange(viewModel.assetSelectorMixin) { + referendaHeaderAdapter.setAsset(it) + } + + viewModel.governanceTotalLocks.observeWhenVisible { + referendaHeaderAdapter.setLocks(it.dataOrNull) + } + + viewModel.governanceDelegated.observeWhenVisible { + referendaHeaderAdapter.setDelegations(it.dataOrNull) + } + + viewModel.tinderGovBanner.observeWhenVisible { + referendaHeaderAdapter.setTinderGovBanner(it) + } + + viewModel.referendaFilterIcon.observeWhenVisible { + referendaHeaderAdapter.setFilterIcon(it) + } + + viewModel.referendaUiFlow.observeReferendaList() + } + + override fun onReferendaClick(referendum: ReferendumModel) { + viewModel.openReferendum(referendum) + } + + override fun onClickAssetSelector() { + viewModel.assetSelectorMixin.assetSelectorClicked() + } + + override fun onClickGovernanceLocks() { + viewModel.governanceLocksClicked() + } + + override fun onClickDelegations() { + viewModel.delegationsClicked() + } + + override fun onClickReferendaSearch() { + viewModel.searchClicked() + } + + override fun onClickReferendaFilters() { + viewModel.filtersClicked() + } + + override fun onTinderGovBannerClicked() { + viewModel.openTinderGovCards() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListHeaderAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListHeaderAdapter.kt new file mode 100644 index 0000000..734f313 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListHeaderAdapter.kt @@ -0,0 +1,170 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list + +import android.view.ViewGroup +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_governance_impl.databinding.ItemReferendaHeaderBinding +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.TinderGovBannerModel +import io.novafoundation.nova.feature_governance_impl.presentation.view.GovernanceLocksModel +import io.novafoundation.nova.feature_governance_impl.presentation.view.setTextOrHide +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.AssetSelectorModel + +class ReferendaListHeaderAdapter(val imageLoader: ImageLoader, val handler: Handler) : RecyclerView.Adapter() { + + interface Handler { + fun onClickAssetSelector() + + fun onClickGovernanceLocks() + + fun onClickDelegations() + + fun onClickReferendaSearch() + + fun onClickReferendaFilters() + + fun onTinderGovBannerClicked() + } + + private var assetModel: AssetSelectorModel? = null + private var locksModel: GovernanceLocksModel? = null + private var delegationsModel: GovernanceLocksModel? = null + private var tinderGovBannerModel: TinderGovBannerModel? = null + private var filterIconRes: Int? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderHolder { + return HeaderHolder(imageLoader, ItemReferendaHeaderBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: HeaderHolder, position: Int) { + holder.bind(assetModel, locksModel, delegationsModel, tinderGovBannerModel, filterIconRes) + } + + override fun onBindViewHolder(holder: HeaderHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + } else { + payloads.filterIsInstance().forEach { + when (it) { + Payload.ASSET -> holder.bindAsset(assetModel) + Payload.LOCKS -> holder.bindLocks(locksModel) + Payload.DELEGATIONS -> holder.bindDelegations(delegationsModel) + Payload.TINDER_GOV -> holder.bindTinderGovBanner(tinderGovBannerModel) + Payload.FILTERS -> holder.bindFilters(filterIconRes) + } + } + } + } + + override fun getItemCount(): Int { + return 1 + } + + fun setAsset(assetModel: AssetSelectorModel) { + this.assetModel = assetModel + notifyItemChanged(0, Payload.ASSET) + } + + fun setLocks(locksModel: GovernanceLocksModel?) { + this.locksModel = locksModel + notifyItemChanged(0, Payload.LOCKS) + } + + fun setDelegations(delegationsModel: GovernanceLocksModel?) { + this.delegationsModel = delegationsModel + notifyItemChanged(0, Payload.DELEGATIONS) + } + + fun setTinderGovBanner(tinderGovBannerModel: TinderGovBannerModel?) { + this.tinderGovBannerModel = tinderGovBannerModel + notifyItemChanged(0, Payload.TINDER_GOV) + } + + fun setFilterIcon(filterIconRes: Int) { + this.filterIconRes = filterIconRes + notifyItemChanged(0, Payload.FILTERS) + } +} + +private enum class Payload { + ASSET, LOCKS, DELEGATIONS, TINDER_GOV, FILTERS +} + +class HeaderHolder( + private val imageLoader: ImageLoader, + private val binder: ItemReferendaHeaderBinding, + handler: ReferendaListHeaderAdapter.Handler +) : RecyclerView.ViewHolder(binder.root) { + + init { + with(binder) { + referendaAssetHeader.setOnClickListener { handler.onClickAssetSelector() } + + governanceLocksHeader.background = binder.root.context.getBlockDrawable() + + governanceLocksLocked.setOnClickListener { handler.onClickGovernanceLocks() } + governanceLocksDelegations.setOnClickListener { handler.onClickDelegations() } + + referendaHeaderSearch.setOnClickListener { handler.onClickReferendaSearch() } + referendaHeaderFilter.setOnClickListener { handler.onClickReferendaFilters() } + referendaTindergovBanner.setOnClickListener { handler.onTinderGovBannerClicked() } + } + } + + fun bind( + assetModel: AssetSelectorModel?, + locksModel: GovernanceLocksModel?, + delegationsModel: GovernanceLocksModel?, + tinderGovBannerModel: TinderGovBannerModel?, + filterIconRes: Int? + ) { + bindAsset(assetModel) + bindLocks(locksModel) + bindDelegations(delegationsModel) + bindTinderGovBanner(tinderGovBannerModel) + bindFilters(filterIconRes) + } + + fun bindAsset(assetModel: AssetSelectorModel?) { + assetModel?.let { binder.referendaAssetHeader.setState(imageLoader, assetModel) } + } + + fun bindLocks(locksModel: GovernanceLocksModel?) { + binder.governanceLocksLocked.letOrHide(locksModel) { + binder.governanceLocksLocked.setModel(it) + } + + updateLocksContainerVisibility() + } + + fun bindDelegations(model: GovernanceLocksModel?) { + binder.governanceLocksDelegations.letOrHide(model) { + binder.governanceLocksDelegations.setModel(it) + } + + updateLocksContainerVisibility() + } + + fun bindTinderGovBanner(model: TinderGovBannerModel?) { + binder.referendaTindergovBanner.letOrHide(model) { + binder.referendaTinderGovChip.setTextOrHide(it.chipText) + } + + updateLocksContainerVisibility() + } + + fun bindFilters(filterIconRes: Int?) { + filterIconRes?.let { binder.referendaHeaderFilter.setImageResource(filterIconRes) } + } + + private fun updateLocksContainerVisibility() { + val contentVisible = binder.governanceLocksHeader.children.any { it.isVisible } + + binder.governanceLocksHeader.setVisible(contentVisible) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListViewModel.kt new file mode 100644 index 0000000..bf93e00 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/ReferendaListViewModel.kt @@ -0,0 +1,335 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list + +import android.util.Log +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.masking.getUnmaskedOrElse +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.firstLoaded +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.withItemScope +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.PlaceholderModel +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.DelegatedState +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.GovernanceLocksOverview +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumGroup +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.dapp.GovernanceDAppsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.filters.ReferendaFiltersInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumType +import io.novafoundation.nova.feature_governance_api.domain.referendum.filters.ReferendumTypeFilter +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListState +import io.novafoundation.nova.feature_governance_impl.domain.summary.ReferendaSummaryInteractor +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.list.ReferendaListStateModel +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.validation.StartSwipeGovValidationPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatterFactory +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendaGroupModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendumModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.TinderGovBannerModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.toReferendumDetailsPrefilledData +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.validation.handleStartSwipeGovValidationFailure +import io.novafoundation.nova.feature_governance_impl.presentation.view.GovernanceLocksModel +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.AssetSelectorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.WithAssetSelector +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatToken +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.runtime.ext.supportTinderGov +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +private class ReferendaListFormatters( + val maskableValueFormatter: MaskableValueFormatter, + val referendaListFormatter: ReferendumFormatter +) + +class ReferendaListViewModel( + assetSelectorFactory: AssetSelectorFactory, + private val referendaListInteractor: ReferendaListInteractor, + private val referendaFiltersInteractor: ReferendaFiltersInteractor, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val selectedAssetSharedState: GovernanceSharedState, + private val resourceManager: ResourceManager, + private val updateSystem: UpdateSystem, + private val governanceRouter: GovernanceRouter, + private val referendumFormatterFactory: ReferendumFormatterFactory, + private val governanceDAppsInteractor: GovernanceDAppsInteractor, + private val referendaSummaryInteractor: ReferendaSummaryInteractor, + private val tinderGovInteractor: TinderGovInteractor, + private val selectedMetaAccountUseCase: SelectedAccountUseCase, + private val validationExecutor: ValidationExecutor, + private val maskableValueFormatterProvider: MaskableValueFormatterProvider, + private val tokenFormatter: TokenFormatter +) : BaseViewModel(), + WithAssetSelector, + Validatable by validationExecutor { + + private val maskableValueFormatterFlow = maskableValueFormatterProvider.provideFormatter() + .shareInBackground() + + private val formattersFlow = maskableValueFormatterFlow.map { + ReferendaListFormatters( + maskableValueFormatter = it, + referendaListFormatter = referendumFormatterFactory.create(it) + ) + }.shareInBackground() + + override val assetSelectorMixin = assetSelectorFactory.create( + scope = this, + amountProvider = referendaListInteractor::availableVoteAmount + ) + + private val selectedMetaAccount = selectedAccountUseCase.selectedMetaAccountFlow() + private val selectedChainAndAssetFlow = selectedAssetSharedState.selectedOption + + private val accountAndChainFlow = combineToPair(selectedMetaAccount, selectedChainAndAssetFlow) + + private val referendaFilters = referendaFiltersInteractor.observeReferendumTypeFilter() + + private val referendaListStateFlow = accountAndChainFlow + .withItemScope(parentScope = this) + .flatMapLatest { (metaAccountWithOptions, scope) -> + val (metaAccount, supportedOption) = metaAccountWithOptions + val chainAndAsset = metaAccountWithOptions.second.assetWithChain + val accountId = metaAccount.accountIdIn(chainAndAsset.chain) + + referendaListInteractor.referendaListStateFlow(metaAccount, accountId, supportedOption, scope, referendaFilters) + } + .catch { Log.e(LOG_TAG, it.message, it) } + .inBackground() + .share() + + val governanceTotalLocks = combine(referendaListStateFlow, formattersFlow) { referendaState, formatters -> + val asset = assetSelectorMixin.selectedAssetFlow.first() + referendaState.map { mapLocksOverviewToUi(formatters.maskableValueFormatter, it.locksOverview, asset) } + } + .inBackground() + .shareWhileSubscribed() + + val governanceDelegated = combine(referendaListStateFlow, formattersFlow) { referendaState, formatters -> + val asset = assetSelectorMixin.selectedAssetFlow.first() + referendaState.map { mapDelegatedToUi(formatters.maskableValueFormatter, it.delegated, asset) } + } + .inBackground() + .shareWhileSubscribed() + + private val referendaSummariesFlow = referendaListStateFlow.mapLoading { referenda -> + val referendaIds = referenda.availableToVoteReferenda.map { it.id } + referendaSummaryInteractor.getReferendaSummaries(referendaIds, viewModelScope) + }.shareInBackground() + + val tinderGovBanner = combine(referendaSummariesFlow, formattersFlow) { summaries, formatters -> + val chain = selectedAssetSharedState.chain() + mapTinderGovToUi(formatters.maskableValueFormatter, chain, summaries) + } + .inBackground() + .shareWhileSubscribed() + + val referendaFilterIcon = referendaFilters + .map { mapFilterTypeToIconRes(it) } + .inBackground() + .shareWhileSubscribed() + + val referendaUiFlow = combine(referendaListStateFlow, formattersFlow) { summaries, formatters -> + summaries.map { mapReferendaListToStateList(it, formatters) } + } + .inBackground() + .shareWhileSubscribed() + + init { + governanceDAppsInteractor.syncGovernanceDapps() + .launchIn(this) + + updateSystem.start() + .launchIn(this) + } + + fun openReferendum(referendum: ReferendumModel) { + val payload = ReferendumDetailsPayload( + referendum.id.value, + allowVoting = referendum.isOngoing, + prefilledData = referendum.toReferendumDetailsPrefilledData() + ) + governanceRouter.openReferendum(payload) + } + + fun openTinderGovCards() = launch { + val payload = StartSwipeGovValidationPayload( + chain = selectedAssetSharedState.chain(), + metaAccount = selectedMetaAccountUseCase.getSelectedMetaAccount() + ) + + validationExecutor.requireValid( + validationSystem = tinderGovInteractor.startSwipeGovValidationSystem(), + payload = payload, + validationFailureTransformerCustom = { validationFailure, _ -> + handleStartSwipeGovValidationFailure( + resourceManager, + validationFailure, + governanceRouter + ) + } + ) { + governanceRouter.openTinderGovCards() + } + } + + private fun mapLocksOverviewToUi( + maskableValueFormatter: MaskableValueFormatter, + locksOverview: GovernanceLocksOverview?, + asset: Asset + ): GovernanceLocksModel? { + if (locksOverview == null) return null + + return GovernanceLocksModel( + title = resourceManager.getString(R.string.wallet_balance_locked), + amount = maskableValueFormatter.format { tokenFormatter.formatToken(locksOverview.locked, asset) }, + showUnlockableLocks = maskableValueFormatter.format { locksOverview.hasClaimableLocks }.getUnmaskedOrElse { false } + ) + } + + private fun mapDelegatedToUi(maskableValueFormatter: MaskableValueFormatter, delegatedState: DelegatedState, asset: Asset): GovernanceLocksModel? { + return when (delegatedState) { + is DelegatedState.Delegated -> GovernanceLocksModel( + amount = maskableValueFormatter.format { tokenFormatter.formatToken(delegatedState.amount, asset) }, + title = resourceManager.getString(R.string.delegation_your_delegations), + showUnlockableLocks = false + ) + + DelegatedState.NotDelegated -> GovernanceLocksModel( + amount = null, + title = resourceManager.getString(R.string.common_add_delegation), + showUnlockableLocks = false + ) + + DelegatedState.DelegationNotSupported -> null + } + } + + private fun mapTinderGovToUi( + maskableValueFormatter: MaskableValueFormatter, + chain: Chain, + referendaSummariesLoadingState: ExtendedLoadingState> + ): TinderGovBannerModel? { + if (!chain.supportTinderGov()) return null + + val referendumSummaries = referendaSummariesLoadingState.dataOrNull ?: return null + + return TinderGovBannerModel( + if (referendumSummaries.isEmpty()) { + null + } else { + maskableValueFormatter.format { + resourceManager.getString(R.string.referenda_swipe_gov_banner_chip, referendumSummaries.size) + } + } + ) + } + + private fun mapReferendumGroupToUi(referendumGroup: ReferendumGroup, groupSize: Int, maskableValueFormatter: MaskableValueFormatter): ReferendaGroupModel { + val nameRes = when (referendumGroup) { + ReferendumGroup.ONGOING -> R.string.common_ongoing + ReferendumGroup.COMPLETED -> R.string.common_completed + } + + return ReferendaGroupModel( + name = resourceManager.getString(nameRes), + badge = maskableValueFormatter.format { groupSize.format() } + ) + } + + private fun mapFilterTypeToIconRes(it: ReferendumTypeFilter) = + if (it.selectedType == ReferendumType.ALL) { + R.drawable.ic_chip_filter + } else { + R.drawable.ic_chip_filter_indicator + } + + private suspend fun mapReferendaListToStateList( + state: ReferendaListState, + formatters: ReferendaListFormatters + ): ReferendaListStateModel { + val asset = assetSelectorMixin.selectedAssetFlow.first() + val chain = selectedAssetSharedState.chain() + + val referendaList = state.groupedReferenda.toListWithHeaders( + keyMapper = { group, referenda -> mapReferendumGroupToUi(group, referenda.size, formatters.maskableValueFormatter) }, + valueMapper = { formatters.referendaListFormatter.formatReferendumPreview(it, asset.token, chain) } + ) + + val placeholderModel = mapReferendaListPlaceholder(referendaList) + + return ReferendaListStateModel(placeholderModel, referendaList) + } + + private suspend fun mapReferendaListPlaceholder(referendaList: List): PlaceholderModel? { + val selectedReferendaType = referendaFilters.first().selectedType + + return if (referendaList.isEmpty()) { + if (selectedReferendaType == ReferendumType.ALL) { + PlaceholderModel( + resourceManager.getString(R.string.referenda_list_placeholder), + R.drawable.ic_placeholder + ) + } else { + PlaceholderModel( + resourceManager.getString(R.string.referenda_list_filter_placeholder), + R.drawable.ic_planet_outline + ) + } + } else { + null + } + } + + fun governanceLocksClicked() { + governanceRouter.openGovernanceLocksOverview() + } + + fun delegationsClicked() { + launch { + val state = referendaListStateFlow.firstLoaded() + + if (state.delegated is DelegatedState.Delegated) { + governanceRouter.openYourDelegations() + } else { + governanceRouter.openAddDelegation() + } + } + } + + fun searchClicked() { + governanceRouter.openReferendaSearch() + } + + fun filtersClicked() { + governanceRouter.openReferendaFilters() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/di/ReferendaListComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/di/ReferendaListComponent.kt new file mode 100644 index 0000000..fc15517 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/di/ReferendaListComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.ReferendaListFragment + +@Subcomponent( + modules = [ + ReferendaListModule::class + ] +) +@ScreenScope +interface ReferendaListComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): ReferendaListComponent + } + + fun inject(fragment: ReferendaListFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/di/ReferendaListModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/di/ReferendaListModule.kt new file mode 100644 index 0000000..7cec99b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/di/ReferendaListModule.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListInteractor +import io.novafoundation.nova.feature_governance_impl.domain.summary.ReferendaSummaryInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.dapp.GovernanceDAppsInteractor +import io.novafoundation.nova.feature_governance_impl.domain.filters.ReferendaFiltersInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatterFactory +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.ReferendaListViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.AssetSelectorFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider + +@Module(includes = [ViewModelModule::class]) +class ReferendaListModule { + + @Provides + @IntoMap + @ViewModelKey(ReferendaListViewModel::class) + fun provideViewModel( + assetSelectorFactory: AssetSelectorFactory, + referendaListInteractor: ReferendaListInteractor, + referendaFiltersInteractor: ReferendaFiltersInteractor, + selectedAccountUseCase: SelectedAccountUseCase, + selectedAssetSharedState: GovernanceSharedState, + resourceManager: ResourceManager, + updateSystem: UpdateSystem, + governanceRouter: GovernanceRouter, + referendumFormatterFactory: ReferendumFormatterFactory, + governanceDAppsInteractor: GovernanceDAppsInteractor, + summaryInteractor: ReferendaSummaryInteractor, + tinderGovInteractor: TinderGovInteractor, + selectedMetaAccountUseCase: SelectedAccountUseCase, + validationExecutor: ValidationExecutor, + maskableValueFormatterProvider: MaskableValueFormatterProvider, + tokenFormatter: TokenFormatter + ): ViewModel { + return ReferendaListViewModel( + assetSelectorFactory = assetSelectorFactory, + referendaListInteractor = referendaListInteractor, + referendaFiltersInteractor = referendaFiltersInteractor, + selectedAccountUseCase = selectedAccountUseCase, + selectedAssetSharedState = selectedAssetSharedState, + resourceManager = resourceManager, + updateSystem = updateSystem, + governanceRouter = governanceRouter, + referendumFormatterFactory = referendumFormatterFactory, + governanceDAppsInteractor = governanceDAppsInteractor, + referendaSummaryInteractor = summaryInteractor, + tinderGovInteractor = tinderGovInteractor, + selectedMetaAccountUseCase = selectedMetaAccountUseCase, + validationExecutor = validationExecutor, + maskableValueFormatterProvider = maskableValueFormatterProvider, + tokenFormatter = tokenFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ReferendaListViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ReferendaListViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/model/ReferendumItemModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/model/ReferendumItemModel.kt new file mode 100644 index 0000000..d1381b0 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/model/ReferendumItemModel.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model + +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteDirectionModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumStatusModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTimeEstimation +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumVotingModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.toDetailsPayload + +data class ReferendaGroupModel(val name: String, val badge: MaskableModel) + +data class ReferendumModel( + val id: ReferendumId, + val status: ReferendumStatusModel, + val name: String, + val timeEstimation: ReferendumTimeEstimation?, + val track: ReferendumTrackModel?, + val number: String, + val voting: ReferendumVotingModel?, + val yourVote: MaskableModel?, + val isOngoing: Boolean +) + +data class YourMultiVotePreviewModel(val votes: List) + +data class YourVotePreviewModel(val voteDirection: VoteDirectionModel, val details: String) + +fun ReferendumModel.toReferendumDetailsPrefilledData(): ReferendumDetailsPayload.PrefilledData { + return ReferendumDetailsPayload.PrefilledData( + referendumNumber = number, + title = name, + voting = voting?.toDetailsPayload(), + status = ReferendumDetailsPayload.StatusData( + status.name, + status.colorRes + ) + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/model/TinderGovBannerModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/model/TinderGovBannerModel.kt new file mode 100644 index 0000000..21718b3 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/model/TinderGovBannerModel.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model + +import io.novafoundation.nova.common.presentation.masking.MaskableModel + +class TinderGovBannerModel( + val chipText: MaskableModel? +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/timeline/TimelineItem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/timeline/TimelineItem.kt new file mode 100644 index 0000000..c638c28 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/timeline/TimelineItem.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.timeline + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.stopTimer +import io.novafoundation.nova.feature_governance_impl.databinding.ItemTimelineDefaultItemBinding +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.setReferendumTimeEstimation + +class TimelineItem @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) { + + private val binder = ItemTimelineDefaultItemBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + } + + fun getDrawPointOffset(): Float { + return binder.itemTimelineTitle.y + binder.itemTimelineTitle.measuredHeight / 2f + } + + fun setTimelineState(timelineState: TimelineLayout.TimelineState) { + binder.itemTimelineSubtitle.stopTimer() + + when (timelineState) { + is TimelineLayout.TimelineState.Historical -> { + binder.itemTimelineTitle.text = timelineState.title + binder.itemTimelineSubtitle.text = timelineState.subtitle + } + is TimelineLayout.TimelineState.Current -> { + binder.itemTimelineTitle.text = timelineState.title + binder.itemTimelineSubtitle.setReferendumTimeEstimation(timelineState.subtitle, Gravity.START) + } + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/timeline/TimelineLayout.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/timeline/TimelineLayout.kt new file mode 100644 index 0000000..9e7921a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/timeline/TimelineLayout.kt @@ -0,0 +1,169 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.timeline + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTimeEstimation +import kotlin.math.roundToInt + +class TimelineLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) { + + private val timelinePointPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val timelinePathPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val timelineUnfinishedPathPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val dashFilledInterval: Float = 2.dpF(context) + private val dashEmptyInterval: Float = 3.dpF(context) + + private var timeline: Timeline = createDefaultTimeline() + + private var statePointSize: Float = 0f + private var pointToStrokeOffset: Float = 0f + private var halfStatePointSize: Float = 0f + private var itemStartPadding: Int = 0 + + private var timelineStatePoints: List = listOf() + private var timelineStatePath: Path = Path() + private val timelineFinishedPath: Path = Path() + + init { + val a = context.obtainStyledAttributes( + attrs, + R.styleable.TimelineLayout, + defStyleAttr, + 0 + ) + + val timelineDefaultColor = a.getColor(R.styleable.TimelineLayout_timelineDefaultColor, Color.GRAY) + val timelineUnfinishedColor = a.getColor(R.styleable.TimelineLayout_timelineUnfinishedColor, Color.GRAY) + val strokeWidth = a.getDimension(R.styleable.TimelineLayout_timelineStrokeWidth, 1.dpF(context)) + val itemPaddingStartToPoint = a.getDimension(R.styleable.TimelineLayout_timelineItemStartPadding, 1.dpF(context)).roundToInt() + pointToStrokeOffset = a.getDimension(R.styleable.TimelineLayout_timelinePointToStrokeOffset, 1.dpF(context)) + statePointSize = a.getDimension(R.styleable.TimelineLayout_timelineItemStatePointSize, 4.dpF(context)) + + a.recycle() + + itemStartPadding = (itemPaddingStartToPoint + statePointSize).roundToInt() + halfStatePointSize = statePointSize / 2f + + with(timelinePointPaint) { + color = timelineDefaultColor + style = Paint.Style.FILL + } + with(timelinePathPaint) { + color = timelineDefaultColor + this.strokeWidth = strokeWidth + style = Paint.Style.STROKE + } + with(timelineUnfinishedPathPaint) { + color = timelineUnfinishedColor + this.strokeWidth = strokeWidth + style = Paint.Style.STROKE + val dashIntervals = floatArrayOf(dashFilledInterval, dashEmptyInterval) + setPathEffect(DashPathEffect(dashIntervals, 0f)) + } + + orientation = VERTICAL + + setWillNotDraw(false) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + super.onLayout(changed, l, t, r, b) + + val halfStatePointSize = statePointSize / 2 + val centerOfStatePointX = paddingStart + halfStatePointSize + for (i in 0 until childCount) { + val childView = getChildAt(i) + val timelineItem = childView as TimelineItem + val centerOfStatePointY = childView.y + timelineItem.getDrawPointOffset() + timelineStatePoints[i].set(centerOfStatePointX, centerOfStatePointY) + } + + val totalOffsetFromPointCenter = halfStatePointSize + pointToStrokeOffset + + timelineStatePath.reset() + if (timelineStatePoints.size > 1) { + val pathsCount = timeline.states.size - 1 + for (i in 0 until pathsCount) { + val pointStart = timelineStatePoints[i] + val pointEnd = timelineStatePoints[i + 1] + timelineStatePath.moveTo(pointStart.x, pointStart.y + totalOffsetFromPointCenter) + timelineStatePath.lineTo(pointEnd.x, pointEnd.y - totalOffsetFromPointCenter) + } + } + + timelineFinishedPath.reset() + if (!timeline.finished && timelineStatePoints.isNotEmpty()) { + val lastPoint = timelineStatePoints.last() + timelineFinishedPath.moveTo(lastPoint.x, lastPoint.y + totalOffsetFromPointCenter) + timelineFinishedPath.lineTo(lastPoint.x, measuredHeight.toFloat()) + } + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + timelineStatePoints.forEach { + canvas.drawCircle(it.x, it.y, halfStatePointSize, timelinePointPaint) + } + canvas.drawPath(timelineStatePath, timelinePathPaint) + canvas.drawPath(timelineFinishedPath, timelineUnfinishedPathPaint) + } + + fun setTimeline(timeline: Timeline) { + this.timeline = timeline + removeAllViewsInLayout() + timeline.states.forEach { timelineState -> + val timelineItem = createTimelineDefaultItem(timelineState) + timelineItem.updatePadding(start = itemStartPadding) + addView(timelineItem) + } + timelineStatePoints = List(timeline.states.size) { PointF() } + + requestLayout() + } + + private fun createTimelineDefaultItem(timelineState: TimelineState): TimelineItem { + val timeLineItem = TimelineItem(context) + timeLineItem.setTimelineState(timelineState) + return timeLineItem + } + + private fun createDefaultTimeline(): Timeline { + return Timeline( + listOf(), + true + ) + } + + class Timeline( + val states: List, + val finished: Boolean + ) + + sealed class TimelineState { + + class Historical( + val title: String, + val subtitle: String?, + ) : TimelineState() + + class Current( + val title: String, + val subtitle: ReferendumTimeEstimation + ) : TimelineState() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/validation/StartSwipeGovValidationUi.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/validation/StartSwipeGovValidationUi.kt new file mode 100644 index 0000000..71309d0 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/validation/StartSwipeGovValidationUi.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.validation + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_account_api.domain.validation.handleChainAccountNotFound +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.validation.StartSwipeGovValidationFailure +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter + +fun handleStartSwipeGovValidationFailure( + resourceManager: ResourceManager, + validationStatus: ValidationStatus.NotValid, + router: GovernanceRouter +): TransformedFailure { + return when (val reason = validationStatus.reason) { + is StartSwipeGovValidationFailure.NoChainAccountFound -> handleChainAccountNotFound( + failure = reason, + addAccountDescriptionRes = R.string.common_network_not_supported, + resourceManager = resourceManager, + goToWalletDetails = { router.openWalletDetails(reason.account.id) } + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/view/YourMultiVotePreviewView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/view/YourMultiVotePreviewView.kt new file mode 100644 index 0000000..1fd5304 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/list/view/YourMultiVotePreviewView.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_governance_impl.databinding.ViewYourVotePreviewBinding +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.YourMultiVotePreviewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.YourVotePreviewModel + +import kotlin.math.max + +private const val MAX_SUPPORTED_VOTE_TYPES = 3 + +class YourMultiVotePreviewView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private val voteTypeViews = Array(MAX_SUPPORTED_VOTE_TYPES) { null } + + init { + orientation = VERTICAL + } + + fun setModel(model: YourMultiVotePreviewModel?) { + val votes = model?.votes.orEmpty() + + val itemsToModify = max(voteTypeViews.size, votes.size) + + for (index in 0 until itemsToModify) { + if (index < votes.size) { // we want to show content + val view = getOrCreateView(index) ?: return + + view.makeVisible() + view.setModel(votes[index]) + } else { // we want to hide view if present + getViewOrNull(index)?.makeGone() + } + } + } + + private fun getOrCreateView(index: Int): YourVotePreviewView? { + if (index >= MAX_SUPPORTED_VOTE_TYPES) return null + + return voteTypeViews.getOrNull(index) ?: createNewView().also { + voteTypeViews[index] = it + addView(it) + } + } + + private fun getViewOrNull(index: Int): YourVotePreviewView? { + return voteTypeViews.getOrNull(index) + } + + private fun createNewView(): YourVotePreviewView { + return YourVotePreviewView(context) + } +} + +class YourVotePreviewView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewYourVotePreviewBinding.inflate(inflater(), this) + + init { + setPadding(0, 12.dp, 0, 0) + } + + fun setModel(maybeModel: YourVotePreviewModel?) = letOrHide(maybeModel) { model -> + binder.itemReferendumYourVoteType.text = model.voteDirection.text + binder.itemReferendumYourVoteType.setTextColorRes(model.voteDirection.textColor) + binder.itemReferendumYourVoteDetails.text = model.details + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/ReferendaSearchFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/ReferendaSearchFragment.kt new file mode 100644 index 0000000..79fe73f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/ReferendaSearchFragment.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.search + +import android.view.View +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ConcatAdapter + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard +import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard +import io.novafoundation.nova.common.utils.submitListPreservingViewPoint +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentReferendaSearchBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.list.BaseReferendaListFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendumModel + +class ReferendaSearchFragment : BaseReferendaListFragment() { + + override fun createBinding() = FragmentReferendaSearchBinding.inflate(layoutInflater) + + override val shimmeringAdapter by lazy(LazyThreadSafetyMode.NONE) { CustomPlaceholderAdapter(R.layout.item_referenda_shimmering_no_groups) } + + override fun applyInsets(rootView: View) { + binder.searchReferendaToolbar.applyStatusBarInsets() + binder.root.applyNavigationBarInsets(consume = false, imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.searchedReferendaList.itemAnimator = null + binder.searchedReferendaList.adapter = ConcatAdapter(shimmeringAdapter, placeholderAdapter, referendaListAdapter) + + binder.searchReferendaToolbar.cancel.setOnClickListener { + viewModel.cancelClicked() + view?.hideSoftKeyboard() + } + + binder.searchReferendaToolbar.searchInput.requestFocus() + binder.searchReferendaToolbar.searchInput.content.showSoftKeyboard() + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .referendaSearchFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ReferendaSearchViewModel) { + binder.searchReferendaToolbar.searchInput.content.bindTo(viewModel.queryFlow, lifecycleScope) + + viewModel.referendaUiFlow.observeReferendaList() + } + + override fun submitReferenda(data: List) { + referendaListAdapter.submitListPreservingViewPoint( + data = data, + into = binder.searchedReferendaList, + extraDiffCompletedCallback = { binder.searchedReferendaList.invalidateItemDecorations() } + ) + } + + override fun onReferendaClick(referendum: ReferendumModel) { + viewModel.openReferendum(referendum) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/ReferendaSearchViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/ReferendaSearchViewModel.kt new file mode 100644 index 0000000..ad68bd9 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/ReferendaSearchViewModel.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.search + +import android.util.Log +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.view.PlaceholderModel +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.list.ReferendaListStateModel +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.ReferendumDetailsPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.ReferendumModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.list.model.toReferendumDetailsPrefilledData +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest + +class ReferendaSearchViewModel( + private val referendaListInteractor: ReferendaListInteractor, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val selectedAssetSharedState: GovernanceSharedState, + private val governanceRouter: GovernanceRouter, + private val referendumFormatter: ReferendumFormatter, + private val resourceManager: ResourceManager, + private val tokenUseCase: TokenUseCase, +) : BaseViewModel() { + + val queryFlow = MutableStateFlow("") + + private val tokenFlow = tokenUseCase.currentTokenFlow() + .shareInBackground() + + private val accountAndChainFlow = combineToPair(selectedAccountUseCase.selectedMetaAccountFlow(), selectedAssetSharedState.selectedOption) + + private val referendaSearchFlow = accountAndChainFlow.flatMapLatest { (metaAccount, supportedOption) -> + val chainAndAsset = supportedOption.assetWithChain + val accountId = metaAccount.accountIdIn(chainAndAsset.chain) + + referendaListInteractor.searchReferendaListStateFlow(metaAccount, queryFlow, accountId, supportedOption, this) + } + .catch { Log.e(LOG_TAG, it.message, it) } + .inBackground() + .shareWhileSubscribed() + + val referendaUiFlow = referendaSearchFlow.mapLoading { referenda -> + mapReferendaListToStateList(referenda) + } + .inBackground() + .shareWhileSubscribed() + + private suspend fun mapReferendaListToStateList(referenda: List): ReferendaListStateModel { + val token = tokenFlow.first() + val chain = selectedAssetSharedState.chain() + + val placeholder = if (referenda.isEmpty()) { + PlaceholderModel( + resourceManager.getString(R.string.referenda_search_placeholder), + R.drawable.ic_placeholder + ) + } else { + null + } + + val referendaUi = referenda.map { referendumFormatter.formatReferendumPreview(it, token, chain) } + + return ReferendaListStateModel(placeholder, referendaUi) + } + + fun openReferendum(referendum: ReferendumModel) { + val payload = ReferendumDetailsPayload( + referendum.id.value, + allowVoting = referendum.isOngoing, + prefilledData = referendum.toReferendumDetailsPrefilledData() + ) + governanceRouter.openReferendum(payload) + } + + fun cancelClicked() { + governanceRouter.back() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/di/ReferendaSearchComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/di/ReferendaSearchComponent.kt new file mode 100644 index 0000000..e24d5ad --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/di/ReferendaSearchComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.search.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.search.ReferendaSearchFragment + +@Subcomponent( + modules = [ + ReferendaSearchModule::class + ] +) +@ScreenScope +interface ReferendaSearchComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): ReferendaSearchComponent + } + + fun inject(fragment: ReferendaSearchFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/di/ReferendaSearchModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/di/ReferendaSearchModule.kt new file mode 100644 index 0000000..a94eeb6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/search/di/ReferendaSearchModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.search.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendaListInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.search.ReferendaSearchViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase + +@Module(includes = [ViewModelModule::class]) +class ReferendaSearchModule { + + @Provides + @IntoMap + @ViewModelKey(ReferendaSearchViewModel::class) + fun provideViewModel( + tokenUseCase: TokenUseCase, + referendaListInteractor: ReferendaListInteractor, + selectedAccountUseCase: SelectedAccountUseCase, + selectedAssetSharedState: GovernanceSharedState, + governanceRouter: GovernanceRouter, + referendumFormatter: ReferendumFormatter, + resourceManager: ResourceManager + ): ViewModel { + return ReferendaSearchViewModel( + tokenUseCase = tokenUseCase, + referendaListInteractor = referendaListInteractor, + selectedAccountUseCase = selectedAccountUseCase, + selectedAssetSharedState = selectedAssetSharedState, + governanceRouter = governanceRouter, + referendumFormatter = referendumFormatter, + resourceManager = resourceManager + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ReferendaSearchViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ReferendaSearchViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/common/LocksChangeFormatter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/common/LocksChangeFormatter.kt new file mode 100644 index 0000000..ce2b636 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/common/LocksChangeFormatter.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_api.domain.locks.reusable.LocksChange +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.Change +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.model.AmountChangeModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.model.LocksChangeModel +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import kotlin.time.Duration + +interface LocksChangeFormatter { + + suspend fun mapLocksChangeToUi( + locksChange: LocksChange, + asset: Asset, + displayPeriodFromWhenSame: Boolean = true, + ): LocksChangeModel + + suspend fun mapAmountChangeToUi( + lockedChange: Change, + asset: Asset + ): AmountChangeModel +} + +class RealLocksChangeFormatter( + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : LocksChangeFormatter { + + override suspend fun mapLocksChangeToUi( + locksChange: LocksChange, + asset: Asset, + displayPeriodFromWhenSame: Boolean, + ): LocksChangeModel { + return LocksChangeModel( + amountChange = mapAmountChangeToUi(locksChange.lockedAmountChange, asset), + periodChange = mapPeriodChangeToUi(locksChange.lockedPeriodChange, displayPeriodFromWhenSame), + transferableChange = mapAmountChangeToUi(locksChange.transferableChange, asset) + ) + } + + override suspend fun mapAmountChangeToUi( + lockedChange: Change, + asset: Asset + ): AmountChangeModel { + val fromFormatted = amountFormatter.formatAmountToAmountModel(lockedChange.previousValue, asset, AmountConfig(includeAssetTicker = false)).token + val toFormatted = amountFormatter.formatAmountToAmountModel(lockedChange.newValue, asset).token + + return when (lockedChange) { + is Change.Changed -> AmountChangeModel( + from = fromFormatted, + to = toFormatted, + difference = amountFormatter.formatAmountToAmountModel(lockedChange.absoluteDifference, asset).token, + positive = lockedChange.positive + ) + is Change.Same -> AmountChangeModel( + from = fromFormatted, + to = toFormatted, + difference = null, + positive = null + ) + } + } + + private fun mapPeriodChangeToUi(periodChange: Change, displayPeriodFromWhenSame: Boolean): AmountChangeModel { + val from = resourceManager.formatDuration(periodChange.previousValue, estimated = false) + val to = resourceManager.formatDuration(periodChange.newValue, estimated = false) + + return when (periodChange) { + is Change.Changed -> { + val difference = resourceManager.formatDuration(periodChange.absoluteDifference, estimated = false) + + AmountChangeModel( + from = from, + to = to, + difference = resourceManager.getString(R.string.common_maximum_format, difference), + positive = periodChange.positive + ) + } + is Change.Same -> AmountChangeModel( + from = from.takeIf { displayPeriodFromWhenSame }, + to = to, + difference = null, + positive = null + ) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/ConfirmReferendumVoteFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/ConfirmReferendumVoteFragment.kt new file mode 100644 index 0000000..7cc8ace --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/ConfirmReferendumVoteFragment.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm + +import android.os.Bundle +import androidx.core.os.bundleOf +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.common.confirmVote.ConfirmVoteFragment + +class ConfirmReferendumVoteFragment : ConfirmVoteFragment() { + + companion object { + + private const val PAYLOAD = "ConfirmReferendumVoteFragment.Payload" + + fun getBundle(payload: ConfirmVoteReferendumPayload): Bundle = bundleOf(PAYLOAD to payload) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .confirmReferendumVoteFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/ConfirmReferendumVoteViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/ConfirmReferendumVoteViewModel.kt new file mode 100644 index 0000000..165283f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/ConfirmReferendumVoteViewModel.kt @@ -0,0 +1,176 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.constructAccountVote +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumVote +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.estimateLocksAfterVoting +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendaValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.handleVoteReferendumValidationFailure +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.confirmVote.ConfirmVoteViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.hints.ReferendumVoteHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.runtime.state.chainAndAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ConfirmReferendumVoteViewModel( + private val router: GovernanceRouter, + private val feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val externalActions: ExternalActions.Presentation, + private val governanceSharedState: GovernanceSharedState, + private val hintsMixinFactory: ReferendumVoteHintsMixinFactory, + private val walletUiUseCase: WalletUiUseCase, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val addressIconGenerator: AddressIconGenerator, + private val interactor: VoteReferendumInteractor, + private val assetUseCase: AssetUseCase, + private val payload: ConfirmVoteReferendumPayload, + private val validationSystem: VoteReferendumValidationSystem, + private val validationExecutor: ValidationExecutor, + private val resourceManager: ResourceManager, + private val referendumFormatter: ReferendumFormatter, + private val locksChangeFormatter: LocksChangeFormatter, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter +) : ConfirmVoteViewModel( + router, + feeLoaderMixinFactory, + externalActions, + governanceSharedState, + hintsMixinFactory, + walletUiUseCase, + selectedAccountUseCase, + addressIconGenerator, + assetUseCase, + validationExecutor +), + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + override val titleFlow: Flow = flowOf { + val formattedNumber = referendumFormatter.formatId(payload.referendumId) + resourceManager.getString(R.string.referendum_vote_setup_title, formattedNumber) + }.shareInBackground() + + override val amountModelFlow: Flow = assetFlow.map { + amountFormatter.formatAmountToAmountModel(payload.vote.amount, it) + }.shareInBackground() + + private val accountVoteFlow = assetFlow.map(::constructAccountVote) + .shareInBackground() + + private val voteAssistantFlow = interactor.voteAssistantFlow(payload.referendumId, viewModelScope) + + override val accountVoteUi = accountVoteFlow.map { + val referendumVote = ReferendumVote.UserDirect(it) + val (chain, chainAsset) = governanceSharedState.chainAndAsset() + + referendumFormatter.formatUserVote(referendumVote, chain, chainAsset) + }.shareInBackground() + + private val locksChangeFlow = voteAssistantFlow.map { voteAssistant -> + val asset = assetFlow.first() + val accountVote = constructAccountVote(asset) + + voteAssistant.estimateLocksAfterVoting(payload.referendumId, accountVote, asset) + } + + init { + setFee() + } + + override val locksChangeUiFlow = locksChangeFlow.map { + locksChangeFormatter.mapLocksChangeToUi(it, assetFlow.first()) + } + .shareInBackground() + + override fun confirmClicked() { + launch { + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = getValidationPayload(), + validationFailureTransformerCustom = { status, actions -> + handleVoteReferendumValidationFailure(status.reason, actions, resourceManager) + }, + progressConsumer = _showNextProgress.progressConsumer(), + ) { + performVote() + } + } + } + + private fun performVote() = launch { + val accountVote = accountVoteFlow.first() + + val result = withContext(Dispatchers.Default) { + interactor.voteReferendum(payload.referendumId, accountVote) + } + + result.onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.backToReferendumDetails() } + } + .onFailure(::showError) + + _showNextProgress.value = false + } + + private fun constructAccountVote(asset: Asset): AccountVote { + val planks = asset.token.planksFromAmount(payload.vote.amount) + + return AccountVote.constructAccountVote(planks, payload.vote.conviction, payload.vote.voteType) + } + + private fun setFee() = launch { + originFeeMixin.setFee(mapFeeFromParcel(payload.fee)) + } + + private suspend fun getValidationPayload(): VoteReferendaValidationPayload { + val voteAssistant = voteAssistantFlow.first() + val asset = assetFlow.first() + val maxAmount = interactor.maxAvailableForVote(asset) + val maxPlanks = asset.token.amountFromPlanks(maxAmount) + + return VoteReferendaValidationPayload( + onChainReferenda = voteAssistant.onChainReferenda, + asset = asset, + trackVoting = voteAssistant.trackVoting, + amount = payload.vote.amount, + conviction = payload.vote.conviction, + voteType = payload.vote.voteType, + fee = originFeeMixin.awaitFee(), + maxAvailableAmount = maxPlanks + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/ConfirmVoteReferendumPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/ConfirmVoteReferendumPayload.kt new file mode 100644 index 0000000..e59d057 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/ConfirmVoteReferendumPayload.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal +import java.math.BigInteger + +@Parcelize +class ConfirmVoteReferendumPayload( + val _referendumId: BigInteger, + val fee: FeeParcelModel, + val vote: AccountVoteParcelModel +) : Parcelable + +val ConfirmVoteReferendumPayload.referendumId: ReferendumId + get() = ReferendumId(_referendumId) + +@Parcelize +class AccountVoteParcelModel( + val amount: BigDecimal, + val conviction: Conviction, + val voteType: VoteType +) : Parcelable diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/di/ConfirmReferendumVoteComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/di/ConfirmReferendumVoteComponent.kt new file mode 100644 index 0000000..7cc44d6 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/di/ConfirmReferendumVoteComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.ConfirmReferendumVoteFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.ConfirmVoteReferendumPayload + +@Subcomponent( + modules = [ + ConfirmReferendumVoteModule::class + ] +) +@ScreenScope +interface ConfirmReferendumVoteComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmVoteReferendumPayload, + ): ConfirmReferendumVoteComponent + } + + fun inject(fragment: ConfirmReferendumVoteFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/di/ConfirmReferendumVoteModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/di/ConfirmReferendumVoteModule.kt new file mode 100644 index 0000000..23efd40 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/confirm/di/ConfirmReferendumVoteModule.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.ConfirmReferendumVoteViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.ConfirmVoteReferendumPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.hints.ReferendumVoteHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmReferendumVoteModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmReferendumVoteViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + externalActions: ExternalActions.Presentation, + governanceSharedState: GovernanceSharedState, + hintsMixinFactory: ReferendumVoteHintsMixinFactory, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + addressIconGenerator: AddressIconGenerator, + interactor: VoteReferendumInteractor, + assetUseCase: AssetUseCase, + payload: ConfirmVoteReferendumPayload, + validationSystem: VoteReferendumValidationSystem, + validationExecutor: ValidationExecutor, + resourceManager: ResourceManager, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + referendumFormatter: ReferendumFormatter, + locksChangeFormatter: LocksChangeFormatter, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmReferendumVoteViewModel( + router = router, + feeLoaderMixinFactory = feeLoaderMixinFactory, + externalActions = externalActions, + governanceSharedState = governanceSharedState, + hintsMixinFactory = hintsMixinFactory, + walletUiUseCase = walletUiUseCase, + selectedAccountUseCase = selectedAccountUseCase, + addressIconGenerator = addressIconGenerator, + interactor = interactor, + assetUseCase = assetUseCase, + payload = payload, + validationSystem = validationSystem, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + referendumFormatter = referendumFormatter, + locksChangeFormatter = locksChangeFormatter, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ConfirmReferendumVoteViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmReferendumVoteViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/hints/ReferendumVoteHints.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/hints/ReferendumVoteHints.kt new file mode 100644 index 0000000..dbbcb9a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/hints/ReferendumVoteHints.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.hints + +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.feature_governance_impl.R +import kotlinx.coroutines.CoroutineScope + +class ReferendumVoteHintsMixinFactory( + private val resourcesHintsMixinFactory: ResourcesHintsMixinFactory +) { + + fun create(scope: CoroutineScope): HintsMixin { + return resourcesHintsMixinFactory.create( + coroutineScope = scope, + hintsRes = listOf(R.string.referendum_vote_unlock_hint) + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/SetupVoteFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/SetupVoteFragment.kt new file mode 100644 index 0000000..ec10465 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/SetupVoteFragment.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentSetupVoteBinding +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.AmountChipModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.setChips +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.view.setAmountChangeModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser + +abstract class SetupVoteFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "SetupVoteFragment.Payload" + + fun getBundle(payload: SetupVotePayload): Bundle = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentSetupVoteBinding.inflate(layoutInflater) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val view = super.onCreateView(inflater, container, savedInstanceState) + binder.setupVoteControlFrame.addView(getControlView(inflater, view)) + return view + } + + override fun initViews() { + binder.setupReferendumVoteToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + } + + override fun subscribe(viewModel: T) { + setupAmountChooser(viewModel.amountChooserMixin, binder.setupReferendumVoteAmount) + observeValidations(viewModel) + + binder.setupReferendumVoteVotePower.votePowerSeekbar.setValues(viewModel.convictionValues) + binder.setupReferendumVoteVotePower.votePowerSeekbar.bindTo(viewModel.selectedConvictionIndex, viewLifecycleOwner.lifecycleScope) + + viewModel.title.observe(binder.setupReferendumVoteTitle::setText) + + viewModel.locksChangeUiFlow.observe { + binder.setupReferendumVoteLockedAmountChanges.setAmountChangeModel(it.amountChange) + binder.setupReferendumVoteLockedPeriodChanges.setAmountChangeModel(it.periodChange) + } + + viewModel.amountChips.observe(::setChips) + + viewModel.votesFormattedFlow.observe { + binder.setupReferendumVoteVotePower.votePowerVotesText.text = it + } + + viewModel.abstainVotingSupported.observe { + binder.setupReferendumVoteAlertView.isVisible = it + } + } + + private fun setChips(newChips: List) { + binder.setupReferendumVoteAmountChipsContainer.setChips( + newChips = newChips, + onClicked = viewModel::amountChipClicked, + scrollingParent = binder.setupReferendumVoteAmountChipsScroll + ) + } + + protected fun getPayload(): SetupVotePayload { + return argument(PAYLOAD) + } + + abstract fun getControlView(inflater: LayoutInflater, parent: View): View +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/SetupVotePayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/SetupVotePayload.kt new file mode 100644 index 0000000..ec3d983 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/SetupVotePayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common + +import android.os.Parcelable +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +@Parcelize +class SetupVotePayload( + val _referendumId: BigInteger +) : Parcelable + +val SetupVotePayload.referendumId: ReferendumId + get() = ReferendumId(_referendumId) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/SetupVoteViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/SetupVoteViewModel.kt new file mode 100644 index 0000000..43e91a7 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/SetupVoteViewModel.kt @@ -0,0 +1,206 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.ProgressConsumer +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.constructAccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.votesFor +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.estimateLocksAfterVoting +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendaValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.handleVoteReferendumValidationFailure +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.conviction.ConvictionValuesProvider +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.AmountChipModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.LocksFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.actualAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setAmountInput +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.BigInteger + +abstract class SetupVoteViewModel( + private val assetUseCase: AssetUseCase, + private val amountChooserMixinFactory: AmountChooserMixin.Factory, + private val interactor: VoteReferendumInteractor, + private val payload: SetupVotePayload, + private val resourceManager: ResourceManager, + private val router: GovernanceRouter, + private val validationSystem: VoteReferendumValidationSystem, + private val validationExecutor: ValidationExecutor, + private val locksChangeFormatter: LocksChangeFormatter, + private val convictionValuesProvider: ConvictionValuesProvider, + private val locksFormatter: LocksFormatter, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, +) : BaseViewModel(), + Validatable by validationExecutor { + + abstract val title: Flow + + private val assetWithOption = assetUseCase.currentAssetAndOptionFlow() + .shareInBackground() + + protected val selectedAsset = assetWithOption.map { it.asset } + .shareInBackground() + + private val selectedChainAsset = selectedAsset.map { it.token.configuration } + .shareInBackground() + + private val voteAssistantFlow = interactor.voteAssistantFlow(payload.referendumId, viewModelScope) + + private val originFeeMixin = feeLoaderMixinFactory.createDefault(this, selectedChainAsset) + + protected val validatingVoteType = MutableStateFlow(null) + + private val maxActionProvider = maxActionProviderFactory.create( + viewModelScope = viewModelScope, + assetInFlow = selectedAsset, + feeLoaderMixin = originFeeMixin, + balance = interactor::maxAvailableForVote, + ) + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = selectedAsset, + maxActionProvider = maxActionProvider + ) + + val convictionValues = convictionValuesProvider.convictionValues() + + val selectedConvictionIndex = MutableStateFlow(0) + + private val selectedConvictionFlow = selectedConvictionIndex.mapNotNull(convictionValues::valueAt) + + private val locksChangeFlow = combine( + amountChooserMixin.amount, + selectedConvictionFlow, + voteAssistantFlow + ) { amount, conviction, voteAssistant -> + val amountPlanks = selectedAsset.first().token.planksFromAmount(amount) + val accountVote = constructAccountVote(amountPlanks, conviction) + voteAssistant.estimateLocksAfterVoting(payload.referendumId, accountVote, selectedAsset.first()) + } + .inBackground() + .shareWhileSubscribed() + + val votesFormattedFlow = combine( + amountChooserMixin.amount, + selectedConvictionFlow + ) { amount, conviction -> + val votes = conviction.votesFor(amount) + + resourceManager.getString(R.string.referendum_votes_format, votes.format()) + }.shareInBackground() + + val locksChangeUiFlow = locksChangeFlow.map { + locksChangeFormatter.mapLocksChangeToUi(it, selectedAsset.first()) + } + .shareInBackground() + + val abstainVotingSupported = flowOf { interactor.isAbstainSupported() } + .shareInBackground() + + val amountChips = voteAssistantFlow.map { voteAssistant -> + val asset = selectedAsset.first() + + voteAssistant.reusableLocks().map { locksFormatter.formatReusableLock(it, asset) } + } + .shareInBackground() + + init { + originFeeMixin.connectWith( + inputSource1 = amountChooserMixin.backPressuredPlanks, + inputSource2 = selectedConvictionFlow, + feeConstructor = { _, amount, conviction -> + val accountVote = constructAccountVote(amount, conviction) + interactor.estimateFee( + referendumId = payload.referendumId, + vote = accountVote + ) + } + ) + } + + open fun backClicked() { + router.back() + } + + fun amountChipClicked(chipModel: AmountChipModel) { + amountChooserMixin.setAmountInput(chipModel.amountInput) + } + + protected fun validateVote(voteType: VoteType) = launch { + validatingVoteType.value = voteType + val voteAssistant = voteAssistantFlow.first() + val asset = selectedAsset.first() + val amount = amountChooserMixin.amount.first() + val conviction = selectedConvictionFlow.first() + val maxAmount = maxActionProvider.maxAvailableBalance.first().actualAmount + + val payload = VoteReferendaValidationPayload( + onChainReferenda = voteAssistant.onChainReferenda, + asset = asset, + trackVoting = voteAssistant.trackVoting, + amount = amount, + voteType = voteType, + conviction = conviction, + fee = originFeeMixin.awaitFee(), + maxAvailableAmount = maxAmount + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { status, action -> + handleVoteReferendumValidationFailure(status.reason, action, resourceManager) + }, + progressConsumer = validatingVoteType.progressConsumer(voteType), + ) { + validatingVoteType.value = null + + onFinish(amount, conviction, voteType, it) + } + } + + abstract fun onFinish(amount: BigDecimal, conviction: Conviction, voteType: VoteType, validationPayload: VoteReferendaValidationPayload) + + private fun MutableStateFlow.progressConsumer(voteType: VoteType?): ProgressConsumer = { inProgress -> + value = voteType.takeIf { inProgress } + } + + private fun constructAccountVote( + amount: BigInteger, + conviction: Conviction + ): AccountVote { + return AccountVote.constructAccountVote(amount, conviction, VoteType.AYE) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/model/AmountChangeModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/model/AmountChangeModel.kt new file mode 100644 index 0000000..fc27f44 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/model/AmountChangeModel.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.model + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.model.AmountChangeModel.DifferenceModel + +class AmountChangeModel( + val to: CharSequence, + val from: CharSequence?, + val difference: DifferenceModel? +) { + + class DifferenceModel( + @DrawableRes val icon: Int, + val text: CharSequence, + @ColorRes val color: Int + ) +} + +fun AmountChangeModel( + from: CharSequence?, + to: CharSequence, + difference: CharSequence?, + positive: Boolean? +): AmountChangeModel { + val differenceModel = if (positive != null && difference != null) { + val icon = if (positive) R.drawable.ic_double_chevron_up else R.drawable.ic_double_chevron_down + + DifferenceModel( + icon = icon, + text = difference, + color = R.color.button_background_primary + ) + } else { + null + } + + return AmountChangeModel( + from = from, + to = to, + difference = differenceModel + ) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/model/LocksChangeModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/model/LocksChangeModel.kt new file mode 100644 index 0000000..1482f41 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/model/LocksChangeModel.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.model + +class LocksChangeModel( + val amountChange: AmountChangeModel, + val periodChange: AmountChangeModel, + val transferableChange: AmountChangeModel, +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/view/AmountChangesView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/view/AmountChangesView.kt new file mode 100644 index 0000000..77ce0e9 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/view/AmountChangesView.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ViewAmountChangesBinding +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.model.AmountChangeModel + +class AmountChangesView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + defStyleRes: Int = 0, +) : ConstraintLayout(context, attrs, defStyle, defStyleRes), + WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewAmountChangesBinding.inflate(inflater(), this) + + init { + setBackgroundResource(R.drawable.bg_primary_list_item) + + attrs?.let { applyAttributes(it) } + } + + fun setFrom(value: CharSequence?) { + if (value != null) { + binder.valueChangesFrom.text = value + binder.valueChangesFromGroup.makeVisible() + } else { + binder.valueChangesFromGroup.makeGone() + } + } + + fun setTo(value: CharSequence) { + binder.valueChangesTo.text = value + } + + fun setDifference(@DrawableRes icon: Int, text: CharSequence, @ColorRes textColor: Int) { + binder.valueChangesDifference.makeVisible() + + binder.valueChangesDifference.setDrawableStart(icon, widthInDp = 16, tint = textColor) + binder.valueChangesDifference.text = text + binder.valueChangesDifference.setTextColorRes(textColor) + } + + fun hideDifference() { + binder.valueChangesDifference.makeGone() + } + + fun setTitle(title: String) { + binder.valueChangesTitle.text = title + } + + fun setIcon(icon: Drawable?) = binder.valueChangesIcon.letOrHide(icon, binder.valueChangesIcon::setImageDrawable) + + private fun applyAttributes(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.AmountChangesView) { typedArray -> + val title = typedArray.getString(R.styleable.AmountChangesView_amountChanges_title) + title?.let(::setTitle) + + val icon = typedArray.getDrawable(R.styleable.AmountChangesView_amountChanges_icon) + setIcon(icon) + } +} + +fun AmountChangesView.setAmountChangeModel(model: AmountChangeModel) { + setFrom(model.from) + setTo(model.to) + + val difference = model.difference + + if (difference != null) { + setDifference(difference.icon, difference.text, difference.color) + } else { + hideDifference() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/view/VotePowerView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/view/VotePowerView.kt new file mode 100644 index 0000000..960b0ee --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/common/view/VotePowerView.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.setPadding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.input.seekbar.Seekbar +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ViewVotePowerBinding + +class VotePowerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewVotePowerBinding.inflate(inflater(), this) + + val votePowerSeekbar: Seekbar + get() = binder.votePowerSelector + + val votePowerVotesText: TextView + get() = binder.votePowerVotes + + init { + orientation = VERTICAL + + setPadding(16.dp) + + background = getRoundedCornerDrawable(fillColorRes = R.color.block_background) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/SetupReferendumVoteFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/SetupReferendumVoteFragment.kt new file mode 100644 index 0000000..e1d9196 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/SetupReferendumVoteFragment.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.referenda + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.LayoutSetupVoteControlAyeNayAbstainBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVoteFragment + +class SetupReferendumVoteFragment : SetupVoteFragment() { + + private lateinit var controlViewBinder: LayoutSetupVoteControlAyeNayAbstainBinding + + override fun getControlView(inflater: LayoutInflater, parent: View): View { + controlViewBinder = LayoutSetupVoteControlAyeNayAbstainBinding.inflate(inflater, parent as ViewGroup, false) + return controlViewBinder.root + } + + override fun initViews() { + super.initViews() + + controlViewBinder.setupReferendumVoteControlView.ayeButton.prepareForProgress(viewLifecycleOwner) + controlViewBinder.setupReferendumVoteControlView.abstainButton.prepareForProgress(viewLifecycleOwner) + controlViewBinder.setupReferendumVoteControlView.nayButton.prepareForProgress(viewLifecycleOwner) + + controlViewBinder.setupReferendumVoteControlView.setAyeClickListener { viewModel.ayeClicked() } + controlViewBinder.setupReferendumVoteControlView.setAbstainClickListener { viewModel.abstainClicked() } + controlViewBinder.setupReferendumVoteControlView.setNayClickListener { viewModel.nayClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .setupReferendumVoteFactory() + .create(this, getPayload()) + .inject(this) + } + + override fun subscribe(viewModel: SetupReferendumVoteViewModel) { + super.subscribe(viewModel) + + viewModel.ayeButtonStateFlow.observe(controlViewBinder.setupReferendumVoteControlView.ayeButton::setState) + viewModel.abstainButtonStateFlow.observe(controlViewBinder.setupReferendumVoteControlView.abstainButton::setState) + viewModel.nayButtonStateFlow.observe(controlViewBinder.setupReferendumVoteControlView.nayButton::setState) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/SetupReferendumVoteViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/SetupReferendumVoteViewModel.kt new file mode 100644 index 0000000..0e0fd13 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/SetupReferendumVoteViewModel.kt @@ -0,0 +1,123 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.referenda + +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendaValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.conviction.ConvictionValuesProvider +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.LocksFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.AccountVoteParcelModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.confirm.ConfirmVoteReferendumPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVoteViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.referendumId +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import java.math.BigDecimal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SetupReferendumVoteViewModel( + private val payload: SetupVotePayload, + private val router: GovernanceRouter, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + assetUseCase: AssetUseCase, + amountChooserMixinFactory: AmountChooserMixin.Factory, + interactor: VoteReferendumInteractor, + validationSystem: VoteReferendumValidationSystem, + validationExecutor: ValidationExecutor, + referendumFormatter: ReferendumFormatter, + locksChangeFormatter: LocksChangeFormatter, + convictionValuesProvider: ConvictionValuesProvider, + maxActionProviderFactory: MaxActionProviderFactory, + locksFormatter: LocksFormatter, +) : SetupVoteViewModel( + assetUseCase, + amountChooserMixinFactory, + interactor, + payload, + resourceManager, + router, + validationSystem, + validationExecutor, + locksChangeFormatter, + convictionValuesProvider, + locksFormatter, + maxActionProviderFactory, + amountFormatter, + feeLoaderMixinFactory +) { + + override val title: Flow = flowOf { + val formattedNumber = referendumFormatter.formatId(payload.referendumId) + resourceManager.getString(R.string.referendum_vote_setup_title, formattedNumber) + }.shareInBackground() + + val ayeButtonStateFlow = validatingVoteType.map { buttonState(VoteType.AYE, validationVoteType = it) } + val abstainButtonStateFlow = combine(validatingVoteType, abstainVotingSupported) { type, isAbstainSupported -> + if (isAbstainSupported) { + buttonState(VoteType.ABSTAIN, validationVoteType = type) + } else { + DescriptiveButtonState.Invisible + } + } + val nayButtonStateFlow = validatingVoteType.map { buttonState(VoteType.NAY, validationVoteType = it) } + + fun ayeClicked() { + validateVote(VoteType.AYE) + } + + fun abstainClicked() { + validateVote(VoteType.ABSTAIN) + } + + fun nayClicked() { + validateVote(VoteType.NAY) + } + + override fun onFinish( + amount: BigDecimal, + conviction: Conviction, + voteType: VoteType, + validationPayload: VoteReferendaValidationPayload + ) { + launch { + val confirmPayload = ConfirmVoteReferendumPayload( + _referendumId = payload._referendumId, + fee = mapFeeToParcel(validationPayload.fee), + vote = AccountVoteParcelModel( + amount = amount, + conviction = conviction, + voteType = voteType + ) + ) + + router.openConfirmVoteReferendum(confirmPayload) + } + } + + private fun buttonState(targetVoteType: VoteType, validationVoteType: VoteType?): DescriptiveButtonState { + return when (validationVoteType) { + null -> DescriptiveButtonState.Enabled("") + targetVoteType -> DescriptiveButtonState.Loading + else -> DescriptiveButtonState.Disabled("") + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/di/SetupReferendumVoteComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/di/SetupReferendumVoteComponent.kt new file mode 100644 index 0000000..f656484 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/di/SetupReferendumVoteComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.referenda.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.referenda.SetupReferendumVoteFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload + +@Subcomponent( + modules = [ + SetupReferendumVoteModule::class + ] +) +@ScreenScope +interface SetupReferendumVoteComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SetupVotePayload, + ): SetupReferendumVoteComponent + } + + fun inject(fragment: SetupReferendumVoteFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/di/SetupReferendumVoteModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/di/SetupReferendumVoteModule.kt new file mode 100644 index 0000000..8dc4061 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/referenda/di/SetupReferendumVoteModule.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.referenda.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.conviction.ConvictionValuesProvider +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.LocksFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.referenda.SetupReferendumVoteViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory + +@Module(includes = [ViewModelModule::class]) +class SetupReferendumVoteModule { + + @Provides + @IntoMap + @ViewModelKey(SetupReferendumVoteViewModel::class) + fun provideViewModel( + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + assetUseCase: AssetUseCase, + amountChooserMixinFactory: AmountChooserMixin.Factory, + interactor: VoteReferendumInteractor, + payload: SetupVotePayload, + resourceManager: ResourceManager, + router: GovernanceRouter, + validationSystem: VoteReferendumValidationSystem, + validationExecutor: ValidationExecutor, + referendumFormatter: ReferendumFormatter, + locksChangeFormatter: LocksChangeFormatter, + convictionValuesProvider: ConvictionValuesProvider, + maxActionProviderFactory: MaxActionProviderFactory, + locksFormatter: LocksFormatter, + amountFormatter: AmountFormatter + ): ViewModel { + return SetupReferendumVoteViewModel( + feeLoaderMixinFactory = feeLoaderMixinFactory, + assetUseCase = assetUseCase, + amountChooserMixinFactory = amountChooserMixinFactory, + interactor = interactor, + payload = payload, + resourceManager = resourceManager, + router = router, + validationSystem = validationSystem, + validationExecutor = validationExecutor, + referendumFormatter = referendumFormatter, + locksChangeFormatter = locksChangeFormatter, + convictionValuesProvider = convictionValuesProvider, + maxActionProviderFactory = maxActionProviderFactory, + locksFormatter = locksFormatter, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): SetupReferendumVoteViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SetupReferendumVoteViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/SetupTinderGovVoteFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/SetupTinderGovVoteFragment.kt new file mode 100644 index 0000000..a455ffb --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/SetupTinderGovVoteFragment.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.LayoutSetupVoteControlContinueBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVoteFragment + +class SetupTinderGovVoteFragment : SetupVoteFragment() { + + private lateinit var controlViewBinder: LayoutSetupVoteControlContinueBinding + + override fun getControlView(inflater: LayoutInflater, parent: View): View { + controlViewBinder = LayoutSetupVoteControlContinueBinding.inflate(inflater, parent as ViewGroup, false) + return controlViewBinder.root + } + + override fun initViews() { + super.initViews() + binder.setupReferendumVoteSubtitle.text = getString(R.string.swipe_gov_vote_subtitle) + + controlViewBinder.setupTinderGovVoteContinue.setOnClickListener { viewModel.continueClicked() } + onBackPressed { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .setupTinderGovVoteFactory() + .create(this, getPayload()) + .inject(this) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/SetupTinderGovVoteViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/SetupTinderGovVoteViewModel.kt new file mode 100644 index 0000000..b5dac85 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/SetupTinderGovVoteViewModel.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.model.VotingPower +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendaValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.conviction.ConvictionValuesProvider +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.LocksFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVoteViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import java.math.BigDecimal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class SetupTinderGovVoteViewModel( + private val payload: SetupVotePayload, + private val router: GovernanceRouter, + private val tinderGovInteractor: TinderGovInteractor, + private val tinderGovVoteResponder: TinderGovVoteResponder, + private val accountRepository: AccountRepository, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + assetUseCase: AssetUseCase, + amountChooserMixinFactory: AmountChooserMixin.Factory, + interactor: VoteReferendumInteractor, + resourceManager: ResourceManager, + validationSystem: VoteReferendumValidationSystem, + validationExecutor: ValidationExecutor, + locksChangeFormatter: LocksChangeFormatter, + convictionValuesProvider: ConvictionValuesProvider, + maxActionProviderFactory: MaxActionProviderFactory, + locksFormatter: LocksFormatter, +) : SetupVoteViewModel( + assetUseCase, + amountChooserMixinFactory, + interactor, + payload, + resourceManager, + router, + validationSystem, + validationExecutor, + locksChangeFormatter, + convictionValuesProvider, + locksFormatter, + maxActionProviderFactory, + amountFormatter, + feeLoaderMixinFactory +) { + override val title: Flow = flowOf { + resourceManager.getString(R.string.swipe_gov_vote_title) + } + + init { + launch { + val metaAccount = accountRepository.getSelectedMetaAccount() + val asset = selectedAsset.first() + val chainAsset = asset.token.configuration + val votingPower = tinderGovInteractor.getVotingPower(metaAccount.id, asset.token.configuration.chainId) ?: return@launch + val amount = chainAsset.amountFromPlanks(votingPower.amount) + amountChooserMixin.setAmount(amount, false) + selectedConvictionIndex.value = votingPower.conviction.ordinal + } + } + + override fun backClicked() { + tinderGovVoteResponder.respond(TinderGovVoteResponder.Response(success = false)) + super.backClicked() + } + + fun continueClicked() { + validateVote(voteType = VoteType.AYE) // Use AYE as a stub + } + + override fun onFinish( + amount: BigDecimal, + conviction: Conviction, + voteType: VoteType, + validationPayload: VoteReferendaValidationPayload + ) { + launch { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chainAsset = validationPayload.asset.token.configuration + val amountInPlanks = chainAsset.planksFromAmount(amount) + val votingPower = VotingPower(metaAccount.id, chainAsset.chainId, amountInPlanks, conviction) + tinderGovInteractor.setVotingPower(votingPower) + tinderGovVoteResponder.respond(TinderGovVoteResponder.Response(success = true)) + router.back() + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/TinderGovCommunicator.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/TinderGovCommunicator.kt new file mode 100644 index 0000000..e1b3309 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/TinderGovCommunicator.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import java.math.BigInteger +import kotlinx.parcelize.Parcelize + +interface TinderGovVoteRequester : InterScreenRequester { + + @Parcelize + class Request(val referendumId: BigInteger) : Parcelable +} + +interface TinderGovVoteResponder : InterScreenResponder { + + @Parcelize + class Response(val success: Boolean) : Parcelable +} + +interface TinderGovVoteCommunicator : TinderGovVoteRequester, TinderGovVoteResponder diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/di/SetupTinderGovVoteComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/di/SetupTinderGovVoteComponent.kt new file mode 100644 index 0000000..f4a1748 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/di/SetupTinderGovVoteComponent.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.referenda.di.SetupTinderGovVoteModule +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.SetupTinderGovVoteFragment + +@Subcomponent( + modules = [ + SetupTinderGovVoteModule::class + ] +) +@ScreenScope +interface SetupTinderGovVoteComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SetupVotePayload, + ): SetupTinderGovVoteComponent + } + + fun inject(fragment: SetupTinderGovVoteFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/di/SetupTinderGovVoteModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/di/SetupTinderGovVoteModule.kt new file mode 100644 index 0000000..b1ecd89 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/vote/setup/tindergov/di/SetupTinderGovVoteModule.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.referenda.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.referendum.VoteReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.conviction.ConvictionValuesProvider +import io.novafoundation.nova.feature_governance_impl.presentation.common.locks.LocksFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.SetupVotePayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.SetupTinderGovVoteViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.TinderGovVoteCommunicator +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory + +@Module(includes = [ViewModelModule::class]) +class SetupTinderGovVoteModule { + + @Provides + @IntoMap + @ViewModelKey(SetupTinderGovVoteViewModel::class) + fun provideViewModel( + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + assetUseCase: AssetUseCase, + tinderGovInteractor: TinderGovInteractor, + accountRepository: AccountRepository, + tinderGovVoteCommunicator: TinderGovVoteCommunicator, + amountChooserMixinFactory: AmountChooserMixin.Factory, + interactor: VoteReferendumInteractor, + payload: SetupVotePayload, + resourceManager: ResourceManager, + router: GovernanceRouter, + validationSystem: VoteReferendumValidationSystem, + validationExecutor: ValidationExecutor, + locksChangeFormatter: LocksChangeFormatter, + convictionValuesProvider: ConvictionValuesProvider, + maxActionProviderFactory: MaxActionProviderFactory, + locksFormatter: LocksFormatter, + amountFormatter: AmountFormatter + ): ViewModel { + return SetupTinderGovVoteViewModel( + feeLoaderMixinFactory = feeLoaderMixinFactory, + assetUseCase = assetUseCase, + amountChooserMixinFactory = amountChooserMixinFactory, + interactor = interactor, + payload = payload, + resourceManager = resourceManager, + router = router, + validationSystem = validationSystem, + validationExecutor = validationExecutor, + locksChangeFormatter = locksChangeFormatter, + convictionValuesProvider = convictionValuesProvider, + locksFormatter = locksFormatter, + tinderGovInteractor = tinderGovInteractor, + tinderGovVoteResponder = tinderGovVoteCommunicator, + maxActionProviderFactory = maxActionProviderFactory, + accountRepository = accountRepository, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): SetupTinderGovVoteViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SetupTinderGovVoteViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/ReferendumVotersFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/ReferendumVotersFragment.kt new file mode 100644 index 0000000..b0c0f2c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/ReferendumVotersFragment.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters + +import android.os.Bundle +import androidx.core.view.isVisible + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentReferendumVotersBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.list.VoterItemDecoration +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.list.VotersAdapter + +import javax.inject.Inject + +class ReferendumVotersFragment : BaseFragment(), VotersAdapter.Handler { + + companion object { + private const val KEY_PAYLOAD = "payload" + + fun getBundle(payload: ReferendumVotersPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentReferendumVotersBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val votersAdapter by lazy(LazyThreadSafetyMode.NONE) { VotersAdapter(this, imageLoader) } + + override fun initViews() { + binder.referendumVotersToolbar.setTitle(viewModel.title) + binder.referendumVotersList.setHasFixedSize(true) + binder.referendumVotersList.adapter = votersAdapter + binder.referendumVotersList.addItemDecoration(VoterItemDecoration(requireContext(), votersAdapter)) + binder.referendumVotersToolbar.setHomeButtonListener { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .referendumVotersFactory() + .create(this, arguments!!.getParcelable(KEY_PAYLOAD)!!) + .inject(this) + } + + override fun subscribe(viewModel: ReferendumVotersViewModel) { + setupExternalActions(viewModel) + + viewModel.voterModels.observe { + if (it is LoadingState.Loaded) { + val voters = it.data + votersAdapter.submitList(voters) + binder.referendumVotersPlaceholder.isVisible = voters.isEmpty() + binder.referendumVotersList.isVisible = voters.isNotEmpty() + binder.referendumVotersCount.makeVisible() + binder.referendumVotersProgress.makeGone() + } else { + binder.referendumVotersPlaceholder.makeGone() + binder.referendumVotersProgress.makeVisible() + } + } + + viewModel.votersCount.observe(binder.referendumVotersCount::setText) + } + + override fun onVoterClick(position: Int) { + viewModel.voterClicked(position) + } + + override fun onExpandItemClick(position: Int) { + viewModel.expandVoterClicked(position) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/ReferendumVotersPayload.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/ReferendumVotersPayload.kt new file mode 100644 index 0000000..1bee158 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/ReferendumVotersPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters + +import android.os.Parcelable +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import java.math.BigInteger +import kotlinx.parcelize.Parcelize + +@Parcelize +class ReferendumVotersPayload( + val referendumId: BigInteger, + val voteType: VoteType +) : Parcelable diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/ReferendumVotersViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/ReferendumVotersViewModel.kt new file mode 100644 index 0000000..472cfb4 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/ReferendumVotersViewModel.kt @@ -0,0 +1,177 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters + +import android.text.TextUtils +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.firstOnLoad +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_account_api.domain.account.identity.Identity +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegate.label.DelegateLabel +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.ReferendumVoter +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.ReferendumVotersInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.formatConvictionVote +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.list.DelegatorVoterRVItem +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.list.ExpandableVoterRVItem +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.list.VoterRvItem +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAsset +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ReferendumVotersViewModel( + private val payload: ReferendumVotersPayload, + private val router: GovernanceRouter, + private val governanceSharedState: GovernanceSharedState, + private val externalActions: ExternalActions.Presentation, + private val referendumVotersInteractor: ReferendumVotersInteractor, + private val resourceManager: ResourceManager, + private val votersFormatter: VotersFormatter, + private val delegateMappers: DelegateMappers +) : BaseViewModel(), ExternalActions by externalActions { + + private val chainFlow = flowOf { governanceSharedState.chain() } + .shareInBackground() + private val chainAssetFlow = flowOf { governanceSharedState.chainAsset() } + .shareInBackground() + + private val voterList = flowOfAll { + val referendumId = ReferendumId(payload.referendumId) + referendumVotersInteractor.votersFlow(referendumId, payload.voteType) + }.shareInBackground() + + val title: String = mapTypeToString(payload.voteType) + + private val expandedVotersFlow: MutableStateFlow> = MutableStateFlow(setOf()) + + val voterModels = combine(voterList, expandedVotersFlow) { voters, expandedVoters -> + val chain = chainFlow.first() + val chainAsset = chainAssetFlow.first() + mapVotersToVoterModels(chain, chainAsset, voters, expandedVoters) + } + .withLoading() + .shareInBackground() + + val votersCount = voterList.map { it.size.format() } + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun expandVoterClicked(position: Int) = launch { + val voters = voterModels.firstOnLoad() + val voterItem = voters[position] as? ExpandableVoterRVItem ?: return@launch + expandedVotersFlow.value = expandedVotersFlow.value.toggle(voterItem.primaryIndex) + } + + fun voterClicked(position: Int) = launch { + val voters = voterModels.firstOnLoad() + val voterItem = voters[position] + val chain = chainFlow.first() + + externalActions.showAddressActions(voterItem.metadata.address, chain) + } + + private suspend fun mapVotersToVoterModels( + chain: Chain, + chainAsset: Chain.Asset, + voters: List, + expandedVoters: Set + ): List { + return buildList { + voters.forEachIndexed { index, referendumVoter -> + val isExpandable = referendumVoter.vote is ReferendumVoter.Vote.WithDelegators + val isExpanded = index in expandedVoters + + add(mapReferendumVoterToExpandableRvItem(index, referendumVoter, chain, chainAsset, isExpandable, isExpanded)) + + if (isExpandable && isExpanded) { + addAll(mapVoterDelegatorsToRvItem(referendumVoter, chain, chainAsset)) + } + } + } + } + + private suspend fun mapReferendumVoterToExpandableRvItem( + index: Int, + referendumVoter: ReferendumVoter, + chain: Chain, + chainAsset: Chain.Asset, + isExpandable: Boolean, + isExpanded: Boolean + ): ExpandableVoterRVItem { + val voteModel = when (val vote = referendumVoter.vote) { + is ReferendumVoter.Vote.OnlySelf -> votersFormatter.formatConvictionVote(vote.selfVote, chainAsset) + is ReferendumVoter.Vote.WithDelegators -> VoteModel( + votesCount = votersFormatter.formatTotalVotes(vote), + votesCountDetails = null + ) + } + + return ExpandableVoterRVItem( + primaryIndex = index, + vote = voteModel, + metadata = delegateMappers.formatDelegateLabel( + accountId = referendumVoter.accountId, + metadata = referendumVoter.metadata, + identityName = referendumVoter.identity?.name, + chain = chain + ), + isExpandable = isExpandable, + isExpanded = isExpanded, + addressEllipsize = mapAddressEllipsize(referendumVoter.metadata, referendumVoter.identity) + ) + } + + private suspend fun mapVoterDelegatorsToRvItem(referendumVoter: ReferendumVoter, chain: Chain, chainAsset: Chain.Asset): List { + val vote = referendumVoter.vote + if (vote !is ReferendumVoter.Vote.WithDelegators) return emptyList() + + return vote.delegators.map { + DelegatorVoterRVItem( + vote = votersFormatter.formatConvictionVote(it.vote, chainAsset), + metadata = delegateMappers.formatDelegateLabel( + accountId = it.accountId, + metadata = it.metadata, + identityName = it.identity?.name, + chain = chain + ), + addressEllipsize = mapAddressEllipsize(it.metadata, it.identity) + ) + } + } + + private fun mapAddressEllipsize(metadata: DelegateLabel.Metadata?, identity: Identity?): TextUtils.TruncateAt { + return if (metadata?.name != null || identity?.name != null) { + TextUtils.TruncateAt.END + } else { + TextUtils.TruncateAt.MIDDLE + } + } + + private fun mapTypeToString(voteType: VoteType): String { + return when (voteType) { + VoteType.AYE -> resourceManager.getString(R.string.referendum_positive_voters_title) + VoteType.NAY -> resourceManager.getString(R.string.referendum_negative_voters_title) + VoteType.ABSTAIN -> resourceManager.getString(R.string.referendum_abstain_voters_title) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/di/CachingDelegateMappers.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/di/CachingDelegateMappers.kt new file mode 100644 index 0000000..eb2d592 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/di/CachingDelegateMappers.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class CachingDelegateMappers diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/di/ReferendumVotersComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/di/ReferendumVotersComponent.kt new file mode 100644 index 0000000..a057eac --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/di/ReferendumVotersComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.ReferendumVotersFragment +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.ReferendumVotersPayload + +@Subcomponent( + modules = [ + ReferendumVotersModule::class + ] +) +@ScreenScope +interface ReferendumVotersComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance parcelable: ReferendumVotersPayload, + ): ReferendumVotersComponent + } + + fun inject(fragment: ReferendumVotersFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/di/ReferendumVotersModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/di/ReferendumVotersModule.kt new file mode 100644 index 0000000..491d029 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/di/ReferendumVotersModule.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.modules.Caching +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_governance_api.domain.referendum.voters.ReferendumVotersInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.DelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.ReferendumVotersPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.ReferendumVotersViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.RealDelegateMappers +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter + +@Module(includes = [ViewModelModule::class]) +class ReferendumVotersModule { + + @Provides + @CachingDelegateMappers + fun provideDelegateMappers( + resourceManager: ResourceManager, + @Caching addressIconGenerator: AddressIconGenerator, + trackFormatter: TrackFormatter, + votersFormatter: VotersFormatter + ): DelegateMappers = RealDelegateMappers(resourceManager, addressIconGenerator, trackFormatter, votersFormatter) + + @Provides + @IntoMap + @ViewModelKey(ReferendumVotersViewModel::class) + fun provideViewModel( + payload: ReferendumVotersPayload, + router: GovernanceRouter, + governanceSharedState: GovernanceSharedState, + externalAction: ExternalActions.Presentation, + referendumVotersInteractor: ReferendumVotersInteractor, + resourceManager: ResourceManager, + votersFormatter: VotersFormatter, + @CachingDelegateMappers delegateMappers: DelegateMappers + ): ViewModel { + return ReferendumVotersViewModel( + payload = payload, + router = router, + governanceSharedState = governanceSharedState, + externalActions = externalAction, + referendumVotersInteractor = referendumVotersInteractor, + resourceManager = resourceManager, + votersFormatter = votersFormatter, + delegateMappers = delegateMappers + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ReferendumVotersViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ReferendumVotersViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/list/ExpandableVoterRVItem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/list/ExpandableVoterRVItem.kt new file mode 100644 index 0000000..758668e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/list/ExpandableVoterRVItem.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.list + +import android.text.TextUtils +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteModel +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.DelegateLabelModel + +sealed interface VoterRvItem { + val vote: VoteModel + val metadata: DelegateLabelModel + val addressEllipsize: TextUtils.TruncateAt +} + +class ExpandableVoterRVItem( + val primaryIndex: Int, + override val vote: VoteModel, + override val metadata: DelegateLabelModel, + val isExpandable: Boolean, + val isExpanded: Boolean, + override val addressEllipsize: TextUtils.TruncateAt +) : VoterRvItem + +class DelegatorVoterRVItem( + override val vote: VoteModel, + override val metadata: DelegateLabelModel, + override val addressEllipsize: TextUtils.TruncateAt +) : VoterRvItem diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/list/VoterItemDecoration.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/list/VoterItemDecoration.kt new file mode 100644 index 0000000..87b5ef1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/list/VoterItemDecoration.kt @@ -0,0 +1,148 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.list + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.feature_governance_impl.R + +class VoterItemDecoration( + private val context: Context, + private val adapter: GroupedListAdapter<*, *> +) : RecyclerView.ItemDecoration() { + + private val treePath = Path() + private val delegatorsStartOffset: Int = 44.dp(context) + private val treeStrokeStartOffset: Float = 28.dpF(context) + private val treePointerLength: Float = 11.dpF(context) + + private val expandableBlockPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = context.getColor(R.color.block_background) + } + + private val treePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = context.getColor(R.color.block_background) + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.MITER + strokeWidth = 2.dpF(context) + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val viewHolder = parent.getChildViewHolder(view) + if (viewHolder is VoterDelegatorHolder) { + view.updatePadding(start = delegatorsStartOffset) + } + } + + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val linearLayoutManager = parent.layoutManager as LinearLayoutManager + + var filling = false + val expandableRanges = mutableListOf() + + for (childIndex in 0 until linearLayoutManager.childCount) { + val (view, viewHolder) = linearLayoutManager.getViewAndViewHolder(parent, childIndex) + + if (view == null) continue + + if (viewHolder is VoterDelegatorHolder) { + if (!filling) { + expandableRanges.add(ExpandableItemRange(linearLayoutManager, adapter)) + filling = true + } + expandableRanges.last() + .add(childIndex, view) + } else if (filling && viewHolder is ExpandableVoterHolder) { + filling = false + } + } + + expandableRanges.forEach { + val left = 0f + val top = it.getTopEdge() + val right = parent.measuredWidth.toFloat() + val bottom = it.getBottomEdge() + + canvas.save() + canvas.clipRect(left, top, right, bottom) + + canvas.drawRect(left, top, right, bottom, expandableBlockPaint) + + treePath.reset() + it.buildTreePath(treePath, treeStrokeStartOffset, treePointerLength) + canvas.drawPath(treePath, treePaint) + + canvas.restore() + } + } + + private fun LinearLayoutManager.getViewAndViewHolder(parent: RecyclerView, index: Int): Pair { + if (index >= childCount) return null to null + + val view = this.getChildAt(index) ?: return null to null + val viewHolder = parent.getChildViewHolder(view) + + return view to viewHolder + } + + class IndexAndView(val index: Int, val view: View) + + class ExpandableItemRange( + private val linearLayoutManager: LinearLayoutManager, + private val adapter: GroupedListAdapter<*, *> + ) { + + private val expandedItems: MutableList = mutableListOf() + + fun add(index: Int, view: View) { + expandedItems.add(IndexAndView(index, view)) + } + + fun buildTreePath(treePath: Path, treeStrokeStartOffset: Float, treePointerLength: Float) { + expandedItems.forEach { + val y = it.view.y + it.view.pivotY + treePath.moveTo(treeStrokeStartOffset + treePointerLength, y) + treePath.lineTo(treeStrokeStartOffset, y) + } + + val lastItem = expandedItems.last().view + val lastItemAdapterPosition = linearLayoutManager.getPosition(lastItem) + val nextItemAdapterPosition = lastItemAdapterPosition + 1 + if (nextItemAdapterPosition < adapter.itemCount && adapter.getItemViewType(nextItemAdapterPosition) == GroupedListAdapter.TYPE_CHILD) { + treePath.moveTo(treeStrokeStartOffset, lastItem.bottom.toFloat() + lastItem.translationY) + } + + val firstItem = expandedItems.first().view + treePath.lineTo(treeStrokeStartOffset, firstItem.top.toFloat() + firstItem.translationY) + } + + fun getTopEdge(): Float { + val firstItem = expandedItems.first() + val anchorView = linearLayoutManager.getChildAt(firstItem.index - 1) + return if (anchorView == null) { + firstItem.view.top.toFloat() + firstItem.view.translationY + } else { + anchorView.bottom.toFloat() + anchorView.translationY + } + } + + fun getBottomEdge(): Float { + val lastItem = expandedItems.last() + val anchorView = linearLayoutManager.getChildAt(lastItem.index + 1) + return if (anchorView == null) { + lastItem.view.bottom.toFloat() + lastItem.view.translationY + } else { + anchorView.top.toFloat() + anchorView.translationY + } + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/list/VotersAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/list/VotersAdapter.kt new file mode 100644 index 0000000..2d2ca57 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/referenda/voters/list/VotersAdapter.kt @@ -0,0 +1,159 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.referenda.voters.list + +import android.view.ViewGroup +import androidx.core.view.isVisible +import coil.ImageLoader +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ItemReferendumVoterBinding +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.nameOrAddress +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.setDelegateIcon +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegate.common.model.setDelegateTypeModelIcon + +class VotersAdapter( + private val handler: Handler, + private val imageLoader: ImageLoader +) : GroupedListAdapter(DiffCallback()) { + + interface Handler { + + fun onVoterClick(position: Int) + + fun onExpandItemClick(position: Int) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return ExpandableVoterHolder(handler, imageLoader, ItemReferendumVoterBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return VoterDelegatorHolder(handler, imageLoader, ItemReferendumVoterBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: ExpandableVoterRVItem) { + require(holder is ExpandableVoterHolder) + holder.bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: DelegatorVoterRVItem) { + require(holder is VoterDelegatorHolder) + holder.bind(child) + } + + override fun bindGroup(holder: GroupedListHolder, position: Int, group: ExpandableVoterRVItem, payloads: List) { + require(holder is ExpandableVoterHolder) + + resolvePayload(holder, position, payloads) { + when (it) { + ExpandableVoterRVItem::isExpanded -> holder.bindExpanding(group) + } + } + } +} + +private object VoterPayloadGenerator : PayloadGenerator(ExpandableVoterRVItem::isExpanded) + +private class DiffCallback : BaseGroupedDiffCallback(ExpandableVoterRVItem::class.java) { + override fun areGroupItemsTheSame(oldItem: ExpandableVoterRVItem, newItem: ExpandableVoterRVItem): Boolean { + return oldItem.metadata.address == newItem.metadata.address + } + + override fun areGroupContentsTheSame(oldItem: ExpandableVoterRVItem, newItem: ExpandableVoterRVItem): Boolean { + return oldItem.isExpanded == newItem.isExpanded + } + + override fun areChildItemsTheSame(oldItem: DelegatorVoterRVItem, newItem: DelegatorVoterRVItem): Boolean { + return oldItem.metadata.address == newItem.metadata.address + } + + override fun areChildContentsTheSame(oldItem: DelegatorVoterRVItem, newItem: DelegatorVoterRVItem): Boolean { + return true + } + + override fun getGroupChangePayload(oldItem: ExpandableVoterRVItem, newItem: ExpandableVoterRVItem): Any? { + return VoterPayloadGenerator.diff(oldItem, newItem) + } +} + +class ExpandableVoterHolder( + private val eventHandler: VotersAdapter.Handler, + private val imageLoader: ImageLoader, + private val binder: ItemReferendumVoterBinding, +) : GroupedListHolder(binder.root) { + + init { + containerView.setBackgroundResource(R.drawable.bg_primary_list_item) + binder.itemVoterAddressContainer.setOnClickListener { eventHandler.onVoterClick(absoluteAdapterPosition) } + } + + fun bind(item: ExpandableVoterRVItem) = with(binder) { + itemVoterChevron.isVisible = item.isExpandable + + if (item.isExpandable) { + itemVoterAddressContainer.background = containerView.context.addRipple() + itemVoterAddressContainer.isClickable = true + containerView.setOnClickListener { eventHandler.onExpandItemClick(absoluteAdapterPosition) } + bindExpanding(item) + } else { + itemVoterAddressContainer.background = null + itemVoterAddressContainer.isClickable = false + containerView.setOnClickListener { eventHandler.onVoterClick(absoluteAdapterPosition) } + } + + val delegateIcon = item.metadata.icon + itemVoterImage.setDelegateIcon(delegateIcon, imageLoader, 4) + itemVoterType.setDelegateTypeModelIcon(item.metadata.type) + itemVoterAddress.text = item.metadata.nameOrAddress() + itemVoterAddress.ellipsize = item.addressEllipsize + itemVoterAddress.requestLayout() + itemVotesCount.text = item.vote.votesCount + itemVotesCountDetails.setTextOrHide(item.vote.votesCountDetails) + } + + fun bindExpanding(item: ExpandableVoterRVItem) = with(binder) { + if (item.isExpandable) { + if (item.isExpanded) { + itemVoterChevron.setImageResource(R.drawable.ic_chevron_up) + } else { + itemVoterChevron.setImageResource(R.drawable.ic_chevron_down) + } + } else { + itemVoterChevron.setImageDrawable(null) + } + } +} + +class VoterDelegatorHolder( + private val eventHandler: VotersAdapter.Handler, + private val imageLoader: ImageLoader, + private val binder: ItemReferendumVoterBinding, +) : GroupedListHolder(binder.root) { + + init { + with(binder) { + containerView.setBackgroundResource(R.drawable.bg_primary_list_item) + itemVoterChevron.makeGone() + containerView.setOnClickListener { eventHandler.onVoterClick(absoluteAdapterPosition) } + } + } + + fun bind(item: DelegatorVoterRVItem) = with(binder) { + val delegateIcon = item.metadata.icon + itemVoterImage.setDelegateIcon(delegateIcon, imageLoader, 4) + + itemVoterType.setDelegateTypeModelIcon(item.metadata.type) + itemVoterAddress.text = item.metadata.nameOrAddress() + itemVoterAddress.ellipsize = item.addressEllipsize + itemVoterAddress.requestLayout() + itemVotesCount.text = item.vote.votesCount + itemVotesCountDetails.text = item.vote.votesCountDetails + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/TinderGovBasketFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/TinderGovBasketFragment.kt new file mode 100644 index 0000000..9cdc697 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/TinderGovBasketFragment.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket + +import androidx.recyclerview.widget.DefaultItemAnimator + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.dialog.infoDialog +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentTinderGovBasketBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.adpter.TinderGovBasketAdapter +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.adpter.TinderGovBasketRvItem + +class TinderGovBasketFragment : BaseFragment(), TinderGovBasketAdapter.Handler { + + override fun createBinding() = FragmentTinderGovBasketBinding.inflate(layoutInflater) + + private val adapter = TinderGovBasketAdapter(this) + + override fun initViews() { + binder.tinderGovBasketToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.tinderGovBasketToolbar.setRightActionClickListener { viewModel.toggleEditMode() } + + binder.tinderGovBasketList.itemAnimator = DefaultItemAnimator() + .apply { + supportsChangeAnimations = false + } + binder.tinderGovBasketList.adapter = adapter + binder.tinderGovBasketButton.setOnClickListener { viewModel.voteClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .tinderGovBasketFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: TinderGovBasketViewModel) { + viewModel.inEditModeFlow.observe { adapter.setEditMode(it) } + viewModel.editModeButtonText.observe { binder.tinderGovBasketToolbar.setTextRight(it) } + + viewModel.basketFlow.observe { + adapter.submitList(it) + } + + viewModel.voteButtonStateFlow.observe { + binder.tinderGovBasketButton.setState(it) + } + + viewModel.removeReferendumAction.awaitableActionLiveData.observeEvent { event -> + warningDialog( + requireContext(), + onPositiveClick = { event.onSuccess(true) }, + onNegativeClick = { event.onSuccess(false) }, + positiveTextRes = R.string.common_remove, + negativeTextRes = R.string.common_cancel, + styleRes = R.style.AccentNegativeAlertDialogTheme_Reversed, + ) { + setTitle(event.payload) + setMessage(R.string.swipe_gov_basket_remove_item_confirm_message) + } + } + + viewModel.itemsWasRemovedFromBasketAction.awaitableActionLiveData.observeEvent { event -> + infoDialog( + requireContext() + ) { + setPositiveButton(R.string.common_ok) { _, _ -> event.onSuccess(Unit) } + + setTitle(R.string.swipe_gov_basket_removed_items_title) + setMessage(requireContext().getString(R.string.swipe_gov_basket_removed_items_message, event.payload)) + } + } + } + + override fun onItemClicked(item: TinderGovBasketRvItem) { + viewModel.onItemClicked(item) + } + + override fun onItemDeleteClicked(item: TinderGovBasketRvItem) { + viewModel.onItemDeleteClicked(item) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/TinderGovBasketViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/TinderGovBasketViewModel.kt new file mode 100644 index 0000000..ea7f491 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/TinderGovBasketViewModel.kt @@ -0,0 +1,178 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket + +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingOrDenyingAction +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.feature_governance_api.data.model.TinderGovBasketItem +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovBasketInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.ReferendumInfoPayload +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.adpter.TinderGovBasketRvItem +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.getCurrentAsset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chainAsset +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class TinderGovBasketViewModel( + private val governanceSharedState: GovernanceSharedState, + private val router: GovernanceRouter, + private val interactor: TinderGovInteractor, + private val basketInteractor: TinderGovBasketInteractor, + private val votersFormatter: VotersFormatter, + private val referendumFormatter: ReferendumFormatter, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val resourceManager: ResourceManager, + private val assetUseCase: AssetUseCase +) : BaseViewModel() { + + val removeReferendumAction = actionAwaitableMixinFactory.confirmingOrDenyingAction() + + val itemsWasRemovedFromBasketAction = actionAwaitableMixinFactory.confirmingAction() + + val inEditModeFlow = MutableStateFlow(false) + + private val availableToVoteReferendaFlow = interactor.observeReferendaAvailableToVote(coroutineScope = this) + .map { it.associateBy { it.id } } + .shareInBackground() + + private val basketItemsFlow = basketInteractor.observeTinderGovBasket() + .shareInBackground() + + val editModeButtonText = inEditModeFlow.map { + if (it) { + resourceManager.getString(R.string.common_done) + } else { + resourceManager.getString(R.string.common_edit) + } + } + + val basketFlow = combine(availableToVoteReferendaFlow, basketItemsFlow) { referendaById, basketItems -> + basketItems.mapNotNull { + val referendum = referendaById[it.referendumId] ?: return@mapNotNull null + mapBasketItem(governanceSharedState.chainAsset(), it, referendum) + } + }.shareInBackground() + + val voteButtonStateFlow = inEditModeFlow.map { + if (it) { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.vote_vote)) + } else { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.vote_vote)) + } + } + + init { + validateReferendaAndRemove() + } + + fun backClicked() { + router.back() + } + + fun onItemClicked(item: TinderGovBasketRvItem) { + launch { + if (inEditModeFlow.value) return@launch + + router.openReferendumInfo(ReferendumInfoPayload(item.id.value)) + } + } + + fun onItemDeleteClicked(item: TinderGovBasketRvItem) { + launch { + val refId = referendumFormatter.formatId(item.id) + val title = resourceManager.getString(R.string.swipe_gov_basket_remove_item_confirm_title, refId) + + if (removeReferendumAction.awaitAction(title)) { + val referendum = basketItemsFlow.first() + .firstOrNull { it.referendumId == item.id } + ?: return@launch + + basketInteractor.removeReferendumFromBasket(referendum) + + closeScreenIfBasketIsEmpty() + } + } + } + + fun voteClicked() { + router.openConfirmTinderGovVote() + } + + fun toggleEditMode() { + inEditModeFlow.toggle() + } + + private fun mapBasketItem( + chainAsset: Chain.Asset, + item: TinderGovBasketItem, + referendum: ReferendumPreview + ): TinderGovBasketRvItem { + val voteType = when (item.voteType) { + VoteType.AYE -> resourceManager.getString(R.string.swipe_gov_aye_format).withColor(R.color.text_positive) + VoteType.NAY -> resourceManager.getString(R.string.swipe_gov_nay_format).withColor(R.color.text_negative) + VoteType.ABSTAIN -> resourceManager.getString(R.string.swipe_gov_abstain_format).withColor(R.color.text_secondary) + } + + val votesAmount = chainAsset.amountFromPlanks(item.amount) + val votes = votersFormatter.formatVotes(votesAmount, item.voteType, item.conviction) + .withColor(R.color.text_secondary) + + return TinderGovBasketRvItem( + id = item.referendumId, + idStr = referendumFormatter.formatId(item.referendumId), + title = referendumFormatter.formatReferendumName(referendum), + subtitle = SpannableFormatter.format(voteType, votes) + ) + } + + private fun String.withColor(@ColorRes color: Int): CharSequence { + return toSpannable(colorSpan(resourceManager.getColor(color))) + } + + private fun validateReferendaAndRemove() { + launch { + val removedReferenda = basketInteractor.getBasketItemsToRemove(coroutineScope) + + if (removedReferenda.isNotEmpty()) { + basketInteractor.removeBasketItems(removedReferenda) + + itemsWasRemovedFromBasketAction.awaitAction(getFormattedAmountAvailableToVote()) + + closeScreenIfBasketIsEmpty() + } + } + } + + private suspend fun getFormattedAmountAvailableToVote(): String { + val asset = assetUseCase.getCurrentAsset() + return asset.free.formatTokenAmount(asset.token.configuration) + } + + private suspend fun closeScreenIfBasketIsEmpty() { + if (basketInteractor.isBasketEmpty()) { + router.back() + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/adpter/TinderGovBasketAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/adpter/TinderGovBasketAdapter.kt new file mode 100644 index 0000000..eda7282 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/adpter/TinderGovBasketAdapter.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.adpter + +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.getDrawableCompat +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ItemTinderGovBasketBinding + +class TinderGovBasketAdapter( + private val handler: Handler +) : ListAdapter(TinderGovBasketDiffCallback()) { + + interface Handler { + fun onItemClicked(item: TinderGovBasketRvItem) + + fun onItemDeleteClicked(item: TinderGovBasketRvItem) + } + + private var editMode = false + + fun setEditMode(editMode: Boolean) { + this.editMode = editMode + + notifyItemRangeChanged(0, itemCount, editMode) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TinderGovBasketViewHolder { + return TinderGovBasketViewHolder(handler, ItemTinderGovBasketBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: TinderGovBasketViewHolder, position: Int) { + holder.bind(getItem(position), editMode) + } +} + +class TinderGovBasketViewHolder( + private val handler: TinderGovBasketAdapter.Handler, + private val binder: ItemTinderGovBasketBinding +) : GroupedListHolder(binder.root) { + + init { + itemView.background = containerView.context.getDrawableCompat(R.drawable.bg_primary_list_item) + } + + fun bind(item: TinderGovBasketRvItem, editMode: Boolean) { + itemView.setOnClickListener { handler.onItemClicked(item) } + + binder.itemTinderGovBasketDelete.setOnClickListener { handler.onItemDeleteClicked(item) } + + binder.itemTinderGovBasketDelete.isVisible = editMode + binder.itemTinderGovBasketInfo.isVisible = !editMode + binder.itemTinderGovBasketId.text = item.idStr + binder.itemTinderGovBasketTitle.text = item.title + binder.itemTinderGovBasketSubtitle.text = item.subtitle + } +} + +private class TinderGovBasketDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: TinderGovBasketRvItem, newItem: TinderGovBasketRvItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: TinderGovBasketRvItem, newItem: TinderGovBasketRvItem): Boolean { + return oldItem == newItem + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/adpter/TinderGovBasketRvItem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/adpter/TinderGovBasketRvItem.kt new file mode 100644 index 0000000..3d19753 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/adpter/TinderGovBasketRvItem.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.adpter + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId + +data class TinderGovBasketRvItem( + val id: ReferendumId, + val idStr: String, + val title: String, + val subtitle: CharSequence +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/di/TinderGovBasketComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/di/TinderGovBasketComponent.kt new file mode 100644 index 0000000..7585671 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/di/TinderGovBasketComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.TinderGovBasketFragment + +@Subcomponent( + modules = [ + TinderGovBasketModule::class + ] +) +@ScreenScope +interface TinderGovBasketComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): TinderGovBasketComponent + } + + fun inject(fragment: TinderGovBasketFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/di/TinderGovBasketModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/di/TinderGovBasketModule.kt new file mode 100644 index 0000000..cdf5c28 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/basket/di/TinderGovBasketModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovBasketInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VotersFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.ReferendumFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.basket.TinderGovBasketViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase + +@Module(includes = [ViewModelModule::class]) +class TinderGovBasketModule { + + @Provides + @IntoMap + @ViewModelKey(TinderGovBasketViewModel::class) + fun provideViewModel( + governanceSharedState: GovernanceSharedState, + router: GovernanceRouter, + interactor: TinderGovInteractor, + votersFormatter: VotersFormatter, + referendumFormatter: ReferendumFormatter, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + resourceManager: ResourceManager, + assetUseCase: AssetUseCase, + basketInteractor: TinderGovBasketInteractor + ): ViewModel { + return TinderGovBasketViewModel( + governanceSharedState = governanceSharedState, + router = router, + interactor = interactor, + basketInteractor = basketInteractor, + votersFormatter = votersFormatter, + referendumFormatter = referendumFormatter, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + resourceManager = resourceManager, + assetUseCase = assetUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): TinderGovBasketViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(TinderGovBasketViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/BasketLabelModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/BasketLabelModel.kt new file mode 100644 index 0000000..7207433 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/BasketLabelModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards + +class BasketLabelModel( + val items: Int, + val backgroundColorRes: Int, + val textColorRes: Int, + val textRes: Int, + val imageTintRes: Int +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/TinderGovCardStackListener.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/TinderGovCardStackListener.kt new file mode 100644 index 0000000..c27bba5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/TinderGovCardStackListener.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards + +import android.view.View +import com.yuyakaido.android.cardstackview.CardStackListener +import com.yuyakaido.android.cardstackview.Direction + +interface TinderGovCardStackListener : CardStackListener { + + override fun onCardDragging(direction: Direction, ratio: Float) {} + + override fun onCardSwiped(direction: Direction) {} + + override fun onCardRewound() {} + + override fun onCardCanceled() {} + + override fun onCardAppeared(view: View?, position: Int) {} + + override fun onCardDisappeared(view: View?, position: Int) {} +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/TinderGovCardsFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/TinderGovCardsFragment.kt new file mode 100644 index 0000000..ad19b16 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/TinderGovCardsFragment.kt @@ -0,0 +1,215 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards + +import android.view.View +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import androidx.core.view.isVisible + +import com.yuyakaido.android.cardstackview.CardStackLayoutManager +import com.yuyakaido.android.cardstackview.CardStackView +import com.yuyakaido.android.cardstackview.Direction +import com.yuyakaido.android.cardstackview.Duration +import com.yuyakaido.android.cardstackview.StackFrom +import com.yuyakaido.android.cardstackview.SwipeAnimationSetting +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.common.view.shape.toColorStateList +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentTinderGovCardsBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.adapter.TinderGovCardRvItem +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.adapter.TinderGovCardsAdapter + +class TinderGovCardsFragment : + BaseFragment(), + TinderGovCardsAdapter.Handler, + TinderGovCardStackListener { + + override fun createBinding() = FragmentTinderGovCardsBinding.inflate(layoutInflater) + + private val adapter = TinderGovCardsAdapter(lifecycleOwner = this, handler = this) + + override fun applyInsets(rootView: View) { + binder.tinderGovCardsStatusBarInsetsContainer.applyStatusBarInsets() + rootView.applyNavigationBarInsets(consume = false) + } + + override fun initViews() { + binder.tinderGovCardsBack.setOnClickListener { viewModel.back() } + binder.tinderGovCardsSettings.setOnClickListener { viewModel.editVotingPowerClicked() } + + binder.tinderGovCardsStack.adapter = adapter + binder.tinderGovCardsStack.itemAnimator = null + + binder.tinderGovCardsStack.layoutManager = CardStackLayoutManager(requireContext(), this) + .apply { + setStackFrom(StackFrom.Bottom) + setDirections(listOf(Direction.Left, Direction.Right, Direction.Top)) + setVisibleCount(TinderGovCardsViewModel.CARD_STACK_SIZE) + setTranslationInterval(8f) + setScaleInterval(getScaleIntervalForPadding(16.dp)) + setOverlayInterpolator(CardsOverlayInterpolator(delay = 0.15f, maxResult = 0.64f)) + } + + binder.tinderGovCardsControlView.setAyeClickListener { swipeCardToDirection(Direction.Right) } + binder.tinderGovCardsControlView.setAbstainClickListener { swipeCardToDirection(Direction.Top) } + binder.tinderGovCardsControlView.setNayClickListener { swipeCardToDirection(Direction.Left) } + + binder.tinderGovCardsBasketButton.setOnClickListener { viewModel.onBasketClicked() } + binder.tinderGovCardsEmptyStateButton.setOnClickListener { viewModel.onBasketClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .tinderGovCardsFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: TinderGovCardsViewModel) { + viewModel.referendumCounterFlow.observe { + binder.tinderGovCardsSubtitle.text = it + } + + viewModel.cardsFlow.observe { adapter.submitList(it) } + + viewModel.placeholderTextFlow.observe { + binder.tinderGovCardsEmptyStateDescription.text = it + } + + viewModel.showConfirmButtonFlow.observe { + binder.tinderGovCardsEmptyStateButton.isVisible = it + } + + viewModel.skipCardEvent.observeEvent { + swipeCardToDirection(Direction.Bottom, forced = true) + } + + viewModel.rewindCardEvent.observeEvent { + binder.tinderGovCardsStack.rewind() + } + + viewModel.resetCardsEvent.observeEvent { + binder.tinderGovCardsStack.cardLayoutManager().topPosition = 0 + } + + viewModel.isCardDraggingAvailable.observe { draggingAvailable -> + binder.tinderGovCardsStack.cardLayoutManager() + .apply { + setCanScrollHorizontal(draggingAvailable) + setCanScrollVertical(draggingAvailable) + } + } + + viewModel.basketModelFlow.observe { + binder.tinderGovCardsBasketItems.text = it.items.toString() + binder.tinderGovCardsBasketItems.setTextColorRes(it.textColorRes) + binder.tinderGovCardsBasketItems.backgroundTintList = requireContext().getColor(it.backgroundColorRes).toColorStateList() + + binder.tinderGovCardsBasketState.setText(it.textRes) + binder.tinderGovCardsBasketState.setTextColorRes(it.textColorRes) + + binder.tinderGovCardsBasketChevron.setImageTintRes(it.imageTintRes) + } + + viewModel.insufficientBalanceChangeAction.awaitableActionLiveData.observeEvent { + warningDialog( + requireContext(), + onPositiveClick = { it.onSuccess(true) }, + onNegativeClick = { it.onSuccess(false) }, + positiveTextRes = R.string.common_change, + negativeTextRes = R.string.common_close, + styleRes = R.style.AccentAlertDialogTheme + ) { + setTitle(it.payload.first) + setMessage(it.payload.second) + } + } + + viewModel.retryReferendumInfoLoadingAction.awaitableActionLiveData.observeEvent { + warningDialog( + requireContext(), + onPositiveClick = { it.onSuccess(true) }, + onNegativeClick = { it.onSuccess(false) }, + positiveTextRes = R.string.common_retry, + negativeTextRes = R.string.common_skip, + styleRes = R.style.AccentAlertDialogTheme + ) { + setTitle(R.string.swipe_gov_card_data_loading_error_title) + setMessage(R.string.swipe_gov_card_data_loading_error_message) + } + } + + viewModel.hasReferendaToVote.observe { + binder.tinderGovCardsSettings.isVisible = it + binder.tinderGovCardsControlView.setVisible(it, falseState = View.INVISIBLE) + + // To avoid click if referenda cards is empty + binder.tinderGovCardsStack.isEnabled = it + } + } + + private fun getScaleIntervalForPadding(desiredPadding: Int): Float { + val screenWidth = resources.displayMetrics.widthPixels + val cardsPadding = binder.tinderGovCardsStack.paddingStart + binder.tinderGovCardsStack.paddingEnd + val cardWidth = screenWidth - cardsPadding + val nextCardWidth = cardWidth - desiredPadding * 2 + return nextCardWidth.toFloat() / cardWidth.toFloat() + } + + override fun onReadMoreClicked(item: TinderGovCardRvItem) { + viewModel.openReadMore(item) + } + + override fun onCardAppeared(view: View?, position: Int) { + viewModel.onCardAppeared(position) + } + + override fun onCardSwiped(direction: Direction) { + val topPosition = binder.tinderGovCardsStack.cardLayoutManager().topPosition + val swipedPosition = topPosition - 1 + when (direction) { + Direction.Left -> viewModel.nayClicked(swipedPosition) + Direction.Right -> viewModel.ayeClicked(swipedPosition) + Direction.Top -> viewModel.abstainClicked(swipedPosition) + Direction.Bottom -> {} + } + } + + private fun swipeCardToDirection(direction: Direction, forced: Boolean = false) { + val layoutManager = binder.tinderGovCardsStack.cardLayoutManager() + if (forced || layoutManager.canScrollVertically() && layoutManager.canScrollHorizontally()) { + val setting = SwipeAnimationSetting.Builder() + .setDirection(direction) + .setDuration(Duration.Normal.duration) + .setInterpolator(AccelerateInterpolator()) + .build() + layoutManager.setSwipeAnimationSetting(setting) + binder.tinderGovCardsStack.swipe() + } + } + + private fun CardStackView.cardLayoutManager(): CardStackLayoutManager { + return layoutManager as CardStackLayoutManager + } +} + +class CardsOverlayInterpolator(private val delay: Float, private val maxResult: Float) : DecelerateInterpolator() { + + override fun getInterpolation(input: Float): Float { + val realInput = (input - delay).coerceAtLeast(0f) + val result = super.getInterpolation(realInput) + + return (result * maxResult).coerceAtMost(maxResult) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/TinderGovCardsViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/TinderGovCardsViewModel.kt new file mode 100644 index 0000000..bada917 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/TinderGovCardsViewModel.kt @@ -0,0 +1,368 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingOrDenyingAction +import io.novafoundation.nova.common.navigation.awaitResponse +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.formatTokenAmount +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.onEachWithPrevious +import io.novafoundation.nova.common.utils.sendEvent +import io.novafoundation.nova.feature_governance_api.data.model.TinderGovBasketItem +import io.novafoundation.nova.feature_governance_api.data.model.VotingPower +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VoteType +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.amountMultiplier +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumPreview +import io.novafoundation.nova.feature_governance_impl.domain.summary.ReferendaSummaryInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovBasketInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.VotingPowerState +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.info.ReferendumInfoPayload +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.TinderGovVoteRequester +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.adapter.TinderGovCardRvItem +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.model.CardWithDetails +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.model.ReferendaCounterModel +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.model.ReferendaWithBasket +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.getCurrentAsset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.fullId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class TinderGovCardsViewModel( + private val router: GovernanceRouter, + private val interactor: TinderGovInteractor, + private val basketInteractor: TinderGovBasketInteractor, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val tinderGovVoteRequester: TinderGovVoteRequester, + private val assetUseCase: AssetUseCase, + private val resourceManager: ResourceManager, + private val referendaSummaryInteractor: ReferendaSummaryInteractor, + private val tokenUseCase: TokenUseCase, + private val cardsMarkdown: Markwon, + private val amountFormatter: AmountFormatter +) : BaseViewModel() { + + companion object { + const val CARD_STACK_SIZE = 3 + } + + private val cardsBackground = createCardsBackground() + + private val topCardIndex = MutableStateFlow(0) + private val sortedReferendaFlow = MutableStateFlow(listOf()) + + private val basketFlow = basketInteractor.observeTinderGovBasket() + .map { it.associateBy { it.referendumId } } + .shareInBackground() + + val cardsFlow = sortedReferendaFlow.map { mapCards(it) } + + val retryReferendumInfoLoadingAction = actionAwaitableMixinFactory.confirmingOrDenyingAction() + + val insufficientBalanceChangeAction = actionAwaitableMixinFactory.confirmingOrDenyingAction() + + private val _skipCardEvent = MutableLiveData>() + val skipCardEvent: LiveData> = _skipCardEvent + + private val _rewindCardEvent = MutableLiveData>() + val rewindCardEvent: LiveData> = _rewindCardEvent + + private val _resetCards = MutableLiveData>() + val resetCardsEvent: LiveData> = _resetCards + + private val topCardFlow = combine(topCardIndex, sortedReferendaFlow) { topCardIndex, referenda -> + referenda.getOrNull(topCardIndex) ?: return@combine null + } + .distinctUntilChanged() + .shareInBackground() + + private var isVotingInProgress = MutableStateFlow(false) + + val isCardDraggingAvailable = isVotingInProgress.map { !it } + + val basketModelFlow = basketFlow + .map { items -> mapBasketModel(items.values.toList()) } + + private val votingReferendaCounterFlow = combine(basketFlow, sortedReferendaFlow) { basket, referenda -> + val currentBasketSize = referenda.count { it.id in basket } + ReferendaCounterModel(currentBasketSize, referenda.size) + }.shareInBackground() + + val referendumCounterFlow = votingReferendaCounterFlow.map { + if (it.hasReferendaToVote()) { + val currentItemIndex = it.remainingReferendaToVote + resourceManager.getString(R.string.swipe_gov_cards_counter, currentItemIndex) + } else { + resourceManager.getString(R.string.swipe_gov_cards_no_referenda_to_vote) + } + } + + val hasReferendaToVote = votingReferendaCounterFlow.map { it.hasReferendaToVote() } + .distinctUntilChanged() + + val placeholderTextFlow = basketFlow.map { + if (it.isEmpty()) { + resourceManager.getString(R.string.swipe_gov_card_placeholder_basket_empty_text) + } else { + resourceManager.getString(R.string.swipe_gov_card_placeholder_basket_full_text) + } + } + .distinctUntilChanged() + + val showConfirmButtonFlow = basketFlow.map { it.isNotEmpty() } + .distinctUntilChanged() + + init { + observeReferendaAndAddToCards() + + loadFirstCards() + } + + fun back() { + router.back() + } + + fun ayeClicked(position: Int) = writeVote(position, VoteType.AYE) + + fun abstainClicked(position: Int) = writeVote(position, VoteType.ABSTAIN) + + fun nayClicked(position: Int) = writeVote(position, VoteType.NAY) + + fun openReadMore(item: TinderGovCardRvItem) { + router.openReferendumInfo(ReferendumInfoPayload(item.id.value)) + } + + fun onCardAppeared(position: Int) { + topCardIndex.value = position + } + + fun onBasketClicked() { + launch { + if (basketFlow.first().isEmpty()) return@launch + + router.openTinderGovBasket() + } + } + + fun editVotingPowerClicked() { + launch { + val topReferendum = topCardFlow.first() + val topReferendumId = topReferendum?.id?.value ?: return@launch + val request = TinderGovVoteRequester.Request(topReferendumId) + tinderGovVoteRequester.openRequest(request) + } + } + + private fun loadFirstCards() { + launch { + sortedReferendaFlow + .filter { it.isNotEmpty() } + .first() // Await while list of cards will be not empty + + onCardAppeared(0) + } + } + + private fun writeVote(position: Int, voteType: VoteType) { + if (isVotingInProgress.value) return + isVotingInProgress.value = true + + launch { + val referendum = sortedReferendaFlow.value.getOrNull(position) + + if (referendum == null) { + isVotingInProgress.value = false + return@launch + } + + val isSufficientAmount = tryEnsureSufficientAmountForVote(referendum.id) + + if (isSufficientAmount) { + basketInteractor.addItemToBasket(referendum.id, voteType) + checkAllReferendaWasVotedAndOpenBasket() + } else { + _rewindCardEvent.sendEvent() + } + + isVotingInProgress.value = false + } + } + + private suspend fun tryEnsureSufficientAmountForVote(referendumId: ReferendumId): Boolean { + return when (val votingPowerState = interactor.getVotingPowerState()) { + VotingPowerState.Empty -> openSetVotingPowerScreen(referendumId) + + is VotingPowerState.InsufficientAmount -> showInsufficientBalanceDialog(referendumId, votingPowerState.votingPower) + + is VotingPowerState.SufficientAmount -> true + } + } + + private suspend fun openSetVotingPowerScreen(referendumId: ReferendumId): Boolean { + val response = tinderGovVoteRequester.awaitResponse(TinderGovVoteRequester.Request(referendumId.value)) + + return response.success + } + + private suspend fun showInsufficientBalanceDialog(referendumId: ReferendumId, votingPower: VotingPower): Boolean { + val asset = assetUseCase.getCurrentAsset() + val chainAsset = asset.token.configuration + + val amount = chainAsset.amountFromPlanks(votingPower.amount) + val formattedAmount = amount.formatTokenAmount(chainAsset.symbol) + val conviction = votingPower.conviction.amountMultiplier().format() + + val title = resourceManager.getString(R.string.swipe_gov_insufficient_balance_dialog_title) + val message = resourceManager.getString(R.string.swipe_gov_insufficient_balance_dialog_message, formattedAmount, conviction) + val openChangeVotingPowerScreen = insufficientBalanceChangeAction.awaitAction(TitleAndMessage(title, message)) + + return if (openChangeVotingPowerScreen) { + openSetVotingPowerScreen(referendumId) + } else { + false + } + } + + private suspend fun checkAllReferendaWasVotedAndOpenBasket() { + val basket = basketInteractor.getTinderGovBasket() + .associateBy { it.referendumId } + val referenda = sortedReferendaFlow.value + + val allReferendaVoted = referenda.all { it.id in basket } + if (allReferendaVoted) { + router.openTinderGovBasket() + } + } + + private fun mapReferendumToUi( + referendumPreview: CardWithDetails, + backgroundRes: Int + ): TinderGovCardRvItem { + return TinderGovCardRvItem( + referendumPreview.id, + summary = cardsMarkdown.toMarkdown(referendumPreview.summary), + requestedAmount = referendumPreview.amount, + backgroundRes = backgroundRes, + ) + } + + private fun mapCards( + sortedReferenda: List + ): List { + return sortedReferenda.mapIndexed { index, referendum -> + val backgroundRes = cardsBackground[index % cardsBackground.size] + + mapReferendumToUi(referendum, backgroundRes) + } + } + + private fun mapBasketModel(items: List): BasketLabelModel { + return BasketLabelModel( + items = items.size, + backgroundColorRes = if (items.isEmpty()) R.color.icon_inactive else R.color.icon_accent, + textColorRes = if (items.isEmpty()) R.color.button_text_inactive else R.color.text_primary, + textRes = if (items.isEmpty()) R.string.swipe_gov_cards_voting_list_empty else R.string.swipe_gov_cards_full_list, + imageTintRes = if (items.isEmpty()) R.color.icon_inactive else R.color.chip_icon, + ) + } + + private fun observeReferendaAndAddToCards() { + combine(interactor.observeReferendaAvailableToVote(this), basketFlow) { referenda, basket -> + val referendaIds = referenda.map { it.id } + val summaries = referendaSummaryInteractor.getReferendaSummaries(referendaIds, viewModelScope) + val amounts = referenda.associate { it.id to it.getAmountModel() } + + val referendaCards = referenda.mapToCardsAndFilterBySummaries(summaries, amounts) + + ReferendaWithBasket(referendaCards, basket) + } + .addNewReferendaToCards() + .launchIn(this) + } + + private fun List.mapToCardsAndFilterBySummaries( + summaries: Map, + amounts: Map + ): List { + return mapNotNull { + val summary = summaries[it.id] ?: return@mapNotNull null + + CardWithDetails(it.id, summary, amounts[it.id]) + } + } + + private suspend fun ReferendumPreview.getAmountModel(): AmountModel? { + val treasuryRequest = interactor.getReferendumAmount(this) + return treasuryRequest?.let { + val token = tokenUseCase.getToken(treasuryRequest.chainAsset.fullId) + amountFormatter.formatAmountToAmountModel(treasuryRequest.amount, token) + } + } + + private fun Flow.addNewReferendaToCards(): Flow { + return onEachWithPrevious { old, new -> + val oldBasket = old?.basket.orEmpty() + val newReferenda = new.referenda + val newBasket = new.basket + + val currentReferenda = sortedReferendaFlow.value + val currentReferendaIds = currentReferenda.map { it.id }.toMutableSet() + + val isBasketItemRemoved = oldBasket.size > newBasket.size + + val result = if (isBasketItemRemoved) { + newReferenda.filter { it.id !in newBasket } // Take only referenda that are not in basket + } else { + val newComingReferenda = newReferenda.filter { it.id !in currentReferendaIds && it.id !in newBasket } + currentReferenda + newComingReferenda // Take old referenda and new coming referenda + } + + sortedReferendaFlow.value = result + + if (isBasketItemRemoved) { + _resetCards.sendEvent() + loadFirstCards() + } + } + } + + private fun createCardsBackground(): List { + return listOf( + R.drawable.tinder_gov_card_background_1, + R.drawable.tinder_gov_card_background_2, + R.drawable.tinder_gov_card_background_3, + R.drawable.tinder_gov_card_background_4, + R.drawable.tinder_gov_card_background_5, + R.drawable.tinder_gov_card_background_6, + R.drawable.tinder_gov_card_background_5, + R.drawable.tinder_gov_card_background_4, + R.drawable.tinder_gov_card_background_3, + R.drawable.tinder_gov_card_background_2, + R.drawable.tinder_gov_card_background_1, + R.drawable.tinder_gov_card_background_0, + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/adapter/TinderGovCardRvItem.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/adapter/TinderGovCardRvItem.kt new file mode 100644 index 0000000..c047e5f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/adapter/TinderGovCardRvItem.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.adapter + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +data class TinderGovCardRvItem( + val id: ReferendumId, + val summary: CharSequence?, + val requestedAmount: AmountModel?, + @DrawableRes val backgroundRes: Int +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/adapter/TinderGovCardsAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/adapter/TinderGovCardsAdapter.kt new file mode 100644 index 0000000..6da2d76 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/adapter/TinderGovCardsAdapter.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.adapter + +import android.view.ViewGroup +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.feature_governance_impl.databinding.ItemTinderGovCardBinding +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.adapter.TinderGovCardsAdapter.Handler + +class TinderGovCardsAdapter( + private val lifecycleOwner: LifecycleOwner, + private val handler: Handler +) : ListAdapter(TinderGovCardsDiffCallback()) { + + interface Handler { + fun onReadMoreClicked(item: TinderGovCardRvItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TinderGovCardViewHolder { + return TinderGovCardViewHolder(handler, lifecycleOwner, ItemTinderGovCardBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: TinderGovCardViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +class TinderGovCardViewHolder( + private val handler: Handler, + lifecycleOwner: LifecycleOwner, + private val binder: ItemTinderGovCardBinding +) : GroupedListHolder(binder.root) { + + init { + binder.tinderGovCardReadMore.prepareForProgress(lifecycleOwner) + } + + fun bind(item: TinderGovCardRvItem) { + binder.itemTinderGovCardSummary.text = item.summary + binder.itemTinderGovCardAmountContainer.letOrHide(item.requestedAmount) { + binder.itemTinderGovCardRequestedAmount.setTextOrHide(it.token) + binder.itemTinderGovCardRequestedFiat.setTextOrHide(it.fiat) + } + + binder.tinderGovCardContainer.setBackgroundResource(item.backgroundRes) + + binder.tinderGovCardReadMore.setOnClickListener { handler.onReadMoreClicked(item) } + } +} + +private class TinderGovCardsDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: TinderGovCardRvItem, newItem: TinderGovCardRvItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: TinderGovCardRvItem, newItem: TinderGovCardRvItem): Boolean { + return oldItem == newItem + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/di/TinderGovCardsComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/di/TinderGovCardsComponent.kt new file mode 100644 index 0000000..152a8ec --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/di/TinderGovCardsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.TinderGovCardsFragment + +@Subcomponent( + modules = [ + TinderGovCardsModule::class + ] +) +@ScreenScope +interface TinderGovCardsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): TinderGovCardsComponent + } + + fun inject(fragment: TinderGovCardsFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/di/TinderGovCardsModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/di/TinderGovCardsModule.kt new file mode 100644 index 0000000..4d6dcba --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/di/TinderGovCardsModule.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.di + +import android.content.Context +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.markdown.BoldStylePlugin +import io.novafoundation.nova.feature_governance_impl.domain.summary.ReferendaSummaryInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovBasketInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.tindergov.TinderGovVoteCommunicator +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.TinderGovCardsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class TinderGovCardsModule { + + @Provides + @ScreenScope + fun provideMarkwon(context: Context): Markwon { + return Markwon.builder(context) + .usePlugin(BoldStylePlugin(context, R.font.public_sans_semi_bold, R.color.text_primary)) + .build() + } + + @Provides + @IntoMap + @ViewModelKey(TinderGovCardsViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + tinderGovInteractor: TinderGovInteractor, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + tinderGovVoteCommunicator: TinderGovVoteCommunicator, + resourceManager: ResourceManager, + assetUseCase: AssetUseCase, + referendaSummaryInteractor: ReferendaSummaryInteractor, + tokenUseCase: TokenUseCase, + basketInteractor: TinderGovBasketInteractor, + markwon: Markwon, + amountFormatter: AmountFormatter + ): ViewModel { + return TinderGovCardsViewModel( + router = router, + interactor = tinderGovInteractor, + basketInteractor = basketInteractor, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + tinderGovVoteRequester = tinderGovVoteCommunicator, + assetUseCase = assetUseCase, + resourceManager = resourceManager, + referendaSummaryInteractor = referendaSummaryInteractor, + tokenUseCase = tokenUseCase, + cardsMarkdown = markwon, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): TinderGovCardsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(TinderGovCardsViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/model/CardWithDetails.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/model/CardWithDetails.kt new file mode 100644 index 0000000..8f0607f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/model/CardWithDetails.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.model + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +data class CardWithDetails( + val id: ReferendumId, + val summary: String, + val amount: AmountModel? +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/model/ReferendaCounterModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/model/ReferendaCounterModel.kt new file mode 100644 index 0000000..d03894f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/model/ReferendaCounterModel.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.model + +class ReferendaCounterModel( + val itemsInBasket: Int, + val referendaSize: Int +) { + + val remainingReferendaToVote: Int + get() = referendaSize - itemsInBasket + + fun hasReferendaToVote(): Boolean { + return referendaSize > itemsInBasket + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/model/ReferendaWithBasket.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/model/ReferendaWithBasket.kt new file mode 100644 index 0000000..12dfcb7 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/cards/model/ReferendaWithBasket.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.cards.model + +import io.novafoundation.nova.feature_governance_api.data.model.TinderGovBasketItem +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId + +data class ReferendaWithBasket( + val referenda: List, + val basket: Map +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/ConfirmTinderGovVoteFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/ConfirmTinderGovVoteFragment.kt new file mode 100644 index 0000000..53340cd --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/ConfirmTinderGovVoteFragment.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.confirm + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.common.confirmVote.ConfirmVoteFragment + +class ConfirmTinderGovVoteFragment : ConfirmVoteFragment() { + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .confirmTinderGovVoteFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmTinderGovVoteViewModel) { + super.subscribe(viewModel) + + observeRetries(viewModel.partialRetriableMixin) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/ConfirmTinderGovVoteViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/ConfirmTinderGovVoteViewModel.kt new file mode 100644 index 0000000..0fb82f3 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/ConfirmTinderGovVoteViewModel.kt @@ -0,0 +1,188 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.submissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_governance_api.data.model.TinderGovBasketItem +import io.novafoundation.nova.feature_governance_api.data.model.accountVote +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovBasketInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.tindergov.VoteTinderGovValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.tindergov.VoteTinderGovValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.tindergov.handleVoteTinderGovValidationFailure +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.common.confirmVote.ConfirmVoteViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.hints.ReferendumVoteHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class ConfirmTinderGovVoteViewModel( + private val router: GovernanceRouter, + private val feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val externalActions: ExternalActions.Presentation, + private val governanceSharedState: GovernanceSharedState, + private val hintsMixinFactory: ReferendumVoteHintsMixinFactory, + private val walletUiUseCase: WalletUiUseCase, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val addressIconGenerator: AddressIconGenerator, + private val interactor: VoteReferendumInteractor, + private val assetUseCase: AssetUseCase, + private val validationSystem: VoteTinderGovValidationSystem, + private val validationExecutor: ValidationExecutor, + private val resourceManager: ResourceManager, + private val locksChangeFormatter: LocksChangeFormatter, + private val tinderGovInteractor: TinderGovInteractor, + private val tinderGovBasketInteractor: TinderGovBasketInteractor, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + partialRetriableMixinFactory: PartialRetriableMixin.Factory, + private val amountFormatter: AmountFormatter +) : ConfirmVoteViewModel( + router, + feeLoaderMixinFactory, + externalActions, + governanceSharedState, + hintsMixinFactory, + walletUiUseCase, + selectedAccountUseCase, + addressIconGenerator, + assetUseCase, + validationExecutor +), + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val basketFlow = tinderGovBasketInteractor.observeTinderGovBasket() + .map { it.associateBy { it.referendumId } } + .shareInBackground() + + private val votesFlow = basketFlow.map { basket -> + basket.mapValues { it.value.accountVote() } + }.shareInBackground() + + override val titleFlow: Flow = basketFlow.map { + resourceManager.getString(R.string.swipe_gov_vote_setup_title, it.size) + }.shareInBackground() + + override val amountModelFlow: Flow = combine(assetFlow, basketFlow) { asset, basket -> + val maxAmount = basket.values.maxOfOrNull { it.amount } ?: BigInteger.ZERO + amountFormatter.formatAmountToAmountModel(maxAmount, asset) + }.shareInBackground() + + override val accountVoteUi = flowOf { null } + + private val voteAssistantFlow = basketFlow.flatMapLatest { basketItems -> + interactor.voteAssistantFlow(basketItems.keys.toList(), viewModelScope) + }.shareInBackground() + + private val locksChangeFlow = combine(votesFlow, voteAssistantFlow) { accountVotes, voteAssistant -> + val asset = assetFlow.first() + + voteAssistant.estimateLocksAfterVoting(accountVotes, asset) + } + + override val locksChangeUiFlow = locksChangeFlow.map { + locksChangeFormatter.mapLocksChangeToUi(it, assetFlow.first()) + } + .shareInBackground() + + val partialRetriableMixin = partialRetriableMixinFactory.create(scope = this) + + init { + launch { + originFeeMixin.loadFeeSuspending( + retryScope = this, + feeConstructor = { interactor.estimateFee(votesFlow.first()) }, + onRetryCancelled = router::back + ) + } + } + + override fun confirmClicked() { + launch { + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = getValidationPayload(), + validationFailureTransformerCustom = { status, actions -> + handleVoteTinderGovValidationFailure(status.reason, actions, resourceManager) + }, + progressConsumer = _showNextProgress.progressConsumer(), + ) { + performVote(it) + } + } + } + + private fun performVote(payload: VoteTinderGovValidationPayload) = launch { + val accountVotes = payload.basket.associateBy { it.referendumId } + .mapValues { (_, value) -> value.accountVote() } + + val result = withContext(Dispatchers.Default) { + interactor.voteReferenda(accountVotes) + } + + partialRetriableMixin.handleMultiResult( + multiResult = result, + onSuccess = { + startNavigation(it.submissionHierarchy()) { onVoteSuccess(payload.basket) } + }, + progressConsumer = _showNextProgress.progressConsumer(), + onRetryCancelled = { router.back() } + ) + } + + private suspend fun onVoteSuccess(basket: List) { + awaitVotedReferendaStateUpdate(basket) + + showToast(resourceManager.getString(R.string.swipe_gov_convirm_votes_success_message, basket.size)) + tinderGovBasketInteractor.clearBasket() + router.backToTinderGovCards() + } + + private suspend fun awaitVotedReferendaStateUpdate(basket: List) { + tinderGovInteractor.awaitAllItemsVoted(coroutineScope, basket) + } + + private suspend fun getValidationPayload(): VoteTinderGovValidationPayload { + val voteAssistant = voteAssistantFlow.first() + val basket = basketFlow.first().values.toList() + val asset = assetFlow.first() + val maxAvailablePlanks = interactor.maxAvailableForVote(asset) + val maxAvailableAmount = asset.token.amountFromPlanks(maxAvailablePlanks) + + return VoteTinderGovValidationPayload( + onChainReferenda = voteAssistant.onChainReferenda, + asset = asset, + trackVoting = voteAssistant.trackVoting, + fee = originFeeMixin.awaitFee(), + basket = basket, + maxAvailableAmount = maxAvailableAmount + ) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/di/ConfirmTinderGovVoteComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/di/ConfirmTinderGovVoteComponent.kt new file mode 100644 index 0000000..26a5914 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/di/ConfirmTinderGovVoteComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.confirm.ConfirmTinderGovVoteFragment + +@Subcomponent( + modules = [ + ConfirmTinderGovVoteModule::class + ] +) +@ScreenScope +interface ConfirmTinderGovVoteComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): ConfirmTinderGovVoteComponent + } + + fun inject(fragment: ConfirmTinderGovVoteFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/di/ConfirmTinderGovVoteModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/di/ConfirmTinderGovVoteModule.kt new file mode 100644 index 0000000..7ae7c83 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tindergov/confirm/di/ConfirmTinderGovVoteModule.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tindergov.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.domain.referendum.vote.VoteReferendumInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovBasketInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.tindergov.TinderGovInteractor +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.tindergov.VoteTinderGovValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.referendum.vote.validations.tindergov.voteTinderGovValidationSystem +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.hints.ReferendumVoteHintsMixinFactory +import io.novafoundation.nova.feature_governance_impl.presentation.tindergov.confirm.ConfirmTinderGovVoteViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmTinderGovVoteModule { + + @Provides + fun provideValidationSystem( + governanceSourceRegistry: GovernanceSourceRegistry, + governanceSharedState: GovernanceSharedState, + ): VoteTinderGovValidationSystem = ValidationSystem.voteTinderGovValidationSystem(governanceSourceRegistry, governanceSharedState) + + @Provides + @IntoMap + @ViewModelKey(ConfirmTinderGovVoteViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + externalActions: ExternalActions.Presentation, + governanceSharedState: GovernanceSharedState, + hintsMixinFactory: ReferendumVoteHintsMixinFactory, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + addressIconGenerator: AddressIconGenerator, + interactor: VoteReferendumInteractor, + assetUseCase: AssetUseCase, + validationSystem: VoteTinderGovValidationSystem, + validationExecutor: ValidationExecutor, + resourceManager: ResourceManager, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + locksChangeFormatter: LocksChangeFormatter, + tinderGovInteractor: TinderGovInteractor, + tinderGovBasketInteractor: TinderGovBasketInteractor, + partialRetriableMixinFactory: PartialRetriableMixin.Factory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmTinderGovVoteViewModel( + router = router, + feeLoaderMixinFactory = feeLoaderMixinFactory, + externalActions = externalActions, + governanceSharedState = governanceSharedState, + hintsMixinFactory = hintsMixinFactory, + walletUiUseCase = walletUiUseCase, + selectedAccountUseCase = selectedAccountUseCase, + addressIconGenerator = addressIconGenerator, + interactor = interactor, + assetUseCase = assetUseCase, + validationSystem = validationSystem, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + locksChangeFormatter = locksChangeFormatter, + tinderGovInteractor = tinderGovInteractor, + tinderGovBasketInteractor = tinderGovBasketInteractor, + partialRetriableMixinFactory = partialRetriableMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ConfirmTinderGovVoteViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmTinderGovVoteViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/TrackDelegationModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/TrackDelegationModel.kt new file mode 100644 index 0000000..817df97 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/TrackDelegationModel.kt @@ -0,0 +1,9 @@ + +package io.novafoundation.nova.feature_governance_impl.presentation.track + +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteModel + +class TrackDelegationModel( + val track: TrackModel, + val delegation: VoteModel +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/TrackFormatter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/TrackFormatter.kt new file mode 100644 index 0000000..36a475b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/TrackFormatter.kt @@ -0,0 +1,130 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.track + +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.resources.formatListPreview +import io.novafoundation.nova.common.utils.capitalize +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.asIcon +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_governance_api.domain.referendum.track.category.TrackType +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.track.category.TrackCategorizer +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface TrackFormatter { + + fun formatTrack(track: Track, asset: Chain.Asset): TrackModel + + fun formatTracksSummary(tracks: Collection, asset: Chain.Asset): String +} + +fun TrackFormatter.formatTracks(tracks: List, asset: Chain.Asset): TracksModel { + val trackModels = tracks.map { formatTrack(it, asset) } + val overview = formatTracksSummary(tracks, asset) + + return TracksModel(trackModels, overview) +} + +class RealTrackFormatter( + private val trackCategorizer: TrackCategorizer, + private val resourceManager: ResourceManager, + private val assetIconProvider: AssetIconProvider +) : TrackFormatter { + + override fun formatTrack(track: Track, asset: Chain.Asset): TrackModel { + return when (trackCategorizer.typeOf(track.name)) { + TrackType.ROOT -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_root), + icon = assetIconProvider.getAssetIconOrFallback(asset, iconMode = AssetIconMode.WHITE, fallbackIcon = R.drawable.ic_block.asIcon()), + ) + + TrackType.WHITELISTED_CALLER -> TrackModel( + name = resourceManager.getString(R.string.referendum_whitelisted_caller), + icon = Icon.FromDrawableRes(R.drawable.ic_users), + ) + + TrackType.STAKING_ADMIN -> TrackModel( + name = resourceManager.getString(R.string.referendum_staking_admin), + icon = Icon.FromDrawableRes(R.drawable.ic_staking_filled), + ) + + TrackType.TREASURER -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_treasurer), + icon = Icon.FromDrawableRes(R.drawable.ic_gem), + ) + + TrackType.LEASE_ADMIN -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_lease_admin), + icon = Icon.FromDrawableRes(R.drawable.ic_governance_check_to_slot), + ) + + TrackType.FELLOWSHIP_ADMIN -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_fellowship_admin), + icon = Icon.FromDrawableRes(R.drawable.ic_users), + ) + + TrackType.GENERAL_ADMIN -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_general_admin), + icon = Icon.FromDrawableRes(R.drawable.ic_governance_check_to_slot), + ) + + TrackType.AUCTION_ADMIN -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_auction_admin), + icon = Icon.FromDrawableRes(R.drawable.ic_rocket), + ) + + TrackType.REFERENDUM_CANCELLER -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_referendum_canceller), + icon = Icon.FromDrawableRes(R.drawable.ic_governance_check_to_slot), + ) + + TrackType.REFERENDUM_KILLER -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_referendum_killer), + icon = Icon.FromDrawableRes(R.drawable.ic_governance_check_to_slot), + ) + + TrackType.SMALL_TIPPER -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_small_tipper), + icon = Icon.FromDrawableRes(R.drawable.ic_gem), + ) + + TrackType.BIG_TIPPER -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_big_tipper), + icon = Icon.FromDrawableRes(R.drawable.ic_gem), + ) + + TrackType.SMALL_SPEND -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_small_spender), + icon = Icon.FromDrawableRes(R.drawable.ic_gem), + ) + + TrackType.MEDIUM_SPEND -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_medium_spender), + icon = Icon.FromDrawableRes(R.drawable.ic_gem), + ) + + TrackType.BIG_SPEND -> TrackModel( + name = resourceManager.getString(R.string.referendum_track_big_spender), + icon = Icon.FromDrawableRes(R.drawable.ic_gem), + ) + + TrackType.OTHER -> TrackModel( + name = mapUnknownTrackNameToUi(track.name), + icon = Icon.FromDrawableRes(R.drawable.ic_block), + ) + } + } + + override fun formatTracksSummary(tracks: Collection, asset: Chain.Asset): String { + val trackLabels = tracks.map { formatTrack(it, asset).name } + + return resourceManager.formatListPreview(trackLabels) + } + + private fun mapUnknownTrackNameToUi(name: String): String { + return name.replace("_", " ").capitalize() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/TrackModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/TrackModel.kt new file mode 100644 index 0000000..1dc48d1 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/TrackModel.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.track + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.view.NovaChipView + +class TracksModel( + val tracks: List, + val overview: String +) + +class TrackModel(val name: String, val icon: Icon) + +fun NovaChipView.setTrackModel(trackModel: TrackModel?) = letOrHide(trackModel) { track -> + setText(track.name) + setIcon(track.icon) + setIconTint(R.color.chip_icon) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/list/TrackDelegationListBottomSheet.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/list/TrackDelegationListBottomSheet.kt new file mode 100644 index 0000000..487af13 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/list/TrackDelegationListBottomSheet.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.track.list + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ReferentialEqualityDiffCallBack +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ItemTrackDelegationBinding +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackDelegationModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.setTrackModel + +class TrackDelegationListBottomSheet( + context: Context, + data: List, +) : DynamicListBottomSheet(context, Payload(data), ReferentialEqualityDiffCallBack(), onClicked = null) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.delegation_tracks) + } + + override fun holderCreator(): HolderCreator = { + TrackDelegationHolder(ItemTrackDelegationBinding.inflate(it.inflater(), it, false)) + } +} + +class TrackDelegationHolder( + private val binder: ItemTrackDelegationBinding +) : DynamicListSheetAdapter.Holder(binder.root) { + + override fun bind( + item: TrackDelegationModel, + isSelected: Boolean, + handler: DynamicListSheetAdapter.Handler + ) = with(binder) { + itemTrackDelegationTrack.setTrackModel(item.track) + itemTrackDelegationVotesCount.text = item.delegation.votesCount + itemTrackDelegationVotesCountDetails.text = item.delegation.votesCountDetails + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/list/TrackListBottomSheet.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/list/TrackListBottomSheet.kt new file mode 100644 index 0000000..31ec659 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/list/TrackListBottomSheet.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.track.list + +import android.content.Context +import android.os.Bundle +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter.Handler +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ItemTrackBinding +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.setTrackModel + +class TrackListBottomSheet( + context: Context, + data: List, +) : DynamicListBottomSheet(context, Payload(data), AccountDiffCallback, onClicked = null) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.delegation_tracks) + } + + override fun holderCreator(): HolderCreator = { + TrackHolder(ItemTrackBinding.inflate(it.inflater(), it, false)) + } +} + +class TrackHolder( + private val binder: ItemTrackBinding +) : DynamicListSheetAdapter.Holder(binder.root) { + + override fun bind(item: TrackModel, isSelected: Boolean, handler: Handler) { + binder.itemTrack.setTrackModel(item) + } +} + +private object AccountDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: TrackModel, newItem: TrackModel): Boolean { + return oldItem.name == newItem.name + } + + override fun areContentsTheSame(oldItem: TrackModel, newItem: TrackModel): Boolean { + return true + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/unavailable/UnavailableTracksAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/unavailable/UnavailableTracksAdapter.kt new file mode 100644 index 0000000..79b2e77 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/unavailable/UnavailableTracksAdapter.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.track.unavailable + +import android.view.ViewGroup +import androidx.core.view.isVisible +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.feature_governance_impl.databinding.ItemUnavailableTrackBinding +import io.novafoundation.nova.feature_governance_impl.databinding.ItemUnavailableTracksGroupBinding +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.track.setTrackModel + +class UnavailableTracksAdapter( + private val handler: Handler +) : GroupedListAdapter(DiffCallback) { + + interface Handler { + fun removeVotesClicked() + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return UnavailableTracksGroupHolder( + ItemUnavailableTracksGroupBinding.inflate(parent.inflater(), parent, false), + handler + ) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return UnavailableTrackHolder(ItemUnavailableTrackBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: UnavailableTracksGroupModel) { + require(holder is UnavailableTracksGroupHolder) + holder.bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: TrackModel) { + require(holder is UnavailableTrackHolder) + holder.bind(child) + } +} + +class UnavailableTracksGroupHolder( + private val binder: ItemUnavailableTracksGroupBinding, + private val removeVotesHandler: UnavailableTracksAdapter.Handler, +) : GroupedListHolder(binder.root) { + + fun bind(item: UnavailableTracksGroupModel) { + with(binder) { + itemUnavailableTrackGroupTitle.setText(item.textRes) + itemUnavailableTrackGroupTitle.updatePadding(bottom = getTitleBottomPadding(item)) + itemUnavailableTrackGroupButton.isVisible = item.showRemoveTracksButton + itemUnavailableTrackGroupButton.setOnClickListener { removeVotesHandler.removeVotesClicked() } + } + } + + private fun getTitleBottomPadding(item: UnavailableTracksGroupModel): Int { + return if (item.showRemoveTracksButton) { + 0 + } else { + 8.dp(itemView.context) + } + } +} + +class UnavailableTrackHolder( + private val binder: ItemUnavailableTrackBinding, +) : GroupedListHolder(binder.root) { + + fun bind(item: TrackModel) { + binder.itemUnavailableTrack.setTrackModel(item) + } +} + +private object DiffCallback : BaseGroupedDiffCallback(UnavailableTracksGroupModel::class.java) { + + override fun areGroupItemsTheSame(oldItem: UnavailableTracksGroupModel, newItem: UnavailableTracksGroupModel): Boolean { + return oldItem.textRes == newItem.textRes + } + + override fun areGroupContentsTheSame(oldItem: UnavailableTracksGroupModel, newItem: UnavailableTracksGroupModel): Boolean { + return true + } + + override fun areChildItemsTheSame(oldItem: TrackModel, newItem: TrackModel): Boolean { + return oldItem.name == newItem.name + } + + override fun areChildContentsTheSame(oldItem: TrackModel, newItem: TrackModel): Boolean { + return true + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/unavailable/UnavailableTracksBottomSheet.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/unavailable/UnavailableTracksBottomSheet.kt new file mode 100644 index 0000000..92bd222 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/unavailable/UnavailableTracksBottomSheet.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.track.unavailable + +import android.content.Context +import android.view.LayoutInflater +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.BottomSheetUnavailableTracksBinding +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackModel + +class UnavailableTracksPayload( + val alreadyVoted: List, + val alreadyDelegated: List +) + +class UnavailableTracksBottomSheet( + context: Context, + private val payload: UnavailableTracksPayload, + private val removeVotesClicked: () -> Unit +) : BaseBottomSheet(context), UnavailableTracksAdapter.Handler { + + override val binder: BottomSheetUnavailableTracksBinding = BottomSheetUnavailableTracksBinding.inflate(LayoutInflater.from(context)) + + private val adapter = UnavailableTracksAdapter(this) + + init { + binder.unavailableTracksList.adapter = adapter + + adapter.submitList(buildList()) + } + + private fun buildList(): List { + return buildList { + if (payload.alreadyDelegated.isNotEmpty()) { + add(UnavailableTracksGroupModel(R.string.unavailable_tracks_delegated_group, showRemoveTracksButton = false)) + addAll(payload.alreadyDelegated) + } + + if (payload.alreadyVoted.isNotEmpty()) { + add(UnavailableTracksGroupModel(R.string.unavailable_tracks_voted_group, showRemoveTracksButton = true)) + addAll(payload.alreadyVoted) + } + } + } + + override fun removeVotesClicked() { + removeVotesClicked.invoke() + dismiss() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/unavailable/UnavailableTracksGroupModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/unavailable/UnavailableTracksGroupModel.kt new file mode 100644 index 0000000..2a01449 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/track/unavailable/UnavailableTracksGroupModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.track.unavailable + +import androidx.annotation.StringRes + +class UnavailableTracksGroupModel( + @StringRes val textRes: Int, + val showRemoveTracksButton: Boolean, +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/BaseSelectTracksFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/BaseSelectTracksFragment.kt new file mode 100644 index 0000000..2f5f90c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/BaseSelectTracksFragment.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base + +import android.graphics.Rect +import androidx.core.view.isVisible +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.RecyclerView + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.list.NestedAdapter +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentSelectTracksBinding +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.adapter.SelectTracksAdapter +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.adapter.SelectTracksPresetsAdapter + +abstract class BaseSelectTracksFragment : + BaseFragment(), + SelectTracksAdapter.Handler, + SelectTracksPresetsAdapter.Handler { + + override fun createBinding() = FragmentSelectTracksBinding.inflate(layoutInflater) + + abstract val headerAdapter: RecyclerView.Adapter<*> + private val presetsAdapter = NestedAdapter( + nestedAdapter = SelectTracksPresetsAdapter(this), + orientation = RecyclerView.HORIZONTAL, + paddingInDp = Rect(12, 8, 12, 12), + disableItemAnimations = true + ) + private val placeholderAdapter = CustomPlaceholderAdapter(R.layout.item_tracks_placeholder) + private val tracksAdapter = SelectTracksAdapter(this) + val adapter by lazy(LazyThreadSafetyMode.NONE) { ConcatAdapter(headerAdapter, presetsAdapter, placeholderAdapter, tracksAdapter) } + + override fun initViews() { + binder.selectTracksList.itemAnimator = null + binder.selectTracksList.adapter = adapter + + binder.selectTracksToolbar.setHomeButtonListener { viewModel.backClicked() } + } + + override fun subscribe(viewModel: V) { + viewModel.trackPresetsModels.observeWhenVisible { + presetsAdapter.show(it.isNotEmpty()) + presetsAdapter.submitList(it) + } + + viewModel.availableTrackModels.observeWhenVisible { + binder.selectTracksProgress.isVisible = it is ExtendedLoadingState.Loading + when (it) { + is ExtendedLoadingState.Error -> {} + is ExtendedLoadingState.Loading -> placeholderAdapter.show(false) + is ExtendedLoadingState.Loaded -> { + placeholderAdapter.show(it.data.isEmpty()) + tracksAdapter.submitList(it.data) + } + } + } + } + + override fun trackClicked(position: Int) { + viewModel.trackClicked(position) + } + + override fun presetClicked(position: Int) { + viewModel.presetClicked(position) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/BaseSelectTracksViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/BaseSelectTracksViewModel.kt new file mode 100644 index 0000000..c86a6be --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/BaseSelectTracksViewModel.kt @@ -0,0 +1,102 @@ +@file:Suppress("LeakingThis") + +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.ChooseTrackData +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.TrackPreset +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.model.DelegationTrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.model.DelegationTracksPresetModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +abstract class BaseSelectTracksViewModel( + private val trackFormatter: TrackFormatter, + private val resourceManager: ResourceManager, + private val router: GovernanceRouter, + chooseTrackDataFlow: Flow +) : BaseViewModel() { + + protected val chooseTrackDataFlowShared = chooseTrackDataFlow + .inBackground() + .shareWhileSubscribed() + + protected val selectedTracksFlow = MutableStateFlow(setOf()) + + private val trackPresetsFlow = chooseTrackDataFlowShared.map { it.presets } + .shareWhileSubscribed() + + protected val availableTrackFlow = chooseTrackDataFlowShared.map { it.trackPartition.available } + .shareWhileSubscribed() + + val trackPresetsModels = trackPresetsFlow + .map(::mapTrackPresets) + .shareWhileSubscribed() + + val availableTrackModels = combine(availableTrackFlow, selectedTracksFlow, ::mapTracksToModel) + .withSafeLoading() + .shareWhileSubscribed() + + abstract suspend fun getChainAsset(): Chain.Asset + + open fun backClicked() { + router.back() + } + + open fun trackClicked(position: Int) { + launch { + val track = availableTrackFlow.first()[position] + val selectedTracks = selectedTracksFlow.value + selectedTracksFlow.value = selectedTracks.toggle(track.id) + } + } + + fun presetClicked(position: Int) { + launch { + val selectedPreset = trackPresetsFlow.first()[position] + selectedTracksFlow.value = selectedPreset.trackIds.toHashSet() + } + } + + private fun mapTrackPresets(trackPresets: List): List { + return trackPresets.map { + DelegationTracksPresetModel( + label = mapPresetTypeToButtonName(it.type), + trackPresetModels = it.type + ) + } + } + + private fun mapPresetTypeToButtonName(trackPresetType: TrackPreset.Type): String { + return when (trackPresetType) { + TrackPreset.Type.ALL -> resourceManager.getString(R.string.delegation_tracks_all_preset) + TrackPreset.Type.FELLOWSHIP -> resourceManager.getString(R.string.delegation_tracks_fellowship_preset) + TrackPreset.Type.TREASURY -> resourceManager.getString(R.string.delegation_tracks_treasury_preset) + TrackPreset.Type.GOVERNANCE -> resourceManager.getString(R.string.delegation_tracks_governance_preset) + } + } + + private suspend fun mapTracksToModel(tracks: List, selectedTracks: Set): List { + return tracks.map { + val trackModel = trackFormatter.formatTrack(it, getChainAsset()) + DelegationTrackModel( + trackModel, + isSelected = it.id in selectedTracks + ) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/adapter/SelectTracksAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/adapter/SelectTracksAdapter.kt new file mode 100644 index 0000000..1794e54 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/adapter/SelectTracksAdapter.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_governance_impl.databinding.ItemDelegationTrackBinding +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.model.DelegationTrackModel + +class SelectTracksAdapter( + private val handler: Handler +) : ListAdapter(DiffCallback) { + + interface Handler { + fun trackClicked(position: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DelegationTrackViewHolder { + return DelegationTrackViewHolder(ItemDelegationTrackBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: DelegationTrackViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: DelegationTrackViewHolder, position: Int, payloads: MutableList) { + resolvePayload(holder, position, payloads) { + when (it) { + DelegationTrackModel::isSelected -> holder.bindSelected(getItem(position)) + } + } + } +} + +private val dAppCategoryPayloadGenerator = PayloadGenerator(DelegationTrackModel::isSelected) + +private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DelegationTrackModel, newItem: DelegationTrackModel): Boolean { + return oldItem.details.name == newItem.details.name + } + + override fun areContentsTheSame(oldItem: DelegationTrackModel, newItem: DelegationTrackModel): Boolean { + return oldItem.isSelected == newItem.isSelected + } + + override fun getChangePayload(oldItem: DelegationTrackModel, newItem: DelegationTrackModel): Any? { + return dAppCategoryPayloadGenerator.diff(oldItem, newItem) + } +} + +class DelegationTrackViewHolder( + private val binder: ItemDelegationTrackBinding, + handler: SelectTracksAdapter.Handler +) : ViewHolder(binder.root) { + + init { + binder.root.setOnClickListener { handler.trackClicked(bindingAdapterPosition) } + } + + fun bind(item: DelegationTrackModel) { + with(binder) { + bindSelected(item) + itemDelegationTrack.setText(item.details.name) + itemDelegationTrack.setIcon(item.details.icon) + } + } + + fun bindSelected(item: DelegationTrackModel) { + with(binder) { + itemDelegationTrackCheckbox.isChecked = item.isSelected + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/adapter/SelectTracksPresetsAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/adapter/SelectTracksPresetsAdapter.kt new file mode 100644 index 0000000..a17d95e --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/adapter/SelectTracksPresetsAdapter.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_governance_impl.databinding.ItemDelegationTracksPresetBinding +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.model.DelegationTracksPresetModel + +class SelectTracksPresetsAdapter( + private val handler: Handler +) : ListAdapter(TracksPresetDiffCallback) { + + interface Handler { + fun presetClicked(position: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DelegationTrackPresetViewHolder { + return DelegationTrackPresetViewHolder(ItemDelegationTracksPresetBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: DelegationTrackPresetViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private object TracksPresetDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DelegationTracksPresetModel, newItem: DelegationTracksPresetModel): Boolean { + return oldItem.label == newItem.label + } + + override fun areContentsTheSame(oldItem: DelegationTracksPresetModel, newItem: DelegationTracksPresetModel): Boolean { + return true + } +} + +class DelegationTrackPresetViewHolder( + private val binder: ItemDelegationTracksPresetBinding, + handler: SelectTracksPresetsAdapter.Handler +) : ViewHolder(binder.root) { + + init { + binder.root.setOnClickListener { handler.presetClicked(bindingAdapterPosition) } + } + + fun bind(item: DelegationTracksPresetModel) { + with(binder) { + itemDelegationTracksPreset.text = item.label + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/model/DelegationTrackModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/model/DelegationTrackModel.kt new file mode 100644 index 0000000..9aa2329 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/model/DelegationTrackModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.model + +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackModel + +data class DelegationTrackModel( + val details: TrackModel, + val isSelected: Boolean +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/model/DelegationTracksHeaderModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/model/DelegationTracksHeaderModel.kt new file mode 100644 index 0000000..1d85f35 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/model/DelegationTracksHeaderModel.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.model + +data class DelegationTracksHeaderModel( + val showUnavailableTracksButton: Boolean +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/model/DelegationTracksPresetModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/model/DelegationTracksPresetModel.kt new file mode 100644 index 0000000..83f2c41 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/base/model/DelegationTracksPresetModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.model + +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.TrackPreset + +data class DelegationTracksPresetModel( + val label: String, + val trackPresetModels: TrackPreset.Type +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/delegationTracks/SelectDelegationTracksFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/delegationTracks/SelectDelegationTracksFragment.kt new file mode 100644 index 0000000..a309e15 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/delegationTracks/SelectDelegationTracksFragment.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks + +import androidx.core.view.isVisible +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.RemoveVotesSuggestionBottomSheet +import io.novafoundation.nova.feature_governance_impl.presentation.track.unavailable.UnavailableTracksBottomSheet +import io.novafoundation.nova.feature_governance_impl.presentation.track.unavailable.UnavailableTracksPayload +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.BaseSelectTracksFragment +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.adapter.SelectTracksAdapter +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks.adapter.SelectTracksHeaderAdapter +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.adapter.SelectTracksPresetsAdapter + +abstract class SelectDelegationTracksFragment : + BaseSelectTracksFragment(), + SelectTracksAdapter.Handler, + SelectTracksHeaderAdapter.Handler, + SelectTracksPresetsAdapter.Handler { + + override val headerAdapter = SelectTracksHeaderAdapter(this) + + override fun initViews() { + super.initViews() + binder.selectTracksApply.isVisible = true + binder.selectTracksApply.setOnClickListener { viewModel.nextClicked() } + } + + override fun subscribe(viewModel: V) { + super.subscribe(viewModel) + headerAdapter.setShowDescription(viewModel.showDescription) + + viewModel.title.observeWhenVisible(headerAdapter::setTitle) + + viewModel.showUnavailableTracksButton.observeWhenVisible { + headerAdapter.showUnavailableTracks(it) + } + + viewModel.buttonState.observeWhenVisible(binder.selectTracksApply::setState) + + viewModel.showRemoveVotesSuggestion.observeEvent { + val bottomSheet = RemoveVotesSuggestionBottomSheet( + context = requireContext(), + votesCount = it, + onApply = viewModel::openRemoveVotesScreen, + onSkip = viewModel::removeVotesSuggestionSkipped + ) + bottomSheet.show() + } + + viewModel.showUnavailableTracksEvent.observeEvent { + val bottomSheet = UnavailableTracksBottomSheet( + requireContext(), + UnavailableTracksPayload( + alreadyVoted = it.alreadyVoted, + alreadyDelegated = it.alreadyDelegated + ), + viewModel::openRemoveVotesScreen + ) + bottomSheet.show() + } + } + + override fun unavailableTracksClicked() { + viewModel.unavailableTracksClicked() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/delegationTracks/SelectDelegationTracksViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/delegationTracks/SelectDelegationTracksViewModel.kt new file mode 100644 index 0000000..805c1a2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/delegationTracks/SelectDelegationTracksViewModel.kt @@ -0,0 +1,134 @@ +@file:Suppress("LeakingThis") + +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.ChooseTrackData +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.model.hasUnavailableTracks +import io.novafoundation.nova.feature_governance_api.domain.track.Track +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.delegation.delegation.removeVotes.RemoveVotesPayload +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackModel +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.BaseSelectTracksViewModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chainAsset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.math.BigInteger + +class UnavailableTracksModel(val alreadyVoted: List, val alreadyDelegated: List) + +abstract class SelectDelegationTracksViewModel( + private val interactor: ChooseTrackInteractor, + private val trackFormatter: TrackFormatter, + private val governanceSharedState: GovernanceSharedState, + private val resourceManager: ResourceManager, + private val router: GovernanceRouter, + chooseTrackDataFlow: Flow +) : BaseSelectTracksViewModel( + trackFormatter = trackFormatter, + resourceManager = resourceManager, + router = router, + chooseTrackDataFlow = chooseTrackDataFlow +) { + + protected abstract fun nextClicked(trackIds: List) + + abstract val title: Flow + + abstract val showDescription: Boolean + + private val _showRemoveVotesSuggestion = MutableLiveData>() + val showRemoveVotesSuggestion: LiveData> = _showRemoveVotesSuggestion + + private val _showUnavailableTracksEvent = MutableLiveData>() + val showUnavailableTracksEvent: LiveData> = _showUnavailableTracksEvent + + val showUnavailableTracksButton = chooseTrackDataFlow + .map { it.trackPartition.hasUnavailableTracks() } + .shareWhileSubscribed() + + val buttonState = selectedTracksFlow + .map { + if (it.isEmpty()) { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.delegation_tracks_disabled_apply_button_text)) + } else { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + } + .shareInBackground() + + init { + checkRemoveVotes() + + applyPreCheckedTracks() + } + + override suspend fun getChainAsset(): Chain.Asset { + return governanceSharedState.chainAsset() + } + + fun unavailableTracksClicked() { + launch { + val tracksPartition = chooseTrackDataFlowShared.first().trackPartition + val alreadyVotedModels = mapTracks(tracksPartition.alreadyVoted) + val alreadyDelegatedModels = mapTracks(tracksPartition.alreadyDelegated) + _showUnavailableTracksEvent.value = Event(UnavailableTracksModel(alreadyVotedModels, alreadyDelegatedModels)) + } + } + + fun nextClicked() = launch { + val selectedTrackIds = selectedTracksFlow.value + nextClicked(selectedTrackIds.map(TrackId::value)) + } + + fun removeVotesSuggestionSkipped() = launch { + interactor.disallowShowRemoveVotesSuggestion() + } + + fun openRemoveVotesScreen() { + launch { + val chooseTrackData = chooseTrackDataFlowShared.first() + val tracksIds = chooseTrackData.trackPartition.alreadyVoted + .map { it.id.value } + val payload = RemoveVotesPayload(tracksIds) + router.openRemoveVotes(payload) + } + } + + private fun applyPreCheckedTracks() { + launch { + val preCheckedTrackIds = chooseTrackDataFlowShared.first().trackPartition.preCheckedTrackIds + + if (preCheckedTrackIds.isNotEmpty()) { + selectedTracksFlow.value = preCheckedTrackIds + } + } + } + + private fun checkRemoveVotes() { + launch { + val chooseTrackData = chooseTrackDataFlowShared.first() + val alreadyVoted = chooseTrackData.trackPartition.alreadyVoted + if (alreadyVoted.isNotEmpty() && interactor.isAllowedToShowRemoveVotesSuggestion()) { + _showRemoveVotesSuggestion.value = Event(alreadyVoted.size) + } + } + } + + private suspend fun mapTracks(tracks: List): List { + val asset = governanceSharedState.chainAsset() + return tracks.map { trackFormatter.formatTrack(it, asset) } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/delegationTracks/adapter/SelectDelegationTracksHeaderAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/delegationTracks/adapter/SelectDelegationTracksHeaderAdapter.kt new file mode 100644 index 0000000..b914b5a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/delegationTracks/adapter/SelectDelegationTracksHeaderAdapter.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_governance_impl.databinding.ItemDelegationTracksHeaderBinding + +class SelectTracksHeaderAdapter( + private val handler: Handler +) : RecyclerView.Adapter() { + + interface Handler { + fun unavailableTracksClicked() + } + + private var showUnavailableTracks: Boolean = false + private var title: String? = null + private var showDescription: Boolean = true + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DelegationTracksHeaderViewHolder { + return DelegationTracksHeaderViewHolder(ItemDelegationTracksHeaderBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun getItemCount(): Int { + return 1 + } + + override fun onBindViewHolder(holder: DelegationTracksHeaderViewHolder, position: Int) { + holder.bind(showUnavailableTracks, title, showDescription) + } + + fun showUnavailableTracks(show: Boolean) { + showUnavailableTracks = show + notifyItemChanged(0) + } + + fun setTitle(title: String) { + this.title = title + notifyItemChanged(0) + } + + fun setShowDescription(show: Boolean) { + showDescription = show + notifyItemChanged(0) + } +} + +class DelegationTracksHeaderViewHolder( + private val binder: ItemDelegationTracksHeaderBinding, + handler: SelectTracksHeaderAdapter.Handler +) : ViewHolder(binder.root) { + + init { + with(binder) { + itemDelegationTracksUnavailableTracksText.setOnClickListener { handler.unavailableTracksClicked() } + } + } + + fun bind(showUnavailableTracks: Boolean, title: String?, showDescription: Boolean) = with(binder) { + itemDelegationTracksUnavailableTracks.setVisible(showUnavailableTracks) + selectDelegationTracksTitle.text = title + itemDelegationTracksDescriptionGroup.setVisible(showDescription) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/SelectGovernanceTracksFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/SelectGovernanceTracksFragment.kt new file mode 100644 index 0000000..3ecff18 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/SelectGovernanceTracksFragment.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.governanceTracks + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksRequester +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.BaseSelectTracksFragment +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks.adapter.SelectGovernanceTracksHeaderAdapter + +class SelectGovernanceTracksFragment : BaseSelectTracksFragment() { + + companion object { + private const val EXTRA_PAYLOAD = "SelectGovernanceTracksFragment.Payload" + + fun getBundle(payload: SelectTracksRequester.Request): Bundle { + return Bundle().apply { + putParcelable(EXTRA_PAYLOAD, payload) + } + } + } + + override val headerAdapter = SelectGovernanceTracksHeaderAdapter() + + override fun initViews() { + super.initViews() + onBackPressed { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ).selectGovernanceTracks() + .create(this, argument(EXTRA_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: SelectGovernanceTracksViewModel) { + super.subscribe(viewModel) + viewModel.chainModel.observe { headerAdapter.setChain(it) } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/SelectGovernanceTracksViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/SelectGovernanceTracksViewModel.kt new file mode 100644 index 0000000..f82df09 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/SelectGovernanceTracksViewModel.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.governanceTracks + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksRequester +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksResponder +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.fromTrackIds +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.toTrackIds +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.base.BaseSelectTracksViewModel +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SelectGovernanceTracksViewModel( + interactor: ChooseTrackInteractor, + trackFormatter: TrackFormatter, + private val resourceManager: ResourceManager, + private val chainRegistry: ChainRegistry, + private val router: GovernanceRouter, + private val payload: SelectTracksRequester.Request, + private val responder: SelectTracksResponder +) : BaseSelectTracksViewModel( + trackFormatter = trackFormatter, + resourceManager = resourceManager, + router = router, + chooseTrackDataFlow = interactor.observeTracksByChain(payload.chainId, payload.governanceType) +) { + + val chainModel = flowOf { chainRegistry.getChain(payload.chainId) } + .map { mapChainToUi(it) } + + init { + selectedTracksFlow.value = payload.selectedTracks.toTrackIds() + } + + override suspend fun getChainAsset(): Chain.Asset { + return chainRegistry.getChain(payload.chainId).utilityAsset + } + + override fun trackClicked(position: Int) { + launch { + val track = availableTrackFlow.first()[position] + val selectedTracks = selectedTracksFlow.value.toggle(track.id) + if (selectedTracks.size >= payload.minTracks) { + super.trackClicked(position) + } else { + showToast( + resourceManager.getQuantityString(R.plurals.governance_select_tracks_min_tracks_error, payload.minTracks, payload.minTracks) + ) + } + } + } + + override fun backClicked() { + launch { + responder.respond(SelectTracksResponder.Response(payload.chainId, payload.governanceType, selectedTracksFlow.value.fromTrackIds())) + + super.backClicked() + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/adapter/SelectGovernanceTracksHeaderAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/adapter/SelectGovernanceTracksHeaderAdapter.kt new file mode 100644 index 0000000..de6c96f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/adapter/SelectGovernanceTracksHeaderAdapter.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.delegationTracks.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_governance_impl.databinding.ItemGovernanceTracksHeaderBinding + +class SelectGovernanceTracksHeaderAdapter : RecyclerView.Adapter() { + + var chainUi: ChainUi? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectGovernanceTracksHeaderViewHolder { + return SelectGovernanceTracksHeaderViewHolder(ItemGovernanceTracksHeaderBinding.inflate(parent.inflater(), parent, false)) + } + + override fun getItemCount(): Int { + return 1 + } + + override fun onBindViewHolder(holder: SelectGovernanceTracksHeaderViewHolder, position: Int) { + holder.bind(chainUi) + } + + fun setChain(chainUi: ChainUi) { + this.chainUi = chainUi + } +} + +class SelectGovernanceTracksHeaderViewHolder(private val binder: ItemGovernanceTracksHeaderBinding) : ViewHolder(binder.root) { + + fun bind(chainUi: ChainUi?) = with(binder) { + selectGovernanceTracksChain.setVisible(chainUi != null) + chainUi?.let { selectGovernanceTracksChain.setChain(chainUi) } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/di/SelectGovernanceTracksComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/di/SelectGovernanceTracksComponent.kt new file mode 100644 index 0000000..ffc48f5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/di/SelectGovernanceTracksComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.governanceTracks.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksRequester +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.governanceTracks.SelectGovernanceTracksFragment + +@Subcomponent( + modules = [ + SelectGovernanceTracksModule::class + ] +) +@ScreenScope +interface SelectGovernanceTracksComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SelectTracksRequester.Request + ): SelectGovernanceTracksComponent + } + + fun inject(fragment: SelectGovernanceTracksFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/di/SelectGovernanceTracksModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/di/SelectGovernanceTracksModule.kt new file mode 100644 index 0000000..a5e81d8 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/tracks/select/governanceTracks/di/SelectGovernanceTracksModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.governanceTracks.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksRequester +import io.novafoundation.nova.feature_governance_api.domain.delegation.delegation.common.chooseTrack.ChooseTrackInteractor +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.track.TrackFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.tracks.select.governanceTracks.SelectGovernanceTracksViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SelectGovernanceTracksModule { + + @Provides + @IntoMap + @ViewModelKey(SelectGovernanceTracksViewModel::class) + fun provideViewModel( + interactor: ChooseTrackInteractor, + trackFormatter: TrackFormatter, + resourceManager: ResourceManager, + router: GovernanceRouter, + payload: SelectTracksRequester.Request, + chainRegistry: ChainRegistry, + selectTracksCommunicator: SelectTracksCommunicator + ): ViewModel { + return SelectGovernanceTracksViewModel( + interactor = interactor, + trackFormatter = trackFormatter, + resourceManager = resourceManager, + router = router, + payload = payload, + responder = selectTracksCommunicator, + chainRegistry = chainRegistry + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): SelectGovernanceTracksViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectGovernanceTracksViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/ConfirmGovernanceUnlockFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/ConfirmGovernanceUnlockFragment.kt new file mode 100644 index 0000000..44b634a --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/ConfirmGovernanceUnlockFragment.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentGovernanceConfirmUnlockBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.setup.common.view.setAmountChangeModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class ConfirmGovernanceUnlockFragment : BaseFragment() { + + override fun createBinding() = FragmentGovernanceConfirmUnlockBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmGovernanceUnlockToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.confirmGovernanceUnlockConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmGovernanceUnlockConfirm.setOnClickListener { viewModel.confirmClicked() } + binder.confirmGovernanceUnlockInformation.setOnAccountClickedListener { viewModel.accountClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .confirmGovernanceUnlockFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmGovernanceUnlockViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.confirmReferendumUnlockHints) + setupFeeLoading(viewModel, binder.confirmGovernanceUnlockInformation.fee) + + viewModel.currentAddressModelFlow.observe(binder.confirmGovernanceUnlockInformation::setAccount) + viewModel.walletModel.observe(binder.confirmGovernanceUnlockInformation::setWallet) + + viewModel.amountModelFlow.observe(binder.confirmReferendumUnlockAmount::setAmount) + + viewModel.transferableChange.observe(binder.confirmReferendumUnlockTransferableChange::setAmountChangeModel) + viewModel.governanceLockChange.observe(binder.confirmReferendumUnlockGovernanceLockChange::setAmountChangeModel) + + viewModel.confirmButtonState.observe(binder.confirmGovernanceUnlockConfirm::setState) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/ConfirmGovernanceUnlockViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/ConfirmGovernanceUnlockViewModel.kt new file mode 100644 index 0000000..1c5f16c --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/ConfirmGovernanceUnlockViewModel.kt @@ -0,0 +1,179 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.Change +import io.novafoundation.nova.feature_governance_api.domain.referendum.common.absoluteDifference +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceUnlockInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.validations.UnlockReferendumValidationPayload +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.validations.UnlockReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.validations.handleUnlockReferendumValidationFailure +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm.hints.ConfirmGovernanceUnlockHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.WithFeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmGovernanceUnlockViewModel( + private val router: GovernanceRouter, + private val externalActions: ExternalActions.Presentation, + private val governanceSharedState: GovernanceSharedState, + private val validationExecutor: ValidationExecutor, + private val interactor: GovernanceUnlockInteractor, + feeMixinFactory: FeeLoaderMixin.Factory, + private val assetUseCase: AssetUseCase, + private val walletUiUseCase: WalletUiUseCase, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val locksChangeFormatter: LocksChangeFormatter, + private val validationSystem: UnlockReferendumValidationSystem, + private val hintsMixinFactory: ConfirmGovernanceUnlockHintsMixinFactory, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + WithFeeLoaderMixin, + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val unlockAffectsFlow = interactor.unlockAffectsFlow( + scope = viewModelScope, + assetFlow = assetFlow + ).shareInBackground() + + override val originFeeMixin = feeMixinFactory.create(assetFlow) + + val hintsMixin = hintsMixinFactory.create( + scope = viewModelScope, + assetFlow = assetFlow, + remainsLockedInfoFlow = unlockAffectsFlow.map { it.remainsLockedInfo } + ) + + val walletModel: Flow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val amountModelFlow = unlockAffectsFlow.map { + val asset = assetFlow.first() + val amount = it.governanceLockChange.absoluteDifference() + + amountFormatter.formatAmountToAmountModel(amount, asset) + }.shareInBackground() + + val currentAddressModelFlow = selectedAccountUseCase.selectedMetaAccountFlow().map { metaAccount -> + val chain = governanceSharedState.chain() + + addressIconGenerator.createAccountAddressModel(chain, metaAccount) + }.shareInBackground() + + val transferableChange = unlockAffectsFlow.map { + val asset = assetFlow.first() + + locksChangeFormatter.mapAmountChangeToUi(it.transferableChange, asset) + }.shareInBackground() + + val governanceLockChange = unlockAffectsFlow.map { + val asset = assetFlow.first() + + locksChangeFormatter.mapAmountChangeToUi(it.governanceLockChange, asset) + }.shareInBackground() + + private val submissionInProgress = MutableStateFlow(false) + + val confirmButtonState = submissionInProgress.map { inProgress -> + if (inProgress) { + DescriptiveButtonState.Loading + } else { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_confirm)) + } + } + + init { + originFeeMixin.connectWith( + inputSource = unlockAffectsFlow, + scope = this, + feeConstructor = { interactor.calculateFee(it.claimableChunk) } + ) + } + + fun accountClicked() = launch { + val chain = governanceSharedState.chain() + val addressModel = currentAddressModelFlow.first() + + externalActions.showAddressActions(addressModel.address, chain) + } + + fun backClicked() { + router.back() + } + + fun confirmClicked() = launch { + submissionInProgress.value = true + + val claimable = unlockAffectsFlow.first().claimableChunk + val locksChange = unlockAffectsFlow.first().governanceLockChange + + val validationPayload = UnlockReferendumValidationPayload( + asset = assetFlow.first(), + fee = originFeeMixin.awaitFee() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + validationFailureTransformer = { handleUnlockReferendumValidationFailure(it, resourceManager) }, + progressConsumer = submissionInProgress.progressConsumer(), + ) { + executeUnlock(claimable, locksChange) + } + } + + private fun executeUnlock( + claimable: ClaimSchedule.UnlockChunk.Claimable?, + lockChange: Change + ) = launch { + interactor.unlock(claimable) + .onFailure(::showError) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.finishUnlockFlow(shouldCloseLocksScreen = lockChange.newValue.isZero) } + } + + submissionInProgress.value = false + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/di/ConfirmGovernanceUnlockComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/di/ConfirmGovernanceUnlockComponent.kt new file mode 100644 index 0000000..0a55264 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/di/ConfirmGovernanceUnlockComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm.ConfirmGovernanceUnlockFragment + +@Subcomponent( + modules = [ + ConfirmGovernanceUnlockModule::class + ] +) +@ScreenScope +interface ConfirmGovernanceUnlockComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): ConfirmGovernanceUnlockComponent + } + + fun inject(fragment: ConfirmGovernanceUnlockFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/di/ConfirmGovernanceUnlockModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/di/ConfirmGovernanceUnlockModule.kt new file mode 100644 index 0000000..9bbbada --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/di/ConfirmGovernanceUnlockModule.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_governance_impl.data.GovernanceSharedState +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceUnlockInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.validations.UnlockReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.validations.unlockReferendumValidationSystem +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.vote.common.LocksChangeFormatter +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm.ConfirmGovernanceUnlockViewModel +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm.hints.ConfirmGovernanceUnlockHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmGovernanceUnlockModule { + + @Provides + @ScreenScope + fun provideValidationSystem(): UnlockReferendumValidationSystem = ValidationSystem.unlockReferendumValidationSystem() + + @Provides + @ScreenScope + fun provieHintsFactory( + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ) = ConfirmGovernanceUnlockHintsMixinFactory(resourceManager, amountFormatter) + + @Provides + @IntoMap + @ViewModelKey(ConfirmGovernanceUnlockViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + externalActions: ExternalActions.Presentation, + governanceSharedState: GovernanceSharedState, + validationExecutor: ValidationExecutor, + interactor: GovernanceUnlockInteractor, + feeMixinFactory: FeeLoaderMixin.Factory, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + locksChangeFormatter: LocksChangeFormatter, + validationSystem: UnlockReferendumValidationSystem, + hintsMixinFactory: ConfirmGovernanceUnlockHintsMixinFactory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmGovernanceUnlockViewModel( + router = router, + externalActions = externalActions, + governanceSharedState = governanceSharedState, + validationExecutor = validationExecutor, + interactor = interactor, + feeMixinFactory = feeMixinFactory, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + selectedAccountUseCase = selectedAccountUseCase, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + locksChangeFormatter = locksChangeFormatter, + validationSystem = validationSystem, + hintsMixinFactory = hintsMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ConfirmGovernanceUnlockViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmGovernanceUnlockViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/hints/ConfirmGovernanceUnlockHintsMixin.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/hints/ConfirmGovernanceUnlockHintsMixin.kt new file mode 100644 index 0000000..acbe4ad --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/confirm/hints/ConfirmGovernanceUnlockHintsMixin.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.confirm.hints + +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.buildSpannable +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceUnlockAffects.RemainsLockedInfo +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.mapBalanceIdToUi +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class ConfirmGovernanceUnlockHintsMixinFactory( + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) { + + fun create( + scope: CoroutineScope, + assetFlow: Flow, + remainsLockedInfoFlow: Flow + ): HintsMixin { + return ConfirmGovernanceUnlockHintsMixin(resourceManager, scope, assetFlow, remainsLockedInfoFlow, amountFormatter) + } +} + +private class ConfirmGovernanceUnlockHintsMixin( + private val resourceManager: ResourceManager, + scope: CoroutineScope, + assetFlow: Flow, + remainsLockedInfoFlow: Flow, + private val amountFormatter: AmountFormatter +) : HintsMixin, WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(scope) { + + override val hintsFlow: Flow> = remainsLockedInfoFlow.map { remainsLockedInfo -> + if (remainsLockedInfo != null && remainsLockedInfo.lockedInIds.isNotEmpty()) { + listOf(remainsLockedHint(assetFlow.first(), remainsLockedInfo)) + } else { + emptyList() + } + }.shareInBackground() + + private fun remainsLockedHint( + asset: Asset, + remainsLockedInfo: RemainsLockedInfo + ): CharSequence { + val amountPart = amountFormatter.formatAmountToAmountModel(remainsLockedInfo.amount, asset).token + val lockedIdsPart = remainsLockedInfo.lockedInIds.joinToString { lockId -> + mapBalanceIdToUi(resourceManager, lockId.value) + } + + return buildSpannable(resourceManager) { + appendColored(amountPart, R.color.text_primary) + + append(" ") + + val rest = resourceManager.getString(R.string.referendum_unlock_remains_locked_format, lockedIdsPart) + appendColored(rest, R.color.text_secondary) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/GovernanceLocksOverviewFragment.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/GovernanceLocksOverviewFragment.kt new file mode 100644 index 0000000..056e866 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/GovernanceLocksOverviewFragment.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.list + +import androidx.recyclerview.widget.ConcatAdapter + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_governance_impl.databinding.FragmentGovernanceLocksOverviewBinding +import io.novafoundation.nova.feature_governance_impl.di.GovernanceFeatureComponent + +class GovernanceLocksOverviewFragment : BaseFragment() { + + override fun createBinding() = FragmentGovernanceLocksOverviewBinding.inflate(layoutInflater) + + private val headerAdapter = TotalGovernanceLocksHeaderAdapter() + + private val listAdapter = UnlockableTokensAdapter() + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + ConcatAdapter(headerAdapter, listAdapter) + } + + override fun initViews() { + binder.governanceLockedTokensToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.governanceUnlockTokensButton.setOnClickListener { viewModel.unlockClicked() } + + binder.governanceLockedTokens.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + GovernanceFeatureApi::class.java + ) + .governanceLocksOverviewFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: GovernanceLocksOverviewViewModel) { + viewModel.totalAmount.observe { + headerAdapter.setAmount(it) + } + + viewModel.lockModels.observe { + if (it is LoadingState.Loaded) { + listAdapter.submitList(it.data) + binder.governanceLockedTokens.makeVisible() + binder.governanceTokensProgress.makeGone() + } else { + binder.governanceLockedTokens.makeGone() + binder.governanceTokensProgress.makeVisible() + } + } + + viewModel.isUnlockAvailable.observe { + if (it is LoadingState.Loaded) { + binder.governanceUnlockTokensButton.isEnabled = it.data + } else { + binder.governanceUnlockTokensButton.isEnabled = false + } + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/GovernanceLocksOverviewViewModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/GovernanceLocksOverviewViewModel.kt new file mode 100644 index 0000000..a5cc9cb --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/GovernanceLocksOverviewViewModel.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.list + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceLocksOverview +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceLocksOverview.ClaimTime +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceLocksOverview.Lock +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceUnlockInteractor +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.canClaimTokens +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.model.GovernanceLockModel +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.model.GovernanceLockModel.StatusContent +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +class GovernanceLocksOverviewViewModel( + private val router: GovernanceRouter, + private val interactor: GovernanceUnlockInteractor, + private val tokenUseCase: TokenUseCase, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : BaseViewModel() { + + private val lockOverviewFlow = interactor.locksOverviewFlow(scope = viewModelScope) + .shareInBackground() + + private val tokenFlow = tokenUseCase.currentTokenFlow() + .shareInBackground() + + val totalAmount = combine(lockOverviewFlow, tokenFlow) { locksOverview, token -> + amountFormatter.formatAmountToAmountModel(locksOverview.totalLocked, token) + }.shareInBackground() + + val lockModels = combine(lockOverviewFlow, tokenFlow) { locksOverview, token -> + locksOverview.locks.mapIndexed { index, lock -> mapUnlockChunkToUi(lock, index, token) } + } + .withLoading() + .shareInBackground() + + val isUnlockAvailable = lockOverviewFlow.map(GovernanceLocksOverview::canClaimTokens) + .withLoading() + .share() + + fun backClicked() { + router.back() + } + + fun unlockClicked() { + router.openConfirmGovernanceUnlock() + } + + private fun mapUnlockChunkToUi(lock: Lock, index: Int, token: Token): GovernanceLockModel { + return when { + lock is Lock.Claimable -> GovernanceLockModel( + index = index, + amount = amountFormatter.formatAmountToAmountModel(lock.amount, token).token, + status = StatusContent.Text(resourceManager.getString(R.string.referendum_unlock_unlockable)), + statusColorRes = R.color.text_positive, + statusIconRes = null, + statusIconColorRes = null + ) + lock is Lock.Pending && lock.claimTime is ClaimTime.At -> GovernanceLockModel( + index = index, + amount = amountFormatter.formatAmountToAmountModel(lock.amount, token).token, + status = StatusContent.Timer(lock.claimTime.timer), + statusColorRes = R.color.text_secondary, + statusIconRes = R.drawable.ic_time_16, + statusIconColorRes = R.color.icon_secondary + ) + + lock is Lock.Pending && lock.claimTime is ClaimTime.UntilAction -> GovernanceLockModel( + index = index, + amount = amountFormatter.formatAmountToAmountModel(lock.amount, token).token, + status = StatusContent.Text(resourceManager.getString(R.string.delegation_your_delegation)), + statusColorRes = R.color.text_secondary, + statusIconRes = null, + statusIconColorRes = null + ) + + else -> error("Not possible") + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/TotalGovernanceLocksHeaderAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/TotalGovernanceLocksHeaderAdapter.kt new file mode 100644 index 0000000..a3deac5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/TotalGovernanceLocksHeaderAdapter.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ItemGovernanceTotalLocksHeaderBinding +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class TotalGovernanceLocksHeaderAdapter : RecyclerView.Adapter() { + + private var amount: AmountModel? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderHolder { + return HeaderHolder(ItemGovernanceTotalLocksHeaderBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: HeaderHolder, position: Int) { + holder.bind(amount) + } + + override fun getItemCount(): Int { + return 1 + } + + fun setAmount(amountModel: AmountModel) { + this.amount = amountModel + notifyItemChanged(0, true) + } + + inner class HeaderHolder(private val binder: ItemGovernanceTotalLocksHeaderBinding) : RecyclerView.ViewHolder(binder.root) { + init { + binder.root.background = binder.root.context.getRoundedCornerDrawable(R.color.block_background) + } + + fun bind(amount: AmountModel?) { + binder.governanceTotalLocksAmount.setAmount(amount) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/UnlockableTokensAdapter.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/UnlockableTokensAdapter.kt new file mode 100644 index 0000000..b5d19ff --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/UnlockableTokensAdapter.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.common.view.stopTimer +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ItemGovernanceLockBinding +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.model.GovernanceLockModel +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.model.GovernanceLockModel.StatusContent + +class UnlockableTokensAdapter : ListAdapter(GovernanceLockCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UnlockableTokenHolder { + return UnlockableTokenHolder(ItemGovernanceLockBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: UnlockableTokenHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: UnlockableTokenHolder, position: Int, payloads: MutableList) { + resolvePayload(holder, position, payloads) { + val item = getItem(position) + + when (it) { + GovernanceLockModel::amount -> holder.bindUnlockAmount(item) + GovernanceLockModel::status -> holder.bindUnlockStatus(item) + } + } + } +} + +private val LocksPayloadGenerator = PayloadGenerator( + GovernanceLockModel::amount, + GovernanceLockModel::status +) + +private object GovernanceLockCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: GovernanceLockModel, newItem: GovernanceLockModel): Boolean { + return oldItem.index == newItem.index + } + + override fun areContentsTheSame(oldItem: GovernanceLockModel, newItem: GovernanceLockModel): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: GovernanceLockModel, newItem: GovernanceLockModel): Any? { + return LocksPayloadGenerator.diff(oldItem, newItem) + } +} + +class UnlockableTokenHolder( + private val binder: ItemGovernanceLockBinding, +) : RecyclerView.ViewHolder(binder.root) { + + fun bind(item: GovernanceLockModel) = with(binder) { + bindUnlockAmount(item) + + bindUnlockStatus(item) + } + + fun bindUnlockAmount(item: GovernanceLockModel) = with(binder) { + unlockableTokensAmount.text = item.amount + } + + fun bindUnlockStatus(item: GovernanceLockModel) = with(binder) { + when (val status = item.status) { + is StatusContent.Text -> { + leftToUnlock.stopTimer() + + leftToUnlock.text = status.text + } + is StatusContent.Timer -> { + leftToUnlock.startTimer(value = status.timer, customMessageFormat = R.string.common_left) + } + } + + leftToUnlock.setDrawableEnd(item.statusIconRes, widthInDp = 16, paddingInDp = 4, tint = item.statusIconColorRes) + leftToUnlock.setTextColorRes(item.statusColorRes) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/di/GovernanceLocksOverviewComponent.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/di/GovernanceLocksOverviewComponent.kt new file mode 100644 index 0000000..2ee5757 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/di/GovernanceLocksOverviewComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.GovernanceLocksOverviewFragment + +@Subcomponent( + modules = [ + GovernanceLocksOverviewModule::class + ] +) +@ScreenScope +interface GovernanceLocksOverviewComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): GovernanceLocksOverviewComponent + } + + fun inject(fragment: GovernanceLocksOverviewFragment) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/di/GovernanceLocksOverviewModule.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/di/GovernanceLocksOverviewModule.kt new file mode 100644 index 0000000..8d9d710 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/di/GovernanceLocksOverviewModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_governance_impl.domain.referendum.unlock.GovernanceUnlockInteractor +import io.novafoundation.nova.feature_governance_impl.presentation.GovernanceRouter +import io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.GovernanceLocksOverviewViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class GovernanceLocksOverviewModule { + + @Provides + @IntoMap + @ViewModelKey(GovernanceLocksOverviewViewModel::class) + fun provideViewModel( + router: GovernanceRouter, + interactor: GovernanceUnlockInteractor, + tokenUseCase: TokenUseCase, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ): ViewModel { + return GovernanceLocksOverviewViewModel( + router = router, + interactor = interactor, + tokenUseCase = tokenUseCase, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): GovernanceLocksOverviewViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(GovernanceLocksOverviewViewModel::class.java) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/model/GovernanceLockModel.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/model/GovernanceLockModel.kt new file mode 100644 index 0000000..9425f55 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/unlock/list/model/GovernanceLockModel.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.unlock.list.model + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.utils.formatting.TimerValue + +data class GovernanceLockModel( + val index: Int, + val amount: CharSequence, + val status: StatusContent, + @ColorRes val statusColorRes: Int, + @DrawableRes val statusIconRes: Int?, + @ColorRes val statusIconColorRes: Int? +) { + + sealed class StatusContent { + + data class Timer(val timer: TimerValue) : StatusContent() + + data class Text(val text: String) : StatusContent() + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/GovernanceCardsStackView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/GovernanceCardsStackView.kt new file mode 100644 index 0000000..beb871b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/GovernanceCardsStackView.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import com.yuyakaido.android.cardstackview.CardStackView + +class GovernanceCardsStackView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : CardStackView(context, attrs, defStyle) { + + override fun onTouchEvent(e: MotionEvent?): Boolean { + if (!isEnabled) return false + + return super.onTouchEvent(e) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/GovernanceLocksView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/GovernanceLocksView.kt new file mode 100644 index 0000000..9a626d2 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/GovernanceLocksView.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ViewGovernanceLocksBinding + +class GovernanceLocksView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + private val binder = ViewGovernanceLocksBinding.inflate(inflater(), this) + + init { + setBackgroundResource(R.drawable.bg_primary_list_item) + + binder.governanceLockAmount.isEnabled = false + + attrs?.let(::applyAttributes) + } + + private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.GovernanceLocksView) { + val title = it.getString(R.styleable.GovernanceLocksView_governanceLocksView_label) + title?.let(::setTitle) + + val leftIcon = it.getDrawable(R.styleable.GovernanceLocksView_governanceLocksView_icon) + leftIcon?.let(::setLeftIcon) + } + + fun setTitle(title: String) { + binder.governanceLockedTitle.text = title + } + + fun setLeftIcon(icon: Drawable?) { + binder.governanceLockedIcon.setImageDrawable(icon) + } + + fun setModel(model: GovernanceLocksModel) { + binder.governanceLockedTitle.text = model.title + binder.governanceLockAmount.setTextOrHide(model.amount) + binder.governanceUnlockBadge.isVisible = model.showUnlockableLocks + } +} + +class GovernanceLocksModel( + val amount: MaskableModel?, + val title: String, + val showUnlockableLocks: Boolean +) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/NovaChipView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/NovaChipView.kt new file mode 100644 index 0000000..5f6041f --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/NovaChipView.kt @@ -0,0 +1,269 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.content.res.TypedArray +import android.graphics.drawable.Drawable +import android.text.TextUtils.TruncateAt +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StyleRes +import androidx.core.content.res.getDimensionOrThrow +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.getEnum +import io.novafoundation.nova.common.utils.getResourceIdOrNull +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ViewChipBinding + +import kotlin.math.roundToInt + +private val SIZE_DEFAULT = NovaChipView.Size.NORMAL + +class NovaChipView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + enum class Size( + val drawablePadding: Float, + val iconVerticalMargin: Float, + val iconHorizontalMargin: Float, + val textTopMargin: Float, + val textBottomMargin: Float, + val textHorizontalMargin: Float, + @StyleRes val textAppearanceRes: Int, + val cornerRadiusDp: Int + ) { + NORMAL( + drawablePadding = 6f, + iconVerticalMargin = 3.0f, + iconHorizontalMargin = 6.0f, + textTopMargin = 4.5f, + textBottomMargin = 4.5f, + textHorizontalMargin = 8.0f, + textAppearanceRes = R.style.TextAppearance_NovaFoundation_SemiBold_Caps1, + cornerRadiusDp = 8 + ), + + SMALL( + drawablePadding = 4f, + iconVerticalMargin = 1.5f, + iconHorizontalMargin = 4.0f, + textTopMargin = 1.5f, + textBottomMargin = 1.5f, + textHorizontalMargin = 6.0f, + textAppearanceRes = R.style.TextAppearance_NovaFoundation_SemiBold_Caps2, + cornerRadiusDp = 6 + ), + + SUM( + drawablePadding = 0f, + iconVerticalMargin = 0f, + iconHorizontalMargin = 0f, + textTopMargin = 3f, + textBottomMargin = 3f, + textHorizontalMargin = 8.0f, + textAppearanceRes = R.style.TextAppearance_NovaFoundation_SemiBold_Footnote, + cornerRadiusDp = 8 + ) + } + + private val binder = ViewChipBinding.inflate(inflater(), this) + + private var size: Size = SIZE_DEFAULT + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + if (isInEditMode) { + ImageLoader.invoke(context) + } else { + FeatureUtils.getCommonApi(context).imageLoader() + } + } + + private val customTextAppearance: Int? + + init { + orientation = HORIZONTAL + + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.NovaChipView) + + val size = typedArray.getEnum(R.styleable.NovaChipView_chipSize, SIZE_DEFAULT) + + customTextAppearance = typedArray.getResourceIdOrNull(R.styleable.NovaChipView_chipTextAppearance) + setSize(size, customTextAppearance) + + if (typedArray.hasValue(R.styleable.NovaChipView_chipIcon)) { + val iconDrawable = typedArray.getDrawable(R.styleable.NovaChipView_chipIcon) + setIconDrawable(iconDrawable) + } else { + setIconDrawable(null) + } + + if (typedArray.hasValue(R.styleable.NovaChipView_chipIconSize)) { + val iconSize = typedArray.getDimensionOrThrow(R.styleable.NovaChipView_chipIconSize) + setIconSize(iconSize) + } + + val backgroundTintColor = typedArray.getResourceId(R.styleable.NovaChipView_backgroundColor, R.color.chips_background) + setChipBackground(backgroundTintColor) + + val text = typedArray.getString(R.styleable.NovaChipView_android_text) + setText(text) + + val textAllCaps = typedArray.getBoolean(R.styleable.NovaChipView_android_textAllCaps, true) + setTextAllCaps(textAllCaps) + + @ColorRes + val textColorRes = typedArray.getResourceId( + R.styleable.NovaChipView_android_textColor, + R.color.chip_text + ) + binder.chipText.setTextColorRes(textColorRes) + + binder.chipText.ellipsize = typedArray.getEllipsize() + + typedArray.recycle() + } + + fun TypedArray.getEllipsize(): TruncateAt? { + val index = getInt(R.styleable.NovaChipView_android_ellipsize, -1) + + if (index <= 0) return null + return TruncateAt.values()[index - 1] + } + + fun setSize(size: Size, customTextAppearance: Int? = null) { + this.size = size + + val startPadding = if (binder.chipIcon.isVisible) { + size.iconHorizontalMargin.dp + } else { + size.textHorizontalMargin.dp + } + + val endPadding = if (binder.chipText.isVisible) { + size.textHorizontalMargin.dp + } else { + size.iconHorizontalMargin.dp + } + + updatePadding(start = startPadding, end = endPadding) + + binder.chipIcon.updateLayoutParams { + val vertical = size.iconVerticalMargin.dp + setMargins(0, vertical, 0, vertical) + } + + binder.chipText.updateLayoutParams { + val top = size.textTopMargin.dp + val bottom = size.textBottomMargin.dp + + setMargins(0, top, 0, bottom) + } + + binder.chipText.setTextAppearance(customTextAppearance ?: size.textAppearanceRes) + + binder.chipDrawablePadding.layoutParams = LayoutParams(size.drawablePadding.dp, LayoutParams.MATCH_PARENT) + } + + fun setIconSize(value: Float) { + binder.chipIcon.updateLayoutParams { + val intValue = value.roundToInt() + this.height = intValue + this.width = intValue + } + } + + fun setIconTint(tintRes: Int?) { + binder.chipIcon.setImageTintRes(tintRes) + } + + fun setIcon(icon: Icon?) { + if (icon == null) { + setIconDrawable(drawable = null) + } else { + binder.chipIcon.setIcon(icon, imageLoader) + } + useIcon(icon != null) + invalidateDrawablePadding() + } + + fun setIconDrawable(drawable: Drawable?) { + binder.chipIcon.setImageDrawable(drawable) + useIcon(drawable != null) + invalidateDrawablePadding() + } + + fun setIcon(@DrawableRes drawableRes: Int) { + binder.chipIcon.setImageResource(drawableRes) + useIcon(true) + invalidateDrawablePadding() + } + + fun setStyle(@ColorRes backgroundColorRes: Int, @ColorRes textColorRes: Int, @ColorRes iconColorRes: Int) { + setChipBackground(backgroundColorRes) + binder.chipText.setTextColorRes(textColorRes) + setIconTint(textColorRes) + } + + fun setText(maskableModel: MaskableModel?) { + binder.chipText.isVisible = maskableModel != null + invalidateDrawablePadding() + if (maskableModel == null) return + + binder.chipText.setMaskableText(maskableModel) + } + + fun setText(text: CharSequence?) { + binder.chipText.setTextOrHide(text) + invalidateDrawablePadding() + } + + fun setTextAllCaps(value: Boolean) { + binder.chipText.isAllCaps = value + } + + private fun useIcon(useIcon: Boolean) { + binder.chipIcon.isVisible = useIcon + + refreshSize() + } + + fun clearIcon() { + binder.chipIcon.clear() + } + + private fun invalidateDrawablePadding() { + binder.chipDrawablePadding.isVisible = binder.chipIcon.isVisible && binder.chipText.isVisible + } + + private fun setChipBackground(backgroundTintColor: Int) { + background = getRoundedCornerDrawable(backgroundTintColor, cornerSizeDp = size.cornerRadiusDp) + .withRippleMask(getRippleMask(cornerSizeDp = size.cornerRadiusDp)) + } + + private fun refreshSize() { + setSize(size, customTextAppearance) + } +} + +fun NovaChipView.setTextOrHide(maskableModel: MaskableModel?) = letOrHide(maskableModel, ::setText) + +fun NovaChipView.setTextOrHide(text: CharSequence?) = letOrHide(text, ::setText) diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/ReferendumDappList.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/ReferendumDappList.kt new file mode 100644 index 0000000..1ee7fe5 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/ReferendumDappList.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.core.view.children +import androidx.core.view.isGone +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ViewReferendumDappListBinding +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.details.model.ReferendumDAppModel + +typealias OnDAppClicked = (ReferendumDAppModel) -> Unit + +class ReferendumDappList @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewReferendumDappListBinding.inflate(inflater(), this) + + init { + background = getRoundedCornerDrawable(R.color.block_background) + orientation = VERTICAL + setPadding(0, 16.dp, 0, 8.dp) + } + + private var onDAppClicked: OnDAppClicked? = null + + fun onDAppClicked(listener: OnDAppClicked) { + onDAppClicked = listener + } + + fun setDApps(dApps: List) { + removeAllDApps() + + dApps.forEach(::addDApp) + } + + fun setDAppsOrHide(dApps: List) { + isGone = dApps.isEmpty() + setDApps(dApps) + } + + private fun addDApp(model: ReferendumDAppModel) { + val dAppView = DAppView.createUsingMathParentWidth(context).apply { + setTitle(model.name) + setSubtitle(model.description) + setIconUrl(model.iconUrl) + setActionResource(iconRes = R.drawable.ic_chevron_right, colorRes = R.color.icon_secondary) + setActionEndPadding(16.dp) + + setOnClickListener { onDAppClicked?.invoke(model) } + } + + addView(dAppView) + } + + private fun removeAllDApps() { + children.toList() + .filterIsInstance() + .forEach { removeView(it) } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VoteControlView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VoteControlView.kt new file mode 100644 index 0000000..212d703 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VoteControlView.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_governance_impl.databinding.ViewVoteControlBinding + +class VoteControlView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewVoteControlBinding.inflate(inflater(), this) + + val nayButton get() = binder.setupReferendumVoteNay + val abstainButton get() = binder.setupReferendumVoteAbstain + val ayeButton get() = binder.setupReferendumVoteAye + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_HORIZONTAL + } + + fun setNayClickListener(listener: OnClickListener?) { + nayButton.setOnClickListener(listener) + } + + fun setAbstainClickListener(listener: OnClickListener?) { + abstainButton.setOnClickListener(listener) + } + + fun setAyeClickListener(listener: OnClickListener?) { + ayeButton.setOnClickListener(listener) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotersView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotersView.kt new file mode 100644 index 0000000..622ebc8 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotersView.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ViewVotersBinding + +class VotersView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewVotersBinding.inflate(inflater(), this) + + init { + setBackgroundResource(R.drawable.bg_primary_list_item) + } + + fun setVoteType(@StringRes voteTypeRes: Int, @ColorRes voteColorRes: Int) { + binder.votersViewVoteType.setText(voteTypeRes) + binder.votersViewVoteTypeColor.background = getRoundedCornerDrawable(voteColorRes, cornerSizeDp = 3) + } + + fun showLoading(loading: Boolean) { + binder.votersViewVotesCountShimmer.isVisible = loading + binder.votersViewVotesCount.isVisible = !loading + } + + fun setVotesValue(value: String?) { + binder.votersViewVotesCount.setTextOrHide(value) + } +} + +class VotersModel( + @StringRes val voteTypeRes: Int, + @ColorRes val voteTypeColorRes: Int, + val votesValue: String?, + val loading: Boolean +) + +fun VotersView.setVotersModel(maybeModel: VotersModel?) = letOrHide(maybeModel) { model -> + showLoading(model.loading) + setVoteType(model.voteTypeRes, model.voteTypeColorRes) + setVotesValue(model.votesValue) +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotesView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotesView.kt new file mode 100644 index 0000000..99f7436 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotesView.kt @@ -0,0 +1,261 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Parcel +import android.os.Parcelable +import android.os.Parcelable.ClassLoaderCreator +import android.util.AttributeSet +import android.view.View +import androidx.annotation.FloatRange +import io.novafoundation.nova.feature_governance_impl.R +import java.lang.Float.max +import java.lang.Float.min + +class VotesView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : View(context, attrs, defStyleAttr, defStyleRes) { + + private val noVotesPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val positivePaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val negativePaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val thresholdPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG) + + private var minimumLineLength: Float = 0f + private var votesLineWidth: Float = 0f + private var cornerRadius: Float = 0f + private var marginBetweenVotes: Float = 0f + private var thresholdWidth: Float = 0f + private var thresholdHeight: Float = 0f + private var thresholdCornerRadius: Float = 0f + + private var threshold: Float = 0.5f + private var positiveFraction: Float = 0.0f + + private var noVotesRect = RectF() + private var positiveRect = RectF() + private var negativeRect = RectF() + private var thresholdRect = RectF() + private var hasPositiveVotes = false + private var hasNegativeVotes = false + + private var thresholdVisible = true + + init { + val a = context.obtainStyledAttributes( + attrs, + R.styleable.VotesView, + defStyleAttr, + 0 + ) + + val noVotesColor = a.getColor(R.styleable.VotesView_noVotesColor, Color.GRAY) + val positiveColor = a.getColor(R.styleable.VotesView_positiveColor, Color.GREEN) + val negativeColor = a.getColor(R.styleable.VotesView_negativeColor, Color.RED) + val thresholdColor = a.getColor(R.styleable.VotesView_thresholdColor, Color.RED) + val thresholdShadowColor = a.getColor(R.styleable.VotesView_thresholdShadowColor, Color.GRAY) + val thresholdShadowSize = a.getDimension(R.styleable.VotesView_thresholdShadowSize, 0f) + votesLineWidth = a.getDimension(R.styleable.VotesView_votesLineWidth, 2f) + marginBetweenVotes = a.getDimension(R.styleable.VotesView_marginBetweenVotes, 0f) + minimumLineLength = a.getDimension(R.styleable.VotesView_minimumLineLength, 0f) + thresholdWidth = a.getDimension(R.styleable.VotesView_thresholdWidth, 0f) + thresholdHeight = a.getDimension(R.styleable.VotesView_thresholdHeight, 0f) + thresholdCornerRadius = a.getDimension(R.styleable.VotesView_thresholdCornerRadius, 0f) + + a.recycle() + + cornerRadius = votesLineWidth / 2 + + noVotesPaint.color = noVotesColor + positivePaint.color = positiveColor + negativePaint.color = negativeColor + + with(thresholdPaint) { + color = thresholdColor + setShadowLayer(thresholdShadowSize, 0f, 0f, thresholdShadowColor) + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + val percentageArea: Float = measuredWidth - (paddingStart + paddingEnd).toFloat() + + val lineY = measuredHeight / 2f + val lineTop = lineY + cornerRadius + val lineBottom = lineY - cornerRadius + val lineStart = paddingStart.toFloat() + val lineEnd = (measuredWidth - paddingEnd).toFloat() + + if (noVotes()) { + noVotesRect.set(lineStart, lineTop, lineEnd, lineBottom) + } else if (hasOnlyPositiveVotes()) { + positiveRect.set(lineStart, lineTop, lineEnd, lineBottom) + } else if (hasOnlyNegativeVotes()) { + negativeRect.set(lineStart, lineTop, lineEnd, lineBottom) + } else { + val halfMarginBetweenVotes = marginBetweenVotes / 2 + val positivePercentageWidth: Float = percentageArea * positiveFraction + val maximumPositivePercentageWidth = percentageArea - halfMarginBetweenVotes - minimumLineLength + val minimumPositivePercentageWidth = minimumLineLength + halfMarginBetweenVotes + val stablePositivePercentageWidth: Float = max(min(positivePercentageWidth, maximumPositivePercentageWidth), minimumPositivePercentageWidth) + val positiveEnd = lineStart + stablePositivePercentageWidth - halfMarginBetweenVotes + val negativeStart = lineStart + stablePositivePercentageWidth + halfMarginBetweenVotes + positiveRect.set(lineStart, lineTop, positiveEnd, lineBottom) + negativeRect.set(negativeStart, lineTop, lineEnd, lineBottom) + } + + val thresholdHalfWidth = thresholdWidth / 2 + val thresholdHalfHeight = thresholdHeight / 2 + val minThresholdPosition = paddingStart + thresholdHalfWidth + val maxThresholdPosition = measuredWidth - paddingEnd - thresholdHalfWidth + + val thresholdPosition = paddingStart + percentageArea * threshold + val validThresholdPosition = thresholdPosition.coerceIn(minThresholdPosition, maxThresholdPosition) + thresholdRect.set( + validThresholdPosition - thresholdHalfWidth, + lineY - thresholdHalfHeight, + validThresholdPosition + thresholdHalfWidth, + lineY + thresholdHalfHeight + ) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (noVotes()) { + canvas.drawRoundRect(noVotesRect, cornerRadius, cornerRadius, noVotesPaint) + } else { + if (hasPositiveVotes) { + canvas.drawRoundRect(positiveRect, cornerRadius, cornerRadius, positivePaint) + } + + if (hasNegativeVotes) { + canvas.drawRoundRect(negativeRect, cornerRadius, cornerRadius, negativePaint) + } + } + + if (thresholdVisible) { + canvas.drawRoundRect(thresholdRect, thresholdCornerRadius, thresholdCornerRadius, thresholdPaint) + } + } + + override fun onSaveInstanceState(): Parcelable { + return SavedState( + super.onSaveInstanceState(), + positiveFraction, + threshold, + hasPositiveVotes, + hasNegativeVotes + ) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + positiveFraction = state.positiveFraction + threshold = state.threshold + hasPositiveVotes = state.hasPositiveVotes + hasNegativeVotes = state.hasNegativeVotes + } else { + super.onRestoreInstanceState(state) + } + } + + fun setPositiveVotesFraction(positiveFraction: Float?) { + if (positiveFraction == null) { + hasPositiveVotes = false + hasNegativeVotes = false + } else { + require(positiveFraction in 0f..1f) + this.positiveFraction = positiveFraction + hasPositiveVotes = positiveFraction > 0f + hasNegativeVotes = positiveFraction < 1f + } + requestLayout() + } + + fun setThreshold(@FloatRange(from = 0.0, to = 1.0) threshold: Float?) { + thresholdVisible = threshold != null + + if (threshold != null) { + require(threshold in 0f..1f) + this.threshold = threshold + } + + requestLayout() + } + + private fun hasOnlyPositiveVotes(): Boolean { + return hasPositiveVotes && !hasNegativeVotes + } + + private fun hasOnlyNegativeVotes(): Boolean { + return hasNegativeVotes && !hasPositiveVotes + } + + private fun noVotes(): Boolean { + return !hasNegativeVotes && !hasPositiveVotes + } + + private class SavedState : BaseSavedState { + + val positiveFraction: Float + val threshold: Float + val hasPositiveVotes: Boolean + val hasNegativeVotes: Boolean + + constructor( + superState: Parcelable?, + positivePercentage: Float, + threshold: Float, + hasPositiveVotes: Boolean, + hasNegativeVotes: Boolean + ) : super(superState) { + this.positiveFraction = positivePercentage + this.threshold = threshold + this.hasPositiveVotes = hasPositiveVotes + this.hasNegativeVotes = hasNegativeVotes + } + + constructor(parcel: Parcel) : this(parcel, null) + + constructor(parcel: Parcel, loader: ClassLoader?) : super(parcel, loader) { + this.positiveFraction = parcel.readFloat() + this.threshold = parcel.readFloat() + this.hasPositiveVotes = parcel.readInt() == 1 + this.hasNegativeVotes = parcel.readInt() == 1 + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeFloat(positiveFraction) + out.writeFloat(threshold) + out.writeInt(if (hasPositiveVotes) 1 else 0) + out.writeInt(if (hasNegativeVotes) 1 else 0) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : ClassLoaderCreator { + override fun createFromParcel(parcel: Parcel): SavedState { + return SavedState(parcel) + } + + override fun createFromParcel(parcel: Parcel, classLoader: ClassLoader?): SavedState { + return SavedState(parcel, classLoader) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotingStatusView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotingStatusView.kt new file mode 100644 index 0000000..941d67d --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotingStatusView.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_governance_impl.R +import io.novafoundation.nova.feature_governance_impl.databinding.ViewVotingStatusBinding +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumStatusModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumTimeEstimation +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumVotingModel +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.setReferendumTimeEstimation + +class VotingStatusView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + override val providedContext: Context = context + + private val binder = ViewVotingStatusBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + + background = getRoundedCornerDrawable(R.color.block_background) + setPadding(0, 16.dp, 0, 16.dp) + } + + fun setTimeEstimation(timeEstimation: ReferendumTimeEstimation?) { + if (timeEstimation == null) { + binder.votingStatusTimeEstimation.makeGone() + return + } + + binder.votingStatusTimeEstimation.makeVisible() + binder.votingStatusTimeEstimation.setReferendumTimeEstimation(timeEstimation, Gravity.END) + } + + fun setStatus(referendumStatusModel: ReferendumStatusModel) { + binder.votingStatus.text = referendumStatusModel.name + binder.votingStatus.setTextColorRes(referendumStatusModel.colorRes) + } + + fun setVotingModel(thresholdModel: ReferendumVotingModel?) { + binder.votingStatusThreshold.setThresholdModel(thresholdModel) + } + + fun setPositiveVoters(votersModel: VotersModel?) { + binder.positiveVotersDetails.setVotersModel(votersModel) + } + + fun setPositiveVotersClickListener(listener: OnClickListener?) { + binder.positiveVotersDetails.setOnClickListener(listener) + } + + fun setNegativeVoters(votersModel: VotersModel?) { + binder.negativeVotersDetails.setVotersModel(votersModel) + } + + fun setNegativeVotersClickListener(listener: OnClickListener?) { + binder.negativeVotersDetails.setOnClickListener(listener) + } + + fun setAbstainVoters(votersModel: VotersModel?) { + binder.abstainVotersDetails.setVotersModel(votersModel) + } + + fun setAbstainVotersClickListener(listener: OnClickListener?) { + binder.abstainVotersDetails.setOnClickListener(listener) + } + + fun setStartVoteOnClickListener(listener: OnClickListener?) { + binder.votingStatusStartVote.setOnClickListener(listener) + } + + fun setVoteButtonState(state: DescriptiveButtonState) { + binder.votingStatusStartVote.setState(state) + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotingThresholdView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotingThresholdView.kt new file mode 100644 index 0000000..d33a42b --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/VotingThresholdView.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.core.view.isVisible +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.feature_governance_impl.databinding.ViewVotingThresholdBinding +import io.novafoundation.nova.feature_governance_impl.presentation.referenda.common.model.ReferendumVotingModel + +class VotingThresholdView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewVotingThresholdBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + } + + fun setThresholdModel(maybeModel: ReferendumVotingModel?) = letOrHide(maybeModel) { model -> + binder.thresholdInfo.isVisible = model.thresholdInfoVisible + binder.thresholdInfo.text = model.thresholdInfo + binder.thresholdInfo.setDrawableStart(model.votingResultIcon, widthInDp = 16, tint = model.votingResultIconColor, paddingInDp = 4) + binder.votesView.setThreshold(model.thresholdFraction) + binder.votesView.setPositiveVotesFraction(model.positiveFraction) + binder.positivePercentage.text = model.positivePercentage + binder.negativePercentage.text = model.negativePercentage + binder.thresholdPercentage.text = model.thresholdPercentage + } + + fun setThresholdInfoVisible(visible: Boolean?) = binder.thresholdInfo.letOrHide(visible) { value -> + binder.thresholdInfo.isVisible = value + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/YourMultiVoteView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/YourMultiVoteView.kt new file mode 100644 index 0000000..ce84731 --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/YourMultiVoteView.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.shape.getBlockDrawable + +class YourMultiVoteModel(val votes: List) + +class YourMultiVoteView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private var currentModel: YourMultiVoteModel? = null + + init { + orientation = VERTICAL + + setPadding(0, 5.dp, 0, 5.dp) + + background = context.getBlockDrawable() + } + + fun setModel(model: YourMultiVoteModel?) { + if (model != null && model == currentModel) { + return + } + currentModel = model + + val votes = model?.votes.orEmpty() + + setVisible(votes.isNotEmpty()) + + removeAllViews() + + votes.forEach { voteModel -> + val voteView = YourVoteView(context) + voteView.setVoteModelOrHide(voteModel) + addView(voteView) + } + } +} diff --git a/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/YourVoteView.kt b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/YourVoteView.kt new file mode 100644 index 0000000..317a2da --- /dev/null +++ b/feature-governance-impl/src/main/java/io/novafoundation/nova/feature_governance_impl/presentation/view/YourVoteView.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_governance_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.ColorRes +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_governance_impl.databinding.ViewYourVoteBinding +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteDirectionModel +import io.novafoundation.nova.feature_governance_impl.presentation.common.voters.VoteModel + +data class YourVoteModel( + val voteTitle: String, + val vote: VoteModel, + val voteDirection: VoteDirectionModel, +) + +class YourVoteView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle), WithContextExtensions { + + override val providedContext: Context = context + + private val binder = ViewYourVoteBinding.inflate(inflater(), this) + + fun setVoteType(voteType: String, @ColorRes voteColorRes: Int) { + binder.viewYourVoteType.text = voteType + binder.viewYourVoteType.setTextColorRes(voteColorRes) + } + + fun setVoteTitle(voteTitle: String) { + binder.viewYourVote.text = voteTitle + } + + fun setVoteValue(value: String, valueDetails: String?) { + binder.viewYourVoteValue.text = value + binder.viewYourVoteValueDetails.text = valueDetails + } +} + +fun YourVoteView.setVoteModelOrHide(maybeModel: YourVoteModel?) = letOrHide(maybeModel) { model -> + setVoteType(model.voteDirection.text, model.voteDirection.textColor) + setVoteValue(model.vote.votesCount, model.vote.votesCountDetails) + setVoteTitle(model.voteTitle) +} diff --git a/feature-governance-impl/src/main/res/layout/bottom_remove_votes_suggestion.xml b/feature-governance-impl/src/main/res/layout/bottom_remove_votes_suggestion.xml new file mode 100644 index 0000000..d38116a --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/bottom_remove_votes_suggestion.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/bottom_sheet_unavailable_tracks.xml b/feature-governance-impl/src/main/res/layout/bottom_sheet_unavailable_tracks.xml new file mode 100644 index 0000000..63aa807 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/bottom_sheet_unavailable_tracks.xml @@ -0,0 +1,43 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_delegate_delegators.xml b/feature-governance-impl/src/main/res/layout/fragment_delegate_delegators.xml new file mode 100644 index 0000000..03901bc --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_delegate_delegators.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_delegate_details.xml b/feature-governance-impl/src/main/res/layout/fragment_delegate_details.xml new file mode 100644 index 0000000..738211a --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_delegate_details.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_delegate_list.xml b/feature-governance-impl/src/main/res/layout/fragment_delegate_list.xml new file mode 100644 index 0000000..95c6137 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_delegate_list.xml @@ -0,0 +1,35 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_delegate_search.xml b/feature-governance-impl/src/main/res/layout/fragment_delegate_search.xml new file mode 100644 index 0000000..6e40a9a --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_delegate_search.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_description.xml b/feature-governance-impl/src/main/res/layout/fragment_description.xml new file mode 100644 index 0000000..a51d88a --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_description.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_governance_confirm_unlock.xml b/feature-governance-impl/src/main/res/layout/fragment_governance_confirm_unlock.xml new file mode 100644 index 0000000..58d3943 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_governance_confirm_unlock.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_governance_locks_overview.xml b/feature-governance-impl/src/main/res/layout/fragment_governance_locks_overview.xml new file mode 100644 index 0000000..05be102 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_governance_locks_overview.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_new_delegation_choose_amount.xml b/feature-governance-impl/src/main/res/layout/fragment_new_delegation_choose_amount.xml new file mode 100644 index 0000000..b80980e --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_new_delegation_choose_amount.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_new_delegation_confirm.xml b/feature-governance-impl/src/main/res/layout/fragment_new_delegation_confirm.xml new file mode 100644 index 0000000..0b62557 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_new_delegation_confirm.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_referenda_filters.xml b/feature-governance-impl/src/main/res/layout/fragment_referenda_filters.xml new file mode 100644 index 0000000..0c3500c --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_referenda_filters.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_referenda_list.xml b/feature-governance-impl/src/main/res/layout/fragment_referenda_list.xml new file mode 100644 index 0000000..6a58cbf --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_referenda_list.xml @@ -0,0 +1,13 @@ + + diff --git a/feature-governance-impl/src/main/res/layout/fragment_referenda_search.xml b/feature-governance-impl/src/main/res/layout/fragment_referenda_search.xml new file mode 100644 index 0000000..d183c37 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_referenda_search.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/feature-governance-impl/src/main/res/layout/fragment_referendum_confirm_vote.xml b/feature-governance-impl/src/main/res/layout/fragment_referendum_confirm_vote.xml new file mode 100644 index 0000000..0a67c04 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_referendum_confirm_vote.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_referendum_details.xml b/feature-governance-impl/src/main/res/layout/fragment_referendum_details.xml new file mode 100644 index 0000000..1414c6f --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_referendum_details.xml @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_referendum_full_details.xml b/feature-governance-impl/src/main/res/layout/fragment_referendum_full_details.xml new file mode 100644 index 0000000..b6a42ef --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_referendum_full_details.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_referendum_info.xml b/feature-governance-impl/src/main/res/layout/fragment_referendum_info.xml new file mode 100644 index 0000000..d13d041 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_referendum_info.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_referendum_voters.xml b/feature-governance-impl/src/main/res/layout/fragment_referendum_voters.xml new file mode 100644 index 0000000..23b9a46 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_referendum_voters.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_remove_votes.xml b/feature-governance-impl/src/main/res/layout/fragment_remove_votes.xml new file mode 100644 index 0000000..8e07089 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_remove_votes.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_revoke_delegation_confirm.xml b/feature-governance-impl/src/main/res/layout/fragment_revoke_delegation_confirm.xml new file mode 100644 index 0000000..c11af7f --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_revoke_delegation_confirm.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_select_tracks.xml b/feature-governance-impl/src/main/res/layout/fragment_select_tracks.xml new file mode 100644 index 0000000..3af220f --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_select_tracks.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_setup_vote.xml b/feature-governance-impl/src/main/res/layout/fragment_setup_vote.xml new file mode 100644 index 0000000..51c3c0a --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_setup_vote.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_tinder_gov_basket.xml b/feature-governance-impl/src/main/res/layout/fragment_tinder_gov_basket.xml new file mode 100644 index 0000000..bbc85e7 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_tinder_gov_basket.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/feature-governance-impl/src/main/res/layout/fragment_tinder_gov_cards.xml b/feature-governance-impl/src/main/res/layout/fragment_tinder_gov_cards.xml new file mode 100644 index 0000000..03685ae --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_tinder_gov_cards.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-governance-impl/src/main/res/layout/fragment_voted_referenda.xml b/feature-governance-impl/src/main/res/layout/fragment_voted_referenda.xml new file mode 100644 index 0000000..d2f269f --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_voted_referenda.xml @@ -0,0 +1,38 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/fragment_your_delegations.xml b/feature-governance-impl/src/main/res/layout/fragment_your_delegations.xml new file mode 100644 index 0000000..064b7df --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/fragment_your_delegations.xml @@ -0,0 +1,44 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_delegate.xml b/feature-governance-impl/src/main/res/layout/item_delegate.xml new file mode 100644 index 0000000..9cd59c5 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegate.xml @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_delegate_shimmering.xml b/feature-governance-impl/src/main/res/layout/item_delegate_shimmering.xml new file mode 100644 index 0000000..bd91668 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegate_shimmering.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_delegates_shimmering.xml b/feature-governance-impl/src/main/res/layout/item_delegates_shimmering.xml new file mode 100644 index 0000000..7c0b6e7 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegates_shimmering.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_delegation_track.xml b/feature-governance-impl/src/main/res/layout/item_delegation_track.xml new file mode 100644 index 0000000..da1b5ca --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegation_track.xml @@ -0,0 +1,44 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_delegation_tracks_header.xml b/feature-governance-impl/src/main/res/layout/item_delegation_tracks_header.xml new file mode 100644 index 0000000..a8a7325 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegation_tracks_header.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_delegation_tracks_preset.xml b/feature-governance-impl/src/main/res/layout/item_delegation_tracks_preset.xml new file mode 100644 index 0000000..9e34b41 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegation_tracks_preset.xml @@ -0,0 +1,14 @@ + + diff --git a/feature-governance-impl/src/main/res/layout/item_delegations_header.xml b/feature-governance-impl/src/main/res/layout/item_delegations_header.xml new file mode 100644 index 0000000..025e2af --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegations_header.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_delegations_search_result_count.xml b/feature-governance-impl/src/main/res/layout/item_delegations_search_result_count.xml new file mode 100644 index 0000000..30d8ff9 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegations_search_result_count.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_delegations_sort_and_filter.xml b/feature-governance-impl/src/main/res/layout/item_delegations_sort_and_filter.xml new file mode 100644 index 0000000..5c49771 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegations_sort_and_filter.xml @@ -0,0 +1,29 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_delegator.xml b/feature-governance-impl/src/main/res/layout/item_delegator.xml new file mode 100644 index 0000000..7fa9e7c --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_delegator.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_governance_lock.xml b/feature-governance-impl/src/main/res/layout/item_governance_lock.xml new file mode 100644 index 0000000..9b27213 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_governance_lock.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_governance_total_locks_header.xml b/feature-governance-impl/src/main/res/layout/item_governance_total_locks_header.xml new file mode 100644 index 0000000..2d5ff0a --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_governance_total_locks_header.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_governance_tracks_header.xml b/feature-governance-impl/src/main/res/layout/item_governance_tracks_header.xml new file mode 100644 index 0000000..620fc4e --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_governance_tracks_header.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/feature-governance-impl/src/main/res/layout/item_referenda_group.xml b/feature-governance-impl/src/main/res/layout/item_referenda_group.xml new file mode 100644 index 0000000..a081c46 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_referenda_group.xml @@ -0,0 +1,38 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_referenda_header.xml b/feature-governance-impl/src/main/res/layout/item_referenda_header.xml new file mode 100644 index 0000000..21469bc --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_referenda_header.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_referenda_placeholder.xml b/feature-governance-impl/src/main/res/layout/item_referenda_placeholder.xml new file mode 100644 index 0000000..8ce32e2 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_referenda_placeholder.xml @@ -0,0 +1,13 @@ + + diff --git a/feature-governance-impl/src/main/res/layout/item_referenda_search_placeholder.xml b/feature-governance-impl/src/main/res/layout/item_referenda_search_placeholder.xml new file mode 100644 index 0000000..b36f92e --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_referenda_search_placeholder.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_referenda_shimmering.xml b/feature-governance-impl/src/main/res/layout/item_referenda_shimmering.xml new file mode 100644 index 0000000..1e038bc --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_referenda_shimmering.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_referenda_shimmering_no_groups.xml b/feature-governance-impl/src/main/res/layout/item_referenda_shimmering_no_groups.xml new file mode 100644 index 0000000..c994b6d --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_referenda_shimmering_no_groups.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_referendum.xml b/feature-governance-impl/src/main/res/layout/item_referendum.xml new file mode 100644 index 0000000..e9cead7 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_referendum.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_referendum_shimmering.xml b/feature-governance-impl/src/main/res/layout/item_referendum_shimmering.xml new file mode 100644 index 0000000..5a1953a --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_referendum_shimmering.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_referendum_voter.xml b/feature-governance-impl/src/main/res/layout/item_referendum_voter.xml new file mode 100644 index 0000000..9912fa0 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_referendum_voter.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_timeline_default_item.xml b/feature-governance-impl/src/main/res/layout/item_timeline_default_item.xml new file mode 100644 index 0000000..9fe3b04 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_timeline_default_item.xml @@ -0,0 +1,33 @@ + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_tinder_gov_basket.xml b/feature-governance-impl/src/main/res/layout/item_tinder_gov_basket.xml new file mode 100644 index 0000000..a4548b9 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_tinder_gov_basket.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + diff --git a/feature-governance-impl/src/main/res/layout/item_tinder_gov_card.xml b/feature-governance-impl/src/main/res/layout/item_tinder_gov_card.xml new file mode 100644 index 0000000..4c9b602 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_tinder_gov_card.xml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-governance-impl/src/main/res/layout/item_track.xml b/feature-governance-impl/src/main/res/layout/item_track.xml new file mode 100644 index 0000000..f3c5a16 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_track.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_track_delegation.xml b/feature-governance-impl/src/main/res/layout/item_track_delegation.xml new file mode 100644 index 0000000..3e6becc --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_track_delegation.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_tracks_placeholder.xml b/feature-governance-impl/src/main/res/layout/item_tracks_placeholder.xml new file mode 100644 index 0000000..fb220e2 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_tracks_placeholder.xml @@ -0,0 +1,15 @@ + + diff --git a/feature-governance-impl/src/main/res/layout/item_unavailable_track.xml b/feature-governance-impl/src/main/res/layout/item_unavailable_track.xml new file mode 100644 index 0000000..a2960b4 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_unavailable_track.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/item_unavailable_tracks_group.xml b/feature-governance-impl/src/main/res/layout/item_unavailable_tracks_group.xml new file mode 100644 index 0000000..d9af23c --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/item_unavailable_tracks_group.xml @@ -0,0 +1,39 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/layout_setup_vote_control_aye_nay_abstain.xml b/feature-governance-impl/src/main/res/layout/layout_setup_vote_control_aye_nay_abstain.xml new file mode 100644 index 0000000..1ff0a13 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/layout_setup_vote_control_aye_nay_abstain.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/layout_setup_vote_control_continue.xml b/feature-governance-impl/src/main/res/layout/layout_setup_vote_control_continue.xml new file mode 100644 index 0000000..a0cc19f --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/layout_setup_vote_control_continue.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_amount_changes.xml b/feature-governance-impl/src/main/res/layout/view_amount_changes.xml new file mode 100644 index 0000000..0f282c6 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_amount_changes.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_chip.xml b/feature-governance-impl/src/main/res/layout/view_chip.xml new file mode 100644 index 0000000..109c7c5 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_chip.xml @@ -0,0 +1,38 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_governance_locks.xml b/feature-governance-impl/src/main/res/layout/view_governance_locks.xml new file mode 100644 index 0000000..9c83e89 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_governance_locks.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_referendum_dapp_list.xml b/feature-governance-impl/src/main/res/layout/view_referendum_dapp_list.xml new file mode 100644 index 0000000..a43c2c4 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_referendum_dapp_list.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_vote_control.xml b/feature-governance-impl/src/main/res/layout/view_vote_control.xml new file mode 100644 index 0000000..84c5132 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_vote_control.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_vote_power.xml b/feature-governance-impl/src/main/res/layout/view_vote_power.xml new file mode 100644 index 0000000..f751a94 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_vote_power.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_voters.xml b/feature-governance-impl/src/main/res/layout/view_voters.xml new file mode 100644 index 0000000..2cebd60 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_voters.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_voting_status.xml b/feature-governance-impl/src/main/res/layout/view_voting_status.xml new file mode 100644 index 0000000..413947c --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_voting_status.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_voting_threshold.xml b/feature-governance-impl/src/main/res/layout/view_voting_threshold.xml new file mode 100644 index 0000000..e050843 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_voting_threshold.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_your_delegation.xml b/feature-governance-impl/src/main/res/layout/view_your_delegation.xml new file mode 100644 index 0000000..4b381d0 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_your_delegation.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_your_vote.xml b/feature-governance-impl/src/main/res/layout/view_your_vote.xml new file mode 100644 index 0000000..ca222c8 --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_your_vote.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/layout/view_your_vote_preview.xml b/feature-governance-impl/src/main/res/layout/view_your_vote_preview.xml new file mode 100644 index 0000000..608997b --- /dev/null +++ b/feature-governance-impl/src/main/res/layout/view_your_vote_preview.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/values/attrs.xml b/feature-governance-impl/src/main/res/values/attrs.xml new file mode 100644 index 0000000..2766e32 --- /dev/null +++ b/feature-governance-impl/src/main/res/values/attrs.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/main/res/values/styles.xml b/feature-governance-impl/src/main/res/values/styles.xml new file mode 100644 index 0000000..f028107 --- /dev/null +++ b/feature-governance-impl/src/main/res/values/styles.xml @@ -0,0 +1,40 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_api/domain/locks/ClaimScheduleTestBuilder.kt b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_api/domain/locks/ClaimScheduleTestBuilder.kt new file mode 100644 index 0000000..d368d43 --- /dev/null +++ b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_api/domain/locks/ClaimScheduleTestBuilder.kt @@ -0,0 +1,274 @@ +package io.novafoundation.nova.feature_governance_api.domain.locks + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AccountVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.AyeVote +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendum +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.OnChainReferendumStatus +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.PriorLock +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.ReferendumId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.Voting +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule.ClaimAction +import io.novafoundation.nova.feature_governance_api.domain.locks.ClaimSchedule.UnlockChunk +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.Conviction +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface ClaimScheduleTestBuilder { + + fun given(builder: Given.() -> Unit) + + interface Given { + + fun currentBlock(block: Int) + + fun track(trackId: Int, builder: Track.() -> Unit) + + interface Track { + + fun lock(lock: Int) + + fun voting(builder: Voting.() -> Unit) + + fun delegating(builder: Delegating.() -> Unit) + + interface Voting { + + fun prior(amount: Int, unlockAt: Int) + + fun vote(amount: Int, referendumId: Int, unlockAt: Int) + } + + interface Delegating { + + fun prior(amount: Int, unlockAt: Int) + + fun delegate(amount: Int) + } + } + } + + fun expect(builder: Expect.() -> Unit) + + interface Expect { + + fun claimable(amount: Int, actions: ClaimableActions.() -> Unit) + + fun nonClaimable(amount: Int, claimAt: Int) + + fun nonClaimable(amount: Int) + + interface ClaimableActions { + + fun unlock(trackId: Int) + + fun removeVote(trackId: Int, referendumId: Int) + } + } +} + +fun ClaimScheduleTest(builder: ClaimScheduleTestBuilder.() -> Unit) { + val test = ClaimScheduleTest().apply(builder) + + test.runTest() +} + +private class ClaimScheduleTest : ClaimScheduleTestBuilder { + + private var calculator: RealClaimScheduleCalculator? = null + private var expectedSchedule: ClaimSchedule? = null + + override fun given(builder: ClaimScheduleTestBuilder.Given.() -> Unit) { + calculator = GivenBuilder().apply(builder).build() + } + + override fun expect(builder: ClaimScheduleTestBuilder.Expect.() -> Unit) { + expectedSchedule = ExpectedBuilder().apply(builder).buildSchedule() + } + + fun runTest() { + val actualSchedule = calculator!!.estimateClaimSchedule() + + assert(actualSchedule == expectedSchedule!!) { + buildString { + append("Expected schedule: $expectedSchedule\n") + append("Actual schedule : $actualSchedule\n") + } + } + } +} + +private class ExpectedBuilder : ClaimScheduleTestBuilder.Expect { + + private var chunks = mutableListOf() + + override fun claimable(amount: Int, actionsBuilder: ClaimScheduleTestBuilder.Expect.ClaimableActions.() -> Unit) { + val actions = ClaimableActionsBuilder().apply(actionsBuilder).buildActions() + + chunks.add(UnlockChunk.Claimable(amount.toBigInteger(), actions)) + } + + override fun nonClaimable(amount: Int, claimAt: Int) { + chunks.add(UnlockChunk.Pending(amount.toBigInteger(), ClaimTime.At(claimAt.toBigInteger()))) + } + + override fun nonClaimable(amount: Int) { + chunks.add(UnlockChunk.Pending(amount.toBigInteger(), ClaimTime.UntilAction)) + } + + fun buildSchedule(): ClaimSchedule { + return ClaimSchedule(chunks) + } +} + +private class ClaimableActionsBuilder : ClaimScheduleTestBuilder.Expect.ClaimableActions { + + private val actions = mutableListOf() + + + override fun unlock(trackId: Int) { + val action = ClaimAction.Unlock(TrackId(trackId.toBigInteger())) + actions.add(action) + } + + override fun removeVote(trackId: Int, referendumId: Int) { + val trackIdTyped = TrackId(trackId.toBigInteger()) + val referendumIdTyped = ReferendumId(referendumId.toBigInteger()) + + val action = ClaimAction.RemoveVote(trackIdTyped, referendumIdTyped) + actions.add(action) + } + + fun buildActions(): List { + return actions + } +} + +private class GivenBuilder : ClaimScheduleTestBuilder.Given { + + private var voting: MutableMap = mutableMapOf() + private var currentBlockNumber: BlockNumber = BlockNumber.ZERO + private var referenda: MutableMap = mutableMapOf() + private var trackLocks: MutableMap = mutableMapOf() + + override fun currentBlock(block: Int) { + currentBlockNumber = block.toBigInteger() + } + + override fun track(trackId: Int, builder: ClaimScheduleTestBuilder.Given.Track.() -> Unit) { + val trackIdTyped = TrackId(trackId.toBigInteger()) + val builtTrack = TrackBuilder().apply(builder) + + voting[trackIdTyped] = builtTrack.buildVoting() + + val newReferenda = builtTrack.buildReferendaApprovedAt().mapValues { (referendaId, approvedAt) -> + OnChainReferendum( + id = referendaId, + status = OnChainReferendumStatus.Approved(since = approvedAt) + ) + } + referenda += newReferenda + + trackLocks[trackIdTyped] = builtTrack.buildTrackLock() + } + + fun build(): RealClaimScheduleCalculator { + return RealClaimScheduleCalculator( + votingByTrack = voting, + currentBlockNumber = currentBlockNumber, + referenda = referenda, + trackLocks = trackLocks, + + // those parameters are only used for ongoing referenda estimation + // we only use approved ones in this tests + tracks = emptyMap(), + undecidingTimeout = BlockNumber.ZERO, + + // we do not use conviction in tests + voteLockingPeriod = BlockNumber.ZERO + ) + } + +} + +private fun PriorLock(): PriorLock = PriorLock(BlockNumber.ZERO, Balance.ZERO) + +private class VotingBuilder : ClaimScheduleTestBuilder.Given.Track.Voting { + + private var prior: PriorLock = PriorLock() + private val votes = mutableMapOf() + private var referendumApprovedAt = mutableMapOf() + override fun prior(amount: Int, unlockAt: Int) { + prior = PriorLock(unlockAt = unlockAt.toBigInteger(), amount = amount.toBigInteger()) + } + + override fun vote(amount: Int, referendumId: Int, unlockAt: Int) { + val referendumIdTyped = ReferendumId(referendumId.toBigInteger()) + votes[referendumIdTyped] = AyeVote(amount.toBigInteger(), Conviction.None) + referendumApprovedAt[referendumIdTyped] = unlockAt.toBigInteger() + } + + fun buildReferendaApprovedAt(): Map { + return referendumApprovedAt + } + + fun build(): Voting.Casting = Voting.Casting(votes, prior) +} + +private class DelegatingBuilder : ClaimScheduleTestBuilder.Given.Track.Delegating { + + private var prior: PriorLock = PriorLock() + private var delegation: Balance? = null + override fun prior(amount: Int, unlockAt: Int) { + prior = PriorLock(unlockAt = unlockAt.toBigInteger(), amount = amount.toBigInteger()) + } + + override fun delegate(amount: Int) { + delegation = amount.toBigInteger() + } + + fun build(): Voting.Delegating { + return Voting.Delegating( + amount = requireNotNull(delegation), + target = AccountId(32), + conviction = Conviction.None, // we don't use conviction since it doesn't matter until undelegated + prior = prior, + ) + } +} + +private class TrackBuilder : ClaimScheduleTestBuilder.Given.Track { + + private var trackLock: Balance = Balance.ZERO + + private var voting: Voting = Voting.Casting(emptyMap(), PriorLock()) + private var referendumApprovedAt = mapOf() + + override fun lock(lock: Int) { + trackLock = lock.toBigInteger() + } + + override fun voting(builder: ClaimScheduleTestBuilder.Given.Track.Voting.() -> Unit) { + val votingBuilder = VotingBuilder().apply(builder) + + voting = votingBuilder.build() + referendumApprovedAt = votingBuilder.buildReferendaApprovedAt() + } + + override fun delegating(builder: ClaimScheduleTestBuilder.Given.Track.Delegating.() -> Unit) { + voting = DelegatingBuilder().apply(builder).build() + } + + fun buildVoting(): Voting { + return voting + } + + fun buildReferendaApprovedAt(): Map { + return referendumApprovedAt + } + + fun buildTrackLock(): Balance { + return trackLock + } +} diff --git a/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_api/domain/locks/RealClaimScheduleCalculatorTest.kt b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_api/domain/locks/RealClaimScheduleCalculatorTest.kt new file mode 100644 index 0000000..932b907 --- /dev/null +++ b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_api/domain/locks/RealClaimScheduleCalculatorTest.kt @@ -0,0 +1,480 @@ +package io.novafoundation.nova.feature_governance_api.domain.locks + +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class RealClaimScheduleCalculatorTest { + + @Test + fun `should handle empty case`() = ClaimScheduleTest { + given { + } + + expect { + + } + } + + @Test + fun `should handle single claimable`() = ClaimScheduleTest { + given { + currentBlock(1000) + + track(0) { + voting { + vote(amount = 1, referendumId = 0, unlockAt = 1000) + } + } + } + + expect { + claimable(amount = 1) { + removeVote(trackId = 0, referendumId = 0) + unlock(trackId = 0) + } + } + } + + @Test + fun `should handle both passed and not priors`() = ClaimScheduleTest { + given { + currentBlock(1000) + + track(0) { + voting { + prior(amount = 2, unlockAt = 1000) + } + } + + track(1) { + voting { + prior(amount = 1, unlockAt = 1100) + } + } + } + + expect { + claimable(amount = 1) { + unlock(trackId = 0) + } + + nonClaimable(amount = 1, claimAt = 1100) + } + } + + @Test + fun `should extend votes by prior`() = ClaimScheduleTest { + given { + currentBlock(1000) + + track(0) { + voting { + prior(amount = 1, unlockAt = 1100) + + vote(amount = 2, unlockAt = 1000, referendumId = 1) + } + } + } + + expect { + nonClaimable(amount = 2, claimAt = 1100) + } + } + + @Test + fun `should take max between two locks with same time`() = ClaimScheduleTest { + given { + currentBlock(1000) + + track(0) { + voting { + vote(amount = 8, referendumId = 0, unlockAt = 1000) + vote(amount = 2, referendumId = 1, unlockAt = 1000) + } + } + } + + expect { + claimable(amount = 8) { + removeVote(trackId = 0, referendumId = 0) + removeVote(trackId = 0, referendumId = 1) + unlock(trackId = 0) + } + } + } + + @Test + fun `should handle rejigged prior`() = ClaimScheduleTest { + given { + currentBlock(1200) + + track(0) { + voting { + prior(amount = 1, unlockAt = 1100) + + vote(amount = 2, unlockAt = 1000, referendumId = 1) + } + } + } + + expect { + claimable(amount = 2) { + removeVote(trackId = 0, referendumId = 1) + unlock(trackId = 0) + } + } + } + + @Test + fun `should fold several claimable to one`() = ClaimScheduleTest { + given { + currentBlock(1100) + + track(0) { + lock(0) + + voting { + vote(amount = 1, referendumId = 0, unlockAt = 1100) + } + } + + track(1) { + lock(0) + + voting { + vote(amount = 2, referendumId = 1, unlockAt = 1000) + } + } + } + + expect { + claimable(amount = 2) { + removeVote(trackId = 1, referendumId = 1) + removeVote(trackId = 0, referendumId = 0) + + unlock(trackId = 1) + unlock(trackId = 0) + } + } + } + + + @Test + fun `should include shadowed actions`() = ClaimScheduleTest { + given { + currentBlock(1200) + + track(1) { + lock(0) + + voting { + vote(amount = 1, referendumId = 1, unlockAt = 1000) + } + } + + track(2) { + lock(0) + + voting { + vote(amount = 2, referendumId = 2, unlockAt = 1100) + } + } + + track(3) { + lock(0) + + voting { + vote(1, referendumId = 3, unlockAt = 1200) + } + } + } + + expect { + claimable(amount = 2) { + removeVote(trackId = 2, referendumId = 2) + removeVote(trackId = 1, referendumId = 1) + removeVote(trackId = 3, referendumId = 3) + + unlock(2) + unlock(1) + unlock(3) + } + } + } + + @Test + fun `should take gap into account`() = ClaimScheduleTest { + given { + currentBlock(1000) + + track(0) { + lock(10) + + voting { + vote(amount = 2, referendumId = 0, unlockAt = 1000) + } + } + } + + expect { + claimable(amount = 10) { + removeVote(trackId = 0, referendumId = 0) + unlock(trackId = 0) + } + } + } + + + @Test + fun `gap should be limited with other locks`() = ClaimScheduleTest { + given { + currentBlock(1000) + + track(0) { + lock(10) + + voting { + vote(amount = 1, referendumId = 0, unlockAt = 1000) + } + } + + track(1) { + voting { + prior(amount = 10, unlockAt = 1000) + } + } + + track(2) { + voting { + prior(amount = 1, unlockAt = 1100) + } + } + } + + expect { + claimable(amount = 9) { + removeVote(trackId = 0, referendumId = 0) + unlock(trackId = 0) + + unlock(trackId = 1) + } + + nonClaimable(amount = 1, claimAt = 1100) + } + } + + @Test + fun `gap claim should be delayed`() = ClaimScheduleTest { + given { + currentBlock(1000) + + track(0) { + lock(10) + } + + track(1) { + voting { + prior(amount = 10, unlockAt = 1100) + } + } + } + + expect { + nonClaimable(amount = 10, claimAt = 1100) + } + } + + + @Test + fun `should not dublicate unlock command with both prior and gap present`() = ClaimScheduleTest { + given { + currentBlock(1100) + + track(0) { + lock(10) + + voting { + prior(amount = 5, unlockAt = 1050) + } + } + } + + expect { + claimable(amount = 10) { + unlock(trackId = 0) + } + } + } + + @Test + fun `pending should be sorted by remaining time`() = ClaimScheduleTest { + given { + currentBlock(1000) + + track(0) { + voting { + vote(amount = 3, unlockAt = 1100, referendumId = 0) + vote(amount = 2, unlockAt = 1200, referendumId = 2) + vote(amount = 1, unlockAt = 1300, referendumId = 1) + } + } + } + + expect { + nonClaimable(amount = 1, claimAt = 1100) + nonClaimable(amount = 1, claimAt = 1200) + nonClaimable(amount = 1, claimAt = 1300) + } + } + + @Test + fun `gap should not be covered by its track locks`() = ClaimScheduleTest { + given { + currentBlock(1000) + + track(20) { + lock(1) + + voting { + vote(amount = 1, unlockAt = 2000, referendumId = 13) + } + } + + track(21) { + // gap is 101 - 10 = 91 - should not be delayed by its own track voting + lock(101) + + voting { + vote(amount = 10, unlockAt = 1500, referendumId = 5) + } + } + } + + expect { + claimable(amount = 91) { + unlock(21) + } + + nonClaimable(amount = 9, claimAt = 1500) + nonClaimable(amount = 1, claimAt = 2000) + } + } + + @Test + fun `should handle standalone delegation`() = ClaimScheduleTest{ + given { + track(0) { + delegating { + delegate(1) + } + } + } + + expect { + nonClaimable(amount = 1) + } + } + + @Test + fun `should take delegation prior lock into account`() = ClaimScheduleTest{ + given { + currentBlock(1000) + + track(0) { + delegating { + prior(amount = 10, unlockAt = 1100) + + delegate(1) + } + } + } + + expect { + nonClaimable(amount = 9, claimAt = 1100) // prior is 10, but 1 is delayed because of delegation + nonClaimable(amount = 1) + } + } + + @Test + fun `delegation plus gap case`() = ClaimScheduleTest{ + given { + currentBlock(1000) + + track(0) { + lock(10) + + delegating { + delegate(1) + } + } + } + + expect { + claimable(amount = 9) { + unlock(0) + } + nonClaimable(amount = 1) + } + } + + @Test + fun `delegate plus voting case`() = ClaimScheduleTest{ + given { + currentBlock(1000) + + track(0) { + delegating { + delegate(1) + } + } + + track(1) { + voting { + prior(10, unlockAt = 1000) + + vote(amount = 5, unlockAt = 1100, referendumId = 0) + } + } + } + + expect { + + // 5 is claimable from track 1 priors + claimable(amount = 5) { + unlock(1) + } + // 4 is delayed until 1100 from track 1 votes + nonClaimable(amount = 4, claimAt = 1100) + + // 1 is delayed indefinitely because of track 1 delegation + nonClaimable(amount = 1) + } + } + + @Test + fun `should not dublicate unlcock when claiming multiple chunks`() = ClaimScheduleTest { + given { + currentBlock(1100) + + track(1) { + lock(10) + + voting { + vote(amount = 5, unlockAt = 1002, referendumId = 2) + vote(amount = 10, unlockAt = 1001, referendumId = 1) + } + } + } + + expect { + claimable(amount = 10) { + removeVote(trackId = 1, referendumId = 1) + removeVote(trackId = 1, referendumId = 2) + + unlock(trackId = 1) + } + } + } +} diff --git a/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/Common.kt b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/Common.kt new file mode 100644 index 0000000..900ebab --- /dev/null +++ b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/Common.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_governance_impl.data.model.curve + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.utils.hasTheSaveValueAs +import io.novafoundation.nova.common.utils.percentageToFraction +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.VotingCurve +import org.junit.Assert +import java.math.BigDecimal +import java.math.BigInteger +import java.math.MathContext + +val Int.percent + get() = this.toBigDecimal().percentageToFraction() + +fun VotingCurve.runThresholdTests(tests: List>) { + tests.forEach { (x, expectedY) -> + val y = threshold(x) + Assert.assertTrue("Expected: ${expectedY}, got: ${y}", expectedY hasTheSaveValueAs y) + } +} + +fun VotingCurve.runDelayTests(tests: List>) { + tests.forEach { (x, expectedY) -> + val y = delay(x) + Assert.assertTrue("Expected $expectedY for input $x but got: $y", expectedY hasTheSaveValueAs y) + } +} diff --git a/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/LinearDecreasingCurveTest.kt b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/LinearDecreasingCurveTest.kt new file mode 100644 index 0000000..2b4db13 --- /dev/null +++ b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/LinearDecreasingCurveTest.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_governance_impl.data.model.curve + +import io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve.LinearDecreasingCurve +import org.junit.Test + +class LinearDecreasingCurveTest { + + val curve = LinearDecreasingCurve( + ceil = 90.percent, + floor = 10.percent, + length = 50.percent, + ) + + // x to y + private val THRESHOLD_TESTS = listOf( + 0.percent to 90.percent, + 25.percent to 50.percent, + 50.percent to 10.percent, + 100.percent to 10.percent + ) + + // y to x + private val DELAY_TESTS = listOf( + 100.percent to 0.percent, + 90.percent to 0.percent, + 50.percent to 25.percent, + 10.percent to 50.percent, + 9.percent to 100.percent, + 0.percent to 100.percent + ) + + @Test + fun threshold() { + curve.runThresholdTests(THRESHOLD_TESTS) + } + + @Test + fun delay() { + curve.runDelayTests(DELAY_TESTS) + } +} diff --git a/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/ReciprocalCurveTest.kt b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/ReciprocalCurveTest.kt new file mode 100644 index 0000000..f555ffa --- /dev/null +++ b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/ReciprocalCurveTest.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_governance_impl.data.model.curve + +import io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve.ReciprocalCurve +import org.junit.Test +import java.math.BigDecimal + +class ReciprocalCurveTest { + + // 10/(x + 1) - 1 + val curve = ReciprocalCurve( + factor = BigDecimal.TEN, + xOffset = BigDecimal.ONE, + yOffset = (-1).toBigDecimal() + ) + + // x to y + private val TESTS = listOf( + BigDecimal.ZERO to 9.toBigDecimal(), + 0.25.toBigDecimal() to 7.toBigDecimal(), + BigDecimal.ONE to 4.toBigDecimal(), + 3.toBigDecimal() to 1.5.toBigDecimal() + ) + + // y to x + private val DELAY_TESTS = listOf( + 9.toBigDecimal() to BigDecimal.ZERO, + 7.toBigDecimal() to 0.25.toBigDecimal(), + 4.toBigDecimal() to BigDecimal.ONE + ) + + @Test + fun threshold() { + curve.runThresholdTests(TESTS) + } + + @Test + fun delay() { + curve.runDelayTests(DELAY_TESTS) + } +} diff --git a/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/SteppedDecreasingCurveTest.kt b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/SteppedDecreasingCurveTest.kt new file mode 100644 index 0000000..587f8d5 --- /dev/null +++ b/feature-governance-impl/src/test/java/io/novafoundation/nova/feature_governance_impl/data/model/curve/SteppedDecreasingCurveTest.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_governance_impl.data.model.curve + +import io.novafoundation.nova.common.utils.lessEpsilon +import io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve.LinearDecreasingCurve +import io.novafoundation.nova.feature_governance_api.data.thresold.gov2.curve.SteppedDecreasingCurve +import java.math.BigDecimal +import org.junit.Test + +class SteppedDecreasingCurveTest { + + val curve = SteppedDecreasingCurve( + begin = 80.percent, + end = 30.percent, + step = 10.percent, + period = 15.percent + ) + + // x to y + private val TESTS = listOf( + 0.percent to 80.percent, + 15.percent.lessEpsilon() to 80.percent, + 15.percent to 70.percent, + 30.percent.lessEpsilon() to 70.percent, + 30.percent to 60.percent, + 100.percent to 30.percent + ) + + // y to x + private val DELAY_TESTS = listOf( + 80.percent to 0.percent, + 70.percent to 15.percent, + 60.percent to 30.percent, + 30.percent to 75.percent, + 10.percent to 100.percent + ) + + @Test + fun threshold() { + curve.runThresholdTests(TESTS) + } + + @Test + fun delay() { + curve.runDelayTests(DELAY_TESTS) + } +} diff --git a/feature-ledger-api/.gitignore b/feature-ledger-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-ledger-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-ledger-api/build.gradle b/feature-ledger-api/build.gradle new file mode 100644 index 0000000..4602cae --- /dev/null +++ b/feature-ledger-api/build.gradle @@ -0,0 +1,18 @@ + +android { + namespace 'io.novafoundation.nova.feature_ledger_api' +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + + implementation daggerDep + + implementation substrateSdkDep + + implementation androidDep + + api project(':core-api') +} \ No newline at end of file diff --git a/feature-ledger-api/consumer-rules.pro b/feature-ledger-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-ledger-api/proguard-rules.pro b/feature-ledger-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-ledger-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-ledger-api/src/main/AndroidManifest.xml b/feature-ledger-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/feature-ledger-api/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/data/repository/LedgerDerivationPath.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/data/repository/LedgerDerivationPath.kt new file mode 100644 index 0000000..c9b4143 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/data/repository/LedgerDerivationPath.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_ledger_api.data.repository + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +object LedgerDerivationPath { + + private const val LEDGER_DERIVATION_PATH_KEY = "LedgerChainAccount.derivationPath" + + fun legacyDerivationPathSecretKey(chainId: ChainId): String { + return "$LEDGER_DERIVATION_PATH_KEY.$chainId" + } + + fun genericDerivationPathSecretKey(): String { + return "$LEDGER_DERIVATION_PATH_KEY.Generic" + } +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/data/repository/LedgerRepository.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/data/repository/LedgerRepository.kt new file mode 100644 index 0000000..6ae0140 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/data/repository/LedgerRepository.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_ledger_api.data.repository + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface LedgerRepository { + + suspend fun getChainAccountDerivationPath( + metaId: Long, + chainId: ChainId + ): String + + suspend fun getGenericDerivationPath(metaId: Long): String +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/di/LedgerFeatureApi.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/di/LedgerFeatureApi.kt new file mode 100644 index 0000000..fa61f2e --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/di/LedgerFeatureApi.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_ledger_api.di + +interface LedgerFeatureApi diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/LedgerApplicationResponse.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/LedgerApplicationResponse.kt new file mode 100644 index 0000000..e84e683 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/LedgerApplicationResponse.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.application.substrate + +enum class LedgerApplicationResponse(val code: UShort) { + UNKNOWN(1u), + BAD_REQUEST(2u), + UNSUPPORTED(3u), + INELIGIBLE_DEVICE(4u), + TIMEOUT_U2F(5u), + TIMEOUT(14u), + NO_ERROR(0x9000u), + DEVICE_BUSY(0x9001u), + DERIVING_KEY_ERROR(0x6802u), + EXECUTION_ERROR(0x6400u), + WRONG_LENGTH(0x6700u), + EMPTY_BUFFER(0x6982u), + OUTPUT_BUFFER_TOO_SMALL(0x6983u), + INVALID_DATA(0x6984u), + CONDITIONS_NOT_SATISFIED(0x6985u), + TRANSACTION_REJECTED(0x6986u), + BAD_KEY(0x6A80u), + INVALID_P1P2(0x6B00u), + INSTRUCTION_NOT_SUPPORTED(0x6D00u), + WRONG_APP_OPEN(0x6E00u), + APP_NOT_OPEN(0x6E01u), + UNKNOWN_ERROR(0x6F00u), + SIGN_VERIFY_ERROR(0x6F01u); + + companion object { + fun fromCode(code: UShort): LedgerApplicationResponse { + return values().firstOrNull { it.code == code } ?: UNKNOWN + } + } +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/LedgerSubstrateAccount.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/LedgerSubstrateAccount.kt new file mode 100644 index 0000000..af78a66 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/LedgerSubstrateAccount.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.application.substrate + +import io.novasama.substrate_sdk_android.encrypt.EncryptionType +import io.novasama.substrate_sdk_android.extensions.asEthereumAccountId +import io.novasama.substrate_sdk_android.extensions.toAddress +import io.novasama.substrate_sdk_android.runtime.AccountId + +class LedgerSubstrateAccount( + val address: String, + val publicKey: ByteArray, + val encryptionType: EncryptionType, + val derivationPath: String, +) + +// Ledger EVM shares derivation path with Ledger Substrate +class LedgerEvmAccount( + val accountId: AccountId, + val publicKey: ByteArray, +) + +fun LedgerEvmAccount.address(): String { + return accountId.asEthereumAccountId().toAddress().value +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/SubstrateApplicationConfig.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/SubstrateApplicationConfig.kt new file mode 100644 index 0000000..5ac5c16 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/SubstrateApplicationConfig.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.application.substrate + +import io.novafoundation.nova.feature_ledger_api.BuildConfig +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class SubstrateApplicationConfig( + val chainId: String, + val coin: Int, + val cla: UByte +) { + + companion object { + + private val ALL by lazy { + listOfNotNull( + SubstrateApplicationConfig(chainId = Chain.Geneses.POLKADOT, coin = 354, cla = 0x90u), + SubstrateApplicationConfig(chainId = Chain.Geneses.KUSAMA, coin = 434, cla = 0x99u), + SubstrateApplicationConfig(chainId = Chain.Geneses.STATEMINT, coin = 354, cla = 0x96u), + SubstrateApplicationConfig(chainId = Chain.Geneses.KUSAMA_ASSET_HUB, coin = 434, cla = 0x97u), + SubstrateApplicationConfig(chainId = Chain.Geneses.EDGEWARE, coin = 523, cla = 0x94u), + SubstrateApplicationConfig(chainId = Chain.Geneses.KARURA, coin = 686, cla = 0x9au), + SubstrateApplicationConfig(chainId = Chain.Geneses.ACALA, coin = 787, cla = 0x9bu), + SubstrateApplicationConfig(chainId = Chain.Geneses.NODLE_PARACHAIN, coin = 1003, cla = 0x98u), + SubstrateApplicationConfig(chainId = Chain.Geneses.POLYMESH, coin = 595, cla = 0x91u), + SubstrateApplicationConfig(chainId = Chain.Geneses.XX_NETWORK, coin = 1955, cla = 0xa3u), + SubstrateApplicationConfig(chainId = Chain.Geneses.ASTAR, coin = 810, cla = 0xa9u), + SubstrateApplicationConfig(chainId = Chain.Geneses.ALEPH_ZERO, coin = 643, cla = 0xa4u), + SubstrateApplicationConfig(chainId = Chain.Geneses.POLKADEX, coin = 799, cla = 0xa0u), + + novasamaLedgerTestnetFakeApp() + ) + } + + fun all() = ALL + + private fun novasamaLedgerTestnetFakeApp(): SubstrateApplicationConfig? { + return SubstrateApplicationConfig(chainId = "d67c91ca75c199ff1ee9555567dfad21b9033165c39977170ec8d3f6c1fa433c", coin = 434, cla = 0x90u) + .takeIf { BuildConfig.DEBUG } + } + } +} + +fun SubstrateApplicationConfig.Companion.supports(chainId: String): Boolean { + return all().any { it.chainId == chainId } +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/SubstrateLedgerApplication.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/SubstrateLedgerApplication.kt new file mode 100644 index 0000000..02189e0 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/SubstrateLedgerApplication.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.application.substrate + +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication + +interface SubstrateLedgerApplication { + + suspend fun getSubstrateAccount( + device: LedgerDevice, + chainId: ChainId, + accountIndex: Int, + confirmAddress: Boolean + ): LedgerSubstrateAccount + + suspend fun getEvmAccount( + device: LedgerDevice, + accountIndex: Int, + confirmAddress: Boolean + ): LedgerEvmAccount? + + suspend fun getSignature( + device: LedgerDevice, + metaId: Long, + chainId: ChainId, + payload: InheritedImplication, + ): SignatureWrapper +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/SubstrateLedgerApplicationError.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/SubstrateLedgerApplicationError.kt new file mode 100644 index 0000000..622cda7 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/application/substrate/SubstrateLedgerApplicationError.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.application.substrate + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +sealed class SubstrateLedgerApplicationError(message: String) : Exception(message) { + + class UnsupportedApp(val chainId: ChainId) : SubstrateLedgerApplicationError("Unsupported app for chainId: $chainId") + + class Response(val response: LedgerApplicationResponse, val errorMessage: String?) : + SubstrateLedgerApplicationError("Application error: $response") +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/connection/LedgerConnection.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/connection/LedgerConnection.kt new file mode 100644 index 0000000..441215d --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/connection/LedgerConnection.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.connection + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +interface LedgerConnection { + + enum class Type { + BLE, + USB + } + + val channel: Short? + + val type: Type + + val isActive: Flow + + suspend fun mtu(): Int + + suspend fun send(chunks: List) + + suspend fun connect(): Result + + suspend fun resetReceiveChannel() + + val receiveChannel: Channel +} + +suspend fun LedgerConnection.ensureConnected() { + if (!isConnected()) connect().getOrThrow() +} +suspend fun LedgerConnection.awaitConnected() = isActive.first { connected -> connected } +suspend fun LedgerConnection.isConnected() = isActive.first() diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/device/LedgerDevice.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/device/LedgerDevice.kt new file mode 100644 index 0000000..ee9b733 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/device/LedgerDevice.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.device + +import io.novafoundation.nova.feature_ledger_api.sdk.connection.LedgerConnection + +class LedgerDevice( + val id: String, + val deviceType: LedgerDeviceType, + val name: String?, + val connection: LedgerConnection, +) { + + override fun toString(): String { + return "${name ?: id} (${connection.type})" + } +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/device/LedgerDeviceType.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/device/LedgerDeviceType.kt new file mode 100644 index 0000000..396d5ce --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/device/LedgerDeviceType.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.device + +import io.novafoundation.nova.common.utils.toUuid +import java.util.UUID + +private const val LEDGER_VENDOR_ID = 11415 + +// You can find this and new devices here: https://github.com/LedgerHQ/device-sdk-ts/blob/develop/packages/device-management-kit/src/api/device-model/data/StaticDeviceModelDataSource.ts +enum class LedgerDeviceType( + val bleDevice: BleDevice, + val usbOptions: UsbDeviceInfo +) { + STAX( + BleDevice.Supported( + serviceUuid = "13d63400-2c97-6004-0000-4c6564676572".toUuid(), + notifyUuid = "13d63400-2c97-6004-0001-4c6564676572".toUuid(), + writeUuid = "13d63400-2c97-6004-0002-4c6564676572".toUuid() + ), + usbOptions = UsbDeviceInfo(vendorId = LEDGER_VENDOR_ID, productId = 24576) + ), + + FLEX( + BleDevice.Supported( + serviceUuid = "13d63400-2c97-3004-0000-4c6564676572".toUuid(), + notifyUuid = "13d63400-2c97-3004-0001-4c6564676572".toUuid(), + writeUuid = "13d63400-2c97-3004-0002-4c6564676572".toUuid() + ), + usbOptions = UsbDeviceInfo(vendorId = LEDGER_VENDOR_ID, productId = 28672) + ), + + NANO_X( + BleDevice.Supported( + serviceUuid = "13d63400-2c97-0004-0000-4c6564676572".toUuid(), + notifyUuid = "13d63400-2c97-0004-0001-4c6564676572".toUuid(), + writeUuid = "13d63400-2c97-0004-0002-4c6564676572".toUuid() + ), + usbOptions = UsbDeviceInfo(vendorId = LEDGER_VENDOR_ID, productId = 16401) + ), + + NANO_S_PLUS( + BleDevice.NotSupported, + usbOptions = UsbDeviceInfo(vendorId = LEDGER_VENDOR_ID, productId = 20480) + ), + + NANO_S( + BleDevice.NotSupported, + usbOptions = UsbDeviceInfo(vendorId = LEDGER_VENDOR_ID, productId = 4113) + ), + + NANO_GEN5( + BleDevice.Supported( + BleDevice.Supported.Spec( + serviceUuid = "13d63400-2c97-8004-0000-4c6564676572".toUuid(), + notifyUuid = "13d63400-2c97-8004-0001-4c6564676572".toUuid(), + writeUuid = "13d63400-2c97-8004-0002-4c6564676572".toUuid() + ), + BleDevice.Supported.Spec( + serviceUuid = "13d63400-2c97-9004-0000-4c6564676572".toUuid(), + notifyUuid = "13d63400-2c97-9004-0001-4c6564676572".toUuid(), + writeUuid = "13d63400-2c97-9004-0002-4c6564676572".toUuid() + ) + ), + usbOptions = UsbDeviceInfo(vendorId = LEDGER_VENDOR_ID, productId = 32768) + ) +} + +sealed interface BleDevice { + + object NotSupported : BleDevice + + class Supported( + vararg val specs: Spec + ) : BleDevice { + + constructor( + serviceUuid: UUID, + writeUuid: UUID, + notifyUuid: UUID, + ) : this(Spec(serviceUuid, writeUuid, notifyUuid)) + + class Spec( + val serviceUuid: UUID, + val writeUuid: UUID, + val notifyUuid: UUID, + ) + } +} + +fun LedgerDeviceType.supportedBleSpecs(): List { + return (bleDevice as? BleDevice.Supported) + ?.specs?.toList() + .orEmpty() +} + +class UsbDeviceInfo( + val vendorId: Int, + val productId: Int +) diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/discovery/DiscoveryMethods.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/discovery/DiscoveryMethods.kt new file mode 100644 index 0000000..f84e65f --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/discovery/DiscoveryMethods.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.discovery + +import io.novafoundation.nova.common.utils.filterToSet + +@JvmInline +value class DiscoveryMethods(val methods: List) { + + constructor(vararg methods: Method) : this(methods.toList()) + + enum class Method { + BLE, + USB + } + + companion object { + fun all() = DiscoveryMethods(Method.BLE, Method.USB) + } +} + +enum class DiscoveryRequirement { + BLUETOOTH, LOCATION +} + +fun DiscoveryMethods.filterBySatisfiedRequirements( + discoveryRequirementAvailability: DiscoveryRequirementAvailability +): Set { + return methods.filterToSet { method -> + val methodRequirements = method.discoveryRequirements() + val requirementsSatisfied = methodRequirements.all { it in discoveryRequirementAvailability.satisfiedRequirements } + val availableWithPermissions = methodRequirements.availableWithPermissions(discoveryRequirementAvailability.permissionsGranted) + + requirementsSatisfied && availableWithPermissions + } +} + +private fun List.availableWithPermissions(permissionsGranted: Boolean): Boolean { + return if (isEmpty()) { + true + } else { + permissionsGranted + } +} + +fun DiscoveryMethods.discoveryRequirements() = methods.flatMap { + when (it) { + DiscoveryMethods.Method.BLE -> listOf(DiscoveryRequirement.BLUETOOTH, DiscoveryRequirement.LOCATION) + DiscoveryMethods.Method.USB -> emptyList() + } +} + +fun DiscoveryMethods.Method.discoveryRequirements(): List { + return when (this) { + DiscoveryMethods.Method.BLE -> listOf(DiscoveryRequirement.BLUETOOTH, DiscoveryRequirement.LOCATION) + DiscoveryMethods.Method.USB -> emptyList() + } +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/discovery/DiscoveryRequirementAvailability.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/discovery/DiscoveryRequirementAvailability.kt new file mode 100644 index 0000000..c0c2607 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/discovery/DiscoveryRequirementAvailability.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.discovery + +data class DiscoveryRequirementAvailability( + val satisfiedRequirements: Set, + val permissionsGranted: Boolean +) + +fun DiscoveryRequirementAvailability.grantPermissions(): DiscoveryRequirementAvailability { + return copy(permissionsGranted = true) +} + +fun DiscoveryRequirementAvailability.satisfyRequirement(requirement: DiscoveryRequirement): DiscoveryRequirementAvailability { + return copy( + satisfiedRequirements = satisfiedRequirements + requirement + ) +} + +fun DiscoveryRequirementAvailability.missRequirement(requirement: DiscoveryRequirement): DiscoveryRequirementAvailability { + return copy( + satisfiedRequirements = satisfiedRequirements - requirement + ) +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/discovery/LedgerDeviceDiscoveryService.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/discovery/LedgerDeviceDiscoveryService.kt new file mode 100644 index 0000000..19ac241 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/discovery/LedgerDeviceDiscoveryService.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.discovery + +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +interface LedgerDeviceDiscoveryService { + + val discoveredDevices: Flow> + + val errors: Flow + + fun startDiscovery(methods: Set) + + fun stopDiscovery(methods: Set) + + fun stopDiscovery() +} + +suspend fun LedgerDeviceDiscoveryService.findDevice(id: String): LedgerDevice? { + val devices = discoveredDevices.first() + + return devices.find { it.id == id } +} + +suspend fun LedgerDeviceDiscoveryService.findDeviceOrThrow(id: String): LedgerDevice { + return findDevice(id) ?: throw IllegalArgumentException("Device not found") +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/transport/LedgerTransport.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/transport/LedgerTransport.kt new file mode 100644 index 0000000..ba25bd7 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/transport/LedgerTransport.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.transport + +import io.novafoundation.nova.common.utils.bigEndianBytes +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice + +interface LedgerTransport { + + suspend fun exchange(data: ByteArray, device: LedgerDevice): ByteArray +} + +@OptIn(ExperimentalUnsignedTypes::class) +suspend fun LedgerTransport.send( + cla: UByte, + ins: UByte, + p1: UByte, + p2: UByte, + data: ByteArray, + device: LedgerDevice +): ByteArray { + var message = ubyteArrayOf(cla, ins, p1, p2) + + if (data.isNotEmpty()) { + if (data.size < 256) { + message += data.size.toUByte() + } else { + message += 0x00u + message += data.size.toShort().bigEndianBytes.toUByteArray() + } + + message += data.toUByteArray() + } + + return exchange(message.toByteArray(), device) +} diff --git a/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/transport/LedgerTransportError.kt b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/transport/LedgerTransportError.kt new file mode 100644 index 0000000..c018052 --- /dev/null +++ b/feature-ledger-api/src/main/java/io/novafoundation/nova/feature_ledger_api/sdk/transport/LedgerTransportError.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_ledger_api.sdk.transport + +class LedgerTransportError(val reason: Reason) : Exception(reason.toString()) { + + enum class Reason { + NO_HEADER_FOUND, UNSUPPORTED_RESPONSE, INCOMPLETE_RESPONSE, NO_MESSAGE_SIZE_FOUND + } +} diff --git a/feature-ledger-core/.gitignore b/feature-ledger-core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-ledger-core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-ledger-core/build.gradle b/feature-ledger-core/build.gradle new file mode 100644 index 0000000..93c1c87 --- /dev/null +++ b/feature-ledger-core/build.gradle @@ -0,0 +1,19 @@ + +android { + namespace 'io.novafoundation.nova.feature_ledger_core' +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + + implementation substrateSdkDep + + implementation daggerDep + ksp daggerCompiler + + implementation androidDep + + api project(':core-api') +} \ No newline at end of file diff --git a/feature-ledger-core/consumer-rules.pro b/feature-ledger-core/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-ledger-core/proguard-rules.pro b/feature-ledger-core/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-ledger-core/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-ledger-core/src/main/AndroidManifest.xml b/feature-ledger-core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-ledger-core/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerCoreComponent.kt b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerCoreComponent.kt new file mode 100644 index 0000000..25aec86 --- /dev/null +++ b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerCoreComponent.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_ledger_core + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + LedgerCoreDependencies::class, + ], + modules = [ + LedgerFeatureModule::class, + ] +) +@FeatureScope +interface LedgerCoreComponent : LedgerCoreApi { + + @Component.Factory + interface Factory { + + fun create( + deps: LedgerCoreDependencies, + ): LedgerCoreComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + ] + ) + interface LedgerCoreDependenciesComponent : LedgerCoreDependencies +} diff --git a/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerCoreDependencies.kt b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerCoreDependencies.kt new file mode 100644 index 0000000..a6e3b19 --- /dev/null +++ b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerCoreDependencies.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_ledger_core + +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +interface LedgerCoreDependencies { + + val chainRegistry: ChainRegistry + + val metadataShortenerService: MetadataShortenerService +} diff --git a/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerFeatureHolder.kt b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerFeatureHolder.kt new file mode 100644 index 0000000..142fc46 --- /dev/null +++ b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerFeatureHolder.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_ledger_core + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class LedgerCoreHolder @Inject constructor( + featureContainer: FeatureContainer, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerLedgerCoreComponent_LedgerCoreDependenciesComponent.builder() + .commonApi(commonApi()) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .build() + + return DaggerLedgerCoreComponent.factory() + .create(accountFeatureDependencies) + } +} diff --git a/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerFeatureModule.kt b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerFeatureModule.kt new file mode 100644 index 0000000..60a3434 --- /dev/null +++ b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/LedgerFeatureModule.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_ledger_core + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_ledger_core.domain.RealLedgerMigrationTracker +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class LedgerFeatureModule { + + @Provides + @FeatureScope + fun provideLedgerMigrationTracker( + metadataShortenerService: MetadataShortenerService, + chainRegistry: ChainRegistry + ): LedgerMigrationTracker { + return RealLedgerMigrationTracker(metadataShortenerService, chainRegistry) + } +} diff --git a/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/di/LedgerCoreApi.kt b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/di/LedgerCoreApi.kt new file mode 100644 index 0000000..6efc3cd --- /dev/null +++ b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/di/LedgerCoreApi.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_ledger_core.di + +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker + +interface LedgerCoreApi { + + val ledgerMigrationTracker: LedgerMigrationTracker +} diff --git a/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/domain/LedgerMigrationTracker.kt b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/domain/LedgerMigrationTracker.kt new file mode 100644 index 0000000..0be81df --- /dev/null +++ b/feature-ledger-core/src/main/java/io/novafoundation/nova/feature_ledger_core/domain/LedgerMigrationTracker.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_ledger_core.domain + +import io.novafoundation.nova.common.utils.mapNotNullToSet +import io.novafoundation.nova.runtime.ext.isGenericLedgerAppSupported +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.findChainIds +import io.novafoundation.nova.runtime.multiNetwork.findChains +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +interface LedgerMigrationTracker { + + suspend fun shouldUseMigrationApp(chainId: ChainId): Boolean + + suspend fun supportedChainsByGenericApp(): List + + suspend fun anyChainSupportsMigrationApp(): Boolean + + fun supportedChainIdsByGenericAppFlow(): Flow> + + suspend fun supportedChainIdsByGenericApp(): Set +} + +internal class RealLedgerMigrationTracker( + private val metadataShortenerService: MetadataShortenerService, + private val chainRegistry: ChainRegistry +) : LedgerMigrationTracker { + + override suspend fun shouldUseMigrationApp(chainId: ChainId): Boolean { + val supportedFromRuntime = metadataShortenerService.isCheckMetadataHashAvailable(chainId) + + // While automatically detect generic app availability from runtime, it is also usefully to be able to disable this for a specific chain + // via a feature flag + val supportedFromLedger = chainRegistry.getChain(chainId).additional.isGenericLedgerAppSupported() + + return supportedFromRuntime && supportedFromLedger + } + + override suspend fun supportedChainsByGenericApp(): List { + return chainRegistry.findChains { + it.additional.isGenericLedgerAppSupported() + } + } + + override suspend fun supportedChainIdsByGenericApp(): Set { + return chainRegistry.findChainIds { + it.additional.isGenericLedgerAppSupported() + } + } + + override suspend fun anyChainSupportsMigrationApp(): Boolean { + return supportedChainsByGenericApp().isNotEmpty() + } + + override fun supportedChainIdsByGenericAppFlow(): Flow> { + return chainRegistry.currentChains.map { chains -> + chains.mapNotNullToSet { chain -> + chain.id.takeIf { chain.additional.isGenericLedgerAppSupported() } + } + }.distinctUntilChanged() + } +} diff --git a/feature-ledger-impl/.gitignore b/feature-ledger-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-ledger-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-ledger-impl/build.gradle b/feature-ledger-impl/build.gradle new file mode 100644 index 0000000..f32f0ad --- /dev/null +++ b/feature-ledger-impl/build.gradle @@ -0,0 +1,55 @@ +apply plugin: 'kotlin-parcelize' + +android { + + defaultConfig { + + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.feature_ledger_impl' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(":feature-ledger-api") + implementation project(":feature-ledger-core") + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(":common") + implementation project(":runtime") + + implementation materialDep + + implementation substrateSdkDep + + implementation bleDep + implementation bleKotlinDep + + implementation kotlinDep + + implementation androidDep + + implementation permissionsDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation lifeCycleKtxDep + + implementation project(":core-db") + + implementation daggerDep + ksp daggerCompiler + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-ledger-impl/consumer-rules.pro b/feature-ledger-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-ledger-impl/proguard-rules.pro b/feature-ledger-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-ledger-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-ledger-impl/src/main/AndroidManifest.xml b/feature-ledger-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/feature-ledger-impl/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/data/repository/RealLedgerRepository.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/data/repository/RealLedgerRepository.kt new file mode 100644 index 0000000..ee8e72d --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/data/repository/RealLedgerRepository.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_ledger_impl.data.repository + +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.feature_ledger_api.data.repository.LedgerDerivationPath +import io.novafoundation.nova.feature_ledger_api.data.repository.LedgerRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class RealLedgerRepository( + private val secretStoreV2: SecretStoreV2, +) : LedgerRepository { + + override suspend fun getChainAccountDerivationPath(metaId: Long, chainId: ChainId): String { + val key = LedgerDerivationPath.legacyDerivationPathSecretKey(chainId) + + return secretStoreV2.getAdditionalMetaAccountSecret(metaId, key) + ?: throw IllegalStateException("Cannot find Ledger derivation path for chain $chainId in meta account $metaId") + } + + override suspend fun getGenericDerivationPath(metaId: Long): String { + val key = LedgerDerivationPath.genericDerivationPathSecretKey() + + return secretStoreV2.getAdditionalMetaAccountSecret(metaId, key) + ?: throw IllegalStateException("Cannot find Ledger generic derivation path for meta account $metaId") + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureComponent.kt new file mode 100644 index 0000000..6b643ee --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureComponent.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_ledger_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.sign.LedgerSignCommunicator +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi +import io.novafoundation.nova.feature_ledger_impl.di.modules.LedgerBindsModule +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.di.AddEvmGenericLedgerAccountSelectAddressComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger.di.AddEvmAccountSelectGenericLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.di.AddLedgerChainAccountSelectAddressComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger.di.AddChainAccountSelectLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.di.FinishImportGenericLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.di.PreviewImportGenericLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectAddress.di.SelectAddressImportGenericLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.di.SelectLedgerGenericImportComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.start.di.StartImportGenericLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.SelectLedgerAddressInterScreenCommunicator +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.di.FillWalletImportLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.di.FinishImportLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectAddress.di.SelectAddressImportLedgerLegacyComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.di.SelectLedgerImportLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.start.di.StartImportLegacyLedgerComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.sign.di.SignLedgerComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + LedgerFeatureDependencies::class, + ], + modules = [ + LedgerFeatureModule::class, + LedgerBindsModule::class + ] +) +@FeatureScope +interface LedgerFeatureComponent : LedgerFeatureApi { + + @Component.Factory + interface Factory { + + fun create( + deps: LedgerFeatureDependencies, + @BindsInstance router: LedgerRouter, + @BindsInstance selectLedgerAddressInterScreenCommunicator: SelectLedgerAddressInterScreenCommunicator, + @BindsInstance signInterScreenCommunicator: LedgerSignCommunicator, + ): LedgerFeatureComponent + } + + fun startImportLegacyLedgerComponentFactory(): StartImportLegacyLedgerComponent.Factory + fun fillWalletImportLedgerComponentFactory(): FillWalletImportLedgerComponent.Factory + fun selectLedgerImportComponentFactory(): SelectLedgerImportLedgerComponent.Factory + fun selectAddressImportLedgerLegacyComponentFactory(): SelectAddressImportLedgerLegacyComponent.Factory + fun selectAddressImportLedgerGenericComponentFactory(): SelectAddressImportGenericLedgerComponent.Factory + fun finishImportLedgerComponentFactory(): FinishImportLedgerComponent.Factory + + fun signLedgerComponentFactory(): SignLedgerComponent.Factory + + fun addChainAccountSelectLedgerComponentFactory(): AddChainAccountSelectLedgerComponent.Factory + fun addChainAccountSelectAddressComponentFactory(): AddLedgerChainAccountSelectAddressComponent.Factory + + // New generic app flow + + fun startImportGenericLedgerComponentFactory(): StartImportGenericLedgerComponent.Factory + fun selectLedgerGenericImportComponentFactory(): SelectLedgerGenericImportComponent.Factory + fun previewImportGenericLedgerComponentFactory(): PreviewImportGenericLedgerComponent.Factory + fun finishGenericImportLedgerComponentFactory(): FinishImportGenericLedgerComponent.Factory + + // Generic import EVM account + fun addEvmAccountSelectGenericLedgerComponentFactory(): AddEvmAccountSelectGenericLedgerComponent.Factory + fun addEvmGenericLedgerAccountSelectAddressComponentFactory(): AddEvmGenericLedgerAccountSelectAddressComponent.Factory + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + WalletFeatureApi::class, + AccountFeatureApi::class, + LedgerCoreApi::class, + DbApi::class, + ] + ) + interface LedgerFeatureDependenciesComponent : LedgerFeatureDependencies +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureDependencies.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureDependencies.kt new file mode 100644 index 0000000..537eaa8 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureDependencies.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_ledger_impl.di + +import coil.ImageLoader +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.GenericLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.LegacyLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.rpc.RpcCalls + +interface LedgerFeatureDependencies { + + val amountFormatter: AmountFormatter + + val chainRegistry: ChainRegistry + + val appLinksProvider: AppLinksProvider + + val imageLoader: ImageLoader + + val addressIconGenerator: AddressIconGenerator + + val resourceManager: ResourceManager + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val bluetoothManager: BluetoothManager + + val locationManager: LocationManager + + val permissionAskerFactory: PermissionsAskerFactory + + val contextManager: ContextManager + + val assetSourceRegistry: AssetSourceRegistry + + val tokenRepository: TokenRepository + + val metaAccountDao: MetaAccountDao + + val accountInteractor: AccountInteractor + + val accountRepository: AccountRepository + + val secretStoreV2: SecretStoreV2 + + val signSharedState: SigningSharedState + + val extrinsicValidityUseCase: ExtrinsicValidityUseCase + + val selectedAccountUseCase: SelectedAccountUseCase + + val legacyLedgerAddAccountRepository: LegacyLedgerAddAccountRepository + + val genericLegacyLedgerAddAccountRepository: GenericLedgerAddAccountRepository + + val apiCreator: NetworkApiCreator + + val rpcCalls: RpcCalls + + val metadataShortenerService: MetadataShortenerService + + val ledgerMigrationTracker: LedgerMigrationTracker + + val externalActions: ExternalActions.Presentation + + val addressActionsMixinFactory: AddressActionsMixin.Factory + + val addressSchemeFormatter: AddressSchemeFormatter +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureHolder.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureHolder.kt new file mode 100644 index 0000000..f88dbb9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureHolder.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_ledger_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.sign.LedgerSignCommunicator +import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.SelectLedgerAddressInterScreenCommunicator +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class LedgerFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: LedgerRouter, + private val selectLedgerAddressInterScreenCommunicator: SelectLedgerAddressInterScreenCommunicator, + private val signInterScreenCommunicator: LedgerSignCommunicator, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerLedgerFeatureComponent_LedgerFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .ledgerCoreApi(getFeature(LedgerCoreApi::class.java)) + .build() + + return DaggerLedgerFeatureComponent.factory() + .create( + accountFeatureDependencies, + router, + selectLedgerAddressInterScreenCommunicator, + signInterScreenCommunicator + ) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureModule.kt new file mode 100644 index 0000000..29929a9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/LedgerFeatureModule.kt @@ -0,0 +1,168 @@ +package io.novafoundation.nova.feature_ledger_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.feature_ledger_api.data.repository.LedgerRepository +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_api.sdk.transport.LedgerTransport +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_ledger_impl.data.repository.RealLedgerRepository +import io.novafoundation.nova.feature_ledger_impl.di.modules.GenericLedgerModule +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.RealSelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.migration.LedgerMigrationUseCase +import io.novafoundation.nova.feature_ledger_impl.domain.migration.RealLedgerMigrationUseCase +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessagePresentable +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.SingleSheetLedgerMessagePresentable +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.legacyApp.LegacySubstrateLedgerApplication +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.newApp.GenericSubstrateLedgerApplication +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.newApp.MigrationSubstrateLedgerApplication +import io.novafoundation.nova.feature_ledger_impl.sdk.connection.ble.LedgerBleManager +import io.novafoundation.nova.feature_ledger_impl.sdk.discovery.CompoundLedgerDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.sdk.discovery.ble.BleLedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.sdk.discovery.usb.UsbLedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.sdk.transport.ChunkedLedgerTransport +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [GenericLedgerModule::class]) +class LedgerFeatureModule { + + @Provides + @FeatureScope + fun provideLedgerTransport(): LedgerTransport = ChunkedLedgerTransport() + + @Provides + @FeatureScope + fun provideSubstrateLedgerApplication( + transport: LedgerTransport, + ledgerRepository: LedgerRepository, + ) = LegacySubstrateLedgerApplication(transport, ledgerRepository) + + @Provides + @FeatureScope + fun provideMigrationLedgerApplication( + transport: LedgerTransport, + chainRegistry: ChainRegistry, + ledgerRepository: LedgerRepository, + metadataShortenerService: MetadataShortenerService + ) = MigrationSubstrateLedgerApplication( + transport = transport, + chainRegistry = chainRegistry, + metadataShortenerService = metadataShortenerService, + ledgerRepository = ledgerRepository + ) + + @Provides + @FeatureScope + fun provideGenericLedgerApplication( + transport: LedgerTransport, + chainRegistry: ChainRegistry, + ledgerRepository: LedgerRepository, + metadataShortenerService: MetadataShortenerService + ) = GenericSubstrateLedgerApplication( + transport = transport, + metadataShortenerService = metadataShortenerService, + ledgerRepository = ledgerRepository, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideLedgerMessageFormatterFactory( + resourceManager: ResourceManager, + migrationTracker: LedgerMigrationTracker, + chainRegistry: ChainRegistry, + appLinksProvider: AppLinksProvider, + ): LedgerMessageFormatterFactory { + return LedgerMessageFormatterFactory(resourceManager, migrationTracker, chainRegistry, appLinksProvider) + } + + @Provides + @FeatureScope + fun provideLedgerMigrationUseCase( + ledgerMigrationTracker: LedgerMigrationTracker, + migrationApp: MigrationSubstrateLedgerApplication, + legacyApp: LegacySubstrateLedgerApplication, + genericApp: GenericSubstrateLedgerApplication, + ): LedgerMigrationUseCase { + return RealLedgerMigrationUseCase(ledgerMigrationTracker, migrationApp, legacyApp, genericApp) + } + + @Provides + @FeatureScope + fun provideLedgerBleManager( + contextManager: ContextManager + ) = LedgerBleManager(contextManager) + + @Provides + @FeatureScope + fun provideLedgerDeviceDiscoveryService( + bluetoothManager: BluetoothManager, + ledgerBleManager: LedgerBleManager + ) = BleLedgerDeviceDiscoveryService( + bluetoothManager = bluetoothManager, + ledgerBleManager = ledgerBleManager + ) + + @Provides + @FeatureScope + fun provideUsbDeviceDiscoveryService( + contextManager: ContextManager + ) = UsbLedgerDeviceDiscoveryService(contextManager) + + @Provides + @FeatureScope + fun provideDeviceDiscoveryService( + bleLedgerDeviceDiscoveryService: BleLedgerDeviceDiscoveryService, + usbLedgerDeviceDiscoveryService: UsbLedgerDeviceDiscoveryService + ): LedgerDeviceDiscoveryService = CompoundLedgerDiscoveryService( + bleLedgerDeviceDiscoveryService, + usbLedgerDeviceDiscoveryService + ) + + @Provides + @FeatureScope + fun provideRepository( + secretStoreV2: SecretStoreV2 + ): LedgerRepository = RealLedgerRepository(secretStoreV2) + + @Provides + fun provideLedgerMessagePresentable(): LedgerMessagePresentable = SingleSheetLedgerMessagePresentable() + + @Provides + @FeatureScope + fun provideSelectAddressInteractor( + migrationUseCase: LedgerMigrationUseCase, + ledgerDeviceDiscoveryService: LedgerDeviceDiscoveryService, + ): SelectAddressLedgerInteractor { + return RealSelectAddressLedgerInteractor( + migrationUseCase = migrationUseCase, + ledgerDeviceDiscoveryService = ledgerDeviceDiscoveryService, + ) + } + + @Provides + @FeatureScope + fun provideLedgerDeviceMapper(resourceManager: ResourceManager): LedgerDeviceFormatter { + return LedgerDeviceFormatter(resourceManager) + } + + @Provides + @FeatureScope + fun provideMessageCommandFormatterFactory( + resourceManager: ResourceManager, + deviceMapper: LedgerDeviceFormatter, + addressSchemeFormatter: AddressSchemeFormatter + ) = MessageCommandFormatterFactory(resourceManager, deviceMapper, addressSchemeFormatter) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/annotations/GenericLedger.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/annotations/GenericLedger.kt new file mode 100644 index 0000000..702a6ca --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/annotations/GenericLedger.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_ledger_impl.di.annotations + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class GenericLedger diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/modules/GenericLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/modules/GenericLedgerModule.kt new file mode 100644 index 0000000..2a29ff9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/modules/GenericLedgerModule.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_ledger_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_ledger_impl.di.annotations.GenericLedger +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatterFactory + +@Module +class GenericLedgerModule { + + @Provides + @FeatureScope + @GenericLedger + fun provideMessageFormatter(factory: LedgerMessageFormatterFactory): LedgerMessageFormatter = factory.createGeneric() + + @Provides + @FeatureScope + @GenericLedger + fun provideMessageCommandFormatter( + @GenericLedger messageFormatter: LedgerMessageFormatter, + messageCommandFormatterFactory: MessageCommandFormatterFactory + ): MessageCommandFormatter = messageCommandFormatterFactory.create(messageFormatter) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/modules/LedgerBindsModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/modules/LedgerBindsModule.kt new file mode 100644 index 0000000..755f57b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/di/modules/LedgerBindsModule.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_ledger_impl.di.modules + +import dagger.Binds +import dagger.Module +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.generic.GenericLedgerEvmAlertFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.generic.RealGenericLedgerEvmAlertFormatter + +@Module +interface LedgerBindsModule { + + @Binds + fun bindEvmUpdateFormatter(real: RealGenericLedgerEvmAlertFormatter): GenericLedgerEvmAlertFormatter +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/addChain/generic/AddEvmAccountToGenericLedgerInteractor.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/addChain/generic/AddEvmAccountToGenericLedgerInteractor.kt new file mode 100644 index 0000000..959597b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/addChain/generic/AddEvmAccountToGenericLedgerInteractor.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_ledger_impl.domain.account.addChain.generic + +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.utils.coerceToUnit +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.GenericLedgerAddAccountRepository +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerEvmAccount +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface AddEvmAccountToGenericLedgerInteractor { + + suspend fun addEvmAccount(metaId: Long, account: LedgerEvmAccount): Result +} + +@ScreenScope +class RealAddEvmAccountToGenericLedgerInteractor @Inject constructor( + private val genericLedgerAddAccountRepository: GenericLedgerAddAccountRepository +) : AddEvmAccountToGenericLedgerInteractor { + + override suspend fun addEvmAccount(metaId: Long, account: LedgerEvmAccount): Result = withContext(Dispatchers.IO) { + runCatching { + val payload = GenericLedgerAddAccountRepository.Payload.AddEvmAccount(metaId, account) + genericLedgerAddAccountRepository.addAccount(payload) + }.coerceToUnit() + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/addChain/legacy/AddLedgerChainAccountInteractor.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/addChain/legacy/AddLedgerChainAccountInteractor.kt new file mode 100644 index 0000000..c87dd00 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/addChain/legacy/AddLedgerChainAccountInteractor.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_ledger_impl.domain.account.addChain.legacy + +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.LegacyLedgerAddAccountRepository +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount + +interface AddLedgerChainAccountInteractor { + + suspend fun addChainAccount(metaId: Long, chainId: String, account: LedgerSubstrateAccount): Result +} + +class RealAddLedgerChainAccountInteractor( + private val legacyLedgerAddAccountRepository: LegacyLedgerAddAccountRepository +) : AddLedgerChainAccountInteractor { + + override suspend fun addChainAccount(metaId: Long, chainId: String, account: LedgerSubstrateAccount): Result = kotlin.runCatching { + legacyLedgerAddAccountRepository.addAccount( + LegacyLedgerAddAccountRepository.Payload.ChainAccount( + metaId, + chainId, + account + ) + ) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/common/selectAddress/SelectAddressLedgerInteractor.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/common/selectAddress/SelectAddressLedgerInteractor.kt new file mode 100644 index 0000000..d2e225d --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/common/selectAddress/SelectAddressLedgerInteractor.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress + +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerEvmAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.findDeviceOrThrow +import io.novafoundation.nova.feature_ledger_impl.domain.migration.LedgerMigrationUseCase +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class LedgerAccount( + val index: Int, + val substrate: LedgerSubstrateAccount, + val evm: LedgerEvmAccount?, +) + +interface SelectAddressLedgerInteractor { + + suspend fun getDevice(deviceId: String): LedgerDevice + + suspend fun loadLedgerAccount(substrateChain: Chain, deviceId: String, accountIndex: Int, ledgerVariant: LedgerVariant): Result + + suspend fun verifyLedgerAccount( + substrateChain: Chain, + deviceId: String, + accountIndex: Int, + ledgerVariant: LedgerVariant, + addressSchemes: List + ): Result +} + +class RealSelectAddressLedgerInteractor( + private val migrationUseCase: LedgerMigrationUseCase, + private val ledgerDeviceDiscoveryService: LedgerDeviceDiscoveryService, +) : SelectAddressLedgerInteractor { + + override suspend fun getDevice(deviceId: String): LedgerDevice { + return ledgerDeviceDiscoveryService.findDeviceOrThrow(deviceId) + } + + override suspend fun loadLedgerAccount( + substrateChain: Chain, + deviceId: String, + accountIndex: Int, + ledgerVariant: LedgerVariant, + ) = runCatching { + val device = ledgerDeviceDiscoveryService.findDeviceOrThrow(deviceId) + val app = migrationUseCase.determineLedgerApp(substrateChain.id, ledgerVariant) + + val substrateAccount = app.getSubstrateAccount(device, substrateChain.id, accountIndex, confirmAddress = false) + val evmAccount = app.getEvmAccount(device, accountIndex, confirmAddress = false) + + LedgerAccount(accountIndex, substrateAccount, evmAccount) + } + + override suspend fun verifyLedgerAccount( + substrateChain: Chain, + deviceId: String, + accountIndex: Int, + ledgerVariant: LedgerVariant, + addressSchemes: List + ): Result = runCatching { + val device = ledgerDeviceDiscoveryService.findDeviceOrThrow(deviceId) + val app = migrationUseCase.determineLedgerApp(substrateChain.id, ledgerVariant) + + val verificationPerScheme = mapOf( + AddressScheme.SUBSTRATE to suspend { app.getSubstrateAccount(device, substrateChain.id, accountIndex, confirmAddress = true) }, + AddressScheme.EVM to suspend { app.getEvmAccount(device, accountIndex, confirmAddress = true) } + ) + + addressSchemes.forEach { + verificationPerScheme.getValue(it).invoke() + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/generic/finish/FinishImportGenericLedgerInteractor.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/generic/finish/FinishImportGenericLedgerInteractor.kt new file mode 100644 index 0000000..e6dcb22 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/generic/finish/FinishImportGenericLedgerInteractor.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_ledger_impl.domain.account.connect.generic.finish + +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.addAccountWithSingleChange +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.GenericLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerEvmAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount + +interface FinishImportGenericLedgerInteractor { + + suspend fun createWallet( + name: String, + substrateAccount: LedgerSubstrateAccount, + evmAccount: LedgerEvmAccount?, + ): Result +} + +class RealFinishImportGenericLedgerInteractor( + private val genericLedgerAddAccountRepository: GenericLedgerAddAccountRepository, + private val accountRepository: AccountRepository, +) : FinishImportGenericLedgerInteractor { + + override suspend fun createWallet( + name: String, + substrateAccount: LedgerSubstrateAccount, + evmAccount: LedgerEvmAccount?, + ) = runCatching { + val payload = GenericLedgerAddAccountRepository.Payload.NewWallet( + name = name, + substrateAccount = substrateAccount, + evmAccount = evmAccount + ) + + val addAccountResult = genericLedgerAddAccountRepository.addAccountWithSingleChange(payload) + + accountRepository.selectMetaAccount(addAccountResult.metaId) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/generic/preview/PreviewImportGenericLedgerInteractor.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/generic/preview/PreviewImportGenericLedgerInteractor.kt new file mode 100644 index 0000000..e8fbde8 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/generic/preview/PreviewImportGenericLedgerInteractor.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_ledger_impl.domain.account.connect.generic.preview + +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.common.utils.mapValuesNotNull +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview.model.ChainAccountPreview +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.findDeviceOrThrow +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.newApp.GenericSubstrateLedgerApplication +import io.novafoundation.nova.runtime.ext.addressScheme +import io.novafoundation.nova.runtime.ext.defaultComparator +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface PreviewImportGenericLedgerInteractor { + + suspend fun getDevice(deviceId: String): LedgerDevice + + suspend fun availableChainAccounts( + substrateAccountId: AccountId, + evmAccountId: AccountId?, + ): GroupedList + + suspend fun verifyAddressOnLedger(accountIndex: Int, deviceId: String): Result +} + +class RealPreviewImportGenericLedgerInteractor( + private val ledgerMigrationTracker: LedgerMigrationTracker, + private val genericSubstrateLedgerApplication: GenericSubstrateLedgerApplication, + private val ledgerDiscoveryService: LedgerDeviceDiscoveryService +) : PreviewImportGenericLedgerInteractor { + + override suspend fun getDevice(deviceId: String): LedgerDevice { + return ledgerDiscoveryService.findDeviceOrThrow(deviceId) + } + + override suspend fun availableChainAccounts( + substrateAccountId: AccountId, + evmAccountId: AccountId?, + ): GroupedList { + return ledgerMigrationTracker.supportedChainsByGenericApp() + .groupBy(Chain::addressScheme) + .mapValuesNotNull { (scheme, chains) -> + val accountId = when (scheme) { + AddressScheme.EVM -> evmAccountId ?: return@mapValuesNotNull null + AddressScheme.SUBSTRATE -> substrateAccountId + } + + chains + .sortedWith(Chain.defaultComparator()) + .map { chain -> ChainAccountPreview(chain, accountId) } + } + } + + override suspend fun verifyAddressOnLedger(accountIndex: Int, deviceId: String): Result = withContext(Dispatchers.IO) { + runCatching { + val device = ledgerDiscoveryService.findDeviceOrThrow(deviceId) + + genericSubstrateLedgerApplication.getUniversalSubstrateAccount(device, accountIndex, confirmAddress = true) + genericSubstrateLedgerApplication.getEvmAccount(device, accountIndex, confirmAddress = true) + + Unit + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/legacy/fillWallet/FillWalletImportLedgerInteractor.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/legacy/fillWallet/FillWalletImportLedgerInteractor.kt new file mode 100644 index 0000000..e53af7b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/legacy/fillWallet/FillWalletImportLedgerInteractor.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_ledger_impl.domain.account.connect.legacy.fillWallet + +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateApplicationConfig +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.enabledChainById + +interface FillWalletImportLedgerInteractor { + + suspend fun availableLedgerChains(): List +} + +class RealFillWalletImportLedgerInteractor( + private val chainRegistry: ChainRegistry +) : FillWalletImportLedgerInteractor { + + override suspend fun availableLedgerChains(): List { + val supportedLedgerApps = SubstrateApplicationConfig.all() + val supportedChainIds = supportedLedgerApps.mapToSet { it.chainId } + + return chainRegistry.enabledChainById() + .filterKeys { it in supportedChainIds } + .values + .toList() + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/legacy/finish/FinishImportLedgerInteractor.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/legacy/finish/FinishImportLedgerInteractor.kt new file mode 100644 index 0000000..35259bc --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/connect/legacy/finish/FinishImportLedgerInteractor.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_ledger_impl.domain.account.connect.legacy.finish + +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.addAccountWithSingleChange +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.LegacyLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface FinishImportLedgerInteractor { + + suspend fun createWallet( + name: String, + ledgerChainAccounts: Map, + ): Result +} + +class RealFinishImportLedgerInteractor( + private val legacyLedgerAddAccountRepository: LegacyLedgerAddAccountRepository, + private val accountRepository: AccountRepository, +) : FinishImportLedgerInteractor { + + override suspend fun createWallet(name: String, ledgerChainAccounts: Map) = runCatching { + val addAccountResult = legacyLedgerAddAccountRepository.addAccountWithSingleChange( + LegacyLedgerAddAccountRepository.Payload.MetaAccount( + name, + ledgerChainAccounts + ) + ) + + accountRepository.selectMetaAccount(addAccountResult.metaId) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/sign/SignLedgerInteractor.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/sign/SignLedgerInteractor.kt new file mode 100644 index 0000000..a0ca87c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/account/sign/SignLedgerInteractor.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_ledger_impl.domain.account.sign + +import io.novafoundation.nova.common.utils.chainId +import io.novafoundation.nova.feature_account_api.data.signer.SeparateFlowSignerState +import io.novafoundation.nova.feature_account_api.data.signer.chainId +import io.novafoundation.nova.feature_account_api.data.signer.signaturePayload +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.domain.migration.LedgerMigrationUseCase +import io.novafoundation.nova.runtime.ext.verifyMultiChain +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.encrypt.SignatureVerifier +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface SignLedgerInteractor { + + suspend fun getSignature( + device: LedgerDevice, + metaId: Long, + payload: InheritedImplication, + ): SignatureWrapper + + suspend fun verifySignature( + payload: SeparateFlowSignerState, + signature: SignatureWrapper + ): Boolean +} + +class RealSignLedgerInteractor( + private val chainRegistry: ChainRegistry, + private val usedVariant: LedgerVariant, + private val migrationUseCase: LedgerMigrationUseCase +) : SignLedgerInteractor { + + override suspend fun getSignature( + device: LedgerDevice, + metaId: Long, + payload: InheritedImplication + ): SignatureWrapper = withContext(Dispatchers.Default) { + val chainId = payload.chainId + val app = migrationUseCase.determineLedgerApp(chainId, usedVariant) + + app.getSignature(device, metaId, chainId, payload) + } + + override suspend fun verifySignature( + payload: SeparateFlowSignerState, + signature: SignatureWrapper + ): Boolean = runCatching { + val payloadBytes = payload.payload.signaturePayload() + val chainId = payload.payload.chainId() + val chain = chainRegistry.getChain(chainId) + + val publicKey = payload.metaAccount.publicKeyIn(chain) ?: throw IllegalStateException("No public key for chain $chainId") + + SignatureVerifier.verifyMultiChain(chain, signature, payloadBytes, publicKey) + }.getOrDefault(false) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/migration/LedgerMigrationUseCase.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/migration/LedgerMigrationUseCase.kt new file mode 100644 index 0000000..3ab8d39 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/domain/migration/LedgerMigrationUseCase.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_ledger_impl.domain.migration + +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateLedgerApplication +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.legacyApp.LegacySubstrateLedgerApplication +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.newApp.GenericSubstrateLedgerApplication +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.newApp.MigrationSubstrateLedgerApplication +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface LedgerMigrationUseCase { + + suspend fun determineLedgerApp(chainId: ChainId, ledgerVariant: LedgerVariant): SubstrateLedgerApplication +} + +suspend fun LedgerMigrationUseCase.determineAppForLegacyAccount(chainId: ChainId): SubstrateLedgerApplication { + return determineLedgerApp(chainId, LedgerVariant.LEGACY) +} + +class RealLedgerMigrationUseCase( + private val ledgerMigrationTracker: LedgerMigrationTracker, + private val migrationApp: MigrationSubstrateLedgerApplication, + private val legacyApp: LegacySubstrateLedgerApplication, + private val genericApp: GenericSubstrateLedgerApplication, +) : LedgerMigrationUseCase { + + override suspend fun determineLedgerApp(chainId: ChainId, ledgerVariant: LedgerVariant): SubstrateLedgerApplication { + return when { + ledgerVariant == LedgerVariant.GENERIC -> genericApp + ledgerMigrationTracker.shouldUseMigrationApp(chainId) -> migrationApp + else -> legacyApp + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/LedgerRouter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/LedgerRouter.kt new file mode 100644 index 0000000..e3e7ed2 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/LedgerRouter.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.AddEvmGenericLedgerAccountSelectAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.AddLedgerChainAccountSelectAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.FinishImportGenericLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.PreviewImportGenericLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.SelectLedgerGenericPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.FillWalletImportLedgerLegacyPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.FinishImportLedgerPayload + +interface LedgerRouter : ReturnableRouter { + + fun openImportFillWallet(payload: FillWalletImportLedgerLegacyPayload) + + fun returnToImportFillWallet() + + fun openSelectImportAddress(payload: SelectLedgerAddressPayload) + + fun openCreatePincode() + + fun openMain() + + fun openFinishImportLedger(payload: FinishImportLedgerPayload) + + fun finishSignFlow() + + fun openAddChainAccountSelectAddress(payload: AddLedgerChainAccountSelectAddressPayload) + + // Generic app flows + + fun openSelectLedgerGeneric(payload: SelectLedgerGenericPayload) + + fun openSelectAddressGenericLedger(payload: SelectLedgerAddressPayload) + + fun openPreviewLedgerAccountsGeneric(payload: PreviewImportGenericLedgerPayload) + + fun openFinishImportLedgerGeneric(payload: FinishImportGenericLedgerPayload) + + fun openAddGenericEvmAddressSelectAddress(payload: AddEvmGenericLedgerAccountSelectAddressPayload) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/AddEvmGenericLedgerAccountSelectAddressFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/AddEvmGenericLedgerAccountSelectAddressFragment.kt new file mode 100644 index 0000000..9bf1c09 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/AddEvmGenericLedgerAccountSelectAddressFragment.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectAddressLedgerFragment + +class AddEvmGenericLedgerAccountSelectAddressFragment : SelectAddressLedgerFragment() { + + companion object { + private const val PAYLOAD_KEY = "AddEvmGenericLedgerAccountSelectAddressFragment.Payload" + + fun getBundle(payload: AddEvmGenericLedgerAccountSelectAddressPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun initViews() { + super.initViews() + + binder.ledgerSelectAddressChain.makeGone() + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .addEvmGenericLedgerAccountSelectAddressComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/AddEvmGenericLedgerAccountSelectAddressPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/AddEvmGenericLedgerAccountSelectAddressPayload.kt new file mode 100644 index 0000000..ae37f02 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/AddEvmGenericLedgerAccountSelectAddressPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class AddEvmGenericLedgerAccountSelectAddressPayload( + val metaId: Long, + val deviceId: String, +) : Parcelable diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/AddEvmGenericLedgerAccountSelectAddressViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/AddEvmGenericLedgerAccountSelectAddressViewModel.kt new file mode 100644 index 0000000..d7fbcee --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/AddEvmGenericLedgerAccountSelectAddressViewModel.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_ledger_impl.domain.account.addChain.generic.AddEvmAccountToGenericLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.LedgerAccount +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.generic.GenericLedgerEvmAlertFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectAddressLedgerViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model.AddressVerificationMode +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.launch + +class AddEvmGenericLedgerAccountSelectAddressViewModel( + private val router: LedgerRouter, + private val payload: AddEvmGenericLedgerAccountSelectAddressPayload, + private val addAccountInteractor: AddEvmAccountToGenericLedgerInteractor, + private val selectAddressLedgerInteractor: SelectAddressLedgerInteractor, + private val evmAlertFormatter: GenericLedgerEvmAlertFormatter, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + selectLedgerAddressPayload: SelectLedgerAddressPayload, + messageCommandFormatter: MessageCommandFormatter, + addressActionsMixinFactory: AddressActionsMixin.Factory +) : SelectAddressLedgerViewModel( + router = router, + interactor = selectAddressLedgerInteractor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + payload = selectLedgerAddressPayload, + chainRegistry = chainRegistry, + messageCommandFormatter = messageCommandFormatter, + addressActionsMixinFactory = addressActionsMixinFactory +) { + + override val ledgerVariant: LedgerVariant = LedgerVariant.GENERIC + + override val addressVerificationMode = AddressVerificationMode.Enabled(addressSchemesToVerify = listOf(AddressScheme.EVM)) + + override suspend fun loadLedgerAccount( + substratePreviewChain: Chain, + deviceId: String, + accountIndex: Int, + ledgerVariant: LedgerVariant + ): Result { + return selectAddressLedgerInteractor.loadLedgerAccount(substratePreviewChain, deviceId, accountIndex, ledgerVariant).map { ledgerAccount -> + if (ledgerAccount.evm != null) { + ledgerAccount + } else { + _alertFlow.emit(evmAlertFormatter.createUpdateAppToGetEvmAddressAlert()) + + null + } + } + } + + override fun onAccountVerified(account: LedgerAccount) { + launch { + val result = addAccountInteractor.addEvmAccount(payload.metaId, account.evm!!) + + result + .onSuccess { router.openMain() } + .onFailure(::showError) + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/di/AddEvmGenericLedgerAccountSelectAddressComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/di/AddEvmGenericLedgerAccountSelectAddressComponent.kt new file mode 100644 index 0000000..5b470f3 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/di/AddEvmGenericLedgerAccountSelectAddressComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.AddEvmGenericLedgerAccountSelectAddressFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.AddEvmGenericLedgerAccountSelectAddressPayload + +@Subcomponent( + modules = [ + AddEvmGenericLedgerAccountSelectAddressModule::class + ] +) +@ScreenScope +interface AddEvmGenericLedgerAccountSelectAddressComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AddEvmGenericLedgerAccountSelectAddressPayload, + ): AddEvmGenericLedgerAccountSelectAddressComponent + } + + fun inject(fragment: AddEvmGenericLedgerAccountSelectAddressFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/di/AddEvmGenericLedgerAccountSelectAddressModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/di/AddEvmGenericLedgerAccountSelectAddressModule.kt new file mode 100644 index 0000000..d942056 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectAddress/di/AddEvmGenericLedgerAccountSelectAddressModule.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_ledger_impl.di.annotations.GenericLedger +import io.novafoundation.nova.feature_ledger_impl.domain.account.addChain.generic.AddEvmAccountToGenericLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.addChain.generic.RealAddEvmAccountToGenericLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.AddEvmGenericLedgerAccountSelectAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.AddEvmGenericLedgerAccountSelectAddressViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.di.AddEvmGenericLedgerAccountSelectAddressModule.BindsModule +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.generic.GenericLedgerEvmAlertFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +@Module(includes = [ViewModelModule::class, BindsModule::class]) +class AddEvmGenericLedgerAccountSelectAddressModule { + + @Module + interface BindsModule { + + @Binds + fun bindInteractor(real: RealAddEvmAccountToGenericLedgerInteractor): AddEvmAccountToGenericLedgerInteractor + } + + @Provides + @IntoMap + @ViewModelKey(AddEvmGenericLedgerAccountSelectAddressViewModel::class) + fun provideViewModel( + router: LedgerRouter, + payload: AddEvmGenericLedgerAccountSelectAddressPayload, + addAccountInteractor: AddEvmAccountToGenericLedgerInteractor, + selectAddressLedgerInteractor: SelectAddressLedgerInteractor, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + @GenericLedger messageCommandFormatter: MessageCommandFormatter, + evmAlertFormatter: GenericLedgerEvmAlertFormatter, + addressActionsMixinFactory: AddressActionsMixin.Factory + ): ViewModel { + val selectLedgerAddressPayload = SelectLedgerAddressPayload(payload.deviceId, substrateChainId = Chain.Geneses.POLKADOT) + + return AddEvmGenericLedgerAccountSelectAddressViewModel( + router = router, + payload = payload, + selectAddressLedgerInteractor = selectAddressLedgerInteractor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + chainRegistry = chainRegistry, + selectLedgerAddressPayload = selectLedgerAddressPayload, + messageCommandFormatter = messageCommandFormatter, + addAccountInteractor = addAccountInteractor, + evmAlertFormatter = evmAlertFormatter, + addressActionsMixinFactory = addressActionsMixinFactory + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddEvmGenericLedgerAccountSelectAddressViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AddEvmGenericLedgerAccountSelectAddressViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/AddEvmAccountSelectGenericLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/AddEvmAccountSelectGenericLedgerFragment.kt new file mode 100644 index 0000000..c1d9745 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/AddEvmAccountSelectGenericLedgerFragment.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerFragment + +class AddEvmAccountSelectGenericLedgerFragment : SelectLedgerFragment() { + + companion object { + private const val KEY_ADD_ACCOUNT_PAYLOAD = "AddEvmAccountSelectGenericLedgerFragment.Payload" + + fun getBundle(payload: AddEvmAccountSelectGenericLedgerPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_ADD_ACCOUNT_PAYLOAD, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .addEvmAccountSelectGenericLedgerComponentFactory() + .create(this, argument(KEY_ADD_ACCOUNT_PAYLOAD)) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/AddEvmAccountSelectGenericLedgerPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/AddEvmAccountSelectGenericLedgerPayload.kt new file mode 100644 index 0000000..0907d95 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/AddEvmAccountSelectGenericLedgerPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger + +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +class AddEvmAccountSelectGenericLedgerPayload(val metaId: Long) : SelectLedgerPayload { + + @IgnoredOnParcel + override val connectionMode: SelectLedgerPayload.ConnectionMode = SelectLedgerPayload.ConnectionMode.ALL +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/AddEvmAccountSelectGenericLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/AddEvmAccountSelectGenericLedgerViewModel.kt new file mode 100644 index 0000000..20144e1 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/AddEvmAccountSelectGenericLedgerViewModel.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectAddress.AddEvmGenericLedgerAccountSelectAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerViewModel + +class AddEvmAccountSelectGenericLedgerViewModel( + private val router: LedgerRouter, + private val payload: AddEvmAccountSelectGenericLedgerPayload, + private val messageCommandFormatter: MessageCommandFormatter, + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + resourceManager: ResourceManager, + messageFormatter: LedgerMessageFormatter, + ledgerDeviceFormatter: LedgerDeviceFormatter +) : SelectLedgerViewModel( + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + messageFormatter = messageFormatter, + ledgerDeviceFormatter = ledgerDeviceFormatter, + messageCommandFormatter = messageCommandFormatter, + payload = payload +) { + + override suspend fun verifyConnection(device: LedgerDevice) { + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + + val payload = AddEvmGenericLedgerAccountSelectAddressPayload(payload.metaId, device.id) + router.openAddGenericEvmAddressSelectAddress(payload) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/di/AddEvmAccountSelectGenericLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/di/AddEvmAccountSelectGenericLedgerComponent.kt new file mode 100644 index 0000000..e03dff0 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/di/AddEvmAccountSelectGenericLedgerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger.AddEvmAccountSelectGenericLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger.AddEvmAccountSelectGenericLedgerPayload + +@Subcomponent( + modules = [ + AddEvmAccountSelectGenericLedgerModule::class + ] +) +@ScreenScope +interface AddEvmAccountSelectGenericLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AddEvmAccountSelectGenericLedgerPayload, + ): AddEvmAccountSelectGenericLedgerComponent + } + + fun inject(fragment: AddEvmAccountSelectGenericLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/di/AddEvmAccountSelectGenericLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/di/AddEvmAccountSelectGenericLedgerModule.kt new file mode 100644 index 0000000..039959f --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/generic/selectLedger/di/AddEvmAccountSelectGenericLedgerModule.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.modules.shared.PermissionAskerForFragmentModule +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.di.annotations.GenericLedger +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger.AddEvmAccountSelectGenericLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.generic.selectLedger.AddEvmAccountSelectGenericLedgerViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter + +@Module(includes = [ViewModelModule::class, PermissionAskerForFragmentModule::class]) +class AddEvmAccountSelectGenericLedgerModule { + + @Provides + @IntoMap + @ViewModelKey(AddEvmAccountSelectGenericLedgerViewModel::class) + fun provideViewModel( + payload: AddEvmAccountSelectGenericLedgerPayload, + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + router: LedgerRouter, + resourceManager: ResourceManager, + ledgerDeviceFormatter: LedgerDeviceFormatter, + @GenericLedger messageFormatter: LedgerMessageFormatter, + @GenericLedger messageCommandFormatter: MessageCommandFormatter + ): ViewModel { + return AddEvmAccountSelectGenericLedgerViewModel( + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + payload = payload, + messageFormatter = messageFormatter, + ledgerDeviceFormatter = ledgerDeviceFormatter, + messageCommandFormatter = messageCommandFormatter + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddEvmAccountSelectGenericLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AddEvmAccountSelectGenericLedgerViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/AddLedgerChainAccountSelectAddressFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/AddLedgerChainAccountSelectAddressFragment.kt new file mode 100644 index 0000000..5822259 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/AddLedgerChainAccountSelectAddressFragment.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectAddressLedgerFragment + +class AddLedgerChainAccountSelectAddressFragment : SelectAddressLedgerFragment() { + + companion object { + private const val PAYLOAD_KEY = "AddChainAccountSelectAddressLedgerFragment.Payload" + + fun getBundle(payload: AddLedgerChainAccountSelectAddressPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .addChainAccountSelectAddressComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/AddLedgerChainAccountSelectAddressPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/AddLedgerChainAccountSelectAddressPayload.kt new file mode 100644 index 0000000..4e22a0b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/AddLedgerChainAccountSelectAddressPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress + +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +@Parcelize +class AddLedgerChainAccountSelectAddressPayload( + val chainId: ChainId, + val metaId: Long, + val deviceId: String, +) : Parcelable diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/AddLedgerChainAccountSelectAddressViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/AddLedgerChainAccountSelectAddressViewModel.kt new file mode 100644 index 0000000..4ee0a8b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/AddLedgerChainAccountSelectAddressViewModel.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_ledger_impl.domain.account.addChain.legacy.AddLedgerChainAccountInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.LedgerAccount +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectAddressLedgerViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model.AddressVerificationMode +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class AddLedgerChainAccountSelectAddressViewModel( + private val router: LedgerRouter, + private val payload: AddLedgerChainAccountSelectAddressPayload, + private val addChainAccountInteractor: AddLedgerChainAccountInteractor, + selectAddressLedgerInteractor: SelectAddressLedgerInteractor, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + selectLedgerAddressPayload: SelectLedgerAddressPayload, + messageCommandFormatter: MessageCommandFormatter, + addressActionsMixinFactory: AddressActionsMixin.Factory +) : SelectAddressLedgerViewModel( + router = router, + interactor = selectAddressLedgerInteractor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + payload = selectLedgerAddressPayload, + chainRegistry = chainRegistry, + messageCommandFormatter = messageCommandFormatter, + addressActionsMixinFactory = addressActionsMixinFactory +) { + + override val ledgerVariant: LedgerVariant = LedgerVariant.LEGACY + + override val addressVerificationMode = AddressVerificationMode.Enabled(addressSchemesToVerify = listOf(AddressScheme.SUBSTRATE)) + + override fun onAccountVerified(account: LedgerAccount) { + launch { + val result = withContext(Dispatchers.Default) { + addChainAccountInteractor.addChainAccount(payload.metaId, payload.chainId, account.substrate) + } + + result.onSuccess { + router.openMain() + }.onFailure(::showError) + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/di/AddLedgerChainAccountSelectAddressComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/di/AddLedgerChainAccountSelectAddressComponent.kt new file mode 100644 index 0000000..50f4894 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/di/AddLedgerChainAccountSelectAddressComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.AddLedgerChainAccountSelectAddressFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.AddLedgerChainAccountSelectAddressPayload + +@Subcomponent( + modules = [ + AddLedgerChainAccountSelectAddressModule::class + ] +) +@ScreenScope +interface AddLedgerChainAccountSelectAddressComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AddLedgerChainAccountSelectAddressPayload, + ): AddLedgerChainAccountSelectAddressComponent + } + + fun inject(fragment: AddLedgerChainAccountSelectAddressFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/di/AddLedgerChainAccountSelectAddressModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/di/AddLedgerChainAccountSelectAddressModule.kt new file mode 100644 index 0000000..db2b23f --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectAddress/di/AddLedgerChainAccountSelectAddressModule.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.LegacyLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_ledger_impl.domain.account.addChain.legacy.AddLedgerChainAccountInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.addChain.legacy.RealAddLedgerChainAccountInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.AddLedgerChainAccountSelectAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.AddLedgerChainAccountSelectAddressViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class AddLedgerChainAccountSelectAddressModule { + + @Provides + @ScreenScope + fun provideInteractor( + legacyLedgerAddAccountRepository: LegacyLedgerAddAccountRepository + ): AddLedgerChainAccountInteractor = RealAddLedgerChainAccountInteractor(legacyLedgerAddAccountRepository) + + @Provides + @ScreenScope + fun provideSelectLedgerAddressPayload( + screenPayload: AddLedgerChainAccountSelectAddressPayload + ): SelectLedgerAddressPayload = SelectLedgerAddressPayload( + deviceId = screenPayload.deviceId, + substrateChainId = screenPayload.chainId + ) + + @Provides + @ScreenScope + fun provideMessageFormatter( + screenPayload: AddLedgerChainAccountSelectAddressPayload, + factory: LedgerMessageFormatterFactory, + ): LedgerMessageFormatter = factory.createLegacy(screenPayload.chainId, showAlerts = false) + + @Provides + @ScreenScope + fun provideMessageCommandFormatter( + messageFormatter: LedgerMessageFormatter, + messageCommandFormatterFactory: MessageCommandFormatterFactory + ): MessageCommandFormatter = messageCommandFormatterFactory.create(messageFormatter) + + @Provides + @IntoMap + @ViewModelKey(AddLedgerChainAccountSelectAddressViewModel::class) + fun provideViewModel( + router: LedgerRouter, + payload: AddLedgerChainAccountSelectAddressPayload, + addChainAccountInteractor: AddLedgerChainAccountInteractor, + selectAddressLedgerInteractor: SelectAddressLedgerInteractor, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + selectLedgerAddressPayload: SelectLedgerAddressPayload, + messageCommandFormatter: MessageCommandFormatter, + addressActionsMixinFactory: AddressActionsMixin.Factory + ): ViewModel { + return AddLedgerChainAccountSelectAddressViewModel( + router = router, + payload = payload, + addChainAccountInteractor = addChainAccountInteractor, + selectAddressLedgerInteractor = selectAddressLedgerInteractor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + chainRegistry = chainRegistry, + selectLedgerAddressPayload = selectLedgerAddressPayload, + messageCommandFormatter = messageCommandFormatter, + addressActionsMixinFactory = addressActionsMixinFactory + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddLedgerChainAccountSelectAddressViewModel { + return ViewModelProvider(fragment, viewModelFactory).get( + AddLedgerChainAccountSelectAddressViewModel::class.java + ) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/AddChainAccountSelectLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/AddChainAccountSelectLedgerFragment.kt new file mode 100644 index 0000000..87c8d8e --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/AddChainAccountSelectLedgerFragment.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerFragment + +class AddChainAccountSelectLedgerFragment : SelectLedgerFragment() { + + companion object { + private const val KEY_ADD_ACCOUNT_PAYLOAD = "AddChainAccountSelectLedgerFragment.Payload" + + fun getBundle(payload: AddChainAccountSelectLedgerPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_ADD_ACCOUNT_PAYLOAD, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .addChainAccountSelectLedgerComponentFactory() + .create(this, argument(KEY_ADD_ACCOUNT_PAYLOAD)) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/AddChainAccountSelectLedgerPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/AddChainAccountSelectLedgerPayload.kt new file mode 100644 index 0000000..82f4bd9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/AddChainAccountSelectLedgerPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger + +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import kotlinx.android.parcel.Parcelize + +@Parcelize +class AddChainAccountSelectLedgerPayload( + val addAccountPayload: AddAccountPayload.ChainAccount, + override val connectionMode: SelectLedgerPayload.ConnectionMode +) : SelectLedgerPayload diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/AddChainAccountSelectLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/AddChainAccountSelectLedgerViewModel.kt new file mode 100644 index 0000000..4d2a416 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/AddChainAccountSelectLedgerViewModel.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.domain.migration.LedgerMigrationUseCase +import io.novafoundation.nova.feature_ledger_impl.domain.migration.determineAppForLegacyAccount +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectAddress.AddLedgerChainAccountSelectAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerViewModel + +class AddChainAccountSelectLedgerViewModel( + private val migrationUseCase: LedgerMigrationUseCase, + private val router: LedgerRouter, + private val payload: AddChainAccountSelectLedgerPayload, + private val messageCommandFormatter: MessageCommandFormatter, + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + resourceManager: ResourceManager, + messageFormatter: LedgerMessageFormatter, + ledgerDeviceFormatter: LedgerDeviceFormatter +) : SelectLedgerViewModel( + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + messageFormatter = messageFormatter, + ledgerDeviceFormatter = ledgerDeviceFormatter, + messageCommandFormatter = messageCommandFormatter, + payload = payload +) { + + private val addAccountPayload = payload.addAccountPayload + + override suspend fun verifyConnection(device: LedgerDevice) { + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + + val app = migrationUseCase.determineAppForLegacyAccount(addAccountPayload.chainId) + + // ensure that address loads successfully + app.getSubstrateAccount(device, addAccountPayload.chainId, accountIndex = 0, confirmAddress = false) + + val payload = AddLedgerChainAccountSelectAddressPayload(addAccountPayload.chainId, addAccountPayload.metaId, device.id) + router.openAddChainAccountSelectAddress(payload) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/di/AddChainAccountSelectLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/di/AddChainAccountSelectLedgerComponent.kt new file mode 100644 index 0000000..9438f6d --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/di/AddChainAccountSelectLedgerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger.AddChainAccountSelectLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger.AddChainAccountSelectLedgerFragment + +@Subcomponent( + modules = [ + AddChainAccountSelectLedgerModule::class + ] +) +@ScreenScope +interface AddChainAccountSelectLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: AddChainAccountSelectLedgerPayload, + ): AddChainAccountSelectLedgerComponent + } + + fun inject(fragment: AddChainAccountSelectLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/di/AddChainAccountSelectLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/di/AddChainAccountSelectLedgerModule.kt new file mode 100644 index 0000000..25d3e3c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/addChain/legacy/selectLedger/di/AddChainAccountSelectLedgerModule.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.modules.shared.PermissionAskerForFragmentModule +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.domain.migration.LedgerMigrationUseCase +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger.AddChainAccountSelectLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.legacy.selectLedger.AddChainAccountSelectLedgerViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatterFactory + +@Module(includes = [ViewModelModule::class, PermissionAskerForFragmentModule::class]) +class AddChainAccountSelectLedgerModule { + + @Provides + @ScreenScope + fun provideMessageFormatter( + payload: AddChainAccountSelectLedgerPayload, + factory: LedgerMessageFormatterFactory, + ): LedgerMessageFormatter = factory.createLegacy(payload.addAccountPayload.chainId, showAlerts = false) + + @Provides + @ScreenScope + fun provideMessageCommandFormatter( + messageFormatter: LedgerMessageFormatter, + messageCommandFormatterFactory: MessageCommandFormatterFactory + ): MessageCommandFormatter = messageCommandFormatterFactory.create(messageFormatter) + + @Provides + @IntoMap + @ViewModelKey(AddChainAccountSelectLedgerViewModel::class) + fun provideViewModel( + migrationUseCase: LedgerMigrationUseCase, + payload: AddChainAccountSelectLedgerPayload, + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + router: LedgerRouter, + resourceManager: ResourceManager, + messageFormatter: LedgerMessageFormatter, + ledgerDeviceFormatter: LedgerDeviceFormatter, + messageCommandFormatter: MessageCommandFormatter + ): ViewModel { + return AddChainAccountSelectLedgerViewModel( + migrationUseCase = migrationUseCase, + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + payload = payload, + messageFormatter = messageFormatter, + ledgerDeviceFormatter = ledgerDeviceFormatter, + messageCommandFormatter = messageCommandFormatter + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddChainAccountSelectLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get( + AddChainAccountSelectLedgerViewModel::class.java + ) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/LedgerMessageBottomSheet.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/LedgerMessageBottomSheet.kt new file mode 100644 index 0000000..a083b81 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/LedgerMessageBottomSheet.kt @@ -0,0 +1,182 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet +import io.novafoundation.nova.common.view.setModelOrHide +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.common.view.stopTimer +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.databinding.FragmentLedgerMessageBinding + +sealed class LedgerMessageCommand { + + companion object + + object Hide : LedgerMessageCommand() + + sealed class Show( + val title: String, + val subtitle: String, + val graphics: Graphics, + val alert: AlertModel?, + val onCancel: () -> Unit, + ) : LedgerMessageCommand() { + + sealed class Error( + title: String, + subtitle: String, + graphics: Graphics, + alert: AlertModel?, + onCancel: () -> Unit, + ) : Show(title, subtitle, graphics, alert, onCancel) { + + class RecoverableError( + title: String, + subtitle: String, + graphics: Graphics, + alert: AlertModel?, + onCancel: () -> Unit, + val onRetry: () -> Unit + ) : Error(title, subtitle, graphics, alert, onCancel) + + class FatalError( + title: String, + subtitle: String, + graphics: Graphics, + alert: AlertModel?, + val onConfirm: () -> Unit, + onCancel: () -> Unit = onConfirm, // when error is fatal, confirm is the same as hide by default + ) : Error(title, subtitle, graphics, alert, onCancel) + } + + class Info( + title: String, + subtitle: String, + graphics: Graphics, + onCancel: () -> Unit, + alert: AlertModel?, + val footer: Footer + ) : Show(title, subtitle, graphics, alert, onCancel) + } + + sealed class Footer { + + class Timer( + val timerValue: TimerValue, + val closeToExpire: (TimerValue) -> Boolean, + val timerFinished: () -> Unit, + @StringRes val messageFormat: Int + ) : Footer() + + class Value(val value: String) : Footer() + + class Rows( + val first: Column, + val second: Column + ) : Footer() { + + class Column( + val label: String, + val value: String + ) + } + } + + class Graphics(@DrawableRes val ledgerImageRes: Int) +} + +class LedgerMessageBottomSheet( + context: Context, +) : BaseBottomSheet(context) { + + override val binder = FragmentLedgerMessageBinding.inflate(LayoutInflater.from(context)) + + val container: View + get() = binder.ledgerMessageContainer + + fun receiveCommand(command: LedgerMessageCommand) { + binder.ledgerMessageActions.setVisible(command is LedgerMessageCommand.Show.Error) + binder.ledgerMessageCancel.setVisible(command is LedgerMessageCommand.Show.Error.RecoverableError) + setupFooterVisibility(command is LedgerMessageCommand.Show.Info) + + when (command) { + LedgerMessageCommand.Hide -> dismiss() + + is LedgerMessageCommand.Show.Error.FatalError -> { + setupBaseShow(command) + binder.ledgerMessageConfirm.setOnClickListener { command.onConfirm() } + binder.ledgerMessageConfirm.setText(R.string.common_ok_back) + } + + is LedgerMessageCommand.Show.Error.RecoverableError -> { + setupBaseShow(command) + binder.ledgerMessageConfirm.setOnClickListener { command.onRetry() } + binder.ledgerMessageConfirm.setText(R.string.common_retry) + binder.ledgerMessageCancel.setOnClickListener { command.onCancel() } + } + + is LedgerMessageCommand.Show.Info -> { + setupBaseShow(command) + showFooter(command.footer) + } + } + } + + private fun setupFooterVisibility(visible: Boolean) { + binder.ledgerMessageFooterMessage.setVisible(visible) + binder.ledgerMessageFooterColumns.setVisible(visible) + + if (!visible) { + binder.ledgerMessageFooterMessage.stopTimer() + } + } + + private fun showFooter(footer: LedgerMessageCommand.Footer) { + binder.ledgerMessageFooterMessage.setVisible(footer !is LedgerMessageCommand.Footer.Rows) + binder.ledgerMessageFooterColumns.setVisible(footer is LedgerMessageCommand.Footer.Rows) + + when (footer) { + is LedgerMessageCommand.Footer.Value -> { + binder.ledgerMessageFooterMessage.text = footer.value + } + + is LedgerMessageCommand.Footer.Timer -> { + binder.ledgerMessageFooterMessage.startTimer( + value = footer.timerValue, + customMessageFormat = footer.messageFormat, + onTick = { view, _ -> + val textColorRes = if (footer.closeToExpire(footer.timerValue)) R.color.text_negative else R.color.text_secondary + + view.setTextColorRes(textColorRes) + }, + onFinish = { footer.timerFinished() } + ) + } + + is LedgerMessageCommand.Footer.Rows -> { + binder.ledgerMessageFooterTitle1.text = footer.first.label + binder.ledgerMessageFooterMessage1.text = footer.first.value + + binder.ledgerMessageFooterTitle2.text = footer.second.label + binder.ledgerMessageFooterMessage2.text = footer.second.value + } + } + } + + private fun setupBaseShow(command: LedgerMessageCommand.Show) { + binder.ledgerMessageTitle.text = command.title + binder.ledgerMessageSubtitle.text = command.subtitle + binder.ledgerMessageImage.setImageResource(command.graphics.ledgerImageRes) + binder.ledgerMessageAlert.setModelOrHide(command.alert) + + setOnCancelListener { command.onCancel() } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/LedgerMessagePresentable.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/LedgerMessagePresentable.kt new file mode 100644 index 0000000..8ab10c6 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/LedgerMessagePresentable.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet + +import android.content.Context +import android.view.View +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.Event + +interface LedgerMessagePresentable { + + fun presentCommand(command: LedgerMessageCommand, context: Context) +} + +interface LedgerMessageCommands { + + val ledgerMessageCommands: MutableLiveData> +} + +class SingleSheetLedgerMessagePresentable : LedgerMessagePresentable { + + private var bottomSheet: LedgerMessageBottomSheet? = null + + override fun presentCommand(command: LedgerMessageCommand, context: Context) { + when { + bottomSheet == null && command is LedgerMessageCommand.Show -> { + bottomSheet = LedgerMessageBottomSheet(context) + bottomSheet?.receiveCommand(command) + bottomSheet?.show() + } + + bottomSheet != null && command is LedgerMessageCommand.Show -> { + bottomSheet?.container?.stateChangeTransition { + bottomSheet?.receiveCommand(command) + } + } + + else -> { + bottomSheet?.receiveCommand(command) + bottomSheet = null + } + } + } + + private fun View.stateChangeTransition(onChangeState: () -> Unit) { + animate() + .alpha(0f) + .withEndAction { + onChangeState() + + animate() + .alpha(1f) + .start() + }.start() + } +} + +fun F.setupLedgerMessages(presentable: LedgerMessagePresentable) + where F : BaseFragment, V : LedgerMessageCommands { + viewModel.ledgerMessageCommands.observeEvent { + presentable.presentCommand(it, requireContext()) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/MessageCommandFormatter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/MessageCommandFormatter.kt new file mode 100644 index 0000000..d3d2040 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/MessageCommandFormatter.kt @@ -0,0 +1,215 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet + +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.second +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerApplicationResponse +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand.Footer.Rows.Column +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand.Show.Error.RecoverableError +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter.MessageKind +import io.novafoundation.nova.runtime.extrinsic.ValidityPeriod +import io.novafoundation.nova.runtime.extrinsic.closeToExpire + +class MessageCommandFormatterFactory( + private val resourceManager: ResourceManager, + private val deviceMapper: LedgerDeviceFormatter, + private val addressSchemeFormatter: AddressSchemeFormatter +) { + + fun create(messageFormatter: LedgerMessageFormatter): MessageCommandFormatter { + return MessageCommandFormatter(resourceManager, deviceMapper, messageFormatter, addressSchemeFormatter) + } +} + +class MessageCommandFormatter( + private val resourceManager: ResourceManager, + private val deviceMapper: LedgerDeviceFormatter, + private val messageFormatter: LedgerMessageFormatter, + private val addressSchemeFormatter: AddressSchemeFormatter, +) { + + context(Browserable.Presentation) + suspend fun unknownError( + device: LedgerDevice, + onRetry: () -> Unit, + onCancel: () -> Unit + ): LedgerMessageCommand { + return retryCommand( + title = resourceManager.getString(R.string.ledger_error_general_title), + subtitle = resourceManager.getString(R.string.ledger_error_general_message), + alertModel = messageFormatter.alertForKind(MessageKind.OTHER), + device = device, + onRetry = onRetry, + onCancel = onCancel + ) + } + + context(Browserable.Presentation) + suspend fun substrateApplicationError( + reason: LedgerApplicationResponse, + device: LedgerDevice, + onCancel: () -> Unit, + onRetry: () -> Unit + ): LedgerMessageCommand { + val errorTitle: String + val errorMessage: String + val alert: AlertModel? + + when (reason) { + LedgerApplicationResponse.APP_NOT_OPEN, LedgerApplicationResponse.WRONG_APP_OPEN -> { + val appName = messageFormatter.appName() + + errorTitle = resourceManager.getString(R.string.ledger_error_app_not_launched_title, appName) + errorMessage = resourceManager.getString(R.string.ledger_error_app_not_launched_message, appName) + alert = messageFormatter.alertForKind(MessageKind.APP_NOT_OPEN) + } + + LedgerApplicationResponse.TRANSACTION_REJECTED -> { + errorTitle = resourceManager.getString(R.string.ledger_error_app_cancelled_title) + errorMessage = resourceManager.getString(R.string.ledger_error_app_cancelled_message) + alert = messageFormatter.alertForKind(MessageKind.OTHER) + } + + else -> { + errorTitle = resourceManager.getString(R.string.ledger_error_general_title) + errorMessage = resourceManager.getString(R.string.ledger_error_general_message) + alert = messageFormatter.alertForKind(MessageKind.OTHER) + } + } + + return retryCommand(errorTitle, errorMessage, device, alert, onCancel, onRetry) + } + + fun retryCommand( + title: String, + subtitle: String, + device: LedgerDevice, + alertModel: AlertModel?, + onCancel: () -> Unit, + onRetry: () -> Unit + ): LedgerMessageCommand { + val deviceMapper = deviceMapper.createDelegate(device) + return RecoverableError( + title = title, + subtitle = subtitle, + alert = alertModel, + onCancel = onCancel, + onRetry = onRetry, + graphics = deviceMapper.getErrorImage() + ) + } + + context(Browserable.Presentation) + suspend fun fatalErrorCommand( + title: String, + subtitle: String, + device: LedgerDevice, + onCancel: () -> Unit, + onConfirm: () -> Unit, + ): LedgerMessageCommand { + val deviceMapper = deviceMapper.createDelegate(device) + + return LedgerMessageCommand.Show.Error.FatalError( + title = title, + subtitle = subtitle, + alert = messageFormatter.alertForKind(MessageKind.OTHER), + graphics = deviceMapper.getErrorImage(), + onCancel = onCancel, + onConfirm = onConfirm + ) + } + + context(Browserable.Presentation) + suspend fun signCommand( + validityPeriod: ValidityPeriod, + device: LedgerDevice, + onTimeFinished: () -> Unit, + onCancel: () -> Unit, + ): LedgerMessageCommand { + val deviceMapper = deviceMapper.createDelegate(device) + + return LedgerMessageCommand.Show.Info( + title = resourceManager.getString(R.string.ledger_review_approve_title), + subtitle = deviceMapper.getSignMessage(), + onCancel = onCancel, + alert = messageFormatter.alertForKind(MessageKind.OTHER), + graphics = deviceMapper.getSignImage(), + footer = LedgerMessageCommand.Footer.Timer( + timerValue = validityPeriod.period, + closeToExpire = { validityPeriod.closeToExpire() }, + timerFinished = { onTimeFinished() }, + messageFormat = R.string.ledger_sign_transaction_validity_format + ) + ) + } + + fun reviewAddressCommand( + addresses: List>, + device: LedgerDevice, + onCancel: () -> Unit, + ): LedgerMessageCommand { + val deviceMapper = deviceMapper.createDelegate(device) + + val footer: LedgerMessageCommand.Footer + val subtitle: String + + when (addresses.size) { + 0 -> error("At least one address should be not null") + + 1 -> { + footer = LedgerMessageCommand.Footer.Value( + value = addresses.single().second.toTwoLinesAddress(), + ) + subtitle = deviceMapper.getReviewAddressMessage() + } + + 2 -> { + footer = LedgerMessageCommand.Footer.Rows( + first = rowFor(addresses.first()), + second = rowFor(addresses.second()) + ) + subtitle = deviceMapper.getReviewAddressesMessage() + } + + else -> error("Too many addresses passed: ${addresses.size}") + } + + return LedgerMessageCommand.Show.Info( + title = resourceManager.getString(R.string.ledger_review_approve_title), + subtitle = subtitle, + onCancel = onCancel, + alert = null, + graphics = deviceMapper.getApproveImage(), + footer = footer + ) + } + + fun hideCommand(): LedgerMessageCommand { + return LedgerMessageCommand.Hide + } + + private fun String.toTwoLinesAddress(): String { + val middle = length / 2 + return substring(0, middle) + "\n" + substring(middle) + } + + private fun rowFor(addressWithScheme: Pair): Column { + val label = addressSchemeFormatter.addressLabel(addressWithScheme.first) + return Column(label, addressWithScheme.second.toTwoLinesAddress()) + } +} + +@Suppress("UNCHECKED_CAST") +fun createLedgerReviewAddresses( + allowedAddressSchemes: List, + vararg allAddresses: Pair +): List> { + return allAddresses.filter { it.first in allowedAddressSchemes && it.second != null } as List> +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerDeviceFormatter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerDeviceFormatter.kt new file mode 100644 index 0000000..292ef1b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerDeviceFormatter.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDeviceType +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand + +class LedgerDeviceFormatter(private val resourceManager: ResourceManager) { + + fun formatName(device: LedgerDevice): String = createDelegate(device).getName() + + fun createDelegate(device: LedgerDevice): LedgerDeviceMapperDelegate { + return when (device.deviceType) { + LedgerDeviceType.STAX -> LedgerStaxMapperDelegate(resourceManager, device) + LedgerDeviceType.FLEX -> LedgerFlexMapperDelegate(resourceManager, device) + LedgerDeviceType.NANO_X -> LedgerNanoXUIMapperDelegate(resourceManager, device) + LedgerDeviceType.NANO_S_PLUS -> LedgerNanoSPlusMapperDelegate(resourceManager, device) + LedgerDeviceType.NANO_S -> LedgerNanoSMapperDelegate(resourceManager, device) + LedgerDeviceType.NANO_GEN5 -> LedgerNanoGen5UIMapperDelegate(resourceManager, device) + } + } +} + +interface LedgerDeviceMapperDelegate { + + fun getName(): String + + fun getApproveImage(): LedgerMessageCommand.Graphics + + fun getErrorImage(): LedgerMessageCommand.Graphics + + fun getSignImage(): LedgerMessageCommand.Graphics + + fun getReviewAddressMessage(): String + + fun getReviewAddressesMessage(): String + + fun getSignMessage(): String +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerFlexMapperDelegate.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerFlexMapperDelegate.kt new file mode 100644 index 0000000..7e5248a --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerFlexMapperDelegate.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand + +class LedgerFlexMapperDelegate( + private val resourceManager: ResourceManager, + private val device: LedgerDevice +) : LedgerDeviceMapperDelegate { + + override fun getName(): String { + return device.name ?: resourceManager.getString(R.string.ledger_device_flex) + } + + override fun getApproveImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_flex_approve) + } + + override fun getSignImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_flex_sign) + } + + override fun getErrorImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_flex_error) + } + + override fun getReviewAddressMessage(): String { + return resourceManager.getString(R.string.ledger_verify_address_message_confirm_button, getName()) + } + + override fun getReviewAddressesMessage(): String { + return resourceManager.getString(R.string.ledger_verify_addresses_message_confirm_button, getName()) + } + + override fun getSignMessage(): String { + return resourceManager.getString(R.string.ledger_hold_to_sign, getName()) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoGen5UIMapperDelegate.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoGen5UIMapperDelegate.kt new file mode 100644 index 0000000..dbba3d9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoGen5UIMapperDelegate.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand + +class LedgerNanoGen5UIMapperDelegate( + private val resourceManager: ResourceManager, + private val device: LedgerDevice +) : LedgerDeviceMapperDelegate { + + override fun getName(): String { + return device.name ?: resourceManager.getString(R.string.ledger_device_nano_gen5) + } + + override fun getApproveImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_nano_gen5_approve) + } + + override fun getSignImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_nano_gen5_sign) + } + + override fun getErrorImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_nano_gen5_error) + } + + override fun getReviewAddressMessage(): String { + return resourceManager.getString(R.string.ledger_verify_address_message_confirm_button, getName()) + } + + override fun getReviewAddressesMessage(): String { + return resourceManager.getString(R.string.ledger_verify_addresses_message_confirm_button, getName()) + } + + override fun getSignMessage(): String { + return resourceManager.getString(R.string.ledger_hold_to_sign, getName()) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoSMapperDelegate.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoSMapperDelegate.kt new file mode 100644 index 0000000..dc3c0b7 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoSMapperDelegate.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand + +class LedgerNanoSMapperDelegate( + private val resourceManager: ResourceManager, + private val device: LedgerDevice +) : LedgerDeviceMapperDelegate { + + override fun getName(): String { + return device.name ?: resourceManager.getString(R.string.ledger_device_nano_s) + } + + override fun getApproveImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_nano_s_approve) + } + + override fun getSignImage(): LedgerMessageCommand.Graphics { + return getApproveImage() + } + + override fun getErrorImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_nano_s_error) + } + + override fun getReviewAddressMessage(): String { + return resourceManager.getString(R.string.ledger_verify_address_message_both_buttons, getName()) + } + + override fun getReviewAddressesMessage(): String { + return resourceManager.getString(R.string.ledger_verify_addresses_message_both_buttons, getName()) + } + + override fun getSignMessage(): String { + return resourceManager.getString(R.string.ledger_sign_approve_message, getName()) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoSPlusMapperDelegate.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoSPlusMapperDelegate.kt new file mode 100644 index 0000000..51f4c02 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoSPlusMapperDelegate.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand + +class LedgerNanoSPlusMapperDelegate( + private val resourceManager: ResourceManager, + private val device: LedgerDevice +) : LedgerDeviceMapperDelegate { + + override fun getName(): String { + return device.name ?: resourceManager.getString(R.string.ledger_device_nano_s_plus) + } + + override fun getApproveImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_nano_s_approve) + } + + override fun getSignImage(): LedgerMessageCommand.Graphics { + return getApproveImage() + } + + override fun getErrorImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_nano_s_error) + } + + override fun getReviewAddressMessage(): String { + return resourceManager.getString(R.string.ledger_verify_address_message_both_buttons, getName()) + } + + override fun getReviewAddressesMessage(): String { + return resourceManager.getString(R.string.ledger_verify_addresses_message_both_buttons, getName()) + } + + override fun getSignMessage(): String { + return resourceManager.getString(R.string.ledger_sign_approve_message, getName()) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoXUIMapperDelegate.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoXUIMapperDelegate.kt new file mode 100644 index 0000000..fa8f952 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerNanoXUIMapperDelegate.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand + +class LedgerNanoXUIMapperDelegate( + private val resourceManager: ResourceManager, + private val device: LedgerDevice +) : LedgerDeviceMapperDelegate { + + override fun getName(): String { + return device.name ?: resourceManager.getString(R.string.ledger_device_nano_x) + } + + override fun getApproveImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_nano_x_approve) + } + + override fun getSignImage(): LedgerMessageCommand.Graphics { + return getApproveImage() + } + + override fun getErrorImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_nano_x_error) + } + + override fun getReviewAddressMessage(): String { + return resourceManager.getString(R.string.ledger_verify_address_message_both_buttons, getName()) + } + + override fun getReviewAddressesMessage(): String { + return resourceManager.getString(R.string.ledger_verify_addresses_message_both_buttons, getName()) + } + + override fun getSignMessage(): String { + return resourceManager.getString(R.string.ledger_sign_approve_message, getName()) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerStaxMapperDelegate.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerStaxMapperDelegate.kt new file mode 100644 index 0000000..0451262 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/bottomSheet/mappers/LedgerStaxMapperDelegate.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand + +class LedgerStaxMapperDelegate( + private val resourceManager: ResourceManager, + private val device: LedgerDevice +) : LedgerDeviceMapperDelegate { + + override fun getName(): String { + return device.name ?: resourceManager.getString(R.string.ledger_device_stax) + } + + override fun getApproveImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_stax_approve) + } + + override fun getSignImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_stax_sign) + } + + override fun getErrorImage(): LedgerMessageCommand.Graphics { + return LedgerMessageCommand.Graphics(R.drawable.ic_ledger_stax_error) + } + + override fun getReviewAddressMessage(): String { + return resourceManager.getString(R.string.ledger_verify_address_message_confirm_button, getName()) + } + + override fun getReviewAddressesMessage(): String { + return resourceManager.getString(R.string.ledger_verify_addresses_message_confirm_button, getName()) + } + + override fun getSignMessage(): String { + return resourceManager.getString(R.string.ledger_hold_to_sign, getName()) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/errors/LedgerApplicationErrors.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/errors/LedgerApplicationErrors.kt new file mode 100644 index 0000000..e732e86 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/errors/LedgerApplicationErrors.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.errors + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateLedgerApplicationError +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommands +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +fun V.handleLedgerError( + reason: Throwable, + device: LedgerDevice, + commandFormatter: MessageCommandFormatter, + onRetry: () -> Unit +) where V : BaseViewModel, V : LedgerMessageCommands, V : Browserable.Presentation { + reason.printStackTrace() + + launch { + when (reason) { + is CancellationException -> { + // do nothing on coroutines cancellation + } + + is SubstrateLedgerApplicationError.Response -> { + ledgerMessageCommands.value = commandFormatter.substrateApplicationError( + reason = reason.response, + device = device, + onCancel = ::hide, + onRetry = onRetry + ).event() + } + + else -> { + ledgerMessageCommands.value = commandFormatter.unknownError( + device = device, + onRetry = onRetry, + onCancel = ::hide + ).event() + } + } + } +} + +private fun LedgerMessageCommands.hide() { + ledgerMessageCommands.value = LedgerMessageCommand.Hide.event() +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/GenericLedgerMessageFormatter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/GenericLedgerMessageFormatter.kt new file mode 100644 index 0000000..dfe3c42 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/GenericLedgerMessageFormatter.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters + +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.feature_ledger_impl.R + +class GenericLedgerMessageFormatter( + private val resourceManager: ResourceManager, +) : LedgerMessageFormatter { + + override suspend fun appName(): String { + return resourceManager.getString(R.string.account_ledger_migration_generic) + } + + context(Browserable.Presentation) + override suspend fun alertForKind(messageKind: LedgerMessageFormatter.MessageKind): AlertModel? { + // We do not show any alerts for new ledger app + return null + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/LedgerMessageFormatter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/LedgerMessageFormatter.kt new file mode 100644 index 0000000..6d0ecad --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/LedgerMessageFormatter.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters + +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.view.AlertModel + +interface LedgerMessageFormatter { + + enum class MessageKind { + APP_NOT_OPEN, OTHER + } + + suspend fun appName(): String + + context(Browserable.Presentation) + suspend fun alertForKind( + messageKind: MessageKind, + ): AlertModel? +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/LedgerMessageFormatterFactory.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/LedgerMessageFormatterFactory.kt new file mode 100644 index 0000000..b4e5ff8 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/LedgerMessageFormatterFactory.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters + +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class LedgerMessageFormatterFactory( + private val resourceManager: ResourceManager, + private val migrationTracker: LedgerMigrationTracker, + private val chainRegistry: ChainRegistry, + private val appLinksProvider: AppLinksProvider, +) { + + fun createLegacy(chainId: ChainId, showAlerts: Boolean): LedgerMessageFormatter { + return LegacyLedgerMessageFormatter(migrationTracker, resourceManager, chainRegistry, appLinksProvider, chainId, showAlerts) + } + + fun createGeneric(): LedgerMessageFormatter { + return GenericLedgerMessageFormatter(resourceManager) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/LegacyLedgerMessageFormatter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/LegacyLedgerMessageFormatter.kt new file mode 100644 index 0000000..b0d0ac1 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/formatters/LegacyLedgerMessageFormatter.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters + +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertModel.ActionModel +import io.novafoundation.nova.common.view.AlertView.StylePreset +import io.novafoundation.nova.common.view.asStyle +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class LegacyLedgerMessageFormatter( + private val migrationTracker: LedgerMigrationTracker, + private val resourceManager: ResourceManager, + private val chainRegistry: ChainRegistry, + private val appLinksProvider: AppLinksProvider, + private val chainId: ChainId, + private val showAlerts: Boolean +) : LedgerMessageFormatter { + + private var shouldUseMigrationApp: Boolean? = null + private val cacheMutex = Mutex() + + override suspend fun appName(): String { + return if (shouldUseMigrationApp()) { + resourceManager.getString(R.string.account_ledger_migration_app) + } else { + val chain = chainRegistry.getChain(chainId) + chain.name + } + } + + context(Browserable.Presentation) + override suspend fun alertForKind(messageKind: LedgerMessageFormatter.MessageKind): AlertModel? { + val shouldShowAlert = showAlerts && shouldUseMigrationApp() + if (!shouldShowAlert) return null + + return when (messageKind) { + LedgerMessageFormatter.MessageKind.APP_NOT_OPEN -> AlertModel( + style = StylePreset.INFO.asStyle(), + message = resourceManager.getString(R.string.account_ledger_legacy_warning_title), + subMessage = resourceManager.getString(R.string.account_ledger_legacy_warning_message), + linkAction = ActionModel( + text = resourceManager.getString(R.string.common_find_out_more), + listener = { showBrowser(appLinksProvider.ledgerMigrationArticle) } + ) + ) + + LedgerMessageFormatter.MessageKind.OTHER -> AlertModel( + style = StylePreset.INFO.asStyle(), + message = resourceManager.getString(R.string.account_ledger_legacy_warning_title), + subMessage = resourceManager.getString(R.string.account_ledger_migration_deprecation_message), + linkAction = ActionModel( + text = resourceManager.getString(R.string.common_find_out_more), + listener = { showBrowser(appLinksProvider.ledgerMigrationArticle) } + ) + ) + } + } + + private suspend fun shouldUseMigrationApp(): Boolean = cacheMutex.withLock { + if (shouldUseMigrationApp == null) { + shouldUseMigrationApp = migrationTracker.shouldUseMigrationApp(chainId) + } + + shouldUseMigrationApp!! + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/generic/GenericLedgerEvmAlertFormatter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/generic/GenericLedgerEvmAlertFormatter.kt new file mode 100644 index 0000000..e25bc32 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/generic/GenericLedgerEvmAlertFormatter.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.generic + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_ledger_impl.R +import javax.inject.Inject + +interface GenericLedgerEvmAlertFormatter { + + fun createUpdateAppToGetEvmAddressAlert(): AlertModel +} + +@FeatureScope +class RealGenericLedgerEvmAlertFormatter @Inject constructor( + private val resourceManager: ResourceManager, +) : GenericLedgerEvmAlertFormatter { + + override fun createUpdateAppToGetEvmAddressAlert(): AlertModel { + return AlertModel( + style = AlertView.Style.fromPreset(AlertView.StylePreset.WARNING), + message = resourceManager.getString(R.string.ledger_select_address_update_for_evm_title), + subMessage = resourceManager.getString(R.string.ledger_select_address_update_for_evm_message) + ) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/SelectAddressLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/SelectAddressLedgerFragment.kt new file mode 100644 index 0000000..9682468 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/SelectAddressLedgerFragment.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress + +import androidx.core.os.bundleOf +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.view.setModelOrHide +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.setupAddressActions +import io.novafoundation.nova.feature_ledger_impl.databinding.FragmentImportLedgerSelectAddressBinding +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessagePresentable +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.setupLedgerMessages +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.list.LedgerAccountAdapter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.list.LedgerSelectAddressLoadMoreAdapter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model.LedgerAccountRvItem +import javax.inject.Inject + +abstract class SelectAddressLedgerFragment : + BaseFragment(), + LedgerSelectAddressLoadMoreAdapter.Handler, + LedgerAccountAdapter.Handler { + + companion object { + + private const val PAYLOAD_KEY = "SelectAddressImportLedgerFragment.PAYLOAD_KEY" + + fun getBundle(payload: SelectLedgerAddressPayload) = bundleOf(PAYLOAD_KEY to payload) + } + + @Inject + lateinit var imageLoader: ImageLoader + + override fun createBinding() = FragmentImportLedgerSelectAddressBinding.inflate(layoutInflater) + + private val addressesAdapter by lazy(LazyThreadSafetyMode.NONE) { + LedgerAccountAdapter(this) + } + private val loadMoreAdapter = LedgerSelectAddressLoadMoreAdapter(handler = this, lifecycleOwner = this) + + @Inject + lateinit var ledgerMessagePresentable: LedgerMessagePresentable + + protected fun payload(): SelectLedgerAddressPayload = argument(PAYLOAD_KEY) + + override fun initViews() { + binder.ledgerSelectAddressToolbar.setHomeButtonListener { + viewModel.backClicked() + } + onBackPressed { viewModel.backClicked() } + + binder.ledgerSelectAddressContent.setHasFixedSize(true) + binder.ledgerSelectAddressContent.adapter = ConcatAdapter(addressesAdapter, loadMoreAdapter) + } + + override fun subscribe(viewModel: V) { + viewModel.loadMoreState.observe(loadMoreAdapter::setState) + viewModel.loadedAccountModels.observe(addressesAdapter::submitList) + + viewModel.chainUi.observe(binder.ledgerSelectAddressChain::setChain) + + viewModel.alertFlow.observe(binder.ledgerSelectAddressAlert::setModelOrHide) + + setupLedgerMessages(ledgerMessagePresentable) + + viewModel.addressActionsMixin.setupAddressActions() + } + + override fun loadMoreClicked() { + viewModel.loadMoreClicked() + } + + override fun itemClicked(item: LedgerAccountRvItem) { + viewModel.accountClicked(item) + } + + override fun addressInfoClicked(addressModel: AddressModel, addressScheme: AddressScheme) { + viewModel.addressInfoClicked(addressModel, addressScheme) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/SelectAddressLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/SelectAddressLedgerViewModel.kt new file mode 100644 index 0000000..e0f3ceb --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/SelectAddressLedgerViewModel.kt @@ -0,0 +1,228 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.format.AddressFormat +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.GENERIC_ADDRESS_PREFIX +import io.novafoundation.nova.common.utils.added +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.showAddressActions +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.address +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.LedgerAccount +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommands +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.createLedgerReviewAddresses +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.errors.handleLedgerError +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model.AddressVerificationMode +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model.LedgerAccountRvItem +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.ss58.SS58Encoder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +abstract class SelectAddressLedgerViewModel( + private val router: LedgerRouter, + protected val interactor: SelectAddressLedgerInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val payload: SelectLedgerAddressPayload, + private val chainRegistry: ChainRegistry, + private val messageCommandFormatter: MessageCommandFormatter, + private val addressActionsMixinFactory: AddressActionsMixin.Factory +) : BaseViewModel(), + LedgerMessageCommands, + Browserable.Presentation by Browserable() { + + abstract val ledgerVariant: LedgerVariant + + abstract val addressVerificationMode: AddressVerificationMode + + override val ledgerMessageCommands: MutableLiveData> = MutableLiveData() + + private val substratePreviewChain by lazyAsync { chainRegistry.getChain(payload.substrateChainId) } + + private val loadingState = MutableStateFlow(AccountLoadingState.CAN_LOAD) + protected val loadedAccounts: MutableStateFlow> = MutableStateFlow(emptyList()) + + private var verifyAddressJob: Job? = null + + val loadedAccountModels = loadedAccounts.mapList { it.toUi() } + .shareInBackground() + + val chainUi = flowOf { mapChainToUi(substratePreviewChain()) } + .shareInBackground() + + val loadMoreState = loadingState.map { loadingState -> + when (loadingState) { + AccountLoadingState.CAN_LOAD -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.ledger_import_select_address_load_more)) + + AccountLoadingState.LOADING -> DescriptiveButtonState.Loading + AccountLoadingState.NOTHING_TO_LOAD -> DescriptiveButtonState.Gone + } + }.shareInBackground() + + protected val _alertFlow = MutableStateFlow(null) + val alertFlow: Flow = _alertFlow + + val device = flowOf { + interactor.getDevice(payload.deviceId) + } + + val addressActionsMixin = addressActionsMixinFactory.create(this) + + init { + loadNewAccount() + } + + abstract fun onAccountVerified(account: LedgerAccount) + + /** + * Loads ledger account. Can return Success(null) to indicate there is nothing to load and "load more" button should be hidden + */ + protected open suspend fun loadLedgerAccount( + substratePreviewChain: Chain, + deviceId: String, + accountIndex: Int, + ledgerVariant: LedgerVariant + ): Result { + return interactor.loadLedgerAccount(substratePreviewChain, deviceId, accountIndex, ledgerVariant) + } + + fun loadMoreClicked() { + if (loadingState.value != AccountLoadingState.CAN_LOAD) return + + loadNewAccount() + } + + fun backClicked() { + router.back() + } + + fun accountClicked(accountUi: LedgerAccountRvItem) { + verifyAccount(accountUi.id) + } + + fun addressInfoClicked(addressModel: AddressModel, addressScheme: AddressScheme) { + addressActionsMixin.showAddressActions(addressModel.address, AddressFormat.defaultForScheme(addressScheme, SS58Encoder.GENERIC_ADDRESS_PREFIX)) + } + + private fun verifyAccount(id: Int) { + verifyAddressJob?.cancel() + verifyAddressJob = launch { + val account = loadedAccounts.value.first { it.index == id } + val verificationMode = addressVerificationMode + + if (verificationMode is AddressVerificationMode.Enabled) { + verifyAccountInternal(account, verificationMode.addressSchemesToVerify) + } else { + onAccountVerified(account) + } + } + } + + private suspend fun verifyAccountInternal(account: LedgerAccount, reviewAddressSchemes: List) { + val device = device.first() + + ledgerMessageCommands.value = messageCommandFormatter.reviewAddressCommand( + addresses = createLedgerReviewAddresses( + allowedAddressSchemes = reviewAddressSchemes, + AddressScheme.SUBSTRATE to account.substrate.address, + AddressScheme.EVM to account.evm?.address() + ), + device = device, + onCancel = ::verifyAddressCancelled, + ).event() + + val result = withContext(Dispatchers.Default) { + interactor.verifyLedgerAccount(substratePreviewChain(), payload.deviceId, account.index, ledgerVariant, reviewAddressSchemes) + } + + result.onFailure { + handleLedgerError(it, device) { verifyAccount(account.index) } + }.onSuccess { + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + + onAccountVerified(account) + } + } + + private fun handleLedgerError(error: Throwable, device: LedgerDevice, retry: () -> Unit) { + handleLedgerError( + reason = error, + device = device, + commandFormatter = messageCommandFormatter, + onRetry = retry + ) + } + + private fun verifyAddressCancelled() { + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + verifyAddressJob?.cancel() + verifyAddressJob = null + } + + private fun loadNewAccount(): Unit = launchUnit(Dispatchers.Default) { + ledgerMessageCommands.postValue(messageCommandFormatter.hideCommand().event()) + + loadingState.value = AccountLoadingState.LOADING + val nextAccountIndex = loadedAccounts.value.size + + loadLedgerAccount(substratePreviewChain(), payload.deviceId, nextAccountIndex, ledgerVariant) + .onSuccess { newAccount -> + if (newAccount != null) { + loadedAccounts.value = loadedAccounts.value.added(newAccount) + loadingState.value = AccountLoadingState.CAN_LOAD + } else { + loadingState.value = AccountLoadingState.NOTHING_TO_LOAD + } + }.onFailure { + Log.e("Ledger", "Failed to load Ledger account", it) + handleLedgerError(it, device.first()) { loadNewAccount() } + loadingState.value = AccountLoadingState.CAN_LOAD + } + } + + private suspend fun LedgerAccount.toUi(): LedgerAccountRvItem { + return LedgerAccountRvItem( + id = index, + label = resourceManager.getString(R.string.ledger_select_address_account_label, (index + 1).format()), + substrate = addressIconGenerator.createAccountAddressModel(substratePreviewChain(), substrate.address), + evm = evm?.let { addressIconGenerator.createAccountAddressModel(AddressFormat.evm(), it.accountId) } + ) + } + + enum class AccountLoadingState { + CAN_LOAD, LOADING, NOTHING_TO_LOAD + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/SelectLedgerAddressPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/SelectLedgerAddressPayload.kt new file mode 100644 index 0000000..b0b9fdc --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/SelectLedgerAddressPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress + +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +@Parcelize +class SelectLedgerAddressPayload( + val deviceId: String, + val substrateChainId: ChainId +) : Parcelable diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/list/LedgerAccountAdapter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/list/LedgerAccountAdapter.kt new file mode 100644 index 0000000..33af451 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/list/LedgerAccountAdapter.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.setExtraInfoAvailable +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.databinding.ItemLedgerAccountBinding +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model.LedgerAccountRvItem + +class LedgerAccountAdapter( + private val handler: Handler +) : BaseListAdapter(DiffCallback()) { + + interface Handler { + + fun itemClicked(item: LedgerAccountRvItem) + + fun addressInfoClicked(addressModel: AddressModel, addressScheme: AddressScheme) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectLedgerHolder { + return SelectLedgerHolder(ItemLedgerAccountBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: SelectLedgerHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: LedgerAccountRvItem, newItem: LedgerAccountRvItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: LedgerAccountRvItem, newItem: LedgerAccountRvItem): Boolean { + return oldItem == newItem + } +} + +class SelectLedgerHolder( + private val viewBinding: ItemLedgerAccountBinding, + private val eventHandler: LedgerAccountAdapter.Handler +) : BaseViewHolder(viewBinding.root) { + init { + viewBinding.root.background = with(containerView.context) { + addRipple(getRoundedCornerDrawable(R.color.block_background)) + } + + viewBinding.itemLedgerAccountSubstrate.setExtraInfoAvailable(true) + } + + fun bind(model: LedgerAccountRvItem) = with(viewBinding) { + viewBinding.root.setOnClickListener { eventHandler.itemClicked(model) } + + itemLedgerAccountLabel.text = model.label + itemLedgerAccountIcon.setImageDrawable(model.substrate.image) + + itemLedgerAccountSubstrate.showAddress(model.substrate) + itemLedgerAccountSubstrate.setOnClickListener { eventHandler.addressInfoClicked(model.substrate, AddressScheme.SUBSTRATE) } + + if (model.evm != null) { + itemLedgerAccountEvm.valuePrimary.setTextColorRes(R.color.text_primary) + itemLedgerAccountEvm.setPrimaryValueStartIcon(null) + itemLedgerAccountEvm.showAddress(model.evm) + + itemLedgerAccountEvm.setOnClickListener { eventHandler.addressInfoClicked(model.evm, AddressScheme.EVM) } + itemLedgerAccountEvm.setExtraInfoAvailable(true) + } else { + itemLedgerAccountEvm.valuePrimary.setTextColorRes(R.color.text_secondary) + itemLedgerAccountEvm.showValue(context.getString(R.string.ledger_select_address_not_found)) + + itemLedgerAccountEvm.setOnClickListener(null) + itemLedgerAccountEvm.setExtraInfoAvailable(false) + + itemLedgerAccountEvm.setPrimaryValueStartIcon(R.drawable.ic_warning_filled) + } + } + + override fun unbind() {} +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/list/LedgerSelectAddressLoadMoreAdapter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/list/LedgerSelectAddressLoadMoreAdapter.kt new file mode 100644 index 0000000..36d4c11 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/list/LedgerSelectAddressLoadMoreAdapter.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.list + +import android.view.ViewGroup +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.utils.inflateChild +import io.novafoundation.nova.common.view.PrimaryButton +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_ledger_impl.R + +class LedgerSelectAddressLoadMoreAdapter( + private val handler: Handler, + private val lifecycleOwner: LifecycleOwner, +) : RecyclerView.Adapter() { + + interface Handler { + + fun loadMoreClicked() + } + + private var state: DescriptiveButtonState? = null + + fun setState(newState: DescriptiveButtonState) { + state = newState + + notifyItemChanged(0) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LedgerSelectAddressLoadMoreViewHolder { + val containerView = parent.inflateChild(R.layout.item_select_address_load_more) as PrimaryButton + + return LedgerSelectAddressLoadMoreViewHolder(containerView, handler, lifecycleOwner) + } + + override fun onBindViewHolder(holder: LedgerSelectAddressLoadMoreViewHolder, position: Int) { + state?.let(holder::bind) + } + + override fun getItemCount(): Int = 1 +} + +class LedgerSelectAddressLoadMoreViewHolder( + override val containerView: PrimaryButton, + handler: LedgerSelectAddressLoadMoreAdapter.Handler, + lifecycleOwner: LifecycleOwner, +) : BaseViewHolder(containerView) { + + init { + containerView.prepareForProgress(lifecycleOwner) + containerView.setOnClickListener { handler.loadMoreClicked() } + } + + fun bind(state: DescriptiveButtonState) { + containerView.setState(state) + } + + override fun unbind() {} +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/model/AddressVerificationMode.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/model/AddressVerificationMode.kt new file mode 100644 index 0000000..b620ab4 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/model/AddressVerificationMode.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model + +import io.novafoundation.nova.common.address.format.AddressScheme + +sealed class AddressVerificationMode { + + data object Disabled : AddressVerificationMode() + + class Enabled(val addressSchemesToVerify: List) : AddressVerificationMode() +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/model/LedgerAccountRvItem.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/model/LedgerAccountRvItem.kt new file mode 100644 index 0000000..16e5ad6 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectAddress/model/LedgerAccountRvItem.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model + +import io.novafoundation.nova.common.address.AddressModel + +data class LedgerAccountRvItem( + val id: Int, + val label: String, + val substrate: AddressModel, + val evm: AddressModel? +) diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerAdapter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerAdapter.kt new file mode 100644 index 0000000..138c54b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerAdapter.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.databinding.ItemLedgerBinding +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.model.SelectLedgerModel + +class SelectLedgerAdapter( + private val handler: Handler +) : BaseListAdapter(DiffCallback()) { + + interface Handler { + + fun itemClicked(item: SelectLedgerModel) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectLedgerHolder { + return SelectLedgerHolder(ItemLedgerBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: SelectLedgerHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SelectLedgerModel, newItem: SelectLedgerModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: SelectLedgerModel, newItem: SelectLedgerModel): Boolean { + return oldItem == newItem + } +} + +class SelectLedgerHolder( + private val binder: ItemLedgerBinding, + private val eventHandler: SelectLedgerAdapter.Handler +) : BaseViewHolder(binder.root) { + init { + binder.itemLedger.background = with(containerView.context) { + addRipple(getRoundedCornerDrawable(R.color.block_background)) + } + + binder.itemLedger.setProgressTint(R.color.icon_secondary) + } + + fun bind(model: SelectLedgerModel) = with(binder) { + itemLedger.title.text = model.name + + bindConnecting(model) + } + + fun bindConnecting(model: SelectLedgerModel) = with(binder) { + itemLedger.setInProgress(model.isConnecting) + + if (model.isConnecting) { + root.setOnClickListener(null) + } else { + root.setOnClickListener { eventHandler.itemClicked(model) } + } + } + + override fun unbind() {} +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerFragment.kt new file mode 100644 index 0000000..6f1026f --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerFragment.kt @@ -0,0 +1,133 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger + +import android.bluetooth.BluetoothAdapter +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.location.LocationManager +import android.os.Bundle +import androidx.core.view.isVisible +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.permissions.setupPermissionAsker +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.dialog.dialog +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.databinding.FragmentSelectLedgerBinding +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessagePresentable +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.setupLedgerMessages +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.model.SelectLedgerModel +import javax.inject.Inject + +abstract class SelectLedgerFragment : BaseFragment(), SelectLedgerAdapter.Handler { + + override fun createBinding() = FragmentSelectLedgerBinding.inflate(layoutInflater) + + @Inject + lateinit var ledgerMessagePresentable: LedgerMessagePresentable + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + SelectLedgerAdapter(this) + } + + private val bluetoothConnectivityReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) { + BluetoothAdapter.STATE_OFF -> viewModel.bluetoothStateChanged(BluetoothState.OFF) + BluetoothAdapter.STATE_ON -> viewModel.bluetoothStateChanged(BluetoothState.ON) + } + } + } + + private val locationStateReceiver: BroadcastReceiver = object : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent) { + val action = intent.action + if (action != null && action == LocationManager.PROVIDERS_CHANGED_ACTION) { + viewModel.locationStateChanged() + } + } + } + + override fun initViews() { + binder.selectLedgerToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.selectLedgerGrantPermissions.setOnClickListener { viewModel.allowAvailabilityRequests() } + + binder.selectLedgerDevices.setHasFixedSize(true) + binder.selectLedgerDevices.adapter = adapter + } + + override fun subscribe(viewModel: V) { + viewModel.deviceModels.observe { + adapter.submitList(it) + + binder.selectLedgerDevices.setVisible(it.isNotEmpty()) + binder.selectLedgerProgress.setVisible(it.isEmpty()) + } + + viewModel.showRequestLocationDialog.observe { + dialog(requireContext(), R.style.AccentAlertDialogTheme) { + setTitle(R.string.select_ledger_location_enable_request_title) + setMessage(getString(R.string.select_ledger_location_enable_request_message)) + setPositiveButton(R.string.common_enable) { _, _ -> viewModel.enableLocationAcknowledged() } + setNegativeButton(R.string.common_cancel, null) + } + } + + viewModel.hints.observe(binder.selectLedgerHints::setText) + viewModel.showPermissionsButton.observe { binder.selectLedgerGrantPermissions.isVisible = it } + + setupPermissionAsker(viewModel) + setupLedgerMessages(ledgerMessagePresentable) + observeBrowserEvents(viewModel) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableBluetoothConnectivityTracker() + enableLocationStateTracker() + } + + override fun onDestroy() { + super.onDestroy() + + disableBluetoothConnectivityTracker() + disableLocationStateTracker() + } + + private fun enableLocationStateTracker() { + val filter = IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION) + + requireActivity().registerReceiver(locationStateReceiver, filter) + } + + private fun disableLocationStateTracker() { + try { + requireActivity().unregisterReceiver(locationStateReceiver) + } catch (e: IllegalArgumentException) { + // Receiver not registered + } + } + + private fun enableBluetoothConnectivityTracker() { + val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + + requireActivity().registerReceiver(bluetoothConnectivityReceiver, filter) + } + + private fun disableBluetoothConnectivityTracker() { + try { + requireActivity().unregisterReceiver(bluetoothConnectivityReceiver) + } catch (e: IllegalArgumentException) { + // Receiver not registered + } + } + + override fun itemClicked(item: SelectLedgerModel) { + viewModel.deviceClicked(item) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerPayload.kt new file mode 100644 index 0000000..3496160 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger + +import android.os.Parcelable + +interface SelectLedgerPayload : Parcelable { + + val connectionMode: ConnectionMode + + enum class ConnectionMode { + BLUETOOTH, USB, ALL + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerViewModel.kt new file mode 100644 index 0000000..02efeb1 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/SelectLedgerViewModel.kt @@ -0,0 +1,315 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger + +import android.Manifest +import android.os.Build +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.permissions.checkPermissions +import io.novafoundation.nova.common.utils.stateMachine.StateMachine +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryRequirement +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryRequirementAvailability +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.discoveryRequirements +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.findDevice +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommands +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.errors.handleLedgerError +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.model.SelectLedgerModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SelectLedgerEvent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SideEffect +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.states.DevicesFoundState +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.states.DiscoveringState +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.states.SelectLedgerState +import io.novafoundation.nova.feature_ledger_impl.sdk.discovery.ble.BleScanFailed +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +enum class BluetoothState { + ON, OFF +} + +abstract class SelectLedgerViewModel( + private val discoveryService: LedgerDeviceDiscoveryService, + private val permissionsAsker: PermissionsAsker.Presentation, + private val bluetoothManager: BluetoothManager, + private val locationManager: LocationManager, + private val router: ReturnableRouter, + private val resourceManager: ResourceManager, + private val messageFormatter: LedgerMessageFormatter, + private val payload: SelectLedgerPayload, + private val ledgerDeviceFormatter: LedgerDeviceFormatter, + private val messageCommandFormatter: MessageCommandFormatter, +) : BaseViewModel(), + PermissionsAsker by permissionsAsker, + LedgerMessageCommands, + Browserable.Presentation by Browserable() { + + private val discoveryMethods = payload.connectionMode.toDiscoveryMethod() + + private val stateMachine = StateMachine(createInitialState(), coroutineScope = this) + + val deviceModels = stateMachine.state.map(::mapStateToUi) + .shareInBackground() + + val hints = flowOf { + when (payload.connectionMode) { + SelectLedgerPayload.ConnectionMode.BLUETOOTH -> resourceManager.getString( + R.string.account_ledger_select_device_description, + messageFormatter.appName() + ) + + SelectLedgerPayload.ConnectionMode.USB -> resourceManager.getString( + R.string.account_ledger_select_device_usb_description, + messageFormatter.appName() + ) + + SelectLedgerPayload.ConnectionMode.ALL -> resourceManager.getString(R.string.account_ledger_select_device_all_description) + } + }.shareInBackground() + + val showPermissionsButton = flowOf { payload.connectionMode == SelectLedgerPayload.ConnectionMode.ALL } + .shareInBackground() + + override val ledgerMessageCommands = MutableLiveData>() + + private val _showRequestLocationDialog = MutableLiveData() + val showRequestLocationDialog: LiveData = _showRequestLocationDialog + + init { + handleSideEffects() + setupDiscoveryObserving() + } + + abstract suspend fun verifyConnection(device: LedgerDevice) + + open suspend fun handleLedgerError(reason: Throwable, device: LedgerDevice) { + handleLedgerError( + reason = reason, + device = device, + commandFormatter = messageCommandFormatter, + onRetry = { stateMachine.onEvent(SelectLedgerEvent.DeviceChosen(device)) } + ) + } + + open fun backClicked() { + router.back() + } + + fun allowAvailabilityRequests() { + stateMachine.onEvent(SelectLedgerEvent.AvailabilityRequestsAllowed) + } + + fun deviceClicked(item: SelectLedgerModel) = launch { + discoveryService.findDevice(item.id)?.let { device -> + stateMachine.onEvent(SelectLedgerEvent.DeviceChosen(device)) + } + } + + fun bluetoothStateChanged(state: BluetoothState) { + when (state) { + BluetoothState.ON -> stateMachine.onEvent(SelectLedgerEvent.DiscoveryRequirementSatisfied(DiscoveryRequirement.BLUETOOTH)) + BluetoothState.OFF -> stateMachine.onEvent(SelectLedgerEvent.DiscoveryRequirementMissing(DiscoveryRequirement.BLUETOOTH)) + } + } + + fun locationStateChanged() { + emitLocationState() + } + + fun enableLocationAcknowledged() { + locationManager.enableLocation() + } + + override fun onCleared() { + discoveryService.stopDiscovery() + } + + private fun emitLocationState() { + when (locationManager.isLocationEnabled()) { + true -> stateMachine.onEvent(SelectLedgerEvent.DiscoveryRequirementSatisfied(DiscoveryRequirement.LOCATION)) + false -> stateMachine.onEvent(SelectLedgerEvent.DiscoveryRequirementMissing(DiscoveryRequirement.LOCATION)) + } + } + + private fun handleSideEffects() { + launch { + for (effect in stateMachine.sideEffects) { + handleSideEffect(effect) + } + } + } + + private fun performConnectionVerification(device: LedgerDevice) = launch { + runCatching { verifyConnection(device) } + .onSuccess { stateMachine.onEvent(SelectLedgerEvent.ConnectionVerified) } + .onFailure { stateMachine.onEvent(SelectLedgerEvent.VerificationFailed(it)) } + } + + private suspend fun handleSideEffect(effect: SideEffect) { + when (effect) { + is SideEffect.PresentLedgerFailure -> launch { handleLedgerError(effect.reason, effect.device) } + + is SideEffect.VerifyConnection -> performConnectionVerification(effect.device) + + is SideEffect.StartDiscovery -> discoveryService.startDiscovery(effect.methods) + + is SideEffect.RequestPermissions -> requestPermissions(effect.requirements, effect.shouldExitUponDenial) + + is SideEffect.RequestSatisfyRequirement -> requestSatisfyRequirement(effect.requirements) + + is SideEffect.StopDiscovery -> discoveryService.stopDiscovery(effect.methods) + } + } + + private suspend fun requestSatisfyRequirement(requirements: List) { + val (awaitable, fireAndForget) = requirements.map { it.createRequest() } + .partition { it.awaitable } + + // Do awaitable requests first to reduce the change of overlapping requests happening + // With this logic overlapping requests may only happen if there are more than one `fireAndForget` requirement + // Important: permissions are handled separately and state machine ensures permissions are always requested first + awaitable.forEach { it.requestAction() } + fireAndForget.forEach { it.requestAction() } + } + + private suspend fun requestPermissions( + discoveryRequirements: List, + shouldExitUponDenial: Boolean + ): Boolean { + val permissions = discoveryRequirements.requiredPermissions() + + val granted = permissionsAsker.requirePermissions(*permissions.toTypedArray()) + + if (granted) { + stateMachine.onEvent(SelectLedgerEvent.PermissionsGranted) + } else { + onPermissionsNotGranted(shouldExitUponDenial) + } + + return granted + } + + private fun setupDiscoveryObserving() { + discoveryService.errors.onEach(::discoveryError) + + discoveryService.discoveredDevices.onEach { + stateMachine.onEvent(SelectLedgerEvent.DiscoveredDevicesListChanged(it)) + }.launchIn(this) + } + + private fun onPermissionsNotGranted(shouldExitUponDenial: Boolean) { + if (shouldExitUponDenial) { + router.back() + } + } + + private fun discoveryError(error: Throwable) { + when (error) { + is BleScanFailed -> { + val event = SelectLedgerEvent.DiscoveryRequirementMissing(DiscoveryRequirement.BLUETOOTH) + stateMachine.onEvent(event) + } + } + } + + private fun mapStateToUi(state: SelectLedgerState): List { + return when (state) { + is DevicesFoundState -> mapDevicesToUi(state.devices, connectingTo = state.verifyingDevice) + is DiscoveringState -> emptyList() + } + } + + private fun mapDevicesToUi(devices: List, connectingTo: LedgerDevice?): List { + return devices.map { + SelectLedgerModel( + id = it.id, + name = ledgerDeviceFormatter.formatName(it), + isConnecting = it.id == connectingTo?.id + ) + } + } + + private fun SelectLedgerPayload.ConnectionMode.toDiscoveryMethod(): DiscoveryMethods { + return when (this) { + SelectLedgerPayload.ConnectionMode.BLUETOOTH -> DiscoveryMethods(DiscoveryMethods.Method.BLE) + SelectLedgerPayload.ConnectionMode.USB -> DiscoveryMethods(DiscoveryMethods.Method.USB) + SelectLedgerPayload.ConnectionMode.ALL -> DiscoveryMethods(DiscoveryMethods.Method.BLE, DiscoveryMethods.Method.USB) + } + } + + private fun List.requiredPermissions() = flatMap { it.requiredPermissions() } + + private fun DiscoveryRequirement.requiredPermissions() = when (this) { + DiscoveryRequirement.BLUETOOTH -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN + ) + } else { + emptyList() + } + } + + DiscoveryRequirement.LOCATION -> listOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + + private fun createInitialState(): SelectLedgerState { + val allRequirements = discoveryMethods.discoveryRequirements() + val permissionsGranted = permissionsAsker.checkPermissions(allRequirements.requiredPermissions()) + + val satisfiedDiscoverRequirements = setOfNotNull( + DiscoveryRequirement.BLUETOOTH.takeIf { bluetoothManager.isBluetoothEnabled() }, + DiscoveryRequirement.LOCATION.takeIf { locationManager.isLocationEnabled() } + ) + + val availability = DiscoveryRequirementAvailability(satisfiedDiscoverRequirements, permissionsGranted) + + return DiscoveringState.initial(discoveryMethods, availability) + } + + private fun DiscoveryRequirement.createRequest(): DiscoveryRequirementRequest { + return when (this) { + DiscoveryRequirement.BLUETOOTH -> DiscoveryRequirementRequest.awaitable { bluetoothManager.enableBluetoothAndAwait() } + DiscoveryRequirement.LOCATION -> DiscoveryRequirementRequest.fireAndForget { requestLocation() } + } + } + + private fun requestLocation() { + _showRequestLocationDialog.value = true + } + + private class DiscoveryRequirementRequest( + val requestAction: suspend () -> Unit, + val awaitable: Boolean + ) { + + companion object { + + fun awaitable(requestAction: suspend () -> Unit): DiscoveryRequirementRequest { + return DiscoveryRequirementRequest(requestAction, awaitable = true) + } + + fun fireAndForget(requestAction: () -> Unit): DiscoveryRequirementRequest { + return DiscoveryRequirementRequest(requestAction, awaitable = false) + } + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/model/SelectLedgerModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/model/SelectLedgerModel.kt new file mode 100644 index 0000000..4c13ba7 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/model/SelectLedgerModel.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.model + +data class SelectLedgerModel( + val name: String, + val id: String, + val isConnecting: Boolean, +) diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/StateMachine.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/StateMachine.kt new file mode 100644 index 0000000..8d0ee1a --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/StateMachine.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine + +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryRequirement + +sealed class SideEffect { + + data class RequestPermissions(val requirements: List, val shouldExitUponDenial: Boolean) : SideEffect() + + data class RequestSatisfyRequirement(val requirements: List) : SideEffect() + + data class PresentLedgerFailure(val reason: Throwable, val device: LedgerDevice) : SideEffect() + + data class VerifyConnection(val device: LedgerDevice) : SideEffect() + + data class StartDiscovery(val methods: Set) : SideEffect() + + data class StopDiscovery(val methods: Set) : SideEffect() +} + +sealed class SelectLedgerEvent { + + data class DiscoveryRequirementSatisfied(val requirement: DiscoveryRequirement) : SelectLedgerEvent() + + data class DiscoveryRequirementMissing(val requirement: DiscoveryRequirement) : SelectLedgerEvent() + + object PermissionsGranted : SelectLedgerEvent() { + override fun toString(): String { + return "PermissionsGranted" + } + } + + object AvailabilityRequestsAllowed : SelectLedgerEvent() { + override fun toString(): String { + return "AvailabilityRequestsAllowed" + } + } + + data class DiscoveredDevicesListChanged(val newDevices: List) : SelectLedgerEvent() + + data class DeviceChosen(val device: LedgerDevice) : SelectLedgerEvent() + + data class VerificationFailed(val reason: Throwable) : SelectLedgerEvent() + + object ConnectionVerified : SelectLedgerEvent() { + override fun toString(): String { + return "ConnectionVerified" + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/states/DevicesFoundState.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/states/DevicesFoundState.kt new file mode 100644 index 0000000..3a99f4f --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/states/DevicesFoundState.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.states + +import io.novafoundation.nova.common.utils.stateMachine.StateMachine +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryRequirementAvailability +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SelectLedgerEvent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SelectLedgerEvent.ConnectionVerified +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SelectLedgerEvent.DeviceChosen +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SelectLedgerEvent.DiscoveredDevicesListChanged +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SelectLedgerEvent.VerificationFailed +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SideEffect + +data class DevicesFoundState( + val devices: List, + val verifyingDevice: LedgerDevice?, + private val discoveryMethods: DiscoveryMethods, + private val discoveryRequirementAvailability: DiscoveryRequirementAvailability, + private val usedAllowedRequirementAvailabilityRequests: Boolean +) : SelectLedgerState() { + + context(StateMachine.Transition) + override suspend fun performTransition(event: SelectLedgerEvent) { + when (event) { + is SelectLedgerEvent.AvailabilityRequestsAllowed -> userAllowedAvailabilityRequests( + discoveryMethods = discoveryMethods, + discoveryRequirementAvailability = discoveryRequirementAvailability, + nextState = copy(usedAllowedRequirementAvailabilityRequests = true) + ) + + is VerificationFailed -> verifyingDevice?.let { device -> + emitState(copy(verifyingDevice = null)) + emitSideEffect(SideEffect.PresentLedgerFailure(event.reason, device)) + } + + is ConnectionVerified -> verifyingDevice?.let { + emitState( + DevicesFoundState( + devices = devices, + verifyingDevice = null, + discoveryMethods = discoveryMethods, + discoveryRequirementAvailability = discoveryRequirementAvailability, + usedAllowedRequirementAvailabilityRequests = usedAllowedRequirementAvailabilityRequests + ) + ) + } + + is DeviceChosen -> { + emitState(copy(verifyingDevice = event.device)) + emitSideEffect(SideEffect.VerifyConnection(event.device)) + } + + is DiscoveredDevicesListChanged -> emitState(copy(devices = event.newDevices)) + + else -> updateActiveDiscoveryMethodsByEvent( + allDiscoveryMethods = discoveryMethods, + previousRequirementsAvailability = discoveryRequirementAvailability, + event = event, + userAllowedRequirementAvailabilityRequests = usedAllowedRequirementAvailabilityRequests + ) { newSatisfiedRequirements -> + DevicesFoundState(devices, verifyingDevice, discoveryMethods, newSatisfiedRequirements, usedAllowedRequirementAvailabilityRequests) + } + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/states/DiscoveringState.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/states/DiscoveringState.kt new file mode 100644 index 0000000..072abaf --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/states/DiscoveringState.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.states + +import io.novafoundation.nova.common.utils.stateMachine.StateMachine +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryRequirementAvailability +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SelectLedgerEvent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SelectLedgerEvent.DiscoveredDevicesListChanged +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SideEffect + +data class DiscoveringState( + private val discoveryMethods: DiscoveryMethods, + private val discoveryRequirementAvailability: DiscoveryRequirementAvailability, + private val usedAllowedRequirementAvailabilityRequests: Boolean +) : SelectLedgerState() { + + companion object { + + fun initial(discoveryMethods: DiscoveryMethods, initialRequirementAvailability: DiscoveryRequirementAvailability): DiscoveringState { + return DiscoveringState(discoveryMethods, initialRequirementAvailability, usedAllowedRequirementAvailabilityRequests = false) + } + } + + context(StateMachine.Transition) + override suspend fun performTransition(event: SelectLedgerEvent) { + when (event) { + is SelectLedgerEvent.AvailabilityRequestsAllowed -> userAllowedAvailabilityRequests( + discoveryMethods = discoveryMethods, + discoveryRequirementAvailability = discoveryRequirementAvailability, + nextState = copy(usedAllowedRequirementAvailabilityRequests = true) + ) + + is DiscoveredDevicesListChanged -> if (event.newDevices.isNotEmpty()) { + val newState = DevicesFoundState( + devices = event.newDevices, + verifyingDevice = null, + discoveryMethods = discoveryMethods, + discoveryRequirementAvailability = discoveryRequirementAvailability, + usedAllowedRequirementAvailabilityRequests = usedAllowedRequirementAvailabilityRequests + ) + emitState(newState) + } + + else -> updateActiveDiscoveryMethodsByEvent( + allDiscoveryMethods = discoveryMethods, + previousRequirementsAvailability = discoveryRequirementAvailability, + event = event, + userAllowedRequirementAvailabilityRequests = usedAllowedRequirementAvailabilityRequests + ) { newDiscoveryRequirementAvailability -> + DiscoveringState(discoveryMethods, newDiscoveryRequirementAvailability, usedAllowedRequirementAvailabilityRequests) + } + } + } + + context(StateMachine.Transition) + override suspend fun bootstrap() { + // Start discovery for all methods available from the start + // I.e. those that do not have any requirements or already have all permissions / requirements satisfied + updateActiveDiscoveryMethods( + allDiscoveryMethods = discoveryMethods, + previousActiveMethods = emptySet(), + newRequirementsAvailability = discoveryRequirementAvailability + ) + + // Perform initial automatic requests to requirements and permissions if needed + // It's important to request permissions firstly since bluetooth request will crash application without permissions after android 12 + requestPermissions(discoveryMethods, discoveryRequirementAvailability, usedAllowedRequirementAvailabilityRequests) + requestMissingDiscoveryRequirements(discoveryMethods, discoveryRequirementAvailability, usedAllowedRequirementAvailabilityRequests) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/states/SelectLedgerState.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/states/SelectLedgerState.kt new file mode 100644 index 0000000..15ec7d9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/stateMachine/states/SelectLedgerState.kt @@ -0,0 +1,168 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.states + +import io.novafoundation.nova.common.utils.stateMachine.StateMachine +import io.novafoundation.nova.common.utils.stateMachine.StateMachine.Transition +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryRequirement +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryRequirementAvailability +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.discoveryRequirements +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.filterBySatisfiedRequirements +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.grantPermissions +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.missRequirement +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.satisfyRequirement +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SelectLedgerEvent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.stateMachine.SideEffect +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods.Method as DiscoveryMethod + +sealed class SelectLedgerState : StateMachine.State { + + context(Transition) + protected suspend fun updateActiveDiscoveryMethodsByEvent( + allDiscoveryMethods: DiscoveryMethods, + previousRequirementsAvailability: DiscoveryRequirementAvailability, + event: SelectLedgerEvent, + userAllowedRequirementAvailabilityRequests: Boolean, + newState: (newRequirementsAvailability: DiscoveryRequirementAvailability) -> SelectLedgerState + ) { + val newPreviousSatisfiedRequirements = updateDiscoveryRequirementAvailabilityByEvent(previousRequirementsAvailability, event) ?: return + + emitState(newState(newPreviousSatisfiedRequirements)) + + updateActiveDiscoveryMethods( + allDiscoveryMethods, + previousRequirementsAvailability, + newPreviousSatisfiedRequirements, + ) + + if (event is SelectLedgerEvent.DiscoveryRequirementMissing) { + requestSatisfyDiscoveryRequirement(allDiscoveryMethods, event.requirement, userAllowedRequirementAvailabilityRequests) + } + } + + context(Transition) + protected suspend fun userAllowedAvailabilityRequests( + discoveryMethods: DiscoveryMethods, + discoveryRequirementAvailability: DiscoveryRequirementAvailability, + nextState: SelectLedgerState, + ) { + emitState(nextState) + + requestPermissions(discoveryMethods, discoveryRequirementAvailability, usedAllowedRequirementAvailabilityRequests = true) + requestMissingDiscoveryRequirements(discoveryMethods, discoveryRequirementAvailability, usedAllowedRequirementAvailabilityRequests = true) + } + + context(Transition) + protected suspend fun updateActiveDiscoveryMethods( + allDiscoveryMethods: DiscoveryMethods, + previousRequirementsAvailability: DiscoveryRequirementAvailability, + newRequirementsAvailability: DiscoveryRequirementAvailability, + ) { + val previousActiveDiscoveryMethods = allDiscoveryMethods.filterBySatisfiedRequirements(previousRequirementsAvailability) + updateActiveDiscoveryMethods( + allDiscoveryMethods, + previousActiveDiscoveryMethods, + newRequirementsAvailability, + ) + } + + context(Transition) + protected suspend fun updateActiveDiscoveryMethods( + allDiscoveryMethods: DiscoveryMethods, + previousActiveMethods: Set, + newRequirementsAvailability: DiscoveryRequirementAvailability, + ) { + val newActiveMethods = allDiscoveryMethods.filterBySatisfiedRequirements(newRequirementsAvailability) + + val methodsToStart = newActiveMethods - previousActiveMethods + val methodsToStop = previousActiveMethods - newActiveMethods + + startDiscovery(methodsToStart) + stopDiscovery(methodsToStop) + } + + context(Transition) + protected suspend fun requestPermissions( + allDiscoveryMethods: DiscoveryMethods, + newRequirementsAvailability: DiscoveryRequirementAvailability, + usedAllowedRequirementAvailabilityRequests: Boolean + ) { + val allDiscoveryRequirements = allDiscoveryMethods.discoveryRequirements() + + val canRequestPermissions = canPerformAvailabilityRequests(allDiscoveryMethods, usedAllowedRequirementAvailabilityRequests) + + // We only need permissions when there is at least one requirement (assuming each requirement requires at least one permission) + // and permissions has not been granted yet + val permissionsNeeded = allDiscoveryRequirements.isNotEmpty() && !newRequirementsAvailability.permissionsGranted + + if (canRequestPermissions && permissionsNeeded) { + val shouldExitUponDenial = allDiscoveryMethods.methods.size == 1 + + emitSideEffect(SideEffect.RequestPermissions(allDiscoveryRequirements, shouldExitUponDenial)) + } + } + + context(Transition) + protected suspend fun requestMissingDiscoveryRequirements( + discoveryMethods: DiscoveryMethods, + discoveryRequirementAvailability: DiscoveryRequirementAvailability, + usedAllowedRequirementAvailabilityRequests: Boolean + ) { + val canRequestRequirement = canPerformAvailabilityRequests(discoveryMethods, usedAllowedRequirementAvailabilityRequests) + if (!canRequestRequirement) return + + val allRequirements = discoveryMethods.discoveryRequirements() + val missingRequirements = allRequirements - discoveryRequirementAvailability.satisfiedRequirements + + if (missingRequirements.isNotEmpty()) { + emitSideEffect(SideEffect.RequestSatisfyRequirement(missingRequirements)) + } + } + + context(Transition) + private suspend fun requestSatisfyDiscoveryRequirement( + allDiscoveryMethods: DiscoveryMethods, + requirement: DiscoveryRequirement, + usedAllowedRequirementAvailabilityRequests: Boolean + ) { + val canRequestRequirement = canPerformAvailabilityRequests(allDiscoveryMethods, usedAllowedRequirementAvailabilityRequests) + + if (canRequestRequirement) { + emitSideEffect(SideEffect.RequestSatisfyRequirement(listOf(requirement))) + } + } + + private fun canPerformAvailabilityRequests( + allDiscoveryMethods: DiscoveryMethods, + usedAllowedRequirementAvailabilityRequests: Boolean + ): Boolean { + // We can only perform availability requests if there is a single method or a user explicitly pressed a button to allow such requests + // Otherwise we don't bother them with the automatic requests as there might be methods that do not need any requirements at all + return allDiscoveryMethods.methods.size == 1 || usedAllowedRequirementAvailabilityRequests + } + + private fun updateDiscoveryRequirementAvailabilityByEvent( + availability: DiscoveryRequirementAvailability, + event: SelectLedgerEvent + ): DiscoveryRequirementAvailability? { + return when (event) { + is SelectLedgerEvent.DiscoveryRequirementMissing -> availability.missRequirement(event.requirement) + is SelectLedgerEvent.DiscoveryRequirementSatisfied -> availability.satisfyRequirement(event.requirement) + is SelectLedgerEvent.PermissionsGranted -> availability.grantPermissions() + else -> null + } + } + + context(Transition) + private suspend fun startDiscovery(methods: Set) { + if (methods.isNotEmpty()) { + emitSideEffect(SideEffect.StartDiscovery(methods)) + } + } + + context(Transition) + private suspend fun stopDiscovery(methods: Set) { + if (methods.isNotEmpty()) { + emitSideEffect(SideEffect.StopDiscovery(methods)) + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/view/ItemLedgerView.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/view/ItemLedgerView.kt new file mode 100644 index 0000000..790b9e9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/selectLedger/view/ItemLedgerView.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.feature_ledger_impl.R + +class ItemLedgerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatTextView(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + init { + setTextAppearance(R.style.TextAppearance_NovaFoundation_Regular_SubHeadline) + setTextColorRes(R.color.text_primary) + + setDrawableEnd(R.drawable.ic_chevron_right, widthInDp = 24, paddingInDp = 4, tint = R.color.icon_secondary) + + updatePadding( + top = 14.dp, + bottom = 14.dp, + start = 12.dp, + end = 12.dp + ) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/start/StartImportLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/start/StartImportLedgerFragment.kt new file mode 100644 index 0000000..74d9569 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/start/StartImportLedgerFragment.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.start + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.list.instruction.InstructionItem +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.formatting.spannable.highlightedText +import io.novafoundation.nova.common.utils.setupWithViewPager2 +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.databinding.FragmentImportLedgerStartBinding + +private const val BLUETOOTH_PAGE_INDEX = 0 +private const val USB_PAGE_INDEX = 1 + +abstract class StartImportLedgerFragment : + BaseFragment(), + StartImportLedgerPagerAdapter.Handler { + + protected val pageAdapter by lazy(LazyThreadSafetyMode.NONE) { StartImportLedgerPagerAdapter(createPages(), this) } + + override fun createBinding() = FragmentImportLedgerStartBinding.inflate(layoutInflater) + + override fun initViews() { + binder.startImportLedgerToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.startImportLedgerContinue.setOnClickListener { + when (binder.startImportLedgerConnectionModePages.currentItem) { + BLUETOOTH_PAGE_INDEX -> viewModel.continueWithBluetooth() + USB_PAGE_INDEX -> viewModel.continueWithUsb() + } + } + + binder.startImportLedgerConnectionModePages.adapter = pageAdapter + binder.startImportLedgerConnectionMode.setupWithViewPager2(binder.startImportLedgerConnectionModePages, pageAdapter::getPageTitle) + } + + override fun subscribe(viewModel: VM) { + observeBrowserEvents(viewModel) + } + + override fun guideLinkClicked() { + viewModel.guideClicked() + } + + private fun createPages(): List { + return buildList { + add(BLUETOOTH_PAGE_INDEX, createBluetoothPage()) + add(USB_PAGE_INDEX, createUSBPage()) + } + } + + private fun createBluetoothPage(): ConnectionModePageModel { + return ConnectionModePageModel( + modeName = getString(R.string.start_import_ledger_connection_mode_bluetooth), + guideItems = listOf( + InstructionItem.Step(1, networkAppIsInstalledStep()), + InstructionItem.Step(2, openingNetworkAppStep()), + InstructionItem.Step(3, enableBluetoothStep()), + InstructionItem.Step(4, selectAccountStep()) + ) + ) + } + + private fun createUSBPage(): ConnectionModePageModel { + return ConnectionModePageModel( + modeName = getString(R.string.start_import_ledger_connection_mode_usb), + guideItems = listOf( + InstructionItem.Step(1, networkAppIsInstalledStep()), + InstructionItem.Step(2, openingNetworkAppStep()), + InstructionItem.Step(3, enableOTGSetting()), + InstructionItem.Step(4, selectAccountStep()) + ) + ) + } + + abstract fun networkAppIsInstalledStep(): CharSequence + + abstract fun openingNetworkAppStep(): CharSequence + + private fun enableBluetoothStep() = requireContext().highlightedText( + R.string.account_ledger_import_start_step_3, + R.string.account_ledger_import_start_step_3_highlighted + ) + + private fun enableOTGSetting() = requireContext().highlightedText( + R.string.account_ledger_import_start_step_otg, + R.string.account_ledger_import_start_step_otg_highlighted + ) + + private fun selectAccountStep() = requireContext().highlightedText( + R.string.account_ledger_import_start_step_4, + R.string.account_ledger_import_start_step_4_highlighted + ) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/start/StartImportLedgerPagerAdapter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/start/StartImportLedgerPagerAdapter.kt new file mode 100644 index 0000000..cd9692f --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/start/StartImportLedgerPagerAdapter.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.start + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.list.instruction.InstructionAdapter +import io.novafoundation.nova.common.list.instruction.InstructionItem +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.setModelOrHide +import io.novafoundation.nova.feature_ledger_impl.databinding.ItemImportLedgerStartPageBinding + +class ConnectionModePageModel( + val modeName: String, + val guideItems: List +) + +class StartImportLedgerPagerAdapter( + private val pages: List, + private val handler: Handler +) : RecyclerView.Adapter() { + + interface Handler { + fun guideLinkClicked() + } + + private var alertModel: AlertModel? = null + + override fun getItemCount(): Int { + return pages.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StartImportLedgerPageViewHolder { + val binder = ItemImportLedgerStartPageBinding.inflate(parent.inflater(), parent, false) + return StartImportLedgerPageViewHolder(binder, handler) + } + + override fun onBindViewHolder(holder: StartImportLedgerPageViewHolder, position: Int) { + holder.bind(pages[position], alertModel) + } + + fun showWarning(alertModel: AlertModel?) { + this.alertModel = alertModel + repeat(pages.size) { notifyItemChanged(it) } + } + + fun getPageTitle(position: Int): CharSequence { + return pages[position].modeName + } +} + +class StartImportLedgerPageViewHolder( + private val binder: ItemImportLedgerStartPageBinding, + private val handler: StartImportLedgerPagerAdapter.Handler +) : ViewHolder(binder.root) { + + private val adapter = InstructionAdapter() + + init { + binder.startImportLedgerInstructions.adapter = adapter + binder.startImportLedgerGuideLink.setOnClickListener { handler.guideLinkClicked() } + } + + fun bind(page: ConnectionModePageModel, alertModel: AlertModel?) { + adapter.submitList(page.guideItems) + binder.startImportLedgerWarning.setModelOrHide(alertModel) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/start/StartImportLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/start/StartImportLedgerViewModel.kt new file mode 100644 index 0000000..e0a9d3c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/common/start/StartImportLedgerViewModel.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.common.start + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter + +abstract class StartImportLedgerViewModel( + private val router: LedgerRouter, + private val appLinksProvider: AppLinksProvider +) : BaseViewModel(), Browserable { + + override val openBrowserEvent = MutableLiveData>() + + fun backClicked() { + router.back() + } + + abstract fun continueWithBluetooth() + + abstract fun continueWithUsb() + + fun guideClicked() { + openBrowserEvent.value = appLinksProvider.ledgerConnectionGuide.event() + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/common/payload/LedgerGenericEvmAccountParcel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/common/payload/LedgerGenericEvmAccountParcel.kt new file mode 100644 index 0000000..55bceea --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/common/payload/LedgerGenericEvmAccountParcel.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.common.payload + +import android.os.Parcelable +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerEvmAccount +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize + +@Parcelize +class LedgerGenericEvmAccountParcel( + val publicKey: ByteArray, + val accountId: AccountId, +) : Parcelable + +fun LedgerEvmAccount.toParcel(): LedgerGenericEvmAccountParcel { + return LedgerGenericEvmAccountParcel(publicKey = publicKey, accountId = accountId) +} + +fun LedgerGenericEvmAccountParcel.toDomain(): LedgerEvmAccount { + return LedgerEvmAccount(accountId = accountId, publicKey = publicKey) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/common/payload/LedgerGenericSubstrateAccountParcel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/common/payload/LedgerGenericSubstrateAccountParcel.kt new file mode 100644 index 0000000..6ffe22c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/common/payload/LedgerGenericSubstrateAccountParcel.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.common.payload + +import android.os.Parcelable +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novasama.substrate_sdk_android.encrypt.EncryptionType +import kotlinx.parcelize.Parcelize + +@Parcelize +class LedgerGenericSubstrateAccountParcel( + val address: String, + val publicKey: ByteArray, + val encryptionType: EncryptionType, + val derivationPath: String, +) : Parcelable + +fun LedgerSubstrateAccount.toGenericParcel(): LedgerGenericSubstrateAccountParcel { + return LedgerGenericSubstrateAccountParcel(address, publicKey, encryptionType, derivationPath) +} + +fun LedgerGenericSubstrateAccountParcel.toDomain(): LedgerSubstrateAccount { + return LedgerSubstrateAccount(address, publicKey, encryptionType, derivationPath) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/FinishImportGenericLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/FinishImportGenericLedgerFragment.kt new file mode 100644 index 0000000..9f7f1ab --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/FinishImportGenericLedgerFragment.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.presenatation.account.createName.CreateWalletNameFragment +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent + +class FinishImportGenericLedgerFragment : CreateWalletNameFragment() { + + companion object { + + private const val PAYLOAD_KEY = "FinishImportLedgerFragment.Payload" + + fun getBundle(payload: FinishImportGenericLedgerPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .finishGenericImportLedgerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/FinishImportGenericLedgerPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/FinishImportGenericLedgerPayload.kt new file mode 100644 index 0000000..7ee676e --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/FinishImportGenericLedgerPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish + +import android.os.Parcelable +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.common.payload.LedgerGenericEvmAccountParcel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.common.payload.LedgerGenericSubstrateAccountParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +class FinishImportGenericLedgerPayload( + val substrateAccount: LedgerGenericSubstrateAccountParcel, + val evmAccount: LedgerGenericEvmAccountParcel?, +) : Parcelable diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/FinishImportGenericLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/FinishImportGenericLedgerViewModel.kt new file mode 100644 index 0000000..aa5440f --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/FinishImportGenericLedgerViewModel.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.createName.CreateWalletNameViewModel +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.generic.finish.FinishImportGenericLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.common.payload.toDomain +import kotlinx.coroutines.launch + +class FinishImportGenericLedgerViewModel( + private val router: LedgerRouter, + private val resourceManager: ResourceManager, + private val payload: FinishImportGenericLedgerPayload, + private val accountInteractor: AccountInteractor, + private val interactor: FinishImportGenericLedgerInteractor +) : CreateWalletNameViewModel(router, resourceManager) { + + override fun proceed(name: String) { + launch { + interactor.createWallet(name, payload.substrateAccount.toDomain(), payload.evmAccount?.toDomain()) + .onSuccess { continueBasedOnCodeStatus() } + .onFailure(::showError) + } + } + + private suspend fun continueBasedOnCodeStatus() { + if (accountInteractor.isCodeSet()) { + router.openMain() + } else { + router.openCreatePincode() + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/di/FinishImportGenericLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/di/FinishImportGenericLedgerComponent.kt new file mode 100644 index 0000000..6d9b10a --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/di/FinishImportGenericLedgerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.FinishImportGenericLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.FinishImportGenericLedgerPayload + +@Subcomponent( + modules = [ + FinishImportGenericLedgerModule::class + ] +) +@ScreenScope +interface FinishImportGenericLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: FinishImportGenericLedgerPayload, + ): FinishImportGenericLedgerComponent + } + + fun inject(fragment: FinishImportGenericLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/di/FinishImportGenericLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/di/FinishImportGenericLedgerModule.kt new file mode 100644 index 0000000..d9ddd9a --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/finish/di/FinishImportGenericLedgerModule.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.GenericLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.generic.finish.FinishImportGenericLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.generic.finish.RealFinishImportGenericLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.FinishImportGenericLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.FinishImportGenericLedgerViewModel + +@Module(includes = [ViewModelModule::class]) +class FinishImportGenericLedgerModule { + + @Provides + @ScreenScope + fun provideInteractor( + genericLedgerAddAccountRepository: GenericLedgerAddAccountRepository, + accountRepository: AccountRepository, + ): FinishImportGenericLedgerInteractor = RealFinishImportGenericLedgerInteractor( + genericLedgerAddAccountRepository = genericLedgerAddAccountRepository, + accountRepository = accountRepository, + ) + + @Provides + @IntoMap + @ViewModelKey(FinishImportGenericLedgerViewModel::class) + fun provideViewModel( + router: LedgerRouter, + resourceManager: ResourceManager, + payload: FinishImportGenericLedgerPayload, + accountInteractor: AccountInteractor, + interactor: FinishImportGenericLedgerInteractor + ): ViewModel { + return FinishImportGenericLedgerViewModel( + router = router, + resourceManager = resourceManager, + payload = payload, + accountInteractor = accountInteractor, + interactor = interactor + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): FinishImportGenericLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(FinishImportGenericLedgerViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/PreviewImportGenericLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/PreviewImportGenericLedgerFragment.kt new file mode 100644 index 0000000..0625727 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/PreviewImportGenericLedgerFragment.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.ChainAccountsAdapter +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview.BaseChainAccountsPreviewFragment +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessagePresentable +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.setupLedgerMessages +import javax.inject.Inject + +class PreviewImportGenericLedgerFragment : BaseChainAccountsPreviewFragment(), ChainAccountsAdapter.Handler { + + @Inject + lateinit var ledgerMessagePresentable: LedgerMessagePresentable + + companion object { + + private const val PAYLOAD_KEY = "PreviewImportGenericLedgerFragment.Payload" + + fun getBundle(payload: PreviewImportGenericLedgerPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + LedgerFeatureApi::class.java + ) + .previewImportGenericLedgerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: PreviewImportGenericLedgerViewModel) { + super.subscribe(viewModel) + + setupLedgerMessages(ledgerMessagePresentable) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/PreviewImportGenericLedgerPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/PreviewImportGenericLedgerPayload.kt new file mode 100644 index 0000000..d0033d9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/PreviewImportGenericLedgerPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview + +import android.os.Parcelable +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.common.payload.LedgerGenericEvmAccountParcel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.common.payload.LedgerGenericSubstrateAccountParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +class PreviewImportGenericLedgerPayload( + val accountIndex: Int, + val substrateAccount: LedgerGenericSubstrateAccountParcel, + val evmAccount: LedgerGenericEvmAccountParcel?, + val deviceId: String +) : Parcelable diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/PreviewImportGenericLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/PreviewImportGenericLedgerViewModel.kt new file mode 100644 index 0000000..fca82e2 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/PreviewImportGenericLedgerViewModel.kt @@ -0,0 +1,132 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.model.ChainAccountGroupUi +import io.novafoundation.nova.feature_account_api.presenatation.account.chain.preview.BaseChainAccountsPreviewViewModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.generic.preview.PreviewImportGenericLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommand +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.LedgerMessageCommands +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.createLedgerReviewAddresses +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.errors.handleLedgerError +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.finish.FinishImportGenericLedgerPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.extensions.asEthereumAccountId +import io.novasama.substrate_sdk_android.extensions.toAddress +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PreviewImportGenericLedgerViewModel( + private val interactor: PreviewImportGenericLedgerInteractor, + private val router: LedgerRouter, + private val iconGenerator: AddressIconGenerator, + private val payload: PreviewImportGenericLedgerPayload, + private val externalActions: ExternalActions.Presentation, + private val chainRegistry: ChainRegistry, + private val resourceManager: ResourceManager, + private val messageCommandFormatter: MessageCommandFormatter, + private val addressSchemeFormatter: AddressSchemeFormatter, +) : BaseChainAccountsPreviewViewModel( + iconGenerator = iconGenerator, + externalActions = externalActions, + chainRegistry = chainRegistry, + router = router +), + LedgerMessageCommands { + + override val ledgerMessageCommands: MutableLiveData> = MutableLiveData() + + override val chainAccountProjections = flowOf { + interactor.availableChainAccounts( + substrateAccountId = payload.substrateAccount.address.toAccountId(), + evmAccountId = payload.evmAccount?.accountId + ).toListWithHeaders( + keyMapper = { scheme, _ -> formatGroupHeader(scheme) }, + valueMapper = { account -> mapChainAccountPreviewToUi(account) } + ) + } + .shareInBackground() + + override val buttonState: Flow = flowOf { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + + val device = flowOf { + interactor.getDevice(payload.deviceId) + } + + private var verifyAddressJob: Job? = null + + override fun continueClicked() { + verifyAddressJob?.cancel() + verifyAddressJob = launch { + verifyAccount() + } + } + + private fun formatGroupHeader(addressScheme: AddressScheme): ChainAccountGroupUi { + return ChainAccountGroupUi( + id = addressScheme.name, + title = addressSchemeFormatter.accountsLabel(addressScheme), + action = null + ) + } + + private suspend fun verifyAccount() { + val device = device.first() + + ledgerMessageCommands.value = messageCommandFormatter.reviewAddressCommand( + addresses = createLedgerReviewAddresses( + allowedAddressSchemes = AddressScheme.entries, + AddressScheme.SUBSTRATE to payload.substrateAccount.address, + AddressScheme.EVM to payload.evmAccount?.accountId?.asEthereumAccountId()?.toAddress()?.value + ), + device = device, + onCancel = ::verifyAddressCancelled, + ).event() + val result = withContext(Dispatchers.Default) { + interactor.verifyAddressOnLedger(payload.accountIndex, payload.deviceId) + } + + result.onFailure { + handleLedgerError( + reason = it, + device = device, + commandFormatter = messageCommandFormatter, + onRetry = ::continueClicked + ) + }.onSuccess { + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + + onAccountVerified() + } + } + + private fun onAccountVerified() { + val nextPayload = FinishImportGenericLedgerPayload(payload.substrateAccount, payload.evmAccount) + router.openFinishImportLedgerGeneric(nextPayload) + } + + private fun verifyAddressCancelled() { + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + verifyAddressJob?.cancel() + verifyAddressJob = null + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/di/PreviewImportGenericLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/di/PreviewImportGenericLedgerComponent.kt new file mode 100644 index 0000000..fa031c3 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/di/PreviewImportGenericLedgerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.PreviewImportGenericLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.PreviewImportGenericLedgerPayload + +@Subcomponent( + modules = [ + PreviewImportGenericLedgerModule::class + ] +) +@ScreenScope +interface PreviewImportGenericLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: PreviewImportGenericLedgerPayload + ): PreviewImportGenericLedgerComponent + } + + fun inject(fragment: PreviewImportGenericLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/di/PreviewImportGenericLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/di/PreviewImportGenericLedgerModule.kt new file mode 100644 index 0000000..2eba578 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/preview/di/PreviewImportGenericLedgerModule.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressSchemeFormatter +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_ledger_impl.di.annotations.GenericLedger +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.generic.preview.PreviewImportGenericLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.generic.preview.RealPreviewImportGenericLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.PreviewImportGenericLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.PreviewImportGenericLedgerViewModel +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.newApp.GenericSubstrateLedgerApplication +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class PreviewImportGenericLedgerModule { + + @Provides + @ScreenScope + fun provideInteractor( + ledgerMigrationTracker: LedgerMigrationTracker, + genericSubstrateLedgerApplication: GenericSubstrateLedgerApplication, + ledgerDiscoveryService: LedgerDeviceDiscoveryService + ): PreviewImportGenericLedgerInteractor { + return RealPreviewImportGenericLedgerInteractor(ledgerMigrationTracker, genericSubstrateLedgerApplication, ledgerDiscoveryService) + } + + @Provides + @IntoMap + @ViewModelKey(PreviewImportGenericLedgerViewModel::class) + fun provideViewModel( + interactor: PreviewImportGenericLedgerInteractor, + router: LedgerRouter, + iconGenerator: AddressIconGenerator, + payload: PreviewImportGenericLedgerPayload, + externalActions: ExternalActions.Presentation, + chainRegistry: ChainRegistry, + resourceManager: ResourceManager, + @GenericLedger messageCommandFormatter: MessageCommandFormatter, + addressSchemeFormatter: AddressSchemeFormatter + ): ViewModel { + return PreviewImportGenericLedgerViewModel( + interactor = interactor, + router = router, + iconGenerator = iconGenerator, + payload = payload, + externalActions = externalActions, + chainRegistry = chainRegistry, + resourceManager = resourceManager, + messageCommandFormatter = messageCommandFormatter, + addressSchemeFormatter = addressSchemeFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PreviewImportGenericLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PreviewImportGenericLedgerViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/SelectAddressImportGenericLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/SelectAddressImportGenericLedgerFragment.kt new file mode 100644 index 0000000..518a5bb --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/SelectAddressImportGenericLedgerFragment.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectAddress + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectAddressLedgerFragment + +class SelectAddressImportGenericLedgerFragment : SelectAddressLedgerFragment() { + + override fun initViews() { + super.initViews() + + binder.ledgerSelectAddressChain.makeGone() + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .selectAddressImportLedgerGenericComponentFactory() + .create(this, payload()) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/SelectAddressImportGenericLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/SelectAddressImportGenericLedgerViewModel.kt new file mode 100644 index 0000000..2521fac --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/SelectAddressImportGenericLedgerViewModel.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectAddress + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.LedgerAccount +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.generic.GenericLedgerEvmAlertFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectAddressLedgerViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model.AddressVerificationMode +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.common.payload.toGenericParcel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.common.payload.toParcel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.preview.PreviewImportGenericLedgerPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class SelectAddressImportGenericLedgerViewModel( + private val router: LedgerRouter, + private val payload: SelectLedgerAddressPayload, + interactor: SelectAddressLedgerInteractor, + addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val evmUpdateFormatter: GenericLedgerEvmAlertFormatter, + chainRegistry: ChainRegistry, + messageCommandFormatter: MessageCommandFormatter, + addressActionsMixinFactory: AddressActionsMixin.Factory +) : SelectAddressLedgerViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + payload = payload, + chainRegistry = chainRegistry, + messageCommandFormatter = messageCommandFormatter, + addressActionsMixinFactory = addressActionsMixinFactory +) { + + override val ledgerVariant: LedgerVariant = LedgerVariant.GENERIC + + override val addressVerificationMode = AddressVerificationMode.Disabled + + init { + loadedAccounts.onEach { accounts -> + val needsUpdateToSupportEvm = accounts.any { it.evm == null } + val model = createAlertModel(needsUpdateToSupportEvm) + _alertFlow.emit(model) + } + .launchIn(this) + } + + override fun onAccountVerified(account: LedgerAccount) { + launch { + val payload = PreviewImportGenericLedgerPayload( + accountIndex = account.index, + substrateAccount = account.substrate.toGenericParcel(), + evmAccount = account.evm?.toParcel(), + deviceId = payload.deviceId + ) + + router.openPreviewLedgerAccountsGeneric(payload) + } + } + + private fun createAlertModel(needsUpdateToSupportEvm: Boolean): AlertModel? { + return if (needsUpdateToSupportEvm) { + evmUpdateFormatter.createUpdateAppToGetEvmAddressAlert() + } else { + null + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/di/SelectAddressImportGenericLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/di/SelectAddressImportGenericLedgerComponent.kt new file mode 100644 index 0000000..72e568d --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/di/SelectAddressImportGenericLedgerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectAddress.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectAddress.SelectAddressImportGenericLedgerFragment + +@Subcomponent( + modules = [ + SelectAddressImportGenericLedgerModule::class + ] +) +@ScreenScope +interface SelectAddressImportGenericLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SelectLedgerAddressPayload, + ): SelectAddressImportGenericLedgerComponent + } + + fun inject(fragment: SelectAddressImportGenericLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/di/SelectAddressImportGenericLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/di/SelectAddressImportGenericLedgerModule.kt new file mode 100644 index 0000000..a5b395a --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectAddress/di/SelectAddressImportGenericLedgerModule.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectAddress.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_ledger_impl.di.annotations.GenericLedger +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.generic.GenericLedgerEvmAlertFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectAddress.SelectAddressImportGenericLedgerViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SelectAddressImportGenericLedgerModule { + + @Provides + @IntoMap + @ViewModelKey(SelectAddressImportGenericLedgerViewModel::class) + fun provideViewModel( + router: LedgerRouter, + interactor: SelectAddressLedgerInteractor, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + payload: SelectLedgerAddressPayload, + chainRegistry: ChainRegistry, + @GenericLedger messageCommandFormatter: MessageCommandFormatter, + evmAlertFormatter: GenericLedgerEvmAlertFormatter, + addressActionsMixinFactory: AddressActionsMixin.Factory + ): ViewModel { + return SelectAddressImportGenericLedgerViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + payload = payload, + chainRegistry = chainRegistry, + messageCommandFormatter = messageCommandFormatter, + evmUpdateFormatter = evmAlertFormatter, + addressActionsMixinFactory = addressActionsMixinFactory + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): SelectAddressImportGenericLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectAddressImportGenericLedgerViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/SelectLedgerGenericImportFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/SelectLedgerGenericImportFragment.kt new file mode 100644 index 0000000..87ee432 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/SelectLedgerGenericImportFragment.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger + +import android.os.Bundle +import androidx.core.os.bundleOf +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerFragment + +class SelectLedgerGenericImportFragment : SelectLedgerFragment() { + + companion object { + + private const val PAYLOAD_KEY = "SelectLedgerGenericImportFragment.PAYLOAD_KEY" + + fun getBundle(payload: SelectLedgerGenericPayload): Bundle = bundleOf(PAYLOAD_KEY to payload) + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .selectLedgerGenericImportComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/SelectLedgerGenericImportViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/SelectLedgerGenericImportViewModel.kt new file mode 100644 index 0000000..6762dc7 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/SelectLedgerGenericImportViewModel.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerViewModel +import io.novafoundation.nova.runtime.ext.ChainGeneses + +class SelectLedgerGenericImportViewModel( + private val router: LedgerRouter, + private val messageCommandFormatter: MessageCommandFormatter, + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + resourceManager: ResourceManager, + messageFormatter: LedgerMessageFormatter, + payload: SelectLedgerPayload, + deviceMapperFactory: LedgerDeviceFormatter, +) : SelectLedgerViewModel( + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + messageFormatter = messageFormatter, + ledgerDeviceFormatter = deviceMapperFactory, + messageCommandFormatter = messageCommandFormatter, + payload = payload +) { + + override suspend fun verifyConnection(device: LedgerDevice) { + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + + val payload = SelectLedgerAddressPayload( + deviceId = device.id, + substrateChainId = getPreviewBalanceChainId() + ) + + router.openSelectAddressGenericLedger(payload) + } + + private fun getPreviewBalanceChainId() = ChainGeneses.POLKADOT +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/SelectLedgerGenericPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/SelectLedgerGenericPayload.kt new file mode 100644 index 0000000..a27cda9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/SelectLedgerGenericPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger + +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import kotlinx.android.parcel.Parcelize + +@Parcelize +class SelectLedgerGenericPayload(override val connectionMode: SelectLedgerPayload.ConnectionMode) : SelectLedgerPayload diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/di/SelectLedgerGenericImportComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/di/SelectLedgerGenericImportComponent.kt new file mode 100644 index 0000000..678f4b0 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/di/SelectLedgerGenericImportComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.SelectLedgerGenericImportFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.SelectLedgerGenericPayload + +@Subcomponent( + modules = [ + SelectLedgerGenericImportModule::class + ] +) +@ScreenScope +interface SelectLedgerGenericImportComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SelectLedgerGenericPayload, + ): SelectLedgerGenericImportComponent + } + + fun inject(fragment: SelectLedgerGenericImportFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/di/SelectLedgerGenericImportModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/di/SelectLedgerGenericImportModule.kt new file mode 100644 index 0000000..9d9fe2b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/selectLedger/di/SelectLedgerGenericImportModule.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.modules.shared.PermissionAskerForFragmentModule +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.di.annotations.GenericLedger +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.SelectLedgerGenericImportViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.SelectLedgerGenericPayload + +@Module(includes = [ViewModelModule::class, PermissionAskerForFragmentModule::class]) +class SelectLedgerGenericImportModule { + + @Provides + @IntoMap + @ViewModelKey(SelectLedgerGenericImportViewModel::class) + fun provideViewModel( + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + router: LedgerRouter, + resourceManager: ResourceManager, + @GenericLedger messageFormatter: LedgerMessageFormatter, + payload: SelectLedgerGenericPayload, + deviceMapperFactory: LedgerDeviceFormatter, + @GenericLedger messageCommandFormatter: MessageCommandFormatter + ): ViewModel { + return SelectLedgerGenericImportViewModel( + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + messageFormatter = messageFormatter, + deviceMapperFactory = deviceMapperFactory, + messageCommandFormatter = messageCommandFormatter, + payload = payload + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): SelectLedgerGenericImportViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectLedgerGenericImportViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/StartImportGenericLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/StartImportGenericLedgerFragment.kt new file mode 100644 index 0000000..d76632a --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/StartImportGenericLedgerFragment.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.start + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.formatting.spannable.highlightedText +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.start.StartImportLedgerFragment + +class StartImportGenericLedgerFragment : StartImportLedgerFragment() { + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .startImportGenericLedgerComponentFactory() + .create(this) + .inject(this) + } + + override fun networkAppIsInstalledStep() = requireContext().highlightedText( + R.string.account_ledger_import_start_step_1, + R.string.account_ledger_generic_import_start_step_1_highlighted + ) + + override fun openingNetworkAppStep() = requireContext().highlightedText( + R.string.account_ledger_import_start_step_2, + R.string.account_ledger_generic_import_start_step_2_highlighted + ) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/StartImportGenericLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/StartImportGenericLedgerViewModel.kt new file mode 100644 index 0000000..309cc4c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/StartImportGenericLedgerViewModel.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.start + +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.start.StartImportLedgerViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.selectLedger.SelectLedgerGenericPayload + +class StartImportGenericLedgerViewModel( + private val router: LedgerRouter, + private val appLinksProvider: AppLinksProvider, +) : StartImportLedgerViewModel(router, appLinksProvider), Browserable { + + override fun continueWithBluetooth() { + router.openSelectLedgerGeneric(SelectLedgerGenericPayload(SelectLedgerPayload.ConnectionMode.BLUETOOTH)) + } + + override fun continueWithUsb() { + router.openSelectLedgerGeneric(SelectLedgerGenericPayload(SelectLedgerPayload.ConnectionMode.USB)) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/di/StartImportGenericLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/di/StartImportGenericLedgerComponent.kt new file mode 100644 index 0000000..2980ccd --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/di/StartImportGenericLedgerComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.start.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.start.StartImportGenericLedgerFragment + +@Subcomponent( + modules = [ + StartImportGenericLedgerModule::class + ] +) +@ScreenScope +interface StartImportGenericLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): StartImportGenericLedgerComponent + } + + fun inject(fragment: StartImportGenericLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/di/StartImportGenericLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/di/StartImportGenericLedgerModule.kt new file mode 100644 index 0000000..e12393a --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/generic/start/di/StartImportGenericLedgerModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.start.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.generic.start.StartImportGenericLedgerViewModel + +@Module(includes = [ViewModelModule::class]) +class StartImportGenericLedgerModule { + + @Provides + @IntoMap + @ViewModelKey(StartImportGenericLedgerViewModel::class) + fun provideViewModel( + router: LedgerRouter, + appLinksProvider: AppLinksProvider, + ): ViewModel { + return StartImportGenericLedgerViewModel(router, appLinksProvider) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): StartImportGenericLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StartImportGenericLedgerViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/SelectLedgerAddressInterScreenCommunicator.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/SelectLedgerAddressInterScreenCommunicator.kt new file mode 100644 index 0000000..ea276e1 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/SelectLedgerAddressInterScreenCommunicator.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.SelectLedgerLegacyPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.EncryptionType +import kotlinx.parcelize.Parcelize + +interface SelectLedgerAddressInterScreenRequester : InterScreenRequester + +interface SelectLedgerAddressInterScreenResponder : InterScreenResponder + +interface SelectLedgerAddressInterScreenCommunicator : SelectLedgerAddressInterScreenRequester, SelectLedgerAddressInterScreenResponder + +@Parcelize +class LedgerChainAccount( + val publicKey: ByteArray, + val encryptionType: EncryptionType, + val address: String, + val chainId: ChainId, + val derivationPath: String, +) : Parcelable diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerAdapter.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerAdapter.kt new file mode 100644 index 0000000..88d837d --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerAdapter.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.getAccentColor +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setImageTint +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_account_api.view.ItemChainAccount +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.model.FillableChainAccountModel + +class FillWalletImportLedgerAdapter( + private val handler: Handler, + private val imageLoader: ImageLoader +) : BaseListAdapter(DiffCallback()) { + + interface Handler { + + fun onItemClicked(item: FillableChainAccountModel) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FillWalletViewHolder { + val view = ItemChainAccount(parent.context).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + return FillWalletViewHolder(view, imageLoader, handler) + } + + override fun onBindViewHolder(holder: FillWalletViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: FillWalletViewHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + + resolvePayload(holder, position, payloads) { + when (it) { + addressExtractor -> holder.bindAddress(item) + } + } + } +} + +private val addressExtractor = { item: FillableChainAccountModel -> item.filledAddressModel?.address } + +private val AssetPayloadGenerator = PayloadGenerator(addressExtractor) + +private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: FillableChainAccountModel, newItem: FillableChainAccountModel): Boolean { + return oldItem.chainUi.id == newItem.chainUi.id + } + + override fun areContentsTheSame(oldItem: FillableChainAccountModel, newItem: FillableChainAccountModel): Boolean { + return oldItem.filledAddressModel?.address == newItem.filledAddressModel?.address + } + + override fun getChangePayload(oldItem: FillableChainAccountModel, newItem: FillableChainAccountModel): Any? { + return AssetPayloadGenerator.diff(oldItem, newItem) + } +} + +class FillWalletViewHolder( + override val containerView: ItemChainAccount, + private val imageLoader: ImageLoader, + private val eventHandler: FillWalletImportLedgerAdapter.Handler +) : BaseViewHolder(containerView) { + + fun bind( + item: FillableChainAccountModel, + ) = with(containerView) { + chainIcon.loadChainIcon(item.chainUi.icon, imageLoader) + chainName.text = item.chainUi.name + + bindAddress(item) + } + + fun bindAddress( + item: FillableChainAccountModel, + ) = with(containerView) { + if (item.filledAddressModel != null) { + accountIcon.makeVisible() + accountAddress.makeVisible() + action.setImageResource(R.drawable.ic_checkmark_circle_16) + action.setImageTintRes(R.color.icon_positive) + + accountIcon.setImageDrawable(item.filledAddressModel.image) + accountAddress.text = item.filledAddressModel.nameOrAddress + + background = null + setOnClickListener(null) + } else { + accountIcon.makeGone() + accountAddress.makeGone() + + action.setImageResource(R.drawable.ic_add_circle) + action.setImageTint(context.getAccentColor()) + + setBackgroundResource(R.drawable.bg_primary_list_item) + setOnClickListener { eventHandler.onItemClicked(item) } + } + } + + override fun unbind() { + containerView.chainIcon.clear() + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerFragment.kt new file mode 100644 index 0000000..47bc227 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerFragment.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet + +import android.os.Bundle +import androidx.core.os.bundleOf +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.databinding.FragmentImportLedgerFillWalletBinding +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.model.FillableChainAccountModel + +import javax.inject.Inject + +class FillWalletImportLedgerFragment : + BaseFragment(), + FillWalletImportLedgerAdapter.Handler { + + override fun createBinding() = FragmentImportLedgerFillWalletBinding.inflate(layoutInflater) + + companion object { + + private const val PAYLOAD_KEY = "SelectLedgerGenericImportFragment.PAYLOAD_KEY" + + fun getBundle(payload: FillWalletImportLedgerLegacyPayload): Bundle = bundleOf(PAYLOAD_KEY to payload) + } + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + FillWalletImportLedgerAdapter(this, imageLoader) + } + + override fun initViews() { + binder.fillWalletImportLedgerToolbar.setHomeButtonListener { + viewModel.backClicked() + } + onBackPressed { viewModel.backClicked() } + + binder.fillWalletImportLedgerAccounts.setHasFixedSize(true) + binder.fillWalletImportLedgerAccounts.adapter = adapter + + binder.fillWalletImportLedgerContinue.setOnClickListener { viewModel.continueClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .fillWalletImportLedgerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun onItemClicked(item: FillableChainAccountModel) { + viewModel.itemClicked(item) + } + + override fun subscribe(viewModel: FillWalletImportLedgerViewModel) { + viewModel.continueState.observe(binder.fillWalletImportLedgerContinue::setState) + viewModel.fillableChainAccountModels.observe(adapter::submitList) + + viewModel.confirmExit.awaitableActionLiveData.observeEvent { + warningDialog( + context = requireContext(), + onPositiveClick = { it.onSuccess(true) }, + onNegativeClick = { it.onSuccess(false) }, + negativeTextRes = R.string.common_no, + positiveTextRes = R.string.common_yes, + ) { + setTitle(R.string.common_cancel_operation_warning) + } + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerLegacyPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerLegacyPayload.kt new file mode 100644 index 0000000..08fbd18 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerLegacyPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet + +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import kotlinx.android.parcel.Parcelize + +@Parcelize +class FillWalletImportLedgerLegacyPayload(override val connectionMode: SelectLedgerPayload.ConnectionMode) : SelectLedgerPayload diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerViewModel.kt new file mode 100644 index 0000000..b369e10 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/FillWalletImportLedgerViewModel.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.awaitAction +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingOrDenyingAction +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inserted +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.legacy.fillWallet.FillWalletImportLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.SelectLedgerLegacyPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.LedgerChainAccount +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.SelectLedgerAddressInterScreenRequester +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.model.FillableChainAccountModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.FinishImportLedgerPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class FillWalletImportLedgerViewModel( + private val router: LedgerRouter, + private val interactor: FillWalletImportLedgerInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val actionAwaitableMixin: ActionAwaitableMixin.Factory, + private val selectLedgerAddressRequester: SelectLedgerAddressInterScreenRequester, + private val payload: FillWalletImportLedgerLegacyPayload +) : BaseViewModel() { + + private val filledAccountsFlow = MutableStateFlow>(emptyMap()) + + private val availableChainsFlow = flowOf { interactor.availableLedgerChains() } + .shareInBackground() + + val fillableChainAccountModels = combine(filledAccountsFlow, availableChainsFlow) { filledAccounts, availableChains -> + availableChains.map { chain -> createFillableChainAccountModel(chain, filledAccounts[chain.id]) } + }.shareInBackground() + + val continueState = filledAccountsFlow.map { + if (it.isEmpty()) { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.account_ledger_import_fill_disabled_hint)) + } else { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_done)) + } + }.shareInBackground() + + val confirmExit = actionAwaitableMixin.confirmingOrDenyingAction() + + init { + selectLedgerAddressRequester.responseFlow + .onEach(::addAccount) + .launchIn(this) + } + + fun continueClicked() = launch { + val payload = buildFinishPayload() + + router.openFinishImportLedger(payload) + } + + private suspend fun buildFinishPayload(): FinishImportLedgerPayload { + val filledAccounts = filledAccountsFlow.first() + val parcelableAccounts = filledAccounts.map { (chainId, account) -> + LedgerChainAccount( + publicKey = account.publicKey, + encryptionType = account.encryptionType, + address = account.address, + derivationPath = account.derivationPath, + chainId = chainId + ) + } + + return FinishImportLedgerPayload(parcelableAccounts) + } + + fun itemClicked(item: FillableChainAccountModel) { + val payload = SelectLedgerLegacyPayload(item.chainUi.id, payload.connectionMode) + + selectLedgerAddressRequester.openRequest(payload) + } + + fun backClicked() = launch { + val filledAccounts = filledAccountsFlow.first() + + if (filledAccounts.isEmpty() || confirmExit.awaitAction()) { + router.back() + } + } + + private suspend fun createFillableChainAccountModel(chain: Chain, account: LedgerSubstrateAccount?): FillableChainAccountModel { + return FillableChainAccountModel( + filledAddressModel = account?.let { + addressIconGenerator.createAccountAddressModel(chain, it.address) + }, + chainUi = mapChainToUi(chain) + ) + } + + private fun addAccount(response: LedgerChainAccount) { + val account = LedgerSubstrateAccount(response.address, response.publicKey, response.encryptionType, response.derivationPath) + + filledAccountsFlow.value = filledAccountsFlow.value.inserted(response.chainId, account) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/di/FillWalletImportLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/di/FillWalletImportLedgerComponent.kt new file mode 100644 index 0000000..034f739 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/di/FillWalletImportLedgerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.FillWalletImportLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.FillWalletImportLedgerLegacyPayload + +@Subcomponent( + modules = [ + FillWalletImportLedgerModule::class + ] +) +@ScreenScope +interface FillWalletImportLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: FillWalletImportLedgerLegacyPayload + ): FillWalletImportLedgerComponent + } + + fun inject(fragment: FillWalletImportLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/di/FillWalletImportLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/di/FillWalletImportLedgerModule.kt new file mode 100644 index 0000000..cd4d040 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/di/FillWalletImportLedgerModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.legacy.fillWallet.FillWalletImportLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.legacy.fillWallet.RealFillWalletImportLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.SelectLedgerAddressInterScreenCommunicator +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.FillWalletImportLedgerLegacyPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.FillWalletImportLedgerViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class FillWalletImportLedgerModule { + + @Provides + @ScreenScope + fun provideInteractor(chainRegistry: ChainRegistry): FillWalletImportLedgerInteractor { + return RealFillWalletImportLedgerInteractor(chainRegistry) + } + + @Provides + @IntoMap + @ViewModelKey(FillWalletImportLedgerViewModel::class) + fun provideViewModel( + router: LedgerRouter, + interactor: FillWalletImportLedgerInteractor, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + selectLedgerAddressInterScreenCommunicator: SelectLedgerAddressInterScreenCommunicator, + payload: FillWalletImportLedgerLegacyPayload + ): ViewModel { + return FillWalletImportLedgerViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + actionAwaitableMixin = actionAwaitableMixinFactory, + selectLedgerAddressRequester = selectLedgerAddressInterScreenCommunicator, + payload = payload + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): FillWalletImportLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(FillWalletImportLedgerViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/model/FillableChainAccountModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/model/FillableChainAccountModel.kt new file mode 100644 index 0000000..7fadb73 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/fillWallet/model/FillableChainAccountModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.model + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi + +class FillableChainAccountModel( + val filledAddressModel: AddressModel?, + val chainUi: ChainUi +) diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/FinishImportLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/FinishImportLedgerFragment.kt new file mode 100644 index 0000000..bedbd3e --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/FinishImportLedgerFragment.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.presenatation.account.createName.CreateWalletNameFragment +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent + +class FinishImportLedgerFragment : CreateWalletNameFragment() { + + companion object { + + private const val PAYLOAD_KEY = "FinishImportLedgerFragment.Payload" + + fun getBundle(payload: FinishImportLedgerPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .finishImportLedgerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/FinishImportLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/FinishImportLedgerViewModel.kt new file mode 100644 index 0000000..7a62c9d --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/FinishImportLedgerViewModel.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.createName.CreateWalletNameViewModel +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.legacy.finish.FinishImportLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.launch + +class FinishImportLedgerViewModel( + private val router: LedgerRouter, + private val resourceManager: ResourceManager, + private val payload: FinishImportLedgerPayload, + private val accountInteractor: AccountInteractor, + private val interactor: FinishImportLedgerInteractor +) : CreateWalletNameViewModel(router, resourceManager) { + + override fun proceed(name: String) { + launch { + interactor.createWallet(name, constructAccountsMap()) + .onSuccess { continueBasedOnCodeStatus() } + .onFailure(::showError) + } + } + + private suspend fun continueBasedOnCodeStatus() { + if (accountInteractor.isCodeSet()) { + router.openMain() + } else { + router.openCreatePincode() + } + } + + private fun constructAccountsMap(): Map = payload.ledgerChainAccounts.associateBy( + keySelector = { it.chainId }, + valueTransform = { LedgerSubstrateAccount(it.address, it.publicKey, it.encryptionType, it.derivationPath) } + ) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/FinishImportLedgetPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/FinishImportLedgetPayload.kt new file mode 100644 index 0000000..f1d0e22 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/FinishImportLedgetPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish + +import android.os.Parcelable +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.LedgerChainAccount +import kotlinx.parcelize.Parcelize + +@Parcelize +class FinishImportLedgerPayload( + val ledgerChainAccounts: List +) : Parcelable diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/di/FinishImportLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/di/FinishImportLedgerComponent.kt new file mode 100644 index 0000000..0d05f35 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/di/FinishImportLedgerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.FinishImportLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.FinishImportLedgerPayload + +@Subcomponent( + modules = [ + FinishImportLedgerModule::class + ] +) +@ScreenScope +interface FinishImportLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: FinishImportLedgerPayload, + ): FinishImportLedgerComponent + } + + fun inject(fragment: FinishImportLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/di/FinishImportLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/di/FinishImportLedgerModule.kt new file mode 100644 index 0000000..cac5113 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/finish/di/FinishImportLedgerModule.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.repository.addAccount.ledger.LegacyLedgerAddAccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.legacy.finish.FinishImportLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.connect.legacy.finish.RealFinishImportLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.FinishImportLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.finish.FinishImportLedgerViewModel + +@Module(includes = [ViewModelModule::class]) +class FinishImportLedgerModule { + + @Provides + @ScreenScope + fun provideInteractor( + legacyLedgerAddAccountRepository: LegacyLedgerAddAccountRepository, + accountRepository: AccountRepository + ): FinishImportLedgerInteractor = RealFinishImportLedgerInteractor(legacyLedgerAddAccountRepository, accountRepository) + + @Provides + @IntoMap + @ViewModelKey(FinishImportLedgerViewModel::class) + fun provideViewModel( + router: LedgerRouter, + resourceManager: ResourceManager, + payload: FinishImportLedgerPayload, + accountInteractor: AccountInteractor, + interactor: FinishImportLedgerInteractor + ): ViewModel { + return FinishImportLedgerViewModel( + router = router, + resourceManager = resourceManager, + payload = payload, + accountInteractor = accountInteractor, + interactor = interactor + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): FinishImportLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(FinishImportLedgerViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/SelectAddressImportLedgerLegacyFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/SelectAddressImportLedgerLegacyFragment.kt new file mode 100644 index 0000000..8aa40d9 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/SelectAddressImportLedgerLegacyFragment.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectAddress + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectAddressLedgerFragment + +class SelectAddressImportLedgerLegacyFragment : SelectAddressLedgerFragment() { + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .selectAddressImportLedgerLegacyComponentFactory() + .create(this, payload()) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/SelectAddressImportLedgerLegacyViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/SelectAddressImportLedgerLegacyViewModel.kt new file mode 100644 index 0000000..d92710e --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/SelectAddressImportLedgerLegacyViewModel.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectAddress + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.LedgerAccount +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectAddressLedgerViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.model.AddressVerificationMode +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.LedgerChainAccount +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.SelectLedgerAddressInterScreenResponder +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class SelectAddressImportLedgerLegacyViewModel( + private val router: LedgerRouter, + private val payload: SelectLedgerAddressPayload, + private val responder: SelectLedgerAddressInterScreenResponder, + interactor: SelectAddressLedgerInteractor, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + messageCommandFormatter: MessageCommandFormatter, + addressActionsMixinFactory: AddressActionsMixin.Factory +) : SelectAddressLedgerViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + payload = payload, + chainRegistry = chainRegistry, + messageCommandFormatter = messageCommandFormatter, + addressActionsMixinFactory = addressActionsMixinFactory +) { + + override val ledgerVariant: LedgerVariant = LedgerVariant.LEGACY + + override val addressVerificationMode = AddressVerificationMode.Enabled(addressSchemesToVerify = listOf(AddressScheme.SUBSTRATE)) + + override fun onAccountVerified(account: LedgerAccount) { + responder.respond(screenResponseFrom(account)) + router.returnToImportFillWallet() + } + + private fun screenResponseFrom(account: LedgerAccount): LedgerChainAccount { + return LedgerChainAccount( + publicKey = account.substrate.publicKey, + address = account.substrate.address, + chainId = payload.substrateChainId, + encryptionType = account.substrate.encryptionType, + derivationPath = account.substrate.derivationPath + ) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/di/SelectAddressImportLedgerLegacyComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/di/SelectAddressImportLedgerLegacyComponent.kt new file mode 100644 index 0000000..160000c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/di/SelectAddressImportLedgerLegacyComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectAddress.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectAddress.SelectAddressImportLedgerLegacyFragment + +@Subcomponent( + modules = [ + SelectAddressImportLedgerLegacyModule::class + ] +) +@ScreenScope +interface SelectAddressImportLedgerLegacyComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SelectLedgerAddressPayload, + ): SelectAddressImportLedgerLegacyComponent + } + + fun inject(fragment: SelectAddressImportLedgerLegacyFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/di/SelectAddressImportLedgerLegacyModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/di/SelectAddressImportLedgerLegacyModule.kt new file mode 100644 index 0000000..02e949b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectAddress/di/SelectAddressImportLedgerLegacyModule.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectAddress.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_ledger_impl.domain.account.common.selectAddress.SelectAddressLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.SelectLedgerAddressInterScreenCommunicator +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectAddress.SelectAddressImportLedgerLegacyViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SelectAddressImportLedgerLegacyModule { + + @Provides + @ScreenScope + fun provideMessageFormatter( + screenPayload: SelectLedgerAddressPayload, + factory: LedgerMessageFormatterFactory, + ): LedgerMessageFormatter = factory.createLegacy(screenPayload.substrateChainId, showAlerts = false) + + @Provides + @ScreenScope + fun provideMessageCommandFormatter( + messageFormatter: LedgerMessageFormatter, + messageCommandFormatterFactory: MessageCommandFormatterFactory + ): MessageCommandFormatter = messageCommandFormatterFactory.create(messageFormatter) + + @Provides + @IntoMap + @ViewModelKey(SelectAddressImportLedgerLegacyViewModel::class) + fun provideViewModel( + router: LedgerRouter, + interactor: SelectAddressLedgerInteractor, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + payload: SelectLedgerAddressPayload, + chainRegistry: ChainRegistry, + selectLedgerAddressInterScreenCommunicator: SelectLedgerAddressInterScreenCommunicator, + messageCommandFormatter: MessageCommandFormatter, + addressActionsMixinFactory: AddressActionsMixin.Factory + ): ViewModel { + return SelectAddressImportLedgerLegacyViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + payload = payload, + chainRegistry = chainRegistry, + responder = selectLedgerAddressInterScreenCommunicator, + messageCommandFormatter = messageCommandFormatter, + addressActionsMixinFactory = addressActionsMixinFactory + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): SelectAddressImportLedgerLegacyViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectAddressImportLedgerLegacyViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/SelectLedgerLegacyImportFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/SelectLedgerLegacyImportFragment.kt new file mode 100644 index 0000000..78d77f8 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/SelectLedgerLegacyImportFragment.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger + +import android.os.Bundle +import androidx.core.os.bundleOf +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerFragment + +class SelectLedgerLegacyImportFragment : SelectLedgerFragment() { + + companion object { + + private const val PAYLOAD_KEY = "SelectLedgerLegacyImportFragment.PAYLOAD_KEY" + + fun getBundle(payload: SelectLedgerLegacyPayload): Bundle = bundleOf(PAYLOAD_KEY to payload) + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .selectLedgerImportComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/SelectLedgerLegacyImportViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/SelectLedgerLegacyImportViewModel.kt new file mode 100644 index 0000000..cf6f4ed --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/SelectLedgerLegacyImportViewModel.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.domain.migration.LedgerMigrationUseCase +import io.novafoundation.nova.feature_ledger_impl.domain.migration.determineAppForLegacyAccount +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectAddress.SelectLedgerAddressPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerViewModel + +class SelectLedgerLegacyImportViewModel( + private val migrationUseCase: LedgerMigrationUseCase, + private val selectLedgerPayload: SelectLedgerLegacyPayload, + private val router: LedgerRouter, + private val messageCommandFormatter: MessageCommandFormatter, + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + resourceManager: ResourceManager, + messageFormatter: LedgerMessageFormatter, + ledgerDeviceFormatter: LedgerDeviceFormatter +) : SelectLedgerViewModel( + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + messageFormatter = messageFormatter, + ledgerDeviceFormatter = ledgerDeviceFormatter, + messageCommandFormatter = messageCommandFormatter, + payload = selectLedgerPayload +) { + + override suspend fun verifyConnection(device: LedgerDevice) { + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + + val app = migrationUseCase.determineAppForLegacyAccount(selectLedgerPayload.chainId) + + // ensure that address loads successfully + app.getSubstrateAccount(device, selectLedgerPayload.chainId, accountIndex = 0, confirmAddress = false) + + val selectAddressPayload = SelectLedgerAddressPayload(device.id, selectLedgerPayload.chainId) + router.openSelectImportAddress(selectAddressPayload) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/SelectLedgerLegacyPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/SelectLedgerLegacyPayload.kt new file mode 100644 index 0000000..4994f6c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/SelectLedgerLegacyPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger + +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.android.parcel.Parcelize + +@Parcelize +class SelectLedgerLegacyPayload( + val chainId: ChainId, + override val connectionMode: SelectLedgerPayload.ConnectionMode +) : SelectLedgerPayload diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/di/SelectLedgerImportLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/di/SelectLedgerImportLedgerComponent.kt new file mode 100644 index 0000000..e5f2962 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/di/SelectLedgerImportLedgerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.SelectLedgerLegacyPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.SelectLedgerLegacyImportFragment + +@Subcomponent( + modules = [ + SelectLedgerImportLedgerModule::class + ] +) +@ScreenScope +interface SelectLedgerImportLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SelectLedgerLegacyPayload, + ): SelectLedgerImportLedgerComponent + } + + fun inject(fragment: SelectLedgerLegacyImportFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/di/SelectLedgerImportLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/di/SelectLedgerImportLedgerModule.kt new file mode 100644 index 0000000..3764ea1 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/selectLedger/di/SelectLedgerImportLedgerModule.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.modules.shared.PermissionAskerForFragmentModule +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.domain.migration.LedgerMigrationUseCase +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.SelectLedgerLegacyPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.selectLedger.SelectLedgerLegacyImportViewModel + +@Module(includes = [ViewModelModule::class, PermissionAskerForFragmentModule::class]) +class SelectLedgerImportLedgerModule { + + @Provides + @ScreenScope + fun provideMessageFormatter( + selectLedgerPayload: SelectLedgerLegacyPayload, + factory: LedgerMessageFormatterFactory, + ): LedgerMessageFormatter = factory.createLegacy(selectLedgerPayload.chainId, showAlerts = false) + + @Provides + @ScreenScope + fun provideMessageCommandFormatter( + messageFormatter: LedgerMessageFormatter, + messageCommandFormatterFactory: MessageCommandFormatterFactory + ): MessageCommandFormatter = messageCommandFormatterFactory.create(messageFormatter) + + @Provides + @IntoMap + @ViewModelKey(SelectLedgerLegacyImportViewModel::class) + fun provideViewModel( + migrationUseCase: LedgerMigrationUseCase, + selectLedgerPayload: SelectLedgerLegacyPayload, + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + router: LedgerRouter, + resourceManager: ResourceManager, + messageFormatter: LedgerMessageFormatter, + deviceMapperFactory: LedgerDeviceFormatter, + messageCommandFormatter: MessageCommandFormatter + ): ViewModel { + return SelectLedgerLegacyImportViewModel( + migrationUseCase = migrationUseCase, + selectLedgerPayload = selectLedgerPayload, + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + messageFormatter = messageFormatter, + ledgerDeviceFormatter = deviceMapperFactory, + messageCommandFormatter = messageCommandFormatter + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): SelectLedgerLegacyImportViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectLedgerLegacyImportViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/StartImportLegacyLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/StartImportLegacyLedgerFragment.kt new file mode 100644 index 0000000..10c2643 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/StartImportLegacyLedgerFragment.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.start + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.formatting.spannable.highlightedText +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.start.StartImportLedgerFragment + +class StartImportLegacyLedgerFragment : StartImportLedgerFragment() { + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .startImportLegacyLedgerComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: StartImportLegacyLedgerViewModel) { + super.subscribe(viewModel) + + viewModel.warningModel.observe { + pageAdapter.showWarning(it) + } + } + + override fun networkAppIsInstalledStep() = requireContext().highlightedText( + R.string.account_ledger_import_start_step_1, + R.string.account_ledger_import_start_step_1_highlighted + ) + + override fun openingNetworkAppStep() = requireContext().highlightedText( + R.string.account_ledger_import_start_step_2, + R.string.account_ledger_import_start_step_2_highlighted + ) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/StartImportLegacyLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/StartImportLegacyLedgerViewModel.kt new file mode 100644 index 0000000..89e90dd --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/StartImportLegacyLedgerViewModel.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.start + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.start.StartImportLedgerViewModel +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.fillWallet.FillWalletImportLedgerLegacyPayload +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +class StartImportLegacyLedgerViewModel( + private val resourceManager: ResourceManager, + private val router: LedgerRouter, + private val appLinksProvider: AppLinksProvider, + private val ledgerMigrationTracker: LedgerMigrationTracker, +) : StartImportLedgerViewModel(router, appLinksProvider), Browserable { + + override val openBrowserEvent = MutableLiveData>() + + override fun continueWithBluetooth() { + router.openImportFillWallet(FillWalletImportLedgerLegacyPayload(SelectLedgerPayload.ConnectionMode.BLUETOOTH)) + } + + override fun continueWithUsb() { + router.openImportFillWallet(FillWalletImportLedgerLegacyPayload(SelectLedgerPayload.ConnectionMode.USB)) + } + + val warningModel = flowOf { ledgerMigrationTracker.anyChainSupportsMigrationApp() } + .map { getWarningModel(it) } + .onStart { emit(null) } + .shareInBackground() + + private fun getWarningModel(shouldBeShown: Boolean): AlertModel? { + if (!shouldBeShown) return null + + return AlertModel( + style = AlertView.Style.fromPreset(AlertView.StylePreset.WARNING), + message = resourceManager.getString(R.string.account_ledger_legacy_warning_title), + subMessage = resourceManager.getString(R.string.account_ledger_legacy_warning_message), + linkAction = AlertModel.ActionModel(resourceManager.getString(R.string.common_find_out_more), ::deprecationWarningClicked) + ) + } + + fun deprecationWarningClicked() { + openBrowserEvent.value = appLinksProvider.ledgerMigrationArticle.event() + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/di/StartImportLegacyLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/di/StartImportLegacyLedgerComponent.kt new file mode 100644 index 0000000..3d270ce --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/di/StartImportLegacyLedgerComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.start.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.start.StartImportLegacyLedgerFragment + +@Subcomponent( + modules = [ + StartImportLegacyLedgerModule::class + ] +) +@ScreenScope +interface StartImportLegacyLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): StartImportLegacyLedgerComponent + } + + fun inject(fragment: StartImportLegacyLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/di/StartImportLegacyLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/di/StartImportLegacyLedgerModule.kt new file mode 100644 index 0000000..a0393c3 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/connect/legacy/start/di/StartImportLegacyLedgerModule.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.start.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.connect.legacy.start.StartImportLegacyLedgerViewModel + +@Module(includes = [ViewModelModule::class]) +class StartImportLegacyLedgerModule { + + @Provides + @IntoMap + @ViewModelKey(StartImportLegacyLedgerViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: LedgerRouter, + appLinksProvider: AppLinksProvider, + ledgerMigrationTracker: LedgerMigrationTracker, + ): ViewModel { + return StartImportLegacyLedgerViewModel(resourceManager, router, appLinksProvider, ledgerMigrationTracker) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): StartImportLegacyLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StartImportLegacyLedgerViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/SignLedgerFragment.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/SignLedgerFragment.kt new file mode 100644 index 0000000..a0ff4a2 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/SignLedgerFragment.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.sign + +import android.os.Bundle +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_ledger_api.di.LedgerFeatureApi +import io.novafoundation.nova.feature_ledger_impl.di.LedgerFeatureComponent +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerFragment + +class SignLedgerFragment : SelectLedgerFragment() { + + companion object { + + private const val PAYLOAD_KEY = "SignLedgerFragment.Payload" + + fun getBundle(payload: SignLedgerPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), LedgerFeatureApi::class.java) + .signLedgerComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/SignLedgerPayload.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/SignLedgerPayload.kt new file mode 100644 index 0000000..4da499b --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/SignLedgerPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.sign + +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenCommunicator +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload +import kotlinx.parcelize.Parcelize + +@Parcelize +class SignLedgerPayload( + val request: SignInterScreenCommunicator.Request, + val ledgerVariant: LedgerVariant, + override val connectionMode: SelectLedgerPayload.ConnectionMode, +) : SelectLedgerPayload diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/SignLedgerViewModel.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/SignLedgerViewModel.kt new file mode 100644 index 0000000..79f7acb --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/SignLedgerViewModel.kt @@ -0,0 +1,219 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.sign + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.getOrThrow +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_api.data.signer.requireExtrinsic +import io.novafoundation.nova.feature_account_api.presenatation.sign.SignInterScreenResponder +import io.novafoundation.nova.feature_account_api.presenatation.sign.cancelled +import io.novafoundation.nova.feature_account_api.presenatation.sign.signed +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerApplicationResponse.INVALID_DATA +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateLedgerApplicationError +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.R +import io.novafoundation.nova.feature_ledger_impl.domain.account.sign.SignLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerViewModel +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase +import io.novafoundation.nova.runtime.extrinsic.ended +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +enum class InvalidDataError { + TX_NOT_SUPPORTED, + METADATA_OUTDATED, +} + +private class InvalidSignatureError : Exception() + +class SignLedgerViewModel( + private val router: LedgerRouter, + private val resourceManager: ResourceManager, + private val signPayloadState: SigningSharedState, + private val extrinsicValidityUseCase: ExtrinsicValidityUseCase, + private val responder: SignInterScreenResponder, + private val interactor: SignLedgerInteractor, + private val messageFormatter: LedgerMessageFormatter, + private val messageCommandFormatter: MessageCommandFormatter, + private val payload: SignLedgerPayload, + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + ledgerDeviceFormatter: LedgerDeviceFormatter, +) : SelectLedgerViewModel( + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + messageFormatter = messageFormatter, + ledgerDeviceFormatter = ledgerDeviceFormatter, + messageCommandFormatter = messageCommandFormatter, + payload = payload +) { + + private val validityPeriod = flowOf { + extrinsicValidityUseCase.extrinsicValidityPeriod(signPayloadState.getOrThrow().requireExtrinsic()) + }.shareInBackground() + + private var signingJob: Deferred? = null + + private val fatalErrorDetected = MutableStateFlow(false) + + override fun backClicked() { + exit() + } + + override suspend fun handleLedgerError(reason: Throwable, device: LedgerDevice) { + if (fatalErrorDetected.value) return + + when { + reason is SubstrateLedgerApplicationError.Response && reason.response == INVALID_DATA -> { + handleInvalidData(reason.errorMessage, device) + } + + reason is InvalidSignatureError -> handleInvalidSignature(device) + + else -> super.handleLedgerError(reason, device) + } + } + + override suspend fun verifyConnection(device: LedgerDevice) { + val validityPeriod = validityPeriod.first() + + if (validityPeriod.ended()) { + timerExpired(device) + return + } + + val signState = signPayloadState.getOrThrow() + + ledgerMessageCommands.value = messageCommandFormatter.signCommand( + validityPeriod, + device, + onTimeFinished = { timerExpired(device) }, + ::bottomSheetClosed + ).event() + + val signingMetaAccount = signState.metaAccount + + signingJob?.cancel() + signingJob = async { + interactor.getSignature( + device = device, + metaId = signingMetaAccount.id, + payload = signState.requireExtrinsic() + ) + } + + val signature = signingJob!!.await() + + if (interactor.verifySignature(signState, signature)) { + responder.respond(payload.request.signed(signature)) + hideBottomSheet() + router.finishSignFlow() + } else { + throw InvalidSignatureError() + } + } + + private suspend fun handleInvalidSignature(ledgerDevice: LedgerDevice) { + showFatalError( + title = resourceManager.getString(R.string.common_signature_invalid), + subtitle = resourceManager.getString(R.string.ledger_sign_signature_invalid_message), + ledgerDevice = ledgerDevice + ) + } + + private suspend fun handleInvalidData(invalidDataMessage: String?, ledgerDevice: LedgerDevice) { + val errorTitle: String + val errorMessage: String + + when (matchInvalidDataMessage(invalidDataMessage)) { + InvalidDataError.TX_NOT_SUPPORTED -> { + errorTitle = resourceManager.getString(R.string.ledger_sign_tx_not_supported_title) + errorMessage = resourceManager.getString(R.string.ledger_sign_tx_not_supported_subtitle) + } + + InvalidDataError.METADATA_OUTDATED -> { + errorTitle = resourceManager.getString(R.string.ledger_sign_metadata_outdated_title) + errorMessage = resourceManager.getString(R.string.ledger_sign_metadata_outdated_subtitle, messageFormatter.appName()) + } + + null -> { + errorTitle = resourceManager.getString(R.string.ledger_error_general_title) + errorMessage = invalidDataMessage ?: resourceManager.getString(R.string.ledger_error_general_message) + } + } + + showFatalError(errorTitle, errorMessage, ledgerDevice) + } + + private fun timerExpired(ledgerDevice: LedgerDevice) { + signingJob?.cancel() + launch { + val period = validityPeriod.first().period.millis.milliseconds + val periodFormatted = resourceManager.formatDuration(period, estimated = false) + + showFatalError( + ledgerDevice = ledgerDevice, + title = resourceManager.getString(R.string.ledger_sign_transaction_expired_title), + subtitle = resourceManager.getString(R.string.ledger_sign_transaction_expired_message, periodFormatted), + ) + } + } + + private suspend fun showFatalError( + title: String, + subtitle: String, + ledgerDevice: LedgerDevice + ) { + fatalErrorDetected.value = true + + ledgerMessageCommands.value = messageCommandFormatter.fatalErrorCommand(title, subtitle, ledgerDevice, ::bottomSheetClosed, ::errorAcknowledged).event() + } + + private fun bottomSheetClosed() { + signingJob?.cancel() + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + } + + private fun errorAcknowledged() { + hideBottomSheet() + + exit() + } + + private fun exit() { + responder.respond(payload.request.cancelled()) + router.finishSignFlow() + } + + private fun hideBottomSheet() { + ledgerMessageCommands.value = messageCommandFormatter.hideCommand().event() + } + + private fun matchInvalidDataMessage(message: String?): InvalidDataError? { + return when (message) { + "Method not supported", "Call nesting not supported" -> InvalidDataError.TX_NOT_SUPPORTED + "Spec version not supported", "Txn version not supported" -> InvalidDataError.METADATA_OUTDATED + else -> null + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/di/SignLedgerComponent.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/di/SignLedgerComponent.kt new file mode 100644 index 0000000..4c7494d --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/di/SignLedgerComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.sign.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_ledger_impl.presentation.account.sign.SignLedgerFragment +import io.novafoundation.nova.feature_ledger_impl.presentation.account.sign.SignLedgerPayload + +@Subcomponent( + modules = [ + SignLedgerModule::class + ] +) +@ScreenScope +interface SignLedgerComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance request: SignLedgerPayload, + ): SignLedgerComponent + } + + fun inject(fragment: SignLedgerFragment) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/di/SignLedgerModule.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/di/SignLedgerModule.kt new file mode 100644 index 0000000..54b459a --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/presentation/account/sign/di/SignLedgerModule.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_ledger_impl.presentation.account.sign.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.modules.shared.PermissionAskerForFragmentModule +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.common.utils.chainId +import io.novafoundation.nova.common.utils.getOrThrow +import io.novafoundation.nova.common.utils.location.LocationManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_account_api.data.signer.SigningSharedState +import io.novafoundation.nova.feature_account_api.data.signer.chainId +import io.novafoundation.nova.feature_account_api.domain.model.LedgerVariant +import io.novafoundation.nova.feature_account_api.presenatation.sign.LedgerSignCommunicator +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import io.novafoundation.nova.feature_ledger_impl.domain.account.sign.RealSignLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.account.sign.SignLedgerInteractor +import io.novafoundation.nova.feature_ledger_impl.domain.migration.LedgerMigrationUseCase +import io.novafoundation.nova.feature_ledger_impl.presentation.LedgerRouter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.MessageCommandFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.bottomSheet.mappers.LedgerDeviceFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatter +import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.formatters.LedgerMessageFormatterFactory +import io.novafoundation.nova.feature_ledger_impl.presentation.account.sign.SignLedgerPayload +import io.novafoundation.nova.feature_ledger_impl.presentation.account.sign.SignLedgerViewModel +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class, PermissionAskerForFragmentModule::class]) +class SignLedgerModule { + + @Provides + @ScreenScope + fun provideInteractor( + chainRegistry: ChainRegistry, + signLedgerPayload: SignLedgerPayload, + migrationUseCase: LedgerMigrationUseCase, + ): SignLedgerInteractor = RealSignLedgerInteractor( + chainRegistry = chainRegistry, + usedVariant = signLedgerPayload.ledgerVariant, + migrationUseCase = migrationUseCase, + ) + + @Provides + @ScreenScope + fun provideMessageFormatter( + signPayloadState: SigningSharedState, + signLedgerPayload: SignLedgerPayload, + factory: LedgerMessageFormatterFactory + ): LedgerMessageFormatter { + val chainId = signPayloadState.getOrThrow().payload.chainId() + + return when (signLedgerPayload.ledgerVariant) { + LedgerVariant.LEGACY -> factory.createLegacy(chainId, showAlerts = true) + LedgerVariant.GENERIC -> factory.createGeneric() + } + } + + @Provides + @ScreenScope + fun provideMessageCommandFormatter( + messageFormatter: LedgerMessageFormatter, + messageCommandFormatterFactory: MessageCommandFormatterFactory + ): MessageCommandFormatter = messageCommandFormatterFactory.create(messageFormatter) + + @Provides + @IntoMap + @ViewModelKey(SignLedgerViewModel::class) + fun provideViewModel( + discoveryService: LedgerDeviceDiscoveryService, + permissionsAsker: PermissionsAsker.Presentation, + bluetoothManager: BluetoothManager, + locationManager: LocationManager, + router: LedgerRouter, + resourceManager: ResourceManager, + signPayloadState: SigningSharedState, + extrinsicValidityUseCase: ExtrinsicValidityUseCase, + payload: SignLedgerPayload, + interactor: SignLedgerInteractor, + responder: LedgerSignCommunicator, + messageFormatter: LedgerMessageFormatter, + deviceMapperFactory: LedgerDeviceFormatter, + messageCommandFormatter: MessageCommandFormatter + ): ViewModel { + return SignLedgerViewModel( + discoveryService = discoveryService, + permissionsAsker = permissionsAsker, + bluetoothManager = bluetoothManager, + locationManager = locationManager, + router = router, + resourceManager = resourceManager, + messageFormatter = messageFormatter, + signPayloadState = signPayloadState, + extrinsicValidityUseCase = extrinsicValidityUseCase, + payload = payload, + responder = responder, + interactor = interactor, + ledgerDeviceFormatter = deviceMapperFactory, + messageCommandFormatter = messageCommandFormatter + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): SignLedgerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SignLedgerViewModel::class.java) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/LedgerAppCommon.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/LedgerAppCommon.kt new file mode 100644 index 0000000..dfab43e --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/LedgerAppCommon.kt @@ -0,0 +1,158 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate + +import android.util.Log +import io.novafoundation.nova.common.utils.SignatureWrapperEcdsa +import io.novafoundation.nova.common.utils.dropBytes +import io.novafoundation.nova.common.utils.dropBytesLast +import io.novafoundation.nova.common.utils.isValidSS58Address +import io.novafoundation.nova.common.utils.toBigEndianU16 +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerApplicationResponse +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateApplicationConfig +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateLedgerApplicationError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.EncryptionType +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.encrypt.json.copyBytes +import io.novasama.substrate_sdk_android.encrypt.junction.BIP32JunctionDecoder +import io.novasama.substrate_sdk_android.extensions.copyLast + +object SubstrateLedgerAppCommon { + + const val CHUNK_SIZE = 250 + + private const val SUBSTRATE_PUBLIC_KEY_LENGTH = 32 + private const val RESPONSE_CODE_LENGTH = 2 + + enum class Instruction(val code: UByte) { + GET_ADDRESS(0x01u), SIGN(0x02u) + } + + enum class CryptoScheme(val code: UByte) { + ED25519(0x00u), SR25519(0x01u), ECDSA(0x02u); + + companion object { + fun fromCode(code: UByte): CryptoScheme { + return values().first { it.code == code } + } + } + } + + enum class DisplayVerificationDialog(val code: UByte) { + YES(0x01u), NO(0x00u); + + companion object { + + fun fromBoolean(shouldVerify: Boolean): DisplayVerificationDialog { + return if (shouldVerify) YES else NO + } + } + } + + enum class SignPayloadType(val code: UByte) { + FIRST(0x00u), ADD(0x01u), LAST(0x02u); + } + + fun defaultCryptoScheme() = CryptoScheme.ED25519 + + fun SignPayloadType(chunkIndex: Int, total: Int): SignPayloadType { + return when { + chunkIndex == 0 -> SignPayloadType.FIRST + chunkIndex < total - 1 -> SignPayloadType.ADD + else -> SignPayloadType.LAST + } + } + + fun parseMultiSignature(raw: ByteArray): SignatureWrapper { + val cryptoSchemeByte = raw[0] + val signature = raw.dropBytes(1) + val cryptoScheme = CryptoScheme.fromCode(cryptoSchemeByte.toUByte()) + + return parseSignature(signature, cryptoScheme) + } + + fun parseSignature(signature: ByteArray, cryptoScheme: CryptoScheme): SignatureWrapper { + return when (cryptoScheme) { + CryptoScheme.ED25519 -> SignatureWrapper.Ed25519(signature) + CryptoScheme.SR25519 -> SignatureWrapper.Sr25519(signature) + CryptoScheme.ECDSA -> SignatureWrapperEcdsa(signature) + } + } + + fun encodeDerivationPath(derivationPath: String): ByteArray { + val junctions = BIP32JunctionDecoder.decode(derivationPath).junctions + + return junctions.serializeInLedgerFormat() + } + + fun parseSubstrateAccountResponse(raw: ByteArray, requestDerivationPath: String): LedgerSubstrateAccount { + val dataWithoutResponseCode = processResponseCode(raw) + + val publicKey = dataWithoutResponseCode.copyBytes(0, SUBSTRATE_PUBLIC_KEY_LENGTH) + require(publicKey.size == SUBSTRATE_PUBLIC_KEY_LENGTH) { + "No public key" + } + + val accountAddressData = dataWithoutResponseCode.dropBytes(SUBSTRATE_PUBLIC_KEY_LENGTH) + val address = accountAddressData.decodeToString() + + require(address.isValidSS58Address()) { + "Invalid address" + } + + val encryptionType = mapCryptoSchemeToEncryptionType(defaultCryptoScheme()) + + return LedgerSubstrateAccount( + address = address, + publicKey = publicKey, + encryptionType = encryptionType, + derivationPath = requestDerivationPath + ) + } + + /** + * Process response code and return data without response code + */ + fun processResponseCode(raw: ByteArray): ByteArray { + val responseCodeData = raw.copyLast(RESPONSE_CODE_LENGTH) + require(responseCodeData.size == RESPONSE_CODE_LENGTH) { + "No response code" + } + val responseData = raw.dropBytesLast(RESPONSE_CODE_LENGTH) + + val responseCode = responseCodeData.toBigEndianU16() + + Log.d("Ledger", "Ledger response code: $responseCode") + + val response = LedgerApplicationResponse.fromCode(responseCode) + + if (response != LedgerApplicationResponse.NO_ERROR) { + val errorMessage = if (responseData.isNotEmpty()) { + responseData.decodeToString() + } else { + null + } + + throw SubstrateLedgerApplicationError.Response(response, errorMessage) + } + + return responseData + } + + fun List.getConfig(chainId: ChainId): SubstrateApplicationConfig { + return find { it.chainId == chainId } + ?: throw SubstrateLedgerApplicationError.UnsupportedApp(chainId) + } + + fun buildDerivationPath(coin: Int, accountIndex: Int): String { + return "//44//$coin//$accountIndex//0//0" + } + + private fun mapCryptoSchemeToEncryptionType(cryptoScheme: CryptoScheme): EncryptionType { + return when (cryptoScheme) { + CryptoScheme.ED25519 -> EncryptionType.ED25519 + CryptoScheme.SR25519 -> EncryptionType.SR25519 + CryptoScheme.ECDSA -> EncryptionType.ECDSA + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/LedgerDerivationPathEncoder.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/LedgerDerivationPathEncoder.kt new file mode 100644 index 0000000..734cb75 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/LedgerDerivationPathEncoder.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate + +import io.novasama.substrate_sdk_android.encrypt.junction.Junction + +fun List.serializeInLedgerFormat(): ByteArray = fold(ByteArray(0)) { acc, junction -> + // Bip32Encoder currently encodes chain codes as big endian, so we need to reverse them to get little endian encoding + // TODO add this ability to library + acc + junction.chaincode.reversedArray() +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/legacyApp/LegacySubstrateLedgerApplication.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/legacyApp/LegacySubstrateLedgerApplication.kt new file mode 100644 index 0000000..ad4095c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/legacyApp/LegacySubstrateLedgerApplication.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.legacyApp + +import io.novafoundation.nova.common.utils.chunked +import io.novafoundation.nova.feature_ledger_api.data.repository.LedgerRepository +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerEvmAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateApplicationConfig +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateLedgerApplication +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.transport.LedgerTransport +import io.novafoundation.nova.feature_ledger_api.sdk.transport.send +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.CHUNK_SIZE +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.buildDerivationPath +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.getConfig +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication + +class LegacySubstrateLedgerApplication( + private val transport: LedgerTransport, + private val ledgerRepository: LedgerRepository, + private val supportedApplications: List = SubstrateApplicationConfig.all(), +) : SubstrateLedgerApplication { + + override suspend fun getSubstrateAccount( + device: LedgerDevice, + chainId: ChainId, + accountIndex: Int, + confirmAddress: Boolean + ): LedgerSubstrateAccount { + val applicationConfig = supportedApplications.getConfig(chainId) + val displayVerificationDialog = SubstrateLedgerAppCommon.DisplayVerificationDialog.fromBoolean(confirmAddress) + + val derivationPath = buildDerivationPath(applicationConfig.coin, accountIndex) + val encodedDerivationPath = SubstrateLedgerAppCommon.encodeDerivationPath(derivationPath) + + val rawResponse = transport.send( + cla = applicationConfig.cla, + ins = SubstrateLedgerAppCommon.Instruction.GET_ADDRESS.code, + p1 = displayVerificationDialog.code, + p2 = SubstrateLedgerAppCommon.defaultCryptoScheme().code, + data = encodedDerivationPath, + device = device + ) + + return SubstrateLedgerAppCommon.parseSubstrateAccountResponse(rawResponse, derivationPath) + } + + override suspend fun getEvmAccount(device: LedgerDevice, accountIndex: Int, confirmAddress: Boolean): LedgerEvmAccount? { + return null + } + + override suspend fun getSignature( + device: LedgerDevice, + metaId: Long, + chainId: ChainId, + payload: InheritedImplication, + ): SignatureWrapper { + val payloadBytes = payload.encoded() + val applicationConfig = supportedApplications.getConfig(chainId) + + val derivationPath = ledgerRepository.getChainAccountDerivationPath(metaId, chainId) + val encodedDerivationPath = SubstrateLedgerAppCommon.encodeDerivationPath(derivationPath) + + val chunks = listOf(encodedDerivationPath) + payloadBytes.chunked(CHUNK_SIZE) + + val results = chunks.mapIndexed { index, chunk -> + val chunkType = SubstrateLedgerAppCommon.SignPayloadType(index, chunks.size) + + val rawResponse = transport.send( + cla = applicationConfig.cla, + ins = SubstrateLedgerAppCommon.Instruction.SIGN.code, + p1 = chunkType.code, + p2 = SubstrateLedgerAppCommon.defaultCryptoScheme().code, + data = chunk, + device = device + ) + + SubstrateLedgerAppCommon.processResponseCode(rawResponse) + } + + val signatureWithType = results.last() + + return SubstrateLedgerAppCommon.parseMultiSignature(signatureWithType) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/newApp/GenericSubstrateLedgerApplication.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/newApp/GenericSubstrateLedgerApplication.kt new file mode 100644 index 0000000..0017653 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/newApp/GenericSubstrateLedgerApplication.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.newApp + +import io.novafoundation.nova.common.utils.GENERIC_ADDRESS_PREFIX +import io.novafoundation.nova.feature_ledger_api.data.repository.LedgerRepository +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerApplicationResponse +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerEvmAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateApplicationConfig +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateLedgerApplicationError +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.transport.LedgerTransport +import io.novafoundation.nova.feature_ledger_api.sdk.transport.send +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.CryptoScheme +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.DisplayVerificationDialog +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.Instruction +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.buildDerivationPath +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.encodeDerivationPath +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.getConfig +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.processResponseCode +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.json.copyBytes +import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder + +class GenericSubstrateLedgerApplication( + private val transport: LedgerTransport, + metadataShortenerService: MetadataShortenerService, + chainRegistry: ChainRegistry, + private val ledgerRepository: LedgerRepository, + private val legacyApplicationConfigs: List = SubstrateApplicationConfig.all() +) : NewSubstrateLedgerApplication(transport, metadataShortenerService, chainRegistry) { + + private val universalConfig = legacyApplicationConfigs.getConfig(Chain.Geneses.POLKADOT) + + override val cla: UByte = CLA + + companion object { + + const val CLA: UByte = 0xf9u + + private const val EVM_PUBLIC_KEY_LENGTH = 33 + } + + suspend fun getUniversalSubstrateAccount( + device: LedgerDevice, + accountIndex: Int, + confirmAddress: Boolean + ): LedgerSubstrateAccount { + return getSubstrateAccount(device, Chain.Geneses.POLKADOT, accountIndex = accountIndex, confirmAddress) + } + + override suspend fun getDerivationPath(chainId: ChainId, accountIndex: Int): String { + return buildDerivationPath(universalConfig.coin, accountIndex) + } + + override suspend fun getDerivationPath(metaId: Long, chainId: ChainId): String { + return ledgerRepository.getGenericDerivationPath(metaId) + } + + override suspend fun getAddressPrefix(chainId: ChainId): Short { + return SS58Encoder.GENERIC_ADDRESS_PREFIX + } + + override suspend fun getEvmAccount( + device: LedgerDevice, + accountIndex: Int, + confirmAddress: Boolean + ): LedgerEvmAccount? { + val displayVerificationDialog = DisplayVerificationDialog.fromBoolean(confirmAddress) + + val derivationPath = buildDerivationPath(universalConfig.coin, accountIndex) + val encodedDerivationPath = encodeDerivationPath(derivationPath) + val payload = encodedDerivationPath + + return try { + val rawResponse = transport.send( + cla = cla, + ins = Instruction.GET_ADDRESS.code, + p1 = displayVerificationDialog.code, + p2 = CryptoScheme.ECDSA.code, + data = payload, + device = device + ) + + parseEvmAccountResponse(rawResponse) + } catch (e: SubstrateLedgerApplicationError.Response) { + if (e.isLedgerNotUpdatedToSupportEvm()) { + null + } else { + throw e + } + } + } + + private fun SubstrateLedgerApplicationError.Response.isLedgerNotUpdatedToSupportEvm(): Boolean { + return response == LedgerApplicationResponse.WRONG_LENGTH + } + + private fun parseEvmAccountResponse(raw: ByteArray): LedgerEvmAccount { + val dataWithoutResponseCode = processResponseCode(raw) + + val publicKey = dataWithoutResponseCode.copyBytes(0, EVM_PUBLIC_KEY_LENGTH) + require(publicKey.size == EVM_PUBLIC_KEY_LENGTH) { + "No public key" + } + + return LedgerEvmAccount( + publicKey = publicKey, + accountId = publicKey.asEthereumPublicKey().toAccountId().value, + ) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/newApp/MigrationSubstrateLedgerApplication.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/newApp/MigrationSubstrateLedgerApplication.kt new file mode 100644 index 0000000..349afff --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/newApp/MigrationSubstrateLedgerApplication.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.newApp + +import io.novafoundation.nova.feature_ledger_api.data.repository.LedgerRepository +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerEvmAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateApplicationConfig +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.transport.LedgerTransport +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.buildDerivationPath +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.getConfig +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class MigrationSubstrateLedgerApplication( + transport: LedgerTransport, + metadataShortenerService: MetadataShortenerService, + private val chainRegistry: ChainRegistry, + private val ledgerRepository: LedgerRepository, + private val legacyApplicationConfigs: List = SubstrateApplicationConfig.all() +) : NewSubstrateLedgerApplication(transport, metadataShortenerService, chainRegistry) { + + override val cla: UByte = GenericSubstrateLedgerApplication.CLA + + override suspend fun getDerivationPath(chainId: ChainId, accountIndex: Int): String { + val applicationConfig = legacyApplicationConfigs.getConfig(chainId) + + return buildDerivationPath(applicationConfig.coin, accountIndex) + } + + override suspend fun getAddressPrefix(chainId: ChainId): Short { + val chain = chainRegistry.getChain(chainId) + + return chain.addressPrefix.toShort() + } + + override suspend fun getDerivationPath(metaId: Long, chainId: ChainId): String { + return ledgerRepository.getChainAccountDerivationPath(metaId, chainId) + } + + override suspend fun getEvmAccount(device: LedgerDevice, accountIndex: Int, confirmAddress: Boolean): LedgerEvmAccount? { + return null + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/newApp/NewSubstrateLedgerApplication.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/newApp/NewSubstrateLedgerApplication.kt new file mode 100644 index 0000000..ce2503e --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/application/substrate/newApp/NewSubstrateLedgerApplication.kt @@ -0,0 +1,144 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.newApp + +import android.util.Log +import io.novafoundation.nova.common.utils.chunked +import io.novafoundation.nova.common.utils.littleEndianBytes +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.LedgerSubstrateAccount +import io.novafoundation.nova.feature_ledger_api.sdk.application.substrate.SubstrateLedgerApplication +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.transport.LedgerTransport +import io.novafoundation.nova.feature_ledger_api.sdk.transport.send +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.CHUNK_SIZE +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.CryptoScheme +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.defaultCryptoScheme +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.encodeDerivationPath +import io.novafoundation.nova.feature_ledger_impl.sdk.application.substrate.SubstrateLedgerAppCommon.parseSubstrateAccountResponse +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication + +abstract class NewSubstrateLedgerApplication( + private val transport: LedgerTransport, + private val metadataShortenerService: MetadataShortenerService, + private val chainRegistry: ChainRegistry, +) : SubstrateLedgerApplication { + + abstract val cla: UByte + + abstract suspend fun getDerivationPath(chainId: ChainId, accountIndex: Int): String + + abstract suspend fun getDerivationPath(metaId: Long, chainId: ChainId): String + + abstract suspend fun getAddressPrefix(chainId: ChainId): Short + + override suspend fun getSubstrateAccount(device: LedgerDevice, chainId: ChainId, accountIndex: Int, confirmAddress: Boolean): LedgerSubstrateAccount { + val displayVerificationDialog = SubstrateLedgerAppCommon.DisplayVerificationDialog.fromBoolean(confirmAddress) + + val derivationPath = getDerivationPath(chainId, accountIndex) + val encodedDerivationPath = encodeDerivationPath(derivationPath) + + val addressPrefix = getAddressPrefix(chainId) + + val payload = encodedDerivationPath + addressPrefix.littleEndianBytes + + val rawResponse = transport.send( + cla = cla, + ins = SubstrateLedgerAppCommon.Instruction.GET_ADDRESS.code, + p1 = displayVerificationDialog.code, + p2 = defaultCryptoScheme().code, + data = payload, + device = device + ) + + return parseSubstrateAccountResponse(rawResponse, derivationPath) + } + + override suspend fun getSignature( + device: LedgerDevice, + metaId: Long, + chainId: ChainId, + payload: InheritedImplication + ): SignatureWrapper { + val chain = chainRegistry.getChain(chainId) + + return if (chain.isEthereumBased) { + getEvmSignature(device, metaId, chainId, payload) + } else { + getSubstrateSignature(device, metaId, chainId, payload) + } + } + + private suspend fun getSubstrateSignature( + device: LedgerDevice, + metaId: Long, + chainId: ChainId, + payload: InheritedImplication + ): SignatureWrapper { + val multiSignature = sendSignChunks(device, metaId, chainId, payload, defaultCryptoScheme()) + return SubstrateLedgerAppCommon.parseMultiSignature(multiSignature) + } + + private suspend fun getEvmSignature( + device: LedgerDevice, + metaId: Long, + chainId: ChainId, + payload: InheritedImplication + ): SignatureWrapper { + val signature = sendSignChunks(device, metaId, chainId, payload, CryptoScheme.ECDSA) + return SubstrateLedgerAppCommon.parseSignature(signature, CryptoScheme.ECDSA) + } + + private suspend fun sendSignChunks( + device: LedgerDevice, + metaId: Long, + chainId: ChainId, + payload: InheritedImplication, + cryptoScheme: CryptoScheme + ): ByteArray { + val chunks = prepareExtrinsicChunks(metaId, chainId, payload) + + val results = chunks.mapIndexed { index, chunk -> + val chunkType = SubstrateLedgerAppCommon.SignPayloadType(index, chunks.size) + + val rawResponse = transport.send( + cla = cla, + ins = SubstrateLedgerAppCommon.Instruction.SIGN.code, + p1 = chunkType.code, + p2 = cryptoScheme.code, + data = chunk, + device = device + ) + + SubstrateLedgerAppCommon.processResponseCode(rawResponse) + } + + return results.last() + } + + private suspend fun prepareExtrinsicChunks( + metaId: Long, + chainId: ChainId, + payload: InheritedImplication + ): List { + val payloadBytes = payload.encoded() + + val derivationPath = getDerivationPath(metaId, chainId) + val encodedDerivationPath = encodeDerivationPath(derivationPath) + + val encodedTxPayloadLength = payloadBytes.size.toShort().littleEndianBytes + + val extrinsicProof = metadataShortenerService.generateExtrinsicProof(payload).value + + val wholePayload = payloadBytes + extrinsicProof + + Log.d("Ledger", "Whole payload size: ${wholePayload.size}, metadata proof size: ${extrinsicProof.size}") + + val firstChunk = encodedDerivationPath + encodedTxPayloadLength + val nextChunks = wholePayload.chunked(CHUNK_SIZE) + + return listOf(firstChunk) + nextChunks + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/BaseLedgerConnection.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/BaseLedgerConnection.kt new file mode 100644 index 0000000..9ce2c9c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/BaseLedgerConnection.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.connection + +import io.novafoundation.nova.feature_ledger_api.sdk.connection.LedgerConnection +import kotlinx.coroutines.channels.Channel + +abstract class BaseLedgerConnection : LedgerConnection { + + @Volatile + private var _receiveChannel = newChannel() + private val receiveChannelLock = Any() + + override val receiveChannel + get() = synchronized(receiveChannelLock) { _receiveChannel } + + override suspend fun resetReceiveChannel() = synchronized(receiveChannelLock) { + _receiveChannel.close() + _receiveChannel = newChannel() + } + + private fun newChannel() = Channel(Channel.BUFFERED) +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/ble/BleConnection.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/ble/BleConnection.kt new file mode 100644 index 0000000..bbf61cc --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/ble/BleConnection.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.connection.ble + +import android.bluetooth.BluetoothDevice +import android.util.Log +import io.novafoundation.nova.feature_ledger_api.sdk.connection.LedgerConnection +import io.novafoundation.nova.feature_ledger_api.sdk.connection.awaitConnected +import io.novafoundation.nova.feature_ledger_impl.sdk.connection.BaseLedgerConnection +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import no.nordicsemi.android.ble.callback.DataReceivedCallback +import no.nordicsemi.android.ble.data.Data +import no.nordicsemi.android.ble.ktx.state.ConnectionState +import no.nordicsemi.android.ble.ktx.stateAsFlow +import no.nordicsemi.android.ble.ktx.suspend + +class BleConnection( + private val bleManager: LedgerBleManager, + private val bluetoothDevice: BluetoothDevice, +) : BaseLedgerConnection(), DataReceivedCallback { + + override val channel: Short? = null + + override suspend fun connect(): Result = runCatching { + bleManager.connect(bluetoothDevice).suspend() + + bleManager.readCallback = this + + awaitConnected() + } + + override val type: LedgerConnection.Type = LedgerConnection.Type.BLE + + override val isActive: Flow + get() = bleManager.stateAsFlow() + .map { it == ConnectionState.Ready } + + override suspend fun mtu(): Int { + ensureCorrectDevice() + + return bleManager.deviceMtu + } + + override suspend fun send(chunks: List) { + ensureCorrectDevice() + + bleManager.send(chunks) + } + + override fun onDataReceived(device: BluetoothDevice, data: Data) { + ensureCorrectDevice() + + data.value?.let { + Log.w("Ledger", "Read non empty bytes from usb: ${it.joinToString()}") + } + + data.value?.let(receiveChannel::trySend) + } + + private fun ensureCorrectDevice() = require(bleManager.bluetoothDevice?.address == bluetoothDevice.address) { + "Wrong device connected" + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/ble/LedgerBleManager.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/ble/LedgerBleManager.kt new file mode 100644 index 0000000..76d7f78 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/ble/LedgerBleManager.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.connection.ble + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.BleDevice +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDeviceType +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull +import no.nordicsemi.android.ble.BleManager +import no.nordicsemi.android.ble.callback.DataReceivedCallback +import no.nordicsemi.android.ble.data.Data +import no.nordicsemi.android.ble.ktx.suspend + +private const val DEFAULT_MTU = 512 +private const val MTU_RESERVED_BYTES = 3 + +class LedgerBleManager( + contextManager: ContextManager +) : BleManager(contextManager.getApplicationContext()), DataReceivedCallback { + + companion object { + fun getSupportedLedgerDevicesInfo(): List { + return LedgerDeviceType.entries + .mapNotNull { it.bleDevice as? BleDevice.Supported } + .flatMap { it.specs.toList() } + } + } + + private var characteristicWrite: BluetoothGattCharacteristic? = null + private var characteristicNotify: BluetoothGattCharacteristic? = null + + var readCallback: DataReceivedCallback? = null + + // 3 bytes are used for internal purposes, so the maximum size is MTU-3. + val deviceMtu + get() = mtu - MTU_RESERVED_BYTES + + override fun getGattCallback(): BleManagerGattCallback { + return object : BleManagerGattCallback() { + + override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean { + val (gattService, supportedDevice) = getSupportedLedgerDevicesInfo().tryFindNonNull { device -> + gatt.getService(device.serviceUuid)?.let { it to device } + } ?: return false + + characteristicWrite = gattService.getCharacteristic(supportedDevice.writeUuid) + characteristicNotify = gattService.getCharacteristic(supportedDevice.notifyUuid) + + return characteristicWrite != null && characteristicNotify != null + } + + override fun onServicesInvalidated() { + characteristicWrite = null + characteristicNotify = null + } + + override fun initialize() { + beginAtomicRequestQueue() + .add(requestMtu(DEFAULT_MTU)) + .add(enableNotifications(characteristicNotify)) + .enqueue() + setNotificationCallback(characteristicNotify) + .with(this@LedgerBleManager) + } + } + } + + suspend fun send(chunks: List) { + beginAtomicRequestQueue().apply { + chunks.forEach { chunk -> + add(writeCharacteristic(characteristicWrite, chunk, WRITE_TYPE_DEFAULT)) + } + } + .suspend() + } + + override fun onDataReceived(device: BluetoothDevice, data: Data) { + readCallback?.onDataReceived(device, data) + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/usb/UsbLedgerConnection.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/usb/UsbLedgerConnection.kt new file mode 100644 index 0000000..86fb8c7 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/connection/usb/UsbLedgerConnection.kt @@ -0,0 +1,161 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.connection.usb + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.util.Log +import io.novafoundation.nova.feature_ledger_api.sdk.connection.LedgerConnection +import io.novafoundation.nova.feature_ledger_api.sdk.connection.awaitConnected +import io.novafoundation.nova.feature_ledger_impl.sdk.connection.BaseLedgerConnection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class UsbLedgerConnection( + private val appContext: Context, + private val device: UsbDevice, + coroutineScope: CoroutineScope +) : BaseLedgerConnection(), CoroutineScope by coroutineScope { + + companion object { + + const val ACTION_USB_PERMISSION = "io.novafoundation.nova.USB_PERMISSION" + + val PERMISSIONS_GRANTED_SIGNAL = MutableSharedFlow(extraBufferCapacity = 1) + } + + override val type: LedgerConnection.Type = LedgerConnection.Type.USB + + override val isActive = MutableStateFlow(false) + private val usbManager: UsbManager = appContext.getSystemService(Context.USB_SERVICE) as UsbManager + + private var usbConnection: UsbDeviceConnection? = null + + private var usbInterface: UsbInterface? = null + + private var endpointOut: UsbEndpoint? = null + private var endpointIn: UsbEndpoint? = null + private val sendingMutex = Mutex() + + override val channel: Short = 1 + + init { + PERMISSIONS_GRANTED_SIGNAL + .onEach { onPermissionGranted() } + .launchIn(coroutineScope) + } + + override suspend fun mtu(): Int { + return 64 + } + + override suspend fun send(chunks: List) = sendingMutex.withLock { + val endpoint = endpointOut + val connection = usbConnection + + require(endpoint != null && connection != null) { + "Not connected" + } + + for (chunk in chunks) { + Log.d("Ledger", "Attempting to send chunk of size ${chunk.size} over usb") + val result = connection.bulkTransfer(endpoint, chunk, chunk.size, 10000) + if (result < 0) { + Log.e("Ledger", "Failed to send bytes over usb: $result") + } else { + Log.d("Ledger", "Successfully sent $result bytes to Ledger") + } + } + + var somethingRead: Boolean = false + + while (true) { + val responseBuffer = ByteArray(mtu()) + val result = usbConnection!!.bulkTransfer(endpointIn!!, responseBuffer, responseBuffer.size, 50) + + when { + result > 0 -> { + Log.d("Ledger", "Read non empty bytes from usb: ${responseBuffer.joinToString()}") + receiveChannel.trySend(responseBuffer.copyOf(result)) + somethingRead = true + } + somethingRead -> { + Log.d("Ledger", "Read empty bytes, stopping polling") + break + } + else -> { + delay(50) + Log.d("Ledger", "Read empty bytes, waiting for at least one response packet") + } + } + } + } + + override suspend fun connect(): Result = runCatching { + val intent = Intent(ACTION_USB_PERMISSION).apply { + setClass(appContext, UsbPermissionReceiver::class.java) + } + + val permissionIntent = PendingIntent.getBroadcast(appContext, 0, intent, PendingIntent.FLAG_MUTABLE) + usbManager.requestPermission(device, permissionIntent) + + awaitConnected() + } + + private fun onPermissionGranted() { + val usbIntf = device.getInterface(0) + usbInterface = usbIntf + usbConnection = usbManager.openDevice(device) + val claimed = usbConnection!!.claimInterface(usbInterface, true) + if (!claimed) { + throw Exception("Failed to claim interface") + } + + Log.d("Ledger", "Endpoints count: ${usbIntf.endpointCount}") + for (i in 0 until usbIntf.endpointCount) { + val tmpEndpoint: UsbEndpoint = usbIntf.getEndpoint(i) + if (tmpEndpoint.direction == UsbConstants.USB_DIR_IN) { + endpointIn = tmpEndpoint + } else { + endpointOut = tmpEndpoint + } + } + + if (endpointIn == null) { + throw Exception("Failed to find in endpoint") + } + + if (endpointOut == null) { + throw Exception("Failed to find out endpoint") + } + + isActive.value = true + } + + class UsbPermissionReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == ACTION_USB_PERMISSION) { + synchronized(this) { + val granted: Boolean = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + + if (granted) { + PERMISSIONS_GRANTED_SIGNAL.tryEmit(Unit) + } + } + } + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/CompoundLedgerDiscoveryService.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/CompoundLedgerDiscoveryService.kt new file mode 100644 index 0000000..78b5a7c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/CompoundLedgerDiscoveryService.kt @@ -0,0 +1,148 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.discovery + +import android.util.Log +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.LedgerDeviceDiscoveryService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.merge +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods.Method as DiscoveryMethod + +class CompoundLedgerDiscoveryService( + private vararg val delegates: LedgerDeviceDiscoveryServiceDelegate +) : LedgerDeviceDiscoveryService { + + private var discoveringSubscribersTracker = DiscoveringSubscribersTracker() + + override val discoveredDevices: Flow> by lazy { + discoveringSubscribersTracker.subscribedMethods.flatMapLatest { subscribedMethods -> + Log.d("Ledger", "Subscribed discovery methods: $subscribedMethods") + val devicesFromSubscribedMethodsFlows = delegates.filter { it.method in subscribedMethods } + .map { it.discoveredDevices } + + combine(devicesFromSubscribedMethodsFlows) { devicesFromSubscribedMethods -> + devicesFromSubscribedMethods.flatMap { it } + } + } + } + + override val errors: Flow by lazy { + delegates.map(LedgerDeviceDiscoveryServiceDelegate::errors).merge() + } + + override fun startDiscovery(methods: Set) { + discoveringSubscribersTracker.withTransaction { + methods.forEach { method -> + if (discoveringSubscribersTracker.noSubscribers(method)) { + getDelegate(method).startDiscovery() + } + + discoveringSubscribersTracker.addSubscriber(method) + } + } + } + + override fun stopDiscovery(methods: Set) { + discoveringSubscribersTracker.withTransaction { + methods.forEach { method -> + val delegate = getDelegate(method) + stopDiscovery(delegate) + } + } + } + + override fun stopDiscovery() { + discoveringSubscribersTracker.withTransaction { + delegates.forEach(::stopDiscovery) + } + } + + private fun stopDiscovery(delegate: LedgerDeviceDiscoveryServiceDelegate) { + val method = delegate.method + + val subscriberRemoved = discoveringSubscribersTracker.removeSubscriber(method) + + if (subscriberRemoved && discoveringSubscribersTracker.noSubscribers(method)) { + getDelegate(method).stopDiscovery() + } + } + + private fun getDelegate(method: DiscoveryMethod) = delegates.first { it.method == method } +} + +private class DiscoveringSubscribersTracker { + + private var subscribersByMethod = mutableMapOf().withDefault { 0 } + + private val _subscribedMethods = MutableStateFlow(emptySet()) + val subscribedMethods = _subscribedMethods.asStateFlow() + + private var txInProgress: Boolean = false + + /** + * During the transaction, no values will be emitted to `subscribedMethods` + */ + fun beginTransaction() { + require(!txInProgress) { "Nested transactions are not supported" } + + txInProgress = true + } + + /** + * Commits the currently present transaction + */ + fun commitTransaction() { + require(txInProgress) { "Transaction not strated" } + + txInProgress = false + emitNewEnabledValue() + } + + fun addSubscriber(method: DiscoveryMethod) { + subscribersByMethod[method] = subscribersByMethod.getValue(method) + 1 + emitNewEnabledValue() + } + + /** + * Reduces subscriber counter by 1 + * @return true if counter was reduced. False if counter was already zero + */ + fun removeSubscriber(method: DiscoveryMethod): Boolean { + val subscribers = subscribersByMethod.getValue(method) + if (subscribers == 0) return false + + val newSubscribers = subscribers - 1 + if (newSubscribers == 0) { + subscribersByMethod.remove(method) + } else { + subscribersByMethod[method] = newSubscribers + } + + emitNewEnabledValue() + return true + } + + fun noSubscribers(method: DiscoveryMethod): Boolean { + return subscribersByMethod.getValue(method) == 0 + } + + private fun emitNewEnabledValue() { + if (!txInProgress) return + + // Using `toSet()` here as StateFlow would not notify subscribers for the same object passed multiple times + _subscribedMethods.value = subscribersByMethod.keys.toSet() + } +} + +private inline fun DiscoveringSubscribersTracker.withTransaction(block: DiscoveringSubscribersTracker.() -> Unit) { + beginTransaction() + + try { + block() + } finally { + commitTransaction() + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/LedgerDeviceDiscoveryServiceDelegate.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/LedgerDeviceDiscoveryServiceDelegate.kt new file mode 100644 index 0000000..126da86 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/LedgerDeviceDiscoveryServiceDelegate.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.discovery + +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface LedgerDeviceDiscoveryServiceDelegate { + + val method: DiscoveryMethods.Method + + val discoveredDevices: StateFlow> + + val errors: Flow + + fun startDiscovery() + + fun stopDiscovery() +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/ble/BleLedgerDeviceDiscoveryService.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/ble/BleLedgerDeviceDiscoveryService.kt new file mode 100644 index 0000000..8cb90ec --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/ble/BleLedgerDeviceDiscoveryService.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.discovery.ble + +import android.annotation.SuppressLint +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.os.ParcelUuid +import io.novafoundation.nova.common.utils.bluetooth.BluetoothManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDeviceType +import io.novafoundation.nova.feature_ledger_api.sdk.device.supportedBleSpecs +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods +import io.novafoundation.nova.feature_ledger_impl.sdk.connection.ble.BleConnection +import io.novafoundation.nova.feature_ledger_impl.sdk.connection.ble.LedgerBleManager +import io.novafoundation.nova.feature_ledger_impl.sdk.discovery.LedgerDeviceDiscoveryServiceDelegate +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow + +class BleScanFailed(val errorCode: Int) : Throwable() + +@SuppressLint("MissingPermission") +class BleLedgerDeviceDiscoveryService( + private val bluetoothManager: BluetoothManager, + private val ledgerBleManager: LedgerBleManager, +) : LedgerDeviceDiscoveryServiceDelegate { + + override val method = DiscoveryMethods.Method.BLE + + override val discoveredDevices = MutableStateFlow(emptyList()) + override val errors = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private var scanCallback: ScanCallback? = null + + override fun startDiscovery() { + val scanFilters = LedgerBleManager.getSupportedLedgerDevicesInfo().map { + ScanFilter.Builder() + .setServiceUuid(ParcelUuid(it.serviceUuid)) + .build() + } + val scanSettings = ScanSettings.Builder().build() + scanCallback = LedgerScanCallback() + + bluetoothManager.startBleScan(scanFilters, scanSettings, scanCallback!!) + } + + override fun stopDiscovery() { + scanCallback?.let(bluetoothManager::stopBleScan) + } + + private inner class LedgerScanCallback : ScanCallback() { + + override fun onScanResult(callbackType: Int, result: ScanResult) { + val alreadyFound = discoveredDevices.value.any { it.id == result.device.address } + val ledgerDeviceType = result.getLedgerDeviceType() + if (alreadyFound || ledgerDeviceType == null) return + + val connection = BleConnection( + bleManager = ledgerBleManager, + bluetoothDevice = result.device + ) + + val device = LedgerDevice( + id = result.device.address, + deviceType = ledgerDeviceType, + name = result.device.name ?: result.device.address, + connection = connection + ) + + discoveredDevices.value += device + } + + override fun onScanFailed(errorCode: Int) { + errors.tryEmit(BleScanFailed(errorCode)) + } + + private fun ScanResult.getLedgerDeviceType(): LedgerDeviceType? { + val searchingServiceUUIDs = this.scanRecord?.serviceUuids + .orEmpty() + .map { it.uuid } + .toSet() + + return LedgerDeviceType.entries.find { + val supportedServicesIds = it.supportedBleSpecs().map { it.serviceUuid }.toSet() + supportedServicesIds.intersect(searchingServiceUUIDs).isNotEmpty() + } + } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/usb/UsbLedgerDeviceDiscoveryService.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/usb/UsbLedgerDeviceDiscoveryService.kt new file mode 100644 index 0000000..35c5328 --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/discovery/usb/UsbLedgerDeviceDiscoveryService.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.discovery.usb + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.util.Log +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDeviceType +import io.novafoundation.nova.feature_ledger_api.sdk.discovery.DiscoveryMethods +import io.novafoundation.nova.feature_ledger_impl.sdk.connection.usb.UsbLedgerConnection +import io.novafoundation.nova.feature_ledger_impl.sdk.discovery.LedgerDeviceDiscoveryServiceDelegate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.coroutines.EmptyCoroutineContext + +class UsbLedgerDeviceDiscoveryService( + private val contextManager: ContextManager +) : LedgerDeviceDiscoveryServiceDelegate { + + private val appContext = contextManager.getApplicationContext() + + override val method = DiscoveryMethods.Method.USB + + override val discoveredDevices = MutableStateFlow(emptyList()) + override val errors = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private val usbManager: UsbManager = appContext.getSystemService(Context.USB_SERVICE) as UsbManager + + private var coroutineScope = CoroutineScope(EmptyCoroutineContext) + + private val usbReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + val device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + device?.let { discoverDevices() } + } + + UsbManager.ACTION_USB_DEVICE_DETACHED -> { + val device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) + device?.let { discoverDevices() } + } + } + } + } + + override fun startDiscovery() { + val filter = IntentFilter().apply { + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + appContext.registerReceiver(usbReceiver, filter) + discoverDevices() + } + + override fun stopDiscovery() { + appContext.unregisterReceiver(usbReceiver) + + coroutineScope.coroutineContext.cancelChildren() + } + + private fun discoverDevices() { + try { + val devices = usbManager.deviceList + .values.mapNotNull { createLedgerDeviceIfSupported(it) } + discoveredDevices.tryEmit(devices) + } catch (e: Exception) { + errors.tryEmit(e) + } + } + + private fun createLedgerDeviceIfSupported(usbDevice: UsbDevice): LedgerDevice? { + val ledgerDeviceType = usbDevice.getLedgerDeviceType() ?: return null + val id = "${usbDevice.vendorId}:${usbDevice.productId}" + val connection = UsbLedgerConnection(appContext, usbDevice, coroutineScope) + + return LedgerDevice(id, ledgerDeviceType, null, connection = connection) + } + + private fun UsbDevice.getLedgerDeviceType(): LedgerDeviceType? { + val searchingVendorId = this.vendorId + val searchingProductId = this.productId + + Log.d("Ledger", "Found productId: $searchingProductId") + + return LedgerDeviceType.values() + .firstOrNull { it.usbOptions.vendorId == searchingVendorId && it.usbOptions.productId == searchingProductId } + } +} diff --git a/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/transport/ChunkedLedgerTransport.kt b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/transport/ChunkedLedgerTransport.kt new file mode 100644 index 0000000..9318f8c --- /dev/null +++ b/feature-ledger-impl/src/main/java/io/novafoundation/nova/feature_ledger_impl/sdk/transport/ChunkedLedgerTransport.kt @@ -0,0 +1,155 @@ +package io.novafoundation.nova.feature_ledger_impl.sdk.transport + +import io.novafoundation.nova.common.utils.bigEndianBytes +import io.novafoundation.nova.common.utils.buildByteArray +import io.novafoundation.nova.common.utils.dropBytes +import io.novafoundation.nova.common.utils.toBigEndianU16 +import io.novafoundation.nova.feature_ledger_api.sdk.connection.LedgerConnection +import io.novafoundation.nova.feature_ledger_api.sdk.connection.ensureConnected +import io.novafoundation.nova.feature_ledger_api.sdk.device.LedgerDevice +import io.novafoundation.nova.feature_ledger_api.sdk.transport.LedgerTransport +import io.novafoundation.nova.feature_ledger_api.sdk.transport.LedgerTransportError +import io.novafoundation.nova.feature_ledger_api.sdk.transport.LedgerTransportError.Reason +import io.novasama.substrate_sdk_android.encrypt.json.copyBytes +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.math.min + +private const val DATA_TAG_ID: Byte = 0x05 + +private const val CHANNEL_LENGTH = 2 +private const val PACKET_INDEX_LENGTH = 2 +private const val MESSAGE_SIZE_LENGTH = 2 +private const val HEADER_MIN_SIZE_NO_CHANNEL = 1 + PACKET_INDEX_LENGTH +private const val HEADER_MIN_SIZE_CHANNEL = CHANNEL_LENGTH + HEADER_MIN_SIZE_NO_CHANNEL + +private class ReceivedChunk(val content: ByteArray, val total: Int?) + +class ChunkedLedgerTransport : LedgerTransport { + + // one request at a time + private val exchangeMutex = Mutex() + + override suspend fun exchange(data: ByteArray, device: LedgerDevice): ByteArray = exchangeMutex.withLock { + device.connection.ensureConnected() + device.connection.resetReceiveChannel() + + val mtu = device.connection.mtu() + val channel = device.connection.channel + + val chunks = buildRequestChunks(data, mtu, channel) + device.connection.send(chunks) + + readChunkedResponse(device.connection) + } + + private fun require(condition: Boolean, errorReason: Reason) { + if (!condition) { + throw LedgerTransportError(errorReason) + } + } + + private fun buildRequestChunks( + data: ByteArray, + mtu: Int, + channel: Short? + ): List { + val chunks = mutableListOf() + val totalLength = data.size + var offset = 0 + + while (offset < totalLength) { + val currentIndex = chunks.size + val isFirst = currentIndex == 0 + + val chunk = buildByteArray { stream -> + channel?.let { + stream.write(channel.toShort().bigEndianBytes) + } + + stream.write(byteArrayOf(DATA_TAG_ID)) + stream.write(currentIndex.toShort().bigEndianBytes) + + if (isFirst) { + stream.write(totalLength.toShort().bigEndianBytes) + } + + val remainingPacketSize = mtu - stream.size() + + if (remainingPacketSize > 0) { + val remainingMessageSize = totalLength - offset + val packetSize = min(remainingPacketSize, remainingMessageSize) + val packetBytes = data.copyBytes(from = offset, size = packetSize) + + stream.write(packetBytes) + offset += packetSize + } + } + + chunks += chunk + } + + return chunks + } + + private suspend fun readChunkedResponse(connection: LedgerConnection): ByteArray { + var result = ByteArray(0) + + val hasChannel = connection.channel != null + + val headerRaw = connection.receiveChannel.receive() + val headerChunk = parseReceivedChunk(headerRaw, hasChannel, readMax = null) + val total = headerChunk.total + result += headerChunk.content + require(total != null, Reason.NO_HEADER_FOUND) + + while (result.size < total!!) { + val raw = connection.receiveChannel.receive() + val readMax = total - result.size + val chunk = parseReceivedChunk(raw, hasChannel, readMax) + + require(chunk.total == null, Reason.INCOMPLETE_RESPONSE) + + result += chunk.content + } + + return result + } + + private fun parseReceivedChunk( + raw: ByteArray, + hasChannel: Boolean, + readMax: Int? + ): ReceivedChunk { + require(raw.size >= headerSize(hasChannel), Reason.NO_HEADER_FOUND) + + var remainedData = raw + + if (hasChannel) { + remainedData = remainedData.dropBytes(CHANNEL_LENGTH) + } + + val tag = remainedData.first() + require(tag == DATA_TAG_ID, Reason.UNSUPPORTED_RESPONSE) + remainedData = remainedData.dropBytes(1) + + val packetIndex = remainedData.copyBytes(from = 0, size = PACKET_INDEX_LENGTH).toBigEndianU16() + remainedData = remainedData.dropBytes(PACKET_INDEX_LENGTH) + + return if (packetIndex == 0.toUShort()) { + require(remainedData.size >= MESSAGE_SIZE_LENGTH, Reason.NO_MESSAGE_SIZE_FOUND) + val messageSize = remainedData.copyBytes(from = 0, size = MESSAGE_SIZE_LENGTH).toBigEndianU16().toInt() + remainedData = remainedData.dropBytes(MESSAGE_SIZE_LENGTH) + + val content = remainedData.take(messageSize).toByteArray() + + ReceivedChunk(content, total = messageSize) + } else { + val content = remainedData.take(readMax!!).toByteArray() + + ReceivedChunk(content, total = null) + } + } + + private fun headerSize(hasChannel: Boolean) = if (hasChannel) HEADER_MIN_SIZE_CHANNEL else HEADER_MIN_SIZE_NO_CHANNEL +} diff --git a/feature-ledger-impl/src/main/res/layout/fragment_generic_import_ledger_start.xml b/feature-ledger-impl/src/main/res/layout/fragment_generic_import_ledger_start.xml new file mode 100644 index 0000000..972fa18 --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/fragment_generic_import_ledger_start.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/layout/fragment_import_ledger_fill_wallet.xml b/feature-ledger-impl/src/main/res/layout/fragment_import_ledger_fill_wallet.xml new file mode 100644 index 0000000..7a217b9 --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/fragment_import_ledger_fill_wallet.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/layout/fragment_import_ledger_select_address.xml b/feature-ledger-impl/src/main/res/layout/fragment_import_ledger_select_address.xml new file mode 100644 index 0000000..5ed9842 --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/fragment_import_ledger_select_address.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/layout/fragment_import_ledger_start.xml b/feature-ledger-impl/src/main/res/layout/fragment_import_ledger_start.xml new file mode 100644 index 0000000..8c3d3d1 --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/fragment_import_ledger_start.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/layout/fragment_ledger_message.xml b/feature-ledger-impl/src/main/res/layout/fragment_ledger_message.xml new file mode 100644 index 0000000..5c433ec --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/fragment_ledger_message.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/layout/fragment_select_ledger.xml b/feature-ledger-impl/src/main/res/layout/fragment_select_ledger.xml new file mode 100644 index 0000000..5e1006c --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/fragment_select_ledger.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/layout/item_import_ledger_start_page.xml b/feature-ledger-impl/src/main/res/layout/item_import_ledger_start_page.xml new file mode 100644 index 0000000..4185832 --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/item_import_ledger_start_page.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + diff --git a/feature-ledger-impl/src/main/res/layout/item_ledger.xml b/feature-ledger-impl/src/main/res/layout/item_ledger.xml new file mode 100644 index 0000000..5acf266 --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/item_ledger.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/layout/item_ledger_account.xml b/feature-ledger-impl/src/main/res/layout/item_ledger_account.xml new file mode 100644 index 0000000..58d02ba --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/item_ledger_account.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/layout/item_select_address_load_more.xml b/feature-ledger-impl/src/main/res/layout/item_select_address_load_more.xml new file mode 100644 index 0000000..48dd447 --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/item_select_address_load_more.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/layout/view_ledger_action.xml b/feature-ledger-impl/src/main/res/layout/view_ledger_action.xml new file mode 100644 index 0000000..2d18469 --- /dev/null +++ b/feature-ledger-impl/src/main/res/layout/view_ledger_action.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/feature-ledger-impl/src/main/res/values/attrs.xml b/feature-ledger-impl/src/main/res/values/attrs.xml new file mode 100644 index 0000000..c9d3fbc --- /dev/null +++ b/feature-ledger-impl/src/main/res/values/attrs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/feature-multisig/operations/.gitignore b/feature-multisig/operations/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-multisig/operations/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-multisig/operations/build.gradle b/feature-multisig/operations/build.gradle new file mode 100644 index 0000000..47a5246 --- /dev/null +++ b/feature-multisig/operations/build.gradle @@ -0,0 +1,57 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_multisig_operations' + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':common') + implementation project(':runtime') + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(':feature-deep-linking') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation cardViewDep + implementation constraintDep + + implementation substrateSdkDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation daggerDep + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation shimmerDep + + implementation gsonDep + + implementation insetterDep + + testImplementation project(":test-shared") +} \ No newline at end of file diff --git a/feature-multisig/operations/consumer-rules.pro b/feature-multisig/operations/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-multisig/operations/proguard-rules.pro b/feature-multisig/operations/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-multisig/operations/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-multisig/operations/src/main/AndroidManifest.xml b/feature-multisig/operations/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-multisig/operations/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureApi.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureApi.kt new file mode 100644 index 0000000..f1ba58c --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureApi.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_multisig_operations.di + +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator +import io.novafoundation.nova.feature_multisig_operations.di.deeplink.MultisigDeepLinks +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter + +interface MultisigOperationsFeatureApi { + + val multisigDeepLinks: MultisigDeepLinks + + val multisigOperationDeepLinkConfigurator: MultisigOperationDeepLinkConfigurator + + val multisigCallFormatter: MultisigCallFormatter +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureComponent.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureComponent.kt new file mode 100644 index 0000000..e0a6691 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureComponent.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_multisig_operations.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.deeplink.DeepLinkModule +import io.novafoundation.nova.feature_multisig_operations.presentation.created.di.MultisigCreatedComponent +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.full.di.MultisigOperationFullDetailsComponent +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di.MultisigOperationDetailsComponent +import io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.di.MultisigOperationEnterCallComponent +import io.novafoundation.nova.feature_multisig_operations.presentation.list.di.MultisigPendingOperationsComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + MultisigOperationsFeatureDependencies::class, + ], + modules = [ + MultisigOperationsFeatureModule::class, + DeepLinkModule::class + ] +) +@FeatureScope +interface MultisigOperationsFeatureComponent : MultisigOperationsFeatureApi { + + fun multisigPendingOperations(): MultisigPendingOperationsComponent.Factory + + fun multisigOperationDetails(): MultisigOperationDetailsComponent.Factory + + fun multisigOperationFullDetails(): MultisigOperationFullDetailsComponent.Factory + + fun multisigOperationEnterCall(): MultisigOperationEnterCallComponent.Factory + + fun multisigCreated(): MultisigCreatedComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: MultisigOperationsRouter, + deps: MultisigOperationsFeatureDependencies + ): MultisigOperationsFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + DbApi::class, + WalletFeatureApi::class, + AccountFeatureApi::class, + DeepLinkingFeatureApi::class + ] + ) + interface MultisigOperationsFeatureDependenciesComponent : MultisigOperationsFeatureDependencies +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureDependencies.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureDependencies.kt new file mode 100644 index 0000000..c12f516 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureDependencies.kt @@ -0,0 +1,130 @@ +package io.novafoundation.nova.feature_multisig_operations.di + +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher +import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalWithOnChainIdentity +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.di.ExtrinsicSerialization +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +interface MultisigOperationsFeatureDependencies { + + val tokenFormatter: TokenFormatter + + val amountFormatter: AmountFormatter + + val assetIconProvider: AssetIconProvider + + val extrinsicSplitter: ExtrinsicSplitter + + val accountRepository: AccountRepository + + val extrinsicService: ExtrinsicService + + val resourceManager: ResourceManager + + val multisigPendingOperationsService: MultisigPendingOperationsService + + val feeLoaderMixinFactory: FeeLoaderMixinV2.Factory + + val imageLoader: ImageLoader + + val externalActions: ExternalActions.Presentation + + val validationExecutor: ValidationExecutor + + val selectedAccountUseCase: SelectedAccountUseCase + + val walletUiUseCase: WalletUiUseCase + + val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + + val edValidationFactory: EnoughTotalToStayAboveEDValidationFactory + + val assetSourceRegistry: AssetSourceRegistry + + val multisigOperationLocalCallRepository: MultisigOperationLocalCallRepository + + val callTraversal: CallTraversal + + val addressIconGenerator: AddressIconGenerator + + val multisigFormatter: MultisigFormatter + + val proxyFormatter: ProxyFormatter + + val accountInteractor: AccountInteractor + + val arbitraryTokenUseCase: ArbitraryTokenUseCase + + val toggleFeatureRepository: ToggleFeatureRepository + + val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory + + val multisigValidationsRepository: MultisigValidationsRepository + + val tokenRepository: TokenRepository + + val chainRegistry: ChainRegistry + + val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher + + val copyTextLauncher: CopyTextLauncher.Presentation + + val accountUIUseCase: AccountUIUseCase + + val linkBuilderFactory: LinkBuilderFactory + + val automaticInteractionGate: AutomaticInteractionGate + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val multisigDetailsRepository: MultisigDetailsRepository + + fun dialogMessageManager(): DialogMessageManager + + @LocalIdentity + fun localIdentityProvider(): IdentityProvider + + @LocalWithOnChainIdentity + fun localWithOnChainIdentityProvider(): IdentityProvider + + @ExtrinsicSerialization + fun extrinsicGson(): Gson +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureHolder.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureHolder.kt new file mode 100644 index 0000000..4e653fa --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureHolder.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_multisig_operations.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class MultisigOperationsFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: MultisigOperationsRouter, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerMultisigOperationsFeatureComponent_MultisigOperationsFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java)) + .build() + + return DaggerMultisigOperationsFeatureComponent.factory() + .create( + router = router, + deps = accountFeatureDependencies + ) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureModule.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureModule.kt new file mode 100644 index 0000000..ea17d70 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/MultisigOperationsFeatureModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_multisig_operations.di + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureModule.BindsModule +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.MultisigActionFormatterDelegate +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.RealMultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.TransferMultisigActionFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.UtilityBatchesActionFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.SignatoryListFormatter + +@Module(includes = [BindsModule::class]) +class MultisigOperationsFeatureModule { + + @Module + internal interface BindsModule { + + @Binds + fun bindMultisigCallFormatter(real: RealMultisigCallFormatter): MultisigCallFormatter + + @Binds + @IntoSet + fun bindTransferCallFormatter(real: TransferMultisigActionFormatter): MultisigActionFormatterDelegate + + @Binds + @IntoSet + fun bindUtilityBatchCallFormatter(real: UtilityBatchesActionFormatter): MultisigActionFormatterDelegate + } + + @Provides + @FeatureScope + fun provideSignatoryListFormatter( + accountInteractor: AccountInteractor, + proxyFormatter: ProxyFormatter, + multisigFormatter: MultisigFormatter + ): SignatoryListFormatter { + return SignatoryListFormatter( + accountInteractor, + proxyFormatter, + multisigFormatter + ) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/deeplink/DeepLinkModule.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/deeplink/DeepLinkModule.kt new file mode 100644 index 0000000..a123330 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/deeplink/DeepLinkModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_multisig_operations.di.deeplink + +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.RealMultisigOperationDeepLinkConfigurator +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDetailsDeepLinkHandler +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideDeepLinkConfigurator( + linkBuilderFactory: LinkBuilderFactory + ): MultisigOperationDeepLinkConfigurator { + return RealMultisigOperationDeepLinkConfigurator(linkBuilderFactory) + } + + @Provides + @FeatureScope + fun provideMultisigOperationDetailsDeepLinkHandler( + router: MultisigOperationsRouter, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + automaticInteractionGate: AutomaticInteractionGate, + dialogMessageManager: DialogMessageManager, + multisigCallFormatter: MultisigCallFormatter, + ): MultisigOperationDetailsDeepLinkHandler { + return MultisigOperationDetailsDeepLinkHandler( + router, + accountRepository, + chainRegistry, + automaticInteractionGate, + dialogMessageManager, + multisigCallFormatter + ) + } + + @Provides + @FeatureScope + fun provideDeepLinks(operationDeepLink: MultisigOperationDetailsDeepLinkHandler): MultisigDeepLinks { + return MultisigDeepLinks(listOf(operationDeepLink)) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/deeplink/MultisigDeepLinks.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/deeplink/MultisigDeepLinks.kt new file mode 100644 index 0000000..5dbc223 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/di/deeplink/MultisigDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_multisig_operations.di.deeplink + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class MultisigDeepLinks(val deepLinkHandlers: List) diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/MultisigOperationDetailsInteractor.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/MultisigOperationDetailsInteractor.kt new file mode 100644 index 0000000..6f656e5 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/MultisigOperationDetailsInteractor.kt @@ -0,0 +1,247 @@ +package io.novafoundation.nova.feature_multisig_operations.domain.details + +import com.google.gson.Gson +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.data.repository.ToggleFeatureRepository +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.callHash +import io.novafoundation.nova.common.utils.toHex +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSplitter +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.composeMultisigAsMulti +import io.novafoundation.nova.feature_account_api.data.multisig.composeMultisigCancelAsMulti +import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigAction +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId +import io.novafoundation.nova.feature_account_api.data.multisig.model.userAction +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.SavedMultisigOperationCall +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_account_api.domain.multisig.intoCallHash +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.queryAccountBalanceCatching +import io.novafoundation.nova.runtime.di.ExtrinsicSerialization +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface MultisigOperationDetailsInteractor { + + suspend fun setCall(operation: PendingMultisigOperation, call: String) + + fun callDetails(call: GenericCall.Instance): String + + suspend fun callHash(call: GenericCall.Instance, chainId: ChainId): String + + suspend fun estimateActionFee(operation: PendingMultisigOperation): Fee? + + suspend fun performAction(operation: PendingMultisigOperation): Result + + fun signatoryFlow(signatoryMetaId: Long): Flow + + suspend fun getSignatoryBalance(signatory: MetaAccount, chain: Chain): Result + + fun isCallValid(operation: PendingMultisigOperation, enteredCall: String): Boolean + + fun setSkipRejectConfirmation(value: Boolean) + + fun getSkipRejectConfirmation(): Boolean + + suspend fun callDataAsString(call: GenericCall.Instance, chainId: ChainId): String + + suspend fun isOperationAvailable(operationId: PendingMultisigOperationId): Boolean +} + +private const val SKIP_REJECT_CONFIRMATION_KEY = "SKIP_REJECT_CONFIRMATION_KEY" + +@FeatureScope +class RealMultisigOperationDetailsInteractor @Inject constructor( + private val extrinsicService: ExtrinsicService, + private val extrinsicSplitter: ExtrinsicSplitter, + private val accountRepository: AccountRepository, + private val assetSourceRegistry: AssetSourceRegistry, + private val multisigOperationLocalCallRepository: MultisigOperationLocalCallRepository, + @ExtrinsicSerialization + private val extrinsicGson: Gson, + private val chainRegistry: ChainRegistry, + private val toggleFeatureRepository: ToggleFeatureRepository, + private val multisigDetailsRepository: MultisigDetailsRepository +) : MultisigOperationDetailsInteractor { + + override suspend fun setCall(operation: PendingMultisigOperation, call: String) { + val metaAccount = accountRepository.getSelectedMetaAccount() + multisigOperationLocalCallRepository.setMultisigCall( + SavedMultisigOperationCall( + metaId = metaAccount.id, + chainId = operation.chain.id, + callHash = operation.callHash.value, + callInstance = call + ) + ) + } + + override fun callDetails(call: GenericCall.Instance): String { + return extrinsicGson.toJson(call) + } + + override suspend fun callHash(call: GenericCall.Instance, chainId: ChainId): String { + val runtime = chainRegistry.getRuntime(chainId) + return call.callHash(runtime).toHexString(withPrefix = true) + } + + override suspend fun callDataAsString(call: GenericCall.Instance, chainId: ChainId): String { + val runtime = chainRegistry.getRuntime(chainId) + return call.toHex(runtime) + } + + override suspend fun isOperationAvailable(operationId: PendingMultisigOperationId): Boolean { + val chain = chainRegistry.getChain(operationId.chainId) + val metaAccount = accountRepository.getMetaAccount(operationId.metaId) + val callHash = operationId.callHash.intoCallHash() + return multisigDetailsRepository.hasMultisigOperation(chain, metaAccount.requireAccountIdKeyIn(chain), callHash) + } + + override suspend fun estimateActionFee(operation: PendingMultisigOperation): Fee? { + val action = operation.userAction().toInternalAction() ?: return null + + return when (action) { + Action.APPROVE -> estimateApproveFee(operation) + Action.REJECT -> estimateRejectFee(operation) + } + } + + override suspend fun performAction(operation: PendingMultisigOperation): Result { + val action = operation.userAction().toInternalAction() ?: return Result.failure(IllegalStateException("No action found")) + + return when (action) { + Action.APPROVE -> performApprove(operation) + Action.REJECT -> performReject(operation) + } + } + + override fun signatoryFlow(signatoryMetaId: Long): Flow { + return accountRepository.metaAccountFlow(signatoryMetaId) + } + + override suspend fun getSignatoryBalance(signatory: MetaAccount, chain: Chain): Result { + val asset = chain.utilityAsset + val signatoryAccountId = signatory.requireAccountIdIn(chain) + return assetSourceRegistry.sourceFor(asset).balance.queryAccountBalanceCatching(chain, asset, signatoryAccountId) + } + + override fun isCallValid(operation: PendingMultisigOperation, enteredCall: String): Boolean = runCatching { + val operationHash = operation.callHash.value + val enteredHash = enteredCall.callHash() + + operationHash.contentEquals(enteredHash) + }.getOrDefault(false) + + override fun getSkipRejectConfirmation(): Boolean { + return toggleFeatureRepository.get(SKIP_REJECT_CONFIRMATION_KEY, false) + } + + override fun setSkipRejectConfirmation(value: Boolean) { + toggleFeatureRepository.set(SKIP_REJECT_CONFIRMATION_KEY, value) + } + + private suspend fun estimateApproveFee(operation: PendingMultisigOperation): Fee? { + if (operation.call == null) return null + + return extrinsicService.estimateFee( + chain = operation.chain, + origin = TransactionOrigin.WalletWithId(operation.signatoryMetaId) + ) { + // Use zero weight to speed up fee calculation + approve(operation, maxWeight = WeightV2.zero()) + } + } + + private suspend fun estimateRejectFee(operation: PendingMultisigOperation): Fee? { + return extrinsicService.estimateFee( + chain = operation.chain, + origin = TransactionOrigin.WalletWithId(operation.signatoryMetaId) + ) { + // Use zero weight to speed up fee calculation + reject(operation) + } + } + + private suspend fun performApprove(operation: PendingMultisigOperation): Result { + val call = operation.call + requireNotNull(call) { "Call data not found" } + + return extrinsicService.submitExtrinsicAndAwaitExecution( + chain = operation.chain, + origin = TransactionOrigin.WalletWithId(operation.signatoryMetaId) + ) { buildingContext -> + val weight = extrinsicSplitter.estimateCallWeight(buildingContext.signer, call, buildingContext.chain) + approve(operation, weight) + }.requireOk() + } + + private suspend fun performReject(operation: PendingMultisigOperation): Result { + return extrinsicService.submitExtrinsicAndAwaitExecution( + chain = operation.chain, + origin = TransactionOrigin.WalletWithId(operation.signatoryMetaId) + ) { + reject(operation) + }.requireOk() + } + + private suspend fun ExtrinsicBuilder.approve( + operation: PendingMultisigOperation, + maxWeight: WeightV2 + ) { + val selectedAccount = accountRepository.getSelectedMetaAccount() as MultisigMetaAccount + + val approveCall = runtime.composeMultisigAsMulti( + multisigMetaAccount = selectedAccount, + maybeTimePoint = operation.timePoint, + call = operation.call!!, + maxWeight = maxWeight + ) + + call(approveCall) + } + + private suspend fun ExtrinsicBuilder.reject(operation: PendingMultisigOperation) { + val selectedAccount = accountRepository.getSelectedMetaAccount() as MultisigMetaAccount + + val approveCall = runtime.composeMultisigCancelAsMulti( + multisigMetaAccount = selectedAccount, + maybeTimePoint = operation.timePoint, + callHash = operation.callHash + ) + + call(approveCall) + } + + private fun MultisigAction.toInternalAction(): Action? { + return when (this) { + is MultisigAction.CanApprove -> Action.APPROVE + is MultisigAction.CanReject -> Action.REJECT + is MultisigAction.Signed -> null + } + } + + private enum class Action { + APPROVE, REJECT + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/ApproveMultisigOperationValidationFailure.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/ApproveMultisigOperationValidationFailure.kt new file mode 100644 index 0000000..e07ffce --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/ApproveMultisigOperationValidationFailure.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_multisig_operations.domain.details.validations + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class ApproveMultisigOperationValidationFailure { + + class NotEnoughBalanceToPayFees( + val signatory: MetaAccount, + val chainAsset: Chain.Asset, + val minimumNeeded: BigDecimal, + val available: BigDecimal + ) : ApproveMultisigOperationValidationFailure() + + data object TransactionIsNotAvailable : ApproveMultisigOperationValidationFailure() +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/ApproveMultisigOperationValidationPayload.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/ApproveMultisigOperationValidationPayload.kt new file mode 100644 index 0000000..f8ee75b --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/ApproveMultisigOperationValidationPayload.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_multisig_operations.domain.details.validations + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ApproveMultisigOperationValidationPayload( + val fee: Fee, + val signatoryBalance: ChainAssetBalance, + val signatory: MetaAccount, + val operation: PendingMultisigOperation, + val multisig: MultisigMetaAccount +) + +val ApproveMultisigOperationValidationPayload.chain: Chain + get() = operation.chain + +val ApproveMultisigOperationValidationPayload.multisigAccountId: AccountIdKey + get() = multisig.requireAccountIdKeyIn(chain) diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/ApproveMultisigOperationValidationSystem.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/ApproveMultisigOperationValidationSystem.kt new file mode 100644 index 0000000..8f69e61 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/ApproveMultisigOperationValidationSystem.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_multisig_operations.domain.details.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.countedTowardsEdAmount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.transferableAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.validate +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +typealias ApproveMultisigOperationValidationSystem = ValidationSystem +typealias ApproveMultisigOperationValidation = Validation +typealias ApproveMultisigOperationValidationSystemBuilder = + ValidationSystemBuilder + +fun ValidationSystem.Companion.approveMultisigOperation( + edFactory: EnoughTotalToStayAboveEDValidationFactory, + operationStillPendingValidation: OperationIsStillPendingValidation, +): ApproveMultisigOperationValidationSystem = ValidationSystem { + enoughToPayFeesAndStayAboveEd(edFactory) + + enoughToPayFees() + + validate(operationStillPendingValidation) +} + +private fun ApproveMultisigOperationValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.signatoryBalance.transferableAmount() }, + error = { + ApproveMultisigOperationValidationFailure.NotEnoughBalanceToPayFees( + signatory = it.payload.signatory, + chainAsset = it.payload.signatoryBalance.chainAsset, + minimumNeeded = it.fee, + available = it.payload.signatoryBalance.transferableAmount() + ) + } + ) +} + +private fun ApproveMultisigOperationValidationSystemBuilder.enoughToPayFeesAndStayAboveEd(edFactory: EnoughTotalToStayAboveEDValidationFactory) { + edFactory.validate( + fee = { it.fee }, + balance = { it.signatoryBalance.countedTowardsEdAmount() }, + chainWithAsset = { ChainWithAsset(it.chain, it.signatoryBalance.chainAsset) }, + error = { payload, errorModel -> + ApproveMultisigOperationValidationFailure.NotEnoughBalanceToPayFees( + signatory = payload.signatory, + chainAsset = payload.signatoryBalance.chainAsset, + minimumNeeded = errorModel.minRequiredBalance, + available = errorModel.availableBalance + ) + } + ) +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/OperationIsStillPendingValidation.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/OperationIsStillPendingValidation.kt new file mode 100644 index 0000000..2980c0c --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/OperationIsStillPendingValidation.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_multisig_operations.domain.details.validations + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import javax.inject.Inject + +@FeatureScope +class OperationIsStillPendingValidation @Inject constructor( + private val multisigValidationsRepository: MultisigValidationsRepository +) : ApproveMultisigOperationValidation { + + override suspend fun validate(value: ApproveMultisigOperationValidationPayload): ValidationStatus { + val hasPendingCallHash = multisigValidationsRepository.hasPendingCallHash(value.chain.id, value.multisigAccountId, value.operation.callHash) + + return hasPendingCallHash isTrueOrError { + ApproveMultisigOperationValidationFailure.TransactionIsNotAvailable + } + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/MultisigOperationsRouter.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/MultisigOperationsRouter.kt new file mode 100644 index 0000000..a85e45a --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/MultisigOperationsRouter.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload + +interface MultisigOperationsRouter : ReturnableRouter { + + fun openPendingOperations() + + fun openMain() + + fun openMultisigOperationDetails(payload: MultisigOperationDetailsPayload) + + fun openMultisigFullDetails(payload: MultisigOperationPayload) + + fun openEnterCallDetails(payload: MultisigOperationPayload) +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallDetailsModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallDetailsModel.kt new file mode 100644 index 0000000..25e41a2 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallDetailsModel.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class MultisigCallDetailsModel( + val title: String, + val primaryAmount: AmountModel?, + val tableEntries: List, + val onBehalfOf: AddressModel? +) { + + class TableEntry( + val name: String, + val value: TableValue + ) + + sealed class TableValue { + + class Account(val addressModel: AddressModel, val chain: Chain) : TableValue() + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallFormatter.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallFormatter.kt new file mode 100644 index 0000000..2f322cf --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallFormatter.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +interface MultisigCallFormatter { + + suspend fun formatPreview( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + chain: Chain, + ): MultisigCallPreviewModel + + suspend fun formatDetails( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + chain: Chain + ): MultisigCallDetailsModel + + suspend fun formatPushNotificationMessage( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + chain: Chain + ): MultisigCallPushNotificationModel + + suspend fun formatExecutedOperationMessage( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + chain: Chain + ): String + + suspend fun formatRejectedOperationMessage( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + rejectedAccountName: String, + chain: Chain + ): String +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallNotificationModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallNotificationModel.kt new file mode 100644 index 0000000..10d6f79 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallNotificationModel.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting + +class MultisigCallNotificationModel(val message: String) diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallPreviewModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallPreviewModel.kt new file mode 100644 index 0000000..c272dcc --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallPreviewModel.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.utils.images.Icon + +data class MultisigCallPreviewModel( + val title: String, + val subtitle: String?, + val primaryValue: CharSequence?, + val icon: Icon, + val onBehalfOf: AddressModel? +) diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallPushNotificationModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallPushNotificationModel.kt new file mode 100644 index 0000000..0d7db35 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/MultisigCallPushNotificationModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting + +import io.novafoundation.nova.common.address.AddressModel + +class MultisigCallPushNotificationModel( + val formattedCall: String, + val onBehalfOf: AddressModel? +) diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/MultisigActionFormatterDelegate.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/MultisigActionFormatterDelegate.kt new file mode 100644 index 0000000..7993828 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/MultisigActionFormatterDelegate.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetIdWithAmount +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface MultisigActionFormatterDelegate { + + suspend fun formatPreview(visit: CallVisit, chain: Chain): MultisigActionFormatterDelegatePreviewResult? + + suspend fun formatDetails(visit: CallVisit, chain: Chain): MultisigActionFormatterDelegateDetailsResult? + + suspend fun formatMessageCall(visit: CallVisit, chain: Chain): String? +} + +class MultisigActionFormatterDelegatePreviewResult( + val title: String, + val subtitle: String?, + val primaryValue: CharSequence?, + val icon: Icon, +) + +class MultisigActionFormatterDelegateDetailsResult( + val title: String, + val primaryAmount: ChainAssetIdWithAmount?, + val tableEntries: List, +) { + + class TableEntry( + val name: String, + val value: TableValue + ) + + sealed class TableValue { + + class Account(val accountId: AccountIdKey, val chain: Chain) : TableValue() + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/RealMultisigCallFormatter.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/RealMultisigCallFormatter.kt new file mode 100644 index 0000000..3a6e3c0 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/RealMultisigCallFormatter.kt @@ -0,0 +1,306 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.capitalize +import io.novafoundation.nova.common.utils.images.asIcon +import io.novafoundation.nova.common.utils.splitAndCapitalizeWords +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallDetailsModel +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallPreviewModel +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallPushNotificationModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.TokenConfig +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountSign +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.collect +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import javax.inject.Inject + +@FeatureScope +class RealMultisigCallFormatter @Inject constructor( + private val delegates: Set<@JvmSuppressWildcards MultisigActionFormatterDelegate>, + private val resourceManager: ResourceManager, + private val callTraversal: CallTraversal, + @LocalIdentity private val identityProvider: IdentityProvider, + private val addressIconGenerator: AddressIconGenerator, + private val assetIconProvider: AssetIconProvider, + private val tokenUseCase: ArbitraryTokenUseCase, + private val amountFormatter: AmountFormatter +) : MultisigCallFormatter { + + override suspend fun formatPreview( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + chain: Chain + ): MultisigCallPreviewModel { + return formatCall( + call = call, + initialOrigin = initialOrigin, + chain = chain, + formatUnknown = ::formatUnknownPreview, + formatDefault = { formatDefaultPreview(it, chain) }, + formatSpecific = { delegate, callVisit -> delegate.formatPreview(callVisit, chain) }, + constructFinalResult = { delegateResult, onBehalfOf -> createCallPreview(delegateResult, onBehalfOf) } + ) + } + + override suspend fun formatDetails( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + chain: Chain + ): MultisigCallDetailsModel { + return formatCall( + call = call, + initialOrigin = initialOrigin, + chain = chain, + formatUnknown = ::formatUnknownDetails, + formatDefault = { formatDetails(it) }, + formatSpecific = { delegate, callVisit -> delegate.formatDetails(callVisit, chain) }, + constructFinalResult = { delegateResult, onBehalfOf -> createCallDetails(delegateResult, onBehalfOf) } + ) + } + + override suspend fun formatPushNotificationMessage( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + chain: Chain + ): MultisigCallPushNotificationModel { + return formatCall( + call = call, + initialOrigin = initialOrigin, + chain = chain, + formatUnknown = { formatPushNotification(chain, pushUnknownCall()) }, + formatDefault = { formatPushNotification(chain, it.format()) }, + formatSpecific = { delegate, callVisit -> delegate.formatMessageCall(callVisit, chain) }, + constructFinalResult = { delegateResult, onBehalfOf -> formatPushNotification(chain, delegateResult, onBehalfOf) } + ) + } + + override suspend fun formatExecutedOperationMessage( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + chain: Chain + ): String { + return formatCall( + call = call, + initialOrigin = initialOrigin, + chain = chain, + formatUnknown = { formatExecutedMessage(chain, dialogUnknownCall()) }, + formatDefault = { formatExecutedMessage(chain, it.format()) }, + formatSpecific = { delegate, callVisit -> delegate.formatMessageCall(callVisit, chain) }, + constructFinalResult = { delegateResult, _ -> formatExecutedMessage(chain, delegateResult) } + ) + } + + override suspend fun formatRejectedOperationMessage( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + rejectedAccountName: String, + chain: Chain + ): String { + return formatCall( + call = call, + initialOrigin = initialOrigin, + chain = chain, + formatUnknown = { formatRejectedMessage(chain, dialogUnknownCall(), rejectedAccountName) }, + formatDefault = { formatRejectedMessage(chain, it.format(), rejectedAccountName) }, + formatSpecific = { delegate, callVisit -> delegate.formatMessageCall(callVisit, chain) }, + constructFinalResult = { delegateResult, _ -> formatRejectedMessage(chain, delegateResult, rejectedAccountName) } + ) + } + + private suspend fun formatCall( + call: GenericCall.Instance?, + initialOrigin: AccountIdKey, + chain: Chain, + formatUnknown: () -> R, + formatDefault: suspend (GenericCall.Instance) -> R, + formatSpecific: suspend (MultisigActionFormatterDelegate, CallVisit) -> D?, + constructFinalResult: suspend (D, onBehalfOf: AddressModel?) -> R + ): R { + if (call == null) return formatUnknown() + + val firstFormattedCall = callTraversal.collect(call, initialOrigin) + .map { formatCallVisit(it) { delegate -> formatSpecific(delegate, it) } } + .getFirstFormated() + + return if (firstFormattedCall != null) { + val (singleMatch, singleMatchVisit) = firstFormattedCall + + val onBehalfOf = createOnBehalfOf(singleMatchVisit, initialOrigin, chain) + return constructFinalResult(singleMatch!!, onBehalfOf) + } else { + formatDefault(call) + } + } + + private fun DelegateResultWithVisit<*>.isFormatted() = first != null + + private fun List>.getFirstFormated(): DelegateResultWithVisit? { + return firstOrNull { it.isFormatted() } + } + + private fun createCallPreview( + delegateResult: MultisigActionFormatterDelegatePreviewResult, + onBehalfOf: AddressModel? + ): MultisigCallPreviewModel { + return with(delegateResult) { MultisigCallPreviewModel(title, subtitle, primaryValue, icon, onBehalfOf) } + } + + private suspend fun createCallDetails( + delegateResult: MultisigActionFormatterDelegateDetailsResult, + onBehalfOf: AddressModel? + ): MultisigCallDetailsModel { + return MultisigCallDetailsModel( + title = delegateResult.title, + primaryAmount = delegateResult.primaryAmount?.let { + val token = tokenUseCase.getToken(it.chainAssetId) + amountFormatter.formatAmountToAmountModel( + it.amount, + token, + config = AmountConfig( + tokenConfig = TokenConfig(tokenAmountSign = AmountSign.NEGATIVE) + ) + ) + }, + tableEntries = delegateResult.tableEntries.map { it.toUi() }, + onBehalfOf = onBehalfOf + ) + } + + private suspend fun MultisigActionFormatterDelegateDetailsResult.TableEntry.toUi(): MultisigCallDetailsModel.TableEntry { + return MultisigCallDetailsModel.TableEntry( + name = name, + value = value.toUi() + ) + } + + private suspend fun MultisigActionFormatterDelegateDetailsResult.TableValue.toUi(): MultisigCallDetailsModel.TableValue { + return when (this) { + is MultisigActionFormatterDelegateDetailsResult.TableValue.Account -> { + val addressModel = addressIconGenerator.createAccountAddressModel( + chain = chain, + accountId = accountId.value, + name = identityProvider.identityFor(accountId.value, chain.id)?.name + ) + + MultisigCallDetailsModel.TableValue.Account(addressModel, chain) + } + } + } + + private suspend fun createOnBehalfOf( + callVisit: CallVisit, + initialOrigin: AccountIdKey, + chain: Chain + ): AddressModel? { + if (callVisit.callOrigin == initialOrigin) return null + + val onBehalfOf = callVisit.callOrigin.value + + return addressIconGenerator.createAccountAddressModel( + chain = chain, + accountId = onBehalfOf, + name = identityProvider.identityFor(onBehalfOf, chain.id)?.name + ) + } + + private suspend fun formatCallVisit( + callVisit: CallVisit, + format: suspend (MultisigActionFormatterDelegate) -> D? + ): DelegateResultWithVisit { + val result = delegates.tryFindNonNull { format(it) } + return result to callVisit + } + + private fun formatDefaultPreview(call: GenericCall.Instance, chain: Chain): MultisigCallPreviewModel { + return MultisigCallPreviewModel( + title = call.function.name.splitAndCapitalizeWords(), + subtitle = call.module.name.splitAndCapitalizeWords(), + primaryValue = null, + icon = assetIconProvider.multisigFormatAssetIcon(chain), + onBehalfOf = null + ) + } + + private fun formatUnknownPreview(): MultisigCallPreviewModel { + return MultisigCallPreviewModel( + title = resourceManager.getString(R.string.multisig_operations_unknown_calldata), + subtitle = null, + primaryValue = null, + icon = R.drawable.ic_unknown_operation.asIcon(), + onBehalfOf = null + ) + } + + private fun formatDetails(call: GenericCall.Instance): MultisigCallDetailsModel { + return MultisigCallDetailsModel( + title = call.function.name.splitAndCapitalizeWords(), + primaryAmount = null, + tableEntries = emptyList(), + onBehalfOf = null + ) + } + + private fun formatUnknownDetails(): MultisigCallDetailsModel { + return MultisigCallDetailsModel( + title = resourceManager.getString(R.string.multisig_operations_unknown_calldata), + primaryAmount = null, + tableEntries = emptyList(), + onBehalfOf = null + ) + } + + private fun formatPushNotification(chain: Chain, formattedCall: String, onBehalfOf: AddressModel? = null): MultisigCallPushNotificationModel { + return MultisigCallPushNotificationModel( + resourceManager.getString( + R.string.multisig_notification_init_transaction_message, + formattedCall, + chain.name + ), + onBehalfOf = onBehalfOf + ) + } + + private fun formatExecutedMessage(chain: Chain, formattedCall: String): String { + return resourceManager.getString( + R.string.multisig_transaction_executed_dialog_message, + formattedCall, + chain.name + ) + } + + private fun formatRejectedMessage(chain: Chain, formattedCall: String, rejectedAccountName: String): String { + return resourceManager.getString( + R.string.multisig_transaction_rejected_dialog_message, + formattedCall, + chain.name, + rejectedAccountName + ) + } + + private fun pushUnknownCall() = resourceManager.getString(R.string.multisig_operations_unknown_calldata) + + private fun dialogUnknownCall() = resourceManager.getString(R.string.multisig_transaction_dialog_message_unknown_call) + + private fun GenericCall.Instance.format(): String { + return resourceManager.getString(R.string.multisig_operation_default_call_format, module.name.capitalize(), function.name.capitalize()) + } +} + +private typealias DelegateResultWithVisit = Pair diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/TransferMultisigActionFormatter.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/TransferMultisigActionFormatter.kt new file mode 100644 index 0000000..4680f94 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/TransferMultisigActionFormatter.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.images.asIcon +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.getNameOrAddress +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.MultisigActionFormatterDelegateDetailsResult.TableEntry +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters.MultisigActionFormatterDelegateDetailsResult.TableValue +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.tryParseTransfer +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.toIdWithAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatToken +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.TokenConfig +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountSign +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull +import javax.inject.Inject + +@FeatureScope +class TransferMultisigActionFormatter @Inject constructor( + @LocalIdentity private val identityProvider: IdentityProvider, + private val assetSourceRegistry: AssetSourceRegistry, + private val resourceManager: ResourceManager, + private val tokenFormatter: TokenFormatter +) : MultisigActionFormatterDelegate { + + override suspend fun formatPreview( + visit: CallVisit, + chain: Chain + ): MultisigActionFormatterDelegatePreviewResult? { + val parsedTransfer = tryParseTransfer(visit, chain) ?: return null + + val destAddress = chain.addressOf(parsedTransfer.destination) + + return MultisigActionFormatterDelegatePreviewResult( + title = resourceManager.getString(R.string.transfer_title), + subtitle = resourceManager.getString(R.string.transfer_history_send_to, destAddress), + primaryValue = parsedTransfer.amount.formatAmount(), + icon = R.drawable.ic_arrow_up.asIcon() + ) + } + + override suspend fun formatDetails(visit: CallVisit, chain: Chain): MultisigActionFormatterDelegateDetailsResult? { + val parsedTransfer = tryParseTransfer(visit, chain) ?: return null + + return MultisigActionFormatterDelegateDetailsResult( + title = resourceManager.getString(R.string.transfer_title), + primaryAmount = parsedTransfer.amount.toIdWithAmount(), + tableEntries = listOf( + TableEntry( + name = resourceManager.getString(R.string.wallet_recipient), + value = TableValue.Account(parsedTransfer.destination, chain) + ) + ) + ) + } + + override suspend fun formatMessageCall(visit: CallVisit, chain: Chain): String? { + val parsedTransfer = tryParseTransfer(visit, chain) ?: return null + + val accountName = identityProvider.getNameOrAddress(parsedTransfer.destination, chain) + val formattedAmount = parsedTransfer.amount.formatAmount(withSign = false) + + return resourceManager.getString( + R.string.multisig_transaction_message_transfer, + formattedAmount, + accountName + ) + } + + private suspend fun tryParseTransfer( + visit: CallVisit, + chain: Chain + ): TransferParsedFromCall? { + return assetSourceRegistry.allSources().tryFindNonNull { + it.transfers.tryParseTransfer(visit.call, chain) + } + } + + private fun ChainAssetWithAmount.formatAmount(withSign: Boolean = true): CharSequence { + return if (withSign) { + tokenFormatter.formatToken(amount, chainAsset, config = TokenConfig(tokenAmountSign = AmountSign.NEGATIVE)) + } else { + tokenFormatter.formatToken(amount, chainAsset) + } + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/UtilityBatchesActionFormatter.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/UtilityBatchesActionFormatter.kt new file mode 100644 index 0000000..698de7b --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/UtilityBatchesActionFormatter.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.capitalize +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import javax.inject.Inject + +@FeatureScope +class UtilityBatchesActionFormatter @Inject constructor( + private val assetIconProvider: AssetIconProvider, + private val resourceManager: ResourceManager, +) : MultisigActionFormatterDelegate { + + override suspend fun formatPreview( + visit: CallVisit, + chain: Chain + ): MultisigActionFormatterDelegatePreviewResult? { + val batchCallFormat = visit.formatCall() ?: return null + + return MultisigActionFormatterDelegatePreviewResult( + title = batchCallFormat, + subtitle = visit.call.module.name.capitalize(), + primaryValue = null, + icon = assetIconProvider.multisigFormatAssetIcon(chain) + ) + } + + override suspend fun formatDetails(visit: CallVisit, chain: Chain): MultisigActionFormatterDelegateDetailsResult? { + val batchCallFormat = visit.formatCall() ?: return null + + return MultisigActionFormatterDelegateDetailsResult( + title = batchCallFormat, + primaryAmount = null, + tableEntries = emptyList() + ) + } + + override suspend fun formatMessageCall(visit: CallVisit, chain: Chain): String? { + val batchCallFormat = visit.formatCall() ?: return null + + return resourceManager.getString( + R.string.multisig_operation_default_call_format, + visit.call.module.name.capitalize(), + batchCallFormat + ) + } + + private fun CallVisit.formatCall(): String? { + if (call.module.name != Modules.UTILITY) return null + + return when (call.function.name) { + "batch" -> resourceManager.getString(R.string.multisig_operation_utility_batch_title) + "batch_all" -> resourceManager.getString(R.string.multisig_operation_utility_batch_all_title) + "force_batch" -> resourceManager.getString(R.string.multisig_operation_utility_force_batch_title) + else -> null + } + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/Utils.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/Utils.kt new file mode 100644 index 0000000..9e17c67 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/callFormatting/formatters/Utils.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.formatters + +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun AssetIconProvider.multisigFormatAssetIcon(chain: Chain) = getAssetIconOrFallback(chain.utilityAsset, AssetIconMode.WHITE) diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/common/MultisigOperationPayload.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/common/MultisigOperationPayload.kt new file mode 100644 index 0000000..069e541 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/common/MultisigOperationPayload.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.common + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId +import kotlinx.parcelize.Parcelize + +@Parcelize +class MultisigOperationPayload( + val chainId: String, + val metaId: Long, + val callHash: String +) : Parcelable { + companion object; +} + +fun MultisigOperationPayload.Companion.fromOperationId(operationId: PendingMultisigOperationId): MultisigOperationPayload { + return MultisigOperationPayload( + chainId = operationId.chainId, + metaId = operationId.metaId, + callHash = operationId.callHash + ) +} + +fun MultisigOperationPayload.toOperationId(): PendingMultisigOperationId { + return PendingMultisigOperationId( + chainId = chainId, + metaId = metaId, + callHash = callHash + ) +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/MultisigCreatedBottomSheet.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/MultisigCreatedBottomSheet.kt new file mode 100644 index 0000000..0cee75a --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/MultisigCreatedBottomSheet.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.created + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.view.bottomSheet.action.fragment.ActionBottomSheetDialogFragment +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent + +class MultisigCreatedBottomSheet : ActionBottomSheetDialogFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun inject() { + FeatureUtils.getFeature(requireContext(), MultisigOperationsFeatureApi::class.java) + .multisigCreated() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: MultisigCreatedViewModel) {} +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/MultisigCreatedPayload.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/MultisigCreatedPayload.kt new file mode 100644 index 0000000..b2c8fb9 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/MultisigCreatedPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.created + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class MultisigCreatedPayload( + val walletWasSwitched: Boolean +) : Parcelable diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/MultisigCreatedViewModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/MultisigCreatedViewModel.kt new file mode 100644 index 0000000..4a75834 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/MultisigCreatedViewModel.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.created + +import android.view.Gravity +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.spannable.highlightedText +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetPayload +import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences +import io.novafoundation.nova.common.view.bottomSheet.action.fragment.ActionBottomSheetViewModel +import io.novafoundation.nova.common.view.bottomSheet.action.primary +import io.novafoundation.nova.common.view.bottomSheet.action.secondary +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter + +class MultisigCreatedViewModel( + private val resourceManager: ResourceManager, + private val router: MultisigOperationsRouter, + private val payload: MultisigCreatedPayload +) : ActionBottomSheetViewModel() { + + override fun getPayload(): ActionBottomSheetPayload { + return ActionBottomSheetPayload( + imageRes = R.drawable.ic_multisig, + title = resourceManager.getString(R.string.multisig_transaction_created_title), + subtitle = getSubtitle(), + actionButtonPreferences = ButtonPreferences.primary(primaryButtonText()), + neutralButtonPreferences = ButtonPreferences.secondary(secondaryButtonText()), + alertModel = getAlertModel(), + checkBoxPreferences = null + ) + } + + private fun getAlertModel(): AlertModel? { + return if (payload.walletWasSwitched) { + AlertModel( + style = AlertView.Style.fromPreset(AlertView.StylePreset.INFO, iconGravity = Gravity.CENTER), + message = resourceManager.getString(R.string.alert_nova_has_selected_ms_wallet) + ) + } else { + null + } + } + + override fun onActionClicked() { + router.openPendingOperations() + } + + private fun getSubtitle() = resourceManager.highlightedText( + mainRes = R.string.multisig_transaction_created_subtitle, + R.string.multisig_transaction_created_subtitle_highlight + ) + + private fun primaryButtonText() = resourceManager.getString(R.string.multisig_transaction_created_view_details) + private fun secondaryButtonText() = resourceManager.getString(R.string.common_close) +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/di/MultisigCreatedComponent.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/di/MultisigCreatedComponent.kt new file mode 100644 index 0000000..246d7fc --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/di/MultisigCreatedComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.created.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedBottomSheet +import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedPayload + +@Subcomponent( + modules = [ + MultisigCreatedModule::class + ] +) +@ScreenScope +interface MultisigCreatedComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: MultisigCreatedPayload + ): MultisigCreatedComponent + } + + fun inject(fragment: MultisigCreatedBottomSheet) +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/di/MultisigCreatedModule.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/di/MultisigCreatedModule.kt new file mode 100644 index 0000000..0230664 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/created/di/MultisigCreatedModule.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.created.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedViewModel + +@Module(includes = [ViewModelModule::class]) +class MultisigCreatedModule { + + @Provides + @IntoMap + @ViewModelKey(MultisigCreatedViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: MultisigOperationsRouter, + payload: MultisigCreatedPayload + ): ViewModel { + return MultisigCreatedViewModel(resourceManager, router, payload) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): MultisigCreatedViewModel { + return ViewModelProvider( + fragment, + viewModelFactory + ).get(MultisigCreatedViewModel::class.java) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDeepLinkConfigurator.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDeepLinkConfigurator.kt new file mode 100644 index 0000000..4a5cef1 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDeepLinkConfigurator.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink + +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.ACTION +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.CALL_HASH_PARAM +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.CHAIN_ID_PARAM +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.MULTISIG_ADDRESS_PARAM +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.SCREEN +import android.net.Uri +import io.novafoundation.nova.common.utils.doIf +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.DeepLinkConfigurator +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilder +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.LinkBuilderFactory +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.addParamIfNotNull +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.ACTOR_IDENTITY_PARAM +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.CALL_DATA_PARAM +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.OPERATION_STATE_PARAM +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator.Companion.SIGNATORY_ADDRESS_PARAM +import io.novafoundation.nova.runtime.ext.ChainGeneses + +class MultisigOperationDeepLinkData( + val chainId: String, + val multisigAddress: String, + val signatoryAddress: String, + val callHash: String, + val callData: String?, + val operationState: State?, +) { + sealed interface State { + companion object; + + data object Active : State + class Rejected(val actorIdentity: String) : State + class Executed(val actorIdentity: String) : State + } +} + +interface MultisigOperationDeepLinkConfigurator : DeepLinkConfigurator { + + companion object { + const val ACTION = "open" + const val SCREEN = "multisigOperation" + const val PREFIX = "/$ACTION/$SCREEN" + const val CHAIN_ID_PARAM = "chainId" + const val MULTISIG_ADDRESS_PARAM = "multisigAddress" + const val SIGNATORY_ADDRESS_PARAM = "signatoryAddress" + const val ACTOR_IDENTITY_PARAM = "actorIdentity" + const val CALL_HASH_PARAM = "callHash" + const val CALL_DATA_PARAM = "callData" + const val OPERATION_STATE_PARAM = "operationState" + } +} + +class RealMultisigOperationDeepLinkConfigurator( + private val linkBuilderFactory: LinkBuilderFactory +) : MultisigOperationDeepLinkConfigurator { + + override fun configure(payload: MultisigOperationDeepLinkData, type: DeepLinkConfigurator.Type): Uri { + // We not add Polkadot chain id to simplify deep link + val appendChainIdParam = payload.chainId != ChainGeneses.POLKADOT + + return linkBuilderFactory.newLink(type) + .setAction(ACTION) + .setScreen(SCREEN) + .doIf(appendChainIdParam) { addParam(CHAIN_ID_PARAM, payload.chainId) } + .addState(payload.operationState) + .addParam(MULTISIG_ADDRESS_PARAM, payload.multisigAddress) + .addParam(SIGNATORY_ADDRESS_PARAM, payload.signatoryAddress) + .addParam(CALL_HASH_PARAM, payload.callHash) + .addParamIfNotNull(CALL_DATA_PARAM, payload.callData) + .build() + } +} + +fun Uri.getOperationState(): MultisigOperationDeepLinkData.State? { + return MultisigOperationDeepLinkData.State.fromString(getQueryParameter(OPERATION_STATE_PARAM), this) +} + +private fun LinkBuilder.addState(state: MultisigOperationDeepLinkData.State?): LinkBuilder { + return addParamIfNotNull(OPERATION_STATE_PARAM, state?.mapToString()) + .addParamIfNotNull(ACTOR_IDENTITY_PARAM, state?.actorIdentityOrNull()) +} + +private fun MultisigOperationDeepLinkData.State.mapToString() = when (this) { + MultisigOperationDeepLinkData.State.Active -> "active" + is MultisigOperationDeepLinkData.State.Executed -> "executed" + is MultisigOperationDeepLinkData.State.Rejected -> "rejected" +} + +private fun MultisigOperationDeepLinkData.State.Companion.fromString(value: String?, uri: Uri) = when (value) { + "active" -> MultisigOperationDeepLinkData.State.Active + "executed" -> MultisigOperationDeepLinkData.State.Executed(uri.getQueryParameter(ACTOR_IDENTITY_PARAM)!!) + "rejected" -> MultisigOperationDeepLinkData.State.Rejected(uri.getQueryParameter(ACTOR_IDENTITY_PARAM)!!) + else -> null +} + +fun MultisigOperationDeepLinkData.State.actorIdentityOrNull() = when (this) { + MultisigOperationDeepLinkData.State.Active -> null + is MultisigOperationDeepLinkData.State.Executed -> actorIdentity + is MultisigOperationDeepLinkData.State.Rejected -> actorIdentity +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDetailsDeepLinkHandler.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDetailsDeepLinkHandler.kt new file mode 100644 index 0000000..e61f7a3 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDetailsDeepLinkHandler.kt @@ -0,0 +1,120 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.DialogMessageManager +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId +import io.novafoundation.nova.feature_account_api.data.multisig.model.create +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.accountIdKeyIn +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.common.fromOperationId +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.ext.toAccountIdKey +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class MultisigOperationDetailsDeepLinkHandler( + private val router: MultisigOperationsRouter, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val automaticInteractionGate: AutomaticInteractionGate, + private val dialogMessageManager: DialogMessageManager, + private val multisigCallFormatter: MultisigCallFormatter, +) : DeepLinkHandler { + + override val callbackFlow: Flow = emptyFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + + return path.startsWith(MultisigOperationDeepLinkConfigurator.PREFIX) + } + + override suspend fun handleDeepLink(data: Uri): Result = runCatching { + automaticInteractionGate.awaitInteractionAllowed() + + val chainId = data.getChainId() + val chain = chainRegistry.getChain(chainId) + + val multisigAccount = data.getMultisigAddress()?.toAccountIdKey(chain) ?: error("Multisig address not found") + val signatoryAccount = data.getSignatoryAddress()?.toAccountIdKey(chain) ?: error("Signatory address not found") + val callHash = data.getCallHash() ?: error("Call hash not found") + val callData = data.getCallData(chainId) + val operationState = data.getOperationState() + + val multisigMetaAccount = accountRepository.getActiveMetaAccounts() + .filterIsInstance() + .firstOrNull { it.accountIdKeyIn(chain) == multisigAccount && it.signatoryAccountId == signatoryAccount } + ?: error("Multisig account not found") + + accountRepository.selectMetaAccount(multisigMetaAccount.id) + + when (operationState) { + null, + MultisigOperationDeepLinkData.State.Active -> { + val operationIdentifier = PendingMultisigOperationId.create(multisigMetaAccount, chain, callHash.removeHexPrefix()) + val operationPayload = MultisigOperationPayload.fromOperationId(operationIdentifier) + router.openMultisigOperationDetails( + MultisigOperationDetailsPayload( + operationPayload, + navigationButtonMode = MultisigOperationDetailsPayload.NavigationButtonMode.CLOSE + ) + ) + } + + is MultisigOperationDeepLinkData.State.Executed -> showDialog( + R.string.multisig_transaction_executed_dialog_title, + multisigCallFormatter.formatExecutedOperationMessage(callData, signatoryAccount, chain) + ) + + is MultisigOperationDeepLinkData.State.Rejected -> showDialog( + R.string.multisig_transaction_rejected_dialog_title, + multisigCallFormatter.formatRejectedOperationMessage(callData, signatoryAccount, operationState.actorIdentity, chain) + ) + } + } + + private fun showDialog(titleRes: Int, messageText: String) { + dialogMessageManager.showDialog { + setTitle(titleRes) + setMessage(messageText) + setPositiveButton(R.string.common_got_it, null) + } + } + + private fun Uri.getChainId(): String { + return getQueryParameter(MultisigOperationDeepLinkConfigurator.CHAIN_ID_PARAM) ?: ChainGeneses.POLKADOT + } + + private fun Uri.getMultisigAddress(): String? { + return getQueryParameter(MultisigOperationDeepLinkConfigurator.MULTISIG_ADDRESS_PARAM) + } + + private fun Uri.getSignatoryAddress(): String? { + return getQueryParameter(MultisigOperationDeepLinkConfigurator.SIGNATORY_ADDRESS_PARAM) + } + + private fun Uri.getCallHash(): String? { + return getQueryParameter(MultisigOperationDeepLinkConfigurator.CALL_HASH_PARAM) + } + + private suspend fun Uri.getCallData(chainId: String): GenericCall.Instance? { + val callDataString = getQueryParameter(MultisigOperationDeepLinkConfigurator.CALL_DATA_PARAM) ?: return null + val runtime = chainRegistry.getRuntime(chainId) + return GenericCall.fromHex(runtime, callDataString) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsFragment.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsFragment.kt new file mode 100644 index 0000000..455b022 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsFragment.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.full + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.copy.setupCopyText +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAccountWithLoading +import io.novafoundation.nova.feature_multisig_operations.databinding.FragmentMultisigOperationFullDetailsBinding +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class MultisigOperationFullDetailsFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentMultisigOperationFullDetailsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.multisigPendingOperationFullDetailsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.multisigPendingOperationDetailsDepositor.setOnClickListener { viewModel.onDepositorClicked() } + binder.multisigPendingOperationDetailsDeposit.setOnClickListener { viewModel.depositClicked() } + binder.multisigPendingOperationDetailsCallHash.setOnClickListener { viewModel.callHashClicked() } + binder.multisigPendingOperationDetailsCallData.setOnClickListener { viewModel.callDataClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + MultisigOperationsFeatureApi::class.java + ) + .multisigOperationFullDetails() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: MultisigOperationFullDetailsViewModel) { + observeDescription(viewModel) + setupExternalActions(viewModel) + setupCopyText(viewModel) + + viewModel.depositorAccountModel.observe { binder.multisigPendingOperationDetailsDepositor.showAccountWithLoading(it) } + viewModel.depositAmount.observe { binder.multisigPendingOperationDetailsDeposit.showAmount(it) } + viewModel.ellipsizedCallHash.observe { binder.multisigPendingOperationDetailsCallHash.showValueOrHide(it, null) } + viewModel.ellipsizedCallData.observe { binder.multisigPendingOperationDetailsCallData.showValueOrHide(it, null) } + viewModel.formattedCall.observe { binder.multisigPendingOperationDetailsCall.text = it } + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsViewModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsViewModel.kt new file mode 100644 index 0000000..a2f61fe --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsViewModel.kt @@ -0,0 +1,120 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.full + +import io.novafoundation.nova.common.address.toHexWithPrefix +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher +import io.novafoundation.nova.common.mixin.copy.showCopyCallHash +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.ellipsizeMiddle +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.common.toOperationId +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.utilityAsset +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private const val CALL_HASH_SHOWN_SYMBOLS = 9 + +class MultisigOperationFullDetailsViewModel( + private val router: MultisigOperationsRouter, + private val resourceManager: ResourceManager, + private val interactor: MultisigOperationDetailsInteractor, + private val multisigOperationsService: MultisigPendingOperationsService, + private val externalActions: ExternalActions.Presentation, + private val payload: MultisigOperationPayload, + private val accountUIUseCase: AccountUIUseCase, + private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + private val copyTextLauncher: CopyTextLauncher.Presentation, + private val arbitraryTokenUseCase: ArbitraryTokenUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + CopyTextLauncher by copyTextLauncher, + DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher { + + fun backClicked() { + router.back() + } + + private val operationFlow = multisigOperationsService.pendingOperationFlow(payload.toOperationId()) + .filterNotNull() + .shareInBackground() + + private val tokenFlow = operationFlow.map { + arbitraryTokenUseCase.getToken(it.chain.utilityAsset.fullId) + }.shareInBackground() + + val depositorAccountModel = operationFlow.map { + accountUIUseCase.getAccountModel(it.depositor, it.chain) + }.withSafeLoading() + .shareInBackground() + + val depositAmount = combine(operationFlow, tokenFlow) { operation, token -> + amountFormatter.formatAmountToAmountModel(operation.deposit, token) + }.shareInBackground() + + private val callDataFlow = operationFlow.map { operation -> + operation.call?.let { interactor.callDataAsString(it, operation.chain.id) } + }.shareInBackground() + + val ellipsizedCallData = callDataFlow.map { it?.ellipsizeMiddle(CALL_HASH_SHOWN_SYMBOLS) } + + val formattedCall = operationFlow.map { operation -> + operation.call?.let { interactor.callDetails(it) } + }.shareInBackground() + + private val callHash = operationFlow.map { operation -> + operation.callHash.toHexWithPrefix() + }.shareInBackground() + + val ellipsizedCallHash = callHash.map { + it.ellipsizeMiddle(CALL_HASH_SHOWN_SYMBOLS) + }.shareInBackground() + + fun onDepositorClicked() = launchUnit { + val chain = operationFlow.first().chain + depositorAccountModel.first().onLoaded { + externalActions.showAddressActions(it.address(), chain) + } + } + + fun callDataClicked() = launchUnit { + val callDataEllipsized = ellipsizedCallData.first() ?: return@launchUnit + val callData = callDataFlow.first() ?: return@launchUnit + copyTextLauncher.showCopyTextDialog( + CopyTextLauncher.Payload( + title = callDataEllipsized.toString(), + textToCopy = callData, + resourceManager.getString(R.string.common_copy_call_data), + resourceManager.getString(R.string.common_share_call_data) + ) + ) + } + + fun depositClicked() { + launchDescriptionBottomSheet( + titleRes = R.string.multisig_deposit, + descriptionRes = R.string.multisig_deposit_description + ) + } + + fun callHashClicked() = launchUnit { + copyTextLauncher.showCopyCallHash(resourceManager, callHash.first()) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/di/MultisigOperationFullDetailsComponent.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/di/MultisigOperationFullDetailsComponent.kt new file mode 100644 index 0000000..fa0be8c --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/di/MultisigOperationFullDetailsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.full.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.details.full.MultisigOperationFullDetailsFragment + +@Subcomponent( + modules = [ + MultisigOperationFullDetailsModule::class + ] +) +@ScreenScope +interface MultisigOperationFullDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: MultisigOperationPayload, + ): MultisigOperationFullDetailsComponent + } + + fun inject(fragment: MultisigOperationFullDetailsFragment) +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/di/MultisigOperationFullDetailsModule.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/di/MultisigOperationFullDetailsModule.kt new file mode 100644 index 0000000..34ae354 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/di/MultisigOperationFullDetailsModule.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.full.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.copy.CopyTextLauncher +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.details.full.MultisigOperationFullDetailsViewModel +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di.MultisigOperationDetailsModule.BindsModule +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, BindsModule::class]) +class MultisigOperationFullDetailsModule { + + @Provides + @IntoMap + @ViewModelKey(MultisigOperationFullDetailsViewModel::class) + fun provideViewModel( + router: MultisigOperationsRouter, + resourceManager: ResourceManager, + interactor: MultisigOperationDetailsInteractor, + multisigOperationsService: MultisigPendingOperationsService, + externalActions: ExternalActions.Presentation, + payload: MultisigOperationPayload, + descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + copyTextLauncher: CopyTextLauncher.Presentation, + accountUIUseCase: AccountUIUseCase, + arbitraryTokenUseCase: ArbitraryTokenUseCase, + amountFormatter: AmountFormatter + ): ViewModel { + return MultisigOperationFullDetailsViewModel( + router = router, + resourceManager = resourceManager, + interactor = interactor, + multisigOperationsService = multisigOperationsService, + externalActions = externalActions, + payload = payload, + descriptionBottomSheetLauncher = descriptionBottomSheetLauncher, + copyTextLauncher = copyTextLauncher, + accountUIUseCase = accountUIUseCase, + arbitraryTokenUseCase = arbitraryTokenUseCase, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): MultisigOperationFullDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(MultisigOperationFullDetailsViewModel::class.java) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsFragment.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsFragment.kt new file mode 100644 index 0000000..869cd8e --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsFragment.kt @@ -0,0 +1,145 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.general + +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.core.view.isGone +import androidx.core.view.isVisible +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.isLoaded +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.common.view.bindWithHideShowButton +import io.novafoundation.nova.common.view.setExtraInfoAvailable +import io.novafoundation.nova.common.view.bottomSheet.action.observeActionBottomSheet +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_account_api.view.showAddressOrHide +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.databinding.FragmentMultisigOperationDetailsBinding +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallDetailsModel +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter.SignatoriesAdapter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.amount.setAmountOrHide + +class MultisigOperationDetailsFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentMultisigOperationDetailsBinding.inflate(layoutInflater) + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { SignatoriesAdapter(viewModel::onSignatoryClicked) } + + override fun initViews() { + binder.multisigPendingOperationDetailsToolbar.setHomeButtonIcon(viewModel.getNavigationIconRes()) + binder.multisigPendingOperationDetailsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.multisigOperationSignatories.adapter = adapter + + binder.multisigPendingOperationDetailsEnterCallData.setOnClickListener { viewModel.enterCallDataClicked() } + binder.multisigPendingOperationDetailsAction.prepareForProgress(viewLifecycleOwner) + binder.multisigPendingOperationDetailsAction.setOnClickListener { viewModel.actionClicked() } + + binder.multisigPendingOperationCallDetails.setOnClickListener { viewModel.callDetailsClicked() } + binder.multisigPendingOperationCallDetails.background = with(requireContext()) { + addRipple(getBlockDrawable()) + } + + binder.multisigPendingOperationDetailsWallet.setOnClickListener { viewModel.walletDetailsClicked() } + binder.multisigPendingOperationDetailsBehalfOf.setOnClickListener { viewModel.behalfOfClicked() } + binder.multisigPendingOperationDetailsSignatory.setOnClickListener { viewModel.signatoryDetailsClicked() } + + binder.multisigOperationSignatoriesContainer.bindWithHideShowButton(binder.multisigOperationShowHideButton) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + MultisigOperationsFeatureApi::class.java + ) + .multisigOperationDetails() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: MultisigOperationDetailsViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel.feeLoaderMixin, binder.multisigPendingOperationDetailsFee) + observeActionBottomSheet(viewModel.actionBottomSheetLauncher) + setupConfirmationDialog(R.style.AccentAlertDialogTheme, viewModel.operationNotFoundAwaitableAction) + + viewModel.isOperationLoadingFlow.observe { + binder.multisigPendingOperationProgress.isVisible = it + binder.multisigPendingOperationDetailsContainer.isGone = it + } + + viewModel.showCallButtonState.observe(binder.multisigPendingOperationDetailsEnterCallData::isVisible::set) + viewModel.actionButtonState.observe(binder.multisigPendingOperationDetailsAction::setState) + viewModel.buttonAppearance.observe(binder.multisigPendingOperationDetailsAction::setAppearance) + + viewModel.chainUiFlow.observe(binder.multisigPendingOperationDetailsNetwork::showChain) + viewModel.walletFlow.observe(binder.multisigPendingOperationDetailsWallet::showWallet) + + viewModel.formattedCall.observe { + binder.multisigPendingOperationDetailsBehalfOf.showAddressOrHide(it.onBehalfOf) + binder.multisigPendingOperationDetailsToolbar.setTitle(it.title) + binder.multisigPendingOperationPrimaryAmount.setAmountOrHide(it.primaryAmount) + + showFormattedCallTable(it.tableEntries) + } + + viewModel.signatoryAccount.observe(binder.multisigPendingOperationDetailsSignatory::showWallet) + + viewModel.signatoriesTitle.observe(binder.multisigOperationSignatoriesTitle::setText) + viewModel.formattedSignatories.observe { signatoriesLoadingState -> + binder.multisigOperationSignatoriesShimmering.isVisible = signatoriesLoadingState.isLoading + binder.multisigOperationSignatories.isVisible = signatoriesLoadingState.isLoaded() + signatoriesLoadingState.onLoaded { adapter.submitList(it) } + } + + viewModel.callDetailsVisible.observe(binder.multisigPendingOperationCallDetails::setVisible) + } + + private fun showFormattedCallTable(tableEntries: List) { + binder.multisigPendingOperationDetailsCallTable.removeAllViews() + + tableEntries.forEach { + val entryView = createFormattedCallEntryView(it) + binder.multisigPendingOperationDetailsCallTable.addView(entryView) + } + + binder.multisigPendingOperationDetailsCallTable.invalidateChildrenVisibility() + } + + private fun createFormattedCallEntryView(entry: MultisigCallDetailsModel.TableEntry): TableCellView { + return TableCellView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + + setTitle(entry.name) + + when (val value = entry.value) { + is MultisigCallDetailsModel.TableValue.Account -> { + setExtraInfoAvailable(true) + showAddress(value.addressModel) + setOnClickListener { viewModel.onTableAccountClicked(value) } + } + } + } + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsPayload.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsPayload.kt new file mode 100644 index 0000000..0f523f8 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsPayload.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.general + +import android.os.Parcelable +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import kotlinx.parcelize.Parcelize + +@Parcelize +class MultisigOperationDetailsPayload( + val operation: MultisigOperationPayload, + val navigationButtonMode: NavigationButtonMode = NavigationButtonMode.BACK +) : Parcelable { + enum class NavigationButtonMode { + BACK, CLOSE + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsViewModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsViewModel.kt new file mode 100644 index 0000000..eb75407 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsViewModel.kt @@ -0,0 +1,427 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.general + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.actionAwaitable.fromRes +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.bold +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.formatting.spannable.format +import io.novafoundation.nova.common.utils.formatting.spannable.highlightedText +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.withLoadingShared +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.common.view.PrimaryButton +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.bottomSheet.action.ButtonPreferences +import io.novafoundation.nova.common.view.bottomSheet.action.CheckBoxPreferences +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigAction +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.model.userAction +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.allSignatories +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor +import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.ApproveMultisigOperationValidationFailure +import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.ApproveMultisigOperationValidationPayload +import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.ApproveMultisigOperationValidationSystem +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallDetailsModel +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.common.toOperationId +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter.SignatoryRvItem +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.ext.utilityAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +class MultisigOperationDetailsViewModel( + private val router: MultisigOperationsRouter, + private val resourceManager: ResourceManager, + private val interactor: MultisigOperationDetailsInteractor, + private val multisigOperationsService: MultisigPendingOperationsService, + private val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val externalActions: ExternalActions.Presentation, + private val validationExecutor: ValidationExecutor, + private val payload: MultisigOperationDetailsPayload, + private val validationSystem: ApproveMultisigOperationValidationSystem, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val signatoryListFormatter: SignatoryListFormatter, + private val multisigCallFormatter: MultisigCallFormatter, + private val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + private val accountInteractor: AccountInteractor, + private val accountUIUseCase: AccountUIUseCase, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val amountFormatter: AmountFormatter, + selectedAccountUseCase: SelectedAccountUseCase, + walletUiUseCase: WalletUiUseCase, +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions { + + val operationNotFoundAwaitableAction = actionAwaitableMixinFactory.confirmingAction() + + private val operationFlow = multisigOperationsService.pendingOperationFlow(payload.operation.toOperationId()) + .filterNotNull() + .shareInBackground() + + private val isLastOperationFlow = flowOf { + val operationsCount = multisigOperationsService.getPendingOperationsCount() + operationsCount == 1 + } + .shareInBackground() + + private val chainFlow = operationFlow.map { it.chain } + .shareInBackground() + + private val chainAssetFlow = chainFlow.map { it.utilityAsset } + .shareInBackground() + + val chainUiFlow = chainFlow.map { mapChainToUi(it) } + .shareInBackground() + + private val selectedAccountFlow = selectedAccountUseCase.selectedMetaAccountFlow() + .filterIsInstance() + .shareInBackground() + + val walletFlow = walletUiUseCase.selectedWalletUiFlow(showAddressIcon = true) + .shareInBackground() + + val formattedCall = combine( + selectedAccountFlow, + operationFlow + ) { metaAccount, operation -> + val initialOrigin = metaAccount.requireAccountIdKeyIn(operation.chain) + multisigCallFormatter.formatDetails(operation.call, initialOrigin, operation.chain) + }.shareInBackground() + + private val signatory = operationFlow + .map { it.signatoryMetaId } + .distinctUntilChanged() + .flatMapLatest(interactor::signatoryFlow) + .shareInBackground() + + val signatoryAccount = signatory.map { walletUiUseCase.walletUiFor(it) } + .shareInBackground() + + val signatoriesTitle = combine( + selectedAccountFlow, + operationFlow + ) { metaAccount, operation -> + resourceManager.getString(R.string.multisig_operation_details_signatories, operation.approvals.size, metaAccount.threshold) + }.shareInBackground() + + private val signatoryAccounts = selectedAccountFlow.map { it.allSignatories() } + .distinctUntilChanged() + .map { accountUIUseCase.getAccountModels(it, chainFlow.first()) } + .shareInBackground() + + val formattedSignatories = combine( + signatory, + signatoryAccounts, + operationFlow + ) { currentSignatory, allSignatories, operation -> + signatoryListFormatter.formatSignatories( + chain = chainFlow.first(), + currentSignatory = currentSignatory, + signatories = allSignatories, + approvals = operation.approvals.toSet() + ) + }.withLoadingShared() + .shareInBackground() + + private val showNextProgress = MutableStateFlow(false) + + val feeLoaderMixin = feeLoaderMixinV2Factory.createDefault(viewModelScope, chainAssetFlow) + + val showCallButtonState = operationFlow.map { it.call == null } + .shareInBackground() + + val actionButtonState = combine(showNextProgress, operationFlow) { submissionInProgress, operation -> + val action = operation.userAction() + + when { + submissionInProgress -> DescriptiveButtonState.Loading + + action is MultisigAction.CanApprove -> when { + + operation.call == null -> DescriptiveButtonState.Gone + + action.isFinalApproval -> DescriptiveButtonState.Enabled( + action = resourceManager.getString(R.string.multisig_operation_details_approve_and_execute) + ) + + else -> DescriptiveButtonState.Enabled( + action = resourceManager.getString(R.string.multisig_operation_details_approve) + ) + } + + action is MultisigAction.CanReject -> DescriptiveButtonState.Enabled( + action = resourceManager.getString(R.string.multisig_operation_details_reject) + ) + + else -> DescriptiveButtonState.Gone + } + }.shareInBackground() + + val buttonAppearance = operationFlow.map { operation -> + when { + operation.userAction() is MultisigAction.CanReject -> PrimaryButton.Appearance.PRIMARY_NEGATIVE + else -> PrimaryButton.Appearance.PRIMARY + } + }.shareInBackground() + + val callDetailsVisible = operationFlow + .map { operation -> operation.call != null } + .shareInBackground() + + val actionBottomSheetLauncher = actionBottomSheetLauncherFactory.create() + + val isOperationLoadingFlow = operationFlow.withLoadingShared() + .map { it.isLoading } + .shareInBackground() + + init { + checkOperationAvailability() + + loadFee() + } + + private fun checkOperationAvailability() = launchUnit { + val isOperationAvailable = interactor.isOperationAvailable(payload.operation.toOperationId()) + + if (!isOperationAvailable) { + showErrorAndCloseScreen() + } + } + + fun enterCallDataClicked() { + router.openEnterCallDetails(payload.operation) + } + + fun actionClicked() { + launch { + val operation = operationFlow.first() + + val isReject = operation.userAction() == MultisigAction.CanReject + if (isReject) { + confirmReject(operation.getDepositorName()) + } + + sendTransactionIfValid() + } + } + + private suspend fun PendingMultisigOperation.getDepositorName(): String { + val depositorAccount = withContext(Dispatchers.Default) { accountInteractor.findMetaAccount(chain, depositor.value) } + return depositorAccount?.name ?: chain.addressOf(depositor) + } + + private suspend fun confirmReject(depositorName: String) = suspendCancellableCoroutine { + if (interactor.getSkipRejectConfirmation()) { + it.resume(Unit) + return@suspendCancellableCoroutine + } + + var isAutoContinueChecked = false + + actionBottomSheetLauncher.launchBottomSheet( + imageRes = R.drawable.ic_multisig, + title = resourceManager.getString(R.string.multisig_signing_warning_title), + subtitle = resourceManager.highlightedText(R.string.multisig_signing_reject_confirmation_subtitle, depositorName), + actionButtonPreferences = ButtonPreferences( + text = resourceManager.getString(R.string.common_confirm), + style = PrimaryButton.Appearance.PRIMARY, + onClick = { + interactor.setSkipRejectConfirmation(isAutoContinueChecked) + it.resume(Unit) + } + ), + neutralButtonPreferences = ButtonPreferences( + text = resourceManager.getString(R.string.common_cancel), + style = PrimaryButton.Appearance.SECONDARY, + onClick = { it.cancel() } + ), + checkBoxPreferences = CheckBoxPreferences( + text = resourceManager.getString(R.string.common_check_box_auto_continue), + onCheckChanged = { isAutoContinueChecked = it } + ) + ) + } + + fun backClicked() { + router.back() + } + + fun callDetailsClicked() = launch { + router.openMultisigFullDetails(payload.operation) + } + + private fun loadFee() { + feeLoaderMixin.connectWith(operationFlow) { _, operation -> + interactor.estimateActionFee(operation) + } + } + + private fun sendTransactionIfValid() = launchUnit { + showNextProgress.value = true + + val signatory = signatory.first() + val operation = operationFlow.first() + + val signatoryBalance = interactor.getSignatoryBalance(signatory, operation.chain) + .onFailure { + showError(it) + showNextProgress.value = false + } + .getOrNull() ?: return@launchUnit + + val payload = ApproveMultisigOperationValidationPayload( + fee = feeLoaderMixin.awaitFee(), + signatoryBalance = signatoryBalance, + signatory = signatory, + operation = operation, + multisig = selectedAccountFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = ::formatValidationFailure, + progressConsumer = showNextProgress.progressConsumer() + ) { + sendTransaction() + } + } + + private fun formatValidationFailure(failure: ApproveMultisigOperationValidationFailure): TitleAndMessage { + return when (failure) { + is ApproveMultisigOperationValidationFailure.NotEnoughBalanceToPayFees -> { + val title = resourceManager.getString(R.string.common_error_not_enough_tokens) + val message = SpannableFormatter.format( + resourceManager, + R.string.multisig_signatory_validation_ed, + failure.signatory.name.bold(), + failure.minimumNeeded.formatTokenAmount(failure.chainAsset), + failure.available.formatTokenAmount(failure.chainAsset), + ) + + title to message + } + + ApproveMultisigOperationValidationFailure.TransactionIsNotAvailable -> { + resourceManager.getString(R.string.multisig_approve_transaction_unavailable_title) to + resourceManager.getString(R.string.multisig_approve_transaction_unavailable_message) + } + } + } + + private fun sendTransaction() = launch { + interactor.performAction(operationFlow.first()) + .onFailure(::showError) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + extrinsicNavigationWrapper.startNavigation(it.submissionHierarchy) { + val isLeastOperation = isLastOperationFlow.first() + + if (isLeastOperation) { + router.openMain() + } else { + router.back() + } + } + } + + showNextProgress.value = false + } + + fun onSignatoryClicked(signatoryRvItem: SignatoryRvItem) = launchUnit { + showAddressActionForOriginChain(signatoryRvItem.accountModel.address()) + } + + fun walletDetailsClicked() = launchUnit { + val metaAccount = selectedAccountFlow.first() + val chain = chainFlow.first() + externalActions.showAddressActions(metaAccount, chain) + } + + fun onTableAccountClicked(tableAccount: MultisigCallDetailsModel.TableValue.Account) = launchUnit { + externalActions.showAddressActions(tableAccount.addressModel.address, tableAccount.chain) + } + + fun behalfOfClicked() = launchUnit { + val behalfOf = formattedCall.first().onBehalfOf ?: return@launchUnit + showAddressActionForOriginChain(behalfOf.address) + } + + fun signatoryDetailsClicked() = launchUnit { + val metaAccount = signatory.first() + val chain = chainFlow.first() + externalActions.showAddressActions(metaAccount, chain) + } + + fun getNavigationIconRes() = when (payload.navigationButtonMode) { + MultisigOperationDetailsPayload.NavigationButtonMode.BACK -> R.drawable.ic_arrow_back + MultisigOperationDetailsPayload.NavigationButtonMode.CLOSE -> R.drawable.ic_close + } + + private suspend fun showAddressActionForOriginChain(address: String) { + externalActions.showAddressActions(address, chainFlow.first()) + } + + private suspend fun showErrorAndCloseScreen() { + val confirmationInfo = ConfirmationDialogInfo.fromRes( + resourceManager, + title = R.string.multisig_operation_details_not_found_title, + message = R.string.multisig_operation_details_not_found_message, + positiveButton = R.string.common_got_it, + negativeButton = null + ) + operationNotFoundAwaitableAction.awaitAction(confirmationInfo) + router.back() + } + + private fun isOperationWasExecuted( + old: ExtendedLoadingState, + new: ExtendedLoadingState + ) = old.dataOrNull == true && new.dataOrNull == false +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/SignatoryListFormatter.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/SignatoryListFormatter.kt new file mode 100644 index 0000000..52d9d94 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/SignatoryListFormatter.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.general + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountModel +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.MultisigFormatter +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.delegeted.ProxyFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter.SignatoryRvItem +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class SignatoryListFormatter( + private val accountInteractor: AccountInteractor, + private val proxyFormatter: ProxyFormatter, + private val multisigFormatter: MultisigFormatter +) { + + private class FormattingContext( + val currentSignatoryAccountId: AccountIdKey, + val currentSignatory: MetaAccount, + val metaAccountByAccountIds: GroupedList, + val metaAccountsByMetaId: Map, + val approvals: Set + ) { + + /** + * We try to show the most relevant account to user. + * For signatory account that associated with current multisig account we like to show the same account + */ + fun account(accountId: AccountIdKey): MetaAccount? { + if (currentSignatoryAccountId == accountId) return currentSignatory + + return metaAccountByAccountIds[accountId] + ?.findRelevantAccountToShow() + } + + fun account(metaId: Long) = metaAccountsByMetaId[metaId] + + fun isApprovedBy(accountId: AccountIdKey): Boolean = accountId in approvals + } + + suspend fun formatSignatories( + chain: Chain, + currentSignatory: MetaAccount, + signatories: Map, + approvals: Set + ): List { + val metaAccounts = accountInteractor.getActiveMetaAccounts() + val formattingContext = formattingContext(chain, currentSignatory, metaAccounts, approvals) + + return signatories.map { (signatoryAccountId, signatoryAccountModel) -> + val maybeMetaAccount = formattingContext.account(signatoryAccountId) + SignatoryRvItem( + accountModel = signatoryAccountModel, + subtitle = maybeMetaAccount?.formatSubtitle(formattingContext), + isApproved = formattingContext.isApprovedBy(signatoryAccountId) + ) + } + } + + private fun formattingContext( + chain: Chain, + currentSignatory: MetaAccount, + metaAccounts: List, + approvals: Set + ): FormattingContext { + val metaAccountsByAccountIds = metaAccounts + .filter { it.hasAccountIn(chain) } + .groupBy { it.requireAccountIdKeyIn(chain) } + + val metaAccountsByMetaIds = metaAccounts.associateBy { it.id } + + return FormattingContext( + currentSignatory.requireAccountIdKeyIn(chain), + currentSignatory, + metaAccountsByAccountIds, + metaAccountsByMetaIds, + approvals + ) + } + + private suspend fun MetaAccount.formatSubtitle(context: FormattingContext): CharSequence? = when (this) { + is ProxiedMetaAccount -> formatSubtitle(context) + is MultisigMetaAccount -> formatSubtitle(context) + else -> null + } + + private suspend fun ProxiedMetaAccount.formatSubtitle(context: FormattingContext): CharSequence? { + val proxyMetaAccount = context.account(this.proxy.proxyMetaId) ?: return null + + return proxyFormatter.mapProxyMetaAccountSubtitle( + proxyMetaAccount.name, + proxyFormatter.makeAccountDrawable(proxyMetaAccount), + proxy + ) + } + + private suspend fun MultisigMetaAccount.formatSubtitle(context: FormattingContext): CharSequence? { + val signatory = context.account(this.signatoryMetaId) ?: return null + + return multisigFormatter.formatSignatorySubtitle(signatory) + } +} + +/** + * Since we may have multiple accounts by one account id it would be better to show the most relevant to user. + * For example we show secrets-account instead of the proxied-account + */ +private fun Collection.findRelevantAccountToShow(): MetaAccount? { + return minByOrNull { it.priorityToShowAsSignatory() } +} + +private fun MetaAccount.priorityToShowAsSignatory() = when (type) { + LightMetaAccount.Type.SECRETS -> 0 + + LightMetaAccount.Type.PARITY_SIGNER, + LightMetaAccount.Type.LEDGER_LEGACY, + LightMetaAccount.Type.LEDGER, + LightMetaAccount.Type.POLKADOT_VAULT, + LightMetaAccount.Type.WATCH_ONLY -> 1 + + LightMetaAccount.Type.PROXIED, + LightMetaAccount.Type.MULTISIG -> 2 +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/adapter/SignatoriesAdapter.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/adapter/SignatoriesAdapter.kt new file mode 100644 index 0000000..4810a52 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/adapter/SignatoriesAdapter.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import coil.clear +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_api.presenatation.account.common.relevantEllipsizeMode +import io.novafoundation.nova.feature_multisig_operations.databinding.ItemMultisigSignatoryAccountBinding + +class SignatoriesAdapter( + private val handler: Handler +) : ListAdapter(SignatoriesDiffCallback()) { + + fun interface Handler { + fun onSignatoryClicked(signatory: SignatoryRvItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SignatoryViewHolder { + return SignatoryViewHolder( + ItemMultisigSignatoryAccountBinding.inflate(parent.inflater(), parent, false), + handler + ) + } + + override fun onBindViewHolder(holder: SignatoryViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +class SignatoryViewHolder( + private val binder: ItemMultisigSignatoryAccountBinding, + private val itemHandler: SignatoriesAdapter.Handler, +) : BaseViewHolder(binder.root) { + + fun bind(item: SignatoryRvItem) = with(binder) { + root.setOnClickListener { itemHandler.onSignatoryClicked(item) } + itemSignatoryAccountIcon.setImageDrawable(item.accountModel.drawable()) + itemSignatoryAccountSelected.setVisible(item.isApproved, falseState = View.INVISIBLE) + itemSignatoryAccountTitle.text = item.accountModel.nameOrAddress() + itemSignatoryAccountTitle.ellipsize = item.accountModel.relevantEllipsizeMode() + itemSignatoryAccountSubtitle.setTextOrHide(item.subtitle) + } + + override fun unbind() { + with(binder) { + itemSignatoryAccountIcon.clear() + } + } +} + +class SignatoriesDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: SignatoryRvItem, newItem: SignatoryRvItem): Boolean { + return oldItem.accountModel == newItem.accountModel + } + + override fun areContentsTheSame(oldItem: SignatoryRvItem, newItem: SignatoryRvItem): Boolean { + return oldItem == newItem + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/adapter/SignatoryRvItem.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/adapter/SignatoryRvItem.kt new file mode 100644 index 0000000..7e2406e --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/adapter/SignatoryRvItem.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.general.adapter + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountModel + +data class SignatoryRvItem( + val accountModel: AccountModel, + val subtitle: CharSequence?, + val isApproved: Boolean, +) diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/di/MultisigOperationDetailsComponent.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/di/MultisigOperationDetailsComponent.kt new file mode 100644 index 0000000..efd7c80 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/di/MultisigOperationDetailsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsFragment +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload + +@Subcomponent( + modules = [ + MultisigOperationDetailsModule::class + ] +) +@ScreenScope +interface MultisigOperationDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: MultisigOperationDetailsPayload, + ): MultisigOperationDetailsComponent + } + + fun inject(fragment: MultisigOperationDetailsFragment) +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/di/MultisigOperationDetailsModule.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/di/MultisigOperationDetailsModule.kt new file mode 100644 index 0000000..04bcb58 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/di/MultisigOperationDetailsModule.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor +import io.novafoundation.nova.feature_multisig_operations.domain.details.RealMultisigOperationDetailsInteractor +import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.ApproveMultisigOperationValidationSystem +import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.OperationIsStillPendingValidation +import io.novafoundation.nova.feature_multisig_operations.domain.details.validations.approveMultisigOperation +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsViewModel +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.SignatoryListFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di.MultisigOperationDetailsModule.BindsModule +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, BindsModule::class]) +class MultisigOperationDetailsModule { + + @Module + interface BindsModule { + + @Binds + fun bindInteractor(real: RealMultisigOperationDetailsInteractor): MultisigOperationDetailsInteractor + } + + @Provides + @ScreenScope + fun provideValidationSystem( + edValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + operationIsStillPendingValidation: OperationIsStillPendingValidation + ): ApproveMultisigOperationValidationSystem { + return ValidationSystem.approveMultisigOperation(edValidationFactory, operationIsStillPendingValidation) + } + + @Provides + @IntoMap + @ViewModelKey(MultisigOperationDetailsViewModel::class) + fun provideViewModel( + router: MultisigOperationsRouter, + resourceManager: ResourceManager, + interactor: MultisigOperationDetailsInteractor, + multisigOperationsService: MultisigPendingOperationsService, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + externalActions: ExternalActions.Presentation, + validationExecutor: ValidationExecutor, + payload: MultisigOperationDetailsPayload, + selectedAccountUseCase: SelectedAccountUseCase, + validationSystem: ApproveMultisigOperationValidationSystem, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + signatoryListFormatter: SignatoryListFormatter, + walletUiUseCase: WalletUiUseCase, + multisigCallFormatter: MultisigCallFormatter, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + accountInteractor: AccountInteractor, + accountUIUseCase: AccountUIUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + amountFormatter: AmountFormatter, + ): ViewModel { + return MultisigOperationDetailsViewModel( + router = router, + resourceManager = resourceManager, + interactor = interactor, + multisigOperationsService = multisigOperationsService, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + externalActions = externalActions, + validationExecutor = validationExecutor, + payload = payload, + selectedAccountUseCase = selectedAccountUseCase, + walletUiUseCase = walletUiUseCase, + validationSystem = validationSystem, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + signatoryListFormatter = signatoryListFormatter, + multisigCallFormatter = multisigCallFormatter, + actionBottomSheetLauncherFactory = actionBottomSheetLauncherFactory, + accountInteractor = accountInteractor, + accountUIUseCase = accountUIUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): MultisigOperationDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(MultisigOperationDetailsViewModel::class.java) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/MultisigOperationEnterCallFragment.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/MultisigOperationEnterCallFragment.kt new file mode 100644 index 0000000..391eb9d --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/MultisigOperationEnterCallFragment.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.enterCall + +import android.view.View +import androidx.lifecycle.lifecycleScope +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_multisig_operations.databinding.FragmentMultisigOperationEnterCallBinding +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload + +class MultisigOperationEnterCallFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentMultisigOperationEnterCallBinding.inflate(layoutInflater) + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + MultisigOperationsFeatureApi::class.java + ) + .multisigOperationEnterCall() + .create(this, payload()) + .inject(this) + } + + override fun applyInsets(rootView: View) { + binder.root.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.multisigOperationEnterCallToolbar.setHomeButtonListener { viewModel.back() } + + binder.multisigOperationEnterCallAction.setOnClickListener { viewModel.approve() } + } + + override fun subscribe(viewModel: MultisigOperationEnterCallViewModel) { + binder.multisigOperationEnterCallInput.content.bindTo(viewModel.enteredCall, viewLifecycleOwner.lifecycleScope) + + viewModel.buttonState.observe { + binder.multisigOperationEnterCallAction.setState(it) + } + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/MultisigOperationEnterCallViewModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/MultisigOperationEnterCallViewModel.kt new file mode 100644 index 0000000..62fde06 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/MultisigOperationEnterCallViewModel.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.enterCall + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.callHashString +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.common.toOperationId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +class MultisigOperationEnterCallViewModel( + private val router: MultisigOperationsRouter, + private val interactor: MultisigOperationDetailsInteractor, + private val multisigOperationsService: MultisigPendingOperationsService, + private val payload: MultisigOperationPayload, + private val resourceManager: ResourceManager +) : BaseViewModel() { + + val enteredCall = MutableStateFlow("") + + val buttonState = enteredCall.map { + when { + it.isBlank() -> DescriptiveButtonState.Disabled(reason = resourceManager.getString(R.string.enter_call_data_title)) + else -> DescriptiveButtonState.Enabled(action = resourceManager.getString(R.string.common_save)) + } + } + + fun back() { + router.back() + } + + fun approve() = launchUnit { + val operation = multisigOperationsService.pendingOperation(payload.toOperationId()) ?: return@launchUnit + if (interactor.isCallValid(operation, enteredCall.value)) { + interactor.setCall(operation, enteredCall.value) + router.back() + } else { + onCallInvalid(enteredCall.value) + } + } + + private fun onCallInvalid(enteredCall: String) = try { + val callHash = enteredCall.callHashString() + showError( + resourceManager.getString(R.string.invalid_call_data_title), + resourceManager.getString(R.string.invalid_call_data_message, callHash) + ) + } catch (e: Exception) { + showError( + resourceManager.getString(R.string.invalid_call_data_title), + resourceManager.getString(R.string.invalid_call_data_format_message) + ) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/di/MultisigOperationEnterCallComponent.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/di/MultisigOperationEnterCallComponent.kt new file mode 100644 index 0000000..8cc29d7 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/di/MultisigOperationEnterCallComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.MultisigOperationEnterCallFragment + +@Subcomponent( + modules = [ + MultisigOperationEnterCallModule::class + ] +) +@ScreenScope +interface MultisigOperationEnterCallComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: MultisigOperationPayload, + ): MultisigOperationEnterCallComponent + } + + fun inject(fragment: MultisigOperationEnterCallFragment) +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/di/MultisigOperationEnterCallModule.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/di/MultisigOperationEnterCallModule.kt new file mode 100644 index 0000000..47d3099 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/enterCall/di/MultisigOperationEnterCallModule.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_multisig_operations.domain.details.MultisigOperationDetailsInteractor +import io.novafoundation.nova.feature_multisig_operations.domain.details.RealMultisigOperationDetailsInteractor +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.di.MultisigOperationDetailsModule.BindsModule +import io.novafoundation.nova.feature_multisig_operations.presentation.enterCall.MultisigOperationEnterCallViewModel + +@Module(includes = [ViewModelModule::class, BindsModule::class]) +class MultisigOperationEnterCallModule { + + @Module + interface BindsModule { + + @Binds + fun bindInteractor(real: RealMultisigOperationDetailsInteractor): MultisigOperationDetailsInteractor + } + + @Provides + @IntoMap + @ViewModelKey(MultisigOperationEnterCallViewModel::class) + fun provideViewModel( + router: MultisigOperationsRouter, + interactor: MultisigOperationDetailsInteractor, + multisigOperationsService: MultisigPendingOperationsService, + payload: MultisigOperationPayload, + resourceManager: ResourceManager + ): ViewModel { + return MultisigOperationEnterCallViewModel( + router = router, + interactor = interactor, + multisigOperationsService = multisigOperationsService, + payload = payload, + resourceManager = resourceManager, + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): MultisigOperationEnterCallViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(MultisigOperationEnterCallViewModel::class.java) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/MultisigPendingOperationsAdapter.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/MultisigPendingOperationsAdapter.kt new file mode 100644 index 0000000..9713c2d --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/MultisigPendingOperationsAdapter.kt @@ -0,0 +1,127 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.list + +import android.view.ViewGroup +import coil.ImageLoader +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.presentation.setColoredText +import io.novafoundation.nova.common.utils.formatting.formatDaysSinceEpoch +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getBottomRoundedCornerDrawable +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.databinding.ItemMultisigPendingOperationBinding +import io.novafoundation.nova.feature_multisig_operations.databinding.ItemMultisigPendingOperationHeaderBinding +import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationHeaderModel +import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationModel + +class MultisigPendingOperationsAdapter( + private val handler: ItemHandler, + private val imageLoader: ImageLoader, +) : GroupedListAdapter(PendingMultisigOperationDiffCallback()) { + + interface ItemHandler { + + fun itemClicked(model: PendingMultisigOperationModel) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return MultisigPendingOperationHeaderHolder( + viewBinding = ItemMultisigPendingOperationHeaderBinding.inflate(parent.inflater(), parent, false) + ) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return MultisigPendingOperationHolder( + viewBinding = ItemMultisigPendingOperationBinding.inflate(parent.inflater(), parent, false), + itemHandler = handler, + imageLoader = imageLoader + ) + } + + override fun bindChild(holder: GroupedListHolder, child: PendingMultisigOperationModel) { + (holder as MultisigPendingOperationHolder).bind(child) + } + + override fun bindGroup(holder: GroupedListHolder, group: PendingMultisigOperationHeaderModel) { + (holder as MultisigPendingOperationHeaderHolder).bind(group) + } +} + +class MultisigPendingOperationHeaderHolder( + private val viewBinding: ItemMultisigPendingOperationHeaderBinding, +) : GroupedListHolder(viewBinding.root) { + + fun bind(model: PendingMultisigOperationHeaderModel) { + viewBinding.itemMultisigPendingOperationHeader.text = model.daysSinceEpoch.formatDaysSinceEpoch(viewBinding.root.context) + } +} + +class MultisigPendingOperationHolder( + private val viewBinding: ItemMultisigPendingOperationBinding, + private val itemHandler: MultisigPendingOperationsAdapter.ItemHandler, + private val imageLoader: ImageLoader, +) : GroupedListHolder(viewBinding.root) { + + init { + with(viewBinding.root) { + background = context.addRipple(context.getRoundedCornerDrawable(R.color.block_background)) + } + + with(viewBinding.itemPendingOperationOnBehalfOfContainer) { + background = context.getBottomRoundedCornerDrawable(R.color.block_background) + } + } + + fun bind(model: PendingMultisigOperationModel) = with(viewBinding) { + itemPendingOperationTitle.text = model.call.title + itemPendingOperationSubtitle.setTextOrHide(model.call.subtitle) + + itemPendingOperationChain.loadChainIcon(model.chain.icon, imageLoader) + itemPendingOperationIcon.setIcon(model.call.icon, imageLoader) + + itemPendingOperationProgress.text = model.progress + itemPendingOperationAction.letOrHide(model.action) { action -> + itemPendingOperationAction.setColoredText(action.text) + itemPendingOperationAction.setDrawableEnd(action.icon, widthInDp = 16, paddingInDp = 4) + } + + itemPendingOperationPrimaryValue.setTextOrHide(model.call.primaryValue) + itemPendingOperationTime.setTextOrHide(model.time) + + itemPendingOperationOnBehalfOfContainer.letOrHide(model.call.onBehalfOf) { onBehalfOf -> + itemPendingOperationOnBehalfOfAddress.text = onBehalfOf.nameOrAddress + itemPendingOperationOnBehalfOfIcon.setImageDrawable(onBehalfOf.image) + } + + root.setOnClickListener { itemHandler.itemClicked(model) } + } +} + +private class PendingMultisigOperationDiffCallback : BaseGroupedDiffCallback( + PendingMultisigOperationHeaderModel::class.java +) { + + override fun areGroupItemsTheSame(oldItem: PendingMultisigOperationHeaderModel, newItem: PendingMultisigOperationHeaderModel): Boolean { + return oldItem.daysSinceEpoch == newItem.daysSinceEpoch + } + + override fun areGroupContentsTheSame(oldItem: PendingMultisigOperationHeaderModel, newItem: PendingMultisigOperationHeaderModel): Boolean { + return true + } + + override fun areChildItemsTheSame(oldItem: PendingMultisigOperationModel, newItem: PendingMultisigOperationModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areChildContentsTheSame(oldItem: PendingMultisigOperationModel, newItem: PendingMultisigOperationModel): Boolean { + return oldItem == newItem + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/MultisigPendingOperationsFragment.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/MultisigPendingOperationsFragment.kt new file mode 100644 index 0000000..7f00224 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/MultisigPendingOperationsFragment.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.list + +import android.view.View +import androidx.core.view.isVisible +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.domain.onNotLoaded +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_multisig_operations.databinding.FragmentMultisigPendingOperationsBinding +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureComponent +import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationModel +import javax.inject.Inject + +class MultisigPendingOperationsFragment : + BaseFragment(), + MultisigPendingOperationsAdapter.ItemHandler { + + override fun createBinding() = FragmentMultisigPendingOperationsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter: MultisigPendingOperationsAdapter by lazy(LazyThreadSafetyMode.NONE) { MultisigPendingOperationsAdapter(this, imageLoader) } + + override fun applyInsets(rootView: View) { + binder.multisigPendingOperationsToolbar.applyStatusBarInsets() + binder.multisigPendingOperationsList.applyNavigationBarInsets(consume = false) + } + + override fun initViews() { + binder.multisigPendingOperationsList.setHasFixedSize(true) + binder.multisigPendingOperationsList.adapter = adapter + + binder.multisigPendingOperationsToolbar.setHomeButtonListener { viewModel.backClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + MultisigOperationsFeatureApi::class.java + ) + .multisigPendingOperations() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: MultisigPendingOperationsViewModel) { + viewModel.pendingOperationsFlow.observe { + it.onLoaded { data -> + binder.multisigPendingOperationsPlaceholder.isVisible = data.isEmpty() + binder.multisigPendingOperationsProgress.makeGone() + binder.multisigPendingOperationsList.makeVisible() + adapter.submitList(data) + }.onNotLoaded { + binder.multisigPendingOperationsProgress.makeVisible() + binder.multisigPendingOperationsList.makeGone() + } + } + } + + override fun itemClicked(model: PendingMultisigOperationModel) { + viewModel.operationClicked(model) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/MultisigPendingOperationsViewModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/MultisigPendingOperationsViewModel.kt new file mode 100644 index 0000000..bbb0c5d --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/MultisigPendingOperationsViewModel.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.list + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.presentation.ColoredDrawable +import io.novafoundation.nova.common.presentation.ColoredText +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigAction +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.model.userAction +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_multisig_operations.R +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.common.fromOperationId +import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload +import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationHeaderModel +import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationModel +import io.novafoundation.nova.feature_multisig_operations.presentation.list.model.PendingMultisigOperationModel.SigningAction +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class MultisigPendingOperationsViewModel( + discoveryService: MultisigPendingOperationsService, + private val router: MultisigOperationsRouter, + private val resourceManager: ResourceManager, + private val multisigCallFormatter: MultisigCallFormatter, + private val selectedAccountUseCase: SelectedAccountUseCase, +) : BaseViewModel() { + + val account = selectedAccountUseCase.selectedMetaAccountFlow() + + val pendingOperationsFlow = discoveryService.pendingOperations() + .map { operations -> + val account = account.first() + + operations + .sortedByDescending { it.timestamp } + .groupBy { it.timestamp.inWholeDays } + .toSortedMap(Comparator.reverseOrder()) + .toListWithHeaders( + keyMapper = { day, _ -> PendingMultisigOperationHeaderModel(day) }, + valueMapper = { operation -> operation.toUi(account) } + ) + } + .withSafeLoading() + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun operationClicked(model: PendingMultisigOperationModel) { + val operationPayload = MultisigOperationPayload.fromOperationId(model.id) + router.openMultisigOperationDetails(MultisigOperationDetailsPayload(operationPayload)) + } + + private suspend fun PendingMultisigOperation.toUi(selectedAccount: MetaAccount): PendingMultisigOperationModel { + val initialOrigin = selectedAccount.requireAccountIdKeyIn(chain) + val formattedCall = multisigCallFormatter.formatPreview(call, initialOrigin, chain) + + return PendingMultisigOperationModel( + id = operationId, + chain = mapChainToUi(chain), + action = formatAction(), + call = formattedCall, + progress = formatProgress(), + time = resourceManager.formatTime(timestamp.inWholeMilliseconds) + ) + } + + private fun PendingMultisigOperation.formatProgress(): String { + return resourceManager.getString(R.string.multisig_operations_progress, approvals.size.format(), threshold.format()) + } + + private fun PendingMultisigOperation.formatAction(): SigningAction? { + return when (userAction()) { + is MultisigAction.CanApprove -> null + + is MultisigAction.CanReject -> SigningAction( + text = ColoredText( + text = resourceManager.getText(R.string.multisig_operations_created), + colorRes = R.color.text_secondary + ), + icon = null + ) + + MultisigAction.Signed -> SigningAction( + text = ColoredText( + text = resourceManager.getText(R.string.multisig_operations_signed), + colorRes = R.color.text_positive + ), + icon = ColoredDrawable( + drawableRes = R.drawable.ic_checkmark_circle_16, + iconColor = R.color.icon_positive + ) + ) + } + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/di/MultisigPendingOperationsComponent.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/di/MultisigPendingOperationsComponent.kt new file mode 100644 index 0000000..a128514 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/di/MultisigPendingOperationsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_multisig_operations.presentation.list.MultisigPendingOperationsFragment + +@Subcomponent( + modules = [ + MultisigPendingOperationsModule::class + ] +) +@ScreenScope +interface MultisigPendingOperationsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): MultisigPendingOperationsComponent + } + + fun inject(fragment: MultisigPendingOperationsFragment) +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/di/MultisigPendingOperationsModule.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/di/MultisigPendingOperationsModule.kt new file mode 100644 index 0000000..7e9fc8c --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/di/MultisigPendingOperationsModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_multisig_operations.presentation.MultisigOperationsRouter +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.list.MultisigPendingOperationsViewModel + +@Module(includes = [ViewModelModule::class]) +class MultisigPendingOperationsModule { + + @Provides + @IntoMap + @ViewModelKey(MultisigPendingOperationsViewModel::class) + fun provideViewModel( + discoveryService: MultisigPendingOperationsService, + router: MultisigOperationsRouter, + resourceManager: ResourceManager, + multisigCallFormatter: MultisigCallFormatter, + accountUseCase: SelectedAccountUseCase, + ): ViewModel { + return MultisigPendingOperationsViewModel( + discoveryService = discoveryService, + router = router, + resourceManager = resourceManager, + multisigCallFormatter = multisigCallFormatter, + selectedAccountUseCase = accountUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): MultisigPendingOperationsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(MultisigPendingOperationsViewModel::class.java) + } +} diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/model/PendingMultisigOperationHeaderModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/model/PendingMultisigOperationHeaderModel.kt new file mode 100644 index 0000000..0e41b9c --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/model/PendingMultisigOperationHeaderModel.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.list.model + +class PendingMultisigOperationHeaderModel(val daysSinceEpoch: Long) diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/model/PendingMultisigOperationModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/model/PendingMultisigOperationModel.kt new file mode 100644 index 0000000..ecad789 --- /dev/null +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/list/model/PendingMultisigOperationModel.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_multisig_operations.presentation.list.model + +import io.novafoundation.nova.common.presentation.ColoredDrawable +import io.novafoundation.nova.common.presentation.ColoredText +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallPreviewModel + +data class PendingMultisigOperationModel( + val id: PendingMultisigOperationId, + val chain: ChainUi, + val action: SigningAction?, + val call: MultisigCallPreviewModel, + val time: String?, + val progress: String +) { + + data class SigningAction( + val text: ColoredText, + val icon: ColoredDrawable? + ) +} diff --git a/feature-multisig/operations/src/main/res/layout/fragment_multisig_operation_details.xml b/feature-multisig/operations/src/main/res/layout/fragment_multisig_operation_details.xml new file mode 100644 index 0000000..7ab7bc9 --- /dev/null +++ b/feature-multisig/operations/src/main/res/layout/fragment_multisig_operation_details.xml @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-multisig/operations/src/main/res/layout/fragment_multisig_operation_enter_call.xml b/feature-multisig/operations/src/main/res/layout/fragment_multisig_operation_enter_call.xml new file mode 100644 index 0000000..ff38ab7 --- /dev/null +++ b/feature-multisig/operations/src/main/res/layout/fragment_multisig_operation_enter_call.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature-multisig/operations/src/main/res/layout/fragment_multisig_operation_full_details.xml b/feature-multisig/operations/src/main/res/layout/fragment_multisig_operation_full_details.xml new file mode 100644 index 0000000..8b3fea9 --- /dev/null +++ b/feature-multisig/operations/src/main/res/layout/fragment_multisig_operation_full_details.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-multisig/operations/src/main/res/layout/fragment_multisig_pending_operations.xml b/feature-multisig/operations/src/main/res/layout/fragment_multisig_pending_operations.xml new file mode 100644 index 0000000..95de7f3 --- /dev/null +++ b/feature-multisig/operations/src/main/res/layout/fragment_multisig_pending_operations.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-multisig/operations/src/main/res/layout/item_multisig_pending_operation.xml b/feature-multisig/operations/src/main/res/layout/item_multisig_pending_operation.xml new file mode 100644 index 0000000..c95612c --- /dev/null +++ b/feature-multisig/operations/src/main/res/layout/item_multisig_pending_operation.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-multisig/operations/src/main/res/layout/item_multisig_pending_operation_header.xml b/feature-multisig/operations/src/main/res/layout/item_multisig_pending_operation_header.xml new file mode 100644 index 0000000..1e6b88d --- /dev/null +++ b/feature-multisig/operations/src/main/res/layout/item_multisig_pending_operation_header.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/feature-multisig/operations/src/main/res/layout/item_multisig_signatory_account.xml b/feature-multisig/operations/src/main/res/layout/item_multisig_signatory_account.xml new file mode 100644 index 0000000..4439a03 --- /dev/null +++ b/feature-multisig/operations/src/main/res/layout/item_multisig_signatory_account.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-multisig/operations/src/main/res/layout/item_signatory_shimmering.xml b/feature-multisig/operations/src/main/res/layout/item_signatory_shimmering.xml new file mode 100644 index 0000000..0af7668 --- /dev/null +++ b/feature-multisig/operations/src/main/res/layout/item_signatory_shimmering.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/feature-nft-api/.gitignore b/feature-nft-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-nft-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-nft-api/build.gradle b/feature-nft-api/build.gradle new file mode 100644 index 0000000..16492b9 --- /dev/null +++ b/feature-nft-api/build.gradle @@ -0,0 +1,29 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_nft_api' +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":feature-account-api") + implementation project(":feature-wallet-api") + implementation project(":common") + + implementation androidDep + implementation materialDep + + implementation daggerDep + + implementation substrateSdkDep + + implementation constraintDep + + implementation lifeCycleKtxDep + + api project(':core-api') + api project(':core-db') + + testImplementation project(':test-shared') +} \ No newline at end of file diff --git a/feature-nft-api/consumer-rules.pro b/feature-nft-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-nft-api/proguard-rules.pro b/feature-nft-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-nft-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-nft-api/src/main/AndroidManifest.xml b/feature-nft-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-nft-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/NftFeatureApi.kt b/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/NftFeatureApi.kt new file mode 100644 index 0000000..376c971 --- /dev/null +++ b/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/NftFeatureApi.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_nft_api + +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository + +interface NftFeatureApi { + + val nftRepository: NftRepository +} diff --git a/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/data/model/Nft.kt b/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/data/model/Nft.kt new file mode 100644 index 0000000..8c556e7 --- /dev/null +++ b/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/data/model/Nft.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_nft_api.data.model + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +class Nft( + val identifier: String, + val instanceId: String?, + val collectionId: String, + val chain: Chain, + val owner: AccountId, + val metadataRaw: ByteArray?, + val details: Details, + val type: Type, +) { + + sealed class Details { + + class Loaded( + val price: Price?, + val issuance: Issuance, + val name: String?, + val label: String?, + val media: String?, + ) : Details() + + object Loadable : Details() + } + + sealed class Price { + + class NonFungible(val nftPrice: BigInteger) : Price() + + class Fungible(val units: BigInteger, val totalPrice: Balance) : Price() + } + + sealed class Issuance { + + object Unlimited : Issuance() + + class Limited(val max: Int, val edition: Int) : Issuance() + + class Fungible(val myAmount: BigInteger, val totalSupply: BigInteger) : Issuance() + } + + sealed class Type(val key: Key) { + + enum class Key { + UNIQUES, RMRKV1, RMRKV2, PDC20, KODADOT, UNIQUE_NETWORK + } + + object Uniques : Type(Key.UNIQUES) + + object Rmrk1 : Type(Key.RMRKV1) + + object Rmrk2 : Type(Key.RMRKV2) + + object Pdc20 : Type(Key.PDC20) + + object Kodadot : Type(Key.KODADOT) + + object UniqueNetwork : Type(Key.UNIQUE_NETWORK) + } +} + +val Nft.isFullySynced + get() = details is Nft.Details.Loaded diff --git a/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/data/model/NftDetails.kt b/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/data/model/NftDetails.kt new file mode 100644 index 0000000..487589a --- /dev/null +++ b/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/data/model/NftDetails.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_nft_api.data.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class NftDetails( + val identifier: String, + val chain: Chain, + val owner: AccountId, + val creator: AccountId?, + val media: String?, + val name: String, + val description: String?, + val issuance: Nft.Issuance, + val price: Nft.Price?, + val collection: Collection? +) { + + class Collection( + val id: String, + val name: String? = null, + val media: String? = null + ) +} diff --git a/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/data/repository/NftRepository.kt b/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/data/repository/NftRepository.kt new file mode 100644 index 0000000..c94c32d --- /dev/null +++ b/feature-nft-api/src/main/java/io/novafoundation/nova/feature_nft_api/data/repository/NftRepository.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_nft_api.data.repository + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +interface NftRepository { + + fun allNftFlow(metaAccount: MetaAccount): Flow> + + fun nftDetails(nftId: String): Flow + + fun initialNftSyncTrigger(): Flow + + suspend fun initialNftSync(metaAccount: MetaAccount, forceOverwrite: Boolean) + + suspend fun initialNftSync(metaAccount: MetaAccount, chain: Chain) + + suspend fun fullNftSync(nft: Nft) +} + +class NftSyncTrigger(val chain: Chain) diff --git a/feature-nft-impl/.gitignore b/feature-nft-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-nft-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-nft-impl/build.gradle b/feature-nft-impl/build.gradle new file mode 100644 index 0000000..906be63 --- /dev/null +++ b/feature-nft-impl/build.gradle @@ -0,0 +1,67 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.feature_nft_impl' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-wallet-api') + implementation project(':feature-account-api') + implementation project(':feature-nft-api') + implementation project(':runtime') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + + implementation daggerDep + ksp daggerCompiler + + implementation roomDep + ksp roomCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation substrateSdkDep + + implementation gsonDep + implementation retrofitDep + + implementation insetterDep + + implementation shimmerDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} \ No newline at end of file diff --git a/feature-nft-impl/consumer-rules.pro b/feature-nft-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-nft-impl/proguard-rules.pro b/feature-nft-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-nft-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-nft-impl/src/main/AndroidManifest.xml b/feature-nft-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-nft-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/NftRouter.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/NftRouter.kt new file mode 100644 index 0000000..d025a27 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/NftRouter.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_nft_impl + +interface NftRouter { + + fun openNftDetails(nftId: String) + + fun back() +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/mappers/Nft.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/mappers/Nft.kt new file mode 100644 index 0000000..c85490b --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/mappers/Nft.kt @@ -0,0 +1,104 @@ +package io.novafoundation.nova.feature_nft_impl.data.mappers + +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.core_db.model.NftLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger + +fun mapNftTypeLocalToTypeKey( + typeLocal: NftLocal.Type +): Nft.Type.Key = when (typeLocal) { + NftLocal.Type.UNIQUES -> Nft.Type.Key.UNIQUES + NftLocal.Type.RMRK1 -> Nft.Type.Key.RMRKV1 + NftLocal.Type.RMRK2 -> Nft.Type.Key.RMRKV2 + NftLocal.Type.PDC20 -> Nft.Type.Key.PDC20 + NftLocal.Type.KODADOT -> Nft.Type.Key.KODADOT + NftLocal.Type.UNIQUE_NETWORK -> Nft.Type.Key.UNIQUE_NETWORK +} + +fun nftIssuance( + typeLocal: NftLocal.IssuanceType, + issuanceTotal: BigInteger?, + issuanceMyEdition: String?, + issuanceMyAmount: BigInteger? +): Nft.Issuance { + return when (typeLocal) { + NftLocal.IssuanceType.UNLIMITED -> Nft.Issuance.Unlimited + + NftLocal.IssuanceType.LIMITED -> { + val myEditionInt = issuanceMyEdition?.toIntOrNull() + + if (issuanceTotal != null && !issuanceTotal.isZero && myEditionInt != null) { + Nft.Issuance.Limited(max = issuanceTotal.toInt(), edition = myEditionInt) + } else { + Nft.Issuance.Unlimited + } + } + NftLocal.IssuanceType.FUNGIBLE -> if (issuanceTotal != null && issuanceMyAmount != null) { + Nft.Issuance.Fungible(myAmount = issuanceMyAmount, totalSupply = issuanceTotal) + } else { + Nft.Issuance.Unlimited + } + } +} + +fun nftIssuance(nftLocal: NftLocal): Nft.Issuance { + require(nftLocal.wholeDetailsLoaded) + + return nftIssuance(nftLocal.issuanceType, nftLocal.issuanceTotal, nftLocal.issuanceMyEdition, nftLocal.issuanceMyAmount) +} + +fun nftPrice(nftLocal: NftLocal): Nft.Price? { + val price = nftLocal.price + if (price == null || price == BigInteger.ZERO) return null + + return when (val units = nftLocal.pricedUnits) { + null -> Nft.Price.NonFungible(price) + else -> Nft.Price.Fungible(units = units, totalPrice = price) + } +} + +fun mapNftLocalToNft( + chainsById: Map, + metaAccount: MetaAccount, + nftLocal: NftLocal +): Nft? { + val chain = chainsById[nftLocal.chainId] ?: return null + + val type = when (nftLocal.type) { + NftLocal.Type.UNIQUES -> Nft.Type.Uniques + NftLocal.Type.RMRK1 -> Nft.Type.Rmrk1 + NftLocal.Type.RMRK2 -> Nft.Type.Rmrk2 + NftLocal.Type.PDC20 -> Nft.Type.Pdc20 + NftLocal.Type.KODADOT -> Nft.Type.Kodadot + NftLocal.Type.UNIQUE_NETWORK -> Nft.Type.UniqueNetwork + } + + val details = if (nftLocal.wholeDetailsLoaded) { + val issuance = nftIssuance(nftLocal) + + Nft.Details.Loaded( + name = nftLocal.name, + label = nftLocal.label, + media = nftLocal.media, + price = nftPrice(nftLocal), + issuance = issuance, + ) + } else { + Nft.Details.Loadable + } + + return Nft( + identifier = nftLocal.identifier, + instanceId = nftLocal.instanceId, + collectionId = nftLocal.collectionId, + chain = chain, + owner = metaAccount.accountIdIn(chain)!!, + metadataRaw = nftLocal.metadata, + type = type, + details = details + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/network/distributed/FileStorageAdapter.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/network/distributed/FileStorageAdapter.kt new file mode 100644 index 0000000..9e11a9a --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/network/distributed/FileStorageAdapter.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_nft_impl.data.network.distributed + +enum class FileStorage(val prefix: String, val additionalPaths: List, val defaultHttpsGateway: String?) { + IPFS("ipfs://", listOf("ipfs/"), "https://bucket.chaotic.art/ipfs/"), + HTTPS("https://", emptyList(), null), + HTTP("http://", emptyList(), null); + + init { + validateHttpsGateway(defaultHttpsGateway) + } +} + +private fun validateHttpsGateway(gateway: String?) { + require(gateway == null || gateway.endsWith("/")) { + "Gateway should end with '/' separator" + } +} + +object FileStorageAdapter { + + fun String.adoptFileStorageLinkToHttps() = adaptToHttps(this) + + fun adaptToHttps(distributedStorageLink: String): String { + val distributedStorage = FileStorage.values().firstOrNull { storage -> + distributedStorageLink.pointsTo(storage) + } ?: FileStorage.IPFS + + val gateway = distributedStorage.defaultHttpsGateway ?: return distributedStorageLink + + validateHttpsGateway(gateway) + + var path = distributedStorageLink.removePrefix(distributedStorage.prefix) + distributedStorage.additionalPaths.forEach { + path = path.removePrefix(it) + } + + return "$gateway$path" + } + + private fun String.pointsTo(fileStorage: FileStorage) = startsWith(fileStorage.prefix) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/repository/NftRepositoryImpl.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/repository/NftRepositoryImpl.kt new file mode 100644 index 0000000..dc260c6 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/repository/NftRepositoryImpl.kt @@ -0,0 +1,123 @@ +package io.novafoundation.nova.feature_nft_impl.data.repository + +import android.util.Log +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.utils.transformLatestDiffed +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_nft_api.data.repository.NftSyncTrigger +import io.novafoundation.nova.feature_nft_impl.data.mappers.mapNftLocalToNft +import io.novafoundation.nova.feature_nft_impl.data.mappers.mapNftTypeLocalToTypeKey +import io.novafoundation.nova.feature_nft_impl.data.source.JobOrchestrator +import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.NftProvidersRegistry +import io.novafoundation.nova.runtime.ext.level +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.enabledChainById +import io.novafoundation.nova.runtime.multiNetwork.enabledChains +import io.novafoundation.nova.runtime.multiNetwork.enabledChainsFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val NFT_TAG = "NFT" + +class NftRepositoryImpl( + private val nftProvidersRegistry: NftProvidersRegistry, + private val chainRegistry: ChainRegistry, + private val jobOrchestrator: JobOrchestrator, + private val nftDao: NftDao, + private val exceptionHandler: HttpExceptionHandler, +) : NftRepository { + + override fun allNftFlow(metaAccount: MetaAccount): Flow> { + return nftDao.nftsFlow(metaAccount.id) + .map { nftsLocal -> + val chainsById = chainRegistry.enabledChainById() + + nftsLocal.mapNotNull { nftLocal -> + mapNftLocalToNft(chainsById, metaAccount, nftLocal) + } + } + } + + override fun nftDetails(nftId: String): Flow { + return flow { + val nftTypeKey = mapNftTypeLocalToTypeKey(nftDao.getNftType(nftId)) + val nftProvider = nftProvidersRegistry.get(nftTypeKey) + + emitAll(nftProvider.nftDetailsFlow(nftId)) + }.catch { throw exceptionHandler.transformException(it) } + } + + override fun initialNftSyncTrigger(): Flow { + return chainRegistry.enabledChainsFlow() + .map { chains -> chains.filter { nftProvidersRegistry.nftSupported(it) } } + .transformLatestDiffed { emit(NftSyncTrigger(it)) } + } + + override suspend fun initialNftSync( + metaAccount: MetaAccount, + forceOverwrite: Boolean, + ): Unit = withContext(Dispatchers.IO) { + val chains = chainRegistry.enabledChains() + + val syncJobs = chains.flatMap { chain -> + nftSyncJobs(chain, metaAccount, forceOverwrite) + } + + syncJobs.joinAll() + } + + override suspend fun initialNftSync(metaAccount: MetaAccount, chain: Chain) = withContext(Dispatchers.IO) { + val syncJobs = nftSyncJobs(chain, metaAccount, forceOverwrite = false) + + syncJobs.joinAll() + } + + private fun CoroutineScope.nftSyncJobs(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean): List { + return nftProvidersRegistry.get(chain) + .filter { it.canSyncIn(chain) } + .map { nftProvider -> + // launch separate job per each nftProvider + launch { + // prevent whole sync from failing if some particular provider fails + runCatching { + nftProvider.initialNftsSync(chain, metaAccount, forceOverwrite) + + Log.d(NFT_TAG, "Completed sync in ${chain.name} using ${nftProvider::class.simpleName}") + }.onFailure { + Log.e(NFT_TAG, "Failed to sync nfts in ${chain.name} using ${nftProvider::class.simpleName}", it) + } + } + } + } + + override suspend fun fullNftSync(nft: Nft) = withContext(Dispatchers.IO) { + jobOrchestrator.runUniqueJob(nft.identifier) { + runCatching { + nftProvidersRegistry.get(nft.type.key).nftFullSync(nft) + }.onFailure { + Log.e(NFT_TAG, "Failed to fully sync nft ${nft.identifier} in ${nft.chain.name} with type ${nft.type::class.simpleName}", it) + } + } + } + + private fun NftProvider.canSyncIn(chain: Chain): Boolean { + val requiredStage = if (requireFullChainSync) Chain.ConnectionState.FULL_SYNC else Chain.ConnectionState.LIGHT_SYNC + + return chain.connectionState.level >= requiredStage.level + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/JobOrchestrator.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/JobOrchestrator.kt new file mode 100644 index 0000000..9653869 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/JobOrchestrator.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_nft_impl.data.source + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.coroutineContext + +class JobOrchestrator { + + private val runningJobs: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) + + private val mutex = Mutex() + + suspend fun runUniqueJob(id: String, action: suspend () -> Unit) = mutex.withLock { + if (id in runningJobs) { + return@withLock + } + + runningJobs += id + + CoroutineScope(coroutineContext).async { action() } + .invokeOnCompletion { runningJobs -= id } + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/NftProvider.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/NftProvider.kt new file mode 100644 index 0000000..6f2577e --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/NftProvider.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_nft_impl.data.source + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +interface NftProvider { + + val requireFullChainSync: Boolean + + suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) + + suspend fun nftFullSync(nft: Nft) + + fun nftDetailsFlow(nftIdentifier: String): Flow +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/NftProvidersRegistry.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/NftProvidersRegistry.kt new file mode 100644 index 0000000..01959ab --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/NftProvidersRegistry.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_nft_impl.data.source + +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.KodadotProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.Pdc20Provider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.RmrkV1NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.RmrkV2NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.UniquesNftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.UniqueNetworkNftProvider +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class NftProvidersRegistry( + private val uniquesNftProvider: UniquesNftProvider, + private val rmrkV1NftProvider: RmrkV1NftProvider, + private val rmrkV2NftProvider: RmrkV2NftProvider, + private val pdc20Provider: Pdc20Provider, + private val kodadotProvider: KodadotProvider, + private val uniqueNetworkNftProvider: UniqueNetworkNftProvider, +) { + + private val kusamaAssetHubProviders = listOf(uniquesNftProvider, kodadotProvider) + private val kusamaProviders = listOf(rmrkV1NftProvider, rmrkV2NftProvider) + private val polkadotProviders = listOf(pdc20Provider) + private val polkadotAssetHubProviders = listOf(kodadotProvider) + private val uniqueNetworkProviders = listOf(uniqueNetworkNftProvider) + + fun get(chain: Chain): List { + return when (chain.id) { + Chain.Geneses.KUSAMA_ASSET_HUB -> kusamaAssetHubProviders + Chain.Geneses.KUSAMA -> kusamaProviders + Chain.Geneses.POLKADOT -> polkadotProviders + Chain.Geneses.POLKADOT_ASSET_HUB -> polkadotAssetHubProviders + Chain.Geneses.UNIQUE_NETWORK -> uniqueNetworkProviders + else -> emptyList() + } + } + + fun nftSupported(chain: Chain): Boolean { + return get(chain).isNotEmpty() + } + + fun get(nftTypeKey: Nft.Type.Key): NftProvider { + return when (nftTypeKey) { + Nft.Type.Key.RMRKV1 -> rmrkV1NftProvider + Nft.Type.Key.RMRKV2 -> rmrkV2NftProvider + Nft.Type.Key.UNIQUES -> uniquesNftProvider + Nft.Type.Key.PDC20 -> pdc20Provider + Nft.Type.Key.KODADOT -> kodadotProvider + Nft.Type.Key.UNIQUE_NETWORK -> uniqueNetworkNftProvider + } + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/KodadotProvider.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/KodadotProvider.kt new file mode 100644 index 0000000..d23aa64 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/KodadotProvider.kt @@ -0,0 +1,147 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot + +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.scopeAsync +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.core_db.model.NftLocal +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice +import io.novafoundation.nova.feature_nft_impl.data.network.distributed.FileStorageAdapter.adoptFileStorageLinkToHttps +import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.KodadotApi +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotCollectionRequest +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotMetadataRequest +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotNftRemote +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotNftsRequest +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotCollectionRemote +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotMetadataRemote +import io.novafoundation.nova.runtime.ext.ChainGeneses +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger +import kotlinx.coroutines.flow.Flow + +private const val NO_COLLECTION_LOCAL_ID = "no_collection_local_id" + +class KodadotProvider( + private val api: KodadotApi, + private val nftDao: NftDao, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, +) : NftProvider { + + override val requireFullChainSync: Boolean = false + + override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) { + val address = metaAccount.addressIn(chain) ?: return + + val apiUrl = getApiUrl(chain) ?: return + val request = KodadotNftsRequest(address) + val nfts = api.getNfts(apiUrl, request) + + val toSave = nfts.data.nftEntities.map { nftRemote -> + NftLocal( + identifier = nftIdentifier(chain, nftRemote), + metaId = metaAccount.id, + chainId = chain.id, + collectionId = nftRemote.collection?.id ?: NO_COLLECTION_LOCAL_ID, + instanceId = nftRemote.id, + metadata = nftRemote.metadata?.encodeToByteArray(), + type = NftLocal.Type.KODADOT, + wholeDetailsLoaded = true, + name = nftRemote.name, + label = nftRemote.sn, + media = nftRemote.image?.adoptFileStorageLinkToHttps(), + issuanceType = nftRemote.collection?.max?.let { NftLocal.IssuanceType.LIMITED } ?: NftLocal.IssuanceType.UNLIMITED, + issuanceTotal = nftRemote.collection?.max?.let { BigInteger(it) }, + issuanceMyAmount = null, + price = nftRemote.price?.let { BigInteger(it) }, + pricedUnits = null + ) + } + + nftDao.insertNftsDiff(NftLocal.Type.KODADOT, chain.id, metaAccount.id, toSave, forceOverwrite) + } + + override suspend fun nftFullSync(nft: Nft) { + // do nothing + } + + override fun nftDetailsFlow(nftIdentifier: String): Flow { + return flowOf { + val nftLocal = nftDao.getNft(nftIdentifier) + require(nftLocal.wholeDetailsLoaded) { + "Cannot load details of non fully-synced NFT" + } + + val chain = chainRegistry.getChain(nftLocal.chainId) + val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId) + + val metadataDeferred = scopeAsync { fetchMetadata(nftLocal, chain) } + val collectionDeferred = scopeAsync { fetchCollection(nftLocal, chain) } + + val metadata = metadataDeferred.await() + val collection = collectionDeferred.await() + + NftDetails( + identifier = nftLocal.identifier, + chain = chain, + owner = metaAccount.requireAccountIdIn(chain), + creator = collection?.issuer?.let { chain.accountIdOf(it) }, + media = metadata?.image?.adoptFileStorageLinkToHttps() ?: nftLocal.media, + name = metadata?.name ?: nftLocal.name ?: nftLocal.instanceId!!, + description = metadata?.description, + issuance = nftIssuance(nftLocal), + price = nftPrice(nftLocal), + collection = collection?.let { + NftDetails.Collection( + id = nftLocal.collectionId, + name = it.name, + media = it.image + ) + } + ) + } + } + + private suspend fun fetchMetadata(nftLocal: NftLocal, chain: Chain): KodadotMetadataRemote? { + val metadataId = nftLocal.metadata?.decodeToString() ?: return null + val apiUrl = getApiUrl(chain) ?: return null + val request = KodadotMetadataRequest(metadataId) + return api.getMetadata(apiUrl, request) + .data + .metadataEntityById + } + + private suspend fun fetchCollection(nftLocal: NftLocal, chain: Chain): KodadotCollectionRemote? { + val collectionId = nftLocal.collectionId + if (collectionId == NO_COLLECTION_LOCAL_ID) { + return null + } + + val apiUrl = getApiUrl(chain) ?: return null + val request = KodadotCollectionRequest(collectionId) + return api.getCollection(apiUrl, request) + .data + .collectionEntityById + } + + private fun nftIdentifier(chain: Chain, nft: KodadotNftRemote): String { + return "kodadot-${chain.id}-${nft.id}" + } + + private fun getApiUrl(chain: Chain): String? { + return when (chain.id) { + ChainGeneses.POLKADOT_ASSET_HUB -> KodadotApi.POLKADOT_ASSET_HUB_URL + ChainGeneses.KUSAMA_ASSET_HUB -> KodadotApi.KUSAMA_ASSET_HUB_URL + else -> null + } + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/KodadotApi.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/KodadotApi.kt new file mode 100644 index 0000000..57a7a7d --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/KodadotApi.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotCollectionRequest +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotMetadataRequest +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request.KodadotNftsRequest +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotCollectionResponse +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotMetadataResponse +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response.KodadotNftResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface KodadotApi { + + companion object { + const val POLKADOT_ASSET_HUB_URL = "https://ahp.gql.api.kodadot.xyz" + const val KUSAMA_ASSET_HUB_URL = "https://ahk.gql.api.kodadot.xyz" + } + + @POST + suspend fun getNfts(@Url url: String, @Body request: KodadotNftsRequest): SubQueryResponse + + @POST + suspend fun getCollection(@Url url: String, @Body request: KodadotCollectionRequest): SubQueryResponse + + @POST + suspend fun getMetadata(@Url url: String, @Body request: KodadotMetadataRequest): SubQueryResponse +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/request/KodadotCollectionRequest.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/request/KodadotCollectionRequest.kt new file mode 100644 index 0000000..b9cc6a6 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/request/KodadotCollectionRequest.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request + +class KodadotCollectionRequest(collectionId: String) { + + val query = """ + { + collectionEntityById(id: "$collectionId") { + name + image + issuer + } + } + """.trimIndent() +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/request/KodadotMetadataRequest.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/request/KodadotMetadataRequest.kt new file mode 100644 index 0000000..d6ad548 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/request/KodadotMetadataRequest.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request + +class KodadotMetadataRequest(metadataId: String) { + + val query = """ + { + metadataEntityById(id: "$metadataId") { + image + name + type + description + } + } + """.trimIndent() +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/request/KodadotNftsRequest.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/request/KodadotNftsRequest.kt new file mode 100644 index 0000000..af8bd9a --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/request/KodadotNftsRequest.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.request + +class KodadotNftsRequest(userAddress: String) { + + val query = """ + query nftListByOwner(${'$'}id: String!) { + nftEntities(where: {currentOwner_eq: ${'$'}id, burned_eq: false}) { + id + image + metadata + name + price + sn + currentOwner + collection { + id + max + } + } + } + """.trimIndent() + + val variables = mapOf("id" to userAddress) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/response/KodadotCollectionResponse.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/response/KodadotCollectionResponse.kt new file mode 100644 index 0000000..f67dc8d --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/response/KodadotCollectionResponse.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response + +class KodadotCollectionResponse( + val collectionEntityById: KodadotCollectionRemote? +) + +class KodadotCollectionRemote( + val name: String?, + val image: String?, + val issuer: String? +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/response/KodadotMetadataResponse.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/response/KodadotMetadataResponse.kt new file mode 100644 index 0000000..ec4ac61 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/response/KodadotMetadataResponse.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response + +class KodadotMetadataResponse( + val metadataEntityById: KodadotMetadataRemote? +) + +class KodadotMetadataRemote( + val name: String?, + val description: String?, + val type: String?, + val image: String? +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/response/KodadotNftResponse.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/response/KodadotNftResponse.kt new file mode 100644 index 0000000..6ca7082 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/kodadot/network/response/KodadotNftResponse.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.response + +class KodadotNftResponse( + val nftEntities: List +) + +class KodadotNftRemote( + val id: String, + val image: String?, + val metadata: String?, + val name: String?, + val price: String?, + val sn: String?, + val currentOwner: String, + val collection: Collection? +) { + + class Collection( + val id: String, + val max: String + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/Pdc20Provider.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/Pdc20Provider.kt new file mode 100644 index 0000000..9f0fa63 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/Pdc20Provider.kt @@ -0,0 +1,111 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20 + +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.core_db.model.NftLocal +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice +import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network.Pdc20Api +import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network.Pdc20Listing +import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network.Pdc20Request +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class Pdc20Provider( + private val api: Pdc20Api, + private val nftDao: NftDao, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, +) : NftProvider { + + override val requireFullChainSync: Boolean = false + + override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) { + val address = metaAccount.addressIn(chain) ?: return + + val request = Pdc20Request(address, network = Pdc20Api.NETWORK_POLKADOT) + val nfts = api.getNfts(request) + + val aggregatedListingsByToken = nfts.data.listings.groupBy { it.token.id } + .mapValues { (_, listings) -> + listings.reduce(Pdc20Listing::plus) + } + + val toSave = nfts.data.userTokenBalances.map { nftRemote -> + val listing = aggregatedListingsByToken[nftRemote.token.id] + + NftLocal( + identifier = nftRemote.token.id, + metaId = metaAccount.id, + chainId = chain.id, + collectionId = nftRemote.token.id, + instanceId = nftRemote.token.id, + metadata = null, + type = NftLocal.Type.PDC20, + wholeDetailsLoaded = true, + name = nftRemote.token.ticker, + label = null, + media = nftRemote.token.logo, + issuanceType = NftLocal.IssuanceType.FUNGIBLE, + // We dont know if supply or holding amount can be fractional or not so we are behaving safe + issuanceTotal = nftRemote.token.totalSupply?.toBigIntegerOrNull(), + issuanceMyAmount = nftRemote.balance.toBigIntegerOrNull(), + price = listing?.value?.let { chain.utilityAsset.planksFromAmount(it) }, + pricedUnits = listing?.amount + ) + } + + nftDao.insertNftsDiff(NftLocal.Type.PDC20, chain.id, metaAccount.id, toSave, forceOverwrite) + } + + override suspend fun nftFullSync(nft: Nft) { + // do nothing + } + + override fun nftDetailsFlow(nftIdentifier: String): Flow { + return flowOf { + val nftLocal = nftDao.getNft(nftIdentifier) + require(nftLocal.wholeDetailsLoaded) { + "Cannot load details of non fully-synced NFT" + } + + val chain = chainRegistry.getChain(nftLocal.chainId) + val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId) + + NftDetails( + identifier = nftLocal.identifier, + chain = chain, + owner = metaAccount.requireAccountIdIn(chain), + creator = null, + media = nftLocal.media, + name = nftLocal.name ?: nftLocal.instanceId!!, + description = null, + issuance = nftIssuance(nftLocal), + price = nftPrice(nftLocal), + collection = null // pdc20 token is the same as collection + ) + } + } +} + +private operator fun Pdc20Listing.plus(other: Pdc20Listing): Pdc20Listing { + require(this.from.address == other.from.address) + require(this.token.id == other.token.id) + + return Pdc20Listing( + from = from, + token = token, + amount = amount + other.amount, + value = value + other.value + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/network/Pdc20Api.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/network/Pdc20Api.kt new file mode 100644 index 0000000..5cb73ce --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/network/Pdc20Api.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface Pdc20Api { + + companion object { + const val NETWORK_POLKADOT = "polkadot" + } + + @POST("https://squid.subsquid.io/dot-ordinals/graphql") + suspend fun getNfts(@Body request: Pdc20Request): SubQueryResponse +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/network/Pdc20NftResponse.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/network/Pdc20NftResponse.kt new file mode 100644 index 0000000..7b1ff88 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/network/Pdc20NftResponse.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network + +import java.math.BigDecimal +import java.math.BigInteger + +class Pdc20NftResponse( + val userTokenBalances: List, + val listings: List +) + +class Pdc20NftRemote( + val balance: String, + val address: PdcAddress, + val token: Token +) { + + class Token( + val id: String, + val logo: String?, + val ticker: String?, + val totalSupply: String?, + val network: String + ) +} + +class Pdc20Listing( + val from: PdcAddress, + val token: Token, + val amount: BigInteger, + val value: BigDecimal +) { + + class Token( + val id: String + ) +} + +class PdcAddress(val address: String) + +class RmrkV1NftMetadataRemote( + val image: String, + val description: String +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/network/Pdc20Request.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/network/Pdc20Request.kt new file mode 100644 index 0000000..b2a3974 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/pdc20/network/Pdc20Request.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network + +class Pdc20Request(userAddress: String, network: String) { + + val query = """ + query { + userTokenBalances( + where: { + address: { + address_eq: "$userAddress" + } + standard_eq: "pdc-20" + token: { network_eq: "$network" } + } + ) { + balance + address { + address + } + token { + id + logo + ticker + totalSupply + network + } + } + + listings( + where: { + from: { address_eq: "$userAddress" } + standard_eq: "pdc-20" + token: { network_eq: "$network" } + } + ) { + from { + address + } + + token { + id + } + + amount + value + } + } + """.trimIndent() +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV1/RmrkV1NftProvider.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV1/RmrkV1NftProvider.kt new file mode 100644 index 0000000..d1cafd5 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV1/RmrkV1NftProvider.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1 + +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.core_db.model.NftLocal +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice +import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.network.RmrkV1Api +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class RmrkV1NftProvider( + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val api: RmrkV1Api, + private val nftDao: NftDao +) : NftProvider { + + override val requireFullChainSync: Boolean = false + + override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) { + throw UnsupportedOperationException("RmrkV1 not supported") + } + + override suspend fun nftFullSync(nft: Nft) { + throw UnsupportedOperationException("RmrkV1 not supported") + } + + override fun nftDetailsFlow(nftIdentifier: String): Flow { + return flowOf { + val nftLocal = nftDao.getNft(nftIdentifier) + require(nftLocal.wholeDetailsLoaded) { + "Cannot load details of non fully-synced NFT" + } + val chain = chainRegistry.getChain(nftLocal.chainId) + val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId) + + NftDetails( + identifier = nftLocal.identifier, + chain = chain, + owner = metaAccount.accountIdIn(chain)!!, + creator = null, + media = nftLocal.media, + name = nftLocal.name!!, + description = nftLocal.label, + issuance = nftIssuance(NftLocal.IssuanceType.LIMITED, nftLocal.issuanceTotal, nftLocal.issuanceMyEdition, nftLocal.issuanceMyAmount), + price = nftPrice(nftLocal), + collection = null + ) + } + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV1/network/RmrkV1Api.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV1/network/RmrkV1Api.kt new file mode 100644 index 0000000..0ecc6b3 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV1/network/RmrkV1Api.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.network + +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Url + +interface RmrkV1Api { + + companion object { + const val BASE_URL = "https://singular.rmrk-api.xyz/api/" + } + + @GET("https://singular.rmrk.app/api/rmrk1/collection/{collectionId}") + suspend fun getCollection(@Path("collectionId") collectionId: String): List + + @GET + suspend fun getIpfsMetadata(@Url url: String): RmrkV1NftMetadataRemote +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV1/network/RmrkV1NftRemote.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV1/network/RmrkV1NftRemote.kt new file mode 100644 index 0000000..ab6f3fd --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV1/network/RmrkV1NftRemote.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.network + +class RmrkV1CollectionRemote( + val max: Int, + val name: String, + val issuer: String, + val metadata: String? +) + +class RmrkV1NftMetadataRemote( + val image: String, + val description: String +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV2/RmrkV2NftProvider.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV2/RmrkV2NftProvider.kt new file mode 100644 index 0000000..5c3e9a4 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV2/RmrkV2NftProvider.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2 + +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.core_db.model.NftLocal +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice +import io.novafoundation.nova.feature_nft_impl.data.network.distributed.FileStorageAdapter.adoptFileStorageLinkToHttps +import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.network.singular.SingularV2Api +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +class RmrkV2NftProvider( + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val singularV2Api: SingularV2Api, + private val nftDao: NftDao +) : NftProvider { + + override val requireFullChainSync: Boolean = false + + override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) { + val address = metaAccount.addressIn(chain) ?: return + val nfts = singularV2Api.getAccountNfts(address) + + val toSave = nfts.map { + NftLocal( + identifier = localIdentifier(chain.id, it.id), + metaId = metaAccount.id, + chainId = chain.id, + collectionId = it.collectionId, + instanceId = it.id, + metadata = it.metadata?.encodeToByteArray(), + media = it.image?.adoptFileStorageLinkToHttps(), + + // let name default to symbol and label to edition in case full sync wont be able to determine them from metadata + name = it.symbol, + label = it.edition, + + price = it.price, + type = NftLocal.Type.RMRK2, + issuanceMyEdition = it.edition, + wholeDetailsLoaded = false, + issuanceType = NftLocal.IssuanceType.LIMITED + ) + } + + nftDao.insertNftsDiff(NftLocal.Type.RMRK2, chain.id, metaAccount.id, toSave, forceOverwrite) + } + + override suspend fun nftFullSync(nft: Nft) { + val metadata = nft.metadataRaw?.let { + val metadataLink = it.decodeToString().adoptFileStorageLinkToHttps() + + singularV2Api.getIpfsMetadata(metadataLink) + } + + val collection = singularV2Api.getCollection(nft.collectionId).first() + + nftDao.updateNft(nft.identifier) { local -> + // media fetched during initial sync (prerender) has more priority than one from metadata + val image = local.media ?: metadata?.image?.adoptFileStorageLinkToHttps() + + local.copy( + media = image, + issuanceTotal = collection.max?.toBigInteger(), + name = metadata?.name ?: local.name, + label = metadata?.description ?: local.label, + wholeDetailsLoaded = true + ) + } + } + + override fun nftDetailsFlow(nftIdentifier: String): Flow { + return flowOf { + val nftLocal = nftDao.getNft(nftIdentifier) + require(nftLocal.wholeDetailsLoaded) { + "Cannot load details of non fully-synced NFT" + } + val chain = chainRegistry.getChain(nftLocal.chainId) + val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId) + + val collection = singularV2Api.getCollection(nftLocal.collectionId).first() + val collectionMetadata = collection.metadata?.let { + singularV2Api.getIpfsMetadata(it.adoptFileStorageLinkToHttps()) + } + + NftDetails( + identifier = nftLocal.identifier, + chain = chain, + owner = metaAccount.accountIdIn(chain)!!, + creator = chain.accountIdOf(collection.issuer), + media = nftLocal.media, + name = nftLocal.name!!, + description = nftLocal.label, + issuance = nftIssuance(nftLocal), + price = nftPrice(nftLocal), + collection = NftDetails.Collection( + id = nftLocal.collectionId, + name = collectionMetadata?.name, + media = collectionMetadata?.image?.adoptFileStorageLinkToHttps() + ) + ) + } + } + + private fun localIdentifier(chainId: ChainId, remoteId: String): String { + return "$chainId-$remoteId" + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV2/network/singular/SingularV2Api.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV2/network/singular/SingularV2Api.kt new file mode 100644 index 0000000..924bec1 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV2/network/singular/SingularV2Api.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.network.singular + +import io.novafoundation.nova.common.data.network.http.CacheControl +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Path +import retrofit2.http.Url + +interface SingularV2Api { + + companion object { + const val BASE_URL = "https://singular.rmrk-api.xyz/api/" + } + + @GET("account/{accountAddress}") + @Headers(CacheControl.NO_CACHE) + suspend fun getAccountNfts(@Path("accountAddress") accountAddress: String): List + + @GET("https://singular.app/api/rmrk2/collection/{collectionId}") + suspend fun getCollection(@Path("collectionId") collectionId: String): List + + @GET + suspend fun getIpfsMetadata(@Url url: String): SingularV2CollectionMetadata +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV2/network/singular/SingularV2Responses.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV2/network/singular/SingularV2Responses.kt new file mode 100644 index 0000000..1a7c2ca --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/rmrkV2/network/singular/SingularV2Responses.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.network.singular + +import com.google.gson.annotations.SerializedName +import java.math.BigInteger + +class SingularV2CollectionRemote( + val metadata: String?, + val issuer: String, + val max: Int? +) + +class SingularV2NftRemote( + val id: String, + @SerializedName("forsale") + val price: BigInteger?, + val collectionId: String, + @SerializedName("sn") + val edition: String, + val image: String?, // prerender, non-null if nft is composable + val metadata: String?, + val symbol: String, +) + +class SingularV2CollectionMetadata( + val name: String, + val description: String?, + + @SerializedName("image", alternate = ["mediaUri"]) + val image: String? +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/UniqueNetworkProvider.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/UniqueNetworkProvider.kt new file mode 100644 index 0000000..c14d3c0 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/UniqueNetworkProvider.kt @@ -0,0 +1,139 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network + +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.core_db.model.NftLocal +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice +import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.UniqueNetworkApi +import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response.UniqueNetworkNft +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class UniqueNetworkNftProvider( + private val uniqueNetworkApi: UniqueNetworkApi, + private val nftDao: NftDao, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, +) : NftProvider { + override val requireFullChainSync: Boolean = false + + override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) { + val owner = metaAccount.addressIn(chain) ?: return + val pageSize = 100 + var offset = 0 + val allNfts = mutableListOf() + + while (true) { + val page = uniqueNetworkApi.getNftsPage( + owner = owner, + offset = offset, + limit = pageSize + ) + + if (page.items.isEmpty()) break + + val pageNfts = page.items.map { remote -> + NftLocal( + identifier = nftIdentifier(chain, remote), + metaId = metaAccount.id, + chainId = chain.id, + collectionId = remote.collectionId.toString(), + instanceId = remote.tokenId.toString(), + metadata = null, + type = NftLocal.Type.UNIQUE_NETWORK, + wholeDetailsLoaded = false, + name = remote.name, + label = "#${remote.tokenId}", + media = remote.image, + issuanceType = NftLocal.IssuanceType.UNLIMITED, + issuanceTotal = null, + issuanceMyEdition = remote.tokenId.toString(), + issuanceMyAmount = null, + price = null, + pricedUnits = null + ) + } + + allNfts += pageNfts + offset += pageSize + + if (allNfts.size >= page.count) break + } + + nftDao.insertNftsDiff(NftLocal.Type.UNIQUE_NETWORK, chain.id, metaAccount.id, allNfts, forceOverwrite) + } + + override suspend fun nftFullSync(nft: Nft) { + val collection = uniqueNetworkApi.getCollection( + collectionId = nft.collectionId.toInt(), + ) + + val issuanceTotal = collection.limits?.tokenLimit?.toBigInteger() ?: collection.lastTokenId?.toBigInteger() + + val issuanceType = when { + collection.limits?.tokenLimit != null -> NftLocal.IssuanceType.LIMITED + else -> NftLocal.IssuanceType.UNLIMITED + } + + nftDao.updateNft(nft.identifier) { local -> + local.copy( + issuanceType = issuanceType, + issuanceTotal = issuanceTotal, + wholeDetailsLoaded = true, + ) + } + } + + override fun nftDetailsFlow(nftIdentifier: String): Flow { + return flowOf { + val nftLocal = nftDao.getNft(nftIdentifier) + require(nftLocal.wholeDetailsLoaded) { + "Cannot load details of non fully-synced NFT" + } + + val chain = chainRegistry.getChain(nftLocal.chainId) + val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId) + + val remoteNft = uniqueNetworkApi.getNft( + collectionId = nftLocal.collectionId.toInt(), + tokenId = nftLocal.instanceId!!.toInt() + ) + + val collection = uniqueNetworkApi.getCollection( + collectionId = nftLocal.collectionId.toInt(), + ) + + NftDetails( + identifier = nftLocal.identifier, + chain = chain, + owner = metaAccount.requireAccountIdIn(chain), + creator = null, + media = nftLocal.media, + name = nftLocal.name ?: nftLocal.instanceId!!, + description = remoteNft.description, + issuance = nftIssuance(nftLocal), + price = nftPrice(nftLocal), + collection = collection.let { + NftDetails.Collection( + id = nftLocal.collectionId, + name = it.name, + media = it.coverImage?.url + ) + } + ) + } + } + + private fun nftIdentifier(chain: Chain, nft: UniqueNetworkNft): String { + return "unique-${chain.id}-${nft.collectionId}-${nft.tokenId}" + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/UniqueNetworkApi.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/UniqueNetworkApi.kt new file mode 100644 index 0000000..1c93d7e --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/UniqueNetworkApi.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network + +import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response.UniqueNetworkCollection +import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response.UniqueNetworkNft +import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response.UniqueNetworkPaginatedResponse +import retrofit2.http.GET +import retrofit2.http.Query +import retrofit2.http.Path + +interface UniqueNetworkApi { + companion object { + const val BASE_URL = "https://api-unique.uniquescan.io/v2/" + } + + @GET("nfts") + suspend fun getNftsPage( + @Query("ownerIn") owner: String, + @Query("offset") offset: Int, + @Query("limit") limit: Int, + @Query("orderByTokenId") order: String = "asc" + ): UniqueNetworkPaginatedResponse + + @GET("nfts/{collectionId}/{tokenId}") + suspend fun getNft( + @Path("collectionId") collectionId: Int, + @Path("tokenId") tokenId: Int, + ): UniqueNetworkNft + + @GET("collections/{collectionId}") + suspend fun getCollection( + @Path("collectionId") collectionId: Int, + ): UniqueNetworkCollection +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/response/UniqueNetworkCollection.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/response/UniqueNetworkCollection.kt new file mode 100644 index 0000000..da20735 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/response/UniqueNetworkCollection.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response + +import com.google.gson.annotations.SerializedName + +data class UniqueNetworkCollection( + val collectionId: Int, + val name: String?, + val coverImage: CoverImage?, + val lastTokenId: Int?, + val limits: Limits? +) { + data class CoverImage( + val url: String? + ) + + data class Limits( + @SerializedName("token_limit") + val tokenLimit: Int?, + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/response/UniqueNetworkNft.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/response/UniqueNetworkNft.kt new file mode 100644 index 0000000..ba63843 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/response/UniqueNetworkNft.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response + +data class UniqueNetworkNft( + val key: String, + val collectionId: Int, + val tokenId: Int, + val image: String?, + val name: String?, + val description: String?, +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/response/UniqueNetworkPaginatedResponse.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/response/UniqueNetworkPaginatedResponse.kt new file mode 100644 index 0000000..6cd457c --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/unique_network/network/response/UniqueNetworkPaginatedResponse.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.response + +data class UniqueNetworkPaginatedResponse( + val items: List, + val count: Int +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/uniques/UniquesNftProvider.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/uniques/UniquesNftProvider.kt new file mode 100644 index 0000000..2872a11 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/uniques/UniquesNftProvider.kt @@ -0,0 +1,194 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.uniques +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.core_db.model.NftLocal +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftIssuance +import io.novafoundation.nova.feature_nft_impl.data.mappers.nftPrice +import io.novafoundation.nova.feature_nft_impl.data.network.distributed.FileStorageAdapter.adoptFileStorageLinkToHttps +import io.novafoundation.nova.feature_nft_impl.data.source.NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.network.IpfsApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.multi.MultiQueryBuilder +import io.novafoundation.nova.runtime.storage.source.multi.singleValueOf +import io.novafoundation.nova.runtime.storage.source.query.multi +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +class UniquesNftProvider( + private val remoteStorage: StorageDataSource, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val nftDao: NftDao, + private val ipfsApi: IpfsApi, +) : NftProvider { + + override val requireFullChainSync: Boolean = true + + override suspend fun initialNftsSync(chain: Chain, metaAccount: MetaAccount, forceOverwrite: Boolean) { + val accountId = metaAccount.accountIdIn(chain) ?: return + + val newNfts = remoteStorage.query(chain.id) { + val classesWithInstances = runtime.metadata.uniques().storage("Account").keys(accountId) + .map { (_: AccountId, collection: BigInteger, instance: BigInteger) -> + listOf(collection, instance) + } + + val classesIds = classesWithInstances.map { (collection, _) -> collection }.distinct() + + val classMetadataDescriptor: MultiQueryBuilder.Descriptor + val totalIssuanceDescriptor: MultiQueryBuilder.Descriptor + val instanceMetadataDescriptor: MultiQueryBuilder.Descriptor, ByteArray?> + + val multiQueryResults = multi { + classMetadataDescriptor = runtime.metadata.uniques().storage("ClassMetadataOf").querySingleArgKeys( + keysArgs = classesIds, + keyExtractor = { (classId: BigInteger) -> classId }, + binding = ::bindMetadata + ) + instanceMetadataDescriptor = runtime.metadata.uniques().storage("InstanceMetadataOf").queryKeys( + keysArgs = classesWithInstances, + keyExtractor = { (classId: BigInteger, instance: BigInteger) -> classId to instance }, + binding = ::bindMetadata + ) + totalIssuanceDescriptor = runtime.metadata.uniques().storage("Class").querySingleArgKeys( + keysArgs = classesIds, + keyExtractor = { (classId: BigInteger) -> classId }, + binding = { bindNumber(it.castToStruct()["items"]) } + ) + } + + val classMetadatas = multiQueryResults[classMetadataDescriptor] + val totalIssuances = multiQueryResults[totalIssuanceDescriptor] + val instancesMetadatas = multiQueryResults[instanceMetadataDescriptor] + + classesWithInstances.map { (collectionId, instanceId) -> + val instanceKey = collectionId to instanceId + + val metadata = instancesMetadatas[instanceKey] ?: classMetadatas[collectionId] + + NftLocal( + identifier = identifier(chain.id, collectionId, instanceId), + metaId = metaAccount.id, + chainId = chain.id, + collectionId = collectionId.toString(), + instanceId = instanceId.toString(), + metadata = metadata, + type = NftLocal.Type.UNIQUES, + issuanceTotal = totalIssuances.getValue(collectionId), + issuanceMyEdition = instanceId.toString(), + issuanceType = NftLocal.IssuanceType.LIMITED, + price = null, + + // to load at full sync + name = null, + label = null, + media = null, + + wholeDetailsLoaded = false + ) + } + } + + nftDao.insertNftsDiff(NftLocal.Type.UNIQUES, chain.id, metaAccount.id, newNfts, forceOverwrite) + } + + override suspend fun nftFullSync(nft: Nft) { + if (nft.metadataRaw == null) { + nftDao.markFullSynced(nft.identifier) + + return + } + + val metadataLink = nft.metadataRaw!!.decodeToString().adoptFileStorageLinkToHttps() + val metadata = ipfsApi.getIpfsMetadata(metadataLink) + + nftDao.updateNft(nft.identifier) { local -> + local.copy( + name = metadata.name!!, + media = metadata.image?.adoptFileStorageLinkToHttps(), + label = metadata.description, + wholeDetailsLoaded = true + ) + } + } + + override fun nftDetailsFlow(nftIdentifier: String): Flow { + return flowOf { + val nftLocal = nftDao.getNft(nftIdentifier) + require(nftLocal.wholeDetailsLoaded) { + "Cannot load details of non fully-synced NFT" + } + + val chain = chainRegistry.getChain(nftLocal.chainId) + val metaAccount = accountRepository.getMetaAccount(nftLocal.metaId) + + val classId = nftLocal.collectionId.toBigInteger() + + remoteStorage.query(chain.id) { + var classMetadataDescriptor: MultiQueryBuilder.Descriptor<*, ByteArray?> + var classDescriptor: MultiQueryBuilder.Descriptor<*, AccountId> + + val queryResults = multi { + classMetadataDescriptor = runtime.metadata.uniques().storage("ClassMetadataOf").queryKey(classId, binding = ::bindMetadata) + classDescriptor = runtime.metadata.uniques().storage("Class").queryKey(classId, binding = ::bindIssuer) + } + + val classMetadataPointer = queryResults.singleValueOf(classMetadataDescriptor) + + val collection = if (classMetadataPointer == null) { + NftDetails.Collection(nftLocal.collectionId) + } else { + val url = classMetadataPointer.decodeToString().adoptFileStorageLinkToHttps() + val classMetadata = ipfsApi.getIpfsMetadata(url) + + NftDetails.Collection( + id = nftLocal.collectionId, + name = classMetadata.name, + media = classMetadata.image?.adoptFileStorageLinkToHttps() + ) + } + + val classIssuer = queryResults.singleValueOf(classDescriptor) + + NftDetails( + identifier = nftLocal.identifier, + chain = chain, + owner = metaAccount.requireAccountIdIn(chain), + creator = classIssuer, + media = nftLocal.media, + name = nftLocal.name ?: nftLocal.instanceId!!, + description = nftLocal.label, + issuance = nftIssuance(nftLocal), + price = nftPrice(nftLocal), + collection = collection + ) + } + } + } + + private fun bindIssuer(dynamic: Any?): AccountId = bindAccountId(dynamic.castToStruct()["issuer"]) + + private fun bindMetadata(dynamic: Any?): ByteArray? = dynamic?.cast()?.getTyped("data") + + private fun identifier(chainId: ChainId, collectionId: BigInteger, instanceId: BigInteger): String { + return "$chainId-$collectionId-$instanceId" + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/uniques/network/IpfsApi.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/uniques/network/IpfsApi.kt new file mode 100644 index 0000000..f7be05c --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/uniques/network/IpfsApi.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.network + +import retrofit2.http.GET +import retrofit2.http.Url + +interface IpfsApi { + + @GET + suspend fun getIpfsMetadata(@Url url: String): UniquesMetadata +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/uniques/network/UniquesMetadata.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/uniques/network/UniquesMetadata.kt new file mode 100644 index 0000000..e6689e2 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/data/source/providers/uniques/network/UniquesMetadata.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.network + +class UniquesMetadata( + val name: String?, + val image: String?, + val description: String? +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureComponent.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureComponent.kt new file mode 100644 index 0000000..23ca11a --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureComponent.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_nft_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_nft_api.NftFeatureApi +import io.novafoundation.nova.feature_nft_impl.NftRouter +import io.novafoundation.nova.feature_nft_impl.presentation.nft.details.di.NftDetailsComponent +import io.novafoundation.nova.feature_nft_impl.presentation.nft.list.di.NftListComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + NftFeatureDependencies::class + ], + modules = [ + NftFeatureModule::class + ] +) +@FeatureScope +interface NftFeatureComponent : NftFeatureApi { + + fun nftListComponentFactory(): NftListComponent.Factory + + fun nftDetailsComponentFactory(): NftDetailsComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: NftRouter, + deps: NftFeatureDependencies + ): NftFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + AccountFeatureApi::class, + WalletFeatureApi::class, + RuntimeApi::class + ] + ) + interface NftFeatureDependenciesComponent : NftFeatureDependencies +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureDependencies.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureDependencies.kt new file mode 100644 index 0000000..476e92d --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureDependencies.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_nft_impl.di + +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +interface NftFeatureDependencies { + + val amountFormatter: AmountFormatter + + fun accountRepository(): AccountRepository + + fun resourceManager(): ResourceManager + + fun selectedAccountUseCase(): SelectedAccountUseCase + + fun addressIconGenerator(): AddressIconGenerator + + fun gson(): Gson + + fun chainRegistry(): ChainRegistry + + fun imageLoader(): ImageLoader + + fun externalAccountActions(): ExternalActions.Presentation + + fun addressDisplayUseCase(): AddressDisplayUseCase + + fun feeLoaderMixinFactory(): FeeLoaderMixin.Factory + + fun extrinsicService(): ExtrinsicService + + fun tokenRepository(): TokenRepository + + fun apiCreator(): NetworkApiCreator + + fun nftDao(): NftDao + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource + + fun exceptionHandler(): HttpExceptionHandler +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureHolder.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureHolder.kt new file mode 100644 index 0000000..76d55ee --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureHolder.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_nft_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_nft_impl.NftRouter +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class NftFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: NftRouter, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dApp = DaggerNftFeatureComponent_NftFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .build() + + return DaggerNftFeatureComponent.factory() + .create(router, dApp) + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureModule.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureModule.kt new file mode 100644 index 0000000..829f971 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/NftFeatureModule.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_nft_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_nft_impl.data.repository.NftRepositoryImpl +import io.novafoundation.nova.feature_nft_impl.data.source.JobOrchestrator +import io.novafoundation.nova.feature_nft_impl.data.source.NftProvidersRegistry +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.KodadotProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.Pdc20Provider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.RmrkV1NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.RmrkV2NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.UniqueNetworkNftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.UniquesNftProvider +import io.novafoundation.nova.feature_nft_impl.di.modules.KodadotModule +import io.novafoundation.nova.feature_nft_impl.di.modules.Pdc20Module +import io.novafoundation.nova.feature_nft_impl.di.modules.RmrkV1Module +import io.novafoundation.nova.feature_nft_impl.di.modules.RmrkV2Module +import io.novafoundation.nova.feature_nft_impl.di.modules.UniquesModule +import io.novafoundation.nova.feature_nft_impl.di.modules.UniqueNetworkModule +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module( + includes = [ + UniquesModule::class, + RmrkV1Module::class, + RmrkV2Module::class, + Pdc20Module::class, + KodadotModule::class, + UniqueNetworkModule::class + ] +) +class NftFeatureModule { + + @Provides + @FeatureScope + fun provideJobOrchestrator() = JobOrchestrator() + + @Provides + @FeatureScope + fun provideNftProviderRegistry( + uniquesNftProvider: UniquesNftProvider, + rmrkV1NftProvider: RmrkV1NftProvider, + rmrkV2NftProvider: RmrkV2NftProvider, + pdc20Provider: Pdc20Provider, + kodadotProvider: KodadotProvider, + uniqueNetworkProvider: UniqueNetworkNftProvider + ) = NftProvidersRegistry(uniquesNftProvider, rmrkV1NftProvider, rmrkV2NftProvider, pdc20Provider, kodadotProvider, uniqueNetworkProvider) + + @Provides + @FeatureScope + fun provideNftRepository( + nftProvidersRegistry: NftProvidersRegistry, + chainRegistry: ChainRegistry, + jobOrchestrator: JobOrchestrator, + nftDao: NftDao, + httpExceptionHandler: HttpExceptionHandler, + ): NftRepository = NftRepositoryImpl( + nftProvidersRegistry = nftProvidersRegistry, + chainRegistry = chainRegistry, + jobOrchestrator = jobOrchestrator, + nftDao = nftDao, + exceptionHandler = httpExceptionHandler + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/KodadotModule.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/KodadotModule.kt new file mode 100644 index 0000000..8ebe219 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/KodadotModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_nft_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.KodadotProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.kodadot.network.KodadotApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class KodadotModule { + + @Provides + @FeatureScope + fun provideApi(networkApiCreator: NetworkApiCreator): KodadotApi { + return networkApiCreator.create(KodadotApi::class.java) + } + + @Provides + @FeatureScope + fun provideKodadotProvider( + api: KodadotApi, + nftDao: NftDao, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + ) = KodadotProvider( + api = api, + nftDao = nftDao, + accountRepository = accountRepository, + chainRegistry = chainRegistry + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/Pdc20Module.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/Pdc20Module.kt new file mode 100644 index 0000000..f58cbb5 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/Pdc20Module.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_nft_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.Pdc20Provider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.pdc20.network.Pdc20Api +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class Pdc20Module { + + @Provides + @FeatureScope + fun provideApi(networkApiCreator: NetworkApiCreator): Pdc20Api { + return networkApiCreator.create(Pdc20Api::class.java) + } + + @Provides + @FeatureScope + fun provideNftProvider( + api: Pdc20Api, + nftDao: NftDao, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + ) = Pdc20Provider( + api = api, + nftDao = nftDao, + accountRepository = accountRepository, + chainRegistry = chainRegistry + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/RmrkV1Module.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/RmrkV1Module.kt new file mode 100644 index 0000000..550c888 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/RmrkV1Module.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_nft_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.RmrkV1NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV1.network.RmrkV1Api +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class RmrkV1Module { + + @Provides + @FeatureScope + fun provideApi(networkApiCreator: NetworkApiCreator): RmrkV1Api { + return networkApiCreator.create(RmrkV1Api::class.java, RmrkV1Api.BASE_URL) + } + + @Provides + @FeatureScope + fun provideNftProvider( + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + api: RmrkV1Api, + nftDao: NftDao + ) = RmrkV1NftProvider(chainRegistry, accountRepository, api, nftDao) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/RmrkV2Module.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/RmrkV2Module.kt new file mode 100644 index 0000000..aa806ee --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/RmrkV2Module.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_nft_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.RmrkV2NftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.rmrkV2.network.singular.SingularV2Api +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class RmrkV2Module { + + @Provides + @FeatureScope + fun provideSingularApi(networkApiCreator: NetworkApiCreator): SingularV2Api { + return networkApiCreator.create(SingularV2Api::class.java, SingularV2Api.BASE_URL) + } + + @Provides + @FeatureScope + fun provideNftProvider( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + singularV2Api: SingularV2Api, + nftDao: NftDao + ) = RmrkV2NftProvider(chainRegistry, accountRepository, singularV2Api, nftDao) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/UniqueNetworkModule.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/UniqueNetworkModule.kt new file mode 100644 index 0000000..b1dc805 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/UniqueNetworkModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_nft_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.UniqueNetworkNftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.unique_network.network.UniqueNetworkApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class UniqueNetworkModule { + + @Provides + @FeatureScope + fun provideApi(networkApiCreator: NetworkApiCreator): UniqueNetworkApi { + return networkApiCreator.create(UniqueNetworkApi::class.java, UniqueNetworkApi.BASE_URL) + } + + @Provides + @FeatureScope + fun provideUniqueNetworkNftProvider( + uniqueNetworkApi: UniqueNetworkApi, + nftDao: NftDao, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + ) = UniqueNetworkNftProvider( + uniqueNetworkApi = uniqueNetworkApi, + nftDao = nftDao, + accountRepository = accountRepository, + chainRegistry = chainRegistry + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/UniquesModule.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/UniquesModule.kt new file mode 100644 index 0000000..1121ba8 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/di/modules/UniquesModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_nft_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.NftDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.UniquesNftProvider +import io.novafoundation.nova.feature_nft_impl.data.source.providers.uniques.network.IpfsApi +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class UniquesModule { + + @Provides + @FeatureScope + fun provideIpfsApi(networkApiCreator: NetworkApiCreator) = networkApiCreator.create(IpfsApi::class.java) + + @Provides + @FeatureScope + fun provideUniquesNftProvider( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + nftDao: NftDao, + ipfsApi: IpfsApi, + ) = UniquesNftProvider(remoteStorageSource, accountRepository, chainRegistry, nftDao, ipfsApi) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/details/NftDetailsInteractor.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/details/NftDetailsInteractor.kt new file mode 100644 index 0000000..b9c00bd --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/details/NftDetailsInteractor.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_nft_impl.domain.nft.details + +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.runtime.ext.utilityAsset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map + +class NftDetailsInteractor( + private val nftRepository: NftRepository, + private val tokenRepository: TokenRepository +) { + + fun nftDetailsFlow(nftIdentifier: String): Flow { + return nftRepository.nftDetails(nftIdentifier).flatMapLatest { nftDetails -> + tokenRepository.observeToken(nftDetails.chain.utilityAsset).map { token -> + PricedNftDetails( + nftDetails = nftDetails, + priceToken = token + ) + } + } + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/details/PricedNftDetails.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/details/PricedNftDetails.kt new file mode 100644 index 0000000..2f9108d --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/details/PricedNftDetails.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_nft_impl.domain.nft.details + +import io.novafoundation.nova.feature_nft_api.data.model.NftDetails +import io.novafoundation.nova.feature_wallet_api.domain.model.Token + +class PricedNftDetails( + val nftDetails: NftDetails, + val priceToken: Token +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/list/NftListInteractor.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/list/NftListInteractor.kt new file mode 100644 index 0000000..7aa362e --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/list/NftListInteractor.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_nft_impl.domain.nft.list + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.utilityAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.withContext + +class NftListInteractor( + private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, + private val nftRepository: NftRepository, +) { + + fun userNftsFlow(): Flow> { + return accountRepository.selectedMetaAccountFlow() + .flatMapLatest(nftRepository::allNftFlow) + .map { nfts -> nfts.sortedBy { it.identifier } } + .flatMapLatest { nfts -> + val allUtilityAssets = nfts.map { it.chain.utilityAsset }.distinct() + + tokenRepository.observeTokens(allUtilityAssets).mapLatest { tokensByUtilityAsset -> + nfts.map { nft -> + PricedNft( + nft = nft, + nftPriceToken = tokensByUtilityAsset[nft.chain.utilityAsset.fullId] + ) + } + } + } + } + + suspend fun syncNftsList() = withContext(Dispatchers.Default) { + nftRepository.initialNftSync(accountRepository.getSelectedMetaAccount(), forceOverwrite = true) + } + + suspend fun fullSyncNft(nft: Nft) { + nftRepository.fullNftSync(nft) + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/list/PricedNft.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/list/PricedNft.kt new file mode 100644 index 0000000..bdcf9a4 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/domain/nft/list/PricedNft.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_nft_impl.domain.nft.list + +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_wallet_api.domain.model.Token + +class PricedNft( + val nft: Nft, + val nftPriceToken: Token? +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/common/Mappers.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/common/Mappers.kt new file mode 100644 index 0000000..33c74e7 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/common/Mappers.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_impl.R +import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model.NftPriceModel +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel + +fun ResourceManager.formatIssuance(issuance: Nft.Issuance): String { + return when (issuance) { + is Nft.Issuance.Unlimited -> getString(R.string.nft_issuance_unlimited) + + is Nft.Issuance.Limited -> { + getString( + R.string.nft_issuance_limited_format, + issuance.edition.format(), + issuance.max.format() + ) + } + + is Nft.Issuance.Fungible -> { + getString( + R.string.nft_issuance_fungible_format, + issuance.myAmount.format(), + issuance.totalSupply.format() + ) + } + } +} + +fun ResourceManager.formatNftPrice(amountFormatter: AmountFormatter, price: Nft.Price?, priceToken: Token?): NftPriceModel? { + if (price == null || priceToken == null) return null + + return when (price) { + is Nft.Price.Fungible -> { + val units = price.units.format() + val amountModel = amountFormatter.formatAmountToAmountModel(price.totalPrice, priceToken) + + NftPriceModel( + amountInfo = getString(R.string.nft_fungile_price, units, amountModel.token), + fiat = amountModel.fiat + ) + } + is Nft.Price.NonFungible -> { + val amountModel = amountFormatter.formatAmountToAmountModel(price.nftPrice, priceToken) + + NftPriceModel( + amountInfo = amountModel.token, + fiat = amountModel.fiat + ) + } + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/common/NftIssuanceView.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/common/NftIssuanceView.kt new file mode 100644 index 0000000..2bc5be3 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/common/NftIssuanceView.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.common + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_nft_impl.R + +class NftIssuanceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatTextView(context, attrs, defStyleAttr), WithContextExtensions { + + override val providedContext: Context = context + + init { + setTextAppearance(R.style.TextAppearance_NovaFoundation_SemiBold_Caps2) + setTextColorRes(R.color.chip_text) + updatePadding(top = 1.5f.dp, bottom = 1.5f.dp, start = 6.dp, end = 6.dp) + background = context.getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 4) + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/common/model/NftPriceModel.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/common/model/NftPriceModel.kt new file mode 100644 index 0000000..1de2461 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/common/model/NftPriceModel.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model + +class NftPriceModel( + val amountInfo: CharSequence, + val fiat: CharSequence? +) diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/NftDetailsFragment.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/NftDetailsFragment.kt new file mode 100644 index 0000000..b6d3911 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/NftDetailsFragment.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.details + +import android.text.TextUtils +import android.view.View +import androidx.core.os.bundleOf + +import coil.ImageLoader +import coil.load +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.dialog.errorDialog +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_nft_api.NftFeatureApi +import io.novafoundation.nova.feature_nft_impl.R +import io.novafoundation.nova.feature_nft_impl.databinding.FragmentNftDetailsBinding +import io.novafoundation.nova.feature_nft_impl.di.NftFeatureComponent +import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model.NftPriceModel +import io.novafoundation.nova.feature_wallet_api.presentation.view.PriceSectionView + +import javax.inject.Inject + +class NftDetailsFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "NftDetailsFragment.PAYLOAD" + + fun getBundle(nftId: String) = bundleOf(PAYLOAD to nftId) + } + + override fun createBinding() = FragmentNftDetailsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val contentViews by lazy(LazyThreadSafetyMode.NONE) { + listOf( + binder.nftDetailsMedia, + binder.nftDetailsTitle, + binder.nftDetailsDescription, + binder.nftDetailsIssuance, + binder.nftDetailsPrice, + binder.nftDetailsTable + ) + } + + override fun initViews() { + binder.nftDetailsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.nftDetailsOnwer.setOnClickListener { viewModel.ownerClicked() } + binder.nftDetailsCreator.setOnClickListener { viewModel.creatorClicked() } + + binder.nftDetailsCollection.valuePrimary.ellipsize = TextUtils.TruncateAt.END + + binder.nftDetailsProgress.makeVisible() + contentViews.forEach(View::makeGone) + } + + override fun inject() { + FeatureUtils.getFeature(this, NftFeatureApi::class.java) + .nftDetailsComponentFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: NftDetailsViewModel) { + setupExternalActions(viewModel) + + viewModel.nftDetailsUi.observe { + binder.nftDetailsProgress.makeGone() + contentViews.forEach(View::makeVisible) + + binder.nftDetailsMedia.load(it.media, imageLoader) { + placeholder(R.drawable.nft_media_progress) + error(R.drawable.nft_media_progress) + } + binder.nftDetailsTitle.text = it.name + binder.nftDetailsDescription.setTextOrHide(it.description) + binder.nftDetailsIssuance.text = it.issuance + + binder.nftDetailsPrice.setPriceOrHide(it.price) + + if (it.collection != null) { + binder.nftDetailsCollection.makeVisible() + binder.nftDetailsCollection.loadImage(it.collection.media) + binder.nftDetailsCollection.showValue(it.collection.name) + } else { + binder.nftDetailsCollection.makeGone() + } + + binder.nftDetailsOnwer.showAddress(it.owner) + + if (it.creator != null) { + binder.nftDetailsCreator.makeVisible() + binder.nftDetailsCreator.showAddress(it.creator) + } else { + binder.nftDetailsCreator.makeGone() + } + + binder.nftDetailsChain.showChain(it.network) + } + + viewModel.exitingErrorLiveData.observeEvent { + errorDialog(requireContext(), onConfirm = viewModel::backClicked) { + setMessage(it) + } + } + } + + private fun PriceSectionView.setPriceOrHide(maybePrice: NftPriceModel?) = letOrHide(maybePrice) { price -> + setPrice(price.amountInfo, price.fiat) + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/NftDetailsModel.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/NftDetailsModel.kt new file mode 100644 index 0000000..7819dc4 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/NftDetailsModel.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.details + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model.NftPriceModel + +class NftDetailsModel( + val media: String?, + val name: String, + val issuance: String, + val description: String?, + val price: NftPriceModel?, + val collection: Collection?, + val owner: AddressModel, + val creator: AddressModel?, + val network: ChainUi +) { + + class Collection( + val name: String, + val media: String? + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/NftDetailsViewModel.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/NftDetailsViewModel.kt new file mode 100644 index 0000000..0a41cbc --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/NftDetailsViewModel.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.details + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_nft_impl.NftRouter +import io.novafoundation.nova.feature_nft_impl.domain.nft.details.NftDetailsInteractor +import io.novafoundation.nova.feature_nft_impl.domain.nft.details.PricedNftDetails +import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.formatIssuance +import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.formatNftPrice +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class NftDetailsViewModel( + private val router: NftRouter, + private val resourceManager: ResourceManager, + private val interactor: NftDetailsInteractor, + private val nftIdentifier: String, + private val externalActionsDelegate: ExternalActions.Presentation, + private val addressIconGenerator: AddressIconGenerator, + private val addressDisplayUseCase: AddressDisplayUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), ExternalActions by externalActionsDelegate { + + private val _exitingErrorLiveData = MutableLiveData>() + val exitingErrorLiveData: LiveData> = _exitingErrorLiveData + + private val nftDetailsFlow = interactor.nftDetailsFlow(nftIdentifier) + .inBackground() + .catch { showExitingError(it) } + .share() + + val nftDetailsUi = nftDetailsFlow + .map(::mapNftDetailsToUi) + .inBackground() + .share() + + fun ownerClicked() = launch { + val pricedNftDetails = nftDetailsFlow.first() + + with(pricedNftDetails.nftDetails) { + externalActionsDelegate.showAddressActions(owner, chain) + } + } + + fun creatorClicked() = launch { + val pricedNftDetails = nftDetailsFlow.first() + + with(pricedNftDetails.nftDetails) { + externalActionsDelegate.showAddressActions(creator!!, chain) + } + } + + private fun showExitingError(exception: Throwable) { + _exitingErrorLiveData.value = exception.message.orEmpty().event() + } + + private suspend fun mapNftDetailsToUi(pricedNftDetails: PricedNftDetails): NftDetailsModel { + val nftDetails = pricedNftDetails.nftDetails + + return NftDetailsModel( + media = nftDetails.media, + name = nftDetails.name, + issuance = resourceManager.formatIssuance(nftDetails.issuance), + description = nftDetails.description, + price = resourceManager.formatNftPrice(amountFormatter, pricedNftDetails.nftDetails.price, pricedNftDetails.priceToken), + collection = nftDetails.collection?.let { + NftDetailsModel.Collection( + name = it.name ?: it.id, + media = it.media, + ) + }, + owner = createAddressModel(nftDetails.owner, nftDetails.chain), + creator = nftDetails.creator?.let { + createAddressModel(it, nftDetails.chain) + }, + network = mapChainToUi(nftDetails.chain) + ) + } + + private suspend fun createAddressModel(accountId: AccountId, chain: Chain) = addressIconGenerator.createAddressModel( + chain = chain, + accountId = accountId, + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + addressDisplayUseCase = addressDisplayUseCase, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + + fun backClicked() { + router.back() + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/di/NfDetailsModule.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/di/NfDetailsModule.kt new file mode 100644 index 0000000..f4ddbac --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/di/NfDetailsModule.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.details.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_nft_impl.NftRouter +import io.novafoundation.nova.feature_nft_impl.domain.nft.details.NftDetailsInteractor +import io.novafoundation.nova.feature_nft_impl.presentation.nft.details.NftDetailsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class NfDetailsModule { + + @Provides + @ScreenScope + fun provideInteractor( + nftRepository: NftRepository, + tokenRepository: TokenRepository + ) = NftDetailsInteractor( + tokenRepository = tokenRepository, + nftRepository = nftRepository + ) + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NftDetailsViewModel { + return ViewModelProvider(fragment, factory).get(NftDetailsViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(NftDetailsViewModel::class) + fun provideViewModel( + router: NftRouter, + resourceManager: ResourceManager, + interactor: NftDetailsInteractor, + nftIdentifier: String, + accountExternalActions: ExternalActions.Presentation, + addressIconGenerator: AddressIconGenerator, + addressDisplayUseCase: AddressDisplayUseCase, + amountFormatter: AmountFormatter + ): ViewModel { + return NftDetailsViewModel( + router = router, + resourceManager = resourceManager, + interactor = interactor, + nftIdentifier = nftIdentifier, + externalActionsDelegate = accountExternalActions, + addressIconGenerator = addressIconGenerator, + addressDisplayUseCase = addressDisplayUseCase, + amountFormatter = amountFormatter + ) + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/di/NftDetailsComponent.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/di/NftDetailsComponent.kt new file mode 100644 index 0000000..3485319 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/details/di/NftDetailsComponent.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.details.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_nft_impl.presentation.nft.details.NftDetailsFragment + +@Subcomponent( + modules = [ + NfDetailsModule::class + ] +) +@ScreenScope +interface NftDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance nftId: String + ): NftDetailsComponent + } + + fun inject(fragment: NftDetailsFragment) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftAdapter.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftAdapter.kt new file mode 100644 index 0000000..feb6eb3 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftAdapter.kt @@ -0,0 +1,134 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.list + +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import coil.clear +import coil.load +import coil.transform.RoundedCornersTransformation +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getRippleMask +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_nft_impl.R +import io.novafoundation.nova.feature_nft_impl.databinding.ItemNftBinding +import kotlinx.android.extensions.LayoutContainer + +class NftAdapter( + private val imageLoader: ImageLoader, + private val handler: Handler +) : ListAdapter(DiffCallback) { + + interface Handler { + + fun itemClicked(item: NftListItem) + + fun loadableItemShown(item: NftListItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NftHolder { + return NftHolder(ItemNftBinding.inflate(parent.inflater(), parent, false), imageLoader, handler) + } + + override fun onBindViewHolder(holder: NftHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onViewRecycled(holder: NftHolder) { + holder.unbind() + } +} + +private object DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: NftListItem, newItem: NftListItem): Boolean { + return oldItem.identifier == newItem.identifier + } + + override fun areContentsTheSame(oldItem: NftListItem, newItem: NftListItem): Boolean { + return oldItem == newItem + } +} + +class NftHolder( + private val binder: ItemNftBinding, + private val imageLoader: ImageLoader, + private val itemHandler: NftAdapter.Handler +) : RecyclerView.ViewHolder(binder.root), LayoutContainer { + + override val containerView = binder.root + + init { + with(containerView) { + binder.itemNftContent.background = with(context) { + addRipple(getRoundedCornerDrawable(R.color.block_background, cornerSizeInDp = 12), mask = getRippleMask(cornerSizeDp = 12)) + } + } + } + + fun unbind() { + binder.itemNftMedia.clear() + } + + fun bind(item: NftListItem) = with(binder) { + when (val content = item.content) { + is LoadingState.Loading -> { + itemNftShimmer.makeVisible() + itemNftShimmer.startShimmer() + itemNftContent.makeGone() + + itemHandler.loadableItemShown(item) + } + is LoadingState.Loaded -> { + itemNftShimmer.makeGone() + itemNftShimmer.stopShimmer() + itemNftContent.makeVisible() + + itemNftMedia.load(content.data.media, imageLoader) { + transformations(RoundedCornersTransformation(8.dpF(containerView.context))) + placeholder(R.drawable.nft_media_progress) + error(R.drawable.nft_media_error) + fallback(R.drawable.nft_media_error) + listener( + onError = { _, _ -> + // so that placeholder would be able to change aspect ratio and fill ImageView entirely + itemNftMedia.scaleType = ImageView.ScaleType.FIT_XY + }, + onSuccess = { _, _ -> + // set default scale type back + itemNftMedia.scaleType = ImageView.ScaleType.FIT_CENTER + } + ) + } + + itemNftIssuance.text = content.data.issuance + itemNftTitle.text = content.data.title + + setPrice(content) + } + } + + containerView.setOnClickListener { itemHandler.itemClicked(item) } + } + + private fun setPrice(content: LoadingState.Loaded) { + val price = content.data.price + + binder.itemNftPriceToken.setVisible(price != null) + binder.itemNftPriceFiat.setVisible(price != null) + binder.itemNftPricePlaceholder.setVisible(price == null) + + if (price != null) { + binder.itemNftPriceToken.text = price.amountInfo + binder.itemNftPriceFiat.text = price.fiat + } + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftListFragment.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftListFragment.kt new file mode 100644 index 0000000..ca83619 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftListFragment.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.list + +import android.view.View +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.submitListPreservingViewPoint +import io.novafoundation.nova.feature_nft_api.NftFeatureApi +import io.novafoundation.nova.feature_nft_impl.databinding.FragmentNftListBinding +import io.novafoundation.nova.feature_nft_impl.di.NftFeatureComponent + +import javax.inject.Inject + +class NftListFragment : BaseFragment(), NftAdapter.Handler { + + override fun createBinding() = FragmentNftListBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { NftAdapter(imageLoader, this) } + + override fun applyInsets(rootView: View) { + binder.nftListToolbar.applyStatusBarInsets() + binder.nftListNfts.applyNavigationBarInsets() + } + + override fun initViews() { + binder.nftListBack.setOnClickListener { viewModel.backClicked() } + + binder.nftListNfts.adapter = adapter + binder.nftListNfts.itemAnimator = null + + binder.nftListRefresh.setOnRefreshListener { viewModel.syncNfts() } + } + + override fun inject() { + FeatureUtils.getFeature(this, NftFeatureApi::class.java) + .nftListComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: NftListViewModel) { + viewModel.nftListItemsFlow.observe { + adapter.submitListPreservingViewPoint(it, binder.nftListNfts) + } + + viewModel.hideRefreshEvent.observeEvent { + binder.nftListRefresh.isRefreshing = false + } + + viewModel.nftCountFlow.observe(binder.nftListCounter::setText) + } + + override fun itemClicked(item: NftListItem) { + viewModel.nftClicked(item) + } + + override fun loadableItemShown(item: NftListItem) { + viewModel.loadableNftShown(item) + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftListItem.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftListItem.kt new file mode 100644 index 0000000..4246a5c --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftListItem.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.list + +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.model.NftPriceModel + +data class NftListItem( + val content: LoadingState, + val identifier: String, +) { + + data class Content( + val issuance: String, + val title: String, + val price: NftPriceModel?, + val media: String?, + ) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftListViewModel.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftListViewModel.kt new file mode 100644 index 0000000..65bc39c --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/NftListViewModel.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.list + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_nft_api.data.model.Nft +import io.novafoundation.nova.feature_nft_impl.NftRouter +import io.novafoundation.nova.feature_nft_impl.domain.nft.list.NftListInteractor +import io.novafoundation.nova.feature_nft_impl.domain.nft.list.PricedNft +import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.formatIssuance +import io.novafoundation.nova.feature_nft_impl.presentation.nft.common.formatNftPrice +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class NftListViewModel( + private val router: NftRouter, + private val resourceManager: ResourceManager, + private val interactor: NftListInteractor, + private val amountFormatter: AmountFormatter +) : BaseViewModel() { + + private val nftsFlow = interactor.userNftsFlow() + .inBackground() + .share() + + val nftCountFlow = nftsFlow.map { it.size.format() } + .share() + + val nftListItemsFlow = nftsFlow.mapList(::mapNftToListItem) + .inBackground() + .share() + + private val _hideRefreshEvent = MutableLiveData>() + val hideRefreshEvent: LiveData> = _hideRefreshEvent + + fun syncNfts() { + viewModelScope.launch { + interactor.syncNftsList() + + _hideRefreshEvent.value = Event(Unit) + } + } + + fun nftClicked(nftListItem: NftListItem) = launch { + if (nftListItem.content is LoadingState.Loaded) { + router.openNftDetails(nftListItem.identifier) + } + } + + fun loadableNftShown(nftListItem: NftListItem) = launch(Dispatchers.Default) { + val pricedNft = nftsFlow.first().firstOrNull { it.nft.identifier == nftListItem.identifier } + ?: return@launch + + interactor.fullSyncNft(pricedNft.nft) + } + + private fun mapNftToListItem(pricedNft: PricedNft): NftListItem { + val content = when (val details = pricedNft.nft.details) { + Nft.Details.Loadable -> LoadingState.Loading() + + is Nft.Details.Loaded -> { + val issuanceFormatted = resourceManager.formatIssuance(details.issuance) + + val price = resourceManager.formatNftPrice(amountFormatter, details.price, pricedNft.nftPriceToken) + + LoadingState.Loaded( + NftListItem.Content( + issuance = issuanceFormatted, + title = details.name ?: pricedNft.nft.instanceId ?: pricedNft.nft.collectionId, + price = price, + media = details.media + ) + ) + } + } + + return NftListItem( + identifier = pricedNft.nft.identifier, + content = content + ) + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/di/NftListComponent.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/di/NftListComponent.kt new file mode 100644 index 0000000..1873b8a --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/di/NftListComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_nft_impl.presentation.nft.list.NftListFragment + +@Subcomponent( + modules = [ + NftListModule::class + ] +) +@ScreenScope +interface NftListComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): NftListComponent + } + + fun inject(fragment: NftListFragment) +} diff --git a/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/di/NftListModule.kt b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/di/NftListModule.kt new file mode 100644 index 0000000..58c4460 --- /dev/null +++ b/feature-nft-impl/src/main/java/io/novafoundation/nova/feature_nft_impl/presentation/nft/list/di/NftListModule.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_nft_impl.presentation.nft.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_nft_api.data.repository.NftRepository +import io.novafoundation.nova.feature_nft_impl.NftRouter +import io.novafoundation.nova.feature_nft_impl.domain.nft.list.NftListInteractor +import io.novafoundation.nova.feature_nft_impl.presentation.nft.list.NftListViewModel +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class NftListModule { + + @Provides + @ScreenScope + fun provideInteractor( + accountRepository: AccountRepository, + nftRepository: NftRepository, + tokenRepository: TokenRepository + ) = NftListInteractor( + accountRepository = accountRepository, + tokenRepository = tokenRepository, + nftRepository = nftRepository + ) + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): NftListViewModel { + return ViewModelProvider(fragment, factory).get(NftListViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(NftListViewModel::class) + fun provideViewModel( + router: NftRouter, + resourceManager: ResourceManager, + interactor: NftListInteractor, + amountFormatter: AmountFormatter + ): ViewModel { + return NftListViewModel( + router = router, + resourceManager = resourceManager, + interactor = interactor, + amountFormatter = amountFormatter + ) + } +} diff --git a/feature-nft-impl/src/main/res/drawable/nft_media_error.xml b/feature-nft-impl/src/main/res/drawable/nft_media_error.xml new file mode 100644 index 0000000..d79c4be --- /dev/null +++ b/feature-nft-impl/src/main/res/drawable/nft_media_error.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/feature-nft-impl/src/main/res/drawable/nft_media_progress.xml b/feature-nft-impl/src/main/res/drawable/nft_media_progress.xml new file mode 100644 index 0000000..d2857ba --- /dev/null +++ b/feature-nft-impl/src/main/res/drawable/nft_media_progress.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/feature-nft-impl/src/main/res/layout/fragment_nft_details.xml b/feature-nft-impl/src/main/res/layout/fragment_nft_details.xml new file mode 100644 index 0000000..5d337b3 --- /dev/null +++ b/feature-nft-impl/src/main/res/layout/fragment_nft_details.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-nft-impl/src/main/res/layout/fragment_nft_list.xml b/feature-nft-impl/src/main/res/layout/fragment_nft_list.xml new file mode 100644 index 0000000..f52bd06 --- /dev/null +++ b/feature-nft-impl/src/main/res/layout/fragment_nft_list.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-nft-impl/src/main/res/layout/item_nft.xml b/feature-nft-impl/src/main/res/layout/item_nft.xml new file mode 100644 index 0000000..3cae636 --- /dev/null +++ b/feature-nft-impl/src/main/res/layout/item_nft.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-nft-impl/src/main/res/values/styles.xml b/feature-nft-impl/src/main/res/values/styles.xml new file mode 100644 index 0000000..7fdd14a --- /dev/null +++ b/feature-nft-impl/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/feature-nft-impl/src/test/java/io/novafoundation/nova/ExampleUnitTest.kt b/feature-nft-impl/src/test/java/io/novafoundation/nova/ExampleUnitTest.kt new file mode 100644 index 0000000..a26e898 --- /dev/null +++ b/feature-nft-impl/src/test/java/io/novafoundation/nova/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/feature-nft-impl/src/test/java/io/novafoundation/nova/feature_nft_impl/data/network/distributed/FileStorageAdapterTest.kt b/feature-nft-impl/src/test/java/io/novafoundation/nova/feature_nft_impl/data/network/distributed/FileStorageAdapterTest.kt new file mode 100644 index 0000000..aea6658 --- /dev/null +++ b/feature-nft-impl/src/test/java/io/novafoundation/nova/feature_nft_impl/data/network/distributed/FileStorageAdapterTest.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_nft_impl.data.network.distributed + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FileStorageAdapterTest { + + + @Test + fun `should adapt ipfs to http`() { + runTest( + initial = "ipfs://ipfs/bafkreig7jn6iwz4fo3mwkl3ndljbrf7ot4mwyvpmzrj4vm2nvwzw6dysb4", + expected = "${FileStorage.IPFS.defaultHttpsGateway}bafkreig7jn6iwz4fo3mwkl3ndljbrf7ot4mwyvpmzrj4vm2nvwzw6dysb4" + ) + } + + @Test + fun `should fallback`() { + runTest( + initial = "bafkreig7jn6iwz4fo3mwkl3ndljbrf7ot4mwyvpmzrj4vm2nvwzw6dysb4", + expected = "${FileStorage.IPFS.defaultHttpsGateway}bafkreig7jn6iwz4fo3mwkl3ndljbrf7ot4mwyvpmzrj4vm2nvwzw6dysb4" + ) + } + + @Test + fun `should leave http and https as is`() { + runTest( + initial = "https://singular.rmrk.app/api/rmrk1/account/EkLXe943A4Ceu8rF4a2Wb8s5BHuTdTcEszJJCgsEPwAiGCi", + expected = "https://singular.rmrk.app/api/rmrk1/account/EkLXe943A4Ceu8rF4a2Wb8s5BHuTdTcEszJJCgsEPwAiGCi" + ) + + runTest( + initial = "http://singular.rmrk.app/api/rmrk1/account/EkLXe943A4Ceu8rF4a2Wb8s5BHuTdTcEszJJCgsEPwAiGCi", + expected = "http://singular.rmrk.app/api/rmrk1/account/EkLXe943A4Ceu8rF4a2Wb8s5BHuTdTcEszJJCgsEPwAiGCi" + ) + } + + private fun runTest( + initial: String, + expected: String, + ) { + val actual = FileStorageAdapter.adaptToHttps(initial) + + assertEquals(expected, actual) + } +} diff --git a/feature-onboarding-api/.gitignore b/feature-onboarding-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-onboarding-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-onboarding-api/build.gradle b/feature-onboarding-api/build.gradle new file mode 100644 index 0000000..be73842 --- /dev/null +++ b/feature-onboarding-api/build.gradle @@ -0,0 +1,8 @@ + +android { + namespace 'io.novafoundation.nova.feature_onboarding_api' +} + +dependencies { + implementation project(':feature-account-api') +} \ No newline at end of file diff --git a/feature-onboarding-api/src/main/AndroidManifest.xml b/feature-onboarding-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/feature-onboarding-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-onboarding-api/src/main/java/io/novafoundation/nova/feature_onboarding_api/di/OnboardingFeatureApi.kt b/feature-onboarding-api/src/main/java/io/novafoundation/nova/feature_onboarding_api/di/OnboardingFeatureApi.kt new file mode 100644 index 0000000..8101adb --- /dev/null +++ b/feature-onboarding-api/src/main/java/io/novafoundation/nova/feature_onboarding_api/di/OnboardingFeatureApi.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_onboarding_api.di + +interface OnboardingFeatureApi diff --git a/feature-onboarding-api/src/main/java/io/novafoundation/nova/feature_onboarding_api/domain/OnboardingInteractor.kt b/feature-onboarding-api/src/main/java/io/novafoundation/nova/feature_onboarding_api/domain/OnboardingInteractor.kt new file mode 100644 index 0000000..6f71ec9 --- /dev/null +++ b/feature-onboarding-api/src/main/java/io/novafoundation/nova/feature_onboarding_api/domain/OnboardingInteractor.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_onboarding_api.domain + +interface OnboardingInteractor { + + suspend fun checkCloudBackupIsExist(): Result + + suspend fun isCloudBackupAvailableForImport(): Boolean + + suspend fun signInToCloud(): Result +} diff --git a/feature-onboarding-impl/.gitignore b/feature-onboarding-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-onboarding-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-onboarding-impl/build.gradle b/feature-onboarding-impl/build.gradle new file mode 100644 index 0000000..967ec73 --- /dev/null +++ b/feature-onboarding-impl/build.gradle @@ -0,0 +1,47 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' + +android { + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.feature_onboarding_impl' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-onboarding-api') + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(':feature-ledger-core') + implementation project(':feature-versions-api') + + implementation project(':feature-cloud-backup-api') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation coroutinesDep + + implementation daggerDep + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation jUnitDep + testImplementation mockitoDep +} diff --git a/feature-onboarding-impl/src/main/AndroidManifest.xml b/feature-onboarding-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..227314e --- /dev/null +++ b/feature-onboarding-impl/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/OnboardingRouter.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/OnboardingRouter.kt new file mode 100644 index 0000000..6a210cc --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/OnboardingRouter.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_onboarding_impl + +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload + +interface OnboardingRouter { + + fun openCreateFirstWallet() + + fun openMnemonicScreen(accountName: String?, payload: AddAccountPayload) + + fun openImportAccountScreen(payload: ImportAccountPayload) + + fun openCreateWatchWallet() + + fun openStartImportParitySigner() + + fun openStartImportLegacyLedger() + + fun openStartImportGenericLedger() + + fun back() + + fun openStartImportPolkadotVault() + + fun openImportOptionsScreen() + + fun restoreCloudBackup() +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureComponent.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureComponent.kt new file mode 100644 index 0000000..aab48c7 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureComponent.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_onboarding_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi +import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi +import io.novafoundation.nova.feature_onboarding_api.di.OnboardingFeatureApi +import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter +import io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser.di.ImportWalletOptionsComponent +import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.di.WelcomeComponent +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi + +@Component( + dependencies = [ + OnboardingFeatureDependencies::class + ], + modules = [ + OnboardingFeatureModule::class + ] +) +@FeatureScope +interface OnboardingFeatureComponent : OnboardingFeatureApi { + + fun welcomeComponentFactory(): WelcomeComponent.Factory + + fun importWalletOptionsComponentFactory(): ImportWalletOptionsComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance onboardingRouter: OnboardingRouter, + deps: OnboardingFeatureDependencies + ): OnboardingFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + AccountFeatureApi::class, + VersionsFeatureApi::class, + LedgerCoreApi::class, + CloudBackupFeatureApi::class + ] + ) + interface OnboardingFeatureDependenciesComponent : OnboardingFeatureDependencies +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureDependencies.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureDependencies.kt new file mode 100644 index 0000000..c439439 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureDependencies.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_onboarding_impl.di + +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.mixin.importType.ImportTypeChooserMixin +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor + +interface OnboardingFeatureDependencies { + + fun updateNotificationsInteractor(): UpdateNotificationsInteractor + + fun accountRepository(): AccountRepository + + fun resourceManager(): ResourceManager + + fun appLinksProvider(): AppLinksProvider + + fun importTypeChooserMixin(): ImportTypeChooserMixin.Presentation + + fun progressDialogMixinFactory(): ProgressDialogMixinFactory + + fun customDialogProvider(): CustomDialogDisplayer.Presentation + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val ledgerMigrationTracker: LedgerMigrationTracker + + val cloudBackupService: CloudBackupService + + val cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureHolder.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureHolder.kt new file mode 100644 index 0000000..c904dd7 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureHolder.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_onboarding_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi +import io.novafoundation.nova.feature_ledger_core.di.LedgerCoreApi +import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import javax.inject.Inject + +@ApplicationScope +class OnboardingFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val onboardingRouter: OnboardingRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val onboardingFeatureDependencies = DaggerOnboardingFeatureComponent_OnboardingFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .versionsFeatureApi(getFeature(VersionsFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .cloudBackupFeatureApi(getFeature(CloudBackupFeatureApi::class.java)) + .ledgerCoreApi(getFeature(LedgerCoreApi::class.java)) + .build() + return DaggerOnboardingFeatureComponent.factory() + .create(onboardingRouter, onboardingFeatureDependencies) + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureModule.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureModule.kt new file mode 100644 index 0000000..9672bac --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/di/OnboardingFeatureModule.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_onboarding_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_onboarding_api.domain.OnboardingInteractor +import io.novafoundation.nova.feature_onboarding_impl.domain.OnboardingInteractorImpl + +@Module +class OnboardingFeatureModule { + + @Provides + fun provideOnboardingInteractor( + cloudBackupService: CloudBackupService, + accountRepository: AccountRepository + ): OnboardingInteractor { + return OnboardingInteractorImpl(cloudBackupService, accountRepository) + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/domain/OnboardingInteractorImpl.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/domain/OnboardingInteractorImpl.kt new file mode 100644 index 0000000..d63a9b2 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/domain/OnboardingInteractorImpl.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_onboarding_impl.domain + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_onboarding_api.domain.OnboardingInteractor + +class OnboardingInteractorImpl( + private val cloudBackupService: CloudBackupService, + private val accountRepository: AccountRepository +) : OnboardingInteractor { + + override suspend fun checkCloudBackupIsExist(): Result { + return cloudBackupService.isCloudBackupExist() + } + + override suspend fun isCloudBackupAvailableForImport(): Boolean { + return !cloudBackupService.session.isSyncWithCloudEnabled() && + !accountRepository.hasActiveMetaAccounts() + } + + override suspend fun signInToCloud(): Result { + return cloudBackupService.signInToCloud() + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/ImportWalletOptionsFragment.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/ImportWalletOptionsFragment.kt new file mode 100644 index 0000000..d5359a5 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/ImportWalletOptionsFragment.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.setupCustomDialogDisplayer +import io.novafoundation.nova.common.utils.progress.observeProgressDialog +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.observeConfirmationAction +import io.novafoundation.nova.feature_onboarding_api.di.OnboardingFeatureApi +import io.novafoundation.nova.feature_onboarding_impl.databinding.FragmentImportWalletOptionsBinding +import io.novafoundation.nova.feature_onboarding_impl.di.OnboardingFeatureComponent +import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.SelectHardwareWalletBottomSheet + +class ImportWalletOptionsFragment : BaseFragment() { + + override fun createBinding() = FragmentImportWalletOptionsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.importOptionsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.importOptionPassphrase.setOnClickListener { viewModel.importMnemonicClicked() } + binder.importOptionTrustWallet.setOnClickListener { viewModel.importTrustWalletClicked() } + binder.importOptionCloud.setOnClickListener { viewModel.importCloudClicked() } + binder.importOptionHardware.setOnClickListener { viewModel.importHardwareClicked() } + binder.importOptionWatchOnly.setOnClickListener { viewModel.importWatchOnlyClicked() } + binder.importOptionRawSeed.setOnClickListener { viewModel.importRawSeedClicked() } + binder.importOptionJson.setOnClickListener { viewModel.importJsonClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(context!!, OnboardingFeatureApi::class.java) + .importWalletOptionsComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ImportWalletOptionsViewModel) { + setupCustomDialogDisplayer(viewModel) + observeProgressDialog(viewModel.progressDialogMixin) + observeConfirmationAction(viewModel.cloudBackupChangingWarningMixin) + + viewModel.selectHardwareWallet.awaitableActionLiveData.observeEvent { + SelectHardwareWalletBottomSheet(requireContext(), it.payload, it.onSuccess) + .show() + } + + viewModel.showImportViaCloudButton.observe { + binder.importOptionCloud.setVisible(it) + } + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/ImportWalletOptionsViewModel.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/ImportWalletOptionsViewModel.kt new file mode 100644 index 0000000..cefb489 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/ImportWalletOptionsViewModel.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.displayDialogOrNothing +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.common.utils.progress.startProgress +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportAccountPayload +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportType +import io.novafoundation.nova.feature_account_api.presenatation.account.add.ImportType.Mnemonic.Origin +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapCheckBackupAvailableFailureToUi +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_onboarding_api.domain.OnboardingInteractor +import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter +import io.novafoundation.nova.feature_onboarding_impl.R +import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.SelectHardwareWalletBottomSheet +import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.model.HardwareWalletModel +import kotlinx.coroutines.launch + +class ImportWalletOptionsViewModel( + private val resourceManager: ResourceManager, + private val router: OnboardingRouter, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val onboardingInteractor: OnboardingInteractor, + private val progressDialogMixinFactory: ProgressDialogMixinFactory, + customDialogProvider: CustomDialogDisplayer.Presentation, + cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory, + private val ledgerMigrationTracker: LedgerMigrationTracker, +) : BaseViewModel(), CustomDialogDisplayer.Presentation by customDialogProvider { + + val progressDialogMixin = progressDialogMixinFactory.create() + + val cloudBackupChangingWarningMixin = cloudBackupChangingWarningMixinFactory.create(this) + + val selectHardwareWallet = actionAwaitableMixinFactory.create() + + val showImportViaCloudButton = flowOf { onboardingInteractor.isCloudBackupAvailableForImport() } + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun importMnemonicClicked() { + openImportType(ImportType.Mnemonic()) + } + + fun importTrustWalletClicked() { + openImportType(ImportType.Mnemonic(origin = Origin.TRUST_WALLET)) + } + + fun importCloudClicked() = launch { + progressDialogMixin.startProgress(R.string.loocking_backup_progress) { + onboardingInteractor.checkCloudBackupIsExist() + .onSuccess { isCloudBackupExist -> + if (isCloudBackupExist) { + router.restoreCloudBackup() + } else { + showBackupNotFoundError() + } + }.onFailure { + val payload = mapCheckBackupAvailableFailureToUi(resourceManager, it, ::initSignIn) + displayDialogOrNothing(payload) + } + } + } + + fun importHardwareClicked() { + cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded { + launch { + val genericLedgerSupported = ledgerMigrationTracker.anyChainSupportsMigrationApp() + val payload = SelectHardwareWalletBottomSheet.Payload(genericLedgerSupported) + + when (val selection = selectHardwareWallet.awaitAction(payload)) { + HardwareWalletModel.LedgerLegacy -> router.openStartImportLegacyLedger() + + HardwareWalletModel.LedgerGeneric -> router.openStartImportGenericLedger() + + is HardwareWalletModel.PolkadotVault -> when (selection.variant) { + PolkadotVaultVariant.POLKADOT_VAULT -> router.openStartImportPolkadotVault() + PolkadotVaultVariant.PARITY_SIGNER -> router.openStartImportParitySigner() + } + } + } + } + } + + fun importWatchOnlyClicked() { + cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded { + router.openCreateWatchWallet() + } + } + + fun importRawSeedClicked() { + openImportType(ImportType.Seed) + } + + fun importJsonClicked() { + openImportType(ImportType.Json) + } + + private fun openImportType(importType: ImportType) { + cloudBackupChangingWarningMixin.launchChangingConfirmationIfNeeded { + router.openImportAccountScreen(ImportAccountPayload(importType = importType, addAccountPayload = AddAccountPayload.MetaAccount)) + } + } + + private fun initSignIn() { + launch { + onboardingInteractor.signInToCloud() + } + } + + private fun showBackupNotFoundError() { + showError( + resourceManager.getString(R.string.import_wallet_cloud_backup_not_found_title), + resourceManager.getString(R.string.import_wallet_cloud_backup_not_found_subtitle), + ) + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/di/ImportWalletOptionsComponent.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/di/ImportWalletOptionsComponent.kt new file mode 100644 index 0000000..b9a1135 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/di/ImportWalletOptionsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser.ImportWalletOptionsFragment + +@Subcomponent( + modules = [ + ImportWalletOptionsModule::class + ] +) +@ScreenScope +interface ImportWalletOptionsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): ImportWalletOptionsComponent + } + + fun inject(fragment: ImportWalletOptionsFragment) +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/di/ImportWalletOptionsModule.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/di/ImportWalletOptionsModule.kt new file mode 100644 index 0000000..bf44067 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/importChooser/di/ImportWalletOptionsModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.feature_cloud_backup_api.presenter.mixin.CloudBackupChangingWarningMixinFactory +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_onboarding_api.domain.OnboardingInteractor +import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter +import io.novafoundation.nova.feature_onboarding_impl.presentation.importChooser.ImportWalletOptionsViewModel + +@Module(includes = [ViewModelModule::class]) +class ImportWalletOptionsModule { + + @Provides + @IntoMap + @ViewModelKey(ImportWalletOptionsViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: OnboardingRouter, + actionAwaitableMixin: ActionAwaitableMixin.Factory, + progressDialogMixinFactory: ProgressDialogMixinFactory, + onboardingInteractor: OnboardingInteractor, + customDialogProvider: CustomDialogDisplayer.Presentation, + cloudBackupChangingWarningMixinFactory: CloudBackupChangingWarningMixinFactory, + ledgerMigrationTracker: LedgerMigrationTracker + ): ViewModel { + return ImportWalletOptionsViewModel( + resourceManager, + router, + actionAwaitableMixin, + onboardingInteractor, + progressDialogMixinFactory, + customDialogProvider, + cloudBackupChangingWarningMixinFactory, + ledgerMigrationTracker + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ImportWalletOptionsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ImportWalletOptionsViewModel::class.java) + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/view/ImportOptionView.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/view/ImportOptionView.kt new file mode 100644 index 0000000..64c75b7 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/view/ImportOptionView.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.util.AttributeSet +import com.google.android.material.card.MaterialCardView +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.feature_onboarding_impl.R +import io.novafoundation.nova.feature_onboarding_impl.databinding.ViewImportOptionBinding + +class ImportOptionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : MaterialCardView(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2.dpF // It will be drawn along border with clipping and finally will be viewed as 1dp + color = context.getColor(R.color.container_border) + } + + private val binder = ViewImportOptionBinding.inflate(inflater(), this) + + init { + setCardBackgroundColor(context.getColor(R.color.button_background_secondary)) + radius = 12.dpF + cardElevation = 0.dpF + elevation = 0.dpF + + attrs?.let(::applyAttributes) + } + + override fun onDrawForeground(canvas: Canvas) { + super.onDrawForeground(canvas) + canvas.save() + canvas.clipRect(binder.importOptionImage.left, binder.importOptionImage.top, binder.importOptionImage.right, binder.importOptionImage.bottom) + canvas.drawRoundRect( + binder.importOptionImage.left.toFloat(), + binder.importOptionImage.top.toFloat(), + binder.importOptionImage.right.toFloat(), + binder.importOptionImage.bottom.toFloat() + radius, + radius, + radius, + strokePaint + ) + canvas.restore() + } + + private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.ImportOptionView) { + if (it.hasValue(R.styleable.ImportOptionView_android_src)) { + binder.importOptionImage.setImageDrawable(it.getDrawable(R.styleable.ImportOptionView_android_src)) + } + + binder.importOptionName.text = it.getString(R.styleable.ImportOptionView_title) + binder.importOptionDescription.text = it.getString(R.styleable.ImportOptionView_android_text) + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/SelectHardwareWalletBottomSheet.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/SelectHardwareWalletBottomSheet.kt new file mode 100644 index 0000000..2c83f17 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/SelectHardwareWalletBottomSheet.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textWithDescriptionItem +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant +import io.novafoundation.nova.feature_account_api.presenatation.account.polkadotVault.config.PolkadotVaultVariantConfigProvider +import io.novafoundation.nova.feature_onboarding_impl.R +import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.model.HardwareWalletModel + +class SelectHardwareWalletBottomSheet( + context: Context, + private val payload: Payload, + private val onSuccess: (HardwareWalletModel) -> Unit +) : FixedListBottomSheet(context, viewConfiguration = ViewConfiguration.default(context)) { + + class Payload( + val genericLedgerSupported: Boolean + ) + + private val polkadotVaultVariantConfigProvider: PolkadotVaultVariantConfigProvider + + init { + polkadotVaultVariantConfigProvider = FeatureUtils + .getFeature(context, AccountFeatureApi::class.java) + .polkadotVaultVariantConfigProvider + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.account_select_hardware_wallet) + + polkadotVaultItem(PolkadotVaultVariant.POLKADOT_VAULT) + + if (payload.genericLedgerSupported) { + textWithDescriptionItem( + iconRes = R.drawable.ic_ledger, + titleRes = R.string.account_ledger_generic_item_title, + descriptionRes = R.string.account_ledger_generic_item_subtitle, + showArrowWhenEnabled = true, + onClick = { onSuccess(HardwareWalletModel.LedgerGeneric) } + ) + + textItem( + iconRes = R.drawable.ic_ledger_legacy, + titleRes = R.string.account_ledger_nano_x_legacy, + showArrow = true, + applyIconTint = false, + onClick = { onSuccess(HardwareWalletModel.LedgerLegacy) } + ) + } else { + textItem( + iconRes = R.drawable.ic_ledger, + titleRes = R.string.account_ledger_nano_x, + showArrow = true, + applyIconTint = false, + onClick = { onSuccess(HardwareWalletModel.LedgerLegacy) } + ) + } + + polkadotVaultItem(PolkadotVaultVariant.PARITY_SIGNER) + } + + private fun polkadotVaultItem(variant: PolkadotVaultVariant) { + val config = polkadotVaultVariantConfigProvider.variantConfigFor(variant) + + textItem( + iconRes = config.common.iconRes, + titleRes = config.common.nameRes, + showArrow = true, + applyIconTint = false, + onClick = { onSuccess(HardwareWalletModel.PolkadotVault(variant)) } + ) + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/WelcomeFragment.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/WelcomeFragment.kt new file mode 100644 index 0000000..92b1b8c --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/WelcomeFragment.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome + +import android.graphics.Color +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.View + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.clickableSpan +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.setFullSpan +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_onboarding_api.di.OnboardingFeatureApi +import io.novafoundation.nova.feature_onboarding_impl.R +import io.novafoundation.nova.feature_onboarding_impl.databinding.FragmentWelcomeBinding +import io.novafoundation.nova.feature_onboarding_impl.di.OnboardingFeatureComponent + +class WelcomeFragment : BaseFragment() { + + companion object { + private const val KEY_DISPLAY_BACK = "display_back" + private const val KEY_ADD_ACCOUNT_PAYLOAD = "add_account_payload" + + fun bundle(displayBack: Boolean): Bundle { + return Bundle().apply { + putBoolean(KEY_DISPLAY_BACK, displayBack) + putParcelable(KEY_ADD_ACCOUNT_PAYLOAD, AddAccountPayload.MetaAccount) + } + } + + fun bundle(payload: AddAccountPayload): Bundle { + return Bundle().apply { + putBoolean(KEY_DISPLAY_BACK, true) + putParcelable(KEY_ADD_ACCOUNT_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentWelcomeBinding.inflate(layoutInflater) + + override fun applyInsets(rootView: View) { + binder.welcomeStatus.applyStatusBarInsets() + binder.welcomeTerms.applyNavigationBarInsets() + } + + override fun initViews() { + configureTermsAndPrivacy( + getString(R.string.onboarding_terms_and_conditions_1_v2_2_1), + getString(R.string.onboarding_terms_and_conditions_2), + getString(R.string.onboarding_privacy_policy) + ) + binder.welcomeTerms.movementMethod = LinkMovementMethod.getInstance() + binder.welcomeTerms.highlightColor = Color.TRANSPARENT + + binder.welcomeCreateWalletButton.setOnClickListener { viewModel.createAccountClicked() } + binder.welcomeRestoreWalletButton.setOnClickListener { viewModel.importAccountClicked() } + + binder.welcomeBackButton.setOnClickListener { viewModel.backClicked() } + } + + private fun configureTermsAndPrivacy(sourceText: String, terms: String, privacy: String) { + val clickableColor = requireContext().getColor(R.color.text_primary) + + binder.welcomeTerms.text = SpannableFormatter.format( + sourceText, + terms.toSpannable(colorSpan(clickableColor)).setFullSpan(clickableSpan(viewModel::termsClicked)), + privacy.toSpannable(colorSpan(clickableColor)).setFullSpan(clickableSpan(viewModel::privacyClicked)), + ) + } + + override fun inject() { + FeatureUtils.getFeature(context!!, OnboardingFeatureApi::class.java) + .welcomeComponentFactory() + .create( + fragment = this, + shouldShowBack = argument(KEY_DISPLAY_BACK), + addAccountPayload = argument(KEY_ADD_ACCOUNT_PAYLOAD) + ) + .inject(this) + } + + override fun subscribe(viewModel: WelcomeViewModel) { + observeBrowserEvents(viewModel) + + viewModel.shouldShowBackLiveData.observe(binder.welcomeBackButton::setVisible) + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/WelcomeViewModel.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/WelcomeViewModel.kt new file mode 100644 index 0000000..8c44624 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/WelcomeViewModel.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor + +class WelcomeViewModel( + shouldShowBack: Boolean, + private val router: OnboardingRouter, + private val appLinksProvider: AppLinksProvider, + private val addAccountPayload: AddAccountPayload, + updateNotificationsInteractor: UpdateNotificationsInteractor +) : BaseViewModel(), + Browserable { + + val shouldShowBackLiveData: LiveData = MutableLiveData(shouldShowBack) + + override val openBrowserEvent = MutableLiveData>() + + init { + updateNotificationsInteractor.allowInAppUpdateCheck() + } + + fun createAccountClicked() { + when (addAccountPayload) { + is AddAccountPayload.MetaAccount -> router.openCreateFirstWallet() + is AddAccountPayload.ChainAccount -> router.openMnemonicScreen(accountName = null, addAccountPayload) + } + } + + fun importAccountClicked() { + router.openImportOptionsScreen() + } + + fun termsClicked() { + openBrowserEvent.value = Event(appLinksProvider.termsUrl) + } + + fun privacyClicked() { + openBrowserEvent.value = Event(appLinksProvider.privacyUrl) + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/di/WelcomeComponent.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/di/WelcomeComponent.kt new file mode 100644 index 0000000..7baa7b2 --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/di/WelcomeComponent.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.WelcomeFragment + +@Subcomponent( + modules = [ + WelcomeModule::class + ] +) +@ScreenScope +interface WelcomeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance shouldShowBack: Boolean, + @BindsInstance addAccountPayload: AddAccountPayload, + ): WelcomeComponent + } + + fun inject(welcomeFragment: WelcomeFragment) +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/di/WelcomeModule.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/di/WelcomeModule.kt new file mode 100644 index 0000000..bb783df --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/di/WelcomeModule.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.presenatation.account.add.AddAccountPayload +import io.novafoundation.nova.feature_ledger_core.domain.LedgerMigrationTracker +import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter +import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.WelcomeViewModel +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor + +@Module(includes = [ViewModelModule::class]) +class WelcomeModule { + + @Provides + @IntoMap + @ViewModelKey(WelcomeViewModel::class) + fun provideViewModel( + router: OnboardingRouter, + appLinksProvider: AppLinksProvider, + shouldShowBack: Boolean, + addAccountPayload: AddAccountPayload, + updateNotificationsInteractor: UpdateNotificationsInteractor, + ledgerMigrationTracker: LedgerMigrationTracker, + ): ViewModel { + return WelcomeViewModel( + shouldShowBack, + router, + appLinksProvider, + addAccountPayload, + updateNotificationsInteractor + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): WelcomeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(WelcomeViewModel::class.java) + } +} diff --git a/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/model/HardwareWalletModel.kt b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/model/HardwareWalletModel.kt new file mode 100644 index 0000000..305356a --- /dev/null +++ b/feature-onboarding-impl/src/main/java/io/novafoundation/nova/feature_onboarding_impl/presentation/welcome/model/HardwareWalletModel.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.model + +import io.novafoundation.nova.feature_account_api.domain.model.PolkadotVaultVariant + +sealed class HardwareWalletModel { + + class PolkadotVault(val variant: PolkadotVaultVariant) : HardwareWalletModel() + + object LedgerGeneric : HardwareWalletModel() + + object LedgerLegacy : HardwareWalletModel() +} diff --git a/feature-onboarding-impl/src/main/res/layout/fragment_import_wallet_options.xml b/feature-onboarding-impl/src/main/res/layout/fragment_import_wallet_options.xml new file mode 100644 index 0000000..9b49a9e --- /dev/null +++ b/feature-onboarding-impl/src/main/res/layout/fragment_import_wallet_options.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-onboarding-impl/src/main/res/layout/fragment_welcome.xml b/feature-onboarding-impl/src/main/res/layout/fragment_welcome.xml new file mode 100644 index 0000000..5702981 --- /dev/null +++ b/feature-onboarding-impl/src/main/res/layout/fragment_welcome.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-onboarding-impl/src/main/res/layout/view_import_option.xml b/feature-onboarding-impl/src/main/res/layout/view_import_option.xml new file mode 100644 index 0000000..9916621 --- /dev/null +++ b/feature-onboarding-impl/src/main/res/layout/view_import_option.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-onboarding-impl/src/main/res/values/attrs.xml b/feature-onboarding-impl/src/main/res/values/attrs.xml new file mode 100644 index 0000000..7eac3da --- /dev/null +++ b/feature-onboarding-impl/src/main/res/values/attrs.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-onboarding-impl/src/test/java/io/novafoundation/nova/feature_onboarding_impl/ExampleUnitTest.kt b/feature-onboarding-impl/src/test/java/io/novafoundation/nova/feature_onboarding_impl/ExampleUnitTest.kt new file mode 100644 index 0000000..e4bbd35 --- /dev/null +++ b/feature-onboarding-impl/src/test/java/io/novafoundation/nova/feature_onboarding_impl/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_onboarding_impl + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/feature-proxy-api/.gitignore b/feature-proxy-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-proxy-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-proxy-api/build.gradle b/feature-proxy-api/build.gradle new file mode 100644 index 0000000..9281fba --- /dev/null +++ b/feature-proxy-api/build.gradle @@ -0,0 +1,11 @@ + +android { + namespace 'io.novafoundation.nova.feature_proxy_api' +} + +dependencies { + implementation project(":common") + implementation project(':runtime') + + implementation substrateSdkDep +} \ No newline at end of file diff --git a/feature-proxy-api/consumer-rules.pro b/feature-proxy-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-proxy-api/proguard-rules.pro b/feature-proxy-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-proxy-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-proxy-api/src/main/AndroidManifest.xml b/feature-proxy-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..de749ac --- /dev/null +++ b/feature-proxy-api/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/calls/ExtrinsicBuilderExt.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/calls/ExtrinsicBuilderExt.kt new file mode 100644 index 0000000..45ed235 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/calls/ExtrinsicBuilderExt.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_proxy_api.data.calls + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import java.math.BigInteger +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call + +fun ExtrinsicBuilder.addProxyCall(proxyAccountId: AccountId, proxyType: ProxyType): ExtrinsicBuilder { + return call( + Modules.PROXY, + "add_proxy", + argumentsForProxy(runtime, proxyAccountId, proxyType) + ) +} + +fun ExtrinsicBuilder.removeProxyCall(proxyAccountId: AccountId, proxyType: ProxyType): ExtrinsicBuilder { + return call( + Modules.PROXY, + "remove_proxy", + argumentsForProxy(runtime, proxyAccountId, proxyType) + ) +} + +private fun argumentsForProxy(runtime: RuntimeSnapshot, proxyAccountId: AccountId, proxyType: ProxyType): Map { + return mapOf( + "delegate" to PezkuwiAddressConstructor.constructInstance(runtime.typeRegistry, proxyAccountId), + "proxy_type" to DictEnum.Entry(proxyType.name, null), + "delay" to BigInteger.ZERO + ) +} diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/common/DepositBaseAndFactor.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/common/DepositBaseAndFactor.kt new file mode 100644 index 0000000..55442f8 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/common/DepositBaseAndFactor.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_proxy_api.data.common + +import java.math.BigInteger + +class DepositBaseAndFactor( + val baseAmount: BigInteger, + val factorAmount: BigInteger +) diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/common/ProxyDepositCalculator.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/common/ProxyDepositCalculator.kt new file mode 100644 index 0000000..736e184 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/common/ProxyDepositCalculator.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_proxy_api.data.common + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger + +interface ProxyDepositCalculator { + + fun calculateProxyDepositForQuantity(baseAndFactor: DepositBaseAndFactor, proxiesCount: Int): BigInteger + + suspend fun calculateProxyDepositForQuantity(chainId: ChainId, proxiesCount: Int): BigInteger +} diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/OnChainProxiedModel.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/OnChainProxiedModel.kt new file mode 100644 index 0000000..c04942b --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/OnChainProxiedModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_proxy_api.data.model + +import java.math.BigInteger + +class OnChainProxiedModel( + val proxies: List, + val deposit: BigInteger +) diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/OnChainProxyModel.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/OnChainProxyModel.kt new file mode 100644 index 0000000..3124286 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/OnChainProxyModel.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_proxy_api.data.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import java.math.BigInteger + +class OnChainProxyModel( + val proxy: AccountIdKey, + val proxyType: ProxyType, + val delay: BigInteger +) diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/ProxiesMap.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/ProxiesMap.kt new file mode 100644 index 0000000..39e7397 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/ProxiesMap.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_proxy_api.data.model + +import io.novafoundation.nova.common.address.AccountIdKey + +typealias ProxiesMap = Map diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/ProxyPermission.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/ProxyPermission.kt new file mode 100644 index 0000000..87d8633 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/model/ProxyPermission.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_proxy_api.data.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType + +data class ProxyPermission( + val proxiedAccountId: AccountIdKey, + val proxyAccountId: AccountIdKey, + val proxyType: ProxyType +) diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/repository/GetProxyRepository.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/repository/GetProxyRepository.kt new file mode 100644 index 0000000..fbfa7d3 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/repository/GetProxyRepository.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_proxy_api.data.repository + +import io.novafoundation.nova.feature_proxy_api.data.model.ProxiesMap +import io.novafoundation.nova.feature_proxy_api.data.model.ProxyPermission +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +interface GetProxyRepository { + + suspend fun getAllProxies(chainId: ChainId): ProxiesMap + + suspend fun getDelegatedProxyTypesRemote(chainId: ChainId, proxiedAccountId: AccountId, proxyAccountId: AccountId): List + + suspend fun getDelegatedProxyTypesLocal(chainId: ChainId, proxiedAccountId: AccountId, proxyAccountId: AccountId): List + + suspend fun getProxiesQuantity(chainId: ChainId, proxiedAccountId: AccountId): Int + + suspend fun getProxyDeposit(chainId: ChainId, proxiedAccountId: AccountId): BigInteger + + suspend fun maxProxiesQuantity(chain: Chain): Int + + fun proxiesByTypeFlow(chain: Chain, accountId: AccountId, proxyType: ProxyType): Flow> + + fun proxiesQuantityByTypeFlow(chain: Chain, accountId: AccountId, proxyType: ProxyType): Flow +} diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/repository/ProxyConstantsRepository.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/repository/ProxyConstantsRepository.kt new file mode 100644 index 0000000..010519a --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/data/repository/ProxyConstantsRepository.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_proxy_api.data.repository + +import io.novafoundation.nova.feature_proxy_api.data.common.DepositBaseAndFactor +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface ProxyConstantsRepository { + + suspend fun getDepositConstants(chainId: ChainId): DepositBaseAndFactor +} diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/di/ProxyFeatureApi.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/di/ProxyFeatureApi.kt new file mode 100644 index 0000000..cebeb79 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/di/ProxyFeatureApi.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_proxy_api.di + +import io.novafoundation.nova.feature_proxy_api.data.common.ProxyDepositCalculator +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.data.repository.ProxyConstantsRepository + +interface ProxyFeatureApi { + + val proxyRepository: GetProxyRepository + + val proxyDepositCalculator: ProxyDepositCalculator + + val proxyConstantsRepository: ProxyConstantsRepository +} diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/domain/model/ProxyType.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/domain/model/ProxyType.kt new file mode 100644 index 0000000..0e678d9 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/domain/model/ProxyType.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_proxy_api.domain.model + +sealed class ProxyType(open val name: String) { + + data object Any : ProxyType("Any") + + data object NonTransfer : ProxyType("NonTransfer") + + data object Governance : ProxyType("Governance") + + data object Staking : ProxyType("Staking") + + data object IdentityJudgement : ProxyType("IdentityJudgement") + + data object CancelProxy : ProxyType("CancelProxy") + + data object Auction : ProxyType("Auction") + + data object NominationPools : ProxyType("NominationPools") + + data class Other(override val name: String) : ProxyType(name) + + companion object +} + +fun ProxyType.Companion.fromString(name: String): ProxyType { + return when (name) { + "Any" -> ProxyType.Any + "NonTransfer" -> ProxyType.NonTransfer + "Governance" -> ProxyType.Governance + "Staking" -> ProxyType.Staking + "IdentityJudgement" -> ProxyType.IdentityJudgement + "CancelProxy" -> ProxyType.CancelProxy + "Auction" -> ProxyType.Auction + "NominationPools" -> ProxyType.NominationPools + else -> ProxyType.Other(name) + } +} diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/domain/validators/MaximumProxiesNotReachedValidation.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/domain/validators/MaximumProxiesNotReachedValidation.kt new file mode 100644 index 0000000..dd88eea --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/domain/validators/MaximumProxiesNotReachedValidation.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_proxy_api.domain.validators + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class MaximumProxiesNotReachedValidation( + private val chain: (P) -> Chain, + private val accountId: (P) -> AccountId, + private val proxiesQuantity: (P) -> Int, + private val error: (P, Int) -> E, + private val proxyRepository: GetProxyRepository +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val newProxiesQuantity = proxiesQuantity(value) + val maximumProxiesQuantiy = proxyRepository.maxProxiesQuantity(chain(value)) + + return validOrError(newProxiesQuantity <= maximumProxiesQuantiy) { + error(value, maximumProxiesQuantiy) + } + } +} + +fun ValidationSystemBuilder.maximumProxiesNotReached( + chain: (P) -> Chain, + accountId: (P) -> AccountId, + proxiesQuantity: (P) -> Int, + error: (P, Int) -> E, + proxyRepository: GetProxyRepository +) { + validate( + MaximumProxiesNotReachedValidation( + chain = chain, + accountId = accountId, + proxiesQuantity = proxiesQuantity, + error = error, + proxyRepository = proxyRepository + ) + ) +} diff --git a/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/domain/validators/ProxyIsNotDuplicationForAccount.kt b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/domain/validators/ProxyIsNotDuplicationForAccount.kt new file mode 100644 index 0000000..bbb89c8 --- /dev/null +++ b/feature-proxy-api/src/main/java/io/novafoundation/nova/feature_proxy_api/domain/validators/ProxyIsNotDuplicationForAccount.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_proxy_api.domain.validators + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class ProxyIsNotDuplicationForAccount( + private val chain: (P) -> Chain, + private val proxiedAccountId: (P) -> AccountId, + private val proxyAccountId: (P) -> AccountId, + private val proxyType: (P) -> ProxyType, + private val error: (P) -> E, + private val proxyRepository: GetProxyRepository +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val chain = chain(value) + val proxyTypes = proxyRepository.getDelegatedProxyTypesLocal(chain.id, proxiedAccountId(value), proxyAccountId(value)) + + return validOrError(!proxyTypes.contains(proxyType(value))) { + error(value) + } + } +} + +fun ValidationSystemBuilder.proxyIsNotDuplicationForAccount( + chain: (P) -> Chain, + proxiedAccountId: (P) -> AccountId, + proxyAccountId: (P) -> AccountId, + proxyType: (P) -> ProxyType, + error: (P) -> E, + proxyRepository: GetProxyRepository +) { + validate( + ProxyIsNotDuplicationForAccount( + chain = chain, + proxiedAccountId = proxiedAccountId, + proxyAccountId = proxyAccountId, + proxyType = proxyType, + error = error, + proxyRepository = proxyRepository + ) + ) +} diff --git a/feature-proxy-impl/.gitignore b/feature-proxy-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-proxy-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-proxy-impl/build.gradle b/feature-proxy-impl/build.gradle new file mode 100644 index 0000000..7e4f1e6 --- /dev/null +++ b/feature-proxy-impl/build.gradle @@ -0,0 +1,51 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + namespace 'io.novafoundation.nova.feature_proxy' + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':common') + implementation project(':runtime') + implementation project(':feature-proxy-api') + + implementation kotlinDep + + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation lifeCycleKtxDep + + implementation daggerDep + ksp daggerCompiler + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation insetterDep + + implementation shimmerDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} \ No newline at end of file diff --git a/feature-proxy-impl/consumer-rules.pro b/feature-proxy-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-proxy-impl/proguard-rules.pro b/feature-proxy-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-proxy-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-proxy-impl/src/main/AndroidManifest.xml b/feature-proxy-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..10728cc --- /dev/null +++ b/feature-proxy-impl/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/data/common/RealProxyDepositCalculator.kt b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/data/common/RealProxyDepositCalculator.kt new file mode 100644 index 0000000..d23fcaa --- /dev/null +++ b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/data/common/RealProxyDepositCalculator.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_proxy_impl.data.common + +import io.novafoundation.nova.feature_proxy_api.data.common.DepositBaseAndFactor +import io.novafoundation.nova.feature_proxy_api.data.common.ProxyDepositCalculator +import io.novafoundation.nova.feature_proxy_api.data.repository.ProxyConstantsRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger + +class RealProxyDepositCalculator( + private val proxyConstantsRepository: ProxyConstantsRepository +) : ProxyDepositCalculator { + + override fun calculateProxyDepositForQuantity(baseAndFactor: DepositBaseAndFactor, proxiesCount: Int): BigInteger { + return if (proxiesCount == 0) { + BigInteger.ZERO + } else { + baseAndFactor.baseAmount + baseAndFactor.factorAmount * proxiesCount.toBigInteger() + } + } + + override suspend fun calculateProxyDepositForQuantity(chainId: ChainId, proxiesCount: Int): BigInteger { + val depositAndFactor = proxyConstantsRepository.getDepositConstants(chainId) + + return calculateProxyDepositForQuantity(depositAndFactor, proxiesCount) + } +} diff --git a/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/data/repository/RealGetProxyRepository.kt b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/data/repository/RealGetProxyRepository.kt new file mode 100644 index 0000000..111c27c --- /dev/null +++ b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/data/repository/RealGetProxyRepository.kt @@ -0,0 +1,148 @@ +package io.novafoundation.nova.feature_proxy_impl.data.repository + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.proxy +import io.novafoundation.nova.feature_proxy_api.data.model.OnChainProxiedModel +import io.novafoundation.nova.feature_proxy_api.data.model.OnChainProxyModel +import io.novafoundation.nova.feature_proxy_api.data.model.ProxiesMap +import io.novafoundation.nova.feature_proxy_api.data.model.ProxyPermission +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.feature_proxy_api.domain.model.fromString +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class RealGetProxyRepository( + private val remoteSource: StorageDataSource, + private val localSource: StorageDataSource, + private val chainRegistry: ChainRegistry, +) : GetProxyRepository { + + override suspend fun getAllProxies(chainId: ChainId): ProxiesMap { + return receiveAllProxiesInChain(chainId) + } + + override suspend fun getDelegatedProxyTypesRemote(chainId: ChainId, proxiedAccountId: AccountId, proxyAccountId: AccountId): List { + return getDelegatedProxyTypes(remoteSource, chainId, proxiedAccountId, proxyAccountId) + } + + // TODO: use it for staking after merge "add staking proxy" branch + override suspend fun getDelegatedProxyTypesLocal(chainId: ChainId, proxiedAccountId: AccountId, proxyAccountId: AccountId): List { + return getDelegatedProxyTypes(localSource, chainId, proxiedAccountId, proxyAccountId) + } + + override suspend fun getProxiesQuantity(chainId: ChainId, proxiedAccountId: AccountId): Int { + val proxied = getAllProxiesFor(localSource, chainId, proxiedAccountId) + + return proxied.proxies.size + } + + override suspend fun getProxyDeposit(chainId: ChainId, proxiedAccountId: AccountId): BigInteger { + val proxied = getAllProxiesFor(localSource, chainId, proxiedAccountId) + + return proxied.deposit + } + + override suspend fun maxProxiesQuantity(chain: Chain): Int { + val runtime = chainRegistry.getRuntime(chain.id) + val constantQuery = runtime.metadata.proxy() + return constantQuery.numberConstant("MaxProxies", runtime).toInt() + } + + override fun proxiesByTypeFlow(chain: Chain, accountId: AccountId, proxyType: ProxyType): Flow> { + return localSource.subscribe(chain.id) { + runtime.metadata.module(Modules.PROXY) + .storage("Proxies") + .observe( + accountId, + binding = { bindProxyAccounts(it) } + ) + }.map { proxied -> + proxied.proxies + .filter { it.proxyType.name == proxyType.name } + .map { ProxyPermission(accountId.intoKey(), it.proxy, it.proxyType) } + } + } + + override fun proxiesQuantityByTypeFlow(chain: Chain, accountId: AccountId, proxyType: ProxyType): Flow { + return proxiesByTypeFlow(chain, accountId, proxyType) + .map { it.size } + } + + private suspend fun getDelegatedProxyTypes( + storageDataSource: StorageDataSource, + chainId: ChainId, + proxiedAccountId: AccountId, + proxyAccountId: AccountId + ): List { + val proxied = getAllProxiesFor(storageDataSource, chainId, proxiedAccountId) + + return proxied.proxies + .filter { it.proxy == proxyAccountId.intoKey() } + .map { it.proxyType } + } + + private suspend fun getAllProxiesFor(storageDataSource: StorageDataSource, chainId: ChainId, accountId: AccountId): OnChainProxiedModel { + return storageDataSource.query(chainId) { + runtime.metadata.module(Modules.PROXY) + .storage("Proxies") + .query( + keyArguments = arrayOf(accountId), + binding = { result -> bindProxyAccounts(result) } + ) + } + } + + private suspend fun receiveAllProxiesInChain(chainId: ChainId): Map { + return remoteSource.query(chainId) { + runtime.metadata.module(Modules.PROXY) + .storage("Proxies") + .entries( + keyExtractor = { (accountId: AccountId) -> AccountIdKey(accountId) }, + binding = { result, _ -> bindProxyAccounts(result) }, + recover = { _, _ -> + // Do nothing if entry binding throws an exception + } + ) + } + } + + private fun bindProxyAccounts(dynamicInstance: Any?): OnChainProxiedModel { + if (dynamicInstance == null) return OnChainProxiedModel(emptyList(), BigInteger.ZERO) + + val root = dynamicInstance.castToList() + val proxies = root[0].castToList() + + return OnChainProxiedModel( + proxies = proxies.map { + val proxy = it.castToStruct() + val proxyAccountId: ByteArray = proxy.getTyped("delegate") + val proxyType = proxy.get("proxyType").castToDictEnum() + val delay = proxy.getTyped("delay") + OnChainProxyModel( + proxy = proxyAccountId.intoKey(), + proxyType = ProxyType.fromString(proxyType.name), + delay = delay + ) + }, + deposit = root[1].cast() + ) + } +} diff --git a/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/data/repository/RealProxyConstantsRepository.kt b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/data/repository/RealProxyConstantsRepository.kt new file mode 100644 index 0000000..c020974 --- /dev/null +++ b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/data/repository/RealProxyConstantsRepository.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_proxy_impl.data.repository + +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.proxy +import io.novafoundation.nova.feature_proxy_api.data.common.DepositBaseAndFactor +import io.novafoundation.nova.feature_proxy_api.data.repository.ProxyConstantsRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime + +class RealProxyConstantsRepository( + private val chainRegestry: ChainRegistry +) : ProxyConstantsRepository { + + override suspend fun getDepositConstants(chainId: ChainId): DepositBaseAndFactor { + val runtime = chainRegestry.getRuntime(chainId) + val constantQuery = runtime.metadata.proxy() + return DepositBaseAndFactor( + baseAmount = constantQuery.numberConstant("ProxyDepositBase", runtime), + factorAmount = constantQuery.numberConstant("ProxyDepositFactor", runtime) + ) + } +} diff --git a/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureComponent.kt b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureComponent.kt new file mode 100644 index 0000000..5110990 --- /dev/null +++ b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureComponent.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_proxy_impl.di + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_proxy_api.di.ProxyFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + ProxyFeatureDependencies::class + ], + modules = [ + ProxyFeatureModule::class, + ] +) +@FeatureScope +interface ProxyFeatureComponent : ProxyFeatureApi { + + @Component.Factory + interface Factory { + + fun create( + deps: ProxyFeatureDependencies + ): ProxyFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class + ] + ) + interface VoteFeatureDependenciesComponent : ProxyFeatureDependencies +} diff --git a/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureDependencies.kt b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureDependencies.kt new file mode 100644 index 0000000..15efb8b --- /dev/null +++ b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureDependencies.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_proxy_impl.di + +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +interface ProxyFeatureDependencies { + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource + + @Named(LOCAL_STORAGE_SOURCE) + fun localStorageSource(): StorageDataSource + + fun chainRegistry(): ChainRegistry +} diff --git a/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureHolder.kt b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureHolder.kt new file mode 100644 index 0000000..24efb1c --- /dev/null +++ b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureHolder.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_proxy_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class ProxyFeatureHolder @Inject constructor( + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerProxyFeatureComponent_VoteFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .build() + return DaggerProxyFeatureComponent.factory() + .create(dependencies) + } +} diff --git a/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureModule.kt b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureModule.kt new file mode 100644 index 0000000..301425d --- /dev/null +++ b/feature-proxy-impl/src/main/java/io/novafoundation/nova/feature_proxy_impl/di/ProxyFeatureModule.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_proxy_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_proxy_api.data.common.ProxyDepositCalculator +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.data.repository.ProxyConstantsRepository +import io.novafoundation.nova.feature_proxy_impl.data.common.RealProxyDepositCalculator +import io.novafoundation.nova.feature_proxy_impl.data.repository.RealGetProxyRepository +import io.novafoundation.nova.feature_proxy_impl.data.repository.RealProxyConstantsRepository +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class ProxyFeatureModule { + + @Provides + @FeatureScope + fun provideProxyRepository( + @Named(REMOTE_STORAGE_SOURCE) remoteSource: StorageDataSource, + @Named(LOCAL_STORAGE_SOURCE) localSource: StorageDataSource, + chainRegistry: ChainRegistry + ): GetProxyRepository = RealGetProxyRepository( + remoteSource = remoteSource, + localSource = localSource, + chainRegistry + ) + + @Provides + @FeatureScope + fun provideProxyConstantsRepository( + chainRegistry: ChainRegistry + ): ProxyConstantsRepository = RealProxyConstantsRepository( + chainRegistry + ) + + @Provides + @FeatureScope + fun provideProxyDepositCalculator( + proxyConstantsRepository: ProxyConstantsRepository + ): ProxyDepositCalculator { + return RealProxyDepositCalculator(proxyConstantsRepository) + } +} diff --git a/feature-push-notifications/.gitignore b/feature-push-notifications/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-push-notifications/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-push-notifications/build.gradle b/feature-push-notifications/build.gradle new file mode 100644 index 0000000..ad775d7 --- /dev/null +++ b/feature-push-notifications/build.gradle @@ -0,0 +1,48 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' + +android { + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.feature_push_notifications' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':common') + implementation project(':runtime') + implementation project(':feature-account-api') + implementation project(':feature-governance-api') + implementation project(':feature-staking-api') + implementation project(':feature-currency-api') + implementation project(':feature-assets') + implementation project(':feature-deep-linking') + implementation project(':feature-multisig:operations') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation platform(firebaseBomDep) + implementation firestoreDep + implementation firebaseCloudMessagingDep + + implementation coroutinesDep + + implementation daggerDep + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler +} \ No newline at end of file diff --git a/feature-push-notifications/consumer-rules.pro b/feature-push-notifications/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-push-notifications/proguard-rules.pro b/feature-push-notifications/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-push-notifications/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-push-notifications/src/main/AndroidManifest.xml b/feature-push-notifications/src/main/AndroidManifest.xml new file mode 100644 index 0000000..de749ac --- /dev/null +++ b/feature-push-notifications/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/NovaFirebaseMessagingService.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/NovaFirebaseMessagingService.kt new file mode 100644 index 0000000..8804d5f --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/NovaFirebaseMessagingService.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_push_notifications + +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsService +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureComponent +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationHandler +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlin.coroutines.CoroutineContext + +class NovaFirebaseMessagingService : FirebaseMessagingService(), CoroutineScope { + + override val coroutineContext: CoroutineContext = Dispatchers.Default + SupervisorJob() + + @Inject + lateinit var pushNotificationsService: PushNotificationsService + + @Inject + lateinit var notificationHandler: NotificationHandler + + override fun onCreate() { + super.onCreate() + + injectDependencies() + } + + override fun onNewToken(token: String) { + pushNotificationsService.onTokenUpdated(token) + } + + override fun onMessageReceived(message: RemoteMessage) { + launch { + notificationHandler.handleNotification(message) + } + } + + override fun onDestroy() { + super.onDestroy() + + coroutineContext.cancel() + } + + private fun injectDependencies() { + FeatureUtils.getFeature(this, PushNotificationsFeatureApi::class.java) + .inject(this) + } + + companion object { + + suspend fun getToken(): String? { + return runCatching { FirebaseMessaging.getInstance().token.await() }.getOrNull() + } + + suspend fun requestToken(): String { + return FirebaseMessaging.getInstance().token.await() + } + + suspend fun deleteToken() { + FirebaseMessaging.getInstance().deleteToken() + } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/PushNotificationsRouter.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/PushNotificationsRouter.kt new file mode 100644 index 0000000..167c52c --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/PushNotificationsRouter.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_push_notifications + +import android.os.Bundle +import io.novafoundation.nova.common.navigation.ReturnableRouter + +interface PushNotificationsRouter : ReturnableRouter { + + fun openPushSettingsWithAccounts() + + fun openPushMultisigsSettings(args: Bundle) + + fun openPushGovernanceSettings(args: Bundle) + + fun openPushStakingSettings(args: Bundle) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/NotificationTypes.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/NotificationTypes.kt new file mode 100644 index 0000000..818ca24 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/NotificationTypes.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_push_notifications.data + +object NotificationTypes { + const val GOV_NEW_REF = "govNewRef" + const val GOV_STATE = "govState" + const val STAKING_REWARD = "stakingReward" + const val TOKENS_SENT = "tokenSent" + const val TOKENS_RECEIVED = "tokenReceived" + const val APP_NEW_RELEASE = "appNewRelease" + + const val NEW_MULTISIG = "newMultisig" + const val MULTISIG_APPROVAL = "multisigApproval" + const val MULTISIG_EXECUTED = "multisigExecuted" + const val MULTISIG_CANCELLED = "multisigCancelled" +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushNotificationsAvailabilityState.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushNotificationsAvailabilityState.kt new file mode 100644 index 0000000..2cf9487 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushNotificationsAvailabilityState.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_push_notifications.data + +enum class PushNotificationsAvailabilityState { + AVAILABLE, + GOOGLE_PLAY_INSTALLATION_REQUIRED, + PLAY_SERVICES_REQUIRED +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushNotificationsService.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushNotificationsService.kt new file mode 100644 index 0000000..029262f --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushNotificationsService.kt @@ -0,0 +1,187 @@ +package io.novafoundation.nova.feature_push_notifications.data + +import com.google.firebase.messaging.messaging +import android.util.Log +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.Firebase +import com.google.firebase.messaging.FirebaseMessaging +import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.interfaces.BuildTypeProvider +import io.novafoundation.nova.common.interfaces.isMarketRelease +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.feature_push_notifications.BuildConfig +import io.novafoundation.nova.feature_push_notifications.NovaFirebaseMessagingService +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import io.novafoundation.nova.feature_push_notifications.data.settings.PushSettingsProvider +import io.novafoundation.nova.feature_push_notifications.data.subscription.PushSubscriptionService +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +const val PUSH_LOG_TAG = "NOVA_PUSH" +private const val PREFS_LAST_SYNC_TIME = "PREFS_LAST_SYNC_TIME" +private const val MIN_DAYS_TO_START_SYNC = 1 +private val SAVING_TIMEOUT = 15.seconds + +interface PushNotificationsService { + + fun onTokenUpdated(token: String) + + fun isPushNotificationsEnabled(): Boolean + + fun pushNotificationsAvaiabilityState(): PushNotificationsAvailabilityState + + suspend fun initPushNotifications(): Result + + suspend fun updatePushSettings(enabled: Boolean, pushSettings: PushSettings?): Result + + fun isPushNotificationsAvailable(): Boolean + + suspend fun syncSettingsIfNeeded() +} + +class RealPushNotificationsService( + private val settingsProvider: PushSettingsProvider, + private val subscriptionService: PushSubscriptionService, + private val rootScope: RootScope, + private val tokenCache: PushTokenCache, + private val googleApiAvailabilityProvider: GoogleApiAvailabilityProvider, + private val pushPermissionRepository: PushPermissionRepository, + private val preferences: Preferences, + private val buildTypeProvider: BuildTypeProvider +) : PushNotificationsService { + + // Using to manually sync subscriptions (firestore, topics) after enabling push notifications + private var skipTokenReceivingCallback = false + + init { + logToken() + } + + override fun onTokenUpdated(token: String) { + if (!isPushNotificationsAvailable()) return + if (!isPushNotificationsEnabled()) return + if (skipTokenReceivingCallback) return + + logToken() + + rootScope.launch { + tokenCache.updatePushToken(token) + updatePushSettings(isPushNotificationsEnabled(), settingsProvider.getPushSettings()) + } + } + + override suspend fun updatePushSettings(enabled: Boolean, pushSettings: PushSettings?): Result { + if (!isPushNotificationsAvailable()) return googleApiFailureResult() + + return runCatching { + withTimeout(SAVING_TIMEOUT) { + handlePushTokenIfNeeded(enabled) + val pushToken = getPushToken() + val oldSettings = settingsProvider.getPushSettings() + subscriptionService.handleSubscription(enabled, pushToken, oldSettings, pushSettings) + settingsProvider.setPushNotificationsEnabled(enabled) + settingsProvider.updateSettings(pushSettings) + updateLastSyncTime() + } + } + } + + override fun isPushNotificationsAvailable(): Boolean { + return pushNotificationsAvaiabilityState() == PushNotificationsAvailabilityState.AVAILABLE + } + + override fun pushNotificationsAvaiabilityState(): PushNotificationsAvailabilityState { + return when { + !googleApiAvailabilityProvider.isAvailable() -> PushNotificationsAvailabilityState.PLAY_SERVICES_REQUIRED + !BuildConfig.DEBUG && !buildTypeProvider.isMarketRelease() -> PushNotificationsAvailabilityState.GOOGLE_PLAY_INSTALLATION_REQUIRED + else -> PushNotificationsAvailabilityState.AVAILABLE + } + } + + override suspend fun syncSettingsIfNeeded() { + if (!isPushNotificationsEnabled()) return + if (!isPushNotificationsAvailable()) return + + if (isPermissionsRevoked() || isTimeToSync()) { + val isPermissionGranted = pushPermissionRepository.isPermissionGranted() + updatePushSettings(isPermissionGranted, settingsProvider.getPushSettings()) + } + } + + override fun isPushNotificationsEnabled(): Boolean { + return settingsProvider.isPushNotificationsEnabled() + } + + override suspend fun initPushNotifications(): Result { + if (!isPushNotificationsAvailable()) return googleApiFailureResult() + + return updatePushSettings(true, settingsProvider.getDefaultPushSettings()) + } + + private suspend fun handlePushTokenIfNeeded(isEnable: Boolean) { + if (!isPushNotificationsAvailable()) return + if (isEnable == isPushNotificationsEnabled()) return + + skipTokenReceivingCallback = true + + val pushToken = if (isEnable) { + NovaFirebaseMessagingService.requestToken() + } else { + NovaFirebaseMessagingService.deleteToken() + null + } + + tokenCache.updatePushToken(pushToken) + Firebase.messaging.isAutoInitEnabled = isEnable + + skipTokenReceivingCallback = false + } + + private fun getPushToken(): String? { + return tokenCache.getPushToken() + } + + private fun logToken() { + if (!isPushNotificationsEnabled()) return + if (!BuildConfig.DEBUG) return + if (!isPushNotificationsAvailable()) return + + FirebaseMessaging.getInstance().token.addOnCompleteListener( + OnCompleteListener { task -> + if (!task.isSuccessful) { + return@OnCompleteListener + } + + Log.d(PUSH_LOG_TAG, "FCM token: ${task.result}") + } + ) + } + + private fun isPermissionsRevoked(): Boolean { + return !pushPermissionRepository.isPermissionGranted() + } + + private fun isTimeToSync(): Boolean { + if (!isPushNotificationsEnabled()) return false + + val lastSyncTime = getLastSyncTimeIfPushEnabled() + val deltaTimeBetweenNowAndLastSync = System.currentTimeMillis() - lastSyncTime + val wholeDays = deltaTimeBetweenNowAndLastSync.milliseconds.inWholeDays + return wholeDays >= MIN_DAYS_TO_START_SYNC + } + + private fun updateLastSyncTime() { + preferences.putLong(PREFS_LAST_SYNC_TIME, System.currentTimeMillis()) + } + + private fun getLastSyncTimeIfPushEnabled(): Long { + return preferences.getLong(PREFS_LAST_SYNC_TIME, 0) + } + + private fun googleApiFailureResult(): Result { + return Result.failure(IllegalStateException("Google API is not available")) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushPermissionRepository.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushPermissionRepository.kt new file mode 100644 index 0000000..bac9326 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushPermissionRepository.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_push_notifications.data + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat + +interface PushPermissionRepository { + + fun isPermissionGranted(): Boolean +} + +class RealPushPermissionRepository( + private val context: Context +) : PushPermissionRepository { + + override fun isPermissionGranted(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushTokenCache.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushTokenCache.kt new file mode 100644 index 0000000..2f01c1a --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/PushTokenCache.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_push_notifications.data + +import io.novafoundation.nova.common.data.storage.Preferences + +private const val PUSH_TOKEN_KEY = "push_token" + +interface PushTokenCache { + + fun getPushToken(): String? + + fun updatePushToken(pushToken: String?) +} + +class RealPushTokenCache( + private val preferences: Preferences +) : PushTokenCache { + + override fun getPushToken(): String? { + return preferences.getString(PUSH_TOKEN_KEY) + } + + override fun updatePushToken(pushToken: String?) { + preferences.putString(PUSH_TOKEN_KEY, pushToken) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/repository/MultisigPushAlertRepository.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/repository/MultisigPushAlertRepository.kt new file mode 100644 index 0000000..cc160a3 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/repository/MultisigPushAlertRepository.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_push_notifications.data.repository + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.feature_push_notifications.domain.interactor.AllowingState + +interface MultisigPushAlertRepository { + + fun isMultisigsPushAlertWasShown(): Boolean + + fun setMultisigsPushAlertWasShown() + + fun showAlertAtStartAllowingState(): AllowingState + + fun setAlertAtStartAllowingState(state: AllowingState) +} + +private const val IS_MULTISIGS_PUSH_ALERT_WAS_SHOWN = "IS_MULTISIGS_PUSH_ALERT_WAS_SHOWN" +private const val MULTISIGS_PUSH_ALERT_ALLOWING_STATE = "MULTISIGS_PUSH_ALERT_ALLOWING_STATE" + +class RealMultisigPushAlertRepository( + private val preferences: Preferences +) : MultisigPushAlertRepository { + override fun isMultisigsPushAlertWasShown(): Boolean { + return preferences.getBoolean(IS_MULTISIGS_PUSH_ALERT_WAS_SHOWN, false) + } + + override fun setMultisigsPushAlertWasShown() { + preferences.putBoolean(IS_MULTISIGS_PUSH_ALERT_WAS_SHOWN, true) + } + + override fun showAlertAtStartAllowingState(): AllowingState { + val state = preferences.getString(MULTISIGS_PUSH_ALERT_ALLOWING_STATE, AllowingState.INITIAL.toString()) + return AllowingState.valueOf(state) + } + + override fun setAlertAtStartAllowingState(state: AllowingState) { + preferences.putString(MULTISIGS_PUSH_ALERT_ALLOWING_STATE, state.toString()) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/repository/PushSettingsRepository.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/repository/PushSettingsRepository.kt new file mode 100644 index 0000000..3c87c1c --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/repository/PushSettingsRepository.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_push_notifications.data.repository + +import io.novafoundation.nova.common.data.storage.Preferences + +interface PushSettingsRepository { + fun isMultisigsWasEnabledFirstTime(): Boolean + + fun setMultisigsWasEnabledFirstTime() +} + +private const val IS_MULTISIG_WAS_ENABLED_FIRST_TIME = "IS_MULTISIG_WAS_ENABLED_FIRST_TIME" + +class RealPushSettingsRepository( + private val preferences: Preferences +) : PushSettingsRepository { + override fun isMultisigsWasEnabledFirstTime(): Boolean { + return preferences.getBoolean(IS_MULTISIG_WAS_ENABLED_FIRST_TIME, false) + } + + override fun setMultisigsWasEnabledFirstTime() { + preferences.putBoolean(IS_MULTISIG_WAS_ENABLED_FIRST_TIME, true) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/PushSettingsProvider.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/PushSettingsProvider.kt new file mode 100644 index 0000000..335e827 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/PushSettingsProvider.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings + +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import kotlinx.coroutines.flow.Flow + +interface PushSettingsProvider { + + suspend fun getPushSettings(): PushSettings + + suspend fun getDefaultPushSettings(): PushSettings + + fun updateSettings(pushWalletSettings: PushSettings?) + + fun setPushNotificationsEnabled(isEnabled: Boolean) + + fun isPushNotificationsEnabled(): Boolean + + fun pushEnabledFlow(): Flow +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/PushSettingsSerializer.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/PushSettingsSerializer.kt new file mode 100644 index 0000000..314f8af --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/PushSettingsSerializer.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings + +import com.google.gson.GsonBuilder +import io.novafoundation.nova.common.utils.gson.SealedTypeAdapterFactory +import io.novafoundation.nova.feature_push_notifications.data.settings.model.chain.ChainFeatureCacheV1 + +object PushSettingsSerializer { + + fun gson() = GsonBuilder() + .registerTypeAdapterFactory(SealedTypeAdapterFactory.of(ChainFeatureCacheV1::class)) + .create() +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/RealPushSettingsProvider.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/RealPushSettingsProvider.kt new file mode 100644 index 0000000..9d707bb --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/RealPushSettingsProvider.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings + +import com.google.gson.Gson +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_push_notifications.data.settings.model.PushSettingsCacheV1 +import io.novafoundation.nova.feature_push_notifications.data.settings.model.PushSettingsCacheV2 +import io.novafoundation.nova.feature_push_notifications.data.settings.model.VersionedPushSettingsCache +import io.novafoundation.nova.feature_push_notifications.data.settings.model.toCache +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import kotlinx.coroutines.flow.Flow + +private const val PUSH_SETTINGS_KEY = "push_settings" +private const val PREFS_PUSH_NOTIFICATIONS_ENABLED = "push_notifications_enabled" + +class RealPushSettingsProvider( + private val gson: Gson, + private val prefs: Preferences, + private val accountRepository: AccountRepository +) : PushSettingsProvider { + + override suspend fun getPushSettings(): PushSettings { + return prefs.getString(PUSH_SETTINGS_KEY) + ?.let { + gson.fromJson(it, VersionedPushSettingsCache::class.java) + .toPushSettings() + } ?: getDefaultPushSettings() + } + + override suspend fun getDefaultPushSettings(): PushSettings { + return PushSettings( + announcementsEnabled = true, + sentTokensEnabled = true, + receivedTokensEnabled = true, + subscribedMetaAccounts = setOf(accountRepository.getSelectedMetaAccount().id), + stakingReward = PushSettings.ChainFeature.All, + governance = emptyMap(), + multisigs = PushSettings.MultisigsState.disabled() + ) + } + + override fun updateSettings(pushWalletSettings: PushSettings?) { + val versionedCache = pushWalletSettings?.toCache() + ?.toVersionedPushSettingsCache() + + prefs.putString(PUSH_SETTINGS_KEY, versionedCache?.let(gson::toJson)) + } + + override fun setPushNotificationsEnabled(isEnabled: Boolean) { + prefs.putBoolean(PREFS_PUSH_NOTIFICATIONS_ENABLED, isEnabled) + } + + override fun isPushNotificationsEnabled(): Boolean { + return prefs.getBoolean(PREFS_PUSH_NOTIFICATIONS_ENABLED, false) + } + + override fun pushEnabledFlow(): Flow { + return prefs.booleanFlow(PREFS_PUSH_NOTIFICATIONS_ENABLED, false) + } + + fun PushSettingsCacheV2.toVersionedPushSettingsCache(): VersionedPushSettingsCache { + return VersionedPushSettingsCache( + version = version, + settings = gson.toJson(this) + ) + } + + fun VersionedPushSettingsCache.toPushSettings(): PushSettings { + return when (version) { + PushSettingsCacheV1.VERSION -> gson.fromJson(settings, PushSettingsCacheV1::class.java) + PushSettingsCacheV2.VERSION -> gson.fromJson(settings, PushSettingsCacheV2::class.java) + else -> throw IllegalStateException("Unknown push settings version: $version") + }.toPushSettings() + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/CommonMappingExt.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/CommonMappingExt.kt new file mode 100644 index 0000000..cbbe1bc --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/CommonMappingExt.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings.model + +import io.novafoundation.nova.feature_push_notifications.data.settings.model.chain.ChainFeatureCacheV1 +import io.novafoundation.nova.feature_push_notifications.data.settings.model.governance.GovernanceStateCacheV1 +import io.novafoundation.nova.feature_push_notifications.data.settings.model.governance.MultisigsStateCacheV1 +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings + +fun PushSettings.toCache(): PushSettingsCacheV2 { + return PushSettingsCacheV2( + announcementsEnabled = announcementsEnabled, + sentTokensEnabled = sentTokensEnabled, + receivedTokensEnabled = receivedTokensEnabled, + subscribedMetaAccounts = subscribedMetaAccounts, + stakingReward = stakingReward.toCache(), + governance = governance.mapValues { (_, value) -> value.toCache() }, + multisigs = multisigs.toCache() + ) +} + +fun PushSettings.ChainFeature.toCache(): ChainFeatureCacheV1 { + return when (this) { + is PushSettings.ChainFeature.All -> ChainFeatureCacheV1.All + is PushSettings.ChainFeature.Concrete -> ChainFeatureCacheV1.Concrete(chainIds) + } +} + +fun PushSettings.GovernanceState.toCache(): GovernanceStateCacheV1 { + return GovernanceStateCacheV1( + newReferendaEnabled = newReferendaEnabled, + referendumUpdateEnabled = referendumUpdateEnabled, + govMyDelegateVotedEnabled = govMyDelegateVotedEnabled, + tracks = tracks + ) +} + +fun PushSettings.MultisigsState.toCache(): MultisigsStateCacheV1 { + return MultisigsStateCacheV1( + isEnabled = isEnabled, + isInitialNotificationsEnabled = isInitiatingEnabled, + isApprovalNotificationsEnabled = isApprovingEnabled, + isExecutionNotificationsEnabled = isExecutionEnabled, + isRejectionNotificationsEnabled = isRejectionEnabled + ) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/PushSettingsCache.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/PushSettingsCache.kt new file mode 100644 index 0000000..49f2d7e --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/PushSettingsCache.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings.model + +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings + +interface PushSettingsCache { + + val version: String + + fun toPushSettings(): PushSettings +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/PushSettingsCacheV1.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/PushSettingsCacheV1.kt new file mode 100644 index 0000000..4482778 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/PushSettingsCacheV1.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings.model + +import io.novafoundation.nova.feature_push_notifications.data.settings.model.chain.ChainFeatureCacheV1 +import io.novafoundation.nova.feature_push_notifications.data.settings.model.chain.toDomain +import io.novafoundation.nova.feature_push_notifications.data.settings.model.governance.GovernanceStateCacheV1 +import io.novafoundation.nova.feature_push_notifications.data.settings.model.governance.toDomain +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class PushSettingsCacheV1( + val announcementsEnabled: Boolean, + val sentTokensEnabled: Boolean, + val receivedTokensEnabled: Boolean, + val subscribedMetaAccounts: Set, + val stakingReward: ChainFeatureCacheV1, + val governance: Map +) : PushSettingsCache { + + companion object { + const val VERSION = "V1" + } + + override val version: String = VERSION + + override fun toPushSettings(): PushSettings { + return PushSettings( + announcementsEnabled = announcementsEnabled, + sentTokensEnabled = sentTokensEnabled, + receivedTokensEnabled = receivedTokensEnabled, + subscribedMetaAccounts = subscribedMetaAccounts, + stakingReward = stakingReward.toDomain(), + governance = governance.mapValues { (_, value) -> value.toDomain() }, + multisigs = PushSettings.MultisigsState.disabled() + ) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/PushSettingsCacheV2.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/PushSettingsCacheV2.kt new file mode 100644 index 0000000..1a544f2 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/PushSettingsCacheV2.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings.model + +import io.novafoundation.nova.feature_push_notifications.data.settings.model.chain.ChainFeatureCacheV1 +import io.novafoundation.nova.feature_push_notifications.data.settings.model.chain.toDomain +import io.novafoundation.nova.feature_push_notifications.data.settings.model.governance.GovernanceStateCacheV1 +import io.novafoundation.nova.feature_push_notifications.data.settings.model.governance.MultisigsStateCacheV1 +import io.novafoundation.nova.feature_push_notifications.data.settings.model.governance.toDomain +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class PushSettingsCacheV2( + val announcementsEnabled: Boolean, + val sentTokensEnabled: Boolean, + val receivedTokensEnabled: Boolean, + val subscribedMetaAccounts: Set, + val stakingReward: ChainFeatureCacheV1, + val governance: Map, + val multisigs: MultisigsStateCacheV1 +) : PushSettingsCache { + + companion object { + const val VERSION = "V2" + } + + override val version: String = VERSION + + override fun toPushSettings(): PushSettings { + return PushSettings( + announcementsEnabled = announcementsEnabled, + sentTokensEnabled = sentTokensEnabled, + receivedTokensEnabled = receivedTokensEnabled, + subscribedMetaAccounts = subscribedMetaAccounts, + stakingReward = stakingReward.toDomain(), + governance = governance.mapValues { (_, value) -> value.toDomain() }, + multisigs = multisigs.toDomain() + ) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/VersionedPushSettingsCache.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/VersionedPushSettingsCache.kt new file mode 100644 index 0000000..3705238 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/VersionedPushSettingsCache.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings.model + +typealias Json = String + +class VersionedPushSettingsCache( + val version: String, + val settings: Json +) diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/chain/ChainFeatureCacheV1.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/chain/ChainFeatureCacheV1.kt new file mode 100644 index 0000000..7248e24 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/chain/ChainFeatureCacheV1.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings.model.chain + +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +sealed class ChainFeatureCacheV1 { + + object All : ChainFeatureCacheV1() + + data class Concrete(val chainIds: List) : ChainFeatureCacheV1() +} + +fun ChainFeatureCacheV1.toDomain(): PushSettings.ChainFeature { + return when (this) { + is ChainFeatureCacheV1.All -> PushSettings.ChainFeature.All + is ChainFeatureCacheV1.Concrete -> PushSettings.ChainFeature.Concrete(chainIds) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/governance/GovernanceStateCacheV1.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/governance/GovernanceStateCacheV1.kt new file mode 100644 index 0000000..0f5b6e7 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/governance/GovernanceStateCacheV1.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings.model.governance + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings + +class GovernanceStateCacheV1( + val newReferendaEnabled: Boolean, + val referendumUpdateEnabled: Boolean, + val govMyDelegateVotedEnabled: Boolean, + val tracks: Set +) + +fun GovernanceStateCacheV1.toDomain(): PushSettings.GovernanceState { + return PushSettings.GovernanceState( + newReferendaEnabled = newReferendaEnabled, + referendumUpdateEnabled = referendumUpdateEnabled, + govMyDelegateVotedEnabled = govMyDelegateVotedEnabled, + tracks = tracks + ) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/governance/MultisigsStateCacheV1.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/governance/MultisigsStateCacheV1.kt new file mode 100644 index 0000000..1f0df08 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/settings/model/governance/MultisigsStateCacheV1.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_push_notifications.data.settings.model.governance + +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings + +class MultisigsStateCacheV1( + val isEnabled: Boolean, // General notifications state. Other states may be enabled to save state when general one is disabled + val isInitialNotificationsEnabled: Boolean, + val isApprovalNotificationsEnabled: Boolean, + val isExecutionNotificationsEnabled: Boolean, + val isRejectionNotificationsEnabled: Boolean +) + +fun MultisigsStateCacheV1.toDomain(): PushSettings.MultisigsState { + return PushSettings.MultisigsState( + isEnabled = isEnabled, + isInitiatingEnabled = isInitialNotificationsEnabled, + isApprovingEnabled = isApprovalNotificationsEnabled, + isExecutionEnabled = isExecutionNotificationsEnabled, + isRejectionEnabled = isRejectionNotificationsEnabled + ) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/subscription/PushSubscriptionService.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/subscription/PushSubscriptionService.kt new file mode 100644 index 0000000..5878e46 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/subscription/PushSubscriptionService.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_push_notifications.data.subscription + +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings + +interface PushSubscriptionService { + + suspend fun handleSubscription(pushEnabled: Boolean, token: String?, oldSettings: PushSettings, newSettings: PushSettings?) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/subscription/RealPushSubscriptionService.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/subscription/RealPushSubscriptionService.kt new file mode 100644 index 0000000..f1ed445 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/data/subscription/RealPushSubscriptionService.kt @@ -0,0 +1,227 @@ +package io.novafoundation.nova.feature_push_notifications.data.subscription + +import android.util.Log +import com.google.firebase.Firebase +import com.google.firebase.firestore.firestore +import com.google.firebase.messaging.messaging +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.utils.formatting.formatDateISO_8601_NoMs +import io.novafoundation.nova.common.utils.mapOfNotNullValues +import io.novafoundation.nova.common.utils.mapValuesNotNull +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.defaultSubstrateAddress +import io.novafoundation.nova.feature_account_api.domain.model.mainEthereumAddress +import io.novafoundation.nova.feature_push_notifications.BuildConfig +import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider +import io.novafoundation.nova.feature_push_notifications.data.PUSH_LOG_TAG +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import io.novafoundation.nova.feature_push_notifications.domain.model.isApprovingEnabledTotal +import io.novafoundation.nova.feature_push_notifications.domain.model.isExecutionEnabledTotal +import io.novafoundation.nova.feature_push_notifications.domain.model.isInitiatingEnabledTotal +import io.novafoundation.nova.feature_push_notifications.domain.model.isRejectionEnabledTotal +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.chainIdHexPrefix16 +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainsById +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import java.math.BigInteger +import java.util.UUID +import java.util.Date +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.tasks.await + +private const val COLLECTION_NAME = "users" +private const val PREFS_FIRESTORE_UUID = "firestore_uuid" + +private const val GOV_STATE_TOPIC_NAME = "govState" +private const val NEW_REFERENDA_TOPIC_NAME = "govNewRef" + +class TrackIdentifiable(val chainId: ChainId, val track: BigInteger) : Identifiable { + override val identifier: String = "$chainId:$track" +} + +class RealPushSubscriptionService( + private val prefs: Preferences, + private val chainRegistry: ChainRegistry, + private val googleApiAvailabilityProvider: GoogleApiAvailabilityProvider, + private val accountRepository: AccountRepository +) : PushSubscriptionService { + + private val generateIdMutex = Mutex() + + override suspend fun handleSubscription(pushEnabled: Boolean, token: String?, oldSettings: PushSettings, newSettings: PushSettings?) { + if (!googleApiAvailabilityProvider.isAvailable()) return + + val tokenExist = token != null + if (pushEnabled != tokenExist) throw IllegalStateException("Token should exist to enable push notifications") + + handleTopics(pushEnabled, oldSettings, newSettings) + handleFirestore(token, newSettings) + + if (BuildConfig.DEBUG) { + Log.d(PUSH_LOG_TAG, "Firestore user updated: ${getFirestoreUUID()}") + } + } + + private suspend fun getFirestoreUUID(): String { + return generateIdMutex.withLock { + var uuid = prefs.getString(PREFS_FIRESTORE_UUID) + + if (uuid == null) { + uuid = UUID.randomUUID().toString() + prefs.putString(PREFS_FIRESTORE_UUID, uuid) + } + + uuid + } + } + + private suspend fun handleFirestore(token: String?, pushSettings: PushSettings?) { + val hasAccounts = pushSettings?.subscribedMetaAccounts?.any() ?: false + if (token == null || pushSettings == null || !hasAccounts) { + Firebase.firestore.collection(COLLECTION_NAME) + .document(getFirestoreUUID()) + .delete() + .await() + } else { + val model = mapToFirestorePushSettings( + token, + Date(), + pushSettings + ) + + Firebase.firestore.collection(COLLECTION_NAME) + .document(getFirestoreUUID()) + .set(model) + .await() + } + } + + private fun handleTopics(pushEnabled: Boolean, oldSettings: PushSettings, newSettings: PushSettings?) { + val referendumUpdateTracks = newSettings?.getGovernanceTracksFor { it.referendumUpdateEnabled } + ?.takeIf { pushEnabled } + .orEmpty() + + val newReferendaTracks = newSettings?.getGovernanceTracksFor { it.newReferendaEnabled } + ?.takeIf { pushEnabled } + .orEmpty() + + val govStateTracksDiff = CollectionDiffer.findDiff( + oldItems = oldSettings.getGovernanceTracksFor { it.referendumUpdateEnabled }, + newItems = referendumUpdateTracks, + forceUseNewItems = false + ) + val newReferendaDiff = CollectionDiffer.findDiff( + oldItems = oldSettings.getGovernanceTracksFor { it.newReferendaEnabled }, + newItems = newReferendaTracks, + forceUseNewItems = false + ) + + val announcementsEnabled = newSettings?.announcementsEnabled ?: false + handleSubscription(announcementsEnabled && pushEnabled, "appUpdates") + + govStateTracksDiff.added + .map { subscribeToTopic("${GOV_STATE_TOPIC_NAME}_${it.chainId}_${it.track}") } + govStateTracksDiff.removed + .map { unsubscribeFromTopic("${GOV_STATE_TOPIC_NAME}_${it.chainId}_${it.track}") } + + newReferendaDiff.added + .map { subscribeToTopic("${NEW_REFERENDA_TOPIC_NAME}_${it.chainId}_${it.track}") } + newReferendaDiff.removed + .map { unsubscribeFromTopic("${NEW_REFERENDA_TOPIC_NAME}_${it.chainId}_${it.track}") } + } + + private fun handleSubscription(subscribe: Boolean, topic: String) { + return if (subscribe) { + subscribeToTopic(topic) + } else { + unsubscribeFromTopic(topic) + } + } + + private fun subscribeToTopic(topic: String) { + Firebase.messaging.subscribeToTopic(topic) + } + + private fun unsubscribeFromTopic(topic: String) { + Firebase.messaging.unsubscribeFromTopic(topic) + } + + private suspend fun mapToFirestorePushSettings( + token: String, + date: Date, + settings: PushSettings + ): Map { + val chainsById = chainRegistry.chainsById() + val metaAccountsById = accountRepository + .getActiveMetaAccounts() + .associateBy { it.id } + + return mapOf( + "pushToken" to token, + "updatedAt" to formatDateISO_8601_NoMs(date), + "wallets" to settings.subscribedMetaAccounts.mapNotNull { mapToFirestoreWallet(it, metaAccountsById, chainsById) }, + "notifications" to mapOfNotNullValues( + "stakingReward" to mapToFirestoreChainFeature(settings.stakingReward), + "tokenSent" to settings.sentTokensEnabled.mapToFirestoreChainFeatureOrNull(), + "tokenReceived" to settings.receivedTokensEnabled.mapToFirestoreChainFeatureOrNull(), + "newMultisig" to settings.multisigs.isInitiatingEnabledTotal().mapToFirestoreChainFeatureOrNull(), + "multisigApproval" to settings.multisigs.isApprovingEnabledTotal().mapToFirestoreChainFeatureOrNull(), + "multisigExecuted" to settings.multisigs.isExecutionEnabledTotal().mapToFirestoreChainFeatureOrNull(), + "multisigCancelled" to settings.multisigs.isRejectionEnabledTotal().mapToFirestoreChainFeatureOrNull() + ) + ) + } + + private fun mapToFirestoreWallet(metaId: Long, metaAccountsById: Map, chainsById: ChainsById): Map? { + val metaAccount = metaAccountsById[metaId] ?: return null + return mapOfNotNullValues( + "baseEthereum" to metaAccount.mainEthereumAddress(), + "baseSubstrate" to metaAccount.defaultSubstrateAddress, + "chainSpecific" to metaAccount.chainAccounts.mapValuesNotNull { (chainId, chainAccount) -> + val chain = chainsById[chainId] ?: return@mapValuesNotNull null + chain.addressOf(chainAccount.accountId) + }.transfromChainIdsTo16Hex() + .nullIfEmpty() + ) + } + + private fun mapToFirestoreChainFeature(chainFeature: PushSettings.ChainFeature): Map? { + return when (chainFeature) { + is PushSettings.ChainFeature.All -> mapOf("type" to "all") + is PushSettings.ChainFeature.Concrete -> { + if (chainFeature.chainIds.isEmpty()) { + null + } else { + mapOf("type" to "concrete", "value" to chainFeature.chainIds.transfromChainIdsTo16Hex()) + } + } + } + } + + private fun Boolean.mapToFirestoreChainFeatureOrNull(): Map? { + return if (this) mapOf("type" to "all") else null + } + + private fun PushSettings.getGovernanceTracksFor(filter: (PushSettings.GovernanceState) -> Boolean): List { + return governance.filter { (_, state) -> filter(state) } + .flatMap { (chainId, state) -> state.tracks.map { TrackIdentifiable(chainId.chainIdHexPrefix16(), it.value) } } + } + + private fun Map.nullIfEmpty(): Map? { + return if (isEmpty()) null else this + } + + private fun Map.transfromChainIdsTo16Hex(): Map { + return mapKeys { (chainId, _) -> chainId.chainIdHexPrefix16() } + } + + private fun List.transfromChainIdsTo16Hex(): List { + return map { chainId -> chainId.chainIdHexPrefix16() } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/NotificationHandlersModule.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/NotificationHandlersModule.kt new file mode 100644 index 0000000..1a4792d --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/NotificationHandlersModule.kt @@ -0,0 +1,365 @@ +package io.novafoundation.nova.feature_push_notifications.di + +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_governance_api.presentation.referenda.common.ReferendaStatusFormatter +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_push_notifications.presentation.handling.CompoundNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.SystemNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.RealNotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.DebugNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.NewReferendumNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.NewReleaseNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.ReferendumStateUpdateNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.StakingRewardNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.TokenReceivedNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.TokenSentNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig.MultisigTransactionCancelledNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig.MultisigTransactionExecutedNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig.MultisigTransactionInitiatedNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig.MultisigTransactionNewApprovalNotificationHandler +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module() +class NotificationHandlersModule { + + @Provides + fun provideNotificationIdProvider(preferences: Preferences): NotificationIdProvider { + return RealNotificationIdProvider(preferences) + } + + @Provides + fun provideNotificationManagerCompat(context: Context): NotificationManagerCompat { + return NotificationManagerCompat.from(context) + } + + @Provides + @IntoSet + fun systemNotificationHandler( + context: Context, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + notificationManagerCompat: NotificationManagerCompat, + resourceManager: ResourceManager, + gson: Gson + ): NotificationHandler { + return SystemNotificationHandler(context, activityIntentProvider, notificationIdProvider, gson, notificationManagerCompat, resourceManager) + } + + @Provides + @IntoSet + fun tokenSentNotificationHandler( + context: Context, + notificationIdProvider: NotificationIdProvider, + notificationManagerCompat: NotificationManagerCompat, + resourceManager: ResourceManager, + gson: Gson, + activityIntentProvider: ActivityIntentProvider, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + tokenRepository: TokenRepository, + configurator: AssetDetailsDeepLinkConfigurator + ): NotificationHandler { + return TokenSentNotificationHandler( + context, + accountRepository, + tokenRepository, + chainRegistry, + configurator, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManagerCompat, + resourceManager + ) + } + + @Provides + @IntoSet + fun tokenReceivedNotificationHandler( + context: Context, + notificationIdProvider: NotificationIdProvider, + notificationManagerCompat: NotificationManagerCompat, + resourceManager: ResourceManager, + gson: Gson, + activityIntentProvider: ActivityIntentProvider, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + tokenRepository: TokenRepository, + configurator: AssetDetailsDeepLinkConfigurator + ): NotificationHandler { + return TokenReceivedNotificationHandler( + context, + accountRepository, + tokenRepository, + configurator, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManagerCompat, + resourceManager + ) + } + + @Provides + @IntoSet + fun stakingRewardNotificationHandler( + context: Context, + notificationIdProvider: NotificationIdProvider, + notificationManagerCompat: NotificationManagerCompat, + resourceManager: ResourceManager, + gson: Gson, + activityIntentProvider: ActivityIntentProvider, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + tokenRepository: TokenRepository, + configurator: AssetDetailsDeepLinkConfigurator + ): NotificationHandler { + return StakingRewardNotificationHandler( + context, + accountRepository, + tokenRepository, + configurator, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManagerCompat, + resourceManager + ) + } + + @Provides + @IntoSet + fun referendumStateUpdateNotificationHandler( + context: Context, + notificationIdProvider: NotificationIdProvider, + notificationManagerCompat: NotificationManagerCompat, + resourceManager: ResourceManager, + activityIntentProvider: ActivityIntentProvider, + referendaStatusFormatter: ReferendaStatusFormatter, + gson: Gson, + chainRegistry: ChainRegistry, + configurator: ReferendumDetailsDeepLinkConfigurator + ): NotificationHandler { + return ReferendumStateUpdateNotificationHandler( + context, + configurator, + referendaStatusFormatter, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManagerCompat, + resourceManager + ) + } + + @Provides + @IntoSet + fun newReleaseNotificationHandler( + context: Context, + appLinksProvider: AppLinksProvider, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + notificationManagerCompat: NotificationManagerCompat, + resourceManager: ResourceManager, + gson: Gson + ): NotificationHandler { + return NewReleaseNotificationHandler( + context, + appLinksProvider, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManagerCompat, + resourceManager + ) + } + + @Provides + @IntoSet + fun newReferendumNotificationHandler( + context: Context, + notificationIdProvider: NotificationIdProvider, + notificationManagerCompat: NotificationManagerCompat, + activityIntentProvider: ActivityIntentProvider, + resourceManager: ResourceManager, + gson: Gson, + chainRegistry: ChainRegistry, + configurator: ReferendumDetailsDeepLinkConfigurator + ): NotificationHandler { + return NewReferendumNotificationHandler( + context, + configurator, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManagerCompat, + resourceManager + ) + } + + @Provides + @IntoSet + fun multisigTransactionInitiatedNotificationHandler( + context: Context, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + multisigCallFormatter: MultisigCallFormatter, + configurator: MultisigOperationDeepLinkConfigurator, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, + @LocalIdentity identityProvider: IdentityProvider + ): NotificationHandler { + return MultisigTransactionInitiatedNotificationHandler( + context, + accountRepository, + multisigCallFormatter, + configurator, + chainRegistry, + identityProvider, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager + ) + } + + @Provides + @IntoSet + fun multisigTransactionNewApprovalNotificationHandler( + context: Context, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + multisigDetailsRepository: MultisigDetailsRepository, + @LocalIdentity identityProvider: IdentityProvider, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + multisigCallFormatter: MultisigCallFormatter, + configurator: MultisigOperationDeepLinkConfigurator, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, + ): NotificationHandler { + return MultisigTransactionNewApprovalNotificationHandler( + context, + accountRepository, + multisigDetailsRepository, + multisigCallFormatter, + configurator, + identityProvider, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager + ) + } + + @Provides + @IntoSet + fun multisigTransactionExecutedNotificationHandler( + context: Context, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + configurator: MultisigOperationDeepLinkConfigurator, + @LocalIdentity identityProvider: IdentityProvider, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + multisigCallFormatter: MultisigCallFormatter, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, + ): NotificationHandler { + return MultisigTransactionExecutedNotificationHandler( + context, + accountRepository, + multisigCallFormatter, + configurator, + identityProvider, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager + ) + } + + @Provides + @IntoSet + fun multisigTransactionCancelledNotificationHandler( + context: Context, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + configurator: MultisigOperationDeepLinkConfigurator, + @LocalIdentity identityProvider: IdentityProvider, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + multisigCallFormatter: MultisigCallFormatter, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, + ): NotificationHandler { + return MultisigTransactionCancelledNotificationHandler( + context, + accountRepository, + multisigCallFormatter, + configurator, + identityProvider, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager + ) + } + + @Provides + fun debugNotificationHandler( + context: Context, + activityIntentProvider: ActivityIntentProvider, + notificationManagerCompat: NotificationManagerCompat, + resourceManager: ResourceManager + ): DebugNotificationHandler { + return DebugNotificationHandler(context, activityIntentProvider, notificationManagerCompat, resourceManager) + } + + @Provides + @FeatureScope + fun provideCompoundNotificationHandler( + handlers: Set<@JvmSuppressWildcards NotificationHandler>, + debugNotificationHandler: DebugNotificationHandler + ): NotificationHandler { + val handlersWithDebugHandler = handlers + debugNotificationHandler // Add debug handler as a fallback in the end + return CompoundNotificationHandler(handlersWithDebugHandler) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureApi.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureApi.kt new file mode 100644 index 0000000..87ac936 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureApi.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_push_notifications.di + +import io.novafoundation.nova.feature_push_notifications.NovaFirebaseMessagingService +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.WelcomePushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning.MultisigPushNotificationsAlertMixinFactory + +interface PushNotificationsFeatureApi { + + val multisigPushNotificationsAlertMixinFactory: MultisigPushNotificationsAlertMixinFactory + + fun inject(service: NovaFirebaseMessagingService) + + fun pushNotificationInteractor(): PushNotificationsInteractor + + fun welcomePushNotificationsInteractor(): WelcomePushNotificationsInteractor +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureComponent.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureComponent.kt new file mode 100644 index 0000000..5b574c9 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureComponent.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_push_notifications.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksCommunicator +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsService +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.governance.di.PushGovernanceSettingsComponent +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.di.PushMultisigSettingsComponent +import io.novafoundation.nova.feature_push_notifications.presentation.settings.di.PushSettingsComponent +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.staking.di.PushStakingSettingsComponent +import io.novafoundation.nova.feature_push_notifications.presentation.welcome.di.PushWelcomeComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + PushNotificationsFeatureDependencies::class + ], + modules = [ + PushNotificationsFeatureModule::class + ] +) +@FeatureScope +interface PushNotificationsFeatureComponent : PushNotificationsFeatureApi { + + fun getPushNotificationService(): PushNotificationsService + + fun pushWelcomeComponentFactory(): PushWelcomeComponent.Factory + + fun pushSettingsComponentFactory(): PushSettingsComponent.Factory + + fun pushGovernanceSettings(): PushGovernanceSettingsComponent.Factory + + fun pushStakingSettings(): PushStakingSettingsComponent.Factory + + fun pushMultisigSettings(): PushMultisigSettingsComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: PushNotificationsRouter, + @BindsInstance selectMultipleWalletsCommunicator: SelectMultipleWalletsCommunicator, + @BindsInstance selectTracksCommunicator: SelectTracksCommunicator, + @BindsInstance pushGovernanceSettingsCommunicator: PushGovernanceSettingsCommunicator, + @BindsInstance pushStakingSettingsCommunicator: PushStakingSettingsCommunicator, + @BindsInstance pushMultisigSettingsCommunicator: PushMultisigSettingsCommunicator, + deps: PushNotificationsFeatureDependencies + ): PushNotificationsFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + AccountFeatureApi::class, + GovernanceFeatureApi::class, + WalletFeatureApi::class, + AssetsFeatureApi::class, + MultisigOperationsFeatureApi::class + ] + ) + interface PushNotificationsFeatureDependenciesComponent : PushNotificationsFeatureDependencies +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureDependencies.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureDependencies.kt new file mode 100644 index 0000000..be8e3c6 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureDependencies.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_push_notifications.di + +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator +import android.content.Context +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.interfaces.BuildTypeProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_account_api.data.events.MetaAccountChangesEventBus +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_governance_api.presentation.referenda.common.ReferendaStatusFormatter +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +interface PushNotificationsFeatureDependencies { + + val rootScope: RootScope + + val preferences: Preferences + + val context: Context + + val chainRegistry: ChainRegistry + + val permissionsAskerFactory: PermissionsAskerFactory + + val resourceManager: ResourceManager + + val accountRepository: AccountRepository + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val governanceSourceRegistry: GovernanceSourceRegistry + + val imageLoader: ImageLoader + + val gson: Gson + + val referendaStatusFormatter: ReferendaStatusFormatter + + val assetDetailsDeepLinkConfigurator: AssetDetailsDeepLinkConfigurator + + val tokenRepository: TokenRepository + + val provideActivityIntentProvider: ActivityIntentProvider + + val appLinksProvider: AppLinksProvider + + val referendumDetailsDeepLinkConfigurator: ReferendumDetailsDeepLinkConfigurator + + val multisigOperationDeepLinkConfigurator: MultisigOperationDeepLinkConfigurator + + val metaAccountChangesEventBus: MetaAccountChangesEventBus + + val googleApiAvailabilityProvider: GoogleApiAvailabilityProvider + + val multisigCallFormatter: MultisigCallFormatter + + val multisigDetailsRepository: MultisigDetailsRepository + + val metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry + + val automaticInteractionGate: AutomaticInteractionGate + + fun buildTypeProvider(): BuildTypeProvider + + @LocalIdentity + fun localWithIdentityProvider(): IdentityProvider +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureHolder.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureHolder.kt new file mode 100644 index 0000000..a943bb4 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureHolder.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_push_notifications.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksCommunicator +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_governance_api.di.GovernanceFeatureApi +import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsCommunicator +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +class PushNotificationsFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: PushNotificationsRouter, + private val selectMultipleWalletsCommunicator: SelectMultipleWalletsCommunicator, + private val selectTracksCommunicator: SelectTracksCommunicator, + private val pushGovernanceSettingsCommunicator: PushGovernanceSettingsCommunicator, + private val pushStakingSettingsCommunicator: PushStakingSettingsCommunicator, + private val pushMultisigSettingsCommunicator: PushMultisigSettingsCommunicator +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerPushNotificationsFeatureComponent_PushNotificationsFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .governanceFeatureApi(getFeature(GovernanceFeatureApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .assetsFeatureApi(getFeature(AssetsFeatureApi::class.java)) + .multisigOperationsFeatureApi(getFeature(MultisigOperationsFeatureApi::class.java)) + .build() + + return DaggerPushNotificationsFeatureComponent.factory() + .create( + router, + selectMultipleWalletsCommunicator, + selectTracksCommunicator, + pushGovernanceSettingsCommunicator, + pushStakingSettingsCommunicator, + pushMultisigSettingsCommunicator, + dependencies + ) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureModule.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureModule.kt new file mode 100644 index 0000000..f275a82 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/di/PushNotificationsFeatureModule.kt @@ -0,0 +1,203 @@ +package io.novafoundation.nova.feature_push_notifications.di + +import android.content.Context +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.GoogleApiAvailabilityProvider +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.interfaces.BuildTypeProvider +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsService +import io.novafoundation.nova.feature_push_notifications.data.PushPermissionRepository +import io.novafoundation.nova.feature_push_notifications.data.PushTokenCache +import io.novafoundation.nova.feature_push_notifications.data.RealPushNotificationsService +import io.novafoundation.nova.feature_push_notifications.data.RealPushPermissionRepository +import io.novafoundation.nova.feature_push_notifications.data.RealPushTokenCache +import io.novafoundation.nova.feature_push_notifications.data.repository.MultisigPushAlertRepository +import io.novafoundation.nova.feature_push_notifications.data.repository.PushSettingsRepository +import io.novafoundation.nova.feature_push_notifications.data.repository.RealMultisigPushAlertRepository +import io.novafoundation.nova.feature_push_notifications.data.repository.RealPushSettingsRepository +import io.novafoundation.nova.feature_push_notifications.data.settings.PushSettingsProvider +import io.novafoundation.nova.feature_push_notifications.data.settings.PushSettingsSerializer +import io.novafoundation.nova.feature_push_notifications.data.settings.RealPushSettingsProvider +import io.novafoundation.nova.feature_push_notifications.data.subscription.PushSubscriptionService +import io.novafoundation.nova.feature_push_notifications.data.subscription.RealPushSubscriptionService +import io.novafoundation.nova.feature_push_notifications.domain.interactor.GovernancePushSettingsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.MultisigPushAlertInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.RealGovernancePushSettingsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.RealMultisigPushAlertInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.RealPushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.RealStakingPushSettingsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.RealWelcomePushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.StakingPushSettingsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.WelcomePushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning.MultisigPushNotificationsAlertMixinFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class PushSettingsSerialization + +@Module(includes = [NotificationHandlersModule::class]) +class PushNotificationsFeatureModule { + + @Provides + @FeatureScope + fun providePushTokenCache( + preferences: Preferences + ): PushTokenCache { + return RealPushTokenCache(preferences) + } + + @Provides + @FeatureScope + @PushSettingsSerialization + fun providePushSettingsGson() = PushSettingsSerializer.gson() + + @Provides + @FeatureScope + fun providePushSettingsProvider( + @PushSettingsSerialization gson: Gson, + preferences: Preferences, + accountRepository: AccountRepository + ): PushSettingsProvider { + return RealPushSettingsProvider(gson, preferences, accountRepository) + } + + @Provides + @FeatureScope + fun providePushSubscriptionService( + prefs: Preferences, + chainRegistry: ChainRegistry, + googleApiAvailabilityProvider: GoogleApiAvailabilityProvider, + accountRepository: AccountRepository + ): PushSubscriptionService { + return RealPushSubscriptionService( + prefs, + chainRegistry, + googleApiAvailabilityProvider, + accountRepository + ) + } + + @Provides + @FeatureScope + fun providePushPermissionRepository(context: Context): PushPermissionRepository { + return RealPushPermissionRepository(context) + } + + @Provides + @FeatureScope + fun providePushNotificationsService( + pushSettingsProvider: PushSettingsProvider, + pushSubscriptionService: PushSubscriptionService, + rootScope: RootScope, + pushTokenCache: PushTokenCache, + googleApiAvailabilityProvider: GoogleApiAvailabilityProvider, + pushPermissionRepository: PushPermissionRepository, + preferences: Preferences, + buildTypeProvider: BuildTypeProvider + ): PushNotificationsService { + return RealPushNotificationsService( + pushSettingsProvider, + pushSubscriptionService, + rootScope, + pushTokenCache, + googleApiAvailabilityProvider, + pushPermissionRepository, + preferences, + buildTypeProvider + ) + } + + @Provides + @FeatureScope + fun providePushSettingsRepository(preferences: Preferences): PushSettingsRepository { + return RealPushSettingsRepository(preferences) + } + + @Provides + @FeatureScope + fun providePushNotificationsInteractor( + pushNotificationsService: PushNotificationsService, + pushSettingsProvider: PushSettingsProvider, + accountRepository: AccountRepository, + pushSettingsRepository: PushSettingsRepository + ): PushNotificationsInteractor { + return RealPushNotificationsInteractor(pushNotificationsService, pushSettingsProvider, accountRepository, pushSettingsRepository) + } + + @Provides + @FeatureScope + fun provideWelcomePushNotificationsInteractor( + preferences: Preferences, + pushNotificationsService: PushNotificationsService + ): WelcomePushNotificationsInteractor { + return RealWelcomePushNotificationsInteractor(preferences, pushNotificationsService) + } + + @Provides + @FeatureScope + fun provideGovernancePushSettingsInteractor( + chainRegistry: ChainRegistry, + governanceSourceRegistry: GovernanceSourceRegistry + ): GovernancePushSettingsInteractor { + return RealGovernancePushSettingsInteractor( + chainRegistry, + governanceSourceRegistry + ) + } + + @Provides + @FeatureScope + fun provideStakingPushSettingsInteractor(chainRegistry: ChainRegistry): StakingPushSettingsInteractor { + return RealStakingPushSettingsInteractor(chainRegistry) + } + + @Provides + @FeatureScope + fun provideMultisigPushAlertRepository( + preferences: Preferences + ): MultisigPushAlertRepository { + return RealMultisigPushAlertRepository(preferences) + } + + @Provides + @FeatureScope + fun provideMultisigPushAlertInteractor( + pushSettingsProvider: PushSettingsProvider, + accountRepository: AccountRepository, + multisigPushAlertRepository: MultisigPushAlertRepository + ): MultisigPushAlertInteractor { + return RealMultisigPushAlertInteractor( + pushSettingsProvider, + accountRepository, + multisigPushAlertRepository + ) + } + + @Provides + @FeatureScope + fun provideMultisigPushNotificationsAlertMixin( + automaticInteractionGate: AutomaticInteractionGate, + interactor: MultisigPushAlertInteractor, + metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry, + router: PushNotificationsRouter + ): MultisigPushNotificationsAlertMixinFactory { + return MultisigPushNotificationsAlertMixinFactory( + automaticInteractionGate, + interactor, + metaAccountsUpdatesRegistry, + router + ) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/GovernancePushSettingsInteractor.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/GovernancePushSettingsInteractor.kt new file mode 100644 index 0000000..c1750f5 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/GovernancePushSettingsInteractor.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_push_notifications.domain.interactor + +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.source.GovernanceSourceRegistry +import io.novafoundation.nova.runtime.ext.defaultComparatorFrom +import io.novafoundation.nova.runtime.ext.openGovIfSupported +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.enabledChainsFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ChainWithGovTracks( + val chain: Chain, + val govVersion: Chain.Governance, + val tracks: Set +) + +interface GovernancePushSettingsInteractor { + + fun governanceChainsFlow(): Flow> +} + +class RealGovernancePushSettingsInteractor( + private val chainRegistry: ChainRegistry, + private val governanceSourceRegistry: GovernanceSourceRegistry +) : GovernancePushSettingsInteractor { + + override fun governanceChainsFlow(): Flow> { + return chainRegistry.enabledChainsFlow() + .map { chains -> + chains.filter { it.pushSupport } + .flatMap { it.supportedGovTypes() } + .map { (chain, govType) -> ChainWithGovTracks(chain, govType, getTrackIds(chain, govType)) } + .sortedWith(Chain.defaultComparatorFrom(ChainWithGovTracks::chain)) + } + } + + private fun Chain.supportedGovTypes(): List> { + return listOfNotNull(openGovIfSupported()?.let { this to it }) + } + + private suspend fun getTrackIds(chain: Chain, governance: Chain.Governance): Set { + return governanceSourceRegistry.sourceFor(governance) + .referenda + .getTracks(chain.id) + .mapToSet { it.id } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/MultisigPushAlertInteractor.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/MultisigPushAlertInteractor.kt new file mode 100644 index 0000000..cf9e953 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/MultisigPushAlertInteractor.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_push_notifications.domain.interactor + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.isMultisig +import io.novafoundation.nova.feature_push_notifications.data.repository.MultisigPushAlertRepository +import io.novafoundation.nova.feature_push_notifications.data.settings.PushSettingsProvider + +interface MultisigPushAlertInteractor { + + fun isPushNotificationsEnabled(): Boolean + + fun isAlertAlreadyShown(): Boolean + + fun setAlertWasAlreadyShown() + + suspend fun allowedToShowAlertAtStart(): Boolean + + suspend fun hasMultisigWallets(consumedMetaIdsUpdates: List): Boolean +} + +enum class AllowingState { + INITIAL, ALLOWED, NOT_ALLOWED +} + +class RealMultisigPushAlertInteractor( + private val pushSettingsProvider: PushSettingsProvider, + private val accountRepository: AccountRepository, + private val multisigPushAlertRepository: MultisigPushAlertRepository +) : MultisigPushAlertInteractor { + + override fun isPushNotificationsEnabled(): Boolean { + return pushSettingsProvider.isPushNotificationsEnabled() + } + + override fun isAlertAlreadyShown(): Boolean { + return multisigPushAlertRepository.isMultisigsPushAlertWasShown() + } + + override fun setAlertWasAlreadyShown() { + multisigPushAlertRepository.setMultisigsPushAlertWasShown() + } + + /** + * We have to check if we can show alert right after user update the app. + * Showing is allowed when user have multisig accounts in first app start after update + */ + override suspend fun allowedToShowAlertAtStart(): Boolean { + val allowingState = multisigPushAlertRepository.showAlertAtStartAllowingState() + + if (allowingState == AllowingState.INITIAL) { + val userHasMultisigs = accountRepository.hasMetaAccountsByType(LightMetaAccount.Type.MULTISIG) + if (userHasMultisigs) { + multisigPushAlertRepository.setAlertAtStartAllowingState(AllowingState.ALLOWED) + return true + } else { + multisigPushAlertRepository.setAlertAtStartAllowingState(AllowingState.NOT_ALLOWED) + return false + } + } + + return allowingState == AllowingState.ALLOWED + } + + override suspend fun hasMultisigWallets(consumedMetaIdsUpdates: List): Boolean { + val consumedMetaAccountUpdates = accountRepository.getMetaAccountsByIds(consumedMetaIdsUpdates) + return consumedMetaAccountUpdates.any { it.isMultisig() } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/PushNotificationsInteractor.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/PushNotificationsInteractor.kt new file mode 100644 index 0000000..b62a0db --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/PushNotificationsInteractor.kt @@ -0,0 +1,143 @@ +package io.novafoundation.nova.feature_push_notifications.domain.interactor + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsAvailabilityState +import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsService +import io.novafoundation.nova.feature_push_notifications.data.repository.PushSettingsRepository +import io.novafoundation.nova.feature_push_notifications.data.settings.PushSettingsProvider +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import kotlinx.coroutines.flow.Flow + +interface PushNotificationsInteractor { + + suspend fun initialSyncSettings() + + fun pushNotificationsEnabledFlow(): Flow + + suspend fun initPushSettings(): Result + + suspend fun updatePushSettings(enable: Boolean, pushSettings: PushSettings): Result + + suspend fun getPushSettings(): PushSettings + + suspend fun getDefaultSettings(): PushSettings + + suspend fun getMetaAccounts(metaIds: List): List + + fun isPushNotificationsEnabled(): Boolean + + fun isMultisigsWasEnabledFirstTime(): Boolean + + fun setMultisigsWasEnabledFirstTime() + + fun pushNotificationsAvailabilityState(): PushNotificationsAvailabilityState + + fun isPushNotificationsAvailable(): Boolean + + suspend fun onMetaAccountChange(changed: List, deleted: List) + + suspend fun filterAvailableMetaIdsAndGetNewState(pushSettings: PushSettings): PushSettings + + suspend fun getNewStateForChangedMetaAccounts(currentSettings: PushSettings, newMetaIds: Set): PushSettings +} + +class RealPushNotificationsInteractor( + private val pushNotificationsService: PushNotificationsService, + private val pushSettingsProvider: PushSettingsProvider, + private val accountRepository: AccountRepository, + private val pushSettingsRepository: PushSettingsRepository +) : PushNotificationsInteractor { + + override suspend fun initialSyncSettings() { + pushNotificationsService.syncSettingsIfNeeded() + } + + override fun pushNotificationsEnabledFlow(): Flow { + return pushSettingsProvider.pushEnabledFlow() + } + + override suspend fun initPushSettings(): Result { + return pushNotificationsService.initPushNotifications() + } + + override suspend fun updatePushSettings(enable: Boolean, pushSettings: PushSettings): Result { + return pushNotificationsService.updatePushSettings(enable, pushSettings) + } + + override suspend fun getPushSettings(): PushSettings { + return pushSettingsProvider.getPushSettings() + } + + override suspend fun getDefaultSettings(): PushSettings { + return pushSettingsProvider.getDefaultPushSettings() + } + + override suspend fun getMetaAccounts(metaIds: List): List { + return accountRepository.getMetaAccountsByIds(metaIds) + } + + override fun isPushNotificationsEnabled(): Boolean { + return pushSettingsProvider.isPushNotificationsEnabled() + } + + override fun isMultisigsWasEnabledFirstTime(): Boolean { + return pushSettingsRepository.isMultisigsWasEnabledFirstTime() + } + + override fun setMultisigsWasEnabledFirstTime() { + pushSettingsRepository.setMultisigsWasEnabledFirstTime() + } + + override fun isPushNotificationsAvailable(): Boolean { + return pushNotificationsService.isPushNotificationsAvailable() + } + + override fun pushNotificationsAvailabilityState(): PushNotificationsAvailabilityState { + return pushNotificationsService.pushNotificationsAvaiabilityState() + } + + override suspend fun onMetaAccountChange(changed: List, deleted: List) { + if (changed.isEmpty() && deleted.isEmpty()) return + + val notificationsEnabled = pushSettingsProvider.isPushNotificationsEnabled() + val noAccounts = accountRepository.getActiveMetaAccountsQuantity() == 0 + val pushSettings = pushSettingsProvider.getPushSettings() + + val allAffected = (changed + deleted).toSet() + val subscribedAccountsAffected = pushSettings.subscribedMetaAccounts.intersect(allAffected).isNotEmpty() + + when { + notificationsEnabled && noAccounts -> pushNotificationsService.updatePushSettings(enabled = false, pushSettings = null) + noAccounts -> pushSettingsProvider.updateSettings(pushWalletSettings = null) + subscribedAccountsAffected -> { + val newSubscribedMetaAccounts = pushSettings.subscribedMetaAccounts - deleted.toSet() + val newEnabledState = notificationsEnabled && newSubscribedMetaAccounts.isNotEmpty() + val newPushSettings = getNewStateForChangedMetaAccounts(pushSettings, newSubscribedMetaAccounts) + pushNotificationsService.updatePushSettings(enabled = newEnabledState, pushSettings = newPushSettings) + } + } + } + + override suspend fun filterAvailableMetaIdsAndGetNewState(pushSettings: PushSettings): PushSettings { + val availableMetaIds = accountRepository.getAvailableMetaIdsFromSet(pushSettings.subscribedMetaAccounts) + if (availableMetaIds == pushSettings.subscribedMetaAccounts) return pushSettings + + return getNewStateForChangedMetaAccounts(pushSettings, availableMetaIds) + } + + override suspend fun getNewStateForChangedMetaAccounts(currentSettings: PushSettings, newMetaIds: Set): PushSettings { + val noMultisigWalletsForNewAccounts = !accountRepository.hasMetaAccountsByType(newMetaIds, LightMetaAccount.Type.MULTISIG) + if (noMultisigWalletsForNewAccounts) { + val disabledMultisigSettings = PushSettings.MultisigsState.disabled() + return currentSettings.copy(subscribedMetaAccounts = newMetaIds, multisigs = disabledMultisigSettings) + } + + return if (!currentSettings.multisigs.isEnabled) { + currentSettings.copy(subscribedMetaAccounts = newMetaIds, multisigs = PushSettings.MultisigsState.enabled()) + } else { + currentSettings.copy(subscribedMetaAccounts = newMetaIds) + } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/StakingPushSettingsInteractor.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/StakingPushSettingsInteractor.kt new file mode 100644 index 0000000..9b67fd2 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/StakingPushSettingsInteractor.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_push_notifications.domain.interactor + +import io.novafoundation.nova.feature_staking_api.data.dashboard.common.supportedStakingOptions +import io.novafoundation.nova.runtime.ext.defaultComparator +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface StakingPushSettingsInteractor { + + fun stakingChainsFlow(): Flow> +} + +class RealStakingPushSettingsInteractor( + private val chainRegistry: ChainRegistry +) : StakingPushSettingsInteractor { + + override fun stakingChainsFlow(): Flow> { + return chainRegistry.stakingChainsFlow() + .map { chains -> + chains.filter { it.pushSupport } + .sortedWith(Chain.defaultComparator()) + } + } + + private fun ChainRegistry.stakingChainsFlow(): Flow> { + return currentChains.map { chains -> + chains.filter { it.supportedStakingOptions() } + } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/WelcomePushNotificationsInteractor.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/WelcomePushNotificationsInteractor.kt new file mode 100644 index 0000000..6841a1a --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/interactor/WelcomePushNotificationsInteractor.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_push_notifications.domain.interactor + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsService + +interface WelcomePushNotificationsInteractor { + fun needToShowWelcomeScreen(): Boolean + + fun setWelcomeScreenShown() +} + +class RealWelcomePushNotificationsInteractor( + private val preferences: Preferences, + private val pushNotificationsService: PushNotificationsService +) : WelcomePushNotificationsInteractor { + + override fun needToShowWelcomeScreen(): Boolean { + return pushNotificationsService.isPushNotificationsAvailable() && + preferences.getBoolean(PREFS_WELCOME_SCREEN_SHOWN, true) + } + + override fun setWelcomeScreenShown() { + return preferences.putBoolean(PREFS_WELCOME_SCREEN_SHOWN, false) + } + + companion object { + private const val PREFS_WELCOME_SCREEN_SHOWN = "welcome_screen_shown" + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/model/PushSettings.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/model/PushSettings.kt new file mode 100644 index 0000000..07adbea --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/domain/model/PushSettings.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_push_notifications.domain.model + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +data class PushSettings( + val announcementsEnabled: Boolean, + val sentTokensEnabled: Boolean, + val receivedTokensEnabled: Boolean, + val subscribedMetaAccounts: Set, + val stakingReward: ChainFeature, + val governance: Map, + val multisigs: MultisigsState +) { + + data class GovernanceState( + val newReferendaEnabled: Boolean, + val referendumUpdateEnabled: Boolean, + val govMyDelegateVotedEnabled: Boolean, + val tracks: Set + ) + + data class MultisigsState( + val isEnabled: Boolean, // General notifications state. Other states may be enabled to save state when general one is disabled + val isInitiatingEnabled: Boolean, + val isApprovingEnabled: Boolean, + val isExecutionEnabled: Boolean, + val isRejectionEnabled: Boolean + ) { + companion object { + fun disabled() = MultisigsState( + isEnabled = false, + isInitiatingEnabled = false, + isApprovingEnabled = false, + isExecutionEnabled = false, + isRejectionEnabled = false + ) + + fun enabled() = MultisigsState( + isEnabled = true, + isInitiatingEnabled = true, + isApprovingEnabled = true, + isExecutionEnabled = true, + isRejectionEnabled = true + ) + } + } + + sealed class ChainFeature { + + object All : ChainFeature() + + data class Concrete(val chainIds: List) : ChainFeature() + } + + fun settingsIsEmpty(): Boolean { + return !announcementsEnabled && + !sentTokensEnabled && + !receivedTokensEnabled && + stakingReward.isEmpty() && + !isGovEnabled() + } +} + +fun PushSettings.ChainFeature.isEmpty(): Boolean { + return when (this) { + is PushSettings.ChainFeature.All -> false + is PushSettings.ChainFeature.Concrete -> chainIds.isEmpty() + } +} + +fun PushSettings.ChainFeature.isNotEmpty(): Boolean { + return !isEmpty() +} + +fun PushSettings.isGovEnabled(): Boolean { + return governance.values.any { + (it.newReferendaEnabled || it.referendumUpdateEnabled || it.govMyDelegateVotedEnabled) && it.tracks.isNotEmpty() + } +} + +fun PushSettings.MultisigsState.isAllTypesDisabled(): Boolean { + return !isInitiatingEnabled && !isApprovingEnabled && !isExecutionEnabled && !isRejectionEnabled +} + +fun PushSettings.MultisigsState.disableIfAllTypesDisabled(): PushSettings.MultisigsState { + return if (isAllTypesDisabled()) copy(isEnabled = false) else this +} + +fun PushSettings.MultisigsState.isInitiatingEnabledTotal() = isInitiatingEnabled && isEnabled +fun PushSettings.MultisigsState.isApprovingEnabledTotal() = isApprovingEnabled && isEnabled +fun PushSettings.MultisigsState.isExecutionEnabledTotal() = isExecutionEnabled && isEnabled +fun PushSettings.MultisigsState.isRejectionEnabledTotal() = isRejectionEnabled && isEnabled diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceModel.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceModel.kt new file mode 100644 index 0000000..baa45e2 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceModel.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.governance + +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +data class PushGovernanceModel( + val chainId: ChainId, + val governance: Chain.Governance, + val chainName: String, + val chainIconUrl: String?, + val isEnabled: Boolean, + val isNewReferendaEnabled: Boolean, + val isReferendaUpdatesEnabled: Boolean, + val trackIds: Set +) { + companion object +} + +fun PushGovernanceModel.Companion.default( + chain: Chain, + governance: Chain.Governance, + tracks: Set +): PushGovernanceModel { + return PushGovernanceModel( + chainId = chain.id, + governance = governance, + chainName = chain.name, + chainIconUrl = chain.icon, + false, + isNewReferendaEnabled = true, + isReferendaUpdatesEnabled = true, + trackIds = tracks + ) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceSettingsCommunicator.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceSettingsCommunicator.kt new file mode 100644 index 0000000..0a6b42d --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceSettingsCommunicator.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.governance + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger +import kotlinx.parcelize.Parcelize + +interface PushGovernanceSettingsRequester : InterScreenRequester { + + @Parcelize + class Request(val enabledGovernanceSettings: List) : Parcelable +} + +interface PushGovernanceSettingsResponder : InterScreenResponder { + + @Parcelize + class Response(val enabledGovernanceSettings: List) : Parcelable +} + +interface PushGovernanceSettingsCommunicator : PushGovernanceSettingsRequester, PushGovernanceSettingsResponder + +@Parcelize +class PushGovernanceSettingsPayload( + val chainId: ChainId, + val governance: Chain.Governance, + val newReferenda: Boolean, + val referendaUpdates: Boolean, + val delegateVotes: Boolean, + val tracksIds: Set +) : Parcelable diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceSettingsFragment.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceSettingsFragment.kt new file mode 100644 index 0000000..b318481 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceSettingsFragment.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.governance + +import android.os.Bundle +import androidx.core.view.isVisible + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.observe +import io.novafoundation.nova.feature_push_notifications.databinding.FragmentPushGovernanceSettingsBinding +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureComponent +import io.novafoundation.nova.feature_push_notifications.presentation.governance.adapter.PushGovernanceRVItem +import io.novafoundation.nova.feature_push_notifications.presentation.governance.adapter.PushGovernanceSettingsAdapter +import javax.inject.Inject + +class PushGovernanceSettingsFragment : + BaseFragment(), + PushGovernanceSettingsAdapter.ItemHandler { + + companion object { + private const val KEY_REQUEST = "KEY_REQUEST" + + fun getBundle(request: PushGovernanceSettingsRequester.Request): Bundle { + return Bundle().apply { + putParcelable(KEY_REQUEST, request) + } + } + } + + override fun createBinding() = FragmentPushGovernanceSettingsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + PushGovernanceSettingsAdapter(imageLoader, this) + } + + override fun initViews() { + binder.pushGovernanceToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.pushGovernanceToolbar.setRightActionClickListener { viewModel.clearClicked() } + onBackPressed { viewModel.backClicked() } + + binder.pushGovernanceList.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), PushNotificationsFeatureApi::class.java) + .pushGovernanceSettings() + .create(this, argument(KEY_REQUEST)) + .inject(this) + } + + override fun subscribe(viewModel: PushGovernanceSettingsViewModel) { + viewModel.clearButtonEnabledFlow.observe { + binder.pushGovernanceToolbar.setRightActionEnabled(it) + } + + viewModel.governanceSettingsList.observe { + binder.pushGovernanceList.isVisible = it is ExtendedLoadingState.Loaded + binder.pushGovernanceProgress.isVisible = it is ExtendedLoadingState.Loading + + if (it is ExtendedLoadingState.Loaded) { + adapter.submitList(it.data) + } + } + } + + override fun enableSwitcherClick(item: PushGovernanceRVItem) { + viewModel.enableSwitcherClicked(item) + } + + override fun newReferendaClick(item: PushGovernanceRVItem) { + viewModel.newReferendaClicked(item) + } + + override fun referendaUpdatesClick(item: PushGovernanceRVItem) { + viewModel.referendaUpdatesClicked(item) + } + + override fun tracksClicked(item: PushGovernanceRVItem) { + viewModel.tracksClicked(item) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceSettingsViewModel.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceSettingsViewModel.kt new file mode 100644 index 0000000..8a5f3b5 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/PushGovernanceSettingsViewModel.kt @@ -0,0 +1,208 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.governance + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.updateValue +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksRequester +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.TrackId +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.fromTrackIds +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.toTrackIds +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.interactor.ChainWithGovTracks +import io.novafoundation.nova.feature_push_notifications.domain.interactor.GovernancePushSettingsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.governance.adapter.PushGovernanceRVItem +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +private const val MIN_TRACKS = 1 + +data class GovChainKey(val chainId: ChainId, val governance: Chain.Governance) + +class PushGovernanceSettingsViewModel( + private val router: PushNotificationsRouter, + private val interactor: GovernancePushSettingsInteractor, + private val pushGovernanceSettingsResponder: PushGovernanceSettingsResponder, + private val chainRegistry: ChainRegistry, + private val request: PushGovernanceSettingsRequester.Request, + private val selectTracksRequester: SelectTracksRequester, + private val resourceManager: ResourceManager +) : BaseViewModel() { + + private val chainsWithTracks = interactor.governanceChainsFlow() + .shareInBackground() + + val _changedGovernanceSettingsList: MutableStateFlow> = MutableStateFlow(emptyMap()) + + val governanceSettingsList = combine(chainsWithTracks, _changedGovernanceSettingsList) { chainsWithTracksQuantity, changedSettings -> + chainsWithTracksQuantity.map { chainAndTracks -> + val pushSettingsModel = changedSettings[chainAndTracks.key()] + ?: PushGovernanceModel.default( + chainAndTracks.chain, + chainAndTracks.govVersion, + chainAndTracks.tracks + ) + + PushGovernanceRVItem( + pushSettingsModel, + formatTracksText(pushSettingsModel.trackIds, chainAndTracks.tracks) + ) + } + }.withSafeLoading() + + val clearButtonEnabledFlow = _changedGovernanceSettingsList.map { + it.any { it.value.isEnabled } + } + + init { + launch { + val chainsById = chainRegistry.chainsById() + + _changedGovernanceSettingsList.value = request.enabledGovernanceSettings + .mapNotNull { chainIdToSettings -> + val chain = chainsById[chainIdToSettings.chainId] ?: return@mapNotNull null + mapCommunicatorModelToItem(chainIdToSettings, chain) + }.associateBy { it.key() } + } + + subscribeOnSelectTracks() + } + + fun backClicked() { + launch { + val enabledGovernanceSettings = _changedGovernanceSettingsList.value + .values + .filter { it.isEnabled } + .map { mapItemToCommunicatorModel(it) } + + val response = PushGovernanceSettingsResponder.Response(enabledGovernanceSettings) + pushGovernanceSettingsResponder.respond(response) + + router.back() + } + } + + fun enableSwitcherClicked(item: PushGovernanceRVItem) { + _changedGovernanceSettingsList.updateValue { + it + item.model.copy(isEnabled = !item.isEnabled) + .enableEverythingIfFeaturesDisabled() + .withKey() + } + } + + fun newReferendaClicked(item: PushGovernanceRVItem) { + _changedGovernanceSettingsList.updateValue { + it + item.model.copy(isNewReferendaEnabled = !item.isNewReferendaEnabled) + .disableCompletelyIfFeaturesDisabled() + .withKey() + } + } + + fun referendaUpdatesClicked(item: PushGovernanceRVItem) { + _changedGovernanceSettingsList.updateValue { + it + item.model.copy(isReferendaUpdatesEnabled = !item.isReferendaUpdatesEnabled) + .disableCompletelyIfFeaturesDisabled() + .withKey() + } + } + + fun tracksClicked(item: PushGovernanceRVItem) { + launch { + val selectedTracks = item.model.trackIds.mapToSet { it.value } + selectTracksRequester.openRequest(SelectTracksRequester.Request(item.chainId, item.governance, selectedTracks, MIN_TRACKS)) + } + } + + fun clearClicked() { + _changedGovernanceSettingsList.value = emptyMap() + } + + private fun mapCommunicatorModelToItem( + item: PushGovernanceSettingsPayload, + chain: Chain + ): PushGovernanceModel { + val tracks = item.tracksIds.toTrackIds() + return PushGovernanceModel( + chainId = item.chainId, + governance = item.governance, + chainName = chain.name, + chainIconUrl = chain.icon, + isEnabled = true, + isNewReferendaEnabled = item.newReferenda, + isReferendaUpdatesEnabled = item.referendaUpdates, + trackIds = tracks + ) + } + + private fun mapItemToCommunicatorModel(item: PushGovernanceModel): PushGovernanceSettingsPayload { + return PushGovernanceSettingsPayload( + item.chainId, + item.governance, + item.isNewReferendaEnabled, + item.isReferendaUpdatesEnabled, + false, // Not supported yet + item.trackIds.fromTrackIds() + ) + } + + private fun PushGovernanceModel.disableCompletelyIfFeaturesDisabled(): PushGovernanceModel { + if (!isNewReferendaEnabled && !isReferendaUpdatesEnabled) { + return copy(isEnabled = false) + } + + return this + } + + private fun PushGovernanceModel.enableEverythingIfFeaturesDisabled(): PushGovernanceModel { + if (!isNewReferendaEnabled && !isReferendaUpdatesEnabled) { + return copy(isEnabled = true, isNewReferendaEnabled = true, isReferendaUpdatesEnabled = true) + } + + return this + } + + private fun subscribeOnSelectTracks() { + selectTracksRequester.responseFlow + .onEach { response -> + val chain = chainRegistry.getChain(response.chainId) + val key = GovChainKey(response.chainId, response.governanceType) + val selectedTracks = response.selectedTracks.toTrackIds() + + _changedGovernanceSettingsList.updateValue { governanceSettings -> + val model = governanceSettings[key]?.copy(trackIds = selectedTracks) + ?: PushGovernanceModel.default( + chain = chain, + governance = response.governanceType, + tracks = selectedTracks + ) + + governanceSettings.plus(key to model) + } + } + .launchIn(this) + } + + private fun PushGovernanceModel.key() = GovChainKey(chainId, governance) + + private fun PushGovernanceModel.withKey() = key() to this + + private fun ChainWithGovTracks.key() = GovChainKey(chain.id, govVersion) + + private fun formatTracksText(selectedTracks: Set, allTracks: Set): String { + return if (selectedTracks.size == allTracks.size) { + resourceManager.getString(R.string.common_all) + } else { + resourceManager.getString(R.string.selected_tracks_quantity, selectedTracks.size, allTracks.size) + } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/adapter/PushGovernanceRVItem.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/adapter/PushGovernanceRVItem.kt new file mode 100644 index 0000000..3ee21a9 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/adapter/PushGovernanceRVItem.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.governance.adapter + +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceModel + +data class PushGovernanceRVItem( + val model: PushGovernanceModel, + val tracksText: String +) { + val chainId = model.chainId + + val governance = model.governance + + val chainName = model.chainName + + val chainIconUrl = model.chainIconUrl + + val isEnabled = model.isEnabled + + val isNewReferendaEnabled = model.isNewReferendaEnabled + + val isReferendaUpdatesEnabled = model.isReferendaUpdatesEnabled +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/adapter/PushGovernanceSettingsAdapter.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/adapter/PushGovernanceSettingsAdapter.kt new file mode 100644 index 0000000..e48d6af --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/adapter/PushGovernanceSettingsAdapter.kt @@ -0,0 +1,128 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.governance.adapter + +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import coil.ImageLoader +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIconToTarget +import io.novafoundation.nova.feature_push_notifications.databinding.ItemPushGovernanceSettingsBinding + +class PushGovernanceSettingsAdapter( + private val imageLoader: ImageLoader, + private val itemHandler: ItemHandler +) : ListAdapter(PushGovernanceItemCallback()) { + + interface ItemHandler { + fun enableSwitcherClick(item: PushGovernanceRVItem) + + fun newReferendaClick(item: PushGovernanceRVItem) + + fun referendaUpdatesClick(item: PushGovernanceRVItem) + + fun tracksClicked(item: PushGovernanceRVItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PushGovernanceItemViewHolder { + return PushGovernanceItemViewHolder(ItemPushGovernanceSettingsBinding.inflate(parent.inflater(), parent, false), imageLoader, itemHandler) + } + + override fun onBindViewHolder(holder: PushGovernanceItemViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: PushGovernanceItemViewHolder, position: Int, payloads: MutableList) { + resolvePayload(holder, position, payloads) { + val item = getItem(position) + holder.updateListenners(item) + + when (it) { + PushGovernanceRVItem::isEnabled -> holder.setEnabled(item) + PushGovernanceRVItem::isNewReferendaEnabled -> holder.setNewReferendaEnabled(item) + PushGovernanceRVItem::isReferendaUpdatesEnabled -> holder.setReferendaUpdatesEnabled(item) + PushGovernanceRVItem::tracksText -> holder.setTracks(item) + } + } + } +} + +class PushGovernanceItemCallback() : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: PushGovernanceRVItem, newItem: PushGovernanceRVItem): Boolean { + return oldItem.chainId == newItem.chainId + } + + override fun areContentsTheSame(oldItem: PushGovernanceRVItem, newItem: PushGovernanceRVItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: PushGovernanceRVItem, newItem: PushGovernanceRVItem): Any? { + return PushGovernancePayloadGenerator.diff(oldItem, newItem) + } +} + +class PushGovernanceItemViewHolder( + private val binder: ItemPushGovernanceSettingsBinding, + private val imageLoader: ImageLoader, + private val itemHandler: PushGovernanceSettingsAdapter.ItemHandler +) : ViewHolder(binder.root) { + + init { + binder.pushGovernanceItemState.setIconTintColor(null) + } + + fun bind(item: PushGovernanceRVItem) { + with(itemView) { + updateListenners(item) + + binder.pushGovernanceItemState.setTitle(item.chainName) + imageLoader.loadChainIconToTarget(item.chainIconUrl, context) { + binder.pushGovernanceItemState.setIcon(it) + } + + setEnabled(item) + setNewReferendaEnabled(item) + setReferendaUpdatesEnabled(item) + setTracks(item) + } + } + + fun setTracks(item: PushGovernanceRVItem) { + binder.pushGovernanceItemTracks.setValue(item.tracksText) + } + + fun setEnabled(item: PushGovernanceRVItem) { + with(binder) { + pushGovernanceItemState.setChecked(item.isEnabled) + pushGovernanceItemNewReferenda.isVisible = item.isEnabled + pushGovernanceItemReferendumUpdate.isVisible = item.isEnabled + // pushGovernanceItemDelegateVotes.isVisible = item.isEnabled // currently disabled + pushGovernanceItemTracks.isVisible = item.isEnabled + } + } + + fun setNewReferendaEnabled(item: PushGovernanceRVItem) { + binder.pushGovernanceItemNewReferenda.setChecked(item.isNewReferendaEnabled) + } + + fun setReferendaUpdatesEnabled(item: PushGovernanceRVItem) { + binder.pushGovernanceItemReferendumUpdate.setChecked(item.isReferendaUpdatesEnabled) + } + + fun updateListenners(item: PushGovernanceRVItem) { + binder.pushGovernanceItemState.setOnClickListener { itemHandler.enableSwitcherClick(item) } + binder.pushGovernanceItemNewReferenda.setOnClickListener { itemHandler.newReferendaClick(item) } + binder.pushGovernanceItemReferendumUpdate.setOnClickListener { itemHandler.referendaUpdatesClick(item) } + binder.pushGovernanceItemTracks.setOnClickListener { itemHandler.tracksClicked(item) } + } +} + +private object PushGovernancePayloadGenerator : PayloadGenerator( + PushGovernanceRVItem::isEnabled, + PushGovernanceRVItem::isNewReferendaEnabled, + PushGovernanceRVItem::isReferendaUpdatesEnabled, + PushGovernanceRVItem::tracksText, +) diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/di/PushGovernanceSettingsComponent.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/di/PushGovernanceSettingsComponent.kt new file mode 100644 index 0000000..e2f2e0b --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/di/PushGovernanceSettingsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.governance.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsFragment +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsRequester + +@Subcomponent( + modules = [ + PushGovernanceSettingsModule::class + ] +) +@ScreenScope +interface PushGovernanceSettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance request: PushGovernanceSettingsRequester.Request + ): PushGovernanceSettingsComponent + } + + fun inject(fragment: PushGovernanceSettingsFragment) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/di/PushGovernanceSettingsModule.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/di/PushGovernanceSettingsModule.kt new file mode 100644 index 0000000..b44848a --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/governance/di/PushGovernanceSettingsModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.governance.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectTracksCommunicator +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.interactor.GovernancePushSettingsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsRequester +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class PushGovernanceSettingsModule { + + @Provides + @IntoMap + @ViewModelKey(PushGovernanceSettingsViewModel::class) + fun provideViewModel( + router: PushNotificationsRouter, + interactor: GovernancePushSettingsInteractor, + pushGovernanceSettingsCommunicator: PushGovernanceSettingsCommunicator, + chainRegistry: ChainRegistry, + request: PushGovernanceSettingsRequester.Request, + selectTracksCommunicator: SelectTracksCommunicator, + resourceManager: ResourceManager + ): ViewModel { + return PushGovernanceSettingsViewModel( + router = router, + interactor = interactor, + pushGovernanceSettingsResponder = pushGovernanceSettingsCommunicator, + chainRegistry = chainRegistry, + request = request, + selectTracksRequester = selectTracksCommunicator, + resourceManager = resourceManager + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PushGovernanceSettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PushGovernanceSettingsViewModel::class.java) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/BaseNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/BaseNotificationHandler.kt new file mode 100644 index 0000000..f58012a --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/BaseNotificationHandler.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.fromJson + +abstract class BaseNotificationHandler( + private val activityIntentProvider: ActivityIntentProvider, + private val notificationIdProvider: NotificationIdProvider, + private val gson: Gson, + private val notificationManager: NotificationManagerCompat, + val resourceManager: ResourceManager, + private val channel: NovaNotificationChannel, + private val importance: Int = NotificationManager.IMPORTANCE_DEFAULT, +) : NotificationHandler { + + final override suspend fun handleNotification(message: RemoteMessage): Boolean { + val channelId = resourceManager.getString(channel.idRes) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + resourceManager.getString(channel.nameRes), + importance + ) + notificationManager.createNotificationChannel(channel) + } + + return runCatching { handleNotificationInternal(channelId, message) } + .onFailure { it.printStackTrace() } + .getOrNull() ?: false + } + + internal fun notify(notification: Notification) { + notificationManager.notify(notificationIdProvider.getId(), notification) + } + + internal fun notify(id: Int, notification: Notification) { + notificationManager.notify(id, notification) + } + + internal fun activityIntent() = activityIntentProvider.getIntent() + + protected abstract suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean + + internal fun RemoteMessage.getMessageContent(): NotificationData { + val payload: Map = data["payload"]?.let { payload -> gson.fromJson(payload) } ?: emptyMap() + + return NotificationData( + type = data.getValue("type"), + chainId = data["chainId"], + payload = payload + ) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/CompoundNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/CompoundNotificationHandler.kt new file mode 100644 index 0000000..4a3b033 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/CompoundNotificationHandler.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling + +import com.google.firebase.messaging.RemoteMessage + +class CompoundNotificationHandler( + val handlers: Set +) : NotificationHandler { + + override suspend fun handleNotification(message: RemoteMessage): Boolean { + for (handler in handlers) { + if (handler.handleNotification(message)) { + return true + } + } + + return false + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/MessageContent.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/MessageContent.kt new file mode 100644 index 0000000..d76a8c6 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/MessageContent.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling + +class NotificationData( + val type: String, + val chainId: String?, + val payload: Map +) diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NotificationHandler.kt new file mode 100644 index 0000000..eccc627 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NotificationHandler.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling + +import com.google.firebase.messaging.RemoteMessage + +interface NotificationHandler { + + /** + * @return true if the notification was handled, false otherwise + */ + suspend fun handleNotification(message: RemoteMessage): Boolean +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NotificationHandlerExt.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NotificationHandlerExt.kt new file mode 100644 index 0000000..d1d97ea --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NotificationHandlerExt.kt @@ -0,0 +1,136 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.app.NotificationCompat +import io.novafoundation.nova.common.utils.asGsonParsedNumber +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatusType +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.runtime.ext.chainIdHexPrefix16 +import io.novafoundation.nova.runtime.ext.onChainAssetId +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import java.math.BigInteger + +private const val PEDDING_INTENT_REQUEST_CODE = 1 + +interface PushChainRegestryHolder { + + val chainRegistry: ChainRegistry + + suspend fun NotificationData.getChain(): Chain { + val chainId = chainId ?: throw NullPointerException("Chain id is null") + return chainRegistry.chainsById() + .mapKeys { it.key.chainIdHexPrefix16() } + .getValue(chainId) + } +} + +internal fun NotificationData.requireType(type: String) { + require(this.type == type) +} + +/** + * Example: {a_field: {b_field: {c_field: "value"}}} + * To take a value from c_field use getPayloadFieldContent("a_field", "b_field", "c_field") + */ +internal inline fun NotificationData.extractPayloadFieldsWithPath(vararg fields: String): T { + val fieldsBeforeLast = fields.dropLast(1) + val last = fields.last() + + val lastSearchingValue = fieldsBeforeLast.fold(payload) { acc, field -> + acc[field] as? Map ?: throw NullPointerException("Notification parameter $field is null") + } + + val result = lastSearchingValue[last] ?: return null as T + + return result as? T ?: throw NullPointerException("Notification parameter $last is null") +} + +internal fun NotificationData.extractBigInteger(vararg fields: String): BigInteger { + return extractPayloadFieldsWithPath(*fields) + .asGsonParsedNumber() +} + +internal fun MetaAccount.formattedAccountName(): String { + return "[$name]" +} + +fun Context.makePendingIntent(intent: Intent): PendingIntent { + return PendingIntent.getActivity( + this, + PEDDING_INTENT_REQUEST_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) +} + +fun NotificationCompat.Builder.buildWithDefaults( + context: Context, + title: CharSequence, + message: CharSequence, + contentIntent: Intent +): NotificationCompat.Builder { + return setContentTitle(title) + .setContentText(message) + .setSmallIcon(R.drawable.ic_pezkuwi) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(message) + ) + .setContentIntent(context.makePendingIntent(contentIntent)) +} + +fun makeNewReleasesIntent( + storeLink: String +): Intent { + return Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(storeLink) } +} + +fun ReferendumStatusType.Companion.fromRemoteNotificationType(type: String): ReferendumStatusType { + return when (type) { + "Created" -> ReferendumStatusType.PREPARING + "Deciding" -> ReferendumStatusType.DECIDING + "Confirming" -> ReferendumStatusType.CONFIRMING + "Approved" -> ReferendumStatusType.APPROVED + "Rejected" -> ReferendumStatusType.REJECTED + "TimedOut" -> ReferendumStatusType.TIMED_OUT + "Cancelled" -> ReferendumStatusType.CANCELLED + "Killed" -> ReferendumStatusType.KILLED + else -> throw IllegalArgumentException("Unknown referendum status type: $this") + } +} + +fun Chain.assetByOnChainAssetIdOrUtility(assetId: String?): Chain.Asset? { + if (assetId == null) return utilityAsset + + return assets.firstOrNull { it.onChainAssetId == assetId } +} + +fun notificationAmountFormat(asset: Chain.Asset, token: Token?, amount: BigInteger): String { + val tokenAmount = amount.formatPlanks(asset) + val fiatAmount = token?.planksToFiat(amount) + ?.formatAsCurrency(token.currency) + + return if (fiatAmount != null) { + "$tokenAmount ($fiatAmount)" + } else { + tokenAmount + } +} + +suspend fun AccountRepository.isNotSingleMetaAccount(): Boolean { + return getActiveMetaAccountsQuantity() > 1 +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NotificationIdProvider.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NotificationIdProvider.kt new file mode 100644 index 0000000..d92c5be --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NotificationIdProvider.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling + +import io.novafoundation.nova.common.data.storage.Preferences + +interface NotificationIdProvider { + fun getId(): Int +} + +class RealNotificationIdProvider( + private val preferences: Preferences +) : NotificationIdProvider { + + override fun getId(): Int { + val id = preferences.getInt(KEY, START_ID) + preferences.putInt(KEY, id + 1) + return id + } + + companion object { + private const val KEY = "notification_id" + private const val START_ID = 0 + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NovaNotificationChannel.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NovaNotificationChannel.kt new file mode 100644 index 0000000..044d96e --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/NovaNotificationChannel.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling + +import androidx.annotation.StringRes +import io.novafoundation.nova.feature_push_notifications.R + +enum class NovaNotificationChannel( + @StringRes val idRes: Int, + @StringRes val nameRes: Int +) { + + DEFAULT(R.string.default_notification_channel_id, R.string.default_notification_channel_name), + + GOVERNANCE(R.string.governance_notification_channel_id, R.string.governance_notification_channel_name), + + TRANSACTIONS(R.string.transactions_notification_channel_id, R.string.transactions_notification_channel_name), + + STAKING(R.string.staking_notification_channel_id, R.string.staking_notification_channel_name), + + MULTISIG(R.string.multisigs_notification_channel_id, R.string.multisigs_notification_channel_name), +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/DebugNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/DebugNotificationHandler.kt new file mode 100644 index 0000000..35d0e1f --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/DebugNotificationHandler.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_push_notifications.BuildConfig +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults + +private const val DEBUG_NOTIFICATION_ID = -1 + +/** + * A [NotificationHandler] that is used as a fallback if previous handlers didn't handle the notification + */ +class DebugNotificationHandler( + private val context: Context, + private val activityIntentProvider: ActivityIntentProvider, + private val notificationManager: NotificationManagerCompat, + private val resourceManager: ResourceManager +) : NotificationHandler { + + override suspend fun handleNotification(message: RemoteMessage): Boolean { + if (!BuildConfig.DEBUG) return false + + val channelId = resourceManager.getString(R.string.default_notification_channel_id) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + resourceManager.getString(R.string.default_notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + notificationManager.createNotificationChannel(channel) + } + + val notification = NotificationCompat.Builder(context, channelId) + .buildWithDefaults( + context, + "Notification handling error!", + "The notification was not handled\n${message.data}", + activityIntentProvider.getIntent() + ).build() + + notify(notification) + + return true + } + + private fun notify(notification: Notification) { + notificationManager.notify(DEBUG_NOTIFICATION_ID, notification) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/NewReferendumNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/NewReferendumNotificationHandler.kt new file mode 100644 index 0000000..dbc2de9 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/NewReferendumNotificationHandler.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.applyDeepLink +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDeepLinkData +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.BaseNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NovaNotificationChannel +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractBigInteger +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class NewReferendumNotificationHandler( + private val context: Context, + private val configurator: ReferendumDetailsDeepLinkConfigurator, + override val chainRegistry: ChainRegistry, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : BaseNotificationHandler( + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager, + channel = NovaNotificationChannel.GOVERNANCE +), + PushChainRegestryHolder { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.GOV_NEW_REF) + + val chain = content.getChain() + require(chain.isEnabled) + + val referendumId = content.extractBigInteger("referendumId") + + val notification = NotificationCompat.Builder(context, channelId) + .buildWithDefaults( + context, + resourceManager.getString(R.string.push_new_referendum_title), + resourceManager.getString(R.string.push_new_referendum_message, chain.name, referendumId.format()), + activityIntent().applyDeepLink( + configurator, + ReferendumDeepLinkData(chain.id, referendumId, Chain.Governance.V2) + ) + ).build() + + notify(notification) + + return true + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/NewReleaseNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/NewReleaseNotificationHandler.kt new file mode 100644 index 0000000..8321c26 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/NewReleaseNotificationHandler.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.BaseNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NovaNotificationChannel +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractPayloadFieldsWithPath +import io.novafoundation.nova.feature_push_notifications.presentation.handling.makeNewReleasesIntent +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType + +class NewReleaseNotificationHandler( + private val context: Context, + private val appLinksProvider: AppLinksProvider, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : BaseNotificationHandler( + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager, + channel = NovaNotificationChannel.DEFAULT +) { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.APP_NEW_RELEASE) + val version = content.extractPayloadFieldsWithPath("version") + + val notification = NotificationCompat.Builder(context, channelId) + .buildWithDefaults( + context, + resourceManager.getString(R.string.push_new_update_title), + resourceManager.getString(R.string.push_new_update_message, version), + makeNewReleasesIntent(appLinksProvider.storeUrl) + ) + .build() + + notify(notification) + + return true + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/ReferendumStateUpdateNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/ReferendumStateUpdateNotificationHandler.kt new file mode 100644 index 0000000..84594e4 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/ReferendumStateUpdateNotificationHandler.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.applyDeepLink +import io.novafoundation.nova.feature_governance_api.domain.referendum.list.ReferendumStatusType +import io.novafoundation.nova.feature_governance_api.presentation.referenda.common.ReferendaStatusFormatter +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDeepLinkData +import io.novafoundation.nova.feature_governance_api.presentation.referenda.details.deeplink.configurators.ReferendumDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.BaseNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NovaNotificationChannel +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractBigInteger +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractPayloadFieldsWithPath +import io.novafoundation.nova.feature_push_notifications.presentation.handling.fromRemoteNotificationType +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +class ReferendumStateUpdateNotificationHandler( + private val context: Context, + private val configurator: ReferendumDetailsDeepLinkConfigurator, + private val referendaStatusFormatter: ReferendaStatusFormatter, + override val chainRegistry: ChainRegistry, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : BaseNotificationHandler( + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager, + channel = NovaNotificationChannel.GOVERNANCE +), + PushChainRegestryHolder { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.GOV_STATE) + + val chain = content.getChain() + require(chain.isEnabled) + + val referendumId = content.extractBigInteger("referendumId") + val stateFrom = content.extractPayloadFieldsWithPath("from")?.let { ReferendumStatusType.fromRemoteNotificationType(it) } + val stateTo = content.extractPayloadFieldsWithPath("to").let { ReferendumStatusType.fromRemoteNotificationType(it) } + + val notification = NotificationCompat.Builder(context, channelId) + .buildWithDefaults( + context, + getTitle(stateTo), + getMessage(chain, referendumId, stateFrom, stateTo), + activityIntent().applyDeepLink( + configurator, + ReferendumDeepLinkData(chain.id, referendumId, Chain.Governance.V2) + ) + ).build() + + notify(notification) + + return true + } + + private fun getTitle(refStateTo: ReferendumStatusType): String { + return when (refStateTo) { + ReferendumStatusType.APPROVED -> resourceManager.getString(R.string.push_referendum_approved_title) + ReferendumStatusType.REJECTED -> resourceManager.getString(R.string.push_referendum_rejected_title) + else -> resourceManager.getString(R.string.push_referendum_status_changed_title) + } + } + + private fun getMessage(chain: Chain, referendumId: BigInteger, stateFrom: ReferendumStatusType?, stateTo: ReferendumStatusType): String { + return when { + stateTo == ReferendumStatusType.APPROVED -> resourceManager.getString(R.string.push_referendum_approved_message, chain.name, referendumId.format()) + stateTo == ReferendumStatusType.REJECTED -> resourceManager.getString(R.string.push_referendum_rejected_message, chain.name, referendumId.format()) + stateFrom == null -> resourceManager.getString( + R.string.push_referendum_to_status_changed_message, + chain.name, + referendumId.format(), + referendaStatusFormatter.formatStatus(stateTo) + ) + + else -> resourceManager.getString( + R.string.push_referendum_from_to_status_changed_message, + chain.name, + referendumId.format(), + referendaStatusFormatter.formatStatus(stateFrom), + referendaStatusFormatter.formatStatus(stateTo) + ) + } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/StakingRewardNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/StakingRewardNotificationHandler.kt new file mode 100644 index 0000000..f2b8826 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/StakingRewardNotificationHandler.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkData +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.applyDeepLink +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.BaseNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NovaNotificationChannel +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractBigInteger +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractPayloadFieldsWithPath +import io.novafoundation.nova.feature_push_notifications.presentation.handling.notificationAmountFormat +import io.novafoundation.nova.feature_push_notifications.presentation.handling.formattedAccountName +import io.novafoundation.nova.feature_push_notifications.presentation.handling.isNotSingleMetaAccount +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +class StakingRewardNotificationHandler( + private val context: Context, + private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, + private val configurator: io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator, + override val chainRegistry: ChainRegistry, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : BaseNotificationHandler( + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager, + channel = NovaNotificationChannel.STAKING +), + PushChainRegestryHolder { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.STAKING_REWARD) + + val chain = content.getChain() + require(chain.isEnabled) + + val recipient = content.extractPayloadFieldsWithPath("recipient") + val amount = content.extractBigInteger("amount") + + val metaAccount = accountRepository.findMetaAccount(chain.accountIdOf(recipient), chain.id) ?: return false + + val notification = NotificationCompat.Builder(context, channelId) + .buildWithDefaults( + context, + getTitle(metaAccount), + getMessage(chain, amount), + activityIntent().applyDeepLink( + configurator, + AssetDetailsDeepLinkData(recipient, chain.id, chain.utilityAsset.id) + ) + ).build() + + notify(notification) + + return true + } + + private suspend fun getTitle(metaAccount: MetaAccount): String { + return when { + accountRepository.isNotSingleMetaAccount() -> resourceManager.getString( + R.string.push_staking_reward_many_accounts_title, + metaAccount.formattedAccountName() + ) + + else -> resourceManager.getString(R.string.push_staking_reward_single_account_title) + } + } + + private suspend fun getMessage( + chain: Chain, + amount: BigInteger + ): String { + val asset = chain.utilityAsset + val token = tokenRepository.getTokenOrNull(asset) + val formattedAmount = notificationAmountFormat(asset, token, amount) + + return resourceManager.getString(R.string.push_staking_reward_message, formattedAmount, chain.name) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/SystemNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/SystemNotificationHandler.kt new file mode 100644 index 0000000..e22b438 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/SystemNotificationHandler.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_push_notifications.presentation.handling.BaseNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NovaNotificationChannel +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults + +class SystemNotificationHandler( + private val context: Context, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : BaseNotificationHandler( + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager, + channel = NovaNotificationChannel.DEFAULT +) { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val notificationPart = message.notification ?: return false + + val title = notificationPart.title ?: return false + val body = notificationPart.body ?: return false + + val notification = NotificationCompat.Builder(context, channelId) + .buildWithDefaults(context, title, body, activityIntent()) + .build() + + notify(notification) + + return true + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/TokenReceivedNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/TokenReceivedNotificationHandler.kt new file mode 100644 index 0000000..363aefb --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/TokenReceivedNotificationHandler.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkData +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.applyDeepLink +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.BaseNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NovaNotificationChannel +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.feature_push_notifications.presentation.handling.assetByOnChainAssetIdOrUtility +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractBigInteger +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractPayloadFieldsWithPath +import io.novafoundation.nova.feature_push_notifications.presentation.handling.formattedAccountName +import io.novafoundation.nova.feature_push_notifications.presentation.handling.isNotSingleMetaAccount +import io.novafoundation.nova.feature_push_notifications.presentation.handling.notificationAmountFormat +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +class TokenReceivedNotificationHandler( + private val context: Context, + private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, + private val configurator: io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator, + override val chainRegistry: ChainRegistry, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : BaseNotificationHandler( + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager, + channel = NovaNotificationChannel.TRANSACTIONS +), + PushChainRegestryHolder { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.TOKENS_RECEIVED) + + val chain = content.getChain() + require(chain.isEnabled) + + val recipient = content.extractPayloadFieldsWithPath("recipient") + val assetId = content.extractPayloadFieldsWithPath("assetId") + val amount = content.extractBigInteger("amount") + + val asset = chain.assetByOnChainAssetIdOrUtility(assetId) ?: return false + val recipientMetaAccount = accountRepository.findMetaAccount(chain.accountIdOf(recipient), chain.id) ?: return false + + val notification = NotificationCompat.Builder(context, channelId) + .buildWithDefaults( + context, + getTitle(recipientMetaAccount), + getMessage(chain, asset, amount), + activityIntent().applyDeepLink( + configurator, + AssetDetailsDeepLinkData(recipient, chain.id, asset.id) + ) + ).build() + + notify(notification) + + return true + } + + private suspend fun getTitle(senderMetaAccount: MetaAccount?): String { + val accountName = senderMetaAccount?.formattedAccountName() + return when { + accountRepository.isNotSingleMetaAccount() && accountName != null -> resourceManager.getString(R.string.push_token_received_title, accountName) + else -> resourceManager.getString(R.string.push_token_received_no_account_name_title) + } + } + + private suspend fun getMessage( + chain: Chain, + asset: Chain.Asset, + amount: BigInteger + ): String { + val token = tokenRepository.getTokenOrNull(asset) + val formattedAmount = notificationAmountFormat(asset, token, amount) + + return resourceManager.getString(R.string.push_token_received_message, formattedAmount, chain.name) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/TokenSentNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/TokenSentNotificationHandler.kt new file mode 100644 index 0000000..8cd617d --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/TokenSentNotificationHandler.kt @@ -0,0 +1,111 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkConfigurator +import io.novafoundation.nova.feature_assets.presentation.balance.detail.deeplink.AssetDetailsDeepLinkData +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.applyDeepLink +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.BaseNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NovaNotificationChannel +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.feature_push_notifications.presentation.handling.assetByOnChainAssetIdOrUtility +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractBigInteger +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractPayloadFieldsWithPath +import io.novafoundation.nova.feature_push_notifications.presentation.handling.formattedAccountName +import io.novafoundation.nova.feature_push_notifications.presentation.handling.isNotSingleMetaAccount +import io.novafoundation.nova.feature_push_notifications.presentation.handling.notificationAmountFormat +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +class TokenSentNotificationHandler( + private val context: Context, + private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, + override val chainRegistry: ChainRegistry, + private val configurator: AssetDetailsDeepLinkConfigurator, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : BaseNotificationHandler( + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager, + channel = NovaNotificationChannel.TRANSACTIONS +), + PushChainRegestryHolder { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.TOKENS_SENT) + + val chain = content.getChain() + require(chain.isEnabled) + + val sender = content.extractPayloadFieldsWithPath("sender") + val recipient = content.extractPayloadFieldsWithPath("recipient") + val assetId = content.extractPayloadFieldsWithPath("assetId") + val amount = content.extractBigInteger("amount") + + val asset = chain.assetByOnChainAssetIdOrUtility(assetId) ?: return false + val senderMetaAccount = accountRepository.findMetaAccount(chain.accountIdOf(sender), chain.id) ?: return false + val recipientMetaAccount = accountRepository.findMetaAccount(chain.accountIdOf(recipient), chain.id) + + val notification = NotificationCompat.Builder(context, channelId) + .buildWithDefaults( + context, + getTitle(senderMetaAccount), + getMessage(chain, recipientMetaAccount, recipient, asset, amount), + activityIntent().applyDeepLink( + configurator, + AssetDetailsDeepLinkData(sender, chain.id, asset.id) + ) + ).build() + + notify(notification) + + return true + } + + private suspend fun getTitle(senderMetaAccount: MetaAccount?): String { + val accountName = senderMetaAccount?.formattedAccountName() + return when { + accountRepository.isNotSingleMetaAccount() && accountName != null -> resourceManager.getString(R.string.push_token_sent_title, accountName) + else -> resourceManager.getString(R.string.push_token_sent_no_account_name_title) + } + } + + private suspend fun getMessage( + chain: Chain, + recipientMetaAccount: MetaAccount?, + recipientAddress: String, + asset: Chain.Asset, + amount: BigInteger + ): String { + val token = tokenRepository.getTokenOrNull(asset) + val formattedAmount = notificationAmountFormat(asset, token, amount) + + val accountNameOrAddress = recipientMetaAccount?.formattedAccountName() ?: recipientAddress + + return resourceManager.getString(R.string.push_token_sent_message, formattedAmount, accountNameOrAddress, chain.name) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigBaseNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigBaseNotificationHandler.kt new file mode 100644 index 0000000..9449964 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigBaseNotificationHandler.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig + +import androidx.core.app.NotificationManagerCompat +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.presentation.handling.BaseNotificationHandler +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NovaNotificationChannel +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +abstract class MultisigBaseNotificationHandler( + private val multisigCallFormatter: MultisigCallFormatter, + override val chainRegistry: ChainRegistry, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager +) : BaseNotificationHandler( + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager, + channel = NovaNotificationChannel.MULTISIG +), + PushChainRegestryHolder { + + fun getSubText(metaAccount: MetaAccount): String { + return resourceManager.getString(R.string.multisig_notification_message_header, metaAccount.name) + } + + suspend fun getMessage( + chain: Chain, + payload: MultisigNotificationPayload, + footer: String? + ): String { + val runtime = chainRegistry.getRuntime(chain.id) + val call = payload.callData?.let { GenericCall.fromHex(runtime, payload.callData) } + + return buildString { + val formattedCall = multisigCallFormatter.formatPushNotificationMessage(call, payload.signatory.accountId, chain) + append(formattedCall.formattedCall) + formattedCall.onBehalfOf?.let { appendLine().append(formatOnBehalfOf(it)) } + footer?.let { appendLine().append(it) } + } + } + + private fun formatOnBehalfOf(addressMode: AddressModel): String { + return resourceManager.getString(R.string.multisig_notification_on_behalf_of, addressMode.nameOrAddress) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionCancelledNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionCancelledNotificationHandler.kt new file mode 100644 index 0000000..a36756b --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionCancelledNotificationHandler.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.model.createOperationHash +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.getNameOrAddress +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.applyDeepLink +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkData +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class MultisigTransactionCancelledNotificationHandler( + private val context: Context, + private val accountRepository: AccountRepository, + private val multisigCallFormatter: MultisigCallFormatter, + private val configurator: MultisigOperationDeepLinkConfigurator, + @LocalIdentity private val identityProvider: IdentityProvider, + override val chainRegistry: ChainRegistry, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : MultisigBaseNotificationHandler( + multisigCallFormatter, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager +), + PushChainRegestryHolder { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.MULTISIG_CANCELLED) + + val chain = content.getChain() + require(chain.isEnabled) + + val payload = content.extractMultisigPayload(signatoryRole = "canceller", chain) + + val multisigAccount = accountRepository.getMultisigForPayload(chain, payload) ?: return true + + val rejecterIdentity = identityProvider.getNameOrAddress(payload.signatory.accountId, chain) + val messageText = getMessage( + chain, + payload, + footer = resourceManager.getString(R.string.multisig_notification_rejected_transaction_message, rejecterIdentity) + ) + + val operationHash = PendingMultisigOperation.createOperationHash(multisigAccount, chain, payload.callHashString) + + val notification = NotificationCompat.Builder(context, channelId) + .setSubText(getSubText(multisigAccount)) + .setGroup(operationHash) + .buildWithDefaults( + context, + resourceManager.getString(R.string.multisig_notification_rejected_transaction_title), + messageText, + activityIntent().applyDeepLink( + configurator, + multisigOperationDeepLinkData(multisigAccount, chain, payload, MultisigOperationDeepLinkData.State.Rejected(rejecterIdentity)) + ) + ).build() + + notify(notification) + + notifyMultisigGroupNotificationWithId( + context = context, + groupId = operationHash, + channelId = channelId, + metaAccount = multisigAccount + ) + + return true + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionExecutedNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionExecutedNotificationHandler.kt new file mode 100644 index 0000000..e2c350b --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionExecutedNotificationHandler.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.model.createOperationHash +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.getNameOrAddress +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.applyDeepLink +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkData +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class MultisigTransactionExecutedNotificationHandler( + private val context: Context, + private val accountRepository: AccountRepository, + private val multisigCallFormatter: MultisigCallFormatter, + private val configurator: MultisigOperationDeepLinkConfigurator, + @LocalIdentity private val identityProvider: IdentityProvider, + override val chainRegistry: ChainRegistry, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : MultisigBaseNotificationHandler( + multisigCallFormatter, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager +), + PushChainRegestryHolder { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.MULTISIG_EXECUTED) + + val chain = content.getChain() + require(chain.isEnabled) + + val payload = content.extractMultisigPayload(signatoryRole = "approver", chain) + + val multisigAccount = accountRepository.getMultisigForPayload(chain, payload) ?: return true + + val approverIdentity = identityProvider.getNameOrAddress(payload.signatory.accountId, chain) + val messageText = getMessage( + chain, + payload, + footer = resourceManager.getString(R.string.multisig_notification_executed_transaction_message, approverIdentity) + ) + + val operationHash = PendingMultisigOperation.createOperationHash(multisigAccount, chain, payload.callHashString) + + val notification = NotificationCompat.Builder(context, channelId) + .setSubText(getSubText(multisigAccount)) + .setGroup(operationHash) + .buildWithDefaults( + context, + resourceManager.getString(R.string.multisig_notification_executed_transaction_title), + messageText, + activityIntent().applyDeepLink( + configurator, + multisigOperationDeepLinkData(multisigAccount, chain, payload, MultisigOperationDeepLinkData.State.Executed(approverIdentity)) + ) + ).build() + + notify(notification) + + notifyMultisigGroupNotificationWithId( + context = context, + groupId = operationHash, + channelId = channelId, + metaAccount = multisigAccount + ) + + return true + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionInitiatedNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionInitiatedNotificationHandler.kt new file mode 100644 index 0000000..27290d8 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionInitiatedNotificationHandler.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig + +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.model.createOperationHash +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.getNameOrAddress +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.applyDeepLink +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkData +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class MultisigTransactionInitiatedNotificationHandler( + private val context: Context, + private val accountRepository: AccountRepository, + private val multisigCallFormatter: MultisigCallFormatter, + private val configurator: MultisigOperationDeepLinkConfigurator, + override val chainRegistry: ChainRegistry, + @LocalIdentity private val identityProvider: IdentityProvider, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : MultisigBaseNotificationHandler( + multisigCallFormatter, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager +), + PushChainRegestryHolder { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.NEW_MULTISIG) + + val chain = content.getChain() + require(chain.isEnabled) + + val payload = content.extractMultisigPayload(signatoryRole = "initiator", chain) + + val multisigAccount = accountRepository.getMultisigForPayload(chain, payload) ?: return true + + val initiatorIdentity = identityProvider.getNameOrAddress(payload.signatory.accountId, chain) + + val operationHash = PendingMultisigOperation.createOperationHash(multisigAccount, chain, payload.callHashString) + + val notification = NotificationCompat.Builder(context, channelId) + .setSubText(getSubText(multisigAccount)) + .setGroup(operationHash) + .buildWithDefaults( + context, + resourceManager.getString(R.string.multisig_notification_init_transaction_title), + getMessage(chain, payload, footer = signFooter(initiatorIdentity)), + activityIntent().applyDeepLink( + configurator, + multisigOperationDeepLinkData(multisigAccount, chain, payload, MultisigOperationDeepLinkData.State.Active) + ) + ).build() + + notify(notification) + + notifyMultisigGroupNotificationWithId( + context = context, + groupId = operationHash, + channelId = channelId, + metaAccount = multisigAccount + ) + + return true + } + + private fun signFooter(initiatorIdentity: String) = resourceManager.getString(R.string.multisig_notification_initiator_footer, initiatorIdentity) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionNewApprovalNotificationHandler.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionNewApprovalNotificationHandler.kt new file mode 100644 index 0000000..ae6a22f --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/MultisigTransactionNewApprovalNotificationHandler.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig + +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkConfigurator +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.ActivityIntentProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.multisig.MultisigDetailsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation +import io.novafoundation.nova.feature_account_api.data.multisig.model.createOperationHash +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.account.identity.getNameOrAddress +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_deep_linking.presentation.configuring.applyDeepLink +import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkData +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.data.NotificationTypes +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationIdProvider +import io.novafoundation.nova.feature_push_notifications.presentation.handling.PushChainRegestryHolder +import io.novafoundation.nova.feature_push_notifications.presentation.handling.buildWithDefaults +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractPayloadFieldsWithPath +import io.novafoundation.nova.feature_push_notifications.presentation.handling.requireType +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +class MultisigTransactionNewApprovalNotificationHandler( + private val context: Context, + private val accountRepository: AccountRepository, + private val multisigDetailsRepository: MultisigDetailsRepository, + private val multisigCallFormatter: MultisigCallFormatter, + private val configurator: MultisigOperationDeepLinkConfigurator, + @LocalIdentity private val identityProvider: IdentityProvider, + override val chainRegistry: ChainRegistry, + activityIntentProvider: ActivityIntentProvider, + notificationIdProvider: NotificationIdProvider, + gson: Gson, + notificationManager: NotificationManagerCompat, + resourceManager: ResourceManager, +) : MultisigBaseNotificationHandler( + multisigCallFormatter, + chainRegistry, + activityIntentProvider, + notificationIdProvider, + gson, + notificationManager, + resourceManager +), + PushChainRegestryHolder { + + override suspend fun handleNotificationInternal(channelId: String, message: RemoteMessage): Boolean { + val content = message.getMessageContent() + content.requireType(NotificationTypes.MULTISIG_APPROVAL) + + val chain = content.getChain() + require(chain.isEnabled) + + val payload = content.extractMultisigPayload(signatoryRole = "approver", chain) + val approvals = content.extractPayloadFieldsWithPath("approvals")?.toInt() ?: return true + + val multisigAccount = accountRepository.getMultisigForPayload(chain, payload) ?: return true + + val approverIdentity = identityProvider.getNameOrAddress(payload.signatory.accountId, chain) + + val operationHash = PendingMultisigOperation.createOperationHash(multisigAccount, chain, payload.callHashString) + + val messageText = getMessage( + chain, + payload, + footer = resourceManager.getString( + R.string.multisig_notification_new_approval_title_additional_message, + approvals, + multisigAccount.threshold + ) + ) + + val notification = NotificationCompat.Builder(context, channelId) + .setSubText(getSubText(multisigAccount)) + .setGroup(operationHash) + .buildWithDefaults( + context, + resourceManager.getString(R.string.multisig_notification_new_approval_title, approverIdentity), + messageText, + activityIntent().applyDeepLink( + configurator, + multisigOperationDeepLinkData(multisigAccount, chain, payload, MultisigOperationDeepLinkData.State.Active) + ) + ).build() + + notify(notification) + + notifyMultisigGroupNotificationWithId( + context = context, + groupId = operationHash, + channelId = channelId, + metaAccount = multisigAccount + ) + + return true + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/Utils.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/Utils.kt new file mode 100644 index 0000000..392fa27 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/handling/types/multisig/Utils.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.handling.types.multisig + +import android.app.Notification +import android.content.Context +import androidx.core.app.NotificationCompat +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.accountIdKeyIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_multisig_operations.presentation.details.deeplink.MultisigOperationDeepLinkData +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.presentation.handling.NotificationData +import io.novafoundation.nova.feature_push_notifications.presentation.handling.extractPayloadFieldsWithPath +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.toAccountIdKey +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.fromHex + +class AddressWithAccountId( + val address: String, + val accountId: AccountIdKey +) + +class MultisigNotificationPayload( + val multisig: AddressWithAccountId, + val signatory: AddressWithAccountId, + val callHashString: String, + val callHash: AccountIdKey, + val callData: String? +) + +fun String.toAddressWithAccountId(chain: Chain) = AddressWithAccountId(this, this.toAccountIdKey(chain)) + +fun NotificationData.extractMultisigPayload(signatoryRole: String, chain: Chain): MultisigNotificationPayload { + val callHashString = extractPayloadFieldsWithPath("callHash") + return MultisigNotificationPayload( + extractPayloadFieldsWithPath("multisig").toAddressWithAccountId(chain), + extractPayloadFieldsWithPath(signatoryRole).toAddressWithAccountId(chain), + callHashString, + callHashString.fromHex().intoKey(), + extractPayloadFieldsWithPath("callData") + ) +} + +suspend fun AccountRepository.getMultisigForPayload(chain: Chain, payload: MultisigNotificationPayload): MultisigMetaAccount? { + return getActiveMetaAccounts() + .filterIsInstance() + .filter { it.accountIdKeyIn(chain) == payload.multisig.accountId } + .getFirstActorExcept(payload.signatory) +} + +fun List.getFirstActorExcept(signatory: AddressWithAccountId): MultisigMetaAccount? { + return firstOrNull { it.signatoryAccountId != signatory.accountId } +} + +fun multisigOperationDeepLinkData( + metaAccount: MultisigMetaAccount, + chain: Chain, + payload: MultisigNotificationPayload, + operationState: MultisigOperationDeepLinkData.State? +): MultisigOperationDeepLinkData { + return MultisigOperationDeepLinkData( + chain.id, + metaAccount.requireAddressIn(chain), + chain.addressOf(metaAccount.signatoryAccountId), + payload.callHashString, + payload.callData, + operationState + ) +} + +fun MultisigBaseNotificationHandler.notifyMultisigGroupNotificationWithId(context: Context, groupId: String, channelId: String, metaAccount: MetaAccount) { + val notificationId = createGroupMessageId(groupId) + val notification = createMultisigGroupNotification(context, groupId, channelId, getSubText(metaAccount)) + notify(notificationId, notification) +} + +fun createGroupMessageId(groupId: String): Int { + return groupId.hashCode() +} + +fun createMultisigGroupNotification(context: Context, groupId: String, channelId: String, walletName: String): Notification { + return NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_pezkuwi) + .setStyle( + NotificationCompat.InboxStyle() + .setSummaryText(walletName) + ) + .setGroup(groupId) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + .setGroupSummary(true) + .build() +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsCommunicator.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsCommunicator.kt new file mode 100644 index 0000000..718beb0 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsCommunicator.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.multisigs + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import kotlinx.parcelize.Parcelize + +interface PushMultisigSettingsRequester : InterScreenRequester { + + @Parcelize + class Request(val isAtLeastOneMultisigWalletSelected: Boolean, val settings: PushMultisigSettingsModel) : Parcelable +} + +interface PushMultisigSettingsResponder : InterScreenResponder { + + @Parcelize + class Response(val settings: PushMultisigSettingsModel) : Parcelable +} + +interface PushMultisigSettingsCommunicator : PushMultisigSettingsRequester, PushMultisigSettingsResponder diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsFragment.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsFragment.kt new file mode 100644 index 0000000..7d59e19 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsFragment.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.multisigs + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.view.dialog.infoDialog +import io.novafoundation.nova.common.view.settings.SettingsSwitcherView +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.databinding.FragmentPushMultisigSettingsBinding +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureComponent +import kotlinx.coroutines.flow.Flow + +class PushMultisigSettingsFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentPushMultisigSettingsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.pushMultisigsToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.pushMultisigSettingsSwitcher.setOnClickListener { viewModel.switchMultisigNotificationsState() } + binder.pushMultisigInitiatingSwitcher.setOnClickListener { viewModel.switchInitialNotificationsState() } + binder.pushMultisigApprovalSwitcher.setOnClickListener { viewModel.switchApprovingNotificationsState() } + binder.pushMultisigExecutedSwitcher.setOnClickListener { viewModel.switchExecutionNotificationsState() } + binder.pushMultisigRejectedSwitcher.setOnClickListener { viewModel.switchRejectionNotificationsState() } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), PushNotificationsFeatureApi::class.java) + .pushMultisigSettings() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: PushMultisigSettingsViewModel) { + observeBrowserEvents(viewModel) + + viewModel.isMultisigNotificationsEnabled.observe { + binder.pushMultisigSettingsSwitcher.setChecked(it) + + binder.pushMultisigInitiatingSwitcher.isEnabled = it + binder.pushMultisigApprovalSwitcher.isEnabled = it + binder.pushMultisigExecutedSwitcher.isEnabled = it + binder.pushMultisigRejectedSwitcher.isEnabled = it + } + + binder.pushMultisigInitiatingSwitcher.bindWithFlow(viewModel.isInitiationEnabled) + binder.pushMultisigApprovalSwitcher.bindWithFlow(viewModel.isApprovingEnabled) + binder.pushMultisigExecutedSwitcher.bindWithFlow(viewModel.isExecutionEnabled) + binder.pushMultisigRejectedSwitcher.bindWithFlow(viewModel.isRejectionEnabled) + + viewModel.noOneMultisigWalletSelectedEvent.observeEvent { + infoDialog(requireContext()) { + setTitle(R.string.no_ms_accounts_found_dialog_title) + setMessage(R.string.no_ms_accounts_found_dialog_message) + setNegativeButton(R.string.common_learn_more) { _, _ -> viewModel.learnMoreClicked() } + setPositiveButton(R.string.common_got_it, null) + } + } + } + + private fun SettingsSwitcherView.bindWithFlow(flow: Flow) { + flow.observe { setChecked(it) } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsModel.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsModel.kt new file mode 100644 index 0000000..a6219bc --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsModel.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.multisigs + +import android.os.Parcelable +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PushMultisigSettingsModel( + val isEnabled: Boolean, + val isInitiatingEnabled: Boolean, + val isApprovingEnabled: Boolean, + val isExecutionEnabled: Boolean, + val isRejectionEnabled: Boolean +) : Parcelable + +fun PushMultisigSettingsModel.toDomain() = PushSettings.MultisigsState( + isEnabled = isEnabled, + isInitiatingEnabled = isInitiatingEnabled, + isApprovingEnabled = isApprovingEnabled, + isExecutionEnabled = isExecutionEnabled, + isRejectionEnabled = isRejectionEnabled +) + +fun PushSettings.MultisigsState.toModel() = PushMultisigSettingsModel( + isEnabled = isEnabled, + isInitiatingEnabled = isInitiatingEnabled, + isApprovingEnabled = isApprovingEnabled, + isExecutionEnabled = isExecutionEnabled, + isRejectionEnabled = isRejectionEnabled +) diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsViewModel.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsViewModel.kt new file mode 100644 index 0000000..02cf998 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/PushMultisigSettingsViewModel.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.multisigs + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.sendEvent +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.model.disableIfAllTypesDisabled +import io.novafoundation.nova.feature_push_notifications.domain.model.isAllTypesDisabled +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +class PushMultisigSettingsViewModel( + private val router: PushNotificationsRouter, + private val pushMultisigSettingsResponder: PushMultisigSettingsResponder, + private val request: PushMultisigSettingsRequester.Request, + private val appLinksProvider: AppLinksProvider +) : BaseViewModel(), Browserable { + + private val settingsState = MutableStateFlow(request.settings.toDomain()) + + val isMultisigNotificationsEnabled = settingsState.map { it.isEnabled } + .distinctUntilChanged() + + val isInitiationEnabled = settingsState.map { it.isInitiatingEnabled } + .distinctUntilChanged() + + val isApprovingEnabled = settingsState.map { it.isApprovingEnabled } + .distinctUntilChanged() + + val isExecutionEnabled = settingsState.map { it.isExecutionEnabled } + .distinctUntilChanged() + + val isRejectionEnabled = settingsState.map { it.isRejectionEnabled } + .distinctUntilChanged() + + private val _noOneMultisigWalletSelectedEvent = MutableLiveData>() + val noOneMultisigWalletSelectedEvent: LiveData> = _noOneMultisigWalletSelectedEvent + + override val openBrowserEvent = MutableLiveData>() + + fun backClicked() { + pushMultisigSettingsResponder.respond(PushMultisigSettingsResponder.Response(settingsState.value.toModel())) + router.back() + } + + fun switchMultisigNotificationsState() { + val noMultisigWalletSelected = !request.isAtLeastOneMultisigWalletSelected + if (isMultisigNotificationsDisabled() && noMultisigWalletSelected) { + _noOneMultisigWalletSelectedEvent.sendEvent() + return + } + + toggleMultisigEnablingState() + } + + private fun isMultisigNotificationsDisabled() = !settingsState.value.isEnabled + + private fun toggleMultisigEnablingState() { + settingsState.update { + if (!it.isEnabled && it.isAllTypesDisabled()) { + it.copy(isEnabled = true, isInitiatingEnabled = true, isApprovingEnabled = true, isExecutionEnabled = true, isRejectionEnabled = true) + } else { + it.copy(isEnabled = !it.isEnabled) + } + } + } + + fun switchInitialNotificationsState() { + settingsState.update { + it.copy(isInitiatingEnabled = !it.isInitiatingEnabled) + .disableIfAllTypesDisabled() + } + } + + fun switchApprovingNotificationsState() { + settingsState.update { + it.copy(isApprovingEnabled = !it.isApprovingEnabled) + .disableIfAllTypesDisabled() + } + } + + fun switchExecutionNotificationsState() { + settingsState.update { + it.copy(isExecutionEnabled = !it.isExecutionEnabled) + .disableIfAllTypesDisabled() + } + } + + fun switchRejectionNotificationsState() { + settingsState.update { + it.copy(isRejectionEnabled = !it.isRejectionEnabled) + .disableIfAllTypesDisabled() + } + } + + fun learnMoreClicked() { + openBrowserEvent.value = Event(appLinksProvider.multisigsWikiUrl) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/di/PushMultisigSettingsComponent.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/di/PushMultisigSettingsComponent.kt new file mode 100644 index 0000000..fdc3bdf --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/di/PushMultisigSettingsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.multisigs.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsFragment +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsRequester + +@Subcomponent( + modules = [ + PushMultisigSettingsModule::class + ] +) +@ScreenScope +interface PushMultisigSettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance request: PushMultisigSettingsRequester.Request + ): PushMultisigSettingsComponent + } + + fun inject(fragment: PushMultisigSettingsFragment) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/di/PushMultisigSettingsModule.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/di/PushMultisigSettingsModule.kt new file mode 100644 index 0000000..1abfd96 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigs/di/PushMultisigSettingsModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.multisigs.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsRequester +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsViewModel + +@Module(includes = [ViewModelModule::class]) +class PushMultisigSettingsModule { + + @Provides + @IntoMap + @ViewModelKey(PushMultisigSettingsViewModel::class) + fun provideViewModel( + router: PushNotificationsRouter, + pushMultisigSettingsCommunicator: PushMultisigSettingsCommunicator, + request: PushMultisigSettingsRequester.Request, + appLinksProvider: AppLinksProvider + ): ViewModel { + return PushMultisigSettingsViewModel( + router = router, + pushMultisigSettingsResponder = pushMultisigSettingsCommunicator, + request = request, + appLinksProvider = appLinksProvider + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PushMultisigSettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PushMultisigSettingsViewModel::class.java) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigsWarning/MultisigPushNotificationsAlertBottomSheet.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigsWarning/MultisigPushNotificationsAlertBottomSheet.kt new file mode 100644 index 0000000..1c5271f --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigsWarning/MultisigPushNotificationsAlertBottomSheet.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet +import io.novafoundation.nova.feature_push_notifications.databinding.FragmentEnableMultisigPushesWarningBinding + +open class MultisigPushNotificationsAlertBottomSheet( + context: Context, + private val onEnableClicked: () -> Unit, +) : BaseBottomSheet(context, R.style.BottomSheetDialog), WithContextExtensions by WithContextExtensions(context) { + + override val binder = FragmentEnableMultisigPushesWarningBinding.inflate(LayoutInflater.from(context)) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binder.enableMultisigPushesNotNow.setOnClickListener { dismiss() } + binder.enableMultisigPushesEnable.setOnClickListener { + onEnableClicked() + dismiss() + } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigsWarning/MultisigPushNotificationsAlertMixin.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigsWarning/MultisigPushNotificationsAlertMixin.kt new file mode 100644 index 0000000..962b38f --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigsWarning/MultisigPushNotificationsAlertMixin.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.sendEvent +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_account_api.data.proxy.MetaAccountsUpdatesRegistry +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.interactor.MultisigPushAlertInteractor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class MultisigPushNotificationsAlertMixinFactory( + private val automaticInteractionGate: AutomaticInteractionGate, + private val interactor: MultisigPushAlertInteractor, + private val metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry, + private val router: PushNotificationsRouter +) { + fun create(coroutineScope: CoroutineScope): MultisigPushNotificationsAlertMixin { + return RealMultisigPushNotificationsAlertMixin( + automaticInteractionGate, + interactor, + metaAccountsUpdatesRegistry, + router, + coroutineScope + ) + } +} + +interface MultisigPushNotificationsAlertMixin { + + val showAlertEvent: LiveData> + + fun subscribeToShowAlert() + + fun showPushSettings() +} + +class RealMultisigPushNotificationsAlertMixin( + private val automaticInteractionGate: AutomaticInteractionGate, + private val interactor: MultisigPushAlertInteractor, + private val metaAccountsUpdatesRegistry: MetaAccountsUpdatesRegistry, + private val router: PushNotificationsRouter, + private val coroutineScope: CoroutineScope +) : MultisigPushNotificationsAlertMixin { + + override val showAlertEvent = MutableLiveData>() + + override fun subscribeToShowAlert() = coroutineScope.launchUnit { + if (interactor.isAlertAlreadyShown()) return@launchUnit + + // We should get this state before multisigs will be discovered so we call this method before interaction gate + val allowedToShowAlertAtStart = interactor.allowedToShowAlertAtStart() + + automaticInteractionGate.awaitInteractionAllowed() + + if (allowedToShowAlertAtStart) { + showAlert() + return@launchUnit + } + + // We have to show alert after user saw new multisigs in account list so we subscribed to its update states + // And show alert when at least one multisig update was consumed + metaAccountsUpdatesRegistry.observeLastConsumedUpdatesMetaIds() + .onEach { consumedMetaIdsUpdates -> + if (interactor.isAlertAlreadyShown()) return@onEach + + if (interactor.hasMultisigWallets(consumedMetaIdsUpdates.toList())) { + // We need to check interaction again since app may went to background before consuming updates + automaticInteractionGate.awaitInteractionAllowed() + showAlert() + } + } + .launchIn(coroutineScope) + } + + private fun showAlert() { + interactor.setAlertWasAlreadyShown() + showAlertEvent.sendEvent() + } + + override fun showPushSettings() { + router.openPushSettingsWithAccounts() + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigsWarning/UI.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigsWarning/UI.kt new file mode 100644 index 0000000..267748c --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/multisigsWarning/UI.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.multisigsWarning + +import io.novafoundation.nova.common.base.BaseScreenMixin + +fun BaseScreenMixin<*>.observeEnableMultisigPushesAlert(mixin: MultisigPushNotificationsAlertMixin) { + mixin.showAlertEvent.observeEvent { + MultisigPushNotificationsAlertBottomSheet(providedContext, onEnableClicked = { mixin.showPushSettings() }).show() + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/PushSettingsFragment.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/PushSettingsFragment.kt new file mode 100644 index 0000000..e5f1120 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/PushSettingsFragment.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.settings + +import androidx.core.view.isVisible +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog +import io.novafoundation.nova.common.utils.FragmentPayloadCreator +import io.novafoundation.nova.common.utils.PayloadCreator +import io.novafoundation.nova.common.utils.payload +import io.novafoundation.nova.common.utils.permissions.setupPermissionAsker +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.databinding.FragmentPushSettingsBinding +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureComponent + +class PushSettingsFragment : BaseFragment() { + + companion object : PayloadCreator by FragmentPayloadCreator() + + override fun createBinding() = FragmentPushSettingsBinding.inflate(layoutInflater) + + override fun initViews() { + onBackPressed { viewModel.backClicked() } + + binder.pushSettingsToolbar.setRightActionClickListener { viewModel.saveClicked() } + binder.pushSettingsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.pushSettingsEnable.setOnClickListener { viewModel.enableSwitcherClicked() } + binder.pushSettingsWallets.setOnClickListener { viewModel.walletsClicked() } + binder.pushSettingsAnnouncements.setOnClickListener { viewModel.announementsClicked() } + binder.pushSettingsSentTokens.setOnClickListener { viewModel.sentTokensClicked() } + binder.pushSettingsMultisigs.setOnClickListener { viewModel.multisigOperationsClicked() } + binder.pushSettingsReceivedTokens.setOnClickListener { viewModel.receivedTokensClicked() } + binder.pushSettingsGovernance.setOnClickListener { viewModel.governanceClicked() } + binder.pushSettingsStakingRewards.setOnClickListener { viewModel.stakingRewardsClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), PushNotificationsFeatureApi::class.java) + .pushSettingsComponentFactory() + .create(this, payload()) + .inject(this) + } + + override fun subscribe(viewModel: PushSettingsViewModel) { + setupConfirmationDialog(R.style.AccentNegativeAlertDialogTheme_Reversed, viewModel.closeConfirmationAction) + setupPermissionAsker(viewModel) + + viewModel.pushSettingsWasChangedState.observe { binder.pushSettingsToolbar.setRightActionEnabled(it) } + viewModel.savingInProgress.observe { binder.pushSettingsToolbar.showProgress(it) } + + viewModel.pushEnabledState.observe { enabled -> + binder.pushSettingsEnable.setChecked(enabled) + binder.pushSettingsAnnouncements.setEnabled(enabled) + binder.pushSettingsSentTokens.setEnabled(enabled) + binder.pushSettingsReceivedTokens.setEnabled(enabled) + binder.pushSettingsMultisigs.setEnabled(enabled) + binder.pushSettingsGovernance.setEnabled(enabled) + binder.pushSettingsStakingRewards.setEnabled(enabled) + } + + viewModel.pushWalletsQuantity.observe { binder.pushSettingsWallets.setValue(it) } + viewModel.showNoSelectedWalletsTip.observe { binder.pushSettingsNoSelectedWallets.isVisible = it } + viewModel.pushAnnouncements.observe { binder.pushSettingsAnnouncements.setChecked(it) } + viewModel.pushSentTokens.observe { binder.pushSettingsSentTokens.setChecked(it) } + viewModel.pushReceivedTokens.observe { binder.pushSettingsReceivedTokens.setChecked(it) } + viewModel.pushMultisigsState.observe { binder.pushSettingsMultisigs.setValue(it) } + viewModel.pushGovernanceState.observe { binder.pushSettingsGovernance.setValue(it) } + viewModel.pushStakingRewardsState.observe { binder.pushSettingsStakingRewards.setValue(it) } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/PushSettingsPayload.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/PushSettingsPayload.kt new file mode 100644 index 0000000..5204bcf --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/PushSettingsPayload.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.settings + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class PushSettingsPayload( + val enableSwitcherOnStart: Boolean, + val navigation: InstantNavigation +) : Parcelable { + companion object; + + sealed interface InstantNavigation : Parcelable { + @Parcelize + data object Nothing : InstantNavigation + + @Parcelize + data object WithWalletSelection : InstantNavigation + } +} + +fun PushSettingsPayload.Companion.default(enableSwitcherOnStart: Boolean = false) = + PushSettingsPayload(enableSwitcherOnStart, PushSettingsPayload.InstantNavigation.Nothing) + +fun PushSettingsPayload.Companion.withWalletSelection(enableSwitcherOnStart: Boolean = false) = + PushSettingsPayload(enableSwitcherOnStart, PushSettingsPayload.InstantNavigation.WithWalletSelection) diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/PushSettingsViewModel.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/PushSettingsViewModel.kt new file mode 100644 index 0000000..05e9ca6 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/PushSettingsViewModel.kt @@ -0,0 +1,346 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.settings + +import android.Manifest +import android.os.Build +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.actionAwaitable.fromRes +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.resources.formatBooleanToState +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.common.utils.updateValue +import io.novafoundation.nova.feature_account_api.domain.model.isMultisig +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsRequester +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.fromTrackIds +import io.novafoundation.nova.feature_governance_api.data.network.blockhain.model.toTrackIds +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.model.PushSettings +import io.novafoundation.nova.feature_push_notifications.domain.model.isGovEnabled +import io.novafoundation.nova.feature_push_notifications.domain.model.isNotEmpty +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsPayload +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsRequester +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsResponder +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsRequester +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.toDomain +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.toModel +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsPayload +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsRequester +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +private const val MIN_WALLETS = 1 +private const val MAX_WALLETS = 10 + +class PushSettingsViewModel( + private val router: PushNotificationsRouter, + private val pushNotificationsInteractor: PushNotificationsInteractor, + private val resourceManager: ResourceManager, + private val walletRequester: SelectMultipleWalletsRequester, + private val pushGovernanceSettingsRequester: PushGovernanceSettingsRequester, + private val pushStakingSettingsRequester: PushStakingSettingsRequester, + private val pushMultisigSettingsRequester: PushMultisigSettingsRequester, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val permissionsAsker: PermissionsAsker.Presentation, + private val payload: PushSettingsPayload +) : BaseViewModel(), PermissionsAsker by permissionsAsker { + + val closeConfirmationAction = actionAwaitableMixinFactory.confirmingAction() + + private val oldPushSettingsState = flowOf { pushNotificationsInteractor.getPushSettings() } + .shareInBackground() + + val pushEnabledState = MutableStateFlow(pushNotificationsInteractor.isPushNotificationsEnabled()) + private val pushSettingsState = MutableStateFlow(null) + + val pushSettingsWasChangedState = combine(pushEnabledState, pushSettingsState, oldPushSettingsState) { pushEnabled, newState, oldState -> + pushEnabled != pushNotificationsInteractor.isPushNotificationsEnabled() || + newState != oldState + } + + val pushWalletsQuantity = pushSettingsState + .mapNotNull { it?.subscribedMetaAccounts?.size?.format() } + .distinctUntilChanged() + + val pushAnnouncements = pushSettingsState.mapNotNull { it?.announcementsEnabled } + .distinctUntilChanged() + + val pushSentTokens = pushSettingsState.mapNotNull { it?.sentTokensEnabled } + .distinctUntilChanged() + + val pushReceivedTokens = pushSettingsState.mapNotNull { it?.receivedTokensEnabled } + .distinctUntilChanged() + + val pushGovernanceState = pushSettingsState.mapNotNull { it } + .map { resourceManager.formatBooleanToState(it.isGovEnabled()) } + .distinctUntilChanged() + + val pushMultisigsState = pushSettingsState.mapNotNull { it } + .map { resourceManager.formatBooleanToState(it.multisigs.isEnabled) } + .distinctUntilChanged() + + val pushStakingRewardsState = pushSettingsState.mapNotNull { it } + .map { resourceManager.formatBooleanToState(it.stakingReward.isNotEmpty()) } + .distinctUntilChanged() + + val showNoSelectedWalletsTip = pushSettingsState + .mapNotNull { it?.subscribedMetaAccounts?.isEmpty() } + .distinctUntilChanged() + + private val _savingInProgress = MutableStateFlow(false) + val savingInProgress: Flow = _savingInProgress + + init { + initFirstState() + + subscribeOnSelectWallets() + subscribeOnGovernanceSettings() + subscribeOnStakingSettings() + subscribeMultisigSettings() + disableNotificationsIfPushSettingsEmpty() + + enableSwitcherOnStartIfRequested() + } + + private fun initFirstState() { + launch { + val settings = oldPushSettingsState.first() + pushSettingsState.value = pushNotificationsInteractor.filterAvailableMetaIdsAndGetNewState(settings) + + openWalletSelectionIfRequested() + } + } + + fun backClicked() { + launch { + if (pushSettingsWasChangedState.first()) { + closeConfirmationAction.awaitAction( + ConfirmationDialogInfo.fromRes( + resourceManager, + R.string.common_confirmation_title, + R.string.common_close_confirmation_message, + R.string.common_close, + R.string.common_cancel, + ) + ) + } + + router.back() + } + } + + fun saveClicked() { + launch { + _savingInProgress.value = true + val pushSettings = pushSettingsState.value ?: return@launch + pushNotificationsInteractor.updatePushSettings(pushEnabledState.value, pushSettings) + .onSuccess { + enableMultisigWalletIfAtLeastOneSelected(pushSettings) + router.back() + } + .onFailure { showError(it) } + + _savingInProgress.value = false + } + } + + private fun enableMultisigWalletIfAtLeastOneSelected(pushSettings: PushSettings) { + if (pushSettings.multisigs.isEnabled && isMultisigsStillWasNotEnabled()) { + pushNotificationsInteractor.setMultisigsWasEnabledFirstTime() + } + } + + fun enableSwitcherClicked() { + launch { + if (!pushEnabledState.value && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val isPermissionsGranted = permissionsAsker.requirePermissions(Manifest.permission.POST_NOTIFICATIONS) + + if (!isPermissionsGranted) { + return@launch + } + } + + if (!pushEnabledState.value) { + setDefaultPushSettingsIfEmpty() + } + + pushEnabledState.toggle() + } + } + + fun walletsClicked() { + selectWallets() + } + + fun announementsClicked() { + pushSettingsState.updateValue { it?.copy(announcementsEnabled = !it.announcementsEnabled) } + } + + fun sentTokensClicked() { + pushSettingsState.updateValue { it?.copy(sentTokensEnabled = !it.sentTokensEnabled) } + } + + fun receivedTokensClicked() { + pushSettingsState.updateValue { it?.copy(receivedTokensEnabled = !it.receivedTokensEnabled) } + } + + fun multisigOperationsClicked() = launchUnit { + val settings = pushSettingsState.value ?: return@launchUnit + val isAtLeastOneAccountMultisig = settings.subscribedMetaAccounts.atLeastOneMultisigWalletEnabled() + pushMultisigSettingsRequester.openRequest( + PushMultisigSettingsRequester.Request(isAtLeastOneAccountMultisig, settings.multisigs.toModel()) + ) + } + + fun governanceClicked() { + val settings = pushSettingsState.value ?: return + pushGovernanceSettingsRequester.openRequest(PushGovernanceSettingsRequester.Request(mapGovSettingsToPayload(settings))) + } + + fun stakingRewardsClicked() { + val stakingRewards = pushSettingsState.value?.stakingReward ?: return + val settings = when (stakingRewards) { + is PushSettings.ChainFeature.All -> PushStakingSettingsPayload.AllChains + is PushSettings.ChainFeature.Concrete -> PushStakingSettingsPayload.SpecifiedChains(stakingRewards.chainIds.toSet()) + } + val request = PushStakingSettingsRequester.Request(settings) + pushStakingSettingsRequester.openRequest(request) + } + + private fun subscribeOnSelectWallets() { + walletRequester.responseFlow + .onEach { response -> + val currentState = pushSettingsState.value ?: return@onEach + val newPushSettingsState = pushNotificationsInteractor.getNewStateForChangedMetaAccounts(currentState, response.selectedMetaIds) + + pushSettingsState.value = newPushSettingsState + } + .launchIn(this) + } + + private fun subscribeOnGovernanceSettings() { + pushGovernanceSettingsRequester.responseFlow + .onEach { response -> + pushSettingsState.updateValue { settings -> + settings?.copy(governance = mapGovSettingsResponseToModel(response)) + } + } + .launchIn(this) + } + + private fun subscribeOnStakingSettings() { + pushStakingSettingsRequester.responseFlow + .onEach { response -> + val stakingSettings = when (response.settings) { + is PushStakingSettingsPayload.AllChains -> PushSettings.ChainFeature.All + is PushStakingSettingsPayload.SpecifiedChains -> PushSettings.ChainFeature.Concrete(response.settings.enabledChainIds.toList()) + } + + pushSettingsState.updateValue { settings -> + settings?.copy(stakingReward = stakingSettings) + } + } + .launchIn(this) + } + + private fun subscribeMultisigSettings() { + pushMultisigSettingsRequester.responseFlow + .onEach { response -> + pushSettingsState.updateValue { settings -> + settings?.copy(multisigs = response.settings.toDomain()) + } + } + .launchIn(this) + } + + private fun mapGovSettingsToPayload(pushSettings: PushSettings): List { + return pushSettings.governance.map { (chainId, govState) -> + PushGovernanceSettingsPayload( + chainId = chainId, + governance = Chain.Governance.V2, + newReferenda = govState.newReferendaEnabled, + referendaUpdates = govState.referendumUpdateEnabled, + delegateVotes = govState.govMyDelegateVotedEnabled, + tracksIds = govState.tracks.fromTrackIds() + ) + } + } + + private fun mapGovSettingsResponseToModel(response: PushGovernanceSettingsResponder.Response): Map { + return response.enabledGovernanceSettings + .associateBy { it.chainId } + .mapValues { (_, govState) -> + PushSettings.GovernanceState( + newReferendaEnabled = govState.newReferenda, + referendumUpdateEnabled = govState.referendaUpdates, + govMyDelegateVotedEnabled = govState.delegateVotes, + tracks = govState.tracksIds.toTrackIds() + ) + } + } + + private fun disableNotificationsIfPushSettingsEmpty() { + pushSettingsState + .filterNotNull() + .onEach { pushSettings -> + if (pushSettings.settingsIsEmpty()) { + pushEnabledState.value = false + } + } + .launchIn(this) + } + + private suspend fun setDefaultPushSettingsIfEmpty() { + if (pushSettingsState.value?.settingsIsEmpty() == true) { + pushSettingsState.value = pushNotificationsInteractor.getPushSettings() + } + } + + private suspend fun Collection.atLeastOneMultisigWalletEnabled(): Boolean { + return pushNotificationsInteractor.getMetaAccounts(this.toList()) + .any { it.isMultisig() } + } + + private fun isMultisigsStillWasNotEnabled() = !pushNotificationsInteractor.isMultisigsWasEnabledFirstTime() + + private fun enableSwitcherOnStartIfRequested() { + if (payload.enableSwitcherOnStart) { + pushEnabledState.value = true + } + } + + private fun openWalletSelectionIfRequested() { + if (payload.navigation is PushSettingsPayload.InstantNavigation.WithWalletSelection) { + selectWallets() + } + } + + private fun selectWallets() { + walletRequester.openRequest( + SelectMultipleWalletsRequester.Request( + titleText = resourceManager.getString(R.string.push_wallets_title, MAX_WALLETS), + currentlySelectedMetaIds = pushSettingsState.value?.subscribedMetaAccounts?.toSet().orEmpty(), + min = MIN_WALLETS, + max = MAX_WALLETS + ) + ) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/di/PushSettingsComponent.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/di/PushSettingsComponent.kt new file mode 100644 index 0000000..87ab493 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/di/PushSettingsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.settings.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_push_notifications.presentation.settings.PushSettingsFragment +import io.novafoundation.nova.feature_push_notifications.presentation.settings.PushSettingsPayload + +@Subcomponent( + modules = [ + PushSettingsModule::class + ] +) +@ScreenScope +interface PushSettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: PushSettingsPayload + ): PushSettingsComponent + } + + fun inject(fragment: PushSettingsFragment) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/di/PushSettingsModule.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/di/PushSettingsModule.kt new file mode 100644 index 0000000..632c4ae --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/settings/di/PushSettingsModule.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.settings.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.list.SelectMultipleWalletsCommunicator +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.governance.PushGovernanceSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.settings.PushSettingsPayload +import io.novafoundation.nova.feature_push_notifications.presentation.multisigs.PushMultisigSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.settings.PushSettingsViewModel +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsCommunicator + +@Module(includes = [ViewModelModule::class]) +class PushSettingsModule { + + @Provides + fun providePermissionAsker( + permissionsAskerFactory: PermissionsAskerFactory, + fragment: Fragment, + router: PushNotificationsRouter + ) = permissionsAskerFactory.createReturnable(fragment, router) + + @Provides + @IntoMap + @ViewModelKey(PushSettingsViewModel::class) + fun provideViewModel( + router: PushNotificationsRouter, + interactor: PushNotificationsInteractor, + resourceManager: ResourceManager, + selectMultipleWalletsCommunicator: SelectMultipleWalletsCommunicator, + pushGovernanceSettingsCommunicator: PushGovernanceSettingsCommunicator, + pushStakingSettingsRequester: PushStakingSettingsCommunicator, + permissionsAsker: PermissionsAsker.Presentation, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + pushMultisigSettingsCommunicator: PushMultisigSettingsCommunicator, + payload: PushSettingsPayload + ): ViewModel { + return PushSettingsViewModel( + router, + interactor, + resourceManager, + selectMultipleWalletsCommunicator, + pushGovernanceSettingsCommunicator, + pushStakingSettingsRequester, + pushMultisigSettingsCommunicator, + actionAwaitableMixinFactory, + permissionsAsker, + payload + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PushSettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PushSettingsViewModel::class.java) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/PushStakingSettingsCommunicator.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/PushStakingSettingsCommunicator.kt new file mode 100644 index 0000000..ce1c712 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/PushStakingSettingsCommunicator.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.staking + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +interface PushStakingSettingsRequester : InterScreenRequester { + + @Parcelize + class Request(val settings: PushStakingSettingsPayload) : Parcelable +} + +interface PushStakingSettingsResponder : InterScreenResponder { + + @Parcelize + class Response(val settings: PushStakingSettingsPayload) : Parcelable +} + +interface PushStakingSettingsCommunicator : PushStakingSettingsRequester, PushStakingSettingsResponder + +sealed class PushStakingSettingsPayload : Parcelable { + + @Parcelize + object AllChains : PushStakingSettingsPayload() + + @Parcelize + class SpecifiedChains(val enabledChainIds: Set) : PushStakingSettingsPayload() +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/PushStakingSettingsFragment.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/PushStakingSettingsFragment.kt new file mode 100644 index 0000000..3c06686 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/PushStakingSettingsFragment.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.staking + +import android.os.Bundle +import androidx.core.view.isVisible + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_push_notifications.databinding.FragmentPushStakingSettingsBinding +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureComponent +import io.novafoundation.nova.feature_push_notifications.presentation.staking.adapter.PushStakingRVItem +import io.novafoundation.nova.feature_push_notifications.presentation.staking.adapter.PushStakingSettingsAdapter +import javax.inject.Inject + +class PushStakingSettingsFragment : BaseFragment(), PushStakingSettingsAdapter.ItemHandler { + + companion object { + private const val KEY_REQUEST = "KEY_REQUEST" + + fun getBundle(request: PushStakingSettingsRequester.Request): Bundle { + return Bundle().apply { + putParcelable(KEY_REQUEST, request) + } + } + } + + override fun createBinding() = FragmentPushStakingSettingsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + PushStakingSettingsAdapter(imageLoader, this) + } + + override fun initViews() { + binder.pushStakingToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.pushStakingToolbar.setRightActionClickListener { viewModel.clearClicked() } + onBackPressed { viewModel.backClicked() } + + binder.pushStakingList.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), PushNotificationsFeatureApi::class.java) + .pushStakingSettings() + .create(this, argument(KEY_REQUEST)) + .inject(this) + } + + override fun subscribe(viewModel: PushStakingSettingsViewModel) { + viewModel.clearButtonEnabledFlow.observe { + binder.pushStakingToolbar.setRightActionEnabled(it) + } + + viewModel.stakingSettingsList.observe { + binder.pushStakingList.isVisible = it is ExtendedLoadingState.Loaded + binder.pushStakingProgress.isVisible = it is ExtendedLoadingState.Loading + + if (it is ExtendedLoadingState.Loaded) { + adapter.submitList(it.data) + } + } + } + + override fun itemClicked(item: PushStakingRVItem) { + viewModel.itemClicked(item) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/PushStakingSettingsViewModel.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/PushStakingSettingsViewModel.kt new file mode 100644 index 0000000..9d3484c --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/PushStakingSettingsViewModel.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.staking + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.common.utils.updateValue +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.interactor.StakingPushSettingsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.staking.adapter.PushStakingRVItem +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class PushStakingSettingsViewModel( + private val router: PushNotificationsRouter, + private val pushStakingSettingsResponder: PushStakingSettingsCommunicator, + private val chainRegistry: ChainRegistry, + private val request: PushStakingSettingsRequester.Request, + private val resourceManager: ResourceManager, + private val stakingPushSettingsInteractor: StakingPushSettingsInteractor +) : BaseViewModel() { + + private val chainsFlow = stakingPushSettingsInteractor.stakingChainsFlow() + + val _enabledStakingSettingsList: MutableStateFlow> = MutableStateFlow(emptySet()) + + val stakingSettingsList = combine(chainsFlow, _enabledStakingSettingsList) { chains, enabledChains -> + chains.map { chain -> + PushStakingRVItem( + chain.id, + chain.name, + chain.icon, + enabledChains.contains(chain.id) + ) + } + }.withSafeLoading() + .shareInBackground() + + val clearButtonEnabledFlow = _enabledStakingSettingsList.map { + it.isNotEmpty() + }.shareInBackground() + + init { + launch { + _enabledStakingSettingsList.value = when (request.settings) { + PushStakingSettingsPayload.AllChains -> chainsFlow.first().mapToSet { it.id } + + is PushStakingSettingsPayload.SpecifiedChains -> request.settings.enabledChainIds + } + } + } + + fun backClicked() { + launch { + val allChainsIds = chainsFlow.first().mapToSet { it.id } + val enabledChains = _enabledStakingSettingsList.value + + val settings = if (enabledChains == allChainsIds) { + PushStakingSettingsPayload.AllChains + } else { + PushStakingSettingsPayload.SpecifiedChains(enabledChains) + } + + val response = PushStakingSettingsResponder.Response(settings) + pushStakingSettingsResponder.respond(response) + + router.back() + } + } + + fun clearClicked() { + _enabledStakingSettingsList.value = emptySet() + } + + fun itemClicked(item: PushStakingRVItem) { + _enabledStakingSettingsList.updateValue { + it.toggle(item.chainId) + } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/adapter/PushStakingRVItem.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/adapter/PushStakingRVItem.kt new file mode 100644 index 0000000..2239fbc --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/adapter/PushStakingRVItem.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.staking.adapter + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +data class PushStakingRVItem( + val chainId: ChainId, + val chainName: String, + val chainIconUrl: String?, + val isEnabled: Boolean, +) diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/adapter/PushStakingSettingsAdapter.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/adapter/PushStakingSettingsAdapter.kt new file mode 100644 index 0000000..ae4a9e5 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/adapter/PushStakingSettingsAdapter.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.staking.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import coil.ImageLoader +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIconToTarget +import io.novafoundation.nova.feature_push_notifications.databinding.ItemPushStakingSettingsBinding + +class PushStakingSettingsAdapter( + private val imageLoader: ImageLoader, + private val itemHandler: ItemHandler +) : ListAdapter(PushStakingItemCallback()) { + + interface ItemHandler { + fun itemClicked(item: PushStakingRVItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PushStakingItemViewHolder { + return PushStakingItemViewHolder(ItemPushStakingSettingsBinding.inflate(parent.inflater(), parent, false), imageLoader, itemHandler) + } + + override fun onBindViewHolder(holder: PushStakingItemViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: PushStakingItemViewHolder, position: Int, payloads: MutableList) { + resolvePayload(holder, position, payloads) { + val item = getItem(position) + + when (it) { + PushStakingRVItem::isEnabled -> holder.setEnabled(item) + } + } + } +} + +class PushStakingItemCallback() : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: PushStakingRVItem, newItem: PushStakingRVItem): Boolean { + return oldItem.chainId == newItem.chainId + } + + override fun areContentsTheSame(oldItem: PushStakingRVItem, newItem: PushStakingRVItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: PushStakingRVItem, newItem: PushStakingRVItem): Any? { + return PushStakingPayloadGenerator.diff(oldItem, newItem) + } +} + +class PushStakingItemViewHolder( + private val binder: ItemPushStakingSettingsBinding, + private val imageLoader: ImageLoader, + private val itemHandler: PushStakingSettingsAdapter.ItemHandler +) : ViewHolder(binder.root) { + + init { + binder.pushStakingItem.setIconTintColor(null) + } + + fun bind(item: PushStakingRVItem) { + with(binder) { + pushStakingItem.setOnClickListener { + itemHandler.itemClicked(item) + } + + pushStakingItem.setTitle(item.chainName) + imageLoader.loadChainIconToTarget(item.chainIconUrl, binder.root.context) { + pushStakingItem.setIcon(it) + } + + setEnabled(item) + } + } + + fun setEnabled(item: PushStakingRVItem) { + binder.pushStakingItem.setChecked(item.isEnabled) + } +} + +private object PushStakingPayloadGenerator : PayloadGenerator( + PushStakingRVItem::isEnabled +) diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/di/PushStakingSettingsComponent.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/di/PushStakingSettingsComponent.kt new file mode 100644 index 0000000..a5dedf4 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/di/PushStakingSettingsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.staking.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsFragment +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsRequester + +@Subcomponent( + modules = [ + PushStakingSettingsModule::class + ] +) +@ScreenScope +interface PushStakingSettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance request: PushStakingSettingsRequester.Request + ): PushStakingSettingsComponent + } + + fun inject(fragment: PushStakingSettingsFragment) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/di/PushStakingSettingsModule.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/di/PushStakingSettingsModule.kt new file mode 100644 index 0000000..1d82c74 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/staking/di/PushStakingSettingsModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.staking.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.interactor.StakingPushSettingsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsCommunicator +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsRequester +import io.novafoundation.nova.feature_push_notifications.presentation.staking.PushStakingSettingsViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class PushStakingSettingsModule { + + @Provides + @IntoMap + @ViewModelKey(PushStakingSettingsViewModel::class) + fun provideViewModel( + router: PushNotificationsRouter, + pushStakingSettingsCommunicator: PushStakingSettingsCommunicator, + chainRegistry: ChainRegistry, + request: PushStakingSettingsRequester.Request, + resourceManager: ResourceManager, + stakingPushSettingsInteractor: StakingPushSettingsInteractor + ): ViewModel { + return PushStakingSettingsViewModel( + router = router, + pushStakingSettingsResponder = pushStakingSettingsCommunicator, + chainRegistry = chainRegistry, + request = request, + resourceManager = resourceManager, + stakingPushSettingsInteractor = stakingPushSettingsInteractor + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PushStakingSettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PushStakingSettingsViewModel::class.java) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/PushWelcomeFragment.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/PushWelcomeFragment.kt new file mode 100644 index 0000000..4d34f29 --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/PushWelcomeFragment.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.welcome + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.utils.formatting.applyTermsAndPrivacyPolicy +import io.novafoundation.nova.common.utils.permissions.setupPermissionAsker +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.databinding.FragmentPushWelcomeBinding +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureComponent + +class PushWelcomeFragment : BaseFragment() { + + override fun createBinding() = FragmentPushWelcomeBinding.inflate(layoutInflater) + + override fun initViews() { + binder.pushWelcomeEnableButton.prepareForProgress(this) + binder.pushWelcomeCancelButton.prepareForProgress(this) + binder.pushWelcomeToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.pushWelcomeEnableButton.setOnClickListener { viewModel.askPermissionAndEnablePushNotifications() } + binder.pushWelcomeCancelButton.setOnClickListener { viewModel.backClicked() } + + configureTermsAndPrivacy() + } + + private fun configureTermsAndPrivacy() { + binder.pushWelcomeTermsAndConditions.applyTermsAndPrivacyPolicy( + R.string.push_welcome_terms_and_conditions, + R.string.common_terms_and_conditions_formatting, + R.string.common_privacy_policy_formatting, + viewModel::termsClicked, + viewModel::privacyClicked + ) + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), PushNotificationsFeatureApi::class.java) + .pushWelcomeComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: PushWelcomeViewModel) { + observeBrowserEvents(viewModel) + observeRetries(viewModel) + setupPermissionAsker(viewModel) + + viewModel.buttonState.observe { state -> + binder.pushWelcomeEnableButton.setState(state) + binder.pushWelcomeCancelButton.setState(state) + } + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/PushWelcomeViewModel.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/PushWelcomeViewModel.kt new file mode 100644 index 0000000..b2f1c1e --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/PushWelcomeViewModel.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.welcome + +import android.Manifest +import android.os.Build +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.RetryPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.WelcomePushNotificationsInteractor +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class PushWelcomeViewModel( + private val router: PushNotificationsRouter, + private val pushNotificationsInteractor: PushNotificationsInteractor, + private val welcomePushNotificationsInteractor: WelcomePushNotificationsInteractor, + private val permissionsAsker: PermissionsAsker.Presentation, + private val resourceManager: ResourceManager, + private val appLinksProvider: AppLinksProvider +) : BaseViewModel(), PermissionsAsker by permissionsAsker, Retriable, Browserable { + + private val _enablingInProgress = MutableStateFlow(false) + + override val retryEvent: MutableLiveData> = MutableLiveData() + + override val openBrowserEvent = MutableLiveData>() + + val buttonState = _enablingInProgress.map { inProgress -> + when (inProgress) { + true -> ButtonState.PROGRESS + false -> ButtonState.NORMAL + } + } + + fun backClicked() { + welcomePushNotificationsInteractor.setWelcomeScreenShown() + router.back() + } + + fun termsClicked() { + openBrowserEvent.value = Event(appLinksProvider.termsUrl) + } + + fun privacyClicked() { + openBrowserEvent.value = Event(appLinksProvider.privacyUrl) + } + + fun askPermissionAndEnablePushNotifications() { + launch { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val isPermissionsGranted = permissionsAsker.requirePermissions(Manifest.permission.POST_NOTIFICATIONS) + + if (!isPermissionsGranted) { + return@launch + } + } + + _enablingInProgress.value = true + pushNotificationsInteractor.initPushSettings() + .onSuccess { + welcomePushNotificationsInteractor.setWelcomeScreenShown() + router.back() + } + .onFailure { + when (it) { + is TimeoutCancellationException -> showError( + resourceManager.getString(R.string.common_something_went_wrong_title), + resourceManager.getString(R.string.push_welcome_timeout_error_message) + ) + + else -> retryDialog() + } + } + + _enablingInProgress.value = false + } + } + + private fun retryDialog() { + retryEvent.value = Event( + RetryPayload( + title = resourceManager.getString(R.string.common_error_general_title), + message = resourceManager.getString(R.string.common_retry_message), + onRetry = { askPermissionAndEnablePushNotifications() } + ) + ) + } +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/di/PushWelcomeComponent.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/di/PushWelcomeComponent.kt new file mode 100644 index 0000000..3438bfc --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/di/PushWelcomeComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.welcome.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_push_notifications.presentation.welcome.PushWelcomeFragment + +@Subcomponent( + modules = [ + PushWelcomeModule::class + ] +) +@ScreenScope +interface PushWelcomeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): PushWelcomeComponent + } + + fun inject(fragment: PushWelcomeFragment) +} diff --git a/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/di/PushWelcomeModule.kt b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/di/PushWelcomeModule.kt new file mode 100644 index 0000000..54f9c8b --- /dev/null +++ b/feature-push-notifications/src/main/java/io/novafoundation/nova/feature_push_notifications/presentation/welcome/di/PushWelcomeModule.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_push_notifications.presentation.welcome.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.feature_push_notifications.PushNotificationsRouter +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.WelcomePushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.presentation.welcome.PushWelcomeViewModel + +@Module(includes = [ViewModelModule::class]) +class PushWelcomeModule { + + @Provides + fun providePermissionAsker( + permissionsAskerFactory: PermissionsAskerFactory, + fragment: Fragment, + router: PushNotificationsRouter + ) = permissionsAskerFactory.createReturnable(fragment, router) + + @Provides + @IntoMap + @ViewModelKey(PushWelcomeViewModel::class) + fun provideViewModel( + router: PushNotificationsRouter, + interactor: PushNotificationsInteractor, + permissionsAsker: PermissionsAsker.Presentation, + resourceManager: ResourceManager, + welcomePushNotificationsInteractor: WelcomePushNotificationsInteractor, + appLinksProvider: AppLinksProvider + ): ViewModel { + return PushWelcomeViewModel( + router, + interactor, + welcomePushNotificationsInteractor, + permissionsAsker, + resourceManager, + appLinksProvider + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PushWelcomeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PushWelcomeViewModel::class.java) + } +} diff --git a/feature-push-notifications/src/main/res/layout/fragment_enable_multisig_pushes_warning.xml b/feature-push-notifications/src/main/res/layout/fragment_enable_multisig_pushes_warning.xml new file mode 100644 index 0000000..41e6a32 --- /dev/null +++ b/feature-push-notifications/src/main/res/layout/fragment_enable_multisig_pushes_warning.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-push-notifications/src/main/res/layout/fragment_push_governance_settings.xml b/feature-push-notifications/src/main/res/layout/fragment_push_governance_settings.xml new file mode 100644 index 0000000..ddb7604 --- /dev/null +++ b/feature-push-notifications/src/main/res/layout/fragment_push_governance_settings.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-push-notifications/src/main/res/layout/fragment_push_multisig_settings.xml b/feature-push-notifications/src/main/res/layout/fragment_push_multisig_settings.xml new file mode 100644 index 0000000..b790037 --- /dev/null +++ b/feature-push-notifications/src/main/res/layout/fragment_push_multisig_settings.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-push-notifications/src/main/res/layout/fragment_push_settings.xml b/feature-push-notifications/src/main/res/layout/fragment_push_settings.xml new file mode 100644 index 0000000..72cebcb --- /dev/null +++ b/feature-push-notifications/src/main/res/layout/fragment_push_settings.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-push-notifications/src/main/res/layout/fragment_push_staking_settings.xml b/feature-push-notifications/src/main/res/layout/fragment_push_staking_settings.xml new file mode 100644 index 0000000..a051dc0 --- /dev/null +++ b/feature-push-notifications/src/main/res/layout/fragment_push_staking_settings.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-push-notifications/src/main/res/layout/fragment_push_welcome.xml b/feature-push-notifications/src/main/res/layout/fragment_push_welcome.xml new file mode 100644 index 0000000..c551930 --- /dev/null +++ b/feature-push-notifications/src/main/res/layout/fragment_push_welcome.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-push-notifications/src/main/res/layout/item_push_governance_settings.xml b/feature-push-notifications/src/main/res/layout/item_push_governance_settings.xml new file mode 100644 index 0000000..e6daa14 --- /dev/null +++ b/feature-push-notifications/src/main/res/layout/item_push_governance_settings.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/feature-push-notifications/src/main/res/layout/item_push_staking_settings.xml b/feature-push-notifications/src/main/res/layout/item_push_staking_settings.xml new file mode 100644 index 0000000..5e0815a --- /dev/null +++ b/feature-push-notifications/src/main/res/layout/item_push_staking_settings.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/feature-settings-api/.gitignore b/feature-settings-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-settings-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-settings-api/build.gradle b/feature-settings-api/build.gradle new file mode 100644 index 0000000..940b5d0 --- /dev/null +++ b/feature-settings-api/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_settings_api' +} + +dependencies { + implementation coroutinesDep + implementation project(":common") + + implementation coroutinesDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-settings-api/consumer-rules.pro b/feature-settings-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-settings-api/proguard-rules.pro b/feature-settings-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-settings-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-settings-api/src/main/AndroidManifest.xml b/feature-settings-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-settings-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-settings-api/src/main/java/io/novafoundation/nova/feature_settings_api/SettingsFeatureApi.kt b/feature-settings-api/src/main/java/io/novafoundation/nova/feature_settings_api/SettingsFeatureApi.kt new file mode 100644 index 0000000..da04b01 --- /dev/null +++ b/feature-settings-api/src/main/java/io/novafoundation/nova/feature_settings_api/SettingsFeatureApi.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_settings_api + +interface SettingsFeatureApi diff --git a/feature-settings-impl/.gitignore b/feature-settings-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-settings-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-settings-impl/build.gradle b/feature-settings-impl/build.gradle new file mode 100644 index 0000000..995fe59 --- /dev/null +++ b/feature-settings-impl/build.gradle @@ -0,0 +1,65 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' + +android { + namespace 'io.novafoundation.nova.feature_settings_impl' + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':common') + implementation project(':runtime') + implementation project(':feature-account-api') + implementation project(':feature-currency-api') + implementation project(':feature-wallet-connect-api') + implementation project(':feature-versions-api') + implementation project(':feature-push-notifications') + implementation project(':feature-assets') + implementation project(':caip') + + implementation project(':feature-settings-api') + + implementation project(':feature-cloud-backup-api') + + implementation kotlinDep + + + implementation androidDep + implementation materialDep + implementation cardViewDep + implementation constraintDep + + implementation shimmerDep + implementation biometricDep + + implementation substrateSdkDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation daggerDep + ksp daggerCompiler + + implementation roomDep + ksp roomCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-settings-impl/consumer-rules.pro b/feature-settings-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-settings-impl/proguard-rules.pro b/feature-settings-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-settings-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-settings-impl/src/main/AndroidManifest.xml b/feature-settings-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-settings-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/SettingsRouter.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/SettingsRouter.kt new file mode 100644 index 0000000..bdf202c --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/SettingsRouter.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_settings_impl + +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodePayload + +interface SettingsRouter : ReturnableRouter { + + fun openWallets() + + fun openNetworks() + + fun openNetworkDetails(payload: ChainNetworkManagementPayload) + + fun openCustomNode(payload: CustomNodePayload) + + fun openPushNotificationSettings() + + fun openCurrencies() + + fun openLanguages() + + fun openAppearance() + + fun openChangePinCode() + + fun openWalletDetails(metaId: Long) + + fun openSwitchWallet() + + fun openWalletConnectScan() + + fun openWalletConnectSessions() + + fun openCloudBackupSettings() + + fun openManualBackup() + + fun addNetwork() + + fun openCreateNetworkFlow() + + fun openCreateNetworkFlow(payload: AddNetworkPayload.Mode.Add) + + fun finishCreateNetworkFlow() + + fun openEditNetwork(payload: AddNetworkPayload.Mode.Edit) + + fun returnToWallet() +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/data/NodeChainIdRepository.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/data/NodeChainIdRepository.kt new file mode 100644 index 0000000..2c7bab4 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/data/NodeChainIdRepository.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_settings_impl.data + +import io.novafoundation.nova.common.data.network.runtime.calls.GetBlockHashRequest +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import io.novafoundation.nova.runtime.ext.evmChainIdFrom +import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnection +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import io.novasama.substrate_sdk_android.wsrpc.executeAsync +import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull +import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo +import java.math.BigInteger +import kotlinx.coroutines.CoroutineScope + +interface NodeChainIdRepository { + + suspend fun requestChainId(): String +} + +class NodeChainIdRepositoryFactory( + private val nodeConnectionFactory: NodeConnectionFactory, + private val web3ApiFactory: Web3ApiFactory +) { + fun create(networkType: NetworkType, nodeUrl: String, coroutineScope: CoroutineScope): NodeChainIdRepository { + val nodeConnection = nodeConnectionFactory.createNodeConnection(nodeUrl, coroutineScope) + + return create(networkType, nodeConnection) + } + + fun create(networkType: NetworkType, nodeConnection: NodeConnection): NodeChainIdRepository { + return when (networkType) { + NetworkType.SUBSTRATE -> substrate(nodeConnection) + + NetworkType.EVM -> evm(nodeConnection) + } + } + + fun substrate(nodeConnection: NodeConnection): SubstrateNodeChainIdRepository { + return SubstrateNodeChainIdRepository(nodeConnection) + } + + fun evm(nodeConnection: NodeConnection): EthereumNodeChainIdRepository { + return EthereumNodeChainIdRepository(nodeConnection, web3ApiFactory) + } +} + +class SubstrateNodeChainIdRepository( + private val nodeConnection: NodeConnection +) : NodeChainIdRepository { + + override suspend fun requestChainId(): String { + val genesisHash = nodeConnection.getSocketService().executeAsync( + GetBlockHashRequest(BigInteger.ZERO), + mapper = pojo().nonNull() + ) + + return genesisHash.removeHexPrefix() + } +} + +class EthereumNodeChainIdRepository( + private val nodeConnection: NodeConnection, + private val web3ApiFactory: Web3ApiFactory +) : NodeChainIdRepository { + + private val web3Api = createWeb3Api() + + override suspend fun requestChainId(): String { + val chainId = web3Api.ethChainId().sendSuspend().chainId + + return evmChainIdFrom(chainId) + } + + private fun createWeb3Api(): Web3Api { + return web3ApiFactory.createWss(nodeConnection.getSocketService()) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureComponent.kt new file mode 100644 index 0000000..ff4c6a0 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureComponent.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.feature_settings_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.presentation.assetIcons.di.AppearanceComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.di.NetworkManagementListComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.di.AddedNetworkListComponent +import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.di.CloudBackupSettingsComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.di.AddNetworkMainComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.di.AddNetworkComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.di.ChainNetworkManagementComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.di.ExistingNetworkListComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.di.PreConfiguredNetworksComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.di.CustomNodeComponent +import io.novafoundation.nova.feature_settings_impl.presentation.settings.di.SettingsComponent +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + SettingsFeatureDependencies::class, + ], + modules = [ + SettingsFeatureModule::class, + ] +) +@FeatureScope +interface SettingsFeatureComponent : SettingsFeatureApi { + + fun settingsComponentFactory(): SettingsComponent.Factory + + fun chainNetworkManagementFactory(): ChainNetworkManagementComponent.Factory + + fun customNodeFactory(): CustomNodeComponent.Factory + + fun networkManagementListFactory(): NetworkManagementListComponent.Factory + + fun addNetworkMainFactory(): AddNetworkMainComponent.Factory + + fun appearanceFactory(): AppearanceComponent.Factory + + fun addNetworkFactory(): AddNetworkComponent.Factory + + fun addedNetworkListFactory(): AddedNetworkListComponent.Factory + + fun existingNetworkListFactory(): ExistingNetworkListComponent.Factory + + fun preConfiguredNetworks(): PreConfiguredNetworksComponent.Factory + + fun backupSettings(): CloudBackupSettingsComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: SettingsRouter, + @BindsInstance syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator, + @BindsInstance changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator, + @BindsInstance restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator, + deps: SettingsFeatureDependencies + ): SettingsFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + AssetsFeatureApi::class, + CurrencyFeatureApi::class, + AccountFeatureApi::class, + WalletConnectFeatureApi::class, + VersionsFeatureApi::class, + PushNotificationsFeatureApi::class, + CloudBackupFeatureApi::class + ] + ) + interface SettingsFeatureDependenciesComponent : SettingsFeatureDependencies +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureDependencies.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureDependencies.kt new file mode 100644 index 0000000..95eada8 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureDependencies.kt @@ -0,0 +1,131 @@ +package io.novafoundation.nova.feature_settings_impl.di + +import android.content.Context +import coil.ImageLoader +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.SafeModeService +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService +import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.addressActions.AddressActionsMixin +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageUseCase +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.WelcomePushNotificationsInteractor +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProviderPool +import io.novafoundation.nova.runtime.repository.ChainNodeRepository +import io.novafoundation.nova.runtime.repository.ChainRepository +import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory + +interface SettingsFeatureDependencies { + + val maskingModeUseCase: MaskingModeUseCase + + val cloudBackupService: CloudBackupService + + val cloudBackupFacade: LocalAccountsCloudBackupFacade + + val bannerVisRepository: BannerVisibilityRepository + + val runtimeProviderPool: RuntimeProviderPool + + val nodeHealthStateTesterFactory: NodeHealthStateTesterFactory + + val chainNodeRepository: ChainNodeRepository + + val nodeConnectionFactory: NodeConnectionFactory + + val web3ApiFactory: Web3ApiFactory + + val validationExecutor: ValidationExecutor + + val preConfiguredChainsRepository: PreConfiguredChainsRepository + + val coinGeckoLinkParser: CoinGeckoLinkParser + + val chainRepository: ChainRepository + + val coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory + + val assetsIconModeRepository: AssetsIconModeRepository + + val accountRepository: AccountRepository + + val accountInteractor: AccountInteractor + + val chainRegistry: ChainRegistry + + val languageUseCase: LanguageUseCase + + val appLinksProvider: AppLinksProvider + + val resourceManager: ResourceManager + + val appVersionProvider: AppVersionProvider + + val selectedAccountUseCase: SelectedAccountUseCase + + val currencyInteractor: CurrencyInteractor + + val safeModeService: SafeModeService + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val walletConnectSessionsUseCase: WalletConnectSessionsUseCase + + val pushNotificationsInteractor: PushNotificationsInteractor + + val welcomePushNotificationsInteractor: WelcomePushNotificationsInteractor + + val imageLoader: ImageLoader + + val metaAccountTypePresentationMapper: MetaAccountTypePresentationMapper + + val addressIconGenerator: AddressIconGenerator + + val walletUiUseCase: WalletUiUseCase + + val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + + val addressActionsMixinFactory: AddressActionsMixin.Factory + + fun biometricServiceFactory(): BiometricServiceFactory + + fun twoFactorVerificationService(): TwoFactorVerificationService + + fun provideListSelectorMixinFactory(): ListSelectorMixin.Factory + + fun actionBottomSheetLauncherFactory(): ActionBottomSheetLauncherFactory + + fun progressDialogMixinFactory(): ProgressDialogMixinFactory + + fun customDialogProvider(): CustomDialogDisplayer.Presentation + + fun context(): Context +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureHolder.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureHolder.kt new file mode 100644 index 0000000..3383849 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureHolder.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_settings_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.feature_cloud_backup_api.di.CloudBackupFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_push_notifications.di.PushNotificationsFeatureApi +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +import javax.inject.Inject + +@ApplicationScope +class SettingsFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator, + private val changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator, + private val restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator, + private val router: SettingsRouter, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerSettingsFeatureComponent_SettingsFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .assetsFeatureApi(getFeature(AssetsFeatureApi::class.java)) + .currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java)) + .versionsFeatureApi(getFeature(VersionsFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .walletConnectFeatureApi(getFeature(WalletConnectFeatureApi::class.java)) + .pushNotificationsFeatureApi(getFeature(PushNotificationsFeatureApi::class.java)) + .cloudBackupFeatureApi(getFeature(CloudBackupFeatureApi::class.java)) + .build() + + return DaggerSettingsFeatureComponent.factory() + .create( + router = router, + syncWalletsBackupPasswordCommunicator = syncWalletsBackupPasswordCommunicator, + changeBackupPasswordCommunicator = changeBackupPasswordCommunicator, + restoreBackupPasswordCommunicator = restoreBackupPasswordCommunicator, + deps = accountFeatureDependencies + ) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureModule.kt new file mode 100644 index 0000000..6b28041 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/di/SettingsFeatureModule.kt @@ -0,0 +1,181 @@ +package io.novafoundation.nova.feature_settings_impl.di + +import dagger.Module +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_settings_impl.domain.CloudBackupSettingsInteractor +import io.novafoundation.nova.feature_settings_impl.domain.RealCloudBackupSettingsInteractor +import dagger.Provides +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory +import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory +import io.novafoundation.nova.feature_settings_impl.domain.AddNetworkInteractor +import io.novafoundation.nova.feature_settings_impl.domain.AppearanceInteractor +import io.novafoundation.nova.feature_settings_impl.domain.CustomNodeInteractor +import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementChainInteractor +import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor +import io.novafoundation.nova.feature_settings_impl.domain.PreConfiguredNetworksInteractor +import io.novafoundation.nova.feature_settings_impl.domain.RealAddNetworkInteractor +import io.novafoundation.nova.feature_settings_impl.domain.RealAppearanceInteractor +import io.novafoundation.nova.feature_settings_impl.domain.RealCustomNodeInteractor +import io.novafoundation.nova.feature_settings_impl.domain.RealNetworkManagementChainInteractor +import io.novafoundation.nova.feature_settings_impl.domain.RealNetworkManagementInteractor +import io.novafoundation.nova.feature_settings_impl.domain.RealPreConfiguredNetworksInteractor +import io.novafoundation.nova.feature_settings_impl.domain.utils.CustomChainFactory +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.RealNetworkListAdapterItemFactory +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.explorer.BlockExplorerLinkFormatter +import io.novafoundation.nova.runtime.explorer.CommonBlockExplorerLinkFormatter +import io.novafoundation.nova.runtime.explorer.EtherscanBlockExplorerLinkFormatter +import io.novafoundation.nova.runtime.explorer.StatescanBlockExplorerLinkFormatter +import io.novafoundation.nova.runtime.explorer.SubscanBlockExplorerLinkFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory +import io.novafoundation.nova.runtime.repository.ChainNodeRepository +import io.novafoundation.nova.runtime.repository.ChainRepository +import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository + +@Module +class SettingsFeatureModule { + + @Provides + @FeatureScope + fun provideCloudBackupSettingsInteractor( + accountRepository: AccountRepository, + cloudBackupService: CloudBackupService, + cloudBackupFacade: LocalAccountsCloudBackupFacade + ): CloudBackupSettingsInteractor { + return RealCloudBackupSettingsInteractor( + accountRepository, + cloudBackupService, + cloudBackupFacade + ) + } + + @Provides + @FeatureScope + fun provideNetworkManagementInteractor( + chainRegistry: ChainRegistry, + bannerVisRepository: BannerVisibilityRepository + ): NetworkManagementInteractor { + return RealNetworkManagementInteractor(chainRegistry, bannerVisRepository) + } + + @Provides + @FeatureScope + fun provideNetworkManagementChainInteractor( + chainRegistry: ChainRegistry, + nodeHealthStateTesterFactory: NodeHealthStateTesterFactory, + chainRepository: ChainRepository, + accountInteractor: AccountInteractor + ): NetworkManagementChainInteractor { + return RealNetworkManagementChainInteractor(chainRegistry, nodeHealthStateTesterFactory, chainRepository, accountInteractor) + } + + @Provides + @FeatureScope + fun provideNetworkListAdapterItemFactory( + resourceManager: ResourceManager + ): NetworkListAdapterItemFactory { + return RealNetworkListAdapterItemFactory(resourceManager) + } + + @Provides + @FeatureScope + fun provideNodeChainIdRepositoryFactory( + nodeConnectionFactory: NodeConnectionFactory, + web3ApiFactory: Web3ApiFactory + ): NodeChainIdRepositoryFactory { + return NodeChainIdRepositoryFactory(nodeConnectionFactory, web3ApiFactory) + } + + @Provides + @FeatureScope + fun provideCustomNodeInteractor( + chainRegistry: ChainRegistry, + chainNodeRepository: ChainNodeRepository, + nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory, + nodeConnectionFactory: NodeConnectionFactory + ): CustomNodeInteractor { + return RealCustomNodeInteractor( + chainRegistry, + chainNodeRepository, + nodeChainIdRepositoryFactory, + nodeConnectionFactory + ) + } + + @Provides + @FeatureScope + fun providePreConfiguredNetworksInteractor( + preConfiguredChainsRepository: PreConfiguredChainsRepository + ): PreConfiguredNetworksInteractor { + return RealPreConfiguredNetworksInteractor( + preConfiguredChainsRepository + ) + } + + @Provides + @FeatureScope + fun provideBlockExplorerLinkFormatter(): BlockExplorerLinkFormatter { + return CommonBlockExplorerLinkFormatter( + listOf( + SubscanBlockExplorerLinkFormatter(), + StatescanBlockExplorerLinkFormatter(), + EtherscanBlockExplorerLinkFormatter() + ) + ) + } + + @Provides + @FeatureScope + fun provideCustomChainFactory( + nodeConnectionFactory: NodeConnectionFactory, + coinGeckoLinkParser: CoinGeckoLinkParser, + blockExplorerLinkFormatter: BlockExplorerLinkFormatter, + nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory + ): CustomChainFactory { + return CustomChainFactory( + nodeConnectionFactory, + nodeChainIdRepositoryFactory, + coinGeckoLinkParser, + blockExplorerLinkFormatter + ) + } + + @Provides + @FeatureScope + fun provideAddNetworkInteractor( + chainRepository: ChainRepository, + chainRegistry: ChainRegistry, + nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory, + coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory, + coinGeckoLinkParser: CoinGeckoLinkParser, + nodeConnectionFactory: NodeConnectionFactory, + customChainFactory: CustomChainFactory + ): AddNetworkInteractor { + return RealAddNetworkInteractor( + chainRepository, + chainRegistry, + nodeChainIdRepositoryFactory, + coinGeckoLinkValidationFactory, + coinGeckoLinkParser, + nodeConnectionFactory, + customChainFactory + ) + } + + @Provides + @FeatureScope + fun provideAppearanceInteractor(assetsIconModeRepository: AssetsIconModeRepository): AppearanceInteractor { + return RealAppearanceInteractor(assetsIconModeRepository) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/AddNetworkInteractor.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/AddNetworkInteractor.kt new file mode 100644 index 0000000..1d8fdd8 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/AddNetworkInteractor.kt @@ -0,0 +1,137 @@ +package io.novafoundation.nova.feature_settings_impl.domain + +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.data.network.runtime.model.firstTokenSymbol +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory +import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory +import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload +import io.novafoundation.nova.feature_settings_impl.domain.utils.CustomChainFactory +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.CustomNetworkValidationSystem +import io.novafoundation.nova.feature_settings_impl.domain.validation.NodeChainIdSingletonHelper +import io.novafoundation.nova.feature_settings_impl.domain.validation.NodeConnectionSingletonHelper +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validateTokenSymbol +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validCoinGeckoLink +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validateNetworkNodeIsAlive +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validateNetworkNotAdded +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.validateNodeSupportedByNetwork +import io.novafoundation.nova.runtime.ext.evmChainIdFrom +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import io.novafoundation.nova.runtime.network.rpc.systemProperties +import io.novafoundation.nova.runtime.repository.ChainRepository +import kotlinx.coroutines.CoroutineScope + +interface AddNetworkInteractor { + + suspend fun createSubstrateNetwork( + payload: CustomNetworkPayload, + prefilledChain: Chain?, + coroutineScope: CoroutineScope + ): Result + + suspend fun createEvmNetwork( + payload: CustomNetworkPayload, + prefilledChain: Chain? + ): Result + + suspend fun updateChain( + chainId: String, + chainName: String, + tokenSymbol: String, + blockExplorerModel: CustomNetworkPayload.BlockExplorer?, + coingeckoLinkUrl: String? + ): Result + + fun getSubstrateValidationSystem(coroutineScope: CoroutineScope): CustomNetworkValidationSystem + + fun getEvmValidationSystem(coroutineScope: CoroutineScope): CustomNetworkValidationSystem +} + +class RealAddNetworkInteractor( + private val chainRepository: ChainRepository, + private val chainRegistry: ChainRegistry, + private val nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory, + private val coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory, + private val coinGeckoLinkParser: CoinGeckoLinkParser, + private val nodeConnectionFactory: NodeConnectionFactory, + private val customChainFactory: CustomChainFactory +) : AddNetworkInteractor { + + override suspend fun createSubstrateNetwork( + payload: CustomNetworkPayload, + prefilledChain: Chain?, + coroutineScope: CoroutineScope + ) = runCatching { + val chain = customChainFactory.createSubstrateChain(payload, prefilledChain, coroutineScope) + + chainRepository.addChain(chain) + } + + override suspend fun createEvmNetwork( + payload: CustomNetworkPayload, + prefilledChain: Chain? + ) = runCatching { + val chain = customChainFactory.createEvmChain(payload, prefilledChain) + + chainRepository.addChain(chain) + } + + override fun getSubstrateValidationSystem(coroutineScope: CoroutineScope): CustomNetworkValidationSystem { + return ValidationSystem { + validCoinGeckoLink(coinGeckoLinkValidationFactory) + + // Using singleton her to receive chain id only one time for all vaildations + val nodeConnectionHelper = getNodeConnectionSingletonHelper(coroutineScope) + val chainIdHelper = getChainIdSingletonHelper(nodeConnectionHelper) + validateNetworkNodeIsAlive { chainIdHelper.getChainId(NetworkType.SUBSTRATE, it.nodeUrl) } + validateNetworkNotAdded(chainRegistry) { chainIdHelper.getChainId() } + validateTokenSymbol { + val systemProperties = nodeConnectionHelper.getNodeConnection() + .getSocketService() + .systemProperties() + + systemProperties.firstTokenSymbol() + } + } + } + + override fun getEvmValidationSystem(coroutineScope: CoroutineScope): CustomNetworkValidationSystem { + return ValidationSystem { + validCoinGeckoLink(coinGeckoLinkValidationFactory) + + validateNetworkNotAdded(chainRegistry) { evmChainIdFrom(it.evmChainId!!) } + + // Using singleton here to receive chain id only one time for all vaildations + val nodeConnectionHelper = getNodeConnectionSingletonHelper(coroutineScope) + val chainIdHelper = getChainIdSingletonHelper(nodeConnectionHelper) + validateNetworkNodeIsAlive { chainIdHelper.getChainId(NetworkType.EVM, it.nodeUrl) } + validateNodeSupportedByNetwork { chainIdHelper.getChainId() } + } + } + + override suspend fun updateChain( + chainId: String, + chainName: String, + tokenSymbol: String, + blockExplorerModel: CustomNetworkPayload.BlockExplorer?, + coingeckoLinkUrl: String? + ): Result { + return runCatching { + val blockExplorer = customChainFactory.getChainExplorer(blockExplorer = blockExplorerModel, chainId = chainId) + val priceId = coingeckoLinkUrl?.let { coinGeckoLinkParser.parse(it).getOrNull()?.priceId } + + chainRepository.editChain(chainId, chainName, tokenSymbol, blockExplorer, priceId) + } + } + + private fun getNodeConnectionSingletonHelper(coroutineScope: CoroutineScope): NodeConnectionSingletonHelper { + return NodeConnectionSingletonHelper(nodeConnectionFactory, coroutineScope) + } + + private fun getChainIdSingletonHelper(helper: NodeConnectionSingletonHelper): NodeChainIdSingletonHelper { + return NodeChainIdSingletonHelper(helper, nodeChainIdRepositoryFactory) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/AppearanceInteractor.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/AppearanceInteractor.kt new file mode 100644 index 0000000..50e0896 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/AppearanceInteractor.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_settings_impl.domain + +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import kotlinx.coroutines.flow.Flow + +interface AppearanceInteractor { + + fun assetIconModeFlow(): Flow + + fun setIconMode(iconMode: AssetIconMode) +} + +class RealAppearanceInteractor( + private val assetsIconModeRepository: AssetsIconModeRepository +) : AppearanceInteractor { + + override fun assetIconModeFlow() = assetsIconModeRepository.assetsIconModeFlow() + + override fun setIconMode(iconMode: AssetIconMode) { + assetsIconModeRepository.setAssetsIconMode(iconMode) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/CloudBackupSettingsInteractor.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/CloudBackupSettingsInteractor.kt new file mode 100644 index 0000000..804a959 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/CloudBackupSettingsInteractor.kt @@ -0,0 +1,141 @@ +package io.novafoundation.nova.feature_settings_impl.domain + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.common.utils.finally +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.feature_account_api.data.cloudBackup.LocalAccountsCloudBackupFacade +import io.novafoundation.nova.feature_account_api.data.cloudBackup.applyNonDestructiveCloudVersionOrThrow +import io.novafoundation.nova.feature_account_api.data.cloudBackup.toMetaAccountType +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.metaAccountTypeComparator +import io.novafoundation.nova.feature_cloud_backup_api.domain.CloudBackupService +import io.novafoundation.nova.feature_cloud_backup_api.domain.fetchAndDecryptExistingBackupWithSavedPassword +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.WriteBackupRequest +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.isNotEmpty +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategy +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.strategy.BackupDiffStrategyFactory +import io.novafoundation.nova.feature_cloud_backup_api.domain.setLastSyncedTimeAsNow +import io.novafoundation.nova.feature_settings_impl.domain.model.CloudBackupChangedAccount +import io.novafoundation.nova.feature_settings_impl.domain.model.CloudBackupChangedAccount.ChangingType +import kotlinx.coroutines.flow.Flow +import java.util.Date + +interface CloudBackupSettingsInteractor { + + suspend fun isSyncCloudBackupEnabled(): Boolean + + fun observeLastSyncedTime(): Flow + + suspend fun syncCloudBackup(): Result + + suspend fun setCloudBackupSyncEnabled(enable: Boolean) + + suspend fun deleteCloudBackup(): Result + + suspend fun writeLocalBackupToCloud(): Result + + suspend fun signInToCloud(): Result + + fun prepareSortedLocalChangesFromDiff(cloudBackupDiff: CloudBackupDiff): GroupedList + + suspend fun applyBackupAccountDiff(cloudBackupDiff: CloudBackupDiff, cloudBackup: CloudBackup): Result +} + +class RealCloudBackupSettingsInteractor( + private val accountRepository: AccountRepository, + private val cloudBackupService: CloudBackupService, + private val cloudBackupFacade: LocalAccountsCloudBackupFacade, +) : CloudBackupSettingsInteractor { + + override fun observeLastSyncedTime(): Flow { + return cloudBackupService.session.lastSyncedTimeFlow() + } + + override suspend fun syncCloudBackup(): Result { + return cloudBackupService.fetchAndDecryptExistingBackupWithSavedPassword() + .mapCatching { cloudBackup -> + cloudBackupFacade.applyNonDestructiveCloudVersionOrThrow(cloudBackup, getCloudBackupDiffStrategy()) + }.flatMap { + if (it.cloudChanges.isNotEmpty()) { + writeLocalBackupToCloud() + } else { + Result.success(Unit) + } + }.finally { + cloudBackupService.session.setLastSyncedTimeAsNow() + }.onSuccess { + cloudBackupService.session.setBackupWasInitialized() + } + } + + override suspend fun setCloudBackupSyncEnabled(enable: Boolean) { + cloudBackupService.session.setSyncingBackupEnabled(enable) + } + + override suspend fun isSyncCloudBackupEnabled(): Boolean { + return cloudBackupService.session.isSyncWithCloudEnabled() + } + + override suspend fun deleteCloudBackup(): Result { + return cloudBackupService.deleteBackup() + } + + override suspend fun writeLocalBackupToCloud(): Result { + return cloudBackupService.session.getSavedPassword() + .flatMap { password -> + val localSnapshot = cloudBackupFacade.fullBackupInfoFromLocalSnapshot() + cloudBackupService.writeBackupToCloud(WriteBackupRequest(localSnapshot, password)) + } + } + + override suspend fun signInToCloud(): Result { + return cloudBackupService.signInToCloud() + } + + override fun prepareSortedLocalChangesFromDiff(cloudBackupDiff: CloudBackupDiff): GroupedList { + val accounts = localAccountChangesFromDiff(cloudBackupDiff.localChanges) + .sortedBy { it.account.name } + return accounts.groupBy { it.account.type.toMetaAccountType() } + .toSortedMap(metaAccountTypeComparator()) + } + + override suspend fun applyBackupAccountDiff(cloudBackupDiff: CloudBackupDiff, cloudBackup: CloudBackup): Result { + return runCatching { + cloudBackupFacade.applyBackupDiff(cloudBackupDiff, cloudBackup) + cloudBackupService.session.setLastSyncedTimeAsNow() + selectMetaAccountIfNeeded() + }.flatMap { + if (cloudBackupDiff.cloudChanges.isNotEmpty()) { + writeLocalBackupToCloud() + } else { + Result.success(Unit) + } + } + } + + private fun localAccountChangesFromDiff(diff: CloudBackupDiff.PerSourceDiff): List { + return diff.added.map { CloudBackupChangedAccount(ChangingType.ADDED, it) } + + diff.modified.map { CloudBackupChangedAccount(ChangingType.CHANGED, it) } + + diff.removed.map { CloudBackupChangedAccount(ChangingType.REMOVED, it) } + } + + private suspend fun selectMetaAccountIfNeeded() { + if (!accountRepository.isAccountSelected()) { + val metaAccounts = accountRepository.getActiveMetaAccounts() + if (metaAccounts.isNotEmpty()) { + accountRepository.selectMetaAccount(metaAccounts.first().id) + } + } + } + + private fun getCloudBackupDiffStrategy(): BackupDiffStrategyFactory { + return if (cloudBackupService.session.cloudBackupWasInitialized()) { + BackupDiffStrategy.syncWithCloud() + } else { + BackupDiffStrategy.importFromCloud() + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/CustomNodeInteractor.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/CustomNodeInteractor.kt new file mode 100644 index 0000000..4f13e10 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/CustomNodeInteractor.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_settings_impl.domain + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory +import io.novafoundation.nova.feature_settings_impl.domain.validation.NodeChainIdSingletonHelper +import io.novafoundation.nova.feature_settings_impl.domain.validation.NodeConnectionSingletonHelper +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.NetworkNodeValidationSystem +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.validateNetworkNodeIsAlive +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.validateNodeNotAdded +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.validateNodeSupportedByNetwork +import io.novafoundation.nova.runtime.ext.networkType +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import io.novafoundation.nova.runtime.repository.ChainNodeRepository +import kotlinx.coroutines.CoroutineScope + +interface CustomNodeInteractor { + + suspend fun getNodeDetails(chainId: String, nodeUrl: String): Result + + suspend fun createNode(chainId: String, url: String, name: String) + + suspend fun updateNode(chainId: String, oldUrl: String, url: String, name: String) + + fun getValidationSystem(coroutineScope: CoroutineScope, skipNodeExistValidation: Boolean): NetworkNodeValidationSystem +} + +class RealCustomNodeInteractor( + private val chainRegistry: ChainRegistry, + private val chainNodeRepository: ChainNodeRepository, + private val nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory, + private val nodeConnectionFactory: NodeConnectionFactory +) : CustomNodeInteractor { + + override suspend fun getNodeDetails(chainId: String, nodeUrl: String): Result { + return runCatching { + chainRegistry.getChain(chainId).nodes + .nodes + .first { it.unformattedUrl == nodeUrl } + } + } + + override suspend fun createNode(chainId: String, url: String, name: String) { + chainNodeRepository.createChainNode(chainId, url, name) + } + + override suspend fun updateNode(chainId: String, oldUrl: String, url: String, name: String) { + chainNodeRepository.saveChainNode(chainId, oldUrl, url, name) + } + + override fun getValidationSystem(coroutineScope: CoroutineScope, skipNodeExistValidation: Boolean): NetworkNodeValidationSystem { + return ValidationSystem { + if (!skipNodeExistValidation) { + validateNodeNotAdded() + } + + val nodeHelper = NodeConnectionSingletonHelper(nodeConnectionFactory, coroutineScope) + val chainIdRequestSingleton = NodeChainIdSingletonHelper(nodeHelper, nodeChainIdRepositoryFactory) + + validateNetworkNodeIsAlive { chainIdRequestSingleton.getChainId(it.chain.networkType(), it.nodeUrl) } + + validateNodeSupportedByNetwork { chainIdRequestSingleton.getChainId() } + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt new file mode 100644 index 0000000..7826013 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt @@ -0,0 +1,163 @@ +package io.novafoundation.nova.feature_settings_impl.domain + +import io.novafoundation.nova.common.utils.combine +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.ext.genesisHash +import io.novafoundation.nova.runtime.ext.isCustomNetwork +import io.novafoundation.nova.runtime.ext.isDisabled +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull +import io.novafoundation.nova.runtime.ext.wssNodes +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Nodes.NodeSelectionStrategy +import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory +import io.novafoundation.nova.runtime.repository.ChainRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.withContext + +class ChainNetworkState( + val chain: Chain, + val networkCanBeDisabled: Boolean, + val nodeHealthStates: List, + val connectingNode: Chain.Node? +) + +class NodeHealthState( + val node: Chain.Node, + val state: State +) { + + sealed interface State { + + object Connecting : State + + class Connected(val ms: Long) : State + + object Disabled : State + } +} + +interface NetworkManagementChainInteractor { + + fun chainStateFlow(chainId: String, coroutineScope: CoroutineScope): Flow + + suspend fun toggleAutoBalance(chainId: String) + + suspend fun selectNode(chainId: String, unformattedNodeUrl: String) + + suspend fun toggleChainEnableState(chainId: String) + + suspend fun deleteNetwork(chainId: String) + + suspend fun deleteNode(chainId: String, unformattedNodeUrl: String) +} + +class RealNetworkManagementChainInteractor( + private val chainRegistry: ChainRegistry, + private val nodeHealthStateTesterFactory: NodeHealthStateTesterFactory, + private val chainRepository: ChainRepository, + private val accountInteractor: AccountInteractor +) : NetworkManagementChainInteractor { + + override fun chainStateFlow(chainId: String, coroutineScope: CoroutineScope): Flow { + return chainRegistry.chainsById + .mapNotNull { it[chainId] } + .flatMapLatest { chain -> + combine(activeNodeFlow(chainId), nodesHealthState(chain, coroutineScope)) { activeNode, nodeHealthStates -> + ChainNetworkState(chain, networkCanBeDisabled(chain), nodeHealthStates, activeNode) + } + } + } + + override suspend fun toggleAutoBalance(chainId: String) { + val chain = chainRegistry.getChain(chainId) + chainRegistry.setWssNodeSelectionStrategy(chainId, chain.nodes.strategyForToggledWssAutoBalance()) + } + + private fun Chain.Nodes.strategyForToggledWssAutoBalance(): NodeSelectionStrategy { + return when (wssNodeSelectionStrategy) { + NodeSelectionStrategy.AutoBalance -> { + val firstNode = wssNodes().first() + NodeSelectionStrategy.SelectedNode(firstNode.unformattedUrl) + } + + is NodeSelectionStrategy.SelectedNode -> NodeSelectionStrategy.AutoBalance + } + } + + override suspend fun selectNode(chainId: String, unformattedNodeUrl: String) { + val strategy = NodeSelectionStrategy.SelectedNode(unformattedNodeUrl) + chainRegistry.setWssNodeSelectionStrategy(chainId, strategy) + } + + override suspend fun toggleChainEnableState(chainId: String) { + val chain = chainRegistry.getChain(chainId) + val connectionState = if (chain.isEnabled) Chain.ConnectionState.DISABLED else Chain.ConnectionState.FULL_SYNC + chainRegistry.changeChainConnectionState(chainId, connectionState) + } + + override suspend fun deleteNetwork(chainId: String) { + val chain = chainRegistry.getChain(chainId) + + require(chain.isCustomNetwork) + + withContext(Dispatchers.Default) { accountInteractor.deleteProxiedMetaAccountsByChain(chainId) } // Delete proxied meta accounts manually + chainRepository.deleteNetwork(chainId) + } + + override suspend fun deleteNode(chainId: String, unformattedNodeUrl: String) { + val chain = chainRegistry.getChain(chainId) + + require(chain.nodes.nodes.size > 1) + + chainRepository.deleteNode(chainId, unformattedNodeUrl) + + if (chain.selectedUnformattedWssNodeUrlOrNull == unformattedNodeUrl) { + chainRegistry.setWssNodeSelectionStrategy(chainId, NodeSelectionStrategy.AutoBalance) + } + } + + private fun networkCanBeDisabled(chain: Chain): Boolean { + return chain.genesisHash != Chain.Geneses.POLKADOT + } + + private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow> { + return chain.nodes.wssNodes().map { + nodeHealthState(chain, it, coroutineScope) + }.combine() + } + + private fun activeNodeFlow(chainId: String): Flow { + val activeConnection = chainRegistry.getConnectionOrNull(chainId) + return activeConnection?.currentUrl?.map { it?.node } ?: flowOf { null } + } + + private fun nodeHealthState(chain: Chain, node: Chain.Node, coroutineScope: CoroutineScope): Flow { + return flow { + if (chain.isDisabled) { + emit(NodeHealthState(node, NodeHealthState.State.Disabled)) + return@flow + } + + emit(NodeHealthState(node, NodeHealthState.State.Connecting)) + + val nodeConnectionDelay = nodeHealthStateTesterFactory.create(chain, node, coroutineScope) + .testNodeHealthState() + .getOrNull() + + nodeConnectionDelay?.let { + emit(NodeHealthState(node, NodeHealthState.State.Connected(it))) + } + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementInteractor.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementInteractor.kt new file mode 100644 index 0000000..32c1f15 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementInteractor.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_settings_impl.domain + +import io.novafoundation.nova.common.data.repository.BannerVisibilityRepository +import io.novafoundation.nova.common.utils.filterList +import io.novafoundation.nova.common.utils.combine +import io.novafoundation.nova.runtime.ext.defaultComparatorFrom +import io.novafoundation.nova.runtime.ext.isCustomNetwork +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.wsrpc.state.SocketStateMachine +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +private const val INTEGRATE_NETWORKS_BANNER_TAG = "INTEGRATE_NETWORKS_BANNER_TAG" + +class NetworkState( + val chain: Chain, + val connectionState: SocketStateMachine.State? +) + +interface NetworkManagementInteractor { + + fun shouldShowBanner(): Flow + + suspend fun hideBanner() + + fun defaultNetworksFlow(): Flow> + + fun addedNetworksFlow(): Flow> +} + +class RealNetworkManagementInteractor( + private val chainRegistry: ChainRegistry, + private val bannerVisibilityRepository: BannerVisibilityRepository +) : NetworkManagementInteractor { + + override fun shouldShowBanner(): Flow { + return bannerVisibilityRepository.shouldShowBannerFlow(INTEGRATE_NETWORKS_BANNER_TAG) + } + + override suspend fun hideBanner() { + bannerVisibilityRepository.hideBanner(INTEGRATE_NETWORKS_BANNER_TAG) + } + + override fun defaultNetworksFlow(): Flow> { + return networksFlow { !it.isCustomNetwork } + } + + override fun addedNetworksFlow(): Flow> { + return networksFlow { it.isCustomNetwork } + } + + private fun networksFlow(filter: (Chain) -> Boolean) = chainRegistry.currentChains + .filterList(filter) + .flatMapLatest { chains -> + connectionsFlow(sortChains(chains)) + } + + private fun connectionsFlow(chains: List): Flow> { + if (chains.isEmpty()) { + return flowOf(emptyList()) + } + + return chains.map { chain -> + val connectionFlow = chainRegistry.getConnectionOrNull(chain.id)?.state ?: flowOf(SocketStateMachine.State.Disconnected) + connectionFlow + .distinctUntilChanged { old, new -> old?.isConnected() == new?.isConnected() } + .map { state -> NetworkState(chain, state) } + }.combine() + } + + private fun sortChains(chains: List): List { + return chains.sortedWith(Chain.defaultComparatorFrom { it }) + } + + // It's enough for us to have 2 states for this implementation: connected and not connected + private fun SocketStateMachine.State.isConnected(): Boolean { + return this is SocketStateMachine.State.Connected + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/PreConfiguredNetworksInteractor.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/PreConfiguredNetworksInteractor.kt new file mode 100644 index 0000000..18cacc1 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/PreConfiguredNetworksInteractor.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_settings_impl.domain + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.LightChain +import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository + +interface PreConfiguredNetworksInteractor { + + suspend fun getPreConfiguredNetworks(): Result> + + suspend fun excludeChains(list: List, chainIds: Set): List + + fun searchNetworks(query: String?, list: List): List + + suspend fun getPreConfiguredNetwork(chainId: String): Result +} + +class RealPreConfiguredNetworksInteractor( + private val preConfiguredChainsRepository: PreConfiguredChainsRepository +) : PreConfiguredNetworksInteractor { + + override suspend fun getPreConfiguredNetworks(): Result> { + return preConfiguredChainsRepository.getPreConfiguredChains() + .map { lightChains -> + lightChains.sortedBy { it.name } + } + } + + override suspend fun excludeChains(list: List, chainIds: Set): List { + return list.filterNot { lightChain -> chainIds.contains(lightChain.id) } + } + + override fun searchNetworks(query: String?, list: List): List { + if (query.isNullOrBlank()) return list + + val loverCaseQuery = query.trim().lowercase() + + return list.filter { lightChain -> lightChain.name.lowercase().startsWith(loverCaseQuery) } + } + + override suspend fun getPreConfiguredNetwork(chainId: String): Result { + return preConfiguredChainsRepository.getPreconfiguredChainById(chainId) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/model/CloudBackupChangedAccount.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/model/CloudBackupChangedAccount.kt new file mode 100644 index 0000000..7565a35 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/model/CloudBackupChangedAccount.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_settings_impl.domain.model + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup + +class CloudBackupChangedAccount(val changingType: ChangingType, val account: CloudBackup.WalletPublicInfo) { + + enum class ChangingType { + ADDED, + REMOVED, + CHANGED + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/model/CustomNetworkPayload.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/model/CustomNetworkPayload.kt new file mode 100644 index 0000000..638b113 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/model/CustomNetworkPayload.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_settings_impl.domain.model + +data class CustomNetworkPayload( + val nodeUrl: String, + val nodeName: String, + val chainName: String, + val tokenSymbol: String, + val evmChainId: Int?, + val blockExplorer: BlockExplorer?, + val coingeckoLinkUrl: String?, + val ignoreChainModifying: Boolean, +) { + + class BlockExplorer( + val name: String, + val url: String + ) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt new file mode 100644 index 0000000..ef91896 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt @@ -0,0 +1,202 @@ +package io.novafoundation.nova.feature_settings_impl.domain.utils + +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.data.network.runtime.model.SystemProperties +import io.novafoundation.nova.common.data.network.runtime.model.firstTokenDecimals +import io.novafoundation.nova.common.utils.DEFAULT_PREFIX +import io.novafoundation.nova.common.utils.Precision +import io.novafoundation.nova.common.utils.asPrecision +import io.novafoundation.nova.common.utils.asTokenSymbol +import io.novafoundation.nova.common.utils.orFalse +import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory +import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload +import io.novafoundation.nova.runtime.explorer.BlockExplorerLinkFormatter +import io.novafoundation.nova.runtime.ext.EVM_DEFAULT_TOKEN_DECIMALS +import io.novafoundation.nova.runtime.ext.evmChainIdFrom +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Nodes.NodeSelectionStrategy +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnection +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import io.novafoundation.nova.runtime.network.rpc.systemProperties +import io.novafoundation.nova.runtime.util.fetchRuntimeSnapshot +import io.novafoundation.nova.runtime.util.isEthereumAddress +import io.novasama.substrate_sdk_android.ss58.SS58Encoder +import kotlinx.coroutines.CoroutineScope + +class CustomChainFactory( + private val nodeConnectionFactory: NodeConnectionFactory, + private val nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory, + private val coinGeckoLinkParser: CoinGeckoLinkParser, + private val blockExplorerLinkFormatter: BlockExplorerLinkFormatter, +) { + + suspend fun createSubstrateChain( + payload: CustomNetworkPayload, + prefilledChain: Chain?, + coroutineScope: CoroutineScope + ): Chain { + val nodeConnection = nodeConnectionFactory.createNodeConnection(payload.nodeUrl, coroutineScope) + val substrateNodeIdRequester = nodeChainIdRepositoryFactory.substrate(nodeConnection) + val runtime = nodeConnection.getSocketService().fetchRuntimeSnapshot() + + val (precision, addressPrefix) = getMainTokenPrecisionAndAddressPrefix(prefilledChain, nodeConnection) + + return createChain( + chainId = substrateNodeIdRequester.requestChainId(), + addressPrefix = addressPrefix, + isEthereumBased = runtime.isEthereumAddress(), + hasSubstrateRuntime = true, + assetDecimals = precision, + assetType = prefilledChain?.utilityAsset?.type ?: Chain.Asset.Type.Native, + payload = payload, + prefilledChain = prefilledChain, + ) + } + + fun createEvmChain( + payload: CustomNetworkPayload, + prefilledChain: Chain? + ): Chain { + val evmChainId = payload.evmChainId!! + val chainId = evmChainIdFrom(evmChainId) + + return createChain( + chainId = chainId, + addressPrefix = evmChainId, + isEthereumBased = true, + hasSubstrateRuntime = false, + assetDecimals = EVM_DEFAULT_TOKEN_DECIMALS.asPrecision(), + assetType = Chain.Asset.Type.EvmNative, + payload = payload, + prefilledChain = prefilledChain, + ) + } + + private fun createChain( + chainId: String, + addressPrefix: Int, + isEthereumBased: Boolean, + hasSubstrateRuntime: Boolean, + assetDecimals: Precision, + assetType: Chain.Asset.Type, + payload: CustomNetworkPayload, + prefilledChain: Chain? + ): Chain { + val priceId = payload.coingeckoLinkUrl?.let { coinGeckoLinkParser.parse(it).getOrNull()?.priceId } + + val prefilledUtilityAsset = prefilledChain?.utilityAsset + + val asset = Chain.Asset( + id = 0, + name = payload.chainName, + enabled = true, + icon = prefilledUtilityAsset?.icon, + priceId = priceId, + chainId = chainId, + symbol = payload.tokenSymbol.asTokenSymbol(), + precision = assetDecimals, + buyProviders = prefilledUtilityAsset?.buyProviders.orEmpty(), + sellProviders = prefilledUtilityAsset?.sellProviders.orEmpty(), + staking = prefilledUtilityAsset?.staking.orEmpty(), + type = assetType, + source = Chain.Asset.Source.MANUAL, + ) + + val explorer = getChainExplorer(payload.blockExplorer, chainId) + + val nodes = Chain.Nodes( + autoBalanceStrategy = prefilledChain?.nodes?.autoBalanceStrategy ?: Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN, + wssNodeSelectionStrategy = NodeSelectionStrategy.AutoBalance, + nodes = createNodeList(chainId, prefilledChain, payload) + ) + + return Chain( + id = chainId, + parentId = prefilledChain?.parentId, + name = payload.chainName, + assets = listOf(asset), + nodes = nodes, + explorers = explorer?.let(::listOf) ?: prefilledChain?.explorers.orEmpty(), + externalApis = prefilledChain?.externalApis.orEmpty(), + icon = prefilledChain?.icon, + addressPrefix = addressPrefix, + legacyAddressPrefix = null, + types = prefilledChain?.types, + isEthereumBased = isEthereumBased, + isTestNet = prefilledChain?.isTestNet.orFalse(), + source = Chain.Source.CUSTOM, + hasSubstrateRuntime = hasSubstrateRuntime, + pushSupport = prefilledChain?.pushSupport.orFalse(), + hasCrowdloans = prefilledChain?.hasCrowdloans.orFalse(), + multisigSupport = prefilledChain?.multisigSupport.orFalse(), + supportProxy = prefilledChain?.supportProxy.orFalse(), + governance = prefilledChain?.governance.orEmpty(), + swap = prefilledChain?.swap.orEmpty(), + customFee = prefilledChain?.customFee.orEmpty(), + connectionState = Chain.ConnectionState.FULL_SYNC, + additional = prefilledChain?.additional + ) + } + + private fun createNodeList( + chainId: String, + prefilledChain: Chain?, + input: CustomNetworkPayload + ): List { + val inputNode = Chain.Node( + chainId = chainId, + unformattedUrl = input.nodeUrl, + name = input.nodeName, + orderId = 0, + isCustom = true, + ) + + val prefilledNodes = prefilledChain?.nodes?.nodes.orEmpty() + val prefilledExceptInput = prefilledNodes.mapNotNull { + val differentFromInput = it.unformattedUrl != inputNode.unformattedUrl + + if (differentFromInput) { + // Consider prefilled nodes as custom + it.copy(isCustom = true) + } else { + null + } + } + + return buildList { + add(inputNode) + addAll(prefilledExceptInput) + } + } + + fun getChainExplorer(blockExplorer: CustomNetworkPayload.BlockExplorer?, chainId: String): Chain.Explorer? { + return blockExplorer?.let { + val links = blockExplorerLinkFormatter.format(it.url) + Chain.Explorer( + chainId = chainId, + name = it.name, + account = links?.account, + extrinsic = links?.extrinsic, + event = links?.event, + ) + } + } + + private suspend fun getSubstrateChainProperties(nodeConnection: NodeConnection): SystemProperties { + return nodeConnection.getSocketService().systemProperties() + } + + private suspend fun getMainTokenPrecisionAndAddressPrefix(chain: Chain?, nodeConnection: NodeConnection): Pair { + if (chain != null) { + val asset = chain.utilityAsset + return Pair(asset.precision, chain.addressPrefix) + } else { + val systemProperties = getSubstrateChainProperties(nodeConnection) + return Pair( + systemProperties.firstTokenDecimals().asPrecision(), + systemProperties.ss58Format ?: systemProperties.SS58Prefix ?: SS58Encoder.DEFAULT_PREFIX.toInt() + ) + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/NodeChainIdSingletonHelper.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/NodeChainIdSingletonHelper.kt new file mode 100644 index 0000000..639f8f3 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/NodeChainIdSingletonHelper.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_settings_impl.domain.validation + +import io.novafoundation.nova.feature_settings_impl.data.NodeChainIdRepositoryFactory +import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class NodeChainIdSingletonHelper( + private val nodeConnectionSingletonHelper: NodeConnectionSingletonHelper, + private val nodeChainIdRepositoryFactory: NodeChainIdRepositoryFactory +) { + + private var chainId: String? = null + private val mutex = Mutex() + + suspend fun getChainId(networkType: NetworkType, nodeUrl: String): String { + return mutex.withLock { + if (chainId == null) { + val nodeConnection = nodeConnectionSingletonHelper.getNodeConnection(nodeUrl) + val nodeChainIdRepository = nodeChainIdRepositoryFactory.create(networkType, nodeConnection) + chainId = nodeChainIdRepository.requestChainId() + chainId!! + } else { + chainId!! + } + } + } + + fun getChainId(): String { + return chainId!! + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/NodeConnectionSingletonHelper.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/NodeConnectionSingletonHelper.kt new file mode 100644 index 0000000..aca7d3c --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/NodeConnectionSingletonHelper.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_settings_impl.domain.validation + +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnection +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class NodeConnectionSingletonHelper( + private val nodeConnectionFactory: NodeConnectionFactory, + private val coroutineScope: CoroutineScope +) { + + private var nodeConnection: NodeConnection? = null + private val mutex = Mutex() + + suspend fun getNodeConnection(nodeUrl: String): NodeConnection { + return mutex.withLock { + if (nodeConnection == null) { + nodeConnection = nodeConnectionFactory.createNodeConnection(nodeUrl, coroutineScope) + nodeConnection!! + } else { + nodeConnection!! + } + } + } + + fun getNodeConnection(): NodeConnection { + return nodeConnection!! + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNetwork/CustomNetworkAssetValidation.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNetwork/CustomNetworkAssetValidation.kt new file mode 100644 index 0000000..0aae248 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNetwork/CustomNetworkAssetValidation.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError + +class CustomNetworkAssetValidation( + private val chainMainAssetSymbolRequester: suspend (P) -> String, + private val symbol: (P) -> String, + private val failure: (P, String) -> F +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val networkSymbol = chainMainAssetSymbolRequester(value) + + return validOrError(networkSymbol == symbol(value)) { + failure(value, networkSymbol) + } + } +} + +fun ValidationSystemBuilder.validateAssetIsMain( + chainMainAssetSymbolRequester: suspend (P) -> String, + symbol: (P) -> String, + failure: (P, String) -> F +) = validate( + CustomNetworkAssetValidation(chainMainAssetSymbolRequester, symbol, failure) +) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNetwork/CustomNetworkValidations.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNetwork/CustomNetworkValidations.kt new file mode 100644 index 0000000..423b9a7 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNetwork/CustomNetworkValidations.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.CoinGeckoLinkValidationFactory +import io.novafoundation.nova.feature_assets.domain.tokens.add.validations.validCoinGeckoLink +import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.nodeSupportedByNetworkValidation +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.validateNetworkNodeIsAlive +import io.novafoundation.nova.runtime.ext.evmChainIdFrom +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +typealias CustomNetworkValidationSystem = ValidationSystem +typealias CustomNetworkValidationSystemBuilder = ValidationSystemBuilder + +sealed interface CustomNetworkFailure { + + class DefaultNetworkAlreadyAdded(val networkName: String) : CustomNetworkFailure + + class CustomNetworkAlreadyAdded(val networkName: String) : CustomNetworkFailure + + object WrongNetwork : CustomNetworkFailure + + object NodeIsNotAlive : CustomNetworkFailure + + object CoingeckoLinkBadFormat : CustomNetworkFailure + + class WrongAsset(val usedSymbol: String, val correctSymbol: String) : CustomNetworkFailure +} + +fun CustomNetworkValidationSystemBuilder.validateNetworkNodeIsAlive( + nodeHealthStateCheckRequest: suspend (CustomNetworkPayload) -> Unit +) = validateNetworkNodeIsAlive( + nodeHealthStateCheckRequest, + nodeUrl = { it.nodeUrl }, + failure = { CustomNetworkFailure.NodeIsNotAlive } +) + +fun CustomNetworkValidationSystemBuilder.validateNodeSupportedByNetwork( + nodeChainIdRequester: suspend (CustomNetworkPayload) -> String +) = nodeSupportedByNetworkValidation( + nodeChainIdRequester = { nodeChainIdRequester(it) }, + originalChainId = { it.evmChainId?.let { evmChainIdFrom(it) } }, + failure = { CustomNetworkFailure.WrongNetwork } +) + +fun CustomNetworkValidationSystemBuilder.validateNetworkNotAdded( + chainRegistry: ChainRegistry, + chainIdRequester: suspend (CustomNetworkPayload) -> String +) = validateNetworkNotAdded( + chainRegistry = chainRegistry, + chainIdRequester = { chainIdRequester(it) }, + ignoreChainModifying = { it.ignoreChainModifying }, + defaultNetworkFailure = { payload, chain -> CustomNetworkFailure.DefaultNetworkAlreadyAdded(chain.name) }, + customNetworkFailure = { payload, chain -> CustomNetworkFailure.CustomNetworkAlreadyAdded(chain.name) } +) + +fun CustomNetworkValidationSystemBuilder.validCoinGeckoLink( + coinGeckoLinkValidationFactory: CoinGeckoLinkValidationFactory +) = validCoinGeckoLink( + coinGeckoLinkValidationFactory = coinGeckoLinkValidationFactory, + optional = true, + link = { it.coingeckoLinkUrl }, + error = { CustomNetworkFailure.CoingeckoLinkBadFormat } +) + +fun CustomNetworkValidationSystemBuilder.validateTokenSymbol( + tokenSymbolRequester: suspend (CustomNetworkPayload) -> String +) = validateAssetIsMain( + chainMainAssetSymbolRequester = tokenSymbolRequester, + symbol = { it.tokenSymbol }, + failure = { payload, correctSymbol -> CustomNetworkFailure.WrongAsset(payload.tokenSymbol, correctSymbol) }, +) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNetwork/NetworkAlreadyAddedValidation.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNetwork/NetworkAlreadyAddedValidation.kt new file mode 100644 index 0000000..2b5b4e1 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNetwork/NetworkAlreadyAddedValidation.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainsById + +class NetworkAlreadyAddedValidation( + private val chainRegistry: ChainRegistry, + private val chainIdRequester: suspend (P) -> String, + private val ignoreChainModifying: (P) -> Boolean, + private val defaultNetworkFailure: (P, Chain) -> F, + private val customNetworkWarning: (P, Chain) -> F +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val chainId = chainIdRequester(value) + + val chain = chainRegistry.chainsById()[chainId] + if (chain != null && !ignoreChainModifying(value)) { + return when (chain.source) { + Chain.Source.DEFAULT -> validationError(defaultNetworkFailure(value, chain)) + Chain.Source.CUSTOM -> validationError(customNetworkWarning(value, chain)) + } + } + + return valid() + } +} + +fun ValidationSystemBuilder.validateNetworkNotAdded( + chainRegistry: ChainRegistry, + chainIdRequester: suspend (P) -> String, + ignoreChainModifying: (P) -> Boolean, + defaultNetworkFailure: (P, Chain) -> F, + customNetworkFailure: (P, Chain) -> F +) = validate( + NetworkAlreadyAddedValidation(chainRegistry, chainIdRequester, ignoreChainModifying, defaultNetworkFailure, customNetworkFailure) +) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NetworkNodeValidations.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NetworkNodeValidations.kt new file mode 100644 index 0000000..75b15ab --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NetworkNodeValidations.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_settings_impl.domain.validation.customNode + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +typealias NetworkNodeValidationSystem = ValidationSystem +typealias NetworkNodeValidationSystemBuilder = ValidationSystemBuilder + +class NetworkNodePayload( + val chain: Chain, + val nodeUrl: String +) + +sealed interface NetworkNodeFailure { + + class NodeAlreadyExists(val node: Chain.Node) : NetworkNodeFailure + + class WrongNetwork(val chain: Chain) : NetworkNodeFailure + + object NodeIsNotAlive : NetworkNodeFailure +} + +fun NetworkNodeValidationSystemBuilder.validateNetworkNodeIsAlive( + nodeHealthStateCheckRequest: suspend (NetworkNodePayload) -> Unit +) = validateNetworkNodeIsAlive( + nodeHealthStateCheckRequest, + nodeUrl = { it.nodeUrl }, + failure = { NetworkNodeFailure.NodeIsNotAlive } +) + +fun NetworkNodeValidationSystemBuilder.validateNodeSupportedByNetwork( + nodeChainIdRequester: suspend (NetworkNodePayload) -> String +) = nodeSupportedByNetworkValidation( + nodeChainIdRequester = { nodeChainIdRequester(it) }, + originalChainId = { it.chain.id }, + failure = { NetworkNodeFailure.WrongNetwork(it.chain) } +) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NodeAlreadyAddedValidation.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NodeAlreadyAddedValidation.kt new file mode 100644 index 0000000..55b36d0 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NodeAlreadyAddedValidation.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_settings_impl.domain.validation.customNode + +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError + +class NodeAlreadyAddedValidation : Validation { + + override suspend fun validate(value: NetworkNodePayload): ValidationStatus { + try { + val node = value.chain.nodes + .nodes + .firstOrNull { it.unformattedUrl == value.nodeUrl.normalize() } + + if (node != null) { + return validationError(NetworkNodeFailure.NodeAlreadyExists(node)) + } + + return valid() + } catch (e: Exception) { + return validationError(NetworkNodeFailure.NodeIsNotAlive) + } + } + + private fun String.normalize(): String { + return Urls.normalizePath(this) + } +} + +fun ValidationSystemBuilder.validateNodeNotAdded() = validate( + NodeAlreadyAddedValidation() +) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NodeIsAliveValidation.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NodeIsAliveValidation.kt new file mode 100644 index 0000000..a7a5d67 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NodeIsAliveValidation.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_settings_impl.domain.validation.customNode + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import java.lang.Exception +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +class NetworkNodeIsAliveValidation( + private val nodeHealthStateCheckRequest: suspend (P) -> Unit, + private val nodeUrl: (P) -> String, + private val failure: (P) -> F +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + return try { + val url = nodeUrl(value) + require(url.startsWith("wss://") || url.startsWith("ws://")) + + withTimeout(10.seconds) { nodeHealthStateCheckRequest(value) } + + valid() + } catch (e: Exception) { + validationError(failure(value)) + } + } +} + +fun ValidationSystemBuilder.validateNetworkNodeIsAlive( + nodeHealthStateCheckRequest: suspend (P) -> Unit, + nodeUrl: (P) -> String, + failure: (P) -> F +) = validate( + NetworkNodeIsAliveValidation(nodeHealthStateCheckRequest, nodeUrl, failure) +) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NodeSupportedByNetworkValidation.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NodeSupportedByNetworkValidation.kt new file mode 100644 index 0000000..0ad4d4a --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/validation/customNode/NodeSupportedByNetworkValidation.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_settings_impl.domain.validation.customNode + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError + +class NodeSupportedByNetworkValidation( + private val nodeChainIdRequester: suspend (P) -> String, + private val originalChainId: (P) -> String?, + private val failure: (P) -> F +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val nodeChainId = nodeChainIdRequester(value) + + return validOrError(nodeChainId == originalChainId(value)) { + failure(value) + } + } +} + +fun ValidationSystemBuilder.nodeSupportedByNetworkValidation( + nodeChainIdRequester: suspend (P) -> String, + originalChainId: (P) -> String?, + failure: (P) -> F +) = validate( + NodeSupportedByNetworkValidation( + nodeChainIdRequester, + originalChainId, + failure + ) +) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/AppearanceFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/AppearanceFragment.kt new file mode 100644 index 0000000..49ad272 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/AppearanceFragment.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.assetIcons + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentAppearanceBinding +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent + +class AppearanceFragment : BaseFragment() { + + override fun createBinding() = FragmentAppearanceBinding.inflate(layoutInflater) + + override fun initViews() { + binder.appearanceToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.appearanceWhiteButton.setOnClickListener { viewModel.selectWhiteIcon() } + binder.appearanceColoredButton.setOnClickListener { viewModel.selectColoredIcon() } + + binder.appearanceWhiteButton.background = getRippleDrawable(cornerSizeInDp = 10) + binder.appearanceColoredButton.background = getRippleDrawable(cornerSizeInDp = 10) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .appearanceFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: AppearanceViewModel) { + viewModel.assetIconsStateFlow.observe { + binder.appearanceWhiteIcon.isSelected = it.whiteActive + binder.appearanceWhiteText.isSelected = it.whiteActive + + binder.appearanceColoredIcon.isSelected = it.coloredActive + binder.appearanceColoredText.isSelected = it.coloredActive + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/AppearanceViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/AppearanceViewModel.kt new file mode 100644 index 0000000..9f2c39e --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/AppearanceViewModel.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.assetIcons + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.model.AssetIconMode +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.AppearanceInteractor +import kotlinx.coroutines.flow.map + +class AssetIconsStateModel( + val whiteActive: Boolean, + val coloredActive: Boolean +) + +class AppearanceViewModel( + private val interactor: AppearanceInteractor, + private val router: SettingsRouter +) : BaseViewModel() { + + val assetIconsStateFlow = interactor.assetIconModeFlow() + .map { + AssetIconsStateModel( + whiteActive = it == AssetIconMode.WHITE, + coloredActive = it == AssetIconMode.COLORED + ) + } + + fun selectWhiteIcon() { + interactor.setIconMode(AssetIconMode.WHITE) + router.returnToWallet() + } + + fun selectColoredIcon() { + interactor.setIconMode(AssetIconMode.COLORED) + router.returnToWallet() + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/di/AppearanceComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/di/AppearanceComponent.kt new file mode 100644 index 0000000..643bca7 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/di/AppearanceComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.assetIcons.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.assetIcons.AppearanceFragment + +@Subcomponent( + modules = [ + AppearanceModule::class + ] +) +@ScreenScope +interface AppearanceComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): AppearanceComponent + } + + fun inject(fragment: AppearanceFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/di/AppearanceModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/di/AppearanceModule.kt new file mode 100644 index 0000000..09f54fe --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/assetIcons/di/AppearanceModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.assetIcons.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.AppearanceInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.assetIcons.AppearanceViewModel + +@Module(includes = [ViewModelModule::class]) +class AppearanceModule { + + @Provides + @IntoMap + @ViewModelKey(AppearanceViewModel::class) + fun provideViewModel( + interactor: AppearanceInteractor, + router: SettingsRouter + ): ViewModel { + return AppearanceViewModel( + interactor, + router + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AppearanceViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AppearanceViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/backupDiff/CloudBackupDiffBottomSheet.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/backupDiff/CloudBackupDiffBottomSheet.kt new file mode 100644 index 0000000..c287277 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/backupDiff/CloudBackupDiffBottomSheet.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff + +import android.content.Context +import android.view.LayoutInflater +import io.novafoundation.nova.common.utils.addColor +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.view.bottomSheet.BaseBottomSheet +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentBackupDiffBinding +import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter.CloudBackupDiffAdapter + +class CloudBackupDiffBottomSheet( + context: Context, + private val payload: Payload, + onApply: (CloudBackupDiff, CloudBackup) -> Unit, +) : BaseBottomSheet(context) { + + override val binder: FragmentBackupDiffBinding = FragmentBackupDiffBinding.inflate(LayoutInflater.from(context)) + + class Payload(val diffList: List, val cloudBackupDiff: CloudBackupDiff, val cloudBackup: CloudBackup) + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + CloudBackupDiffAdapter() + } + + init { + binder.backupDiffSubtitle.text = buildSubtitleText() + binder.backupDiffList.adapter = adapter + + binder.backupDiffCancel.setOnClickListener { dismiss() } + binder.backupDiffApply.setOnClickListener { + onApply(payload.cloudBackupDiff, payload.cloudBackup) + dismiss() + } + adapter.submitList(payload.diffList) + } + + private fun buildSubtitleText(): CharSequence { + return SpannableFormatter.format( + context.getString(R.string.backup_diff_subtitle), + context.getString(R.string.backup_diff_subtitle_highlighted).addColor(context.getColor(R.color.text_primary)) + ) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/backupDiff/adapter/AccountDiffRVItem.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/backupDiff/adapter/AccountDiffRVItem.kt new file mode 100644 index 0000000..888990b --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/backupDiff/adapter/AccountDiffRVItem.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter + +import android.graphics.drawable.Drawable +import io.novafoundation.nova.common.view.ChipLabelModel + +interface CloudBackupDiffRVItem + +class CloudBackupDiffGroupRVItem( + val chipModel: ChipLabelModel +) : CloudBackupDiffRVItem + +class AccountDiffRVItem( + val id: String, + val icon: Drawable, + val title: String, + val state: String, + val stateColorRes: Int, + val stateIconRes: Int?, +) : CloudBackupDiffRVItem diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/backupDiff/adapter/CloudBackupDiffAdapter.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/backupDiff/adapter/CloudBackupDiffAdapter.kt new file mode 100644 index 0000000..e4a3bf8 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/backupDiff/adapter/CloudBackupDiffAdapter.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter + +import android.view.ViewGroup +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.view.ChipLabelView +import io.novafoundation.nova.feature_account_api.presenatation.account.listing.holders.AccountChipHolder +import io.novafoundation.nova.feature_settings_impl.databinding.ItemCloudBackupAccountDiffBinding + +class CloudBackupDiffAdapter : GroupedListAdapter(BackupAccountDiffCallback()) { + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return AccountChipHolder(ChipLabelView(parent.context)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return AccountDiffHolder(ItemCloudBackupAccountDiffBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: CloudBackupDiffGroupRVItem) { + (holder as AccountChipHolder).bind(group.chipModel) + } + + override fun bindChild(holder: GroupedListHolder, child: AccountDiffRVItem) { + (holder as AccountDiffHolder).bind(child) + } +} + +class AccountDiffHolder(private val binder: ItemCloudBackupAccountDiffBinding) : GroupedListHolder(binder.root) { + + fun bind(accountModel: AccountDiffRVItem) = with(binder) { + itemCloudBackupAccountDiffIcon.setImageDrawable(accountModel.icon) + itemCloudBackupAccountDiffName.text = accountModel.title + itemCloudBackupAccountDiffState.text = accountModel.state + itemCloudBackupAccountDiffState.setTextColor(root.context.getColor(accountModel.stateColorRes)) + itemCloudBackupAccountDiffState.setDrawableStart(accountModel.stateIconRes, paddingInDp = 4) + } +} + +private class BackupAccountDiffCallback : BaseGroupedDiffCallback(CloudBackupDiffGroupRVItem::class.java) { + + override fun areGroupItemsTheSame(oldItem: CloudBackupDiffGroupRVItem, newItem: CloudBackupDiffGroupRVItem): Boolean { + return oldItem.chipModel.title == newItem.chipModel.title + } + + override fun areGroupContentsTheSame(oldItem: CloudBackupDiffGroupRVItem, newItem: CloudBackupDiffGroupRVItem): Boolean { + return oldItem.chipModel.title == newItem.chipModel.title + } + + override fun areChildItemsTheSame(oldItem: AccountDiffRVItem, newItem: AccountDiffRVItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areChildContentsTheSame(oldItem: AccountDiffRVItem, newItem: AccountDiffRVItem): Boolean { + return oldItem.id == newItem.id + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/BackupSettingsFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/BackupSettingsFragment.kt new file mode 100644 index 0000000..9483513 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/BackupSettingsFragment.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog +import io.novafoundation.nova.common.mixin.impl.setupCustomDialogDisplayer +import io.novafoundation.nova.common.utils.progress.observeProgressDialog +import io.novafoundation.nova.common.view.bottomSheet.action.observeActionBottomSheet +import io.novafoundation.nova.common.view.input.selector.setupListSelectorMixin +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentBackupSettingsBinding +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent +import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.CloudBackupDiffBottomSheet + +class BackupSettingsFragment : BaseFragment() { + + override fun createBinding() = FragmentBackupSettingsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.backupSettingsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.backupStateView.setOnClickListener { viewModel.cloudBackupManageClicked() } + binder.backupStateView.setProblemClickListener() { viewModel.problemButtonClicked() } + binder.backupSettingsSwitcher.setOnClickListener { viewModel.backupSwitcherClicked() } + binder.backupSettingsManualBtn.setOnClickListener { viewModel.manualBackupClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .backupSettings() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: BackupSettingsViewModel) { + setupCustomDialogDisplayer(viewModel) + observeProgressDialog(viewModel.progressDialogMixin) + observeActionBottomSheet(viewModel) + setupListSelectorMixin(viewModel.listSelectorMixin) + setupConfirmationDialog(R.style.AccentNegativeAlertDialogTheme_Reversed, viewModel.negativeConfirmationAwaitableAction) + setupConfirmationDialog(R.style.AccentAlertDialogTheme, viewModel.neutralConfirmationAwaitableAction) + + viewModel.cloudBackupEnabled.observe { enabled -> + binder.backupSettingsSwitcher.setChecked(enabled) + } + + viewModel.cloudBackupStateModel.observe { state -> + binder.backupStateView.setState(state) + } + + viewModel.cloudBackupChangesLiveData.observeEvent { + showBackupDiffBottomSheet(it) + } + } + + private fun showBackupDiffBottomSheet(payload: CloudBackupDiffBottomSheet.Payload) { + val bottomSheet = CloudBackupDiffBottomSheet( + requireContext(), + payload, + onApply = { diff, cloudBackup -> viewModel.applyBackupDestructiveChanges(diff, cloudBackup) } + ) + + bottomSheet.show() + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/BackupSettingsViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/BackupSettingsViewModel.kt new file mode 100644 index 0000000..21f12de --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/BackupSettingsViewModel.kt @@ -0,0 +1,431 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.base.showError +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.displayDialogOrNothing +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.common.utils.progress.startProgress +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncher +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordRequester +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordRequester +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordRequester +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CannotApplyNonDestructiveDiff +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.CloudBackupNotFound +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.FetchBackupError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.InvalidBackupPasswordError +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.errors.PasswordNotSaved +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchCloudBackupChangesAction +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchCorruptedBackupFoundAction +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchDeleteBackupAction +import io.novafoundation.nova.feature_cloud_backup_api.presenter.action.launchDeprecatedPasswordAction +import io.novafoundation.nova.feature_cloud_backup_api.presenter.confirmation.awaitBackupDestructiveChangesConfirmation +import io.novafoundation.nova.feature_cloud_backup_api.presenter.confirmation.awaitDeleteBackupConfirmation +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.handlers.showCloudBackupUnknownError +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapCloudBackupSyncFailed +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapDeleteBackupFailureToUi +import io.novafoundation.nova.feature_cloud_backup_api.presenter.errorHandling.mapWriteBackupFailureToUi +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.CloudBackupSettingsInteractor +import io.novafoundation.nova.feature_settings_impl.domain.model.CloudBackupChangedAccount +import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.CloudBackupDiffBottomSheet +import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter.AccountDiffRVItem +import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.backupDiff.adapter.CloudBackupDiffGroupRVItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class BackupSettingsViewModel( + private val resourceManager: ResourceManager, + private val router: SettingsRouter, + private val accountInteractor: AccountInteractor, + private val cloudBackupSettingsInteractor: CloudBackupSettingsInteractor, + private val syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator, + private val changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator, + private val restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator, + private val actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + private val accountTypePresentationMapper: MetaAccountTypePresentationMapper, + private val walletUiUseCase: WalletUiUseCase, + private val progressDialogMixinFactory: ProgressDialogMixinFactory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + listSelectorMixinFactory: ListSelectorMixin.Factory, + customDialogProvider: CustomDialogDisplayer.Presentation +) : BaseViewModel(), + ActionBottomSheetLauncher by actionBottomSheetLauncherFactory.create(), + CustomDialogDisplayer.Presentation by customDialogProvider { + + val progressDialogMixin = progressDialogMixinFactory.create() + + val negativeConfirmationAwaitableAction = actionAwaitableMixinFactory.confirmingAction() + + val neutralConfirmationAwaitableAction = actionAwaitableMixinFactory.confirmingAction() + + val listSelectorMixin = listSelectorMixinFactory.create(viewModelScope) + + private val _isSyncing = MutableStateFlow(false) + val isSyncing: Flow = _isSyncing + + private val syncedState = MutableStateFlow(BackupSyncOutcome.Ok) + + private val lastSync = cloudBackupSettingsInteractor.observeLastSyncedTime() + + val cloudBackupEnabled = MutableStateFlow(false) + + val cloudBackupStateModel: Flow = combine( + cloudBackupEnabled, + _isSyncing, + syncedState, + lastSync + ) { backupEnabled, syncingInProgress, state, lastSync -> + mapCloudBackupStateModel(resourceManager, backupEnabled, syncingInProgress, state, lastSync) + } + + private val _cloudBackupChangesLiveData = MutableLiveData>() + val cloudBackupChangesLiveData = _cloudBackupChangesLiveData + + init { + syncCloudBackupState() + observeRequesterResults() + + launch { + cloudBackupEnabled.value = cloudBackupSettingsInteractor.isSyncCloudBackupEnabled() + } + } + + fun backClicked() { + router.back() + } + + fun backupSwitcherClicked() { + launch { + if (cloudBackupSettingsInteractor.isSyncCloudBackupEnabled()) { + cloudBackupEnabled.value = false + cloudBackupSettingsInteractor.setCloudBackupSyncEnabled(false) + } else { + cloudBackupEnabled.value = true + + syncCloudBackupOnSwitcher() + } + } + } + + fun manualBackupClicked() { + launch { + if (accountInteractor.hasSecretsAccounts()) { + router.openManualBackup() + } else { + showError( + resourceManager.getString(R.string.backup_settings_no_wallets_error_title), + resourceManager.getString(R.string.backup_settings_no_wallets_error_message) + ) + } + } + } + + fun cloudBackupManageClicked() { + if (_isSyncing.value) return + + when (syncedState.value) { + BackupSyncOutcome.StorageAuthFailed -> return + + BackupSyncOutcome.EmptyPassword, + BackupSyncOutcome.UnknownPassword, + BackupSyncOutcome.CorruptedBackup -> { + listSelectorMixin.showSelector( + R.string.manage_cloud_backup, + listOf(manageBackupDeleteBackupItem()) + ) + } + + BackupSyncOutcome.Ok, + is BackupSyncOutcome.DestructiveDiff, + BackupSyncOutcome.UnknownError -> { + listSelectorMixin.showSelector( + R.string.manage_cloud_backup, + listOf(manageBackupChangePasswordItem(), manageBackupDeleteBackupItem()) + ) + } + } + } + + fun problemButtonClicked() { + when (val value = syncedState.value) { + BackupSyncOutcome.EmptyPassword, + BackupSyncOutcome.UnknownPassword -> openRestorePassword() + + BackupSyncOutcome.CorruptedBackup -> showCorruptedBackupActionDialog() + is BackupSyncOutcome.DestructiveDiff -> openCloudBackupDiffScreen(value.cloudBackupDiff, value.cloudBackup) + BackupSyncOutcome.StorageAuthFailed -> initSignInToCloud() + + BackupSyncOutcome.UnknownError -> showCloudBackupUnknownError(resourceManager) + + BackupSyncOutcome.Ok -> {} + } + } + + fun applyBackupDestructiveChanges(cloudBackupDiff: CloudBackupDiff, cloudBackup: CloudBackup) { + launch { + neutralConfirmationAwaitableAction.awaitBackupDestructiveChangesConfirmation(resourceManager) + + _isSyncing.value = true + + cloudBackupSettingsInteractor.applyBackupAccountDiff(cloudBackupDiff, cloudBackup) + .onSuccess { syncedState.value = BackupSyncOutcome.Ok } + .onFailure { showError(mapWriteBackupFailureToUi(resourceManager, it)) } + + _isSyncing.value = false + } + } + + private fun openDestructiveDiffAction(diff: CloudBackupDiff, cloudBackup: CloudBackup) { + launchCloudBackupChangesAction(resourceManager) { + openCloudBackupDiffScreen(diff, cloudBackup) + } + } + + private fun openCloudBackupDiffScreen(diff: CloudBackupDiff, cloudBackup: CloudBackup) { + launch { + val sortedDiff = cloudBackupSettingsInteractor.prepareSortedLocalChangesFromDiff(diff) + val cloudBackupChangesList = sortedDiff.toListWithHeaders( + keyMapper = { type, _ -> accountTypePresentationMapper.mapTypeToChipLabel(type)?.let { CloudBackupDiffGroupRVItem(it) } }, + valueMapper = { mapMetaAccountDiffToUi(it) } + ) + + _cloudBackupChangesLiveData.value = CloudBackupDiffBottomSheet.Payload(cloudBackupChangesList, diff, cloudBackup).event() + } + } + + private fun Throwable.toEnableBackupSyncState(): BackupSyncOutcome { + return when (this) { + is PasswordNotSaved -> BackupSyncOutcome.EmptyPassword + is InvalidBackupPasswordError -> BackupSyncOutcome.UnknownPassword + is CannotApplyNonDestructiveDiff -> BackupSyncOutcome.DestructiveDiff(cloudBackupDiff, cloudBackup) + // not found backup is ok when we enable backup and when we start initial sync since we will create a new backup + is FetchBackupError.BackupNotFound -> BackupSyncOutcome.Ok + is FetchBackupError.CorruptedBackup -> BackupSyncOutcome.CorruptedBackup + is FetchBackupError.Other -> BackupSyncOutcome.UnknownError + is FetchBackupError.AuthFailed -> BackupSyncOutcome.StorageAuthFailed + else -> BackupSyncOutcome.UnknownError + } + } + + private fun syncCloudBackupState() = launch { + if (cloudBackupSettingsInteractor.isSyncCloudBackupEnabled()) { + runSyncWithProgress { result -> + result.onFailure { throwable -> + if (throwable is CloudBackupNotFound) { + writeBackupToCloudAndSync() + } + } + } + } + } + + private suspend fun writeBackupToCloudAndSync() { + cloudBackupSettingsInteractor.writeLocalBackupToCloud() + .flatMap { + cloudBackupSettingsInteractor.syncCloudBackup() + }.handleSyncBackupResult() + } + + private suspend fun syncCloudBackupOnSwitcher() = runSyncWithProgress { result -> + result.onSuccess { cloudBackupSettingsInteractor.setCloudBackupSyncEnabled(true) } + .onFailure { throwable -> + + when (throwable) { + is CloudBackupNotFound -> { + syncWalletsBackupPasswordCommunicator.openRequest(SyncWalletsBackupPasswordRequester.EmptyRequest) + } + + else -> { + cloudBackupSettingsInteractor.setCloudBackupSyncEnabled(true) + } + } + } + } + + private suspend inline fun runSyncWithProgress(action: (Result) -> Unit) { + _isSyncing.value = true + + val result = cloudBackupSettingsInteractor.syncCloudBackup() + .handleSyncBackupResult() + + action(result) + + _isSyncing.value = false + } + + private fun Result.handleSyncBackupResult(): Result { + return onSuccess { syncedState.value = BackupSyncOutcome.Ok; } + .onFailure { throwable -> + val state = throwable.toEnableBackupSyncState() + syncedState.value = state + if (state == BackupSyncOutcome.EmptyPassword) { + openRestorePassword() + } else { + handleBackupError(throwable) + } + } + } + + private fun handleBackupError(throwable: Throwable) { + val payload = mapCloudBackupSyncFailed( + resourceManager, + throwable, + onDestructiveBackupFound = ::openDestructiveDiffAction, + onPasswordDeprecated = ::showPasswordDeprecatedActionDialog, + onCorruptedBackup = ::showCorruptedBackupActionDialog, + initSignIn = ::initSignInToCloud + ) + + displayDialogOrNothing(payload) + } + + private fun manageBackupChangePasswordItem(): ListSelectorMixin.Item { + return ListSelectorMixin.Item( + R.drawable.ic_pin, + R.color.icon_primary, + R.string.common_change_password, + R.color.text_primary, + ::onChangePasswordClicked + ) + } + + private fun manageBackupDeleteBackupItem(): ListSelectorMixin.Item { + return ListSelectorMixin.Item( + R.drawable.ic_delete, + R.color.icon_negative, + R.string.backup_settings_delete_backup, + R.color.text_negative, + ::onDeleteBackupClicked + ) + } + + private fun onChangePasswordClicked() { + changeBackupPasswordCommunicator.openRequest(ChangeBackupPasswordRequester.EmptyRequest) + } + + private fun onDeleteBackupClicked() { + launchDeleteBackupAction(resourceManager, ::confirmCloudBackupDelete) + } + + private fun observeRequesterResults() { + changeBackupPasswordCommunicator.responseFlow.syncBackupOnEach() + restoreBackupPasswordCommunicator.responseFlow.syncBackupOnEach() + syncWalletsBackupPasswordCommunicator.responseFlow.syncBackupOnEach() + + syncWalletsBackupPasswordCommunicator.responseFlow.onEach { response -> + cloudBackupEnabled.value = cloudBackupSettingsInteractor.isSyncCloudBackupEnabled() + syncedState.value = BackupSyncOutcome.Ok + }.launchIn(this) + } + + private fun Flow.syncBackupOnEach() { + this.onEach { + syncCloudBackupState() + } + .launchIn(this@BackupSettingsViewModel) + } + + private fun showPasswordDeprecatedActionDialog() { + launchDeprecatedPasswordAction(resourceManager, ::openRestorePassword) + } + + private fun showCorruptedBackupActionDialog() { + launchCorruptedBackupFoundAction(resourceManager, ::confirmCloudBackupDelete) + } + + private fun openRestorePassword() { + restoreBackupPasswordCommunicator.openRequest(RestoreBackupPasswordRequester.EmptyRequest) + } + + private fun initSignInToCloud() { + launch { + cloudBackupSettingsInteractor.signInToCloud() + .onSuccess { syncCloudBackupState() } + } + } + + private fun confirmCloudBackupDelete() { + launch { + negativeConfirmationAwaitableAction.awaitDeleteBackupConfirmation(resourceManager) + + progressDialogMixin.startProgress(R.string.deleting_backup_progress) { + runDeleteBackup() + } + } + } + + private suspend fun runDeleteBackup() { + cloudBackupSettingsInteractor.deleteCloudBackup() + .onSuccess { + cloudBackupSettingsInteractor.setCloudBackupSyncEnabled(false) + cloudBackupEnabled.value = false + } + .onFailure { throwable -> + val titleAndMessage = mapDeleteBackupFailureToUi(resourceManager, throwable) + titleAndMessage?.let { showError(it) } + } + } + + private suspend fun mapMetaAccountDiffToUi(changedAccount: CloudBackupChangedAccount): AccountDiffRVItem { + return with(changedAccount) { + val (stateText, stateColorRes, stateIconRes) = mapChangingTypeToUi(changingType) + val walletIcon = walletUiUseCase.walletIcon( + account.substrateAccountId, + account.ethereumAddress, + account.chainAccounts.map(CloudBackup.WalletPublicInfo.ChainAccountInfo::accountId) + ) + + AccountDiffRVItem( + id = account.walletId, + icon = walletIcon, + title = account.name, + state = stateText, + stateColorRes = stateColorRes, + stateIconRes = stateIconRes + ) + } + } + + private fun mapChangingTypeToUi(type: CloudBackupChangedAccount.ChangingType): Triple { + return when (type) { + CloudBackupChangedAccount.ChangingType.ADDED -> Triple(resourceManager.getString(R.string.state_new), R.color.text_secondary, null) + CloudBackupChangedAccount.ChangingType.REMOVED -> Triple( + resourceManager.getString(R.string.state_removed), + R.color.text_negative, + R.drawable.ic_red_cross + ) + + CloudBackupChangedAccount.ChangingType.CHANGED -> Triple( + resourceManager.getString(R.string.state_changed), + R.color.text_warning, + R.drawable.ic_warning_filled + ) + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/BackupSyncOutcome.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/BackupSyncOutcome.kt new file mode 100644 index 0000000..f4fff4b --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/BackupSyncOutcome.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings + +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.diff.CloudBackupDiff + +sealed class BackupSyncOutcome { + + object Ok : BackupSyncOutcome() + + object EmptyPassword : BackupSyncOutcome() + + object UnknownPassword : BackupSyncOutcome() + + class DestructiveDiff(val cloudBackupDiff: CloudBackupDiff, val cloudBackup: CloudBackup) : BackupSyncOutcome() + + object StorageAuthFailed : BackupSyncOutcome() + + object CorruptedBackup : BackupSyncOutcome() + + object UnknownError : BackupSyncOutcome() +} + +fun BackupSyncOutcome.isError(): Boolean { + return this != BackupSyncOutcome.Ok +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/CloudBackupStateMapper.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/CloudBackupStateMapper.kt new file mode 100644 index 0000000..e6079f1 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/CloudBackupStateMapper.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.formatDateSinceEpoch +import io.novafoundation.nova.common.utils.formatting.formatTime +import io.novafoundation.nova.feature_settings_impl.R +import java.util.Date + +fun mapCloudBackupStateModel( + resourceManager: ResourceManager, + backupEnabled: Boolean, + syncingInProgress: Boolean, + syncOutcome: BackupSyncOutcome, + lastSync: Date? +): CloudBackupStateModel { + val (stateImage, stateImageTint, stateBackgroundColor) = mapStateImage(backupEnabled, syncOutcome) + + return CloudBackupStateModel( + stateImg = stateImage, + stateImageTint = stateImageTint, + stateColorBackgroundRes = stateBackgroundColor, + showProgress = syncingInProgress, + title = mapCloudBackupStateTitle(resourceManager, backupEnabled, syncingInProgress, syncOutcome), + subtitle = mapCloudBackupStateSubtitle(resourceManager, backupEnabled, lastSync), + isClickable = mapCloudBackupClickability(backupEnabled, syncingInProgress, syncOutcome), + problemButtonText = mapCloudBackupProblemButton(resourceManager, backupEnabled, syncingInProgress, syncOutcome) + ) +} + +private fun mapStateImage(backupEnabled: Boolean, syncOutcome: BackupSyncOutcome) = when { + !backupEnabled -> Triple(R.drawable.ic_cloud_backup_status_disabled, R.color.icon_secondary, R.color.waiting_status_background) + syncOutcome.isError() -> Triple(R.drawable.ic_cloud_backup_status_warning, R.color.icon_warning, R.color.warning_block_background) + else -> Triple(R.drawable.ic_cloud_backup_status_active, R.color.icon_positive, R.color.active_status_background) +} + +private fun mapCloudBackupStateTitle( + resourceManager: ResourceManager, + backupEnabled: Boolean, + syncingInProgress: Boolean, + syncOutcome: BackupSyncOutcome +) = when { + syncingInProgress -> resourceManager.getString(R.string.cloud_backup_state_syncing_title) + !backupEnabled -> resourceManager.getString(R.string.cloud_backup_state_disabled_title) + syncOutcome.isError() -> resourceManager.getString(R.string.cloud_backup_state_unsynced_title) + else -> resourceManager.getString(R.string.cloud_backup_state_synced_title) +} + +private fun mapCloudBackupStateSubtitle( + resourceManager: ResourceManager, + backupEnabled: Boolean, + lastSync: Date? +) = when { + !backupEnabled -> resourceManager.getString(R.string.cloud_backup_settings_disabled_state_subtitle) + lastSync != null -> resourceManager.getString( + R.string.cloud_backup_settings_last_sync, + lastSync.formatDateSinceEpoch(resourceManager), + resourceManager.formatTime(lastSync) + ) + + else -> null +} + +private fun mapCloudBackupClickability( + backupEnabled: Boolean, + syncingInProgress: Boolean, + syncOutcome: BackupSyncOutcome +): Boolean { + if (!backupEnabled) return false + if (syncingInProgress) return false + + return when (syncOutcome) { + BackupSyncOutcome.Ok, + is BackupSyncOutcome.DestructiveDiff, + BackupSyncOutcome.EmptyPassword, + BackupSyncOutcome.UnknownPassword, + BackupSyncOutcome.CorruptedBackup, + BackupSyncOutcome.UnknownError -> true + + BackupSyncOutcome.StorageAuthFailed -> false + } +} + +private fun mapCloudBackupProblemButton( + resourceManager: ResourceManager, + backupEnabled: Boolean, + syncingInProgress: Boolean, + syncOutcome: BackupSyncOutcome +): String? { + if (!backupEnabled) return null + if (syncingInProgress) return null + + return when (syncOutcome) { + BackupSyncOutcome.Ok -> null + BackupSyncOutcome.CorruptedBackup -> resourceManager.getString(R.string.cloud_backup_settings_backup_errors_button) + is BackupSyncOutcome.DestructiveDiff -> resourceManager.getString(R.string.cloud_backup_settings_corrupted_backup_button) + + BackupSyncOutcome.EmptyPassword, + BackupSyncOutcome.UnknownPassword -> resourceManager.getString(R.string.cloud_backup_settings_deprecated_password_button) + + BackupSyncOutcome.StorageAuthFailed -> resourceManager.getString(R.string.cloud_backup_settings_not_auth_button) + BackupSyncOutcome.UnknownError -> resourceManager.getString(R.string.cloud_backup_settings_other_errors_button) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/CloudBackupStateModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/CloudBackupStateModel.kt new file mode 100644 index 0000000..a01a57d --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/CloudBackupStateModel.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings + +import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.views.CloudBackupStateView + +class CloudBackupStateModel( + val stateImg: Int?, + val stateImageTint: Int, + val stateColorBackgroundRes: Int?, + val showProgress: Boolean, + val title: String, + val subtitle: String?, + val isClickable: Boolean, + val problemButtonText: String? +) + +fun CloudBackupStateView.setState(state: CloudBackupStateModel) { + setStateImage(state.stateImg, state.stateImageTint, state.stateColorBackgroundRes) + setProgressVisibility(state.showProgress) + setTitle(state.title) + setSubtitle(state.subtitle) + isClickable = state.isClickable + showMoreButton(state.isClickable) + setProblemText(state.problemButtonText) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/di/CloudBackupSettingsComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/di/CloudBackupSettingsComponent.kt new file mode 100644 index 0000000..0c99e06 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/di/CloudBackupSettingsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.BackupSettingsFragment + +@Subcomponent( + modules = [ + CloudBackupSettingsModule::class + ] +) +@ScreenScope +interface CloudBackupSettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): CloudBackupSettingsComponent + } + + fun inject(fragment: BackupSettingsFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/di/CloudBackupSettingsModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/di/CloudBackupSettingsModule.kt new file mode 100644 index 0000000..3bf0a21 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/di/CloudBackupSettingsModule.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.common.view.bottomSheet.action.ActionBottomSheetLauncherFactory +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountInteractor +import io.novafoundation.nova.feature_account_api.presenatation.account.common.listing.MetaAccountTypePresentationMapper +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.ChangeBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.changePassword.RestoreBackupPasswordCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.cloudBackup.createPassword.SyncWalletsBackupPasswordCommunicator +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.CloudBackupSettingsInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.BackupSettingsViewModel + +@Module(includes = [ViewModelModule::class]) +class CloudBackupSettingsModule { + + @Provides + @IntoMap + @ViewModelKey(BackupSettingsViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: SettingsRouter, + accountInteractor: AccountInteractor, + cloudBackupSettingsInteractor: CloudBackupSettingsInteractor, + syncWalletsBackupPasswordCommunicator: SyncWalletsBackupPasswordCommunicator, + changeBackupPasswordCommunicator: ChangeBackupPasswordCommunicator, + restoreBackupPasswordCommunicator: RestoreBackupPasswordCommunicator, + actionBottomSheetLauncherFactory: ActionBottomSheetLauncherFactory, + accountTypePresentationMapper: MetaAccountTypePresentationMapper, + walletUiUseCase: WalletUiUseCase, + progressDialogMixinFactory: ProgressDialogMixinFactory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + listSelectorMixinFactory: ListSelectorMixin.Factory, + customDialogProvider: CustomDialogDisplayer.Presentation + ): ViewModel { + return BackupSettingsViewModel( + resourceManager, + router, + accountInteractor, + cloudBackupSettingsInteractor, + syncWalletsBackupPasswordCommunicator, + changeBackupPasswordCommunicator, + restoreBackupPasswordCommunicator, + actionBottomSheetLauncherFactory, + accountTypePresentationMapper, + walletUiUseCase, + progressDialogMixinFactory, + actionAwaitableMixinFactory, + listSelectorMixinFactory, + customDialogProvider + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): BackupSettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(BackupSettingsViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/views/CloudBackupStateView.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/views/CloudBackupStateView.kt new file mode 100644 index 0000000..7572f9b --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/cloudBackup/settings/views/CloudBackupStateView.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.cloudBackup.settings.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isGone +import androidx.core.view.isVisible +import io.novafoundation.nova.common.utils.getRippleMask +import io.novafoundation.nova.common.utils.getRoundedCornerDrawable +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setImageTint +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.withRippleMask +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.common.view.shape.ovalDrawable +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.databinding.ViewCloudBackupStateBinding + +class CloudBackupStateView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + private val binder = ViewCloudBackupStateBinding.inflate(inflater(), this, true) + + private val stateImage: ImageView + get() = binder.backupStateImg + + private val progress: ProgressBar + get() = binder.backupStateProgress + + private val title: TextView + get() = binder.backupStateTitle + + private val subtitle: TextView + get() = binder.backupStateSubtitle + + private val more: View + get() = binder.backupStateMore + + private val divider: View + get() = binder.backupStateDivider + + private val problemButton: TextView + get() = binder.backupStateProblemBtn + + init { + background = getRoundedCornerDrawable(fillColorRes = R.color.block_background).withRippleMask() + problemButton.background = context.getRoundedCornerDrawable(fillColorRes = null).withRippleMask(getRippleMask(cornerSizeDp = 8)) + } + + fun setStateImage(resId: Int?, stateImageTiniRes: Int?, backgroundColorRes: Int?) { + resId?.let { stateImage.setImageResource(it) } ?: stateImage.setImageDrawable(null) + stateImage.setImageTint(stateImageTiniRes?.let { context.getColor(it) }) + stateImage.background = backgroundColorRes?.let { ovalDrawable(context.getColor(it)) } + } + + fun setProgressVisibility(visible: Boolean) { + progress.isVisible = visible + stateImage.isGone = visible + } + + fun setTitle(titleText: String) { + title.text = titleText + } + + fun setSubtitle(text: String?) { + subtitle.setTextOrHide(text) + } + + fun setProblemText(text: String?) { + divider.isVisible = text != null + problemButton.isVisible = text != null + problemButton.text = text + } + + fun setProblemClickListener(listener: OnClickListener?) { + problemButton.setOnClickListener(listener) + } + + fun showMoreButton(show: Boolean) { + more.isVisible = show + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkMainFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkMainFragment.kt new file mode 100644 index 0000000..b3226ef --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkMainFragment.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main + +import android.os.Bundle +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.setupWithViewPager2 +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentAddNetworkMainBinding +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent + +class AddNetworkMainFragment : BaseFragment() { + + companion object { + + private const val KEY_PAYLOAD = "key_payload" + + fun getBundle(payload: AddNetworkPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentAddNetworkMainBinding.inflate(layoutInflater) + + override fun initViews() { + binder.addNetworkMainToolbar.setHomeButtonListener { viewModel.backClicked() } + + val payload: AddNetworkPayload? = argumentOrNull(KEY_PAYLOAD) + val adapter = AddNetworkMainPagerAdapter(this, payload) + binder.addNetworkMainViewPager.adapter = adapter + + if (payload == null) { + binder.addNetworkMainTabLayout.setupWithViewPager2(binder.addNetworkMainViewPager, adapter::getPageTitle) + } else { + binder.addNetworkMainTabLayout.makeGone() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .addNetworkMainFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: AddNetworkMainViewModel) { + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkMainPagerAdapter.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkMainPagerAdapter.kt new file mode 100644 index 0000000..edd0fef --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkMainPagerAdapter.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload.Mode.Add.NetworkType +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.AddNetworkFragment + +class AddNetworkMainPagerAdapter( + private val fragment: Fragment, + private val payloadForSinglePage: AddNetworkPayload? +) : FragmentStateAdapter(fragment) { + + override fun getItemCount(): Int { + return if (payloadForSinglePage == null) { + 2 + } else { + 1 + } + } + + override fun createFragment(position: Int): Fragment { + return if (payloadForSinglePage == null) { + when (position) { + 0 -> AddNetworkFragment().addPayloadEmptyMode(NetworkType.SUBSTRATE) + 1 -> AddNetworkFragment().addPayloadEmptyMode(NetworkType.EVM) + else -> throw IllegalArgumentException("Invalid position") + } + } else { + AddNetworkFragment().addPayload(payloadForSinglePage) + } + } + + fun getPageTitle(position: Int): CharSequence { + return if (payloadForSinglePage == null) { + when (position) { + 0 -> fragment.getString(R.string.common_substrate) + 1 -> fragment.getString(R.string.common_evm) + else -> throw IllegalArgumentException("Invalid position") + } + } else { + "" // For single page we hide TabLayout + } + } + + private fun AddNetworkFragment.addPayloadEmptyMode(networkType: NetworkType): AddNetworkFragment { + return addPayload(AddNetworkPayload(AddNetworkPayload.Mode.Add(networkType, null))) + } + + private fun AddNetworkFragment.addPayload(mode: AddNetworkPayload): AddNetworkFragment { + this.arguments = AddNetworkFragment.getBundle(mode) + + return this + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkMainViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkMainViewModel.kt new file mode 100644 index 0000000..d88c5f4 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkMainViewModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_settings_impl.SettingsRouter + +class AddNetworkMainViewModel( + private val router: SettingsRouter +) : BaseViewModel() { + + fun backClicked() { + router.back() + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkPayload.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkPayload.kt new file mode 100644 index 0000000..844217b --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/AddNetworkPayload.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main + +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.util.ChainParcel +import kotlinx.parcelize.Parcelize + +@Parcelize +class AddNetworkPayload( + val mode: Mode, + +) : Parcelable { + + sealed interface Mode : Parcelable { + + @Parcelize + class Add(val networkType: NetworkType, val chainParcel: ChainParcel?) : Mode { + + enum class NetworkType { + SUBSTRATE, EVM + } + } + + @Parcelize + class Edit(val chainId: String) : Mode + } +} + +fun AddNetworkPayload.Mode.Add.getChain(): Chain? { + return chainParcel?.chain +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/di/AddNetworkMainComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/di/AddNetworkMainComponent.kt new file mode 100644 index 0000000..7104960 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/di/AddNetworkMainComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkMainFragment + +@Subcomponent( + modules = [ + AddNetworkMainModule::class + ] +) +@ScreenScope +interface AddNetworkMainComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): AddNetworkMainComponent + } + + fun inject(fragment: AddNetworkMainFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/di/AddNetworkMainModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/di/AddNetworkMainModule.kt new file mode 100644 index 0000000..50c102e --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/main/di/AddNetworkMainModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkMainViewModel + +@Module(includes = [ViewModelModule::class]) +class AddNetworkMainModule { + + @Provides + @IntoMap + @ViewModelKey(AddNetworkMainViewModel::class) + fun provideViewModel( + router: SettingsRouter + ): ViewModel { + return AddNetworkMainViewModel( + router + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddNetworkMainViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AddNetworkMainViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/AddNetworkFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/AddNetworkFragment.kt new file mode 100644 index 0000000..a4ed18f --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/AddNetworkFragment.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails + +import android.os.Bundle +import androidx.core.view.isVisible + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentAddNetworkBinding +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload + +class AddNetworkFragment : BaseFragment() { + + companion object { + + private const val KEY_PAYLOAD = "key_payload" + + fun getBundle(payload: AddNetworkPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentAddNetworkBinding.inflate(layoutInflater) + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .addNetworkFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun initViews() { + binder.addNetworkApplyButton.prepareForProgress(lifecycleOwner) + + binder.addNetworkApplyButton.setOnClickListener { viewModel.addNetworkClicked() } + } + + override fun subscribe(viewModel: AddNetworkViewModel) { + observeValidations(viewModel) + viewModel.isNodeEditable.observe { binder.addNetworkNodeUrl.isEnabled = it } + binder.addNetworkNodeUrl.bindTo(viewModel.nodeUrlFlow, viewModel) + binder.addNetworkName.bindTo(viewModel.networkNameFlow, viewModel) + binder.addNetworkCurrency.bindTo(viewModel.tokenSymbolFlow, viewModel) + viewModel.isChainIdVisibleFlow.observe { binder.addNetworkChainIdContainer.isVisible = it } + binder.addNetworkChainId.bindTo(viewModel.evmChainIdFlow, viewModel) + binder.addNetworkBlockExplorer.bindTo(viewModel.blockExplorerFlow, viewModel) + binder.addNetworkPriceInfoProvider.bindTo(viewModel.priceProviderFlow, viewModel) + viewModel.buttonState.observe { binder.addNetworkApplyButton.setState(it) } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/AddNetworkViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/AddNetworkViewModel.kt new file mode 100644 index 0000000..94489a9 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/AddNetworkViewModel.kt @@ -0,0 +1,250 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails + +import android.util.Log +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.nullIfBlank +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.AddNetworkInteractor +import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.CustomNetworkValidationSystem +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload.Mode +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.getChain +import io.novafoundation.nova.runtime.ext.evmChainIdOrNull +import io.novafoundation.nova.runtime.ext.networkType +import io.novafoundation.nova.runtime.ext.normalizedUrl +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +class AddNetworkViewModel( + private val resourceManager: ResourceManager, + private val router: SettingsRouter, + private val payload: AddNetworkPayload, + private val interactor: AddNetworkInteractor, + private val validationExecutor: ValidationExecutor, + private val autofillNetworkMetadataMixinFactory: AutofillNetworkMetadataMixinFactory, + private val coinGeckoLinkParser: CoinGeckoLinkParser, + private val chainRegistry: ChainRegistry +) : BaseViewModel(), Validatable by validationExecutor { + + private val networkType = flowOf { getNetworkType() } + .shareInBackground() + + val isChainIdVisibleFlow = flowOf { isChainIdFieldRequired() } + .shareInBackground() + + val isNodeEditable = flowOf { payload.mode is Mode.Add } + .shareInBackground() + + val nodeUrlFlow = MutableStateFlow("") + val networkNameFlow = MutableStateFlow("") + val tokenSymbolFlow = MutableStateFlow("") + val evmChainIdFlow = MutableStateFlow("") + val blockExplorerFlow = MutableStateFlow("") + val priceProviderFlow = MutableStateFlow("") + + private val loadingState = MutableStateFlow(false) + + val buttonState = combine( + nodeUrlFlow, + networkNameFlow, + tokenSymbolFlow, + evmChainIdFlow, + loadingState + ) { url, networkName, tokenName, chainId, isLoading -> + val chainIdRequiredAndEmpty = isChainIdFieldRequired() && chainId.isBlank() + when { + isLoading -> DescriptiveButtonState.Loading + url.isBlank() || networkName.isBlank() || tokenName.isBlank() || chainIdRequiredAndEmpty -> DescriptiveButtonState.Disabled( + resourceManager.getString(R.string.common_enter_details) + ) + + else -> { + val resId = when (payload.mode) { + is Mode.Add -> R.string.common_add_network + is Mode.Edit -> R.string.common_save + } + DescriptiveButtonState.Enabled(resourceManager.getString(resId)) + } + } + } + + init { + launch { + prefillData() + + runAutofill() + } + } + + private suspend fun prefillData() { + val mode = payload.mode + val chain = when (mode) { + is Mode.Add -> mode.getChain() + is Mode.Edit -> chainRegistry.getChain(mode.chainId) + } + + val asset = chain?.utilityAsset + nodeUrlFlow.value = chain?.nodes?.nodes?.first()?.unformattedUrl.orEmpty() + networkNameFlow.value = chain?.name.orEmpty() + tokenSymbolFlow.value = asset?.symbol?.value.orEmpty() + evmChainIdFlow.value = chain?.evmChainIdOrNull()?.format().orEmpty() + blockExplorerFlow.value = chain?.explorers?.firstOrNull()?.normalizedUrl().orEmpty() + priceProviderFlow.value = asset?.priceId?.let { coinGeckoLinkParser.format(it) }.orEmpty() + } + + fun addNetworkClicked() { + launch { + val chainName = networkNameFlow.value + val blockExplorerUrl = blockExplorerFlow.value.nullIfBlank() + val blockExplorerName = resourceManager.getString(R.string.create_network_block_explorer_name, chainName) + val validationPayload = CustomNetworkPayload( + nodeUrl = nodeUrlFlow.value, + nodeName = resourceManager.getString(R.string.create_network_node_name, chainName), + chainName = chainName, + tokenSymbol = tokenSymbolFlow.value, + evmChainId = evmChainIdFlow.value.toIntOrNull(), + blockExplorer = blockExplorerUrl?.let { CustomNetworkPayload.BlockExplorer(blockExplorerName, it) }, + coingeckoLinkUrl = priceProviderFlow.value.nullIfBlank(), + ignoreChainModifying = payload.mode is Mode.Edit // Skip dialog about Chain modifying if it's already editing + ) + + validationExecutor.requireValid( + validationSystem = getValidationSystem(), + payload = validationPayload, + progressConsumer = loadingState.progressConsumer(), + validationFailureTransformerCustom = { status, actions -> + mapSaveCustomNetworkFailureToUI( + resourceManager, + status, + actions, + ::changeTokenSymbol + ) + } + ) { + executeSaving(validationPayload) + } + } + } + + private fun executeSaving(savingPayload: CustomNetworkPayload) { + launch { + val result = when (payload.mode) { + is Mode.Add -> createNetwork(payload.mode, savingPayload) + is Mode.Edit -> editNetwork(payload.mode, savingPayload) + } + + result.onSuccess { finishSavingFlow() } + .onFailure { + Log.e(LOG_TAG, "Failed to save network", it) + showError(resourceManager.getString(R.string.common_something_went_wrong_title)) + } + + loadingState.value = false + } + } + + private fun finishSavingFlow() { + launch { + when (payload.mode) { + is Mode.Add -> router.finishCreateNetworkFlow() + is Mode.Edit -> router.back() + } + } + } + + private suspend fun createNetwork(mode: Mode.Add, savingPayload: CustomNetworkPayload): Result { + return when (mode.networkType) { + Mode.Add.NetworkType.EVM -> interactor.createEvmNetwork(savingPayload, mode.getChain()) + + Mode.Add.NetworkType.SUBSTRATE -> interactor.createSubstrateNetwork(savingPayload, mode.getChain(), coroutineScope) + } + } + + private suspend fun editNetwork(mode: Mode.Edit, savingPayload: CustomNetworkPayload): Result { + return interactor.updateChain( + mode.chainId, + savingPayload.chainName, + savingPayload.tokenSymbol, + savingPayload.blockExplorer, + savingPayload.coingeckoLinkUrl + ) + } + + private fun changeTokenSymbol(symbol: String) { + tokenSymbolFlow.value = symbol + } + + private suspend fun getValidationSystem(): CustomNetworkValidationSystem { + return when (networkType.first()) { + NetworkType.EVM -> interactor.getEvmValidationSystem(viewModelScope) + NetworkType.SUBSTRATE -> interactor.getSubstrateValidationSystem(viewModelScope) + } + } + + private fun runAutofill() { + launch { + val autofillMixin = when (networkType.first()) { + NetworkType.EVM -> autofillNetworkMetadataMixinFactory.evm(viewModelScope) + NetworkType.SUBSTRATE -> autofillNetworkMetadataMixinFactory.substrate(viewModelScope) + } + + nodeUrlFlow + .ignorePrefilledValues() + .debounce(500.milliseconds) + .mapLatest { url -> autofillMixin.autofill(url) } + .onEach { result -> + result.onSuccess { data -> + data.chainName?.let { networkNameFlow.value = it } + data.tokenSymbol?.let { tokenSymbolFlow.value = it } + data.evmChainId?.let { evmChainIdFlow.value = it.toString() } + } + } + .launchIn(viewModelScope) + } + } + + private fun Flow.ignorePrefilledValues(): Flow { + return this.drop(1) + } + + private fun isChainIdFieldRequired(): Boolean { + return payload.mode is Mode.Add && payload.mode.networkType == Mode.Add.NetworkType.EVM + } + + private suspend fun getNetworkType(): NetworkType { + return when (payload.mode) { + is Mode.Add -> { + when (payload.mode.networkType) { + Mode.Add.NetworkType.SUBSTRATE -> NetworkType.SUBSTRATE + Mode.Add.NetworkType.EVM -> NetworkType.EVM + } + } + + is Mode.Edit -> chainRegistry.getChain(payload.mode.chainId).networkType() + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/AutofillNetworkMetadataMixin.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/AutofillNetworkMetadataMixin.kt new file mode 100644 index 0000000..4250dda --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/AutofillNetworkMetadataMixin.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails + +import io.novafoundation.nova.common.data.network.runtime.calls.GetChainRequest +import io.novafoundation.nova.common.data.network.runtime.calls.GetSystemPropertiesRequest +import io.novafoundation.nova.common.data.network.runtime.model.SystemProperties +import io.novafoundation.nova.common.data.network.runtime.model.firstTokenSymbol +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnection +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import io.novasama.substrate_sdk_android.wsrpc.executeAsync +import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull +import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo +import java.math.BigInteger +import kotlinx.coroutines.CoroutineScope + +class AutofillNetworkData( + val chainName: String?, + val tokenSymbol: String?, + val evmChainId: BigInteger? +) + +class AutofillNetworkMetadataMixinFactory( + private val nodeConnectionFactory: NodeConnectionFactory, + private val web3ApiFactory: Web3ApiFactory +) { + fun substrate(coroutineScope: CoroutineScope): SubstrateAutofillNetworkMetadataMixin { + return SubstrateAutofillNetworkMetadataMixin(nodeConnectionFactory, coroutineScope) + } + + fun evm(coroutineScope: CoroutineScope): EvmAutofillNetworkMetadataMixin { + return EvmAutofillNetworkMetadataMixin(nodeConnectionFactory, coroutineScope, web3ApiFactory) + } +} + +interface AutofillNetworkMetadataMixin { + + suspend fun autofill(url: String): Result +} + +class SubstrateAutofillNetworkMetadataMixin( + private val nodeConnectionFactory: NodeConnectionFactory, + private val coroutineScope: CoroutineScope +) : AutofillNetworkMetadataMixin { + + private var nodeConnection: NodeConnection? = null + + override suspend fun autofill(url: String): Result = runCatching { + if (nodeConnection == null) { + nodeConnection = nodeConnectionFactory.createNodeConnection(url, coroutineScope) + } else { + nodeConnection!!.switchUrl(url) + } + + val properties = getSubstrateChainProperties(nodeConnection!!) + val chainName = getSubstrateChainName(nodeConnection!!) + + AutofillNetworkData( + chainName = chainName, + tokenSymbol = properties.firstTokenSymbol(), + evmChainId = null + ) + } + + private suspend fun getSubstrateChainProperties(nodeConnection: NodeConnection): SystemProperties { + return nodeConnection.getSocketService() + .executeAsync(GetSystemPropertiesRequest(), mapper = pojo().nonNull()) + } + + private suspend fun getSubstrateChainName(nodeConnection: NodeConnection): String { + return nodeConnection.getSocketService() + .executeAsync(GetChainRequest(), mapper = pojo().nonNull()) + } +} + +class EvmAutofillNetworkMetadataMixin( + private val nodeConnectionFactory: NodeConnectionFactory, + private val coroutineScope: CoroutineScope, + private val web3ApiFactory: Web3ApiFactory +) : AutofillNetworkMetadataMixin { + + private var nodeConnection: NodeConnection? = null + private var web3Api: Web3Api? = null + + override suspend fun autofill(url: String): Result = runCatching { + if (nodeConnection == null) { + nodeConnection = nodeConnectionFactory.createNodeConnection(url, coroutineScope) + } else { + nodeConnection!!.switchUrl(url) + } + + if (web3Api == null) { + web3Api = web3ApiFactory.createWss(nodeConnection!!.getSocketService()) + } + + val chainId = web3Api!!.ethChainId().sendSuspend().chainId + + AutofillNetworkData( + chainName = null, + tokenSymbol = null, + evmChainId = chainId + ) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/SaveCustomNetworkValidationFailureUi.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/SaveCustomNetworkValidationFailureUi.kt new file mode 100644 index 0000000..785c1f9 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/SaveCustomNetworkValidationFailureUi.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.TransformedFailure.Default +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNetwork.CustomNetworkFailure +import io.novafoundation.nova.feature_settings_impl.domain.model.CustomNetworkPayload + +fun mapSaveCustomNetworkFailureToUI( + resourceManager: ResourceManager, + status: ValidationStatus.NotValid, + actions: ValidationFlowActions, + changeTokenSymbolAction: (String) -> Unit +): TransformedFailure { + return when (val reason = status.reason) { + is CustomNetworkFailure.DefaultNetworkAlreadyAdded -> Default( + resourceManager.getString(R.string.network_already_exist_failure_title) to + resourceManager.getString(R.string.default_network_already_exist_failure_message, reason.networkName) + ) + + is CustomNetworkFailure.CustomNetworkAlreadyAdded -> { + TransformedFailure.Custom( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.network_already_exist_failure_title), + message = resourceManager.getString(R.string.custom_network_already_exist_failure_message, reason.networkName), + cancelAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_close), + action = { } + ), + okAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_modify), + action = { + actions.revalidate { payload -> + payload.copy(ignoreChainModifying = true) + } + } + ), + customStyle = R.style.AccentAlertDialogTheme + ) + ) + } + + CustomNetworkFailure.NodeIsNotAlive -> Default( + resourceManager.getString(R.string.node_not_alive_title) to + resourceManager.getString(R.string.node_not_alive_message) + ) + + is CustomNetworkFailure.WrongNetwork -> Default( + resourceManager.getString(R.string.create_network_invalid_chain_id_title) to + resourceManager.getString(R.string.create_network_invalid_chain_id_message) + ) + + CustomNetworkFailure.CoingeckoLinkBadFormat -> Default( + resourceManager.getString(R.string.asset_add_evm_token_invalid_coin_gecko_link_title) to + resourceManager.getString(R.string.asset_add_evm_token_invalid_coin_gecko_link_message) + ) + + is CustomNetworkFailure.WrongAsset -> { + TransformedFailure.Custom( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.invalid_token_symbol_title), + message = resourceManager.getString(R.string.invalid_token_symbol_message, reason.usedSymbol, reason.correctSymbol), + cancelAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_close), + action = { } + ), + okAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_apply), + action = { + changeTokenSymbolAction(reason.correctSymbol) + } + ), + customStyle = R.style.AccentAlertDialogTheme + ) + ) + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/di/AddNetworkComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/di/AddNetworkComponent.kt new file mode 100644 index 0000000..612afbc --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/di/AddNetworkComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.AddNetworkFragment + +@Subcomponent( + modules = [ + AddNetworkModule::class + ] +) +@ScreenScope +interface AddNetworkComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance argument: AddNetworkPayload, + ): AddNetworkComponent + } + + fun inject(fragment: AddNetworkFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/di/AddNetworkModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/di/AddNetworkModule.kt new file mode 100644 index 0000000..609afbe --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/add/networkDetails/di/AddNetworkModule.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.AddNetworkInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.AddNetworkViewModel +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.networkDetails.AutofillNetworkMetadataMixinFactory +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory + +@Module(includes = [ViewModelModule::class]) +class AddNetworkModule { + + @Provides + fun provideAutofillNetworkMetadataMixinFactory( + nodeConnectionFactory: NodeConnectionFactory, + web3ApiFactory: Web3ApiFactory + ): AutofillNetworkMetadataMixinFactory { + return AutofillNetworkMetadataMixinFactory( + nodeConnectionFactory, + web3ApiFactory + ) + } + + @Provides + @IntoMap + @ViewModelKey(AddNetworkViewModel::class) + fun provideViewModel( + resourceManager: ResourceManager, + router: SettingsRouter, + payload: AddNetworkPayload, + interactor: AddNetworkInteractor, + validationExecutor: ValidationExecutor, + autofillNetworkMetadataMixinFactory: AutofillNetworkMetadataMixinFactory, + coinGeckoLinkParser: CoinGeckoLinkParser, + chainRegistry: ChainRegistry, + ): ViewModel { + return AddNetworkViewModel( + resourceManager = resourceManager, + router = router, + payload = payload, + interactor = interactor, + validationExecutor = validationExecutor, + autofillNetworkMetadataMixinFactory = autofillNetworkMetadataMixinFactory, + coinGeckoLinkParser = coinGeckoLinkParser, + chainRegistry = chainRegistry + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddNetworkViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AddNetworkViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/ChainNetworkManagementFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/ChainNetworkManagementFragment.kt new file mode 100644 index 0000000..cc71112 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/ChainNetworkManagementFragment.kt @@ -0,0 +1,166 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain + +import android.os.Bundle +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.ViewSpace +import io.novafoundation.nova.common.view.dialog.warningDialog +import io.novafoundation.nova.common.view.input.selector.setupListSelectorMixin +import io.novafoundation.nova.common.view.recyclerview.adapter.text.TextAdapter +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentChainNetworkManagementBinding +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.headerAdapter.ChainNetworkManagementHeaderAdapter +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.ChainNetworkManagementNodesAdapter +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.NodesItemBackgroundDecoration +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.NodesItemDividerDecoration +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodeRvItem +import javax.inject.Inject + +private val NODES_GROUP_TEXT_STYLE = R.style.TextAppearance_NovaFoundation_SemiBold_Caps2 +private val NODES_GROUP_COLOR_RES = R.color.text_secondary +private val NODES_GROUP_TEXT_PADDING_DP = ViewSpace(16, 24, 16, 0) + +class ChainNetworkManagementFragment : + BaseFragment(), + ChainNetworkManagementHeaderAdapter.ItemHandler, + ChainNetworkManagementNodesAdapter.ItemHandler { + + companion object { + + private const val EXTRA_PAYLOAD = "payload" + + fun getBundle(payload: ChainNetworkManagementPayload): Bundle { + return Bundle().apply { + putParcelable(EXTRA_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentChainNetworkManagementBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val headerAdapter by lazy { ChainNetworkManagementHeaderAdapter(imageLoader, this) } + private val customNodesTitleAdapter by lazy { + TextAdapter( + requireContext().getString(R.string.network_management_custom_nodes), + NODES_GROUP_TEXT_STYLE, + NODES_GROUP_COLOR_RES, + NODES_GROUP_TEXT_PADDING_DP + ) + } + private val customNodes by lazy { ChainNetworkManagementNodesAdapter(this) } + private val defaultNodesTitleAdapter by lazy { + TextAdapter( + requireContext().getString(R.string.network_management_default_nodes), + NODES_GROUP_TEXT_STYLE, + NODES_GROUP_COLOR_RES, + NODES_GROUP_TEXT_PADDING_DP + ) + } + private val defaultNodes by lazy { ChainNetworkManagementNodesAdapter(this) } + + private val adapter by lazy { + ConcatAdapter( + headerAdapter, + customNodesTitleAdapter, + customNodes, + defaultNodesTitleAdapter, + defaultNodes + ) + } + + override fun initViews() { + binder.chainNetworkManagementToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.chainNetworkManagementToolbar.setRightActionClickListener { viewModel.networkActionsClicked() } + + binder.chainNetworkManagementContent.adapter = adapter + binder.chainNetworkManagementContent.itemAnimator = null + binder.chainNetworkManagementContent.addItemDecoration(NodesItemBackgroundDecoration(requireContext())) + binder.chainNetworkManagementContent.addItemDecoration(NodesItemDividerDecoration(requireContext())) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .chainNetworkManagementFactory() + .create(this, argument(EXTRA_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ChainNetworkManagementViewModel) { + setupListSelectorMixin(viewModel.listSelectorMixin) + + viewModel.isNetworkEditable.observe { + if (it) { + binder.chainNetworkManagementToolbar.setRightActionTint(R.color.icon_primary) + binder.chainNetworkManagementToolbar.setRightIconRes(R.drawable.ic_more_horizontal) + } + } + + viewModel.isNetworkCanBeDisabled.observe { + headerAdapter.setNetworkCanBeDisabled(it) + } + + viewModel.chainEnabled.observe { + headerAdapter.setChainEnabled(it) + } + + viewModel.autoBalanceEnabled.observe { + headerAdapter.setAutoBalanceEnabled(it) + } + + viewModel.chainModel.observe { + headerAdapter.setChainUiModel(it) + } + + viewModel.customNodes.observe { + customNodes.submitList(it) + } + + viewModel.defaultNodes.observe { + defaultNodesTitleAdapter.show(it.isNotEmpty()) + defaultNodes.submitList(it) + } + + viewModel.confirmAccountDeletion.awaitableActionLiveData.observeEvent { + warningDialog( + requireContext(), + onPositiveClick = { it.onSuccess(true) }, + onNegativeClick = { it.onSuccess(false) }, + positiveTextRes = R.string.common_delete + ) { + setTitle(it.payload.first) + setMessage(it.payload.second) + } + } + } + + override fun chainEnableClicked() { + viewModel.chainEnableClicked() + } + + override fun autoBalanceClicked() { + viewModel.autoBalanceClicked() + } + + override fun selectNode(item: NetworkNodeRvItem) { + viewModel.selectNode(item) + } + + override fun editNode(item: NetworkNodeRvItem) { + viewModel.nodeActionClicked(item) + } + + override fun addNewNode() { + viewModel.addNewNode() + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/ChainNetworkManagementPayload.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/ChainNetworkManagementPayload.kt new file mode 100644 index 0000000..35e87d1 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/ChainNetworkManagementPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class ChainNetworkManagementPayload( + val chainId: String +) : Parcelable diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/ChainNetworkManagementViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/ChainNetworkManagementViewModel.kt new file mode 100644 index 0000000..61ae27e --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/ChainNetworkManagementViewModel.kt @@ -0,0 +1,229 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingOrDenyingAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.ChainNetworkState +import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementChainInteractor +import io.novafoundation.nova.feature_settings_impl.domain.NodeHealthState +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkConnectionRvItem +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodeRvItem +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodesAddCustomRvItem +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common.ConnectionStateModel +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodePayload +import io.novafoundation.nova.runtime.ext.autoBalanceEnabled +import io.novafoundation.nova.runtime.ext.isCustomNetwork +import io.novafoundation.nova.runtime.ext.isEnabled +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ChainNetworkManagementViewModel( + private val router: SettingsRouter, + private val resourceManager: ResourceManager, + private val networkManagementChainInteractor: NetworkManagementChainInteractor, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val listSelectorMixinFactory: ListSelectorMixin.Factory, + private val payload: ChainNetworkManagementPayload +) : BaseViewModel() { + + private val chainNetworkStateFlow = networkManagementChainInteractor.chainStateFlow(payload.chainId, viewModelScope) + .shareInBackground() + + val isNetworkCanBeDisabled: Flow = chainNetworkStateFlow.map { it.networkCanBeDisabled } + val chainEnabled: Flow = chainNetworkStateFlow.map { it.chain.isEnabled } + val autoBalanceEnabled: Flow = chainNetworkStateFlow.map { it.chain.autoBalanceEnabled } + val chainModel: Flow = chainNetworkStateFlow.map { mapChainToUi(it.chain) } + + val isNetworkEditable = chainNetworkStateFlow.map { it.chain.isCustomNetwork } + + val customNodes: Flow> = chainNetworkStateFlow.map { chainNetworkState -> + buildList { + val nodes = chainNetworkState.nodeHealthStates.filter { it.node.isCustom } + .map { mapNodeToUi(it, chainNetworkState) } + + add(NetworkNodesAddCustomRvItem()) + addAll(nodes) + } + } + + val defaultNodes: Flow> = chainNetworkStateFlow.map { chainNetworkState -> + chainNetworkState.nodeHealthStates.filter { !it.node.isCustom } + .map { mapNodeToUi(it, chainNetworkState) } + } + + val confirmAccountDeletion = actionAwaitableMixinFactory.confirmingOrDenyingAction>() + + val listSelectorMixin = listSelectorMixinFactory.create(viewModelScope) + + fun backClicked() { + router.back() + } + + fun chainEnableClicked() { + launch { + networkManagementChainInteractor.toggleChainEnableState(payload.chainId) + } + } + + fun autoBalanceClicked() { + launch { + networkManagementChainInteractor.toggleAutoBalance(payload.chainId) + } + } + + fun selectNode(item: NetworkNodeRvItem) { + launch { + networkManagementChainInteractor.selectNode(payload.chainId, item.unformattedUrl) + } + } + + fun nodeActionClicked(item: NetworkNodeRvItem) { + launch { + val state = chainNetworkStateFlow.first() + val nodes = state.chain.nodes.nodes + if (nodes.size > 1) { + listSelectorMixin.showSelector( + R.string.manage_node_actions_title, + subtitle = item.name, + listOf( + editItem(R.string.manage_node_action_edit) { editNode(item.unformattedUrl) }, + deleteItem(R.string.manage_node_action_delete) { deleteNode(item) } + ) + ) + } else { + editNode(item.unformattedUrl) + } + } + } + + fun addNewNode() { + router.openCustomNode(CustomNodePayload(payload.chainId, CustomNodePayload.Mode.Add)) + } + + fun networkActionsClicked() { + listSelectorMixin.showSelector( + R.string.manage_network_actions_title, + listOf( + editItem(R.string.manage_network_action_edit, ::editNetwork), + deleteItem(R.string.manage_network_action_delete, ::deleteNetwork) + ) + ) + } + + private fun editNetwork() { + router.openEditNetwork(AddNetworkPayload.Mode.Edit(payload.chainId)) + } + + private fun deleteNetwork() { + launch { + confirmAccountDeletion.awaitAction( + Pair( + resourceManager.getString(R.string.manage_network_delete_title), + resourceManager.getString(R.string.manage_network_delete_message) + ) + ) + + networkManagementChainInteractor.deleteNetwork(payload.chainId) + router.back() + } + } + + private fun editNode(nodeUrl: String) { + router.openCustomNode(CustomNodePayload(payload.chainId, CustomNodePayload.Mode.Edit(nodeUrl))) + } + + private fun deleteNode(item: NetworkNodeRvItem) { + launch { + confirmAccountDeletion.awaitAction( + Pair( + resourceManager.getString(R.string.manage_network_delete_title), + resourceManager.getString(R.string.manage_network_delete_message, item.name) + ) + ) + + networkManagementChainInteractor.deleteNode(chainId = payload.chainId, unformattedNodeUrl = item.unformattedUrl) + } + } + + private fun editItem(textRes: Int, action: () -> Unit): ListSelectorMixin.Item { + return ListSelectorMixin.Item( + R.drawable.ic_pencil_edit, + R.color.icon_primary, + textRes, + R.color.text_primary, + action + ) + } + + private fun deleteItem(textRes: Int, action: () -> Unit): ListSelectorMixin.Item { + return ListSelectorMixin.Item( + R.drawable.ic_pencil_edit, + R.color.icon_negative, + textRes, + R.color.text_negative, + action + ) + } + + private fun mapNodeToUi(nodeHealthState: NodeHealthState, networkState: ChainNetworkState): NetworkNodeRvItem { + val selectingAvailable = !networkState.chain.autoBalanceEnabled && networkState.chain.isEnabled + + return NetworkNodeRvItem( + id = nodeHealthState.node.unformattedUrl, + name = nodeHealthState.node.name, + unformattedUrl = nodeHealthState.node.unformattedUrl, + isEditable = nodeHealthState.node.isCustom, + isDeletable = networkState.nodeHealthStates.size > 1 && nodeHealthState.node.isCustom, + isSelected = nodeHealthState.node.unformattedUrl == networkState.connectingNode?.unformattedUrl, + connectionState = mapConnectionStateToUi(nodeHealthState), + isSelectable = selectingAvailable, + nameColorRes = if (selectingAvailable) R.color.text_primary else R.color.text_secondary + ) + } + + private fun mapConnectionStateToUi(nodeHealthState: NodeHealthState): ConnectionStateModel { + return when (val state = nodeHealthState.state) { + NodeHealthState.State.Connecting -> ConnectionStateModel( + name = resourceManager.getString(R.string.common_connecting), + chainStatusColor = resourceManager.getColor(R.color.text_secondary), + chainStatusIcon = R.drawable.ic_connection_status_connecting, + chainStatusIconColor = resourceManager.getColor(R.color.icon_secondary), + showShimmering = true + ) + + is NodeHealthState.State.Connected -> { + val (iconRes, textColorRes) = when { + state.ms < 99 -> R.drawable.ic_connection_status_good to R.color.text_positive + state.ms < 499 -> R.drawable.ic_connection_status_average to R.color.text_warning + else -> R.drawable.ic_connection_status_bad to R.color.text_negative + } + + ConnectionStateModel( + name = resourceManager.getString(R.string.common_connected_ms, state.ms), + chainStatusColor = resourceManager.getColor(textColorRes), + chainStatusIcon = iconRes, + chainStatusIconColor = null, + showShimmering = false + ) + } + + NodeHealthState.State.Disabled -> ConnectionStateModel( + name = null, + chainStatusColor = null, + chainStatusIcon = R.drawable.ic_connection_status_connecting, + chainStatusIconColor = resourceManager.getColor(R.color.icon_inactive), + showShimmering = false + ) + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/di/ChainNetworkManagementComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/di/ChainNetworkManagementComponent.kt new file mode 100644 index 0000000..8408b61 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/di/ChainNetworkManagementComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementFragment +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementPayload + +@Subcomponent( + modules = [ + ChainNetworkManagementModule::class + ] +) +@ScreenScope +interface ChainNetworkManagementComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ChainNetworkManagementPayload + ): ChainNetworkManagementComponent + } + + fun inject(fragment: ChainNetworkManagementFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/di/ChainNetworkManagementModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/di/ChainNetworkManagementModule.kt new file mode 100644 index 0000000..4e2f5b6 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/di/ChainNetworkManagementModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementChainInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementViewModel + +@Module(includes = [ViewModelModule::class]) +class ChainNetworkManagementModule { + + @Provides + @IntoMap + @ViewModelKey(ChainNetworkManagementViewModel::class) + fun provideViewModel( + router: SettingsRouter, + resourceManager: ResourceManager, + networkManagementChainInteractor: NetworkManagementChainInteractor, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + listSelectorMixinFactory: ListSelectorMixin.Factory, + payload: ChainNetworkManagementPayload + ): ViewModel { + return ChainNetworkManagementViewModel( + router, + resourceManager, + networkManagementChainInteractor, + actionAwaitableMixinFactory, + listSelectorMixinFactory, + payload + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ChainNetworkManagementViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ChainNetworkManagementViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/headerAdapter/ChainNetworkManagementHeaderAdapter.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/headerAdapter/ChainNetworkManagementHeaderAdapter.kt new file mode 100644 index 0000000..1ac68ae --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/headerAdapter/ChainNetworkManagementHeaderAdapter.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.headerAdapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import io.novafoundation.nova.common.list.SingleItemAdapter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_settings_impl.databinding.ItemChanNetworkManagementHeaderBinding + +class ChainNetworkManagementHeaderAdapter( + private val imageLoader: ImageLoader, + private val itemHandler: ItemHandler +) : SingleItemAdapter(isShownByDefault = true) { + + private var chainUiModel: ChainUi? = null + private var chainEnabled = false + private var autoBalanceEnabled = false + private var networkCanBeDisabled = false + + interface ItemHandler { + + fun chainEnableClicked() + + fun autoBalanceClicked() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChainNetworkManagementHeaderViewHolder { + return ChainNetworkManagementHeaderViewHolder( + ItemChanNetworkManagementHeaderBinding.inflate(parent.inflater(), parent, false), + imageLoader, + itemHandler + ) + } + + override fun onBindViewHolder(holder: ChainNetworkManagementHeaderViewHolder, position: Int) { + chainUiModel?.let { holder.bind(it, chainEnabled, autoBalanceEnabled, networkCanBeDisabled) } + } + + fun setChainUiModel(chainUiModel: ChainUi) { + this.chainUiModel = chainUiModel + notifyItemChanged(0) + } + + fun setChainEnabled(chainEnabled: Boolean) { + this.chainEnabled = chainEnabled + notifyItemChanged(0) + } + + fun setAutoBalanceEnabled(autoBalanceEnabled: Boolean) { + this.autoBalanceEnabled = autoBalanceEnabled + notifyItemChanged(0) + } + + fun setNetworkCanBeDisabled(networkCanBeDisabled: Boolean) { + this.networkCanBeDisabled = networkCanBeDisabled + } +} + +class ChainNetworkManagementHeaderViewHolder( + private val binder: ItemChanNetworkManagementHeaderBinding, + private val imageLoader: ImageLoader, + private val itemHandler: ChainNetworkManagementHeaderAdapter.ItemHandler +) : RecyclerView.ViewHolder(binder.root) { + + init { + with(binder) { + chainNetworkManagementEnable.setOnClickListener { itemHandler.chainEnableClicked() } + chainNetworkManagementAutoBalance.setOnClickListener { itemHandler.autoBalanceClicked() } + } + } + + fun bind(chainUi: ChainUi, chainEnabled: Boolean, autoBalanceEnabled: Boolean, networkCanBeDisabled: Boolean) { + with(binder) { + chainNetworkManagementIcon.loadChainIcon(chainUi.icon, imageLoader) + chainNetworkManagementTitle.text = chainUi.name + + chainNetworkManagementEnable.setChecked(chainEnabled) + chainNetworkManagementEnable.isEnabled = networkCanBeDisabled + + chainNetworkManagementAutoBalance.setChecked(autoBalanceEnabled) + chainNetworkManagementAutoBalance.isEnabled = chainEnabled + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/ChainNetworkManagementNodesAdapter.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/ChainNetworkManagementNodesAdapter.kt new file mode 100644 index 0000000..21ea75b --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/ChainNetworkManagementNodesAdapter.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter + +import android.annotation.SuppressLint +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setCompoundDrawableTint +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setShimmerShown +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.shape.getMaskedRipple +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.databinding.ItemChanNetworkManagementAddNodeButtonBinding +import io.novafoundation.nova.feature_settings_impl.databinding.ItemChanNetworkManagementNodeBinding +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkConnectionRvItem +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodeRvItem +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items.NetworkNodesAddCustomRvItem + +class ChainNetworkManagementNodesAdapter( + private val itemHandler: ItemHandler +) : ListAdapter(DiffCallback()) { + + interface ItemHandler { + + fun selectNode(item: NetworkNodeRvItem) + + fun editNode(item: NetworkNodeRvItem) + + fun addNewNode() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return when (viewType) { + R.layout.item_chan_network_management_node -> ChainNetworkManagementNodeViewHolder( + ItemChanNetworkManagementNodeBinding.inflate(parent.inflater(), parent, false), + itemHandler + ) + + R.layout.item_chan_network_management_add_node_button -> ChainNetworkManagementAddNodeButtonViewHolder( + ItemChanNetworkManagementAddNodeButtonBinding.inflate(parent.inflater(), parent, false), + itemHandler + ) + + else -> throw IllegalArgumentException("Unknown view type") + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + if (holder is ChainNetworkManagementNodeViewHolder) { + holder.bind(getItem(position) as NetworkNodeRvItem) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is NetworkNodeRvItem -> R.layout.item_chan_network_management_node + is NetworkNodesAddCustomRvItem -> R.layout.item_chan_network_management_add_node_button + else -> throw IllegalArgumentException("Unknown item type") + } + } +} + +class ChainNetworkManagementAddNodeButtonViewHolder( + private val binder: ItemChanNetworkManagementAddNodeButtonBinding, + private val itemHandler: ChainNetworkManagementNodesAdapter.ItemHandler +) : ViewHolder(binder.root) { + + init { + itemView.background = itemView.context.getMaskedRipple(cornerSizeInDp = 0) + itemView.setOnClickListener { + itemHandler.addNewNode() + } + } +} + +class ChainNetworkManagementNodeViewHolder( + private val binder: ItemChanNetworkManagementNodeBinding, + private val itemHandler: ChainNetworkManagementNodesAdapter.ItemHandler +) : ViewHolder(binder.root) { + + init { + itemView.background = itemView.context.getMaskedRipple(cornerSizeInDp = 0) + } + + fun bind(item: NetworkNodeRvItem) { + with(binder) { + if (item.isSelectable) { + itemView.setOnClickListener { itemHandler.selectNode(item) } + } else { + itemView.setOnClickListener(null) + } + + chainNodeRadioButton.isChecked = item.isSelected + chainNodeRadioButton.isEnabled = item.isSelectable + chainNodeName.text = item.name + chainNodeName.setTextColorRes(item.nameColorRes) + chainNodeSocketAddress.text = item.unformattedUrl + chainNodeConnectionStatusShimmering.setShimmerShown(item.connectionState.showShimmering) + chainNodeConnectionState.setText(item.connectionState.name) + item.connectionState.chainStatusColor?.let { chainNodeConnectionState.setTextColor(it) } + chainNodeConnectionState.setDrawableStart(item.connectionState.chainStatusIcon, paddingInDp = 6) + chainNodeConnectionState.setCompoundDrawableTint(item.connectionState.chainStatusIconColor) + + chainNodeEditButton.isVisible = item.isEditable && !item.isDeletable + chainNodeManageButton.isVisible = item.isEditable && item.isDeletable + chainNodeEditButton.setOnClickListener { itemHandler.editNode(item) } + chainNodeManageButton.setOnClickListener { itemHandler.editNode(item) } + } + } +} + +private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: NetworkConnectionRvItem, newItem: NetworkConnectionRvItem): Boolean { + return when (oldItem) { + is NetworkNodeRvItem -> newItem is NetworkNodeRvItem && oldItem.id == newItem.id + is NetworkNodesAddCustomRvItem -> newItem is NetworkNodesAddCustomRvItem + else -> throw IllegalArgumentException("Unknown item type") + } + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: NetworkConnectionRvItem, newItem: NetworkConnectionRvItem): Boolean { + return oldItem == newItem + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/NodesItemBackgroundDecoration.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/NodesItemBackgroundDecoration.kt new file mode 100644 index 0000000..53d5f32 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/NodesItemBackgroundDecoration.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter + +import android.content.Context +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.decoration.BackgroundItemDecoration +import io.novafoundation.nova.common.list.decoration.DividerItemDecoration + +class NodesItemBackgroundDecoration(context: Context) : BackgroundItemDecoration( + context = context, + outerHorizontalMarginDp = 16, + innerVerticalPaddingDp = 0 +) { + + override fun shouldApplyDecoration(holder: RecyclerView.ViewHolder): Boolean { + return holder.isNodeItem() + } +} + +class NodesItemDividerDecoration(context: Context) : DividerItemDecoration(context, dividerMarginDp = 32) { + + override fun shouldApplyDecorationBetween(top: RecyclerView.ViewHolder, bottom: RecyclerView.ViewHolder): Boolean { + return top.isNodeItem() && bottom.isNodeItem() + } +} + +private fun RecyclerView.ViewHolder.isNodeItem(): Boolean { + return this is ChainNetworkManagementNodeViewHolder || this is ChainNetworkManagementAddNodeButtonViewHolder +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/items/NetworkConnectionRvItem.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/items/NetworkConnectionRvItem.kt new file mode 100644 index 0000000..7526bf6 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/items/NetworkConnectionRvItem.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items + +interface NetworkConnectionRvItem diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/items/NetworkNodeRvItem.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/items/NetworkNodeRvItem.kt new file mode 100644 index 0000000..e07fa0c --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/items/NetworkNodeRvItem.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items + +import androidx.annotation.ColorRes +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common.ConnectionStateModel + +data class NetworkNodeRvItem( + val id: String, + val name: String, + @ColorRes val nameColorRes: Int, + val unformattedUrl: String, + val isEditable: Boolean, + val isDeletable: Boolean, + val isSelected: Boolean, + val connectionState: ConnectionStateModel, + val isSelectable: Boolean +) : NetworkConnectionRvItem diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/items/NetworkNodesAddCustomRvItem.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/items/NetworkNodesAddCustomRvItem.kt new file mode 100644 index 0000000..10489ca --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/chain/nodeAdapter/items/NetworkNodesAddCustomRvItem.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.nodeAdapter.items + +class NetworkNodesAddCustomRvItem : NetworkConnectionRvItem diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/common/ConnectionStateModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/common/ConnectionStateModel.kt new file mode 100644 index 0000000..fceb63d --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/common/ConnectionStateModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common + +data class ConnectionStateModel( + val name: String?, + val chainStatusColor: Int?, + val chainStatusIcon: Int, + val chainStatusIconColor: Int?, + val showShimmering: Boolean +) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/NetworkManagementListFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/NetworkManagementListFragment.kt new file mode 100644 index 0000000..2105b8e --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/NetworkManagementListFragment.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main + +import android.os.Bundle +import android.os.Handler +import android.os.Looper + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setupWithViewPager2 +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentNetworkManagementBinding +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent + +class NetworkManagementListFragment : BaseFragment() { + + companion object { + + private const val KEY_OPEN_ADDED_TAB = "key_payload" + + fun getBundle(openAddedTab: Boolean): Bundle { + return Bundle().apply { + putBoolean(KEY_OPEN_ADDED_TAB, openAddedTab) + } + } + } + + override fun createBinding() = FragmentNetworkManagementBinding.inflate(layoutInflater) + + override fun initViews() { + binder.networkManagementToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.networkManagementToolbar.setRightActionClickListener { viewModel.addNetworkClicked() } + + val adapter = NetworkManagementPagerAdapter(this) + binder.networkManagementViewPager.adapter = adapter + binder.networkManagementTabLayout.setupWithViewPager2(binder.networkManagementViewPager, adapter::getPageTitle) + + Handler(Looper.getMainLooper()).post { + setDefaultTab(adapter) + } + } + + private fun setDefaultTab(adapter: NetworkManagementPagerAdapter) { + val openAddedTab = argumentOrNull(KEY_OPEN_ADDED_TAB) ?: return + val tabIndex = if (openAddedTab) adapter.addedTabIndex() else adapter.defaultTabIndex() + binder.networkManagementViewPager.currentItem = tabIndex + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .networkManagementListFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: NetworkManagementListViewModel) { + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/NetworkManagementListViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/NetworkManagementListViewModel.kt new file mode 100644 index 0000000..f270b69 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/NetworkManagementListViewModel.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_settings_impl.SettingsRouter + +class NetworkManagementListViewModel( + private val router: SettingsRouter +) : BaseViewModel() { + + fun backClicked() { + router.back() + } + + fun addNetworkClicked() { + router.addNetwork() + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/NetworkManagementPagerAdapter.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/NetworkManagementPagerAdapter.kt new file mode 100644 index 0000000..c59ad7b --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/NetworkManagementPagerAdapter.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.AddedNetworkListFragment +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.ExistingNetworkListFragment + +class NetworkManagementPagerAdapter(private val fragment: Fragment) : FragmentStateAdapter(fragment) { + + override fun getItemCount(): Int { + return 2 + } + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> ExistingNetworkListFragment() + 1 -> AddedNetworkListFragment() + else -> throw IllegalArgumentException("Invalid position") + } + } + + fun getPageTitle(position: Int): CharSequence { + return when (position) { + defaultTabIndex() -> fragment.getString(R.string.network_management_default_page_title) + addedTabIndex() -> fragment.getString(R.string.network_management_added_page_title) + else -> throw IllegalArgumentException("Invalid position") + } + } + + fun defaultTabIndex(): Int { + return 0 + } + + fun addedTabIndex(): Int { + return 1 + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/di/NetworkManagementListComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/di/NetworkManagementListComponent.kt new file mode 100644 index 0000000..1c0f46a --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/di/NetworkManagementListComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.NetworkManagementListFragment + +@Subcomponent( + modules = [ + NetworkManagementListModule::class + ] +) +@ScreenScope +interface NetworkManagementListComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): NetworkManagementListComponent + } + + fun inject(fragment: NetworkManagementListFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/di/NetworkManagementListModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/di/NetworkManagementListModule.kt new file mode 100644 index 0000000..559de24 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/main/di/NetworkManagementListModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.main.NetworkManagementListViewModel + +@Module(includes = [ViewModelModule::class]) +class NetworkManagementListModule { + + @Provides + @IntoMap + @ViewModelKey(NetworkManagementListViewModel::class) + fun provideViewModel( + router: SettingsRouter + ): ViewModel { + return NetworkManagementListViewModel( + router + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): NetworkManagementListViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NetworkManagementListViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/AddedNetworkListFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/AddedNetworkListFragment.kt new file mode 100644 index 0000000..4478ebd --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/AddedNetworkListFragment.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks + +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.list.EditablePlaceholderAdapter +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.ViewSpace +import io.novafoundation.nova.common.view.PlaceholderModel +import io.novafoundation.nova.common.view.PlaceholderView +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.adapter.NetworksBannerAdapter +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListFragment + +class AddedNetworkListFragment : NetworkListFragment(), NetworksBannerAdapter.ItemHandler { + + private val bannerAdapter = NetworksBannerAdapter(this) + + private val placeholderAdapter = EditablePlaceholderAdapter() + + override val adapter: RecyclerView.Adapter<*> by lazy(LazyThreadSafetyMode.NONE) { + ConcatAdapter(bannerAdapter, placeholderAdapter, networksAdapter) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .addedNetworkListFactory() + .create(this) + .inject(this) + } + + override fun initViews() { + super.initViews() + placeholderAdapter.setPadding(ViewSpace(top = 64.dp)) + placeholderAdapter.setPlaceholderData( + PlaceholderModel( + requireContext().getString(R.string.added_networks_empty_placeholder), + R.drawable.ic_no_added_networks, + requireContext().getString(R.string.common_add_network), + PlaceholderView.Style.NO_BACKGROUND, + imageTint = null + ) + ) + placeholderAdapter.setButtonClickListener { viewModel.addNetworkClicked() } + } + + override fun subscribe(viewModel: AddedNetworkListViewModel) { + super.subscribe(viewModel) + + observeBrowserEvents(viewModel) + + viewModel.showBanner.observe { bannerAdapter.show(it) } + viewModel.networkList.observe { placeholderAdapter.show(it.isEmpty()) } + } + + override fun closeBannerClicked() { + viewModel.closeBannerClicked() + } + + override fun bannerWikiLinkClicked() { + viewModel.bannerWikiClicked() + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/AddedNetworkListViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/AddedNetworkListViewModel.kt new file mode 100644 index 0000000..5e3b922 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/AddedNetworkListViewModel.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListViewModel +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkListRvItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +class AddedNetworkListViewModel( + private val networkManagementInteractor: NetworkManagementInteractor, + private val networkListAdapterItemFactory: NetworkListAdapterItemFactory, + private val appLinksProvider: AppLinksProvider, + private val router: SettingsRouter +) : NetworkListViewModel(router), Browserable { + + private val networks = networkManagementInteractor.addedNetworksFlow() + .shareInBackground() + + override val networkList: Flow> = networks.mapList { + networkListAdapterItemFactory.getNetworkItem(it) + } + + override val openBrowserEvent = MutableLiveData>() + + val showBanner = networkManagementInteractor.shouldShowBanner() + .shareInBackground() + + fun closeBannerClicked() { + launch { + networkManagementInteractor.hideBanner() + } + } + + fun bannerWikiClicked() { + openBrowserEvent.value = appLinksProvider.integrateNetwork.event() + } + + fun addNetworkClicked() { + router.addNetwork() + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/adapter/IntegrateNetworksBannerAdapter.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/adapter/IntegrateNetworksBannerAdapter.kt new file mode 100644 index 0000000..8590148 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/adapter/IntegrateNetworksBannerAdapter.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.SingleItemAdapter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_settings_impl.databinding.ItemIntegrateNetworksBannerBinding + +class NetworksBannerAdapter( + private val itemHandler: ItemHandler +) : SingleItemAdapter() { + + interface ItemHandler { + + fun closeBannerClicked() + + fun bannerWikiLinkClicked() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NetworkBannerViewHolder { + return NetworkBannerViewHolder(ItemIntegrateNetworksBannerBinding.inflate(parent.inflater(), parent, false), itemHandler) + } + + override fun onBindViewHolder(holder: NetworkBannerViewHolder, position: Int) { + // Not need to bind anything + } +} + +class NetworkBannerViewHolder( + private val binder: ItemIntegrateNetworksBannerBinding, + private val itemHandler: NetworksBannerAdapter.ItemHandler +) : RecyclerView.ViewHolder(binder.root) { + + init { + with(binder) { + integrateNetworkBannerClose.setOnClickListener { itemHandler.closeBannerClicked() } + integrateNetworkBannerLink.setOnClickListener { itemHandler.bannerWikiLinkClicked() } + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/di/AddedNetworkListComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/di/AddedNetworkListComponent.kt new file mode 100644 index 0000000..c93161d --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/di/AddedNetworkListComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.AddedNetworkListFragment + +@Subcomponent( + modules = [ + AddedNetworkListModule::class + ] +) +@ScreenScope +interface AddedNetworkListComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): AddedNetworkListComponent + } + + fun inject(fragment: AddedNetworkListFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/di/AddedNetworkListModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/di/AddedNetworkListModule.kt new file mode 100644 index 0000000..1e31659 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/addedNetworks/di/AddedNetworkListModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.addedNetworks.AddedNetworkListViewModel + +@Module(includes = [ViewModelModule::class]) +class AddedNetworkListModule { + + @Provides + @IntoMap + @ViewModelKey(AddedNetworkListViewModel::class) + fun provideViewModel( + networkManagementInteractor: NetworkManagementInteractor, + networkListAdapterItemFactory: NetworkListAdapterItemFactory, + appLinksProvider: AppLinksProvider, + router: SettingsRouter + ): ViewModel { + return AddedNetworkListViewModel( + networkManagementInteractor, + networkListAdapterItemFactory, + appLinksProvider, + router + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): AddedNetworkListViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AddedNetworkListViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt new file mode 100644 index 0000000..e51b868 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.chain.asIconOrFallback +import io.novafoundation.nova.feature_account_api.presenatation.chain.iconOrFallback +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.domain.NetworkState +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common.ConnectionStateModel +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkListRvItem +import io.novafoundation.nova.runtime.ext.isDisabled +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.LightChain +import io.novasama.substrate_sdk_android.wsrpc.state.SocketStateMachine + +interface NetworkListAdapterItemFactory { + + fun getNetworkItem(network: NetworkState): NetworkListRvItem + + fun getNetworkItem(network: LightChain): NetworkListRvItem +} + +class RealNetworkListAdapterItemFactory( + private val resourceManager: ResourceManager +) : NetworkListAdapterItemFactory { + + override fun getNetworkItem(network: NetworkState): NetworkListRvItem { + val chain = network.chain + val subtitle = if (chain.isDisabled) resourceManager.getString(R.string.common_disabled) else null + val label = getChainLabel(chain) + return NetworkListRvItem( + chainIcon = chain.iconOrFallback(), + chainId = chain.id, + title = chain.name, + subtitle = subtitle, + chainLabel = label, + disabled = chain.isDisabled, + status = getConnectingState(network) + ) + } + + override fun getNetworkItem(network: LightChain): NetworkListRvItem { + return NetworkListRvItem( + chainIcon = network.icon.asIconOrFallback(), + chainId = network.id, + title = network.name, + subtitle = null, + chainLabel = null, + disabled = false, + status = null + ) + } + + private fun getChainLabel(chain: Chain): String? { + return if (chain.isTestNet) { + resourceManager.getString(R.string.common_testnet) + } else { + null + } + } + + private fun getConnectingState(network: NetworkState): ConnectionStateModel? { + if (network.chain.isDisabled) return null + + return when (network.connectionState) { + is SocketStateMachine.State.Connected -> null + + else -> ConnectionStateModel( + name = resourceManager.getString(R.string.common_connecting), + chainStatusColor = resourceManager.getColor(R.color.text_primary), + chainStatusIcon = R.drawable.ic_connection_status_connecting, + chainStatusIconColor = resourceManager.getColor(R.color.icon_primary), + showShimmering = true + ) + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListFragment.kt new file mode 100644 index 0000000..c11c6f0 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListFragment.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common + +import androidx.recyclerview.widget.RecyclerView + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentNetworkListBinding +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkManagementListAdapter +import javax.inject.Inject + +abstract class NetworkListFragment : BaseFragment(), NetworkManagementListAdapter.ItemHandler { + + override fun createBinding() = FragmentNetworkListBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + protected val networksAdapter by lazy(LazyThreadSafetyMode.NONE) { NetworkManagementListAdapter(imageLoader, this) } + + protected abstract val adapter: RecyclerView.Adapter<*> + + override fun initViews() { + binder.networkList.adapter = adapter + binder.networkList.itemAnimator = null + } + + override fun subscribe(viewModel: T) { + viewModel.networkList.observe { networksAdapter.submitList(it) } + } + + override fun onNetworkClicked(chainId: String) { + viewModel.onNetworkClicked(chainId) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListViewModel.kt new file mode 100644 index 0000000..af4e9c5 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListViewModel.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.chain.ChainNetworkManagementPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkListRvItem +import kotlinx.coroutines.flow.Flow + +abstract class NetworkListViewModel( + private val router: SettingsRouter +) : BaseViewModel() { + + abstract val networkList: Flow> + + fun onNetworkClicked(chainId: String) { + router.openNetworkDetails(ChainNetworkManagementPayload(chainId)) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/adapter/NetworkListRvItem.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/adapter/NetworkListRvItem.kt new file mode 100644 index 0000000..34d7354 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/adapter/NetworkListRvItem.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.common.ConnectionStateModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +data class NetworkListRvItem( + val chainIcon: Icon, + val chainId: ChainId, + val title: String, + val subtitle: String?, + val chainLabel: String?, + val disabled: Boolean, + val status: ConnectionStateModel? +) diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/adapter/NetworkManagementListAdapter.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/adapter/NetworkManagementListAdapter.kt new file mode 100644 index 0000000..ac6b846 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/adapter/NetworkManagementListAdapter.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter + +import android.annotation.SuppressLint +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import coil.ImageLoader +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setCompoundDrawableTint +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.shape.getMaskedRipple +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.databinding.ItemNetworkSettingsBinding + +class NetworkManagementListAdapter( + private val imageLoader: ImageLoader, + private val itemHandler: ItemHandler +) : ListAdapter(NetworkManagementListDiffCallback()) { + + interface ItemHandler { + + fun onNetworkClicked(chainId: String) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NetworkListViewHolder { + return NetworkListViewHolder(ItemNetworkSettingsBinding.inflate(parent.inflater(), parent, false), imageLoader, itemHandler) + } + + override fun onBindViewHolder(holder: NetworkListViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +class NetworkManagementListDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: NetworkListRvItem, newItem: NetworkListRvItem): Boolean { + return oldItem.chainId == newItem.chainId + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: NetworkListRvItem, newItem: NetworkListRvItem): Boolean { + return oldItem == newItem + } +} + +class NetworkListViewHolder( + private val binder: ItemNetworkSettingsBinding, + private val imageLoader: ImageLoader, + private val itemHandler: NetworkManagementListAdapter.ItemHandler +) : ViewHolder(binder.root) { + + init { + itemView.background = itemView.context.getMaskedRipple(cornerSizeInDp = 0) + } + + fun bind(item: NetworkListRvItem) = with(binder) { + itemView.setOnClickListener { itemHandler.onNetworkClicked(item.chainId) } + + itemNetworkImage.setIcon(item.chainIcon, imageLoader) + itemNetworkTitle.text = item.title + itemNetworkSubtitle.setTextOrHide(item.subtitle) + itemNetworkLabel.setTextOrHide(item.chainLabel) + + itemNetworkStatusShimmer.isVisible = item.status != null + if (item.status != null) { + itemNetworkStatus.setText(item.status.name) + item.status.chainStatusColor?.let { itemNetworkStatus.setTextColor(it) } + itemNetworkStatus.setDrawableStart(item.status.chainStatusIcon, paddingInDp = 6) + itemNetworkStatus.setCompoundDrawableTint(item.status.chainStatusIconColor) + } + + if (item.disabled) { + itemNetworkImage.alpha = 0.32f + itemNetworkTitle.setTextColorRes(R.color.text_secondary) + } else { + itemNetworkImage.alpha = 1f + itemNetworkTitle.setTextColorRes(R.color.text_primary) + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/ExistingNetworkListFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/ExistingNetworkListFragment.kt new file mode 100644 index 0000000..e326160 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/ExistingNetworkListFragment.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks + +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListFragment + +class ExistingNetworkListFragment : NetworkListFragment() { + + override val adapter: RecyclerView.Adapter<*> by lazy(LazyThreadSafetyMode.NONE) { networksAdapter } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .existingNetworkListFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/ExistingNetworkListViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/ExistingNetworkListViewModel.kt new file mode 100644 index 0000000..02ef741 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/ExistingNetworkListViewModel.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks + +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListViewModel +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkListRvItem +import kotlinx.coroutines.flow.Flow + +class ExistingNetworkListViewModel( + private val networkManagementInteractor: NetworkManagementInteractor, + private val networkListAdapterItemFactory: NetworkListAdapterItemFactory, + router: SettingsRouter +) : NetworkListViewModel(router) { + + private val networks = networkManagementInteractor.defaultNetworksFlow() + .shareInBackground() + + override val networkList: Flow> = networks.mapList { + networkListAdapterItemFactory.getNetworkItem(it) + }.shareInBackground() +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/di/ExistingNetworkListComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/di/ExistingNetworkListComponent.kt new file mode 100644 index 0000000..c9a3f9f --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/di/ExistingNetworkListComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.ExistingNetworkListFragment + +@Subcomponent( + modules = [ + ExistingNetworkListModule::class + ] +) +@ScreenScope +interface ExistingNetworkListComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): ExistingNetworkListComponent + } + + fun inject(fragment: ExistingNetworkListFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/di/ExistingNetworkListModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/di/ExistingNetworkListModule.kt new file mode 100644 index 0000000..f98aa96 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/defaultNetworks/di/ExistingNetworkListModule.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.NetworkManagementInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.defaultNetworks.ExistingNetworkListViewModel + +@Module(includes = [ViewModelModule::class]) +class ExistingNetworkListModule { + + @Provides + @IntoMap + @ViewModelKey(ExistingNetworkListViewModel::class) + fun provideViewModel( + networkManagementInteractor: NetworkManagementInteractor, + networkListAdapterItemFactory: NetworkListAdapterItemFactory, + router: SettingsRouter + ): ViewModel { + return ExistingNetworkListViewModel( + networkManagementInteractor, + networkListAdapterItemFactory, + router + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): ExistingNetworkListViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ExistingNetworkListViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/PreConfiguredNetworksFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/PreConfiguredNetworksFragment.kt new file mode 100644 index 0000000..13b9183 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/PreConfiguredNetworksFragment.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks + +import androidx.core.view.isVisible +import androidx.lifecycle.viewModelScope +import androidx.recyclerview.widget.ConcatAdapter + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.progress.observeProgressDialog +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentPreConfiguredNetworkListBinding +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.adapter.NetworkManagementListAdapter +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.adapter.AddCustomNetworkAdapter +import javax.inject.Inject + +class PreConfiguredNetworksFragment : + BaseFragment(), + NetworkManagementListAdapter.ItemHandler, + AddCustomNetworkAdapter.ItemHandler { + + override fun createBinding() = FragmentPreConfiguredNetworkListBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val addCustomNetworkAdapter = AddCustomNetworkAdapter(this) + + private val networksAdapter by lazy(LazyThreadSafetyMode.NONE) { NetworkManagementListAdapter(imageLoader, this) } + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { ConcatAdapter(addCustomNetworkAdapter, networksAdapter) } + + override fun initViews() { + binder.preConfiguredNetworksToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.preConfiguredNetworkList.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .preConfiguredNetworks() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: PreConfiguredNetworksViewModel) { + observeRetries(viewModel) + observeProgressDialog(viewModel.progressDialogMixin) + + binder.preConfiguredNetworksSearch.content.bindTo(viewModel.searchQuery, viewModel.viewModelScope) + viewModel.networkList.observe { + binder.preConfiguredNetworkProgress.isVisible = it.isLoading() + if (it is ExtendedLoadingState.Loaded) { + networksAdapter.submitList(it.data) + } + } + } + + override fun onNetworkClicked(chainId: String) { + viewModel.networkClicked(chainId) + } + + override fun onAddNetworkClicked() { + viewModel.addNetworkClicked() + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/PreConfiguredNetworksViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/PreConfiguredNetworksViewModel.kt new file mode 100644 index 0000000..700ddc5 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/PreConfiguredNetworksViewModel.kt @@ -0,0 +1,123 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.RetryPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.common.utils.progress.startProgress +import io.novafoundation.nova.feature_push_notifications.R +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.PreConfiguredNetworksInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.add.main.AddNetworkPayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory +import io.novafoundation.nova.runtime.ext.networkType +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.LightChain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType +import io.novafoundation.nova.runtime.util.ChainParcel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +typealias LoadingNetworks = ExtendedLoadingState> + +class PreConfiguredNetworksViewModel( + private val interactor: PreConfiguredNetworksInteractor, + private val networkListAdapterItemFactory: NetworkListAdapterItemFactory, + private val router: SettingsRouter, + private val resourceManager: ResourceManager, + private val chainRegistry: ChainRegistry, + private val progressDialogMixinFactory: ProgressDialogMixinFactory +) : BaseViewModel(), Retriable { + + val progressDialogMixin = progressDialogMixinFactory.create() + + val searchQuery: MutableStateFlow = MutableStateFlow("") + private val allPreConfiguredNetworksFlow = MutableStateFlow(ExtendedLoadingState.Loading) + + private val networks = combine( + allPreConfiguredNetworksFlow, + searchQuery, + chainRegistry.chainsById + ) { preConfiguredNetworks, query, currentChains -> + preConfiguredNetworks.map { + val filteredNetworks = interactor.excludeChains(it, currentChains.keys) + interactor.searchNetworks(query, filteredNetworks) + } + } + + val networkList = networks.mapLoading { networks -> + networks.map { networkListAdapterItemFactory.getNetworkItem(it) } + } + + override val retryEvent: MutableLiveData> = MutableLiveData() + + init { + fetchPreConfiguredNetworks() + } + + fun backClicked() { + router.back() + } + + fun networkClicked(chainId: String) { + launch { + progressDialogMixin.startProgress(R.string.loading_network_info) { + interactor.getPreConfiguredNetwork(chainId) + .onSuccess { + openAddChainScreen(it) + } + .onFailure { showError(resourceManager.getString(R.string.common_something_went_wrong_title)) } + } + } + } + + private fun openAddChainScreen(chain: Chain) { + val networkType = when (chain.networkType()) { + NetworkType.SUBSTRATE -> AddNetworkPayload.Mode.Add.NetworkType.SUBSTRATE + NetworkType.EVM -> AddNetworkPayload.Mode.Add.NetworkType.EVM + } + + val payload = AddNetworkPayload.Mode.Add( + networkType = networkType, + ChainParcel(chain) + ) + + router.openCreateNetworkFlow(payload) + } + + fun addNetworkClicked() { + router.openCreateNetworkFlow() + } + + private fun fetchPreConfiguredNetworks() { + launch { + allPreConfiguredNetworksFlow.value = ExtendedLoadingState.Loading + + interactor.getPreConfiguredNetworks() + .onSuccess { + allPreConfiguredNetworksFlow.value = ExtendedLoadingState.Loaded(it) + }.onFailure { + launchRetryDialog() + } + } + } + + private fun launchRetryDialog() { + retryEvent.value = Event( + RetryPayload( + title = resourceManager.getString(R.string.common_error_general_title), + message = resourceManager.getString(R.string.common_retry_message), + onRetry = { fetchPreConfiguredNetworks() }, + onCancel = { router.back() } + ) + ) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/adapter/AddCustomNetworkAdapter.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/adapter/AddCustomNetworkAdapter.kt new file mode 100644 index 0000000..de589bf --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/adapter/AddCustomNetworkAdapter.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.utils.inflateChild +import io.novafoundation.nova.common.view.shape.getMaskedRipple +import io.novafoundation.nova.feature_settings_impl.R + +class AddCustomNetworkAdapter( + private val itemHandler: ItemHandler +) : RecyclerView.Adapter() { + + interface ItemHandler { + + fun onAddNetworkClicked() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddCustomNetworkViewHolder { + return AddCustomNetworkViewHolder(parent, itemHandler) + } + + override fun getItemCount(): Int { + return 1 + } + + override fun onBindViewHolder(holder: AddCustomNetworkViewHolder, position: Int) {} +} + +class AddCustomNetworkViewHolder( + parent: ViewGroup, + private val itemHandler: AddCustomNetworkAdapter.ItemHandler +) : ViewHolder(parent.inflateChild(R.layout.item_add_custom_network)) { + + init { + itemView.background = itemView.context.getMaskedRipple(cornerSizeInDp = 0) + itemView.setOnClickListener { itemHandler.onAddNetworkClicked() } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/di/PreConfiguredNetworksComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/di/PreConfiguredNetworksComponent.kt new file mode 100644 index 0000000..06ea0bf --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/di/PreConfiguredNetworksComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.PreConfiguredNetworksFragment + +@Subcomponent( + modules = [ + PreConfiguredNetworksModule::class + ] +) +@ScreenScope +interface PreConfiguredNetworksComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): PreConfiguredNetworksComponent + } + + fun inject(fragment: PreConfiguredNetworksFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/di/PreConfiguredNetworksModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/di/PreConfiguredNetworksModule.kt new file mode 100644 index 0000000..f8411e8 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/preConfiguredNetworks/di/PreConfiguredNetworksModule.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.progress.ProgressDialogMixinFactory +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.PreConfiguredNetworksInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.common.NetworkListAdapterItemFactory +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.networkList.preConfiguredNetworks.PreConfiguredNetworksViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class PreConfiguredNetworksModule { + + @Provides + @IntoMap + @ViewModelKey(PreConfiguredNetworksViewModel::class) + fun provideViewModel( + preConfiguredNetworksInteractor: PreConfiguredNetworksInteractor, + networkListAdapterItemFactory: NetworkListAdapterItemFactory, + router: SettingsRouter, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + progressDialogMixinFactory: ProgressDialogMixinFactory, + coinGeckoLinkParser: CoinGeckoLinkParser + ): ViewModel { + return PreConfiguredNetworksViewModel( + interactor = preConfiguredNetworksInteractor, + networkListAdapterItemFactory = networkListAdapterItemFactory, + router = router, + resourceManager = resourceManager, + chainRegistry = chainRegistry, + progressDialogMixinFactory = progressDialogMixinFactory + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): PreConfiguredNetworksViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PreConfiguredNetworksViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/CustomNodeFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/CustomNodeFragment.kt new file mode 100644 index 0000000..da53840 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/CustomNodeFragment.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node + +import android.os.Bundle + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentCustomNodeBinding +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent +import javax.inject.Inject + +class CustomNodeFragment : BaseFragment() { + + companion object { + + private const val EXTRA_PAYLOAD = "payload" + + fun getBundle(payload: CustomNodePayload): Bundle { + return Bundle().apply { + putParcelable(EXTRA_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentCustomNodeBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun initViews() { + binder.customNodeToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.customNodeApplyButton.prepareForProgress(this) + binder.customNodeApplyButton.setOnClickListener { viewModel.saveNodeClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .customNodeFactory() + .create(this, argument(EXTRA_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: CustomNodeViewModel) { + observeValidations(viewModel) + + binder.customNodeTitle.text = viewModel.getTitle() + binder.customNodeUrlInput.bindTo(viewModel.nodeUrlInput, viewModel) + binder.customNodeNameInput.bindTo(viewModel.nodeNameInput, viewModel) + viewModel.buttonState.observe { + binder.customNodeApplyButton.setState(it) + } + + viewModel.chainModel.observe { + binder.customNodeChain.setChain(it) + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/CustomNodePayload.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/CustomNodePayload.kt new file mode 100644 index 0000000..33e5081 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/CustomNodePayload.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class CustomNodePayload( + val chainId: String, + val mode: Mode +) : Parcelable { + + sealed interface Mode : Parcelable { + + @Parcelize + object Add : Mode + + @Parcelize + class Edit(val url: String) : Mode + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/CustomNodeViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/CustomNodeViewModel.kt new file mode 100644 index 0000000..d2e817f --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/CustomNodeViewModel.kt @@ -0,0 +1,120 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.CustomNodeInteractor +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.NetworkNodePayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class CustomNodeViewModel( + private val router: SettingsRouter, + private val resourceManager: ResourceManager, + private val payload: CustomNodePayload, + private val customNodeInteractor: CustomNodeInteractor, + private val validationExecutor: ValidationExecutor, + private val chainRegistry: ChainRegistry +) : BaseViewModel(), Validatable by validationExecutor { + + val chainModel = flowOf { chainRegistry.getChain(payload.chainId) } + .map { mapChainToUi(it) } + + val nodeUrlInput = MutableStateFlow("") + val nodeNameInput = MutableStateFlow("") + + private val saveProgressFlow = MutableStateFlow(false) + + val buttonState = combine(saveProgressFlow, nodeUrlInput, nodeNameInput) { validationProgress, url, name -> + when { + validationProgress -> DescriptiveButtonState.Loading + + url.isBlank() || name.isBlank() -> { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.custom_node_disabled_button_state)) + } + + else -> getEnabledButtonState() + } + } + + init { + launch { + if (payload.mode is CustomNodePayload.Mode.Edit) { + customNodeInteractor.getNodeDetails(payload.chainId, payload.mode.url) + .onSuccess { node -> + nodeUrlInput.value = node.unformattedUrl + nodeNameInput.value = node.name + }.onFailure { + router.back() + } + } + } + } + + fun backClicked() { + router.back() + } + + fun getTitle(): String { + return when (payload.mode) { + CustomNodePayload.Mode.Add -> resourceManager.getString(R.string.custom_node_add_title) + + is CustomNodePayload.Mode.Edit -> resourceManager.getString(R.string.custom_node_edit_title) + } + } + + fun saveNodeClicked() { + launch { + saveProgressFlow.value = true + val validationPayload = NetworkNodePayload( + chain = chainRegistry.getChain(payload.chainId), + nodeUrl = nodeUrlInput.value + ) + + validationExecutor.requireValid( + validationSystem = customNodeInteractor.getValidationSystem( + viewModelScope, + skipNodeExistValidation = payload.mode is CustomNodePayload.Mode.Edit + ), + payload = validationPayload, + progressConsumer = saveProgressFlow.progressConsumer(), + validationFailureTransformerCustom = { status, actions -> mapSaveCustomNodeFailureToUI(resourceManager, status) } + ) { + saveProgressFlow.value = false + + executeSaving() + } + } + } + + private fun executeSaving() { + launch { + when (payload.mode) { + CustomNodePayload.Mode.Add -> customNodeInteractor.createNode(payload.chainId, nodeUrlInput.value, nodeNameInput.value) + + is CustomNodePayload.Mode.Edit -> customNodeInteractor.updateNode(payload.chainId, payload.mode.url, nodeUrlInput.value, nodeNameInput.value) + } + + router.back() + } + } + + private fun getEnabledButtonState(): DescriptiveButtonState.Enabled { + return when (payload.mode) { + CustomNodePayload.Mode.Add -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.custom_node_add_button_state)) + + is CustomNodePayload.Mode.Edit -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.custom_node_edit_button_state)) + } + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/SaveCustomNodeValidationFailureUi.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/SaveCustomNodeValidationFailureUi.kt new file mode 100644 index 0000000..66879f4 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/SaveCustomNodeValidationFailureUi.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.TransformedFailure.Default +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.domain.validation.customNode.NetworkNodeFailure + +fun mapSaveCustomNodeFailureToUI( + resourceManager: ResourceManager, + status: ValidationStatus.NotValid +): TransformedFailure { + return when (val reason = status.reason) { + is NetworkNodeFailure.NodeAlreadyExists -> Default( + resourceManager.getString(R.string.node_already_exist_failure_title) to + resourceManager.getString(R.string.node_already_exist_failure_message, reason.node.name) + ) + + NetworkNodeFailure.NodeIsNotAlive -> Default( + resourceManager.getString(R.string.node_not_alive_title) to + resourceManager.getString(R.string.node_not_alive_message) + ) + + is NetworkNodeFailure.WrongNetwork -> Default( + resourceManager.getString(R.string.node_not_supported_by_network_title) to + resourceManager.getString(R.string.node_not_supported_by_network_message, reason.chain.name) + ) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/di/CustomNodeComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/di/CustomNodeComponent.kt new file mode 100644 index 0000000..ee1291a --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/di/CustomNodeComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodeFragment +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodePayload + +@Subcomponent( + modules = [ + CustomNodeModule::class + ] +) +@ScreenScope +interface CustomNodeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: CustomNodePayload + ): CustomNodeComponent + } + + fun inject(fragment: CustomNodeFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/di/CustomNodeModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/di/CustomNodeModule.kt new file mode 100644 index 0000000..d472683 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/node/di/CustomNodeModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.domain.CustomNodeInteractor +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodePayload +import io.novafoundation.nova.feature_settings_impl.presentation.networkManagement.node.CustomNodeViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class CustomNodeModule { + + @Provides + @IntoMap + @ViewModelKey(CustomNodeViewModel::class) + fun provideViewModel( + router: SettingsRouter, + resourceManager: ResourceManager, + payload: CustomNodePayload, + customNodeInteractor: CustomNodeInteractor, + validationExecutor: ValidationExecutor, + chainRegistry: ChainRegistry + ): ViewModel { + return CustomNodeViewModel( + router, + resourceManager, + payload, + customNodeInteractor, + validationExecutor, + chainRegistry + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): CustomNodeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(CustomNodeViewModel::class.java) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/SettingsFragment.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/SettingsFragment.kt new file mode 100644 index 0000000..e2a31d6 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/SettingsFragment.kt @@ -0,0 +1,148 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.settings + +import android.content.Intent +import android.provider.Settings +import android.view.View +import android.widget.Toast + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.sendEmailIntent +import io.novafoundation.nova.common.view.dialog.dialog +import io.novafoundation.nova.feature_settings_api.SettingsFeatureApi +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.databinding.FragmentSettingsBinding +import io.novafoundation.nova.feature_settings_impl.di.SettingsFeatureComponent + +class SettingsFragment : BaseFragment() { + + override fun createBinding() = FragmentSettingsBinding.inflate(layoutInflater) + + override fun applyInsets(rootView: View) { + binder.settingsContainer.applyStatusBarInsets() + } + + override fun initViews() { + binder.accountView.setWholeClickListener { viewModel.accountActionsClicked() } + + binder.settingsWallets.setOnClickListener { viewModel.walletsClicked() } + binder.settingsNetworks.setOnClickListener { viewModel.networksClicked() } + binder.settingsPushNotifications.setOnClickListener { viewModel.pushNotificationsClicked() } + binder.settingsCurrency.setOnClickListener { viewModel.currenciesClicked() } + binder.settingsLanguage.setOnClickListener { viewModel.languagesClicked() } + binder.settingsAppearance.setOnClickListener { viewModel.appearanceClicked() } + + binder.settingsTelegram.setOnClickListener { viewModel.telegramClicked() } + binder.settingsTwitter.setOnClickListener { viewModel.twitterClicked() } + binder.settingsYoutube.setOnClickListener { viewModel.openYoutube() } + + binder.settingsWebsite.setOnClickListener { viewModel.websiteClicked() } + binder.settingsGithub.setOnClickListener { viewModel.githubClicked() } + binder.settingsTerms.setOnClickListener { viewModel.termsClicked() } + binder.settingsPrivacy.setOnClickListener { viewModel.privacyClicked() } + + binder.settingsRateUs.setOnClickListener { viewModel.rateUsClicked() } + binder.settingsWiki.setOnClickListener { viewModel.wikiClicked() } + binder.settingsEmail.setOnClickListener { viewModel.emailClicked() } + + binder.settingsBiometricAuth.setOnClickListener { viewModel.changeBiometricAuth() } + binder.settingsPinCodeVerification.setOnClickListener { viewModel.changePincodeVerification() } + binder.settingsSafeMode.setOnClickListener { viewModel.changeSafeMode() } + binder.settingsHideBalances.setOnClickListener { viewModel.changeHideBalances() } + binder.settingsPin.setOnClickListener { viewModel.changePinCodeClicked() } + + binder.settingsCloudBackup.setOnClickListener { viewModel.cloudBackupClicked() } + + binder.settingsWalletConnect.setOnClickListener { viewModel.walletConnectClicked() } + + binder.settingsAvatar.setOnClickListener { viewModel.selectedWalletClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SettingsFeatureApi::class.java + ) + .settingsComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SettingsViewModel) { + setupConfirmationDialog(R.style.AccentAlertDialogTheme, viewModel.confirmationAwaitableAction) + observeBrowserEvents(viewModel) + + viewModel.selectedWalletModel.observe { + binder.settingsAvatar.setModel(it) + + binder.accountView.setAccountIcon(it.walletIcon) + binder.accountView.setTitle(it.name) + } + + viewModel.pushNotificationsState.observe { + binder.settingsPushNotifications.setValue(it) + } + + viewModel.selectedCurrencyFlow.observe { + binder.settingsCurrency.setValue(it.code) + } + + viewModel.selectedLanguageFlow.observe { + binder.settingsLanguage.setValue(it.displayName) + } + + viewModel.showBiometricNotReadyDialogEvent.observeEvent { + showBiometricNotReadyDialog() + } + + viewModel.biometricAuthStatus.observe { + binder.settingsBiometricAuth.setChecked(it) + } + + viewModel.biometricEventMessages.observe { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + + viewModel.pinCodeVerificationStatus.observe { + binder.settingsPinCodeVerification.setChecked(it) + } + + viewModel.safeModeStatus.observe { + binder.settingsSafeMode.setChecked(it) + } + + viewModel.hideBalancesOnLaunchState.observe { + binder.settingsHideBalances.setChecked(it) + } + + viewModel.appVersionFlow.observe(binder.settingsAppVersion::setText) + + viewModel.openEmailEvent.observeEvent { requireContext().sendEmailIntent(it) } + + viewModel.walletConnectSessionsUi.observe(binder.settingsWalletConnect::setValue) + } + + override fun onResume() { + super.onResume() + viewModel.onResume() + } + + private fun showBiometricNotReadyDialog() { + dialog(requireContext(), customStyle = R.style.AccentAlertDialogTheme) { + setTitle(R.string.settings_biometric_not_ready_title) + setMessage(R.string.settings_biometric_not_ready_message) + setNegativeButton(R.string.common_cancel, null) + setPositiveButton(getString(R.string.common_settings)) { _, _ -> + startActivity(Intent(Settings.ACTION_SETTINGS)) + } + } + } + + override fun onPause() { + super.onPause() + viewModel.onPause() + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/SettingsViewModel.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/SettingsViewModel.kt new file mode 100644 index 0000000..54514c8 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/SettingsViewModel.kt @@ -0,0 +1,297 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.settings + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.actionAwaitable.fromRes +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.resources.formatBooleanToState +import io.novafoundation.nova.common.sequrity.SafeModeService +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationResult +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService +import io.novafoundation.nova.common.sequrity.biometry.BiometricResponse +import io.novafoundation.nova.common.sequrity.biometry.BiometricService +import io.novafoundation.nova.common.sequrity.biometry.mapBiometricErrors +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageUseCase +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_currency_api.presentation.mapper.mapCurrencyToUI +import io.novafoundation.nova.feature_push_notifications.data.PushNotificationsAvailabilityState +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_api.presentation.mapNumberOfActiveSessionsToUi +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class SettingsViewModel( + private val languageUseCase: LanguageUseCase, + private val router: SettingsRouter, + private val appLinksProvider: AppLinksProvider, + private val resourceManager: ResourceManager, + private val appVersionProvider: AppVersionProvider, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val currencyInteractor: CurrencyInteractor, + private val safeModeService: SafeModeService, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val walletConnectSessionsUseCase: WalletConnectSessionsUseCase, + private val twoFactorVerificationService: TwoFactorVerificationService, + private val biometricService: BiometricService, + private val pushNotificationsInteractor: PushNotificationsInteractor, + private val maskingModeUseCase: MaskingModeUseCase +) : BaseViewModel(), Browserable { + + val confirmationAwaitableAction = actionAwaitableMixinFactory.confirmingAction() + + val selectedWalletModel = selectedAccountUseCase.selectedWalletModelFlow() + .shareInBackground() + + val selectedCurrencyFlow = currencyInteractor.observeSelectCurrency() + .map { mapCurrencyToUI(it) } + .inBackground() + .share() + + val selectedLanguageFlow = flowOf { + languageUseCase.selectedLanguageModel() + } + .shareInBackground() + + val appVersionFlow = flowOf { + resourceManager.getString(R.string.about_version_template, appVersionProvider.versionName) + } + .inBackground() + .share() + + val pinCodeVerificationStatus = twoFactorVerificationService.isEnabledFlow() + + private val walletConnectSessions = walletConnectSessionsUseCase.activeSessionsNumberFlow() + .shareInBackground() + + val walletConnectSessionsUi = walletConnectSessions + .map(::mapNumberOfActiveSessionsToUi) + .shareInBackground() + + val safeModeStatus = safeModeService.safeModeStatusFlow() + + val hideBalancesOnLaunchState = maskingModeUseCase.observeHideBalancesOnLaunchEnabled() + + override val openBrowserEvent = MutableLiveData>() + + private val _openEmailEvent = MutableLiveData>() + val openEmailEvent: LiveData> = _openEmailEvent + + val biometricAuthStatus = biometricService.isEnabledFlow() + + val biometricEventMessages = biometricService.biometryServiceResponseFlow + .mapNotNull { mapBiometricErrors(resourceManager, it) } + .shareInBackground() + .asLiveData() + + val showBiometricNotReadyDialogEvent = biometricService.biometryServiceResponseFlow + .filterIsInstance() + .map { Event(true) } + .asLiveData() + + val pushNotificationsState = pushNotificationsInteractor.pushNotificationsEnabledFlow() + .map { resourceManager.formatBooleanToState(it) } + .shareInBackground() + + init { + setupBiometric() + } + + fun walletsClicked() { + router.openWallets() + } + + fun pushNotificationsClicked() { + when (pushNotificationsInteractor.pushNotificationsAvailabilityState()) { + PushNotificationsAvailabilityState.AVAILABLE -> router.openPushNotificationSettings() + + PushNotificationsAvailabilityState.PLAY_SERVICES_REQUIRED -> { + showError( + resourceManager.getString(R.string.common_not_available), + resourceManager.getString(R.string.settings_push_notifications_only_available_with_google_services_error) + ) + } + + PushNotificationsAvailabilityState.GOOGLE_PLAY_INSTALLATION_REQUIRED -> { + showError( + resourceManager.getString(R.string.common_not_available), + resourceManager.getString(R.string.settings_push_notifications_only_available_from_google_play_error) + ) + } + } + } + + fun currenciesClicked() { + router.openCurrencies() + } + + fun languagesClicked() { + router.openLanguages() + } + + fun appearanceClicked() { + router.openAppearance() + } + + fun changeBiometricAuth() { + launch { + if (biometricService.isEnabled()) { + val confirmationResult = twoFactorVerificationService.requestConfirmation(useBiometry = false) + if (confirmationResult == TwoFactorVerificationResult.CONFIRMED) { + biometricService.toggle() + } + } else { + biometricService.requestBiometric() + } + } + } + + fun changePincodeVerification() { + launch { + if (!twoFactorVerificationService.isEnabled()) { + confirmationAwaitableAction.awaitAction( + ConfirmationDialogInfo.fromRes( + resourceManager, + R.string.settings_pin_code_verification_confirmation_title, + R.string.settings_pin_code_verification_confirmation_message, + R.string.common_enable, + R.string.common_cancel + ) + ) + } + + twoFactorVerificationService.toggle() + } + } + + fun changeSafeMode() { + launch { + if (!safeModeService.isSafeModeEnabled()) { + confirmationAwaitableAction.awaitAction( + ConfirmationDialogInfo.fromRes( + resourceManager, + R.string.settings_safe_mode_confirmation_title, + R.string.settings_safe_mode_confirmation_message, + R.string.common_enable, + R.string.common_cancel + ) + ) + } + + safeModeService.toggleSafeMode() + } + } + + fun changeHideBalances() { + maskingModeUseCase.toggleHideBalancesOnLaunch() + } + + fun changePinCodeClicked() { + router.openChangePinCode() + } + + fun telegramClicked() { + openLink(appLinksProvider.telegram) + } + + fun twitterClicked() { + openLink(appLinksProvider.twitter) + } + + fun rateUsClicked() { + openLink(appLinksProvider.rateApp) + } + + fun wikiClicked() { + openLink(appLinksProvider.wikiBase) + } + + fun websiteClicked() { + openLink(appLinksProvider.website) + } + + fun githubClicked() { + openLink(appLinksProvider.github) + } + + fun termsClicked() { + openLink(appLinksProvider.termsUrl) + } + + fun privacyClicked() { + openLink(appLinksProvider.privacyUrl) + } + + fun emailClicked() { + _openEmailEvent.value = appLinksProvider.email.event() + } + + fun openYoutube() { + openLink(appLinksProvider.youtube) + } + + fun accountActionsClicked() = launch { + val selectedWalletId = selectedAccountUseCase.getSelectedMetaAccount().id + + router.openWalletDetails(selectedWalletId) + } + + fun selectedWalletClicked() { + router.openSwitchWallet() + } + + fun networksClicked() { + router.openNetworks() + } + + fun walletConnectClicked() = launch { + if (walletConnectSessions.first() > 0) { + router.openWalletConnectSessions() + } else { + router.openWalletConnectScan() + } + } + + fun cloudBackupClicked() { + router.openCloudBackupSettings() + } + + fun onResume() { + biometricService.refreshBiometryState() + } + + fun onPause() { + biometricService.cancel() + } + + private fun openLink(link: String) { + openBrowserEvent.value = link.event() + } + + private fun setupBiometric() { + biometricService.biometryServiceResponseFlow + .filterIsInstance() + .onEach { biometricService.toggle() } + .launchIn(this) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/di/SettingsComponent.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/di/SettingsComponent.kt new file mode 100644 index 0000000..2bc5cf2 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/di/SettingsComponent.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.settings.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope + +@Subcomponent( + modules = [ + SettingsModule::class + ] +) +@ScreenScope +interface SettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): SettingsComponent + } + + fun inject(settingsFragment: io.novafoundation.nova.feature_settings_impl.presentation.settings.SettingsFragment) +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/di/SettingsModule.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/di/SettingsModule.kt new file mode 100644 index 0000000..e4825cd --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/di/SettingsModule.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.settings.di + +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import io.novafoundation.nova.common.io.MainThreadExecutor +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.sequrity.SafeModeService +import io.novafoundation.nova.common.sequrity.TwoFactorVerificationService +import io.novafoundation.nova.common.sequrity.biometry.BiometricPromptFactory +import io.novafoundation.nova.common.sequrity.biometry.BiometricService +import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.language.LanguageUseCase +import io.novafoundation.nova.feature_currency_api.domain.CurrencyInteractor +import io.novafoundation.nova.feature_push_notifications.domain.interactor.PushNotificationsInteractor +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.SettingsRouter +import io.novafoundation.nova.feature_settings_impl.presentation.settings.SettingsViewModel +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase + +@Module(includes = [ViewModelModule::class]) +class SettingsModule { + + @Provides + @IntoMap + @ViewModelKey(SettingsViewModel::class) + fun provideViewModel( + languageUseCase: LanguageUseCase, + router: SettingsRouter, + appLinksProvider: AppLinksProvider, + resourceManager: ResourceManager, + appVersionProvider: AppVersionProvider, + selectedAccountUseCase: SelectedAccountUseCase, + currencyInteractor: CurrencyInteractor, + safeModeService: SafeModeService, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + walletConnectSessionsUseCase: WalletConnectSessionsUseCase, + twoFactorVerificationService: TwoFactorVerificationService, + biometricService: BiometricService, + pushNotificationsInteractor: PushNotificationsInteractor, + maskingModeUseCase: MaskingModeUseCase + ): ViewModel { + return SettingsViewModel( + languageUseCase, + router, + appLinksProvider, + resourceManager, + appVersionProvider, + selectedAccountUseCase, + currencyInteractor, + safeModeService, + actionAwaitableMixinFactory, + walletConnectSessionsUseCase, + twoFactorVerificationService, + biometricService, + pushNotificationsInteractor, + maskingModeUseCase + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): SettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SettingsViewModel::class.java) + } + + @Provides + fun provideBiometricService( + fragment: Fragment, + context: Context, + resourceManager: ResourceManager, + biometricServiceFactory: BiometricServiceFactory + ): BiometricService { + val biometricManager = BiometricManager.from(context) + val biometricPromptFactory = BiometricPromptFactory(fragment, MainThreadExecutor()) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(resourceManager.getString(R.string.biometric_auth_title)) + .setSubtitle(resourceManager.getString(R.string.pincode_biometry_dialog_subtitle)) + .setNegativeButtonText(resourceManager.getString(R.string.common_cancel)) + .build() + + return biometricServiceFactory.create(biometricManager, biometricPromptFactory, promptInfo) + } +} diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/view/WalletConnectItemView.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/view/WalletConnectItemView.kt new file mode 100644 index 0000000..eafe8a2 --- /dev/null +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/settings/view/WalletConnectItemView.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_settings_impl.presentation.settings.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import androidx.appcompat.widget.AppCompatTextView +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.getDrawableCompat +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.themed +import io.novafoundation.nova.feature_settings_impl.R +import io.novafoundation.nova.feature_settings_impl.databinding.ViewWalletConnectItemBinding +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectSessionsModel + +class WalletConnectItemView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binder = ViewWalletConnectItemBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + background = context.getDrawableCompat(R.drawable.bg_primary_list_item) + } + + fun setValue(value: WalletConnectSessionsModel) { + binder.walletConnectItemValue.setModel(value) + } +} + +class WalletConnectConnectionsChip @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : AppCompatTextView(context.themed(R.style.TextAppearance_NovaFoundation_Regular_Footnote), attrs, defStyleAttr), + WithContextExtensions by WithContextExtensions(context) { + + init { + setTextColorRes(R.color.chip_text) + setDrawableStart(R.drawable.ic_connections, widthInDp = 12, paddingInDp = 4, tint = R.color.chip_icon) + + gravity = Gravity.CENTER_VERTICAL + includeFontPadding = false + + setPadding(8.dp, 4.dp, 8.dp, 4.dp) + + background = getRoundedCornerDrawable(R.color.chips_background, cornerSizeDp = 8) + } +} + +fun WalletConnectConnectionsChip.setModel(model: WalletConnectSessionsModel) { + setTextOrHide(model.connections) +} diff --git a/feature-settings-impl/src/main/res/layout/fragment_add_network.xml b/feature-settings-impl/src/main/res/layout/fragment_add_network.xml new file mode 100644 index 0000000..37f4f37 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_add_network.xml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/fragment_add_network_main.xml b/feature-settings-impl/src/main/res/layout/fragment_add_network_main.xml new file mode 100644 index 0000000..7f3971a --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_add_network_main.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/feature-settings-impl/src/main/res/layout/fragment_appearance.xml b/feature-settings-impl/src/main/res/layout/fragment_appearance.xml new file mode 100644 index 0000000..79ff49b --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_appearance.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-settings-impl/src/main/res/layout/fragment_backup_diff.xml b/feature-settings-impl/src/main/res/layout/fragment_backup_diff.xml new file mode 100644 index 0000000..c024647 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_backup_diff.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/fragment_backup_settings.xml b/feature-settings-impl/src/main/res/layout/fragment_backup_settings.xml new file mode 100644 index 0000000..e1afd12 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_backup_settings.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/fragment_chain_network_management.xml b/feature-settings-impl/src/main/res/layout/fragment_chain_network_management.xml new file mode 100644 index 0000000..27d1a89 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_chain_network_management.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/feature-settings-impl/src/main/res/layout/fragment_custom_node.xml b/feature-settings-impl/src/main/res/layout/fragment_custom_node.xml new file mode 100644 index 0000000..e3ff7fe --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_custom_node.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-settings-impl/src/main/res/layout/fragment_network_list.xml b/feature-settings-impl/src/main/res/layout/fragment_network_list.xml new file mode 100644 index 0000000..8d05fe7 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_network_list.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/feature-settings-impl/src/main/res/layout/fragment_network_management.xml b/feature-settings-impl/src/main/res/layout/fragment_network_management.xml new file mode 100644 index 0000000..9f5f655 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_network_management.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/feature-settings-impl/src/main/res/layout/fragment_pre_configured_network_list.xml b/feature-settings-impl/src/main/res/layout/fragment_pre_configured_network_list.xml new file mode 100644 index 0000000..1bf9d2b --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_pre_configured_network_list.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + diff --git a/feature-settings-impl/src/main/res/layout/fragment_settings.xml b/feature-settings-impl/src/main/res/layout/fragment_settings.xml new file mode 100644 index 0000000..23f1021 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/fragment_settings.xml @@ -0,0 +1,295 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/item_add_custom_network.xml b/feature-settings-impl/src/main/res/layout/item_add_custom_network.xml new file mode 100644 index 0000000..599d375 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/item_add_custom_network.xml @@ -0,0 +1,28 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/item_chan_network_management_add_node_button.xml b/feature-settings-impl/src/main/res/layout/item_chan_network_management_add_node_button.xml new file mode 100644 index 0000000..1d63368 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/item_chan_network_management_add_node_button.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/item_chan_network_management_header.xml b/feature-settings-impl/src/main/res/layout/item_chan_network_management_header.xml new file mode 100644 index 0000000..da8c5aa --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/item_chan_network_management_header.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/item_chan_network_management_node.xml b/feature-settings-impl/src/main/res/layout/item_chan_network_management_node.xml new file mode 100644 index 0000000..bcf253a --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/item_chan_network_management_node.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/item_cloud_backup_account_diff.xml b/feature-settings-impl/src/main/res/layout/item_cloud_backup_account_diff.xml new file mode 100644 index 0000000..4edd519 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/item_cloud_backup_account_diff.xml @@ -0,0 +1,48 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/item_integrate_networks_banner.xml b/feature-settings-impl/src/main/res/layout/item_integrate_networks_banner.xml new file mode 100644 index 0000000..bc08342 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/item_integrate_networks_banner.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/item_network_settings.xml b/feature-settings-impl/src/main/res/layout/item_network_settings.xml new file mode 100644 index 0000000..0b7eef8 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/item_network_settings.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/view_cloud_backup_state.xml b/feature-settings-impl/src/main/res/layout/view_cloud_backup_state.xml new file mode 100644 index 0000000..399bfb9 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/view_cloud_backup_state.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-settings-impl/src/main/res/layout/view_wallet_connect_item.xml b/feature-settings-impl/src/main/res/layout/view_wallet_connect_item.xml new file mode 100644 index 0000000..ba06bd2 --- /dev/null +++ b/feature-settings-impl/src/main/res/layout/view_wallet_connect_item.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature-splash/.gitignore b/feature-splash/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/feature-splash/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature-splash/build.gradle b/feature-splash/build.gradle new file mode 100644 index 0000000..1060b3a --- /dev/null +++ b/feature-splash/build.gradle @@ -0,0 +1,41 @@ +apply plugin: 'kotlin-parcelize' + +android { + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.splash' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':common') + implementation project(':feature-account-api') + implementation project(':feature-versions-api') + + implementation kotlinDep + + implementation androidDep + + implementation daggerDep + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep +} diff --git a/feature-splash/src/main/AndroidManifest.xml b/feature-splash/src/main/AndroidManifest.xml new file mode 100644 index 0000000..099dea4 --- /dev/null +++ b/feature-splash/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/SplashRouter.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/SplashRouter.kt new file mode 100644 index 0000000..2ba450e --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/SplashRouter.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.splash + +import io.novafoundation.nova.common.navigation.SecureRouter + +interface SplashRouter : SecureRouter { + + fun openWelcomeScreen() + + fun openCreatePincode() + + fun openInitialCheckPincode() +} diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureApi.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureApi.kt new file mode 100644 index 0000000..54a8fff --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureApi.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.splash.di + +interface SplashFeatureApi diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureComponent.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureComponent.kt new file mode 100644 index 0000000..e05edd7 --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureComponent.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.splash.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.splash.SplashRouter +import io.novafoundation.nova.splash.presentation.di.SplashComponent + +@Component( + dependencies = [ + SplashFeatureDependencies::class + ], + modules = [ + SplashFeatureModule::class + ] +) +@FeatureScope +interface SplashFeatureComponent : SplashFeatureApi { + + fun splashComponentFactory(): SplashComponent.Factory + + @Component.Builder + interface Builder { + + @BindsInstance + fun router(splashRouter: SplashRouter): Builder + + fun withDependencies(deps: SplashFeatureDependencies): Builder + + fun build(): SplashFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + AccountFeatureApi::class, + VersionsFeatureApi::class + ] + ) + interface SplashFeatureDependenciesComponent : SplashFeatureDependencies +} diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureDependencies.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureDependencies.kt new file mode 100644 index 0000000..d84272f --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureDependencies.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.splash.di + +import io.novafoundation.nova.common.utils.splash.SplashPassedObserver +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor + +interface SplashFeatureDependencies { + + val splashPassedObserver: SplashPassedObserver + + fun accountRepository(): AccountRepository + + fun updateNotificationsInteractor(): UpdateNotificationsInteractor +} diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureHolder.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureHolder.kt new file mode 100644 index 0000000..8096996 --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureHolder.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.splash.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.splash.SplashRouter +import javax.inject.Inject + +@ApplicationScope +class SplashFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val splashRouter: SplashRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val splashFeatureDependencies = DaggerSplashFeatureComponent_SplashFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .versionsFeatureApi(getFeature(VersionsFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .build() + return DaggerSplashFeatureComponent.builder() + .withDependencies(splashFeatureDependencies) + .router(splashRouter) + .build() + } +} diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureModule.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureModule.kt new file mode 100644 index 0000000..b4a5ce2 --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/di/SplashFeatureModule.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.splash.di + +import dagger.Module + +@Module +class SplashFeatureModule diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/SplashBackgroundHolder.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/SplashBackgroundHolder.kt new file mode 100644 index 0000000..169ed55 --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/SplashBackgroundHolder.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.splash.presentation + +interface SplashBackgroundHolder { + fun removeSplashBackground() +} diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/SplashFragment.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/SplashFragment.kt new file mode 100644 index 0000000..6b1a2c8 --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/SplashFragment.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.splash.presentation + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.splash.databinding.FragmentSplashBinding +import io.novafoundation.nova.splash.di.SplashFeatureApi +import io.novafoundation.nova.splash.di.SplashFeatureComponent +import javax.inject.Inject + +class SplashFragment : BaseFragment() { + + override fun createBinding() = FragmentSplashBinding.inflate(layoutInflater) + + @Inject lateinit var splashViewModel: SplashViewModel + + override fun initViews() { + } + + override fun inject() { + FeatureUtils.getFeature(this, SplashFeatureApi::class.java) + .splashComponentFactory() + .create(this) + .inject(this) + } + + override fun onResume() { + super.onResume() + viewModel.openInitialDestination() + } + + override fun onDestroy() { + super.onDestroy() + + (activity as? SplashBackgroundHolder)?.removeSplashBackground() + } + + override fun subscribe(viewModel: SplashViewModel) {} +} diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/SplashViewModel.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/SplashViewModel.kt new file mode 100644 index 0000000..160dcf9 --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/SplashViewModel.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.splash.presentation + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.splash.SplashPassedObserver +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.splash.SplashRouter +import kotlinx.coroutines.launch + +class SplashViewModel( + private val router: SplashRouter, + private val repository: AccountRepository, + private val splashPassedObserver: SplashPassedObserver +) : BaseViewModel() { + + fun openInitialDestination() { + viewModelScope.launch { + if (repository.isAccountSelected()) { + openPinCode() + } else { + openWelcomeScreen() + } + + splashPassedObserver.setSplashPassed() + } + } + + private suspend fun openPinCode() { + if (repository.isCodeSet()) { + router.openInitialCheckPincode() + } else { + router.openCreatePincode() + } + } + + private fun openWelcomeScreen() { + router.openWelcomeScreen() + } +} diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/di/SplashComponent.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/di/SplashComponent.kt new file mode 100644 index 0000000..29bc03c --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/di/SplashComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.splash.presentation.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.splash.presentation.SplashFragment + +@Subcomponent( + modules = [ + SplashModule::class + ] +) +@ScreenScope +interface SplashComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): SplashComponent + } + + fun inject(fragment: SplashFragment) +} diff --git a/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/di/SplashModule.kt b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/di/SplashModule.kt new file mode 100644 index 0000000..7c7c3c0 --- /dev/null +++ b/feature-splash/src/main/java/io/novafoundation/nova/splash/presentation/di/SplashModule.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.splash.presentation.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.utils.splash.SplashPassedObserver +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.splash.SplashRouter +import io.novafoundation.nova.splash.presentation.SplashViewModel + +@Module(includes = [ViewModelModule::class]) +class SplashModule { + + @Provides + internal fun provideScannerViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): SplashViewModel { + return ViewModelProvider(fragment, factory).get(SplashViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(SplashViewModel::class) + fun provideSignInViewModel( + accountRepository: AccountRepository, + router: SplashRouter, + splashPassedObserver: SplashPassedObserver + ): ViewModel { + return SplashViewModel(router, accountRepository, splashPassedObserver) + } +} diff --git a/feature-splash/src/main/res/layout/fragment_splash.xml b/feature-splash/src/main/res/layout/fragment_splash.xml new file mode 100644 index 0000000..625d2e3 --- /dev/null +++ b/feature-splash/src/main/res/layout/fragment_splash.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/feature-staking-api/.gitignore b/feature-staking-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-staking-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-staking-api/build.gradle b/feature-staking-api/build.gradle new file mode 100644 index 0000000..5c9f4f9 --- /dev/null +++ b/feature-staking-api/build.gradle @@ -0,0 +1,22 @@ + +android { + namespace 'io.novafoundation.nova.feature_staking_api' +} + +dependencies { + implementation coroutinesDep + + implementation substrateSdkDep + implementation daggerDep + + implementation project(':runtime') + implementation project(':common') + implementation project(':feature-proxy-api') + implementation project(':feature-deep-linking') + implementation project(':feature-wallet-api') + + api project(":feature-wallet-api") + api project(":feature-account-api") + + api project(':core-api') +} \ No newline at end of file diff --git a/feature-staking-api/src/main/AndroidManifest.xml b/feature-staking-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/feature-staking-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/dashboard/StakingDashboardSyncTracker.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/dashboard/StakingDashboardSyncTracker.kt new file mode 100644 index 0000000..be0dc04 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/dashboard/StakingDashboardSyncTracker.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_staking_api.data.dashboard + +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.SyncingStage +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import kotlinx.coroutines.flow.Flow + +interface StakingDashboardSyncTracker { + + val syncedItemsFlow: Flow +} + +typealias SyncingStageMap = Map + +fun SyncingStageMap.getSyncingStage(stakingOptionId: StakingOptionId): SyncingStage { + return get(stakingOptionId) ?: SyncingStage.SYNCING_ALL +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/dashboard/StakingDashboardUpdateSystem.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/dashboard/StakingDashboardUpdateSystem.kt new file mode 100644 index 0000000..3be9423 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/dashboard/StakingDashboardUpdateSystem.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_api.data.dashboard + +import io.novafoundation.nova.core.updater.UpdateSystem + +interface StakingDashboardUpdateSystem : UpdateSystem, StakingDashboardSyncTracker diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/dashboard/common/ChainExt.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/dashboard/common/ChainExt.kt new file mode 100644 index 0000000..687a6fa --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/dashboard/common/ChainExt.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_api.data.dashboard.common + +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.ext.supportedStakingOptions +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainsById +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.findChains +import io.novafoundation.nova.runtime.multiNetwork.findChainsById + +suspend fun ChainRegistry.stakingChains(): List { + return findChains { it.isEnabled && it.supportedStakingOptions() } +} + +suspend fun ChainRegistry.stakingChainsById(): ChainsById { + return findChainsById { it.isEnabled && it.supportedStakingOptions() } +} + +fun Chain.supportedStakingOptions(): Boolean { + return utilityAsset.supportedStakingOptions().isNotEmpty() +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/mythos/MythosMainPotMatcherFactory.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/mythos/MythosMainPotMatcherFactory.kt new file mode 100644 index 0000000..eb14aa7 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/mythos/MythosMainPotMatcherFactory.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_api.data.mythos + +import io.novafoundation.nova.feature_account_api.domain.account.system.SystemAccountMatcher +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface MythosMainPotMatcherFactory { + + suspend fun create(chainAsset: Chain.Asset): SystemAccountMatcher? +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/network/blockhain/updaters/PooledBalanceUpdaterFactory.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/network/blockhain/updaters/PooledBalanceUpdaterFactory.kt new file mode 100644 index 0000000..46f5c65 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/network/blockhain/updaters/PooledBalanceUpdaterFactory.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters + +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface PooledBalanceUpdaterFactory { + + fun create(chain: Chain): Updater +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/nominationPools/pool/PoolAccountDerivation.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/nominationPools/pool/PoolAccountDerivation.kt new file mode 100644 index 0000000..741883d --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/nominationPools/pool/PoolAccountDerivation.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_api.data.nominationPools.pool + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.domain.account.system.SystemAccountMatcher +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface PoolAccountDerivation { + + enum class PoolAccountType { + + BONDED, REWARD + } + + suspend fun derivePoolAccount(poolId: PoolId, derivationType: PoolAccountType, chainId: ChainId): AccountId + + /** + * Derives pool accounts with poolId from range 1..[numberOfPools] (end-inclusive) + */ + suspend fun derivePoolAccountsRange(numberOfPools: Int, derivationType: PoolAccountType, chainId: ChainId): Map + + suspend fun poolAccountMatcher(derivationType: PoolAccountType, chainId: ChainId): SystemAccountMatcher? +} + +suspend fun PoolAccountDerivation.poolRewardAccountMatcher(chainId: ChainId): SystemAccountMatcher? { + return poolAccountMatcher(PoolAccountDerivation.PoolAccountType.REWARD, chainId) +} + +suspend fun PoolAccountDerivation.bondedAccountOf(poolId: PoolId, chainId: ChainId): AccountId { + return derivePoolAccount(poolId, PoolAccountDerivation.PoolAccountType.BONDED, chainId) +} + +suspend fun PoolAccountDerivation.deriveAllBondedPools(lastPoolId: PoolId, chainId: ChainId): Map { + return derivePoolAccountsRange(numberOfPools = lastPoolId.value.toInt(), PoolAccountDerivation.PoolAccountType.BONDED, chainId) +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/parachainStaking/turing/repository/OptimalAutomation.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/parachainStaking/turing/repository/OptimalAutomation.kt new file mode 100644 index 0000000..11c46dc --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/parachainStaking/turing/repository/OptimalAutomation.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository + +import java.math.BigInteger + +class OptimalAutomationRequest( + val collator: String, + val amount: BigInteger, +) + +data class OptimalAutomationResponse( + val apy: Double, + val period: Int, +) + +enum class AutomationAction(val rpcParamName: String) { + NOTIFY("Notify"), + NATIVE_TRANSFER("NativeTransfer"), + XCMP("XCMP"), + AUTO_COMPOUND_DELEGATED_STAKE("AutoCompoundDelegatedStake"), +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/parachainStaking/turing/repository/TuringAutomationTask.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/parachainStaking/turing/repository/TuringAutomationTask.kt new file mode 100644 index 0000000..5ea2d31 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/parachainStaking/turing/repository/TuringAutomationTask.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger +import io.novasama.substrate_sdk_android.runtime.AccountId + +class TuringAutomationTask( + val id: String, + val delegator: AccountId, + val collator: AccountId, + val accountMinimum: Balance, + val schedule: Schedule +) { + + sealed interface Schedule { + object Unknown : Schedule + + class Recurring(val frequency: BigInteger) : Schedule + } +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/parachainStaking/turing/repository/TuringAutomationTasksRepository.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/parachainStaking/turing/repository/TuringAutomationTasksRepository.kt new file mode 100644 index 0000000..f3cc808 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/data/parachainStaking/turing/repository/TuringAutomationTasksRepository.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface TuringAutomationTasksRepository { + + fun automationTasksFlow(chainId: ChainId, accountId: AccountId): Flow> + + suspend fun calculateOptimalAutomation(chainId: ChainId, request: OptimalAutomationRequest): OptimalAutomationResponse + + suspend fun getTimeAutomationFees(chainId: ChainId, action: AutomationAction, executions: Int): Balance +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/di/Staking.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/di/Staking.kt new file mode 100644 index 0000000..6618ca0 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/di/Staking.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_api.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Staking diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/di/StakingFeatureApi.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/di/StakingFeatureApi.kt new file mode 100644 index 0000000..c0ea4a5 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/di/StakingFeatureApi.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_api.di + +import io.novafoundation.nova.feature_staking_api.data.dashboard.StakingDashboardUpdateSystem +import io.novafoundation.nova.feature_staking_api.data.mythos.MythosMainPotMatcherFactory +import io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters.PooledBalanceUpdaterFactory +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTasksRepository +import io.novafoundation.nova.feature_staking_api.di.deeplinks.StakingDeepLinks +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.dashboard.StakingDashboardInteractor +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayUseCase + +interface StakingFeatureApi { + + fun repository(): StakingRepository + + val turingAutomationRepository: TuringAutomationTasksRepository + + val dashboardInteractor: StakingDashboardInteractor + + val dashboardUpdateSystem: StakingDashboardUpdateSystem + + val pooledBalanceUpdaterFactory: PooledBalanceUpdaterFactory + + val poolDisplayUseCase: PoolDisplayUseCase + + val poolAccountDerivation: PoolAccountDerivation + + val mythosMainPotMatcherFactory: MythosMainPotMatcherFactory + + val stakingDeepLinks: StakingDeepLinks +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/di/deeplinks/StakingDeepLinks.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/di/deeplinks/StakingDeepLinks.kt new file mode 100644 index 0000000..0e86f9a --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/di/deeplinks/StakingDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_api.di.deeplinks + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class StakingDeepLinks(val deepLinkHandlers: List) diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/api/StakingRepository.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/api/StakingRepository.kt new file mode 100644 index 0000000..8c33ea0 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/api/StakingRepository.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_staking_api.domain.api + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.InflationPredictionInfo +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novafoundation.nova.feature_staking_api.domain.model.SlashingSpans +import io.novafoundation.nova.feature_staking_api.domain.model.StakingLedger +import io.novafoundation.nova.feature_staking_api.domain.model.ValidatorPrefs +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +typealias ExposuresWithEraIndex = Pair, EraIndex> + +interface StakingRepository { + + suspend fun eraStartSessionIndex(chainId: ChainId, era: EraIndex): EraIndex + + suspend fun eraLength(chain: Chain): BigInteger + + suspend fun getActiveEraIndex(chainId: ChainId): EraIndex + + suspend fun getCurrentEraIndex(chainId: ChainId): EraIndex + + suspend fun getHistoryDepth(chainId: ChainId): BigInteger + + fun observeActiveEraIndex(chainId: ChainId): Flow + + suspend fun getElectedValidatorsExposure(chainId: ChainId, eraIndex: EraIndex): AccountIdMap + + suspend fun getValidatorPrefs(chainId: ChainId, accountIdsHex: Collection): AccountIdMap + + suspend fun getSlashes(chainId: ChainId, accountIdsHex: Collection): Set + + suspend fun getSlashingSpan(chainId: ChainId, accountId: AccountId): SlashingSpans? + + fun stakingStateFlow( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId + ): Flow + + fun ledgerFlow(stakingState: StakingState.Stash): Flow + + suspend fun ledger(chainId: ChainId, accountId: AccountId): StakingLedger? + + suspend fun getRewardDestination(stakingState: StakingState.Stash): RewardDestination + + suspend fun minimumNominatorBond(chainId: ChainId): BigInteger + + suspend fun maxNominators(chainId: ChainId): BigInteger? + + suspend fun nominatorsCount(chainId: ChainId): BigInteger? + + suspend fun getInflationPredictionInfo(chainId: ChainId): InflationPredictionInfo +} + +suspend fun StakingRepository.historicalEras(chainId: ChainId): List { + val activeEra = getActiveEraIndex(chainId).toInt() + val currentEra = getCurrentEraIndex(chainId).toInt() + val historyDepth = getHistoryDepth(chainId).toInt() + + val startingIndex = (currentEra - historyDepth).coerceAtLeast(0) + val historicalRange = startingIndex until activeEra + + return historicalRange.map(Int::toBigInteger) +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/StakingDashboardInteractor.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/StakingDashboardInteractor.kt new file mode 100644 index 0000000..a73d716 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/StakingDashboardInteractor.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_api.domain.dashboard + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.MoreStakingOptions +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingDashboard +import kotlinx.coroutines.flow.Flow + +interface StakingDashboardInteractor { + + suspend fun syncDapps() + + fun stakingDashboardFlow(): Flow> + + fun moreStakingOptionsFlow(): Flow +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/model/AggregatedStakingDashboardOption.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/model/AggregatedStakingDashboardOption.kt new file mode 100644 index 0000000..38e91ed --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/model/AggregatedStakingDashboardOption.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_staking_api.domain.dashboard.model + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.Percent +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.NoStake.FlowType +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.SyncingStage +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class AggregatedStakingDashboardOption( + val chain: Chain, + val token: Token, + val stakingState: S, + val syncingStage: SyncingStage +) { + + class HasStake( + val showStakingType: Boolean, + val stakingType: Chain.Asset.StakingType, + val stake: Balance, + val stats: ExtendedLoadingState, + ) { + + class Stats(val rewards: Balance, val estimatedEarnings: Percent, val status: StakingStatus) + + enum class StakingStatus { + ACTIVE, INACTIVE, WAITING + } + } + + sealed interface WithoutStake + + class NoStake(val stats: ExtendedLoadingState, val flowType: FlowType, val availableBalance: Balance) : WithoutStake { + + sealed class FlowType { + + class Aggregated(val stakingTypes: List) : FlowType() + + class Single(val stakingType: Chain.Asset.StakingType, val showStakingType: Boolean) : FlowType() + } + + class Stats(val estimatedEarnings: Percent) + } + + object NotYetResolved : WithoutStake + + enum class SyncingStage { + SYNCING_ALL, SYNCING_SECONDARY, SYNCED + } +} + +val FlowType.allStakingTypes: List + get() = when (this) { + is FlowType.Aggregated -> stakingTypes + is FlowType.Single -> listOf(stakingType) + } + +fun SyncingStage.isSyncing(): Boolean { + return this != SyncingStage.SYNCED +} + +fun SyncingStage.isSyncingPrimary(): Boolean { + return this == SyncingStage.SYNCING_ALL +} + +fun SyncingStage.isSyncingSecondary(): Boolean { + return this < SyncingStage.SYNCED +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/model/StakingDashboard.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/model/StakingDashboard.kt new file mode 100644 index 0000000..b90d26a --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/model/StakingDashboard.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_staking_api.domain.dashboard.model + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.HasStake +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.WithoutStake + +class StakingDashboard( + val hasStake: List>, + val withoutStake: List>, +) + +class MoreStakingOptions( + val inAppStaking: List>, + val browserStaking: ExtendedLoadingState>, +) + +class StakingDApp( + val url: String, + val iconUrl: String, + val name: String +) diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/model/StakingOptionId.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/model/StakingOptionId.kt new file mode 100644 index 0000000..d768cff --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/dashboard/model/StakingOptionId.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_api.domain.dashboard.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +data class StakingOptionId(val chainId: ChainId, val chainAssetId: ChainAssetId, val stakingType: Chain.Asset.StakingType) + +data class MultiStakingOptionIds(val chainId: ChainId, val chainAssetId: ChainAssetId, val stakingTypes: List) diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/BondedEras.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/BondedEras.kt new file mode 100644 index 0000000..894a81c --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/BondedEras.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +@JvmInline +value class BondedEras(val value: List) { + + companion object +} + +class BondedEra(val era: EraIndex, val startSessionIndex: SessionIndex) { + + companion object +} + +fun BondedEras.findStartSessionIndexOf(era: EraIndex): SessionIndex? { + return value.find { it.era == era }?.startSessionIndex +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/EraIndex.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/EraIndex.kt new file mode 100644 index 0000000..4e2dc66 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/EraIndex.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import java.math.BigInteger + +typealias EraIndex = BigInteger diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/EraRedeemable.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/EraRedeemable.kt new file mode 100644 index 0000000..76ca93d --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/EraRedeemable.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger + +interface EraRedeemable { + + val redeemEra: EraIndex + + companion object +} + +interface RedeemableAmount : EraRedeemable { + + val amount: Balance +} + +fun EraRedeemable.isUnbondingIn(activeEraIndex: BigInteger) = redeemEra > activeEraIndex + +fun EraRedeemable.isRedeemableIn(activeEraIndex: BigInteger) = redeemEra <= activeEraIndex + +fun EraRedeemable.Companion.of(eraIndex: EraIndex): EraRedeemable = InlineEraRedeemable(eraIndex) + +@JvmInline +private value class InlineEraRedeemable(override val redeemEra: EraIndex) : EraRedeemable diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/Exposure.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/Exposure.kt new file mode 100644 index 0000000..adcfe00 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/Exposure.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import java.math.BigInteger + +class IndividualExposure(val who: ByteArray, val value: BigInteger) + +class Exposure(val total: BigInteger, val own: BigInteger, val others: List) + +class ExposureOverview(val total: BigInteger, val own: BigInteger, val pageCount: BigInteger, val nominatorCount: BigInteger) + +class ExposurePage(val others: List) + +class PagedExposure( + val overview: ExposureOverview, + val pages: List +) diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/InflationPredictionInfo.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/InflationPredictionInfo.kt new file mode 100644 index 0000000..3e99081 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/InflationPredictionInfo.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.divideToDecimal +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlin.math.roundToInt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days + +class InflationPredictionInfo( + val nextMint: NextMint +) { + + class NextMint( + val toStakers: Balance, + val toTreasury: Balance + ) + + companion object { + + fun fromDecoded(decoded: Any?): InflationPredictionInfo { + val asStruct = decoded.castToStruct() + + return InflationPredictionInfo( + nextMint = bindNextMint(asStruct["nextMint"]) + ) + } + + private fun bindNextMint(decoded: Any?): NextMint { + val (toStakersRaw, toTreasuryRaw) = decoded.castToList() + + return NextMint( + toStakers = bindNumber(toStakersRaw), + toTreasury = bindNumber(toTreasuryRaw) + ) + } + } +} + +fun InflationPredictionInfo.calculateStakersInflation(totalIssuance: Balance, eraDuration: Duration): Double { + val periodsInYear = (365.days / eraDuration).roundToInt() + val inflationPerMint = nextMint.toStakers.divideToDecimal(totalIssuance) + + return inflationPerMint.toDouble() * periodsInYear +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/NominatedValidator.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/NominatedValidator.kt new file mode 100644 index 0000000..fd99d7e --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/NominatedValidator.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import java.math.BigInteger + +class NominatedValidator( + val validator: Validator, + val status: Status, +) { + + sealed class Status { + + class Active(val nomination: BigInteger, val willUserBeRewarded: Boolean) : Status() + object Elected : Status() + object Inactive : Status() + object WaitingForNextEra : Status() + + sealed class Group(val numberOfValidators: Int, val position: Int) { + companion object { + val COMPARATOR = Comparator.comparingInt { it.position } + } + + class Active(numberOfValidators: Int) : Group(numberOfValidators, 0) + class Elected(numberOfValidators: Int) : Group(numberOfValidators, 1) + class Inactive(numberOfValidators: Int) : Group(numberOfValidators, 2) + class WaitingForNextEra(val maxValidatorsPerNominator: Int, numberOfValidators: Int) : Group(numberOfValidators, 3) + } + } +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/Nominations.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/Nominations.kt new file mode 100644 index 0000000..53a3a74 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/Nominations.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import io.novasama.substrate_sdk_android.runtime.AccountId + +class Nominations( + val targets: List, + val submittedInEra: EraIndex, + val suppressed: Boolean +) diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/RewardDestination.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/RewardDestination.kt new file mode 100644 index 0000000..5a703ea --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/RewardDestination.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import io.novasama.substrate_sdk_android.runtime.AccountId + +sealed class RewardDestination { + + object Restake : RewardDestination() + + class Payout(val targetAccountId: AccountId) : RewardDestination() +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/SessionIndex.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/SessionIndex.kt new file mode 100644 index 0000000..a8910fe --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/SessionIndex.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import java.math.BigInteger + +typealias SessionIndex = BigInteger diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/SlashingSpans.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/SlashingSpans.kt new file mode 100644 index 0000000..732a2a2 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/SlashingSpans.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import java.math.BigInteger + +class SlashingSpans( + val lastNonZeroSlash: EraIndex, + val prior: List +) + +fun SlashingSpans?.numberOfSlashingSpans(): BigInteger { + if (this == null) return BigInteger.ZERO + + // all from prior + one for lastNonZeroSlash + val numberOfSlashingSpans = prior.size + 1 + + return numberOfSlashingSpans.toBigInteger() +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/StakingAccount.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/StakingAccount.kt new file mode 100644 index 0000000..8cf6835 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/StakingAccount.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +class StakingAccount( + val address: String, + val name: String?, +) diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/StakingLedger.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/StakingLedger.kt new file mode 100644 index 0000000..81b5e26 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/StakingLedger.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +class StakingLedger( + val stashId: AccountId, + val total: BigInteger, + val active: BigInteger, + val unlocking: List, + val claimedRewards: List +) + +class UnlockChunk(override val amount: BigInteger, val era: BigInteger) : RedeemableAmount { + override val redeemEra: EraIndex = era +} + +fun List.totalRedeemableIn(activeEra: EraIndex): Balance = sumStaking { it.isRedeemableIn(activeEra) } + +fun List.sumStaking( + condition: (chunk: UnlockChunk) -> Boolean +): BigInteger { + return filter { condition(it) } + .sumByBigInteger(UnlockChunk::amount) +} + +fun StakingLedger?.activeBalance(): Balance { + return this?.active.orZero() +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/Validator.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/Validator.kt new file mode 100644 index 0000000..2aaf462 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/Validator.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_api.domain.model + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import java.math.BigDecimal +import java.math.BigInteger + +typealias Commission = BigDecimal + +class ValidatorPrefs(val commission: Commission, val blocked: Boolean) + +class Validator( + val address: String, + val slashed: Boolean, + val accountIdHex: String, + val prefs: ValidatorPrefs?, + val electedInfo: ElectedInfo?, + val identity: OnChainIdentity?, + val isNovaValidator: Boolean +) : Identifiable { + + class ElectedInfo( + val totalStake: BigInteger, + val ownStake: BigInteger, + val nominatorStakes: List, + val apy: BigDecimal, + val isOversubscribed: Boolean + ) + + override val identifier: String = address +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/parachain/DelegatorState.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/parachain/DelegatorState.kt new file mode 100644 index 0000000..757af5b --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/parachain/DelegatorState.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_api.domain.model.parachain + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigDecimal +import java.math.BigInteger + +sealed class DelegatorState( + val chain: Chain, + val chainAsset: Chain.Asset, +) { + + class Delegator( + val accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + val delegations: List, + val total: Balance, + val lessTotal: Balance, + ) : DelegatorState(chain, chainAsset) + + class None(chain: Chain, chainAsset: Chain.Asset) : DelegatorState(chain, chainAsset) +} + +fun DelegatorState.stakeablePlanks(freeBalance: BigInteger): BigInteger { + return (freeBalance - totalBonded).coerceAtLeast(BigInteger.ZERO) +} + +fun DelegatorState.stakeableAmount(freeBalance: BigInteger): BigDecimal { + return chainAsset.amountFromPlanks(stakeablePlanks(freeBalance)) +} + +private val DelegatorState.totalBonded: BigInteger + get() = asDelegator()?.total.orZero() + +val DelegatorState.activeBonded: Balance + get() = when (this) { + is DelegatorState.Delegator -> total - lessTotal + is DelegatorState.None -> Balance.ZERO + } + +val DelegatorState.delegationsCount + get() = when (this) { + is DelegatorState.Delegator -> delegations.size + is DelegatorState.None -> 0 + } + +fun DelegatorState.Delegator.delegatedCollatorIds() = delegations.map { it.owner } +fun DelegatorState.Delegator.delegatedCollatorIdsHex() = delegations.map { it.owner.toHexString() } + +fun DelegatorState.delegationAmountTo(collatorId: AccountId): BalanceOf? { + return castOrNull()?.delegations?.find { it.owner.contentEquals(collatorId) }?.balance +} + +fun DelegatorState.hasDelegation(collatorId: AccountId): Boolean = this is DelegatorState.Delegator && delegations.any { it.owner.contentEquals(collatorId) } + +fun DelegatorState.asDelegator(): DelegatorState.Delegator? = castOrNull() + +class DelegatorBond( + val owner: AccountId, + val balance: BalanceOf, +) + +class ScheduledDelegationRequest( + val collator: AccountId, + val delegator: AccountId, + val whenExecutable: RoundIndex, + val action: DelegationAction +) + +fun ScheduledDelegationRequest.redeemableIn(roundIndex: RoundIndex): Boolean = whenExecutable <= roundIndex +fun ScheduledDelegationRequest.unbondingIn(roundIndex: RoundIndex): Boolean = whenExecutable > roundIndex + +sealed class DelegationAction(val amount: BalanceOf) { + class Revoke(amount: Balance) : DelegationAction(amount) + + class Decrease(amount: Balance) : DelegationAction(amount) +} + +typealias RoundIndex = BigInteger + +fun DelegatorState.Delegator.address() = chain.addressOf(accountId) diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/relaychain/StakingState.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/relaychain/StakingState.kt new file mode 100644 index 0000000..fc958b6 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/model/relaychain/StakingState.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_api.domain.model.relaychain + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novafoundation.nova.feature_staking_api.domain.model.ValidatorPrefs +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +sealed class StakingState( + val chain: Chain, + val chainAsset: Chain.Asset, +) { + + class NonStash(chain: Chain, chainAsset: Chain.Asset) : StakingState(chain, chainAsset) + + sealed class Stash( + chain: Chain, + chainAsset: Chain.Asset, + val accountId: AccountId, + val controllerId: AccountId, + val stashId: AccountId + ) : StakingState(chain, chainAsset) { + + val accountAddress: String = chain.addressOf(accountId) + + val stashAddress = chain.addressOf(stashId) + val controllerAddress = chain.addressOf(controllerId) + + class None( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + controllerId: AccountId, + stashId: AccountId, + ) : Stash(chain, chainAsset, accountId, controllerId, stashId) + + class Validator( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + controllerId: AccountId, + stashId: AccountId, + val prefs: ValidatorPrefs, + ) : Stash(chain, chainAsset, accountId, controllerId, stashId) + + class Nominator( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + controllerId: AccountId, + stashId: AccountId, + val nominations: Nominations, + ) : Stash(chain, chainAsset, accountId, controllerId, stashId) + } +} + +fun StakingState.Stash.stashTransactionOrigin(): TransactionOrigin = TransactionOrigin.WalletWithAccount(stashId) + +fun StakingState.Stash.controllerTransactionOrigin(): TransactionOrigin = TransactionOrigin.WalletWithAccount(controllerId) + +fun StakingState.Stash.accountIsStash(): Boolean = accountId.contentEquals(stashId) diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/nominationPool/model/PoolId.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/nominationPool/model/PoolId.kt new file mode 100644 index 0000000..9a7dc38 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/domain/nominationPool/model/PoolId.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_api.domain.nominationPool.model + +import java.math.BigInteger + +@JvmInline +value class PoolId(val value: PoolIdRaw) + +typealias PoolIdRaw = BigInteger + +fun PoolId(id: Int) = PoolId(id.toBigInteger()) diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/presentation/nominationPools/display/PoolDisplayModel.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/presentation/nominationPools/display/PoolDisplayModel.kt new file mode 100644 index 0000000..0fdfafb --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/presentation/nominationPools/display/PoolDisplayModel.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.view.TableCellView +import io.novasama.substrate_sdk_android.runtime.AccountId + +class PoolDisplayModel( + val icon: Icon, + val title: String, + val poolAccountId: AccountId, + val address: String +) + +fun TableCellView.showPool(poolDisplayModel: PoolDisplayModel) { + loadImage(poolDisplayModel.icon) + showValue(poolDisplayModel.title) +} diff --git a/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/presentation/nominationPools/display/PoolDisplayUseCase.kt b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/presentation/nominationPools/display/PoolDisplayUseCase.kt new file mode 100644 index 0000000..2e3e043 --- /dev/null +++ b/feature-staking-api/src/main/java/io/novafoundation/nova/feature_staking_api/presentation/nominationPools/display/PoolDisplayUseCase.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface PoolDisplayUseCase { + + suspend fun getPoolDisplay(poolId: Int, chain: Chain): PoolDisplayModel +} diff --git a/feature-staking-impl/.gitignore b/feature-staking-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-staking-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-staking-impl/build.gradle b/feature-staking-impl/build.gradle new file mode 100644 index 0000000..5885d7b --- /dev/null +++ b/feature-staking-impl/build.gradle @@ -0,0 +1,82 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + + defaultConfig { + + + + buildConfigField "String", "GLOBAL_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/staking/global_config_dev.json\"" + buildConfigField "String", "RECOMMENDED_VALIDATORS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/staking/validators/v1/nova_validators_dev.json\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + buildConfigField "String", "GLOBAL_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/staking/global_config.json\"" + buildConfigField "String", "RECOMMENDED_VALIDATORS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/staking/validators/v1/nova_validators.json\"" + } + } + namespace 'io.novafoundation.nova.feature_staking_impl' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-staking-api') + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(':feature-currency-api') + implementation project(':feature-ledger-api') + implementation project(':feature-dapp-api') + implementation project(':feature-proxy-api') + implementation project(':runtime') + implementation project(':feature-deep-linking') + implementation project(':feature-ahm-api') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation cardViewDep + implementation constraintDep + + implementation permissionsDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation insetterDep + + implementation daggerDep + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + ksp daggerCompiler + + implementation roomDep + ksp roomCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation substrateSdkDep + compileOnly wsDep + + implementation gsonDep + implementation retrofitDep + + implementation shimmerDep +} \ No newline at end of file diff --git a/feature-staking-impl/src/main/AndroidManifest.xml b/feature-staking-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/feature-staking-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/StakingOptionExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/StakingOptionExt.kt new file mode 100644 index 0000000..7152b28 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/StakingOptionExt.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.data + +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.MultiStakingOptionIds +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset + +val StakingOption.fullId + get() = StakingOptionId(chainId = assetWithChain.chain.id, assetWithChain.asset.id, additional.stakingType) + +val StakingOption.components: Triple + get() = Triple(assetWithChain.chain, assetWithChain.asset, additional.stakingType) + +val StakingOption.chain: Chain + get() = assetWithChain.chain + +val StakingOption.asset: Chain.Asset + get() = assetWithChain.asset + +val StakingOption.stakingType: Chain.Asset.StakingType + get() = additional.stakingType + +suspend fun ChainRegistry.constructStakingOptions(stakingOptionId: MultiStakingOptionIds): List { + val (chain, asset) = chainWithAsset(stakingOptionId.chainId, stakingOptionId.chainAssetId) + + return stakingOptionId.stakingTypes.map { stakingType -> + createStakingOption(chain, asset, stakingType) + } +} + +suspend fun ChainRegistry.constructStakingOption(stakingOptionId: StakingOptionId): StakingOption { + val (chain, asset) = chainWithAsset(stakingOptionId.chainId, stakingOptionId.chainAssetId) + + return createStakingOption(chain, asset, stakingOptionId.stakingType) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/StakingSharedState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/StakingSharedState.kt new file mode 100644 index 0000000..6b7b7bc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/StakingSharedState.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_staking_impl.data + +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.findStakingTypeBackingNominationPools +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState.SupportedAssetOption +import kotlinx.coroutines.flow.Flow + +typealias StakingOption = SupportedAssetOption + +class StakingSharedState : SelectedAssetOptionSharedState { + + class OptionAdditionalData(val stakingType: Chain.Asset.StakingType) + + private val _selectedOption = singleReplaySharedFlow() + override val selectedOption: Flow = _selectedOption + + suspend fun setSelectedOption( + chain: Chain, + chainAsset: Chain.Asset, + stakingType: Chain.Asset.StakingType + ) { + val selectedOption = createStakingOption(chain, chainAsset, stakingType) + + setSelectedOption(selectedOption) + } + + suspend fun setSelectedOption(option: StakingOption) { + _selectedOption.emit(option) + } +} + +fun createStakingOption(chainWithAsset: ChainWithAsset, stakingType: Chain.Asset.StakingType): StakingOption { + return StakingOption( + assetWithChain = chainWithAsset, + additional = StakingSharedState.OptionAdditionalData(stakingType) + ) +} + +fun createStakingOption(chain: Chain, chainAsset: Chain.Asset, stakingType: Chain.Asset.StakingType): StakingOption { + return createStakingOption( + chainWithAsset = ChainWithAsset(chain, chainAsset), + stakingType = stakingType + ) +} + +fun StakingOption.unwrapNominationPools(): StakingOption { + return if (stakingType == Chain.Asset.StakingType.NOMINATION_POOLS) { + val backingType = assetWithChain.asset.findStakingTypeBackingNominationPools() + copy(additional = StakingSharedState.OptionAdditionalData(backingType)) + } else { + this + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/cache/StakingDashboardCache.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/cache/StakingDashboardCache.kt new file mode 100644 index 0000000..165e0dd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/cache/StakingDashboardCache.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.cache + +import io.novafoundation.nova.core_db.dao.StakingDashboardDao +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface StakingDashboardCache { + + suspend fun update( + chainId: ChainId, + assetId: ChainAssetId, + stakingTypeLocal: String, + metaAccountId: Long, + updating: (previousValue: StakingDashboardItemLocal?) -> StakingDashboardItemLocal + ) +} + +class RealStakingDashboardCache( + private val dao: StakingDashboardDao +) : StakingDashboardCache { + + override suspend fun update( + chainId: ChainId, + assetId: ChainAssetId, + stakingTypeLocal: String, + metaAccountId: Long, + updating: (previousValue: StakingDashboardItemLocal?) -> StakingDashboardItemLocal + ) { + val fromCache = dao.getDashboardItem(chainId, assetId, stakingTypeLocal, metaAccountId) + val toInsert = updating(fromCache) + + dao.insertItem(toInsert) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/model/StakingDashboardItem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/model/StakingDashboardItem.kt new file mode 100644 index 0000000..ceebc55 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/model/StakingDashboardItem.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.model + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.Percent +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +class StakingDashboardItem( + val fullChainAssetId: FullChainAssetId, + val stakingType: Chain.Asset.StakingType, + val stakeState: StakeState, +) { + + sealed interface StakeState { + + val stats: ExtendedLoadingState + + class HasStake( + val stake: Balance, + override val stats: ExtendedLoadingState, + ) : StakeState { + + class Stats( + val rewards: Balance, + val status: StakingStatus, + override val estimatedEarnings: Percent + ) : CommonStats + + enum class StakingStatus { + ACTIVE, INACTIVE, WAITING + } + } + + class NoStake(override val stats: ExtendedLoadingState) : StakeState { + + class Stats(override val estimatedEarnings: Percent) : CommonStats + } + + interface CommonStats { + + val estimatedEarnings: Percent + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/model/StakingDashboardPrimaryAccountView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/model/StakingDashboardPrimaryAccountView.kt new file mode 100644 index 0000000..344e5f7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/model/StakingDashboardPrimaryAccountView.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId + +data class StakingDashboardOptionAccounts( + val stakingOptionId: StakingOptionId, + val stakingStatusAccount: AccountIdKey?, + val rewardsAccount: AccountIdKey? +) : Identifiable { + + override val identifier: String = "${stakingOptionId.chainId}:${stakingOptionId.chainAssetId}:${stakingOptionId.stakingType}" +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/MultiChainStakingStats.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/MultiChainStakingStats.kt new file mode 100644 index 0000000..80dc143 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/MultiChainStakingStats.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats + +import io.novafoundation.nova.common.utils.Percent +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +typealias MultiChainStakingStats = Map + +class ChainStakingStats( + val estimatedEarnings: Percent, + val accountPresentInActiveStakers: Boolean, + val rewards: Balance +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingAccounts.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingAccounts.kt new file mode 100644 index 0000000..7de42cd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingAccounts.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId + +typealias StakingAccounts = Map + +data class StakingOptionAccounts(val rewards: AccountIdKey, val stakingStatus: AccountIdKey) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingStatsDataSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingStatsDataSource.kt new file mode 100644 index 0000000..f2042a4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/StakingStatsDataSource.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats + +import io.novafoundation.nova.common.data.config.GlobalConfigDataSource +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import io.novafoundation.nova.common.utils.asPerbill +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.common.utils.retryUntilDone +import io.novafoundation.nova.common.utils.toPercent +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api.StakingStatsApi +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api.StakingStatsRequest +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api.StakingStatsResponse +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api.StakingStatsResponse.AccumulatedReward +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api.StakingStatsResponse.WithStakingId +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api.StakingStatsRewards +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api.mapSubQueryIdToStakingType +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.UTILITY_ASSET_ID +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface StakingStatsDataSource { + + suspend fun fetchStakingStats(stakingAccounts: StakingAccounts, stakingChains: List): MultiChainStakingStats +} + +class RealStakingStatsDataSource( + private val api: StakingStatsApi, + private val globalConfigDataSource: GlobalConfigDataSource +) : StakingStatsDataSource { + + override suspend fun fetchStakingStats( + stakingAccounts: StakingAccounts, + stakingChains: List + ): MultiChainStakingStats = withContext(Dispatchers.IO) { + retryUntilDone { + val request = StakingStatsRequest(stakingAccounts, stakingChains) + val globalConfig = globalConfigDataSource.getGlobalConfig() + val response = api.fetchStakingStats(request, globalConfig.multiStakingApiUrl).data + + val earnings = response.stakingApies.associatedById() + val rewards = response.rewards?.associatedById() ?: emptyMap() + val slashes = response.slashes?.associatedById() ?: emptyMap() + val activeStakers = response.activeStakers?.groupedById() ?: emptyMap() + + request.stakingKeysMapping.mapValues { (originalStakingOptionId, stakingKeys) -> + val totalReward = rewards.getPlanks(originalStakingOptionId) - slashes.getPlanks(originalStakingOptionId) + + val stakingStatusAddress = stakingKeys.stakingStatusAddress + val stakingOptionActiveStakers = activeStakers[stakingKeys.stakingStatusOptionId].orEmpty() + val isStakingActive = stakingStatusAddress != null && stakingStatusAddress in stakingOptionActiveStakers + + ChainStakingStats( + estimatedEarnings = earnings[originalStakingOptionId]?.maxAPY.orZero().asPerbill().toPercent(), + accountPresentInActiveStakers = isStakingActive, + rewards = totalReward.atLeastZero() + ) + } + } + } + + private fun Map.getPlanks(key: StakingOptionId): Balance { + return get(key)?.amount?.toBigInteger().orZero() + } + + private fun SubQueryNodes.associatedById(): Map { + return nodes.associateBy { + StakingOptionId( + chainId = it.networkId.removeHexPrefix(), + chainAssetId = UTILITY_ASSET_ID, + stakingType = mapSubQueryIdToStakingType(it.stakingType) + ) + } + } + + private fun SubQueryNodes.groupedById(): Map> { + return nodes.groupBy( + keySelector = { + StakingOptionId( + chainId = it.networkId.removeHexPrefix(), + chainAssetId = UTILITY_ASSET_ID, + stakingType = mapSubQueryIdToStakingType(it.stakingType) + ) + }, + valueTransform = { it.address } + ) + } + + private fun StakingStatsRewards.associatedById(): Map { + return groupedAggregates.associateBy( + keySelector = { rewardAggregate -> + val (networkId, stakingTypeRaw) = rewardAggregate.keys + + StakingOptionId( + chainId = networkId.removeHexPrefix(), + chainAssetId = UTILITY_ASSET_ID, + stakingType = mapSubQueryIdToStakingType(stakingTypeRaw) + ) + }, + valueTransform = { rewardAggregate -> rewardAggregate.sum } + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingStatsApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingStatsApi.kt new file mode 100644 index 0000000..96b5c43 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingStatsApi.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface StakingStatsApi { + + @POST + suspend fun fetchStakingStats( + @Body request: StakingStatsRequest, + @Url url: String + ): SubQueryResponse +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingStatsRequest.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingStatsRequest.kt new file mode 100644 index 0000000..fa51fe0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingStatsRequest.kt @@ -0,0 +1,166 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api + +import io.novafoundation.nova.common.data.network.subquery.SubQueryFilters +import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.and +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_impl.data.createStakingOption +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.StakingAccounts +import io.novafoundation.nova.feature_staking_impl.data.fullId +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.supportedStakingOptions +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.requireHexPrefix + +class StakingStatsRequest(stakingAccounts: StakingAccounts, chains: List) { + + @Transient + val stakingKeysMapping: Map = constructStakingTypeOverrides(chains, stakingAccounts) + + val query = """ + { + activeStakers${constructFilters(chains, FilterParent.STAKING_STATUS)} { + nodes { + networkId + stakingType + address + } + } + + stakingApies { + nodes { + networkId + stakingType + maxAPY + } + } + + rewards: rewards${constructFilters(chains, FilterParent.REWARD)} { + groupedAggregates(groupBy: [NETWORK_ID, STAKING_TYPE]) { + sum { + amount + } + + keys + } + } + + slashes: rewards${constructFilters(chains, FilterParent.SLASH)} { + groupedAggregates(groupBy: [NETWORK_ID, STAKING_TYPE]) { + sum { + amount + } + + keys + } + } + } + """.trimIndent() + + private fun constructStakingTypeOverrides( + chains: List, + stakingAccounts: StakingAccounts + ): Map { + return chains.flatMap { chain -> + val utilityAsset = chain.utilityAsset + + utilityAsset.supportedStakingOptions().mapNotNull { stakingType -> + val stakingOption = createStakingOption(chain, utilityAsset, stakingType) + val stakingOptionId = stakingOption.fullId + val stakingOptionAccounts = stakingAccounts[stakingOptionId] + + val stakingKeys = StakingKeys( + otherStakingOptionId = stakingOptionId, + stakingStatusAddress = stakingOptionAccounts?.stakingStatus?.value?.let(chain::addressOf), + rewardsAddress = stakingOptionAccounts?.rewards?.value?.let(chain::addressOf), + stakingStatusOptionId = stakingOptionId.copy(stakingType = stakingOption.unwrapNominationPools().stakingType) + ) + + stakingOptionId to stakingKeys + } + }.toMap() + } + + private fun constructFilters(chains: List, filterParent: FilterParent): String = with(SubQueryFilters) { + val targetAddresses = mutableSetOf() + val targetNetworks = mutableSetOf() + val targetStakingTypes = mutableSetOf() + + chains.forEach { chain -> + val utilityAsset = chain.utilityAsset + + utilityAsset.supportedStakingOptions().forEach { stakingType -> + val stakingOption = createStakingOption(chain, utilityAsset, stakingType) + val stakingOptionId = stakingOption.fullId + + val stakingKeys = stakingKeysMapping[stakingOptionId] ?: return@forEach + val address = stakingKeys.addressFor(filterParent) ?: return@forEach + + val requestStakingType = stakingKeys.stakingTypeFor(filterParent) + val requestStakingTypeId = mapStakingTypeToSubQueryId(requestStakingType) ?: return@forEach + + targetAddresses.add(address) + targetNetworks.add(chain.id.requireHexPrefix()) + targetStakingTypes.add(requestStakingTypeId) + } + } + + if (targetAddresses.isEmpty() || targetNetworks.isEmpty() || targetStakingTypes.isEmpty()) { + return@with "" + } + + val addressFilter = "address" containedIn targetAddresses + val networkFilter = "networkId" containedIn targetNetworks + val typeFilter = "stakingType" containedIn targetStakingTypes + + val aggregatedFilters = and(addressFilter, networkFilter, typeFilter) + + val finalFilters = appendFiltersSpecificToParent(baseFilters = aggregatedFilters, filterParent) + + queryParams(filter = finalFilters) + } + + private fun SubQueryFilters.hasRewardType(type: String): String { + return "type" equalToEnum type + } + + private fun SubQueryFilters.Companion.appendFiltersSpecificToParent(baseFilters: String, filterParent: FilterParent): String { + return when (filterParent) { + FilterParent.REWARD -> baseFilters and hasRewardType("reward") + FilterParent.SLASH -> baseFilters and hasRewardType("slash") + FilterParent.STAKING_STATUS -> baseFilters + } + } + + private fun StakingKeys.addressFor(filterParent: FilterParent): String? { + return when (filterParent) { + FilterParent.REWARD, FilterParent.SLASH -> rewardsAddress + FilterParent.STAKING_STATUS -> stakingStatusAddress + } + } + + private fun StakingKeys.stakingTypeFor(filterParent: FilterParent): Chain.Asset.StakingType { + return when (filterParent) { + FilterParent.REWARD, FilterParent.SLASH -> otherStakingOptionId.stakingType + FilterParent.STAKING_STATUS -> stakingStatusOptionId.stakingType + } + } + + private infix fun String.containedIn(values: Set): String { + val joinedValues = values.joinToString(separator = "\", \"", prefix = "\"", postfix = "\"") + return "$this: { in: [$joinedValues] }" + } + + private enum class FilterParent { + REWARD, SLASH, STAKING_STATUS + } + + class StakingKeys( + val otherStakingOptionId: StakingOptionId, + val stakingStatusOptionId: StakingOptionId, + val stakingStatusAddress: String?, + val rewardsAddress: String?, + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingStatsResponse.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingStatsResponse.kt new file mode 100644 index 0000000..32edd52 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingStatsResponse.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api + +import io.novafoundation.nova.common.data.network.subquery.GroupedAggregate +import io.novafoundation.nova.common.data.network.subquery.SubQueryGroupedAggregates +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import java.math.BigDecimal + +typealias StakingStatsRewards = SubQueryGroupedAggregates> + +class StakingStatsResponse( + val activeStakers: SubQueryNodes?, + val stakingApies: SubQueryNodes, + val rewards: SubQueryGroupedAggregates>?, + val slashes: SubQueryGroupedAggregates>? +) { + + interface WithStakingId { + val networkId: String + val stakingType: String + } + + class ActiveStaker(override val networkId: String, override val stakingType: String, val address: String) : WithStakingId + + class StakingApy(override val networkId: String, override val stakingType: String, val maxAPY: Double) : WithStakingId + + class AccumulatedReward(val amount: BigDecimal) // We use BigDecimal to support scientific notations +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingTypeMappers.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingTypeMappers.kt new file mode 100644 index 0000000..959c667 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/stats/api/StakingTypeMappers.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun mapStakingTypeToSubQueryId(stakingType: Chain.Asset.StakingType): String? { + return when (stakingType) { + Chain.Asset.StakingType.UNSUPPORTED -> null + Chain.Asset.StakingType.RELAYCHAIN -> "relaychain" + Chain.Asset.StakingType.PARACHAIN -> "parachain" + Chain.Asset.StakingType.RELAYCHAIN_AURA -> "aura-relaychain" + Chain.Asset.StakingType.TURING -> "turing" + Chain.Asset.StakingType.ALEPH_ZERO -> "aleph-zero" + Chain.Asset.StakingType.NOMINATION_POOLS -> "nomination-pool" + Chain.Asset.StakingType.MYTHOS -> "mythos" + } +} + +fun mapSubQueryIdToStakingType(subQueryStakingTypeId: String?): Chain.Asset.StakingType { + return when (subQueryStakingTypeId) { + null -> Chain.Asset.StakingType.UNSUPPORTED + "relaychain" -> Chain.Asset.StakingType.RELAYCHAIN + "parachain" -> Chain.Asset.StakingType.PARACHAIN + "aura-relaychain" -> Chain.Asset.StakingType.RELAYCHAIN_AURA + "turing" -> Chain.Asset.StakingType.TURING + "aleph-zero" -> Chain.Asset.StakingType.ALEPH_ZERO + "nomination-pool" -> Chain.Asset.StakingType.NOMINATION_POOLS + "mythos" -> Chain.Asset.StakingType.MYTHOS + else -> Chain.Asset.StakingType.UNSUPPORTED + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/OffChainSyncResult.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/OffChainSyncResult.kt new file mode 100644 index 0000000..06cfbb1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/OffChainSyncResult.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters + +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.MultiChainStakingStats + +data class MultiChainOffChainSyncResult( + val index: Int, + val multiChainStakingStats: MultiChainStakingStats, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/StakingDashboardUpdateSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/StakingDashboardUpdateSystem.kt new file mode 100644 index 0000000..8ba1cce --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/StakingDashboardUpdateSystem.kt @@ -0,0 +1,188 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters + +import android.util.Log +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.inserted +import io.novafoundation.nova.common.utils.mergeIfMultiple +import io.novafoundation.nova.common.utils.throttleLast +import io.novafoundation.nova.common.utils.zipWithPrevious +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_api.data.dashboard.StakingDashboardUpdateSystem +import io.novafoundation.nova.feature_staking_api.data.dashboard.SyncingStageMap +import io.novafoundation.nova.feature_staking_api.data.dashboard.getSyncingStage +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.SyncingStage +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_api.data.dashboard.common.stakingChains +import io.novafoundation.nova.feature_staking_impl.data.dashboard.model.StakingDashboardOptionAccounts +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.StakingAccounts +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.StakingOptionAccounts +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.StakingStatsDataSource +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain.StakingDashboardUpdaterEvent +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain.StakingDashboardUpdaterFactory +import io.novafoundation.nova.feature_staking_impl.data.dashboard.repository.StakingDashboardRepository +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ext.supportedStakingOptions +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.withIndex +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +private const val EMPTY_OFF_CHAIN_SYNC_INDEX = -1 + +class RealStakingDashboardUpdateSystem( + private val stakingStatsDataSource: StakingStatsDataSource, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val updaterFactory: StakingDashboardUpdaterFactory, + private val sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + private val stakingDashboardRepository: StakingDashboardRepository, + private val offChainSyncDebounceRate: Duration = 1.seconds +) : StakingDashboardUpdateSystem { + + override val syncedItemsFlow: MutableStateFlow = MutableStateFlow(emptyMap()) + private val latestOffChainSyncIndex: MutableStateFlow = MutableStateFlow(EMPTY_OFF_CHAIN_SYNC_INDEX) + + override fun start(): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + val accountScope = CoroutineScope(coroutineContext) + + syncedItemsFlow.emit(emptyMap()) + latestOffChainSyncIndex.emit(EMPTY_OFF_CHAIN_SYNC_INDEX) + + val stakingChains = chainRegistry.stakingChains() + val stakingOptionsWithChain = stakingChains.associateWithStakingOptions() + + val offChainSyncFlow = debouncedOffChainSyncFlow(metaAccount, stakingOptionsWithChain, stakingChains) + .shareIn(accountScope, started = SharingStarted.Eagerly, replay = 1) + + val updateFlows = stakingChains.map { stakingChain -> + flowOfAll { + val sharedRequestsBuilder = sharedRequestsBuilderFactory.create(stakingChain.id) + + val chainUpdates = stakingChain.utilityAsset.supportedStakingOptions().mapNotNull { stakingType -> + val updater = updaterFactory.createUpdater(stakingChain, stakingType, metaAccount, offChainSyncFlow) + ?: return@mapNotNull null + + updater.listenForUpdates(sharedRequestsBuilder, Unit) + } + + sharedRequestsBuilder.subscribe(accountScope) + + chainUpdates.mergeIfMultiple() + }.catch { + Log.d("StakingDashboardUpdateSystem", "Failed to sync staking dashboard status for ${stakingChain.name}") + } + } + + updateFlows.merge() + .filterIsInstance() + .onEach(::handleUpdaterEvent) + } + .onCompletion { + syncedItemsFlow.emit(emptyMap()) + } + } + + private fun debouncedOffChainSyncFlow( + metaAccount: MetaAccount, + stakingOptionsWithChain: Map, + stakingChains: List + ): Flow { + return stakingDashboardRepository.stakingAccountsFlow(metaAccount.id) + .map { stakingPrimaryAccounts -> constructStakingAccounts(stakingOptionsWithChain, metaAccount, stakingPrimaryAccounts) } + .zipWithPrevious() + .transform { (previousAccounts, currentAccounts) -> + if (previousAccounts != null) { + val diff = CollectionDiffer.findDiff(previousAccounts, currentAccounts, forceUseNewItems = false) + if (diff.newOrUpdated.isNotEmpty()) { + markSyncingSecondaryFor(diff.newOrUpdated) + emit(currentAccounts) + } + } else { + emit(currentAccounts) + } + } + .withIndex() + .onEach { latestOffChainSyncIndex.value = it.index } + .throttleLast(offChainSyncDebounceRate) + .mapLatest { (index, stakingAccounts) -> + MultiChainOffChainSyncResult( + index = index, + multiChainStakingStats = stakingStatsDataSource.fetchStakingStats(stakingAccounts, stakingChains), + ) + } + } + + private fun markSyncingSecondaryFor(changedPrimaryAccounts: List>) { + val result = syncedItemsFlow.value.toMutableMap() + + changedPrimaryAccounts.forEach { (stakingOptionId, _) -> + result[stakingOptionId] = result.getSyncingStage(stakingOptionId).coerceAtMost(SyncingStage.SYNCING_SECONDARY) + } + + syncedItemsFlow.value = result + } + + private fun List.associateWithStakingOptions(): Map { + return flatMap { chain -> + chain.assets.flatMap { asset -> + asset.supportedStakingOptions().map { + StakingOptionId(chain.id, asset.id, it) to chain + } + } + }.toMap() + } + + private fun constructStakingAccounts( + stakingOptionIds: Map, + metaAccount: MetaAccount, + knownPrimaryAccounts: List + ): StakingAccounts { + val knownStakingAccountsByOptionId = knownPrimaryAccounts.associateBy(StakingDashboardOptionAccounts::stakingOptionId) + + return stakingOptionIds.mapValues { (optionId, chain) -> + val knownPrimaryAccount = knownStakingAccountsByOptionId[optionId] + val default = metaAccount.accountIdIn(chain) ?: return@mapValues null + + val stakeStatusAccount = knownPrimaryAccount?.stakingStatusAccount?.value ?: default + val rewardsAccount = knownPrimaryAccount?.rewardsAccount?.value ?: default + + StakingOptionAccounts(rewards = rewardsAccount.intoKey(), stakingStatus = stakeStatusAccount.intoKey()) + } + } + + private fun handleUpdaterEvent(event: StakingDashboardUpdaterEvent) { + when (event) { + is StakingDashboardUpdaterEvent.AllSynced -> { + // we only mark option as synced if there are no fresher syncs + if (event.indexOfUsedOffChainSync >= latestOffChainSyncIndex.value) { + syncedItemsFlow.value = syncedItemsFlow.value.inserted(event.option, SyncingStage.SYNCED) + } + } + is StakingDashboardUpdaterEvent.PrimarySynced -> { + syncedItemsFlow.value = syncedItemsFlow.value.inserted(event.option, SyncingStage.SYNCING_SECONDARY) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/BaseStakingDashboardUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/BaseStakingDashboardUpdater.kt new file mode 100644 index 0000000..8aa4385 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/BaseStakingDashboardUpdater.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain + +import io.novafoundation.nova.core.updater.GlobalScopeUpdater +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_impl.data.dashboard.cache.StakingDashboardCache +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapStakingTypeToStakingString +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +abstract class BaseStakingDashboardUpdater( + protected val chain: Chain, + protected val chainAsset: Chain.Asset, + protected val stakingType: Chain.Asset.StakingType, + protected val metaAccount: MetaAccount, +) : GlobalScopeUpdater { + + protected val stakingTypeLocal = requireNotNull(mapStakingTypeToStakingString(stakingType)) + + override val requiredModules: List = emptyList() + + abstract suspend fun listenForUpdates(storageSubscriptionBuilder: SharedRequestsBuilder): Flow + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: Unit + ): Flow { + return listenForUpdates(storageSubscriptionBuilder) + } + + protected fun primarySynced(): StakingDashboardUpdaterEvent { + return StakingDashboardUpdaterEvent.PrimarySynced(stakingOptionId()) + } + + protected fun secondarySynced(indexOfUsedOffChainSync: Int): StakingDashboardUpdaterEvent { + return StakingDashboardUpdaterEvent.AllSynced(stakingOptionId(), indexOfUsedOffChainSync) + } + + protected fun stakingOptionId(): StakingOptionId { + return StakingOptionId(chain.id, chainAsset.id, stakingType) + } + + protected suspend fun StakingDashboardCache.update(updating: (StakingDashboardItemLocal?) -> StakingDashboardItemLocal) { + update(chain.id, chainAsset.id, stakingTypeLocal, metaAccount.id, updating) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardMythosUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardMythosUpdater.kt new file mode 100644 index 0000000..8eb9837 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardMythosUpdater.kt @@ -0,0 +1,180 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.takeUnlessZero +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.accountIdKeyIn +import io.novafoundation.nova.feature_staking_impl.data.dashboard.cache.StakingDashboardCache +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.MultiChainStakingStats +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.MultiChainOffChainSyncResult +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.userStake +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.UserStakeInfo +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.hasActiveCollators +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.observeMythosLocks +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.total +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.session +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.validators +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.SessionValidators +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.cache.StorageCachingContext +import io.novafoundation.nova.runtime.storage.cache.cacheValues +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest + +class StakingDashboardMythosUpdater( + chain: Chain, + chainAsset: Chain.Asset, + stakingType: Chain.Asset.StakingType, + metaAccount: MetaAccount, + private val stakingStatsFlow: Flow, + private val balanceLocksRepository: BalanceLocksRepository, + private val stakingDashboardCache: StakingDashboardCache, + override val storageCache: StorageCache, + private val remoteStorageSource: StorageDataSource, +) : BaseStakingDashboardUpdater(chain, chainAsset, stakingType, metaAccount), + StorageCachingContext by StorageCachingContext(storageCache) { + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder + ): Flow { + return subscribeToOnChainState(storageSubscriptionBuilder).transformLatest { onChainState -> + saveItem(onChainState, secondaryInfo = null) + emit(primarySynced()) + + val secondarySyncFlow = stakingStatsFlow.map { (index, stakingStats) -> + val secondaryInfo = constructSecondaryInfo(onChainState, stakingStats) + saveItem(onChainState, secondaryInfo) + + secondarySynced(index) + } + + emitAll(secondarySyncFlow) + } + } + + private suspend fun subscribeToOnChainState(storageSubscriptionBuilder: SharedRequestsBuilder): Flow { + val accountId = metaAccount.accountIdKeyIn(chain) ?: return flowOf(null) + + return combine( + subscribeToTotalStake(), + subscribeToUserStake(storageSubscriptionBuilder, accountId), + sessionValidatorsFlow(storageSubscriptionBuilder) + ) { totalStake, userStakeInfo, sessionValidators -> + constructOnChainInfo(totalStake, userStakeInfo, accountId, sessionValidators) + } + } + + private suspend fun sessionValidatorsFlow(storageSubscriptionBuilder: SharedRequestsBuilder): Flow> { + return remoteStorageSource.subscribe(chain.id, storageSubscriptionBuilder) { + metadata.session.validators.observeNonNull() + } + } + + private fun constructOnChainInfo( + totalStake: Balance?, + userStakeInfo: UserStakeInfo?, + accountId: AccountIdKey, + sessionValidators: SessionValidators, + ): OnChainInfo? { + if (totalStake == null) return null + + val hasActiveValidators = userStakeInfo.hasActiveCollators(sessionValidators) + val activeStake = userStakeInfo?.balance.orZero() + + return OnChainInfo(activeStake, accountId, hasActiveValidators) + } + + private fun constructSecondaryInfo( + baseInfo: OnChainInfo?, + multiChainStakingStats: MultiChainStakingStats, + ): SecondaryInfo? { + val chainStakingStats = multiChainStakingStats[stakingOptionId()] ?: return null + + return SecondaryInfo( + rewards = chainStakingStats.rewards, + estimatedEarnings = chainStakingStats.estimatedEarnings.value, + status = determineStakingStatus(baseInfo) + ) + } + + private fun determineStakingStatus(baseInfo: OnChainInfo?): StakingDashboardItemLocal.Status? { + return when { + baseInfo == null -> null + baseInfo.activeStake.isZero -> StakingDashboardItemLocal.Status.INACTIVE + baseInfo.hasActiveCollators -> StakingDashboardItemLocal.Status.ACTIVE + else -> StakingDashboardItemLocal.Status.INACTIVE + } + } + + private fun subscribeToTotalStake(): Flow { + return balanceLocksRepository.observeMythosLocks(metaAccount.id, chain, chainAsset).map { mythosLocks -> + mythosLocks.total.takeUnlessZero() + } + } + + private suspend fun subscribeToUserStake( + storageSubscriptionBuilder: SharedRequestsBuilder, + accountId: AccountIdKey + ): Flow { + return remoteStorageSource.subscribe(chain.id, storageSubscriptionBuilder) { + metadata.collatorStaking.userStake.observeWithRaw(accountId.value) + .cacheValues() + } + } + + private suspend fun saveItem( + onChainInfo: OnChainInfo?, + secondaryInfo: SecondaryInfo? + ) = stakingDashboardCache.update { fromCache -> + if (onChainInfo != null) { + StakingDashboardItemLocal.staking( + chainId = chain.id, + chainAssetId = chainAsset.id, + stakingType = stakingTypeLocal, + metaId = metaAccount.id, + stake = onChainInfo.activeStake, + status = secondaryInfo?.status ?: fromCache?.status, + rewards = secondaryInfo?.rewards ?: fromCache?.rewards, + estimatedEarnings = secondaryInfo?.estimatedEarnings ?: fromCache?.estimatedEarnings, + stakeStatusAccount = onChainInfo.accountId.value, + rewardsAccount = onChainInfo.accountId.value + ) + } else { + StakingDashboardItemLocal.notStaking( + chainId = chain.id, + chainAssetId = chainAsset.id, + stakingType = stakingTypeLocal, + metaId = metaAccount.id, + estimatedEarnings = secondaryInfo?.estimatedEarnings ?: fromCache?.estimatedEarnings + ) + } + } + + private class OnChainInfo( + val activeStake: Balance, + val accountId: AccountIdKey, + val hasActiveCollators: Boolean + ) + + private class SecondaryInfo( + val rewards: Balance, + val estimatedEarnings: Double, + val status: StakingDashboardItemLocal.Status? + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardNominationPoolsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardNominationPoolsUpdater.kt new file mode 100644 index 0000000..2aa58e9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardNominationPoolsUpdater.kt @@ -0,0 +1,229 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain + +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novafoundation.nova.feature_staking_api.domain.model.activeBalance +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.dashboard.cache.StakingDashboardCache +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.ChainStakingStats +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.MultiChainStakingStats +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.MultiChainOffChainSyncResult +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.activeEra +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.staking +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.bondedPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.poolMembers +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.BondedPool +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolPoints +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.isWaiting +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.PoolBalanceConvertable +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.amountOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.cache.StorageCachingContext +import io.novafoundation.nova.runtime.storage.cache.cacheValues +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.common.utils.metadata +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.transformLatest +import kotlin.coroutines.coroutineContext + +class StakingDashboardNominationPoolsUpdater( + chain: Chain, + chainAsset: Chain.Asset, + stakingType: Chain.Asset.StakingType, + metaAccount: MetaAccount, + private val stakingStatsFlow: Flow, + private val stakingDashboardCache: StakingDashboardCache, + private val remoteStorageSource: StorageDataSource, + private val nominationPoolStateRepository: NominationPoolStateRepository, + private val poolAccountDerivation: PoolAccountDerivation, + storageCache: StorageCache, +) : BaseStakingDashboardUpdater(chain, chainAsset, stakingType, metaAccount), + StorageCachingContext by StorageCachingContext(storageCache) { + + override suspend fun listenForUpdates(storageSubscriptionBuilder: SharedRequestsBuilder): Flow { + return remoteStorageSource.subscribe(chain.id, storageSubscriptionBuilder) { + val stakingStateFlow = subscribeToStakingState() + val activeEraFlow = metadata.staking.activeEra + .observeWithRaw() + .cacheValues() + .filterNotNull() + + combineToPair(stakingStateFlow, activeEraFlow) + } + .transformLatest { (onChainInfo, activeEra) -> + saveItem(onChainInfo, secondaryInfo = null) + emit(primarySynced()) + + val secondarySyncFlow = stakingStatsFlow.map { (index, stakingStats) -> + val secondaryInfo = constructSecondaryInfo(onChainInfo, activeEra, stakingStats) + saveItem(onChainInfo, secondaryInfo) + + secondarySynced(index) + } + + emitAll(secondarySyncFlow) + } + } + + private suspend fun StorageQueryContext.subscribeToStakingState(): Flow { + val accountId = metaAccount.accountIdIn(chain) ?: return flowOf(null) + + val poolMemberFlow = metadata.nominationPools.poolMembers + .observeWithRaw(accountId) + .cacheValues() + + return flowOfAll { + val poolMemberFlowShared = poolMemberFlow + .shareIn(CoroutineScope(coroutineContext), SharingStarted.Lazily, replay = 1) + + val poolAggregatedStateFlow = poolMemberFlowShared + .map { it?.poolId } + .distinctUntilChanged() + .flatMapLatest(::subscribeToPoolWithBalance) + + combine(poolMemberFlow, poolAggregatedStateFlow) { poolMember, poolWithBalance -> + if (poolMember != null && poolWithBalance != null) { + PoolsOnChainInfo(poolMember, poolWithBalance) + } else { + null + } + } + } + } + + private suspend fun subscribeToPoolWithBalance(poolId: PoolId?): Flow { + if (poolId == null) return flowOf(null) + + val bondedPoolAccountId = poolAccountDerivation.derivePoolAccount(poolId, PoolAccountDerivation.PoolAccountType.BONDED, chain.id) + + return remoteStorageSource.subscribeBatched(chain.id) { + val bondedPoolFlow = metadata.nominationPools.bondedPools.observeWithRaw(poolId.value) + .cacheValues() + .filterNotNull() + + val poolNominationsFlow = nominationPoolStateRepository.observePoolNominations(bondedPoolAccountId) + .cacheValues() + + val activeStakeFlow = nominationPoolStateRepository.observeBondedPoolLedger(bondedPoolAccountId) + .cacheValues() + .map { it.activeBalance() } + + combine( + bondedPoolFlow, + poolNominationsFlow, + activeStakeFlow, + ) { bondedPool, nominations, balance -> + PoolAggregatedState(bondedPool, nominations, balance, bondedPoolAccountId) + } + } + } + + private suspend fun saveItem( + relaychainStakingBaseInfo: PoolsOnChainInfo?, + secondaryInfo: NominationPoolsSecondaryInfo?, + ) = stakingDashboardCache.update { fromCache -> + if (relaychainStakingBaseInfo != null) { + StakingDashboardItemLocal.staking( + chainId = chain.id, + chainAssetId = chainAsset.id, + stakingType = stakingTypeLocal, + metaId = metaAccount.id, + stake = relaychainStakingBaseInfo.stakedBalance(), + status = secondaryInfo?.status ?: fromCache?.status, + rewards = secondaryInfo?.rewards ?: fromCache?.rewards, + estimatedEarnings = secondaryInfo?.estimatedEarnings ?: fromCache?.estimatedEarnings, + stakeStatusAccount = relaychainStakingBaseInfo.poolAggregatedState.poolStash, + rewardsAccount = relaychainStakingBaseInfo.poolMember.accountId + ) + } else { + StakingDashboardItemLocal.notStaking( + chainId = chain.id, + chainAssetId = chainAsset.id, + stakingType = stakingTypeLocal, + metaId = metaAccount.id, + estimatedEarnings = secondaryInfo?.estimatedEarnings ?: fromCache?.estimatedEarnings + ) + } + } + + private fun constructSecondaryInfo( + baseInfo: PoolsOnChainInfo?, + activeEra: EraIndex, + multiChainStakingStats: MultiChainStakingStats, + ): NominationPoolsSecondaryInfo? { + val chainStakingStats = multiChainStakingStats[stakingOptionId()] ?: return null + + return NominationPoolsSecondaryInfo( + rewards = chainStakingStats.rewards, + estimatedEarnings = chainStakingStats.estimatedEarnings.value, + status = determineStakingStatus(baseInfo, activeEra, chainStakingStats) + ) + } + + private fun determineStakingStatus( + baseInfo: PoolsOnChainInfo?, + activeEra: EraIndex, + chainStakingStats: ChainStakingStats, + ): StakingDashboardItemLocal.Status? { + return when { + baseInfo == null -> null + baseInfo.poolMember.points.value.isZero -> StakingDashboardItemLocal.Status.INACTIVE + chainStakingStats.accountPresentInActiveStakers -> StakingDashboardItemLocal.Status.ACTIVE + baseInfo.poolAggregatedState.poolNominations != null && baseInfo.poolAggregatedState.poolNominations.isWaiting(activeEra) -> { + StakingDashboardItemLocal.Status.WAITING + } + else -> StakingDashboardItemLocal.Status.INACTIVE + } + } + + private class PoolsOnChainInfo( + val poolMember: PoolMember, + val poolAggregatedState: PoolAggregatedState + ) { + + fun stakedBalance(): Balance { + return poolAggregatedState.amountOf(poolMember.points) + } + } + + private class PoolAggregatedState( + val pool: BondedPool, + val poolNominations: Nominations?, + override val poolBalance: Balance, + val poolStash: AccountId + ) : PoolBalanceConvertable { + + override val poolPoints: PoolPoints = pool.points + } + + private class NominationPoolsSecondaryInfo( + val rewards: Balance, + val estimatedEarnings: Double, + val status: StakingDashboardItemLocal.Status? + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardParachainStakingUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardParachainStakingUpdater.kt new file mode 100644 index 0000000..d18253b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardParachainStakingUpdater.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain + +import io.novafoundation.nova.common.address.get +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal +import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.activeBonded +import io.novafoundation.nova.feature_staking_impl.data.dashboard.cache.StakingDashboardCache +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.ChainStakingStats +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.MultiChainStakingStats +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.MultiChainOffChainSyncResult +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CandidateMetadata +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindCandidateMetadata +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindDelegatorState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.isActive +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.isStakeEnoughToEarnRewards +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest + +class StakingDashboardParachainStakingUpdater( + chain: Chain, + chainAsset: Chain.Asset, + stakingType: Chain.Asset.StakingType, + metaAccount: MetaAccount, + private val stakingStatsFlow: Flow, + private val stakingDashboardCache: StakingDashboardCache, + private val remoteStorageSource: StorageDataSource +) : BaseStakingDashboardUpdater(chain, chainAsset, stakingType, metaAccount) { + + override suspend fun listenForUpdates(storageSubscriptionBuilder: SharedRequestsBuilder): Flow { + return remoteStorageSource.subscribe(chain.id, storageSubscriptionBuilder) { subscribeToStakingState() } + .transformLatest { parachainStakingBaseInfo -> + saveItem(parachainStakingBaseInfo, secondaryInfo = null) + emit(primarySynced()) + + val secondarySyncFlow = stakingStatsFlow.map { (index, stakingStats) -> + val secondaryInfo = constructSecondaryInfo(parachainStakingBaseInfo, stakingStats) + saveItem(parachainStakingBaseInfo, secondaryInfo) + + secondarySynced(index) + } + + emitAll(secondarySyncFlow) + } + } + + private suspend fun StorageQueryContext.subscribeToStakingState(): Flow { + val accountId = metaAccount.accountIdIn(chain) ?: return flowOf(null) + + val delegatorStateFlow = runtime.metadata.parachainStaking().storage("DelegatorState").observe( + accountId, + binding = { bindDelegatorState(it, accountId, chain, chainAsset) } + ) + + return delegatorStateFlow.map { delegatorState -> + if (delegatorState is DelegatorState.Delegator) { + val delegationKeys = delegatorState.delegations.map { listOf(it.owner) } + + val collatorMetadatas = remoteStorageSource.query(chain.id) { + runtime.metadata.parachainStaking().storage("CandidateInfo").entries( + keysArguments = delegationKeys, + keyExtractor = { (candidateId: AccountId) -> candidateId.intoKey() }, + binding = { decoded, _ -> bindCandidateMetadata(decoded) } + ) + } + + ParachainStakingBaseInfo(delegatorState, collatorMetadatas) + } else { + null + } + } + } + + private suspend fun saveItem( + parachainStakingBaseInfo: ParachainStakingBaseInfo?, + secondaryInfo: ParachainStakingSecondaryInfo? + ) = stakingDashboardCache.update { fromCache -> + if (parachainStakingBaseInfo != null) { + StakingDashboardItemLocal.staking( + chainId = chain.id, + chainAssetId = chainAsset.id, + stakingType = stakingTypeLocal, + metaId = metaAccount.id, + stake = parachainStakingBaseInfo.delegatorState.activeBonded, + status = secondaryInfo?.status ?: fromCache?.status, + rewards = secondaryInfo?.rewards ?: fromCache?.rewards, + estimatedEarnings = secondaryInfo?.estimatedEarnings ?: fromCache?.estimatedEarnings, + stakeStatusAccount = parachainStakingBaseInfo.delegatorState.accountId, + rewardsAccount = parachainStakingBaseInfo.delegatorState.accountId + ) + } else { + StakingDashboardItemLocal.notStaking( + chainId = chain.id, + chainAssetId = chainAsset.id, + stakingType = stakingTypeLocal, + metaId = metaAccount.id, + estimatedEarnings = secondaryInfo?.estimatedEarnings ?: fromCache?.estimatedEarnings + ) + } + } + + private fun constructSecondaryInfo( + baseInfo: ParachainStakingBaseInfo?, + multiChainStakingStats: MultiChainStakingStats, + ): ParachainStakingSecondaryInfo? { + val chainStakingStats = multiChainStakingStats[stakingOptionId()] ?: return null + + return ParachainStakingSecondaryInfo( + rewards = chainStakingStats.rewards, + estimatedEarnings = chainStakingStats.estimatedEarnings.value, + status = determineStakingStatus(baseInfo, chainStakingStats) + ) + } + + private fun determineStakingStatus( + baseInfo: ParachainStakingBaseInfo?, + chainStakingStats: ChainStakingStats, + ): StakingDashboardItemLocal.Status? { + return when { + baseInfo == null -> null + baseInfo.delegatorState.activeBonded.isZero -> StakingDashboardItemLocal.Status.INACTIVE + chainStakingStats.accountPresentInActiveStakers -> StakingDashboardItemLocal.Status.ACTIVE + baseInfo.hasWaitingCollators() -> StakingDashboardItemLocal.Status.WAITING + else -> StakingDashboardItemLocal.Status.INACTIVE + } + } + + private fun ParachainStakingBaseInfo.hasWaitingCollators(): Boolean { + return delegatorState.delegations.any { delegatorBond -> + val delegateMetadata = delegatesMetadata[delegatorBond.owner] + + delegateMetadata != null && delegateMetadata.isActive && delegateMetadata.isStakeEnoughToEarnRewards(delegatorBond.balance) + } + } +} + +private class ParachainStakingBaseInfo( + val delegatorState: DelegatorState.Delegator, + val delegatesMetadata: AccountIdKeyMap +) { + + companion object +} + +private class ParachainStakingSecondaryInfo( + val rewards: Balance, + val estimatedEarnings: Double, + val status: StakingDashboardItemLocal.Status? +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardRelayStakingUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardRelayStakingUpdater.kt new file mode 100644 index 0000000..d2ad87d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardRelayStakingUpdater.kt @@ -0,0 +1,176 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain + +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal.Status +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novafoundation.nova.feature_staking_api.domain.model.StakingLedger +import io.novafoundation.nova.feature_staking_api.domain.model.ValidatorPrefs +import io.novafoundation.nova.feature_staking_impl.data.dashboard.cache.StakingDashboardCache +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.ChainStakingStats +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.MultiChainStakingStats +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.MultiChainOffChainSyncResult +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.activeEra +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.bonded +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.ledger +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.nominators +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.staking +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.validators +import io.novafoundation.nova.feature_staking_impl.domain.common.isWaiting +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.common.utils.metadata +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest + +class StakingDashboardRelayStakingUpdater( + chain: Chain, + chainAsset: Chain.Asset, + stakingType: Chain.Asset.StakingType, + metaAccount: MetaAccount, + private val stakingStatsFlow: Flow, + private val stakingDashboardCache: StakingDashboardCache, + private val remoteStorageSource: StorageDataSource +) : BaseStakingDashboardUpdater(chain, chainAsset, stakingType, metaAccount) { + + override suspend fun listenForUpdates(storageSubscriptionBuilder: SharedRequestsBuilder): Flow { + val accountId = metaAccount.accountIdIn(chain) + + return remoteStorageSource.subscribe(chain.id, storageSubscriptionBuilder) { + val activeEraFlow = metadata.staking.activeEra.observeNonNull() + + val baseInfo = if (accountId != null) { + val bondedFlow = metadata.staking.bonded.observe(accountId) + + bondedFlow.flatMapLatest { maybeController -> + val controllerId = maybeController ?: accountId + + subscribeToStakingState(controllerId) + } + } else { + flowOf(null) + } + + combineToPair(baseInfo, activeEraFlow) + }.transformLatest { (relaychainStakingState, activeEra) -> + saveItem(relaychainStakingState, secondaryInfo = null) + emit(primarySynced()) + + val secondarySyncFlow = stakingStatsFlow.map { (index, stakingStats) -> + val secondaryInfo = constructSecondaryInfo(relaychainStakingState, activeEra, stakingStats) + saveItem(relaychainStakingState, secondaryInfo) + + secondarySynced(index) + } + + emitAll(secondarySyncFlow) + } + } + + private fun subscribeToStakingState(controllerId: AccountId): Flow { + return remoteStorageSource.subscribe(chain.id) { + metadata.staking.ledger.observe(controllerId).flatMapLatest { ledger -> + if (ledger != null) { + subscribeToStakerIntentions(ledger.stashId).map { (nominations, validatorPrefs) -> + RelaychainStakingBaseInfo(ledger, nominations, validatorPrefs) + } + } else { + flowOf(null) + } + } + } + } + + private suspend fun subscribeToStakerIntentions(stashId: AccountId): Flow> { + return remoteStorageSource.subscribeBatched(chain.id) { + combineToPair( + metadata.staking.nominators.observe(stashId), + metadata.staking.validators.observe(stashId) + ) + } + } + + private suspend fun saveItem( + relaychainStakingBaseInfo: RelaychainStakingBaseInfo?, + secondaryInfo: RelaychainStakingSecondaryInfo? + ) = stakingDashboardCache.update { fromCache -> + if (relaychainStakingBaseInfo != null) { + StakingDashboardItemLocal.staking( + chainId = chain.id, + chainAssetId = chainAsset.id, + stakingType = stakingTypeLocal, + metaId = metaAccount.id, + stake = relaychainStakingBaseInfo.stakingLedger.active, + status = secondaryInfo?.status ?: fromCache?.status, + rewards = secondaryInfo?.rewards ?: fromCache?.rewards, + estimatedEarnings = secondaryInfo?.estimatedEarnings ?: fromCache?.estimatedEarnings, + stakeStatusAccount = relaychainStakingBaseInfo.stakingLedger.stashId, + rewardsAccount = relaychainStakingBaseInfo.stakingLedger.stashId, + ) + } else { + StakingDashboardItemLocal.notStaking( + chainId = chain.id, + chainAssetId = chainAsset.id, + stakingType = stakingTypeLocal, + metaId = metaAccount.id, + estimatedEarnings = secondaryInfo?.estimatedEarnings ?: fromCache?.estimatedEarnings + ) + } + } + + private fun constructSecondaryInfo( + baseInfo: RelaychainStakingBaseInfo?, + activeEra: EraIndex, + multiChainStakingStats: MultiChainStakingStats, + ): RelaychainStakingSecondaryInfo? { + val chainStakingStats = multiChainStakingStats[stakingOptionId()] ?: return null + + return RelaychainStakingSecondaryInfo( + rewards = chainStakingStats.rewards, + estimatedEarnings = chainStakingStats.estimatedEarnings.value, + status = determineStakingStatus(baseInfo, activeEra, chainStakingStats) + ) + } + + private fun determineStakingStatus( + baseInfo: RelaychainStakingBaseInfo?, + activeEra: EraIndex, + chainStakingStats: ChainStakingStats, + ): Status? { + return when { + baseInfo == null -> null + baseInfo.stakingLedger.active.isZero -> Status.INACTIVE + baseInfo.nominations == null && baseInfo.validatorPrefs == null -> Status.INACTIVE + chainStakingStats.accountPresentInActiveStakers -> Status.ACTIVE + baseInfo.nominations != null && baseInfo.nominations.isWaiting(activeEra) -> Status.WAITING + else -> Status.INACTIVE + } + } +} + +private class RelaychainStakingBaseInfo( + val stakingLedger: StakingLedger, + val nominations: Nominations?, + val validatorPrefs: ValidatorPrefs?, +) + +private class RelaychainStakingSecondaryInfo( + val rewards: Balance, + val estimatedEarnings: Double, + val status: Status? +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardUpdaterFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardUpdaterFactory.kt new file mode 100644 index 0000000..e237a8f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/StakingDashboardUpdaterFactory.kt @@ -0,0 +1,114 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain + +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScopeUpdater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_impl.data.dashboard.cache.StakingDashboardCache +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.MultiChainOffChainSyncResult +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import kotlinx.coroutines.flow.Flow + +class StakingDashboardUpdaterFactory( + private val stakingDashboardCache: StakingDashboardCache, + private val remoteStorageSource: StorageDataSource, + private val nominationPoolBalanceRepository: NominationPoolStateRepository, + private val poolAccountDerivation: PoolAccountDerivation, + private val storageCache: StorageCache, + private val balanceLocksRepository: BalanceLocksRepository, +) { + + fun createUpdater( + chain: Chain, + stakingType: Chain.Asset.StakingType, + metaAccount: MetaAccount, + stakingStatsFlow: Flow, + ): GlobalScopeUpdater? { + return when (stakingType.group()) { + StakingTypeGroup.RELAYCHAIN -> relayChain(chain, stakingType, metaAccount, stakingStatsFlow) + StakingTypeGroup.PARACHAIN -> parachain(chain, stakingType, metaAccount, stakingStatsFlow) + StakingTypeGroup.NOMINATION_POOL -> nominationPools(chain, stakingType, metaAccount, stakingStatsFlow) + StakingTypeGroup.MYTHOS -> mythos(chain, stakingType, metaAccount, stakingStatsFlow) + StakingTypeGroup.UNSUPPORTED -> null + } + } + + private fun relayChain( + chain: Chain, + stakingType: Chain.Asset.StakingType, + metaAccount: MetaAccount, + stakingStatsFlow: Flow, + ): GlobalScopeUpdater { + return StakingDashboardRelayStakingUpdater( + chain = chain, + chainAsset = chain.utilityAsset, + stakingType = stakingType, + metaAccount = metaAccount, + stakingStatsFlow = stakingStatsFlow, + stakingDashboardCache = stakingDashboardCache, + remoteStorageSource = remoteStorageSource + ) + } + + private fun parachain( + chain: Chain, + stakingType: Chain.Asset.StakingType, + metaAccount: MetaAccount, + stakingStatsFlow: Flow, + ): GlobalScopeUpdater { + return StakingDashboardParachainStakingUpdater( + chain = chain, + chainAsset = chain.utilityAsset, + stakingType = stakingType, + metaAccount = metaAccount, + stakingStatsFlow = stakingStatsFlow, + stakingDashboardCache = stakingDashboardCache, + remoteStorageSource = remoteStorageSource + ) + } + + private fun nominationPools( + chain: Chain, + stakingType: Chain.Asset.StakingType, + metaAccount: MetaAccount, + stakingStatsFlow: Flow, + ): GlobalScopeUpdater { + return StakingDashboardNominationPoolsUpdater( + chain = chain, + chainAsset = chain.utilityAsset, + stakingType = stakingType, + metaAccount = metaAccount, + stakingStatsFlow = stakingStatsFlow, + stakingDashboardCache = stakingDashboardCache, + remoteStorageSource = remoteStorageSource, + nominationPoolStateRepository = nominationPoolBalanceRepository, + poolAccountDerivation = poolAccountDerivation, + storageCache = storageCache + ) + } + + private fun mythos( + chain: Chain, + stakingType: Chain.Asset.StakingType, + metaAccount: MetaAccount, + stakingStatsFlow: Flow, + ): GlobalScopeUpdater { + return StakingDashboardMythosUpdater( + chain = chain, + chainAsset = chain.utilityAsset, + stakingType = stakingType, + metaAccount = metaAccount, + stakingDashboardCache = stakingDashboardCache, + balanceLocksRepository = balanceLocksRepository, + storageCache = storageCache, + remoteStorageSource = remoteStorageSource, + stakingStatsFlow = stakingStatsFlow + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/SyncingStageUpdated.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/SyncingStageUpdated.kt new file mode 100644 index 0000000..3199475 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/network/updaters/chain/SyncingStageUpdated.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain + +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId + +sealed class StakingDashboardUpdaterEvent : Updater.SideEffect { + + class AllSynced(val option: StakingOptionId, val indexOfUsedOffChainSync: Int) : StakingDashboardUpdaterEvent() + + class PrimarySynced(val option: StakingOptionId) : StakingDashboardUpdaterEvent() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/repository/StakingDashboardRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/repository/StakingDashboardRepository.kt new file mode 100644 index 0000000..e5fb709 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/repository/StakingDashboardRepository.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.repository + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.fromOption +import io.novafoundation.nova.common.utils.asPercent +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.StakingDashboardDao +import io.novafoundation.nova.core_db.model.StakingDashboardAccountsView +import io.novafoundation.nova.core_db.model.StakingDashboardItemLocal +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.MultiStakingOptionIds +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_impl.data.dashboard.model.StakingDashboardItem +import io.novafoundation.nova.feature_staking_impl.data.dashboard.model.StakingDashboardItem.StakeState.HasStake +import io.novafoundation.nova.feature_staking_impl.data.dashboard.model.StakingDashboardItem.StakeState.NoStake +import io.novafoundation.nova.feature_staking_impl.data.dashboard.model.StakingDashboardOptionAccounts +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapStakingStringToStakingType +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapStakingTypeToStakingString +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.Flow + +interface StakingDashboardRepository { + + fun dashboardItemsFlow(metaAccountId: Long): Flow> + + fun dashboardItemsFlow(metaAccountId: Long, multiStakingOptionIds: MultiStakingOptionIds): Flow> + + fun stakingAccountsFlow(metaAccountId: Long): Flow> +} + +class RealStakingDashboardRepository( + private val dao: StakingDashboardDao +) : StakingDashboardRepository { + + override fun dashboardItemsFlow(metaAccountId: Long): Flow> { + return dao.dashboardItemsFlow(metaAccountId).mapList(::mapDashboardItemFromLocal) + } + + override fun dashboardItemsFlow(metaAccountId: Long, multiStakingOptionIds: MultiStakingOptionIds): Flow> { + val stakingTypes = multiStakingOptionIds.stakingTypes.mapNotNull(::mapStakingTypeToStakingString) + + return dao.dashboardItemsFlow(metaAccountId, multiStakingOptionIds.chainId, multiStakingOptionIds.chainAssetId, stakingTypes) + .mapList(::mapDashboardItemFromLocal) + } + + override fun stakingAccountsFlow(metaAccountId: Long): Flow> { + return dao.stakingAccountsViewFlow(metaAccountId).mapList(::mapStakingAccountViewFromLocal) + } + + private fun mapDashboardItemFromLocal(localItem: StakingDashboardItemLocal): StakingDashboardItem { + return StakingDashboardItem( + fullChainAssetId = FullChainAssetId( + chainId = localItem.chainId, + assetId = localItem.chainAssetId, + ), + stakingType = mapStakingStringToStakingType(localItem.stakingType), + stakeState = if (localItem.hasStake) hasStakeState(localItem) else noStakeState(localItem) + ) + } + + private fun mapStakingAccountViewFromLocal(localItem: StakingDashboardAccountsView): StakingDashboardOptionAccounts { + return StakingDashboardOptionAccounts( + stakingOptionId = StakingOptionId( + chainId = localItem.chainId, + chainAssetId = localItem.chainAssetId, + stakingType = mapStakingStringToStakingType(localItem.stakingType), + ), + stakingStatusAccount = localItem.stakeStatusAccount?.intoKey(), + rewardsAccount = localItem.rewardsAccount?.intoKey() + ) + } + + private fun hasStakeState(localItem: StakingDashboardItemLocal): HasStake { + val estimatedEarnings = localItem.estimatedEarnings + val rewards = localItem.rewards + val status = localItem.status + + val stats = if (estimatedEarnings != null && rewards != null && status != null) { + HasStake.Stats( + rewards = rewards, + status = mapStakingStatusFromLocal(status), + estimatedEarnings = estimatedEarnings.asPercent() + ) + } else { + null + } + + return HasStake( + stake = requireNotNull(localItem.stake), + stats = ExtendedLoadingState.fromOption(stats) + ) + } + + private fun noStakeState(localItem: StakingDashboardItemLocal): NoStake { + val stats = localItem.estimatedEarnings?.let { estimatedEarnings -> + NoStake.Stats(estimatedEarnings.asPercent()) + } + + return NoStake(ExtendedLoadingState.fromOption(stats)) + } + + private fun mapStakingStatusFromLocal(localStatus: StakingDashboardItemLocal.Status): HasStake.StakingStatus { + return when (localStatus) { + StakingDashboardItemLocal.Status.ACTIVE -> HasStake.StakingStatus.ACTIVE + StakingDashboardItemLocal.Status.INACTIVE -> HasStake.StakingStatus.INACTIVE + StakingDashboardItemLocal.Status.WAITING -> HasStake.StakingStatus.WAITING + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/repository/TotalStakeChainComparatorProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/repository/TotalStakeChainComparatorProvider.kt new file mode 100644 index 0000000..8c4f997 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/dashboard/repository/TotalStakeChainComparatorProvider.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.data.dashboard.repository + +import io.novafoundation.nova.common.utils.associateWithIndex +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface TotalStakeChainComparatorProvider { + + suspend fun getTotalStakeComparator(): Comparator +} + +class RealTotalStakeChainComparatorProvider : TotalStakeChainComparatorProvider { + + private val positionByGenesisHash by lazy { + listOf( + Chain.Geneses.POLKADOT, + Chain.Geneses.KUSAMA, + Chain.Geneses.ALEPH_ZERO, + Chain.Geneses.MOONBEAM, + Chain.Geneses.MOONRIVER, + Chain.Geneses.TERNOA, + Chain.Geneses.POLKADEX, + Chain.Geneses.CALAMARI, + Chain.Geneses.ZEITGEIST, + Chain.Geneses.TURING + ).associateWithIndex() + } + + override suspend fun getTotalStakeComparator(): Comparator { + return compareBy { + positionByGenesisHash[it.id] ?: Int.MAX_VALUE + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mappers/Account.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mappers/Account.kt new file mode 100644 index 0000000..f7c687d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mappers/Account.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.data.mappers + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_staking_api.domain.model.StakingAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun mapAccountToStakingAccount(chain: Chain, metaAccount: MetaAccount): StakingAccount? = with(metaAccount) { + val address = addressIn(chain) + + address?.let { + StakingAccount( + address = address, + name = name, + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mappers/SetupStaking.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mappers/SetupStaking.kt new file mode 100644 index 0000000..d010664 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mappers/SetupStaking.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.data.mappers + +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination.RewardDestinationModel + +fun mapRewardDestinationModelToRewardDestination( + rewardDestinationModel: RewardDestinationModel, +): RewardDestination { + return when (rewardDestinationModel) { + is RewardDestinationModel.Restake -> RewardDestination.Restake + is RewardDestinationModel.Payout -> RewardDestination.Payout(rewardDestinationModel.destination.address.toAccountId()) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mappers/StakingReward.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mappers/StakingReward.kt new file mode 100644 index 0000000..9536723 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mappers/StakingReward.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.data.mappers + +import io.novafoundation.nova.core_db.model.TotalRewardLocal +import io.novafoundation.nova.feature_staking_impl.domain.model.TotalReward + +fun mapTotalRewardLocalToTotalReward(reward: TotalRewardLocal): TotalReward { + return reward.totalReward +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/model/ChainExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/model/ChainExt.kt new file mode 100644 index 0000000..e613edb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/model/ChainExt.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.data.model + +import io.novafoundation.nova.runtime.ext.allExternalApis +import io.novafoundation.nova.runtime.ext.externalApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun Chain.stakingRewardsExternalApi(): List = allExternalApis() + +fun Chain.stakingExternalApi(): Chain.ExternalApi.Staking? = externalApi() diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/model/Payout.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/model/Payout.kt new file mode 100644 index 0000000..91bc945 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/model/Payout.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.data.model + +import io.novafoundation.nova.common.address.AccountIdKey +import java.math.BigInteger + +class Payout( + val validatorStash: AccountIdKey, + val era: BigInteger, + val amount: BigInteger, + val pagesToClaim: List +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/RealMythosMainPotMatcherFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/RealMythosMainPotMatcherFactory.kt new file mode 100644 index 0000000..d35c5cf --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/RealMythosMainPotMatcherFactory.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.account.system.AccountSystemAccountMatcher +import io.novafoundation.nova.feature_account_api.domain.account.system.SystemAccountMatcher +import io.novafoundation.nova.feature_staking_api.data.mythos.MythosMainPotMatcherFactory +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.MYTHOS +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +@FeatureScope +class RealMythosMainPotMatcherFactory @Inject constructor( + private val mythosStakingRepository: MythosStakingRepository +) : MythosMainPotMatcherFactory { + + private val fetchMutex = Mutex() + private var cache: SystemAccountMatcher? = null + + override suspend fun create(chainAsset: Chain.Asset): SystemAccountMatcher? { + if (MYTHOS !in chainAsset.staking) return null + + return fetchMutex.withLock { + if (cache == null) { + cache = mythosStakingRepository.getMainStakingPot(chainAsset.chainId) + .map(::AccountSystemAccountMatcher) + .getOrNull() + } + + cache + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/duration/MythosSessionDurationCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/duration/MythosSessionDurationCalculator.kt new file mode 100644 index 0000000..ee2a1e7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/duration/MythosSessionDurationCalculator.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.duration + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.toDuration +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.RealMythosSessionRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.EraRewardCalculatorComparable +import io.novafoundation.nova.feature_staking_impl.domain.common.ignoreInsignificantTimeChanges +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import java.math.BigInteger +import javax.inject.Inject +import kotlin.time.Duration + +interface MythosSessionDurationCalculator : EraRewardCalculatorComparable { + + val blockTime: BigInteger + + fun sessionDuration(): Duration + + /** + * Remaining time of the current session + */ + fun remainingSessionDuration(): Duration +} + +@FeatureScope +class MythosSessionDurationCalculatorFactory @Inject constructor( + private val mythosSessionRepository: RealMythosSessionRepository, + private val chainStateRepository: ChainStateRepository, +) { + + fun create(stakingOption: StakingOption): Flow { + val chainId = stakingOption.chain.id + + return flowOfAll { + val sessionLength = mythosSessionRepository.sessionLength(stakingOption.chain) + + combine( + mythosSessionRepository.currentSlotFlow(chainId), + chainStateRepository.predictedBlockTimeFlow(chainId) + ) { currentSlot, blockTime -> + RealMythosSessionDurationCalculator( + blockTime = blockTime, + currentSlot = currentSlot, + slotsInSession = sessionLength + ) + } + }.ignoreInsignificantTimeChanges() + } +} + +private class RealMythosSessionDurationCalculator( + override val blockTime: BigInteger, + private val currentSlot: BigInteger, + private val slotsInSession: BigInteger +) : MythosSessionDurationCalculator { + + override fun sessionDuration(): Duration { + return (slotsInSession * blockTime).toDuration() + } + + override fun remainingSessionDuration(): Duration { + val remainingBlocks = slotsInSession - sessionProgress() + return (remainingBlocks * blockTime).toDuration() + } + + override fun derivedTimestamp(): Duration { + return (currentSlot * blockTime).toDuration() + } + + private fun sessionProgress(): BigInteger { + // Mythos has 0 offset for sessions, so first block number of a session is divisible by session length + return currentSlot % slotsInSession + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/api/CollatorStakingRuntimeApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/api/CollatorStakingRuntimeApi.kt new file mode 100644 index 0000000..b61a846 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/api/CollatorStakingRuntimeApi.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindPercentFraction +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.Invulnerables +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythCandidateInfo +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythDelegation +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythReleaseRequest +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.UserStakeInfo +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.bindDelegationInfo +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.bindInvulnerables +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.bindMythCandidateInfo +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.bindMythReleaseQueues +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.bindUserStakeInfo +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry0 +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry2 +import io.novafoundation.nova.runtime.storage.source.query.api.converters.scaleDecoder +import io.novafoundation.nova.runtime.storage.source.query.api.converters.scaleEncoder +import io.novafoundation.nova.runtime.storage.source.query.api.storage0 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage2 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class CollatorStakingRuntimeApi(override val module: Module) : QueryableModule + +context(RuntimeContext) +val RuntimeMetadata.collatorStaking: CollatorStakingRuntimeApi + get() = CollatorStakingRuntimeApi(collatorStaking()) + +context(RuntimeContext) +val CollatorStakingRuntimeApi.userStake: QueryableStorageEntry1 + get() = storage1("UserStake", binding = { decoded, _ -> bindUserStakeInfo(decoded) }) + +context(RuntimeContext) +val CollatorStakingRuntimeApi.minStake: QueryableStorageEntry0 + get() = storage0("MinStake", binding = ::bindNumber) + +context(RuntimeContext) +val CollatorStakingRuntimeApi.extraReward: QueryableStorageEntry0 + get() = storage0("ExtraReward", binding = ::bindNumber) + +context(RuntimeContext) +val CollatorStakingRuntimeApi.collatorRewardPercentage: QueryableStorageEntry0 + get() = storage0("CollatorRewardPercentage", binding = ::bindPercentFraction) + +context(RuntimeContext) +val CollatorStakingRuntimeApi.invulnerables: QueryableStorageEntry0 + get() = storage0("Invulnerables", binding = ::bindInvulnerables) + +context(RuntimeContext) +val CollatorStakingRuntimeApi.candidates: QueryableStorageEntry1 + get() = storage1( + "Candidates", + binding = { decoded, _ -> bindMythCandidateInfo(decoded) }, + keyBinding = ::bindAccountIdKey + ) + +context(RuntimeContext) +val CollatorStakingRuntimeApi.candidateStake: QueryableStorageEntry2 + get() = storage2( + "CandidateStake", + binding = { decoded, _, _, -> bindDelegationInfo(decoded) }, + key1ToInternalConverter = AccountIdKey.scaleEncoder, + key2ToInternalConverter = AccountIdKey.scaleEncoder, + key1FromInternalConverter = AccountIdKey.scaleDecoder, + key2FromInternalConverter = AccountIdKey.scaleDecoder + ) + +context(RuntimeContext) +val CollatorStakingRuntimeApi.releaseQueues: QueryableStorageEntry1> + get() = storage1("ReleaseQueues", binding = { decoded, _ -> bindMythReleaseQueues(decoded) }) + +context(RuntimeContext) +val CollatorStakingRuntimeApi.autoCompound: QueryableStorageEntry1 + get() = storage1("AutoCompound", binding = { decoded, _ -> bindPercentFraction(decoded) }) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/calls/CollatorStakingCalls.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/calls/CollatorStakingCalls.kt new file mode 100644 index 0000000..09723d5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/calls/CollatorStakingCalls.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.structOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call + +@JvmInline +value class CollatorStakingCalls(val builder: ExtrinsicBuilder) + +val ExtrinsicBuilder.collatorStaking: CollatorStakingCalls + get() = CollatorStakingCalls(this) + +fun CollatorStakingCalls.lock(amount: Balance) { + builder.call( + moduleName = Modules.COLLATOR_STAKING, + callName = "lock", + arguments = mapOf( + "amount" to amount + ) + ) +} + +data class StakingIntent(val candidate: AccountIdKey, val stake: Balance) { + + companion object { + + fun zero(candidate: AccountIdKey) = StakingIntent(candidate, Balance.ZERO) + } +} + +fun CollatorStakingCalls.stake(intents: List) { + val targets = intents.map(StakingIntent::toEncodableInstance) + + builder.call( + moduleName = Modules.COLLATOR_STAKING, + callName = "stake", + arguments = mapOf( + "targets" to targets + ) + ) +} + +fun CollatorStakingCalls.unstakeFrom(collatorId: AccountIdKey) { + builder.call( + moduleName = Modules.COLLATOR_STAKING, + callName = "unstake_from", + arguments = mapOf( + "account" to collatorId.value + ) + ) +} + +fun CollatorStakingCalls.unlock(amount: Balance) { + builder.call( + moduleName = Modules.COLLATOR_STAKING, + callName = "unlock", + arguments = mapOf( + "maybe_amount" to amount + ) + ) +} + +fun CollatorStakingCalls.release() { + builder.call( + moduleName = Modules.COLLATOR_STAKING, + callName = "release", + arguments = emptyMap() + ) +} + +fun CollatorStakingCalls.claimRewards() { + builder.call( + moduleName = Modules.COLLATOR_STAKING, + callName = "claim_rewards", + arguments = emptyMap() + ) +} + +fun CollatorStakingCalls.setAutoCompoundPercentage(percent: Fraction) { + builder.call( + moduleName = Modules.COLLATOR_STAKING, + callName = "set_autocompound_percentage", + arguments = mapOf( + "percent" to percent.inWholePercents + ) + ) +} + +private fun StakingIntent.toEncodableInstance(): Any { + return structOf( + "candidate" to candidate.value, + "stake" to stake + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/Invulnerables.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/Invulnerables.kt new file mode 100644 index 0000000..634949f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/Invulnerables.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindSet + +typealias Invulnerables = Set + +fun bindInvulnerables(decoded: Any?): Invulnerables { + return bindSet(decoded, ::bindAccountIdKey) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythCandidateInfo.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythCandidateInfo.kt new file mode 100644 index 0000000..9490ed1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythCandidateInfo.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindInt +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class MythCandidateInfo( + val stake: Balance, + val stakers: Int +) + +typealias MythCandidateInfos = AccountIdKeyMap + +fun bindMythCandidateInfo(decoded: Any?): MythCandidateInfo { + val asStruct = decoded.castToStruct() + return MythCandidateInfo( + stake = bindNumber(asStruct["stake"]), + stakers = bindInt(asStruct["stakers"]) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythDelegation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythDelegation.kt new file mode 100644 index 0000000..335286f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythDelegation.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_staking_api.domain.model.SessionIndex +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSessionIndex +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class MythDelegation( + val session: SessionIndex, + val stake: Balance +) + +fun bindDelegationInfo(decoded: Any?): MythDelegation { + val asStruct = decoded.castToStruct() + + return MythDelegation( + session = bindSessionIndex(asStruct["session"]), + stake = bindNumber(asStruct["stake"]) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythReleaseRequest.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythReleaseRequest.kt new file mode 100644 index 0000000..0efde4e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythReleaseRequest.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindBlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class MythReleaseRequest( + val block: BlockNumber, + val amount: Balance +) + +fun MythReleaseRequest.isRedeemableAt(at: BlockNumber): Boolean { + return at >= block +} + +fun List.totalRedeemable(at: BlockNumber): Balance { + return sumByBigInteger { if (it.isRedeemableAt(at)) it.amount else Balance.ZERO } +} + +fun bindMythReleaseRequest(decoded: Any?): MythReleaseRequest { + val asStruct = decoded.castToStruct() + + return MythReleaseRequest( + block = bindBlockNumber(asStruct["block"]), + amount = bindNumber(asStruct["amount"]) + ) +} + +fun bindMythReleaseQueues(decoded: Any): List { + return bindList(decoded, ::bindMythReleaseRequest) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythosStakingFreezeIds.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythosStakingFreezeIds.kt new file mode 100644 index 0000000..04da77f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/MythosStakingFreezeIds.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId + +object MythosStakingFreezeIds { + + val STAKING = BalanceLockId.fromPath(Modules.COLLATOR_STAKING, "Staking") + + val RELEASING = BalanceLockId.fromPath(Modules.COLLATOR_STAKING, "Releasing") +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/UserStakeInfo.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/UserStakeInfo.kt new file mode 100644 index 0000000..55dacf7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/network/blockchain/model/UserStakeInfo.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindBlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.SessionValidators +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger + +class UserStakeInfo( + val balance: Balance, + val maybeLastUnstake: LastUnstake?, + val candidates: List, + val maybeLastRewardSession: SessionIndex? +) + +fun UserStakeInfo.hasActiveCollators(sessionValidators: SessionValidators): Boolean { + return candidates.any { it in sessionValidators } +} + +fun UserStakeInfo.hasInactiveCollators(sessionValidators: SessionValidators): Boolean { + return candidates.any { it !in sessionValidators } +} + +@JvmName("hasActiveCollatorsOrFalse") +fun UserStakeInfo?.hasActiveCollators(sessionValidators: SessionValidators): Boolean { + if (this == null) return false + return hasActiveCollators(sessionValidators) +} + +class LastUnstake( + val amount: Balance, + val availableForRestakeAt: BlockNumber +) + +typealias SessionIndex = BigInteger + +fun bindUserStakeInfo(decoded: Any?): UserStakeInfo { + val asStruct = decoded.castToStruct() + + return UserStakeInfo( + balance = bindNumber(asStruct["stake"]), + maybeLastUnstake = bindLastUnstake(asStruct["maybeLastUnstake"]), + candidates = bindList(asStruct["candidates"], ::bindAccountIdKey), + maybeLastRewardSession = asStruct.get("maybeLastRewardSession")?.let(::bindNumber) + ) +} + +private fun bindLastUnstake(decoded: Any?): LastUnstake? { + if (decoded == null) return null + + // Tuple + val (amountRaw, availableForRestakeAtRaw) = decoded.castToList() + + return LastUnstake( + amount = bindNumber(amountRaw), + availableForRestakeAt = bindBlockNumber(availableForRestakeAtRaw) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosCandidatesRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosCandidatesRepository.kt new file mode 100644 index 0000000..16a24a9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosCandidatesRepository.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.repository + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.candidates +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythCandidateInfos +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Inject +import javax.inject.Named + +interface MythosCandidatesRepository { + + suspend fun getCandidateInfos(chainId: ChainId): MythCandidateInfos +} + +@FeatureScope +class RealMythosCandidatesRepository @Inject constructor( + @Named(REMOTE_STORAGE_SOURCE) + private val remoteStorageSource: StorageDataSource +) : MythosCandidatesRepository { + + override suspend fun getCandidateInfos(chainId: ChainId): MythCandidateInfos { + return remoteStorageSource.query(chainId) { + metadata.collatorStaking.candidates.entries() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosLocks.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosLocks.kt new file mode 100644 index 0000000..19072cf --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosLocks.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.repository + +import io.novafoundation.nova.common.utils.findById +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythosStakingFreezeIds +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +data class MythosLocks( + val releasing: Balance, + val staked: Balance +) + +val MythosLocks.total: Balance + get() = releasing + staked + +fun BalanceLocksRepository.observeMythosLocks(metaId: Long, chain: Chain, chainAsset: Chain.Asset): Flow { + return observeBalanceLocks(metaId, chain, chainAsset) + .map { locks -> locks.findMythosLocks() } + .distinctUntilChanged() +} + +suspend fun BalanceLocksRepository.getMythosLocks(metaId: Long, chainAsset: Chain.Asset): MythosLocks { + return getBalanceLocks(metaId, chainAsset).findMythosLocks() +} + +private fun List.findMythosLocks(): MythosLocks { + return MythosLocks( + releasing = findAmountOrZero(MythosStakingFreezeIds.RELEASING), + staked = findAmountOrZero(MythosStakingFreezeIds.STAKING) + ) +} + +private fun List.findAmountOrZero(id: BalanceLockId): Balance { + return findById(id)?.amountInPlanks.orZero() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosSessionRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosSessionRepository.kt new file mode 100644 index 0000000..0ba903f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosSessionRepository.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.AuraSession +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface MythosSessionRepository { + + suspend fun sessionLength(chain: Chain): BlockNumber + + fun currentSlotFlow(chainId: ChainId): Flow +} + +@FeatureScope +class RealMythosSessionRepository @Inject constructor( + private val auraSession: AuraSession, +) : MythosSessionRepository { + + override suspend fun sessionLength(chain: Chain): BlockNumber { + return chain.additional?.sessionLength?.toBigInteger() + ?: auraSession.sessionLength(chain.id) + } + + override fun currentSlotFlow(chainId: ChainId): Flow { + return auraSession.currentSlotFlow(chainId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosStakingRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosStakingRepository.kt new file mode 100644 index 0000000..9414c0e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosStakingRepository.kt @@ -0,0 +1,136 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.repository + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.collatorStaking +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorRewardPercentage +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.extraReward +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.invulnerables +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.minStake +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.Invulnerables +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.call.RuntimeCallsApi +import io.novafoundation.nova.runtime.call.callCatching +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Named + +interface MythosStakingRepository { + + fun minStakeFlow(chainId: ChainId): Flow + + suspend fun minStake(chainId: ChainId): Balance + + suspend fun maxCollatorsPerDelegator(chainId: ChainId): Int + + suspend fun maxDelegatorsPerCollator(chainId: ChainId): Int + + suspend fun unstakeDurationInBlocks(chainId: ChainId): BlockNumber + + suspend fun maxReleaseRequests(chainId: ChainId): Int + + suspend fun perBlockReward(chainId: ChainId): Balance + + suspend fun collatorCommission(chainId: ChainId): Fraction + + suspend fun getMainStakingPot(chainId: ChainId): Result + + suspend fun getInvulnerableCollators(chainId: ChainId): Invulnerables + + suspend fun autoCompoundThreshold(chainId: ChainId): Balance +} + +@FeatureScope +class RealMythosStakingRepository @Inject constructor( + @Named(LOCAL_STORAGE_SOURCE) + private val localStorageDataSource: StorageDataSource, + + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, + private val chainRegistry: ChainRegistry +) : MythosStakingRepository { + + override fun minStakeFlow(chainId: ChainId): Flow { + return localStorageDataSource.subscribe(chainId) { + metadata.collatorStaking.minStake.observeNonNull() + } + } + + override suspend fun minStake(chainId: ChainId): Balance { + return localStorageDataSource.query(chainId) { + metadata.collatorStaking.minStake.queryNonNull() + } + } + + override suspend fun maxCollatorsPerDelegator(chainId: ChainId): Int { + return chainRegistry.withRuntime(chainId) { + metadata.collatorStaking().numberConstant("MaxStakedCandidates").toInt() + } + } + + override suspend fun maxDelegatorsPerCollator(chainId: ChainId): Int { + return chainRegistry.withRuntime(chainId) { + metadata.collatorStaking().numberConstant("MaxStakers").toInt() + } + } + + override suspend fun unstakeDurationInBlocks(chainId: ChainId): BlockNumber { + return chainRegistry.withRuntime(chainId) { + metadata.collatorStaking().numberConstant("StakeUnlockDelay") + } + } + + override suspend fun maxReleaseRequests(chainId: ChainId): Int { + return maxCollatorsPerDelegator(chainId) + } + + override suspend fun perBlockReward(chainId: ChainId): Balance { + return localStorageDataSource.query(chainId, applyStorageDefault = true) { + metadata.collatorStaking.extraReward.queryNonNull() + } + } + + override suspend fun collatorCommission(chainId: ChainId): Fraction { + return localStorageDataSource.query(chainId) { + metadata.collatorStaking.collatorRewardPercentage.queryNonNull() + } + } + + override suspend fun getMainStakingPot(chainId: ChainId): Result { + return multiChainRuntimeCallsApi.forChain(chainId).mainStakingPot() + } + + override suspend fun getInvulnerableCollators(chainId: ChainId): Invulnerables { + return localStorageDataSource.query(chainId) { + metadata.collatorStaking.invulnerables.query().orEmpty() + } + } + + override suspend fun autoCompoundThreshold(chainId: ChainId): Balance { + return chainRegistry.withRuntime(chainId) { + metadata.collatorStaking().numberConstant("AutoCompoundingThreshold") + } + } + + private suspend fun RuntimeCallsApi.mainStakingPot(): Result { + return callCatching( + section = "CollatorStakingApi", + method = "main_pot_account", + arguments = emptyMap(), + returnBinding = ::bindAccountIdKey + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosUserStakeRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosUserStakeRepository.kt new file mode 100644 index 0000000..6d70537 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/repository/MythosUserStakeRepository.kt @@ -0,0 +1,190 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.repository + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindBoolean +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.autoCompound +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.candidateStake +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.releaseQueues +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.userStake +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythDelegation +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythReleaseRequest +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.UserStakeInfo +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.call.RuntimeCallsApi +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Named + +interface MythosUserStakeRepository { + + fun userStakeOrDefaultFlow(chainId: ChainId, accountId: AccountId): Flow + + suspend fun userStakeOrDefault(chainId: ChainId, accountId: AccountId): UserStakeInfo + + fun userDelegationsFlow( + chainId: ChainId, + userId: AccountIdKey, + delegationIds: List + ): Flow> + + suspend fun userDelegations( + chainId: ChainId, + userId: AccountIdKey, + delegationIds: List + ): Map + + suspend fun shouldClaimRewards( + chainId: ChainId, + accountId: AccountIdKey + ): Boolean + + suspend fun getpPendingRewards( + chainId: ChainId, + accountId: AccountIdKey + ): Balance + + fun releaseQueuesFlow( + chainId: ChainId, + accountId: AccountIdKey + ): Flow> + + suspend fun releaseQueues( + chainId: ChainId, + accountId: AccountIdKey + ): List + + suspend fun getAutoCompoundPercentage( + chainId: ChainId, + accountId: AccountIdKey + ): Fraction + + suspend fun lastShouldRestakeSelection(): Boolean? + + suspend fun setLastShouldRestakeSelection(shouldRestake: Boolean) +} + +private const val SHOULD_RESTAKE_KEY = "RealMythosUserStakeRepository.COMPOUND_MODIFIED_KEY" + +@FeatureScope +class RealMythosUserStakeRepository @Inject constructor( + @Named(LOCAL_STORAGE_SOURCE) + private val localStorageDataSource: StorageDataSource, + private val callApi: MultiChainRuntimeCallsApi, + private val preferences: Preferences, +) : MythosUserStakeRepository { + + override fun userStakeOrDefaultFlow(chainId: ChainId, accountId: AccountId): Flow { + return localStorageDataSource.subscribe(chainId, applyStorageDefault = true) { + metadata.collatorStaking.userStake.observeNonNull(accountId) + } + } + + override suspend fun userStakeOrDefault(chainId: ChainId, accountId: AccountId): UserStakeInfo { + return localStorageDataSource.query(chainId, applyStorageDefault = true) { + metadata.collatorStaking.userStake.queryNonNull(accountId) + } + } + + override fun userDelegationsFlow( + chainId: ChainId, + userId: AccountIdKey, + delegationIds: List + ): Flow> { + return localStorageDataSource.subscribe(chainId) { + val allKeys = delegationIds.map { it to userId } + + metadata.collatorStaking.candidateStake.observe(allKeys).map { resultMap -> + resultMap.filterNotNull().mapKeys { (keys, _) -> keys.first } + } + } + } + + override suspend fun userDelegations( + chainId: ChainId, + userId: AccountIdKey, + delegationIds: List + ): Map { + return localStorageDataSource.query(chainId) { + val allKeys = delegationIds.map { it to userId } + + metadata.collatorStaking.candidateStake.entries(allKeys) + .mapKeys { (keys, _) -> keys.first } + } + } + + override suspend fun shouldClaimRewards(chainId: ChainId, accountId: AccountIdKey): Boolean { + return callApi.forChain(chainId).shouldClaimPendingRewards(accountId) + } + + override suspend fun getpPendingRewards(chainId: ChainId, accountId: AccountIdKey): Balance { + return callApi.forChain(chainId).pendingRewards(accountId) + } + + override fun releaseQueuesFlow(chainId: ChainId, accountId: AccountIdKey): Flow> { + return localStorageDataSource.subscribe(chainId) { + metadata.collatorStaking.releaseQueues.observe(accountId.value) + .map { it.orEmpty() } + } + } + + override suspend fun releaseQueues(chainId: ChainId, accountId: AccountIdKey): List { + return localStorageDataSource.query(chainId) { + metadata.collatorStaking.releaseQueues.query(accountId.value).orEmpty() + } + } + + override suspend fun getAutoCompoundPercentage(chainId: ChainId, accountId: AccountIdKey): Fraction { + return localStorageDataSource.query(chainId, applyStorageDefault = true) { + metadata.collatorStaking.autoCompound.queryNonNull(accountId.value) + } + } + + override suspend fun lastShouldRestakeSelection(): Boolean? { + if (preferences.contains(SHOULD_RESTAKE_KEY)) { + return preferences.getBoolean(SHOULD_RESTAKE_KEY, true) + } + + return null + } + + override suspend fun setLastShouldRestakeSelection(shouldRestake: Boolean) { + preferences.putBoolean(SHOULD_RESTAKE_KEY, shouldRestake) + } + + private suspend fun RuntimeCallsApi.shouldClaimPendingRewards(accountId: AccountIdKey): Boolean { + return call( + section = "CollatorStakingApi", + method = "should_claim", + arguments = mapOf( + "account" to accountId.value + ), + returnBinding = ::bindBoolean + ) + } + + private suspend fun RuntimeCallsApi.pendingRewards(accountId: AccountIdKey): Balance { + return call( + section = "CollatorStakingApi", + method = "total_rewards", + arguments = mapOf( + "account" to accountId.value + ), + returnBinding = ::bindNumber + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosCollatorRewardPercentageUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosCollatorRewardPercentageUpdater.kt new file mode 100644 index 0000000..8852d1f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosCollatorRewardPercentageUpdater.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.updaters + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorRewardPercentage +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class MythosCollatorRewardPercentageUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return with(RuntimeContext(runtime)) { + metadata.collatorStaking.collatorRewardPercentage.storageKey() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosCompoundPercentageUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosCompoundPercentageUpdater.kt new file mode 100644 index 0000000..fa97b21 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosCompoundPercentageUpdater.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.updaters + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.autoCompound +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class MythosCompoundPercentageUpdater( + private val stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + scope: AccountUpdateScope, +) : SingleStorageKeyUpdater(scope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: MetaAccount): String? { + return with(RuntimeContext(runtime)) { + val chain = stakingSharedState.chain() + val accountId = scopeValue.accountIdIn(chain) ?: return@with null + + metadata.collatorStaking.autoCompound.storageKey(accountId) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosExtraRewardUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosExtraRewardUpdater.kt new file mode 100644 index 0000000..2a8e0eb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosExtraRewardUpdater.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.updaters + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.extraReward +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class MythosExtraRewardUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return with(RuntimeContext(runtime)) { + metadata.collatorStaking.extraReward.storageKey() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosMinStakeUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosMinStakeUpdater.kt new file mode 100644 index 0000000..2f2eb11 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosMinStakeUpdater.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.updaters + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.minStake +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class MythosMinStakeUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return with(RuntimeContext(runtime)) { + metadata.collatorStaking.minStake.storageKey() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosReleaseQueuesUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosReleaseQueuesUpdater.kt new file mode 100644 index 0000000..84b2da3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosReleaseQueuesUpdater.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.updaters + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.releaseQueues +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class MythosReleaseQueuesUpdater( + private val stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + scope: AccountUpdateScope, +) : SingleStorageKeyUpdater(scope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: MetaAccount): String? { + return with(RuntimeContext(runtime)) { + val chain = stakingSharedState.chain() + val accountId = scopeValue.accountIdIn(chain) ?: return@with null + + metadata.collatorStaking.releaseQueues.storageKey(accountId) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosSelectedCandidatesUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosSelectedCandidatesUpdater.kt new file mode 100644 index 0000000..d475a1a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/mythos/updaters/MythosSelectedCandidatesUpdater.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.data.mythos.updaters + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.onEachLatest +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.storage.insert +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.candidateStake +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.UserStakeInfo +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class MythosSelectedCandidatesUpdater( + override val scope: AccountUpdateScope, + private val stakingSharedState: StakingSharedState, + private val storageCache: StorageCache, + private val mythosUserStakeRepository: MythosUserStakeRepository, + private val remoteStorageDataSource: StorageDataSource, +) : SharedStateBasedUpdater { + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: MetaAccount + ): Flow { + val chain = stakingSharedState.chain() + val accountId = scopeValue.accountIdIn(chain) ?: return emptyFlow() + + return mythosUserStakeRepository.userStakeOrDefaultFlow(chain.id, accountId).onEachLatest { userStake -> + syncUserDelegations(chain.id, userStake, accountId.intoKey()) + }.noSideAffects() + } + + private suspend fun syncUserDelegations( + chainId: ChainId, + userStake: UserStakeInfo, + userAccountId: AccountIdKey, + ) { + val allKeys = userStake.candidates.map { it to userAccountId } + + val entries = remoteStorageDataSource.query(chainId) { + metadata.collatorStaking.candidateStake.entriesRaw(allKeys) + } + + storageCache.insert(entries, chainId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/api/BabeRuntimeApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/api/BabeRuntimeApi.kt new file mode 100644 index 0000000..ce22213 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/api/BabeRuntimeApi.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.babe +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSlot +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry0 +import io.novafoundation.nova.runtime.storage.source.query.api.storage0 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import java.math.BigInteger + +@JvmInline +value class BabeRuntimeApi(override val module: Module) : QueryableModule + +context(RuntimeContext) +val RuntimeMetadata.babe: BabeRuntimeApi + get() = BabeRuntimeApi(babe()) + +context(RuntimeContext) +val BabeRuntimeApi.currentSlot: QueryableStorageEntry0 + get() = storage0("CurrentSlot", binding = ::bindSlot) + +context(RuntimeContext) +val BabeRuntimeApi.genesisSlot: QueryableStorageEntry0 + get() = storage0("GenesisSlot", binding = ::bindSlot) + +context(RuntimeContext) +val BabeRuntimeApi.epochIndex: QueryableStorageEntry0 + get() = storage0("EpochIndex", binding = ::bindNumber) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/api/SessionRuntimeApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/api/SessionRuntimeApi.kt new file mode 100644 index 0000000..ef7fa78 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/api/SessionRuntimeApi.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.session +import io.novafoundation.nova.feature_staking_api.domain.model.SessionIndex +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.SessionValidators +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSessionIndex +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSessionValidators +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry0 +import io.novafoundation.nova.runtime.storage.source.query.api.storage0 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class SessionRuntimeApi(override val module: Module) : QueryableModule + +context(RuntimeContext) +val RuntimeMetadata.session: SessionRuntimeApi + get() = SessionRuntimeApi(session()) + +context(RuntimeContext) +val SessionRuntimeApi.currentIndex: QueryableStorageEntry0 + get() = storage0("CurrentIndex", binding = ::bindSessionIndex) + +context(RuntimeContext) +val SessionRuntimeApi.validators: QueryableStorageEntry0 + get() = storage0("Validators", binding = ::bindSessionValidators) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/api/StakingRuntimeApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/api/StakingRuntimeApi.kt new file mode 100644 index 0000000..454b2e1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/api/StakingRuntimeApi.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.feature_staking_api.domain.model.BondedEras +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novafoundation.nova.feature_staking_api.domain.model.SessionIndex +import io.novafoundation.nova.feature_staking_api.domain.model.StakingLedger +import io.novafoundation.nova.feature_staking_api.domain.model.ValidatorPrefs +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.UnappliedSlashKey +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bind +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindActiveEra +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindEraIndex +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindNominations +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSessionIndex +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindStakingLedger +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindValidatorPrefs +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry0 +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry2 +import io.novafoundation.nova.runtime.storage.source.query.api.storage0 +import io.novafoundation.nova.runtime.storage.source.query.api.storage0OrNull +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1OrNull +import io.novafoundation.nova.runtime.storage.source.query.api.storage2 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class StakingRuntimeApi(override val module: Module) : QueryableModule + +context(RuntimeContext) +val RuntimeMetadata.staking: StakingRuntimeApi + get() = StakingRuntimeApi(staking()) + +context(RuntimeContext) +val StakingRuntimeApi.ledger: QueryableStorageEntry1 + get() = storage1("Ledger", binding = { decoded, _ -> bindStakingLedger(decoded) }) + +context(RuntimeContext) +val StakingRuntimeApi.nominators: QueryableStorageEntry1 + get() = storage1("Nominators", binding = { decoded, _ -> bindNominations(decoded) }) + +context(RuntimeContext) +val StakingRuntimeApi.validators: QueryableStorageEntry1 + get() = storage1("Validators", binding = { decoded, _ -> bindValidatorPrefs(decoded) }) + +context(RuntimeContext) +val StakingRuntimeApi.bonded: QueryableStorageEntry1 + get() = storage1("Bonded", binding = { decoded, _ -> bindAccountId(decoded) }) + +context(RuntimeContext) +val StakingRuntimeApi.activeEra: QueryableStorageEntry0 + get() = storage0("ActiveEra", binding = ::bindActiveEra) + +context(RuntimeContext) +val StakingRuntimeApi.erasStartSessionIndexOrNull: QueryableStorageEntry1? + get() = storage1OrNull("ErasStartSessionIndex", binding = { decoded, _ -> bindSessionIndex(decoded) }) + +context(RuntimeContext) +val StakingRuntimeApi.bondedErasOrNull: QueryableStorageEntry0? + get() = storage0OrNull("BondedEras", binding = BondedEras.Companion::bind) + +context(RuntimeContext) +val StakingRuntimeApi.unappliedSlashes: QueryableStorageEntry2 + get() = storage2( + name = "UnappliedSlashes", + binding = { _, _, _ -> }, + key1ToInternalConverter = { it }, + key2ToInternalConverter = { TODO("Not yet needed") }, + key1FromInternalConverter = ::bindEraIndex, + key2FromInternalConverter = UnappliedSlashKey.Companion::bind + ) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/BondedEras.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/BondedEras.kt new file mode 100644 index 0000000..3e068d9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/BondedEras.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.feature_staking_api.domain.model.BondedEra +import io.novafoundation.nova.feature_staking_api.domain.model.BondedEras + +fun BondedEras.Companion.bind(decoded: Any?): BondedEras { + val value = bindList(decoded, BondedEra.Companion::bind) + return BondedEras(value) +} + +private fun BondedEra.Companion.bind(decoded: Any?): BondedEra { + val (eraIndex, sessionIndex) = decoded.castToList() + + return BondedEra( + bindEraIndex(dynamicInstance = eraIndex), + bindSessionIndex(sessionIndex) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/ClaimedRewardPages.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/ClaimedRewardPages.kt new file mode 100644 index 0000000..292d8c7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/ClaimedRewardPages.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.utils.mapToSet + +typealias ClaimedRewardsPages = Set + +fun bindClaimedPages(decoded: Any?): Set { + val asList = decoded.castToList() + + return asList.mapToSet { item -> + bindNumber(item).toInt() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Constants.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Constants.kt new file mode 100644 index 0000000..be22f47 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Constants.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumberConstant +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.module.Constant +import java.math.BigInteger + +/* +SlashDeferDuration = EraIndex + */ +@UseCaseBinding +fun bindSlashDeferDuration( + constant: Constant, + runtime: RuntimeSnapshot +): BigInteger = bindNumberConstant(constant, runtime) + +@UseCaseBinding +fun bindMaximumRewardedNominators( + constant: Constant, + runtime: RuntimeSnapshot +): BigInteger = bindNumberConstant(constant, runtime) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Era.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Era.kt new file mode 100644 index 0000000..63c862f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Era.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.data.network.runtime.binding.storageReturnType +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.SessionIndex +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import java.math.BigInteger + +/* +"ActiveEraInfo": { + "type": "struct", + "type_mapping": [ + [ + "index", + "EraIndex" + ], + [ + "start", + "Option" + ] + ] +} + */ +@UseCaseBinding +fun bindActiveEra( + scale: String, + runtime: RuntimeSnapshot +): BigInteger { + val returnType = runtime.metadata.storageReturnType("Staking", "ActiveEra") + val decoded = returnType.fromHexOrNull(runtime, scale) + + return bindActiveEra(decoded) +} + +fun bindActiveEra(decoded: Any?): BigInteger { + return bindEraIndex(decoded.castToStruct().getTyped("index")) +} + +/* +EraIndex + */ +@UseCaseBinding +fun bindCurrentEra( + scale: String, + runtime: RuntimeSnapshot +): BigInteger { + val returnType = runtime.metadata.storageReturnType("Staking", "CurrentEra") + + return bindEraIndex(returnType.fromHexOrNull(runtime, scale)) +} + +@HelperBinding +fun bindEraIndex(dynamicInstance: Any?): EraIndex = bindNumber(dynamicInstance) + +@HelperBinding +fun bindSessionIndex(dynamicInstance: Any?): SessionIndex = bindNumber(dynamicInstance) + +@HelperBinding +fun bindSlot(dynamicInstance: Any?): BigInteger = bindNumber(dynamicInstance) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/EraRewardPoints.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/EraRewardPoints.kt new file mode 100644 index 0000000..e5690d3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/EraRewardPoints.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getList +import io.novafoundation.nova.common.data.network.runtime.binding.requireType +import io.novafoundation.nova.common.utils.second +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +typealias RewardPoints = BigInteger + +class EraRewardPoints( + val totalPoints: RewardPoints, + val individual: List +) { + class Individual(val accountId: AccountId, val rewardPoints: RewardPoints) +} + +@UseCaseBinding +fun bindEraRewardPoints( + decoded: Any? +): EraRewardPoints { + val dynamicInstance = decoded.castToStruct() + + return EraRewardPoints( + totalPoints = bindRewardPoint(dynamicInstance["total"]), + individual = dynamicInstance.getList("individual").map { + requireType>(it) // (AccountId, RewardPoint) + + EraRewardPoints.Individual( + accountId = bindAccountId(it.first()), + rewardPoints = bindRewardPoint(it.second()) + ) + } + ) +} + +@HelperBinding +fun bindRewardPoint(dynamicInstance: Any?): RewardPoints = bindNumber(dynamicInstance) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Exposure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Exposure.kt new file mode 100644 index 0000000..4b0e5d6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Exposure.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.data.network.runtime.binding.requireType +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.ExposureOverview +import io.novafoundation.nova.feature_staking_api.domain.model.ExposurePage +import io.novafoundation.nova.feature_staking_api.domain.model.IndividualExposure +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import java.math.BigInteger + +/* +IndividualExposure: { + who: AccountId; // account id of the nominator + value: Compact; // nominator’s stake +} + */ +@HelperBinding +private fun bindIndividualExposure(dynamicInstance: Any?): IndividualExposure { + requireType(dynamicInstance) + + val who = dynamicInstance.get("who") ?: incompatible() + val value = dynamicInstance.get("value") ?: incompatible() + + return IndividualExposure(who, value) +} + +@UseCaseBinding +fun bindExposure(instance: Any?): Exposure { + val decoded = instance.castToStruct() + + val total = decoded.get("total") ?: incompatible() + val own = decoded.get("own") ?: incompatible() + + val others = decoded.get>("others")?.map { bindIndividualExposure(it) } ?: incompatible() + + return Exposure(total, own, others) +} + +@UseCaseBinding +fun bindExposureOverview(instance: Any?): ExposureOverview { + val decoded = instance.castToStruct() + + return ExposureOverview( + total = bindNumber(decoded["total"]), + own = bindNumber(decoded["own"]), + nominatorCount = bindNumber(decoded["nominatorCount"]), + pageCount = bindNumber(decoded["pageCount"]) + ) +} + +@UseCaseBinding +fun bindExposurePage(instance: Any?): ExposurePage { + val decoded = instance.castToStruct() + + return ExposurePage( + others = bindList(decoded["others"], ::bindIndividualExposure) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/HistoryDepth.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/HistoryDepth.kt new file mode 100644 index 0000000..a05b287 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/HistoryDepth.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.fromHexOrIncompatible +import io.novafoundation.nova.common.data.network.runtime.binding.storageReturnType +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import java.math.BigInteger + +@UseCaseBinding +fun bindHistoryDepth(scale: String, runtime: RuntimeSnapshot): BigInteger { + val type = runtime.metadata.storageReturnType("Staking", "HistoryDepth") + + return bindNumber(type.fromHexOrIncompatible(scale, runtime)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Nominations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Nominations.kt new file mode 100644 index 0000000..b8b09af --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Nominations.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.getList +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.data.network.runtime.binding.requireType +import io.novafoundation.nova.common.data.network.runtime.binding.returnType +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +@UseCaseBinding +fun bindNominations(scale: String, runtime: RuntimeSnapshot): Nominations { + val type = runtime.metadata.staking().storage("Nominators").returnType() + + val dynamicInstance = type.fromHexOrNull(runtime, scale) ?: incompatible() + + return bindNominations(dynamicInstance) +} + +fun bindNominations(dynamicInstance: Any): Nominations { + requireType(dynamicInstance) + + return Nominations( + targets = dynamicInstance.getList("targets").map { it as AccountId }, + submittedInEra = bindEraIndex(dynamicInstance["submittedIn"]), + suppressed = dynamicInstance.getTyped("suppressed") + ) +} + +fun bindNominationsOrNull(dynamicInstance: Any?): Nominations? { + return dynamicInstance?.let(::bindNominations) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Primitive.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Primitive.kt new file mode 100644 index 0000000..f991fca --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/Primitive.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.fromHexOrIncompatible +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.Type +import java.math.BigInteger + +@UseCaseBinding +fun bindTotalValidatorEraReward(scale: String?, runtime: RuntimeSnapshot, type: Type<*>): BigInteger { + val result = scale?.let { bindNumber(type.fromHexOrIncompatible(it, runtime)) } + return result ?: incompatible() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/RewardDestination.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/RewardDestination.kt new file mode 100644 index 0000000..d12d02d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/RewardDestination.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.data.network.runtime.binding.returnType +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +private const val TYPE_STAKED = "Staked" +private const val TYPE_ACCOUNT = "Account" + +fun bindRewardDestination(rewardDestination: RewardDestination) = when (rewardDestination) { + is RewardDestination.Restake -> DictEnum.Entry(TYPE_STAKED, null) + is RewardDestination.Payout -> DictEnum.Entry(TYPE_ACCOUNT, rewardDestination.targetAccountId) +} + +fun bindRewardDestination( + scale: String, + runtime: RuntimeSnapshot, + stashId: AccountId, + controllerId: AccountId, +): RewardDestination { + val type = runtime.metadata.staking().storage("Payee").returnType() + + val dynamicInstance = type.fromHexOrNull(runtime, scale).cast>() + + return when (dynamicInstance.name) { + TYPE_STAKED -> RewardDestination.Restake + TYPE_ACCOUNT -> RewardDestination.Payout(dynamicInstance.value.cast()) + "Stash" -> RewardDestination.Payout(stashId) + "Controller" -> RewardDestination.Payout(controllerId) + else -> incompatible() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/SessionValidators.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/SessionValidators.kt new file mode 100644 index 0000000..6be85d6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/SessionValidators.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindSet + +typealias SessionValidators = Set + +fun bindSessionValidators(dynamicInstance: Any?) = bindSet(dynamicInstance, ::bindAccountIdKey) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/SlashingSpans.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/SlashingSpans.kt new file mode 100644 index 0000000..d9de7fa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/SlashingSpans.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getList +import io.novafoundation.nova.common.data.network.runtime.binding.storageReturnType +import io.novafoundation.nova.feature_staking_api.domain.model.SlashingSpans +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.Type +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull + +fun bindSlashingSpans( + decoded: Any?, +): SlashingSpans { + val asStruct = decoded.castToStruct() + + return SlashingSpans( + lastNonZeroSlash = bindEraIndex(asStruct["lastNonzeroSlash"]), + prior = asStruct.getList("prior").map(::bindEraIndex) + ) +} + +@UseCaseBinding +fun bindSlashingSpans( + scale: String, + runtime: RuntimeSnapshot, + returnType: Type<*> = runtime.metadata.storageReturnType("Staking", "SlashingSpans") +): SlashingSpans { + val decoded = returnType.fromHexOrNull(runtime, scale) + + return bindSlashingSpans(decoded) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/StakingLedger.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/StakingLedger.kt new file mode 100644 index 0000000..d6261f5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/StakingLedger.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.getList +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.data.network.runtime.binding.requireType +import io.novafoundation.nova.common.data.network.runtime.binding.returnType +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.feature_staking_api.domain.model.StakingLedger +import io.novafoundation.nova.feature_staking_api.domain.model.UnlockChunk +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +@UseCaseBinding +fun bindStakingLedger(scale: String, runtime: RuntimeSnapshot): StakingLedger { + val type = runtime.metadata.staking().storage("Ledger").returnType() + val dynamicInstance = type.fromHexOrNull(runtime, scale) ?: incompatible() + + return bindStakingLedger(dynamicInstance) +} + +@UseCaseBinding +fun bindStakingLedger(decoded: Any): StakingLedger { + requireType(decoded) + + return StakingLedger( + stashId = decoded.getTyped("stash"), + total = decoded.getTyped("total"), + active = decoded.getTyped("active"), + unlocking = decoded.getList("unlocking").map(::bindUnlockChunk), + claimedRewards = bindList( + dynamicInstance = decoded["claimedRewards"] ?: decoded["legacyClaimedRewards"] ?: emptyList(), + itemBinder = ::bindEraIndex + ) + ) +} + +@UseCaseBinding +fun bindStakingLedgerOrNull(dynamicInstance: Any?): StakingLedger? { + return dynamicInstance?.let(::bindStakingLedger) +} + +@HelperBinding +fun bindUnlockChunk(dynamicInstance: Any?): UnlockChunk { + requireType(dynamicInstance) + + return UnlockChunk( + amount = dynamicInstance.getTyped("value"), + era = dynamicInstance.getTyped("era") + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/StakingMinMaxStorages.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/StakingMinMaxStorages.kt new file mode 100644 index 0000000..0ffe3bd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/StakingMinMaxStorages.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.fromHexOrIncompatible +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.Type +import java.math.BigInteger + +fun bindMinBond(scale: String, runtimeSnapshot: RuntimeSnapshot, type: Type<*>): BigInteger { + return bindNumber(scale, runtimeSnapshot, type) +} + +fun bindMaxNominators(scale: String, runtimeSnapshot: RuntimeSnapshot, type: Type<*>): BigInteger { + return bindNumber(scale, runtimeSnapshot, type) +} + +fun bindNominatorsCount(scale: String, runtimeSnapshot: RuntimeSnapshot, type: Type<*>): BigInteger { + return bindNumber(scale, runtimeSnapshot, type) +} + +private fun bindNumber(scale: String, runtimeSnapshot: RuntimeSnapshot, type: Type<*>): BigInteger { + return bindNumber(type.fromHexOrIncompatible(scale, runtimeSnapshot)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/UnappliedSlashKey.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/UnappliedSlashKey.kt new file mode 100644 index 0000000..eaf6230 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/UnappliedSlashKey.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.castToList + +data class UnappliedSlashKey(val validator: AccountIdKey) { + + companion object { + + fun bind(decoded: Any?): UnappliedSlashKey { + val (validator) = decoded.castToList() + + return UnappliedSlashKey( + validator = bindAccountIdKey(validator), + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/ValidatorPrefs.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/ValidatorPrefs.kt new file mode 100644 index 0000000..e2179ca --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/bindings/ValidatorPrefs.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindPerbillNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.feature_staking_api.domain.model.ValidatorPrefs + +private const val BLOCKED_DEFAULT = false + +fun bindValidatorPrefs(decoded: Any?): ValidatorPrefs { + val asStruct = decoded.castToStruct() + + return ValidatorPrefs( + commission = bindPerbillNumber(asStruct.getTyped("commission")), + blocked = asStruct["blocked"] ?: BLOCKED_DEFAULT + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/calls/ExtrinsicBuilderExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/calls/ExtrinsicBuilderExt.kt new file mode 100644 index 0000000..9d0f689 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/calls/ExtrinsicBuilderExt.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls + +import io.novafoundation.nova.common.data.network.runtime.binding.MultiAddress +import io.novafoundation.nova.common.data.network.runtime.binding.bindMultiAddress +import io.novafoundation.nova.common.utils.voterListName +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindRewardDestination +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import java.math.BigInteger + +fun ExtrinsicBuilder.setController(controllerAddress: MultiAddress): ExtrinsicBuilder { + return call( + "Staking", + "set_controller", + mapOf( + // `controller` argument is missing on newer versions of staking pallets + // but we don`t sanitize it since library will ignore unknown arguments on encoding stage + "controller" to bindMultiAddress(controllerAddress) + ) + ) +} + +fun ExtrinsicBuilder.bond( + controllerAddress: MultiAddress, + amount: BigInteger, + payee: RewardDestination, +): ExtrinsicBuilder { + return call( + "Staking", + "bond", + mapOf( + // `controller` argument is missing on newer versions of staking pallets + // but we don`t sanitize it since library will ignore unknown arguments on encoding stage + "controller" to bindMultiAddress(controllerAddress), + "value" to amount, + "payee" to bindRewardDestination(payee) + ) + ) +} + +fun ExtrinsicBuilder.nominate(targets: List): ExtrinsicBuilder { + return call( + "Staking", + "nominate", + mapOf( + "targets" to targets.map(::bindMultiAddress) + ) + ) +} + +fun ExtrinsicBuilder.bondMore(amount: BigInteger): ExtrinsicBuilder { + return call( + "Staking", + "bond_extra", + mapOf( + "max_additional" to amount + ) + ) +} + +fun ExtrinsicBuilder.chill(): ExtrinsicBuilder { + return call("Staking", "chill", emptyMap()) +} + +fun ExtrinsicBuilder.unbond(amount: BigInteger): ExtrinsicBuilder { + return call( + "Staking", + "unbond", + mapOf( + "value" to amount + ) + ) +} + +fun ExtrinsicBuilder.withdrawUnbonded(numberOfSlashingSpans: BigInteger): ExtrinsicBuilder { + return call( + "Staking", + "withdraw_unbonded", + mapOf( + "num_slashing_spans" to numberOfSlashingSpans + ) + ) +} + +fun ExtrinsicBuilder.rebond(amount: BigInteger): ExtrinsicBuilder { + return call( + "Staking", + "rebond", + mapOf( + "value" to amount + ) + ) +} + +fun ExtrinsicBuilder.setPayee(rewardDestination: RewardDestination): ExtrinsicBuilder { + return call( + "Staking", + "set_payee", + mapOf( + "payee" to bindRewardDestination(rewardDestination) + ) + ) +} + +fun ExtrinsicBuilder.rebag(dislocated: AccountId): ExtrinsicBuilder { + return call( + moduleName = runtime.metadata.voterListName(), + callName = "rebag", + arguments = mapOf( + "dislocated" to PezkuwiAddressConstructor.constructInstance(runtime.typeRegistry, dislocated) + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/AccountNominationsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/AccountNominationsUpdater.kt new file mode 100644 index 0000000..903579e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/AccountNominationsUpdater.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.AccountStakingScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class AccountNominationsUpdater( + scope: AccountStakingScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(scope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: AccountStakingLocal): String? { + val stakingAccessInfo = scopeValue.stakingAccessInfo ?: return null + val stashId = stakingAccessInfo.stashId + + return runtime.metadata.staking().storage("Nominators").storageKey(runtime, stashId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/AccountRewardDestinationUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/AccountRewardDestinationUpdater.kt new file mode 100644 index 0000000..16d6ae3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/AccountRewardDestinationUpdater.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.AccountStakingScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class AccountRewardDestinationUpdater( + scope: AccountStakingScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(scope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: AccountStakingLocal): String? { + val stakingAccessInfo = scopeValue.stakingAccessInfo ?: return null + val stashId = stakingAccessInfo.stashId + + return runtime.metadata.staking().storage("Payee").storageKey(runtime, stashId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/AccountValidatorPrefsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/AccountValidatorPrefsUpdater.kt new file mode 100644 index 0000000..525280f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/AccountValidatorPrefsUpdater.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.AccountStakingScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class AccountValidatorPrefsUpdater( + scope: AccountStakingScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(scope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: AccountStakingLocal): String? { + val stakingAccessInfo = scopeValue.stakingAccessInfo ?: return null + val stashId = stakingAccessInfo.stashId + + return runtime.metadata.staking().storage("Validators").storageKey(runtime, stashId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ActiveEraUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ActiveEraUpdater.kt new file mode 100644 index 0000000..33aaee2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ActiveEraUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class ActiveEraUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.staking().storage("ActiveEra").storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/BagListNodeUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/BagListNodeUpdater.kt new file mode 100644 index 0000000..bb5ad8f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/BagListNodeUpdater.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.voterListOrNull +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.AccountStakingScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class BagListNodeUpdater( + scope: AccountStakingScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(scope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: AccountStakingLocal): String? { + val stakingAccessInfo = scopeValue.stakingAccessInfo ?: return null + val stashId = stakingAccessInfo.stashId + + return runtime.metadata.voterListOrNull()?.storage("ListNodes")?.storageKey(runtime, stashId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/Common.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/Common.kt new file mode 100644 index 0000000..601c7f5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/Common.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.model.StorageEntry +import io.novafoundation.nova.core.storage.StorageCache +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.wsrpc.SocketService + +fun RuntimeMetadata.activeEraStorageKey() = staking().storage("ActiveEra").storageKey() + +suspend fun BulkRetriever.fetchValuesToCache( + socketService: SocketService, + keys: List, + storageCache: StorageCache, + chainId: String, +) { + val allValues = queryKeys(socketService, keys) + + val toInsert = allValues.map { (key, value) -> StorageEntry(key, value) } + + storageCache.insert(toInsert, chainId) +} + +/** + * @return number of fetched keys + */ +suspend fun BulkRetriever.fetchPrefixValuesToCache( + socketService: SocketService, + prefix: String, + storageCache: StorageCache, + chainId: String +): Int { + val allKeys = retrieveAllKeys(socketService, prefix) + + if (allKeys.isNotEmpty()) { + fetchValuesToCache(socketService, allKeys, storageCache, chainId) + } + + return allKeys.size +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/CounterForListNodesUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/CounterForListNodesUpdater.kt new file mode 100644 index 0000000..d12fee8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/CounterForListNodesUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.voterListOrNull +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class CounterForListNodesUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String? { + return runtime.metadata.voterListOrNull()?.storage("CounterForListNodes")?.storageKey(runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/CounterForNominatorsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/CounterForNominatorsUpdater.kt new file mode 100644 index 0000000..5d1a67b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/CounterForNominatorsUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull + +class CounterForNominatorsUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String? { + return runtime.metadata.staking().storageOrNull("CounterForNominators")?.storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/CurrentEraUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/CurrentEraUpdater.kt new file mode 100644 index 0000000..e1ea45c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/CurrentEraUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class CurrentEraUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.staking().storage("CurrentEra").storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/HistoryDepthUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/HistoryDepthUpdater.kt new file mode 100644 index 0000000..d534dfd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/HistoryDepthUpdater.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.defaultInHex +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull + +class HistoryDepthUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override fun fallbackValue(runtime: RuntimeSnapshot): String? { + return storageEntry(runtime)?.defaultInHex() + } + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String? { + return storageEntry(runtime)?.storageKey() + } + + private fun storageEntry(runtime: RuntimeSnapshot) = runtime.metadata.staking().storageOrNull("HistoryDepth") +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/MaxNominatorsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/MaxNominatorsUpdater.kt new file mode 100644 index 0000000..3659237 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/MaxNominatorsUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull + +class MaxNominatorsUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String? { + return runtime.metadata.staking().storageOrNull("MaxNominatorsCount")?.storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/MinBondUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/MinBondUpdater.kt new file mode 100644 index 0000000..05bc351 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/MinBondUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull + +class MinBondUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String? { + return runtime.metadata.staking().storageOrNull("MinNominatorBond")?.storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ParachainsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ParachainsUpdater.kt new file mode 100644 index 0000000..f8cefff --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ParachainsUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.parasOrNull +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class ParachainsUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String? { + return runtime.metadata.parasOrNull()?.storage("Parachains")?.storageKey(runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ProxiesUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ProxiesUpdater.kt new file mode 100644 index 0000000..91cf903 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ProxiesUpdater.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.proxyOrNull +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.AccountStakingScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull + +class ProxiesUpdater( + scope: AccountStakingScope, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(scope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: AccountStakingLocal): String? { + val accountId = scopeValue.accountId + return runtime.metadata.proxyOrNull()?.storageOrNull("Proxies")?.storageKey(runtime, accountId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingLandingInfoUpdateSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingLandingInfoUpdateSystem.kt new file mode 100644 index 0000000..3ec09c0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingLandingInfoUpdateSystem.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.mergeIfMultiple +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.ChainUpdaterGroupUpdateSystem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn + +class StakingLandingInfoUpdateSystemFactory( + private val stakingUpdaters: StakingUpdaters, + private val chainRegistry: ChainRegistry, + private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) { + + fun create(chainId: ChainId, stakingTypes: List): StakingLandingInfoUpdateSystem { + return StakingLandingInfoUpdateSystem( + stakingUpdaters, + chainId, + stakingTypes, + chainRegistry, + storageSharedRequestsBuilderFactory, + ) + } +} + +class StakingLandingInfoUpdateSystem( + private val stakingUpdaters: StakingUpdaters, + private val chainId: ChainId, + private val stakingTypes: List, + private val chainRegistry: ChainRegistry, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : ChainUpdaterGroupUpdateSystem(chainRegistry, storageSharedRequestsBuilderFactory) { + + override fun start(): Flow = flowOfAll { + val stakingChain = chainRegistry.getChain(chainId) + + val updatersBySyncChainId = stakingUpdaters.getUpdaters(stakingChain, stakingTypes) + + updatersBySyncChainId.map { (syncChainId, updaters) -> + val syncChain = chainRegistry.getChain(syncChainId) + runUpdaters(syncChain, updaters) + }.mergeIfMultiple() + }.flowOn(Dispatchers.Default) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingLedgerUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingLedgerUpdater.kt new file mode 100644 index 0000000..93e5577 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingLedgerUpdater.kt @@ -0,0 +1,182 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.model.StorageChange +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.dao.AccountStakingDao +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.StakingLedger +import io.novafoundation.nova.feature_staking_api.domain.model.isRedeemableIn +import io.novafoundation.nova.feature_staking_api.domain.model.isUnbondingIn +import io.novafoundation.nova.feature_staking_api.domain.model.sumStaking +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindStakingLedger +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.runtime.ext.disabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.network.updaters.insert +import io.novafoundation.nova.runtime.state.assetWithChain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.SubscribeStorageRequest +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.storageChange +import io.novasama.substrate_sdk_android.wsrpc.subscriptionFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.math.BigInteger + +class LedgerWithController( + val ledger: StakingLedger, + val controllerId: AccountId, +) + +class StakingLedgerUpdater( + private val stakingRepository: StakingRepository, + private val stakingSharedState: StakingSharedState, + private val chainRegistry: ChainRegistry, + private val accountStakingDao: AccountStakingDao, + private val storageCache: StorageCache, + private val assetCache: AssetCache, + override val scope: AccountUpdateScope, +) : SharedStateBasedUpdater { + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: MetaAccount + ): Flow { + val (chain, chainAsset) = stakingSharedState.assetWithChain.first() + if (chainAsset.disabled) return emptyFlow() + + val runtime = chainRegistry.getRuntime(chain.id) + + val currentAccountId = scopeValue.accountIdIn(chain) ?: return emptyFlow() + val socketService = storageSubscriptionBuilder.socketService ?: return emptyFlow() + + val key = runtime.metadata.staking().storage("Bonded").storageKey(runtime, currentAccountId) + + return storageSubscriptionBuilder.subscribe(key) + .flatMapLatest { change -> + // assume we're controller, if no controller found + val controllerId = change.value?.fromHex() ?: currentAccountId + + subscribeToLedger(socketService, runtime, chain.id, controllerId) + }.onEach { ledgerWithController -> + updateAccountStaking(chain.id, chainAsset.id, currentAccountId, ledgerWithController) + + ledgerWithController?.let { + val era = stakingRepository.getActiveEraIndex(chain.id) + + val stashId = it.ledger.stashId + val controllerId = it.controllerId + + updateAssetStaking(it.ledger.stashId, chainAsset, it.ledger, era) + + if (!stashId.contentEquals(controllerId)) { + updateAssetStaking(controllerId, chainAsset, it.ledger, era) + } + } ?: updateAssetStakingForEmptyLedger(currentAccountId, chainAsset) + } + .flowOn(Dispatchers.IO) + .noSideAffects() + } + + private suspend fun updateAccountStaking( + chainId: String, + chainAssetId: Int, + accountId: AccountId, + ledgerWithController: LedgerWithController?, + ) { + val accountStaking = AccountStakingLocal( + chainId = chainId, + chainAssetId = chainAssetId, + accountId = accountId, + stakingAccessInfo = ledgerWithController?.let { + AccountStakingLocal.AccessInfo( + stashId = it.ledger.stashId, + controllerId = it.controllerId, + ) + } + ) + + accountStakingDao.insert(accountStaking) + } + + private suspend fun subscribeToLedger( + socketService: SocketService, + runtime: RuntimeSnapshot, + chainId: String, + controllerId: AccountId, + ): Flow { + val key = runtime.metadata.staking().storage("Ledger").storageKey(runtime, controllerId) + val request = SubscribeStorageRequest(key) + + return socketService.subscriptionFlow(request) + .map { it.storageChange() } + .onEach { + val storageChange = StorageChange(it.block, key, it.getSingleChange()) + + storageCache.insert(storageChange, chainId) + } + .map { + val change = it.getSingleChange() + + if (change != null) { + val ledger = bindStakingLedger(change, runtime) + + LedgerWithController(ledger, controllerId) + } else { + null + } + } + } + + private suspend fun updateAssetStaking( + accountId: AccountId, + chainAsset: Chain.Asset, + stakingLedger: StakingLedger, + era: BigInteger, + ) { + assetCache.updateAsset(accountId, chainAsset) { cached -> + + val redeemable = stakingLedger.unlocking.sumStaking { it.isRedeemableIn(era) } + val unbonding = stakingLedger.unlocking.sumStaking { it.isUnbondingIn(era) } + + cached.copy( + redeemableInPlanks = redeemable, + unbondingInPlanks = unbonding, + bondedInPlanks = stakingLedger.active + ) + } + } + + private suspend fun updateAssetStakingForEmptyLedger( + accountId: AccountId, + chainAsset: Chain.Asset, + ) { + assetCache.updateAsset(accountId, chainAsset) { cached -> + cached.copy( + redeemableInPlanks = BigInteger.ZERO, + unbondingInPlanks = BigInteger.ZERO, + bondedInPlanks = BigInteger.ZERO + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingUpdateSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingUpdateSystem.kt new file mode 100644 index 0000000..5601f4a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingUpdateSystem.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.MultiMap +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.multiChain.MultiChainUpdateSystem + +class StakingUpdateSystem( + private val chainRegistry: ChainRegistry, + private val stakingUpdaters: StakingUpdaters, + stakingSharedState: StakingSharedState, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : MultiChainUpdateSystem(chainRegistry, stakingSharedState, storageSharedRequestsBuilderFactory) { + + override fun getUpdaters(option: StakingOption): MultiMap> { + return stakingUpdaters.getUpdaters(option.chain, option.stakingType) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingUpdaters.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingUpdaters.kt new file mode 100644 index 0000000..6bb6f08 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/StakingUpdaters.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.MultiMap +import io.novafoundation.nova.common.utils.buildMultiMap +import io.novafoundation.nova.common.utils.putAll +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.ALEPH_ZERO +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.MYTHOS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.NOMINATION_POOLS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.PARACHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN_AURA +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.TURING +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.UNSUPPORTED +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.groupBySyncingChain + +class StakingUpdaters( + private val relaychainUpdaters: Group, + private val parachainUpdaters: Group, + private val commonUpdaters: Group, + private val turingExtraUpdaters: Group, + private val nominationPoolsUpdaters: Group, + private val mythosUpdaters: Group, +) { + + class Group(val updaters: List>) { + constructor(vararg updaters: SharedStateBasedUpdater<*>) : this(updaters.toList()) + } + + fun getUpdaters(stakingChain: Chain, stakingType: StakingType): MultiMap> { + return buildMultiMap { + putAll(getCommonUpdaters(stakingChain)) + putAll(getUpdatersByType(stakingChain, stakingType)) + } + } + + fun getUpdaters(stakingChain: Chain, stakingTypes: List): MultiMap> { + return buildMultiMap { + putAll(getCommonUpdaters(stakingChain)) + + stakingTypes.forEach { + putAll(getUpdatersByType(stakingChain, it)) + } + } + } + + private fun getCommonUpdaters(stakingChain: Chain): MultiMap> { + return commonUpdaters.updaters.groupBySyncingChain(stakingChain) + } + + private fun getUpdatersByType(stakingChain: Chain, stakingType: StakingType): MultiMap> { + val byTypeUpdaters = when (stakingType) { + RELAYCHAIN, RELAYCHAIN_AURA, ALEPH_ZERO -> relaychainUpdaters.updaters + PARACHAIN -> parachainUpdaters.updaters + TURING -> parachainUpdaters.updaters + turingExtraUpdaters.updaters + NOMINATION_POOLS -> nominationPoolsUpdaters.updaters + MYTHOS -> mythosUpdaters.updaters + UNSUPPORTED -> emptyList() + } + + return byTypeUpdaters.groupBySyncingChain(stakingChain) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/TimelineDelegatingSingleKeyUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/TimelineDelegatingSingleKeyUpdater.kt new file mode 100644 index 0000000..0dab51f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/TimelineDelegatingSingleKeyUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.UpdateScope +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater + +abstract class TimelineDelegatingSingleKeyUpdater( + scope: UpdateScope, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder +) : SingleStorageKeyUpdater(scope, timelineDelegatingChainIdHolder, chainRegistry, storageCache), SharedStateBasedUpdater { + + override fun getSyncChainId(sharedStateChain: Chain): ChainId { + return sharedStateChain.timelineChainIdOrSelf() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ValidatorExposureUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ValidatorExposureUpdater.kt new file mode 100644 index 0000000..1ce07a1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/ValidatorExposureUpdater.kt @@ -0,0 +1,251 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.utils.hasStorage +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.model.StorageEntry +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ValidatorExposureUpdater.Companion.STORAGE_KEY_PAGED_EXPOSURES +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.ActiveEraScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import java.math.BigInteger + +/** + * Manages sync for validators exposures + * Depending on the version of the staking pallet, exposures might be stored differently: + * + * 1. Legacy version - exposures are stored in `EraStakers` storage + * 2. Current version - exposures are paged and stored in two storages: `EraStakersPaged` and `EraStakersOverview` + * + * Note that during the transition from Legacy to Current version during next [Staking.historyDepth] + * eras older storage will still be present and filled with previous era information. Old storage will be cleared on era-by-era basis once new era happens. + * Also, right after chain has just upgraded, `EraStakersPaged` will be empty untill next era happens. + * + * The updater takes care of that all also storing special [STORAGE_KEY_PAGED_EXPOSURES] indicating whether + * paged exposures are actually present for the latest era or not + */ +class ValidatorExposureUpdater( + private val bulkRetriever: BulkRetriever, + private val stakingSharedState: StakingSharedState, + private val chainRegistry: ChainRegistry, + private val storageCache: StorageCache, + override val scope: ActiveEraScope, +) : SharedStateBasedUpdater { + + companion object { + + const val STORAGE_KEY_PAGED_EXPOSURES = "NovaWallet.Staking.PagedExposuresUsed" + + fun isPagedExposuresValue(enabled: Boolean) = enabled.toString() + + fun decodeIsPagedExposuresValue(value: String?) = value.toBoolean() + } + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: EraIndex, + ): Flow { + @Suppress("UnnecessaryVariable") + val activeEra = scopeValue + val socketService = storageSubscriptionBuilder.socketService ?: return emptyFlow() + + return flow { + val chainId = stakingSharedState.chainId() + val runtime = chainRegistry.getRuntime(chainId) + + if (checkValuesInCache(activeEra, chainId, runtime)) { + return@flow + } + + cleanupOutdatedEras(chainId, runtime) + + syncNewExposures(activeEra, runtime, socketService, chainId) + }.noSideAffects() + } + + private suspend fun checkValuesInCache(era: BigInteger, chainId: String, runtimeSnapshot: RuntimeSnapshot): Boolean { + if (!isPagedExposuresFlagInCache(chainId)) return false + + return isPagedExposuresInCache(era, chainId, runtimeSnapshot) || isLegacyExposuresInCache(era, chainId, runtimeSnapshot) + } + + private suspend fun isPagedExposuresFlagInCache(chainId: String): Boolean { + return storageCache.isFullKeyInCache(STORAGE_KEY_PAGED_EXPOSURES, chainId) + } + + private suspend fun isPagedExposuresInCache(era: BigInteger, chainId: String, runtimeSnapshot: RuntimeSnapshot): Boolean { + if (!runtimeSnapshot.pagedExposuresEnabled()) return false + + val prefix = runtimeSnapshot.eraStakersPagedPrefixFor(era) + + return storageCache.isPrefixInCache(prefix, chainId) + } + + private suspend fun isLegacyExposuresInCache(era: BigInteger, chainId: String, runtimeSnapshot: RuntimeSnapshot): Boolean { + // We cannot construct storage key to remove legacy exposures + // There is a little chance they are still there given that removing of legacy exposures should + // happen at least when all legacy exposures are removed from the chain + if (runtimeSnapshot.legacyExposuresFullyRemoved()) { + return false + } + + val prefix = runtimeSnapshot.eraStakersPrefixFor(era) + + return storageCache.isPrefixInCache(prefix, chainId) + } + + private suspend fun cleanupOutdatedEras(chainId: String, runtimeSnapshot: RuntimeSnapshot) { + cleanupPagedExposures(runtimeSnapshot, chainId) + cleanupLegacyExposures(runtimeSnapshot, chainId) + } + + private suspend fun cleanupLegacyExposures(runtimeSnapshot: RuntimeSnapshot, chainId: String) { + if (runtimeSnapshot.legacyExposuresFullyRemoved()) return + + storageCache.removeByPrefix(runtimeSnapshot.eraStakersPrefix(), chainId) + } + + private suspend fun cleanupPagedExposures(runtimeSnapshot: RuntimeSnapshot, chainId: String) { + if (!runtimeSnapshot.pagedExposuresEnabled()) return + + storageCache.removeByPrefix(runtimeSnapshot.eraStakersPagedPrefix(), chainId) + storageCache.removeByPrefix(runtimeSnapshot.eraStakersOverviewPrefix(), chainId) + } + + private suspend fun syncNewExposures(era: BigInteger, runtimeSnapshot: RuntimeSnapshot, socketService: SocketService, chainId: String) { + var pagedExposureState = runtimeSnapshot.detectExposureStateFromPallets() + + if (pagedExposureState.shouldTrySyncingPagedExposures()) { + val pagedExposuresPresent = tryFetchingPagedExposures(era, runtimeSnapshot, socketService, chainId) + + if (pagedExposuresPresent) { + pagedExposureState = ExposureState.CERTAIN_PAGED + } + } + + if (pagedExposureState.shouldTrySyncingLegacyExposures()) { + val legacyExposuresPresent = fetchLegacyExposures(era, runtimeSnapshot, socketService, chainId) + + if (legacyExposuresPresent) { + pagedExposureState = ExposureState.CERTAIN_LEGACY + } + } + + saveIsExposuresUsedFlag(pagedExposureState, chainId) + } + + private fun RuntimeSnapshot.detectExposureStateFromPallets(): ExposureState { + return when { + legacyExposuresFullyRemoved() -> ExposureState.CERTAIN_PAGED + // Just because paged exposures are enabled does not mean they are actually used yet + pagedExposuresEnabled() -> ExposureState.UNCERTAIN + else -> ExposureState.CERTAIN_LEGACY + } + } + + private enum class ExposureState { + CERTAIN_PAGED, UNCERTAIN, CERTAIN_LEGACY + } + + private fun ExposureState.shouldTrySyncingPagedExposures(): Boolean { + return when (this) { + ExposureState.CERTAIN_PAGED, ExposureState.UNCERTAIN -> true + ExposureState.CERTAIN_LEGACY -> false + } + } + + private fun ExposureState.shouldTrySyncingLegacyExposures(): Boolean { + return when (this) { + ExposureState.CERTAIN_LEGACY, ExposureState.UNCERTAIN -> true + ExposureState.CERTAIN_PAGED -> false + } + } + + private suspend fun tryFetchingPagedExposures( + era: BigInteger, + runtimeSnapshot: RuntimeSnapshot, + socketService: SocketService, + chainId: String + ): Boolean { + val overviewPrefix = runtimeSnapshot.eraStakersOverviewPrefixFor(era) + val numberOfKeysSynced = bulkRetriever.fetchPrefixValuesToCache(socketService, overviewPrefix, storageCache, chainId) + + val pagedExposuresPresent = numberOfKeysSynced > 0 + + if (pagedExposuresPresent) { + val pagedExposuresPrefix = runtimeSnapshot.eraStakersPagedPrefixFor(era) + bulkRetriever.fetchPrefixValuesToCache(socketService, pagedExposuresPrefix, storageCache, chainId) + } + + return pagedExposuresPresent + } + + private suspend fun fetchLegacyExposures( + era: BigInteger, + runtimeSnapshot: RuntimeSnapshot, + socketService: SocketService, + chainId: String + ): Boolean { + val prefix = runtimeSnapshot.eraStakersPrefixFor(era) + val keysFetched = bulkRetriever.fetchPrefixValuesToCache(socketService, prefix, storageCache, chainId) + return keysFetched > 0 + } + + private suspend fun saveIsExposuresUsedFlag(state: ExposureState, chainId: String) { + val isUsed = when (state) { + ExposureState.CERTAIN_PAGED -> true + ExposureState.CERTAIN_LEGACY -> false + ExposureState.UNCERTAIN -> return + } + + val encodedValue = isPagedExposuresValue(isUsed) + val entry = StorageEntry(STORAGE_KEY_PAGED_EXPOSURES, encodedValue) + + storageCache.insert(entry, chainId) + } + + private fun RuntimeSnapshot.pagedExposuresEnabled(): Boolean { + return metadata.staking().hasStorage("ErasStakersPaged") + } + + private fun RuntimeSnapshot.legacyExposuresFullyRemoved(): Boolean { + return !metadata.staking().hasStorage("ErasStakers") + } + + private fun RuntimeSnapshot.eraStakersPrefixFor(era: BigInteger): String { + return metadata.staking().storage("ErasStakers").storageKey(this, era) + } + + private fun RuntimeSnapshot.eraStakersOverviewPrefixFor(era: BigInteger): String { + return metadata.staking().storage("ErasStakersOverview").storageKey(this, era) + } + + private fun RuntimeSnapshot.eraStakersOverviewPrefix(): String { + return metadata.staking().storage("ErasStakersOverview").storageKey(this) + } + + private fun RuntimeSnapshot.eraStakersPagedPrefixFor(era: BigInteger): String { + return metadata.staking().storage("ErasStakersPaged").storageKey(this, era) + } + + private fun RuntimeSnapshot.eraStakersPagedPrefix(): String { + return metadata.staking().storage("ErasStakersPaged").storageKey(this) + } + + private fun RuntimeSnapshot.eraStakersPrefix(): String { + return metadata.staking().storage("ErasStakers").storageKey(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/controller/AccountControllerBalanceUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/controller/AccountControllerBalanceUpdater.kt new file mode 100644 index 0000000..62f37d6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/controller/AccountControllerBalanceUpdater.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.controller + +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.AccountStakingScope +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.bindAccountInfoOrDefault +import io.novafoundation.nova.feature_wallet_api.data.cache.updateAsset +import io.novafoundation.nova.runtime.ext.disabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.state.chainAndAsset +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach + +class AccountControllerBalanceUpdater( + override val scope: AccountStakingScope, + private val sharedState: StakingSharedState, + private val chainRegistry: ChainRegistry, + private val assetCache: AssetCache, +) : SharedStateBasedUpdater { + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: AccountStakingLocal + ): Flow { + val (chain, chainAsset) = sharedState.chainAndAsset() + if (chainAsset.disabled) return emptyFlow() + + val runtime = chainRegistry.getRuntime(chain.id) + + val accountStaking = scopeValue + val stakingAccessInfo = accountStaking.stakingAccessInfo ?: return emptyFlow() + + val controllerId = stakingAccessInfo.controllerId + val stashId = stakingAccessInfo.stashId + val accountId = accountStaking.accountId + + if (controllerId.contentEquals(stashId)) { + // balance is already observed, no need to do it twice + return emptyFlow() + } + + val companionAccountId = when { + accountId.contentEquals(controllerId) -> stashId + accountId.contentEquals(stashId) -> controllerId + else -> throw IllegalArgumentException() + } + + val key = runtime.metadata.system().storage("Account").storageKey(runtime, companionAccountId) + + return storageSubscriptionBuilder.subscribe(key) + .onEach { change -> + val newAccountInfo = bindAccountInfoOrDefault(change.value, runtime) + + assetCache.updateAsset(companionAccountId, chainAsset, newAccountInfo) + } + .flowOn(Dispatchers.IO) + .noSideAffects() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/historical/HistoricalTotalValidatorRewardUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/historical/HistoricalTotalValidatorRewardUpdater.kt new file mode 100644 index 0000000..9757154 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/historical/HistoricalTotalValidatorRewardUpdater.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.historical + +import io.novafoundation.nova.common.utils.staking +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class HistoricalTotalValidatorRewardUpdater : HistoricalUpdater { + + override fun constructKeyPrefix(runtime: RuntimeSnapshot): String { + return runtime.metadata.staking().storage("ErasValidatorReward").storageKey(runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/historical/HistoricalUpdateMediator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/historical/HistoricalUpdateMediator.kt new file mode 100644 index 0000000..c33c793 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/historical/HistoricalUpdateMediator.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.historical + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.ActiveEraScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.flow.Flow + +interface HistoricalUpdater { + + fun constructKeyPrefix(runtime: RuntimeSnapshot): String +} + +class HistoricalUpdateMediator( + override val scope: ActiveEraScope, + private val historicalUpdaters: List, + private val stakingSharedState: StakingSharedState, + private val storageCache: StorageCache, + private val chainRegistry: ChainRegistry, + private val preferences: Preferences, +) : Updater, SharedStateBasedUpdater { + + override suspend fun listenForUpdates(storageSubscriptionBuilder: SharedRequestsBuilder, scopeValue: EraIndex): Flow { + val chainId = stakingSharedState.chainId() + val runtime = chainRegistry.getRuntime(chainId) + + return flowOf { + if (isHistoricalDataCleared(chainId)) return@flowOf null + + val prefixes = historicalUpdaters.map { it.constructKeyPrefix(runtime) } + prefixes.onEach { storageCache.removeByPrefix(prefixKey = it, chainId) } + + setHistoricalDataCleared(chainId) + } + .noSideAffects() + } + + private fun isHistoricalDataCleared(chainId: ChainId): Boolean { + return preferences.contains(isHistoricalDataClearedKey(chainId)) + } + + private fun setHistoricalDataCleared(chainId: ChainId) { + preferences.putBoolean(isHistoricalDataClearedKey(chainId), true) + } + + private fun isHistoricalDataClearedKey(chainId: ChainId): String { + return "HistoricalUpdateMediator.HistoricalDataCleared::$chainId" + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/historical/HistoricalValidatorRewardPointsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/historical/HistoricalValidatorRewardPointsUpdater.kt new file mode 100644 index 0000000..fd60b4b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/historical/HistoricalValidatorRewardPointsUpdater.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.historical + +import io.novafoundation.nova.common.utils.staking +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class HistoricalValidatorRewardPointsUpdater : HistoricalUpdater { + + override fun constructKeyPrefix(runtime: RuntimeSnapshot): String { + return runtime.metadata.staking().storage("ErasRewardPoints").storageKey(runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/scope/AccountStakingScope.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/scope/AccountStakingScope.kt new file mode 100644 index 0000000..5bbde17 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/scope/AccountStakingScope.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope + +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.core.updater.UpdateScope +import io.novafoundation.nova.core_db.dao.AccountStakingDao +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.state.assetWithChain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest + +class AccountStakingScope( + private val accountRepository: AccountRepository, + private val accountStakingDao: AccountStakingDao, + private val sharedStakingState: StakingSharedState +) : UpdateScope { + + override fun invalidationFlow(): Flow { + return combineToPair( + sharedStakingState.assetWithChain, + accountRepository.selectedMetaAccountFlow() + ).flatMapLatest { (chainWithAsset, account) -> + val (chain, chainAsset) = chainWithAsset + val accountId = account.accountIdIn(chain) ?: return@flatMapLatest emptyFlow() + + accountStakingDao.observeDistinct(chain.id, chainAsset.id, accountId) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/scope/ActiveEraScope.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/scope/ActiveEraScope.kt new file mode 100644 index 0000000..48ad4d0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/scope/ActiveEraScope.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope + +import io.novafoundation.nova.common.utils.withFlowScope +import io.novafoundation.nova.core.updater.UpdateScope +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import kotlinx.coroutines.flow.Flow + +class ActiveEraScope( + private val stakingSharedComputation: StakingSharedComputation, + private val stakingSharedState: StakingSharedState, +) : UpdateScope { + + override fun invalidationFlow(): Flow { + return withFlowScope { flowScope -> + val chainId = stakingSharedState.chainId() + + stakingSharedComputation.activeEraFlow(chainId, flowScope) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/BondedErasUpdaterUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/BondedErasUpdaterUpdater.kt new file mode 100644 index 0000000..78cda88 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/BondedErasUpdaterUpdater.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session + +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.provideContext +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.bondedErasOrNull +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.staking +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class BondedErasUpdaterUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), + SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String? { + return runtime.provideContext { metadata.staking.bondedErasOrNull?.storageKey() } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/CurrentEpochIndexUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/CurrentEpochIndexUpdater.kt new file mode 100644 index 0000000..a736c2b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/CurrentEpochIndexUpdater.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session + +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSession +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSessionRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder + +class CurrentEpochIndexUpdater( + electionsSessionRegistry: ElectionsSessionRegistry, + stakingSharedState: StakingSharedState, + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : ElectionsSessionParameterUpdater( + electionsSessionRegistry = electionsSessionRegistry, + stakingSharedState = stakingSharedState, + timelineDelegatingChainIdHolder = timelineDelegatingChainIdHolder, + chainRegistry = chainRegistry, + storageCache = storageCache +) { + + override suspend fun ElectionsSession.updaterStorageKey(chainId: ChainId): String? { + return currentEpochIndexStorageKey(chainId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/CurrentSessionIndexUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/CurrentSessionIndexUpdater.kt new file mode 100644 index 0000000..8cd7c9c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/CurrentSessionIndexUpdater.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session + +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.provideContext +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.currentIndex +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.session +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.TimelineDelegatingSingleKeyUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class CurrentSessionIndexUpdater( + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : TimelineDelegatingSingleKeyUpdater(GlobalScope, chainRegistry, storageCache, timelineDelegatingChainIdHolder), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.provideContext { + metadata.session.currentIndex.storageKey() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/CurrentSlotUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/CurrentSlotUpdater.kt new file mode 100644 index 0000000..d792372 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/CurrentSlotUpdater.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session + +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSession +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSessionRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder + +class CurrentSlotUpdater( + electionsSessionRegistry: ElectionsSessionRegistry, + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : ElectionsSessionParameterUpdater( + electionsSessionRegistry = electionsSessionRegistry, + stakingSharedState = stakingSharedState, + timelineDelegatingChainIdHolder = timelineDelegatingChainIdHolder, + chainRegistry = chainRegistry, + storageCache = storageCache +) { + + override suspend fun ElectionsSession.updaterStorageKey(chainId: ChainId): String? { + return currentSlotStorageKey(chainId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/ElectionsSessionParameterUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/ElectionsSessionParameterUpdater.kt new file mode 100644 index 0000000..6aa2a85 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/ElectionsSessionParameterUpdater.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session + +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.TimelineDelegatingSingleKeyUpdater +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSession +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSessionRegistry +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +abstract class ElectionsSessionParameterUpdater( + private val electionsSessionRegistry: ElectionsSessionRegistry, + private val stakingSharedState: StakingSharedState, + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : TimelineDelegatingSingleKeyUpdater(GlobalScope, chainRegistry, storageCache, timelineDelegatingChainIdHolder) { + + protected abstract suspend fun ElectionsSession.updaterStorageKey(chainId: ChainId): String? + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String? { + // We lookup election session from the staking chain asset itself + val stakingOption = stakingSharedState.selectedOption() + val electionsSession = electionsSessionRegistry.electionsSessionFor(stakingOption) + + // But delegate sync to timeline chain + val timelineChainId = stakingOption.chain.timelineChainIdOrSelf() + return electionsSession.updaterStorageKey(timelineChainId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/EraStartSessionIndexUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/EraStartSessionIndexUpdater.kt new file mode 100644 index 0000000..7cae75b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/EraStartSessionIndexUpdater.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session + +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.provideContext +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.erasStartSessionIndexOrNull +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.staking +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.ActiveEraScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class EraStartSessionIndexUpdater( + activeEraScope: ActiveEraScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(activeEraScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: EraIndex): String? { + return runtime.provideContext { metadata.staking.erasStartSessionIndexOrNull?.storageKey(scopeValue) } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/GenesisSlotUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/GenesisSlotUpdater.kt new file mode 100644 index 0000000..1a3502d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/GenesisSlotUpdater.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session + +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSession +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSessionRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder + +class GenesisSlotUpdater( + electionsSessionRegistry: ElectionsSessionRegistry, + stakingSharedState: StakingSharedState, + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : ElectionsSessionParameterUpdater( + electionsSessionRegistry = electionsSessionRegistry, + stakingSharedState = stakingSharedState, + timelineDelegatingChainIdHolder = timelineDelegatingChainIdHolder, + chainRegistry = chainRegistry, + storageCache = storageCache +) { + + override suspend fun ElectionsSession.updaterStorageKey(chainId: ChainId): String? { + return genesisSlotStorageKey(chainId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/InvulnerablesUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/InvulnerablesUpdater.kt new file mode 100644 index 0000000..b6cf413 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/InvulnerablesUpdater.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.api.invulnerables +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class InvulnerablesUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return with(RuntimeContext(runtime)) { + runtime.metadata.collatorStaking.invulnerables.storageKey() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/SessionValidatorsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/SessionValidatorsUpdater.kt new file mode 100644 index 0000000..c2674c9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/blockhain/updaters/session/SessionValidatorsUpdater.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.session +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.validators +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +class SessionValidatorsUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return with(RuntimeContext(runtime)) { + runtime.metadata.session.validators.storageKey() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/StakingApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/StakingApi.kt new file mode 100644 index 0000000..7df2dca --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/StakingApi.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.subquery + +import io.novafoundation.nova.common.data.network.subquery.EraValidatorInfoQueryResponse +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.request.DirectStakingPeriodRewardsRequest +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.request.PoolStakingPeriodRewardsRequest +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.request.StakingNominatorEraInfosRequest +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.request.StakingValidatorEraInfosRequest +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.response.StakingPeriodRewardsResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface StakingApi { + + @POST + suspend fun getRewardsByPeriod( + @Url url: String, + @Body body: DirectStakingPeriodRewardsRequest + ): SubQueryResponse + + @POST + suspend fun getPoolRewardsByPeriod( + @Url url: String, + @Body body: PoolStakingPeriodRewardsRequest + ): SubQueryResponse + + @POST + suspend fun getNominatorEraInfos( + @Url url: String, + @Body body: StakingNominatorEraInfosRequest + ): SubQueryResponse + + @POST + suspend fun getValidatorEraInfos( + @Url url: String, + @Body body: StakingValidatorEraInfosRequest + ): SubQueryResponse +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/SubQueryValidatorSetFetcher.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/SubQueryValidatorSetFetcher.kt new file mode 100644 index 0000000..b5126c2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/SubQueryValidatorSetFetcher.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.subquery + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.subquery.EraValidatorInfoQueryResponse +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_impl.data.model.stakingExternalApi +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.request.StakingNominatorEraInfosRequest +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.request.StakingValidatorEraInfosRequest +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import java.math.BigInteger + +data class PayoutTarget(val validatorStash: AccountIdKey, val era: BigInteger) + +class SubQueryValidatorSetFetcher( + private val stakingApi: StakingApi, +) { + + suspend fun findNominatorPayoutTargets( + chain: Chain, + stashAccountAddress: String, + eraRange: List, + ): List { + return findPayoutTargets(chain) { apiUrl -> + stakingApi.getNominatorEraInfos( + apiUrl, + StakingNominatorEraInfosRequest( + eraFrom = eraRange.first(), + eraTo = eraRange.last(), + nominatorStashAddress = stashAccountAddress + ) + ) + } + } + + suspend fun findValidatorPayoutTargets( + chain: Chain, + stashAccountAddress: String, + eraRange: List, + ): List { + return findPayoutTargets(chain) { apiUrl -> + stakingApi.getValidatorEraInfos( + apiUrl, + StakingValidatorEraInfosRequest( + eraFrom = eraRange.first(), + eraTo = eraRange.last(), + validatorStashAddress = stashAccountAddress + ) + ) + } + } + + private suspend fun findPayoutTargets( + chain: Chain, + apiCall: suspend (url: String) -> SubQueryResponse + ): List { + val stakingExternalApi = chain.stakingExternalApi() ?: return emptyList() + + val validatorsInfos = apiCall(stakingExternalApi.url) + + val nodes = validatorsInfos.data.eraValidatorInfos?.nodes.orEmpty() + + return nodes.map { + PayoutTarget(it.address.toAccountId().intoKey(), it.era) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/BaseStakingPeriodRewardsRequest.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/BaseStakingPeriodRewardsRequest.kt new file mode 100644 index 0000000..932a095 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/BaseStakingPeriodRewardsRequest.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.subquery.request + +abstract class BaseStakingPeriodRewardsRequest(@Transient private val startTimestamp: Long?, @Transient private val endTimestamp: Long?) { + + protected fun getTimestampFilter(): String { + val start = startTimestamp?.let { "timestamp: { greaterThanOrEqualTo: \"$it\" }" } + val end = endTimestamp?.let { "timestamp: { lessThanOrEqualTo: \"$it\" }" } + return if (startTimestamp != null && endTimestamp != null) { + "$start and: { $end }" + } else { + start ?: end ?: "" + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/DirectStakingPeriodRewardsRequest.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/DirectStakingPeriodRewardsRequest.kt new file mode 100644 index 0000000..bbca9be --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/DirectStakingPeriodRewardsRequest.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.subquery.request + +class DirectStakingPeriodRewardsRequest(accountAddress: String, startTimestamp: Long?, endTimestamp: Long?) : + BaseStakingPeriodRewardsRequest(startTimestamp, endTimestamp) { + val query = """ + query { + rewards: accountRewards( + filter: { + address: { equalTo : "$accountAddress" } + type: { equalTo: reward } + ${getTimestampFilter()} + } + ) { + groupedAggregates(groupBy: ADDRESS) { + sum { + amount + } + } + } + + slashes: accountRewards( + filter: { + address: { equalTo : "$accountAddress" } + type: { equalTo: slash } + ${getTimestampFilter()} + } + ) { + groupedAggregates(groupBy: ADDRESS) { + sum { + amount + } + } + } + } + """.trimIndent() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/PoolStakingPeriodRewardsRequest.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/PoolStakingPeriodRewardsRequest.kt new file mode 100644 index 0000000..69e9122 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/PoolStakingPeriodRewardsRequest.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.subquery.request + +class PoolStakingPeriodRewardsRequest(accountAddress: String, startTimestamp: Long?, endTimestamp: Long?) : + BaseStakingPeriodRewardsRequest(startTimestamp, endTimestamp) { + val query = """ + query { + rewards: accountPoolRewards( + filter: { + address: { equalTo : "$accountAddress" } + type: { equalTo: reward } + ${getTimestampFilter()} + } + ) { + groupedAggregates(groupBy: ADDRESS) { + sum { + amount + } + } + } + + slashes: accountPoolRewards( + filter: { + address: { equalTo : "$accountAddress" } + type: { equalTo: slash } + ${getTimestampFilter()} + } + ) { + groupedAggregates(groupBy: ADDRESS) { + sum { + amount + } + } + } + } + """.trimIndent() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/StakingNominatorEraInfosRequest.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/StakingNominatorEraInfosRequest.kt new file mode 100644 index 0000000..d3b11f6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/StakingNominatorEraInfosRequest.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.subquery.request + +import java.math.BigInteger + +class StakingNominatorEraInfosRequest(eraFrom: BigInteger, eraTo: BigInteger, nominatorStashAddress: String) { + val query = """ + query { + eraValidatorInfos( + filter:{ + era:{ greaterThanOrEqualTo: $eraFrom, lessThanOrEqualTo: $eraTo}, + others:{ contains:[{who: "$nominatorStashAddress"}]} + } + ) { + nodes { + id + address + era + total + own + } + } + } + """.trimIndent() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/StakingValidatorEraInfosRequest.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/StakingValidatorEraInfosRequest.kt new file mode 100644 index 0000000..eb63c57 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/request/StakingValidatorEraInfosRequest.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.subquery.request + +import io.novafoundation.nova.common.data.network.subquery.SubQueryFilters +import java.math.BigInteger + +class StakingValidatorEraInfosRequest(eraFrom: BigInteger, eraTo: BigInteger, validatorStashAddress: String) : SubQueryFilters { + val query = """ + query { + eraValidatorInfos( + filter:{ + era:{ greaterThanOrEqualTo: $eraFrom, lessThanOrEqualTo: $eraTo}, + ${"address" equalTo validatorStashAddress} + } + ) { + nodes { + id + address + era + total + own + } + } + } + """.trimIndent() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/response/StakingPeriodRewardsResponse.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/response/StakingPeriodRewardsResponse.kt new file mode 100644 index 0000000..a9e34a5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/response/StakingPeriodRewardsResponse.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.subquery.response + +import io.novafoundation.nova.common.data.network.subquery.GroupedAggregate +import io.novafoundation.nova.common.data.network.subquery.SubQueryGroupedAggregates +import io.novafoundation.nova.common.data.network.subquery.firstSum +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.orZero +import java.math.BigDecimal +import java.math.BigInteger + +class StakingPeriodRewardsResponse( + val rewards: SubQueryGroupedAggregates>, + val slashes: SubQueryGroupedAggregates> +) { + + class RewardNode(val amount: BigDecimal) +} + +val StakingPeriodRewardsResponse.RewardNode.planksAmount: BigInteger + get() = amount.toBigInteger() + +val StakingPeriodRewardsResponse.totalReward: BigInteger + get() { + val rewards = rewards.firstSum()?.planksAmount.orZero() + val slashes = slashes.firstSum()?.planksAmount.orZero() + + return (rewards - slashes).atLeastZero() + } diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/response/StakingTotalRewardResponse.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/response/StakingTotalRewardResponse.kt new file mode 100644 index 0000000..294955c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/network/subquery/response/StakingTotalRewardResponse.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.data.network.subquery.response + +import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes +import java.math.BigInteger + +class StakingTotalRewardResponse(val accumulatedRewards: SubQueryNodes) { + + class TotalRewardNode(val amount: BigInteger) +} + +val StakingTotalRewardResponse.totalReward: BigInteger + get() = accumulatedRewards.nodes.firstOrNull()?.amount ?: BigInteger.ZERO diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/datasource/KnownMaxUnlockingOverwrites.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/datasource/KnownMaxUnlockingOverwrites.kt new file mode 100644 index 0000000..cc7d893 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/datasource/KnownMaxUnlockingOverwrites.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.datasource + +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger + +interface KnownMaxUnlockingOverwrites { + + suspend fun getUnlockChunksFor(chainId: ChainId): BigInteger? +} + +class RealKnownMaxUnlockingOverwrites : KnownMaxUnlockingOverwrites { + + private val knownMaxUnlockChunksByChainId = mapOf( + Chain.Geneses.ALEPH_ZERO to 8.toBigInteger() + ) + + override suspend fun getUnlockChunksFor(chainId: ChainId): BigInteger? { + return knownMaxUnlockChunksByChainId[chainId] + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/api/DelegatedStakingApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/api/DelegatedStakingApi.kt new file mode 100644 index 0000000..0dc62d1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/api/DelegatedStakingApi.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api + +import io.novafoundation.nova.common.utils.delegatedStaking +import io.novafoundation.nova.common.utils.delegatedStakingOrNull +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.DelegatedStakingDelegation +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.bindDelegatedStakingDelegation +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class DelegatedStakingApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.delegatedStaking: DelegatedStakingApi + get() = DelegatedStakingApi(delegatedStaking()) + +context(StorageQueryContext) +val RuntimeMetadata.delegatedStakingOrNull: DelegatedStakingApi? + get() = delegatedStakingOrNull()?.let(::DelegatedStakingApi) + +context(StorageQueryContext) +val DelegatedStakingApi.delegators: QueryableStorageEntry1 + get() = storage1("Delegators", binding = { decoded, _ -> bindDelegatedStakingDelegation(decoded) }) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/api/NominationPoolsRuntimeApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/api/NominationPoolsRuntimeApi.kt new file mode 100644 index 0000000..7df5115 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/api/NominationPoolsRuntimeApi.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.nominationPools +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolIdRaw +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.BondedPool +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMetadata +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.UnbondingPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.bindBondedPool +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.bindPoolMember +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.bindPoolMetadata +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.bindUnbondingPools +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry0 +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage0 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import java.math.BigInteger + +@JvmInline +value class NominationPoolsApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.nominationPools: NominationPoolsApi + get() = NominationPoolsApi(nominationPools()) + +context(StorageQueryContext) +val NominationPoolsApi.bondedPools: QueryableStorageEntry1 + get() = storage1("BondedPools", binding = { decoded, poolIdRaw -> bindBondedPool(decoded, PoolId(poolIdRaw)) }) + +context(StorageQueryContext) +val NominationPoolsApi.poolMembers: QueryableStorageEntry1 + get() = storage1("PoolMembers", binding = { decoded, accountId -> bindPoolMember(decoded, accountId) }) + +context(StorageQueryContext) +val NominationPoolsApi.maxPoolMembers: QueryableStorageEntry0 + get() = storage0("MaxPoolMembers", binding = ::bindNumber) + +context(StorageQueryContext) +val NominationPoolsApi.maxPoolMembersPerPool: QueryableStorageEntry0 + get() = storage0("MaxPoolMembersPerPool", binding = ::bindNumber) + +context(StorageQueryContext) +val NominationPoolsApi.counterForPoolMembers: QueryableStorageEntry0 + get() = storage0("CounterForPoolMembers", binding = ::bindNumber) + +context(StorageQueryContext) +val NominationPoolsApi.lastPoolId: QueryableStorageEntry0 + get() = storage0("LastPoolId", binding = ::bindNumber) + +context(StorageQueryContext) +val NominationPoolsApi.minJoinBond: QueryableStorageEntry0 + get() = storage0("MinJoinBond", binding = ::bindNumber) + +context(StorageQueryContext) +val NominationPoolsApi.subPoolsStorage: QueryableStorageEntry1 + get() = storage1("SubPoolsStorage", binding = { decoded, _ -> bindUnbondingPools(decoded) }) + +context(StorageQueryContext) +val NominationPoolsApi.metadata: QueryableStorageEntry1 + get() = storage1("Metadata", binding = { decoded, _ -> bindPoolMetadata(decoded) }) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/calls/NominationPoolBondExtraSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/calls/NominationPoolBondExtraSource.kt new file mode 100644 index 0000000..62ef7aa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/calls/NominationPoolBondExtraSource.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +sealed class NominationPoolBondExtraSource { + + class FreeBalance(val amount: Balance) : NominationPoolBondExtraSource() + + object Rewards : NominationPoolBondExtraSource() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/calls/NominationPoolsCalls.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/calls/NominationPoolsCalls.kt new file mode 100644 index 0000000..e6e4bc1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/calls/NominationPoolsCalls.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolPoints +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import java.math.BigInteger + +@JvmInline +value class NominationPoolsCalls(val extrinsicBuilder: ExtrinsicBuilder) + +val ExtrinsicBuilder.nominationPools: NominationPoolsCalls + get() = NominationPoolsCalls(this) + +fun NominationPoolsCalls.join(amount: Balance, poolId: PoolId) { + extrinsicBuilder.call( + moduleName = Modules.NOMINATION_POOLS, + callName = "join", + arguments = mapOf( + "amount" to amount, + "pool_id" to poolId.value + ) + ) +} + +fun NominationPoolsCalls.bondExtra(source: NominationPoolBondExtraSource) { + extrinsicBuilder.call( + moduleName = Modules.NOMINATION_POOLS, + callName = "bond_extra", + arguments = mapOf( + "extra" to source.prepareForEncoding() + ) + ) +} + +fun NominationPoolsCalls.bondExtra(amount: Balance) { + bondExtra(NominationPoolBondExtraSource.FreeBalance(amount)) +} + +fun NominationPoolsCalls.unbond(unbondAccount: AccountId, unbondPoints: PoolPoints) { + extrinsicBuilder.call( + moduleName = Modules.NOMINATION_POOLS, + callName = "unbond", + arguments = mapOf( + "member_account" to PezkuwiAddressConstructor.constructInstance(extrinsicBuilder.runtime.typeRegistry, unbondAccount), + "unbonding_points" to unbondPoints.value + ) + ) +} + +fun NominationPoolsCalls.withdrawUnbonded(memberAccount: AccountId, numberOfSlashingSpans: BigInteger) { + extrinsicBuilder.call( + moduleName = Modules.NOMINATION_POOLS, + callName = "withdraw_unbonded", + arguments = mapOf( + "member_account" to PezkuwiAddressConstructor.constructInstance(extrinsicBuilder.runtime.typeRegistry, memberAccount), + "num_slashing_spans" to numberOfSlashingSpans + ) + ) +} + +fun NominationPoolsCalls.claimPayout() { + extrinsicBuilder.call( + moduleName = Modules.NOMINATION_POOLS, + callName = "claim_payout", + arguments = emptyMap() + ) +} + +fun NominationPoolsCalls.migrateDelegation(memberAccount: AccountId) { + extrinsicBuilder.call( + moduleName = Modules.NOMINATION_POOLS, + callName = "migrate_delegation", + arguments = mapOf( + "member_account" to PezkuwiAddressConstructor.constructInstance(extrinsicBuilder.runtime.typeRegistry, memberAccount), + ) + ) +} + +private fun NominationPoolBondExtraSource.prepareForEncoding(): DictEnum.Entry<*> { + return when (this) { + is NominationPoolBondExtraSource.FreeBalance -> DictEnum.Entry("FreeBalance", amount) + NominationPoolBondExtraSource.Rewards -> DictEnum.Entry("Rewards", null) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/BondedPool.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/BondedPool.kt new file mode 100644 index 0000000..bd3aa83 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/BondedPool.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models + +import io.novafoundation.nova.common.data.network.runtime.binding.bindCollectionEnum +import io.novafoundation.nova.common.data.network.runtime.binding.bindInt +import io.novafoundation.nova.common.data.network.runtime.binding.bindPerbillTyped +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.Perbill +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId + +class BondedPool( + val poolId: PoolId, + val points: PoolPoints, + val memberCounter: Int, + val commission: PoolCommission?, + val state: PoolState +) + +class PoolCommission(val current: Current?) { + + class Current(val perbill: Perbill) +} + +enum class PoolState { + + Open, Blocked, Destroying +} + +val PoolState.isOpen: Boolean + get() = this == PoolState.Open + +fun bindBondedPool(decoded: Any, poolId: PoolId): BondedPool { + val asStruct = decoded.castToStruct() + + return BondedPool( + points = bindPoolPoints(asStruct["points"]), + poolId = poolId, + memberCounter = bindInt(asStruct["memberCounter"]), + commission = asStruct.get("commission")?.let(::bindPoolCommission), + state = bindCollectionEnum(asStruct["state"]) + ) +} + +fun bindPoolCommission(decoded: Any): PoolCommission { + val asStruct = decoded.castToStruct() + + return PoolCommission( + current = bindCurrentCommission(asStruct["current"]) + ) +} + +private fun bindCurrentCommission(decoded: Any?): PoolCommission.Current? { + return decoded?.let { + val (perbill, _) = decoded.castToList() + + PoolCommission.Current(bindPerbillTyped(perbill)) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/DelegatedStakingDelegation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/DelegatedStakingDelegation.kt new file mode 100644 index 0000000..98d1c85 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/DelegatedStakingDelegation.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class DelegatedStakingDelegation( + val amount: Balance +) + +fun bindDelegatedStakingDelegation(decoded: Any): DelegatedStakingDelegation { + val asStruct = decoded.castToStruct() + + return DelegatedStakingDelegation( + amount = bindNumber(asStruct["amount"]) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/Points.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/Points.kt new file mode 100644 index 0000000..9e9cca1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/Points.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import java.math.BigInteger + +@JvmInline +value class PoolPoints(val value: BigInteger) : Comparable { + + operator fun plus(other: PoolPoints): PoolPoints = PoolPoints(value + other.value) + + override fun compareTo(other: PoolPoints): Int { + return value.compareTo(other.value) + } +} + +@Suppress("NOTHING_TO_INLINE") +inline fun bindPoolPoints(decoded: Any?) = PoolPoints(bindNumber(decoded)) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/PoolId.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/PoolId.kt new file mode 100644 index 0000000..1160e25 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/PoolId.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId + +fun bindPoolId(decoded: Any?): PoolId { + return PoolId(bindNumber(decoded)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/PoolMember.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/PoolMember.kt new file mode 100644 index 0000000..47afeb5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/PoolMember.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models + +import io.novafoundation.nova.common.data.network.runtime.binding.bindMap +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.EraRedeemable +import io.novafoundation.nova.feature_staking_api.domain.model.isUnbondingIn +import io.novafoundation.nova.feature_staking_api.domain.model.of +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindEraIndex +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +class PoolMember( + val accountId: AccountId, + val poolId: PoolId, + val points: PoolPoints, + val lastRecordedRewardCounter: BigInteger, + val unbondingEras: Map +) + +fun PoolMember.totalPointsAfterRedeemAt(activeEra: EraIndex): PoolPoints { + return points + remainsUnbondingAt(activeEra) +} + +private fun PoolMember.remainsUnbondingAt(activeEra: EraIndex): PoolPoints { + val totalRedeemablePoints = unbondingEras.entries.sumByBigInteger { (era, eraPoints) -> + if (EraRedeemable.of(era).isUnbondingIn(activeEra)) { + eraPoints.value + } else { + BigInteger.ZERO + } + } + + return PoolPoints(totalRedeemablePoints) +} + +fun bindPoolMember(decoded: Any, accountId: AccountId): PoolMember { + val asStruct = decoded.castToStruct() + + return PoolMember( + accountId = accountId, + poolId = bindPoolId(asStruct["poolId"]), + points = bindPoolPoints(asStruct["points"]), + lastRecordedRewardCounter = bindNumber(asStruct["lastRecordedRewardCounter"]), + unbondingEras = bindMap( + dynamicInstance = asStruct["unbondingEras"], + keyBinder = ::bindEraIndex, + valueBinder = ::bindPoolPoints + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/PoolMetadata.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/PoolMetadata.kt new file mode 100644 index 0000000..47f4fc1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/PoolMetadata.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models + +import io.novafoundation.nova.common.data.network.runtime.binding.bindString + +@JvmInline +value class PoolMetadata(val title: String) + +fun bindPoolMetadata(decoded: Any): PoolMetadata { + return PoolMetadata(bindString(decoded)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/UnbondingPools.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/UnbondingPools.kt new file mode 100644 index 0000000..1b8ce92 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/models/UnbondingPools.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models + +import io.novafoundation.nova.common.data.network.runtime.binding.bindMap +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.UnlockChunk +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindEraIndex +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.PoolBalanceConvertable +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.amountOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class UnbondingPools( + val noEra: UnbondingPool, + val withEra: Map +) + +class UnbondingPool( + override val poolPoints: PoolPoints, + override val poolBalance: Balance +) : PoolBalanceConvertable + +fun UnbondingPools.getPool(era: EraIndex): UnbondingPool { + return withEra[era] ?: noEra +} + +fun UnbondingPools?.unlockChunksFor(poolMember: PoolMember): List { + if (this == null) return emptyList() + + return poolMember.unbondingEras.map { (unbondEra, unbondPoints) -> + val unbondingPool = getPool(unbondEra) + val unbondBalance = unbondingPool.amountOf(unbondPoints) + + UnlockChunk(amount = unbondBalance, era = unbondEra) + } +} + +fun UnbondingPools?.totalUnbondingFor(poolMember: PoolMember): Balance { + if (this == null) return Balance.ZERO + + return poolMember.unbondingEras.entries.sumByBigInteger { (unbondEra, unbondPoints) -> + val unbondingPool = getPool(unbondEra) + unbondingPool.amountOf(unbondPoints) + } +} + +fun bindUnbondingPools(decoded: Any): UnbondingPools { + val asStruct = decoded.castToStruct() + + return UnbondingPools( + noEra = bindUnbondingPool(asStruct["noEra"]), + withEra = bindMap( + dynamicInstance = asStruct["withEra"], + keyBinder = ::bindEraIndex, + valueBinder = ::bindUnbondingPool + ) + ) +} + +fun bindUnbondingPool(decoded: Any?): UnbondingPool { + val asStruct = decoded.castToStruct() + + return UnbondingPool( + poolPoints = bindPoolPoints(asStruct["points"]), + poolBalance = bindNumber(asStruct["balance"]) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/CounterForPoolMembersUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/CounterForPoolMembersUpdater.kt new file mode 100644 index 0000000..274236f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/CounterForPoolMembersUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater + +import io.novafoundation.nova.common.utils.nominationPools +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class CounterForPoolMembersUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.nominationPools().storage("CounterForPoolMembers").storageKey(runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/DelegatedStakeUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/DelegatedStakeUpdater.kt new file mode 100644 index 0000000..c81b538 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/DelegatedStakeUpdater.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater + +import io.novafoundation.nova.common.utils.delegatedStakingOrNull +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class DelegatedStakeUpdater( + override val scope: AccountUpdateScope, + private val stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(scope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: MetaAccount): String? { + val chain = stakingSharedState.chain() + val accountId = scopeValue.accountIdIn(chain) ?: return null + + return runtime.metadata.delegatedStakingOrNull()?.storage("Delegators")?.storageKey(runtime, accountId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/LastPoolIdUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/LastPoolIdUpdater.kt new file mode 100644 index 0000000..b28bf2d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/LastPoolIdUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater + +import io.novafoundation.nova.common.utils.nominationPools +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class LastPoolIdUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.nominationPools().storage("LastPoolId").storageKey(runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/MaxPoolMembersPerPoolUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/MaxPoolMembersPerPoolUpdater.kt new file mode 100644 index 0000000..1dc2884 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/MaxPoolMembersPerPoolUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater + +import io.novafoundation.nova.common.utils.nominationPools +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class MaxPoolMembersPerPoolUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.nominationPools().storage("MaxPoolMembersPerPool").storageKey(runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/MaxPoolMembersUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/MaxPoolMembersUpdater.kt new file mode 100644 index 0000000..2f0dafc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/MaxPoolMembersUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater + +import io.novafoundation.nova.common.utils.nominationPools +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class MaxPoolMembersUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.nominationPools().storage("MaxPoolMembers").storageKey(runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/MinJoinBondUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/MinJoinBondUpdater.kt new file mode 100644 index 0000000..9585d8d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/MinJoinBondUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater + +import io.novafoundation.nova.common.utils.nominationPools +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class MinJoinBondUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.nominationPools().storage("MinJoinBond").storageKey(runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/PoolMetadataUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/PoolMetadataUpdater.kt new file mode 100644 index 0000000..8ec4046 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/PoolMetadataUpdater.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater + +import io.novafoundation.nova.common.utils.nominationPools +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.scope.PoolScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class PoolMetadataUpdater( + poolScope: PoolScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(poolScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: PoolId?): String? { + if (scopeValue == null) return null + + return runtime.metadata.nominationPools().storage("Metadata").storageKey(runtime, scopeValue.value) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/PooledBalanceUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/PooledBalanceUpdater.kt new file mode 100644 index 0000000..2c1a129 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/PooledBalanceUpdater.kt @@ -0,0 +1,180 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.dao.updateExternalBalance +import io.novafoundation.nova.core_db.model.ExternalBalanceLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters.PooledBalanceUpdaterFactory +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.bondedAccountOf +import io.novafoundation.nova.feature_staking_api.domain.model.activeBalance +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.ledger +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.staking +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.bondedPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.delegatedStakingOrNull +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.delegators +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.poolMembers +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.subPoolsStorage +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.BondedPool +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolPoints +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.UnbondingPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.totalUnbondingFor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.PoolBalanceConvertable +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.amountOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.common.utils.metadata +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class RealPooledBalanceUpdaterFactory( + private val remoteStorageSource: StorageDataSource, + private val poolAccountDerivation: PoolAccountDerivation, + private val externalBalanceDao: ExternalBalanceDao, + private val scope: AccountUpdateScope, +) : PooledBalanceUpdaterFactory { + + override fun create(chain: Chain): Updater { + return PooledBalanceUpdater( + scope = scope, + chain = chain, + remoteStorageSource = remoteStorageSource, + poolAccountDerivation = poolAccountDerivation, + externalBalanceDao = externalBalanceDao + ) + } +} + +class PooledBalanceUpdater( + override val scope: AccountUpdateScope, + private val chain: Chain, + private val remoteStorageSource: StorageDataSource, + private val poolAccountDerivation: PoolAccountDerivation, + private val externalBalanceDao: ExternalBalanceDao, +) : Updater { + + val chainAsset = chain.utilityAsset + + override val requiredModules: List = emptyList() + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: MetaAccount, + ): Flow { + return if (chainAsset.enabled && StakingType.NOMINATION_POOLS in chainAsset.staking) { + sync(storageSubscriptionBuilder, metaAccount = scopeValue) + } else { + emptyFlow() + } + } + + private suspend fun sync(storageSubscriptionBuilder: SharedRequestsBuilder, metaAccount: MetaAccount): Flow { + val accountId = metaAccount.accountIdIn(chain) ?: return emptyFlow() + + return remoteStorageSource.subscribe(chain.id, storageSubscriptionBuilder) { + val poolMemberFlow = metadata.nominationPools.poolMembers + .observe(accountId) + + val inAccountPoolStakeFlow = observeInAccountPoolStake(accountId) + + val poolWithBalance = poolMemberFlow + .map { it?.poolId } + .distinctUntilChanged() + .flatMapLatest(::subscribeToPoolWithBalance) + + combine(poolMemberFlow, poolWithBalance, inAccountPoolStakeFlow) { poolMember, totalPoolBalances, inAccountPoolStake -> + insertExternalBalance(poolMember, totalPoolBalances, metaAccount, inAccountPoolStake) + } + }.noSideAffects() + } + + private suspend fun subscribeToPoolWithBalance(poolId: PoolId?): Flow { + if (poolId == null) return flowOf(null) + + val bondedPoolAccountId = poolAccountDerivation.bondedAccountOf(poolId, chain.id) + + return remoteStorageSource.subscribeBatched(chain.id) { + val bondedPoolFlow = metadata.nominationPools.bondedPools.observeNonNull(poolId.value) + val unbondingPoolsFlow = metadata.nominationPools.subPoolsStorage.observe(poolId.value) + + val bondedPoolStakeFlow = metadata.staking.ledger + .observeNonNull(bondedPoolAccountId) + .map { it.activeBalance() } + + combine(bondedPoolFlow, unbondingPoolsFlow, bondedPoolStakeFlow, ::TotalPoolBalances) + } + } + + context(StorageQueryContext) + @Suppress("IfThenToElvis") + private fun observeInAccountPoolStake(accountId: AccountId): Flow { + val delegatedStakingPallet = metadata.delegatedStakingOrNull + + return if (delegatedStakingPallet != null) { + delegatedStakingPallet.delegators.observe(accountId).map { + it?.amount.orZero() + } + } else { + flowOf(Balance.ZERO) + } + } + + private suspend fun insertExternalBalance( + poolMember: PoolMember?, + totalPoolBalances: TotalPoolBalances?, + metaAccount: MetaAccount, + inAccountPoolStake: Balance, + ) { + val totalStake = if (poolMember != null && totalPoolBalances != null) { + // Only use stake part that is not in account as external balance entry + totalPoolBalances.totalStakeOf(poolMember) - inAccountPoolStake + } else { + Balance.ZERO + } + + val externalBalance = ExternalBalanceLocal( + metaId = metaAccount.id, + chainId = chain.id, + assetId = chainAsset.id, + type = ExternalBalanceLocal.Type.NOMINATION_POOL, + subtype = ExternalBalanceLocal.EMPTY_SUBTYPE, + amount = totalStake + ) + + externalBalanceDao.updateExternalBalance(externalBalance) + } + + private class TotalPoolBalances( + pool: BondedPool, + val unbondingPools: UnbondingPools?, + override val poolBalance: Balance, + ) : PoolBalanceConvertable { + + override val poolPoints: PoolPoints = pool.points + } + + private fun TotalPoolBalances.totalStakeOf(poolMember: PoolMember): Balance { + val activeStake = amountOf(poolMember.points) + val unbonding = unbondingPools.totalUnbondingFor(poolMember) + + return activeStake + unbonding + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/SubPoolsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/SubPoolsUpdater.kt new file mode 100644 index 0000000..1a9621b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/SubPoolsUpdater.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater + +import io.novafoundation.nova.common.utils.nominationPools +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.scope.PoolScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class SubPoolsUpdater( + poolScope: PoolScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(poolScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: PoolId?): String? { + if (scopeValue == null) return null + + return runtime.metadata.nominationPools().storage("SubPoolsStorage").storageKey(runtime, scopeValue.value) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/scope/PoolScope.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/scope/PoolScope.kt new file mode 100644 index 0000000..6ec8e2e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/network/blockhain/updater/scope/PoolScope.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.scope + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.core.updater.UpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlin.coroutines.coroutineContext + +class PoolScope( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val stakingSharedState: StakingSharedState, +) : UpdateScope { + + override fun invalidationFlow(): Flow { + return poolMemberFlow() + .map { it?.poolId } + .distinctUntilChanged() + } + + private fun poolMemberFlow() = flowOfAll { + val scope = CoroutineScope(coroutineContext) + val chain = stakingSharedState.chain() + + nominationPoolSharedComputation.currentPoolMemberFlow(chain, scope) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/pool/KnownNovaPools.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/pool/KnownNovaPools.kt new file mode 100644 index 0000000..2597089 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/pool/KnownNovaPools.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool + +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface KnownNovaPools { + + val novaPoolIds: Set> +} + +fun KnownNovaPools.isNovaPool(chainId: ChainId, poolId: PoolId) = chainId to poolId in novaPoolIds + +class FixedKnownNovaPools : KnownNovaPools { + + override val novaPoolIds: Set> = setOf( + key(Chain.Geneses.POLKADOT_ASSET_HUB, 54), + key(Chain.Geneses.KUSAMA_ASSET_HUB, 160), + key(Chain.Geneses.ALEPH_ZERO, 74), + key(Chain.Geneses.VARA, 65), + key(Chain.Geneses.AVAIL, 3) + ) + + private fun key(chainId: ChainId, poolId: Int) = chainId to PoolId(poolId) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/pool/PoolAccountDerivation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/pool/PoolAccountDerivation.kt new file mode 100644 index 0000000..7227c28 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/pool/PoolAccountDerivation.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.constantOrNull +import io.novafoundation.nova.common.utils.nominationPoolsOrNull +import io.novafoundation.nova.common.utils.toByteArray +import io.novafoundation.nova.feature_account_api.domain.account.system.PrefixSystemAccountMatcher +import io.novafoundation.nova.feature_account_api.domain.account.system.SystemAccountMatcher +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation.PoolAccountType +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.common.utils.metadata +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.scale.dataType.uint32 + +private const val PREFIX = "modl" + +class RealPoolAccountDerivation( + private val localDataSource: StorageDataSource, +) : PoolAccountDerivation { + + override suspend fun derivePoolAccount(poolId: PoolId, derivationType: PoolAccountType, chainId: ChainId): AccountId { + val commonPrefix = requirePoolAccountPrefix(derivationType, chainId) + val poolIdBytes = uint32.toByteArray(poolId.value.toInt().toUInt()) + + return (commonPrefix + poolIdBytes).truncateToAccountId() + } + + override suspend fun derivePoolAccountsRange(numberOfPools: Int, derivationType: PoolAccountType, chainId: ChainId): Map { + val commonPrefix = requirePoolAccountPrefix(derivationType, chainId) + + return (1..numberOfPools).associateBy( + keySelector = { PoolId(it.toBigInteger()) }, + valueTransform = { poolId -> + val poolIdBytes = uint32.toByteArray(poolId.toUInt()) + + (commonPrefix + poolIdBytes).truncateToAccountId().intoKey() + } + ) + } + + override suspend fun poolAccountMatcher(derivationType: PoolAccountType, chainId: ChainId): SystemAccountMatcher? { + val poolAccountPrefix = poolAccountPrefix(derivationType, chainId) ?: return null + return PrefixSystemAccountMatcher(poolAccountPrefix) + } + + private fun ByteArray.truncateToAccountId(): AccountId = copyOf(newSize = 32) + + private suspend fun palletId(chainId: ChainId): ByteArray? { + return runCatching { + // We might fail to initiate query by chainId, e.g. in case it is EVM-only and doesn't have runtime + localDataSource.query(chainId) { + metadata.nominationPoolsOrNull()?.constantOrNull("PalletId")?.value + } + }.getOrNull() + } + + private val PoolAccountType.derivationIndex: Byte + get() = when (this) { + PoolAccountType.BONDED -> 0 + PoolAccountType.REWARD -> 1 + } + + private suspend fun requirePoolAccountPrefix(derivationType: PoolAccountType, chainId: ChainId): ByteArray { + return requireNotNull(poolAccountPrefix(derivationType, chainId)) + } + + private suspend fun poolAccountPrefix(derivationType: PoolAccountType, chainId: ChainId): ByteArray? { + val prefixBytes = PREFIX.encodeToByteArray() + val palletId = palletId(chainId) ?: return null + val derivationTypeIndex = derivationType.derivationIndex + + return prefixBytes + palletId + derivationTypeIndex + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/pool/PoolImageDataSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/pool/PoolImageDataSource.kt new file mode 100644 index 0000000..aa91b47 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/pool/PoolImageDataSource.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.asIcon +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface PoolImageDataSource { + + suspend fun getPoolIcon(poolId: PoolId, chainId: ChainId): Icon? +} + +class PredefinedPoolImageDataSource( + knownNovaPools: KnownNovaPools, +) : PoolImageDataSource { + + private val presets: Map, Icon?> = knownNovaPools.novaPoolIds + .associateWith { R.drawable.ic_pezkuwi_logo.asIcon() } + + override suspend fun getPoolIcon(poolId: PoolId, chainId: ChainId): Icon? { + val key = key(poolId, chainId) + return presets[key] + } + + @Suppress("SameParameterValue") + private fun key(poolId: PoolId, chainId: ChainId) = chainId to poolId +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolDelegatedStakeRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolDelegatedStakeRepository.kt new file mode 100644 index 0000000..91e02eb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolDelegatedStakeRepository.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository + +import io.novafoundation.nova.common.utils.delegatedStakingOrNull +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.delegatedStaking +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.delegators +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.DelegatedStakeMigrationState +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.common.utils.metadata +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface NominationPoolDelegatedStakeRepository { + + suspend fun hasMigratedToDelegatedStake(chainId: ChainId): Boolean + + suspend fun poolMemberMigrationState(chainId: ChainId, accountId: AccountId): DelegatedStakeMigrationState +} + +suspend fun NominationPoolDelegatedStakeRepository.shouldMigrateToDelegatedStake(chainId: ChainId, accountId: AccountId): Boolean { + return poolMemberMigrationState(chainId, accountId) == DelegatedStakeMigrationState.NEEDS_MIGRATION +} + +class RealNominationPoolDelegatedStakeRepository( + private val localStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, +) : NominationPoolDelegatedStakeRepository { + + override suspend fun hasMigratedToDelegatedStake(chainId: ChainId): Boolean { + return chainRegistry.getRuntime(chainId).metadata.delegatedStakingOrNull() != null + } + + override suspend fun poolMemberMigrationState(chainId: ChainId, accountId: AccountId): DelegatedStakeMigrationState { + return when { + !hasMigratedToDelegatedStake(chainId) -> DelegatedStakeMigrationState.NOT_SUPPORTED + hasDelegatedStake(chainId, accountId) -> DelegatedStakeMigrationState.MIGRATED + else -> DelegatedStakeMigrationState.NEEDS_MIGRATION + } + } + + private suspend fun hasDelegatedStake(chainId: ChainId, accountId: AccountId): Boolean { + val delegatedStake = localStorageSource.query(chainId) { + metadata.delegatedStaking.delegators.query(accountId) + } + + return delegatedStake != null + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolGlobalsRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolGlobalsRepository.kt new file mode 100644 index 0000000..c71e7de --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolGlobalsRepository.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.datasource.KnownMaxUnlockingOverwrites +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.counterForPoolMembers +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.lastPoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.maxPoolMembers +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.maxPoolMembersPerPool +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.minJoinBond +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.common.utils.metadata +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +interface NominationPoolGlobalsRepository { + + fun lastPoolIdFlow(chainId: ChainId): Flow + + suspend fun lastPoolId(chainId: ChainId): PoolId + + suspend fun maxUnlockChunks(chainId: ChainId): BigInteger + + fun observeMinJoinBond(chainId: ChainId): Flow + + suspend fun minJoinBond(chainId: ChainId): Balance + + suspend fun maxPoolMembers(chainId: ChainId): Int? + + suspend fun maxPoolMembersPerPool(chainId: ChainId): Int? + + suspend fun counterForPoolMembers(chainId: ChainId): Int +} + +class RealNominationPoolGlobalsRepository( + private val localStorageDataSource: StorageDataSource, + private val knownMaxUnlockingOverwrites: KnownMaxUnlockingOverwrites, + private val stakingRepository: StakingConstantsRepository, +) : NominationPoolGlobalsRepository { + + override fun lastPoolIdFlow(chainId: ChainId): Flow { + return localStorageDataSource.subscribe(chainId) { + metadata.nominationPools.lastPoolId.observeNonNull() + .map(::PoolId) + } + } + + override suspend fun lastPoolId(chainId: ChainId): PoolId { + return localStorageDataSource.query(chainId) { + val poolIdRaw = metadata.nominationPools.lastPoolId.query() + + PoolId(poolIdRaw.orZero()) + } + } + + override suspend fun maxUnlockChunks(chainId: ChainId): BigInteger { + val overwrite = knownMaxUnlockingOverwrites.getUnlockChunksFor(chainId) + if (overwrite != null) return overwrite + + return stakingRepository.maxUnlockingChunks(chainId) + } + + override fun observeMinJoinBond(chainId: ChainId): Flow { + return localStorageDataSource.subscribe(chainId) { + metadata.nominationPools.minJoinBond + .observe() + .filterNotNull() + } + } + + override suspend fun minJoinBond(chainId: ChainId): Balance { + return localStorageDataSource.query(chainId) { + metadata.nominationPools.minJoinBond + .query() + .orZero() + } + } + + override suspend fun maxPoolMembers(chainId: ChainId): Int? { + return localStorageDataSource.query(chainId) { + metadata.nominationPools.maxPoolMembers.query() + ?.toInt() + } + } + + override suspend fun maxPoolMembersPerPool(chainId: ChainId): Int? { + return localStorageDataSource.query(chainId) { + metadata.nominationPools.maxPoolMembersPerPool.query() + ?.toInt() + } + } + + override suspend fun counterForPoolMembers(chainId: ChainId): Int { + return localStorageDataSource.query(chainId) { + metadata.nominationPools.counterForPoolMembers.query() + .orZero() + .toInt() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolMembersRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolMembersRepository.kt new file mode 100644 index 0000000..954f39e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolMembersRepository.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.poolMembers +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.common.utils.metadata +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface NominationPoolMembersRepository { + + fun observePoolMember(chainId: ChainId, accountId: AccountId): Flow + + suspend fun getPoolMember(chainId: ChainId, accountId: AccountId): PoolMember? + + suspend fun getPendingRewards(poolMemberAccountId: AccountId, chainId: ChainId): Balance +} + +class RealNominationPoolMembersRepository( + private val localStorageSource: StorageDataSource, + private val remoteStorageSource: StorageDataSource, + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, +) : NominationPoolMembersRepository { + + override fun observePoolMember(chainId: ChainId, accountId: AccountId): Flow { + return localStorageSource.subscribe(chainId) { + metadata.nominationPools.poolMembers.observe(accountId) + } + } + + override suspend fun getPoolMember(chainId: ChainId, accountId: AccountId): PoolMember? { + return remoteStorageSource.query(chainId) { + metadata.nominationPools.poolMembers.query(accountId) + } + } + + override suspend fun getPendingRewards(poolMemberAccountId: AccountId, chainId: ChainId): Balance { + return multiChainRuntimeCallsApi.forChain(chainId).call( + section = "NominationPoolsApi", + method = "pending_rewards", + arguments = listOf(poolMemberAccountId to "GenericAccountId"), + returnType = "Balance", + returnBinding = ::bindNumber + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolStateRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolStateRepository.kt new file mode 100644 index 0000000..3c88613 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolStateRepository.kt @@ -0,0 +1,127 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository + +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novafoundation.nova.feature_staking_api.domain.model.StakingLedger +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.ledger +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.nominators +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.staking +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.bondedPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.metadata +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.BondedPool +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMetadata +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.PoolImageDataSource +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.WithRawValue +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface NominationPoolStateRepository { + + context(StorageQueryContext) + fun observeBondedPoolLedger(poolAccount: AccountId): Flow> + + context(StorageQueryContext) + fun observePoolNominations(poolAccount: AccountId): Flow> + + fun observeParticipatingPoolLedger(poolAccount: AccountId, chainId: ChainId): Flow + + fun observeParticipatingPoolNominations(poolAccount: AccountId, chainId: ChainId): Flow + + fun observeParticipatingBondedPool(poolId: PoolId, chainId: ChainId): Flow + + suspend fun getParticipatingBondedPool(poolId: PoolId, chainId: ChainId): BondedPool + + suspend fun getBondedPools(poolIds: Set, chainId: ChainId): Map + + fun observePoolMetadata(poolId: PoolId, chainId: ChainId): Flow + + suspend fun getPoolMetadatas(poolIds: Set, chainId: ChainId): Map + + suspend fun getAnyPoolMetadata(poolId: PoolId, chainId: ChainId): PoolMetadata? + + suspend fun getPoolIcon(poolId: PoolId, chainId: ChainId): Icon? +} + +class RealNominationPoolStateRepository( + private val localStorage: StorageDataSource, + private val remoteStorage: StorageDataSource, + private val poolImageDataSource: PoolImageDataSource, +) : NominationPoolStateRepository { + + context(StorageQueryContext) + override fun observeBondedPoolLedger(poolAccount: AccountId): Flow> { + return metadata.staking.ledger.observeWithRaw(poolAccount) + } + + context(StorageQueryContext) + override fun observePoolNominations(poolAccount: AccountId): Flow> { + return metadata.staking.nominators.observeWithRaw(poolAccount) + } + + override fun observeParticipatingPoolLedger(poolAccount: AccountId, chainId: ChainId): Flow { + return localStorage.subscribe(chainId) { + metadata.staking.ledger.observe(poolAccount) + } + } + + override fun observeParticipatingPoolNominations(poolAccount: AccountId, chainId: ChainId): Flow { + return localStorage.subscribe(chainId) { + metadata.staking.nominators.observe(poolAccount) + } + } + + override fun observeParticipatingBondedPool(poolId: PoolId, chainId: ChainId): Flow { + return localStorage.subscribe(chainId) { + metadata.nominationPools.bondedPools.observeNonNull(poolId.value) + } + } + + override suspend fun getParticipatingBondedPool(poolId: PoolId, chainId: ChainId): BondedPool { + return localStorage.query(chainId) { + metadata.nominationPools.bondedPools.queryNonNull(poolId.value) + } + } + + override suspend fun getBondedPools(poolIds: Set, chainId: ChainId): Map { + return remoteStorage.query(chainId) { + metadata.nominationPools.bondedPools.multi( + keys = poolIds.map { it.value }, + keyTransform = { PoolId(it) } + ).filterNotNull() + } + } + + override fun observePoolMetadata(poolId: PoolId, chainId: ChainId): Flow { + return localStorage.subscribe(chainId) { + metadata.nominationPools.metadata.observe(poolId.value) + } + } + + override suspend fun getPoolMetadatas(poolIds: Set, chainId: ChainId): Map { + return remoteStorage.query(chainId) { + metadata.nominationPools.metadata.multi( + keys = poolIds.map { it.value }, + keyTransform = { PoolId(it) } + ).filterNotNull() + } + } + + override suspend fun getAnyPoolMetadata(poolId: PoolId, chainId: ChainId): PoolMetadata? { + return remoteStorage.query(chainId) { + metadata.nominationPools.metadata.query(poolId.value) + } + } + + override suspend fun getPoolIcon(poolId: PoolId, chainId: ChainId): Icon? { + return poolImageDataSource.getPoolIcon(poolId, chainId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolUnbondRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolUnbondRepository.kt new file mode 100644 index 0000000..a71109f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/nominationPools/repository/NominationPoolUnbondRepository.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository + +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.api.subPoolsStorage +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.UnbondingPools +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.common.utils.metadata +import kotlinx.coroutines.flow.Flow + +interface NominationPoolUnbondRepository { + + fun unbondingPoolsFlow(poolId: PoolId, chainId: ChainId): Flow +} + +class RealNominationPoolUnbondRepository( + private val localStorageDataSource: StorageDataSource, +) : NominationPoolUnbondRepository { + + override fun unbondingPoolsFlow(poolId: PoolId, chainId: ChainId): Flow { + return localStorageDataSource.subscribe(chainId) { + metadata.nominationPools.subPoolsStorage.observe(poolId.value) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/CalculationResultExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/CalculationResultExt.kt new file mode 100644 index 0000000..6efb458 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/CalculationResultExt.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking + +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator.DurationCalculator + +fun DurationCalculator.CalculationResult.toTimerValue(): TimerValue { + return TimerValue( + millis = duration.inWholeMilliseconds, + millisCalculatedAt = calculatedAt + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/RoundDurationEstimator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/RoundDurationEstimator.kt new file mode 100644 index 0000000..d751621 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/RoundDurationEstimator.kt @@ -0,0 +1,149 @@ +@file:OptIn(ExperimentalTime::class) + +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.asNumber +import io.novafoundation.nova.common.utils.constant +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.RoundIndex +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator.DurationCalculator.CalculationResult +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.RoundInfo +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import java.math.BigInteger +import kotlinx.coroutines.flow.flatMapLatest +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime + +interface RoundDurationEstimator { + + interface DurationCalculator { + + fun timeTillRound(targetRound: RoundIndex): CalculationResult + + data class CalculationResult( + val duration: Duration, + val calculatedAt: Long + ) + } + + /** + * Creates a duration calculator based on current state of the storages + */ + suspend fun createDurationCalculator(chainId: ChainId): DurationCalculator + + suspend fun timeTillRoundFlow(chainId: ChainId, targetRound: RoundIndex): Flow + + suspend fun unstakeDurationFlow(chainId: ChainId): Flow + + suspend fun roundDurationFlow(chainId: ChainId): Flow + + suspend fun firstRewardReceivingDelayFlow(chainId: ChainId): Flow +} + +class RealRoundDurationEstimator( + private val parachainStakingConstantsRepository: ParachainStakingConstantsRepository, + private val chainStateRepository: ChainStateRepository, + private val currentRoundRepository: CurrentRoundRepository, + private val storageDataSource: StorageDataSource +) : RoundDurationEstimator { + + override suspend fun createDurationCalculator(chainId: ChainId): RoundDurationEstimator.DurationCalculator { + val currentRoundInfo = currentRoundRepository.currentRoundInfo(chainId) + + val blocksPerRound = currentRoundInfo.length + val blockTime = chainStateRepository.predictedBlockTime(chainId) + val blockNumber = chainStateRepository.currentBlock(chainId) + + return RealDurationCalculator(currentRoundInfo, blockTime, blocksPerRound, blockNumber) + } + + override suspend fun timeTillRoundFlow(chainId: ChainId, targetRound: RoundIndex): Flow { + val currentRoundInfo = currentRoundRepository.currentRoundInfo(chainId) + + val blocksPerRound = currentRoundInfo.length + val blockTime = chainStateRepository.predictedBlockTime(chainId) + + return chainStateRepository.currentBlockNumberFlow(chainId).map { currentBlock -> + val durationCalculator = RealDurationCalculator(currentRoundInfo, blockTime, blocksPerRound, currentBlock) + + durationCalculator.timeTillRound(targetRound).duration + } + } + + override suspend fun unstakeDurationFlow(chainId: ChainId): Flow { + val bondLessDelay = parachainStakingConstantsRepository.delegationBondLessDelay(chainId) + + return estimateDuration(chainId, numberOfRounds = bondLessDelay) + } + + override suspend fun roundDurationFlow(chainId: ChainId): Flow { + return combine( + currentRoundRepository.currentRoundInfoFlow(chainId), + chainStateRepository.predictedBlockTimeFlow(chainId) + ) { roundInfo, blockTime -> + val blocksPerRound = roundInfo.length + + val durationInMillis = blocksPerRound * blockTime + + durationInMillis.toLong().milliseconds + } + } + + override suspend fun firstRewardReceivingDelayFlow(chainId: ChainId): Flow { + return combine( + observeCurrentRoundRemainingTime(chainId), + roundDurationFlow(chainId) + ) { remainingEraDuration, eraDuration -> + val receivingDelayInEras = rewardReceivingDelay(chainId) + eraDuration * receivingDelayInEras.toInt() + remainingEraDuration + } + } + + private suspend fun estimateDuration(chainId: ChainId, numberOfRounds: BigInteger): Flow { + return roundDurationFlow(chainId).map { roundDuration -> roundDuration * numberOfRounds.toInt() } + } + + private fun observeCurrentRoundRemainingTime(chainId: String): Flow { + return currentRoundRepository.currentRoundInfoFlow(chainId).flatMapLatest { + timeTillRoundFlow(chainId, it.current) + } + } + + private suspend fun rewardReceivingDelay(chainId: ChainId): BigInteger { + return storageDataSource.query(chainId) { + runtime.metadata.parachainStaking().constant("RewardPaymentDelay").asNumber(runtime) + } + } +} + +class RealDurationCalculator( + private val currentRoundInfo: RoundInfo, + private val blockTime: BigInteger, + private val blocksPerRound: BigInteger, + private val currentBlockNumber: BlockNumber, +) : RoundDurationEstimator.DurationCalculator { + + override fun timeTillRound(targetRound: RoundIndex): CalculationResult { + // minus one since current round is going and it is not full + val remainedFullRounds = (targetRound - currentRoundInfo.current - BigInteger.ONE).coerceAtLeast(BigInteger.ZERO) + + val remainedBlocksTillCurrentRound = (currentRoundInfo.first + currentRoundInfo.length - currentBlockNumber).coerceAtLeast(BigInteger.ZERO) + + val remainedBlocks = remainedFullRounds * blocksPerRound + remainedBlocksTillCurrentRound + val durationInMillis = remainedBlocks * blockTime + + return CalculationResult( + duration = durationInMillis.toLong().milliseconds, + calculatedAt = System.currentTimeMillis() + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/CandidateMetadata.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/CandidateMetadata.kt new file mode 100644 index 0000000..8e57760 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/CandidateMetadata.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindCollectionEnum +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger + +class CandidateMetadata( + val totalCounted: Balance, + val delegationCount: BigInteger, + val lowestBottomDelegationAmount: Balance, + val highestBottomDelegationAmount: Balance, + val lowestTopDelegationAmount: Balance, + val topCapacity: CapacityStatus, + val bottomCapacity: CapacityStatus, + val status: CollatorStatus, +) + +enum class CapacityStatus { + Full, Empty, Partial +} + +enum class CollatorStatus { + Active, Idle, Leaving +} + +fun CandidateMetadata.isFull(): Boolean { + return bottomCapacity == CapacityStatus.Full +} +fun CandidateMetadata.isRewardedListFull(): Boolean { + return topCapacity == CapacityStatus.Full +} + +val CandidateMetadata.isActive + get() = status == CollatorStatus.Active + +fun CandidateMetadata.isBottomDelegationsNotEmpty(): Boolean { + return bottomCapacity != CapacityStatus.Empty +} + +fun CandidateMetadata.isStakeEnoughToEarnRewards(stake: BigInteger): Boolean { + return if (isRewardedListFull()) { + stake > lowestTopDelegationAmount + } else { + true + } +} + +fun CandidateMetadata.minimumStakeToGetRewards(techMinimumStake: Balance): Balance { + return if (topCapacity == CapacityStatus.Full) { + lowestTopDelegationAmount + } else { + techMinimumStake + } +} + +fun bindCandidateMetadata(decoded: Any?): CandidateMetadata { + return decoded.castToStruct().let { struct -> + CandidateMetadata( + totalCounted = bindNumber(struct["totalCounted"]), + delegationCount = bindNumber(struct["delegationCount"]), + lowestBottomDelegationAmount = bindNumber(struct["lowestBottomDelegationAmount"]), + lowestTopDelegationAmount = bindNumber(struct["lowestTopDelegationAmount"]), + highestBottomDelegationAmount = bindNumber(struct["highestBottomDelegationAmount"]), + topCapacity = bindCollectionEnum(struct["topCapacity"]), + bottomCapacity = bindCollectionEnum(struct["bottomCapacity"]), + status = bindCollectionEnum(struct["status"]) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/CollatorSnapshot.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/CollatorSnapshot.kt new file mode 100644 index 0000000..38749cb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/CollatorSnapshot.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorBond +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class CollatorSnapshot( + val bond: Balance, + val delegations: List, + val total: Balance, +) + +fun bindCollatorSnapshot(instance: Any?): CollatorSnapshot { + val asStruct = instance.castToStruct() + + return CollatorSnapshot( + bond = bindNumber(asStruct["bond"]), + delegations = bindList(asStruct["delegations"], ::bindBond), + total = bindNumber(asStruct["total"]) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/DelegationRequest.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/DelegationRequest.kt new file mode 100644 index 0000000..66aebaa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/DelegationRequest.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegationAction +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum + +fun bindDelegationAction( + instance: DictEnum.Entry +): DelegationAction { + return when (instance.name) { + "Revoke" -> DelegationAction.Revoke(bindNumber(instance.value)) + "Decrease" -> DelegationAction.Decrease(bindNumber(instance.value)) + else -> incompatible() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/DelegatorState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/DelegatorState.kt new file mode 100644 index 0000000..f0ce5df --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/DelegatorState.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorBond +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct + +fun bindDelegatorState( + dynamicInstance: Any?, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, +): DelegatorState { + return when (dynamicInstance) { + null -> DelegatorState.None(chain, chainAsset) + is Struct.Instance -> bindDelegator(dynamicInstance, accountId, chain, chainAsset) + else -> incompatible() + } +} + +private fun bindDelegator( + struct: Struct.Instance, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, +): DelegatorState.Delegator { + return DelegatorState.Delegator( + accountId = accountId, + chain = chain, + chainAsset = chainAsset, + delegations = bindList(struct["delegations"], ::bindBond), + total = bindNumber(struct["total"]), + lessTotal = bindNumber(struct["lessTotal"]) + ) +} + +fun bindBond( + instance: Any?, +): DelegatorBond { + val struct = instance.castToStruct() + + return DelegatorBond( + owner = bindAccountId(struct["owner"]), + balance = bindNumber(struct["amount"]) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/InflationConfig.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/InflationConfig.kt new file mode 100644 index 0000000..d6a35d6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/InflationConfig.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindPerbill +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class InflationInfo( + // Staking expectations + val expect: Range, + + // Annual inflation range + val annual: Range, + + // Round inflation range + val round: Range, +) + +fun bindInflationInfo(dynamic: Any?): InflationInfo { + val asStruct = dynamic.castToStruct() + + return InflationInfo( + expect = bindRange(asStruct["expect"], ::bindNumber), + annual = bindRange(asStruct["annual"], ::bindPerbill), + round = bindRange(asStruct["round"], ::bindPerbill) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/InflationDistributionAccount.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/InflationDistributionAccount.kt new file mode 100644 index 0000000..8db0571 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/InflationDistributionAccount.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.percentageToFraction +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +typealias Percent = BigInteger + +@JvmInline +value class InflationDistributionConfig(val accounts: List) + +class InflationDistributionAccount( + // Account which receives funds intended for parachain bond + val account: AccountId, + + // Percent of inflation set aside for parachain bond account + // Will be integer number (30%) + val percent: Percent +) + +fun InflationDistributionConfig.totalPercentAsFraction(): Double { + return accounts.sumOf { it.percent }.toDouble().percentageToFraction() +} + +fun bindParachainBondConfig(decoded: Any?): InflationDistributionConfig { + val distributionAccount = bindInflationDistributionAccount(decoded) + return InflationDistributionConfig(listOf(distributionAccount)) +} + +fun bindInflationDistributionConfig(decoded: Any?): InflationDistributionConfig { + return InflationDistributionConfig(bindList(decoded, ::bindInflationDistributionAccount)) +} + +private fun bindInflationDistributionAccount(decoded: Any?): InflationDistributionAccount = decoded.castToStruct().let { + InflationDistributionAccount( + account = bindAccountId(it["account"]), + percent = bindPercent(it["percent"]) + ) +} + +private fun bindPercent(dynamicInstance: Any?): Percent = dynamicInstance.cast() diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/Range.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/Range.kt new file mode 100644 index 0000000..990cd95 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/Range.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct + +class Range( + val min: T, + val max: T, + val ideal: T +) + +fun bindRange(dynamic: Any?, innerTypeBinding: (Any?) -> T): Range { + val asStruct = dynamic.castToStruct() + + return Range( + min = innerTypeBinding(asStruct["min"]), + max = innerTypeBinding(asStruct["max"]), + ideal = innerTypeBinding(asStruct["ideal"]) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/RoundIndex.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/RoundIndex.kt new file mode 100644 index 0000000..6113536 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/RoundIndex.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.RoundIndex + +fun bindRoundIndex(instance: Any?): RoundIndex = bindNumber(instance) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/RoundInfo.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/RoundInfo.kt new file mode 100644 index 0000000..e318e00 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/bindings/RoundInfo.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindBlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.RoundIndex +import java.math.BigInteger + +class RoundInfo( + // Current round index + val current: RoundIndex, + + // The first block of the current round + val first: BlockNumber, + + // / The length of the current round in number of blocks + val length: BigInteger +) + +fun bindRoundInfo(dynamic: Any?): RoundInfo { + val asStruct = dynamic.castToStruct() + + return RoundInfo( + current = bindRoundIndex(asStruct["current"]), + first = bindBlockNumber(asStruct["first"]), + length = bindNumber(asStruct["length"]) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/CollatorCommissionUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/CollatorCommissionUpdater.kt new file mode 100644 index 0000000..c699c10 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/CollatorCommissionUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class CollatorCommissionUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.parachainStaking().storage("CollatorCommission").storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/CurrentRoundCollatorsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/CurrentRoundCollatorsUpdater.kt new file mode 100644 index 0000000..dfa9c38 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/CurrentRoundCollatorsUpdater.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters + +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScopeUpdater +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.RoundIndex +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.fetchPrefixValuesToCache +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +class CurrentRoundCollatorsUpdater( + private val bulkRetriever: BulkRetriever, + private val stakingSharedState: StakingSharedState, + private val chainRegistry: ChainRegistry, + private val storageCache: StorageCache, + private val currentRoundRepository: CurrentRoundRepository, +) : GlobalScopeUpdater, SharedStateBasedUpdater { + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: Unit, + ): Flow { + val socketService = storageSubscriptionBuilder.socketService ?: return emptyFlow() + + val chainId = stakingSharedState.chainId() + val runtime = chainRegistry.getRuntime(chainId) + + return currentRoundRepository.currentRoundInfoFlow(chainId) + .map { runtime.collatorSnapshotPrefixFor(it.current) } + .filterNot { storageCache.isPrefixInCache(it, chainId) } + .onEach { cleanupPreviousRounds(runtime, chainId) } + .onEach { updateCollatorsPerRound(it, socketService, chainId) } + .noSideAffects() + } + + private suspend fun cleanupPreviousRounds(runtimeSnapshot: RuntimeSnapshot, chainId: String) { + val prefix = runtimeSnapshot.collatorSnapshotPrefix() + + storageCache.removeByPrefix(prefix, chainId) + } + + private fun RuntimeSnapshot.collatorSnapshotPrefix(): String { + return metadata.parachainStaking().storage("AtStake").storageKey(this) + } + + private fun RuntimeSnapshot.collatorSnapshotPrefixFor(roundIndex: RoundIndex): String { + return metadata.parachainStaking().storage("AtStake").storageKey(this, roundIndex) + } + + private suspend fun updateCollatorsPerRound( + prefix: String, + socketService: SocketService, + chainId: String + ) = runCatching { + bulkRetriever.fetchPrefixValuesToCache(socketService, prefix, storageCache, chainId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/CurrentRoundUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/CurrentRoundUpdater.kt new file mode 100644 index 0000000..cc2735e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/CurrentRoundUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class CurrentRoundUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.parachainStaking().storage("Round").storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/DelegatorStateUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/DelegatorStateUpdater.kt new file mode 100644 index 0000000..1f19509 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/DelegatorStateUpdater.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class DelegatorStateUpdater( + scope: AccountUpdateScope, + storageCache: StorageCache, + val stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, +) : SingleStorageKeyUpdater(scope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: MetaAccount): String? { + val account = scopeValue + val chain = stakingSharedState.chain() + + val accountId = account.accountIdIn(chain) ?: return null + + return runtime.metadata.parachainStaking().storage("DelegatorState").storageKey(runtime, accountId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/InflationConfigUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/InflationConfigUpdater.kt new file mode 100644 index 0000000..4229cd2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/InflationConfigUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class InflationConfigUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.parachainStaking().storage("InflationConfig").storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/InflationDistributionConfigUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/InflationDistributionConfigUpdater.kt new file mode 100644 index 0000000..6833480 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/InflationDistributionConfigUpdater.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull + +class InflationDistributionConfigUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + val parachainStaking = runtime.metadata.parachainStaking() + + return parachainStaking.storageOrNull("InflationDistributionInfo")?.storageKey() + ?: parachainStaking.storage("ParachainBondInfo").storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/ScheduledDelegationRequestsUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/ScheduledDelegationRequestsUpdater.kt new file mode 100644 index 0000000..6890d9e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/ScheduledDelegationRequestsUpdater.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.decodeValue +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.storage.insert +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindDelegatorState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.scheduledRequests.DelegationScheduledRequestFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.state.chainAndAsset +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map + +class ScheduledDelegationRequestsUpdater( + override val scope: AccountUpdateScope, + private val storageCache: StorageCache, + private val stakingSharedState: StakingSharedState, + private val remoteStorageDataSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val delegationScheduledRequestFactory: DelegationScheduledRequestFactory +) : SharedStateBasedUpdater { + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: MetaAccount + ): Flow { + val account = scopeValue + val (chain, asset) = stakingSharedState.chainAndAsset() + val runtime = chainRegistry.getRuntime(chain.id) + + val accountId = account.accountIdIn(chain) ?: return emptyFlow() + + val storage = runtime.metadata.parachainStaking().storage("DelegatorState") + val key = storage.storageKey(runtime, accountId) + + return storageSubscriptionBuilder.subscribe(key).map { + val dynamicInstance = storage.decodeValue(it.value, runtime) + val delegationState = bindDelegatorState(dynamicInstance, accountId, chain, asset) + + fetchUnbondings(delegationState, it.block)?.let { unbondings -> + storageCache.insert(unbondings, chain.id) + } + } + .noSideAffects() + } + + private suspend fun fetchUnbondings( + delegatorState: DelegatorState, + blockHash: String + ): Map? = when (delegatorState) { + is DelegatorState.Delegator -> { + remoteStorageDataSource.query(chainId = delegatorState.chain.id, at = blockHash) { + delegationScheduledRequestFactory.create() + .entriesRaw(delegatorState) + } + } + + is DelegatorState.None -> null + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/TotalDelegatedUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/TotalDelegatedUpdater.kt new file mode 100644 index 0000000..02bc874 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/TotalDelegatedUpdater.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class TotalDelegatedUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.parachainStaking().storage("Total").storageKey() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/turing/TuringAdditionalIssuanceUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/turing/TuringAdditionalIssuanceUpdater.kt new file mode 100644 index 0000000..c2e9350 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/turing/TuringAdditionalIssuanceUpdater.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.turing + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.vesting +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class TuringAdditionalIssuanceUpdater( + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, stakingSharedState, chainRegistry, storageCache), SharedStateBasedUpdater { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.vesting().storage("TotalUnvestedAllocation").storageKey() + } + + override val requiredModules: List = listOf(Modules.VESTING) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/turing/TuringAutomationTasksUpdater.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/turing/TuringAutomationTasksUpdater.kt new file mode 100644 index 0000000..cd198ea --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/blockhain/updaters/turing/TuringAutomationTasksUpdater.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.turing + +import io.novafoundation.nova.common.utils.automationTime +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.storage.insertPrefixEntries +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.network.updaters.multiChain.SharedStateBasedUpdater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.withIndex + +class TuringAutomationTasksUpdater( + private val stakingSharedState: StakingSharedState, + private val storageCache: StorageCache, + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + override val scope: AccountUpdateScope, +) : SharedStateBasedUpdater { + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: MetaAccount + ): Flow { + val chain = stakingSharedState.chain() + val metaAccount = scopeValue + val accountId = metaAccount.accountIdIn(chain) ?: return emptyFlow() + val runtime = chainRegistry.getRuntime(chain.id) + + val accountKey = runtime.metadata.system().storage("Account").storageKey(runtime, accountId) + + return storageSubscriptionBuilder.subscribe(accountKey) + .withIndex() + .mapLatest { (index, change) -> + val isChange = index > 0 + val at = if (isChange) change.block else null + + remoteStorageSource.query(chain.id, at) { + val storageEntry = runtime.metadata.automationTime().storage("AccountTasks") + val entries = storageEntry.entriesRaw(accountId) + val storagePrefix = storageEntry.storageKey(runtime, accountId) + + storageCache.insertPrefixEntries(entries, prefix = storagePrefix, chainId = chain.id) + } + }.noSideAffects() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/calls/ExtrinsicBuilderExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/calls/ExtrinsicBuilderExt.kt new file mode 100644 index 0000000..61588a2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/network/calls/ExtrinsicBuilderExt.kt @@ -0,0 +1,114 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.calls + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.hasCall +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import java.math.BigInteger + +fun ExtrinsicBuilder.delegate( + candidate: AccountId, + amount: Balance, + candidateDelegationCount: BigInteger, + delegationCount: BigInteger +): ExtrinsicBuilder { + return if (runtime.metadata.hasDelegateAutoCompoundCall()) { + call( + moduleName = Modules.PARACHAIN_STAKING, + callName = "delegate_with_auto_compound", + arguments = mapOf( + "candidate" to candidate, + "amount" to amount, + "auto_compound" to BigInteger.ZERO, + "candidate_delegation_count" to candidateDelegationCount, + "candidate_auto_compounding_delegation_count" to BigInteger.ZERO, + "delegation_count" to delegationCount + ) + ) + } else { + call( + moduleName = Modules.PARACHAIN_STAKING, + callName = "delegate", + arguments = mapOf( + "candidate" to candidate, + "amount" to amount, + "candidate_delegation_count" to candidateDelegationCount, + "delegation_count" to delegationCount + ) + ) + } +} + +private fun RuntimeMetadata.hasDelegateAutoCompoundCall(): Boolean { + return parachainStaking().hasCall("delegate_with_auto_compound") +} + +fun ExtrinsicBuilder.delegatorBondMore( + candidate: AccountId, + amount: Balance, +): ExtrinsicBuilder { + return call( + moduleName = Modules.PARACHAIN_STAKING, + callName = "delegator_bond_more", + arguments = mapOf( + "candidate" to candidate, + "more" to amount + ) + ) +} + +fun ExtrinsicBuilder.scheduleRevokeDelegation( + collatorId: AccountId +): ExtrinsicBuilder { + return call( + moduleName = Modules.PARACHAIN_STAKING, + callName = "schedule_revoke_delegation", + arguments = mapOf( + "collator" to collatorId, + ) + ) +} + +fun ExtrinsicBuilder.scheduleBondLess( + collatorId: AccountId, + amount: Balance, +): ExtrinsicBuilder { + return call( + moduleName = Modules.PARACHAIN_STAKING, + callName = "schedule_delegator_bond_less", + arguments = mapOf( + "candidate" to collatorId, + "less" to amount + ) + ) +} + +fun ExtrinsicBuilder.executeDelegationRequest( + delegator: AccountId, + collatorId: AccountId +): ExtrinsicBuilder { + return call( + moduleName = Modules.PARACHAIN_STAKING, + callName = "execute_delegation_request", + arguments = mapOf( + "delegator" to delegator, + "candidate" to collatorId + ) + ) +} + +fun ExtrinsicBuilder.cancelDelegationRequest( + collatorId: AccountId +): ExtrinsicBuilder { + return call( + moduleName = Modules.PARACHAIN_STAKING, + callName = "cancel_delegation_request", + arguments = mapOf( + "candidate" to collatorId, + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/CandidatesRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/CandidatesRepository.kt new file mode 100644 index 0000000..147100c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/CandidatesRepository.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository + +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CandidateMetadata +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindCandidateMetadata +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.wrapSingleArgumentKeys +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +interface CandidatesRepository { + + suspend fun getCandidateMetadata(chainId: ChainId, collatorId: AccountId): CandidateMetadata + + suspend fun getCandidatesMetadata(chainId: ChainId, collatorIds: Collection): AccountIdMap +} + +class RealCandidatesRepository( + private val storageDataSource: StorageDataSource +) : CandidatesRepository { + + override suspend fun getCandidateMetadata(chainId: ChainId, collatorId: AccountId): CandidateMetadata { + return storageDataSource.query(chainId) { + runtime.metadata.parachainStaking().storage("CandidateInfo").query(collatorId, binding = ::bindCandidateMetadata) + } + } + + override suspend fun getCandidatesMetadata(chainId: ChainId, collatorIds: Collection): AccountIdMap { + return storageDataSource.query(chainId) { + runtime.metadata.parachainStaking().storage("CandidateInfo").entries( + keysArguments = collatorIds.wrapSingleArgumentKeys(), + keyExtractor = { (accountId: AccountId) -> accountId.toHexString() }, + binding = { instance, _ -> bindCandidateMetadata(instance) } + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/CurrentRoundRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/CurrentRoundRepository.kt new file mode 100644 index 0000000..9cb0068 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/CurrentRoundRepository.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.RoundIndex +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CollatorSnapshot +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.RoundInfo +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindCollatorSnapshot +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindRoundInfo +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow + +interface CurrentRoundRepository { + + fun currentRoundInfoFlow(chainId: ChainId): Flow + + suspend fun currentRoundInfo(chainId: ChainId): RoundInfo + + suspend fun collatorsSnapshot(chainId: ChainId, roundIndex: RoundIndex): AccountIdMap + + suspend fun collatorSnapshot(chainId: ChainId, collatorId: AccountId, roundIndex: RoundIndex): CollatorSnapshot? + + fun totalStakedFlow(chainId: ChainId): Flow + + suspend fun totalStaked(chainId: ChainId): Balance +} + +suspend fun CurrentRoundRepository.collatorsSnapshotInCurrentRound(chainId: ChainId): AccountIdMap { + val roundIndex = currentRoundInfo(chainId).current + + return collatorsSnapshot(chainId, roundIndex) +} + +suspend fun CurrentRoundRepository.collatorSnapshotInCurrentRound( + chainId: ChainId, + collatorId: AccountId, +): CollatorSnapshot? { + val roundIndex = currentRoundInfo(chainId).current + + return collatorSnapshot(chainId, collatorId, roundIndex) +} + +class RealCurrentRoundRepository( + private val storageDataSource: StorageDataSource, +) : CurrentRoundRepository { + + override fun currentRoundInfoFlow(chainId: ChainId): Flow { + return storageDataSource.subscribe(chainId) { + runtime.metadata.parachainStaking().storage("Round").observe(binding = ::bindRoundInfo) + } + } + + override suspend fun currentRoundInfo(chainId: ChainId): RoundInfo { + return storageDataSource.query(chainId) { + runtime.metadata.parachainStaking().storage("Round").query(binding = ::bindRoundInfo) + } + } + + override suspend fun collatorsSnapshot(chainId: ChainId, roundIndex: RoundIndex): AccountIdMap { + return storageDataSource.query(chainId) { + runtime.metadata.parachainStaking().storage("AtStake").entries( + roundIndex, + keyExtractor = { (_: RoundIndex, collatorId: AccountId) -> collatorId.toHexString() }, + binding = { instance, _ -> bindCollatorSnapshot(instance) } + ) + } + } + + override suspend fun collatorSnapshot(chainId: ChainId, collatorId: AccountId, roundIndex: RoundIndex): CollatorSnapshot? { + return storageDataSource.query(chainId) { + runtime.metadata.parachainStaking().storage("AtStake").query( + roundIndex, + collatorId, + binding = ::bindCollatorSnapshot + ) + } + } + + override fun totalStakedFlow(chainId: ChainId): Flow { + return storageDataSource.subscribe(chainId) { + runtime.metadata.parachainStaking().storage("Total").observe(binding = ::bindNumber) + } + } + + override suspend fun totalStaked(chainId: ChainId): Balance { + return storageDataSource.query(chainId) { + runtime.metadata.parachainStaking().storage("Total").query(binding = ::bindNumber) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/DelegatorStateRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/DelegatorStateRepository.kt new file mode 100644 index 0000000..fd8c568 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/DelegatorStateRepository.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository + +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.ScheduledDelegationRequest +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.hasDelegation +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindDelegatorState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.scheduledRequests.DelegationScheduledRequestFactory +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow + +interface DelegatorStateRepository { + + /** + * Returns mapping from collator id to scheduled delegation request + */ + suspend fun scheduledDelegationRequests(delegatorState: DelegatorState.Delegator): AccountIdMap + + fun scheduledDelegationRequestsFlow(delegatorState: DelegatorState.Delegator): Flow> + + suspend fun scheduledDelegationRequest(delegatorState: DelegatorState.Delegator, collatorId: AccountId): ScheduledDelegationRequest? + + fun observeDelegatorState( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow + + suspend fun getDelegationState( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId + ): DelegatorState +} + +class RealDelegatorStateRepository( + private val localStorage: StorageDataSource, + private val remoteStorage: StorageDataSource, + private val delegationScheduledRequestFactory: DelegationScheduledRequestFactory +) : DelegatorStateRepository { + + override suspend fun scheduledDelegationRequests(delegatorState: DelegatorState.Delegator): AccountIdMap { + return localStorage.query(delegatorState.chain.id) { + delegationScheduledRequestFactory.create() + .entries(delegatorState) + } + } + + override fun scheduledDelegationRequestsFlow(delegatorState: DelegatorState.Delegator): Flow> { + return localStorage.subscribe(delegatorState.chain.id) { + delegationScheduledRequestFactory.create() + .observe(delegatorState) + } + } + + override suspend fun scheduledDelegationRequest(delegatorState: DelegatorState.Delegator, collatorId: AccountId): ScheduledDelegationRequest? { + if (!delegatorState.hasDelegation(collatorId)) return null + + return localStorage.query(delegatorState.chain.id) { + delegationScheduledRequestFactory.create() + .query(delegatorState, collatorId) + } + } + + override fun observeDelegatorState(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): Flow { + return localStorage.subscribe(chain.id) { + runtime.metadata.parachainStaking().storage("DelegatorState").observe( + accountId, + binding = { bindDelegatorState(it, accountId, chain, chainAsset) } + ) + } + } + + override suspend fun getDelegationState(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): DelegatorState { + return localStorage.query(chain.id) { + runtime.metadata.parachainStaking().storage("DelegatorState").query( + accountId, + binding = { bindDelegatorState(it, accountId, chain, chainAsset) } + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/ParachainStakingConstantsRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/ParachainStakingConstantsRepository.kt new file mode 100644 index 0000000..688d23d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/ParachainStakingConstantsRepository.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository + +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.numberConstantOrNull +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import java.math.BigInteger + +interface ParachainStakingConstantsRepository { + + suspend fun maxRewardedDelegatorsPerCollator(chainId: ChainId): BigInteger + + suspend fun minimumDelegation(chainId: ChainId): BigInteger + + suspend fun minimumDelegatorStake(chainId: ChainId): BigInteger + + suspend fun delegationBondLessDelay(chainId: ChainId): BigInteger + + suspend fun maxDelegationsPerDelegator(chainId: ChainId): BigInteger +} + +suspend fun ParachainStakingConstantsRepository.systemForcedMinStake(chainId: ChainId): BigInteger { + return minimumDelegatorStake(chainId).max(minimumDelegation(chainId)) +} + +class RuntimeParachainStakingConstantsRepository( + private val chainRegistry: ChainRegistry +) : ParachainStakingConstantsRepository { + + override suspend fun maxRewardedDelegatorsPerCollator(chainId: ChainId): BigInteger { + return numberConstant(chainId, "MaxTopDelegationsPerCandidate") + } + + override suspend fun minimumDelegation(chainId: ChainId): BigInteger { + return numberConstant(chainId, "MinDelegation") + } + + override suspend fun minimumDelegatorStake(chainId: ChainId): BigInteger { + return numberConstantOrNull(chainId, "MinDelegatorStk") + // Starting from runtime 2500, MinDelegatorStk was removed and only MinDelegation remained + ?: minimumDelegation(chainId) + } + + override suspend fun delegationBondLessDelay(chainId: ChainId): BigInteger { + return numberConstant(chainId, "DelegationBondLessDelay") + } + + override suspend fun maxDelegationsPerDelegator(chainId: ChainId): BigInteger { + return numberConstant(chainId, "MaxDelegationsPerDelegator") + } + + private suspend fun numberConstant(chainId: ChainId, name: String): BigInteger { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.parachainStaking().numberConstant(name, runtime) + } + + private suspend fun numberConstantOrNull(chainId: ChainId, name: String): BigInteger? { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.parachainStaking().numberConstantOrNull(name, runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/RewardsRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/RewardsRepository.kt new file mode 100644 index 0000000..377dfa4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/RewardsRepository.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.common.data.network.runtime.binding.bindPerbill +import io.novafoundation.nova.common.utils.hasStorage +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.InflationDistributionConfig +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.InflationInfo +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindInflationDistributionConfig +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindInflationInfo +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindParachainBondConfig +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +interface RewardsRepository { + + suspend fun getInflationInfo(chainId: ChainId): InflationInfo + + suspend fun getInflationDistributionConfig(chainId: ChainId): InflationDistributionConfig + + suspend fun getCollatorCommission(chainId: ChainId): Perbill +} + +class RealRewardsRepository( + private val storageDataSource: StorageDataSource, +) : RewardsRepository { + override suspend fun getInflationInfo(chainId: ChainId): InflationInfo { + return storageDataSource.query(chainId) { + runtime.metadata.parachainStaking().storage("InflationConfig").query(binding = ::bindInflationInfo) + } + } + + override suspend fun getInflationDistributionConfig(chainId: ChainId): InflationDistributionConfig { + return storageDataSource.query(chainId) { + val parachainStaking = runtime.metadata.parachainStaking() + val usesMultipleDistributionAccounts = parachainStaking.hasStorage("InflationDistributionInfo") + + if (usesMultipleDistributionAccounts) { + parachainStaking.storage("InflationDistributionInfo").query(binding = ::bindInflationDistributionConfig) + } else { + parachainStaking.storage("ParachainBondInfo").query(binding = ::bindParachainBondConfig) + } + } + } + + override suspend fun getCollatorCommission(chainId: ChainId): Perbill { + return storageDataSource.query(chainId) { + runtime.metadata.parachainStaking().storage("CollatorCommission").query(binding = ::bindPerbill) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/scheduledRequests/DelegationScheduledRequestFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/scheduledRequests/DelegationScheduledRequestFactory.kt new file mode 100644 index 0000000..bfc6466 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/scheduledRequests/DelegationScheduledRequestFactory.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.scheduledRequests + +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.ScheduledDelegationRequest +import io.novafoundation.nova.runtime.storage.source.StorageEntries +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Alias +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Vec +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow + +class DelegationScheduledRequestFactory { + context(StorageQueryContext) + fun create(): DelegationScheduledRequestExecutor { + val storage = runtime.metadata.parachainStaking().storage("DelegationScheduledRequests") + val vec = storage.type.value as Vec + val alias = vec.typeReference.value as Alias + val struct = alias.aliasedReference.value as Struct + + return when { + struct.mapping.contains("delegator") -> LegacyDelegationScheduledRequestExecutor() + else -> NewDelegationScheduledRequestExecutor() + } + } +} + +interface DelegationScheduledRequestExecutor { + context(StorageQueryContext) + suspend fun entries(delegatorState: DelegatorState.Delegator): Map + + context(StorageQueryContext) + suspend fun observe(delegatorState: DelegatorState.Delegator): Flow> + + context(StorageQueryContext) + suspend fun query(delegatorState: DelegatorState.Delegator, collatorId: AccountId): ScheduledDelegationRequest? + + context(StorageQueryContext) + suspend fun entriesRaw(delegatorState: DelegatorState.Delegator): StorageEntries +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/scheduledRequests/LegacyDelegationScheduledRequestExecutor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/scheduledRequests/LegacyDelegationScheduledRequestExecutor.kt new file mode 100644 index 0000000..51f92a4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/scheduledRequests/LegacyDelegationScheduledRequestExecutor.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.scheduledRequests + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.mapValuesNotNull +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.ScheduledDelegationRequest +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindDelegationAction +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindRoundIndex +import io.novafoundation.nova.runtime.storage.source.StorageEntries +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.wrapSingleArgumentKeys +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.find +import kotlin.collections.orEmpty + +class LegacyDelegationScheduledRequestExecutor : DelegationScheduledRequestExecutor { + context(StorageQueryContext) + override suspend fun entries(delegatorState: DelegatorState.Delegator): Map { + val keyArguments = delegatorState.delegations.map { listOf(it.owner) } + + return runtime.metadata.parachainStaking().storage("DelegationScheduledRequests").entries( + keyArguments, + keyExtractor = { (collatorId: AccountId) -> collatorId.toHexString() }, + binding = { dynamicInstance, collatorId -> bindDelegationRequests(dynamicInstance, collatorId.fromHex()) } + ).byDelegator(delegatorState.accountId) + } + + context(StorageQueryContext) + override suspend fun observe(delegatorState: DelegatorState.Delegator): Flow> { + val keyArguments = delegatorState.delegations.map { listOf(it.owner) } + + return runtime.metadata.parachainStaking().storage("DelegationScheduledRequests").observe( + keyArguments, + keyExtractor = { (collatorId: AccountId) -> collatorId.toHexString() }, + binding = { dynamicInstance, collatorId -> bindDelegationRequests(dynamicInstance, collatorId.fromHex()) } + ).map { it.filterNotNull().byDelegator(delegatorState.accountId).values } + } + + context(StorageQueryContext) + override suspend fun query( + delegatorState: DelegatorState.Delegator, + collatorId: AccountId + ): ScheduledDelegationRequest? { + val allCollatorDelegationRequests = runtime.metadata.parachainStaking().storage("DelegationScheduledRequests").query( + collatorId, + binding = { bindDelegationRequests(it, collatorId) } + ) + + return allCollatorDelegationRequests.find { it.delegator.contentEquals(delegatorState.accountId) } + } + + context(StorageQueryContext) + override suspend fun entriesRaw(delegatorState: DelegatorState.Delegator): StorageEntries { + val delegatorIdsArgs = delegatorState.delegations.map { it.owner }.wrapSingleArgumentKeys() + + return runtime.metadata.parachainStaking().storage("DelegationScheduledRequests").entriesRaw(delegatorIdsArgs) + } + + private fun bindDelegationRequests(instance: Any?, collatorId: AccountId) = instance?.let { + bindList(instance) { listElement -> bindDelegationRequest(collatorId, listElement) } + }.orEmpty() + + private fun bindDelegationRequest( + collatorId: AccountId, + instance: Any?, + ): ScheduledDelegationRequest { + val delegationRequestStruct = instance.castToStruct() + + return ScheduledDelegationRequest( + delegator = bindAccountId(delegationRequestStruct["delegator"]), + whenExecutable = bindRoundIndex(delegationRequestStruct["whenExecutable"]), + action = bindDelegationAction(delegationRequestStruct.getTyped("action")), + collator = collatorId + ) + } + + private fun Map>.byDelegator(delegator: AccountId) = mapValuesNotNull { (_, pendingRequests) -> + pendingRequests.find { it.delegator.contentEquals(delegator) } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/scheduledRequests/NewDelegationScheduledRequestExecutor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/scheduledRequests/NewDelegationScheduledRequestExecutor.kt new file mode 100644 index 0000000..ccec18b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/repository/scheduledRequests/NewDelegationScheduledRequestExecutor.kt @@ -0,0 +1,102 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.scheduledRequests + +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getTyped +import io.novafoundation.nova.common.utils.mapValuesNotNull +import io.novafoundation.nova.common.utils.parachainStaking +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.ScheduledDelegationRequest +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindDelegationAction +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.bindRoundIndex +import io.novafoundation.nova.runtime.storage.source.StorageEntries +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.firstOrNull +import kotlin.collections.mapKeys +import kotlin.collections.orEmpty + +private class RequestKey(val collatorId: String, val delegatorId: String) + +class NewDelegationScheduledRequestExecutor : DelegationScheduledRequestExecutor { + context(StorageQueryContext) + override suspend fun entries(delegatorState: DelegatorState.Delegator): Map { + val keyArguments = delegatorState.delegations.map { listOf(it.owner, delegatorState.accountId) } + + return runtime.metadata.parachainStaking().storage("DelegationScheduledRequests").entries( + keyArguments, + keyExtractor = { (collatorId: AccountId, delegatorId: AccountId) -> RequestKey(collatorId.toHexString(), delegatorId.toHexString()) }, + binding = { dynamicInstance, key -> + bindDelegationRequests( + instance = dynamicInstance, + collatorId = key.collatorId.fromHex(), + delegatorId = key.delegatorId.fromHex() + ) + } + ).mapKeys { (key, _) -> key.collatorId } + .mapValuesNotNull { it.value.firstOrNull() } + } + + context(StorageQueryContext) + override suspend fun observe(delegatorState: DelegatorState.Delegator): Flow> { + val keyArguments = delegatorState.delegations.map { listOf(it.owner, delegatorState.accountId) } + + return runtime.metadata.parachainStaking().storage("DelegationScheduledRequests").observe( + keyArguments, + keyExtractor = { (collatorId: AccountId, delegatorId: AccountId) -> RequestKey(collatorId.toHexString(), delegatorId.toHexString()) }, + binding = { dynamicInstance, key -> + bindDelegationRequests( + dynamicInstance, + collatorId = key.collatorId.fromHex(), + delegatorId = key.delegatorId.fromHex() + ) + } + ).mapNotNull { instances -> instances.values.flatMap { it.orEmpty() } } + } + + context(StorageQueryContext) + override suspend fun query(delegatorState: DelegatorState.Delegator, collatorId: AccountId): ScheduledDelegationRequest? { + return runtime.metadata.parachainStaking().storage("DelegationScheduledRequests").query( + collatorId, + delegatorState.accountId, + binding = { bindDelegationRequests(it, collatorId, delegatorState.accountId) } + ).firstOrNull() + } + + context(StorageQueryContext) + override suspend fun entriesRaw(delegatorState: DelegatorState.Delegator): StorageEntries { + val delegatorIdsArgs = delegatorState.delegations.map { listOf(it.owner, delegatorState.accountId) } + + return runtime.metadata.parachainStaking().storage("DelegationScheduledRequests").entriesRaw(delegatorIdsArgs) + } + + fun bindDelegationRequests( + instance: Any?, + collatorId: AccountId, + delegatorId: AccountId, + ) = instance?.let { + bindList(instance) { listElement -> bindDelegationRequest(collatorId, delegatorId, listElement) } + }.orEmpty() + + private fun bindDelegationRequest( + collatorId: AccountId, + delegatorId: AccountId, + instance: Any?, + ): ScheduledDelegationRequest { + val delegationRequestStruct = instance.castToStruct() + + return ScheduledDelegationRequest( + delegator = delegatorId, + whenExecutable = bindRoundIndex(delegationRequestStruct["whenExecutable"]), + action = bindDelegationAction(delegationRequestStruct.getTyped("action")), + collator = collatorId + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/turing/network/rpc/TuringAutomationRpcApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/turing/network/rpc/TuringAutomationRpcApi.kt new file mode 100644 index 0000000..c53fb2d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/turing/network/rpc/TuringAutomationRpcApi.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.network.rpc + +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.asGsonParsedNumberOrNull +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.AutomationAction +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.OptimalAutomationRequest +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.OptimalAutomationResponse +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getSocket +import io.novasama.substrate_sdk_android.wsrpc.executeAsync +import io.novasama.substrate_sdk_android.wsrpc.mappers.NullableMapper +import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull +import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest +import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse + +interface TuringAutomationRpcApi { + + suspend fun getTimeAutomationFees(chainId: ChainId, action: AutomationAction, executions: Int): Balance + + suspend fun calculateOptimalAutomation(chainId: ChainId, request: OptimalAutomationRequest): OptimalAutomationResponse +} + +class RealTuringAutomationRpcApi( + private val chainRegistry: ChainRegistry, +) : TuringAutomationRpcApi { + + override suspend fun getTimeAutomationFees(chainId: ChainId, action: AutomationAction, executions: Int): Balance { + val socket = chainRegistry.getSocket(chainId) + val rpcRequest = RuntimeRequest( + method = "automationTime_getTimeAutomationFees", + params = listOf(action.rpcParamName, executions), + ) + + return socket.executeAsync(rpcRequest, mapper = GsonNumberMapper().nonNull()) + } + + override suspend fun calculateOptimalAutomation(chainId: ChainId, request: OptimalAutomationRequest): OptimalAutomationResponse { + val socket = chainRegistry.getSocket(chainId) + val rpcRequest = RuntimeRequest( + method = "automationTime_calculateOptimalAutostaking", + params = listOf(request.amount, request.collator), + ) + + return socket.executeAsync(rpcRequest, mapper = pojo().nonNull()) + } + + private class GsonNumberMapper : NullableMapper() { + override fun mapNullable(rpcResponse: RpcResponse, jsonMapper: Gson): Balance? { + return rpcResponse.result.asGsonParsedNumberOrNull() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/turing/repository/TuringAutomationTasksRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/turing/repository/TuringAutomationTasksRepository.kt new file mode 100644 index 0000000..bd8789e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/turing/repository/TuringAutomationTasksRepository.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.utils.automationTime +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.AutomationAction +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.OptimalAutomationRequest +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.OptimalAutomationResponse +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTask +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTasksRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.network.rpc.TuringAutomationRpcApi +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import java.math.BigInteger +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RealTuringAutomationTasksRepository( + private val localStorageDataSource: StorageDataSource, + private val turingAutomationRpcApi: TuringAutomationRpcApi, +) : TuringAutomationTasksRepository { + + override fun automationTasksFlow(chainId: ChainId, accountId: AccountId): Flow> { + return localStorageDataSource.subscribe(chainId) { + runtime.metadata.automationTime().storage("AccountTasks").observeByPrefix( + accountId, + keyExtractor = { (_: AccountId, taskId: ByteArray) -> taskId.toHexString() }, + binding = ::bindAutomationTasks + ).map { it.values.filterNotNull() } + } + } + + override suspend fun calculateOptimalAutomation(chainId: ChainId, request: OptimalAutomationRequest): OptimalAutomationResponse { + return turingAutomationRpcApi.calculateOptimalAutomation(chainId, request) + } + + override suspend fun getTimeAutomationFees(chainId: ChainId, action: AutomationAction, executions: Int): Balance { + return turingAutomationRpcApi.getTimeAutomationFees(chainId, action, executions) + } + + private fun bindAutomationTasks(raw: Any?, taskId: String): TuringAutomationTask? = runCatching { + val struct = raw.castToStruct() + val action = struct.get>("action") ?: incompatible() + val actionType = action.name + + if (actionType != "AutoCompoundDelegatedStake") return null + + val schedule = struct.get>("schedule") ?: incompatible() + val actionValue = action.value.castToStruct() + + return TuringAutomationTask( + id = taskId, + delegator = bindAccountId(actionValue["delegator"]), + collator = bindAccountId(actionValue["collator"]), + accountMinimum = bindNumber(actionValue["account_minimum"]), + schedule = bindSchedule(schedule) + ) + }.getOrNull() + + private fun bindSchedule(schedule: DictEnum.Entry<*>): TuringAutomationTask.Schedule = runCatching { + if (schedule.name == "Recurring") { + val recurring = schedule.value.castToStruct().get("frequency") ?: incompatible() + return TuringAutomationTask.Schedule.Recurring(recurring) + } + + return TuringAutomationTask.Schedule.Unknown + }.getOrNull() ?: TuringAutomationTask.Schedule.Unknown +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/turing/repository/TuringStakingRewardsRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/turing/repository/TuringStakingRewardsRepository.kt new file mode 100644 index 0000000..fddb60c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/parachainStaking/turing/repository/TuringStakingRewardsRepository.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.vesting +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +interface TuringStakingRewardsRepository { + + suspend fun additionalIssuance(chainId: ChainId): Balance +} + +class RealTuringStakingRewardsRepository( + private val localStorage: StorageDataSource +) : TuringStakingRewardsRepository { + + override suspend fun additionalIssuance(chainId: ChainId): Balance { + return localStorage.query(chainId) { + runtime.metadata.vesting().storage("TotalUnvestedAllocation").query(binding = ::bindNumber) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/BagListRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/BagListRepository.kt new file mode 100644 index 0000000..54e0978 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/BagListRepository.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindNullableAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.constant +import io.novafoundation.nova.common.utils.electionProviderMultiPhaseOrNull +import io.novafoundation.nova.common.utils.getAs +import io.novafoundation.nova.common.utils.numberConstantOrNull +import io.novafoundation.nova.common.utils.voterListOrNull +import io.novafoundation.nova.feature_staking_impl.domain.bagList.BagListLocator +import io.novafoundation.nova.feature_staking_impl.domain.model.BagListNode +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novafoundation.nova.runtime.network.binding.collectionOf +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import java.math.BigInteger + +interface BagListRepository { + + suspend fun bagThresholds(chainId: ChainId): List? + + suspend fun bagListSize(chainId: ChainId): BigInteger? + + suspend fun maxElectingVotes(chainId: ChainId): BigInteger? + + fun listNodeFlow(stash: AccountId, chainId: ChainId): Flow +} + +suspend fun BagListRepository.bagListLocatorOrNull(chainId: ChainId): BagListLocator? = bagThresholds(chainId)?.let(::BagListLocator) +suspend fun BagListRepository.bagListLocatorOrThrow(chainId: ChainId): BagListLocator = requireNotNull(bagListLocatorOrNull(chainId)) + +class LocalBagListRepository( + private val localStorage: StorageDataSource, + private val chainRegistry: ChainRegistry +) : BagListRepository { + + override suspend fun bagThresholds(chainId: ChainId): List? { + return chainRegistry.withRuntime(chainId) { + runtime.metadata.voterListOrNull()?.constant("BagThresholds")?.getAs(collectionOf(::score)) + } + } + + override suspend fun bagListSize(chainId: ChainId): BigInteger? { + return localStorage.query(chainId) { + runtime.metadata.voterListOrNull()?.storage("CounterForListNodes")?.query(binding = ::bindNumber) + } + } + + override suspend fun maxElectingVotes(chainId: ChainId): BigInteger? { + return localStorage.query(chainId) { + runtime.metadata.electionProviderMultiPhaseOrNull()?.numberConstantOrNull("MaxElectingVoters", runtime) + ?: knownMaxElectingVoters(chainId) + } + } + + override fun listNodeFlow(stash: AccountId, chainId: ChainId): Flow { + return localStorage.subscribe(chainId) { + runtime.metadata.voterListOrNull()?.storage("ListNodes")?.observe(stash, binding = ::bindBagListNode) + ?: flowOf(null) + } + } + + private fun bindBagListNode(decoded: Any?): BagListNode? = runCatching { + val nodeStruct = decoded.castToStruct() + + return BagListNode( + id = bindAccountId(nodeStruct["id"]), + previous = bindNullableAccountId(nodeStruct["prev"]), + next = bindNullableAccountId(nodeStruct["next"]), + bagUpper = score(nodeStruct["bagUpper"]), + score = score(nodeStruct["score"]), + ) + }.getOrNull() + + private fun score(decoded: Any?): BagListNode.Score = BagListNode.Score(bindNumber(decoded)) + + private suspend fun knownMaxElectingVoters(chainId: ChainId): BigInteger? { + val chain = chainRegistry.getChain(chainId) + + return chain.additional?.stakingMaxElectingVoters?.toBigInteger() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/ParasRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/ParasRepository.kt new file mode 100644 index 0000000..48df5cc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/ParasRepository.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.parasOrNull +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +interface ParasRepository { + + suspend fun activePublicParachains(chainId: ChainId): Int? +} + +private val LOWEST_PUBLIC_ID = 2000.toBigInteger() + +class RealParasRepository( + private val localSource: StorageDataSource +) : ParasRepository { + + override suspend fun activePublicParachains(chainId: ChainId): Int? { + return localSource.query(chainId) { + val parachains = runtime.metadata.parasOrNull()?.storage("Parachains") + ?.query(binding = ::bindParachains) ?: return@query null + + parachains.count { it >= LOWEST_PUBLIC_ID } + } + } + + private fun bindParachains(decoded: Any?): List { + return bindList(decoded, ::bindNumber) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/PayoutRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/PayoutRepository.kt new file mode 100644 index 0000000..29dffe5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/PayoutRepository.kt @@ -0,0 +1,547 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.findPartitionPoint +import io.novafoundation.nova.common.utils.hasStorage +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.reversed +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.api.historicalEras +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.ExposureOverview +import io.novafoundation.nova.feature_staking_api.domain.model.ExposurePage +import io.novafoundation.nova.feature_staking_api.domain.model.PagedExposure +import io.novafoundation.nova.feature_staking_api.domain.model.ValidatorPrefs +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.data.model.Payout +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.ClaimedRewardsPages +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.EraRewardPoints +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindClaimedPages +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindEraRewardPoints +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindExposure +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindExposureOverview +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindExposurePage +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindStakingLedger +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindValidatorPrefs +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.PayoutTarget +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.SubQueryValidatorSetFetcher +import io.novafoundation.nova.feature_staking_impl.data.repository.PayoutRepository.ValidatorEraStake.NominatorInfo +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.multi.MultiQueryBuilder +import io.novafoundation.nova.runtime.storage.source.query.DynamicInstanceBinder +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.runtime.storage.source.query.multi +import io.novafoundation.nova.runtime.storage.source.query.wrapSingleArgumentKeys +import io.novasama.substrate_sdk_android.hash.isPositive +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import java.math.BigInteger + +typealias HistoricalMapping = Map // EraIndex -> T + +class PayoutRepository( + private val stakingRepository: StakingRepository, + private val validatorSetFetcher: SubQueryValidatorSetFetcher, + private val chainRegistry: ChainRegistry, + private val remoteStorage: StorageDataSource, + private val rpcCalls: RpcCalls, +) { + + suspend fun calculateUnpaidPayouts(stakingState: StakingState.Stash): List { + return when (stakingState) { + is StakingState.Stash.Nominator -> calculateUnpaidPayouts( + chain = stakingState.chain, + retrievePayoutTargets = { historicalRange -> + validatorSetFetcher.findNominatorPayoutTargets(stakingState.chain, stakingState.stashAddress, historicalRange) + }, + calculatePayout = { + calculateNominatorReward(stakingState.stashId, it) + } + ) + is StakingState.Stash.Validator -> calculateUnpaidPayouts( + chain = stakingState.chain, + retrievePayoutTargets = { historicalRange -> + validatorSetFetcher.findValidatorPayoutTargets(stakingState.chain, stakingState.stashAddress, historicalRange) + }, + calculatePayout = ::calculateValidatorReward + ) + else -> throw IllegalStateException("Cannot calculate payouts for ${stakingState::class.simpleName} state") + } + } + + private suspend fun calculateUnpaidPayouts( + chain: Chain, + retrievePayoutTargets: suspend (historicalRange: List) -> List, + calculatePayout: (RewardCalculationContext) -> Payout? + ): List { + val chainId = chain.id + + val runtime = chainRegistry.getRuntime(chain.id) + + val historicalRange = stakingRepository.historicalEras(chainId) + val payoutTargets = retrievePayoutTargets(historicalRange) + + Log.d("PayoutRepository", "Fetched payoutTargets") + + val involvedEras = payoutTargets.map { it.era }.distinct() + + val validatorEraStakes = getValidatorHistoricalStats(chainId, runtime, historicalRange, payoutTargets) + + val historicalTotalEraRewards = retrieveTotalEraReward(chainId, runtime, involvedEras) + Log.d("PayoutRepository", "Fetched historicalTotalEraRewards") + + val historicalRewardDistribution = retrieveEraPointsDistribution(chainId, runtime, involvedEras) + Log.d("PayoutRepository", "Fetched historicalRewardDistribution") + + val eraClaims = fetchValidatorEraClaims(chain, payoutTargets) + Log.d("PayoutRepository", "Fetched eraClaims") + + return validatorEraStakes.mapNotNull { validatorEraStake -> + val key = validatorEraStake.era to validatorEraStake.stash + val eraPointsDistribution = historicalRewardDistribution[validatorEraStake.era] ?: return@mapNotNull null + + val rewardCalculationContext = RewardCalculationContext( + eraStake = validatorEraStake, + eraClaim = eraClaims[key] ?: return@mapNotNull null, + totalEraReward = historicalTotalEraRewards[validatorEraStake.era] ?: return@mapNotNull null, + eraPoints = eraPointsDistribution + ) + + calculatePayout(rewardCalculationContext) + }.also { + Log.d("PayoutRepository", "Constructed payouts") + } + } + + private suspend fun partitionHistoricalRangeByPagedExposurePresence( + historicalRange: List, + runtime: RuntimeSnapshot, + chainId: ChainId + ): PartitionedHistoricalRange { + val firstPagedExposureEra = findFirstPagedExposureIndex(historicalRange, runtime, chainId) + ?: return PartitionedHistoricalRange(legacy = historicalRange, paged = emptyList()) + + return PartitionedHistoricalRange( + legacy = historicalRange.subList(0, firstPagedExposureEra), + paged = historicalRange.subList(firstPagedExposureEra, historicalRange.size) + ) + } + + private suspend fun findFirstPagedExposureIndex( + historicalRange: List, + runtime: RuntimeSnapshot, + chainId: ChainId + ): Int? { + val oldestEra = historicalRange.first() + + if (!runtime.metadata.staking().hasStorage("ErasStakersOverview")) return null + if (!runtime.metadata.staking().hasStorage("ErasStakersClipped")) return 0 + + // We expect certain delay between all legacy exposures are gone and runtime upgrade that removes storages completely + // This small optimizations reduces the number of requests needed to 1 for such period despite adding 1 extra request during migration period + if (isExposurePaged(oldestEra, runtime, chainId)) return 0 + + return historicalRange.findPartitionPoint { era -> isExposurePaged(era, runtime, chainId) }.also { + Log.d("PayoutRepository", "Fount first paged exposures era: ${it?.let { historicalRange[it] }}") + } + } + + private suspend fun isExposurePaged(eraIndex: EraIndex, runtime: RuntimeSnapshot, chainId: ChainId): Boolean { + val pagedEraPrefix = runtime.metadata.staking().storage("ErasStakersOverview").storageKey(runtime, eraIndex) + val storageSize = rpcCalls.getStorageSize(chainId, pagedEraPrefix) + + Log.d("PayoutRepository", "Fetched storage size for era $eraIndex: $storageSize") + + return storageSize.isPositive() + } + + private suspend fun fetchValidatorEraClaims( + chain: Chain, + payoutTargets: List, + ): Map, ValidatorEraClaim> { + return remoteStorage.query(chain.id) { + var validatorsPrefsDescriptor: MultiQueryBuilder.Descriptor, ValidatorPrefs?> + var controllersDescriptor: MultiQueryBuilder.Descriptor + var claimedPagesDescriptor: MultiQueryBuilder.Descriptor, ClaimedRewardsPages?>? = null + + val multiQueryResults = multi { + val eraAndValidatorKeys = payoutTargets.map { listOf(it.era, it.validatorStash.value) } + val validatorKeys = payoutTargets.mapToSet { it.validatorStash }.map { listOf(it.value) } + + validatorsPrefsDescriptor = runtime.metadata.staking().storage("ErasValidatorPrefs").queryKeys( + keysArgs = eraAndValidatorKeys, + keyExtractor = { (era: EraIndex, validatorStash: AccountId) -> era to validatorStash.intoKey() }, + binding = { decoded -> decoded?.let { bindValidatorPrefs(decoded) } } + ) + controllersDescriptor = runtime.metadata.staking().storage("Bonded").queryKeys( + keysArgs = validatorKeys, + keyExtractor = { (validatorStash: AccountId) -> validatorStash.intoKey() }, + binding = { decoded -> decoded?.let { bindAccountId(decoded).intoKey() } } + ) + if (metadata.staking().hasStorage("ClaimedRewards")) { + claimedPagesDescriptor = runtime.metadata.staking().storage("ClaimedRewards").queryKeys( + keysArgs = eraAndValidatorKeys, + keyExtractor = { (era: EraIndex, validatorStash: AccountId) -> era to validatorStash.intoKey() }, + binding = { decoded -> decoded?.let { bindClaimedPages(decoded) } } + ) + } + } + + Log.d("PayoutRepository", "Fetched prefs, controllers and claimed pages") + + val validatorsPrefs = multiQueryResults[validatorsPrefsDescriptor] + val controllers = multiQueryResults[controllersDescriptor] + + val stashByController = controllers + .mapValues { (stash, controller) -> controller ?: stash } + .reversed() + + val claimedLegacyRewards = metadata.staking().storage("Ledger").entries( + keysArguments = stashByController.keys.map { listOf(it.value) }, + keyExtractor = { (controller: AccountId) -> stashByController.getValue(controller.intoKey()) }, + binding = { decoded, _ -> decoded?.let { bindStakingLedger(decoded).claimedRewards.toSet() } } + ) + + Log.d("PayoutRepository", "Fetched claimedLegacyRewards") + + val allClaimedPages = claimedPagesDescriptor?.let(multiQueryResults::get) + + payoutTargets.mapNotNull { payoutTarget -> + val key = payoutTarget.era to payoutTarget.validatorStash + + val prefs = validatorsPrefs[key] ?: return@mapNotNull null + val claimedLegacyRewardsForValidator = claimedLegacyRewards[payoutTarget.validatorStash] ?: return@mapNotNull null + val claimedPages = allClaimedPages?.get(key).orEmpty() + + key to ValidatorEraClaim(prefs, claimedLegacyRewardsForValidator, payoutTarget.era, claimedPages) + }.toMap() + } + } + + // Nominator only pays out its own reward page + private fun calculateNominatorReward( + nominatorAccountId: AccountId, + rewardCalculationContext: RewardCalculationContext + ): Payout? = with(rewardCalculationContext) { + val nominatorEraInfo = rewardCalculationContext.eraStake.findNominatorInfo(nominatorAccountId) ?: return null + val nominatorPage = nominatorEraInfo.pageNumber + + if (rewardCalculationContext.eraClaim.pageClaimed(nominatorPage)) return null // already did the payout + + val nominatorStakeInEra = nominatorEraInfo.stake.toDouble() + + val validatorTotalStake = eraStake.totalStake.toDouble() + val validatorCommission = eraClaim.validatorPrefs.commission.toDouble() + + val validatorTotalReward = calculateValidatorTotalReward(totalEraReward, eraPoints, eraStake.stash.value) ?: return null + + val nominatorReward = validatorTotalReward * (1 - validatorCommission) * (nominatorStakeInEra / validatorTotalStake) + val nominatorRewardPlanks = nominatorReward.toBigDecimal().toBigInteger() + + Payout( + validatorStash = rewardCalculationContext.eraStake.stash, + era = rewardCalculationContext.eraStake.era, + amount = nominatorRewardPlanks, + pagesToClaim = listOf(nominatorPage) + ) + } + + // Validator pays out whole payout + private fun calculateValidatorReward( + rewardCalculationContext: RewardCalculationContext + ): Payout? = with(rewardCalculationContext) { + val totalPayoutPages = eraStake.totalPages + val unpaidPages = eraClaim.remainingPagesToClaim(totalPayoutPages) + + if (unpaidPages.isEmpty()) return null + + val validatorTotalStake = eraStake.totalStake.toDouble() + val validatorOwnStake = eraStake.validatorStake.toDouble() + val validatorCommission = eraClaim.validatorPrefs.commission.toDouble() + + val validatorTotalReward = calculateValidatorTotalReward(totalEraReward, eraPoints, eraStake.stash.value) ?: return null + + val validatorRewardFromCommission = validatorTotalReward * validatorCommission + val validatorRewardFromStake = validatorTotalReward * (1 - validatorCommission) * (validatorOwnStake / validatorTotalStake) + + val validatorOwnReward = validatorRewardFromStake + validatorRewardFromCommission + val validatorRewardPlanks = validatorOwnReward.toBigDecimal().toBigInteger() + + Payout( + validatorStash = rewardCalculationContext.eraStake.stash, + era = rewardCalculationContext.eraStake.era, + amount = validatorRewardPlanks, + pagesToClaim = unpaidPages + ) + } + + private fun calculateValidatorTotalReward( + totalEraReward: BigInteger, + eraValidatorPointsDistribution: EraRewardPoints, + validatorAccountId: AccountId, + ): Double? { + val totalMinted = totalEraReward.toDouble() + + val totalPoints = eraValidatorPointsDistribution.totalPoints.toDouble() + val validatorPoints = eraValidatorPointsDistribution.individual.firstOrNull { + it.accountId.contentEquals(validatorAccountId) + }?.rewardPoints?.toDouble() ?: return null + + return totalMinted * validatorPoints / totalPoints + } + + private suspend fun getValidatorHistoricalStats( + chainId: ChainId, + runtime: RuntimeSnapshot, + historicalRange: List, + payoutTargets: List, + ): List { + val (legacyEras, _) = partitionHistoricalRangeByPagedExposurePresence(historicalRange, runtime, chainId) + val legacyEraSet = legacyEras.toSet() + + val (legacyPayoutTargets, pagedPayoutTargets) = payoutTargets.partition { it.era in legacyEraSet } + + return getLegacyValidatorEraStake(chainId, legacyPayoutTargets) + getPagedValidatorHistoricalStats(chainId, pagedPayoutTargets) + } + + private suspend fun getPagedValidatorHistoricalStats( + chainId: ChainId, + payoutTargets: List, + ): List { + if (payoutTargets.isEmpty()) return emptyList() + + val pagedExposures = remoteStorage.fetchPagedExposures(chainId, payoutTargets) + + return pagedExposures.mapNotNull { (key, pagedExposure) -> + if (pagedExposure == null) return@mapNotNull null + + val (era, accountId) = key + PagedValidatorEraStake(accountId, era, pagedExposure) + }.also { + Log.d("PayoutRepository", "Fetched getPagedValidatorHistoricalStats for ${payoutTargets.size} targets") + } + } + + private suspend fun StorageDataSource.fetchPagedExposures( + chainId: ChainId, + payoutTargets: List + ): Map, PagedExposure?> { + return query(chainId) { + val eraAndValidatorKeys = payoutTargets.map { listOf(it.era, it.validatorStash.value) } + + val overview = runtime.metadata.staking().storage("ErasStakersOverview").entries( + keysArguments = eraAndValidatorKeys, + keyExtractor = { (era: EraIndex, validatorStash: AccountId) -> era to validatorStash.intoKey() }, + binding = { decoded, _ -> decoded?.let { bindExposureOverview(decoded) } }, + ) + + val pageKeys = overview.flatMap { (key, exposureOverview) -> + if (exposureOverview == null) return@flatMap emptyList() + + (0 until exposureOverview.pageCount.toInt()).map { page -> + listOf(key.first, key.second.value, page.toBigInteger()) + } + } + + val pages = runtime.metadata.staking().storage("ErasStakersPaged").entries( + keysArguments = pageKeys, + keyExtractor = { (era: EraIndex, validatorStash: AccountId, page: BigInteger) -> Triple(era, validatorStash.intoKey(), page.toInt()) }, + binding = { decoded, _ -> decoded?.let { bindExposurePage(decoded) } }, + ) + + mergeIntoPagedExposure(overview, pages) + } + } + + private fun mergeIntoPagedExposure( + overviews: Map, ExposureOverview?>, + pages: Map, ExposurePage?> + ): Map, PagedExposure?> { + return overviews.mapValues { (key, exposureOverview) -> + if (exposureOverview == null) return@mapValues null + + val totalPages = exposureOverview.pageCount.toInt() + val exposurePages = (0 until totalPages).map { page -> + pages[Triple(key.first, key.second, page)] ?: return@mapValues null + } + + PagedExposure(exposureOverview, exposurePages) + } + } + + private suspend fun getLegacyValidatorEraStake( + chainId: ChainId, + payoutTargets: List, + ): List { + if (payoutTargets.isEmpty()) return emptyList() + + return remoteStorage.query(chainId) { + val eraAndValidatorKeys = payoutTargets.map { listOf(it.era, it.validatorStash.value) } + + val exposuresClipped = runtime.metadata.staking().storage("ErasStakersClipped").entries( + keysArguments = eraAndValidatorKeys, + keyExtractor = { (era: EraIndex, validatorStash: AccountId) -> era to validatorStash.intoKey() }, + binding = { decoded, _ -> decoded?.let { bindExposure(decoded) } }, + ) + + payoutTargets.mapNotNull { payoutTarget -> + val eraAndAccountIdKey = payoutTarget.era to payoutTarget.validatorStash + + val exposure = exposuresClipped[eraAndAccountIdKey] ?: return@mapNotNull null + + LegacyValidatorEraStake(payoutTarget.validatorStash, payoutTarget.era, exposure) + }.also { + Log.d("PayoutRepository", "Fetched LegacyValidatorEraStake for ${payoutTargets.size} targets") + } + } + } + + private suspend fun retrieveEraPointsDistribution( + chainId: ChainId, + runtime: RuntimeSnapshot, + eras: List, + ): HistoricalMapping { + val storage = runtime.metadata.staking().storage("ErasRewardPoints") + + return retrieveHistoricalInfoForValidator(chainId, eras, storage, ::bindEraRewardPoints) + } + + private suspend fun retrieveTotalEraReward( + chainId: ChainId, + runtime: RuntimeSnapshot, + eras: List, + ): HistoricalMapping { + val storage = runtime.metadata.staking().storage("ErasValidatorReward") + + return retrieveHistoricalInfoForValidator(chainId, eras, storage, ::bindNumber) + } + + private suspend fun retrieveHistoricalInfoForValidator( + chainId: ChainId, + eras: List, + storage: StorageEntry, + binding: DynamicInstanceBinder, + ): HistoricalMapping { + return remoteStorage.query(chainId) { + storage.entries( + keysArguments = eras.wrapSingleArgumentKeys(), + keyExtractor = { (era: EraIndex) -> era }, + binding = { decoded, _ -> binding(decoded) } + ) + } + } + + private data class PartitionedHistoricalRange( + val legacy: List, + val paged: List + ) + + private class RewardCalculationContext( + val eraStake: ValidatorEraStake, + val eraClaim: ValidatorEraClaim, + val eraPoints: EraRewardPoints, + val totalEraReward: BigInteger + ) + + private interface ValidatorEraStake { + + class NominatorInfo(val stake: Balance, val pageNumber: Int) + + val stash: AccountIdKey + + val era: EraIndex + + val totalStake: BigInteger + + val totalPages: Int + + val validatorStake: BigInteger + + fun findNominatorInfo(accountId: AccountId): NominatorInfo? + } + + private class LegacyValidatorEraStake( + override val stash: AccountIdKey, + override val era: EraIndex, + private val exposure: Exposure + ) : ValidatorEraStake { + + override val totalPages: Int = 1 + + override val totalStake: BigInteger = exposure.total + + override val validatorStake: BigInteger = exposure.own + override fun findNominatorInfo(accountId: AccountId): NominatorInfo? { + val individualExposure = exposure.others.find { it.who.contentEquals(accountId) } + + return individualExposure?.let { + NominatorInfo(individualExposure.value, pageNumber = 0) + } + } + } + + private class PagedValidatorEraStake( + override val stash: AccountIdKey, + override val era: EraIndex, + private val exposure: PagedExposure, + ) : ValidatorEraStake { + + override val totalStake: BigInteger = exposure.overview.total + + override val validatorStake: BigInteger = exposure.overview.own + + override val totalPages: Int = exposure.overview.pageCount.toInt() + + override fun findNominatorInfo(accountId: AccountId): NominatorInfo? { + var result: NominatorInfo? = null + + exposure.pages.forEachIndexed { index, exposurePage -> + val individualExposure = exposurePage.others.find { it.who.contentEquals(accountId) } + + if (individualExposure != null) { + result = NominatorInfo(individualExposure.value, index) + } + } + + return result + } + } + + private class ValidatorEraClaim( + val validatorPrefs: ValidatorPrefs, + legacyClaimedEras: Set, + era: EraIndex, + private val claimedPages: ClaimedRewardsPages + ) { + + private val claimedFromLegacy = era in legacyClaimedEras + + fun pageClaimed(page: Int): Boolean { + return claimedFromLegacy || page in claimedPages + } + + fun remainingPagesToClaim(totalPages: Int): List { + if (claimedFromLegacy) return emptyList() + + val allPages = (0 until totalPages).toSet() + val unpaidPages = allPages - claimedPages + + return unpaidPages.toList() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/SessionRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/SessionRepository.kt new file mode 100644 index 0000000..d2b424f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/SessionRepository.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.currentIndex +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.session +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.validators +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.SessionValidators +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +interface SessionRepository { + + fun observeCurrentSessionIndex(chainId: ChainId): Flow + + fun sessionValidatorsFlow(chainId: ChainId): Flow + + suspend fun getSessionValidators(chainId: ChainId): SessionValidators +} + +class RealSessionRepository( + private val localStorage: StorageDataSource, +) : SessionRepository { + + override fun observeCurrentSessionIndex(chainId: ChainId) = localStorage.subscribe(chainId) { + metadata.session.currentIndex.observeNonNull() + } + + override fun sessionValidatorsFlow(chainId: ChainId): Flow { + return localStorage.subscribe(chainId) { + metadata.session.validators.observeNonNull() + } + } + + override suspend fun getSessionValidators(chainId: ChainId): SessionValidators { + return localStorage.query(chainId) { + metadata.session.validators.queryNonNull() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingConstantsRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingConstantsRepository.kt new file mode 100644 index 0000000..56da12a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingConstantsRepository.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.numberConstantOrNull +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import java.math.BigInteger + +private const val MAX_NOMINATIONS_FALLBACK = 16 +private const val MAX_UNLOCK_CHUNKS_FALLBACK = 32 + +class StakingConstantsRepository( + private val chainRegistry: ChainRegistry, + private val runtimeCallsApi: MultiChainRuntimeCallsApi +) { + + suspend fun maxUnlockingChunks(chainId: ChainId): BigInteger { + return getOptionalNumberConstant(chainId, "MaxUnlockingChunks") + ?: MAX_UNLOCK_CHUNKS_FALLBACK.toBigInteger() + } + + /** + * Returns maxRewardedNominatorPerValidator or null in case there is no limitation on rewarded nominators per validator + */ + suspend fun maxRewardedNominatorPerValidator(chainId: ChainId): Int? { + return getOptionalNumberConstant(chainId, "MaxNominatorRewardedPerValidator")?.toInt() + } + + suspend fun lockupPeriodInEras(chainId: ChainId): BigInteger = getNumberConstant(chainId, "BondingDuration") + + suspend fun maxValidatorsPerNominator(chainId: ChainId, stake: Balance): Int { + return getOptionalNumberConstant(chainId, "MaxNominations")?.toInt() + ?: getMaxNominationsQuota(chainId, stake)?.toInt() + ?: MAX_NOMINATIONS_FALLBACK + } + + private suspend fun getMaxNominationsQuota(chainId: ChainId, stake: Balance): BigInteger? = runCatching { + val runtimeCallApi = runtimeCallsApi.forChain(chainId) + + runtimeCallApi.call( + section = "StakingApi", + method = "nominations_quota", + arguments = listOf(stake to "Balance"), + returnType = "u32", + returnBinding = ::bindNumber + ) + }.getOrNull() + + private suspend fun getNumberConstant(chainId: ChainId, constantName: String): BigInteger { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.staking().numberConstant(constantName, runtime) + } + + private suspend fun getOptionalNumberConstant(chainId: ChainId, constantName: String): BigInteger? { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.staking().numberConstantOrNull(constantName, runtime) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingPeriodRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingPeriodRepository.kt new file mode 100644 index 0000000..f907ae7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingPeriodRepository.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import io.novafoundation.nova.feature_staking_impl.data.repository.datasource.StakingRewardPeriodDataSource +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface StakingPeriodRepository { + + suspend fun setRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType, rewardPeriod: RewardPeriod) + + suspend fun getRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType): RewardPeriod + + fun observeRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType): Flow +} + +class RealStakingPeriodRepository( + private val stakingRewardPeriodDataSource: StakingRewardPeriodDataSource +) : StakingPeriodRepository { + + override suspend fun setRewardPeriod( + accountId: AccountId, + chain: Chain, + asset: Chain.Asset, + stakingType: Chain.Asset.StakingType, + rewardPeriod: RewardPeriod + ) { + stakingRewardPeriodDataSource.setRewardPeriod(accountId, chain, asset, stakingType, rewardPeriod) + } + + override suspend fun getRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType): RewardPeriod { + return stakingRewardPeriodDataSource.getRewardPeriod(accountId, chain, asset, stakingType) + } + + override fun observeRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType): Flow { + return stakingRewardPeriodDataSource.observeRewardPeriod(accountId, chain, asset, stakingType) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingRepositoryImpl.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingRepositoryImpl.kt new file mode 100644 index 0000000..1a0dc29 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingRepositoryImpl.kt @@ -0,0 +1,437 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.fromHex +import io.novafoundation.nova.common.address.toHex +import io.novafoundation.nova.common.data.network.runtime.binding.NonNullBinderWithType +import io.novafoundation.nova.common.data.network.runtime.binding.returnType +import io.novafoundation.nova.common.utils.constant +import io.novafoundation.nova.common.utils.filterToSet +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.numberConstantOrNull +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.AccountStakingDao +import io.novafoundation.nova.core_db.model.AccountStakingLocal +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.ExposureOverview +import io.novafoundation.nova.feature_staking_api.domain.model.ExposurePage +import io.novafoundation.nova.feature_staking_api.domain.model.IndividualExposure +import io.novafoundation.nova.feature_staking_api.domain.model.InflationPredictionInfo +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novafoundation.nova.feature_staking_api.domain.model.SlashingSpans +import io.novafoundation.nova.feature_staking_api.domain.model.StakingLedger +import io.novafoundation.nova.feature_staking_api.domain.model.ValidatorPrefs +import io.novafoundation.nova.feature_staking_api.domain.model.findStartSessionIndexOf +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.activeEra +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.bondedErasOrNull +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.erasStartSessionIndexOrNull +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.ledger +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.staking +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.unappliedSlashes +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindActiveEra +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindCurrentEra +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindExposure +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindExposureOverview +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindExposurePage +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindHistoryDepth +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindMaxNominators +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindMinBond +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindNominations +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindNominatorsCount +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindRewardDestination +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSlashDeferDuration +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSlashingSpans +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindValidatorPrefs +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ValidatorExposureUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.activeEraStorageKey +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.observeNonNull +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull +import io.novafoundation.nova.runtime.storage.source.queryCatching +import io.novafoundation.nova.runtime.storage.source.queryNonNull +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class StakingRepositoryImpl( + private val accountStakingDao: AccountStakingDao, + private val remoteStorage: StorageDataSource, + private val localStorage: StorageDataSource, + private val walletConstants: WalletConstants, + private val chainRegistry: ChainRegistry, + private val storageCache: StorageCache, + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, +) : StakingRepository { + + override suspend fun eraStartSessionIndex(chainId: ChainId, era: EraIndex): EraIndex { + return localStorage.query(chainId) { + val sessionIndex = eraStartSessionIndexNew(era) ?: eraStartSessionIndexLegacy(era) + requireNotNull(sessionIndex) { + "No start session index detected for era $era on chain $chainId" + } + } + } + + context(StorageQueryContext) + private suspend fun eraStartSessionIndexNew(eraIndex: EraIndex): EraIndex? { + val storage = metadata.staking.bondedErasOrNull ?: return null + + // We are waiting for present value since we expect the value to be always present for the active era + // But the first returned value might be a outdated bit delayed because of outdated cache + return storage.observeNonNull() + .map { it.findStartSessionIndexOf(eraIndex) } + .filterNotNull() + .first() + } + + context(StorageQueryContext) + private suspend fun eraStartSessionIndexLegacy(eraIndex: EraIndex): EraIndex? { + return metadata.staking.erasStartSessionIndexOrNull?.query(eraIndex) + } + + override suspend fun eraLength(chain: Chain): BigInteger { + chain.additional?.sessionsPerEra?.let { + return it.toBigInteger() + } + + val runtime = runtimeFor(chain.id) + return runtime.metadata.staking().numberConstant("SessionsPerEra", runtime) // How many sessions per era + } + + override suspend fun getActiveEraIndex(chainId: ChainId): EraIndex = localStorage.query(chainId) { + metadata.staking.activeEra.queryNonNull() + } + + override suspend fun getCurrentEraIndex(chainId: ChainId): EraIndex = localStorage.queryNonNull( + keyBuilder = { it.metadata.staking().storage("CurrentEra").storageKey() }, + binding = ::bindCurrentEra, + chainId = chainId + ) + + override suspend fun getHistoryDepth(chainId: ChainId): BigInteger { + val runtime = runtimeFor(chainId) + val fromConstants = runtime.metadata.staking().numberConstantOrNull("HistoryDepth", runtime) + + return fromConstants ?: localStorage.queryNonNull( + keyBuilder = { it.metadata.staking().storage("HistoryDepth").storageKey() }, + binding = ::bindHistoryDepth, + chainId = chainId + ) + } + + override fun observeActiveEraIndex(chainId: String) = localStorage.observeNonNull( + chainId = chainId, + keyBuilder = { it.metadata.activeEraStorageKey() }, + binding = { scale, runtime -> bindActiveEra(scale, runtime) } + ) + + override suspend fun getElectedValidatorsExposure(chainId: ChainId, eraIndex: EraIndex): AccountIdMap { + val isPagedExposures = isPagedExposuresUsed(chainId) + + return if (isPagedExposures) { + fetchPagedEraStakers(chainId, eraIndex) + } else { + fetchLegacyEraStakers(chainId, eraIndex) + } + } + + private suspend fun fetchPagedEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap = localStorage.query(chainId) { + val eraStakersOverview = metadata.staking().storage("ErasStakersOverview").entries( + eraIndex, + keyExtractor = { (_: BigInteger, accountId: ByteArray) -> accountId.toHexString() }, + binding = { instance, _ -> bindExposureOverview(instance) } + ) + + val atLeastOneNominatorPresent = eraStakersOverview.any { (_, exposureOverview) -> exposureOverview.nominatorCount > BigInteger.ZERO } + + val eraStakersPaged = if (atLeastOneNominatorPresent) { + runtime.metadata.staking().storage("ErasStakersPaged").entries( + eraIndex, + keyExtractor = { (_: BigInteger, accountId: ByteArray, page: BigInteger) -> accountId.toHexString() to page.toInt() }, + binding = { instance, _ -> bindExposurePage(instance) } + ) + } else { + emptyMap() + } + + mergeOverviewsAndPagedOthers(eraStakersOverview, eraStakersPaged) + } + + private fun mergeOverviewsAndPagedOthers( + eraStakerOverviews: AccountIdMap, + othersPaged: Map, ExposurePage> + ): AccountIdMap { + return eraStakerOverviews.mapValues { (accountId, overview) -> + // avoid unnecessary growth allocations by pre-allocating exact needed number of elements + val others = ArrayList(overview.nominatorCount.toInt()) + + (0 until overview.pageCount.toInt()).forEach { pageNumber -> + val page = othersPaged[accountId to pageNumber]?.others.orEmpty() + others.addAll(page) + } + + Exposure( + total = overview.total, + own = overview.own, + others = others + ) + } + } + + private suspend fun fetchLegacyEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap = localStorage.query(chainId) { + runtime.metadata.staking().storage("ErasStakers").entries( + eraIndex, + keyExtractor = { (_: BigInteger, accountId: ByteArray) -> accountId.toHexString() }, + binding = { instance, _ -> bindExposure(instance) } + ) + } + + override suspend fun getValidatorPrefs( + chainId: ChainId, + accountIdsHex: Collection, + ): AccountIdMap { + return remoteStorage.query(chainId) { + runtime.metadata.staking().storage("Validators").entries( + keysArguments = accountIdsHex.map { listOf(it.fromHex()) }, + keyExtractor = { (accountId: AccountId) -> accountId.toHexString() }, + binding = { decoded, _ -> decoded?.let { bindValidatorPrefs(decoded) } } + ) + } + } + + override suspend fun getSlashes( + chainId: ChainId, + accountIdsHex: Collection + ): Set = withContext(Dispatchers.Default) { + val activeEra = getActiveEraIndex(chainId) + + remoteStorage.queryCatching(chainId) { + getSlashesFromSpans(accountIdsHex, activeEra) + ?: getSlashesFromUnappliedSlashes(accountIdsHex) + } + .onFailure { Log.w("StakingRepository", "Failed to get slashes for chain $chainId", it) } + .getOrDefault(emptySet()) + } + + context(StorageQueryContext) + private suspend fun getSlashesFromSpans( + accountIdsHex: Collection, + activeEraIndex: BigInteger, + ): Set? { + val slashDeferDurationConstant = runtime.metadata.staking().constant("SlashDeferDuration") + val slashDeferDuration = bindSlashDeferDuration(slashDeferDurationConstant, runtime) + + val storage = runtime.metadata.staking().storageOrNull("SlashingSpans") ?: return null + + return storage.entries( + keysArguments = accountIdsHex.map { listOf(it.fromHex()) }, + keyExtractor = { (accountId: AccountId) -> accountId.toHexString() }, + binding = { decoded, _ -> + val span = decoded?.let { bindSlashingSpans(it) } + + isSlashed(span, activeEraIndex, slashDeferDuration) + } + ) + .mapNotNull { (key, value) -> AccountIdKey.fromHex(key).getOrNull().takeIf { value } } + .toSet() + } + + context(StorageQueryContext) + private suspend fun getSlashesFromUnappliedSlashes( + accountIdsHex: Collection, + ): Set { + return runtime.metadata.staking.unappliedSlashes.keys() + .mapToSet { it.second.validator } + .filterToSet { it.toHex() in accountIdsHex } + } + + override suspend fun getSlashingSpan(chainId: ChainId, accountId: AccountId): SlashingSpans? { + return remoteStorage.query(chainId) { + metadata.staking().storageOrNull("SlashingSpans")?.query( + accountId, + binding = { decoded -> decoded?.let { bindSlashingSpans(it) } } + ) + } + } + + override fun stakingStateFlow( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId + ): Flow { + return accountStakingDao.observeDistinct(chain.id, chainAsset.id, accountId) + .flatMapLatest { accountStaking -> + val accessInfo = accountStaking.stakingAccessInfo + + if (accessInfo == null) { + flowOf(StakingState.NonStash(chain, chainAsset)) + } else { + observeStashState(chain, chainAsset, accessInfo, accountId) + } + } + } + + override suspend fun getRewardDestination(stakingState: StakingState.Stash) = localStorage.queryNonNull( + keyBuilder = { it.metadata.staking().storage("Payee").storageKey(it, stakingState.stashId) }, + binding = { scale, runtime -> bindRewardDestination(scale, runtime, stakingState.stashId, stakingState.controllerId) }, + chainId = stakingState.chain.id + ) + + override suspend fun minimumNominatorBond(chainId: ChainId): BigInteger { + val minBond = queryStorageIfExists( + storageName = "MinNominatorBond", + binder = ::bindMinBond, + chainId = chainId + ) ?: BigInteger.ZERO + + val existentialDeposit = walletConstants.existentialDeposit(chainId) + + return minBond.max(existentialDeposit) + } + + override suspend fun maxNominators(chainId: ChainId): BigInteger? = queryStorageIfExists( + storageName = "MaxNominatorsCount", + binder = ::bindMaxNominators, + chainId = chainId + ) + + override suspend fun nominatorsCount(chainId: ChainId): BigInteger? = queryStorageIfExists( + storageName = "CounterForNominators", + binder = ::bindNominatorsCount, + chainId = chainId + ) + + override suspend fun getInflationPredictionInfo(chainId: ChainId): InflationPredictionInfo { + val callApi = multiChainRuntimeCallsApi.forChain(chainId) + + return callApi.call( + section = "Inflation", + method = "experimental_inflation_prediction_info", + arguments = emptyMap(), + returnBinding = InflationPredictionInfo::fromDecoded + ) + } + + private suspend fun queryStorageIfExists( + chainId: ChainId, + storageName: String, + binder: NonNullBinderWithType, + ): T? { + val runtime = runtimeFor(chainId) + + return runtime.metadata.staking().storageOrNull(storageName)?.let { storageEntry -> + localStorage.query( + keyBuilder = { storageEntry.storageKey() }, + binding = { scale, _ -> scale?.let { binder(scale, runtime, storageEntry.returnType()) } }, + chainId = chainId + ) + } + } + + override fun ledgerFlow(stakingState: StakingState.Stash): Flow { + return localStorage.subscribe(stakingState.chain.id) { + metadata.staking.ledger.observe(stakingState.controllerId) + }.filterNotNull() + } + + override suspend fun ledger(chainId: ChainId, accountId: AccountId): StakingLedger? = remoteStorage.query(chainId) { + metadata.staking.ledger.query(accountId) + } + + private fun observeStashState( + chain: Chain, + chainAsset: Chain.Asset, + accessInfo: AccountStakingLocal.AccessInfo, + accountId: AccountId, + ): Flow { + val stashId = accessInfo.stashId + val controllerId = accessInfo.controllerId + + return combine( + observeAccountNominations(chain.id, stashId), + observeAccountValidatorPrefs(chain.id, stashId) + ) { nominations, prefs -> + when { + prefs != null -> StakingState.Stash.Validator( + chain, + chainAsset, + accountId, + controllerId, + stashId, + prefs + ) + + nominations != null -> StakingState.Stash.Nominator( + chain, + chainAsset, + accountId, + controllerId, + stashId, + nominations + ) + + else -> StakingState.Stash.None(chain, chainAsset, accountId, controllerId, stashId) + } + } + } + + private suspend fun isPagedExposuresUsed(chainId: ChainId): Boolean { + val isPagedExposuresValue = storageCache.getEntry(ValidatorExposureUpdater.STORAGE_KEY_PAGED_EXPOSURES, chainId) + + return ValidatorExposureUpdater.decodeIsPagedExposuresValue(isPagedExposuresValue.content) + } + + private fun observeAccountValidatorPrefs(chainId: ChainId, stashId: AccountId): Flow { + return localStorage.subscribe(chainId) { + runtime.metadata.staking().storage("Validators").observe( + stashId, + binding = { decoded -> decoded?.let { bindValidatorPrefs(decoded) } } + ) + } + } + + private fun observeAccountNominations(chainId: ChainId, stashId: AccountId): Flow { + return localStorage.observe( + chainId = chainId, + keyBuilder = { it.metadata.staking().storage("Nominators").storageKey(it, stashId) }, + binder = { scale, runtime -> scale?.let { bindNominations(it, runtime) } } + ) + } + + private fun isSlashed( + slashingSpans: SlashingSpans?, + activeEraIndex: BigInteger, + slashDeferDuration: BigInteger, + ) = slashingSpans != null && activeEraIndex - slashingSpans.lastNonZeroSlash < slashDeferDuration + + private suspend fun runtimeFor(chainId: String) = chainRegistry.getRuntime(chainId) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingRewardsRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingRewardsRepository.kt new file mode 100644 index 0000000..989d5ef --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingRewardsRepository.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.fullId +import io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward.StakingRewardsDataSourceRegistry +import io.novafoundation.nova.feature_staking_impl.domain.model.TotalReward +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface StakingRewardsRepository { + + fun totalRewardFlow(accountId: AccountId, stakingOptionId: StakingOptionId): Flow + + suspend fun sync(accountId: AccountId, stakingOption: StakingOption, rewardPeriod: RewardPeriod) +} + +class RealStakingRewardsRepository( + private val dataSourceRegistry: StakingRewardsDataSourceRegistry, +) : StakingRewardsRepository { + + override fun totalRewardFlow(accountId: AccountId, stakingOptionId: StakingOptionId): Flow { + return sourceFor(stakingOptionId).totalRewardsFlow(accountId, stakingOptionId) + } + + override suspend fun sync(accountId: AccountId, stakingOption: StakingOption, rewardPeriod: RewardPeriod) { + return sourceFor(stakingOption.fullId).sync(accountId, stakingOption, rewardPeriod) + } + + private fun sourceFor(stakingOption: StakingOptionId) = dataSourceRegistry.getDataSourceFor(stakingOption) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingVersioningRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingVersioningRepository.kt new file mode 100644 index 0000000..eeb75ae --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/StakingVersioningRepository.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.metadata.call + +interface StakingVersioningRepository { + + suspend fun controllersDeprecationStage(chainId: ChainId): ControllersDeprecationStage +} + +enum class ControllersDeprecationStage { + NORMAL, DEPRECATED +} + +class RealStakingVersioningRepository( + private val chainRegistry: ChainRegistry, +) : StakingVersioningRepository { + + override suspend fun controllersDeprecationStage(chainId: ChainId): ControllersDeprecationStage { + val runtime = chainRegistry.getRuntime(chainId) + val setControllerFunction = runtime.metadata.staking().call("set_controller") + + return if (setControllerFunction.arguments.isEmpty()) { + ControllersDeprecationStage.DEPRECATED + } else { + ControllersDeprecationStage.NORMAL + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/VaraRepository.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/VaraRepository.kt new file mode 100644 index 0000000..2e69c1e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/VaraRepository.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.asPerQuintill +import io.novafoundation.nova.common.utils.Perbill +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getSocket +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.executeAsync +import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull +import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest +import java.math.BigDecimal + +interface VaraRepository { + + suspend fun getVaraInflation(chainId: ChainId): Perbill +} + +class RealVaraRepository( + private val chainRegistry: ChainRegistry +) : VaraRepository { + + override suspend fun getVaraInflation(chainId: ChainId): Perbill { + return chainRegistry.getSocket(chainId).inflationInfo().inflation + .toBigInteger() + .asPerQuintill() + } + + private suspend fun SocketService.inflationInfo(): InflationInfo { + return executeAsync(InflationInfoRequest(), mapper = pojo().nonNull()) + } + + private class InflationInfoRequest : RuntimeRequest( + method = "stakingRewards_inflationInfo", + params = emptyList() + ) + + private class InflationInfo(val inflation: BigDecimal) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/AuraSession.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/AuraSession.kt new file mode 100644 index 0000000..a114e7a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/AuraSession.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.consensus + +import io.novafoundation.nova.common.utils.committeeManagementOrNull +import io.novafoundation.nova.common.utils.electionsOrNull +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.numberConstantOrNull +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.network.updaters.SharedAssetBlockNumberUpdater +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.runtime.storage.typed.number +import io.novafoundation.nova.runtime.storage.typed.system +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import java.math.BigInteger + +private const val SESSION_PERIOD_DEFAULT = 50 + +class AuraSession( + private val chainRegistry: ChainRegistry, + private val localStorage: StorageDataSource, +) : ElectionsSession { + + override suspend fun sessionLength(chainId: ChainId): BigInteger { + val runtime = runtimeFor(chainId) + + return runtime.metadata.electionsOrNull()?.numberConstantOrNull("SessionPeriod", runtime) + ?: runtime.metadata.committeeManagementOrNull()?.numberConstantOrNull("SessionPeriod", runtime) + ?: SESSION_PERIOD_DEFAULT.toBigInteger() + } + + override fun currentEpochIndexFlow(chainId: ChainId): Flow { + return flowOf(null) + } + + override fun currentSlotFlow(chainId: ChainId) = localStorage.subscribe(chainId) { + metadata.system.number.observeNonNull() + } + + override suspend fun currentSlotStorageKey(chainId: ChainId): String? { + /** + * we're already syncing system number as part of [SharedAssetBlockNumberUpdater] + */ + return null + } + + override suspend fun genesisSlotStorageKey(chainId: ChainId): String? { + // genesis slot for aura is zero so nothing to sync + return null + } + + override suspend fun currentEpochIndexStorageKey(chainId: ChainId): String? { + // there is no separate epoch index for aura + return null + } + + override suspend fun genesisSlot(chainId: ChainId): BigInteger = BigInteger.ZERO + + private suspend fun runtimeFor(chainId: ChainId): RuntimeSnapshot { + return chainRegistry.getRuntime(chainId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/BabeSession.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/BabeSession.kt new file mode 100644 index 0000000..a5c0850 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/BabeSession.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.consensus + +import io.novafoundation.nova.common.utils.babe +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.babe +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.currentSlot +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.epochIndex +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.api.genesisSlot +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +class BabeSession( + private val localStorage: StorageDataSource, + private val chainRegistry: ChainRegistry, +) : ElectionsSession { + + override suspend fun sessionLength(chainId: ChainId): BigInteger { + return chainRegistry.withRuntime(chainId) { + metadata.babe().numberConstant("EpochDuration") + } + } + + override fun currentEpochIndexFlow(chainId: ChainId): Flow { + return localStorage.subscribe(chainId) { + runtime.metadata.babe.epochIndex.observe() + } + } + + override fun currentSlotFlow(chainId: ChainId) = localStorage.subscribe(chainId) { + metadata.babe.currentSlot.observeNonNull() + } + + override suspend fun genesisSlot(chainId: ChainId) = localStorage.query(chainId) { + metadata.babe.genesisSlot.queryNonNull() + } + + override suspend fun currentSlotStorageKey(chainId: ChainId): String { + return chainRegistry.withRuntime(chainId) { + metadata.babe.currentSlot.storageKey() + } + } + + override suspend fun genesisSlotStorageKey(chainId: ChainId): String { + return chainRegistry.withRuntime(chainId) { + metadata.babe.genesisSlot.storageKey() + } + } + + override suspend fun currentEpochIndexStorageKey(chainId: ChainId): String { + return chainRegistry.withRuntime(chainId) { + runtime.metadata.babe.epochIndex.storageKey() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/ElectionsSession.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/ElectionsSession.kt new file mode 100644 index 0000000..6328feb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/ElectionsSession.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.consensus + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +interface ElectionsSession { + + fun currentSlotFlow(chainId: ChainId): Flow + + suspend fun genesisSlot(chainId: ChainId): BigInteger + + suspend fun sessionLength(chainId: ChainId): BigInteger + + fun currentEpochIndexFlow(chainId: ChainId): Flow + + suspend fun currentSlotStorageKey(chainId: ChainId): String? + + suspend fun genesisSlotStorageKey(chainId: ChainId): String? + + suspend fun currentEpochIndexStorageKey(chainId: ChainId): String? +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/ElectionsSessionRegistry.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/ElectionsSessionRegistry.kt new file mode 100644 index 0000000..e54e22c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/consensus/ElectionsSessionRegistry.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.consensus + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.ALEPH_ZERO +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.MYTHOS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN_AURA + +interface ElectionsSessionRegistry { + + fun electionsSessionFor(stakingOption: StakingOption): ElectionsSession +} + +class RealElectionsSessionRegistry( + private val babeSession: BabeSession, + private val auraSession: AuraSession +) : ElectionsSessionRegistry { + + override fun electionsSessionFor(stakingOption: StakingOption): ElectionsSession { + return electionsFor(stakingOption.unwrapNominationPools().stakingType) + } + + private fun electionsFor(stakingType: Chain.Asset.StakingType): ElectionsSession { + return when (stakingType) { + RELAYCHAIN -> babeSession + RELAYCHAIN_AURA, ALEPH_ZERO, MYTHOS -> auraSession + else -> throw IllegalArgumentException("Unsupported staking type in RealStakingSessionRegistry") + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/StakingRewardPeriodDataSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/StakingRewardPeriodDataSource.kt new file mode 100644 index 0000000..2f32da6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/StakingRewardPeriodDataSource.kt @@ -0,0 +1,124 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.datasource + +import io.novafoundation.nova.common.data.network.runtime.binding.castOrNull +import io.novafoundation.nova.core_db.dao.StakingRewardPeriodDao +import io.novafoundation.nova.core_db.model.StakingRewardPeriodLocal +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod.CustomRange +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriodType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.Date + +interface StakingRewardPeriodDataSource { + + suspend fun setRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType, rewardPeriod: RewardPeriod) + + suspend fun getRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType): RewardPeriod + + fun observeRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType): Flow +} + +class RealStakingRewardPeriodDataSource( + private val dao: StakingRewardPeriodDao +) : StakingRewardPeriodDataSource { + + override suspend fun setRewardPeriod( + accountId: AccountId, + chain: Chain, + asset: Chain.Asset, + stakingType: Chain.Asset.StakingType, + rewardPeriod: RewardPeriod + ) { + val localModel = mapRewardPeriodToLocal(accountId, chain.id, asset.id, stakingType, rewardPeriod) + dao.insertStakingRewardPeriod(localModel) + } + + override suspend fun getRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType): RewardPeriod { + val stakingTypeStr = mapStakingTypeToLocal(stakingType) + val period = dao.getStakingRewardPeriod(accountId, chain.id, asset.id, stakingTypeStr) + return mapToRewardPeriodFromLocal(period) + } + + override fun observeRewardPeriod(accountId: AccountId, chain: Chain, asset: Chain.Asset, stakingType: Chain.Asset.StakingType): Flow { + val stakingTypeStr = mapStakingTypeToLocal(stakingType) + return dao.observeStakingRewardPeriod(accountId, chain.id, asset.id, stakingTypeStr) + .map { mapToRewardPeriodFromLocal(it) } + } + + private fun mapRewardPeriodToLocal( + accountId: AccountId, + chainId: String, + assetId: Int, + stakingType: Chain.Asset.StakingType, + rewardPeriod: RewardPeriod + ): StakingRewardPeriodLocal { + return StakingRewardPeriodLocal( + chainId = chainId, + assetId = assetId, + accountId = accountId, + stakingType = mapStakingTypeToLocal(stakingType), + periodType = mapPeriodTypeToLocal(rewardPeriod), + customPeriodStart = rewardPeriod.castOrNull()?.start?.time, + customPeriodEnd = rewardPeriod.castOrNull()?.end?.time, + ) + } + + private fun mapToRewardPeriodFromLocal(period: StakingRewardPeriodLocal?): RewardPeriod { + val rewardPeriodType = mapPeriodTypeFromLocal(period) ?: return RewardPeriod.AllTime + + return when (rewardPeriodType) { + RewardPeriodType.AllTime -> RewardPeriod.AllTime + + is RewardPeriodType.Preset -> { + val offsetFromCurrentDate = RewardPeriod.getPresetOffset(rewardPeriodType) + RewardPeriod.OffsetFromCurrent(offsetFromCurrentDate, rewardPeriodType) + } + + RewardPeriodType.Custom -> CustomRange( + start = Date(period?.customPeriodStart ?: 0L), + end = period?.customPeriodEnd?.let(::Date) + ) + } + } + + private fun mapPeriodTypeFromLocal(period: StakingRewardPeriodLocal?): RewardPeriodType? { + return when (period?.periodType) { + "ALL_TIME" -> RewardPeriodType.AllTime + "WEEK" -> RewardPeriodType.Preset.WEEK + "MONTH" -> RewardPeriodType.Preset.MONTH + "QUARTER" -> RewardPeriodType.Preset.QUARTER + "HALF_YEAR" -> RewardPeriodType.Preset.HALF_YEAR + "YEAR" -> RewardPeriodType.Preset.YEAR + "CUSTOM" -> RewardPeriodType.Custom + else -> null + } + } + + private fun mapPeriodTypeToLocal(rewardPeriod: RewardPeriod): String { + return when (rewardPeriod.type) { + RewardPeriodType.AllTime -> "ALL_TIME" + RewardPeriodType.Preset.WEEK -> "WEEK" + RewardPeriodType.Preset.MONTH -> "MONTH" + RewardPeriodType.Preset.QUARTER -> "QUARTER" + RewardPeriodType.Preset.HALF_YEAR -> "HALF_YEAR" + RewardPeriodType.Preset.YEAR -> "YEAR" + RewardPeriodType.Custom -> "CUSTOM" + } + } + + private fun mapStakingTypeToLocal(stakingType: Chain.Asset.StakingType): String { + return when (stakingType) { + Chain.Asset.StakingType.UNSUPPORTED -> "UNSUPPORTED" + Chain.Asset.StakingType.ALEPH_ZERO -> "ALEPH_ZERO" + Chain.Asset.StakingType.PARACHAIN -> "PARACHAIN" + Chain.Asset.StakingType.RELAYCHAIN -> "RELAYCHAIN" + Chain.Asset.StakingType.RELAYCHAIN_AURA -> "RELAYCHAIN_AURA" + Chain.Asset.StakingType.TURING -> "TURING" + Chain.Asset.StakingType.NOMINATION_POOLS -> "NOMINATION_POOLS" + Chain.Asset.StakingType.MYTHOS -> "MYTHOS" + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/BaseStakingRewardsDataSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/BaseStakingRewardsDataSource.kt new file mode 100644 index 0000000..7626f56 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/BaseStakingRewardsDataSource.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.common.utils.atTheBeginningOfTheDay +import io.novafoundation.nova.common.utils.atTheEndOfTheDay +import io.novafoundation.nova.common.utils.mapAsync +import io.novafoundation.nova.common.utils.timestamp +import io.novafoundation.nova.core_db.dao.StakingTotalRewardDao +import io.novafoundation.nova.core_db.model.TotalRewardLocal +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.mappers.mapTotalRewardLocalToTotalReward +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.response.StakingPeriodRewardsResponse +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.response.totalReward +import io.novafoundation.nova.feature_staking_impl.domain.model.TotalReward +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapStakingTypeToStakingString +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map + +abstract class BaseStakingRewardsDataSource( + private val stakingTotalRewardDao: StakingTotalRewardDao, +) : StakingRewardsDataSource { + + override fun totalRewardsFlow(accountId: AccountId, stakingOptionId: StakingOptionId): Flow { + val stakingTypeRaw = mapStakingTypeToStakingString(stakingOptionId.stakingType) ?: return emptyFlow() + + return stakingTotalRewardDao.observeTotalRewards(accountId, stakingOptionId.chainId, stakingOptionId.chainAssetId, stakingTypeRaw) + .filterNotNull() + .map(::mapTotalRewardLocalToTotalReward) + } + + protected suspend fun saveTotalReward(totalReward: Balance, accountId: AccountId, stakingOption: StakingOption) { + val stakingTypeRaw = mapStakingTypeToStakingString(stakingOption.additional.stakingType) ?: return + + val totalRewardLocal = TotalRewardLocal( + accountId = accountId, + chainId = stakingOption.assetWithChain.chain.id, + chainAssetId = stakingOption.assetWithChain.asset.id, + stakingType = stakingTypeRaw, + totalReward = totalReward + ) + + stakingTotalRewardDao.insert(totalRewardLocal) + } + + override suspend fun sync(accountId: AccountId, stakingOption: StakingOption, rewardPeriod: RewardPeriod) { + val chain = stakingOption.assetWithChain.chain + + val totalReward = getTotalRewards(chain, accountId, rewardPeriod) + + saveTotalReward(totalReward, accountId, stakingOption) + } + + abstract suspend fun getTotalRewards(chain: Chain, accountId: AccountId, rewardPeriod: RewardPeriod): Balance + + protected suspend fun getAggregatedRewards( + externalApis: List, + receiver: suspend (String) -> SubQueryResponse + ): BigInteger { + val urls = externalApis.map { it.url } + val rewardsDeferredList = urls.mapAsync { url -> + receiver(url) + } + + return rewardsDeferredList.sumOf { + it.data.totalReward + } + } + + protected val RewardPeriod.startTimestamp: Long? + get() = start?.atTheBeginningOfTheDay()?.timestamp() // Using atTheBeginningOfTheDay() to avoid invalid data + + protected val RewardPeriod.endTimestamp: Long? + get() = end?.atTheEndOfTheDay()?.timestamp() // Using atTheEndOfTheDay() since the end of the day is fully included in the period +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/DirectStakingRewardsDataSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/DirectStakingRewardsDataSource.kt new file mode 100644 index 0000000..ce63c00 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/DirectStakingRewardsDataSource.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward + +import io.novafoundation.nova.core_db.dao.StakingTotalRewardDao +import io.novafoundation.nova.feature_staking_impl.data.model.stakingRewardsExternalApi +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.StakingApi +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.request.DirectStakingPeriodRewardsRequest +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class DirectStakingRewardsDataSource( + private val stakingApi: StakingApi, + stakingTotalRewardDao: StakingTotalRewardDao, +) : BaseStakingRewardsDataSource(stakingTotalRewardDao) { + + override suspend fun getTotalRewards(chain: Chain, accountId: AccountId, rewardPeriod: RewardPeriod): Balance { + val stakingExternalApis = chain.stakingRewardsExternalApi() + val address = chain.addressOf(accountId) + + return getAggregatedRewards(stakingExternalApis) { url -> + stakingApi.getRewardsByPeriod( + url = url, + body = DirectStakingPeriodRewardsRequest( + accountAddress = address, + startTimestamp = rewardPeriod.startTimestamp, + endTimestamp = rewardPeriod.endTimestamp + ) + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/PoolStakingRewardsDataSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/PoolStakingRewardsDataSource.kt new file mode 100644 index 0000000..1d88993 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/PoolStakingRewardsDataSource.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward + +import io.novafoundation.nova.core_db.dao.StakingTotalRewardDao +import io.novafoundation.nova.feature_staking_impl.data.model.stakingRewardsExternalApi +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.StakingApi +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.request.PoolStakingPeriodRewardsRequest +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class PoolStakingRewardsDataSource( + private val stakingApi: StakingApi, + stakingTotalRewardDao: StakingTotalRewardDao, +) : BaseStakingRewardsDataSource(stakingTotalRewardDao) { + + override suspend fun getTotalRewards(chain: Chain, accountId: AccountId, rewardPeriod: RewardPeriod): Balance { + val stakingExternalApi = chain.stakingRewardsExternalApi() + val address = chain.addressOf(accountId) + + return getAggregatedRewards(stakingExternalApi) { url -> + stakingApi.getPoolRewardsByPeriod( + url = url, + body = PoolStakingPeriodRewardsRequest( + accountAddress = address, + startTimestamp = rewardPeriod.startTimestamp, + endTimestamp = rewardPeriod.endTimestamp + ) + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/StakingRewardsDataSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/StakingRewardsDataSource.kt new file mode 100644 index 0000000..0d22436 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/StakingRewardsDataSource.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward + +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.model.TotalReward +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface StakingRewardsDataSource { + + fun totalRewardsFlow( + accountId: AccountId, + stakingOptionId: StakingOptionId, + ): Flow + + suspend fun sync( + accountId: AccountId, + stakingOption: StakingOption, + rewardPeriod: RewardPeriod + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/StakingRewardsDataSourceRegistry.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/StakingRewardsDataSourceRegistry.kt new file mode 100644 index 0000000..fdc7653 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/repository/datasource/reward/StakingRewardsDataSourceRegistry.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward + +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType + +interface StakingRewardsDataSourceRegistry { + + fun getDataSourceFor(stakingOptionId: StakingOptionId): StakingRewardsDataSource +} + +class RealStakingRewardsDataSourceRegistry( + private val directStakingRewardsDataSource: StakingRewardsDataSource, + private val poolStakingRewardsDataSource: StakingRewardsDataSource +) : StakingRewardsDataSourceRegistry { + + override fun getDataSourceFor(stakingOptionId: StakingOptionId): StakingRewardsDataSource { + return when (stakingOptionId.stakingType) { + StakingType.NOMINATION_POOLS -> poolStakingRewardsDataSource + else -> directStakingRewardsDataSource + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/validators/NovaValidatorsApi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/validators/NovaValidatorsApi.kt new file mode 100644 index 0000000..0111547 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/validators/NovaValidatorsApi.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.data.validators + +import io.novafoundation.nova.feature_staking_impl.BuildConfig +import retrofit2.http.GET + +interface NovaValidatorsApi { + + @GET(BuildConfig.RECOMMENDED_VALIDATORS_URL) + suspend fun getValidators(): ValidatorsPreferencesRemote +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/validators/ValidatorsPreferencesRemote.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/validators/ValidatorsPreferencesRemote.kt new file mode 100644 index 0000000..17f7c34 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/validators/ValidatorsPreferencesRemote.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.data.validators + +class ValidatorsPreferencesRemote( + val preferred: Map>, + val excluded: Map> +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/validators/ValidatorsPreferencesSource.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/validators/ValidatorsPreferencesSource.kt new file mode 100644 index 0000000..01c6cc3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/data/validators/ValidatorsPreferencesSource.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_staking_impl.data.validators + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.mapNotNullToSet +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.mapValuesNotNull +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +// TODO migrate this to use AccountIdKey instead of hex-encoded account id +interface ValidatorsPreferencesSource { + + suspend fun getRecommendedValidatorIds(chainId: ChainId): Set + + suspend fun getExcludedValidatorIds(chainId: ChainId): Set +} + +suspend fun ValidatorsPreferencesSource.getRecommendedValidatorIdKeys(chainId: ChainId): Set { + return getRecommendedValidatorIds(chainId).mapToSet { it.fromHex().intoKey() } +} + +suspend fun ValidatorsPreferencesSource.getExcludedValidatorIdKeys(chainId: ChainId): Set { + return getExcludedValidatorIds(chainId).mapToSet { it.fromHex().intoKey() } +} + +class RemoteValidatorsPreferencesSource( + private val validatorsApi: NovaValidatorsApi, + private val chainRegistry: ChainRegistry, +) : ValidatorsPreferencesSource { + + private var validatorsPreferences: ValidatorsPreferencesRemote? = null + private val validatorsMutex = Mutex() + + override suspend fun getRecommendedValidatorIds(chainId: ChainId): Set { + return getValidators().preferred[chainId].orEmpty() + } + + override suspend fun getExcludedValidatorIds(chainId: ChainId): Set { + return getValidators().excluded[chainId].orEmpty() + } + + private suspend fun getValidators(): ValidatorsPreferencesRemote { + return validatorsMutex.withLock { + if (validatorsPreferences == null) { + validatorsPreferences = fetchValidators() + } + + validatorsPreferences ?: ValidatorsPreferencesRemote(emptyMap(), emptyMap()) + } + } + + private suspend fun fetchValidators(): ValidatorsPreferencesRemote? { + return runCatching { + val chainsById = chainRegistry.chainsById() + val preferences = validatorsApi.getValidators() + val recommended = preferences.preferred.mapValuesNotNull { (chainId, addresses) -> + chainsById[chainId]?.convertAddressesToAccountIds(addresses) + } + val excluded = preferences.excluded.mapValuesNotNull { (chainId, addresses) -> + chainsById[chainId]?.convertAddressesToAccountIds(addresses) + } + + ValidatorsPreferencesRemote(recommended, excluded) + }.getOrNull() + } + + private fun Chain.convertAddressesToAccountIds(addresses: Set): Set { + return addresses.mapNotNullToSet { + this.tryConvertAddressToAccountIdHex(it) + } + } + + private fun Chain.tryConvertAddressToAccountIdHex(address: String): String? { + return runCatching { accountIdOf(address).toHexString() }.getOrNull() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureComponent.kt new file mode 100644 index 0000000..90965c2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureComponent.kt @@ -0,0 +1,311 @@ +package io.novafoundation.nova.feature_staking_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_proxy_api.di.ProxyFeatureApi +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdateSystem +import io.novafoundation.nova.feature_staking_impl.di.staking.UpdatersModule +import io.novafoundation.nova.feature_staking_impl.di.staking.dashboard.StakingDashboardModule +import io.novafoundation.nova.feature_staking_impl.di.staking.mythos.MythosModule +import io.novafoundation.nova.feature_staking_impl.di.staking.nominationPool.NominationPoolModule +import io.novafoundation.nova.feature_staking_impl.di.staking.parachain.ParachainStakingModule +import io.novafoundation.nova.feature_staking_impl.di.staking.stakingTypeDetails.StakingTypeDetailsModule +import io.novafoundation.nova.feature_staking_impl.di.staking.startMultiStaking.StartMultiStakingModule +import io.novafoundation.nova.feature_staking_impl.di.staking.unbond.StakingUnbondModule +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.rewards.NominationPoolRewardCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.bagList.rebag.di.RebagComponent +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.di.StakingDashboardComponent +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.di.MoreStakingOptionsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.claimRewards.di.MythosClaimRewardsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.currentCollators.di.MythosCurrentCollatorsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.redeem.di.MythosRedeemComponent +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.di.ConfirmStartMythosStakingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.di.SelectMythosCollatorComponent +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.di.SelectMythCollatorSettingsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup.di.SetupStartMythosStakingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.di.ConfirmUnbondMythosComponent +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.setup.di.SetupUnbondMythosComponent +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.di.NominationPoolsConfirmBondMoreComponent +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.setup.di.NominationPoolsSetupBondMoreComponent +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.claimRewards.di.NominationPoolsClaimRewardsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.redeem.di.NominationPoolsRedeemComponent +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.di.NominationPoolsConfirmUnbondComponent +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.setup.di.NominationPoolsSetupUnbondComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.current.di.CurrentCollatorsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.search.di.SearchCollatorComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.di.SelectCollatorComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.di.SelectCollatorSettingsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.di.ParachainStakingRebondComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.redeem.di.ParachainStakingRedeemComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.di.ConfirmStartParachainStakingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.di.SetupStartParachainStakingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.di.ParachainStakingUnbondConfirmComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.setup.di.ParachainStakingUnbondComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.di.YieldBoostConfirmComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.setup.di.SetupYieldBoostComponent +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.di.ConfirmPayoutComponent +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.detail.di.PayoutDetailsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.di.PayoutsListComponent +import io.novafoundation.nova.feature_staking_impl.presentation.period.di.StakingPeriodComponent +import io.novafoundation.nova.feature_staking_impl.presentation.pools.searchPool.di.SearchPoolComponent +import io.novafoundation.nova.feature_staking_impl.presentation.pools.selectPool.di.SelectPoolComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.di.ConfirmBondMoreComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select.di.SelectBondMoreComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.di.ConfirmSetControllerComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.set.di.SetControllerComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.di.ConfirmAddStakingProxyComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.set.di.AddStakingProxyComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.di.StakingProxyListComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.di.ConfirmRemoveStakingProxyComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.StakingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.di.ConfirmRebondComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.custom.di.CustomRebondComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem.di.RedeemComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.di.ConfirmRewardDestinationComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.select.di.SelectRewardDestinationComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.di.ConfirmMultiStakingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.di.StartStakingLandingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.di.SetupAmountMultiStakingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.di.SetupStakingTypeComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.di.ConfirmUnbondComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.select.di.SelectUnbondComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.di.ConfirmChangeValidatorsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.nominations.di.ConfirmNominationsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.di.ReviewCustomValidatorsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.search.di.SearchCustomValidatorsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select.di.SelectCustomValidatorsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.settings.di.CustomValidatorsSettingsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.recommended.di.RecommendedValidatorsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.start.di.StartChangeValidatorsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.current.di.CurrentValidatorsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.di.ValidatorDetailsComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + StakingFeatureDependencies::class + ], + modules = [ + StakingFeatureModule::class, + UpdatersModule::class, + StakingValidationModule::class, + StakingUnbondModule::class, + ParachainStakingModule::class, + NominationPoolModule::class, + MythosModule::class, + StakingDashboardModule::class, + StartMultiStakingModule::class, + StakingTypeDetailsModule::class + ] +) +@FeatureScope +interface StakingFeatureComponent : StakingFeatureApi { + + fun dashboardComponentFactory(): StakingDashboardComponent.Factory + + fun moreStakingOptionsFactory(): MoreStakingOptionsComponent.Factory + + // start multi-staking + + fun startStakingLandingComponentFactory(): StartStakingLandingComponent.Factory + + fun setupAmountMultiStakingComponentFactory(): SetupAmountMultiStakingComponent.Factory + + fun setupStakingType(): SetupStakingTypeComponent.Factory + + fun confirmMultiStakingComponentFactory(): ConfirmMultiStakingComponent.Factory + + // relaychain staking + + fun searchCustomValidatorsComponentFactory(): SearchCustomValidatorsComponent.Factory + + fun customValidatorsSettingsComponentFactory(): CustomValidatorsSettingsComponent.Factory + + fun reviewCustomValidatorsComponentFactory(): ReviewCustomValidatorsComponent.Factory + + fun selectCustomValidatorsComponentFactory(): SelectCustomValidatorsComponent.Factory + + fun startChangeValidatorsComponentFactory(): StartChangeValidatorsComponent.Factory + + fun selectPoolComponentFactory(): SelectPoolComponent.Factory + + fun searchPoolComponentFactory(): SearchPoolComponent.Factory + + fun recommendedValidatorsComponentFactory(): RecommendedValidatorsComponent.Factory + + fun stakingComponentFactory(): StakingComponent.Factory + + fun stakingPeriodComponentFactory(): StakingPeriodComponent.Factory + + fun confirmStakingComponentFactory(): ConfirmChangeValidatorsComponent.Factory + + fun confirmNominationsComponentFactory(): ConfirmNominationsComponent.Factory + + fun validatorDetailsComponentFactory(): ValidatorDetailsComponent.Factory + + fun payoutsListFactory(): PayoutsListComponent.Factory + + fun payoutDetailsFactory(): PayoutDetailsComponent.Factory + + fun confirmPayoutFactory(): ConfirmPayoutComponent.Factory + + fun selectBondMoreFactory(): SelectBondMoreComponent.Factory + + fun confirmBondMoreFactory(): ConfirmBondMoreComponent.Factory + + fun selectUnbondFactory(): SelectUnbondComponent.Factory + + fun confirmUnbondFactory(): ConfirmUnbondComponent.Factory + + fun redeemFactory(): RedeemComponent.Factory + + fun confirmRebondFactory(): ConfirmRebondComponent.Factory + + fun setControllerFactory(): SetControllerComponent.Factory + + fun setStakingProxyFactory(): AddStakingProxyComponent.Factory + + fun stakingProxyListFactory(): StakingProxyListComponent.Factory + + fun confirmSetControllerFactory(): ConfirmSetControllerComponent.Factory + + fun confirmAddStakingProxyFactory(): ConfirmAddStakingProxyComponent.Factory + + fun confirmRevokeStakingProxyFactory(): ConfirmRemoveStakingProxyComponent.Factory + + fun rebondCustomFactory(): CustomRebondComponent.Factory + + fun currentValidatorsFactory(): CurrentValidatorsComponent.Factory + + fun selectRewardDestinationFactory(): SelectRewardDestinationComponent.Factory + + fun confirmRewardDestinationFactory(): ConfirmRewardDestinationComponent.Factory + + fun rebagComponentFractory(): RebagComponent.Factory + + // parachain staking + + fun startParachainStakingFactory(): SetupStartParachainStakingComponent.Factory + + fun confirmStartParachainStakingFactory(): ConfirmStartParachainStakingComponent.Factory + + fun selectCollatorFactory(): SelectCollatorComponent.Factory + + fun selectCollatorSettingsFactory(): SelectCollatorSettingsComponent.Factory + + fun searchCollatorFactory(): SearchCollatorComponent.Factory + + fun currentCollatorsFactory(): CurrentCollatorsComponent.Factory + + fun parachainStakingUnbondSetupFactory(): ParachainStakingUnbondComponent.Factory + + fun parachainStakingUnbondConfirmFactory(): ParachainStakingUnbondConfirmComponent.Factory + + fun parachainStakingRedeemFactory(): ParachainStakingRedeemComponent.Factory + + fun parachainStakingRebondFactory(): ParachainStakingRebondComponent.Factory + + // turing + + fun setupYieldBoostComponentFactory(): SetupYieldBoostComponent.Factory + + fun confirmYieldBoostComponentFactory(): YieldBoostConfirmComponent.Factory + + // nomination pools + + fun nominationPoolsStakingSetupBondMore(): NominationPoolsSetupBondMoreComponent.Factory + + fun nominationPoolsStakingConfirmBondMore(): NominationPoolsConfirmBondMoreComponent.Factory + + fun nominationPoolsStakingSetupUnbond(): NominationPoolsSetupUnbondComponent.Factory + + fun nominationPoolsStakingConfirmUnbond(): NominationPoolsConfirmUnbondComponent.Factory + + fun nominationPoolsStakingRedeem(): NominationPoolsRedeemComponent.Factory + + fun nominationPoolsStakingClaimRewards(): NominationPoolsClaimRewardsComponent.Factory + + // Mythos staking + + fun startMythosStakingFactory(): SetupStartMythosStakingComponent.Factory + + fun selectMythosCollatorFactory(): SelectMythosCollatorComponent.Factory + + fun selectMythosSettingsFactory(): SelectMythCollatorSettingsComponent.Factory + + fun confirmStartMythosStakingFactory(): ConfirmStartMythosStakingComponent.Factory + + fun setupUnbondMythosFactory(): SetupUnbondMythosComponent.Factory + + fun confirmUnbondMythosFactory(): ConfirmUnbondMythosComponent.Factory + + fun redeemMythosFactory(): MythosRedeemComponent.Factory + + fun claimMythosRewardsFactory(): MythosClaimRewardsComponent.Factory + + fun currentMythosCollatorsFactory(): MythosCurrentCollatorsComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: StakingRouter, + + @BindsInstance parachainStaking: ParachainStakingRouter, + @BindsInstance selectCollatorInterScreenCommunicator: SelectCollatorInterScreenCommunicator, + @BindsInstance selectCollatorSettingsInterScreenCommunicator: SelectCollatorSettingsInterScreenCommunicator, + @BindsInstance selectAddressCommunicator: SelectAddressCommunicator, + + @BindsInstance mythosStakingRouter: MythosStakingRouter, + @BindsInstance selectMythosCollatorInterScreenCommunicator: SelectMythosInterScreenCommunicator, + @BindsInstance selectMythosCollatorSettingsInterScreenCommunicator: SelectMythCollatorSettingsInterScreenCommunicator, + + @BindsInstance nominationPoolsRouter: NominationPoolsRouter, + + @BindsInstance startMultiStakingRouter: StartMultiStakingRouter, + @BindsInstance stakingDashboardRouter: StakingDashboardRouter, + + deps: StakingFeatureDependencies + ): StakingFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + RuntimeApi::class, + AccountFeatureApi::class, + ProxyFeatureApi::class, + WalletFeatureApi::class, + DAppFeatureApi::class, + ChainMigrationFeatureApi::class + ] + ) + interface StakingFeatureDependenciesComponent : StakingFeatureDependencies + + val nominationPoolRewardCalculatorFactory: NominationPoolRewardCalculatorFactory + + val stakingUpdateSystem: StakingUpdateSystem + + val stakingSharedState: StakingSharedState +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureDependencies.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureDependencies.kt new file mode 100644 index 0000000..8b7d9ae --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureDependencies.kt @@ -0,0 +1,231 @@ +package io.novafoundation.nova.feature_staking_impl.di + +import android.content.SharedPreferences +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.config.GlobalConfigDataSource +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.modules.Caching +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.AccountStakingDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.dao.StakingDashboardDao +import io.novafoundation.nova.core_db.dao.StakingRewardPeriodDao +import io.novafoundation.nova.core_db.dao.StakingTotalRewardDao +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_proxy_api.data.common.ProxyDepositCalculator +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.data.repository.ProxyConstantsRepository +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.TimestampRepository +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +interface StakingFeatureDependencies { + + val maskableValueFormatterFactory: MaskableValueFormatterFactory + + val maskableValueFormatterProvider: MaskableValueFormatterProvider + + val amountChooserMixinFactory: AmountChooserMixin.Factory + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val walletUiUseCase: WalletUiUseCase + + val resourcesHintsMixinFactory: ResourcesHintsMixinFactory + + val selectedAccountUseCase: SelectedAccountUseCase + + val chainStateRepository: ChainStateRepository + + val sampledBlockTimeStorage: SampledBlockTimeStorage + + val timestampRepository: TimestampRepository + + val totalIssuanceRepository: TotalIssuanceRepository + + val onChainIdentityRepository: OnChainIdentityRepository + + val identityMixinFactory: IdentityMixin.Factory + + val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory + + val stakingDashboardDao: StakingDashboardDao + + val dAppMetadataRepository: DAppMetadataRepository + + val runtimeCallsApi: MultiChainRuntimeCallsApi + + val arbitraryAssetUseCase: ArbitraryAssetUseCase + + val locksRepository: BalanceLocksRepository + + val externalBalanceDao: ExternalBalanceDao + + val partialRetriableMixinFactory: PartialRetriableMixin.Factory + + val proxyDepositCalculator: ProxyDepositCalculator + + val getProxyRepository: GetProxyRepository + + val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher + + val metaAccountGroupingInteractor: MetaAccountGroupingInteractor + + val selectAddressMixinFactory: SelectAddressMixin.Factory + + val proxyConstantsRepository: ProxyConstantsRepository + + val externalAccountsSyncService: ExternalAccountsSyncService + + val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory + + val maxActionProviderFactory: MaxActionProviderFactory + + val automaticInteractionGate: AutomaticInteractionGate + + val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + + val assetSourceRegistry: AssetSourceRegistry + + val balanceHoldsRepository: BalanceHoldsRepository + + val amountFormatter: AmountFormatter + + val chainMigrationInfoUseCase: ChainMigrationInfoUseCase + + val assetIconProvider: AssetIconProvider + + fun contextManager(): ContextManager + + fun computationalCache(): ComputationalCache + + fun accountRepository(): AccountRepository + + fun storageCache(): StorageCache + + fun addressIconGenerator(): AddressIconGenerator + + fun appLinksProvider(): AppLinksProvider + + fun walletRepository(): WalletRepository + + fun tokenRepository(): TokenRepository + + fun resourceManager(): ResourceManager + + fun extrinsicBuilderFactory(): ExtrinsicBuilderFactory + + fun substrateCalls(): RpcCalls + + fun externalAccountActions(): ExternalActions.Presentation + + fun assetCache(): AssetCache + + fun accountStakingDao(): AccountStakingDao + + fun accountUpdateScope(): AccountUpdateScope + + fun stakingTotalRewardsDao(): StakingTotalRewardDao + + fun networkApiCreator(): NetworkApiCreator + + fun httpExceptionHandler(): HttpExceptionHandler + + fun walletConstants(): WalletConstants + + fun gson(): Gson + + fun addressxDisplayUseCase(): AddressDisplayUseCase + + fun extrinsicService(): ExtrinsicService + + fun validationExecutor(): ValidationExecutor + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource + + @Named(LOCAL_STORAGE_SOURCE) + fun localStorageSource(): StorageDataSource + + @LocalIdentity + fun localIdentity(): IdentityProvider + + fun chainRegistry(): ChainRegistry + + fun imageLoader(): ImageLoader + + fun preferences(): Preferences + + fun feeLoaderMixinFactory(): FeeLoaderMixin.Factory + + fun sharedPreferences(): SharedPreferences + + fun stakingRewardPeriodDao(): StakingRewardPeriodDao + + fun enoughTotalToStayAboveEDValidationFactory(): EnoughTotalToStayAboveEDValidationFactory + + fun addressInputMixinFactory(): AddressInputMixinFactory + + @Caching + fun cachingIconGenerator(): AddressIconGenerator + + fun globalConfigDataSource(): GlobalConfigDataSource +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureHolder.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureHolder.kt new file mode 100644 index 0000000..344fe20 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureHolder.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_staking_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_ahm_api.di.ChainMigrationFeatureApi +import io.novafoundation.nova.feature_dapp_api.di.DAppFeatureApi +import io.novafoundation.nova.feature_proxy_api.di.ProxyFeatureApi +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class StakingFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: StakingRouter, + private val parachainStakingRouter: ParachainStakingRouter, + private val nominationPoolsRouter: NominationPoolsRouter, + private val startMultiStakingRouter: StartMultiStakingRouter, + private val stakingDashboardRouter: StakingDashboardRouter, + private val mythosStakingRouter: MythosStakingRouter, + private val selectAddressCommunicator: SelectAddressCommunicator, + private val selectCollatorInterScreenCommunicator: SelectCollatorInterScreenCommunicator, + private val selectCollatorSettingsInterScreenCommunicator: SelectCollatorSettingsInterScreenCommunicator, + private val selectMythosCollatorInterScreenCommunicator: SelectMythosInterScreenCommunicator, + private val selectMythosCollatorSettingsInterScreenCommunicator: SelectMythCollatorSettingsInterScreenCommunicator, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerStakingFeatureComponent_StakingFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .dbApi(getFeature(DbApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .proxyFeatureApi(getFeature(ProxyFeatureApi::class.java)) + .dAppFeatureApi(getFeature(DAppFeatureApi::class.java)) + .chainMigrationFeatureApi(getFeature(ChainMigrationFeatureApi::class.java)) + .build() + + return DaggerStakingFeatureComponent.factory() + .create( + router = router, + parachainStaking = parachainStakingRouter, + mythosStakingRouter = mythosStakingRouter, + selectCollatorInterScreenCommunicator = selectCollatorInterScreenCommunicator, + selectCollatorSettingsInterScreenCommunicator = selectCollatorSettingsInterScreenCommunicator, + selectAddressCommunicator = selectAddressCommunicator, + nominationPoolsRouter = nominationPoolsRouter, + startMultiStakingRouter = startMultiStakingRouter, + stakingDashboardRouter = stakingDashboardRouter, + selectMythosCollatorInterScreenCommunicator = selectMythosCollatorInterScreenCommunicator, + selectMythosCollatorSettingsInterScreenCommunicator = selectMythosCollatorSettingsInterScreenCommunicator, + deps = dependencies + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureModule.kt new file mode 100644 index 0000000..d025836 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingFeatureModule.kt @@ -0,0 +1,710 @@ +package io.novafoundation.nova.feature_staking_impl.di + +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.AccountStakingDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.dao.StakingRewardPeriodDao +import io.novafoundation.nova.core_db.dao.StakingTotalRewardDao +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_account_api.domain.account.identity.LocalIdentity +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_proxy_api.data.common.ProxyDepositCalculator +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.data.repository.ProxyConstantsRepository +import io.novafoundation.nova.feature_staking_api.data.network.blockhain.updaters.PooledBalanceUpdaterFactory +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayUseCase +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.dashboard.repository.StakingDashboardRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.StakingApi +import io.novafoundation.nova.feature_staking_impl.data.network.subquery.SubQueryValidatorSetFetcher +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.RealPooledBalanceUpdaterFactory +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolDelegatedStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolMembersRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator +import io.novafoundation.nova.feature_staking_impl.data.repository.BagListRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.LocalBagListRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.ParasRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.PayoutRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.RealParasRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.RealSessionRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.RealStakingPeriodRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.RealStakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.RealStakingVersioningRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.RealVaraRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.SessionRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingPeriodRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRepositoryImpl +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingVersioningRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.VaraRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.AuraSession +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.BabeSession +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSessionRegistry +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.RealElectionsSessionRegistry +import io.novafoundation.nova.feature_staking_impl.data.repository.datasource.RealStakingRewardPeriodDataSource +import io.novafoundation.nova.feature_staking_impl.data.repository.datasource.StakingRewardPeriodDataSource +import io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward.DirectStakingRewardsDataSource +import io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward.PoolStakingRewardsDataSource +import io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward.RealStakingRewardsDataSourceRegistry +import io.novafoundation.nova.feature_staking_impl.data.repository.datasource.reward.StakingRewardsDataSourceRegistry +import io.novafoundation.nova.feature_staking_impl.data.validators.NovaValidatorsApi +import io.novafoundation.nova.feature_staking_impl.data.validators.RemoteValidatorsPreferencesSource +import io.novafoundation.nova.feature_staking_impl.data.validators.ValidatorsPreferencesSource +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureModule.BindsModule +import io.novafoundation.nova.feature_staking_impl.di.deeplinks.DeepLinkModule +import io.novafoundation.nova.feature_staking_impl.di.staking.DefaultBulkRetriever +import io.novafoundation.nova.feature_staking_impl.di.staking.PayoutsBulkRetriever +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.alerts.AlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.EraTimeCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.common.RealStakingHoldsMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingHoldsMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.era.StakingEraInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.payout.PayoutInteractor +import io.novafoundation.nova.feature_staking_impl.domain.period.RealStakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.setup.ChangeValidatorsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.controller.ControllerInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.AddStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.RealAddStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.list.RealStakingProxyListInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.list.StakingProxyListInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.remove.RealRemoveStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.remove.RemoveStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.rebond.RebondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.rewardDestination.ChangeRewardDestinationInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.RealStakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.domain.validators.ValidatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.validators.current.CurrentValidatorsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validators.current.search.SearchCustomValidatorsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.common.hints.StakingHintsUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination.RewardDestinationMixin +import io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination.RewardDestinationProvider +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.StakingDashboardPresentationMapperFactory +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.display.RealPoolDisplayUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.CompoundStakingComponentFactory +import io.novafoundation.nova.feature_wallet_api.di.common.AssetUseCaseModule +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +const val PAYOUTS_BULK_RETRIEVER_PAGE_SIZE = 500 +const val DEFAULT_BULK_RETRIEVER_PAGE_SIZE = 1000 + +@Module(includes = [AssetUseCaseModule::class, DeepLinkModule::class, BindsModule::class]) +class StakingFeatureModule { + + @Module + interface BindsModule { + + @Binds + fun bindHoldsMigrationUseCase(real: RealStakingHoldsMigrationUseCase): StakingHoldsMigrationUseCase + } + + @Provides + @FeatureScope + fun provideTimelineDelegatingHolder(stakingSharedState: StakingSharedState) = DelegateToTimelineChainIdHolder(stakingSharedState) + + @Provides + @FeatureScope + fun provideStakingEraInteractorFactory( + roundDurationEstimator: RoundDurationEstimator, + stakingSharedComputation: StakingSharedComputation, + stakingConstantsRepository: StakingConstantsRepository, + mythosSharedComputation: MythosSharedComputation, + mythosStakingRepository: MythosStakingRepository, + ) = StakingEraInteractorFactory( + roundDurationEstimator = roundDurationEstimator, + stakingSharedComputation = stakingSharedComputation, + stakingConstantsRepository = stakingConstantsRepository, + mythosSharedComputation = mythosSharedComputation, + mythosStakingRepository = mythosStakingRepository + ) + + @Provides + @FeatureScope + fun provideFeeLoaderMixin( + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + tokenUseCase: TokenUseCase, + ): FeeLoaderMixin.Presentation = feeLoaderMixinFactory.create(tokenUseCase) + + @Provides + @FeatureScope + fun provideStakingSharedState() = StakingSharedState() + + @Provides + @FeatureScope + fun provideSelectableSharedState(stakingSharedState: StakingSharedState): SelectedAssetOptionSharedState<*> = stakingSharedState + + @Provides + @FeatureScope + fun provideStakingRepository( + accountStakingDao: AccountStakingDao, + @Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource, + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + walletConstants: WalletConstants, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi + ): StakingRepository = StakingRepositoryImpl( + accountStakingDao = accountStakingDao, + remoteStorage = remoteStorageSource, + localStorage = localStorageSource, + walletConstants = walletConstants, + chainRegistry = chainRegistry, + storageCache = storageCache, + multiChainRuntimeCallsApi = multiChainRuntimeCallsApi + ) + + @Provides + @FeatureScope + fun provideStakingSharedComputation( + computationalCache: ComputationalCache, + stakingRepository: StakingRepository, + rewardCalculatorFactory: RewardCalculatorFactory, + accountRepository: AccountRepository, + bagListRepository: BagListRepository, + totalIssuanceRepository: TotalIssuanceRepository, + eraTimeCalculatorFactory: EraTimeCalculatorFactory, + stakingConstantsRepository: StakingConstantsRepository + ) = StakingSharedComputation( + stakingRepository = stakingRepository, + computationalCache = computationalCache, + rewardCalculatorFactory = rewardCalculatorFactory, + accountRepository = accountRepository, + bagListRepository = bagListRepository, + totalIssuanceRepository = totalIssuanceRepository, + eraTimeCalculatorFactory = eraTimeCalculatorFactory, + stakingConstantsRepository = stakingConstantsRepository + ) + + @Provides + @FeatureScope + fun provideBagListRepository( + @Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource, + chainRegistry: ChainRegistry + ): BagListRepository = LocalBagListRepository(localStorageSource, chainRegistry) + + @Provides + @FeatureScope + fun provideParasRepository( + @Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource, + ): ParasRepository = RealParasRepository(localStorageSource) + + @Provides + @FeatureScope + fun provideStakingInteractor( + walletRepository: WalletRepository, + accountRepository: AccountRepository, + stakingRepository: StakingRepository, + stakingRewardsRepository: StakingRewardsRepository, + stakingConstantsRepository: StakingConstantsRepository, + identityRepository: OnChainIdentityRepository, + payoutRepository: PayoutRepository, + stakingSharedState: StakingSharedState, + assetUseCase: AssetUseCase, + stakingSharedComputation: StakingSharedComputation, + ) = StakingInteractor( + walletRepository, + accountRepository, + stakingRepository, + stakingRewardsRepository, + stakingConstantsRepository, + identityRepository, + stakingSharedState, + payoutRepository, + assetUseCase, + stakingSharedComputation, + ) + + @Provides + @FeatureScope + fun provideAuraConsensus( + chainRegistry: ChainRegistry, + @Named(LOCAL_STORAGE_SOURCE) storageDataSource: StorageDataSource, + ) = AuraSession(chainRegistry, storageDataSource) + + @Provides + @FeatureScope + fun provideBabeConsensus( + chainRegistry: ChainRegistry, + @Named(LOCAL_STORAGE_SOURCE) storageDataSource: StorageDataSource, + ) = BabeSession(storageDataSource, chainRegistry) + + @Provides + @FeatureScope + fun provideElectionsSessionRegistry( + auraSession: AuraSession, + babeSession: BabeSession + ): ElectionsSessionRegistry = RealElectionsSessionRegistry(babeSession, auraSession) + + @Provides + @FeatureScope + fun provideSessionRepository( + @Named(LOCAL_STORAGE_SOURCE) storageDataSource: StorageDataSource, + ): SessionRepository = RealSessionRepository(storageDataSource) + + @Provides + @FeatureScope + fun provideEraTimeCalculatorFactory( + stakingRepository: StakingRepository, + sessionRepository: SessionRepository, + chainStateRepository: ChainStateRepository, + electionsSessionRegistry: ElectionsSessionRegistry, + ) = EraTimeCalculatorFactory(stakingRepository, sessionRepository, chainStateRepository, electionsSessionRegistry) + + @Provides + @FeatureScope + fun provideAlertsInteractor( + stakingRepository: StakingRepository, + stakingConstantsRepository: StakingConstantsRepository, + walletRepository: WalletRepository, + bagListRepository: BagListRepository, + totalIssuanceRepository: TotalIssuanceRepository, + stakingSharedComputation: StakingSharedComputation, + ) = AlertsInteractor( + stakingRepository, + stakingConstantsRepository, + walletRepository, + stakingSharedComputation, + bagListRepository, + totalIssuanceRepository + ) + + @Provides + @FeatureScope + fun provideVaraRepository(chainRegistry: ChainRegistry): VaraRepository = RealVaraRepository(chainRegistry) + + @Provides + @FeatureScope + fun provideRewardCalculatorFactory( + repository: StakingRepository, + totalIssuanceRepository: TotalIssuanceRepository, + stakingSharedComputation: dagger.Lazy, + parasRepository: ParasRepository, + varaRepository: VaraRepository + ) = RewardCalculatorFactory( + stakingRepository = repository, + totalIssuanceRepository = totalIssuanceRepository, + shareStakingSharedComputation = stakingSharedComputation, + parasRepository = parasRepository, + varaRepository = varaRepository + ) + + @Provides + @FeatureScope + fun provideNovaValidatorsApi(apiCreator: NetworkApiCreator): NovaValidatorsApi { + return apiCreator.create(NovaValidatorsApi::class.java) + } + + @Provides + @FeatureScope + fun provideKnownNovaValidators( + novaValidatorsApi: NovaValidatorsApi, + chainRegistry: ChainRegistry + ): ValidatorsPreferencesSource = RemoteValidatorsPreferencesSource(novaValidatorsApi, chainRegistry) + + @Provides + @FeatureScope + fun provideValidatorRecommendatorFactory( + validatorProvider: ValidatorProvider, + computationalCache: ComputationalCache, + sharedState: StakingSharedState, + validatorsPreferencesSource: ValidatorsPreferencesSource + ) = ValidatorRecommenderFactory(validatorProvider, sharedState, computationalCache, validatorsPreferencesSource) + + @Provides + @FeatureScope + fun provideValidatorProvider( + stakingRepository: StakingRepository, + identityRepository: OnChainIdentityRepository, + rewardCalculatorFactory: RewardCalculatorFactory, + stakingConstantsRepository: StakingConstantsRepository, + stakingSharedComputation: StakingSharedComputation, + validatorsPreferencesSource: ValidatorsPreferencesSource + ) = ValidatorProvider( + stakingRepository = stakingRepository, + identityRepository = identityRepository, + rewardCalculatorFactory = rewardCalculatorFactory, + stakingConstantsRepository = stakingConstantsRepository, + stakingSharedComputation = stakingSharedComputation, + validatorsPreferencesSource = validatorsPreferencesSource + ) + + @Provides + @FeatureScope + fun provideStakingConstantsRepository( + chainRegistry: ChainRegistry, + runtimeCallsApi: MultiChainRuntimeCallsApi + ) = StakingConstantsRepository(chainRegistry, runtimeCallsApi) + + @Provides + @FeatureScope + fun provideRecommendationSettingsProviderFactory( + computationalCache: ComputationalCache, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = RecommendationSettingsProviderFactory( + computationalCache, + chainRegistry, + sharedState + ) + + @Provides + @FeatureScope + fun provideChangeValidatorsInteractor( + extrinsicService: ExtrinsicService, + sharedState: StakingSharedState, + ) = ChangeValidatorsInteractor(extrinsicService, sharedState) + + @Provides + @FeatureScope + fun provideSetupStakingSharedState() = SetupStakingSharedState() + + @Provides + fun provideRewardDestinationChooserMixin( + resourceManager: ResourceManager, + appLinksProvider: AppLinksProvider, + stakingInteractor: StakingInteractor, + iconGenerator: AddressIconGenerator, + accountDisplayUseCase: AddressDisplayUseCase, + sharedState: StakingSharedState, + ): RewardDestinationMixin.Presentation = RewardDestinationProvider( + resourceManager, + stakingInteractor, + iconGenerator, + appLinksProvider, + sharedState, + accountDisplayUseCase + ) + + @Provides + @FeatureScope + fun provideStakingRewardsApi(networkApiCreator: NetworkApiCreator): StakingApi { + return networkApiCreator.create(StakingApi::class.java) + } + + @Provides + @FeatureScope + fun provideDirectStakingRewardsDataSource( + stakingApi: StakingApi, + stakingTotalRewardDao: StakingTotalRewardDao, + ) = DirectStakingRewardsDataSource( + stakingApi = stakingApi, + stakingTotalRewardDao = stakingTotalRewardDao + ) + + @Provides + @FeatureScope + fun providePoolStakingRewardsDataSource( + stakingApi: StakingApi, + stakingTotalRewardDao: StakingTotalRewardDao, + ) = PoolStakingRewardsDataSource( + stakingApi = stakingApi, + stakingTotalRewardDao = stakingTotalRewardDao + ) + + @Provides + @FeatureScope + fun provideStakingRewardsDataSourceRegistry( + directStakingRewardsDataSource: DirectStakingRewardsDataSource, + poolStakingRewardsDataSource: PoolStakingRewardsDataSource + ): StakingRewardsDataSourceRegistry = RealStakingRewardsDataSourceRegistry( + directStakingRewardsDataSource = directStakingRewardsDataSource, + poolStakingRewardsDataSource = poolStakingRewardsDataSource + ) + + @Provides + @FeatureScope + fun provideStakingRewardsRepository( + rewardsDataSourceRegistry: StakingRewardsDataSourceRegistry + ): StakingRewardsRepository { + return RealStakingRewardsRepository(rewardsDataSourceRegistry) + } + + @Provides + @FeatureScope + fun provideValidatorSetFetcher( + stakingApi: StakingApi, + ): SubQueryValidatorSetFetcher { + return SubQueryValidatorSetFetcher(stakingApi) + } + + @Provides + @FeatureScope + @DefaultBulkRetriever + fun provideDefaultBulkRetriever(): BulkRetriever { + return BulkRetriever(DEFAULT_BULK_RETRIEVER_PAGE_SIZE) + } + + @Provides + @FeatureScope + @PayoutsBulkRetriever + fun providePayoutBulkRetriever(): BulkRetriever { + return BulkRetriever(PAYOUTS_BULK_RETRIEVER_PAGE_SIZE) + } + + @Provides + @FeatureScope + fun providePayoutRepository( + stakingRepository: StakingRepository, + validatorSetFetcher: SubQueryValidatorSetFetcher, + chainRegistry: ChainRegistry, + rpcCalls: RpcCalls, + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource + ): PayoutRepository { + return PayoutRepository(stakingRepository, validatorSetFetcher, chainRegistry, remoteStorageSource, rpcCalls) + } + + @Provides + @FeatureScope + fun providePayoutInteractor( + sharedState: StakingSharedState, + extrinsicService: ExtrinsicService, + ) = PayoutInteractor(sharedState, extrinsicService) + + @Provides + @FeatureScope + fun provideRedeemInteractor( + extrinsicService: ExtrinsicService, + stakingRepository: StakingRepository, + ) = RedeemInteractor(extrinsicService, stakingRepository) + + @Provides + @FeatureScope + fun provideRebondInteractor( + sharedState: StakingSharedState, + extrinsicService: ExtrinsicService, + ) = RebondInteractor(extrinsicService, sharedState) + + @Provides + @FeatureScope + fun provideStakingVersioningRepository(chainRegistry: ChainRegistry): StakingVersioningRepository { + return RealStakingVersioningRepository(chainRegistry) + } + + @Provides + @FeatureScope + fun provideControllerInteractor( + sharedState: StakingSharedState, + extrinsicService: ExtrinsicService, + stakingVersioningRepository: StakingVersioningRepository + ) = ControllerInteractor(extrinsicService, sharedState, stakingVersioningRepository) + + @Provides + @FeatureScope + fun provideCurrentValidatorsInteractor( + stakingRepository: StakingRepository, + stakingConstantsRepository: StakingConstantsRepository, + validatorProvider: ValidatorProvider, + stahingSharedState: StakingSharedState, + accountRepository: AccountRepository, + stakingSharedComputation: StakingSharedComputation + ) = CurrentValidatorsInteractor( + stakingRepository, + stakingConstantsRepository, + validatorProvider, + stahingSharedState, + accountRepository, + stakingSharedComputation, + ) + + @Provides + @FeatureScope + fun provideChangeRewardDestinationInteractor( + extrinsicService: ExtrinsicService, + ) = ChangeRewardDestinationInteractor(extrinsicService) + + @Provides + @FeatureScope + fun provideSearchCustomValidatorsInteractor( + validatorProvider: ValidatorProvider, + sharedState: StakingSharedState + ) = SearchCustomValidatorsInteractor(validatorProvider, sharedState) + + @Provides + @FeatureScope + fun provideStakingHintsUseCase( + resourceManager: ResourceManager, + stakingInteractor: StakingInteractor + ) = StakingHintsUseCase(resourceManager, stakingInteractor) + + @Provides + @FeatureScope + fun provideCompoundStatefullComponent( + sharedState: StakingSharedState, + ) = CompoundStakingComponentFactory(sharedState) + + @Provides + @FeatureScope + fun provideStakingRewardPeriodDataSource( + stakingRewardPeriodDao: StakingRewardPeriodDao + ): StakingRewardPeriodDataSource = RealStakingRewardPeriodDataSource(stakingRewardPeriodDao) + + @Provides + @FeatureScope + fun provideStakingPeriodRepository( + dataSource: StakingRewardPeriodDataSource + ): StakingPeriodRepository = RealStakingPeriodRepository(dataSource) + + @Provides + @FeatureScope + fun provideStakingRewardInteractor( + stakingPeriodRepository: StakingPeriodRepository, + accountRepository: AccountRepository + ): StakingRewardPeriodInteractor = RealStakingRewardPeriodInteractor( + stakingPeriodRepository, + accountRepository + ) + + @Provides + @FeatureScope + fun provideStakingDashboardPresentationMapper( + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider + ): StakingDashboardPresentationMapperFactory { + return StakingDashboardPresentationMapperFactory(resourceManager, assetIconProvider) + } + + @Provides + @FeatureScope + fun providePooledBalanceUpdaterFactory( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + poolAccountDerivation: PoolAccountDerivation, + externalBalanceDao: ExternalBalanceDao, + scope: AccountUpdateScope, + ): PooledBalanceUpdaterFactory { + return RealPooledBalanceUpdaterFactory( + remoteStorageSource = remoteStorageSource, + poolAccountDerivation = poolAccountDerivation, + externalBalanceDao = externalBalanceDao, + scope = scope + ) + } + + @Provides + @FeatureScope + fun providePoolDisplayUseCase( + poolDisplayFormatter: PoolDisplayFormatter, + poolAccountDerivation: PoolAccountDerivation, + poolStateRepository: NominationPoolStateRepository, + ): PoolDisplayUseCase = RealPoolDisplayUseCase( + poolDisplayFormatter = poolDisplayFormatter, + poolAccountDerivation = poolAccountDerivation, + poolStateRepository = poolStateRepository + ) + + @Provides + @FeatureScope + fun provideStakingStartedDetectionService( + stakingDashboardRepository: StakingDashboardRepository, + computationalCache: ComputationalCache, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + ): StakingStartedDetectionService = RealStakingStartedDetectionService( + stakingDashboardRepository = stakingDashboardRepository, + computationalCache = computationalCache, + accountRepository = accountRepository, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideAddProxyRepository( + extrinsicService: ExtrinsicService, + proxyDepositCalculator: ProxyDepositCalculator, + getProxyRepository: GetProxyRepository, + proxyConstantsRepository: ProxyConstantsRepository, + externalAccountsSyncService: ExternalAccountsSyncService, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + ): AddStakingProxyInteractor { + return RealAddStakingProxyInteractor( + extrinsicService, + proxyDepositCalculator, + getProxyRepository, + proxyConstantsRepository, + externalAccountsSyncService, + extrinsicNavigationWrapper = extrinsicNavigationWrapper + ) + } + + @Provides + @FeatureScope + fun provideStakingProxyListInteractor( + getProxyRepository: GetProxyRepository, + @LocalIdentity identityProvider: IdentityProvider + ): StakingProxyListInteractor = RealStakingProxyListInteractor( + getProxyRepository, + identityProvider + ) + + @Provides + @FeatureScope + fun removeStakingProxyInteractor( + extrinsicService: ExtrinsicService, + externalAccountsSyncService: ExternalAccountsSyncService, + ): RemoveStakingProxyInteractor = RealRemoveStakingProxyInteractor( + extrinsicService = extrinsicService, + externalAccountsSyncService = externalAccountsSyncService + ) + + @Provides + @FeatureScope + fun provideStakingConflictsValidationFactory( + stakingRepository: StakingRepository, + delegatedStakeRepository: NominationPoolDelegatedStakeRepository, + nominationPoolStakingRepository: NominationPoolMembersRepository + ): StakingTypesConflictValidationFactory { + return StakingTypesConflictValidationFactory( + stakingRepository = stakingRepository, + delegatedStakeRepository = delegatedStakeRepository, + nominationPoolStakingRepository = nominationPoolStakingRepository + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingValidationModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingValidationModule.kt new file mode 100644 index 0000000..431d5ed --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/StakingValidationModule.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.di + +import dagger.Module +import io.novafoundation.nova.feature_staking_impl.di.validations.AddStakingProxyValidationsModule +import io.novafoundation.nova.feature_staking_impl.di.validations.BondMoreValidationsModule +import io.novafoundation.nova.feature_staking_impl.di.validations.MakePayoutValidationsModule +import io.novafoundation.nova.feature_staking_impl.di.validations.RebondValidationsModule +import io.novafoundation.nova.feature_staking_impl.di.validations.RedeemValidationsModule +import io.novafoundation.nova.feature_staking_impl.di.validations.RemoveStakingProxyValidationsModule +import io.novafoundation.nova.feature_staking_impl.di.validations.RewardDestinationValidationsModule +import io.novafoundation.nova.feature_staking_impl.di.validations.SetControllerValidationsModule +import io.novafoundation.nova.feature_staking_impl.di.validations.SetupStakingValidationsModule +import io.novafoundation.nova.feature_staking_impl.di.validations.StakeActionsValidationModule +import io.novafoundation.nova.feature_staking_impl.di.validations.UnbondValidationsModule + +@Module( + includes = [ + MakePayoutValidationsModule::class, + SetupStakingValidationsModule::class, + BondMoreValidationsModule::class, + UnbondValidationsModule::class, + RedeemValidationsModule::class, + RebondValidationsModule::class, + SetControllerValidationsModule::class, + RewardDestinationValidationsModule::class, + StakeActionsValidationModule::class, + AddStakingProxyValidationsModule::class, + RemoveStakingProxyValidationsModule::class + ] +) +class StakingValidationModule diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/deeplinks/DeepLinkModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/deeplinks/DeepLinkModule.kt new file mode 100644 index 0000000..89aa44a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/deeplinks/DeepLinkModule.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.di.deeplinks + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_staking_api.di.deeplinks.StakingDeepLinks +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.deeplink.StakingDashboardDeepLinkHandler + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideStakingDashboardDeepLinkHandler( + router: StakingRouter, + automaticInteractionGate: AutomaticInteractionGate + ) = StakingDashboardDeepLinkHandler( + router, + automaticInteractionGate + ) + + @Provides + @FeatureScope + fun provideDeepLinks(stakingDashboard: StakingDashboardDeepLinkHandler): StakingDeepLinks { + return StakingDeepLinks(listOf(stakingDashboard)) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/BulkRetrieverType.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/BulkRetrieverType.kt new file mode 100644 index 0000000..a05e7a9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/BulkRetrieverType.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class PayoutsBulkRetriever + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class DefaultBulkRetriever diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/UpdatersModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/UpdatersModule.kt new file mode 100644 index 0000000..c15f5e6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/UpdatersModule.kt @@ -0,0 +1,124 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdateSystem +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdaters +import io.novafoundation.nova.feature_staking_impl.di.staking.mythos.Mythos +import io.novafoundation.nova.feature_staking_impl.di.staking.mythos.MythosStakingUpdatersModule +import io.novafoundation.nova.feature_staking_impl.di.staking.nominationPool.NominationPoolStakingUpdatersModule +import io.novafoundation.nova.feature_staking_impl.di.staking.nominationPool.NominationPools +import io.novafoundation.nova.feature_staking_impl.di.staking.parachain.Parachain +import io.novafoundation.nova.feature_staking_impl.di.staking.parachain.ParachainStakingUpdatersModule +import io.novafoundation.nova.feature_staking_impl.di.staking.parachain.turing.Turing +import io.novafoundation.nova.feature_staking_impl.di.staking.parachain.turing.TuringStakingUpdatersModule +import io.novafoundation.nova.feature_staking_impl.di.staking.relaychain.Relaychain +import io.novafoundation.nova.feature_staking_impl.di.staking.relaychain.RelaychainStakingUpdatersModule +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.BlockTimeUpdater +import io.novafoundation.nova.runtime.network.updaters.SharedAssetBlockNumberUpdater +import io.novafoundation.nova.runtime.network.updaters.TotalIssuanceUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.AsSharedStateUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimeLineChainUpdater +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder +import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named +import javax.inject.Qualifier + +@Module( + includes = [ + RelaychainStakingUpdatersModule::class, + ParachainStakingUpdatersModule::class, + TuringStakingUpdatersModule::class, + NominationPoolStakingUpdatersModule::class, + MythosStakingUpdatersModule::class + ] +) +class UpdatersModule { + + @Provides + @CommonUpdaters + @FeatureScope + fun provideCommonUpdaters( + blockTimeUpdater: BlockTimeUpdater, + blockNumberUpdater: SharedAssetBlockNumberUpdater, + totalIssuanceUpdater: TotalIssuanceUpdater + ) = StakingUpdaters.Group( + DelegateToTimeLineChainUpdater(blockTimeUpdater), + DelegateToTimeLineChainUpdater(blockNumberUpdater), + AsSharedStateUpdater(totalIssuanceUpdater) + ) + + @Provides + @FeatureScope + fun provideStakingUpdaters( + @Relaychain relaychainUpdaters: StakingUpdaters.Group, + @Parachain parachainUpdaters: StakingUpdaters.Group, + @Turing turingUpdaters: StakingUpdaters.Group, + @NominationPools nominationPoolsUpdaters: StakingUpdaters.Group, + @Mythos mythosUpdaters: StakingUpdaters.Group, + @CommonUpdaters commonUpdaters: StakingUpdaters.Group + ): StakingUpdaters { + return StakingUpdaters( + relaychainUpdaters = relaychainUpdaters, + parachainUpdaters = parachainUpdaters, + commonUpdaters = commonUpdaters, + turingExtraUpdaters = turingUpdaters, + nominationPoolsUpdaters = nominationPoolsUpdaters, + mythosUpdaters = mythosUpdaters + ) + } + + @Provides + @FeatureScope + fun provideStakingUpdateSystem( + stakingUpdaters: StakingUpdaters, + chainRegistry: ChainRegistry, + singleAssetSharedState: StakingSharedState, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + ) = StakingUpdateSystem( + stakingUpdaters = stakingUpdaters, + chainRegistry = chainRegistry, + stakingSharedState = singleAssetSharedState, + storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory + ) + + @Provides + @FeatureScope + fun blockTimeUpdater( + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + chainRegistry: ChainRegistry, + sampledBlockTimeStorage: SampledBlockTimeStorage, + @Named(REMOTE_STORAGE_SOURCE) remoteStorage: StorageDataSource, + ) = BlockTimeUpdater(timelineDelegatingChainIdHolder, chainRegistry, sampledBlockTimeStorage, remoteStorage) + + @Provides + @FeatureScope + fun provideBlockNumberUpdater( + chainRegistry: ChainRegistry, + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + storageCache: StorageCache, + ) = SharedAssetBlockNumberUpdater(chainRegistry, timelineDelegatingChainIdHolder, storageCache) + + @Provides + @FeatureScope + fun provideTotalInsuranceUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = TotalIssuanceUpdater( + sharedState, + storageCache, + chainRegistry + ) +} + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class CommonUpdaters diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/dashboard/StakingDashboardModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/dashboard/StakingDashboardModule.kt new file mode 100644 index 0000000..7e4e3f8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/dashboard/StakingDashboardModule.kt @@ -0,0 +1,124 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.dashboard + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.config.GlobalConfigDataSource +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.StakingDashboardDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_staking_api.data.dashboard.StakingDashboardUpdateSystem +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.domain.dashboard.StakingDashboardInteractor +import io.novafoundation.nova.feature_staking_impl.data.dashboard.cache.RealStakingDashboardCache +import io.novafoundation.nova.feature_staking_impl.data.dashboard.cache.StakingDashboardCache +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.RealStakingStatsDataSource +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.StakingStatsDataSource +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.stats.api.StakingStatsApi +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.RealStakingDashboardUpdateSystem +import io.novafoundation.nova.feature_staking_impl.data.dashboard.network.updaters.chain.StakingDashboardUpdaterFactory +import io.novafoundation.nova.feature_staking_impl.data.dashboard.repository.RealStakingDashboardRepository +import io.novafoundation.nova.feature_staking_impl.data.dashboard.repository.RealTotalStakeChainComparatorProvider +import io.novafoundation.nova.feature_staking_impl.data.dashboard.repository.StakingDashboardRepository +import io.novafoundation.nova.feature_staking_impl.data.dashboard.repository.TotalStakeChainComparatorProvider +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.dashboard.RealStakingDashboardInteractor +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class StakingDashboardModule { + + @Provides + @FeatureScope + fun provideStakingDashboardRepository(dao: StakingDashboardDao): StakingDashboardRepository = RealStakingDashboardRepository(dao) + + @Provides + @FeatureScope + fun provideStakingStatsApi(apiCreator: NetworkApiCreator): StakingStatsApi { + return apiCreator.create(StakingStatsApi::class.java) + } + + @Provides + @FeatureScope + fun provideStakingStatsDataSource( + api: StakingStatsApi, + globalConfigDataSource: GlobalConfigDataSource + ): StakingStatsDataSource { + return RealStakingStatsDataSource( + api = api, + globalConfigDataSource = globalConfigDataSource + ) + } + + @Provides + @FeatureScope + fun provideStakingDashboardCache(dao: StakingDashboardDao): StakingDashboardCache = RealStakingDashboardCache(dao) + + @Provides + @FeatureScope + fun provideStakingDashboardUpdaterFactory( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + stakingDashboardCache: StakingDashboardCache, + nominationPoolBalanceRepository: NominationPoolStateRepository, + poolAccountDerivation: PoolAccountDerivation, + storageCache: StorageCache, + balanceLocksRepository: BalanceLocksRepository, + ) = StakingDashboardUpdaterFactory( + stakingDashboardCache = stakingDashboardCache, + remoteStorageSource = remoteStorageSource, + nominationPoolBalanceRepository = nominationPoolBalanceRepository, + poolAccountDerivation = poolAccountDerivation, + storageCache = storageCache, + balanceLocksRepository = balanceLocksRepository + ) + + @Provides + @FeatureScope + fun provideUpdateSystem( + stakingStatsDataSource: StakingStatsDataSource, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + updaterFactory: StakingDashboardUpdaterFactory, + sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + stakingDashboardRepository: StakingDashboardRepository, + ): StakingDashboardUpdateSystem = RealStakingDashboardUpdateSystem( + stakingStatsDataSource = stakingStatsDataSource, + accountRepository = accountRepository, + chainRegistry = chainRegistry, + updaterFactory = updaterFactory, + sharedRequestsBuilderFactory = sharedRequestsBuilderFactory, + stakingDashboardRepository = stakingDashboardRepository + ) + + @Provides + @FeatureScope + fun provideTotalStakeComparatorProvider(): TotalStakeChainComparatorProvider = RealTotalStakeChainComparatorProvider() + + @Provides + @FeatureScope + fun provideInteractor( + dashboardRepository: StakingDashboardRepository, + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + stakingDashboardUpdateSystem: StakingDashboardUpdateSystem, + dAppMetadataRepository: DAppMetadataRepository, + walletRepository: WalletRepository, + totalStakeChainComparatorProvider: TotalStakeChainComparatorProvider + ): StakingDashboardInteractor = RealStakingDashboardInteractor( + dashboardRepository = dashboardRepository, + chainRegistry = chainRegistry, + accountRepository = accountRepository, + stakingDashboardSyncTracker = stakingDashboardUpdateSystem, + walletRepository = walletRepository, + dAppMetadataRepository = dAppMetadataRepository, + totalStakeChainComparatorProvider = totalStakeChainComparatorProvider + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/Mythos.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/Mythos.kt new file mode 100644 index 0000000..b255cb5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/Mythos.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.mythos + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class Mythos diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/MythosBindsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/MythosBindsModule.kt new file mode 100644 index 0000000..8c2cda5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/MythosBindsModule.kt @@ -0,0 +1,108 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.mythos + +import dagger.Binds +import dagger.Module +import io.novafoundation.nova.feature_staking_api.data.mythos.MythosMainPotMatcherFactory +import io.novafoundation.nova.feature_staking_impl.data.mythos.RealMythosMainPotMatcherFactory +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosCandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosSessionRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.RealMythosCandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.RealMythosSessionRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.RealMythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.RealMythosUserStakeRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.RealStakingBlockNumberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingBlockNumberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.MythosClaimRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.RealMythosClaimRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosDelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.RealMythosDelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.collator.MythosCollatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.collator.RealMythosCollatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.rewards.MythosClaimPendingRewardsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.rewards.RealMythosClaimPendingRewardsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.MythosCurrentCollatorsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.RealMythosCurrentCollatorsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.alerts.MythosStakingAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.alerts.RealMythosStakingAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.stakeSummary.MythosStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.stakeSummary.RealMythosStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.unbonding.MythosUnbondingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.unbonding.RealMythosUnbondingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.userRewards.MythosUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.userRewards.RealMythosUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.MythosRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.RealMythosRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.RealStartMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.StartMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.RealUnbondMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.UnbondMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.MythosCollatorFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.RealMythosCollatorFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.RealMythosStakingValidationFailureFormatter + +@Module +interface MythosBindsModule { + + @Binds + fun bindUserStakeRepository(implementation: RealMythosUserStakeRepository): MythosUserStakeRepository + + @Binds + fun bindStakingRepository(implementation: RealMythosStakingRepository): MythosStakingRepository + + @Binds + fun bindSessionRepository(implementation: RealMythosSessionRepository): MythosSessionRepository + + @Binds + fun bindMythosCandidateRepository(implementation: RealMythosCandidatesRepository): MythosCandidatesRepository + + @Binds + fun bindCollatorProvider(implementation: RealMythosCollatorProvider): MythosCollatorProvider + + @Binds + fun bindUserStakeUseCase(implementation: RealMythosDelegatorStateUseCase): MythosDelegatorStateUseCase + + @Binds + fun bindStakeSummaryInteractor(implementation: RealMythosStakeSummaryInteractor): MythosStakeSummaryInteractor + + @Binds + fun bindUnbondingInteractor(implementation: RealMythosUnbondingInteractor): MythosUnbondingInteractor + + @Binds + fun bindAlertsInteractor(implementation: RealMythosStakingAlertsInteractor): MythosStakingAlertsInteractor + + @Binds + fun bindUserRewardsInteractor(implementation: RealMythosUserRewardsInteractor): MythosUserRewardsInteractor + + @Binds + fun bindRedeemInteractor(implementation: RealMythosRedeemInteractor): MythosRedeemInteractor + + @Binds + fun bindStartStakingInteractor(implementation: RealStartMythosStakingInteractor): StartMythosStakingInteractor + + @Binds + fun bindUnbondInteractor(implementation: RealUnbondMythosStakingInteractor): UnbondMythosStakingInteractor + + @Binds + fun bindClaimRewardsInteractor(implementation: RealMythosClaimRewardsInteractor): MythosClaimRewardsInteractor + + @Binds + fun bindCurrentCollatorsInteractor(implementation: RealMythosCurrentCollatorsInteractor): MythosCurrentCollatorsInteractor + + @Binds + fun bindMythosCollatorFormatter(implementation: RealMythosCollatorFormatter): MythosCollatorFormatter + + @Binds + fun bindBlockNumberUseCase(implementation: RealStakingBlockNumberUseCase): StakingBlockNumberUseCase + + @Binds + fun bindValidationFormatter(implementation: RealMythosStakingValidationFailureFormatter): MythosStakingValidationFailureFormatter + + @Binds + fun bindMainPotMatcherFactory(implementation: RealMythosMainPotMatcherFactory): MythosMainPotMatcherFactory + + @Binds + fun bindMythosClaimPendingRewardsUseCase(implementation: RealMythosClaimPendingRewardsUseCase): MythosClaimPendingRewardsUseCase +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/MythosModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/MythosModule.kt new file mode 100644 index 0000000..f0d2771 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/MythosModule.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.mythos + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.validations.MythosClaimRewardsValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.validations.mythosClaimRewards +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.validations.RedeemMythosValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.validations.mythosRedeem +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.MythosMinimumDelegationValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.StartMythosStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.mythosStakingStart +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.MythosReleaseRequestLimitNotReachedValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.UnbondMythosValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.mythosUnbond + +@Module(includes = [MythosBindsModule::class]) +class MythosModule { + + @Provides + @FeatureScope + fun provideStartStakingValidationSystem( + minimumDelegationValidationFactory: MythosMinimumDelegationValidationFactory, + ): StartMythosStakingValidationSystem { + return ValidationSystem.mythosStakingStart(minimumDelegationValidationFactory) + } + + @Provides + @FeatureScope + fun provideUnbondValidationSystem( + releaseRequestLimitNotReachedValidation: MythosReleaseRequestLimitNotReachedValidationFactory + ): UnbondMythosValidationSystem { + return ValidationSystem.mythosUnbond(releaseRequestLimitNotReachedValidation) + } + + @Provides + @FeatureScope + fun provideRedeemValidationSystem(): RedeemMythosValidationSystem { + return ValidationSystem.mythosRedeem() + } + + @Provides + @FeatureScope + fun provideClaimRewardsValidationSystem(): MythosClaimRewardsValidationSystem { + return ValidationSystem.mythosClaimRewards() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/MythosStakingUpdatersModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/MythosStakingUpdatersModule.kt new file mode 100644 index 0000000..0e1bc55 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/mythos/MythosStakingUpdatersModule.kt @@ -0,0 +1,161 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.mythos + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.updaters.MythosCollatorRewardPercentageUpdater +import io.novafoundation.nova.feature_staking_impl.data.mythos.updaters.MythosCompoundPercentageUpdater +import io.novafoundation.nova.feature_staking_impl.data.mythos.updaters.MythosExtraRewardUpdater +import io.novafoundation.nova.feature_staking_impl.data.mythos.updaters.MythosMinStakeUpdater +import io.novafoundation.nova.feature_staking_impl.data.mythos.updaters.MythosReleaseQueuesUpdater +import io.novafoundation.nova.feature_staking_impl.data.mythos.updaters.MythosSelectedCandidatesUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdaters +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.CurrentSlotUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.InvulnerablesUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.SessionValidatorsUpdater +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class MythosStakingUpdatersModule { + + @Provides + @FeatureScope + fun provideSessionValidatorsUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = SessionValidatorsUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideInvulnerablesUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = InvulnerablesUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideMinStakeUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = MythosMinStakeUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideExtraRewardUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = MythosExtraRewardUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideCollatorRewardPercentageUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = MythosCollatorRewardPercentageUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideSelectedCandidatesUpdater( + scope: AccountUpdateScope, + stakingSharedState: StakingSharedState, + storageCache: StorageCache, + mythosUserStakeRepository: MythosUserStakeRepository, + @Named(REMOTE_STORAGE_SOURCE) + remoteStorageDataSource: StorageDataSource, + ) = MythosSelectedCandidatesUpdater( + scope = scope, + stakingSharedState = stakingSharedState, + storageCache = storageCache, + mythosUserStakeRepository = mythosUserStakeRepository, + remoteStorageDataSource = remoteStorageDataSource + ) + + @Provides + @FeatureScope + fun provideReleaseQueuesUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + accountUpdateScope: AccountUpdateScope, + ) = MythosReleaseQueuesUpdater( + sharedState, + chainRegistry, + storageCache, + accountUpdateScope + ) + + @Provides + @FeatureScope + fun provideCompoundPercentageUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + accountUpdateScope: AccountUpdateScope, + ) = MythosCompoundPercentageUpdater( + sharedState, + chainRegistry, + storageCache, + accountUpdateScope + ) + + @Provides + @FeatureScope + @Mythos + fun provideMythosStakingUpdaters( + // UserStake in synced in-place in StakingDashboardMythosUpdater by dashboard + sessionValidatorsUpdater: SessionValidatorsUpdater, + invulnerablesUpdater: InvulnerablesUpdater, + minStakeUpdater: MythosMinStakeUpdater, + // For syncing aura session info + currentSlotUpdater: CurrentSlotUpdater, + selectedCandidatesUpdater: MythosSelectedCandidatesUpdater, + releaseQueuesUpdater: MythosReleaseQueuesUpdater, + extraRewardUpdater: MythosExtraRewardUpdater, + collatorRewardPercentageUpdater: MythosCollatorRewardPercentageUpdater, + compoundPercentageUpdater: MythosCompoundPercentageUpdater + ): StakingUpdaters.Group { + return StakingUpdaters.Group( + sessionValidatorsUpdater, + invulnerablesUpdater, + minStakeUpdater, + currentSlotUpdater, + selectedCandidatesUpdater, + releaseQueuesUpdater, + extraRewardUpdater, + collatorRewardPercentageUpdater, + compoundPercentageUpdater + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPoolModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPoolModule.kt new file mode 100644 index 0000000..36fec3c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPoolModule.kt @@ -0,0 +1,302 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.nominationPool + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.datasource.KnownMaxUnlockingOverwrites +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.datasource.RealKnownMaxUnlockingOverwrites +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.FixedKnownNovaPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.KnownNovaPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.PoolImageDataSource +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.PredefinedPoolImageDataSource +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.RealPoolAccountDerivation +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolDelegatedStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolMembersRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolUnbondRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolDelegatedStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolMembersRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolUnbondRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.RealNominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.DelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.RealDelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.hints.NominationPoolHintsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.hints.RealNominationPoolHintsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.rewards.NominationPoolRewardCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts.NominationPoolsAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts.RealNominationPoolsAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.networkInfo.NominationPoolsNetworkInfoInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.networkInfo.RealNominationPoolsNetworkInfoInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.stakeSummary.NominationPoolStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.stakeSummary.RealNominationPoolStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.unbondings.NominationPoolUnbondingsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.unbondings.RealNominationPoolUnbondingsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.userRewards.NominationPoolsUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.userRewards.RealNominationPoolsUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.yourPool.NominationPoolYourPoolInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.yourPool.RealNominationPoolYourPoolInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.NominationPoolProvider +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.RealNominationPoolProvider +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.recommendation.NominationPoolRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.RealPoolDisplayFormatter +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module(includes = [NominationPoolsValidationsModule::class]) +class NominationPoolModule { + + @Provides + @FeatureScope + fun providePoolAccountDerivation( + @Named(LOCAL_STORAGE_SOURCE) dataSource: StorageDataSource + ): PoolAccountDerivation = RealPoolAccountDerivation(dataSource) + + @Provides + @FeatureScope + fun provideKnownNovaPools(): KnownNovaPools = FixedKnownNovaPools() + + @Provides + @FeatureScope + fun providePoolImageDataSource(knownNovaPools: KnownNovaPools): PoolImageDataSource { + return PredefinedPoolImageDataSource(knownNovaPools) + } + + @Provides + @FeatureScope + fun providePoolDisplayFormatter( + addressIconGenerator: AddressIconGenerator + ): PoolDisplayFormatter = RealPoolDisplayFormatter(addressIconGenerator) + + @Provides + @FeatureScope + fun provideNominationPoolBalanceRepository( + @Named(LOCAL_STORAGE_SOURCE) localDataSource: StorageDataSource, + @Named(REMOTE_STORAGE_SOURCE) remoteDataSource: StorageDataSource, + poolImageDataSource: PoolImageDataSource, + ): NominationPoolStateRepository = RealNominationPoolStateRepository( + localStorage = localDataSource, + remoteStorage = remoteDataSource, + poolImageDataSource = poolImageDataSource, + ) + + @Provides + @FeatureScope + fun provideKnownMaxUnlockingOverwrites(): KnownMaxUnlockingOverwrites = RealKnownMaxUnlockingOverwrites() + + @Provides + @FeatureScope + fun provideNominationPoolGlobalsRepository( + @Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource, + knownMaxUnlockingOverwrites: KnownMaxUnlockingOverwrites, + stakingRepository: StakingConstantsRepository, + ): NominationPoolGlobalsRepository { + return RealNominationPoolGlobalsRepository(localStorageSource, knownMaxUnlockingOverwrites, stakingRepository) + } + + @Provides + @FeatureScope + fun provideNominationPoolMembersRepository( + @Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource, + @Named(REMOTE_STORAGE_SOURCE) remoteDataSource: StorageDataSource, + multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, + ): NominationPoolMembersRepository { + return RealNominationPoolMembersRepository(localStorageSource, remoteDataSource, multiChainRuntimeCallsApi) + } + + @Provides + @FeatureScope + fun provideNominationPoolUnbondRepository( + @Named(LOCAL_STORAGE_SOURCE) dataSource: StorageDataSource + ): NominationPoolUnbondRepository = RealNominationPoolUnbondRepository(dataSource) + + @Provides + @FeatureScope + fun provideNominationPoolMembersUseCase( + accountRepository: AccountRepository, + nominationPoolMembersRepository: NominationPoolMembersRepository, + stakingSharedState: StakingSharedState, + ): NominationPoolMemberUseCase { + return RealNominationPoolMemberUseCase( + accountRepository = accountRepository, + stakingSharedState = stakingSharedState, + nominationPoolMembersRepository = nominationPoolMembersRepository + ) + } + + @Provides + @FeatureScope + fun provideNetworkInfoInteractor( + relaychainStakingSharedComputation: StakingSharedComputation, + nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, + poolAccountDerivation: PoolAccountDerivation, + relaychainStakingInteractor: StakingInteractor, + nominationPoolMemberUseCase: NominationPoolMemberUseCase, + ): NominationPoolsNetworkInfoInteractor = RealNominationPoolsNetworkInfoInteractor( + relaychainStakingSharedComputation = relaychainStakingSharedComputation, + nominationPoolGlobalsRepository = nominationPoolGlobalsRepository, + poolAccountDerivation = poolAccountDerivation, + relaychainStakingInteractor = relaychainStakingInteractor, + nominationPoolMemberUseCase = nominationPoolMemberUseCase + ) + + @Provides + @FeatureScope + fun provideUnbondingsInteractor( + nominationPoolSharedComputation: NominationPoolSharedComputation, + stakingSharedComputation: StakingSharedComputation, + ): NominationPoolUnbondingsInteractor = RealNominationPoolUnbondingsInteractor( + nominationPoolSharedComputation = nominationPoolSharedComputation, + stakingSharedComputation = stakingSharedComputation, + ) + + @Provides + @FeatureScope + fun provideStakeSummaryInteractor( + stakingSharedComputation: StakingSharedComputation, + poolAccountDerivation: PoolAccountDerivation, + nominationPoolSharedComputation: NominationPoolSharedComputation, + ): NominationPoolStakeSummaryInteractor = RealNominationPoolStakeSummaryInteractor( + stakingSharedComputation = stakingSharedComputation, + poolAccountDerivation = poolAccountDerivation, + nominationPoolSharedComputation = nominationPoolSharedComputation + ) + + @Provides + @FeatureScope + fun provideNominationPoolRewardCalculatorFactory( + stakingSharedComputation: StakingSharedComputation, + nominationPoolSharedComputation: NominationPoolSharedComputation, + ): NominationPoolRewardCalculatorFactory { + return NominationPoolRewardCalculatorFactory( + sharedStakingSharedComputation = stakingSharedComputation, + nominationPoolSharedComputation = nominationPoolSharedComputation + ) + } + + @Provides + @FeatureScope + fun provideNominationPoolSharedComputation( + computationalCache: ComputationalCache, + nominationPoolMemberUseCase: NominationPoolMemberUseCase, + nominationPoolStateRepository: NominationPoolStateRepository, + nominationPoolUnbondRepository: NominationPoolUnbondRepository, + poolAccountDerivation: PoolAccountDerivation, + nominationPoolRewardCalculatorFactory: dagger.Lazy, + nominationPoolGlobalsRepository: NominationPoolGlobalsRepository + ): NominationPoolSharedComputation { + return NominationPoolSharedComputation( + computationalCache = computationalCache, + nominationPoolMemberUseCase = nominationPoolMemberUseCase, + nominationPoolStateRepository = nominationPoolStateRepository, + nominationPoolUnbondRepository = nominationPoolUnbondRepository, + poolAccountDerivation = poolAccountDerivation, + nominationPoolRewardCalculatorFactory = nominationPoolRewardCalculatorFactory, + nominationPoolGlobalsRepository = nominationPoolGlobalsRepository + ) + } + + @Provides + @FeatureScope + fun provideUserRewardsInteractor( + repository: NominationPoolMembersRepository, + stakingRewardsRepository: StakingRewardsRepository, + ): NominationPoolsUserRewardsInteractor = RealNominationPoolsUserRewardsInteractor(repository, stakingRewardsRepository) + + @Provides + @FeatureScope + fun provideYourPoolInteractor( + poolAccountDerivation: PoolAccountDerivation, + poolStateRepository: NominationPoolStateRepository, + ): NominationPoolYourPoolInteractor = RealNominationPoolYourPoolInteractor(poolAccountDerivation, poolStateRepository) + + @Provides + @FeatureScope + fun provideAlertsInteractor( + nominationPoolsSharedComputation: NominationPoolSharedComputation, + stakingSharedComputation: StakingSharedComputation, + poolAccountDerivation: PoolAccountDerivation, + ): NominationPoolsAlertsInteractor = RealNominationPoolsAlertsInteractor( + nominationPoolsSharedComputation = nominationPoolsSharedComputation, + stakingSharedComputation = stakingSharedComputation, + poolAccountDerivation = poolAccountDerivation + ) + + @Provides + @FeatureScope + fun provideHintsUseCase( + stakingSharedState: StakingSharedState, + poolMembersRepository: NominationPoolMembersRepository, + accountRepository: AccountRepository, + resourceManager: ResourceManager, + ): NominationPoolHintsUseCase = RealNominationPoolHintsUseCase( + stakingSharedState = stakingSharedState, + poolMembersRepository = poolMembersRepository, + accountRepository = accountRepository, + resourceManager = resourceManager + ) + + @Provides + @FeatureScope + fun provideNominationPoolRecommendatorFactory( + computationalCache: ComputationalCache, + nominationPoolProvider: NominationPoolProvider, + knownNovaPools: KnownNovaPools, + nominationPoolGlobalsRepository: NominationPoolGlobalsRepository + ) = NominationPoolRecommenderFactory( + computationalCache = computationalCache, + nominationPoolProvider = nominationPoolProvider, + knownNovaPools = knownNovaPools, + nominationPoolGlobalsRepository = nominationPoolGlobalsRepository + ) + + @Provides + @FeatureScope + fun provideNominationPoolProvider( + nominationPoolSharedComputation: NominationPoolSharedComputation, + nominationPoolStateRepository: NominationPoolStateRepository, + poolStateRepository: NominationPoolStateRepository + ): NominationPoolProvider = RealNominationPoolProvider( + nominationPoolSharedComputation = nominationPoolSharedComputation, + nominationPoolStateRepository = nominationPoolStateRepository, + poolStateRepository = poolStateRepository + ) + + @Provides + @FeatureScope + fun provideNominationPoolDelegatedStakeRepository( + @Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource, + chainRegistry: ChainRegistry + ): NominationPoolDelegatedStakeRepository { + return RealNominationPoolDelegatedStakeRepository(localStorageSource, chainRegistry) + } + + @Provides + @FeatureScope + fun provideDelegatedStakeMigrationUseCase( + delegatedStakeRepository: NominationPoolDelegatedStakeRepository, + stakingSharedState: StakingSharedState, + accountRepository: AccountRepository + ): DelegatedStakeMigrationUseCase { + return RealDelegatedStakeMigrationUseCase(delegatedStakeRepository, stakingSharedState, accountRepository) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPoolStakingUpdatersModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPoolStakingUpdatersModule.kt new file mode 100644 index 0000000..f0caa1e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPoolStakingUpdatersModule.kt @@ -0,0 +1,186 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.nominationPool + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ActiveEraUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.CurrentEraUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ParachainsUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdaters +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ValidatorExposureUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.BondedErasUpdaterUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.CurrentEpochIndexUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.CurrentSessionIndexUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.CurrentSlotUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.EraStartSessionIndexUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.GenesisSlotUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.CounterForPoolMembersUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.DelegatedStakeUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.LastPoolIdUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.MaxPoolMembersPerPoolUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.MaxPoolMembersUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.MinJoinBondUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.PoolMetadataUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.SubPoolsUpdater +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.updater.scope.PoolScope +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class NominationPoolStakingUpdatersModule { + + @Provides + @FeatureScope + fun providePoolScope( + nominationPoolSharedComputation: NominationPoolSharedComputation, + stakingSharedState: StakingSharedState + ) = PoolScope(nominationPoolSharedComputation, stakingSharedState) + + @Provides + @FeatureScope + fun provideLastPoolIdUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = LastPoolIdUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideDelegatedStakeUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + scope: AccountUpdateScope + ) = DelegatedStakeUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry, + scope = scope + ) + + @Provides + @FeatureScope + fun provideMinJoinBondUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = MinJoinBondUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideMaxPoolMembersUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = MaxPoolMembersUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideMaxPoolMembersPerPoolUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = MaxPoolMembersPerPoolUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideCounterForPoolMembersUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = CounterForPoolMembersUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideSubPoolsUpdater( + poolScope: PoolScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = SubPoolsUpdater( + poolScope = poolScope, + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun providePoolMetadataUpdater( + poolScope: PoolScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = PoolMetadataUpdater( + poolScope = poolScope, + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @NominationPools + @FeatureScope + fun provideNominationPoolStakingUpdaters( + lastPoolIdUpdater: LastPoolIdUpdater, + minJoinBondUpdater: MinJoinBondUpdater, + poolMetadataUpdater: PoolMetadataUpdater, + exposureUpdater: ValidatorExposureUpdater, + subPoolsUpdater: SubPoolsUpdater, + maxPoolMembersUpdater: MaxPoolMembersUpdater, + maxPoolMembersPerPoolUpdater: MaxPoolMembersPerPoolUpdater, + counterForPoolMembersUpdater: CounterForPoolMembersUpdater, + activeEraUpdater: ActiveEraUpdater, + currentEraUpdater: CurrentEraUpdater, + currentEpochIndexUpdater: CurrentEpochIndexUpdater, + currentSlotUpdater: CurrentSlotUpdater, + genesisSlotUpdater: GenesisSlotUpdater, + currentSessionIndexUpdater: CurrentSessionIndexUpdater, + eraStartSessionIndexUpdater: EraStartSessionIndexUpdater, + bondedErasUpdaterUpdater: BondedErasUpdaterUpdater, + parachainsUpdater: ParachainsUpdater, + delegatedStakeUpdater: DelegatedStakeUpdater, + ) = StakingUpdaters.Group( + lastPoolIdUpdater, + minJoinBondUpdater, + poolMetadataUpdater, + exposureUpdater, + activeEraUpdater, + currentEraUpdater, + subPoolsUpdater, + maxPoolMembersUpdater, + maxPoolMembersPerPoolUpdater, + counterForPoolMembersUpdater, + currentEpochIndexUpdater, + currentSlotUpdater, + genesisSlotUpdater, + currentSessionIndexUpdater, + eraStartSessionIndexUpdater, + parachainsUpdater, + delegatedStakeUpdater, + bondedErasUpdaterUpdater + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPools.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPools.kt new file mode 100644 index 0000000..fe1186e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPools.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.nominationPool + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class NominationPools diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPoolsValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPoolsValidationsModule.kt new file mode 100644 index 0000000..851d8d1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/nominationPool/NominationPoolsValidationsModule.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.nominationPool + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolAvailableBalanceValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolStateValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.NominationPoolsAvailableBalanceResolver + +@Module +class NominationPoolsValidationsModule { + + @Provides + @FeatureScope + fun providePoolStateValidationFactory( + nominationPoolStateRepository: NominationPoolStateRepository + ) = PoolStateValidationFactory(nominationPoolStateRepository) + + @Provides + @FeatureScope + fun providePoolAvailableBalanceValidationFactory( + poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver, + ) = PoolAvailableBalanceValidationFactory(poolsAvailableBalanceResolver) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/Parachain.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/Parachain.kt new file mode 100644 index 0000000..86e7691 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/Parachain.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.parachain + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class Parachain diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/ParachainStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/ParachainStakingModule.kt new file mode 100644 index 0000000..a2325e6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/ParachainStakingModule.kt @@ -0,0 +1,205 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.parachain + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RealRoundDurationEstimator +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.RealCandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.RealCurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.RealDelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.RealRewardsRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.RewardsRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.RuntimeParachainStakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.scheduledRequests.DelegationScheduledRequestFactory +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.repository.TuringStakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.data.validators.ValidatorsPreferencesSource +import io.novafoundation.nova.feature_staking_impl.di.staking.parachain.start.StartParachainStakingFlowModule +import io.novafoundation.nova.feature_staking_impl.di.staking.parachain.turing.TuringStakingModule +import io.novafoundation.nova.feature_staking_impl.di.staking.parachain.unbond.ParachainStakingUnbondModule +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.RealCollatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.RealCollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.ParachainNetworkInfoInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.alerts.ParachainStakingAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.alerts.RealParachainStakingAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.stakeSummary.ParachainStakingStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.unbondings.ParachainStakingUnbondingsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.userRewards.ParachainStakingUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rewards.ParachainStakingRewardCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.ParachainStakingHintsUseCase +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module(includes = [StartParachainStakingFlowModule::class, ParachainStakingUnbondModule::class, TuringStakingModule::class]) +class ParachainStakingModule { + + @Provides + @FeatureScope + fun provideDelegationScheduledRequestFactory() = DelegationScheduledRequestFactory() + + @Provides + @FeatureScope + fun provideDelegatorStateRepository( + @Named(LOCAL_STORAGE_SOURCE) localDataSource: StorageDataSource, + @Named(REMOTE_STORAGE_SOURCE) remoteDataSource: StorageDataSource, + delegationScheduledRequestFactory: DelegationScheduledRequestFactory + ): DelegatorStateRepository = RealDelegatorStateRepository( + localStorage = localDataSource, + remoteStorage = remoteDataSource, + delegationScheduledRequestFactory + ) + + @Provides + @FeatureScope + fun provideCurrentRoundRepository( + @Named(LOCAL_STORAGE_SOURCE) storageDataSource: StorageDataSource + ): CurrentRoundRepository = RealCurrentRoundRepository(storageDataSource) + + @Provides + @FeatureScope + fun provideRewardsRepository( + @Named(LOCAL_STORAGE_SOURCE) storageDataSource: StorageDataSource + ): RewardsRepository = RealRewardsRepository(storageDataSource) + + @Provides + @FeatureScope + fun provideCandidatesRepository( + @Named(REMOTE_STORAGE_SOURCE) storageDataSource: StorageDataSource + ): CandidatesRepository = RealCandidatesRepository(storageDataSource) + + @Provides + @FeatureScope + fun provideConstantsRepository( + chainRegistry: ChainRegistry + ): ParachainStakingConstantsRepository = RuntimeParachainStakingConstantsRepository(chainRegistry) + + @Provides + @FeatureScope + fun provideRoundDurationEstimator( + parachainStakingConstantsRepository: ParachainStakingConstantsRepository, + chainStateRepository: ChainStateRepository, + currentRoundRepository: CurrentRoundRepository, + @Named(LOCAL_STORAGE_SOURCE) storageDataSource: StorageDataSource, + ): RoundDurationEstimator = RealRoundDurationEstimator(parachainStakingConstantsRepository, chainStateRepository, currentRoundRepository, storageDataSource) + + @Provides + @FeatureScope + fun provideRewardCalculatorFactory( + rewardsRepository: RewardsRepository, + commonStakingRepository: TotalIssuanceRepository, + currentRoundRepository: CurrentRoundRepository, + turingStakingRewardsRepository: TuringStakingRewardsRepository, + ) = ParachainStakingRewardCalculatorFactory(rewardsRepository, currentRoundRepository, commonStakingRepository, turingStakingRewardsRepository) + + @Provides + @FeatureScope + fun provideDelegatorStateUseCase( + repository: DelegatorStateRepository, + stakingSharedState: StakingSharedState, + accountRepository: AccountRepository + ) = DelegatorStateUseCase(repository, stakingSharedState, accountRepository) + + @Provides + @FeatureScope + fun provideNetworkInfoInteractor( + currentRoundRepository: CurrentRoundRepository, + parachainStakingConstantsRepository: ParachainStakingConstantsRepository, + roundDurationEstimator: RoundDurationEstimator + ) = ParachainNetworkInfoInteractor(currentRoundRepository, parachainStakingConstantsRepository, roundDurationEstimator) + + @Provides + @FeatureScope + fun provideCollatorProvider( + currentRoundRepository: CurrentRoundRepository, + identityRepository: OnChainIdentityRepository, + parachainStakingConstantsRepository: ParachainStakingConstantsRepository, + candidatesRepository: CandidatesRepository, + rewardCalculatorFactory: ParachainStakingRewardCalculatorFactory, + chainRegistry: ChainRegistry, + ): CollatorProvider = RealCollatorProvider( + identityRepository = identityRepository, + currentRoundRepository = currentRoundRepository, + parachainStakingConstantsRepository = parachainStakingConstantsRepository, + rewardCalculatorFactory = rewardCalculatorFactory, + chainRegistry = chainRegistry, + candidatesRepository = candidatesRepository + ) + + @Provides + @FeatureScope + fun provideParachainStakingHintsUseCase( + stakingSharedState: StakingSharedState, + resourceManager: ResourceManager, + roundDurationEstimator: RoundDurationEstimator + ) = ParachainStakingHintsUseCase(stakingSharedState, resourceManager, roundDurationEstimator) + + @Provides + @FeatureScope + fun provideCollatorRecommendatorFactory( + collatorProvider: CollatorProvider, + computationalCache: ComputationalCache, + knownNovaCollators: ValidatorsPreferencesSource + ) = CollatorRecommendatorFactory(collatorProvider, computationalCache, knownNovaCollators) + + @Provides + @FeatureScope + fun provideTotalRewardsInteractor( + stakingRewardsRepository: StakingRewardsRepository, + ) = ParachainStakingUserRewardsInteractor(stakingRewardsRepository) + + @Provides + @FeatureScope + fun provideCollatorConstantsUseCase( + parachainStakingConstantsRepository: ParachainStakingConstantsRepository, + stakingSharedState: StakingSharedState, + collatorProvider: CollatorProvider, + addressIconGenerator: AddressIconGenerator + ): CollatorsUseCase = RealCollatorsUseCase(stakingSharedState, parachainStakingConstantsRepository, collatorProvider, addressIconGenerator) + + @Provides + @FeatureScope + fun provideStakeSummaryInteractor( + currentRoundRepository: CurrentRoundRepository, + roundDurationEstimator: RoundDurationEstimator, + candidatesRepository: CandidatesRepository + ) = ParachainStakingStakeSummaryInteractor(currentRoundRepository, candidatesRepository, roundDurationEstimator) + + @Provides + @FeatureScope + fun provideUnbondingInteractor( + delegatorStateRepository: DelegatorStateRepository, + currentRoundRepository: CurrentRoundRepository, + roundDurationEstimator: RoundDurationEstimator, + identityRepository: OnChainIdentityRepository + ) = ParachainStakingUnbondingsInteractor(delegatorStateRepository, currentRoundRepository, roundDurationEstimator, identityRepository) + + @Provides + @FeatureScope + fun provideAlertsInteractor( + candidatesRepository: CandidatesRepository, + currentRoundRepository: CurrentRoundRepository, + delegatorStateRepository: DelegatorStateRepository, + ): ParachainStakingAlertsInteractor { + return RealParachainStakingAlertsInteractor(candidatesRepository, currentRoundRepository, delegatorStateRepository) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/ParachainStakingUpdatersModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/ParachainStakingUpdatersModule.kt new file mode 100644 index 0000000..188ebea --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/ParachainStakingUpdatersModule.kt @@ -0,0 +1,160 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.parachain + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdaters +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.CollatorCommissionUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.CurrentRoundCollatorsUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.CurrentRoundUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.DelegatorStateUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.InflationConfigUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.InflationDistributionConfigUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.ScheduledDelegationRequestsUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.TotalDelegatedUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.scheduledRequests.DelegationScheduledRequestFactory +import io.novafoundation.nova.feature_staking_impl.di.staking.DefaultBulkRetriever +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class ParachainStakingUpdatersModule { + + @Provides + @FeatureScope + fun provideDelegatorStateUpdater( + scope: AccountUpdateScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = DelegatorStateUpdater( + scope = scope, + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideCurrentRoundUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = CurrentRoundUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideCurrentRoundCollatorsUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + @DefaultBulkRetriever bulkRetriever: BulkRetriever, + currentRoundRepository: CurrentRoundRepository, + ) = CurrentRoundCollatorsUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry, + bulkRetriever = bulkRetriever, + currentRoundRepository = currentRoundRepository + ) + + @Provides + @FeatureScope + fun provideTotalDelegatedUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = TotalDelegatedUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideInflationConfigUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = InflationConfigUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideParachainBondInfoUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = InflationDistributionConfigUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideCollatorCommissionUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = CollatorCommissionUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideScheduledDelegationRequestsUpdater( + scope: AccountUpdateScope, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + @Named(REMOTE_STORAGE_SOURCE) storageDataSource: StorageDataSource, + delegationScheduledRequestFactory: DelegationScheduledRequestFactory + ) = ScheduledDelegationRequestsUpdater( + scope = scope, + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry, + remoteStorageDataSource = storageDataSource, + delegationScheduledRequestFactory = delegationScheduledRequestFactory + ) + + @Provides + @Parachain + @FeatureScope + fun provideRelaychainStakingUpdaters( + delegatorStateUpdater: DelegatorStateUpdater, + currentRoundUpdater: CurrentRoundUpdater, + currentRoundCollatorsUpdater: CurrentRoundCollatorsUpdater, + totalDelegatedUpdater: TotalDelegatedUpdater, + inflationConfigUpdater: InflationConfigUpdater, + inflationDistributionConfigUpdater: InflationDistributionConfigUpdater, + collatorCommissionUpdater: CollatorCommissionUpdater, + scheduledDelegationRequestsUpdater: ScheduledDelegationRequestsUpdater, + ) = StakingUpdaters.Group( + delegatorStateUpdater, + currentRoundUpdater, + currentRoundCollatorsUpdater, + totalDelegatedUpdater, + inflationConfigUpdater, + inflationDistributionConfigUpdater, + collatorCommissionUpdater, + scheduledDelegationRequestsUpdater + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/start/StartParachainStakingFlowModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/start/StartParachainStakingFlowModule.kt new file mode 100644 index 0000000..f4bfd95 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/start/StartParachainStakingFlowModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.parachain.start + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.RealStartParachainStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.StartParachainStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.MinimumDelegationValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.NoPendingRevokeValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.parachainStakingStart + +@Module +class StartParachainStakingFlowModule { + + @Provides + @FeatureScope + fun provideMinimumValidationFactory( + candidatesRepository: CandidatesRepository, + stakingConstantsRepository: ParachainStakingConstantsRepository, + delegatorStateUseCase: DelegatorStateUseCase, + ) = MinimumDelegationValidationFactory(stakingConstantsRepository, candidatesRepository, delegatorStateUseCase) + + @Provides + @FeatureScope + fun provideNoPendingRevokeValidationFactory( + delegatorStateRepository: DelegatorStateRepository, + ) = NoPendingRevokeValidationFactory(delegatorStateRepository) + + @Provides + @FeatureScope + fun provideValidationSystem( + minimumDelegationValidationFactory: MinimumDelegationValidationFactory, + noPendingRevokeValidationFactory: NoPendingRevokeValidationFactory, + ): StartParachainStakingValidationSystem = ValidationSystem.parachainStakingStart(minimumDelegationValidationFactory, noPendingRevokeValidationFactory) + + @Provides + @FeatureScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + singleAssetSharedState: StakingSharedState, + stakingConstantsRepository: ParachainStakingConstantsRepository, + delegatorStateRepository: DelegatorStateRepository, + candidatesRepository: CandidatesRepository, + accountRepository: AccountRepository, + ): StartParachainStakingInteractor = RealStartParachainStakingInteractor( + extrinsicService = extrinsicService, + singleAssetSharedState = singleAssetSharedState, + stakingConstantsRepository = stakingConstantsRepository, + delegatorStateRepository = delegatorStateRepository, + candidatesRepository = candidatesRepository, + accountRepository = accountRepository + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/turing/Turing.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/turing/Turing.kt new file mode 100644 index 0000000..6426553 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/turing/Turing.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.parachain.turing + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class Turing diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/turing/TuringStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/turing/TuringStakingModule.kt new file mode 100644 index 0000000..2429bd4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/turing/TuringStakingModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.parachain.turing + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTasksRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.network.rpc.RealTuringAutomationRpcApi +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.network.rpc.TuringAutomationRpcApi +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.repository.RealTuringAutomationTasksRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.repository.RealTuringStakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.repository.TuringStakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.RealYieldBoostInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.yieldBoost +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.TimestampRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class TuringStakingModule { + + @Provides + @FeatureScope + fun provideTuringAutomationRpcApi(chainRegistry: ChainRegistry): TuringAutomationRpcApi = RealTuringAutomationRpcApi(chainRegistry) + + @Provides + @FeatureScope + fun provideTuringRewardsRepository( + @Named(LOCAL_STORAGE_SOURCE) storageDataSource: StorageDataSource + ): TuringStakingRewardsRepository = RealTuringStakingRewardsRepository(storageDataSource) + + @Provides + @FeatureScope + fun provideTuringAutomationRepository( + @Named(LOCAL_STORAGE_SOURCE) storageDataSource: StorageDataSource, + turingAutomationRpcApi: TuringAutomationRpcApi, + ): TuringAutomationTasksRepository = RealTuringAutomationTasksRepository(storageDataSource, turingAutomationRpcApi) + + @Provides + @FeatureScope + fun provideYieldBoostInteractor( + automationTasksRepository: TuringAutomationTasksRepository, + extrinsicService: ExtrinsicService, + stakingSharedState: StakingSharedState, + timestampRepository: TimestampRepository + ): YieldBoostInteractor = RealYieldBoostInteractor(automationTasksRepository, extrinsicService, stakingSharedState, timestampRepository) + + @Provides + @FeatureScope + fun provideYieldBoostValidationSystem(automationTasksRepository: TuringAutomationTasksRepository): YieldBoostValidationSystem { + return ValidationSystem.yieldBoost(automationTasksRepository) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/turing/TuringStakingUpdatersModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/turing/TuringStakingUpdatersModule.kt new file mode 100644 index 0000000..029f11a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/turing/TuringStakingUpdatersModule.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.parachain.turing + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdaters +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.turing.TuringAdditionalIssuanceUpdater +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.blockhain.updaters.turing.TuringAutomationTasksUpdater +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class TuringStakingUpdatersModule { + + @Provides + @FeatureScope + fun provideAdditionalIssuanceUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = TuringAdditionalIssuanceUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + chainRegistry = chainRegistry + ) + + @Provides + @FeatureScope + fun provideAutomationTaskUpdater( + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + chainRegistry: ChainRegistry, + @Named(REMOTE_STORAGE_SOURCE) remoteStorageDataSource: StorageDataSource, + accountUpdateScope: AccountUpdateScope, + ) = TuringAutomationTasksUpdater( + storageCache = storageCache, + stakingSharedState = stakingSharedState, + remoteStorageSource = remoteStorageDataSource, + chainRegistry = chainRegistry, + scope = accountUpdateScope + ) + + @Provides + @FeatureScope + @Turing + fun provideTuringExtraUpdaters( + turingAdditionalIssuanceUpdater: TuringAdditionalIssuanceUpdater, + turingAutomationTasksUpdater: TuringAutomationTasksUpdater, + ) = StakingUpdaters.Group(turingAdditionalIssuanceUpdater, turingAutomationTasksUpdater) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/unbond/ParachainStakingUnbondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/unbond/ParachainStakingUnbondModule.kt new file mode 100644 index 0000000..f756462 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/parachain/unbond/ParachainStakingUnbondModule.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.parachain.unbond + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.ParachainStakingUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.RealParachainStakingUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.NoExistingDelegationRequestsToCollatorValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.RemainingUnbondValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.parachainStakingUnbond +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.AnyAvailableCollatorForUnbondValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.ParachainStakingUnbondPreliminaryValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.parachainStakingPreliminaryUnbond +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.ParachainStakingHintsUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.hints.ParachainStakingUnbondHintsMixinFactory + +@Module +class ParachainStakingUnbondModule { + + @Provides + @FeatureScope + fun provideRemainingUnbondValidationFactory( + candidatesRepository: CandidatesRepository, + stakingConstantsRepository: ParachainStakingConstantsRepository, + delegatorStateUseCase: DelegatorStateUseCase, + ) = RemainingUnbondValidationFactory(stakingConstantsRepository, candidatesRepository, delegatorStateUseCase) + + @Provides + @FeatureScope + fun provideNoExistingDelegationRequestsToCollatorValidationFactory( + interactor: ParachainStakingUnbondInteractor, + delegatorStateUseCase: DelegatorStateUseCase, + ) = NoExistingDelegationRequestsToCollatorValidationFactory(interactor, delegatorStateUseCase) + + @Provides + @FeatureScope + fun provideAnyAvailableCollatorsToUnbondValidationFactory( + delegatorStateRepository: DelegatorStateRepository, + delegatorStateUseCase: DelegatorStateUseCase, + ) = AnyAvailableCollatorForUnbondValidationFactory(delegatorStateRepository, delegatorStateUseCase) + + @Provides + @FeatureScope + fun providePreliminaryValidationSystem( + anyAvailableCollatorForUnbondValidationFactory: AnyAvailableCollatorForUnbondValidationFactory + ): ParachainStakingUnbondPreliminaryValidationSystem = ValidationSystem.parachainStakingPreliminaryUnbond( + anyAvailableCollatorForUnbondValidationFactory = anyAvailableCollatorForUnbondValidationFactory + ) + + @Provides + @FeatureScope + fun provideValidationSystem( + remainingUnbondValidationFactory: RemainingUnbondValidationFactory, + noExistingDelegationRequestsToCollatorValidationFactory: NoExistingDelegationRequestsToCollatorValidationFactory, + ): ParachainStakingUnbondValidationSystem = ValidationSystem.parachainStakingUnbond( + remainingUnbondValidationFactory = remainingUnbondValidationFactory, + noExistingDelegationRequestsToCollatorValidationFactory = noExistingDelegationRequestsToCollatorValidationFactory + ) + + @Provides + @FeatureScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + delegatorStateUseCase: DelegatorStateUseCase, + stakingSharedState: StakingSharedState, + delegatorStateRepository: DelegatorStateRepository, + collatorsUseCase: CollatorsUseCase, + ): ParachainStakingUnbondInteractor = RealParachainStakingUnbondInteractor( + extrinsicService = extrinsicService, + delegatorStateUseCase = delegatorStateUseCase, + selectedAssetSharedState = stakingSharedState, + delegatorStateRepository = delegatorStateRepository, + collatorsUseCase = collatorsUseCase + ) + + @Provides + @FeatureScope + fun provideHintsFactory( + stakingHintsUseCase: ParachainStakingHintsUseCase + ) = ParachainStakingUnbondHintsMixinFactory(stakingHintsUseCase) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/relaychain/Relaychain.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/relaychain/Relaychain.kt new file mode 100644 index 0000000..7c93f65 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/relaychain/Relaychain.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.relaychain + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class Relaychain diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/relaychain/RelaychainStakingUpdatersModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/relaychain/RelaychainStakingUpdatersModule.kt new file mode 100644 index 0000000..f06514c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/relaychain/RelaychainStakingUpdatersModule.kt @@ -0,0 +1,450 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.relaychain + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.AccountStakingDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.AccountNominationsUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.AccountRewardDestinationUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.AccountValidatorPrefsUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ActiveEraUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.BagListNodeUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.CounterForListNodesUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.CounterForNominatorsUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.CurrentEraUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.HistoryDepthUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.MaxNominatorsUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.MinBondUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ParachainsUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ProxiesUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingLedgerUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdaters +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ValidatorExposureUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.controller.AccountControllerBalanceUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.historical.HistoricalTotalValidatorRewardUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.historical.HistoricalUpdateMediator +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.historical.HistoricalValidatorRewardPointsUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.AccountStakingScope +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.scope.ActiveEraScope +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.BondedErasUpdaterUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.CurrentEpochIndexUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.CurrentSessionIndexUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.CurrentSlotUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.EraStartSessionIndexUpdater +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.session.GenesisSlotUpdater +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSessionRegistry +import io.novafoundation.nova.feature_staking_impl.di.staking.DefaultBulkRetriever +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.updaters.multiChain.DelegateToTimelineChainIdHolder + +@Module +class RelaychainStakingUpdatersModule { + + @Provides + @FeatureScope + fun provideAccountStakingScope( + accountRepository: AccountRepository, + accountStakingDao: AccountStakingDao, + sharedState: StakingSharedState, + ) = AccountStakingScope( + accountRepository, + accountStakingDao, + sharedState + ) + + @Provides + @FeatureScope + fun provideActiveEraScope( + stakingSharedComputation: StakingSharedComputation, + stakingSharedState: StakingSharedState + ) = ActiveEraScope( + stakingSharedComputation = stakingSharedComputation, + stakingSharedState = stakingSharedState + ) + + @Provides + @FeatureScope + fun provideActiveEraUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = ActiveEraUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideElectedNominatorsUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + @DefaultBulkRetriever bulkRetriever: BulkRetriever, + storageCache: StorageCache, + scope: ActiveEraScope + ) = ValidatorExposureUpdater( + bulkRetriever, + sharedState, + chainRegistry, + storageCache, + scope + ) + + @Provides + @FeatureScope + fun provideCurrentEraUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = CurrentEraUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideStakingLedgerUpdater( + stakingRepository: StakingRepository, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + accountStakingDao: AccountStakingDao, + assetCache: AssetCache, + storageCache: StorageCache, + accountUpdateScope: AccountUpdateScope, + ): StakingLedgerUpdater { + return StakingLedgerUpdater( + stakingRepository, + sharedState, + chainRegistry, + accountStakingDao, + storageCache, + assetCache, + accountUpdateScope + ) + } + + @Provides + @FeatureScope + fun provideAccountValidatorPrefsUpdater( + storageCache: StorageCache, + scope: AccountStakingScope, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = AccountValidatorPrefsUpdater( + scope, + storageCache, + sharedState, + chainRegistry, + ) + + @Provides + @FeatureScope + fun provideAccountNominationsUpdater( + storageCache: StorageCache, + scope: AccountStakingScope, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = AccountNominationsUpdater( + scope, + storageCache, + sharedState, + chainRegistry, + ) + + @Provides + @FeatureScope + fun provideAccountRewardDestinationUpdater( + storageCache: StorageCache, + scope: AccountStakingScope, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = AccountRewardDestinationUpdater( + scope, + storageCache, + sharedState, + chainRegistry, + ) + + @Provides + @FeatureScope + fun provideHistoryDepthUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = HistoryDepthUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideHistoricalMediator( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + activeEraScope: ActiveEraScope, + preferences: Preferences + ) = HistoricalUpdateMediator( + historicalUpdaters = listOf( + HistoricalTotalValidatorRewardUpdater(), + HistoricalValidatorRewardPointsUpdater(), + ), + stakingSharedState = sharedState, + chainRegistry = chainRegistry, + storageCache = storageCache, + scope = activeEraScope, + preferences = preferences + ) + + @Provides + @FeatureScope + fun provideEraStartSessionIndexUpdater( + chainRegistry: ChainRegistry, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + activeEraScope: ActiveEraScope, + ) = EraStartSessionIndexUpdater( + activeEraScope = activeEraScope, + storageCache = storageCache, + chainRegistry = chainRegistry, + stakingSharedState = stakingSharedState + ) + + @Provides + @FeatureScope + fun provideBondedErasUpdater( + chainRegistry: ChainRegistry, + storageCache: StorageCache, + stakingSharedState: StakingSharedState, + ) = BondedErasUpdaterUpdater( + storageCache = storageCache, + chainRegistry = chainRegistry, + stakingSharedState = stakingSharedState + ) + + @Provides + @FeatureScope + fun provideCurrentSessionIndexUpdater( + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = CurrentSessionIndexUpdater( + timelineDelegatingChainIdHolder, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideCurrentSlotUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + electionsSessionRegistry: ElectionsSessionRegistry, + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + ) = CurrentSlotUpdater( + electionsSessionRegistry = electionsSessionRegistry, + stakingSharedState = sharedState, + chainRegistry = chainRegistry, + storageCache = storageCache, + timelineDelegatingChainIdHolder = timelineDelegatingChainIdHolder + ) + + @Provides + @FeatureScope + fun provideGenesisSlotUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + electionsSessionRegistry: ElectionsSessionRegistry, + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + ) = GenesisSlotUpdater( + electionsSessionRegistry = electionsSessionRegistry, + stakingSharedState = sharedState, + chainRegistry = chainRegistry, + storageCache = storageCache, + timelineDelegatingChainIdHolder = timelineDelegatingChainIdHolder + ) + + @Provides + @FeatureScope + fun provideCurrentEpochIndexUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + electionsSessionRegistry: ElectionsSessionRegistry, + timelineDelegatingChainIdHolder: DelegateToTimelineChainIdHolder, + ) = CurrentEpochIndexUpdater( + electionsSessionRegistry = electionsSessionRegistry, + stakingSharedState = sharedState, + chainRegistry = chainRegistry, + storageCache = storageCache, + timelineDelegatingChainIdHolder = timelineDelegatingChainIdHolder + ) + + @Provides + @FeatureScope + fun provideAccountControllerBalanceUpdater( + assetCache: AssetCache, + scope: AccountStakingScope, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = AccountControllerBalanceUpdater( + scope, + sharedState, + chainRegistry, + assetCache + ) + + @Provides + @FeatureScope + fun provideMinBondUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = MinBondUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideMaxNominatorsUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = MaxNominatorsUpdater( + storageCache, + sharedState, + chainRegistry + ) + + @Provides + @FeatureScope + fun provideCounterForNominatorsUpdater( + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + storageCache: StorageCache, + ) = CounterForNominatorsUpdater( + sharedState, + chainRegistry, + storageCache + ) + + @Provides + @FeatureScope + fun provideBagListNodeUpdater( + storageCache: StorageCache, + scope: AccountStakingScope, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = BagListNodeUpdater( + scope, + storageCache, + sharedState, + chainRegistry, + ) + + @Provides + @FeatureScope + fun provideCounterForListNodesUpdater( + storageCache: StorageCache, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = CounterForListNodesUpdater( + storageCache, + sharedState, + chainRegistry, + ) + + @Provides + @FeatureScope + fun provideParasUpdater( + storageCache: StorageCache, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = ParachainsUpdater( + storageCache, + sharedState, + chainRegistry, + ) + + @Provides + @FeatureScope + fun provideProxiesUpdater( + storageCache: StorageCache, + scope: AccountStakingScope, + sharedState: StakingSharedState, + chainRegistry: ChainRegistry, + ) = ProxiesUpdater( + scope, + sharedState, + chainRegistry, + storageCache, + ) + + @Provides + @Relaychain + @FeatureScope + fun provideRelaychainStakingUpdaters( + activeEraUpdater: ActiveEraUpdater, + validatorExposureUpdater: ValidatorExposureUpdater, + currentEraUpdater: CurrentEraUpdater, + stakingLedgerUpdater: StakingLedgerUpdater, + accountValidatorPrefsUpdater: AccountValidatorPrefsUpdater, + accountNominationsUpdater: AccountNominationsUpdater, + rewardDestinationUpdater: AccountRewardDestinationUpdater, + historyDepthUpdater: HistoryDepthUpdater, + historicalUpdateMediator: HistoricalUpdateMediator, + accountControllerBalanceUpdater: AccountControllerBalanceUpdater, + minBondUpdater: MinBondUpdater, + maxNominatorsUpdater: MaxNominatorsUpdater, + counterForNominatorsUpdater: CounterForNominatorsUpdater, + bagListNodeUpdater: BagListNodeUpdater, + counterForListNodesUpdater: CounterForListNodesUpdater, + parachainsUpdater: ParachainsUpdater, + currentEpochIndexUpdater: CurrentEpochIndexUpdater, + currentSlotUpdater: CurrentSlotUpdater, + genesisSlotUpdater: GenesisSlotUpdater, + currentSessionIndexUpdater: CurrentSessionIndexUpdater, + eraStartSessionIndexUpdater: EraStartSessionIndexUpdater, + bondedErasUpdaterUpdater: BondedErasUpdaterUpdater, + proxiesUpdater: ProxiesUpdater + ) = StakingUpdaters.Group( + activeEraUpdater, + validatorExposureUpdater, + currentEraUpdater, + stakingLedgerUpdater, + accountValidatorPrefsUpdater, + accountNominationsUpdater, + rewardDestinationUpdater, + historyDepthUpdater, + historicalUpdateMediator, + accountControllerBalanceUpdater, + minBondUpdater, + maxNominatorsUpdater, + counterForNominatorsUpdater, + bagListNodeUpdater, + counterForListNodesUpdater, + parachainsUpdater, + currentEpochIndexUpdater, + currentSlotUpdater, + genesisSlotUpdater, + currentSessionIndexUpdater, + eraStartSessionIndexUpdater, + proxiesUpdater, + bondedErasUpdaterUpdater + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/stakingTypeDetails/StakingTypeDetailsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/stakingTypeDetails/StakingTypeDetailsModule.kt new file mode 100644 index 0000000..0518199 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/stakingTypeDetails/StakingTypeDetailsModule.kt @@ -0,0 +1,133 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.stakingTypeDetails + +import dagger.MapKey +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.era.StakingEraInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.ParachainNetworkInfoInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rewards.ParachainStakingRewardCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.NominationPoolsAvailableBalanceResolver +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct.MythosStakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct.ParachainStakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct.RelaychainStakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct.StakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.pools.PoolStakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.StakingTypeDetailsCompoundInteractorFactory +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER +) +@MapKey +annotation class StakingTypeDetailsKey(val group: StakingTypeGroup) + +@Module +class StakingTypeDetailsModule { + + @Provides + @FeatureScope + fun providePoolStakingTypeDetailsInteractorFactory( + nominationPoolSharedComputation: NominationPoolSharedComputation, + poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver + ): PoolStakingTypeDetailsInteractorFactory { + return PoolStakingTypeDetailsInteractorFactory( + nominationPoolSharedComputation, + poolsAvailableBalanceResolver + ) + } + + @Provides + @FeatureScope + fun provideRelaychainStakingTypeDetailsInteractorFactory( + stakingSharedComputation: StakingSharedComputation + ): RelaychainStakingTypeDetailsInteractorFactory { + return RelaychainStakingTypeDetailsInteractorFactory(stakingSharedComputation) + } + + @Provides + @FeatureScope + fun provideParachainStakingTypeDetailsInteractorFactory( + parachainNetworkInfoInteractor: ParachainNetworkInfoInteractor, + parachainStakingRewardCalculatorFactory: ParachainStakingRewardCalculatorFactory, + ): ParachainStakingTypeDetailsInteractorFactory { + return ParachainStakingTypeDetailsInteractorFactory( + parachainNetworkInfoInteractor, + parachainStakingRewardCalculatorFactory + ) + } + + @Provides + @FeatureScope + fun provideMythosStakingTypeDetailsInteractorFactory( + mythosSharedComputation: MythosSharedComputation, + ): MythosStakingTypeDetailsInteractorFactory { + return MythosStakingTypeDetailsInteractorFactory(mythosSharedComputation) + } + + @Provides + @FeatureScope + @IntoMap + @StakingTypeDetailsKey(StakingTypeGroup.NOMINATION_POOL) + fun provideAbstractPoolStakingTypeDetailsInteractorFactory( + stakingTypeDetailsInteractorFactory: PoolStakingTypeDetailsInteractorFactory + ): StakingTypeDetailsInteractorFactory { + return stakingTypeDetailsInteractorFactory + } + + @Provides + @FeatureScope + @IntoMap + @StakingTypeDetailsKey(StakingTypeGroup.RELAYCHAIN) + fun provideAbstractRelaychainStakingTypeDetailsInteractorFactory( + stakingTypeDetailsInteractorFactory: RelaychainStakingTypeDetailsInteractorFactory + ): StakingTypeDetailsInteractorFactory { + return stakingTypeDetailsInteractorFactory + } + + @Provides + @FeatureScope + @IntoMap + @StakingTypeDetailsKey(StakingTypeGroup.PARACHAIN) + fun provideAbstractParachainStakingTypeDetailsInteractorFactory( + stakingTypeDetailsInteractorFactory: ParachainStakingTypeDetailsInteractorFactory + ): StakingTypeDetailsInteractorFactory { + return stakingTypeDetailsInteractorFactory + } + + @Provides + @FeatureScope + @IntoMap + @StakingTypeDetailsKey(StakingTypeGroup.MYTHOS) + fun provideAbstractMythosStakingTypeDetailsInteractorFactory( + stakingTypeDetailsInteractorFactory: MythosStakingTypeDetailsInteractorFactory + ): StakingTypeDetailsInteractorFactory { + return stakingTypeDetailsInteractorFactory + } + + @Provides + fun provideStartStakingInteractorFactory( + walletRepository: WalletRepository, + accountRepository: AccountRepository, + stakingEraInteractorFactory: StakingEraInteractorFactory, + chainRegistry: ChainRegistry, + factories: Map + ): StakingTypeDetailsCompoundInteractorFactory { + return StakingTypeDetailsCompoundInteractorFactory( + walletRepository = walletRepository, + accountRepository = accountRepository, + stakingEraInteractorFactory = stakingEraInteractorFactory, + stakingTypeDetailsInteractorFactories = factories, + chainRegistry = chainRegistry + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/startMultiStaking/StartMultiStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/startMultiStaking/StartMultiStakingModule.kt new file mode 100644 index 0000000..9bf78a1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/startMultiStaking/StartMultiStakingModule.kt @@ -0,0 +1,248 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.startMultiStaking + +import dagger.MapKey +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.KnownNovaPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolAvailableBalanceValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.NominationPoolProvider +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.recommendation.NominationPoolRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting.SearchNominationPoolInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.NominationPoolsAvailableBalanceResolver +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.RealNominationPoolsAvailableBalanceResolver +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.CompoundStakingTypeDetailsProvidersFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsProviderFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct.RelaychainStakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.pools.PoolStakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.MultiSingleStakingPropertiesFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingPropertiesFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.direct.DirectStakingPropertiesFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools.NominationPoolStakingPropertiesFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.selectionType.MultiStakingSelectionTypeProviderFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.SetupStakingTypeSelectionMixinFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.pool.RealStakingTypeDetailsProviderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.MultiStakingTargetSelectionFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.RealMultiStakingTargetSelectionFormatter +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import javax.inject.Qualifier + +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER +) +@MapKey +annotation class StakingTypeGroupKey(val group: StakingTypeGroup) + +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER +) +@MapKey +annotation class StakingTypeProviderKey(val group: StakingTypeGroup) + +@Qualifier +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER) +annotation class MultiStakingSelectionStoreProviderKey() + +@Qualifier +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER) +annotation class StakingTypeEditingStoreProviderKey() + +@Module +class StartMultiStakingModule { + + @Provides + @FeatureScope + @IntoMap + @StakingTypeProviderKey(StakingTypeGroup.NOMINATION_POOL) + fun providePoolStakingTypeDetailsProviderFactory( + poolStakingTypeDetailsInteractorFactory: PoolStakingTypeDetailsInteractorFactory, + singleStakingPropertiesFactory: SingleStakingPropertiesFactory, + @MultiStakingSelectionStoreProviderKey currentSelectionStoreProvider: StartMultiStakingSelectionStoreProvider + ): StakingTypeDetailsProviderFactory { + return RealStakingTypeDetailsProviderFactory( + poolStakingTypeDetailsInteractorFactory, + singleStakingPropertiesFactory, + currentSelectionStoreProvider + ) + } + + @Provides + @FeatureScope + @IntoMap + @StakingTypeProviderKey(StakingTypeGroup.RELAYCHAIN) + fun provideRelaychainDirectStakingTypeDetailsProviderFactory( + relaychainStakingTypeDetailsInteractorFactory: RelaychainStakingTypeDetailsInteractorFactory, + singleStakingPropertiesFactory: SingleStakingPropertiesFactory, + @MultiStakingSelectionStoreProviderKey currentSelectionStoreProvider: StartMultiStakingSelectionStoreProvider + ): StakingTypeDetailsProviderFactory { + return RealStakingTypeDetailsProviderFactory( + relaychainStakingTypeDetailsInteractorFactory, + singleStakingPropertiesFactory, + currentSelectionStoreProvider + ) + } + + @Provides + @FeatureScope + fun provideCompoundStakingTypeDetailsProvidersFactory( + factories: Map, + ): CompoundStakingTypeDetailsProvidersFactory { + return CompoundStakingTypeDetailsProvidersFactory(factories) + } + + @Provides + @FeatureScope + fun provideSetupStakingTypeSelectionMixinFactory( + @MultiStakingSelectionStoreProviderKey currentSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + @StakingTypeEditingStoreProviderKey editableSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + singleStakingPropertiesFactory: SingleStakingPropertiesFactory + ): SetupStakingTypeSelectionMixinFactory { + return SetupStakingTypeSelectionMixinFactory( + currentSelectionStoreProvider, + editableSelectionStoreProvider, + singleStakingPropertiesFactory + ) + } + + @Provides + @FeatureScope + fun provideMultiStakingSelectionFormatter( + resourceManager: ResourceManager, + poolDisplayFormatter: PoolDisplayFormatter, + ): MultiStakingTargetSelectionFormatter { + return RealMultiStakingTargetSelectionFormatter(resourceManager, poolDisplayFormatter) + } + + @Provides + @FeatureScope + @MultiStakingSelectionStoreProviderKey + fun provideStartMultiStakingSelectionStoreProvider( + computationalCache: ComputationalCache + ): StartMultiStakingSelectionStoreProvider { + return StartMultiStakingSelectionStoreProvider(computationalCache, "MultiStakingSelection") + } + + @Provides + @FeatureScope + @StakingTypeEditingStoreProviderKey + fun provideStakingTypeEditingSelectionStoreProvider( + computationalCache: ComputationalCache + ): StartMultiStakingSelectionStoreProvider { + return StartMultiStakingSelectionStoreProvider(computationalCache, "StakingTypeEditing") + } + + @Provides + @FeatureScope + fun provideNominationPoolsAvailableBalanceResolver(walletConstants: WalletConstants): NominationPoolsAvailableBalanceResolver { + return RealNominationPoolsAvailableBalanceResolver(walletConstants) + } + + @Provides + @FeatureScope + @IntoMap + @StakingTypeGroupKey(StakingTypeGroup.RELAYCHAIN) + fun provideDirectStakingPropertiesFactory( + validatorRecommenderFactory: ValidatorRecommenderFactory, + recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + stakingSharedComputation: StakingSharedComputation, + stakingRepository: StakingRepository, + stakingConstantsRepository: StakingConstantsRepository, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory, + selectedAccountUseCase: SelectedAccountUseCase, + ): SingleStakingPropertiesFactory { + return DirectStakingPropertiesFactory( + validatorRecommenderFactory = validatorRecommenderFactory, + recommendationSettingsProviderFactory = recommendationSettingsProviderFactory, + stakingSharedComputation = stakingSharedComputation, + stakingRepository = stakingRepository, + stakingConstantsRepository = stakingConstantsRepository, + stakingTypesConflictValidationFactory = stakingTypesConflictValidationFactory, + selectedAccountUseCase = selectedAccountUseCase + ) + } + + @Provides + @FeatureScope + @IntoMap + @StakingTypeGroupKey(StakingTypeGroup.NOMINATION_POOL) + fun providePoolsStakingPropertiesFactory( + nominationPoolSharedComputation: NominationPoolSharedComputation, + nominationPoolRecommenderFactory: NominationPoolRecommenderFactory, + availableBalanceResolver: NominationPoolsAvailableBalanceResolver, + nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, + poolAvailableBalanceValidationFactory: PoolAvailableBalanceValidationFactory, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory, + selectedAccountUseCase: SelectedAccountUseCase, + ): SingleStakingPropertiesFactory { + return NominationPoolStakingPropertiesFactory( + nominationPoolSharedComputation = nominationPoolSharedComputation, + nominationPoolRecommenderFactory = nominationPoolRecommenderFactory, + poolsAvailableBalanceResolver = availableBalanceResolver, + nominationPoolGlobalsRepository = nominationPoolGlobalsRepository, + poolAvailableBalanceValidationFactory = poolAvailableBalanceValidationFactory, + stakingTypesConflictValidationFactory = stakingTypesConflictValidationFactory, + selectedAccountUseCase = selectedAccountUseCase + ) + } + + @Provides + @FeatureScope + fun provideStakingPropertiesFactory( + creators: Map, + ): SingleStakingPropertiesFactory { + return MultiSingleStakingPropertiesFactory(creators) + } + + @Provides + @FeatureScope + fun provideMultiStakingSelectionTypeProviderFactory( + @MultiStakingSelectionStoreProviderKey selectionStoreProvider: StartMultiStakingSelectionStoreProvider, + singleStakingPropertiesFactory: SingleStakingPropertiesFactory, + chainRegistry: ChainRegistry, + locksRepository: BalanceLocksRepository + ): MultiStakingSelectionTypeProviderFactory { + return MultiStakingSelectionTypeProviderFactory( + selectionStoreProvider = selectionStoreProvider, + singleStakingPropertiesFactory = singleStakingPropertiesFactory, + chainRegistry = chainRegistry, + locksRepository = locksRepository + ) + } + + @Provides + @FeatureScope + fun provideSelectNominationPoolInteractor( + nominationPoolProvider: NominationPoolProvider, + knownNovaPools: KnownNovaPools, + nominationPoolRecommenderFactory: NominationPoolRecommenderFactory, + nominationPoolGlobalsRepository: NominationPoolGlobalsRepository + ): SearchNominationPoolInteractor { + return SearchNominationPoolInteractor( + nominationPoolProvider, + knownNovaPools, + nominationPoolRecommenderFactory, + nominationPoolGlobalsRepository + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/unbond/StakingUnbondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/unbond/StakingUnbondModule.kt new file mode 100644 index 0000000..c9b0278 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/staking/unbond/StakingUnbondModule.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.di.staking.unbond + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.UnbondInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.common.hints.StakingHintsUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.hints.UnbondHintsMixinFactory + +@Module +class StakingUnbondModule { + + @Provides + @FeatureScope + fun provideUnbondInteractor( + extrinsicService: ExtrinsicService, + stakingRepository: StakingRepository, + stakingSharedState: StakingSharedState, + stakingSharedComputation: StakingSharedComputation + ) = UnbondInteractor(extrinsicService, stakingRepository, stakingSharedState, stakingSharedComputation) + + @Provides + @FeatureScope + fun provideHintsMixinFactory( + stakingHintsUseCase: StakingHintsUseCase + ) = UnbondHintsMixinFactory(stakingHintsUseCase) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/AddStakingProxyValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/AddStakingProxyValidationsModule.kt new file mode 100644 index 0000000..6e15dd8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/AddStakingProxyValidationsModule.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.enoughBalanceToPayDepositAndFee +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.maximumProxies +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.notSelfAccount +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.stakingTypeIsNotDuplication +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.sufficientBalanceToPayFee +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.sufficientBalanceToStayAboveEd +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.validAddress +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory + +@Module +class AddStakingProxyValidationsModule { + + @FeatureScope + @Provides + fun provideAddStakingProxyValidationSystem( + getProxyRepository: GetProxyRepository, + accountRepository: AccountRepository, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ): AddStakingProxyValidationSystem = ValidationSystem { + validAddress() + + notSelfAccount(accountRepository) + + sufficientBalanceToPayFee() + + sufficientBalanceToStayAboveEd(enoughTotalToStayAboveEDValidationFactory) + + stakingTypeIsNotDuplication(getProxyRepository) + + maximumProxies(getProxyRepository) + + enoughBalanceToPayDepositAndFee() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/BondMoreValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/BondMoreValidationsModule.kt new file mode 100644 index 0000000..e3904f6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/BondMoreValidationsModule.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.BondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.bondMore +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory + +@Module +class BondMoreValidationsModule { + + @Provides + @FeatureScope + fun provideBondMoreValidationSystem( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ): BondMoreValidationSystem = ValidationSystem.bondMore(enoughTotalToStayAboveEDValidationFactory) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/MakePayoutValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/MakePayoutValidationsModule.kt new file mode 100644 index 0000000..066c06d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/MakePayoutValidationsModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.common.validation.ProfitableActionValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.payout.MakePayoutPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.payout.PayoutFeeValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.payout.PayoutValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidationGeneric + +typealias ProfitablePayoutValidation = ProfitableActionValidation + +@Module +class MakePayoutValidationsModule { + + @Provides + @FeatureScope + fun provideFeeValidation(): PayoutFeeValidation { + return EnoughAmountToTransferValidationGeneric( + feeExtractor = { it.fee }, + availableBalanceProducer = { it.asset.transferable }, + errorProducer = { PayoutValidationFailure.CannotPayFee } + ) + } + + @FeatureScope + @Provides + fun provideProfitableValidation() = ProfitablePayoutValidation( + fee = { it.fee }, + amount = { totalReward }, + error = { PayoutValidationFailure.UnprofitablePayout } + ) + + @Provides + @FeatureScope + fun provideValidationSystem( + enoughToPayFeesValidation: PayoutFeeValidation, + profitablePayoutValidation: ProfitablePayoutValidation, + ) = ValidationSystem( + CompositeValidation( + listOf( + enoughToPayFeesValidation, + profitablePayoutValidation, + ) + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RebondValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RebondValidationsModule.kt new file mode 100644 index 0000000..9b7b40b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RebondValidationsModule.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.EnoughToRebondValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.NotZeroRebondValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondFeeValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidationGeneric +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughBalanceToStayAboveEDValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +@Module +class RebondValidationsModule { + + @FeatureScope + @Provides + fun provideFeeValidation(): RebondFeeValidation = EnoughAmountToTransferValidationGeneric( + feeExtractor = { it.fee }, + availableBalanceProducer = { it.controllerAsset.transferable }, + errorProducer = { RebondValidationFailure.CannotPayFee } + ) + + @FeatureScope + @Provides + fun provideNotZeroRebondValidation() = NotZeroRebondValidation( + amountExtractor = { it.rebondAmount }, + errorProvider = { RebondValidationFailure.ZeroAmount } + ) + + @FeatureScope + @Provides + fun provideEnoughToRebondValidation() = EnoughToRebondValidation() + + @FeatureScope + @Provides + fun provideEnoughTotalToStayAboveEDValidation( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ): EnoughBalanceToStayAboveEDValidation { + return enoughTotalToStayAboveEDValidationFactory.create( + fee = { it.fee }, + balance = { it.controllerAsset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.chain, it.controllerAsset.token.configuration) }, + error = { payload, error -> RebondValidationFailure.NotEnoughBalanceToStayAboveED(payload.controllerAsset.token.configuration, error) } + ) + } + + @FeatureScope + @Provides + fun provideRebondValidationSystem( + rebondFeeValidation: RebondFeeValidation, + notZeroRebondValidation: NotZeroRebondValidation, + enoughToRebondValidation: EnoughToRebondValidation, + enoughBalanceToStayAboveEDValidation: EnoughBalanceToStayAboveEDValidation + ) = RebondValidationSystem( + CompositeValidation( + validations = listOf( + rebondFeeValidation, + notZeroRebondValidation, + enoughToRebondValidation, + enoughBalanceToStayAboveEDValidation + ) + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RedeemValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RedeemValidationsModule.kt new file mode 100644 index 0000000..0499e2e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RedeemValidationsModule.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem.RedeemFeeValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem.RedeemValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem.RedeemValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem.RedeemValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidationGeneric +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughBalanceToStayAboveEDValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +@Module +class RedeemValidationsModule { + + @FeatureScope + @Provides + fun provideFeeValidation(): RedeemFeeValidation = EnoughAmountToTransferValidationGeneric( + feeExtractor = { it.fee }, + availableBalanceProducer = { it.asset.transferable }, + errorProducer = { RedeemValidationFailure.CannotPayFees } + ) + + @FeatureScope + @Provides + fun provideEnoughTotalToStayAboveEDValidation( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ): EnoughBalanceToStayAboveEDValidation { + return enoughTotalToStayAboveEDValidationFactory.create( + fee = { it.fee }, + balance = { it.asset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.chain, it.asset.token.configuration) }, + error = { payload, error -> RedeemValidationFailure.NotEnoughBalanceToStayAboveED(payload.asset.token.configuration, error) } + ) + } + + @FeatureScope + @Provides + fun provideRedeemValidationSystem( + feeValidation: RedeemFeeValidation, + enoughTotalToStayAboveEDValidation: EnoughBalanceToStayAboveEDValidation + ) = RedeemValidationSystem( + CompositeValidation( + validations = listOf( + feeValidation, + enoughTotalToStayAboveEDValidation + ) + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RemoveStakingProxyValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RemoveStakingProxyValidationsModule.kt new file mode 100644 index 0000000..d956efa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RemoveStakingProxyValidationsModule.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.sufficientBalanceToPayFee +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.sufficientBalanceToStayAboveEd +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove.RemoveStakingProxyValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove.sufficientBalanceToPayFee +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove.sufficientBalanceToStayAboveEd +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory + +@Module +class RemoveStakingProxyValidationsModule { + + @FeatureScope + @Provides + fun provideAddStakingProxyValidationSystem( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ): RemoveStakingProxyValidationSystem = ValidationSystem { + sufficientBalanceToPayFee() + + sufficientBalanceToStayAboveEd(enoughTotalToStayAboveEDValidationFactory) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RewardDestinationValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RewardDestinationValidationsModule.kt new file mode 100644 index 0000000..81f4f85 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/RewardDestinationValidationsModule.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationControllerRequiredValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationFeeValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidationGeneric +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughBalanceToStayAboveEDValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +@Module +class RewardDestinationValidationsModule { + + @FeatureScope + @Provides + fun provideFeeValidation(): RewardDestinationFeeValidation = EnoughAmountToTransferValidationGeneric( + feeExtractor = { it.fee }, + availableBalanceProducer = { it.availableControllerBalance }, + errorProducer = { RewardDestinationValidationFailure.CannotPayFees } + ) + + @Provides + @FeatureScope + fun controllerRequiredValidation( + stakingSharedState: StakingSharedState, + accountRepository: AccountRepository, + ) = RewardDestinationControllerRequiredValidation( + accountRepository = accountRepository, + accountAddressExtractor = { it.stashState.controllerAddress }, + errorProducer = RewardDestinationValidationFailure::MissingController, + sharedState = stakingSharedState + ) + + @FeatureScope + @Provides + fun provideBalanceTowardsValidation( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ): EnoughBalanceToStayAboveEDValidation { + return enoughTotalToStayAboveEDValidationFactory.create( + fee = { it.fee }, + balance = { it.asset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.stashState.chain, it.stashState.chainAsset) }, + error = { payload, error -> RewardDestinationValidationFailure.NotEnoughBalanceToStayAboveED(payload.stashState.chainAsset, error) } + ) + } + + @FeatureScope + @Provides + fun provideRedeemValidationSystem( + feeValidation: RewardDestinationFeeValidation, + controllerRequiredValidation: RewardDestinationControllerRequiredValidation, + enoughToStayAboveEDValidation: EnoughBalanceToStayAboveEDValidation + ) = RewardDestinationValidationSystem( + CompositeValidation( + validations = listOf( + feeValidation, + controllerRequiredValidation, + enoughToStayAboveEDValidation + ) + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/SetControllerValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/SetControllerValidationsModule.kt new file mode 100644 index 0000000..f1c3f76 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/SetControllerValidationsModule.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.validations.NotZeroBalanceValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.IsNotControllerAccountValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerFeeValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationSystem +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidationGeneric + +@Module +class SetControllerValidationsModule { + + @FeatureScope + @Provides + fun provideFeeValidation(): SetControllerFeeValidation { + return EnoughAmountToTransferValidationGeneric( + feeExtractor = { it.fee }, + availableBalanceProducer = { it.transferable }, + errorProducer = { SetControllerValidationFailure.NOT_ENOUGH_TO_PAY_FEES } + ) + } + + @FeatureScope + @Provides + fun provideControllerValidation( + stakingSharedState: StakingSharedState, + stakingRepository: StakingRepository + ) = IsNotControllerAccountValidation( + stakingRepository = stakingRepository, + controllerAddressProducer = { it.controllerAddress }, + errorProducer = { SetControllerValidationFailure.ALREADY_CONTROLLER }, + sharedState = stakingSharedState + ) + + @FeatureScope + @Provides + fun provideZeroBalanceControllerValidation( + stakingSharedState: StakingSharedState, + assetSourceRegistry: AssetSourceRegistry, + ): NotZeroBalanceValidation { + return NotZeroBalanceValidation( + assetSourceRegistry = assetSourceRegistry, + stakingSharedState = stakingSharedState + ) + } + + @FeatureScope + @Provides + fun provideSetControllerValidationSystem( + enoughToPayFeesValidation: SetControllerFeeValidation, + isNotControllerAccountValidation: IsNotControllerAccountValidation, + controllerAccountIsNotZeroBalance: NotZeroBalanceValidation + ) = SetControllerValidationSystem( + CompositeValidation( + validations = listOf( + enoughToPayFeesValidation, + isNotControllerAccountValidation, + controllerAccountIsNotZeroBalance + ) + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/SetupStakingValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/SetupStakingValidationsModule.kt new file mode 100644 index 0000000..9ad5642 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/SetupStakingValidationsModule.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.changeValidators +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory + +@Module +class SetupStakingValidationsModule { + + @Provides + @FeatureScope + fun provideSetupStakingValidationSystem( + stakingRepository: StakingRepository, + stakingSharedComputation: StakingSharedComputation, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ): SetupStakingValidationSystem { + return ValidationSystem.changeValidators(stakingRepository, stakingSharedComputation, enoughTotalToStayAboveEDValidationFactory) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/StakeActionsValidationModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/StakeActionsValidationModule.kt new file mode 100644 index 0000000..0ef5a5c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/StakeActionsValidationModule.kt @@ -0,0 +1,227 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Binds +import dagger.MapKey +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.BALANCE_CONTROLLER_IS_NOT_ALLOWED +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.BALANCE_REQUIRED_CONTROLLER +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.BALANCE_REQUIRED_STASH_META_ACCOUNT +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.ControllerAccountIsNotAllowedValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.MainStakingMetaAccountRequiredValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.MainStakingUnlockingLimitValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_ADD_PROXY +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_PROXIES +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_REWARD_DESTINATION +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_BOND_MORE +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_REBAG +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_REBOND +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_REDEEM +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_UNBOND +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationSystem +import javax.inject.Named + +@Target(AnnotationTarget.FUNCTION) +@MapKey +annotation class StakeActionsValidationKey(val value: String) + +@Module +class StakeActionsValidationsModule { + + // --------- validations ---------- + + @FeatureScope + @Named(BALANCE_REQUIRED_CONTROLLER) + @Provides + fun provideControllerValidation( + stakingSharedState: StakingSharedState, + accountRepository: AccountRepository + ) = MainStakingMetaAccountRequiredValidation( + accountRepository, + accountAddressExtractor = { it.stashState.controllerAddress }, + errorProducer = StakeActionsValidationFailure::ControllerRequired, + sharedState = stakingSharedState + ) + + @FeatureScope + @Named(BALANCE_REQUIRED_STASH_META_ACCOUNT) + @Provides + fun provideStashValidation( + stakingSharedState: StakingSharedState, + accountRepository: AccountRepository + ) = MainStakingMetaAccountRequiredValidation( + accountRepository, + accountAddressExtractor = { it.stashState.stashAddress }, + errorProducer = StakeActionsValidationFailure::StashRequired, + sharedState = stakingSharedState + ) + + @FeatureScope + @Named(BALANCE_CONTROLLER_IS_NOT_ALLOWED) + @Provides + fun provideControllerNotAllowedValidation( + stakingSharedState: StakingSharedState, + accountRepository: AccountRepository + ) = ControllerAccountIsNotAllowedValidation( + accountRepository, + stakingState = { it.stashState }, + errorProducer = StakeActionsValidationFailure::StashRequiredToManageProxies, + sharedState = stakingSharedState + ) + + @FeatureScope + @Provides + fun provideUnbondingLimitValidation( + stakingRepository: StakingRepository, + ) = MainStakingUnlockingLimitValidation( + stakingRepository, + stashStateProducer = { it.stashState }, + errorProducer = StakeActionsValidationFailure::UnbondingRequestLimitReached + ) + + // --------- validation systems ---------- + + @FeatureScope + @Named(SYSTEM_MANAGE_STAKING_REDEEM) + @Provides + fun provideRedeemValidationSystem( + @Named(BALANCE_REQUIRED_CONTROLLER) + controllerRequiredValidation: MainStakingMetaAccountRequiredValidation, + ) = StakeActionsValidationSystem( + CompositeValidation( + validations = listOf( + controllerRequiredValidation + ) + ) + ) + + @FeatureScope + @Named(SYSTEM_MANAGE_PROXIES) + @Provides + fun provideManageProxiesValidationSystem( + @Named(BALANCE_CONTROLLER_IS_NOT_ALLOWED) + controllerAccountIsNotAllowedValidation: ControllerAccountIsNotAllowedValidation, + ) = StakeActionsValidationSystem(controllerAccountIsNotAllowedValidation) + + @FeatureScope + @Named(SYSTEM_ADD_PROXY) + @Provides + fun provideAddProxiesValidationSystem( + @Named(BALANCE_CONTROLLER_IS_NOT_ALLOWED) + controllerAccountIsNotAllowedValidation: ControllerAccountIsNotAllowedValidation, + ) = StakeActionsValidationSystem(controllerAccountIsNotAllowedValidation) + + @FeatureScope + @Named(SYSTEM_MANAGE_STAKING_BOND_MORE) + @Provides + fun provideBondMoreValidationSystem( + @Named(BALANCE_REQUIRED_STASH_META_ACCOUNT) + stashRequiredValidation: MainStakingMetaAccountRequiredValidation, + ) = StakeActionsValidationSystem( + CompositeValidation( + validations = listOf( + stashRequiredValidation, + ) + ) + ) + + @FeatureScope + @Named(SYSTEM_MANAGE_STAKING_UNBOND) + @Provides + fun provideUnbondValidationSystem( + @Named(BALANCE_REQUIRED_CONTROLLER) + controllerRequiredValidation: MainStakingMetaAccountRequiredValidation, + balanceUnlockingLimitValidation: MainStakingUnlockingLimitValidation + ) = StakeActionsValidationSystem( + CompositeValidation( + validations = listOf( + controllerRequiredValidation, + balanceUnlockingLimitValidation + ) + ) + ) + + @FeatureScope + @Named(SYSTEM_MANAGE_STAKING_REBOND) + @Provides + fun provideRebondValidationSystem( + @Named(BALANCE_REQUIRED_CONTROLLER) + controllerRequiredValidation: MainStakingMetaAccountRequiredValidation + ) = StakeActionsValidationSystem( + CompositeValidation( + validations = listOf( + controllerRequiredValidation + ) + ) + ) + + @FeatureScope + @Named(SYSTEM_MANAGE_STAKING_REBAG) + @Provides + fun provideRebagValidationSystem( + @Named(BALANCE_REQUIRED_STASH_META_ACCOUNT) + stashRequiredValidation: MainStakingMetaAccountRequiredValidation + ): StakeActionsValidationSystem = ValidationSystem { + validate(stashRequiredValidation) + } + + @FeatureScope + @Named(SYSTEM_MANAGE_REWARD_DESTINATION) + @Provides + fun provideRewardDestinationValidationSystemToMap( + @Named(BALANCE_REQUIRED_CONTROLLER) + controllerRequiredValidation: MainStakingMetaAccountRequiredValidation, + ): StakeActionsValidationSystem = StakeActionsValidationSystem(controllerRequiredValidation) +} + +@Module(includes = [StakeActionsValidationsModule::class]) +interface StakeActionsValidationModule { + + @FeatureScope + @StakeActionsValidationKey(SYSTEM_MANAGE_REWARD_DESTINATION) + @IntoMap + @Binds + fun provideRewardDestinationValidationSystemToMap( + @Named(SYSTEM_MANAGE_REWARD_DESTINATION) system: StakeActionsValidationSystem, + ): StakeActionsValidationSystem + + @FeatureScope + @StakeActionsValidationKey(SYSTEM_MANAGE_STAKING_BOND_MORE) + @IntoMap + @Binds + fun provideBondMoreValidationSystemToMap( + @Named(SYSTEM_MANAGE_STAKING_BOND_MORE) system: StakeActionsValidationSystem, + ): StakeActionsValidationSystem + + @FeatureScope + @StakeActionsValidationKey(SYSTEM_MANAGE_STAKING_UNBOND) + @IntoMap + @Binds + fun provideUnbondValidationSystemToMap( + @Named(SYSTEM_MANAGE_STAKING_UNBOND) system: StakeActionsValidationSystem, + ): StakeActionsValidationSystem + + @FeatureScope + @StakeActionsValidationKey(SYSTEM_MANAGE_PROXIES) + @IntoMap + @Binds + fun provideManageProxyValidationSystemToMap( + @Named(SYSTEM_MANAGE_PROXIES) system: StakeActionsValidationSystem, + ): StakeActionsValidationSystem + + @FeatureScope + @StakeActionsValidationKey(SYSTEM_ADD_PROXY) + @IntoMap + @Binds + fun provideAddProxyValidationSystemToMap( + @Named(SYSTEM_ADD_PROXY) system: StakeActionsValidationSystem, + ): StakeActionsValidationSystem +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/UnbondValidationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/UnbondValidationsModule.kt new file mode 100644 index 0000000..c48d7b1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/di/validations/UnbondValidationsModule.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.feature_staking_impl.di.validations + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.CompositeValidation +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.EnoughToUnbondValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.NotZeroUnbondValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondFeeValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondLimitValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationFailure.BondedWillCrossExistential +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.CrossMinimumBalanceValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidationGeneric +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughBalanceToStayAboveEDValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +typealias RemainingUnbondValidation = CrossMinimumBalanceValidation + +@Module +class UnbondValidationsModule { + + @FeatureScope + @Provides + fun provideFeeValidation(): UnbondFeeValidation = EnoughAmountToTransferValidationGeneric( + feeExtractor = { it.fee }, + availableBalanceProducer = { it.asset.transferable }, + errorProducer = { UnbondValidationFailure.CannotPayFees } + ) + + @FeatureScope + @Provides + fun provideNotZeroUnbondValidation() = NotZeroUnbondValidation( + amountExtractor = { it.amount }, + errorProvider = { UnbondValidationFailure.ZeroUnbond } + ) + + @FeatureScope + @Provides + fun provideUnbondLimitValidation( + stakingRepository: StakingRepository + ) = UnbondLimitValidation( + stakingRepository = stakingRepository, + stashStateProducer = { it.stash }, + errorProducer = UnbondValidationFailure::UnbondLimitReached + ) + + @FeatureScope + @Provides + fun provideEnoughToUnbondValidation() = EnoughToUnbondValidation() + + @FeatureScope + @Provides + fun provideCrossExistentialValidation( + walletConstants: WalletConstants + ) = RemainingUnbondValidation( + minimumBalance = { walletConstants.existentialDeposit(it.asset.token.configuration.chainId) }, + chainAsset = { it.asset.token.configuration }, + currentBalance = { it.asset.bonded }, + deductingAmount = { it.amount }, + error = ::BondedWillCrossExistential + ) + + @FeatureScope + @Provides + fun provideBalanceTowardsValidation( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ): EnoughBalanceToStayAboveEDValidation { + return enoughTotalToStayAboveEDValidationFactory.create( + fee = { it.fee }, + balance = { it.asset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.stash.chain, it.stash.chain.commissionAsset) }, + error = { payload, error -> UnbondValidationFailure.NotEnoughBalanceToStayAboveED(payload.stash.chain.commissionAsset, error) } + ) + } + + @FeatureScope + @Provides + fun provideUnbondValidationSystem( + unbondFeeValidation: UnbondFeeValidation, + notZeroUnbondValidation: NotZeroUnbondValidation, + unbondLimitValidation: UnbondLimitValidation, + enoughToUnbondValidation: EnoughToUnbondValidation, + remainingBondedAmountValidation: RemainingUnbondValidation, + enoughToStayAboveEDValidation: EnoughBalanceToStayAboveEDValidation + ) = UnbondValidationSystem( + CompositeValidation( + validations = listOf( + unbondFeeValidation, + notZeroUnbondValidation, + unbondLimitValidation, + enoughToUnbondValidation, + remainingBondedAmountValidation, + enoughToStayAboveEDValidation + ) + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/StakingInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/StakingInteractor.kt new file mode 100644 index 0000000..8853db5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/StakingInteractor.kt @@ -0,0 +1,365 @@ +package io.novafoundation.nova.feature_staking_impl.domain + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novafoundation.nova.feature_staking_api.domain.model.StakingAccount +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.fullId +import io.novafoundation.nova.feature_staking_impl.data.mappers.mapAccountToStakingAccount +import io.novafoundation.nova.feature_staking_impl.data.repository.PayoutRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.ActiveEraInfo +import io.novafoundation.nova.feature_staking_impl.domain.common.EraTimeCalculator +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.isWaiting +import io.novafoundation.nova.feature_staking_impl.domain.model.NetworkInfo +import io.novafoundation.nova.feature_staking_impl.domain.model.NominatorStatus +import io.novafoundation.nova.feature_staking_impl.domain.model.PendingPayout +import io.novafoundation.nova.feature_staking_impl.domain.model.PendingPayoutsStatistics +import io.novafoundation.nova.feature_staking_impl.domain.model.StakeSummary +import io.novafoundation.nova.feature_staking_impl.domain.model.StakingPeriod +import io.novafoundation.nova.feature_staking_impl.domain.model.StashNoneStatus +import io.novafoundation.nova.feature_staking_impl.domain.model.TotalReward +import io.novafoundation.nova.feature_staking_impl.domain.model.ValidatorStatus +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.state.assetWithChain +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAndAsset +import io.novafoundation.nova.runtime.state.chainAsset +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.math.BigInteger +import kotlin.time.Duration + +val ERA_OFFSET = 1.toBigInteger() + +class StakingInteractor( + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, + private val stakingRepository: StakingRepository, + private val stakingRewardsRepository: StakingRewardsRepository, + private val stakingConstantsRepository: StakingConstantsRepository, + private val identityRepository: OnChainIdentityRepository, + private val stakingSharedState: StakingSharedState, + private val payoutRepository: PayoutRepository, + private val assetUseCase: AssetUseCase, + private val stakingSharedComputation: StakingSharedComputation, +) { + + suspend fun calculatePendingPayouts(scope: CoroutineScope): Result = withContext(Dispatchers.Default) { + runCatching { + val currentStakingState = selectedAccountStakingStateFlow(scope).first() + val chainId = currentStakingState.chain.id + val calculator = getEraTimeCalculator(scope) + + require(currentStakingState is StakingState.Stash) + + val activeEraIndex = stakingRepository.getActiveEraIndex(chainId) + val historyDepth = stakingRepository.getHistoryDepth(chainId) + + val payouts = payoutRepository.calculateUnpaidPayouts(currentStakingState) + + val allValidatorStashes = payouts.map { it.validatorStash.value }.distinct() + val identityMapping = identityRepository.getIdentitiesFromIds(allValidatorStashes, chainId) + + val pendingPayouts = payouts.map { + val erasLeft = remainingEras(createdAtEra = it.era, activeEraIndex, historyDepth) + + val closeToExpire = erasLeft < historyDepth / 2.toBigInteger() + + val leftTime = calculator.calculateTillEraSet(destinationEra = it.era + historyDepth + ERA_OFFSET).toLong() + val currentTimestamp = System.currentTimeMillis() + with(it) { + val validatorIdentity = identityMapping[validatorStash] + + val validatorInfo = PendingPayout.ValidatorInfo( + address = currentStakingState.chain.addressOf(validatorStash.value), + identityName = validatorIdentity?.display + ) + + PendingPayout( + validatorInfo = validatorInfo, + era = era, + amountInPlanks = amount, + timeLeft = leftTime, + timeLeftCalculatedAt = currentTimestamp, + closeToExpire = closeToExpire, + pagesToClaim = pagesToClaim + ) + } + }.sortedBy { it.era } + + PendingPayoutsStatistics( + payouts = pendingPayouts, + totalAmountInPlanks = pendingPayouts.sumByBigInteger(PendingPayout::amountInPlanks) + ) + } + } + + suspend fun syncStakingRewards( + stakingState: StakingState.Stash, + stakingOption: StakingOption, + period: RewardPeriod + ) = withContext(Dispatchers.IO) { + runCatching { + stakingRewardsRepository.sync(stakingState.stashId, stakingOption, period) + } + } + + suspend fun observeStashSummary( + stashState: StakingState.Stash.None, + scope: CoroutineScope + ): Flow> = observeStakeSummary(stashState, scope) { + emit(StashNoneStatus.INACTIVE) + } + + suspend fun observeValidatorSummary( + validatorState: StakingState.Stash.Validator, + scope: CoroutineScope + ): Flow> = observeStakeSummary(validatorState, scope) { + val status = when { + it.activeStake.isZero -> ValidatorStatus.INACTIVE + isValidatorActive(validatorState.stashId, it.activeEraInfo.exposures) -> ValidatorStatus.ACTIVE + else -> ValidatorStatus.INACTIVE + } + + emit(status) + } + + suspend fun observeNominatorSummary( + nominatorState: StakingState.Stash.Nominator, + scope: CoroutineScope + ): Flow> = observeStakeSummary(nominatorState, scope) { + val eraStakers = it.activeEraInfo.exposures.values + + when { + it.activeStake.isZero -> emit(NominatorStatus.Inactive(NominatorStatus.Inactive.Reason.MIN_STAKE)) + + nominationStatus(nominatorState.stashId, eraStakers, it.rewardedNominatorsPerValidator).isActive -> emit(NominatorStatus.Active) + + nominatorState.nominations.isWaiting(it.activeEraInfo.eraIndex) -> { + val nextEra = nominatorState.nominations.submittedInEra + ERA_OFFSET + + val timerFlow = eraTimeCalculatorFlow(scope).map { eraTimeCalculator -> + val timeLift = eraTimeCalculator.calculate(nextEra).toLong() + + NominatorStatus.Waiting(timeLift) + } + + emitAll(timerFlow) + } + + else -> { + val inactiveReason = when { + it.asset.bondedInPlanks < it.activeEraInfo.minStake -> NominatorStatus.Inactive.Reason.MIN_STAKE + else -> NominatorStatus.Inactive.Reason.NO_ACTIVE_VALIDATOR + } + + emit(NominatorStatus.Inactive(inactiveReason)) + } + } + } + + fun observeUserRewards( + state: StakingState.Stash, + stakingOption: StakingOption, + ): Flow { + return stakingRewardsRepository.totalRewardFlow(state.stashId, stakingOption.fullId) + } + + fun observeNetworkInfoState(chainId: ChainId, scope: CoroutineScope): Flow = flow { + val lockupPeriod = getLockupDuration(chainId, scope) + + val innerFlow = stakingSharedComputation.activeEraInfo(chainId, scope).map { activeEraInfo -> + val exposures = activeEraInfo.exposures.values + + NetworkInfo( + lockupPeriod = lockupPeriod, + minimumStake = activeEraInfo.minStake, + totalStake = totalStake(exposures), + stakingPeriod = StakingPeriod.Unlimited, + nominatorsCount = activeNominators(chainId, exposures), + ) + } + + emitAll(innerFlow) + } + + suspend fun getLockupDuration(sharedComputationScope: CoroutineScope) = withContext(Dispatchers.Default) { + getLockupDuration(stakingSharedState.chainId(), sharedComputationScope) + } + + fun selectedAccountStakingStateFlow(scope: CoroutineScope) = flowOfAll { + val assetWithChain = stakingSharedState.chainAndAsset() + + stakingSharedComputation.selectedAccountStakingStateFlow( + assetWithChain = assetWithChain, + scope = scope + ) + } + + suspend fun getAccountProjectionsInSelectedChains() = withContext(Dispatchers.Default) { + val chain = stakingSharedState.chain() + + accountRepository.getActiveMetaAccounts().mapNotNull { + mapAccountToStakingAccount(chain, it) + } + } + + fun currentAssetFlow() = assetUseCase.currentAssetFlow() + + fun chainFlow() = assetUseCase.currentAssetAndOptionFlow().map { it.option.assetWithChain.chain } + + fun assetFlow(accountAddress: String): Flow { + return flow { + val (chain, chainAsset) = stakingSharedState.assetWithChain.first() + + emitAll( + walletRepository.assetFlow( + accountId = chain.accountIdOf(accountAddress), + chainAsset = chainAsset + ) + ) + } + } + + suspend fun getSelectedAccountProjection(): StakingAccount = withContext(Dispatchers.Default) { + val metaAccount = accountRepository.getSelectedMetaAccount() + val selectedChain = stakingSharedState.chain() + + mapAccountToStakingAccount(selectedChain, metaAccount)!! + } + + suspend fun getRewardDestination(accountStakingState: StakingState.Stash): RewardDestination = withContext(Dispatchers.Default) { + stakingRepository.getRewardDestination(accountStakingState) + } + + suspend fun maxValidatorsPerNominator(stake: Balance): Int = withContext(Dispatchers.Default) { + stakingConstantsRepository.maxValidatorsPerNominator(stakingSharedState.chainId(), stake) + } + + suspend fun maxRewardedNominators(): Int? = withContext(Dispatchers.Default) { + stakingConstantsRepository.maxRewardedNominatorPerValidator(stakingSharedState.chainId()) + } + + private suspend fun eraTimeCalculatorFlow(coroutineScope: CoroutineScope): Flow { + return stakingSharedComputation.eraCalculatorFlow(stakingSharedState.selectedOption(), coroutineScope) + } + + private suspend fun getEraTimeCalculator(coroutineScope: CoroutineScope): EraTimeCalculator { + return eraTimeCalculatorFlow(coroutineScope).first() + } + + private fun remainingEras( + createdAtEra: BigInteger, + activeEra: BigInteger, + lifespanInEras: BigInteger, + ): BigInteger { + val erasPast = activeEra - createdAtEra + + return lifespanInEras - erasPast + } + + private suspend fun observeStakeSummary( + state: StakingState.Stash, + scope: CoroutineScope, + statusResolver: suspend FlowCollector.(StatusResolutionContext) -> Unit, + ): Flow> = withContext(Dispatchers.Default) { + val chainAsset = stakingSharedState.chainAsset() + val chainId = chainAsset.chainId + + combineToPair( + stakingSharedComputation.activeEraInfo(chainId, scope), + walletRepository.assetFlow(state.accountId, chainAsset) + ).flatMapLatest { (activeEraInfo, asset) -> + val activeStake = asset.bondedInPlanks + + val rewardedNominatorsPerValidator = stakingConstantsRepository.maxRewardedNominatorPerValidator(chainId) + + val statusResolutionContext = StatusResolutionContext( + activeEraInfo, + asset, + rewardedNominatorsPerValidator, + activeStake = activeStake + ) + + flow { statusResolver(statusResolutionContext) }.map { status -> + StakeSummary( + status = status, + activeStake = activeStake + ) + } + } + } + + private fun isValidatorActive(stashId: ByteArray, exposures: AccountIdMap): Boolean { + val stashIdHex = stashId.toHexString() + + return stashIdHex in exposures.keys + } + + private suspend fun activeNominators(chainId: ChainId, exposures: Collection): Int { + val active = mutableSetOf() + val activeNominatorsPerValidator = stakingConstantsRepository.maxRewardedNominatorPerValidator(chainId) + + exposures.forEach { exposure -> + val activeNominatorsOfValidator = if (activeNominatorsPerValidator != null) { + exposure.others.take(activeNominatorsPerValidator) + } else { + exposure.others + } + + active.addAll(activeNominatorsOfValidator.map { it.who.intoKey() }) + } + + return active.size + } + + private fun totalStake(exposures: Collection): BigInteger { + return exposures.sumOf(Exposure::total) + } + + private suspend fun getLockupDuration(chainId: ChainId, coroutineScope: CoroutineScope): Duration { + val eraCalculator = getEraTimeCalculator(coroutineScope) + val eraDuration = eraCalculator.eraDuration() + + return eraDuration * stakingConstantsRepository.lockupPeriodInEras(chainId).toInt() + } + + private class StatusResolutionContext( + val activeEraInfo: ActiveEraInfo, + val asset: Asset, + val rewardedNominatorsPerValidator: Int?, + val activeStake: Balance, + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/StakingInteractorExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/StakingInteractorExt.kt new file mode 100644 index 0000000..6f613e6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/StakingInteractorExt.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_staking_impl.domain + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.IndividualExposure +import io.novafoundation.nova.feature_staking_impl.domain.bagList.BagListLocator +import io.novafoundation.nova.feature_staking_impl.domain.bagList.BagListScoreConverter +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +enum class NominationStatus { + NOT_PRESENT, OVERSUBSCRIBED, ACTIVE +} + +val NominationStatus.isActive: Boolean + get() = this == NominationStatus.ACTIVE + +val NominationStatus.isOversubscribed: Boolean + get() = this == NominationStatus.OVERSUBSCRIBED + +fun nominationStatus( + stashId: AccountId, + exposures: Collection, + rewardedNominatorsPerValidator: Int? +): NominationStatus { + val comparator = { accountId: IndividualExposure -> + accountId.who.contentEquals(stashId) + } + + val validatorsWithOurStake = exposures.filter { exposure -> + exposure.others.any(comparator) + } + if (validatorsWithOurStake.isEmpty()) { + return NominationStatus.NOT_PRESENT + } + + val willBeRewarded = rewardedNominatorsPerValidator == null || validatorsWithOurStake.any { + it.willAccountBeRewarded(stashId, rewardedNominatorsPerValidator) + } + + return if (willBeRewarded) NominationStatus.ACTIVE else NominationStatus.OVERSUBSCRIBED +} + +fun Exposure.willAccountBeRewarded( + accountId: AccountId, + rewardedNominatorsPerValidator: Int +): Boolean { + val indexInRewardedList = others.sortedByDescending(IndividualExposure::value).indexOfFirst { + it.who.contentEquals(accountId) + } + + if (indexInRewardedList == -1) { + return false + } + + val numberInRewardedList = indexInRewardedList + 1 + + return numberInRewardedList <= rewardedNominatorsPerValidator +} + +fun minimumStake( + exposures: Collection, + minimumNominatorBond: BigInteger, + bagListLocator: BagListLocator?, + bagListScoreConverter: BagListScoreConverter, + bagListSize: BigInteger?, + maxElectingVoters: BigInteger? +): BigInteger { + if (bagListSize == null || maxElectingVoters == null || bagListSize < maxElectingVoters) return minimumNominatorBond + + val stakeByNominator = exposures + .fold(mutableMapOf()) { acc, exposure -> + exposure.others + .forEach { individualExposure -> + val key = individualExposure.who.intoKey() + val currentExposure = acc.getOrDefault(key, BigInteger.ZERO) + acc[key] = currentExposure + individualExposure.value + } + + acc + } + + val minElectedStake = stakeByNominator.values.minOrNull().orZero().coerceAtLeast(minimumNominatorBond) + + if (bagListLocator == null) return minElectedStake + + val lastElectedBag = bagListLocator.bagBoundaries(bagListScoreConverter.scoreOf(minElectedStake)) + + val nextBagThreshold = bagListScoreConverter.balanceOf(lastElectedBag.endInclusive ?: lastElectedBag.start) + val epsilon = Balance.ONE + + val nextBagRequiredAmount = nextBagThreshold + epsilon + + return nextBagRequiredAmount.coerceAtLeast(minElectedStake) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/alerts/Alert.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/alerts/Alert.kt new file mode 100644 index 0000000..08e1712 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/alerts/Alert.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.domain.alerts + +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import java.math.BigDecimal + +sealed class Alert { + + class RedeemTokens(val amount: BigDecimal, val token: Token) : Alert() + + class BondMoreTokens(val minimalStake: BigDecimal, val token: Token) : Alert() + + class ChangeValidators(val reason: Reason) : Alert() { + + enum class Reason { + NONE_ELECTED, OVERSUBSCRIBED + } + } + + object WaitingForNextEra : Alert() + + object SetValidators : Alert() + + object Rebag : Alert() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/alerts/AlertsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/alerts/AlertsInteractor.kt new file mode 100644 index 0000000..7004c55 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/alerts/AlertsInteractor.kt @@ -0,0 +1,185 @@ +package io.novafoundation.nova.feature_staking_impl.domain.alerts + +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.data.repository.BagListRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.alerts.Alert.ChangeValidators.Reason +import io.novafoundation.nova.feature_staking_impl.domain.bagList.BagListScoreConverter +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.isWaiting +import io.novafoundation.nova.feature_staking_impl.domain.isActive +import io.novafoundation.nova.feature_staking_impl.domain.isOversubscribed +import io.novafoundation.nova.feature_staking_impl.domain.model.BagListNode +import io.novafoundation.nova.feature_staking_impl.domain.nominationStatus +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import java.math.BigDecimal +import java.math.BigInteger + +private const val NOMINATIONS_ACTIVE_MEMO = "NOMINATIONS_ACTIVE_MEMO" + +class AlertsInteractor( + private val stakingRepository: StakingRepository, + private val stakingConstantsRepository: StakingConstantsRepository, + private val walletRepository: WalletRepository, + private val stakingSharedComputation: StakingSharedComputation, + private val bagListRepository: BagListRepository, + private val totalIssuanceRepository: TotalIssuanceRepository, +) { + + class AlertContext( + val exposures: Map, + val stakingState: StakingState, + val maxRewardedNominatorsPerValidator: Int?, + val minRecommendedStake: Balance, + val activeEra: BigInteger, + val asset: Asset, + val bagListNode: BagListNode?, + val bagListScoreConverter: BagListScoreConverter, + ) { + + val memo = mutableMapOf() + + inline fun useMemo( + key: Any, + lazyProducer: () -> T, + ): T { + return memo.getOrPut(key, lazyProducer) as T + } + } + + private fun AlertContext.nominationStatus(stashId: AccountId) = useMemo(NOMINATIONS_ACTIVE_MEMO) { + nominationStatus(stashId, exposures.values, maxRewardedNominatorsPerValidator) + } + private fun AlertContext.isStakingActive(stashId: AccountId) = nominationStatus(stashId).isActive + + private fun produceSetValidatorsAlert(context: AlertContext): Alert? { + return requireState(context.stakingState) { _: StakingState.Stash.None -> + Alert.SetValidators + } + } + + private fun produceChangeValidatorsAlert(context: AlertContext): Alert? { + return requireState(context.stakingState) { nominatorState: StakingState.Stash.Nominator -> + val targets = nominatorState.nominations.targets.map { it.toHexString() } + + when { + // none of nominated validators were elected + targets.intersect(context.exposures.keys).isEmpty() -> Alert.ChangeValidators(Reason.NONE_ELECTED) + + // user's delegation is elected but it is in oversubscribed part + context.nominationStatus(nominatorState.stashId).isOversubscribed && + // there is no pending change + nominatorState.nominations.isWaiting(context.activeEra).not() -> Alert.ChangeValidators(Reason.OVERSUBSCRIBED) + + else -> null + } + } + } + + private fun produceRedeemableAlert(context: AlertContext): Alert? = requireState(context.stakingState) { _: StakingState.Stash -> + with(context.asset) { + if (redeemable > BigDecimal.ZERO) Alert.RedeemTokens(redeemable, token) else null + } + } + + private fun produceMinStakeAlert(context: AlertContext) = requireState(context.stakingState) { state: StakingState.Stash -> + with(context) { + if ( + // do not show alert for validators + state !is StakingState.Stash.Validator && + asset.bondedInPlanks < context.minRecommendedStake && + // prevent alert for situation where all tokens are being unbounded + asset.bondedInPlanks > BigInteger.ZERO + ) { + val minimalStake = asset.token.amountFromPlanks(context.minRecommendedStake) + + Alert.BondMoreTokens(minimalStake, asset.token) + } else { + null + } + } + } + + private fun produceWaitingNextEraAlert(context: AlertContext) = requireState(context.stakingState) { nominatorState: StakingState.Stash.Nominator -> + Alert.WaitingForNextEra.takeIf { + val isStakingActive = context.isStakingActive(nominatorState.stashId) + + // staking is inactive and there is pending change + isStakingActive.not() && nominatorState.nominations.isWaiting(context.activeEra) + } + } + + private fun produceBagListAlert(context: AlertContext) = requireState(context.stakingState) { _: StakingState.Stash.Nominator -> + val bagListNode = context.bagListNode ?: return@requireState null + + val currentScore = context.bagListScoreConverter.scoreOf(context.asset.bondedInPlanks) + + Alert.Rebag.takeIf { currentScore > bagListNode.bagUpper } + } + + private val alertProducers = listOf( + ::produceChangeValidatorsAlert, + ::produceRedeemableAlert, + ::produceMinStakeAlert, + ::produceWaitingNextEraAlert, + ::produceSetValidatorsAlert, + ::produceBagListAlert + ) + + fun getAlertsFlow(stakingState: StakingState, scope: CoroutineScope): Flow> = flow { + if (stakingState !is StakingState.Stash) { + emit(emptyList()) + return@flow + } + + val chain = stakingState.chain + val chainAsset = stakingState.chainAsset + + val maxRewardedNominatorsPerValidator = stakingConstantsRepository.maxRewardedNominatorPerValidator(chain.id) + val totalIssuance = totalIssuanceRepository.getTotalIssuance(chain.id) + val bagListScoreConverter = BagListScoreConverter.U128(totalIssuance) + + val alertsFlow = combine( + stakingSharedComputation.activeEraInfo(chain.id, scope), + walletRepository.assetFlow(stakingState.accountId, chainAsset), + stakingRepository.observeActiveEraIndex(chain.id), + bagListRepository.listNodeFlow(stakingState.stashId, chain.id) + ) { activeEraInfo, asset, activeEra, bagListNode -> + + val context = AlertContext( + exposures = activeEraInfo.exposures, + stakingState = stakingState, + maxRewardedNominatorsPerValidator = maxRewardedNominatorsPerValidator, + minRecommendedStake = activeEraInfo.minStake, + asset = asset, + activeEra = activeEra, + bagListNode = bagListNode, + bagListScoreConverter = bagListScoreConverter + ) + + alertProducers.mapNotNull { it.invoke(context) } + } + + emitAll(alertsFlow) + } + + private inline fun requireState( + state: StakingState, + block: (T) -> R, + ): R? { + return (state as? T)?.let(block) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/BagListLocator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/BagListLocator.kt new file mode 100644 index 0000000..76cc783 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/BagListLocator.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.domain.bagList + +import io.novafoundation.nova.common.utils.rangeTo +import io.novafoundation.nova.feature_staking_impl.domain.model.BagListNode.Score + +interface BagListLocator { + + fun bagBoundaries(userScore: Score): BagScoreBoundaries +} + +fun BagListLocator(thresholds: List): BagListLocator = RealBagListLocator(thresholds) + +private class RealBagListLocator(private val thresholds: List) : BagListLocator { + + override fun bagBoundaries(userScore: Score): BagScoreBoundaries { + val bagIndex = notionalBagIndexFor(userScore) + + return bagBoundariesAt(bagIndex) + } + + private fun bagBoundariesAt(index: Int): BagScoreBoundaries { + val bagUpper = thresholds.getOrNull(index) + val bagLower = if (index > 0) thresholds[index - 1] else Score.zero() + + return bagLower..bagUpper + } + + private fun notionalBagIndexFor(score: Score): Int { + val index = thresholds.binarySearch(score) + + return if (index >= 0) { + index + } else { + val insertionPoint = (-index - 1) // convert from inverted insertion point + + insertionPoint + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/BagListScoreConverter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/BagListScoreConverter.kt new file mode 100644 index 0000000..4e3bead --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/BagListScoreConverter.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.domain.bagList + +import io.novafoundation.nova.feature_staking_impl.domain.model.BagListNode +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger + +interface BagListScoreConverter { + + companion object { + + fun U128(totalIssuance: Balance): BagListScoreConverter = U128BagListScoreConverter(totalIssuance) + } + + fun scoreOf(stake: Balance): BagListNode.Score + + fun balanceOf(score: BagListNode.Score): Balance +} + +private val U64_MAX = BigInteger("18446744073709551615") + +private class U128BagListScoreConverter( + private val totalIssuance: Balance +) : BagListScoreConverter { + + private val factor = (totalIssuance / U64_MAX).max(BigInteger.ONE) + + override fun scoreOf(stake: Balance): BagListNode.Score { + return BagListNode.Score(stake / factor) + } + + override fun balanceOf(score: BagListNode.Score): Balance { + return score.value * factor + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/BagScoreBoundaries.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/BagScoreBoundaries.kt new file mode 100644 index 0000000..7e578e4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/BagScoreBoundaries.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.domain.bagList + +import io.novafoundation.nova.common.utils.SemiUnboundedRange +import io.novafoundation.nova.feature_staking_impl.domain.model.BagListNode.Score + +typealias BagScoreBoundaries = SemiUnboundedRange diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/RebagInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/RebagInteractor.kt new file mode 100644 index 0000000..75d081d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/RebagInteractor.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.map +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.stashTransactionOrigin +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.rebag +import io.novafoundation.nova.feature_staking_impl.data.repository.BagListRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.bagListLocatorOrThrow +import io.novafoundation.nova.feature_staking_impl.domain.bagList.BagListScoreConverter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull + +interface RebagInteractor { + + suspend fun calculateFee(stakingState: StakingState.Stash): Fee + + suspend fun rebag(stakingState: StakingState.Stash): Result + + fun rebagMovementFlow(stakingState: StakingState.Stash): Flow +} + +class RealRebagInteractor( + private val totalIssuanceRepository: TotalIssuanceRepository, + private val bagListRepository: BagListRepository, + private val assetUseCase: AssetUseCase, + private val extrinsicService: ExtrinsicService, +) : RebagInteractor { + + override suspend fun calculateFee(stakingState: StakingState.Stash): Fee { + return extrinsicService.estimateFee(stakingState.chain, stakingState.stashTransactionOrigin()) { + rebag(stakingState.stashId) + } + } + + override suspend fun rebag(stakingState: StakingState.Stash): Result { + return extrinsicService.submitExtrinsic(stakingState.chain, stakingState.stashTransactionOrigin()) { + rebag(stakingState.stashId) + } + } + + override fun rebagMovementFlow(stakingState: StakingState.Stash): Flow { + return flowOfAll { + val chain = stakingState.chain + val totalIssuance = totalIssuanceRepository.getTotalIssuance(chain.id) + val bagListLocator = bagListRepository.bagListLocatorOrThrow(chain.id) + val bagScoreConverter = BagListScoreConverter.U128(totalIssuance) + + combine( + assetUseCase.currentAssetFlow(), + bagListRepository.listNodeFlow(stakingState.stashId, chain.id).filterNotNull() + ) { asset, bagListNode -> + val from = bagListLocator.bagBoundaries(bagListNode.score).map(bagScoreConverter::balanceOf) + + val actualUserScore = bagScoreConverter.scoreOf(asset.bondedInPlanks) + val to = bagListLocator.bagBoundaries(actualUserScore).map(bagScoreConverter::balanceOf) + + RebagMovement(from = from, to = to) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/RebagMovement.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/RebagMovement.kt new file mode 100644 index 0000000..99e8ff7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/RebagMovement.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag + +import io.novafoundation.nova.common.utils.SemiUnboundedRange +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +typealias BagAmountBoundaries = SemiUnboundedRange + +class RebagMovement( + val from: BagAmountBoundaries, + val to: BagAmountBoundaries, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/RebagValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/RebagValidationFailure.kt new file mode 100644 index 0000000..7e0ce1f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/RebagValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class RebagValidationFailure { + + class NotEnoughToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : RebagValidationFailure(), NotEnoughToPayFeesError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/RebagValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/RebagValidationPayload.kt new file mode 100644 index 0000000..095fae6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/RebagValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.validations + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee + +class RebagValidationPayload( + val fee: Fee, + val asset: Asset, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/RebagValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/RebagValidationSystem.kt new file mode 100644 index 0000000..6c165a4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/RebagValidationSystem.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias RebagValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.rebagValidationSystem(): RebagValidationSystem = ValidationSystem { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + RebagValidationFailure.NotEnoughToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/ValidationFailureUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/ValidationFailureUi.kt new file mode 100644 index 0000000..612f93d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/bagList/rebag/validations/ValidationFailureUi.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError + +fun handleRebagValidationFailure(failure: RebagValidationFailure, resourceManager: ResourceManager): TitleAndMessage { + return when (failure) { + is RebagValidationFailure.NotEnoughToPayFees -> handleNotEnoughFeeError(failure, resourceManager) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/AssetExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/AssetExt.kt new file mode 100644 index 0000000..9fce60f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/AssetExt.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset + +val Asset.totalStakedPlanks: Balance + get() = bondedInPlanks + redeemableInPlanks + unbondingInPlanks diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/EraCalculatorDiffing.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/EraCalculatorDiffing.kt new file mode 100644 index 0000000..9406f0c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/EraCalculatorDiffing.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common + +import android.util.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +interface EraRewardCalculatorComparable { + + /** + * Returns a number that can be used to compare different instances of [EraTimeCalculator] + * to determine how much their calculations would deffer between each other + * This wont correspond to real timestamp and shouldn't be used as such + */ + fun derivedTimestamp(): Duration +} + +fun Flow.ignoreInsignificantTimeChanges(): Flow { + return distinctUntilChanged { old, new -> new.canBeIgnoredAfter(old) } +} + +private fun EraRewardCalculatorComparable.canBeIgnoredAfter(previous: EraRewardCalculatorComparable): Boolean { + val previousTimestamp = previous.derivedTimestamp() + val newTimestamp = derivedTimestamp() + + val difference = (newTimestamp - previousTimestamp).absoluteValue + + val canIgnore = difference < ERA_DURATION_DIFFERENCE_THRESHOLD + + Log.d("EraRewardCalculatorComparable", "New update for EraRewardCalculatorComparable, difference: $difference can ignore: $canIgnore") + + return canIgnore +} + +private val ERA_DURATION_DIFFERENCE_THRESHOLD = 10.minutes diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/EraCalculatorFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/EraCalculatorFactory.kt new file mode 100644 index 0000000..3f6737f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/EraCalculatorFactory.kt @@ -0,0 +1,139 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.toDuration +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.repository.SessionRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.consensus.ElectionsSessionRegistry +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import java.math.BigInteger +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class EraTimeCalculator( + private val startTimeStamp: BigInteger, + private val sessionLength: BigInteger, // Number of blocks per session + private val eraLength: BigInteger, // Number of sessions per era + private val blockCreationTime: BigInteger, // How long it takes to create a block + private val currentSessionIndex: BigInteger, + private val currentEpochIndex: BigInteger, + private val currentSlot: BigInteger, + private val genesisSlot: BigInteger, + private val eraStartSessionIndex: BigInteger, + val activeEra: EraIndex, +) : EraRewardCalculatorComparable { + + fun calculate(destinationEra: EraIndex? = null): BigInteger { + val eraRemained = remainingEraBlocks() + + // EraTimeCalculator was initialized based on values known at startTimeStamp. + // We need to adjust timers in case this instance is used for a long period of time + val finishTimeStamp = System.currentTimeMillis().toBigInteger() + val deltaTime = finishTimeStamp - startTimeStamp + + return if (destinationEra != null) { + val leftEras = destinationEra - activeEra - 1.toBigInteger() + val timeForLeftEras = leftEras * eraLength * sessionLength * blockCreationTime + + eraRemained * blockCreationTime + timeForLeftEras - deltaTime + } else { + eraRemained * blockCreationTime - deltaTime + } + } + + /** + * Duration till the end of current active era + */ + fun remainingEraDuration(): Duration { + return (remainingEraBlocks() * blockCreationTime).toDuration() + } + + fun eraDuration(): Duration { + return (blockCreationTime * eraLength * sessionLength).toDuration() + } + + fun calculateTillEraSet(destinationEra: EraIndex): BigInteger { + val sessionDuration = sessionLength * blockCreationTime + val tillEraStart = calculate(destinationEra) + return tillEraStart - sessionDuration + } + + override fun derivedTimestamp(): Duration { + val derivedProgressInBlocks = activeEra * eraLength * sessionLength + eraProgress() + + return (derivedProgressInBlocks * blockCreationTime).toDuration() + } + + private fun eraProgress(): BlockNumber { + val epochStartSlot = currentEpochIndex * sessionLength + genesisSlot + val sessionProgress = currentSlot - epochStartSlot + + return (currentSessionIndex - eraStartSessionIndex) * sessionLength + sessionProgress + } + + private fun remainingEraBlocks(): BlockNumber { + return eraLength * sessionLength - eraProgress() + } +} + +fun EraTimeCalculator.erasDuration(numberOfEras: BigInteger): Duration { + return eraDuration() * numberOfEras.toInt() +} + +fun EraTimeCalculator.calculateDurationTill(era: EraIndex): Duration { + return calculate(era).toLong().milliseconds +} + +class EraTimeCalculatorFactory( + private val stakingRepository: StakingRepository, + private val sessionRepository: SessionRepository, + private val chainStateRepository: ChainStateRepository, + private val electionsSessionRegistry: ElectionsSessionRegistry, +) { + + suspend fun create( + stakingOption: StakingOption, + activeEraFlow: Flow + ): Flow { + val stakingChain = stakingOption.chain + val stakingChainId = stakingChain.id + val timelineChainId = stakingChain.timelineChainIdOrSelf() + + val electionsSession = electionsSessionRegistry.electionsSessionFor(stakingOption) + + val genesisSlot = electionsSession.genesisSlot(timelineChainId) + val sessionLength = electionsSession.sessionLength(timelineChainId) + + val eraAndStartSessionIndex = activeEraFlow.map { activeEra -> + val eraStartSessionIndex = stakingRepository.eraStartSessionIndex(stakingChainId, activeEra) + activeEra to eraStartSessionIndex + } + + return combine( + eraAndStartSessionIndex, + sessionRepository.observeCurrentSessionIndex(timelineChainId), + electionsSession.currentEpochIndexFlow(timelineChainId), + electionsSession.currentSlotFlow(timelineChainId), + ) { (activeEra, eraStartSessionIndex), currentSessionIndex, currentEpochIndex, currentSlot -> + EraTimeCalculator( + startTimeStamp = System.currentTimeMillis().toBigInteger(), + eraLength = stakingRepository.eraLength(stakingChain), + blockCreationTime = chainStateRepository.predictedBlockTime(timelineChainId), + currentSessionIndex = currentSessionIndex, + currentEpochIndex = currentEpochIndex ?: currentSessionIndex, + sessionLength = sessionLength, + currentSlot = currentSlot, + genesisSlot = genesisSlot, + eraStartSessionIndex = eraStartSessionIndex, + activeEra = activeEra + ) + }.ignoreInsignificantTimeChanges() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/Nominations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/Nominations.kt new file mode 100644 index 0000000..950ad52 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/Nominations.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common + +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import java.math.BigInteger + +fun Nominations.isWaiting(activeEraIndex: BigInteger): Boolean { + return submittedInEra >= activeEraIndex +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/StakingBlockNumberUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/StakingBlockNumberUseCase.kt new file mode 100644 index 0000000..bea04aa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/StakingBlockNumberUseCase.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface StakingBlockNumberUseCase { + + fun currentBlockNumberFlow(): Flow +} + +@FeatureScope +class RealStakingBlockNumberUseCase @Inject constructor( + private val chainStateRepository: ChainStateRepository, + private val stakingSharedState: StakingSharedState, +) : StakingBlockNumberUseCase { + + override fun currentBlockNumberFlow(): Flow { + return flowOfAll { + val chain = stakingSharedState.chainId() + + chainStateRepository.currentBlockNumberFlow(chain) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/StakingHoldsMigrationUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/StakingHoldsMigrationUseCase.kt new file mode 100644 index 0000000..4a6153b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/StakingHoldsMigrationUseCase.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold.HoldId +import javax.inject.Inject + +interface StakingHoldsMigrationUseCase { + + suspend fun isStakedBalanceMigratedToHolds(): Boolean +} + +@FeatureScope +class RealStakingHoldsMigrationUseCase @Inject constructor( + private val stakingSharedState: StakingSharedState, + private val balanceHoldsRepository: BalanceHoldsRepository +) : StakingHoldsMigrationUseCase { + + override suspend fun isStakedBalanceMigratedToHolds(): Boolean { + val chainId = stakingSharedState.chainId() + val stakingHoldId = HoldId("Staking", "Staking") + return balanceHoldsRepository.chainHasHoldId(chainId, stakingHoldId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/StakingSharedComputation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/StakingSharedComputation.kt new file mode 100644 index 0000000..d17ad39 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/StakingSharedComputation.kt @@ -0,0 +1,167 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_api.domain.api.ExposuresWithEraIndex +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.createStakingOption +import io.novafoundation.nova.feature_staking_impl.data.repository.BagListRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.bagListLocatorOrNull +import io.novafoundation.nova.feature_staking_impl.domain.bagList.BagListScoreConverter +import io.novafoundation.nova.feature_staking_impl.domain.minimumStake +import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculator +import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculatorFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest + +class ActiveEraInfo( + val eraIndex: EraIndex, + val exposures: AccountIdMap, + val minStake: Balance, +) + +class StakingSharedComputation( + private val stakingRepository: StakingRepository, + private val computationalCache: ComputationalCache, + private val rewardCalculatorFactory: RewardCalculatorFactory, + private val accountRepository: AccountRepository, + private val bagListRepository: BagListRepository, + private val totalIssuanceRepository: TotalIssuanceRepository, + private val eraTimeCalculatorFactory: EraTimeCalculatorFactory, + private val stakingConstantsRepository: StakingConstantsRepository +) { + + fun eraCalculatorFlow(stakingOption: StakingOption, scope: CoroutineScope): Flow { + val chainId = stakingOption.assetWithChain.chain.id + val key = "ERA_TIME_CALCULATOR:$chainId" + + return computationalCache.useSharedFlow(key, scope) { + val activeEraFlow = activeEraFlow(chainId, scope) + + eraTimeCalculatorFactory.create(stakingOption, activeEraFlow) + } + } + + fun activeEraFlow(chainId: ChainId, scope: CoroutineScope): Flow { + val key = "ACTIVE_ERA:$chainId" + + return computationalCache.useSharedFlow(key, scope) { + stakingRepository.observeActiveEraIndex(chainId) + } + } + + fun electedExposuresWithActiveEraFlow(chainId: ChainId, scope: CoroutineScope): Flow { + val key = "ELECTED_EXPOSURES:$chainId" + + return computationalCache.useSharedFlow(key, scope) { + activeEraFlow(chainId, scope).map { eraIndex -> + stakingRepository.getElectedValidatorsExposure(chainId, eraIndex) to eraIndex + } + } + } + + fun activeEraInfo(chainId: ChainId, scope: CoroutineScope): Flow { + val key = "MIN_STAKE:$chainId" + + return computationalCache.useSharedFlow(key, scope) { + val minBond = stakingRepository.minimumNominatorBond(chainId) + val bagListLocator = bagListRepository.bagListLocatorOrNull(chainId) + val totalIssuance = totalIssuanceRepository.getTotalIssuance(chainId) + val bagListScoreConverter = BagListScoreConverter.U128(totalIssuance) + val maxElectingVoters = bagListRepository.maxElectingVotes(chainId) + val bagListSize = bagListRepository.bagListSize(chainId) + + electedExposuresWithActiveEraFlow(chainId, scope).map { (exposures, activeEraIndex) -> + val minStake = minimumStake( + exposures = exposures.values, + minimumNominatorBond = minBond, + bagListLocator = bagListLocator, + bagListScoreConverter = bagListScoreConverter, + bagListSize = bagListSize, + maxElectingVoters = maxElectingVoters + ) + + ActiveEraInfo(activeEraIndex, exposures, minStake) + } + } + } + + fun selectedAccountStakingStateFlow(scope: CoroutineScope, assetWithChain: ChainWithAsset): Flow { + val (chain, asset) = assetWithChain + val key = "STAKING_STATE:${assetWithChain.chain.id}:${assetWithChain.asset.id}" + + return computationalCache.useSharedFlow(key, scope) { + accountRepository.selectedMetaAccountFlow().transformLatest { account -> + val accountId = account.accountIdIn(chain) + + if (accountId != null) { + emitAll(stakingRepository.stakingStateFlow(chain, asset, accountId)) + } else { + emit(StakingState.NonStash(chain, asset)) + } + } + } + } + + suspend fun rewardCalculator( + stakingOption: StakingOption, + scope: CoroutineScope + ): RewardCalculator { + val chainAsset = stakingOption.assetWithChain.asset + + val key = "REWARD_CALCULATOR:${chainAsset.chainId}:${chainAsset.id}:${stakingOption.additional.stakingType}" + + return computationalCache.useCache(key, scope) { + rewardCalculatorFactory.create(stakingOption, scope) + } + } + + suspend fun rewardCalculator( + chain: Chain, + chainAsset: Chain.Asset, + stakingType: Chain.Asset.StakingType, + scope: CoroutineScope + ): RewardCalculator { + val stakingOption = createStakingOption(chain, chainAsset, stakingType) + + return rewardCalculator(stakingOption, scope) + } +} + +suspend fun StakingSharedComputation.electedExposuresInActiveEra( + chainId: ChainId, + scope: CoroutineScope +): AccountIdMap = electedExposuresInActiveEraFlow(chainId, scope).first() + +fun StakingSharedComputation.electedExposuresInActiveEraFlow(chainId: ChainId, scope: CoroutineScope): Flow> { + return electedExposuresWithActiveEraFlow(chainId, scope).map { (exposures, _) -> exposures } +} + +suspend fun StakingSharedComputation.getActiveEra(chainId: ChainId, scope: CoroutineScope): EraIndex { + return activeEraFlow(chainId, scope).first() +} + +suspend fun StakingSharedComputation.minStake( + chainId: ChainId, + scope: CoroutineScope +): Balance = activeEraInfo(chainId, scope).first().minStake + +suspend fun StakingSharedComputation.eraTimeCalculator( + stakingOption: StakingOption, + scope: CoroutineScope +) = eraCalculatorFlow(stakingOption, scope).first() diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/SingleSelectRecommendator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/SingleSelectRecommendator.kt new file mode 100644 index 0000000..4b8f315 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/SingleSelectRecommendator.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.recommendations.SingleSelectRecommendatorConfig + +interface SingleSelectRecommendator { + + interface Factory { + + context(ComputationalScope) + suspend fun create(stakingOption: StakingOption, computationalScope: ComputationalScope): SingleSelectRecommendator + } + + fun recommendations(config: SingleSelectRecommendatorConfig): List + + fun defaultRecommendation(): T? +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/model/TargetWithStakedAmount.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/model/TargetWithStakedAmount.kt new file mode 100644 index 0000000..0e9d10a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/model/TargetWithStakedAmount.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +open class TargetWithStakedAmount( + val stake: Balance, + val target: T +) : Identifiable by target diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/recommendations/FilteringSingleSelectRecommendator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/recommendations/FilteringSingleSelectRecommendator.kt new file mode 100644 index 0000000..a11d007 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/recommendations/FilteringSingleSelectRecommendator.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.recommendations + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.WithAccountId +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.validators.ValidatorsPreferencesSource +import io.novafoundation.nova.feature_staking_impl.data.validators.getExcludedValidatorIdKeys +import io.novafoundation.nova.feature_staking_impl.data.validators.getRecommendedValidatorIdKeys +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.SingleSelectRecommendator + +class FilteringSingleSelectRecommendator( + private val allTargets: List, + private val recommended: Set, + private val excluded: Set +) : SingleSelectRecommendator { + + override fun recommendations(config: SingleSelectRecommendatorConfig): List { + return allTargets.filter { it.accountId !in excluded } + .sortedWith( + // accounts from recommended list first + compareByDescending { it.accountId in recommended } + // then by the supplied sorting rule + .then(config) + ) + } + + override fun defaultRecommendation(): T? { + return allTargets.find { it.accountId in recommended } + } +} + +abstract class FilteringSingleSelectRecommendatorFactory( + private val computationalCache: ComputationalCache, + private val validatorsPreferencesSource: ValidatorsPreferencesSource +) : SingleSelectRecommendator.Factory { + + context(ComputationalScope) + protected abstract suspend fun getAllTargets(stakingOption: StakingOption): List + + context(ComputationalScope) + final override suspend fun create( + stakingOption: StakingOption, + computationalScope: ComputationalScope + ) = computationalCache.useCache(javaClass.name, computationalScope) { + val allTargets = getAllTargets(stakingOption) + + val recommended = validatorsPreferencesSource.getRecommendedValidatorIdKeys(stakingOption.chain.id) + val excluded = validatorsPreferencesSource.getExcludedValidatorIdKeys(stakingOption.chain.id) + + FilteringSingleSelectRecommendator(allTargets, recommended = recommended, excluded = excluded) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/recommendations/SingleSelectRecommendatorConfig.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/recommendations/SingleSelectRecommendatorConfig.kt new file mode 100644 index 0000000..7e2cbb9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/singleSelect/recommendations/SingleSelectRecommendatorConfig.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.recommendations + +typealias SingleSelectRecommendatorConfig = Comparator diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/validation/ProfitableActionValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/validation/ProfitableActionValidation.kt new file mode 100644 index 0000000..4724762 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/common/validation/ProfitableActionValidation.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.domain.common.validation + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isTrueOrWarning +import io.novafoundation.nova.feature_account_api.data.model.decimalAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.SimpleFeeProducer +import java.math.BigDecimal + +class ProfitableActionValidation( + val amount: P.() -> BigDecimal, + val fee: SimpleFeeProducer

, + val error: (P) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + // No matter who paid the fee we check that it is profitable + val isProfitable = fee(value)?.decimalAmount.orZero() < value.amount() + + return isProfitable isTrueOrWarning { + error(value) + } + } +} + +fun ValidationSystemBuilder.profitableAction( + amount: P.() -> BigDecimal, + fee: SimpleFeeProducer

, + error: (P) -> E +) { + validate(ProfitableActionValidation(amount, fee, error)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/dashboard/RealStakingDashboardInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/dashboard/RealStakingDashboardInteractor.kt new file mode 100644 index 0000000..55fd1ff --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/dashboard/RealStakingDashboardInteractor.kt @@ -0,0 +1,484 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.novafoundation.nova.feature_staking_impl.domain.dashboard + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.asLoaded +import io.novafoundation.nova.common.domain.dataOrNull +import io.novafoundation.nova.common.domain.fromOption +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.utils.Percent +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_dapp_api.data.model.DappCategory +import io.novafoundation.nova.feature_dapp_api.data.model.isStaking +import io.novafoundation.nova.feature_dapp_api.data.repository.DAppMetadataRepository +import io.novafoundation.nova.feature_staking_api.data.dashboard.StakingDashboardSyncTracker +import io.novafoundation.nova.feature_staking_api.data.dashboard.SyncingStageMap +import io.novafoundation.nova.feature_staking_api.data.dashboard.getSyncingStage +import io.novafoundation.nova.feature_staking_api.domain.dashboard.StakingDashboardInteractor +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.HasStake +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.NoStake +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.NotYetResolved +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.SyncingStage +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.WithoutStake +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.MoreStakingOptions +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingDApp +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingDashboard +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_api.data.dashboard.common.stakingChainsById +import io.novafoundation.nova.feature_staking_impl.data.dashboard.model.StakingDashboardItem +import io.novafoundation.nova.feature_staking_impl.data.dashboard.repository.StakingDashboardRepository +import io.novafoundation.nova.feature_staking_impl.data.dashboard.repository.TotalStakeChainComparatorProvider +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.alphabeticalOrder +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.ext.mainChainsFirstAscendingOrder +import io.novafoundation.nova.runtime.ext.supportedStakingOptions +import io.novafoundation.nova.runtime.ext.testnetsLastAscendingOrder +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainsById +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class RealStakingDashboardInteractor( + private val dashboardRepository: StakingDashboardRepository, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val stakingDashboardSyncTracker: StakingDashboardSyncTracker, + private val dAppMetadataRepository: DAppMetadataRepository, + private val walletRepository: WalletRepository, + private val totalStakeChainComparatorProvider: TotalStakeChainComparatorProvider, +) : StakingDashboardInteractor { + + override suspend fun syncDapps() { + runCatching { + withContext(Dispatchers.Default) { + dAppMetadataRepository.syncDAppMetadatas() + } + } + } + + override fun stakingDashboardFlow(): Flow> { + return flow { + val stakingChains = chainRegistry.stakingChainsById() + val knownStakingAssets = stakingChains.knownStakingAssets() + + emit(ExtendedLoadingState.Loading) + + val dashboardFlow = accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + val noPriceDashboardFlow = combine( + dashboardRepository.dashboardItemsFlow(metaAccount.id), + stakingDashboardSyncTracker.syncedItemsFlow, + ) { dashboardItems, syncedItems -> + constructStakingDashboard(stakingChains, dashboardItems, syncedItems) + } + + val assetsFlow = walletRepository.supportedAssetsByIdFlow(metaAccount.id, knownStakingAssets) + + combine(noPriceDashboardFlow, assetsFlow, ::addPricesToDashboard) + } + + emitAll(dashboardFlow) + } + } + + override fun moreStakingOptionsFlow(): Flow { + return flow { + val stakingChains = chainRegistry.stakingChainsById() + val knownStakingAssets = stakingChains.knownStakingAssets() + val metaAccount = accountRepository.getSelectedMetaAccount() + + val noPriceDashboardFlow = combine( + dashboardRepository.dashboardItemsFlow(metaAccount.id), + stakingDashboardSyncTracker.syncedItemsFlow, + ) { dashboardItems, syncedItems -> + constructMoreStakingOptions(stakingChains, dashboardItems, syncedItems) + } + + val assetsFlow = walletRepository.supportedAssetsByIdFlow(metaAccount.id, knownStakingAssets) + val dApps = dAppMetadataRepository.stakingDAppsFlow() + .mapLoading { dapps -> dapps.sortedBy { it.name } } + + val dashboardFlow = combine( + noPriceDashboardFlow, + assetsFlow, + dApps, + ::combineNoMoreOptionsInfo + ) + + emitAll(dashboardFlow) + } + } + + private fun WalletRepository.supportedAssetsByIdFlow(metaId: Long, chainAssets: List): Flow> { + return supportedAssetsFlow(metaId, chainAssets) + .map { assets -> assets.associateBy { asset -> asset.token.configuration.fullId } } + } + + private fun constructStakingDashboard( + stakingChains: ChainsById, + dashboardItems: List, + syncingStageMap: SyncingStageMap + ): NoPriceStakingDashboard { + val itemsByChainAndAsset = dashboardItems + .groupBy { it.fullChainAssetId.chainId } + .mapValues { (_, chainAssets) -> chainAssets.groupBy { it.fullChainAssetId.assetId } } + + val hasStake = mutableListOf>() + val noStake = mutableListOf>() + val notYetResolved = mutableListOf>() + + stakingChains.values.forEach { chain -> + val itemsByChain = itemsByChainAndAsset[chain.id] + + if (itemsByChain == null) { + if (!chain.isTestNet) { + notYetResolved.add(notYetResolvedChainOption(chain, chain.utilityAsset)) + } + return@forEach + } + + itemsByChain.forEach innerForEach@{ assetId, dashboardItems -> + val asset = chain.assetsById[assetId] ?: return@innerForEach + + if (dashboardItems.isNoStakePresent()) { + if (!chain.isTestNet) { + noStake.add(noStakeAggregatedOption(chain, asset, dashboardItems, syncingStageMap)) + } + } else { + val hasStakingOptionsSize = dashboardItems.count { it.stakeState is StakingDashboardItem.StakeState.HasStake } + val shouldShowStakingType = hasStakingOptionsSize > 1 + + val hasStakeOptions = dashboardItems.mapNotNull { item -> hasStakeOption(chain, asset, shouldShowStakingType, item, syncingStageMap) } + hasStake.addAll(hasStakeOptions) + } + } + } + + return NoPriceStakingDashboard( + hasStake = hasStake, + noStake = noStake, + notYetResolved = notYetResolved + ) + } + + private fun constructMoreStakingOptions( + stakingChains: ChainsById, + dashboardItems: List, + syncingStageMap: SyncingStageMap, + ): NoPriceMoreStakingOptions { + val itemsByChainAndAsset = dashboardItems + .groupBy { it.fullChainAssetId.chainId } + .mapValues { (_, chainAssets) -> chainAssets.groupBy { it.fullChainAssetId.assetId } } + + val noStake = mutableListOf>() + val notYetResolved = mutableListOf>() + + stakingChains.values.forEach { chain -> + val itemsByChain = itemsByChainAndAsset[chain.id] + + if (itemsByChain == null) { + if (chain.isTestNet) { + notYetResolved.add(notYetResolvedChainOption(chain, chain.utilityAsset)) + } + return@forEach + } + + itemsByChain.forEach innerForEach@{ assetId, dashboardItems -> + val asset = chain.assetsById[assetId] ?: return@innerForEach + + if (dashboardItems.isNoStakePresent()) { + if (chain.isTestNet) { + noStake.add(noStakeAggregatedOption(chain, asset, dashboardItems, syncingStageMap)) + } + } else { + val separateNoStakeOptions = dashboardItems.filter { it.stakeState is StakingDashboardItem.StakeState.NoStake } + .map { noStakeSeparateOption(chain, asset, it, syncingStageMap) } + + noStake.addAll(separateNoStakeOptions) + } + } + } + + return NoPriceMoreStakingOptions(noStake, notYetResolved) + } + + private suspend fun addPricesToDashboard( + noPriceStakingDashboard: NoPriceStakingDashboard, + assets: Map, + ): ExtendedLoadingState { + val hasStakeOptions = noPriceStakingDashboard.hasStake.map { addPriceToHasStakeItem(it, assets) } + val noStakeOptions = noPriceStakingDashboard.noStake.map { addAssetInfoToNoStakeItem(it, assets) } + val notYetResolvedOptions = noPriceStakingDashboard.notYetResolved.map { addAssetInfoToNotYetResolvedItem(it, assets) } + + return StakingDashboard( + hasStake = hasStakeOptions.sortedByChain(), + withoutStake = (noStakeOptions + notYetResolvedOptions).sortedByChain(), + ).asLoaded() + } + + private suspend fun combineNoMoreOptionsInfo( + noPriceMoreStakingOptions: NoPriceMoreStakingOptions, + assets: Map, + stakingDApps: ExtendedLoadingState>, + ): MoreStakingOptions { + val noStakeOptions = noPriceMoreStakingOptions.noStake.map { addAssetInfoToNoStakeItem(it, assets) } + val notYetResolvedOptions = noPriceMoreStakingOptions.notYetResolved.map { addAssetInfoToNotYetResolvedItem(it, assets) } + + return MoreStakingOptions( + inAppStaking = (noStakeOptions + notYetResolvedOptions).sortedByChain(), + browserStaking = stakingDApps + ) + } + + private fun addPriceToHasStakeItem( + item: NoPriceStakingDashboardOption, + assets: Map, + ): AggregatedStakingDashboardOption { + return AggregatedStakingDashboardOption( + chain = item.chain, + token = assets.getValue(item.chainAsset.fullId).token, + stakingState = item.stakingState, + syncingStage = item.syncingStage + ) + } + + private fun addAssetInfoToNoStakeItem( + item: NoPriceStakingDashboardOption, + assets: Map, + ): AggregatedStakingDashboardOption { + val asset = assets.getValue(item.chainAsset.fullId) + + return AggregatedStakingDashboardOption( + chain = item.chain, + token = asset.token, + stakingState = NoStake( + stats = item.stakingState.stats, + flowType = item.stakingState.flowType, + availableBalance = asset.availableBalanceForStakingFor(item.stakingState.flowType) + ), + syncingStage = item.syncingStage + ) + } + + private fun addAssetInfoToNotYetResolvedItem( + item: NoPriceStakingDashboardOption, + assets: Map, + ): AggregatedStakingDashboardOption { + val asset = assets.getValue(item.chainAsset.fullId) + + return AggregatedStakingDashboardOption( + chain = item.chain, + token = asset.token, + stakingState = item.stakingState, + syncingStage = item.syncingStage + ) + } + + private fun List.isNoStakePresent() = all { it.stakeState is StakingDashboardItem.StakeState.NoStake } + + private fun List.findMaxEarnings(): Percent? = mapNotNull { + it.stakeState.stats.dataOrNull?.estimatedEarnings + }.maxOrNull() + + private fun notYetResolvedChainOption( + chain: Chain, + chainAsset: Chain.Asset, + ): NoPriceStakingDashboardOption { + return NoPriceStakingDashboardOption( + chain = chain, + chainAsset = chainAsset, + stakingState = NotYetResolved, + syncingStage = SyncingStage.SYNCING_ALL + ) + } + + private fun noStakeAggregatedOption( + chain: Chain, + chainAsset: Chain.Asset, + noStakeItems: List, + syncingStageMap: SyncingStageMap, + ): NoPriceStakingDashboardOption { + val maxEarnings = noStakeItems.findMaxEarnings() + val stats = maxEarnings?.let(NoStake::Stats) + + val flowType = if (noStakeItems.size > 1) { + NoStake.FlowType.Aggregated(noStakeItems.map { it.stakingType }) + } else { + // aggregating means there is no staking present, hence we always hide staking type badge + NoStake.FlowType.Single(noStakeItems.single().stakingType, showStakingType = false) + } + + return NoPriceStakingDashboardOption( + chain = chain, + chainAsset = chainAsset, + stakingState = NoBalanceNoStake( + stats = ExtendedLoadingState.fromOption(stats), + flowType = flowType + ), + syncingStage = chainAsset.supportedStakingOptions().minOf { stakingType -> + val stakingOptionId = StakingOptionId(chain.id, chainAsset.id, stakingType) + syncingStageMap.getSyncingStage(stakingOptionId) + } + ) + } + + private fun noStakeSeparateOption( + chain: Chain, + chainAsset: Chain.Asset, + noStakeItem: StakingDashboardItem, + syncingStageMap: SyncingStageMap, + ): NoPriceStakingDashboardOption { + val stats = noStakeItem.stakeState.stats.map { + NoStake.Stats(it.estimatedEarnings) + } + + return NoPriceStakingDashboardOption( + chain = chain, + chainAsset = chainAsset, + stakingState = NoBalanceNoStake( + stats = stats, + flowType = NoStake.FlowType.Single(noStakeItem.stakingType, showStakingType = true) + ), + syncingStage = syncingStageMap.getSyncingStage(StakingOptionId(chain.id, chainAsset.id, noStakeItem.stakingType)) + ) + } + + private fun hasStakeOption( + chain: Chain, + chainAsset: Chain.Asset, + showStakingType: Boolean, + item: StakingDashboardItem, + syncingStageMap: SyncingStageMap, + ): NoPriceStakingDashboardOption? { + if (item.stakeState !is StakingDashboardItem.StakeState.HasStake) return null + + return NoPriceStakingDashboardOption( + chain = chain, + chainAsset = chainAsset, + stakingState = HasStake( + showStakingType = showStakingType, + stats = item.stakeState.stats.map(::mapItemStatsToOptionStats), + stakingType = item.stakingType, + stake = item.stakeState.stake + ), + syncingStage = syncingStageMap.getSyncingStage(StakingOptionId(chain.id, chainAsset.id, item.stakingType)) + ) + } + + private suspend fun List>.sortedByChain(): List> { + val chainTotalStakeComparator = totalStakeChainComparatorProvider.getTotalStakeComparator() + + return sortedWith( + compareBy> { it.chain.mainChainsFirstAscendingOrder } + .thenByDescending { it.token.planksToFiat(it.stakingState.availableBalance()) } + .thenByDescending { it.stakingState.availableBalance() } + .thenComparing(Comparator.comparing(AggregatedStakingDashboardOption<*>::chain, chainTotalStakeComparator)) + .thenBy { it.chain.alphabeticalOrder } + ) + } + + @JvmName("sortedHasStakeByChain") + private fun List>.sortedByChain(): List> { + return sortedWith( + compareByDescending> { it.token.planksToFiat(it.stakingState.stake) } + .thenByDescending { it.stakingState.stake } + .thenBy { it.chain.testnetsLastAscendingOrder } + .thenBy { it.chain.alphabeticalOrder } + ) + } + + private fun WithoutStake.availableBalance(): Balance { + return when (this) { + is NoStake -> availableBalance + NotYetResolved -> Balance.ZERO + } + } + + private fun mapItemStatsToOptionStats(itemStats: StakingDashboardItem.StakeState.HasStake.Stats): HasStake.Stats { + return HasStake.Stats( + rewards = itemStats.rewards, + estimatedEarnings = itemStats.estimatedEarnings, + status = mapItemStatusToOptionStatus(itemStats.status) + ) + } + + private fun mapItemStatusToOptionStatus(itemStatus: StakingDashboardItem.StakeState.HasStake.StakingStatus): HasStake.StakingStatus { + return when (itemStatus) { + StakingDashboardItem.StakeState.HasStake.StakingStatus.ACTIVE -> HasStake.StakingStatus.ACTIVE + StakingDashboardItem.StakeState.HasStake.StakingStatus.INACTIVE -> HasStake.StakingStatus.INACTIVE + StakingDashboardItem.StakeState.HasStake.StakingStatus.WAITING -> HasStake.StakingStatus.WAITING + } + } + + private fun ChainsById.knownStakingAssets(): List { + return flatMap { (_, chain) -> chain.assets.filter { it.supportedStakingOptions().isNotEmpty() } } + } + + private fun DAppMetadataRepository.stakingDAppsFlow(): Flow>> { + return observeDAppCatalog().map { dappCatalog -> + dappCatalog.dApps + .filter { dApp -> dApp.categories.any(DappCategory::isStaking) } + .map { StakingDApp(it.url, it.iconLink, it.name) } + }.withSafeLoading() + } + + private fun Asset.availableBalanceForStakingFor(flowType: NoStake.FlowType): Balance { + return when (flowType) { + is NoStake.FlowType.Aggregated -> flowType.stakingTypes.maxOf { availableBalanceForStakingFor(it) } + + is NoStake.FlowType.Single -> availableBalanceForStakingFor(flowType.stakingType) + } + } + + private fun Asset.availableBalanceForStakingFor(stakingType: Chain.Asset.StakingType): Balance { + // assumes account has no stake + return when (stakingType.group()) { + StakingTypeGroup.RELAYCHAIN -> freeInPlanks + StakingTypeGroup.PARACHAIN -> freeInPlanks + StakingTypeGroup.NOMINATION_POOL -> transferableInPlanks + StakingTypeGroup.UNSUPPORTED -> Balance.ZERO + StakingTypeGroup.MYTHOS -> freeInPlanks + } + } + + private class NoPriceStakingDashboardOption( + val chain: Chain, + val chainAsset: Chain.Asset, + val stakingState: S, + val syncingStage: SyncingStage + ) + + private class NoPriceStakingDashboard( + val hasStake: List>, + val noStake: List>, + val notYetResolved: List>, + ) + + private class NoBalanceNoStake( + val stats: ExtendedLoadingState, + val flowType: NoStake.FlowType + ) + + private class NoPriceMoreStakingOptions( + val noStake: List>, + val notYetResolved: List> + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/MythosStakingEraInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/MythosStakingEraInteractor.kt new file mode 100644 index 0000000..55a6a0f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/MythosStakingEraInteractor.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.domain.era + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.toDuration +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.mythos.duration.MythosSessionDurationCalculator +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.model.StartStakingEraInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.time.Duration + +class MythosStakingEraInteractor( + private val mythosSharedComputation: MythosSharedComputation, + private val mythosStakingRepository: MythosStakingRepository, + private val stakingOption: StakingOption, + private val computationalScope: ComputationalScope +) : StakingEraInteractor, ComputationalScope by computationalScope { + + override fun observeEraInfo(): Flow { + return flowOfAll { + val chainId = stakingOption.chain.id + val unstakingDurationInBlocks = mythosStakingRepository.unstakeDurationInBlocks(chainId) + + mythosSharedComputation.eraDurationCalculatorFlow(stakingOption).map { sessionDurationCalculator -> + StartStakingEraInfo( + unstakeTime = (unstakingDurationInBlocks * sessionDurationCalculator.blockTime).toDuration(), + eraDuration = sessionDurationCalculator.sessionDuration(), + firstRewardReceivingDuration = sessionDurationCalculator.firstRewardDelay() + ) + } + } + } + + private fun MythosSessionDurationCalculator.firstRewardDelay(): Duration { + return remainingSessionDuration() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/ParachainStakingEraInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/ParachainStakingEraInteractor.kt new file mode 100644 index 0000000..b819d8f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/ParachainStakingEraInteractor.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.domain.era + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.model.StartStakingEraInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class ParachainStakingEraInteractor( + private val roundDurationEstimator: RoundDurationEstimator, + private val stakingOption: StakingOption, +) : StakingEraInteractor { + + override fun observeEraInfo(): Flow { + val chain = stakingOption.chain + + return flowOfAll { + combine( + roundDurationEstimator.unstakeDurationFlow(chain.id), + roundDurationEstimator.roundDurationFlow(chain.id), + roundDurationEstimator.firstRewardReceivingDelayFlow(chain.id) + ) { unstakeDuration, eraDuration, firstRewardReceivingDelay -> + StartStakingEraInfo(unstakeDuration, eraDuration, firstRewardReceivingDelay) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/RelaychainStakingEraInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/RelaychainStakingEraInteractor.kt new file mode 100644 index 0000000..ecef404 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/RelaychainStakingEraInteractor.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.domain.era + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.EraTimeCalculator +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.erasDuration +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.model.StartStakingEraInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.time.Duration + +class RelaychainStakingEraInteractor( + private val stakingSharedComputation: StakingSharedComputation, + private val sharedComputationScope: CoroutineScope, + private val stakingOption: StakingOption, + private val stakingConstantsRepository: StakingConstantsRepository +) : StakingEraInteractor { + + override fun observeEraInfo(): Flow { + val chain = stakingOption.chain + + return stakingSharedComputation.eraCalculatorFlow(stakingOption, sharedComputationScope).map { eraTimeCalculator -> + val unstakeEras = stakingConstantsRepository.lockupPeriodInEras(chain.id) + + StartStakingEraInfo( + unstakeTime = eraTimeCalculator.erasDuration(numberOfEras = unstakeEras), + eraDuration = eraTimeCalculator.eraDuration(), + firstRewardReceivingDuration = eraTimeCalculator.firstRewardDelay() + ) + } + } + + private fun EraTimeCalculator.firstRewardDelay(): Duration { + return remainingEraDuration() + eraDuration() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/StakingEraInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/StakingEraInteractor.kt new file mode 100644 index 0000000..009c128 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/StakingEraInteractor.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.era + +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.model.StartStakingEraInfo +import kotlinx.coroutines.flow.Flow + +interface StakingEraInteractor { + + fun observeEraInfo(): Flow +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/StakingEraInteractorFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/StakingEraInteractorFactory.kt new file mode 100644 index 0000000..9df9d4d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/StakingEraInteractorFactory.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.domain.era + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.createStakingOption +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull +import kotlinx.coroutines.CoroutineScope + +class StakingEraInteractorFactory( + private val roundDurationEstimator: RoundDurationEstimator, + private val stakingSharedComputation: StakingSharedComputation, + private val stakingConstantsRepository: StakingConstantsRepository, + private val mythosSharedComputation: MythosSharedComputation, + private val mythosStakingRepository: MythosStakingRepository, +) { + + private val creators = mapOf( + StakingTypeGroup.RELAYCHAIN to ::createRelaychain, + StakingTypeGroup.PARACHAIN to ::createParachain, + StakingTypeGroup.MYTHOS to ::createMythos + ) + + fun create(chain: Chain, chainAsset: Chain.Asset, computationScope: ComputationalScope): StakingEraInteractor { + return creators.entries.tryFindNonNull { (stakingTypeGroup, creator) -> + val stakingType = chainAsset.findStakingTypeByGroup(stakingTypeGroup) ?: return@tryFindNonNull null + val stakingOption = createStakingOption(chain, chainAsset, stakingType) + + creator(stakingOption, computationScope) + } ?: UnsupportedStakingEraInteractor() + } + + private fun Chain.Asset.findStakingTypeByGroup(stakingTypeGroup: StakingTypeGroup): Chain.Asset.StakingType? { + return staking.find { it.group() == stakingTypeGroup } + } + + private fun createParachain(stakingOption: StakingOption, sharedComputationScope: CoroutineScope): StakingEraInteractor { + return ParachainStakingEraInteractor( + roundDurationEstimator = roundDurationEstimator, + stakingOption = stakingOption + ) + } + + private fun createRelaychain(stakingOption: StakingOption, sharedComputationScope: CoroutineScope): StakingEraInteractor { + return RelaychainStakingEraInteractor( + stakingSharedComputation = stakingSharedComputation, + sharedComputationScope = sharedComputationScope, + stakingOption = stakingOption, + stakingConstantsRepository = stakingConstantsRepository + ) + } + + private fun createMythos(stakingOption: StakingOption, computationScope: ComputationalScope): StakingEraInteractor { + return MythosStakingEraInteractor(mythosSharedComputation, mythosStakingRepository, stakingOption, computationScope) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/UnsupportedStakingEraInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/UnsupportedStakingEraInteractor.kt new file mode 100644 index 0000000..c850ab9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/era/UnsupportedStakingEraInteractor.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.domain.era + +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.model.StartStakingEraInfo +import kotlinx.coroutines.flow.Flow + +class UnsupportedStakingEraInteractor : StakingEraInteractor { + + override fun observeEraInfo(): Flow { + throw UnsupportedOperationException("Unsupported staking type") + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/error/AccountIdMap.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/error/AccountIdMap.kt new file mode 100644 index 0000000..4ef0f1c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/error/AccountIdMap.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_staking_impl.domain.error + +fun accountIdNotFound(accountIdHex: String): Nothing = error("Target with account id $accountIdHex was not found") diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/BagListNode.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/BagListNode.kt new file mode 100644 index 0000000..214a540 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/BagListNode.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.domain.model + +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +class BagListNode( + val id: AccountId, + val previous: AccountId?, + val next: AccountId?, + val bagUpper: Score, + val score: Score, +) { + + @JvmInline + value class Score(val value: BigInteger) : Comparable { + + companion object { + + fun zero() = Score(BigInteger.ZERO) + } + + override fun compareTo(other: Score): Int { + return value.compareTo(other.value) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/NetworkInfo.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/NetworkInfo.kt new file mode 100644 index 0000000..9935c0b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/NetworkInfo.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.model + +import java.math.BigInteger +import kotlin.time.Duration + +data class NetworkInfo( + val lockupPeriod: Duration, + val minimumStake: BigInteger, + val totalStake: BigInteger, + val stakingPeriod: StakingPeriod, + val nominatorsCount: Int? +) + +sealed class StakingPeriod { + + object Unlimited : StakingPeriod() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/PayoutType.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/PayoutType.kt new file mode 100644 index 0000000..e765c65 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/PayoutType.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.model + +sealed interface PayoutType { + + sealed interface Automatically : PayoutType { + + object Restake : Automatically + + object Payout : Automatically + } + + object Manual : PayoutType +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/PendingPayoutsStatistics.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/PendingPayoutsStatistics.kt new file mode 100644 index 0000000..41121f1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/PendingPayoutsStatistics.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.domain.model + +import java.math.BigInteger + +class PendingPayoutsStatistics( + val payouts: List, + val totalAmountInPlanks: BigInteger, +) + +data class PendingPayout( + val validatorInfo: ValidatorInfo, + val era: BigInteger, + val amountInPlanks: BigInteger, + val timeLeft: Long, + val timeLeftCalculatedAt: Long, + val closeToExpire: Boolean, + val pagesToClaim: List, +) { + class ValidatorInfo( + val address: String, + val identityName: String?, + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/StakeSummary.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/StakeSummary.kt new file mode 100644 index 0000000..91a38a1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/StakeSummary.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.domain.model + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class StakeSummary( + val status: S, + val activeStake: Balance, +) + +sealed class NominatorStatus { + object Active : NominatorStatus() + + class Waiting(val timeLeft: Long) : NominatorStatus() + + class Inactive(val reason: Reason) : NominatorStatus() { + + enum class Reason { + MIN_STAKE, NO_ACTIVE_VALIDATOR + } + } +} + +enum class StashNoneStatus { + INACTIVE +} + +enum class ValidatorStatus { + ACTIVE, INACTIVE +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/StakingReward.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/StakingReward.kt new file mode 100644 index 0000000..e21d893 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/StakingReward.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.domain.model + +import java.math.BigDecimal + +class StakingReward( + val accountAddress: String, + val type: Type, + val blockNumber: Long, + val extrinsicIndex: Int, + val extrinsicHash: String, + val moduleId: String, + val eventIndex: String, + val amount: BigDecimal, + val blockTimestamp: Long, +) { + + enum class Type(val summingCoefficient: Int) { + REWARD(1), SLASH(-1) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/TotalReward.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/TotalReward.kt new file mode 100644 index 0000000..e243001 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/TotalReward.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_impl.domain.model + +import java.math.BigInteger + +typealias TotalReward = BigInteger diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/Unbonding.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/Unbonding.kt new file mode 100644 index 0000000..843c7c4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/model/Unbonding.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.model + +import io.novafoundation.nova.common.utils.formatting.TimerValue +import java.math.BigInteger + +class Unbonding(val id: String, val amount: BigInteger, val status: Status) { + + sealed class Status { + + data class Unbonding(val timer: TimerValue) : Status() + + object Redeemable : Status() + } +} + +val Unbonding.isRedeemable: Boolean + get() = status is Unbonding.Status.Redeemable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/MythosClaimRewardsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/MythosClaimRewardsInteractor.kt new file mode 100644 index 0000000..2873bac --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/MythosClaimRewardsInteractor.kt @@ -0,0 +1,143 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.splitByWeights +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdKeyOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.StakingIntent +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.claimRewards +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.lock +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.stake +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosDelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.mapLatest +import javax.inject.Inject + +interface MythosClaimRewardsInteractor { + + context(ComputationalScope) + fun pendingRewardsFlow(): Flow + + suspend fun initialShouldRestakeSetting(): Boolean + + suspend fun estimateFee( + claimableRewards: Balance, + shouldRestake: Boolean + ): Fee + + suspend fun claimRewards( + claimableRewards: Balance, + shouldRestake: Boolean + ): Result +} + +private const val SHOULD_RESTAKE_DEFAULT = true + +@FeatureScope +class RealMythosClaimRewardsInteractor @Inject constructor( + private val stakingSharedState: StakingSharedState, + private val extrinsicService: ExtrinsicService, + private val delegatorStateUseCase: MythosDelegatorStateUseCase, + private val accountRepository: AccountRepository, + private val userStakeRepository: MythosUserStakeRepository, +) : MythosClaimRewardsInteractor { + + context(ComputationalScope) + override fun pendingRewardsFlow(): Flow { + return delegatorStateUseCase.currentDelegatorState() + .filterIsInstance() + .distinctUntilChangedBy { it.userStakeInfo.maybeLastRewardSession } + .mapLatest { + val chain = stakingSharedState.chain() + val accountId = accountRepository.requireIdOfSelectedMetaAccountIn(chain).intoKey() + + userStakeRepository.getpPendingRewards(chain.id, accountId) + }.distinctUntilChanged() + } + + override suspend fun initialShouldRestakeSetting(): Boolean { + return userStakeRepository.lastShouldRestakeSelection() ?: SHOULD_RESTAKE_DEFAULT + } + + override suspend fun estimateFee( + claimableRewards: Balance, + shouldRestake: Boolean + ): Fee { + val chain = stakingSharedState.chain() + + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + claimRewards(claimableRewards, shouldRestake) + } + } + + override suspend fun claimRewards( + claimableRewards: Balance, + shouldRestake: Boolean + ): Result { + val chain = stakingSharedState.chain() + + return extrinsicService.submitExtrinsicAndAwaitExecution(chain, TransactionOrigin.SelectedWallet) { + claimRewards(claimableRewards, shouldRestake) + } + .requireOk() + .onSuccess { userStakeRepository.setLastShouldRestakeSelection(shouldRestake) } + } + + private suspend fun ExtrinsicBuilder.claimRewards( + claimableRewards: Balance, + shouldRestake: Boolean + ) { + collatorStaking.claimRewards() + + val canRestakeViaBondMore = isAutoCompoundDisabled() + if (shouldRestake && canRestakeViaBondMore) { + restakeRewards(claimableRewards) + } + } + + private suspend fun ExtrinsicBuilder.restakeRewards(claimableRewards: Balance) { + collatorStaking.lock(claimableRewards) + + val newStakes = determineNewCollatorStakes(claimableRewards) + collatorStaking.stake(newStakes) + } + + private suspend fun isAutoCompoundDisabled(): Boolean { + val chain = stakingSharedState.chain() + val accountId = accountRepository.requireIdKeyOfSelectedMetaAccountIn(chain) + + val autoCompoundPercentage = userStakeRepository.getAutoCompoundPercentage(chain.id, accountId) + return autoCompoundPercentage.isZero + } + + private suspend fun determineNewCollatorStakes(claimedRewards: Balance): List { + val delegations = delegatorStateUseCase.getUserDelegations().toList() + val splitWeights = delegations.map { (_, delegation) -> delegation.stake } + val collatorIds = delegations.map { (collatorId, _) -> collatorId } + + val rewardAllocations = claimedRewards.splitByWeights(splitWeights) + + return collatorIds.zip(rewardAllocations).map { (collatorId, stakeMoreAmount) -> + StakingIntent(collatorId, stakeMoreAmount) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/validations/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/validations/Declarations.kt new file mode 100644 index 0000000..a4159b9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/validations/Declarations.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_staking_impl.domain.common.validation.profitableAction +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias MythosClaimRewardsValidationSystem = + ValidationSystem + +typealias MythosClaimRewardsValidationSystemBuilder = + ValidationSystemBuilder + +fun ValidationSystem.Companion.mythosClaimRewards(): MythosClaimRewardsValidationSystem = ValidationSystem { + enoughToPayFees() + + profitableClaim() +} + +private fun MythosClaimRewardsValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + MythosClaimRewardsValidationFailure.NotEnoughBalanceToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) +} + +private fun MythosClaimRewardsValidationSystemBuilder.profitableClaim() { + profitableAction( + amount = { pendingRewards }, + fee = { it.fee }, + error = { MythosClaimRewardsValidationFailure.NonProfitableClaim } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/validations/MythosClaimRewardsValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/validations/MythosClaimRewardsValidationFailure.kt new file mode 100644 index 0000000..966727d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/validations/MythosClaimRewardsValidationFailure.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class MythosClaimRewardsValidationFailure { + + class NotEnoughBalanceToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : MythosClaimRewardsValidationFailure(), NotEnoughToPayFeesError + + object NonProfitableClaim : MythosClaimRewardsValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/validations/MythosClaimRewardsValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/validations/MythosClaimRewardsValidationPayload.kt new file mode 100644 index 0000000..7b73599 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/claimRewards/validations/MythosClaimRewardsValidationPayload.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.validations + +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import java.math.BigDecimal + +class MythosClaimRewardsValidationPayload( + val fee: Fee, + val pendingRewardsPlanks: Balance, + val asset: Asset, +) + +val MythosClaimRewardsValidationPayload.pendingRewards: BigDecimal + get() = asset.token.amountFromPlanks(pendingRewardsPlanks) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/MythosDelegatorStateUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/MythosDelegatorStateUseCase.kt new file mode 100644 index 0000000..9ab2c56 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/MythosDelegatorStateUseCase.kt @@ -0,0 +1,121 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.novafoundation.nova.feature_staking_impl.domain.mythos.common + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdKeyOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythDelegation +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.UserStakeInfo +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosLocks +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.observeMythosLocks +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.total +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model.TargetWithStakedAmount +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.collator.MythosCollatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.collator.MythosCollatorProvider.MythosCollatorSource +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.stakeByCollator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.model.MythosCollatorWithAmount +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.state.assetWithChain +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest +import javax.inject.Inject + +interface MythosDelegatorStateUseCase { + + context(ComputationalScope) + fun currentDelegatorState(): Flow + + suspend fun getUserDelegations(): Map + + context(ComputationalScope) + suspend fun getStakedCollators(state: MythosDelegatorState): List +} + +@FeatureScope +class RealMythosDelegatorStateUseCase @Inject constructor( + private val accountRepository: AccountRepository, + private val mythosUserStakeRepository: MythosUserStakeRepository, + private val stakingSharedState: StakingSharedState, + private val balanceLocksRepository: BalanceLocksRepository, + private val collatorProvider: MythosCollatorProvider, +) : MythosDelegatorStateUseCase { + + context(ComputationalScope) + override fun currentDelegatorState(): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { selectedMetaAccount -> + stakingSharedState.assetWithChain.flatMapLatest { (chain, chainAsset) -> + val accountId = selectedMetaAccount.accountIdIn(chain) ?: return@flatMapLatest flowOf(MythosDelegatorState.NotStarted) + + combineToPair( + mythosUserStakeRepository.userStakeOrDefaultFlow(chain.id, accountId), + balanceLocksRepository.observeMythosLocks(selectedMetaAccount.id, chain, chainAsset) + ).transformLatest<_, MythosDelegatorState> { (userStake, mythosLocks) -> + collectDelegatorStake(userStake, mythosLocks, chain.id, accountId.intoKey()) + } + } + } + } + + override suspend fun getUserDelegations(): Map { + val chain = stakingSharedState.chain() + val accountId = accountRepository.requireIdKeyOfSelectedMetaAccountIn(chain) + + val userStake = mythosUserStakeRepository.userStakeOrDefault(chain.id, accountId.value) + val delegations = mythosUserStakeRepository.userDelegations(chain.id, accountId, userStake.candidates) + + return delegations + } + + context(ComputationalScope) + override suspend fun getStakedCollators(state: MythosDelegatorState): List { + val stakeByCollator = state.stakeByCollator() + val stakedCollatorIds = stakeByCollator.keys + val stakingOption = stakingSharedState.selectedOption() + + val collators = collatorProvider.getCollators(stakingOption, MythosCollatorSource.Custom(stakedCollatorIds)) + + return collators.map { mythosCollator -> + TargetWithStakedAmount( + stake = stakeByCollator.getValue(mythosCollator.accountId), + target = mythosCollator + ) + } + } + + context(FlowCollector) + suspend fun collectDelegatorStake( + userStakeInfo: UserStakeInfo, + mythosLocks: MythosLocks, + chainId: ChainId, + userAccountId: AccountIdKey, + ) { + when { + mythosLocks.total.isZero -> emit(MythosDelegatorState.NotStarted) + + else -> { + val stakedStateUpdates = mythosUserStakeRepository.userDelegationsFlow(chainId, userAccountId, userStakeInfo.candidates) + .map { MythosDelegatorState.Staked(userStakeInfo, it, mythosLocks) } + + emitAll(stakedStateUpdates) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/MythosSharedComputation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/MythosSharedComputation.kt new file mode 100644 index 0000000..986434a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/MythosSharedComputation.kt @@ -0,0 +1,112 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.common + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.toHex +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.data.memory.SharedComputation +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.mythos.duration.MythosSessionDurationCalculator +import io.novafoundation.nova.feature_staking_impl.data.mythos.duration.MythosSessionDurationCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.Invulnerables +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythCandidateInfos +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythReleaseRequest +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosCandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.SessionValidators +import io.novafoundation.nova.feature_staking_impl.data.repository.SessionRepository +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.rewards.MythosStakingRewardCalculator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.rewards.MythosStakingRewardCalculatorFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +@FeatureScope +class MythosSharedComputation @Inject constructor( + private val mythosDelegatorStateUseCase: MythosDelegatorStateUseCase, + private val mythosStakingRepository: MythosStakingRepository, + private val sessionRepository: SessionRepository, + private val mythosSessionDurationCalculatorFactory: MythosSessionDurationCalculatorFactory, + private val candidatesRepository: MythosCandidatesRepository, + private val rewardCalculatorFactory: MythosStakingRewardCalculatorFactory, + private val userStakeRepository: MythosUserStakeRepository, + computationalCache: ComputationalCache +) : SharedComputation(computationalCache) { + + context(ComputationalScope) + fun eraDurationCalculatorFlow(stakingOption: StakingOption): Flow { + return cachedFlow("MythosSharedComputation.eraDurationCalculatorFlow", stakingOption.chain.id) { + mythosSessionDurationCalculatorFactory.create(stakingOption) + } + } + + context(ComputationalScope) + fun minStakeFlow(chainId: ChainId): Flow { + return cachedFlow("MythosSharedComputation.minStakeFlow", chainId) { + mythosStakingRepository.minStakeFlow(chainId) + } + } + + context(ComputationalScope) + fun delegatorStateFlow(): Flow { + return cachedFlow("MythosSharedComputation.userStakeFlow") { + mythosDelegatorStateUseCase.currentDelegatorState() + } + } + + context(ComputationalScope) + fun sessionValidatorsFlow(chainId: ChainId): Flow { + return cachedFlow("MythosSharedComputation.sessionValidatorsFlow", chainId) { + sessionRepository.sessionValidatorsFlow(chainId) + } + } + + context(ComputationalScope) + suspend fun getInvulnerableCollators(chainId: ChainId): Invulnerables { + return cachedValue("MythosSharedComputation.invulnerables", chainId) { + mythosStakingRepository.getInvulnerableCollators(chainId) + } + } + + context(ComputationalScope) + suspend fun candidateInfos(chainId: ChainId): MythCandidateInfos { + return cachedValue("MythosSharedComputation.candidateInfos", chainId) { + candidatesRepository.getCandidateInfos(chainId) + } + } + + context(ComputationalScope) + suspend fun rewardCalculator(chainId: ChainId): MythosStakingRewardCalculator { + return cachedValue("MythosSharedComputation.rewardCalculator", chainId) { + rewardCalculatorFactory.create(chainId) + } + } + + context(ComputationalScope) + fun releaseQueuesFlow(chainId: ChainId, accountId: AccountIdKey): Flow> { + return cachedFlow("MythosSharedComputation.releaseQueuesFlow", chainId, accountId.toHex()) { + userStakeRepository.releaseQueuesFlow(chainId, accountId) + } + } +} + +context(ComputationalScope) +suspend fun MythosSharedComputation.sessionValidators(chainId: ChainId): SessionValidators { + return sessionValidatorsFlow(chainId).first() +} + +context(ComputationalScope) +suspend fun MythosSharedComputation.delegatorState(): MythosDelegatorState { + return delegatorStateFlow().first() +} + +context(ComputationalScope) +suspend fun MythosSharedComputation.minStake(chainId: ChainId): Balance { + return minStakeFlow(chainId).first() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/collator/MythosCollatorProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/collator/MythosCollatorProvider.kt new file mode 100644 index 0000000..ef8f946 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/collator/MythosCollatorProvider.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.common.collator + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.collator.MythosCollatorProvider.MythosCollatorSource +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.sessionValidators +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import javax.inject.Inject + +interface MythosCollatorProvider { + + sealed class MythosCollatorSource { + + /** + * Elected collators that are not invulnerable + */ + object ElectedCandidates : MythosCollatorSource() + + class Custom(val collatorIds: Collection) : MythosCollatorSource() + } + + context(ComputationalScope) + suspend fun getCollators( + stakingOption: StakingOption, + collatorSource: MythosCollatorSource, + ): List +} + +@FeatureScope +class RealMythosCollatorProvider @Inject constructor( + private val mythosSharedComputation: dagger.Lazy, + private val identityRepository: OnChainIdentityRepository +) : MythosCollatorProvider { + + context(ComputationalScope) + override suspend fun getCollators( + stakingOption: StakingOption, + collatorSource: MythosCollatorSource + ): List { + val chainId = stakingOption.chain.id + val requestedCollatorIds = collatorSource.requestedCollatorIds(chainId) + + if (requestedCollatorIds.isEmpty()) return emptyList() + + val sharedComputation = mythosSharedComputation.get() + + val collatorStakes = sharedComputation.candidateInfos(chainId) + + val accountIdsRaw = requestedCollatorIds.map { it.value } + val identities = identityRepository.getIdentitiesFromIds(accountIdsRaw, chainId) + + val rewardCalculator = sharedComputation.rewardCalculator(chainId) + + return requestedCollatorIds.map { collatorId -> + val collatorStake = collatorStakes[collatorId] + + MythosCollator( + accountId = collatorId, + identity = identities[collatorId], + totalStake = collatorStake?.stake.orZero(), + delegators = collatorStake?.stakers.orZero(), + apr = rewardCalculator.collatorApr(collatorId) + ) + } + } + + context(ComputationalScope) + private suspend fun MythosCollatorSource.requestedCollatorIds(chainId: ChainId): Collection { + return when (this) { + is MythosCollatorSource.Custom -> collatorIds + MythosCollatorSource.ElectedCandidates -> getElectedCandidates(chainId) + } + } + + context(ComputationalScope) + private suspend fun getElectedCandidates(chainId: ChainId): Collection { + val sessionValidators = mythosSharedComputation.get().sessionValidators(chainId) + val invulnerables = mythosSharedComputation.get().getInvulnerableCollators(chainId) + + return sessionValidators - invulnerables + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/model/MythosCollator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/model/MythosCollator.kt new file mode 100644 index 0000000..d4a45c1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/model/MythosCollator.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.WithAccountId +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.extensions.toHexString + +class MythosCollator( + override val accountId: AccountIdKey, + val identity: OnChainIdentity?, + val totalStake: Balance, + val delegators: Int, + val apr: Fraction?, +) : Identifiable, WithAccountId { + + override val identifier: String = accountId.value.toHexString() +} + +val MythosCollator.isActive: Boolean + get() = apr != null diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/model/MythosDelegatorState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/model/MythosDelegatorState.kt new file mode 100644 index 0000000..e0c4e1d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/model/MythosDelegatorState.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythDelegation +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.UserStakeInfo +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.hasActiveCollators +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosLocks +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.total +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.SessionValidators +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +sealed class MythosDelegatorState { + + object NotStarted : MythosDelegatorState() + + class Staked( + val userStakeInfo: UserStakeInfo, + val stakeByCollator: Map, + val locks: MythosLocks + ) : MythosDelegatorState() +} + +@OptIn(ExperimentalContracts::class) +fun MythosDelegatorState.hasStakedCollators(): Boolean { + contract { + returns(true) implies (this@hasStakedCollators is MythosDelegatorState.Staked) + } + + return this is MythosDelegatorState.Staked && stakeByCollator.isNotEmpty() +} + +@OptIn(ExperimentalContracts::class) +fun MythosDelegatorState.isNotStarted(): Boolean { + contract { + returns(true) implies (this@isNotStarted is MythosDelegatorState.NotStarted) + } + + return this is MythosDelegatorState.NotStarted +} + +fun MythosDelegatorState.stakeByCollator(): Map { + return when (this) { + is MythosDelegatorState.Staked -> stakeByCollator.mapValues { it.value.stake } + MythosDelegatorState.NotStarted -> emptyMap() + } +} + +fun MythosDelegatorState.stakedCollatorsCount(): Int { + return when (this) { + is MythosDelegatorState.Staked -> userStakeInfo.candidates.size + MythosDelegatorState.NotStarted -> 0 + } +} + +fun MythosDelegatorState.hasActiveValidators(sessionValidators: SessionValidators): Boolean { + return when (this) { + is MythosDelegatorState.Staked -> userStakeInfo.hasActiveCollators(sessionValidators) + MythosDelegatorState.NotStarted -> false + } +} + +val MythosDelegatorState.activeStake: Balance + get() = when (this) { + is MythosDelegatorState.Staked -> userStakeInfo.balance + + MythosDelegatorState.NotStarted -> Balance.ZERO + } + +fun MythosDelegatorState.stakeableBalance(asset: Asset, currentBlockNumber: BlockNumber): Balance { + return when (this) { + // Since there is no staking yet, we can stake the whole free balance + MythosDelegatorState.NotStarted -> asset.freeInPlanks + + // We can stake from not-yet-staked balance + can restake unused part of CollatorStaking::Staking freeze + is MythosDelegatorState.Staked -> { + val fromNonLocked = asset.freeInPlanks - locks.total + val fromLocked = locks.staked - userStakeInfo.balance - userStakeInfo.restrictedFromRestake(currentBlockNumber) + + fromNonLocked.atLeastZero() + fromLocked.atLeastZero() + } + } +} + +fun MythosDelegatorState.requiredAdditionalLockToStake( + desiredStake: Balance, + currentBlockNumber: BlockNumber +): Balance { + return when (this) { + MythosDelegatorState.NotStarted -> desiredStake + + is MythosDelegatorState.Staked -> { + val canBeUsedFromLocked = locks.staked - userStakeInfo.balance - userStakeInfo.restrictedFromRestake(currentBlockNumber) + (desiredStake - canBeUsedFromLocked).atLeastZero() + } + } +} + +fun UserStakeInfo.restrictedFromRestake(currentBlockNumber: BlockNumber): Balance { + return if (maybeLastUnstake != null && currentBlockNumber < maybeLastUnstake.availableForRestakeAt) { + maybeLastUnstake.amount + } else { + Balance.ZERO + } +} + +fun MythosDelegatorState.delegationAmountTo(collator: AccountIdKey): Balance { + return when (this) { + is MythosDelegatorState.Staked -> stakeByCollator[collator]?.stake.orZero() + MythosDelegatorState.NotStarted -> Balance.ZERO + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/recommendations/MythosCollatorRecommendatorFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/recommendations/MythosCollatorRecommendatorFactory.kt new file mode 100644 index 0000000..bbb9308 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/recommendations/MythosCollatorRecommendatorFactory.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.data.validators.ValidatorsPreferencesSource +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.recommendations.FilteringSingleSelectRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.collator.MythosCollatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.collator.MythosCollatorProvider.MythosCollatorSource +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import javax.inject.Inject + +@FeatureScope +class MythosCollatorRecommendatorFactory @Inject constructor( + private val mythosCollatorProvider: MythosCollatorProvider, + private val mythosStakingRepository: MythosStakingRepository, + computationalCache: ComputationalCache, + validatorsPreferencesSource: ValidatorsPreferencesSource +) : FilteringSingleSelectRecommendatorFactory(computationalCache, validatorsPreferencesSource) { + + context(ComputationalScope) + override suspend fun getAllTargets(stakingOption: StakingOption): List { + val maxStakers = mythosStakingRepository.maxDelegatorsPerCollator(stakingOption.chain.id) + + return mythosCollatorProvider.getCollators(stakingOption, MythosCollatorSource.ElectedCandidates) + .filter { it.delegators < maxStakers } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/recommendations/MythosCollatorSorting.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/recommendations/MythosCollatorSorting.kt new file mode 100644 index 0000000..964bb2e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/recommendations/MythosCollatorSorting.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations + +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator + +enum class MythosCollatorSorting(private val collatorComparator: Comparator) : Comparator by collatorComparator { + + REWARDS(compareByDescending { it.apr }), + TOTAL_STAKE(compareByDescending { it.totalStake }), +} + +data class MythosCollatorRecommendationConfig(val sorting: MythosCollatorSorting) : Comparator by sorting { + + companion object { + + val DEFAULT = MythosCollatorRecommendationConfig(MythosCollatorSorting.REWARDS) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/rewards/MythosClaimPendingRewardsUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/rewards/MythosClaimPendingRewardsUseCase.kt new file mode 100644 index 0000000..b01f97f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/common/rewards/MythosClaimPendingRewardsUseCase.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.common.rewards + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdKeyOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.claimRewards +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import javax.inject.Inject + +interface MythosClaimPendingRewardsUseCase { + + context(ExtrinsicBuilder) + suspend fun claimPendingRewards(chain: Chain) +} + +@FeatureScope +class RealMythosClaimPendingRewardsUseCase @Inject constructor( + private val userStakeRepository: MythosUserStakeRepository, + private val accountRepository: AccountRepository, +) : MythosClaimPendingRewardsUseCase { + + context(ExtrinsicBuilder) + override suspend fun claimPendingRewards(chain: Chain) { + val accountId = accountRepository.requireIdKeyOfSelectedMetaAccountIn(chain) + val hasPendingRewards = userStakeRepository.shouldClaimRewards(chain.id, accountId) + if (hasPendingRewards) { + collatorStaking.claimRewards() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/currentCollators/MythosCurrentCollatorsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/currentCollators/MythosCurrentCollatorsInteractor.kt new file mode 100644 index 0000000..6caf1ed --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/currentCollators/MythosCurrentCollatorsInteractor.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosDelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.stakeByCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.model.CurrentMythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.model.MythosDelegationStatus +import io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.model.delegationStatus +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.mapLatest +import javax.inject.Inject + +interface MythosCurrentCollatorsInteractor { + + context(ComputationalScope) + fun currentCollatorsFlow(): Flow> +} + +@FeatureScope +class RealMythosCurrentCollatorsInteractor @Inject constructor( + private val delegatorStateUseCase: MythosDelegatorStateUseCase, +) : MythosCurrentCollatorsInteractor { + + context(ComputationalScope) + override fun currentCollatorsFlow(): Flow> { + return delegatorStateUseCase.currentDelegatorState() + .distinctUntilChangedBy { it.stakeByCollator() } + .mapLatest { delegatorState -> + delegatorStateUseCase.getStakedCollators(delegatorState).map { collatorWithAmount -> + CurrentMythosCollator( + collator = collatorWithAmount.target, + userStake = collatorWithAmount.stake, + status = collatorWithAmount.target.delegationStatus() + ) + }.groupBy { it.status } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/currentCollators/model/CurrentMythosCollator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/currentCollators/model/CurrentMythosCollator.kt new file mode 100644 index 0000000..5375059 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/currentCollators/model/CurrentMythosCollator.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.model + +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class CurrentMythosCollator( + val collator: MythosCollator, + val userStake: Balance, + val status: MythosDelegationStatus, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/currentCollators/model/MythosDelegationStatus.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/currentCollators/model/MythosDelegationStatus.kt new file mode 100644 index 0000000..b1512f4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/currentCollators/model/MythosDelegationStatus.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.model + +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.isActive + +enum class MythosDelegationStatus { + + ACTIVE, NOT_ACTIVE +} + +fun MythosCollator.delegationStatus(): MythosDelegationStatus { + return if (isActive) MythosDelegationStatus.ACTIVE else MythosDelegationStatus.NOT_ACTIVE +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/alerts/MythosStakingAlert.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/alerts/MythosStakingAlert.kt new file mode 100644 index 0000000..1728ca3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/alerts/MythosStakingAlert.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.main.alerts + +import java.math.BigInteger + +sealed class MythosStakingAlert { + + object ChangeCollator : MythosStakingAlert() + + class RedeemTokens(val redeemableAmount: BigInteger) : MythosStakingAlert() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/alerts/MythosStakingAlertsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/alerts/MythosStakingAlertsInteractor.kt new file mode 100644 index 0000000..234688d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/alerts/MythosStakingAlertsInteractor.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.main.alerts + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythReleaseRequest +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.hasInactiveCollators +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.totalRedeemable +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.SessionValidators +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +interface MythosStakingAlertsInteractor { + + context(ComputationalScope) + fun alertsFlow(delegatorState: MythosDelegatorState.Staked): Flow> +} + +@FeatureScope +class RealMythosStakingAlertsInteractor @Inject constructor( + private val mythosSharedComputation: MythosSharedComputation, + private val mythosUserStakeRepository: MythosUserStakeRepository, + private val chainStateRepository: ChainStateRepository, + private val stakingSharedState: StakingSharedState, + private val accountRepository: AccountRepository, +) : MythosStakingAlertsInteractor { + + context(ComputationalScope) + override fun alertsFlow(delegatorState: MythosDelegatorState.Staked): Flow> { + return flowOfAll { + val chain = stakingSharedState.chain() + val accountId = accountRepository.requireIdOfSelectedMetaAccountIn(chain).intoKey() + + combine( + mythosSharedComputation.sessionValidatorsFlow(chain.id), + mythosUserStakeRepository.releaseQueuesFlow(chain.id, accountId), + chainStateRepository.currentBlockNumberFlow(chain.id) + ) { sessionValidators, releaseQueues, currentBlockNumber -> + val context = AlertCalculationContext(delegatorState, sessionValidators, currentBlockNumber, releaseQueues) + alertProducers.mapNotNull { it.invoke(context) } + } + } + } + + private fun changeCollatorsAlert(context: AlertCalculationContext): MythosStakingAlert.ChangeCollator? { + val userStakeInfo = context.delegationState.userStakeInfo + val sessionValidators = context.sessionValidators + + val hasInactiveCollators = userStakeInfo.hasInactiveCollators(sessionValidators) + + return MythosStakingAlert.ChangeCollator.takeIf { hasInactiveCollators } + } + + private fun redeemAlert(context: AlertCalculationContext): MythosStakingAlert.RedeemTokens? { + val totalRedeemAmount = context.releaseRequests.totalRedeemable(at = context.currentBlockNumber) + + return if (totalRedeemAmount.isPositive()) { + MythosStakingAlert.RedeemTokens(totalRedeemAmount) + } else { + null + } + } + + private val alertProducers: List = listOf( + ::changeCollatorsAlert, + ::redeemAlert + ) +} + +private typealias AlertProducer = (AlertCalculationContext) -> MythosStakingAlert? + +private class AlertCalculationContext( + val delegationState: MythosDelegatorState.Staked, + val sessionValidators: SessionValidators, + val currentBlockNumber: BlockNumber, + val releaseRequests: List +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/stakeSummary/MythosDelegatorStatus.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/stakeSummary/MythosDelegatorStatus.kt new file mode 100644 index 0000000..e6832d3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/stakeSummary/MythosDelegatorStatus.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.main.stakeSummary + +sealed class MythosDelegatorStatus { + + object Active : MythosDelegatorStatus() + + object Inactive : MythosDelegatorStatus() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/stakeSummary/MythosStakeSummaryInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/stakeSummary/MythosStakeSummaryInteractor.kt new file mode 100644 index 0000000..edd9947 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/stakeSummary/MythosStakeSummaryInteractor.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.main.stakeSummary + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.model.StakeSummary +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.activeStake +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.hasActiveValidators +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +interface MythosStakeSummaryInteractor { + + context(ComputationalScope) + fun stakeSummaryFlow( + delegatorState: MythosDelegatorState.Staked, + stakingOption: StakingOption, + ): Flow> +} + +@FeatureScope +class RealMythosStakeSummaryInteractor @Inject constructor( + private val mythosSharedComputation: MythosSharedComputation, +) : MythosStakeSummaryInteractor { + + context(ComputationalScope) + override fun stakeSummaryFlow( + delegatorState: MythosDelegatorState.Staked, + stakingOption: StakingOption, + ): Flow> { + val chainId = stakingOption.assetWithChain.chain.id + + return mythosSharedComputation.sessionValidatorsFlow(chainId).map { sessionValidators -> + val status = when { + delegatorState.activeStake.isZero -> MythosDelegatorStatus.Inactive + delegatorState.hasActiveValidators(sessionValidators) -> MythosDelegatorStatus.Active + else -> MythosDelegatorStatus.Inactive + } + + StakeSummary( + status = status, + activeStake = delegatorState.activeStake + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/unbonding/MythosUnbondingInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/unbonding/MythosUnbondingInteractor.kt new file mode 100644 index 0000000..a79f6ca --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/unbonding/MythosUnbondingInteractor.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.main.unbonding + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.MythReleaseRequest +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.UnbondingList +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.Unbondings +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.from +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.blockDurationEstimatorFlow +import io.novafoundation.nova.runtime.util.BlockDurationEstimator +import io.novafoundation.nova.runtime.util.isBlockedPassed +import io.novafoundation.nova.runtime.util.timerUntil +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +interface MythosUnbondingInteractor { + + context(ComputationalScope) + fun unbondingsFlow( + delegatorState: MythosDelegatorState.Staked, + stakingOption: StakingOption + ): Flow +} + +@FeatureScope +class RealMythosUnbondingInteractor @Inject constructor( + private val chainStateRepository: ChainStateRepository, + private val accountRepository: AccountRepository, + private val mythosSharedComputation: MythosSharedComputation, +) : MythosUnbondingInteractor { + + context(ComputationalScope) + override fun unbondingsFlow(delegatorState: MythosDelegatorState.Staked, stakingOption: StakingOption): Flow { + val chainId = stakingOption.chain.id + + return flowOfAll { + val accountId = accountRepository.requireIdOfSelectedMetaAccountIn(stakingOption.chain).intoKey() + + combine( + mythosSharedComputation.releaseQueuesFlow(chainId, accountId), + chainStateRepository.blockDurationEstimatorFlow(chainId) + ) { releaseQueues, durationEstimator -> + val unbondingList = releaseQueues.toUnbondingList(durationEstimator) + Unbondings.from(unbondingList, rebondPossible = false) + } + } + } + + private fun List.toUnbondingList(durationEstimator: BlockDurationEstimator): UnbondingList { + return mapIndexed { index, releaseRequest -> + Unbonding( + id = index.toString(), + amount = releaseRequest.amount, + status = releaseRequest.unbondingStatus(durationEstimator) + ) + } + } + + private fun MythReleaseRequest.unbondingStatus(durationEstimator: BlockDurationEstimator): Unbonding.Status { + return if (durationEstimator.isBlockedPassed(block)) { + Unbonding.Status.Redeemable + } else { + val timer = durationEstimator.timerUntil(block) + + Unbonding.Status.Unbonding(timer) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/userRewards/MythosUserRewardsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/userRewards/MythosUserRewardsInteractor.kt new file mode 100644 index 0000000..14f7e96 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/main/userRewards/MythosUserRewardsInteractor.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.main.userRewards + +import android.util.Log +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.fullId +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.userRewards.MythosUserRewardsInteractor.MythosRewards +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +interface MythosUserRewardsInteractor { + + class MythosRewards( + val total: LoadingState, + val claimable: LoadingState + ) + + fun rewardsFlow(stakingOption: StakingOption): Flow + + suspend fun syncTotalRewards(stakingOption: StakingOption, rewardPeriod: RewardPeriod): Result +} + +@FeatureScope +class RealMythosUserRewardsInteractor @Inject constructor( + private val repository: MythosUserStakeRepository, + private val stakingRewardsRepository: StakingRewardsRepository, + private val accountRepository: AccountRepository, +) : MythosUserRewardsInteractor { + + override fun rewardsFlow(stakingOption: StakingOption): Flow { + return flowOfAll { + val accountId = accountRepository.requireIdOfSelectedMetaAccountIn(stakingOption.chain) + + combine( + pendingRewardsFlow(accountId, stakingOption.chain.id).withLoading(), + stakingRewardsRepository.totalRewardFlow(accountId, stakingOption.fullId).withLoading() + ) { pendingRewards, totalRewards -> + MythosRewards( + total = totalRewards, + claimable = pendingRewards, + ) + } + } + } + + override suspend fun syncTotalRewards(stakingOption: StakingOption, rewardPeriod: RewardPeriod): Result { + return runCatching { + val accountId = accountRepository.requireIdOfSelectedMetaAccountIn(stakingOption.chain) + + stakingRewardsRepository.sync(accountId, stakingOption, rewardPeriod) + } + } + + private fun pendingRewardsFlow(accountId: AccountId, chainId: ChainId): Flow { + return flowOf { repository.getpPendingRewards(chainId, accountId.intoKey()) } + .catch { Log.e("RealMythosUserRewardsInteractor", "Failed to fetch pending rewards", it) } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/MythosRedeemInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/MythosRedeemInteractor.kt new file mode 100644 index 0000000..d28a86e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/MythosRedeemInteractor.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.release +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.model.totalRedeemable +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.getMythosLocks +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.total +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemConsequences +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAndAsset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +interface MythosRedeemInteractor { + + fun redeemAmountFlow(): Flow + + suspend fun estimateFee(): Fee + + suspend fun redeem(redeemAmount: Balance): Result> +} + +@FeatureScope +class RealMythosRedeemInteractor @Inject constructor( + private val userStakeRepository: MythosUserStakeRepository, + private val chainStateRepository: ChainStateRepository, + private val stakingSharedState: StakingSharedState, + private val accountRepository: AccountRepository, + private val extrinsicService: ExtrinsicService, + private val balanceLocksRepository: BalanceLocksRepository, +) : MythosRedeemInteractor { + + override fun redeemAmountFlow(): Flow { + return flowOfAll { + val chain = stakingSharedState.chain() + val accountId = accountRepository.requireIdOfSelectedMetaAccountIn(chain).intoKey() + + combine( + userStakeRepository.releaseQueuesFlow(chain.id, accountId), + chainStateRepository.currentBlockNumberFlow(chain.id) + ) { releaseRequests, blockNumber -> + releaseRequests.totalRedeemable(at = blockNumber) + } + } + } + + override suspend fun estimateFee(): Fee { + val chain = stakingSharedState.chain() + + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + collatorStaking.release() + } + } + + override suspend fun redeem(redeemAmount: Balance): Result> { + val (chain, chainAsset) = stakingSharedState.chainAndAsset() + val metaAccount = accountRepository.getSelectedMetaAccount() + val mythStakingFreezes = balanceLocksRepository.getMythosLocks(metaAccount.id, chainAsset) + + return extrinsicService.submitExtrinsicAndAwaitExecution(chain, TransactionOrigin.SelectedWallet) { + collatorStaking.release() + } + .requireOk() + .map { + val redeemedAll = mythStakingFreezes.total == redeemAmount + it to RedeemConsequences(willKillStash = redeemedAll) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/validations/RedeemMythosStakingValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/validations/RedeemMythosStakingValidationFailure.kt new file mode 100644 index 0000000..d65d798 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/validations/RedeemMythosStakingValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class RedeemMythosStakingValidationFailure { + + class NotEnoughBalanceToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : RedeemMythosStakingValidationFailure(), NotEnoughToPayFeesError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/validations/RedeemMythosStakingValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/validations/RedeemMythosStakingValidationPayload.kt new file mode 100644 index 0000000..81fb19b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/validations/RedeemMythosStakingValidationPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.validations + +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class RedeemMythosStakingValidationPayload( + val fee: Fee, + val asset: Asset, +) + +val RedeemMythosStakingValidationPayload.chainId: ChainId + get() = asset.token.configuration.chainId diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/validations/RedeemMythosValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/validations/RedeemMythosValidationSystem.kt new file mode 100644 index 0000000..75cc4e3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/redeem/validations/RedeemMythosValidationSystem.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias RedeemMythosValidationSystem = ValidationSystem +typealias RedeemMythosValidationSystemBuilder = ValidationSystemBuilder + +fun ValidationSystem.Companion.mythosRedeem(): RedeemMythosValidationSystem = ValidationSystem { + enoughToPayFees() +} +private fun RedeemMythosValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { + RedeemMythosStakingValidationFailure.NotEnoughBalanceToPayFees( + chainAsset = it.payload.asset.token.configuration, + maxUsable = it.maxUsable, + fee = it.fee + ) + } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/rewards/MythosStakingRewardCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/rewards/MythosStakingRewardCalculator.kt new file mode 100644 index 0000000..003859b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/rewards/MythosStakingRewardCalculator.kt @@ -0,0 +1,101 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.rewards + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_impl.domain.rewards.PeriodReturns +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigDecimal +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days + +interface MythosStakingRewardCalculator { + + val maxApr: Fraction + + fun collatorApr(collatorId: AccountIdKey): Fraction? + + fun calculateCollatorAnnualReturns(collatorId: AccountIdKey, amount: BigDecimal): PeriodReturns + + fun calculateMaxAnnualReturns(amount: BigDecimal): PeriodReturns +} + +/** + * Implementation based on the following derivation: + * + * x - user stake + * T - a particular collator's current total stake + * Cn - number of collators + * e - per-collator emission (in tokens) + * E - total emission + * user_yield - user yield per session, in %, for a particular collator + * + * e = E / Cn + * staked_portion = x / (x + T) + * user_yield (in %) = staked_portion * e / x = e / (x + T) + * + * We use min stake for x to not face enormous numbers when total stake in the system is close to zero + */ +class RealMythosStakingRewardCalculator( + private val perBlockRewards: Balance, + private val blockDuration: Duration, + private val collatorCommission: Fraction, + private val collators: List, + private val minStake: Balance +) : MythosStakingRewardCalculator { + + private val yearlyEmission = calculateYearlyEmission() + private val collatorCommissionFraction = collatorCommission.inFraction + + private val aprByCollator = collators.associateBy( + keySelector = MythosStakingRewardTarget::accountId, + valueTransform = ::calculateCollatorApr + ) + + private val _maxApr = aprByCollator.values.maxOrNull().orZero() + override val maxApr: Fraction = _maxApr.fractions + + override fun collatorApr(collatorId: AccountIdKey): Fraction? { + return aprByCollator[collatorId]?.fractions + } + + override fun calculateCollatorAnnualReturns(collatorId: AccountIdKey, amount: BigDecimal): PeriodReturns { + val collatorApr = collatorApr(collatorId).orZero() + val aprFraction = collatorApr.inFraction.toBigDecimal() + + return PeriodReturns( + gainAmount = amount * aprFraction, + gainFraction = aprFraction, + isCompound = false + ) + } + + override fun calculateMaxAnnualReturns(amount: BigDecimal): PeriodReturns { + val maxApr = _maxApr.toBigDecimal() + + return PeriodReturns( + gainAmount = amount * maxApr, + gainFraction = maxApr, + isCompound = false + ) + } + + private fun calculateYearlyEmission(): Double { + val blocksPerYear = (365.days / blockDuration).toInt() + return perBlockRewards.toDouble() * blocksPerYear + } + + private fun calculateCollatorApr(collator: MythosStakingRewardTarget): Double { + return collatorApr(collator.totalStake.toDouble()) + } + + private fun collatorApr(collatorStake: Double): Double { + val perCollatorRewards = yearlyEmission / collators.size * (1 - collatorCommissionFraction) + val minUserStake = minStake.toDouble() + + // We estimate rewards assuming user stakes at least min_stake - this will compute maximum possible APR + // But at least not as big as when min stake not accounted + return perCollatorRewards / (collatorStake + minUserStake) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/rewards/MythosStakingRewardCalculatorFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/rewards/MythosStakingRewardCalculatorFactory.kt new file mode 100644 index 0000000..95398c8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/rewards/MythosStakingRewardCalculatorFactory.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.rewards + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.minStake +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.sessionValidators +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.expectedBlockTime +import javax.inject.Inject + +@FeatureScope +class MythosStakingRewardCalculatorFactory @Inject constructor( + private val mythosStakingRepository: MythosStakingRepository, + private val chainStateRepository: ChainStateRepository, + private val mythosSharedComputation: dagger.Lazy, +) { + + context(ComputationalScope) + suspend fun create(chainId: ChainId): MythosStakingRewardCalculator { + val sessionValidators = mythosSharedComputation.get().sessionValidators(chainId).toSet() + val candidateInfos = mythosSharedComputation.get().candidateInfos(chainId) + val collators = candidateInfos + .map { (accountId, candidateInfo) -> MythosStakingRewardTarget(candidateInfo.stake, accountId) } + .filter { it.accountId in sessionValidators } + + return RealMythosStakingRewardCalculator( + perBlockRewards = mythosStakingRepository.perBlockReward(chainId), + blockDuration = chainStateRepository.expectedBlockTime(chainId), + collatorCommission = mythosStakingRepository.collatorCommission(chainId), + collators = collators, + minStake = mythosSharedComputation.get().minStake(chainId) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/rewards/MythosStakingRewardTarget.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/rewards/MythosStakingRewardTarget.kt new file mode 100644 index 0000000..52b6d34 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/rewards/MythosStakingRewardTarget.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.rewards + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class MythosStakingRewardTarget( + val totalStake: Balance, + val accountId: AccountIdKey +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/StartMythosStakingInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/StartMythosStakingInteractor.kt new file mode 100644 index 0000000..fddefc2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/StartMythosStakingInteractor.kt @@ -0,0 +1,128 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.start + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.StakingIntent +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.lock +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.stake +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.requiredAdditionalLockToStake +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.stakedCollatorsCount +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.rewards.MythosClaimPendingRewardsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.DelegationsLimit +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.emptyAccountId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.hash.isPositive +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface StartMythosStakingInteractor { + + context(ComputationalScope) + suspend fun minStake(): Balance + + suspend fun estimateFee( + currentState: MythosDelegatorState, + candidate: AccountIdKey?, + amount: Balance + ): Fee + + suspend fun stake( + currentState: MythosDelegatorState, + candidate: AccountIdKey, + amount: Balance + ): Result + + suspend fun checkDelegationsLimit( + delegatorState: MythosDelegatorState + ): DelegationsLimit +} + +@FeatureScope +class RealStartMythosStakingInteractor @Inject constructor( + private val mythosSharedComputation: MythosSharedComputation, + private val stakingSharedState: StakingSharedState, + private val extrinsicService: ExtrinsicService, + private val chainStateRepository: ChainStateRepository, + private val stakingRepository: MythosStakingRepository, + private val claimPendingRewardsUseCase: MythosClaimPendingRewardsUseCase, +) : StartMythosStakingInteractor { + + context(ComputationalScope) + override suspend fun minStake(): Balance { + return mythosSharedComputation.minStakeFlow(stakingSharedState.chainId()).first() + } + + override suspend fun estimateFee( + currentState: MythosDelegatorState, + candidate: AccountIdKey?, + amount: Balance + ): Fee { + val chain = stakingSharedState.chain() + + val candidateOrEmpty = candidate ?: chain.emptyAccountId().intoKey() + + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + claimPendingRewardsUseCase.claimPendingRewards(chain) + + stakeMore(chain, currentState, candidateOrEmpty, amount) + } + } + + override suspend fun stake(currentState: MythosDelegatorState, candidate: AccountIdKey, amount: Balance): Result { + val chain = stakingSharedState.chain() + + return extrinsicService.submitExtrinsicAndAwaitExecution(chain, TransactionOrigin.SelectedWallet) { + claimPendingRewardsUseCase.claimPendingRewards(chain) + + stakeMore(chain, currentState, candidate, amount) + } + .requireOk() + } + + override suspend fun checkDelegationsLimit(delegatorState: MythosDelegatorState): DelegationsLimit { + return withContext(Dispatchers.IO) { + val chainId = stakingSharedState.chainId() + val maxCandidatesPerCollator = stakingRepository.maxCollatorsPerDelegator(chainId) + + if (delegatorState.stakedCollatorsCount() < maxCandidatesPerCollator) { + DelegationsLimit.NotReached + } else { + DelegationsLimit.Reached(maxCandidatesPerCollator) + } + } + } + + private suspend fun ExtrinsicBuilder.stakeMore( + chain: Chain, + currentState: MythosDelegatorState, + candidate: AccountIdKey, + amount: Balance + ) { + val currentBlockNumber = chainStateRepository.currentBlock(chain.id) + + val extraToLock = currentState.requiredAdditionalLockToStake(desiredStake = amount, currentBlockNumber) + if (extraToLock.isPositive()) { + collatorStaking.lock(amount) + } + + collatorStaking.stake(listOf(StakingIntent(candidate, amount))) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/MythosMinimumDelegationValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/MythosMinimumDelegationValidation.kt new file mode 100644 index 0000000..a96bbef --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/MythosMinimumDelegationValidation.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.activeStake +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import javax.inject.Inject + +@FeatureScope +class MythosMinimumDelegationValidationFactory @Inject constructor( + private val mythosStakingRepository: MythosStakingRepository, +) { + + context(StartMythosStakingValidationSystemBuilder) + fun minimumDelegation() { + validate(MythosMinimumDelegationValidation(mythosStakingRepository)) + } +} + +private class MythosMinimumDelegationValidation( + private val mythosStakingRepository: MythosStakingRepository, +) : StartMythosStakingValidation { + + override suspend fun validate(value: StartMythosStakingValidationPayload): ValidationStatus { + val minStake = mythosStakingRepository.minStake(value.chainId) + + val amountPlanks = value.asset.token.planksFromAmount(value.amount) + val newStake = value.delegatorState.activeStake + amountPlanks + + return (newStake >= minStake) isTrueOrError { + StartMythosStakingValidationFailure.TooLowStakeAmount( + minimumStake = minStake, + asset = value.asset + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/StartMythosStakingValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/StartMythosStakingValidationFailure.kt new file mode 100644 index 0000000..8435a11 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/StartMythosStakingValidationFailure.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class StartMythosStakingValidationFailure { + + object NotPositiveAmount : StartMythosStakingValidationFailure() + + class NotEnoughBalanceToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : StartMythosStakingValidationFailure(), NotEnoughToPayFeesError + + object NotEnoughStakeableBalance : StartMythosStakingValidationFailure() + + class TooLowStakeAmount( + val minimumStake: Balance, + val asset: Asset + ) : StartMythosStakingValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/StartMythosStakingValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/StartMythosStakingValidationPayload.kt new file mode 100644 index 0000000..3bce8b9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/StartMythosStakingValidationPayload.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.stakeableBalance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigDecimal + +class StartMythosStakingValidationPayload( + val amount: BigDecimal, + val fee: Fee, + val collator: MythosCollator, + val asset: Asset, + val delegatorState: MythosDelegatorState, + val currentBlockNumber: BlockNumber, +) + +val StartMythosStakingValidationPayload.chainId: ChainId + get() = asset.token.configuration.chainId + +fun StartMythosStakingValidationPayload.stakeableAmount(): BigDecimal { + val amountInPlanks = delegatorState.stakeableBalance(asset, currentBlockNumber) + return asset.token.amountFromPlanks(amountInPlanks) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/StartMythosStakingValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/StartMythosStakingValidationSystem.kt new file mode 100644 index 0000000..6ff988e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/start/validations/StartMythosStakingValidationSystem.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias StartMythosStakingValidationSystem = ValidationSystem +typealias StartMythosStakingValidationSystemBuilder = ValidationSystemBuilder +typealias StartMythosStakingValidation = Validation + +fun ValidationSystem.Companion.mythosStakingStart( + minimumDelegationValidationFactory: MythosMinimumDelegationValidationFactory, +): StartMythosStakingValidationSystem = ValidationSystem { + positiveAmount( + amount = { it.amount }, + error = { StartMythosStakingValidationFailure.NotPositiveAmount } + ) + + minimumDelegationValidationFactory.minimumDelegation() + + enoughToPayFees() + + // We should have both this and enoughStakeableAfterFees since we want to show different error messages in those two different cases + enoughStakeable() + + enoughStakeableAfterFees() +} + +private fun StartMythosStakingValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { + StartMythosStakingValidationFailure.NotEnoughBalanceToPayFees( + chainAsset = it.payload.asset.token.configuration, + maxUsable = it.maxUsable, + fee = it.fee + ) + } + ) +} + +private fun StartMythosStakingValidationSystemBuilder.enoughStakeableAfterFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.stakeableAmount() }, + amount = { it.amount }, + error = { + StartMythosStakingValidationFailure.NotEnoughBalanceToPayFees( + chainAsset = it.payload.asset.token.configuration, + maxUsable = it.maxUsable, + fee = it.fee + ) + } + ) +} + +private fun StartMythosStakingValidationSystemBuilder.enoughStakeable() { + sufficientBalance( + available = { it.stakeableAmount() }, + amount = { it.amount }, + error = { StartMythosStakingValidationFailure.NotEnoughStakeableBalance } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/UnbondMythosStakingInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/UnbondMythosStakingInteractor.kt new file mode 100644 index 0000000..0950b5e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/UnbondMythosStakingInteractor.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.collatorStaking +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.unlock +import io.novafoundation.nova.feature_staking_impl.data.mythos.network.blockchain.calls.unstakeFrom +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.rewards.MythosClaimPendingRewardsUseCase +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import javax.inject.Inject + +interface UnbondMythosStakingInteractor { + + suspend fun estimateFee(delegatorState: MythosDelegatorState, candidate: AccountIdKey): Fee + + suspend fun unbond(delegatorState: MythosDelegatorState, candidate: AccountIdKey): Result +} + +@FeatureScope +class RealUnbondMythosStakingInteractor @Inject constructor( + private val stakingSharedState: StakingSharedState, + private val extrinsicService: ExtrinsicService, + private val claimPendingRewardsUseCase: MythosClaimPendingRewardsUseCase, +) : UnbondMythosStakingInteractor { + + override suspend fun estimateFee(delegatorState: MythosDelegatorState, candidate: AccountIdKey): Fee { + val chain = stakingSharedState.chain() + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + claimPendingRewardsUseCase.claimPendingRewards(chain) + + unbond(delegatorState, candidate) + } + } + + override suspend fun unbond(delegatorState: MythosDelegatorState, candidate: AccountIdKey): Result { + val chain = stakingSharedState.chain() + + return extrinsicService.submitExtrinsicAndAwaitExecution(chain, TransactionOrigin.SelectedWallet) { + claimPendingRewardsUseCase.claimPendingRewards(chain) + + unbond(delegatorState, candidate) + } + .requireOk() + } + + private fun ExtrinsicBuilder.unbond( + delegatorState: MythosDelegatorState, + candidate: AccountIdKey, + ) { + collatorStaking.unstakeFrom(candidate) + + val stakedAmount = delegatorState.delegationAmountTo(candidate) + collatorStaking.unlock(stakedAmount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/ReleaseRequestLimitNotReachedValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/ReleaseRequestLimitNotReachedValidation.kt new file mode 100644 index 0000000..a83c0f4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/ReleaseRequestLimitNotReachedValidation.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireIdOfSelectedMetaAccountIn +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosStakingRepository +import io.novafoundation.nova.feature_staking_impl.data.mythos.repository.MythosUserStakeRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import javax.inject.Inject + +@FeatureScope +class MythosReleaseRequestLimitNotReachedValidationFactory @Inject constructor( + private val stakingRepository: MythosStakingRepository, + private val userStakeRepository: MythosUserStakeRepository, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, +) { + + context(UnbondMythosValidationSystemBuilder) + fun releaseRequestsLimitNotReached() { + validate( + ReleaseRequestLimitNotReachedValidation( + stakingRepository = stakingRepository, + userStakeRepository = userStakeRepository, + accountRepository = accountRepository, + chainRegistry = chainRegistry + ) + ) + } +} + +private class ReleaseRequestLimitNotReachedValidation( + private val stakingRepository: MythosStakingRepository, + private val userStakeRepository: MythosUserStakeRepository, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, +) : UnbondMythosValidation { + + override suspend fun validate(value: UnbondMythosStakingValidationPayload): ValidationStatus { + val chain = chainRegistry.getChain(value.chainId) + val accountId = accountRepository.requireIdOfSelectedMetaAccountIn(chain).intoKey() + + val releaseQueues = userStakeRepository.releaseQueues(chain.id, accountId) + val limit = stakingRepository.maxReleaseRequests(chain.id) + + return (releaseQueues.size < limit) isTrueOrError { + UnbondMythosStakingValidationFailure.ReleaseRequestsLimitReached(limit) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/UnbondMythosStakingValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/UnbondMythosStakingValidationFailure.kt new file mode 100644 index 0000000..0ecdc88 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/UnbondMythosStakingValidationFailure.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class UnbondMythosStakingValidationFailure { + + class NotEnoughBalanceToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : UnbondMythosStakingValidationFailure(), NotEnoughToPayFeesError + + class ReleaseRequestsLimitReached(val limit: Int) : UnbondMythosStakingValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/UnbondMythosStakingValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/UnbondMythosStakingValidationPayload.kt new file mode 100644 index 0000000..b232ecf --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/UnbondMythosStakingValidationPayload.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations + +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class UnbondMythosStakingValidationPayload( + val fee: Fee, + val collator: MythosCollator, + val asset: Asset, + val delegatorState: MythosDelegatorState, +) + +val UnbondMythosStakingValidationPayload.chainId: ChainId + get() = asset.token.configuration.chainId diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/UnbondMythosValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/UnbondMythosValidationSystem.kt new file mode 100644 index 0000000..6e52606 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/mythos/unbond/validations/UnbondMythosValidationSystem.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias UnbondMythosValidationSystem = ValidationSystem +typealias UnbondMythosValidationSystemBuilder = ValidationSystemBuilder +typealias UnbondMythosValidation = Validation + +fun ValidationSystem.Companion.mythosUnbond( + releaseRequestLimitNotReachedValidation: MythosReleaseRequestLimitNotReachedValidationFactory +): UnbondMythosValidationSystem = ValidationSystem { + releaseRequestLimitNotReachedValidation.releaseRequestsLimitNotReached() + + enoughToPayFees() +} + +private fun UnbondMythosValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { + UnbondMythosStakingValidationFailure.NotEnoughBalanceToPayFees( + chainAsset = it.payload.asset.token.configuration, + maxUsable = it.maxUsable, + fee = it.fee + ) + } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/ChainExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/ChainExt.kt new file mode 100644 index 0000000..785f2df --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/ChainExt.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun Chain.Asset.findStakingTypeBackingNominationPools(): Chain.Asset.StakingType { + return staking.first { it != Chain.Asset.StakingType.NOMINATION_POOLS } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/NominationPoolsBondMoreInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/NominationPoolsBondMoreInteractor.kt new file mode 100644 index 0000000..37138eb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/NominationPoolsBondMoreInteractor.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.NominationPoolBondExtraSource +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.bondExtra +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.nominationPools +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.DelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.NominationPoolsAvailableBalanceResolver +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface NominationPoolsBondMoreInteractor { + + suspend fun estimateFee(bondMoreAmount: Balance): Fee + + suspend fun bondMore(bondMoreAmount: Balance): Result + + suspend fun stakeableAmount(asset: Asset): Balance +} + +class RealNominationPoolsBondMoreInteractor( + private val extrinsicService: ExtrinsicService, + private val stakingSharedState: StakingSharedState, + private val migrationUseCase: DelegatedStakeMigrationUseCase, + private val poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver, +) : NominationPoolsBondMoreInteractor { + + override suspend fun estimateFee(bondMoreAmount: Balance): Fee { + return withContext(Dispatchers.IO) { + extrinsicService.estimateFee(stakingSharedState.chain(), TransactionOrigin.SelectedWallet) { + bondExtra(bondMoreAmount) + } + } + } + + override suspend fun bondMore(bondMoreAmount: Balance): Result { + return withContext(Dispatchers.IO) { + extrinsicService.submitExtrinsic(stakingSharedState.chain(), TransactionOrigin.SelectedWallet) { + bondExtra(bondMoreAmount) + } + } + } + + override suspend fun stakeableAmount(asset: Asset): Balance { + return poolsAvailableBalanceResolver.maximumBalanceToStake(asset) + } + + private suspend fun ExtrinsicBuilder.bondExtra(amount: Balance) { + migrationUseCase.migrateToDelegatedStakeIfNeeded() + + nominationPools.bondExtra(NominationPoolBondExtraSource.FreeBalance(amount)) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/Declarations.kt new file mode 100644 index 0000000..ffdc1f9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/Declarations.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolAvailableBalanceValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolStateValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.validateNotDestroying +import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount + +typealias NominationPoolsBondMoreValidation = Validation +typealias NominationPoolsBondMoreValidationSystem = ValidationSystem +typealias NominationPoolsBondMoreValidationSystemBuilder = + ValidationSystemBuilder + +fun ValidationSystem.Companion.nominationPoolsBondMore( + poolStateValidationFactory: PoolStateValidationFactory, + poolAvailableBalanceValidationFactory: PoolAvailableBalanceValidationFactory, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory +): NominationPoolsBondMoreValidationSystem = ValidationSystem { + noStakingTypesConflict(stakingTypesConflictValidationFactory) + + poolIsNotDestroying(poolStateValidationFactory) + + notUnstakingAll() + + enoughAvailableToStakeInPool(poolAvailableBalanceValidationFactory) + + positiveBond() +} + +private fun NominationPoolsBondMoreValidationSystemBuilder.noStakingTypesConflict(factory: StakingTypesConflictValidationFactory) { + factory.noStakingTypesConflict( + accountId = { it.poolMember.accountId }, + chainId = { it.asset.token.configuration.chainId }, + error = { NominationPoolsBondMoreValidationFailure.StakingTypesConflict } + ) +} + +private fun NominationPoolsBondMoreValidationSystemBuilder.poolIsNotDestroying(factory: PoolStateValidationFactory) { + factory.validateNotDestroying( + poolId = { it.poolMember.poolId }, + chainId = { it.asset.token.configuration.chainId }, + error = { NominationPoolsBondMoreValidationFailure.PoolIsDestroying } + ) +} + +private fun NominationPoolsBondMoreValidationSystemBuilder.enoughAvailableToStakeInPool(factory: PoolAvailableBalanceValidationFactory) { + factory.enoughAvailableBalanceToStake( + asset = { it.asset }, + fee = { it.fee }, + amount = { it.amount }, + error = NominationPoolsBondMoreValidationFailure::NotEnoughToBond + ) +} + +private fun NominationPoolsBondMoreValidationSystemBuilder.positiveBond() { + positiveAmount( + amount = { it.amount }, + error = { NominationPoolsBondMoreValidationFailure.NotPositiveAmount } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/NominationPoolsBondMoreValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/NominationPoolsBondMoreValidationFailure.kt new file mode 100644 index 0000000..0be7caf --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/NominationPoolsBondMoreValidationFailure.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations + +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolAvailableBalanceValidation + +sealed class NominationPoolsBondMoreValidationFailure { + + class NotEnoughToBond( + override val context: PoolAvailableBalanceValidation.ValidationError.Context + ) : PoolAvailableBalanceValidation.ValidationError, NominationPoolsBondMoreValidationFailure() + + object NotPositiveAmount : NominationPoolsBondMoreValidationFailure() + + object PoolIsDestroying : NominationPoolsBondMoreValidationFailure() + + object UnstakingAll : NominationPoolsBondMoreValidationFailure() + + object StakingTypesConflict : NominationPoolsBondMoreValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/NominationPoolsBondMoreValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/NominationPoolsBondMoreValidationPayload.kt new file mode 100644 index 0000000..145366a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/NominationPoolsBondMoreValidationPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations + +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal + +data class NominationPoolsBondMoreValidationPayload( + val poolMember: PoolMember, + val amount: BigDecimal, + val fee: Fee, + val asset: Asset, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/NotUnstakingAllValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/NotUnstakingAllValidation.kt new file mode 100644 index 0000000..e41750f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/NotUnstakingAllValidation.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novasama.substrate_sdk_android.hash.isPositive + +class NotUnstakingAllValidation : NominationPoolsBondMoreValidation { + + override suspend fun validate(value: NominationPoolsBondMoreValidationPayload): ValidationStatus { + val activePoints = value.poolMember.points.value + + return activePoints.isPositive() isTrueOrError { + NominationPoolsBondMoreValidationFailure.UnstakingAll + } + } +} + +fun NominationPoolsBondMoreValidationSystemBuilder.notUnstakingAll() { + validate(NotUnstakingAllValidation()) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/ValidationFailureUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/ValidationFailureUi.kt new file mode 100644 index 0000000..82cdb0e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/bondMore/validations/ValidationFailureUi.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationFailure.NotEnoughToBond +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationFailure.NotPositiveAmount +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationFailure.PoolIsDestroying +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationFailure.StakingTypesConflict +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationFailure.UnstakingAll +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.handlePoolAvailableBalanceError +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.handlePoolStakingTypesConflictValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.validation.zeroAmount +import java.math.BigDecimal + +fun nominationPoolsBondMoreValidationFailure( + validationStatus: ValidationStatus.NotValid, + resourceManager: ResourceManager, + flowActions: ValidationFlowActions, + updateAmountInUi: (maxAmountToStake: BigDecimal) -> Unit = {} +): TransformedFailure { + return when (val reason = validationStatus.reason) { + is NotEnoughToBond -> handlePoolAvailableBalanceError( + error = reason, + resourceManager = resourceManager, + flowActions = flowActions, + modifyPayload = { oldPayload, maxAmountToStake -> oldPayload.copy(amount = maxAmountToStake) }, + updateAmountInUi = updateAmountInUi + ) + + NotPositiveAmount -> TransformedFailure.Default(resourceManager.zeroAmount()) + + PoolIsDestroying -> TransformedFailure.Default( + resourceManager.getString(R.string.nomination_pools_pool_destroying_error_title) to + resourceManager.getString(R.string.nomination_pools_pool_destroying_error_message) + ) + + UnstakingAll -> TransformedFailure.Default( + resourceManager.getString(R.string.staking_unable_to_stake_more_title) to + resourceManager.getString(R.string.staking_unable_to_stake_more_message) + ) + + StakingTypesConflict -> TransformedFailure.Default(handlePoolStakingTypesConflictValidationFailure(resourceManager)) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/NominationPoolsClaimRewardsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/NominationPoolsClaimRewardsInteractor.kt new file mode 100644 index 0000000..a4a9536 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/NominationPoolsClaimRewardsInteractor.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.NominationPoolBondExtraSource +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.bondExtra +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.claimPayout +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolMembersRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.DelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.claimRewards.PoolPendingRewards +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.withContext + +interface NominationPoolsClaimRewardsInteractor { + + fun pendingRewardsFlow(): Flow + + suspend fun estimateFee(shouldRestake: Boolean): Fee + + suspend fun claimRewards(shouldRestake: Boolean): Result +} + +class RealNominationPoolsClaimRewardsInteractor( + private val poolMemberUseCase: NominationPoolMemberUseCase, + private val poolMembersRepository: NominationPoolMembersRepository, + private val stakingSharedState: StakingSharedState, + private val extrinsicService: ExtrinsicService, + private val migrationUseCase: DelegatedStakeMigrationUseCase +) : NominationPoolsClaimRewardsInteractor { + + override fun pendingRewardsFlow(): Flow { + return poolMemberUseCase.currentPoolMemberFlow() + .filterNotNull() + .distinctUntilChangedBy { it.lastRecordedRewardCounter } + .mapLatest { poolMember -> + val rewards = poolMembersRepository.getPendingRewards(poolMember.accountId, stakingSharedState.chainId()) + + PoolPendingRewards(rewards, poolMember) + } + } + + override suspend fun estimateFee(shouldRestake: Boolean): Fee { + return withContext(Dispatchers.IO) { + extrinsicService.estimateFee(stakingSharedState.chain(), TransactionOrigin.SelectedWallet) { + claimRewards(shouldRestake) + } + } + } + + override suspend fun claimRewards(shouldRestake: Boolean): Result { + return withContext(Dispatchers.IO) { + extrinsicService.submitExtrinsic(stakingSharedState.chain(), TransactionOrigin.SelectedWallet) { + claimRewards(shouldRestake) + } + } + } + + private suspend fun ExtrinsicBuilder.claimRewards(shouldRestake: Boolean) { + migrationUseCase.migrateToDelegatedStakeIfNeeded() + + if (shouldRestake) { + nominationPools.bondExtra(NominationPoolBondExtraSource.Rewards) + } else { + nominationPools.claimPayout() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/Declarations.kt new file mode 100644 index 0000000..16324b0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/Declarations.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_staking_impl.domain.common.validation.profitableAction +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.validate +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +typealias NominationPoolsClaimRewardsValidationSystem = + ValidationSystem + +typealias NominationPoolsClaimRewardsValidationSystemBuilder = + ValidationSystemBuilder + +fun ValidationSystem.Companion.nominationPoolsClaimRewards( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory, +): NominationPoolsClaimRewardsValidationSystem = ValidationSystem { + noStakingTypesConflict(stakingTypesConflictValidationFactory) + + enoughToPayFees() + + sufficientCommissionBalanceToStayAboveED(enoughTotalToStayAboveEDValidationFactory) + + profitableClaim() +} + +private fun NominationPoolsClaimRewardsValidationSystemBuilder.noStakingTypesConflict(factory: StakingTypesConflictValidationFactory) { + factory.noStakingTypesConflict( + accountId = { it.poolMember.accountId }, + chainId = { it.asset.token.configuration.chainId }, + error = { NominationPoolsClaimRewardsValidationFailure.StakingTypesConflict } + ) +} + +private fun NominationPoolsClaimRewardsValidationSystemBuilder.sufficientCommissionBalanceToStayAboveED( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) { + enoughTotalToStayAboveEDValidationFactory.validate( + fee = { it.fee }, + balance = { it.asset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.chain, it.chain.utilityAsset) }, + error = { payload, error -> NominationPoolsClaimRewardsValidationFailure.ToStayAboveED(payload.chain.utilityAsset, error) } + ) +} + +private fun NominationPoolsClaimRewardsValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + NominationPoolsClaimRewardsValidationFailure.NotEnoughBalanceToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) +} + +private fun NominationPoolsClaimRewardsValidationSystemBuilder.profitableClaim() { + profitableAction( + amount = { pendingRewards }, + fee = { it.fee }, + error = { NominationPoolsClaimRewardsValidationFailure.NonProfitableClaim } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/NominationPoolsClaimRewardsValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/NominationPoolsClaimRewardsValidationFailure.kt new file mode 100644 index 0000000..55617e2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/NominationPoolsClaimRewardsValidationFailure.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class NominationPoolsClaimRewardsValidationFailure { + + class NotEnoughBalanceToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : NominationPoolsClaimRewardsValidationFailure(), NotEnoughToPayFeesError + + object NonProfitableClaim : NominationPoolsClaimRewardsValidationFailure() + + class ToStayAboveED(override val asset: Chain.Asset, override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel) : + NominationPoolsClaimRewardsValidationFailure(), InsufficientBalanceToStayAboveEDError + + object StakingTypesConflict : NominationPoolsClaimRewardsValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/NominationPoolsClaimRewardsValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/NominationPoolsClaimRewardsValidationPayload.kt new file mode 100644 index 0000000..1608233 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/NominationPoolsClaimRewardsValidationPayload.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations + +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +class NominationPoolsClaimRewardsValidationPayload( + val fee: Fee, + val pendingRewardsPlanks: Balance, + val asset: Asset, + val chain: Chain, + val poolMember: PoolMember +) + +val NominationPoolsClaimRewardsValidationPayload.pendingRewards: BigDecimal + get() = asset.token.amountFromPlanks(pendingRewardsPlanks) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/ValidationFailureUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/ValidationFailureUi.kt new file mode 100644 index 0000000..37a9046 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/claimRewards/validations/ValidationFailureUi.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations.NominationPoolsClaimRewardsValidationFailure.NotEnoughBalanceToPayFees +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.handlePoolStakingTypesConflictValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun nominationPoolsClaimRewardsValidationFailure( + failure: NominationPoolsClaimRewardsValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage { + return when (failure) { + is NotEnoughBalanceToPayFees -> handleNotEnoughFeeError(failure, resourceManager) + + NominationPoolsClaimRewardsValidationFailure.NonProfitableClaim -> resourceManager.getString(R.string.common_confirmation_title) to + resourceManager.getString(R.string.staking_warning_tiny_payout) + + is NominationPoolsClaimRewardsValidationFailure.ToStayAboveED -> handleInsufficientBalanceCommission( + failure, + resourceManager + ) + + NominationPoolsClaimRewardsValidationFailure.StakingTypesConflict -> handlePoolStakingTypesConflictValidationFailure(resourceManager) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/NominationPoolComparation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/NominationPoolComparation.kt new file mode 100644 index 0000000..d4ce555 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/NominationPoolComparation.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.KnownNovaPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.isNovaPool +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.apy +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun getPoolComparator(knownNovaPools: KnownNovaPools, chain: Chain): Comparator { + return compareByDescending { pool -> knownNovaPools.isNovaPool(chain.id, pool.id) } + .thenByDescending { it.apy.orZero() } + .thenByDescending { it.membersCount } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/NominationPoolMemberUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/NominationPoolMemberUseCase.kt new file mode 100644 index 0000000..36b4f74 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/NominationPoolMemberUseCase.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolMembersRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +interface NominationPoolMemberUseCase { + + fun currentPoolMemberFlow(): Flow + + fun currentPoolMemberFlow(chain: Chain): Flow +} + +class RealNominationPoolMemberUseCase( + private val accountRepository: AccountRepository, + private val stakingSharedState: StakingSharedState, + private val nominationPoolMembersRepository: NominationPoolMembersRepository, +) : NominationPoolMemberUseCase { + + override fun currentPoolMemberFlow(): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { selectedMetaAccount -> + val chain = stakingSharedState.chain() + + val accountId = selectedMetaAccount.accountIdIn(chain) ?: return@flatMapLatest flowOf(null) + + nominationPoolMembersRepository.observePoolMember(chain.id, accountId) + } + } + + override fun currentPoolMemberFlow(chain: Chain): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { selectedMetaAccount -> + val accountId = selectedMetaAccount.accountIdIn(chain) ?: return@flatMapLatest flowOf(null) + + nominationPoolMembersRepository.observePoolMember(chain.id, accountId) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/NominationPoolSharedComputation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/NominationPoolSharedComputation.kt new file mode 100644 index 0000000..46def7d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/NominationPoolSharedComputation.kt @@ -0,0 +1,168 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novafoundation.nova.feature_staking_api.domain.model.StakingLedger +import io.novafoundation.nova.feature_staking_api.domain.model.activeBalance +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.BondedPool +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.UnbondingPools +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.bondedAccountOf +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.deriveAllBondedPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolUnbondRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.rewards.NominationPoolRewardCalculator +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.rewards.NominationPoolRewardCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.BondedPoolState +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlin.coroutines.coroutineContext + +class NominationPoolSharedComputation( + private val computationalCache: ComputationalCache, + private val nominationPoolMemberUseCase: NominationPoolMemberUseCase, + private val nominationPoolStateRepository: NominationPoolStateRepository, + private val nominationPoolUnbondRepository: NominationPoolUnbondRepository, + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, + private val poolAccountDerivation: PoolAccountDerivation, + private val nominationPoolRewardCalculatorFactory: dagger.Lazy, +) { + + fun currentPoolMemberFlow(chain: Chain, scope: CoroutineScope): Flow { + val key = "POOL_MEMBER:${chain.id}" + + return computationalCache.useSharedFlow(key, scope) { + nominationPoolMemberUseCase.currentPoolMemberFlow(chain) + } + } + + fun participatingBondedPoolFlow(poolId: PoolId, chainId: ChainId, scope: CoroutineScope): Flow { + val key = "BONDED_POOL:$chainId:${poolId.value}" + + return computationalCache.useSharedFlow(key, scope) { + nominationPoolStateRepository.observeParticipatingBondedPool(poolId, chainId) + } + } + + fun unbondingPoolsFlow(poolId: PoolId, chainId: ChainId, scope: CoroutineScope): Flow { + val key = "UNBONDING_POOLS:$chainId:${poolId.value}" + + return computationalCache.useSharedFlow(key, scope) { + nominationPoolUnbondRepository.unbondingPoolsFlow(poolId, chainId) + } + } + + fun participatingPoolNominationsFlow( + poolStash: AccountId, + poolId: PoolId, + chainId: ChainId, + scope: CoroutineScope + ): Flow { + val key = "POOL_NOMINATION:$chainId:${poolId.value}" + + return computationalCache.useSharedFlow(key, scope) { + nominationPoolStateRepository.observeParticipatingPoolNominations(poolStash, chainId) + } + } + + fun participatingBondedPoolLedgerFlow( + poolStash: AccountId, + poolId: PoolId, + chainId: ChainId, + scope: CoroutineScope + ): Flow { + val key = "POOL_BONDED_LEDGER:$chainId:${poolId.value}" + + return computationalCache.useSharedFlow(key, scope) { + nominationPoolStateRepository.observeParticipatingPoolLedger(poolStash, chainId) + } + } + + suspend fun participatingBondedPoolLedger( + poolId: PoolId, + chainId: ChainId, + scope: CoroutineScope + ): StakingLedger? { + val poolStash = poolAccountDerivation.bondedAccountOf(poolId, chainId) + + return participatingBondedPoolLedgerFlow(poolStash, poolId, chainId, scope).first() + } + + suspend fun poolRewardCalculator( + stakingOption: StakingOption, + scope: CoroutineScope + ): NominationPoolRewardCalculator { + val key = "NOMINATION_POOLS_REWARD_CALCULATOR:${stakingOption.chain.id}" + + return computationalCache.useCache(key, scope) { + nominationPoolRewardCalculatorFactory.get().create(stakingOption, scope) + } + } + + suspend fun minJoinBond( + chainId: ChainId, + scope: CoroutineScope + ): Balance { + val key = "NOMINATION_POOLS_MIN_JOIN_BOND" + + return computationalCache.useCache(key, scope) { + nominationPoolGlobalsRepository.minJoinBond(chainId) + } + } + + suspend fun allBondedPoolAccounts( + chainId: ChainId, + scope: CoroutineScope + ): Map { + val key = "NOMINATION_POOLS_STASH_IDS" + + return computationalCache.useCache(key, scope) { + val lastPoolId = nominationPoolGlobalsRepository.lastPoolId(chainId) + + poolAccountDerivation.deriveAllBondedPools(lastPoolId, chainId) + } + } + + suspend fun allBondedPools( + chainId: ChainId, + scope: CoroutineScope + ): Map { + val key = "NOMINATION_POOLS_ALL_BONDED_POOLS" + + return computationalCache.useCache(key, scope) { + val allBondedPoolAccounts = allBondedPoolAccounts(chainId, scope) + + nominationPoolStateRepository.getBondedPools(allBondedPoolAccounts.keys, chainId) + } + } +} + +fun NominationPoolSharedComputation.participatingBondedPoolStateFlow( + poolStash: AccountId, + poolId: PoolId, + chainId: ChainId, + scope: CoroutineScope +): Flow = combine( + participatingBondedPoolFlow(poolId, chainId, scope), + participatingBondedPoolLedgerFlow(poolStash, poolId, chainId, scope).map { it.activeBalance() }, + ::BondedPoolState +) + +suspend fun NominationPoolSharedComputation.getParticipatingBondedPoolState( + poolStash: AccountId, + poolId: PoolId, + chainId: ChainId +): BondedPoolState = participatingBondedPoolStateFlow(poolStash, poolId, chainId, CoroutineScope(coroutineContext)).first() diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/delegatedStake/DelegatedStakeMigrationUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/delegatedStake/DelegatedStakeMigrationUseCase.kt new file mode 100644 index 0000000..9a44298 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/delegatedStake/DelegatedStakeMigrationUseCase.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.migrateDelegation +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolDelegatedStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.shouldMigrateToDelegatedStake +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder + +interface DelegatedStakeMigrationUseCase { + + context(ExtrinsicBuilder) + suspend fun migrateToDelegatedStakeIfNeeded() +} + +class RealDelegatedStakeMigrationUseCase( + private val delegatedStakeRepository: NominationPoolDelegatedStakeRepository, + private val stakingSharedState: StakingSharedState, + private val accountRepository: AccountRepository +) : DelegatedStakeMigrationUseCase { + + context(ExtrinsicBuilder) + override suspend fun migrateToDelegatedStakeIfNeeded() { + val chain = stakingSharedState.chain() + val account = accountRepository.getSelectedMetaAccount() + val accountId = account.requireAccountIdIn(chain) + + if (delegatedStakeRepository.shouldMigrateToDelegatedStake(stakingSharedState.chainId(), accountId)) { + nominationPools.migrateDelegation(accountId) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/hints/NominationPoolHintsUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/hints/NominationPoolHintsUseCase.kt new file mode 100644 index 0000000..b794008 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/hints/NominationPoolHintsUseCase.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.hints + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.isNonPositive +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolMembersRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.runtime.state.chainAndAsset + +interface NominationPoolHintsUseCase { + + suspend fun rewardsWillBeClaimedHint(): String? +} + +class RealNominationPoolHintsUseCase( + private val stakingSharedState: StakingSharedState, + private val poolMembersRepository: NominationPoolMembersRepository, + private val accountRepository: AccountRepository, + private val resourceManager: ResourceManager, +) : NominationPoolHintsUseCase { + + override suspend fun rewardsWillBeClaimedHint(): String? = runCatching { + val account = accountRepository.getSelectedMetaAccount() + val (chain, chainAsset) = stakingSharedState.chainAndAsset() + + val accountId = account.requireAccountIdIn(chain) + + val pendingRewards = poolMembersRepository.getPendingRewards(accountId, chain.id) + + if (pendingRewards.isNonPositive) return@runCatching null + + val amountFormatted = pendingRewards.formatPlanks(chainAsset) + + resourceManager.getString(R.string.nomination_pools_pending_claim_hint_format, amountFormatted) + }.getOrNull() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/poolState/PoolState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/poolState/PoolState.kt new file mode 100644 index 0000000..d1c0f60 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/poolState/PoolState.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.poolState + +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId + +fun AccountIdMap.isPoolStaking(poolStash: AccountId, poolNominations: Nominations?): Boolean { + // whereas pool might still stake without nominations if era it has chilled in haven't yet finished + // we still mark it as Inactive to warn user preemptively + if (poolNominations == null) return false + + // fast path - we don't need to traverse the entire exposure map if pool is staking with some of its current validators + if (isPoolStakingWithCurrentNominations(poolStash, poolNominations)) return true + + // slow path - despite pool is not staking with its current nomination it might still stake with a validator if it changed nominations recently + return isPoolStakingWithAnyValidator(poolStash) +} + +private fun AccountIdMap.isPoolStakingWithCurrentNominations(poolStash: AccountId, poolNominations: Nominations): Boolean { + return poolNominations.targets.any { validator -> + val accountIdHex = validator.toHexString() + val exposure = get(accountIdHex) ?: return@any false + + exposure.others.any { it.who.contentEquals(poolStash) } + } +} + +private fun AccountIdMap.isPoolStakingWithAnyValidator(poolStash: AccountId): Boolean { + return any { (_, exposure) -> + exposure.others.any { it.who.contentEquals(poolStash) } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/rewards/NominationPoolRewardCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/rewards/NominationPoolRewardCalculator.kt new file mode 100644 index 0000000..267a19f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/rewards/NominationPoolRewardCalculator.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.rewards + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId + +interface NominationPoolRewardCalculator { + + val maxAPY: Fraction + + fun apyFor(poolId: PoolId): Fraction? +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/rewards/RealNominationPoolRewardCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/rewards/RealNominationPoolRewardCalculator.kt new file mode 100644 index 0000000..4cec59f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/rewards/RealNominationPoolRewardCalculator.kt @@ -0,0 +1,112 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.rewards + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.common.utils.Perbill +import io.novafoundation.nova.common.utils.mapValuesNotNull +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.reversed +import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.electedExposuresInActiveEra +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculator +import kotlinx.coroutines.CoroutineScope + +class NominationPoolRewardCalculatorFactory( + private val sharedStakingSharedComputation: StakingSharedComputation, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, +) { + + suspend fun create(stakingOption: StakingOption, sharedComputationScope: CoroutineScope): NominationPoolRewardCalculator { + val chainId = stakingOption.chain.id + + val delegateOption = stakingOption.unwrapNominationPools() + + val delegate = sharedStakingSharedComputation.rewardCalculator(delegateOption, sharedComputationScope) + val allPoolAccounts = nominationPoolSharedComputation.allBondedPoolAccounts(chainId, sharedComputationScope) + val poolCommissions = nominationPoolSharedComputation.allBondedPools(chainId, sharedComputationScope) + .mapValues { (_, pool) -> pool.commission?.current?.perbill } + + return RealNominationPoolRewardCalculator( + directStakingDelegate = delegate, + exposures = sharedStakingSharedComputation.electedExposuresInActiveEra(stakingOption.assetWithChain.chain.id, sharedComputationScope), + commissions = poolCommissions, + poolStashesById = allPoolAccounts + ) + } +} + +private class RealNominationPoolRewardCalculator( + private val directStakingDelegate: RewardCalculator, + private val exposures: AccountIdMap, + private val commissions: Map, + poolStashesById: Map, +) : NominationPoolRewardCalculator { + + private val poolIdsByStashes: AccountIdKeyMap = poolStashesById.reversed() + + private val apyByPoolStash: Map = constructPoolsApy() + + override val maxAPY: Fraction = apyByPoolStash + .values + .maxOrNull() + .orZero() + + override fun apyFor(poolId: PoolId): Fraction? { + return apyByPoolStash[poolId] + } + + private fun constructPoolsApy(): Map { + val activeValidatorsByPoolStash = exposures.findPoolsValidators(poolIdsByStashes.keys) + + return activeValidatorsByPoolStash.mapValuesNotNull { (poolStash, nominators) -> + calculatePoolApy(poolStash, nominators) + } + } + + private fun calculatePoolApy(poolId: PoolId, poolValidatorsIdsHex: List): Fraction? { + if (poolValidatorsIdsHex.isEmpty()) return null + + val commission = commissions[poolId]?.value.orZero() + + val maxApyAcrossValidators = poolValidatorsIdsHex.maxOf { validatorIdHex -> + directStakingDelegate.getApyFor(validatorIdHex).toDouble() + } + + val apy = maxApyAcrossValidators * (1.0 - commission) + + return apy.fractions + } + + private fun AccountIdMap.findPoolsValidators(poolStashes: Set): Map> { + val activeValidatorsByPoolStash = mutableMapOf>() + + forEach { (validatorIdHex, exposure) -> + exposure.others.forEach { nominator -> + val nominatorKey = nominator.who.intoKey() + + if (nominatorKey in poolStashes) { + poolIdsByStashes[nominatorKey]?.let { poolId -> + activeValidatorsByPoolStash.addListItem(poolId, validatorIdHex) + } + } + } + } + + return activeValidatorsByPoolStash + } + + private fun MutableMap>.addListItem(key: K, item: V) { + val items = getOrPut(key) { mutableListOf() } + items.add(item) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/validations/PoolAvailableBalanceValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/validations/PoolAvailableBalanceValidation.kt new file mode 100644 index 0000000..3029b15 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/validations/PoolAvailableBalanceValidation.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer.Payload.DialogAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isTrueOrWarning +import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.NominationPoolsAvailableBalanceResolver +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.SimpleFeeProducer +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.hash.isPositive +import java.math.BigDecimal + +class PoolAvailableBalanceValidationFactory( + private val poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver, +) { + + context(ValidationSystemBuilder) + fun enoughAvailableBalanceToStake( + asset: (P) -> Asset, + fee: SimpleFeeProducer

, + amount: (P) -> BigDecimal, + error: (PoolAvailableBalanceValidation.ValidationError.Context) -> E + ) { + validate( + PoolAvailableBalanceValidation( + poolsAvailableBalanceResolver = poolsAvailableBalanceResolver, + asset = asset, + fee = fee, + error = error, + amount = amount + ) + ) + } +} + +class PoolAvailableBalanceValidation( + private val poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver, + private val asset: (P) -> Asset, + private val fee: SimpleFeeProducer

, + private val amount: (P) -> BigDecimal, + private val error: (ValidationError.Context) -> E +) : Validation { + + interface ValidationError { + + val context: Context + + class Context( + val availableBalance: Balance, + val minimumBalance: Balance, + val fee: Balance, + val maximumToStake: Balance, + val chainAsset: Chain.Asset, + ) + } + + override suspend fun validate(value: P): ValidationStatus { + val asset = asset(value) + val chainAsset = asset.token.configuration + + val fee = fee(value)?.amountByExecutingAccount.orZero() + val availableBalance = poolsAvailableBalanceResolver.availableBalanceToStartStaking(asset) + val maxToStake = poolsAvailableBalanceResolver.maximumBalanceToStake(asset, fee) + val enteredAmount = chainAsset.planksFromAmount(amount(value)) + + val hasEnoughToStake = enteredAmount <= maxToStake.maxToStake + + return hasEnoughToStake isTrueOrWarning { + val errorContext = ValidationError.Context( + availableBalance = availableBalance, + minimumBalance = maxToStake.existentialDeposit, + fee = fee, + maximumToStake = maxToStake.maxToStake, + chainAsset = chainAsset + ) + error(errorContext) + } + } +} + +fun

handlePoolAvailableBalanceError( + error: PoolAvailableBalanceValidation.ValidationError, + resourceManager: ResourceManager, + flowActions: ValidationFlowActions

, + modifyPayload: (oldPayload: P, maxAmountToStake: BigDecimal) -> P, + updateAmountInUi: (maxAmountToStake: BigDecimal) -> Unit = {} +): TransformedFailure.Custom = with(error.context) { + val maximumToStakeAmount = chainAsset.amountFromPlanks(maximumToStake) + + val dialogPayload = CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.common_not_enough_funds_title), + message = resourceManager.getString( + R.string.staking_pool_available_validation_message, + availableBalance.formatPlanks(chainAsset), + minimumBalance.formatPlanks(chainAsset), + fee.formatPlanks(chainAsset), + maximumToStakeAmount.formatTokenAmount(chainAsset) + ), + okAction = if (maximumToStake.isPositive()) { + DialogAction( + title = resourceManager.getString(R.string.staking_stake_max), + action = { + updateAmountInUi(maximumToStakeAmount) + + flowActions.revalidate { oldPayload -> modifyPayload(oldPayload, maximumToStakeAmount) } + } + ) + } else { + null + }, + cancelAction = DialogAction.noOp(resourceManager.getString(R.string.common_close)) + ) + + TransformedFailure.Custom(dialogPayload) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/validations/PoolStateValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/validations/PoolStateValidation.kt new file mode 100644 index 0000000..6939421 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/validations/PoolStateValidation.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class PoolStateValidationFactory( + private val poolStateRepository: NominationPoolStateRepository, +) { + + context(ValidationSystemBuilder) + fun validatePoolState( + poolId: (T) -> PoolId, + chainId: (T) -> ChainId, + stateValid: (PoolState) -> Boolean, + error: (T) -> S + ) { + validate(PoolStateValidation(poolStateRepository, poolId, chainId, stateValid, error)) + } +} + +context(ValidationSystemBuilder) +fun PoolStateValidationFactory.validateNotDestroying( + poolId: (T) -> PoolId, + chainId: (T) -> ChainId, + error: (T) -> S +) { + validatePoolState( + poolId = poolId, + chainId = chainId, + stateValid = { it != PoolState.Destroying }, + error = error + ) +} + +class PoolStateValidation( + private val poolStateRepository: NominationPoolStateRepository, + private val poolId: (T) -> PoolId, + private val chainId: (T) -> ChainId, + private val stateValid: (PoolState) -> Boolean, + private val error: (T) -> S +) : Validation { + + override suspend fun validate(value: T): ValidationStatus { + val bondedPool = poolStateRepository.getParticipatingBondedPool(poolId(value), chainId(value)) + val isStateValid = stateValid(bondedPool.state) + + return isStateValid isTrueOrError { error(value) } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/validations/StakingTypesConflictValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/validations/StakingTypesConflictValidation.kt new file mode 100644 index 0000000..0dfedcc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/common/validations/StakingTypesConflictValidation.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isFalseOrError +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolDelegatedStakeRepository +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolMembersRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidation.ConflictingStakingType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +class StakingTypesConflictValidationFactory( + private val stakingRepository: StakingRepository, + private val delegatedStakeRepository: NominationPoolDelegatedStakeRepository, + private val nominationPoolStakingRepository: NominationPoolMembersRepository +) { + + context(ValidationSystemBuilder) + fun noStakingTypesConflict( + accountId: suspend (P) -> AccountId, + chainId: (P) -> ChainId, + error: () -> E, + checkStakingTypeNotPresent: ConflictingStakingType = ConflictingStakingType.DIRECT + ) { + validate( + StakingTypesConflictValidation( + accountId = accountId, + chainId = chainId, + error = error, + stakingRepository = stakingRepository, + delegatedStakeRepository = delegatedStakeRepository, + nominationPoolStakingRepository = nominationPoolStakingRepository, + checkStakingTypeNotPresent = checkStakingTypeNotPresent + ) + ) + } +} + +class StakingTypesConflictValidation( + private val accountId: suspend (P) -> AccountId, + private val chainId: (P) -> ChainId, + private val error: () -> E, + private val stakingRepository: StakingRepository, + private val nominationPoolStakingRepository: NominationPoolMembersRepository, + private val delegatedStakeRepository: NominationPoolDelegatedStakeRepository, + private val checkStakingTypeNotPresent: ConflictingStakingType +) : Validation { + + enum class ConflictingStakingType { + POOLS, DIRECT + } + + override suspend fun validate(value: P): ValidationStatus { + val chainId = chainId(value) + val delegatedStakeSupported = delegatedStakeRepository.hasMigratedToDelegatedStake(chainId) + if (!delegatedStakeSupported) return valid() + + val isConflictingTypePresent = checkStakingTypeNotPresent.checkPresent(chainId, accountId(value)) + + return isConflictingTypePresent isFalseOrError error + } + + private suspend fun ConflictingStakingType.checkPresent(chainId: ChainId, accountId: AccountId): Boolean { + return when (this) { + ConflictingStakingType.POOLS -> nominationPoolStakingRepository.getPoolMember(chainId, accountId) != null + ConflictingStakingType.DIRECT -> stakingRepository.ledger(chainId, accountId) != null + } + } +} + +fun handlePoolStakingTypesConflictValidationFailure(resourceManager: ResourceManager): TitleAndMessage { + return resourceManager.getString(R.string.pool_staking_conflict_title) to + resourceManager.getString(R.string.pool_staking_conflict_message) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/alerts/NominationPoolAlert.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/alerts/NominationPoolAlert.kt new file mode 100644 index 0000000..8fcff9c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/alerts/NominationPoolAlert.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +sealed class NominationPoolAlert { + + object WaitingForNextEra : NominationPoolAlert() + + class RedeemTokens(val amount: Balance) : NominationPoolAlert() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/alerts/NominationPoolsAlertsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/alerts/NominationPoolsAlertsInteractor.kt new file mode 100644 index 0000000..7c7e07d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/alerts/NominationPoolsAlertsInteractor.kt @@ -0,0 +1,111 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novafoundation.nova.feature_staking_api.domain.model.totalRedeemableIn +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.UnbondingPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.unlockChunksFor +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.bondedAccountOf +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.isWaiting +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.poolState.isPoolStaking +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts.NominationPoolAlert.RedeemTokens +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts.NominationPoolAlert.WaitingForNextEra +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts.RealNominationPoolsAlertsInteractor.AlertsResolutionContext.PoolContext +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.hash.isPositive +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +interface NominationPoolsAlertsInteractor { + + fun alertsFlow( + poolMember: PoolMember, + chain: Chain, + shareComputationScope: CoroutineScope + ): Flow> +} + +class RealNominationPoolsAlertsInteractor( + private val nominationPoolsSharedComputation: NominationPoolSharedComputation, + private val stakingSharedComputation: StakingSharedComputation, + private val poolAccountDerivation: PoolAccountDerivation, +) : NominationPoolsAlertsInteractor { + + private val alertsConstructors = listOf( + ::constructWaitingForNextEraAlert, + ::constructRedeemAlert + ) + + override fun alertsFlow(poolMember: PoolMember, chain: Chain, shareComputationScope: CoroutineScope): Flow> { + return flowOfAll { + val poolId = poolMember.poolId + val poolStash = poolAccountDerivation.bondedAccountOf(poolId, chain.id) + + combine( + nominationPoolsSharedComputation.participatingPoolNominationsFlow(poolStash, poolId, chain.id, shareComputationScope), + nominationPoolsSharedComputation.unbondingPoolsFlow(poolId, chain.id, shareComputationScope), + stakingSharedComputation.electedExposuresWithActiveEraFlow(chain.id, shareComputationScope), + ) { poolNominations, unbondingPools, (eraStakers, activeEra) -> + val alertsContext = AlertsResolutionContext( + eraStakers = eraStakers, + activeEra = activeEra, + pool = PoolContext( + nominations = poolNominations, + unbonding = unbondingPools, + stash = poolStash, + ), + poolMember = poolMember + ) + + constructAlerts(alertsContext) + } + } + } + + private fun constructAlerts(context: AlertsResolutionContext): List { + return alertsConstructors.mapNotNull { it.invoke(context) } + } + + private fun constructWaitingForNextEraAlert(context: AlertsResolutionContext): WaitingForNextEra? = with(context) { + val isPoolStaking = eraStakers.isPoolStaking(pool.stash, pool.nominations) + val isNominationWaiting = pool.nominations != null && pool.nominations.isWaiting(activeEra) + + val isWaitingForNextEra = !isPoolStaking && isNominationWaiting + + WaitingForNextEra.takeIf { isWaitingForNextEra } + } + + private fun constructRedeemAlert(context: AlertsResolutionContext): RedeemTokens? = with(context) { + val unlockChunks = pool.unbonding.unlockChunksFor(poolMember) + val totalRedeemable = unlockChunks.totalRedeemableIn(activeEra) + + if (totalRedeemable.isPositive()) { + RedeemTokens(totalRedeemable) + } else { + null + } + } + + private class AlertsResolutionContext( + val eraStakers: AccountIdMap, + val activeEra: EraIndex, + val pool: PoolContext, + val poolMember: PoolMember, + ) { + + class PoolContext( + val nominations: Nominations?, + val unbonding: UnbondingPools?, + val stash: AccountId, + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/networkInfo/NominationPoolsNetworkInfoInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/networkInfo/NominationPoolsNetworkInfoInteractor.kt new file mode 100644 index 0000000..e86730a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/networkInfo/NominationPoolsNetworkInfoInteractor.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.networkInfo + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.deriveAllBondedPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.electedExposuresInActiveEraFlow +import io.novafoundation.nova.feature_staking_impl.domain.model.NetworkInfo +import io.novafoundation.nova.feature_staking_impl.domain.model.StakingPeriod +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +interface NominationPoolsNetworkInfoInteractor { + + fun observeShouldShowNetworkInfo(): Flow + + fun observeNetworkInfo(chainId: ChainId, sharedComputationScope: CoroutineScope): Flow +} + +class RealNominationPoolsNetworkInfoInteractor( + private val relaychainStakingSharedComputation: StakingSharedComputation, + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, + private val poolAccountDerivation: PoolAccountDerivation, + private val relaychainStakingInteractor: StakingInteractor, + private val nominationPoolMemberUseCase: NominationPoolMemberUseCase, +) : NominationPoolsNetworkInfoInteractor { + + override fun observeShouldShowNetworkInfo(): Flow { + return nominationPoolMemberUseCase.currentPoolMemberFlow().map { it == null } + } + + override fun observeNetworkInfo( + chainId: ChainId, + sharedComputationScope: CoroutineScope + ): Flow { + return combine( + relaychainStakingSharedComputation.electedExposuresInActiveEraFlow(chainId, sharedComputationScope), + nominationPoolGlobalsRepository.observeMinJoinBond(chainId), + nominationPoolGlobalsRepository.lastPoolIdFlow(chainId), + lockupDurationFlow(sharedComputationScope), + ) { exposures, minJoinBond, lastPoolId, lockupDuration -> + NetworkInfo( + lockupPeriod = lockupDuration, + minimumStake = minJoinBond, + totalStake = calculateTotalStake(exposures, lastPoolId, chainId), + stakingPeriod = StakingPeriod.Unlimited, + nominatorsCount = null + ) + } + } + + private fun lockupDurationFlow(sharedComputationScope: CoroutineScope) = flowOf { relaychainStakingInteractor.getLockupDuration(sharedComputationScope) } + + private suspend fun calculateTotalStake( + exposures: AccountIdMap, + lastPoolId: PoolId, + chainId: ChainId, + ): Balance { + val allPoolAccountIds = poolAccountDerivation.deriveAllBondedPools(lastPoolId, chainId).values + + return exposures.values.sumOf { exposure -> + exposure.others.sumOf { nominatorExposure -> + if (nominatorExposure.who.intoKey() in allPoolAccountIds) { + nominatorExposure.value + } else { + BigInteger.ZERO + } + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/stakeSummary/NominationPoolStakeSummaryInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/stakeSummary/NominationPoolStakeSummaryInteractor.kt new file mode 100644 index 0000000..e5a3f73 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/stakeSummary/NominationPoolStakeSummaryInteractor.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.stakeSummary + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.bondedAccountOf +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.Nominations +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.isWaiting +import io.novafoundation.nova.feature_staking_impl.domain.model.StakeSummary +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.participatingBondedPoolStateFlow +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.poolState.isPoolStaking +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.amountOf +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlin.time.Duration.Companion.milliseconds + +interface NominationPoolStakeSummaryInteractor { + + fun stakeSummaryFlow( + poolMember: PoolMember, + stakingOption: StakingOption, + sharedComputationScope: CoroutineScope, + ): Flow> +} + +class RealNominationPoolStakeSummaryInteractor( + private val stakingSharedComputation: StakingSharedComputation, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val poolAccountDerivation: PoolAccountDerivation, +) : NominationPoolStakeSummaryInteractor { + + override fun stakeSummaryFlow( + poolMember: PoolMember, + stakingOption: StakingOption, + sharedComputationScope: CoroutineScope, + ): Flow> = flowOfAll { + val chainId = stakingOption.assetWithChain.chain.id + val poolStash = poolAccountDerivation.bondedAccountOf(poolMember.poolId, chainId) + + combineTransform( + nominationPoolSharedComputation.participatingBondedPoolStateFlow(poolStash, poolMember.poolId, chainId, sharedComputationScope), + nominationPoolSharedComputation.participatingPoolNominationsFlow(poolStash, poolMember.poolId, chainId, sharedComputationScope), + stakingSharedComputation.electedExposuresWithActiveEraFlow(chainId, sharedComputationScope) + ) { bondedPoolState, poolNominations, (eraStakers, activeEra) -> + val activeStaked = bondedPoolState.amountOf(poolMember.points) + + val stakeSummaryFlow = flow { + determineStakeStatus(stakingOption, eraStakers, activeEra, poolNominations, poolStash, poolMember, sharedComputationScope) + } + .map { status -> StakeSummary(status, activeStaked) } + + emitAll(stakeSummaryFlow) + } + } + + private suspend fun FlowCollector.determineStakeStatus( + stakingOption: StakingOption, + eraStakers: AccountIdMap, + activeEra: EraIndex, + poolNominations: Nominations?, + poolStash: AccountId, + poolMember: PoolMember, + sharedComputationScope: CoroutineScope + ) { + when { + poolMember.points.value.isZero -> emit(PoolMemberStatus.Inactive) + + eraStakers.isPoolStaking(poolStash, poolNominations) -> emit(PoolMemberStatus.Active) + + poolNominations != null && poolNominations.isWaiting(activeEra) -> { + val nominationsEffectiveEra = poolNominations.submittedInEra + EraIndex.ONE + + val statusFlow = stakingSharedComputation.eraCalculatorFlow(stakingOption, sharedComputationScope).map { eraTimerCalculator -> + val waitingTime = eraTimerCalculator.calculate(nominationsEffectiveEra) + + PoolMemberStatus.Waiting(waitingTime.toLong().milliseconds) + } + + emitAll(statusFlow) + } + + else -> emit(PoolMemberStatus.Inactive) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/stakeSummary/PoolMemberStatus.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/stakeSummary/PoolMemberStatus.kt new file mode 100644 index 0000000..c59d35f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/stakeSummary/PoolMemberStatus.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.stakeSummary + +import kotlin.time.Duration + +sealed class PoolMemberStatus { + + object Active : PoolMemberStatus() + + class Waiting(val timeLeft: Duration) : PoolMemberStatus() + + object Inactive : PoolMemberStatus() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/unbondings/NominationPoolUnbondingsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/unbondings/NominationPoolUnbondingsInteractor.kt new file mode 100644 index 0000000..3887049 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/unbondings/NominationPoolUnbondingsInteractor.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.unbondings + +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.UnbondingPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.unlockChunksFor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.Unbondings +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.constructUnbondingList +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.from +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +interface NominationPoolUnbondingsInteractor { + + fun unbondingsFlow( + poolMember: PoolMember, + stakingOption: StakingOption, + sharedComputationScope: CoroutineScope, + ): Flow +} + +class RealNominationPoolUnbondingsInteractor( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val stakingSharedComputation: StakingSharedComputation, +) : NominationPoolUnbondingsInteractor { + + override fun unbondingsFlow( + poolMember: PoolMember, + stakingOption: StakingOption, + sharedComputationScope: CoroutineScope, + ): Flow { + val chainId = stakingOption.assetWithChain.chain.id + return combineToPair( + stakingSharedComputation.activeEraFlow(chainId, sharedComputationScope), + nominationPoolSharedComputation.unbondingPoolsFlow(poolMember.poolId, chainId, sharedComputationScope), + ) + .flatMapLatest { (activeEraIndex, unbondingPools) -> + unbondingPools.unbondingsFor(poolMember, activeEraIndex, stakingOption, sharedComputationScope) + } + .map { Unbondings.from(it, rebondPossible = false) } + } + + private fun UnbondingPools?.unbondingsFor( + poolMember: PoolMember, + activeEra: EraIndex, + stakingOption: StakingOption, + sharedComputationScope: CoroutineScope, + ): Flow> { + if (this == null) return flowOf(emptyList()) + + val unlockChunks = unlockChunksFor(poolMember) + + return stakingSharedComputation.constructUnbondingList( + eraRedeemables = unlockChunks, + activeEra = activeEra, + stakingOption = stakingOption, + sharedComputationScope = sharedComputationScope + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/userRewards/NomnationPoolsUserRewardsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/userRewards/NomnationPoolsUserRewardsInteractor.kt new file mode 100644 index 0000000..282e5b0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/userRewards/NomnationPoolsUserRewardsInteractor.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.userRewards + +import android.util.Log +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolMembersRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.userRewards.NominationPoolsUserRewardsInteractor.NominationPoolRewards +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine + +interface NominationPoolsUserRewardsInteractor { + + class NominationPoolRewards( + val total: LoadingState, + val claimable: LoadingState + ) + + fun rewardsFlow(accountId: AccountId, stakingOptionId: StakingOptionId): Flow + + suspend fun syncRewards(accountId: AccountId, stakingOption: StakingOption, rewardPeriod: RewardPeriod): Result<*> +} + +class RealNominationPoolsUserRewardsInteractor( + private val repository: NominationPoolMembersRepository, + private val stakingRewardsRepository: StakingRewardsRepository, +) : NominationPoolsUserRewardsInteractor { + + override fun rewardsFlow(accountId: AccountId, stakingOptionId: StakingOptionId): Flow { + return combine( + pendingRewardsFlow(accountId, stakingOptionId.chainId).withLoading(), + stakingRewardsRepository.totalRewardFlow(accountId, stakingOptionId).withLoading() + ) { pendingRewards, totalRewards -> + NominationPoolRewards( + total = totalRewards, + claimable = pendingRewards, + ) + } + } + + override suspend fun syncRewards(accountId: AccountId, stakingOption: StakingOption, rewardPeriod: RewardPeriod): Result<*> { + return runCatching { stakingRewardsRepository.sync(accountId, stakingOption, rewardPeriod) } + } + + private fun pendingRewardsFlow(accountId: AccountId, chainId: ChainId): Flow { + return flowOf { repository.getPendingRewards(accountId, chainId) } + .catch { Log.e("NominationPoolsUserRewardsInteractor", "Failed to fetch pending rewards", it) } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/yourPool/NominationPoolYourPoolInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/yourPool/NominationPoolYourPoolInteractor.kt new file mode 100644 index 0000000..abab2a6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/main/yourPool/NominationPoolYourPoolInteractor.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.yourPool + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMetadata +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.bondedAccountOf +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.yourPool.NominationPoolYourPoolInteractor.YourPool +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.PoolDisplay +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface NominationPoolYourPoolInteractor { + + class YourPool( + val id: PoolId, + override val icon: Icon?, + override val metadata: PoolMetadata?, + override val stashAccountId: AccountId, + ) : PoolDisplay + + fun yourPoolFlow(poolId: PoolId, chainId: ChainId): Flow +} + +class RealNominationPoolYourPoolInteractor( + private val poolAccountDerivation: PoolAccountDerivation, + private val poolStateRepository: NominationPoolStateRepository, +) : NominationPoolYourPoolInteractor { + override fun yourPoolFlow(poolId: PoolId, chainId: ChainId): Flow { + return flowOfAll { + val stashAccountId = poolAccountDerivation.bondedAccountOf(poolId, chainId) + val icon = poolStateRepository.getPoolIcon(poolId, chainId) + + poolStateRepository.observePoolMetadata(poolId, chainId).map { poolMetadata -> + YourPool( + id = poolId, + icon = icon, + metadata = poolMetadata, + stashAccountId = stashAccountId + ) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/BondedPoolState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/BondedPoolState.kt new file mode 100644 index 0000000..320124e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/BondedPoolState.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model + +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.BondedPool +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolPoints +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class BondedPoolState( + val bondedPool: BondedPool, + override val poolBalance: Balance +) : PoolBalanceConvertable { + + override val poolPoints: PoolPoints = bondedPool.points +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/DelegatedStakeMigrationState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/DelegatedStakeMigrationState.kt new file mode 100644 index 0000000..4b6fddb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/DelegatedStakeMigrationState.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model + +enum class DelegatedStakeMigrationState { + + NOT_SUPPORTED, NEEDS_MIGRATION, MIGRATED +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/NominationPool.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/NominationPool.kt new file mode 100644 index 0000000..c7d70b2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/NominationPool.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMetadata +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolState +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class NominationPool( + val id: PoolId, + val membersCount: Int, + val state: PoolState, + val status: Status, + override val metadata: PoolMetadata?, + override val icon: Icon?, + override val stashAccountId: AccountId +) : PoolDisplay { + + sealed class Status { + + class Active(val apy: Fraction) : Status() + + object Inactive : Status() + } +} + +val NominationPool.apy: Fraction? + get() = when (status) { + is NominationPool.Status.Active -> status.apy + NominationPool.Status.Inactive -> null + } + +val NominationPool.Status.isActive: Boolean + get() = this is NominationPool.Status.Active + +fun NominationPool.address(chain: Chain): String { + return chain.addressOf(stashAccountId) +} + +fun NominationPool.name(): String? { + return metadata?.title +} + +fun NominationPool.nameOrAddress(chain: Chain): String { + return name() ?: address(chain) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/PoolBalanceConvertable.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/PoolBalanceConvertable.kt new file mode 100644 index 0000000..2ce098e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/PoolBalanceConvertable.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model + +import io.novafoundation.nova.common.utils.divideToDecimal +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolPoints +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigDecimal +import java.math.RoundingMode + +interface PoolBalanceConvertable { + + val poolPoints: PoolPoints + + val poolBalance: Balance +} + +val PoolBalanceConvertable.balanceToPointsRatio: BigDecimal + get() { + if (poolPoints.value.isZero) return BigDecimal.ZERO + + return poolBalance.divideToDecimal(poolPoints.value) + } + +val PoolBalanceConvertable.pointsToBalanceRatio: BigDecimal + get() { + if (poolBalance.isZero) return BigDecimal.ZERO + + return poolPoints.value.divideToDecimal(poolBalance) + } + +fun PoolBalanceConvertable.amountOf(memberPoints: PoolPoints): Balance { + return (balanceToPointsRatio * memberPoints.value.toBigDecimal()).toBigInteger() +} + +fun PoolBalanceConvertable.pointsOf(amount: Balance): PoolPoints { + val pointsRaw = (pointsToBalanceRatio * amount.toBigDecimal()) + .setScale(0, RoundingMode.UP) + .toBigInteger() + + return PoolPoints(pointsRaw) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/PoolDisplay.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/PoolDisplay.kt new file mode 100644 index 0000000..fcde182 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/model/PoolDisplay.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMetadata +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface PoolDisplay { + + val icon: Icon? + + val metadata: PoolMetadata? + + val stashAccountId: AccountId +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/pools/NominationPoolProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/pools/NominationPoolProvider.kt new file mode 100644 index 0000000..a88fb56 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/pools/NominationPoolProvider.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.rewards.NominationPoolRewardCalculator +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import kotlinx.coroutines.CoroutineScope + +interface NominationPoolProvider { + + suspend fun getNominationPools( + stakingOption: StakingOption, + computationScope: CoroutineScope, + ): List +} + +class RealNominationPoolProvider( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val nominationPoolStateRepository: NominationPoolStateRepository, + private val poolStateRepository: NominationPoolStateRepository +) : NominationPoolProvider { + + override suspend fun getNominationPools( + stakingOption: StakingOption, + computationScope: CoroutineScope + ): List { + val chainId = stakingOption.chain.id + + val rewardCalculator = nominationPoolSharedComputation.poolRewardCalculator(stakingOption, computationScope) + val bondedPools = nominationPoolSharedComputation.allBondedPools(chainId, computationScope) + + val allPoolAccounts = nominationPoolSharedComputation.allBondedPoolAccounts(chainId, computationScope) + val poolMetadatas = nominationPoolStateRepository.getPoolMetadatas(allPoolAccounts.keys, chainId) + + return bondedPools.map { (poolId, bondedPool) -> + NominationPool( + id = poolId, + membersCount = bondedPool.memberCounter, + status = rewardCalculator.getPoolStatus(poolId), + metadata = poolMetadatas[poolId], + state = bondedPool.state, + icon = poolStateRepository.getPoolIcon(poolId, chainId), + stashAccountId = allPoolAccounts.getValue(poolId).value, + ) + } + } + + private fun NominationPoolRewardCalculator.getPoolStatus(poolId: PoolId): NominationPool.Status { + val apy = apyFor(poolId) + + return if (apy != null) { + NominationPool.Status.Active(apy) + } else { + NominationPool.Status.Inactive + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/pools/recommendation/NominationPoolRecommender.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/pools/recommendation/NominationPoolRecommender.kt new file mode 100644 index 0000000..cbee477 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/pools/recommendation/NominationPoolRecommender.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.recommendation + +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool + +interface NominationPoolRecommender { + + val recommendations: List + + fun recommendedPool(): NominationPool? +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/pools/recommendation/NominationPoolRecommenderFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/pools/recommendation/NominationPoolRecommenderFactory.kt new file mode 100644 index 0000000..51d6adc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/pools/recommendation/NominationPoolRecommenderFactory.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.recommendation + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.isOpen +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.KnownNovaPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.getPoolComparator +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.isActive +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.NominationPoolProvider +import kotlinx.coroutines.CoroutineScope + +class NominationPoolRecommenderFactory( + private val computationalCache: ComputationalCache, + private val nominationPoolProvider: NominationPoolProvider, + private val knownNovaPools: KnownNovaPools, + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, +) { + + suspend fun create(stakingOption: StakingOption, computationScope: CoroutineScope): NominationPoolRecommender { + val key = "NominationPoolRecommender" + + return computationalCache.useCache(key, computationScope) { + val nominationPools = nominationPoolProvider.getNominationPools(stakingOption, computationScope) + val maxPoolMembersPerPool = nominationPoolGlobalsRepository.maxPoolMembersPerPool(stakingOption.chain.id) + + RealNominationPoolRecommender( + allNominationPools = nominationPools, + maxPoolMembersPerPool = maxPoolMembersPerPool, + poolComparator = getPoolComparator(knownNovaPools, stakingOption.chain) + ) + } + } +} + +private class RealNominationPoolRecommender( + private val allNominationPools: List, + private val maxPoolMembersPerPool: Int?, + private val poolComparator: Comparator +) : NominationPoolRecommender { + + override val recommendations = constructRecommendationList() + + override fun recommendedPool(): NominationPool? { + return recommendations.firstOrNull() + } + + private fun constructRecommendationList(): List { + return allNominationPools + .filter { it.status.isActive && it.canBeJoined() } + // weaken filter conditions if no matching pools were found + .ifEmpty { allNominationPools.filter { it.canBeJoined() } } + .sortedWith(poolComparator) + } + + private fun NominationPool.canBeJoined(): Boolean { + return state.isOpen && hasFreeSpots() + } + + private fun NominationPool.hasFreeSpots(): Boolean { + return maxPoolMembersPerPool == null || membersCount < maxPoolMembersPerPool + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/NominationPoolsRedeemInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/NominationPoolsRedeemInteractor.kt new file mode 100644 index 0000000..478e121 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/NominationPoolsRedeemInteractor.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.bondedAccountOf +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.numberOfSlashingSpans +import io.novafoundation.nova.feature_staking_api.domain.model.totalRedeemableIn +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.withdrawUnbonded +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.totalPointsAfterRedeemAt +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.unlockChunksFor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.DelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemConsequences +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.withContext + +interface NominationPoolsRedeemInteractor { + + fun redeemAmountFlow(poolMember: PoolMember, computationScope: CoroutineScope): Flow + + suspend fun estimateFee(poolMember: PoolMember): Fee + + suspend fun redeem(poolMember: PoolMember): Result> +} + +class RealNominationPoolsRedeemInteractor( + private val extrinsicService: ExtrinsicService, + private val stakingRepository: StakingRepository, + private val poolAccountDerivation: PoolAccountDerivation, + private val stakingSharedState: StakingSharedState, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val stakingSharedComputation: StakingSharedComputation, + private val migrationUseCase: DelegatedStakeMigrationUseCase +) : NominationPoolsRedeemInteractor { + + override fun redeemAmountFlow(poolMember: PoolMember, computationScope: CoroutineScope): Flow { + return flowOfAll { + val chainId = stakingSharedState.chainId() + + combine( + nominationPoolSharedComputation.unbondingPoolsFlow(poolMember.poolId, chainId, computationScope), + stakingSharedComputation.activeEraFlow(chainId, computationScope) + ) { unbondingPools, activeEra -> + unbondingPools.unlockChunksFor(poolMember).totalRedeemableIn(activeEra) + } + } + } + + override suspend fun estimateFee(poolMember: PoolMember): Fee { + return withContext(Dispatchers.IO) { + extrinsicService.estimateFee(stakingSharedState.chain(), TransactionOrigin.SelectedWallet) { + redeem(poolMember) + } + } + } + + override suspend fun redeem(poolMember: PoolMember): Result> { + return withContext(Dispatchers.IO) { + val chain = stakingSharedState.chain() + val activeEra = stakingRepository.getActiveEraIndex(chain.id) + + extrinsicService.submitExtrinsic(chain, TransactionOrigin.SelectedWallet) { + redeem(poolMember) + }.map { + val totalAfterRedeem = poolMember.totalPointsAfterRedeemAt(activeEra) + + it to RedeemConsequences(willKillStash = totalAfterRedeem.value.isZero) + } + } + } + + private suspend fun ExtrinsicBuilder.redeem(poolMember: PoolMember) { + migrationUseCase.migrateToDelegatedStakeIfNeeded() + + val chainId = stakingSharedState.chainId() + val poolStash = poolAccountDerivation.bondedAccountOf(poolMember.poolId, chainId) + val slashingSpans = stakingRepository.getSlashingSpan(chainId, poolStash).numberOfSlashingSpans() + + nominationPools.withdrawUnbonded(poolMember.accountId, slashingSpans) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/Declarations.kt new file mode 100644 index 0000000..cc01fe3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/Declarations.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.validate +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +typealias NominationPoolsRedeemValidationSystem = ValidationSystem +typealias NominationPoolsRedeemValidationSystemBuilder = ValidationSystemBuilder + +fun ValidationSystem.Companion.nominationPoolsRedeem( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory +): NominationPoolsRedeemValidationSystem = ValidationSystem { + noStakingTypesConflict(stakingTypesConflictValidationFactory) + + enoughToPayFees() + + sufficientCommissionBalanceToStayAboveED(enoughTotalToStayAboveEDValidationFactory) +} + +private fun NominationPoolsRedeemValidationSystemBuilder.noStakingTypesConflict(factory: StakingTypesConflictValidationFactory) { + factory.noStakingTypesConflict( + accountId = { it.poolMember.accountId }, + chainId = { it.asset.token.configuration.chainId }, + error = { NominationPoolsRedeemValidationFailure.StakingTypesConflict } + ) +} + +private fun NominationPoolsRedeemValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + NominationPoolsRedeemValidationFailure.NotEnoughBalanceToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) +} + +private fun NominationPoolsRedeemValidationSystemBuilder.sufficientCommissionBalanceToStayAboveED( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) { + enoughTotalToStayAboveEDValidationFactory.validate( + fee = { it.fee }, + balance = { it.asset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.chain, it.chain.utilityAsset) }, + error = { payload, error -> NominationPoolsRedeemValidationFailure.ToStayAboveED(payload.chain.utilityAsset, error) } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/NominationPoolsRedeemValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/NominationPoolsRedeemValidationFailure.kt new file mode 100644 index 0000000..9fa103c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/NominationPoolsRedeemValidationFailure.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class NominationPoolsRedeemValidationFailure { + + class NotEnoughBalanceToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : NominationPoolsRedeemValidationFailure(), NotEnoughToPayFeesError + + class ToStayAboveED(override val asset: Chain.Asset, override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel) : + NominationPoolsRedeemValidationFailure(), InsufficientBalanceToStayAboveEDError + + object StakingTypesConflict : NominationPoolsRedeemValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/NominationPoolsRedeemValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/NominationPoolsRedeemValidationPayload.kt new file mode 100644 index 0000000..075e2a7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/NominationPoolsRedeemValidationPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations + +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class NominationPoolsRedeemValidationPayload( + val fee: Fee, + val asset: Asset, + val chain: Chain, + val poolMember: PoolMember, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/ValidationFailureUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/ValidationFailureUi.kt new file mode 100644 index 0000000..e72b22f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/redeem/validations/ValidationFailureUi.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.handlePoolStakingTypesConflictValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations.NominationPoolsRedeemValidationFailure.NotEnoughBalanceToPayFees +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun nominationPoolsRedeemValidationFailure( + failure: NominationPoolsRedeemValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage { + return when (failure) { + is NotEnoughBalanceToPayFees -> handleNotEnoughFeeError(failure, resourceManager) + is NominationPoolsRedeemValidationFailure.ToStayAboveED -> handleInsufficientBalanceCommission( + failure, + resourceManager + ) + + NominationPoolsRedeemValidationFailure.StakingTypesConflict -> handlePoolStakingTypesConflictValidationFailure(resourceManager) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/selecting/PoolAvailabilityValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/selecting/PoolAvailabilityValidationSystem.kt new file mode 100644 index 0000000..0fc9909 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/selecting/PoolAvailabilityValidationSystem.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +typealias PoolAvailabilityValidationSystem = ValidationSystem + +class PoolAvailabilityPayload(val nominationPool: NominationPool, val chain: Chain) + +sealed interface PoolAvailabilityFailure { + + object PoolIsFull : PoolAvailabilityFailure + + object PoolIsClosed : PoolAvailabilityFailure +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/selecting/SearchNominationPoolInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/selecting/SearchNominationPoolInteractor.kt new file mode 100644 index 0000000..0a6e91d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/selecting/SearchNominationPoolInteractor.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting + +import io.novafoundation.nova.common.utils.orFalse +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.pool.KnownNovaPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.getPoolComparator +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.address +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.name +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.NominationPoolProvider +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.recommendation.NominationPoolRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.poolAvailable +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class SearchNominationPoolInteractor( + private val nominationPoolProvider: NominationPoolProvider, + private val knownNovaPools: KnownNovaPools, + private val nominationPoolRecommenderFactory: NominationPoolRecommenderFactory, + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository +) { + + suspend fun getSortedNominationPools(stakingOption: StakingOption, coroutineScope: CoroutineScope): List { + return nominationPoolRecommenderFactory.create(stakingOption, coroutineScope) + .recommendations + } + + suspend fun searchNominationPools( + queryFlow: Flow, + stakingOption: StakingOption, + coroutineScope: CoroutineScope + ): Flow> { + val nominationPools = nominationPoolProvider.getNominationPools(stakingOption, coroutineScope) + val comparator = getPoolComparator(knownNovaPools, stakingOption.chain) + + return queryFlow.map { query -> + if (query.isEmpty()) { + return@map emptyList() + } + + nominationPools.filter { + val name = it.name()?.lowercase() + val address = it.address(stakingOption.chain) + name?.contains(query).orFalse() || address.startsWith(query) || it.hasId(query) + } + .sortedWith(comparator) + } + } + + private fun NominationPool.hasId(query: String): Boolean { + return id.value.toString() == query + } + + fun getValidationSystem(): PoolAvailabilityValidationSystem { + return ValidationSystem { + poolAvailable(nominationPoolGlobalsRepository) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/NominationPoolsUnbondInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/NominationPoolsUnbondInteractor.kt new file mode 100644 index 0000000..d2e7484 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/NominationPoolsUnbondInteractor.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.bondedAccountOf +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.nominationPools +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.unbond +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.DelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.getParticipatingBondedPoolState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.participatingBondedPoolStateFlow +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.pointsOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +interface NominationPoolsUnbondInteractor { + + fun poolMemberStateFlow(computationScope: CoroutineScope): Flow + + suspend fun estimateFee(poolMember: PoolMember, amount: Balance): Fee + + suspend fun unbond(poolMember: PoolMember, amount: Balance): Result +} + +class RealNominationPoolsUnbondInteractor( + private val extrinsicService: ExtrinsicService, + private val stakingSharedState: StakingSharedState, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val poolAccountDerivation: PoolAccountDerivation, + private val poolMemberUseCase: NominationPoolMemberUseCase, + private val migrationUseCase: DelegatedStakeMigrationUseCase +) : NominationPoolsUnbondInteractor { + + override fun poolMemberStateFlow(computationScope: CoroutineScope): Flow { + return poolMemberUseCase.currentPoolMemberFlow() + .filterNotNull() + .flatMapLatest { poolMember -> + val poolId = poolMember.poolId + val chainId = stakingSharedState.chainId() + val stash = poolAccountDerivation.bondedAccountOf(poolId, chainId) + + nominationPoolSharedComputation.participatingBondedPoolStateFlow(stash, poolId, chainId, computationScope).map { bondedPoolState -> + PoolMemberState(bondedPoolState, poolMember) + } + } + } + + override suspend fun estimateFee(poolMember: PoolMember, amount: Balance): Fee { + return withContext(Dispatchers.IO) { + val chain = stakingSharedState.chain() + + extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + unbond(poolMember, amount, chain.id) + } + } + } + + override suspend fun unbond(poolMember: PoolMember, amount: Balance): Result { + return withContext(Dispatchers.IO) { + val chain = stakingSharedState.chain() + + extrinsicService.submitExtrinsic(stakingSharedState.chain(), TransactionOrigin.SelectedWallet) { + unbond(poolMember, amount, chain.id) + } + } + } + + private suspend fun ExtrinsicBuilder.unbond(poolMember: PoolMember, amount: Balance, chainId: ChainId) { + migrationUseCase.migrateToDelegatedStakeIfNeeded() + + val poolAccount = poolAccountDerivation.bondedAccountOf(poolMember.poolId, chainId) + + val bondedPoolState = nominationPoolSharedComputation.getParticipatingBondedPoolState(poolAccount, poolMember.poolId, chainId) + val unbondPoints = bondedPoolState.pointsOf(amount).coerceAtMost(poolMember.points) + + nominationPools.unbond(poolMember.accountId, unbondPoints) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/PoolMemberState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/PoolMemberState.kt new file mode 100644 index 0000000..494048d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/PoolMemberState.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond + +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.BondedPoolState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.amountOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class PoolMemberState( + val bondedPoolState: BondedPoolState, + val poolMember: PoolMember, +) + +val PoolMemberState.stakedBalance: Balance + get() = bondedPoolState.amountOf(poolMember.points) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/Declarations.kt new file mode 100644 index 0000000..9c3cfb9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/Declarations.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.validate +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +typealias NominationPoolsUnbondValidationSystem = ValidationSystem +typealias NominationPoolsUnbondValidationSystemBuilder = + ValidationSystemBuilder + +typealias NominationPoolsUnbondValidation = Validation + +fun ValidationSystem.Companion.nominationPoolsUnbond( + unbondValidationFactory: NominationPoolsUnbondValidationFactory, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory, +): NominationPoolsUnbondValidationSystem = ValidationSystem { + noStakingTypesConflict(stakingTypesConflictValidationFactory) + + unbondValidationFactory.poolCanUnbond() + + unbondValidationFactory.poolMemberCanUnbond() + + enoughToUnbond() + + enoughToPayFees() + + sufficientCommissionBalanceToStayAboveED(enoughTotalToStayAboveEDValidationFactory) + + positiveUnbond() + + unbondValidationFactory.partialUnbondLeavesMinBond() +} + +private fun NominationPoolsUnbondValidationSystemBuilder.noStakingTypesConflict(factory: StakingTypesConflictValidationFactory) { + factory.noStakingTypesConflict( + accountId = { it.poolMember.accountId }, + chainId = { it.asset.token.configuration.chainId }, + error = { NominationPoolsUnbondValidationFailure.StakingTypesConflict } + ) +} + +private fun NominationPoolsUnbondValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + NominationPoolsUnbondValidationFailure.NotEnoughBalanceToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) +} + +private fun NominationPoolsUnbondValidationSystemBuilder.enoughToUnbond() { + sufficientBalance( + available = { it.stakedBalance }, + amount = { it.amount }, + error = { NominationPoolsUnbondValidationFailure.NotEnoughToUnbond } + ) +} + +private fun NominationPoolsUnbondValidationSystemBuilder.positiveUnbond() { + positiveAmount( + amount = { it.amount }, + error = { NominationPoolsUnbondValidationFailure.NotPositiveAmount } + ) +} + +private fun NominationPoolsUnbondValidationSystemBuilder.sufficientCommissionBalanceToStayAboveED( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) { + enoughTotalToStayAboveEDValidationFactory.validate( + fee = { it.fee }, + balance = { it.asset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.chain, it.chain.utilityAsset) }, + error = { payload, error -> NominationPoolsUnbondValidationFailure.ToStayAboveED(payload.chain.utilityAsset, error) } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/NominationPoolsUnbondValidationFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/NominationPoolsUnbondValidationFactory.kt new file mode 100644 index 0000000..34d89cf --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/NominationPoolsUnbondValidationFactory.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations + +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.PartialUnbondLeavesLessThanMinBond +import io.novafoundation.nova.feature_wallet_api.domain.validation.CrossMinimumBalanceValidation + +class NominationPoolsUnbondValidationFactory( + private val stakingConstantsRepository: StakingConstantsRepository, + private val stakingRepository: StakingRepository, + private val stakingSharedComputation: StakingSharedComputation, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val stakingSharedState: StakingSharedState, + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, +) { + + context(NominationPoolsUnbondValidationSystemBuilder) + fun poolCanUnbond() { + validate( + PoolUnlockChunksLimitValidation( + stakingConstantsRepository = stakingConstantsRepository, + stakingSharedComputation = stakingSharedComputation, + nominationPoolSharedComputation = nominationPoolSharedComputation, + stakingSharedState = stakingSharedState + ) + ) + } + + context(NominationPoolsUnbondValidationSystemBuilder) + fun poolMemberCanUnbond() { + validate( + PoolMemberUnlockChunksLimitValidation( + stakingConstantsRepository = stakingConstantsRepository, + stakingRepository = stakingRepository, + nominationPoolGlobalsRepository = nominationPoolGlobalsRepository, + stakingSharedState = stakingSharedState + ) + ) + } + + context(NominationPoolsUnbondValidationSystemBuilder) + fun partialUnbondLeavesMinBond() { + validate( + CrossMinimumBalanceValidation( + minimumBalance = { nominationPoolGlobalsRepository.minJoinBond(it.asset.token.configuration.chainId) }, + chainAsset = { it.asset.token.configuration }, + currentBalance = { it.stakedBalance }, + deductingAmount = { it.amount }, + error = ::PartialUnbondLeavesLessThanMinBond + ) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/NominationPoolsUnbondValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/NominationPoolsUnbondValidationFailure.kt new file mode 100644 index 0000000..f0dae1f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/NominationPoolsUnbondValidationFailure.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations + +import io.novafoundation.nova.feature_wallet_api.domain.validation.CrossMinimumBalanceValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.CrossMinimumBalanceValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import kotlin.time.Duration + +sealed class NominationPoolsUnbondValidationFailure { + + class NotEnoughBalanceToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : NominationPoolsUnbondValidationFailure(), NotEnoughToPayFeesError + + object NotEnoughToUnbond : NominationPoolsUnbondValidationFailure() + + object NotPositiveAmount : NominationPoolsUnbondValidationFailure() + + class PartialUnbondLeavesLessThanMinBond(override val errorContext: CrossMinimumBalanceValidation.ErrorContext) : + NominationPoolsUnbondValidationFailure(), + CrossMinimumBalanceValidationFailure + + class PoolUnlockChunksLimitReached(val timeTillNextAvailableSlot: Duration) : NominationPoolsUnbondValidationFailure() + + class PoolMemberMaxUnlockingLimitReached(val limit: Int) : NominationPoolsUnbondValidationFailure() + + class ToStayAboveED(override val asset: Chain.Asset, override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel) : + NominationPoolsUnbondValidationFailure(), InsufficientBalanceToStayAboveEDError + + object StakingTypesConflict : NominationPoolsUnbondValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/NominationPoolsUnbondValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/NominationPoolsUnbondValidationPayload.kt new file mode 100644 index 0000000..21b890e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/NominationPoolsUnbondValidationPayload.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations + +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import java.math.BigDecimal + +data class NominationPoolsUnbondValidationPayload( + val poolMember: PoolMember, + val stakedBalance: BigDecimal, + val amount: BigDecimal, + val fee: Fee, + val asset: Asset, + val chain: Chain, + val sharedComputationScope: CoroutineScope, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/PoolUnlockChunksLimitValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/PoolUnlockChunksLimitValidation.kt new file mode 100644 index 0000000..ece7d3e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/PoolUnlockChunksLimitValidation.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.isRedeemableIn +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.calculateDurationTill +import io.novafoundation.nova.feature_staking_impl.domain.common.eraTimeCalculator +import io.novafoundation.nova.feature_staking_impl.domain.common.getActiveEra +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.PoolMemberMaxUnlockingLimitReached +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.PoolUnlockChunksLimitReached +import io.novafoundation.nova.runtime.state.selectedOption + +class PoolUnlockChunksLimitValidation( + private val stakingConstantsRepository: StakingConstantsRepository, + private val stakingSharedComputation: StakingSharedComputation, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val stakingSharedState: StakingSharedState, +) : NominationPoolsUnbondValidation { + + override suspend fun validate(value: NominationPoolsUnbondValidationPayload): ValidationStatus { + val stakingOption = stakingSharedState.selectedOption() + val chainId = stakingOption.assetWithChain.chain.id + val poolId = value.poolMember.poolId + val sharedComputationScope = value.sharedComputationScope + + val activeEra = stakingSharedComputation.getActiveEra(chainId, sharedComputationScope) + + val maxUnlockingChunks = stakingConstantsRepository.maxUnlockingChunks(chainId).toInt() + + // poolMember cannot exist without a pool so it is safe to apply non-null assertion here + val bondedPoolLedger = nominationPoolSharedComputation.participatingBondedPoolLedger(poolId, chainId, sharedComputationScope)!! + val poolUnlockChunks = bondedPoolLedger.unlocking + + val unlockListHasFreePlaces = poolUnlockChunks.size < maxUnlockingChunks + val canRedeem = poolUnlockChunks.any { it.isRedeemableIn(activeEra) } + + val canAddNewUnlockChunk = unlockListHasFreePlaces || canRedeem + + return canAddNewUnlockChunk isTrueOrError { + val eraTimeCalculator = stakingSharedComputation.eraTimeCalculator(stakingOption, sharedComputationScope) + + val nearestUnlockingEra = poolUnlockChunks.minOf { it.redeemEra } + val estimatedAllowanceTime = eraTimeCalculator.calculateDurationTill(nearestUnlockingEra) + + PoolUnlockChunksLimitReached(estimatedAllowanceTime) + } + } +} + +class PoolMemberUnlockChunksLimitValidation( + private val stakingConstantsRepository: StakingConstantsRepository, + private val stakingRepository: StakingRepository, + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, + private val stakingSharedState: StakingSharedState, +) : NominationPoolsUnbondValidation { + + override suspend fun validate(value: NominationPoolsUnbondValidationPayload): ValidationStatus { + val stakingOption = stakingSharedState.selectedOption() + val chainId = stakingOption.assetWithChain.chain.id + + val bondingDuration = stakingConstantsRepository.lockupPeriodInEras(chainId) + val currentEra = stakingRepository.getCurrentEraIndex(chainId) + + val unbondEra = currentEra + bondingDuration + + val maxUnlockingChunks = nominationPoolGlobalsRepository.maxUnlockChunks(chainId).toInt() + + val poolMemberUnlockChunks = value.poolMember.unbondingEras.keys + + val unlockListHasFreePlaces = poolMemberUnlockChunks.size < maxUnlockingChunks + val targetUnbondEraPresentInUnlockingList = unbondEra in poolMemberUnlockChunks + + val canAddNewUnlockChunk = unlockListHasFreePlaces || targetUnbondEraPresentInUnlockingList + + return canAddNewUnlockChunk isTrueOrError { + PoolMemberMaxUnlockingLimitReached(maxUnlockingChunks) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/ValidationFailureUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/ValidationFailureUi.kt new file mode 100644 index 0000000..5dbccfc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/nominationPools/unbond/validations/ValidationFailureUi.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.asDefault +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.handlePoolStakingTypesConflictValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.NotEnoughBalanceToPayFees +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.NotEnoughToUnbond +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.NotPositiveAmount +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.PartialUnbondLeavesLessThanMinBond +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.PoolMemberMaxUnlockingLimitReached +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.PoolUnlockChunksLimitReached +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.StakingTypesConflict +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFailure.ToStayAboveED +import io.novafoundation.nova.feature_wallet_api.domain.validation.amountIsTooBig +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleWith +import io.novafoundation.nova.feature_wallet_api.domain.validation.zeroAmount +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun nominationPoolsUnbondValidationFailure( + status: ValidationStatus.NotValid, + flowActions: ValidationFlowActions, + resourceManager: ResourceManager +): TransformedFailure { + return when (val failure = status.reason) { + is NotEnoughBalanceToPayFees -> handleNotEnoughFeeError(failure, resourceManager).asDefault() + + NotEnoughToUnbond -> resourceManager.amountIsTooBig().asDefault() + + NotPositiveAmount -> resourceManager.zeroAmount().asDefault() + + is PartialUnbondLeavesLessThanMinBond -> failure.handleWith(resourceManager, flowActions) { oldPayload, newAmount -> + oldPayload.copy(amount = newAmount) + } + + is PoolMemberMaxUnlockingLimitReached -> + TransformedFailure.Default( + resourceManager.getString(R.string.staking_unbonding_limit_reached_title) to + resourceManager.getString(R.string.staking_unbonding_limit_reached_message, failure.limit) + ) + + is PoolUnlockChunksLimitReached -> { + val durationFormatted = resourceManager.formatDuration(failure.timeTillNextAvailableSlot) + + TransformedFailure.Default( + resourceManager.getString(R.string.nomination_pools_pool_reached_unbondings_limit_title) to + resourceManager.getString(R.string.nomination_pools_pool_reached_unbondings_limit_message, durationFormatted) + ) + } + + is ToStayAboveED -> handleInsufficientBalanceCommission( + failure, + resourceManager + ).asDefault() + + StakingTypesConflict -> handlePoolStakingTypesConflictValidationFailure(resourceManager).asDefault() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/collator/current/CurrentCollatorInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/collator/current/CurrentCollatorInteractor.kt new file mode 100644 index 0000000..f14af6a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/collator/current/CurrentCollatorInteractor.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.current + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegatedCollatorIdsHex +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorProvider.CollatorSource +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegationState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.delegationStatesFor +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapLatest + +interface CurrentCollatorInteractor { + + fun currentCollatorsFlow(delegatorState: DelegatorState.Delegator): Flow> +} + +class RealCurrentCollatorInteractor( + private val currentRoundRepository: CurrentRoundRepository, + private val collatorProvider: CollatorProvider, + private val stakingSharedState: StakingSharedState, +) : CurrentCollatorInteractor { + + override fun currentCollatorsFlow(delegatorState: DelegatorState.Delegator): Flow> = flow { + val chainId = delegatorState.chain.id + val stakingOption = stakingSharedState.selectedOption() + + val innerFlow = currentRoundRepository.currentRoundInfoFlow(chainId).mapLatest { currentRoundInfo -> + val snapshots = currentRoundRepository.collatorsSnapshot(chainId, currentRoundInfo.current) + + val delegationAccountIds = delegatorState.delegatedCollatorIdsHex() + val collatorsById = collatorProvider.getCollators(stakingOption, CollatorSource.Custom(delegationAccountIds), snapshots) + .associateBy { it.accountIdHex } + + val delegationStates = delegatorState.delegationStatesFor(collatorsById) + + val delegatedCollators = delegatorState.delegations.map { delegation -> + val delegationState = delegationStates.getValue(delegation) + + DelegatedCollator( + collator = collatorsById.getValue(delegation.owner.toHexString()), + delegation = delegation.balance, + delegationStatus = delegationState + ) + } + + groupDelegatedCollators(delegatedCollators) + } + + emitAll(innerFlow) + } + + private fun groupDelegatedCollators(delegatedCollators: List): Map> { + val delegatorsByStatus = delegatedCollators.groupBy { it.delegationStatus } + + val electedCollatorsCount = delegatorsByStatus.sizeOf(DelegationState.ACTIVE) + delegatorsByStatus.sizeOf(DelegationState.TOO_LOW_STAKE) + val activeGroup = DelegatedCollatorGroup.Active(electedCollatorsCount) + + return delegatorsByStatus.mapKeys { (status, groupCollators) -> + val groupSize = groupCollators.size + + when (status) { + DelegationState.ACTIVE -> activeGroup + DelegationState.TOO_LOW_STAKE -> DelegatedCollatorGroup.Elected(groupSize) + DelegationState.COLLATOR_NOT_ACTIVE -> DelegatedCollatorGroup.Inactive(groupSize) + DelegationState.WAITING -> DelegatedCollatorGroup.WaitingForNextEra(groupSize) + } + }.toSortedMap(DelegatedCollatorGroup.COMPARATOR) + } + + private fun Map>.sizeOf(key: K) = get(key).orEmpty().size +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/collator/current/DelegatedCollator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/collator/current/DelegatedCollator.kt new file mode 100644 index 0000000..52afa33 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/collator/current/DelegatedCollator.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.current + +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegationState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import java.math.BigInteger + +class DelegatedCollator( + val collator: Collator, + val delegation: BigInteger, + val delegationStatus: DelegationState +) + +sealed class DelegatedCollatorGroup(val numberOfCollators: Int, val position: Int) { + companion object { + val COMPARATOR = Comparator.comparingInt { it.position } + } + + class Active(numberOfCollators: Int) : DelegatedCollatorGroup(numberOfCollators, 0) + class Elected(numberOfCollators: Int) : DelegatedCollatorGroup(numberOfCollators, 1) + class Inactive(numberOfCollators: Int) : DelegatedCollatorGroup(numberOfCollators, 2) + class WaitingForNextEra(numberOfCollators: Int) : DelegatedCollatorGroup(numberOfCollators, 3) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/collator/search/SearchCollatorsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/collator/search/SearchCollatorsInteractor.kt new file mode 100644 index 0000000..b4abd28 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/collator/search/SearchCollatorsInteractor.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.search + +import android.annotation.SuppressLint +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator + +class SearchCollatorsInteractor { + + @SuppressLint("DefaultLocale") + fun searchValidator(query: String, localValidators: Collection): List { + val queryLower = query.lowercase() + + return localValidators.filter { + val foundInIdentity = it.identity?.display?.lowercase()?.contains(queryLower) ?: false + val foundInAddress = it.address.lowercase().startsWith(queryLower) + + foundInIdentity || foundInAddress + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/CollatorProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/CollatorProvider.kt new file mode 100644 index 0000000..e108f00 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/CollatorProvider.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common + +import io.novafoundation.nova.common.address.get +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CollatorSnapshot +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.minimumStakeToGetRewards +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.collatorsSnapshotInCurrentRound +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.systemForcedMinStake +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorProvider.CollatorSource +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rewards.ParachainStakingRewardCalculatorFactory +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface CollatorProvider { + + sealed class CollatorSource { + + object Elected : CollatorSource() + + class Custom(val collatorIdsHex: Collection) : CollatorSource() + } + + suspend fun getCollators( + stakingOption: StakingOption, + collatorSource: CollatorSource, + cachedSnapshots: AccountIdMap? = null + ): List +} + +class RealCollatorProvider( + private val identityRepository: OnChainIdentityRepository, + private val currentRoundRepository: CurrentRoundRepository, + private val parachainStakingConstantsRepository: ParachainStakingConstantsRepository, + private val candidatesRepository: CandidatesRepository, + private val rewardCalculatorFactory: ParachainStakingRewardCalculatorFactory, + private val chainRegistry: ChainRegistry, +) : CollatorProvider { + + override suspend fun getCollators( + stakingOption: StakingOption, + collatorSource: CollatorSource, + cachedSnapshots: AccountIdMap? + ): List { + val chainAsset = stakingOption.assetWithChain.asset + val chainId = chainAsset.chainId + val chain = chainRegistry.getChain(chainAsset.chainId) + + val snapshots = cachedSnapshots ?: currentRoundRepository.collatorsSnapshotInCurrentRound(chainId) + + val requestedCollatorIdsHex = when (collatorSource) { + is CollatorSource.Custom -> collatorSource.collatorIdsHex.toSet() + CollatorSource.Elected -> snapshots.keys + } + val requestedCollatorIds = requestedCollatorIdsHex.map { it.fromHex() } + + val candidateMetadatas = candidatesRepository.getCandidatesMetadata(chainId, requestedCollatorIds) + val identities = identityRepository.getIdentitiesFromIds(requestedCollatorIds, chainId) + + val systemForcedMinimumStake = parachainStakingConstantsRepository.systemForcedMinStake(chainId) + val rewardCalculator = rewardCalculatorFactory.create(stakingOption, snapshots) + + return requestedCollatorIdsHex.map { accountIdHex -> + val collatorSnapshot = snapshots[accountIdHex] + val candidateMetadata = candidateMetadatas.getValue(accountIdHex) + val accountId = accountIdHex.fromHex() + + Collator( + accountIdHex = accountIdHex, + address = chain.addressOf(accountId), + identity = identities[accountId], + snapshot = collatorSnapshot, + minimumStakeToGetRewards = candidateMetadata.minimumStakeToGetRewards(systemForcedMinimumStake), + candidateMetadata = candidateMetadata, + apr = rewardCalculator.collatorApr(accountIdHex) + ) + } + } +} + +suspend fun CollatorProvider.getCollator(stakingOption: StakingOption, collatorId: AccountId): Collator = getCollators( + stakingOption = stakingOption, + collatorSource = CollatorSource.Custom(listOf(collatorId.toHexString())), +).first() diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/CollatorsUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/CollatorsUseCase.kt new file mode 100644 index 0000000..f53b048 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/CollatorsUseCase.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.systemForcedMinStake +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model.TargetWithStakedAmount +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.SelectedCollator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.collators.collatorAddressModel +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +enum class SelectedCollatorSorting { + DELEGATION, APR +} + +interface CollatorsUseCase { + + suspend fun collatorAddressModel(collator: Collator): AddressModel + + suspend fun getCollator(collatorId: AccountId): Collator + + suspend fun getSelectedCollators( + delegatorState: DelegatorState, + sorting: SelectedCollatorSorting = SelectedCollatorSorting.DELEGATION + ): List + + suspend fun maxRewardedDelegatorsPerCollator(): Int + + suspend fun defaultMinimumStake(): BigInteger +} + +class RealCollatorsUseCase( + private val stakingSharedState: StakingSharedState, + private val parachainStakingConstantsRepository: ParachainStakingConstantsRepository, + private val collatorProvider: CollatorProvider, + private val addressIconGenerator: AddressIconGenerator, +) : CollatorsUseCase { + + override suspend fun maxRewardedDelegatorsPerCollator(): Int { + val chainId = stakingSharedState.chainId() + + return parachainStakingConstantsRepository.maxRewardedDelegatorsPerCollator(chainId).toInt() + } + + override suspend fun defaultMinimumStake(): BigInteger { + return parachainStakingConstantsRepository.systemForcedMinStake(stakingSharedState.chainId()) + } + + override suspend fun collatorAddressModel(collator: Collator): AddressModel { + return addressIconGenerator.collatorAddressModel( + collator = collator, + chain = stakingSharedState.chain() + ) + } + + override suspend fun getCollator(collatorId: AccountId): Collator = withContext(Dispatchers.IO) { + collatorProvider.getCollator(stakingSharedState.selectedOption(), collatorId) + } + + override suspend fun getSelectedCollators( + delegatorState: DelegatorState, + sorting: SelectedCollatorSorting + ): List { + val stakingOption = stakingSharedState.selectedOption() + + return when (delegatorState) { + is DelegatorState.Delegator -> { + val delegationAmountByCollator = delegatorState.delegations.associateBy( + keySelector = { it.owner.toHexString() }, + valueTransform = { it.balance } + ) + val stakedCollatorsIds = delegationAmountByCollator.keys + + val collatorSource = CollatorProvider.CollatorSource.Custom(stakedCollatorsIds) + + collatorProvider.getCollators(stakingOption, collatorSource) + .map { collator -> + TargetWithStakedAmount( + target = collator, + stake = delegationAmountByCollator.getValue(collator.accountIdHex) + ) + } + .sortedWith(sorting.ascendingComparator().reversed()) + } + + is DelegatorState.None -> emptyList() + } + } + + private fun SelectedCollatorSorting.ascendingComparator() = when (this) { + SelectedCollatorSorting.DELEGATION -> compareBy { it.stake } + SelectedCollatorSorting.APR -> compareBy { it.target.apr } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/DelegationState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/DelegationState.kt new file mode 100644 index 0000000..705e72c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/DelegationState.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common + +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorBond +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CandidateMetadata +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CollatorSnapshot +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.isStakeEnoughToEarnRewards +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.isElected +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId + +enum class DelegationState { + COLLATOR_NOT_ACTIVE, TOO_LOW_STAKE, WAITING, ACTIVE +} + +fun DelegatorState.Delegator.delegationStatesFor( + delegatedCollators: AccountIdMap, +): Map { + return delegations.associateWith { delegation -> + val delegatedCollator = delegatedCollators.getValue(delegation.owner.toHexString()) + + when { + !delegatedCollator.isElected -> DelegationState.COLLATOR_NOT_ACTIVE + delegatedCollator.snapshot!!.hasDelegator(accountId) -> DelegationState.ACTIVE + !delegatedCollator.candidateMetadata.isStakeEnoughToEarnRewards(delegation.balance) -> DelegationState.TOO_LOW_STAKE + else -> DelegationState.WAITING + } + } +} + +fun DelegatorState.Delegator.delegationStatesIn( + snapshots: AccountIdMap, + candidateMetadatas: AccountIdMap, +): Map { + return delegations.associateWith { delegation -> + val collator = snapshots[delegation.owner.toHexString()] + val candidateMetadata = candidateMetadatas.getValue(delegation.owner.toHexString()) + + when { + collator == null -> DelegationState.COLLATOR_NOT_ACTIVE + collator.hasDelegator(accountId) -> DelegationState.ACTIVE + !candidateMetadata.isStakeEnoughToEarnRewards(delegation.balance) -> DelegationState.TOO_LOW_STAKE + else -> DelegationState.WAITING + } + } +} + +private fun CollatorSnapshot.hasDelegator(delegatorId: AccountId): Boolean { + return delegations.any { it.owner.contentEquals(delegatorId) } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/DelegatorStateUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/DelegatorStateUseCase.kt new file mode 100644 index 0000000..01ce7ef --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/DelegatorStateUseCase.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chainAndAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +class DelegatorStateUseCase( + private val delegatorStateRepository: DelegatorStateRepository, + private val singleAssetSharedState: AnySelectedAssetOptionSharedState, + private val accountRepository: AccountRepository, +) { + + fun delegatorStateFlow( + account: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset + ): Flow { + return flow { + val accountId = account.accountIdIn(chain) + + if (accountId != null) { + emitAll(delegatorStateRepository.observeDelegatorState(chain, chainAsset, accountId)) + } else { + emit(DelegatorState.None(chain, chainAsset)) + } + } + } + + fun currentDelegatorStateFlow() = accountRepository.selectedMetaAccountFlow().flatMapLatest { + val (chain, asset) = singleAssetSharedState.chainAndAsset() + + delegatorStateFlow(it, chain, asset) + } + + suspend fun currentDelegatorState(): DelegatorState = withContext(Dispatchers.Default) { + val account = accountRepository.getSelectedMetaAccount() + val (chain, asset) = singleAssetSharedState.chainAndAsset() + + val accountId = account.accountIdIn(chain) + + if (accountId != null) { + delegatorStateRepository.getDelegationState(chain, asset, accountId) + } else { + DelegatorState.None(chain, asset) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/model/Collator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/model/Collator.kt new file mode 100644 index 0000000..2d45478 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/model/Collator.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.WithAccountId +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CandidateMetadata +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CollatorSnapshot +import io.novafoundation.nova.feature_staking_impl.domain.rewards.PeriodReturns +import io.novasama.substrate_sdk_android.extensions.fromHex +import java.math.BigDecimal +import java.math.BigInteger + +class Collator( + // TODO migrate to AccountIdKey + val accountIdHex: String, + val address: String, + val identity: OnChainIdentity?, + val snapshot: CollatorSnapshot?, + val candidateMetadata: CandidateMetadata, + val minimumStakeToGetRewards: BigInteger, + val apr: BigDecimal?, +) : Identifiable, WithAccountId { + + override val identifier: String = accountIdHex + + override val accountId: AccountIdKey = accountIdHex.fromHex().intoKey() +} + +val Collator.isElected + get() = snapshot != null + +fun CollatorSnapshot.minimumStake( + systemForcedMinStake: BigInteger, + maxRewardableDelegatorsPerCollator: BigInteger +): BigInteger { + if (delegations.size < maxRewardableDelegatorsPerCollator.toInt()) { + return systemForcedMinStake + } + + val minStakeToGetRewards = delegations.minOfOrNull { it.balance } ?: systemForcedMinStake + + return minStakeToGetRewards.coerceAtLeast(systemForcedMinStake) +} + +fun Collator.estimatedAprReturns(amount: BigDecimal): PeriodReturns { + return PeriodReturns( + gainAmount = amount * apr.orZero(), + gainFraction = apr.orZero(), + isCompound = false + ) +} + +fun Collator.accountId() = accountId.value diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/model/SelectedCollator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/model/SelectedCollator.kt new file mode 100644 index 0000000..12bbcfe --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/model/SelectedCollator.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model + +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model.TargetWithStakedAmount + +typealias SelectedCollator = TargetWithStakedAmount diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/recommendations/CollatorRecommendator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/recommendations/CollatorRecommendator.kt new file mode 100644 index 0000000..9d124d8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/recommendations/CollatorRecommendator.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.validators.ValidatorsPreferencesSource +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.recommendations.FilteringSingleSelectRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorProvider.CollatorSource +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator + +class CollatorRecommendatorFactory( + private val collatorProvider: CollatorProvider, + computationalCache: ComputationalCache, + validatorsPreferencesSource: ValidatorsPreferencesSource +) : FilteringSingleSelectRecommendatorFactory(computationalCache, validatorsPreferencesSource) { + + context(ComputationalScope) + override suspend fun getAllTargets(stakingOption: StakingOption): List { + return collatorProvider.getCollators(stakingOption, CollatorSource.Elected) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/recommendations/CollatorSorting.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/recommendations/CollatorSorting.kt new file mode 100644 index 0000000..62c5995 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/common/recommendations/CollatorSorting.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator + +enum class CollatorSorting(private val collatorComparator: Comparator) : Comparator by collatorComparator { + + REWARDS(compareByDescending { it.apr }), + MIN_STAKE(compareBy { it.minimumStakeToGetRewards }), + TOTAL_STAKE(compareByDescending { it.snapshot?.total.orZero() }), + OWN_STAKE(compareByDescending { it.snapshot?.bond.orZero() }) +} + +data class CollatorRecommendationConfig(val sorting: CollatorSorting) : Comparator by sorting { + + companion object { + + val DEFAULT = CollatorRecommendationConfig(CollatorSorting.REWARDS) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/ParachainNetworkInfoInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/ParachainNetworkInfoInteractor.kt new file mode 100644 index 0000000..634ebbc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/ParachainNetworkInfoInteractor.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main + +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CollatorSnapshot +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.systemForcedMinStake +import io.novafoundation.nova.feature_staking_impl.domain.model.NetworkInfo +import io.novafoundation.nova.feature_staking_impl.domain.model.StakingPeriod +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.minimumStake +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import java.math.BigInteger + +class ParachainRoundInfo( + val minimumStake: BigInteger, + val nominatorsCount: Int +) + +class ParachainNetworkInfoInteractor( + private val currentRoundRepository: CurrentRoundRepository, + private val parachainStakingConstantsRepository: ParachainStakingConstantsRepository, + private val roundDurationEstimator: RoundDurationEstimator, +) { + + fun observeNetworkInfo(chainId: ChainId): Flow = flow { + val realtimeChanges = combine( + currentRoundRepository.totalStakedFlow(chainId), + roundDurationEstimator.unstakeDurationFlow(chainId), + observeRoundInfo(chainId) + ) { totalStaked, lockupPeriodDuration, roundInfo -> + NetworkInfo( + lockupPeriod = lockupPeriodDuration, + minimumStake = roundInfo.minimumStake, + totalStake = totalStaked, + stakingPeriod = StakingPeriod.Unlimited, + nominatorsCount = roundInfo.nominatorsCount + ) + } + + emitAll(realtimeChanges) + } + + fun observeRoundInfo(chainId: ChainId): Flow { + return currentRoundRepository.currentRoundInfoFlow(chainId).flatMapLatest { + val currentCollatorSnapshot = currentRoundRepository.collatorsSnapshot(chainId, it.current) + + val systemForcedMinStake = parachainStakingConstantsRepository.systemForcedMinStake(chainId) + val maxRewardedDelegatorsPerCollator = parachainStakingConstantsRepository.maxRewardedDelegatorsPerCollator(chainId) + + val minimumStake = currentCollatorSnapshot.minimumStake(systemForcedMinStake, maxRewardedDelegatorsPerCollator) + val nominatorsCount = currentCollatorSnapshot.activeDelegatorsCount() + + combine( + currentRoundRepository.totalStakedFlow(chainId), + roundDurationEstimator.unstakeDurationFlow(chainId) + ) { totalStaked, lockupPeriodDuration -> + ParachainRoundInfo( + minimumStake = minimumStake, + nominatorsCount = nominatorsCount + ) + } + } + } + + private fun AccountIdMap.activeDelegatorsCount(): Int { + return values.flatMapTo(mutableSetOf()) { collatorSnapshot -> + collatorSnapshot.delegations.map { it.owner.toHexString() } + }.size + } + + private fun AccountIdMap.minimumStake( + systemForcedMinStake: Balance, + maxRewardedDelegatorsPerCollator: BigInteger + ): BigInteger { + val minStakeFromCollators = values.minOfOrNull { collatorSnapshot -> + collatorSnapshot.minimumStake(systemForcedMinStake, maxRewardedDelegatorsPerCollator) + } ?: systemForcedMinStake + + return minStakeFromCollators + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/alerts/ParachainStakingAlert.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/alerts/ParachainStakingAlert.kt new file mode 100644 index 0000000..0b5c87e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/alerts/ParachainStakingAlert.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.alerts + +import java.math.BigInteger + +sealed class ParachainStakingAlert { + + object ChangeCollator : ParachainStakingAlert() + + object StakeMore : ParachainStakingAlert() + + class RedeemTokens(val redeemableAmount: BigInteger) : ParachainStakingAlert() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/alerts/ParachainStakingAlertsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/alerts/ParachainStakingAlertsInteractor.kt new file mode 100644 index 0000000..74251bb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/alerts/ParachainStakingAlertsInteractor.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.alerts + +import io.novafoundation.nova.common.utils.anyIs +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.ScheduledDelegationRequest +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegatedCollatorIds +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.redeemableIn +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CandidateMetadata +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CollatorSnapshot +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegationState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.delegationStatesIn +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +interface ParachainStakingAlertsInteractor { + + fun alertsFlow(delegatorState: DelegatorState.Delegator): Flow> +} + +private class AlertCalculationContext( + val snapshots: AccountIdMap, + val delegatedCollatorsMetadata: AccountIdMap, + val scheduledDelegationRequests: Collection, + val currentRound: BigInteger, + val delegatorState: DelegatorState.Delegator +) + +private typealias AlertProducer = (AlertCalculationContext) -> List + +class RealParachainStakingAlertsInteractor( + private val candidatesRepository: CandidatesRepository, + private val currentRoundRepository: CurrentRoundRepository, + private val delegatorStateRepository: DelegatorStateRepository, +) : ParachainStakingAlertsInteractor { + + override fun alertsFlow(delegatorState: DelegatorState.Delegator): Flow> { + return flow { + val chainId = delegatorState.chain.id + val candidateMetadatas = candidatesRepository.getCandidatesMetadata(chainId, delegatorState.delegatedCollatorIds()) + + val innerFlow = currentRoundRepository.currentRoundInfoFlow(chainId).flatMapLatest { currentRoundInfo -> + val currentRound = currentRoundInfo.current + val snapshots = currentRoundRepository.collatorsSnapshot(chainId, currentRound) + + delegatorStateRepository.scheduledDelegationRequestsFlow(delegatorState).map { scheduledDelegationRequests -> + val alertContext = AlertCalculationContext( + snapshots = snapshots, + delegatedCollatorsMetadata = candidateMetadatas, + scheduledDelegationRequests = scheduledDelegationRequests, + currentRound = currentRound, + delegatorState = delegatorState + ) + + alertProducers.flatMap { it.invoke(alertContext) } + } + } + + emitAll(innerFlow) + } + } + + private fun collatorsAlerts(context: AlertCalculationContext): List { + val delegationStates = context.delegatorState + .delegationStatesIn(context.snapshots, context.delegatedCollatorsMetadata) + .values + + return listOfNotNull( + ParachainStakingAlert.StakeMore.takeIf { delegationStates.anyIs(DelegationState.TOO_LOW_STAKE) }, + ParachainStakingAlert.ChangeCollator.takeIf { delegationStates.anyIs(DelegationState.COLLATOR_NOT_ACTIVE) } + ) + } + + private fun redeemAlert(context: AlertCalculationContext): List { + val redeemableRequests = context.scheduledDelegationRequests.filter { it.redeemableIn(context.currentRound) } + + return if (redeemableRequests.isNotEmpty()) { + val amount = redeemableRequests.sumByBigInteger { it.action.amount } + + listOf(ParachainStakingAlert.RedeemTokens(amount)) + } else { + emptyList() + } + } + + private val alertProducers: List = listOf( + ::collatorsAlerts, + ::redeemAlert + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/stakeSummary/DelegatorStatus.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/stakeSummary/DelegatorStatus.kt new file mode 100644 index 0000000..5462340 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/stakeSummary/DelegatorStatus.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.stakeSummary + +import kotlin.time.Duration + +sealed class DelegatorStatus { + + object Active : DelegatorStatus() + + class Waiting(val timeLeft: Duration) : DelegatorStatus() + + object Inactive : DelegatorStatus() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/stakeSummary/ParachainStakingStakeSummaryInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/stakeSummary/ParachainStakingStakeSummaryInteractor.kt new file mode 100644 index 0000000..f666692 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/stakeSummary/ParachainStakingStakeSummaryInteractor.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.stakeSummary + +import io.novafoundation.nova.common.utils.anyIs +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.activeBonded +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegatedCollatorIds +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegationState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.delegationStatesIn +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest +import java.math.BigInteger + +class ParachainStakingStakeSummaryInteractor( + private val currentRoundRepository: CurrentRoundRepository, + private val candidatesRepository: CandidatesRepository, + private val roundDurationEstimator: RoundDurationEstimator, +) { + + suspend fun delegatorStatusFlow(delegatorState: DelegatorState.Delegator): Flow { + val chainId = delegatorState.chain.id + + return currentRoundRepository.currentRoundInfoFlow(chainId).transformLatest { currentRoundInfo -> + val snapshots = currentRoundRepository.collatorsSnapshot(chainId, currentRoundInfo.current) + val delegatedIds = delegatorState.delegatedCollatorIds() + val candidateMetadatas = candidatesRepository.getCandidatesMetadata(chainId, delegatedIds) + + val delegationStates = delegatorState.delegationStatesIn(snapshots, candidateMetadatas).values + + when { + delegatorState.activeBonded.isZero -> emit(DelegatorStatus.Inactive) + delegationStates.anyIs(DelegationState.ACTIVE) -> emit(DelegatorStatus.Active) + delegationStates.anyIs(DelegationState.WAITING) -> { + val targetRound = currentRoundInfo.current + BigInteger.ONE + + val waitingStatusFlow = roundDurationEstimator.timeTillRoundFlow(chainId, targetRound) + .map(DelegatorStatus::Waiting) + + emitAll(waitingStatusFlow) + } + else -> emit(DelegatorStatus.Inactive) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/unbondings/ParachainStakingUnbondingsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/unbondings/ParachainStakingUnbondingsInteractor.kt new file mode 100644 index 0000000..a381535 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/unbondings/ParachainStakingUnbondingsInteractor.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.unbondings + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.ScheduledDelegationRequest +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.redeemableIn +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.unbondingIn +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.toTimerValue +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.Unbondings +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.from +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +class DelegationRequestWithCollatorInfo( + val request: ScheduledDelegationRequest, + val collatorIdentity: OnChainIdentity?, +) : Identifiable { + + override val identifier by lazy { request.collator.toHexString() } +} + +class ParachainStakingUnbondingsInteractor( + private val delegatorStateRepository: DelegatorStateRepository, + private val currentRoundRepository: CurrentRoundRepository, + private val roundDurationEstimator: RoundDurationEstimator, + private val identityRepository: OnChainIdentityRepository, +) { + + suspend fun pendingUnbondings(delegatorState: DelegatorState.Delegator): List = withContext(Dispatchers.Default) { + val requests = delegatorStateRepository.scheduledDelegationRequests(delegatorState) + val currentRound = currentRoundRepository.currentRoundInfo(delegatorState.chain.id).current + + val unbondingRequests = requests.values.filter { it.unbondingIn(currentRound) } + + val collatorIds = unbondingRequests.map { it.collator.toHexString() } + val identities = identityRepository.getIdentitiesFromIdsHex(delegatorState.chain.id, collatorIds) + + collatorIds.map { + DelegationRequestWithCollatorInfo( + request = requests.getValue(it), + collatorIdentity = identities[it] + ) + }.sortedBy { it.request.whenExecutable } + } + + fun unbondingsFlow(delegatorState: DelegatorState.Delegator): Flow = flow { + val chainId = delegatorState.chain.id + + val unbondingsFlow = combine( + delegatorStateRepository.scheduledDelegationRequestsFlow(delegatorState), + currentRoundRepository.currentRoundInfoFlow(chainId) + ) { scheduledRequests, currentRoundInfo -> + val currentRoundIndex = currentRoundInfo.current + val durationCalculator = roundDurationEstimator.createDurationCalculator(chainId) + + val unbondingsList = scheduledRequests + .sortedBy { it.whenExecutable } + .map { scheduledDelegationRequest -> + val status = if (scheduledDelegationRequest.redeemableIn(currentRoundIndex)) { + Unbonding.Status.Redeemable + } else { + val calculatedDuration = durationCalculator.timeTillRound(scheduledDelegationRequest.whenExecutable) + + Unbonding.Status.Unbonding(calculatedDuration.toTimerValue()) + } + + Unbonding( + id = scheduledDelegationRequest.uniqueId(), + amount = scheduledDelegationRequest.action.amount, + status = status + ) + } + + Unbondings.from(unbondingsList, rebondPossible = true) + } + + emitAll(unbondingsFlow) + } + + private fun ScheduledDelegationRequest.uniqueId() = "${collator.toHexString()}:${action.amount}:$whenExecutable" +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/userRewards/ParachainStakingUserRewardsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/userRewards/ParachainStakingUserRewardsInteractor.kt new file mode 100644 index 0000000..fbc638d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/main/userRewards/ParachainStakingUserRewardsInteractor.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.userRewards + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.fullId +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +class ParachainStakingUserRewardsInteractor( + private val stakingRewardsRepository: StakingRewardsRepository, +) { + + suspend fun syncRewards( + delegator: DelegatorState.Delegator, + stakingOption: StakingOption, + rewardPeriod: RewardPeriod + ): Result<*> = withContext(Dispatchers.Default) { + runCatching { + stakingRewardsRepository.sync(delegator.accountId, stakingOption, rewardPeriod) + }.onFailure { + Log.e(this@ParachainStakingUserRewardsInteractor.LOG_TAG, "Failed to sync rewards: $it") + } + } + + fun observeRewards( + delegator: DelegatorState.Delegator, + stakingOption: StakingOption, + ) = flow { + val rewardsFlow = stakingRewardsRepository.totalRewardFlow(delegator.accountId, stakingOption.fullId) + + emitAll(rewardsFlow) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/ParachainStakingRebondInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/ParachainStakingRebondInteractor.kt new file mode 100644 index 0000000..6e24491 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/ParachainStakingRebondInteractor.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.calls.cancelDelegationRequest +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +interface ParachainStakingRebondInteractor { + + suspend fun estimateFee(collatorId: AccountId): Fee + + suspend fun rebondAmount( + delegatorState: DelegatorState, + collatorId: AccountId + ): BigInteger + + suspend fun rebond(collatorId: AccountId): Result> +} + +class RealParachainStakingRebondInteractor( + private val extrinsicService: ExtrinsicService, + private val delegatorStateRepository: DelegatorStateRepository, + private val selectedAssetState: AnySelectedAssetOptionSharedState, +) : ParachainStakingRebondInteractor { + + override suspend fun estimateFee(collatorId: AccountId): Fee = withContext(Dispatchers.IO) { + extrinsicService.estimateFee(selectedAssetState.chain(), TransactionOrigin.SelectedWallet) { + cancelDelegationRequest(collatorId) + } + } + + override suspend fun rebondAmount(delegatorState: DelegatorState, collatorId: AccountId): BigInteger = withContext(Dispatchers.IO) { + when (delegatorState) { + is DelegatorState.Delegator -> { + val request = delegatorStateRepository.scheduledDelegationRequest(delegatorState, collatorId) + + request?.action?.amount.orZero() + } + is DelegatorState.None -> BigInteger.ZERO + } + } + + override suspend fun rebond(collatorId: AccountId) = withContext(Dispatchers.IO) { + extrinsicService.submitAndWatchExtrinsic(selectedAssetState.chain(), TransactionOrigin.SelectedWallet) { + cancelDelegationRequest(collatorId) + }.awaitInBlock() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/validations/ParachainStakingRebondValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/validations/ParachainStakingRebondValidationFailure.kt new file mode 100644 index 0000000..cfa18c2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/validations/ParachainStakingRebondValidationFailure.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.validations + +sealed class ParachainStakingRebondValidationFailure { + + object NotEnoughBalanceToPayFees : ParachainStakingRebondValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/validations/ParachainStakingRebondValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/validations/ParachainStakingRebondValidationPayload.kt new file mode 100644 index 0000000..13e569c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/validations/ParachainStakingRebondValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.validations + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee + +class ParachainStakingRebondValidationPayload( + val asset: Asset, + val fee: Fee, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/validations/ParachainStakingUnbondValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/validations/ParachainStakingUnbondValidationSystem.kt new file mode 100644 index 0000000..1450f8e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rebond/validations/ParachainStakingUnbondValidationSystem.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias ParachainStakingRebondValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.parachainStakingRebond(): ParachainStakingRebondValidationSystem = ValidationSystem { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { ParachainStakingRebondValidationFailure.NotEnoughBalanceToPayFees } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/ParachainStakingRedeemInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/ParachainStakingRedeemInteractor.kt new file mode 100644 index 0000000..c5624d9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/ParachainStakingRedeemInteractor.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem + +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.ScheduledDelegationRequest +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.activeBonded +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.redeemableIn +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.calls.executeDelegationRequest +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemConsequences +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +interface ParachainStakingRedeemInteractor { + + suspend fun estimateFee(delegatorState: DelegatorState): Fee + + suspend fun redeemableAmount(delegatorState: DelegatorState): BigInteger + + suspend fun redeem(delegatorState: DelegatorState): Result, RedeemConsequences>> +} + +class RealParachainStakingRedeemInteractor( + private val extrinsicService: ExtrinsicService, + private val currentRoundRepository: CurrentRoundRepository, + private val delegatorStateRepository: DelegatorStateRepository, +) : ParachainStakingRedeemInteractor { + + override suspend fun estimateFee(delegatorState: DelegatorState): Fee = withContext(Dispatchers.Default) { + extrinsicService.estimateFee(delegatorState.chain, TransactionOrigin.SelectedWallet) { + redeem(delegatorState) + } + } + + override suspend fun redeemableAmount(delegatorState: DelegatorState): BigInteger { + val redeemableUnbondings = getRedeemableUnbondings(delegatorState) + + return redeemableUnbondings.values.sumByBigInteger { it.action.amount } + } + + override suspend fun redeem(delegatorState: DelegatorState) = withContext(Dispatchers.Default) { + extrinsicService.submitAndWatchExtrinsic(delegatorState.chain, TransactionOrigin.SelectedWallet) { + redeem(delegatorState) + } + .awaitInBlock() + .map { + it to RedeemConsequences(willKillStash = delegatorState.activeBonded.isZero) + } + } + + private suspend fun ExtrinsicBuilder.redeem(delegatorState: DelegatorState) { + val redeemableUnbondings = getRedeemableUnbondings(delegatorState) + + redeemableUnbondings.forEach { (collatorIdHex, redeemableRequest) -> + executeDelegationRequest( + collatorId = collatorIdHex.fromHex(), + delegator = redeemableRequest.delegator + ) + } + } + + private suspend fun getRedeemableUnbondings(delegatorState: DelegatorState): AccountIdMap { + return when (delegatorState) { + is DelegatorState.Delegator -> { + val currentRound = currentRoundRepository.currentRoundInfo(delegatorState.chain.id).current + val scheduledRequests = delegatorStateRepository.scheduledDelegationRequests(delegatorState) + + scheduledRequests.filterValues { request -> request.redeemableIn(currentRound) } + } + + is DelegatorState.None -> emptyMap() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/validations/ParachainStakingRedeemValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/validations/ParachainStakingRedeemValidationFailure.kt new file mode 100644 index 0000000..a497006 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/validations/ParachainStakingRedeemValidationFailure.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.validations + +sealed class ParachainStakingRedeemValidationFailure { + + object NotEnoughBalanceToPayFees : ParachainStakingRedeemValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/validations/ParachainStakingRedeemValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/validations/ParachainStakingRedeemValidationPayload.kt new file mode 100644 index 0000000..8a8380c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/validations/ParachainStakingRedeemValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.validations + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee + +class ParachainStakingRedeemValidationPayload( + val asset: Asset, + val fee: Fee, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/validations/ParachainStakingUnbondValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/validations/ParachainStakingUnbondValidationSystem.kt new file mode 100644 index 0000000..4add7a9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/redeem/validations/ParachainStakingUnbondValidationSystem.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias ParachainStakingRedeemValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.parachainStakingRedeem(): ParachainStakingRedeemValidationSystem = ValidationSystem { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { ParachainStakingRedeemValidationFailure.NotEnoughBalanceToPayFees } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rewards/ParachainStakingRewardCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rewards/ParachainStakingRewardCalculator.kt new file mode 100644 index 0000000..8f0e170 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rewards/ParachainStakingRewardCalculator.kt @@ -0,0 +1,102 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rewards + +import io.novafoundation.nova.common.data.network.runtime.binding.Perbill +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.InflationDistributionConfig +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.InflationInfo +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.totalPercentAsFraction +import io.novafoundation.nova.feature_staking_impl.domain.rewards.PeriodReturns +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigDecimal +import java.math.BigInteger + +class ParachainStakingRewardTarget( + val totalStake: BigInteger, + val accountIdHex: String +) + +interface ParachainStakingRewardCalculator { + + fun maximumGain(days: Int): BigDecimal + + fun collatorApr(collatorIdHex: String): BigDecimal? + + fun calculateCollatorAnnualReturns(collatorId: AccountId, amount: BigDecimal): PeriodReturns + + fun calculateMaxAnnualReturns(amount: BigDecimal): PeriodReturns +} + +private const val DAYS_IN_YEAR = 365 + +class RealParachainStakingRewardCalculator( + private val inflationDistributionConfig: InflationDistributionConfig, + inflationInfo: InflationInfo, + totalIssuance: BigInteger, + totalStaked: BigInteger, + collators: List, + private val collatorCommission: Perbill +) : ParachainStakingRewardCalculator { + + private val stakedPortion = totalStaked.toDouble() / totalIssuance.toDouble() + + private val annualInflation = when { + totalStaked < inflationInfo.expect.min -> inflationInfo.annual.min + totalStaked > inflationInfo.expect.max -> inflationInfo.annual.max + else -> inflationInfo.annual.ideal + } + + private val annualReturn = annualInflation.toDouble() / stakedPortion + + private val averageStake = collators.map { it.totalStake.toDouble() }.average() + + private val aprByCollator = collators.associateBy( + keySelector = ParachainStakingRewardTarget::accountIdHex, + valueTransform = ::calculateCollatorApr + ) + + private val averageApr = calculatorApr(collatorStake = averageStake) + + private val maxApr = aprByCollator.values.maxOrNull() ?: 0.0 + + override fun maximumGain(days: Int): BigDecimal { + return (maxApr * days / DAYS_IN_YEAR).toBigDecimal() + } + + override fun collatorApr(collatorIdHex: String): BigDecimal? { + return aprByCollator[collatorIdHex]?.toBigDecimal() + } + + override fun calculateCollatorAnnualReturns(collatorId: AccountId, amount: BigDecimal): PeriodReturns { + val collatorApr = collatorApr(collatorId.toHexString()) ?: averageApr() + + return PeriodReturns( + gainAmount = amount * collatorApr, + gainFraction = collatorApr, + isCompound = false + ) + } + + override fun calculateMaxAnnualReturns(amount: BigDecimal): PeriodReturns { + val averageApr = maximumAnnualApr() + + return PeriodReturns( + gainAmount = amount * averageApr, + gainFraction = averageApr, + isCompound = false + ) + } + + private fun averageApr(): BigDecimal { + return averageApr.toBigDecimal() + } + + private fun calculateCollatorApr(collator: ParachainStakingRewardTarget): Double { + return calculatorApr(collator.totalStake.toDouble()) + } + + private fun calculatorApr(collatorStake: Double): Double { + return annualReturn * (1 - inflationDistributionConfig.totalPercentAsFraction() - collatorCommission.toDouble()) * (averageStake / collatorStake) + } +} + +fun ParachainStakingRewardCalculator.maximumAnnualApr() = maximumGain(DAYS_IN_YEAR) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rewards/ParachainStakingRewardCalculatorFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rewards/ParachainStakingRewardCalculatorFactory.kt new file mode 100644 index 0000000..2343e27 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/rewards/ParachainStakingRewardCalculatorFactory.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rewards + +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CollatorSnapshot +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.RewardsRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.turing.repository.TuringStakingRewardsRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.ALEPH_ZERO +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.MYTHOS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.NOMINATION_POOLS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.PARACHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN_AURA +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.TURING +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.UNSUPPORTED +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository + +class ParachainStakingRewardCalculatorFactory( + private val rewardsRepository: RewardsRepository, + private val currentRoundRepository: CurrentRoundRepository, + private val commonStakingRepository: TotalIssuanceRepository, + private val turingStakingRewardsRepository: TuringStakingRewardsRepository, +) { + + suspend fun create( + stakingOption: StakingOption, + snapshots: AccountIdMap + ): ParachainStakingRewardCalculator { + val chainId = stakingOption.assetWithChain.chain.id + + return when (stakingOption.additional.stakingType) { + PARACHAIN -> defaultCalculator(chainId, snapshots) + TURING -> turingCalculator(chainId, snapshots) + NOMINATION_POOLS, RELAYCHAIN, RELAYCHAIN_AURA, ALEPH_ZERO, UNSUPPORTED, MYTHOS -> { + throw IllegalStateException("Unknown staking type in ParachainStakingRewardCalculatorFactory") + } + } + } + + private suspend fun turingCalculator( + chainId: ChainId, + snapshots: AccountIdMap + ): ParachainStakingRewardCalculator { + val additionalIssuance = turingStakingRewardsRepository.additionalIssuance(chainId) + val totalIssuance = commonStakingRepository.getTotalIssuance(chainId) + + val circulating = additionalIssuance + totalIssuance + + return RealParachainStakingRewardCalculator( + inflationDistributionConfig = rewardsRepository.getInflationDistributionConfig(chainId), + inflationInfo = rewardsRepository.getInflationInfo(chainId), + totalIssuance = circulating, + totalStaked = currentRoundRepository.totalStaked(chainId), + collators = snapshots.toCollatorList(), + collatorCommission = rewardsRepository.getCollatorCommission(chainId) + ) + } + + private suspend fun defaultCalculator( + chainId: ChainId, + snapshots: AccountIdMap + ) = RealParachainStakingRewardCalculator( + inflationDistributionConfig = rewardsRepository.getInflationDistributionConfig(chainId), + inflationInfo = rewardsRepository.getInflationInfo(chainId), + totalIssuance = commonStakingRepository.getTotalIssuance(chainId), + totalStaked = currentRoundRepository.totalStaked(chainId), + collators = snapshots.toCollatorList(), + collatorCommission = rewardsRepository.getCollatorCommission(chainId) + ) + + suspend fun create(stakingOption: StakingOption): ParachainStakingRewardCalculator { + val chainId = stakingOption.assetWithChain.chain.id + + val roundIndex = currentRoundRepository.currentRoundInfo(chainId).current + val snapshot = currentRoundRepository.collatorsSnapshot(chainId, roundIndex) + + return create(stakingOption, snapshot) + } + + private fun AccountIdMap.toCollatorList() = entries.map { (accountIdHex, snapshot) -> + ParachainStakingRewardTarget( + totalStake = snapshot.total, + accountIdHex = accountIdHex + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/DelegationsLimit.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/DelegationsLimit.kt new file mode 100644 index 0000000..e338a40 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/DelegationsLimit.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start + +sealed class DelegationsLimit { + + object NotReached : DelegationsLimit() + + class Reached(val limit: Int) : DelegationsLimit() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/StartParachainStakingInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/StartParachainStakingInteractor.kt new file mode 100644 index 0000000..24ffa05 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/StartParachainStakingInteractor.kt @@ -0,0 +1,123 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegationsCount +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.hasDelegation +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.calls.delegate +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.calls.delegatorBondMore +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.runtime.ext.emptyAccountId +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chainAndAsset +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +interface StartParachainStakingInteractor { + + suspend fun estimateFee(amount: BigInteger, collatorId: AccountId?): Fee + + suspend fun delegate(amount: BigInteger, collator: AccountId): Result> + + suspend fun checkDelegationsLimit(delegatorState: DelegatorState): DelegationsLimit +} + +class RealStartParachainStakingInteractor( + private val accountRepository: AccountRepository, + private val extrinsicService: ExtrinsicService, + private val singleAssetSharedState: AnySelectedAssetOptionSharedState, + private val stakingConstantsRepository: ParachainStakingConstantsRepository, + private val delegatorStateRepository: DelegatorStateRepository, + private val candidatesRepository: CandidatesRepository, +) : StartParachainStakingInteractor { + + override suspend fun estimateFee(amount: BigInteger, collatorId: AccountId?): Fee { + val (chain, chainAsset) = singleAssetSharedState.chainAndAsset() + val collatorIdOrEmpty = collatorId ?: chain.emptyAccountId() + val metaAccount = accountRepository.getSelectedMetaAccount() + val accountId = metaAccount.accountIdIn(chain)!! + + val currentDelegationState = delegatorStateRepository.getDelegationState(chain, chainAsset, accountId) + + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + if (currentDelegationState.hasDelegation(collatorIdOrEmpty)) { + delegatorBondMore( + candidate = collatorIdOrEmpty, + amount = amount + ) + } else { + val delegationCount = getDelegationCountOrFake(collatorId, chain) + + delegate( + candidate = collatorIdOrEmpty, + amount = amount, + candidateDelegationCount = delegationCount, + delegationCount = currentDelegationState.delegationsCount.toBigInteger() + ) + } + } + } + + override suspend fun delegate(amount: BigInteger, collator: AccountId) = withContext(Dispatchers.Default) { + runCatching { + val (chain, chainAsset) = singleAssetSharedState.chainAndAsset() + val metaAccount = accountRepository.getSelectedMetaAccount() + val accountId = metaAccount.requireAccountIdIn(chain) + + val currentDelegationState = delegatorStateRepository.getDelegationState(chain, chainAsset, accountId) + + extrinsicService.submitAndWatchExtrinsic(chain, TransactionOrigin.SelectedWallet) { + if (currentDelegationState.hasDelegation(collator)) { + delegatorBondMore( + candidate = collator, + amount = amount + ) + } else { + val candidateMetadata = candidatesRepository.getCandidateMetadata(chain.id, collator) + + delegate( + candidate = collator, + amount = amount, + candidateDelegationCount = candidateMetadata.delegationCount, + delegationCount = currentDelegationState.delegationsCount.toBigInteger() + ) + } + }.awaitInBlock() + .getOrThrow() + } + } + + override suspend fun checkDelegationsLimit(delegatorState: DelegatorState): DelegationsLimit { + val maxDelegations = stakingConstantsRepository.maxDelegationsPerDelegator(delegatorState.chain.id).toInt() + + return if (delegatorState.delegationsCount < maxDelegations) { + DelegationsLimit.NotReached + } else { + DelegationsLimit.Reached(maxDelegations) + } + } + + private suspend fun getDelegationCountOrFake( + collatorId: AccountId?, + chain: Chain + ): BigInteger = if (collatorId == null) { + fakeDelegationCount() + } else { + candidatesRepository.getCandidateMetadata(chain.id, collatorId) + .delegationCount + } + + private fun fakeDelegationCount() = BigInteger.TEN +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/ActiveCollatorValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/ActiveCollatorValidation.kt new file mode 100644 index 0000000..48993ac --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/ActiveCollatorValidation.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.isActive + +class ActiveCollatorValidation : StartParachainStakingValidation { + + override suspend fun validate(value: StartParachainStakingValidationPayload): ValidationStatus { + val candidateMetadata = value.collator.candidateMetadata + + return candidateMetadata.isActive isTrueOrError { StartParachainStakingValidationFailure.CollatorIsNotActive } + } +} + +fun ValidationSystemBuilder.activeCollator() { + validate(ActiveCollatorValidation()) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/MinimumDelegationValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/MinimumDelegationValidation.kt new file mode 100644 index 0000000..d8083f1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/MinimumDelegationValidation.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations + +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.common.validation.validationWarning +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.isFull +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.isRewardedListFull +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure.TooLowStake.TooLowDelegation +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure.TooLowStake.TooLowTotalStake +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure.TooLowStake.WontReceiveRewards +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novasama.substrate_sdk_android.extensions.fromHex + +class MinimumDelegationValidationFactory( + private val stakingConstantsRepository: ParachainStakingConstantsRepository, + private val candidatesRepository: CandidatesRepository, + private val delegatorStateUseCase: DelegatorStateUseCase, +) { + + fun ValidationSystemBuilder.minimumDelegation() { + validate(MinimumDelegationValidation(stakingConstantsRepository, candidatesRepository, delegatorStateUseCase)) + } +} + +class MinimumDelegationValidation( + private val stakingConstantsRepository: ParachainStakingConstantsRepository, + private val candidatesRepository: CandidatesRepository, + private val delegatorStateUseCase: DelegatorStateUseCase, +) : StartParachainStakingValidation { + + override suspend fun validate(value: StartParachainStakingValidationPayload): ValidationStatus { + val asset = value.asset + val token = asset.token + val chainId = token.configuration.chainId + + val collatorId = value.collator.accountIdHex.fromHex() + + val minimumDelegationInPlanks = stakingConstantsRepository.minimumDelegation(chainId) + val minimumDelegationAmount = token.amountFromPlanks(minimumDelegationInPlanks) + + val minimumTotalStakeInPlanks = stakingConstantsRepository.minimumDelegatorStake(chainId) + val minimumTotalStakeAmount = token.amountFromPlanks(minimumTotalStakeInPlanks) + + val minStakeToGetRewards = token.amountFromPlanks(value.collator.minimumStakeToGetRewards.orZero()) + + val candidateMetadata = value.collator.candidateMetadata + val lowestBottomDelegationAmount = token.amountFromPlanks(candidateMetadata.lowestBottomDelegationAmount) + + val delegatorState = delegatorStateUseCase.currentDelegatorState() + val asDelegator = delegatorState.castOrNull() + + val totalDelegatedPlanks = asDelegator?.total.orZero() + val totalDelegated = token.amountFromPlanks(totalDelegatedPlanks) + + val stakedInSelectedCollatorPlanks = asDelegator?.delegationAmountTo(collatorId).orZero() + val stakedInSelectedCollator = token.amountFromPlanks(stakedInSelectedCollatorPlanks) + + return when { + // amount is lower than minimum required delegation + stakedInSelectedCollator + value.amount < minimumDelegationAmount -> { + val needToStake = minimumDelegationAmount - stakedInSelectedCollator + validationError(TooLowDelegation(needToStake, asset, strictGreaterThan = false)) + } + + // amount is lower than minimum total stake + totalDelegated + value.amount < minimumTotalStakeAmount -> { + val needToStake = minimumTotalStakeAmount - totalDelegated + validationError(TooLowTotalStake(needToStake, asset)) + } + + // collator is full so we need strictly greater amount then minimum stake + candidateMetadata.isFull() && value.amount + stakedInSelectedCollator <= lowestBottomDelegationAmount -> { + validationError(TooLowDelegation(lowestBottomDelegationAmount, asset, strictGreaterThan = true)) + } + + // collator's top is full but we can still join bottom delegations + candidateMetadata.isRewardedListFull() && value.amount + stakedInSelectedCollator <= minStakeToGetRewards -> { + validationWarning(WontReceiveRewards(minStakeToGetRewards, asset)) + } + + // otherwise we join collator's top + else -> valid() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/NoPendingRevokeValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/NoPendingRevokeValidation.kt new file mode 100644 index 0000000..d67bef1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/NoPendingRevokeValidation.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isFalseOrError +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegationAction +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novasama.substrate_sdk_android.extensions.fromHex + +class NoPendingRevokeValidationFactory( + private val delegatorStateRepository: DelegatorStateRepository, +) { + + fun ValidationSystemBuilder.noPendingRevoke() { + validate(NoPendingRevokeValidation(delegatorStateRepository)) + } +} + +class NoPendingRevokeValidation( + private val delegatorStateRepository: DelegatorStateRepository, +) : StartParachainStakingValidation { + + override suspend fun validate(value: StartParachainStakingValidationPayload): ValidationStatus { + val hasPendingRevoke = when (val delegatorState = value.delegatorState) { + is DelegatorState.Delegator -> { + val collatorId = value.collator.accountIdHex.fromHex() + val pendingRequest = delegatorStateRepository.scheduledDelegationRequest(delegatorState, collatorId) + + pendingRequest != null && pendingRequest.action is DelegationAction.Revoke + } + is DelegatorState.None -> false + } + + return hasPendingRevoke isFalseOrError { StartParachainStakingValidationFailure.PendingRevoke } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/StartParachainStakingValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/StartParachainStakingValidationFailure.kt new file mode 100644 index 0000000..23af924 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/StartParachainStakingValidationFailure.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import java.math.BigDecimal + +sealed class StartParachainStakingValidationFailure { + + object NotPositiveAmount : StartParachainStakingValidationFailure() + + object NotEnoughBalanceToPayFees : StartParachainStakingValidationFailure() + + object NotEnoughStakeableBalance : StartParachainStakingValidationFailure() + + object PendingRevoke : StartParachainStakingValidationFailure() + + object CollatorIsNotActive : StartParachainStakingValidationFailure() + + sealed class TooLowStake(val minimumStake: BigDecimal, val asset: Asset) : StartParachainStakingValidationFailure() { + + class TooLowDelegation(minimumStake: BigDecimal, asset: Asset, val strictGreaterThan: Boolean) : TooLowStake(minimumStake, asset) + + class TooLowTotalStake(minimumStake: BigDecimal, asset: Asset) : TooLowStake(minimumStake, asset) + + class WontReceiveRewards(minimumStake: BigDecimal, asset: Asset) : TooLowStake(minimumStake, asset) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/StartParachainStakingValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/StartParachainStakingValidationPayload.kt new file mode 100644 index 0000000..b50d766 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/StartParachainStakingValidationPayload.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations + +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal + +class StartParachainStakingValidationPayload( + val amount: BigDecimal, + val fee: Fee, + val collator: Collator, + val asset: Asset, + val delegatorState: DelegatorState, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/StartParachainStakingValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/StartParachainStakingValidationSystem.kt new file mode 100644 index 0000000..3764a53 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/start/validations/StartParachainStakingValidationSystem.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.stakeableAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import java.math.BigDecimal + +typealias StartParachainStakingValidationSystem = ValidationSystem +typealias StartParachainStakingValidationSystemBuilder = ValidationSystemBuilder +typealias StartParachainStakingValidation = Validation + +fun ValidationSystem.Companion.parachainStakingStart( + minimumDelegationValidationFactory: MinimumDelegationValidationFactory, + noPendingRevokeValidationFactory: NoPendingRevokeValidationFactory, +): StartParachainStakingValidationSystem = ValidationSystem { + with(minimumDelegationValidationFactory) { + minimumDelegation() + } + + with(noPendingRevokeValidationFactory) { + noPendingRevoke() + } + + activeCollator() + + positiveAmount( + amount = { it.amount }, + error = { StartParachainStakingValidationFailure.NotPositiveAmount } + ) + + enoughToPayFees() + + enoughStakeable() +} + +private fun StartParachainStakingValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { StartParachainStakingValidationFailure.NotEnoughBalanceToPayFees } + ) +} + +private fun StartParachainStakingValidationSystemBuilder.enoughStakeable() { + sufficientBalance( + fee = { it.fee }, + available = { it.stakeableAmount() }, + amount = { it.amount }, + error = { StartParachainStakingValidationFailure.NotEnoughBalanceToPayFees } + ) +} + +private fun StartParachainStakingValidationPayload.stakeableAmount(): BigDecimal { + return delegatorState.stakeableAmount(asset.freeInPlanks) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/ParachainStakingUnbondInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/ParachainStakingUnbondInteractor.kt new file mode 100644 index 0000000..50a03fb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/ParachainStakingUnbondInteractor.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.calls.scheduleBondLess +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.calls.scheduleRevokeDelegation +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +interface ParachainStakingUnbondInteractor { + + suspend fun estimateFee(amount: BigInteger, collatorId: AccountId): Fee + + suspend fun unbond(amount: BigInteger, collator: AccountId): Result> + + suspend fun canUnbond(fromCollator: AccountId, delegatorState: DelegatorState): Boolean + + suspend fun getSelectedCollators(delegatorState: DelegatorState): List +} + +class RealParachainStakingUnbondInteractor( + private val extrinsicService: ExtrinsicService, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val delegatorStateRepository: DelegatorStateRepository, + private val selectedAssetSharedState: AnySelectedAssetOptionSharedState, + private val collatorsUseCase: CollatorsUseCase, +) : ParachainStakingUnbondInteractor { + + override suspend fun estimateFee(amount: BigInteger, collatorId: AccountId): Fee { + val chain = selectedAssetSharedState.chain() + + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + unbond(amount, collatorId) + } + } + + override suspend fun unbond(amount: BigInteger, collator: AccountId) = withContext(Dispatchers.IO) { + val chain = selectedAssetSharedState.chain() + + extrinsicService.submitAndWatchExtrinsic(chain, TransactionOrigin.SelectedWallet) { + unbond(amount, collator) + }.awaitInBlock() + } + + override suspend fun canUnbond(fromCollator: AccountId, delegatorState: DelegatorState): Boolean = withContext(Dispatchers.IO) { + when (delegatorState) { + is DelegatorState.Delegator -> { + val scheduledDelegationRequest = delegatorStateRepository.scheduledDelegationRequest(delegatorState, fromCollator) + + scheduledDelegationRequest == null // can unbond only if there is no scheduled request already + } + is DelegatorState.None -> false + } + } + + override suspend fun getSelectedCollators(delegatorState: DelegatorState): List = withContext(Dispatchers.Default) { + when (delegatorState) { + is DelegatorState.Delegator -> { + val collators = collatorsUseCase.getSelectedCollators(delegatorState) + val unbondings = delegatorStateRepository.scheduledDelegationRequests(delegatorState) + + collators.map { selectedCollator -> + UnbondingCollator( + selectedCollator = selectedCollator, + hasPendingUnbonding = selectedCollator.target.accountIdHex in unbondings + ) + } + } + + is DelegatorState.None -> emptyList() + } + } + + private suspend fun ExtrinsicBuilder.unbond(amount: BigInteger, collatorId: AccountId) { + val delegatorState = delegatorStateUseCase.currentDelegatorState() + val delegationAmount = delegatorState.delegationAmountTo(collatorId).orZero() + + if (amount >= delegationAmount) { + scheduleRevokeDelegation(collatorId) + } else { + scheduleBondLess(collatorId, amount) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/UnbondingCollator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/UnbondingCollator.kt new file mode 100644 index 0000000..abb6a9c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/UnbondingCollator.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond + +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model.TargetWithStakedAmount +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.SelectedCollator +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class UnbondingCollator( + collator: Collator, + delegation: Balance, + val hasPendingUnbonding: Boolean +) : TargetWithStakedAmount(delegation, collator) + +fun UnbondingCollator(selectedCollator: SelectedCollator, hasPendingUnbonding: Boolean) = UnbondingCollator( + collator = selectedCollator.target, + delegation = selectedCollator.stake, + hasPendingUnbonding = hasPendingUnbonding +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/NoExistingDelegationRequestsToCollatorValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/NoExistingDelegationRequestsToCollatorValidation.kt new file mode 100644 index 0000000..c1d46ca --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/NoExistingDelegationRequestsToCollatorValidation.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.ParachainStakingUnbondInteractor +import io.novasama.substrate_sdk_android.extensions.fromHex + +class NoExistingDelegationRequestsToCollatorValidationFactory( + private val interactor: ParachainStakingUnbondInteractor, + private val delegatorStateUseCase: DelegatorStateUseCase, +) { + + fun ValidationSystemBuilder.noExistingDelegationRequestsToCollator() { + validate(NoExistingDelegationRequestsToCollatorValidation(interactor, delegatorStateUseCase)) + } +} + +class NoExistingDelegationRequestsToCollatorValidation( + private val interactor: ParachainStakingUnbondInteractor, + private val delegatorStateUseCase: DelegatorStateUseCase, +) : ParachainStakingUnbondValidation { + + override suspend fun validate(value: ParachainStakingUnbondValidationPayload): ValidationStatus { + val delegatorState = delegatorStateUseCase.currentDelegatorState() + val collatorId = value.collator.accountIdHex.fromHex() + + val canUnbond = interactor.canUnbond(collatorId, delegatorState) + + return canUnbond isTrueOrError { ParachainStakingUnbondValidationFailure.AlreadyHasDelegationRequestToCollator } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/ParachainStakingUnbondValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/ParachainStakingUnbondValidationFailure.kt new file mode 100644 index 0000000..0068d54 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/ParachainStakingUnbondValidationFailure.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import java.math.BigDecimal + +sealed class ParachainStakingUnbondValidationFailure { + + object NotPositiveAmount : ParachainStakingUnbondValidationFailure() + + object NotEnoughBalanceToPayFees : ParachainStakingUnbondValidationFailure() + object NotEnoughBondedToUnbond : ParachainStakingUnbondValidationFailure() + + object AlreadyHasDelegationRequestToCollator : ParachainStakingUnbondValidationFailure() + + sealed class TooLowRemainingBond(val minimumRequired: BigDecimal, val asset: Asset) : ParachainStakingUnbondValidationFailure() { + + class WillBeAddedToUnbondings(val newAmount: BigDecimal, minimumStake: BigDecimal, asset: Asset) : TooLowRemainingBond(minimumStake, asset) + + class WontReceiveRewards(minimumStake: BigDecimal, asset: Asset) : TooLowRemainingBond(minimumStake, asset) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/ParachainStakingUnbondValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/ParachainStakingUnbondValidationPayload.kt new file mode 100644 index 0000000..4d5a2f9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/ParachainStakingUnbondValidationPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow + +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal + +data class ParachainStakingUnbondValidationPayload( + val amount: BigDecimal, + val fee: Fee, + val collator: Collator, + val asset: Asset, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/ParachainStakingUnbondValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/ParachainStakingUnbondValidationSystem.kt new file mode 100644 index 0000000..be3ca65 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/ParachainStakingUnbondValidationSystem.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias ParachainStakingUnbondValidationSystem = ValidationSystem +typealias ParachainStakingUnbondValidation = Validation + +fun ValidationSystem.Companion.parachainStakingUnbond( + remainingUnbondValidationFactory: RemainingUnbondValidationFactory, + noExistingDelegationRequestsToCollatorValidationFactory: NoExistingDelegationRequestsToCollatorValidationFactory, +): ParachainStakingUnbondValidationSystem = ValidationSystem { + with(remainingUnbondValidationFactory) { + validRemainingUnbond() + } + + with(noExistingDelegationRequestsToCollatorValidationFactory) { + noExistingDelegationRequestsToCollator() + } + + positiveAmount( + amount = { it.amount }, + error = { ParachainStakingUnbondValidationFailure.NotPositiveAmount } + ) + + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { ParachainStakingUnbondValidationFailure.NotEnoughBalanceToPayFees } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/RemainingUnbondValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/RemainingUnbondValidation.kt new file mode 100644 index 0000000..c50ddae --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/flow/RemainingUnbondValidation.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow + +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.hasTheSaveValueAs +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.common.validation.validationWarning +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.activeBonded +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.isBottomDelegationsNotEmpty +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.isRewardedListFull +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CandidatesRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.ParachainStakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationFailure.TooLowRemainingBond.WillBeAddedToUnbondings +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationFailure.TooLowRemainingBond.WontReceiveRewards +import io.novasama.substrate_sdk_android.extensions.fromHex + +class RemainingUnbondValidationFactory( + private val stakingConstantsRepository: ParachainStakingConstantsRepository, + private val candidatesRepository: CandidatesRepository, + private val delegatorStateUseCase: DelegatorStateUseCase, +) { + + fun ValidationSystemBuilder.validRemainingUnbond() { + validate(RemainingUnbondValidation(stakingConstantsRepository, candidatesRepository, delegatorStateUseCase)) + } +} + +class RemainingUnbondValidation( + private val stakingConstantsRepository: ParachainStakingConstantsRepository, + private val candidatesRepository: CandidatesRepository, + private val delegatorStateUseCase: DelegatorStateUseCase, +) : ParachainStakingUnbondValidation { + + override suspend fun validate( + value: ParachainStakingUnbondValidationPayload + ): ValidationStatus = with(value.asset.token) { + val asset = value.asset + val chainId = configuration.chainId + + val collatorId = value.collator.accountIdHex.fromHex() + + val minimumDelegationAmount = stakingConstantsRepository.minimumDelegation(chainId).toAmount() + val minimumTotalStakeAmount = stakingConstantsRepository.minimumDelegatorStake(chainId).toAmount() + val minStakeToGetRewards = value.collator.minimumStakeToGetRewards.orZero().toAmount() + + val candidateMetadata = candidatesRepository.getCandidateMetadata(chainId, collatorId) + val highestBottomDelegationAmount = candidateMetadata.highestBottomDelegationAmount.toAmount() + + val delegatorState = delegatorStateUseCase.currentDelegatorState() + val asDelegator = delegatorState.castOrNull() + + val activeBondedAmount = asDelegator?.activeBonded.orZero().toAmount() + val stakedInSelectedCollator = asDelegator?.delegationAmountTo(collatorId).orZero().toAmount() + + val isUnbondingAll = stakedInSelectedCollator hasTheSaveValueAs value.amount + + return when { + // enough bonded balance to unbond specified amount + value.amount > stakedInSelectedCollator -> { + validationError(ParachainStakingUnbondValidationFailure.NotEnoughBondedToUnbond) + } + + // remaining bond in selected collator will be less then minimum + stakedInSelectedCollator - value.amount < minimumDelegationAmount && !isUnbondingAll -> { + validationWarning(WillBeAddedToUnbondings(newAmount = stakedInSelectedCollator, minimumStake = minimumDelegationAmount, asset = asset)) + } + + // remaining total bond will be less then minimum + activeBondedAmount - value.amount < minimumTotalStakeAmount && !isUnbondingAll -> { + validationWarning(WillBeAddedToUnbondings(newAmount = stakedInSelectedCollator, minimumStake = minimumTotalStakeAmount, asset = asset)) + } + + // warn users if they will loose rewards by doing this unbond + candidateMetadata.isRewardedListFull() && // only relevant if rewarded list is full + candidateMetadata.isBottomDelegationsNotEmpty() && // there are delegators who can potentially kick user out from rewarded set + stakedInSelectedCollator >= minStakeToGetRewards && // if user currently receives rewards + stakedInSelectedCollator - value.amount < highestBottomDelegationAmount && // but will stop doing so after unbond + !isUnbondingAll // only in case user unbonds not the whole amount + -> { + validationWarning(WontReceiveRewards(minStakeToGetRewards, asset)) + } + + // otherwise no consequences for user + else -> valid() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/AnyAvailableCollatorsForUnbondValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/AnyAvailableCollatorsForUnbondValidation.kt new file mode 100644 index 0000000..0d47e2c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/AnyAvailableCollatorsForUnbondValidation.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary + +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.ParachainStakingUnbondPreliminaryValidationFailure.NoAvailableCollators + +class AnyAvailableCollatorForUnbondValidationFactory( + private val delegatorStateRepository: DelegatorStateRepository, + private val delegatorStateUseCase: DelegatorStateUseCase, +) { + + fun ParachainStakingUnbondPreliminaryValidationSystemBuilder.anyAvailableCollatorForUnbond() { + validate(AnyAvailableCollatorsForUnbondValidation(delegatorStateRepository, delegatorStateUseCase)) + } +} + +class AnyAvailableCollatorsForUnbondValidation( + private val delegatorStateRepository: DelegatorStateRepository, + private val delegatorStateUseCase: DelegatorStateUseCase, +) : ParachainStakingUnbondPreliminaryValidation { + + override suspend fun validate( + value: ParachainStakingUnbondPreliminaryValidationPayload + ): ValidationStatus { + val delegatorState = delegatorStateUseCase.currentDelegatorState().castOrNull() ?: return valid() + + val pendingRequests = delegatorStateRepository.scheduledDelegationRequests(delegatorState) + val anyCollatorAvailableForUnbond = pendingRequests.size < delegatorState.delegations.size + + return anyCollatorAvailableForUnbond isTrueOrError { NoAvailableCollators } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/ParachainStakingUnbondPreliminaryValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/ParachainStakingUnbondPreliminaryValidationFailure.kt new file mode 100644 index 0000000..9a9e9fc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/ParachainStakingUnbondPreliminaryValidationFailure.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary + +sealed class ParachainStakingUnbondPreliminaryValidationFailure { + + object NoAvailableCollators : ParachainStakingUnbondPreliminaryValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/ParachainStakingUnbondPreliminaryValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/ParachainStakingUnbondPreliminaryValidationPayload.kt new file mode 100644 index 0000000..5b6a064 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/ParachainStakingUnbondPreliminaryValidationPayload.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary + +typealias ParachainStakingUnbondPreliminaryValidationPayload = Unit diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/ParachainStakingUnbondValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/ParachainStakingUnbondValidationSystem.kt new file mode 100644 index 0000000..6b59b2e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/unbond/validations/preliminary/ParachainStakingUnbondValidationSystem.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder + +typealias ParachainStakingUnbondPreliminaryValidationSystem = + ValidationSystem +typealias ParachainStakingUnbondPreliminaryValidation = + Validation + +typealias ParachainStakingUnbondPreliminaryValidationSystemBuilder = + ValidationSystemBuilder + +fun ValidationSystem.Companion.parachainStakingPreliminaryUnbond( + anyAvailableCollatorForUnbondValidationFactory: AnyAvailableCollatorForUnbondValidationFactory +): ParachainStakingUnbondPreliminaryValidationSystem = ValidationSystem { + with(anyAvailableCollatorForUnbondValidationFactory) { + anyAvailableCollatorForUnbond() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/YieldBoostConfiguration.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/YieldBoostConfiguration.kt new file mode 100644 index 0000000..7c2b3dd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/YieldBoostConfiguration.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost + +import java.math.BigInteger + +sealed class YieldBoostConfiguration(open val collatorIdHex: String) { + + data class Off(override val collatorIdHex: String) : YieldBoostConfiguration(collatorIdHex) + + data class On( + val threshold: BigInteger, + val frequencyInDays: Int, + override val collatorIdHex: String + ) : YieldBoostConfiguration(collatorIdHex) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/YieldBoostInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/YieldBoostInteractor.kt new file mode 100644 index 0000000..3340548 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/YieldBoostInteractor.kt @@ -0,0 +1,195 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.OptimalAutomationRequest +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTask +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTasksRepository +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.domain.rewards.PeriodReturns +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.TimestampRepository +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.math.roundToLong +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +class YieldBoostParameters( + val yearlyReturns: PeriodReturns, + val periodInDays: Int +) + +interface YieldBoostInteractor { + + suspend fun calculateFee( + configuration: YieldBoostConfiguration, + activeTasks: List, + ): Fee + + suspend fun setYieldBoost( + configuration: YieldBoostConfiguration, + activeTasks: List + ): Result> + + suspend fun optimalYieldBoostParameters(delegatorState: DelegatorState, collatorId: AccountId): YieldBoostParameters + + fun activeYieldBoostTasks(delegatorState: DelegatorState.Delegator): Flow> +} + +class RealYieldBoostInteractor( + private val yieldBoostRepository: TuringAutomationTasksRepository, + private val extrinsicService: ExtrinsicService, + private val singleAssetSharedState: AnySelectedAssetOptionSharedState, + private val timestampRepository: TimestampRepository, +) : YieldBoostInteractor { + + override suspend fun calculateFee( + configuration: YieldBoostConfiguration, + activeTasks: List + ): Fee { + val chain = singleAssetSharedState.chain() + + return extrinsicService.estimateFee(chain, TransactionOrigin.SelectedWallet) { + setYieldBoost(chain, activeTasks, configuration) + } + } + + override suspend fun setYieldBoost( + configuration: YieldBoostConfiguration, + activeTasks: List + ): Result> { + val chain = singleAssetSharedState.chain() + + return extrinsicService.submitAndWatchExtrinsic(chain, TransactionOrigin.SelectedWallet) { + setYieldBoost(chain, activeTasks, configuration) + }.awaitInBlock() + } + + override suspend fun optimalYieldBoostParameters(delegatorState: DelegatorState, collatorId: AccountId): YieldBoostParameters { + val amountInPlanks = delegatorState.delegationAmountTo(collatorId).orZero() + val amount = delegatorState.chainAsset.amountFromPlanks(amountInPlanks) + + val collatorAddress = delegatorState.chain.addressOf(collatorId) + + val request = OptimalAutomationRequest(collatorAddress, amountInPlanks) + val optimalAutomationResponse = yieldBoostRepository.calculateOptimalAutomation(delegatorState.chain.id, request) + + val apy = optimalAutomationResponse.apy.toBigDecimal() + + return YieldBoostParameters( + yearlyReturns = PeriodReturns( + gainFraction = apy, + gainAmount = apy * amount, + isCompound = true + ), + periodInDays = optimalAutomationResponse.period + ) + } + + override fun activeYieldBoostTasks(delegatorState: DelegatorState.Delegator): Flow> { + return yieldBoostRepository.automationTasksFlow(delegatorState.chain.id, delegatorState.accountId).map { tasks -> + tasks.map { + YieldBoostTask( + id = it.id, + collator = it.collator, + accountMinimum = it.accountMinimum, + schedule = mapYieldBoostTaskSchedule(it.schedule) + ) + } + } + } + + private suspend fun ExtrinsicBuilder.setYieldBoost( + chain: Chain, + activeTasks: List, + configuration: YieldBoostConfiguration + ) { + val collatorId = configuration.collatorIdHex.fromHex() + val activeCollatorTask = activeTasks.findByCollator(collatorId) + + when (configuration) { + is YieldBoostConfiguration.Off -> { + activeCollatorTask?.let { + stopAutoCompounding(it) + } + } + + is YieldBoostConfiguration.On -> { + if (activeCollatorTask != null) { + // updating existing yield-boost - cancel only modified collator task + stopAutoCompounding(activeCollatorTask) + } else { + // setting up new yield boost - cancel every existing task + stopAllAutoCompounding(activeTasks) + } + + startAutoCompounding(chain.id, configuration) + } + } + } + + private suspend fun ExtrinsicBuilder.startAutoCompounding(chainId: ChainId, configuration: YieldBoostConfiguration.On) { + val currentTimeStamp = timestampRepository.now(chainId).toLong().milliseconds + val frequency = configuration.frequencyInDays.days + + val firstExecution = currentTimeStamp + frequency + val firstExecutionInHours = firstExecution.toDouble(DurationUnit.HOURS) + val firstExecutionRoundedToHours = firstExecutionInHours.roundToLong().hours.inWholeSeconds.toBigInteger() + + call( + moduleName = Modules.AUTOMATION_TIME, + callName = "schedule_auto_compound_delegated_stake_task", + arguments = mapOf( + "execution_time" to firstExecutionRoundedToHours, + "frequency" to frequency.inWholeSeconds.toBigInteger(), + "collator_id" to configuration.collatorIdHex.fromHex(), + "account_minimum" to configuration.threshold + ) + ) + } + + private fun ExtrinsicBuilder.stopAllAutoCompounding(tasks: List) { + tasks.forEach { task -> + stopAutoCompounding(task) + } + } + + private fun ExtrinsicBuilder.stopAutoCompounding(task: YieldBoostTask) { + call( + moduleName = Modules.AUTOMATION_TIME, + callName = "cancel_task", + arguments = mapOf( + "task_id" to task.id.fromHex() + ) + ) + } + + private fun mapYieldBoostTaskSchedule(task: TuringAutomationTask.Schedule): YieldBoostTask.Schedule { + return when (task) { + TuringAutomationTask.Schedule.Unknown -> YieldBoostTask.Schedule.Unknown + is TuringAutomationTask.Schedule.Recurring -> YieldBoostTask.Schedule.Recurring( + frequency = task.frequency.toLong().seconds + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/YieldBoostTask.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/YieldBoostTask.kt new file mode 100644 index 0000000..082899d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/YieldBoostTask.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlin.time.Duration +import kotlin.time.DurationUnit + +class YieldBoostTask( + val id: String, + val collator: AccountId, + val accountMinimum: Balance, + val schedule: Schedule, +) { + + sealed interface Schedule { + object Unknown : Schedule + + class Recurring(val frequency: Duration) : Schedule + } +} + +fun YieldBoostTask.frequencyInDays() = when (schedule) { + is YieldBoostTask.Schedule.Recurring -> schedule.frequency.toInt(DurationUnit.DAYS).coerceAtLeast(1) + YieldBoostTask.Schedule.Unknown -> null +} + +fun List.findByCollator(collatorId: AccountId): YieldBoostTask? = find { it.collator.contentEquals(collatorId) } diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/CancelActiveTasksValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/CancelActiveTasksValidation.kt new file mode 100644 index 0000000..42d7102 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/CancelActiveTasksValidation.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationWarning +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.accountId +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostConfiguration +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.findByCollator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationFailure.WillCancelAllExistingTasks + +class CancelActiveTasksValidation : YieldBoostValidation { + + override suspend fun validate(value: YieldBoostValidationPayload): ValidationStatus { + return when { + // there is no active yield boost tasks so we wont cancel anything + value.activeTasks.isEmpty() -> valid() + + // cancel transactions are always OK + value.configuration is YieldBoostConfiguration.Off -> valid() + + // user wants to change existing task + value.activeTasks.findByCollator(value.collator.accountId()) != null -> valid() + + else -> WillCancelAllExistingTasks(newCollator = value.collator).validationWarning() + } + } +} + +fun YieldBoostValidationBuilder.cancelActiveTasks() { + validate(CancelActiveTasksValidation()) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/FirstTaskCanExecute.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/FirstTaskCanExecute.kt new file mode 100644 index 0000000..c0806c1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/FirstTaskCanExecute.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_account_api.data.model.decimalAmountByExecutingAccount +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.AutomationAction +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTasksRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostConfiguration +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationFailure.FirstTaskCannotExecute.Type.EXECUTION_FEE +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationFailure.FirstTaskCannotExecute.Type.THRESHOLD +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks + +class FirstTaskCanExecute( + private val automationTasksRepository: TuringAutomationTasksRepository, +) : YieldBoostValidation { + + override suspend fun validate(value: YieldBoostValidationPayload): ValidationStatus { + if (value.configuration !is YieldBoostConfiguration.On) return valid() + + val token = value.asset.token + + val balanceBeforeTransaction = value.asset.transferable + val balanceAfterTransaction = balanceBeforeTransaction - value.fee.decimalAmountByExecutingAccount + + val chainId = value.asset.token.configuration.chainId + + val taskExecutionFeePlanks = automationTasksRepository.getTimeAutomationFees(chainId, AutomationAction.AUTO_COMPOUND_DELEGATED_STAKE, executions = 1) + val taskExecutionFee = token.amountFromPlanks(taskExecutionFeePlanks) + + val threshold = token.amountFromPlanks(value.configuration.threshold) + + return when { + taskExecutionFee > balanceAfterTransaction -> YieldBoostValidationFailure.FirstTaskCannotExecute( + minimumBalanceRequired = taskExecutionFee, + networkFee = value.fee.decimalAmountByExecutingAccount, + availableBalanceBeforeFees = balanceBeforeTransaction, + type = EXECUTION_FEE, + chainAsset = token.configuration + ).validationError() + + threshold > balanceAfterTransaction -> YieldBoostValidationFailure.FirstTaskCannotExecute( + minimumBalanceRequired = threshold, + networkFee = value.fee.decimalAmountByExecutingAccount, + availableBalanceBeforeFees = balanceBeforeTransaction, + type = THRESHOLD, + chainAsset = token.configuration + ).validationError() + + else -> valid() + } + } +} + +fun YieldBoostValidationBuilder.firstTaskCanExecute(automationTasksRepository: TuringAutomationTasksRepository) { + validate(FirstTaskCanExecute(automationTasksRepository)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/ValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/ValidationSystem.kt new file mode 100644 index 0000000..951755d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/ValidationSystem.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTasksRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +typealias YieldBoostValidationSystem = ValidationSystem +typealias YieldBoostValidation = Validation +typealias YieldBoostValidationBuilder = ValidationSystemBuilder + +fun ValidationSystem.Companion.yieldBoost( + automationTasksRepository: TuringAutomationTasksRepository +): YieldBoostValidationSystem = ValidationSystem { + sufficientBalance( + fee = { it.fee }, + available = { it.asset.transferable }, + error = { context -> + YieldBoostValidationFailure.NotEnoughToPayToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) + + cancelActiveTasks() + + firstTaskCanExecute(automationTasksRepository) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/YieldBoostValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/YieldBoostValidationFailure.kt new file mode 100644 index 0000000..9caf557 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/YieldBoostValidationFailure.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations + +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class YieldBoostValidationFailure { + + class FirstTaskCannotExecute( + val chainAsset: Chain.Asset, + val minimumBalanceRequired: BigDecimal, + val networkFee: BigDecimal, + val availableBalanceBeforeFees: BigDecimal, + val type: Type + ) : YieldBoostValidationFailure() { + + enum class Type { + EXECUTION_FEE, THRESHOLD + } + } + + class WillCancelAllExistingTasks( + val newCollator: Collator, + ) : YieldBoostValidationFailure() + + class NotEnoughToPayToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : YieldBoostValidationFailure(), NotEnoughToPayFeesError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/YieldBoostValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/YieldBoostValidationPayload.kt new file mode 100644 index 0000000..f74fd27 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/parachainStaking/yieldBoost/validations/YieldBoostValidationPayload.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations + +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostConfiguration +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostTask +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee + +class YieldBoostValidationPayload( + val collator: Collator, + val activeTasks: List, + val configuration: YieldBoostConfiguration, + val asset: Asset, + val fee: Fee, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/payout/PayoutInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/payout/PayoutInteractor.kt new file mode 100644 index 0000000..6a3318e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/payout/PayoutInteractor.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_staking_impl.domain.payout + +import io.novafoundation.nova.common.utils.hasCall +import io.novafoundation.nova.common.utils.multiResult.RetriableMultiResult +import io.novafoundation.nova.common.utils.staking +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.model.Payout +import io.novafoundation.nova.feature_staking_impl.domain.validations.payout.MakePayoutPayload +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.extrinsic.multi.CallBuilder +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class PayoutInteractor( + private val stakingSharedState: StakingSharedState, + private val extrinsicService: ExtrinsicService +) { + + suspend fun estimatePayoutFee(payouts: List): Fee { + return withContext(Dispatchers.IO) { + extrinsicService.estimateMultiFee(stakingSharedState.chain(), TransactionOrigin.SelectedWallet) { + payoutMultiple(payouts) + } + } + } + + suspend fun makePayouts(payload: MakePayoutPayload): RetriableMultiResult> { + return withContext(Dispatchers.IO) { + val chain = stakingSharedState.chain() + val accountId = chain.accountIdOf(payload.originAddress) + val origin = TransactionOrigin.WalletWithAccount(accountId) + + extrinsicService.submitMultiExtrinsicAwaitingInclusion(chain, origin) { + payoutMultiple(payload.payouts) + } + } + } + + private fun CallBuilder.payoutMultiple(payouts: List) { + payouts.forEach { payout -> + makePayout(payout) + } + } + + private fun CallBuilder.makePayout(payout: Payout) { + if (runtime.metadata.staking().hasCall("payout_stakers_by_page")) { + payout.pagesToClaim.onEach { page -> + payoutStakersByPage(payout.era, payout.validatorStash.value, page) + } + } else { + // paged payout is not present so we use regular one + payoutStakers(payout.era, payout.validatorStash.value) + } + } + + private fun CallBuilder.payoutStakers(era: BigInteger, validatorId: AccountId) { + addCall( + "Staking", + "payout_stakers", + mapOf( + "validator_stash" to validatorId, + "era" to era + ) + ) + } + + private fun CallBuilder.payoutStakersByPage(era: BigInteger, validatorId: AccountId, page: Int) { + addCall( + "Staking", + "payout_stakers_by_page", + mapOf( + "validator_stash" to validatorId, + "era" to era, + "page" to page.toBigInteger() + ) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/period/RewardPeriod.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/period/RewardPeriod.kt new file mode 100644 index 0000000..5680b84 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/period/RewardPeriod.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_staking_impl.domain.period + +import io.novafoundation.nova.common.utils.atTheNextDay +import io.novafoundation.nova.common.utils.atTheBeginningOfTheDay +import java.util.Date +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds + +sealed interface RewardPeriod { + + val type: RewardPeriodType + + val start: Date? + + val end: Date? + + data class OffsetFromCurrent( + val offset: Duration, + override val type: RewardPeriodType.Preset + ) : RewardPeriod { + + // Since we take the currentDate as the whole day we add 1 day to the start period using atTheNextDay() + override val start: Date + get() = Date(System.currentTimeMillis() - offset.inWholeMilliseconds).atTheNextDay() + + override val end: Date? = null + } + + data class CustomRange(override val start: Date, override val end: Date?) : RewardPeriod { + override val type = RewardPeriodType.Custom + } + + object AllTime : RewardPeriod { + override val type = RewardPeriodType.AllTime + + override val start: Date? = null + override val end: Date? = null + } + + companion object { + fun getPresetOffset(type: RewardPeriodType.Preset): Duration { + val numberOfDays = when (type) { + RewardPeriodType.Preset.WEEK -> 7 + RewardPeriodType.Preset.MONTH -> 30 + RewardPeriodType.Preset.QUARTER -> 90 + RewardPeriodType.Preset.HALF_YEAR -> 180 + RewardPeriodType.Preset.YEAR -> 365 + } + + return numberOfDays.days + } + } +} + +sealed interface RewardPeriodType { + + object AllTime : RewardPeriodType + + enum class Preset : RewardPeriodType { + WEEK, + MONTH, + QUARTER, + HALF_YEAR, + YEAR + } + + object Custom : RewardPeriodType +} + +fun RewardPeriod.getPeriodDays(): Long { + return when (this) { + is RewardPeriod.OffsetFromCurrent -> offset.inWholeDays + + // Since we consider the end date as a full day we add 1 day to the end date using atTheNextDay() to calculate the true duration + // We also use atTheBeginningOfTheDay() for startDate to be sure that we use valid data + is RewardPeriod.CustomRange -> { + val endTime = end ?: Date() + val durationMillis = endTime.atTheNextDay().time - start.atTheBeginningOfTheDay().time + + durationMillis.milliseconds.inWholeDays + } + + else -> -1 + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/period/StakingRewardPeriodInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/period/StakingRewardPeriodInteractor.kt new file mode 100644 index 0000000..0e5b40e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/period/StakingRewardPeriodInteractor.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_staking_impl.domain.period + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.components +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingPeriodRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest + +interface StakingRewardPeriodInteractor { + + suspend fun setRewardPeriod(stakingOption: StakingOption, rewardPeriod: RewardPeriod) + + suspend fun getRewardPeriod(stakingOption: StakingOption): RewardPeriod + + fun observeRewardPeriod(stakingOption: StakingOption): Flow +} + +class RealStakingRewardPeriodInteractor( + private val stakingPeriodRepository: StakingPeriodRepository, + private val accountRepository: AccountRepository +) : StakingRewardPeriodInteractor { + + override suspend fun setRewardPeriod(stakingOption: StakingOption, rewardPeriod: RewardPeriod) { + val metaAccount = accountRepository.getSelectedMetaAccount() + val (chain, asset, stakingType) = stakingOption.components + val accountId = metaAccount.accountIdIn(chain) ?: return + stakingPeriodRepository.setRewardPeriod(accountId, chain, asset, stakingType, rewardPeriod) + } + + override suspend fun getRewardPeriod(stakingOption: StakingOption): RewardPeriod { + val metaAccount = accountRepository.getSelectedMetaAccount() + val (chain, asset, stakingType) = stakingOption.components + val accountId = metaAccount.accountIdIn(chain) ?: return RewardPeriod.AllTime + return stakingPeriodRepository.getRewardPeriod(accountId, chain, asset, stakingType) + } + + override fun observeRewardPeriod(stakingOption: StakingOption): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { + val (chain, asset, stakingType) = stakingOption.components + val accountId = it.accountIdIn(stakingOption.assetWithChain.chain) ?: return@flatMapLatest emptyFlow() + stakingPeriodRepository.observeRewardPeriod(accountId, chain, asset, stakingType) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/ValidatorRecommender.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/ValidatorRecommender.kt new file mode 100644 index 0000000..e418fdf --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/ValidatorRecommender.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations + +import io.novafoundation.nova.common.utils.applyFilters +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettings +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSorting +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ValidatorRecommender( + val availableValidators: List, + private val novaValidatorIds: Set, + private val excludedValidators: Set, +) { + + suspend fun recommendations(settings: RecommendationSettings) = withContext(Dispatchers.Default) { + val all = availableValidators.applyFiltersAdaptingToEmptyResult(settings.allFilters) + .filterExcludedIfNeeded(settings) + .sortedWith(settings.sorting) + + val postprocessed = settings.postProcessors.fold(all) { acc, postProcessor -> + postProcessor.apply(acc) + } + + if (settings.limit != null) { + postprocessed.applyLimit(settings.limit, settings.sorting) + } else { + postprocessed + } + } + + private fun List.applyLimit(limit: Int, sorting: RecommendationSorting): List { + if (isEmpty()) return emptyList() + + val (novaValidators, others) = partition { it.accountIdHex in novaValidatorIds } + val cappedNovaValidators = novaValidators.take(limit) + + val cappedOthers = others.take(limit - cappedNovaValidators.size) + + return (cappedNovaValidators + cappedOthers).sortedWith(sorting) + } + + private fun List.applyFiltersAdaptingToEmptyResult(filters: List): List { + var filtered = applyFilters(filters) + + if (filtered.isEmpty()) { + val weakenedFilters = filters.filterNot { it.canIgnoreWhenNoApplicableCandidatesFound() } + + filtered = applyFilters(weakenedFilters) + } + + return filtered + } + + private fun List.filterExcludedIfNeeded(settings: RecommendationSettings): List { + if (!settings.filterExcluded) return this + + return filter { it.accountIdHex !in excludedValidators } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/ValidatorRecommenderFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/ValidatorRecommenderFactory.kt new file mode 100644 index 0000000..8a29e0f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/ValidatorRecommenderFactory.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.validators.ValidatorsPreferencesSource +import io.novafoundation.nova.feature_staking_impl.domain.validators.ValidatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.validators.ValidatorSource +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val ELECTED_VALIDATORS_CACHE = "ELECTED_VALIDATORS_CACHE" + +class ValidatorRecommenderFactory( + private val validatorProvider: ValidatorProvider, + private val sharedState: StakingSharedState, + private val computationalCache: ComputationalCache, + private val validatorsPreferencesSource: ValidatorsPreferencesSource, +) { + + suspend fun awaitRecommendatorLoading(scope: CoroutineScope) = withContext(Dispatchers.IO) { + loadRecommendator(scope) + } + + suspend fun create(scope: CoroutineScope): ValidatorRecommender = withContext(Dispatchers.IO) { + loadRecommendator(scope) + } + + private suspend fun loadRecommendator(scope: CoroutineScope) = computationalCache.useCache(ELECTED_VALIDATORS_CACHE, scope) { + val stakingOption = sharedState.selectedOption() + + val sources = listOf(ValidatorSource.Elected, ValidatorSource.NovaValidators) + + val validators = validatorProvider.getValidators(stakingOption, sources, scope) + + val recommendedValidators = validatorsPreferencesSource.getRecommendedValidatorIds(stakingOption.chain.id) + val excludedValidators = validatorsPreferencesSource.getExcludedValidatorIds(stakingOption.chain.id) + + ValidatorRecommender(validators, recommendedValidators, excludedValidators) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/Error.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/Error.kt new file mode 100644 index 0000000..9f6941f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/Error.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings + +fun noValidatorPrefs(accountIdHex: String): Nothing = throw IllegalStateException("Sorting/Filtering validator $accountIdHex with no prefs") + +fun notElected(accountIdHex: String): Nothing = throw IllegalStateException("Sorting/Filtering not elected validator $accountIdHex") diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/RecommendationSettings.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/RecommendationSettings.kt new file mode 100644 index 0000000..703cee2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/RecommendationSettings.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings + +import io.novafoundation.nova.common.utils.PalletBasedFilter +import io.novafoundation.nova.common.utils.RuntimeDependent +import io.novafoundation.nova.feature_staking_api.domain.model.Validator + +interface RecommendationFilter : PalletBasedFilter { + + fun canIgnoreWhenNoApplicableCandidatesFound(): Boolean +} + +typealias RecommendationSorting = Comparator + +interface RecommendationPostProcessor : RuntimeDependent { + + fun apply(original: List): List +} + +data class RecommendationSettings( + val alwaysEnabledFilters: List, + val customEnabledFilters: List, + val postProcessors: List, + val sorting: RecommendationSorting, + val filterExcluded: Boolean, + val limit: Int? = null +) { + + val allFilters = alwaysEnabledFilters + customEnabledFilters +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/RecommendationSettingsProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/RecommendationSettingsProvider.kt new file mode 100644 index 0000000..113e569 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/RecommendationSettingsProvider.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings + +import io.novafoundation.nova.common.utils.RuntimeDependent +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.HasIdentityFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.NotBlockedFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.NotOverSubscribedFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.NotSlashedFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.postprocessors.RemoveClusteringPostprocessor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.APYSorting +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class RecommendationSettingsProvider( + private val runtimeSnapshot: RuntimeSnapshot, +) { + + private val alwaysEnabledFilters = runtimeSnapshot.availableDependents( + NotBlockedFilter, + ) + + private val customizableFilters = runtimeSnapshot.availableDependents( + NotSlashedFilter, + HasIdentityFilter, + NotOverSubscribedFilter + ) + + val allAvailableFilters = alwaysEnabledFilters + customizableFilters + + val allPostProcessors = runtimeSnapshot.availableDependents( + RemoveClusteringPostprocessor + ) + + private val customSettingsFlow = MutableStateFlow(defaultSelectCustomSettings()) + + fun createModifiedCustomValidatorsSettings( + filterIncluder: (RecommendationFilter) -> Boolean, + postProcessorIncluder: (RecommendationPostProcessor) -> Boolean, + sorting: RecommendationSorting? = null + ): RecommendationSettings { + val current = customSettingsFlow.value + + return current.copy( + alwaysEnabledFilters = alwaysEnabledFilters, + customEnabledFilters = customizableFilters.filter(filterIncluder), + postProcessors = allPostProcessors.filter(postProcessorIncluder), + sorting = sorting ?: current.sorting + ) + } + + fun setCustomValidatorsSettings(recommendationSettings: RecommendationSettings) { + customSettingsFlow.value = recommendationSettings + } + + fun observeRecommendationSettings(): Flow = customSettingsFlow + + fun currentSettings() = customSettingsFlow.value + + fun recommendedSettings(maximumValidatorsPerNominator: Int): RecommendationSettings { + return RecommendationSettings( + alwaysEnabledFilters = alwaysEnabledFilters, + customEnabledFilters = customizableFilters, + sorting = APYSorting, + postProcessors = allPostProcessors, + filterExcluded = true, + limit = maximumValidatorsPerNominator + ) + } + + fun defaultSelectCustomSettings() = RecommendationSettings( + alwaysEnabledFilters = alwaysEnabledFilters, + customEnabledFilters = customizableFilters, + sorting = APYSorting, + postProcessors = allPostProcessors, + filterExcluded = false, + limit = null + ) + + private fun RuntimeSnapshot.availableDependents(vararg candidates: T): List { + return candidates.filter { it.availableIn(this) } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/RecommendationSettingsProviderFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/RecommendationSettingsProviderFactory.kt new file mode 100644 index 0000000..6fcd069 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/RecommendationSettingsProviderFactory.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import kotlinx.coroutines.CoroutineScope + +private const val SETTINGS_PROVIDER_KEY = "SETTINGS_PROVIDER_KEY" + +class RecommendationSettingsProviderFactory( + private val computationalCache: ComputationalCache, + private val chainRegistry: ChainRegistry, + private val sharedState: StakingSharedState, +) { + + suspend fun create(scope: CoroutineScope): RecommendationSettingsProvider { + return computationalCache.useCache(SETTINGS_PROVIDER_KEY, scope) { + val chainId = sharedState.chainId() + + RecommendationSettingsProvider(chainRegistry.getRuntime(chainId)) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/HasIdentityFilter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/HasIdentityFilter.kt new file mode 100644 index 0000000..8a2a05e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/HasIdentityFilter.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.hasModule +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationFilter +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +object HasIdentityFilter : RecommendationFilter { + + override fun shouldInclude(model: Validator): Boolean { + return model.identity != null + } + + override fun canIgnoreWhenNoApplicableCandidatesFound(): Boolean { + return true + } + + override fun availableIn(runtime: RuntimeSnapshot): Boolean { + return runtime.metadata.hasModule(Modules.IDENTITY) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/NotBlockedFilter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/NotBlockedFilter.kt new file mode 100644 index 0000000..e54d578 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/NotBlockedFilter.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters + +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationFilter + +object NotBlockedFilter : RecommendationFilter { + + override fun canIgnoreWhenNoApplicableCandidatesFound(): Boolean { + return false + } + + override fun shouldInclude(model: Validator) = model.prefs?.blocked?.not() ?: false +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/NotOverSubscribedFilter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/NotOverSubscribedFilter.kt new file mode 100644 index 0000000..77fb25d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/NotOverSubscribedFilter.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters + +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationFilter + +object NotOverSubscribedFilter : RecommendationFilter { + + override fun canIgnoreWhenNoApplicableCandidatesFound(): Boolean { + return true + } + + override fun shouldInclude(model: Validator): Boolean { + val isOversubscribed = model.electedInfo?.isOversubscribed + + return if (isOversubscribed != null) { + !isOversubscribed + } else { + true // inactive validators are considered as non-oversubscribed + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/NotSlashedFilter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/NotSlashedFilter.kt new file mode 100644 index 0000000..4005020 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/filters/NotSlashedFilter.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters + +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationFilter + +object NotSlashedFilter : RecommendationFilter { + + override fun canIgnoreWhenNoApplicableCandidatesFound(): Boolean { + return true + } + + override fun shouldInclude(model: Validator): Boolean { + return !model.slashed + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/postprocessors/RemoveClusteringPostprocessor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/postprocessors/RemoveClusteringPostprocessor.kt new file mode 100644 index 0000000..8e43859 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/postprocessors/RemoveClusteringPostprocessor.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.postprocessors + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.hasModule +import io.novafoundation.nova.feature_account_api.data.model.ChildIdentity +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_account_api.data.model.RootIdentity +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationPostProcessor +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +private const val MAX_PER_CLUSTER = 2 + +object RemoveClusteringPostprocessor : RecommendationPostProcessor { + + override fun apply(original: List): List { + val clusterCounter = mutableMapOf() + + return original.filter { validator -> + if (validator.shouldSkipClusteringFiltering()) return@filter true + + validator.clusterIdentity()?.let { + val currentCounter = clusterCounter.getOrDefault(it, 0) + + clusterCounter[it] = currentCounter + 1 + + currentCounter < MAX_PER_CLUSTER + } ?: true + } + } + + override fun availableIn(runtime: RuntimeSnapshot): Boolean { + return runtime.metadata.hasModule(Modules.IDENTITY) + } + + private fun Validator.clusterIdentity(): OnChainIdentity? { + return when (val validatorIdentity = identity) { + is RootIdentity -> validatorIdentity + is ChildIdentity -> validatorIdentity.parentIdentity + else -> null + } + } + + private fun Validator.shouldSkipClusteringFiltering(): Boolean { + return isNovaValidator + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/sortings/APYSorting.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/sortings/APYSorting.kt new file mode 100644 index 0000000..f444232 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/sortings/APYSorting.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSorting + +object APYSorting : RecommendationSorting by Comparator.comparing({ validator: Validator -> + validator.electedInfo?.apy.orZero() +}).reversed() diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/sortings/TotalStakeSorting.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/sortings/TotalStakeSorting.kt new file mode 100644 index 0000000..347b068 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/sortings/TotalStakeSorting.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSorting + +object TotalStakeSorting : RecommendationSorting by Comparator.comparing({ validator: Validator -> + validator.electedInfo?.totalStake.orZero() +}).reversed() diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/sortings/ValidatorOwnStakeSorting.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/sortings/ValidatorOwnStakeSorting.kt new file mode 100644 index 0000000..cbc1a6f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/recommendations/settings/sortings/ValidatorOwnStakeSorting.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSorting + +object ValidatorOwnStakeSorting : RecommendationSorting by Comparator.comparing({ validator: Validator -> + validator.electedInfo?.ownStake.orZero() +}).reversed() diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/AlephZeroRewardCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/AlephZeroRewardCalculator.kt new file mode 100644 index 0000000..7a3fa1a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/AlephZeroRewardCalculator.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.domain.rewards + +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +class AlephZeroRewardCalculator( + validators: List, + yearlyMint: BigInteger, +) : RewardCalculator { + + private val totalStake = validators.sumByBigInteger { it.totalStake } + + private val apr = yearlyMint.toDouble() / totalStake.toDouble() + + private var apyByValidator = validators.associateBy( + keySelector = { it.accountIdHex }, + valueTransform = { calculateValidatorApy(it.commission) } + ) + + override val expectedAPY: BigDecimal = apyByValidator.values + .average() + .toBigDecimal() + + override val maxAPY: Double = apyByValidator.values.max() + + override fun getApyFor(targetIdHex: String): BigDecimal { + return apyByValidator[targetIdHex]?.toBigDecimal() ?: expectedAPY + } + + private fun calculateValidatorApy(validatorCommission: BigDecimal): Double { + val validatorApr = apr * (1 - validatorCommission.toDouble()) + + return aprToApy(validatorApr) + } +} + +@Suppress("FunctionName") +fun AlephZeroRewardCalculator( + validators: List, + chainAsset: Chain.Asset +): RewardCalculator { + // https://github.com/Cardinal-Cryptography/aleph-node/blob/5acf27dc475767134aeb29b0681768ab93435101/primitives/src/lib.rs#L228 + val yearlyTotalMint = 30_000_000 + val yearlyStakingMint = yearlyTotalMint * 0.9 // 10% goes to treasury + + val yearlyMintPlanks = chainAsset.planksFromAmount(yearlyStakingMint.toBigDecimal()) + + return AlephZeroRewardCalculator( + validators = validators, + yearlyMint = yearlyMintPlanks + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/InflationBasedRewardCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/InflationBasedRewardCalculator.kt new file mode 100644 index 0000000..d639b41 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/InflationBasedRewardCalculator.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_staking_impl.domain.rewards + +import io.novafoundation.nova.common.utils.median +import io.novafoundation.nova.common.utils.sumByBigInteger +import java.math.BigDecimal +import java.math.BigInteger + +private val IGNORED_COMMISSION_THRESHOLD = 1.toBigDecimal() + +abstract class InflationBasedRewardCalculator( + private val validators: List, + private val totalIssuance: BigInteger, +) : RewardCalculator { + + abstract fun calculateYearlyInflation(stakedPortion: Double): Double + + private val totalStaked by lazy { + validators.sumByBigInteger(RewardCalculationTarget::totalStake).toDouble() + } + + private val averageValidatorRewardPercentage by lazy { + val stakedPortion = totalStaked / totalIssuance.toDouble() + val yearlyInflation = calculateYearlyInflation(stakedPortion) + + yearlyInflation / stakedPortion + } + + private val apyByValidator by lazy { + val averageValidatorStake = totalStaked / validators.size + + validators.associateBy( + keySelector = RewardCalculationTarget::accountIdHex, + valueTransform = { calculateValidatorAPY(it, averageValidatorRewardPercentage, averageValidatorStake) } + ) + } + + override val expectedAPY by lazy { + calculateExpectedAPY(averageValidatorRewardPercentage).toBigDecimal() + } + + override val maxAPY by lazy { + apyByValidator.values.maxOrNull() ?: 0.0 + } + + private fun calculateValidatorAPY( + validator: RewardCalculationTarget, + averageValidatorRewardPercentage: Double, + averageValidatorStake: Double, + ): Double { + val yearlyRewardPercentage = averageValidatorRewardPercentage * averageValidatorStake / validator.totalStake.toDouble() + + return yearlyRewardPercentage * (1 - validator.commission.toDouble()) + } + + private fun calculateExpectedAPY( + averageValidatorRewardPercentage: Double + ): Double { + val medianCommission = validators + .filter { it.commission < IGNORED_COMMISSION_THRESHOLD } + .map { it.commission.toDouble() } + .median() + + return averageValidatorRewardPercentage * (1 - medianCommission) + } + + override fun getApyFor(targetIdHex: String): BigDecimal { + return apyByValidator[targetIdHex]?.toBigDecimal() ?: expectedAPY + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/InflationPredictionInfoCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/InflationPredictionInfoCalculator.kt new file mode 100644 index 0000000..d8ed7fb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/InflationPredictionInfoCalculator.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.domain.rewards + +import io.novafoundation.nova.feature_staking_api.domain.model.InflationPredictionInfo +import io.novafoundation.nova.feature_staking_api.domain.model.calculateStakersInflation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlin.time.Duration + +class InflationPredictionInfoCalculator( + private val inflationPredictionInfo: InflationPredictionInfo, + private val eraDuration: Duration, + private val totalIssuance: Balance, + validators: List +) : InflationBasedRewardCalculator(validators, totalIssuance) { + + override fun calculateYearlyInflation(stakedPortion: Double): Double { + return inflationPredictionInfo.calculateStakersInflation(totalIssuance, eraDuration) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculationTarget.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculationTarget.kt new file mode 100644 index 0000000..6909828 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculationTarget.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.domain.rewards + +import java.math.BigDecimal +import java.math.BigInteger + +class RewardCalculationTarget( + val accountIdHex: String, + val totalStake: BigInteger, + val commission: BigDecimal +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculator.kt new file mode 100644 index 0000000..ca9b0fa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculator.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_staking_impl.domain.rewards + +import java.math.BigDecimal + +class PeriodReturns( + val gainAmount: BigDecimal, + val gainFraction: BigDecimal, + // true = APY, false = APR + val isCompound: Boolean, +) + +interface RewardCalculator { + + val expectedAPY: BigDecimal + + val maxAPY: Double + + fun getApyFor(targetIdHex: String): BigDecimal +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculatorExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculatorExt.kt new file mode 100644 index 0000000..0e5a541 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculatorExt.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_staking_impl.domain.rewards + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigDecimal +import kotlin.math.exp +import kotlin.math.pow + +const val DAYS_IN_YEAR = 365 + +suspend fun RewardCalculator.calculateMaxReturns( + amount: BigDecimal, + days: Int, + isCompound: Boolean, +) = withContext(Dispatchers.Default) { + val dailyPercentage = (maxAPY + 1).pow(1.0 / DAYS_IN_YEAR) - 1 + + calculateReward(amount.toDouble(), days, dailyPercentage, isCompound) +} + +private fun calculateReward( + amount: Double, + days: Int, + dailyPercentage: Double, + isCompound: Boolean +): PeriodReturns { + val gainPercentage = if (isCompound) { + calculateCompoundPercentage(days, dailyPercentage) + } else { + calculateSimplePercentage(days, dailyPercentage) + } + + val gainAmount = gainPercentage * amount + + return PeriodReturns( + gainAmount = gainAmount.toBigDecimal(), + gainFraction = gainPercentage.toBigDecimal(), + isCompound = isCompound + ) +} + +private fun calculateCompoundPercentage(days: Int, dailyPercentage: Double): Double { + return (1 + dailyPercentage).pow(days) - 1 +} + +private fun calculateSimplePercentage(days: Int, dailyPercentage: Double): Double { + return dailyPercentage * days +} + +fun aprToApy(apr: Double) = exp(apr) - 1.0 diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculatorFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculatorFactory.kt new file mode 100644 index 0000000..997761a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCalculatorFactory.kt @@ -0,0 +1,155 @@ +package io.novafoundation.nova.feature_staking_impl.domain.rewards + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.ValidatorPrefs +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.repository.ParasRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.VaraRepository +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.electedExposuresInActiveEra +import io.novafoundation.nova.feature_staking_impl.domain.common.eraTimeCalculator +import io.novafoundation.nova.feature_staking_impl.domain.error.accountIdNotFound +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.ALEPH_ZERO +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.MYTHOS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.NOMINATION_POOLS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.PARACHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN_AURA +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.TURING +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.UNSUPPORTED +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class RewardCalculatorFactory( + private val stakingRepository: StakingRepository, + private val totalIssuanceRepository: TotalIssuanceRepository, + private val shareStakingSharedComputation: dagger.Lazy, + private val parasRepository: ParasRepository, + private val varaRepository: VaraRepository, +) { + + suspend fun create( + stakingOption: StakingOption, + exposures: AccountIdMap, + validatorsPrefs: AccountIdMap, + scope: CoroutineScope + ): RewardCalculator = withContext(Dispatchers.Default) { + val totalIssuance = totalIssuanceRepository.getTotalIssuance(stakingOption.assetWithChain.chain.id) + + val validators = exposures.keys.mapNotNull { accountIdHex -> + val exposure = exposures[accountIdHex] ?: accountIdNotFound(accountIdHex) + val validatorPrefs = validatorsPrefs[accountIdHex] ?: return@mapNotNull null + + RewardCalculationTarget( + accountIdHex = accountIdHex, + totalStake = exposure.total, + commission = validatorPrefs.commission + ) + } + + stakingOption.createRewardCalculator(validators, totalIssuance, scope) + } + + suspend fun create(stakingOption: StakingOption, scope: CoroutineScope): RewardCalculator = withContext(Dispatchers.Default) { + val chainId = stakingOption.assetWithChain.chain.id + + val exposures = shareStakingSharedComputation.get().electedExposuresInActiveEra(chainId, scope) + val validatorsPrefs = stakingRepository.getValidatorPrefs(chainId, exposures.keys) + + create(stakingOption, exposures, validatorsPrefs, scope) + } + + private suspend fun StakingOption.createRewardCalculator( + validators: List, + totalIssuance: BigInteger, + scope: CoroutineScope + ): RewardCalculator { + return when (unwrapNominationPools().stakingType) { + RELAYCHAIN, RELAYCHAIN_AURA -> { + val custom = customRelayChainCalculator(validators, totalIssuance, scope) + if (custom != null) return custom + + val activePublicParachains = parasRepository.activePublicParachains(assetWithChain.chain.id) + val inflationConfig = InflationConfig.create(chain.id, activePublicParachains) + + RewardCurveInflationRewardCalculator(validators, totalIssuance, inflationConfig) + } + + ALEPH_ZERO -> AlephZeroRewardCalculator(validators, chainAsset = assetWithChain.asset) + NOMINATION_POOLS, UNSUPPORTED, PARACHAIN, TURING, MYTHOS -> throw IllegalStateException("Unknown staking type in RelaychainRewardFactory") + } + } + + private suspend fun StakingOption.customRelayChainCalculator( + validators: List, + totalIssuance: BigInteger, + scope: CoroutineScope + ): RewardCalculator? { + return when (chain.id) { + Chain.Geneses.VARA -> Vara(chain.id, validators, totalIssuance) + Chain.Geneses.POLKADOT -> PolkadotInflationPrediction(validators, totalIssuance, scope) + else -> null + } + } + + private fun InflationConfig.Companion.create(chainId: ChainId, activePublicParachains: Int?): InflationConfig { + return when (chainId) { + Chain.Geneses.POLKADOT -> Polkadot(activePublicParachains) + Chain.Geneses.AVAIL_TURING_TESTNET, Chain.Geneses.AVAIL -> Avail() + else -> Default(activePublicParachains) + } + } + + private suspend fun Vara( + chainId: ChainId, + validators: List, + totalIssuance: BigInteger + ): RewardCalculator? { + return runCatching { + val inflationInfo = varaRepository.getVaraInflation(chainId) + + VaraRewardCalculator(validators, totalIssuance, inflationInfo) + } + .onFailure { + Log.e(LOG_TAG, "Failed to create Vara reward calculator, fallbacking to default", it) + } + .getOrNull() + } + + private suspend fun StakingOption.PolkadotInflationPrediction( + validators: List, + totalIssuance: BigInteger, + scope: CoroutineScope + ): RewardCalculator? { + return runCatching { + val eraRewardCalculator = shareStakingSharedComputation.get().eraTimeCalculator(this, scope) + val eraDuration = eraRewardCalculator.eraDuration() + + val inflationPredictionInfo = stakingRepository.getInflationPredictionInfo(chain.id) + + InflationPredictionInfoCalculator( + inflationPredictionInfo = inflationPredictionInfo, + eraDuration = eraDuration, + totalIssuance = totalIssuance, + validators = validators + ) + } + .onFailure { + Log.e("RewardCalculatorFactory", "Failed to create Polkadot Inflation Prediction reward calculator, fallbacking to default", it) + } + .getOrNull() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCurveInflationRewardCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCurveInflationRewardCalculator.kt new file mode 100644 index 0000000..c195868 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/RewardCurveInflationRewardCalculator.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_staking_impl.domain.rewards + +import java.math.BigInteger +import kotlin.math.pow + +class InflationConfig( + val falloff: Double, + val maxInflation: Double, + val minInflation: Double, + val stakeTarget: Double, + val parachainAdjust: ParachainAdjust? +) { + + class ParachainAdjust( + val maxParachains: Int, + val activePublicParachains: Int, + val parachainReservedSupplyFraction: Double + ) + + companion object { + + // defaults based on Kusama runtime + fun Default(activePublicParachains: Int?) = InflationConfig( + falloff = 0.05, + maxInflation = 0.1, + minInflation = 0.025, + stakeTarget = 0.75, + parachainAdjust = activePublicParachains?.let { + ParachainAdjust( + maxParachains = 60, + activePublicParachains = activePublicParachains, + parachainReservedSupplyFraction = 0.3 + ) + } + ) + + // Polkadot has different `parachainReservedSupplyFraction` + fun Polkadot(activePublicParachains: Int?) = InflationConfig( + falloff = 0.05, + maxInflation = 0.1, + minInflation = 0.025, + stakeTarget = 0.75, + parachainAdjust = activePublicParachains?.let { + ParachainAdjust( + maxParachains = 60, + activePublicParachains = activePublicParachains, + parachainReservedSupplyFraction = 0.2 + ) + } + ) + + // Source: https://github.com/availproject/avail/blob/main/runtime/src/constants.rs#L223 + fun Avail() = InflationConfig( + falloff = 0.05, + maxInflation = 0.05, + minInflation = 0.01, + stakeTarget = 0.50, + parachainAdjust = null + ) + } +} + +private fun InflationConfig.idealStake(): Double { + val parachainAdjust = if (parachainAdjust != null) { + with(parachainAdjust) { + val cappedActiveParachains = activePublicParachains.coerceAtMost(maxParachains) + + cappedActiveParachains.toDouble() / maxParachains * parachainReservedSupplyFraction + } + } else { + 0.0 + } + + return stakeTarget - parachainAdjust +} + +class RewardCurveInflationRewardCalculator( + validators: List, + totalIssuance: BigInteger, + private val inflationConfig: InflationConfig, +) : InflationBasedRewardCalculator(validators, totalIssuance) { + + override fun calculateYearlyInflation(stakedPortion: Double): Double = with(inflationConfig) { + val idealStake = idealStake() + val idealInterest = maxInflation / idealStake + + val inflation = inflationConfig.minInflation + if (stakedPortion in 0.0..idealStake) { + stakedPortion * (idealInterest - minInflation / idealStake) + } else { + (idealInterest * idealStake - minInflation) * 2.0.pow((idealStake - stakedPortion) / falloff) + } + + inflation + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/VaraRewardCalculator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/VaraRewardCalculator.kt new file mode 100644 index 0000000..5906daa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/rewards/VaraRewardCalculator.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.domain.rewards + +import io.novafoundation.nova.common.utils.Perbill +import java.math.BigInteger + +class VaraRewardCalculator( + validators: List, + totalIssuance: BigInteger, + private val inflation: Perbill +) : InflationBasedRewardCalculator(validators, totalIssuance) { + + override fun calculateYearlyInflation(stakedPortion: Double): Double { + // When calculating era payout, Vara runtime simply divides yearly payout by number of eras in the year + // Which results in `inflation` to correspond to simple returns (APR) + // So, we adjust it to compound returns (APY) + return aprToApy(inflation.value) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/setup/ChangeValidatorsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/setup/ChangeValidatorsInteractor.kt new file mode 100644 index 0000000..c06a9af --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/setup/ChangeValidatorsInteractor.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.domain.setup + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.controllerTransactionOrigin +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.nominate +import io.novafoundation.nova.runtime.ext.multiAddressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ChangeValidatorsInteractor( + private val extrinsicService: ExtrinsicService, + private val stakingSharedState: StakingSharedState, +) { + + suspend fun estimateFee(validatorAccountIds: List, stakingState: StakingState.Stash): Fee { + val chain = stakingSharedState.chain() + + return extrinsicService.estimateFee(chain, stakingState.controllerTransactionOrigin()) { + formExtrinsic(chain, validatorAccountIds) + } + } + + suspend fun changeValidators( + stakingState: StakingState.Stash, + validatorAccountIds: List + ): Result = withContext(Dispatchers.Default) { + val chain = stakingSharedState.chain() + + extrinsicService.submitExtrinsic(chain, stakingState.controllerTransactionOrigin()) { + formExtrinsic(chain, validatorAccountIds) + } + } + + private fun ExtrinsicBuilder.formExtrinsic( + chain: Chain, + validatorAccountIdsHex: List, + ) { + val validatorsIds = validatorAccountIdsHex.map(String::fromHex) + val targets = validatorsIds.map(chain::multiAddressOf) + + nominate(targets) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/bond/BondMoreInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/bond/BondMoreInteractor.kt new file mode 100644 index 0000000..ec78c05 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/bond/BondMoreInteractor.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.bond + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.stashTransactionOrigin +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.bondMore +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingHoldsMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.common.totalStakedPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger +import javax.inject.Inject + +@FeatureScope +class BondMoreInteractor @Inject constructor( + private val extrinsicService: ExtrinsicService, + private val stakingSharedState: StakingSharedState, + private val stakingHoldsMigrationUseCase: StakingHoldsMigrationUseCase, +) { + + suspend fun stakeableAmount(asset: Asset): Balance { + val isMigratedToHolds = stakingHoldsMigrationUseCase.isStakedBalanceMigratedToHolds() + + return if (isMigratedToHolds) { + // Staked amount is counted in reserved - free is already reduced by reserved amount + asset.freeInPlanks + } else { + // Staked amount is counted in frozen - we should substrate staked amount from free + asset.freeInPlanks - asset.totalStakedPlanks + } + } + + suspend fun estimateFee(amount: BigInteger, stakingState: StakingState.Stash): Fee { + return withContext(Dispatchers.IO) { + val chain = stakingSharedState.chain() + + extrinsicService.estimateFee(chain, stakingState.stashTransactionOrigin()) { + bondMore(amount) + } + } + } + + suspend fun bondMore(stashAddress: String, amount: BigInteger): Result { + return withContext(Dispatchers.IO) { + val chain = stakingSharedState.chain() + val accountId = chain.accountIdOf(stashAddress) + + extrinsicService.submitExtrinsic(chain, accountId.intoOrigin()) { + bondMore(amount) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/controller/ControllerInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/controller/ControllerInteractor.kt new file mode 100644 index 0000000..85d5f3b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/controller/ControllerInteractor.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.controller + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.stashTransactionOrigin +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.setController +import io.novafoundation.nova.feature_staking_impl.data.repository.ControllersDeprecationStage +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingVersioningRepository +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.multiAddressOf +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ControllerInteractor( + private val extrinsicService: ExtrinsicService, + private val sharedStakingSate: StakingSharedState, + private val stakingVersioningRepository: StakingVersioningRepository, +) { + + suspend fun controllerDeprecationStage(): ControllersDeprecationStage { + return withContext(Dispatchers.Default) { + val chain = sharedStakingSate.chain() + + stakingVersioningRepository.controllersDeprecationStage(chain.id) + } + } + + suspend fun estimateFee(controllerAccountAddress: String, stakingState: StakingState.Stash): Fee { + return withContext(Dispatchers.IO) { + val chain = sharedStakingSate.chain() + + extrinsicService.estimateFee(chain, stakingState.stashTransactionOrigin()) { + setController(chain.multiAddressOf(controllerAccountAddress)) + } + } + } + + suspend fun setController(stashAccountAddress: String, controllerAccountAddress: String): Result { + return withContext(Dispatchers.IO) { + val chain = sharedStakingSate.chain() + val accountId = chain.accountIdOf(stashAccountAddress) + + extrinsicService.submitExtrinsic(chain, accountId.intoOrigin()) { + setController(chain.multiAddressOf(controllerAccountAddress)) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/AddStakingProxyInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/AddStakingProxyInteractor.kt new file mode 100644 index 0000000..f76fbd6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/AddStakingProxyInteractor.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy + +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface AddStakingProxyInteractor { + + suspend fun estimateFee(chain: Chain, proxiedAccountId: AccountId): Fee + + suspend fun addProxy(chain: Chain, proxiedAccountId: AccountId, proxyAccountId: AccountId): Result> + + suspend fun calculateDeltaDepositForAddProxy(chain: Chain, accountId: AccountId): Balance +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/RealAddStakingProxyInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/RealAddStakingProxyInteractor.kt new file mode 100644 index 0000000..54c2bd2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/RealAddStakingProxyInteractor.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_proxy_api.data.calls.addProxyCall +import io.novafoundation.nova.feature_proxy_api.data.common.ProxyDepositCalculator +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.data.repository.ProxyConstantsRepository +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.emptyAccountId +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class RealAddStakingProxyInteractor( + private val extrinsicService: ExtrinsicService, + private val proxyDepositCalculator: ProxyDepositCalculator, + private val getProxyRepository: GetProxyRepository, + private val proxyConstantsRepository: ProxyConstantsRepository, + private val externalAccountsSyncService: ExternalAccountsSyncService, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper +) : AddStakingProxyInteractor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + override suspend fun estimateFee(chain: Chain, proxiedAccountId: AccountId): Fee { + return withContext(Dispatchers.IO) { + extrinsicService.estimateFee(chain, proxiedAccountId.intoOrigin()) { + addProxyCall(chain.emptyAccountId(), ProxyType.Staking) + } + } + } + + override suspend fun addProxy(chain: Chain, proxiedAccountId: AccountId, proxyAccountId: AccountId): Result> { + return withContext(Dispatchers.Default) { + val result = extrinsicService.submitAndWatchExtrinsic(chain, proxiedAccountId.intoOrigin()) { + addProxyCall(proxyAccountId, ProxyType.Staking) + } + + result.awaitInBlock().also { externalAccountsSyncService.sync() } + } + } + + override suspend fun calculateDeltaDepositForAddProxy(chain: Chain, accountId: AccountId): Balance { + val depositConstants = proxyConstantsRepository.getDepositConstants(chain.id) + val currentProxiesCount = getProxyRepository.getProxiesQuantity(chain.id, accountId) + val oldDeposit = proxyDepositCalculator.calculateProxyDepositForQuantity(depositConstants, currentProxiesCount) + val newDeposit = proxyDepositCalculator.calculateProxyDepositForQuantity(depositConstants, currentProxiesCount + 1) + return newDeposit - oldDeposit + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/list/RealStakingProxyListInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/list/RealStakingProxyListInteractor.kt new file mode 100644 index 0000000..257b3d2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/list/RealStakingProxyListInteractor.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.list + +import io.novafoundation.nova.feature_account_api.domain.account.identity.IdentityProvider +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.list.model.StakingProxyAccount +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface StakingProxyListInteractor { + fun stakingProxyListFlow(chain: Chain, accountId: AccountId): Flow> +} + +class RealStakingProxyListInteractor( + val getProxyRepository: GetProxyRepository, + val identityProvider: IdentityProvider +) : StakingProxyListInteractor { + + override fun stakingProxyListFlow(chain: Chain, accountId: AccountId): Flow> { + return getProxyRepository.proxiesByTypeFlow(chain, accountId, ProxyType.Staking) + .map { proxies -> + val proxiesAccountIds = proxies.map { it.proxyAccountId.value } + val proxyIdentities = identityProvider.identitiesFor(proxiesAccountIds, chain.id) + proxies.map { proxy -> + val proxyAccountId = proxy.proxyAccountId + val identity = proxyIdentities[proxyAccountId] + StakingProxyAccount( + identity?.name ?: chain.addressOf(proxyAccountId.value), + proxyAccountId.value + ) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/list/model/StakingProxyAccount.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/list/model/StakingProxyAccount.kt new file mode 100644 index 0000000..372aee9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/list/model/StakingProxyAccount.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.list.model + +import io.novasama.substrate_sdk_android.runtime.AccountId + +class StakingProxyAccount( + val accountName: String, + val proxyAccountId: AccountId +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/remove/RemoveStakingProxyInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/remove/RemoveStakingProxyInteractor.kt new file mode 100644 index 0000000..45a3d29 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/delegation/proxy/remove/RemoveStakingProxyInteractor.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.remove + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.externalAccounts.ExternalAccountsSyncService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.awaitInBlock +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.ExtrinsicWatchResult +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_proxy_api.data.calls.removeProxyCall +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.runtime.ext.emptyAccountId +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface RemoveStakingProxyInteractor { + + suspend fun estimateFee(chain: Chain, proxiedAccountId: AccountId): Fee + + suspend fun removeProxy(chain: Chain, proxiedAccountId: AccountId, proxyAccountId: AccountId): Result> +} + +class RealRemoveStakingProxyInteractor( + private val extrinsicService: ExtrinsicService, + private val externalAccountsSyncService: ExternalAccountsSyncService, +) : RemoveStakingProxyInteractor { + + override suspend fun estimateFee(chain: Chain, proxiedAccountId: AccountId): Fee { + return withContext(Dispatchers.IO) { + extrinsicService.estimateFee(chain, proxiedAccountId.intoOrigin()) { + removeProxyCall(chain.emptyAccountId(), ProxyType.Staking) + } + } + } + + override suspend fun removeProxy( + chain: Chain, + proxiedAccountId: AccountId, + proxyAccountId: AccountId + ): Result> { + return withContext(Dispatchers.Default) { + val result = extrinsicService.submitAndWatchExtrinsic(chain, proxiedAccountId.intoOrigin()) { + removeProxyCall(proxyAccountId, ProxyType.Staking) + } + + result.awaitInBlock().also { externalAccountsSyncService.sync() } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/rebond/RebondInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/rebond/RebondInteractor.kt new file mode 100644 index 0000000..c5859a0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/rebond/RebondInteractor.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.rebond + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.controllerTransactionOrigin +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.rebond +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class RebondInteractor( + private val extrinsicService: ExtrinsicService, + private val sharedStakingSate: StakingSharedState +) { + + suspend fun estimateFee(amount: BigInteger, stakingState: StakingState.Stash): Fee { + return withContext(Dispatchers.IO) { + val chain = sharedStakingSate.chain() + + extrinsicService.estimateFee(chain, stakingState.controllerTransactionOrigin()) { + rebond(amount) + } + } + } + + suspend fun rebond(stashState: StakingState.Stash, amount: BigInteger): Result { + return withContext(Dispatchers.IO) { + extrinsicService.submitExtrinsic(stashState.chain, stashState.controllerTransactionOrigin()) { + rebond(amount) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/redeem/RedeemConsequences.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/redeem/RedeemConsequences.kt new file mode 100644 index 0000000..93694aa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/redeem/RedeemConsequences.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.redeem + +class RedeemConsequences( + val willKillStash: Boolean +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/redeem/RedeemInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/redeem/RedeemInteractor.kt new file mode 100644 index 0000000..2af0a42 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/redeem/RedeemInteractor.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.redeem + +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.numberOfSlashingSpans +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.controllerTransactionOrigin +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.withdrawUnbonded +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class RedeemInteractor( + private val extrinsicService: ExtrinsicService, + private val stakingRepository: StakingRepository, +) { + + suspend fun estimateFee(stakingState: StakingState.Stash): Fee { + return withContext(Dispatchers.IO) { + extrinsicService.estimateFee(stakingState.chain, stakingState.controllerTransactionOrigin()) { + withdrawUnbonded(getSlashingSpansNumber(stakingState)) + } + } + } + + suspend fun redeem(stakingState: StakingState.Stash, asset: Asset): Result> { + return withContext(Dispatchers.IO) { + extrinsicService.submitExtrinsic(stakingState.chain, stakingState.controllerTransactionOrigin()) { + withdrawUnbonded(getSlashingSpansNumber(stakingState)) + }.map { + it to RedeemConsequences(willKillStash = asset.isRedeemingAll()) + } + } + } + + private fun Asset.isRedeemingAll(): Boolean { + return bonded.isZero && unbonding.isZero + } + + private suspend fun getSlashingSpansNumber(stakingState: StakingState.Stash): BigInteger { + return stakingRepository.getSlashingSpan(stakingState.chain.id, stakingState.stashId).numberOfSlashingSpans() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/rewardDestination/ChangeRewardDestinationInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/rewardDestination/ChangeRewardDestinationInteractor.kt new file mode 100644 index 0000000..4e7d08e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/rewardDestination/ChangeRewardDestinationInteractor.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.rewardDestination + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.controllerTransactionOrigin +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.setPayee +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ChangeRewardDestinationInteractor( + private val extrinsicService: ExtrinsicService +) { + + suspend fun estimateFee( + stashState: StakingState.Stash, + rewardDestination: RewardDestination, + ): Fee = withContext(Dispatchers.IO) { + extrinsicService.estimateFee(stashState.chain, stashState.controllerTransactionOrigin()) { + setPayee(rewardDestination) + } + } + + suspend fun changeRewardDestination( + stashState: StakingState.Stash, + rewardDestination: RewardDestination, + ): Result = withContext(Dispatchers.IO) { + extrinsicService.submitExtrinsic(stashState.chain, stashState.controllerTransactionOrigin()) { + setPayee(rewardDestination) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/NominationPoolsAvailableBalanceResolver.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/NominationPoolsAvailableBalanceResolver.kt new file mode 100644 index 0000000..cefc38c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/NominationPoolsAvailableBalanceResolver.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common + +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.NominationPoolsAvailableBalanceResolver.MaxBalanceToStake +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset + +interface NominationPoolsAvailableBalanceResolver { + + suspend fun availableBalanceToStartStaking(asset: Asset): Balance + + suspend fun maximumBalanceToStake(asset: Asset, fee: Balance): MaxBalanceToStake + + suspend fun maximumBalanceToStake(asset: Asset): Balance + + class MaxBalanceToStake(val maxToStake: Balance, val existentialDeposit: Balance) +} + +class RealNominationPoolsAvailableBalanceResolver( + private val walletConstants: WalletConstants +) : NominationPoolsAvailableBalanceResolver { + + override suspend fun availableBalanceToStartStaking(asset: Asset): Balance { + return asset.transferableInPlanks + } + + override suspend fun maximumBalanceToStake(asset: Asset, fee: Balance): MaxBalanceToStake { + val existentialDeposit = walletConstants.existentialDeposit(asset.token.configuration.chainId) + + val maxToStake = minOf(asset.transferableInPlanks, asset.balanceCountedTowardsEDInPlanks - existentialDeposit) - fee + + return MaxBalanceToStake(maxToStake.atLeastZero(), existentialDeposit) + } + + override suspend fun maximumBalanceToStake(asset: Asset): Balance { + val existentialDeposit = walletConstants.existentialDeposit(asset.token.configuration.chainId) + return minOf(asset.transferableInPlanks, asset.balanceCountedTowardsEDInPlanks - existentialDeposit) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/StakingStartedDetectionService.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/StakingStartedDetectionService.kt new file mode 100644 index 0000000..e947675 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/StakingStartedDetectionService.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.MultiStakingOptionIds +import io.novafoundation.nova.feature_staking_impl.data.dashboard.model.StakingDashboardItem +import io.novafoundation.nova.feature_staking_impl.data.dashboard.repository.StakingDashboardRepository +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService.DetectionState +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.transform + +interface StakingStartedDetectionService { + + enum class DetectionState { + ACTIVE, PAUSED + } + + fun observeStatingStarted(stakingOptionIds: MultiStakingOptionIds, screenScope: CoroutineScope): Flow + + suspend fun setDetectionState(state: DetectionState, screenScope: CoroutineScope) +} + +suspend fun StakingStartedDetectionService.activateDetection(screenScope: CoroutineScope) { + setDetectionState(DetectionState.ACTIVE, screenScope) +} + +suspend fun StakingStartedDetectionService.pauseDetection(screenScope: CoroutineScope) { + setDetectionState(DetectionState.PAUSED, screenScope) +} + +suspend fun StakingStartedDetectionService.awaitStakingStarted(stakingOptionIds: MultiStakingOptionIds, screenScope: CoroutineScope): Chain { + return observeStatingStarted(stakingOptionIds, screenScope).first() +} + +private const val CACHE_KEY = "RealStakingStartedDetectionService.detectionActiveState" + +class RealStakingStartedDetectionService( + private val stakingDashboardRepository: StakingDashboardRepository, + private val computationalCache: ComputationalCache, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, +) : StakingStartedDetectionService { + + override fun observeStatingStarted( + stakingOptionIds: MultiStakingOptionIds, + screenScope: CoroutineScope + ): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { account -> + stakingDashboardRepository.dashboardItemsFlow(account.id, stakingOptionIds) + .transform { dashboardItems -> + val hasAnyStake = dashboardItems.any { item -> item.stakeState is StakingDashboardItem.StakeState.HasStake } + + if (hasAnyStake) { + val chain = chainRegistry.getChain(stakingOptionIds.chainId) + + awaitDetectionActive(screenScope) + + emit(chain) + } + } + } + } + + override suspend fun setDetectionState(state: DetectionState, screenScope: CoroutineScope) { + detectionActiveState(screenScope).value = state + } + + private suspend fun awaitDetectionActive(screenScope: CoroutineScope) { + detectionActiveState(screenScope).first { it == DetectionState.ACTIVE } + } + + private suspend fun detectionActiveState(scope: CoroutineScope): MutableStateFlow { + return computationalCache.useCache(CACHE_KEY, scope) { + MutableStateFlow(DetectionState.ACTIVE) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/StartMultiStakingInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/StartMultiStakingInteractor.kt new file mode 100644 index 0000000..e0c6e5a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/StartMultiStakingInteractor.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +interface StartMultiStakingInteractor { + + suspend fun calculateFee(selection: StartMultiStakingSelection): Fee + + suspend fun startStaking(selection: StartMultiStakingSelection): Result +} + +class RealStartMultiStakingInteractor( + private val extrinsicService: ExtrinsicService, + private val accountRepository: AccountRepository, +) : StartMultiStakingInteractor { + + override suspend fun calculateFee(selection: StartMultiStakingSelection): Fee { + return withContext(Dispatchers.IO) { + extrinsicService.estimateFee(selection.stakingOption.chain, TransactionOrigin.SelectedWallet) { + startStaking(selection) + } + } + } + + override suspend fun startStaking(selection: StartMultiStakingSelection): Result { + return withContext(Dispatchers.IO) { + extrinsicService.submitExtrinsicAndAwaitExecution(selection.stakingOption.chain, TransactionOrigin.SelectedWallet) { + startStaking(selection) + }.requireOk() + } + } + + private suspend fun ExtrinsicBuilder.startStaking(selection: StartMultiStakingSelection) { + val account = accountRepository.getSelectedMetaAccount() + + with(selection) { + startStaking(account) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/StartMultiStakingSelection.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/StartMultiStakingSelection.kt new file mode 100644 index 0000000..95a075a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/StartMultiStakingSelection.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.asset +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingProperties +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools.NominationPoolSelection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import java.math.BigDecimal + +interface StartMultiStakingSelection { + + val stakingOption: StakingOption + + val apy: Fraction? + + val stake: Balance + + fun ExtrinsicBuilder.startStaking(metaAccount: MetaAccount) + + fun isSettingsEquals(other: StartMultiStakingSelection): Boolean + + fun copyWith(stake: Balance): StartMultiStakingSelection +} + +sealed class SelectionTypeSource { + + object Automatic : SelectionTypeSource() + + data class Manual(val contentRecommended: Boolean) : SelectionTypeSource() +} + +data class RecommendableMultiStakingSelection( + val source: SelectionTypeSource, + val selection: StartMultiStakingSelection, + val properties: SingleStakingProperties +) + +fun StartMultiStakingSelection.copyWith(newAmount: BigDecimal) = copyWith( + stake = stakingOption.asset.planksFromAmount(newAmount) +) + +fun RecommendableMultiStakingSelection.copyWith(newAmount: Balance) = copy( + selection = selection.copyWith(newAmount) +) + +fun RecommendableMultiStakingSelection.copyWith(newAmount: BigDecimal) = copy( + selection = selection.copyWith(newAmount) +) + +val SelectionTypeSource.isRecommended: Boolean + get() = when (this) { + SelectionTypeSource.Automatic -> true + is SelectionTypeSource.Manual -> contentRecommended + } + +fun StartMultiStakingSelection.stakeAmount(): BigDecimal = stakingOption.asset.amountFromPlanks(stake) + +val RecommendableMultiStakingSelection.isNominationPoolSelection: Boolean + get() = selection is NominationPoolSelection diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/store/StartMultiStakingSelectionStore.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/store/StartMultiStakingSelectionStore.kt new file mode 100644 index 0000000..9405cba --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/store/StartMultiStakingSelectionStore.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store + +import io.novafoundation.nova.common.utils.selectionStore.MutableSelectionStore +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.RecommendableMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.direct.DirectStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools.NominationPoolSelection +import java.math.BigInteger + +class StartMultiStakingSelectionStore : MutableSelectionStore() { + + fun updateStake(amount: BigInteger) { + val currentSelection = getCurrentSelection() + currentSelection?.let { + currentSelectionFlow.value = currentSelection.copy( + selection = currentSelection.selection.copyWith(amount) + ) + } + } +} + +fun StartMultiStakingSelectionStore.getValidatorsOrEmpty(): List { + val selection = getCurrentSelection()?.selection + return if (selection is DirectStakingSelection) { + selection.validators + } else { + emptyList() + } +} + +fun StartMultiStakingSelectionStore.getPoolOrNull(): NominationPool? { + val selection = getCurrentSelection()?.selection + return if (selection is NominationPoolSelection) { + selection.pool + } else { + return null + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/store/StartMultiStakingSelectionStoreProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/store/StartMultiStakingSelectionStoreProvider.kt new file mode 100644 index 0000000..c3f4312 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/store/StartMultiStakingSelectionStoreProvider.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.selectionStore.ComputationalCacheSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.RecommendableMultiStakingSelection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +fun StartMultiStakingSelectionStoreProvider.currentSelectionFlow(scope: CoroutineScope): Flow { + return flowOfAll { + getSelectionStore(scope).currentSelectionFlow + } +} + +class StartMultiStakingSelectionStoreProvider( + computationalCache: ComputationalCache, + key: String +) : ComputationalCacheSelectionStoreProvider(computationalCache, key) { + + protected override fun initSelectionStore(): StartMultiStakingSelectionStore { + return StartMultiStakingSelectionStore() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/store/model/MultiStakingType.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/store/model/MultiStakingType.kt new file mode 100644 index 0000000..aabe55e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/selection/store/model/MultiStakingType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.model + +enum class MultiStakingType { + DIRECT, POOL +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/CompoundStakingTypeDetailsProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/CompoundStakingTypeDetailsProvider.kt new file mode 100644 index 0000000..e093257 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/CompoundStakingTypeDetailsProvider.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.combine +import io.novafoundation.nova.feature_staking_impl.data.createStakingOption +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypePayload +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypeValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.model.ValidatedStakingTypeDetails +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class CompoundStakingTypeDetailsProvidersFactory( + private val factories: Map +) { + + suspend fun create( + computationalScope: ComputationalScope, + chainWithAsset: ChainWithAsset, + availableStakingTypes: List + ): CompoundStakingTypeDetailsProviders { + val providers = chainWithAsset.asset.staking.mapNotNull { stakingType -> + val supportedFactory = factories[stakingType.group()] + supportedFactory?.create(createStakingOption(chainWithAsset, stakingType), computationalScope, availableStakingTypes) + } + + return CompoundStakingTypeDetailsProviders(providers) + } +} + +class CompoundStakingTypeDetailsProviders(private val providers: List) { + + fun getStakingTypeDetails(): Flow> { + return providers.map { it.stakingTypeDetails } + .combine() + } + + fun getValidationSystem(stakingType: Chain.Asset.StakingType): EditingStakingTypeValidationSystem { + return providers.first { it.stakingType == stakingType } + .getValidationSystem() + } + + suspend fun getValidationPayload(stakingType: Chain.Asset.StakingType): EditingStakingTypePayload? { + return providers.first { it.stakingType == stakingType } + .getValidationPayload() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/StakingTypeDetails.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/StakingTypeDetails.kt new file mode 100644 index 0000000..f31ed0d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/StakingTypeDetails.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_staking_impl.domain.model.PayoutType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +class StakingTypeDetails( + val maxEarningRate: Fraction, + val minStake: BigInteger, + val payoutType: PayoutType, + val participationInGovernance: Boolean, + val advancedOptionsAvailable: Boolean, + val stakingType: Chain.Asset.StakingType +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/StakingTypeDetailsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/StakingTypeDetailsInteractor.kt new file mode 100644 index 0000000..171707f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/StakingTypeDetailsInteractor.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +interface StakingTypeDetailsInteractor { + + fun observeData(): Flow + + suspend fun getAvailableBalance(asset: Asset): BigInteger +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/StakingTypeDetailsProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/StakingTypeDetailsProvider.kt new file mode 100644 index 0000000..9055982 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/StakingTypeDetailsProvider.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingRecommendation +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypePayload +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypeValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.model.ValidatedStakingTypeDetails +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +interface StakingTypeDetailsProviderFactory { + + suspend fun create( + stakingOption: StakingOption, + computationalScope: ComputationalScope, + availableStakingTypes: List + ): StakingTypeDetailsProvider +} + +interface StakingTypeDetailsProvider { + + val stakingType: Chain.Asset.StakingType + + val stakingTypeDetails: Flow + + val recommendationProvider: SingleStakingRecommendation + + fun getValidationSystem(): EditingStakingTypeValidationSystem + + suspend fun getValidationPayload(): EditingStakingTypePayload? +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/MythosStakingTypeDetailsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/MythosStakingTypeDetailsInteractor.kt new file mode 100644 index 0000000..82bd74d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/MythosStakingTypeDetailsInteractor.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.model.PayoutType +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetails +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsInteractor +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class MythosStakingTypeDetailsInteractorFactory( + private val mythosSharedComputation: MythosSharedComputation, +) : StakingTypeDetailsInteractorFactory { + + override suspend fun create( + stakingOption: StakingOption, + computationalScope: ComputationalScope + ): StakingTypeDetailsInteractor { + return MythosStakingTypeDetailsInteractor( + mythosSharedComputation = mythosSharedComputation, + stakingOption = stakingOption, + computationalScope = computationalScope + ) + } +} + +private class MythosStakingTypeDetailsInteractor( + private val mythosSharedComputation: MythosSharedComputation, + private val stakingOption: StakingOption, + computationalScope: ComputationalScope +) : StakingTypeDetailsInteractor, ComputationalScope by computationalScope { + + override fun observeData(): Flow { + return flowOfAll { + val chain = stakingOption.chain + + val rewardCalculator = mythosSharedComputation.rewardCalculator(chain.id) + + mythosSharedComputation.minStakeFlow(chain.id).map { minStake -> + StakingTypeDetails( + maxEarningRate = rewardCalculator.maxApr, + minStake = minStake, + payoutType = PayoutType.Manual, + participationInGovernance = false, + advancedOptionsAvailable = false, + stakingType = stakingOption.stakingType + ) + } + } + } + + override suspend fun getAvailableBalance(asset: Asset): BigInteger { + return asset.freeInPlanks + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/ParachainStakingTypeDetailsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/ParachainStakingTypeDetailsInteractor.kt new file mode 100644 index 0000000..8ef9ad8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/ParachainStakingTypeDetailsInteractor.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.model.PayoutType +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.ParachainNetworkInfoInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rewards.ParachainStakingRewardCalculator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rewards.ParachainStakingRewardCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.rewards.DAYS_IN_YEAR +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetails +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsInteractor +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class ParachainStakingTypeDetailsInteractorFactory( + private val parachainNetworkInfoInteractor: ParachainNetworkInfoInteractor, + private val parachainStakingRewardCalculatorFactory: ParachainStakingRewardCalculatorFactory +) : StakingTypeDetailsInteractorFactory { + + override suspend fun create( + stakingOption: StakingOption, + computationalScope: ComputationalScope + ): ParachainStakingTypeDetailsInteractor { + return ParachainStakingTypeDetailsInteractor( + parachainNetworkInfoInteractor, + parachainStakingRewardCalculatorFactory.create(stakingOption), + stakingOption, + ) + } +} + +class ParachainStakingTypeDetailsInteractor( + private val parachainNetworkInfoInteractor: ParachainNetworkInfoInteractor, + private val parachainStakingRewardCalculator: ParachainStakingRewardCalculator, + private val stakingOption: StakingOption, +) : StakingTypeDetailsInteractor { + + override fun observeData(): Flow { + val chain = stakingOption.chain + + return parachainNetworkInfoInteractor.observeRoundInfo(chain.id).map { activeEraInfo -> + StakingTypeDetails( + maxEarningRate = parachainStakingRewardCalculator.maximumGain(DAYS_IN_YEAR).fractions, + minStake = activeEraInfo.minimumStake, + payoutType = PayoutType.Automatically.Payout, + participationInGovernance = chain.governance.isNotEmpty(), + advancedOptionsAvailable = true, + stakingType = stakingOption.stakingType + ) + } + } + + override suspend fun getAvailableBalance(asset: Asset): BigInteger { + return asset.freeInPlanks + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/RelaychainStakingTypeDetailsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/RelaychainStakingTypeDetailsInteractor.kt new file mode 100644 index 0000000..69e7c04 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/RelaychainStakingTypeDetailsInteractor.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.components +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.model.PayoutType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetails +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsInteractor +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class RelaychainStakingTypeDetailsInteractorFactory( + private val stakingSharedComputation: StakingSharedComputation, +) : StakingTypeDetailsInteractorFactory { + + override suspend fun create( + stakingOption: StakingOption, + computationalScope: ComputationalScope + ): StakingTypeDetailsInteractor { + return RelaychainStakingTypeDetailsInteractor( + stakingSharedComputation, + stakingOption, + computationalScope, + ) + } +} + +class RelaychainStakingTypeDetailsInteractor( + private val stakingSharedComputation: StakingSharedComputation, + private val stakingOption: StakingOption, + private val computationalScope: ComputationalScope, +) : StakingTypeDetailsInteractor { + + override fun observeData(): Flow { + val chain = stakingOption.chain + + return stakingSharedComputation.activeEraInfo(chain.id, computationalScope).map { activeEraInfo -> + StakingTypeDetails( + maxEarningRate = calculateEarningRate(), + minStake = activeEraInfo.minStake, + payoutType = PayoutType.Automatically.Restake, + participationInGovernance = chain.governance.isNotEmpty(), + advancedOptionsAvailable = true, + stakingType = stakingOption.stakingType + ) + } + } + + override suspend fun getAvailableBalance(asset: Asset): BigInteger { + return asset.freeInPlanks + } + + private suspend fun calculateEarningRate(): Fraction { + val (chain, chainAsset, stakingType) = stakingOption.components + + return stakingSharedComputation.rewardCalculator(chain, chainAsset, stakingType, computationalScope) + .maxAPY + .fractions + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/StakingTypeDetailsInteractorFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/StakingTypeDetailsInteractorFactory.kt new file mode 100644 index 0000000..640ae94 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/direct/StakingTypeDetailsInteractorFactory.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsInteractor + +interface StakingTypeDetailsInteractorFactory { + + suspend fun create(stakingOption: StakingOption, computationalScope: ComputationalScope): StakingTypeDetailsInteractor +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/pools/PoolStakingTypeDetailsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/pools/PoolStakingTypeDetailsInteractor.kt new file mode 100644 index 0000000..9eb2d97 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/types/pools/PoolStakingTypeDetailsInteractor.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.pools + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.model.PayoutType +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.NominationPoolsAvailableBalanceResolver +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetails +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct.StakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +class PoolStakingTypeDetailsInteractorFactory( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver +) : StakingTypeDetailsInteractorFactory { + + override suspend fun create( + stakingOption: StakingOption, + computationalScope: ComputationalScope + ): PoolStakingTypeDetailsInteractor { + return PoolStakingTypeDetailsInteractor( + nominationPoolSharedComputation, + poolsAvailableBalanceResolver, + stakingOption, + computationalScope + ) + } +} + +class PoolStakingTypeDetailsInteractor( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver, + private val stakingOption: StakingOption, + private val scope: ComputationalScope, +) : StakingTypeDetailsInteractor { + + override fun observeData(): Flow { + return flowOf { + val rewardCalculator = nominationPoolSharedComputation.poolRewardCalculator(stakingOption, scope) + val minJoinBond = nominationPoolSharedComputation.minJoinBond(stakingOption.chain.id, scope) + + StakingTypeDetails( + maxEarningRate = rewardCalculator.maxAPY, + minStake = minJoinBond, + payoutType = PayoutType.Manual, + participationInGovernance = false, + advancedOptionsAvailable = false, + stakingType = stakingOption.stakingType + ) + } + } + + override suspend fun getAvailableBalance(asset: Asset): BigInteger { + return poolsAvailableBalanceResolver.availableBalanceToStartStaking(asset) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/AvailableBalanceGapValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/AvailableBalanceGapValidation.kt new file mode 100644 index 0000000..567185e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/AvailableBalanceGapValidation.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount +import io.novafoundation.nova.feature_staking_impl.data.asset +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingProperties +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.maximumToStake +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import java.math.BigDecimal + +class AvailableBalanceGapValidation( + private val candidates: List, + private val locksRepository: BalanceLocksRepository, +) : StartMultiStakingValidation { + + override suspend fun validate(value: StartMultiStakingValidationPayload): ValidationStatus { + val amount = value.selection.stake + val stakingOption = value.selection.stakingOption + val fee = value.fee.amountByExecutingAccount + + val maxToStakeWithMinStakes = candidates.map { + val maximumToStake = it.maximumToStake(value.asset, fee) + + maximumToStake to it.minStake() + } + + // check against global maximum + val globalMaxToStake = maxToStakeWithMinStakes.maxOf { (maxToStake) -> maxToStake } + if (globalMaxToStake == BigDecimal.ZERO || amount > globalMaxToStake) { + return leaveForFurtherValidations() + } + + // check against currently selected maximum + val selectedCandidate = candidates.first { it.stakingType == stakingOption.stakingType } + val selectedCandidateMaxToStakeBalance = selectedCandidate.maximumToStake(value.asset, fee) + + if (amount > selectedCandidateMaxToStakeBalance) { + val biggestLock = locksRepository.getBiggestLock(stakingOption.chain, stakingOption.asset) + // in case no locks we found we let type-specific validations handle it + ?: return leaveForFurtherValidations() + + // we're sure such item exists due to global maximum check before + val (_, matchingAlternativeMinStake) = maxToStakeWithMinStakes.first { (maxToSTake, _) -> amount <= maxToSTake } + + return validationError( + StartMultiStakingValidationFailure.AvailableBalanceGap( + currentMaxAvailable = selectedCandidateMaxToStakeBalance, + alternativeMinStake = matchingAlternativeMinStake, + chainAsset = stakingOption.asset, + biggestLockId = biggestLock.id + ) + ) + } + + return valid() + } + + private fun leaveForFurtherValidations(): ValidationStatus { + return valid() + } +} + +fun StartMultiStakingValidationSystemBuilder.availableBalanceGapValidation( + candidates: List, + locksRepository: BalanceLocksRepository, +) { + validate(AvailableBalanceGapValidation(candidates, locksRepository)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationFailure.kt new file mode 100644 index 0000000..da9612f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationFailure.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations + +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolAvailableBalanceValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.StakingMinimumBondError +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class StartMultiStakingValidationFailure { + + class NotEnoughToPayFees( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : StartMultiStakingValidationFailure(), NotEnoughToPayFeesError + + object NonPositiveAmount : StartMultiStakingValidationFailure() + + object NotEnoughAvailableToStake : StartMultiStakingValidationFailure() + + class AmountLessThanMinimum(override val context: StakingMinimumBondError.Context) : StartMultiStakingValidationFailure(), StakingMinimumBondError + + class MaxNominatorsReached(val stakingType: Chain.Asset.StakingType) : StartMultiStakingValidationFailure() + + class AvailableBalanceGap( + val currentMaxAvailable: Balance, + val alternativeMinStake: Balance, + val biggestLockId: BalanceLockId, + val chainAsset: Chain.Asset, + ) : StartMultiStakingValidationFailure() + + class PoolAvailableBalance( + override val context: PoolAvailableBalanceValidation.ValidationError.Context + ) : PoolAvailableBalanceValidation.ValidationError, StartMultiStakingValidationFailure() + + object InactivePool : StartMultiStakingValidationFailure() + + object HasConflictingStakingType : StartMultiStakingValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationFailureUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationFailureUi.kt new file mode 100644 index 0000000..cc5e6ec --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationFailureUi.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.asDefault +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.handlePoolAvailableBalanceError +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.copyWith +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure.AmountLessThanMinimum +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure.AvailableBalanceGap +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure.HasConflictingStakingType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure.InactivePool +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure.MaxNominatorsReached +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure.NonPositiveAmount +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure.NotEnoughAvailableToStake +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure.NotEnoughToPayFees +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.handleStakingMinimumBondError +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.formatStakingTypeLabel +import io.novafoundation.nova.feature_wallet_api.domain.validation.amountIsTooBig +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.domain.validation.zeroAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.mapBalanceIdToUi +import java.math.BigDecimal + +fun handleStartMultiStakingValidationFailure( + status: ValidationStatus.NotValid, + resourceManager: ResourceManager, + flowActions: ValidationFlowActions, + updateAmountInUi: (BigDecimal) -> Unit, +): TransformedFailure { + return when (val reason = status.reason) { + is AmountLessThanMinimum -> handleStakingMinimumBondError(resourceManager, reason).asDefault() + + is NotEnoughToPayFees -> handleNotEnoughFeeError(reason, resourceManager).asDefault() + + is MaxNominatorsReached -> { + val stakingTypeLabel = resourceManager.formatStakingTypeLabel(reason.stakingType) + + TransformedFailure.Default( + resourceManager.getString(R.string.start_staking_max_nominators_reached_title, stakingTypeLabel) to + resourceManager.getString(R.string.start_staking_max_nominators_reached_message) + ) + } + + NotEnoughAvailableToStake -> resourceManager.amountIsTooBig().asDefault() + + NonPositiveAmount -> resourceManager.zeroAmount().asDefault() + + is AvailableBalanceGap -> { + val lockDisplay = mapBalanceIdToUi(resourceManager, reason.biggestLockId.value) + val currentMaxAvailable = reason.currentMaxAvailable.formatPlanks(reason.chainAsset) + val alternativeMinStake = reason.alternativeMinStake.formatPlanks(reason.chainAsset) + + TransformedFailure.Default( + resourceManager.getString(R.string.start_staking_cant_stake_amount) to + resourceManager.getString( + R.string.start_staking_available_balance_gap_message, + lockDisplay, + currentMaxAvailable, + alternativeMinStake, + lockDisplay + ) + ) + } + + InactivePool -> TransformedFailure.Default( + resourceManager.getString(R.string.staking_parachain_wont_receive_rewards_title) to + resourceManager.getString(R.string.start_staking_inactive_pool_message) + ) + + is StartMultiStakingValidationFailure.PoolAvailableBalance -> handlePoolAvailableBalanceError( + error = reason, + resourceManager = resourceManager, + flowActions = flowActions, + modifyPayload = { oldPayload, _ -> + val newSelection = oldPayload.recommendableSelection.copyWith(reason.context.maximumToStake) + + oldPayload.copy(recommendableSelection = newSelection) + }, + updateAmountInUi = updateAmountInUi + ) + + is HasConflictingStakingType -> handleStartStakingConflictingTypesFailure(resourceManager).asDefault() + } +} + +private fun handleStartStakingConflictingTypesFailure( + resourceManager: ResourceManager +): TitleAndMessage { + return resourceManager.getString(R.string.setup_staking_conflict_title) to + resourceManager.getString(R.string.setup_staking_conflict_message) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationPayload.kt new file mode 100644 index 0000000..442a817 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationPayload.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations + +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.RecommendableMultiStakingSelection +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigInteger + +data class StartMultiStakingValidationPayload( + val recommendableSelection: RecommendableMultiStakingSelection, + val asset: Asset, + val fee: Fee, +) { + val selection = recommendableSelection.selection +} + +fun StartMultiStakingValidationPayload.amountOf(planks: BigInteger) = asset.token.amountFromPlanks(planks) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationSystem.kt new file mode 100644 index 0000000..60f1285 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/StartMultiStakingValidationSystem.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount + +typealias StartMultiStakingValidation = Validation +typealias StartMultiStakingValidationSystem = ValidationSystem +typealias StartMultiStakingValidationSystemBuilder = ValidationSystemBuilder + +fun StartMultiStakingValidationSystemBuilder.positiveBond() { + positiveAmount( + amount = { it.amountOf(it.selection.stake) }, + error = { StartMultiStakingValidationFailure.NonPositiveAmount } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/nominationPools/ActivePoolValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/nominationPools/ActivePoolValidation.kt new file mode 100644 index 0000000..7b6fdb5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/nominationPools/ActivePoolValidation.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.nominationPools + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrWarning +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidation +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystemBuilder + +class ActivePoolValidation : StartMultiStakingValidation { + + override suspend fun validate(value: StartMultiStakingValidationPayload): ValidationStatus { + val isPoolActive = value.selection.apy != null + + return isPoolActive isTrueOrWarning { + StartMultiStakingValidationFailure.InactivePool + } + } +} + +fun StartMultiStakingValidationSystemBuilder.activePool() { + validate(ActivePoolValidation()) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/nominationPools/MaxPoolMembersValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/nominationPools/MaxPoolMembersValidation.kt new file mode 100644 index 0000000..d257f0c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/nominationPools/MaxPoolMembersValidation.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.nominationPools + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidation +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystemBuilder + +class MaxPoolMembersValidation( + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository +) : StartMultiStakingValidation { + + override suspend fun validate(value: StartMultiStakingValidationPayload): ValidationStatus { + val chainId = value.selection.stakingOption.chain.id + val stakingType = value.selection.stakingOption.stakingType + + val maxPoolMembers = nominationPoolGlobalsRepository.maxPoolMembers(chainId) ?: return valid() + val numberOfPoolMembers = nominationPoolGlobalsRepository.counterForPoolMembers(chainId) + val hasFreeSlots = numberOfPoolMembers < maxPoolMembers + + return hasFreeSlots isTrueOrError { + StartMultiStakingValidationFailure.MaxNominatorsReached(stakingType) + } + } +} + +fun StartMultiStakingValidationSystemBuilder.maxPoolMembersNotReached( + nominationPoolGlobalsRepository: NominationPoolGlobalsRepository +) { + validate(MaxPoolMembersValidation(nominationPoolGlobalsRepository)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/nominationPools/MinJoinBondValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/nominationPools/MinJoinBondValidation.kt new file mode 100644 index 0000000..8aa2fdb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/common/validations/nominationPools/MinJoinBondValidation.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.nominationPools + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_staking_impl.data.asset +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidation +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure.AmountLessThanMinimum +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystemBuilder +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingProperties +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.StakingMinimumBondError +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.StakingMinimumBondError.ThresholdType + +class MinJoinBondValidation( + private val stakingProperties: SingleStakingProperties, +) : StartMultiStakingValidation { + + override suspend fun validate(value: StartMultiStakingValidationPayload): ValidationStatus { + val minStake = stakingProperties.minStake() + val amountIsEnoughToStake = value.selection.stake >= minStake + + return amountIsEnoughToStake isTrueOrError { + val context = StakingMinimumBondError.Context( + threshold = minStake, + chainAsset = value.selection.stakingOption.asset, + thresholdType = ThresholdType.REQUIRED + ) + + AmountLessThanMinimum(context) + } + } +} + +context (SingleStakingProperties) +fun StartMultiStakingValidationSystemBuilder.enoughForMinJoinBond() { + validate(MinJoinBondValidation(this@SingleStakingProperties)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/RealStakingTypeDetailsCompoundInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/RealStakingTypeDetailsCompoundInteractor.kt new file mode 100644 index 0000000..3d68e1c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/RealStakingTypeDetailsCompoundInteractor.kt @@ -0,0 +1,135 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.domain.era.StakingEraInteractor +import io.novafoundation.nova.feature_staking_impl.domain.model.PayoutType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetails +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.model.StartStakingEraInfo +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.validations.StartStakingLandingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.validations.startStalingLanding +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import java.math.BigInteger +import io.novafoundation.nova.common.utils.combine as combineList + +sealed interface ParticipationInGovernance { + class Participate(val minAmount: BigInteger?) : ParticipationInGovernance + object NotParticipate : ParticipationInGovernance +} + +class Payouts( + val payoutTypes: List, + val automaticPayoutMinAmount: BigInteger?, + val isAutomaticPayoutHasSmallestMinStake: Boolean +) + +class StartStakingCompoundData( + val chain: Chain, + val asset: Asset, + val maxEarningRate: Fraction, + val minStake: BigInteger, + val eraInfo: StartStakingEraInfo, + val participationInGovernance: ParticipationInGovernance, + val payouts: Payouts +) + +class LandingAvailableBalance(val asset: Asset, val availableBalance: BigInteger) + +interface StakingTypeDetailsCompoundInteractor { + + val chain: Chain + + suspend fun validationSystem(): StartStakingLandingValidationSystem + + fun observeStartStakingInfo(): Flow + + fun observeAvailableBalance(): Flow +} + +class RealStakingTypeDetailsCompoundInteractor( + override val chain: Chain, + private val chainAsset: Chain.Asset, + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, + private val interactors: List, + private val stakingEraInteractor: StakingEraInteractor +) : StakingTypeDetailsCompoundInteractor { + + override suspend fun validationSystem(): StartStakingLandingValidationSystem { + return ValidationSystem.startStalingLanding() + } + + override fun observeStartStakingInfo(): Flow { + val startStakingDataFlow = interactors.map { interactor -> + interactor.observeData() + }.combineList() + val assetFlow = assetFlow() + val eraInfoDataFlow = stakingEraInteractor.observeEraInfo() + + return combine(startStakingDataFlow, assetFlow, eraInfoDataFlow) { startStakingData, asset, eraInfo -> + StartStakingCompoundData( + chain = chain, + asset = asset, + maxEarningRate = startStakingData.maxOf { it.maxEarningRate }, + minStake = startStakingData.minOf { it.minStake }, + eraInfo = eraInfo, + participationInGovernance = getParticipationInGovernance(startStakingData), + payouts = getPayouts(startStakingData) + ) + } + } + + override fun observeAvailableBalance(): Flow { + return assetFlow().map { + val maxAvailableBalance = interactors + .maxOfOrNull { interactor -> interactor.getAvailableBalance(it) } + .orZero() + + LandingAvailableBalance(it, maxAvailableBalance) + } + } + + private fun assetFlow(): Flow { + return accountRepository.selectedMetaAccountFlow() + .flatMapLatest { metaAccount -> walletRepository.assetFlow(metaAccount.id, chainAsset) } + } + + private fun getParticipationInGovernance(stakingTypeDetails: List): ParticipationInGovernance { + val participationInGovernanceData = stakingTypeDetails.filter { it.participationInGovernance } + + return when { + participationInGovernanceData.isNotEmpty() -> { + val minAmount = participationInGovernanceData.minOf { it.minStake } + val isParticipationInGovernanceHasSmallestMinStake = stakingTypeDetails.all { it.minStake >= minAmount } + ParticipationInGovernance.Participate(minAmount.takeUnless { isParticipationInGovernanceHasSmallestMinStake }) + } + else -> ParticipationInGovernance.NotParticipate + } + } + + private fun getPayouts(stakingTypeDetails: List): Payouts { + val automaticPayoutMinAmount = stakingTypeDetails.filter { it.payoutType is PayoutType.Automatically } + .minOfOrNull { it.minStake } + + return Payouts( + payoutTypes = stakingTypeDetails.map { it.payoutType }.distinct(), + automaticPayoutMinAmount = automaticPayoutMinAmount, + isAutomaticPayoutHasSmallestMinStake = isAutomaticPayoutHasSmallestMinStake(stakingTypeDetails, automaticPayoutMinAmount) + ) + } + + private fun isAutomaticPayoutHasSmallestMinStake(stakingTypeDetails: List, automaticPayoutMinAmount: BigInteger?): Boolean { + if (automaticPayoutMinAmount == null) return false + + return stakingTypeDetails.all { it.minStake >= automaticPayoutMinAmount } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/StakingTypeDetailsCompoundInteractorFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/StakingTypeDetailsCompoundInteractorFactory.kt new file mode 100644 index 0000000..88e07ec --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/StakingTypeDetailsCompoundInteractorFactory.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.MultiStakingOptionIds +import io.novafoundation.nova.feature_staking_impl.data.createStakingOption +import io.novafoundation.nova.feature_staking_impl.domain.era.StakingEraInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct.StakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset + +class StakingTypeDetailsCompoundInteractorFactory( + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, + private val stakingEraInteractorFactory: StakingEraInteractorFactory, + private val stakingTypeDetailsInteractorFactories: Map, + private val chainRegistry: ChainRegistry +) { + + suspend fun create( + multiStakingOptionIds: MultiStakingOptionIds, + computationalScope: ComputationalScope + ): StakingTypeDetailsCompoundInteractor { + val (chain, chainAsset) = chainRegistry.chainWithAsset(multiStakingOptionIds.chainId, multiStakingOptionIds.chainAssetId) + val interactors = createInteractors(chain, chainAsset, multiStakingOptionIds.stakingTypes, computationalScope) + val stakingEraInteractor = stakingEraInteractorFactory.create(chain, chainAsset, computationalScope) + + return RealStakingTypeDetailsCompoundInteractor( + chain = chain, + chainAsset = chainAsset, + walletRepository = walletRepository, + accountRepository = accountRepository, + interactors = interactors, + stakingEraInteractor = stakingEraInteractor + ) + } + + private suspend fun createInteractors( + chain: Chain, + asset: Chain.Asset, + stakingTypes: List, + computationalScope: ComputationalScope + ): List { + return stakingTypes.mapNotNull { stakingType -> + val stakingOption = createStakingOption(chain, asset, stakingType) + + val supportedInteractorFactory = stakingTypeDetailsInteractorFactories[stakingType.group()] + supportedInteractorFactory?.create(stakingOption, computationalScope) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/model/StartStakingEraInfo.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/model/StartStakingEraInfo.kt new file mode 100644 index 0000000..4e3af9d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/model/StartStakingEraInfo.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.model + +import kotlin.time.Duration + +class StartStakingEraInfo( + val unstakeTime: Duration, + val eraDuration: Duration, + val firstRewardReceivingDuration: Duration, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationFailure.kt new file mode 100644 index 0000000..e01ace8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.validations + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.validation.NoChainAccountFoundError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed class StartStakingLandingValidationFailure { + + class NoChainAccountFound( + override val chain: Chain, + override val account: MetaAccount, + override val addAccountState: NoChainAccountFoundError.AddAccountState + ) : StartStakingLandingValidationFailure(), NoChainAccountFoundError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationPayload.kt new file mode 100644 index 0000000..bbb6390 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.validations + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class StartStakingLandingValidationPayload( + val chain: Chain, + val metaAccount: MetaAccount +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationSystem.kt new file mode 100644 index 0000000..0a6af4e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationSystem.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.validations + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.validation.hasChainAccount + +typealias StartStakingLandingValidationSystem = ValidationSystem + +fun ValidationSystem.Companion.startStalingLanding(): StartStakingLandingValidationSystem = ValidationSystem { + hasChainAccount( + chain = { it.chain }, + metaAccount = { it.metaAccount }, + error = StartStakingLandingValidationFailure::NoChainAccountFound + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationUi.kt new file mode 100644 index 0000000..7f4a5b1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/landing/validations/StartStakingLandingValidationUi.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.validations + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_account_api.domain.validation.handleChainAccountNotFound +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter + +fun handleStartStakingLandingValidationFailure( + resourceManager: ResourceManager, + validationStatus: ValidationStatus.NotValid, + router: StartMultiStakingRouter, +): TransformedFailure { + return when (val reason = validationStatus.reason) { + is StartStakingLandingValidationFailure.NoChainAccountFound -> handleChainAccountNotFound( + failure = reason, + addAccountDescriptionRes = R.string.staking_missing_account_message, + resourceManager = resourceManager, + goToWalletDetails = router::goToWalletDetails + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/SingleStakingProperties.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/SingleStakingProperties.kt new file mode 100644 index 0000000..1c76582 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/SingleStakingProperties.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount + +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystem +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface SingleStakingProperties { + + val stakingType: Chain.Asset.StakingType + + suspend fun availableBalance(asset: Asset): Balance + + suspend fun maximumToStake(asset: Asset): Balance + + val recommendation: SingleStakingRecommendation + + val validationSystem: StartMultiStakingValidationSystem + + suspend fun minStake(): Balance +} + +suspend fun SingleStakingProperties.maximumToStake(asset: Asset, fee: Balance): Balance { + return maximumToStake(asset) - fee +} + +interface SingleStakingRecommendation { + + suspend fun recommendedSelection(stake: Balance): StartMultiStakingSelection? +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/SingleStakingPropertiesFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/SingleStakingPropertiesFactory.kt new file mode 100644 index 0000000..8fd7635 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/SingleStakingPropertiesFactory.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import kotlinx.coroutines.CoroutineScope + +interface SingleStakingPropertiesFactory { + + fun createProperties(scope: CoroutineScope, stakingOption: StakingOption): SingleStakingProperties +} + +class MultiSingleStakingPropertiesFactory( + private val creators: Map +) : SingleStakingPropertiesFactory { + + override fun createProperties(scope: CoroutineScope, stakingOption: StakingOption): SingleStakingProperties { + val stakingGroup = stakingOption.additional.stakingType.group() + + return creators.getValue(stakingGroup).createProperties(scope, stakingOption) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/direct/DirectStakingProperties.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/direct/DirectStakingProperties.kt new file mode 100644 index 0000000..7ad13a1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/direct/DirectStakingProperties.kt @@ -0,0 +1,147 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.direct + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.asset +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.minStake +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidation.ConflictingStakingType +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystemBuilder +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.amountOf +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.positiveBond +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingProperties +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingPropertiesFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingRecommendation +import io.novafoundation.nova.feature_staking_impl.domain.validations.maximumNominatorsReached +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.minimumBondValidation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope + +class DirectStakingPropertiesFactory( + private val validatorRecommenderFactory: ValidatorRecommenderFactory, + private val recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + private val stakingSharedComputation: StakingSharedComputation, + private val stakingRepository: StakingRepository, + private val stakingConstantsRepository: StakingConstantsRepository, + private val stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory, + private val selectedAccountUseCase: SelectedAccountUseCase, +) : SingleStakingPropertiesFactory { + + override fun createProperties(scope: CoroutineScope, stakingOption: StakingOption): SingleStakingProperties { + return DirectStakingProperties( + validatorRecommenderFactory = validatorRecommenderFactory, + recommendationSettingsProviderFactory = recommendationSettingsProviderFactory, + stakingOption = stakingOption, + scope = scope, + stakingSharedComputation = stakingSharedComputation, + stakingRepository = stakingRepository, + stakingConstantsRepository = stakingConstantsRepository, + stakingTypesConflictValidationFactory = stakingTypesConflictValidationFactory, + selectedAccountUseCase = selectedAccountUseCase + ) + } +} + +private class DirectStakingProperties( + validatorRecommenderFactory: ValidatorRecommenderFactory, + recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + private val stakingOption: StakingOption, + private val scope: CoroutineScope, + private val stakingSharedComputation: StakingSharedComputation, + private val stakingRepository: StakingRepository, + private val stakingConstantsRepository: StakingConstantsRepository, + private val stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory, + private val selectedAccountUseCase: SelectedAccountUseCase, +) : SingleStakingProperties { + + override val stakingType: Chain.Asset.StakingType = stakingOption.stakingType + + override suspend fun availableBalance(asset: Asset): Balance { + return asset.freeInPlanks + } + + override suspend fun maximumToStake(asset: Asset): Balance { + return availableBalance(asset) + } + + override val recommendation: SingleStakingRecommendation = DirectStakingRecommendation( + stakingConstantsRepository = stakingConstantsRepository, + validatorRecommenderFactory = validatorRecommenderFactory, + recommendationSettingsProviderFactory = recommendationSettingsProviderFactory, + stakingOption = stakingOption, + scope = scope + ) + + override val validationSystem: StartMultiStakingValidationSystem = ValidationSystem { + noConflictingStaking() + + maximumNominatorsReached() + + positiveBond() + + enoughForMinimumStake() + + enoughAvailableToStake() + } + + override suspend fun minStake(): Balance { + return stakingSharedComputation.minStake(stakingOption.chain.id, scope) + } + + private fun StartMultiStakingValidationSystemBuilder.noConflictingStaking() { + stakingTypesConflictValidationFactory.noStakingTypesConflict( + accountId = { selectedAccountUseCase.getSelectedMetaAccount().requireAccountIdIn(it.recommendableSelection.selection.stakingOption.chain) }, + chainId = { it.asset.token.configuration.chainId }, + error = { StartMultiStakingValidationFailure.HasConflictingStakingType }, + checkStakingTypeNotPresent = ConflictingStakingType.POOLS + ) + } + + private fun StartMultiStakingValidationSystemBuilder.enoughForMinimumStake() { + minimumBondValidation( + stakingRepository = stakingRepository, + stakingSharedComputation = stakingSharedComputation, + chainAsset = { stakingOption.asset }, + balanceToCheckAgainstRequired = { it.selection.stake }, + balanceToCheckAgainstRecommended = { it.selection.stake }, + error = StartMultiStakingValidationFailure::AmountLessThanMinimum + ) + } + + private fun StartMultiStakingValidationSystemBuilder.maximumNominatorsReached() { + maximumNominatorsReached( + stakingRepository = stakingRepository, + chainId = { stakingOption.chain.id }, + errorProducer = { StartMultiStakingValidationFailure.MaxNominatorsReached(stakingType) } + ) + } + + private fun StartMultiStakingValidationSystemBuilder.enoughAvailableToStake() { + sufficientBalance( + fee = { it.fee }, + available = { it.amountOf(availableBalance(it.asset)) }, + amount = { it.amountOf(it.selection.stake) }, + error = { context -> + StartMultiStakingValidationFailure.NotEnoughToPayFees( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/direct/DirectStakingRecommendation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/direct/DirectStakingRecommendation.kt new file mode 100644 index 0000000..8125696 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/direct/DirectStakingRecommendation.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.direct + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingRecommendation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async + +class DirectStakingRecommendation( + private val validatorRecommenderFactory: ValidatorRecommenderFactory, + private val recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + private val stakingConstantsRepository: StakingConstantsRepository, + private val stakingOption: StakingOption, + private val scope: CoroutineScope +) : SingleStakingRecommendation { + + private val recommendator = scope.async { + validatorRecommenderFactory.create(scope) + } + + private val recommendationSettingsProvider = scope.async { + recommendationSettingsProviderFactory.create(scope) + } + + override suspend fun recommendedSelection(stake: Balance): StartMultiStakingSelection { + val provider = recommendationSettingsProvider.await() + val maximumValidatorsPerNominator = stakingConstantsRepository.maxValidatorsPerNominator(stakingOption.chain.id, stake) + val recommendationSettings = provider.recommendedSettings(maximumValidatorsPerNominator) + val recommendator = recommendator.await() + + val recommendedValidators = recommendator.recommendations(recommendationSettings) + + return DirectStakingSelection( + validators = recommendedValidators, + validatorsLimit = maximumValidatorsPerNominator, + stakingOption = stakingOption, + stake = stake + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/direct/DirectStakingSelection.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/direct/DirectStakingSelection.kt new file mode 100644 index 0000000..435753f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/direct/DirectStakingSelection.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.direct + +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.bond +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.nominate +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.multiAddressOf +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder + +data class DirectStakingSelection( + val validators: List, + val validatorsLimit: Int, + override val stakingOption: StakingOption, + override val stake: Balance, +) : StartMultiStakingSelection { + + override val apy = validators.mapNotNull { it.electedInfo?.apy?.fractions } + .maxOrNull() + + override fun ExtrinsicBuilder.startStaking(metaAccount: MetaAccount) { + val chain = stakingOption.chain + + val targets = validators.map { chain.multiAddressOf(it.accountIdHex.fromHex()) } + val controllerAddress = chain.multiAddressOf(metaAccount.requireAccountIdIn(chain)) + + bond(controllerAddress, stake, RewardDestination.Restake) + nominate(targets) + } + + override fun copyWith(stake: Balance): StartMultiStakingSelection { + return copy(stake = stake) + } + + override fun isSettingsEquals(other: StartMultiStakingSelection): Boolean { + if (other === this) return true + if (other !is DirectStakingSelection) return false + + val otherAddresses = other.validators.map { it.address }.toSet() + val thisAddresses = validators.map { it.address }.toSet() + return thisAddresses == otherAddresses && + validatorsLimit == other.validatorsLimit + } +} + +fun StartMultiStakingSelection.asDirectSelection(): DirectStakingSelection? { + return this as? DirectStakingSelection +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/pools/NominationPoolRecommendation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/pools/NominationPoolRecommendation.kt new file mode 100644 index 0000000..fce77ad --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/pools/NominationPoolRecommendation.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.recommendation.NominationPoolRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingRecommendation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async + +class NominationPoolRecommendation( + private val scope: CoroutineScope, + private val stakingOption: StakingOption, + private val nominationPoolRecommenderFactory: NominationPoolRecommenderFactory +) : SingleStakingRecommendation { + + private val recommendator = scope.async { + nominationPoolRecommenderFactory.create(stakingOption, scope) + } + + override suspend fun recommendedSelection(stake: Balance): StartMultiStakingSelection? { + val recommendedPool = recommendator.await().recommendedPool() ?: return null + + return NominationPoolSelection(recommendedPool, stakingOption, stake) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/pools/NominationPoolSelection.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/pools/NominationPoolSelection.kt new file mode 100644 index 0000000..ea7ddf5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/pools/NominationPoolSelection.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.join +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.calls.nominationPools +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.apy +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder + +data class NominationPoolSelection( + val pool: NominationPool, + override val stakingOption: StakingOption, + override val stake: Balance, +) : StartMultiStakingSelection { + + override val apy = pool.apy + + override fun ExtrinsicBuilder.startStaking(metaAccount: MetaAccount) { + nominationPools.join(stake, pool.id) + } + + override fun isSettingsEquals(other: StartMultiStakingSelection): Boolean { + if (this === other) return true + if (other !is NominationPoolSelection) return false + + return pool.id.value == other.pool.id.value + } + + override fun copyWith(stake: Balance): StartMultiStakingSelection { + return copy(stake = stake) + } +} + +fun StartMultiStakingSelection.asPoolSelection(): NominationPoolSelection? { + return this as? NominationPoolSelection +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/pools/NominationPoolStakingProperties.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/pools/NominationPoolStakingProperties.kt new file mode 100644 index 0000000..194e92f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/pools/NominationPoolStakingProperties.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolAvailableBalanceValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidation.ConflictingStakingType +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.recommendation.NominationPoolRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.NominationPoolsAvailableBalanceResolver +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.stakeAmount +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystemBuilder +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.nominationPools.activePool +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.nominationPools.enoughForMinJoinBond +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.nominationPools.maxPoolMembersNotReached +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.positiveBond +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingProperties +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingPropertiesFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingRecommendation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope + +class NominationPoolStakingPropertiesFactory( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val nominationPoolRecommenderFactory: NominationPoolRecommenderFactory, + private val poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver, + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, + private val poolAvailableBalanceValidationFactory: PoolAvailableBalanceValidationFactory, + private val stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory, + private val selectedAccountUseCase: SelectedAccountUseCase, +) : SingleStakingPropertiesFactory { + + override fun createProperties(scope: CoroutineScope, stakingOption: StakingOption): SingleStakingProperties { + return NominationPoolStakingProperties( + nominationPoolSharedComputation = nominationPoolSharedComputation, + nominationPoolRecommenderFactory = nominationPoolRecommenderFactory, + sharedComputationScope = scope, + stakingOption = stakingOption, + poolsAvailableBalanceResolver = poolsAvailableBalanceResolver, + nominationPoolGlobalsRepository = nominationPoolGlobalsRepository, + poolAvailableBalanceValidationFactory = poolAvailableBalanceValidationFactory, + stakingTypesConflictValidationFactory = stakingTypesConflictValidationFactory, + selectedAccountUseCase = selectedAccountUseCase + ) + } +} + +private class NominationPoolStakingProperties( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val nominationPoolRecommenderFactory: NominationPoolRecommenderFactory, + private val sharedComputationScope: CoroutineScope, + private val stakingOption: StakingOption, + private val poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver, + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, + private val poolAvailableBalanceValidationFactory: PoolAvailableBalanceValidationFactory, + private val stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory, + private val selectedAccountUseCase: SelectedAccountUseCase, +) : SingleStakingProperties { + + override val stakingType: Chain.Asset.StakingType = stakingOption.stakingType + + override suspend fun availableBalance(asset: Asset): Balance { + return poolsAvailableBalanceResolver.availableBalanceToStartStaking(asset) + } + + override suspend fun maximumToStake(asset: Asset): Balance { + return poolsAvailableBalanceResolver.maximumBalanceToStake(asset) + } + + override val recommendation: SingleStakingRecommendation = NominationPoolRecommendation( + scope = sharedComputationScope, + stakingOption = stakingOption, + nominationPoolRecommenderFactory = nominationPoolRecommenderFactory + ) + + override val validationSystem: StartMultiStakingValidationSystem = ValidationSystem { + noConflictingStaking() + + maxPoolMembersNotReached(nominationPoolGlobalsRepository) + + activePool() + + positiveBond() + + enoughForMinJoinBond() + + poolAvailableBalanceValidationFactory.enoughAvailableBalanceToStake( + asset = { it.asset }, + fee = { it.fee }, + amount = { it.selection.stakeAmount() }, + error = StartMultiStakingValidationFailure::PoolAvailableBalance + ) + } + + private fun StartMultiStakingValidationSystemBuilder.noConflictingStaking() { + stakingTypesConflictValidationFactory.noStakingTypesConflict( + accountId = { selectedAccountUseCase.getSelectedMetaAccount().requireAccountIdIn(it.recommendableSelection.selection.stakingOption.chain) }, + chainId = { it.asset.token.configuration.chainId }, + error = { StartMultiStakingValidationFailure.HasConflictingStakingType }, + checkStakingTypeNotPresent = ConflictingStakingType.DIRECT + ) + } + + override suspend fun minStake(): Balance { + return nominationPoolSharedComputation.minJoinBond(stakingOption.chain.id, sharedComputationScope) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/AutomaticMultiStakingSelectionType.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/AutomaticMultiStakingSelectionType.kt new file mode 100644 index 0000000..f64b434 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/AutomaticMultiStakingSelectionType.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.selectionType + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.copyIntoCurrent +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.RecommendableMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.SelectionTypeSource +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStore +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.availableBalanceGapValidation +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingProperties +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset + +class AutomaticMultiStakingSelectionType( + private val candidates: List, + private val selectionStore: StartMultiStakingSelectionStore, + private val locksRepository: BalanceLocksRepository, +) : MultiStakingSelectionType { + + override suspend fun validationSystem(selection: StartMultiStakingSelection): StartMultiStakingValidationSystem { + val candidateValidationSystem = candidates.first { it.stakingType == selection.stakingOption.stakingType } + .validationSystem + + return ValidationSystem { + // should always go before `candidateValidationSystem` since it delegates some cases to type-specific validations + availableBalanceGapValidation( + candidates = candidates, + locksRepository = locksRepository + ) + + candidateValidationSystem.copyIntoCurrent() + } + } + + override suspend fun availableBalance(asset: Asset): Balance { + return candidates.maxOf { it.availableBalance(asset) } + } + + override suspend fun maxAmountToStake(asset: Asset): Balance { + return candidates.maxOf { it.maximumToStake(asset) } + } + + override suspend fun updateSelectionFor(stake: Balance) { + val stakingProperties = typePropertiesFor(stake) + val candidates = stakingProperties.recommendation.recommendedSelection(stake) ?: return + + val recommendableSelection = RecommendableMultiStakingSelection( + source = SelectionTypeSource.Automatic, + selection = candidates, + properties = stakingProperties + ) + + selectionStore.updateSelection(recommendableSelection) + } + + private suspend fun typePropertiesFor(stake: Balance): SingleStakingProperties { + return candidates.firstAllowingToStake(stake) ?: candidates.findWithMinimumStake() + } + + private suspend fun List.firstAllowingToStake(stake: Balance): SingleStakingProperties? { + return find { it.minStake() <= stake } + } + + private suspend fun List.findWithMinimumStake(): SingleStakingProperties { + return minBy { it.minStake() } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/ManualMultiStakingSelectionType.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/ManualMultiStakingSelectionType.kt new file mode 100644 index 0000000..fc96d28 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/ManualMultiStakingSelectionType.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.selectionType + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.copyIntoCurrent +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStore +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystemBuilder +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.amountOf +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingProperties +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance + +class ManualMultiStakingSelectionType( + private val selectedType: SingleStakingProperties, + private val selectionStore: StartMultiStakingSelectionStore +) : MultiStakingSelectionType { + + override suspend fun validationSystem(selection: StartMultiStakingSelection): StartMultiStakingValidationSystem { + return ValidationSystem { + selectedType.validationSystem.copyIntoCurrent() + + enoughAvailableToStake() + } + } + + override suspend fun availableBalance(asset: Asset): Balance { + return selectedType.availableBalance(asset) + } + + override suspend fun maxAmountToStake(asset: Asset): Balance { + return selectedType.maximumToStake(asset) + } + + override suspend fun updateSelectionFor(stake: Balance) { + selectionStore.updateStake(stake) + } + + private fun StartMultiStakingValidationSystemBuilder.enoughAvailableToStake() { + sufficientBalance( + available = { it.amountOf(availableBalance(it.asset)) }, + amount = { it.amountOf(it.selection.stake) }, + error = { StartMultiStakingValidationFailure.NotEnoughAvailableToStake }, + fee = { it.fee } + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/MultiStakingSelectionType.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/MultiStakingSelectionType.kt new file mode 100644 index 0000000..510902b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/MultiStakingSelectionType.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.selectionType + +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationSystem +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset + +interface MultiStakingSelectionType { + + suspend fun validationSystem(selection: StartMultiStakingSelection): StartMultiStakingValidationSystem + + suspend fun availableBalance(asset: Asset): Balance + + suspend fun maxAmountToStake(asset: Asset): Balance + + suspend fun updateSelectionFor(stake: Balance) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/MultiStakingSelectionTypeProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/MultiStakingSelectionTypeProvider.kt new file mode 100644 index 0000000..f098c31 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupAmount/selectionType/MultiStakingSelectionTypeProvider.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.selectionType + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.MultiStakingOptionIds +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingOptionId +import io.novafoundation.nova.feature_staking_impl.data.constructStakingOption +import io.novafoundation.nova.feature_staking_impl.data.constructStakingOptions +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.RecommendableMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.SelectionTypeSource +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStore +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingPropertiesFactory +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.map + +interface MultiStakingSelectionTypeProvider { + + fun multiStakingSelectionTypeFlow(): Flow +} + +class MultiStakingSelectionTypeProviderFactory( + private val selectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val singleStakingPropertiesFactory: SingleStakingPropertiesFactory, + private val chainRegistry: ChainRegistry, + private val locksRepository: BalanceLocksRepository, +) { + + fun create( + scope: CoroutineScope, + candidateOptionsIds: MultiStakingOptionIds + ): MultiStakingSelectionTypeProvider { + return RealMultiStakingSelectionTypeProvider( + selectionStoreProvider = selectionStoreProvider, + candidateOptionIds = candidateOptionsIds, + scope = scope, + singleStakingPropertiesFactory = singleStakingPropertiesFactory, + locksRepository = locksRepository, + chainRegistry = chainRegistry + ) + } +} + +private class RealMultiStakingSelectionTypeProvider( + private val selectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val candidateOptionIds: MultiStakingOptionIds, + private val scope: CoroutineScope, + private val singleStakingPropertiesFactory: SingleStakingPropertiesFactory, + private val locksRepository: BalanceLocksRepository, + private val chainRegistry: ChainRegistry, +) : MultiStakingSelectionTypeProvider { + + override fun multiStakingSelectionTypeFlow(): Flow { + return flowOfAll { + val selectionStore = selectionStoreProvider.getSelectionStore(scope) + + selectionStore.currentSelectionFlow + .distinctUntilChangedBy { it.diffId() } + .map { createSelectionType(it, selectionStore) } + } + } + + private suspend fun createSelectionType( + currentSelection: RecommendableMultiStakingSelection?, + selectionStore: StartMultiStakingSelectionStore, + ): MultiStakingSelectionType { + return when (currentSelection?.source) { + null, SelectionTypeSource.Automatic -> createAutomaticSelection(selectionStore) + + is SelectionTypeSource.Manual -> createManualSelection(currentSelection.selection.stakingOption.stakingType, selectionStore) + } + } + + private suspend fun createAutomaticSelection( + selectionStore: StartMultiStakingSelectionStore, + ): MultiStakingSelectionType { + val candidateOptions = chainRegistry.constructStakingOptions(candidateOptionIds) + .sortedBy { it.stakingType.multiStakingPriority() } + + val candidates = candidateOptions.map { option -> + singleStakingPropertiesFactory.createProperties(scope, option) + } + + return AutomaticMultiStakingSelectionType(candidates, selectionStore, locksRepository) + } + + private suspend fun createManualSelection( + selectedStakingType: Chain.Asset.StakingType, + selectionStore: StartMultiStakingSelectionStore + ): MultiStakingSelectionType { + val optionId = StakingOptionId(candidateOptionIds.chainId, candidateOptionIds.chainAssetId, selectedStakingType) + val option = chainRegistry.constructStakingOption(optionId) + + val stakingProperties = singleStakingPropertiesFactory.createProperties(scope, option) + + return ManualMultiStakingSelectionType(stakingProperties, selectionStore) + } + + private fun Chain.Asset.StakingType.multiStakingPriority(): Int { + return when (group()) { + StakingTypeGroup.RELAYCHAIN -> 0 + StakingTypeGroup.NOMINATION_POOL -> 1 + else -> 2 + } + } + + /** + * Ensures [MultiStakingSelectionType] wont be recreated when not needed + */ + private fun RecommendableMultiStakingSelection?.diffId(): Int { + return when { + // when automatic (or null) is selected we consider them same + this == null || source is SelectionTypeSource.Automatic -> -1 + + // when manual is selected we only care about staking type when constructing `MultiStakingSelectionType` + source is SelectionTypeSource.Manual -> selection.stakingOption.stakingType.ordinal + + // in case we forgot to include something there - prevent false positive equivalence check + else -> hashCode() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/SetupStakingTypeSelectionMixin.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/SetupStakingTypeSelectionMixin.kt new file mode 100644 index 0000000..bd99f01 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/SetupStakingTypeSelectionMixin.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType + +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.RecommendableMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.SelectionTypeSource +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.currentSelectionFlow +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingPropertiesFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.direct.DirectStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools.NominationPoolSelection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import java.math.BigInteger + +class SetupStakingTypeSelectionMixinFactory( + private val currentSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val editableSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val singleStakingPropertiesFactory: SingleStakingPropertiesFactory +) { + + fun create( + scope: CoroutineScope + ): SetupStakingTypeSelectionMixin { + return SetupStakingTypeSelectionMixin( + currentSelectionStoreProvider, + editableSelectionStoreProvider, + singleStakingPropertiesFactory, + scope + ) + } +} + +class SetupStakingTypeSelectionMixin( + private val currentSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val editableSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val singleStakingPropertiesFactory: SingleStakingPropertiesFactory, + private val scope: CoroutineScope +) { + + val editableSelectionFlow = editableSelectionStoreProvider.currentSelectionFlow(scope) + .filterNotNull() + + suspend fun apply() { + val recommendableSelection = editableSelectionStoreProvider.getSelectionStore(scope).getCurrentSelection() ?: return + currentSelectionStoreProvider.getSelectionStore(scope) + .updateSelection(recommendableSelection) + } + + suspend fun selectNominationPoolAndApply(pool: NominationPool, stakingOption: StakingOption) { + val editingSelection = editableSelectionFlow.first().selection as? NominationPoolSelection ?: return + val newSelection = editingSelection.copy(pool = pool) + setSelectionAndApply(newSelection, stakingOption) + } + + suspend fun selectValidatorsAndApply(validators: List, stakingOption: StakingOption) { + val editingSelection = editableSelectionFlow.first().selection as? DirectStakingSelection ?: return + val newSelection = editingSelection.copy(validators = validators) + setSelectionAndApply(newSelection, stakingOption) + } + + private suspend fun setSelectionAndApply(selection: StartMultiStakingSelection, stakingOption: StakingOption) { + val properties = singleStakingPropertiesFactory.createProperties(scope, stakingOption) + val recommendedSelection = properties.recommendation.recommendedSelection(selection.stake) + + // Content is not recommended if recommendation does not exist + val contentRecommended = recommendedSelection?.isSettingsEquals(selection) ?: false + + currentSelectionStoreProvider.getSelectionStore(scope) + .updateSelection( + RecommendableMultiStakingSelection( + SelectionTypeSource.Manual(contentRecommended = contentRecommended), + selection, + properties + ) + ) + } + + suspend fun selectRecommended(viewModelScope: CoroutineScope, stakingOption: StakingOption, amount: BigInteger) { + val properties = singleStakingPropertiesFactory.createProperties(scope, stakingOption) + val recommendedSelection = singleStakingPropertiesFactory.createProperties(scope, stakingOption) + .recommendation + .recommendedSelection(amount) ?: return + + val recommendableMultiStakingSelection = RecommendableMultiStakingSelection( + source = SelectionTypeSource.Manual(contentRecommended = true), + selection = recommendedSelection, + properties = properties + ) + + editableSelectionStoreProvider.getSelectionStore(viewModelScope) + .updateSelection(recommendableMultiStakingSelection) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/direct/EditingStakingTypeValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/direct/EditingStakingTypeValidation.kt new file mode 100644 index 0000000..59dcbfe --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/direct/EditingStakingTypeValidation.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.stakingAmountValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.stakingTypeAvailability +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +typealias EditingStakingTypeValidationSystem = ValidationSystem + +class EditingStakingTypePayload( + val selectedAmount: BigInteger, + val stakingType: Chain.Asset.StakingType, + val minStake: BigInteger, +) + +sealed interface EditingStakingTypeFailure { + + class AmountIsLessThanMinStake(val minStake: BigInteger, val stakingType: Chain.Asset.StakingType) : EditingStakingTypeFailure + + class StakingTypeIsAlreadyUsing(val stakingType: Chain.Asset.StakingType) : EditingStakingTypeFailure +} + +fun ValidationSystem.Companion.editingStakingType( + availableStakingTypes: List +): EditingStakingTypeValidationSystem { + return ValidationSystem { + stakingAmountValidation { + EditingStakingTypeFailure.AmountIsLessThanMinStake(it.minStake, it.stakingType) + } + + stakingTypeAvailability(availableStakingTypes) { + EditingStakingTypeFailure.StakingTypeIsAlreadyUsing(it.stakingType) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/model/ValidatedStakingTypeDetails.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/model/ValidatedStakingTypeDetails.kt new file mode 100644 index 0000000..5a90520 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/model/ValidatedStakingTypeDetails.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.model + +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetails + +class ValidatedStakingTypeDetails( + val isAvailable: Boolean, + val stakingTypeDetails: StakingTypeDetails +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/pool/RealStakingTypeDetailsProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/pool/RealStakingTypeDetailsProvider.kt new file mode 100644 index 0000000..9a31bfc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/start/setupStakingType/pool/RealStakingTypeDetailsProvider.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.pool + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetailsProviderFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.direct.StakingTypeDetailsInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingProperties +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingPropertiesFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.SingleStakingRecommendation +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypeFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypePayload +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypeValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.editingStakingType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.model.ValidatedStakingTypeDetails +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RealStakingTypeDetailsProviderFactory( + private val poolStakingTypeDetailsInteractorFactory: StakingTypeDetailsInteractorFactory, + private val singleStakingPropertiesFactory: SingleStakingPropertiesFactory, + private val currentSelectionStoreProvider: StartMultiStakingSelectionStoreProvider +) : StakingTypeDetailsProviderFactory { + + override suspend fun create( + stakingOption: StakingOption, + computationalScope: ComputationalScope, + availableStakingTypes: List + ): StakingTypeDetailsProvider { + val singleStakingProperties = singleStakingPropertiesFactory.createProperties(computationalScope, stakingOption) + val validationSystem = ValidationSystem.editingStakingType(availableStakingTypes) + + return RealStakingTypeDetailsProvider( + validationSystem, + poolStakingTypeDetailsInteractorFactory.create(stakingOption, computationalScope), + stakingOption.stakingType, + singleStakingProperties, + currentSelectionStoreProvider, + computationalScope + ) + } +} + +class RealStakingTypeDetailsProvider( + private val validationSystem: EditingStakingTypeValidationSystem, + stakingTypeDetailsInteractor: StakingTypeDetailsInteractor, + override val stakingType: Chain.Asset.StakingType, + private val singleStakingProperties: SingleStakingProperties, + private val currentSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val coroutineScope: CoroutineScope +) : StakingTypeDetailsProvider { + + override val recommendationProvider: SingleStakingRecommendation = singleStakingProperties.recommendation + + override val stakingTypeDetails: Flow = stakingTypeDetailsInteractor.observeData() + .map { + ValidatedStakingTypeDetails( + isAvailable = validate() is ValidationStatus.Valid, + stakingTypeDetails = it + ) + } + + private suspend fun validate(): ValidationStatus? { + val payload = getValidationPayload() ?: return null + return validationSystem.validate(payload).getOrNull() + } + + override fun getValidationSystem(): EditingStakingTypeValidationSystem { + return validationSystem + } + + override suspend fun getValidationPayload(): EditingStakingTypePayload? { + val selectionStore = currentSelectionStoreProvider.getSelectionStore(coroutineScope) + val selectedStake = selectionStore.getCurrentSelection()?.selection?.stake ?: return null + return EditingStakingTypePayload(selectedStake, stakingType, singleStakingProperties.minStake()) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/unbond/UnbondInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/unbond/UnbondInteractor.kt new file mode 100644 index 0000000..b91624e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/unbond/UnbondInteractor.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.unbond + +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.controllerTransactionOrigin +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.chill +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.calls.unbond +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.math.BigInteger + +class UnbondInteractor( + private val extrinsicService: ExtrinsicService, + private val stakingRepository: StakingRepository, + private val stakingSharedState: StakingSharedState, + private val stakingSharedComputation: StakingSharedComputation, +) { + + suspend fun estimateFee( + stashState: StakingState.Stash, + currentBondedBalance: BigInteger, + amount: BigInteger + ): Fee { + return withContext(Dispatchers.IO) { + extrinsicService.estimateFee(stashState.chain, stashState.controllerTransactionOrigin()) { + constructUnbondExtrinsic(stashState, currentBondedBalance, amount) + } + } + } + + suspend fun unbond( + stashState: StakingState.Stash, + currentBondedBalance: BigInteger, + amount: BigInteger + ): Result { + return withContext(Dispatchers.IO) { + extrinsicService.submitExtrinsicAndAwaitExecution(stashState.chain, stashState.controllerTransactionOrigin()) { + constructUnbondExtrinsic(stashState, currentBondedBalance, amount) + } + .requireOk() + } + } + + fun unbondingsFlow(stakingState: StakingState.Stash, sharedComputationScope: CoroutineScope): Flow { + return combineToPair( + stakingRepository.ledgerFlow(stakingState), + stakingRepository.observeActiveEraIndex(stakingState.chain.id) + ).flatMapLatest { (ledger, activeEraIndex) -> + stakingSharedComputation.constructUnbondingList( + eraRedeemables = ledger.unlocking, + activeEra = activeEraIndex, + stakingOption = stakingSharedState.selectedOption(), + sharedComputationScope = sharedComputationScope + ).map { unbondings -> + Unbondings.from(unbondings, rebondPossible = true) + } + } + } + + private suspend fun ExtrinsicBuilder.constructUnbondExtrinsic( + stashState: StakingState.Stash, + currentBondedBalance: BigInteger, + unbondAmount: BigInteger + ) { + // see https://github.com/paritytech/substrate/blob/master/frame/staking/src/lib.rs#L1614 + if ( + // if account is nominating + stashState is StakingState.Stash.Nominator && + // and resulting bonded balance is less than min bond + currentBondedBalance - unbondAmount < stakingRepository.minimumNominatorBond(stashState.chain.id) + ) { + chill() + } + + unbond(unbondAmount) + } + + // unbondings are always going from the oldest to newest so last in the list will be the newest one + fun newestUnbondingAmount(unbondings: List) = unbondings.filterNonRedeemable().last().amount + + fun allUnbondingsAmount(unbondings: List): BigInteger = unbondings.filterNonRedeemable().sumByBigInteger(Unbonding::amount) + + private fun List.filterNonRedeemable() = filter { it.status is Unbonding.Status.Unbonding } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/unbond/UnbondingConstruction.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/unbond/UnbondingConstruction.kt new file mode 100644 index 0000000..fc0d0a8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/unbond/UnbondingConstruction.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.unbond + +import io.novafoundation.nova.common.utils.formatting.toTimerValue +import io.novafoundation.nova.feature_staking_api.domain.model.EraIndex +import io.novafoundation.nova.feature_staking_api.domain.model.RedeemableAmount +import io.novafoundation.nova.feature_staking_api.domain.model.isRedeemableIn +import io.novafoundation.nova.feature_staking_api.domain.model.isUnbondingIn +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.calculateDurationTill +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +fun StakingSharedComputation.constructUnbondingList( + eraRedeemables: List, + activeEra: EraIndex, + stakingOption: StakingOption, + sharedComputationScope: CoroutineScope, +): Flow> { + val stillUnbondingCount = eraRedeemables.count { it.isUnbondingIn(activeEra) } + + if (stillUnbondingCount == 0) { + val allRedeemable = eraRedeemables.mapIndexed { index, eraRedeemable -> + Unbonding( + id = index.toString(), + amount = eraRedeemable.amount, + status = Unbonding.Status.Redeemable + ) + } + + return flowOf(allRedeemable) + } + + return eraCalculatorFlow(stakingOption, sharedComputationScope).map { eraTimeCalculator -> + eraRedeemables.mapIndexed { index, eraRedeemable -> + val isRedeemable = eraRedeemable.isRedeemableIn(activeEra) + + val status = if (isRedeemable) { + Unbonding.Status.Redeemable + } else { + val timer = eraTimeCalculator.calculateDurationTill(eraRedeemable.redeemEra).toTimerValue() + + Unbonding.Status.Unbonding(timer) + } + + Unbonding( + id = index.toString(), + amount = eraRedeemable.amount, + status = status + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/unbond/Unbondings.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/unbond/Unbondings.kt new file mode 100644 index 0000000..651c749 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/staking/unbond/Unbondings.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.domain.staking.unbond + +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.Unbondings.RebondState + +typealias UnbondingList = List + +class Unbondings( + val unbondings: UnbondingList, + val anythingToRedeem: Boolean, + val rebondState: RebondState +) { + + enum class RebondState { + CAN_REBOND, NOTHING_TO_REBOND, REBOND_NOT_POSSIBLE + } + + companion object +} + +fun Unbondings.Companion.from(unbondings: List, rebondPossible: Boolean) = Unbondings( + unbondings = unbondings, + anythingToRedeem = unbondings.any { it.status is Unbonding.Status.Redeemable }, + rebondState = when { + !rebondPossible -> RebondState.REBOND_NOT_POSSIBLE + unbondings.any { it.status is Unbonding.Status.Unbonding } -> RebondState.CAN_REBOND + else -> RebondState.NOTHING_TO_REBOND + } +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/AccountIsNotControllerValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/AccountIsNotControllerValidation.kt new file mode 100644 index 0000000..abf574e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/AccountIsNotControllerValidation.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations + +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.state.chain + +class AccountIsNotControllerValidation( + private val stakingRepository: StakingRepository, + private val controllerAddressProducer: (P) -> String, + private val sharedState: StakingSharedState, + private val errorProducer: (P) -> E, +) : Validation { + override suspend fun validate(value: P): ValidationStatus { + val controllerAddress = controllerAddressProducer(value) + val chain = sharedState.chain() + val ledger = stakingRepository.ledger(sharedState.chainId(), chain.accountIdOf(controllerAddress)) + + return if (ledger == null) { + ValidationStatus.Valid() + } else { + ValidationStatus.NotValid(DefaultFailureLevel.ERROR, errorProducer(value)) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/AccountRequiredValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/AccountRequiredValidation.kt new file mode 100644 index 0000000..29104f1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/AccountRequiredValidation.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations + +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.state.chain + +class AccountRequiredValidation( + val accountRepository: AccountRepository, + val accountAddressExtractor: (P) -> String, + val sharedState: StakingSharedState, + val errorProducer: (controllerAddress: String) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val accountAddress = accountAddressExtractor(value) + val chain = sharedState.chain() + + return if (accountRepository.isAccountExists(chain.accountIdOf(accountAddress), chain.id)) { + ValidationStatus.Valid() + } else { + ValidationStatus.NotValid(DefaultFailureLevel.ERROR, errorProducer(accountAddress)) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/MaxNominatorsReachedValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/MaxNominatorsReachedValidation.kt new file mode 100644 index 0000000..3efc207 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/MaxNominatorsReachedValidation.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class MaxNominatorsReachedValidation( + private val stakingRepository: StakingRepository, + private val chainId: (P) -> ChainId, + private val errorProducer: () -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val chainId = chainId(value) + + val nominatorCount = stakingRepository.nominatorsCount(chainId) ?: return ValidationStatus.Valid() + val maxNominatorsAllowed = stakingRepository.maxNominators(chainId) ?: return ValidationStatus.Valid() + + return validOrError(nominatorCount < maxNominatorsAllowed) { + errorProducer() + } + } +} + +fun ValidationSystemBuilder.maximumNominatorsReached( + stakingRepository: StakingRepository, + chainId: (P) -> ChainId, + errorProducer: () -> E +) { + validate(MaxNominatorsReachedValidation(stakingRepository, chainId, errorProducer)) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/NotZeroBalanceValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/NotZeroBalanceValidation.kt new file mode 100644 index 0000000..e63dd0a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/NotZeroBalanceValidation.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations + +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.state.chain +import java.math.BigDecimal + +class NotZeroBalanceValidation( + private val stakingSharedState: StakingSharedState, + private val assetSourceRegistry: AssetSourceRegistry, +) : Validation { + + override suspend fun validate(value: SetControllerValidationPayload): ValidationStatus { + val chain = stakingSharedState.chain() + val feeAsset = chain.utilityAsset + val controllerId = chain.accountIdOf(value.controllerAddress) + + val controllerBalance = assetSourceRegistry.sourceFor(feeAsset).balance.queryAccountBalance(chain, feeAsset, controllerId).free.toBigDecimal() + + return if (controllerBalance > BigDecimal.ZERO) { + ValidationStatus.Valid() + } else { + ValidationStatus.NotValid(DefaultFailureLevel.WARNING, SetControllerValidationFailure.ZERO_CONTROLLER_BALANCE) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/StashOnlyIsAllowedValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/StashOnlyIsAllowedValidation.kt new file mode 100644 index 0000000..5d53a8a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/StashOnlyIsAllowedValidation.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations + +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.accountIsStash +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.state.chain + +class StashOnlyIsAllowedValidation( + val accountRepository: AccountRepository, + val stakingState: (P) -> StakingState, + val sharedState: StakingSharedState, + val errorProducer: (stashAddress: String, stashAccount: MetaAccount?) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val stakingState = stakingState(value) + if (stakingState !is StakingState.Stash) throw IllegalStateException("StashOnlyIsAllowedValidation can be used only for Stash state") + + return if (stakingState.accountIsStash()) { + ValidationStatus.Valid() + } else { + val chain = sharedState.chain() + val stashMetaAccount = accountRepository.findMetaAccount(stakingState.stashId, chain.id) + ValidationStatus.NotValid(DefaultFailureLevel.ERROR, errorProducer(stakingState.stashAddress, stashMetaAccount)) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/UnbondingRequestsLimitValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/UnbondingRequestsLimitValidation.kt new file mode 100644 index 0000000..bfc0fc4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/UnbondingRequestsLimitValidation.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations + +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import kotlinx.coroutines.flow.first + +private const val UNLOCKING_LIMIT = 32 + +class UnbondingRequestsLimitValidation( + val stakingRepository: StakingRepository, + val stashStateProducer: (P) -> StakingState.Stash, + val errorProducer: (limit: Int) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val ledger = stakingRepository.ledgerFlow(stashStateProducer(value)).first() + + return if (ledger.unlocking.size < UNLOCKING_LIMIT) { + ValidationStatus.Valid() + } else { + ValidationStatus.NotValid(DefaultFailureLevel.ERROR, errorProducer(UNLOCKING_LIMIT)) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/bond/BondMoreValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/bond/BondMoreValidationFailure.kt new file mode 100644 index 0000000..6c6b0f9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/bond/BondMoreValidationFailure.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.bond + +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed class BondMoreValidationFailure { + + object NotEnoughToPayFees : BondMoreValidationFailure() + + object NotEnoughStakeable : BondMoreValidationFailure() + + object ZeroBond : BondMoreValidationFailure() + + class NotEnoughFundToStayAboveED( + override val asset: Chain.Asset, + override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel + ) : BondMoreValidationFailure(), InsufficientBalanceToStayAboveEDError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/bond/BondMoreValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/bond/BondMoreValidationPayload.kt new file mode 100644 index 0000000..9487727 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/bond/BondMoreValidationPayload.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.bond + +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +class BondMoreValidationPayload( + val stashAddress: String, + val fee: Fee, + val chain: Chain, + val amount: BigDecimal, + val stashAsset: Asset, + val stakeable: BigDecimal +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/bond/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/bond/Declarations.kt new file mode 100644 index 0000000..5740999 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/bond/Declarations.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.bond + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.validate +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +typealias BondMoreValidationSystem = ValidationSystem +typealias BondMoreValidationSystemBuilder = ValidationSystemBuilder + +fun ValidationSystem.Companion.bondMore( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +): BondMoreValidationSystem = ValidationSystem { + enoughToPayFees() + + enoughStakeable() + + sufficientCommissionBalanceToStayAboveED(enoughTotalToStayAboveEDValidationFactory) + + positiveBond() +} + +private fun BondMoreValidationSystemBuilder.enoughToPayFees() { + sufficientBalance( + fee = { it.fee }, + available = { it.stashAsset.transferable }, + error = { BondMoreValidationFailure.NotEnoughToPayFees } + ) +} + +private fun BondMoreValidationSystemBuilder.enoughStakeable() { + sufficientBalance( + fee = { it.fee }, + available = { it.stakeable }, + amount = { it.amount }, + error = { BondMoreValidationFailure.NotEnoughStakeable } + ) +} + +private fun BondMoreValidationSystemBuilder.positiveBond() { + positiveAmount( + amount = { it.amount }, + error = { BondMoreValidationFailure.ZeroBond } + ) +} + +fun BondMoreValidationSystemBuilder.sufficientCommissionBalanceToStayAboveED( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) { + enoughTotalToStayAboveEDValidationFactory.validate( + fee = { it.fee }, + balance = { it.stashAsset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.chain, it.stashAsset.token.configuration) }, + error = { payload, error -> BondMoreValidationFailure.NotEnoughFundToStayAboveED(payload.stashAsset.token.configuration, error) } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/ChangeStackingValidationSystem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/ChangeStackingValidationSystem.kt new file mode 100644 index 0000000..1aa56e3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/ChangeStackingValidationSystem.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.validations.AccountRequiredValidation + +class ChangeStackingValidationPayload( + val controllerAddress: String +) + +enum class ChangeStackingValidationFailure { + NO_ACCESS_TO_CONTROLLER_ACCOUNT +} + +typealias ControllerRequiredValidation = AccountRequiredValidation + +typealias ChangeStackingValidationSystem = ValidationSystem +typealias ChangeStackingValidationSystemBuilder = ValidationSystemBuilder + +fun ChangeStackingValidationSystemBuilder.controllerAccountAccess(accountRepository: AccountRepository, stakingSharedState: StakingSharedState) { + return validate( + ControllerRequiredValidation( + accountRepository = accountRepository, + accountAddressExtractor = { it.controllerAddress }, + sharedState = stakingSharedState, + errorProducer = { ChangeStackingValidationFailure.NO_ACCESS_TO_CONTROLLER_ACCOUNT } + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/Declarations.kt new file mode 100644 index 0000000..d633bc5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/Declarations.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.AccountIsNotControllerValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidation + +typealias SetControllerFeeValidation = EnoughAmountToTransferValidation +typealias IsNotControllerAccountValidation = AccountIsNotControllerValidation + +typealias SetControllerValidationSystem = ValidationSystem diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/SetControllerValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/SetControllerValidationFailure.kt new file mode 100644 index 0000000..98e7849 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/SetControllerValidationFailure.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller + +enum class SetControllerValidationFailure { + NOT_ENOUGH_TO_PAY_FEES, ALREADY_CONTROLLER, ZERO_CONTROLLER_BALANCE +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/SetControllerValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/SetControllerValidationPayload.kt new file mode 100644 index 0000000..bc14388 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/controller/SetControllerValidationPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller + +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal + +class SetControllerValidationPayload( + val stashAddress: String, + val controllerAddress: String, + val fee: Fee, + val transferable: BigDecimal +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/add/AddStakingProxyValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/add/AddStakingProxyValidationFailure.kt new file mode 100644 index 0000000..20ab8ba --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/add/AddStakingProxyValidationFailure.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed interface AddStakingProxyValidationFailure { + + class NotEnoughToPayFee( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : AddStakingProxyValidationFailure, NotEnoughToPayFeesError + + class NotEnoughToStayAboveED(override val asset: Chain.Asset, override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel) : + AddStakingProxyValidationFailure, InsufficientBalanceToStayAboveEDError + + class NotEnoughBalanceToReserveDeposit( + val chainAsset: Chain.Asset, + val maxUsable: Balance, + val deposit: Balance + ) : AddStakingProxyValidationFailure + + class InvalidAddress(val chain: Chain) : AddStakingProxyValidationFailure + + object SelfDelegation : AddStakingProxyValidationFailure + + class MaximumProxiesReached(val chain: Chain, val max: Int) : AddStakingProxyValidationFailure + + class AlreadyDelegated(val address: String) : AddStakingProxyValidationFailure +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/add/AddStakingProxyValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/add/AddStakingProxyValidationPayload.kt new file mode 100644 index 0000000..ac9f092 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/add/AddStakingProxyValidationPayload.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class AddStakingProxyValidationPayload( + val chain: Chain, + val asset: Asset, + val proxiedAccountId: AccountId, + val proxyAddress: String, + val fee: Fee, + val deltaDeposit: Balance, + val currentQuantity: Int +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/add/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/add/Declarations.kt new file mode 100644 index 0000000..f3be5e5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/add/Declarations.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.validation.notSelfAccount +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.feature_proxy_api.domain.validators.maximumProxiesNotReached +import io.novafoundation.nova.feature_proxy_api.domain.validators.proxyIsNotDuplicationForAccount +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.regularTransferableBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.validAddress +import io.novafoundation.nova.feature_wallet_api.domain.validation.validate +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import java.math.BigDecimal + +typealias AddStakingProxyValidationSystem = ValidationSystem +typealias AddStakingProxyValidationSystemBuilder = ValidationSystemBuilder + +fun AddStakingProxyValidationSystemBuilder.validAddress() = validAddress( + address = { it.proxyAddress }, + chain = { it.chain }, + error = { AddStakingProxyValidationFailure.InvalidAddress(it.chain) } +) + +fun AddStakingProxyValidationSystemBuilder.sufficientBalanceToStayAboveEd( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) = enoughTotalToStayAboveEDValidationFactory.validate( + chainWithAsset = { ChainWithAsset(it.chain, it.asset.token.configuration) }, + balance = { it.asset.balanceCountedTowardsED() }, + fee = { it.fee }, + error = { payload, errorModel -> + AddStakingProxyValidationFailure.NotEnoughToStayAboveED( + asset = payload.asset.token.configuration, + errorModel = errorModel + ) + } +) + +fun AddStakingProxyValidationSystemBuilder.sufficientBalanceToPayFee() = + sufficientBalance( + available = { it.asset.transferable }, + amount = { BigDecimal.ZERO }, + fee = { it.fee }, + error = { context -> + AddStakingProxyValidationFailure.NotEnoughToPayFee( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) + +fun AddStakingProxyValidationSystemBuilder.maximumProxies( + proxyRepository: GetProxyRepository +) = maximumProxiesNotReached( + chain = { it.chain }, + accountId = { it.proxiedAccountId }, + proxiesQuantity = { it.currentQuantity + 1 }, + error = { payload, max -> + AddStakingProxyValidationFailure.MaximumProxiesReached( + chain = payload.chain, + max = max + ) + }, + proxyRepository = proxyRepository +) + +fun AddStakingProxyValidationSystemBuilder.enoughBalanceToPayDepositAndFee() = sufficientBalance( + fee = { it.fee }, + amount = { it.asset.token.configuration.amountFromPlanks(it.deltaDeposit) }, + available = { it.asset.token.amountFromPlanks(it.asset.regularTransferableBalance()) }, + error = { + val chainAsset = it.payload.asset.token.configuration + AddStakingProxyValidationFailure.NotEnoughBalanceToReserveDeposit( + chainAsset = chainAsset, + maxUsable = chainAsset.planksFromAmount(it.maxUsable), + deposit = it.payload.deltaDeposit + ) + } +) + +fun AddStakingProxyValidationSystemBuilder.stakingTypeIsNotDuplication( + proxyRepository: GetProxyRepository +) = proxyIsNotDuplicationForAccount( + chain = { it.chain }, + proxiedAccountId = { it.proxiedAccountId }, + proxyAccountId = { it.chain.accountIdOf(it.proxyAddress) }, + proxyType = { ProxyType.Staking }, + error = { payload -> AddStakingProxyValidationFailure.AlreadyDelegated(payload.proxyAddress) }, + proxyRepository = proxyRepository +) + +fun AddStakingProxyValidationSystemBuilder.notSelfAccount( + accountRepository: AccountRepository +) = notSelfAccount( + chainProvider = { it.chain }, + accountIdProvider = { it.chain.accountIdOf(it.proxyAddress) }, + failure = { AddStakingProxyValidationFailure.SelfDelegation }, + accountRepository = accountRepository +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/remove/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/remove/Declarations.kt new file mode 100644 index 0000000..c05e700 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/remove/Declarations.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.validate +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import java.math.BigDecimal + +typealias RemoveStakingProxyValidationSystem = ValidationSystem +typealias RemoveStakingProxyValidationSystemBuilder = ValidationSystemBuilder + +fun RemoveStakingProxyValidationSystemBuilder.sufficientBalanceToStayAboveEd( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) = enoughTotalToStayAboveEDValidationFactory.validate( + chainWithAsset = { ChainWithAsset(it.chain, it.asset.token.configuration) }, + balance = { it.asset.balanceCountedTowardsED() }, + fee = { it.fee }, + error = { payload, errorModel -> + RemoveStakingProxyValidationFailure.NotEnoughToStayAboveED( + asset = payload.asset.token.configuration, + errorModel = errorModel + ) + } +) + +fun RemoveStakingProxyValidationSystemBuilder.sufficientBalanceToPayFee() { + return sufficientBalance( + available = { it.asset.transferable }, + amount = { BigDecimal.ZERO }, + fee = { it.fee }, + error = { context -> + RemoveStakingProxyValidationFailure.NotEnoughToPayFee( + chainAsset = context.payload.asset.token.configuration, + maxUsable = context.maxUsable, + fee = context.fee + ) + } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/remove/RemoveStakingProxyValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/remove/RemoveStakingProxyValidationFailure.kt new file mode 100644 index 0000000..01fc63d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/remove/RemoveStakingProxyValidationFailure.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove + +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed interface RemoveStakingProxyValidationFailure { + + class NotEnoughToPayFee( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : RemoveStakingProxyValidationFailure, NotEnoughToPayFeesError + + class NotEnoughToStayAboveED(override val asset: Chain.Asset, override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel) : + RemoveStakingProxyValidationFailure, InsufficientBalanceToStayAboveEDError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/remove/RemoveStakingProxyValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/remove/RemoveStakingProxyValidationPayload.kt new file mode 100644 index 0000000..9779811 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/delegation/proxy/remove/RemoveStakingProxyValidationPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class RemoveStakingProxyValidationPayload( + val chain: Chain, + val asset: Asset, + val proxiedAccountId: AccountId, + val proxyAddress: String, + val fee: Fee +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/Reused.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/Reused.kt new file mode 100644 index 0000000..28fbea3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/Reused.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.main + +import io.novafoundation.nova.feature_staking_impl.domain.validations.AccountRequiredValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.StashOnlyIsAllowedValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.UnbondingRequestsLimitValidation + +const val BALANCE_CONTROLLER_IS_NOT_ALLOWED = "ControllerAccountIsNotAllowedValidation" +const val BALANCE_REQUIRED_STASH_META_ACCOUNT = "MainStakingAccountRequiredValidation.Stash" +const val BALANCE_REQUIRED_CONTROLLER = "MainStakingAccountRequiredValidation.Controller" + +typealias ControllerAccountIsNotAllowedValidation = StashOnlyIsAllowedValidation +typealias MainStakingMetaAccountRequiredValidation = AccountRequiredValidation +typealias MainStakingUnlockingLimitValidation = UnbondingRequestsLimitValidation diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/StakeActionsValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/StakeActionsValidationFailure.kt new file mode 100644 index 0000000..aec7cd1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/StakeActionsValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.main + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount + +sealed class StakeActionsValidationFailure { + + class UnbondingRequestLimitReached(val limit: Int) : StakeActionsValidationFailure() + + class ControllerRequired(val controllerAddress: String) : StakeActionsValidationFailure() + + class StashRequired(val stashAddress: String) : StakeActionsValidationFailure() + + class StashRequiredToManageProxies(val stashAddress: String, val stashMetaAccount: MetaAccount?) : StakeActionsValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/StakeActionsValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/StakeActionsValidationPayload.kt new file mode 100644 index 0000000..70666d4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/StakeActionsValidationPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.main + +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState + +class StakeActionsValidationPayload( + val stashState: StakingState.Stash +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/ValidationSystems.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/ValidationSystems.kt new file mode 100644 index 0000000..077adc1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/main/ValidationSystems.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.main + +import io.novafoundation.nova.common.validation.ValidationSystem + +const val SYSTEM_MANAGE_STAKING_REDEEM = "ManageStakingRedeem" +const val SYSTEM_MANAGE_STAKING_BOND_MORE = "ManageStakingBondMore" +const val SYSTEM_MANAGE_STAKING_UNBOND = "ManageStakingUnbond" +const val SYSTEM_MANAGE_STAKING_REBOND = "ManageStakingRebond" +const val SYSTEM_MANAGE_REWARD_DESTINATION = "ManageStakingRewardDestination" +const val SYSTEM_MANAGE_PAYOUTS = "ManageStakingPayouts" +const val SYSTEM_MANAGE_VALIDATORS = "ManageStakingValidators" +const val SYSTEM_MANAGE_CONTROLLER = "ManageStakingController" +const val SYSTEM_MANAGE_STAKING_REBAG = "ManageStakingRebag" +const val SYSTEM_ADD_PROXY = "AddStakingProxy" +const val SYSTEM_MANAGE_PROXIES = "ManageStakingProxies" + +typealias StakeActionsValidationSystem = ValidationSystem diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/payout/MakePayoutPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/payout/MakePayoutPayload.kt new file mode 100644 index 0000000..a869d38 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/payout/MakePayoutPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.payout + +import io.novafoundation.nova.feature_staking_impl.data.model.Payout +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal + +class MakePayoutPayload( + val originAddress: String, + val fee: Fee, + val totalReward: BigDecimal, + val asset: Asset, + val payouts: List +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/payout/PayoutFeeValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/payout/PayoutFeeValidation.kt new file mode 100644 index 0000000..cc9c0cb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/payout/PayoutFeeValidation.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.payout + +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidation + +typealias PayoutFeeValidation = EnoughAmountToTransferValidation diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/payout/PayoutValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/payout/PayoutValidationFailure.kt new file mode 100644 index 0000000..bc7c518 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/payout/PayoutValidationFailure.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.payout + +sealed class PayoutValidationFailure { + object CannotPayFee : PayoutValidationFailure() + + object UnprofitablePayout : PayoutValidationFailure() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/Declarations.kt new file mode 100644 index 0000000..0f1570a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/Declarations.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.rebond + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.PositiveAmountValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidation + +typealias RebondFeeValidation = EnoughAmountToTransferValidation +typealias NotZeroRebondValidation = PositiveAmountValidation + +typealias RebondValidation = Validation + +typealias RebondValidationSystem = ValidationSystem diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/EnoughToRebondValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/EnoughToRebondValidation.kt new file mode 100644 index 0000000..62c8893 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/EnoughToRebondValidation.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.rebond + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrError + +class EnoughToRebondValidation : RebondValidation { + + override suspend fun validate(value: RebondValidationPayload): ValidationStatus { + return validOrError(value.rebondAmount <= value.controllerAsset.unbonding) { + RebondValidationFailure.NotEnoughUnbondings + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/RebondValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/RebondValidationFailure.kt new file mode 100644 index 0000000..59b01bd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/RebondValidationFailure.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.rebond + +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed interface RebondValidationFailure { + + object NotEnoughUnbondings : RebondValidationFailure + + object CannotPayFee : RebondValidationFailure + + object ZeroAmount : RebondValidationFailure + + class NotEnoughBalanceToStayAboveED( + override val asset: Chain.Asset, + override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel + ) : RebondValidationFailure, InsufficientBalanceToStayAboveEDError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/RebondValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/RebondValidationPayload.kt new file mode 100644 index 0000000..e0d0f41 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rebond/RebondValidationPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.rebond + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +class RebondValidationPayload( + val controllerAsset: Asset, + val chain: Chain, + val fee: Fee, + val rebondAmount: BigDecimal +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/reedeem/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/reedeem/Declarations.kt new file mode 100644 index 0000000..a58b652 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/reedeem/Declarations.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidation + +typealias RedeemFeeValidation = EnoughAmountToTransferValidation + +typealias RedeemValidationSystem = ValidationSystem diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/reedeem/RedeemValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/reedeem/RedeemValidationFailure.kt new file mode 100644 index 0000000..1147915 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/reedeem/RedeemValidationFailure.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem + +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed interface RedeemValidationFailure { + object CannotPayFees : RedeemValidationFailure + + class NotEnoughBalanceToStayAboveED( + override val asset: Chain.Asset, + override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel + ) : RedeemValidationFailure, InsufficientBalanceToStayAboveEDError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/reedeem/RedeemValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/reedeem/RedeemValidationPayload.kt new file mode 100644 index 0000000..21609fe --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/reedeem/RedeemValidationPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class RedeemValidationPayload( + val fee: Fee, + val asset: Asset, + val chain: Chain +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rewardDestination/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rewardDestination/Declarations.kt new file mode 100644 index 0000000..6ee6b2e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rewardDestination/Declarations.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.AccountRequiredValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidation + +typealias RewardDestinationFeeValidation = EnoughAmountToTransferValidation +typealias RewardDestinationControllerRequiredValidation = AccountRequiredValidation + +typealias RewardDestinationValidationSystem = ValidationSystem diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rewardDestination/RewardDestinationValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rewardDestination/RewardDestinationValidationFailure.kt new file mode 100644 index 0000000..d204f36 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rewardDestination/RewardDestinationValidationFailure.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination + +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed class RewardDestinationValidationFailure { + object CannotPayFees : RewardDestinationValidationFailure() + + class MissingController(val controllerAddress: String) : RewardDestinationValidationFailure() + + class NotEnoughBalanceToStayAboveED(override val asset: Chain.Asset, override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel) : + RewardDestinationValidationFailure(), + InsufficientBalanceToStayAboveEDError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rewardDestination/RewardDestinationValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rewardDestination/RewardDestinationValidationPayload.kt new file mode 100644 index 0000000..7d252d6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/rewardDestination/RewardDestinationValidationPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination + +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import java.math.BigDecimal + +class RewardDestinationValidationPayload( + val availableControllerBalance: BigDecimal, + val asset: Asset, + val fee: Fee, + val stashState: StakingState.Stash +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/Declarations.kt new file mode 100644 index 0000000..990cffb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/Declarations.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.setup + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.sufficientCommissionBalanceToStayAboveED +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.validate +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset + +typealias SetupStakingValidationSystem = ValidationSystem +typealias SetupStakingValidationSystemBuilder = ValidationSystemBuilder + +fun ValidationSystem.Companion.changeValidators( + stakingRepository: StakingRepository, + stakingSharedComputation: StakingSharedComputation, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +): SetupStakingValidationSystem = ValidationSystem { + enoughToPayFee() + + minimumBondValidation( + stakingRepository = stakingRepository, + stakingSharedComputation = stakingSharedComputation, + chainAsset = { it.controllerAsset.token.configuration }, + balanceToCheckAgainstRequired = { it.controllerAsset.bondedInPlanks }, + balanceToCheckAgainstRecommended = { null }, // while changing validators we don't check against recommended minimum + error = SetupStakingValidationFailure::AmountLessThanMinimum + ) + + sufficientCommissionBalanceToStayAboveED(enoughTotalToStayAboveEDValidationFactory) +} + +private fun SetupStakingValidationSystemBuilder.enoughToPayFee() { + sufficientBalance( + fee = { it.maxFee }, + available = { it.controllerAsset.transferable }, + error = { SetupStakingValidationFailure.CannotPayFee } + ) +} + +fun SetupStakingValidationSystemBuilder.sufficientCommissionBalanceToStayAboveED( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) { + enoughTotalToStayAboveEDValidationFactory.validate( + fee = { it.maxFee }, + balance = { it.controllerAsset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.chain, it.controllerAsset.token.configuration) }, + error = { payload, error -> SetupStakingValidationFailure.NotEnoughFundToStayAboveED(payload.controllerAsset.token.configuration, error) } + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/MinimumStakeValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/MinimumStakeValidation.kt new file mode 100644 index 0000000..02697a8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/MinimumStakeValidation.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.setup + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.common.validation.validationWarning +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.minStake +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.StakingMinimumBondError.ThresholdType +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import java.math.RoundingMode +import kotlin.coroutines.coroutineContext + +interface StakingMinimumBondError { + + class Context(val threshold: Balance, val chainAsset: Chain.Asset, val thresholdType: ThresholdType) + + enum class ThresholdType { REQUIRED, RECOMMENDED } + + val context: Context +} + +class MinimumStakeValidation( + private val stakingRepository: StakingRepository, + private val stakingSharedComputation: StakingSharedComputation, + private val chainAsset: (P) -> Chain.Asset, + private val balanceToCheckAgainstRequired: suspend (P) -> Balance, + private val balanceToCheckAgainstRecommended: suspend (P) -> Balance?, + private val error: (StakingMinimumBondError.Context) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val chainAsset = chainAsset(value) + + val scope = CoroutineScope(coroutineContext) // scope for cached execution == scope of coroutine + + val hardMinimum = stakingRepository.minimumNominatorBond(chainAsset.chainId) + val recommendedMinimum = stakingSharedComputation.minStake(chainAsset.chainId, scope) + + val balanceToCheckAgainstRequired = balanceToCheckAgainstRequired(value) + val balanceToCheckAgainstRecommended = balanceToCheckAgainstRecommended(value) + + return when { + balanceToCheckAgainstRequired < hardMinimum -> { + val context = StakingMinimumBondError.Context(hardMinimum, chainAsset, ThresholdType.REQUIRED) + + validationError(error(context)) + } + + balanceToCheckAgainstRecommended != null && balanceToCheckAgainstRecommended < recommendedMinimum -> { + val context = StakingMinimumBondError.Context(recommendedMinimum, chainAsset, ThresholdType.RECOMMENDED) + + validationWarning(error(context)) + } + + else -> valid() + } + } +} + +fun ValidationSystemBuilder.minimumBondValidation( + stakingRepository: StakingRepository, + stakingSharedComputation: StakingSharedComputation, + chainAsset: (P) -> Chain.Asset, + balanceToCheckAgainstRequired: suspend (P) -> Balance, + balanceToCheckAgainstRecommended: suspend (P) -> Balance?, + error: (StakingMinimumBondError.Context) -> E +) { + validate( + MinimumStakeValidation( + stakingRepository = stakingRepository, + stakingSharedComputation = stakingSharedComputation, + chainAsset = chainAsset, + balanceToCheckAgainstRequired = balanceToCheckAgainstRequired, + balanceToCheckAgainstRecommended = balanceToCheckAgainstRecommended, + error = error + ) + ) +} + +fun handleStakingMinimumBondError( + resourceManager: ResourceManager, + error: StakingMinimumBondError +): TitleAndMessage { + val title = resourceManager.getString(R.string.common_amount_low) + + val formattedThreshold = error.context.threshold.formatPlanks(error.context.chainAsset, RoundingMode.UP) + val subtitle = when (error.context.thresholdType) { + ThresholdType.REQUIRED -> resourceManager.getString(R.string.staking_setup_amount_too_low, formattedThreshold) + ThresholdType.RECOMMENDED -> resourceManager.getString(R.string.staking_setup_amount_less_than_recommended, formattedThreshold) + } + + return title to subtitle +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/PoolAvailabilityValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/PoolAvailabilityValidation.kt new file mode 100644 index 0000000..9e59569 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/PoolAvailabilityValidation.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.setup + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.isOpen +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting.PoolAvailabilityFailure +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting.PoolAvailabilityPayload + +class PoolAvailabilityValidation( + private val nominationPoolGlobalsRepository: NominationPoolGlobalsRepository +) : Validation { + + override suspend fun validate(value: PoolAvailabilityPayload): ValidationStatus { + val pool = value.nominationPool + return when { + !pool.state.isOpen -> validationError(PoolAvailabilityFailure.PoolIsClosed) + isPoolFull(value) -> validationError(PoolAvailabilityFailure.PoolIsFull) + else -> valid() + } + } + + private suspend fun isPoolFull(value: PoolAvailabilityPayload): Boolean { + val pool = value.nominationPool + val maxPoolMembers = nominationPoolGlobalsRepository.maxPoolMembersPerPool(value.chain.id) + return maxPoolMembers != null && maxPoolMembers <= pool.membersCount + } +} + +fun ValidationSystemBuilder.poolAvailable( + nominationPoolGlobalsRepository: NominationPoolGlobalsRepository +) { + val validation = PoolAvailabilityValidation(nominationPoolGlobalsRepository = nominationPoolGlobalsRepository) + validate(validation) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/SetupStakingPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/SetupStakingPayload.kt new file mode 100644 index 0000000..bac0df8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/SetupStakingPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.setup + +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class SetupStakingPayload( + val maxFee: Fee, + val chain: Chain, + val controllerAsset: Asset, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/SetupStakingValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/SetupStakingValidationFailure.kt new file mode 100644 index 0000000..cb99193 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/SetupStakingValidationFailure.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.setup + +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed class SetupStakingValidationFailure { + + object CannotPayFee : SetupStakingValidationFailure() + + object NotEnoughStakeable : SetupStakingValidationFailure() + + class AmountLessThanMinimum(override val context: StakingMinimumBondError.Context) : SetupStakingValidationFailure(), StakingMinimumBondError + + object MaxNominatorsReached : SetupStakingValidationFailure() + + class NotEnoughFundToStayAboveED( + override val asset: Chain.Asset, + override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel + ) : SetupStakingValidationFailure(), InsufficientBalanceToStayAboveEDError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/StakingAmountValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/StakingAmountValidation.kt new file mode 100644 index 0000000..3a763fe --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/StakingAmountValidation.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.setup + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypeFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypePayload + +class StakingAmountValidation( + private val error: (EditingStakingTypePayload) -> EditingStakingTypeFailure, +) : Validation { + + override suspend fun validate(value: EditingStakingTypePayload): ValidationStatus { + return if (value.minStake <= value.selectedAmount) { + valid() + } else { + validationError(error(value)) + } + } +} + +fun ValidationSystemBuilder.stakingAmountValidation( + errorFormatter: (EditingStakingTypePayload) -> EditingStakingTypeFailure, +) { + validate( + StakingAmountValidation( + errorFormatter, + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/StakingTypeAvailabilityValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/StakingTypeAvailabilityValidation.kt new file mode 100644 index 0000000..46bfe5f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/setup/StakingTypeAvailabilityValidation.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.setup + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypeFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypePayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class StakingTypeAvailabilityValidation( + private val availableStakingTypes: List, + private val error: (EditingStakingTypePayload) -> EditingStakingTypeFailure, +) : Validation { + + override suspend fun validate(value: EditingStakingTypePayload): ValidationStatus { + return if (availableStakingTypes.contains(value.stakingType)) { + valid() + } else { + validationError(error(value)) + } + } +} + +fun ValidationSystemBuilder.stakingTypeAvailability( + availableStakingTypes: List, + errorFormatter: (EditingStakingTypePayload) -> EditingStakingTypeFailure, +) { + validate( + StakingTypeAvailabilityValidation( + availableStakingTypes, + errorFormatter, + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/Declarations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/Declarations.kt new file mode 100644 index 0000000..a173676 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/Declarations.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.unbond + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_wallet_api.domain.validation.PositiveAmountValidation +import io.novafoundation.nova.feature_staking_impl.domain.validations.UnbondingRequestsLimitValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughAmountToTransferValidation + +typealias UnbondFeeValidation = EnoughAmountToTransferValidation +typealias NotZeroUnbondValidation = PositiveAmountValidation +typealias UnbondLimitValidation = UnbondingRequestsLimitValidation + +typealias UnbondValidation = Validation + +typealias UnbondValidationSystem = ValidationSystem diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/EnoughToUnbondValidation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/EnoughToUnbondValidation.kt new file mode 100644 index 0000000..f834729 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/EnoughToUnbondValidation.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.unbond + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrError + +class EnoughToUnbondValidation : UnbondValidation { + + override suspend fun validate(value: UnbondValidationPayload): ValidationStatus { + return validOrError(value.amount <= value.asset.bonded) { + UnbondValidationFailure.NotEnoughBonded + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/UnbondValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/UnbondValidationFailure.kt new file mode 100644 index 0000000..937a21d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/UnbondValidationFailure.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.unbond + +import io.novafoundation.nova.feature_wallet_api.domain.validation.CrossMinimumBalanceValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.CrossMinimumBalanceValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +sealed class UnbondValidationFailure { + + object CannotPayFees : UnbondValidationFailure() + + object NotEnoughBonded : UnbondValidationFailure() + + object ZeroUnbond : UnbondValidationFailure() + + class BondedWillCrossExistential(override val errorContext: CrossMinimumBalanceValidation.ErrorContext) : + UnbondValidationFailure(), + CrossMinimumBalanceValidationFailure + + class UnbondLimitReached(val limit: Int) : UnbondValidationFailure() + + class NotEnoughBalanceToStayAboveED(override val asset: Chain.Asset, override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel) : + UnbondValidationFailure(), + InsufficientBalanceToStayAboveEDError +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/UnbondValidationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/UnbondValidationPayload.kt new file mode 100644 index 0000000..bdbb300 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validations/unbond/UnbondValidationPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validations.unbond + +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal + +data class UnbondValidationPayload( + val stash: StakingState.Stash, + val fee: Fee, + val amount: BigDecimal, + val asset: Asset, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validators/ValidatorProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validators/ValidatorProvider.kt new file mode 100644 index 0000000..9354cb6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validators/ValidatorProvider.kt @@ -0,0 +1,143 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validators + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.fromHex +import io.novafoundation.nova.common.utils.foldToSet +import io.novafoundation.nova.common.utils.toHexAccountId +import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.data.validators.ValidatorsPreferencesSource +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.electedExposuresInActiveEra +import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculatorFactory +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.CoroutineScope + +sealed class ValidatorSource { + + object Elected : ValidatorSource() + + class Custom(val validatorIds: Set) : ValidatorSource() + + object NovaValidators : ValidatorSource() +} + +class ValidatorProvider( + private val stakingRepository: StakingRepository, + private val identityRepository: OnChainIdentityRepository, + private val rewardCalculatorFactory: RewardCalculatorFactory, + private val stakingConstantsRepository: StakingConstantsRepository, + private val stakingSharedComputation: StakingSharedComputation, + private val validatorsPreferencesSource: ValidatorsPreferencesSource, +) { + + suspend fun getValidators( + stakingOption: StakingOption, + sources: List, + scope: CoroutineScope, + ): List { + val chain = stakingOption.assetWithChain.chain + val chainId = chain.id + + val novaValidatorIds = validatorsPreferencesSource.getRecommendedValidatorIds(chainId) + val electedValidatorExposures = stakingSharedComputation.electedExposuresInActiveEra(chainId, scope) + + val requestedValidatorIds = sources.allValidatorIds(chainId, electedValidatorExposures, novaValidatorIds) + // we always need validator prefs for elected validators to construct reward calculator + val validatorIdsToQueryPrefs = electedValidatorExposures.keys + requestedValidatorIds + + val validatorPrefs = stakingRepository.getValidatorPrefs(chainId, validatorIdsToQueryPrefs) + val identities = identityRepository.getIdentitiesFromIdsHex(chainId, requestedValidatorIds) + val slashes = stakingRepository.getSlashes(chain.id, requestedValidatorIds) + + val rewardCalculator = rewardCalculatorFactory.create(stakingOption, electedValidatorExposures, validatorPrefs, scope) + val maxNominators = stakingConstantsRepository.maxRewardedNominatorPerValidator(chainId) + + return requestedValidatorIds.map { accountIdHex -> + val accountId = AccountIdKey.fromHex(accountIdHex).getOrThrow() + + val electedInfo = electedValidatorExposures[accountIdHex]?.let { + Validator.ElectedInfo( + totalStake = it.total, + ownStake = it.own, + nominatorStakes = it.others, + apy = rewardCalculator.getApyFor(accountIdHex), + isOversubscribed = maxNominators != null && it.others.size > maxNominators + ) + } + + Validator( + slashed = accountId in slashes, + accountIdHex = accountIdHex, + electedInfo = electedInfo, + prefs = validatorPrefs[accountIdHex], + identity = identities[accountIdHex], + address = chain.addressOf(accountId.value), + isNovaValidator = accountIdHex in novaValidatorIds + ) + } + } + + suspend fun getValidatorWithoutElectedInfo(chain: Chain, address: String): Validator { + val chainId = chain.id + + val accountIdHex = address.toHexAccountId() + val accountId = AccountIdKey.fromHex(accountIdHex).getOrThrow() + + val accountIdHexBridged = listOf(accountIdHex) + + val prefs = stakingRepository.getValidatorPrefs(chainId, accountIdHexBridged)[accountIdHex] + val identity = identityRepository.getIdentitiesFromIdsHex(chainId, accountIdHexBridged)[accountIdHex] + + val slashes = stakingRepository.getSlashes(chain.id, accountIdHexBridged) + + val novaValidatorIds = validatorsPreferencesSource.getRecommendedValidatorIds(chainId) + + return Validator( + slashed = accountId in slashes, + accountIdHex = accountIdHex, + address = address, + prefs = prefs, + identity = identity, + electedInfo = null, + isNovaValidator = accountIdHex in novaValidatorIds + ) + } + + private fun List.allValidatorIds( + chainId: ChainId, + electedExposures: AccountIdMap, + novaValidatorIds: Set, + ): Set { + return foldToSet { it.validatorIds(chainId, electedExposures, novaValidatorIds) } + } + + private fun ValidatorSource.validatorIds( + chainId: ChainId, + electedExposures: AccountIdMap, + novaValidatorIds: Set, + ): Set { + return when (this) { + is ValidatorSource.Custom -> validatorIds + ValidatorSource.Elected -> electedExposures.keys + ValidatorSource.NovaValidators -> novaValidatorIds + } + } +} + +suspend fun ValidatorProvider.getValidators( + stakingOption: StakingOption, + source: ValidatorSource, + scope: CoroutineScope, +): List = getValidators( + stakingOption = stakingOption, + sources = listOf(source), + scope = scope +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validators/current/CurrentValidatorsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validators/current/CurrentValidatorsInteractor.kt new file mode 100644 index 0000000..797a51e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validators/current/CurrentValidatorsInteractor.kt @@ -0,0 +1,128 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validators.current + +import io.novafoundation.nova.common.list.GroupedList +import io.novafoundation.nova.common.list.emptyGroupedList +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_api.domain.model.IndividualExposure +import io.novafoundation.nova.feature_staking_api.domain.model.NominatedValidator +import io.novafoundation.nova.feature_staking_api.domain.model.NominatedValidator.Status +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.common.electedExposuresInActiveEra +import io.novafoundation.nova.feature_staking_impl.domain.common.isWaiting +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.ChangeStackingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.controllerAccountAccess +import io.novafoundation.nova.feature_staking_impl.domain.validators.ValidatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.validators.ValidatorSource +import io.novafoundation.nova.feature_staking_impl.domain.validators.getValidators +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.state.selectedOption +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlin.reflect.KClass + +class CurrentValidatorsInteractor( + private val stakingRepository: StakingRepository, + private val stakingConstantsRepository: StakingConstantsRepository, + private val validatorProvider: ValidatorProvider, + private val stakingSharedState: StakingSharedState, + private val accountRepository: AccountRepository, + private val stakingSharedComputation: StakingSharedComputation, +) { + + suspend fun nominatedValidatorsFlow( + nominatorState: StakingState.Stash, + activeStake: Balance, + scope: CoroutineScope, + ): Flow> { + if (nominatorState !is StakingState.Stash.Nominator) { + return flowOf(emptyGroupedList()) + } + + val stakingOption = stakingSharedState.selectedOption() + val chain = stakingOption.assetWithChain.chain + val chainId = chain.id + + return stakingRepository.observeActiveEraIndex(chainId).map { activeEra -> + val stashId = nominatorState.stashId + + val exposures = stakingSharedComputation.electedExposuresInActiveEra(chainId, scope) + + val activeNominations = exposures.mapValues { (_, exposure) -> + exposure.others.firstOrNull { it.who.contentEquals(stashId) } + } + + val nominatedValidatorIds = nominatorState.nominations.targets.mapTo(mutableSetOf(), ByteArray::toHexString) + + val isWaitingForNextEra = nominatorState.nominations.isWaiting(activeEra) + + val maxRewardedNominators = stakingConstantsRepository.maxRewardedNominatorPerValidator(chainId) + + val groupedByStatusClass = validatorProvider.getValidators( + stakingOption = stakingOption, + source = ValidatorSource.Custom(nominatedValidatorIds), + scope = scope + ) + .map { validator -> + val userIndividualExposure = activeNominations[validator.accountIdHex] + + val status = when { + userIndividualExposure != null -> { + // safe to !! here since non null nomination means that validator is elected + val userNominationIndex = validator.electedInfo!!.nominatorStakes + .sortedByDescending(IndividualExposure::value) + .indexOfFirst { it.who.contentEquals(stashId) } + + val userNominationRank = userNominationIndex + 1 + + val willBeRewarded = maxRewardedNominators == null || userNominationRank < maxRewardedNominators + + Status.Active(nomination = userIndividualExposure.value, willUserBeRewarded = willBeRewarded) + } + isWaitingForNextEra -> Status.WaitingForNextEra + exposures[validator.accountIdHex] != null -> Status.Elected + else -> Status.Inactive + } + + NominatedValidator(validator, status) + } + .groupBy { it.status::class } + + val totalElectiveCount = with(groupedByStatusClass) { groupSize(Status.Active::class) + groupSize(Status.Elected::class) } + val electedGroup = Status.Group.Active(totalElectiveCount) + + val waitingForNextEraGroup = Status.Group.WaitingForNextEra( + maxValidatorsPerNominator = stakingConstantsRepository.maxValidatorsPerNominator(chainId, activeStake), + numberOfValidators = groupedByStatusClass.groupSize(Status.WaitingForNextEra::class) + ) + + groupedByStatusClass.mapKeys { (statusClass, validators) -> + when (statusClass) { + Status.Active::class -> electedGroup + Status.Elected::class -> Status.Group.Elected(validators.size) + Status.Inactive::class -> Status.Group.Inactive(validators.size) + Status.WaitingForNextEra::class -> waitingForNextEraGroup + else -> throw IllegalArgumentException("Unknown status class: $statusClass") + } + } + .toSortedMap(Status.Group.COMPARATOR) + } + } + + fun getValidationSystem(): ChangeStackingValidationSystem { + return ValidationSystem { + controllerAccountAccess(accountRepository, stakingSharedState) + } + } + + private fun Map, List>.groupSize(statusClass: KClass): Int { + return get(statusClass)?.size ?: 0 + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validators/current/search/SearchCustomValidatorsInteractor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validators/current/search/SearchCustomValidatorsInteractor.kt new file mode 100644 index 0000000..34652b9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/domain/validators/current/search/SearchCustomValidatorsInteractor.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.domain.validators.current.search + +import android.annotation.SuppressLint +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.validators.ValidatorProvider +import io.novafoundation.nova.runtime.ext.isValidAddress +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SearchCustomValidatorsInteractor( + private val validatorProvider: ValidatorProvider, + private val sharedState: StakingSharedState +) { + + @SuppressLint("DefaultLocale") + suspend fun searchValidator(query: String, localValidators: Collection): List = withContext(Dispatchers.Default) { + val queryLower = query.lowercase() + + val searchInLocal = localValidators.filter { + val foundInIdentity = it.identity?.display?.lowercase()?.contains(queryLower) ?: false + + it.address.startsWith(query) || foundInIdentity + } + + if (searchInLocal.isNotEmpty()) { + return@withContext searchInLocal + } + + val chain = sharedState.chain() + + if (chain.isValidAddress(query)) { + val validator = validatorProvider.getValidatorWithoutElectedInfo(chain, query) + + if (validator.prefs != null) { + listOf(validator) + } else { + emptyList() + } + } else { + emptyList() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/MythosStakingRouter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/MythosStakingRouter.kt new file mode 100644 index 0000000..02928d4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/MythosStakingRouter.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.presentation + +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemConsequences +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.ConfirmStartMythosStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.ConfirmUnbondMythosPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload + +interface MythosStakingRouter : StarkingReturnableRouter { + + fun openCollatorDetails(payload: StakeTargetDetailsPayload) + + fun openConfirmStartStaking(payload: ConfirmStartMythosStakingPayload) + + fun openClaimRewards() + + fun returnToStartStaking() + + fun openBondMore() + + fun openUnbond() + + fun openUnbondConfirm(payload: ConfirmUnbondMythosPayload) + + fun openRedeem() + + fun finishRedeemFlow(redeemConsequences: RedeemConsequences) + + fun openStakedCollators() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/NominationPoolsRouter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/NominationPoolsRouter.kt new file mode 100644 index 0000000..c506a97 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/NominationPoolsRouter.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.NominationPoolsConfirmBondMorePayload +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.NominationPoolsConfirmUnbondPayload + +interface NominationPoolsRouter : ReturnableRouter { + + fun openSetupBondMore() + + fun openConfirmBondMore(payload: NominationPoolsConfirmBondMorePayload) + + fun openSetupUnbond() + + fun openConfirmUnbond(payload: NominationPoolsConfirmUnbondPayload) + + fun openRedeem() + + fun openClaimRewards() + + fun returnToStakingMain() + + fun returnToMain() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/ParachainStakingRouter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/ParachainStakingRouter.kt new file mode 100644 index 0000000..784abb1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/ParachainStakingRouter.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_staking_impl.presentation + +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.model.ParachainStakingRebondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.StartParachainStakingMode +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.model.ConfirmStartParachainStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.StartParachainStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.model.ParachainStakingUnbondConfirmPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfirmPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload + +interface ParachainStakingRouter : StarkingReturnableRouter { + + fun openStartStaking(payload: StartParachainStakingPayload) + + fun returnToStartStaking() + + fun openConfirmStartStaking(payload: ConfirmStartParachainStakingPayload) + + fun openSearchCollator() + + fun openCollatorDetails(payload: StakeTargetDetailsPayload) + + fun openWalletDetails(metaId: Long) + + fun openCurrentCollators() + + fun openUnbond() + fun openConfirmUnbond(payload: ParachainStakingUnbondConfirmPayload) + + fun openRedeem() + + fun openRebond(payload: ParachainStakingRebondPayload) + + fun openSetupYieldBoost() + fun openConfirmYieldBoost(payload: YieldBoostConfirmPayload) + + fun openAddStakingProxy() +} + +fun ParachainStakingRouter.openStartStaking(flowMode: StartParachainStakingMode) = openStartStaking(StartParachainStakingPayload(flowMode)) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StakingDashboardRouter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StakingDashboardRouter.kt new file mode 100644 index 0000000..b111513 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StakingDashboardRouter.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.presentation + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.utils.Event + +interface StakingDashboardRouter { + + val scrollToDashboardTopEvent: LiveData> + + fun backInStakingTab() + + fun openMoreStakingOptions() + + fun returnToStakingDashboard() + + fun openStakingDashboard() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StakingRouter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StakingRouter.kt new file mode 100644 index 0000000..b9a6d0b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StakingRouter.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_staking_impl.presentation + +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemConsequences +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.model.ConfirmPayoutPayload +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.model.PendingPayoutParcelable +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.ConfirmBondMorePayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.ConfirmSetControllerPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.ConfirmAddStakingProxyPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.ConfirmRemoveStakingProxyPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.ConfirmRebondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel.ConfirmRewardDestinationPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.ConfirmUnbondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload + +interface StakingRouter { + + fun openChainStakingMain() + + fun openStartChangeValidators() + + fun openRecommendedValidators() + + fun openSelectCustomValidators() + + fun openCustomValidatorsSettings() + + fun openSearchCustomValidators() + + fun openReviewCustomValidators(payload: CustomValidatorsPayload) + + fun openValidatorDetails(payload: StakeTargetDetailsPayload) + + fun openConfirmStaking() + + fun openConfirmNominations() + + fun returnToStakingMain() + + fun openSwitchWallet() + + fun openPayouts() + + fun openPayoutDetails(payout: PendingPayoutParcelable) + + fun openConfirmPayout(payload: ConfirmPayoutPayload) + + fun openBondMore() + + fun openConfirmBondMore(payload: ConfirmBondMorePayload) + + fun openSelectUnbond() + + fun openConfirmUnbond(payload: ConfirmUnbondPayload) + + fun openRedeem() + + fun openControllerAccount() + + fun back() + + fun openConfirmSetController(payload: ConfirmSetControllerPayload) + + fun openCustomRebond() + fun openConfirmRebond(payload: ConfirmRebondPayload) + + fun openCurrentValidators() + + fun returnToCurrentValidators() + + fun openChangeRewardDestination() + + fun openConfirmRewardDestination(payload: ConfirmRewardDestinationPayload) + + fun openWalletDetails(metaAccountId: Long) + + fun openRebag() + + fun openStakingPeriods() + + fun openSetupStakingType() + + fun openSelectPool(payload: SelectingPoolPayload) + + fun openSearchPool(payload: SelectingPoolPayload) + + fun finishSetupValidatorsFlow() + + fun finishSetupPoolFlow() + + fun finishRedeemFlow(redeemConsequences: RedeemConsequences) + + fun openAddStakingProxy() + + fun openConfirmAddStakingProxy(payload: ConfirmAddStakingProxyPayload) + + fun openStakingProxyList() + + fun openConfirmRemoveStakingProxy(payload: ConfirmRemoveStakingProxyPayload) + + fun openDAppBrowser(url: String) + + fun openStakingDashboard() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StarkingReturnableRouter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StarkingReturnableRouter.kt new file mode 100644 index 0000000..3eb8335 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StarkingReturnableRouter.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter + +interface StarkingReturnableRouter : ReturnableRouter { + + fun returnToStakingMain() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StartMultiStakingRouter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StartMultiStakingRouter.kt new file mode 100644 index 0000000..2ca0cee --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/StartMultiStakingRouter.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.ConfirmMultiStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StartStakingLandingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.SetupAmountMultiStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.SetupStakingTypePayload + +interface StartMultiStakingRouter : ReturnableRouter { + + fun openStartStakingLanding(payload: StartStakingLandingPayload) + + fun openStartParachainStaking() + + fun openStartMythosStaking() + + fun openStartMultiStaking(payload: SetupAmountMultiStakingPayload) + + fun openSetupStakingType(payload: SetupStakingTypePayload) + + fun openConfirm(payload: ConfirmMultiStakingPayload) + + fun openSelectedValidators() + + fun returnToStakingDashboard() + + fun goToWalletDetails(metaId: Long) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/RebagFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/RebagFragment.kt new file mode 100644 index 0000000..967f2a9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/RebagFragment.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.bagList.rebag + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentRebagBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class RebagFragment : BaseFragment() { + + override fun createBinding() = FragmentRebagBinding.inflate(layoutInflater) + + override fun initViews() { + binder.rebagToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.rebagConfirm.prepareForProgress(viewLifecycleOwner) + binder.rebagConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.rebagExtrinsicInfo.setOnAccountClickedListener { viewModel.accountClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .rebagComponentFractory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: RebagViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.rebagHints) + + setupFeeLoading(viewModel, binder.rebagExtrinsicInfo.fee) + viewModel.originAddressModelFlow.observe(binder.rebagExtrinsicInfo::setAccount) + viewModel.walletModel.observe(binder.rebagExtrinsicInfo::setWallet) + + viewModel.rebagMovementModel.observe { + binder.rebagCurrentBag.showValue(it.currentBag) + binder.rebagNewBag.showValue(it.newBag) + } + + viewModel.showNextProgress.observe(binder.rebagConfirm::setProgressState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/RebagViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/RebagViewModel.kt new file mode 100644 index 0000000..83e77c2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/RebagViewModel.kt @@ -0,0 +1,168 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.bagList.rebag + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.RebagInteractor +import io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.RebagMovement +import io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.validations.RebagValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.validations.RebagValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.validations.handleRebagValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.bagList.rebag.model.RebagMovementModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanksRange +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.WithFeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.chainAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class RebagViewModel( + private val interactor: RebagInteractor, + private val stakingInteractor: StakingInteractor, + private val stakingSharedState: StakingSharedState, + private val feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val walletUiUseCase: WalletUiUseCase, + private val router: StakingRouter, + private val externalActions: ExternalActions.Presentation, + private val validationExecutor: ValidationExecutor, + private val validationSystem: RebagValidationSystem, + private val resourceManager: ResourceManager, + private val iconGenerator: AddressIconGenerator, + private val resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, +) : BaseViewModel(), + WithFeeLoaderMixin, + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val accountStakingFlow = stakingInteractor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .shareInBackground() + + private val stashAsset = accountStakingFlow.flatMapLatest { + stakingInteractor.assetFlow(it.stashAddress) + }.shareInBackground() + + override val originFeeMixin = feeLoaderMixinFactory.create(stashAsset) + + val walletModel = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val originAddressModelFlow = accountStakingFlow.map { + iconGenerator.createAccountAddressModel(stakingSharedState.chain(), it.stashAddress) + } + .shareInBackground() + + val hintsMixin = resourcesHintsMixinFactory.create( + coroutineScope = viewModelScope, + hintsRes = listOf(R.string.staking_alert_rebag_message) + ) + + val rebagMovementModel = accountStakingFlow.flatMapLatest(interactor::rebagMovementFlow) + .map { rebagMovement -> + val chainAsset = stakingSharedState.chainAsset() + mapRebagMovementToUi(rebagMovement, chainAsset) + } + .shareInBackground() + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + init { + loadFee() + } + + fun confirmClicked() { + rebagIfValid() + } + + fun backClicked() { + router.back() + } + + fun accountClicked() = launch { + val addressModel = originAddressModelFlow.first() + + externalActions.showAddressActions(addressModel.address, stakingSharedState.chain()) + } + + private fun rebagIfValid() { + launch { + _showNextProgress.value = true + + val validationPayload = RebagValidationPayload( + fee = originFeeMixin.awaitFee(), + asset = stashAsset.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + progressConsumer = _showNextProgress.progressConsumer(), + validationFailureTransformer = { handleRebagValidationFailure(it, resourceManager) } + ) { + rebag() + } + } + } + + private fun rebag() = launch { + val result = withContext(Dispatchers.Default) { + interactor.rebag(accountStakingFlow.first()) + } + + result.onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.back() } + } + .onFailure(::showError) + + _showNextProgress.value = false + } + + private fun mapRebagMovementToUi(rebagMovement: RebagMovement, chainAsset: Chain.Asset): RebagMovementModel { + return RebagMovementModel( + currentBag = rebagMovement.from.formatPlanksRange(chainAsset), + newBag = rebagMovement.to.formatPlanksRange(chainAsset) + ) + } + + private fun loadFee() { + launch { + originFeeMixin.loadFee( + coroutineScope = this, + feeConstructor = { interactor.calculateFee(accountStakingFlow.first()) }, + onRetryCancelled = {} + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/di/RebagComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/di/RebagComponent.kt new file mode 100644 index 0000000..0974fd3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/di/RebagComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.bagList.rebag.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.bagList.rebag.RebagFragment + +@Subcomponent( + modules = [ + RebagModule::class + ] +) +@ScreenScope +interface RebagComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): RebagComponent + } + + fun inject(fragment: RebagFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/di/RebagModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/di/RebagModule.kt new file mode 100644 index 0000000..b98bc4d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/di/RebagModule.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.bagList.rebag.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.repository.BagListRepository +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.RealRebagInteractor +import io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.RebagInteractor +import io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.validations.RebagValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.bagList.rebag.validations.rebagValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.bagList.rebag.RebagViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository + +@Module(includes = [ViewModelModule::class]) +class RebagModule { + + @Provides + @ScreenScope + fun provideValidationSystem() = ValidationSystem.rebagValidationSystem() + + @Provides + @ScreenScope + fun provideInteractor( + totalIssuanceRepository: TotalIssuanceRepository, + bagListRepository: BagListRepository, + assetUseCase: AssetUseCase, + extrinsicService: ExtrinsicService, + ): RebagInteractor { + return RealRebagInteractor( + totalIssuanceRepository = totalIssuanceRepository, + bagListRepository = bagListRepository, + assetUseCase = assetUseCase, + extrinsicService = extrinsicService + ) + } + + @Provides + @IntoMap + @ViewModelKey(RebagViewModel::class) + fun provideViewModel( + interactor: RebagInteractor, + stakingInteractor: StakingInteractor, + stakingSharedState: StakingSharedState, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + walletUiUseCase: WalletUiUseCase, + router: StakingRouter, + externalActions: ExternalActions.Presentation, + validationExecutor: ValidationExecutor, + validationSystem: RebagValidationSystem, + resourceManager: ResourceManager, + iconGenerator: AddressIconGenerator, + resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + ): ViewModel { + return RebagViewModel( + interactor = interactor, + stakingInteractor = stakingInteractor, + stakingSharedState = stakingSharedState, + feeLoaderMixinFactory = feeLoaderMixinFactory, + walletUiUseCase = walletUiUseCase, + router = router, + validationSystem = validationSystem, + externalActions = externalActions, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + iconGenerator = iconGenerator, + resourcesHintsMixinFactory = resourcesHintsMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): RebagViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(RebagViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/model/RebagMovementModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/model/RebagMovementModel.kt new file mode 100644 index 0000000..87c8eef --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/bagList/rebag/model/RebagMovementModel.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.bagList.rebag.model + +class RebagMovementModel( + val currentBag: String, + val newBag: String +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/SetupStakingSharedState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/SetupStakingSharedState.kt new file mode 100644 index 0000000..cf29bb3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/SetupStakingSharedState.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common + +import android.util.Log +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.coroutines.flow.MutableStateFlow + +sealed class SetupStakingProcess { + + object Initial : SetupStakingProcess() { + + fun next( + activeStake: Balance, + currentlyActiveValidators: List, + ): ChoosingValidators { + return ChoosingValidators(currentlySelectedValidators = currentlyActiveValidators, activeStake) + } + } + + class ChoosingValidators( + val currentlySelectedValidators: List, + val activeStake: Balance, + ) : SetupStakingProcess() { + + fun next( + newValidators: List, + selectionMethod: ReadyToSubmit.SelectionMethod, + ) = ReadyToSubmit( + activeStake = activeStake, + newValidators = newValidators, + selectionMethod = selectionMethod, + currentlySelectedValidators = currentlySelectedValidators + ) + } + + data class ReadyToSubmit( + val activeStake: Balance, + val newValidators: List, + val selectionMethod: SelectionMethod, + val currentlySelectedValidators: List, + ) : SetupStakingProcess() { + + enum class SelectionMethod { + RECOMMENDED, CUSTOM + } + + fun changeValidators( + newValidators: List, + selectionMethod: SelectionMethod + ) = copy(newValidators = newValidators, selectionMethod = selectionMethod) + + fun previous(): ChoosingValidators { + return ChoosingValidators(currentlySelectedValidators, activeStake) + } + } +} + +class SetupStakingSharedState { + + val setupStakingProcess = MutableStateFlow(SetupStakingProcess.Initial) + + fun set(newState: SetupStakingProcess) { + Log.d("RX", "${setupStakingProcess.value.javaClass.simpleName} -> ${newState.javaClass.simpleName}") + + setupStakingProcess.value = newState + } + + inline fun get(): T = setupStakingProcess.value as T + + fun mutate(mutation: (SetupStakingProcess) -> SetupStakingProcess) { + set(mutation(get())) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/CurrentStakeTargetAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/CurrentStakeTargetAdapter.kt new file mode 100644 index 0000000..8023911 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/CurrentStakeTargetAdapter.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets + +import android.view.ViewGroup +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.feature_staking_impl.databinding.ItemCurrentValidatorBinding +import io.novafoundation.nova.feature_staking_impl.databinding.ItemCurrentValidatorGroupBinding +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.SelectedStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.SelectedStakeTargetStatusModel + +class CurrentStakeTargetAdapter( + private val handler: Handler, +) : GroupedListAdapter(CurrentValidatorsDiffCallback) { + + interface Handler { + + fun infoClicked(stakeTargetModel: SelectedStakeTargetModel) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return CurrentValidatorsGroupHolder(ItemCurrentValidatorGroupBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return CurrentValidatorsChildHolder(ItemCurrentValidatorBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: SelectedStakeTargetStatusModel) { + (holder as CurrentValidatorsGroupHolder).bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: SelectedStakeTargetModel) { + (holder as CurrentValidatorsChildHolder).bind(child, handler) + } +} + +private class CurrentValidatorsGroupHolder(private val binder: ItemCurrentValidatorGroupBinding) : GroupedListHolder(binder.root) { + + fun bind(group: SelectedStakeTargetStatusModel) = with(binder) { + val topPadding = if (isFirst()) 16 else 24 + itemCurrentValidatorContainer.updatePadding(top = topPadding.dp(binder.root.context)) + + itemCurrentValidatorGroupStatus.setTextOrHide(group.titleConfig?.text) + + group.titleConfig?.let { + itemCurrentValidatorGroupStatus.setTextColorRes(it.textColorRes) + itemCurrentValidatorGroupStatus.setDrawableStart(it.iconRes, widthInDp = 16, paddingInDp = 8, tint = it.iconTintRes) + } + + itemCurrentValidatorGroupDescription.text = group.description + } + + private fun isFirst() = absoluteAdapterPosition == 0 +} + +private class CurrentValidatorsChildHolder(private val binder: ItemCurrentValidatorBinding) : GroupedListHolder(binder.root) { + + fun bind(validator: SelectedStakeTargetModel, handler: CurrentStakeTargetAdapter.Handler) = with(binder) { + itemCurrentValidatorIcon.setImageDrawable(validator.addressModel.image) + itemCurrentValidatorName.text = validator.addressModel.nameOrAddress + + itemCurrentValidatorNominated.setVisible(validator.nominated != null) + itemCurrentValidatorNominatedAmount.text = validator.nominated?.token + + itemCurrentValidatorApy.setTextOrHide(validator.apy) + + itemCurrentValidatorInfo.setOnClickListener { handler.infoClicked(validator) } + + itemCurrentValidatorOversubscribed.setVisible(validator.isOversubscribed) + currentValidatorSlashedIcon.setVisible(validator.isSlashed) + } +} + +private object CurrentValidatorsDiffCallback : + BaseGroupedDiffCallback(SelectedStakeTargetStatusModel::class.java) { + + override fun areGroupItemsTheSame(oldItem: SelectedStakeTargetStatusModel, newItem: SelectedStakeTargetStatusModel): Boolean { + return oldItem == newItem + } + + override fun areGroupContentsTheSame(oldItem: SelectedStakeTargetStatusModel, newItem: SelectedStakeTargetStatusModel): Boolean { + return true + } + + override fun areChildItemsTheSame(oldItem: SelectedStakeTargetModel, newItem: SelectedStakeTargetModel): Boolean { + return oldItem.addressModel.address == newItem.addressModel.address + } + + override fun areChildContentsTheSame(oldItem: SelectedStakeTargetModel, newItem: SelectedStakeTargetModel): Boolean { + return oldItem.nominated == newItem.nominated + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/CurrentStakeTargetsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/CurrentStakeTargetsFragment.kt new file mode 100644 index 0000000..fff3a93 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/CurrentStakeTargetsFragment.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets + +import androidx.annotation.CallSuper +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentCurrentValidatorsBinding +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.SelectedStakeTargetModel + +abstract class CurrentStakeTargetsFragment : + BaseFragment(), + CurrentStakeTargetAdapter.Handler { + + override fun createBinding() = FragmentCurrentValidatorsBinding.inflate(layoutInflater) + + lateinit var adapter: CurrentStakeTargetAdapter + + override fun initViews() { + adapter = CurrentStakeTargetAdapter(this) + binder.currentValidatorsList.adapter = adapter + + binder.currentValidatorsList.setHasFixedSize(true) + + binder.currentValidatorsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.currentValidatorsToolbar.setRightActionClickListener { viewModel.changeClicked() } + } + + @CallSuper + override fun subscribe(viewModel: V) { + viewModel.currentStakeTargetsFlow.observe { loadingState -> + when (loadingState) { + is LoadingState.Loading -> { + binder.currentValidatorsList.makeGone() + binder.currentValidatorsProgress.makeVisible() + } + + is LoadingState.Loaded -> { + binder.currentValidatorsList.makeVisible() + binder.currentValidatorsProgress.makeGone() + + adapter.submitList(loadingState.data) + } + } + } + + viewModel.warningFlow.observe { + if (it != null) { + binder.currentValidatorsOversubscribedMessage.makeVisible() + binder.currentValidatorsOversubscribedMessage.setMessage(it) + } else { + binder.currentValidatorsOversubscribedMessage.makeGone() + } + } + + viewModel.titleFlow.observe(binder.currentValidatorsToolbar::setTitle) + } + + override fun infoClicked(stakeTargetModel: SelectedStakeTargetModel) { + viewModel.stakeTargetInfoClicked(stakeTargetModel.addressModel.address) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/CurrentStakeTargetsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/CurrentStakeTargetsViewModel.kt new file mode 100644 index 0000000..c9c90d7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/CurrentStakeTargetsViewModel.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.LoadingState +import kotlinx.coroutines.flow.Flow + +abstract class CurrentStakeTargetsViewModel : BaseViewModel() { + + abstract val currentStakeTargetsFlow: Flow>> + + abstract val warningFlow: Flow + + abstract val titleFlow: Flow + + abstract fun stakeTargetInfoClicked(address: String) + + abstract fun backClicked() + + abstract fun changeClicked() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/actions/CollatorManageActionsBottomSheet.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/actions/CollatorManageActionsBottomSheet.kt new file mode 100644 index 0000000..92cc720 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/actions/CollatorManageActionsBottomSheet.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.actions + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem +import io.novafoundation.nova.feature_staking_impl.R + +class CollatorManageActionsBottomSheet( + context: Context, + private val itemSelected: (ManageCurrentStakeTargetsAction) -> Unit, + onCancel: (() -> Unit)? = null, +) : FixedListBottomSheet(context, viewConfiguration = ViewConfiguration.default(context), onCancel) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.parachain_staking_manage_collators) + + ManageCurrentStakeTargetsAction.values().forEach { + item(it) + } + } + + private fun item(action: ManageCurrentStakeTargetsAction) = textItem(action.iconRes, action.titleRes) { + itemSelected(action) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/actions/ManageCurrentStakeTargetsAction.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/actions/ManageCurrentStakeTargetsAction.kt new file mode 100644 index 0000000..da78379 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/actions/ManageCurrentStakeTargetsAction.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.actions + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.feature_staking_impl.R + +enum class ManageCurrentStakeTargetsAction(@StringRes val titleRes: Int, @DrawableRes val iconRes: Int) { + BOND_MORE(R.string.staking_bond_more_v1_9_0, R.drawable.ic_add_circle_outline), UNBOND(R.string.staking_unbond_v1_9_0, R.drawable.ic_minus_circle_outline) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/model/SelectedStakeTargetStatusModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/model/SelectedStakeTargetStatusModel.kt new file mode 100644 index 0000000..96783b7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/currentStakeTargets/model/SelectedStakeTargetStatusModel.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class SelectedStakeTargetModel( + val addressModel: AddressModel, + val nominated: AmountModel?, + val apy: String?, + val isOversubscribed: Boolean, + val isSlashed: Boolean +) + +data class SelectedStakeTargetStatusModel( + val titleConfig: TitleConfig?, + val description: String, +) { + + companion object; + + data class TitleConfig( + val text: String, + @DrawableRes val iconRes: Int, + @ColorRes val iconTintRes: Int, + @ColorRes val textColorRes: Int + ) +} + +fun SelectedStakeTargetStatusModel.Companion.Active( + resourceManager: ResourceManager, + groupSize: Int, + @StringRes description: Int +) = SelectedStakeTargetStatusModel( + SelectedStakeTargetStatusModel.TitleConfig( + text = resourceManager.getString(R.string.staking_your_elected_format, groupSize), + iconRes = R.drawable.ic_checkmark_circle_16, + iconTintRes = R.color.icon_positive, + textColorRes = R.color.text_primary, + ), + description = resourceManager.getString(description) +) + +fun SelectedStakeTargetStatusModel.Companion.Inactive( + resourceManager: ResourceManager, + groupSize: Int, + @StringRes description: Int +) = SelectedStakeTargetStatusModel( + SelectedStakeTargetStatusModel.TitleConfig( + text = resourceManager.getString(R.string.staking_your_not_elected_format, groupSize), + iconRes = R.drawable.ic_time_16, + iconTintRes = R.color.text_secondary, + textColorRes = R.color.text_secondary, + ), + description = resourceManager.getString(description) +) + +fun SelectedStakeTargetStatusModel.Companion.Elected( + resourceManager: ResourceManager, + @StringRes description: Int +) = SelectedStakeTargetStatusModel( + null, + description = resourceManager.getString(description) +) + +fun SelectedStakeTargetStatusModel.Companion.Waiting( + resourceManager: ResourceManager, + title: String, + @StringRes description: Int +) = SelectedStakeTargetStatusModel( + SelectedStakeTargetStatusModel.TitleConfig( + text = title, + iconRes = R.drawable.ic_time_16, + iconTintRes = R.color.text_secondary, + textColorRes = R.color.text_secondary, + ), + description = resourceManager.getString(description) +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/hints/StakingHintsUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/hints/StakingHintsUseCase.kt new file mode 100644 index 0000000..0c86a41 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/hints/StakingHintsUseCase.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.hints + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import kotlinx.coroutines.CoroutineScope + +class StakingHintsUseCase( + private val resourceManager: ResourceManager, + private val stakingInteractor: StakingInteractor, +) { + + fun redeemHint(): String { + return resourceManager.getString(R.string.staking_hint_redeem_v2_2_0) + } + + suspend fun unstakingDurationHint(coroutineScope: CoroutineScope): String { + val lockupPeriod = stakingInteractor.getLockupDuration(coroutineScope) + val formattedDuration = resourceManager.formatDuration(lockupPeriod) + + return resourceManager.getString(R.string.staking_hint_unstake_format_v2_2_0, formattedDuration) + } + + fun noRewardDurationUnstakingHint(): String { + return resourceManager.getString(R.string.staking_hint_no_rewards_v2_2_0) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationEstimations.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationEstimations.kt new file mode 100644 index 0000000..5c98ca8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationEstimations.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination + +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.model.RewardEstimation + +class RewardDestinationEstimations( + val restake: RewardEstimation, + val payout: RewardEstimation, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationMixin.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationMixin.kt new file mode 100644 index 0000000..0f32b35 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationMixin.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculator +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import java.math.BigDecimal + +interface RewardDestinationMixin : Browserable { + + val rewardReturnsLiveData: LiveData + + val showDestinationChooserEvent: LiveData>> + + val rewardDestinationModelFlow: Flow + + fun payoutClicked(scope: CoroutineScope) + + fun payoutTargetClicked(scope: CoroutineScope) + + fun payoutDestinationChanged(newDestination: AddressModel) + + fun learnMoreClicked() + + fun restakeClicked() + + interface Presentation : RewardDestinationMixin { + + val rewardDestinationChangedFlow: Flow + + suspend fun loadActiveRewardDestination(stashState: StakingState.Stash) + + suspend fun updateReturns( + rewardCalculator: RewardCalculator, + asset: Asset, + amount: BigDecimal, + ) + } +} + +fun RewardDestinationMixin.Presentation.connectWith( + amountChooserMixin: AmountChooserMixin.Presentation, + rewardCalculator: Deferred +) { + amountChooserMixin.usedAssetFlow.combine(amountChooserMixin.amount) { asset, amount -> + updateReturns(rewardCalculator(), asset, amount) + }.launchIn(scope = amountChooserMixin) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationModel.kt new file mode 100644 index 0000000..300e57d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationModel.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination + +import io.novafoundation.nova.common.address.AddressModel + +sealed class RewardDestinationModel { + + object Restake : RewardDestinationModel() + + class Payout(val destination: AddressModel) : RewardDestinationModel() { + + override fun equals(other: Any?): Boolean { + return other is Payout && other.destination.address == destination.address + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationProvider.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationProvider.kt new file mode 100644 index 0000000..ac36ec6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationProvider.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.createSubstrateAddressModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAddressModel +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novafoundation.nova.feature_staking_api.domain.model.StakingAccount +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.rewards.DAYS_IN_YEAR +import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculator +import io.novafoundation.nova.feature_staking_impl.domain.rewards.calculateMaxReturns +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapPeriodReturnsToRewardEstimation +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import java.math.BigDecimal + +class RewardDestinationProvider( + private val resourceManager: ResourceManager, + private val interactor: StakingInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val appLinksProvider: AppLinksProvider, + private val sharedState: StakingSharedState, + private val accountDisplayUseCase: AddressDisplayUseCase +) : RewardDestinationMixin.Presentation { + + override val rewardReturnsLiveData = MutableLiveData() + override val showDestinationChooserEvent = MutableLiveData>>() + + override val rewardDestinationModelFlow = MutableStateFlow(RewardDestinationModel.Restake) + + override val openBrowserEvent = MutableLiveData>() + + private val initialRewardDestination = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + override val rewardDestinationChangedFlow = initialRewardDestination.combine(rewardDestinationModelFlow) { initial, current -> + initial != current + }.onStart { emit(false) } + + override fun payoutClicked(scope: CoroutineScope) { + scope.launch { + val currentAccount = interactor.getSelectedAccountProjection() + + rewardDestinationModelFlow.value = RewardDestinationModel.Payout(generateDestinationModel(currentAccount)) + } + } + + override fun payoutTargetClicked(scope: CoroutineScope) { + val selectedDestination = rewardDestinationModelFlow.value as? RewardDestinationModel.Payout ?: return + + scope.launch { + val accountsInNetwork = accountsInCurrentNetwork() + + showDestinationChooserEvent.value = Event(DynamicListBottomSheet.Payload(accountsInNetwork, selectedDestination.destination)) + } + } + + override fun payoutDestinationChanged(newDestination: AddressModel) { + rewardDestinationModelFlow.value = RewardDestinationModel.Payout(newDestination) + } + + override fun learnMoreClicked() { + openBrowserEvent.value = Event(appLinksProvider.payoutsLearnMore) + } + + override fun restakeClicked() { + rewardDestinationModelFlow.value = RewardDestinationModel.Restake + } + + override suspend fun loadActiveRewardDestination(stashState: StakingState.Stash) { + val rewardDestination = interactor.getRewardDestination(stashState) + val rewardDestinationModel = mapRewardDestinationToRewardDestinationModel(rewardDestination) + + initialRewardDestination.emit(rewardDestinationModel) + rewardDestinationModelFlow.value = rewardDestinationModel + } + + override suspend fun updateReturns(rewardCalculator: RewardCalculator, asset: Asset, amount: BigDecimal) { + val restakeReturns = rewardCalculator.calculateMaxReturns(amount, DAYS_IN_YEAR, true) + val payoutReturns = rewardCalculator.calculateMaxReturns(amount, DAYS_IN_YEAR, false) + + val restakeEstimations = mapPeriodReturnsToRewardEstimation(restakeReturns, asset.token, resourceManager) + val payoutEstimations = mapPeriodReturnsToRewardEstimation(payoutReturns, asset.token, resourceManager) + + rewardReturnsLiveData.value = RewardDestinationEstimations(restakeEstimations, payoutEstimations) + } + + private suspend fun mapRewardDestinationToRewardDestinationModel(rewardDestination: RewardDestination): RewardDestinationModel { + return when (rewardDestination) { + RewardDestination.Restake -> RewardDestinationModel.Restake + is RewardDestination.Payout -> { + val chain = sharedState.chain() + val addressModel = generateDestinationModel(chain, rewardDestination.targetAccountId) + + RewardDestinationModel.Payout(addressModel) + } + } + } + + private suspend fun accountsInCurrentNetwork(): List { + return interactor.getAccountProjectionsInSelectedChains() + .map { generateDestinationModel(it) } + } + + private suspend fun generateDestinationModel(account: StakingAccount): AddressModel { + return addressIconGenerator.createSubstrateAddressModel( + accountAddress = account.address, + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + accountName = account.name, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + } + + private suspend fun generateDestinationModel(chain: Chain, accountId: AccountId): AddressModel { + return addressIconGenerator.createAddressModel( + chain = chain, + accountId = accountId, + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + addressDisplayUseCase = accountDisplayUseCase, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationUI.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationUI.kt new file mode 100644 index 0000000..78a828c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/rewardDestination/RewardDestinationUI.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_account_api.presenatation.account.chooser.AccountChooserBottomSheetDialog +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.presentation.view.RewardDestinationChooserView +import io.novafoundation.nova.feature_staking_impl.presentation.view.showRewardEstimation + +fun BaseFragment.observeRewardDestinationChooser( + viewModel: V, + chooser: RewardDestinationChooserView, +) where V : BaseViewModel, V : RewardDestinationMixin { + viewModel.rewardDestinationModelFlow.observe { + chooser.payoutTitle.setVisible(it is RewardDestinationModel.Payout) + chooser.payoutTarget.setVisible(it is RewardDestinationModel.Payout) + chooser.destinationRestake.setChecked(it is RewardDestinationModel.Restake) + chooser.destinationPayout.setChecked(it is RewardDestinationModel.Payout) + + if (it is RewardDestinationModel.Payout) { + chooser.payoutTarget.setAddressModel(it.destination) + } + } + + viewModel.rewardReturnsLiveData.observe { + chooser.destinationPayout.showRewardEstimation(it.payout) + chooser.destinationRestake.showRewardEstimation(it.restake) + } + + viewModel.showDestinationChooserEvent.observeEvent { + AccountChooserBottomSheetDialog( + context = requireContext(), + payload = it, + onSuccess = { _, item -> viewModel.payoutDestinationChanged(item) }, + onCancel = null, + title = R.string.staking_select_payout_account + ).show() + } + + chooser.destinationPayout.setOnClickListener { viewModel.payoutClicked(viewModel) } + chooser.destinationRestake.setOnClickListener { viewModel.restakeClicked() } + chooser.payoutTarget.setActionClickListener { viewModel.payoutTargetClicked(viewModel) } + chooser.learnMore.setOnClickListener { viewModel.learnMoreClicked() } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/search/SearchStakeTargetFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/search/SearchStakeTargetFragment.kt new file mode 100644 index 0000000..d9ecd43 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/search/SearchStakeTargetFragment.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.search + +import android.view.View +import androidx.annotation.StringRes +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.presentation.SearchState +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard +import io.novafoundation.nova.common.utils.submitListPreservingViewPoint +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentSearchCustomValidatorsBinding +import io.novafoundation.nova.feature_staking_impl.presentation.validators.StakeTargetAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel + +typealias DoneAction = () -> Unit + +abstract class SearchStakeTargetFragment, S> : + BaseFragment(), + StakeTargetAdapter.ItemHandler { + + class Configuration( + val doneAction: DoneAction?, + @StringRes val sortingLabelRes: Int, + ) + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + StakeTargetAdapter(this) + } + + override fun createBinding() = FragmentSearchCustomValidatorsBinding.inflate(layoutInflater) + + abstract val configuration: Configuration + + override fun initViews() { + binder.searchCustomValidatorsList.adapter = adapter + binder.searchCustomValidatorsList.setHasFixedSize(true) + binder.searchCustomValidatorsList.itemAnimator = null + + binder.searchCustomValidatorsToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + if (configuration.doneAction != null) { + binder.searchCustomValidatorsToolbar.setTextRight(getString(R.string.common_done)) + + binder.searchCustomValidatorsToolbar.setRightActionClickListener { + configuration.doneAction!!.invoke() + } + } + + binder.searchCustomValidatorRewards.setText(configuration.sortingLabelRes) + + binder.searchCustomValidatorsInput.requestFocus() + binder.searchCustomValidatorsInput.content.showSoftKeyboard() + } + + override fun subscribe(viewModel: V) { + viewModel.screenState.observe { + binder.searchCustomValidatorsList.setVisible(it is SearchState.Success, falseState = View.INVISIBLE) + binder.searchCustomValidatorProgress.setVisible(it is SearchState.Loading, falseState = View.INVISIBLE) + binder.searchCustomValidatorsPlaceholder.setVisible(it is SearchState.NoResults || it is SearchState.NoInput) + binder.searchCustomValidatorListHeader.setVisible(it is SearchState.Success) + + when (it) { + SearchState.NoInput -> { + binder.searchCustomValidatorsPlaceholder.setImage(R.drawable.ic_placeholder) + binder.searchCustomValidatorsPlaceholder.setText(getString(R.string.search_recipient_welcome_v2_2_0)) + } + + SearchState.NoResults -> { + binder.searchCustomValidatorsPlaceholder.setImage(R.drawable.ic_no_search_results) + binder.searchCustomValidatorsPlaceholder.setText(getString(R.string.staking_validator_search_empty_title)) + } + + SearchState.Loading -> {} + is SearchState.Success -> { + binder.searchCustomValidatorAccounts.text = it.headerTitle + + adapter.submitListPreservingViewPoint(it.data, binder.searchCustomValidatorsList) + } + } + } + + binder.searchCustomValidatorsInput.content.bindTo(viewModel.enteredQuery, viewLifecycleOwner.lifecycleScope) + } + + override fun stakeTargetInfoClicked(stakeTargetModel: StakeTargetModel) { + viewModel.itemInfoClicked(stakeTargetModel) + } + + override fun stakeTargetClicked(stakeTargetModel: StakeTargetModel) { + viewModel.itemClicked(stakeTargetModel) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/search/SearchStakeTargetViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/search/SearchStakeTargetViewModel.kt new file mode 100644 index 0000000..a3ac90e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/search/SearchStakeTargetViewModel.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.search + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.SearchState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +abstract class SearchStakeTargetViewModel(protected val resourceManager: ResourceManager) : BaseViewModel() { + + protected abstract val dataFlow: Flow>?> + + // flow wrapper is needed to avoid calling dataFlow before child constructor has been finished + val screenState = flow { + val innerFlow = dataFlow.map { data -> + when { + data == null -> SearchState.NoInput + + data.isNullOrEmpty().not() -> { + SearchState.Success( + data = data, + headerTitle = resourceManager.getString(R.string.common_search_results_number, data.size) + ) + } + + else -> SearchState.NoResults + } + } + + emitAll(innerFlow) + } + .onStart { emit(SearchState.Loading) } + .shareInBackground(SharingStarted.Lazily) + + val enteredQuery = MutableStateFlow("") + + abstract fun itemClicked(item: StakeTargetModel) + + abstract fun itemInfoClicked(item: StakeTargetModel) + + abstract fun backClicked() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/selectStakeTarget/ChooseStakedStakeTargetsBottomSheet.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/selectStakeTarget/ChooseStakedStakeTargetsBottomSheet.kt new file mode 100644 index 0000000..372bc0f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/selectStakeTarget/ChooseStakedStakeTargetsBottomSheet.kt @@ -0,0 +1,155 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.annotation.StringRes +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.utils.addAfter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.AccentActionView +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ClickHandler +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ItemSelectStakedCollatorBinding +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet.SelectionStyle +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.view.bindSelectedCollator + +class ChooseStakedStakeTargetsBottomSheet( + context: Context, + private val payload: Payload>, + stakedCollatorSelected: ClickHandler>, + onCancel: () -> Unit, + private val newStakeTargetClicked: ClickHandler?, + private val selectionStyle: SelectionStyle = SelectionStyle.RadioGroup +) : DynamicListBottomSheet>( + context = context, + payload = payload, + diffCallback = DiffCallback(), + onClicked = stakedCollatorSelected, + onCancel = onCancel +) { + + class Payload( + data: List, + selected: T? = null, + @StringRes val titleRes: Int = R.string.staking_parachain_collator, + ) : DynamicListBottomSheet.Payload(data, selected) + + enum class SelectionStyle { + RadioGroup, Arrow + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(payload.titleRes) + + maybeAddNewCollatorButton() + } + + override fun holderCreator(): HolderCreator> = { parent -> + ViewHolder( + binder = ItemSelectStakedCollatorBinding.inflate(parent.inflater(), parent, false), + selectionStyle = selectionStyle + ) + } + + private fun maybeAddNewCollatorButton() { + if (newStakeTargetClicked != null) { + val newCollatorAction = AccentActionView(context).apply { + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + + setDismissingClickListener { newStakeTargetClicked.invoke(this@ChooseStakedStakeTargetsBottomSheet, Unit) } + + setText(R.string.staking_parachain_new_collator) + setIcon(R.drawable.ic_add_circle) + } + + container.addAfter(headerView, newCollatorAction) + } + } +} + +private class ViewHolder( + private val binder: ItemSelectStakedCollatorBinding, + private val selectionStyle: SelectionStyle +) : DynamicListSheetAdapter.Holder>(binder.root) { + + init { + setInitialState() + } + + override fun bind( + item: SelectStakeTargetModel, + isSelected: Boolean, + handler: DynamicListSheetAdapter.Handler> + ) = with(binder) { + super.bind(item, isSelected, handler) + + itemSelectStakedCollatorCollator.bindSelectedCollator(item) + itemSelectStakedCollatorCheck.isChecked = isSelected + + val primaryTextColor = if (item.active) R.color.text_primary else R.color.text_secondary + + with(itemSelectStakedCollatorCollator) { + itemStakingTargetName.setTextColorRes(primaryTextColor) + itemStakingTargetSubtitleValue.setTextColorRes(primaryTextColor) + } + } + + private fun setInitialState() = with(binder) { + itemSelectStakedCollatorCollator.root.background = null + itemSelectStakedCollatorCollator.itemStakingTargetSubtitleLabel.makeGone() + + when (selectionStyle) { + SelectionStyle.RadioGroup -> { + itemSelectStakedCollatorCollator.itemStakingTargetInfo.makeGone() + itemSelectStakedCollatorCheck.makeVisible() + } + SelectionStyle.Arrow -> { + itemSelectStakedCollatorCheck.makeGone() + itemSelectStakedCollatorCollator.itemStakingTargetInfo.makeVisible() + itemSelectStakedCollatorCollator.itemStakingTargetInfo.setImageResource(R.drawable.ic_chevron_right) + } + } + } +} + +fun ChooseStakedStakeTargetsBottomSheet( + context: Context, + payload: ChooseStakedStakeTargetsBottomSheet.Payload>, + onResponse: (ChooseStakedStakeTargetsResponse) -> Unit, + onCancel: () -> Unit, + selectionStyle: SelectionStyle = SelectionStyle.RadioGroup +): ChooseStakedStakeTargetsBottomSheet { + return ChooseStakedStakeTargetsBottomSheet( + context = context, + payload = payload, + stakedCollatorSelected = { _, targetModel -> onResponse(ChooseStakedStakeTargetsResponse.Existing(targetModel.payload)) }, + onCancel = onCancel, + newStakeTargetClicked = { _, _ -> onResponse(ChooseStakedStakeTargetsResponse.New) }, + selectionStyle = selectionStyle + ) +} + +private class DiffCallback : DiffUtil.ItemCallback>() { + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: SelectStakeTargetModel, newItem: SelectStakeTargetModel): Boolean { + return oldItem.subtitle.toString() == newItem.subtitle.toString() && oldItem.active != newItem.active + } + + override fun areItemsTheSame(oldItem: SelectStakeTargetModel, newItem: SelectStakeTargetModel): Boolean { + return oldItem.addressModel.address == newItem.addressModel.address + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/selectStakeTarget/ChooseStakedStakeTargetsResponse.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/selectStakeTarget/ChooseStakedStakeTargetsResponse.kt new file mode 100644 index 0000000..7ea7751 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/selectStakeTarget/ChooseStakedStakeTargetsResponse.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget + +sealed class ChooseStakedStakeTargetsResponse { + + object New : ChooseStakedStakeTargetsResponse() + + class Existing(val target: T) : ChooseStakedStakeTargetsResponse() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/selectStakeTarget/SelectStakeTargetModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/selectStakeTarget/SelectStakeTargetModel.kt new file mode 100644 index 0000000..fa747f4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/selectStakeTarget/SelectStakeTargetModel.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.utils.Identifiable + +class SelectStakeTargetModel( + val addressModel: AddressModel, + val subtitle: CharSequence?, + val active: Boolean, + val payload: T, +) : Identifiable by payload diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/chooseTarget/SingleSelectChooseTargetFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/chooseTarget/SingleSelectChooseTargetFragment.kt new file mode 100644 index 0000000..4bf4079 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/chooseTarget/SingleSelectChooseTargetFragment.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.chooseTarget + +import android.widget.ImageView +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.scrollToTopWhenItemsShuffled +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentParachainStakingSelectCollatorBinding +import io.novafoundation.nova.feature_staking_impl.presentation.validators.StakeTargetAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel + +abstract class SingleSelectChooseTargetFragment> : + BaseFragment(), + StakeTargetAdapter.ItemHandler { + + override fun createBinding() = FragmentParachainStakingSelectCollatorBinding.inflate(layoutInflater) + + val adapter by lazy(LazyThreadSafetyMode.NONE) { + StakeTargetAdapter(this) + } + + private var filterAction: ImageView? = null + + override fun initViews() { + binder.selectCollatorList.adapter = adapter + binder.selectCollatorList.setHasFixedSize(true) + binder.selectCollatorList.itemAnimator = null + + binder.selectCollatorToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + filterAction = binder.selectCollatorToolbar.addCustomAction(R.drawable.ic_filter) { + viewModel.settingsClicked() + } + + if (viewModel.searchVisible) { + binder.selectCollatorToolbar.addCustomAction(R.drawable.ic_search) { + viewModel.searchClicked() + } + } + + binder.selectCollatorList.scrollToTopWhenItemsShuffled(viewLifecycleOwner) + + binder.selectCollatorClearFilters.setOnClickListener { viewModel.clearFiltersClicked() } + } + + override fun onDestroyView() { + super.onDestroyView() + + filterAction = null + } + + override fun subscribe(viewModel: V) { + viewModel.targetModelsFlow.observe { + adapter.submitList(it) + + binder.selectCollatorContentGroup.makeVisible() + binder.selectCollatorProgress.makeGone() + } + + viewModel.targetsCount.observe(binder.selectCollatorCount::setText) + + viewModel.scoringHeader.observe(binder.selectCollatorSorting::setText) + + viewModel.recommendationSettingsIcon.observe { icon -> + filterAction?.setImageResource(icon) + } + + viewModel.clearFiltersEnabled.observe(binder.selectCollatorClearFilters::setEnabled) + } + + override fun stakeTargetInfoClicked(stakeTargetModel: StakeTargetModel) { + viewModel.targetInfoClicked(stakeTargetModel) + } + + override fun stakeTargetClicked(stakeTargetModel: StakeTargetModel) { + viewModel.targetClicked(stakeTargetModel) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/chooseTarget/SingleSelectChooseTargetViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/chooseTarget/SingleSelectChooseTargetViewModel.kt new file mode 100644 index 0000000..a6a78b1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/chooseTarget/SingleSelectChooseTargetViewModel.kt @@ -0,0 +1,135 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.chooseTarget + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.SingleSelectRecommendator +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.recommendations.SingleSelectRecommendatorConfig +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +abstract class SingleSelectChooseTargetViewModel>( + private val router: ReturnableRouter, + private val recommendatorFactory: SingleSelectRecommendator.Factory, + private val resourceManager: ResourceManager, + private val tokenUseCase: TokenUseCase, + private val selectedAssetState: StakingSharedState, + private val state: SingleSelectChooseTargetState +) : BaseViewModel() { + + private val recommendator by lazyAsync { + recommendatorFactory.create(selectedAssetState.selectedOption(), computationalScope = this) + } + + private val recommendationConfigFlow = MutableStateFlow(state.defaultRecommendatorConfig) + + private val isChangedRecommendationConfigFlow = recommendationConfigFlow.map { + it != state.defaultRecommendatorConfig + } + + val recommendationSettingsIcon = isChangedRecommendationConfigFlow.map { isChanged -> + if (isChanged) R.drawable.ic_filter_indicator else R.drawable.ic_filter + } + .shareInBackground() + + val clearFiltersEnabled = isChangedRecommendationConfigFlow + + private val shownTargets = recommendationConfigFlow.map { it -> + ShownStakeTargets(recommendator().recommendations(it), it) + }.shareInBackground() + + private val tokenFlow = tokenUseCase.currentTokenFlow() + .inBackground() + .share() + + val targetModelsFlow = combine(shownTargets, tokenFlow) { shownTargets, token -> + state.convertTargetsToUi(shownTargets.targets, token, shownTargets.usedConfig) + } + .shareInBackground() + + val targetsCount = shownTargets.map { + resourceManager.getString(R.string.staking_parachain_collators_number_format, it.targets.size) + }.shareInBackground() + + val scoringHeader = recommendationConfigFlow.map(state::scoringHeaderFor) + .shareInBackground() + + val searchVisible = state.searchAction != null + + init { + listenRecommendationConfigChanges() + } + + protected abstract fun settingsClicked(currentConfig: C) + + protected abstract suspend fun targetInfoClicked(target: T) + + protected abstract suspend fun targetSelected(target: T) + + fun clearFiltersClicked() { + recommendationConfigFlow.value = state.defaultRecommendatorConfig + } + + fun backClicked() { + router.back() + } + + fun targetInfoClicked(stakeTargetModel: StakeTargetModel) = launchUnit { + targetInfoClicked(stakeTargetModel.stakeTarget) + } + + fun targetClicked(stakeTargetModel: StakeTargetModel) = launchUnit { + targetSelected(stakeTargetModel.stakeTarget) + } + + fun settingsClicked() = launch { + settingsClicked(recommendationConfigFlow.value) + } + + fun searchClicked() { + state.searchAction?.invoke() + } + + private fun listenRecommendationConfigChanges() { + state.recommendationConfigChanges() + .onEach { recommendationConfigFlow.value = it } + .inBackground() + .launchIn(this) + } + + interface SingleSelectChooseTargetState { + + val defaultRecommendatorConfig: C + + val searchAction: SearchAction? + + suspend fun convertTargetsToUi( + targets: List, + token: Token, + config: C + ): List> + + fun scoringHeaderFor(config: C): String + + fun recommendationConfigChanges(): Flow + } + + private inner class ShownStakeTargets(val targets: List, val usedConfig: C) +} + +typealias SearchAction = () -> Unit diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/SingleSelectStakingRewardEstimationComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/SingleSelectStakingRewardEstimationComponent.kt new file mode 100644 index 0000000..aa05961 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/SingleSelectStakingRewardEstimationComponent.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.feature_staking_impl.domain.rewards.PeriodReturns +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapPeriodReturnsToRewardEstimation +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.model.RewardEstimation +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart +import java.math.BigDecimal + +abstract class SingleSelectStakingRewardEstimationComponent( + private val resourceManager: ResourceManager, + + computationalScope: ComputationalScope, + assetFlow: Flow, + selectedAmountFlow: Flow, + selectedTargetFlow: Flow +) : StakingRewardEstimationComponent, + ComputationalScope by computationalScope { + + abstract suspend fun calculatePeriodReturns(selectedTarget: AccountIdKey?, selectedAmount: BigDecimal): PeriodReturns + + private val periodReturnsFlow = combine( + selectedAmountFlow.onStart { emit(BigDecimal.ONE) }, + selectedTargetFlow.onStart { emit(null) } + ) { selectedAmount, selectedTarget -> + calculatePeriodReturns(selectedTarget, selectedAmount) + } + + override val rewardEstimation: Flow = combine(assetFlow, periodReturnsFlow) { asset, periodReturns -> + mapPeriodReturnsToRewardEstimation( + periodReturns = periodReturns, + token = asset.token, + resourceManager = resourceManager, + ) + }.shareInBackground(SharingStarted.Lazily) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/SingleSelectStakingRewardEstimationComponentFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/SingleSelectStakingRewardEstimationComponentFactory.kt new file mode 100644 index 0000000..e85cd5c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/SingleSelectStakingRewardEstimationComponentFactory.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal + +interface SingleSelectStakingRewardEstimationComponentFactory { + + fun create( + computationalScope: ComputationalScope, + assetFlow: Flow, + selectedAmount: Flow, + selectedTarget: Flow + ): SingleSelectStakingRewardEstimationComponent +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/StakingRewardEstimationComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/StakingRewardEstimationComponent.kt new file mode 100644 index 0000000..a720866 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/StakingRewardEstimationComponent.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards + +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.model.RewardEstimation +import kotlinx.coroutines.flow.Flow + +interface StakingRewardEstimationComponent { + + val rewardEstimation: Flow +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/Ui.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/Ui.kt new file mode 100644 index 0000000..d34ffda --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/rewards/Ui.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards + +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.feature_staking_impl.presentation.view.RewardDestinationView +import io.novafoundation.nova.feature_staking_impl.presentation.view.showRewardEstimation + +fun BaseFragmentMixin<*>.setupParachainStakingRewardsComponent( + component: StakingRewardEstimationComponent, + view: RewardDestinationView +) { + component.rewardEstimation.observe(view::showRewardEstimation) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/start/StartSingleSelectStakingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/start/StartSingleSelectStakingFragment.kt new file mode 100644 index 0000000..023a3a2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/start/StartSingleSelectStakingFragment.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.start + +import androidx.annotation.CallSuper +import io.novafoundation.nova.common.address.WithAccountId +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentParachainStakingStartBinding +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards.setupParachainStakingRewardsComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +abstract class StartSingleSelectStakingFragment> : BaseFragment() + where T : Identifiable, T : WithAccountId { + + override fun createBinding() = FragmentParachainStakingStartBinding.inflate(layoutInflater) + + @CallSuper + override fun initViews() { + binder.startParachainStakingToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.startParachainStakingNext.prepareForProgress(viewLifecycleOwner) + binder.startParachainStakingNext.setOnClickListener { viewModel.nextClicked() } + + binder.startParachainStakingCollator.setOnClickListener { viewModel.selectTargetClicked() } + } + + override fun subscribe(viewModel: V) { + observeValidations(viewModel) + setupAmountChooser(viewModel.amountChooserMixin, binder.startParachainStakingAmountField) + setupParachainStakingRewardsComponent(viewModel.rewardsComponent, binder.startParachainStakingRewards) + setupFeeLoading(viewModel.originFeeMixin, binder.startParachainStakingFee) + observeHints(viewModel.hintsMixin, binder.startParachainStakingHints) + + viewModel.title.observe(binder.startParachainStakingToolbar::setTitle) + + viewModel.selectedTargetModelFlow.observe { + binder.startParachainStakingCollator.setSelectedTarget(it) + } + + viewModel.buttonState.observe(binder.startParachainStakingNext::setState) + + viewModel.minimumStake.observe(binder.startParachainStakingMinStake::showAmount) + + viewModel.chooseTargetAction.awaitableActionLiveData.observeEvent { action -> + ChooseStakedStakeTargetsBottomSheet( + context = requireContext(), + payload = action.payload, + onResponse = action.onSuccess, + onCancel = action.onCancel, + ).show() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/start/StartSingleSelectStakingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/start/StartSingleSelectStakingViewModel.kt new file mode 100644 index 0000000..dde42ea --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/start/StartSingleSelectStakingViewModel.kt @@ -0,0 +1,314 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.start + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.WithAccountId +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.utils.findById +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.SingleSelectRecommendator +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model.TargetWithStakedAmount +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsResponse +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.SelectStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards.SingleSelectStakingRewardEstimationComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.start.StartSingleSelectStakingViewModel.StartSingleSelectStakingLogic +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.create +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.math.BigDecimal +import java.math.BigInteger + +abstract class StartSingleSelectStakingViewModel>( + logicFactory: StartSingleSelectStakingLogic.Factory, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val rewardsComponentFactory: SingleSelectStakingRewardEstimationComponentFactory, + private val assetUseCase: AssetUseCase, + private val resourceManager: ResourceManager, + protected val validationExecutor: ValidationExecutor, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val recommendatorFactory: SingleSelectRecommendator.Factory, + private val selectedAssetState: StakingSharedState, + private val router: ReturnableRouter, + amountChooserMixinFactory: AmountChooserMixin.Factory, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor + where T : Identifiable, T : WithAccountId { + + @Suppress("LeakingThis") + protected val logic: L = logicFactory.create(this) + + abstract val hintsMixin: HintsMixin + + private val selectRecommendator by lazyAsync { + recommendatorFactory.create(selectedAssetState.selectedOption(), computationalScope = this) + } + + protected val validationInProgress = MutableStateFlow(false) + + protected val assetFlow = assetUseCase.currentAssetFlow() + .share() + + private val chainAssetFlow = selectedAssetState.selectedAssetFlow() + + private val stakeableAmount = logic.stakeableAmount(assetFlow) + .shareInBackground() + + val originFeeMixin = feeLoaderMixinV2Factory.createDefault(viewModelScope, chainAssetFlow) + + private val maxAmountProvider = MaxActionProvider.create(viewModelScope) { + chainAssetFlow.providingBalance(stakeableAmount) + .deductFee(originFeeMixin) + } + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = assetFlow, + maxActionProvider = maxAmountProvider + ) + + private val isStakeMoreFlow = logic.isStakeMore() + + private val alreadyStakedTargetsFlow = logic.alreadyStakedTargets() + .shareInBackground() + + private val selectedTargetFlow = MutableStateFlow(null) + + private val selectedTargetIdFlow = selectedTargetFlow.map { it?.accountId } + .distinctUntilChanged() + + private val selectedTargetWithStake = selectedTargetFlow.filterNotNull().flatMapLatest { selectedTarget -> + logic.alreadyStakedAmountTo(selectedTarget.accountId).map { + TargetWithStakedAmount(it, selectedTarget) + } + } + + @Suppress("UNCHECKED_CAST") + val selectedTargetModelFlow = combine(selectedTargetWithStake, assetFlow, logic::mapStakedTargetToUi) + .onStart?> { emit(null) } + .shareInBackground() + + private val alreadyStakedAmountToSelected = selectedTargetFlow.transformLatest { selectedTarget -> + if (selectedTarget == null) { + emit(BigInteger.ZERO) + return@transformLatest + } + + emitAll(logic.alreadyStakedAmountTo(selectedTarget.accountId)) + } + + private val resultingStakedAmountFlow = combine( + alreadyStakedAmountToSelected, + amountChooserMixin.amount, + assetFlow + ) { currentDelegationInPlanks, enteredAmount, asset -> + val currentDelegationAmount = asset.token.amountFromPlanks(currentDelegationInPlanks) + + currentDelegationAmount + enteredAmount + } + + val chooseTargetAction = actionAwaitableMixinFactory.create, ChooseStakedStakeTargetsResponse>() + + val minimumStake = selectedTargetFlow.map { + val minimumStake = logic.minimumStakeToGetRewards(it) + val asset = assetFlow.first() + + amountFormatter.formatAmountToAmountModel(minimumStake, asset) + }.shareInBackground() + + val rewardsComponent = rewardsComponentFactory.create( + computationalScope = this, + assetFlow = assetFlow, + selectedAmount = resultingStakedAmountFlow, + selectedTarget = selectedTargetIdFlow + ) + + val title = combine(assetFlow, isStakeMoreFlow) { asset, isStakeMore -> + if (isStakeMore) { + resourceManager.getString(R.string.staking_bond_more_v1_9_0) + } else { + resourceManager.getString(R.string.staking_stake_format, asset.token.configuration.symbol) + } + } + .shareInBackground() + + val buttonState = combine( + validationInProgress, + selectedTargetFlow, + amountChooserMixin.inputState + ) { validationInProgress, collator, amountInput -> + when { + validationInProgress -> DescriptiveButtonState.Loading + collator == null -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.staking_parachain_select_collator_hint)) + amountInput.value.isEmpty() -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_amount)) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + } + + init { + originFeeMixin.connectWith( + inputSource1 = amountChooserMixin.backPressuredPlanks, + inputSource2 = selectedTargetIdFlow, + feeConstructor = { _, amount, selectedTargetId -> + val stakeTargetId = selectedTargetId + + logic.estimateFee(amount, stakeTargetId) + }, + ) + + listenSelectedTargetChanges() + setInitialTarget() + } + + protected abstract suspend fun openSelectNewTarget() + + protected abstract suspend fun openSelectFirstTarget() + + protected abstract suspend fun goNext( + target: T, + amount: BigDecimal, + fee: Fee, + asset: Asset + ) + + fun selectTargetClicked() = launch { + val alreadyStakedCollators = alreadyStakedTargetsFlow.first() + val selected = selectedTargetFlow.first() + val asset = assetFlow.first() + + if (alreadyStakedCollators.isEmpty()) { + openSelectFirstTarget() + } else { + val payload = createSelectTargetPayload(alreadyStakedCollators, selected, asset) + + when (val response = chooseTargetAction.awaitAction(payload)) { + ChooseStakedStakeTargetsResponse.New -> openSelectNewTarget() + is ChooseStakedStakeTargetsResponse.Existing -> selectedTargetFlow.value = response.target + } + } + } + + fun nextClicked() = launchUnit { + val selectedTarget = selectedTargetFlow.first() ?: return@launchUnit + + validationInProgress.value = true + + val asset = assetFlow.first() + val amount = amountChooserMixin.amount.first() + val fee = originFeeMixin.awaitFee() + + goNext(selectedTarget, amount, fee, asset) + } + + fun backClicked() { + router.back() + } + + private suspend fun createSelectTargetPayload( + targetWithStakedAmounts: List>, + selected: T?, + asset: Asset, + ): ChooseStakeTargetActionPayload { + return withContext(Dispatchers.Default) { + val collatorModels = targetWithStakedAmounts.map { + logic.mapStakedTargetToUi(it, asset) + } + + val selectedModel = collatorModels.findById(selected) + + ChooseStakedStakeTargetsBottomSheet.Payload(collatorModels, selectedModel) + } + } + + private fun setInitialTarget() = launch { + val isStakeMore = isStakeMoreFlow.first() + + if (isStakeMore) { + val alreadyStakedTargets = alreadyStakedTargetsFlow.first() + selectedTargetFlow.value = alreadyStakedTargets.first().target + } else { + selectedTargetFlow.value = selectRecommendator.await().defaultRecommendation() + } + } + + private fun listenSelectedTargetChanges() { + logic.selectedTargetChanges() + .onEach { selectedTargetFlow.value = it } + .inBackground() + .launchIn(this) + } + + interface StartSingleSelectStakingLogic { + + fun interface Factory> { + + fun create(computationalScope: ComputationalScope): L + } + + fun selectedTargetChanges(): Flow + + fun stakeableAmount(assetFlow: Flow): Flow + + fun isStakeMore(): Flow + + fun alreadyStakedTargets(): Flow>> + + fun alreadyStakedAmountTo(accountIdKey: AccountIdKey): Flow + + suspend fun mapStakedTargetToUi( + target: TargetWithStakedAmount, + asset: Asset + ): SelectStakeTargetModel + + suspend fun minimumStakeToGetRewards(selectedStakeTarget: T?): Balance + + suspend fun estimateFee(amount: Balance, targetId: AccountIdKey?): Fee + } +} + +private typealias ChooseStakeTargetActionPayload = ChooseStakedStakeTargetsBottomSheet.Payload> diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/startConfirm/ConfirmStartSingleTargetStakingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/startConfirm/ConfirmStartSingleTargetStakingFragment.kt new file mode 100644 index 0000000..9dab6da --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/startConfirm/ConfirmStartSingleTargetStakingFragment.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentParachainStakingStartConfirmBinding +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +abstract class ConfirmStartSingleTargetStakingFragment> : + BaseFragment() { + + override fun createBinding() = FragmentParachainStakingStartConfirmBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmStartParachainStakingToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.confirmStartParachainStakingExtrinsicInfo.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.confirmStartParachainStakingConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmStartParachainStakingConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.confirmStartParachainStakingCollator.setOnClickListener { viewModel.stakeTargetClicked() } + } + + override fun subscribe(viewModel: V) { + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel.feeLoaderMixin, binder.confirmStartParachainStakingExtrinsicInfo.fee) + observeHints(viewModel.hintsMixin, binder.confirmStartParachainStakingHints) + + viewModel.title.observe(binder.confirmStartParachainStakingToolbar::setTitle) + viewModel.showNextProgress.observe(binder.confirmStartParachainStakingConfirm::setProgressState) + + viewModel.amountModel.observe { amountModel -> + binder.confirmStartParachainStakingAmount.setAmount(amountModel) + binder.confirmStartParachainStakingAmount.makeVisible() + } + + viewModel.currentAccountModelFlow.observe(binder.confirmStartParachainStakingExtrinsicInfo::setAccount) + viewModel.walletFlow.observe(binder.confirmStartParachainStakingExtrinsicInfo::setWallet) + + viewModel.collatorAddressModel.observe(binder.confirmStartParachainStakingCollator::showAddress) + viewModel.amountModel.observe(binder.confirmStartParachainStakingAmount::setAmount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/startConfirm/ConfirmStartSingleTargetStakingPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/startConfirm/ConfirmStartSingleTargetStakingPayload.kt new file mode 100644 index 0000000..a39f69e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/startConfirm/ConfirmStartSingleTargetStakingPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel + +interface ConfirmStartSingleTargetStakingPayload { + + val fee: FeeParcelModel + + val amount: Balance +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/startConfirm/ConfirmStartSingleTargetStakingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/startConfirm/ConfirmStartSingleTargetStakingViewModel.kt new file mode 100644 index 0000000..dfe1089 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/startConfirm/ConfirmStartSingleTargetStakingViewModel.kt @@ -0,0 +1,149 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.presentation.StarkingReturnableRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm.ConfirmStartSingleTargetStakingViewModel.ConfirmStartSingleTargetStakingState +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@Suppress("LeakingThis") +abstract class ConfirmStartSingleTargetStakingViewModel( + stateFactory: ConfirmStartSingleTargetStakingState.Factory, + private val router: StarkingReturnableRouter, + private val addressIconGenerator: AddressIconGenerator, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val resourceManager: ResourceManager, + private val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + private val payload: ConfirmStartSingleTargetStakingPayload, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions { + + protected val state = stateFactory.create(scope = this) + + private val fee = mapFeeFromParcel(payload.fee) + + abstract val hintsMixin: HintsMixin + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + val currentAccountModelFlow = selectedAccountUseCase.selectedMetaAccountFlow().map { + addressIconGenerator.createAccountAddressModel( + chain = selectedAssetState.chain(), + account = it, + name = null + ) + }.shareInBackground() + + val title = state.isStakeMoreFlow().map { isStakeMore -> + if (isStakeMore) { + resourceManager.getString(R.string.staking_bond_more_v1_9_0) + } else { + resourceManager.getString(R.string.staking_start_title) + } + } + .shareInBackground() + + val amountModel = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.amount, asset) + } + .shareInBackground() + + val feeLoaderMixin = feeLoaderMixinV2Factory.createDefault(viewModelScope, selectedAssetState.selectedAssetFlow()) + + val walletFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val collatorAddressModel = flowOf { + state.collatorAddressModel(selectedAssetState.chain()) + }.shareInBackground() + + protected val _showNextProgress = MutableStateFlow(false) + val showNextProgress: StateFlow = _showNextProgress + + init { + setInitialFee() + } + + protected abstract suspend fun confirmClicked( + fee: Fee, + amount: Balance, + asset: Asset + ) + + protected abstract suspend fun openStakeTargetInfo() + + fun confirmClicked() = launchUnit { + confirmClicked(fee, payload.amount, assetFlow.first()) + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launchUnit { + val address = currentAccountModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + fun stakeTargetClicked() = launchUnit { + openStakeTargetInfo() + } + + private fun setInitialFee() = launch { + feeLoaderMixin.setFee(fee) + } + + interface ConfirmStartSingleTargetStakingState { + + fun interface Factory { + + fun create(scope: ComputationalScope): S + } + + fun isStakeMoreFlow(): Flow + + suspend fun collatorAddressModel(chain: Chain): AddressModel + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/view/SelectStakeTargetView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/view/SelectStakeTargetView.kt new file mode 100644 index 0000000..0219ee2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/singleSelect/view/SelectStakeTargetView.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.view + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ItemValidatorBinding +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.SelectStakeTargetModel + +class SelectStakeTargetView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + val binder = ItemValidatorBinding.inflate(inflater(), this, true) + + private var unselectedLabel: Int = R.string.staking_parachain_select_collator + + init { + background = getRoundedCornerDrawable(R.color.block_background).withRippleMask() + clipToOutline = true + + binder.itemStakingTargetCheck.makeGone() + binder.itemStakingTargetActionIcon.makeGone() + binder.itemStakingTargetSubtitleLabel.makeGone() + + binder.itemStakingTargetInfo.setImageResource(R.drawable.ic_chevron_right) + binder.itemStakingTargetInfo.setImageTintRes(R.color.icon_secondary) + + setSelectedTarget(null) + } + + fun setSelectedTarget(selectedTarget: SelectStakeTargetModel<*>?) { + if (selectedTarget == null) { + binder.itemStakingTargetName.setText(unselectedLabel) + binder.itemStakingTargetIcon.setImageResource(R.drawable.ic_identicon_placeholder) + } else { + binder.bindSelectedCollator(selectedTarget) + } + } + + fun setUnselectedLabel(@StringRes label: Int) { + unselectedLabel = label + } +} + +fun ItemValidatorBinding.bindSelectedCollator(selectedCollator: SelectStakeTargetModel<*>) { + itemStakingTargetName.text = selectedCollator.addressModel.nameOrAddress + itemStakingTargetIcon.setImageDrawable(selectedCollator.addressModel.image) + + bindSubtitle(selectedCollator.subtitle) +} + +private fun ItemValidatorBinding.bindSubtitle(subtitle: CharSequence?) { + itemStakingTargetSubtitleValue.setVisible(subtitle != null) + + if (subtitle != null) { + itemStakingTargetSubtitleValue.text = subtitle + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/validation/Failure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/validation/Failure.kt new file mode 100644 index 0000000..93a132f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/common/validation/Failure.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.common.validation + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.ParachainStakingUnbondPreliminaryValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingValidationFailure.NotEnoughStakeable +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingValidationFailure.CannotPayFee +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingValidationFailure.MaxNominatorsReached +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingValidationFailure.AmountLessThanMinimum +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingValidationFailure.NotEnoughFundToStayAboveED +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.handleStakingMinimumBondError +import io.novafoundation.nova.feature_wallet_api.domain.validation.amountIsTooBig +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun stakingValidationFailure( + reason: SetupStakingValidationFailure, + resourceManager: ResourceManager, +): TitleAndMessage { + val (title, message) = with(resourceManager) { + when (reason) { + NotEnoughStakeable, CannotPayFee -> amountIsTooBig() + + MaxNominatorsReached -> { + getString(R.string.staking_max_nominators_reached_title) to getString(R.string.staking_max_nominators_reached_message) + } + + is AmountLessThanMinimum -> handleStakingMinimumBondError(resourceManager, reason) + + is NotEnoughFundToStayAboveED -> handleInsufficientBalanceCommission( + reason, + resourceManager + ) + } + } + + return title to message +} + +fun unbondPreliminaryValidationFailure( + reason: ParachainStakingUnbondPreliminaryValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage { + return when (reason) { + ParachainStakingUnbondPreliminaryValidationFailure.NoAvailableCollators -> { + resourceManager.getString(R.string.staking_parachain_no_unbond_collators_title) to + resourceManager.getString(R.string.staking_parachain_no_unbond_collators_message) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/Mappers.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/Mappers.kt new file mode 100644 index 0000000..f71d327 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/Mappers.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common + +import android.text.SpannableStringBuilder +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.getAssetIconOrFallback +import io.novafoundation.nova.common.presentation.masking.getUnmaskedOrElse +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.appendEnd +import io.novafoundation.nova.common.utils.drawableSpan +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.formatting.spannable.format +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.NoStake +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.NoStake.FlowType +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.NotYetResolved +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.WithoutStake +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.isSyncingPrimary +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.isSyncingSecondary +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model.StakingDashboardModel +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model.StakingDashboardModel.StakingTypeModel +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view.syncingIf +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType +import io.novasama.substrate_sdk_android.hash.isPositive + +class StakingDashboardPresentationMapperFactory( + private val resourceManager: ResourceManager, + private val assetIconProvider: AssetIconProvider +) { + fun create(maskableValueFormatter: MaskableValueFormatter): StakingDashboardPresentationMapper { + return RealStakingDashboardPresentationMapper(resourceManager, maskableValueFormatter, assetIconProvider) + } +} + +interface StakingDashboardPresentationMapper { + + fun mapWithoutStakeItemToUi(withoutStake: AggregatedStakingDashboardOption): StakingDashboardModel.NoStakeItem + + fun mapStakingTypeToUi(stakingType: StakingType): StakingTypeModel +} + +class RealStakingDashboardPresentationMapper( + private val resourceManager: ResourceManager, + private val maskableValueFormatter: MaskableValueFormatter, + private val assetIconProvider: AssetIconProvider +) : StakingDashboardPresentationMapper { + + @Suppress("UNCHECKED_CAST") + override fun mapWithoutStakeItemToUi( + withoutStake: AggregatedStakingDashboardOption + ): StakingDashboardModel.NoStakeItem { + return when (withoutStake.stakingState) { + is NoStake -> mapNoStakeItemToUi(withoutStake as AggregatedStakingDashboardOption) + NotYetResolved -> mapNotYetResolvedItemToUi(withoutStake as AggregatedStakingDashboardOption) + } + } + + override fun mapStakingTypeToUi(stakingType: StakingType): StakingTypeModel { + return if (stakingType == StakingType.NOMINATION_POOLS) { + StakingTypeModel( + icon = R.drawable.ic_nomination_pool, + text = resourceManager.getString(R.string.nomination_pools_pool) + ) + } else { + StakingTypeModel( + icon = R.drawable.ic_nominator, + text = resourceManager.getString(R.string.nomination_pools_direct) + ) + } + } + + private fun mapNotYetResolvedItemToUi(noStake: AggregatedStakingDashboardOption): StakingDashboardModel.NoStakeItem { + return StakingDashboardModel.NoStakeItem( + tokenName = noStake.token.configuration.name.syncingIf(isSyncing = true), + assetId = noStake.token.configuration.fullId, + earnings = ExtendedLoadingState.Loading, + availableBalance = null, + stakingTypeBadge = null, + assetIcon = assetIconProvider.getAssetIconOrFallback(noStake.token.configuration.icon).syncingIf(isSyncing = true) + ) + } + + private fun mapNoStakeItemToUi(noStake: AggregatedStakingDashboardOption): StakingDashboardModel.NoStakeItem { + val stats = noStake.stakingState.stats + val syncingStage = noStake.syncingStage + + val availableBalance = noStake.stakingState.availableBalance + val formattedAvailableBalance = if (availableBalance.isPositive()) { + val maskableValue = maskableValueFormatter.format { availableBalance.formatPlanks(noStake.token.configuration) } + .getUnmaskedOrElse { + val maskingDrawable = resourceManager.getDrawable(R.drawable.mask_dots_small) + SpannableStringBuilder() + .append(" ") // Small space before masking + .appendEnd(drawableSpan(maskingDrawable, extendToLineHeight = true)) + } + + SpannableFormatter.format(resourceManager, R.string.common_available_format, maskableValue) + } else { + null + } + + val stakingType = noStake.stakingState.flowType.displayableStakingType() + + return StakingDashboardModel.NoStakeItem( + tokenName = noStake.token.configuration.name.syncingIf(syncingStage.isSyncingPrimary()), + assetId = noStake.token.configuration.fullId, + earnings = stats.map { it.estimatedEarnings.format().syncingIf(syncingStage.isSyncingSecondary()) }, + availableBalance = formattedAvailableBalance, + stakingTypeBadge = stakingType?.let(::mapStakingTypeToUi), + assetIcon = assetIconProvider.getAssetIconOrFallback(noStake.token.configuration.icon).syncingIf(syncingStage.isSyncingPrimary()) + ) + } + + private fun FlowType.displayableStakingType(): StakingType? { + return when (this) { + is FlowType.Aggregated -> null + is FlowType.Single -> stakingType.takeIf { showStakingType } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/list/DashboardLoadingAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/list/DashboardLoadingAdapter.kt new file mode 100644 index 0000000..e47ccdd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/list/DashboardLoadingAdapter.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.list + +import android.view.ViewGroup +import androidx.annotation.LayoutRes +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import com.facebook.shimmer.ShimmerFrameLayout +import io.novafoundation.nova.common.utils.inflateChild +import kotlinx.android.extensions.LayoutContainer + +class DashboardLoadingAdapter( + private val initialNumberOfItems: Int, + @LayoutRes private val layout: Int, +) : RecyclerView.Adapter() { + + private var numberOfItems: Int = initialNumberOfItems + + fun setLoaded(loaded: Boolean) { + val newItems = if (loaded) 0 else initialNumberOfItems + setNumberOfLoadingItems(newItems) + } + + private fun setNumberOfLoadingItems(loadingItems: Int) { + val previousNumber = numberOfItems + numberOfItems = loadingItems + + if (previousNumber < numberOfItems) { + val itemsAdded = numberOfItems - previousNumber + + notifyItemRangeInserted(previousNumber, itemsAdded) + } else if (previousNumber > numberOfItems) { + val itemsRemoved = previousNumber - numberOfItems + + notifyItemRangeRemoved(numberOfItems, itemsRemoved) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DashboardLoadingHolder { + return DashboardLoadingHolder(parent.inflateChild(layout) as ViewGroup) + } + + override fun onBindViewHolder(holder: DashboardLoadingHolder, position: Int) { + holder.bind() + } + + override fun onViewRecycled(holder: DashboardLoadingHolder) { + holder.unbind() + } + + override fun getItemViewType(position: Int): Int { + return layout + } + + override fun getItemCount(): Int { + return numberOfItems + } +} + +class DashboardLoadingHolder(override val containerView: ViewGroup) : RecyclerView.ViewHolder(containerView), LayoutContainer { + + fun bind() { + containerView.children.forEach { (it as? ShimmerFrameLayout)?.startShimmer() } + } + + fun unbind() { + containerView.children.forEach { (it as? ShimmerFrameLayout)?.stopShimmer() } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/list/DashboardNoStakeAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/list/DashboardNoStakeAdapter.kt new file mode 100644 index 0000000..dddcdbd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/list/DashboardNoStakeAdapter.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model.StakingDashboardModel.NoStakeItem +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view.StakingDashboardNoStakeView + +class DashboardNoStakeAdapter( + private val handler: Handler, +) : ListAdapter(DashboardNoStakeDiffCallback()) { + + interface Handler { + + fun onNoStakeItemClicked(index: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DashboardNoStakeViewHolder { + return DashboardNoStakeViewHolder(StakingDashboardNoStakeView(parent.context), handler) + } + + override fun onBindViewHolder(holder: DashboardNoStakeViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: DashboardNoStakeViewHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + + resolvePayload(holder, position, payloads) { + when (it) { + NoStakeItem::earnings -> holder.bindEarnings(item) + NoStakeItem::availableBalance -> holder.bindAvailableBalance(item) + NoStakeItem::tokenName -> holder.bindTokenName(item) + NoStakeItem::assetIcon -> holder.bindAssetIcon(item) + NoStakeItem::stakingTypeBadge -> holder.bindStakingType(item) + } + } + } +} + +class DashboardNoStakeViewHolder( + override val containerView: StakingDashboardNoStakeView, + private val handler: DashboardNoStakeAdapter.Handler, +) : BaseViewHolder(containerView) { + + init { + containerView.setOnClickListener { handler.onNoStakeItemClicked(bindingAdapterPosition) } + } + + fun bind(model: NoStakeItem) { + bindEarnings(model) + bindAvailableBalance(model) + bindTokenName(model) + bindAssetIcon(model) + bindStakingType(model) + } + + fun bindTokenName(model: NoStakeItem) { + containerView.setTokenName(model.tokenName) + } + + fun bindEarnings(model: NoStakeItem) { + containerView.setEarnings(model.earnings) + } + + fun bindAvailableBalance(model: NoStakeItem) { + containerView.setAvailableBalance(model.availableBalance) + } + + fun bindStakingType(model: NoStakeItem) { + containerView.setStakingTypeBadge(model.stakingTypeBadge) + } + + fun bindAssetIcon(model: NoStakeItem) { + containerView.setAssetIcon(model.assetIcon) + } + + override fun unbind() { + containerView.unbind() + } +} + +private class DashboardNoStakeDiffCallback : DiffUtil.ItemCallback() { + + private val payloadGenerator = PayloadGenerator( + NoStakeItem::earnings, + NoStakeItem::availableBalance, + NoStakeItem::tokenName, + NoStakeItem::assetIcon, + NoStakeItem::stakingTypeBadge + ) + + override fun areItemsTheSame(oldItem: NoStakeItem, newItem: NoStakeItem): Boolean { + return oldItem.assetId == newItem.assetId + } + + override fun areContentsTheSame(oldItem: NoStakeItem, newItem: NoStakeItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: NoStakeItem, newItem: NoStakeItem): Any? { + return payloadGenerator.diff(oldItem, newItem) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/list/DashboardSectionAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/list/DashboardSectionAdapter.kt new file mode 100644 index 0000000..e51350a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/common/list/DashboardSectionAdapter.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.list + +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflateChild +import io.novafoundation.nova.feature_staking_impl.R + +class DashboardSectionAdapter( + private val textRes: Int +) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DashboardSectionHolder { + val item = parent.inflateChild(R.layout.item_dashboard_section) as TextView + + return DashboardSectionHolder(item, textRes) + } + + override fun getItemCount(): Int = 1 + + override fun onBindViewHolder(holder: DashboardSectionHolder, position: Int) {} +} + +class DashboardSectionHolder( + containerView: TextView, + @StringRes textRes: Int +) : RecyclerView.ViewHolder(containerView) { + + init { + containerView.setText(textRes) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/StakingDashboardFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/StakingDashboardFragment.kt new file mode 100644 index 0000000..b69d250 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/StakingDashboardFragment.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main + +import android.view.View +import androidx.recyclerview.widget.ConcatAdapter + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.domain.onNotLoaded +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.submitListPreservingViewPoint +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentStakingDashboardBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.list.DashboardLoadingAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.list.DashboardNoStakeAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.list.DashboardSectionAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.list.DashboardHasStakeAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.list.DashboardHeaderAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.list.MoreStakingOptionsAdapter + +class StakingDashboardFragment : + BaseFragment(), + DashboardHasStakeAdapter.Handler, + DashboardNoStakeAdapter.Handler, + DashboardHeaderAdapter.Handler, + MoreStakingOptionsAdapter.Handler { + + override fun createBinding() = FragmentStakingDashboardBinding.inflate(layoutInflater) + + private val headerAdapter = DashboardHeaderAdapter(this) + private val hasStakeLoadingAdapter = DashboardLoadingAdapter(initialNumberOfItems = 1, layout = R.layout.item_dashboard_has_stake_loading) + private val hasStakeAdapter = DashboardHasStakeAdapter(this) + private val sectionAdapter = DashboardSectionAdapter(R.string.staking_dashboard_no_stake_header) + private val noStakeLoadingAdapter = DashboardLoadingAdapter(initialNumberOfItems = 3, layout = R.layout.item_dashboard_loading) + private val noStakeAdapter = DashboardNoStakeAdapter(this) + private val moreStakingOptionsAdapter = MoreStakingOptionsAdapter(this) + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .dashboardComponentFactory() + .create(this) + .inject(this) + } + + override fun applyInsets(rootView: View) { + binder.stakingDashboardContent.applyStatusBarInsets() + } + + override fun initViews() { + binder.stakingDashboardContent.setHasFixedSize(true) + + binder.stakingDashboardContent.adapter = ConcatAdapter( + headerAdapter, + hasStakeLoadingAdapter, + hasStakeAdapter, + sectionAdapter, + noStakeLoadingAdapter, + noStakeAdapter, + moreStakingOptionsAdapter + ) + + binder.stakingDashboardContent.itemAnimator = null + } + + override fun subscribe(viewModel: StakingDashboardViewModel) { + viewModel.stakingDashboardUiFlow.observe { dashboardLoading -> + dashboardLoading.onLoaded { + hasStakeAdapter.submitListPreservingViewPoint(it.hasStakeItems, binder.stakingDashboardContent) + noStakeAdapter.submitListPreservingViewPoint(it.noStakeItems, binder.stakingDashboardContent) + + hasStakeLoadingAdapter.setLoaded(true) + noStakeLoadingAdapter.setLoaded(true) + }.onNotLoaded { + hasStakeLoadingAdapter.setLoaded(false) + noStakeLoadingAdapter.setLoaded(false) + } + } + + viewModel.walletUi.observe(headerAdapter::setSelectedWallet) + + viewModel.scrollToTopEvent.observeEvent { + binder.stakingDashboardContent.scrollToPosition(0) + } + } + + override fun onHasStakeItemClicked(index: Int) { + viewModel.onHasStakeItemClicked(index) + } + + override fun onNoStakeItemClicked(index: Int) { + viewModel.onNoStakeItemClicked(index) + } + + override fun avatarClicked() { + viewModel.avatarClicked() + } + + override fun onMoreOptionsClicked() { + viewModel.onMoreOptionsClicked() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/StakingDashboardViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/StakingDashboardViewModel.kt new file mode 100644 index 0000000..4523ac3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/StakingDashboardViewModel.kt @@ -0,0 +1,202 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.getAssetIconOrFallback +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.firstLoaded +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.throttleLast +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_staking_api.data.dashboard.StakingDashboardUpdateSystem +import io.novafoundation.nova.feature_staking_api.domain.dashboard.StakingDashboardInteractor +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.HasStake +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption.NoStake +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingDashboard +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.allStakingTypes +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.isSyncingPrimary +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.isSyncingSecondary +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.StakingDashboardPresentationMapper +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.StakingDashboardPresentationMapperFactory +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model.StakingDashboardModel +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view.syncingIf +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.AvailableStakingOptionsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StartStakingLandingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.view.StakeStatusModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private class DashboardFormatters( + val maskableValueFormatter: MaskableValueFormatter, + val presentationMapper: StakingDashboardPresentationMapper +) + +class StakingDashboardViewModel( + private val interactor: StakingDashboardInteractor, + private val accountUseCase: SelectedAccountUseCase, + private val resourceManager: ResourceManager, + private val stakingDashboardUpdateSystem: StakingDashboardUpdateSystem, + private val dashboardRouter: StakingDashboardRouter, + private val router: StakingRouter, + private val startMultiStakingRouter: StartMultiStakingRouter, + private val stakingSharedState: StakingSharedState, + private val presentationMapperFactory: StakingDashboardPresentationMapperFactory, + private val dashboardUpdatePeriod: Duration = 200.milliseconds, + private val maskableValueFormatterProvider: MaskableValueFormatterProvider, + private val amountFormatter: AmountFormatter, + private val assetIconProvider: AssetIconProvider +) : BaseViewModel() { + + private val dashboardFormattersFlow = maskableValueFormatterProvider.provideFormatter() + .map { DashboardFormatters(it, presentationMapperFactory.create(it)) } + .shareInBackground() + + val scrollToTopEvent = dashboardRouter.scrollToDashboardTopEvent + + val walletUi = accountUseCase.selectedWalletModelFlow() + .shareInBackground() + + private val stakingDashboardFlow = interactor.stakingDashboardFlow() + .shareInBackground() + + val stakingDashboardUiFlow = stakingDashboardFlow + .throttleLast(dashboardUpdatePeriod) + .combine(dashboardFormattersFlow) { dashboardLoading, valueFormatter -> dashboardLoading.map { mapDashboardToUi(it, valueFormatter) } } + .shareInBackground() + + init { + stakingDashboardUpdateSystem.start() + .inBackground() + .launchIn(this) + } + + fun onHasStakeItemClicked(index: Int) = launch { + val hasStakeItems = stakingDashboardFlow.firstLoaded().hasStake + val hasStakeItem = hasStakeItems.getOrNull(index) ?: return@launch + + openChainStaking( + chain = hasStakeItem.chain, + chainAsset = hasStakeItem.token.configuration, + stakingType = hasStakeItem.stakingState.stakingType + ) + } + + fun onNoStakeItemClicked(index: Int) = launch { + val withoutStakeItems = stakingDashboardFlow.firstLoaded().withoutStake + val withoutStakeItem = withoutStakeItems.getOrNull(index) ?: return@launch + val noStakeItemState = withoutStakeItem.stakingState as? NoStake ?: return@launch + + val stakingTypes = noStakeItemState.flowType.allStakingTypes + val chain = withoutStakeItem.chain + val chainAsset = withoutStakeItem.token.configuration + + stakingSharedState.setSelectedOption(chain, chainAsset, stakingTypes.first()) + + val payload = StartStakingLandingPayload( + availableStakingOptions = AvailableStakingOptionsPayload(chain.id, chainAsset.id, stakingTypes) + ) + + startMultiStakingRouter.openStartStakingLanding(payload) + } + + fun onMoreOptionsClicked() { + dashboardRouter.openMoreStakingOptions() + } + + fun avatarClicked() { + router.openSwitchWallet() + } + + private fun mapDashboardToUi(dashboard: StakingDashboard, formatters: DashboardFormatters): StakingDashboardModel { + return StakingDashboardModel( + hasStakeItems = dashboard.hasStake.map { mapHasStakeItemToUi(it, formatters) }, + noStakeItems = dashboard.withoutStake.map(formatters.presentationMapper::mapWithoutStakeItemToUi), + ) + } + + private fun mapHasStakeItemToUi( + hasStake: AggregatedStakingDashboardOption, + formatters: DashboardFormatters + ): StakingDashboardModel.HasStakeItem { + val stats = hasStake.stakingState.stats + val isSyncingPrimary = hasStake.syncingStage.isSyncingPrimary() + val isSyncingSecondary = hasStake.syncingStage.isSyncingSecondary() + + val stakingTypBadge = if (hasStake.stakingState.showStakingType) { + formatters.presentationMapper.mapStakingTypeToUi(hasStake.stakingState.stakingType) + } else { + null + } + + return StakingDashboardModel.HasStakeItem( + assetLabel = resourceManager.getString(R.string.staking_rewards, hasStake.token.configuration.name).syncingIf(isSyncingPrimary), + assetId = hasStake.token.configuration.fullId, + rewards = stats.map { + formatters.maskableValueFormatter.format { + amountFormatter.formatAmountToAmountModel(it.rewards, hasStake.token) + }.syncingIf(isSyncingSecondary) + }, + stake = formatters.maskableValueFormatter.format { + amountFormatter.formatAmountToAmountModel(hasStake.stakingState.stake, hasStake.token) + }.syncingIf(isSyncingSecondary), + status = stats.map { mapStakingStatusToUi(it.status).syncingIf(isSyncingSecondary) }, + earnings = stats.map { it.estimatedEarnings.format().syncingIf(isSyncingSecondary) }, + stakingTypeBadge = stakingTypBadge, + assetIcon = assetIconProvider.getAssetIconOrFallback(hasStake.token.configuration.icon).syncingIf(isSyncingPrimary) + ) + } + + private fun mapStakingStatusToUi(stakingStatus: HasStake.StakingStatus): StakeStatusModel { + return when (stakingStatus) { + HasStake.StakingStatus.ACTIVE -> StakeStatusModel( + indicatorRes = R.drawable.ic_indicator_positive_pulse, + text = resourceManager.getString(R.string.common_active), + textColorRes = R.color.text_positive + ) + + HasStake.StakingStatus.INACTIVE -> StakeStatusModel( + indicatorRes = R.drawable.ic_indicator_negative_pulse, + text = resourceManager.getString(R.string.staking_nominator_status_inactive), + textColorRes = R.color.text_negative + ) + + HasStake.StakingStatus.WAITING -> StakeStatusModel( + indicatorRes = R.drawable.ic_indicator_inactive_pulse, + text = resourceManager.getString(R.string.common_waiting), + textColorRes = R.color.text_primary + ) + } + } + + private suspend fun openChainStaking( + chain: Chain, + chainAsset: Chain.Asset, + stakingType: Chain.Asset.StakingType + ) { + stakingSharedState.setSelectedOption( + chain = chain, + chainAsset = chainAsset, + stakingType = stakingType + ) + + router.openChainStakingMain() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/deeplink/StakingDashboardDeepLinkHandler.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/deeplink/StakingDashboardDeepLinkHandler.kt new file mode 100644 index 0000000..9504ee8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/deeplink/StakingDashboardDeepLinkHandler.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +private const val STAKING_DASHBOARD_DEEP_LINK_PREFIX = "/open/staking" + +class StakingDashboardDeepLinkHandler( + private val stakingRouter: StakingRouter, + private val automaticInteractionGate: AutomaticInteractionGate +) : DeepLinkHandler { + + override val callbackFlow: Flow = emptyFlow() + + override suspend fun matches(data: Uri): Boolean { + val path = data.path ?: return false + return path.startsWith(STAKING_DASHBOARD_DEEP_LINK_PREFIX) + } + + override suspend fun handleDeepLink(data: Uri) = runCatching { + automaticInteractionGate.awaitInteractionAllowed() + + stakingRouter.openStakingDashboard() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/di/StakingDashboardComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/di/StakingDashboardComponent.kt new file mode 100644 index 0000000..2ca1285 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/di/StakingDashboardComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.StakingDashboardFragment + +@Subcomponent( + modules = [ + StakingDashboardModule::class + ] +) +@ScreenScope +interface StakingDashboardComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): StakingDashboardComponent + } + + fun inject(fragment: StakingDashboardFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/di/StakingDashboardModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/di/StakingDashboardModule.kt new file mode 100644 index 0000000..1cd53e0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/di/StakingDashboardModule.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_staking_api.data.dashboard.StakingDashboardUpdateSystem +import io.novafoundation.nova.feature_staking_api.domain.dashboard.StakingDashboardInteractor +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.StakingDashboardPresentationMapperFactory +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.StakingDashboardViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.components.ComponentsModule +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider + +@Module(includes = [ViewModelModule::class, ComponentsModule::class]) +class StakingDashboardModule { + + @Provides + @IntoMap + @ViewModelKey(StakingDashboardViewModel::class) + fun provideViewModel( + interactor: StakingDashboardInteractor, + accountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + dashboardUpdateSystem: StakingDashboardUpdateSystem, + dashboardRouter: StakingDashboardRouter, + router: StakingRouter, + stakingSharedState: StakingSharedState, + presentationMapperFactory: StakingDashboardPresentationMapperFactory, + startMultiStakingRouter: StartMultiStakingRouter, + valueFormatterProvider: MaskableValueFormatterProvider, + amountFormatter: AmountFormatter, + assetIconProvider: AssetIconProvider + ): ViewModel { + return StakingDashboardViewModel( + interactor = interactor, + accountUseCase = accountUseCase, + resourceManager = resourceManager, + stakingDashboardUpdateSystem = dashboardUpdateSystem, + dashboardRouter = dashboardRouter, + router = router, + stakingSharedState = stakingSharedState, + presentationMapperFactory = presentationMapperFactory, + startMultiStakingRouter = startMultiStakingRouter, + maskableValueFormatterProvider = valueFormatterProvider, + amountFormatter = amountFormatter, + assetIconProvider = assetIconProvider + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): StakingDashboardViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StakingDashboardViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/list/DashboardHasStakeAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/list/DashboardHasStakeAdapter.kt new file mode 100644 index 0000000..1dbc7a9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/list/DashboardHasStakeAdapter.kt @@ -0,0 +1,118 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model.StakingDashboardModel.HasStakeItem +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view.StakingDashboardHasStakeView + +class DashboardHasStakeAdapter( + private val handler: Handler, +) : ListAdapter(DashboardHasStakeDiffCallback()) { + + interface Handler { + + fun onHasStakeItemClicked(index: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DashboardHasStakeViewHolder { + return DashboardHasStakeViewHolder(StakingDashboardHasStakeView(parent.context), handler) + } + + override fun onBindViewHolder(holder: DashboardHasStakeViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: DashboardHasStakeViewHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + + resolvePayload(holder, position, payloads) { + when (it) { + HasStakeItem::stake -> holder.bindStake(item) + HasStakeItem::rewards -> holder.bindRewards(item) + HasStakeItem::status -> holder.bindStatus(item) + HasStakeItem::earnings -> holder.bindEarnings(item) + HasStakeItem::assetIcon -> holder.bindAssetIcon(item) + HasStakeItem::assetLabel -> holder.bindAssetLabel(item) + HasStakeItem::stakingTypeBadge -> holder.bindStakingType(item) + } + } + } +} + +class DashboardHasStakeViewHolder( + override val containerView: StakingDashboardHasStakeView, + private val handler: DashboardHasStakeAdapter.Handler, +) : BaseViewHolder(containerView) { + + init { + containerView.setOnClickListener { handler.onHasStakeItemClicked(bindingAdapterPosition) } + } + + fun bind(model: HasStakeItem) { + bindEarnings(model) + bindRewards(model) + bindStake(model) + bindStatus(model) + bindAssetIcon(model) + bindAssetLabel(model) + bindStakingType(model) + } + + fun bindAssetIcon(model: HasStakeItem) { + containerView.setAssetIcon(model.assetIcon) + } + + fun bindAssetLabel(model: HasStakeItem) { + containerView.setAssetLabel(model.assetLabel) + } + + fun bindEarnings(model: HasStakeItem) { + containerView.setEarnings(model.earnings) + } + + fun bindStakingType(model: HasStakeItem) { + containerView.setStakingTypeBadge(model.stakingTypeBadge) + } + + fun bindStake(model: HasStakeItem) { + containerView.setStake(model.stake) + } + + fun bindRewards(model: HasStakeItem) { + containerView.setRewards(model.rewards) + } + + fun bindStatus(model: HasStakeItem) { + containerView.setStatus(model.status) + } +} + +private class DashboardHasStakeDiffCallback : DiffUtil.ItemCallback() { + + private val payloadGenerator = PayloadGenerator( + HasStakeItem::assetLabel, + HasStakeItem::stake, + HasStakeItem::earnings, + HasStakeItem::status, + HasStakeItem::rewards, + HasStakeItem::assetIcon, + HasStakeItem::assetLabel, + HasStakeItem::stakingTypeBadge + ) + + override fun areItemsTheSame(oldItem: HasStakeItem, newItem: HasStakeItem): Boolean { + return oldItem.assetId == newItem.assetId + } + + override fun areContentsTheSame(oldItem: HasStakeItem, newItem: HasStakeItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: HasStakeItem, newItem: HasStakeItem): Any? { + return payloadGenerator.diff(oldItem, newItem) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/list/DashboardHeaderAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/list/DashboardHeaderAdapter.kt new file mode 100644 index 0000000..7faafe1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/list/DashboardHeaderAdapter.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedWalletModel +import io.novafoundation.nova.feature_staking_impl.databinding.ItemDashboardHeaderBinding + +class DashboardHeaderAdapter(private val handler: Handler) : RecyclerView.Adapter() { + + interface Handler { + fun avatarClicked() + } + + private var selectedWalletModel: SelectedWalletModel? = null + + fun setSelectedWallet(walletModel: SelectedWalletModel) { + this.selectedWalletModel = walletModel + + notifyItemChanged(0, Payload.ADDRESS) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DashboardHeaderHolder { + return DashboardHeaderHolder(ItemDashboardHeaderBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun onBindViewHolder(holder: DashboardHeaderHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + } else { + payloads.filterIsInstance().forEach { + when (it) { + Payload.ADDRESS -> holder.bindAddress(selectedWalletModel) + } + } + } + } + + override fun onBindViewHolder(holder: DashboardHeaderHolder, position: Int) { + holder.bind(selectedWalletModel) + } + + override fun getItemCount(): Int { + return 1 + } +} + +private enum class Payload { + ADDRESS +} + +class DashboardHeaderHolder( + private val binder: ItemDashboardHeaderBinding, + handler: DashboardHeaderAdapter.Handler, +) : RecyclerView.ViewHolder(binder.root) { + + init { + with(binder) { + stakingDashboardHeaderAvatar.setOnClickListener { handler.avatarClicked() } + } + } + + fun bind(addressModel: SelectedWalletModel?) { + bindAddress(addressModel) + } + + fun bindAddress(walletModel: SelectedWalletModel?) = walletModel?.let { + binder.stakingDashboardHeaderAvatar.setModel(it) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/list/MoreStakingOptionsAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/list/MoreStakingOptionsAdapter.kt new file mode 100644 index 0000000..8a70b1a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/list/MoreStakingOptionsAdapter.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view.StakingDashboardMoreOptionsView + +class MoreStakingOptionsAdapter( + private val handler: Handler +) : RecyclerView.Adapter() { + + interface Handler { + + fun onMoreOptionsClicked() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DashboardSectionHolder { + val item = StakingDashboardMoreOptionsView(parent.context) + + return DashboardSectionHolder(item, handler) + } + + override fun getItemCount(): Int = 1 + + override fun onBindViewHolder(holder: DashboardSectionHolder, position: Int) {} +} + +class DashboardSectionHolder( + containerView: StakingDashboardMoreOptionsView, + handler: MoreStakingOptionsAdapter.Handler +) : RecyclerView.ViewHolder(containerView) { + + init { + containerView.setOnClickListener { handler.onMoreOptionsClicked() } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/model/StakingDashboardModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/model/StakingDashboardModel.kt new file mode 100644 index 0000000..f8f2731 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/model/StakingDashboardModel.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view.SyncingData +import io.novafoundation.nova.feature_staking_impl.presentation.view.StakeStatusModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +class StakingDashboardModel( + val hasStakeItems: List, + val noStakeItems: List, +) { + + data class HasStakeItem( + val assetLabel: SyncingData, + override val assetId: FullChainAssetId, + override val stakingTypeBadge: StakingTypeModel?, + override val assetIcon: SyncingData, + val rewards: ExtendedLoadingState>>, + val stake: SyncingData>, + val status: ExtendedLoadingState>, + val earnings: ExtendedLoadingState>, + ) : BaseItem + + data class NoStakeItem( + override val stakingTypeBadge: StakingTypeModel?, + override val assetId: FullChainAssetId, + override val assetIcon: SyncingData, + val tokenName: SyncingData, + val availableBalance: CharSequence?, + val earnings: ExtendedLoadingState>, + ) : BaseItem + + interface BaseItem { + val stakingTypeBadge: StakingTypeModel? + val assetId: FullChainAssetId + val assetIcon: SyncingData + } + + data class StakingTypeModel(@DrawableRes val icon: Int, val text: String) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/Common.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/Common.kt new file mode 100644 index 0000000..5e4a27f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/Common.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view + +import android.view.View +import com.facebook.shimmer.ShimmerFrameLayout +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setShimmerShown + +class ShimmerableGroup(val container: ShimmerFrameLayout, val shimmerShape: View? = null, val content: V) + +data class SyncingData(val data: T, val isSyncing: Boolean) + +fun T.syncingIf(isSyncing: Boolean) = SyncingData(this, isSyncing) + +fun ShimmerableGroup.applyState( + loadingState: ExtendedLoadingState>, + setContent: (V.(T) -> Unit)? = null +) { + when (loadingState) { + is ExtendedLoadingState.Error, ExtendedLoadingState.Loading -> { + shimmerShape?.makeVisible() + container.showShimmer(true) + content.makeGone() + } + + is ExtendedLoadingState.Loaded -> { + shimmerShape?.makeGone() + content.makeVisible() + container.setShimmerShown(loadingState.data.isSyncing) + + setContent?.invoke(content, loadingState.data.data) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingDashboardHasStakeView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingDashboardHasStakeView.kt new file mode 100644 index 0000000..cf18321 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingDashboardHasStakeView.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.isLoaded +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setShimmerShown +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.unsafeLazy +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ItemDashboardHasStakeBinding +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model.StakingDashboardModel.StakingTypeModel +import io.novafoundation.nova.feature_staking_impl.presentation.view.StakeStatusModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableFiat +import io.novafoundation.nova.feature_wallet_api.presentation.model.maskableToken + +class StakingDashboardHasStakeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val imageLoader: ImageLoader + + private val binder = ItemDashboardHasStakeBinding.inflate(inflater(), this) + + private val rewardsAmountGroup by unsafeLazy { + ShimmerableGroup( + container = binder.itemDashboardHasStakeRewardsAmountContainer, + shimmerShape = binder.itemDashboardHasStakeRewardsAmountShimmer, + content = binder.itemDashboardHasStakeRewardsAmount + ) + } + + private val rewardsFiatGroup by unsafeLazy { + ShimmerableGroup( + container = binder.itemDashboardHasStakeRewardsFiatContainer, + shimmerShape = binder.itemDashboardHasStakeRewardsFiatShimmer, + content = binder.itemDashboardHasStakeRewardsFiat + ) + } + + private val stakeStatusGroup by unsafeLazy { + ShimmerableGroup( + container = binder.itemDashboardHasStakeStatusContainer, + shimmerShape = binder.itemDashboardHasStakeStatusShimmer, + content = binder.itemDashboardHasStakeStatus + ) + } + + private val earningsGroup by unsafeLazy { + ShimmerableGroup( + container = binder.itemDashboardHasStakeEarningsContainer, + shimmerShape = binder.itemDashboardHasStakeEarningsShimmer, + content = binder.itemDashboardHasStakeEarnings + ) + } + + init { + imageLoader = FeatureUtils.getCommonApi(context).imageLoader() + + background = context.getBlockDrawable().withRippleMask() + binder.itemDashboardHasStakeRightSection.background = getRoundedCornerDrawable(cornerSizeDp = 10, fillColorRes = R.color.block_background_dark) + + layoutParams = MarginLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + setMargins(16.dp(context), 8.dp(context), 16.dp(context), 0) + } + } + + fun setAssetIcon(assetIcon: SyncingData) { + binder.itemDashboardHasStakeAssetIcon.setIcon(assetIcon.data, imageLoader) + binder.itemDashboardHasStakeAssetContainer.setShimmerShown(assetIcon.isSyncing) + } + + fun setAssetLabel(assetLabel: SyncingData) { + binder.itemDashboardHasStakeRewardsLabelContainer.setShimmerShown(assetLabel.isSyncing) + binder.itemDashboardHasStakeRewardsLabel.text = assetLabel.data + } + + fun setRewards(rewardsState: ExtendedLoadingState>>) { + rewardsAmountGroup.applyState(rewardsState) { setMaskableText(it.maskableToken(), maskDrawableRes = R.drawable.mask_dots_big) } + rewardsFiatGroup.applyState(rewardsState) { setMaskableText(it.maskableFiat()) } + } + + fun setStake(stake: SyncingData>) { + binder.itemDashboardHasStakeStakeAmount.setMaskableText(stake.data.maskableToken()) + binder.itemDashboardHasStakeStakeAmountContainer.setShimmerShown(stake.isSyncing) + + binder.itemDashboardHasStakeStakesFiat.setMaskableText(stake.data.maskableFiat()) + binder.itemDashboardHasStakeStakesFiatContainer.setShimmerShown(stake.isSyncing) + } + + fun setStatus(status: ExtendedLoadingState>) { + stakeStatusGroup.applyState(status) { setModel(it) } + } + + fun setEarnings(earningsState: ExtendedLoadingState>) { + earningsGroup.applyState(earningsState) { text = it } + + binder.itemDashboardHasStakeEarningsSuffix.setVisible(earningsState.isLoaded()) + } + + fun setStakingTypeBadge(model: StakingTypeModel?) { + binder.itemDashboardHasStakeStakingType.setModelOrHide(model) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingDashboardMoreOptionsView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingDashboardMoreOptionsView.kt new file mode 100644 index 0000000..c0a00e5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingDashboardMoreOptionsView.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_staking_impl.databinding.ViewMoreOptionsBinding + +class StakingDashboardMoreOptionsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewMoreOptionsBinding.inflate(inflater(), this) + + init { + background = context.getBlockDrawable().withRippleMask() + + orientation = HORIZONTAL + + layoutParams = MarginLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + setMargins(16.dp(context), 8.dp(context), 16.dp(context), 0) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingDashboardNoStakeView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingDashboardNoStakeView.kt new file mode 100644 index 0000000..b9067b0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingDashboardNoStakeView.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import coil.clear +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setShimmerShown +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.unsafeLazy +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_staking_impl.databinding.ItemDashboardNoStakeBinding +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model.StakingDashboardModel.StakingTypeModel + +class StakingDashboardNoStakeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ItemDashboardNoStakeBinding.inflate(inflater(), this) + + private val imageLoader: ImageLoader + + private val earningsGroup by unsafeLazy { + ShimmerableGroup( + container = binder.itemDashboardNoStakeEarningsContainer, + shimmerShape = binder.itemDashboardNoStakeEarningsShimmerShape, + content = binder.itemDashboardNoStakeEarnings + ) + } + + private val earningsSuffixGroup by unsafeLazy { + ShimmerableGroup( + container = binder.itemDashboardNoStakeEarningsSuffixContainer, + shimmerShape = binder.itemDashboardNoStakeEarningsSuffixShimmerShape, + content = binder.itemDashboardNoStakeEarningsSuffix + ) + } + + init { + background = context.getBlockDrawable().withRippleMask() + + layoutParams = MarginLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + setMargins(16.dp(context), 8.dp(context), 16.dp(context), 0) + } + + imageLoader = FeatureUtils.getCommonApi(context).imageLoader() + } + + fun setAssetIcon(icon: SyncingData) { + binder.itemDashboardNoStakeTokenIcon.alpha = if (icon.isSyncing) 0.56f else 1.0f + binder.itemDashboardNoStakeTokenIcon.setIcon(icon.data, imageLoader) + } + + fun setTokenName(tokenName: SyncingData) { + binder.itemDashboardNoStakeTokenName.text = tokenName.data + binder.itemDashboardNoStakeTokenNameContainer.setShimmerShown(tokenName.isSyncing) + } + + fun setEarnings(earningsState: ExtendedLoadingState>) { + earningsGroup.applyState(earningsState) { earnings -> + binder.itemDashboardNoStakeEarnings.text = earnings + } + earningsSuffixGroup.applyState(earningsState) + } + + fun setAvailableBalance(maybeBalance: CharSequence?) { + binder.itemDashboardNoStakeChainAvailableBalance.setTextOrHide(maybeBalance) + } + + fun setStakingTypeBadge(model: StakingTypeModel?) { + binder.itemDashboardNoStakeStakingType.setModelOrHide(model) + } + + fun unbind() { + binder.itemDashboardNoStakeTokenIcon.clear() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingTypeBadgeView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingTypeBadgeView.kt new file mode 100644 index 0000000..81aca21 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/main/view/StakingTypeBadgeView.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model.StakingDashboardModel.StakingTypeModel + +class StakingTypeBadgeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : AppCompatTextView(context, attrs, defStyleAttr), + WithContextExtensions by WithContextExtensions(context) { + + init { + setTextAppearance(R.style.TextAppearance_NovaFoundation_SemiBold_Caps2) + setTextColorRes(R.color.chip_text) + + gravity = Gravity.CENTER_VERTICAL + includeFontPadding = false + + background = getRoundedCornerDrawable(R.color.chips_background, cornerSizeDp = 6) + } + + fun setModel(model: StakingTypeModel) { + setDrawableStart(model.icon, widthInDp = 10, paddingInDp = 4, tint = R.color.icon_secondary) + text = model.text + } +} + +fun StakingTypeBadgeView.setModelOrHide(maybeModel: StakingTypeModel?) = letOrHide(maybeModel, ::setModel) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/MoreStakingOptionsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/MoreStakingOptionsFragment.kt new file mode 100644 index 0000000..5c67c6d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/MoreStakingOptionsFragment.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more + +import android.view.View +import androidx.recyclerview.widget.ConcatAdapter + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.submitListPreservingViewPoint +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentMoreStakingOptionsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.list.DashboardNoStakeAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.list.DashboardSectionAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.list.StakingDAppsDecoration +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.list.StakingDappsAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.model.StakingDAppModel + +class MoreStakingOptionsFragment : + BaseFragment(), + DashboardNoStakeAdapter.Handler, + StakingDappsAdapter.Handler { + + override fun createBinding() = FragmentMoreStakingOptionsBinding.inflate(layoutInflater) + + private val noStakeAdapter = DashboardNoStakeAdapter(this) + + private val sectionAdapter = DashboardSectionAdapter(R.string.staking_dashboard_browser_stake_header) + + private val dAppAdapter = StakingDappsAdapter(this) + private val dAppLoadingAdapter = CustomPlaceholderAdapter(R.layout.layout_dapps_shimmering) + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .moreStakingOptionsFactory() + .create(this) + .inject(this) + } + + override fun applyInsets(rootView: View) { + binder.moreStakingOptionsToolbar.applyStatusBarInsets() + } + + override fun initViews() { + binder.moreStakingOptionsToolbar.setHomeButtonListener { viewModel.goBack() } + + with(binder.moreStakingOptionsContent) { + setHasFixedSize(true) + adapter = ConcatAdapter( + noStakeAdapter, + sectionAdapter, + dAppAdapter, + dAppLoadingAdapter + ) + itemAnimator = null + + addItemDecoration(StakingDAppsDecoration(requireContext())) + } + } + + override fun subscribe(viewModel: MoreStakingOptionsViewModel) { + viewModel.moreStakingOptionsUiFlow.observe { stakingOptionsModel -> + noStakeAdapter.submitListPreservingViewPoint(stakingOptionsModel.inAppStaking, binder.moreStakingOptionsContent) + + when (val browserStakingState = stakingOptionsModel.browserStaking) { + is ExtendedLoadingState.Loaded -> { + dAppLoadingAdapter.show(false) + dAppAdapter.submitList(browserStakingState.data) + } + + else -> { + dAppLoadingAdapter.show(true) + dAppAdapter.submitList(listOf()) + } + } + } + } + + override fun onNoStakeItemClicked(index: Int) { + viewModel.onInAppStakingItemClicked(index) + } + + override fun onDAppClicked(item: StakingDAppModel) { + viewModel.onBrowserStakingItemClicked(item) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/MoreStakingOptionsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/MoreStakingOptionsViewModel.kt new file mode 100644 index 0000000..d95f4d1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/MoreStakingOptionsViewModel.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.feature_staking_api.domain.dashboard.StakingDashboardInteractor +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.AggregatedStakingDashboardOption +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.MoreStakingOptions +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.StakingDApp +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.allStakingTypes +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.StakingDashboardPresentationMapper +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.model.MoreStakingOptionsModel +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.model.StakingDAppModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.AvailableStakingOptionsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StartStakingLandingPayload +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class MoreStakingOptionsViewModel( + private val interactor: StakingDashboardInteractor, + private val startStakingRouter: StartMultiStakingRouter, + private val dashboardRouter: StakingDashboardRouter, + private val stakingSharedState: StakingSharedState, + private val presentationMapper: StakingDashboardPresentationMapper, + private val stakingRouter: StakingRouter, +) : BaseViewModel() { + + init { + syncDApps() + } + + private val moreStakingOptionsFlow = interactor.moreStakingOptionsFlow() + .shareInBackground() + + val moreStakingOptionsUiFlow = moreStakingOptionsFlow + .map(::mapMoreOptionsToUi) + .shareInBackground() + + fun onInAppStakingItemClicked(index: Int) = launch { + val withoutStakeItems = moreStakingOptionsFlow.first().inAppStaking + val withoutStakeItem = withoutStakeItems.getOrNull(index) ?: return@launch + + val noStakeItemState = withoutStakeItem.stakingState as? AggregatedStakingDashboardOption.NoStake ?: return@launch + + val stakingTypes = noStakeItemState.flowType.allStakingTypes + + openChainStaking(withoutStakeItem.chain, withoutStakeItem.token.configuration, stakingTypes) + } + + fun onBrowserStakingItemClicked(item: StakingDAppModel) = launch { + stakingRouter.openDAppBrowser(item.url) + } + + private fun syncDApps() = launch { + interactor.syncDapps() + } + + private fun mapMoreOptionsToUi(moreStakingOptions: MoreStakingOptions): MoreStakingOptionsModel { + return MoreStakingOptionsModel( + inAppStaking = moreStakingOptions.inAppStaking.map(presentationMapper::mapWithoutStakeItemToUi), + browserStaking = moreStakingOptions.browserStaking.map { dApps -> dApps.map(::mapStakingDAppToUi) } + ) + } + + private fun mapStakingDAppToUi(stakingDApp: StakingDApp): StakingDAppModel { + return with(stakingDApp) { + StakingDAppModel(url = url, iconUrl = iconUrl, name = name) + } + } + + private suspend fun openChainStaking(chain: Chain, chainAsset: Chain.Asset, stakingTypes: List) { + stakingSharedState.setSelectedOption(chain, chainAsset, stakingTypes.first()) + + val payload = StartStakingLandingPayload(AvailableStakingOptionsPayload(chain.id, chainAsset.id, stakingTypes)) + + startStakingRouter.openStartStakingLanding(payload) + } + + fun goBack() { + dashboardRouter.backInStakingTab() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/di/MoreStakingOptionsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/di/MoreStakingOptionsComponent.kt new file mode 100644 index 0000000..06038fb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/di/MoreStakingOptionsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.MoreStakingOptionsFragment + +@Subcomponent( + modules = [ + MoreStakingOptionsModule::class + ] +) +@ScreenScope +interface MoreStakingOptionsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): MoreStakingOptionsComponent + } + + fun inject(fragment: MoreStakingOptionsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/di/MoreStakingOptionsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/di/MoreStakingOptionsModule.kt new file mode 100644 index 0000000..250c1cc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/di/MoreStakingOptionsModule.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.model.MaskingMode +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_staking_api.domain.dashboard.StakingDashboardInteractor +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.StakingDashboardRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.common.StakingDashboardPresentationMapperFactory +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.MoreStakingOptionsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.components.ComponentsModule +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory + +@Module(includes = [ViewModelModule::class, ComponentsModule::class]) +class MoreStakingOptionsModule { + + @Provides + @IntoMap + @ViewModelKey(MoreStakingOptionsViewModel::class) + fun provideViewModel( + interactor: StakingDashboardInteractor, + dashboardRouter: StakingDashboardRouter, + stakingRouter: StakingRouter, + stakingSharedState: StakingSharedState, + presentationMapperFactory: StakingDashboardPresentationMapperFactory, + startMultiStakingRouter: StartMultiStakingRouter, + maskableValueFormatterFactory: MaskableValueFormatterFactory + ): ViewModel { + return MoreStakingOptionsViewModel( + interactor = interactor, + startStakingRouter = startMultiStakingRouter, + dashboardRouter = dashboardRouter, + stakingRouter = stakingRouter, + stakingSharedState = stakingSharedState, + // Show all items in more staking options + presentationMapper = presentationMapperFactory.create(maskableValueFormatterFactory.create(MaskingMode.DISABLED)) + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): MoreStakingOptionsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(MoreStakingOptionsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/list/StakingDAppsAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/list/StakingDAppsAdapter.kt new file mode 100644 index 0000000..57c73fa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/list/StakingDAppsAdapter.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.model.StakingDAppModel + +class StakingDappsAdapter( + private val handler: Handler +) : BaseListAdapter(StakingDappDiffCallback()) { + + interface Handler { + + fun onDAppClicked(item: StakingDAppModel) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StakingDappViewHolder { + return StakingDappViewHolder(DAppView.createUsingMathParentWidth(parent.context), handler) + } + + override fun onBindViewHolder(holder: StakingDappViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private class StakingDappDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: StakingDAppModel, newItem: StakingDAppModel): Boolean { + return oldItem.url == newItem.url + } + + override fun areContentsTheSame(oldItem: StakingDAppModel, newItem: StakingDAppModel): Boolean { + return oldItem == newItem + } +} + +class StakingDappViewHolder( + private val dAppView: DAppView, + private val itemHandler: StakingDappsAdapter.Handler, +) : BaseViewHolder(dAppView) { + + fun bind(item: StakingDAppModel) = with(dAppView) { + setTitle(item.name) + showSubtitle(false) + setIconUrl(item.iconUrl) + + setOnClickListener { itemHandler.onDAppClicked(item) } + } + + override fun unbind() { + dAppView.clearIcon() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/list/StakingDAppsDecoration.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/list/StakingDAppsDecoration.kt new file mode 100644 index 0000000..d3d319c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/list/StakingDAppsDecoration.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.list + +import android.content.Context +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.StubHolder +import io.novafoundation.nova.common.list.decoration.BackgroundItemDecoration + +class StakingDAppsDecoration(context: Context) : BackgroundItemDecoration( + context = context, + outerHorizontalMarginDp = 16, + innerVerticalPaddingDp = 4 +) { + + override fun shouldApplyDecoration(holder: RecyclerView.ViewHolder): Boolean { + return holder is StakingDappViewHolder || holder is StubHolder + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/model/MoreStakingOptionsModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/model/MoreStakingOptionsModel.kt new file mode 100644 index 0000000..f05bb76 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/model/MoreStakingOptionsModel.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.model + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_staking_impl.presentation.dashboard.main.model.StakingDashboardModel.NoStakeItem + +class MoreStakingOptionsModel( + val inAppStaking: List, + val browserStaking: ExtendedLoadingState> +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/model/StakingDAppModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/model/StakingDAppModel.kt new file mode 100644 index 0000000..b09d1da --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/dashboard/more/model/StakingDAppModel.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.dashboard.more.model + +data class StakingDAppModel( + val url: String, + val iconUrl: String, + val name: String +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Identity.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Identity.kt new file mode 100644 index 0000000..76c0e7b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Identity.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mappers + +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.feature_account_api.data.model.ChildIdentity +import io.novafoundation.nova.feature_account_api.data.model.OnChainIdentity +import io.novafoundation.nova.feature_account_api.data.model.RootIdentity +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.IdentityParcelModel + +fun mapIdentityToIdentityParcelModel(identity: OnChainIdentity): IdentityParcelModel { + return with(identity) { + val childInfo = identity.castOrNull()?.let { + IdentityParcelModel.ChildInfo( + parentSeparateDisplay = it.parentIdentity.display, + childName = it.childName + ) + } + + IdentityParcelModel(display, legal, web, matrix, email, pgpFingerprint, image, twitter, childInfo) + } +} + +fun OnChainIdentity.toParcel(): IdentityParcelModel { + return mapIdentityToIdentityParcelModel(this) +} + +fun mapIdentityParcelModelToIdentity(identity: IdentityParcelModel): OnChainIdentity { + return with(identity) { + if (childInfo != null) { + val parent = RootIdentity(childInfo.parentSeparateDisplay, legal, web, matrix, email, pgpFingerprint, image, twitter) + + ChildIdentity(childInfo.childName, parent) + } else { + RootIdentity(display, legal, web, matrix, email, pgpFingerprint, image, twitter) + } + } +} + +fun mapIdentityParcelModelToIdentityModel(identity: IdentityParcelModel): IdentityModel { + return with(identity) { + IdentityModel(display, legal, web, matrix, email, image, twitter) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Nominator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Nominator.kt new file mode 100644 index 0000000..59b7073 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Nominator.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mappers + +import io.novafoundation.nova.feature_staking_api.domain.model.IndividualExposure +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakerParcelModel + +fun mapNominatorToNominatorParcelModel(nominator: IndividualExposure): StakerParcelModel { + return with(nominator) { + StakerParcelModel(who, value) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Pool.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Pool.kt new file mode 100644 index 0000000..f5814bb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Pool.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mappers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatAsSpannable +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.formatting.formatPercents +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.apy +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.PoolRvItem +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +suspend fun mapNominationPoolToPoolRvItem( + chain: Chain, + pool: NominationPool, + resourceManager: ResourceManager, + poolDisplayFormatter: PoolDisplayFormatter, + isChecked: Boolean +): PoolRvItem { + val model = poolDisplayFormatter.format(pool, chain) + return PoolRvItem( + id = pool.id.value, + model = model, + subtitle = getSubtitle(pool, resourceManager), + members = pool.membersCount.format(), + isChecked = isChecked + ) +} + +private fun getSubtitle( + pool: NominationPool, + resourceManager: ResourceManager, +): CharSequence { + val apyColor = resourceManager.getColor(R.color.text_positive) + val apy = pool.apy.orZero() + val apyString = apy.formatPercents().toSpannable(colorSpan(apyColor)) + return resourceManager.getString(R.string.common_per_year_format) + .formatAsSpannable(apyString) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Reward.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Reward.kt new file mode 100644 index 0000000..6d3d644 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Reward.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mappers + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.formatFractionAsPercentage +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.rewards.PeriodReturns +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.model.RewardEstimation +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import java.math.BigDecimal + +enum class RewardSuffix(@StringRes val suffixResourceId: Int) { + APY(R.string.staking_apy), + APR(R.string.staking_apr) +} + +fun RewardSuffix.format(resourceManager: ResourceManager, gainsFraction: BigDecimal): String { + val gainsFormatted = gainsFraction.formatFractionAsPercentage() + + return resourceManager.getString(suffixResourceId, gainsFormatted) +} + +fun PeriodReturns.rewardSuffix(): RewardSuffix { + return if (isCompound) RewardSuffix.APY else RewardSuffix.APR +} + +fun mapPeriodReturnsToRewardEstimation( + periodReturns: PeriodReturns, + token: Token, + resourceManager: ResourceManager, +): RewardEstimation { + val suffix = periodReturns.rewardSuffix() + + val gainWithSuffix = suffix.format(resourceManager, periodReturns.gainFraction) + + val amountFormatted = periodReturns.gainAmount.formatTokenAmount(token.configuration) + val amountWithSuffix = resourceManager.getString(R.string.common_per_year_format, amountFormatted) + + return RewardEstimation( + amount = amountWithSuffix, + fiatAmount = token.amountToFiat(periodReturns.gainAmount).formatAsCurrency(token.currency), + gain = gainWithSuffix + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Validator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Validator.kt new file mode 100644 index 0000000..0e6da76 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mappers/Validator.kt @@ -0,0 +1,243 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mappers + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.createSubstrateAddressModel +import io.novafoundation.nova.common.presentation.ColoredText +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.formatting.formatPercents +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_staking_api.domain.model.NominatedValidator +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSorting +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.APYSorting +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.TotalStakeSorting +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.ValidatorOwnStakeSorting +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.model.ValidatorAlert +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.model.ValidatorDetailsModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.model.ValidatorStakeModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetStakeParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetStakeParcelModel.Active.UserStakeInfo +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import java.math.BigDecimal +import java.math.BigInteger + +private const val ICON_SIZE_DP = 24 + +suspend fun mapValidatorToValidatorModel( + chain: Chain, + validator: Validator, + iconGenerator: AddressIconGenerator, + token: Token, + isChecked: Boolean? = null, + sorting: RecommendationSorting = APYSorting, +) = mapValidatorToValidatorModel( + chain = chain, + validator = validator, + createIcon = { iconGenerator.createSubstrateAddressModel(it, ICON_SIZE_DP, validator.identity?.display, AddressIconGenerator.BACKGROUND_TRANSPARENT) }, + token = token, + isChecked = isChecked, + sorting = sorting +) + +suspend fun mapValidatorToValidatorModel( + chain: Chain, + validator: Validator, + createIcon: suspend (address: String) -> AddressModel, + token: Token, + isChecked: Boolean? = null, + sorting: RecommendationSorting = APYSorting, +): ValidatorStakeTargetModel { + val address = chain.addressOf(validator.accountIdHex.fromHex()) + val addressModel = createIcon(address) + + return with(validator) { + val scoring = when (sorting) { + APYSorting -> rewardsToScoring(electedInfo?.apy) + + TotalStakeSorting -> stakeToScoring(electedInfo?.totalStake, token) + + ValidatorOwnStakeSorting -> stakeToScoring(electedInfo?.ownStake, token) + + else -> throw NotImplementedError("Unsupported sorting: $sorting") + } + + ValidatorStakeTargetModel( + accountIdHex = accountIdHex, + slashed = slashed, + addressModel = addressModel, + scoring = scoring, + isChecked = isChecked, + stakeTarget = validator, + subtitle = null // TODO relaychain subtitles + ) + } +} + +fun rewardsToScoring(rewardsGain: BigDecimal?) = rewardsToScoring(rewardsGain?.fractions) + +fun rewardsToScoring(rewardsGain: Fraction?) = rewardsToColoredText(rewardsGain)?.let(StakeTargetModel.Scoring::OneField) + +fun rewardsToColoredText(rewardsGain: BigDecimal?) = rewardsToColoredText(rewardsGain?.fractions) + +fun rewardsToColoredText(rewardsGain: Fraction?) = formatStakeTargetRewardsOrNull(rewardsGain)?.let { + ColoredText(it, R.color.text_positive) +} + +fun stakeToScoring(stakeInPlanks: BigInteger?, token: Token): StakeTargetModel.Scoring.TwoFields? { + if (stakeInPlanks == null) return null + + val stake = token.amountFromPlanks(stakeInPlanks) + + return StakeTargetModel.Scoring.TwoFields( + primary = stake.formatTokenAmount(token.configuration), + secondary = token.amountToFiat(stake).formatAsCurrency(token.currency) + ) +} + +fun mapValidatorToValidatorDetailsParcelModel( + validator: Validator, +): StakeTargetDetailsParcelModel { + return mapValidatorToValidatorDetailsParcelModel(validator, nominationStatus = null) +} + +fun mapValidatorToValidatorDetailsWithStakeFlagParcelModel( + nominatedValidator: NominatedValidator, +): StakeTargetDetailsParcelModel = mapValidatorToValidatorDetailsParcelModel(nominatedValidator.validator, nominatedValidator.status) + +private fun mapValidatorToValidatorDetailsParcelModel( + validator: Validator, + nominationStatus: NominatedValidator.Status?, +): StakeTargetDetailsParcelModel { + return with(validator) { + val identityModel = identity?.let(::mapIdentityToIdentityParcelModel) + + val stakeModel = electedInfo?.let { + val nominators = it.nominatorStakes.map(::mapNominatorToNominatorParcelModel) + + val nominatorInfo = (nominationStatus as? NominatedValidator.Status.Active)?.let { activeStatus -> + UserStakeInfo(willBeRewarded = activeStatus.willUserBeRewarded) + } + + StakeTargetStakeParcelModel.Active( + totalStake = it.totalStake, + ownStake = it.ownStake, + stakers = nominators, + minimumStake = null, + rewards = it.apy, + isOversubscribed = it.isOversubscribed, + userStakeInfo = nominatorInfo + ) + } ?: StakeTargetStakeParcelModel.Inactive + + StakeTargetDetailsParcelModel( + accountIdHex = accountIdHex, + isSlashed = validator.slashed, + stake = stakeModel, + identity = identityModel + ) + } +} + +fun mapStakeTargetDetailsToErrors( + stakeTarget: StakeTargetDetailsParcelModel, + displayConfig: StakeTargetDetailsPayload.DisplayConfig, +): List { + return buildList { + if (stakeTarget.isSlashed) { + add(ValidatorAlert.Slashed) + } + + if (stakeTarget.stake is StakeTargetStakeParcelModel.Active && stakeTarget.stake.isOversubscribed) { + val nominatorInfo = stakeTarget.stake.userStakeInfo + + if (nominatorInfo == null || nominatorInfo.willBeRewarded) { + add(ValidatorAlert.Oversubscribed.UserNotInvolved) + } else { + add(ValidatorAlert.Oversubscribed.UserMissedReward(displayConfig.oversubscribedWarningText)) + } + } + } +} + +suspend fun mapValidatorDetailsParcelToValidatorDetailsModel( + chain: Chain, + validator: StakeTargetDetailsParcelModel, + asset: Asset, + displayConfig: StakeTargetDetailsPayload.DisplayConfig, + iconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter, +): ValidatorDetailsModel { + return with(validator) { + val address = chain.addressOf(validator.accountIdHex.fromHex()) + + val addressModel = iconGenerator.createAccountAddressModel(chain, address, validator.identity?.display) + + val identity = identity?.let(::mapIdentityParcelModelToIdentityModel) + + val stake = when (val stake = validator.stake) { + StakeTargetStakeParcelModel.Inactive -> ValidatorStakeModel( + status = ValidatorStakeModel.Status( + text = resourceManager.getString(R.string.staking_nominator_status_inactive), + icon = R.drawable.ic_time_16, + iconTint = R.color.icon_secondary + ), + activeStakeModel = null + ) + + is StakeTargetStakeParcelModel.Active -> { + val totalStakeModel = amountFormatter.formatAmountToAmountModel(stake.totalStake, asset) + + val nominatorsCount = stake.stakersCount + val rewardsWithLabel = displayConfig.rewardSuffix.format(resourceManager, stake.rewards) + + val formattedMaxStakers = displayConfig.rewardedStakersPerStakeTarget?.format() + + ValidatorStakeModel( + status = ValidatorStakeModel.Status( + text = resourceManager.getString(R.string.common_active), + icon = R.drawable.ic_checkmark_circle_16, + iconTint = R.color.icon_positive + ), + activeStakeModel = ValidatorStakeModel.ActiveStakeModel( + totalStake = totalStakeModel, + minimumStake = stake.minimumStake?.let { amountFormatter.formatAmountToAmountModel(it, asset) }, + nominatorsCount = nominatorsCount.format(), + maxNominations = formattedMaxStakers?.let { resourceManager.getString(R.string.staking_nominations_rewarded_format, it) }, + apy = rewardsWithLabel + ) + ) + } + } + + ValidatorDetailsModel( + stake = stake, + addressModel = addressModel, + identity = identity + ) + } +} + +fun formatStakeTargetRewards(rewardsRate: Fraction) = rewardsRate.formatPercents() +fun formatStakeTargetRewardsOrNull(rewardsRate: Fraction?) = rewardsRate?.let(::formatStakeTargetRewards) +fun formatStakeTargetRewardsOrNull(rewardsRate: BigDecimal?) = formatStakeTargetRewardsOrNull(rewardsRate?.fractions) + +fun formatValidatorApy(validator: Validator) = formatStakeTargetRewardsOrNull(validator.electedInfo?.apy) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/SelectMythosInterScreenCommunicator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/SelectMythosInterScreenCommunicator.kt new file mode 100644 index 0000000..584bfc0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/SelectMythosInterScreenCommunicator.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenCommunicator.Request +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.MythosCollatorParcel +import kotlinx.android.parcel.Parcelize + +interface SelectMythosInterScreenRequester : InterScreenRequester +interface SelectMythosInterScreenResponder : InterScreenResponder + +interface SelectMythosInterScreenCommunicator : SelectMythosInterScreenRequester, SelectMythosInterScreenResponder { + + @Parcelize + object Request : Parcelable +} + +fun SelectMythosInterScreenRequester.openRequest() = openRequest(Request) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/MythosClaimRewardsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/MythosClaimRewardsFragment.kt new file mode 100644 index 0000000..aed7b1e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/MythosClaimRewardsFragment.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.claimRewards + +import androidx.lifecycle.lifecycleScope +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentMythosClaimRewardsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading + +class MythosClaimRewardsFragment : BaseFragment() { + + override fun createBinding() = FragmentMythosClaimRewardsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.mythosClaimRewardsExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.mythosClaimRewardsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.mythosClaimRewardsConfirm.prepareForProgress(viewLifecycleOwner) + binder.mythosClaimRewardsConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .claimMythosRewardsFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: MythosClaimRewardsViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + viewModel.feeLoaderMixin.setupFeeLoading(binder.mythosClaimRewardsExtrinsicInformation.fee) + + viewModel.showNextProgress.observe(binder.mythosClaimRewardsConfirm::setProgressState) + + viewModel.pendingRewardsAmountModel.observe(binder.mythosClaimRewardsAmount::setAmount) + + viewModel.walletUiFlow.observe(binder.mythosClaimRewardsExtrinsicInformation::setWallet) + viewModel.originAddressModelFlow.observe(binder.mythosClaimRewardsExtrinsicInformation::setAccount) + + binder.mythosClaimRewardRestakeSwitch.field.bindTo(viewModel.shouldRestakeFlow, viewLifecycleOwner.lifecycleScope) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/MythosClaimRewardsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/MythosClaimRewardsViewModel.kt new file mode 100644 index 0000000..c0c4952 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/MythosClaimRewardsViewModel.kt @@ -0,0 +1,148 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.claimRewards + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.MythosClaimRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.validations.MythosClaimRewardsValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.validations.MythosClaimRewardsValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class MythosClaimRewardsViewModel( + private val router: MythosStakingRouter, + private val interactor: MythosClaimRewardsInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: MythosClaimRewardsValidationSystem, + private val validationFailureFormatter: MythosStakingValidationFailureFormatter, + private val stakingSharedState: StakingSharedState, + private val externalActions: ExternalActions.Presentation, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + selectedAccountUseCase: SelectedAccountUseCase, + walletUiUseCase: WalletUiUseCase, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + assetUseCase: AssetUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val pendingRewardsFlow = interactor.pendingRewardsFlow() + .shareInBackground() + + val shouldRestakeFlow = MutableStateFlow(true) + + val pendingRewardsAmountModel = combine(pendingRewardsFlow, assetFlow) { pendingRewards, asset -> + amountFormatter.formatAmountToAmountModel(pendingRewards, asset) + } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val feeLoaderMixin = feeLoaderMixinFactory.createDefault( + scope = viewModelScope, + selectedChainAssetFlow = stakingSharedState.selectedAssetFlow(), + ) + + val originAddressModelFlow = selectedAccountUseCase.selectedAddressModelFlow { stakingSharedState.chain() } + .shareInBackground() + + init { + setupFee() + + setDefaultRestakeSetting() + } + + fun confirmClicked() { + claimRewardsIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = originAddressModelFlow.first().address + val chain = stakingSharedState.chain() + + externalActions.showAddressActions(address, chain) + } + + private fun setupFee() { + feeLoaderMixin.connectWith(pendingRewardsFlow, shouldRestakeFlow) { _, pendingRewards, shouldRestake -> + interactor.estimateFee(pendingRewards, shouldRestake) + } + } + + private fun claimRewardsIfValid() = launchUnit { + _showNextProgress.value = true + + val payload = MythosClaimRewardsValidationPayload( + fee = feeLoaderMixin.awaitFee(), + pendingRewardsPlanks = pendingRewardsFlow.first(), + asset = assetFlow.first(), + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = validationFailureFormatter::formatClaimRewards, + progressConsumer = _showNextProgress.progressConsumer(), + block = { sendTransaction() } + ) + } + + private fun sendTransaction() = launchUnit { + val pendingRewards = pendingRewardsFlow.first() + val shouldRestake = shouldRestakeFlow.value + + interactor.claimRewards(pendingRewards, shouldRestake) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + .onFailure(::showError) + + _showNextProgress.value = false + } + + private fun setDefaultRestakeSetting() = launchUnit { + shouldRestakeFlow.value = interactor.initialShouldRestakeSetting() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/di/MythosClaimRewardsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/di/MythosClaimRewardsComponent.kt new file mode 100644 index 0000000..7578367 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/di/MythosClaimRewardsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.claimRewards.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.claimRewards.MythosClaimRewardsFragment + +@Subcomponent( + modules = [ + MythosClaimRewardsModule::class + ] +) +@ScreenScope +interface MythosClaimRewardsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): MythosClaimRewardsComponent + } + + fun inject(fragment: MythosClaimRewardsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/di/MythosClaimRewardsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/di/MythosClaimRewardsModule.kt new file mode 100644 index 0000000..c528c7a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/claimRewards/di/MythosClaimRewardsModule.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.claimRewards.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.MythosClaimRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.validations.MythosClaimRewardsValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.claimRewards.MythosClaimRewardsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class MythosClaimRewardsModule { + + @Provides + @IntoMap + @ViewModelKey(MythosClaimRewardsViewModel::class) + fun provideViewModel( + router: MythosStakingRouter, + interactor: MythosClaimRewardsInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: MythosClaimRewardsValidationSystem, + validationFailureFormatter: MythosStakingValidationFailureFormatter, + stakingSharedState: StakingSharedState, + externalActions: ExternalActions.Presentation, + selectedAccountUseCase: SelectedAccountUseCase, + walletUiUseCase: WalletUiUseCase, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + assetUseCase: AssetUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return MythosClaimRewardsViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + validationFailureFormatter = validationFailureFormatter, + stakingSharedState = stakingSharedState, + externalActions = externalActions, + selectedAccountUseCase = selectedAccountUseCase, + walletUiUseCase = walletUiUseCase, + feeLoaderMixinFactory = feeLoaderMixinFactory, + assetUseCase = assetUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): MythosClaimRewardsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(MythosClaimRewardsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/AddressIcon.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/AddressIcon.kt new file mode 100644 index 0000000..8a12316 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/AddressIcon.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.common + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +suspend fun AddressIconGenerator.collatorAddressModel(collator: MythosCollator, chain: Chain) = createAccountAddressModel( + chain = chain, + address = chain.addressOf(collator.accountId.value), + name = collator.identity?.display +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/MythosCollatorFormatter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/MythosCollatorFormatter.kt new file mode 100644 index 0000000..a380145 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/MythosCollatorFormatter.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.common + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.toHex +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.takeUnlessZero +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorRecommendationConfig +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorSorting +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.rewardsToColoredText +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.rewardsToScoring +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.stakeToScoring +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.model.MythosCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.model.MythosCollatorWithAmount +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.model.MythosSelectCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers.withSubtitleLabelSuffix +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.selectCollators.labeledAmountSubtitle +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.chain +import javax.inject.Inject + +interface MythosCollatorFormatter { + + suspend fun collatorToSelectUi( + collator: MythosCollatorWithAmount, + token: Token + ): MythosSelectCollatorModel + + suspend fun collatorToUi( + collator: MythosCollator, + token: Token, + recommendationConfig: MythosCollatorRecommendationConfig + ): MythosCollatorModel +} + +@FeatureScope +class RealMythosCollatorFormatter @Inject constructor( + private val stakingSharedState: StakingSharedState, + private val resourceManager: ResourceManager, + private val addressIconGenerator: AddressIconGenerator, + private val amountFormatter: AmountFormatter +) : MythosCollatorFormatter { + + override suspend fun collatorToSelectUi( + collatorWithAmount: MythosCollatorWithAmount, + token: Token + ): MythosSelectCollatorModel { + return mapCollatorToSelectCollatorModel( + collator = collatorWithAmount.target, + stakedAmount = collatorWithAmount.stake.takeUnlessZero(), + token = token + ) + } + + override suspend fun collatorToUi( + collator: MythosCollator, + token: Token, + recommendationConfig: MythosCollatorRecommendationConfig + ): MythosCollatorModel { + return mapCollatorToCollatorModel( + collator = collator, + chain = stakingSharedState.chain(), + sorting = recommendationConfig.sorting, + token = token + ) + } + + private suspend fun mapCollatorToSelectCollatorModel( + collator: MythosCollator, + token: Token, + stakedAmount: Balance? = null, + active: Boolean = true, + ): MythosSelectCollatorModel { + val addressModel = addressIconGenerator.collatorAddressModel(collator, stakingSharedState.chain()) + val stakedAmountModel = stakedAmount?.let { amountFormatter.formatAmountToAmountModel(stakedAmount, token) } + + val subtitle = stakedAmountModel?.let { + resourceManager.labeledAmountSubtitle(R.string.staking_main_stake_balance_staked, it, selectionActive = active) + } + + return MythosSelectCollatorModel( + addressModel = addressModel, + payload = collator, + active = active, + subtitle = subtitle + ) + } + + private suspend fun mapCollatorToCollatorModel( + collator: MythosCollator, + chain: Chain, + sorting: MythosCollatorSorting, + token: Token, + ): MythosCollatorModel { + val addressModel = addressIconGenerator.collatorAddressModel(collator, chain) + + val scoring = when (sorting) { + MythosCollatorSorting.REWARDS -> rewardsToScoring(collator.apr) + MythosCollatorSorting.TOTAL_STAKE -> stakeToScoring(collator.totalStake, token) + } + + val subtitle = when (sorting) { + MythosCollatorSorting.REWARDS -> null + + MythosCollatorSorting.TOTAL_STAKE -> StakeTargetModel.Subtitle( + label = resourceManager.getString(R.string.staking_rewards).withSubtitleLabelSuffix(), + value = rewardsToColoredText(collator.apr)!! + ) + } + + return MythosCollatorModel( + accountIdHex = collator.accountId.toHex(), + slashed = false, + addressModel = addressModel, + stakeTarget = collator, + isChecked = null, + scoring = scoring, + subtitle = subtitle + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/StakeTargetDetailsMappers.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/StakeTargetDetailsMappers.kt new file mode 100644 index 0000000..e893105 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/StakeTargetDetailsMappers.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.common + +import io.novafoundation.nova.common.address.toHex +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.toParcel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetStakeParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetStakeParcelModel.Active.UserStakeInfo + +fun MythosCollator.toTargetDetailsParcel(): StakeTargetDetailsParcelModel { + val stake = if (apr != null) { + StakeTargetStakeParcelModel.Active( + totalStake = totalStake, + ownStake = null, + minimumStake = null, + stakers = null, + stakersCount = delegators, + rewards = apr.inFraction.toBigDecimal(), + isOversubscribed = false, + userStakeInfo = UserStakeInfo(willBeRewarded = true) + ) + } else { + StakeTargetStakeParcelModel.Inactive + } + + return StakeTargetDetailsParcelModel( + accountIdHex = accountId.toHex(), + isSlashed = false, + stake = stake, + identity = identity?.toParcel() + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/model/MythosCollatorAliases.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/model/MythosCollatorAliases.kt new file mode 100644 index 0000000..792995b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/model/MythosCollatorAliases.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.model + +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model.TargetWithStakedAmount +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.SelectStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel + +typealias MythosSelectCollatorModel = SelectStakeTargetModel +typealias MythosCollatorModel = StakeTargetModel +typealias MythosCollatorWithAmount = TargetWithStakedAmount diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/validations/MythosStakingValidationFailureFormatter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/validations/MythosStakingValidationFailureFormatter.kt new file mode 100644 index 0000000..0f6e420 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/common/validations/MythosStakingValidationFailureFormatter.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.asDefault +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.mythos.claimRewards.validations.MythosClaimRewardsValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.validations.RedeemMythosStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.StartMythosStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.UnbondMythosStakingValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.validation.amountIsTooBig +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.domain.validation.zeroAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import javax.inject.Inject + +interface MythosStakingValidationFailureFormatter { + + fun formatStartStaking(failure: ValidationStatus.NotValid): TransformedFailure + + fun formatUnbond(failure: ValidationStatus.NotValid): TransformedFailure + + fun formatRedeem(reason: RedeemMythosStakingValidationFailure): TitleAndMessage + + fun formatClaimRewards(reason: MythosClaimRewardsValidationFailure): TitleAndMessage +} + +@FeatureScope +class RealMythosStakingValidationFailureFormatter @Inject constructor( + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : MythosStakingValidationFailureFormatter { + + override fun formatStartStaking(failure: ValidationStatus.NotValid): TransformedFailure { + return when (val reason = failure.reason) { + is StartMythosStakingValidationFailure.NotEnoughBalanceToPayFees -> handleNotEnoughFeeError(reason, resourceManager).asDefault() + + StartMythosStakingValidationFailure.NotEnoughStakeableBalance -> resourceManager.amountIsTooBig().asDefault() + + StartMythosStakingValidationFailure.NotPositiveAmount -> resourceManager.zeroAmount().asDefault() + + is StartMythosStakingValidationFailure.TooLowStakeAmount -> { + val formattedMinStake = amountFormatter.formatAmountToAmountModel(reason.minimumStake, reason.asset).token + + val content = resourceManager.getString(R.string.common_amount_low) to + resourceManager.getString(R.string.staking_setup_amount_too_low, formattedMinStake) + + content.asDefault() + } + } + } + + override fun formatUnbond(failure: ValidationStatus.NotValid): TransformedFailure { + return when (val reason = failure.reason) { + is UnbondMythosStakingValidationFailure.NotEnoughBalanceToPayFees -> handleNotEnoughFeeError(reason, resourceManager).asDefault() + + is UnbondMythosStakingValidationFailure.ReleaseRequestsLimitReached -> { + val content = resourceManager.getString(R.string.staking_unbonding_limit_reached_title) to + resourceManager.getString(R.string.staking_unbonding_limit_reached_message, reason.limit) + + content.asDefault() + } + } + } + + override fun formatRedeem(reason: RedeemMythosStakingValidationFailure): TitleAndMessage { + return when (reason) { + is RedeemMythosStakingValidationFailure.NotEnoughBalanceToPayFees -> handleNotEnoughFeeError(reason, resourceManager) + } + } + + override fun formatClaimRewards(reason: MythosClaimRewardsValidationFailure): TitleAndMessage { + return when (reason) { + MythosClaimRewardsValidationFailure.NonProfitableClaim -> resourceManager.getString(R.string.common_confirmation_title) to + resourceManager.getString(R.string.staking_warning_tiny_payout) + + is MythosClaimRewardsValidationFailure.NotEnoughBalanceToPayFees -> handleNotEnoughFeeError(reason, resourceManager) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/MythosCurrentCollatorsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/MythosCurrentCollatorsFragment.kt new file mode 100644 index 0000000..0e83628 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/MythosCurrentCollatorsFragment.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.currentCollators + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.CurrentStakeTargetsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.actions.CollatorManageActionsBottomSheet + +class MythosCurrentCollatorsFragment : CurrentStakeTargetsFragment() { + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .currentMythosCollatorsFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: MythosCurrentCollatorsViewModel) { + super.subscribe(viewModel) + + viewModel.selectManageCurrentStakeTargetsAction.awaitableActionLiveData.observeEvent { + CollatorManageActionsBottomSheet( + context = requireContext(), + itemSelected = it.onSuccess, + onCancel = it.onCancel + ).show() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/MythosCurrentCollatorsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/MythosCurrentCollatorsViewModel.kt new file mode 100644 index 0000000..f987be6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/MythosCurrentCollatorsViewModel.kt @@ -0,0 +1,142 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.currentCollators + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.list.toValueList +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.awaitAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.MythosCurrentCollatorsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.model.CurrentMythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.model.MythosDelegationStatus +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.CurrentStakeTargetsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.actions.ManageCurrentStakeTargetsAction +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.Active +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.Inactive +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.SelectedStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.SelectedStakeTargetStatusModel +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.formatStakeTargetRewardsOrNull +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.collatorAddressModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.details.mythos +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.accountIdKeyOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class MythosCurrentCollatorsViewModel( + private val router: MythosStakingRouter, + private val resourceManager: ResourceManager, + private val iconGenerator: AddressIconGenerator, + private val currentCollatorsInteractor: MythosCurrentCollatorsInteractor, + private val stakingSharedState: AnySelectedAssetOptionSharedState, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + tokenUseCase: TokenUseCase, + private val amountFormatter: AmountFormatter +) : CurrentStakeTargetsViewModel() { + + private val groupedCurrentCollatorsFlow = currentCollatorsInteractor.currentCollatorsFlow() + .shareInBackground() + + private val flattenCurrentCollators = groupedCurrentCollatorsFlow + .map { it.toValueList() } + .shareInBackground() + + override val currentStakeTargetsFlow = combine( + groupedCurrentCollatorsFlow, + tokenUseCase.currentTokenFlow() + ) { gropedList, token -> + val chain = stakingSharedState.chain() + + gropedList.mapKeys { (statusGroup, collators) -> mapGroupToUi(statusGroup, collators.size) } + .mapValues { (_, nominatedValidators) -> nominatedValidators.map { mapCurrentCollatorToUi(chain, it, token) } } + .toListWithHeaders() + } + .withLoading() + .shareInBackground() + + override val warningFlow = groupedCurrentCollatorsFlow.map { collatorsByGroup -> + val hasNonRewardedDelegations = MythosDelegationStatus.NOT_ACTIVE in collatorsByGroup + + if (hasNonRewardedDelegations) { + resourceManager.getString(R.string.staking_parachain_your_collaotrs_no_rewards) + } else { + null + } + } + .shareInBackground() + + override val titleFlow: Flow = flowOf { + resourceManager.getString(R.string.staking_parachain_your_collators) + } + + val selectManageCurrentStakeTargetsAction = actionAwaitableMixinFactory.create() + + override fun stakeTargetInfoClicked(address: String) = launchUnit { + val chain = stakingSharedState.chain() + val accountId = chain.accountIdKeyOf(address) + + val allCollators = flattenCurrentCollators.first() + val clickedCollator = allCollators.first { it.collator.accountId == accountId } + + val payload = StakeTargetDetailsPayload.mythos(clickedCollator.collator) + router.openCollatorDetails(payload) + } + + override fun backClicked() { + router.back() + } + + override fun changeClicked() { + launch { + when (selectManageCurrentStakeTargetsAction.awaitAction()) { + ManageCurrentStakeTargetsAction.BOND_MORE -> router.openBondMore() + ManageCurrentStakeTargetsAction.UNBOND -> router.openUnbond() + } + } + } + + private suspend fun mapCurrentCollatorToUi( + chain: Chain, + currentCollator: CurrentMythosCollator, + token: Token + ): SelectedStakeTargetModel { + val collator = currentCollator.collator + + return SelectedStakeTargetModel( + addressModel = iconGenerator.collatorAddressModel(collator, chain), + nominated = amountFormatter.formatAmountToAmountModel(currentCollator.userStake, token), + isOversubscribed = false, + isSlashed = false, + apy = formatStakeTargetRewardsOrNull(collator.apr) + ) + } + + private fun mapGroupToUi(status: MythosDelegationStatus, groupSize: Int) = when (status) { + MythosDelegationStatus.ACTIVE -> SelectedStakeTargetStatusModel.Active( + resourceManager = resourceManager, + groupSize = groupSize, + description = R.string.staking_parachain_your_collators_active + ) + + MythosDelegationStatus.NOT_ACTIVE -> SelectedStakeTargetStatusModel.Inactive( + resourceManager = resourceManager, + groupSize = groupSize, + description = R.string.staking_parachain_your_collators_inactive, + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/di/MythosCurrentCollatorsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/di/MythosCurrentCollatorsComponent.kt new file mode 100644 index 0000000..3a97190 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/di/MythosCurrentCollatorsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.currentCollators.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.currentCollators.MythosCurrentCollatorsFragment + +@Subcomponent( + modules = [ + MythosCurrentCollatorsModule::class + ] +) +@ScreenScope +interface MythosCurrentCollatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): MythosCurrentCollatorsComponent + } + + fun inject(fragment: MythosCurrentCollatorsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/di/MythosCurrentCollatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/di/MythosCurrentCollatorsModule.kt new file mode 100644 index 0000000..ef1628c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/currentCollators/di/MythosCurrentCollatorsModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.currentCollators.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.currentCollators.MythosCurrentCollatorsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.currentCollators.MythosCurrentCollatorsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class MythosCurrentCollatorsModule { + + @Provides + @IntoMap + @ViewModelKey(MythosCurrentCollatorsViewModel::class) + fun provideViewModel( + router: MythosStakingRouter, + resourceManager: ResourceManager, + iconGenerator: AddressIconGenerator, + currentCollatorsInteractor: MythosCurrentCollatorsInteractor, + stakingSharedState: StakingSharedState, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + tokenUseCase: TokenUseCase, + amountFormatter: AmountFormatter + ): ViewModel { + return MythosCurrentCollatorsViewModel( + router = router, + resourceManager = resourceManager, + iconGenerator = iconGenerator, + currentCollatorsInteractor = currentCollatorsInteractor, + stakingSharedState = stakingSharedState, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + tokenUseCase = tokenUseCase, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): MythosCurrentCollatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(MythosCurrentCollatorsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/MythosRedeemFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/MythosRedeemFragment.kt new file mode 100644 index 0000000..3291e60 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/MythosRedeemFragment.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.redeem + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.presentation.showLoadingState +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentMythosRedeemBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class MythosRedeemFragment : BaseFragment() { + + override fun createBinding() = FragmentMythosRedeemBinding.inflate(layoutInflater) + + override fun initViews() { + binder.mythosRedeemToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.mythosRedeemExtrinsicInfo.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.mythosRedeemConfirm.prepareForProgress(viewLifecycleOwner) + binder.mythosRedeemConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .redeemMythosFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: MythosRedeemViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel.originFeeMixin, binder.mythosRedeemExtrinsicInfo.fee) + + viewModel.showNextProgress.observe(binder.mythosRedeemConfirm::setProgressState) + + viewModel.currentAccountModelFlow.observe(binder.mythosRedeemExtrinsicInfo::setAccount) + viewModel.walletFlow.observe(binder.mythosRedeemExtrinsicInfo::setWallet) + + viewModel.redeemableAmountModelFlow.observe(binder.mythosRedeemAmount::showLoadingState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/MythosRedeemViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/MythosRedeemViewModel.kt new file mode 100644 index 0000000..060e19f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/MythosRedeemViewModel.kt @@ -0,0 +1,131 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.redeem + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.MythosRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.validations.RedeemMythosStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.validations.RedeemMythosValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class MythosRedeemViewModel( + private val router: MythosStakingRouter, + private val resourceManager: ResourceManager, + private val validationSystem: RedeemMythosValidationSystem, + private val validationFailureFormatter: MythosStakingValidationFailureFormatter, + private val interactor: MythosRedeemInteractor, + private val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val redeemableAmountFlow = interactor.redeemAmountFlow() + .shareInBackground() + + val redeemableAmountModelFlow = combine(redeemableAmountFlow, assetFlow) { amount, asset -> + amountFormatter.formatAmountToAmountModel(amount, asset) + } + .withSafeLoading() + .shareInBackground() + + val currentAccountModelFlow = selectedAccountUseCase.selectedAddressModelFlow { selectedAssetState.chain() } + .shareInBackground() + + val walletFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val originFeeMixin = feeLoaderMixinV2Factory.createDefault(viewModelScope, selectedAssetState.selectedAssetFlow()) + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: StateFlow = _showNextProgress + + init { + originFeeMixin.loadFee { interactor.estimateFee() } + } + + fun confirmClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = currentAccountModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + private fun sendTransactionIfValid() = launchUnit { + _showNextProgress.value = true + + val payload = RedeemMythosStakingValidationPayload( + fee = originFeeMixin.awaitFee(), + asset = assetFlow.first() + ) + + val redeemAmount = redeemableAmountFlow.first() + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = validationFailureFormatter::formatRedeem, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction(redeemAmount) + } + } + + private fun sendTransaction(redeemAmount: Balance) = launch { + interactor.redeem(redeemAmount) + .onFailure(::showError) + .onSuccess { (submissionResult, redeemConsequences) -> + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(submissionResult.submissionHierarchy) { router.finishRedeemFlow(redeemConsequences) } + } + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/di/MythosRedeemComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/di/MythosRedeemComponent.kt new file mode 100644 index 0000000..646109d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/di/MythosRedeemComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.redeem.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.redeem.MythosRedeemFragment + +@Subcomponent( + modules = [ + MythosRedeemModule::class + ] +) +@ScreenScope +interface MythosRedeemComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): MythosRedeemComponent + } + + fun inject(fragment: MythosRedeemFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/di/MythosRedeemModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/di/MythosRedeemModule.kt new file mode 100644 index 0000000..27665fe --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/redeem/di/MythosRedeemModule.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.redeem.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.MythosRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.redeem.validations.RedeemMythosValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.redeem.MythosRedeemViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class MythosRedeemModule { + + @Provides + @IntoMap + @ViewModelKey(MythosRedeemViewModel::class) + fun provideViewModel( + router: MythosStakingRouter, + resourceManager: ResourceManager, + validationSystem: RedeemMythosValidationSystem, + validationFailureFormatter: MythosStakingValidationFailureFormatter, + interactor: MythosRedeemInteractor, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + externalActions: ExternalActions.Presentation, + selectedAssetState: AnySelectedAssetOptionSharedState, + validationExecutor: ValidationExecutor, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return MythosRedeemViewModel( + router = router, + resourceManager = resourceManager, + validationSystem = validationSystem, + validationFailureFormatter = validationFailureFormatter, + interactor = interactor, + feeLoaderMixinV2Factory = feeLoaderMixinFactory, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + selectedAccountUseCase = selectedAccountUseCase, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): MythosRedeemViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(MythosRedeemViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/ConfirmStartMythosStakingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/ConfirmStartMythosStakingFragment.kt new file mode 100644 index 0000000..3859cab --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/ConfirmStartMythosStakingFragment.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm + +import androidx.core.os.bundleOf +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm.ConfirmStartSingleTargetStakingFragment + +class ConfirmStartMythosStakingFragment : ConfirmStartSingleTargetStakingFragment() { + + companion object { + + private const val PAYLOAD = "ConfirmStartMythosStakingFragment.Payload" + + fun getBundle(payload: ConfirmStartMythosStakingPayload) = bundleOf(PAYLOAD to payload) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmStartMythosStakingFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/ConfirmStartMythosStakingPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/ConfirmStartMythosStakingPayload.kt new file mode 100644 index 0000000..1bf4317 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/ConfirmStartMythosStakingPayload.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm.ConfirmStartSingleTargetStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.MythosCollatorParcel +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.android.parcel.Parcelize + +@Parcelize +class ConfirmStartMythosStakingPayload( + val collator: MythosCollatorParcel, + override val amount: Balance, + override val fee: FeeParcelModel, +) : Parcelable, ConfirmStartSingleTargetStakingPayload diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/ConfirmStartMythosStakingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/ConfirmStartMythosStakingViewModel.kt new file mode 100644 index 0000000..6c0e99b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/ConfirmStartMythosStakingViewModel.kt @@ -0,0 +1,187 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.mixin.hints.NoHintsMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingBlockNumberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.hasStakedCollators +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.isNotStarted +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.StartMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.StartMythosStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.StartMythosStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.activateDetection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.pauseDetection +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm.ConfirmStartSingleTargetStakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.collatorAddressModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.ConfirmStartMythosStakingViewModel.MythosConfirmStartStakingState +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.details.mythos +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.toDomain +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take + +class ConfirmStartMythosStakingViewModel( + private val mythosRouter: MythosStakingRouter, + private val startStakingRouter: StartMultiStakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val resourceManager: ResourceManager, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val assetUseCase: AssetUseCase, + private val payload: ConfirmStartMythosStakingPayload, + private val stakingStartedDetectionService: StakingStartedDetectionService, + private val validationSystem: StartMythosStakingValidationSystem, + private val stakingBlockNumberUseCase: StakingBlockNumberUseCase, + private val mythosStakingValidationFailureFormatter: MythosStakingValidationFailureFormatter, + private val interactor: StartMythosStakingInteractor, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter, + mythosSharedComputation: MythosSharedComputation, + walletUiUseCase: WalletUiUseCase, +) : ConfirmStartSingleTargetStakingViewModel( + stateFactory = { computationalScope -> + MythosConfirmStartStakingState( + computationalScope = computationalScope, + mythosSharedComputation = mythosSharedComputation, + addressIconGenerator = addressIconGenerator, + payload = payload, + stakingBlockNumberUseCase = stakingBlockNumberUseCase + ) + }, + router = mythosRouter, + addressIconGenerator = addressIconGenerator, + selectedAccountUseCase = selectedAccountUseCase, + resourceManager = resourceManager, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + payload = payload, + amountFormatter = amountFormatter +), + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + override val hintsMixin = NoHintsMixin() + + override suspend fun confirmClicked(fee: Fee, amount: Balance, asset: Asset) { + val payload = StartMythosStakingValidationPayload( + amount = asset.token.amountFromPlanks(amount), + fee = fee, + asset = asset, + collator = state.collator, + delegatorState = state.currentDelegatorStateFlow.first(), + currentBlockNumber = state.currentBlockNumberFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { reason, _ -> mythosStakingValidationFailureFormatter.formatStartStaking(reason) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction(amount, it.collator, it.delegatorState) + } + } + + override suspend fun openStakeTargetInfo() { + val parcel = StakeTargetDetailsPayload.mythos(state.collator) + mythosRouter.openCollatorDetails(parcel) + } + + private fun sendTransaction( + amountInPlanks: Balance, + collator: MythosCollator, + currentState: MythosDelegatorState, + ) = launchUnit { + stakingStartedDetectionService.pauseDetection(viewModelScope) + + interactor.stake( + amount = amountInPlanks, + currentState = currentState, + candidate = collator.accountId, + ) + .onFailure { + showError(it) + + stakingStartedDetectionService.activateDetection(viewModelScope) + } + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { finishFlow(currentState) } + } + + _showNextProgress.value = false + } + + private fun finishFlow(previousState: MythosDelegatorState) { + if (previousState.isNotStarted()) { + startStakingRouter.returnToStakingDashboard() + } else { + mythosRouter.returnToStakingMain() + } + } + + class MythosConfirmStartStakingState( + computationalScope: ComputationalScope, + mythosSharedComputation: MythosSharedComputation, + private val addressIconGenerator: AddressIconGenerator, + payload: ConfirmStartMythosStakingPayload, + private val stakingBlockNumberUseCase: StakingBlockNumberUseCase, + ) : ConfirmStartSingleTargetStakingState, + ComputationalScope by computationalScope { + + val currentDelegatorStateFlow = mythosSharedComputation.delegatorStateFlow() + .take(1) // Take 1 to avoid changing state after tx is in block + .shareInBackground() + + val currentBlockNumberFlow = stakingBlockNumberUseCase.currentBlockNumberFlow() + .shareInBackground() + + val collator = payload.collator.toDomain() + + override fun isStakeMoreFlow(): Flow { + return currentDelegatorStateFlow.map { it.hasStakedCollators() } + } + + override suspend fun collatorAddressModel(chain: Chain): AddressModel { + return addressIconGenerator.collatorAddressModel(collator, chain) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/di/ConfirmStartMythosStakingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/di/ConfirmStartMythosStakingComponent.kt new file mode 100644 index 0000000..c6cc621 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/di/ConfirmStartMythosStakingComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.ConfirmStartMythosStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.ConfirmStartMythosStakingPayload + +@Subcomponent( + modules = [ + ConfirmStartMythosStakingModule::class + ] +) +@ScreenScope +interface ConfirmStartMythosStakingComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmStartMythosStakingPayload, + ): ConfirmStartMythosStakingComponent + } + + fun inject(fragment: ConfirmStartMythosStakingFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/di/ConfirmStartMythosStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/di/ConfirmStartMythosStakingModule.kt new file mode 100644 index 0000000..5bb0897 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/confirm/di/ConfirmStartMythosStakingModule.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingBlockNumberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.StartMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.StartMythosStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.ConfirmStartMythosStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.ConfirmStartMythosStakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.di.StartParachainStakingModule +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 + +@Module(includes = [ViewModelModule::class, StartParachainStakingModule::class]) +class ConfirmStartMythosStakingModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmStartMythosStakingViewModel::class) + fun provideViewModel( + mythosRouter: MythosStakingRouter, + startStakingRouter: StartMultiStakingRouter, + addressIconGenerator: AddressIconGenerator, + selectedAccountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + externalActions: ExternalActions.Presentation, + selectedAssetState: StakingSharedState, + validationExecutor: ValidationExecutor, + assetUseCase: AssetUseCase, + payload: ConfirmStartMythosStakingPayload, + stakingStartedDetectionService: StakingStartedDetectionService, + mythosSharedComputation: MythosSharedComputation, + walletUiUseCase: WalletUiUseCase, + validationSystem: StartMythosStakingValidationSystem, + stakingBlockNumberUseCase: StakingBlockNumberUseCase, + mythosStakingValidationFailureFormatter: MythosStakingValidationFailureFormatter, + interactor: StartMythosStakingInteractor, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmStartMythosStakingViewModel( + mythosRouter = mythosRouter, + startStakingRouter = startStakingRouter, + addressIconGenerator = addressIconGenerator, + selectedAccountUseCase = selectedAccountUseCase, + resourceManager = resourceManager, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + assetUseCase = assetUseCase, + payload = payload, + stakingStartedDetectionService = stakingStartedDetectionService, + mythosSharedComputation = mythosSharedComputation, + walletUiUseCase = walletUiUseCase, + validationSystem = validationSystem, + stakingBlockNumberUseCase = stakingBlockNumberUseCase, + mythosStakingValidationFailureFormatter = mythosStakingValidationFailureFormatter, + interactor = interactor, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ConfirmStartMythosStakingViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmStartMythosStakingViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/details/Payload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/details/Payload.kt new file mode 100644 index 0000000..a982758 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/details/Payload.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.details + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.RewardSuffix +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.toTargetDetailsParcel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload + +fun StakeTargetDetailsPayload.Companion.mythos(collator: MythosCollator) = StakeTargetDetailsPayload( + stakeTarget = collator.toTargetDetailsParcel(), + displayConfig = StakeTargetDetailsPayload.DisplayConfig( + rewardSuffix = RewardSuffix.APR, + rewardedStakersPerStakeTarget = null, + titleRes = R.string.staking_parachain_collator_info, + stakersLabelRes = R.string.staking_parachain_delegators, + oversubscribedWarningText = R.string.staking_parachain_collator_details_oversubscribed + ) +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/SelectMythosCollatorFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/SelectMythosCollatorFragment.kt new file mode 100644 index 0000000..13cd91f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/SelectMythosCollatorFragment.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.chooseTarget.SingleSelectChooseTargetFragment + +class SelectMythosCollatorFragment : SingleSelectChooseTargetFragment() { + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .selectMythosCollatorFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/SelectMythosCollatorViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/SelectMythosCollatorViewModel.kt new file mode 100644 index 0000000..f236a71 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/SelectMythosCollatorViewModel.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorRecommendationConfig +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorSorting +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.chooseTarget.SearchAction +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.chooseTarget.SingleSelectChooseTargetViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenResponder +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.MythosCollatorFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.details.mythos +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.toParcel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsInterScreenRequester +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.toDomain +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.toParcel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class SelectMythosCollatorViewModel( + private val router: MythosStakingRouter, + private val recommendatorFactory: MythosCollatorRecommendatorFactory, + private val resourceManager: ResourceManager, + private val tokenUseCase: TokenUseCase, + private val selectedAssetState: StakingSharedState, + private val mythosCollatorFormatter: MythosCollatorFormatter, + private val selectCollatorResponder: SelectMythosInterScreenResponder, + private val settingsRequester: SelectMythCollatorSettingsInterScreenRequester +) : SingleSelectChooseTargetViewModel( + router = router, + recommendatorFactory = recommendatorFactory, + resourceManager = resourceManager, + tokenUseCase = tokenUseCase, + selectedAssetState = selectedAssetState, + state = MythosState( + mythosCollatorFormatter = mythosCollatorFormatter, + resourceManager = resourceManager, + settingsRequester = settingsRequester + ) +) { + + override fun settingsClicked(currentConfig: MythosCollatorRecommendationConfig) { + settingsRequester.openRequest(currentConfig.toParcel()) + } + + override suspend fun targetInfoClicked(target: MythosCollator) { + val payload = StakeTargetDetailsPayload.mythos(target) + router.openCollatorDetails(payload) + } + + override suspend fun targetSelected(target: MythosCollator) { + selectCollatorResponder.respond(target.toParcel()) + router.returnToStartStaking() + } + + class MythosState( + private val mythosCollatorFormatter: MythosCollatorFormatter, + private val resourceManager: ResourceManager, + private val settingsRequester: SelectMythCollatorSettingsInterScreenRequester + ) : SingleSelectChooseTargetState { + + override val defaultRecommendatorConfig: MythosCollatorRecommendationConfig = MythosCollatorRecommendationConfig.DEFAULT + + override val searchAction: SearchAction? = null + + override suspend fun convertTargetsToUi( + targets: List, + token: Token, + config: MythosCollatorRecommendationConfig + ): List> { + return targets.map { + mythosCollatorFormatter.collatorToUi(it, token, config) + } + } + + override fun scoringHeaderFor(config: MythosCollatorRecommendationConfig): String { + return when (config.sorting) { + MythosCollatorSorting.REWARDS -> resourceManager.getString(R.string.staking_rewards) + MythosCollatorSorting.TOTAL_STAKE -> resourceManager.getString(R.string.staking_validator_total_stake) + } + } + + override fun recommendationConfigChanges(): Flow { + return settingsRequester.responseFlow + .map { it.toDomain() } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/di/SelectMythosCollatorComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/di/SelectMythosCollatorComponent.kt new file mode 100644 index 0000000..a442756 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/di/SelectMythosCollatorComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.SelectMythosCollatorFragment + +@Subcomponent( + modules = [ + SelectMythosCollatorModule::class + ] +) +@ScreenScope +interface SelectMythosCollatorComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): SelectMythosCollatorComponent + } + + fun inject(fragment: SelectMythosCollatorFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/di/SelectMythosCollatorModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/di/SelectMythosCollatorModule.kt new file mode 100644 index 0000000..7b873a7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/di/SelectMythosCollatorModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.MythosCollatorFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.SelectMythosCollatorViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase + +@Module(includes = [ViewModelModule::class]) +class SelectMythosCollatorModule { + + @Provides + @IntoMap + @ViewModelKey(SelectMythosCollatorViewModel::class) + fun provideViewModel( + router: MythosStakingRouter, + recommendatorFactory: MythosCollatorRecommendatorFactory, + resourceManager: ResourceManager, + tokenUseCase: TokenUseCase, + selectedAssetState: StakingSharedState, + mythosCollatorFormatter: MythosCollatorFormatter, + selectCollatorInterScreenCommunicator: SelectMythosInterScreenCommunicator, + settingsRequester: SelectMythCollatorSettingsInterScreenCommunicator + ): ViewModel { + return SelectMythosCollatorViewModel( + router = router, + recommendatorFactory = recommendatorFactory, + resourceManager = resourceManager, + tokenUseCase = tokenUseCase, + selectedAssetState = selectedAssetState, + mythosCollatorFormatter = mythosCollatorFormatter, + selectCollatorResponder = selectCollatorInterScreenCommunicator, + settingsRequester = settingsRequester + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectMythosCollatorViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectMythosCollatorViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/model/MythosCollatorParcel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/model/MythosCollatorParcel.kt new file mode 100644 index 0000000..fe3ee57 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollator/model/MythosCollatorParcel.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model + +import android.os.Parcelable +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapIdentityParcelModelToIdentity +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapIdentityToIdentityParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.IdentityParcelModel +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.android.parcel.Parcelize + +@Parcelize +class MythosCollatorParcel( + val accountId: AccountId, + val identity: IdentityParcelModel?, + val totalStake: Balance, + val delegators: Int, + val apr: Double? +) : Parcelable + +fun MythosCollator.toParcel(): MythosCollatorParcel { + return MythosCollatorParcel( + accountId = this.accountId.value, + identity = this.identity?.let { mapIdentityToIdentityParcelModel(it) }, + totalStake = this.totalStake, + delegators = this.delegators, + apr = this.apr?.inFraction + ) +} + +fun MythosCollatorParcel.toDomain(): MythosCollator { + return MythosCollator( + accountId = accountId.intoKey(), + identity = identity?.let { mapIdentityParcelModelToIdentity(it) }, + totalStake = totalStake, + delegators = delegators, + apr = apr?.fractions + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/SelectMythCollatorSettingsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/SelectMythCollatorSettingsFragment.kt new file mode 100644 index 0000000..f59d82c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/SelectMythCollatorSettingsFragment.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings + +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentMythosStakingSelectCollatorSettingsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorSorting +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.MythCollatorRecommendationConfigParcel + +private val SORT_MAPPING = mapOf( + MythosCollatorSorting.REWARDS to R.id.selectCollatorSettingsSortRewards, + MythosCollatorSorting.TOTAL_STAKE to R.id.selectCollatorSettingsSortTotalStake, +) + +class SelectMythCollatorSettingsFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD_KEY = "SelectMythCollatorSettingsFragment.Payload" + + fun getBundle(payload: MythCollatorRecommendationConfigParcel) = bundleOf(PAYLOAD_KEY to payload) + } + + override fun createBinding() = FragmentMythosStakingSelectCollatorSettingsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.selectCollatorSettingsApply.setOnClickListener { viewModel.applyChanges() } + + binder.selectCollatorSettingsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.selectCollatorSettingsToolbar.setRightActionClickListener { viewModel.reset() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .selectMythosSettingsFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: SelectMythCollatorSettingsViewModel) { + binder.selectCollatorSettingsSort.bindTo(viewModel.selectedSortingFlow, lifecycleScope, SORT_MAPPING) + + viewModel.isApplyButtonEnabled.observe { + binder.selectCollatorSettingsApply.setState(if (it) ButtonState.NORMAL else ButtonState.DISABLED) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/SelectMythCollatorSettingsInterScreenCommunicator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/SelectMythCollatorSettingsInterScreenCommunicator.kt new file mode 100644 index 0000000..ee3b115 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/SelectMythCollatorSettingsInterScreenCommunicator.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings + +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.MythCollatorRecommendationConfigParcel + +interface SelectMythCollatorSettingsInterScreenRequester : InterScreenRequester +interface SelectMythCollatorSettingsInterScreenResponder : InterScreenResponder + +interface SelectMythCollatorSettingsInterScreenCommunicator : SelectMythCollatorSettingsInterScreenRequester, SelectMythCollatorSettingsInterScreenResponder diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/SelectMythCollatorSettingsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/SelectMythCollatorSettingsViewModel.kt new file mode 100644 index 0000000..fc4fe7a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/SelectMythCollatorSettingsViewModel.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorRecommendationConfig +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.MythCollatorRecommendationConfigParcel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.toDomain +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.toParcel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SelectMythCollatorSettingsViewModel( + private val router: MythosStakingRouter, + private val payload: MythCollatorRecommendationConfigParcel, + private val selectCollatorSettingsInterScreenResponder: SelectMythCollatorSettingsInterScreenResponder, +) : BaseViewModel() { + + val selectedSortingFlow = MutableStateFlow(payload.sorting) + + private val initialConfig = payload.toDomain() + + private val modifiedConfig = selectedSortingFlow.map(::MythosCollatorRecommendationConfig) + .share() + + val isApplyButtonEnabled = modifiedConfig.map { modified -> + initialConfig != modified + }.share() + + fun reset() { + viewModelScope.launch { + val defaultSettings = MythosCollatorRecommendationConfig.DEFAULT + selectedSortingFlow.value = defaultSettings.sorting + } + } + + fun applyChanges() { + viewModelScope.launch { + val newConfig = modifiedConfig.first().toParcel() + selectCollatorSettingsInterScreenResponder.respond(newConfig) + + router.back() + } + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/di/SelectMythCollatorSettingsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/di/SelectMythCollatorSettingsComponent.kt new file mode 100644 index 0000000..c99fb7f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/di/SelectMythCollatorSettingsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.MythCollatorRecommendationConfigParcel + +@Subcomponent( + modules = [ + SelectMythCollatorSettingsModule::class + ] +) +@ScreenScope +interface SelectMythCollatorSettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: MythCollatorRecommendationConfigParcel, + ): SelectMythCollatorSettingsComponent + } + + fun inject(fragment: SelectMythCollatorSettingsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/di/SelectMythCollatorSettingsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/di/SelectMythCollatorSettingsModule.kt new file mode 100644 index 0000000..770976b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/di/SelectMythCollatorSettingsModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.SelectMythCollatorSettingsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model.MythCollatorRecommendationConfigParcel + +@Module(includes = [ViewModelModule::class]) +class SelectMythCollatorSettingsModule { + + @Provides + @IntoMap + @ViewModelKey(SelectMythCollatorSettingsViewModel::class) + fun provideViewModel( + router: MythosStakingRouter, + payload: MythCollatorRecommendationConfigParcel, + selectCollatorSettingsInterScreenResponder: SelectMythCollatorSettingsInterScreenCommunicator, + ): ViewModel { + return SelectMythCollatorSettingsViewModel( + router = router, + payload = payload, + selectCollatorSettingsInterScreenResponder = selectCollatorSettingsInterScreenResponder + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectMythCollatorSettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectMythCollatorSettingsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/model/MythCollatorRecommendationConfigParcel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/model/MythCollatorRecommendationConfigParcel.kt new file mode 100644 index 0000000..96bc743 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/selectCollatorSettings/model/MythCollatorRecommendationConfigParcel.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollatorSettings.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorRecommendationConfig +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorSorting +import kotlinx.android.parcel.Parcelize + +@Parcelize +class MythCollatorRecommendationConfigParcel(val sorting: MythosCollatorSorting) : Parcelable + +fun MythosCollatorRecommendationConfig.toParcel(): MythCollatorRecommendationConfigParcel { + return MythCollatorRecommendationConfigParcel(sorting) +} + +fun MythCollatorRecommendationConfigParcel.toDomain(): MythosCollatorRecommendationConfig { + return MythosCollatorRecommendationConfig(sorting) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/SetupStartMythosStakingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/SetupStartMythosStakingFragment.kt new file mode 100644 index 0000000..358522e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/SetupStartMythosStakingFragment.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.start.StartSingleSelectStakingFragment + +class SetupStartMythosStakingFragment : StartSingleSelectStakingFragment() { + + override fun initViews() { + super.initViews() + + binder.startParachainStakingRewards.setName(getString(R.string.staking_earnings_per_year)) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .startMythosStakingFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/SetupStartMythosStakingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/SetupStartMythosStakingViewModel.kt new file mode 100644 index 0000000..6e2c886 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/SetupStartMythosStakingViewModel.kt @@ -0,0 +1,207 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.hints.NoHintsMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingBlockNumberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model.TargetWithStakedAmount +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosDelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.hasStakedCollators +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.stakeableBalance +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.StartMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.StartMythosStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.StartMythosStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.DelegationsLimit +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.SelectStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.start.StartSingleSelectStakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenRequester +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.MythosCollatorFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.openRequest +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.confirm.ConfirmStartMythosStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.toDomain +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.toParcel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup.rewards.MythosStakingRewardsComponentFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.toParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.math.BigDecimal + +class SetupStartMythosStakingViewModel( + private val router: MythosStakingRouter, + rewardsComponentFactory: MythosStakingRewardsComponentFactory, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + assetUseCase: AssetUseCase, + private val resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val collatorRecommendatorFactory: MythosCollatorRecommendatorFactory, + private val mythosDelegatorStateUseCase: MythosDelegatorStateUseCase, + private val selectedAssetState: StakingSharedState, + private val validationSystem: StartMythosStakingValidationSystem, + private val stakingBlockNumberUseCase: StakingBlockNumberUseCase, + private val mythosStakingValidationFailureFormatter: MythosStakingValidationFailureFormatter, + mythosSharedComputation: MythosSharedComputation, + mythosCollatorFormatter: MythosCollatorFormatter, + private val interactor: StartMythosStakingInteractor, + private val selectCollatorInterScreenRequester: SelectMythosInterScreenRequester, + private val amountFormatter: AmountFormatter, + amountChooserMixinFactory: AmountChooserMixin.Factory, +) : StartSingleSelectStakingViewModel( + logicFactory = { scope -> + MythosLogic( + computationalScope = scope, + mythosSharedComputation = mythosSharedComputation, + mythosCollatorFormatter = mythosCollatorFormatter, + interactor = interactor, + mythosDelegatorStateUseCase = mythosDelegatorStateUseCase, + selectCollatorRequester = selectCollatorInterScreenRequester, + stakingBlockNumberUseCase = stakingBlockNumberUseCase + ) + }, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + rewardsComponentFactory = rewardsComponentFactory, + assetUseCase = assetUseCase, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + recommendatorFactory = collatorRecommendatorFactory, + selectedAssetState = selectedAssetState, + router = router, + amountChooserMixinFactory = amountChooserMixinFactory, + amountFormatter = amountFormatter +) { + + override val hintsMixin = NoHintsMixin() + + override suspend fun openSelectNewTarget() { + val delegatorState = logic.currentDelegatorStateFlow.first() + + when (val check = interactor.checkDelegationsLimit(delegatorState)) { + DelegationsLimit.NotReached -> selectCollatorInterScreenRequester.openRequest() + is DelegationsLimit.Reached -> { + showError( + title = resourceManager.getString(R.string.staking_parachain_max_delegations_title), + text = resourceManager.getString(R.string.staking_parachain_max_delegations_message, check.limit) + ) + } + } + } + + override suspend fun openSelectFirstTarget() { + selectCollatorInterScreenRequester.openRequest() + } + + override suspend fun goNext(target: MythosCollator, amount: BigDecimal, fee: Fee, asset: Asset) { + val payload = StartMythosStakingValidationPayload( + amount = amount, + fee = fee, + asset = asset, + collator = target, + delegatorState = logic.currentDelegatorStateFlow.first(), + currentBlockNumber = logic.currentBlockNumberFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { reason, _ -> mythosStakingValidationFailureFormatter.formatStartStaking(reason) }, + progressConsumer = validationInProgress.progressConsumer() + ) { + validationInProgress.value = false + + goToNextStep(fee, asset.token.planksFromAmount(amount), target) + } + } + + private fun goToNextStep( + fee: Fee, + amount: Balance, + collator: MythosCollator, + ) { + val payload = ConfirmStartMythosStakingPayload(collator.toParcel(), amount, fee.toParcel()) + router.openConfirmStartStaking(payload) + } + + class MythosLogic( + computationalScope: ComputationalScope, + private val mythosSharedComputation: MythosSharedComputation, + private val mythosCollatorFormatter: MythosCollatorFormatter, + private val mythosDelegatorStateUseCase: MythosDelegatorStateUseCase, + private val interactor: StartMythosStakingInteractor, + private val selectCollatorRequester: SelectMythosInterScreenRequester, + private val stakingBlockNumberUseCase: StakingBlockNumberUseCase, + ) : StartSingleSelectStakingLogic, + ComputationalScope by computationalScope { + + val currentDelegatorStateFlow = mythosSharedComputation.delegatorStateFlow() + .shareInBackground() + + val currentBlockNumberFlow = stakingBlockNumberUseCase.currentBlockNumberFlow() + .shareInBackground() + + override fun selectedTargetChanges(): Flow { + return selectCollatorRequester.responseFlow + .map { it.toDomain() } + } + + override fun stakeableAmount(assetFlow: Flow): Flow { + return combine( + assetFlow, + currentDelegatorStateFlow, + currentBlockNumberFlow + ) { asset, mythosDelegatorState, currentBlockNumberFlow -> + mythosDelegatorState.stakeableBalance(asset, currentBlockNumberFlow) + } + } + + override fun isStakeMore(): Flow { + return currentDelegatorStateFlow.map { it.hasStakedCollators() } + } + + override fun alreadyStakedTargets(): Flow>> { + return currentDelegatorStateFlow.map { mythosDelegatorStateUseCase.getStakedCollators(it) } + } + + override fun alreadyStakedAmountTo(accountIdKey: AccountIdKey): Flow { + return currentDelegatorStateFlow.map { + it.delegationAmountTo(accountIdKey).orZero() + } + } + + override suspend fun mapStakedTargetToUi(target: TargetWithStakedAmount, asset: Asset): SelectStakeTargetModel { + return mythosCollatorFormatter.collatorToSelectUi(target, asset.token) + } + + override suspend fun minimumStakeToGetRewards(selectedStakeTarget: MythosCollator?): Balance { + return interactor.minStake() + } + + override suspend fun estimateFee(amount: Balance, targetId: AccountIdKey?): Fee { + return interactor.estimateFee(currentDelegatorStateFlow.first(), targetId, amount) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/di/SetupStartMythosStakingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/di/SetupStartMythosStakingComponent.kt new file mode 100644 index 0000000..b75bb65 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/di/SetupStartMythosStakingComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup.SetupStartMythosStakingFragment + +@Subcomponent( + modules = [ + SetupStartMythosStakingModule::class + ] +) +@ScreenScope +interface SetupStartMythosStakingComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): SetupStartMythosStakingComponent + } + + fun inject(fragment: SetupStartMythosStakingFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/di/SetupStartMythosStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/di/SetupStartMythosStakingModule.kt new file mode 100644 index 0000000..c588952 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/di/SetupStartMythosStakingModule.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingBlockNumberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosDelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.recommendations.MythosCollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.StartMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.start.validations.StartMythosStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.SelectMythosInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.MythosCollatorFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup.SetupStartMythosStakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup.rewards.MythosStakingRewardsComponentFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 + +@Module(includes = [ViewModelModule::class]) +class SetupStartMythosStakingModule { + + @Provides + @IntoMap + @ViewModelKey(SetupStartMythosStakingViewModel::class) + fun provideViewModel( + router: MythosStakingRouter, + rewardsComponentFactory: MythosStakingRewardsComponentFactory, + assetUseCase: AssetUseCase, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + collatorRecommendatorFactory: MythosCollatorRecommendatorFactory, + mythosDelegatorStateUseCase: MythosDelegatorStateUseCase, + selectedAssetState: StakingSharedState, + mythosSharedComputation: MythosSharedComputation, + mythosCollatorFormatter: MythosCollatorFormatter, + interactor: StartMythosStakingInteractor, + amountChooserMixinFactory: AmountChooserMixin.Factory, + selectCollatorInterScreenCommunicator: SelectMythosInterScreenCommunicator, + validationSystem: StartMythosStakingValidationSystem, + blockNumberUseCase: StakingBlockNumberUseCase, + validationFailureFormatter: MythosStakingValidationFailureFormatter, + amountFormatter: AmountFormatter + ): ViewModel { + return SetupStartMythosStakingViewModel( + router = router, + rewardsComponentFactory = rewardsComponentFactory, + assetUseCase = assetUseCase, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + collatorRecommendatorFactory = collatorRecommendatorFactory, + mythosDelegatorStateUseCase = mythosDelegatorStateUseCase, + selectedAssetState = selectedAssetState, + mythosSharedComputation = mythosSharedComputation, + mythosCollatorFormatter = mythosCollatorFormatter, + interactor = interactor, + amountChooserMixinFactory = amountChooserMixinFactory, + selectCollatorInterScreenRequester = selectCollatorInterScreenCommunicator, + validationSystem = validationSystem, + mythosStakingValidationFailureFormatter = validationFailureFormatter, + stakingBlockNumberUseCase = blockNumberUseCase, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SetupStartMythosStakingViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SetupStartMythosStakingViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/rewards/MythosStakingRewardsComponentFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/rewards/MythosStakingRewardsComponentFactory.kt new file mode 100644 index 0000000..0b33766 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/start/setup/rewards/MythosStakingRewardsComponentFactory.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.setup.rewards + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.rewards.PeriodReturns +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards.SingleSelectStakingRewardEstimationComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards.SingleSelectStakingRewardEstimationComponentFactory +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal +import javax.inject.Inject + +@FeatureScope +class MythosStakingRewardsComponentFactory @Inject constructor( + private val singleAssetSharedState: StakingSharedState, + private val mythosSharedComputation: MythosSharedComputation, + private val resourceManager: ResourceManager, +) : SingleSelectStakingRewardEstimationComponentFactory { + + override fun create( + computationalScope: ComputationalScope, + assetFlow: Flow, + selectedAmount: Flow, + selectedTarget: Flow + ): SingleSelectStakingRewardEstimationComponent = MythosStakingRewardsComponent( + singleAssetSharedState = singleAssetSharedState, + resourceManager = resourceManager, + computationalScope = computationalScope, + assetFlow = assetFlow, + selectedAmountFlow = selectedAmount, + selectedTargetFlow = selectedTarget, + mythosSharedComputation = mythosSharedComputation + ) +} + +private class MythosStakingRewardsComponent( + private val singleAssetSharedState: StakingSharedState, + private val mythosSharedComputation: MythosSharedComputation, + resourceManager: ResourceManager, + + computationalScope: ComputationalScope, + assetFlow: Flow, + selectedAmountFlow: Flow, + selectedTargetFlow: Flow +) : SingleSelectStakingRewardEstimationComponent(resourceManager, computationalScope, assetFlow, selectedAmountFlow, selectedTargetFlow) { + + private val rewardCalculator by lazyAsync { + mythosSharedComputation.rewardCalculator(singleAssetSharedState.chainId()) + } + + override suspend fun calculatePeriodReturns(selectedTarget: AccountIdKey?, selectedAmount: BigDecimal): PeriodReturns { + return if (selectedTarget != null) { + rewardCalculator().calculateCollatorAnnualReturns(selectedTarget, selectedAmount) + } else { + rewardCalculator().calculateMaxAnnualReturns(selectedAmount) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/ConfirmUnbondMythosFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/ConfirmUnbondMythosFragment.kt new file mode 100644 index 0000000..fb8efee --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/ConfirmUnbondMythosFragment.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm + +import androidx.core.os.bundleOf +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentMythosUnbondConfirmBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class ConfirmUnbondMythosFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "ConfirmUnbondMythosFragment.Payload" + + fun getBundle(payload: ConfirmUnbondMythosPayload) = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentMythosUnbondConfirmBinding.inflate(layoutInflater) + + override fun initViews() { + binder.mythosUnbondConfirmToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.mythosUnbondConfirmExtrinsicInfo.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.mythosUnbondConfirmConfirm.prepareForProgress(viewLifecycleOwner) + binder.mythosUnbondConfirmConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.mythosUnbondConfirmCollator.setOnClickListener { viewModel.collatorClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmUnbondMythosFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmUnbondMythosViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel.feeLoaderMixin, binder.mythosUnbondConfirmExtrinsicInfo.fee) + + viewModel.showNextProgress.observe(binder.mythosUnbondConfirmConfirm::setProgressState) + + viewModel.currentAccountModelFlow.observe(binder.mythosUnbondConfirmExtrinsicInfo::setAccount) + viewModel.walletFlow.observe(binder.mythosUnbondConfirmExtrinsicInfo::setWallet) + + viewModel.collatorAddressModel.observe(binder.mythosUnbondConfirmCollator::showAddress) + viewModel.amountModel.observe(binder.mythosUnbondConfirmAmount::setAmount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/ConfirmUnbondMythosPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/ConfirmUnbondMythosPayload.kt new file mode 100644 index 0000000..3ce04d7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/ConfirmUnbondMythosPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.MythosCollatorParcel +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.android.parcel.Parcelize + +@Parcelize +class ConfirmUnbondMythosPayload( + val collator: MythosCollatorParcel, + val amount: Balance, + val fee: FeeParcelModel +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/ConfirmUnbondMythosViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/ConfirmUnbondMythosViewModel.kt new file mode 100644 index 0000000..ded12c7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/ConfirmUnbondMythosViewModel.kt @@ -0,0 +1,147 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.delegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.UnbondMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.UnbondMythosStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.UnbondMythosValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.collatorAddressModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.details.mythos +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.toDomain +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.toDomain +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmUnbondMythosViewModel( + private val router: MythosStakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val validationSystem: UnbondMythosValidationSystem, + private val interactor: UnbondMythosStakingInteractor, + private val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val mythosSharedComputation: MythosSharedComputation, + private val payload: ConfirmUnbondMythosPayload, + private val validationFailureFormatter: MythosStakingValidationFailureFormatter, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val fee = payload.fee.toDomain() + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val collator = payload.collator.toDomain() + + val currentAccountModelFlow = selectedAccountUseCase.selectedAddressModelFlow { selectedAssetState.chain() } + .shareInBackground() + + val amountModel = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.amount, asset) + } + .shareInBackground() + + val walletFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val collatorAddressModel = flowOf { + addressIconGenerator.collatorAddressModel(collator, selectedAssetState.chain()) + }.shareInBackground() + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: StateFlow = _showNextProgress + + val feeLoaderMixin = feeLoaderMixinV2Factory.createDefault(viewModelScope, selectedAssetState.selectedAssetFlow()) + + init { + setInitialFee() + } + + fun confirmClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = currentAccountModelFlow.first().address + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + fun collatorClicked() { + router.openCollatorDetails(StakeTargetDetailsPayload.mythos(collator)) + } + + private fun setInitialFee() = launch { + feeLoaderMixin.setFee(fee) + } + + private fun sendTransactionIfValid() = launch { + val payload = UnbondMythosStakingValidationPayload( + fee = fee, + collator = collator, + asset = assetFlow.first(), + delegatorState = mythosSharedComputation.delegatorState() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { failure, _ -> validationFailureFormatter.formatUnbond(failure) }, + progressConsumer = _showNextProgress.progressConsumer(), + block = ::sendTransaction + ) + } + + private fun sendTransaction(validPayload: UnbondMythosStakingValidationPayload) = launch { + interactor.unbond(validPayload.delegatorState, validPayload.collator.accountId) + .onFailure(::showError) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/di/ConfirmUnbondMythosComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/di/ConfirmUnbondMythosComponent.kt new file mode 100644 index 0000000..162e645 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/di/ConfirmUnbondMythosComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.ConfirmUnbondMythosFragment +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.ConfirmUnbondMythosPayload + +@Subcomponent( + modules = [ + ConfirmUnbondMythosModule::class + ] +) +@ScreenScope +interface ConfirmUnbondMythosComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmUnbondMythosPayload + ): ConfirmUnbondMythosComponent + } + + fun inject(fragment: ConfirmUnbondMythosFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/di/ConfirmUnbondMythosModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/di/ConfirmUnbondMythosModule.kt new file mode 100644 index 0000000..d92ad82 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/confirm/di/ConfirmUnbondMythosModule.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.UnbondMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.UnbondMythosValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.ConfirmUnbondMythosPayload +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.ConfirmUnbondMythosViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmUnbondMythosModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmUnbondMythosViewModel::class) + fun provideViewModel( + router: MythosStakingRouter, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + validationSystem: UnbondMythosValidationSystem, + interactor: UnbondMythosStakingInteractor, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + externalActions: ExternalActions.Presentation, + selectedAssetState: StakingSharedState, + validationExecutor: ValidationExecutor, + mythosSharedComputation: MythosSharedComputation, + payload: ConfirmUnbondMythosPayload, + validationFailureFormatter: MythosStakingValidationFailureFormatter, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmUnbondMythosViewModel( + router = router, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + validationSystem = validationSystem, + interactor = interactor, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + mythosSharedComputation = mythosSharedComputation, + payload = payload, + validationFailureFormatter = validationFailureFormatter, + selectedAccountUseCase = selectedAccountUseCase, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmUnbondMythosViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmUnbondMythosViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/SetupUnbondMythosFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/SetupUnbondMythosFragment.kt new file mode 100644 index 0000000..5fdf5e4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/SetupUnbondMythosFragment.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.setup + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentMythosUnbondBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class SetupUnbondMythosFragment : BaseFragment() { + + override fun createBinding() = FragmentMythosUnbondBinding.inflate(layoutInflater) + + override fun initViews() { + binder.mythosUnbondToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.mythosUnbondNext.prepareForProgress(viewLifecycleOwner) + binder.mythosUnbondNext.setOnClickListener { viewModel.nextClicked() } + + binder.mythosUnbondCollator.setOnClickListener { viewModel.selectCollatorClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .setupUnbondMythosFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SetupUnbondMythosViewModel) { + observeValidations(viewModel) + setupAmountChooser(viewModel.amountChooserMixin, binder.mythosUnbondAmountField) + setupFeeLoading(viewModel.feeLoaderMixin, binder.mythosUnbondFee) + + viewModel.selectedCollatorModel.observe(binder.mythosUnbondCollator::setSelectedTarget) + + viewModel.buttonState.observe(binder.mythosUnbondNext::setState) + + viewModel.transferable.observe(binder.mythosUnbondTransferable::showAmount) + + viewModel.chooseCollatorAction.awaitableActionLiveData.observeEvent { action -> + ChooseStakedStakeTargetsBottomSheet( + context = requireContext(), + payload = action.payload, + stakedCollatorSelected = { _, item -> action.onSuccess(item) }, + onCancel = action.onCancel, + newStakeTargetClicked = null + ).show() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/SetupUnbondMythosViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/SetupUnbondMythosViewModel.kt new file mode 100644 index 0000000..f061d87 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/SetupUnbondMythosViewModel.kt @@ -0,0 +1,227 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.setup + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.findById +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model.TargetWithStakedAmount +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosDelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosCollator +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.UnbondMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.UnbondMythosStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.UnbondMythosValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.MythosCollatorFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.model.MythosCollatorWithAmount +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.model.MythosSelectCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.start.selectCollator.model.toParcel +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.confirm.ConfirmUnbondMythosPayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.transferableAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setBlockedAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setInputBlocked +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.toParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.runtime.state.chainAsset +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet.Payload as SelectCollatorPayload + +class SetupUnbondMythosViewModel( + private val router: MythosStakingRouter, + private val interactor: UnbondMythosStakingInteractor, + private val assetUseCase: AssetUseCase, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: UnbondMythosValidationSystem, + private val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val delegatorStateUseCase: MythosDelegatorStateUseCase, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val mythosSharedComputation: MythosSharedComputation, + private val mythosCollatorFormatter: MythosCollatorFormatter, + private val mythosValidationFailureFormatter: MythosStakingValidationFailureFormatter, + private val stakingSharedState: StakingSharedState, + private val amountFormatter: AmountFormatter, + amountChooserMixinFactory: AmountChooserMixin.Factory, +) : BaseViewModel(), + Validatable by validationExecutor { + + private val validationInProgress = MutableStateFlow(false) + + private val assetFlow = assetUseCase.currentAssetFlow() + .share() + + private val chainAssetFlow = stakingSharedState.selectedAssetFlow() + + private val currentDelegatorStateFlow = mythosSharedComputation.delegatorStateFlow() + .shareInBackground() + + private val alreadyStakedCollatorsFlow = currentDelegatorStateFlow + .mapLatest { delegatorStateUseCase.getStakedCollators(it) } + .shareInBackground() + + private val selectedCollatorFlow = singleReplaySharedFlow() + private val selectedCollatorIdFlow = selectedCollatorFlow.map { it.accountId } + + private val selectedCollatorWithStake = combine(selectedCollatorFlow, currentDelegatorStateFlow) { selectedCollator, currentDelegatorState -> + val stake = currentDelegatorState.delegationAmountTo(selectedCollator.accountId) + + TargetWithStakedAmount(stake, selectedCollator) + } + + private val stakedAmount = selectedCollatorWithStake.map { it.stake } + + val selectedCollatorModel = combine( + selectedCollatorWithStake, + assetFlow + ) { selectedCollator, asset -> + mythosCollatorFormatter.collatorToSelectUi(selectedCollator, asset.token) + }.shareInBackground() + + val chooseCollatorAction = actionAwaitableMixinFactory.create, MythosSelectCollatorModel>() + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = assetFlow, + // Amount is pre-determined, so we don't show max button at all + maxActionProvider = null + ) + + val transferable = assetFlow.map { it.transferableAmountModel(amountFormatter) } + .shareInBackground() + + val feeLoaderMixin = feeLoaderMixinV2Factory.createDefault(viewModelScope, chainAssetFlow) + + val buttonState = combine( + validationInProgress, + amountChooserMixin.inputState + ) { validationInProgress, inputState -> + when { + validationInProgress -> DescriptiveButtonState.Loading + inputState.value.isEmpty() -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_amount)) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + } + + init { + feeLoaderMixin.connectWith( + inputSource1 = selectedCollatorIdFlow, + feeConstructor = { _, collatorId -> interactor.estimateFee(currentDelegatorStateFlow.first(), collatorId) }, + ) + + setInitialCollator() + + setupAmountChanges() + } + + private fun setupAmountChanges() { + // Block input immediately + amountChooserMixin.setInputBlocked() + + stakedAmount.onEach { planks -> + val chainAsset = stakingSharedState.chainAsset() + val amount = chainAsset.amountFromPlanks(planks) + + amountChooserMixin.setBlockedAmount(amount) + }.launchIn(this) + } + + fun selectCollatorClicked() = launch { + val alreadyStakedCollators = alreadyStakedCollatorsFlow.first() + + val selectedCollator = selectedCollatorFlow.first() + val asset = assetFlow.first() + + val payload = createSelectCollatorPayload(alreadyStakedCollators, asset, selectedCollator) + + val newCollator = chooseCollatorAction.awaitAction(payload) + selectedCollatorFlow.emit(newCollator.payload) + } + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + private suspend fun createSelectCollatorPayload( + alreadyStakedCollators: List, + asset: Asset, + selectedCollator: MythosCollator, + ): SelectCollatorPayload { + val collatorModels = alreadyStakedCollators.map { target -> + mythosCollatorFormatter.collatorToSelectUi(target, asset.token) + } + val selected = collatorModels.findById(selectedCollator) + + return SelectCollatorPayload(collatorModels, selected) + } + + private fun setInitialCollator() = launch { + val stakedCollators = alreadyStakedCollatorsFlow.first() + + if (stakedCollators.isNotEmpty()) { + selectedCollatorFlow.emit(stakedCollators.first().target) + } + } + + private fun maybeGoToNext() = launch { + validationInProgress.value = true + + val payload = UnbondMythosStakingValidationPayload( + fee = feeLoaderMixin.awaitFee(), + asset = assetFlow.first(), + delegatorState = currentDelegatorStateFlow.first(), + collator = selectedCollatorFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { failure, _ -> mythosValidationFailureFormatter.formatUnbond(failure) }, + progressConsumer = validationInProgress.progressConsumer() + ) { fixedPayload -> + validationInProgress.value = false + + goToNextStep(fixedPayload.fee, fixedPayload.collator) + } + } + + private fun goToNextStep(fee: Fee, collator: MythosCollator) = launch { + val nextScreenPayload = ConfirmUnbondMythosPayload( + collator = collator.toParcel(), + amount = stakedAmount.first(), + fee = fee.toParcel() + ) + + router.openUnbondConfirm(nextScreenPayload) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/di/SetupUnbondMythosComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/di/SetupUnbondMythosComponent.kt new file mode 100644 index 0000000..efa38c9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/di/SetupUnbondMythosComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.setup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.setup.SetupUnbondMythosFragment + +@Subcomponent( + modules = [ + SetupUnbondMythosModule::class + ] +) +@ScreenScope +interface SetupUnbondMythosComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): SetupUnbondMythosComponent + } + + fun inject(fragment: SetupUnbondMythosFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/di/SetupUnbondMythosModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/di/SetupUnbondMythosModule.kt new file mode 100644 index 0000000..5d6e65a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/mythos/unbond/setup/di/SetupUnbondMythosModule.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.setup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosDelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.UnbondMythosStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.unbond.validations.UnbondMythosValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.MythosCollatorFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.common.validations.MythosStakingValidationFailureFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.mythos.unbond.setup.SetupUnbondMythosViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class SetupUnbondMythosModule { + + @Provides + @IntoMap + @ViewModelKey(SetupUnbondMythosViewModel::class) + fun provideViewModel( + router: MythosStakingRouter, + interactor: UnbondMythosStakingInteractor, + assetUseCase: AssetUseCase, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: UnbondMythosValidationSystem, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + delegatorStateUseCase: MythosDelegatorStateUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + mythosSharedComputation: MythosSharedComputation, + mythosCollatorFormatter: MythosCollatorFormatter, + mythosValidationFailureFormatter: MythosStakingValidationFailureFormatter, + amountChooserMixinFactory: AmountChooserMixin.Factory, + stakingSharedState: StakingSharedState, + amountFormatter: AmountFormatter + ): ViewModel { + return SetupUnbondMythosViewModel( + router = router, + interactor = interactor, + assetUseCase = assetUseCase, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + delegatorStateUseCase = delegatorStateUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + mythosSharedComputation = mythosSharedComputation, + mythosCollatorFormatter = mythosCollatorFormatter, + mythosValidationFailureFormatter = mythosValidationFailureFormatter, + amountChooserMixinFactory = amountChooserMixinFactory, + stakingSharedState = stakingSharedState, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SetupUnbondMythosViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SetupUnbondMythosViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/common/di/NominationPoolsCommonBondMoreModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/common/di/NominationPoolsCommonBondMoreModule.kt new file mode 100644 index 0000000..3959301 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/common/di/NominationPoolsCommonBondMoreModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.common.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.NominationPoolsBondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.RealNominationPoolsBondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.nominationPoolsBondMore +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.DelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.hints.NominationPoolHintsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolAvailableBalanceValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.PoolStateValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.NominationPoolsAvailableBalanceResolver +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.hints.NominationPoolsBondMoreHintsFactory + +@Module +class NominationPoolsCommonBondMoreModule { + + @Provides + @ScreenScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + stakingSharedState: StakingSharedState, + migrationUseCase: DelegatedStakeMigrationUseCase, + poolsAvailableBalanceResolver: NominationPoolsAvailableBalanceResolver, + ): NominationPoolsBondMoreInteractor { + return RealNominationPoolsBondMoreInteractor( + extrinsicService, + stakingSharedState, + migrationUseCase, + poolsAvailableBalanceResolver, + ) + } + + @Provides + @ScreenScope + fun provideValidationSystem( + poolStateValidationFactory: PoolStateValidationFactory, + poolAvailableBalanceValidationFactory: PoolAvailableBalanceValidationFactory, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory + ): NominationPoolsBondMoreValidationSystem = ValidationSystem.nominationPoolsBondMore( + poolStateValidationFactory = poolStateValidationFactory, + poolAvailableBalanceValidationFactory = poolAvailableBalanceValidationFactory, + stakingTypesConflictValidationFactory = stakingTypesConflictValidationFactory + ) + + @Provides + @ScreenScope + fun provideHintsMixinFactory( + nominationPoolHintsUseCase: NominationPoolHintsUseCase, + resourceManager: ResourceManager + ) = NominationPoolsBondMoreHintsFactory(nominationPoolHintsUseCase, resourceManager) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/NominationPoolsConfirmBondMoreFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/NominationPoolsConfirmBondMoreFragment.kt new file mode 100644 index 0000000..bcc7216 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/NominationPoolsConfirmBondMoreFragment.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentNominationPoolsConfirmBondMoreBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent + +private const val PAYLOAD_KEY = "NominationPoolsConfirmBondMoreFragment.PAYLOAD_KEY" + +class NominationPoolsConfirmBondMoreFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: NominationPoolsConfirmBondMorePayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentNominationPoolsConfirmBondMoreBinding.inflate(layoutInflater) + + override fun initViews() { + binder.nominationPoolsConfirmBondMoreExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.nominationPoolsConfirmBondMoreToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.nominationPoolsConfirmBondMoreConfirm.prepareForProgress(viewLifecycleOwner) + binder.nominationPoolsConfirmBondMoreConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .nominationPoolsStakingConfirmBondMore() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: NominationPoolsConfirmBondMoreViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.nominationPoolsConfirmBondMoreHints) + + viewModel.showNextProgress.observe(binder.nominationPoolsConfirmBondMoreConfirm::setProgressState) + + viewModel.amountModelFlow.observe(binder.nominationPoolsConfirmBondMoreAmount::setAmount) + + viewModel.feeStatusFlow.observe(binder.nominationPoolsConfirmBondMoreExtrinsicInformation::setFeeStatus) + viewModel.walletUiFlow.observe(binder.nominationPoolsConfirmBondMoreExtrinsicInformation::setWallet) + viewModel.originAddressModelFlow.observe(binder.nominationPoolsConfirmBondMoreExtrinsicInformation::setAccount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/NominationPoolsConfirmBondMorePayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/NominationPoolsConfirmBondMorePayload.kt new file mode 100644 index 0000000..b6dc0db --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/NominationPoolsConfirmBondMorePayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class NominationPoolsConfirmBondMorePayload( + val amount: BigDecimal, + val fee: FeeParcelModel, +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/NominationPoolsConfirmBondMoreViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/NominationPoolsConfirmBondMoreViewModel.kt new file mode 100644 index 0000000..a3d092c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/NominationPoolsConfirmBondMoreViewModel.kt @@ -0,0 +1,147 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.setter +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.NominationPoolsBondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.nominationPoolsBondMoreValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.hints.NominationPoolsBondMoreHintsFactory +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapFeeToFeeModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class NominationPoolsConfirmBondMoreViewModel( + private val router: NominationPoolsRouter, + private val interactor: NominationPoolsBondMoreInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: NominationPoolsBondMoreValidationSystem, + private val externalActions: ExternalActions.Presentation, + private val stakingSharedState: StakingSharedState, + private val payload: NominationPoolsConfirmBondMorePayload, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + poolMemberUseCase: NominationPoolMemberUseCase, + hintsFactory: NominationPoolsBondMoreHintsFactory, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val submissionFee = mapFeeFromParcel(payload.fee) + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + val hintsMixin = hintsFactory.create(coroutineScope = this) + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val amountFlow = MutableStateFlow(payload.amount) + + val amountModelFlow = combine(amountFlow, assetFlow) { amount, asset -> + amountFormatter.formatAmountToAmountModel(amount, asset) + } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val feeStatusFlow = assetFlow.map { asset -> + val feeModel = mapFeeToFeeModel(submissionFee, asset.token, amountFormatter = amountFormatter) + + FeeStatus.Loaded(feeModel) + } + .shareInBackground() + + val originAddressModelFlow = selectedAccountUseCase.selectedAddressModelFlow { stakingSharedState.chain() } + .shareInBackground() + + private val poolMember = poolMemberUseCase.currentPoolMemberFlow() + .filterNotNull() + .shareInBackground() + + fun confirmClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = originAddressModelFlow.first().address + val chain = stakingSharedState.chain() + + externalActions.showAddressActions(address, chain) + } + + private fun maybeGoToNext() = launch { + val payload = NominationPoolsBondMoreValidationPayload( + fee = submissionFee, + amount = amountFlow.first(), + poolMember = poolMember.first(), + asset = assetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { status, flowActions -> + nominationPoolsBondMoreValidationFailure(status, resourceManager, flowActions, amountFlow.setter()) + }, + progressConsumer = _showNextProgress.progressConsumer(), + block = ::sendTransaction + ) + } + + private fun sendTransaction(validationPayload: NominationPoolsBondMoreValidationPayload) = launch { + val token = validationPayload.asset.token + val amountInPlanks = token.planksFromAmount(payload.amount) + + interactor.bondMore(amountInPlanks) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { finishFlow() } + } + .onFailure(::showError) + + _showNextProgress.value = false + } + + private fun finishFlow() { + router.returnToStakingMain() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/di/NominationPoolsConfirmBondMoreComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/di/NominationPoolsConfirmBondMoreComponent.kt new file mode 100644 index 0000000..de69d7d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/di/NominationPoolsConfirmBondMoreComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.NominationPoolsConfirmBondMoreFragment +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.NominationPoolsConfirmBondMorePayload + +@Subcomponent( + modules = [ + NominationPoolsConfirmBondMoreModule::class + ] +) +@ScreenScope +interface NominationPoolsConfirmBondMoreComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: NominationPoolsConfirmBondMorePayload, + ): NominationPoolsConfirmBondMoreComponent + } + + fun inject(fragment: NominationPoolsConfirmBondMoreFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/di/NominationPoolsConfirmBondMoreModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/di/NominationPoolsConfirmBondMoreModule.kt new file mode 100644 index 0000000..4632c58 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/confirm/di/NominationPoolsConfirmBondMoreModule.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.NominationPoolsBondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.common.di.NominationPoolsCommonBondMoreModule +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.NominationPoolsConfirmBondMorePayload +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.NominationPoolsConfirmBondMoreViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.hints.NominationPoolsBondMoreHintsFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, NominationPoolsCommonBondMoreModule::class]) +class NominationPoolsConfirmBondMoreModule { + + @Provides + @IntoMap + @ViewModelKey(NominationPoolsConfirmBondMoreViewModel::class) + fun provideViewModel( + router: NominationPoolsRouter, + interactor: NominationPoolsBondMoreInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: NominationPoolsBondMoreValidationSystem, + externalActions: ExternalActions.Presentation, + stakingSharedState: StakingSharedState, + payload: NominationPoolsConfirmBondMorePayload, + poolMemberUseCase: NominationPoolMemberUseCase, + hintsFactory: NominationPoolsBondMoreHintsFactory, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return NominationPoolsConfirmBondMoreViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + externalActions = externalActions, + stakingSharedState = stakingSharedState, + payload = payload, + poolMemberUseCase = poolMemberUseCase, + hintsFactory = hintsFactory, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + selectedAccountUseCase = selectedAccountUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): NominationPoolsConfirmBondMoreViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NominationPoolsConfirmBondMoreViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/hints/NominationPoolsBondMoreHints.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/hints/NominationPoolsBondMoreHints.kt new file mode 100644 index 0000000..4180b1c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/hints/NominationPoolsBondMoreHints.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.hints + +import io.novafoundation.nova.common.mixin.hints.ConstantHintsMixin +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.hints.NominationPoolHintsUseCase +import kotlinx.coroutines.CoroutineScope + +class NominationPoolsBondMoreHintsFactory( + private val nominationPoolHintsUseCase: NominationPoolHintsUseCase, + private val resourceManager: ResourceManager, +) { + + fun create(coroutineScope: CoroutineScope): HintsMixin { + return NominationPoolsBondMoreHints(nominationPoolHintsUseCase, resourceManager, coroutineScope) + } +} + +private class NominationPoolsBondMoreHints( + private val nominationPoolHintsUseCase: NominationPoolHintsUseCase, + private val resourceManager: ResourceManager, + coroutineScope: CoroutineScope +) : ConstantHintsMixin(coroutineScope) { + + override suspend fun getHints(): List { + return listOfNotNull( + increasedRewardsHint(), + nominationPoolHintsUseCase.rewardsWillBeClaimedHint() + ) + } + + private fun increasedRewardsHint(): String = resourceManager.getString(R.string.staking_hint_reward_bond_more_v2_2_0) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/NominationPoolsSetupBondMoreFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/NominationPoolsSetupBondMoreFragment.kt new file mode 100644 index 0000000..8981ec3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/NominationPoolsSetupBondMoreFragment.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.setup + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentNominationPoolsBondMoreBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class NominationPoolsSetupBondMoreFragment : BaseFragment() { + + override fun createBinding() = FragmentNominationPoolsBondMoreBinding.inflate(layoutInflater) + + override fun initViews() { + binder.nominationPoolsBondMoreToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.nominationPoolsBondMoreContinue.prepareForProgress(viewLifecycleOwner) + binder.nominationPoolsBondMoreContinue.setOnClickListener { viewModel.nextClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .nominationPoolsStakingSetupBondMore() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: NominationPoolsSetupBondMoreViewModel) { + observeValidations(viewModel) + setupAmountChooser(viewModel.amountChooserMixin, binder.nominationPoolsBondMoreAmount) + setupFeeLoading(viewModel.originFeeMixin, binder.nominationPoolsBondMoreFee) + observeHints(viewModel.hintsMixin, binder.nominationPoolsBondMoreHints) + + viewModel.buttonState.observe(binder.nominationPoolsBondMoreContinue::setState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/NominationPoolsSetupBondMoreViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/NominationPoolsSetupBondMoreViewModel.kt new file mode 100644 index 0000000..c893cca --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/NominationPoolsSetupBondMoreViewModel.kt @@ -0,0 +1,150 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.setup + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.NominationPoolsBondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.nominationPoolsBondMoreValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.confirm.NominationPoolsConfirmBondMorePayload +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.hints.NominationPoolsBondMoreHintsFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.ext.fullId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class NominationPoolsSetupBondMoreViewModel( + private val router: NominationPoolsRouter, + private val interactor: NominationPoolsBondMoreInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: NominationPoolsBondMoreValidationSystem, + private val poolMemberUseCase: NominationPoolMemberUseCase, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val amountFormatter: AmountFormatter, + assetUseCase: AssetUseCase, + hintsFactory: NominationPoolsBondMoreHintsFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, +) : BaseViewModel(), + Validatable by validationExecutor { + + private val showNextProgress = MutableStateFlow(false) + + private val assetWithOption = assetUseCase.currentAssetAndOptionFlow() + .shareInBackground() + + private val selectedAsset = assetWithOption.map { it.asset } + .shareInBackground() + + private val selectedChainAsset = selectedAsset.map { it.token.configuration } + .distinctUntilChangedBy { it.fullId } + .shareInBackground() + + val poolMember = poolMemberUseCase.currentPoolMemberFlow() + .filterNotNull() + .shareInBackground() + + val originFeeMixin = feeLoaderMixinFactory.createDefault(this, selectedChainAsset) + + private val stakeableAmount = selectedAsset.map(interactor::stakeableAmount) + + private val maxActionProvider = maxActionProviderFactory.createCustom(viewModelScope) { + selectedChainAsset.providingBalance(stakeableAmount) + .deductFee(originFeeMixin) + } + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = selectedAsset, + maxActionProvider = maxActionProvider + ) + + val hintsMixin = hintsFactory.create(coroutineScope = this) + + val buttonState = combine(showNextProgress, amountChooserMixin.amountInput) { inProgress, amountInput -> + when { + inProgress -> DescriptiveButtonState.Loading + amountInput.isEmpty() -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_amount)) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + }.shareInBackground() + + init { + listenFee() + } + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + private fun listenFee() { + originFeeMixin.connectWith( + inputSource1 = amountChooserMixin.backPressuredPlanks, + feeConstructor = { _, amount -> + interactor.estimateFee(amount) + } + ) + } + + private fun maybeGoToNext() = launch { + showNextProgress.value = true + + val fee = originFeeMixin.awaitFee() + + val payload = NominationPoolsBondMoreValidationPayload( + fee = fee, + amount = amountChooserMixin.amount.first(), + poolMember = poolMember.first(), + asset = selectedAsset.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { status, flowActions -> + nominationPoolsBondMoreValidationFailure(status, resourceManager, flowActions, amountChooserMixin::setAmount) + }, + progressConsumer = showNextProgress.progressConsumer() + ) { updatedPayload -> + showNextProgress.value = false + + openConfirm(updatedPayload) + } + } + + private fun openConfirm(validationPayload: NominationPoolsBondMoreValidationPayload) { + val confirmPayload = NominationPoolsConfirmBondMorePayload( + amount = validationPayload.amount, + fee = mapFeeToParcel(validationPayload.fee) + ) + + router.openConfirmBondMore(confirmPayload) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/di/NominationPoolsSetupBondMoreComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/di/NominationPoolsSetupBondMoreComponent.kt new file mode 100644 index 0000000..6c0af9b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/di/NominationPoolsSetupBondMoreComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.setup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.setup.NominationPoolsSetupBondMoreFragment + +@Subcomponent( + modules = [ + NominationPoolsSetupBondMoreModule::class + ] +) +@ScreenScope +interface NominationPoolsSetupBondMoreComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): NominationPoolsSetupBondMoreComponent + } + + fun inject(fragment: NominationPoolsSetupBondMoreFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/di/NominationPoolsSetupBondMoreModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/di/NominationPoolsSetupBondMoreModule.kt new file mode 100644 index 0000000..260ac1a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/bondMore/setup/di/NominationPoolsSetupBondMoreModule.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.setup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.NominationPoolsBondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.bondMore.validations.NominationPoolsBondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.common.di.NominationPoolsCommonBondMoreModule +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.hints.NominationPoolsBondMoreHintsFactory +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.bondMore.setup.NominationPoolsSetupBondMoreViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, NominationPoolsCommonBondMoreModule::class]) +class NominationPoolsSetupBondMoreModule { + + @Provides + @IntoMap + @ViewModelKey(NominationPoolsSetupBondMoreViewModel::class) + fun provideViewModel( + router: NominationPoolsRouter, + interactor: NominationPoolsBondMoreInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: NominationPoolsBondMoreValidationSystem, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + poolMemberUseCase: NominationPoolMemberUseCase, + assetUseCase: AssetUseCase, + hintsFactory: NominationPoolsBondMoreHintsFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + maxActionProviderFactory: MaxActionProviderFactory, + amountFormatter: AmountFormatter, + ): ViewModel { + return NominationPoolsSetupBondMoreViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + feeLoaderMixinFactory = feeLoaderMixinFactory, + poolMemberUseCase = poolMemberUseCase, + assetUseCase = assetUseCase, + hintsFactory = hintsFactory, + maxActionProviderFactory = maxActionProviderFactory, + amountChooserMixinFactory = amountChooserMixinFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): NominationPoolsSetupBondMoreViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NominationPoolsSetupBondMoreViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/NominationPoolsClaimRewardsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/NominationPoolsClaimRewardsFragment.kt new file mode 100644 index 0000000..a778603 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/NominationPoolsClaimRewardsFragment.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.claimRewards + +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentNominationPoolsClaimRewardsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class NominationPoolsClaimRewardsFragment : BaseFragment() { + + override fun createBinding() = FragmentNominationPoolsClaimRewardsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.nominationPoolsClaimRewardsExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.nominationPoolsClaimRewardsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.nominationPoolsClaimRewardsConfirm.prepareForProgress(viewLifecycleOwner) + binder.nominationPoolsClaimRewardsConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .nominationPoolsStakingClaimRewards() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: NominationPoolsClaimRewardsViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel.feeLoaderMixin, binder.nominationPoolsClaimRewardsExtrinsicInformation.fee) + + viewModel.showNextProgress.observe(binder.nominationPoolsClaimRewardsConfirm::setProgressState) + + viewModel.pendingRewardsAmountModel.observe(binder.nominationPoolsClaimRewardsAmount::setAmount) + + viewModel.walletUiFlow.observe(binder.nominationPoolsClaimRewardsExtrinsicInformation::setWallet) + viewModel.originAddressModelFlow.observe(binder.nominationPoolsClaimRewardsExtrinsicInformation::setAccount) + + binder.nominationPoolsClaimRewardRestakeSwitch.field.bindTo(viewModel.shouldRestakeInput, viewLifecycleOwner.lifecycleScope) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/NominationPoolsClaimRewardsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/NominationPoolsClaimRewardsViewModel.kt new file mode 100644 index 0000000..0b2bee2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/NominationPoolsClaimRewardsViewModel.kt @@ -0,0 +1,141 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.claimRewards + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.NominationPoolsClaimRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations.NominationPoolsClaimRewardsValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations.NominationPoolsClaimRewardsValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations.nominationPoolsClaimRewardsValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class NominationPoolsClaimRewardsViewModel( + private val router: NominationPoolsRouter, + private val interactor: NominationPoolsClaimRewardsInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: NominationPoolsClaimRewardsValidationSystem, + private val stakingSharedState: StakingSharedState, + private val externalActions: ExternalActions.Presentation, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + selectedAccountUseCase: SelectedAccountUseCase, + walletUiUseCase: WalletUiUseCase, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + assetUseCase: AssetUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + val shouldRestakeInput = MutableStateFlow(false) + + private val pendingRewards = interactor.pendingRewardsFlow() + .shareInBackground() + + val pendingRewardsAmountModel = combine(pendingRewards.map { it.amount }, assetFlow) { amount, asset -> + amountFormatter.formatAmountToAmountModel(amount, asset) + } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val feeLoaderMixin = feeLoaderMixinFactory.create(assetFlow) + + val originAddressModelFlow = selectedAccountUseCase.selectedAddressModelFlow { stakingSharedState.chain() } + .shareInBackground() + + init { + listenFee() + } + + fun confirmClicked() { + claimRewardsIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = originAddressModelFlow.first().address + val chain = stakingSharedState.chain() + + externalActions.showAddressActions(address, chain) + } + + private fun listenFee() { + feeLoaderMixin.connectWith( + inputSource = shouldRestakeInput, + scope = viewModelScope, + feeConstructor = { shouldRestake -> interactor.estimateFee(shouldRestake) } + ) + } + + private fun claimRewardsIfValid() = launch { + _showNextProgress.value = true + + val shouldRestake = shouldRestakeInput.first() + val pendingRewards = pendingRewards.first() + + val payload = NominationPoolsClaimRewardsValidationPayload( + fee = feeLoaderMixin.awaitFee(), + pendingRewardsPlanks = pendingRewards.amount, + asset = assetFlow.first(), + chain = stakingSharedState.chain(), + poolMember = pendingRewards.poolMember + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { nominationPoolsClaimRewardsValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer(), + block = { sendTransaction(shouldRestake) } + ) + } + + private fun sendTransaction(shouldRestake: Boolean) = launch { + interactor.claimRewards(shouldRestake) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + .onFailure(::showError) + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/PoolPendingRewards.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/PoolPendingRewards.kt new file mode 100644 index 0000000..07bdc9a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/PoolPendingRewards.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.claimRewards + +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class PoolPendingRewards( + val amount: Balance, + val poolMember: PoolMember, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/di/NominationPoolsClaimRewardsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/di/NominationPoolsClaimRewardsComponent.kt new file mode 100644 index 0000000..c62114e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/di/NominationPoolsClaimRewardsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.claimRewards.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.claimRewards.NominationPoolsClaimRewardsFragment + +@Subcomponent( + modules = [ + NominationPoolsClaimRewardsModule::class + ] +) +@ScreenScope +interface NominationPoolsClaimRewardsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): NominationPoolsClaimRewardsComponent + } + + fun inject(fragment: NominationPoolsClaimRewardsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/di/NominationPoolsClaimRewardsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/di/NominationPoolsClaimRewardsModule.kt new file mode 100644 index 0000000..efaf156 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/claimRewards/di/NominationPoolsClaimRewardsModule.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.claimRewards.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolMembersRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.NominationPoolsClaimRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.RealNominationPoolsClaimRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations.NominationPoolsClaimRewardsValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.claimRewards.validations.nominationPoolsClaimRewards +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.DelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.claimRewards.NominationPoolsClaimRewardsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class NominationPoolsClaimRewardsModule { + + @Provides + @ScreenScope + fun provideInteractor( + poolMemberUseCase: NominationPoolMemberUseCase, + poolMembersRepository: NominationPoolMembersRepository, + stakingSharedState: StakingSharedState, + extrinsicService: ExtrinsicService, + migrationUseCase: DelegatedStakeMigrationUseCase + ): NominationPoolsClaimRewardsInteractor = RealNominationPoolsClaimRewardsInteractor( + poolMemberUseCase = poolMemberUseCase, + poolMembersRepository = poolMembersRepository, + stakingSharedState = stakingSharedState, + extrinsicService = extrinsicService, + migrationUseCase = migrationUseCase + ) + + @Provides + @ScreenScope + fun provideValidationSystem( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory + ): NominationPoolsClaimRewardsValidationSystem { + return ValidationSystem.nominationPoolsClaimRewards(enoughTotalToStayAboveEDValidationFactory, stakingTypesConflictValidationFactory) + } + + @Provides + @IntoMap + @ViewModelKey(NominationPoolsClaimRewardsViewModel::class) + fun provideViewModel( + router: NominationPoolsRouter, + interactor: NominationPoolsClaimRewardsInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: NominationPoolsClaimRewardsValidationSystem, + stakingSharedState: StakingSharedState, + externalActions: ExternalActions.Presentation, + selectedAccountUseCase: SelectedAccountUseCase, + walletUiUseCase: WalletUiUseCase, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + assetUseCase: AssetUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return NominationPoolsClaimRewardsViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + stakingSharedState = stakingSharedState, + externalActions = externalActions, + selectedAccountUseCase = selectedAccountUseCase, + walletUiUseCase = walletUiUseCase, + feeLoaderMixinFactory = feeLoaderMixinFactory, + assetUseCase = assetUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): NominationPoolsClaimRewardsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NominationPoolsClaimRewardsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/common/PoolDisplayFormatter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/common/PoolDisplayFormatter.kt new file mode 100644 index 0000000..1869510 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/common/PoolDisplayFormatter.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.utils.images.asIcon +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayModel +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.PoolDisplay +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface PoolDisplayFormatter { + + suspend fun format(poolDisplay: PoolDisplay, chain: Chain): PoolDisplayModel + + fun formatTitle(poolDisplay: PoolDisplay, chain: Chain): String +} + +class RealPoolDisplayFormatter( + private val addressIconGenerator: AddressIconGenerator, +) : PoolDisplayFormatter { + + override suspend fun format(poolDisplay: PoolDisplay, chain: Chain): PoolDisplayModel { + val title = poolDisplay.metadata?.title + val poolAccount = addressIconGenerator.createAccountAddressModel(chain, poolDisplay.stashAccountId, title) + + return PoolDisplayModel( + icon = poolDisplay.icon ?: poolAccount.image.asIcon(), + title = poolAccount.nameOrAddress, + poolAccountId = poolDisplay.stashAccountId, + address = poolAccount.address + ) + } + + override fun formatTitle(poolDisplay: PoolDisplay, chain: Chain): String { + return poolDisplay.metadata?.title ?: chain.addressOf(poolDisplay.stashAccountId) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/common/display/RealPoolDisplayUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/common/display/RealPoolDisplayUseCase.kt new file mode 100644 index 0000000..ac03236 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/common/display/RealPoolDisplayUseCase.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.display + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.bondedAccountOf +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayModel +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayUseCase +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMetadata +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.PoolDisplay +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +internal class RealPoolDisplayUseCase( + private val poolDisplayFormatter: PoolDisplayFormatter, + private val poolAccountDerivation: PoolAccountDerivation, + private val poolStateRepository: NominationPoolStateRepository, +) : PoolDisplayUseCase { + + override suspend fun getPoolDisplay(poolId: Int, chain: Chain): PoolDisplayModel { + val poolIdTyped = PoolId(poolId) + + val poolDisplay = SimplePoolDisplay( + icon = poolStateRepository.getPoolIcon(poolIdTyped, chain.id), + metadata = poolStateRepository.getAnyPoolMetadata(poolIdTyped, chain.id), + stashAccountId = poolAccountDerivation.bondedAccountOf(poolIdTyped, chain.id) + ) + + return poolDisplayFormatter.format(poolDisplay, chain) + } + + private class SimplePoolDisplay( + override val icon: Icon?, + override val metadata: PoolMetadata?, + override val stashAccountId: AccountId + ) : PoolDisplay +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/NominationPoolsRedeemFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/NominationPoolsRedeemFragment.kt new file mode 100644 index 0000000..312fd30 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/NominationPoolsRedeemFragment.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.redeem + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentNominationPoolsRedeemBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class NominationPoolsRedeemFragment : BaseFragment() { + + override fun createBinding() = FragmentNominationPoolsRedeemBinding.inflate(layoutInflater) + + override fun initViews() { + binder.nominationPoolsRedeemExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.nominationPoolsRedeemToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.nominationPoolsRedeemConfirm.prepareForProgress(viewLifecycleOwner) + binder.nominationPoolsRedeemConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .nominationPoolsStakingRedeem() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: NominationPoolsRedeemViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel.feeLoaderMixin, binder.nominationPoolsRedeemExtrinsicInformation.fee) + + viewModel.showNextProgress.observe(binder.nominationPoolsRedeemConfirm::setProgressState) + + viewModel.redeemAmountModel.observe(binder.nominationPoolsRedeemAmount::setAmount) + + viewModel.walletUiFlow.observe(binder.nominationPoolsRedeemExtrinsicInformation::setWallet) + viewModel.originAddressModelFlow.observe(binder.nominationPoolsRedeemExtrinsicInformation::setAccount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/NominationPoolsRedeemViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/NominationPoolsRedeemViewModel.kt new file mode 100644 index 0000000..cd748c6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/NominationPoolsRedeemViewModel.kt @@ -0,0 +1,143 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.redeem + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.NominationPoolsRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations.NominationPoolsRedeemValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations.NominationPoolsRedeemValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations.nominationPoolsRedeemValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +class NominationPoolsRedeemViewModel( + private val router: StakingRouter, + private val interactor: NominationPoolsRedeemInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: NominationPoolsRedeemValidationSystem, + private val walletUiUseCase: WalletUiUseCase, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val stakingSharedState: StakingSharedState, + private val externalActions: ExternalActions.Presentation, + private val poolMemberUseCase: NominationPoolMemberUseCase, + private val feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + assetUseCase: AssetUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val poolMemberFlow = poolMemberUseCase.currentPoolMemberFlow() + .filterNotNull() + .shareInBackground() + + private val redeemAmount = poolMemberFlow.flatMapLatest { poolMember -> + interactor.redeemAmountFlow(poolMember, viewModelScope) + } + + val redeemAmountModel = combine(redeemAmount, assetFlow) { redeemAmount, asset -> + amountFormatter.formatAmountToAmountModel(redeemAmount, asset) + } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val feeLoaderMixin = feeLoaderMixinFactory.create(assetFlow) + + val originAddressModelFlow = selectedAccountUseCase.selectedAddressModelFlow { stakingSharedState.chain() } + .shareInBackground() + + init { + listenFee() + } + + fun confirmClicked() { + redeemIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = originAddressModelFlow.first().address + val chain = stakingSharedState.chain() + + externalActions.showAddressActions(address, chain) + } + + private fun listenFee() { + feeLoaderMixin.connectWith( + inputSource = poolMemberFlow, + scope = viewModelScope, + feeConstructor = { interactor.estimateFee(it) } + ) + } + + private fun redeemIfValid() = launch { + _showNextProgress.value = true + + val payload = NominationPoolsRedeemValidationPayload( + fee = feeLoaderMixin.awaitFee(), + asset = assetFlow.first(), + chain = stakingSharedState.chain(), + poolMember = poolMemberFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { nominationPoolsRedeemValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer(), + block = { sendTransaction() } + ) + } + + private fun sendTransaction() = launch { + interactor.redeem(poolMemberFlow.first()) + .onSuccess { (submissionResult, redeemConsequences) -> + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(submissionResult.submissionHierarchy) { router.finishRedeemFlow(redeemConsequences) } + } + .onFailure(::showError) + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/di/NominationPoolsConfirmUnbondComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/di/NominationPoolsConfirmUnbondComponent.kt new file mode 100644 index 0000000..d8da7fe --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/di/NominationPoolsConfirmUnbondComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.redeem.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.redeem.NominationPoolsRedeemFragment + +@Subcomponent( + modules = [ + NominationPoolsRedeemModule::class + ] +) +@ScreenScope +interface NominationPoolsRedeemComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): NominationPoolsRedeemComponent + } + + fun inject(fragment: NominationPoolsRedeemFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/di/NominationPoolsRedeemModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/di/NominationPoolsRedeemModule.kt new file mode 100644 index 0000000..9e95900 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/redeem/di/NominationPoolsRedeemModule.kt @@ -0,0 +1,115 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.redeem.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.DelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.NominationPoolsRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.RealNominationPoolsRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations.NominationPoolsRedeemValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.redeem.validations.nominationPoolsRedeem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.redeem.NominationPoolsRedeemViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class NominationPoolsRedeemModule { + + @Provides + @ScreenScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + stakingRepository: StakingRepository, + poolAccountDerivation: PoolAccountDerivation, + stakingSharedState: StakingSharedState, + nominationPoolSharedComputation: NominationPoolSharedComputation, + stakingSharedComputation: StakingSharedComputation, + migrationUseCase: DelegatedStakeMigrationUseCase + ): NominationPoolsRedeemInteractor = RealNominationPoolsRedeemInteractor( + extrinsicService = extrinsicService, + stakingRepository = stakingRepository, + poolAccountDerivation = poolAccountDerivation, + stakingSharedState = stakingSharedState, + nominationPoolSharedComputation = nominationPoolSharedComputation, + stakingSharedComputation = stakingSharedComputation, + migrationUseCase = migrationUseCase + ) + + @Provides + @ScreenScope + fun provideValidationSystem( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory + ): NominationPoolsRedeemValidationSystem { + return ValidationSystem.nominationPoolsRedeem(enoughTotalToStayAboveEDValidationFactory, stakingTypesConflictValidationFactory) + } + + @Provides + @IntoMap + @ViewModelKey(NominationPoolsRedeemViewModel::class) + fun provideViewModel( + router: StakingRouter, + interactor: NominationPoolsRedeemInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: NominationPoolsRedeemValidationSystem, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + stakingSharedState: StakingSharedState, + externalActions: ExternalActions.Presentation, + poolMemberUseCase: NominationPoolMemberUseCase, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + assetUseCase: AssetUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return NominationPoolsRedeemViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + walletUiUseCase = walletUiUseCase, + selectedAccountUseCase = selectedAccountUseCase, + stakingSharedState = stakingSharedState, + externalActions = externalActions, + poolMemberUseCase = poolMemberUseCase, + feeLoaderMixinFactory = feeLoaderMixinFactory, + assetUseCase = assetUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): NominationPoolsRedeemViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NominationPoolsRedeemViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/common/di/NominationPoolsCommonUnbondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/common/di/NominationPoolsCommonUnbondModule.kt new file mode 100644 index 0000000..1624a44 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/common/di/NominationPoolsCommonUnbondModule.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.common.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_staking_api.data.nominationPools.pool.PoolAccountDerivation +import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.NominationPoolGlobalsRepository +import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolMemberUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.delegatedStake.DelegatedStakeMigrationUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.hints.NominationPoolHintsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.validations.StakingTypesConflictValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.NominationPoolsUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.RealNominationPoolsUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.nominationPoolsUnbond +import io.novafoundation.nova.feature_staking_impl.presentation.common.hints.StakingHintsUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.hints.NominationPoolsUnbondHintsFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory + +@Module +class NominationPoolsCommonUnbondModule { + + @Provides + @ScreenScope + fun provideUnbondValidationFactory( + stakingConstantsRepository: StakingConstantsRepository, + stakingRepository: StakingRepository, + stakingSharedComputation: StakingSharedComputation, + nominationPoolSharedComputation: NominationPoolSharedComputation, + stakingSharedState: StakingSharedState, + nominationPoolGlobalsRepository: NominationPoolGlobalsRepository, + ) = NominationPoolsUnbondValidationFactory( + stakingConstantsRepository = stakingConstantsRepository, + stakingRepository = stakingRepository, + stakingSharedComputation = stakingSharedComputation, + nominationPoolSharedComputation = nominationPoolSharedComputation, + stakingSharedState = stakingSharedState, + nominationPoolGlobalsRepository = nominationPoolGlobalsRepository + ) + + @Provides + @ScreenScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + stakingSharedState: StakingSharedState, + nominationPoolSharedComputation: NominationPoolSharedComputation, + poolAccountDerivation: PoolAccountDerivation, + poolMemberUseCase: NominationPoolMemberUseCase, + migrationUseCase: DelegatedStakeMigrationUseCase + ): NominationPoolsUnbondInteractor { + return RealNominationPoolsUnbondInteractor( + extrinsicService = extrinsicService, + stakingSharedState = stakingSharedState, + nominationPoolSharedComputation = nominationPoolSharedComputation, + poolAccountDerivation = poolAccountDerivation, + poolMemberUseCase = poolMemberUseCase, + migrationUseCase = migrationUseCase + ) + } + + @Provides + @ScreenScope + fun provideValidationSystem( + validationFactory: NominationPoolsUnbondValidationFactory, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + stakingTypesConflictValidationFactory: StakingTypesConflictValidationFactory + ): NominationPoolsUnbondValidationSystem { + return ValidationSystem.nominationPoolsUnbond( + unbondValidationFactory = validationFactory, + enoughTotalToStayAboveEDValidationFactory = enoughTotalToStayAboveEDValidationFactory, + stakingTypesConflictValidationFactory = stakingTypesConflictValidationFactory + ) + } + + @Provides + @ScreenScope + fun provideHintsMixinFactory( + nominationPoolHintsUseCase: NominationPoolHintsUseCase, + stakingHintsUseCase: StakingHintsUseCase, + ) = NominationPoolsUnbondHintsFactory(nominationPoolHintsUseCase, stakingHintsUseCase) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/NominationPoolsConfirmUnbondFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/NominationPoolsConfirmUnbondFragment.kt new file mode 100644 index 0000000..6f2d5a8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/NominationPoolsConfirmUnbondFragment.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentNominationPoolsConfirmUnbondBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent + +private const val PAYLOAD_KEY = "NominationPoolsConfirmUnbondFragment.PAYLOAD_KEY" + +class NominationPoolsConfirmUnbondFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: NominationPoolsConfirmUnbondPayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentNominationPoolsConfirmUnbondBinding.inflate(layoutInflater) + + override fun initViews() { + binder.nominationPoolsConfirmUnbondExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.nominationPoolsConfirmUnbondToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.nominationPoolsConfirmUnbondConfirm.prepareForProgress(viewLifecycleOwner) + binder.nominationPoolsConfirmUnbondConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .nominationPoolsStakingConfirmUnbond() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: NominationPoolsConfirmUnbondViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.nominationPoolsConfirmUnbondHints) + + viewModel.showNextProgress.observe(binder.nominationPoolsConfirmUnbondConfirm::setProgressState) + + viewModel.amountModelFlow.observe(binder.nominationPoolsConfirmUnbondAmount::setAmount) + + viewModel.feeStatusFlow.observe(binder.nominationPoolsConfirmUnbondExtrinsicInformation::setFeeStatus) + viewModel.walletUiFlow.observe(binder.nominationPoolsConfirmUnbondExtrinsicInformation::setWallet) + viewModel.originAddressModelFlow.observe(binder.nominationPoolsConfirmUnbondExtrinsicInformation::setAccount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/NominationPoolsConfirmUnbondPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/NominationPoolsConfirmUnbondPayload.kt new file mode 100644 index 0000000..eeba754 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/NominationPoolsConfirmUnbondPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class NominationPoolsConfirmUnbondPayload( + val amount: BigDecimal, + val fee: FeeParcelModel, +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/NominationPoolsConfirmUnbondViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/NominationPoolsConfirmUnbondViewModel.kt new file mode 100644 index 0000000..cc55dc3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/NominationPoolsConfirmUnbondViewModel.kt @@ -0,0 +1,150 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.NominationPoolsUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.stakedBalance +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.nominationPoolsUnbondValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.hints.NominationPoolsUnbondHintsFactory +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapFeeToFeeModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class NominationPoolsConfirmUnbondViewModel( + private val router: NominationPoolsRouter, + private val interactor: NominationPoolsUnbondInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: NominationPoolsUnbondValidationSystem, + private val payload: NominationPoolsConfirmUnbondPayload, + private val walletUiUseCase: WalletUiUseCase, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val stakingSharedState: StakingSharedState, + private val externalActions: ExternalActions.Presentation, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + assetUseCase: AssetUseCase, + hintsFactory: NominationPoolsUnbondHintsFactory, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val decimalFee = mapFeeFromParcel(payload.fee) + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + val hintsMixin = hintsFactory.create(coroutineScope = this) + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + val amountModelFlow = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.amount, asset) + } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val feeStatusFlow = assetFlow.map { asset -> + val feeModel = mapFeeToFeeModel(decimalFee, asset.token, amountFormatter = amountFormatter) + + FeeStatus.Loaded(feeModel) + } + .shareInBackground() + + val originAddressModelFlow = selectedAccountUseCase.selectedAddressModelFlow { stakingSharedState.chain() } + .shareInBackground() + + private val poolMemberStateFlow = interactor.poolMemberStateFlow(viewModelScope) + .shareInBackground() + + private val poolMemberFlow = poolMemberStateFlow.map { it.poolMember } + + private val stakedBalance = poolMemberStateFlow.map { it.stakedBalance } + + fun confirmClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = originAddressModelFlow.first().address + val chain = stakingSharedState.chain() + + externalActions.showAddressActions(address, chain) + } + + private fun maybeGoToNext() = launch { + val asset = assetFlow.first() + val stakedBalance = asset.token.amountFromPlanks(stakedBalance.first()) + + val payload = NominationPoolsUnbondValidationPayload( + fee = decimalFee, + amount = payload.amount, + poolMember = poolMemberFlow.first(), + asset = asset, + stakedBalance = stakedBalance, + sharedComputationScope = viewModelScope, + chain = stakingSharedState.chain() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { status, flowActions -> nominationPoolsUnbondValidationFailure(status, flowActions, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer(), + block = ::sendTransaction + ) + } + + private fun sendTransaction(validationPayload: NominationPoolsUnbondValidationPayload) = launch { + val token = validationPayload.asset.token + val amountInPlanks = token.planksFromAmount(payload.amount) + + interactor.unbond(validationPayload.poolMember, amountInPlanks) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { finishFlow() } + } + .onFailure(::showError) + + _showNextProgress.value = false + } + + private fun finishFlow() { + router.returnToStakingMain() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/di/NominationPoolsConfirmUnbondComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/di/NominationPoolsConfirmUnbondComponent.kt new file mode 100644 index 0000000..8d1b11f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/di/NominationPoolsConfirmUnbondComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.NominationPoolsConfirmUnbondFragment +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.NominationPoolsConfirmUnbondPayload + +@Subcomponent( + modules = [ + NominationPoolsConfirmUnbondModule::class + ] +) +@ScreenScope +interface NominationPoolsConfirmUnbondComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: NominationPoolsConfirmUnbondPayload, + ): NominationPoolsConfirmUnbondComponent + } + + fun inject(fragment: NominationPoolsConfirmUnbondFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/di/NominationPoolsConfirmUnbondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/di/NominationPoolsConfirmUnbondModule.kt new file mode 100644 index 0000000..93789c3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/confirm/di/NominationPoolsConfirmUnbondModule.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.NominationPoolsUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.common.di.NominationPoolsCommonUnbondModule +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.NominationPoolsConfirmUnbondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.NominationPoolsConfirmUnbondViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.hints.NominationPoolsUnbondHintsFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, NominationPoolsCommonUnbondModule::class]) +class NominationPoolsConfirmUnbondModule { + + @Provides + @IntoMap + @ViewModelKey(NominationPoolsConfirmUnbondViewModel::class) + fun provideViewModel( + router: NominationPoolsRouter, + interactor: NominationPoolsUnbondInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: NominationPoolsUnbondValidationSystem, + payload: NominationPoolsConfirmUnbondPayload, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + stakingSharedState: StakingSharedState, + externalActions: ExternalActions.Presentation, + assetUseCase: AssetUseCase, + hintsFactory: NominationPoolsUnbondHintsFactory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return NominationPoolsConfirmUnbondViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + payload = payload, + walletUiUseCase = walletUiUseCase, + selectedAccountUseCase = selectedAccountUseCase, + stakingSharedState = stakingSharedState, + externalActions = externalActions, + assetUseCase = assetUseCase, + hintsFactory = hintsFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): NominationPoolsConfirmUnbondViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NominationPoolsConfirmUnbondViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/hints/NominationPoolsUnbondHints.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/hints/NominationPoolsUnbondHints.kt new file mode 100644 index 0000000..5ccb022 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/hints/NominationPoolsUnbondHints.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.hints + +import io.novafoundation.nova.common.mixin.hints.ConstantHintsMixin +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.hints.NominationPoolHintsUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.common.hints.StakingHintsUseCase +import kotlinx.coroutines.CoroutineScope + +class NominationPoolsUnbondHintsFactory( + private val nominationPoolHintsUseCase: NominationPoolHintsUseCase, + private val stakingHintsUseCase: StakingHintsUseCase, +) { + + fun create(coroutineScope: CoroutineScope): HintsMixin { + return NominationPoolsUnbondHints(nominationPoolHintsUseCase, stakingHintsUseCase, coroutineScope) + } +} + +private class NominationPoolsUnbondHints( + private val nominationPoolHintsUseCase: NominationPoolHintsUseCase, + private val stakingHintsUseCase: StakingHintsUseCase, + coroutineScope: CoroutineScope +) : ConstantHintsMixin(coroutineScope) { + + override suspend fun getHints(): List { + return listOfNotNull( + stakingHintsUseCase.unstakingDurationHint(coroutineScope = this), + stakingHintsUseCase.noRewardDurationUnstakingHint(), + stakingHintsUseCase.redeemHint(), + nominationPoolHintsUseCase.rewardsWillBeClaimedHint() + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/NominationPoolsSetupUnbondFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/NominationPoolsSetupUnbondFragment.kt new file mode 100644 index 0000000..63abf27 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/NominationPoolsSetupUnbondFragment.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.setup + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentNominationPoolsSetupUnbondBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class NominationPoolsSetupUnbondFragment : BaseFragment() { + + override fun createBinding() = FragmentNominationPoolsSetupUnbondBinding.inflate(layoutInflater) + + override fun initViews() { + binder.nominationPoolsUnbondToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.nominationPoolsUnbondContinue.prepareForProgress(viewLifecycleOwner) + binder.nominationPoolsUnbondContinue.setOnClickListener { viewModel.nextClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .nominationPoolsStakingSetupUnbond() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: NominationPoolsSetupUnbondViewModel) { + observeValidations(viewModel) + setupAmountChooser(viewModel.amountChooserMixin, binder.nominationPoolsUnbondAmount) + setupFeeLoading(viewModel.originFeeMixin, binder.nominationPoolsUnbondFee) + observeHints(viewModel.hintsMixin, binder.nominationPoolsUnbondHints) + + viewModel.transferableBalance.observe(binder.nominationPoolsUnbondTransferable::showAmount) + + viewModel.buttonState.observe(binder.nominationPoolsUnbondContinue::setState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/NominationPoolsSetupUnbondViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/NominationPoolsSetupUnbondViewModel.kt new file mode 100644 index 0000000..c5fe135 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/NominationPoolsSetupUnbondViewModel.kt @@ -0,0 +1,159 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.setup + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.NominationPoolsUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.stakedBalance +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.nominationPoolsUnbondValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.confirm.NominationPoolsConfirmUnbondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.hints.NominationPoolsUnbondHintsFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class NominationPoolsSetupUnbondViewModel( + private val router: NominationPoolsRouter, + private val interactor: NominationPoolsUnbondInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: NominationPoolsUnbondValidationSystem, + private val stakingSharedState: StakingSharedState, + private val maxActionProviderFactory: MaxActionProviderFactory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + assetUseCase: AssetUseCase, + hintsFactory: NominationPoolsUnbondHintsFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor { + + private val showNextProgress = MutableStateFlow(false) + + private val assetWithOption = assetUseCase.currentAssetAndOptionFlow() + .shareInBackground() + + private val selectedAsset = assetWithOption.map { it.asset } + .shareInBackground() + + private val selectedChainAsset = selectedAsset.map { it.token.configuration } + .shareInBackground() + + val originFeeMixin = feeLoaderMixinFactory.createDefault( + this, + selectedChainAsset + ) + + private val poolMemberStateFlow = interactor.poolMemberStateFlow(viewModelScope) + .shareInBackground() + + private val poolMemberFlow = poolMemberStateFlow.map { it.poolMember } + + private val stakedBalance = poolMemberStateFlow.map { it.stakedBalance } + + val transferableBalance = selectedAsset.map { + amountFormatter.formatAmountToAmountModel(it.transferable, it) + }.shareInBackground() + + private val maxActionProvider = maxActionProviderFactory.createCustom(viewModelScope) { + selectedChainAsset.providingBalance(stakedBalance) + } + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = selectedAsset, + maxActionProvider = maxActionProvider + ) + + val hintsMixin = hintsFactory.create(coroutineScope = this) + + val buttonState = combine(showNextProgress, amountChooserMixin.amountInput) { inProgress, amountInput -> + when { + inProgress -> DescriptiveButtonState.Loading + amountInput.isEmpty() -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_amount)) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + }.shareInBackground() + + init { + listenFee() + } + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + private fun listenFee() { + originFeeMixin.connectWith( + inputSource1 = poolMemberFlow, + inputSource2 = amountChooserMixin.backPressuredPlanks, + feeConstructor = { _, poolMember, amount -> + interactor.estimateFee(poolMember, amount) + } + ) + } + + private fun maybeGoToNext() = launch { + showNextProgress.value = true + + val asset = selectedAsset.first() + val stakedBalance = asset.token.amountFromPlanks(stakedBalance.first()) + + val payload = NominationPoolsUnbondValidationPayload( + fee = originFeeMixin.awaitFee(), + poolMember = poolMemberFlow.first(), + stakedBalance = stakedBalance, + asset = asset, + sharedComputationScope = viewModelScope, + amount = amountChooserMixin.amount.first(), + chain = stakingSharedState.chain() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { status, flowActions -> nominationPoolsUnbondValidationFailure(status, flowActions, resourceManager) }, + progressConsumer = showNextProgress.progressConsumer() + ) { updatedPayload -> + showNextProgress.value = false + + openConfirm(updatedPayload) + } + } + + private fun openConfirm(validationPayload: NominationPoolsUnbondValidationPayload) = launch { + val confirmPayload = NominationPoolsConfirmUnbondPayload( + amount = validationPayload.amount, + fee = mapFeeToParcel(validationPayload.fee) + ) + + router.openConfirmUnbond(confirmPayload) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/di/NominationPoolsSetupUnbondComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/di/NominationPoolsSetupUnbondComponent.kt new file mode 100644 index 0000000..23b8ff7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/di/NominationPoolsSetupUnbondComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.setup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.setup.NominationPoolsSetupUnbondFragment + +@Subcomponent( + modules = [ + NominationPoolsSetupUnbondModule::class + ] +) +@ScreenScope +interface NominationPoolsSetupUnbondComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): NominationPoolsSetupUnbondComponent + } + + fun inject(fragment: NominationPoolsSetupUnbondFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/di/NominationPoolsSetupUnbondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/di/NominationPoolsSetupUnbondModule.kt new file mode 100644 index 0000000..a49b096 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/nominationPools/unbond/setup/di/NominationPoolsSetupUnbondModule.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.setup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.NominationPoolsUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.unbond.validations.NominationPoolsUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.common.di.NominationPoolsCommonUnbondModule +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.hints.NominationPoolsUnbondHintsFactory +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.unbond.setup.NominationPoolsSetupUnbondViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, NominationPoolsCommonUnbondModule::class]) +class NominationPoolsSetupUnbondModule { + + @Provides + @IntoMap + @ViewModelKey(NominationPoolsSetupUnbondViewModel::class) + fun provideViewModel( + router: NominationPoolsRouter, + interactor: NominationPoolsUnbondInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: NominationPoolsUnbondValidationSystem, + stakingSharedState: StakingSharedState, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + assetUseCase: AssetUseCase, + hintsFactory: NominationPoolsUnbondHintsFactory, + maxActionProviderFactory: MaxActionProviderFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + amountFormatter: AmountFormatter + ): ViewModel { + return NominationPoolsSetupUnbondViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + feeLoaderMixinFactory = feeLoaderMixinFactory, + assetUseCase = assetUseCase, + hintsFactory = hintsFactory, + amountChooserMixinFactory = amountChooserMixinFactory, + maxActionProviderFactory = maxActionProviderFactory, + stakingSharedState = stakingSharedState, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): NominationPoolsSetupUnbondViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(NominationPoolsSetupUnbondViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/common/SelectCollatorInterScreenCommunicator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/common/SelectCollatorInterScreenCommunicator.kt new file mode 100644 index 0000000..f26d153 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/common/SelectCollatorInterScreenCommunicator.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator.Request +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator.Response +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.CollatorParcelModel +import kotlinx.parcelize.Parcelize + +interface SelectCollatorInterScreenRequester : InterScreenRequester +interface SelectCollatorInterScreenResponder : InterScreenResponder + +interface SelectCollatorInterScreenCommunicator : SelectCollatorInterScreenRequester, SelectCollatorInterScreenResponder { + + @Parcelize + class Response(val collator: CollatorParcelModel) : Parcelable + + @kotlinx.android.parcel.Parcelize + object Request : Parcelable +} + +fun SelectCollatorInterScreenRequester.openRequest() = openRequest(Request) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/CurrentCollatorsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/CurrentCollatorsFragment.kt new file mode 100644 index 0000000..a1daafe --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/CurrentCollatorsFragment.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.current + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.CurrentStakeTargetsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.actions.CollatorManageActionsBottomSheet + +class CurrentCollatorsFragment : CurrentStakeTargetsFragment() { + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .currentCollatorsFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: CurrentCollatorsViewModel) { + super.subscribe(viewModel) + + viewModel.selectManageCollatorsAction.awaitableActionLiveData.observeEvent { + CollatorManageActionsBottomSheet( + context = requireContext(), + itemSelected = it.onSuccess, + onCancel = it.onCancel + ).show() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/CurrentCollatorsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/CurrentCollatorsViewModel.kt new file mode 100644 index 0000000..f831851 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/CurrentCollatorsViewModel.kt @@ -0,0 +1,179 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.current + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.list.toValueList +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.awaitAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.current.CurrentCollatorInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.current.DelegatedCollator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.current.DelegatedCollatorGroup +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegationState.COLLATOR_NOT_ACTIVE +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegationState.TOO_LOW_STAKE +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.CurrentStakeTargetsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.actions.ManageCurrentStakeTargetsAction +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.Active +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.Elected +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.Inactive +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.SelectedStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.SelectedStakeTargetStatusModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.Waiting +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.formatStakeTargetRewardsOrNull +import io.novafoundation.nova.feature_staking_impl.presentation.openStartStaking +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.details.parachain +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers.mapCollatorToDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.StartParachainStakingMode +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class CurrentCollatorsViewModel( + private val router: ParachainStakingRouter, + private val resourceManager: ResourceManager, + private val iconGenerator: AddressIconGenerator, + private val currentCollatorsInteractor: CurrentCollatorInteractor, + private val selectedChainStale: AnySelectedAssetOptionSharedState, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val collatorsUseCase: CollatorsUseCase, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + tokenUseCase: TokenUseCase, + private val amountFormatter: AmountFormatter +) : CurrentStakeTargetsViewModel() { + + private val groupedCurrentCollatorsFlow = delegatorStateUseCase.currentDelegatorStateFlow() + .filterIsInstance() + .flatMapLatest(currentCollatorsInteractor::currentCollatorsFlow) + .shareInBackground() + + private val flattenCurrentCollators = groupedCurrentCollatorsFlow + .map { it.toValueList() } + .inBackground() + .share() + + override val currentStakeTargetsFlow = combine( + groupedCurrentCollatorsFlow, + tokenUseCase.currentTokenFlow() + ) { gropedList, token -> + val chain = selectedChainStale.chain() + + gropedList.mapKeys { (statusGroup, _) -> mapDelegatedCollatorStatusToUiModel(statusGroup) } + .mapValues { (_, nominatedValidators) -> nominatedValidators.map { mapDelegatedCollatorToUiModel(chain, it, token) } } + .toListWithHeaders() + } + .withLoading() + .shareInBackground() + + override val warningFlow = flattenCurrentCollators.map { collators -> + val hasNonRewardedDelegations = collators.any { it.delegationStatus == TOO_LOW_STAKE || it.delegationStatus == COLLATOR_NOT_ACTIVE } + + if (hasNonRewardedDelegations) { + resourceManager.getString(R.string.staking_parachain_your_collaotrs_no_rewards) + } else { + null + } + } + .inBackground() + .share() + + override val titleFlow: Flow = flowOf { + resourceManager.getString(R.string.staking_parachain_your_collators) + } + + val selectManageCollatorsAction = actionAwaitableMixinFactory.create() + + override fun stakeTargetInfoClicked(address: String) { + launch { + val payload = withContext(Dispatchers.Default) { + val allCollators = flattenCurrentCollators.first() + val selectedCollator = allCollators.first { it.collator.address == address } + + val stakeTarget = mapCollatorToDetailsParcelModel(selectedCollator.collator, selectedCollator.delegationStatus) + + StakeTargetDetailsPayload.parachain(stakeTarget, collatorsUseCase) + } + + router.openCollatorDetails(payload) + } + } + + override fun backClicked() { + router.back() + } + + override fun changeClicked() { + launch { + when (selectManageCollatorsAction.awaitAction()) { + ManageCurrentStakeTargetsAction.BOND_MORE -> router.openStartStaking(StartParachainStakingMode.BOND_MORE) + ManageCurrentStakeTargetsAction.UNBOND -> router.openUnbond() + } + } + } + + private suspend fun mapDelegatedCollatorToUiModel( + chain: Chain, + delegatedCollator: DelegatedCollator, + token: Token + ): SelectedStakeTargetModel { + val collator = delegatedCollator.collator + + return SelectedStakeTargetModel( + addressModel = iconGenerator.createAccountAddressModel( + chain = chain, + address = collator.address, + name = collator.identity?.display + ), + nominated = amountFormatter.formatAmountToAmountModel(delegatedCollator.delegation, token), + isOversubscribed = delegatedCollator.delegationStatus == TOO_LOW_STAKE, + isSlashed = false, + apy = formatStakeTargetRewardsOrNull(collator.apr) + ) + } + + private fun mapDelegatedCollatorStatusToUiModel(statusGroup: DelegatedCollatorGroup) = when (statusGroup) { + is DelegatedCollatorGroup.Active -> SelectedStakeTargetStatusModel.Active( + resourceManager = resourceManager, + groupSize = statusGroup.numberOfCollators, + description = R.string.staking_parachain_your_collators_active + ) + is DelegatedCollatorGroup.Elected -> SelectedStakeTargetStatusModel.Elected( + resourceManager = resourceManager, + description = R.string.staking_parachain_your_collators_elected + ) + + is DelegatedCollatorGroup.Inactive -> SelectedStakeTargetStatusModel.Inactive( + resourceManager = resourceManager, + groupSize = statusGroup.numberOfCollators, + description = R.string.staking_parachain_your_collators_inactive, + ) + + is DelegatedCollatorGroup.WaitingForNextEra -> SelectedStakeTargetStatusModel.Waiting( + resourceManager = resourceManager, + title = resourceManager.getString(R.string.staking_parachain_your_collators_waiting_title, statusGroup.numberOfCollators), + description = R.string.staking_parachain_your_collators_waiting + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/di/CurrentCollatorsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/di/CurrentCollatorsComponent.kt new file mode 100644 index 0000000..0650e54 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/di/CurrentCollatorsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.current.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.current.CurrentCollatorsFragment + +@Subcomponent( + modules = [ + CurrentCollatorsModule::class + ] +) +@ScreenScope +interface CurrentCollatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): CurrentCollatorsComponent + } + + fun inject(fragment: CurrentCollatorsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/di/CurrentCollatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/di/CurrentCollatorsModule.kt new file mode 100644 index 0000000..5cff11e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/current/di/CurrentCollatorsModule.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.current.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.current.CurrentCollatorInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.current.RealCurrentCollatorInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorProvider +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.current.CurrentCollatorsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class CurrentCollatorsModule { + + @Provides + @ScreenScope + fun provideInteractor( + currentRoundRepository: CurrentRoundRepository, + collatorProvider: CollatorProvider, + stakingSharedState: StakingSharedState, + ): CurrentCollatorInteractor = RealCurrentCollatorInteractor( + currentRoundRepository = currentRoundRepository, + collatorProvider = collatorProvider, + stakingSharedState = stakingSharedState + ) + + @Provides + @IntoMap + @ViewModelKey(CurrentCollatorsViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + resourceManager: ResourceManager, + iconGenerator: AddressIconGenerator, + currentCollatorsInteractor: CurrentCollatorInteractor, + selectedChainStale: StakingSharedState, + collatorsUseCase: CollatorsUseCase, + delegatorStateUseCase: DelegatorStateUseCase, + tokenUseCase: TokenUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + amountFormatter: AmountFormatter + ): ViewModel { + return CurrentCollatorsViewModel( + router = router, + resourceManager = resourceManager, + iconGenerator = iconGenerator, + currentCollatorsInteractor = currentCollatorsInteractor, + selectedChainStale = selectedChainStale, + delegatorStateUseCase = delegatorStateUseCase, + tokenUseCase = tokenUseCase, + collatorsUseCase = collatorsUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): CurrentCollatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(CurrentCollatorsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/details/Payload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/details/Payload.kt new file mode 100644 index 0000000..26a2ad2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/details/Payload.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.details + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.RewardSuffix +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetDetailsParcelModel + +suspend fun StakeTargetDetailsPayload.Companion.parachain( + stakeTarget: StakeTargetDetailsParcelModel, + collatorsUseCase: CollatorsUseCase, +) = StakeTargetDetailsPayload( + stakeTarget = stakeTarget, + displayConfig = StakeTargetDetailsPayload.DisplayConfig( + rewardSuffix = RewardSuffix.APR, + rewardedStakersPerStakeTarget = collatorsUseCase.maxRewardedDelegatorsPerCollator(), + titleRes = R.string.staking_parachain_collator_info, + stakersLabelRes = R.string.staking_parachain_delegators, + oversubscribedWarningText = R.string.staking_parachain_collator_details_oversubscribed + ) +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/SearchCollatorFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/SearchCollatorFragment.kt new file mode 100644 index 0000000..5c941ae --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/SearchCollatorFragment.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.search + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.presentation.common.search.SearchStakeTargetFragment + +class SearchCollatorFragment : SearchStakeTargetFragment() { + + override val configuration by lazy(LazyThreadSafetyMode.NONE) { + Configuration(doneAction = null, sortingLabelRes = R.string.staking_rewards) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .searchCollatorFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/SearchCollatorViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/SearchCollatorViewModel.kt new file mode 100644 index 0000000..493ca83 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/SearchCollatorViewModel.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.search + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.search.SearchCollatorsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendationConfig +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.search.SearchStakeTargetViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenResponder +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.details.parachain +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.mapCollatorToCollatorParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers.mapCollatorToCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers.mapCollatorToDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SearchCollatorViewModel( + private val router: ParachainStakingRouter, + private val interactor: SearchCollatorsInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val selectCollatorInterScreenResponder: SelectCollatorInterScreenResponder, + private val collatorRecommendatorFactory: CollatorRecommendatorFactory, + private val singleAssetSharedState: StakingSharedState, + private val collatorsUseCase: CollatorsUseCase, + private val amountFormatter: AmountFormatter, + resourceManager: ResourceManager, + tokenUseCase: TokenUseCase, +) : SearchStakeTargetViewModel(resourceManager) { + + private val currentTokenFlow = tokenUseCase.currentTokenFlow() + .share() + + private val electedCollators by lazyAsync { + val stakingOption = singleAssetSharedState.selectedOption() + + collatorRecommendatorFactory.create(stakingOption, computationalScope = this) + .recommendations(CollatorRecommendationConfig.DEFAULT) + } + + private val foundCollatorsFlow = enteredQuery + .mapLatest { + if (it.isNotEmpty()) { + interactor.searchValidator(it, electedCollators()) + } else { + null + } + } + .shareInBackground() + + override val dataFlow = combine( + foundCollatorsFlow, + currentTokenFlow + ) { foundCollators, token -> + val chain = singleAssetSharedState.chain() + + foundCollators?.map { collator -> + mapCollatorToCollatorModel( + chain = chain, + collator = collator, + token = token, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + sorting = CollatorRecommendationConfig.DEFAULT.sorting, + amountFormatter = amountFormatter + ) + } + } + + override fun itemClicked(item: StakeTargetModel) { + launch { + val response = withContext(Dispatchers.Default) { + val parcel = mapCollatorToCollatorParcelModel(item.stakeTarget) + + SelectCollatorInterScreenCommunicator.Response(parcel) + } + + selectCollatorInterScreenResponder.respond(response) + router.returnToStartStaking() + } + } + + override fun itemInfoClicked(item: StakeTargetModel) { + launch { + val payload = withContext(Dispatchers.Default) { + val parcel = mapCollatorToDetailsParcelModel(item.stakeTarget) + + StakeTargetDetailsPayload.parachain(parcel, collatorsUseCase) + } + + router.openCollatorDetails(payload) + } + } + + override fun backClicked() { + router.back() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/di/SearchCollatorComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/di/SearchCollatorComponent.kt new file mode 100644 index 0000000..fea92c8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/di/SearchCollatorComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.search.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.search.SearchCollatorFragment + +@Subcomponent( + modules = [ + SearchCollatorValidatorsModule::class + ] +) +@ScreenScope +interface SearchCollatorComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): SearchCollatorComponent + } + + fun inject(fragment: SearchCollatorFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/di/SearchCollatorValidatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/di/SearchCollatorValidatorsModule.kt new file mode 100644 index 0000000..744fdb5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/search/di/SearchCollatorValidatorsModule.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.search.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.collator.search.SearchCollatorsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.search.SearchCollatorViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class SearchCollatorValidatorsModule { + + @Provides + @ScreenScope + fun provideInteractor() = SearchCollatorsInteractor() + + @Provides + @IntoMap + @ViewModelKey(SearchCollatorViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + interactor: SearchCollatorsInteractor, + addressIconGenerator: AddressIconGenerator, + stakingSharedState: StakingSharedState, + selectCollatorInterScreenResponder: SelectCollatorInterScreenCommunicator, + collatorRecommendatorFactory: CollatorRecommendatorFactory, + collatorsUseCase: CollatorsUseCase, + resourceManager: ResourceManager, + tokenUseCase: TokenUseCase, + amountFormatter: AmountFormatter + ): ViewModel { + return SearchCollatorViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + singleAssetSharedState = stakingSharedState, + selectCollatorInterScreenResponder = selectCollatorInterScreenResponder, + collatorRecommendatorFactory = collatorRecommendatorFactory, + collatorsUseCase = collatorsUseCase, + resourceManager = resourceManager, + tokenUseCase = tokenUseCase, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SearchCollatorViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SearchCollatorViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/SelectCollatorFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/SelectCollatorFragment.kt new file mode 100644 index 0000000..694b138 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/SelectCollatorFragment.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.chooseTarget.SingleSelectChooseTargetFragment + +class SelectCollatorFragment : SingleSelectChooseTargetFragment() { + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .selectCollatorFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/SelectCollatorViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/SelectCollatorViewModel.kt new file mode 100644 index 0000000..cb1fdcd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/SelectCollatorViewModel.kt @@ -0,0 +1,132 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendationConfig +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorSorting +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.chooseTarget.SearchAction +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.chooseTarget.SingleSelectChooseTargetViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator.Response +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenResponder +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.details.parachain +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.mapCollatorToCollatorParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator.Request +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenRequester +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model.mapCollatorRecommendationConfigFromParcel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model.mapCollatorRecommendationConfigToParcel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers.mapCollatorToCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers.mapCollatorToDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class SelectCollatorViewModel( + private val router: ParachainStakingRouter, + private val selectCollatorInterScreenResponder: SelectCollatorInterScreenResponder, + private val selectCollatorSettingsInterScreenRequester: SelectCollatorSettingsInterScreenRequester, + private val collatorsUseCase: CollatorsUseCase, + private val amountFormatter: AmountFormatter, + collatorRecommendatorFactory: CollatorRecommendatorFactory, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + tokenUseCase: TokenUseCase, + selectedAssetState: StakingSharedState, +) : SingleSelectChooseTargetViewModel( + router = router, + recommendatorFactory = collatorRecommendatorFactory, + resourceManager = resourceManager, + tokenUseCase = tokenUseCase, + selectedAssetState = selectedAssetState, + state = CollatorState( + router = router, + selectCollatorSettingsInterScreenRequester = selectCollatorSettingsInterScreenRequester, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + selectedAssetState = selectedAssetState, + amountFormatter = amountFormatter + ) +) { + + class CollatorState( + private val router: ParachainStakingRouter, + private val selectCollatorSettingsInterScreenRequester: SelectCollatorSettingsInterScreenRequester, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val selectedAssetState: StakingSharedState, + private val amountFormatter: AmountFormatter, + ) : SingleSelectChooseTargetState { + + override val defaultRecommendatorConfig: CollatorRecommendationConfig = CollatorRecommendationConfig.DEFAULT + + override val searchAction: SearchAction = { + router.openSearchCollator() + } + + override fun recommendationConfigChanges(): Flow { + return selectCollatorSettingsInterScreenRequester.responseFlow + .map { mapCollatorRecommendationConfigFromParcel(it.newConfig) } + } + + override fun scoringHeaderFor(config: CollatorRecommendationConfig): String { + return when (config.sorting) { + CollatorSorting.REWARDS -> resourceManager.getString(R.string.staking_rewards) + CollatorSorting.MIN_STAKE -> resourceManager.getString(R.string.staking_main_minimum_stake_title) + CollatorSorting.TOTAL_STAKE -> resourceManager.getString(R.string.staking_validator_total_stake) + CollatorSorting.OWN_STAKE -> resourceManager.getString(R.string.staking_parachain_collator_own_stake) + } + } + + override suspend fun convertTargetsToUi( + targets: List, + token: Token, + config: CollatorRecommendationConfig, + ): List> { + return targets.map { collator -> + mapCollatorToCollatorModel( + chain = selectedAssetState.chain(), + collator = collator, + addressIconGenerator = addressIconGenerator, + sorting = config.sorting, + resourceManager = resourceManager, + token = token, + amountFormatter = amountFormatter + ) + } + } + } + + override fun settingsClicked(currentConfig: CollatorRecommendationConfig) { + val configPayload = mapCollatorRecommendationConfigToParcel(currentConfig) + selectCollatorSettingsInterScreenRequester.openRequest(Request((configPayload))) + } + + override suspend fun targetSelected(target: Collator) { + val response = Response(mapCollatorToCollatorParcelModel(target)) + + selectCollatorInterScreenResponder.respond(response) + router.returnToStartStaking() + } + + override suspend fun targetInfoClicked(target: Collator) { + val payload = withContext(Dispatchers.Default) { + val parcel = mapCollatorToDetailsParcelModel(target) + + StakeTargetDetailsPayload.parachain(parcel, collatorsUseCase) + } + + router.openCollatorDetails(payload) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/di/SelectCollatorComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/di/SelectCollatorComponent.kt new file mode 100644 index 0000000..f865b38 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/di/SelectCollatorComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.SelectCollatorFragment + +@Subcomponent( + modules = [ + SelectCollatorModule::class + ] +) +@ScreenScope +interface SelectCollatorComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): SelectCollatorComponent + } + + fun inject(fragment: SelectCollatorFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/di/SelectCollatorModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/di/SelectCollatorModule.kt new file mode 100644 index 0000000..2aee649 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/di/SelectCollatorModule.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.modules.Caching +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.SelectCollatorViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class SelectCollatorModule { + + @Provides + @IntoMap + @ViewModelKey(SelectCollatorViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + selectCollatorInterScreenCommunicator: SelectCollatorInterScreenCommunicator, + selectCollatorSettingsInterScreenCommunicator: SelectCollatorSettingsInterScreenCommunicator, + collatorRecommendatorFactory: CollatorRecommendatorFactory, + collatorsUseCase: CollatorsUseCase, + @Caching addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + tokenUseCase: TokenUseCase, + selectedAssetState: StakingSharedState, + amountFormatter: AmountFormatter + ): ViewModel { + return SelectCollatorViewModel( + router = router, + selectCollatorInterScreenResponder = selectCollatorInterScreenCommunicator, + collatorRecommendatorFactory = collatorRecommendatorFactory, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + tokenUseCase = tokenUseCase, + selectedAssetState = selectedAssetState, + collatorsUseCase = collatorsUseCase, + selectCollatorSettingsInterScreenRequester = selectCollatorSettingsInterScreenCommunicator, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectCollatorViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectCollatorViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/model/CollatorParcelModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/model/CollatorParcelModel.kt new file mode 100644 index 0000000..2c44059 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/select/model/CollatorParcelModel.kt @@ -0,0 +1,145 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorBond +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CandidateMetadata +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CapacityStatus +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CollatorSnapshot +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.network.bindings.CollatorStatus +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapIdentityParcelModelToIdentity +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapIdentityToIdentityParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.CollatorParcelModel.CandidateMetadataParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.CollatorParcelModel.CollatorSnapshotParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.CollatorParcelModel.DelegatorBondParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.IdentityParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal +import java.math.BigInteger + +@Parcelize +class CollatorParcelModel( + val accountIdHex: String, + val address: String, + val identity: IdentityParcelModel?, + val snapshot: CollatorSnapshotParcelModel?, + val candidateMetadata: CandidateMetadataParcelModel, + val minimumStakeToGetRewards: BigInteger, + val apr: BigDecimal?, +) : Parcelable { + + @Parcelize + class CollatorSnapshotParcelModel( + val bond: BigInteger, + val delegations: List, + val total: BigInteger, + ) : Parcelable + + @Parcelize + class DelegatorBondParcelModel( + val owner: ByteArray, + val balance: BigInteger, + ) : Parcelable + + @Parcelize + class CandidateMetadataParcelModel( + val totalCounted: BigInteger, + val delegationCount: BigInteger, + val lowestBottomDelegationAmount: BigInteger, + val highestBottomDelegationAmount: BigInteger, + val lowestTopDelegationAmount: BigInteger, + val topCapacity: CapacityStatus, + val bottomCapacity: CapacityStatus, + val status: CollatorStatus, + ) : Parcelable +} + +fun mapCollatorToCollatorParcelModel(collator: Collator): CollatorParcelModel { + return with(collator) { + CollatorParcelModel( + accountIdHex = accountIdHex, + identity = identity?.let(::mapIdentityToIdentityParcelModel), + snapshot = mapCollatorSnapshotToParcelModel(snapshot), + candidateMetadata = mapCandidateMetadataToParcelModel(candidateMetadata), + minimumStakeToGetRewards = minimumStakeToGetRewards, + apr = apr, + address = address + ) + } +} + +fun mapCollatorParcelModelToCollator(collator: CollatorParcelModel): Collator { + return with(collator) { + Collator( + accountIdHex = accountIdHex, + identity = identity?.let(::mapIdentityParcelModelToIdentity), + snapshot = mapCollatorSnapshotFromParcelModel(snapshot), + candidateMetadata = mapCandidateMetadataFromParcelModel(candidateMetadata), + minimumStakeToGetRewards = minimumStakeToGetRewards, + apr = apr, + address = address + ) + } +} + +private fun mapCandidateMetadataToParcelModel(candidateMetadata: CandidateMetadata): CandidateMetadataParcelModel { + return with(candidateMetadata) { + CandidateMetadataParcelModel( + totalCounted = totalCounted, + delegationCount = delegationCount, + lowestBottomDelegationAmount = lowestBottomDelegationAmount, + highestBottomDelegationAmount = highestBottomDelegationAmount, + lowestTopDelegationAmount = lowestTopDelegationAmount, + topCapacity = topCapacity, + bottomCapacity = bottomCapacity, + status = status + ) + } +} + +private fun mapCandidateMetadataFromParcelModel(candidateMetadata: CandidateMetadataParcelModel): CandidateMetadata { + return with(candidateMetadata) { + CandidateMetadata( + totalCounted = totalCounted, + delegationCount = delegationCount, + lowestBottomDelegationAmount = lowestBottomDelegationAmount, + highestBottomDelegationAmount = highestBottomDelegationAmount, + lowestTopDelegationAmount = lowestTopDelegationAmount, + topCapacity = topCapacity, + bottomCapacity = bottomCapacity, + status = status + ) + } +} + +private fun mapCollatorSnapshotToParcelModel(snapshot: CollatorSnapshot?): CollatorSnapshotParcelModel? { + return snapshot?.run { + CollatorSnapshotParcelModel( + bond = bond, + delegations = delegations.map(::mapDelegatorBondToParcelModel), + total = total + ) + } +} + +private fun mapDelegatorBondToParcelModel(delegatorBond: DelegatorBond): DelegatorBondParcelModel { + return with(delegatorBond) { + DelegatorBondParcelModel(owner, balance) + } +} + +private fun mapCollatorSnapshotFromParcelModel(snapshot: CollatorSnapshotParcelModel?): CollatorSnapshot? { + return snapshot?.run { + CollatorSnapshot( + bond = bond, + delegations = delegations.map(::mapDelegatorBondFromParcelModel), + total = total + ) + } +} + +private fun mapDelegatorBondFromParcelModel(delegatorBond: DelegatorBondParcelModel): DelegatorBond { + return with(delegatorBond) { + DelegatorBond(owner, balance) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/SelectCollatorSettingsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/SelectCollatorSettingsFragment.kt new file mode 100644 index 0000000..17a88c0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/SelectCollatorSettingsFragment.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings + +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentParachainStakingSelectCollatorSettingsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorSorting +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model.CollatorRecommendationConfigParcelModel + +private val SORT_MAPPING = mapOf( + CollatorSorting.REWARDS to R.id.selectCollatorSettingsSortRewards, + CollatorSorting.MIN_STAKE to R.id.selectCollatorSettingsSortMinimumStake, + CollatorSorting.TOTAL_STAKE to R.id.selectCollatorSettingsSortTotalStake, + CollatorSorting.OWN_STAKE to R.id.selectCollatorSettingsSortOwnStake, +) + +class SelectCollatorSettingsFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD_KEY = "SelectCollatorSettingsFragment.Payload" + + fun getBundle(payload: CollatorRecommendationConfigParcelModel) = bundleOf(PAYLOAD_KEY to payload) + } + + override fun createBinding() = FragmentParachainStakingSelectCollatorSettingsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.selectCollatorSettingsApply.setOnClickListener { viewModel.applyChanges() } + + binder.selectCollatorSettingsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.selectCollatorSettingsToolbar.setRightActionClickListener { viewModel.reset() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .selectCollatorSettingsFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: SelectCollatorSettingsViewModel) { + binder.selectCollatorSettingsSort.bindTo(viewModel.selectedSortingFlow, lifecycleScope, SORT_MAPPING) + + viewModel.isApplyButtonEnabled.observe { + binder.selectCollatorSettingsApply.setState(if (it) ButtonState.NORMAL else ButtonState.DISABLED) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/SelectCollatorSettingsInterScreenCommunicator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/SelectCollatorSettingsInterScreenCommunicator.kt new file mode 100644 index 0000000..e518548 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/SelectCollatorSettingsInterScreenCommunicator.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings + +import android.os.Parcelable +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator.Request +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator.Response +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model.CollatorRecommendationConfigParcelModel +import kotlinx.parcelize.Parcelize + +interface SelectCollatorSettingsInterScreenRequester : InterScreenRequester +interface SelectCollatorSettingsInterScreenResponder : InterScreenResponder + +interface SelectCollatorSettingsInterScreenCommunicator : SelectCollatorSettingsInterScreenRequester, SelectCollatorSettingsInterScreenResponder { + + @Parcelize + class Response(val newConfig: CollatorRecommendationConfigParcelModel) : Parcelable + + @Parcelize + class Request(val currentConfig: CollatorRecommendationConfigParcelModel) : Parcelable +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/SelectCollatorSettingsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/SelectCollatorSettingsViewModel.kt new file mode 100644 index 0000000..31dd4e5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/SelectCollatorSettingsViewModel.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendationConfig +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator.Response +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model.CollatorRecommendationConfigParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model.mapCollatorRecommendationConfigFromParcel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model.mapCollatorRecommendationConfigToParcel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SelectCollatorSettingsViewModel( + private val router: ParachainStakingRouter, + private val payload: CollatorRecommendationConfigParcelModel, + private val selectCollatorSettingsInterScreenResponder: SelectCollatorSettingsInterScreenResponder, +) : BaseViewModel() { + + val selectedSortingFlow = MutableStateFlow(CollatorRecommendationConfig.DEFAULT.sorting) + + private val initialConfig = mapCollatorRecommendationConfigFromParcel(payload) + + private val modifiedConfig = selectedSortingFlow.map(::CollatorRecommendationConfig) + .share() + + val isApplyButtonEnabled = modifiedConfig.map { modified -> + initialConfig != modified + }.share() + + init { + setFromSettings(initialConfig) + } + + fun reset() { + viewModelScope.launch { + val defaultSettings = CollatorRecommendationConfig.DEFAULT + + setFromSettings(defaultSettings) + } + } + + fun applyChanges() { + viewModelScope.launch { + val newConfig = mapCollatorRecommendationConfigToParcel(modifiedConfig.first()) + val response = Response(newConfig) + + selectCollatorSettingsInterScreenResponder.respond(response) + + router.back() + } + } + + fun backClicked() { + router.back() + } + + private fun setFromSettings(currentSettings: CollatorRecommendationConfig) { + selectedSortingFlow.value = currentSettings.sorting + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/di/SelectCollatorSettingsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/di/SelectCollatorSettingsComponent.kt new file mode 100644 index 0000000..a69da34 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/di/SelectCollatorSettingsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model.CollatorRecommendationConfigParcelModel + +@Subcomponent( + modules = [ + SelectCollatorSettingsModule::class + ] +) +@ScreenScope +interface SelectCollatorSettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: CollatorRecommendationConfigParcelModel, + ): SelectCollatorSettingsComponent + } + + fun inject(fragment: SelectCollatorSettingsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/di/SelectCollatorSettingsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/di/SelectCollatorSettingsModule.kt new file mode 100644 index 0000000..b8ca2c4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/di/SelectCollatorSettingsModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.SelectCollatorSettingsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model.CollatorRecommendationConfigParcelModel + +@Module(includes = [ViewModelModule::class]) +class SelectCollatorSettingsModule { + + @Provides + @IntoMap + @ViewModelKey(SelectCollatorSettingsViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + selectCollatorSettingsInterScreenCommunicator: SelectCollatorSettingsInterScreenCommunicator, + payload: CollatorRecommendationConfigParcelModel + ): ViewModel { + return SelectCollatorSettingsViewModel( + router = router, + selectCollatorSettingsInterScreenResponder = selectCollatorSettingsInterScreenCommunicator, + payload = payload + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectCollatorSettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectCollatorSettingsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/model/CollatorRecommendationConfigParcelModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/model/CollatorRecommendationConfigParcelModel.kt new file mode 100644 index 0000000..70378ec --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/collator/settings/model/CollatorRecommendationConfigParcelModel.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.settings.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendationConfig +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorSorting +import kotlinx.parcelize.Parcelize + +@Parcelize +class CollatorRecommendationConfigParcelModel(val sorting: CollatorSorting) : Parcelable + +fun mapCollatorRecommendationConfigToParcel(collatorRecommendationConfig: CollatorRecommendationConfig): CollatorRecommendationConfigParcelModel { + return with(collatorRecommendationConfig) { + CollatorRecommendationConfigParcelModel(sorting) + } +} + +fun mapCollatorRecommendationConfigFromParcel(collatorRecommendationConfig: CollatorRecommendationConfigParcelModel): CollatorRecommendationConfig { + return with(collatorRecommendationConfig) { + CollatorRecommendationConfig(sorting) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/ParachainStakingHintsUseCase.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/ParachainStakingHintsUseCase.kt new file mode 100644 index 0000000..a553d29 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/ParachainStakingHintsUseCase.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.RoundDurationEstimator +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +@Suppress("OPT_IN_USAGE_FUTURE_ERROR") +class ParachainStakingHintsUseCase( + private val singleAssetSharedState: AnySelectedAssetOptionSharedState, + private val resourceManager: ResourceManager, + private val roundDurationEstimator: RoundDurationEstimator, +) { + + fun unstakeDurationHintFlow(): Flow = flowWithChainId { chainId -> + roundDurationEstimator.unstakeDurationFlow(chainId).map { + val durationFormatted = resourceManager.formatDuration(it) + + resourceManager.getString(R.string.staking_hint_unstake_format_v2_2_0, durationFormatted) + } + } + + fun noRewardDuringUnstakingHint(): String { + return resourceManager.getString(R.string.staking_hint_no_rewards_v2_2_0) + } + + fun redeemHint(): String { + return resourceManager.getString(R.string.staking_hint_redeem_v2_2_0) + } + + private fun flowWithChainId(producer: suspend (ChainId) -> Flow): Flow = flow { + val chainId = singleAssetSharedState.chainId() + + emitAll(producer(chainId)) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/collators/AddressIcon.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/collators/AddressIcon.kt new file mode 100644 index 0000000..1e74ea4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/collators/AddressIcon.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.collators + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.fromHex + +suspend fun AddressIconGenerator.collatorAddressModel(collator: Collator, chain: Chain) = createAccountAddressModel( + chain = chain, + address = chain.addressOf(collator.accountIdHex.fromHex()), + name = collator.identity?.display +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/mappers/Collator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/mappers/Collator.kt new file mode 100644 index 0000000..ad3114f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/mappers/Collator.kt @@ -0,0 +1,110 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.presentation.ColoredText +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegationState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorSorting +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapIdentityToIdentityParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.rewardsToColoredText +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.rewardsToScoring +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.stakeToScoring +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.collators.collatorAddressModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetStakeParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetStakeParcelModel.Active.UserStakeInfo +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakerParcelModel +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +typealias CollatorModel = StakeTargetModel + +suspend fun mapCollatorToCollatorModel( + collator: Collator, + chain: Chain, + addressIconGenerator: AddressIconGenerator, + sorting: CollatorSorting, + resourceManager: ResourceManager, + token: Token, + amountFormatter: AmountFormatter +): CollatorModel { + val addressModel = addressIconGenerator.collatorAddressModel( + collator = collator, + chain = chain + ) + + val scoring = when (sorting) { + CollatorSorting.REWARDS -> rewardsToScoring(collator.apr) + CollatorSorting.MIN_STAKE -> stakeToScoring(collator.minimumStakeToGetRewards, token) + CollatorSorting.TOTAL_STAKE -> stakeToScoring(collator.snapshot?.total, token) + CollatorSorting.OWN_STAKE -> stakeToScoring(collator.snapshot?.bond, token) + } + + val subtitle = when (sorting) { + CollatorSorting.REWARDS -> collator.minimumStakeToGetRewards?.let { + val formattedMinStake = amountFormatter.formatAmountToAmountModel(it, token).token + + StakeTargetModel.Subtitle( + label = resourceManager.getString(R.string.staking_min_stake).withSubtitleLabelSuffix(), + value = ColoredText(formattedMinStake, R.color.text_primary), + ) + } + + else -> StakeTargetModel.Subtitle( + label = resourceManager.getString(R.string.staking_rewards).withSubtitleLabelSuffix(), + value = rewardsToColoredText(collator.apr)!! + ) + } + + return CollatorModel( + accountIdHex = collator.accountIdHex, + slashed = false, + addressModel = addressModel, + stakeTarget = collator, + isChecked = null, + scoring = scoring, + subtitle = subtitle + ) +} + +fun mapCollatorToDetailsParcelModel( + collator: Collator, + delegationState: DelegationState? = null +): StakeTargetDetailsParcelModel { + val snapshot = collator.snapshot + + val stakeParcelModel = if (snapshot != null && collator.apr != null) { + val isOversubscribed = delegationState == DelegationState.TOO_LOW_STAKE + + StakeTargetStakeParcelModel.Active( + totalStake = snapshot.total, + ownStake = snapshot.bond, + stakers = snapshot.delegations.map { + StakerParcelModel( + who = it.owner, + value = it.balance + ) + }, + rewards = collator.apr, + isOversubscribed = isOversubscribed, + minimumStake = collator.minimumStakeToGetRewards, + userStakeInfo = UserStakeInfo(willBeRewarded = !isOversubscribed), + ) + } else { + StakeTargetStakeParcelModel.Inactive + } + + return StakeTargetDetailsParcelModel( + accountIdHex = collator.accountIdHex, + isSlashed = false, + stake = stakeParcelModel, + identity = collator.identity?.let(::mapIdentityToIdentityParcelModel) + ) +} + +fun String.withSubtitleLabelSuffix() = "$this:" diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/selectCollators/Mappers.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/selectCollators/Mappers.kt new file mode 100644 index 0000000..70b1e03 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/common/selectCollators/Mappers.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.selectCollators + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.buildSpannable +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.takeUnlessZero +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.SelectedCollator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.UnbondingCollator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.collators.collatorAddressModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.model.SelectCollatorModel +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.fromHex + +suspend fun mapUnbondingCollatorToSelectCollatorModel( + unbondingCollator: UnbondingCollator, + chain: Chain, + asset: Asset, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter +): SelectCollatorModel = mapSelectedCollatorToSelectCollatorModel( + selectedCollator = unbondingCollator, + active = unbondingCollator.hasPendingUnbonding.not(), + chain = chain, + asset = asset, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + amountFormatter = amountFormatter +) + +suspend fun mapSelectedCollatorToSelectCollatorModel( + selectedCollator: SelectedCollator, + active: Boolean = true, + chain: Chain, + asset: Asset, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter +): SelectCollatorModel = mapCollatorToSelectCollatorModel( + collator = selectedCollator.target, + stakedAmount = selectedCollator.stake.takeUnlessZero(), + chain = chain, + active = active, + asset = asset, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + amountFormatter = amountFormatter +) + +suspend fun mapCollatorToSelectCollatorModel( + collator: Collator, + delegatorState: DelegatorState, + asset: Asset, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter, + active: Boolean = true +): SelectCollatorModel { + val chain = delegatorState.chain + + val collatorId = collator.accountIdHex.fromHex() + val stakedAmount = delegatorState.castOrNull()?.delegationAmountTo(collatorId) + + return mapCollatorToSelectCollatorModel( + collator = collator, + stakedAmount = stakedAmount, + chain = chain, + active = active, + asset = asset, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) +} + +suspend fun mapCollatorToSelectCollatorModel( + collator: Collator, + stakedAmount: Balance? = null, + active: Boolean = true, + chain: Chain, + asset: Asset, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter +): SelectCollatorModel { + val addressModel = addressIconGenerator.collatorAddressModel(collator, chain) + val stakedAmountModel = stakedAmount?.let { amountFormatter.formatAmountToAmountModel(stakedAmount, asset) } + + val subtitle = stakedAmountModel?.let { + resourceManager.labeledAmountSubtitle(R.string.staking_main_stake_balance_staked, it, selectionActive = active) + } + + return SelectCollatorModel( + addressModel = addressModel, + payload = collator, + active = active, + subtitle = subtitle + ) +} + +fun ResourceManager.labeledAmountSubtitle( + @StringRes labelRes: Int, + amount: AmountModel, + selectionActive: Boolean +): CharSequence { + val labelText = "${getString(labelRes)}: " + + return if (selectionActive) { + buildSpannable(this) { + appendColored(labelText, R.color.text_secondary) + appendColored(amount.token, R.color.text_primary) + } + } else { + buildSpannable(this) { + appendColored(labelText + amount.token, R.color.text_secondary) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/ParachainStakingRebondFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/ParachainStakingRebondFragment.kt new file mode 100644 index 0000000..b9992e8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/ParachainStakingRebondFragment.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond + +import androidx.core.os.bundleOf + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.presentation.showLoadingState +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentParachainStakingRebondBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.model.ParachainStakingRebondPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class ParachainStakingRebondFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "ParachainStakingRebondFragment.Payload" + + fun getBundle(payload: ParachainStakingRebondPayload) = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentParachainStakingRebondBinding.inflate(layoutInflater) + + override fun initViews() { + binder.parachainStakingRebondToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.parachainStakingRebondExtrinsicInfo.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.parachainStakingRebondCollator.setOnClickListener { viewModel.collatorClicked() } + + binder.parachainStakingRebondConfirm.prepareForProgress(viewLifecycleOwner) + binder.parachainStakingRebondConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .parachainStakingRebondFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ParachainStakingRebondViewModel) { + observeRetries(viewModel) + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel, binder.parachainStakingRebondExtrinsicInfo.fee) + observeHints(viewModel.hintsMixin, binder.parachainStakingRebondHints) + + viewModel.showNextProgress.observe(binder.parachainStakingRebondConfirm::setProgressState) + + viewModel.currentAccountModelFlow.observe(binder.parachainStakingRebondExtrinsicInfo::setAccount) + viewModel.walletFlow.observe(binder.parachainStakingRebondExtrinsicInfo::setWallet) + + viewModel.collatorAddressModel.observe(binder.parachainStakingRebondCollator::showAddress) + + viewModel.rebondAmount.observe(binder.parachainStakingRebondAmount::showLoadingState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/ParachainStakingRebondViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/ParachainStakingRebondViewModel.kt new file mode 100644 index 0000000..f046353 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/ParachainStakingRebondViewModel.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.ParachainStakingRebondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.validations.ParachainStakingRebondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.validations.ParachainStakingRebondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.details.parachain +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers.mapCollatorToDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.model.ParachainStakingRebondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ParachainStakingRebondViewModel( + private val router: ParachainStakingRouter, + private val resourceManager: ResourceManager, + private val validationSystem: ParachainStakingRebondValidationSystem, + private val interactor: ParachainStakingRebondInteractor, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val payload: ParachainStakingRebondPayload, + private val collatorsUseCase: CollatorsUseCase, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Retriable, + Validatable by validationExecutor, + FeeLoaderMixin by feeLoaderMixin, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val delegatorState = delegatorStateUseCase.currentDelegatorStateFlow() + .shareInBackground() + + val rebondAmount = delegatorState.flatMapLatest { state -> + val amount = interactor.rebondAmount(state, payload.collatorId) + + assetFlow.map { amountFormatter.formatAmountToAmountModel(amount, it) } + } + .withLoading() + .shareInBackground() + + val hintsMixin = resourcesHintsMixinFactory.create( + coroutineScope = this, + hintsRes = listOf(R.string.staking_parachain_rebond_hint) + ) + + private val collator = flowOf { + collatorsUseCase.getCollator(payload.collatorId) + }.shareInBackground() + + val collatorAddressModel = collator.map(collatorsUseCase::collatorAddressModel) + .shareInBackground() + + val currentAccountModelFlow = selectedAccountUseCase.selectedAddressModelFlow(selectedAssetState::chain) + .shareInBackground() + + val walletFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: StateFlow = _showNextProgress + + init { + feeLoaderMixin.loadFee( + coroutineScope = this, + feeConstructor = { interactor.estimateFee(payload.collatorId) }, + onRetryCancelled = ::backClicked + ) + } + + fun confirmClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = currentAccountModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + fun collatorClicked() = launch { + val parcel = withContext(Dispatchers.Default) { + mapCollatorToDetailsParcelModel(collator.first()) + } + + router.openCollatorDetails(StakeTargetDetailsPayload.parachain(parcel, collatorsUseCase)) + } + + private fun sendTransactionIfValid() = launch { + _showNextProgress.value = true + + val payload = ParachainStakingRebondValidationPayload( + fee = feeLoaderMixin.awaitFee(), + asset = assetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { parachainStakingRebondValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction() + } + } + + private fun sendTransaction() = launch { + interactor.rebond(payload.collatorId) + .onFailure(::showError) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/ValidationUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/ValidationUi.kt new file mode 100644 index 0000000..5810e61 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/ValidationUi.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.validations.ParachainStakingRebondValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.validations.ParachainStakingRebondValidationFailure.NotEnoughBalanceToPayFees +import io.novafoundation.nova.feature_wallet_api.domain.validation.notSufficientBalanceToPayFeeErrorMessage + +fun parachainStakingRebondValidationFailure(reason: ParachainStakingRebondValidationFailure, resourceManager: ResourceManager): TitleAndMessage { + return when (reason) { + NotEnoughBalanceToPayFees -> resourceManager.notSufficientBalanceToPayFeeErrorMessage() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/di/ParachainStakingRebondComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/di/ParachainStakingRebondComponent.kt new file mode 100644 index 0000000..071137e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/di/ParachainStakingRebondComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.ParachainStakingRebondFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.model.ParachainStakingRebondPayload + +@Subcomponent( + modules = [ + ParachainStakingRebondModule::class + ] +) +@ScreenScope +interface ParachainStakingRebondComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ParachainStakingRebondPayload, + ): ParachainStakingRebondComponent + } + + fun inject(fragment: ParachainStakingRebondFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/di/ParachainStakingRebondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/di/ParachainStakingRebondModule.kt new file mode 100644 index 0000000..09f25e7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/di/ParachainStakingRebondModule.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.ParachainStakingRebondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.RealParachainStakingRebondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.validations.ParachainStakingRebondValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rebond.validations.parachainStakingRebond +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.ParachainStakingRebondViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.model.ParachainStakingRebondPayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ParachainStakingRebondModule { + + @Provides + @ScreenScope + fun provideValidationSystem(): ParachainStakingRebondValidationSystem { + return ValidationSystem.parachainStakingRebond() + } + + @Provides + @ScreenScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + delegatorStateRepository: DelegatorStateRepository, + selectedAssetState: StakingSharedState, + ): ParachainStakingRebondInteractor = RealParachainStakingRebondInteractor( + extrinsicService = extrinsicService, + delegatorStateRepository = delegatorStateRepository, + selectedAssetState = selectedAssetState + ) + + @Provides + @IntoMap + @ViewModelKey(ParachainStakingRebondViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + resourceManager: ResourceManager, + validationSystem: ParachainStakingRebondValidationSystem, + interactor: ParachainStakingRebondInteractor, + feeLoaderMixin: FeeLoaderMixin.Presentation, + externalActions: ExternalActions.Presentation, + selectedAssetState: StakingSharedState, + validationExecutor: ValidationExecutor, + delegatorStateUseCase: DelegatorStateUseCase, + payload: ParachainStakingRebondPayload, + collatorsUseCase: CollatorsUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ParachainStakingRebondViewModel( + router = router, + resourceManager = resourceManager, + interactor = interactor, + feeLoaderMixin = feeLoaderMixin, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + delegatorStateUseCase = delegatorStateUseCase, + payload = payload, + collatorsUseCase = collatorsUseCase, + selectedAccountUseCase = selectedAccountUseCase, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + validationSystem = validationSystem, + resourcesHintsMixinFactory = resourcesHintsMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ParachainStakingRebondViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ParachainStakingRebondViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/model/ParachainStakingRebondPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/model/ParachainStakingRebondPayload.kt new file mode 100644 index 0000000..17b853d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/rebond/model/ParachainStakingRebondPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.model + +import android.os.Parcelable +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize + +@Parcelize +class ParachainStakingRebondPayload( + val collatorId: AccountId +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/ParachainStakingRedeemFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/ParachainStakingRedeemFragment.kt new file mode 100644 index 0000000..3c323a4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/ParachainStakingRedeemFragment.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.redeem + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.presentation.showLoadingState +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentParachainStakingRedeemBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class ParachainStakingRedeemFragment : BaseFragment() { + + override fun createBinding() = FragmentParachainStakingRedeemBinding.inflate(layoutInflater) + + override fun initViews() { + binder.parachainStakingRedeemToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.parachainStakingRedeemExtrinsicInfo.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.parachainStakingRedeemConfirm.prepareForProgress(viewLifecycleOwner) + binder.parachainStakingRedeemConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .parachainStakingRedeemFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ParachainStakingRedeemViewModel) { + observeRetries(viewModel) + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel, binder.parachainStakingRedeemExtrinsicInfo.fee) + + viewModel.showNextProgress.observe(binder.parachainStakingRedeemConfirm::setProgressState) + + viewModel.currentAccountModelFlow.observe(binder.parachainStakingRedeemExtrinsicInfo::setAccount) + viewModel.walletFlow.observe(binder.parachainStakingRedeemExtrinsicInfo::setWallet) + + viewModel.redeemableAmount.observe(binder.parachainStakingRedeemAmount::showLoadingState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/ParachainStakingRedeemViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/ParachainStakingRedeemViewModel.kt new file mode 100644 index 0000000..672f9e5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/ParachainStakingRedeemViewModel.kt @@ -0,0 +1,144 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.redeem + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.ParachainStakingRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.validations.ParachainStakingRedeemValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.validations.ParachainStakingRedeemValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ParachainStakingRedeemViewModel( + private val router: StakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val validationSystem: ParachainStakingRedeemValidationSystem, + private val interactor: ParachainStakingRedeemInteractor, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Retriable, + Validatable by validationExecutor, + FeeLoaderMixin by feeLoaderMixin, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val delegatorState = delegatorStateUseCase.currentDelegatorStateFlow() + .shareInBackground() + + val redeemableAmount = delegatorState.flatMapLatest { delegatorState -> + val amount = interactor.redeemableAmount(delegatorState) + + assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(amount, asset) + } + } + .withLoading() + .shareInBackground() + + val currentAccountModelFlow = selectedAccountUseCase.selectedMetaAccountFlow().map { + addressIconGenerator.createAccountAddressModel( + chain = selectedAssetState.chain(), + account = it, + name = null + ) + }.shareInBackground() + + val walletFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: StateFlow = _showNextProgress + + init { + feeLoaderMixin.connectWith( + inputSource = delegatorState, + scope = this, + feeConstructor = { delegatorState -> interactor.estimateFee(delegatorState) }, + onRetryCancelled = ::backClicked + ) + } + + fun confirmClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = currentAccountModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + private fun sendTransactionIfValid() = launch { + _showNextProgress.value = true + + val payload = ParachainStakingRedeemValidationPayload( + fee = feeLoaderMixin.awaitFee(), + asset = assetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { parachainStakingRedeemValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction() + } + } + + private fun sendTransaction() = launch { + interactor.redeem(delegatorState.first()) + .onFailure(::showError) + .onSuccess { (submissionResult, redeemConsequences) -> + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(submissionResult.submissionHierarchy) { router.finishRedeemFlow(redeemConsequences) } + } + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/ValidationUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/ValidationUi.kt new file mode 100644 index 0000000..24b6fea --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/ValidationUi.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.redeem + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.validations.ParachainStakingRedeemValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.validations.ParachainStakingRedeemValidationFailure.NotEnoughBalanceToPayFees +import io.novafoundation.nova.feature_wallet_api.domain.validation.notSufficientBalanceToPayFeeErrorMessage + +fun parachainStakingRedeemValidationFailure(reason: ParachainStakingRedeemValidationFailure, resourceManager: ResourceManager): TitleAndMessage { + return when (reason) { + NotEnoughBalanceToPayFees -> resourceManager.notSufficientBalanceToPayFeeErrorMessage() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/di/ParachainStakingRedeemComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/di/ParachainStakingRedeemComponent.kt new file mode 100644 index 0000000..4c308f5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/di/ParachainStakingRedeemComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.redeem.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.redeem.ParachainStakingRedeemFragment + +@Subcomponent( + modules = [ + ParachainStakingRedeemModule::class + ] +) +@ScreenScope +interface ParachainStakingRedeemComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): ParachainStakingRedeemComponent + } + + fun inject(fragment: ParachainStakingRedeemFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/di/ParachainStakingRedeemModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/di/ParachainStakingRedeemModule.kt new file mode 100644 index 0000000..3c1b71c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/redeem/di/ParachainStakingRedeemModule.kt @@ -0,0 +1,102 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.redeem.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.CurrentRoundRepository +import io.novafoundation.nova.feature_staking_impl.data.parachainStaking.repository.DelegatorStateRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.ParachainStakingRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.RealParachainStakingRedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.validations.ParachainStakingRedeemValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.redeem.validations.parachainStakingRedeem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.redeem.ParachainStakingRedeemViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ParachainStakingRedeemModule { + + @Provides + @ScreenScope + fun provideValidationSystem(): ParachainStakingRedeemValidationSystem { + return ValidationSystem.parachainStakingRedeem() + } + + @Provides + @ScreenScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + cureRoundRepository: CurrentRoundRepository, + delegatorStateRepository: DelegatorStateRepository, + ): ParachainStakingRedeemInteractor = RealParachainStakingRedeemInteractor( + extrinsicService = extrinsicService, + currentRoundRepository = cureRoundRepository, + delegatorStateRepository = delegatorStateRepository + ) + + @Provides + @IntoMap + @ViewModelKey(ParachainStakingRedeemViewModel::class) + fun provideViewModel( + router: StakingRouter, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + validationSystem: ParachainStakingRedeemValidationSystem, + interactor: ParachainStakingRedeemInteractor, + feeLoaderMixin: FeeLoaderMixin.Presentation, + externalActions: ExternalActions.Presentation, + selectedAssetState: StakingSharedState, + validationExecutor: ValidationExecutor, + delegatorStateUseCase: DelegatorStateUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ParachainStakingRedeemViewModel( + router = router, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + validationSystem = validationSystem, + interactor = interactor, + feeLoaderMixin = feeLoaderMixin, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + delegatorStateUseCase = delegatorStateUseCase, + selectedAccountUseCase = selectedAccountUseCase, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ParachainStakingRedeemViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ParachainStakingRedeemViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/ValidationUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/ValidationUi.kt new file mode 100644 index 0000000..d127f03 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/ValidationUi.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure.CollatorIsNotActive +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure.NotEnoughBalanceToPayFees +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure.NotEnoughStakeableBalance +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure.NotPositiveAmount +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure.PendingRevoke +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationFailure.TooLowStake +import io.novafoundation.nova.feature_wallet_api.domain.validation.amountIsTooBig +import io.novafoundation.nova.feature_wallet_api.domain.validation.zeroAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel + +fun startParachainStakingValidationFailure( + failure: StartParachainStakingValidationFailure, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter +): TitleAndMessage { + return when (failure) { + NotEnoughBalanceToPayFees -> { + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.common_not_enough_funds_message) + } + + NotEnoughStakeableBalance -> resourceManager.amountIsTooBig() + + is TooLowStake -> { + val formattedMinStake = amountFormatter.formatAmountToAmountModel(failure.minimumStake, failure.asset).token + + when (failure) { + is TooLowStake.TooLowDelegation -> { + val messageFormat = if (failure.strictGreaterThan) R.string.staking_setup_amount_too_low_strict else R.string.staking_setup_amount_too_low + + resourceManager.getString(R.string.common_amount_low) to + resourceManager.getString(messageFormat, formattedMinStake) + } + is TooLowStake.TooLowTotalStake -> { + resourceManager.getString(R.string.common_amount_low) to + resourceManager.getString(R.string.staking_setup_amount_too_low, formattedMinStake) + } + is TooLowStake.WontReceiveRewards -> { + resourceManager.getString(R.string.staking_parachain_wont_receive_rewards_title) to + resourceManager.getString(R.string.staking_parachain_wont_receive_rewards_message, formattedMinStake) + } + } + } + NotPositiveAmount -> resourceManager.zeroAmount() + + CollatorIsNotActive -> { + resourceManager.getString(R.string.parachain_staking_cannot_stake_with_collator) to + resourceManager.getString(R.string.parachain_staking_not_active_collator_message) + } + PendingRevoke -> { + resourceManager.getString(R.string.parachain_staking_collator_cannot_bond_more) to + resourceManager.getString(R.string.parachain_staking_pending_revoke_message) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/common/StartParachainStakingMode.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/common/StartParachainStakingMode.kt new file mode 100644 index 0000000..87fc7c1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/common/StartParachainStakingMode.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common + +enum class StartParachainStakingMode { + START, BOND_MORE +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/common/di/StartParachainStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/common/di/StartParachainStakingModule.kt new file mode 100644 index 0000000..1776e8a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/common/di/StartParachainStakingModule.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.hints.ConfirmStartParachainStakingHintsMixinFactory + +@Module +class StartParachainStakingModule { + + @Provides + @ScreenScope + fun provideConfirmStartParachainStakingHintsMixinFactory( + resourceManager: ResourceManager + ): ConfirmStartParachainStakingHintsMixinFactory { + return ConfirmStartParachainStakingHintsMixinFactory(resourceManager) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/ConfirmStartParachainStakingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/ConfirmStartParachainStakingFragment.kt new file mode 100644 index 0000000..9a5b982 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/ConfirmStartParachainStakingFragment.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm + +import androidx.core.os.bundleOf + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm.ConfirmStartSingleTargetStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.model.ConfirmStartParachainStakingPayload + +class ConfirmStartParachainStakingFragment : ConfirmStartSingleTargetStakingFragment() { + + companion object { + + private const val PAYLOAD = "ConfirmStartParachainStakingFragment.Payload" + + fun getBundle(payload: ConfirmStartParachainStakingPayload) = bundleOf(PAYLOAD to payload) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmStartParachainStakingFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/ConfirmStartParachainStakingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/ConfirmStartParachainStakingViewModel.kt new file mode 100644 index 0000000..c00d5b2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/ConfirmStartParachainStakingViewModel.kt @@ -0,0 +1,189 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.accountId +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.StartParachainStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.activateDetection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.pauseDetection +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm.ConfirmStartSingleTargetStakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.details.parachain +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.mapCollatorParcelModelToCollator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.collators.collatorAddressModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers.mapCollatorToDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.StartParachainStakingMode +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.ConfirmStartParachainStakingViewModel.ParachainConfirmStartStakingState +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.hints.ConfirmStartParachainStakingHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.model.ConfirmStartParachainStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.startParachainStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ConfirmStartParachainStakingViewModel( + private val parachainStakingRouter: ParachainStakingRouter, + private val startStakingRouter: StartMultiStakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val resourceManager: ResourceManager, + private val validationSystem: StartParachainStakingValidationSystem, + private val interactor: StartParachainStakingInteractor, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val assetUseCase: AssetUseCase, + private val collatorsUseCase: CollatorsUseCase, + private val delegatorStateUseCase: DelegatorStateUseCase, + walletUiUseCase: WalletUiUseCase, + private val payload: ConfirmStartParachainStakingPayload, + private val stakingStartedDetectionService: StakingStartedDetectionService, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter, + hintsMixinFactory: ConfirmStartParachainStakingHintsMixinFactory, +) : ConfirmStartSingleTargetStakingViewModel( + stateFactory = { computationalScope -> + ParachainConfirmStartStakingState( + computationalScope = computationalScope, + delegatorStateUseCase = delegatorStateUseCase, + addressIconGenerator = addressIconGenerator, + payload = payload + ) + }, + router = parachainStakingRouter, + addressIconGenerator = addressIconGenerator, + selectedAccountUseCase = selectedAccountUseCase, + resourceManager = resourceManager, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + payload = payload, + amountFormatter = amountFormatter, +), + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + override val hintsMixin = hintsMixinFactory.create(coroutineScope = this, payload.flowMode) + + override suspend fun confirmClicked(fee: Fee, amount: Balance, asset: Asset) { + val payload = StartParachainStakingValidationPayload( + amount = asset.token.amountFromPlanks(amount), + fee = fee, + collator = state.collator(), + asset = asset, + delegatorState = state.delegatorStateFlow.first(), + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { startParachainStakingValidationFailure(it, resourceManager, amountFormatter) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction(amount, payload.collator) + } + } + + override suspend fun openStakeTargetInfo() { + val parcel = withContext(Dispatchers.Default) { + mapCollatorToDetailsParcelModel(state.collator()) + } + + parachainStakingRouter.openCollatorDetails(StakeTargetDetailsPayload.parachain(parcel, collatorsUseCase)) + } + + private fun sendTransaction( + amountInPlanks: Balance, + collator: Collator, + ) = launch { + stakingStartedDetectionService.pauseDetection(viewModelScope) + + interactor.delegate( + amount = amountInPlanks, + collator = collator.accountId() + ) + .onFailure { + showError(it) + + stakingStartedDetectionService.activateDetection(viewModelScope) + } + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { finishFlow() } + } + + _showNextProgress.value = false + } + + private fun finishFlow() { + when (payload.flowMode) { + StartParachainStakingMode.START -> startStakingRouter.returnToStakingDashboard() + StartParachainStakingMode.BOND_MORE -> parachainStakingRouter.returnToStakingMain() + } + } + + class ParachainConfirmStartStakingState( + computationalScope: ComputationalScope, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val addressIconGenerator: AddressIconGenerator, + payload: ConfirmStartParachainStakingPayload, + ) : ConfirmStartSingleTargetStakingState, + ComputationalScope by computationalScope { + + // Take state only once since subscribing to it might cause switch to Delegator state while waiting for tx confirmation + val delegatorStateFlow = flowOf { delegatorStateUseCase.currentDelegatorState() } + .shareInBackground() + + val collator by lazyAsync(Dispatchers.Default) { + mapCollatorParcelModelToCollator(payload.collator) + } + + override fun isStakeMoreFlow(): Flow { + return delegatorStateFlow.map { it is DelegatorState.Delegator } + } + + override suspend fun collatorAddressModel(chain: Chain): AddressModel { + return addressIconGenerator.collatorAddressModel(collator(), chain) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/di/ConfirmStartParachainStakingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/di/ConfirmStartParachainStakingComponent.kt new file mode 100644 index 0000000..a0be325 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/di/ConfirmStartParachainStakingComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.ConfirmStartParachainStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.model.ConfirmStartParachainStakingPayload + +@Subcomponent( + modules = [ + ConfirmStartParachainStakingModule::class + ] +) +@ScreenScope +interface ConfirmStartParachainStakingComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmStartParachainStakingPayload, + ): ConfirmStartParachainStakingComponent + } + + fun inject(fragment: ConfirmStartParachainStakingFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/di/ConfirmStartParachainStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/di/ConfirmStartParachainStakingModule.kt new file mode 100644 index 0000000..172b997 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/di/ConfirmStartParachainStakingModule.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.StartParachainStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.di.StartParachainStakingModule +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.ConfirmStartParachainStakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.hints.ConfirmStartParachainStakingHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.model.ConfirmStartParachainStakingPayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, StartParachainStakingModule::class]) +class ConfirmStartParachainStakingModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmStartParachainStakingViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + startStakingRouter: StartMultiStakingRouter, + addressIconGenerator: AddressIconGenerator, + selectedAccountUseCase: SelectedAccountUseCase, + resourceManager: ResourceManager, + validationSystem: StartParachainStakingValidationSystem, + collatorsUseCase: CollatorsUseCase, + validationExecutor: ValidationExecutor, + assetUseCase: AssetUseCase, + interactor: StartParachainStakingInteractor, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + externalActions: ExternalActions.Presentation, + selectedAssetState: StakingSharedState, + walletUiUseCase: WalletUiUseCase, + payload: ConfirmStartParachainStakingPayload, + hintsMixinFactory: ConfirmStartParachainStakingHintsMixinFactory, + delegatorStateUseCase: DelegatorStateUseCase, + stakingStartedDetectionService: StakingStartedDetectionService, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmStartParachainStakingViewModel( + parachainStakingRouter = router, + addressIconGenerator = addressIconGenerator, + selectedAccountUseCase = selectedAccountUseCase, + resourceManager = resourceManager, + validationSystem = validationSystem, + interactor = interactor, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + payload = payload, + hintsMixinFactory = hintsMixinFactory, + collatorsUseCase = collatorsUseCase, + delegatorStateUseCase = delegatorStateUseCase, + startStakingRouter = startStakingRouter, + stakingStartedDetectionService = stakingStartedDetectionService, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ConfirmStartParachainStakingViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmStartParachainStakingViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/hints/ConfirmStartParachainStakingHintsMixin.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/hints/ConfirmStartParachainStakingHintsMixin.kt new file mode 100644 index 0000000..447a9ec --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/hints/ConfirmStartParachainStakingHintsMixin.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.hints + +import io.novafoundation.nova.common.mixin.hints.ConstantHintsMixin +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.StartParachainStakingMode +import kotlinx.coroutines.CoroutineScope + +class ConfirmStartParachainStakingHintsMixinFactory( + private val resourceManager: ResourceManager, +) { + + fun create( + coroutineScope: CoroutineScope, + mode: StartParachainStakingMode, + ): HintsMixin = ConfirmStartParachainStakingHintsMixin( + coroutineScope = coroutineScope, + resourceManager = resourceManager, + mode = mode + ) +} + +private class ConfirmStartParachainStakingHintsMixin( + private val resourceManager: ResourceManager, + private val mode: StartParachainStakingMode, + coroutineScope: CoroutineScope +) : ConstantHintsMixin(coroutineScope) { + + override suspend fun getHints(): List = when (mode) { + StartParachainStakingMode.START -> startStakingHints() + StartParachainStakingMode.BOND_MORE -> stakeMoreHints() + } + + private fun stakeMoreHints() = listOf( + resourceManager.getString(R.string.staking_parachain_stake_more_hint) + ) + + private fun startStakingHints(): List = emptyList() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/model/ConfirmStartParachainStakingPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/model/ConfirmStartParachainStakingPayload.kt new file mode 100644 index 0000000..09ee505 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/confirm/model/ConfirmStartParachainStakingPayload.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.startConfirm.ConfirmStartSingleTargetStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.CollatorParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.StartParachainStakingMode +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize + +@Parcelize +class ConfirmStartParachainStakingPayload( + val collator: CollatorParcelModel, + override val amount: Balance, + override val fee: FeeParcelModel, + val flowMode: StartParachainStakingMode +) : Parcelable, ConfirmStartSingleTargetStakingPayload diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/StartParachainStakingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/StartParachainStakingFragment.kt new file mode 100644 index 0000000..a90fc8c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/StartParachainStakingFragment.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup + +import androidx.core.os.bundleOf +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.start.StartSingleSelectStakingFragment + +class StartParachainStakingFragment : StartSingleSelectStakingFragment() { + + companion object { + + private const val PAYLOAD = "StartParachainStakingFragment.Payload" + + fun getBundle(payload: StartParachainStakingPayload) = bundleOf(PAYLOAD to payload) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .startParachainStakingFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/StartParachainStakingPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/StartParachainStakingPayload.kt new file mode 100644 index 0000000..112b87c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/StartParachainStakingPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.StartParachainStakingMode +import kotlinx.parcelize.Parcelize + +@Parcelize +class StartParachainStakingPayload( + val flowMode: StartParachainStakingMode +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/StartParachainStakingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/StartParachainStakingViewModel.kt new file mode 100644 index 0000000..ef89049 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/StartParachainStakingViewModel.kt @@ -0,0 +1,225 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegationAmountTo +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.stakeablePlanks +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.common.singleSelect.model.TargetWithStakedAmount +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.DelegationsLimit +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.StartParachainStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.SelectStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.start.StartSingleSelectStakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenRequester +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.openRequest +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.mapCollatorParcelModelToCollator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.mapCollatorToCollatorParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.selectCollators.mapSelectedCollatorToSelectCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.hints.ConfirmStartParachainStakingHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.model.ConfirmStartParachainStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.rewards.ParachainStakingRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.startParachainStakingValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.math.BigDecimal + +class StartParachainStakingViewModel( + private val router: ParachainStakingRouter, + private val selectCollatorInterScreenRequester: SelectCollatorInterScreenRequester, + private val interactor: StartParachainStakingInteractor, + private val rewardsComponentFactory: ParachainStakingRewardsComponentFactory, + private val addressIconGenerator: AddressIconGenerator, + private val assetUseCase: AssetUseCase, + private val resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + private val validationSystem: StartParachainStakingValidationSystem, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val collatorsUseCase: CollatorsUseCase, + private val payload: StartParachainStakingPayload, + private val collatorRecommendatorFactory: CollatorRecommendatorFactory, + private val selectedAssetState: StakingSharedState, + private val amountFormatter: AmountFormatter, + hintsMixinFactory: ConfirmStartParachainStakingHintsMixinFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, +) : StartSingleSelectStakingViewModel( + logicFactory = { scope -> + ParachainLogic( + scope, + selectCollatorInterScreenRequester, + delegatorStateUseCase, + collatorsUseCase, + selectedAssetState, + addressIconGenerator, + resourceManager, + interactor, + amountFormatter + ) + }, + rewardsComponentFactory = rewardsComponentFactory, + assetUseCase = assetUseCase, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + recommendatorFactory = collatorRecommendatorFactory, + selectedAssetState = selectedAssetState, + router = router, + amountChooserMixinFactory = amountChooserMixinFactory, + amountFormatter = amountFormatter +) { + + override val hintsMixin = hintsMixinFactory.create(coroutineScope = this, payload.flowMode) + + override suspend fun openSelectNewTarget() { + val delegatorState = logic.currentDelegatorStateFlow.first() + + when (val check = interactor.checkDelegationsLimit(delegatorState)) { + DelegationsLimit.NotReached -> selectCollatorInterScreenRequester.openRequest() + is DelegationsLimit.Reached -> { + showError( + title = resourceManager.getString(R.string.staking_parachain_max_delegations_title), + text = resourceManager.getString(R.string.staking_parachain_max_delegations_message, check.limit) + ) + } + } + } + + override suspend fun openSelectFirstTarget() { + selectCollatorInterScreenRequester.openRequest() + } + + override suspend fun goNext(target: Collator, amount: BigDecimal, fee: Fee, asset: Asset) { + val payload = StartParachainStakingValidationPayload( + amount = amount, + fee = fee, + asset = assetFlow.first(), + collator = target, + delegatorState = logic.currentDelegatorStateFlow.first(), + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { startParachainStakingValidationFailure(it, resourceManager, amountFormatter) }, + progressConsumer = validationInProgress.progressConsumer() + ) { + validationInProgress.value = false + + goToNextStep(fee = it.fee, amount = amount, collator = target) + } + } + + private fun goToNextStep( + fee: Fee, + amount: BigDecimal, + collator: Collator, + ) = launch { + val payload = withContext(Dispatchers.Default) { + ConfirmStartParachainStakingPayload( + collator = mapCollatorToCollatorParcelModel(collator), + amount = fee.asset.planksFromAmount(amount), + fee = mapFeeToParcel(fee), + flowMode = payload.flowMode + ) + } + + router.openConfirmStartStaking(payload) + } + + class ParachainLogic( + computationalScope: ComputationalScope, + private val selectCollatorInterScreenRequester: SelectCollatorInterScreenRequester, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val collatorsUseCase: CollatorsUseCase, + private val selectedAssetState: StakingSharedState, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val interactor: StartParachainStakingInteractor, + private val amountFormatter: AmountFormatter, + ) : StartSingleSelectStakingLogic, + ComputationalScope by computationalScope { + + val currentDelegatorStateFlow = delegatorStateUseCase.currentDelegatorStateFlow() + .shareInBackground() + + override fun selectedTargetChanges(): Flow { + return selectCollatorInterScreenRequester.responseFlow.map { response -> + mapCollatorParcelModelToCollator(response.collator) + } + } + + override fun stakeableAmount(assetFlow: Flow): Flow { + return combine(assetFlow, currentDelegatorStateFlow) { asset, currentDelegator -> + currentDelegator.stakeablePlanks(asset.freeInPlanks) + } + } + + override fun isStakeMore(): Flow { + return currentDelegatorStateFlow.map { it is DelegatorState.Delegator } + } + + override fun alreadyStakedTargets(): Flow>> { + return currentDelegatorStateFlow + .mapLatest(collatorsUseCase::getSelectedCollators) + } + + override fun alreadyStakedAmountTo(accountIdKey: AccountIdKey): Flow { + return currentDelegatorStateFlow.map { + it.delegationAmountTo(accountIdKey.value).orZero() + } + } + + override suspend fun mapStakedTargetToUi(target: TargetWithStakedAmount, asset: Asset): SelectStakeTargetModel { + return mapSelectedCollatorToSelectCollatorModel( + selectedCollator = target, + chain = selectedAssetState.chain(), + asset = asset, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) + } + + override suspend fun minimumStakeToGetRewards(selectedStakeTarget: Collator?): Balance { + return selectedStakeTarget?.minimumStakeToGetRewards ?: collatorsUseCase.defaultMinimumStake() + } + + override suspend fun estimateFee(amount: Balance, targetId: AccountIdKey?): Fee { + return interactor.estimateFee(amount, targetId?.value) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/di/SetupStartParachainStakingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/di/SetupStartParachainStakingComponent.kt new file mode 100644 index 0000000..6eb6093 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/di/SetupStartParachainStakingComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.StartParachainStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.StartParachainStakingPayload + +@Subcomponent( + modules = [ + SetupStartParachainStakingModule::class + ] +) +@ScreenScope +interface SetupStartParachainStakingComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: StartParachainStakingPayload, + ): SetupStartParachainStakingComponent + } + + fun inject(fragment: StartParachainStakingFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/di/SetupStartParachainStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/di/SetupStartParachainStakingModule.kt new file mode 100644 index 0000000..13c52f1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/di/SetupStartParachainStakingModule.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.recommendations.CollatorRecommendatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rewards.ParachainStakingRewardCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.StartParachainStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.start.validations.StartParachainStakingValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.common.SelectCollatorInterScreenCommunicator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.di.StartParachainStakingModule +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.confirm.hints.ConfirmStartParachainStakingHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.StartParachainStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.StartParachainStakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.rewards.ParachainStakingRewardsComponentFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, StartParachainStakingModule::class]) +class SetupStartParachainStakingModule { + + @Provides + @ScreenScope + fun provideRewardsComponentFactory( + rewardCalculatorFactory: ParachainStakingRewardCalculatorFactory, + singleAssetSharedState: StakingSharedState, + resourceManager: ResourceManager, + ) = ParachainStakingRewardsComponentFactory(rewardCalculatorFactory, singleAssetSharedState, resourceManager) + + @Provides + @IntoMap + @ViewModelKey(StartParachainStakingViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + selectCollatorInterScreenCommunicator: SelectCollatorInterScreenCommunicator, + interactor: StartParachainStakingInteractor, + assetUseCase: AssetUseCase, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory, + rewardsComponentFactory: ParachainStakingRewardsComponentFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + validationSystem: StartParachainStakingValidationSystem, + addressIconGenerator: AddressIconGenerator, + delegatorStateUseCase: DelegatorStateUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + hintsMixinFactory: ConfirmStartParachainStakingHintsMixinFactory, + collatorsUseCase: CollatorsUseCase, + selectedAssetState: StakingSharedState, + collatorRecommendatorFactory: CollatorRecommendatorFactory, + payload: StartParachainStakingPayload, + amountFormatter: AmountFormatter + ): ViewModel { + return StartParachainStakingViewModel( + router = router, + selectCollatorInterScreenRequester = selectCollatorInterScreenCommunicator, + interactor = interactor, + rewardsComponentFactory = rewardsComponentFactory, + assetUseCase = assetUseCase, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + feeLoaderMixinV2Factory = feeLoaderMixinV2Factory, + amountChooserMixinFactory = amountChooserMixinFactory, + addressIconGenerator = addressIconGenerator, + validationSystem = validationSystem, + delegatorStateUseCase = delegatorStateUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + collatorsUseCase = collatorsUseCase, + hintsMixinFactory = hintsMixinFactory, + selectedAssetState = selectedAssetState, + collatorRecommendatorFactory = collatorRecommendatorFactory, + payload = payload, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): StartParachainStakingViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StartParachainStakingViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/model/SelectCollatorModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/model/SelectCollatorModel.kt new file mode 100644 index 0000000..741982d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/model/SelectCollatorModel.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.model + +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.SelectStakeTargetModel + +typealias SelectCollatorModel = SelectStakeTargetModel diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/rewards/ParachainStakingRewardsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/rewards/ParachainStakingRewardsComponent.kt new file mode 100644 index 0000000..c8a662a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/start/setup/rewards/ParachainStakingRewardsComponent.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.rewards + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.rewards.ParachainStakingRewardCalculatorFactory +import io.novafoundation.nova.feature_staking_impl.domain.rewards.PeriodReturns +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards.SingleSelectStakingRewardEstimationComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.singleSelect.rewards.SingleSelectStakingRewardEstimationComponentFactory +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal + +class ParachainStakingRewardsComponentFactory( + private val rewardCalculatorFactory: ParachainStakingRewardCalculatorFactory, + private val singleAssetSharedState: StakingSharedState, + private val resourceManager: ResourceManager, +) : SingleSelectStakingRewardEstimationComponentFactory { + + override fun create( + computationalScope: ComputationalScope, + assetFlow: Flow, + selectedAmount: Flow, + selectedTarget: Flow + ): SingleSelectStakingRewardEstimationComponent = ParachainStakingRewardsComponent( + rewardCalculatorFactory = rewardCalculatorFactory, + singleAssetSharedState = singleAssetSharedState, + resourceManager = resourceManager, + computationalScope = computationalScope, + assetFlow = assetFlow, + selectedAmountFlow = selectedAmount, + selectedTargetFlow = selectedTarget + ) +} + +private class ParachainStakingRewardsComponent( + private val rewardCalculatorFactory: ParachainStakingRewardCalculatorFactory, + private val singleAssetSharedState: StakingSharedState, + resourceManager: ResourceManager, + + computationalScope: ComputationalScope, + assetFlow: Flow, + selectedAmountFlow: Flow, + selectedTargetFlow: Flow +) : SingleSelectStakingRewardEstimationComponent(resourceManager, computationalScope, assetFlow, selectedAmountFlow, selectedTargetFlow) { + + private val rewardCalculator by lazyAsync { + rewardCalculatorFactory.create(singleAssetSharedState.selectedOption()) + } + + override suspend fun calculatePeriodReturns(selectedTarget: AccountIdKey?, selectedAmount: BigDecimal): PeriodReturns { + return if (selectedTarget != null) { + rewardCalculator().calculateCollatorAnnualReturns(selectedTarget.value, selectedAmount) + } else { + rewardCalculator().calculateMaxAnnualReturns(selectedAmount) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/ValidationUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/ValidationUi.kt new file mode 100644 index 0000000..4c1a564 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/ValidationUi.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationFailure.AlreadyHasDelegationRequestToCollator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationFailure.NotEnoughBalanceToPayFees +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationFailure.NotEnoughBondedToUnbond +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationFailure.NotPositiveAmount +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationFailure.TooLowRemainingBond +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationPayload +import io.novafoundation.nova.feature_wallet_api.domain.validation.notSufficientBalanceToPayFeeErrorMessage +import io.novafoundation.nova.feature_wallet_api.domain.validation.zeroAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel + +fun parachainStakingUnbondValidationFailure( + failure: ParachainStakingUnbondValidationFailure, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter +): TitleAndMessage { + return when (failure) { + NotEnoughBalanceToPayFees -> resourceManager.notSufficientBalanceToPayFeeErrorMessage() + + is TooLowRemainingBond -> { + val minimumRequired = amountFormatter.formatAmountToAmountModel(failure.minimumRequired, failure.asset).token + + when (failure) { + is TooLowRemainingBond.WontReceiveRewards -> { + resourceManager.getString(R.string.staking_parachain_wont_receive_rewards_title) to + resourceManager.getString(R.string.parachain_staking_unbond_no_rewards, minimumRequired) + } + is TooLowRemainingBond.WillBeAddedToUnbondings -> { + resourceManager.getString(R.string.staking_unstake_all_question) to + resourceManager.getString(R.string.parachain_staking_unstake_all, minimumRequired) + } + } + } + + NotPositiveAmount -> resourceManager.zeroAmount() + + AlreadyHasDelegationRequestToCollator -> { + resourceManager.getString(R.string.staking_parachain_unbond_already_exists_title) to + resourceManager.getString(R.string.staking_parachain_unbond_already_exists_message) + } + + NotEnoughBondedToUnbond -> { + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.staking_unbond_too_big) + } + } +} + +fun parachainStakingUnbondPayloadAutoFix(payload: ParachainStakingUnbondValidationPayload, reason: ParachainStakingUnbondValidationFailure) = when (reason) { + is TooLowRemainingBond.WillBeAddedToUnbondings -> payload.copy(amount = reason.newAmount) + else -> payload +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/ParachainStakingUnbondConfirmFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/ParachainStakingUnbondConfirmFragment.kt new file mode 100644 index 0000000..a1b94cc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/ParachainStakingUnbondConfirmFragment.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm + +import androidx.core.os.bundleOf + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentParachainStakingUnbondConfirmBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.model.ParachainStakingUnbondConfirmPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class ParachainStakingUnbondConfirmFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "ParachainStakingUnbondConfirmFragment.Payload" + + fun getBundle(payload: ParachainStakingUnbondConfirmPayload) = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentParachainStakingUnbondConfirmBinding.inflate(layoutInflater) + + override fun initViews() { + binder.parachainStakingUnbondConfirmToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.parachainStakingUnbondConfirmExtrinsicInfo.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.parachainStakingUnbondConfirmConfirm.prepareForProgress(viewLifecycleOwner) + binder.parachainStakingUnbondConfirmConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.parachainStakingUnbondConfirmCollator.setOnClickListener { viewModel.collatorClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .parachainStakingUnbondConfirmFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ParachainStakingUnbondConfirmViewModel) { + observeRetries(viewModel) + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel, binder.parachainStakingUnbondConfirmExtrinsicInfo.fee) + observeHints(viewModel.hintsMixin, binder.parachainStakingUnbondConfirmHints) + + viewModel.showNextProgress.observe(binder.parachainStakingUnbondConfirmConfirm::setProgressState) + + viewModel.currentAccountModelFlow.observe(binder.parachainStakingUnbondConfirmExtrinsicInfo::setAccount) + viewModel.walletFlow.observe(binder.parachainStakingUnbondConfirmExtrinsicInfo::setWallet) + + viewModel.collatorAddressModel.observe(binder.parachainStakingUnbondConfirmCollator::showAddress) + viewModel.amountModel.observe(binder.parachainStakingUnbondConfirmAmount::setAmount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/ParachainStakingUnbondConfirmViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/ParachainStakingUnbondConfirmViewModel.kt new file mode 100644 index 0000000..ed77320 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/ParachainStakingUnbondConfirmViewModel.kt @@ -0,0 +1,177 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.ParachainStakingUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.details.parachain +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.mapCollatorParcelModelToCollator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.collators.collatorAddressModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.mappers.mapCollatorToDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.model.ParachainStakingUnbondConfirmPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.hints.ParachainStakingUnbondHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.parachainStakingUnbondPayloadAutoFix +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.parachainStakingUnbondValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ParachainStakingUnbondConfirmViewModel( + private val router: ParachainStakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val validationSystem: ParachainStakingUnbondValidationSystem, + private val interactor: ParachainStakingUnbondInteractor, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val collatorsUseCase: CollatorsUseCase, + private val payload: ParachainStakingUnbondConfirmPayload, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + hintsMixinFactory: ParachainStakingUnbondHintsMixinFactory, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Retriable, + Validatable by validationExecutor, + FeeLoaderMixin by feeLoaderMixin, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val decimalFee = mapFeeFromParcel(payload.fee) + + val hintsMixin = hintsMixinFactory.create(coroutineScope = this) + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val collator by lazyAsync(Dispatchers.Default) { + mapCollatorParcelModelToCollator(payload.collator) + } + + val currentAccountModelFlow = selectedAccountUseCase.selectedMetaAccountFlow().map { + addressIconGenerator.createAccountAddressModel( + chain = selectedAssetState.chain(), + account = it, + name = null + ) + }.shareInBackground() + + val amountModel = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.amount, asset) + } + .shareInBackground() + + val walletFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val collatorAddressModel = flowOf { + addressIconGenerator.collatorAddressModel(collator(), selectedAssetState.chain()) + }.shareInBackground() + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: StateFlow = _showNextProgress + + init { + setInitialFee() + } + + fun confirmClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = currentAccountModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + fun collatorClicked() = launch { + val parcel = withContext(Dispatchers.Default) { + mapCollatorToDetailsParcelModel(collator()) + } + + router.openCollatorDetails(StakeTargetDetailsPayload.parachain(parcel, collatorsUseCase)) + } + + private fun setInitialFee() = launch { + feeLoaderMixin.setFee(decimalFee) + } + + private fun sendTransactionIfValid() = launch { + val payload = ParachainStakingUnbondValidationPayload( + amount = payload.amount, + fee = decimalFee, + collator = collator(), + asset = assetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { parachainStakingUnbondValidationFailure(it, resourceManager, amountFormatter) }, + autoFixPayload = ::parachainStakingUnbondPayloadAutoFix, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction() + } + } + + private fun sendTransaction() = launch { + val token = assetFlow.first().token + val amountInPlanks = token.planksFromAmount(payload.amount) + + interactor.unbond( + amount = amountInPlanks, + collator = payload.collator.accountIdHex.fromHex() + ) + .onFailure(::showError) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/di/ParachainStakingUnbondConfirmComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/di/ParachainStakingUnbondConfirmComponent.kt new file mode 100644 index 0000000..f60fd94 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/di/ParachainStakingUnbondConfirmComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.ParachainStakingUnbondConfirmFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.model.ParachainStakingUnbondConfirmPayload + +@Subcomponent( + modules = [ + ParachainStakingUnbondConfirmModule::class + ] +) +@ScreenScope +interface ParachainStakingUnbondConfirmComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ParachainStakingUnbondConfirmPayload, + ): ParachainStakingUnbondConfirmComponent + } + + fun inject(fragment: ParachainStakingUnbondConfirmFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/di/ParachainStakingUnbondConfirmModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/di/ParachainStakingUnbondConfirmModule.kt new file mode 100644 index 0000000..feeca96 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/di/ParachainStakingUnbondConfirmModule.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.ParachainStakingUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.ParachainStakingUnbondConfirmViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.model.ParachainStakingUnbondConfirmPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.hints.ParachainStakingUnbondHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ParachainStakingUnbondConfirmModule { + + @Provides + @IntoMap + @ViewModelKey(ParachainStakingUnbondConfirmViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + validationSystem: ParachainStakingUnbondValidationSystem, + interactor: ParachainStakingUnbondInteractor, + feeLoaderMixin: FeeLoaderMixin.Presentation, + externalActions: ExternalActions.Presentation, + selectedAssetState: StakingSharedState, + validationExecutor: ValidationExecutor, + collatorsUseCase: CollatorsUseCase, + payload: ParachainStakingUnbondConfirmPayload, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + hintsMixinFactory: ParachainStakingUnbondHintsMixinFactory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ParachainStakingUnbondConfirmViewModel( + router = router, + addressIconGenerator = addressIconGenerator, + selectedAccountUseCase = selectedAccountUseCase, + resourceManager = resourceManager, + validationSystem = validationSystem, + interactor = interactor, + feeLoaderMixin = feeLoaderMixin, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + payload = payload, + hintsMixinFactory = hintsMixinFactory, + collatorsUseCase = collatorsUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ParachainStakingUnbondConfirmViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ParachainStakingUnbondConfirmViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/model/ParachainStakingUnbondConfirmPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/model/ParachainStakingUnbondConfirmPayload.kt new file mode 100644 index 0000000..fb4775c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/confirm/model/ParachainStakingUnbondConfirmPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.CollatorParcelModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class ParachainStakingUnbondConfirmPayload( + val collator: CollatorParcelModel, + val amount: BigDecimal, + val fee: FeeParcelModel +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/hints/ParachainStakingUnbondHintsMixinFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/hints/ParachainStakingUnbondHintsMixinFactory.kt new file mode 100644 index 0000000..b8588e3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/hints/ParachainStakingUnbondHintsMixinFactory.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.hints + +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.ParachainStakingHintsUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ParachainStakingUnbondHintsMixinFactory( + private val stakingHintsUseCase: ParachainStakingHintsUseCase, +) { + + fun create( + coroutineScope: CoroutineScope, + ): HintsMixin = ParachainStakingUnbondHintsMixin( + coroutineScope = coroutineScope, + stakingHintsUseCase = stakingHintsUseCase, + ) +} + +private class ParachainStakingUnbondHintsMixin( + private val stakingHintsUseCase: ParachainStakingHintsUseCase, + + coroutineScope: CoroutineScope +) : HintsMixin, WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + override val hintsFlow: Flow> = stakingHintsUseCase.unstakeDurationHintFlow().map { unstakeDurationHint -> + listOf( + unstakeDurationHint, + stakingHintsUseCase.noRewardDuringUnstakingHint(), + stakingHintsUseCase.redeemHint() + ) + }.shareInBackground() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/ParachainStakingUnbondFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/ParachainStakingUnbondFragment.kt new file mode 100644 index 0000000..4409d09 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/ParachainStakingUnbondFragment.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.setup + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentParachainStakingUnbondBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class ParachainStakingUnbondFragment : BaseFragment() { + + override fun createBinding() = FragmentParachainStakingUnbondBinding.inflate(layoutInflater) + + override fun initViews() { + binder.parachainStakingUnbondToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.parachainStakingUnbondNext.prepareForProgress(viewLifecycleOwner) + binder.parachainStakingUnbondNext.setOnClickListener { viewModel.nextClicked() } + + binder.parachainStakingUnbondCollator.setOnClickListener { viewModel.selectCollatorClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .parachainStakingUnbondSetupFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ParachainStakingUnbondViewModel) { + observeRetries(viewModel) + observeValidations(viewModel) + setupAmountChooser(viewModel.amountChooserMixin, binder.parachainStakingUnbondAmountField) + setupFeeLoading(viewModel.originFeeMixin, binder.parachainStakingUnbondFee) + observeHints(viewModel.hintsMixin, binder.parachainStakingUnbondHints) + + viewModel.selectedCollatorModel.observe(binder.parachainStakingUnbondCollator::setSelectedTarget) + + viewModel.buttonState.observe(binder.parachainStakingUnbondNext::setState) + + viewModel.minimumStake.observe(binder.parachainStakingUnbondMinStake::showAmount) + viewModel.transferable.observe(binder.parachainStakingUnbondTransferable::showAmount) + + viewModel.chooseCollatorAction.awaitableActionLiveData.observeEvent { action -> + ChooseStakedStakeTargetsBottomSheet( + context = requireContext(), + payload = action.payload, + stakedCollatorSelected = { _, item -> action.onSuccess(item) }, + onCancel = action.onCancel, + newStakeTargetClicked = null + ).show() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/ParachainStakingUnbondViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/ParachainStakingUnbondViewModel.kt new file mode 100644 index 0000000..7f209ff --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/ParachainStakingUnbondViewModel.kt @@ -0,0 +1,273 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.setup + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.RetryPayload +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.findById +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.ParachainStakingUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.UnbondingCollator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.mapCollatorToCollatorParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.selectCollators.mapCollatorToSelectCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.selectCollators.mapUnbondingCollatorToSelectCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.model.SelectCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.confirm.model.ParachainStakingUnbondConfirmPayload +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.hints.ParachainStakingUnbondHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.parachainStakingUnbondPayloadAutoFix +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.parachainStakingUnbondValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.transferableAmountModel +import io.novasama.substrate_sdk_android.extensions.fromHex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.math.BigDecimal + +class ParachainStakingUnbondViewModel( + private val router: ParachainStakingRouter, + private val interactor: ParachainStakingUnbondInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val assetUseCase: AssetUseCase, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: ParachainStakingUnbondValidationSystem, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val hintsMixinFactory: ParachainStakingUnbondHintsMixinFactory, + private val maxActionProviderFactory: MaxActionProviderFactory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Retriable, + Validatable by validationExecutor { + + private val validationInProgress = MutableStateFlow(false) + + private val assetWithOption = assetUseCase.currentAssetAndOptionFlow() + .shareInBackground() + + private val selectedAsset = assetWithOption.map { it.asset } + .shareInBackground() + + private val selectedChainAsset = selectedAsset.map { it.token.configuration } + .shareInBackground() + + private val currentDelegatorStateFlow = delegatorStateUseCase.currentDelegatorStateFlow() + .shareInBackground() + + private val alreadyStakedCollatorsFlow = currentDelegatorStateFlow + .mapLatest(interactor::getSelectedCollators) + .shareInBackground() + + private val selectedCollatorFlow = singleReplaySharedFlow() + private val selectedCollatorIdFlow = selectedCollatorFlow.map { it.accountIdHex.fromHex() } + + private val stakedAmount = combine( + currentDelegatorStateFlow, + selectedCollatorIdFlow + ) { delegatorState, collatorId -> + delegatorState.delegationAmountTo(collatorId).orZero() + } + + val originFeeMixin = feeLoaderMixinFactory.createDefault( + this, + selectedChainAsset, + FeeLoaderMixinV2.Configuration(onRetryCancelled = ::backClicked) + ) + + override val retryEvent: MutableLiveData> = originFeeMixin.retryEvent + + private val maxActionProvider = maxActionProviderFactory.createCustom(viewModelScope) { + selectedChainAsset.providingBalance(stakedAmount) + } + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = selectedAsset, + maxActionProvider = maxActionProvider + ) + + val selectedCollatorModel = combine( + selectedCollatorFlow, + currentDelegatorStateFlow, + selectedAsset + ) { selectedCollator, currentDelegatorState, asset -> + mapCollatorToSelectCollatorModel(selectedCollator, currentDelegatorState, asset, addressIconGenerator, resourceManager, amountFormatter) + }.shareInBackground() + + val chooseCollatorAction = actionAwaitableMixinFactory.create, SelectCollatorModel>() + + val minimumStake = selectedCollatorFlow.map { + val minimumStake = it.minimumStakeToGetRewards + val asset = selectedAsset.first() + + amountFormatter.formatAmountToAmountModel(minimumStake, asset) + }.shareInBackground() + + val transferable = selectedAsset.map { it.transferableAmountModel(amountFormatter) } + .shareInBackground() + + val buttonState = combine( + validationInProgress, + amountChooserMixin.amountInput + ) { validationInProgress, amountInput -> + when { + validationInProgress -> DescriptiveButtonState.Loading + amountInput.isEmpty() -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_amount)) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + } + + val hintsMixin = hintsMixinFactory.create(coroutineScope = this) + + init { + originFeeMixin.connectWith( + inputSource1 = amountChooserMixin.backPressuredPlanks, + inputSource2 = selectedCollatorIdFlow, + feeConstructor = { _, amount, collatorId -> + interactor.estimateFee(amount, collatorId) + } + ) + + setInitialCollator() + } + + fun selectCollatorClicked() = launch { + val delegatorState = currentDelegatorStateFlow.first() + val alreadyStakedCollators = alreadyStakedCollatorsFlow.first() + + val payload = createSelectCollatorPayload(alreadyStakedCollators, delegatorState) + + val newCollator = chooseCollatorAction.awaitAction(payload) + setCollatorIfCanUnbond(newCollator, delegatorState) + } + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + private suspend fun createSelectCollatorPayload( + alreadyStakedCollators: List, + delegatorState: DelegatorState + ): ChooseStakedStakeTargetsBottomSheet.Payload { + val asset = selectedAsset.first() + val selectedCollator = selectedCollatorFlow.first() + + return withContext(Dispatchers.Default) { + val collatorModels = alreadyStakedCollators.map { + mapUnbondingCollatorToSelectCollatorModel( + unbondingCollator = it, + chain = delegatorState.chain, + asset = asset, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) + } + val selected = collatorModels.findById(selectedCollator) + + ChooseStakedStakeTargetsBottomSheet.Payload(collatorModels, selected) + } + } + + private suspend fun setCollatorIfCanUnbond(newCollator: SelectCollatorModel, delegatorState: DelegatorState) { + val collarAccountId = newCollator.payload.accountIdHex.fromHex() + + if (interactor.canUnbond(collarAccountId, delegatorState)) { + selectedCollatorFlow.emit(newCollator.payload) + } else { + showError( + title = resourceManager.getString(R.string.staking_parachain_unbond_already_exists_title), + text = resourceManager.getString(R.string.staking_parachain_unbond_already_exists_message) + ) + } + } + + private fun setInitialCollator() = launch(Dispatchers.Default) { + val collatorsWithoutUnbonding = alreadyStakedCollatorsFlow.first() + .filterNot { it.hasPendingUnbonding } + + if (collatorsWithoutUnbonding.isNotEmpty()) { + selectedCollatorFlow.emit(collatorsWithoutUnbonding.first().target) + } + } + + private fun maybeGoToNext() = launch { + validationInProgress.value = true + + val payload = ParachainStakingUnbondValidationPayload( + amount = amountChooserMixin.amount.first(), + fee = originFeeMixin.awaitFee(), + asset = selectedAsset.first(), + collator = selectedCollatorFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { parachainStakingUnbondValidationFailure(it, resourceManager, amountFormatter) }, + autoFixPayload = ::parachainStakingUnbondPayloadAutoFix, + progressConsumer = validationInProgress.progressConsumer() + ) { fixedPayload -> + validationInProgress.value = false + + goToNextStep(fee = fixedPayload.fee, amount = fixedPayload.amount, collator = fixedPayload.collator) + } + } + + private fun goToNextStep( + fee: Fee, + amount: BigDecimal, + collator: Collator, + ) = launch { + val payload = withContext(Dispatchers.Default) { + ParachainStakingUnbondConfirmPayload( + collator = mapCollatorToCollatorParcelModel(collator), + amount = amount, + fee = mapFeeToParcel(fee) + ) + } + + router.openConfirmUnbond(payload) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/di/ParachainStakingUnbondComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/di/ParachainStakingUnbondComponent.kt new file mode 100644 index 0000000..83e570d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/di/ParachainStakingUnbondComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.setup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.setup.ParachainStakingUnbondFragment + +@Subcomponent( + modules = [ + ParachainStakingUnbondModule::class + ] +) +@ScreenScope +interface ParachainStakingUnbondComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): ParachainStakingUnbondComponent + } + + fun inject(fragment: ParachainStakingUnbondFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/di/ParachainStakingUnbondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/di/ParachainStakingUnbondModule.kt new file mode 100644 index 0000000..8f7a621 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/unbond/setup/di/ParachainStakingUnbondModule.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.setup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.ParachainStakingUnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.flow.ParachainStakingUnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.hints.ParachainStakingUnbondHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.unbond.setup.ParachainStakingUnbondViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ParachainStakingUnbondModule { + + @Provides + @IntoMap + @ViewModelKey(ParachainStakingUnbondViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + interactor: ParachainStakingUnbondInteractor, + addressIconGenerator: AddressIconGenerator, + assetUseCase: AssetUseCase, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: ParachainStakingUnbondValidationSystem, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + maxActionProviderFactory: MaxActionProviderFactory, + delegatorStateUseCase: DelegatorStateUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + hintsMixinFactory: ParachainStakingUnbondHintsMixinFactory, + amountFormatter: AmountFormatter + ): ViewModel { + return ParachainStakingUnbondViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + assetUseCase = assetUseCase, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + feeLoaderMixinFactory = feeLoaderMixinFactory, + maxActionProviderFactory = maxActionProviderFactory, + delegatorStateUseCase = delegatorStateUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + amountChooserMixinFactory = amountChooserMixinFactory, + hintsMixinFactory = hintsMixinFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ParachainStakingUnbondViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ParachainStakingUnbondViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/common/Formatting.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/common/Formatting.kt new file mode 100644 index 0000000..e0fb4c5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/common/Formatting.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.feature_staking_impl.R + +fun ResourceManager.formatDaysFrequency(days: Int): String { + return if (days == 1) { + getString(R.string.common_frequency_days_everyday) + } else { + getQuantityString(R.plurals.common_frequency_days, days, days.format()) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/common/ValidationUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/common/ValidationUi.kt new file mode 100644 index 0000000..0c4e56d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/common/ValidationUi.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.common + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationFailure.FirstTaskCannotExecute.Type.EXECUTION_FEE +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationFailure.FirstTaskCannotExecute.Type.THRESHOLD +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount + +fun yieldBoostValidationFailure(failure: YieldBoostValidationFailure, resourceManager: ResourceManager): TitleAndMessage { + return when (failure) { + is YieldBoostValidationFailure.FirstTaskCannotExecute -> { + val (titleRes, messageRes) = when (failure.type) { + THRESHOLD -> R.string.yield_boost_not_enough_threshold_title to R.string.yield_boost_not_enough_threshold_message + EXECUTION_FEE -> R.string.yield_boost_not_enough_execution_fee_title to R.string.yield_boost_not_enough_execution_fee_message + } + + val networkFee = failure.networkFee.formatTokenAmount(failure.chainAsset) + val minimumRequired = failure.minimumBalanceRequired.formatTokenAmount(failure.chainAsset) + val available = failure.availableBalanceBeforeFees.formatTokenAmount(failure.chainAsset) + + resourceManager.getString(titleRes) to resourceManager.getString(messageRes, networkFee, minimumRequired, available) + } + + is YieldBoostValidationFailure.NotEnoughToPayToPayFees -> handleNotEnoughFeeError(failure, resourceManager) + + is YieldBoostValidationFailure.WillCancelAllExistingTasks -> { + val collatorName = failure.newCollator.identity?.display ?: failure.newCollator.address + + resourceManager.getString(R.string.yield_boost_already_enabled_title) to + resourceManager.getString(R.string.yield_boost_already_enabled_message, collatorName) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/YieldBoostConfirmFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/YieldBoostConfirmFragment.kt new file mode 100644 index 0000000..7ae00c9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/YieldBoostConfirmFragment.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm + +import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentYieldBoostConfirmBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfirmPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmountOrHide + +class YieldBoostConfirmFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD = "YieldBoostConfirmFragment.Payload" + + fun getBundle(payload: YieldBoostConfirmPayload) = bundleOf(PAYLOAD to payload) + } + + override fun createBinding() = FragmentYieldBoostConfirmBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmYieldBoostToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.confirmYieldBoostExtrinsicInfo.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.confirmYieldBoostConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmYieldBoostConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmYieldBoostComponentFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: YieldBoostConfirmViewModel) { + observeRetries(viewModel) + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel, binder.confirmYieldBoostExtrinsicInfo.fee) + + viewModel.buttonState.observe(binder.confirmYieldBoostConfirm::setState) + + viewModel.currentAccountModelFlow.observe(binder.confirmYieldBoostExtrinsicInfo::setAccount) + viewModel.walletFlow.observe(binder.confirmYieldBoostExtrinsicInfo::setWallet) + + viewModel.collatorAddressModel.observe(binder.confirmYieldBoostCollator::showAddress) + + viewModel.yieldBoostConfigurationUi.observe { + binder.confirmYieldBoostThreshold.showAmountOrHide(it.threshold) + binder.confirmYieldBoostFrequency.showValueOrHide(it.frequency) + binder.confirmYieldBoostMode.showValue(it.mode) + binder.confirmYieldBoostTerms.text = it.termsText + } + + binder.confirmYieldBoostTerms.bindTo(viewModel.termsCheckedFlow, viewLifecycleOwner.lifecycleScope) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/YieldBoostConfirmViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/YieldBoostConfirmViewModel.kt new file mode 100644 index 0000000..6e33914 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/YieldBoostConfirmViewModel.kt @@ -0,0 +1,222 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.input.Input +import io.novafoundation.nova.common.utils.input.disabledInput +import io.novafoundation.nova.common.utils.input.modifiableInput +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostConfiguration +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostTask +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.mapCollatorParcelModelToCollator +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.collators.collatorAddressModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.common.formatDaysFrequency +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.common.yieldBoostValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfiguration +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfigurationModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfigurationParcel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfirmPayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class YieldBoostConfirmViewModel( + private val router: ParachainStakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val validationSystem: YieldBoostValidationSystem, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val interactor: YieldBoostInteractor, + private val payload: YieldBoostConfirmPayload, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Retriable, + Validatable by validationExecutor, + FeeLoaderMixin by feeLoaderMixin, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val decimalFee = mapFeeFromParcel(payload.fee) + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val collator by lazyAsync(Dispatchers.Default) { + mapCollatorParcelModelToCollator(payload.collator) + } + + private val chain by lazyAsync { + selectedAssetState.chain() + } + + private val yieldBoostConfiguration by lazyAsync { + YieldBoostConfiguration(payload.configurationParcel) + } + + private val _showNextProgress = MutableStateFlow(false) + + private val activeTasksFlow = delegatorStateUseCase.currentDelegatorStateFlow() + .filterIsInstance() + .flatMapLatest(interactor::activeYieldBoostTasks) + .shareInBackground() + + val currentAccountModelFlow = selectedAccountUseCase.selectedAddressModelFlow(chain::await) + .shareInBackground() + + val walletFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val collatorAddressModel = flowOf { + addressIconGenerator.collatorAddressModel(collator(), chain()) + }.shareInBackground() + + val termsCheckedFlow = MutableStateFlow(initialTermsCheckedInput()) + + val buttonState = combine(termsCheckedFlow, _showNextProgress) { termsChecked, showNextProgress -> + when { + showNextProgress -> DescriptiveButtonState.Loading + + termsChecked is Input.Enabled.Modifiable && !termsChecked.value -> { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_accept_terms)) + } + + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_confirm)) + } + }.shareInBackground() + + val yieldBoostConfigurationUi = flowOf { + mapConfigurationToUi(yieldBoostConfiguration()) + }.shareInBackground() + + init { + setInitialFee() + } + + fun confirmClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = currentAccountModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + private fun setInitialFee() = launch { + feeLoaderMixin.setFee(decimalFee) + } + + private fun sendTransactionIfValid() = launch { + val payload = YieldBoostValidationPayload( + collator = collator(), + configuration = yieldBoostConfiguration(), + fee = decimalFee, + activeTasks = activeTasksFlow.first(), + asset = assetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { yieldBoostValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction(it.activeTasks, it.configuration) + } + } + + private fun sendTransaction( + activeTasks: List, + yieldBoostConfiguration: YieldBoostConfiguration, + ) = launch { + val outcome = withContext(Dispatchers.Default) { + interactor.setYieldBoost( + configuration = yieldBoostConfiguration, + activeTasks = activeTasks + ) + } + + outcome.onFailure(::showError) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + + _showNextProgress.value = false + } + + private fun initialTermsCheckedInput(): Input = when (payload.configurationParcel) { + is YieldBoostConfigurationParcel.Off -> disabledInput() + is YieldBoostConfigurationParcel.On -> false.modifiableInput() + } + + private suspend fun mapConfigurationToUi(configuration: YieldBoostConfiguration) = when (configuration) { + is YieldBoostConfiguration.Off -> YieldBoostConfigurationModel( + mode = resourceManager.getString(R.string.staking_turing_destination_payout), + frequency = null, + threshold = null, + termsText = null + ) + is YieldBoostConfiguration.On -> { + val asset = assetFlow.first() + + val threshold = amountFormatter.formatAmountToAmountModel(configuration.threshold, asset) + val frequency = resourceManager.formatDaysFrequency(configuration.frequencyInDays) + + val termsText = resourceManager.getString(R.string.yield_boost_terms, frequency, threshold.token) + + YieldBoostConfigurationModel( + mode = resourceManager.getString(R.string.staking_turing_destination_restake), + frequency = frequency, + threshold = threshold, + termsText = termsText + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/di/YieldBoostConfirmComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/di/YieldBoostConfirmComponent.kt new file mode 100644 index 0000000..ac225a2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/di/YieldBoostConfirmComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.YieldBoostConfirmFragment +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfirmPayload + +@Subcomponent( + modules = [ + YieldBoostConfirmModule::class + ] +) +@ScreenScope +interface YieldBoostConfirmComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: YieldBoostConfirmPayload, + ): YieldBoostConfirmComponent + } + + fun inject(fragment: YieldBoostConfirmFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/di/YieldBoostConfirmModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/di/YieldBoostConfirmModule.kt new file mode 100644 index 0000000..c3ec9c0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/di/YieldBoostConfirmModule.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.YieldBoostConfirmViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfirmPayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class YieldBoostConfirmModule { + + @Provides + @IntoMap + @ViewModelKey(YieldBoostConfirmViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + validationSystem: YieldBoostValidationSystem, + feeLoaderMixin: FeeLoaderMixin.Presentation, + externalActions: ExternalActions.Presentation, + selectedAssetState: StakingSharedState, + validationExecutor: ValidationExecutor, + interactor: YieldBoostInteractor, + payload: YieldBoostConfirmPayload, + delegatorStateUseCase: DelegatorStateUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + assetUseCase: AssetUseCase, + walletUiUseCase: WalletUiUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return YieldBoostConfirmViewModel( + router = router, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + validationSystem = validationSystem, + feeLoaderMixin = feeLoaderMixin, + externalActions = externalActions, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + interactor = interactor, + payload = payload, + delegatorStateUseCase = delegatorStateUseCase, + selectedAccountUseCase = selectedAccountUseCase, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): YieldBoostConfirmViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(YieldBoostConfirmViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/model/YieldBoostConfigurationModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/model/YieldBoostConfigurationModel.kt new file mode 100644 index 0000000..46544ae --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/model/YieldBoostConfigurationModel.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model + +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class YieldBoostConfigurationModel( + val mode: String, + val frequency: String?, + val threshold: AmountModel?, + val termsText: String?, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/model/YieldBoostConfirmPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/model/YieldBoostConfirmPayload.kt new file mode 100644 index 0000000..062c7d6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/confirm/model/YieldBoostConfirmPayload.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostConfiguration +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.CollatorParcelModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +@Parcelize +class YieldBoostConfirmPayload( + val collator: CollatorParcelModel, + val configurationParcel: YieldBoostConfigurationParcel, + val fee: FeeParcelModel, +) : Parcelable + +sealed class YieldBoostConfigurationParcel(open val collatorIdHex: String) : Parcelable { + + @Parcelize + class On( + val threshold: BigInteger, + val frequencyInDays: Int, + override val collatorIdHex: String + ) : YieldBoostConfigurationParcel(collatorIdHex) + + @Parcelize + class Off(override val collatorIdHex: String) : YieldBoostConfigurationParcel(collatorIdHex) +} + +fun YieldBoostConfigurationParcel(configuration: YieldBoostConfiguration) = when (configuration) { + is YieldBoostConfiguration.On -> YieldBoostConfigurationParcel.On( + threshold = configuration.threshold, + frequencyInDays = configuration.frequencyInDays, + collatorIdHex = configuration.collatorIdHex + ) + + is YieldBoostConfiguration.Off -> YieldBoostConfigurationParcel.Off(configuration.collatorIdHex) +} + +fun YieldBoostConfiguration(parcel: YieldBoostConfigurationParcel) = when (parcel) { + is YieldBoostConfigurationParcel.On -> YieldBoostConfiguration.On( + threshold = parcel.threshold, + frequencyInDays = parcel.frequencyInDays, + collatorIdHex = parcel.collatorIdHex + ) + + is YieldBoostConfigurationParcel.Off -> YieldBoostConfiguration.Off(parcel.collatorIdHex) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/SetupYieldBoostFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/SetupYieldBoostFragment.kt new file mode 100644 index 0000000..aa64fc7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/SetupYieldBoostFragment.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.setup + +import android.view.View +import androidx.core.view.isVisible + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.insets.ImeInsetsState +import io.novafoundation.nova.common.utils.insets.applySystemBarInsets +import io.novafoundation.nova.common.utils.scrollOnFocusTo +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentYieldBoostSetupBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_staking_impl.presentation.view.showRewardEstimation +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class SetupYieldBoostFragment : BaseFragment() { + + override fun createBinding() = FragmentYieldBoostSetupBinding.inflate(layoutInflater) + + override fun applyInsets(rootView: View) { + binder.root.applySystemBarInsets(imeInsets = ImeInsetsState.ENABLE_IF_SUPPORTED) + } + + override fun initViews() { + binder.setupYieldBoostToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.setupYieldBoostContinue.prepareForProgress(viewLifecycleOwner) + binder.setupYieldBoostContinue.setOnClickListener { viewModel.nextClicked() } + + binder.setupYieldBoostCollator.setOnClickListener { viewModel.selectCollatorClicked() } + + binder.setupYieldBoostOn.setOnClickListener { viewModel.yieldBoostStateChanged(yieldBoostOn = true) } + binder.setupYieldBoostOff.setOnClickListener { viewModel.yieldBoostStateChanged(yieldBoostOn = false) } + + binder.setupYieldBoostScrollArea.scrollOnFocusTo(binder.setupYieldBoostThreshold) + + setYieldViewsVisible(false) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .setupYieldBoostComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SetupYieldBoostViewModel) { + observeValidations(viewModel) + setupAmountChooser(viewModel.boostThresholdChooserMixin, binder.setupYieldBoostThreshold) + setupFeeLoading(viewModel.originFeeMixin, binder.setupYieldBoostFee) + + viewModel.selectedCollatorModel.observe { + binder.setupYieldBoostCollator.setSelectedTarget(it) + } + + viewModel.chooseCollatorAction.awaitableActionLiveData.observeEvent { action -> + ChooseStakedStakeTargetsBottomSheet( + context = requireContext(), + payload = action.payload, + stakedCollatorSelected = { _, item -> action.onSuccess(item) }, + onCancel = action.onCancel, + newStakeTargetClicked = null + ).show() + } + + viewModel.configurationUi.observe { state -> + setYieldViewsVisible(state is YieldBoostStateModel.On) + + binder.setupYieldBoostOn.setChecked(state is YieldBoostStateModel.On) + binder.setupYieldBoostOff.setChecked(state is YieldBoostStateModel.Off) + + if (state is YieldBoostStateModel.On) { + binder.setupYieldBoostFrequency.text = state.frequencyTitle + } + } + + viewModel.buttonState.observe(binder.setupYieldBoostContinue::setState) + + viewModel.rewardsWithYieldBoost.observe(binder.setupYieldBoostOn::showRewardEstimation) + viewModel.rewardsWithoutYieldBoost.observe(binder.setupYieldBoostOff::showRewardEstimation) + } + + private fun setYieldViewsVisible(visible: Boolean) { + listOf( + binder.setupYieldBoostFrequency, + binder.setupYieldBoostThreshold, + binder.setupYieldBoostOakLogo + ).onEach { it.isVisible = visible } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/SetupYieldBoostViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/SetupYieldBoostViewModel.kt new file mode 100644 index 0000000..2e43639 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/SetupYieldBoostViewModel.kt @@ -0,0 +1,454 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.setup + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.RetryPayload +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.buildSpannable +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.findById +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.delegationAmountTo +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.SelectedCollatorSorting +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.Collator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.SelectedCollator +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.accountId +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.model.estimatedAprReturns +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostConfiguration +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostParameters +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostTask +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.frequencyInDays +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.RewardSuffix +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.format +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapPeriodReturnsToRewardEstimation +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.collator.select.model.mapCollatorToCollatorParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.collators.collatorAddressModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.selectCollators.mapCollatorToSelectCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.setup.model.SelectCollatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.common.formatDaysFrequency +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.common.yieldBoostValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfigurationParcel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.confirm.model.YieldBoostConfirmPayload +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setAmountInput +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.create +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novasama.substrate_sdk_android.extensions.toHexString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.math.BigDecimal + +sealed class YieldBoostStateModel { + + object Off : YieldBoostStateModel() + + class On(val frequencyTitle: String) : YieldBoostStateModel() +} + +private const val FEE_DEBOUNCE_MILLIS = 500L + +class SetupYieldBoostViewModel( + private val router: ParachainStakingRouter, + private val interactor: YieldBoostInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val assetUseCase: AssetUseCase, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val collatorsUseCase: CollatorsUseCase, + private val validationSystem: YieldBoostValidationSystem, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + amountChooserMixinFactory: AmountChooserMixin.Factory, +) : BaseViewModel(), + Retriable, + Validatable by validationExecutor { + + private val validationInProgressFlow = MutableStateFlow(false) + + private val assetWithOption = assetUseCase.currentAssetAndOptionFlow() + .shareInBackground() + + private val chainFlow = assetWithOption.map { it.option.assetWithChain.chain } + .shareInBackground() + + private val assetFlow = assetWithOption.map { it.asset } + .shareInBackground() + + private val selectedChainAsset = assetFlow.map { it.token.configuration } + .shareInBackground() + + val originFeeMixin = feeLoaderMixinFactory.createDefault( + this, + selectedChainAsset, + FeeLoaderMixinV2.Configuration(onRetryCancelled = ::backClicked) + ) + + override val retryEvent: MutableLiveData> = originFeeMixin.retryEvent + + private val maxActionProvider = maxActionProviderFactory.create( + viewModelScope = viewModelScope, + assetInFlow = assetFlow, + feeLoaderMixin = originFeeMixin, + ) + + val boostThresholdChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = assetFlow, + maxActionProvider = maxActionProvider + ) + + val chooseCollatorAction = actionAwaitableMixinFactory.create, SelectCollatorModel>() + + private val currentDelegatorStateFlow = delegatorStateUseCase.currentDelegatorStateFlow() + .filterIsInstance() + .shareInBackground() + + private val selectedCollatorsFlow = currentDelegatorStateFlow + .mapLatest { collatorsUseCase.getSelectedCollators(it, SelectedCollatorSorting.APR) } + .shareInBackground() + + private val selectedCollatorFlow = MutableSharedFlow(replay = 1) + private val selectedCollatorIdFlow = selectedCollatorFlow.map { it.accountId() } + + val selectedCollatorModel = combine( + selectedCollatorFlow, + currentDelegatorStateFlow, + assetFlow + ) { selectedCollator, currentDelegatorState, asset -> + mapCollatorToSelectCollatorModel(selectedCollator, currentDelegatorState, asset, addressIconGenerator, resourceManager, amountFormatter) + }.shareInBackground() + + val rewardsWithoutYieldBoost = combine(currentDelegatorStateFlow, selectedCollatorFlow) { delegatorState, collator -> + val token = assetFlow.first().token + val delegationPlanks = delegatorState.delegationAmountTo(collator.accountId()).orZero() + val periodReturns = collator.estimatedAprReturns(token.amountFromPlanks(delegationPlanks)) + + mapPeriodReturnsToRewardEstimation( + periodReturns = periodReturns, + token = assetFlow.first().token, + resourceManager = resourceManager, + ) + }.shareInBackground() + + private val optimalYieldBoostParameters = combine(currentDelegatorStateFlow, selectedCollatorIdFlow) { delegatorState, collatorId -> + interactor.optimalYieldBoostParameters(delegatorState, collatorId) + }.shareInBackground() + + val rewardsWithYieldBoost = optimalYieldBoostParameters.map { + mapPeriodReturnsToRewardEstimation( + periodReturns = it.yearlyReturns, + token = assetFlow.first().token, + resourceManager = resourceManager, + ) + }.shareInBackground() + + private val activeTasksFlow = currentDelegatorStateFlow.flatMapLatest(interactor::activeYieldBoostTasks) + .shareInBackground() + + private val activeYieldBoostConfiguration = combine(activeTasksFlow, selectedCollatorFlow, ::constructActiveConfiguration) + .shareInBackground() + + private val modifiedYieldBoostEnabled = MutableStateFlow(false) + + private val modifiedYieldBoostConfiguration = combine( + modifiedYieldBoostEnabled, + boostThresholdChooserMixin.amount, + optimalYieldBoostParameters, + selectedCollatorFlow, + ::constructModifiedConfiguration + ) + .shareInBackground() + + val configurationUi = combine(activeYieldBoostConfiguration, modifiedYieldBoostConfiguration, ::createYieldBoostUiState) + .shareInBackground() + + val buttonState = combine( + activeYieldBoostConfiguration, + modifiedYieldBoostConfiguration, + boostThresholdChooserMixin.amountInput, + validationInProgressFlow + ) { activeConfiguration, modifiedConfiguration, amountInput, validationInProgress -> + when { + validationInProgress -> DescriptiveButtonState.Loading + activeConfiguration == modifiedConfiguration -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_no_changes)) + amountInput.isEmpty() -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_amount)) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + } + .onStart { emit(DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_no_changes))) } + .shareInBackground() + + init { + setInitialCollator() + + updateYieldBoostStateOnCollatorChange() + + listenFee() + } + + fun selectCollatorClicked() = launch { + val delegatorState = currentDelegatorStateFlow.first() + val alreadyStakedCollators = selectedCollatorsFlow.first() + val activeTasks = activeTasksFlow.first() + + val payload = createSelectCollatorPayload(alreadyStakedCollators, delegatorState, activeTasks) + val newSelectedCollatorModel = chooseCollatorAction.awaitAction(payload) + + selectedCollatorFlow.emit(newSelectedCollatorModel.payload) + } + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun yieldBoostStateChanged(yieldBoostOn: Boolean) { + modifiedYieldBoostEnabled.value = yieldBoostOn + } + + private fun updateYieldBoostStateOnCollatorChange() { + activeYieldBoostConfiguration + .distinctUntilChangedBy { it.collatorIdHex } + .onEach { + setActiveAmount(it) + setIsEnabled(it) + } + .inBackground() + .launchIn(this) + } + + @OptIn(FlowPreview::class) + private fun listenFee() { + originFeeMixin.connectWith( + inputSource1 = modifiedYieldBoostConfiguration.debounce(FEE_DEBOUNCE_MILLIS), + feeConstructor = { feePaymentCurrency, config -> + interactor.calculateFee(config, activeTasksFlow.first()) + }, + ) + } + + private fun setIsEnabled(configuration: YieldBoostConfiguration) { + modifiedYieldBoostEnabled.value = configuration is YieldBoostConfiguration.On + } + + private suspend fun setActiveAmount(configuration: YieldBoostConfiguration) { + val newAmount = if (configuration is YieldBoostConfiguration.On) { + assetFlow.first().token.amountFromPlanks(configuration.threshold).format() + } else { + "" + } + + boostThresholdChooserMixin.setAmountInput(newAmount) + } + + private fun createYieldBoostUiState( + activeConfiguration: YieldBoostConfiguration, + modifiedConfiguration: YieldBoostConfiguration + ): YieldBoostStateModel { + return when (modifiedConfiguration) { + is YieldBoostConfiguration.Off -> YieldBoostStateModel.Off + is YieldBoostConfiguration.On -> { + val optionalFrequency = modifiedConfiguration.frequencyInDays + val activeFrequency = activeConfiguration.castOrNull()?.frequencyInDays + + val title = createFrequencyTitle(optionalFrequency, activeFrequency) + + YieldBoostStateModel.On(title) + } + } + } + + private fun createFrequencyTitle(optimalFrequency: Int, currentFrequency: Int?): String { + val optimalFrequencyFormatted = resourceManager.formatDaysFrequency(optimalFrequency) + + return if (currentFrequency != null && currentFrequency != optimalFrequency) { + val currentFrequencyFormatted = resourceManager.formatDaysFrequency(currentFrequency) + + resourceManager.getString(R.string.staking_turing_frequency_update_title, optimalFrequencyFormatted, currentFrequencyFormatted) + } else { + resourceManager.getString(R.string.staking_turing_frequency_new_title, optimalFrequencyFormatted) + } + } + + private suspend fun createSelectCollatorPayload( + stakedCollators: List, + delegatorState: DelegatorState, + activeTasks: List, + ): ChooseStakedStakeTargetsBottomSheet.Payload { + val selectedCollator = selectedCollatorFlow.first() + val chain = delegatorState.chain + + return withContext(Dispatchers.Default) { + val activeTaskCollatorIds = activeTasks.yieldBoostedCollatorIdsSet() + + val collatorModels = stakedCollators.map { + SelectCollatorModel( + addressModel = addressIconGenerator.collatorAddressModel(it.target, chain), + subtitle = selectCollatorSubsTitle( + collator = it.target, + hasActiveYieldBoost = it.target.accountIdHex in activeTaskCollatorIds + ), + active = true, + payload = it.target + ) + } + val selected = collatorModels.findById(selectedCollator) + + ChooseStakedStakeTargetsBottomSheet.Payload(collatorModels, selected) + } + } + + private fun setInitialCollator() = launch(Dispatchers.Default) { + val alreadyStakedCollators = selectedCollatorsFlow.first() + val activeTasks = activeTasksFlow.first() + val yieldBoostedCollatorsSet = activeTasks.yieldBoostedCollatorIdsSet() + + val mostRelevantCollator = alreadyStakedCollators + .firstOrNull { it.target.accountIdHex in yieldBoostedCollatorsSet } + ?: alreadyStakedCollators.first() + + selectedCollatorFlow.emit(mostRelevantCollator.target) + } + + private fun maybeGoToNext() = launch { + validationInProgressFlow.value = true + + val payload = YieldBoostValidationPayload( + collator = selectedCollatorFlow.first(), + configuration = modifiedYieldBoostConfiguration.first(), + fee = originFeeMixin.awaitFee(), + activeTasks = activeTasksFlow.first(), + asset = assetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { yieldBoostValidationFailure(it, resourceManager) }, + progressConsumer = validationInProgressFlow.progressConsumer() + ) { + validationInProgressFlow.value = false + + goToNextStep(fee = it.fee, collator = it.collator, configuration = it.configuration) + } + } + + private fun goToNextStep( + fee: Fee, + configuration: YieldBoostConfiguration, + collator: Collator, + ) = launch { + val payload = withContext(Dispatchers.Default) { + YieldBoostConfirmPayload( + fee = mapFeeToParcel(fee), + configurationParcel = YieldBoostConfigurationParcel(configuration), + collator = mapCollatorToCollatorParcelModel(collator) + ) + } + + router.openConfirmYieldBoost(payload) + } + + private fun constructActiveConfiguration(tasks: List, collator: Collator): YieldBoostConfiguration { + val collatorId = collator.accountId() + val collatorTask = tasks.find { it.collator.contentEquals(collatorId) } + + return if (collatorTask != null) { + YieldBoostConfiguration.On( + threshold = collatorTask.accountMinimum, + frequencyInDays = collatorTask.frequencyInDays() ?: 0, // TODO this creates an invalid state + collatorIdHex = collator.accountIdHex + ) + } else { + YieldBoostConfiguration.Off(collator.accountIdHex) + } + } + + private suspend fun constructModifiedConfiguration( + enabled: Boolean, + threshold: BigDecimal, + optimalParams: YieldBoostParameters, + collator: Collator, + ): YieldBoostConfiguration { + return if (enabled) { + val thresholdPlanks = assetFlow.first().token.planksFromAmount(threshold) + + YieldBoostConfiguration.On( + threshold = thresholdPlanks, + frequencyInDays = optimalParams.periodInDays, + collatorIdHex = collator.accountIdHex + ) + } else { + YieldBoostConfiguration.Off(collator.accountIdHex) + } + } + + private fun selectCollatorSubsTitle(collator: Collator, hasActiveYieldBoost: Boolean): CharSequence { + return buildSpannable(resourceManager) { + val aprText = RewardSuffix.APR.format(resourceManager, collator.apr.orZero()) + + appendColored(aprText, R.color.text_positive) + + if (hasActiveYieldBoost) { + appendColored(", ", R.color.text_positive) + appendColored(resourceManager.getString(R.string.yiled_boost_yield_boosted), R.color.text_secondary) + } + } + } + + private fun List.yieldBoostedCollatorIdsSet(): Set { + return mapToSet { it.collator.toHexString() } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/di/SetupYieldBoostComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/di/SetupYieldBoostComponent.kt new file mode 100644 index 0000000..da517b1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/di/SetupYieldBoostComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.setup.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.setup.SetupYieldBoostFragment + +@Subcomponent( + modules = [ + SetupYieldBoostModule::class + ] +) +@ScreenScope +interface SetupYieldBoostComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): SetupYieldBoostComponent + } + + fun inject(fragment: SetupYieldBoostFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/di/SetupYieldBoostModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/di/SetupYieldBoostModule.kt new file mode 100644 index 0000000..0dcc610 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/parachainStaking/yieldBoost/setup/di/SetupYieldBoostModule.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.setup.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.CollatorsUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.YieldBoostInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.yieldBoost.validations.YieldBoostValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.yieldBoost.setup.SetupYieldBoostViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class SetupYieldBoostModule { + + @Provides + @IntoMap + @ViewModelKey(SetupYieldBoostViewModel::class) + fun provideViewModel( + router: ParachainStakingRouter, + interactor: YieldBoostInteractor, + addressIconGenerator: AddressIconGenerator, + assetUseCase: AssetUseCase, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + maxActionProviderFactory: MaxActionProviderFactory, + delegatorStateUseCase: DelegatorStateUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + collatorsUseCase: CollatorsUseCase, + yieldBoostValidationSystem: YieldBoostValidationSystem, + amountFormatter: AmountFormatter, + ): ViewModel { + return SetupYieldBoostViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + assetUseCase = assetUseCase, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + feeLoaderMixinFactory = feeLoaderMixinFactory, + maxActionProviderFactory = maxActionProviderFactory, + delegatorStateUseCase = delegatorStateUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + collatorsUseCase = collatorsUseCase, + amountChooserMixinFactory = amountChooserMixinFactory, + validationSystem = yieldBoostValidationSystem, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SetupYieldBoostViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SetupYieldBoostViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/ConfirmPayoutFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/ConfirmPayoutFragment.kt new file mode 100644 index 0000000..e27a917 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/ConfirmPayoutFragment.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmPayoutBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.model.ConfirmPayoutPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class ConfirmPayoutFragment : BaseFragment() { + + companion object { + private const val KEY_PAYOUTS = "payouts" + + fun getBundle(payload: ConfirmPayoutPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYOUTS, payload) + } + } + } + + override fun createBinding() = FragmentConfirmPayoutBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmPayoutConfirm.setOnClickListener { viewModel.submitClicked() } + binder.confirmPayoutConfirm.prepareForProgress(viewLifecycleOwner) + + binder.confirmPayoutToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.confirmPayoutExtrinsicInformation.setOnAccountClickedListener { viewModel.accountClicked() } + } + + override fun inject() { + val payload = argument(KEY_PAYOUTS) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmPayoutFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmPayoutViewModel) { + observeRetries(viewModel.partialRetriableMixin) + setupExternalActions(viewModel) + observeValidations(viewModel) + observeRetries(viewModel) + setupFeeLoading(viewModel, binder.confirmPayoutExtrinsicInformation.fee) + + viewModel.initiatorAddressModel.observe(binder.confirmPayoutExtrinsicInformation::setAccount) + viewModel.walletUiFlow.observe(binder.confirmPayoutExtrinsicInformation::setWallet) + + viewModel.totalRewardFlow.observe(binder.confirmPayoutAmount::setAmount) + + viewModel.showNextProgress.observe(binder.confirmPayoutConfirm::setProgressState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/ConfirmPayoutViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/ConfirmPayoutViewModel.kt new file mode 100644 index 0000000..fc28b78 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/ConfirmPayoutViewModel.kt @@ -0,0 +1,168 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.watch.submissionHierarchy +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.payout.PayoutInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.payout.MakePayoutPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.payout.PayoutValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.model.ConfirmPayoutPayload +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.model.mapPendingPayoutParcelToPayout +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmPayoutViewModel( + private val interactor: StakingInteractor, + private val payoutInteractor: PayoutInteractor, + private val router: StakingRouter, + private val payload: ConfirmPayoutPayload, + private val addressModelGenerator: AddressIconGenerator, + private val externalActions: ExternalActions.Presentation, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val validationSystem: ValidationSystem, + private val validationExecutor: ValidationExecutor, + private val resourceManager: ResourceManager, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + walletUiUseCase: WalletUiUseCase, + partialRetriableMixinFactory: PartialRetriableMixin.Factory, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions.Presentation by externalActions, + FeeLoaderMixin by feeLoaderMixin, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val assetFlow = interactor.currentAssetFlow() + .share() + + private val stakingStateFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .share() + + private val payouts = payload.payouts.map(::mapPendingPayoutParcelToPayout) + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + val totalRewardFlow = assetFlow.map { + amountFormatter.formatAmountToAmountModel(payload.totalRewardInPlanks, it) + } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val initiatorAddressModel = stakingStateFlow.map { stakingState -> + addressModelGenerator.createAccountAddressModel(selectedAssetState.chain(), stakingState.accountAddress) + } + .shareInBackground() + + val partialRetriableMixin = partialRetriableMixinFactory.create(scope = this) + + init { + loadFee() + } + + fun accountClicked() { + launch { + val address = initiatorAddressModel.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + } + + fun submitClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + private fun sendTransactionIfValid() = launch { + val asset = assetFlow.first() + val accountAddress = stakingStateFlow.first().accountAddress + val amount = asset.token.configuration.amountFromPlanks(payload.totalRewardInPlanks) + + val makePayoutPayload = MakePayoutPayload( + originAddress = accountAddress, + fee = feeLoaderMixin.awaitFee(), + totalReward = amount, + asset = asset, + payouts = payouts + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = makePayoutPayload, + validationFailureTransformer = ::payloadValidationFailure, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction(makePayoutPayload) + } + } + + private fun sendTransaction(payload: MakePayoutPayload) = launch { + val result = payoutInteractor.makePayouts(payload) + + partialRetriableMixin.handleMultiResult( + multiResult = result, + onSuccess = { + showToast(resourceManager.getString(R.string.make_payout_transaction_sent)) + + startNavigation(it.submissionHierarchy()) { router.returnToStakingMain() } + }, + progressConsumer = _showNextProgress.progressConsumer(), + onRetryCancelled = { router.back() } + ) + } + + private fun loadFee() { + feeLoaderMixin.loadFee( + coroutineScope = viewModelScope, + feeConstructor = { + payoutInteractor.estimatePayoutFee(payouts) + }, + onRetryCancelled = ::backClicked + ) + } + + private fun payloadValidationFailure(reason: PayoutValidationFailure): TitleAndMessage { + val (titleRes, messageRes) = when (reason) { + PayoutValidationFailure.CannotPayFee -> R.string.common_not_enough_funds_title to R.string.common_not_enough_funds_message + PayoutValidationFailure.UnprofitablePayout -> R.string.common_confirmation_title to R.string.staking_warning_tiny_payout + } + + return resourceManager.getString(titleRes) to resourceManager.getString(messageRes) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/di/ConfirmPayoutComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/di/ConfirmPayoutComponent.kt new file mode 100644 index 0000000..89f76ac --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/di/ConfirmPayoutComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.ConfirmPayoutFragment +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.model.ConfirmPayoutPayload + +@Subcomponent( + modules = [ + ConfirmPayoutModule::class + ] +) +@ScreenScope +interface ConfirmPayoutComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmPayoutPayload + ): ConfirmPayoutComponent + } + + fun inject(fragment: ConfirmPayoutFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/di/ConfirmPayoutModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/di/ConfirmPayoutModule.kt new file mode 100644 index 0000000..d31631d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/di/ConfirmPayoutModule.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.multiResult.PartialRetriableMixin +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.payout.PayoutInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.payout.MakePayoutPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.payout.PayoutValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.ConfirmPayoutViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.model.ConfirmPayoutPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmPayoutModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmPayoutViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + payload: ConfirmPayoutPayload, + payoutInteractor: PayoutInteractor, + addressIconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + feeLoaderMixin: FeeLoaderMixin.Presentation, + validationSystem: ValidationSystem, + validationExecutor: ValidationExecutor, + resourceManager: ResourceManager, + singleAssetSharedState: StakingSharedState, + walletUiUseCase: WalletUiUseCase, + partialRetriableMixinFactory: PartialRetriableMixin.Factory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmPayoutViewModel( + interactor = interactor, + payoutInteractor = payoutInteractor, + router = router, + payload = payload, + addressModelGenerator = addressIconGenerator, + externalActions = externalActions, + feeLoaderMixin = feeLoaderMixin, + validationSystem = validationSystem, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + selectedAssetState = singleAssetSharedState, + walletUiUseCase = walletUiUseCase, + partialRetriableMixinFactory = partialRetriableMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ConfirmPayoutViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmPayoutViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/model/ConfirmPayoutPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/model/ConfirmPayoutPayload.kt new file mode 100644 index 0000000..8d43b16 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/confirm/model/ConfirmPayoutPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.model.PendingPayoutParcelable +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +@Parcelize +class ConfirmPayoutPayload( + val payouts: List, + val totalRewardInPlanks: BigInteger +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/PayoutDetailsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/PayoutDetailsFragment.kt new file mode 100644 index 0000000..b0d51c3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/PayoutDetailsFragment.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.detail + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentPayoutDetailsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.model.PendingPayoutParcelable + +class PayoutDetailsFragment : BaseFragment() { + + companion object { + private const val KEY_PAYOUT = "payout" + + fun getBundle(payout: PendingPayoutParcelable): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYOUT, payout) + } + } + } + + override fun createBinding() = FragmentPayoutDetailsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.payoutDetailsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.payoutDetailsSubmit.setOnClickListener { viewModel.payoutClicked() } + + binder.payoutDetailsValidator.setOnClickListener { viewModel.validatorExternalActionClicked() } + } + + override fun inject() { + val payout = argument(KEY_PAYOUT) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .payoutDetailsFactory() + .create(this, payout) + .inject(this) + } + + override fun subscribe(viewModel: PayoutDetailsViewModel) { + setupExternalActions(viewModel) + + viewModel.payoutDetails.observe { + binder.payoutDetailsToolbar.titleView.startTimer(millis = it.timeLeft, millisCalculatedAt = it.timeLeftCalculatedAt) + binder.payoutDetailsToolbar.titleView.setTextColorRes(it.timerColor) + + binder.payoutDetailsEra.showValue(it.eraDisplay) + binder.payoutDetailsValidator.showAddress(it.validatorAddressModel) + + binder.payoutDetailsAmount.setAmount(it.reward) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/PayoutDetailsModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/PayoutDetailsModel.kt new file mode 100644 index 0000000..8fc7af7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/PayoutDetailsModel.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.detail + +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class PayoutDetailsModel( + val validatorAddressModel: AddressModel, + val timeLeft: Long, + val timeLeftCalculatedAt: Long, + @ColorRes val timerColor: Int, + val eraDisplay: String, + val reward: AmountModel, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/PayoutDetailsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/PayoutDetailsViewModel.kt new file mode 100644 index 0000000..ff7a391 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/PayoutDetailsViewModel.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.detail + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.model.ConfirmPayoutPayload +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.model.PendingPayoutParcelable +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class PayoutDetailsViewModel( + private val interactor: StakingInteractor, + private val router: StakingRouter, + private val payout: PendingPayoutParcelable, + private val addressModelGenerator: AddressIconGenerator, + private val externalActions: ExternalActions.Presentation, + private val resourceManager: ResourceManager, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), ExternalActions.Presentation by externalActions { + + val payoutDetails = interactor.currentAssetFlow() + .map(::mapPayoutParcelableToPayoutDetailsModel) + .inBackground() + .asLiveData() + + fun backClicked() { + router.back() + } + + fun payoutClicked() { + val payload = ConfirmPayoutPayload( + totalRewardInPlanks = payout.amountInPlanks, + payouts = listOf(payout) + ) + + router.openConfirmPayout(payload) + } + + fun validatorExternalActionClicked() = launch { + externalActions.showAddressActions(payout.validatorInfo.address, selectedAssetState.chain()) + } + + private suspend fun mapPayoutParcelableToPayoutDetailsModel(asset: Asset): PayoutDetailsModel { + val addressModel = with(payout.validatorInfo) { + addressModelGenerator.createAccountAddressModel(selectedAssetState.chain(), address, identityName) + } + + return PayoutDetailsModel( + validatorAddressModel = addressModel, + timeLeft = payout.timeLeft, + timeLeftCalculatedAt = payout.timeLeftCalculatedAt, + eraDisplay = resourceManager.getString(R.string.staking_era_index_no_prefix, payout.era.toLong()), + reward = amountFormatter.formatAmountToAmountModel(payout.amountInPlanks, asset), + timerColor = if (payout.closeToExpire) R.color.text_negative else R.color.text_primary, + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/di/PayoutDetailsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/di/PayoutDetailsComponent.kt new file mode 100644 index 0000000..a153e6c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/di/PayoutDetailsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.detail.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.detail.PayoutDetailsFragment +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.model.PendingPayoutParcelable + +@Subcomponent( + modules = [ + PayoutDetailsModule::class + ] +) +@ScreenScope +interface PayoutDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payout: PendingPayoutParcelable + ): PayoutDetailsComponent + } + + fun inject(fragment: PayoutDetailsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/di/PayoutDetailsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/di/PayoutDetailsModule.kt new file mode 100644 index 0000000..8da6a6a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/detail/di/PayoutDetailsModule.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.detail.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.detail.PayoutDetailsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.model.PendingPayoutParcelable +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class PayoutDetailsModule { + + @Provides + @IntoMap + @ViewModelKey(PayoutDetailsViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + payout: PendingPayoutParcelable, + addressIconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + resourceManager: ResourceManager, + selectedAssetState: StakingSharedState, + amountFormatter: AmountFormatter + ): ViewModel { + return PayoutDetailsViewModel( + interactor, + router, + payout, + addressIconGenerator, + externalActions, + resourceManager, + selectedAssetState, + amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PayoutDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PayoutDetailsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/PayoutAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/PayoutAdapter.kt new file mode 100644 index 0000000..df22e3b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/PayoutAdapter.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ItemListDefaultBinding +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.model.PendingPayoutModel + +import kotlin.time.ExperimentalTime + +class PayoutAdapter( + private val itemHandler: ItemHandler, +) : ListAdapter(PayoutModelDiffCallback()) { + + interface ItemHandler { + fun payoutClicked(index: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PayoutViewHolder { + return PayoutViewHolder(ItemListDefaultBinding.inflate(parent.inflater(), parent, false)) + } + + @ExperimentalTime + override fun onBindViewHolder(holder: PayoutViewHolder, position: Int) { + val item = getItem(position) + + holder.bind(item, itemHandler) + } +} + +private class PayoutModelDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: PendingPayoutModel, newItem: PendingPayoutModel): Boolean { + return oldItem === newItem + } + + override fun areContentsTheSame(oldItem: PendingPayoutModel, newItem: PendingPayoutModel): Boolean { + return true + } +} + +class PayoutViewHolder(private val binder: ItemListDefaultBinding) : RecyclerView.ViewHolder(binder.root) { + + @ExperimentalTime + fun bind(payout: PendingPayoutModel, itemHandler: PayoutAdapter.ItemHandler) = with(binder) { + with(payout) { + itemListElementDescriptionLeft.startTimer(timeLeft, createdAt) { + it.text = binder.root.context.getText(R.string.staking_payout_expired) + it.setTextColor(binder.root.context.getColor(R.color.text_negative)) + } + + itemListElementTitleLeft.text = validatorTitle + itemListElementTitleRight.text = amount + itemListElementDescriptionRight.text = amountFiat + itemListElementDescriptionLeft.setTextColorRes(daysLeftColor) + } + + binder.root.setOnClickListener { itemHandler.payoutClicked(bindingAdapterPosition) } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/PayoutsListFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/PayoutsListFragment.kt new file mode 100644 index 0000000..17657ae --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/PayoutsListFragment.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.list + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentPayoutsListBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.model.PendingPayoutsStatisticsModel + +class PayoutsListFragment : BaseFragment(), PayoutAdapter.ItemHandler { + + override fun createBinding() = FragmentPayoutsListBinding.inflate(layoutInflater) + + lateinit var adapter: PayoutAdapter + + override fun initViews() { + adapter = PayoutAdapter(this) + binder.payoutsList.adapter = adapter + + binder.payoutsList.setHasFixedSize(true) + + binder.payoutsListToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.payoutsListAll.setOnClickListener { + viewModel.payoutAllClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .payoutsListFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: PayoutsListViewModel) { + viewModel.payoutsStatisticsState.observe { + if (it is LoadingState.Loaded) { + val placeholderVisible = it.data.placeholderVisible + + setContentVisible(!placeholderVisible) + binder.payoutListPlaceholder.setVisible(placeholderVisible) + binder.payoutsListProgress.makeGone() + + adapter.submitList(it.data.payouts) + + binder.payoutsListAll.text = it.data.payoutAllTitle + } + } + + observeRetries(viewModel) + } + + override fun payoutClicked(index: Int) { + viewModel.payoutClicked(index) + } + + private fun setContentVisible(visible: Boolean) { + binder.payoutsList.setVisible(visible) + binder.payoutsListAll.setVisible(visible) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/PayoutsListViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/PayoutsListViewModel.kt new file mode 100644 index 0000000..00785a2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/PayoutsListViewModel.kt @@ -0,0 +1,145 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.list + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.RetryPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.model.PendingPayout +import io.novafoundation.nova.feature_staking_impl.domain.model.PendingPayoutsStatistics +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.confirm.model.ConfirmPayoutPayload +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.model.PendingPayoutModel +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.model.PendingPayoutsStatisticsModel +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.model.PendingPayoutParcelable +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenChange +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class PayoutsListViewModel( + private val router: StakingRouter, + private val resourceManager: ResourceManager, + private val interactor: StakingInteractor, +) : BaseViewModel(), Retriable { + + override val retryEvent: MutableLiveData> = MutableLiveData() + + private val payoutsStatisticsFlow = singleReplaySharedFlow() + + val payoutsStatisticsState = payoutsStatisticsFlow + .map(::convertToUiModel) + .withLoading() + .inBackground() + + init { + loadPayouts() + } + + fun backClicked() { + router.back() + } + + fun payoutAllClicked() { + launch { + val payoutStatistics = payoutsStatisticsFlow.first() + + val payload = ConfirmPayoutPayload( + totalRewardInPlanks = payoutStatistics.totalAmountInPlanks, + payouts = payoutStatistics.payouts.map { mapPayoutToParcelable(it) } + ) + + router.openConfirmPayout(payload) + } + } + + fun payoutClicked(index: Int) { + launch { + val payouts = payoutsStatisticsFlow.first().payouts + val payout = payouts[index] + + val payoutParcelable = mapPayoutToParcelable(payout) + + router.openPayoutDetails(payoutParcelable) + } + } + + private fun loadPayouts() { + launch { + val result = interactor.calculatePendingPayouts(viewModelScope) + + result.onSuccess { value -> + payoutsStatisticsFlow.emit(value) + }.onFailure { exception -> + val errorMessage = exception.message ?: resourceManager.getString(R.string.common_undefined_error_message) + + retryEvent.value = Event( + RetryPayload( + title = resourceManager.getString(R.string.common_error_general_title), + message = errorMessage, + onRetry = ::loadPayouts, + onCancel = ::backClicked + ) + ) + } + } + } + + private suspend fun convertToUiModel( + statistics: PendingPayoutsStatistics, + ): PendingPayoutsStatisticsModel { + val token = interactor.currentAssetFlow().first().token + val totalAmount = token.amountFromPlanks(statistics.totalAmountInPlanks).formatTokenAmount(token.configuration) + + val payouts = statistics.payouts.map { mapPayoutToPayoutModel(token, it) } + + return PendingPayoutsStatisticsModel( + payouts = payouts, + payoutAllTitle = resourceManager.getString(R.string.staking_reward_payouts_payout_all, totalAmount), + placeholderVisible = payouts.isEmpty() + ) + } + + private fun mapPayoutToPayoutModel(token: Token, payout: PendingPayout): PendingPayoutModel { + return with(payout) { + val amount = token.amountFromPlanks(amountInPlanks) + + PendingPayoutModel( + validatorTitle = validatorInfo.identityName ?: validatorInfo.address, + timeLeft = timeLeft, + createdAt = timeLeftCalculatedAt, + daysLeftColor = if (closeToExpire) R.color.text_negative else R.color.text_secondary, + amount = amount.formatTokenChange(token.configuration, isIncome = true), + amountFiat = token.amountToFiat(amount).formatAsCurrency(token.currency) + ) + } + } + + private fun mapPayoutToParcelable(payout: PendingPayout): PendingPayoutParcelable { + return with(payout) { + PendingPayoutParcelable( + validatorInfo = PendingPayoutParcelable.ValidatorInfoParcelable( + address = validatorInfo.address, + identityName = validatorInfo.identityName + ), + era = era, + amountInPlanks = amountInPlanks, + timeLeftCalculatedAt = timeLeftCalculatedAt, + timeLeft = timeLeft, + closeToExpire = closeToExpire, + pagesToClaim = pagesToClaim + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/di/PayoutsListComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/di/PayoutsListComponent.kt new file mode 100644 index 0000000..0d5b759 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/di/PayoutsListComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.PayoutsListFragment + +@Subcomponent( + modules = [ + PayoutsListModule::class + ] +) +@ScreenScope +interface PayoutsListComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): PayoutsListComponent + } + + fun inject(fragment: PayoutsListFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/di/PayoutsListModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/di/PayoutsListModule.kt new file mode 100644 index 0000000..2031194 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/di/PayoutsListModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.PayoutsListViewModel + +@Module(includes = [ViewModelModule::class]) +class PayoutsListModule { + + @Provides + @IntoMap + @ViewModelKey(PayoutsListViewModel::class) + fun provideViewModel( + stakingInteractor: StakingInteractor, + resourceManager: ResourceManager, + router: StakingRouter, + ): ViewModel { + return PayoutsListViewModel( + router = router, + resourceManager = resourceManager, + interactor = stakingInteractor, + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): PayoutsListViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(PayoutsListViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/model/PendingPayoutModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/model/PendingPayoutModel.kt new file mode 100644 index 0000000..99875b3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/list/model/PendingPayoutModel.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.list.model + +import androidx.annotation.ColorRes + +class PendingPayoutsStatisticsModel( + val payouts: List, + val payoutAllTitle: String, + val placeholderVisible: Boolean +) + +class PendingPayoutModel( + val validatorTitle: String, + val timeLeft: Long, + val createdAt: Long, // the timestamp when we counted left time. Without it the timer will restart all over again on scroll of recycler view + @ColorRes val daysLeftColor: Int, + val amount: String, + val amountFiat: String?, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/model/PendingPayoutParcelable.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/model/PendingPayoutParcelable.kt new file mode 100644 index 0000000..89aa58b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/payouts/model/PendingPayoutParcelable.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.payouts.model + +import android.os.Parcelable +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.feature_staking_impl.data.model.Payout +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +@Parcelize +class PendingPayoutParcelable( + val validatorInfo: ValidatorInfoParcelable, + val era: BigInteger, + val amountInPlanks: BigInteger, + val timeLeftCalculatedAt: Long, + val timeLeft: Long, + val closeToExpire: Boolean, + val pagesToClaim: List +) : Parcelable { + @Parcelize + class ValidatorInfoParcelable( + val address: String, + val identityName: String?, + ) : Parcelable +} + +fun mapPendingPayoutParcelToPayout( + parcelPayoutParcelable: PendingPayoutParcelable +): Payout { + return Payout( + validatorStash = parcelPayoutParcelable.validatorInfo.address.toAccountId().intoKey(), + era = parcelPayoutParcelable.era, + amount = parcelPayoutParcelable.amountInPlanks, + pagesToClaim = parcelPayoutParcelable.pagesToClaim + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/RewardPeriodMapping.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/RewardPeriodMapping.kt new file mode 100644 index 0000000..51aa91f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/RewardPeriodMapping.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.period + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriodType +import io.novafoundation.nova.feature_staking_impl.domain.period.getPeriodDays + +fun mapRewardPeriodToString(resourceManager: ResourceManager, rewardPeriod: RewardPeriod): String { + return when (rewardPeriod.type) { + RewardPeriodType.AllTime -> resourceManager.getString(R.string.staking_period_all_short) + RewardPeriodType.Preset.WEEK -> resourceManager.getString(R.string.staking_period_week_short) + RewardPeriodType.Preset.MONTH -> resourceManager.getString(R.string.staking_period_month_short) + RewardPeriodType.Preset.QUARTER -> resourceManager.getString(R.string.staking_period_quarter_short) + RewardPeriodType.Preset.HALF_YEAR -> resourceManager.getString(R.string.staking_period_half_year_short) + RewardPeriodType.Preset.YEAR -> resourceManager.getString(R.string.staking_period_year_short) + RewardPeriodType.Custom -> { + resourceManager.getString(R.string.staking_period_custom_short, rewardPeriod.getPeriodDays()) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/StakingPeriodFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/StakingPeriodFragment.kt new file mode 100644 index 0000000..74c5e5f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/StakingPeriodFragment.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.period + +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope + +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.MaterialDatePicker +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.RangeDateValidator +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentPeriodStakingBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent + +private const val DATE_PICKER_TAG = "datePicker" + +class StakingPeriodFragment : BaseFragment() { + + override fun createBinding() = FragmentPeriodStakingBinding.inflate(layoutInflater) + + override fun initViews() { + binder.stakingPeriodToolbar.setRightActionClickListener { viewModel.onSaveClick() } + binder.stakingPeriodToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.customStakingPeriodStart.setOnClickListener { viewModel.openStartDatePicker() } + binder.customStakingPeriodEnd.setOnClickListener { viewModel.openEndDatePicker() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .stakingPeriodComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: StakingPeriodViewModel) { + binder.stakingPeriodGroup.bindTo(viewModel.selectedPeriod, this.lifecycleScope) + binder.customStakingPeriodAlwaysToday.bindTo(viewModel.endIsToday, this.lifecycleScope, viewModel::endIsAlwaysTodayChanged) + + viewModel.startDatePickerEvent.observeEvent { + startDatePicker(R.string.staking_period_start_date_picker_title, it, ::onStartDateSelected) + } + + viewModel.endDatePickerEvent.observeEvent { + startDatePicker(R.string.staking_period_end_date_picker_title, it, ::onEndDateSelected) + } + + viewModel.startCustomPeriod.observe { + binder.customStakingPeriodStart.showValue(it) + } + + viewModel.showCustomDetails.observe { + binder.customPeriodSettings.isVisible = it + } + + viewModel.endIsToday.observe { + binder.customStakingPeriodEnd.isVisible = !it + } + + viewModel.endCustomPeriod.observe { + binder.customStakingPeriodEnd.showValue(it) + } + + viewModel.saveButtonEnabledState.observe { + binder.stakingPeriodToolbar.setRightActionEnabled(it) + } + } + + private fun startDatePicker(@StringRes title: Int, date: DateRangeWithCurrent, onDateSelected: (Long) -> Unit) { + val calendarConstraints = CalendarConstraints.Builder() + .setEnd(date.end) + .setOpenAt(date.current) + .setValidator(RangeDateValidator(date.start, date.end)) + .build() + + val datePicker = MaterialDatePicker.Builder.datePicker() + .setTitleText(title) + .setSelection(date.current) + .setCalendarConstraints(calendarConstraints) + .build() + + datePicker.addOnPositiveButtonClickListener(onDateSelected) + datePicker.show(childFragmentManager, DATE_PICKER_TAG) + } + + private fun onStartDateSelected(value: Long) { + viewModel.startDateSelected(value) + } + + private fun onEndDateSelected(value: Long) { + viewModel.endDateSelected(value) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/StakingPeriodViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/StakingPeriodViewModel.kt new file mode 100644 index 0000000..07bb2b2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/StakingPeriodViewModel.kt @@ -0,0 +1,200 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.period + +import androidx.annotation.IdRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriod +import io.novafoundation.nova.feature_staking_impl.domain.period.RewardPeriodType +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.util.Date + +private val PERIOD_TYPES_TO_IDS = mapOf( + RewardPeriodType.AllTime to R.id.allTimeStakingPeriod, + RewardPeriodType.Preset.WEEK to R.id.weekStakingPeriod, + RewardPeriodType.Preset.MONTH to R.id.monthStakingPeriod, + RewardPeriodType.Preset.QUARTER to R.id.quarterStakingPeriod, + RewardPeriodType.Preset.HALF_YEAR to R.id.halfYearStakingPeriod, + RewardPeriodType.Preset.YEAR to R.id.yearStakingPeriod, + RewardPeriodType.Custom to R.id.customStakingPeriod +) + +private val PRESETS_BY_IDS = mapOf( + R.id.weekStakingPeriod to RewardPeriodType.Preset.WEEK, + R.id.monthStakingPeriod to RewardPeriodType.Preset.MONTH, + R.id.quarterStakingPeriod to RewardPeriodType.Preset.QUARTER, + R.id.halfYearStakingPeriod to RewardPeriodType.Preset.HALF_YEAR, + R.id.yearStakingPeriod to RewardPeriodType.Preset.YEAR, +) + +data class CustomPeriod( + val start: Long? = null, + val end: Long? = null, + val isEndToday: Boolean = true +) + +class DateRangeWithCurrent( + var current: Long, + var start: Long?, + var end: Long +) + +class StakingPeriodViewModel( + private val stakingRewardPeriodInteractor: StakingRewardPeriodInteractor, + private val stakingSharedState: StakingSharedState, + private val resourceManager: ResourceManager, + private val router: StakingRouter +) : BaseViewModel() { + + private val _startDatePickerEvent = MutableLiveData>() + val startDatePickerEvent: LiveData> = _startDatePickerEvent + + private val _endDatePickerEvent = MutableLiveData>() + val endDatePickerEvent: LiveData> = _endDatePickerEvent + + val selectedPeriod: MutableStateFlow = MutableStateFlow(R.id.allTimeStakingPeriod) + private val _customPeriod: MutableStateFlow = MutableStateFlow(CustomPeriod()) + + val showCustomDetails: Flow = selectedPeriod + .map { it == R.id.customStakingPeriod } + .shareInBackground() + + val startCustomPeriod = _customPeriod + .map { mapMillisToStrDate(it.start) } + .shareInBackground() + + val endCustomPeriod = _customPeriod + .map { mapMillisToStrDate(it.end) } + .shareInBackground() + + val endIsToday = _customPeriod + .map { it.isEndToday } + .shareInBackground() + + val saveButtonEnabledState: Flow = combine(selectedPeriod, _customPeriod) { id, customPeriod -> + val selectedPeriod = mapSelectedPeriod(id, customPeriod) + val currentPeriod = getSelectedRewardPeriod() + selectedPeriod != null && selectedPeriod != currentPeriod + } + .shareInBackground() + + init { + launch { + val rewardPeriod = getSelectedRewardPeriod() + + if (rewardPeriod is RewardPeriod.CustomRange) { + _customPeriod.value = mapCustomPeriodFromEntity(rewardPeriod) + } + + selectedPeriod.value = mapStackingPeriodToIdRes(rewardPeriod) + } + } + + private suspend fun getSelectedRewardPeriod(): RewardPeriod { + val stakingOption = stakingSharedState.selectedOption() + return stakingRewardPeriodInteractor.getRewardPeriod(stakingOption) + } + + fun onSaveClick() { + launch { + val result = mapSelectedPeriod(selectedPeriod.value, _customPeriod.value) + val stakingOption = stakingSharedState.selectedOption() + result?.let { stakingRewardPeriodInteractor.setRewardPeriod(stakingOption, it) } + router.back() + } + } + + fun endIsAlwaysTodayChanged(isAlwaysToday: Boolean) { + val customPeriod = _customPeriod.value + _customPeriod.value = customPeriod.copy(end = null, isEndToday = isAlwaysToday) + } + + fun startDateSelected(value: Long) { + val customPeriod = _customPeriod.value + _customPeriod.value = customPeriod.copy(start = value) + } + + fun endDateSelected(value: Long) { + val customPeriod = _customPeriod.value + _customPeriod.value = customPeriod.copy(end = value) + } + + fun openStartDatePicker() { + val customPeriod = _customPeriod.value + val currentSelectedStartTime = customPeriod.start ?: System.currentTimeMillis() + val endValidPeriod = customPeriod.end ?: System.currentTimeMillis() + val dateRange = DateRangeWithCurrent(currentSelectedStartTime, null, endValidPeriod) + _startDatePickerEvent.value = dateRange.event() + } + + fun openEndDatePicker() { + val customPeriod = _customPeriod.value + val currentSelectedEndTime = customPeriod.end ?: System.currentTimeMillis() + val startValidPeriod = customPeriod.start + val endValidPeriod = System.currentTimeMillis() + val dateRange = DateRangeWithCurrent(currentSelectedEndTime, startValidPeriod, endValidPeriod) + _endDatePickerEvent.value = dateRange.event() + } + + private fun mapStackingPeriodToIdRes(rewardPeriod: RewardPeriod): Int { + return PERIOD_TYPES_TO_IDS.getValue(rewardPeriod.type) + } + + private fun mapSelectedPeriod(@IdRes periodId: Int, customPeriod: CustomPeriod?): RewardPeriod? { + return when { + periodId == R.id.allTimeStakingPeriod -> RewardPeriod.AllTime + periodId == R.id.customStakingPeriod -> mapCustomPeriodToEntity(customPeriod) + PRESETS_BY_IDS.containsKey(periodId) -> { + val type = PRESETS_BY_IDS.getValue(periodId) + RewardPeriod.OffsetFromCurrent(RewardPeriod.getPresetOffset(type), type) + } + else -> null + } + } + + private fun mapCustomPeriodToEntity(customPeriod: CustomPeriod?): RewardPeriod.CustomRange? { + if (customPeriod == null) return null + + val startPeriod = customPeriod.start?.let { Date(it) } ?: return null + + val endPeriod = if (customPeriod.isEndToday) { + null + } else { + val end = customPeriod.end ?: return null + Date(end) + } + + return RewardPeriod.CustomRange(startPeriod, endPeriod) + } + + private fun mapCustomPeriodFromEntity(period: RewardPeriod.CustomRange): CustomPeriod { + val isEndToday = period.end == null + + return CustomPeriod( + start = period.start.time, + end = period.end?.time, + isEndToday = isEndToday + ) + } + + private fun mapMillisToStrDate(millis: Long?): String { + return millis?.let { resourceManager.formatDate(it) } + ?: resourceManager.getString(R.string.staking_period_select_date) + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/di/StakingPeriodComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/di/StakingPeriodComponent.kt new file mode 100644 index 0000000..74026b4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/di/StakingPeriodComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.period.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.period.StakingPeriodFragment + +@Subcomponent( + modules = [ + StakingPeriodModule::class + ] +) +@ScreenScope +interface StakingPeriodComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): StakingPeriodComponent + } + + fun inject(fragment: StakingPeriodFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/di/StakingPeriodModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/di/StakingPeriodModule.kt new file mode 100644 index 0000000..f7ea35c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/period/di/StakingPeriodModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.period.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.period.StakingPeriodViewModel + +@Module(includes = [ViewModelModule::class]) +class StakingPeriodModule { + + @Provides + @IntoMap + @ViewModelKey(StakingPeriodViewModel::class) + fun provideViewModel( + stakingRewardPeriodInteractor: StakingRewardPeriodInteractor, + stakingSharedState: StakingSharedState, + resourceManager: ResourceManager, + stakingRouter: StakingRouter + ): ViewModel { + return StakingPeriodViewModel( + stakingRewardPeriodInteractor, + stakingSharedState, + resourceManager, + stakingRouter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): StakingPeriodViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StakingPeriodViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/common/PoolAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/common/PoolAdapter.kt new file mode 100644 index 0000000..fb20ca6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/common/PoolAdapter.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.common + +import android.view.ViewGroup +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.ImageLoader +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_staking_impl.databinding.ItemPoolBinding + +class PoolAdapter( + private val imageLoader: ImageLoader, + private val itemHandler: ItemHandler +) : ListAdapter(PoolDiffCallback()) { + + interface ItemHandler { + + fun poolInfoClicked(poolItem: PoolRvItem) + + fun poolClicked(poolItem: PoolRvItem) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PoolViewHolder { + return PoolViewHolder(ItemPoolBinding.inflate(parent.inflater(), parent, false), imageLoader, itemHandler) + } + + override fun onBindViewHolder(holder: PoolViewHolder, position: Int) { + val item = getItem(position) + + holder.bind(item) + } +} + +class PoolViewHolder( + private val binder: ItemPoolBinding, + private val imageLoader: ImageLoader, + private val itemHandler: PoolAdapter.ItemHandler +) : RecyclerView.ViewHolder(binder.root) { + + fun bind(poolItem: PoolRvItem) = with(binder) { + itemPoolTitle.text = poolItem.model.title + itemPoolSubtitle.text = poolItem.subtitle + itemPoolMembersCount.text = poolItem.members + itemPoolIcon.setIcon(poolItem.model.icon, imageLoader) + + itemPoolIcon.isInvisible = poolItem.isChecked + itemPoolCheckBox.isVisible = poolItem.isChecked + + itemPoolInfo.setOnClickListener { + itemHandler.poolInfoClicked(poolItem) + } + + binder.root.setOnClickListener { + itemHandler.poolClicked(poolItem) + } + } +} + +class PoolDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: PoolRvItem, newItem: PoolRvItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: PoolRvItem, newItem: PoolRvItem): Boolean { + return oldItem.members == newItem.members && oldItem.isChecked == newItem.isChecked + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/common/PoolRvItem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/common/PoolRvItem.kt new file mode 100644 index 0000000..e8ddccf --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/common/PoolRvItem.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.common + +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayModel +import java.math.BigInteger + +class PoolRvItem( + val id: BigInteger, + val model: PoolDisplayModel, + val subtitle: CharSequence, + val members: CharSequence, + val isChecked: Boolean +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/common/SelectingPoolPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/common/SelectingPoolPayload.kt new file mode 100644 index 0000000..98240cd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/common/SelectingPoolPayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.common + +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +@Parcelize +class SelectingPoolPayload( + val chainId: ChainId, + val assetId: Int, + val stakingType: Chain.Asset.StakingType +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/SearchPoolFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/SearchPoolFragment.kt new file mode 100644 index 0000000..7de3b31 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/SearchPoolFragment.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.searchPool + +import android.os.Bundle +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard +import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard +import io.novafoundation.nova.common.utils.scrollToTopWhenItemsShuffled +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentSearchPoolBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.PoolAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.PoolRvItem +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import javax.inject.Inject + +class SearchPoolFragment : BaseFragment(), PoolAdapter.ItemHandler { + + companion object { + + const val PAYLOAD_KEY = "SearchPoolViewModel.Payload" + + fun getBundle(payload: SelectingPoolPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun createBinding() = FragmentSearchPoolBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + val adapter by lazy { + PoolAdapter(imageLoader, this) + } + + override fun initViews() { + binder.searchPoolToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.searchPoolList.adapter = adapter + binder.searchPoolList.setHasFixedSize(true) + binder.searchPoolList.scrollToTopWhenItemsShuffled(viewLifecycleOwner) + + binder.searchPoolToolbar.searchField.requestFocus() + binder.searchPoolToolbar.searchField.content.showSoftKeyboard() + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .searchPoolComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: SearchPoolViewModel) { + setupExternalActions(viewModel) + observeValidations(viewModel) + + binder.searchPoolToolbar.searchField.content.bindTo(viewModel.query, lifecycleScope) + + viewModel.poolModelsFlow.observe { + binder.searchPoolListHeader.isInvisible = it.isEmpty() + adapter.submitList(it) + } + + viewModel.placeholderFlow.observe { placeholder -> + binder.searchPoolPlaceholder.isVisible = placeholder != null + placeholder?.let { binder.searchPoolPlaceholder.setModel(placeholder) } + } + + viewModel.selectedTitle.observe(binder.searchPoolCount::setText) + } + + override fun poolInfoClicked(poolItem: PoolRvItem) { + viewModel.poolInfoClicked(poolItem) + } + + override fun poolClicked(poolItem: PoolRvItem) { + viewModel.poolClicked(poolItem) + } + + override fun onDestroyView() { + super.onDestroyView() + + binder.searchPoolToolbar.searchField.hideSoftKeyboard() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/SearchPoolViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/SearchPoolViewModel.kt new file mode 100644 index 0000000..13e99b0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/SearchPoolViewModel.kt @@ -0,0 +1,152 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.searchPool + +import android.text.TextUtils +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.PlaceholderModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.createStakingOption +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting.PoolAvailabilityPayload +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting.SearchNominationPoolInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools.asPoolSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.SetupStakingTypeSelectionMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapNominationPoolToPoolRvItem +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.PoolRvItem +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import java.math.BigInteger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SearchPoolViewModel( + private val router: StakingRouter, + private val setupStakingTypeSelectionMixinFactory: SetupStakingTypeSelectionMixinFactory, + private val payload: SelectingPoolPayload, + private val resourceManager: ResourceManager, + private val selectNominationPoolInteractor: SearchNominationPoolInteractor, + private val chainRegistry: ChainRegistry, + private val externalActions: ExternalActions.Presentation, + private val poolDisplayFormatter: PoolDisplayFormatter, + private val validationExecutor: ValidationExecutor, +) : BaseViewModel(), ExternalActions by externalActions, Validatable by validationExecutor { + + val query = MutableStateFlow("") + + private val stakingOption = async(Dispatchers.Default) { + val chainWithAsset = chainRegistry.chainWithAsset(payload.chainId, payload.assetId) + createStakingOption(chainWithAsset, payload.stakingType) + } + + private val setupStakingTypeSelectionMixin = setupStakingTypeSelectionMixinFactory.create(viewModelScope) + + private val editableSelection = setupStakingTypeSelectionMixin.editableSelectionFlow + + private val poolsFlow = flowOfAll { selectNominationPoolInteractor.searchNominationPools(query, stakingOption(), viewModelScope) } + + val poolModelsFlow = combine(poolsFlow, editableSelection) { pools, selection -> + val selectedPool = selection.selection.asPoolSelection()?.pool + convertToModels(pools, selectedPool) + } + .inBackground() + .share() + + val selectedTitle = poolsFlow + .map { resourceManager.getString(R.string.select_custom_pool_active_pools_count, it.size) } + .shareInBackground() + + val placeholderFlow = combine(query, poolModelsFlow) { searchRaw, pools -> + mapToPlaceholderModel(searchRaw, pools) + } + .shareInBackground() + + fun backClicked() { + router.back() + } + + fun poolInfoClicked(poolItem: PoolRvItem) { + launch { + externalActions.showAddressActions( + poolItem.model.address, + stakingOption().chain + ) + } + } + + fun poolClicked(poolItem: PoolRvItem) { + launch { + val pool = getPoolById(poolItem.id) ?: return@launch + + val validationSystem = selectNominationPoolInteractor.getValidationSystem() + val payload = PoolAvailabilityPayload(pool, stakingOption().chain) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { handleSelectPoolValidationFailure(it, resourceManager) }, + ) { + finishSetupPoolFlow(pool) + } + } + } + + private fun finishSetupPoolFlow(pool: NominationPool) { + launch { + setupStakingTypeSelectionMixin.selectNominationPoolAndApply(pool, stakingOption()) + router.finishSetupPoolFlow() + } + } + + private suspend fun convertToModels( + pools: List, + selectedPool: NominationPool? + ): List { + return pools.map { pool -> + mapNominationPoolToPoolRvItem( + chain = stakingOption().chain, + pool = pool, + resourceManager = resourceManager, + poolDisplayFormatter = poolDisplayFormatter, + isChecked = pool.id == selectedPool?.id, + ) + } + } + + private suspend fun getPoolById(id: BigInteger): NominationPool? { + return poolsFlow.first().firstOrNull { it.id.value == id } + } + + private fun mapToPlaceholderModel( + searchRaw: String, + pools: List + ): PlaceholderModel? { + return when { + TextUtils.isEmpty(searchRaw) -> { + PlaceholderModel(resourceManager.getString(R.string.common_search_placeholder_default), R.drawable.ic_placeholder) + } + + pools.isEmpty() -> { + PlaceholderModel(resourceManager.getString(R.string.search_pool_no_pools_found_placeholder), R.drawable.ic_planet_outline) + } + + else -> null + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/SelectPoolValidationFailureUI.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/SelectPoolValidationFailureUI.kt new file mode 100644 index 0000000..4e5886e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/SelectPoolValidationFailureUI.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.searchPool + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting.PoolAvailabilityFailure + +fun handleSelectPoolValidationFailure(error: PoolAvailabilityFailure, resourceManager: ResourceManager): TitleAndMessage { + return when (error) { + PoolAvailabilityFailure.PoolIsFull -> TitleAndMessage( + resourceManager.getString(R.string.pool_full_failure_title), + resourceManager.getString(R.string.pool_full_failure_message) + ) + + PoolAvailabilityFailure.PoolIsClosed -> TitleAndMessage( + resourceManager.getString(R.string.pool_inactive_failure_title), + resourceManager.getString(R.string.pool_inactive_failure_message) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/di/SearchPoolComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/di/SearchPoolComponent.kt new file mode 100644 index 0000000..33a3bbf --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/di/SearchPoolComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.searchPool.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import io.novafoundation.nova.feature_staking_impl.presentation.pools.searchPool.SearchPoolFragment + +@Subcomponent( + modules = [ + SearchPoolModule::class + ] +) +@ScreenScope +interface SearchPoolComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SelectingPoolPayload + ): SearchPoolComponent + } + + fun inject(fragment: SearchPoolFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/di/SearchPoolModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/di/SearchPoolModule.kt new file mode 100644 index 0000000..e3fd466 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/searchPool/di/SearchPoolModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.searchPool.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting.SearchNominationPoolInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.SetupStakingTypeSelectionMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import io.novafoundation.nova.feature_staking_impl.presentation.pools.searchPool.SearchPoolViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SearchPoolModule { + + @Provides + @IntoMap + @ViewModelKey(SearchPoolViewModel::class) + fun provideViewModel( + router: StakingRouter, + setupStakingTypeSelectionMixinFactory: SetupStakingTypeSelectionMixinFactory, + payload: SelectingPoolPayload, + resourceManager: ResourceManager, + selectNominationPoolInteractor: SearchNominationPoolInteractor, + chainRegistry: ChainRegistry, + externalActions: ExternalActions.Presentation, + poolDisplayFormatter: PoolDisplayFormatter, + validationExecutor: ValidationExecutor + ): ViewModel { + return SearchPoolViewModel( + router, + setupStakingTypeSelectionMixinFactory, + payload, + resourceManager, + selectNominationPoolInteractor, + chainRegistry, + externalActions, + poolDisplayFormatter, + validationExecutor + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SearchPoolViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SearchPoolViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/SelectPoolFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/SelectPoolFragment.kt new file mode 100644 index 0000000..7ce6796 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/SelectPoolFragment.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.selectPool + +import android.os.Bundle +import androidx.core.view.isVisible + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.utils.scrollToTopWhenItemsShuffled +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentSelectPoolBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.PoolAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.PoolRvItem +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import javax.inject.Inject + +class SelectPoolFragment : BaseFragment(), PoolAdapter.ItemHandler { + + companion object { + + const val PAYLOAD_KEY = "SelectCustomPoolFragment.Payload" + + fun getBundle(payload: SelectingPoolPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun createBinding() = FragmentSelectPoolBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + val adapter by lazy { + PoolAdapter(imageLoader, this) + } + + override fun initViews() { + binder.selectPoolToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.selectPoolToolbar.addCustomAction(R.drawable.ic_search) { + viewModel.searchClicked() + } + + binder.selectPoolList.adapter = adapter + binder.selectPoolList.setHasFixedSize(true) + binder.selectPoolList.scrollToTopWhenItemsShuffled(viewLifecycleOwner) + + binder.selectPoolRecommendedAction.setOnClickListener { viewModel.selectRecommended() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .selectPoolComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: SelectPoolViewModel) { + setupExternalActions(viewModel) + + viewModel.poolModelsFlow.observe { loadingState -> + loadingState.onLoaded { + adapter.submitList(it) + } + binder.selectPoolProgressBar.isVisible = loadingState.isLoading() + } + + viewModel.selectedTitle.observe(binder.selectPoolCount::setText) + + viewModel.fillWithRecommendedEnabled.observe(binder.selectPoolRecommendedAction::setEnabled) + } + + override fun poolInfoClicked(poolItem: PoolRvItem) { + viewModel.poolInfoClicked(poolItem) + } + + override fun poolClicked(poolItem: PoolRvItem) { + viewModel.poolClicked(poolItem) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/SelectPoolViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/SelectPoolViewModel.kt new file mode 100644 index 0000000..138572d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/SelectPoolViewModel.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.selectPool + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.createStakingOption +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.model.NominationPool +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.recommendation.NominationPoolRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting.SearchNominationPoolInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools.asPoolSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.SetupStakingTypeSelectionMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapNominationPoolToPoolRvItem +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.PoolRvItem +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.math.BigInteger + +class SelectPoolViewModel( + private val router: StakingRouter, + private val nominationPoolRecommenderFactory: NominationPoolRecommenderFactory, + private val setupStakingTypeSelectionMixinFactory: SetupStakingTypeSelectionMixinFactory, + private val payload: SelectingPoolPayload, + private val resourceManager: ResourceManager, + private val selectNominationPoolInteractor: SearchNominationPoolInteractor, + private val chainRegistry: ChainRegistry, + private val externalActions: ExternalActions.Presentation, + private val poolDisplayFormatter: PoolDisplayFormatter, +) : BaseViewModel(), ExternalActions by externalActions { + + private val stakingOption = async(Dispatchers.Default) { + val chainWithAsset = chainRegistry.chainWithAsset(payload.chainId, payload.assetId) + createStakingOption(chainWithAsset, payload.stakingType) + } + + private val setupStakingTypeSelectionMixin = setupStakingTypeSelectionMixinFactory.create(viewModelScope) + + private val editableSelection = setupStakingTypeSelectionMixin.editableSelectionFlow + + private val poolsFlow = flowOf { selectNominationPoolInteractor.getSortedNominationPools(stakingOption(), viewModelScope) } + + private val nominationPoolRecommenderFlow = flowOf { nominationPoolRecommenderFactory.create(stakingOption(), viewModelScope) } + + val poolModelsFlow = combine(poolsFlow, editableSelection) { allPools, selection -> + val selectedPool = selection.selection.asPoolSelection()?.pool + convertToModels(allPools, selectedPool) + } + .withSafeLoading() + .shareInBackground() + + val selectedTitle = poolsFlow + .map { resourceManager.getString(R.string.select_custom_pool_active_pools_count, it.size) } + .shareInBackground() + + val fillWithRecommendedEnabled = combine(nominationPoolRecommenderFlow, editableSelection) { recommender, selection -> + val selectedPool = selection.selection.asPoolSelection()?.pool + val recommendedPool = recommender.recommendedPool() + + recommendedPool != null && recommendedPool.id != selectedPool?.id + } + .share() + + fun backClicked() { + router.back() + } + + fun poolInfoClicked(poolItem: PoolRvItem) { + launch { + externalActions.showAddressActions( + poolItem.model.address, + stakingOption().chain + ) + } + } + + fun poolClicked(poolItem: PoolRvItem) { + launch { + val pool = getPoolById(poolItem.id) ?: return@launch + setupStakingTypeSelectionMixin.selectNominationPoolAndApply(pool, stakingOption()) + router.finishSetupPoolFlow() + } + } + + fun searchClicked() { + router.openSearchPool(payload) + } + + fun selectRecommended() { + launch { + val recommendedPool = nominationPoolRecommenderFlow.first().recommendedPool() ?: return@launch + setupStakingTypeSelectionMixin.selectNominationPoolAndApply(recommendedPool, stakingOption()) + router.finishSetupPoolFlow() + } + } + + private suspend fun convertToModels( + pools: List, + selectedPool: NominationPool? + ): List { + return pools.map { pool -> + mapNominationPoolToPoolRvItem( + chain = stakingOption().chain, + pool = pool, + resourceManager = resourceManager, + poolDisplayFormatter = poolDisplayFormatter, + isChecked = pool.id == selectedPool?.id, + ) + } + } + + private suspend fun getPoolById(id: BigInteger): NominationPool? { + return poolsFlow.first().firstOrNull { it.id.value == id } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/di/SelectPoolComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/di/SelectPoolComponent.kt new file mode 100644 index 0000000..7e54a38 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/di/SelectPoolComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.selectPool.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.pools.selectPool.SelectPoolFragment +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload + +@Subcomponent( + modules = [ + SelectPoolModule::class + ] +) +@ScreenScope +interface SelectPoolComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SelectingPoolPayload + ): SelectPoolComponent + } + + fun inject(fragment: SelectPoolFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/di/SelectPoolModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/di/SelectPoolModule.kt new file mode 100644 index 0000000..1ba339a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/pools/selectPool/di/SelectPoolModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.pools.selectPool.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.pools.recommendation.NominationPoolRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.selecting.SearchNominationPoolInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.SetupStakingTypeSelectionMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import io.novafoundation.nova.feature_staking_impl.presentation.pools.selectPool.SelectPoolViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SelectPoolModule { + + @Provides + @IntoMap + @ViewModelKey(SelectPoolViewModel::class) + fun provideViewModel( + stakingRouter: StakingRouter, + selectNominationPoolInteractor: SearchNominationPoolInteractor, + nominationPoolRecommenderFactory: NominationPoolRecommenderFactory, + setupStakingTypeSelectionMixinFactory: SetupStakingTypeSelectionMixinFactory, + payload: SelectingPoolPayload, + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + poolDisplayFormatter: PoolDisplayFormatter, + externalActions: ExternalActions.Presentation + ): ViewModel { + return SelectPoolViewModel( + stakingRouter, + nominationPoolRecommenderFactory, + setupStakingTypeSelectionMixinFactory, + payload, + resourceManager, + selectNominationPoolInteractor, + chainRegistry, + externalActions, + poolDisplayFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectPoolViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectPoolViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/ValidationFailureMessage.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/ValidationFailureMessage.kt new file mode 100644 index 0000000..1d7a414 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/ValidationFailureMessage.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.BondMoreValidationFailure +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun bondMoreValidationFailure( + reason: BondMoreValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage { + return when (reason) { + BondMoreValidationFailure.NotEnoughToPayFees, BondMoreValidationFailure.NotEnoughStakeable -> { + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.choose_amount_error_too_big) + } + + BondMoreValidationFailure.ZeroBond -> { + resourceManager.getString(R.string.common_error_general_title) to + resourceManager.getString(R.string.common_zero_amount_error) + } + + is BondMoreValidationFailure.NotEnoughFundToStayAboveED -> handleInsufficientBalanceCommission( + reason, + resourceManager + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/ConfirmBondMoreFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/ConfirmBondMoreFragment.kt new file mode 100644 index 0000000..23c9c52 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/ConfirmBondMoreFragment.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmBondMoreBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent + +private const val PAYLOAD_KEY = "PAYLOAD_KEY" + +class ConfirmBondMoreFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: ConfirmBondMorePayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentConfirmBondMoreBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmBondMoreExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.confirmBondMoreToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.confirmBondMoreConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmBondMoreConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + val payload = argument(PAYLOAD_KEY) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmBondMoreFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmBondMoreViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.confirmBondMoreHints) + + viewModel.showNextProgress.observe(binder.confirmBondMoreConfirm::setProgressState) + + viewModel.amountModelFlow.observe(binder.confirmBondMoreAmount::setAmount) + + viewModel.feeStatusFlow.observe(binder.confirmBondMoreExtrinsicInformation::setFeeStatus) + viewModel.walletUiFlow.observe(binder.confirmBondMoreExtrinsicInformation::setWallet) + viewModel.originAddressModelFlow.observe(binder.confirmBondMoreExtrinsicInformation::setAccount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/ConfirmBondMorePayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/ConfirmBondMorePayload.kt new file mode 100644 index 0000000..f13a63e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/ConfirmBondMorePayload.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class ConfirmBondMorePayload( + val amount: BigDecimal, + val fee: FeeParcelModel, + val stashAddress: String, +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/ConfirmBondMoreViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/ConfirmBondMoreViewModel.kt new file mode 100644 index 0000000..8adbbe8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/ConfirmBondMoreViewModel.kt @@ -0,0 +1,147 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.bond.BondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.BondMoreValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.BondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.bondMoreValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapFeeToFeeModel +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmBondMoreViewModel( + private val router: StakingRouter, + interactor: StakingInteractor, + private val bondMoreInteractor: BondMoreInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val iconGenerator: AddressIconGenerator, + private val validationSystem: BondMoreValidationSystem, + private val externalActions: ExternalActions.Presentation, + private val payload: ConfirmBondMorePayload, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + walletUiUseCase: WalletUiUseCase, + hintsMixinFactory: ResourcesHintsMixinFactory, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val decimalFee = mapFeeFromParcel(payload.fee) + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + val hintsMixin = hintsMixinFactory.create( + coroutineScope = this, + hintsRes = listOf(R.string.staking_hint_reward_bond_more_v2_2_0) + ) + + private val stashAssetFlow = interactor.assetFlow(payload.stashAddress) + .shareInBackground() + + private val stakeableAmountFlow = stashAssetFlow.map { + val planks = bondMoreInteractor.stakeableAmount(it) + it.token.amountFromPlanks(planks) + }.shareInBackground() + + val amountModelFlow = stashAssetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.amount, asset) + } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val feeStatusFlow = stashAssetFlow.map { asset -> + val feeModel = mapFeeToFeeModel(decimalFee, asset.token, amountFormatter = amountFormatter) + + FeeStatus.Loaded(feeModel) + } + .shareInBackground() + + val originAddressModelFlow = flowOf { + iconGenerator.createAccountAddressModel(selectedAssetState.chain(), payload.stashAddress) + } + .shareInBackground() + + fun confirmClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + externalActions.showAddressActions(payload.stashAddress, selectedAssetState.chain()) + } + + private fun maybeGoToNext() = launch { + val payload = BondMoreValidationPayload( + stashAddress = payload.stashAddress, + fee = decimalFee, + amount = payload.amount, + stashAsset = stashAssetFlow.first(), + chain = selectedAssetState.chain(), + stakeable = stakeableAmountFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { bondMoreValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction() + } + } + + private fun sendTransaction() = launch { + val token = stashAssetFlow.first().token + val amountInPlanks = token.planksFromAmount(payload.amount) + + bondMoreInteractor.bondMore(payload.stashAddress, amountInPlanks) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { finishFlow() } + }.onFailure { + showError(it) + } + + _showNextProgress.value = false + } + + private fun finishFlow() { + router.returnToStakingMain() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/di/ConfirmBondMoreComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/di/ConfirmBondMoreComponent.kt new file mode 100644 index 0000000..f48fb32 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/di/ConfirmBondMoreComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.ConfirmBondMoreFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.ConfirmBondMorePayload + +@Subcomponent( + modules = [ + ConfirmBondMoreModule::class + ] +) +@ScreenScope +interface ConfirmBondMoreComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmBondMorePayload, + ): ConfirmBondMoreComponent + } + + fun inject(fragment: ConfirmBondMoreFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/di/ConfirmBondMoreModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/di/ConfirmBondMoreModule.kt new file mode 100644 index 0000000..1eb1f0d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/confirm/di/ConfirmBondMoreModule.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.bond.BondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.BondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.ConfirmBondMorePayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.ConfirmBondMoreViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmBondMoreModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmBondMoreViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + bondMoreInteractor: BondMoreInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: BondMoreValidationSystem, + iconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + payload: ConfirmBondMorePayload, + singleAssetSharedState: StakingSharedState, + walletUiUseCase: WalletUiUseCase, + hintsMixinFactory: ResourcesHintsMixinFactory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmBondMoreViewModel( + router = router, + interactor = interactor, + bondMoreInteractor = bondMoreInteractor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + iconGenerator = iconGenerator, + validationSystem = validationSystem, + externalActions = externalActions, + payload = payload, + selectedAssetState = singleAssetSharedState, + walletUiUseCase = walletUiUseCase, + hintsMixinFactory = hintsMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmBondMoreViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmBondMoreViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/SelectBondMoreFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/SelectBondMoreFragment.kt new file mode 100644 index 0000000..ff9b057 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/SelectBondMoreFragment.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentBondMoreBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +private const val PAYLOAD_KEY = "PAYLOAD_KEY" + +class SelectBondMoreFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: SelectBondMorePayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentBondMoreBinding.inflate(layoutInflater) + + override fun initViews() { + binder.bondMoreToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.bondMoreContinue.prepareForProgress(viewLifecycleOwner) + binder.bondMoreContinue.setOnClickListener { viewModel.nextClicked() } + } + + override fun inject() { + val payload = argument(PAYLOAD_KEY) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .selectBondMoreFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: SelectBondMoreViewModel) { + observeValidations(viewModel) + setupAmountChooser(viewModel.amountChooserMixin, binder.bondMoreAmount) + setupFeeLoading(viewModel.originFeeMixin, binder.bondMoreFee) + observeHints(viewModel.hintsMixin, binder.bondMoreHints) + + viewModel.showNextProgress.observe(binder.bondMoreContinue::setProgressState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/SelectBondMorePayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/SelectBondMorePayload.kt new file mode 100644 index 0000000..ee68f3b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/SelectBondMorePayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class SelectBondMorePayload : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/SelectBondMoreViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/SelectBondMoreViewModel.kt new file mode 100644 index 0000000..98f5ea2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/SelectBondMoreViewModel.kt @@ -0,0 +1,152 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.bond.BondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.BondMoreValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.BondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.bondMoreValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.confirm.ConfirmBondMorePayload +import io.novafoundation.nova.feature_wallet_api.domain.model.decimalAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SelectBondMoreViewModel( + private val router: StakingRouter, + private val interactor: StakingInteractor, + private val bondMoreInteractor: BondMoreInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: BondMoreValidationSystem, + private val payload: SelectBondMorePayload, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + hintsMixinFactory: ResourcesHintsMixinFactory, +) : BaseViewModel(), + Validatable by validationExecutor { + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + private val accountStakingFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .inBackground() + .share() + + private val assetFlow = accountStakingFlow + .flatMapLatest { interactor.assetFlow(it.stashAddress) } + .inBackground() + .share() + + private val stakeableBalance = assetFlow.map { + val amount = bondMoreInteractor.stakeableAmount(it) + it.token.configuration.withAmount(amount) + }.shareInBackground() + + private val selectedChainAsset = assetFlow.map { it.token.configuration } + .shareInBackground() + + val originFeeMixin = feeLoaderMixinFactory.createDefault( + this, + selectedChainAsset + ) + + private val maxActionProvider = maxActionProviderFactory.createCustom(viewModelScope) { + stakeableBalance.asMaxAmountProvider() + .deductFee(originFeeMixin) + } + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = assetFlow, + maxActionProvider = maxActionProvider + ) + + val hintsMixin = hintsMixinFactory.create( + coroutineScope = this, + hintsRes = listOf(R.string.staking_hint_reward_bond_more_v2_2_0) + ) + + init { + listenFee() + } + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + private fun listenFee() { + originFeeMixin.connectWith( + inputSource1 = amountChooserMixin.backPressuredPlanks, + feeConstructor = { _, amount -> + bondMoreInteractor.estimateFee(amount, accountStakingFlow.first()) + } + ) + } + + private fun maybeGoToNext() = launch { + _showNextProgress.value = true + + val payload = BondMoreValidationPayload( + stashAddress = stashAddress(), + fee = originFeeMixin.awaitFee(), + amount = amountChooserMixin.amount.first(), + stashAsset = assetFlow.first(), + chain = accountStakingFlow.first().chain, + stakeable = stakeableBalance.first().decimalAmount + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { bondMoreValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + _showNextProgress.value = false + + openConfirm(payload) + } + } + + private fun openConfirm(validationPayload: BondMoreValidationPayload) { + val confirmPayload = ConfirmBondMorePayload( + amount = validationPayload.amount, + fee = mapFeeToParcel(validationPayload.fee), + stashAddress = validationPayload.stashAddress, + ) + + router.openConfirmBondMore(confirmPayload) + } + + private suspend fun stashAddress() = accountStakingFlow.first().stashAddress +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/di/SelectBondMoreComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/di/SelectBondMoreComponent.kt new file mode 100644 index 0000000..b83e16e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/di/SelectBondMoreComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select.SelectBondMoreFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select.SelectBondMorePayload + +@Subcomponent( + modules = [ + SelectBondMoreModule::class + ] +) +@ScreenScope +interface SelectBondMoreComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SelectBondMorePayload + ): SelectBondMoreComponent + } + + fun inject(fragment: SelectBondMoreFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/di/SelectBondMoreModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/di/SelectBondMoreModule.kt new file mode 100644 index 0000000..d3778bc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/bond/select/di/SelectBondMoreModule.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.bond.BondMoreInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.bond.BondMoreValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select.SelectBondMorePayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.bond.select.SelectBondMoreViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class SelectBondMoreModule { + + @Provides + @IntoMap + @ViewModelKey(SelectBondMoreViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + bondMoreInteractor: BondMoreInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: BondMoreValidationSystem, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + maxActionProviderFactory: MaxActionProviderFactory, + payload: SelectBondMorePayload, + amountChooserMixinFactory: AmountChooserMixin.Factory, + resourcesHintsMixinFactory: ResourcesHintsMixinFactory, + amountFormatter: AmountFormatter, + ): ViewModel { + return SelectBondMoreViewModel( + router = router, + interactor = interactor, + bondMoreInteractor = bondMoreInteractor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + feeLoaderMixinFactory = feeLoaderMixinFactory, + maxActionProviderFactory = maxActionProviderFactory, + payload = payload, + amountChooserMixinFactory = amountChooserMixinFactory, + hintsMixinFactory = resourcesHintsMixinFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectBondMoreViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectBondMoreViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/ConfirmSetControllerFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/ConfirmSetControllerFragment.kt new file mode 100644 index 0000000..52584a5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/ConfirmSetControllerFragment.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmSetControllerBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent + +private const val PAYLOAD_KEY = "PAYLOAD_KEY" + +class ConfirmSetControllerFragment : BaseFragment() { + + companion object { + fun getBundle(payload: ConfirmSetControllerPayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentConfirmSetControllerBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmSetControllerToolbar.setHomeButtonListener { viewModel.back() } + + binder.confirmSetControllerConfirm.setOnClickListener { viewModel.confirmClicked() } + binder.confirmSetControllerConfirm.prepareForProgress(viewLifecycleOwner) + + binder.confirmSetControllerExtrinsicInformation.setOnAccountClickedListener { viewModel.stashClicked() } + + binder.confirmSetControllerController.setOnClickListener { viewModel.controllerClicked() } + } + + override fun inject() { + val payload = argument(PAYLOAD_KEY) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmSetControllerFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmSetControllerViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + + viewModel.walletUiFlow.observe(binder.confirmSetControllerExtrinsicInformation::setWallet) + viewModel.stashAddressFlow.observe(binder.confirmSetControllerExtrinsicInformation::setAccount) + viewModel.feeStatusFlow.observe(binder.confirmSetControllerExtrinsicInformation::setFeeStatus) + + viewModel.controllerAddressLiveData.observe(binder.confirmSetControllerController::showAddress) + + viewModel.submittingInProgress.observe(binder.confirmSetControllerConfirm::setProgressState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/ConfirmSetControllerPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/ConfirmSetControllerPayload.kt new file mode 100644 index 0000000..e268745 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/ConfirmSetControllerPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class ConfirmSetControllerPayload( + val fee: FeeParcelModel, + val stashAddress: String, + val controllerAddress: String, + val transferable: BigDecimal +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/ConfirmSetControllerViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/ConfirmSetControllerViewModel.kt new file mode 100644 index 0000000..472d317 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/ConfirmSetControllerViewModel.kt @@ -0,0 +1,133 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.controller.ControllerInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.set.bondSetControllerValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapFeeToFeeModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmSetControllerViewModel( + private val router: StakingRouter, + private val controllerInteractor: ControllerInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val payload: ConfirmSetControllerPayload, + private val interactor: StakingInteractor, + private val resourceManager: ResourceManager, + private val externalActions: ExternalActions.Presentation, + private val validationExecutor: ValidationExecutor, + private val validationSystem: SetControllerValidationSystem, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter, + walletUiUseCase: WalletUiUseCase, +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val decimalFee = mapFeeFromParcel(payload.fee) + + private val assetFlow = interactor.assetFlow(payload.stashAddress) + .inBackground() + .share() + + val feeStatusFlow = assetFlow.map { asset -> + val feeModel = mapFeeToFeeModel(decimalFee, asset.token, amountFormatter = amountFormatter) + + FeeStatus.Loaded(feeModel) + } + .shareInBackground() + + val stashAddressFlow = flowOf { + generateIcon(payload.stashAddress) + }.shareInBackground() + + val controllerAddressLiveData = flowOf { + generateIcon(payload.controllerAddress) + }.shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val submittingInProgress = MutableStateFlow(false) + + fun confirmClicked() { + maybeConfirm() + } + + fun stashClicked() { + showExternalActions(payload.stashAddress) + } + + fun controllerClicked() { + showExternalActions(payload.controllerAddress) + } + + fun back() { + router.back() + } + + private fun showExternalActions(address: String) = launch { + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + private fun maybeConfirm() = launch { + val payload = SetControllerValidationPayload( + stashAddress = payload.stashAddress, + controllerAddress = payload.controllerAddress, + fee = decimalFee, + transferable = payload.transferable + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + progressConsumer = submittingInProgress.progressConsumer(), + validationFailureTransformer = { bondSetControllerValidationFailure(it, resourceManager) } + ) { + sendTransaction() + } + } + + private fun sendTransaction() = launch { + controllerInteractor.setController( + stashAccountAddress = payload.stashAddress, + controllerAccountAddress = payload.controllerAddress + ).onSuccess { + showToast(resourceManager.getString(R.string.staking_controller_change_success)) + + router.returnToStakingMain() + } + + submittingInProgress.value = false + } + + private suspend fun generateIcon(address: String) = addressIconGenerator.createAccountAddressModel( + chain = selectedAssetState.chain(), + address = address, + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/di/ConfirmSetControllerComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/di/ConfirmSetControllerComponent.kt new file mode 100644 index 0000000..2ab54de --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/di/ConfirmSetControllerComponent.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.ConfirmSetControllerFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.ConfirmSetControllerPayload + +@Subcomponent( + modules = [ + ConfirmSetControllerModule::class + ] +) +@ScreenScope +interface ConfirmSetControllerComponent { + + @Subcomponent.Factory + interface Factory { + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmSetControllerPayload, + ): ConfirmSetControllerComponent + } + + fun inject(fragment: ConfirmSetControllerFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/di/ConfirmSetControllerModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/di/ConfirmSetControllerModule.kt new file mode 100644 index 0000000..06c40d3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/confirm/di/ConfirmSetControllerModule.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.controller.ControllerInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.ConfirmSetControllerPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.ConfirmSetControllerViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmSetControllerModule { + @Provides + @IntoMap + @ViewModelKey(ConfirmSetControllerViewModel::class) + fun provideViewModule( + router: StakingRouter, + controllerInteractor: ControllerInteractor, + addressIconGenerator: AddressIconGenerator, + payload: ConfirmSetControllerPayload, + interactor: StakingInteractor, + resourceManager: ResourceManager, + externalActions: ExternalActions.Presentation, + validationExecutor: ValidationExecutor, + validationSystem: SetControllerValidationSystem, + singleAssetSharedState: StakingSharedState, + walletUiUseCase: WalletUiUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmSetControllerViewModel( + router = router, + controllerInteractor = controllerInteractor, + addressIconGenerator = addressIconGenerator, + payload = payload, + interactor = interactor, + resourceManager = resourceManager, + externalActions = externalActions, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + selectedAssetState = singleAssetSharedState, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmSetControllerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmSetControllerViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/SetControllerFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/SetControllerFragment.kt new file mode 100644 index 0000000..5fe702d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/SetControllerFragment.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.set + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.presenatation.account.chooser.AccountChooserBottomSheetDialog +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentSetControllerAccountBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent + +class SetControllerFragment : BaseFragment() { + + override fun createBinding() = FragmentSetControllerAccountBinding.inflate(layoutInflater) + + override fun initViews() { + binder.setControllerContinue.setOnClickListener { viewModel.continueClicked() } + binder.setControllerContinue.prepareForProgress(viewLifecycleOwner) + + binder.setControllerStash.setOnClickListener { viewModel.stashClicked() } + binder.setControllerController.setOnClickListener { viewModel.controllerClicked() } + + binder.setControllerAdvertisement.setOnLearnMoreClickedListener { viewModel.onMoreClicked() } + binder.setControllerToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.setControllerController.setActionTint(R.color.icon_secondary) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .setControllerFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SetControllerViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + + viewModel.stashAccountModel.observe(binder.setControllerStash::setAddressModel) + viewModel.controllerAccountModel.observe(binder.setControllerController::setAddressModel) + + viewModel.chooseControllerAction.awaitableActionLiveData.observeEvent { + AccountChooserBottomSheetDialog( + context = requireContext(), + payload = it.payload, + onSuccess = { _, item -> it.onSuccess(item) }, + onCancel = it.onCancel, + title = R.string.staking_controller_account + ).show() + } + + viewModel.isControllerSelectorEnabled.observe { ableToChangeController -> + val controllerViewAction = R.drawable.ic_chevron_down.takeIf { ableToChangeController } + binder.setControllerController.setActionIcon(controllerViewAction) + binder.setControllerController.isEnabled = ableToChangeController + } + + viewModel.showSwitchToStashWarning.observe(binder.setControllerSwitchToStashWarning::setVisible) + + viewModel.advertisementCardModel.observe(binder.setControllerAdvertisement::setModel) + + viewModel.continueButtonState.observe(binder.setControllerContinue::setState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/SetControllerViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/SetControllerViewModel.kt new file mode 100644 index 0000000..8a6b5ac --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/SetControllerViewModel.kt @@ -0,0 +1,299 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.set + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.selectingOneOf +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.mediatorLiveData +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.utils.updateFrom +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.common.view.AdvertisementCardModel +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_staking_api.domain.model.StakingAccount +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.accountIsStash +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.repository.ControllersDeprecationStage +import io.novafoundation.nova.feature_staking_impl.data.repository.ControllersDeprecationStage.DEPRECATED +import io.novafoundation.nova.feature_staking_impl.data.repository.ControllersDeprecationStage.NORMAL +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.controller.ControllerInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.confirm.ConfirmSetControllerPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class SetControllerViewModel( + private val interactor: ControllerInteractor, + private val stakingInteractor: StakingInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val router: StakingRouter, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val externalActions: ExternalActions.Presentation, + private val appLinksProvider: AppLinksProvider, + private val resourceManager: ResourceManager, + private val addressDisplayUseCase: AddressDisplayUseCase, + private val validationExecutor: ValidationExecutor, + private val validationSystem: SetControllerValidationSystem, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory +) : BaseViewModel(), + FeeLoaderMixin by feeLoaderMixin, + ExternalActions by externalActions, + Validatable by validationExecutor { + + private val accountStakingFlow = stakingInteractor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .shareInBackground() + + private val controllerDeprecationStageFlow = flowOf { interactor.controllerDeprecationStage() } + .shareInBackground() + + val stashAccountModel = accountStakingFlow.map { + createAccountAddressModel(it.stashAddress) + }.shareInBackground() + + private val assetFlow = stakingInteractor.currentAssetFlow() + .shareInBackground() + + private val _controllerAccountModel = singleReplaySharedFlow() + val controllerAccountModel: Flow = _controllerAccountModel + + override val openBrowserEvent = mediatorLiveData { + updateFrom(externalActions.openBrowserEvent) + } + + val chooseControllerAction = actionAwaitableMixinFactory.selectingOneOf() + + private val validationInProgress = MutableStateFlow(false) + + val isControllerSelectorEnabled = combine(accountStakingFlow, controllerDeprecationStageFlow) { stakingState, controllerDeprecationStage -> + stakingState.isUsingCorrectAccountToChangeController() && controllerDeprecationStage == NORMAL + } + .share() + + val showSwitchToStashWarning = combine(controllerDeprecationStageFlow, accountStakingFlow) { controllerDeprecationStage, stakingState -> + val usingWrongAccountToChangeController = stakingState.isUsingWrongAccountToChangeController() + + when (controllerDeprecationStage) { + NORMAL -> usingWrongAccountToChangeController + + // when controllers are deprecated, there is no point to show the switch to stash warning if user has not separate controller + // switching to stash wont allow to change controller anyway + DEPRECATED -> usingWrongAccountToChangeController && stakingState.hasSeparateController() + } + } + .shareInBackground() + + val continueButtonState = combine( + controllerAccountModel, + accountStakingFlow, + controllerDeprecationStageFlow, + validationInProgress + ) { selectedController, stakingState, controllerDeprecationStage, validationInProgress -> + when { + validationInProgress -> DescriptiveButtonState.Loading + + stakingState.isUsingWrongAccountToChangeController() -> DescriptiveButtonState.Gone + + // Let user to remove controller when controllers are deprecated + controllerDeprecationStage == DEPRECATED && stakingState.hasSeparateController() -> { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.staking_set_controller_deprecated_action)) + } + + // User cant do anything beneficial when controllers are deprecated and user doesn't have controller set up + controllerDeprecationStage == DEPRECATED && stakingState.hasSeparateController().not() -> { + DescriptiveButtonState.Gone + } + + // The user selected account that was not the controller already + selectedController.address != stakingState.controllerAddress -> { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + + else -> DescriptiveButtonState.Gone + } + } + .shareInBackground() + + val advertisementCardModel = controllerDeprecationStageFlow.map(::createBannerContent) + .shareInBackground() + + init { + loadFee() + + setInitialController() + } + + fun onMoreClicked() = launch { + openBrowserEvent.value = Event(getLearnMoreUrl()) + } + + fun stashClicked() { + viewModelScope.launch { + externalActions.showAddressActions(stashAddress(), selectedAssetState.chain()) + } + } + + fun controllerClicked() = launch { + val accountsInNetwork = accountsInCurrentNetwork() + val currentController = controllerAccountModel.first() + val payload = DynamicListBottomSheet.Payload(accountsInNetwork, currentController) + + val newController = chooseControllerAction.awaitAction(payload) + + _controllerAccountModel.emit(newController) + } + + private fun setInitialController() = launch { + val initialController = createAccountAddressModel(controllerAddress()) + + _controllerAccountModel.emit(initialController) + } + + private fun loadFee() { + feeLoaderMixin.loadFee( + coroutineScope = viewModelScope, + feeConstructor = { interactor.estimateFee(controllerAddress(), accountStakingFlow.first()) }, + onRetryCancelled = ::backClicked + ) + } + + fun backClicked() { + router.back() + } + + fun continueClicked() { + maybeGoToConfirm() + } + + private suspend fun accountsInCurrentNetwork(): List { + return stakingInteractor.getAccountProjectionsInSelectedChains() + .map { addressModelForStakingAccount(it) } + } + + private suspend fun stashAddress() = accountStakingFlow.first().stashAddress + + private suspend fun controllerAddress() = accountStakingFlow.first().controllerAddress + + private fun maybeGoToConfirm() = launch { + validationInProgress.value = true + + val controllerAddress = getNewControllerAddress() + + val payload = SetControllerValidationPayload( + stashAddress = stashAddress(), + controllerAddress = controllerAddress, + fee = feeLoaderMixin.awaitFee(), + transferable = assetFlow.first().transferable + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + progressConsumer = validationInProgress.progressConsumer(), + validationFailureTransformer = { bondSetControllerValidationFailure(it, resourceManager) } + ) { + validationInProgress.value = false + + openConfirm( + ConfirmSetControllerPayload( + fee = mapFeeToParcel(it.fee), + stashAddress = payload.stashAddress, + controllerAddress = payload.controllerAddress, + transferable = payload.transferable + ) + ) + } + } + + private suspend fun addressModelForStakingAccount(account: StakingAccount): AddressModel { + return addressIconGenerator.createAccountAddressModel( + chain = selectedAssetState.chain(), + address = account.address, + name = account.name + ) + } + + private suspend fun createAccountAddressModel(address: String): AddressModel { + return addressIconGenerator.createAccountAddressModel( + chain = selectedAssetState.chain(), + address = address, + addressDisplayUseCase = addressDisplayUseCase + ) + } + + private fun openConfirm(payload: ConfirmSetControllerPayload) { + router.openConfirmSetController(payload) + } + + private fun StakingState.Stash.isUsingCorrectAccountToChangeController(): Boolean { + return accountIsStash() + } + + private fun StakingState.Stash.isUsingWrongAccountToChangeController(): Boolean { + return !isUsingCorrectAccountToChangeController() + } + + private fun StakingState.Stash.hasSeparateController(): Boolean { + return !controllerId.contentEquals(stashId) + } + + private fun createBannerContent(deprecationStage: ControllersDeprecationStage): AdvertisementCardModel { + return when (deprecationStage) { + NORMAL -> AdvertisementCardModel( + title = resourceManager.getString(R.string.staking_set_controller_title), + subtitle = resourceManager.getString(R.string.staking_set_controller_subtitle), + imageRes = R.drawable.shield, + bannerBackgroundRes = R.drawable.ic_banner_grey_gradient + ) + + DEPRECATED -> AdvertisementCardModel( + title = resourceManager.getString(R.string.staking_set_controller_deprecated_title), + subtitle = resourceManager.getString(R.string.staking_set_controller_deprecated_subtitle), + imageRes = R.drawable.ic_banner_warning, + bannerBackgroundRes = R.drawable.ic_banner_yellow_gradient + ) + } + } + + private suspend fun getNewControllerAddress(): String { + return when (controllerDeprecationStageFlow.first()) { + NORMAL -> controllerAccountModel.first().address + DEPRECATED -> accountStakingFlow.first().stashAddress + } + } + + private suspend fun getLearnMoreUrl(): String { + return when (controllerDeprecationStageFlow.first()) { + NORMAL -> appLinksProvider.setControllerLearnMore + DEPRECATED -> appLinksProvider.setControllerDeprecatedLeanMore + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/ValidationFailureMessage.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/ValidationFailureMessage.kt new file mode 100644 index 0000000..3b0c385 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/ValidationFailureMessage.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.set + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationFailure + +fun bondSetControllerValidationFailure( + reason: SetControllerValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage { + return when (reason) { + SetControllerValidationFailure.ALREADY_CONTROLLER -> { + resourceManager.getString(R.string.staking_already_controller_title) to + resourceManager.getString(R.string.staking_account_is_used_as_controller) + } + SetControllerValidationFailure.NOT_ENOUGH_TO_PAY_FEES -> { + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.common_not_enough_funds_message) + } + SetControllerValidationFailure.ZERO_CONTROLLER_BALANCE -> { + resourceManager.getString(R.string.common_confirmation_title) to + resourceManager.getString(R.string.staking_controller_account_zero_balance) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/di/SetControllerComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/di/SetControllerComponent.kt new file mode 100644 index 0000000..3998902 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/di/SetControllerComponent.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.set.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.set.SetControllerFragment + +@Subcomponent( + modules = [ + SetControllerModule::class + ] +) +@ScreenScope +interface SetControllerComponent { + + @Subcomponent.Factory + interface Factory { + fun create(@BindsInstance fragment: Fragment): SetControllerComponent + } + + fun inject(fragment: SetControllerFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/di/SetControllerModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/di/SetControllerModule.kt new file mode 100644 index 0000000..f8e9396 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/controller/set/di/SetControllerModule.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.set.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.controller.ControllerInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.SetControllerValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.controller.set.SetControllerViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin + +@Module(includes = [ViewModelModule::class]) +class SetControllerModule { + @Provides + @IntoMap + @ViewModelKey(SetControllerViewModel::class) + fun provideViewModel( + interactor: ControllerInteractor, + stakingInteractor: StakingInteractor, + addressIconGenerator: AddressIconGenerator, + router: StakingRouter, + feeLoaderMixin: FeeLoaderMixin.Presentation, + externalActions: ExternalActions.Presentation, + appLinksProvider: AppLinksProvider, + resourceManager: ResourceManager, + addressDisplayUseCase: AddressDisplayUseCase, + validationExecutor: ValidationExecutor, + validationSystem: SetControllerValidationSystem, + selectedAssetState: StakingSharedState, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + ): ViewModel { + return SetControllerViewModel( + interactor = interactor, + stakingInteractor = stakingInteractor, + addressIconGenerator = addressIconGenerator, + router = router, + feeLoaderMixin = feeLoaderMixin, + externalActions = externalActions, + appLinksProvider = appLinksProvider, + resourceManager = resourceManager, + addressDisplayUseCase = addressDisplayUseCase, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + selectedAssetState = selectedAssetState, + actionAwaitableMixinFactory = actionAwaitableMixinFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SetControllerViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SetControllerViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/ConfirmAddStakingProxyFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/ConfirmAddStakingProxyFragment.kt new file mode 100644 index 0000000..2201efe --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/ConfirmAddStakingProxyFragment.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmAddStakingProxyBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class ConfirmAddStakingProxyFragment : BaseFragment() { + + companion object { + + private const val PAYLOAD_KEY = "PAYLOAD_KEY" + + fun getBundle(payload: ConfirmAddStakingProxyPayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentConfirmAddStakingProxyBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmAddStakingProxyToolbar.setHomeButtonListener { viewModel.back() } + + binder.confirmAddStakingProxyButton.setOnClickListener { viewModel.confirmClicked() } + binder.confirmAddStakingProxyButton.prepareForProgress(viewLifecycleOwner) + + binder.confirmAddStakingProxyProxiedAccount.setOnClickListener { viewModel.proxiedAccountClicked() } + binder.confirmAddStakingProxyDeposit.setOnClickListener { viewModel.depositClicked() } + binder.confirmAddStakingProxyDelegationAccount.setOnClickListener { viewModel.proxyAccountClicked() } + } + + override fun inject() { + val payload = argument(PAYLOAD_KEY) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmAddStakingProxyFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmAddStakingProxyViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeDescription(viewModel) + + viewModel.chainModel.observe { binder.confirmAddStakingProxyNetwork.showChain(it) } + viewModel.walletUiFlow.observe { binder.confirmAddStakingProxyWallet.showWallet(it) } + viewModel.proxiedAccountModel.observe { binder.confirmAddStakingProxyProxiedAccount.showAddress(it) } + viewModel.proxyDeposit.observe { binder.confirmAddStakingProxyDeposit.showAmount(it) } + viewModel.feeModelFlow.observe { binder.confirmAddStakingProxyNetworkFee.showAmount(it) } + viewModel.proxyAccountModel.observe { binder.confirmAddStakingProxyDelegationAccount.showAddress(it) } + + viewModel.validationProgressFlow.observe(binder.confirmAddStakingProxyButton::setProgressState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/ConfirmAddStakingProxyPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/ConfirmAddStakingProxyPayload.kt new file mode 100644 index 0000000..898b38c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/ConfirmAddStakingProxyPayload.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize + +@Parcelize +class ConfirmAddStakingProxyPayload( + val fee: FeeParcelModel, + val proxyAddress: String, + val deltaDeposit: Balance, + val currentQuantity: Int +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/ConfirmAddStakingProxyViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/ConfirmAddStakingProxyViewModel.kt new file mode 100644 index 0000000..b7b2d0d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/ConfirmAddStakingProxyViewModel.kt @@ -0,0 +1,167 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.AddStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.common.launchProxyDepositDescription +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.common.mapAddStakingProxyValidationFailureToUi +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import io.novafoundation.nova.runtime.state.selectedChainFlow +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmAddStakingProxyViewModel( + private val router: StakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val payload: ConfirmAddStakingProxyPayload, + private val accountRepository: AccountRepository, + private val resourceManager: ResourceManager, + private val externalActions: ExternalActions.Presentation, + private val validationExecutor: ValidationExecutor, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val assetUseCase: ArbitraryAssetUseCase, + private val addStakingProxyValidationSystem: AddStakingProxyValidationSystem, + private val addStakingProxyInteractor: AddStakingProxyInteractor, + private val walletUiUseCase: WalletUiUseCase, + private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher, + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val selectedMetaAccountFlow = accountRepository.selectedMetaAccountFlow() + .shareInBackground() + + private val chainFlow = selectedAssetState.selectedChainFlow() + .shareInBackground() + + @OptIn(ExperimentalCoroutinesApi::class) + private val assetFlow = selectedAssetState.selectedAssetFlow() + .flatMapLatest { assetUseCase.assetFlow(it) } + .shareInBackground() + + private val feeFlow = flowOf { mapFeeFromParcel(payload.fee) } + .shareInBackground() + + val chainModel = chainFlow.map { chain -> + mapChainToUi(chain) + } + + val walletUiFlow = selectedMetaAccountFlow.map { walletUiUseCase.walletUiFor(it) } + + val proxiedAccountModel = combine(selectedMetaAccountFlow, chainFlow) { metaAccount, chain -> + val address = metaAccount.requireAddressIn(chain) + + generateAccountAddressModel(chain, address) + } + + val proxyDeposit = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.deltaDeposit, asset) + } + + val feeModelFlow = combine(assetFlow, feeFlow) { asset, fee -> + amountFormatter.formatAmountToAmountModel(fee.amount, asset) + } + + val proxyAccountModel = chainFlow.map { chain -> + generateAccountAddressModel(chain, payload.proxyAddress) + } + + val validationProgressFlow = MutableStateFlow(false) + + fun back() { + router.back() + } + + fun confirmClicked() = launch { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = selectedAssetState.chain() + val validationPayload = AddStakingProxyValidationPayload( + chain = chain, + asset = assetFlow.first(), + proxyAddress = payload.proxyAddress, + proxiedAccountId = metaAccount.requireAccountIdIn(chain), + fee = feeFlow.first(), + deltaDeposit = payload.deltaDeposit, + currentQuantity = payload.currentQuantity + ) + + validationExecutor.requireValid( + validationSystem = addStakingProxyValidationSystem, + payload = validationPayload, + validationFailureTransformer = { mapAddStakingProxyValidationFailureToUi(resourceManager, it) }, + progressConsumer = validationProgressFlow.progressConsumer() + ) { + sendTransaction(it.chain, it.proxiedAccountId, it.chain.accountIdOf(it.proxyAddress)) + } + } + + private fun sendTransaction(chain: Chain, proxiedAccount: AccountId, proxyAccount: AccountId) = launch { + val result = addStakingProxyInteractor.addProxy(chain, proxiedAccount, proxyAccount) + + validationProgressFlow.value = false + + result.onSuccess { + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + .onFailure(::showError) + } + + private suspend fun generateAccountAddressModel(chain: Chain, address: String) = addressIconGenerator.createAccountAddressModel( + chain = chain, + address = address, + ) + + fun proxiedAccountClicked() { + launch { + showExternalActions(proxiedAccountModel.first().address) + } + } + + fun depositClicked() { + descriptionBottomSheetLauncher.launchProxyDepositDescription() + } + + fun proxyAccountClicked() { + showExternalActions(payload.proxyAddress) + } + + private fun showExternalActions(address: String) = launch { + externalActions.showAddressActions(address, selectedAssetState.chain()) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/di/ConfirmAddStakingProxyComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/di/ConfirmAddStakingProxyComponent.kt new file mode 100644 index 0000000..54b0851 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/di/ConfirmAddStakingProxyComponent.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.ConfirmAddStakingProxyFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.ConfirmAddStakingProxyPayload + +@Subcomponent( + modules = [ + ConfirmAddStakingProxyModule::class + ] +) +@ScreenScope +interface ConfirmAddStakingProxyComponent { + + @Subcomponent.Factory + interface Factory { + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmAddStakingProxyPayload, + ): ConfirmAddStakingProxyComponent + } + + fun inject(fragment: ConfirmAddStakingProxyFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/di/ConfirmAddStakingProxyModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/di/ConfirmAddStakingProxyModule.kt new file mode 100644 index 0000000..17e5c5e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/confirm/di/ConfirmAddStakingProxyModule.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.AddStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.ConfirmAddStakingProxyPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.ConfirmAddStakingProxyViewModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmAddStakingProxyModule { + @Provides + @IntoMap + @ViewModelKey(ConfirmAddStakingProxyViewModel::class) + fun provideViewModule( + router: StakingRouter, + addressIconGenerator: AddressIconGenerator, + payload: ConfirmAddStakingProxyPayload, + accountRepository: AccountRepository, + resourceManager: ResourceManager, + externalActions: ExternalActions.Presentation, + validationExecutor: ValidationExecutor, + selectedAssetState: AnySelectedAssetOptionSharedState, + assetUseCase: ArbitraryAssetUseCase, + addStakingProxyValidationSystem: AddStakingProxyValidationSystem, + addStakingProxyRepository: AddStakingProxyInteractor, + walletUiUseCase: WalletUiUseCase, + descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmAddStakingProxyViewModel( + router = router, + addressIconGenerator = addressIconGenerator, + payload = payload, + accountRepository = accountRepository, + resourceManager = resourceManager, + externalActions = externalActions, + validationExecutor = validationExecutor, + selectedAssetState = selectedAssetState, + assetUseCase = assetUseCase, + addStakingProxyValidationSystem = addStakingProxyValidationSystem, + addStakingProxyInteractor = addStakingProxyRepository, + walletUiUseCase = walletUiUseCase, + descriptionBottomSheetLauncher = descriptionBottomSheetLauncher, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmAddStakingProxyViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmAddStakingProxyViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/AddStakingProxyFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/AddStakingProxyFragment.kt new file mode 100644 index 0000000..c39fac5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/AddStakingProxyFragment.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.set + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.setupAddressInput +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.setupExternalAccounts +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.setupYourWalletsBtn +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentAddStakingProxyBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class AddStakingProxyFragment : BaseFragment() { + + override fun createBinding() = FragmentAddStakingProxyBinding.inflate(layoutInflater) + + override fun initViews() { + binder.addProxyToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.addStakingProxyButton.prepareForProgress(this) + + binder.addStakingProxySelectWallet.setOnClickListener { viewModel.selectAuthorityWallet() } + binder.addStakingProxyButton.setOnClickListener { viewModel.onConfirmClick() } + binder.addStakingProxyDeposit.setOnClickListener { viewModel.showProxyDepositDescription() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .setStakingProxyFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: AddStakingProxyViewModel) { + setupExternalActions(viewModel) + observeValidations(viewModel) + observeDescription(viewModel) + + setupAddressInput(viewModel.addressInputMixin, binder.setStakingProxyAddress) + setupExternalAccounts(viewModel.addressInputMixin, binder.setStakingProxyAddress) + setupYourWalletsBtn(binder.addStakingProxySelectWallet, viewModel.selectAddressMixin) + + viewModel.titleFlow.observe { + binder.addStakingProxyTitle.text = it + } + + viewModel.proxyDepositModel.observe { + binder.addStakingProxyDeposit.showAmount(it) + } + + setupFeeLoading(viewModel.feeMixin, binder.addStakingProxyFee) + + viewModel.continueButtonState.observe(binder.addStakingProxyButton::setState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/AddStakingProxyViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/AddStakingProxyViewModel.kt new file mode 100644 index 0000000..5484b6a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/AddStakingProxyViewModel.kt @@ -0,0 +1,256 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.set + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.domain.filter.selectAddress.SelectAccountFilter +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.common.SelectWalletFilterPayload +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.AddStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.confirm.ConfirmAddStakingProxyPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.common.launchProxyDepositDescription +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.common.mapAddStakingProxyValidationFailureToUi +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.assetWithChain +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import io.novafoundation.nova.runtime.state.selectedChainFlow +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class AddStakingProxyViewModel( + addressInputMixinFactory: AddressInputMixinFactory, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val externalActions: ExternalActions.Presentation, + private val interactor: StakingInteractor, + private val accountRepository: AccountRepository, + private val assetUseCase: ArbitraryAssetUseCase, + private val resourceManager: ResourceManager, + private val addStakingProxyInteractor: AddStakingProxyInteractor, + private val validationExecutor: ValidationExecutor, + private val addStakingProxyValidationSystem: AddStakingProxyValidationSystem, + private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + private val metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + private val stakingRouter: StakingRouter, + private val selectAddressMixinFactory: SelectAddressMixin.Factory, + private val getProxyRepository: GetProxyRepository, + private val amountFormat: AmountFormatter +) : BaseViewModel(), + DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher, + ExternalActions by externalActions, + Validatable by validationExecutor { + + @OptIn(ExperimentalCoroutinesApi::class) + private val commissionAssetFlow = selectedAssetState.selectedChainFlow() + .flatMapLatest { assetUseCase.assetFlow(it.id, it.commissionAsset.id) } + .shareInBackground() + + @OptIn(ExperimentalCoroutinesApi::class) + private val selectedAssetFlow = selectedAssetState.selectedAssetFlow() + .flatMapLatest { assetUseCase.assetFlow(it) } + .shareInBackground() + + private val selectAddressPayloadFlow = flowOf { + val selectedMetaAccount = accountRepository.getSelectedMetaAccount() + val chain = selectedAssetState.chain() + val filter = metaAccountsFilter(chain, selectedMetaAccount.requireAccountIdIn(chain)) + SelectAddressMixin.Payload(chain, filter) + } + + val selectAddressMixin = selectAddressMixinFactory.create(this, selectAddressPayloadFlow, ::onAddressSelect) + + val titleFlow = selectedAssetFlow.map { + resourceManager.getString(R.string.fragment_set_staking_proxy_title, it.token.configuration.symbol) + } + .shareInBackground() + + val addressInputMixin = with(addressInputMixinFactory) { + val inputSpec = singleChainInputSpec(selectedAssetState.selectedChainFlow()) + + create( + inputSpecProvider = inputSpec, + myselfBehaviorProvider = noMyself(), + accountIdentifierProvider = web3nIdentifiers( + destinationChainFlow = selectedAssetState.assetWithChain, + inputSpecProvider = inputSpec, + coroutineScope = this@AddStakingProxyViewModel, + ), + errorDisplayer = this@AddStakingProxyViewModel::showError, + showAccountEvent = this@AddStakingProxyViewModel::showAccountDetails, + coroutineScope = this@AddStakingProxyViewModel, + ) + } + + val isSelectAddressAvailable = flowOf { + val selectedMetaAccount = accountRepository.getSelectedMetaAccount() + val chain = selectedAssetState.chain() + val filter = metaAccountsFilter(chain, selectedMetaAccount.requireAccountIdIn(chain)) + metaAccountGroupingInteractor.hasAvailableMetaAccountsForChain(selectedAssetState.chainId(), filter) + } + .shareInBackground() + + private val proxyDepositDelta: Flow = flowOf { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = selectedAssetState.chain() + val accountId = metaAccount.requireAccountIdIn(chain) + addStakingProxyInteractor.calculateDeltaDepositForAddProxy(chain, accountId) + } + .shareInBackground() + + val proxyDepositModel: Flow = combine(proxyDepositDelta, selectedAssetFlow) { depositDelta, asset -> + amountFormat.formatAmountToAmountModel(depositDelta, asset) + } + .shareInBackground() + + val feeMixin: FeeLoaderMixin.Presentation = feeLoaderMixinFactory.create(commissionAssetFlow) + + private val _validationProgressFlow = MutableStateFlow(false) + + val continueButtonState = combine( + addressInputMixin.inputFlow, + _validationProgressFlow + ) { addressInput, validationInProgress -> + when { + validationInProgress -> DescriptiveButtonState.Loading + + addressInput.isEmpty() -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_address)) + + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + } + + init { + runFeeUpdate() + } + + fun backClicked() { + stakingRouter.back() + } + + fun showProxyDepositDescription() { + descriptionBottomSheetLauncher.launchProxyDepositDescription() + } + + fun selectAuthorityWallet() { + launch { + val selectedAddress = addressInputMixin.getAddress() + selectAddressMixin.openSelectAddress(selectedAddress) + } + } + + fun onConfirmClick() = launch { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = selectedAssetState.chain() + val proxiedAccountId = metaAccount.requireAccountIdIn(chain) + val validationPayload = AddStakingProxyValidationPayload( + chain = chain, + asset = selectedAssetFlow.first(), + proxyAddress = addressInputMixin.getAddress(), + proxiedAccountId = metaAccount.requireAccountIdIn(chain), + fee = feeMixin.awaitFee(), + deltaDeposit = proxyDepositDelta.first(), + currentQuantity = getProxyRepository.getProxiesQuantity(chain.id, proxiedAccountId) + ) + + validationExecutor.requireValid( + validationSystem = addStakingProxyValidationSystem, + payload = validationPayload, + validationFailureTransformer = { mapAddStakingProxyValidationFailureToUi(resourceManager, it) }, + progressConsumer = _validationProgressFlow.progressConsumer() + ) { + openConfirmScreen(it) + _validationProgressFlow.value = false + } + } + + private fun openConfirmScreen(validationPayload: AddStakingProxyValidationPayload) { + val screenPayload = validationPayload.run { + ConfirmAddStakingProxyPayload( + fee = mapFeeToParcel(validationPayload.fee), + proxyAddress = proxyAddress, + deltaDeposit = deltaDeposit, + currentQuantity = currentQuantity + ) + } + stakingRouter.openConfirmAddStakingProxy(screenPayload) + } + + private fun onAddressSelect(address: String) { + addressInputMixin.inputFlow.value = address + } + + private fun runFeeUpdate() { + addressInputMixin.inputFlow.onEach { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = selectedAssetState.chain() + + feeMixin.loadFee( + coroutineScope = this, + feeConstructor = { addStakingProxyInteractor.estimateFee(chain, metaAccount.requireAccountIdIn(chain)) }, + onRetryCancelled = {} + ) + }.launchIn(this) + } + + private fun showAccountDetails(address: String) { + launch { + val chain = selectedAssetState.chain() + externalActions.showAddressActions(address, chain) + } + } + + private suspend fun getMetaAccountsFilterPayload(chain: Chain, accountId: AccountId): SelectWalletFilterPayload.ExcludeMetaIds { + val filteredMetaAccounts = accountRepository.getActiveMetaAccounts() + .filter { it.accountIdIn(chain)?.intoKey() == accountId.intoKey() } + .map { it.id } + + return SelectWalletFilterPayload.ExcludeMetaIds(filteredMetaAccounts) + } + + private suspend fun metaAccountsFilter(chain: Chain, accountId: AccountId): SelectAccountFilter { + val metaAccountsFilterPayload = getMetaAccountsFilterPayload(chain, accountId) + + return SelectAccountFilter.ExcludeMetaAccounts( + metaAccountsFilterPayload.metaIds + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/di/AddStakingProxyComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/di/AddStakingProxyComponent.kt new file mode 100644 index 0000000..46645e8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/di/AddStakingProxyComponent.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.set.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.set.AddStakingProxyFragment + +@Subcomponent( + modules = [ + AddStakingProxyModule::class + ] +) +@ScreenScope +interface AddStakingProxyComponent { + + @Subcomponent.Factory + interface Factory { + fun create(@BindsInstance fragment: Fragment): AddStakingProxyComponent + } + + fun inject(fragment: AddStakingProxyFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/di/AddStakingProxyModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/di/AddStakingProxyModule.kt new file mode 100644 index 0000000..ef2d99d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/add/set/di/AddStakingProxyModule.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.set.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.MetaAccountGroupingInteractor +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressCommunicator +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.AddressInputMixinFactory +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectAddress.SelectAddressMixin +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.AddStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.add.set.AddStakingProxyViewModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState + +@Module(includes = [ViewModelModule::class]) +class AddStakingProxyModule { + + @Provides + @IntoMap + @ViewModelKey(AddStakingProxyViewModel::class) + fun provideViewModel( + addressInputMixinFactory: AddressInputMixinFactory, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + selectedAssetState: AnySelectedAssetOptionSharedState, + externalActions: ExternalActions.Presentation, + interactor: StakingInteractor, + accountRepository: AccountRepository, + assetUseCase: ArbitraryAssetUseCase, + resourceManager: ResourceManager, + selectAddressCommunicator: SelectAddressCommunicator, + addStakingProxyInteractor: AddStakingProxyInteractor, + validationExecutor: ValidationExecutor, + addStakingProxyValidationSystem: AddStakingProxyValidationSystem, + descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + metaAccountGroupingInteractor: MetaAccountGroupingInteractor, + stakingRouter: StakingRouter, + selectAddressMixinFactory: SelectAddressMixin.Factory, + getProxyRepository: GetProxyRepository, + amountFormat: AmountFormatter + ): ViewModel { + return AddStakingProxyViewModel( + addressInputMixinFactory = addressInputMixinFactory, + feeLoaderMixinFactory = feeLoaderMixinFactory, + selectedAssetState = selectedAssetState, + externalActions = externalActions, + interactor = interactor, + accountRepository = accountRepository, + assetUseCase = assetUseCase, + resourceManager = resourceManager, + addStakingProxyInteractor = addStakingProxyInteractor, + validationExecutor = validationExecutor, + addStakingProxyValidationSystem = addStakingProxyValidationSystem, + descriptionBottomSheetLauncher = descriptionBottomSheetLauncher, + metaAccountGroupingInteractor = metaAccountGroupingInteractor, + stakingRouter = stakingRouter, + selectAddressMixinFactory = selectAddressMixinFactory, + getProxyRepository = getProxyRepository, + amountFormat = amountFormat + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): AddStakingProxyViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(AddStakingProxyViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/common/AddProxyValidationFailureHandling.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/common/AddProxyValidationFailureHandling.kt new file mode 100644 index 0000000..f0b3bbd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/common/AddProxyValidationFailureHandling.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.common + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationFailure.NotEnoughBalanceToReserveDeposit +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationFailure.InvalidAddress +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationFailure.MaximumProxiesReached +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationFailure.NotEnoughToPayFee +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.add.AddStakingProxyValidationFailure.NotEnoughToStayAboveED +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun mapAddStakingProxyValidationFailureToUi( + resourceManager: ResourceManager, + failure: AddStakingProxyValidationFailure, +): TitleAndMessage { + return when (failure) { + is NotEnoughBalanceToReserveDeposit -> resourceManager.getString(R.string.common_error_not_enough_tokens) to + resourceManager.getString( + R.string.staking_not_enough_balance_to_pay_proxy_deposit_message, + failure.deposit.formatPlanks(failure.chainAsset), + failure.maxUsable.formatPlanks(failure.chainAsset) + ) + + is InvalidAddress -> resourceManager.getString(R.string.invalid_proxy_address_title) to + resourceManager.getString(R.string.invalid_proxy_address_message, failure.chain.name) + + AddStakingProxyValidationFailure.SelfDelegation -> resourceManager.getString(R.string.delegation_error_self_delegate_title) to + resourceManager.getString(R.string.delegation_error_self_delegate_message) + + is MaximumProxiesReached -> resourceManager.getString(R.string.add_proxy_maximum_reached_error_title) to + resourceManager.getString(R.string.add_proxy_maximum_reached_error_message, failure.max, failure.chain.name) + + is NotEnoughToPayFee -> handleNotEnoughFeeError(failure, resourceManager) + + is NotEnoughToStayAboveED -> handleInsufficientBalanceCommission(failure, resourceManager) + + is AddStakingProxyValidationFailure.AlreadyDelegated -> resourceManager.getString(R.string.duplicate_proxy_type_title) to + resourceManager.getString(R.string.duplicate_proxy_type_message, failure.address) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/common/ProxyDepositDescriptionSetupExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/common/ProxyDepositDescriptionSetupExt.kt new file mode 100644 index 0000000..6802f10 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/common/ProxyDepositDescriptionSetupExt.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.common + +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_staking_impl.R + +fun DescriptionBottomSheetLauncher.launchProxyDepositDescription() { + launchDescriptionBottomSheet( + titleRes = R.string.common_proxy_deposit, + descriptionRes = R.string.add_proxy_deposit_description_message + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/common/RemoveStakingProxyValidationFailureToUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/common/RemoveStakingProxyValidationFailureToUi.kt new file mode 100644 index 0000000..4908110 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/common/RemoveStakingProxyValidationFailureToUi.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.common + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove.RemoveStakingProxyValidationFailure.NotEnoughToPayFee +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove.RemoveStakingProxyValidationFailure.NotEnoughToStayAboveED +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove.RemoveStakingProxyValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun mapRemoveStakingProxyValidationFailureToUi( + resourceManager: ResourceManager, + failure: RemoveStakingProxyValidationFailure, +): TitleAndMessage { + return when (failure) { + is NotEnoughToPayFee -> handleNotEnoughFeeError(failure, resourceManager) + + is NotEnoughToStayAboveED -> handleInsufficientBalanceCommission(failure, resourceManager) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/StakingProxyListAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/StakingProxyListAdapter.kt new file mode 100644 index 0000000..e7b52b3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/StakingProxyListAdapter.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list + +import android.view.ViewGroup +import coil.ImageLoader +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_staking_impl.databinding.ItemProxyBinding +import io.novafoundation.nova.feature_staking_impl.databinding.ItemProxyGroupBinding +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.model.StakingProxyGroupRvItem +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.model.StakingProxyRvItem + +class StakingProxyListAdapter( + private val handler: Handler, + private val imageLoader: ImageLoader +) : GroupedListAdapter(DiffCallback()) { + + interface Handler { + fun onProxyClick(item: StakingProxyRvItem) + } + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return StakingProxyGroupHolder(ItemProxyGroupBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return StakingProxyHolder(handler, imageLoader, ItemProxyBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindGroup(holder: GroupedListHolder, group: StakingProxyGroupRvItem) { + require(holder is StakingProxyGroupHolder) + holder.bind(group) + } + + override fun bindChild(holder: GroupedListHolder, child: StakingProxyRvItem) { + require(holder is StakingProxyHolder) + holder.bind(child) + } +} + +private class DiffCallback : BaseGroupedDiffCallback(StakingProxyGroupRvItem::class.java) { + + override fun areGroupItemsTheSame(oldItem: StakingProxyGroupRvItem, newItem: StakingProxyGroupRvItem): Boolean { + return oldItem.text == newItem.text + } + + override fun areGroupContentsTheSame(oldItem: StakingProxyGroupRvItem, newItem: StakingProxyGroupRvItem): Boolean { + return true + } + + override fun areChildItemsTheSame(oldItem: StakingProxyRvItem, newItem: StakingProxyRvItem): Boolean { + return oldItem.accountAddress == newItem.accountAddress + } + + override fun areChildContentsTheSame(oldItem: StakingProxyRvItem, newItem: StakingProxyRvItem): Boolean { + return true + } +} + +class StakingProxyGroupHolder( + private val binder: ItemProxyGroupBinding, +) : GroupedListHolder(binder.root) { + + fun bind(item: StakingProxyGroupRvItem) = with(binder) { + itemProxyGroup.text = item.text + } +} + +class StakingProxyHolder( + private val eventHandler: StakingProxyListAdapter.Handler, + private val imageLoader: ImageLoader, + private val binder: ItemProxyBinding, +) : GroupedListHolder(binder.root) { + + fun bind(item: StakingProxyRvItem) = with(binder) { + root.setOnClickListener { eventHandler.onProxyClick(item) } + itemStakingProxyIcon.setImageDrawable(item.accountIcon) + itemStakingProxyChainIcon.loadChainIcon(item.chainIconUrl, imageLoader) + itemStakingProxyAccountTitle.text = item.accountTitle + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/StakingProxyListFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/StakingProxyListFragment.kt new file mode 100644 index 0000000..bd335ed --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/StakingProxyListFragment.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_account_api.presenatation.actions.CustomizableExternalActionsSheet +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActionModel +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentStakingProxyListBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.model.StakingProxyRvItem +import javax.inject.Inject + +class StakingProxyListFragment : BaseFragment(), StakingProxyListAdapter.Handler { + + override fun createBinding() = FragmentStakingProxyListBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { StakingProxyListAdapter(this, imageLoader) } + + override fun initViews() { + binder.stakingProxyListToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.stakingProxyListAddProxyButton.setOnClickListener { viewModel.addProxyClicked() } + + binder.stakingProxyList.adapter = adapter + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .stakingProxyListFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: StakingProxyListViewModel) { + setupExternalActions(viewModel) { context, payload -> + CustomizableExternalActionsSheet( + context, + payload, + onCopy = viewModel::copyValue, + onViewExternal = viewModel::viewExternalClicked, + additionalOptions = rewokeAccessExternalAction(payload) + ) + } + + viewModel.proxyModels.observe { + adapter.submitList(it) + } + } + + override fun onProxyClick(item: StakingProxyRvItem) { + viewModel.proxyClicked(item) + } + + private fun rewokeAccessExternalAction(payload: ExternalActions.Payload): List { + return listOf( + ExternalActionModel( + R.drawable.ic_delete, + getString(R.string.common_proxy_rewoke_access), + onClick = { viewModel.rewokeAccess(payload) } + ) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/StakingProxyListViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/StakingProxyListViewModel.kt new file mode 100644 index 0000000..819ab90 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/StakingProxyListViewModel.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressIconGenerator.Companion.SIZE_BIG +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.list.StakingProxyListInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.list.model.StakingProxyAccount +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.model.StakingProxyGroupRvItem +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.model.StakingProxyRvItem +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.ConfirmRemoveStakingProxyPayload +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class StakingProxyListViewModel( + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val externalActions: ExternalActions.Presentation, + private val accountRepository: AccountRepository, + private val stakingProxyListInteractor: StakingProxyListInteractor, + private val resourceManager: ResourceManager, + private val stakingRouter: StakingRouter, + private val addressIconGenerator: AddressIconGenerator +) : BaseViewModel(), ExternalActions by externalActions { + + val selectedMetaAccount = accountRepository.selectedMetaAccountFlow() + .shareInBackground() + + val proxies = selectedMetaAccount.flatMapLatest { + val chain = selectedAssetState.chain() + val accountId = it.requireAccountIdIn(chain) + stakingProxyListInteractor.stakingProxyListFlow(chain, accountId) + } + .shareInBackground() + + val proxyModels: Flow> = proxies.map { + mapToProxyList(it) + } + .shareInBackground() + + fun backClicked() { + stakingRouter.back() + } + + fun addProxyClicked() { + stakingRouter.openAddStakingProxy() + } + + fun proxyClicked(item: StakingProxyRvItem) { + launch { + val chain = selectedAssetState.chain() + externalActions.showAddressActions(item.accountAddress, chain) + } + } + + fun rewokeAccess(externalActionPayload: ExternalActions.Payload) { + val payload = ConfirmRemoveStakingProxyPayload(externalActionPayload.requireAddress()) + stakingRouter.openConfirmRemoveStakingProxy(payload) + } + + private suspend fun mapToProxyList(proxies: List): List { + val chain = selectedAssetState.chain() + return buildList { + val groupTitle = resourceManager.getString(R.string.staking_proxies_group_title) + add(StakingProxyGroupRvItem(groupTitle)) + + val proxyRvItems = proxies.map { stakingProxyAccount -> + val accountAddress = chain.addressOf(stakingProxyAccount.proxyAccountId) + StakingProxyRvItem( + addressIconGenerator.createAddressIcon(stakingProxyAccount.proxyAccountId, SIZE_BIG), + chain.icon, + stakingProxyAccount.accountName, + accountAddress + ) + } + + addAll(proxyRvItems) + } + } + + private fun ExternalActions.Payload.requireAddress() = type.castOrNull()!!.address!! +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/di/StakingProxyListComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/di/StakingProxyListComponent.kt new file mode 100644 index 0000000..c47fa60 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/di/StakingProxyListComponent.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.StakingProxyListFragment + +@Subcomponent( + modules = [ + StakingProxyListModule::class + ] +) +@ScreenScope +interface StakingProxyListComponent { + + @Subcomponent.Factory + interface Factory { + fun create(@BindsInstance fragment: Fragment): StakingProxyListComponent + } + + fun inject(fragment: StakingProxyListFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/di/StakingProxyListModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/di/StakingProxyListModule.kt new file mode 100644 index 0000000..9817146 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/di/StakingProxyListModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.list.StakingProxyListInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.StakingProxyListViewModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState + +@Module(includes = [ViewModelModule::class]) +class StakingProxyListModule { + + @Provides + @IntoMap + @ViewModelKey(StakingProxyListViewModel::class) + fun provideViewModel( + selectedAssetState: AnySelectedAssetOptionSharedState, + externalActions: ExternalActions.Presentation, + accountRepository: AccountRepository, + resourceManager: ResourceManager, + stakingRouter: StakingRouter, + addressIconGenerator: AddressIconGenerator, + stakingProxyListInteractor: StakingProxyListInteractor + ): ViewModel { + return StakingProxyListViewModel( + selectedAssetState = selectedAssetState, + externalActions = externalActions, + accountRepository = accountRepository, + resourceManager = resourceManager, + stakingRouter = stakingRouter, + addressIconGenerator = addressIconGenerator, + stakingProxyListInteractor = stakingProxyListInteractor + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): StakingProxyListViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StakingProxyListViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/model/StakingProxyListModels.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/model/StakingProxyListModels.kt new file mode 100644 index 0000000..8b0d3d0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/list/model/StakingProxyListModels.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.list.model + +import android.graphics.drawable.Drawable + +class StakingProxyGroupRvItem(val text: String) + +class StakingProxyRvItem( + val accountIcon: Drawable, + val chainIconUrl: String?, + val accountTitle: String, + val accountAddress: String +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/ConfirmRemoveStakingProxyFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/ConfirmRemoveStakingProxyFragment.kt new file mode 100644 index 0000000..8c0d1ad --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/ConfirmRemoveStakingProxyFragment.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_account_api.view.showChain +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmRevokeStakingProxyBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class ConfirmRemoveStakingProxyFragment : BaseFragment() { + companion object { + + private const val PAYLOAD_KEY = "PAYLOAD_KEY" + + fun getBundle(payload: ConfirmRemoveStakingProxyPayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentConfirmRevokeStakingProxyBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmRemoveStakingProxyToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.confirmRemoveStakingProxyButton.setOnClickListener { viewModel.confirmClicked() } + binder.confirmRemoveStakingProxyButton.prepareForProgress(viewLifecycleOwner) + + binder.confirmRemoveStakingProxyProxiedAccount.setOnClickListener { viewModel.proxiedAccountClicked() } + binder.confirmRemoveStakingProxyDelegationAccount.setOnClickListener { viewModel.proxyAccountClicked() } + } + + override fun inject() { + val payload = argument(PAYLOAD_KEY) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmRevokeStakingProxyFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmRemoveStakingProxyViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel.feeMixin, binder.confirmRemoveStakingProxyNetworkFee) + + viewModel.chainModel.observe { binder.confirmRemoveStakingProxyNetwork.showChain(it) } + viewModel.walletUiFlow.observe { binder.confirmRemoveStakingProxyWallet.showWallet(it) } + viewModel.proxiedAccountModel.observe { binder.confirmRemoveStakingProxyProxiedAccount.showAddress(it) } + viewModel.proxyAccountModel.observe { binder.confirmRemoveStakingProxyDelegationAccount.showAddress(it) } + + viewModel.validationProgressFlow.observe(binder.confirmRemoveStakingProxyButton::setProgressState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/ConfirmRemoveStakingProxyPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/ConfirmRemoveStakingProxyPayload.kt new file mode 100644 index 0000000..444eab1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/ConfirmRemoveStakingProxyPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class ConfirmRemoveStakingProxyPayload( + val proxyAddress: String +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/ConfirmRemoveStakingProxyViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/ConfirmRemoveStakingProxyViewModel.kt new file mode 100644 index 0000000..1e7e821 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/ConfirmRemoveStakingProxyViewModel.kt @@ -0,0 +1,166 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke + +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.remove.RemoveStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove.RemoveStakingProxyValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove.RemoveStakingProxyValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.common.mapRemoveStakingProxyValidationFailureToUi +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.create +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import io.novafoundation.nova.runtime.state.selectedChainFlow +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmRemoveStakingProxyViewModel( + private val router: StakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val payload: ConfirmRemoveStakingProxyPayload, + private val accountRepository: AccountRepository, + private val resourceManager: ResourceManager, + private val externalActions: ExternalActions.Presentation, + private val validationExecutor: ValidationExecutor, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val assetUseCase: ArbitraryAssetUseCase, + private val removeStakingProxyValidationSystem: RemoveStakingProxyValidationSystem, + private val walletUiUseCase: WalletUiUseCase, + private val removeStakingProxyInteractor: RemoveStakingProxyInteractor, + private val feeLoaderMixinFactory: FeeLoaderMixin.Factory, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val selectedMetaAccountFlow = accountRepository.selectedMetaAccountFlow() + .shareInBackground() + + private val chainFlow = selectedAssetState.selectedChainFlow() + .shareInBackground() + + @OptIn(ExperimentalCoroutinesApi::class) + private val assetFlow = selectedAssetState.selectedAssetFlow() + .flatMapLatest { assetUseCase.assetFlow(it) } + .shareInBackground() + + val feeMixin: FeeLoaderMixin.Presentation = feeLoaderMixinFactory.create(assetFlow) + + val chainModel = chainFlow.map { chain -> + mapChainToUi(chain) + } + + val walletUiFlow = selectedMetaAccountFlow.map { walletUiUseCase.walletUiFor(it) } + + val proxiedAccountModel = combine(selectedMetaAccountFlow, chainFlow) { metaAccount, chain -> + val address = metaAccount.requireAddressIn(chain) + + generateAccountAddressModel(chain, address) + } + + val proxyAccountModel = chainFlow.map { chain -> + generateAccountAddressModel(chain, payload.proxyAddress) + } + + val validationProgressFlow = MutableStateFlow(false) + + init { + loadFee() + } + + fun backClicked() { + router.back() + } + + fun proxiedAccountClicked() { + launch { + showExternalActions(proxiedAccountModel.first().address) + } + } + + fun proxyAccountClicked() { + showExternalActions(payload.proxyAddress) + } + + fun confirmClicked() = launch { + val metaAccount = accountRepository.getSelectedMetaAccount() + val chain = selectedAssetState.chain() + val validationPayload = RemoveStakingProxyValidationPayload( + chain = chain, + asset = assetFlow.first(), + proxyAddress = payload.proxyAddress, + proxiedAccountId = metaAccount.requireAccountIdIn(chain), + fee = feeMixin.awaitFee() + ) + + validationExecutor.requireValid( + validationSystem = removeStakingProxyValidationSystem, + payload = validationPayload, + validationFailureTransformer = { mapRemoveStakingProxyValidationFailureToUi(resourceManager, it) }, + progressConsumer = validationProgressFlow.progressConsumer() + ) { + sendTransaction(it.chain, it.proxiedAccountId, it.chain.accountIdOf(it.proxyAddress)) + } + } + + private fun loadFee() { + launch { + val metaAccount = selectedMetaAccountFlow.first() + val chain = selectedAssetState.chain() + val proxiedAccountId = metaAccount.requireAccountIdIn(chain) + + feeMixin.loadFee( + this, + feeConstructor = { removeStakingProxyInteractor.estimateFee(chain, proxiedAccountId) }, + onRetryCancelled = ::backClicked + ) + } + } + + private fun sendTransaction(chain: Chain, proxiedAccount: AccountId, proxyAccount: AccountId) = launch { + val result = removeStakingProxyInteractor.removeProxy(chain, proxiedAccount, proxyAccount) + + validationProgressFlow.value = false + + result.onSuccess { + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + .onFailure(::showError) + } + + private suspend fun generateAccountAddressModel(chain: Chain, address: String) = addressIconGenerator.createAccountAddressModel( + chain = chain, + address = address, + ) + + private fun showExternalActions(address: String) = launch { + externalActions.showAddressActions(address, selectedAssetState.chain()) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/di/ConfirmRemoveStakingProxyComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/di/ConfirmRemoveStakingProxyComponent.kt new file mode 100644 index 0000000..d4a5aab --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/di/ConfirmRemoveStakingProxyComponent.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.ConfirmRemoveStakingProxyFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.ConfirmRemoveStakingProxyPayload + +@Subcomponent( + modules = [ + ConfirmRemoveStakingProxyModule::class + ] +) +@ScreenScope +interface ConfirmRemoveStakingProxyComponent { + + @Subcomponent.Factory + interface Factory { + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmRemoveStakingProxyPayload, + ): ConfirmRemoveStakingProxyComponent + } + + fun inject(fragment: ConfirmRemoveStakingProxyFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/di/ConfirmRemoveStakingProxyModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/di/ConfirmRemoveStakingProxyModule.kt new file mode 100644 index 0000000..f9cb33f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/delegation/proxy/revoke/di/ConfirmRemoveStakingProxyModule.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.domain.staking.delegation.proxy.remove.RemoveStakingProxyInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.proxy.remove.RemoveStakingProxyValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.ConfirmRemoveStakingProxyPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.delegation.proxy.revoke.ConfirmRemoveStakingProxyViewModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState + +@Module(includes = [ViewModelModule::class]) +class ConfirmRemoveStakingProxyModule { + @Provides + @IntoMap + @ViewModelKey(ConfirmRemoveStakingProxyViewModel::class) + fun provideViewModule( + router: StakingRouter, + addressIconGenerator: AddressIconGenerator, + payload: ConfirmRemoveStakingProxyPayload, + accountRepository: AccountRepository, + resourceManager: ResourceManager, + externalActions: ExternalActions.Presentation, + validationExecutor: ValidationExecutor, + selectedAssetState: AnySelectedAssetOptionSharedState, + assetUseCase: ArbitraryAssetUseCase, + walletUiUseCase: WalletUiUseCase, + removeStakingProxyInteractor: RemoveStakingProxyInteractor, + removeStakingProxyValidationSystem: RemoveStakingProxyValidationSystem, + feeLoaderMixinFactory: FeeLoaderMixin.Factory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + ): ViewModel { + return ConfirmRemoveStakingProxyViewModel( + router = router, + addressIconGenerator = addressIconGenerator, + payload = payload, + accountRepository = accountRepository, + resourceManager = resourceManager, + externalActions = externalActions, + validationExecutor = validationExecutor, + selectedAssetState = selectedAssetState, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + removeStakingProxyInteractor = removeStakingProxyInteractor, + removeStakingProxyValidationSystem = removeStakingProxyValidationSystem, + feeLoaderMixinFactory = feeLoaderMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmRemoveStakingProxyViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmRemoveStakingProxyViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/StakingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/StakingFragment.kt new file mode 100644 index 0000000..17d6124 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/StakingFragment.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main + +import android.view.View +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.insets.applyNavigationBarInsets +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.view.setModelOrHide +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentStakingBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.setupAlertsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.setupNetworkInfoComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.setupStakeActionsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.setupStakeSummaryComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.setupUnbondingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.setupUserRewardsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.setupYourPoolComponent + +import javax.inject.Inject + +class StakingFragment : BaseFragment() { + + override fun createBinding() = FragmentStakingBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun applyInsets(rootView: View) { + binder.stakingToolbar.applyStatusBarInsets() + binder.root.applyNavigationBarInsets(consume = false) + } + + override fun initViews() { + binder.stakingToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.stakingMigrationAlert.setOnCloseClickListener { viewModel.closeMigrationAlert() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .stakingComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: StakingViewModel) { + observeBrowserEvents(viewModel) + observeValidations(viewModel) + setupExternalActions(viewModel) + + viewModel.migrationAlertFlow.observe { binder.stakingMigrationAlert.setModelOrHide(it) } + + setupNetworkInfoComponent(viewModel.networkInfoComponent, binder.stakingNetworkInfo) + setupStakeSummaryComponent(viewModel.stakeSummaryComponent, binder.stakingStakeSummary) + setupUserRewardsComponent(viewModel.userRewardsComponent, binder.stakingUserRewards, viewModel.router) + setupUnbondingComponent(viewModel.unbondingComponent, binder.stakingStakeUnbondings) + setupStakeActionsComponent(viewModel.stakeActionsComponent, binder.stakingStakeManage) + setupAlertsComponent(viewModel.alertsComponent, binder.stakingAlertsInfo) + setupYourPoolComponent(viewModel.yourPoolComponent, binder.stakingYourPool) + + viewModel.titleFlow.observe(binder.stakingToolbar::setTitle) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/StakingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/StakingViewModel.kt new file mode 100644 index 0000000..423c951 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/StakingViewModel.kt @@ -0,0 +1,143 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.AlertModel +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase +import io.novafoundation.nova.feature_ahm_api.domain.model.ChainMigrationConfig +import io.novafoundation.nova.feature_ahm_api.presentation.getChainMigrationDateFormat +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.NetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.YourPoolComponentFactory +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +private const val STAKING_MIGRATION_INFO = "STAKING_MIGRATION_INFO" + +class StakingViewModel( + selectedAccountUseCase: SelectedAccountUseCase, + + assetUseCase: AssetUseCase, + alertsComponentFactory: AlertsComponentFactory, + unbondingComponentFactory: UnbondingComponentFactory, + stakeSummaryComponentFactory: StakeSummaryComponentFactory, + userRewardsComponentFactory: UserRewardsComponentFactory, + stakeActionsComponentFactory: StakeActionsComponentFactory, + networkInfoComponentFactory: NetworkInfoComponentFactory, + yourPoolComponentFactory: YourPoolComponentFactory, + + val router: StakingRouter, + + private val validationExecutor: ValidationExecutor, + private val stakingSharedState: StakingSharedState, + private val resourceManager: ResourceManager, + private val externalActionsMixin: ExternalActions.Presentation, + private val chainMigrationInfoUseCase: ChainMigrationInfoUseCase, + stakingUpdateSystem: UpdateSystem, +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActionsMixin, + Browserable { + + override val openBrowserEvent = MutableLiveData>() + + private val selectedAssetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + val titleFlow = stakingSharedState.selectedAssetFlow() + .map { resourceManager.getString(R.string.staking_title_format, it.name) } + .shareInBackground() + + private val selectedAccountFlow = selectedAccountUseCase.selectedMetaAccountFlow() + .shareInBackground() + + private val componentHostContext = ComponentHostContext( + errorDisplayer = ::showError, + selectedAccount = selectedAccountFlow, + assetFlow = selectedAssetFlow, + scope = this, + validationExecutor = validationExecutor, + externalActions = externalActionsMixin + ) + + val unbondingComponent = unbondingComponentFactory.create(componentHostContext) + val stakeSummaryComponent = stakeSummaryComponentFactory.create(componentHostContext) + val userRewardsComponent = userRewardsComponentFactory.create(componentHostContext) + val stakeActionsComponent = stakeActionsComponentFactory.create(componentHostContext) + val networkInfoComponent = networkInfoComponentFactory.create(componentHostContext) + val alertsComponent = alertsComponentFactory.create(componentHostContext) + val yourPoolComponent = yourPoolComponentFactory.create(componentHostContext) + + private val dateFormatter = getChainMigrationDateFormat() + + val migrationAlertFlow = selectedAssetFlow.flatMapLatest { + val chainAsset = it.token.configuration + combine( + chainMigrationInfoUseCase.observeMigrationConfigOrNull(chainAsset.chainId, chainAsset.id), + chainMigrationInfoUseCase.observeInfoShouldBeHidden(STAKING_MIGRATION_INFO, chainAsset.chainId, chainAsset.id) + ) { configWithChains, shouldBeHidden -> + if (shouldBeHidden) return@combine null + if (configWithChains == null) return@combine null + + val config = configWithChains.config + val destinationAsset = configWithChains.destinationAsset + val destinationChain = configWithChains.destinationChain + val formattedDate = dateFormatter.format(config.timeStartAt) + AlertModel( + style = AlertView.Style.fromPreset(AlertView.StylePreset.INFO), + message = resourceManager.getString( + R.string.staking_details_migration_alert_title, + destinationAsset.name, + destinationChain.name, + formattedDate + ), + linkAction = AlertModel.ActionModel(resourceManager.getString(R.string.common_learn_more)) { learnMoreMigrationClicked(config) }, + ) + } + }.shareInBackground() + + fun backClicked() { + router.back() + } + + fun closeMigrationAlert() { + launch { + val chainAsset = selectedAssetFlow.first().token.configuration + chainMigrationInfoUseCase.markMigrationInfoAsHidden(STAKING_MIGRATION_INFO, chainAsset.chainId, chainAsset.id) + } + } + + init { + stakingUpdateSystem.start() + .launchIn(this) + } + + private fun learnMoreMigrationClicked(config: ChainMigrationConfig) { + launch { + openBrowserEvent.value = Event(config.wikiURL) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/ValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/ValidationFailure.kt new file mode 100644 index 0000000..0a87047 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/ValidationFailure.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationFailure + +fun mainStakingValidationFailure( + reason: StakeActionsValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage = with(resourceManager) { + when (reason) { + is StakeActionsValidationFailure.ControllerRequired -> { + getString(R.string.common_error_general_title) to + getString(R.string.staking_add_controller, reason.controllerAddress) + } + + is StakeActionsValidationFailure.UnbondingRequestLimitReached -> { + getString(R.string.staking_unbonding_limit_reached_title) to + getString(R.string.staking_unbonding_limit_reached_message, reason.limit) + } + + is StakeActionsValidationFailure.StashRequired -> { + getString(R.string.common_error_general_title) to + getString(R.string.staking_stash_missing_message, reason.stashAddress) + } + + is StakeActionsValidationFailure.StashRequiredToManageProxies -> { + getString(R.string.staking_manage_proxy_requires_stash_title) to + getString(R.string.staking_manage_proxy_requires_stash_message, reason.stashMetaAccount?.name ?: reason.stashAddress) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/ComponentHostContext.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/ComponentHostContext.kt new file mode 100644 index 0000000..61d1a2d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/ComponentHostContext.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components + +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow + +data class ComponentHostContext( + val errorDisplayer: (Throwable) -> Unit, + val validationExecutor: ValidationExecutor, + val selectedAccount: Flow, + val assetFlow: Flow, + val scope: ComputationalScope, + val externalActions: ExternalActions.Presentation +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/CompoundStatefullComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/CompoundStatefullComponent.kt new file mode 100644 index 0000000..92baa99 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/CompoundStatefullComponent.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.switchMap +import io.novafoundation.nova.common.utils.withItemScope +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.ALEPH_ZERO +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.MYTHOS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.NOMINATION_POOLS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.PARACHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN_AURA +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.TURING +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.UNSUPPORTED +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +typealias ComponentCreator = (StakingOption, hostContext: ComponentHostContext) -> StatefullComponent + +class CompoundStakingComponentFactory( + private val stakingSharedState: StakingSharedState, +) { + + fun create( + relaychainComponentCreator: ComponentCreator, + parachainComponentCreator: ComponentCreator, + turingComponentCreator: ComponentCreator = parachainComponentCreator, + nominationPoolsCreator: ComponentCreator, + mythosCreator: ComponentCreator, + hostContext: ComponentHostContext, + ): StatefullComponent = CompoundStakingComponent( + relaychainComponentCreator = relaychainComponentCreator, + parachainComponentCreator = parachainComponentCreator, + turingComponentCreator = turingComponentCreator, + nominationPoolsCreator = nominationPoolsCreator, + singleAssetSharedState = stakingSharedState, + mythosCreator = mythosCreator, + hostContext = hostContext + ) +} + +private class CompoundStakingComponent( + singleAssetSharedState: StakingSharedState, + + private val relaychainComponentCreator: ComponentCreator, + private val parachainComponentCreator: ComponentCreator, + private val turingComponentCreator: ComponentCreator, + private val nominationPoolsCreator: ComponentCreator, + private val mythosCreator: ComponentCreator, + private val hostContext: ComponentHostContext, +) : StatefullComponent, CoroutineScope by hostContext.scope, WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + private val delegateFlow = singleAssetSharedState.selectedOption + .withItemScope(parentScope = hostContext.scope) + .map { (stakingOption, itemScope) -> + val childHostContext = hostContext.copy(scope = ComputationalScope(itemScope)) + createDelegate(stakingOption, childHostContext) + }.shareInBackground() + + override val events: LiveData> = delegateFlow + .asLiveData() + .switchMap { it.events } + + override val state: Flow = delegateFlow + .flatMapLatest { it.state } + .shareInBackground() + + override fun onAction(action: A) { + launch { + delegateFlow.first().onAction(action) + } + } + + private fun createDelegate(stakingOption: StakingOption, childHostContext: ComponentHostContext): StatefullComponent { + return when (stakingOption.additional.stakingType) { + UNSUPPORTED -> UnsupportedComponent() + RELAYCHAIN, RELAYCHAIN_AURA, ALEPH_ZERO -> relaychainComponentCreator(stakingOption, childHostContext) + PARACHAIN -> parachainComponentCreator(stakingOption, childHostContext) + TURING -> turingComponentCreator(stakingOption, childHostContext) + NOMINATION_POOLS -> nominationPoolsCreator(stakingOption, childHostContext) + MYTHOS -> mythosCreator(stakingOption, childHostContext) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/StatefullComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/StatefullComponent.kt new file mode 100644 index 0000000..6df3e03 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/StatefullComponent.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.feature_staking_impl.R +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +interface StatefullComponent { + + /** + * one-shot events + */ + val events: LiveData> + + /** + * state will be null if component is not available in the current context + */ + val state: Flow + + fun onAction(action: ACTION) +} + +class UnsupportedComponent : StatefullComponent { + + companion object { + + fun creator(): ComponentCreator = { _, _ -> UnsupportedComponent() } + } + + override val events = MutableLiveData>() + + override val state: Flow = flowOf(null) + + override fun onAction(action: A) { + // pass + } +} + +interface AwaitableEvent { + + val value: ActionAwaitableMixin.Action +} + +typealias ChooseOneOfAwaitableEvent = AwaitableEvent, E> +typealias ChooseOneOfManyAwaitableEvent = AwaitableEvent, E> + +suspend fun , P, R> MutableLiveData>.awaitAction( + payload: P, + eventCreator: (ActionAwaitableMixin.Action) -> A +): R { + return suspendCancellableCoroutine { continuation -> + val action = ActionAwaitableMixin.Action( + payload = payload, + onSuccess = { continuation.resume(it) }, + onCancel = { continuation.cancel() } + ) + + // Type inference does not work in IR compiler + @Suppress("UNCHECKED_CAST") + (this as MutableLiveData).value = eventCreator(action).event() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertModel.kt new file mode 100644 index 0000000..321dd94 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts + +class AlertModel( + val title: CharSequence, + val extraMessage: CharSequence, + val type: Type +) { + sealed class Type { + object Info : Type() + + class CallToAction(val action: () -> Unit) : Type() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertUi.kt new file mode 100644 index 0000000..a8b91aa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertUi.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts + +import io.novafoundation.nova.common.base.BaseFragment + +fun BaseFragment<*, *>.setupAlertsComponent(component: AlertsComponent, view: AlertsView) { + // state + component.state.observe { networkInfoState -> + view.setState(networkInfoState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertsAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertsAdapter.kt new file mode 100644 index 0000000..882d5d4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertsAdapter.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_staking_impl.databinding.ItemAlertBinding + +class AlertsAdapter : ListAdapter(AlertDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AlertViewHolder { + return AlertViewHolder(ItemAlertBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: AlertViewHolder, position: Int) { + val item = getItem(position) + + holder.bind(item) + } + + class AlertViewHolder(private val binder: ItemAlertBinding) : RecyclerView.ViewHolder(binder.root) { + + fun bind(alert: AlertModel) = with(binder) { + alertItemTitle.text = alert.title + alertItemMessage.text = alert.extraMessage + + if (alert.type is AlertModel.Type.CallToAction) { + alertItemGoToFlowIcon.makeVisible() + + root.setOnClickListener { + alert.type.action() + } + } else { + alertItemGoToFlowIcon.makeGone() + } + } + } +} + +private class AlertDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AlertModel, newItem: AlertModel): Boolean { + return oldItem.title == newItem.title && oldItem.extraMessage == newItem.extraMessage + } + + override fun areContentsTheSame(oldItem: AlertModel, newItem: AlertModel): Boolean { + return true + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertsComponent.kt new file mode 100644 index 0000000..b941ed1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertsComponent.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts + +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.CompoundStakingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.StatefullComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.mythos.MythosAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.nominationPools.NominationPoolsAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.parachain.ParachainAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.relaychain.RelaychainAlertsComponentFactory + +typealias AlertsComponent = StatefullComponent + +typealias AlertsState = LoadingState> + +typealias AlertsEvent = Nothing + +typealias AlertsAction = Nothing + +class AlertsComponentFactory( + private val relaychainComponentFactory: RelaychainAlertsComponentFactory, + private val parachainAlertsComponentFactory: ParachainAlertsComponentFactory, + private val nominationPoolsFactory: NominationPoolsAlertsComponentFactory, + private val mythos: MythosAlertsComponentFactory, + private val compoundStakingComponentFactory: CompoundStakingComponentFactory, +) { + + fun create( + hostContext: ComponentHostContext, + ): AlertsComponent = compoundStakingComponentFactory.create( + relaychainComponentCreator = relaychainComponentFactory::create, + parachainComponentCreator = parachainAlertsComponentFactory::create, + nominationPoolsCreator = nominationPoolsFactory::create, + mythosCreator = mythos::create, + hostContext = hostContext + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertsView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertsView.kt new file mode 100644 index 0000000..b1b3569 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/AlertsView.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_staking_impl.databinding.ViewAlertsBinding + +class AlertsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val alertsAdapter = AlertsAdapter() + + private val binder = ViewAlertsBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + + with(context) { + background = getBlockDrawable() + } + + binder.alertsRecycler.adapter = alertsAdapter + } + + fun setStatus(alerts: List) { + if (alerts.isEmpty()) { + makeGone() + } else { + makeVisible() + + binder.alertShimmer.makeGone() + binder.alertsRecycler.makeVisible() + + alertsAdapter.submitList(alerts) + } + } + + fun showLoading() { + binder.alertShimmer.makeVisible() + binder.alertsRecycler.makeGone() + } +} + +fun AlertsView.setState(alertsState: AlertsState?) { + when (alertsState) { + is LoadingState.Loaded -> setStatus(alertsState.data) + is LoadingState.Loading -> showLoading() + null -> makeGone() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/mythos/MythosAlertsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/mythos/MythosAlertsComponent.kt new file mode 100644 index 0000000..facd42a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/mythos/MythosAlertsComponent.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.mythos + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.alerts.MythosStakingAlert +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.alerts.MythosStakingAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.mythos.loadUserStakeState +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class MythosAlertsComponentFactory( + private val mythosSharedComputation: MythosSharedComputation, + private val interactor: MythosStakingAlertsInteractor, + private val resourceManager: ResourceManager, + private val router: MythosStakingRouter, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): AlertsComponent = MythosAlertsComponent( + mythosSharedComputation = mythosSharedComputation, + interactor = interactor, + resourceManager = resourceManager, + hostContext = hostContext, + router = router, + amountFormatter = amountFormatter + ) +} + +private class MythosAlertsComponent( + private val mythosSharedComputation: MythosSharedComputation, + private val interactor: MythosStakingAlertsInteractor, + private val resourceManager: ResourceManager, + private val hostContext: ComponentHostContext, + private val router: MythosStakingRouter, + private val amountFormatter: AmountFormatter +) : AlertsComponent, + ComputationalScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + override val state = mythosSharedComputation.loadUserStakeState( + hostContext = hostContext, + stateProducer = ::stateFor + ) + .shareInBackground() + + override fun onAction(action: AlertsAction) {} + + private fun stateFor(delegatorState: MythosDelegatorState.Staked): Flow> { + return combine( + interactor.alertsFlow(delegatorState), + hostContext.assetFlow, + ) { alerts, asset -> + alerts.map { mapAlertToAlertModel(it, asset) } + } + } + + private fun mapAlertToAlertModel(alert: MythosStakingAlert, asset: Asset): AlertModel { + return when (alert) { + MythosStakingAlert.ChangeCollator -> AlertModel( + title = resourceManager.getString(R.string.parachain_staking_change_collator), + extraMessage = resourceManager.getString(R.string.parachain_staking_alerts_change_collator_message), + type = AlertModel.Type.CallToAction { router.openStakedCollators() } + ) + + is MythosStakingAlert.RedeemTokens -> { + val amount = amountFormatter.formatAmountToAmountModel(alert.redeemableAmount, asset).token + + AlertModel( + title = resourceManager.getString(R.string.staking_alert_redeem_title), + extraMessage = resourceManager.getString(R.string.parachain_staking_alerts_redeem_message, amount), + type = AlertModel.Type.CallToAction { router.openRedeem() } + ) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/nominationPools/NominationPoolsAlertsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/nominationPools/NominationPoolsAlertsComponent.kt new file mode 100644 index 0000000..600c168 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/nominationPools/NominationPoolsAlertsComponent.kt @@ -0,0 +1,102 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.nominationPools + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts.NominationPoolAlert +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts.NominationPoolsAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.nominationPools.loadPoolMemberState +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class NominationPoolsAlertsComponentFactory( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val interactor: NominationPoolsAlertsInteractor, + private val resourceManager: ResourceManager, + private val router: NominationPoolsRouter, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): AlertsComponent = NominationPoolsAlertsComponent( + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + nominationPoolSharedComputation = nominationPoolSharedComputation, + interactor = interactor, + router = router, + amountFormatter = amountFormatter + ) +} + +private open class NominationPoolsAlertsComponent( + private val router: NominationPoolsRouter, + private val interactor: NominationPoolsAlertsInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter, + nominationPoolSharedComputation: NominationPoolSharedComputation, + private val hostContext: ComponentHostContext, + private val stakingOption: StakingOption, +) : AlertsComponent, CoroutineScope by hostContext.scope { + + override val events = MutableLiveData>() + + override val state = nominationPoolSharedComputation.loadPoolMemberState( + hostContext = hostContext, + chain = stakingOption.assetWithChain.chain, + stateProducer = ::constructAlertsState, + ) + .shareInBackground() + + override fun onAction(action: AlertsAction) {} + + private fun constructAlertsState(poolMember: PoolMember): Flow> { + return combine( + interactor.alertsFlow(poolMember, stakingOption.assetWithChain.chain, hostContext.scope), + hostContext.assetFlow, + ) { alerts, asset -> + alerts.map { mapAlertToAlertModel(it, asset) } + } + } + + private fun openRedeem() { + router.openRedeem() + } + + private fun mapAlertToAlertModel(alert: NominationPoolAlert, asset: Asset): AlertModel { + return when (alert) { + is NominationPoolAlert.RedeemTokens -> { + val amount = amountFormatter.formatAmountToAmountModel(alert.amount, asset).token + + AlertModel( + title = resourceManager.getString(R.string.staking_alert_redeem_title), + extraMessage = amount, + type = AlertModel.Type.CallToAction(::openRedeem) + ) + } + + NominationPoolAlert.WaitingForNextEra -> AlertModel( + resourceManager.getString(R.string.staking_nominator_status_alert_waiting_message), + resourceManager.getString(R.string.staking_alert_start_next_era_message), + AlertModel.Type.Info + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/parachain/ParachainAlertsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/parachain/ParachainAlertsComponent.kt new file mode 100644 index 0000000..9fa4332 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/parachain/ParachainAlertsComponent.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.parachain + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.alerts.ParachainStakingAlert +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.alerts.ParachainStakingAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.parachainStaking.loadDelegatingState +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class ParachainAlertsComponentFactory( + private val delegatorStateUseCase: DelegatorStateUseCase, + private val interactor: ParachainStakingAlertsInteractor, + private val resourceManager: ResourceManager, + private val router: ParachainStakingRouter, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): AlertsComponent = ParachainAlertsComponent( + delegatorStateUseCase = delegatorStateUseCase, + interactor = interactor, + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + router = router, + amountFormatter = amountFormatter + ) +} + +private class ParachainAlertsComponent( + private val delegatorStateUseCase: DelegatorStateUseCase, + private val interactor: ParachainStakingAlertsInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val router: ParachainStakingRouter +) : AlertsComponent, + CoroutineScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + override val state = delegatorStateUseCase.loadDelegatingState( + hostContext = hostContext, + assetWithChain = stakingOption.assetWithChain, + stateProducer = ::stateFor + ) + .shareInBackground() + + override fun onAction(action: AlertsAction) {} + + private fun stateFor(delegatorState: DelegatorState.Delegator): Flow> { + return combine( + interactor.alertsFlow(delegatorState), + hostContext.assetFlow, + ) { alerts, asset -> + alerts.map { mapAlertToAlertModel(it, asset) } + } + } + + private fun mapAlertToAlertModel(alert: ParachainStakingAlert, asset: Asset): AlertModel { + return when (alert) { + ParachainStakingAlert.ChangeCollator -> AlertModel( + title = resourceManager.getString(R.string.parachain_staking_change_collator), + extraMessage = resourceManager.getString(R.string.parachain_staking_alerts_change_collator_message), + type = AlertModel.Type.CallToAction { router.openCurrentCollators() } + ) + + is ParachainStakingAlert.RedeemTokens -> { + val amount = amountFormatter.formatAmountToAmountModel(alert.redeemableAmount, asset).token + + AlertModel( + title = resourceManager.getString(R.string.staking_alert_redeem_title), + extraMessage = resourceManager.getString(R.string.parachain_staking_alerts_redeem_message, amount), + type = AlertModel.Type.CallToAction { router.openRedeem() } + ) + } + + ParachainStakingAlert.StakeMore -> AlertModel( + title = resourceManager.getString(R.string.staking_bond_more_v1_9_0), + extraMessage = resourceManager.getString(R.string.parachain_staking_alerts_bond_more_message), + type = AlertModel.Type.CallToAction { router.openCurrentCollators() } + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/relaychain/RelaychainAlertsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/relaychain/RelaychainAlertsComponent.kt new file mode 100644 index 0000000..c094fe0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/alerts/relaychain/RelaychainAlertsComponent.kt @@ -0,0 +1,188 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.relaychain + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.alerts.Alert +import io.novafoundation.nova.feature_staking_impl.domain.alerts.Alert.ChangeValidators.Reason +import io.novafoundation.nova.feature_staking_impl.domain.alerts.AlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.mainStakingValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.RoundingMode + +class RelaychainAlertsComponentFactory( + private val alertsInteractor: AlertsInteractor, + private val resourceManager: ResourceManager, + private val redeemValidationSystem: StakeActionsValidationSystem, + private val bondMoreValidationSystem: StakeActionsValidationSystem, + private val rebagValidationSystem: StakeActionsValidationSystem, + private val router: StakingRouter, + private val stakingSharedComputation: StakingSharedComputation, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): AlertsComponent = RelaychainAlertsComponent( + stakingSharedComputation = stakingSharedComputation, + resourceManager = resourceManager, + alertsInteractor = alertsInteractor, + redeemValidationSystem = redeemValidationSystem, + bondMoreValidationSystem = bondMoreValidationSystem, + rebagValidationSystem = rebagValidationSystem, + router = router, + stakingOption = stakingOption, + hostContext = hostContext + ) +} + +private class RelaychainAlertsComponent( + private val alertsInteractor: AlertsInteractor, + private val resourceManager: ResourceManager, + private val stakingSharedComputation: StakingSharedComputation, + + private val hostContext: ComponentHostContext, + private val stakingOption: StakingOption, + + private val redeemValidationSystem: StakeActionsValidationSystem, + private val bondMoreValidationSystem: StakeActionsValidationSystem, + private val rebagValidationSystem: StakeActionsValidationSystem, + private val router: StakingRouter, +) : AlertsComponent, + CoroutineScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + private val selectedAccountStakingStateFlow = stakingSharedComputation.selectedAccountStakingStateFlow( + assetWithChain = stakingOption.assetWithChain, + scope = hostContext.scope + ) + + override val events = MutableLiveData>() + + override val state: Flow = selectedAccountStakingStateFlow.flatMapLatest { + alertsInteractor.getAlertsFlow(it, hostContext.scope) + } + .mapList { mapAlertToAlertModel(it) } + .withLoading() + .onStart { emit(null) } + .shareInBackground() + + override fun onAction(action: AlertsAction) {} + + private fun mapAlertToAlertModel(alert: Alert): AlertModel { + return when (alert) { + is Alert.ChangeValidators -> { + val message = when (alert.reason) { + Reason.NONE_ELECTED -> R.string.staking_nominator_status_alert_no_validators + Reason.OVERSUBSCRIBED -> R.string.staking_your_oversubscribed_message + } + + AlertModel( + resourceManager.getString(R.string.staking_alert_change_validators), + resourceManager.getString(message), + AlertModel.Type.CallToAction { router.openCurrentValidators() } + ) + } + + is Alert.RedeemTokens -> { + AlertModel( + resourceManager.getString(R.string.staking_alert_redeem_title), + formatAlertTokenAmount(alert.amount, alert.token, RoundingMode.FLOOR), + AlertModel.Type.CallToAction(::redeemAlertClicked) + ) + } + + is Alert.BondMoreTokens -> { + val minStakeFormatted = formatAlertTokenAmount(alert.minimalStake, alert.token, RoundingMode.CEILING) + + AlertModel( + resourceManager.getString(R.string.staking_alert_bond_more_title), + resourceManager.getString(R.string.staking_alert_bond_more_message, minStakeFormatted), + AlertModel.Type.CallToAction(::bondMoreAlertClicked) + ) + } + + is Alert.WaitingForNextEra -> AlertModel( + resourceManager.getString(R.string.staking_nominator_status_alert_waiting_message), + resourceManager.getString(R.string.staking_alert_start_next_era_message), + AlertModel.Type.Info + ) + Alert.SetValidators -> AlertModel( + resourceManager.getString(R.string.staking_set_validators_title), + resourceManager.getString(R.string.staking_set_validators_message), + AlertModel.Type.CallToAction { router.openCurrentValidators() } + ) + Alert.Rebag -> AlertModel( + resourceManager.getString(R.string.staking_alert_rebag_title), + resourceManager.getString(R.string.staking_alert_rebag_message), + AlertModel.Type.CallToAction(::rebagClicked) + ) + } + } + + private fun formatAlertTokenAmount(amount: BigDecimal, token: Token, roundingMode: RoundingMode): String { + val formattedFiat = token.amountToFiat(amount).formatAsCurrency(token.currency) + val formattedAmount = amount.formatTokenAmount(token.configuration, roundingMode) + + return buildString { + append(formattedAmount) + + append(" ($formattedFiat)") + } + } + + private fun bondMoreAlertClicked() = requireValidManageStakingAction(bondMoreValidationSystem) { + router.openBondMore() + } + + private fun redeemAlertClicked() = requireValidManageStakingAction(redeemValidationSystem) { + router.openRedeem() + } + + private fun rebagClicked() = requireValidManageStakingAction(rebagValidationSystem) { + router.openRebag() + } + + private fun requireValidManageStakingAction( + validationSystem: StakeActionsValidationSystem, + action: () -> Unit, + ) = launch { + val stakingState = selectedAccountStakingStateFlow.first() + val stashState = stakingState as? StakingState.Stash ?: return@launch + + hostContext.validationExecutor.requireValid( + validationSystem = validationSystem, + payload = StakeActionsValidationPayload(stashState), + errorDisplayer = hostContext.errorDisplayer, + validationFailureTransformerDefault = { mainStakingValidationFailure(it, resourceManager) }, + scope = hostContext.scope + ) { + action() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/LoadHasStakingComponentState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/LoadHasStakingComponentState.kt new file mode 100644 index 0000000..42eabf9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/LoadHasStakingComponentState.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common + +import android.util.Log +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.withIndex + +inline fun loadHasStakingComponentState( + hostContext: ComponentHostContext, + crossinline hasStakingStateProducer: (MetaAccount) -> Flow, + crossinline componentStateProducer: suspend (S) -> Flow, + crossinline onComponentStateChange: (S) -> Unit +): Flow?> = hostContext.selectedAccount.transformLatest { account -> + emit(null) // hide UI until state of staking is determined + + val stateFlow = hasStakingStateProducer(account) + .withIndex() + .transformLatest { (index, hasStakingState) -> + if (hasStakingState != null) { + // first loading of might take a while - show loading. + // We do not show loading for subsequent updates since there is already some info on the screen from the first load + if (index == 0) { + onComponentStateChange(hasStakingState) + + emit(LoadingState.Loading()) + } + + val summaryFlow = componentStateProducer(hasStakingState).map { LoadingState.Loaded(it) } + + emitAll(summaryFlow) + } else { + emit(null) + } + } + + emitAll(stateFlow) +} + .onStart { emit(null) } + .catch { Log.e("StatefullComponent", "Failed to construct state", it) } diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/mythos/loadUserStakeState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/mythos/loadUserStakeState.kt new file mode 100644 index 0000000..cbd80f2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/mythos/loadUserStakeState.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.mythos + +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.loadHasStakingComponentState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +fun MythosSharedComputation.loadUserStakeState( + hostContext: ComponentHostContext, + stateProducer: suspend (MythosDelegatorState.Staked) -> Flow, + distinctUntilChanged: (MythosDelegatorState.Staked, MythosDelegatorState.Staked) -> Boolean = { _, _ -> false }, +): Flow?> = loadHasStakingComponentState( + hostContext = hostContext, + hasStakingStateProducer = { + with(hostContext.scope) { + delegatorStateFlow() + .map { it as? MythosDelegatorState.Staked } + .distinctUntilChanged { old, new -> + if (old == null || new == null) return@distinctUntilChanged false + + distinctUntilChanged(old, new) + } + } + }, + componentStateProducer = stateProducer, + onComponentStateChange = {} +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/nominationPools/loadPoolMemberState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/nominationPools/loadPoolMemberState.kt new file mode 100644 index 0000000..5619039 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/nominationPools/loadPoolMemberState.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.nominationPools + +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.loadHasStakingComponentState +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged + +context(CoroutineScope) +fun NominationPoolSharedComputation.loadPoolMemberState( + hostContext: ComponentHostContext, + chain: Chain, + stateProducer: suspend (PoolMember) -> Flow, + distinctUntilChanged: (PoolMember?, PoolMember?) -> Boolean = { _, _ -> false }, +): Flow?> = loadHasStakingComponentState( + hostContext = hostContext, + hasStakingStateProducer = { currentPoolMemberFlow(chain, this@CoroutineScope).distinctUntilChanged(distinctUntilChanged) }, + componentStateProducer = stateProducer, + onComponentStateChange = {} +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/parachainStaking/Ext.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/parachainStaking/Ext.kt new file mode 100644 index 0000000..5404ba5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/common/parachainStaking/Ext.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.parachainStaking + +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.loadHasStakingComponentState +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +fun DelegatorStateUseCase.loadDelegatingState( + hostContext: ComponentHostContext, + assetWithChain: ChainWithAsset, + stateProducer: suspend (DelegatorState.Delegator) -> Flow, + onDelegatorChange: (DelegatorState.Delegator) -> Unit = {} +): Flow?> = loadHasStakingComponentState( + hostContext = hostContext, + hasStakingStateProducer = { account -> + delegatorStateFlow(account, assetWithChain.chain, assetWithChain.asset) + .map { it as? DelegatorState.Delegator } + }, + componentStateProducer = stateProducer, + onComponentStateChange = onDelegatorChange +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/BaseNetworkInfoComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/BaseNetworkInfoComponent.kt new file mode 100644 index 0000000..c72231f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/BaseNetworkInfoComponent.kt @@ -0,0 +1,131 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo + +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.asLoaded +import io.novafoundation.nova.common.domain.map +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.model.NetworkInfo +import io.novafoundation.nova.feature_staking_impl.domain.model.StakingPeriod +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsEvent +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.math.RoundingMode + +abstract class BaseNetworkInfoComponent( + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter, + coroutineScope: CoroutineScope, + @StringRes titleRes: Int, +) : NetworkInfoComponent, + CoroutineScope by coroutineScope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + override val events = MutableLiveData>() + + override val state = MutableStateFlow(initialState(titleRes)) + + abstract fun initialItems(): List + + override fun onAction(action: NetworkInfoAction) { + when (action) { + NetworkInfoAction.ChangeExpendedStateClicked -> updateExpanded { !it.expanded } + } + } + + protected fun createNetworkInfoItems( + asset: Asset, + networkInfo: NetworkInfo, + @StringRes nominatorsLabel: Int? + ): List { + val unstakingPeriod = resourceManager.formatDuration(networkInfo.lockupPeriod) + + val stakingPeriod = when (networkInfo.stakingPeriod) { + StakingPeriod.Unlimited -> resourceManager.getString(R.string.common_unlimited) + } + + return createNetworkInfoItems( + totalStaked = amountFormatter.formatAmountToAmountModel(networkInfo.totalStake, asset).asLoaded(), + minimumStake = amountFormatter.formatAmountToAmountModel( + networkInfo.minimumStake, + asset, + AmountConfig(roundingMode = RoundingMode.CEILING) + ).asLoaded(), + activeNominators = networkInfo.nominatorsCount?.format()?.asLoaded(), + unstakingPeriod = unstakingPeriod.asLoaded(), + stakingPeriod = stakingPeriod.asLoaded(), + nominatorsLabel = nominatorsLabel + ) + } + + protected fun updateState(update: (NetworkInfoState) -> NetworkInfoState) { + state.value = update(state.value) + } + + private fun initialState(@StringRes titleRes: Int): NetworkInfoState { + return NetworkInfoState( + title = resourceManager.getString(titleRes), + actions = initialItems(), + expanded = false + ) + } + + protected fun createNetworkInfoItems( + totalStaked: ExtendedLoadingState = ExtendedLoadingState.Loading, + minimumStake: ExtendedLoadingState = ExtendedLoadingState.Loading, + activeNominators: ExtendedLoadingState? = ExtendedLoadingState.Loading, + stakingPeriod: ExtendedLoadingState = ExtendedLoadingState.Loading, + unstakingPeriod: ExtendedLoadingState = ExtendedLoadingState.Loading, + @StringRes nominatorsLabel: Int? + ): List { + val nominatorsItem = if (nominatorsLabel != null && activeNominators != null) { + NetworkInfoItem( + title = resourceManager.getString(nominatorsLabel), + content = activeNominators.toNetworkInfoContent() + ) + } else { + null + } + + return listOfNotNull( + NetworkInfoItem.totalStaked(resourceManager, totalStaked.toNetworkInfoContent()), + NetworkInfoItem.minimumStake(resourceManager, minimumStake.toNetworkInfoContent()), + nominatorsItem, + NetworkInfoItem.stakingPeriod(resourceManager, stakingPeriod.toNetworkInfoContent()), + NetworkInfoItem.unstakingPeriod(resourceManager, unstakingPeriod.toNetworkInfoContent()) + ) + } + + protected fun updateExpandedState(with: Flow) { + with + .onEach { expanded -> updateExpanded { expanded } } + .inBackground() + .catch { Log.e(LOG_TAG, "Error while updating expanded state", it) } + .launchIn(this) + } + + private fun updateExpanded(expanded: (NetworkInfoState) -> Boolean) = updateState { + it.copy(expanded = expanded(it)) + } + + @JvmName("toNetworkInfoContentString") + private fun ExtendedLoadingState.toNetworkInfoContent() = map { NetworkInfoItem.Content(primary = it, secondary = null) } + private fun ExtendedLoadingState.toNetworkInfoContent() = map { NetworkInfoItem.Content(primary = it.token, secondary = it.fiat) } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoAdapter.kt new file mode 100644 index 0000000..b969b5b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoAdapter.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo + +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.domain.onNotLoaded +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.view.TableCellView + +class NetworkInfoAdapter : BaseListAdapter(DiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NetworkInfoHolder { + val view = TableCellView(parent.context).apply { + layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + } + + return NetworkInfoHolder(view) + } + + override fun onBindViewHolder(holder: NetworkInfoHolder, position: Int) { + val isLast = position == currentList.size - 1 + + holder.bind(getItem(position), isLast) + } +} + +private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: NetworkInfoItem, newItem: NetworkInfoItem): Boolean { + return oldItem.title == newItem.title + } + + override fun areContentsTheSame(oldItem: NetworkInfoItem, newItem: NetworkInfoItem): Boolean { + return oldItem.content == newItem.content + } +} + +class NetworkInfoHolder(override val containerView: TableCellView) : BaseViewHolder(containerView) { + + fun bind(item: NetworkInfoItem, isLast: Boolean) = with(containerView) { + setTitle(item.title) + + item.content + .onLoaded { showValue(it.primary, it.secondary) } + .onNotLoaded { showProgress() } + + setOwnDividerVisible(!isLast) + } + + override fun unbind() {} +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoComponent.kt new file mode 100644 index 0000000..954fb75 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoComponent.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo + +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.CompoundStakingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.StatefullComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.UnsupportedComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.nominationPools.NominationPoolsNetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.parachain.ParachainNetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.relaychain.RelaychainNetworkInfoComponentFactory + +typealias NetworkInfoComponent = StatefullComponent + +data class NetworkInfoState( + val title: String, + val actions: List, + val expanded: Boolean, +) + +typealias NetworkInfoEvent = Nothing + +sealed class NetworkInfoAction { + + object ChangeExpendedStateClicked : NetworkInfoAction() +} + +class NetworkInfoComponentFactory( + private val relaychainComponentFactory: RelaychainNetworkInfoComponentFactory, + private val parachainComponentFactory: ParachainNetworkInfoComponentFactory, + private val nominationPoolsComponentFactory: NominationPoolsNetworkInfoComponentFactory, + private val compoundStakingComponentFactory: CompoundStakingComponentFactory, +) { + + fun create( + hostContext: ComponentHostContext + ): NetworkInfoComponent = compoundStakingComponentFactory.create( + relaychainComponentCreator = relaychainComponentFactory::create, + parachainComponentCreator = parachainComponentFactory::create, + nominationPoolsCreator = nominationPoolsComponentFactory::create, + // TODO network info + mythosCreator = UnsupportedComponent.creator(), + hostContext = hostContext + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoItem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoItem.kt new file mode 100644 index 0000000..ecc1a9a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoItem.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R + +data class NetworkInfoItem(val title: String, val content: ExtendedLoadingState) { + + companion object; + + data class Content(val primary: CharSequence, val secondary: CharSequence?) +} + +fun NetworkInfoItem.Companion.totalStaked(resourceManager: ResourceManager, content: ExtendedLoadingState): NetworkInfoItem { + return NetworkInfoItem( + title = resourceManager.getString(R.string.staking_total_staked), + content = content + ) +} + +fun NetworkInfoItem.Companion.minimumStake(resourceManager: ResourceManager, content: ExtendedLoadingState): NetworkInfoItem { + return NetworkInfoItem( + title = resourceManager.getString(R.string.staking_main_minimum_stake_title), + content = content + ) +} + +fun NetworkInfoItem.Companion.activeNominators(resourceManager: ResourceManager, content: ExtendedLoadingState): NetworkInfoItem { + return NetworkInfoItem( + title = resourceManager.getString(R.string.staking_main_active_nominators_title), + content = content + ) +} + +fun NetworkInfoItem.Companion.stakingPeriod(resourceManager: ResourceManager, content: ExtendedLoadingState): NetworkInfoItem { + return NetworkInfoItem( + title = resourceManager.getString(R.string.staking_staking_period), + content = content + ) +} + +fun NetworkInfoItem.Companion.unstakingPeriod(resourceManager: ResourceManager, content: ExtendedLoadingState): NetworkInfoItem { + return NetworkInfoItem( + title = resourceManager.getString(R.string.staking_unbonding_period_v1_9_0), + content = content + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoUi.kt new file mode 100644 index 0000000..ade21da --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoUi.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo + +import io.novafoundation.nova.common.base.BaseFragment + +fun BaseFragment<*, *>.setupNetworkInfoComponent(component: NetworkInfoComponent, view: NetworkInfoView) { + // state + component.state.observe { networkInfoState -> + view.setState(networkInfoState) + } + + // actions + view.onExpandClicked { + component.onAction(NetworkInfoAction.ChangeExpendedStateClicked) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoView.kt new file mode 100644 index 0000000..b6e1874 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/NetworkInfoView.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ViewNetworkInfoBinding + +private const val ANIMATION_DURATION = 220L + +class NetworkInfoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : LinearLayout(context, attrs, defStyle) { + + enum class State { + EXPANDED, + COLLAPSED + } + + private var currentState = State.COLLAPSED + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + NetworkInfoAdapter() + } + + private val binder = ViewNetworkInfoBinding.inflate(inflater(), this) + + init { + with(context) { + background = getBlockDrawable() + } + + orientation = VERTICAL + + binder.stakingNetworkCollapsibleView.adapter = adapter + binder.stakingNetworkCollapsibleView.itemAnimator = null + } + + fun setState(state: NetworkInfoState?) = letOrHide(state) { networkInfoState -> + setExpanded(networkInfoState.expanded) + + binder.stakingNetworkInfoTitle.text = networkInfoState.title + + adapter.submitList(networkInfoState.actions) + } + + fun onExpandClicked(listener: OnClickListener) { + binder.stakingNetworkInfoTitle.setOnClickListener(listener) + } + + private fun setExpanded(expanded: Boolean) { + if (expanded) { + expand() + } else { + collapse() + } + } + + private fun collapse() { + if (currentState == State.COLLAPSED) return + + binder.stakingNetworkInfoTitle.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_chevron_down, 0) + currentState = State.COLLAPSED + binder.stakingNetworkCollapsibleView.animate() + .setDuration(ANIMATION_DURATION) + .alpha(0f) + .withEndAction { binder.stakingNetworkCollapsibleView.makeGone() } + } + + private fun expand() { + if (currentState == State.EXPANDED) return + + binder.stakingNetworkInfoTitle.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_chevron_up, 0) + binder.stakingNetworkCollapsibleView.makeVisible() + currentState = State.EXPANDED + binder.stakingNetworkCollapsibleView.animate() + .setDuration(ANIMATION_DURATION) + .alpha(1f) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/nominationPools/NominationPoolsNetworkInfoComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/nominationPools/NominationPoolsNetworkInfoComponent.kt new file mode 100644 index 0000000..6caabf4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/nominationPools/NominationPoolsNetworkInfoComponent.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.nominationPools + +import android.util.Log +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.networkInfo.NominationPoolsNetworkInfoInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.BaseNetworkInfoComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.NetworkInfoComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.NetworkInfoItem +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn + +class NominationPoolsNetworkInfoComponentFactory( + private val interactor: NominationPoolsNetworkInfoInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): NetworkInfoComponent = NominationPoolsNetworkInfoComponent( + interactor = interactor, + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + amountFormatter = amountFormatter + ) +} + +private class NominationPoolsNetworkInfoComponent( + private val interactor: NominationPoolsNetworkInfoInteractor, + amountFormatter: AmountFormatter, + resourceManager: ResourceManager, + + private val hostContext: ComponentHostContext, + private val stakingOption: StakingOption, +) : BaseNetworkInfoComponent(resourceManager, amountFormatter, hostContext.scope, titleRes = R.string.nomination_pools_info) { + + init { + updateContentState() + + updateExpandedState(with = shouldBeExpandedFlow()) + } + + override fun initialItems(): List { + return createNetworkInfoItems(activeNominators = null, nominatorsLabel = null) + } + + private fun shouldBeExpandedFlow(): Flow { + return interactor.observeShouldShowNetworkInfo() + } + + private fun updateContentState() { + combine( + hostContext.assetFlow, + interactor.observeNetworkInfo(stakingOption.assetWithChain.chain.id, hostContext.scope) + ) { asset, networkInfo -> + val items = createNetworkInfoItems(asset, networkInfo, nominatorsLabel = null) + + updateState { it.copy(actions = items) } + } + .catch { Log.e(LOG_TAG, "Error while updating content state", it) } + .inBackground() + .launchIn(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/parachain/ParachainNetworkInfoComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/parachain/ParachainNetworkInfoComponent.kt new file mode 100644 index 0000000..85ff30d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/parachain/ParachainNetworkInfoComponent.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.parachain + +import android.util.Log +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.ParachainNetworkInfoInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.BaseNetworkInfoComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.NetworkInfoComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.NetworkInfoItem +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map + +class ParachainNetworkInfoComponentFactory( + private val interactor: ParachainNetworkInfoInteractor, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): NetworkInfoComponent = ParachainNetworkInfoComponent( + interactor = interactor, + delegatorStateUseCase = delegatorStateUseCase, + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + amountFormatter = amountFormatter + ) +} + +private val NOMINATORS_TITLE_RES = R.string.staking_active_delegators + +private class ParachainNetworkInfoComponent( + private val interactor: ParachainNetworkInfoInteractor, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val amountFormatter: AmountFormatter, + resourceManager: ResourceManager, + + private val hostContext: ComponentHostContext, + private val stakingOption: StakingOption, +) : BaseNetworkInfoComponent(resourceManager, amountFormatter, hostContext.scope, titleRes = R.string.staking_info) { + + private val delegatorStateFlow = hostContext.selectedAccount.flatMapLatest { + delegatorStateUseCase.delegatorStateFlow(it, stakingOption.assetWithChain.chain, stakingOption.assetWithChain.asset) + }.shareInBackground() + + init { + updateContentState() + + updateExpandedState(with = expandForceChangeFlow()) + } + + override fun initialItems(): List { + return createNetworkInfoItems(nominatorsLabel = NOMINATORS_TITLE_RES) + } + + private fun expandForceChangeFlow(): Flow { + return delegatorStateFlow.map { it is DelegatorState.None } + } + + private fun updateContentState() { + combine( + hostContext.assetFlow, + interactor.observeNetworkInfo(stakingOption.assetWithChain.chain.id) + ) { asset, networkInfo -> + val items = createNetworkInfoItems(asset, networkInfo, nominatorsLabel = NOMINATORS_TITLE_RES) + + updateState { it.copy(actions = items) } + } + .catch { Log.e(LOG_TAG, "Error while updating content state", it) } + .inBackground() + .launchIn(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/relaychain/RelaychainNetworkInfoComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/relaychain/RelaychainNetworkInfoComponent.kt new file mode 100644 index 0000000..2910505 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/networkInfo/relaychain/RelaychainNetworkInfoComponent.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.relaychain + +import android.util.Log +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.BaseNetworkInfoComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.NetworkInfoComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.NetworkInfoItem +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map + +class RelaychainNetworkInfoComponentFactory( + private val stakingInteractor: StakingInteractor, + private val resourceManager: ResourceManager, + private val stakingSharedComputation: StakingSharedComputation, + private val amountFormatter: AmountFormatter, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): NetworkInfoComponent = RelaychainNetworkInfoComponent( + stakingInteractor = stakingInteractor, + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + stakingSharedComputation = stakingSharedComputation, + amountFormatter = amountFormatter + ) +} + +private val NOMINATORS_TITLE_RES = R.string.staking_main_active_nominators_title + +private class RelaychainNetworkInfoComponent( + private val stakingInteractor: StakingInteractor, + private val stakingSharedComputation: StakingSharedComputation, + private val amountFormatter: AmountFormatter, + resourceManager: ResourceManager, + + private val hostContext: ComponentHostContext, + private val stakingOption: StakingOption, +) : BaseNetworkInfoComponent(resourceManager, amountFormatter, hostContext.scope, titleRes = R.string.staking_info) { + + private val selectedAccountStakingStateFlow = stakingSharedComputation.selectedAccountStakingStateFlow( + assetWithChain = stakingOption.assetWithChain, + scope = hostContext.scope + ) + + init { + updateContentState() + + updateExpandedState(with = expandForceChangeFlow()) + } + + override fun initialItems(): List { + return createNetworkInfoItems(nominatorsLabel = NOMINATORS_TITLE_RES) + } + + private fun expandForceChangeFlow(): Flow { + return selectedAccountStakingStateFlow.map { it is StakingState.NonStash } + } + + private fun updateContentState() { + combine( + hostContext.assetFlow, + stakingInteractor.observeNetworkInfoState(stakingOption.assetWithChain.chain.id, hostContext.scope) + ) { asset, networkInfo -> + val items = createNetworkInfoItems(asset, networkInfo, nominatorsLabel = NOMINATORS_TITLE_RES) + + updateState { it.copy(actions = items) } + } + .catch { Log.e(LOG_TAG, "Error while updating content state", it) } + .inBackground() + .launchIn(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/ManageStakeAction.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/ManageStakeAction.kt new file mode 100644 index 0000000..4d05f40 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/ManageStakeAction.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_ADD_PROXY +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_CONTROLLER +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_PAYOUTS +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_PROXIES +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_REWARD_DESTINATION +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_BOND_MORE +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_UNBOND +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_VALIDATORS + +class ManageStakeAction( + val id: String, + val label: String, + @DrawableRes val iconRes: Int, + val badge: String? = null, +) { + companion object +} + +fun ManageStakeAction.Companion.bondMore(resourceManager: ResourceManager): ManageStakeAction { + return ManageStakeAction( + id = SYSTEM_MANAGE_STAKING_BOND_MORE, + label = resourceManager.getString(R.string.staking_bond_more_v1_9_0), + iconRes = R.drawable.ic_add_circle_outline + ) +} + +fun ManageStakeAction.Companion.unbond(resourceManager: ResourceManager): ManageStakeAction { + return ManageStakeAction( + id = SYSTEM_MANAGE_STAKING_UNBOND, + label = resourceManager.getString(R.string.staking_unbond_v1_9_0), + iconRes = R.drawable.ic_minus_circle_outline + ) +} + +fun ManageStakeAction.Companion.rewardDestination(resourceManager: ResourceManager): ManageStakeAction { + return ManageStakeAction( + id = SYSTEM_MANAGE_REWARD_DESTINATION, + label = resourceManager.getString(R.string.staking_rewards_destination_title_v2_0_0), + iconRes = R.drawable.ic_buy_outline + ) +} + +fun ManageStakeAction.Companion.payouts(resourceManager: ResourceManager): ManageStakeAction { + return ManageStakeAction( + id = SYSTEM_MANAGE_PAYOUTS, + label = resourceManager.getString(R.string.staking_reward_payouts_title_v2_2_0), + iconRes = R.drawable.ic_unpaid_rewards + ) +} + +fun ManageStakeAction.Companion.validators(resourceManager: ResourceManager): ManageStakeAction { + return ManageStakeAction( + id = SYSTEM_MANAGE_VALIDATORS, + label = resourceManager.getString(R.string.staking_your_validators), + iconRes = R.drawable.ic_validators_outline + ) +} + +fun ManageStakeAction.Companion.controller(resourceManager: ResourceManager): ManageStakeAction { + return ManageStakeAction( + id = SYSTEM_MANAGE_CONTROLLER, + label = resourceManager.getString(R.string.staking_controller_account), + iconRes = R.drawable.ic_people_outline + ) +} + +fun ManageStakeAction.Companion.addStakingProxy(resourceManager: ResourceManager): ManageStakeAction { + return ManageStakeAction( + id = SYSTEM_ADD_PROXY, + label = resourceManager.getString(R.string.staking_action_add_proxy), + iconRes = R.drawable.ic_delegate_outline + ) +} + +fun ManageStakeAction.Companion.stakingProxies(resourceManager: ResourceManager, delegations: String): ManageStakeAction { + return ManageStakeAction( + id = SYSTEM_MANAGE_PROXIES, + label = resourceManager.getString(R.string.staking_action_your_proxies), + iconRes = R.drawable.ic_delegate_outline, + badge = delegations + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/StakeActionsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/StakeActionsComponent.kt new file mode 100644 index 0000000..212d1ba --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/StakeActionsComponent.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions + +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.CompoundStakingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.StatefullComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.mythos.MythosStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.nominationPools.NominationPoolsStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain.ParachainStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain.turing.TuringStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.relaychain.RelaychainStakeActionsComponentFactory + +typealias StakeActionsComponent = StatefullComponent + +class StakeActionsState( + val availableActions: List +) + +typealias StakeActionsEvent = Nothing + +sealed class StakeActionsAction { + + class ActionClicked(val action: ManageStakeAction) : StakeActionsAction() +} + +class StakeActionsComponentFactory( + private val relaychainComponentFactory: RelaychainStakeActionsComponentFactory, + private val parachainComponentFactory: ParachainStakeActionsComponentFactory, + private val turingComponentFactory: TuringStakeActionsComponentFactory, + private val nominationPoolsComponentFactory: NominationPoolsStakeActionsComponentFactory, + private val mythosStakeActionsComponentFactory: MythosStakeActionsComponentFactory, + private val compoundStakingComponentFactory: CompoundStakingComponentFactory, +) { + + fun create( + hostContext: ComponentHostContext + ): StakeActionsComponent = compoundStakingComponentFactory.create( + relaychainComponentCreator = relaychainComponentFactory::create, + parachainComponentCreator = parachainComponentFactory::create, + nominationPoolsCreator = nominationPoolsComponentFactory::create, + turingComponentCreator = turingComponentFactory::create, + mythosCreator = mythosStakeActionsComponentFactory::create, + hostContext = hostContext + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/StakeActionsUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/StakeActionsUi.kt new file mode 100644 index 0000000..fcb54a4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/StakeActionsUi.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.view.ManageStakingView + +fun BaseFragment<*, *>.setupStakeActionsComponent(component: StakeActionsComponent, view: ManageStakingView) { + // state + component.state.observe { stakeActionsState -> + if (stakeActionsState == null) { + view.makeGone() + return@observe + } + + view.makeVisible() + + view.setAvailableActions(stakeActionsState.availableActions) + } + + // actions + view.onManageStakeActionClicked { + component.onAction(StakeActionsAction.ActionClicked(it)) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/mythos/MythosStakeActionsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/mythos/MythosStakeActionsComponent.kt new file mode 100644 index 0000000..9802bea --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/mythos/MythosStakeActionsComponent.kt @@ -0,0 +1,104 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.mythos + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.presentation.dataOrNull +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.hasStakedCollators +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_BOND_MORE +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_UNBOND +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_VALIDATORS +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.mythos.loadUserStakeState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.ManageStakeAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.bondMore +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain.collators +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.unbond +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class MythosStakeActionsComponentFactory( + private val mythosSharedComputation: MythosSharedComputation, + private val resourceManager: ResourceManager, + private val router: MythosStakingRouter, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): StakeActionsComponent = MythosStakeActionsComponent( + mythosSharedComputation = mythosSharedComputation, + resourceManager = resourceManager, + router = router, + stakingOption = stakingOption, + hostContext = hostContext + ) +} + +private class MythosStakeActionsComponent( + private val mythosSharedComputation: MythosSharedComputation, + private val resourceManager: ResourceManager, + private val router: MythosStakingRouter, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, +) : StakeActionsComponent, + CoroutineScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + override val state = mythosSharedComputation.loadUserStakeState( + hostContext = hostContext, + stateProducer = ::stateFor + ) + .map { it?.dataOrNull } + .shareInBackground() + + override fun onAction(action: StakeActionsAction) { + when (action) { + is StakeActionsAction.ActionClicked -> { + navigateToAction(action.action) + } + } + } + + private fun navigateToAction(action: ManageStakeAction) { + when (action.id) { + SYSTEM_MANAGE_STAKING_BOND_MORE -> router.openBondMore() + SYSTEM_MANAGE_STAKING_UNBOND -> router.openUnbond() + SYSTEM_MANAGE_VALIDATORS -> router.openStakedCollators() + } + } + + private fun stateFor(delegatorState: MythosDelegatorState.Staked): Flow { + return flowOf { + val availableActions = availableStakingActionsFor(delegatorState) + + StakeActionsState(availableActions) + } + } + + private fun availableStakingActionsFor(delegatorState: MythosDelegatorState.Staked): List = buildList { + add(ManageStakeAction.bondMore(resourceManager)) + + if (delegatorState.hasStakedCollators()) { + add(ManageStakeAction.unbond(resourceManager)) + + val collatorsCount = delegatorState.userStakeInfo.candidates.size.format() + add(ManageStakeAction.collators(resourceManager, collatorsCount)) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/nominationPools/NominationPoolsStakeActionsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/nominationPools/NominationPoolsStakeActionsComponent.kt new file mode 100644 index 0000000..13747b8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/nominationPools/NominationPoolsStakeActionsComponent.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.nominationPools + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.presentation.dataOrNull +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_BOND_MORE +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_UNBOND +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.nominationPools.loadPoolMemberState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.ManageStakeAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.bondMore +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.unbond +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class NominationPoolsStakeActionsComponentFactory( + private val router: NominationPoolsRouter, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val resourceManager: ResourceManager, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): StakeActionsComponent = NominationPoolsStakeActionsComponent( + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + nominationPoolSharedComputation = nominationPoolSharedComputation, + router = router + ) +} + +private open class NominationPoolsStakeActionsComponent( + nominationPoolSharedComputation: NominationPoolSharedComputation, + stakingOption: StakingOption, + private val router: NominationPoolsRouter, + private val hostContext: ComponentHostContext, + private val resourceManager: ResourceManager, +) : StakeActionsComponent, + CoroutineScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + override val state = nominationPoolSharedComputation.loadPoolMemberState( + hostContext = hostContext, + chain = stakingOption.assetWithChain.chain, + stateProducer = ::stakeActionsState + ) + .map { it?.dataOrNull } + .shareInBackground() + + override fun onAction(action: StakeActionsAction) { + when (action) { + is StakeActionsAction.ActionClicked -> { + navigateToAction(action.action) + } + } + } + + private fun navigateToAction(action: ManageStakeAction) { + when (action.id) { + SYSTEM_MANAGE_STAKING_BOND_MORE -> router.openSetupBondMore() + SYSTEM_MANAGE_STAKING_UNBOND -> router.openSetupUnbond() + } + } + + private fun stakeActionsState(poolMember: PoolMember): Flow { + return flowOf { + val availableActions = availablePoolMemberActions() + + StakeActionsState(availableActions) + } + } + + private fun availablePoolMemberActions(): List = listOf( + ManageStakeAction.bondMore(resourceManager), + ManageStakeAction.unbond(resourceManager) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/parachain/ParachainStakeActionsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/parachain/ParachainStakeActionsComponent.kt new file mode 100644 index 0000000..50659af --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/parachain/ParachainStakeActionsComponent.kt @@ -0,0 +1,136 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.presentation.dataOrNull +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.ParachainStakingUnbondPreliminaryValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.ParachainStakingUnbondPreliminaryValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_BOND_MORE +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_UNBOND +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_VALIDATORS +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.validation.unbondPreliminaryValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.openStartStaking +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.start.common.StartParachainStakingMode +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.parachainStaking.loadDelegatingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.ManageStakeAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.bondMore +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.unbond +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ParachainStakeActionsComponentFactory( + private val delegatorStateUseCase: DelegatorStateUseCase, + private val resourceManager: ResourceManager, + private val router: ParachainStakingRouter, + private val validationExecutor: ValidationExecutor, + private val unbondPreliminaryValidationSystem: ParachainStakingUnbondPreliminaryValidationSystem, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): StakeActionsComponent = ParachainStakeActionsComponent( + delegatorStateUseCase = delegatorStateUseCase, + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + router = router, + validationExecutor = validationExecutor, + unbondValidationSystem = unbondPreliminaryValidationSystem + ) +} + +internal open class ParachainStakeActionsComponent( + delegatorStateUseCase: DelegatorStateUseCase, + private val resourceManager: ResourceManager, + private val router: ParachainStakingRouter, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + + private val unbondValidationSystem: ParachainStakingUnbondPreliminaryValidationSystem, + private val validationExecutor: ValidationExecutor +) : StakeActionsComponent, + CoroutineScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + override val state = delegatorStateUseCase.loadDelegatingState( + hostContext = hostContext, + assetWithChain = stakingOption.assetWithChain, + stateProducer = ::stateFor + ) + .map { it?.dataOrNull } // we don't need loading state in this component + .shareInBackground() + + override fun onAction(action: StakeActionsAction) { + when (action) { + is StakeActionsAction.ActionClicked -> { + navigateToAction(action.action) + } + } + } + + private fun navigateToAction(action: ManageStakeAction) { + when (action.id) { + SYSTEM_MANAGE_VALIDATORS -> router.openCurrentCollators() + SYSTEM_MANAGE_STAKING_BOND_MORE -> router.openStartStaking(StartParachainStakingMode.BOND_MORE) + SYSTEM_MANAGE_STAKING_UNBOND -> openUnbondIfValid() + } + } + + protected open fun stateFor(delegatorState: DelegatorState.Delegator): Flow { + return flowOf { + val availableActions = availableParachainStakingActionsFor(delegatorState) + + StakeActionsState(availableActions) + } + } + + protected fun availableParachainStakingActionsFor(delegatorState: DelegatorState.Delegator): List = buildList { + add(ManageStakeAction.bondMore(resourceManager)) + add(ManageStakeAction.unbond(resourceManager)) + + val collatorsCount = delegatorState.delegations.size.format() + add(ManageStakeAction.collators(resourceManager, collatorsCount)) + } + + private fun openUnbondIfValid() = launch { + validationExecutor.requireValid( + validationSystem = unbondValidationSystem, + payload = ParachainStakingUnbondPreliminaryValidationPayload, + errorDisplayer = hostContext.errorDisplayer, + validationFailureTransformerDefault = { unbondPreliminaryValidationFailure(it, resourceManager) }, + scope = hostContext.scope + ) { + router.openUnbond() + } + } +} + +fun ManageStakeAction.Companion.collators(resourceManager: ResourceManager, collatorsCount: String): ManageStakeAction { + return ManageStakeAction( + id = SYSTEM_MANAGE_VALIDATORS, + label = resourceManager.getString(R.string.staking_parachain_your_collators), + iconRes = R.drawable.ic_validators_outline, + badge = collatorsCount + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/parachain/turing/TuringStakeActionsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/parachain/turing/TuringStakeActionsComponent.kt new file mode 100644 index 0000000..58fff4f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/parachain/turing/TuringStakeActionsComponent.kt @@ -0,0 +1,110 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain.turing + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.prepended +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTasksRepository +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.ParachainStakingUnbondPreliminaryValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.ManageStakeAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain.ParachainStakeActionsComponent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +private const val YIELD_BOOST_ACTION = "YIELD_BOOST" + +class TuringStakeActionsComponentFactory( + private val delegatorStateUseCase: DelegatorStateUseCase, + private val resourceManager: ResourceManager, + private val router: ParachainStakingRouter, + private val validationExecutor: ValidationExecutor, + private val unbondPreliminaryValidationSystem: ParachainStakingUnbondPreliminaryValidationSystem, + private val turingAutomationTasksRepository: TuringAutomationTasksRepository, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): StakeActionsComponent = TuringStakeActionsComponent( + delegatorStateUseCase = delegatorStateUseCase, + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + router = router, + validationExecutor = validationExecutor, + unbondValidationSystem = unbondPreliminaryValidationSystem, + turingAutomationTasksRepository = turingAutomationTasksRepository + ) +} + +private class TuringStakeActionsComponent( + delegatorStateUseCase: DelegatorStateUseCase, + private val resourceManager: ResourceManager, + private val router: ParachainStakingRouter, + stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + unbondValidationSystem: ParachainStakingUnbondPreliminaryValidationSystem, + validationExecutor: ValidationExecutor, + private val turingAutomationTasksRepository: TuringAutomationTasksRepository, +) : ParachainStakeActionsComponent( + delegatorStateUseCase = delegatorStateUseCase, + resourceManager = resourceManager, + router = router, + stakingOption = stakingOption, + hostContext = hostContext, + unbondValidationSystem = unbondValidationSystem, + validationExecutor = validationExecutor +) { + + override fun stateFor(delegatorState: DelegatorState.Delegator): Flow { + return flow { + val parachainStakingActions = availableParachainStakingActionsFor(delegatorState) + emit(StakeActionsState((parachainStakingActions))) + + val allActionsState = turingAutomationTasksRepository.automationTasksFlow(delegatorState.chain.id, delegatorState.accountId).map { tasks -> + val yieldBoostActive = tasks.isNotEmpty() + val yieldBoostAction = ManageStakeAction.yieldBoost(yieldBoostActive) + val allActions = parachainStakingActions.prepended(yieldBoostAction) + + StakeActionsState(allActions) + } + + emitAll(allActionsState) + } + } + + override fun onAction(action: StakeActionsAction) { + if (action is StakeActionsAction.ActionClicked && action.action.id == YIELD_BOOST_ACTION) { + goToYieldBoost() + } else { + super.onAction(action) + } + } + + private fun goToYieldBoost() { + router.openSetupYieldBoost() + } + + private fun ManageStakeAction.Companion.yieldBoost(yieldBoostActive: Boolean): ManageStakeAction { + return ManageStakeAction( + id = YIELD_BOOST_ACTION, + label = resourceManager.getString(R.string.staking_turing_yield_boost), + iconRes = R.drawable.ic_chevron_up_circle_outline, + badge = run { + val resId = if (yieldBoostActive) R.string.common_on else R.string.common_off + + resourceManager.getString(resId) + } + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/relaychain/RelaychainStakeActionsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/relaychain/RelaychainStakeActionsComponent.kt new file mode 100644 index 0000000..719af99 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeActions/relaychain/RelaychainStakeActionsComponent.kt @@ -0,0 +1,191 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.relaychain + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_proxy_api.domain.model.ProxyType +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_ADD_PROXY +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_CONTROLLER +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_PAYOUTS +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_PROXIES +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_REWARD_DESTINATION +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_BOND_MORE +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_UNBOND +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_VALIDATORS +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.ManageStakeAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.addStakingProxy +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.bondMore +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.controller +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.payouts +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.rewardDestination +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.stakingProxies +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.unbond +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.validators +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.mainStakingValidationFailure +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +class RelaychainStakeActionsComponentFactory( + private val stakingSharedComputation: StakingSharedComputation, + private val resourceManager: ResourceManager, + private val stakeActionsValidations: Map, + private val router: StakingRouter, + private val accountRepository: AccountRepository, + private val getProxyRepository: GetProxyRepository +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): StakeActionsComponent = RelaychainStakeActionsComponent( + stakingSharedComputation = stakingSharedComputation, + resourceManager = resourceManager, + router = router, + stakeActionsValidations = stakeActionsValidations, + stakingOption = stakingOption, + hostContext = hostContext, + accountRepository = accountRepository, + getProxyRepository = getProxyRepository + ) +} + +private class RelaychainStakeActionsComponent( + private val stakingSharedComputation: StakingSharedComputation, + private val resourceManager: ResourceManager, + private val router: StakingRouter, + private val stakeActionsValidations: Map, + private val hostContext: ComponentHostContext, + private val stakingOption: StakingOption, + private val accountRepository: AccountRepository, + private val getProxyRepository: GetProxyRepository +) : StakeActionsComponent, + CoroutineScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + @OptIn(ExperimentalCoroutinesApi::class) + private val stakingProxiesQuantity = accountRepository.selectedMetaAccountFlow() + .flatMapLatest { metaAccount -> + getProxiesQuantity(metaAccount) + } + + private val selectedAccountStakingStateFlow = stakingSharedComputation.selectedAccountStakingStateFlow( + assetWithChain = stakingOption.assetWithChain, + scope = hostContext.scope + ) + + override val state = combineTransform( + selectedAccountStakingStateFlow, + stakingProxiesQuantity + ) { stakingState, proxiesQuantity -> + if (stakingState is StakingState.Stash) { + emit(StakeActionsState(availableActionsFor(stakingState, proxiesQuantity))) + } else { + emit(null) + } + } + .shareInBackground() + + override fun onAction(action: StakeActionsAction) { + when (action) { + is StakeActionsAction.ActionClicked -> manageStakeActionChosen(action.action) + } + } + + private suspend fun getProxiesQuantity(metaAccount: MetaAccount): Flow { + val chain = stakingOption.assetWithChain.chain + if (chain.supportProxy.not()) return flowOf(0) + + val accountId = metaAccount.requireAccountIdIn(chain) + return getProxyRepository.proxiesQuantityByTypeFlow(chain, accountId, ProxyType.Staking) + } + + private fun manageStakeActionChosen(manageStakeAction: ManageStakeAction) { + val validationSystem = stakeActionsValidations[manageStakeAction.id] + + if (validationSystem != null) { + launch { + val stakingState = selectedAccountStakingStateFlow.filterIsInstance().first() + val payload = StakeActionsValidationPayload(stakingState) + + hostContext.validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + errorDisplayer = hostContext.errorDisplayer, + validationFailureTransformerDefault = { mainStakingValidationFailure(it, resourceManager) }, + scope = hostContext.scope + ) { + navigateToAction(manageStakeAction) + } + } + } else { + navigateToAction(manageStakeAction) + } + } + + private fun navigateToAction(action: ManageStakeAction) { + when (action.id) { + SYSTEM_MANAGE_PAYOUTS -> router.openPayouts() + SYSTEM_MANAGE_STAKING_BOND_MORE -> router.openBondMore() + SYSTEM_MANAGE_STAKING_UNBOND -> router.openSelectUnbond() + SYSTEM_MANAGE_CONTROLLER -> router.openControllerAccount() + SYSTEM_MANAGE_VALIDATORS -> router.openCurrentValidators() + SYSTEM_MANAGE_REWARD_DESTINATION -> router.openChangeRewardDestination() + SYSTEM_ADD_PROXY -> router.openAddStakingProxy() + SYSTEM_MANAGE_PROXIES -> router.openStakingProxyList() + } + } + + private fun availableActionsFor(stakingState: StakingState.Stash, proxiesQuantity: Int): List = buildList { + add(ManageStakeAction.bondMore(resourceManager)) + add(ManageStakeAction.unbond(resourceManager)) + add(ManageStakeAction.rewardDestination(resourceManager)) + + if (stakingState !is StakingState.Stash.None) { + add(ManageStakeAction.payouts(resourceManager)) + } + + if (stakingState !is StakingState.Stash.Validator) { + add(ManageStakeAction.validators(resourceManager)) + } + + if (stakingOption.chain.supportProxy) { + add(proxiesAction(proxiesQuantity)) + } + + add(ManageStakeAction.controller(resourceManager)) + } + + private fun proxiesAction(proxiesQuantity: Int): ManageStakeAction { + return if (proxiesQuantity == 0) { + ManageStakeAction.addStakingProxy(resourceManager) + } else { + ManageStakeAction.stakingProxies(resourceManager, proxiesQuantity.toString()) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/BaseStakeSummaryComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/BaseStakeSummaryComponent.kt new file mode 100644 index 0000000..241d293 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/BaseStakeSummaryComponent.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions + +abstract class BaseStakeSummaryComponent( + scope: ComputationalScope +) : StakeSummaryComponent, + ComputationalScope by scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(scope) { + + override val events = MutableLiveData>() + + override fun onAction(action: StakeSummaryAction) {} +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/StakeSummaryComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/StakeSummaryComponent.kt new file mode 100644 index 0000000..264fdd6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/StakeSummaryComponent.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.CompoundStakingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.StatefullComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.mythos.MythosStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.nominationPools.NominationPoolsStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.parachain.ParachainStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.relaychain.RelaychainStakeSummaryComponentFactory +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +typealias StakeSummaryComponent = StatefullComponent + +typealias StakeSummaryState = LoadingState + +class StakeSummaryModel( + val totalStaked: AmountModel, + val status: StakeStatusModel, +) + +sealed class StakeStatusModel { + + object Active : StakeStatusModel() + + class Waiting( + val timeLeft: Long, + @StringRes val messageFormat: Int, + ) : StakeStatusModel() + + object Inactive : StakeStatusModel() +} + +typealias StakeSummaryEvent = Unit +typealias StakeSummaryAction = Unit + +class StakeSummaryComponentFactory( + private val relaychainComponentFactory: RelaychainStakeSummaryComponentFactory, + private val parachainStakeSummaryComponentFactory: ParachainStakeSummaryComponentFactory, + private val nominationPoolsStakeSummaryComponentFactory: NominationPoolsStakeSummaryComponentFactory, + private val mythos: MythosStakeSummaryComponentFactory, + private val compoundStakingComponentFactory: CompoundStakingComponentFactory, +) { + + fun create( + hostContext: ComponentHostContext + ): StakeSummaryComponent = compoundStakingComponentFactory.create( + relaychainComponentCreator = relaychainComponentFactory::create, + parachainComponentCreator = parachainStakeSummaryComponentFactory::create, + nominationPoolsCreator = nominationPoolsStakeSummaryComponentFactory::create, + mythosCreator = mythos::create, + hostContext = hostContext + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/StakeSummaryUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/StakeSummaryUi.kt new file mode 100644 index 0000000..3eea31c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/StakeSummaryUi.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible + +fun BaseFragment<*, *>.setupStakeSummaryComponent(component: StakeSummaryComponent, view: StakeSummaryView) { + // state + component.state.observe { stakeSummaryState -> + if (stakeSummaryState == null) { + view.makeGone() + return@observe + } + + view.makeVisible() + + when (stakeSummaryState) { + is LoadingState.Loaded -> { + val summary = stakeSummaryState.data + + view.showStakeAmount(summary.totalStaked) + view.showStakeStatus(mapStatus(summary.status)) + } + is LoadingState.Loading -> view.showLoading() + } + } +} + +private fun mapStatus(status: StakeStatusModel): StakeSummaryView.Status { + return when (status) { + is StakeStatusModel.Active -> StakeSummaryView.Status.Active + is StakeStatusModel.Inactive -> StakeSummaryView.Status.Inactive + is StakeStatusModel.Waiting -> StakeSummaryView.Status.Waiting(status.timeLeft, status.messageFormat) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/StakeSummaryView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/StakeSummaryView.kt new file mode 100644 index 0000000..cb9dc09 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/StakeSummaryView.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.common.view.stopTimer +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ViewStakeSummaryBinding +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class StakeSummaryView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + sealed class Status(@StringRes val textRes: Int, @ColorRes val tintRes: Int, @DrawableRes val indicatorRes: Int) { + + object Active : Status(R.string.common_active, R.color.text_positive, R.drawable.ic_indicator_positive_pulse) + + object Inactive : Status(R.string.staking_nominator_status_inactive, R.color.text_negative, R.drawable.ic_indicator_negative_pulse) + + class Waiting( + val timeLeft: Long, + @StringRes customMessageFormat: Int + ) : Status(customMessageFormat, R.color.text_secondary, R.drawable.ic_indicator_inactive_pulse) + } + + private val binder = ViewStakeSummaryBinding.inflate(inflater(), this) + + init { + with(context) { + background = getBlockDrawable() + } + } + + fun showStakeStatus(status: Status) { + binder.stakeSummaryStatusShimmer.makeGone() + + with(binder.stakeSummaryStatus) { + makeVisible() + + setStatusIndicator(status.indicatorRes) + setTextColorRes(status.tintRes) + + if (status is Status.Waiting) { + startTimer( + millis = status.timeLeft, + customMessageFormat = status.textRes + ) + } else { + stopTimer() + setText(status.textRes) + } + } + } + + fun showStakeAmount(amountModel: AmountModel) { + binder.stakeSummaryTokenStake.makeVisible() + binder.stakeSummaryFiatStake.makeVisible() + + binder.stakeSummaryFiatStakeShimmer.makeGone() + binder.stakeSummaryTokenStakeShimmer.makeGone() + + binder.stakeSummaryTokenStake.text = amountModel.token + binder.stakeSummaryFiatStake.text = amountModel.fiat + } + + fun showLoading() { + binder.stakeSummaryShimmerGroup.makeVisible() + binder.stakeSummaryContentGroup.makeGone() + } + + fun setStatusClickListener(listener: OnClickListener) { + binder.stakeSummaryStatus.setOnClickListener(listener) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/mythos/MythosStakeSummaryComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/mythos/MythosStakeSummaryComponent.kt new file mode 100644 index 0000000..f1c87b0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/mythos/MythosStakeSummaryComponent.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.mythos + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.stakeSummary.MythosDelegatorStatus +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.stakeSummary.MythosStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.mythos.loadUserStakeState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.BaseStakeSummaryComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeStatusModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryState +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest + +class MythosStakeSummaryComponentFactory( + private val mythosSharedComputation: MythosSharedComputation, + private val interactor: MythosStakeSummaryInteractor, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): StakeSummaryComponent = MythosStakeSummaryComponent( + mythosSharedComputation = mythosSharedComputation, + interactor = interactor, + stakingOption = stakingOption, + hostContext = hostContext, + amountFormatter = amountFormatter + ) +} + +private class MythosStakeSummaryComponent( + private val mythosSharedComputation: MythosSharedComputation, + private val interactor: MythosStakeSummaryInteractor, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val amountFormatter: AmountFormatter +) : BaseStakeSummaryComponent(hostContext.scope) { + + override val state: Flow = mythosSharedComputation.loadUserStakeState( + hostContext = hostContext, + stateProducer = ::userStakeSummary + ) + .shareInBackground() + + private fun userStakeSummary(delegatorState: MythosDelegatorState.Staked): Flow { + return interactor.stakeSummaryFlow(delegatorState, stakingOption).flatMapLatest { stakeSummary -> + val status = stakeSummary.status.toUi() + + hostContext.assetFlow.mapLatest { asset -> + StakeSummaryModel( + totalStaked = amountFormatter.formatAmountToAmountModel(stakeSummary.activeStake, asset), + status = status + ) + } + } + } + + private fun MythosDelegatorStatus.toUi(): StakeStatusModel { + return when (this) { + MythosDelegatorStatus.Active -> StakeStatusModel.Active + MythosDelegatorStatus.Inactive -> StakeStatusModel.Inactive + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/nominationPools/NominationPoolsStakeSummaryComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/nominationPools/NominationPoolsStakeSummaryComponent.kt new file mode 100644 index 0000000..08221e0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/nominationPools/NominationPoolsStakeSummaryComponent.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.nominationPools + +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.stakeSummary.NominationPoolStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.stakeSummary.PoolMemberStatus +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.nominationPools.loadPoolMemberState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.BaseStakeSummaryComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeStatusModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryState +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest + +class NominationPoolsStakeSummaryComponentFactory( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val interactor: NominationPoolStakeSummaryInteractor, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): StakeSummaryComponent = NominationPoolsStakeSummaryComponent( + stakingOption = stakingOption, + hostContext = hostContext, + nominationPoolSharedComputation = nominationPoolSharedComputation, + interactor = interactor, + amountFormatter = amountFormatter + ) +} + +private class NominationPoolsStakeSummaryComponent( + nominationPoolSharedComputation: NominationPoolSharedComputation, + private val interactor: NominationPoolStakeSummaryInteractor, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val amountFormatter: AmountFormatter +) : BaseStakeSummaryComponent(hostContext.scope) { + + override val state: Flow = nominationPoolSharedComputation.loadPoolMemberState( + hostContext = hostContext, + chain = stakingOption.assetWithChain.chain, + stateProducer = ::poolMemberStakeSummary + ) + .shareInBackground() + + private fun poolMemberStakeSummary(poolMember: PoolMember): Flow { + return interactor.stakeSummaryFlow(poolMember, stakingOption, sharedComputationScope = this).flatMapLatest { stakeSummary -> + val status = mapPoolMemberStatusToUi(stakeSummary.status) + + hostContext.assetFlow.mapLatest { asset -> + StakeSummaryModel( + totalStaked = amountFormatter.formatAmountToAmountModel(stakeSummary.activeStake, asset), + status = status + ) + } + } + } + + private fun mapPoolMemberStatusToUi( + poolMemberStatus: PoolMemberStatus + ): StakeStatusModel { + return when (poolMemberStatus) { + PoolMemberStatus.Active -> StakeStatusModel.Active + PoolMemberStatus.Inactive -> StakeStatusModel.Inactive + is PoolMemberStatus.Waiting -> StakeStatusModel.Waiting( + timeLeft = poolMemberStatus.timeLeft.inWholeMilliseconds, + messageFormat = R.string.staking_nominator_status_waiting_format, + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/parachain/ParachainStakeSummaryComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/parachain/ParachainStakeSummaryComponent.kt new file mode 100644 index 0000000..98ebf00 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/parachain/ParachainStakeSummaryComponent.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.parachain + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.activeBonded +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.stakeSummary.DelegatorStatus +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.stakeSummary.ParachainStakingStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.parachainStaking.loadDelegatingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.BaseStakeSummaryComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeStatusModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryState +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import kotlin.time.ExperimentalTime + +class ParachainStakeSummaryComponentFactory( + private val resourceManager: ResourceManager, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val interactor: ParachainStakingStakeSummaryInteractor, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): StakeSummaryComponent = ParachainStakeSummaryComponent( + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + delegatorStateUseCase = delegatorStateUseCase, + interactor = interactor, + amountFormatter = amountFormatter + ) +} + +@OptIn(ExperimentalTime::class) +private class ParachainStakeSummaryComponent( + delegatorStateUseCase: DelegatorStateUseCase, + private val interactor: ParachainStakingStakeSummaryInteractor, + private val resourceManager: ResourceManager, + stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val amountFormatter: AmountFormatter +) : BaseStakeSummaryComponent(hostContext.scope) { + + override val state: Flow = delegatorStateUseCase.loadDelegatingState( + hostContext = hostContext, + assetWithChain = stakingOption.assetWithChain, + stateProducer = ::delegatorSummaryStateFlow + ) + .shareInBackground() + + private suspend fun delegatorSummaryStateFlow(delegatorState: DelegatorState.Delegator): Flow { + return interactor.delegatorStatusFlow(delegatorState).flatMapLatest { delegatorStatus -> + val status = mapDelegatorStatusToStakeStatusModel(delegatorStatus) + + hostContext.assetFlow.mapLatest { asset -> + StakeSummaryModel( + totalStaked = amountFormatter.formatAmountToAmountModel(delegatorState.activeBonded, asset), + status = status + ) + } + } + } + + private fun mapDelegatorStatusToStakeStatusModel( + delegatorStatus: DelegatorStatus + ): StakeStatusModel { + return when (delegatorStatus) { + DelegatorStatus.Active -> StakeStatusModel.Active + DelegatorStatus.Inactive -> StakeStatusModel.Inactive + is DelegatorStatus.Waiting -> StakeStatusModel.Waiting( + timeLeft = delegatorStatus.timeLeft.inWholeMilliseconds, + messageFormat = R.string.staking_parachain_next_round_format, + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/relaychain/RelaychainStakeSummaryComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/relaychain/RelaychainStakeSummaryComponent.kt new file mode 100644 index 0000000..80c8d61 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/stakeSummary/relaychain/RelaychainStakeSummaryComponent.kt @@ -0,0 +1,118 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.relaychain + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.model.NominatorStatus +import io.novafoundation.nova.feature_staking_impl.domain.model.StakeSummary +import io.novafoundation.nova.feature_staking_impl.domain.model.StashNoneStatus +import io.novafoundation.nova.feature_staking_impl.domain.model.ValidatorStatus +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.BaseStakeSummaryComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeStatusModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryState +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transformLatest + +class RelaychainStakeSummaryComponentFactory( + private val stakingInteractor: StakingInteractor, + private val resourceManager: ResourceManager, + private val stakingSharedComputation: StakingSharedComputation, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): StakeSummaryComponent = RelaychainStakeSummaryComponent( + stakingInteractor = stakingInteractor, + resourceManager = resourceManager, + stakingOption = stakingOption, + hostContext = hostContext, + stakingSharedComputation = stakingSharedComputation, + amountFormatter = amountFormatter + ) +} + +private class RelaychainStakeSummaryComponent( + private val stakingInteractor: StakingInteractor, + private val stakingSharedComputation: StakingSharedComputation, + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : BaseStakeSummaryComponent(hostContext.scope) { + + private val selectedAccountStakingStateFlow = stakingSharedComputation.selectedAccountStakingStateFlow( + assetWithChain = stakingOption.assetWithChain, + scope = hostContext.scope + ) + + override val state: Flow = selectedAccountStakingStateFlow.transformLatest { stakingState -> + when (stakingState) { + is StakingState.NonStash -> emit(null) + is StakingState.Stash.Nominator -> emitAll(nominatorState(stakingState)) + is StakingState.Stash.Validator -> emitAll(validatorState(stakingState)) + is StakingState.Stash.None -> emitAll(neitherState(stakingState)) + } + } + .onStart { emit(null) } + .shareInBackground() + + private suspend fun nominatorState( + stakingState: StakingState.Stash.Nominator, + ): Flow = stakeSummaryState(stakingInteractor.observeNominatorSummary(stakingState, hostContext.scope)) { status -> + when (status) { + NominatorStatus.Active -> StakeStatusModel.Active + + is NominatorStatus.Inactive -> StakeStatusModel.Inactive + + is NominatorStatus.Waiting -> StakeStatusModel.Waiting( + timeLeft = status.timeLeft, + messageFormat = R.string.staking_nominator_status_waiting_format, + ) + } + } + + private suspend fun validatorState( + stakingState: StakingState.Stash.Validator + ): Flow = stakeSummaryState(stakingInteractor.observeValidatorSummary(stakingState, hostContext.scope)) { status -> + when (status) { + ValidatorStatus.ACTIVE -> StakeStatusModel.Active + + ValidatorStatus.INACTIVE -> StakeStatusModel.Inactive + } + } + + private suspend fun neitherState( + stakingState: StakingState.Stash.None + ): Flow = stakeSummaryState(stakingInteractor.observeStashSummary(stakingState, hostContext.scope)) { status -> + when (status) { + StashNoneStatus.INACTIVE -> StakeStatusModel.Inactive + } + } + + private fun stakeSummaryState( + domainFlow: Flow>, + statusMapper: (STATUS) -> StakeStatusModel + ): Flow = combine( + hostContext.assetFlow, + domainFlow + ) { asset, summary -> + StakeSummaryModel( + totalStaked = amountFormatter.formatAmountToAmountModel(summary.activeStake, asset), + status = statusMapper(summary.status), + ) + }.withLoading() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingComponent.kt new file mode 100644 index 0000000..05a7273 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingComponent.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding + +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ChooseOneOfAwaitableAction +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.SelectStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.AwaitableEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ChooseOneOfAwaitableEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.CompoundStakingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.StatefullComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.mythos.MythosUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.nominationPools.NominationPoolsUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.parachain.ParachainUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.rebond.RebondKind +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.relaychain.RelaychainUnbondingComponentFactory + +typealias UnbondingComponent = StatefullComponent, UnbondingEvent, UnbondingAction> +typealias ChooseOneOfStakedTargetsAction = ActionAwaitableMixin.Action, E> +typealias ChooseOneOfStakedTargetsEvent = AwaitableEvent, E> + +sealed class UnbondingState { + + companion object + + object Empty : UnbondingState() + + data class HaveUnbondings( + val redeemEnabled: Boolean, + val cancelState: ButtonState, + val unbondings: List + ) : UnbondingState() +} + +sealed class UnbondingEvent { + + class ChooseRebondKind( + override val value: ChooseOneOfAwaitableAction + ) : ChooseOneOfAwaitableEvent, UnbondingEvent() + + class ChooseRebondTarget( + override val value: ChooseOneOfStakedTargetsAction> + ) : ChooseOneOfStakedTargetsEvent>, UnbondingEvent() +} + +sealed class UnbondingAction { + + object RebondClicked : UnbondingAction() + + object RedeemClicked : UnbondingAction() +} + +class UnbondingComponentFactory( + private val relaychainUnbondingComponentFactory: RelaychainUnbondingComponentFactory, + private val parachainComponentFactory: ParachainUnbondingComponentFactory, + private val nominationPoolsUnbondingComponentFactory: NominationPoolsUnbondingComponentFactory, + private val compoundStakingComponentFactory: CompoundStakingComponentFactory, + private val mythos: MythosUnbondingComponentFactory, +) { + + fun create( + hostContext: ComponentHostContext + ): UnbondingComponent = compoundStakingComponentFactory.create( + relaychainComponentCreator = relaychainUnbondingComponentFactory::create, + parachainComponentCreator = parachainComponentFactory::create, + nominationPoolsCreator = nominationPoolsUnbondingComponentFactory::create, + mythosCreator = mythos::create, + hostContext = hostContext + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingModel.kt new file mode 100644 index 0000000..05f6e71 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingModel.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding + +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +data class UnbondingModel( + val id: String, + val status: Unbonding.Status, + val amountModel: AmountModel +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingStateExt.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingStateExt.kt new file mode 100644 index 0000000..e8665d2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingStateExt.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding + +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.Unbondings +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.Unbondings.RebondState.CAN_REBOND +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.Unbondings.RebondState.REBOND_NOT_POSSIBLE +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel + +fun UnbondingState.Companion.from( + unbondings: Unbondings, + asset: Asset, + amountFormatter: AmountFormatter, + cancelLoading: Boolean = false +): UnbondingState { + return when { + unbondings.unbondings.isEmpty() -> UnbondingState.Empty + else -> { + UnbondingState.HaveUnbondings( + redeemEnabled = unbondings.anythingToRedeem, + cancelState = when { + cancelLoading -> ButtonState.PROGRESS + unbondings.rebondState == CAN_REBOND -> ButtonState.NORMAL + unbondings.rebondState == REBOND_NOT_POSSIBLE -> ButtonState.GONE + else -> ButtonState.DISABLED + }, + unbondings = unbondings.unbondings.map { unbonding -> + mapUnbondingToUnbondingModel(unbonding, asset, amountFormatter) + } + ) + } + } +} + +private fun mapUnbondingToUnbondingModel(unbonding: Unbonding, asset: Asset, amountFormatter: AmountFormatter): UnbondingModel { + return UnbondingModel( + id = unbonding.id, + status = unbonding.status, + amountModel = amountFormatter.formatAmountToAmountModel(unbonding.amount, asset) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingUi.kt new file mode 100644 index 0000000..15364b4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingUi.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet.SelectionStyle +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.rebond.ChooseRebondKindBottomSheet + +fun BaseFragment<*, *>.setupUnbondingComponent(component: UnbondingComponent, view: UnbondingsView) { + component.events.observeEvent { + when (it) { + is UnbondingEvent.ChooseRebondKind -> { + ChooseRebondKindBottomSheet(requireContext(), it.value) + .show() + } + is UnbondingEvent.ChooseRebondTarget -> { + ChooseStakedStakeTargetsBottomSheet( + context = requireContext(), + payload = it.value.payload, + stakedCollatorSelected = { _, item -> it.value.onSuccess(item) }, + onCancel = it.value.onCancel, + newStakeTargetClicked = null, + selectionStyle = SelectionStyle.Arrow + ).show() + } + } + } + + view.prepareForProgress(viewLifecycleOwner) + + view.onCancelClicked { component.onAction(UnbondingAction.RebondClicked) } + view.onRedeemClicked { component.onAction(UnbondingAction.RedeemClicked) } + + component.state.observe { state -> + when (state) { + null, is LoadingState.Loading -> view.makeGone() + is LoadingState.Loaded -> { + view.makeVisible() + view.setState(state.data) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingsAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingsAdapter.kt new file mode 100644 index 0000000..771ded5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingsAdapter.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.removeCompoundDrawables +import io.novafoundation.nova.common.utils.setDrawableEnd +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.common.view.stopTimer +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ItemUnbondingBinding +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding + +import kotlin.time.ExperimentalTime + +class UnbondingsAdapter : ListAdapter(UnbondingModelDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UnbondingsHolder { + return UnbondingsHolder(ItemUnbondingBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: UnbondingsHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + + resolvePayload(holder, position, payloads) { + when (it) { + UnbondingModel::status -> holder.bindStatus(item) + UnbondingModel::amountModel -> holder.bindAmount(item) + } + } + } + + @ExperimentalTime + override fun onBindViewHolder(holder: UnbondingsHolder, position: Int) { + val item = getItem(position) + + holder.bind(item) + } +} + +class UnbondingsHolder(private val binder: ItemUnbondingBinding) : RecyclerView.ViewHolder(binder.root) { + + fun bind(unbonding: UnbondingModel) { + bindStatus(unbonding) + bindAmount(unbonding) + } + + fun bindAmount(unbonding: UnbondingModel) { + binder.itemUnbondAmount.text = unbonding.amountModel.token + } + + fun bindStatus(unbonding: UnbondingModel) = with(binder) { + when (val status = unbonding.status) { + Unbonding.Status.Redeemable -> { + itemUnbondStatus.setTextColorRes(R.color.text_positive) + itemUnbondStatus.removeCompoundDrawables() + itemUnbondStatus.stopTimer() + itemUnbondStatus.setText(R.string.wallet_balance_redeemable) + } + is Unbonding.Status.Unbonding -> { + itemUnbondStatus.setTextColorRes(R.color.text_secondary) + itemUnbondStatus.setDrawableEnd(R.drawable.ic_time_16, paddingInDp = 4, tint = R.color.icon_secondary) + + itemUnbondStatus.startTimer(status.timer) + } + } + } +} + +private val PAYLOAD_GENERATOR = PayloadGenerator(UnbondingModel::status, UnbondingModel::amountModel) + +private class UnbondingModelDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: UnbondingModel, newItem: UnbondingModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: UnbondingModel, newItem: UnbondingModel): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: UnbondingModel, newItem: UnbondingModel): Any? { + return PAYLOAD_GENERATOR.diff(oldItem, newItem) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingsView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingsView.kt new file mode 100644 index 0000000..40ec661 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/UnbondingsView.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.lifecycle.LifecycleOwner +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ViewUnbondingsBinding + +class UnbondingsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + private val unbondingsAdapter = UnbondingsAdapter() + + private val binder = ViewUnbondingsBinding.inflate(inflater(), this) + + init { + background = context.getRoundedCornerDrawable(R.color.block_background) + + binder.unbondingsList.adapter = unbondingsAdapter + } + + fun onRedeemClicked(action: () -> Unit) { + binder.unbondingRedeem.setOnClickListener { action() } + } + + fun onCancelClicked(action: () -> Unit) { + binder.unbondingCancel.setOnClickListener { action() } + } + + fun setState(state: UnbondingState) { + when (state) { + UnbondingState.Empty -> makeGone() + is UnbondingState.HaveUnbondings -> { + makeVisible() + + unbondingsAdapter.submitList(state.unbondings) + + binder.unbondingRedeem.isEnabled = state.redeemEnabled + binder.unbondingCancel.setState(state.cancelState) + } + } + } + + fun prepareForProgress(lifecycleOwner: LifecycleOwner) { + binder.unbondingCancel.prepareForProgress(lifecycleOwner) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/mythos/MythosUnbondingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/mythos/MythosUnbondingComponent.kt new file mode 100644 index 0000000..57c02f2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/mythos/MythosUnbondingComponent.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.mythos + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.unbonding.MythosUnbondingInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.mythos.loadUserStakeState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.from +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class MythosUnbondingComponentFactory( + private val mythosSharedComputation: MythosSharedComputation, + private val interactor: MythosUnbondingInteractor, + private val router: MythosStakingRouter, + private val amountFormatter: AmountFormatter, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): UnbondingComponent = MythosUnbondingComponent( + stakingOption = stakingOption, + hostContext = hostContext, + mythosSharedComputation = mythosSharedComputation, + interactor = interactor, + router = router, + amountFormatter = amountFormatter + ) +} + +private class MythosUnbondingComponent( + private val mythosSharedComputation: MythosSharedComputation, + private val interactor: MythosUnbondingInteractor, + private val amountFormatter: AmountFormatter, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val router: MythosStakingRouter +) : UnbondingComponent, + ComputationalScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + override val state = mythosSharedComputation.loadUserStakeState( + hostContext = hostContext, + stateProducer = ::unbondingState + ) + .shareInBackground() + + override fun onAction(action: UnbondingAction) { + when (action) { + UnbondingAction.RebondClicked -> Unit // rebond is not supported in mythos + UnbondingAction.RedeemClicked -> router.openRedeem() + } + } + + private fun unbondingState(delegatorState: MythosDelegatorState.Staked): Flow { + return combine( + interactor.unbondingsFlow(delegatorState, stakingOption), + hostContext.assetFlow, + ) { unbondings, asset -> + UnbondingState.from(unbondings, asset, amountFormatter) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/nominationPools/NominationPoolsUnbondingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/nominationPools/NominationPoolsUnbondingComponent.kt new file mode 100644 index 0000000..1c48ffd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/nominationPools/NominationPoolsUnbondingComponent.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.nominationPools + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.unbondings.NominationPoolUnbondingsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.nominationPools.loadPoolMemberState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.from +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +class NominationPoolsUnbondingComponentFactory( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val interactor: NominationPoolUnbondingsInteractor, + private val router: NominationPoolsRouter, + private val amountFormatter: AmountFormatter, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): UnbondingComponent = NominationPoolsUnbondingComponent( + nominationPoolSharedComputation = nominationPoolSharedComputation, + interactor = interactor, + hostContext = hostContext, + stakingOption = stakingOption, + router = router, + amountFormatter = amountFormatter + ) +} + +private class NominationPoolsUnbondingComponent( + private val router: NominationPoolsRouter, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val interactor: NominationPoolUnbondingsInteractor, + private val amountFormatter: AmountFormatter, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, +) : UnbondingComponent, + CoroutineScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + override val state = nominationPoolSharedComputation.loadPoolMemberState( + hostContext = hostContext, + chain = stakingOption.assetWithChain.chain, + stateProducer = ::loadUnbondings + ) + .shareInBackground() + + override fun onAction(action: UnbondingAction) { + launch { + when (action) { + UnbondingAction.RebondClicked -> {} // rebond is not supported in nomination pools + UnbondingAction.RedeemClicked -> redeemClicked() + } + } + } + + private fun loadUnbondings(poolMember: PoolMember): Flow { + return combine( + interactor.unbondingsFlow(poolMember, stakingOption, hostContext.scope), + hostContext.assetFlow, + ) { unbondings, asset -> + UnbondingState.from(unbondings, asset, amountFormatter) + } + } + + fun redeemClicked() { + router.openRedeem() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/parachain/ParachainUnbondingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/parachain/ParachainUnbondingComponent.kt new file mode 100644 index 0000000..951c045 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/parachain/ParachainUnbondingComponent.kt @@ -0,0 +1,145 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.parachain + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.withFlagSet +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.unbondings.DelegationRequestWithCollatorInfo +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.unbondings.ParachainStakingUnbondingsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.ChooseStakedStakeTargetsBottomSheet +import io.novafoundation.nova.feature_staking_impl.presentation.common.selectStakeTarget.SelectStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.common.selectCollators.labeledAmountSubtitle +import io.novafoundation.nova.feature_staking_impl.presentation.parachainStaking.rebond.model.ParachainStakingRebondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.awaitAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.parachainStaking.loadDelegatingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.from +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ParachainUnbondingComponentFactory( + private val delegatorStateUseCase: DelegatorStateUseCase, + private val addressIconGenerator: AddressIconGenerator, + private val interactor: ParachainStakingUnbondingsInteractor, + private val router: ParachainStakingRouter, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): UnbondingComponent = ParachainUnbondingComponent( + stakingOption = stakingOption, + hostContext = hostContext, + delegatorStateUseCase = delegatorStateUseCase, + interactor = interactor, + router = router, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) +} + +private class ParachainUnbondingComponent( + private val delegatorStateUseCase: DelegatorStateUseCase, + private val interactor: ParachainStakingUnbondingsInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val router: ParachainStakingRouter +) : UnbondingComponent, + CoroutineScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + override val state = delegatorStateUseCase.loadDelegatingState( + hostContext = hostContext, + assetWithChain = stakingOption.assetWithChain, + stateProducer = ::delegatorSummaryStateFlow + ) + .shareInBackground() + + override fun onAction(action: UnbondingAction) { + when (action) { + UnbondingAction.RebondClicked -> handleRebond() + UnbondingAction.RedeemClicked -> router.openRedeem() + } + } + + private val cancelLoadingFlow = MutableStateFlow(false) + + private fun handleRebond() = launch { + val delegatorState = delegatorStateUseCase.currentDelegatorState().castOrNull() ?: return@launch + val chooserPayload = cancelLoadingFlow.withFlagSet { createRebondChooserPayload(delegatorState) } + + val selected = events.awaitAction(chooserPayload, UnbondingEvent::ChooseRebondTarget).payload as DelegationRequestWithCollatorInfo + + val rebondPayload = ParachainStakingRebondPayload(selected.request.collator) + router.openRebond(rebondPayload) + } + + private suspend fun createRebondChooserPayload( + delegatorState: DelegatorState.Delegator + ) = withContext(Dispatchers.Default) { + val unbondingRequests = interactor.pendingUnbondings(delegatorState) + val asset = hostContext.assetFlow.first() + + val selectStakeTargetModels = unbondingRequests.map { unbondingWithCollator -> + val amountModel = amountFormatter.formatAmountToAmountModel(unbondingWithCollator.request.action.amount, asset) + val subtitle = resourceManager.labeledAmountSubtitle(R.string.wallet_balance_unbonding_v1_9_0, amountModel, selectionActive = true) + + SelectStakeTargetModel( + addressModel = addressIconGenerator.createAccountAddressModel( + chain = delegatorState.chain, + accountId = unbondingWithCollator.request.collator, + name = unbondingWithCollator.collatorIdentity?.display + ), + payload = unbondingWithCollator, + active = true, + subtitle = subtitle + ) + } + + ChooseStakedStakeTargetsBottomSheet.Payload( + data = selectStakeTargetModels, + selected = null, + titleRes = R.string.staking_rebond + ) + } + + private fun delegatorSummaryStateFlow(delegatorState: DelegatorState.Delegator): Flow { + return combine( + interactor.unbondingsFlow(delegatorState), + hostContext.assetFlow, + cancelLoadingFlow + ) { unbondings, asset, cancelLoading -> + UnbondingState.from(unbondings, asset, amountFormatter, cancelLoading) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/rebond/ChooseRebondKindBottomSheet.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/rebond/ChooseRebondKindBottomSheet.kt new file mode 100644 index 0000000..c802691 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/rebond/ChooseRebondKindBottomSheet.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.rebond + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.mixin.actionAwaitable.ChooseOneOfAwaitableAction +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textItem +import io.novafoundation.nova.feature_staking_impl.R + +class ChooseRebondKindBottomSheet( + context: Context, + private val chooseOneOfAwaitableAction: ChooseOneOfAwaitableAction, +) : FixedListBottomSheet(context, viewConfiguration = ViewConfiguration.default(context)) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setOnCancelListener { chooseOneOfAwaitableAction.onCancel() } + + setTitle(R.string.staking_rebond) + + chooseOneOfAwaitableAction.payload.forEach { rebondKind -> + textItem(R.drawable.ic_staking_outline, rebondKind.title) { + chooseOneOfAwaitableAction.onSuccess(rebondKind) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/rebond/RebondKind.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/rebond/RebondKind.kt new file mode 100644 index 0000000..e246961 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/rebond/RebondKind.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.rebond + +class RebondKind( + val id: String, + val title: String +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/relaychain/RelaychainUnbondingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/relaychain/RelaychainUnbondingComponent.kt new file mode 100644 index 0000000..77a8b0c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/unbonding/relaychain/RelaychainUnbondingComponent.kt @@ -0,0 +1,174 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.relaychain + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.presentation.dataOrNull +import io.novafoundation.nova.common.presentation.map +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.firstNotNull +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.model.Unbonding +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.UnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.awaitAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingEvent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.from +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.rebond.RebondKind +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.mainStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.ConfirmRebondPayload +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import java.math.BigInteger + +const val REBOND_KIND_ALL = "All" +const val REBOND_KIND_LAST = "Last" +const val REBOND_KIND_CUSTOM = "Custom" + +class RelaychainUnbondingComponentFactory( + private val unbondInteractor: UnbondInteractor, + private val validationExecutor: ValidationExecutor, + private val resourceManager: ResourceManager, + private val rebondValidationSystem: StakeActionsValidationSystem, + private val redeemValidationSystem: StakeActionsValidationSystem, + private val router: StakingRouter, + private val stakingSharedComputation: StakingSharedComputation, + private val amountFormatter: AmountFormatter, +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext, + ): UnbondingComponent = RelaychainUnbondingComponent( + unbondInteractor = unbondInteractor, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + rebondValidationSystem = rebondValidationSystem, + redeemValidationSystem = redeemValidationSystem, + router = router, + hostContext = hostContext, + stakingSharedComputation = stakingSharedComputation, + stakingOption = stakingOption, + amountFormatter = amountFormatter + ) +} + +private class RelaychainUnbondingComponent( + private val stakingSharedComputation: StakingSharedComputation, + private val unbondInteractor: UnbondInteractor, + private val validationExecutor: ValidationExecutor, + private val rebondValidationSystem: StakeActionsValidationSystem, + private val redeemValidationSystem: StakeActionsValidationSystem, + private val router: StakingRouter, + private val amountFormatter: AmountFormatter, + private val resourceManager: ResourceManager, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, +) : UnbondingComponent, + CoroutineScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + private val selectedAccountStakingStateFlow = stakingSharedComputation.selectedAccountStakingStateFlow( + assetWithChain = stakingOption.assetWithChain, + scope = hostContext.scope + ) + + private val unbondingsFlow = selectedAccountStakingStateFlow.transformLatest { + if (it !is StakingState.Stash) { + emit(null) + } else { + emitAll(unbondInteractor.unbondingsFlow(it, hostContext.scope).withLoading()) + } + } + .shareInBackground() + + override val state = combine(hostContext.assetFlow, unbondingsFlow) { asset, unbondingsLoading -> + unbondingsLoading?.let { + it.map { unbonding -> + UnbondingState.from(unbonding, asset, amountFormatter) + } + } + } + .onStart { emit(null) } + .shareInBackground() + + override fun onAction(action: UnbondingAction) { + launch { + when (action) { + UnbondingAction.RebondClicked -> rebondClicked() + UnbondingAction.RedeemClicked -> redeemClicked() + } + } + } + + private fun rebondClicked() = requireValidManageAction(rebondValidationSystem) { + val chosenKind = events.awaitAction(rebondKinds(), UnbondingEvent::ChooseRebondKind) + + when (chosenKind.id) { + REBOND_KIND_ALL -> openConfirmRebond(unbondInteractor::allUnbondingsAmount) + REBOND_KIND_LAST -> openConfirmRebond(unbondInteractor::newestUnbondingAmount) + REBOND_KIND_CUSTOM -> router.openCustomRebond() + } + } + + fun redeemClicked() = requireValidManageAction(redeemValidationSystem) { + router.openRedeem() + } + + private fun rebondKinds() = listOf( + RebondKind(REBOND_KIND_ALL, resourceManager.getString(R.string.staking_rebond_all)), + RebondKind(REBOND_KIND_LAST, resourceManager.getString(R.string.staking_rebond_last)), + RebondKind(REBOND_KIND_CUSTOM, resourceManager.getString(R.string.staking_rebond_custom)), + ) + + private suspend fun openConfirmRebond(amountBuilder: (List) -> BigInteger) { + val unbondingsState = unbondingsFlow.map { it?.dataOrNull }.firstNotNull() + val asset = hostContext.assetFlow.first() + + val amountInPlanks = amountBuilder(unbondingsState.unbondings) + val amount = asset.token.amountFromPlanks(amountInPlanks) + + router.openConfirmRebond(ConfirmRebondPayload(amount)) + } + + private fun requireValidManageAction( + validationSystem: StakeActionsValidationSystem, + block: suspend (StakeActionsValidationPayload) -> Unit, + ) { + launch { + val stashState = selectedAccountStakingStateFlow.filterIsInstance().first() + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = StakeActionsValidationPayload(stashState), + errorDisplayer = hostContext.errorDisplayer, + validationFailureTransformerDefault = { mainStakingValidationFailure(it, resourceManager) }, + block = { launch { block(it) } }, + scope = hostContext.scope + ) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/BaseRewardComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/BaseRewardComponent.kt new file mode 100644 index 0000000..d30580d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/BaseRewardComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.data.memory.ComputationalScope +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext + +abstract class BaseRewardComponent(hostContext: ComponentHostContext) : + UserRewardsComponent, + ComputationalScope by hostContext.scope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(hostContext.scope) { + + override val events = MutableLiveData>() + + fun rewardPeriodClicked() { + events.postValue(UserRewardsEvent.UserRewardPeriodClicked.event()) + } + + override fun onAction(action: UserRewardsAction) { + if (action is UserRewardsAction.UserRewardPeriodClicked) { + rewardPeriodClicked() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/UserRewardsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/UserRewardsComponent.kt new file mode 100644 index 0000000..80ed915 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/UserRewardsComponent.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.CompoundStakingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.StatefullComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.mythos.MythosUserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.nominationPools.NominationPoolUserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.parachain.ParachainUserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.relaychain.RelaychainUserRewardsComponentFactory +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +typealias UserRewardsComponent = StatefullComponent + +class UserRewardsState( + val amount: LoadingState, + val claimableRewards: LoadingState?, + @DrawableRes val iconRes: Int, + val selectedRewardPeriod: String +) { + + class ClaimableRewards( + val amountModel: AmountModel, + val canClaim: Boolean + ) +} + +sealed class UserRewardsEvent { + + object UserRewardPeriodClicked : UserRewardsEvent() +} + +sealed class UserRewardsAction { + + object ClaimRewardsClicked : UserRewardsAction() + + object UserRewardPeriodClicked : UserRewardsAction() +} + +class UserRewardsComponentFactory( + private val relaychainComponentFactory: RelaychainUserRewardsComponentFactory, + private val parachainComponentFactory: ParachainUserRewardsComponentFactory, + private val nominationPoolsComponentFactory: NominationPoolUserRewardsComponentFactory, + private val mythos: MythosUserRewardsComponentFactory, + private val compoundStakingComponentFactory: CompoundStakingComponentFactory, +) { + + fun create( + hostContext: ComponentHostContext + ): UserRewardsComponent = compoundStakingComponentFactory.create( + relaychainComponentCreator = relaychainComponentFactory::create, + parachainComponentCreator = parachainComponentFactory::create, + nominationPoolsCreator = nominationPoolsComponentFactory::create, + mythosCreator = mythos::create, + hostContext = hostContext + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/UserRewardsUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/UserRewardsUi.kt new file mode 100644 index 0000000..65d19da --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/UserRewardsUi.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.view.UserRewardsView + +fun BaseFragment<*, *>.setupUserRewardsComponent(component: UserRewardsComponent, view: UserRewardsView, router: StakingRouter) { + view.setOnRewardPeriodClickedListener { + component.onAction(UserRewardsAction.UserRewardPeriodClicked) + } + + view.setClaimClickListener { + component.onAction(UserRewardsAction.ClaimRewardsClicked) + } + + component.state.observe { userRewardsState -> + view.setVisible(userRewardsState != null) + + userRewardsState?.selectedRewardPeriod?.let { view.setStakingPeriod(it) } + + when (val amount = userRewardsState?.amount) { + is LoadingState.Loaded -> view.showRewards(amount.data) + is LoadingState.Loading -> view.showPendingRewardsLoading() + null -> {} + } + + view.setClaimableRewardsState(userRewardsState?.claimableRewards) + userRewardsState?.iconRes?.let { view.setBannerImage(it) } + } + + component.events.observeEvent { + when (it) { + is UserRewardsEvent.UserRewardPeriodClicked -> router.openStakingPeriods() + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/mythos/MythosUserRewardsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/mythos/MythosUserRewardsComponent.kt new file mode 100644 index 0000000..708778e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/mythos/MythosUserRewardsComponent.kt @@ -0,0 +1,130 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.mythos + +import io.novafoundation.nova.common.presentation.flatMap +import io.novafoundation.nova.common.presentation.map +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.model.MythosDelegatorState +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.userRewards.MythosUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.period.mapRewardPeriodToString +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.mythos.loadUserStakeState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.BaseRewardComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsState +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn + +class MythosUserRewardsComponentFactory( + private val router: MythosStakingRouter, + private val mythosSharedComputation: MythosSharedComputation, + private val interactor: MythosUserRewardsInteractor, + private val rewardPeriodsInteractor: StakingRewardPeriodInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): UserRewardsComponent = MythosUserRewardsComponent( + interactor = interactor, + stakingOption = stakingOption, + hostContext = hostContext, + rewardPeriodsInteractor = rewardPeriodsInteractor, + resourceManager = resourceManager, + router = router, + mythosSharedComputation = mythosSharedComputation, + amountFormatter = amountFormatter + ) +} + +private class MythosUserRewardsComponent( + private val router: MythosStakingRouter, + private val mythosSharedComputation: MythosSharedComputation, + private val interactor: MythosUserRewardsInteractor, + private val rewardPeriodsInteractor: StakingRewardPeriodInteractor, + private val resourceManager: ResourceManager, + + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val amountFormatter: AmountFormatter +) : BaseRewardComponent(hostContext) { + + private val stateDiffing = { old: MythosDelegatorState.Staked, new: MythosDelegatorState.Staked -> + old.userStakeInfo.maybeLastRewardSession == new.userStakeInfo.maybeLastRewardSession + } + + private val rewardPeriodState = rewardPeriodsInteractor.observeRewardPeriod(stakingOption) + .shareInBackground() + + private val nominationPoolRewardsState = mythosSharedComputation.loadUserStakeState( + hostContext = hostContext, + stateProducer = { interactor.rewardsFlow(stakingOption) }, + distinctUntilChanged = stateDiffing + ) + .shareInBackground() + + override fun onAction(action: UserRewardsAction) { + if (action is UserRewardsAction.ClaimRewardsClicked) { + router.openClaimRewards() + } else { + super.onAction(action) + } + } + + init { + launchRewardsSync() + } + + override val state = combine( + nominationPoolRewardsState, + rewardPeriodState, + hostContext.assetFlow + ) { rewardsState, rewardPeriod, asset -> + if (rewardsState == null) return@combine null + + val total = rewardsState.flatMap { poolRewards -> + poolRewards.total.map { total -> amountFormatter.formatAmountToAmountModel(total, asset) } + } + val claimable = rewardsState.flatMap { poolRewards -> + poolRewards.claimable.map { claimable -> + UserRewardsState.ClaimableRewards( + amountModel = amountFormatter.formatAmountToAmountModel(claimable, asset), + canClaim = claimable.isPositive() + ) + } + } + + UserRewardsState( + amount = total, + claimableRewards = claimable, + iconRes = R.drawable.ic_direct_staking_banner_picture, + selectedRewardPeriod = mapRewardPeriodToString(resourceManager, rewardPeriod) + ) + } + .shareInBackground() + + private fun launchRewardsSync() { + val stateUpdates = mythosSharedComputation.delegatorStateFlow() + .filterIsInstance() + .distinctUntilChanged(stateDiffing) + + combine( + rewardPeriodState, + stateUpdates + ) { rewardPeriod, _ -> + interactor.syncTotalRewards(stakingOption, rewardPeriod) + }.launchIn(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/nominationPools/NominationPoolUserRewardsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/nominationPools/NominationPoolUserRewardsComponent.kt new file mode 100644 index 0000000..599954e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/nominationPools/NominationPoolUserRewardsComponent.kt @@ -0,0 +1,138 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.nominationPools + +import io.novafoundation.nova.common.presentation.flatMap +import io.novafoundation.nova.common.presentation.map +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.data.fullId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.userRewards.NominationPoolsUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.period.mapRewardPeriodToString +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.nominationPools.loadPoolMemberState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.BaseRewardComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsState +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn + +class NominationPoolUserRewardsComponentFactory( + private val router: NominationPoolsRouter, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val interactor: NominationPoolsUserRewardsInteractor, + private val rewardPeriodsInteractor: StakingRewardPeriodInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): UserRewardsComponent = NominationPoolUserRewardsComponent( + nominationPoolSharedComputation = nominationPoolSharedComputation, + interactor = interactor, + stakingOption = stakingOption, + hostContext = hostContext, + rewardPeriodsInteractor = rewardPeriodsInteractor, + resourceManager = resourceManager, + router = router, + amountFormatter = amountFormatter + ) +} + +private class NominationPoolUserRewardsComponent( + private val router: NominationPoolsRouter, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val interactor: NominationPoolsUserRewardsInteractor, + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val rewardPeriodsInteractor: StakingRewardPeriodInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : BaseRewardComponent(hostContext) { + + private val poolMemberDiffing = { old: PoolMember?, new: PoolMember? -> + // we only care about accountId and rewardCounter being same, all other changes don't matter + old?.accountId.contentEquals(new?.accountId) && + old?.lastRecordedRewardCounter.orZero() == new?.lastRecordedRewardCounter.orZero() + } + + private val rewardPeriodState = rewardPeriodsInteractor.observeRewardPeriod(stakingOption) + .shareInBackground() + + private val nominationPoolRewardsState = nominationPoolSharedComputation.loadPoolMemberState( + hostContext = hostContext, + chain = stakingOption.assetWithChain.chain, + stateProducer = ::rewardsFlow, + distinctUntilChanged = poolMemberDiffing, + ) + + override fun onAction(action: UserRewardsAction) { + if (action is UserRewardsAction.ClaimRewardsClicked) { + router.openClaimRewards() + } else { + super.onAction(action) + } + } + + init { + launchRewardsSync() + } + + override val state = combine( + nominationPoolRewardsState, + rewardPeriodState, + hostContext.assetFlow + ) { rewardsState, rewardPeriod, asset -> + if (rewardsState == null) return@combine null + + val total = rewardsState.flatMap { poolRewards -> + poolRewards.total.map { total -> amountFormatter.formatAmountToAmountModel(total, asset) } + } + val claimable = rewardsState.flatMap { poolRewards -> + poolRewards.claimable.map { claimable -> + UserRewardsState.ClaimableRewards( + amountModel = amountFormatter.formatAmountToAmountModel(claimable, asset), + canClaim = claimable.isPositive() + ) + } + } + + UserRewardsState( + amount = total, + claimableRewards = claimable, + iconRes = R.drawable.ic_pool_staking_banner_picture, + selectedRewardPeriod = mapRewardPeriodToString(resourceManager, rewardPeriod) + ) + } + .shareInBackground() + + private fun rewardsFlow(poolMember: PoolMember): Flow { + return interactor.rewardsFlow(poolMember.accountId, stakingOption.fullId) + } + + private fun launchRewardsSync() { + val poolMemberUpdates = nominationPoolSharedComputation.currentPoolMemberFlow(stakingOption.assetWithChain.chain, coroutineScope) + .filterNotNull() + .distinctUntilChanged(poolMemberDiffing) + + combine( + rewardPeriodState, + poolMemberUpdates + ) { rewardPeriod, poolMember -> + interactor.syncRewards(poolMember.accountId, stakingOption, rewardPeriod) + }.launchIn(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/parachain/ParachainUserRewardsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/parachain/ParachainUserRewardsComponent.kt new file mode 100644 index 0000000..3b8ac50 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/parachain/ParachainUserRewardsComponent.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.parachain + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_api.domain.model.parachain.DelegatorState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.userRewards.ParachainStakingUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.period.mapRewardPeriodToString +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.parachainStaking.loadDelegatingState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.BaseRewardComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsState +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class ParachainUserRewardsComponentFactory( + private val interactor: ParachainStakingUserRewardsInteractor, + private val delegatorStateUseCase: DelegatorStateUseCase, + private val rewardPeriodsInteractor: StakingRewardPeriodInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): UserRewardsComponent = ParachainUserRewardsComponent( + interactor = interactor, + delegatorStateUseCase = delegatorStateUseCase, + stakingOption = stakingOption, + hostContext = hostContext, + rewardPeriodsInteractor = rewardPeriodsInteractor, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) +} + +private class ParachainUserRewardsComponent( + private val delegatorStateUseCase: DelegatorStateUseCase, + private val interactor: ParachainStakingUserRewardsInteractor, + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val rewardPeriodsInteractor: StakingRewardPeriodInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : BaseRewardComponent(hostContext) { + + private val rewardPeriodState = rewardPeriodsInteractor.observeRewardPeriod(stakingOption) + .shareInBackground() + + private val rewardAmountState = delegatorStateUseCase.loadDelegatingState( + hostContext = hostContext, + assetWithChain = stakingOption.assetWithChain, + stateProducer = ::rewardsFlow, + onDelegatorChange = ::syncStakingRewards + ) + + override val state = combine( + rewardAmountState, + rewardPeriodState + ) { rewardAmount, rewardPeriod -> + rewardAmount?.let { + UserRewardsState( + amount = rewardAmount, + claimableRewards = null, + iconRes = R.drawable.ic_direct_staking_banner_picture, + selectedRewardPeriod = mapRewardPeriodToString(resourceManager, rewardPeriod) + ) + } + } + .shareInBackground() + + private fun rewardsFlow(delegatorState: DelegatorState.Delegator): Flow = combine( + interactor.observeRewards(delegatorState, stakingOption), + hostContext.assetFlow + ) { totalReward, asset -> + amountFormatter.formatAmountToAmountModel(totalReward, asset) + } + + private fun syncStakingRewards(delegatorState: DelegatorState.Delegator) { + rewardPeriodState.onEach { + interactor.syncRewards(delegatorState, stakingOption, it) + }.launchIn(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/relaychain/RelaychainUserRewardsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/relaychain/RelaychainUserRewardsComponent.kt new file mode 100644 index 0000000..e68a18b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/userRewards/relaychain/RelaychainUserRewardsComponent.kt @@ -0,0 +1,112 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.relaychain + +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.period.mapRewardPeriodToString +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.BaseRewardComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsState +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onStart +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.flow.transformLatest + +class RelaychainUserRewardsComponentFactory( + private val stakingInteractor: StakingInteractor, + private val stakingSharedComputation: StakingSharedComputation, + private val rewardPeriodsInteractor: StakingRewardPeriodInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) { + + fun create( + stakingOption: StakingOption, + hostContext: ComponentHostContext + ): UserRewardsComponent = RelaychainUserRewardsComponent( + stakingInteractor = stakingInteractor, + stakingOption = stakingOption, + hostContext = hostContext, + stakingSharedComputation = stakingSharedComputation, + rewardPeriodsInteractor = rewardPeriodsInteractor, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) +} + +private class RelaychainUserRewardsComponent( + private val stakingInteractor: StakingInteractor, + private val stakingSharedComputation: StakingSharedComputation, + private val stakingOption: StakingOption, + private val hostContext: ComponentHostContext, + private val rewardPeriodsInteractor: StakingRewardPeriodInteractor, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : BaseRewardComponent(hostContext) { + + private val selectedAccountStakingStateFlow = stakingSharedComputation.selectedAccountStakingStateFlow( + assetWithChain = stakingOption.assetWithChain, + scope = hostContext.scope + ) + + private val rewardPeriodState = rewardPeriodsInteractor.observeRewardPeriod(stakingOption) + .shareInBackground() + + private val rewardAmountState = selectedAccountStakingStateFlow.transformLatest { stakingState -> + if (stakingState is StakingState.Stash) { + emitAll(rewardsFlow(stakingState)) + } else { + emit(null) + } + } + .onStart { emit(null) } + .shareInBackground() + + override val state: Flow = combine( + rewardAmountState, + rewardPeriodState + ) { rewardAmount, rewardPeriod -> + rewardAmount?.let { + UserRewardsState( + amount = rewardAmount, + claimableRewards = null, + iconRes = R.drawable.ic_direct_staking_banner_picture, + selectedRewardPeriod = mapRewardPeriodToString(resourceManager, rewardPeriod) + ) + } + } + + init { + syncStakingRewards() + } + + private fun rewardsFlow(stakingState: StakingState.Stash): Flow> = combine( + stakingInteractor.observeUserRewards(stakingState, stakingOption), + hostContext.assetFlow + ) { totalReward, asset -> + amountFormatter.formatAmountToAmountModel(totalReward, asset) + }.withLoading() + + private fun syncStakingRewards() { + val stashAccountStakingStateFlow = selectedAccountStakingStateFlow.filterIsInstance() + combine(stashAccountStakingStateFlow, rewardPeriodState) { staking, period -> + stakingInteractor.syncStakingRewards(staking, stakingOption, period) + } + .inBackground() + .launchIn(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/YourPoolComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/YourPoolComponent.kt new file mode 100644 index 0000000..96d40af --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/YourPoolComponent.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool + +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_staking_api.presentation.nominationPools.display.PoolDisplayModel +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.CompoundStakingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.StatefullComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.UnsupportedComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.nominationPools.NominationPoolsYourPoolComponentFactory +import io.novasama.substrate_sdk_android.runtime.AccountId + +typealias YourPoolComponent = StatefullComponent, YourPoolEvent, YourPoolAction> + +class YourPoolComponentState( + val poolStash: AccountId, + val poolId: PoolId, + val display: PoolDisplayModel, + val title: String, +) + +typealias YourPoolEvent = Nothing + +object YourPoolAction + +class YourPoolComponentFactory( + private val nominationPoolsFactory: NominationPoolsYourPoolComponentFactory, + private val compoundStakingComponentFactory: CompoundStakingComponentFactory, +) { + + fun create( + hostContext: ComponentHostContext + ): YourPoolComponent = compoundStakingComponentFactory.create( + relaychainComponentCreator = UnsupportedComponent.creator(), + parachainComponentCreator = UnsupportedComponent.creator(), + mythosCreator = UnsupportedComponent.creator(), + nominationPoolsCreator = nominationPoolsFactory::create, + hostContext = hostContext + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/YourPoolUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/YourPoolUi.kt new file mode 100644 index 0000000..0a541d5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/YourPoolUi.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.letOrHide + +fun BaseFragment<*, *>.setupYourPoolComponent(component: YourPoolComponent, view: YourPoolView) { + component.state.observe { optionalStakeSummaryState -> + view.letOrHide(optionalStakeSummaryState, view::showYourPoolState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/YourPoolView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/YourPoolView.kt new file mode 100644 index 0000000..4621bd6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/YourPoolView.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_staking_impl.databinding.ViewYourPoolBinding + +class YourPoolView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + private val imageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + private val binder = ViewYourPoolBinding.inflate(inflater(), this) + + init { + with(context) { + background = addRipple(getBlockDrawable()) + } + } + + fun showYourPoolState(yourPoolState: LoadingState) { + when (yourPoolState) { + is LoadingState.Loaded -> showLoaded(yourPoolState.data) + is LoadingState.Loading -> showLoading() + } + } + + private fun showLoaded(yourPoolState: YourPoolComponentState) { + binder.yourPoolContentGroup.makeVisible() + binder.yourPoolLoadingGroup.makeGone() + + binder.yourPoolIcon.setIcon(yourPoolState.display.icon, imageLoader) + binder.yourPoolName.text = yourPoolState.display.title + binder.yourPoolTitle.text = yourPoolState.title + } + + private fun showLoading() { + binder.yourPoolContentGroup.makeGone() + binder.yourPoolLoadingGroup.makeVisible() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/nominationPools/NominationPoolsYourPoolComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/nominationPools/NominationPoolsYourPoolComponent.kt new file mode 100644 index 0000000..df72b0f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/components/yourPool/nominationPools/NominationPoolsYourPoolComponent.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.nominationPools + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.presentation.dataOrNull +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId +import io.novafoundation.nova.feature_staking_impl.data.nominationPools.network.blockhain.models.PoolMember +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.yourPool.NominationPoolYourPoolInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.ComponentHostContext +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.common.nominationPools.loadPoolMemberState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.YourPoolAction +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.YourPoolComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.YourPoolComponentState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.YourPoolEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class NominationPoolsYourPoolComponentFactory( + private val interactor: NominationPoolYourPoolInteractor, + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val resourceManager: ResourceManager, + private val poolDisplayFormatter: PoolDisplayFormatter, +) { + + fun create(stakingOption: StakingOption, hostContext: ComponentHostContext): NominationPoolsYourPoolComponent { + return NominationPoolsYourPoolComponent( + nominationPoolSharedComputation = nominationPoolSharedComputation, + interactor = interactor, + poolDisplayFormatter = poolDisplayFormatter, + resourceManager = resourceManager, + hostContext = hostContext, + stakingOption = stakingOption, + ) + } +} + +class NominationPoolsYourPoolComponent( + private val nominationPoolSharedComputation: NominationPoolSharedComputation, + private val interactor: NominationPoolYourPoolInteractor, + private val resourceManager: ResourceManager, + private val hostContext: ComponentHostContext, + private val stakingOption: StakingOption, + private val poolDisplayFormatter: PoolDisplayFormatter, +) : YourPoolComponent, CoroutineScope by hostContext.scope { + + override val events: LiveData> = MutableLiveData() + + override val state = nominationPoolSharedComputation.loadPoolMemberState( + hostContext = hostContext, + chain = stakingOption.assetWithChain.chain, + stateProducer = ::createState, + distinctUntilChanged = { old, new -> old?.poolId == new?.poolId } + ).shareInBackground() + + override fun onAction(action: YourPoolAction) { + // No actions + } + + private fun createState(poolMember: PoolMember): Flow { + val chain = stakingOption.assetWithChain.chain + + return interactor.yourPoolFlow(poolMember.poolId, chain.id).map { yourPool -> + YourPoolComponentState( + poolId = yourPool.id, + display = poolDisplayFormatter.format(yourPool, chain), + poolStash = yourPool.stashAccountId, + title = formatPoolTitle(yourPool.id) + ) + } + } + + private fun formatPoolTitle(poolId: PoolId): String { + return resourceManager.getString(R.string.nomination_pools_your_pool_format, poolId.value.toInt()) + } + + private fun handlePoolInfoClicked() = launch { + val poolAccount = state.first()?.dataOrNull?.poolStash ?: return@launch + val chain = stakingOption.assetWithChain.chain + + hostContext.externalActions.showAddressActions(poolAccount, chain) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/StakingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/StakingComponent.kt new file mode 100644 index 0000000..a7e9f56 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/StakingComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.StakingFragment + +@Subcomponent( + modules = [ + StakingModule::class + ] +) +@ScreenScope +interface StakingComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): StakingComponent + } + + fun inject(fragment: StakingFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/StakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/StakingModule.kt new file mode 100644 index 0000000..626f06e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/StakingModule.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdateSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.StakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.NetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.YourPoolComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.components.ComponentsModule +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase + +@Module(includes = [ViewModelModule::class, ComponentsModule::class]) +class StakingModule { + + @Provides + @IntoMap + @ViewModelKey(StakingViewModel::class) + fun provideViewModel( + selectedAccountUseCase: SelectedAccountUseCase, + + assetUseCase: AssetUseCase, + alertsComponentFactory: AlertsComponentFactory, + unbondingComponentFactory: UnbondingComponentFactory, + stakeSummaryComponentFactory: StakeSummaryComponentFactory, + userRewardsComponentFactory: UserRewardsComponentFactory, + stakeActionsComponentFactory: StakeActionsComponentFactory, + networkInfoComponentFactory: NetworkInfoComponentFactory, + yourPoolComponentFactory: YourPoolComponentFactory, + + router: StakingRouter, + + validationExecutor: ValidationExecutor, + stakingUpdateSystem: StakingUpdateSystem, + stakingSharedState: StakingSharedState, + resourceManager: ResourceManager, + externalActionsMixin: ExternalActions.Presentation, + chainMigrationInfoUseCase: ChainMigrationInfoUseCase + ): ViewModel { + return StakingViewModel( + selectedAccountUseCase = selectedAccountUseCase, + alertsComponentFactory = alertsComponentFactory, + unbondingComponentFactory = unbondingComponentFactory, + stakeSummaryComponentFactory = stakeSummaryComponentFactory, + userRewardsComponentFactory = userRewardsComponentFactory, + stakeActionsComponentFactory = stakeActionsComponentFactory, + networkInfoComponentFactory = networkInfoComponentFactory, + yourPoolComponentFactory = yourPoolComponentFactory, + router = router, + validationExecutor = validationExecutor, + stakingUpdateSystem = stakingUpdateSystem, + assetUseCase = assetUseCase, + stakingSharedState = stakingSharedState, + resourceManager = resourceManager, + externalActionsMixin = externalActionsMixin, + chainMigrationInfoUseCase = chainMigrationInfoUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): StakingViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StakingViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/ComponentsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/ComponentsModule.kt new file mode 100644 index 0000000..467f6e8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/ComponentsModule.kt @@ -0,0 +1,153 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.components + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.CompoundStakingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.AlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.mythos.MythosAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.nominationPools.NominationPoolsAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.parachain.ParachainAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.relaychain.RelaychainAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.NetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.nominationPools.NominationPoolsNetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.parachain.ParachainNetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.relaychain.RelaychainNetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.StakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.mythos.MythosStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.nominationPools.NominationPoolsStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain.ParachainStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain.turing.TuringStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.relaychain.RelaychainStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.StakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.mythos.MythosStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.nominationPools.NominationPoolsStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.parachain.ParachainStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.relaychain.RelaychainStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.UnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.mythos.MythosUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.nominationPools.NominationPoolsUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.parachain.ParachainUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.relaychain.RelaychainUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.mythos.MythosUserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.nominationPools.NominationPoolUserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.parachain.ParachainUserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.relaychain.RelaychainUserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.YourPoolComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.nominationPools.NominationPoolsYourPoolComponentFactory + +@Module( + includes = [ + RelaychainModule::class, + ParachainModule::class, + TuringModule::class, + NominationPoolsModule::class, + MythosModule::class + ] +) +class ComponentsModule { + + @Provides + @ScreenScope + fun provideAlertsComponentFactory( + relaychainComponentFactory: RelaychainAlertsComponentFactory, + parachainComponentFactory: ParachainAlertsComponentFactory, + nominationPoolsFactory: NominationPoolsAlertsComponentFactory, + mythos: MythosAlertsComponentFactory, + compoundStakingComponentFactory: CompoundStakingComponentFactory, + ) = AlertsComponentFactory( + relaychainComponentFactory = relaychainComponentFactory, + parachainAlertsComponentFactory = parachainComponentFactory, + nominationPoolsFactory = nominationPoolsFactory, + mythos = mythos, + compoundStakingComponentFactory = compoundStakingComponentFactory + ) + + @Provides + @ScreenScope + fun provideNetworkInfoFactory( + relaychainComponentFactory: RelaychainNetworkInfoComponentFactory, + parachainComponentFactory: ParachainNetworkInfoComponentFactory, + nominationPoolsNetworkInfoComponentFactory: NominationPoolsNetworkInfoComponentFactory, + compoundStakingComponentFactory: CompoundStakingComponentFactory, + ) = NetworkInfoComponentFactory( + relaychainComponentFactory = relaychainComponentFactory, + parachainComponentFactory = parachainComponentFactory, + nominationPoolsComponentFactory = nominationPoolsNetworkInfoComponentFactory, + compoundStakingComponentFactory = compoundStakingComponentFactory + ) + + @Provides + @ScreenScope + fun provideStakeActionsComponentFactory( + relaychainComponentFactory: RelaychainStakeActionsComponentFactory, + parachainComponentFactory: ParachainStakeActionsComponentFactory, + turingStakeActionsComponentFactory: TuringStakeActionsComponentFactory, + nominationPoolsStakeActionsComponentFactory: NominationPoolsStakeActionsComponentFactory, + mythosStakeActionsComponentFactory: MythosStakeActionsComponentFactory, + compoundStakingComponentFactory: CompoundStakingComponentFactory, + ) = StakeActionsComponentFactory( + relaychainComponentFactory = relaychainComponentFactory, + parachainComponentFactory = parachainComponentFactory, + turingComponentFactory = turingStakeActionsComponentFactory, + nominationPoolsComponentFactory = nominationPoolsStakeActionsComponentFactory, + mythosStakeActionsComponentFactory = mythosStakeActionsComponentFactory, + compoundStakingComponentFactory = compoundStakingComponentFactory + ) + + @Provides + @ScreenScope + fun provideStakeSummaryComponentFactory( + relaychainComponentFactory: RelaychainStakeSummaryComponentFactory, + parachainComponentFactory: ParachainStakeSummaryComponentFactory, + nominationPoolsStakeSummaryComponentFactory: NominationPoolsStakeSummaryComponentFactory, + compoundStakingComponentFactory: CompoundStakingComponentFactory, + mythosStakeSummaryComponentFactory: MythosStakeSummaryComponentFactory + ) = StakeSummaryComponentFactory( + relaychainComponentFactory = relaychainComponentFactory, + parachainStakeSummaryComponentFactory = parachainComponentFactory, + nominationPoolsStakeSummaryComponentFactory = nominationPoolsStakeSummaryComponentFactory, + mythos = mythosStakeSummaryComponentFactory, + compoundStakingComponentFactory = compoundStakingComponentFactory + ) + + @Provides + @ScreenScope + fun provideUnbondingComponentFactory( + relaychainComponentFactory: RelaychainUnbondingComponentFactory, + parachainComponentFactory: ParachainUnbondingComponentFactory, + nominationPoolsUnbondingComponentFactory: NominationPoolsUnbondingComponentFactory, + mythos: MythosUnbondingComponentFactory, + compoundStakingComponentFactory: CompoundStakingComponentFactory, + ) = UnbondingComponentFactory( + relaychainUnbondingComponentFactory = relaychainComponentFactory, + parachainComponentFactory = parachainComponentFactory, + nominationPoolsUnbondingComponentFactory = nominationPoolsUnbondingComponentFactory, + mythos = mythos, + compoundStakingComponentFactory = compoundStakingComponentFactory + ) + + @Provides + @ScreenScope + fun provideUserRewardsComponentFactory( + relaychainComponentFactory: RelaychainUserRewardsComponentFactory, + parachainComponentFactory: ParachainUserRewardsComponentFactory, + nominationPoolUserRewardsComponentFactory: NominationPoolUserRewardsComponentFactory, + mythos: MythosUserRewardsComponentFactory, + compoundStakingComponentFactory: CompoundStakingComponentFactory, + ) = UserRewardsComponentFactory( + relaychainComponentFactory = relaychainComponentFactory, + parachainComponentFactory = parachainComponentFactory, + nominationPoolsComponentFactory = nominationPoolUserRewardsComponentFactory, + mythos = mythos, + compoundStakingComponentFactory = compoundStakingComponentFactory + ) + + @Provides + @ScreenScope + fun provideYourPoolComponentFactory( + nominationPoolsFactory: NominationPoolsYourPoolComponentFactory, + compoundFactory: CompoundStakingComponentFactory, + ) = YourPoolComponentFactory(nominationPoolsFactory, compoundFactory) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/MythosModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/MythosModule.kt new file mode 100644 index 0000000..dc576cf --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/MythosModule.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.components + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.domain.mythos.common.MythosSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.alerts.MythosStakingAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.stakeSummary.MythosStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.unbonding.MythosUnbondingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.mythos.main.userRewards.MythosUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.MythosStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.mythos.MythosAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.mythos.MythosStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.mythos.MythosStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.mythos.MythosUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.mythos.MythosUserRewardsComponentFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module +class MythosModule { + + @Provides + @ScreenScope + fun provideStakeSummaryComponentFactory( + mythosSharedComputation: MythosSharedComputation, + interactor: MythosStakeSummaryInteractor, + amountFormatter: AmountFormatter + ) = MythosStakeSummaryComponentFactory( + mythosSharedComputation = mythosSharedComputation, + interactor = interactor, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideStakeActionsFactory( + mythosSharedComputation: MythosSharedComputation, + resourceManager: ResourceManager, + router: MythosStakingRouter, + ) = MythosStakeActionsComponentFactory( + mythosSharedComputation = mythosSharedComputation, + resourceManager = resourceManager, + router = router + ) + + @Provides + @ScreenScope + fun provideUnbondingComponentFactory( + mythosSharedComputation: MythosSharedComputation, + interactor: MythosUnbondingInteractor, + router: MythosStakingRouter, + amountFormatter: AmountFormatter + ) = MythosUnbondingComponentFactory( + mythosSharedComputation = mythosSharedComputation, + interactor = interactor, + router = router, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideUserRewardsComponentFactory( + router: MythosStakingRouter, + mythosSharedComputation: MythosSharedComputation, + interactor: MythosUserRewardsInteractor, + rewardPeriodsInteractor: StakingRewardPeriodInteractor, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ) = MythosUserRewardsComponentFactory( + router = router, + mythosSharedComputation = mythosSharedComputation, + interactor = interactor, + rewardPeriodsInteractor = rewardPeriodsInteractor, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideAlertsFactory( + mythosSharedComputation: MythosSharedComputation, + interactor: MythosStakingAlertsInteractor, + resourceManager: ResourceManager, + router: MythosStakingRouter, + amountFormatter: AmountFormatter + ) = MythosAlertsComponentFactory( + mythosSharedComputation = mythosSharedComputation, + interactor = interactor, + resourceManager = resourceManager, + router = router, + amountFormatter = amountFormatter + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/NominationPoolsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/NominationPoolsModule.kt new file mode 100644 index 0000000..a0c696f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/NominationPoolsModule.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.components + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.alerts.NominationPoolsAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.networkInfo.NominationPoolsNetworkInfoInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.stakeSummary.NominationPoolStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.unbondings.NominationPoolUnbondingsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.userRewards.NominationPoolsUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.main.yourPool.NominationPoolYourPoolInteractor +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.NominationPoolsRouter +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.nominationPools.NominationPoolsAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.nominationPools.NominationPoolsNetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.nominationPools.NominationPoolsStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.nominationPools.NominationPoolsStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.nominationPools.NominationPoolsUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.nominationPools.NominationPoolUserRewardsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.yourPool.nominationPools.NominationPoolsYourPoolComponentFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module +class NominationPoolsModule { + + @Provides + @ScreenScope + fun provideNetworkInfoComponentFactory( + parachainNetworkInfoInteractor: NominationPoolsNetworkInfoInteractor, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ) = NominationPoolsNetworkInfoComponentFactory( + resourceManager = resourceManager, + interactor = parachainNetworkInfoInteractor, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideStakeSummaryComponentFactory( + interactor: NominationPoolStakeSummaryInteractor, + sharedComputation: NominationPoolSharedComputation, + amountFormatter: AmountFormatter + ) = NominationPoolsStakeSummaryComponentFactory( + nominationPoolSharedComputation = sharedComputation, + interactor = interactor, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideUnbondComponentFactory( + interactor: NominationPoolUnbondingsInteractor, + sharedComputation: NominationPoolSharedComputation, + router: NominationPoolsRouter, + amountFormatter: AmountFormatter + ) = NominationPoolsUnbondingComponentFactory( + nominationPoolSharedComputation = sharedComputation, + interactor = interactor, + router = router, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideUserRewardsComponentFactory( + nominationPoolSharedComputation: NominationPoolSharedComputation, + interactor: NominationPoolsUserRewardsInteractor, + rewardPeriodsInteractor: StakingRewardPeriodInteractor, + resourceManager: ResourceManager, + router: NominationPoolsRouter, + amountFormatter: AmountFormatter + ) = NominationPoolUserRewardsComponentFactory( + nominationPoolSharedComputation = nominationPoolSharedComputation, + interactor = interactor, + rewardPeriodsInteractor = rewardPeriodsInteractor, + resourceManager = resourceManager, + router = router, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideYourPoolComponentFactory( + nominationPoolSharedComputation: NominationPoolSharedComputation, + interactor: NominationPoolYourPoolInteractor, + resourceManager: ResourceManager, + poolDisplayFormatter: PoolDisplayFormatter, + ) = NominationPoolsYourPoolComponentFactory( + nominationPoolSharedComputation = nominationPoolSharedComputation, + interactor = interactor, + resourceManager = resourceManager, + poolDisplayFormatter = poolDisplayFormatter + ) + + @Provides + @ScreenScope + fun provideStakeActionsComponentFactory( + resourceManager: ResourceManager, + sharedComputation: NominationPoolSharedComputation, + router: NominationPoolsRouter + ) = NominationPoolsStakeActionsComponentFactory( + nominationPoolSharedComputation = sharedComputation, + resourceManager = resourceManager, + router = router + ) + + @Provides + @ScreenScope + fun provideAlertsComponentFactory( + nominationPoolSharedComputation: NominationPoolSharedComputation, + interactor: NominationPoolsAlertsInteractor, + resourceManager: ResourceManager, + router: NominationPoolsRouter, + amountFormatter: AmountFormatter + ) = NominationPoolsAlertsComponentFactory( + nominationPoolSharedComputation = nominationPoolSharedComputation, + interactor = interactor, + resourceManager = resourceManager, + router = router, + amountFormatter = amountFormatter + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/ParachainModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/ParachainModule.kt new file mode 100644 index 0000000..d902061 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/ParachainModule.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.components + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.ParachainNetworkInfoInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.alerts.ParachainStakingAlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.stakeSummary.ParachainStakingStakeSummaryInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.unbondings.ParachainStakingUnbondingsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.main.userRewards.ParachainStakingUserRewardsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.ParachainStakingUnbondPreliminaryValidationSystem +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.parachain.ParachainAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.parachain.ParachainNetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain.ParachainStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.parachain.ParachainStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.parachain.ParachainUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.parachain.ParachainUserRewardsComponentFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module +class ParachainModule { + + @Provides + @ScreenScope + fun provideParachainStakeSummaryComponentFactory( + delegatorStateUseCase: DelegatorStateUseCase, + interactor: ParachainStakingStakeSummaryInteractor, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ) = ParachainStakeSummaryComponentFactory( + delegatorStateUseCase = delegatorStateUseCase, + resourceManager = resourceManager, + interactor = interactor, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideParachainNetworkInfoComponentFactory( + delegatorStateUseCase: DelegatorStateUseCase, + parachainNetworkInfoInteractor: ParachainNetworkInfoInteractor, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ) = ParachainNetworkInfoComponentFactory( + delegatorStateUseCase = delegatorStateUseCase, + resourceManager = resourceManager, + interactor = parachainNetworkInfoInteractor, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideParachainUserRewardsComponentFactory( + delegatorStateUseCase: DelegatorStateUseCase, + interactor: ParachainStakingUserRewardsInteractor, + stakingRewardPeriodInteractor: StakingRewardPeriodInteractor, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ) = ParachainUserRewardsComponentFactory( + delegatorStateUseCase = delegatorStateUseCase, + interactor = interactor, + rewardPeriodsInteractor = stakingRewardPeriodInteractor, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideParachainUnbondingsFactory( + delegatorStateUseCase: DelegatorStateUseCase, + interactor: ParachainStakingUnbondingsInteractor, + router: ParachainStakingRouter, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ) = ParachainUnbondingComponentFactory( + delegatorStateUseCase = delegatorStateUseCase, + interactor = interactor, + router = router, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideStakeActionsFactory( + delegatorStateUseCase: DelegatorStateUseCase, + resourceManager: ResourceManager, + router: ParachainStakingRouter, + validationExecutor: ValidationExecutor, + unbondPreliminaryValidationSystem: ParachainStakingUnbondPreliminaryValidationSystem, + ) = ParachainStakeActionsComponentFactory( + delegatorStateUseCase = delegatorStateUseCase, + resourceManager = resourceManager, + router = router, + validationExecutor = validationExecutor, + unbondPreliminaryValidationSystem = unbondPreliminaryValidationSystem + ) + + @Provides + @ScreenScope + fun provideAlertsFactory( + delegatorStateUseCase: DelegatorStateUseCase, + resourceManager: ResourceManager, + router: ParachainStakingRouter, + interactor: ParachainStakingAlertsInteractor, + amountFormatter: AmountFormatter + ) = ParachainAlertsComponentFactory( + delegatorStateUseCase = delegatorStateUseCase, + resourceManager = resourceManager, + router = router, + interactor = interactor, + amountFormatter = amountFormatter + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/RelaychainModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/RelaychainModule.kt new file mode 100644 index 0000000..3578a00 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/RelaychainModule.kt @@ -0,0 +1,136 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.components + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_proxy_api.data.repository.GetProxyRepository +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.alerts.AlertsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.period.StakingRewardPeriodInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.UnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_BOND_MORE +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_REBAG +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_REBOND +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.SYSTEM_MANAGE_STAKING_REDEEM +import io.novafoundation.nova.feature_staking_impl.domain.validations.main.StakeActionsValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.alerts.relaychain.RelaychainAlertsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.networkInfo.relaychain.RelaychainNetworkInfoComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.relaychain.RelaychainStakeActionsComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeSummary.relaychain.RelaychainStakeSummaryComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.unbonding.relaychain.RelaychainUnbondingComponentFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.relaychain.RelaychainUserRewardsComponentFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import javax.inject.Named + +@Module +class RelaychainModule { + + @Provides + @ScreenScope + fun provideRelaychainAlertsComponentFactory( + stakingSharedComputation: StakingSharedComputation, + alertsInteractor: AlertsInteractor, + resourceManager: ResourceManager, + @Named(SYSTEM_MANAGE_STAKING_REDEEM) redeemValidationSystem: StakeActionsValidationSystem, + @Named(SYSTEM_MANAGE_STAKING_BOND_MORE) bondMoreValidationSystem: StakeActionsValidationSystem, + @Named(SYSTEM_MANAGE_STAKING_REBAG) rebagValidationSystem: StakeActionsValidationSystem, + router: StakingRouter, + ) = RelaychainAlertsComponentFactory( + stakingSharedComputation = stakingSharedComputation, + alertsInteractor = alertsInteractor, + resourceManager = resourceManager, + redeemValidationSystem = redeemValidationSystem, + bondMoreValidationSystem = bondMoreValidationSystem, + rebagValidationSystem = rebagValidationSystem, + router = router + ) + + @Provides + @ScreenScope + fun provideRelaychainNetworkInfoComponentFactory( + stakingInteractor: StakingInteractor, + resourceManager: ResourceManager, + stakingSharedComputation: StakingSharedComputation, + amountFormatter: AmountFormatter + ) = RelaychainNetworkInfoComponentFactory( + stakingInteractor = stakingInteractor, + resourceManager = resourceManager, + stakingSharedComputation = stakingSharedComputation, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideRelaychainStakeActionsComponentFactory( + stakingSharedComputation: StakingSharedComputation, + resourceManager: ResourceManager, + stakeActionsValidations: Map<@JvmSuppressWildcards String, StakeActionsValidationSystem>, + router: StakingRouter, + accountRepository: AccountRepository, + getProxyRepository: GetProxyRepository + ) = RelaychainStakeActionsComponentFactory( + stakingSharedComputation = stakingSharedComputation, + resourceManager = resourceManager, + stakeActionsValidations = stakeActionsValidations, + router = router, + accountRepository = accountRepository, + getProxyRepository = getProxyRepository + ) + + @Provides + @ScreenScope + fun provideRelaychainStakeSummaryComponentFactory( + stakingInteractor: StakingInteractor, + resourceManager: ResourceManager, + stakingSharedComputation: StakingSharedComputation, + amountFormatter: AmountFormatter + ) = RelaychainStakeSummaryComponentFactory( + stakingInteractor = stakingInteractor, + resourceManager = resourceManager, + stakingSharedComputation = stakingSharedComputation, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideRelaychainUnbondingComponentFactory( + unbondInteractor: UnbondInteractor, + validationExecutor: ValidationExecutor, + resourceManager: ResourceManager, + @Named(SYSTEM_MANAGE_STAKING_REBOND) rebondValidationSystem: StakeActionsValidationSystem, + @Named(SYSTEM_MANAGE_STAKING_REDEEM) redeemValidationSystem: StakeActionsValidationSystem, + router: StakingRouter, + stakingSharedComputation: StakingSharedComputation, + amountFormatter: AmountFormatter + ) = RelaychainUnbondingComponentFactory( + unbondInteractor = unbondInteractor, + validationExecutor = validationExecutor, + resourceManager = resourceManager, + rebondValidationSystem = rebondValidationSystem, + redeemValidationSystem = redeemValidationSystem, + router = router, + stakingSharedComputation = stakingSharedComputation, + amountFormatter = amountFormatter + ) + + @Provides + @ScreenScope + fun provideRelaychainUserRewardsComponentFactory( + stakingInteractor: StakingInteractor, + stakingSharedComputation: StakingSharedComputation, + stakingRewardPeriodInteractor: StakingRewardPeriodInteractor, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ) = RelaychainUserRewardsComponentFactory( + stakingInteractor = stakingInteractor, + stakingSharedComputation = stakingSharedComputation, + rewardPeriodsInteractor = stakingRewardPeriodInteractor, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/TuringModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/TuringModule.kt new file mode 100644 index 0000000..a19ef21 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/di/components/TuringModule.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.di.components + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_api.data.parachainStaking.turing.repository.TuringAutomationTasksRepository +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.common.DelegatorStateUseCase +import io.novafoundation.nova.feature_staking_impl.domain.parachainStaking.unbond.validations.preliminary.ParachainStakingUnbondPreliminaryValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.ParachainStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.parachain.turing.TuringStakeActionsComponentFactory + +@Module +class TuringModule { + + @Provides + @ScreenScope + fun provideStakeActionsFactory( + delegatorStateUseCase: DelegatorStateUseCase, + resourceManager: ResourceManager, + router: ParachainStakingRouter, + validationExecutor: ValidationExecutor, + unbondPreliminaryValidationSystem: ParachainStakingUnbondPreliminaryValidationSystem, + turingAutomationTasksRepository: TuringAutomationTasksRepository + ) = TuringStakeActionsComponentFactory( + delegatorStateUseCase = delegatorStateUseCase, + resourceManager = resourceManager, + router = router, + validationExecutor = validationExecutor, + unbondPreliminaryValidationSystem = unbondPreliminaryValidationSystem, + turingAutomationTasksRepository = turingAutomationTasksRepository + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/model/RewardEstimation.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/model/RewardEstimation.kt new file mode 100644 index 0000000..8782a77 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/model/RewardEstimation.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.model + +class RewardEstimation( + val amount: String, + val fiatAmount: String?, + val gain: String, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/model/StakingNetworkInfoModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/model/StakingNetworkInfoModel.kt new file mode 100644 index 0000000..547daf9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/model/StakingNetworkInfoModel.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.model + +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +data class StakingNetworkInfoModel( + val totalStaked: AmountModel, + val minimumStake: AmountModel, + val activeNominators: String, + val stakingPeriod: String, + val unstakingPeriod: String +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/ManageStakingActionView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/ManageStakingActionView.kt new file mode 100644 index 0000000..14d3f4d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/ManageStakingActionView.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ItemStakingManageActionBinding + +class ManageStakingActionView @kotlin.jvm.JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ItemStakingManageActionBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + + setBackgroundResource(R.drawable.bg_primary_list_item) + + attrs?.let(::applyAttrs) + } + + fun setLabel(label: String) { + binder.itemManageStakingActionText.text = label + } + + fun setIcon(icon: Drawable) { + binder.itemManageStakingActionImage.setImageDrawable(icon) + } + + fun setIconRes(@DrawableRes iconRes: Int) { + binder.itemManageStakingActionImage.setImageResource(iconRes) + } + + fun setBadge(content: String?) { + binder.itemManageStakingActionBadge.setTextOrHide(content) + } + + private fun applyAttrs(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.ManageStakingActionView) { + val label = it.getString(R.styleable.ManageStakingActionView_label) + label?.let(::setLabel) + + val icon = it.getDrawable(R.styleable.ManageStakingActionView_icon) + icon?.let(::setIcon) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/ManageStakingView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/ManageStakingView.kt new file mode 100644 index 0000000..30c23f8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/ManageStakingView.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.updatePadding +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.stakeActions.ManageStakeAction + +typealias ManageStakeActionClickListener = (ManageStakeAction) -> Unit + +class ManageStakingView @kotlin.jvm.JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : LinearLayout(context, attrs, defStyle), + WithContextExtensions by WithContextExtensions(context) { + + private var listener: ManageStakeActionClickListener? = null + + init { + orientation = VERTICAL + updatePadding(top = 8.dp, bottom = 8.dp) + + background = context.getBlockDrawable() + clipToOutline = true + } + + fun setAvailableActions(actions: Collection) { + removeAllViews() + + actions.forEach { addViewFor(it) } + } + + private fun addViewFor(action: ManageStakeAction) { + val view = ManageStakingActionView(context).apply { + setLabel(action.label) + setIconRes(action.iconRes) + setBadge(action.badge) + + setOnClickListener { listener?.invoke(action) } + } + + addView(view) + } + + fun onManageStakeActionClicked(listener: ManageStakeActionClickListener) { + this.listener = listener + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/StakingInfoView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/StakingInfoView.kt new file mode 100644 index 0000000..7dba3b9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/StakingInfoView.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setShimmerVisible +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ViewStakingInfoBinding + +class StakingInfoView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewStakingInfoBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + + attrs?.let { applyAttributes(it) } + } + + private fun applyAttributes(attributeSet: AttributeSet) { + val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.StakingInfoView) + + val title = typedArray.getString(R.styleable.StakingInfoView_title) + title?.let { setTitle(title) } + + typedArray.recycle() + } + + fun setTitle(title: String) { + binder.stakingInfoTitle.text = title + } + + fun showLoading() { + binder.stakingInfoGainShimmer.setShimmerVisible(true) + binder.stakingInfoGain.makeGone() + } + + fun showGain(gain: String) { + binder.stakingInfoGainShimmer.setShimmerVisible(false) + binder.stakingInfoGain.makeVisible() + + binder.stakingInfoGain.text = gain + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/UserRewardsView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/UserRewardsView.kt new file mode 100644 index 0000000..a500fd6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/main/view/UserRewardsView.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.main.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.annotation.DrawableRes +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeInvisible +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ViewUserRewardsBinding +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.components.userRewards.UserRewardsState.ClaimableRewards +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class UserRewardsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewUserRewardsBinding.inflate(inflater(), this, true) + + init { + binder.userRewardsPendingContainer.background = getRoundedCornerDrawable(fillColorRes = R.color.block_background, cornerSizeDp = 10) + binder.userRewardsPendingGroup.makeGone() + } + + fun showPendingRewardsLoading() { + binder.userRewardsShimmerGroup.makeVisible() + binder.userRewardsContentGroup.makeInvisible() + + binder.userRewardsTokenAmountShimmer.startShimmer() + binder.userRewardsFiatAmountShimmer.startShimmer() + } + + fun setStakingPeriod(period: String) { + binder.userRewardsStakingPeriod.text = period + } + + fun showRewards(amountModel: AmountModel) { + binder.userRewardsShimmerGroup.makeGone() + binder.userRewardsContentGroup.makeVisible() + + binder.userRewardsTokenAmountShimmer.stopShimmer() + binder.userRewardsFiatAmountShimmer.stopShimmer() + + binder.userRewardsTokenAmount.text = amountModel.token + binder.userRewardsFiatAmount.text = amountModel.fiat + } + + fun setBannerImage(@DrawableRes imageRes: Int) { + binder.userRewardsBanner.setImage(imageRes) + } + + fun setClaimClickListener(listener: (View) -> Unit) { + binder.userRewardsPendingClaim.setOnClickListener(listener) + } + + fun setClaimableRewardsState(pendingRewardsState: LoadingState?) { + when (pendingRewardsState) { + null, is LoadingState.Loading -> binder.userRewardsPendingGroup.makeGone() + is LoadingState.Loaded -> { + val claimableRewards = pendingRewardsState.data + + binder.userRewardsPendingGroup.makeVisible() + + binder.userRewardsPendingAmount.text = claimableRewards.amountModel.token + binder.userRewardsPendingFiat.text = claimableRewards.amountModel.fiat + + binder.userRewardsPendingClaim.isEnabled = claimableRewards.canClaim + } + } + } + + fun setOnRewardPeriodClickedListener(onClick: OnClickListener) { + binder.userRewardsStakingPeriod.setOnClickListener(onClick) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/ValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/ValidationFailure.kt new file mode 100644 index 0000000..bfce019 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/ValidationFailure.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationFailure +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun rebondValidationFailure( + reason: RebondValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage { + return when (reason) { + RebondValidationFailure.CannotPayFee -> { + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.common_not_enough_funds_message) + } + + RebondValidationFailure.NotEnoughUnbondings -> { + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.staking_rebond_insufficient_bondings) + } + + RebondValidationFailure.ZeroAmount -> { + resourceManager.getString(R.string.common_error_general_title) to + resourceManager.getString(R.string.common_zero_amount_error) + } + + is RebondValidationFailure.NotEnoughBalanceToStayAboveED -> handleInsufficientBalanceCommission( + reason, + resourceManager + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/ConfirmRebondFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/ConfirmRebondFragment.kt new file mode 100644 index 0000000..f12de40 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/ConfirmRebondFragment.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmRebondBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +private const val PAYLOAD_KEY = "PAYLOAD_KEY" + +class ConfirmRebondFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: ConfirmRebondPayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentConfirmRebondBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmRebondExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.confirmRebondToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.confirmRebondConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmRebondConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + val payload = argument(PAYLOAD_KEY) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmRebondFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmRebondViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.confirmRebondHints) + setupFeeLoading(viewModel, binder.confirmRebondExtrinsicInformation.fee) + + viewModel.showNextProgress.observe(binder.confirmRebondConfirm::setProgressState) + + viewModel.amountModelFlow.observe(binder.confirmRebondAmount::setAmount) + + viewModel.walletUiFlow.observe(binder.confirmRebondExtrinsicInformation::setWallet) + viewModel.feeLiveData.observe(binder.confirmRebondExtrinsicInformation::setFeeStatus) + viewModel.originAddressModelFlow.observe(binder.confirmRebondExtrinsicInformation::setAccount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/ConfirmRebondPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/ConfirmRebondPayload.kt new file mode 100644 index 0000000..f7bd267 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/ConfirmRebondPayload.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class ConfirmRebondPayload( + val amount: BigDecimal +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/ConfirmRebondViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/ConfirmRebondViewModel.kt new file mode 100644 index 0000000..07e5386 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/ConfirmRebondViewModel.kt @@ -0,0 +1,159 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.rebond.RebondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.rebondValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmRebondViewModel( + private val router: StakingRouter, + interactor: StakingInteractor, + private val rebondInteractor: RebondInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: RebondValidationSystem, + private val iconGenerator: AddressIconGenerator, + private val externalActions: ExternalActions.Presentation, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val payload: ConfirmRebondPayload, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + hintsMixinFactory: ResourcesHintsMixinFactory, + walletUiUseCase: WalletUiUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + FeeLoaderMixin by feeLoaderMixin, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + private val accountStakingFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .inBackground() + .share() + + private val assetFlow = accountStakingFlow.flatMapLatest { + interactor.assetFlow(it.controllerAddress) + } + .inBackground() + .share() + + val hintsMixin = hintsMixinFactory.create( + coroutineScope = this, + hintsRes = listOf(R.string.staking_rebond_counted_next_era_hint) + ) + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val amountModelFlow = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.amount, asset) + } + .shareInBackground() + + val originAddressModelFlow = accountStakingFlow.map { + iconGenerator.createAccountAddressModel(it.chain, it.controllerAddress) + }.shareInBackground() + + init { + loadFee() + } + + fun confirmClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() { + launch { + val address = originAddressModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + } + + private fun loadFee() { + feeLoaderMixin.loadFee( + coroutineScope = viewModelScope, + feeConstructor = { token -> + val amountInPlanks = token.planksFromAmount(payload.amount) + + rebondInteractor.estimateFee(amountInPlanks, accountStakingFlow.first()) + }, + onRetryCancelled = ::backClicked + ) + } + + private fun maybeGoToNext() = launch { + _showNextProgress.value = true + + val payload = RebondValidationPayload( + fee = feeLoaderMixin.awaitFee(), + rebondAmount = payload.amount, + controllerAsset = assetFlow.first(), + chain = accountStakingFlow.first().chain + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { rebondValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer(), + block = ::sendTransaction + ) + } + + private fun sendTransaction(validPayload: RebondValidationPayload) = launch { + val amountInPlanks = validPayload.controllerAsset.token.planksFromAmount(payload.amount) + val stashState = accountStakingFlow.first() + + rebondInteractor.rebond(stashState, amountInPlanks) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + .onFailure(::showError) + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/di/ConfirmRebondComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/di/ConfirmRebondComponent.kt new file mode 100644 index 0000000..eac6c35 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/di/ConfirmRebondComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.ConfirmRebondFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.ConfirmRebondPayload + +@Subcomponent( + modules = [ + ConfirmRebondModule::class + ] +) +@ScreenScope +interface ConfirmRebondComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmRebondPayload, + ): ConfirmRebondComponent + } + + fun inject(fragment: ConfirmRebondFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/di/ConfirmRebondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/di/ConfirmRebondModule.kt new file mode 100644 index 0000000..7d84e23 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/confirm/di/ConfirmRebondModule.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.rebond.RebondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.ConfirmRebondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.ConfirmRebondViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmRebondModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmRebondViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + rebondInteractor: RebondInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + feeLoaderMixin: FeeLoaderMixin.Presentation, + validationSystem: RebondValidationSystem, + iconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + payload: ConfirmRebondPayload, + singleAssetSharedState: StakingSharedState, + hintsMixinFactory: ResourcesHintsMixinFactory, + walletUiUseCase: WalletUiUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmRebondViewModel( + router = router, + interactor = interactor, + rebondInteractor = rebondInteractor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + iconGenerator = iconGenerator, + externalActions = externalActions, + feeLoaderMixin = feeLoaderMixin, + payload = payload, + selectedAssetState = singleAssetSharedState, + hintsMixinFactory = hintsMixinFactory, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ConfirmRebondViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmRebondViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/CustomRebondFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/CustomRebondFragment.kt new file mode 100644 index 0000000..2e63b93 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/CustomRebondFragment.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.custom + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentRebondCustomBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class CustomRebondFragment : BaseFragment() { + + override fun createBinding() = FragmentRebondCustomBinding.inflate(layoutInflater) + + override fun initViews() { + binder.rebondToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.rebondContinue.prepareForProgress(viewLifecycleOwner) + binder.rebondContinue.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .rebondCustomFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: CustomRebondViewModel) { + observeValidations(viewModel) + observeHints(viewModel.hintsMixin, binder.rebondHints) + setupAmountChooser(viewModel.amountChooserMixin, binder.rebondAmount) + setupFeeLoading(viewModel.originFeeMixin, binder.rebondFee) + + viewModel.transferableFlow.observe(binder.rebondTransferable::showAmount) + + viewModel.showNextProgress.observe(binder.rebondContinue::setProgressState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/CustomRebondViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/CustomRebondViewModel.kt new file mode 100644 index 0000000..6452966 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/CustomRebondViewModel.kt @@ -0,0 +1,136 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.custom + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.rebond.RebondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.confirm.ConfirmRebondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.rebondValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.transferableAmountModelOf +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch + +class CustomRebondViewModel( + private val router: StakingRouter, + interactor: StakingInteractor, + private val rebondInteractor: RebondInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: RebondValidationSystem, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + hintsMixinFactory: ResourcesHintsMixinFactory +) : BaseViewModel(), + Validatable by validationExecutor { + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + private val accountStakingFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .shareInBackground() + + private val assetFlow = accountStakingFlow.flatMapLatest { + interactor.assetFlow(it.controllerAddress) + } + .shareInBackground() + + private val selectedChainAsset = assetFlow.map { it.token.configuration } + .shareInBackground() + + val originFeeMixin = feeLoaderMixinFactory.createDefault( + this, + selectedChainAsset, + FeeLoaderMixinV2.Configuration(onRetryCancelled = ::backClicked) + ) + + private val maxActionProvider = maxActionProviderFactory.createCustom(viewModelScope) { + assetFlow.providingMaxOf(Asset::unbondingInPlanks) + } + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = assetFlow, + maxActionProvider = maxActionProvider + ) + + val hintsMixin = hintsMixinFactory.create( + coroutineScope = this, + hintsRes = listOf(R.string.staking_rebond_counted_next_era_hint) + ) + + val transferableFlow = assetFlow.mapLatest { transferableAmountModelOf(amountFormatter, it) } + .shareInBackground() + + init { + listenFee() + } + + fun confirmClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + private fun listenFee() { + originFeeMixin.connectWith( + inputSource1 = amountChooserMixin.backPressuredPlanks, + feeConstructor = { _, amount -> + rebondInteractor.estimateFee(amount, accountStakingFlow.first()) + } + ) + } + + private fun maybeGoToNext() = launch { + val payload = RebondValidationPayload( + fee = originFeeMixin.awaitFee(), + rebondAmount = amountChooserMixin.amount.first(), + controllerAsset = assetFlow.first(), + chain = accountStakingFlow.first().chain + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { rebondValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer(), + block = ::openConfirm + ) + } + + private fun openConfirm(validPayload: RebondValidationPayload) { + _showNextProgress.value = false + + val confirmPayload = ConfirmRebondPayload(validPayload.rebondAmount) + + router.openConfirmRebond(confirmPayload) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/di/CustomRebondComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/di/CustomRebondComponent.kt new file mode 100644 index 0000000..8f6a55c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/di/CustomRebondComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.custom.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.custom.CustomRebondFragment + +@Subcomponent( + modules = [ + CustomRebondModule::class + ] +) +@ScreenScope +interface CustomRebondComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): CustomRebondComponent + } + + fun inject(fragment: CustomRebondFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/di/CustomRebondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/di/CustomRebondModule.kt new file mode 100644 index 0000000..860ee13 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rebond/custom/di/CustomRebondModule.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.custom.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.rebond.RebondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.rebond.RebondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rebond.custom.CustomRebondViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class CustomRebondModule { + + @Provides + @IntoMap + @ViewModelKey(CustomRebondViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + rebondInteractor: RebondInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + maxActionProviderFactory: MaxActionProviderFactory, + validationSystem: RebondValidationSystem, + amountChooserMixin: AmountChooserMixin.Factory, + hintsMixinFactory: ResourcesHintsMixinFactory, + amountFormatter: AmountFormatter + ): ViewModel { + return CustomRebondViewModel( + router = router, + interactor = interactor, + rebondInteractor = rebondInteractor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + feeLoaderMixinFactory = feeLoaderMixinFactory, + maxActionProviderFactory = maxActionProviderFactory, + amountChooserMixinFactory = amountChooserMixin, + hintsMixinFactory = hintsMixinFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): CustomRebondViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(CustomRebondViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/RedeemFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/RedeemFragment.kt new file mode 100644 index 0000000..bdbb549 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/RedeemFragment.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentRedeemBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +private const val PAYLOAD_KEY = "PAYLOAD_KEY" + +class RedeemFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: RedeemPayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentRedeemBinding.inflate(layoutInflater) + + override fun initViews() { + binder.redeemToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.redeemConfirm.prepareForProgress(viewLifecycleOwner) + binder.redeemConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.redeemExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + } + + override fun inject() { + val payload = argument(PAYLOAD_KEY) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .redeemFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: RedeemViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel, binder.redeemExtrinsicInformation.fee) + + viewModel.showNextProgress.observe(binder.redeemConfirm::setProgressState) + + viewModel.amountModelFlow.observe(binder.redeemAmount::setAmount) + + viewModel.walletUiFlow.observe(binder.redeemExtrinsicInformation::setWallet) + viewModel.feeLiveData.observe(binder.redeemExtrinsicInformation::setFeeStatus) + viewModel.originAddressModelFlow.observe(binder.redeemExtrinsicInformation::setAccount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/RedeemPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/RedeemPayload.kt new file mode 100644 index 0000000..bd8f73e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/RedeemPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class RedeemPayload() : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/RedeemViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/RedeemViewModel.kt new file mode 100644 index 0000000..cc65bf3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/RedeemViewModel.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem.RedeemValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem.RedeemValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class RedeemViewModel( + private val router: StakingRouter, + private val interactor: StakingInteractor, + private val redeemInteractor: RedeemInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: RedeemValidationSystem, + private val iconGenerator: AddressIconGenerator, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + walletUiUseCase: WalletUiUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Validatable by validationExecutor, + FeeLoaderMixin by feeLoaderMixin, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + private val accountStakingFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .shareInBackground() + + private val assetFlow = accountStakingFlow + .flatMapLatest { interactor.assetFlow(it.controllerAddress) } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val amountModelFlow = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(asset.redeemable, asset) + } + .shareInBackground() + + val originAddressModelFlow = accountStakingFlow.map { + iconGenerator.createAccountAddressModel(selectedAssetState.chain(), it.controllerAddress) + } + .shareInBackground() + + init { + loadFee() + } + + fun confirmClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() { + launch { + val address = originAddressModelFlow.first().address + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + } + + private fun loadFee() { + feeLoaderMixin.loadFee( + coroutineScope = viewModelScope, + feeConstructor = { redeemInteractor.estimateFee(accountStakingFlow.first()) }, + onRetryCancelled = ::backClicked + ) + } + + private fun maybeGoToNext() = launch { + _showNextProgress.value = true + + val asset = assetFlow.first() + + val validationPayload = RedeemValidationPayload( + fee = feeLoaderMixin.awaitFee(), + asset = asset, + chain = accountStakingFlow.first().chain + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = validationPayload, + validationFailureTransformer = { redeemValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction(it) + } + } + + private fun sendTransaction(redeemValidationPayload: RedeemValidationPayload) = launch { + redeemInteractor.redeem(accountStakingFlow.first(), redeemValidationPayload.asset) + .onSuccess { (submissionResult, redeemConsequences) -> + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(submissionResult.submissionHierarchy) { router.finishRedeemFlow(redeemConsequences) } + } + .onFailure(::showError) + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/ValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/ValidationFailure.kt new file mode 100644 index 0000000..fd1bd85 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/ValidationFailure.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem.RedeemValidationFailure +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun redeemValidationFailure( + reason: RedeemValidationFailure, + resourceManager: ResourceManager +): TitleAndMessage { + return when (reason) { + RedeemValidationFailure.CannotPayFees -> { + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.common_not_enough_funds_message) + } + + is RedeemValidationFailure.NotEnoughBalanceToStayAboveED -> handleInsufficientBalanceCommission( + reason, + resourceManager + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/di/RedeemComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/di/RedeemComponent.kt new file mode 100644 index 0000000..b67ebe2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/di/RedeemComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem.RedeemFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem.RedeemPayload + +@Subcomponent( + modules = [ + RedeemModule::class + ] +) +@ScreenScope +interface RedeemComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: RedeemPayload + ): RedeemComponent + } + + fun inject(fragment: RedeemFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/di/RedeemModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/di/RedeemModule.kt new file mode 100644 index 0000000..6553874 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/redeem/di/RedeemModule.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.redeem.RedeemInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.reedeem.RedeemValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.redeem.RedeemViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class RedeemModule { + + @Provides + @IntoMap + @ViewModelKey(RedeemViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + redeemInteractor: RedeemInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: RedeemValidationSystem, + iconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + feeLoaderMixin: FeeLoaderMixin.Presentation, + singleAssetSharedState: StakingSharedState, + walletUiUseCase: WalletUiUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return RedeemViewModel( + router = router, + interactor = interactor, + redeemInteractor = redeemInteractor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + iconGenerator = iconGenerator, + feeLoaderMixin = feeLoaderMixin, + externalActions = externalActions, + selectedAssetState = singleAssetSharedState, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): RedeemViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(RedeemViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/ConfirmRewardDestinationFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/ConfirmRewardDestinationFragment.kt new file mode 100644 index 0000000..c58618b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/ConfirmRewardDestinationFragment.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmRewardDestinationBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel.ConfirmRewardDestinationPayload + +private const val KEY_PAYLOAD = "KEY_PAYLOAD" + +class ConfirmRewardDestinationFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: ConfirmRewardDestinationPayload) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentConfirmRewardDestinationBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmRewardDestinationToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.confirmRewardDestinationExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.confirmRewardDestinationConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmRewardDestinationConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.confirmRewardDestinationRewardDestination.setPayoutAccountClickListener { viewModel.payoutAccountClicked() } + } + + override fun inject() { + val payload = argument(KEY_PAYLOAD) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmRewardDestinationFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmRewardDestinationViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + + viewModel.showNextProgress.observe(binder.confirmRewardDestinationConfirm::setProgressState) + + viewModel.rewardDestinationFlow.observe(binder.confirmRewardDestinationRewardDestination::showRewardDestination) + + viewModel.walletUiFlow.observe(binder.confirmRewardDestinationExtrinsicInformation::setWallet) + viewModel.feeStatusFlow.observe(binder.confirmRewardDestinationExtrinsicInformation::setFeeStatus) + viewModel.originAccountModelFlow.observe(binder.confirmRewardDestinationExtrinsicInformation::setAccount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/ConfirmRewardDestinationViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/ConfirmRewardDestinationViewModel.kt new file mode 100644 index 0000000..6f3aec0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/ConfirmRewardDestinationViewModel.kt @@ -0,0 +1,171 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.mappers.mapRewardDestinationModelToRewardDestination +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.rewardDestination.ChangeRewardDestinationInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination.RewardDestinationModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel.ConfirmRewardDestinationPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel.RewardDestinationParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.select.rewardDestinationValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapFeeToFeeModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmRewardDestinationViewModel( + private val router: StakingRouter, + private val interactor: StakingInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val validationSystem: RewardDestinationValidationSystem, + private val rewardDestinationInteractor: ChangeRewardDestinationInteractor, + private val externalActions: ExternalActions.Presentation, + private val validationExecutor: ValidationExecutor, + private val payload: ConfirmRewardDestinationPayload, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter, + walletUiUseCase: WalletUiUseCase, +) : BaseViewModel(), + Validatable by validationExecutor, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val decimalFee = mapFeeFromParcel(payload.fee) + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + private val stashFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .shareInBackground() + + private val controllerAssetFlow = stashFlow + .flatMapLatest { interactor.assetFlow(it.controllerAddress) } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val originAccountModelFlow = stashFlow.map { + addressIconGenerator.createAccountAddressModel(it.chain, it.controllerAddress) + } + .shareInBackground() + + val rewardDestinationFlow = flowOf { + mapRewardDestinationParcelModelToRewardDestinationModel(payload.rewardDestination) + } + .shareInBackground() + + val feeStatusFlow = controllerAssetFlow.map { + FeeStatus.Loaded(mapFeeToFeeModel(decimalFee, it.token, amountFormatter = amountFormatter)) + } + .shareInBackground() + + fun confirmClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val originAddress = originAccountModelFlow.first().address + + showAddressExternalActions(originAddress) + } + + fun payoutAccountClicked() = launch { + val payoutDestination = rewardDestinationFlow.first() as? RewardDestinationModel.Payout ?: return@launch + + showAddressExternalActions(payoutDestination.destination.address) + } + + private suspend fun showAddressExternalActions(address: String) { + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + private suspend fun mapRewardDestinationParcelModelToRewardDestinationModel( + rewardDestinationParcelModel: RewardDestinationParcelModel, + ): RewardDestinationModel { + return when (rewardDestinationParcelModel) { + is RewardDestinationParcelModel.Restake -> RewardDestinationModel.Restake + is RewardDestinationParcelModel.Payout -> { + val address = rewardDestinationParcelModel.targetAccountAddress + val addressModel = addressIconGenerator.createAccountAddressModel(selectedAssetState.chain(), address) + + RewardDestinationModel.Payout(addressModel) + } + } + } + + private fun sendTransactionIfValid() = launch { + val rewardDestinationModel = rewardDestinationFlow.first() + + val controllerAsset = controllerAssetFlow.first() + val stashState = stashFlow.first() + + val payload = RewardDestinationValidationPayload( + availableControllerBalance = controllerAsset.transferable, + fee = decimalFee, + stashState = stashState, + asset = controllerAssetFlow.first() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { rewardDestinationValidationFailure(resourceManager, it) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction(stashState, mapRewardDestinationModelToRewardDestination(rewardDestinationModel)) + } + } + + private fun sendTransaction( + stashState: StakingState.Stash, + rewardDestination: RewardDestination, + ) = launch { + rewardDestinationInteractor.changeRewardDestination(stashState, rewardDestination) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + .onFailure { + showError(it) + } + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/di/ConfirmRewardDestinationComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/di/ConfirmRewardDestinationComponent.kt new file mode 100644 index 0000000..9c6a9e9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/di/ConfirmRewardDestinationComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.ConfirmRewardDestinationFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel.ConfirmRewardDestinationPayload + +@Subcomponent( + modules = [ + ConfirmRewardDestinationModule::class + ] +) +@ScreenScope +interface ConfirmRewardDestinationComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmRewardDestinationPayload + ): ConfirmRewardDestinationComponent + } + + fun inject(fragment: ConfirmRewardDestinationFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/di/ConfirmRewardDestinationModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/di/ConfirmRewardDestinationModule.kt new file mode 100644 index 0000000..389409d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/di/ConfirmRewardDestinationModule.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.rewardDestination.ChangeRewardDestinationInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.ConfirmRewardDestinationViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel.ConfirmRewardDestinationPayload +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmRewardDestinationModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmRewardDestinationViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + validationSystem: RewardDestinationValidationSystem, + validationExecutor: ValidationExecutor, + rewardDestinationInteractor: ChangeRewardDestinationInteractor, + externalActions: ExternalActions.Presentation, + payload: ConfirmRewardDestinationPayload, + singleAssetSharedState: StakingSharedState, + walletUiUseCase: WalletUiUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmRewardDestinationViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + validationSystem = validationSystem, + rewardDestinationInteractor = rewardDestinationInteractor, + externalActions = externalActions, + validationExecutor = validationExecutor, + payload = payload, + selectedAssetState = singleAssetSharedState, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmRewardDestinationViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmRewardDestinationViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/parcel/ConfirmRewardDestinationPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/parcel/ConfirmRewardDestinationPayload.kt new file mode 100644 index 0000000..60d0397 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/parcel/ConfirmRewardDestinationPayload.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize + +@Parcelize +class ConfirmRewardDestinationPayload( + val fee: FeeParcelModel, + val rewardDestination: RewardDestinationParcelModel, +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/parcel/RewardDestinationParcelModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/parcel/RewardDestinationParcelModel.kt new file mode 100644 index 0000000..baa3896 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/confirm/parcel/RewardDestinationParcelModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class RewardDestinationParcelModel : Parcelable { + + @Parcelize + object Restake : RewardDestinationParcelModel() + + @Parcelize + class Payout(val targetAccountAddress: String) : RewardDestinationParcelModel() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/SelectRewardDestinationFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/SelectRewardDestinationFragment.kt new file mode 100644 index 0000000..3d41b75 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/SelectRewardDestinationFragment.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.select + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentSelectRewardDestinationBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination.observeRewardDestinationChooser + +class SelectRewardDestinationFragment : BaseFragment() { + + override fun createBinding() = FragmentSelectRewardDestinationBinding.inflate(layoutInflater) + + override fun initViews() { + binder.selectRewardDestinationToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.selectRewardDestinationContinue.prepareForProgress(viewLifecycleOwner) + binder.selectRewardDestinationContinue.setOnClickListener { viewModel.nextClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .selectRewardDestinationFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SelectRewardDestinationViewModel) { + observeRetries(viewModel) + observeValidations(viewModel) + observeBrowserEvents(viewModel) + observeRewardDestinationChooser(viewModel, binder.selectRewardDestinationChooser) + + viewModel.showNextProgress.observe(binder.selectRewardDestinationContinue::setProgressState) + + viewModel.feeLiveData.observe(binder.selectRewardDestinationFee::setFeeStatus) + + viewModel.continueAvailable.observe { + val state = if (it) ButtonState.NORMAL else ButtonState.DISABLED + + binder.selectRewardDestinationContinue.setState(state) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/SelectRewardDestinationViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/SelectRewardDestinationViewModel.kt new file mode 100644 index 0000000..a779f62 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/SelectRewardDestinationViewModel.kt @@ -0,0 +1,157 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.select + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_staking_api.domain.model.RewardDestination +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.data.mappers.mapRewardDestinationModelToRewardDestination +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.staking.rewardDestination.ChangeRewardDestinationInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination.RewardDestinationMixin +import io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination.RewardDestinationModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel.ConfirmRewardDestinationPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.confirm.parcel.RewardDestinationParcelModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.runtime.state.selectedOption +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class SelectRewardDestinationViewModel( + private val router: StakingRouter, + private val interactor: StakingInteractor, + private val resourceManager: ResourceManager, + private val changeRewardDestinationInteractor: ChangeRewardDestinationInteractor, + private val validationSystem: RewardDestinationValidationSystem, + private val validationExecutor: ValidationExecutor, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val rewardDestinationMixin: RewardDestinationMixin.Presentation, + private val selectedAssetSharedState: StakingSharedState, + private val stakingSharedComputation: StakingSharedComputation, +) : BaseViewModel(), + Retriable, + Validatable by validationExecutor, + FeeLoaderMixin by feeLoaderMixin, + RewardDestinationMixin by rewardDestinationMixin { + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + private val rewardCalculator = viewModelScope.async { + stakingSharedComputation.rewardCalculator( + stakingOption = selectedAssetSharedState.selectedOption(), + scope = viewModelScope + ) + } + + private val rewardDestinationFlow = rewardDestinationMixin.rewardDestinationModelFlow + .map { mapRewardDestinationModelToRewardDestination(it) } + .inBackground() + .share() + + private val stashStateFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .inBackground() + .share() + + private val controllerAssetFlow = stashStateFlow + .flatMapLatest { interactor.assetFlow(it.controllerAddress) } + .inBackground() + .share() + + val continueAvailable = rewardDestinationMixin.rewardDestinationChangedFlow + .asLiveData() + + init { + rewardDestinationFlow.combine(stashStateFlow) { rewardDestination, stashState -> + loadFee(rewardDestination, stashState) + }.launchIn(viewModelScope) + + controllerAssetFlow.onEach { + rewardDestinationMixin.updateReturns(rewardCalculator(), it, it.bonded) + }.launchIn(viewModelScope) + + stashStateFlow.onEach(rewardDestinationMixin::loadActiveRewardDestination) + .launchIn(viewModelScope) + } + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + private fun loadFee(rewardDestination: RewardDestination, stashState: StakingState.Stash) { + feeLoaderMixin.loadFee( + coroutineScope = viewModelScope, + feeConstructor = { changeRewardDestinationInteractor.estimateFee(stashState, rewardDestination) }, + onRetryCancelled = ::backClicked + ) + } + + private fun maybeGoToNext() = launch { + _showNextProgress.value = true + + val payload = RewardDestinationValidationPayload( + availableControllerBalance = controllerAssetFlow.first().transferable, + fee = feeLoaderMixin.awaitFee(), + stashState = stashStateFlow.first(), + asset = controllerAssetFlow.first() + ) + + val rewardDestination = rewardDestinationModelFlow.first() + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { rewardDestinationValidationFailure(resourceManager, it) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + _showNextProgress.value = false + + goToNextStep(rewardDestination, it.fee) + } + } + + private fun goToNextStep(rewardDestination: RewardDestinationModel, fee: Fee) { + val payload = ConfirmRewardDestinationPayload( + fee = mapFeeToParcel(fee), + rewardDestination = mapRewardDestinationModelToRewardDestinationParcelModel(rewardDestination) + ) + + router.openConfirmRewardDestination(payload) + } + + private fun mapRewardDestinationModelToRewardDestinationParcelModel(rewardDestination: RewardDestinationModel): RewardDestinationParcelModel { + return when (rewardDestination) { + RewardDestinationModel.Restake -> RewardDestinationParcelModel.Restake + is RewardDestinationModel.Payout -> RewardDestinationParcelModel.Payout(rewardDestination.destination.address) + } + } + + private suspend fun rewardCalculator() = rewardCalculator.await() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/ValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/ValidationFailure.kt new file mode 100644 index 0000000..03ddea3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/ValidationFailure.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.select + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationFailure +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun rewardDestinationValidationFailure( + resourceManager: ResourceManager, + failure: RewardDestinationValidationFailure +): TitleAndMessage = when (failure) { + is RewardDestinationValidationFailure.MissingController -> { + resourceManager.getString(R.string.common_error_general_title) to + resourceManager.getString(R.string.staking_add_controller, failure.controllerAddress) + } + + RewardDestinationValidationFailure.CannotPayFees -> { + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.common_not_enough_funds_message) + } + + is RewardDestinationValidationFailure.NotEnoughBalanceToStayAboveED -> handleInsufficientBalanceCommission( + failure, + resourceManager + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/di/SelectRewardDestinationComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/di/SelectRewardDestinationComponent.kt new file mode 100644 index 0000000..10edee2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/di/SelectRewardDestinationComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.select.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.select.SelectRewardDestinationFragment + +@Subcomponent( + modules = [ + SelectRewardDestinationModule::class + ] +) +@ScreenScope +interface SelectRewardDestinationComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): SelectRewardDestinationComponent + } + + fun inject(fragment: SelectRewardDestinationFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/di/SelectRewardDestinationModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/di/SelectRewardDestinationModule.kt new file mode 100644 index 0000000..dac18c4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/rewardDestination/select/di/SelectRewardDestinationModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.select.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation +import io.novafoundation.nova.feature_staking_impl.domain.staking.rewardDestination.ChangeRewardDestinationInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.rewardDestination.RewardDestinationValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination.RewardDestinationMixin +import io.novafoundation.nova.feature_staking_impl.presentation.staking.rewardDestination.select.SelectRewardDestinationViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin + +@Module(includes = [ViewModelModule::class]) +class SelectRewardDestinationModule { + + @Provides + @IntoMap + @ViewModelKey(SelectRewardDestinationViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + resourceManager: ResourceManager, + changeRewardDestinationInteractor: ChangeRewardDestinationInteractor, + validationSystem: RewardDestinationValidationSystem, + validationExecutor: ValidationExecutor, + rewardDestinationMixin: RewardDestinationMixin.Presentation, + feeLoaderMixin: FeeLoaderMixin.Presentation, + selectedAssetSharedState: StakingSharedState, + stakingSharedComputation: StakingSharedComputation, + ): ViewModel { + return SelectRewardDestinationViewModel( + router, + interactor, + resourceManager, + changeRewardDestinationInteractor, + validationSystem, + validationExecutor, + feeLoaderMixin, + rewardDestinationMixin, + selectedAssetSharedState, + stakingSharedComputation + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): SelectRewardDestinationViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectRewardDestinationViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/AvailableStakingOptionsPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/AvailableStakingOptionsPayload.kt new file mode 100644 index 0000000..8eecbaa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/AvailableStakingOptionsPayload.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_api.domain.dashboard.model.MultiStakingOptionIds +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.parcelize.Parcelize + +@Parcelize +class AvailableStakingOptionsPayload( + val chainId: ChainId, + val assetId: Int, + val stakingTypes: List +) : Parcelable + +fun AvailableStakingOptionsPayload.toStakingOptionIds(): MultiStakingOptionIds { + return MultiStakingOptionIds( + chainId = chainId, + chainAssetId = assetId, + stakingTypes = stakingTypes + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/MultiStakingTargetSelectionFormatter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/MultiStakingTargetSelectionFormatter.kt new file mode 100644 index 0000000..2fd9cbd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/MultiStakingTargetSelectionFormatter.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.presentation.ColoredText +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.RecommendableMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.isRecommended +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.direct.DirectStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools.NominationPoolSelection +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.view.stakingTarget.StakingTargetModel + +interface MultiStakingTargetSelectionFormatter { + + suspend fun formatForSetupAmount(recommendableSelection: RecommendableMultiStakingSelection): StakingTargetModel + + suspend fun formatForStakingType(recommendableSelection: RecommendableMultiStakingSelection): StakingTargetModel +} + +class RealMultiStakingTargetSelectionFormatter( + private val resourceManager: ResourceManager, + private val poolDisplayFormatter: PoolDisplayFormatter, +) : MultiStakingTargetSelectionFormatter { + + override suspend fun formatForSetupAmount( + recommendableSelection: RecommendableMultiStakingSelection, + ): StakingTargetModel { + return when (val selection = recommendableSelection.selection) { + is DirectStakingSelection -> StakingTargetModel( + title = resourceManager.getString(R.string.setup_staking_type_direct_staking), + subtitle = formatValidatorsSubtitle(R.string.start_staking_selection_validators_subtitle, recommendableSelection, selection), + icon = null + ) + + is NominationPoolSelection -> StakingTargetModel( + title = resourceManager.getString(R.string.setup_staking_type_pool_staking), + subtitle = formatSubtitle(recommendableSelection) { + poolDisplayFormatter.formatTitle(selection.pool, selection.stakingOption.chain) + }, + icon = null + ) + + else -> notYetImplemented(selection) + } + } + + override suspend fun formatForStakingType( + recommendableSelection: RecommendableMultiStakingSelection, + ): StakingTargetModel { + return when (val selection = recommendableSelection.selection) { + is DirectStakingSelection -> StakingTargetModel( + title = resourceManager.getString(R.string.staking_recommended_title), + subtitle = formatValidatorsSubtitle(R.string.start_staking_editing_selection_validators_subtitle, recommendableSelection, selection), + icon = StakingTargetModel.TargetIcon.Quantity(selection.validators.size.format()) + ) + + is NominationPoolSelection -> { + val poolDisplay = poolDisplayFormatter.format(selection.pool, selection.stakingOption.chain) + + StakingTargetModel( + title = poolDisplay.title, + subtitle = recommendedSubtitle(recommendableSelection), + icon = poolDisplay.icon.asStakeTargetIcon() + ) + } + + else -> notYetImplemented(selection) + } + } + + private fun formatValidatorsSubtitle( + @StringRes resId: Int, + recommendableSelection: RecommendableMultiStakingSelection, + selection: DirectStakingSelection + ) = formatSubtitle(recommendableSelection) { + resourceManager.getString(resId, selection.validators.size, selection.validatorsLimit) + } + + private fun recommendedSubtitle(selection: RecommendableMultiStakingSelection) = formatSubtitle(selection, notRecommendedText = { null }) + + private fun formatSubtitle(selection: RecommendableMultiStakingSelection, notRecommendedText: () -> String?): ColoredText? { + return if (selection.source.isRecommended) { + ColoredText( + text = resourceManager.getString(R.string.common_recommended), + colorRes = R.color.text_positive + ) + } else { + notRecommendedText()?.let { + ColoredText( + text = it, + colorRes = R.color.text_secondary + ) + } + } + } + + private fun notYetImplemented(selection: StartMultiStakingSelection): Nothing { + error("Not yet implemented: ${selection::class.simpleName}") + } + + private fun Icon?.asStakeTargetIcon(): StakingTargetModel.TargetIcon? = this?.let(StakingTargetModel.TargetIcon::Icon) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/StakingTypeFormatter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/StakingTypeFormatter.kt new file mode 100644 index 0000000..73f81c5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/StakingTypeFormatter.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun ResourceManager.formatStakingTypeLabel(stakingType: Chain.Asset.StakingType): String { + return when (stakingType.group()) { + StakingTypeGroup.RELAYCHAIN -> getString(R.string.setup_staking_type_direct_staking) + StakingTypeGroup.NOMINATION_POOL -> getString(R.string.setup_staking_type_pool_staking) + StakingTypeGroup.UNSUPPORTED, StakingTypeGroup.PARACHAIN, StakingTypeGroup.MYTHOS -> error("Not yet available") + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/di/CommonMultiStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/di/CommonMultiStakingModule.kt new file mode 100644 index 0000000..7586d17 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/common/di/CommonMultiStakingModule.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.RealStartMultiStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StartMultiStakingInteractor + +@Module +class CommonMultiStakingModule { + + @Provides + @ScreenScope + fun provideInteractor( + extrinsicService: ExtrinsicService, + accountRepository: AccountRepository, + ): StartMultiStakingInteractor = RealStartMultiStakingInteractor(extrinsicService, accountRepository) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/ConfirmMultiStakingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/ConfirmMultiStakingFragment.kt new file mode 100644 index 0000000..6c5afa6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/ConfirmMultiStakingFragment.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm + +import android.os.Bundle +import android.text.TextUtils.TruncateAt + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentStartMultiStakingConfirmBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent + +private const val PAYLOAD_KEY = "ConfirmMultiStakingFragment.PAYLOAD_KEY" + +class ConfirmMultiStakingFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: ConfirmMultiStakingPayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentStartMultiStakingConfirmBinding.inflate(layoutInflater) + + override fun initViews() { + binder.startMultiStakingConfirmExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.startMultiStakingConfirmToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.startMultiStakingConfirmConfirm.prepareForProgress(viewLifecycleOwner) + binder.startMultiStakingConfirmConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.startMultiStakingConfirmStakingTypeDetails.setOnClickListener { viewModel.onStakingTypeDetailsClicked() } + binder.startMultiStakingConfirmStakingTypeDetails.valuePrimary.ellipsize = TruncateAt.END + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmMultiStakingComponentFactory() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmMultiStakingViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + + viewModel.showNextProgress.observe(binder.startMultiStakingConfirmConfirm::setProgressState) + + viewModel.amountModelFlow.observe(binder.startMultiStakingConfirmAmount::setAmount) + + viewModel.feeStatusFlow.observe(binder.startMultiStakingConfirmExtrinsicInformation::setFeeStatus) + viewModel.walletUiFlow.observe(binder.startMultiStakingConfirmExtrinsicInformation::setWallet) + viewModel.originAddressModelFlow.observe(binder.startMultiStakingConfirmExtrinsicInformation::setAccount) + + viewModel.stakingTypeModel.observe { model -> + binder.startMultiStakingConfirmStakingType.showValue(model.stakingTypeValue) + + with(model.stakingTypeDetails) { + binder.startMultiStakingConfirmStakingTypeDetails.setTitle(label) + binder.startMultiStakingConfirmStakingTypeDetails.showValue(value) + binder.startMultiStakingConfirmStakingTypeDetails.loadImage(icon) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/ConfirmMultiStakingPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/ConfirmMultiStakingPayload.kt new file mode 100644 index 0000000..9742c18 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/ConfirmMultiStakingPayload.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.AvailableStakingOptionsPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize + +@Parcelize +class ConfirmMultiStakingPayload(val fee: FeeParcelModel, val availableStakingOptions: AvailableStakingOptionsPayload) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/ConfirmMultiStakingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/ConfirmMultiStakingViewModel.kt new file mode 100644 index 0000000..2fd14a5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/ConfirmMultiStakingViewModel.kt @@ -0,0 +1,189 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.data.components +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StartMultiStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.activateDetection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.pauseDetection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.copyWith +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.currentSelectionFlow +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.handleStartMultiStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.selectionType.MultiStakingSelectionTypeProviderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.toStakingOptionIds +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.types.ConfirmMultiStakingTypeFactory +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapFeeToFeeModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.math.BigDecimal + +class ConfirmMultiStakingViewModel( + private val router: StartMultiStakingRouter, + private val interactor: StartMultiStakingInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val externalActions: ExternalActions.Presentation, + private val confirmMultiStakingTypeFactory: ConfirmMultiStakingTypeFactory, + private val selectionStoreProvider: StartMultiStakingSelectionStoreProvider, + payload: ConfirmMultiStakingPayload, + selectionTypeProviderFactory: MultiStakingSelectionTypeProviderFactory, + assetUseCase: ArbitraryAssetUseCase, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + private val stakingStartedDetectionService: StakingStartedDetectionService, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val decimalFee = mapFeeFromParcel(payload.fee) + + private val _showNextProgress = MutableStateFlow(false) + val showNextProgress: Flow = _showNextProgress + + private val currentSelectionFlow = selectionStoreProvider.currentSelectionFlow(viewModelScope) + .filterNotNull() + .shareInBackground() + + private val stakingTypeContext = ConfirmMultiStakingTypeFactory.Context( + externalActions = externalActions, + scope = viewModelScope + ) + + private val confirmMultiStakingTypeFlow = currentSelectionFlow.map { currentSelection -> + confirmMultiStakingTypeFactory.constructConfirmMultiStakingType( + selection = currentSelection.selection, + parentContext = stakingTypeContext + ) + }.shareInBackground() + + private val multiStakingSelectionTypeFlow = selectionTypeProviderFactory.create(viewModelScope, payload.availableStakingOptions.toStakingOptionIds()) + .multiStakingSelectionTypeFlow() + .shareInBackground() + + val stakingTypeModel = confirmMultiStakingTypeFlow.flatMapLatest { it.stakingTypeModel } + .shareInBackground() + + private val assetFlow = flowOfAll { + val (chain, chainAsset) = currentSelectionFlow.first().selection.stakingOption.components + + assetUseCase.assetFlow(chain.id, chainAsset.id) + } + .shareInBackground() + + val amountModelFlow = combine(currentSelectionFlow, assetFlow) { currentSelection, asset -> + amountFormatter.formatAmountToAmountModel(currentSelection.selection.stake, asset) + } + .shareInBackground() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val feeStatusFlow = assetFlow.map { asset -> + val feeModel = mapFeeToFeeModel(decimalFee, asset.token, amountFormatter = amountFormatter) + + FeeStatus.Loaded(feeModel) + } + .shareInBackground() + + val originAddressModelFlow = selectedAccountUseCase.selectedAddressModelFlow { currentSelectionFlow.first().selection.stakingOption.chain } + .shareInBackground() + + fun confirmClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = originAddressModelFlow.first().address + val chain = currentSelectionFlow.first().selection.stakingOption.chain + + externalActions.showAddressActions(address, chain) + } + + fun onStakingTypeDetailsClicked() = launch { + confirmMultiStakingTypeFlow.first().onStakingTypeDetailsClicked() + } + + private fun maybeGoToNext() = launch { + val recommendableSelection = currentSelectionFlow.first() + val validationSystem = multiStakingSelectionTypeFlow.first().validationSystem(recommendableSelection.selection) + + val payload = StartMultiStakingValidationPayload( + recommendableSelection = recommendableSelection, + asset = assetFlow.first(), + fee = decimalFee, + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { status, flowActions -> + handleStartMultiStakingValidationFailure(status, resourceManager, flowActions, ::updateAmount) + }, + progressConsumer = _showNextProgress.progressConsumer(), + block = ::sendTransaction + ) + } + + private fun sendTransaction(validationPayload: StartMultiStakingValidationPayload) = launch { + stakingStartedDetectionService.pauseDetection(viewModelScope) + + interactor.startStaking(validationPayload.selection) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { finishFlow() } + } + .onFailure { + showError(it) + + stakingStartedDetectionService.activateDetection(viewModelScope) + } + + _showNextProgress.value = false + } + + private fun finishFlow() { + router.returnToStakingDashboard() + } + + private fun updateAmount(newAmount: BigDecimal) = launch { + val currentSelection = currentSelectionFlow.first() + val newSelection = currentSelection.copyWith(newAmount) + + selectionStoreProvider.getSelectionStore(viewModelScope).updateSelection(newSelection) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/di/ConfirmMultiStakingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/di/ConfirmMultiStakingComponent.kt new file mode 100644 index 0000000..bd0351e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/di/ConfirmMultiStakingComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.ConfirmMultiStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.ConfirmMultiStakingPayload + +@Subcomponent( + modules = [ + ConfirmMultiStakingModule::class + ] +) +@ScreenScope +interface ConfirmMultiStakingComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance argument: ConfirmMultiStakingPayload + ): ConfirmMultiStakingComponent + } + + fun inject(fragment: ConfirmMultiStakingFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/di/ConfirmMultiStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/di/ConfirmMultiStakingModule.kt new file mode 100644 index 0000000..8a9eff7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/di/ConfirmMultiStakingModule.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.di.staking.startMultiStaking.MultiStakingSelectionStoreProviderKey +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StartMultiStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.selectionType.MultiStakingSelectionTypeProviderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.di.CommonMultiStakingModule +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.ConfirmMultiStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.ConfirmMultiStakingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.types.ConfirmMultiStakingTypeFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.types.RealConfirmMultiStakingTypeFactory +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, CommonMultiStakingModule::class]) +class ConfirmMultiStakingModule { + + @Provides + @ScreenScope + fun provideConfirmMultiStakingTypeFactory( + router: StartMultiStakingRouter, + setupStakingSharedState: SetupStakingSharedState, + resourceManager: ResourceManager, + poolDisplayFormatter: PoolDisplayFormatter, + ): ConfirmMultiStakingTypeFactory { + return RealConfirmMultiStakingTypeFactory( + router = router, + setupStakingSharedState = setupStakingSharedState, + resourceManager = resourceManager, + poolDisplayFormatter = poolDisplayFormatter + ) + } + + @Provides + @IntoMap + @ViewModelKey(ConfirmMultiStakingViewModel::class) + fun provideViewModel( + router: StartMultiStakingRouter, + interactor: StartMultiStakingInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + externalActions: ExternalActions.Presentation, + payload: ConfirmMultiStakingPayload, + @MultiStakingSelectionStoreProviderKey selectionStoreProvider: StartMultiStakingSelectionStoreProvider, + confirmMultiStakingTypeFactory: ConfirmMultiStakingTypeFactory, + assetUseCase: ArbitraryAssetUseCase, + walletUiUseCase: WalletUiUseCase, + selectedAccountUseCase: SelectedAccountUseCase, + selectionTypeProviderFactory: MultiStakingSelectionTypeProviderFactory, + stakingStartedDetectionService: StakingStartedDetectionService, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmMultiStakingViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + externalActions = externalActions, + payload = payload, + selectionStoreProvider = selectionStoreProvider, + confirmMultiStakingTypeFactory = confirmMultiStakingTypeFactory, + assetUseCase = assetUseCase, + walletUiUseCase = walletUiUseCase, + selectedAccountUseCase = selectedAccountUseCase, + selectionTypeProviderFactory = selectionTypeProviderFactory, + stakingStartedDetectionService = stakingStartedDetectionService, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmMultiStakingViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmMultiStakingViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/model/ConfirmMultiStakingTypeModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/model/ConfirmMultiStakingTypeModel.kt new file mode 100644 index 0000000..b267410 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/model/ConfirmMultiStakingTypeModel.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.model + +import io.novafoundation.nova.common.utils.images.Icon + +class ConfirmMultiStakingTypeModel( + val stakingTypeValue: String, + val stakingTypeDetails: TypeDetails +) { + + class TypeDetails( + val label: String, + val value: String, + val icon: Icon?, + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/ConfirmMultiStakingType.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/ConfirmMultiStakingType.kt new file mode 100644 index 0000000..b356a05 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/ConfirmMultiStakingType.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.types + +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.model.ConfirmMultiStakingTypeModel +import kotlinx.coroutines.flow.Flow + +interface ConfirmMultiStakingType { + + val stakingTypeModel: Flow + + suspend fun onStakingTypeDetailsClicked() +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/ConfirmMultiStakingTypeFactory.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/ConfirmMultiStakingTypeFactory.kt new file mode 100644 index 0000000..f38a9ba --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/ConfirmMultiStakingTypeFactory.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.types + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.StartMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.direct.DirectStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools.NominationPoolSelection +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import kotlinx.coroutines.CoroutineScope + +interface ConfirmMultiStakingTypeFactory { + + class Context( + val externalActions: ExternalActions.Presentation, + val scope: CoroutineScope, + ) + + suspend fun constructConfirmMultiStakingType( + selection: StartMultiStakingSelection, + parentContext: Context + ): ConfirmMultiStakingType +} + +class RealConfirmMultiStakingTypeFactory( + private val router: StartMultiStakingRouter, + private val setupStakingSharedState: SetupStakingSharedState, + private val resourceManager: ResourceManager, + private val poolDisplayFormatter: PoolDisplayFormatter, +) : ConfirmMultiStakingTypeFactory { + + override suspend fun constructConfirmMultiStakingType( + selection: StartMultiStakingSelection, + parentContext: ConfirmMultiStakingTypeFactory.Context + ): ConfirmMultiStakingType { + return when (selection) { + is DirectStakingSelection -> createDirect(selection, parentContext) + is NominationPoolSelection -> createPool(selection, parentContext) + else -> error("Unknown staking type") + } + } + + private fun createDirect(selection: DirectStakingSelection, parentContext: ConfirmMultiStakingTypeFactory.Context): ConfirmMultiStakingType { + return DirectConfirmMultiStakingType( + selection = selection, + router = router, + setupStakingSharedState = setupStakingSharedState, + resourceManager = resourceManager, + parentContext = parentContext + ) + } + + private fun createPool(selection: NominationPoolSelection, parentContext: ConfirmMultiStakingTypeFactory.Context): ConfirmMultiStakingType { + return PoolsConfirmMultiStakingType( + selection = selection, + resourceManager = resourceManager, + poolDisplayFormatter = poolDisplayFormatter, + parentContext = parentContext + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/DirectConfirmMultiStakingType.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/DirectConfirmMultiStakingType.kt new file mode 100644 index 0000000..fb94ce9 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/DirectConfirmMultiStakingType.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.types + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.invokeOnCompletion +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.direct.DirectStakingSelection +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess.ReadyToSubmit +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.model.ConfirmMultiStakingTypeModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.model.ConfirmMultiStakingTypeModel.TypeDetails +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +class DirectConfirmMultiStakingType( + private val selection: DirectStakingSelection, + private val router: StartMultiStakingRouter, + private val setupStakingSharedState: SetupStakingSharedState, + private val resourceManager: ResourceManager, + private val parentContext: ConfirmMultiStakingTypeFactory.Context, +) : ConfirmMultiStakingType, CoroutineScope by parentContext.scope { + + init { + clearStateOnCompletion() + } + + override val stakingTypeModel: Flow = flowOf { + constructUiModel() + } + + override suspend fun onStakingTypeDetailsClicked() { + // act as an adapter between new flow and legacy logic + val reviewValidatorsState = ReadyToSubmit( + newValidators = selection.validators, + selectionMethod = ReadyToSubmit.SelectionMethod.RECOMMENDED, + activeStake = selection.stake, + currentlySelectedValidators = emptyList() + ) + setupStakingSharedState.set(reviewValidatorsState) + + router.openSelectedValidators() + } + + private fun clearStateOnCompletion() { + invokeOnCompletion { + setupStakingSharedState.set(SetupStakingProcess.Initial) + } + } + + private fun constructUiModel(): ConfirmMultiStakingTypeModel { + return ConfirmMultiStakingTypeModel( + stakingTypeValue = resourceManager.getString(R.string.setup_staking_type_direct_staking), + stakingTypeDetails = TypeDetails( + label = resourceManager.getString(R.string.staking_recommended_title), + value = resourceManager.getString(R.string.staking_max_format, selection.validators.size, selection.validatorsLimit), + icon = null + ) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/PoolsConfirmMultiStakingType.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/PoolsConfirmMultiStakingType.kt new file mode 100644 index 0000000..b9dd8ee --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/confirm/types/PoolsConfirmMultiStakingType.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.types + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.chain +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.pools.NominationPoolSelection +import io.novafoundation.nova.feature_staking_impl.presentation.nominationPools.common.PoolDisplayFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.model.ConfirmMultiStakingTypeModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.model.ConfirmMultiStakingTypeModel.TypeDetails +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +class PoolsConfirmMultiStakingType( + private val selection: NominationPoolSelection, + private val resourceManager: ResourceManager, + private val poolDisplayFormatter: PoolDisplayFormatter, + private val parentContext: ConfirmMultiStakingTypeFactory.Context, +) : ConfirmMultiStakingType, CoroutineScope by parentContext.scope { + + override val stakingTypeModel: Flow = flowOf { + constructUiModel() + } + + override suspend fun onStakingTypeDetailsClicked() { + parentContext.externalActions.showAddressActions( + accountId = selection.pool.stashAccountId, + chain = selection.stakingOption.chain + ) + } + + private suspend fun constructUiModel(): ConfirmMultiStakingTypeModel { + val poolDisplay = poolDisplayFormatter.format(selection.pool, selection.stakingOption.chain) + + return ConfirmMultiStakingTypeModel( + stakingTypeValue = resourceManager.getString(R.string.setup_staking_type_pool_staking), + stakingTypeDetails = TypeDetails( + label = resourceManager.getString(R.string.nomination_pools_pool), + value = poolDisplay.title, + icon = poolDisplay.icon + ) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingAdapter.kt new file mode 100644 index 0000000..6f1c92f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingAdapter.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_staking_impl.databinding.ItemStartStakingLandingConditionBinding +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StakingConditionRVItem + +class StartStakingLandingAdapter : BaseListAdapter(StakingConditionDiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StakingConditionViewHolder { + return StakingConditionViewHolder(ItemStartStakingLandingConditionBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: StakingConditionViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +class StakingConditionDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: StakingConditionRVItem, newItem: StakingConditionRVItem): Boolean { + return oldItem.iconId == newItem.iconId + } + + override fun areContentsTheSame(oldItem: StakingConditionRVItem, newItem: StakingConditionRVItem): Boolean { + return oldItem == newItem + } +} + +class StakingConditionViewHolder(private val binder: ItemStartStakingLandingConditionBinding) : BaseViewHolder(binder.root) { + + fun bind(item: StakingConditionRVItem) { + with(binder) { + itemStakingConditionIcon.setImageResource(item.iconId) + itemStakingConditionText.text = item.text + } + } + + override fun unbind() { + with(binder) { + itemStakingConditionIcon.setImageDrawable(null) + itemStakingConditionText.text = null + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingFooterAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingFooterAdapter.kt new file mode 100644 index 0000000..960a993 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingFooterAdapter.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing + +import android.text.method.LinkMovementMethod +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.SingleItemAdapter +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.clickableSpan +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.drawableSpan +import io.novafoundation.nova.common.utils.fontSpan +import io.novafoundation.nova.common.utils.inflateChild +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setEndSpan +import io.novafoundation.nova.common.utils.setFullSpan +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ItemStartStakingLandingFooterBinding + +class StartStakingLandingFooterAdapter(private val handler: ClickHandler) : SingleItemAdapter() { + + interface ClickHandler { + fun onTermsOfUseClicked() + } + + private var moreInfoText: CharSequence = "" + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StartStakingLandingFooterViewHolder { + parent.inflateChild(R.layout.item_start_staking_landing_footer) + return StartStakingLandingFooterViewHolder(handler, ItemStartStakingLandingFooterBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: StartStakingLandingFooterViewHolder, position: Int) { + holder.bind(moreInfoText) + } + + fun setMoreInformationText(text: CharSequence) { + this.moreInfoText = text + notifyItemChanged(0) + } +} + +class StartStakingLandingFooterViewHolder( + private val clickHandler: StartStakingLandingFooterAdapter.ClickHandler, + private val binder: ItemStartStakingLandingFooterBinding +) : RecyclerView.ViewHolder(binder.root) { + + init { + with(binder) { + val context = root.context + val resources = context.resources + val iconColor = context.getColor(R.color.chip_icon) + val clickablePartColor = context.getColor(R.color.link_text) + val chevronSize = 20.dp(context) + val chevronRight = ContextCompat.getDrawable(context, R.drawable.ic_chevron_right) + ?.apply { + setBounds(0, 0, chevronSize, chevronSize) + setTint(iconColor) + } + val termsClickablePart = resources.getText(R.string.start_staking_fragment_terms_of_use_clicable_part) + .toSpannable(colorSpan(clickablePartColor)) + .setFullSpan(clickableSpan { clickHandler.onTermsOfUseClicked() }) + .setFullSpan(fontSpan(context, R.font.public_sans_semi_bold)) + .setEndSpan(drawableSpan(chevronRight!!)) + + itemStakingLandingFooterTermsOfUse.text = SpannableFormatter.format( + resources.getString(R.string.start_staking_fragment_terms_of_use), + termsClickablePart + ) + + itemStakingLandingFooterTermsOfUse.movementMethod = LinkMovementMethod.getInstance() + itemStakingLandingFooterMoreInfo.movementMethod = LinkMovementMethod.getInstance() + } + } + + fun bind(title: CharSequence) { + with(binder) { + itemStakingLandingFooterMoreInfo.text = title + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingFragment.kt new file mode 100644 index 0000000..f58821b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingFragment.kt @@ -0,0 +1,115 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing + +import android.os.Bundle +import androidx.recyclerview.widget.ConcatAdapter + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.domain.isLoaded +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.list.CustomPlaceholderAdapter +import io.novafoundation.nova.common.mixin.actionAwaitable.awaitableActionFlow +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.dialog.dialog +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentStartStakingLandingBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StartStakingLandingPayload + +class StartStakingLandingFragment : + BaseFragment(), + StartStakingLandingFooterAdapter.ClickHandler { + + companion object { + private const val KEY_PAYLOAD = "payload" + + fun getBundle(payload: StartStakingLandingPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentStartStakingLandingBinding.inflate(layoutInflater) + + private val headerAdapter = StartStakingLandingHeaderAdapter() + private val conditionsAdapter = StartStakingLandingAdapter() + private val footerAdapter = StartStakingLandingFooterAdapter(this) + private val shimmeringAdapter = CustomPlaceholderAdapter(R.layout.item_start_staking_landing_shimmering) + private val adapter = ConcatAdapter(shimmeringAdapter, headerAdapter, conditionsAdapter, footerAdapter) + + override fun initViews() { + binder.startStakingLandingToolbar.setHomeButtonListener { viewModel.back() } + binder.startStakingLandingList.adapter = adapter + binder.startStakingLandingList.itemAnimator = null + + binder.startStakingLandingButton.prepareForProgress(viewLifecycleOwner) + binder.startStakingLandingButton.setOnClickListener { viewModel.continueClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .startStakingLandingComponentFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: StartStakingLandingViewModel) { + observeBrowserEvents(viewModel) + observeValidations(viewModel) + + viewModel.isContinueButtonLoading.observe(binder.startStakingLandingButton::setProgressState) + + viewModel.modelFlow.observe { + val isLoaded = it.isLoaded() + + headerAdapter.show(isLoaded) + footerAdapter.show(isLoaded) + shimmeringAdapter.show(it.isLoading()) + + when (it) { + is ExtendedLoadingState.Loaded -> { + headerAdapter.setTitle(it.data.title) + conditionsAdapter.submitList(it.data.conditions) + footerAdapter.setMoreInformationText(it.data.moreInfo) + binder.startStakingLandingButton.setButtonColor(it.data.buttonColor) + } + + is ExtendedLoadingState.Error -> { + dialog(providedContext) { + setTitle(providedContext.getString(io.novafoundation.nova.common.R.string.common_error_general_title)) + it.exception.message?.let { setMessage(it) } + setPositiveButton(io.novafoundation.nova.common.R.string.common_ok) { _, _ -> viewModel.back() } + } + } + + else -> {} + } + } + + viewModel.availableBalanceTextFlow.observe { + binder.startStakingLandingAvailableBalance.text = it + } + + viewModel.acknowledgeStakingStarted.awaitableActionFlow.observeWhenCreated { action -> + dialog(requireContext()) { + setTitle(action.payload) + + setPositiveButton(R.string.common_close) { _, _ -> + action.onSuccess(Unit) + } + } + } + } + + override fun onTermsOfUseClicked() { + viewModel.termsOfUseClicked() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingHeader.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingHeader.kt new file mode 100644 index 0000000..9c080aa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingHeader.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.list.SingleItemAdapter +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_staking_impl.databinding.ItemStartStakingLandingTitleBinding + +class StartStakingLandingHeaderAdapter : SingleItemAdapter() { + + private var title: CharSequence = "" + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StartStakingLandingHeaderViewHolder { + return StartStakingLandingHeaderViewHolder(ItemStartStakingLandingTitleBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: StartStakingLandingHeaderViewHolder, position: Int) { + holder.bind(title) + } + + fun setTitle(title: CharSequence) { + this.title = title + notifyItemChanged(0) + } +} + +class StartStakingLandingHeaderViewHolder(private val binder: ItemStartStakingLandingTitleBinding) : ViewHolder(binder.root) { + + fun bind(title: CharSequence) { + with(binder) { + itemStakingLandingTitle.text = title + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingViewModel.kt new file mode 100644 index 0000000..912d219 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/StartStakingLandingViewModel.kt @@ -0,0 +1,480 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing + +import android.graphics.Color +import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.domain.mapLoading +import io.novafoundation.nova.common.domain.onError +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.clickableSpan +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.drawableSpan +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.fontSpan +import io.novafoundation.nova.common.utils.formatAsSpannable +import io.novafoundation.nova.common.utils.formatting.baseDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.BoundedDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.DayAndHourDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.DayDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.DayDurationShortcut +import io.novafoundation.nova.common.utils.formatting.duration.DurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.HoursDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.MinutesDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.ShortcutDurationFormatter +import io.novafoundation.nova.common.utils.formatting.duration.wrapInto +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.formatting.formatPercents +import io.novafoundation.nova.common.utils.formatting.spannable.SpannableFormatter +import io.novafoundation.nova.common.utils.setEndSpan +import io.novafoundation.nova.common.utils.setFullSpan +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingLandingInfoUpdateSystemFactory +import io.novafoundation.nova.feature_staking_impl.domain.model.PayoutType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.awaitStakingStarted +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.ParticipationInGovernance +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.Payouts +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.StakingTypeDetailsCompoundInteractorFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.StartStakingCompoundData +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.validations.StartStakingLandingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.validations.handleStartStakingLandingValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.toStakingOptionIds +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StakingConditionRVItem +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StartStakingLandingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.SetupAmountMultiStakingPayload +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.math.BigInteger +import kotlin.time.Duration + +class StartStakingInfoModel( + val title: CharSequence, + val conditions: List, + val moreInfo: CharSequence, + val buttonColor: Int +) + +typealias AcknowledgeStakingStartedTitle = String + +class StartStakingLandingViewModel( + private val router: StartMultiStakingRouter, + private val resourceManager: ResourceManager, + private val updateSystemFactory: StakingLandingInfoUpdateSystemFactory, + private val stakingTypeDetailsCompoundInteractorFactory: StakingTypeDetailsCompoundInteractorFactory, + private val appLinksProvider: AppLinksProvider, + private val startStakingLandingPayload: StartStakingLandingPayload, + private val validationExecutor: ValidationExecutor, + private val selectedMetaAccountUseCase: SelectedAccountUseCase, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val stakingStartedDetectionService: StakingStartedDetectionService, + private val chainRegistry: ChainRegistry, + private val contextManager: ContextManager, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + Browserable, + Validatable by validationExecutor { + + private val durationFormatter: DurationFormatter = createBaseDurationFormatter() + + private val durationShortcutFormatter: DurationFormatter = createDurationShortcutFormatter() + + private val availableStakingOptionsPayload = startStakingLandingPayload.availableStakingOptions + private val stakingOptionIds = availableStakingOptionsPayload.toStakingOptionIds() + + private val startStakingInteractor = flowOf { + stakingTypeDetailsCompoundInteractorFactory.create( + multiStakingOptionIds = stakingOptionIds, + computationalScope = this + ) + }.shareInBackground() + + val acknowledgeStakingStarted = actionAwaitableMixinFactory.confirmingAction() + + private val startStakingInfo = startStakingInteractor.flatMapLatest { interactor -> + interactor.observeStartStakingInfo() + } + .withSafeLoading() + .shareInBackground() + + val modelFlow = startStakingInfo + .mapLoading { + val themeColor = getThemeColor(it.chain) + StartStakingInfoModel( + title = createTitle(it.asset.token.configuration, it.maxEarningRate, themeColor), + conditions = createConditions(it, themeColor), + moreInfo = createMoreInfoText(it.chain, it.asset.token.configuration), + buttonColor = themeColor + ) + } + .onEach { it.onError { Log.e("StartStakingLandingViewModel", "Failed to load staking info", it) } } + .shareInBackground() + + private val availableBalance = startStakingInteractor.flatMapLatest { interactor -> + interactor.observeAvailableBalance() + }.shareInBackground() + + val availableBalanceTextFlow = availableBalance.map { availableBalance -> + val amountModel = amountFormatter.formatAmountToAmountModel(availableBalance.availableBalance, availableBalance.asset.token) + resourceManager.getString(R.string.start_staking_fragment_available_balance, amountModel.token, amountModel.fiat!!) + }.shareInBackground() + + private val validationInProgressFlow = MutableStateFlow(false) + + val isContinueButtonLoading = combine(validationInProgressFlow, modelFlow) { validationInProgress, model -> + validationInProgress || model.isLoading() + } + + override val openBrowserEvent = MutableLiveData>() + + init { + launchSync() + + closeOnStakingStarted() + } + + fun back() { + router.back() + } + + fun continueClicked() = launch { + val interactor = startStakingInteractor.first() + + val validationSystem = interactor.validationSystem() + val payload = StartStakingLandingValidationPayload( + chain = interactor.chain, + metaAccount = selectedMetaAccountUseCase.getSelectedMetaAccount() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { validationFailure, _ -> + handleStartStakingLandingValidationFailure( + resourceManager, + validationFailure, + router + ) + }, + progressConsumer = validationInProgressFlow.progressConsumer() + ) { + validationInProgressFlow.value = false + + openStartStaking() + } + } + + fun termsOfUseClicked() { + openBrowserEvent.value = Event(appLinksProvider.termsUrl) + } + + private fun launchSync() { + launch { + // Start syncing for all staking type since we need to show it on select staking type screen + val asset = chainRegistry.asset(availableStakingOptionsPayload.chainId, availableStakingOptionsPayload.assetId) + updateSystemFactory.create(availableStakingOptionsPayload.chainId, asset.staking) + .start() + .launchIn(this) + } + } + + private fun closeOnStakingStarted() = launch { + val stakingStartedChain = stakingStartedDetectionService.awaitStakingStarted( + stakingOptionIds = stakingOptionIds, + screenScope = viewModelScope + ) + + val title = resourceManager.getString(R.string.staking_already_staking_title, stakingStartedChain.name) + + acknowledgeStakingStarted.awaitAction(title) + + router.returnToStakingDashboard() + } + + // TODO this should be provided by a particular staking implementation + private fun openStartStaking() { + val firstStakingType = availableStakingOptionsPayload.stakingTypes.first() + + when (firstStakingType.group()) { + StakingTypeGroup.PARACHAIN -> router.openStartParachainStaking() + + StakingTypeGroup.RELAYCHAIN, + StakingTypeGroup.NOMINATION_POOL -> router.openStartMultiStaking(SetupAmountMultiStakingPayload(availableStakingOptionsPayload)) + + StakingTypeGroup.MYTHOS -> router.openStartMythosStaking() + + StakingTypeGroup.UNSUPPORTED -> Unit + } + } + + private fun createTitle(chainAsset: Chain.Asset, earning: Fraction, themeColor: Int): CharSequence { + val apy = resourceManager.getString( + R.string.start_staking_fragment_title_APY, + earning.formatPercents() + ).toSpannable(colorSpan(themeColor)) + + return SpannableFormatter.format( + resourceManager.getString(R.string.start_staking_fragment_title), + apy, + chainAsset.symbol + ) + } + + private fun createMoreInfoText(chain: Chain, asset: Chain.Asset): CharSequence { + val iconColor = resourceManager.getColor(R.color.chip_icon) + val clickableTextColor = resourceManager.getColor(R.color.link_text) + val chevronSize = resourceManager.measureInPx(20) + val chevronRight = resourceManager.getDrawable(R.drawable.ic_chevron_right).apply { + setBounds(0, 0, chevronSize, chevronSize) + setTint(iconColor) + } + val clickablePart = resourceManager.getString(R.string.start_staking_fragment_more_info_clicable_part) + .toSpannable(colorSpan(clickableTextColor)) + .setFullSpan(clickableSpan { novaWikiClicked(chain.additional?.stakingWiki) }) + .setFullSpan(fontSpan(resourceManager, R.font.public_sans_semi_bold)) + .setEndSpan(drawableSpan(chevronRight)) + + return SpannableFormatter.format( + resourceManager.getString(R.string.start_staking_fragment_more_info), + asset.name, + clickablePart + ) + } + + private fun createConditions(data: StartStakingCompoundData, themeColor: Int): List { + return listOfNotNull( + createTestNetworkCondition(data.chain, themeColor), + createMinStakeCondition(data.asset, data.minStake, data.eraInfo.firstRewardReceivingDuration, themeColor), + createUnstakeCondition(data.eraInfo.unstakeTime, themeColor), + createRewardsFrequencyCondition(data.eraInfo.eraDuration, data.payouts, data.asset, themeColor), + createGovernanceParticipatingCondition(data.asset, data.participationInGovernance, themeColor), + createStakeMonitoring(themeColor) + ) + } + + private fun createTestNetworkCondition(chain: Chain, themeColor: Int): StakingConditionRVItem? { + if (!chain.isTestNet) { + return null + } + val chainName = chain.name.toSpannable(colorSpan(themeColor)) + val testNetwork = resourceManager.getString(R.string.start_staking_fragment_test_network_condition_test_network) + .toSpannable(colorSpan(themeColor)) + val noTokenValue = resourceManager.getString(R.string.start_staking_fragment_test_network_condition_no_token) + .toSpannable(colorSpan(themeColor)) + + return StakingConditionRVItem( + iconId = R.drawable.ic_test_network, + text = resourceManager.getString(R.string.start_staking_fragment_test_network_condition).formatAsSpannable(chainName, testNetwork, noTokenValue), + ) + } + + private fun createMinStakeCondition( + asset: Asset, + minStakeAmount: BigInteger, + eraDuration: Duration, + themeColor: Int + ): StakingConditionRVItem { + val time = resourceManager.getString( + R.string.start_staking_fragment_min_stake_condition_duration, + durationFormatter.format(eraDuration) + ).toSpannable(colorSpan(themeColor)) + + return if (minStakeAmount.isPositive()) { + val minStake = minStakeAmount.formatPlanks(asset.token.configuration) + .toSpannable(colorSpan(themeColor)) + + StakingConditionRVItem( + iconId = R.drawable.ic_stake_anytime, + text = resourceManager.getString(R.string.start_staking_fragment_min_stake_condition).formatAsSpannable(minStake, time), + ) + } else { + StakingConditionRVItem( + iconId = R.drawable.ic_stake_anytime, + text = resourceManager.getString(R.string.start_staking_fragment_min_stake_condition_no_min_stake).formatAsSpannable(time), + ) + } + } + + private fun createUnstakeCondition( + unstakeDuration: Duration, + themeColor: Int + ): StakingConditionRVItem { + val time = resourceManager.getString( + R.string.start_staking_fragment_unstake_condition_duration, + durationFormatter.format(unstakeDuration) + ).toSpannable(colorSpan(themeColor)) + return StakingConditionRVItem( + iconId = R.drawable.ic_unstake_anytime, + text = resourceManager.getString(R.string.start_staking_fragment_unstake_condition).formatAsSpannable(time), + ) + } + + private fun createRewardsFrequencyCondition( + eraDuration: Duration, + payouts: Payouts, + asset: Asset, + themeColor: Int + ): StakingConditionRVItem { + val time = durationShortcutFormatter.format(eraDuration) + .toSpannable(colorSpan(themeColor)) + + val payoutTypes = payouts.payoutTypes + val text = when { + isRestakeOnlyCase(payouts) -> { + resourceManager.getString(R.string.start_staking_fragment_reward_frequency_condition_restake_only).formatAsSpannable(time) + } + + isPayoutsOnlyCase(payouts) -> { + resourceManager.getString(R.string.start_staking_fragment_reward_frequency_condition_payout_only).formatAsSpannable(time) + } + + payoutTypes.containsOnly(PayoutType.Manual) -> { + resourceManager.getString(R.string.start_staking_fragment_reward_frequency_condition_manual).formatAsSpannable(time) + } + + payoutTypes.containsManualAndAutomatic() -> { + val automaticPayoutFormattedAmount = payouts.automaticPayoutMinAmount?.formatPlanks(asset.token.configuration).orEmpty() + resourceManager.getString(R.string.start_staking_fragment_reward_frequency_condition_automatic_and_manual) + .formatAsSpannable(time, automaticPayoutFormattedAmount) + } + + else -> { + resourceManager.getString(R.string.start_staking_fragment_reward_frequency_condition_fallback) + .formatAsSpannable(time) + } + } + + return StakingConditionRVItem( + iconId = R.drawable.ic_rewards, + text = text, + ) + } + + private fun createGovernanceParticipatingCondition( + asset: Asset, + participationInGovernance: ParticipationInGovernance, + themeColor: Int + ): StakingConditionRVItem? { + if (participationInGovernance !is ParticipationInGovernance.Participate) return null + + val text = if (participationInGovernance.minAmount != null) { + val minAmount = participationInGovernance.minAmount.formatPlanks(asset.token.configuration) + val participation = resourceManager.getString(R.string.start_staking_fragment_governance_participation_with_min_amount_accent) + .toSpannable(colorSpan(themeColor)) + resourceManager.getString(R.string.start_staking_fragment_governance_participation_with_min_amount).formatAsSpannable(minAmount, participation) + } else { + val participation = resourceManager.getString(R.string.start_staking_fragment_governance_participation_no_conditions_accent) + .toSpannable(colorSpan(themeColor)) + resourceManager.getString(R.string.start_staking_fragment_governance_participation_no_conditions).formatAsSpannable(participation) + } + + return StakingConditionRVItem( + iconId = R.drawable.ic_participate_in_governance, + text = text, + ) + } + + private fun createStakeMonitoring(themeColor: Int): StakingConditionRVItem { + val monitorStaking = resourceManager.getString(R.string.start_staking_fragment_stake_monitoring_monitor_stake).toSpannable(colorSpan(themeColor)) + + return StakingConditionRVItem( + iconId = R.drawable.ic_monitor_your_stake, + text = resourceManager.getString(R.string.start_staking_fragment_stake_monitoring).formatAsSpannable(monitorStaking), + ) + } + + private fun novaWikiClicked(stakingWiki: String?) { + openBrowserEvent.value = Event(stakingWiki ?: appLinksProvider.wikiBase) + } + + private fun List.containsOnly(type: PayoutType): Boolean { + return contains(type) && size == 1 + } + + private fun List.containsManualAndAutomatic(): Boolean { + return contains(PayoutType.Manual) && any { it is PayoutType.Automatically } && size == 2 + } + + private fun isRestakeOnlyCase(payouts: Payouts): Boolean { + return payouts.payoutTypes.containsOnly(PayoutType.Automatically.Restake) || + payouts.payoutTypes.contains(PayoutType.Automatically.Restake) && payouts.isAutomaticPayoutHasSmallestMinStake + } + + private fun isPayoutsOnlyCase(payouts: Payouts): Boolean { + return payouts.payoutTypes.containsOnly(PayoutType.Automatically.Payout) || + payouts.payoutTypes.contains(PayoutType.Automatically.Payout) && payouts.isAutomaticPayoutHasSmallestMinStake + } + + private fun getThemeColor(chain: Chain): Int { + return chain.additional?.themeColor?.let { Color.parseColor(it) } + ?: resourceManager.getColor(R.color.text_positive) + } + + private fun createDurationShortcutFormatter(): DurationFormatter { + val context = contextManager.getApplicationContext() + val durationShortcut = DayDurationShortcut( + shortcut = resourceManager.getString(R.string.common_frequency_days_daily) + ) + + return baseDurationFormatter( + contextManager.getApplicationContext(), + dayDurationFormatter = ShortcutDurationFormatter( + shortcuts = listOf(durationShortcut), + nestedFormatter = createDayDurationFormatter() + ), + hoursDurationFormatter = HoursDurationFormatter(context) + .wrapInto(context, R.string.start_staking_fragment_reward_frequency_condition_duration), + minutesDurationFormatter = MinutesDurationFormatter(context) + .wrapInto(context, R.string.start_staking_fragment_reward_frequency_condition_duration) + ) + } + + private fun createBaseDurationFormatter(): DurationFormatter { + val dayDurationFormatter = createDayDurationFormatter() + return baseDurationFormatter( + contextManager.getApplicationContext(), + dayDurationFormatter = dayDurationFormatter + ) + } + + private fun createDayDurationFormatter(): BoundedDurationFormatter { + val context = contextManager.getApplicationContext() + return DayAndHourDurationFormatter( + dayFormatter = DayDurationFormatter(context), + hoursFormatter = HoursDurationFormatter(context), + format = resourceManager.getString(R.string.common_days_and_hours_format_with_delimeter) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/di/StartStakingLandingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/di/StartStakingLandingComponent.kt new file mode 100644 index 0000000..fc3d5bb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/di/StartStakingLandingComponent.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.StartStakingLandingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StartStakingLandingPayload + +@Subcomponent( + modules = [ + StartStakingLandingModule::class + ] +) +@ScreenScope +interface StartStakingLandingComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment, @BindsInstance argument: StartStakingLandingPayload): StartStakingLandingComponent + } + + fun inject(fragment: StartStakingLandingFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/di/StartStakingLandingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/di/StartStakingLandingModule.kt new file mode 100644 index 0000000..19e3214 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/di/StartStakingLandingModule.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ContextManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingLandingInfoUpdateSystemFactory +import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.StakingUpdaters +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StakingStartedDetectionService +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.landing.StakingTypeDetailsCompoundInteractorFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.StartStakingLandingViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model.StartStakingLandingPayload +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class StartStakingLandingModule { + + @Provides + fun provideStartStakingLandingUpdateSystemFactory( + stakingUpdaters: StakingUpdaters, + chainRegistry: ChainRegistry, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory + ): StakingLandingInfoUpdateSystemFactory { + return StakingLandingInfoUpdateSystemFactory( + stakingUpdaters, + chainRegistry, + storageSharedRequestsBuilderFactory + ) + } + + @Provides + @IntoMap + @ViewModelKey(StartStakingLandingViewModel::class) + fun provideViewModel( + router: StartMultiStakingRouter, + resourceManager: ResourceManager, + updateSystemFactory: StakingLandingInfoUpdateSystemFactory, + stakingTypeDetailsCompoundInteractorFactory: StakingTypeDetailsCompoundInteractorFactory, + appLinksProvider: AppLinksProvider, + startStakingLandingPayload: StartStakingLandingPayload, + validationExecutor: ValidationExecutor, + selectedMetaAccountUseCase: SelectedAccountUseCase, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + stakingStartedDetectionService: StakingStartedDetectionService, + chainRegistry: ChainRegistry, + contextManager: ContextManager, + amountFormatter: AmountFormatter + ): ViewModel { + return StartStakingLandingViewModel( + router = router, + resourceManager = resourceManager, + updateSystemFactory = updateSystemFactory, + stakingTypeDetailsCompoundInteractorFactory = stakingTypeDetailsCompoundInteractorFactory, + appLinksProvider = appLinksProvider, + startStakingLandingPayload = startStakingLandingPayload, + validationExecutor = validationExecutor, + selectedMetaAccountUseCase = selectedMetaAccountUseCase, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + stakingStartedDetectionService = stakingStartedDetectionService, + chainRegistry = chainRegistry, + contextManager = contextManager, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): StartStakingLandingViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StartStakingLandingViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/model/StakingConditionRVItem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/model/StakingConditionRVItem.kt new file mode 100644 index 0000000..3fd24aa --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/model/StakingConditionRVItem.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model + +import androidx.annotation.DrawableRes + +data class StakingConditionRVItem( + @DrawableRes val iconId: Int, + val text: CharSequence +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/model/StartStakingLandingPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/model/StartStakingLandingPayload.kt new file mode 100644 index 0000000..b12361b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/landing/model/StartStakingLandingPayload.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.landing.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.AvailableStakingOptionsPayload +import kotlinx.parcelize.Parcelize + +@Parcelize +class StartStakingLandingPayload(val availableStakingOptions: AvailableStakingOptionsPayload) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/SetupAmountMultiStakingFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/SetupAmountMultiStakingFragment.kt new file mode 100644 index 0000000..7bc158b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/SetupAmountMultiStakingFragment.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.keyboard.hideSoftKeyboard +import io.novafoundation.nova.common.utils.keyboard.showSoftKeyboard +import io.novafoundation.nova.common.utils.makeGoneViews +import io.novafoundation.nova.common.utils.makeVisibleViews +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentStartMultiStakingAmountBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.model.StakingPropertiesModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser + +class SetupAmountMultiStakingFragment : BaseFragment() { + + companion object { + private const val KEY_PAYLOAD = "SetupAmountMultiStakingFragment.payload" + + fun getBundle(payload: SetupAmountMultiStakingPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentStartMultiStakingAmountBinding.inflate(layoutInflater) + + override fun initViews() { + binder.startMultiStakingSetupAmountToolbar.setHomeButtonListener { viewModel.back() } + + binder.startMultiStakingSetupAmountContinue.prepareForProgress(viewLifecycleOwner) + binder.startMultiStakingSetupAmountContinue.setOnClickListener { viewModel.continueClicked() } + + binder.startMultiStakingSetupAmountSelection.setOnClickListener { viewModel.selectionClicked() } + + binder.startMultiStakingSetupAmountAmount.amountInput.showSoftKeyboard() + } + + override fun onDestroyView() { + super.onDestroyView() + + binder.startMultiStakingSetupAmountAmount.amountInput.hideSoftKeyboard() + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .setupAmountMultiStakingComponentFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: SetupAmountMultiStakingViewModel) { + setupAmountChooser(viewModel.amountChooserMixin, binder.startMultiStakingSetupAmountAmount) + observeValidations(viewModel) + + viewModel.stakingPropertiesModel.observe(::showStakingProperties) + viewModel.title.observe(binder.startMultiStakingSetupAmountToolbar::setTitle) + + viewModel.continueButtonState.observe(binder.startMultiStakingSetupAmountContinue::setState) + } + + private fun showStakingProperties(properties: StakingPropertiesModel) { + when (properties) { + StakingPropertiesModel.Hidden -> { + makeGoneViews(binder.startMultiStakingSetupAmountSelection, binder.startMultiStakingSetupAmountRewards) + } + + is StakingPropertiesModel.Loaded -> { + makeVisibleViews(binder.startMultiStakingSetupAmountSelection, binder.startMultiStakingSetupAmountRewards) + binder.startMultiStakingSetupAmountSelection.setModel(properties.content.selection) + binder.startMultiStakingSetupAmountRewards.showEarnings(properties.content.estimatedReward) + } + + StakingPropertiesModel.Loading -> { + makeVisibleViews(binder.startMultiStakingSetupAmountSelection, binder.startMultiStakingSetupAmountRewards) + binder.startMultiStakingSetupAmountSelection.setLoadingState() + binder.startMultiStakingSetupAmountRewards.showLoading() + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/SetupAmountMultiStakingPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/SetupAmountMultiStakingPayload.kt new file mode 100644 index 0000000..fabddf7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/SetupAmountMultiStakingPayload.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.AvailableStakingOptionsPayload +import kotlinx.parcelize.Parcelize + +@Parcelize +class SetupAmountMultiStakingPayload(val availableStakingOptions: AvailableStakingOptionsPayload) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/SetupAmountMultiStakingViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/SetupAmountMultiStakingViewModel.kt new file mode 100644 index 0000000..c62d18d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/SetupAmountMultiStakingViewModel.kt @@ -0,0 +1,234 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.formatting.formatPercents +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StartMultiStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.currentSelectionFlow +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.StartMultiStakingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.validations.handleStartMultiStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.selectionType.MultiStakingSelectionTypeProviderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.MultiStakingTargetSelectionFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.toStakingOptionIds +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.confirm.ConfirmMultiStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.model.StakingPropertiesModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.SetupStakingTypePayload +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.create +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.ext.fullId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +private const val DEBOUNCE_RATE_MILLIS = 500 + +class SetupAmountMultiStakingViewModel( + private val multiStakingTargetSelectionFormatter: MultiStakingTargetSelectionFormatter, + private val resourceManager: ResourceManager, + private val router: StartMultiStakingRouter, + private val interactor: StartMultiStakingInteractor, + private val validationExecutor: ValidationExecutor, + multiStakingSelectionTypeProviderFactory: MultiStakingSelectionTypeProviderFactory, + assetUseCase: ArbitraryAssetUseCase, + amountChooserMixinFactory: AmountChooserMixin.Factory, + private val selectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val payload: SetupAmountMultiStakingPayload, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, +) : BaseViewModel(), + Validatable by validationExecutor { + + private val multiStakingSelectionTypeProvider = multiStakingSelectionTypeProviderFactory.create( + scope = viewModelScope, + candidateOptionsIds = payload.availableStakingOptions.toStakingOptionIds() + ) + + private val multiStakingSelectionTypeFlow = multiStakingSelectionTypeProvider.multiStakingSelectionTypeFlow() + .shareInBackground() + + private val currentSelectionFlow = selectionStoreProvider.currentSelectionFlow(viewModelScope) + .shareInBackground() + + val currentAssetFlow = assetUseCase.assetFlow( + chainId = payload.availableStakingOptions.chainId, + assetId = payload.availableStakingOptions.assetId + ).shareInBackground() + + private val maxStakeableBalance = combine( + currentAssetFlow, + multiStakingSelectionTypeFlow, + currentSelectionFlow + ) { asset, selectionType, currentSelection -> + currentSelection?.properties?.maximumToStake(asset) // If selection is already known, use it directly for more precise estimation + ?: selectionType.maxAmountToStake(asset) // if selection is still unset (e.g. empty form), show best-effort estimation from selection type + } + .distinctUntilChanged() + .shareInBackground() + + private val chainAssetFlow = currentAssetFlow.map { it.token.configuration } + .distinctUntilChangedBy { it.fullId } + .shareInBackground() + + val feeLoaderMixin = feeLoaderMixinFactory.createDefault(this, chainAssetFlow) + + private val maxActionProvider = maxActionProviderFactory.createCustom(viewModelScope) { + chainAssetFlow.providingBalance(maxStakeableBalance) + .deductFee(feeLoaderMixin) + } + + val amountChooserMixin = amountChooserMixinFactory.create( + scope = viewModelScope, + assetFlow = currentAssetFlow, + maxActionProvider = maxActionProvider + ) + + private val loadingInProgressFlow = MutableStateFlow(false) + + private val amountEmptyFlow = amountChooserMixin.amountInput + .map { it.isEmpty() } + .distinctUntilChanged() + + val stakingPropertiesModel = combine( + amountEmptyFlow, + currentSelectionFlow + ) { amountEmpty, currentSelection -> + when { + currentSelection == null && amountEmpty -> StakingPropertiesModel.Hidden + currentSelection == null -> StakingPropertiesModel.Loading + else -> { + val content = StakingPropertiesModel.Content( + estimatedReward = currentSelection.selection.apy.orZero().formatPercents(), + selection = multiStakingTargetSelectionFormatter.formatForSetupAmount(currentSelection) + ) + + StakingPropertiesModel.Loaded(content) + } + } + }.shareInBackground() + + val title = currentAssetFlow.map { + val tokenSymbol = it.token.configuration.symbol + + resourceManager.getString(R.string.staking_stake_format, tokenSymbol) + }.shareInBackground() + + val continueButtonState = combine( + loadingInProgressFlow, + currentSelectionFlow, + amountEmptyFlow + ) { loadingInProgress, currentSelection, amountEmpty -> + when { + loadingInProgress -> DescriptiveButtonState.Loading + amountEmpty -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_enter_amount)) + currentSelection == null -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_continue)) + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + }.shareInBackground() + + init { + runSelectionUpdates() + + runFeeUpdates() + } + + fun back() { + router.back() + } + + fun selectionClicked() { + router.openSetupStakingType(SetupStakingTypePayload(payload.availableStakingOptions)) + } + + fun continueClicked() = launch { + val recommendableSelection = currentSelectionFlow.first() ?: return@launch + loadingInProgressFlow.value = true + + val validationSystem = multiStakingSelectionTypeFlow.first().validationSystem(recommendableSelection.selection) + val payload = StartMultiStakingValidationPayload( + recommendableSelection = recommendableSelection, + asset = currentAssetFlow.first(), + fee = feeLoaderMixin.awaitFee() + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { status, flowActions -> + handleStartMultiStakingValidationFailure( + status, + resourceManager, + flowActions, + amountChooserMixin::setAmount + ) + }, + progressConsumer = loadingInProgressFlow.progressConsumer(), + ) { newPayload -> + loadingInProgressFlow.value = false + + openConfirm(newPayload) + } + } + + private fun openConfirm(validPayload: StartMultiStakingValidationPayload) = launch { + // payload might've been updated during validations so we should store it again + selectionStoreProvider.getSelectionStore(viewModelScope).updateSelection(validPayload.recommendableSelection) + + val confirmPayload = ConfirmMultiStakingPayload(mapFeeToParcel(validPayload.fee), payload.availableStakingOptions) + + router.openConfirm(confirmPayload) + } + + private fun runFeeUpdates() { + feeLoaderMixin.connectWith( + inputSource1 = currentSelectionFlow + .filterNotNull() + .debounce(DEBOUNCE_RATE_MILLIS.milliseconds), + feeConstructor = { _, selection -> interactor.calculateFee(selection.selection) }, + ) + } + + private fun runSelectionUpdates() { + launch(Dispatchers.Default) { + combineToPair( + multiStakingSelectionTypeFlow, + amountChooserMixin.amountState + ).collectLatest { (multiStakingSelectionType, amountInput) -> + val amount = amountInput.value ?: return@collectLatest + val asset = currentAssetFlow.first() + val planks = asset.token.planksFromAmount(amount) + + multiStakingSelectionType.updateSelectionFor(planks) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/di/SetupAmountMultiStakingComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/di/SetupAmountMultiStakingComponent.kt new file mode 100644 index 0000000..24ec793 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/di/SetupAmountMultiStakingComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.SetupAmountMultiStakingFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.SetupAmountMultiStakingPayload + +@Subcomponent( + modules = [ + SetupAmountMultiStakingModule::class + ] +) +@ScreenScope +interface SetupAmountMultiStakingComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance argument: SetupAmountMultiStakingPayload + ): SetupAmountMultiStakingComponent + } + + fun inject(fragment: SetupAmountMultiStakingFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/di/SetupAmountMultiStakingModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/di/SetupAmountMultiStakingModule.kt new file mode 100644 index 0000000..1368ad0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/di/SetupAmountMultiStakingModule.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.di.staking.startMultiStaking.MultiStakingSelectionStoreProviderKey +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.StartMultiStakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupAmount.selectionType.MultiStakingSelectionTypeProviderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StartMultiStakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.MultiStakingTargetSelectionFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.di.CommonMultiStakingModule +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.SetupAmountMultiStakingPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.SetupAmountMultiStakingViewModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class, CommonMultiStakingModule::class]) +class SetupAmountMultiStakingModule { + + @Provides + @IntoMap + @ViewModelKey(SetupAmountMultiStakingViewModel::class) + fun provideViewModel( + multiStakingTargetSelectionFormatter: MultiStakingTargetSelectionFormatter, + resourceManager: ResourceManager, + router: StartMultiStakingRouter, + multiStakingSelectionTypeProviderFactory: MultiStakingSelectionTypeProviderFactory, + assetUseCase: ArbitraryAssetUseCase, + amountChooserMixinFactory: AmountChooserMixin.Factory, + @MultiStakingSelectionStoreProviderKey selectionStoreProvider: StartMultiStakingSelectionStoreProvider, + startMultiStakingInteractor: StartMultiStakingInteractor, + payload: SetupAmountMultiStakingPayload, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + maxActionProviderFactory: MaxActionProviderFactory, + validationExecutor: ValidationExecutor, + amountFormatter: AmountFormatter + ): ViewModel { + return SetupAmountMultiStakingViewModel( + multiStakingTargetSelectionFormatter = multiStakingTargetSelectionFormatter, + resourceManager = resourceManager, + router = router, + multiStakingSelectionTypeProviderFactory = multiStakingSelectionTypeProviderFactory, + assetUseCase = assetUseCase, + amountChooserMixinFactory = amountChooserMixinFactory, + selectionStoreProvider = selectionStoreProvider, + payload = payload, + validationExecutor = validationExecutor, + interactor = startMultiStakingInteractor, + feeLoaderMixinFactory = feeLoaderMixinFactory, + maxActionProviderFactory = maxActionProviderFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SetupAmountMultiStakingViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SetupAmountMultiStakingViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/model/StakingPropertiesModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/model/StakingPropertiesModel.kt new file mode 100644 index 0000000..ed7063f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/model/StakingPropertiesModel.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.model + +import io.novafoundation.nova.feature_staking_impl.presentation.view.stakingTarget.StakingTargetModel + +sealed class StakingPropertiesModel { + + object Hidden : StakingPropertiesModel() + + object Loading : StakingPropertiesModel() + + class Loaded(val content: Content) : StakingPropertiesModel() + + class Content( + val estimatedReward: String, + val selection: StakingTargetModel + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/view/EstimatedRewardsView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/view/EstimatedRewardsView.kt new file mode 100644 index 0000000..69b4f51 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupAmount/view/EstimatedRewardsView.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupAmount.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeGoneViews +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.makeVisibleViews +import io.novafoundation.nova.feature_staking_impl.databinding.ViewEstimatedRewardsBinding + +class EstimatedRewardsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewEstimatedRewardsBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + + fun showLoading() { + makeGoneViews(binder.viewEstimatedRewardsEarnings, binder.viewEstimatedRewardsEarningsSuffix) + binder.viewEstimatedRewardsEarningsShimmer.makeVisible() + } + + fun showEarnings(earnings: String) { + makeVisibleViews(binder.viewEstimatedRewardsEarnings, binder.viewEstimatedRewardsEarningsSuffix) + binder.viewEstimatedRewardsEarningsShimmer.makeGone() + + binder.viewEstimatedRewardsEarnings.text = earnings + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/EditableStakingTypeComparator.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/EditableStakingTypeComparator.kt new file mode 100644 index 0000000..875729c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/EditableStakingTypeComparator.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType + +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.model.ValidatedStakingTypeDetails +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group + +fun getEditableStakingTypeComparator(): Comparator { + return compareBy { + when (it.stakingTypeDetails.stakingType.group()) { + StakingTypeGroup.NOMINATION_POOL -> 0 + StakingTypeGroup.RELAYCHAIN -> 1 + else -> 3 + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/EditableStakingTypeItemFormatter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/EditableStakingTypeItemFormatter.kt new file mode 100644 index 0000000..5ae6b2a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/EditableStakingTypeItemFormatter.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.stakingType +import io.novafoundation.nova.feature_staking_impl.domain.model.PayoutType +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.RecommendableMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.StakingTypeDetails +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.model.ValidatedStakingTypeDetails +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.MultiStakingTargetSelectionFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.adapter.EditableStakingTypeRVItem +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.isDirectStaking +import io.novafoundation.nova.runtime.ext.isPoolStaking + +class EditableStakingTypeItemFormatter( + private val resourceManager: ResourceManager, + private val multiStakingTargetSelectionFormatter: MultiStakingTargetSelectionFormatter, + private val amountFormatter: AmountFormatter +) { + + suspend fun format( + asset: Asset, + validatedStakingType: ValidatedStakingTypeDetails, + selection: RecommendableMultiStakingSelection + ): EditableStakingTypeRVItem? { + val stakingTarget = multiStakingTargetSelectionFormatter.formatForStakingType(selection) + val selectedStakingType = selection.selection.stakingOption.stakingType + val stakingType = validatedStakingType.stakingTypeDetails.stakingType + + val (titleRes, imageRes) = when { + stakingType.isDirectStaking() -> R.string.setup_staking_type_direct_staking to R.drawable.ic_direct_staking_banner_picture + stakingType.isPoolStaking() -> R.string.setup_staking_type_pool_staking to R.drawable.ic_pool_staking_banner_picture + else -> return null + } + + val isSelected = selectedStakingType == stakingType + + return EditableStakingTypeRVItem( + isSelected = isSelected, + isSelectable = validatedStakingType.isAvailable || isSelected, + title = resourceManager.getString(titleRes), + imageRes = imageRes, + conditions = mapConditions(asset, validatedStakingType.stakingTypeDetails), + stakingTarget = stakingTarget.takeIf { selectedStakingType == stakingType } + ) + } + + private fun mapConditions(asset: Asset, stakingTypeDetails: StakingTypeDetails): List { + return buildList { + val minAmount = amountFormatter.formatAmountToAmountModel(stakingTypeDetails.minStake, asset.token) + add(resourceManager.getString(R.string.setup_staking_type_min_amount_condition, minAmount.token)) + + val payoutCondition = when (stakingTypeDetails.payoutType) { + is PayoutType.Automatically -> resourceManager.getString(R.string.setup_staking_type_payout_type_automatically_condition) + is PayoutType.Manual -> resourceManager.getString(R.string.setup_staking_type_payout_type_manual_condition) + } + add(payoutCondition) + + if (stakingTypeDetails.participationInGovernance) { + add(resourceManager.getString(R.string.setup_staking_type_governance_condition)) + } + + if (stakingTypeDetails.advancedOptionsAvailable) { + add(resourceManager.getString(R.string.setup_staking_type_advanced_options_condition)) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeFlowExecutor.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeFlowExecutor.kt new file mode 100644 index 0000000..b6e4885 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeFlowExecutor.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.getValidatorsOrEmpty +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.pools.common.SelectingPoolPayload +import io.novafoundation.nova.runtime.ext.isDirectStaking +import io.novafoundation.nova.runtime.ext.isPoolStaking +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.CoroutineScope + +class SetupStakingTypeFlowExecutorFactory( + private val router: StakingRouter, + private val setupStakingSharedState: SetupStakingSharedState, + private val editableSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, +) { + + fun create(chainId: ChainId, assetId: Int, stakingType: Chain.Asset.StakingType): SetupStakingTypeFlowExecutor { + return when { + stakingType.isDirectStaking() -> SetupDirectStakingFlowExecutor( + router, + setupStakingSharedState, + editableSelectionStoreProvider + ) + + stakingType.isPoolStaking() -> SetupPoolStakingFlowExecutor( + router, + chainId, + assetId, + stakingType + ) + + else -> throw IllegalArgumentException("Unsupported staking type: $stakingType") + } + } +} + +interface SetupStakingTypeFlowExecutor { + + suspend fun execute(coroutineScope: CoroutineScope) +} + +class SetupDirectStakingFlowExecutor( + private val router: StakingRouter, + private val setupStakingSharedState: SetupStakingSharedState, + private val editableSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, +) : SetupStakingTypeFlowExecutor { + + override suspend fun execute(coroutineScope: CoroutineScope) { + val selectionStore = editableSelectionStoreProvider.getSelectionStore(coroutineScope) + setupStakingSharedState.set( + SetupStakingProcess.ReadyToSubmit( + activeStake = selectionStore.getCurrentSelection()?.selection?.stake.orZero(), + newValidators = selectionStore.getValidatorsOrEmpty(), + selectionMethod = SetupStakingProcess.ReadyToSubmit.SelectionMethod.CUSTOM, + currentlySelectedValidators = emptyList() + ) + ) + router.openSelectCustomValidators() + } +} + +class SetupPoolStakingFlowExecutor( + private val router: StakingRouter, + private val chainId: ChainId, + private val assetId: Int, + private val stakingType: Chain.Asset.StakingType +) : SetupStakingTypeFlowExecutor { + + override suspend fun execute(coroutineScope: CoroutineScope) { + val selectingPoolPayload = SelectingPoolPayload( + chainId, + assetId, + stakingType + ) + router.openSelectPool(selectingPoolPayload) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeFragment.kt new file mode 100644 index 0000000..17bc2c1 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeFragment.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.actionAwaitable.setupConfirmationDialog +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentSetupStakingTypeBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.adapter.EditableStakingTypeRVItem +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.adapter.SetupStakingTypeAdapter + +class SetupStakingTypeFragment : BaseFragment(), SetupStakingTypeAdapter.ItemAssetHandler { + + companion object { + + private val PAYLOAD_KEY = "SetupStakingTypeFragment.Payload" + + fun getArguments(payload: SetupStakingTypePayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + } + + override fun createBinding() = FragmentSetupStakingTypeBinding.inflate(layoutInflater) + + private val adapter = SetupStakingTypeAdapter(this) + + override fun initViews() { + binder.setupStakingTypeToolbar.setRightActionClickListener { viewModel.donePressed() } + binder.setupStakingTypeToolbar.setHomeButtonListener { viewModel.backPressed() } + binder.setupStakingTypeList.adapter = adapter + binder.setupStakingTypeList.itemAnimator = null + + onBackPressed { viewModel.backPressed() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .setupStakingType() + .create(this, argument(PAYLOAD_KEY)) + .inject(this) + } + + override fun subscribe(viewModel: SetupStakingTypeViewModel) { + setupConfirmationDialog(R.style.AccentNegativeAlertDialogTheme_Reversed, viewModel.closeConfirmationAction) + observeValidations(viewModel) + + viewModel.dataHasBeenChanged.observe { binder.setupStakingTypeToolbar.setRightActionEnabled(it) } + + viewModel.stakingTypeModels.observe { + adapter.submitList(it) + } + } + + override fun stakingTypeClicked(stakingTypeRVItem: EditableStakingTypeRVItem, position: Int) { + viewModel.stakingTypeClicked(stakingTypeRVItem, position) + } + + override fun stakingTargetClicked(position: Int) { + viewModel.stakingTargetClicked(position) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypePayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypePayload.kt new file mode 100644 index 0000000..41e7ae3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypePayload.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType + +import android.os.Parcelable +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.AvailableStakingOptionsPayload +import kotlinx.parcelize.Parcelize + +@Parcelize +class SetupStakingTypePayload(val availableStakingOptions: AvailableStakingOptionsPayload) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeValidationFailureUi.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeValidationFailureUi.kt new file mode 100644 index 0000000..57dd0bb --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeValidationFailureUi.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.direct.EditingStakingTypeFailure +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.formatStakingTypeLabel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun handleSetupStakingTypeValidationFailure(chainAsset: Chain.Asset, error: EditingStakingTypeFailure, resourceManager: ResourceManager): TitleAndMessage { + return when (error) { + is EditingStakingTypeFailure.AmountIsLessThanMinStake -> { + val amount = error.minStake.formatPlanks(chainAsset) + val stakingTypeFormat = resourceManager.formatStakingTypeLabel(error.stakingType) + TitleAndMessage( + resourceManager.getString(R.string.setup_staking_type_staking_amount_is_less_than_min_amount_title), + resourceManager.getString(R.string.setup_staking_type_direct_staking_amount_is_less_than_min_amount_message, amount, stakingTypeFormat) + ) + } + + is EditingStakingTypeFailure.StakingTypeIsAlreadyUsing -> { + when (error.stakingType.group()) { + StakingTypeGroup.PARACHAIN, + StakingTypeGroup.MYTHOS, + StakingTypeGroup.RELAYCHAIN -> TitleAndMessage( + resourceManager.getString(R.string.setup_staking_type_already_used_title), + resourceManager.getString(R.string.setup_staking_type_direct_already_used_message) + ) + StakingTypeGroup.NOMINATION_POOL -> TitleAndMessage( + resourceManager.getString(R.string.setup_staking_type_already_used_title), + resourceManager.getString(R.string.setup_staking_type_pool_already_used_message) + ) + StakingTypeGroup.UNSUPPORTED -> TitleAndMessage( + resourceManager.getString(R.string.setup_staking_type_already_used_title), + resourceManager.getString(R.string.setup_staking_type_unsupported_staking_type_used_fallback_message) + ) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeViewModel.kt new file mode 100644 index 0000000..681fc34 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/SetupStakingTypeViewModel.kt @@ -0,0 +1,208 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.actionAwaitable.ConfirmationDialogInfo +import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction +import io.novafoundation.nova.common.mixin.actionAwaitable.fromRes +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.createStakingOption +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.RecommendableMultiStakingSelection +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.currentSelectionFlow +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.CompoundStakingTypeDetailsProvidersFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.model.ValidatedStakingTypeDetails +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.adapter.EditableStakingTypeRVItem +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.SetupStakingTypeSelectionMixinFactory +import java.math.BigInteger +import kotlinx.coroutines.launch + +class SetupStakingTypeViewModel( + private val router: StakingRouter, + private val assetUseCase: ArbitraryAssetUseCase, + private val payload: SetupStakingTypePayload, + private val currentSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val editableSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + private val editableStakingTypeItemFormatter: EditableStakingTypeItemFormatter, + private val compoundStakingTypeDetailsProvidersFactory: CompoundStakingTypeDetailsProvidersFactory, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val setupStakingTypeFlowExecutorFactory: SetupStakingTypeFlowExecutorFactory, + private val setupStakingTypeSelectionMixinFactory: SetupStakingTypeSelectionMixinFactory, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + chainRegistry: ChainRegistry +) : BaseViewModel(), Validatable by validationExecutor { + + val closeConfirmationAction = actionAwaitableMixinFactory.confirmingAction() + + private val setupStakingTypeSelectionMixin = setupStakingTypeSelectionMixinFactory.create(viewModelScope) + + private val chainWithAssetFlow = flowOf { + chainRegistry.chainWithAsset( + payload.availableStakingOptions.chainId, + payload.availableStakingOptions.assetId + ) + } + + private val assetFlow = assetUseCase.assetFlow( + payload.availableStakingOptions.chainId, + payload.availableStakingOptions.assetId + ) + + private val compoundStakingTypeDetailsProviderFlow = chainWithAssetFlow.map { + compoundStakingTypeDetailsProvidersFactory.create( + computationalScope = this, + chainWithAsset = it, + availableStakingTypes = payload.availableStakingOptions.stakingTypes + ) + } + + private val editableSelectionFlow = editableSelectionStoreProvider.currentSelectionFlow(viewModelScope) + .filterNotNull() + .shareInBackground() + + private val currentSelectionFlow = currentSelectionStoreProvider.currentSelectionFlow(viewModelScope) + .filterNotNull() + .shareInBackground() + + private val editableStakingTypeComparator = getEditableStakingTypeComparator() + + private val stakingTypesDataFlow = compoundStakingTypeDetailsProviderFlow + .flatMapLatest { it.getStakingTypeDetails() } + .map { it.sortedWith(editableStakingTypeComparator) } + .shareInBackground() + + val dataHasBeenChanged = combine( + currentSelectionFlow, + editableSelectionFlow + ) { current, editable -> + !current.selection.isSettingsEquals(editable.selection) + }.shareInBackground() + + val stakingTypeModels = combine( + assetFlow, + stakingTypesDataFlow, + editableSelectionFlow + ) { asset, stakingTypesData, selection -> + mapStakingTypes(asset, stakingTypesData, selection) + } + .shareInBackground() + + init { + currentSelectionFlow + .onEach { + editableSelectionStoreProvider + .getSelectionStore(viewModelScope) + .updateSelection(it) + }.launchIn(this) + } + + fun backPressed() { + launch { + val dataHasBeenChanged = dataHasBeenChanged.first() + + if (dataHasBeenChanged) { + closeConfirmationAction.awaitAction( + ConfirmationDialogInfo.fromRes( + resourceManager, + R.string.common_confirmation_title, + R.string.common_close_confirmation_message, + R.string.common_close, + R.string.common_cancel, + ) + ) + } + + router.back() + } + } + + fun donePressed() { + launch { + setupStakingTypeSelectionMixin.apply() + + router.back() + } + } + + fun stakingTypeClicked(stakingTypeRVItem: EditableStakingTypeRVItem, position: Int) { + if (stakingTypeRVItem.isSelected) return + + launch { + val enteredAmount = getEnteredAmount() ?: return@launch + val chainAsset = chainWithAssetFlow.first().asset + val stakingType = stakingTypesDataFlow.first()[position] + .stakingTypeDetails + .stakingType + + val compoundStakingTypeDetailsProvider = compoundStakingTypeDetailsProviderFlow.first() + val validationSystem = compoundStakingTypeDetailsProvider.getValidationSystem(stakingType) + val payload = compoundStakingTypeDetailsProvider.getValidationPayload(stakingType) ?: return@launch + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { handleSetupStakingTypeValidationFailure(chainAsset, it, resourceManager) }, + ) { + setRecommendedSelection(enteredAmount, stakingType) + } + } + } + + fun stakingTargetClicked(position: Int) { + launch { + val stakingType = stakingTypesDataFlow.first()[position] + .stakingTypeDetails + .stakingType + val setupStakingTypeFlowExecutor = setupStakingTypeFlowExecutorFactory.create( + payload.availableStakingOptions.chainId, + payload.availableStakingOptions.assetId, + stakingType + ) + setupStakingTypeFlowExecutor.execute(viewModelScope) + } + } + + private fun setRecommendedSelection(enteredAmount: BigInteger, stakingType: Chain.Asset.StakingType) { + launch { + val chainWithAsset = chainWithAssetFlow.first() + val stakingOption = createStakingOption(chainWithAsset, stakingType) + setupStakingTypeSelectionMixin.selectRecommended(viewModelScope, stakingOption, enteredAmount) + } + } + + private suspend fun mapStakingTypes( + asset: Asset, + stakingTypesDetails: List, + selection: RecommendableMultiStakingSelection + ): List { + return stakingTypesDetails.mapNotNull { + editableStakingTypeItemFormatter.format(asset, it, selection) + } + } + + private suspend fun getEnteredAmount(): BigInteger? { + return currentSelectionStoreProvider.getSelectionStore(viewModelScope) + .getCurrentSelection() + ?.selection + ?.stake + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/adapter/EditableStakingTypeRVItem.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/adapter/EditableStakingTypeRVItem.kt new file mode 100644 index 0000000..1d8f891 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/adapter/EditableStakingTypeRVItem.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.adapter + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.feature_staking_impl.presentation.view.stakingTarget.StakingTargetModel + +class EditableStakingTypeRVItem( + val isSelected: Boolean, + val isSelectable: Boolean, + val title: String, + @DrawableRes val imageRes: Int, + val conditions: List, + val stakingTarget: StakingTargetModel? +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/adapter/SetupStakingTypeAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/adapter/SetupStakingTypeAdapter.kt new file mode 100644 index 0000000..4e42cd7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/adapter/SetupStakingTypeAdapter.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.utils.doIfPositionValid +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_staking_impl.databinding.ItemEditableStakingTypeBinding + +class SetupStakingTypeAdapter( + private val handler: ItemAssetHandler +) : ListAdapter(SetupStakingTypeDiffUtil()) { + + interface ItemAssetHandler { + + fun stakingTypeClicked(stakingTypeRVItem: EditableStakingTypeRVItem, position: Int) + + fun stakingTargetClicked(position: Int) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EditableStakingTypeViewHolder { + return EditableStakingTypeViewHolder(handler, ItemEditableStakingTypeBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: EditableStakingTypeViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun onBindViewHolder(holder: EditableStakingTypeViewHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + resolvePayload(holder, position, payloads) { + when (it) { + EditableStakingTypeRVItem::title -> holder.setTitle(item) + EditableStakingTypeRVItem::conditions -> holder.setConditions(item) + EditableStakingTypeRVItem::isSelected -> holder.select(item) + EditableStakingTypeRVItem::isSelectable -> holder.setSelectable(item) + EditableStakingTypeRVItem::stakingTarget -> holder.setStakingTarget(item) + EditableStakingTypeRVItem::imageRes -> holder.setImage(item) + } + } + } +} + +private class SetupStakingTypeDiffUtil : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: EditableStakingTypeRVItem, newItem: EditableStakingTypeRVItem): Boolean { + return oldItem === newItem + } + + override fun areContentsTheSame(oldItem: EditableStakingTypeRVItem, newItem: EditableStakingTypeRVItem): Boolean { + return true + } + + override fun getChangePayload(oldItem: EditableStakingTypeRVItem, newItem: EditableStakingTypeRVItem): Any? { + return SetupStakingTypePayloadGenerator.diff(oldItem, newItem) + } +} + +class EditableStakingTypeViewHolder( + private val clickHandler: SetupStakingTypeAdapter.ItemAssetHandler, + private val binder: ItemEditableStakingTypeBinding +) : RecyclerView.ViewHolder(binder.root) { + + fun bind(item: EditableStakingTypeRVItem) = with(binder) { + setTitle(item) + setConditions(item) + select(item) + setSelectable(item) + setStakingTarget(item) + setImage(item) + + editableStakingType.setOnClickListener { + doIfPositionValid { position -> clickHandler.stakingTypeClicked(item, position) } + } + + editableStakingType.setStakingTargetClickListener { + doIfPositionValid { position -> clickHandler.stakingTargetClicked(position) } + } + } + + fun setTitle(item: EditableStakingTypeRVItem) { + binder.editableStakingType.setTitle(item.title) + } + + fun setConditions(item: EditableStakingTypeRVItem) { + binder.editableStakingType.setConditions(item.conditions) + } + + fun select(item: EditableStakingTypeRVItem) { + binder.editableStakingType.select(item.isSelected) + } + + fun setSelectable(item: EditableStakingTypeRVItem) { + binder.editableStakingType.setSelectable(item.isSelectable) + } + + fun setStakingTarget(item: EditableStakingTypeRVItem) { + binder.editableStakingType.setStakingTarget(item.stakingTarget) + } + + fun setImage(item: EditableStakingTypeRVItem) { + binder.editableStakingType.setBackgroundRes(item.imageRes) + } +} + +private object SetupStakingTypePayloadGenerator : PayloadGenerator( + EditableStakingTypeRVItem::title, + EditableStakingTypeRVItem::conditions, + EditableStakingTypeRVItem::isSelected, + EditableStakingTypeRVItem::isSelectable, + EditableStakingTypeRVItem::stakingTarget, + EditableStakingTypeRVItem::imageRes +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/di/SetupStakingTypeComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/di/SetupStakingTypeComponent.kt new file mode 100644 index 0000000..60647cc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/di/SetupStakingTypeComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.SetupStakingTypeFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.SetupStakingTypePayload + +@Subcomponent( + modules = [ + SetupStakingTypeModule::class + ] +) +@ScreenScope +interface SetupStakingTypeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance argument: SetupStakingTypePayload + ): SetupStakingTypeComponent + } + + fun inject(setupStakingTypeFragment: SetupStakingTypeFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/di/SetupStakingTypeModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/di/SetupStakingTypeModule.kt new file mode 100644 index 0000000..ca32a8b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/start/setupStakingType/di/SetupStakingTypeModule.kt @@ -0,0 +1,102 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.di.staking.startMultiStaking.MultiStakingSelectionStoreProviderKey +import io.novafoundation.nova.feature_staking_impl.di.staking.startMultiStaking.StakingTypeEditingStoreProviderKey +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.selection.store.StartMultiStakingSelectionStoreProvider +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.common.types.CompoundStakingTypeDetailsProvidersFactory +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.SetupStakingTypeSelectionMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.common.MultiStakingTargetSelectionFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.EditableStakingTypeItemFormatter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.SetupStakingTypeFlowExecutorFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.SetupStakingTypePayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.start.setupStakingType.SetupStakingTypeViewModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SetupStakingTypeModule { + + @Provides + fun provideSetupStakingTypeFlowExecutorFactory( + stakingRouter: StakingRouter, + setupStakingSharedState: SetupStakingSharedState, + @StakingTypeEditingStoreProviderKey editableSelectionStoreProvider: StartMultiStakingSelectionStoreProvider + ): SetupStakingTypeFlowExecutorFactory { + return SetupStakingTypeFlowExecutorFactory( + stakingRouter, + setupStakingSharedState, + editableSelectionStoreProvider + ) + } + + @Provides + fun provideEditableStakingTypeItemFormatter( + resourceManager: ResourceManager, + multiStakingTargetSelectionFormatter: MultiStakingTargetSelectionFormatter, + amountFormatter: AmountFormatter + ): EditableStakingTypeItemFormatter { + return EditableStakingTypeItemFormatter( + resourceManager, + multiStakingTargetSelectionFormatter, + amountFormatter = amountFormatter + ) + } + + @Provides + @IntoMap + @ViewModelKey(SetupStakingTypeViewModel::class) + fun provideViewModel( + stakingRouter: StakingRouter, + assetUseCase: ArbitraryAssetUseCase, + payload: SetupStakingTypePayload, + @MultiStakingSelectionStoreProviderKey currentSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + @StakingTypeEditingStoreProviderKey editableSelectionStoreProvider: StartMultiStakingSelectionStoreProvider, + editableStakingTypeItemFormatter: EditableStakingTypeItemFormatter, + compoundStakingTypeDetailsProvidersFactory: CompoundStakingTypeDetailsProvidersFactory, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + setupStakingTypeFlowExecutorFactory: SetupStakingTypeFlowExecutorFactory, + setupStakingTypeSelectionMixinFactory: SetupStakingTypeSelectionMixinFactory, + chainRegistry: ChainRegistry, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + amountFormatter: AmountFormatter + ): ViewModel { + return SetupStakingTypeViewModel( + stakingRouter, + assetUseCase, + payload, + currentSelectionStoreProvider, + editableSelectionStoreProvider, + editableStakingTypeItemFormatter, + compoundStakingTypeDetailsProvidersFactory, + resourceManager, + validationExecutor, + setupStakingTypeFlowExecutorFactory, + setupStakingTypeSelectionMixinFactory, + actionAwaitableMixinFactory, + chainRegistry + ) + } + + @Provides + fun viewModelCreator( + fragment: Fragment, + viewModelProviderFactory: ViewModelProvider.Factory + ): SetupStakingTypeViewModel { + return ViewModelProvider(fragment, viewModelProviderFactory).get(SetupStakingTypeViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/ValidationFailure.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/ValidationFailure.kt new file mode 100644 index 0000000..d87648a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/ValidationFailure.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.asDefault +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationFailure +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationPayload +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleWith +import io.novafoundation.nova.feature_wallet_api.domain.validation.zeroAmount +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission + +fun unbondValidationFailure( + status: ValidationStatus.NotValid, + flowActions: ValidationFlowActions, + resourceManager: ResourceManager +): TransformedFailure { + return when (val reason = status.reason) { + is UnbondValidationFailure.BondedWillCrossExistential -> reason.handleWith(resourceManager, flowActions) { old, newAmount -> + old.copy(amount = newAmount) + } + + UnbondValidationFailure.CannotPayFees -> TransformedFailure.Default( + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.common_not_enough_funds_message) + ) + + UnbondValidationFailure.NotEnoughBonded -> TransformedFailure.Default( + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.staking_unbond_too_big) + ) + + is UnbondValidationFailure.UnbondLimitReached -> TransformedFailure.Default( + resourceManager.getString(R.string.staking_unbonding_limit_reached_title) to + resourceManager.getString(R.string.staking_unbonding_limit_reached_message, reason.limit) + ) + + UnbondValidationFailure.ZeroUnbond -> resourceManager.zeroAmount().asDefault() + + is UnbondValidationFailure.NotEnoughBalanceToStayAboveED -> handleInsufficientBalanceCommission( + reason, + resourceManager + ).asDefault() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/ConfirmUnbondFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/ConfirmUnbondFragment.kt new file mode 100644 index 0000000..4c72e26 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/ConfirmUnbondFragment.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmUnbondBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent + +private const val PAYLOAD_KEY = "PAYLOAD_KEY" + +class ConfirmUnbondFragment : BaseFragment() { + + companion object { + + fun getBundle(payload: ConfirmUnbondPayload) = Bundle().apply { + putParcelable(PAYLOAD_KEY, payload) + } + } + + override fun createBinding() = FragmentConfirmUnbondBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmUnbondExtrinsicInformation.setOnAccountClickedListener { viewModel.originAccountClicked() } + + binder.confirmUnbondToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.confirmUnbondConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmUnbondConfirm.setOnClickListener { viewModel.confirmClicked() } + } + + override fun inject() { + val payload = argument(PAYLOAD_KEY) + + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmUnbondFactory() + .create(this, payload) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmUnbondViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeHints(viewModel.hintsMixin, binder.confirmUnbondHints) + + viewModel.amountModelFlow.observe(binder.confirmUnbondAmount::setAmount) + + viewModel.showNextProgress.observe(binder.confirmUnbondConfirm::setProgressState) + + viewModel.walletUiFlow.observe(binder.confirmUnbondExtrinsicInformation::setWallet) + viewModel.feeStatusLiveData.observe(binder.confirmUnbondExtrinsicInformation::setFeeStatus) + viewModel.originAddressModelFlow.observe(binder.confirmUnbondExtrinsicInformation::setAccount) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/ConfirmUnbondPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/ConfirmUnbondPayload.kt new file mode 100644 index 0000000..f100bf7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/ConfirmUnbondPayload.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeParcelModel +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +class ConfirmUnbondPayload( + val amount: BigDecimal, + val fee: FeeParcelModel +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/ConfirmUnbondViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/ConfirmUnbondViewModel.kt new file mode 100644 index 0000000..1212529 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/ConfirmUnbondViewModel.kt @@ -0,0 +1,149 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.UnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.hints.UnbondHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.unbondValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapFeeToFeeModel +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeFromParcel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmUnbondViewModel( + private val router: StakingRouter, + interactor: StakingInteractor, + private val unbondInteractor: UnbondInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val iconGenerator: AddressIconGenerator, + private val validationSystem: UnbondValidationSystem, + private val externalActions: ExternalActions.Presentation, + private val payload: ConfirmUnbondPayload, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + unbondHintsMixinFactory: UnbondHintsMixinFactory, + walletUiUseCase: WalletUiUseCase, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val decimalFee = mapFeeFromParcel(payload.fee) + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + val hintsMixin = unbondHintsMixinFactory.create(coroutineScope = this) + + private val accountStakingFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .inBackground() + .share() + + private val assetFlow = accountStakingFlow.flatMapLatest { + interactor.assetFlow(it.controllerAddress) + } + .inBackground() + .share() + + val walletUiFlow = walletUiUseCase.selectedWalletUiFlow() + .shareInBackground() + + val amountModelFlow = assetFlow.map { asset -> + amountFormatter.formatAmountToAmountModel(payload.amount, asset) + } + .shareInBackground() + + val feeStatusLiveData = assetFlow.map { asset -> + val feeModel = mapFeeToFeeModel(decimalFee, asset.token, amountFormatter = amountFormatter) + + FeeStatus.Loaded(feeModel) + } + .inBackground() + .asLiveData() + + val originAddressModelFlow = accountStakingFlow.map { + iconGenerator.createAccountAddressModel(it.chain, it.controllerAddress) + } + .shareInBackground() + + fun confirmClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() { + launch { + val address = originAddressModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + } + + private fun maybeGoToNext() = launch { + val asset = assetFlow.first() + + val payload = UnbondValidationPayload( + asset = asset, + stash = accountStakingFlow.first(), + fee = decimalFee, + amount = payload.amount, + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { status, flowActions -> unbondValidationFailure(status, flowActions, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { validPayload -> + sendTransaction(validPayload) + } + } + + private fun sendTransaction(validPayload: UnbondValidationPayload) = launch { + val amountInPlanks = validPayload.asset.token.configuration.planksFromAmount(payload.amount) + + unbondInteractor.unbond(validPayload.stash, validPayload.asset.bondedInPlanks, amountInPlanks) + .onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { router.returnToStakingMain() } + } + .onFailure(::showError) + + _showNextProgress.value = false + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/di/ConfirmUnbondComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/di/ConfirmUnbondComponent.kt new file mode 100644 index 0000000..db38512 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/di/ConfirmUnbondComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.ConfirmUnbondFragment +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.ConfirmUnbondPayload + +@Subcomponent( + modules = [ + ConfirmUnbondModule::class + ] +) +@ScreenScope +interface ConfirmUnbondComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: ConfirmUnbondPayload, + ): ConfirmUnbondComponent + } + + fun inject(fragment: ConfirmUnbondFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/di/ConfirmUnbondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/di/ConfirmUnbondModule.kt new file mode 100644 index 0000000..1b02f28 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/confirm/di/ConfirmUnbondModule.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.UnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.ConfirmUnbondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.ConfirmUnbondViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.hints.UnbondHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ConfirmUnbondModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmUnbondViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + unbondInteractor: UnbondInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: UnbondValidationSystem, + iconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + payload: ConfirmUnbondPayload, + singleAssetSharedState: StakingSharedState, + unbondHintsMixinFactory: UnbondHintsMixinFactory, + walletUiUseCase: WalletUiUseCase, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + amountFormatter: AmountFormatter + ): ViewModel { + return ConfirmUnbondViewModel( + router = router, + interactor = interactor, + unbondInteractor = unbondInteractor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + iconGenerator = iconGenerator, + validationSystem = validationSystem, + externalActions = externalActions, + payload = payload, + selectedAssetState = singleAssetSharedState, + unbondHintsMixinFactory = unbondHintsMixinFactory, + walletUiUseCase = walletUiUseCase, + extrinsicNavigationWrapper = extrinsicNavigationWrapper, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ConfirmUnbondViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmUnbondViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/hints/UnbondHintsMixin.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/hints/UnbondHintsMixin.kt new file mode 100644 index 0000000..2bccdf2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/hints/UnbondHintsMixin.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.hints + +import io.novafoundation.nova.common.mixin.hints.ConstantHintsMixin +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.feature_staking_impl.presentation.common.hints.StakingHintsUseCase +import kotlinx.coroutines.CoroutineScope + +class UnbondHintsMixinFactory( + private val stakingHintsUseCase: StakingHintsUseCase, +) { + + fun create(coroutineScope: CoroutineScope,): HintsMixin = UnbondHintsMixin( + coroutineScope = coroutineScope, + stakingHintsUseCase = stakingHintsUseCase + ) +} + +private class UnbondHintsMixin( + coroutineScope: CoroutineScope, + private val stakingHintsUseCase: StakingHintsUseCase, +) : ConstantHintsMixin(coroutineScope) { + + override suspend fun getHints(): List = listOf( + stakingHintsUseCase.unstakingDurationHint(coroutineScope), + stakingHintsUseCase.noRewardDurationUnstakingHint(), + stakingHintsUseCase.redeemHint(), + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/SelectUnbondFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/SelectUnbondFragment.kt new file mode 100644 index 0000000..3aca513 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/SelectUnbondFragment.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.select + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentSelectUnbondBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooser +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount + +class SelectUnbondFragment : BaseFragment() { + + override fun createBinding() = FragmentSelectUnbondBinding.inflate(layoutInflater) + + override fun initViews() { + binder.unbondToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.unbondContinue.prepareForProgress(viewLifecycleOwner) + binder.unbondContinue.setOnClickListener { viewModel.nextClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .selectUnbondFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SelectUnbondViewModel) { + observeValidations(viewModel) + setupFeeLoading(viewModel.originFeeMixin, binder.unbondFee) + observeHints(viewModel.hintsMixin, binder.unbondHints) + setupAmountChooser(viewModel.amountMixin, binder.unbondAmount) + + viewModel.transferableFlow.observe(binder.unbondTransferable::showAmount) + + viewModel.showNextProgress.observe(binder.unbondContinue::setProgressState) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/SelectUnbondViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/SelectUnbondViewModel.kt new file mode 100644 index 0000000..43bd92a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/SelectUnbondViewModel.kt @@ -0,0 +1,142 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.select + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.UnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.confirm.ConfirmUnbondPayload +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.hints.UnbondHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.unbondValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.transferableAmountModelOf +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.mapFeeToParcel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.connectWith +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefault +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch + +class SelectUnbondViewModel( + private val router: StakingRouter, + private val interactor: StakingInteractor, + private val unbondInteractor: UnbondInteractor, + private val resourceManager: ResourceManager, + private val validationExecutor: ValidationExecutor, + private val validationSystem: UnbondValidationSystem, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val amountFormatter: AmountFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + unbondHintsMixinFactory: UnbondHintsMixinFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory +) : BaseViewModel(), + Validatable by validationExecutor { + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + private val accountStakingFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .shareInBackground() + + private val assetFlow = accountStakingFlow + .flatMapLatest { interactor.assetFlow(it.controllerAddress) } + .shareInBackground() + + private val chainAssetFlow = assetFlow.map { it.token.configuration } + .shareInBackground() + + val transferableFlow = assetFlow.mapLatest { transferableAmountModelOf(amountFormatter, it) } + .shareInBackground() + + val hintsMixin = unbondHintsMixinFactory.create(coroutineScope = this) + + val originFeeMixin = feeLoaderMixinFactory.createDefault( + this, + chainAssetFlow, + FeeLoaderMixinV2.Configuration(onRetryCancelled = ::backClicked) + ) + + private val maxActionProvider = maxActionProviderFactory.createCustom(viewModelScope) { + assetFlow.providingMaxOf(Asset::bondedInPlanks) + } + + val amountMixin = amountChooserMixinFactory.create( + scope = this, + assetFlow = assetFlow, + maxActionProvider = maxActionProvider + ) + + init { + listenFee() + } + + fun nextClicked() { + maybeGoToNext() + } + + fun backClicked() { + router.back() + } + + private fun listenFee() { + originFeeMixin.connectWith( + inputSource1 = amountMixin.backPressuredPlanks, + feeConstructor = { _, amount -> + val asset = assetFlow.first() + + unbondInteractor.estimateFee(accountStakingFlow.first(), asset.bondedInPlanks, amount) + } + ) + } + + private fun maybeGoToNext() = launch { + _showNextProgress.value = true + + val asset = assetFlow.first() + + val payload = UnbondValidationPayload( + stash = accountStakingFlow.first(), + asset = asset, + fee = originFeeMixin.awaitFee(), + amount = amountMixin.amount.first(), + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformerCustom = { status, flowActions -> unbondValidationFailure(status, flowActions, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { correctPayload -> + _showNextProgress.value = false + + openConfirm(correctPayload) + } + } + + private fun openConfirm(validationPayload: UnbondValidationPayload) { + val confirmUnbondPayload = ConfirmUnbondPayload( + amount = validationPayload.amount, + fee = mapFeeToParcel(validationPayload.fee) + ) + + router.openConfirmUnbond(confirmUnbondPayload) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/di/SelectUnbondComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/di/SelectUnbondComponent.kt new file mode 100644 index 0000000..0fdbabd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/di/SelectUnbondComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.select.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.select.SelectUnbondFragment + +@Subcomponent( + modules = [ + SelectUnbondModule::class + ] +) +@ScreenScope +interface SelectUnbondComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): SelectUnbondComponent + } + + fun inject(fragment: SelectUnbondFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/di/SelectUnbondModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/di/SelectUnbondModule.kt new file mode 100644 index 0000000..77332c8 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/staking/unbond/select/di/SelectUnbondModule.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.select.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.unbond.UnbondInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.unbond.UnbondValidationSystem +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.hints.UnbondHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.staking.unbond.select.SelectUnbondViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class SelectUnbondModule { + + @Provides + @IntoMap + @ViewModelKey(SelectUnbondViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + unbondInteractor: UnbondInteractor, + resourceManager: ResourceManager, + validationExecutor: ValidationExecutor, + validationSystem: UnbondValidationSystem, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + maxActionProviderFactory: MaxActionProviderFactory, + unbondHintsMixinFactory: UnbondHintsMixinFactory, + amountChooserMixinFactory: AmountChooserMixin.Factory, + amountFormatter: AmountFormatter + ): ViewModel { + return SelectUnbondViewModel( + router = router, + interactor = interactor, + unbondInteractor = unbondInteractor, + resourceManager = resourceManager, + validationExecutor = validationExecutor, + validationSystem = validationSystem, + feeLoaderMixinFactory = feeLoaderMixinFactory, + maxActionProviderFactory = maxActionProviderFactory, + unbondHintsMixinFactory = unbondHintsMixinFactory, + amountChooserMixinFactory = amountChooserMixinFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectUnbondViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectUnbondViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/Common.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/Common.kt new file mode 100644 index 0000000..4899fbd --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/Common.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators + +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +suspend fun List.findSelectedValidator(accountIdHex: String) = withContext(Dispatchers.Default) { + firstOrNull { it.accountIdHex == accountIdHex } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/StakeTargetAdapter.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/StakeTargetAdapter.kt new file mode 100644 index 0000000..8224616 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/StakeTargetAdapter.kt @@ -0,0 +1,187 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.PayloadGenerator +import io.novafoundation.nova.common.list.resolvePayload +import io.novafoundation.nova.common.presentation.setColoredText +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ItemValidatorBinding +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.StakeTargetModel + +class StakeTargetAdapter( + private val itemHandler: ItemHandler, + initialMode: Mode = Mode.VIEW +) : ListAdapter, StakingTargetViewHolder>(StakingTargetDiffCallback()) { + + private var mode = initialMode + + interface ItemHandler { + + fun stakeTargetInfoClicked(stakeTargetModel: StakeTargetModel) + + fun stakeTargetClicked(stakeTargetModel: StakeTargetModel) { + // default empty + } + + fun removeClicked(StakeTargetModel: StakeTargetModel) { + // default empty + } + } + + enum class Mode { + VIEW, EDIT + } + + fun modeChanged(newMode: Mode) { + mode = newMode + + notifyItemRangeChanged(0, itemCount, mode) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StakingTargetViewHolder { + return StakingTargetViewHolder(ItemValidatorBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: StakingTargetViewHolder, position: Int) { + val item = getItem(position) + + holder.bind(item, itemHandler, mode) + } + + override fun onBindViewHolder(holder: StakingTargetViewHolder, position: Int, payloads: MutableList) { + val item = getItem(position) + + resolvePayload( + holder, + position, + payloads, + onUnknownPayload = { holder.bindIcon(mode, item, itemHandler) }, + onDiffCheck = { + when (it) { + StakeTargetModel<*>::isChecked -> holder.bindIcon(mode, item, itemHandler) + StakeTargetModel<*>::scoring -> holder.bindScoring(item) + StakeTargetModel<*>::subtitle -> holder.bindSubtitle(item) + } + } + ) + } +} + +class StakingTargetViewHolder(private val binder: ItemValidatorBinding) : RecyclerView.ViewHolder(binder.root) { + + fun bind( + stakeTargetModel: StakeTargetModel, + itemHandler: StakeTargetAdapter.ItemHandler, + mode: StakeTargetAdapter.Mode + ) = with(binder) { + itemStakingTargetName.text = stakeTargetModel.addressModel.nameOrAddress + itemStakingTargetIcon.setImageDrawable(stakeTargetModel.addressModel.image) + + itemStakingTargetInfo.setOnClickListener { + itemHandler.stakeTargetInfoClicked(stakeTargetModel) + } + + root.setOnClickListener { + itemHandler.stakeTargetClicked(stakeTargetModel) + } + + bindIcon(mode, stakeTargetModel, itemHandler) + + bindScoring(stakeTargetModel) + bindSubtitle(stakeTargetModel) + } + + fun bindIcon( + mode: StakeTargetAdapter.Mode, + StakeTargetModel: StakeTargetModel, + handler: StakeTargetAdapter.ItemHandler + ) = with(binder) { + when { + mode == StakeTargetAdapter.Mode.EDIT -> { + itemStakingTargetActionIcon.makeVisible() + itemStakingTargetCheck.makeGone() + + itemStakingTargetActionIcon.setOnClickListener { handler.removeClicked(StakeTargetModel) } + } + + StakeTargetModel.isChecked == null -> { + itemStakingTargetActionIcon.makeGone() + itemStakingTargetCheck.makeGone() + } + + else -> { + itemStakingTargetActionIcon.makeGone() + itemStakingTargetCheck.makeVisible() + + itemStakingTargetCheck.isChecked = StakeTargetModel.isChecked + } + } + } + + fun bindScoring(StakeTargetModel: StakeTargetModel<*>) = with(binder) { + when (val scoring = StakeTargetModel.scoring) { + null -> { + itemStakingTargetScoringPrimary.makeGone() + itemStakingTargetScoringSecondary.makeGone() + } + + is StakeTargetModel.Scoring.OneField -> { + itemStakingTargetScoringPrimary.setTextColorRes(R.color.text_secondary) + itemStakingTargetScoringPrimary.makeVisible() + itemStakingTargetScoringSecondary.makeGone() + itemStakingTargetScoringPrimary.setColoredText(scoring.field) + } + + is StakeTargetModel.Scoring.TwoFields -> { + itemStakingTargetScoringPrimary.setTextColorRes(R.color.text_primary) + itemStakingTargetScoringPrimary.makeVisible() + itemStakingTargetScoringSecondary.makeVisible() + itemStakingTargetScoringPrimary.text = scoring.primary + itemStakingTargetScoringSecondary.text = scoring.secondary + } + } + } + + fun bindSubtitle(item: StakeTargetModel<*>) = with(binder) { + if (item.subtitle != null) { + itemStakingTargetSubtitleLabel.makeVisible() + itemStakingTargetSubtitleValue.makeVisible() + + itemStakingTargetSubtitleLabel.text = item.subtitle.label + + itemStakingTargetSubtitleValue.text = item.subtitle.value.text + itemStakingTargetSubtitleValue.setTextColorRes(item.subtitle.value.colorRes) + } else { + itemStakingTargetSubtitleValue.makeGone() + itemStakingTargetSubtitleLabel.makeGone() + } + } +} + +class StakingTargetDiffCallback : DiffUtil.ItemCallback>() { + + override fun areItemsTheSame(oldItem: StakeTargetModel, newItem: StakeTargetModel): Boolean { + return oldItem.accountIdHex == newItem.accountIdHex + } + + override fun areContentsTheSame(oldItem: StakeTargetModel, newItem: StakeTargetModel): Boolean { + return oldItem.scoring == newItem.scoring && oldItem.isChecked == newItem.isChecked && oldItem.subtitle == newItem.subtitle + } + + override fun getChangePayload(oldItem: StakeTargetModel, newItem: StakeTargetModel): Any? { + return StakingTargetPayloadGenerator.diff(oldItem, newItem) + } +} + +private object StakingTargetPayloadGenerator : PayloadGenerator>( + StakeTargetModel<*>::isChecked, + StakeTargetModel<*>::scoring, + StakeTargetModel<*>::subtitle +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/ChangeStakingValidationFailureUI.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/ChangeStakingValidationFailureUI.kt new file mode 100644 index 0000000..3b9667a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/ChangeStakingValidationFailureUI.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.ChangeStackingValidationFailure + +fun mapAddEvmTokensValidationFailureToUI( + resourceManager: ResourceManager, + failure: ChangeStackingValidationFailure +): TitleAndMessage { + return when (failure) { + ChangeStackingValidationFailure.NO_ACCESS_TO_CONTROLLER_ACCOUNT -> { + resourceManager.getString(R.string.staking_no_access_to_controller_account_title) to + resourceManager.getString(R.string.staking_no_access_to_controller_account_message) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/StateTransitions.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/StateTransitions.kt new file mode 100644 index 0000000..67d0081 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/StateTransitions.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change + +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess.ReadyToSubmit.SelectionMethod +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +fun SetupStakingSharedState.reset() = mutate { + SetupStakingProcess.Initial +} + +fun SetupStakingSharedState.retractRecommended() = mutate { + if (it is SetupStakingProcess.ReadyToSubmit && it.selectionMethod == SelectionMethod.RECOMMENDED) { + it.previous() + } else { + it + } +} + +fun SetupStakingSharedState.setCustomValidators( + validators: List +) = setValidators(validators, SelectionMethod.CUSTOM) + +fun SetupStakingSharedState.setRecommendedValidators( + validators: List +) = setValidators(validators, SelectionMethod.RECOMMENDED) + +fun SetupStakingSharedState.activeStake(): Balance { + return when (val state = setupStakingProcess.value) { + is SetupStakingProcess.ReadyToSubmit -> state.activeStake + is SetupStakingProcess.ChoosingValidators -> state.activeStake + SetupStakingProcess.Initial -> throw IllegalArgumentException("Cannot get active stake from $state state") + } +} + +/** + * Validators that has been selected by user during current flow + * Does not count currently selected validators + */ +fun SetupStakingSharedState.getNewValidators(): List { + return when (val process = setupStakingProcess.value) { + is SetupStakingProcess.ReadyToSubmit -> process.newValidators + SetupStakingProcess.Initial, is SetupStakingProcess.ChoosingValidators -> { + throw IllegalArgumentException("Cannot get validators from $process state") + } + } +} + +/** + * Validators that should be shown for the user as selected + * It is either its already updated selection during current flow + * or its currently selected validators (based on on-chain nominations) + */ +fun SetupStakingProcess.getSelectedValidatorsOrNull(): List? { + return when (this) { + is SetupStakingProcess.ReadyToSubmit -> newValidators + is SetupStakingProcess.ChoosingValidators -> currentlySelectedValidators + SetupStakingProcess.Initial -> null + } +} + +fun SetupStakingProcess.getSelectedValidatorsOrEmpty(): List { + return getSelectedValidatorsOrNull().orEmpty() +} + +private fun SetupStakingSharedState.setValidators( + validators: List, + selectionMethod: SelectionMethod +) = mutate { + when (it) { + is SetupStakingProcess.ReadyToSubmit -> it.changeValidators(validators, selectionMethod) + is SetupStakingProcess.ChoosingValidators -> it.next(validators, selectionMethod) + SetupStakingProcess.Initial -> throw IllegalArgumentException("Cannot set validators from $it state") + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/ValidatorModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/ValidatorModel.kt new file mode 100644 index 0000000..e632279 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/ValidatorModel.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.presentation.ColoredText +import io.novafoundation.nova.feature_staking_api.domain.model.Validator + +typealias ValidatorStakeTargetModel = StakeTargetModel + +data class StakeTargetModel( + val accountIdHex: String, + val slashed: Boolean, + val scoring: Scoring?, + val subtitle: Subtitle?, + val addressModel: AddressModel, + val isChecked: Boolean?, + val stakeTarget: V, +) { + + data class Subtitle( + val label: String, + val value: ColoredText + ) + + sealed class Scoring { + class OneField(val field: ColoredText) : Scoring() + + class TwoFields(val primary: String, val secondary: String?) : Scoring() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/ConfirmChangeValidatorsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/ConfirmChangeValidatorsFragment.kt new file mode 100644 index 0000000..8d08872 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/ConfirmChangeValidatorsFragment.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.hints.observeHints +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmChangeValidatorsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading + +class ConfirmChangeValidatorsFragment : BaseFragment() { + + override fun createBinding() = FragmentConfirmChangeValidatorsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.confirmChangeValidatorsToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.confirmChangeValidatorsAccount.setOnClickListener { viewModel.originAccountClicked() } + + binder.confirmChangeValidatorsConfirm.prepareForProgress(viewLifecycleOwner) + binder.confirmChangeValidatorsConfirm.setOnClickListener { viewModel.confirmClicked() } + + binder.confirmChangeValidatorsValidators.setOnClickListener { viewModel.nominationsClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmStakingComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmChangeValidatorsViewModel) { + observeRetries(viewModel) + observeValidations(viewModel) + setupExternalActions(viewModel) + setupFeeLoading(viewModel, binder.confirmChangeValidatorsFee) + observeHints(viewModel.hintsMixin, binder.confirmChangeValidatorsHints) + + viewModel.showNextProgress.observe(binder.confirmChangeValidatorsConfirm::setProgressState) + + viewModel.currentAccountModelFlow.observe(binder.confirmChangeValidatorsAccount::showAddress) + viewModel.walletFlow.observe(binder.confirmChangeValidatorsWallet::showWallet) + + viewModel.nominationsFlow.observe { + binder.confirmChangeValidatorsValidators.showValue(it) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/ConfirmChangeValidatorsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/ConfirmChangeValidatorsViewModel.kt new file mode 100644 index 0000000..513367a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/ConfirmChangeValidatorsViewModel.kt @@ -0,0 +1,187 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.address.createSubstrateAddressModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper + +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.setup.ChangeValidatorsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.common.validation.stakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.activeStake +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.hints.ConfirmStakeHintsMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.reset +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.awaitFee +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ConfirmChangeValidatorsViewModel( + private val router: StakingRouter, + private val interactor: StakingInteractor, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val validationSystem: ValidationSystem, + private val setupStakingSharedState: SetupStakingSharedState, + private val changeValidatorsInteractor: ChangeValidatorsInteractor, + private val feeLoaderMixin: FeeLoaderMixin.Presentation, + private val externalActions: ExternalActions.Presentation, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + walletUiUseCase: WalletUiUseCase, + hintsMixinFactory: ConfirmStakeHintsMixinFactory, +) : BaseViewModel(), + Retriable, + Validatable by validationExecutor, + FeeLoaderMixin by feeLoaderMixin, + ExternalActions by externalActions, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val maxValidatorsPerNominator by lazyAsync { + interactor.maxValidatorsPerNominator(setupStakingSharedState.activeStake()) + } + + private val currentProcessState = setupStakingSharedState.get() + + val hintsMixin = hintsMixinFactory.create(coroutineScope = this) + + private val stashFlow = interactor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .inBackground() + .share() + + private val controllerAddressFlow = stashFlow.map { it.controllerAddress } + .shareInBackground() + + private val controllerAssetFlow = controllerAddressFlow + .flatMapLatest(interactor::assetFlow) + .shareInBackground() + + val walletFlow = walletUiUseCase.selectedWalletUiFlow() + .inBackground() + .share() + + val currentAccountModelFlow = controllerAddressFlow.map { + generateDestinationModel(it, name = null) + } + .inBackground() + .share() + + val nominationsFlow = flowOf { + val selectedCount = currentProcessState.newValidators.size + val maxValidatorsPerNominator = maxValidatorsPerNominator() + + resourceManager.getString(R.string.staking_confirm_nominations, selectedCount, maxValidatorsPerNominator) + } + + private val _showNextProgress = MutableLiveData(false) + val showNextProgress: LiveData = _showNextProgress + + init { + loadFee() + } + + fun confirmClicked() { + sendTransactionIfValid() + } + + fun backClicked() { + router.back() + } + + fun originAccountClicked() = launch { + val address = currentAccountModelFlow.first().address + + externalActions.showAddressActions(address, selectedAssetState.chain()) + } + + fun nominationsClicked() { + router.openConfirmNominations() + } + + private fun loadFee() { + feeLoaderMixin.loadFee( + coroutineScope = viewModelScope, + feeConstructor = { changeValidatorsInteractor.estimateFee(prepareNominations(), stashFlow.first()) }, + onRetryCancelled = ::backClicked + ) + } + + private fun prepareNominations() = currentProcessState.newValidators.map(Validator::accountIdHex) + + private fun sendTransactionIfValid() = launch { + _showNextProgress.value = true + + val payload = SetupStakingPayload( + maxFee = feeLoaderMixin.awaitFee(), + controllerAsset = controllerAssetFlow.first(), + chain = stashFlow.first().chain + ) + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + validationFailureTransformer = { stakingValidationFailure(it, resourceManager) }, + progressConsumer = _showNextProgress.progressConsumer() + ) { + sendTransaction() + } + } + + private fun sendTransaction() = launch { + changeValidatorsInteractor.changeValidators( + stakingState = stashFlow.first(), + validatorAccountIds = prepareNominations(), + ).onSuccess { + showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + setupStakingSharedState.reset() + + startNavigation(it.submissionHierarchy) { router.returnToCurrentValidators() } + }.onFailure { + showError(it) + } + + _showNextProgress.value = false + } + + private suspend fun generateDestinationModel(address: String, name: String?): AddressModel { + return addressIconGenerator.createSubstrateAddressModel( + accountAddress = address, + sizeInDp = AddressIconGenerator.SIZE_MEDIUM, + accountName = name, + background = AddressIconGenerator.BACKGROUND_TRANSPARENT + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/di/ConfirmChangeValidatorsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/di/ConfirmChangeValidatorsComponent.kt new file mode 100644 index 0000000..0fcec36 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/di/ConfirmChangeValidatorsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.ConfirmChangeValidatorsFragment + +@Subcomponent( + modules = [ + ConfirmChangeValidatorsModule::class + ] +) +@ScreenScope +interface ConfirmChangeValidatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): ConfirmChangeValidatorsComponent + } + + fun inject(fragment: ConfirmChangeValidatorsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/di/ConfirmChangeValidatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/di/ConfirmChangeValidatorsModule.kt new file mode 100644 index 0000000..875cea7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/di/ConfirmChangeValidatorsModule.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.setup.ChangeValidatorsInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingPayload +import io.novafoundation.nova.feature_staking_impl.domain.validations.setup.SetupStakingValidationFailure +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.ConfirmChangeValidatorsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.hints.ConfirmStakeHintsMixinFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin + +@Module(includes = [ViewModelModule::class]) +class ConfirmChangeValidatorsModule { + + @Provides + @ScreenScope + fun provideConfirmStakeHintsMixinFactory( + resourceManager: ResourceManager, + ): ConfirmStakeHintsMixinFactory { + return ConfirmStakeHintsMixinFactory(resourceManager) + } + + @Provides + @IntoMap + @ViewModelKey(ConfirmChangeValidatorsViewModel::class) + fun provideViewModel( + interactor: StakingInteractor, + router: StakingRouter, + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + changeValidatorsInteractor: ChangeValidatorsInteractor, + validationSystem: ValidationSystem, + validationExecutor: ValidationExecutor, + setupStakingSharedState: SetupStakingSharedState, + feeLoaderMixin: FeeLoaderMixin.Presentation, + externalActions: ExternalActions.Presentation, + singleAssetSharedState: StakingSharedState, + walletUiUseCase: WalletUiUseCase, + hintsMixinFactory: ConfirmStakeHintsMixinFactory, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + ): ViewModel { + return ConfirmChangeValidatorsViewModel( + router = router, + interactor = interactor, + addressIconGenerator = addressIconGenerator, + resourceManager = resourceManager, + validationSystem = validationSystem, + setupStakingSharedState = setupStakingSharedState, + changeValidatorsInteractor = changeValidatorsInteractor, + feeLoaderMixin = feeLoaderMixin, + externalActions = externalActions, + selectedAssetState = singleAssetSharedState, + validationExecutor = validationExecutor, + walletUiUseCase = walletUiUseCase, + hintsMixinFactory = hintsMixinFactory, + extrinsicNavigationWrapper = extrinsicNavigationWrapper + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory, + ): ConfirmChangeValidatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmChangeValidatorsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/hints/ConfirmStakeHintsMixin.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/hints/ConfirmStakeHintsMixin.kt new file mode 100644 index 0000000..2ae321f --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/hints/ConfirmStakeHintsMixin.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.hints + +import io.novafoundation.nova.common.mixin.hints.ConstantHintsMixin +import io.novafoundation.nova.common.mixin.hints.HintsMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.R +import kotlinx.coroutines.CoroutineScope + +class ConfirmStakeHintsMixinFactory( + private val resourceManager: ResourceManager, +) { + + fun create(coroutineScope: CoroutineScope): HintsMixin = ConfirmStakeHintsMixin( + resourceManager = resourceManager, + coroutineScope = coroutineScope, + ) +} + +private class ConfirmStakeHintsMixin( + private val resourceManager: ResourceManager, + coroutineScope: CoroutineScope +) : ConstantHintsMixin(coroutineScope) { + + override suspend fun getHints(): List { + return changeValidatorsHints() + } + + private fun changeValidatorsHints(): List = listOf( + validatorsChangeHint() + ) + + private fun validatorsChangeHint(): String { + return resourceManager.getString(R.string.staking_your_validators_changing_title) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/ConfirmNominationsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/ConfirmNominationsFragment.kt new file mode 100644 index 0000000..9396371 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/ConfirmNominationsFragment.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.nominations + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentConfirmNominationsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.StakeTargetAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel + +class ConfirmNominationsFragment : BaseFragment(), StakeTargetAdapter.ItemHandler { + + lateinit var adapter: StakeTargetAdapter + + override fun createBinding() = FragmentConfirmNominationsBinding.inflate(layoutInflater) + + override fun initViews() { + adapter = StakeTargetAdapter(this) + binder.confirmNominationsList.adapter = adapter + + binder.confirmNominationsList.setHasFixedSize(true) + + binder.confirmNominationsToolbar.setHomeButtonListener { + viewModel.backClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .confirmNominationsComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: ConfirmNominationsViewModel) { + viewModel.selectedValidatorsLiveData.observe(adapter::submitList) + + viewModel.toolbarTitle.observe(binder.confirmNominationsToolbar::setTitle) + } + + override fun stakeTargetInfoClicked(validatorModel: ValidatorStakeTargetModel) { + viewModel.validatorInfoClicked(validatorModel) + } + + override fun stakeTargetClicked(validatorModel: ValidatorStakeTargetModel) { + viewModel.validatorInfoClicked(validatorModel) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/ConfirmNominationsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/ConfirmNominationsViewModel.kt new file mode 100644 index 0000000..f8d3a35 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/ConfirmNominationsViewModel.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.nominations + +import androidx.lifecycle.liveData +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.relaychain +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ConfirmNominationsViewModel( + private val interactor: StakingInteractor, + private val router: StakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val resourceManager: ResourceManager, + private val sharedStateSetup: SetupStakingSharedState, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val tokenUseCase: TokenUseCase +) : BaseViewModel() { + + private val currentSetupStakingProcess = sharedStateSetup.get() + + private val validators = currentSetupStakingProcess.newValidators + + val selectedValidatorsLiveData = liveData(Dispatchers.Default) { + emit(convertToModels(validators, tokenUseCase.currentToken())) + } + + val toolbarTitle = selectedValidatorsLiveData.map { + resourceManager.getString(R.string.staking_selected_validators_mask, it.size) + } + + fun backClicked() { + router.back() + } + + fun validatorInfoClicked(validatorModel: ValidatorStakeTargetModel) { + viewModelScope.launch { + val stakeTarget = mapValidatorToValidatorDetailsParcelModel(validatorModel.stakeTarget) + val payload = StakeTargetDetailsPayload.relaychain(stakeTarget, interactor) + + router.openValidatorDetails(payload) + } + } + + private suspend fun convertToModels( + validators: List, + token: Token, + ): List { + val chain = selectedAssetState.chain() + + return validators.map { + mapValidatorToValidatorModel(chain, it, addressIconGenerator, token) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/di/ConfirmNominationsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/di/ConfirmNominationsComponent.kt new file mode 100644 index 0000000..126c34e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/di/ConfirmNominationsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.nominations.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.nominations.ConfirmNominationsFragment + +@Subcomponent( + modules = [ + ConfirmNominationsModule::class + ] +) +@ScreenScope +interface ConfirmNominationsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): ConfirmNominationsComponent + } + + fun inject(fragment: ConfirmNominationsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/di/ConfirmNominationsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/di/ConfirmNominationsModule.kt new file mode 100644 index 0000000..a21221c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/confirm/nominations/di/ConfirmNominationsModule.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.nominations.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.confirm.nominations.ConfirmNominationsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase + +@Module(includes = [ViewModelModule::class]) +class ConfirmNominationsModule { + + @Provides + @IntoMap + @ViewModelKey(ConfirmNominationsViewModel::class) + fun provideViewModel( + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + router: StakingRouter, + setupStakingSharedState: SetupStakingSharedState, + tokenUseCase: TokenUseCase, + selectedAssetState: StakingSharedState, + stakingInteractor: StakingInteractor, + ): ViewModel { + return ConfirmNominationsViewModel( + stakingInteractor, + router, + addressIconGenerator, + resourceManager, + setupStakingSharedState, + selectedAssetState, + tokenUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ConfirmNominationsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ConfirmNominationsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/common/CustomValidatorsPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/common/CustomValidatorsPayload.kt new file mode 100644 index 0000000..76c0e6b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/common/CustomValidatorsPayload.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class CustomValidatorsPayload( + val flowType: FlowType +) : Parcelable { + + enum class FlowType { + SETUP_STAKING_VALIDATORS, + CHANGE_STAKING_VALIDATORS + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/ReviewCustomValidatorsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/ReviewCustomValidatorsFragment.kt new file mode 100644 index 0000000..ebd73fc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/ReviewCustomValidatorsFragment.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentReviewCustomValidatorsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.StakeTargetAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.validators.StakeTargetAdapter.Mode.EDIT +import io.novafoundation.nova.feature_staking_impl.presentation.validators.StakeTargetAdapter.Mode.VIEW +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload + +class ReviewCustomValidatorsFragment : + BaseFragment(), + StakeTargetAdapter.ItemHandler { + + companion object { + + private const val KEY_PAYLOAD = "SelectCustomValidatorsFragment.Payload" + + fun getBundle( + payload: CustomValidatorsPayload + ) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentReviewCustomValidatorsBinding.inflate(layoutInflater) + + private val adapter by lazy(LazyThreadSafetyMode.NONE) { + StakeTargetAdapter(this) + } + + override fun initViews() { + binder.reviewCustomValidatorsList.adapter = adapter + + binder.reviewCustomValidatorsList.setHasFixedSize(true) + + binder.reviewCustomValidatorsToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.reviewCustomValidatorsNext.setOnClickListener { + viewModel.nextClicked() + } + + binder.reviewCustomValidatorsToolbar.setRightActionClickListener { + viewModel.isInEditMode.toggle() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .reviewCustomValidatorsComponentFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ReviewCustomValidatorsViewModel) { + viewModel.selectedValidatorModels.observe(adapter::submitList) + + viewModel.selectionStateFlow.observe { + binder.reviewCustomValidatorsAccounts.setTextColorRes(if (it.isOverflow) R.color.text_negative else R.color.text_primary) + binder.reviewCustomValidatorsAccounts.text = it.selectedHeaderText + + binder.reviewCustomValidatorsNext.setState(if (it.isOverflow) ButtonState.DISABLED else ButtonState.NORMAL) + binder.reviewCustomValidatorsNext.text = it.nextButtonText + } + + viewModel.isInEditMode.observe { + adapter.modeChanged(if (it) EDIT else VIEW) + + val rightActionRes = if (it) R.string.common_done else R.string.common_edit + + binder.reviewCustomValidatorsToolbar.setTextRight(getString(rightActionRes)) + } + } + + override fun stakeTargetInfoClicked(validatorModel: ValidatorStakeTargetModel) { + viewModel.validatorInfoClicked(validatorModel) + } + + override fun removeClicked(validatorModel: ValidatorStakeTargetModel) { + viewModel.deleteClicked(validatorModel) + } + + override fun stakeTargetClicked(validatorModel: ValidatorStakeTargetModel) { + viewModel.validatorInfoClicked(validatorModel) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/ReviewCustomValidatorsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/ReviewCustomValidatorsViewModel.kt new file mode 100644 index 0000000..36c57e3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/ReviewCustomValidatorsViewModel.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.flowAction.ReviewValidatorsFlowAction +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.model.ValidatorsSelectionState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.setCustomValidators +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.relaychain +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class ReviewCustomValidatorsViewModel( + private val router: StakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val interactor: StakingInteractor, + private val resourceManager: ResourceManager, + private val sharedStateSetup: SetupStakingSharedState, + private val selectedAssetState: StakingSharedState, + private val reviewValidatorsFlowAction: ReviewValidatorsFlowAction, + tokenUseCase: TokenUseCase +) : BaseViewModel() { + + private val confirmSetupState = sharedStateSetup.setupStakingProcess + .filterIsInstance() + .share() + + private val selectedValidators = confirmSetupState + .map { it.newValidators } + .share() + + private val currentTokenFlow = tokenUseCase.currentTokenFlow() + .share() + + private val maxValidatorsPerNominatorFlow = flowOf { + val activeStake = confirmSetupState.first().activeStake + interactor.maxValidatorsPerNominator(activeStake) + }.share() + + val selectionStateFlow = combine( + selectedValidators, + maxValidatorsPerNominatorFlow + ) { validators, maxValidatorsPerNominator -> + val isOverflow = validators.size > maxValidatorsPerNominator + + ValidatorsSelectionState( + selectedHeaderText = resourceManager.getString(R.string.staking_custom_header_validators_title, validators.size, maxValidatorsPerNominator), + isOverflow = isOverflow, + nextButtonText = if (isOverflow) { + resourceManager.getString(R.string.staking_custom_proceed_button_disabled_title, maxValidatorsPerNominator) + } else { + resourceManager.getString(R.string.common_continue) + } + ) + } + + val selectedValidatorModels = combine( + selectedValidators, + currentTokenFlow + ) { validators, token -> + validators.map { validator -> + val chain = selectedAssetState.chain() + + mapValidatorToValidatorModel(chain, validator, addressIconGenerator, token) + } + } + .inBackground() + .share() + + val isInEditMode = MutableStateFlow(false) + + fun deleteClicked(validatorModel: ValidatorStakeTargetModel) { + launch { + val validators = selectedValidators.first() + + val withoutRemoved = validators - validatorModel.stakeTarget + + sharedStateSetup.setCustomValidators(withoutRemoved) + + if (withoutRemoved.isEmpty()) { + router.back() + } + } + } + + fun backClicked() { + router.back() + } + + fun validatorInfoClicked(validatorModel: ValidatorStakeTargetModel) = launch { + val stakeTarget = mapValidatorToValidatorDetailsParcelModel(validatorModel.stakeTarget) + + router.openValidatorDetails(StakeTargetDetailsPayload.relaychain(stakeTarget, interactor)) + } + + fun nextClicked() { + launch { + val stakingOption = selectedAssetState.selectedOption.first() + reviewValidatorsFlowAction.execute(viewModelScope, stakingOption) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/di/ReviewCustomValidatorsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/di/ReviewCustomValidatorsComponent.kt new file mode 100644 index 0000000..b2ba927 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/di/ReviewCustomValidatorsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.ReviewCustomValidatorsFragment + +@Subcomponent( + modules = [ + ReviewCustomValidatorsModule::class + ] +) +@ScreenScope +interface ReviewCustomValidatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance argument: CustomValidatorsPayload + ): ReviewCustomValidatorsComponent + } + + fun inject(fragment: ReviewCustomValidatorsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/di/ReviewCustomValidatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/di/ReviewCustomValidatorsModule.kt new file mode 100644 index 0000000..30dff2d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/di/ReviewCustomValidatorsModule.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.SetupStakingTypeSelectionMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.flowAction.DefaultReviewValidatorsFlowAction +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.ReviewCustomValidatorsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.flowAction.ReviewValidatorsFlowAction +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.flowAction.SetupStakingReviewValidatorsFlowAction +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase + +@Module(includes = [ViewModelModule::class]) +class ReviewCustomValidatorsModule { + + @Provides + fun provideReviewValidatorsFlowAction( + router: StakingRouter, + payload: CustomValidatorsPayload, + setupStakingSharedState: SetupStakingSharedState, + setupStakingTypeSelectionMixinFactory: SetupStakingTypeSelectionMixinFactory + ): ReviewValidatorsFlowAction { + return when (payload.flowType) { + CustomValidatorsPayload.FlowType.SETUP_STAKING_VALIDATORS -> SetupStakingReviewValidatorsFlowAction( + router, + setupStakingSharedState, + setupStakingTypeSelectionMixinFactory + ) + + CustomValidatorsPayload.FlowType.CHANGE_STAKING_VALIDATORS -> DefaultReviewValidatorsFlowAction(router) + } + } + + @Provides + @IntoMap + @ViewModelKey(ReviewCustomValidatorsViewModel::class) + fun provideViewModel( + addressIconGenerator: AddressIconGenerator, + stakingInteractor: StakingInteractor, + resourceManager: ResourceManager, + router: StakingRouter, + setupStakingSharedState: SetupStakingSharedState, + tokenUseCase: TokenUseCase, + selectedAssetState: StakingSharedState, + reviewValidatorsFlowAction: ReviewValidatorsFlowAction + ): ViewModel { + return ReviewCustomValidatorsViewModel( + router, + addressIconGenerator, + stakingInteractor, + resourceManager, + setupStakingSharedState, + selectedAssetState, + reviewValidatorsFlowAction, + tokenUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ReviewCustomValidatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ReviewCustomValidatorsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/flowAction/DefaultReviewValidatorsFlowAction.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/flowAction/DefaultReviewValidatorsFlowAction.kt new file mode 100644 index 0000000..a50dc0a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/flowAction/DefaultReviewValidatorsFlowAction.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.flowAction + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import kotlinx.coroutines.CoroutineScope + +class DefaultReviewValidatorsFlowAction( + private val stakingRouter: StakingRouter +) : ReviewValidatorsFlowAction { + + override suspend fun execute(coroutineScope: CoroutineScope, stakingOption: StakingOption) { + stakingRouter.openConfirmStaking() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/flowAction/ReviewCustomValidatorsFlowAction.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/flowAction/ReviewCustomValidatorsFlowAction.kt new file mode 100644 index 0000000..33ad7ef --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/flowAction/ReviewCustomValidatorsFlowAction.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.flowAction + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import kotlinx.coroutines.CoroutineScope + +interface ReviewValidatorsFlowAction { + + suspend fun execute(coroutineScope: CoroutineScope, stakingOption: StakingOption) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/flowAction/SetupStakingReviewValidatorsFlowAction.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/flowAction/SetupStakingReviewValidatorsFlowAction.kt new file mode 100644 index 0000000..c4a3d5e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/flowAction/SetupStakingReviewValidatorsFlowAction.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.flowAction + +import io.novafoundation.nova.feature_staking_impl.data.StakingOption +import io.novafoundation.nova.feature_staking_impl.domain.staking.start.setupStakingType.SetupStakingTypeSelectionMixinFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.getNewValidators +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.reset +import kotlinx.coroutines.CoroutineScope + +class SetupStakingReviewValidatorsFlowAction( + private val stakingRouter: StakingRouter, + private val sharedStateSetup: SetupStakingSharedState, + private val setupStakingTypeSelectionMixinFactory: SetupStakingTypeSelectionMixinFactory +) : ReviewValidatorsFlowAction { + + override suspend fun execute(coroutineScope: CoroutineScope, stakingOption: StakingOption) { + val setupStakingTypeSelectionMixin = setupStakingTypeSelectionMixinFactory.create(coroutineScope) + val selectedValidators = sharedStateSetup.getNewValidators() + setupStakingTypeSelectionMixin.selectValidatorsAndApply(selectedValidators, stakingOption) + sharedStateSetup.reset() + + stakingRouter.finishSetupValidatorsFlow() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/model/ValidatorsSelectionState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/model/ValidatorsSelectionState.kt new file mode 100644 index 0000000..0cf9608 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/review/model/ValidatorsSelectionState.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.review.model + +class ValidatorsSelectionState( + val selectedHeaderText: String, + val nextButtonText: String, + val isOverflow: Boolean +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/SearchCustomValidatorsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/SearchCustomValidatorsFragment.kt new file mode 100644 index 0000000..e0c51d7 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/SearchCustomValidatorsFragment.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.search + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.search.SearchStakeTargetFragment + +class SearchCustomValidatorsFragment : SearchStakeTargetFragment() { + + override val configuration by lazy(LazyThreadSafetyMode.NONE) { + Configuration( + doneAction = viewModel::doneClicked, + sortingLabelRes = R.string.staking_rewards_apy + ) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .searchCustomValidatorsComponentFactory() + .create(this) + .inject(this) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt new file mode 100644 index 0000000..d2af570 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/SearchCustomValidatorsViewModel.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.search + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.validators.current.search.SearchCustomValidatorsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.common.search.SearchStakeTargetViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.setCustomValidators +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.relaychain +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch + +class SearchCustomValidatorsViewModel( + private val router: StakingRouter, + private val addressIconGenerator: AddressIconGenerator, + private val interactor: SearchCustomValidatorsInteractor, + private val stakingInteractor: StakingInteractor, + resourceManager: ResourceManager, + private val sharedStateSetup: SetupStakingSharedState, + private val validatorRecommenderFactory: ValidatorRecommenderFactory, + private val singleAssetSharedState: AnySelectedAssetOptionSharedState, + tokenUseCase: TokenUseCase, +) : SearchStakeTargetViewModel(resourceManager) { + + private val confirmSetupState = sharedStateSetup.setupStakingProcess + .filterIsInstance() + .share() + + private val selectedValidators = confirmSetupState + .map { it.newValidators.toSet() } + .inBackground() + .share() + + private val currentTokenFlow = tokenUseCase.currentTokenFlow() + .share() + + private val allRecommendedValidators by lazyAsync { + validatorRecommenderFactory.create(scope = viewModelScope).availableValidators.toSet() + } + + private val foundValidatorsState = enteredQuery + .mapLatest { + if (it.isNotEmpty()) { + interactor.searchValidator(it, allRecommendedValidators() + selectedValidators.first()) + } else { + null + } + } + .inBackground() + .share() + + override val dataFlow = combine( + selectedValidators, + foundValidatorsState, + currentTokenFlow + ) { selectedValidators, foundValidators, token -> + val chain = singleAssetSharedState.chain() + + foundValidators?.map { validator -> + mapValidatorToValidatorModel( + chain = chain, + validator = validator, + iconGenerator = addressIconGenerator, + token = token, + isChecked = validator in selectedValidators + ) + } + } + + override fun itemClicked(item: ValidatorStakeTargetModel) { + if (item.stakeTarget.prefs!!.blocked) { + showError(resourceManager.getString(R.string.staking_custom_blocked_warning)) + return + } + + launch { + val newSelected = selectedValidators.first().toggle(item.stakeTarget) + + sharedStateSetup.setCustomValidators(newSelected.toList()) + } + } + + override fun itemInfoClicked(item: ValidatorStakeTargetModel) { + launch { + val stakeTarget = mapValidatorToValidatorDetailsParcelModel(item.stakeTarget) + val payload = StakeTargetDetailsPayload.relaychain(stakeTarget, stakingInteractor) + + router.openValidatorDetails(payload) + } + } + + override fun backClicked() { + router.back() + } + + fun doneClicked() { + router.back() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/di/SearchCustomValidatorsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/di/SearchCustomValidatorsComponent.kt new file mode 100644 index 0000000..a2dd659 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/di/SearchCustomValidatorsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.search.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.search.SearchCustomValidatorsFragment + +@Subcomponent( + modules = [ + SearchCustomValidatorsModule::class + ] +) +@ScreenScope +interface SearchCustomValidatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): SearchCustomValidatorsComponent + } + + fun inject(fragment: SearchCustomValidatorsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/di/SearchCustomValidatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/di/SearchCustomValidatorsModule.kt new file mode 100644 index 0000000..712957d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/search/di/SearchCustomValidatorsModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.search.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.validators.current.search.SearchCustomValidatorsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.search.SearchCustomValidatorsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase + +@Module(includes = [ViewModelModule::class]) +class SearchCustomValidatorsModule { + + @Provides + @IntoMap + @ViewModelKey(SearchCustomValidatorsViewModel::class) + fun provideViewModel( + addressIconGenerator: AddressIconGenerator, + resourceManager: ResourceManager, + router: StakingRouter, + setupStakingSharedState: SetupStakingSharedState, + searchCustomValidatorsInteractor: SearchCustomValidatorsInteractor, + validatorRecommenderFactory: ValidatorRecommenderFactory, + stakingInteractor: StakingInteractor, + tokenUseCase: TokenUseCase, + selectedAssetState: StakingSharedState + ): ViewModel { + return SearchCustomValidatorsViewModel( + router, + addressIconGenerator, + searchCustomValidatorsInteractor, + stakingInteractor, + resourceManager, + setupStakingSharedState, + validatorRecommenderFactory, + selectedAssetState, + tokenUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SearchCustomValidatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SearchCustomValidatorsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/SelectCustomValidatorsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/SelectCustomValidatorsFragment.kt new file mode 100644 index 0000000..7465183 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/SelectCustomValidatorsFragment.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select + +import android.os.Bundle +import android.widget.ImageView + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.scrollToTopWhenItemsShuffled +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentSelectCustomValidatorsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.StakeTargetAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload + +class SelectCustomValidatorsFragment : + BaseFragment(), + StakeTargetAdapter.ItemHandler { + + companion object { + + private const val KEY_PAYLOAD = "SelectCustomValidatorsFragment.Payload" + + fun getBundle( + payload: CustomValidatorsPayload + ) = Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + + override fun createBinding() = FragmentSelectCustomValidatorsBinding.inflate(layoutInflater) + + val adapter by lazy(LazyThreadSafetyMode.NONE) { + StakeTargetAdapter(this) + } + + var filterAction: ImageView? = null + + override fun initViews() { + binder.selectCustomValidatorsList.adapter = adapter + binder.selectCustomValidatorsList.setHasFixedSize(true) + + binder.selectCustomValidatorsToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + filterAction = binder.selectCustomValidatorsToolbar.addCustomAction(R.drawable.ic_filter) { + viewModel.settingsClicked() + } + + binder.selectCustomValidatorsToolbar.addCustomAction(R.drawable.ic_search) { + viewModel.searchClicked() + } + + binder.selectCustomValidatorsList.scrollToTopWhenItemsShuffled(viewLifecycleOwner) + + binder.selectCustomValidatorsFillWithRecommended.setOnClickListener { viewModel.fillRestWithRecommended() } + binder.selectCustomValidatorsClearFilters.setOnClickListener { viewModel.clearFilters() } + binder.selectCustomValidatorsDeselectAll.setOnClickListener { viewModel.deselectAll() } + + binder.selectCustomValidatorsNext.setOnClickListener { viewModel.nextClicked() } + } + + override fun onDestroyView() { + super.onDestroyView() + + filterAction = null + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .selectCustomValidatorsComponentFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: SelectCustomValidatorsViewModel) { + viewModel.validatorModelsFlow.observe(adapter::submitList) + + viewModel.selectedTitle.observe(binder.selectCustomValidatorsCount::setText) + + viewModel.buttonState.observe { + binder.selectCustomValidatorsNext.text = it.text + + val state = if (it.enabled) ButtonState.NORMAL else ButtonState.DISABLED + + binder.selectCustomValidatorsNext.setState(state) + } + + viewModel.scoringHeader.observe(binder.selectCustomValidatorsSorting::setText) + + viewModel.fillWithRecommendedEnabled.observe(binder.selectCustomValidatorsFillWithRecommended::setEnabled) + viewModel.clearFiltersEnabled.observe(binder.selectCustomValidatorsClearFilters::setEnabled) + viewModel.deselectAllEnabled.observe(binder.selectCustomValidatorsDeselectAll::setEnabled) + + viewModel.recommendationSettingsIcon.observe { icon -> + filterAction?.setImageResource(icon) + } + } + + override fun stakeTargetInfoClicked(stakeTargetModel: ValidatorStakeTargetModel) { + viewModel.validatorInfoClicked(stakeTargetModel) + } + + override fun stakeTargetClicked(stakeTargetModel: ValidatorStakeTargetModel) { + viewModel.validatorClicked(stakeTargetModel) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/SelectCustomValidatorsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/SelectCustomValidatorsViewModel.kt new file mode 100644 index 0000000..040c4ca --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/SelectCustomValidatorsViewModel.kt @@ -0,0 +1,254 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.SetItem +import io.novafoundation.nova.common.utils.asSetItem +import io.novafoundation.nova.common.utils.asSetItems +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.toggle +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.APYSorting +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.TotalStakeSorting +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.ValidatorOwnStakeSorting +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.activeStake +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select.model.ContinueButtonState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.getSelectedValidatorsOrNull +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.setCustomValidators +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.relaychain +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +class SelectCustomValidatorsViewModel( + private val router: StakingRouter, + private val validatorRecommenderFactory: ValidatorRecommenderFactory, + private val recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + private val addressIconGenerator: AddressIconGenerator, + private val interactor: StakingInteractor, + private val resourceManager: ResourceManager, + private val setupStakingSharedState: SetupStakingSharedState, + private val tokenUseCase: TokenUseCase, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val payload: CustomValidatorsPayload, +) : BaseViewModel() { + + private val validatorRecommendator by lazyAsync { + validatorRecommenderFactory.create(scope = viewModelScope) + } + + private val recommendationSettingsProvider by lazyAsync { + recommendationSettingsProviderFactory.create(scope = viewModelScope) + } + + private val recommendationSettingsFlow = flow { + emitAll(recommendationSettingsProvider().observeRecommendationSettings()) + }.share() + + val recommendationSettingsIcon = recommendationSettingsFlow.map { + val isChanged = it != recommendationSettingsProvider().defaultSelectCustomSettings() + + if (isChanged) R.drawable.ic_filter_indicator else R.drawable.ic_filter + } + .inBackground() + .share() + + private val shownValidators = recommendationSettingsFlow.map { + recommendator().recommendations(it) + }.share() + + private val tokenFlow = tokenUseCase.currentTokenFlow() + .inBackground() + .share() + + private val selectedValidators = MutableStateFlow(emptySet>()) + + private val maxSelectedValidatorsFlow = flowOf { + interactor.maxValidatorsPerNominator(setupStakingSharedState.activeStake()) + }.shareInBackground() + + val validatorModelsFlow = combine( + shownValidators, + selectedValidators, + tokenFlow, + ) { shown, selected, token -> + val chain = selectedAssetState.chain() + + convertToModels(chain, shown, selected, token) + } + .inBackground() + .share() + + val selectedTitle = shownValidators.map { + resourceManager.getString(R.string.staking_custom_header_validators_title, it.size, recommendator().availableValidators.size) + }.inBackground().share() + + val buttonState = selectedValidators.map { + val maxSelectedValidators = maxSelectedValidatorsFlow.first() + + if (it.isEmpty()) { + ContinueButtonState( + enabled = false, + text = resourceManager.getString(R.string.staking_custom_proceed_button_disabled_title, maxSelectedValidators) + ) + } else { + ContinueButtonState( + enabled = true, + text = resourceManager.getString(R.string.staking_custom_proceed_button_enabled_title, it.size, maxSelectedValidators) + ) + } + } + + val scoringHeader = recommendationSettingsFlow.map { + when (it.sorting) { + APYSorting -> resourceManager.getString(R.string.staking_rewards_apy) + TotalStakeSorting -> resourceManager.getString(R.string.staking_validator_total_stake) + ValidatorOwnStakeSorting -> resourceManager.getString(R.string.staking_filter_title_own_stake) + else -> throw IllegalArgumentException("Unknown sorting: ${it.sorting}") + } + }.inBackground().share() + + val fillWithRecommendedEnabled = selectedValidators.map { it.size < maxSelectedValidatorsFlow.first() } + .onStart { emit(false) } + .share() + + val clearFiltersEnabled = recommendationSettingsFlow.map { it.customEnabledFilters.isNotEmpty() || it.postProcessors.isNotEmpty() } + .share() + + val deselectAllEnabled = selectedValidators.map { it.isNotEmpty() } + .share() + + init { + observeExternalSelectionChanges() + } + + fun backClicked() { + updateSetupStakingState() + + router.back() + } + + fun nextClicked() { + updateSetupStakingState() + + router.openReviewCustomValidators(payload) + } + + fun validatorInfoClicked(validatorModel: ValidatorStakeTargetModel) = launch { + val stakeTarget = mapValidatorToValidatorDetailsParcelModel(validatorModel.stakeTarget) + val payload = StakeTargetDetailsPayload.relaychain(stakeTarget, interactor) + + router.openValidatorDetails(payload) + } + + fun validatorClicked(validatorModel: ValidatorStakeTargetModel) { + mutateSelected { + it.toggle(validatorModel.stakeTarget.asSetItem()) + } + } + + fun settingsClicked() { + router.openCustomValidatorsSettings() + } + + fun searchClicked() { + updateSetupStakingState() + + router.openSearchCustomValidators() + } + + private fun updateSetupStakingState() { + val validatorList = selectedValidators.value.map { it.value } + setupStakingSharedState.setCustomValidators(validatorList) + } + + fun clearFilters() { + launch { + val settings = recommendationSettingsProvider().createModifiedCustomValidatorsSettings( + filterIncluder = { false }, + postProcessorIncluder = { false } + ) + + recommendationSettingsProvider().setCustomValidatorsSettings(settings) + } + } + + fun deselectAll() { + mutateSelected { emptySet() } + } + + fun fillRestWithRecommended() { + mutateSelected { selected -> + val maxValidatorsPerNominator = maxSelectedValidatorsFlow.first() + val defaultSettings = recommendationSettingsProvider().recommendedSettings(maxValidatorsPerNominator) + val recommended = recommendator().recommendations(defaultSettings) + + val missingFromRecommended = recommended.asSetItems() - selected + val neededToFill = maxSelectedValidatorsFlow.first() - selected.size + + selected + missingFromRecommended.take(neededToFill).toSet() + } + } + + private fun observeExternalSelectionChanges() { + setupStakingSharedState.setupStakingProcess + .mapNotNull { it.getSelectedValidatorsOrNull() } + .onEach { validators -> selectedValidators.value = validators.asSetItems() } + .launchIn(viewModelScope) + } + + private suspend fun convertToModels( + chain: Chain, + validators: List, + selectedValidators: Set>, + token: Token, + ): List { + return validators.map { validator -> + mapValidatorToValidatorModel( + chain = chain, + validator = validator, + iconGenerator = addressIconGenerator, + token = token, + isChecked = validator.asSetItem() in selectedValidators, + sorting = recommendationSettingsFlow.first().sorting + ) + } + } + + private suspend fun recommendator() = validatorRecommendator.await() + + private fun mutateSelected(mutation: suspend (Set>) -> Set>) { + launch { + selectedValidators.value = mutation(selectedValidators.value) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/di/SelectCustomValidatorsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/di/SelectCustomValidatorsComponent.kt new file mode 100644 index 0000000..40c6ccc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/di/SelectCustomValidatorsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select.SelectCustomValidatorsFragment + +@Subcomponent( + modules = [ + SelectCustomValidatorsModule::class + ] +) +@ScreenScope +interface SelectCustomValidatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance argument: CustomValidatorsPayload + ): SelectCustomValidatorsComponent + } + + fun inject(fragment: SelectCustomValidatorsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/di/SelectCustomValidatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/di/SelectCustomValidatorsModule.kt new file mode 100644 index 0000000..db90222 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/di/SelectCustomValidatorsModule.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.modules.Caching +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.common.CustomValidatorsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select.SelectCustomValidatorsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase + +@Module(includes = [ViewModelModule::class]) +class SelectCustomValidatorsModule { + + @Provides + @IntoMap + @ViewModelKey(SelectCustomValidatorsViewModel::class) + fun provideViewModel( + validatorRecommenderFactory: ValidatorRecommenderFactory, + recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + @Caching addressIconGenerator: AddressIconGenerator, + stakingInteractor: StakingInteractor, + resourceManager: ResourceManager, + setupStakingSharedState: SetupStakingSharedState, + router: StakingRouter, + tokenUseCase: TokenUseCase, + selectedAssetState: StakingSharedState, + payload: CustomValidatorsPayload + ): ViewModel { + return SelectCustomValidatorsViewModel( + router, + validatorRecommenderFactory, + recommendationSettingsProviderFactory, + addressIconGenerator, + stakingInteractor, + resourceManager, + setupStakingSharedState, + tokenUseCase, + selectedAssetState, + payload + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SelectCustomValidatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SelectCustomValidatorsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/model/ContinueButtonState.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/model/ContinueButtonState.kt new file mode 100644 index 0000000..33754dc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/select/model/ContinueButtonState.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.select.model + +class ContinueButtonState( + val enabled: Boolean, + val text: String +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/CustomValidatorsSettingsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/CustomValidatorsSettingsFragment.kt new file mode 100644 index 0000000..4e62a27 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/CustomValidatorsSettingsFragment.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.settings + +import android.widget.CompoundButton +import androidx.lifecycle.lifecycleScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.common.view.bindFromMap +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentCustomValidatorsSettingsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationPostProcessor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.HasIdentityFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.NotOverSubscribedFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.NotSlashedFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.postprocessors.RemoveClusteringPostprocessor + +class CustomValidatorsSettingsFragment : BaseFragment() { + + override fun createBinding() = FragmentCustomValidatorsSettingsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.customValidatorSettingsApply.setOnClickListener { viewModel.applyChanges() } + + binder.customValidatorSettingsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.customValidatorSettingsToolbar.setRightActionClickListener { viewModel.reset() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .customValidatorsSettingsComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: CustomValidatorsSettingsViewModel) { + bindFilters(viewModel) + bindPostprocessors(viewModel) + + binder.customValidatorSettingsSort.bindTo(viewModel.selectedSortingIdFlow, lifecycleScope) + + viewModel.isResetButtonEnabled.observe(binder.customValidatorSettingsToolbar.rightActionText::setEnabled) + viewModel.isApplyButtonEnabled.observe { + binder.customValidatorSettingsApply.setState(if (it) ButtonState.NORMAL else ButtonState.DISABLED) + } + + viewModel.tokenNameFlow.observe { + binder.customValidatorSettingsSortTotalStake.text = getString(R.string.staking_validator_total_stake_token, it) + binder.customValidatorSettingsSortOwnStake.text = getString(R.string.staking_filter_title_own_stake_token, it) + } + } + + private fun bindFilters(viewModel: CustomValidatorsSettingsViewModel) { + val filterToView = listOf( + HasIdentityFilter::class.java to binder.customValidatorSettingsFilterIdentity, + NotSlashedFilter::class.java to binder.customValidatorSettingsFilterSlashes, + NotOverSubscribedFilter::class.java to binder.customValidatorSettingsFilterOverSubscribed, + ) + + filterToView.onEach { (filterClass, view) -> view.field.bindFilter(filterClass) } + + viewModel.allAvailableFilters.observe { availableFilters -> + filterToView.onEach { (filterClass, view) -> + view.setVisible(filterClass in availableFilters) + } + } + } + + private fun bindPostprocessors(viewModel: CustomValidatorsSettingsViewModel) { + val postProcessorToView = listOf( + RemoveClusteringPostprocessor::class.java to binder.customValidatorSettingsFilterClustering + ) + + postProcessorToView.onEach { (postProcessorClass, view) -> view.field.bindPostProcessor(postProcessorClass) } + + viewModel.availablePostProcessors.observe { availablePostProcessors -> + postProcessorToView.onEach { (postProcessorClass, view) -> + view.setVisible(postProcessorClass in availablePostProcessors) + } + } + } + + private fun CompoundButton.bindPostProcessor(postProcessorClass: Class) { + bindFromMap(postProcessorClass, viewModel.postProcessorsEnabledMap, lifecycleScope) + } + + private fun CompoundButton.bindFilter(filterClass: Class) { + bindFromMap(filterClass, viewModel.filtersEnabledMap, lifecycleScope) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/CustomValidatorsSettingsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/CustomValidatorsSettingsViewModel.kt new file mode 100644 index 0000000..43dd5d4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/CustomValidatorsSettingsViewModel.kt @@ -0,0 +1,138 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.settings + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.reversed +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettings +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.HasIdentityFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.NotOverSubscribedFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.filters.NotSlashedFilter +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.postprocessors.RemoveClusteringPostprocessor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.APYSorting +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.TotalStakeSorting +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.sortings.ValidatorOwnStakeSorting +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +private val SORT_MAPPING = mapOf( + R.id.customValidatorSettingsSortAPY to APYSorting, + R.id.customValidatorSettingsSortTotalStake to TotalStakeSorting, + R.id.customValidatorSettingsSortOwnStake to ValidatorOwnStakeSorting +) + +private val SORT_MAPPING_REVERSE = SORT_MAPPING.reversed() + +class CustomValidatorsSettingsViewModel( + private val router: StakingRouter, + private val recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + private val tokenUseCase: TokenUseCase +) : BaseViewModel() { + + private val recommendationSettingsProvider by lazyAsync { + recommendationSettingsProviderFactory.create(scope = viewModelScope) + } + + val selectedSortingIdFlow = MutableStateFlow(R.id.customValidatorSettingsSortAPY) + + private val initialSettingsFlow = flow { emit(recommendationSettingsProvider().currentSettings()) } + .share() + + private val defaultSettingsFlow = flow { emit(recommendationSettingsProvider().defaultSelectCustomSettings()) } + .share() + + val allAvailableFilters = flowOf { + recommendationSettingsProvider().allAvailableFilters.mapToSet { it::class.java } + }.shareInBackground() + + val availablePostProcessors = flowOf { + recommendationSettingsProvider().allPostProcessors.mapToSet { it::class.java } + }.shareInBackground() + + val filtersEnabledMap = createClassEnabledMap( + HasIdentityFilter::class.java, + NotOverSubscribedFilter::class.java, + NotSlashedFilter::class.java + ) + + val postProcessorsEnabledMap = createClassEnabledMap( + RemoveClusteringPostprocessor::class.java + ) + + private val modifiedSettings = combine( + filtersEnabledMap.values + postProcessorsEnabledMap.values + selectedSortingIdFlow + ) { + recommendationSettingsProvider().createModifiedCustomValidatorsSettings( + filterIncluder = { filtersEnabledMap.checkEnabled(it::class.java) }, + postProcessorIncluder = { postProcessorsEnabledMap.checkEnabled(it::class.java) }, + sorting = SORT_MAPPING.getValue(selectedSortingIdFlow.value) + ) + }.inBackground() + .share() + + val tokenNameFlow = tokenUseCase.currentTokenFlow().map { it.configuration.symbol } + + val isApplyButtonEnabled = combine(initialSettingsFlow, modifiedSettings) { initial, modified -> + initial != modified + }.share() + + val isResetButtonEnabled = combine(defaultSettingsFlow, modifiedSettings) { default, modified -> + default != modified + } + + init { + viewModelScope.launch { + initFromSettings(initialSettingsFlow.first()) + } + } + + private fun initFromSettings(currentSettings: RecommendationSettings) { + currentSettings.customEnabledFilters.forEach { + filtersEnabledMap[it::class.java]?.value = true + } + + currentSettings.postProcessors.forEach { + postProcessorsEnabledMap[it::class.java]?.value = true + } + + selectedSortingIdFlow.value = SORT_MAPPING_REVERSE[currentSettings.sorting]!! + } + + fun reset() { + viewModelScope.launch { + val defaultSettings = recommendationSettingsProvider().defaultSelectCustomSettings() + + initFromSettings(defaultSettings) + } + } + + fun applyChanges() { + viewModelScope.launch { + recommendationSettingsProvider().setCustomValidatorsSettings(modifiedSettings.first()) + + router.back() + } + } + + fun backClicked() { + router.back() + } + + private fun createClassEnabledMap(vararg classes: Class) = classes.associate { + it to MutableStateFlow(false) + } + + private fun Map>.checkEnabled(key: T) = get(key)?.value ?: false +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/di/CustomValidatorsSettingsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/di/CustomValidatorsSettingsComponent.kt new file mode 100644 index 0000000..85af06a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/di/CustomValidatorsSettingsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.settings.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.settings.CustomValidatorsSettingsFragment + +@Subcomponent( + modules = [ + CustomValidatorsSettingsModule::class + ] +) +@ScreenScope +interface CustomValidatorsSettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): CustomValidatorsSettingsComponent + } + + fun inject(fragment: CustomValidatorsSettingsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/di/CustomValidatorsSettingsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/di/CustomValidatorsSettingsModule.kt new file mode 100644 index 0000000..04a7f8e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/custom/settings/di/CustomValidatorsSettingsModule.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.settings.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.custom.settings.CustomValidatorsSettingsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase + +@Module(includes = [ViewModelModule::class]) +class CustomValidatorsSettingsModule { + + @Provides + @IntoMap + @ViewModelKey(CustomValidatorsSettingsViewModel::class) + fun provideViewModel( + recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + router: StakingRouter, + tokenUseCase: TokenUseCase + ): ViewModel { + return CustomValidatorsSettingsViewModel( + router, + recommendationSettingsProviderFactory, + tokenUseCase + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): CustomValidatorsSettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(CustomValidatorsSettingsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/RecommendedValidatorsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/RecommendedValidatorsFragment.kt new file mode 100644 index 0000000..15c32dc --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/RecommendedValidatorsFragment.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.recommended + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentRecommendedValidatorsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.StakeTargetAdapter +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel + +class RecommendedValidatorsFragment : + BaseFragment(), + StakeTargetAdapter.ItemHandler { + + override fun createBinding() = FragmentRecommendedValidatorsBinding.inflate(layoutInflater) + + val adapter by lazy(LazyThreadSafetyMode.NONE) { + StakeTargetAdapter(this) + } + + override fun initViews() { + binder.recommendedValidatorsList.adapter = adapter + + binder.recommendedValidatorsList.setHasFixedSize(true) + + binder.recommendedValidatorsToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.recommendedValidatorsNext.setOnClickListener { + viewModel.nextClicked() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .recommendedValidatorsComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: RecommendedValidatorsViewModel) { + viewModel.recommendedValidatorModels.observe { + adapter.submitList(it) + + binder.recommendedValidatorsProgress.setVisible(false) + binder.recommendedValidatorsContent.setVisible(true) + } + + viewModel.selectedTitle.observe(binder.recommendedValidatorsAccounts::setText) + } + + override fun stakeTargetInfoClicked(validatorModel: ValidatorStakeTargetModel) { + viewModel.validatorInfoClicked(validatorModel) + } + + override fun stakeTargetClicked(validatorModel: ValidatorStakeTargetModel) { + viewModel.validatorInfoClicked(validatorModel) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/RecommendedValidatorsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/RecommendedValidatorsViewModel.kt new file mode 100644 index 0000000..f4219e0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/RecommendedValidatorsViewModel.kt @@ -0,0 +1,101 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.recommended + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.feature_staking_api.domain.model.Validator +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorDetailsParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.ValidatorStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.activeStake +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.retractRecommended +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.setRecommendedValidators +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.relaychain +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class RecommendedValidatorsViewModel( + private val router: StakingRouter, + private val validatorRecommenderFactory: ValidatorRecommenderFactory, + private val recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + private val addressIconGenerator: AddressIconGenerator, + private val interactor: StakingInteractor, + private val resourceManager: ResourceManager, + private val sharedStateSetup: SetupStakingSharedState, + private val tokenUseCase: TokenUseCase, + private val selectedAssetState: AnySelectedAssetOptionSharedState +) : BaseViewModel() { + + private val maxValidatorsPerNominator by lazyAsync { + interactor.maxValidatorsPerNominator(sharedStateSetup.activeStake()) + } + + private val recommendedSettings by lazyAsync { + recommendationSettingsProviderFactory.create(scope = viewModelScope).recommendedSettings(maxValidatorsPerNominator()) + } + + private val recommendedValidators = flow { + val validatorRecommendator = validatorRecommenderFactory.create(scope = viewModelScope) + val validators = validatorRecommendator.recommendations(recommendedSettings()) + + emit(validators) + }.inBackground().share() + + val recommendedValidatorModels = recommendedValidators.map { + convertToModels(it, tokenUseCase.currentToken()) + }.inBackground().share() + + val selectedTitle = recommendedValidators.map { + val maxValidators = maxValidatorsPerNominator() + + resourceManager.getString(R.string.staking_custom_header_validators_title, it.size, maxValidators) + }.inBackground().share() + + fun backClicked() { + sharedStateSetup.retractRecommended() + + router.back() + } + + fun validatorInfoClicked(validatorModel: ValidatorStakeTargetModel) = launch { + val stakeTarget = mapValidatorToValidatorDetailsParcelModel(validatorModel.stakeTarget) + val payload = StakeTargetDetailsPayload.relaychain(stakeTarget, interactor) + + router.openValidatorDetails(payload) + } + + fun nextClicked() { + viewModelScope.launch { + sharedStateSetup.setRecommendedValidators(recommendedValidators.first()) + router.openConfirmStaking() + } + } + + private suspend fun convertToModels( + validators: List, + token: Token + ): List { + val chain = selectedAssetState.chain() + + return validators.map { + mapValidatorToValidatorModel(chain, it, addressIconGenerator, token) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/di/RecommendedValidatorsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/di/RecommendedValidatorsComponent.kt new file mode 100644 index 0000000..ac44947 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/di/RecommendedValidatorsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.recommended.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.recommended.RecommendedValidatorsFragment + +@Subcomponent( + modules = [ + RecommendedValidatorsModule::class + ] +) +@ScreenScope +interface RecommendedValidatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): RecommendedValidatorsComponent + } + + fun inject(fragment: RecommendedValidatorsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/di/RecommendedValidatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/di/RecommendedValidatorsModule.kt new file mode 100644 index 0000000..e29a64e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/recommended/di/RecommendedValidatorsModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.recommended.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.settings.RecommendationSettingsProviderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.recommended.RecommendedValidatorsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase + +@Module(includes = [ViewModelModule::class]) +class RecommendedValidatorsModule { + + @Provides + @IntoMap + @ViewModelKey(RecommendedValidatorsViewModel::class) + fun provideViewModel( + validatorRecommenderFactory: ValidatorRecommenderFactory, + recommendationSettingsProviderFactory: RecommendationSettingsProviderFactory, + addressIconGenerator: AddressIconGenerator, + stakingInteractor: StakingInteractor, + resourceManager: ResourceManager, + router: StakingRouter, + setupStakingSharedState: SetupStakingSharedState, + tokenUseCase: TokenUseCase, + selectedAssetState: StakingSharedState + ): ViewModel { + return RecommendedValidatorsViewModel( + router, + validatorRecommenderFactory, + recommendationSettingsProviderFactory, + addressIconGenerator, + stakingInteractor, + resourceManager, + setupStakingSharedState, + tokenUseCase, + selectedAssetState + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): RecommendedValidatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(RecommendedValidatorsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/StartChangeValidatorsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/StartChangeValidatorsFragment.kt new file mode 100644 index 0000000..6110049 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/StartChangeValidatorsFragment.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.start + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeBrowserEvents +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentStartChangeValidatorsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent + +class StartChangeValidatorsFragment : BaseFragment() { + + override fun createBinding() = FragmentStartChangeValidatorsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.startChangeValidatorsToolbar.setHomeButtonListener { viewModel.backClicked() } + onBackPressed { viewModel.backClicked() } + + binder.startChangeValidatorsRecommended.setupAction(viewLifecycleOwner) { viewModel.goToRecommendedClicked() } + binder.startChangeValidatorsRecommended.setOnLearnMoreClickedListener { viewModel.recommendedLearnMoreClicked() } + + binder.startChangeValidatorsCustom.background = getRoundedCornerDrawable(R.color.block_background).withRippleMask() + binder.startChangeValidatorsCustom.setOnClickListener { viewModel.goToCustomClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .startChangeValidatorsComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: StartChangeValidatorsViewModel) { + observeBrowserEvents(viewModel) + + viewModel.validatorsLoading.observe { loading -> + binder.startChangeValidatorsRecommended.action.setProgressState(loading) + binder.startChangeValidatorsCustom.setInProgress(loading) + } + + viewModel.customValidatorsTexts.observe { + binder.startChangeValidatorsToolbar.setTitle(it.toolbarTitle) + binder.startChangeValidatorsCustom.title.text = it.selectManuallyTitle + binder.startChangeValidatorsCustom.setBadgeText(it.selectManuallyBadge) + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/StartChangeValidatorsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/StartChangeValidatorsViewModel.kt new file mode 100644 index 0000000..f27614c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/StartChangeValidatorsViewModel.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.start + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.mixin.api.Browserable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.activeStake +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.getSelectedValidatorsOrEmpty +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.reset +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class Texts( + val toolbarTitle: String, + val selectManuallyTitle: String, + val selectManuallyBadge: String? +) + +class StartChangeValidatorsViewModel( + private val router: StakingRouter, + private val validatorRecommenderFactory: ValidatorRecommenderFactory, + private val setupStakingSharedState: SetupStakingSharedState, + private val appLinksProvider: AppLinksProvider, + private val resourceManager: ResourceManager, + private val interactor: StakingInteractor, +) : BaseViewModel(), Browserable { + + override val openBrowserEvent = MutableLiveData>() + + private val maxValidatorsPerNominator = flowOf { + interactor.maxValidatorsPerNominator(setupStakingSharedState.activeStake()) + }.shareInBackground() + + val validatorsLoading = MutableStateFlow(true) + + val customValidatorsTexts = setupStakingSharedState.setupStakingProcess.map { + val selectedValidators = it.getSelectedValidatorsOrEmpty() + + if (selectedValidators.isNotEmpty()) { + Texts( + toolbarTitle = resourceManager.getString(R.string.staking_change_validators), + selectManuallyTitle = resourceManager.getString(R.string.staking_select_custom), + selectManuallyBadge = resourceManager.getString( + R.string.staking_max_format, + selectedValidators.size, + maxValidatorsPerNominator.first() + ) + ) + } else { + Texts( + toolbarTitle = resourceManager.getString(R.string.staking_set_validators), + selectManuallyTitle = resourceManager.getString(R.string.staking_select_custom), + selectManuallyBadge = null + ) + } + }.shareInBackground() + + init { + launch { + validatorRecommenderFactory.awaitRecommendatorLoading(scope = viewModelScope) + + validatorsLoading.value = false + } + } + + fun goToCustomClicked() { + router.openSelectCustomValidators() + } + + fun goToRecommendedClicked() { + router.openRecommendedValidators() + } + + fun backClicked() { + setupStakingSharedState.reset() + + router.back() + } + + fun recommendedLearnMoreClicked() { + openBrowserEvent.value = appLinksProvider.recommendedValidatorsLearnMore.event() + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/di/StartChangeValidatorsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/di/StartChangeValidatorsComponent.kt new file mode 100644 index 0000000..84adb83 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/di/StartChangeValidatorsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.start.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.start.StartChangeValidatorsFragment + +@Subcomponent( + modules = [ + StartChangeValidatorsModule::class + ] +) +@ScreenScope +interface StartChangeValidatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): StartChangeValidatorsComponent + } + + fun inject(fragment: StartChangeValidatorsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/di/StartChangeValidatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/di/StartChangeValidatorsModule.kt new file mode 100644 index 0000000..c8071e0 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/change/start/di/StartChangeValidatorsModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.change.start.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.recommendations.ValidatorRecommenderFactory +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.start.StartChangeValidatorsViewModel + +@Module(includes = [ViewModelModule::class]) +class StartChangeValidatorsModule { + + @Provides + @IntoMap + @ViewModelKey(StartChangeValidatorsViewModel::class) + fun provideViewModel( + validatorRecommenderFactory: ValidatorRecommenderFactory, + router: StakingRouter, + sharedState: SetupStakingSharedState, + resourceManager: ResourceManager, + appLinksProvider: AppLinksProvider, + interactor: StakingInteractor + ): ViewModel { + return StartChangeValidatorsViewModel( + router = router, + validatorRecommenderFactory = validatorRecommenderFactory, + setupStakingSharedState = sharedState, + appLinksProvider = appLinksProvider, + resourceManager = resourceManager, + interactor = interactor + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): StartChangeValidatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(StartChangeValidatorsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/CurrentValidatorsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/CurrentValidatorsFragment.kt new file mode 100644 index 0000000..6ee591e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/CurrentValidatorsFragment.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.current + +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.CurrentStakeTargetsFragment + +class CurrentValidatorsFragment : CurrentStakeTargetsFragment() { + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .currentValidatorsFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: CurrentValidatorsViewModel) { + super.subscribe(viewModel) + observeValidations(viewModel) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/CurrentValidatorsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/CurrentValidatorsViewModel.kt new file mode 100644 index 0000000..499c417 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/CurrentValidatorsViewModel.kt @@ -0,0 +1,224 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.current + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.list.toListWithHeaders +import io.novafoundation.nova.common.list.toValueList +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.toHexAccountId +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_staking_api.domain.model.NominatedValidator +import io.novafoundation.nova.feature_staking_api.domain.model.relaychain.StakingState +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validations.delegation.controller.ChangeStackingValidationPayload +import io.novafoundation.nova.feature_staking_impl.domain.validators.current.CurrentValidatorsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingProcess +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.CurrentStakeTargetsViewModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.SelectedStakeTargetModel +import io.novafoundation.nova.feature_staking_impl.presentation.common.currentStakeTargets.model.SelectedStakeTargetStatusModel +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.formatValidatorApy +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorToValidatorDetailsWithStakeFlagParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.change.mapAddEvmTokensValidationFailureToUI +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.relaychain +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class CurrentValidatorsViewModel( + private val router: StakingRouter, + private val resourceManager: ResourceManager, + private val stakingInteractor: StakingInteractor, + private val iconGenerator: AddressIconGenerator, + private val currentValidatorsInteractor: CurrentValidatorsInteractor, + private val setupStakingSharedState: SetupStakingSharedState, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val validationExecutor: ValidationExecutor, + private val assetUseCase: AssetUseCase, + private val amountFormatter: AmountFormatter +) : CurrentStakeTargetsViewModel(), Validatable by validationExecutor { + + private val stashFlow = stakingInteractor.selectedAccountStakingStateFlow(viewModelScope) + .filterIsInstance() + .shareInBackground() + + private val assetFlow = assetUseCase.currentAssetFlow() + .shareInBackground() + + private val activeStakeFlow = assetFlow + .map { it.bondedInPlanks } + .distinctUntilChanged() + + private val groupedCurrentValidatorsFlow = combineToPair(stashFlow, activeStakeFlow) + .flatMapLatest { (stash, activeStake) -> currentValidatorsInteractor.nominatedValidatorsFlow(stash, activeStake, viewModelScope) } + .shareInBackground() + + private val flattenCurrentValidators = groupedCurrentValidatorsFlow + .map { it.toValueList() } + .shareInBackground() + + private val tokenFlow = assetFlow + .map { it.token } + + override val currentStakeTargetsFlow = groupedCurrentValidatorsFlow.combine(tokenFlow) { gropedList, token -> + val chain = selectedAssetState.chain() + + gropedList.mapKeys { (statusGroup, _) -> mapNominatedValidatorStatusToUiModel(statusGroup) } + .mapValues { (_, nominatedValidators) -> nominatedValidators.map { mapNominatedValidatorToUiModel(chain, it, token) } } + .toListWithHeaders() + } + .withLoading() + .shareInBackground() + + override val warningFlow = groupedCurrentValidatorsFlow.map { groupedList -> + val (_, validators) = groupedList.entries.firstOrNull { (group, _) -> group is NominatedValidator.Status.Group.Active } ?: return@map null + + val shouldShowWarning = validators.any { (it.status as NominatedValidator.Status.Active).willUserBeRewarded.not() } + + if (shouldShowWarning) { + resourceManager.getString(R.string.staking_your_oversubscribed_message) + } else { + null + } + } + + override val titleFlow: Flow = flowOf { + resourceManager.getString(R.string.staking_your_validators) + } + + override fun backClicked() { + router.back() + } + + override fun changeClicked() { + launch { + val accountSettings = stashFlow.first() + val payload = ChangeStackingValidationPayload(accountSettings.controllerAddress) + + validationExecutor.requireValid( + validationSystem = currentValidatorsInteractor.getValidationSystem(), + payload = payload, + validationFailureTransformer = { mapAddEvmTokensValidationFailureToUI(resourceManager, it) } + ) { + openStartChangeValidators() + } + } + } + + override fun stakeTargetInfoClicked(address: String) { + launch { + val payload = withContext(Dispatchers.Default) { + val accountId = address.toHexAccountId() + val allValidators = flattenCurrentValidators.first() + + val nominatedValidator = allValidators.first { it.validator.accountIdHex == accountId } + + val stakeTarget = mapValidatorToValidatorDetailsWithStakeFlagParcelModel(nominatedValidator) + StakeTargetDetailsPayload.relaychain(stakeTarget, stakingInteractor) + } + + router.openValidatorDetails(payload) + } + } + + private fun openStartChangeValidators() { + launch { + val currentValidators = flattenCurrentValidators.first().map(NominatedValidator::validator) + val activeStake = activeStakeFlow.first() + val newState = SetupStakingProcess.Initial.next(activeStake, currentValidators) + setupStakingSharedState.set(newState) + router.openStartChangeValidators() + } + } + + private suspend fun mapNominatedValidatorToUiModel( + chain: Chain, + nominatedValidator: NominatedValidator, + token: Token + ): SelectedStakeTargetModel { + val validator = nominatedValidator.validator + + val nominationAmount = (nominatedValidator.status as? NominatedValidator.Status.Active)?.let { activeStatus -> + amountFormatter.formatAmountToAmountModel(activeStatus.nomination, token) + } + + val validatorAddress = chain.addressOf(validator.accountIdHex.fromHex()) + + return SelectedStakeTargetModel( + addressModel = iconGenerator.createAccountAddressModel( + chain = chain, + address = validatorAddress, + name = validator.identity?.display + ), + nominated = nominationAmount, + isOversubscribed = validator.electedInfo?.isOversubscribed ?: false, + isSlashed = validator.slashed, + apy = formatValidatorApy(validator) + ) + } + + private fun mapNominatedValidatorStatusToUiModel(statusGroup: NominatedValidator.Status.Group) = when (statusGroup) { + is NominatedValidator.Status.Group.Active -> SelectedStakeTargetStatusModel( + SelectedStakeTargetStatusModel.TitleConfig( + text = resourceManager.getString(R.string.staking_your_elected_format, statusGroup.numberOfValidators), + iconRes = R.drawable.ic_checkmark_circle_16, + iconTintRes = R.color.text_positive, + textColorRes = R.color.text_primary, + ), + description = resourceManager.getString(R.string.staking_your_allocated_description_v2_2_0) + ) + + is NominatedValidator.Status.Group.Inactive -> SelectedStakeTargetStatusModel( + SelectedStakeTargetStatusModel.TitleConfig( + text = resourceManager.getString(R.string.staking_your_not_elected_format, statusGroup.numberOfValidators), + iconRes = R.drawable.ic_time_16, + iconTintRes = R.color.text_secondary, + textColorRes = R.color.text_secondary, + ), + description = resourceManager.getString(R.string.staking_your_inactive_description_v2_2_0) + ) + + is NominatedValidator.Status.Group.Elected -> SelectedStakeTargetStatusModel( + null, + description = resourceManager.getString(R.string.staking_your_not_allocated_description_v2_2_0) + ) + + is NominatedValidator.Status.Group.WaitingForNextEra -> SelectedStakeTargetStatusModel( + SelectedStakeTargetStatusModel.TitleConfig( + text = resourceManager.getString( + R.string.staking_custom_header_validators_title, + statusGroup.numberOfValidators, + statusGroup.maxValidatorsPerNominator + ), + iconRes = R.drawable.ic_time_16, + iconTintRes = R.color.text_secondary, + textColorRes = R.color.text_secondary, + ), + description = resourceManager.getString(R.string.staking_your_validators_changing_title) + ) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/di/CurrentValidatorsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/di/CurrentValidatorsComponent.kt new file mode 100644 index 0000000..611efb3 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/di/CurrentValidatorsComponent.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.current.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.current.CurrentValidatorsFragment + +@Subcomponent( + modules = [ + CurrentValidatorsModule::class + ] +) +@ScreenScope +interface CurrentValidatorsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create(@BindsInstance fragment: Fragment): CurrentValidatorsComponent + } + + fun inject(fragment: CurrentValidatorsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/di/CurrentValidatorsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/di/CurrentValidatorsModule.kt new file mode 100644 index 0000000..242e8b5 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/current/di/CurrentValidatorsModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.current.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.domain.validators.current.CurrentValidatorsInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.common.SetupStakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.validators.current.CurrentValidatorsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class CurrentValidatorsModule { + + @Provides + @IntoMap + @ViewModelKey(CurrentValidatorsViewModel::class) + fun provideViewModel( + stakingInteractor: StakingInteractor, + resourceManager: ResourceManager, + iconGenerator: AddressIconGenerator, + currentValidatorsInteractor: CurrentValidatorsInteractor, + setupStakingSharedState: SetupStakingSharedState, + router: StakingRouter, + selectedAssetState: StakingSharedState, + assetUseCase: AssetUseCase, + validationExecutor: ValidationExecutor, + amountFormatter: AmountFormatter + ): ViewModel { + return CurrentValidatorsViewModel( + router = router, + resourceManager = resourceManager, + stakingInteractor = stakingInteractor, + iconGenerator = iconGenerator, + currentValidatorsInteractor = currentValidatorsInteractor, + setupStakingSharedState = setupStakingSharedState, + selectedAssetState = selectedAssetState, + validationExecutor = validationExecutor, + assetUseCase = assetUseCase, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): CurrentValidatorsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(CurrentValidatorsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/StakeTargetDetailsPayload.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/StakeTargetDetailsPayload.kt new file mode 100644 index 0000000..3d1810a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/StakeTargetDetailsPayload.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details + +import android.os.Parcelable +import androidx.annotation.StringRes +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.RewardSuffix +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetDetailsParcelModel +import kotlinx.parcelize.Parcelize + +@Parcelize +class StakeTargetDetailsPayload( + val stakeTarget: StakeTargetDetailsParcelModel, + val displayConfig: DisplayConfig +) : Parcelable { + + companion object + + @Parcelize + class DisplayConfig( + val rewardSuffix: RewardSuffix, + val rewardedStakersPerStakeTarget: Int?, + @StringRes val titleRes: Int, + @StringRes val stakersLabelRes: Int, + @StringRes val oversubscribedWarningText: Int, + ) : Parcelable +} + +suspend fun StakeTargetDetailsPayload.Companion.relaychain( + stakeTarget: StakeTargetDetailsParcelModel, + stakingInteractor: StakingInteractor, +): StakeTargetDetailsPayload { + return StakeTargetDetailsPayload( + stakeTarget = stakeTarget, + displayConfig = StakeTargetDetailsPayload.DisplayConfig( + rewardSuffix = RewardSuffix.APY, + rewardedStakersPerStakeTarget = stakingInteractor.maxRewardedNominators(), + titleRes = R.string.staking_validator_info_title, + stakersLabelRes = R.string.staking_validator_nominators, + oversubscribedWarningText = R.string.staking_validator_my_oversubscribed_message + ) + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/ValidatorDetailsFragment.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/ValidatorDetailsFragment.kt new file mode 100644 index 0000000..4764a52 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/ValidatorDetailsFragment.kt @@ -0,0 +1,125 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details + +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams.MATCH_PARENT +import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT +import androidx.core.view.children +import androidx.core.view.updateMarginsRelative + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.addAfter +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.view.AlertView +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.setModelOrHide +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.setupIdentityMixin +import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi +import io.novafoundation.nova.feature_staking_impl.databinding.FragmentValidatorDetailsBinding +import io.novafoundation.nova.feature_staking_impl.di.StakingFeatureComponent +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.model.ValidatorAlert +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmountOrHide + +class ValidatorDetailsFragment : BaseFragment() { + + companion object { + private const val PAYLOAD = "ValidatorDetailsFragment.Payload" + + fun getBundle(payload: StakeTargetDetailsPayload): Bundle { + return Bundle().apply { + putParcelable(PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentValidatorDetailsBinding.inflate(layoutInflater) + + private val activeStakingFields by lazy(LazyThreadSafetyMode.NONE) { + listOf(binder.validatorStakingStakers, binder.validatorStakingTotalStake, binder.validatorStakingEstimatedReward, binder.validatorStakingMinimumStake) + } + + override fun initViews() { + binder.validatorDetailsToolbar.setHomeButtonListener { viewModel.backClicked() } + + binder.validatorStakingTotalStake.setOnClickListener { viewModel.totalStakeClicked() } + + binder.validatorAccountInfo.setOnClickListener { viewModel.accountActionsClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + StakingFeatureApi::class.java + ) + .validatorDetailsComponentFactory() + .create(this, argument(PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: ValidatorDetailsViewModel) { + setupExternalActions(viewModel) + setupIdentityMixin(viewModel.identityMixin, binder.validatorIdentity) + + viewModel.stakeTargetDetails.observe { validator -> + with(validator.stake) { + binder.validatorStakingStatus.showValue(status.text) + binder.validatorStakingStatus.setPrimaryValueEndIcon(status.icon, tint = status.iconTint) + + if (activeStakeModel != null) { + activeStakingFields.forEach(View::makeVisible) + + binder.validatorStakingStakers.showValue(activeStakeModel.nominatorsCount, activeStakeModel.maxNominations) + binder.validatorStakingTotalStake.showAmount(activeStakeModel.totalStake) + binder.validatorStakingMinimumStake.showAmountOrHide(activeStakeModel.minimumStake) + binder.validatorStakingEstimatedReward.showValue(activeStakeModel.apy) + } else { + activeStakingFields.forEach(View::makeGone) + } + } + + binder.validatorIdentity.setModelOrHide(validator.identity) + + binder.validatorAccountInfo.setAddressModel(validator.addressModel) + } + + viewModel.errorFlow.observe { alerts -> + removeAllAlerts() + + val alertViews = alerts.map(::createAlertView) + binder.validatorDetailsContainer.addAfter(binder.validatorAccountInfo, alertViews) + } + + viewModel.totalStakeEvent.observeEvent { + ValidatorStakeBottomSheet(requireContext(), it).show() + } + + binder.validatorDetailsToolbar.setTitle(viewModel.displayConfig.titleRes) + binder.validatorStakingStakers.setTitle(viewModel.displayConfig.stakersLabelRes) + } + + private fun removeAllAlerts() { + binder.validatorDetailsContainer.children + .filterIsInstance() + .forEach(binder.validatorDetailsContainer::removeView) + } + + private fun createAlertView(alert: ValidatorAlert): AlertView { + val style = when (alert.severity) { + ValidatorAlert.Severity.WARNING -> AlertView.StylePreset.WARNING + ValidatorAlert.Severity.ERROR -> AlertView.StylePreset.ERROR + } + + return AlertView(requireContext()).also { alertView -> + alertView.setStylePreset(style) + alertView.setMessage(alert.descriptionRes) + + alertView.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).also { params -> + params.updateMarginsRelative(start = 16.dp, end = 16.dp, top = 12.dp) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/ValidatorDetailsViewModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/ValidatorDetailsViewModel.kt new file mode 100644 index 0000000..ab06970 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/ValidatorDetailsViewModel.kt @@ -0,0 +1,112 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapStakeTargetDetailsToErrors +import io.novafoundation.nova.feature_staking_impl.presentation.mappers.mapValidatorDetailsParcelToValidatorDetailsModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakeTargetStakeParcelModel +import io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel.StakerParcelModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ValidatorDetailsViewModel( + private val assetUseCase: AssetUseCase, + private val router: StakingRouter, + private val payload: StakeTargetDetailsPayload, + private val iconGenerator: AddressIconGenerator, + private val externalActions: ExternalActions.Presentation, + private val resourceManager: ResourceManager, + private val selectedAssetState: AnySelectedAssetOptionSharedState, + private val identityMixinFactory: IdentityMixin.Factory, + private val amountFormatter: AmountFormatter +) : BaseViewModel(), ExternalActions.Presentation by externalActions { + + private val stakeTarget = payload.stakeTarget + val displayConfig = payload.displayConfig + + private val assetFlow = assetUseCase.currentAssetFlow() + .share() + + val identityMixin = identityMixinFactory.create() + + val stakeTargetDetails = assetFlow.map { asset -> + mapValidatorDetailsParcelToValidatorDetailsModel( + chain = selectedAssetState.chain(), + validator = stakeTarget, + asset = asset, + displayConfig = payload.displayConfig, + iconGenerator = iconGenerator, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) + } + .shareInBackground() + + val errorFlow = flowOf { mapStakeTargetDetailsToErrors(stakeTarget, displayConfig) } + .inBackground() + .share() + + private val _totalStakeEvent = MutableLiveData>() + val totalStakeEvent: LiveData> = _totalStakeEvent + + init { + stakeTargetDetails.onEach { + identityMixin.setIdentity(it.identity) + }.launchIn(viewModelScope) + } + + fun backClicked() { + router.back() + } + + fun totalStakeClicked() = launch { + val validatorStake = stakeTarget.stake + val asset = assetFlow.first() + val payload = calculatePayload(asset, validatorStake) + + _totalStakeEvent.value = Event(payload) + } + + private suspend fun calculatePayload(asset: Asset, stakeTargetStake: StakeTargetStakeParcelModel) = withContext(Dispatchers.Default) { + require(stakeTargetStake is StakeTargetStakeParcelModel.Active) + + val nominatorsStake = stakeTargetStake.stakers?.sumByBigInteger(StakerParcelModel::value) + + ValidatorStakeBottomSheet.Payload( + own = stakeTargetStake.ownStake?.let { amountFormatter.formatAmountToAmountModel(it, asset) }, + stakers = nominatorsStake?.let { amountFormatter.formatAmountToAmountModel(it, asset) }, + total = amountFormatter.formatAmountToAmountModel(stakeTargetStake.totalStake, asset), + stakersLabel = payload.displayConfig.stakersLabelRes + ) + } + + fun accountActionsClicked() = launch { + val address = stakeTargetDetails.first().addressModel.address + val chain = selectedAssetState.chain() + + externalActions.showAddressActions(address, chain) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/ValidatorStakeBottomSheet.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/ValidatorStakeBottomSheet.kt new file mode 100644 index 0000000..57ffe1e --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/ValidatorStakeBottomSheet.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details + +import android.content.Context +import android.os.Bundle +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import androidx.annotation.StringRes +import androidx.core.view.updateMarginsRelative +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmount +import io.novafoundation.nova.feature_wallet_api.presentation.view.showAmountOrHide + +class ValidatorStakeBottomSheet( + context: Context, + private val payload: Payload +) : FixedListBottomSheet(context, viewConfiguration = ViewConfiguration.default(context)), + WithContextExtensions by WithContextExtensions( + context + ) { + + class Payload( + val own: AmountModel?, + val stakers: AmountModel?, + val total: AmountModel, + @StringRes val stakersLabel: Int + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.staking_validator_total_stake) + + item(createCellView()) { + it.setTitle(R.string.staking_validator_own_stake) + it.showAmountOrHide(payload.own) + } + + item(createCellView()) { + it.setTitle(payload.stakersLabel) + it.showAmountOrHide(payload.stakers) + } + + item(createCellView()) { + it.setTitle(R.string.common_total) + it.showAmount(payload.total) + } + } + + private fun createCellView(): TableCellView { + return TableCellView(context).also { view -> + view.layoutParams = ViewGroup.MarginLayoutParams(MATCH_PARENT, WRAP_CONTENT).also { params -> + params.updateMarginsRelative(start = 16.dp, end = 16.dp) + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/di/ValidatorDetailsComponent.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/di/ValidatorDetailsComponent.kt new file mode 100644 index 0000000..fa0d996 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/di/ValidatorDetailsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.ValidatorDetailsFragment + +@Subcomponent( + modules = [ + ValidatorDetailsModule::class + ] +) +@ScreenScope +interface ValidatorDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: StakeTargetDetailsPayload + ): ValidatorDetailsComponent + } + + fun inject(fragment: ValidatorDetailsFragment) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/di/ValidatorDetailsModule.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/di/ValidatorDetailsModule.kt new file mode 100644 index 0000000..446b18d --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/di/ValidatorDetailsModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_staking_impl.data.StakingSharedState +import io.novafoundation.nova.feature_staking_impl.presentation.StakingRouter +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.StakeTargetDetailsPayload +import io.novafoundation.nova.feature_staking_impl.presentation.validators.details.ValidatorDetailsViewModel +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter + +@Module(includes = [ViewModelModule::class]) +class ValidatorDetailsModule { + + @Provides + @IntoMap + @ViewModelKey(ValidatorDetailsViewModel::class) + fun provideViewModel( + router: StakingRouter, + payload: StakeTargetDetailsPayload, + assetUseCase: AssetUseCase, + addressIconGenerator: AddressIconGenerator, + externalActions: ExternalActions.Presentation, + resourceManager: ResourceManager, + singleAssetSharedState: StakingSharedState, + identityMixinFactory: IdentityMixin.Factory, + amountFormatter: AmountFormatter + ): ViewModel { + return ValidatorDetailsViewModel( + assetUseCase = assetUseCase, + router = router, + payload = payload, + iconGenerator = addressIconGenerator, + externalActions = externalActions, + resourceManager = resourceManager, + selectedAssetState = singleAssetSharedState, + identityMixinFactory = identityMixinFactory, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): ValidatorDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(ValidatorDetailsViewModel::class.java) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/NominatorModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/NominatorModel.kt new file mode 100644 index 0000000..fc9692c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/NominatorModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details.model + +import java.math.BigInteger + +class NominatorModel( + val who: ByteArray, + val value: BigInteger +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/ValidatorAlert.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/ValidatorAlert.kt new file mode 100644 index 0000000..044d7af --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/ValidatorAlert.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details.model +import androidx.annotation.StringRes +import io.novafoundation.nova.feature_staking_impl.R + +sealed class ValidatorAlert(@StringRes val descriptionRes: Int, val severity: Severity) { + + enum class Severity { + WARNING, ERROR + } + + sealed class Oversubscribed(@StringRes errorDescription: Int) : ValidatorAlert(errorDescription, Severity.WARNING) { + + object UserNotInvolved : ValidatorAlert.Oversubscribed(R.string.staking_validator_other_oversubscribed_message) + + class UserMissedReward(errorDescription: Int) : ValidatorAlert.Oversubscribed(errorDescription) + } + + object Slashed : ValidatorAlert(R.string.staking_validator_slashed_desc, Severity.ERROR) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/ValidatorDetailsModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/ValidatorDetailsModel.kt new file mode 100644 index 0000000..b636207 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/ValidatorDetailsModel.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details.model + +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityModel + +class ValidatorDetailsModel( + val stake: ValidatorStakeModel, + val addressModel: AddressModel, + val identity: IdentityModel?, +) diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/ValidatorStakeModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/ValidatorStakeModel.kt new file mode 100644 index 0000000..717a481 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/details/model/ValidatorStakeModel.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.details.model + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class ValidatorStakeModel( + val status: Status, + val activeStakeModel: ActiveStakeModel?, +) { + + class Status( + val text: String, + @DrawableRes val icon: Int, + @ColorRes val iconTint: Int, + ) + + class ActiveStakeModel( + val totalStake: AmountModel, + val minimumStake: AmountModel?, + val nominatorsCount: String, + val maxNominations: String?, + val apy: String + ) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/IdentityParcelModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/IdentityParcelModel.kt new file mode 100644 index 0000000..20b11a2 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/IdentityParcelModel.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class IdentityParcelModel( + val display: String?, + val legal: String?, + val web: String?, + val matrix: String?, + val email: String?, + val pgpFingerprint: String?, + val image: String?, + val twitter: String?, + val childInfo: ChildInfo?, +) : Parcelable { + + @Parcelize + class ChildInfo( + val childName: String?, + val parentSeparateDisplay: String? + ) : Parcelable +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/StakeTargetDetailsParcelModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/StakeTargetDetailsParcelModel.kt new file mode 100644 index 0000000..0622ec6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/StakeTargetDetailsParcelModel.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class StakeTargetDetailsParcelModel( + val accountIdHex: String, + val isSlashed: Boolean, + val stake: StakeTargetStakeParcelModel, + val identity: IdentityParcelModel?, +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/StakeTargetStakeParcelModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/StakeTargetStakeParcelModel.kt new file mode 100644 index 0000000..2671488 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/StakeTargetStakeParcelModel.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel + +import android.os.Parcelable +import io.novafoundation.nova.common.utils.orZero +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal +import java.math.BigInteger + +sealed class StakeTargetStakeParcelModel : Parcelable { + + @Parcelize + object Inactive : StakeTargetStakeParcelModel() + + @Parcelize + class Active( + val totalStake: BigInteger, + val ownStake: BigInteger?, // null in case unknown + val minimumStake: BigInteger?, // null in case there is no separate min stake for this stake target + val stakers: List?, // null in case unknown + val stakersCount: Int = stakers?.size.orZero(), + val rewards: BigDecimal, + val isOversubscribed: Boolean, + val userStakeInfo: UserStakeInfo? = null + ) : StakeTargetStakeParcelModel() { + + @Parcelize + class UserStakeInfo(val willBeRewarded: Boolean) : Parcelable + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/StakerParcelModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/StakerParcelModel.kt new file mode 100644 index 0000000..e858ef4 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/validators/parcel/StakerParcelModel.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.validators.parcel + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +@Parcelize +class StakerParcelModel( + val who: ByteArray, + val value: BigInteger +) : Parcelable diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/RewardDestinationChooserView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/RewardDestinationChooserView.kt new file mode 100644 index 0000000..4f7eb92 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/RewardDestinationChooserView.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import android.widget.TextView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.view.AccountView +import io.novafoundation.nova.feature_staking_impl.databinding.ViewRewardDestinationChooserBinding + +class RewardDestinationChooserView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewRewardDestinationChooserBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + } + + val learnMore: TextView + get() = binder.rewardDestinationChooserLearnMore + + val destinationRestake: RewardDestinationView + get() = binder.rewardDestinationChooserRestake + + val destinationPayout: RewardDestinationView + get() = binder.rewardDestinationChooserPayout + + val payoutTarget: AccountView + get() = binder.rewardDestinationChooserPayoutTarget + + val payoutTitle: TextView + get() = binder.rewardDestinationChooserPayoutTitle +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/RewardDestinationView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/RewardDestinationView.kt new file mode 100644 index 0000000..3eb704c --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/RewardDestinationView.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ViewPayoutTargetBinding +import io.novafoundation.nova.feature_staking_impl.presentation.staking.main.model.RewardEstimation + +private const val CHECKABLE_DEFAULT = true + +class RewardDestinationView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private var checkable = CHECKABLE_DEFAULT + + private val binder = ViewPayoutTargetBinding.inflate(inflater(), this) + + init { + background = context.getRoundedCornerDrawable(R.color.block_background) + + attrs?.let(this::applyAttrs) + } + + private fun applyAttrs(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.RewardDestinationView) { typedArray -> + checkable = typedArray.getBoolean(R.styleable.RewardDestinationView_android_checkable, CHECKABLE_DEFAULT) + + if (checkable) { + val checked = typedArray.getBoolean(R.styleable.RewardDestinationView_android_checked, false) + setChecked(checked) + } + binder.payoutTargetCheck.setVisible(checkable) + + val targetName = typedArray.getString(R.styleable.RewardDestinationView_targetName) + targetName?.let(::setName) + } + + fun setName(name: String) { + binder.payoutTargetName.text = name + } + + fun setTokenAmount(amount: String) { + binder.payoutTargetAmountToken.text = amount + } + + fun setPercentageGain(gain: String) { + binder.payoutTargetAmountGain.text = gain + } + + fun setFiatAmount(amount: String?) { + binder.payoutTargetAmountFiat.setTextOrHide(amount) + } + + fun setChecked(checked: Boolean) { + require(checkable) { + "Cannot check non-checkable view" + } + + binder.payoutTargetCheck.isChecked = checked + } +} + +fun RewardDestinationView.showRewardEstimation(rewardEstimation: RewardEstimation) { + setTokenAmount(rewardEstimation.amount) + setFiatAmount(rewardEstimation.fiatAmount) + setPercentageGain(rewardEstimation.gain) +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/RewardDestinationViewer.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/RewardDestinationViewer.kt new file mode 100644 index 0000000..909e0a6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/RewardDestinationViewer.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.TableView +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ViewRewardDestinationViewerBinding +import io.novafoundation.nova.feature_staking_impl.presentation.common.rewardDestination.RewardDestinationModel + +class RewardDestinationViewer @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : TableView(context, attrs, defStyle) { + + private val binder = ViewRewardDestinationViewerBinding.inflate(inflater(), this) + + fun showRewardDestination(rewardDestinationModel: RewardDestinationModel?) { + if (rewardDestinationModel == null) { + makeGone() + return + } + + makeVisible() + binder.viewRewardDestinationPayoutAccount.setVisible(rewardDestinationModel is RewardDestinationModel.Payout) + + when (rewardDestinationModel) { + is RewardDestinationModel.Restake -> { + binder.viewRewardDestinationDestination.showValue(context.getString(R.string.staking_setup_restake_v2_2_0)) + } + + is RewardDestinationModel.Payout -> { + binder.viewRewardDestinationDestination.showValue(context.getString(R.string.staking_reward_destination_payout)) + binder.viewRewardDestinationPayoutAccount.showAddress(rewardDestinationModel.destination) + } + } + } + + fun setPayoutAccountClickListener(listener: (View) -> Unit) { + binder.viewRewardDestinationPayoutAccount.setOnClickListener(listener) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/StakeStatusView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/StakeStatusView.kt new file mode 100644 index 0000000..670ab1a --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/StakeStatusView.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.view + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.appcompat.widget.AppCompatTextView +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_staking_impl.R + +data class StakeStatusModel( + @DrawableRes val indicatorRes: Int, + val text: String, + @ColorRes val textColorRes: Int +) + +class StakeStatusView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : AppCompatTextView(context, attrs, defStyleAttr), + WithContextExtensions by WithContextExtensions(context) { + + init { + background = getRoundedCornerDrawable(fillColorRes = R.color.chips_background) + } + + fun setModel(stakeStatusModel: StakeStatusModel) { + setStatusIndicator(stakeStatusModel.indicatorRes) + setDrawableStart(stakeStatusModel.indicatorRes, widthInDp = 14, paddingInDp = 5) + + text = stakeStatusModel.text + setTextColorRes(stakeStatusModel.textColorRes) + } + + fun setStatusIndicator(@DrawableRes indicatorRes: Int) { + setDrawableStart(indicatorRes, widthInDp = 14, paddingInDp = 5) + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingTarget/StakingTargetModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingTarget/StakingTargetModel.kt new file mode 100644 index 0000000..f0b5651 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingTarget/StakingTargetModel.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.view.stakingTarget + +import io.novafoundation.nova.common.presentation.ColoredText +import io.novafoundation.nova.common.utils.images.Icon as CommonIcon + +data class StakingTargetModel( + val title: String, + val subtitle: ColoredText?, + val icon: TargetIcon? +) { + + sealed interface TargetIcon { + + data class Quantity(val quantity: String) : TargetIcon + + class Icon(val icon: CommonIcon) : TargetIcon + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingTarget/StakingTargetView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingTarget/StakingTargetView.kt new file mode 100644 index 0000000..496626b --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingTarget/StakingTargetView.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.view.stakingTarget + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.setColoredTextOrHide +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.getRippleMask +import io.novafoundation.nova.common.utils.getRoundedCornerDrawable +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeGoneViews +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.makeVisibleViews +import io.novafoundation.nova.common.utils.withRippleMask +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ViewStakingTargetBinding + +class StakingTargetView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + private val binder = ViewStakingTargetBinding.inflate(inflater(), this) + + init { + minHeight = 52.dp(context) + + background = getRoundedCornerDrawable(fillColorRes = R.color.block_background, cornerSizeDp = 8) + .withRippleMask(getRippleMask(cornerSizeDp = 8)) + + binder.stakingTargetQuantity.background = context.getRoundedCornerDrawable(fillColorRes = R.color.chips_background, cornerSizeInDp = 6) + } + + fun setLoadingState() { + makeVisibleViews(binder.stakingTargetTitleShimmering, binder.stakingTargetSubtitleShimmering, binder.stakingTargetIconShimmer) + makeGoneViews(binder.stakingTargetTitle, binder.stakingTargetSubtitle, binder.stakingTargetQuantity, binder.stakingTargetIcon) + } + + fun setModel(stakingTargetModel: StakingTargetModel) { + binder.stakingTargetTitle.text = stakingTargetModel.title + binder.stakingTargetTitle.makeVisible() + + binder.stakingTargetSubtitle.setColoredTextOrHide(stakingTargetModel.subtitle) + + makeGoneViews(binder.stakingTargetTitleShimmering, binder.stakingTargetSubtitleShimmering, binder.stakingTargetIconShimmer) + + when (val icon = stakingTargetModel.icon) { + is StakingTargetModel.TargetIcon.Icon -> { + binder.stakingTargetQuantity.makeGone() + binder.stakingTargetIcon.makeVisible() + binder.stakingTargetIcon.setIcon(icon.icon, imageLoader) + } + + is StakingTargetModel.TargetIcon.Quantity -> { + binder.stakingTargetIcon.makeGone() + binder.stakingTargetQuantity.makeVisible() + binder.stakingTargetQuantity.text = icon.quantity + } + + null -> { + makeGoneViews(binder.stakingTargetIcon, binder.stakingTargetQuantity) + binder.stakingTargetIcon.makeGone() + binder.stakingTargetQuantity.makeGone() + } + } + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingType/StakingTypeModel.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingType/StakingTypeModel.kt new file mode 100644 index 0000000..584b3a6 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingType/StakingTypeModel.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.view.stakingType + +import io.novafoundation.nova.feature_staking_impl.presentation.view.stakingTarget.StakingTargetModel + +class StakingTypeModel( + val isSelectable: Boolean, + val conditions: List, + val stakingTarget: StakingTarget?, +) { + interface StakingTarget { + + object Loading : StakingTarget + + class Model(val model: StakingTargetModel) : StakingTarget + } +} diff --git a/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingType/StakingTypeView.kt b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingType/StakingTypeView.kt new file mode 100644 index 0000000..f2a6853 --- /dev/null +++ b/feature-staking-impl/src/main/java/io/novafoundation/nova/feature_staking_impl/presentation/view/stakingType/StakingTypeView.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_staking_impl.presentation.view.stakingType + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.view.updateLayoutParams +import com.google.android.material.card.MaterialCardView +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_staking_impl.R +import io.novafoundation.nova.feature_staking_impl.databinding.ViewStakingTypeBinding +import io.novafoundation.nova.feature_staking_impl.presentation.view.stakingTarget.StakingTargetModel + +class StakingTypeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : MaterialCardView(context, attrs, defStyleAttr) { + + private val binder = ViewStakingTypeBinding.inflate(inflater(), this) + + init { + setCardBackgroundColor(context.getColor(R.color.secondary_screen_background)) + strokeColor = context.getColor(R.color.staking_type_card_border) + strokeWidth = 1.dp(context) + radius = 12.dpF(context) + cardElevation = 0f + } + + fun setTitle(title: String) { + binder.stakingTypeTitle.text = title + } + + fun setBackgroundRes(@DrawableRes resId: Int) { + val drawable = ContextCompat.getDrawable(context, resId) ?: return + binder.stakingTypeBackground.setImageDrawable(drawable) + binder.stakingTypeBackground.updateLayoutParams { + this.height = drawable.intrinsicHeight + } + } + + fun select(isSelected: Boolean) { + binder.stakingTypeRadioButton.isChecked = isSelected + strokeColor = if (isSelected) { + context.getColor(R.color.active_border) + } else { + context.getColor(R.color.staking_type_card_border) + } + } + + fun setConditions(conditions: List) { + binder.stakingTypeConditions.text = conditions.joinToString(separator = "\n") { it } + } + + fun setSelectable(isSelectable: Boolean) { + if (isSelectable) { + binder.stakingTypeTitle.setTextColorRes(R.color.text_primary) + binder.stakingTypeConditions.setTextColorRes(R.color.staking_type_banner_text) + } else { + binder.stakingTypeTitle.setTextColorRes(R.color.staking_type_banner_text_inactive) + binder.stakingTypeConditions.setTextColorRes(R.color.staking_type_banner_text_inactive) + } + } + + fun setStakingTarget(stakingTarget: StakingTargetModel?) { + if (stakingTarget == null) { + binder.stakingTypeTarget.makeGone() + return + } + + binder.stakingTypeTarget.makeVisible() + binder.stakingTypeTarget.setModel(stakingTarget) + } + + fun setStakingTargetClickListener(listener: OnClickListener) { + binder.stakingTypeTarget.setOnClickListener(listener) + } +} diff --git a/feature-staking-impl/src/main/res/drawable-hdpi/bg_your_stake.png b/feature-staking-impl/src/main/res/drawable-hdpi/bg_your_stake.png new file mode 100644 index 0000000..faeb09e Binary files /dev/null and b/feature-staking-impl/src/main/res/drawable-hdpi/bg_your_stake.png differ diff --git a/feature-staking-impl/src/main/res/drawable-mdpi/bg_your_stake.png b/feature-staking-impl/src/main/res/drawable-mdpi/bg_your_stake.png new file mode 100644 index 0000000..5124aef Binary files /dev/null and b/feature-staking-impl/src/main/res/drawable-mdpi/bg_your_stake.png differ diff --git a/feature-staking-impl/src/main/res/drawable-xhdpi/bg_your_stake.png b/feature-staking-impl/src/main/res/drawable-xhdpi/bg_your_stake.png new file mode 100644 index 0000000..53e95ba Binary files /dev/null and b/feature-staking-impl/src/main/res/drawable-xhdpi/bg_your_stake.png differ diff --git a/feature-staking-impl/src/main/res/drawable-xxhdpi/bg_your_stake.png b/feature-staking-impl/src/main/res/drawable-xxhdpi/bg_your_stake.png new file mode 100644 index 0000000..967b8e0 Binary files /dev/null and b/feature-staking-impl/src/main/res/drawable-xxhdpi/bg_your_stake.png differ diff --git a/feature-staking-impl/src/main/res/drawable-xxxhdpi/bg_your_stake.png b/feature-staking-impl/src/main/res/drawable-xxxhdpi/bg_your_stake.png new file mode 100644 index 0000000..f861332 Binary files /dev/null and b/feature-staking-impl/src/main/res/drawable-xxxhdpi/bg_your_stake.png differ diff --git a/feature-staking-impl/src/main/res/drawable/ic_validator_not_elected.xml b/feature-staking-impl/src/main/res/drawable/ic_validator_not_elected.xml new file mode 100644 index 0000000..8e64478 --- /dev/null +++ b/feature-staking-impl/src/main/res/drawable/ic_validator_not_elected.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_add_staking_proxy.xml b/feature-staking-impl/src/main/res/layout/fragment_add_staking_proxy.xml new file mode 100644 index 0000000..751c2fc --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_add_staking_proxy.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_bond_more.xml b/feature-staking-impl/src/main/res/layout/fragment_bond_more.xml new file mode 100644 index 0000000..bc693d9 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_bond_more.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_add_staking_proxy.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_add_staking_proxy.xml new file mode 100644 index 0000000..8b07847 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_add_staking_proxy.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_bond_more.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_bond_more.xml new file mode 100644 index 0000000..619f325 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_bond_more.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_change_validators.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_change_validators.xml new file mode 100644 index 0000000..4df50e7 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_change_validators.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_nominations.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_nominations.xml new file mode 100644 index 0000000..e2ab7ad --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_nominations.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_payout.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_payout.xml new file mode 100644 index 0000000..b1483d3 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_payout.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_rebond.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_rebond.xml new file mode 100644 index 0000000..183d160 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_rebond.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_revoke_staking_proxy.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_revoke_staking_proxy.xml new file mode 100644 index 0000000..6510d6e --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_revoke_staking_proxy.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_reward_destination.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_reward_destination.xml new file mode 100644 index 0000000..7837620 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_reward_destination.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_set_controller.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_set_controller.xml new file mode 100644 index 0000000..ef2f7d7 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_set_controller.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_confirm_unbond.xml b/feature-staking-impl/src/main/res/layout/fragment_confirm_unbond.xml new file mode 100644 index 0000000..340bfcc --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_confirm_unbond.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_current_validators.xml b/feature-staking-impl/src/main/res/layout/fragment_current_validators.xml new file mode 100644 index 0000000..01e41f2 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_current_validators.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_custom_validators_settings.xml b/feature-staking-impl/src/main/res/layout/fragment_custom_validators_settings.xml new file mode 100644 index 0000000..35d5e5e --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_custom_validators_settings.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_more_staking_options.xml b/feature-staking-impl/src/main/res/layout/fragment_more_staking_options.xml new file mode 100644 index 0000000..b655694 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_more_staking_options.xml @@ -0,0 +1,25 @@ + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_mythos_claim_rewards.xml b/feature-staking-impl/src/main/res/layout/fragment_mythos_claim_rewards.xml new file mode 100644 index 0000000..a052bb0 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_mythos_claim_rewards.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_mythos_redeem.xml b/feature-staking-impl/src/main/res/layout/fragment_mythos_redeem.xml new file mode 100644 index 0000000..4057e24 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_mythos_redeem.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_mythos_staking_select_collator_settings.xml b/feature-staking-impl/src/main/res/layout/fragment_mythos_staking_select_collator_settings.xml new file mode 100644 index 0000000..7df8025 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_mythos_staking_select_collator_settings.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_mythos_unbond.xml b/feature-staking-impl/src/main/res/layout/fragment_mythos_unbond.xml new file mode 100644 index 0000000..724a470 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_mythos_unbond.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_mythos_unbond_confirm.xml b/feature-staking-impl/src/main/res/layout/fragment_mythos_unbond_confirm.xml new file mode 100644 index 0000000..511851e --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_mythos_unbond_confirm.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_bond_more.xml b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_bond_more.xml new file mode 100644 index 0000000..f275bf3 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_bond_more.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_claim_rewards.xml b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_claim_rewards.xml new file mode 100644 index 0000000..fe1eedf --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_claim_rewards.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_confirm_bond_more.xml b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_confirm_bond_more.xml new file mode 100644 index 0000000..db54b4d --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_confirm_bond_more.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_confirm_unbond.xml b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_confirm_unbond.xml new file mode 100644 index 0000000..7b0b743 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_confirm_unbond.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_redeem.xml b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_redeem.xml new file mode 100644 index 0000000..64d674c --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_redeem.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_setup_unbond.xml b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_setup_unbond.xml new file mode 100644 index 0000000..3db495e --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_nomination_pools_setup_unbond.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_rebond.xml b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_rebond.xml new file mode 100644 index 0000000..d6dac4f --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_rebond.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_redeem.xml b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_redeem.xml new file mode 100644 index 0000000..7be1500 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_redeem.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_select_collator.xml b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_select_collator.xml new file mode 100644 index 0000000..0be8b92 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_select_collator.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_select_collator_settings.xml b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_select_collator_settings.xml new file mode 100644 index 0000000..17baaa1 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_select_collator_settings.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_start.xml b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_start.xml new file mode 100644 index 0000000..740a9e2 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_start.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_start_confirm.xml b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_start_confirm.xml new file mode 100644 index 0000000..27451f3 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_start_confirm.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_unbond.xml b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_unbond.xml new file mode 100644 index 0000000..2ac9b21 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_unbond.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_unbond_confirm.xml b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_unbond_confirm.xml new file mode 100644 index 0000000..05267f5 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_parachain_staking_unbond_confirm.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_payout_details.xml b/feature-staking-impl/src/main/res/layout/fragment_payout_details.xml new file mode 100644 index 0000000..3b3b196 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_payout_details.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_payouts_list.xml b/feature-staking-impl/src/main/res/layout/fragment_payouts_list.xml new file mode 100644 index 0000000..c3bcaf1 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_payouts_list.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_period_staking.xml b/feature-staking-impl/src/main/res/layout/fragment_period_staking.xml new file mode 100644 index 0000000..4574bbc --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_period_staking.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_rebag.xml b/feature-staking-impl/src/main/res/layout/fragment_rebag.xml new file mode 100644 index 0000000..e4f6e75 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_rebag.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_rebond_custom.xml b/feature-staking-impl/src/main/res/layout/fragment_rebond_custom.xml new file mode 100644 index 0000000..b1063be --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_rebond_custom.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_recommended_validators.xml b/feature-staking-impl/src/main/res/layout/fragment_recommended_validators.xml new file mode 100644 index 0000000..ebbbb40 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_recommended_validators.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_redeem.xml b/feature-staking-impl/src/main/res/layout/fragment_redeem.xml new file mode 100644 index 0000000..5613b22 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_redeem.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_review_custom_validators.xml b/feature-staking-impl/src/main/res/layout/fragment_review_custom_validators.xml new file mode 100644 index 0000000..da1ff5e --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_review_custom_validators.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + diff --git a/feature-staking-impl/src/main/res/layout/fragment_search_custom_validators.xml b/feature-staking-impl/src/main/res/layout/fragment_search_custom_validators.xml new file mode 100644 index 0000000..ec18eca --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_search_custom_validators.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_search_pool.xml b/feature-staking-impl/src/main/res/layout/fragment_search_pool.xml new file mode 100644 index 0000000..35370dc --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_search_pool.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_select_custom_validators.xml b/feature-staking-impl/src/main/res/layout/fragment_select_custom_validators.xml new file mode 100644 index 0000000..2365be3 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_select_custom_validators.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_select_pool.xml b/feature-staking-impl/src/main/res/layout/fragment_select_pool.xml new file mode 100644 index 0000000..fc53ba7 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_select_pool.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_select_reward_destination.xml b/feature-staking-impl/src/main/res/layout/fragment_select_reward_destination.xml new file mode 100644 index 0000000..bc80739 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_select_reward_destination.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_select_unbond.xml b/feature-staking-impl/src/main/res/layout/fragment_select_unbond.xml new file mode 100644 index 0000000..40ebb31 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_select_unbond.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_set_controller_account.xml b/feature-staking-impl/src/main/res/layout/fragment_set_controller_account.xml new file mode 100644 index 0000000..3331057 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_set_controller_account.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_setup_staking_type.xml b/feature-staking-impl/src/main/res/layout/fragment_setup_staking_type.xml new file mode 100644 index 0000000..9f7e238 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_setup_staking_type.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_staking.xml b/feature-staking-impl/src/main/res/layout/fragment_staking.xml new file mode 100644 index 0000000..9ab227f --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_staking.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-staking-impl/src/main/res/layout/fragment_staking_dashboard.xml b/feature-staking-impl/src/main/res/layout/fragment_staking_dashboard.xml new file mode 100644 index 0000000..2e82d18 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_staking_dashboard.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_staking_proxy_list.xml b/feature-staking-impl/src/main/res/layout/fragment_staking_proxy_list.xml new file mode 100644 index 0000000..b0f58d2 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_staking_proxy_list.xml @@ -0,0 +1,37 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_start_change_validators.xml b/feature-staking-impl/src/main/res/layout/fragment_start_change_validators.xml new file mode 100644 index 0000000..35c845c --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_start_change_validators.xml @@ -0,0 +1,44 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_start_multi_staking_amount.xml b/feature-staking-impl/src/main/res/layout/fragment_start_multi_staking_amount.xml new file mode 100644 index 0000000..fd640a7 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_start_multi_staking_amount.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_start_multi_staking_confirm.xml b/feature-staking-impl/src/main/res/layout/fragment_start_multi_staking_confirm.xml new file mode 100644 index 0000000..d6f18c5 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_start_multi_staking_confirm.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_start_staking_landing.xml b/feature-staking-impl/src/main/res/layout/fragment_start_staking_landing.xml new file mode 100644 index 0000000..f5e6273 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_start_staking_landing.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + diff --git a/feature-staking-impl/src/main/res/layout/fragment_validator_details.xml b/feature-staking-impl/src/main/res/layout/fragment_validator_details.xml new file mode 100644 index 0000000..2a26cd5 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_validator_details.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_yield_boost_confirm.xml b/feature-staking-impl/src/main/res/layout/fragment_yield_boost_confirm.xml new file mode 100644 index 0000000..d72a936 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_yield_boost_confirm.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/fragment_yield_boost_setup.xml b/feature-staking-impl/src/main/res/layout/fragment_yield_boost_setup.xml new file mode 100644 index 0000000..9e4c941 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/fragment_yield_boost_setup.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_alert.xml b/feature-staking-impl/src/main/res/layout/item_alert.xml new file mode 100644 index 0000000..688ce79 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_alert.xml @@ -0,0 +1,53 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_current_validator.xml b/feature-staking-impl/src/main/res/layout/item_current_validator.xml new file mode 100644 index 0000000..5447a03 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_current_validator.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_current_validator_group.xml b/feature-staking-impl/src/main/res/layout/item_current_validator_group.xml new file mode 100644 index 0000000..d2d3a89 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_current_validator_group.xml @@ -0,0 +1,35 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_dashboard_has_stake.xml b/feature-staking-impl/src/main/res/layout/item_dashboard_has_stake.xml new file mode 100644 index 0000000..4563fda --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_dashboard_has_stake.xml @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_dashboard_has_stake_loading.xml b/feature-staking-impl/src/main/res/layout/item_dashboard_has_stake_loading.xml new file mode 100644 index 0000000..b686926 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_dashboard_has_stake_loading.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_dashboard_header.xml b/feature-staking-impl/src/main/res/layout/item_dashboard_header.xml new file mode 100644 index 0000000..c15d0e8 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_dashboard_header.xml @@ -0,0 +1,33 @@ + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_dashboard_loading.xml b/feature-staking-impl/src/main/res/layout/item_dashboard_loading.xml new file mode 100644 index 0000000..e804ba8 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_dashboard_loading.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + diff --git a/feature-staking-impl/src/main/res/layout/item_dashboard_no_stake.xml b/feature-staking-impl/src/main/res/layout/item_dashboard_no_stake.xml new file mode 100644 index 0000000..098b201 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_dashboard_no_stake.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_dashboard_section.xml b/feature-staking-impl/src/main/res/layout/item_dashboard_section.xml new file mode 100644 index 0000000..f2b446f --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_dashboard_section.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_editable_staking_type.xml b/feature-staking-impl/src/main/res/layout/item_editable_staking_type.xml new file mode 100644 index 0000000..e296fc9 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_editable_staking_type.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_list_default.xml b/feature-staking-impl/src/main/res/layout/item_list_default.xml new file mode 100644 index 0000000..501858b --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_list_default.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_pool.xml b/feature-staking-impl/src/main/res/layout/item_pool.xml new file mode 100644 index 0000000..9d3e8a4 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_pool.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_proxy.xml b/feature-staking-impl/src/main/res/layout/item_proxy.xml new file mode 100644 index 0000000..363ad6c --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_proxy.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_proxy_group.xml b/feature-staking-impl/src/main/res/layout/item_proxy_group.xml new file mode 100644 index 0000000..e023874 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_proxy_group.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_select_staked_collator.xml b/feature-staking-impl/src/main/res/layout/item_select_staked_collator.xml new file mode 100644 index 0000000..f62a57c --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_select_staked_collator.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_sheet_staking_reward_estimation.xml b/feature-staking-impl/src/main/res/layout/item_sheet_staking_reward_estimation.xml new file mode 100644 index 0000000..7573586 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_sheet_staking_reward_estimation.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_staking_manage_action.xml b/feature-staking-impl/src/main/res/layout/item_staking_manage_action.xml new file mode 100644 index 0000000..fca7bc8 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_staking_manage_action.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_start_staking_landing_condition.xml b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_condition.xml new file mode 100644 index 0000000..cff182b --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_condition.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/feature-staking-impl/src/main/res/layout/item_start_staking_landing_footer.xml b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_footer.xml new file mode 100644 index 0000000..c977836 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_footer.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_start_staking_landing_shimmering.xml b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_shimmering.xml new file mode 100644 index 0000000..53dfd5b --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_shimmering.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-staking-impl/src/main/res/layout/item_start_staking_landing_shimmering_item.xml b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_shimmering_item.xml new file mode 100644 index 0000000..09b50a6 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_shimmering_item.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/feature-staking-impl/src/main/res/layout/item_start_staking_landing_title.xml b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_title.xml new file mode 100644 index 0000000..5c17ebb --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_start_staking_landing_title.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_unbonding.xml b/feature-staking-impl/src/main/res/layout/item_unbonding.xml new file mode 100644 index 0000000..0c17a1d --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_unbonding.xml @@ -0,0 +1,40 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/item_validator.xml b/feature-staking-impl/src/main/res/layout/item_validator.xml new file mode 100644 index 0000000..42afa0b --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/item_validator.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_alerts.xml b/feature-staking-impl/src/main/res/layout/view_alerts.xml new file mode 100644 index 0000000..b6a4f75 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_alerts.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_estimate_earning.xml b/feature-staking-impl/src/main/res/layout/view_estimate_earning.xml new file mode 100644 index 0000000..23fec67 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_estimate_earning.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_estimated_rewards.xml b/feature-staking-impl/src/main/res/layout/view_estimated_rewards.xml new file mode 100644 index 0000000..4aa6200 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_estimated_rewards.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_more_options.xml b/feature-staking-impl/src/main/res/layout/view_more_options.xml new file mode 100644 index 0000000..dae1c97 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_more_options.xml @@ -0,0 +1,29 @@ + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_network_info.xml b/feature-staking-impl/src/main/res/layout/view_network_info.xml new file mode 100644 index 0000000..97ce3a2 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_network_info.xml @@ -0,0 +1,41 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_network_info_cell.xml b/feature-staking-impl/src/main/res/layout/view_network_info_cell.xml new file mode 100644 index 0000000..356af28 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_network_info_cell.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_payout_target.xml b/feature-staking-impl/src/main/res/layout/view_payout_target.xml new file mode 100644 index 0000000..a085a69 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_payout_target.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_reward_destination_chooser.xml b/feature-staking-impl/src/main/res/layout/view_reward_destination_chooser.xml new file mode 100644 index 0000000..0c5f759 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_reward_destination_chooser.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_reward_destination_viewer.xml b/feature-staking-impl/src/main/res/layout/view_reward_destination_viewer.xml new file mode 100644 index 0000000..d2215d7 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_reward_destination_viewer.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_stake_summary.xml b/feature-staking-impl/src/main/res/layout/view_stake_summary.xml new file mode 100644 index 0000000..6e8a9e5 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_stake_summary.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_staking_balance_actions.xml b/feature-staking-impl/src/main/res/layout/view_staking_balance_actions.xml new file mode 100644 index 0000000..4a97ae0 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_staking_balance_actions.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/feature-staking-impl/src/main/res/layout/view_staking_info.xml b/feature-staking-impl/src/main/res/layout/view_staking_info.xml new file mode 100644 index 0000000..b6d4a5d --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_staking_info.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_staking_target.xml b/feature-staking-impl/src/main/res/layout/view_staking_target.xml new file mode 100644 index 0000000..28f9fe8 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_staking_target.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_staking_type.xml b/feature-staking-impl/src/main/res/layout/view_staking_type.xml new file mode 100644 index 0000000..b98ac64 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_staking_type.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_unbondings.xml b/feature-staking-impl/src/main/res/layout/view_unbondings.xml new file mode 100644 index 0000000..11b6a3f --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_unbondings.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_user_rewards.xml b/feature-staking-impl/src/main/res/layout/view_user_rewards.xml new file mode 100644 index 0000000..e7e9903 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_user_rewards.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/layout/view_your_pool.xml b/feature-staking-impl/src/main/res/layout/view_your_pool.xml new file mode 100644 index 0000000..07ce8d6 --- /dev/null +++ b/feature-staking-impl/src/main/res/layout/view_your_pool.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/values/attrs.xml b/feature-staking-impl/src/main/res/values/attrs.xml new file mode 100644 index 0000000..589eca4 --- /dev/null +++ b/feature-staking-impl/src/main/res/values/attrs.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/main/res/values/styles.xml b/feature-staking-impl/src/main/res/values/styles.xml new file mode 100644 index 0000000..6830bcb --- /dev/null +++ b/feature-staking-impl/src/main/res/values/styles.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature-staking-impl/src/test/java/io/novafoundation/nova/feature_staking_impl/domain/StakingInteractorExtKtTest.kt b/feature-staking-impl/src/test/java/io/novafoundation/nova/feature_staking_impl/domain/StakingInteractorExtKtTest.kt new file mode 100644 index 0000000..038d336 --- /dev/null +++ b/feature-staking-impl/src/test/java/io/novafoundation/nova/feature_staking_impl/domain/StakingInteractorExtKtTest.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_staking_impl.domain + +import io.novafoundation.nova.feature_staking_api.domain.model.Exposure +import io.novafoundation.nova.feature_staking_api.domain.model.IndividualExposure +import org.junit.Assert.assertEquals +import org.junit.Test + +class StakingInteractorExtKtTest { + + private val exposures = listOf( + Exposure( + total = 6.toBigInteger(), + own = 0.toBigInteger(), + others = listOf( + IndividualExposure(byteArrayOf(3), 3.toBigInteger()), + IndividualExposure(byteArrayOf(1), 1.toBigInteger()), + IndividualExposure(byteArrayOf(2), 2.toBigInteger()), + ) + ), + Exposure( + total = 3.toBigInteger(), + own = 0.toBigInteger(), + others = listOf( + IndividualExposure(byteArrayOf(1), 1.toBigInteger()), + IndividualExposure(byteArrayOf(2), 2.toBigInteger()), + ) + ) + ) + + @Test + fun `account not from nominators should not be rewarded`() { + runWillBeRewardedTest(expected = false, who = byteArrayOf(4), maxRewarded = 3) + } + + @Test + fun `account from first maxRewarded should be rewarded`() { + runWillBeRewardedTest(expected = true, who = byteArrayOf(2), maxRewarded = 2) + } + + @Test + fun `account not from first maxRewarded should not be rewarded`() { + runWillBeRewardedTest(expected = true, who = byteArrayOf(3), maxRewarded = 2) + } + + @Test + fun `should report NOT_PRESENT if stash is not in any validators nominations`() { + runIsActiveTest(expected = NominationStatus.NOT_PRESENT, who = byteArrayOf(4), maxRewarded = 3) + } + + @Test + fun `should report ACTIVE if at least one stake portion is not in oversubscribed section of validator`() { + // 1 is in oversubscribed section for first validator, but not for the second + runIsActiveTest(expected = NominationStatus.ACTIVE, who = byteArrayOf(1), maxRewarded = 2) + } + + @Test + fun `should report OVERSUBSCRIBED if all stake portions are in oversubscribed section of validator`() { + runIsActiveTest(expected = NominationStatus.OVERSUBSCRIBED, who = byteArrayOf(1), maxRewarded = 1) + } + + @Test + fun `should report ACTIVE if all stake portions are not in oversubscribed section of validator`() { + runIsActiveTest(expected = NominationStatus.ACTIVE, who = byteArrayOf(3), maxRewarded = 1) + } + + private fun runIsActiveTest(expected: NominationStatus, who: ByteArray, maxRewarded: Int) { + val actual = nominationStatus(who, exposures, maxRewarded) + + assertEquals(expected, actual) + } + + private fun runWillBeRewardedTest(expected: Boolean, who: ByteArray, maxRewarded: Int) { + val exposure = exposures.first() + + val actual = exposure.willAccountBeRewarded(who, maxRewarded) + + assertEquals(expected, actual) + } +} diff --git a/feature-swap-api/.gitignore b/feature-swap-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-swap-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-swap-api/build.gradle b/feature-swap-api/build.gradle new file mode 100644 index 0000000..df701f4 --- /dev/null +++ b/feature-swap-api/build.gradle @@ -0,0 +1,31 @@ +apply plugin: 'kotlin-parcelize' + +android { + + defaultConfig { + + + + namespace 'io.novafoundation.nova.feature_swap_api' + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation coroutinesDep + + implementation substrateSdkDep + implementation daggerDep + + implementation project(':runtime') + implementation project(':common') + implementation project(":feature-swap-core") + + api project(":feature-wallet-api") + api project(":feature-account-api") + + api project(':core-api') +} \ No newline at end of file diff --git a/feature-swap-api/consumer-rules.pro b/feature-swap-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-swap-api/proguard-rules.pro b/feature-swap-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-swap-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-swap-api/src/main/AndroidManifest.xml b/feature-swap-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-swap-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/di/SwapFeatureApi.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/di/SwapFeatureApi.kt new file mode 100644 index 0000000..4dae83a --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/di/SwapFeatureApi.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_swap_api.di + +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider + +interface SwapFeatureApi { + + val swapService: SwapService + + val swapSettingsStateProvider: SwapSettingsStateProvider + + val swapAvailabilityInteractor: SwapAvailabilityInteractor + + val swapRateFormatter: SwapRateFormatter + + val swapFlowScopeAggregator: SwapFlowScopeAggregator +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/interactor/SwapAvailabilityInteractor.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/interactor/SwapAvailabilityInteractor.kt new file mode 100644 index 0000000..d5f1063 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/interactor/SwapAvailabilityInteractor.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_swap_api.domain.interactor + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface SwapAvailabilityInteractor { + + suspend fun sync(coroutineScope: CoroutineScope) + + suspend fun warmUpCommonlyUsedChains(computationScope: CoroutineScope) + + fun anySwapAvailableFlow(): Flow + + suspend fun swapAvailableFlow(asset: Chain.Asset, coroutineScope: CoroutineScope): Flow +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicOperationDisplayData.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicOperationDisplayData.kt new file mode 100644 index 0000000..0c44fc3 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicOperationDisplayData.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetIdWithAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +sealed class AtomicOperationDisplayData { + + data class Transfer( + val from: FullChainAssetId, + val to: FullChainAssetId, + val amount: Balance + ) : AtomicOperationDisplayData() + + data class Swap( + val from: ChainAssetIdWithAmount, + val to: ChainAssetIdWithAmount, + ) : AtomicOperationDisplayData() +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicOperationFeeDisplayData.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicOperationFeeDisplayData.kt new file mode 100644 index 0000000..26d2125 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicOperationFeeDisplayData.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData.SwapFeeComponentDisplay +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData.SwapFeeType + +class AtomicOperationFeeDisplayData( + val components: List +) { + + class SwapFeeComponentDisplay( + val fees: List, + val type: SwapFeeType + ) { + + companion object; + } + + enum class SwapFeeType { + NETWORK, CROSS_CHAIN + } +} + +fun SwapFeeComponentDisplay.Companion.network(vararg fee: FeeBase): SwapFeeComponentDisplay { + return SwapFeeComponentDisplay(fee.toList(), SwapFeeType.NETWORK) +} + +fun SwapFeeComponentDisplay.Companion.crossChain(vararg fee: FeeBase): SwapFeeComponentDisplay { + return SwapFeeComponentDisplay(fee.toList(), SwapFeeType.CROSS_CHAIN) +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicSwapOperation.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicSwapOperation.kt new file mode 100644 index 0000000..84401dc --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicSwapOperation.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.totalAmount +import io.novafoundation.nova.feature_account_api.data.model.totalPlanksEnsuringAsset +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +interface AtomicSwapOperation { + + val estimatedSwapLimit: SwapLimit + + val assetIn: FullChainAssetId + + val assetOut: FullChainAssetId + + suspend fun constructDisplayData(): AtomicOperationDisplayData + + suspend fun estimateFee(): AtomicSwapOperationFee + + /** + * Calculates how much of assetIn (of the current segment) is needed to buy given [extraOutAmount] of asset out (of the current segment) + * Used to estimate how much extra amount of assetIn to add to the user input to accommodate future segment fees + */ + suspend fun requiredAmountInToGetAmountOut(extraOutAmount: Balance): Balance + + /** + * Additional amount that max amount calculation should leave aside for the **first** operation in the swap + * One example is Existential Deposit in case operation executes in "keep alive" manner + */ + suspend fun additionalMaxAmountDeduction(): SwapMaxAdditionalAmountDeduction + + suspend fun execute(args: AtomicSwapOperationSubmissionArgs): Result + + suspend fun submit(args: AtomicSwapOperationSubmissionArgs): Result +} + +class AtomicSwapOperationSubmissionArgs( + val actualSwapLimit: SwapLimit, +) + +class AtomicSwapOperationArgs( + val estimatedSwapLimit: SwapLimit, + val feePaymentCurrency: FeePaymentCurrency, +) + +fun AtomicSwapOperationFee.amountToLeaveOnOriginToPayTxFees(): Balance { + val submissionAsset = submissionFee.asset + return submissionFee.amount + postSubmissionFees.paidByAccount.totalAmount(submissionAsset, submissionFee.submissionOrigin.executingAccount) +} + +fun AtomicSwapOperationFee.totalFeeEnsuringSubmissionAsset(): Balance { + val postSubmissionFeesByAccount = postSubmissionFees.paidByAccount.totalPlanksEnsuringAsset(submissionFee.asset) + val postSubmissionFeesFromHolding = postSubmissionFees.paidByAccount.totalPlanksEnsuringAsset(submissionFee.asset) + + return submissionFee.amount + postSubmissionFeesByAccount + postSubmissionFeesFromHolding +} + +/** + * Collects all [FeeBase] instances from fee components + */ +fun AtomicSwapOperationFee.allBasicFees(): List { + return buildList { + add(submissionFee) + postSubmissionFees.paidByAccount.onEach(::add) + postSubmissionFees.paidFromAmount.onEach(::add) + } +} + +fun AtomicSwapOperationFee.allFeeAssets(): List { + return allBasicFees() + .map { it.asset } + .distinctBy { it.fullId } +} + +class SwapExecutionCorrection( + val actualReceivedAmount: Balance +) + +class SwapSubmissionResult( + val submissionHierarchy: SubmissionHierarchy +) diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicSwapOperationPrototype.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicSwapOperationPrototype.kt new file mode 100644 index 0000000..8a0e0b7 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/AtomicSwapOperationPrototype.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigDecimal +import kotlin.time.Duration + +interface AtomicSwapOperationPrototype { + + val fromChain: ChainId + + /** + * Roughly estimate fees for the current operation in native asset + * Implementations should favour speed instead of precision as this is called for each quoting action + */ + suspend fun roughlyEstimateNativeFee(usdConverter: UsdConverter): BigDecimal + + suspend fun maximumExecutionTime(): Duration +} + +interface UsdConverter { + + suspend fun nativeAssetEquivalentOf(usdAmount: Double): BigDecimal +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/FeeLabels.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/FeeLabels.kt new file mode 100644 index 0000000..a918ed3 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/FeeLabels.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee + +interface WithDebugLabel { + val debugLabel: String +} + +class SubmissionFeeWithLabel( + val fee: SubmissionFee, + override val debugLabel: String = "Submission" +) : WithDebugLabel, SubmissionFee by fee + +fun SubmissionFeeWithLabel(fee: SubmissionFee?, debugLabel: String): SubmissionFeeWithLabel? { + return fee?.let { SubmissionFeeWithLabel(it, debugLabel) } +} + +class FeeWithLabel( + val fee: FeeBase, + override val debugLabel: String +) : WithDebugLabel, FeeBase by fee diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/ReQuoteTrigger.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/ReQuoteTrigger.kt new file mode 100644 index 0000000..8624568 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/ReQuoteTrigger.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +typealias ReQuoteTrigger = Unit diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SlippageConfig.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SlippageConfig.kt new file mode 100644 index 0000000..14035ec --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SlippageConfig.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.percents + +class SlippageConfig( + val defaultSlippage: Fraction, + val slippageTips: List, + val minAvailableSlippage: Fraction, + val maxAvailableSlippage: Fraction, + val smallSlippage: Fraction, + val bigSlippage: Fraction +) { + + companion object { + + fun default(): SlippageConfig { + return SlippageConfig( + defaultSlippage = 0.5.percents, + slippageTips = listOf(0.1.percents, 0.5.percents, 1.0.percents), + minAvailableSlippage = 0.01.percents, + maxAvailableSlippage = 50.0.percents, + smallSlippage = 0.05.percents, + bigSlippage = 1.0.percents + ) + } + } +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapExecutionEstimate.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapExecutionEstimate.kt new file mode 100644 index 0000000..4e6a7bc --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapExecutionEstimate.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.common.utils.sum +import kotlin.time.Duration + +class SwapExecutionEstimate( + val atomicOperationsEstimates: List, + val additionalBuffer: Duration +) + +fun SwapExecutionEstimate.totalTime(): Duration { + return remainingTimeWhenExecuting(stepIndex = 0) +} + +fun SwapExecutionEstimate.remainingTimeWhenExecuting(stepIndex: Int): Duration { + return atomicOperationsEstimates.drop(stepIndex).sum() + additionalBuffer +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapFee.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapFee.kt new file mode 100644 index 0000000..8fd1b0c --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapFee.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFeeBase +import io.novafoundation.nova.feature_account_api.data.model.getAmount +import io.novafoundation.nova.feature_account_api.data.model.totalAmount +import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class SwapFee( + val segments: List, + + /** + * Fees for second and subsequent segments converted to assetIn + */ + val intermediateSegmentFeesInAssetIn: FeeBase, + + /** + * Additional deductions from max amount of asset in that are not directly caused by fees + */ + val additionalMaxAmountDeduction: SwapMaxAdditionalAmountDeduction, +) : MaxAvailableDeduction { + + data class SwapSegment(val fee: AtomicSwapOperationFee, val operation: AtomicSwapOperation) + + val firstSegmentFee = segments.first().fee + + val initialSubmissionFee = firstSegmentFee.submissionFee + + private val initialPostSubmissionFees = firstSegmentFee.postSubmissionFees + + private val assetIn = intermediateSegmentFeesInAssetIn.asset + + // Always in asset in + val additionalAmountForSwap = additionalAmountForSwap() + + override fun maxAmountDeductionFor(amountAsset: Chain.Asset): Balance { + return totalFeeAmount(amountAsset) + additionalMaxAmountDeduction(amountAsset) + } + + fun allBasicFees(): List { + return segments.flatMap { it.fee.allBasicFees() } + } + + fun totalFeeAmount(amountAsset: Chain.Asset): Balance { + val executingAccount = initialSubmissionFee.submissionOrigin.executingAccount + + val submissionFeeAmount = initialSubmissionFee.getAmount(amountAsset, executingAccount) + val additionalFeesAmount = initialPostSubmissionFees.paidByAccount.totalAmount(amountAsset, executingAccount) + + return submissionFeeAmount + additionalFeesAmount + additionalAmountForSwap.getAmount(amountAsset) + } + + private fun additionalMaxAmountDeduction(amountAsset: Chain.Asset): Balance { + // TODO deducting `fromCountedTowardsEd` from max amount is over-conservative + // Ideally we should deduct max((fromCountedTowardsEd - (countedTowardsEd - transferable)) , 0) + return if (amountAsset.fullId == assetIn.fullId) additionalMaxAmountDeduction.fromCountedTowardsEd else Balance.ZERO + } + + private fun additionalAmountForSwap(): FeeBase { + val amountTakenFromAssetIn = initialPostSubmissionFees.paidFromAmount.totalAmount(assetIn) + val totalFutureFeeInAssetIn = amountTakenFromAssetIn + intermediateSegmentFeesInAssetIn.amount + + return SubstrateFeeBase(totalFutureFeeInAssetIn, assetIn) + } +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapGraph.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapGraph.kt new file mode 100644 index 0000000..9f56663 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapGraph.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.common.utils.graph.Graph +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +interface SwapGraphEdge : QuotableEdge { + + /** + * Begin a fully-constructed, ready to submit operation + */ + suspend fun beginOperation(args: AtomicSwapOperationArgs): AtomicSwapOperation + + /** + * Append current swap edge execution to the existing transaction + * Return null if it is not possible, indicating that the new transaction should be initiated to handle this edge via + * [beginOperation] + */ + suspend fun appendToOperation(currentTransaction: AtomicSwapOperation, args: AtomicSwapOperationArgs): AtomicSwapOperation? + + /** + * Begin a operation prototype that should reflect similar structure to [beginOperation] and [appendToOperation] but is limited to available functionality + * Used during quoting to construct the operations array when not all parameters are still known + */ + suspend fun beginOperationPrototype(): AtomicSwapOperationPrototype + + /** + * Append current swap edge execution to the existing transaction prototype + * Return null if it is not possible, indicating that the new transaction should be initiated to handle this edge via + * [beginOperationPrototype] + */ + suspend fun appendToOperationPrototype(currentTransaction: AtomicSwapOperationPrototype): AtomicSwapOperationPrototype? + + /** + * Debug label to describe this edge for logging + */ + suspend fun debugLabel(): String + + /** + * Whether this Edge fee check should be skipped when adding to after a specified [predecessor] + * The main purpose is to mirror the behavior of [appendToOperation] - multiple segments appended together + * most likely will only use fee configuration from the first segment in the batch + * Note that returning true here means that [canPayNonNativeFeesInIntermediatePosition] wont be called and checked + */ + fun predecessorHandlesFees(predecessor: SwapGraphEdge): Boolean + + /** + * This indicates whether this segment can be appended to the previous one to form a single transaction + * It defaults to [predecessorHandlesFees] since ultimately they do the same thing, just under different name + */ + fun canAppendToPredecessor(predecessor: SwapGraphEdge): Boolean { + return predecessorHandlesFees(predecessor) + } + + /** + * Can be used to define additional restrictions on top of default one, "is able to pay submission fee on origin" + * This will only be called for intermediate hops for non-utility assets since other cases are always payable + */ + suspend fun canPayNonNativeFeesInIntermediatePosition(): Boolean + + /** + * Determines whether it is possible to spend whole asset-in to receive asset-out + * This has to be true for edge to be considered a valid intermediate segment + * since we don't want dust leftovers across intermediate segments + */ + suspend fun canTransferOutWholeAccountBalance(): Boolean +} + +typealias SwapGraph = Graph diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapLimit.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapLimit.kt new file mode 100644 index 0000000..ddc67cb --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapLimit.kt @@ -0,0 +1,185 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.divideToDecimal +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigDecimal + +sealed class SwapLimit { + + companion object; + + data class SpecifiedIn( + val amountIn: Balance, + val amountOutQuote: Balance, + val amountOutMin: Balance + ) : SwapLimit() + + data class SpecifiedOut( + val amountOut: Balance, + val amountInQuote: Balance, + val amountInMax: Balance + ) : SwapLimit() +} + +val SwapLimit.swapDirection: SwapDirection + get() = when (this) { + is SwapLimit.SpecifiedIn -> SwapDirection.SPECIFIED_IN + is SwapLimit.SpecifiedOut -> SwapDirection.SPECIFIED_OUT + } + +val SwapLimit.quotedAmount: Balance + get() = when (this) { + is SwapLimit.SpecifiedIn -> amountIn + is SwapLimit.SpecifiedOut -> amountOut + } + +val SwapLimit.estimatedAmountIn: Balance + get() = when (this) { + is SwapLimit.SpecifiedIn -> amountIn + is SwapLimit.SpecifiedOut -> amountInQuote + } + +val SwapLimit.amountOutMin: Balance + get() = when (this) { + is SwapLimit.SpecifiedIn -> amountOutMin + is SwapLimit.SpecifiedOut -> amountOut + } + +val SwapLimit.estimatedAmountOut: Balance + get() = when (this) { + is SwapLimit.SpecifiedIn -> amountOutQuote + is SwapLimit.SpecifiedOut -> amountOut + } + +/** + * Adjusts SwapLimit to the [newAmountIn] based on the quoted swap rate + * This is only suitable for small changes amount in, as it implicitly assumes the swap rate stays the same + */ +fun SwapLimit.replaceAmountIn(newAmountIn: Balance, shouldReplaceBuyWithSell: Boolean): SwapLimit { + return when (this) { + is SwapLimit.SpecifiedIn -> updateInAmount(newAmountIn) + is SwapLimit.SpecifiedOut -> { + if (shouldReplaceBuyWithSell) { + updateInAmountChangingToSell(newAmountIn) + } else { + updateInAmount(newAmountIn) + } + } + } +} + +fun SwapLimit.Companion.createAggregated(firstLimit: SwapLimit, lastLimit: SwapLimit): SwapLimit { + return when (firstLimit) { + is SwapLimit.SpecifiedIn -> { + require(lastLimit is SwapLimit.SpecifiedIn) + + SwapLimit.SpecifiedIn( + amountIn = firstLimit.amountIn, + amountOutQuote = lastLimit.amountOutQuote, + amountOutMin = lastLimit.amountOutMin + ) + } + + is SwapLimit.SpecifiedOut -> { + require(lastLimit is SwapLimit.SpecifiedOut) + + SwapLimit.SpecifiedOut( + amountOut = lastLimit.amountOut, + amountInQuote = firstLimit.amountInQuote, + amountInMax = firstLimit.amountInMax + ) + } + } +} + +private fun SwapLimit.SpecifiedOut.updateInAmountChangingToSell(newAmountIn: Balance): SwapLimit { + val slippage = slippage() + + val inferredQuotedBalance = replacedInQuoteAmount(newAmountIn, amountOut) + + return SpecifiedIn(amount = newAmountIn, slippage, quotedBalance = inferredQuotedBalance) +} + +private fun SwapLimit.SpecifiedOut.slippage(): Fraction { + if (amountInQuote.isZero) return Fraction.ZERO + + val slippageAsFraction = (amountInMax.divideToDecimal(amountInQuote) - BigDecimal.ONE).atLeastZero() + return slippageAsFraction.fractions +} + +private fun SwapLimit.SpecifiedIn.replaceInMultiplier(amount: Balance): BigDecimal { + return amount.divideToDecimal(amountIn) +} + +private fun SwapLimit.SpecifiedIn.replacingInAmount(newInAmount: Balance, replacingAmount: Balance): Balance { + return (replaceInMultiplier(replacingAmount) * newInAmount.toBigDecimal()).toBigInteger() +} + +private fun SwapLimit.SpecifiedIn.updateInAmount(newAmountIn: Balance): SwapLimit.SpecifiedIn { + return SwapLimit.SpecifiedIn( + amountIn = newAmountIn, + amountOutQuote = replacingInAmount(newAmountIn, replacingAmount = amountOutQuote), + amountOutMin = replacingInAmount(newAmountIn, replacingAmount = amountOutMin) + ) +} + +private fun SwapLimit.SpecifiedOut.replaceInQuoteMultiplier(amount: Balance): BigDecimal { + return amount.divideToDecimal(amountInQuote) +} + +private fun SwapLimit.SpecifiedOut.replacedInQuoteAmount(newInQuoteAmount: Balance, replacingAmount: Balance): Balance { + return (replaceInQuoteMultiplier(replacingAmount) * newInQuoteAmount.toBigDecimal()).toBigInteger() +} + +private fun SwapLimit.SpecifiedOut.updateInAmount(newAmountInQuote: Balance): SwapLimit.SpecifiedOut { + return SwapLimit.SpecifiedOut( + amountOut = replacedInQuoteAmount(newAmountInQuote, amountOut), + amountInQuote = newAmountInQuote, + amountInMax = replacedInQuoteAmount(newAmountInQuote, amountInMax) + ) +} + +fun SwapQuote.toExecuteArgs(slippage: Fraction, firstSegmentFees: FeePaymentCurrency): SwapFeeArgs { + return SwapFeeArgs( + assetIn = amountIn.chainAsset, + slippage = slippage, + direction = quotedPath.direction, + executionPath = quotedPath.path.map { quotedSwapEdge -> SegmentExecuteArgs(quotedSwapEdge) }, + firstSegmentFees = firstSegmentFees + ) +} + +fun SwapLimit(direction: SwapDirection, amount: Balance, slippage: Fraction, quotedBalance: Balance): SwapLimit { + return when (direction) { + SwapDirection.SPECIFIED_IN -> SpecifiedIn(amount, slippage, quotedBalance) + SwapDirection.SPECIFIED_OUT -> SpecifiedOut(amount, slippage, quotedBalance) + } +} + +private fun SpecifiedIn(amount: Balance, slippage: Fraction, quotedBalance: Balance): SwapLimit.SpecifiedIn { + val lessAmountCoefficient = BigDecimal.ONE - slippage.inFraction.toBigDecimal() + val amountOutMin = quotedBalance.toBigDecimal() * lessAmountCoefficient + + return SwapLimit.SpecifiedIn( + amountIn = amount, + amountOutQuote = quotedBalance, + amountOutMin = amountOutMin.toBigInteger() + ) +} + +private fun SpecifiedOut(amount: Balance, slippage: Fraction, quotedBalance: Balance): SwapLimit.SpecifiedOut { + val moreAmountCoefficient = BigDecimal.ONE + slippage.inFraction.toBigDecimal() + val amountInMax = quotedBalance.toBigDecimal() * moreAmountCoefficient + + return SwapLimit.SpecifiedOut( + amountOut = amount, + amountInQuote = quotedBalance, + amountInMax = amountInMax.toBigInteger() + ) +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapMaxAdditionalAmountDeduction.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapMaxAdditionalAmountDeduction.kt new file mode 100644 index 0000000..458c6b8 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapMaxAdditionalAmountDeduction.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +/** + * Deductions from account balance other than those caused by fees + */ +class SwapMaxAdditionalAmountDeduction( + val fromCountedTowardsEd: Balance +) diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapOperationSubmissionException.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapOperationSubmissionException.kt new file mode 100644 index 0000000..029c9fb --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapOperationSubmissionException.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +sealed class SwapOperationSubmissionException : Throwable() { + + class SimulationFailed : SwapOperationSubmissionException() +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapProgress.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapProgress.kt new file mode 100644 index 0000000..bf6a5e1 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapProgress.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +sealed class SwapProgress { + + class StepStarted(val step: SwapProgressStep) : SwapProgress() + + class Failure(val error: Throwable, val attemptedStep: SwapProgressStep) : SwapProgress() + + data object Done : SwapProgress() +} + +class SwapProgressStep( + val index: Int, + val displayData: AtomicOperationDisplayData, + val operation: AtomicSwapOperation +) diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapQuote.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapQuote.kt new file mode 100644 index 0000000..9cf0711 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapQuote.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedPath +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +data class SwapQuote( + val amountIn: ChainAssetWithAmount, + val amountOut: ChainAssetWithAmount, + val priceImpact: Fraction, + val quotedPath: QuotedPath, + val executionEstimate: SwapExecutionEstimate, + val direction: SwapDirection, +) { + + val assetIn: Chain.Asset + get() = amountIn.chainAsset + + val assetOut: Chain.Asset + get() = amountOut.chainAsset + + val planksIn: Balance + get() = amountIn.amount + + val planksOut: Balance + get() = amountOut.amount +} + +fun SwapQuote.swapRate(): BigDecimal { + return amountIn rateAgainst amountOut +} + +infix fun ChainAssetWithAmount.rateAgainst(assetOut: ChainAssetWithAmount): BigDecimal { + if (amount == Balance.ZERO) return BigDecimal.ZERO + + val amountIn = chainAsset.amountFromPlanks(amount) + val amountOut = assetOut.chainAsset.amountFromPlanks(assetOut.amount) + + return amountOut / amountIn +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapQuoteArgs.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapQuoteArgs.kt new file mode 100644 index 0000000..65e8e0d --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/SwapQuoteArgs.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_swap_api.domain.model + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedEdge +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +data class SwapQuoteArgs( + val tokenIn: Token, + val tokenOut: Token, + val amount: Balance, + val swapDirection: SwapDirection, +) + +open class SwapFeeArgs( + val assetIn: Chain.Asset, + val slippage: Fraction, + val executionPath: Path, + val direction: SwapDirection, + val firstSegmentFees: FeePaymentCurrency +) + +class SegmentExecuteArgs( + val quotedSwapEdge: QuotedEdge, +) diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/fee/AtomicSwapOperationFee.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/fee/AtomicSwapOperationFee.kt new file mode 100644 index 0000000..cf1db7a --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/fee/AtomicSwapOperationFee.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_swap_api.domain.model.fee + +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.FeeWithLabel +import io.novafoundation.nova.feature_swap_api.domain.model.SubmissionFeeWithLabel + +interface AtomicSwapOperationFee { + + /** + * Fee that is paid when submitting transaction + */ + val submissionFee: SubmissionFeeWithLabel + + val postSubmissionFees: PostSubmissionFees + + fun constructDisplayData(): AtomicOperationFeeDisplayData + + class PostSubmissionFees( + /** + * Post-submission fees paid by (some) origin account. + * This is typed as `SubmissionFee` as those fee might still use different accounts (e.g. delivery fees are always paid from requested account) + */ + val paidByAccount: List = emptyList(), + + /** + * Post-submission fees paid from swapping amount directly. Its payment is isolated and does not involve any withdrawals from accounts + */ + val paidFromAmount: List = emptyList() + ) +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/fee/SubmissionOnlyAtomicSwapOperationFee.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/fee/SubmissionOnlyAtomicSwapOperationFee.kt new file mode 100644 index 0000000..6dff81b --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/model/fee/SubmissionOnlyAtomicSwapOperationFee.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_swap_api.domain.model.fee + +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData.SwapFeeComponentDisplay +import io.novafoundation.nova.feature_swap_api.domain.model.SubmissionFeeWithLabel +import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee.PostSubmissionFees + +class SubmissionOnlyAtomicSwapOperationFee(submissionFee: SubmissionFee) : AtomicSwapOperationFee { + + override val submissionFee: SubmissionFeeWithLabel = SubmissionFeeWithLabel(submissionFee) + + override val postSubmissionFees: PostSubmissionFees = PostSubmissionFees() + + override fun constructDisplayData(): AtomicOperationFeeDisplayData { + return AtomicOperationFeeDisplayData( + components = listOf( + SwapFeeComponentDisplay( + type = AtomicOperationFeeDisplayData.SwapFeeType.NETWORK, + fees = listOf(submissionFee) + ) + ), + ) + } +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/swap/SwapService.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/swap/SwapService.kt new file mode 100644 index 0000000..c56aa66 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/domain/swap/SwapService.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_swap_api.domain.swap + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger +import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFeeArgs +import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgress +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs +import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface SwapService { + + suspend fun warmUpCommonChains(computationScope: CoroutineScope): Result + + suspend fun sync(coroutineScope: CoroutineScope) + + suspend fun assetsAvailableForSwap(computationScope: CoroutineScope): Flow> + + suspend fun availableSwapDirectionsFor(asset: Chain.Asset, computationScope: CoroutineScope): Flow> + + suspend fun hasAvailableSwapDirections(asset: Chain.Asset, computationScope: CoroutineScope): Flow + + suspend fun quote( + args: SwapQuoteArgs, + computationSharingScope: CoroutineScope + ): Result + + suspend fun estimateFee(executeArgs: SwapFeeArgs): SwapFee + + suspend fun swap(calculatedFee: SwapFee): Flow + + suspend fun submitFirstSwapStep(calculatedFee: SwapFee): Result + + suspend fun defaultSlippageConfig(chainId: ChainId): SlippageConfig + + fun runSubscriptions(metaAccount: MetaAccount): Flow + + suspend fun isDeepSwapAllowed(): Boolean +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/formatters/SwapRateFormatter.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/formatters/SwapRateFormatter.kt new file mode 100644 index 0000000..a3045a6 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/formatters/SwapRateFormatter.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_swap_api.presentation.formatters + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +interface SwapRateFormatter { + + fun format(rate: BigDecimal, assetIn: Chain.Asset, assetOut: Chain.Asset): String +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/model/SwapDirectionParcel.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/model/SwapDirectionParcel.kt new file mode 100644 index 0000000..a1a608a --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/model/SwapDirectionParcel.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_swap_api.presentation.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import kotlinx.android.parcel.Parcelize + +@Parcelize +enum class SwapDirectionParcel : Parcelable { + SPECIFIED_IN, + SPECIFIED_OUT +} + +fun SwapDirectionParcel.mapFromModel(): SwapDirection { + return when (this) { + SwapDirectionParcel.SPECIFIED_IN -> SwapDirection.SPECIFIED_IN + SwapDirectionParcel.SPECIFIED_OUT -> SwapDirection.SPECIFIED_OUT + } +} + +fun SwapDirection.toParcel(): SwapDirectionParcel { + return when (this) { + SwapDirection.SPECIFIED_IN -> SwapDirectionParcel.SPECIFIED_IN + SwapDirection.SPECIFIED_OUT -> SwapDirectionParcel.SPECIFIED_OUT + } +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/model/SwapSettingsPayload.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/model/SwapSettingsPayload.kt new file mode 100644 index 0000000..2911d3d --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/model/SwapSettingsPayload.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_swap_api.presentation.model + +import android.os.Parcelable +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.parcelize.Parcelize +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload + +sealed interface SwapSettingsPayload : Parcelable { + + val assetIn: AssetPayload + + @Parcelize + class DefaultFlow(override val assetIn: AssetPayload) : SwapSettingsPayload + + @Parcelize + class RepeatOperation( + override val assetIn: AssetPayload, + val assetOut: AssetPayload, + val amount: Balance, + val direction: SwapDirectionParcel + ) : SwapSettingsPayload +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/navigation/SwapFlowScopeAggregator.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/navigation/SwapFlowScopeAggregator.kt new file mode 100644 index 0000000..d554b45 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/navigation/SwapFlowScopeAggregator.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_swap_api.presentation.navigation + +import kotlinx.coroutines.CoroutineScope + +interface SwapFlowScopeAggregator { + + fun getFlowScope(screenScope: CoroutineScope): CoroutineScope +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/state/SwapSettings.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/state/SwapSettings.kt new file mode 100644 index 0000000..96a2b56 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/state/SwapSettings.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_swap_api.presentation.state + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.percents +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +val DEFAULT_SLIPPAGE = 0.5.percents + +data class SwapSettings( + val assetIn: Chain.Asset? = null, + val assetOut: Chain.Asset? = null, + val amount: Balance? = null, + val swapDirection: SwapDirection? = null, + val slippage: Fraction +) diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/state/SwapSettingsState.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/state/SwapSettingsState.kt new file mode 100644 index 0000000..e89a6a6 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/state/SwapSettingsState.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_swap_api.presentation.state + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.state.SelectedOptionSharedState + +interface SwapSettingsState : SelectedOptionSharedState { + + suspend fun setAssetIn(asset: Chain.Asset) + + fun setAssetOut(asset: Chain.Asset) + + fun setAmount(amount: Balance?, swapDirection: SwapDirection) + + fun setSlippage(slippage: Fraction) + + suspend fun flipAssets(): SwapSettings + + fun setSwapSettings(swapSettings: SwapSettings) +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/state/SwapSettingsStateProvider.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/state/SwapSettingsStateProvider.kt new file mode 100644 index 0000000..651daa5 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/state/SwapSettingsStateProvider.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_swap_api.presentation.state + +import kotlinx.coroutines.CoroutineScope + +interface SwapSettingsStateProvider { + + suspend fun getSwapSettingsState(coroutineScope: CoroutineScope): SwapSettingsState +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/view/SwapAssetView.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/view/SwapAssetView.kt new file mode 100644 index 0000000..3f24a8d --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/view/SwapAssetView.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_swap_api.presentation.view + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.ColorRes +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.shape.getInputBackground +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_swap_api.R +import io.novafoundation.nova.feature_swap_api.databinding.ViewSwapAssetBinding +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class SwapAssetView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), + WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewSwapAssetBinding.inflate(inflater(), this) + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + background = context.getInputBackground() + } + + fun setModel(model: Model) { + setAssetImageUrl(model.assetIcon) + setAmount(model.amount) + setNetwork(model.chainUi) + binder.swapAssetAmount.setTextColorRes(model.amountTextColorRes) + } + + private fun setAssetImageUrl(icon: Icon) { + binder.swapAssetImage.setTokenIcon(icon, imageLoader) + binder.swapAssetImage.setBackgroundResource(R.drawable.bg_token_container) + } + + private fun setAmount(amount: AmountModel) { + binder.swapAssetAmount.text = amount.token + binder.swapAssetFiat.setTextOrHide(amount.fiat) + } + + private fun setNetwork(chainUi: ChainUi) { + binder.swapAssetNetworkImage.loadChainIcon(chainUi.icon, imageLoader) + binder.swapAssetNetwork.text = chainUi.name + } + + class Model( + val assetIcon: Icon, + val amount: AmountModel, + val chainUi: ChainUi, + @ColorRes val amountTextColorRes: Int = R.color.text_primary + ) +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/view/SwapAssetsView.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/view/SwapAssetsView.kt new file mode 100644 index 0000000..8e7f03b --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/view/SwapAssetsView.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_swap_api.presentation.view + +import android.content.Context +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_swap_api.databinding.ViewSwapAssetsBinding + +class SwapAssetsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binder = ViewSwapAssetsBinding.inflate(inflater(), this) + + fun setModel(model: Model) { + setAssetIn(model.assetIn) + setAssetOut(model.assetOut) + } + + fun setAssetIn(model: SwapAssetView.Model) { + binder.viewSwapAssetsIn.setModel(model) + } + + fun setAssetOut(model: SwapAssetView.Model) { + binder.viewSwapAssetsOut.setModel(model) + } + + class Model( + val assetIn: SwapAssetView.Model, + val assetOut: SwapAssetView.Model + ) +} diff --git a/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/view/bottomSheet/description/DescriptionBottomSheetLauncherExt.kt b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/view/bottomSheet/description/DescriptionBottomSheetLauncherExt.kt new file mode 100644 index 0000000..1121b45 --- /dev/null +++ b/feature-swap-api/src/main/java/io/novafoundation/nova/feature_swap_api/presentation/view/bottomSheet/description/DescriptionBottomSheetLauncherExt.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description + +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_swap_api.R + +fun DescriptionBottomSheetLauncher.launchSwapRateDescription() { + launchDescriptionBottomSheet( + titleRes = R.string.swap_rate_title, + descriptionRes = R.string.swap_rate_description + ) +} + +fun DescriptionBottomSheetLauncher.launchPriceDifferenceDescription() { + launchDescriptionBottomSheet( + titleRes = R.string.swap_price_difference_title, + descriptionRes = R.string.swap_price_difference_description + ) +} + +fun DescriptionBottomSheetLauncher.launchSlippageDescription() { + launchDescriptionBottomSheet( + titleRes = R.string.swap_slippage_title, + descriptionRes = R.string.swap_slippage_description + ) +} diff --git a/feature-swap-api/src/main/res/layout/view_swap_asset.xml b/feature-swap-api/src/main/res/layout/view_swap_asset.xml new file mode 100644 index 0000000..c5ac7a0 --- /dev/null +++ b/feature-swap-api/src/main/res/layout/view_swap_asset.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-api/src/main/res/layout/view_swap_assets.xml b/feature-swap-api/src/main/res/layout/view_swap_assets.xml new file mode 100644 index 0000000..fa4529e --- /dev/null +++ b/feature-swap-api/src/main/res/layout/view_swap_assets.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-core/.gitignore b/feature-swap-core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-swap-core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-swap-core/api/.gitignore b/feature-swap-core/api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-swap-core/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-swap-core/api/build.gradle b/feature-swap-core/api/build.gradle new file mode 100644 index 0000000..3349c6a --- /dev/null +++ b/feature-swap-core/api/build.gradle @@ -0,0 +1,19 @@ + +android { + namespace 'io.novafoundation.nova.feature_swap_core_api' +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + + implementation substrateSdkDep + + implementation daggerDep + ksp daggerCompiler + + implementation androidDep + + api project(':core-api') +} \ No newline at end of file diff --git a/feature-swap-core/api/consumer-rules.pro b/feature-swap-core/api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-swap-core/api/proguard-rules.pro b/feature-swap-core/api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-swap-core/api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-swap-core/api/src/main/AndroidManifest.xml b/feature-swap-core/api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-swap-core/api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/network/HydraDxAssetIdConverter.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/network/HydraDxAssetIdConverter.kt new file mode 100644 index 0000000..50fa3ab --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/network/HydraDxAssetIdConverter.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_swap_core_api.data.network + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +typealias HydraDxAssetId = BigInteger +typealias HydraRemoteToLocalMapping = Map + +interface HydraDxAssetIdConverter { + + val systemAssetId: HydraDxAssetId + + suspend fun toOnChainIdOrNull(chainAsset: Chain.Asset): HydraDxAssetId? + + suspend fun toChainAssetOrNull(chain: Chain, onChainId: HydraDxAssetId): Chain.Asset? + + suspend fun allOnChainIds(chain: Chain): HydraRemoteToLocalMapping +} + +fun HydraDxAssetIdConverter.isSystemAsset(assetId: HydraDxAssetId): Boolean { + return assetId == systemAssetId +} + +suspend fun HydraDxAssetIdConverter.toOnChainIdOrThrow(chainAsset: Chain.Asset): HydraDxAssetId { + return requireNotNull(toOnChainIdOrNull(chainAsset)) +} + +suspend fun HydraDxAssetIdConverter.toChainAssetOrThrow(chain: Chain, onChainId: HydraDxAssetId): Chain.Asset { + return requireNotNull(toChainAssetOrNull(chain, onChainId)) +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/PathFeeEstimator.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/PathFeeEstimator.kt new file mode 100644 index 0000000..b3ad93a --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/PathFeeEstimator.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_swap_core_api.data.paths + +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.PathRoughFeeEstimation +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedEdge + +interface PathFeeEstimator { + + suspend fun roughlyEstimateFee(path: Path>): PathRoughFeeEstimation +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/PathQuoter.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/PathQuoter.kt new file mode 100644 index 0000000..3df7a20 --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/PathQuoter.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_swap_core_api.data.paths + +import io.novafoundation.nova.common.utils.graph.EdgeVisitFilter +import io.novafoundation.nova.common.utils.graph.Graph +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.BestPathQuote +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import java.math.BigInteger + +interface PathQuoter { + + interface Factory { + + fun create( + graphFlow: Flow>, + computationalScope: CoroutineScope, + pathFeeEstimation: PathFeeEstimator? = null, + filter: EdgeVisitFilter? = null + ): PathQuoter + } + + suspend fun findBestPath( + chainAssetIn: Chain.Asset, + chainAssetOut: Chain.Asset, + amount: BigInteger, + swapDirection: SwapDirection, + ): BestPathQuote +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/BestPathQuote.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/BestPathQuote.kt new file mode 100644 index 0000000..b6eaefc --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/BestPathQuote.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_swap_core_api.data.paths.model + +class BestPathQuote( + val candidates: List> +) { + + val bestPath: QuotedPath = candidates.max() +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/PathRoughFeeEstimation.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/PathRoughFeeEstimation.kt new file mode 100644 index 0000000..b0881bd --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/PathRoughFeeEstimation.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_swap_core_api.data.paths.model + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import java.math.BigInteger + +class PathRoughFeeEstimation(val inAssetOut: BalanceOf, val inAssetIn: BalanceOf) { + + companion object { + + fun zero(): PathRoughFeeEstimation { + return PathRoughFeeEstimation(BigInteger.ZERO, BigInteger.ZERO) + } + } +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/QuotedEdge.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/QuotedEdge.kt new file mode 100644 index 0000000..9b255a3 --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/QuotedEdge.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_swap_core_api.data.paths.model + +import java.math.BigInteger + +class QuotedEdge( + val quotedAmount: BigInteger, + val quote: BigInteger, + val edge: E +) diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/QuotedPath.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/QuotedPath.kt new file mode 100644 index 0000000..2e4750c --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/paths/model/QuotedPath.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_swap_core_api.data.paths.model + +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.common.utils.graph.WeightedEdge +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import java.math.BigInteger + +class QuotedPath( + val direction: SwapDirection, + val path: Path>, + val roughFeeEstimation: PathRoughFeeEstimation, +) : Comparable> { + + private val amountOutAfterFees: BigInteger = lastSegmentQuote - roughFeeEstimation.inAssetOut + private val amountInAfterFees: BigInteger = firstSegmentQuote + roughFeeEstimation.inAssetIn + + override fun compareTo(other: QuotedPath): Int { + return when (direction) { + // When we want to sell a token, the bigger the quote - the better + SwapDirection.SPECIFIED_IN -> (amountOutAfterFees - other.amountOutAfterFees).signum() + // When we want to buy a token, the smaller the quote - the better + SwapDirection.SPECIFIED_OUT -> (other.amountInAfterFees - amountInAfterFees).signum() + } + } +} + +class WeightBreakdown private constructor( + val individualWeights: List, + val total: Int +) { + + companion object { + + fun > fromQuotedPath(path: QuotedPath): WeightBreakdown { + val weightedPath = mutableListOf() + val individualWeights = mutableListOf() + var weight = 0 + + path.path.forEach { quotedEdge -> + val edgeWeight = quotedEdge.edge.weightForAppendingTo(weightedPath) + + weight += edgeWeight + weightedPath += quotedEdge.edge + individualWeights += edgeWeight + } + + return WeightBreakdown(individualWeights, weight) + } + } +} + +val QuotedPath<*>.quote: BigInteger + get() = when (direction) { + SwapDirection.SPECIFIED_IN -> lastSegmentQuote + SwapDirection.SPECIFIED_OUT -> firstSegmentQuote + } + +val QuotedPath<*>.quotedAmount: BigInteger + get() = when (direction) { + SwapDirection.SPECIFIED_IN -> firstSegmentQuotedAmount + SwapDirection.SPECIFIED_OUT -> lastSegmentQuotedAmount + } + +val QuotedPath<*>.lastSegmentQuotedAmount: BigInteger + get() = path.last().quotedAmount + +val QuotedPath<*>.lastSegmentQuote: BigInteger + get() = path.last().quote + +val QuotedPath<*>.firstSegmentQuote: BigInteger + get() = path.first().quote + +val QuotedPath<*>.firstSegmentQuotedAmount: BigInteger + get() = path.first().quotedAmount diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/SwapQuoting.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/SwapQuoting.kt new file mode 100644 index 0000000..6e3c7e9 --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/SwapQuoting.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_swap_core_api.data.primitive + +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface SwapQuoting { + + interface QuotingHost { + + val sharedSubscriptions: SwapQuotingSubscriptions + } + + /** + * Perform initial data sync needed to later perform [runSubscriptions] + * This is separated from [runSubscriptions] since [runSubscriptions] might be io-intense + * [sync] should be sufficient for [availableSwapDirections] to work + * whereas [runSubscriptions] should enable [QuotableEdge.quote] method to work + */ + suspend fun sync() + + suspend fun availableSwapDirections(): List + + suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/SwapQuotingSubscriptions.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/SwapQuotingSubscriptions.kt new file mode 100644 index 0000000..229efb5 --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/SwapQuotingSubscriptions.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_swap_core_api.data.primitive + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface SwapQuotingSubscriptions { + + suspend fun blockNumber(chainId: ChainId): Flow +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/errors/SwapQuoteException.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/errors/SwapQuoteException.kt new file mode 100644 index 0000000..b9c6883 --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/errors/SwapQuoteException.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_swap_core_api.data.primitive.errors + +sealed class SwapQuoteException : Exception() { + + object NotEnoughLiquidity : SwapQuoteException() +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/model/QuotableEdge.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/model/QuotableEdge.kt new file mode 100644 index 0000000..8e59316 --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/model/QuotableEdge.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_swap_core_api.data.primitive.model + +import io.novafoundation.nova.common.utils.graph.WeightedEdge +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import java.math.BigInteger + +interface QuotableEdge : WeightedEdge { + companion object { + + // Allow [0..100] precision for smaller weights + const val DEFAULT_SEGMENT_WEIGHT = 100 + } + + suspend fun quote( + amount: BigInteger, + direction: SwapDirection + ): BigInteger +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/model/SwapDirection.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/model/SwapDirection.kt new file mode 100644 index 0000000..e6574ed --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/primitive/model/SwapDirection.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_swap_core_api.data.primitive.model + +enum class SwapDirection { + SPECIFIED_IN, SPECIFIED_OUT +} + +fun SwapDirection.flip(): SwapDirection { + return when (this) { + SwapDirection.SPECIFIED_IN -> SwapDirection.SPECIFIED_OUT + SwapDirection.SPECIFIED_OUT -> SwapDirection.SPECIFIED_IN + } +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydraDxQuoting.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydraDxQuoting.kt new file mode 100644 index 0000000..d4342bd --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydraDxQuoting.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_swap_core_api.data.types.hydra + +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface HydraDxQuoting : SwapQuoting { + + interface Factory { + + fun create(chain: Chain, host: SwapQuoting.QuotingHost): HydraDxQuoting + } + + fun getSource(id: String): HydraDxQuotingSource<*> +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydraDxQuotingSource.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydraDxQuotingSource.kt new file mode 100644 index 0000000..d8e7613 --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydraDxQuotingSource.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_core_api.data.types.hydra + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface HydraDxQuotingSource : Identifiable { + + suspend fun sync() + + suspend fun availableSwapDirections(): Collection + + suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow + + interface Factory> { + + fun create(chain: Chain, host: SwapQuoting.QuotingHost): S + } +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydrationAcceptedFeeCurrenciesFetcher.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydrationAcceptedFeeCurrenciesFetcher.kt new file mode 100644 index 0000000..6c44f72 --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydrationAcceptedFeeCurrenciesFetcher.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_swap_core_api.data.types.hydra + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId + +interface HydrationAcceptedFeeCurrenciesFetcher { + + suspend fun fetchAcceptedFeeCurrencies(chain: Chain): Result> + + suspend fun isAcceptedCurrency(chainAsset: Chain.Asset): Result +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydrationPriceConversionFallback.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydrationPriceConversionFallback.kt new file mode 100644 index 0000000..44e728e --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/data/types/hydra/HydrationPriceConversionFallback.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_swap_core_api.data.types.hydra + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface HydrationPriceConversionFallback { + + suspend fun convertNativeAmount( + amount: BalanceOf, + conversionTarget: Chain.Asset + ): BalanceOf +} diff --git a/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/di/SwapCoreApi.kt b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/di/SwapCoreApi.kt new file mode 100644 index 0000000..8f44502 --- /dev/null +++ b/feature-swap-core/api/src/main/java/io/novafoundation/nova/feature_swap_core_api/di/SwapCoreApi.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_swap_core_api.di + +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback + +interface SwapCoreApi { + + val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher + + val hydraDxQuotingFactory: HydraDxQuoting.Factory + + val hydraDxAssetIdConverter: HydraDxAssetIdConverter + + val hydrationPriceConversionFallback: HydrationPriceConversionFallback + + val pathQuoterFactory: PathQuoter.Factory +} diff --git a/feature-swap-core/build.gradle b/feature-swap-core/build.gradle new file mode 100644 index 0000000..e7ef48e --- /dev/null +++ b/feature-swap-core/build.gradle @@ -0,0 +1,22 @@ + +android { + namespace 'io.novafoundation.nova.feature_swap_core' +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + + implementation project(":bindings:hydra-dx-math") + api project(":feature-swap-core:api") + + implementation substrateSdkDep + + implementation daggerDep + ksp daggerCompiler + + implementation androidDep + + api project(':core-api') +} \ No newline at end of file diff --git a/feature-swap-core/consumer-rules.pro b/feature-swap-core/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-swap-core/proguard-rules.pro b/feature-swap-core/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-swap-core/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-swap-core/src/main/AndroidManifest.xml b/feature-swap-core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-swap-core/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/MultiTransactionPaymentApi.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/MultiTransactionPaymentApi.kt new file mode 100644 index 0000000..c0c0624 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/MultiTransactionPaymentApi.kt @@ -0,0 +1,36 @@ +@file:Suppress("RedundantUnitExpression") + +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.multiTransactionPayment +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import java.math.BigInteger + +@JvmInline +value class MultiTransactionPaymentApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.multiTransactionPayment: MultiTransactionPaymentApi + get() = MultiTransactionPaymentApi(multiTransactionPayment()) + +context(StorageQueryContext) +val MultiTransactionPaymentApi.acceptedCurrencies: QueryableStorageEntry1 + get() = storage1( + name = "AcceptedCurrencies", + binding = { decoded, _ -> bindNumber(decoded) }, + ) + +context(StorageQueryContext) +val MultiTransactionPaymentApi.accountCurrencyMap: QueryableStorageEntry1 + get() = storage1( + name = "AccountCurrencyMap", + binding = { decoded, _ -> bindNumber(decoded) }, + ) diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydraDxAssetIdConverter.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydraDxAssetIdConverter.kt new file mode 100644 index 0000000..c25e719 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydraDxAssetIdConverter.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumberOrNull +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.runtime.ext.decodeOrNull +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import java.math.BigInteger + +private val SYSTEM_ON_CHAIN_ASSET_ID = BigInteger.ZERO + +internal class RealHydraDxAssetIdConverter( + private val chainRegistry: ChainRegistry +) : HydraDxAssetIdConverter { + + override val systemAssetId: HydraDxAssetId = SYSTEM_ON_CHAIN_ASSET_ID + + override suspend fun toOnChainIdOrNull(chainAsset: Chain.Asset): HydraDxAssetId? { + val runtime = chainRegistry.getRuntime(chainAsset.chainId) + return chainAsset.omniPoolTokenIdOrNull(runtime) + } + + override suspend fun toChainAssetOrNull(chain: Chain, onChainId: HydraDxAssetId): Chain.Asset? { + val runtime = chainRegistry.getRuntime(chain.id) + + return chain.assets.find { chainAsset -> + val omniPoolId = chainAsset.omniPoolTokenIdOrNull(runtime) + + omniPoolId == onChainId + } + } + + override suspend fun allOnChainIds(chain: Chain): Map { + val runtime = chainRegistry.getRuntime(chain.id) + + return chain.assets.mapNotNull { chainAsset -> + chainAsset.omniPoolTokenIdOrNull(runtime)?.let { it to chainAsset } + }.toMap() + } + + private fun Chain.Asset.omniPoolTokenIdOrNull(runtimeSnapshot: RuntimeSnapshot): HydraDxAssetId? { + return when (val type = type) { + is Chain.Asset.Type.Orml -> bindNumberOrNull(type.decodeOrNull(runtimeSnapshot)) + is Chain.Asset.Type.Native -> systemAssetId + else -> null + } + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydraDxQuoting.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydraDxQuoting.kt new file mode 100644 index 0000000..1df0025 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydraDxQuoting.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra + +import io.novafoundation.nova.common.utils.flatMapAsync +import io.novafoundation.nova.common.utils.forEachAsync +import io.novafoundation.nova.common.utils.mergeIfMultiple +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +class RealHydraDxQuotingFactory( + private val conversionSourceFactories: Iterable>, +) : HydraDxQuoting.Factory { + + override fun create(chain: Chain, host: SwapQuoting.QuotingHost): HydraDxQuoting { + return RealHydraDxQuoting( + chain = chain, + quotingSourceFactories = conversionSourceFactories, + host = host + ) + } +} + +private class RealHydraDxQuoting( + private val chain: Chain, + private val quotingSourceFactories: Iterable>, + private val host: SwapQuoting.QuotingHost, +) : HydraDxQuoting { + + private val quotingSources: Map> = createSources() + + override fun getSource(id: String): HydraDxQuotingSource<*> { + return quotingSources.getValue(id) + } + + override suspend fun sync() { + quotingSources.values.forEachAsync { it.sync() } + } + + override suspend fun availableSwapDirections(): List { + return quotingSources.values.flatMapAsync { source -> source.availableSwapDirections() } + } + + override suspend fun runSubscriptions(userAccountId: AccountId, subscriptionBuilder: SharedRequestsBuilder): Flow { + return quotingSources.values.map { + it.runSubscriptions(userAccountId, subscriptionBuilder) + }.mergeIfMultiple() + } + + private fun createSources(): Map> { + return quotingSourceFactories.map { it.create(chain, host) } + .associateBy { it.identifier } + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydrationAcceptedFeeCurrenciesFetcher.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydrationAcceptedFeeCurrenciesFetcher.kt new file mode 100644 index 0000000..02f61cb --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydrationAcceptedFeeCurrenciesFetcher.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra + +import io.novafoundation.nova.common.utils.mapNotNullToSet +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.network.isSystemAsset +import io.novafoundation.nova.feature_swap_core_api.data.network.toOnChainIdOrThrow +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Inject +import javax.inject.Named + +internal class RealHydrationAcceptedFeeCurrenciesFetcher @Inject constructor( + @Named(REMOTE_STORAGE_SOURCE) private val remoteStorage: StorageDataSource, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter +) : HydrationAcceptedFeeCurrenciesFetcher { + + override suspend fun fetchAcceptedFeeCurrencies(chain: Chain): Result> { + return runCatching { + val acceptedOnChainIds = remoteStorage.query(chain.id) { + metadata.multiTransactionPayment.acceptedCurrencies.keys() + } + + val onChainToLocalIds = hydraDxAssetIdConverter.allOnChainIds(chain) + + acceptedOnChainIds.mapNotNullToSet { onChainToLocalIds[it]?.id } + } + } + + override suspend fun isAcceptedCurrency(chainAsset: Chain.Asset): Result { + return runCatching { + val onChainId = hydraDxAssetIdConverter.toOnChainIdOrThrow(chainAsset) + + if (hydraDxAssetIdConverter.isSystemAsset(onChainId)) return@runCatching true + + val fallbackPrice = remoteStorage.query(chainAsset.chainId) { + metadata.multiTransactionPayment.acceptedCurrencies.query(onChainId) + } + + fallbackPrice != null + } + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydrationPriceConversionFallback.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydrationPriceConversionFallback.kt new file mode 100644 index 0000000..3f2ba96 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/RealHydrationPriceConversionFallback.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.BigRational +import io.novafoundation.nova.common.utils.fixedU128 +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.network.toOnChainIdOrThrow +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Inject +import javax.inject.Named + +@FeatureScope +internal class RealHydrationPriceConversionFallback @Inject constructor( + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + @Named(REMOTE_STORAGE_SOURCE) + private val remoteStorageSource: StorageDataSource, +) : HydrationPriceConversionFallback { + + override suspend fun convertNativeAmount(amount: BalanceOf, conversionTarget: Chain.Asset): BalanceOf { + if (conversionTarget.isUtilityAsset) return amount + + val targetOnChainId = hydraDxAssetIdConverter.toOnChainIdOrThrow(conversionTarget) + + val fallbackPrice = remoteStorageSource.query(conversionTarget.chainId) { + metadata.multiTransactionPayment.acceptedCurrencies.query(targetOnChainId) + } ?: error("No fallback price found") + + val fallbackPriceFractional = BigRational.fixedU128(fallbackPrice).quotient + val converted = fallbackPriceFractional * amount.toBigDecimal() + + return converted.toBigInteger() + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/HydraDxQuotableEdge.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/HydraDxQuotableEdge.kt new file mode 100644 index 0000000..d6e089b --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/HydraDxQuotableEdge.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources + +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge + +interface HydraDxQuotableEdge : QuotableEdge diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/Wegiths.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/Wegiths.kt new file mode 100644 index 0000000..179728d --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/Wegiths.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources + +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge + +object Weights { + + object Hydra { + + fun weightAppendingToPath(path: Path<*>, baseWeight: Int): Int { + // Significantly reduce weight of consequent hydration segments since they are collapsed into single tx + return if (path.isNotEmpty() && path.last() is HydraDxQuotableEdge) { + // We divide here by 10 to achieve two goals: + // 1. Divisor should be significant enough to allow multiple appended segments to be added without influencing total hydration weight much + // 2. On the other hand, divisor cannot be extremely large as we will loose precision and it wont be possible + // to distinguish different hydration segments weights between each other. + // That is also why OMNIPOOL, STABLESWAP and XYK differ by a multiple of ten + (baseWeight / 10) + } else { + baseWeight + } + } + + const val OMNIPOOL = QuotableEdge.DEFAULT_SEGMENT_WEIGHT + + const val STABLESWAP = QuotableEdge.DEFAULT_SEGMENT_WEIGHT - 10 + + const val XYK = QuotableEdge.DEFAULT_SEGMENT_WEIGHT + 10 + + const val AAVE = STABLESWAP + } + + object AssetConversion { + + // Asset conversion pools liquidity, they are unfavourable + // We do x3 to allow heuristics to find routes with 3 cross-chain to be ranked even higher prioritize + // Search via Hydration + const val SWAP = 3 * CrossChainTransfer.TRANSFER + 10 + } + + object CrossChainTransfer { + + const val TRANSFER = QuotableEdge.DEFAULT_SEGMENT_WEIGHT + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/aave/AavePoolQuotingSource.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/aave/AavePoolQuotingSource.kt new file mode 100644 index 0000000..8a2d807 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/aave/AavePoolQuotingSource.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave + +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.RemoteAndLocalId +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource + +interface AavePoolQuotingSource : HydraDxQuotingSource { + + interface Edge : QuotableEdge { + + val fromAsset: RemoteAndLocalId + + val toAsset: RemoteAndLocalId + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/aave/RealAaveSwapQuotingSource.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/aave/RealAaveSwapQuotingSource.kt new file mode 100644 index 0000000..422985f --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/aave/RealAaveSwapQuotingSource.kt @@ -0,0 +1,190 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave + +import android.util.Log +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.common.utils.graph.WeightedEdge +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights.Hydra.weightAppendingToPath +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave.model.AavePool +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave.model.AavePools +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.RemoteAndLocalId +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.matchId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting +import io.novafoundation.nova.feature_swap_core_api.data.primitive.errors.SwapQuoteException +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.math.BigInteger +import javax.inject.Inject + +@FeatureScope +class AaveSwapQuotingSourceFactory @Inject constructor( + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, +) : HydraDxQuotingSource.Factory { + + companion object { + + const val ID = "Aave" + } + + override fun create(chain: Chain, host: SwapQuoting.QuotingHost): AavePoolQuotingSource { + return RealAaveSwapQuotingSource( + hydraDxAssetIdConverter = hydraDxAssetIdConverter, + multiChainRuntimeCallsApi = multiChainRuntimeCallsApi, + chain = chain, + host = host + ) + } +} + +private class RealAaveSwapQuotingSource( + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, + private val chain: Chain, + private val host: SwapQuoting.QuotingHost, +) : AavePoolQuotingSource { + + override val identifier: String = AaveSwapQuotingSourceFactory.ID + + private val initialPoolsInfo: MutableSharedFlow> = singleReplaySharedFlow() + + private val aavePools: MutableSharedFlow = singleReplaySharedFlow() + + override suspend fun sync() { + val pairs = getPairs() + + val poolInitialInfo = pairs.matchIdsWithLocal() + initialPoolsInfo.emit(poolInitialInfo) + } + + override suspend fun availableSwapDirections(): Collection { + val poolInitialInfo = initialPoolsInfo.first() + + return poolInitialInfo.allPossibleDirections() + } + + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow = coroutineScope { + aavePools.resetReplayCache() + + host.sharedSubscriptions.blockNumber(chain.id).map { + val pools = getPools() + aavePools.emit(pools) + } + } + + private suspend fun getPairs(): List { + return runCatching { + multiChainRuntimeCallsApi.forChain(chain.id).call( + section = "AaveTradeExecutor", + method = "pairs", + arguments = emptyMap(), + returnBinding = ::bindPairs + ) + }.onFailure { Log.d(LOG_TAG, "Failed to get aave pairs", it) } + .getOrDefault(emptyList()) + } + + private suspend fun getPools(): AavePools { + return multiChainRuntimeCallsApi.forChain(chain.id).call( + section = "AaveTradeExecutor", + method = "pools", + arguments = emptyMap(), + returnBinding = ::bindPools + ) + } + + private suspend fun List.matchIdsWithLocal(): List { + val allOnChainIds = hydraDxAssetIdConverter.allOnChainIds(chain) + + return mapNotNull { poolInfo -> + AavePoolInitialInfo( + firstAsset = allOnChainIds.matchId(poolInfo.firstAsset) ?: return@mapNotNull null, + secondAsset = allOnChainIds.matchId(poolInfo.secondAsset) ?: return@mapNotNull null, + ) + } + } + + private fun Collection.allPossibleDirections(): Collection { + return buildList { + this@allPossibleDirections.forEach { poolInfo -> + add(RealXYKSwapQuotingEdge(fromAsset = poolInfo.firstAsset, toAsset = poolInfo.secondAsset)) + add(RealXYKSwapQuotingEdge(fromAsset = poolInfo.secondAsset, toAsset = poolInfo.firstAsset)) + } + } + } + + inner class RealXYKSwapQuotingEdge( + override val fromAsset: RemoteAndLocalId, + override val toAsset: RemoteAndLocalId, + ) : AavePoolQuotingSource.Edge { + + override val from: FullChainAssetId = fromAsset.second + + override val to: FullChainAssetId = toAsset.second + + override fun weightForAppendingTo(path: Path>): Int { + return weightAppendingToPath(path, Weights.Hydra.AAVE) + } + + override suspend fun quote(amount: BigInteger, direction: SwapDirection): BigInteger { + val allPools = aavePools.first() + + return allPools.quote(fromAsset.first, toAsset.first, amount, direction) + ?: throw SwapQuoteException.NotEnoughLiquidity + } + } + + private fun bindPairs(decoded: Any?): List { + return bindList(decoded) { item -> + val (first, second) = item.castToList() + AavePoolPair(bindNumber(first), bindNumber(second)) + } + } + + private fun bindPools(decoded: Any?): AavePools { + val pools = bindList(decoded, ::bindPool) + return AavePools(pools) + } + + private fun bindPool(decoded: Any?): AavePool { + val asStruct = decoded.castToStruct() + + return AavePool( + reserve = bindNumber(asStruct["reserve"]), + atoken = bindNumber(asStruct["atoken"]), + liqudityIn = bindNumber(asStruct["liqudityIn"]), + liquidityOut = bindNumber(asStruct["liqudityOut"]) + ) + } +} + +private class AavePoolPair(val firstAsset: HydraDxAssetId, val secondAsset: HydraDxAssetId) + +private class AavePoolInitialInfo( + val firstAsset: RemoteAndLocalId, + val secondAsset: RemoteAndLocalId +) diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/aave/model/AavePools.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/aave/model/AavePools.kt new file mode 100644 index 0000000..37c4af8 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/aave/model/AavePools.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave.model + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import java.math.BigInteger + +data class AavePools( + val pools: List +) { + + fun quote( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amount: BigInteger, + direction: SwapDirection + ): BalanceOf? { + val pool = findPool(assetIdIn, assetIdOut) ?: return null + + return pool.quote(assetIdOut, amount, direction) + } + + private fun findPool(assetIdIn: HydraDxAssetId, assetIdOut: HydraDxAssetId): AavePool? { + return pools.find { it.canHandleTrade(assetIdIn, assetIdOut) } + } +} + +data class AavePool( + val reserve: HydraDxAssetId, + val atoken: HydraDxAssetId, + val liqudityIn: BalanceOf, + val liquidityOut: BalanceOf +) { + + fun canHandleTrade(assetIdIn: HydraDxAssetId, assetIdOut: HydraDxAssetId): Boolean { + return findPoolTokenLiquidity(assetIdIn) != null && findPoolTokenLiquidity(assetIdOut) != null + } + + fun quote( + assetIdOut: HydraDxAssetId, + amount: BigInteger, + direction: SwapDirection + ): BalanceOf? { + return when (direction) { + SwapDirection.SPECIFIED_IN -> calculateOutGivenIn(assetIdOut, amount) + SwapDirection.SPECIFIED_OUT -> calculateInGivenOut(assetIdOut, amount) + } + } + + // Here and in calculateInGivenOut we always validate amount out (either specified or calculated) against + // assetIdOut liquidity since that's the asset that will be removed from the pool + private fun calculateOutGivenIn( + assetIdOut: HydraDxAssetId, + amountIn: BigInteger, + ): BalanceOf? { + val calculatedOut = amountIn + val liquidityOut = findPoolTokenLiquidity(assetIdOut) ?: return null + + return calculatedOut.takeIf { calculatedOut <= liquidityOut } + } + + private fun calculateInGivenOut( + assetIdOut: HydraDxAssetId, + amountOut: BigInteger, + ): BalanceOf? { + val calculatedIn = amountOut + val liquidityOut = findPoolTokenLiquidity(assetIdOut) ?: return null + + return calculatedIn.takeIf { amountOut <= liquidityOut } + } + + private fun findPoolTokenLiquidity(assetId: HydraDxAssetId): BalanceOf? { + return when (assetId) { + reserve -> liqudityIn + atoken -> liquidityOut + else -> null + } + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/AssetRegistryApi.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/AssetRegistryApi.kt new file mode 100644 index 0000000..3ae2d40 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/AssetRegistryApi.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common + +import io.novafoundation.nova.common.data.network.runtime.binding.bindInt +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.assetRegistry +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class AssetRegistryApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.assetRegistry: AssetRegistryApi + get() = AssetRegistryApi(assetRegistry()) + +context(StorageQueryContext) +val AssetRegistryApi.assets: QueryableStorageEntry1 + get() = storage1(name = "Assets", binding = ::bindHydrationAssetMetadata) + +private fun bindHydrationAssetMetadata( + decoded: Any, + assetId: HydraDxAssetId +): HydrationAssetMetadata { + val asStruct = decoded.castToStruct() + + return HydrationAssetMetadata( + assetId = assetId, + decimals = bindInt(asStruct["decimals"]), + assetType = asStruct.get>("assetType")!!.name + ) +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/HydrationAssetMetadata.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/HydrationAssetMetadata.kt new file mode 100644 index 0000000..32e1b69 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/HydrationAssetMetadata.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common + +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId + +class HydrationAssetMetadata( + val assetId: HydraDxAssetId, + val decimals: Int, + val assetType: String +) { + + fun determineAssetType(nativeId: HydraDxAssetId): HydrationAssetType { + return when { + assetId == nativeId -> HydrationAssetType.Native + assetType == "Erc20" -> HydrationAssetType.Erc20(assetId) + else -> HydrationAssetType.Orml(assetId) + } + } +} + +class HydrationAssetMetadataMap( + private val nativeId: HydraDxAssetId, + private val metadataMap: Map +) { + + fun getAssetType(assetId: HydraDxAssetId): HydrationAssetType? { + val metadata = metadataMap[assetId] ?: return null + + return metadata.determineAssetType(nativeId) + } + + fun getDecimals(assetId: HydraDxAssetId): Int? { + val metadata = metadataMap[assetId] ?: return null + + return metadata.decimals + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/HydrationAssetType.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/HydrationAssetType.kt new file mode 100644 index 0000000..f2171ea --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/HydrationAssetType.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common + +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type.Orml.SubType + +sealed class HydrationAssetType { + + companion object; + + data object Native : HydrationAssetType() + + class Orml(val assetId: HydraDxAssetId) : HydrationAssetType() + + class Erc20(val assetId: HydraDxAssetId) : HydrationAssetType() +} + +fun HydrationAssetType.Companion.fromAsset(chainAsset: Chain.Asset, hydrationAssetId: HydraDxAssetId): HydrationAssetType { + return when (val type = chainAsset.type) { + is Chain.Asset.Type.Native -> HydrationAssetType.Native + is Chain.Asset.Type.Orml -> when (type.subType) { + SubType.DEFAULT -> HydrationAssetType.Orml(hydrationAssetId) + SubType.HYDRATION_EVM -> HydrationAssetType.Erc20(hydrationAssetId) + } + + else -> throw IllegalArgumentException("Unsupported asset type: ${chainAsset.type}") + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/HydrationBalanceFetcher.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/HydrationBalanceFetcher.kt new file mode 100644 index 0000000..cb94ada --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/common/HydrationBalanceFetcher.kt @@ -0,0 +1,157 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common + +import io.novafoundation.nova.common.data.network.ext.transferableBalance +import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance +import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindOrmlAccountBalanceOrEmpty +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.domain.balance.TransferableMode +import io.novafoundation.nova.common.domain.balance.calculateTransferable +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.tokens +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.call.RuntimeCallsApi +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.typed.account +import io.novafoundation.nova.runtime.storage.typed.system +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger +import javax.inject.Inject +import javax.inject.Named + +/** + * This is a simplified version of [AssetBalanceSource] which we use here because usage of AssetBalanceSource + * would create a circular dependency + * + * TODO fix this: balances should be extracted to a separate module to allow better reusability + */ +interface HydrationBalanceFetcher { + + suspend fun subscribeToTransferableBalance( + chainId: ChainId, + type: HydrationAssetType, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow +} + +@FeatureScope +class HydrationBalanceFetcherFactory @Inject constructor( + @Named(REMOTE_STORAGE_SOURCE) + private val remoteStorageDataSource: StorageDataSource, + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi +) { + + fun create(swapHost: SwapQuoting.QuotingHost): HydrationBalanceFetcher { + return RealHydrationBalanceFetcher(remoteStorageDataSource, multiChainRuntimeCallsApi, swapHost) + } +} + +class RealHydrationBalanceFetcher( + private val remoteStorageDataSource: StorageDataSource, + private val runtimeCallsApi: MultiChainRuntimeCallsApi, + private val swapHost: SwapQuoting.QuotingHost, +) : HydrationBalanceFetcher { + + override suspend fun subscribeToTransferableBalance( + chainId: ChainId, + type: HydrationAssetType, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return when (type) { + is HydrationAssetType.Native -> subscribeNativeAssetBalance(chainId, accountId, subscriptionBuilder) + is HydrationAssetType.Orml -> subscribeOrmlAssetBalance(chainId, type.assetId, accountId, subscriptionBuilder) + is HydrationAssetType.Erc20 -> subscribeErc20AssetBalance(chainId, type.assetId, accountId) + } + } + + private suspend fun subscribeNativeAssetBalance( + chainId: ChainId, + poolAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return remoteStorageDataSource.subscribe(chainId, subscriptionBuilder) { + metadata.system.account.observe(poolAccountId).map { + val accountInfo = it ?: AccountInfo.empty() + + accountInfo.transferableBalance() + } + } + } + + private suspend fun subscribeOrmlAssetBalance( + chainId: ChainId, + hydrationAssetId: HydraDxAssetId, + poolAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return remoteStorageDataSource.subscribe(chainId, subscriptionBuilder) { + metadata.tokens().storage("Accounts").observe( + poolAccountId, + hydrationAssetId, + binding = ::bindOrmlAccountBalanceOrEmpty + ).map { + TransferableMode.REGULAR.calculateTransferable(it) + } + } + } + + private suspend fun subscribeErc20AssetBalance( + chainId: ChainId, + hydrationAssetId: HydraDxAssetId, + accountId: AccountId, + ): Flow { + val blockNumberFlow = swapHost.sharedSubscriptions.blockNumber(chainId) + + return flow { + val initialBalance = fetchBalance(chainId, hydrationAssetId, accountId) + emit(initialBalance) + + blockNumberFlow.collect { + val newBalance = fetchBalance(chainId, hydrationAssetId, accountId) + emit(newBalance) + } + } + .map { TransferableMode.REGULAR.calculateTransferable(it) } + .distinctUntilChanged() + } + + private suspend fun fetchBalance(chainId: ChainId, hydrationAssetId: HydraDxAssetId, accountId: AccountId): AccountBalance { + return runtimeCallsApi.forChain(chainId).fetchBalance(hydrationAssetId, accountId) + } + + private suspend fun RuntimeCallsApi.fetchBalance(hydrationAssetId: HydraDxAssetId, accountId: AccountId): AccountBalance { + return call( + section = "CurrenciesApi", + method = "account", + arguments = mapOf( + "asset_id" to hydrationAssetId, + "who" to accountId + ), + returnBinding = { bindAssetBalance(it) } + ) + } + + private fun bindAssetBalance(decoded: Any?): AccountBalance { + val asStruct = decoded.castToStruct() + + return AccountBalance( + free = bindNumber(asStruct["free"]), + frozen = bindNumber(asStruct["frozen"]), + reserved = bindNumber(asStruct["reserved"]), + ) + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/DynamicFeesApi.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/DynamicFeesApi.kt new file mode 100644 index 0000000..438bd3f --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/DynamicFeesApi.kt @@ -0,0 +1,28 @@ +@file:Suppress("RedundantUnitExpression") + +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool + +import io.novafoundation.nova.common.utils.dynamicFees +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.DynamicFee +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.bindDynamicFee +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class DynamicFeesApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.dynamicFeesApi: DynamicFeesApi + get() = DynamicFeesApi(dynamicFees()) + +context(StorageQueryContext) +val DynamicFeesApi.assetFee: QueryableStorageEntry1 + get() = storage1( + name = "AssetFee", + binding = { decoded, _ -> bindDynamicFee(decoded) }, + ) diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/OmniPoolId.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/OmniPoolId.kt new file mode 100644 index 0000000..5d7b571 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/OmniPoolId.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool + +import io.novafoundation.nova.common.utils.padEnd +import io.novasama.substrate_sdk_android.runtime.AccountId + +fun omniPoolAccountId(): AccountId { + return "modlomnipool".encodeToByteArray().padEnd(expectedSize = 32, padding = 0) +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/OmniPoolQuotingSource.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/OmniPoolQuotingSource.kt new file mode 100644 index 0000000..680f76f --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/OmniPoolQuotingSource.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool + +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.RemoteIdAndLocalAsset +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource + +interface OmniPoolQuotingSource : HydraDxQuotingSource { + + interface Edge : QuotableEdge { + + val fromAsset: RemoteIdAndLocalAsset + + val toAsset: RemoteIdAndLocalAsset + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/OmnipoolApi.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/OmnipoolApi.kt new file mode 100644 index 0000000..9a9017e --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/OmnipoolApi.kt @@ -0,0 +1,33 @@ +@file:Suppress("RedundantUnitExpression") + +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool + +import io.novafoundation.nova.common.utils.omnipool +import io.novafoundation.nova.common.utils.omnipoolOrNull +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.OmnipoolAssetState +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.bindOmnipoolAssetState +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class OmnipoolApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.omnipoolOrNull: OmnipoolApi? + get() = omnipoolOrNull()?.let(::OmnipoolApi) + +context(StorageQueryContext) +val RuntimeMetadata.omnipool: OmnipoolApi + get() = OmnipoolApi(omnipool()) + +context(StorageQueryContext) +val OmnipoolApi.assets: QueryableStorageEntry1 + get() = storage1( + name = "Assets", + binding = ::bindOmnipoolAssetState, + ) diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/RealOmniPoolQuotingSource.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/RealOmniPoolQuotingSource.kt new file mode 100644 index 0000000..20dd541 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/RealOmniPoolQuotingSource.kt @@ -0,0 +1,236 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.dynamicFees +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.common.utils.graph.WeightedEdge +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.omnipool +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.utils.toMultiSubscription +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.HydrationAssetType +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.HydrationBalanceFetcher +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.HydrationBalanceFetcherFactory +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.fromAsset +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights.Hydra.weightAppendingToPath +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.DynamicFee +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.OmniPool +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.OmniPoolFees +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.OmniPoolToken +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.OmnipoolAssetState +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.RemoteIdAndLocalAsset +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.feeParamsConstant +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.quote +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting +import io.novafoundation.nova.feature_swap_core_api.data.primitive.errors.SwapQuoteException +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.math.BigInteger +import javax.inject.Inject +import javax.inject.Named + +@FeatureScope +class OmniPoolQuotingSourceFactory @Inject constructor( + @Named(REMOTE_STORAGE_SOURCE) + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val hydrationBalanceFetcherFactory: HydrationBalanceFetcherFactory +) : HydraDxQuotingSource.Factory { + + companion object { + + const val SOURCE_ID = "OmniPool" + } + + override fun create(chain: Chain, host: SwapQuoting.QuotingHost): OmniPoolQuotingSource { + return RealOmniPoolQuotingSource( + remoteStorageSource = remoteStorageSource, + chainRegistry = chainRegistry, + hydraDxAssetIdConverter = hydraDxAssetIdConverter, + hydrationBalanceFetcher = hydrationBalanceFetcherFactory.create(host), + chain = chain, + ) + } +} + +private class RealOmniPoolQuotingSource( + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val hydrationBalanceFetcher: HydrationBalanceFetcher, + private val chain: Chain, +) : OmniPoolQuotingSource { + + override val identifier = OmniPoolQuotingSourceFactory.SOURCE_ID + + private val pooledOnChainAssetIdsState: MutableSharedFlow> = singleReplaySharedFlow() + + private val omniPoolFlow: MutableSharedFlow = singleReplaySharedFlow() + + override suspend fun sync() { + val pooledOnChainAssetIds = getPooledOnChainAssetIds() + + val pooledChainAssetsIds = matchKnownChainAssetIds(pooledOnChainAssetIds) + pooledOnChainAssetIdsState.emit(pooledChainAssetsIds) + } + + override suspend fun availableSwapDirections(): Collection { + val pooledOnChainAssetIds = pooledOnChainAssetIdsState.first() + + return pooledOnChainAssetIds.flatMap { remoteAndLocal -> + pooledOnChainAssetIds.mapNotNull { otherRemoteAndLocal -> + // In OmniPool, each asset is tradable with any other except itself + if (remoteAndLocal.second.id != otherRemoteAndLocal.second.id) { + RealOmniPoolQuotingEdge(fromAsset = remoteAndLocal, toAsset = otherRemoteAndLocal) + } else { + null + } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + omniPoolFlow.resetReplayCache() + + val pooledAssets = pooledOnChainAssetIdsState.first() + + val omniPoolStateFlow = pooledAssets.map { (onChainId, _) -> + remoteStorageSource.subscribe(chain.id, subscriptionBuilder) { + metadata.omnipool.assets.observeNonNull(onChainId).map { + onChainId to it + } + } + } + .toMultiSubscription(pooledAssets.size) + + val poolAccountId = omniPoolAccountId() + + val omniPoolBalancesFlow = pooledAssets.map { (omniPoolTokenId, chainAsset) -> + val hydrationAssetType = HydrationAssetType.fromAsset(chainAsset, omniPoolTokenId) + + hydrationBalanceFetcher.subscribeToTransferableBalance(chainAsset.chainId, hydrationAssetType, poolAccountId, subscriptionBuilder).map { + omniPoolTokenId to it + } + } + .toMultiSubscription(pooledAssets.size) + + val feesFlow = pooledAssets.map { (omniPoolTokenId, _) -> + remoteStorageSource.subscribe(chain.id, subscriptionBuilder) { + metadata.dynamicFeesApi.assetFee.observe(omniPoolTokenId).map { + omniPoolTokenId to it + } + } + }.toMultiSubscription(pooledAssets.size) + + val defaultFees = getDefaultFees() + + return combine(omniPoolStateFlow, omniPoolBalancesFlow, feesFlow) { poolState, poolBalances, fees -> + createOmniPool(poolState, poolBalances, fees, defaultFees) + } + .onEach(omniPoolFlow::emit) + .map { } + } + + private suspend fun getPooledOnChainAssetIds(): List { + return remoteStorageSource.query(chain.id) { + val hubAssetId = metadata.omnipool().numberConstant("HubAssetId", runtime) + val allAssets = runtime.metadata.omnipoolOrNull?.assets?.keys().orEmpty() + + // remove hubAssetId from trading paths + allAssets.filter { it != hubAssetId } + } + } + + private suspend fun matchKnownChainAssetIds(onChainIds: List): List { + val hydraDxAssetIds = hydraDxAssetIdConverter.allOnChainIds(chain) + + return onChainIds.mapNotNull { onChainId -> + val asset = hydraDxAssetIds[onChainId] ?: return@mapNotNull null + + onChainId to asset + } + } + + private fun createOmniPool( + poolAssetStates: Map, + poolBalances: Map, + fees: Map, + defaultFees: OmniPoolFees, + ): OmniPool { + val tokensState = poolAssetStates.mapValues { (tokenId, poolAssetState) -> + val assetBalance = poolBalances[tokenId].orZero() + val tokenFees = fees[tokenId]?.let { OmniPoolFees(it.protocolFee, it.assetFee) } ?: defaultFees + + OmniPoolToken( + hubReserve = poolAssetState.hubReserve, + shares = poolAssetState.shares, + protocolShares = poolAssetState.protocolShares, + tradeability = poolAssetState.tradeability, + balance = assetBalance, + fees = tokenFees + ) + } + + return OmniPool(tokensState) + } + + private suspend fun getDefaultFees(): OmniPoolFees { + val runtime = chainRegistry.getRuntime(chain.id) + + val assetFeeParams = runtime.metadata.dynamicFees().feeParamsConstant("AssetFeeParameters", runtime) + val protocolFeeParams = runtime.metadata.dynamicFees().feeParamsConstant("ProtocolFeeParameters", runtime) + + return OmniPoolFees( + protocolFee = protocolFeeParams.minFee, + assetFee = assetFeeParams.minFee + ) + } + + private inner class RealOmniPoolQuotingEdge( + override val fromAsset: RemoteIdAndLocalAsset, + override val toAsset: RemoteIdAndLocalAsset, + ) : OmniPoolQuotingSource.Edge { + + override val from: FullChainAssetId = fromAsset.second.fullId + + override val to: FullChainAssetId = toAsset.second.fullId + + override fun weightForAppendingTo(path: Path>): Int { + return weightAppendingToPath(path, Weights.Hydra.OMNIPOOL) + } + + override suspend fun quote(amount: BigInteger, direction: SwapDirection): BigInteger { + val omniPool = omniPoolFlow.first() + + return omniPool.quote(fromAsset.first, toAsset.first, amount, direction) + ?: throw SwapQuoteException.NotEnoughLiquidity + } + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/Aliases.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/Aliases.kt new file mode 100644 index 0000000..f5c0bdb --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/Aliases.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model + +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraRemoteToLocalMapping +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +typealias RemoteAndLocalId = Pair +typealias RemoteIdAndLocalAsset = Pair +typealias RemoteAndLocalIdOptional = Pair + +@Suppress("UNCHECKED_CAST") +fun RemoteAndLocalIdOptional.flatten(): RemoteAndLocalId? { + return second?.let { this as RemoteAndLocalId } +} + +val RemoteAndLocalId.remoteId + get() = first + +val RemoteAndLocalId.localId + get() = second + +fun HydraRemoteToLocalMapping.matchId(remoteId: HydraDxAssetId): RemoteAndLocalId? { + return get(remoteId)?.fullId?.let { remoteId to it } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/DynamicFee.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/DynamicFee.kt new file mode 100644 index 0000000..b868f81 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/DynamicFee.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindPermill +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.Perbill +import io.novafoundation.nova.common.utils.constant +import io.novafoundation.nova.common.utils.decoded +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +class DynamicFee( + val assetFee: Perbill, + val protocolFee: Perbill +) + +fun bindDynamicFee(decoded: Any): DynamicFee { + val asStruct = decoded.castToStruct() + + return DynamicFee( + assetFee = bindPermill(asStruct["assetFee"]), + protocolFee = bindPermill(asStruct["protocolFee"]), + ) +} + +class FeeParams( + val minFee: Perbill, +) + +fun bindFeeParams(decoded: Any?): FeeParams { + val asStruct = decoded.castToStruct() + + return FeeParams( + minFee = bindPermill(asStruct["minFee"]), + ) +} + +fun Module.feeParamsConstant(name: String, runtimeSnapshot: RuntimeSnapshot): FeeParams { + return bindFeeParams(constant(name).decoded(runtimeSnapshot)) +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/OmniPool.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/OmniPool.kt new file mode 100644 index 0000000..0b3bb25 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/OmniPool.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model + +import io.novafoundation.nova.common.utils.Perbill +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import java.math.BigInteger +import kotlin.math.floor + +class OmniPool( + val tokens: Map, +) + +class OmniPoolFees( + val protocolFee: Perbill, + val assetFee: Perbill +) + +class OmniPoolToken( + val hubReserve: BigInteger, + val shares: BigInteger, + val protocolShares: BigInteger, + val tradeability: Tradeability, + val balance: BigInteger, + val fees: OmniPoolFees +) + +fun OmniPool.quote( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amount: BigInteger, + direction: SwapDirection +): BigInteger? { + return when (direction) { + SwapDirection.SPECIFIED_IN -> calculateOutGivenIn(assetIdIn, assetIdOut, amount) + SwapDirection.SPECIFIED_OUT -> calculateInGivenOut(assetIdIn, assetIdOut, amount) + } +} + +fun OmniPool.calculateOutGivenIn( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amountIn: BigInteger +): BigInteger { + val tokenInState = tokens.getValue(assetIdIn) + val tokenOutState = tokens.getValue(assetIdOut) + + val protocolFee = tokenInState.fees.protocolFee + val assetFee = tokenOutState.fees.assetFee + + val inHubReserve = tokenInState.hubReserve.toDouble() + val inReserve = tokenInState.balance.toDouble() + + val inAmount = amountIn.toDouble() + + val deltaHubReserveIn = inAmount * inHubReserve / (inReserve + inAmount) + + val protocolFeeAmount = floor(protocolFee.value * deltaHubReserveIn) + + val deltaHubReserveOut = deltaHubReserveIn - protocolFeeAmount + + val outReserveHp = tokenOutState.balance.toDouble() + val outHubReserveHp = tokenOutState.hubReserve.toDouble() + + val deltaReserveOut = outReserveHp * deltaHubReserveOut / (outHubReserveHp + deltaHubReserveOut) + val amountOut = deltaReserveOut.deductFraction(assetFee) + + return amountOut.toBigDecimal().toBigInteger() +} + +fun OmniPool.calculateInGivenOut( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amountOut: BigInteger +): BigInteger? { + val tokenInState = tokens.getValue(assetIdIn) + val tokenOutState = tokens.getValue(assetIdOut) + + val protocolFee = tokenInState.fees.protocolFee + val assetFee = tokenOutState.fees.assetFee + + val outHubReserve = tokenOutState.hubReserve.toDouble() + val outReserve = tokenOutState.balance.toDouble() + + val outAmount = amountOut.toDouble() + + val outReserveNoFee = outReserve.deductFraction(assetFee) + + val deltaHubReserveOut = outHubReserve * outAmount / (outReserveNoFee - outAmount) + 1 + + val deltaHubReserveIn = deltaHubReserveOut / (1.0 - protocolFee.value) + + val inHubReserveHp = tokenInState.hubReserve.toDouble() + + if (deltaHubReserveIn >= inHubReserveHp) { + return null + } + + val inReserveHp = tokenInState.balance.toDouble() + + val deltaReserveIn = inReserveHp * deltaHubReserveIn / (inHubReserveHp - deltaHubReserveIn) + 1 + + return deltaReserveIn.takeIf { it >= 0 }?.toBigDecimal()?.toBigInteger() +} + +private fun Double.deductFraction(perbill: Perbill): Double = this - this * perbill.value diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/OmnipoolAssetState.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/OmnipoolAssetState.kt new file mode 100644 index 0000000..2c040c6 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/OmnipoolAssetState.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import java.math.BigInteger + +class OmnipoolAssetState( + val tokenId: HydraDxAssetId, + val hubReserve: BigInteger, + val shares: BigInteger, + val protocolShares: BigInteger, + val tradeability: Tradeability +) + +fun bindOmnipoolAssetState(decoded: Any?, tokenId: HydraDxAssetId): OmnipoolAssetState { + val struct = decoded.castToStruct() + + return OmnipoolAssetState( + tokenId = tokenId, + hubReserve = bindNumber(struct["hubReserve"]), + shares = bindNumber(struct["shares"]), + protocolShares = bindNumber(struct["protocolShares"]), + tradeability = bindTradeability(struct["tradable"]) + ) +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/Tradeability.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/Tradeability.kt new file mode 100644 index 0000000..3c081d2 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/omnipool/model/Tradeability.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import java.math.BigInteger + +@JvmInline +value class Tradeability(val value: BigInteger) { + + companion object { + // / Asset is allowed to be sold into omnipool + val SELL = 0b0000_0001.toBigInteger() + + // / Asset is allowed to be bought into omnipool + val BUY = 0b0000_0010.toBigInteger() + } + + fun canBuy(): Boolean = flagEnabled(BUY) + + fun canSell(): Boolean = flagEnabled(SELL) + + private fun flagEnabled(flag: BigInteger) = value and flag == flag +} + +fun bindTradeability(value: Any?): Tradeability { + val asStruct = value.castToStruct() + + return Tradeability(bindNumber(asStruct["bits"])) +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/RealStableSwapQuotingSource.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/RealStableSwapQuotingSource.kt new file mode 100644 index 0000000..eebd1b8 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/RealStableSwapQuotingSource.kt @@ -0,0 +1,352 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap + +import com.google.gson.Gson +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.common.utils.graph.WeightedEdge +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.utils.toMultiSubscription +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.HydrationAssetMetadataMap +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.HydrationBalanceFetcher +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.HydrationBalanceFetcherFactory +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.assetRegistry +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.assets +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights.Hydra.weightAppendingToPath +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.RemoteAndLocalId +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.RemoteAndLocalIdOptional +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.Tradeability +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.flatten +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.omniPoolAccountId +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model.StablePool +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model.StablePoolAsset +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model.StableSwapPoolInfo +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model.StalbeSwapPoolPegInfo +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model.quote +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting +import io.novafoundation.nova.feature_swap_core_api.data.primitive.errors.SwapQuoteException +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.encrypt.json.asLittleEndianBytes +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.math.BigInteger +import javax.inject.Inject +import javax.inject.Named +import io.novafoundation.nova.common.utils.combine as combine6 + +@FeatureScope +class StableSwapQuotingSourceFactory @Inject constructor( + @Named(REMOTE_STORAGE_SOURCE) + private val remoteStorageSource: StorageDataSource, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val hydrationBalanceFetcherFactory: HydrationBalanceFetcherFactory, + private val gson: Gson, +) : HydraDxQuotingSource.Factory { + + companion object { + + const val ID = "StableSwap" + } + + override fun create(chain: Chain, host: SwapQuoting.QuotingHost): StableSwapQuotingSource { + return RealStableSwapQuotingSource( + remoteStorageSource = remoteStorageSource, + hydraDxAssetIdConverter = hydraDxAssetIdConverter, + hydrationBalanceFetcher = hydrationBalanceFetcherFactory.create(host), + chain = chain, + gson = gson, + host = host + ) + } +} + +private class RealStableSwapQuotingSource( + private val remoteStorageSource: StorageDataSource, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val hydrationBalanceFetcher: HydrationBalanceFetcher, + override val chain: Chain, + private val gson: Gson, + private val host: SwapQuoting.QuotingHost, +) : StableSwapQuotingSource { + + override val identifier: String = StableSwapQuotingSourceFactory.ID + + private val initialPoolsInfo: MutableSharedFlow> = singleReplaySharedFlow() + + private val stablePools: MutableSharedFlow> = singleReplaySharedFlow() + + override suspend fun sync() { + val pools = getPools() + + val poolInitialInfo = pools.matchIdsWithLocal() + initialPoolsInfo.emit(poolInitialInfo) + } + + override suspend fun availableSwapDirections(): Collection { + val poolInitialInfo = initialPoolsInfo.first() + + return poolInitialInfo.allPossibleDirections() + } + + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow = coroutineScope { + stablePools.resetReplayCache() + + val initialPoolsInfo = initialPoolsInfo.first() + + val poolInfoSubscriptions = initialPoolsInfo.map { poolInfo -> + remoteStorageSource.subscribe(chain.id, subscriptionBuilder) { + runtime.metadata.stableSwap.pools.observe(poolInfo.sharedAsset.first).map { + poolInfo.sharedAsset.first to it + } + } + }.toMultiSubscription(initialPoolsInfo.size) + + val omniPoolAccountId = omniPoolAccountId() + + val allAssetIds = initialPoolsInfo.collectAllAssetIds() + val assetsMetadataMap = fetchAssetMetadataMap(allAssetIds) + + val poolSharedAssetBalanceSubscriptions = initialPoolsInfo.map { poolInfo -> + val sharedAssetRemoteId = poolInfo.sharedAsset.first + + subscribeTransferableBalance(subscriptionBuilder, omniPoolAccountId, sharedAssetRemoteId, assetsMetadataMap).map { + sharedAssetRemoteId to it + } + }.toMultiSubscription(initialPoolsInfo.size) + + val totalPooledAssets = initialPoolsInfo.sumOf { it.poolAssets.size } + + val poolParticipatingAssetsBalanceSubscription = initialPoolsInfo.flatMap { poolInfo -> + val poolAccountId = stableSwapPoolAccountId(poolInfo.sharedAsset.first) + + poolInfo.poolAssets.map { poolAsset -> + subscribeTransferableBalance(subscriptionBuilder, poolAccountId, poolAsset.first, assetsMetadataMap).map { + val key = poolInfo.sharedAsset.first to poolAsset.first + key to it + } + } + }.toMultiSubscription(totalPooledAssets) + + val totalIssuanceSubscriptions = initialPoolsInfo.map { poolInfo -> + remoteStorageSource.subscribe(chain.id, subscriptionBuilder) { + runtime.metadata.hydraTokens.totalIssuance.observe(poolInfo.sharedAsset.first).map { + poolInfo.sharedAsset.first to it.orZero() + } + } + }.toMultiSubscription(initialPoolsInfo.size) + + val pegsSubscriptions = initialPoolsInfo.map { poolInfo -> + remoteStorageSource.subscribe(chain.id, subscriptionBuilder) { + val poolId = poolInfo.sharedAsset.first + runtime.metadata.stableSwap.poolPegs.observe(poolId).map { + poolId to it + } + } + }.toMultiSubscription(initialPoolsInfo.size) + + combine6( + poolInfoSubscriptions, + poolSharedAssetBalanceSubscriptions, + poolParticipatingAssetsBalanceSubscription, + totalIssuanceSubscriptions, + host.sharedSubscriptions.blockNumber(chain.id), + pegsSubscriptions + ) { poolInfos, poolSharedAssetBalances, poolParticipatingAssetBalances, totalIssuances, currentBlock, pegs -> + createStableSwapPool(poolInfos, poolSharedAssetBalances, poolParticipatingAssetBalances, totalIssuances, currentBlock, assetsMetadataMap, pegs) + } + .onEach(stablePools::emit) + .map { } + } + + private suspend fun subscribeTransferableBalance( + subscriptionBuilder: SharedRequestsBuilder, + account: AccountId, + assetId: HydraDxAssetId, + assetMetadataMap: HydrationAssetMetadataMap, + ): Flow { + // In case token type was not possible to resolve - just return zero + val tokenType = assetMetadataMap.getAssetType(assetId) ?: return flowOf(BalanceOf.ZERO) + return hydrationBalanceFetcher.subscribeToTransferableBalance(chain.id, tokenType, account, subscriptionBuilder) + } + + private fun createStableSwapPool( + poolInfos: Map, + poolSharedAssetBalances: Map, + poolParticipatingAssetBalances: Map, BigInteger>, + totalIssuances: Map, + currentBlock: BlockNumber, + assetMetadataMap: HydrationAssetMetadataMap, + pegs: Map + ): List { + return poolInfos.mapNotNull outer@{ (poolId, poolInfo) -> + if (poolInfo == null) return@outer null + + val sharedAssetBalance = poolSharedAssetBalances[poolId].orZero() + val sharedChainAssetPrecision = assetMetadataMap.getDecimals(poolId) ?: return@outer null + val sharedAsset = StablePoolAsset(sharedAssetBalance, poolId, sharedChainAssetPrecision) + val sharedAssetIssuance = totalIssuances[poolId].orZero() + + val pooledAssets = poolInfo.assets.mapNotNull { pooledAssetId -> + val pooledAssetBalance = poolParticipatingAssetBalances[poolId to pooledAssetId].orZero() + val decimals = assetMetadataMap.getDecimals(pooledAssetId) ?: return@mapNotNull null + + StablePoolAsset(pooledAssetBalance, pooledAssetId, decimals) + } + + StablePool( + sharedAsset = sharedAsset, + assets = pooledAssets, + initialAmplification = poolInfo.initialAmplification, + finalAmplification = poolInfo.finalAmplification, + initialBlock = poolInfo.initialBlock, + finalBlock = poolInfo.finalBlock, + fee = poolInfo.fee, + sharedAssetIssuance = sharedAssetIssuance, + gson = gson, + currentBlock = currentBlock, + pegs = pegs[poolId]?.current ?: StablePool.getDefaultPegs(pooledAssets.size) + ) + } + } + + private fun Collection.collectAllAssetIds(): List { + return flatMap { pool -> + buildList { + add(pool.sharedAsset.first) + + pool.poolAssets.onEach { + add(it.first) + } + } + } + } + + private suspend fun fetchAssetMetadataMap(allAssetIds: List): HydrationAssetMetadataMap { + return remoteStorageSource.query(chain.id) { + val assetMetadatas = metadata.assetRegistry.assets.multi(allAssetIds).filterNotNull() + HydrationAssetMetadataMap( + nativeId = hydraDxAssetIdConverter.systemAssetId, + metadataMap = assetMetadatas + ) + } + } + + private fun stableSwapPoolAccountId(poolId: HydraDxAssetId): AccountId { + val prefix = "sts".encodeToByteArray() + val suffix = poolId.toInt().asLittleEndianBytes() + + return (prefix + suffix).blake2b256() + } + + private suspend fun getPools(): Map { + return remoteStorageSource.query(chain.id) { + val tradabilities = runtime.metadata.stableSwapOrNull?.assetTradability?.entries().orEmpty() + runtime.metadata.stableSwapOrNull?.pools?.entries().orEmpty() + .filterByTradability(tradabilities) + } + } + + private fun Map.filterByTradability( + tradabilities: Map + ): Map { + return this.filter { (poolId, _) -> + val tradability = tradabilities[poolId] ?: return@filter true + + tradability.canBuy() && tradability.canSell() + } + } + + private suspend fun Map.matchIdsWithLocal(): List { + val allOnChainIds = hydraDxAssetIdConverter.allOnChainIds(chain) + + return mapNotNull outer@{ (poolAssetId, poolInfo) -> + val poolAssetMatchedId = allOnChainIds[poolAssetId]?.fullId + + val participatingAssetsMatchedIds = poolInfo.assets.map { assetId -> + val localId = allOnChainIds[assetId]?.fullId + + assetId to localId + } + + PoolInitialInfo( + sharedAsset = poolAssetId to poolAssetMatchedId, + poolAssets = participatingAssetsMatchedIds + ) + } + } + + private fun Collection.allPossibleDirections(): Collection { + return flatMap { (poolAssetId, poolAssets) -> + val allPoolAssetIds = buildList { + addAll(poolAssets.mapNotNull { it.flatten() }) + + val sharedAssetId = poolAssetId.flatten() + + if (sharedAssetId != null) { + add(sharedAssetId) + } + } + + allPoolAssetIds.flatMap { assetId -> + allPoolAssetIds.mapNotNull { otherAssetId -> + otherAssetId.takeIf { assetId != otherAssetId } + ?.let { RealStableSwapQuotingEdge(assetId, otherAssetId, poolAssetId.first) } + } + } + } + } + + private data class PoolInitialInfo( + val sharedAsset: RemoteAndLocalIdOptional, + val poolAssets: List + ) + + inner class RealStableSwapQuotingEdge( + override val fromAsset: RemoteAndLocalId, + override val toAsset: RemoteAndLocalId, + override val poolId: HydraDxAssetId + ) : StableSwapQuotingSource.Edge { + + override val from: FullChainAssetId = fromAsset.second + + override val to: FullChainAssetId = toAsset.second + + override fun weightForAppendingTo(path: Path>): Int { + return weightAppendingToPath(path, Weights.Hydra.STABLESWAP) + } + + override suspend fun quote(amount: BigInteger, direction: SwapDirection): BigInteger { + val allPools = stablePools.first() + val relevantPool = allPools.first { it.sharedAsset.id == poolId } + + return relevantPool.quote(fromAsset.first, toAsset.first, amount, direction) + ?: throw SwapQuoteException.NotEnoughLiquidity + } + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/StableSwapApi.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/StableSwapApi.kt new file mode 100644 index 0000000..a44f95b --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/StableSwapApi.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap + +import io.novafoundation.nova.common.utils.stableSwap +import io.novafoundation.nova.common.utils.stableSwapOrNull +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.Tradeability +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.bindTradeability +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model.StableSwapPoolInfo +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model.StalbeSwapPoolPegInfo +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model.bindPoolPegInfo +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model.bindStablePoolInfo +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class StableSwapApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.stableSwapOrNull: StableSwapApi? + get() = stableSwapOrNull()?.let(::StableSwapApi) + +context(StorageQueryContext) +val RuntimeMetadata.stableSwap: StableSwapApi + get() = StableSwapApi(stableSwap()) + +context(StorageQueryContext) +val StableSwapApi.pools: QueryableStorageEntry1 + get() = storage1( + name = "Pools", + binding = ::bindStablePoolInfo, + ) + +context(StorageQueryContext) +val StableSwapApi.poolPegs: QueryableStorageEntry1 + get() = storage1( + name = "PoolPegs", + binding = { decoded, _ -> bindPoolPegInfo(decoded) }, + ) + +context(StorageQueryContext) +val StableSwapApi.assetTradability: QueryableStorageEntry1 + get() = storage1( + name = "AssetTradability", + binding = { decoded, _ -> + bindTradeability(decoded) + }, + ) diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/StableSwapQuotingSource.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/StableSwapQuotingSource.kt new file mode 100644 index 0000000..6bcb772 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/StableSwapQuotingSource.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap + +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.RemoteAndLocalId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface StableSwapQuotingSource : HydraDxQuotingSource { + + val chain: Chain + + interface Edge : QuotableEdge { + + val fromAsset: RemoteAndLocalId + + val toAsset: RemoteAndLocalId + + val poolId: HydraDxAssetId + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/TokensApi.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/TokensApi.kt new file mode 100644 index 0000000..4f7461c --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/TokensApi.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindOrmlAccountData +import io.novafoundation.nova.common.utils.tokens +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry2 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage2 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import java.math.BigInteger + +@JvmInline +value class TokensApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.hydraTokens: TokensApi + get() = TokensApi(tokens()) + +context(StorageQueryContext) +val TokensApi.totalIssuance: QueryableStorageEntry1 + get() = storage1( + name = "TotalIssuance", + binding = { decoded, _ -> bindNumber(decoded) }, + ) + +context(StorageQueryContext) +val TokensApi.accounts: QueryableStorageEntry2 + get() = storage2( + name = "Accounts", + binding = { decoded, _, _ -> bindOrmlAccountData(decoded) }, + ) diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/model/StablePool.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/model/StablePool.kt new file mode 100644 index 0000000..ad532f9 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/model/StablePool.kt @@ -0,0 +1,206 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.Perbill +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.hydra_dx_math.HydraDxMathConversions.fromBridgeResultToBalance +import io.novafoundation.nova.hydra_dx_math.stableswap.StableSwapMathBridge +import java.math.BigInteger + +class StablePool( + val sharedAsset: StablePoolAsset, + sharedAssetIssuance: BigInteger, + val assets: List, + val initialAmplification: BigInteger, + val finalAmplification: BigInteger, + val initialBlock: BigInteger, + val finalBlock: BigInteger, + val currentBlock: BlockNumber, + fee: Perbill, + val gson: Gson, + val pegs: List> +) { + + companion object { + fun getDefaultPegs(size: Int): List> { + return (0 until size).map { + listOf(BigInteger.ONE, BigInteger.ONE) + } + } + } + + val sharedAssetIssuance = sharedAssetIssuance.toString() + val fee: String = fee.value.toBigDecimal().toPlainString() + + val reserves: String by lazy(LazyThreadSafetyMode.NONE) { + val reservesInput = assets.map { ReservesInput(it.balance.toString(), it.id.toInt(), it.decimals) } + gson.toJson(reservesInput) + } + + val amplification by lazy(LazyThreadSafetyMode.NONE) { + calculateAmplification() + } + + val pegsSerialized: String by lazy(LazyThreadSafetyMode.NONE) { + val pegsInput = pegs.map { inner -> inner.map { it.toString() } } + gson.toJson(pegsInput) + } + + private fun calculateAmplification(): String { + return StableSwapMathBridge.calculate_amplification( + initialAmplification.toString(), + finalAmplification.toString(), + initialBlock.toString(), + finalBlock.toString(), + currentBlock.toString() + ) + } +} + +class StablePoolAsset( + val balance: BigInteger, + val id: HydraDxAssetId, + val decimals: Int +) + +fun StablePool.quote( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amount: BigInteger, + direction: SwapDirection +): BigInteger? { + return when (direction) { + SwapDirection.SPECIFIED_IN -> calculateOutGivenIn(assetIdIn, assetIdOut, amount) + SwapDirection.SPECIFIED_OUT -> calculateInGivenOut(assetIdIn, assetIdOut, amount) + } +} + +fun StablePool.calculateOutGivenIn( + assetIn: HydraDxAssetId, + assetOut: HydraDxAssetId, + amountIn: BigInteger, +): BigInteger? { + return when { + assetIn == sharedAsset.id -> calculateWithdrawOneAsset(assetOut, amountIn) + assetOut == sharedAsset.id -> calculateShares(assetIn, amountIn) + else -> calculateOut(assetIn, assetOut, amountIn) + } +} + +fun StablePool.calculateInGivenOut( + assetIn: HydraDxAssetId, + assetOut: HydraDxAssetId, + amountOut: BigInteger, +): BigInteger? { + return when { + assetOut == sharedAsset.id -> calculateAddOneAsset(assetIn, amountOut) + assetIn == sharedAsset.id -> calculateSharesForAmount(assetOut, amountOut) + else -> calculateIn(assetIn, assetOut, amountOut) + } +} + +private fun StablePool.calculateAddOneAsset( + assetIn: HydraDxAssetId, + amountOut: BigInteger, +): BigInteger? { + return StableSwapMathBridge.calculate_add_one_asset( + reserves, + amountOut.toString(), + assetIn.toInt(), + amplification, + sharedAssetIssuance, + fee, + pegsSerialized + ).fromBridgeResultToBalance() +} + +private fun StablePool.calculateSharesForAmount( + assetOut: HydraDxAssetId, + amountOut: BigInteger, +): BigInteger? { + return StableSwapMathBridge.calculate_shares_for_amount( + reserves, + assetOut.toInt(), + amountOut.toString(), + amplification, + sharedAssetIssuance, + fee, + pegsSerialized + ).fromBridgeResultToBalance() +} + +private fun StablePool.calculateIn( + assetIn: HydraDxAssetId, + assetOut: HydraDxAssetId, + amountOut: BigInteger, +): BigInteger? { + return StableSwapMathBridge.calculate_in_given_out( + reserves, + assetIn.toInt(), + assetOut.toInt(), + amountOut.toString(), + amplification, + fee, + pegsSerialized + ).fromBridgeResultToBalance() +} + +private fun StablePool.calculateWithdrawOneAsset( + assetOut: HydraDxAssetId, + amountIn: BigInteger, +): BigInteger? { + return StableSwapMathBridge.calculate_liquidity_out_one_asset( + reserves, + amountIn.toString(), + assetOut.toInt(), + amplification, + sharedAssetIssuance, + fee, + pegsSerialized + ).fromBridgeResultToBalance() +} + +private fun StablePool.calculateShares( + assetIn: HydraDxAssetId, + amountIn: BigInteger, +): BigInteger? { + val assets = listOf(SharesAssetInput(assetIn.toInt(), amountIn.toString())) + val assetsJson = gson.toJson(assets) + + return StableSwapMathBridge.calculate_shares( + reserves, + assetsJson, + amplification, + sharedAssetIssuance, + fee, + pegsSerialized + ).fromBridgeResultToBalance() +} + +private fun StablePool.calculateOut( + assetIn: HydraDxAssetId, + assetOut: HydraDxAssetId, + amountIn: BigInteger, +): BigInteger? { + return StableSwapMathBridge.calculate_out_given_in( + this.reserves, + assetIn.toInt(), + assetOut.toInt(), + amountIn.toString(), + amplification, + fee, + pegsSerialized + ).fromBridgeResultToBalance() +} + +private class SharesAssetInput(@SerializedName("asset_id") val assetId: Int, val amount: String) + +private class ReservesInput( + val amount: String, + @SerializedName("asset_id") + val id: Int, + val decimals: Int +) diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/model/StableSwapPoolInfo.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/model/StableSwapPoolInfo.kt new file mode 100644 index 0000000..1612b47 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/model/StableSwapPoolInfo.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindPermill +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.Perbill +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import java.math.BigInteger + +class StableSwapPoolInfo( + val poolAssetId: HydraDxAssetId, + val assets: List, + val initialAmplification: BigInteger, + val finalAmplification: BigInteger, + val initialBlock: BigInteger, + val finalBlock: BigInteger, + val fee: Perbill, +) + +fun bindStablePoolInfo(decoded: Any?, poolTokenId: HydraDxAssetId): StableSwapPoolInfo { + val struct = decoded.castToStruct() + + return StableSwapPoolInfo( + poolAssetId = poolTokenId, + assets = bindList(decoded["assets"], ::bindNumber), + initialAmplification = bindNumber(struct["initialAmplification"]), + finalAmplification = bindNumber(struct["finalAmplification"]), + initialBlock = bindNumber(struct["initialBlock"]), + finalBlock = bindNumber(struct["finalBlock"]), + fee = bindPermill(struct["fee"]) + ) +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/model/StalbeSwapPoolPegInfo.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/model/StalbeSwapPoolPegInfo.kt new file mode 100644 index 0000000..28c8eaf --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/stableswap/model/StalbeSwapPoolPegInfo.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import java.math.BigInteger + +class StalbeSwapPoolPegInfo( + val current: List> +) + +fun bindPoolPegInfo(decoded: Any?): StalbeSwapPoolPegInfo { + val asStruct = decoded.castToStruct() + return StalbeSwapPoolPegInfo( + current = bindList(asStruct["current"]) { item -> + bindList(item, ::bindNumber) + } + ) +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/RealXYKSwapQuotingSource.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/RealXYKSwapQuotingSource.kt new file mode 100644 index 0000000..0433d6d --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/RealXYKSwapQuotingSource.kt @@ -0,0 +1,211 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.combine +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.common.utils.graph.WeightedEdge +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.utils.xyk +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights.Hydra.weightAppendingToPath +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.HydrationAssetType +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.HydrationBalanceFetcher +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.HydrationBalanceFetcherFactory +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.common.fromAsset +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.RemoteAndLocalId +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.localId +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.matchId +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.remoteId +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model.XYKPool +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model.XYKPoolAsset +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model.XYKPoolInfo +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model.XYKPools +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model.poolFeesConstant +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting +import io.novafoundation.nova.feature_swap_core_api.data.primitive.errors.SwapQuoteException +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.math.BigInteger +import javax.inject.Inject +import javax.inject.Named + +@FeatureScope +class XYKSwapQuotingSourceFactory @Inject constructor( + @Named(REMOTE_STORAGE_SOURCE) + private val remoteStorageSource: StorageDataSource, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val hydrationBalanceFetcherFactory: HydrationBalanceFetcherFactory, +) : HydraDxQuotingSource.Factory { + + companion object { + + const val ID = "XYK" + } + + override fun create(chain: Chain, host: SwapQuoting.QuotingHost): XYKSwapQuotingSource { + return RealXYKSwapQuotingSource( + remoteStorageSource = remoteStorageSource, + hydraDxAssetIdConverter = hydraDxAssetIdConverter, + hydrationBalanceFetcher = hydrationBalanceFetcherFactory.create(host), + chain = chain + ) + } +} + +private class RealXYKSwapQuotingSource( + private val remoteStorageSource: StorageDataSource, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val hydrationBalanceFetcher: HydrationBalanceFetcher, + private val chain: Chain +) : XYKSwapQuotingSource { + + override val identifier: String = XYKSwapQuotingSourceFactory.ID + + private val initialPoolsInfo: MutableSharedFlow> = singleReplaySharedFlow() + + private val xykPools: MutableSharedFlow = singleReplaySharedFlow() + + override suspend fun sync() { + val pools = getPools() + + val poolInitialInfo = pools.matchIdsWithLocal() + initialPoolsInfo.emit(poolInitialInfo) + } + + override suspend fun availableSwapDirections(): Collection { + val poolInitialInfo = initialPoolsInfo.first() + + return poolInitialInfo.allPossibleDirections() + } + + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow = coroutineScope { + xykPools.resetReplayCache() + + val initialPoolsInfo = initialPoolsInfo.first() + + val poolsSubscription = initialPoolsInfo.map { poolInfo -> + val firstBalanceFlow = subscribeToBalance(poolInfo.firstAsset, poolInfo.poolAddress, subscriptionBuilder) + val secondBalanceFlow = subscribeToBalance(poolInfo.secondAsset, poolInfo.poolAddress, subscriptionBuilder) + + firstBalanceFlow.combine(secondBalanceFlow) { firstBalance, secondBalance -> + XYKPool( + address = poolInfo.poolAddress, + firstAsset = XYKPoolAsset(firstBalance, poolInfo.firstAsset.first), + secondAsset = XYKPoolAsset(secondBalance, poolInfo.secondAsset.first), + ) + } + }.combine() + + val fees = remoteStorageSource.query(chain.id) { + runtime.metadata.xyk().poolFeesConstant(runtime) + } + + poolsSubscription.map { pools -> + val built = XYKPools(fees, pools) + xykPools.emit(built) + } + } + + private suspend fun subscribeToBalance( + assetId: RemoteAndLocalId, + poolAddress: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + val chainAsset = chain.assetsById.getValue(assetId.localId.assetId) + val hydrationAssetType = HydrationAssetType.fromAsset(chainAsset, assetId.remoteId) + + return hydrationBalanceFetcher.subscribeToTransferableBalance( + chainId = chainAsset.chainId, + type = hydrationAssetType, + accountId = poolAddress, + subscriptionBuilder = subscriptionBuilder + ) + } + + private suspend fun getPools(): Map { + return remoteStorageSource.query(chain.id) { + runtime.metadata.xykOrNull?.poolAssets?.entries().orEmpty() + } + } + + private suspend fun Map.matchIdsWithLocal(): List { + val allOnChainIds = hydraDxAssetIdConverter.allOnChainIds(chain) + + return mapNotNull { (poolAddress, poolInfo) -> + PoolInitialInfo( + poolAddress = poolAddress.value, + firstAsset = allOnChainIds.matchId(poolInfo.firstAsset) ?: return@mapNotNull null, + secondAsset = allOnChainIds.matchId(poolInfo.secondAsset) ?: return@mapNotNull null, + ) + } + } + + private fun Collection.allPossibleDirections(): Collection { + return buildList { + this@allPossibleDirections.forEach { poolInfo -> + add( + RealXYKSwapQuotingEdge( + fromAsset = poolInfo.firstAsset, + toAsset = poolInfo.secondAsset, + poolAddress = poolInfo.poolAddress + ) + ) + + add( + RealXYKSwapQuotingEdge( + fromAsset = poolInfo.secondAsset, + toAsset = poolInfo.firstAsset, + poolAddress = poolInfo.poolAddress + ) + ) + } + } + } + + inner class RealXYKSwapQuotingEdge( + override val fromAsset: RemoteAndLocalId, + override val toAsset: RemoteAndLocalId, + override val poolAddress: AccountId + ) : XYKSwapQuotingSource.Edge { + + override val from: FullChainAssetId = fromAsset.second + + override val to: FullChainAssetId = toAsset.second + + override fun weightForAppendingTo(path: Path>): Int { + return weightAppendingToPath(path, Weights.Hydra.XYK) + } + + override suspend fun quote(amount: BigInteger, direction: SwapDirection): BigInteger { + val allPools = xykPools.first() + + return allPools.quote(poolAddress, fromAsset.first, toAsset.first, amount, direction) + ?: throw SwapQuoteException.NotEnoughLiquidity + } + } +} + +private class PoolInitialInfo( + val poolAddress: AccountId, + val firstAsset: RemoteAndLocalId, + val secondAsset: RemoteAndLocalId +) diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/XYKApi.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/XYKApi.kt new file mode 100644 index 0000000..63bfd9e --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/XYKApi.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.utils.xyk +import io.novafoundation.nova.common.utils.xykOrNull +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model.XYKPoolInfo +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model.bindXYKPoolInfo +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class XYKSwapApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.xykOrNull: XYKSwapApi? + get() = xykOrNull()?.let(::XYKSwapApi) + +context(StorageQueryContext) +val RuntimeMetadata.xyk: XYKSwapApi + get() = XYKSwapApi(xyk()) + +context(StorageQueryContext) +val XYKSwapApi.poolAssets: QueryableStorageEntry1 + get() = storage1( + name = "PoolAssets", + keyBinding = { bindAccountId(it).intoKey() }, + binding = { decoded, _ -> bindXYKPoolInfo(decoded) }, + ) diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/XYKSwapQuotingSource.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/XYKSwapQuotingSource.kt new file mode 100644 index 0000000..3494a7f --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/XYKSwapQuotingSource.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk + +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.model.RemoteAndLocalId +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface XYKSwapQuotingSource : HydraDxQuotingSource { + + interface Edge : QuotableEdge { + + val fromAsset: RemoteAndLocalId + + val toAsset: RemoteAndLocalId + + val poolAddress: AccountId + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/model/XYKFees.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/model/XYKFees.kt new file mode 100644 index 0000000..999376f --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/model/XYKFees.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindInt +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.utils.constant +import io.novafoundation.nova.common.utils.decoded +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +class XYKFees(val nominator: Int, val denominator: Int) + +fun bindXYKFees(decoded: Any?): XYKFees { + val (first, second) = decoded.castToList() + + return XYKFees(bindInt(first), bindInt(second)) +} + +fun Module.poolFeesConstant(runtimeSnapshot: RuntimeSnapshot): XYKFees { + return bindXYKFees(constant("GetExchangeFee").decoded(runtimeSnapshot)) +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/model/XYKPool.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/model/XYKPool.kt new file mode 100644 index 0000000..73f5866 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/model/XYKPool.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model + +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.hydra_dx_math.HydraDxMathConversions.fromBridgeResultToBalance +import io.novafoundation.nova.hydra_dx_math.xyk.HYKSwapMathBridge +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +class XYKPools( + val fees: XYKFees, + val pools: List +) { + + fun quote( + poolAddress: AccountId, + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amount: BigInteger, + direction: SwapDirection + ): BigInteger? { + val relevantPool = pools.first { it.address.contentEquals(poolAddress) } + + return relevantPool.quote(assetIdIn, assetIdOut, amount, direction, fees) + } +} + +class XYKPool( + val address: AccountId, + val firstAsset: XYKPoolAsset, + val secondAsset: XYKPoolAsset, +) { + + fun getAsset(assetId: HydraDxAssetId): XYKPoolAsset { + return when { + firstAsset.id == assetId -> firstAsset + secondAsset.id == assetId -> secondAsset + else -> error("Unknown asset for the pool") + } + } +} + +class XYKPoolAsset( + val balance: BigInteger, + val id: HydraDxAssetId, +) + +fun XYKPool.quote( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amount: BigInteger, + direction: SwapDirection, + fees: XYKFees +): BigInteger? { + return when (direction) { + SwapDirection.SPECIFIED_IN -> calculateOutGivenIn(assetIdIn, assetIdOut, amount, fees) + SwapDirection.SPECIFIED_OUT -> calculateInGivenOut(assetIdIn, assetIdOut, amount, fees) + } +} + +private fun XYKPool.calculateOutGivenIn( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amountIn: BigInteger, + feesConfig: XYKFees +): BigInteger? { + val assetIn = getAsset(assetIdIn) + val assetOut = getAsset(assetIdOut) + + val amountOut = HYKSwapMathBridge.calculate_out_given_in( + assetIn.balance.toString(), + assetOut.balance.toString(), + amountIn.toString() + ).fromBridgeResultToBalance() ?: return null + + val fees = feesConfig.feeFrom(amountOut) ?: return null + + return (amountOut - fees).atLeastZero() +} + +private fun XYKPool.calculateInGivenOut( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amountOut: BigInteger, + feesConfig: XYKFees, +): BigInteger? { + val assetIn = getAsset(assetIdIn) + val assetOut = getAsset(assetIdOut) + + val amountIn = HYKSwapMathBridge.calculate_in_given_out( + assetIn.balance.toString(), + assetOut.balance.toString(), + amountOut.toString() + ).fromBridgeResultToBalance() ?: return null + + val fees = feesConfig.feeFrom(amountIn) ?: return null + + return amountIn + fees +} + +private fun XYKFees.feeFrom(amount: BigInteger): BigInteger? { + return HYKSwapMathBridge.calculate_pool_trade_fee(amount.toString(), nominator.toString(), denominator.toString()) + .fromBridgeResultToBalance() +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/model/XYKPoolInfo.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/model/XYKPoolInfo.kt new file mode 100644 index 0000000..d4ba0b5 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/data/assetExchange/conversion/types/hydra/sources/xyk/model/XYKPoolInfo.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId + +class XYKPoolInfo(val firstAsset: HydraDxAssetId, val secondAsset: HydraDxAssetId) + +fun bindXYKPoolInfo(decoded: Any): XYKPoolInfo { + val (first, second) = decoded.castToList() + + return XYKPoolInfo(bindNumber(first), bindNumber(second)) +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreComponent.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreComponent.kt new file mode 100644 index 0000000..f5415fd --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreComponent.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_swap_core.di + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + SwapCoreDependencies::class, + ], + modules = [ + SwapCoreModule::class, + ] +) +@FeatureScope +interface SwapCoreComponent : SwapCoreApi { + + @Component.Factory + interface Factory { + + fun create(deps: SwapCoreDependencies): SwapCoreComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class + ] + ) + interface SwapCoreDependenciesComponent : SwapCoreDependencies +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreDependencies.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreDependencies.kt new file mode 100644 index 0000000..5edc61e --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreDependencies.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_core.di + +import com.google.gson.Gson +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +interface SwapCoreDependencies { + + val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi + + val chainRegistry: ChainRegistry + + val chainStateRepository: ChainStateRepository + + val gson: Gson + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource + + val computationalCache: ComputationalCache +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreHolder.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreHolder.kt new file mode 100644 index 0000000..cb805b8 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreHolder.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_swap_core.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class SwapCoreHolder @Inject constructor( + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerSwapCoreComponent_SwapCoreDependenciesComponent.builder() + .commonApi(commonApi()) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .build() + + return DaggerSwapCoreComponent.factory() + .create(accountFeatureDependencies) + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreModule.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreModule.kt new file mode 100644 index 0000000..fe96ce0 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/SwapCoreModule.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_swap_core.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.RealHydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core.di.conversions.HydraDxConversionModule +import io.novafoundation.nova.feature_swap_core.domain.paths.RealPathQuoterFactory +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [HydraDxConversionModule::class]) +class SwapCoreModule { + + @Provides + @FeatureScope + fun provideHydraDxAssetIdConverter( + chainRegistry: ChainRegistry + ): HydraDxAssetIdConverter { + return RealHydraDxAssetIdConverter(chainRegistry) + } + + @Provides + @FeatureScope + fun providePathsQuoterFactory( + computationalCache: ComputationalCache + ): PathQuoter.Factory { + return RealPathQuoterFactory(computationalCache) + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/conversions/HydraDxBindsModule.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/conversions/HydraDxBindsModule.kt new file mode 100644 index 0000000..4da1275 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/conversions/HydraDxBindsModule.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_swap_core.di.conversions + +import dagger.Binds +import dagger.Module +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.RealHydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.RealHydrationPriceConversionFallback +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback + +@Module +internal interface HydraDxBindsModule { + + @Binds + fun bindHydrationPriceConversionFallback(real: RealHydrationPriceConversionFallback): HydrationPriceConversionFallback + + @Binds + @FeatureScope + fun bindHydrationAcceptedFeeCurrenciesFetcher(real: RealHydrationAcceptedFeeCurrenciesFetcher): HydrationAcceptedFeeCurrenciesFetcher +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/conversions/HydraDxConversionModule.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/conversions/HydraDxConversionModule.kt new file mode 100644 index 0000000..6de7265 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/di/conversions/HydraDxConversionModule.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_swap_core.di.conversions + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.RealHydraDxQuotingFactory +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave.AaveSwapQuotingSourceFactory +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.OmniPoolQuotingSourceFactory +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.StableSwapQuotingSourceFactory +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.XYKSwapQuotingSourceFactory +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource + +@Module(includes = [HydraDxBindsModule::class]) +class HydraDxConversionModule { + + @Provides + @IntoSet + fun provideOmniPoolSourceFactory(implementation: OmniPoolQuotingSourceFactory): HydraDxQuotingSource.Factory<*> { + return implementation + } + + @Provides + @IntoSet + fun provideStableSwapSourceFactory(implementation: StableSwapQuotingSourceFactory): HydraDxQuotingSource.Factory<*> { + return implementation + } + + @Provides + @IntoSet + fun provideXykSwapSourceFactory(implementation: XYKSwapQuotingSourceFactory): HydraDxQuotingSource.Factory<*> { + return implementation + } + + @Provides + @IntoSet + fun provideAavePoolQuotingSourceFactory(implementation: AaveSwapQuotingSourceFactory): HydraDxQuotingSource.Factory<*> { + return implementation + } + + @Provides + @FeatureScope + fun provideHydraDxAssetConversionFactory( + conversionSourceFactories: Set<@JvmSuppressWildcards HydraDxQuotingSource.Factory<*>>, + ): HydraDxQuoting.Factory { + return RealHydraDxQuotingFactory( + conversionSourceFactories = conversionSourceFactories, + ) + } +} diff --git a/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/domain/paths/RealPathQuoter.kt b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/domain/paths/RealPathQuoter.kt new file mode 100644 index 0000000..595e4f6 --- /dev/null +++ b/feature-swap-core/src/main/java/io/novafoundation/nova/feature_swap_core/domain/paths/RealPathQuoter.kt @@ -0,0 +1,151 @@ +package io.novafoundation.nova.feature_swap_core.domain.paths + +import android.util.Log +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.utils.graph.EdgeVisitFilter +import io.novafoundation.nova.common.utils.graph.Graph +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.common.utils.graph.findDijkstraPathsBetween +import io.novafoundation.nova.common.utils.graph.numberOfEdges +import io.novafoundation.nova.common.utils.mapAsync +import io.novafoundation.nova.common.utils.measureExecution +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathFeeEstimator +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.BestPathQuote +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.PathRoughFeeEstimation +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedEdge +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedPath +import io.novafoundation.nova.feature_swap_core_api.data.primitive.errors.SwapQuoteException +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import java.math.BigInteger + +private const val PATHS_LIMIT = 4 +private const val QUOTES_CACHE = "RealSwapService.QuotesCache" + +class RealPathQuoterFactory( + private val computationalCache: ComputationalCache, +) : PathQuoter.Factory { + + override fun create( + graphFlow: Flow>, + computationalScope: CoroutineScope, + pathFeeEstimation: PathFeeEstimator?, + filter: EdgeVisitFilter? + ): PathQuoter { + return RealPathQuoter(computationalCache, graphFlow, computationalScope, pathFeeEstimation, filter) + } +} + +private class RealPathQuoter( + private val computationalCache: ComputationalCache, + private val graphFlow: Flow>, + private val computationalScope: CoroutineScope, + private val pathFeeEstimation: PathFeeEstimator?, + private val filter: EdgeVisitFilter?, +) : PathQuoter { + + override suspend fun findBestPath( + chainAssetIn: Chain.Asset, + chainAssetOut: Chain.Asset, + amount: BigInteger, + swapDirection: SwapDirection, + ): BestPathQuote { + val from = chainAssetIn.fullId + val to = chainAssetOut.fullId + + val paths = pathsFromCacheOrCompute(from, to, computationalScope) { graph -> + val paths = measureExecution("Finding ${chainAssetIn.symbol} -> ${chainAssetOut.symbol} paths") { + graph.findDijkstraPathsBetween(from, to, limit = PATHS_LIMIT, filter) + } + + paths + } + + val quotedPaths = paths.mapAsync { path -> quotePath(path, amount, swapDirection) } + .filterNotNull() + + if (quotedPaths.isEmpty()) { + throw SwapQuoteException.NotEnoughLiquidity + } + + return BestPathQuote(quotedPaths) + } + + private suspend fun pathsFromCacheOrCompute( + from: FullChainAssetId, + to: FullChainAssetId, + scope: CoroutineScope, + computation: suspend (graph: Graph) -> List> + ): List> { + val graph = graphFlow.first() + + val cacheKey = "$QUOTES_CACHE:${pathsCacheKey(from, to)}:${graph.numberOfEdges()}" + + return computationalCache.useCache(cacheKey, scope) { + computation(graph) + } + } + + private fun pathsCacheKey(from: FullChainAssetId, to: FullChainAssetId): String { + val fromKey = "${from.chainId}:${from.assetId}" + val toKey = "${to.chainId}:${to.assetId}" + + return "$fromKey:$toKey" + } + + private suspend fun quotePath( + path: Path, + amount: BigInteger, + swapDirection: SwapDirection + ): QuotedPath? { + val quote = when (swapDirection) { + SwapDirection.SPECIFIED_IN -> quotePathSell(path, amount) + SwapDirection.SPECIFIED_OUT -> quotePathBuy(path, amount) + } ?: return null + + val pathRoughFeeEstimation = pathFeeEstimation.roughlyEstimateFeeOrZero(quote) + + return QuotedPath(swapDirection, quote, pathRoughFeeEstimation) + } + + private suspend fun PathFeeEstimator?.roughlyEstimateFeeOrZero(quote: Path>): PathRoughFeeEstimation { + return this?.roughlyEstimateFee(quote) ?: PathRoughFeeEstimation.zero() + } + + private suspend fun quotePathBuy(path: Path, amount: BigInteger): Path>? { + return runCatching { + val initial = mutableListOf>() to amount + + path.foldRight(initial) { segment, (quotedPath, currentAmount) -> + val segmentQuote = segment.quote(currentAmount, SwapDirection.SPECIFIED_OUT) + quotedPath.add(0, QuotedEdge(currentAmount, segmentQuote, segment)) + + quotedPath to segmentQuote + }.first + } + .onFailure { Log.w("Swaps", "Failed to quote path", it) } + .getOrNull() + } + + private suspend fun quotePathSell(path: Path, amount: BigInteger): Path>? { + return runCatching { + val initial = mutableListOf>() to amount + + path.fold(initial) { (quotedPath, currentAmount), segment -> + val segmentQuote = segment.quote(currentAmount, SwapDirection.SPECIFIED_IN) + quotedPath.add(QuotedEdge(currentAmount, segmentQuote, segment)) + + quotedPath to segmentQuote + }.first + } + .onFailure { Log.w("Swaps", "Failed to quote path", it) } + .getOrNull() + } +} diff --git a/feature-swap-impl/.gitignore b/feature-swap-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-swap-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-swap-impl/build.gradle b/feature-swap-impl/build.gradle new file mode 100644 index 0000000..c2222d3 --- /dev/null +++ b/feature-swap-impl/build.gradle @@ -0,0 +1,62 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_swap_impl' + + + defaultConfig { + + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(':feature-swap-api') + implementation project(":feature-swap-core") + implementation project(':feature-currency-api') + implementation project(':feature-buy-api') + implementation project(':feature-xcm:api') + + implementation project(":common") + implementation project(":runtime") + + implementation project(":bindings:hydra-dx-math") + + implementation materialDep + + implementation substrateSdkDep + + implementation kotlinDep + + + implementation androidDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation lifeCycleKtxDep + + implementation project(":core-db") + + implementation viewModelKtxDep + + implementation shimmerDep + + implementation daggerDep + ksp daggerCompiler + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-swap-impl/consumer-rules.pro b/feature-swap-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-swap-impl/proguard-rules.pro b/feature-swap-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-swap-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-swap-impl/src/main/AndroidManifest.xml b/feature-swap-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-swap-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/AssetExchange.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/AssetExchange.kt new file mode 100644 index 0000000..c23b48c --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/AssetExchange.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProvider +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger +import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting.QuotingHost +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface AssetExchange { + + interface SingleChainFactory { + + suspend fun create(chain: Chain, swapHost: SwapHost): AssetExchange + } + + interface MultiChainFactory { + + suspend fun create(swapHost: SwapHost): AssetExchange + } + + interface SwapHost : QuotingHost { + + val scope: CoroutineScope + + override val sharedSubscriptions: SharedSwapSubscriptions + + suspend fun quote(quoteArgs: ParentQuoterArgs): Balance + + suspend fun extrinsicService(): ExtrinsicService + } + + suspend fun sync() + + suspend fun availableDirectSwapConnections(): List + + fun feePaymentOverrides(): List + + fun runSubscriptions(metaAccount: MetaAccount): Flow +} + +data class FeePaymentProviderOverride( + val provider: FeePaymentProvider, + val chain: Chain +) + +data class ParentQuoterArgs( + val chainAssetIn: Chain.Asset, + val chainAssetOut: Chain.Asset, + val amount: Balance, + val swapDirection: SwapDirection, +) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/SharedSwapSubscriptions.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/SharedSwapSubscriptions.kt new file mode 100644 index 0000000..2701b57 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/SharedSwapSubscriptions.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange + +import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuotingSubscriptions + +interface SharedSwapSubscriptions : SwapQuotingSubscriptions diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/assetConversion/AssetConversionExchange.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/assetConversion/AssetConversionExchange.kt new file mode 100644 index 0000000..83cd648 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/assetConversion/AssetConversionExchange.kt @@ -0,0 +1,374 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.assetConversion + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumberOrNull +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.assetConversionAssetIdType +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.common.utils.graph.WeightedEdge +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.feature_account_api.data.conversion.assethub.assetConversionOrNull +import io.novafoundation.nova.feature_account_api.data.conversion.assethub.pools +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOutcomeOk +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperation +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationPrototype +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationSubmissionArgs +import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger +import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionCorrection +import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge +import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit +import io.novafoundation.nova.feature_swap_api.domain.model.SwapMaxAdditionalAmountDeduction +import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult +import io.novafoundation.nova.feature_swap_api.domain.model.UsdConverter +import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountIn +import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountOut +import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee +import io.novafoundation.nova.feature_swap_api.domain.model.fee.SubmissionOnlyAtomicSwapOperationFee +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights +import io.novafoundation.nova.feature_swap_core_api.data.primitive.errors.SwapQuoteException +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.AssetExchange +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.FeePaymentProviderOverride +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.ParentQuoterArgs +import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase +import io.novafoundation.nova.feature_swap_impl.domain.swap.BaseSwapGraphEdge +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.converter.toMultiLocationOrThrow +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.feature_xcm_api.versions.orDefault +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.call.RuntimeCallsApi +import io.novafoundation.nova.runtime.ext.emptyAccountId +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEventOrThrow +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.expectedBlockTime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import java.math.BigDecimal +import kotlin.time.Duration + +class AssetConversionExchangeFactory( + private val multiLocationConverterFactory: MultiLocationConverterFactory, + private val remoteStorageSource: StorageDataSource, + private val runtimeCallsApi: MultiChainRuntimeCallsApi, + private val chainStateRepository: ChainStateRepository, + private val deductionUseCase: AssetInAdditionalSwapDeductionUseCase, + private val xcmVersionDetector: XcmVersionDetector, +) : AssetExchange.SingleChainFactory { + + override suspend fun create( + chain: Chain, + swapHost: AssetExchange.SwapHost, + ): AssetExchange { + val converter = multiLocationConverterFactory.defaultAsync(chain, swapHost.scope) + + return AssetConversionExchange( + chain = chain, + multiLocationConverter = converter, + remoteStorageSource = remoteStorageSource, + multiChainRuntimeCallsApi = runtimeCallsApi, + chainStateRepository = chainStateRepository, + swapHost = swapHost, + deductionUseCase = deductionUseCase, + xcmVersionDetector = xcmVersionDetector + ) + } +} + +private class AssetConversionExchange( + private val chain: Chain, + private val multiLocationConverter: MultiLocationConverter, + private val remoteStorageSource: StorageDataSource, + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, + private val chainStateRepository: ChainStateRepository, + private val swapHost: AssetExchange.SwapHost, + private val deductionUseCase: AssetInAdditionalSwapDeductionUseCase, + private val xcmVersionDetector: XcmVersionDetector, +) : AssetExchange { + + override suspend fun sync() { + // nothing to sync + } + + override suspend fun availableDirectSwapConnections(): List { + return remoteStorageSource.query(chain.id) { + val allPools = metadata.assetConversionOrNull?.pools?.keys().orEmpty() + + constructAllAvailableDirections(allPools) + } + } + + override fun feePaymentOverrides(): List { + return emptyList() + } + + override fun runSubscriptions(metaAccount: MetaAccount): Flow { + return chainStateRepository.currentBlockNumberFlow(chain.id) + .drop(1) // skip immediate value from the cache to not perform double-quote on chain change + .map { ReQuoteTrigger } + } + + private suspend fun constructAllAvailableDirections(pools: List>): List { + return buildList { + pools.forEach { (firstLocation, secondLocation) -> + val firstAsset = multiLocationConverter.toChainAsset(firstLocation) ?: return@forEach + val secondAsset = multiLocationConverter.toChainAsset(secondLocation) ?: return@forEach + + add(AssetConversionEdge(firstAsset, secondAsset)) + add(AssetConversionEdge(secondAsset, firstAsset)) + } + } + } + + private suspend fun detectAssetIdXcmVersion(runtime: RuntimeSnapshot): XcmVersion { + val assetIdType = runtime.metadata.assetConversionAssetIdType() + return xcmVersionDetector.detectMultiLocationVersion(chain.id, assetIdType).orDefault() + } + + private suspend fun RuntimeCallsApi.quote( + swapDirection: SwapDirection, + assetIn: Chain.Asset, + assetOut: Chain.Asset, + amount: Balance, + ): Balance? { + val method = when (swapDirection) { + SwapDirection.SPECIFIED_IN -> "quote_price_exact_tokens_for_tokens" + SwapDirection.SPECIFIED_OUT -> "quote_price_tokens_for_exact_tokens" + } + + val assetIdXcmVersion = detectAssetIdXcmVersion(runtime) + + val asset1 = multiLocationConverter.toMultiLocationOrThrow(assetIn).toEncodableInstance(assetIdXcmVersion) + val asset2 = multiLocationConverter.toMultiLocationOrThrow(assetOut).toEncodableInstance(assetIdXcmVersion) + + return call( + section = "AssetConversionApi", + method = method, + arguments = mapOf( + "asset1" to asset1, + "asset2" to asset2, + "amount" to amount, + "include_fee" to true + ), + returnBinding = ::bindNumberOrNull + ) + } + + private inner class AssetConversionEdge(fromAsset: Chain.Asset, toAsset: Chain.Asset) : BaseSwapGraphEdge(fromAsset, toAsset) { + + override suspend fun beginOperation(args: AtomicSwapOperationArgs): AtomicSwapOperation { + return AssetConversionOperation(args, fromAsset, toAsset) + } + + override suspend fun appendToOperation(currentTransaction: AtomicSwapOperation, args: AtomicSwapOperationArgs): AtomicSwapOperation? { + return null + } + + override suspend fun beginOperationPrototype(): AtomicSwapOperationPrototype { + return AssetConversionOperationPrototype(fromAsset.chainId) + } + + override suspend fun appendToOperationPrototype(currentTransaction: AtomicSwapOperationPrototype): AtomicSwapOperationPrototype? { + return null + } + + override suspend fun debugLabel(): String { + return "AssetConversion" + } + + override fun predecessorHandlesFees(predecessor: SwapGraphEdge): Boolean { + return false + } + + override suspend fun canPayNonNativeFeesInIntermediatePosition(): Boolean { + return true + } + + override suspend fun canTransferOutWholeAccountBalance(): Boolean { + return true + } + + override suspend fun quote( + amount: Balance, + direction: SwapDirection + ): Balance { + val runtimeCallsApi = multiChainRuntimeCallsApi.forChain(chain.id) + + return runtimeCallsApi.quote( + swapDirection = direction, + assetIn = fromAsset, + assetOut = toAsset, + amount = amount + ) ?: throw SwapQuoteException.NotEnoughLiquidity + } + + override fun weightForAppendingTo(path: Path>): Int { + return Weights.AssetConversion.SWAP + } + } + + inner class AssetConversionOperationPrototype(override val fromChain: ChainId) : AtomicSwapOperationPrototype { + + override suspend fun roughlyEstimateNativeFee(usdConverter: UsdConverter): BigDecimal { + // in DOT + return 0.0015.toBigDecimal() + } + + override suspend fun maximumExecutionTime(): Duration { + return chainStateRepository.expectedBlockTime(chain.id) + } + } + + inner class AssetConversionOperation( + private val transactionArgs: AtomicSwapOperationArgs, + private val fromAsset: Chain.Asset, + private val toAsset: Chain.Asset + ) : AtomicSwapOperation { + + override val estimatedSwapLimit: SwapLimit = transactionArgs.estimatedSwapLimit + + override val assetOut: FullChainAssetId = toAsset.fullId + + override val assetIn: FullChainAssetId = fromAsset.fullId + + override suspend fun constructDisplayData(): AtomicOperationDisplayData { + return AtomicOperationDisplayData.Swap( + from = fromAsset.fullId.withAmount(estimatedSwapLimit.estimatedAmountIn), + to = toAsset.fullId.withAmount(estimatedSwapLimit.estimatedAmountOut), + ) + } + + override suspend fun estimateFee(): AtomicSwapOperationFee { + val submissionFee = swapHost.extrinsicService().estimateFee( + chain = chain, + origin = TransactionOrigin.SelectedWallet, + submissionOptions = ExtrinsicService.SubmissionOptions( + feePaymentCurrency = transactionArgs.feePaymentCurrency + ) + ) { + executeSwap(swapLimit = estimatedSwapLimit, sendTo = chain.emptyAccountId()) + } + + return SubmissionOnlyAtomicSwapOperationFee(submissionFee) + } + + override suspend fun requiredAmountInToGetAmountOut(extraOutAmount: Balance): Balance { + val quoteArgs = ParentQuoterArgs( + chainAssetIn = fromAsset, + chainAssetOut = toAsset, + amount = extraOutAmount, + swapDirection = SwapDirection.SPECIFIED_OUT + ) + + return swapHost.quote(quoteArgs) + } + + override suspend fun additionalMaxAmountDeduction(): SwapMaxAdditionalAmountDeduction { + return SwapMaxAdditionalAmountDeduction( + fromCountedTowardsEd = deductionUseCase.invoke(fromAsset, toAsset) + ) + } + + override suspend fun execute(args: AtomicSwapOperationSubmissionArgs): Result { + return submitInternal(args) + .mapCatching { + SwapExecutionCorrection( + actualReceivedAmount = it.requireOutcomeOk().emittedEvents.determineActualSwappedAmount() + ) + } + } + + override suspend fun submit(args: AtomicSwapOperationSubmissionArgs): Result { + return submitInternal(args) + .map { SwapSubmissionResult(it.submissionHierarchy) } + } + + private suspend fun submitInternal(args: AtomicSwapOperationSubmissionArgs): Result { + return swapHost.extrinsicService().submitExtrinsicAndAwaitExecution( + chain = chain, + origin = TransactionOrigin.SelectedWallet, + submissionOptions = ExtrinsicService.SubmissionOptions( + feePaymentCurrency = transactionArgs.feePaymentCurrency + ) + ) { buildingContext -> + // Send swapped funds to the executingAccount since it the account doing the swap + executeSwap(swapLimit = args.actualSwapLimit, sendTo = buildingContext.submissionOrigin.executingAccount) + }.requireOk() + } + + private fun List.determineActualSwappedAmount(): Balance { + val swap = findEventOrThrow(Modules.ASSET_CONVERSION, "SwapExecuted") + val (_, _, _, amountOut) = swap.arguments + + return bindNumber(amountOut) + } + + private suspend fun ExtrinsicBuilder.executeSwap( + swapLimit: SwapLimit, + sendTo: AccountId + ) { + val assetIdXcmVersion = detectAssetIdXcmVersion(runtime) + + val path = listOf(fromAsset, toAsset) + .map { asset -> multiLocationConverter.encodableMultiLocationOf(asset, assetIdXcmVersion) } + + val keepAlive = false + + when (swapLimit) { + is SwapLimit.SpecifiedIn -> call( + moduleName = Modules.ASSET_CONVERSION, + callName = "swap_exact_tokens_for_tokens", + arguments = mapOf( + "path" to path, + "amount_in" to swapLimit.amountIn, + "amount_out_min" to swapLimit.amountOutMin, + "send_to" to sendTo, + "keep_alive" to keepAlive + ) + ) + + is SwapLimit.SpecifiedOut -> call( + moduleName = Modules.ASSET_CONVERSION, + callName = "swap_tokens_for_exact_tokens", + arguments = mapOf( + "path" to path, + "amount_out" to swapLimit.amountOut, + "amount_in_max" to swapLimit.amountInMax, + "send_to" to sendTo, + "keep_alive" to keepAlive + ) + ) + } + } + + private suspend fun MultiLocationConverter.encodableMultiLocationOf( + chainAsset: Chain.Asset, + xcmVersion: XcmVersion + ): Any { + return toMultiLocationOrThrow(chainAsset).toEncodableInstance(xcmVersion) + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/crossChain/CrossChainTransferAssetExchange.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/crossChain/CrossChainTransferAssetExchange.kt new file mode 100644 index 0000000..9afdcbb --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/crossChain/CrossChainTransferAssetExchange.kt @@ -0,0 +1,358 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.crossChain + +import io.novafoundation.nova.common.utils.firstNotNull +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.common.utils.graph.Edge +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.common.utils.graph.WeightedEdge +import io.novafoundation.nova.common.utils.mapError +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.model.addPlanks +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData.SwapFeeComponentDisplay +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperation +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationPrototype +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationSubmissionArgs +import io.novafoundation.nova.feature_swap_api.domain.model.FeeWithLabel +import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger +import io.novafoundation.nova.feature_swap_api.domain.model.SubmissionFeeWithLabel +import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionCorrection +import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge +import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit +import io.novafoundation.nova.feature_swap_api.domain.model.SwapMaxAdditionalAmountDeduction +import io.novafoundation.nova.feature_swap_api.domain.model.SwapOperationSubmissionException +import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult +import io.novafoundation.nova.feature_swap_api.domain.model.UsdConverter +import io.novafoundation.nova.feature_swap_api.domain.model.crossChain +import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountIn +import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee +import io.novafoundation.nova.feature_swap_api.domain.model.network +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.AssetExchange +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.FeePaymentProviderOverride +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferDirection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.XcmTransferDryRunOrigin +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.CrossChainTransferFee +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransfersConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.availableInDestinations +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.transferFeatures +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.hasDeliveryFee +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.disabledChains +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import java.math.BigDecimal +import java.math.BigInteger +import kotlin.time.Duration + +class CrossChainTransferAssetExchangeFactory( + private val crossChainTransfersUseCase: CrossChainTransfersUseCase, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, +) : AssetExchange.MultiChainFactory { + + override suspend fun create( + swapHost: AssetExchange.SwapHost + ): AssetExchange { + return CrossChainTransferAssetExchange( + crossChainTransfersUseCase = crossChainTransfersUseCase, + chainRegistry = chainRegistry, + accountRepository = accountRepository, + swapHost = swapHost + ) + } +} + +class CrossChainTransferAssetExchange( + private val crossChainTransfersUseCase: CrossChainTransfersUseCase, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val swapHost: AssetExchange.SwapHost, +) : AssetExchange { + + private val crossChainConfig = MutableStateFlow(null) + + override suspend fun sync() { + crossChainTransfersUseCase.syncCrossChainConfig() + + crossChainConfig.emit(crossChainTransfersUseCase.getConfiguration()) + } + + override suspend fun availableDirectSwapConnections(): List { + val config = crossChainConfig.firstNotNull() + val disabledChainIds = chainRegistry.disabledChains().mapToSet { it.id } + + return config.availableInDestinations() + .filter { it.from.chainId !in disabledChainIds && it.to.chainId !in disabledChainIds } + .map(::CrossChainTransferEdge) + } + + override fun feePaymentOverrides(): List { + return emptyList() + } + + override fun runSubscriptions(metaAccount: MetaAccount): Flow { + return emptyFlow() + } + + inner class CrossChainTransferEdge( + val delegate: Edge + ) : SwapGraphEdge, Edge by delegate { + + private var canUseXcmExecute: Boolean? = null + + override suspend fun beginOperation(args: AtomicSwapOperationArgs): AtomicSwapOperation { + return CrossChainTransferOperation(args, this) + } + + override suspend fun appendToOperation(currentTransaction: AtomicSwapOperation, args: AtomicSwapOperationArgs): AtomicSwapOperation? { + return null + } + + override suspend fun beginOperationPrototype(): AtomicSwapOperationPrototype { + return CrossChainTransferOperationPrototype(this) + } + + override suspend fun appendToOperationPrototype(currentTransaction: AtomicSwapOperationPrototype): AtomicSwapOperationPrototype? { + return null + } + + override suspend fun debugLabel(): String { + return "To ${chainRegistry.getChain(delegate.to.chainId).name}" + } + + override fun predecessorHandlesFees(predecessor: SwapGraphEdge): Boolean { + return false + } + + override suspend fun canPayNonNativeFeesInIntermediatePosition(): Boolean { + // By default, delivery fees are not payable in non native assets + return !hasDeliveryFees() || + // ... but xcm execute allows to workaround it + canUseXcmExecute() + } + + override suspend fun canTransferOutWholeAccountBalance(): Boolean { + // Precisely speaking just checking for delivery fees is not enough + // AssetTransactor on origin should also use Preserve transfers when executing TransferAssets instruction + // However it is much harder to check and there are no chains yet that have limitations on AssetTransactor level + // but don't have delivery fees, so we only check for delivery fees + return !hasDeliveryFees() || + // When direction has delivery fees, xcm execute can be used to pay them from holding, thus allowing to transfer whole balance + // and also workaround AssetTransactor issue as "Withdraw" instruction doesn't use Preserve transfers but rather use burn + canUseXcmExecute() + } + + override suspend fun quote(amount: BigInteger, direction: SwapDirection): BigInteger { + return amount + } + + override fun weightForAppendingTo(path: Path>): Int { + return Weights.CrossChainTransfer.TRANSFER + } + + private suspend fun canUseXcmExecute(): Boolean { + if (canUseXcmExecute == null) { + canUseXcmExecute = calculateCanUseXcmExecute() + } + + return canUseXcmExecute!! + } + + private fun hasDeliveryFees(): Boolean { + val config = crossChainConfig.value ?: return false + return config.hasDeliveryFee(delegate.from, delegate.to) + } + + private suspend fun calculateCanUseXcmExecute(): Boolean { + val features = crossChainConfig.value?.dynamic?.transferFeatures(delegate.from, delegate.to.chainId) ?: return false + return crossChainTransfersUseCase.supportsXcmExecute(delegate.from.chainId, features) + } + } + + inner class CrossChainTransferOperationPrototype( + private val edge: Edge, + ) : AtomicSwapOperationPrototype { + + override val fromChain: ChainId = edge.from.chainId + + private val toChain: ChainId = edge.to.chainId + + override suspend fun roughlyEstimateNativeFee(usdConverter: UsdConverter): BigDecimal { + var totalAmount = BigDecimal.ZERO + + if (isChainWithExpensiveCrossChain(fromChain)) { + totalAmount += usdConverter.nativeAssetEquivalentOf(0.15) + } + + if (isChainWithExpensiveCrossChain(toChain)) { + totalAmount += usdConverter.nativeAssetEquivalentOf(0.1) + } + + if (!(isChainWithExpensiveCrossChain(fromChain) || isChainWithExpensiveCrossChain(toChain))) { + totalAmount += usdConverter.nativeAssetEquivalentOf(0.01) + } + + return totalAmount + } + + override suspend fun maximumExecutionTime(): Duration { + val (fromChain, fromAsset) = chainRegistry.chainWithAsset(edge.from) + val (toChain, toAsset) = chainRegistry.chainWithAsset(edge.to) + + val transferDirection = AssetTransferDirection(fromChain, fromAsset, toChain, toAsset) + + return crossChainTransfersUseCase.maximumExecutionTime(transferDirection, swapHost.scope) + } + + private fun isChainWithExpensiveCrossChain(chainId: ChainId): Boolean { + return (chainId == Chain.Geneses.POLKADOT) or (chainId == Chain.Geneses.POLKADOT_ASSET_HUB) + } + } + + inner class CrossChainTransferOperation( + private val transactionArgs: AtomicSwapOperationArgs, + private val edge: Edge + ) : AtomicSwapOperation { + + override val estimatedSwapLimit: SwapLimit = transactionArgs.estimatedSwapLimit + + override val assetOut: FullChainAssetId = edge.to + + override val assetIn: FullChainAssetId = edge.from + + override suspend fun constructDisplayData(): AtomicOperationDisplayData { + return AtomicOperationDisplayData.Transfer( + from = edge.from, + to = edge.to, + amount = estimatedSwapLimit.estimatedAmountIn + ) + } + + override suspend fun estimateFee(): AtomicSwapOperationFee { + val transfer = createTransfer(amount = estimatedSwapLimit.crossChainTransferAmount) + + val crossChainFee = with(crossChainTransfersUseCase) { + swapHost.extrinsicService().estimateFee(transfer, swapHost.scope) + } + + return CrossChainAtomicOperationFee(crossChainFee) + } + + override suspend fun requiredAmountInToGetAmountOut(extraOutAmount: Balance): Balance { + return extraOutAmount + } + + override suspend fun additionalMaxAmountDeduction(): SwapMaxAdditionalAmountDeduction { + val (originChain, originChainAsset) = chainRegistry.chainWithAsset(edge.from) + val destinationChain = chainRegistry.getChain(edge.to.chainId) + + return SwapMaxAdditionalAmountDeduction( + fromCountedTowardsEd = crossChainTransfersUseCase.requiredRemainingAmountAfterTransfer(originChain, originChainAsset, destinationChain) + ) + } + + override suspend fun execute(args: AtomicSwapOperationSubmissionArgs): Result { + val transfer = createTransfer(amount = args.actualSwapLimit.crossChainTransferAmount) + + return dryRunTransfer(transfer) + .flatMap { with(crossChainTransfersUseCase) { swapHost.extrinsicService().performTransferAndTrackTransfer(transfer, swapHost.scope) } } + .map(::SwapExecutionCorrection) + } + + override suspend fun submit(args: AtomicSwapOperationSubmissionArgs): Result { + val transfer = createTransfer(amount = args.actualSwapLimit.crossChainTransferAmount) + + return dryRunTransfer(transfer) + .flatMap { with(crossChainTransfersUseCase) { swapHost.extrinsicService().performTransferOfExactAmount(transfer, swapHost.scope) } } + .map { SwapSubmissionResult(it.submissionHierarchy) } + } + + private suspend fun dryRunTransfer(transfer: AssetTransferBase): Result { + val metaAccount = accountRepository.getSelectedMetaAccount() + val origin = metaAccount.requireAccountIdKeyIn(transfer.originChain) + + return crossChainTransfersUseCase.dryRunTransferIfPossible( + transfer = transfer, + // We are transferring exact amount, so we use zero for the fee here + origin = XcmTransferDryRunOrigin.Signed(origin, crossChainFee = Balance.ZERO), + computationalScope = swapHost.scope + ) + .mapError { SwapOperationSubmissionException.SimulationFailed() } + } + + private suspend fun createTransfer(amount: Balance): AssetTransferBase { + val (originChain, originAsset) = chainRegistry.chainWithAsset(edge.from) + val (destinationChain, destinationAsset) = chainRegistry.chainWithAsset(edge.to) + + val selectedAccount = accountRepository.getSelectedMetaAccount() + + return AssetTransferBase( + recipient = selectedAccount.requireAddressIn(destinationChain), + originChain = originChain, + originChainAsset = originAsset, + destinationChain = destinationChain, + destinationChainAsset = destinationAsset, + feePaymentCurrency = transactionArgs.feePaymentCurrency, + amountPlanks = amount + ) + } + + private val SwapLimit.crossChainTransferAmount: Balance + get() = when (this) { + is SwapLimit.SpecifiedIn -> amountIn + is SwapLimit.SpecifiedOut -> amountOut + } + } + + private class CrossChainAtomicOperationFee( + private val crossChainFee: CrossChainTransferFee + ) : AtomicSwapOperationFee { + + override val submissionFee = SubmissionFeeWithLabel(crossChainFee.submissionFee) + + override val postSubmissionFees = AtomicSwapOperationFee.PostSubmissionFees( + paidByAccount = listOfNotNull( + SubmissionFeeWithLabel(crossChainFee.postSubmissionByAccount, debugLabel = "Delivery"), + ), + paidFromAmount = listOf( + FeeWithLabel(crossChainFee.postSubmissionFromAmount, debugLabel = "Execution") + ) + ) + + override fun constructDisplayData(): AtomicOperationFeeDisplayData { + val deliveryFee = crossChainFee.postSubmissionByAccount + val shouldSeparateDeliveryFromExecution = deliveryFee != null && deliveryFee.asset.fullId != crossChainFee.postSubmissionFromAmount.asset.fullId + + val crossChainFeeComponentDisplay = if (shouldSeparateDeliveryFromExecution) { + SwapFeeComponentDisplay.crossChain(crossChainFee.postSubmissionFromAmount, deliveryFee!!) + } else { + val totalCrossChain = crossChainFee.postSubmissionFromAmount.addPlanks(deliveryFee?.amount.orZero()) + SwapFeeComponentDisplay.crossChain(totalCrossChain) + } + + val submissionFeeComponent = SwapFeeComponentDisplay.network(crossChainFee.submissionFee) + + val components = listOf(submissionFeeComponent, crossChainFeeComponentDisplay) + return AtomicOperationFeeDisplayData(components) + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/HydraDxAssetExchange.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/HydraDxAssetExchange.kt new file mode 100644 index 0000000..1974b51 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/HydraDxAssetExchange.kt @@ -0,0 +1,607 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.flatMapAsync +import io.novafoundation.nova.common.utils.forEachAsync +import io.novafoundation.nova.common.utils.mergeIfMultiple +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.utils.structOf +import io.novafoundation.nova.common.utils.times +import io.novafoundation.nova.common.utils.withFlowScope +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOutcomeOk +import io.novafoundation.nova.feature_account_api.data.fee.FeePayment +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability +import io.novafoundation.nova.feature_account_api.data.fee.chains.CustomOrNativeFeePaymentProvider +import io.novafoundation.nova.feature_account_api.data.fee.types.NativeFeePayment +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector.ResetMode +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector.SetFeesMode +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector.SetMode +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFee +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperation +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationPrototype +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationSubmissionArgs +import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger +import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionCorrection +import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge +import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit +import io.novafoundation.nova.feature_swap_api.domain.model.SwapMaxAdditionalAmountDeduction +import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult +import io.novafoundation.nova.feature_swap_api.domain.model.UsdConverter +import io.novafoundation.nova.feature_swap_api.domain.model.createAggregated +import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountIn +import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountOut +import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee +import io.novafoundation.nova.feature_swap_api.domain.model.fee.SubmissionOnlyAtomicSwapOperationFee +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.accountCurrencyMap +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.multiTransactionPayment +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.HydraDxQuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.network.toOnChainIdOrThrow +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.AssetExchange +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.FeePaymentProviderOverride +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.ParentQuoterArgs +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.HydraDxNovaReferral +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.linkedAccounts +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.referralsOrNull +import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilder +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEventOrThrow +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.expectedBlockTime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.math.BigDecimal +import kotlin.time.Duration + +class HydraDxExchangeFactory( + private val remoteStorageSource: StorageDataSource, + private val sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val hydraDxNovaReferral: HydraDxNovaReferral, + private val swapSourceFactories: Iterable>, + private val quotingFactory: HydraDxQuoting.Factory, + private val hydrationFeeInjector: HydrationFeeInjector, + private val chainStateRepository: ChainStateRepository, + private val swapDeductionUseCase: AssetInAdditionalSwapDeductionUseCase, + private val hydrationPriceConversionFallback: HydrationPriceConversionFallback, + private val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher +) : AssetExchange.SingleChainFactory { + + override suspend fun create(chain: Chain, swapHost: AssetExchange.SwapHost): AssetExchange { + return HydraDxAssetExchange( + remoteStorageSource = remoteStorageSource, + chain = chain, + storageSharedRequestsBuilderFactory = sharedRequestsBuilderFactory, + hydraDxAssetIdConverter = hydraDxAssetIdConverter, + hydraDxNovaReferral = hydraDxNovaReferral, + swapSourceFactories = swapSourceFactories, + swapHost = swapHost, + hydrationFeeInjector = hydrationFeeInjector, + delegate = quotingFactory.create(chain, swapHost), + chainStateRepository = chainStateRepository, + swapDeductionUseCase = swapDeductionUseCase, + hydrationPriceConversionFallback = hydrationPriceConversionFallback, + hydrationAcceptedFeeCurrenciesFetcher = hydrationAcceptedFeeCurrenciesFetcher + ) + } +} + +private const val ROUTE_EXECUTED_AMOUNT_OUT_IDX = 3 +private const val FEE_QUOTE_BUFFER = 1.1 + +private class HydraDxAssetExchange( + private val delegate: HydraDxQuoting, + private val remoteStorageSource: StorageDataSource, + private val chain: Chain, + private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val hydraDxNovaReferral: HydraDxNovaReferral, + private val swapSourceFactories: Iterable>, + private val swapHost: AssetExchange.SwapHost, + private val hydrationFeeInjector: HydrationFeeInjector, + private val chainStateRepository: ChainStateRepository, + private val swapDeductionUseCase: AssetInAdditionalSwapDeductionUseCase, + private val hydrationPriceConversionFallback: HydrationPriceConversionFallback, + private val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher +) : AssetExchange { + + private val swapSources: List = createSources() + + private val currentPaymentAsset: MutableSharedFlow = singleReplaySharedFlow() + + private val userReferralState: MutableSharedFlow = singleReplaySharedFlow() + + override suspend fun sync() { + return swapSources.forEachAsync { it.sync() } + } + + override suspend fun availableDirectSwapConnections(): List { + return swapSources.flatMapAsync { source -> + source.availableSwapDirections().map(::HydraDxSwapEdge) + } + } + + override fun feePaymentOverrides(): List { + return listOf( + FeePaymentProviderOverride( + provider = ReusableQuoteFeePaymentProvider(), + chain = chain + ) + ) + } + + override fun runSubscriptions(metaAccount: MetaAccount): Flow { + return withFlowScope { scope -> + val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) + val userAccountId = metaAccount.requireAccountIdIn(chain) + + val feeCurrency = remoteStorageSource.subscribe(chain.id, subscriptionBuilder) { + metadata.multiTransactionPayment.accountCurrencyMap.observe(userAccountId) + } + + val userReferral = subscribeUserReferral(userAccountId, subscriptionBuilder).onEach { + userReferralState.emit(it) + } + + val sourcesSubscription = swapSources.map { + it.runSubscriptions(userAccountId, subscriptionBuilder) + }.mergeIfMultiple() + + subscriptionBuilder.subscribe(scope) + + val feeCurrencyUpdates = feeCurrency.onEach { tokenId -> + val feePaymentAsset = tokenId ?: hydraDxAssetIdConverter.systemAssetId + currentPaymentAsset.emit(feePaymentAsset) + } + + combine(sourcesSubscription, feeCurrencyUpdates, userReferral) { _, _, _ -> + ReQuoteTrigger + } + } + } + + @Suppress("IfThenToElvis") + private suspend fun subscribeUserReferral( + userAccountId: AccountId, + subscriptionBuilder: StorageSharedRequestsBuilder + ): Flow { + return remoteStorageSource.subscribe(chain.id, subscriptionBuilder) { + val referralsModule = metadata.referralsOrNull + + if (referralsModule != null) { + referralsModule.linkedAccounts.observe(userAccountId).map { linkedAccount -> + if (linkedAccount != null) ReferralState.SET else ReferralState.NOT_SET + } + } else { + flowOf(ReferralState.NOT_AVAILABLE) + } + } + } + + private suspend fun HydraDxAssetIdConverter.toOnChainIdOrThrow(localId: FullChainAssetId): HydraDxAssetId { + val chainAsset = chain.assetsById.getValue(localId.assetId) + + return toOnChainIdOrThrow(chainAsset) + } + + private enum class ReferralState { + SET, NOT_SET, NOT_AVAILABLE + } + + @Suppress("UNCHECKED_CAST") + private fun createSources(): List { + return swapSourceFactories.map { + val sourceDelegate = delegate.getSource(it.identifier) + + // Cast should be safe as long as identifiers between delegates and wrappers match + (it as HydraDxSwapSource.Factory>).create(sourceDelegate) + } + } + + private inner class HydraDxSwapEdge( + private val sourceQuotableEdge: HydraDxSourceEdge, + ) : SwapGraphEdge, HydraDxQuotableEdge by sourceQuotableEdge { + + override suspend fun beginOperation(args: AtomicSwapOperationArgs): AtomicSwapOperation { + return HydraDxOperation(sourceQuotableEdge, args) + } + + override suspend fun appendToOperation(currentTransaction: AtomicSwapOperation, args: AtomicSwapOperationArgs): AtomicSwapOperation? { + if (currentTransaction !is HydraDxOperation) return null + + return currentTransaction.appendSegment(sourceQuotableEdge, args) + } + + override suspend fun beginOperationPrototype(): AtomicSwapOperationPrototype { + return HydraDxOperationPrototype(from.chainId) + } + + override suspend fun appendToOperationPrototype(currentTransaction: AtomicSwapOperationPrototype): AtomicSwapOperationPrototype? { + return if (currentTransaction is HydraDxOperationPrototype) { + currentTransaction + } else { + null + } + } + + override suspend fun debugLabel(): String { + return sourceQuotableEdge.debugLabel() + } + + override fun predecessorHandlesFees(predecessor: SwapGraphEdge): Boolean { + // When chaining multiple hydra edges together, the fee is always paid with the starting edge + return predecessor is HydraDxSwapEdge + } + + override suspend fun canPayNonNativeFeesInIntermediatePosition(): Boolean { + return true + } + + override suspend fun canTransferOutWholeAccountBalance(): Boolean { + return true + } + } + + inner class HydraDxOperationPrototype(override val fromChain: ChainId) : AtomicSwapOperationPrototype { + + override suspend fun roughlyEstimateNativeFee(usdConverter: UsdConverter): BigDecimal { + // in HDX + return 0.5.toBigDecimal() + } + + override suspend fun maximumExecutionTime(): Duration { + return chainStateRepository.expectedBlockTime(chain.id) + } + } + + inner class HydraDxOperation private constructor( + val segments: List, + val feePaymentCurrency: FeePaymentCurrency + ) : AtomicSwapOperation { + + override val estimatedSwapLimit: SwapLimit = aggregatedSwapLimit() + + override val assetOut: FullChainAssetId = segments.last().edge.to + + override val assetIn: FullChainAssetId = segments.first().edge.from + + constructor(sourceEdge: HydraDxSourceEdge, args: AtomicSwapOperationArgs) : + this(listOf(HydraDxSwapTransactionSegment(sourceEdge, args.estimatedSwapLimit)), args.feePaymentCurrency) + + fun appendSegment(nextEdge: HydraDxSourceEdge, nextSwapArgs: AtomicSwapOperationArgs): HydraDxOperation { + val nextSegment = HydraDxSwapTransactionSegment(nextEdge, nextSwapArgs.estimatedSwapLimit) + + // Ignore nextSwapArgs.feePaymentCurrency - we are using configuration from the very first segment + return HydraDxOperation(segments + nextSegment, feePaymentCurrency) + } + + override suspend fun constructDisplayData(): AtomicOperationDisplayData { + return AtomicOperationDisplayData.Swap( + from = assetIn.withAmount(estimatedSwapLimit.estimatedAmountIn), + to = assetOut.withAmount(estimatedSwapLimit.estimatedAmountOut), + ) + } + + override suspend fun estimateFee(): AtomicSwapOperationFee { + val submissionFee = swapHost.extrinsicService().estimateFee( + chain = chain, + origin = TransactionOrigin.SelectedWallet, + submissionOptions = ExtrinsicService.SubmissionOptions( + batchMode = BatchMode.BATCH_ALL, + feePaymentCurrency = feePaymentCurrency + ) + ) { + executeSwap(estimatedSwapLimit) + } + + return SubmissionOnlyAtomicSwapOperationFee(submissionFee) + } + + override suspend fun requiredAmountInToGetAmountOut(extraOutAmount: Balance): Balance { + val assetInId = assetIn.assetId + val assetIn = chain.assetsById.getValue(assetInId) + + val assetOutId = assetOut.assetId + val assetOut = chain.assetsById.getValue(assetOutId) + + val quoteArgs = ParentQuoterArgs( + chainAssetIn = assetIn, + chainAssetOut = assetOut, + amount = extraOutAmount, + swapDirection = SwapDirection.SPECIFIED_OUT + ) + + return swapHost.quote(quoteArgs) + } + + override suspend fun additionalMaxAmountDeduction(): SwapMaxAdditionalAmountDeduction { + val assetInId = assetIn.assetId + val assetIn = chain.assetsById.getValue(assetInId) + + val assetOutId = assetOut.assetId + val assetOut = chain.assetsById.getValue(assetOutId) + + return SwapMaxAdditionalAmountDeduction( + fromCountedTowardsEd = swapDeductionUseCase.invoke(assetIn, assetOut) + ) + } + + override suspend fun execute(args: AtomicSwapOperationSubmissionArgs): Result { + return submitInternal(args) + .mapCatching { + SwapExecutionCorrection( + actualReceivedAmount = it.requireOutcomeOk().emittedEvents.determineActualSwappedAmount() + ) + } + } + + override suspend fun submit(args: AtomicSwapOperationSubmissionArgs): Result { + return submitInternal(args) + .map { SwapSubmissionResult(it.submissionHierarchy) } + } + + private suspend fun submitInternal(args: AtomicSwapOperationSubmissionArgs): Result { + return swapHost.extrinsicService().submitExtrinsicAndAwaitExecution( + chain = chain, + origin = TransactionOrigin.SelectedWallet, + submissionOptions = ExtrinsicService.SubmissionOptions( + batchMode = BatchMode.BATCH_ALL, + feePaymentCurrency = feePaymentCurrency + ) + ) { + executeSwap(args.actualSwapLimit) + }.requireOk() + } + + private fun List.determineActualSwappedAmount(): Balance { + val standaloneHydraSwap = getStandaloneSwap() + if (standaloneHydraSwap != null) { + return standaloneHydraSwap.extractReceivedAmount(this) + } + + val swapExecutedEvent = findEvent(Modules.ROUTER, "RouteExecuted") + ?: findEventOrThrow(Modules.ROUTER, "Executed") + + val amountOut = swapExecutedEvent.arguments[ROUTE_EXECUTED_AMOUNT_OUT_IDX] + return bindNumber(amountOut) + } + + private suspend fun ExtrinsicBuilder.executeSwap(actualSwapLimit: SwapLimit) { + maybeSetReferral() + + addSwapCall(actualSwapLimit) + } + + private suspend fun ExtrinsicBuilder.addSwapCall(actualSwapLimit: SwapLimit) { + val optimizationSucceeded = tryOptimizedSwap(actualSwapLimit) + + if (!optimizationSucceeded) { + executeRouterSwap(actualSwapLimit) + } + } + + private fun ExtrinsicBuilder.tryOptimizedSwap(actualSwapLimit: SwapLimit): Boolean { + val standaloneSwap = getStandaloneSwap() ?: return false + + val args = AtomicSwapOperationArgs(actualSwapLimit, feePaymentCurrency) + standaloneSwap.addSwapCall(args) + + return true + } + + private fun getStandaloneSwap(): StandaloneHydraSwap? { + if (segments.size != 1) return null + + val onlySegment = segments.single() + return onlySegment.edge.standaloneSwap + } + + private suspend fun ExtrinsicBuilder.executeRouterSwap(actualSwapLimit: SwapLimit) { + val firstSegment = segments.first() + val lastSegment = segments.last() + + when (actualSwapLimit) { + is SwapLimit.SpecifiedIn -> executeRouterSell( + firstEdge = firstSegment.edge, + lastEdge = lastSegment.edge, + limit = actualSwapLimit, + ) + + is SwapLimit.SpecifiedOut -> executeRouterBuy( + firstEdge = firstSegment.edge, + lastEdge = lastSegment.edge, + limit = actualSwapLimit, + ) + } + } + + private suspend fun ExtrinsicBuilder.executeRouterBuy( + firstEdge: HydraDxSourceEdge, + lastEdge: HydraDxSourceEdge, + limit: SwapLimit.SpecifiedOut, + ) { + call( + moduleName = Modules.ROUTER, + callName = "buy", + arguments = mapOf( + "asset_in" to hydraDxAssetIdConverter.toOnChainIdOrThrow(firstEdge.from), + "asset_out" to hydraDxAssetIdConverter.toOnChainIdOrThrow(lastEdge.to), + "amount_out" to limit.amountOut, + "max_amount_in" to limit.amountInMax, + "route" to routerTradePath() + ) + ) + } + + private suspend fun ExtrinsicBuilder.executeRouterSell( + firstEdge: HydraDxSourceEdge, + lastEdge: HydraDxSourceEdge, + limit: SwapLimit.SpecifiedIn, + ) { + call( + moduleName = Modules.ROUTER, + callName = "sell", + arguments = mapOf( + "asset_in" to hydraDxAssetIdConverter.toOnChainIdOrThrow(firstEdge.from), + "asset_out" to hydraDxAssetIdConverter.toOnChainIdOrThrow(lastEdge.to), + "amount_in" to limit.amountIn, + "min_amount_out" to limit.amountOutMin, + "route" to routerTradePath() + ) + ) + } + + private suspend fun routerTradePath(): List { + return segments.map { segment -> + structOf( + "pool" to segment.edge.routerPoolArgument(), + "assetIn" to hydraDxAssetIdConverter.toOnChainIdOrThrow(segment.edge.from), + "assetOut" to hydraDxAssetIdConverter.toOnChainIdOrThrow(segment.edge.to) + ) + } + } + + private suspend fun ExtrinsicBuilder.maybeSetReferral() { + val referralState = userReferralState.first() + + if (referralState == ReferralState.NOT_SET) { + val novaReferralCode = hydraDxNovaReferral.getNovaReferralCode() + + linkCode(novaReferralCode) + } + } + + private fun ExtrinsicBuilder.linkCode(referralCode: String) { + call( + moduleName = Modules.REFERRALS, + callName = "link_code", + arguments = mapOf( + "code" to referralCode.encodeToByteArray() + ) + ) + } + + private fun aggregatedSwapLimit(): SwapLimit { + val firstLimit = segments.first().swapLimit + val lastLimit = segments.last().swapLimit + + return SwapLimit.createAggregated(firstLimit, lastLimit) + } + } + + // This is an optimization to reuse swap quoting state for hydra fee estimation instead of letting ExtrinsicService to spin up its own quoting + private inner class ReusableQuoteFeePaymentProvider() : CustomOrNativeFeePaymentProvider() { + + override val chain: Chain = this@HydraDxAssetExchange.chain + + override suspend fun feePaymentFor(customFeeAsset: Chain.Asset, coroutineScope: CoroutineScope?): FeePayment { + return ReusableQuoteFeePayment(customFeeAsset) + } + + override suspend fun canPayFeeInNonUtilityToken(customFeeAsset: Chain.Asset): Result { + return hydrationAcceptedFeeCurrenciesFetcher.isAcceptedCurrency(customFeeAsset) + } + + override suspend fun detectFeePaymentFromExtrinsic(extrinsic: SendableExtrinsic): FeePayment { + // Todo Hydration fee support from extrinsic + return NativeFeePayment() + } + + override suspend fun fastLookupCustomFeeCapability(): Result { + return runCatching { + val acceptedCurrencies = hydrationAcceptedFeeCurrenciesFetcher.fetchAcceptedFeeCurrencies(chain) + .getOrDefault(emptySet()) + + HydrationFastLookupFeeCapability(acceptedCurrencies) + } + } + } + + private inner class ReusableQuoteFeePayment( + private val customFeeAsset: Chain.Asset + ) : FeePayment { + + override suspend fun modifyExtrinsic(extrinsicBuilder: ExtrinsicBuilder) { + val currentFeeTokenId = currentPaymentAsset.first() + + val setFeesMode = SetFeesMode( + setMode = SetMode.Lazy(currentFeeTokenId), + resetMode = ResetMode.ToNativeLazily(currentFeeTokenId) + ) + + hydrationFeeInjector.setFees(extrinsicBuilder, customFeeAsset, setFeesMode) + } + + override suspend fun convertNativeFee(nativeFee: Fee): Fee { + val args = ParentQuoterArgs( + chainAssetIn = customFeeAsset, + chainAssetOut = chain.utilityAsset, + amount = nativeFee.amount, + swapDirection = SwapDirection.SPECIFIED_OUT + ) + + val quotedFee = runCatching { swapHost.quote(args) } + .recoverCatching { hydrationPriceConversionFallback.convertNativeAmount(nativeFee.amount, customFeeAsset) } + .getOrThrow() + + // Fees in non-native assets are especially volatile since conversion happens through swaps so we add some buffer to mitigate volatility + val quotedFeeWithBuffer = quotedFee * FEE_QUOTE_BUFFER + + return SubstrateFee( + amount = quotedFeeWithBuffer, + submissionOrigin = nativeFee.submissionOrigin, + asset = customFeeAsset + ) + } + } + + private inner class HydrationFastLookupFeeCapability( + override val nonUtilityFeeCapableTokens: Set + ) : FastLookupCustomFeeCapability + + class HydraDxSwapTransactionSegment( + val edge: HydraDxSourceEdge, + val swapLimit: SwapLimit, + ) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/HydraDxSwapSource.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/HydraDxSwapSource.kt new file mode 100644 index 0000000..5439a71 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/HydraDxSwapSource.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.HydraDxQuotableEdge +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.flow.Flow + +interface StandaloneHydraSwap { + + context(ExtrinsicBuilder) + fun addSwapCall(args: AtomicSwapOperationArgs) + + fun extractReceivedAmount(events: List): Balance +} + +interface HydraDxSourceEdge : HydraDxQuotableEdge { + + fun routerPoolArgument(): DictEnum.Entry<*> + + /** + * Whether hydra swap source is able to perform optimized standalone swap without using Router + */ + val standaloneSwap: StandaloneHydraSwap? + + suspend fun debugLabel(): String +} + +interface HydraDxSwapSource : Identifiable { + + suspend fun sync() + + suspend fun availableSwapDirections(): Collection + + suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow + + interface Factory> : Identifiable { + + fun create(delegate: D): HydraDxSwapSource + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/aave/AaveSwapSource.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/aave/AaveSwapSource.kt new file mode 100644 index 0000000..80dfdf5 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/aave/AaveSwapSource.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.aave + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave.AavePoolQuotingSource +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave.AaveSwapQuotingSourceFactory +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSourceEdge +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +@FeatureScope +class AaveSwapSourceFactory @Inject constructor() : HydraDxSwapSource.Factory { + + override val identifier: String = AaveSwapQuotingSourceFactory.ID + + override fun create(delegate: AavePoolQuotingSource): HydraDxSwapSource { + return AaveSwapSource(delegate) + } +} + +private class AaveSwapSource( + private val delegate: AavePoolQuotingSource, +) : HydraDxSwapSource, Identifiable by delegate { + + override suspend fun sync() { + return delegate.sync() + } + + override suspend fun availableSwapDirections(): Collection { + return delegate.availableSwapDirections().map(::AaveSwapEdge) + } + + override suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return delegate.runSubscriptions(userAccountId, subscriptionBuilder) + } + + inner class AaveSwapEdge( + private val delegate: AavePoolQuotingSource.Edge + ) : HydraDxSourceEdge, QuotableEdge by delegate { + + override fun routerPoolArgument(): DictEnum.Entry<*> { + return DictEnum.Entry("Aave", null) + } + + override val standaloneSwap = null + + override suspend fun debugLabel(): String { + return "Aave" + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/omnipool/OmniPoolSwapSource.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/omnipool/OmniPoolSwapSource.kt new file mode 100644 index 0000000..4398769 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/omnipool/OmniPoolSwapSource.kt @@ -0,0 +1,136 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.omnipool + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs +import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.OmniPoolQuotingSource +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.OmniPoolQuotingSourceFactory +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSourceEdge +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.StandaloneHydraSwap +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEventOrThrow +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import kotlinx.coroutines.flow.Flow + +private const val AMOUNT_OUT_POSITION = 4 + +class OmniPoolSwapSourceFactory : HydraDxSwapSource.Factory { + + override val identifier: String = OmniPoolQuotingSourceFactory.SOURCE_ID + + override fun create(delegate: OmniPoolQuotingSource): HydraDxSwapSource { + return OmniPoolSwapSource(delegate) + } +} + +private class OmniPoolSwapSource( + private val delegate: OmniPoolQuotingSource, +) : HydraDxSwapSource, Identifiable by delegate { + + override suspend fun sync() { + return delegate.sync() + } + + override suspend fun availableSwapDirections(): Collection { + return delegate.availableSwapDirections().map(::OmniPoolSwapEdge) + } + + override suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return delegate.runSubscriptions(userAccountId, subscriptionBuilder) + } + + private inner class OmniPoolSwapEdge( + private val delegate: OmniPoolQuotingSource.Edge + ) : HydraDxSourceEdge, QuotableEdge by delegate, StandaloneHydraSwap { + + override fun routerPoolArgument(): DictEnum.Entry<*> { + return DictEnum.Entry("Omnipool", null) + } + + override val standaloneSwap = this + + override suspend fun debugLabel(): String { + return "OmniPool" + } + + context(ExtrinsicBuilder) + override fun addSwapCall(args: AtomicSwapOperationArgs) { + val assetIdIn = delegate.fromAsset.first + val assetIdOut = delegate.toAsset.first + + when (val limit = args.estimatedSwapLimit) { + is SwapLimit.SpecifiedIn -> sell( + assetIdIn = assetIdIn, + assetIdOut = assetIdOut, + amountIn = limit.amountIn, + minBuyAmount = limit.amountOutMin + ) + + is SwapLimit.SpecifiedOut -> buy( + assetIdIn = assetIdIn, + assetIdOut = assetIdOut, + amountOut = limit.amountOut, + maxSellAmount = limit.amountInMax + ) + } + } + + override fun extractReceivedAmount(events: List): Balance { + val swapExecutedEvent = events.findEvent(Modules.OMNIPOOL, "BuyExecuted") + ?: events.findEventOrThrow(Modules.OMNIPOOL, "SellExecuted") + + val amountOut = swapExecutedEvent.arguments[AMOUNT_OUT_POSITION] + return bindNumber(amountOut) + } + + private fun ExtrinsicBuilder.sell( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amountIn: Balance, + minBuyAmount: Balance + ) { + call( + moduleName = Modules.OMNIPOOL, + callName = "sell", + arguments = mapOf( + "asset_in" to assetIdIn, + "asset_out" to assetIdOut, + "amount" to amountIn, + "min_buy_amount" to minBuyAmount + ) + ) + } + + private fun ExtrinsicBuilder.buy( + assetIdIn: HydraDxAssetId, + assetIdOut: HydraDxAssetId, + amountOut: Balance, + maxSellAmount: Balance + ) { + call( + moduleName = Modules.OMNIPOOL, + callName = "buy", + arguments = mapOf( + "asset_out" to assetIdOut, + "asset_in" to assetIdIn, + "amount" to amountOut, + "max_sell_amount" to maxSellAmount + ) + ) + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/referrals/HydraDxNovaReferral.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/referrals/HydraDxNovaReferral.kt new file mode 100644 index 0000000..08ba26d --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/referrals/HydraDxNovaReferral.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals + +interface HydraDxNovaReferral { + + fun getNovaReferralCode(): String +} + +class RealHydraDxNovaReferral : HydraDxNovaReferral { + + override fun getNovaReferralCode(): String { + return "NOVA" + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/referrals/ReferralsApi.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/referrals/ReferralsApi.kt new file mode 100644 index 0000000..bbb7d42 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/referrals/ReferralsApi.kt @@ -0,0 +1,27 @@ +@file:Suppress("RedundantUnitExpression") + +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.utils.referralsOrNull +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class ReferralsApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.referralsOrNull: ReferralsApi? + get() = referralsOrNull()?.let(::ReferralsApi) + +context(StorageQueryContext) +val ReferralsApi.linkedAccounts: QueryableStorageEntry1 + get() = storage1( + name = "LinkedAccounts", + binding = { decoded, _ -> bindAccountId(decoded) }, + ) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/stableswap/StableSwapSource.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/stableswap/StableSwapSource.kt new file mode 100644 index 0000000..259b06f --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/stableswap/StableSwapSource.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.stableswap + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.StableSwapQuotingSource +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.StableSwapQuotingSourceFactory +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.network.toChainAssetOrThrow +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSourceEdge +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import kotlinx.coroutines.flow.Flow + +class StableSwapSourceFactory( + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, +) : HydraDxSwapSource.Factory { + + override fun create(delegate: StableSwapQuotingSource): HydraDxSwapSource { + return StableSwapSource( + delegate = delegate, + hydraDxAssetIdConverter = hydraDxAssetIdConverter + ) + } + + override val identifier: String = StableSwapQuotingSourceFactory.ID +} + +private class StableSwapSource( + private val delegate: StableSwapQuotingSource, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, +) : HydraDxSwapSource, Identifiable by delegate { + + private val chain = delegate.chain + + override suspend fun sync() { + return delegate.sync() + } + + override suspend fun availableSwapDirections(): Collection { + return delegate.availableSwapDirections().map(::StableSwapEdge) + } + + override suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return delegate.runSubscriptions(userAccountId, subscriptionBuilder) + } + + inner class StableSwapEdge( + private val delegate: StableSwapQuotingSource.Edge + ) : HydraDxSourceEdge, QuotableEdge by delegate { + + override val standaloneSwap = null + + override suspend fun debugLabel(): String { + val poolAsset = hydraDxAssetIdConverter.toChainAssetOrThrow(chain, delegate.poolId) + return "StableSwap.${poolAsset.symbol}" + } + + override fun routerPoolArgument(): DictEnum.Entry<*> { + return DictEnum.Entry("Stableswap", delegate.poolId) + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/xyk/XYKSwapSource.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/xyk/XYKSwapSource.kt new file mode 100644 index 0000000..7e0667c --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/assetExchange/hydraDx/xyk/XYKSwapSource.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.xyk + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.XYKSwapQuotingSource +import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.XYKSwapQuotingSourceFactory +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSourceEdge +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import kotlinx.coroutines.flow.Flow + +class XYKSwapSourceFactory : HydraDxSwapSource.Factory { + + override val identifier: String = XYKSwapQuotingSourceFactory.ID + + override fun create(delegate: XYKSwapQuotingSource): HydraDxSwapSource { + return XYKSwapSource(delegate) + } +} + +private class XYKSwapSource( + private val delegate: XYKSwapQuotingSource, +) : HydraDxSwapSource, Identifiable by delegate { + + override suspend fun sync() { + return delegate.sync() + } + + override suspend fun availableSwapDirections(): Collection { + return delegate.availableSwapDirections().map(::XYKSwapEdge) + } + + override suspend fun runSubscriptions( + userAccountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return delegate.runSubscriptions(userAccountId, subscriptionBuilder) + } + + inner class XYKSwapEdge( + private val delegate: XYKSwapQuotingSource.Edge + ) : HydraDxSourceEdge, QuotableEdge by delegate { + + override fun routerPoolArgument(): DictEnum.Entry<*> { + return DictEnum.Entry("XYK", null) + } + + override val standaloneSwap = null + + override suspend fun debugLabel(): String { + return "XYK" + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/network/blockhain/updaters/SwapUpdateSystemFactory.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/network/blockhain/updaters/SwapUpdateSystemFactory.kt new file mode 100644 index 0000000..dec6f24 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/network/blockhain/updaters/SwapUpdateSystemFactory.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_swap_impl.data.network.blockhain.updaters + +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_account_api.domain.updaters.ChainUpdateScope +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsState +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.feature_wallet_api.domain.updater.AccountInfoUpdaterFactory +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.network.updaters.ConstantSingleChainUpdateSystem +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.SupportedAssetOption +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull + +class SwapUpdateSystemFactory( + private val swapSettingsStateProvider: SwapSettingsStateProvider, + private val chainRegistry: ChainRegistry, + private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + private val accountInfoUpdaterFactory: AccountInfoUpdaterFactory +) { + + suspend fun create(chainFlow: Flow, coroutineScope: CoroutineScope): UpdateSystem { + val swapSettingsState = swapSettingsStateProvider.getSwapSettingsState(coroutineScope) + val sharedStateAdapter = SwapSharedStateAdapter(swapSettingsState, chainRegistry, coroutineScope) + + val updaters = listOf( + accountInfoUpdaterFactory.create(ChainUpdateScope(chainFlow), sharedStateAdapter) + ) + + return ConstantSingleChainUpdateSystem( + chainRegistry = chainRegistry, + singleAssetSharedState = sharedStateAdapter, + storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory, + updaters = updaters + ) + } +} + +/** + * Adapter wrapper to be able to use SwapSettingsState with UpdateSystem + */ +private class SwapSharedStateAdapter( + private val swapSettingsState: SwapSettingsState, + private val chainRegistry: ChainRegistry, + private val coroutineScope: CoroutineScope, +) : SelectedAssetOptionSharedState, CoroutineScope by coroutineScope { + + override val selectedOption: Flow> = swapSettingsState.selectedOption + .mapNotNull { it.assetIn } + .distinctUntilChangedBy { it.fullId } + .map { asset -> + val chain = chainRegistry.getChain(asset.chainId) + + SupportedAssetOption(ChainWithAsset(chain, asset)) + } + .shareInBackground() +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/repository/SwapTransactionHistoryRepository.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/repository/SwapTransactionHistoryRepository.kt new file mode 100644 index 0000000..9e97f19 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/data/repository/SwapTransactionHistoryRepository.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_swap_impl.data.repository + +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal.AssetWithAmount +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFeeArgs +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.localId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface SwapTransactionHistoryRepository { + + suspend fun insertPendingSwap( + chainAsset: Chain.Asset, + swapArgs: SwapFeeArgs, + fee: SwapFee, + txSubmission: ExtrinsicSubmission + ) +} + +class RealSwapTransactionHistoryRepository( + private val operationDao: OperationDao, + private val chainRegistry: ChainRegistry, +) : SwapTransactionHistoryRepository { + + override suspend fun insertPendingSwap( + chainAsset: Chain.Asset, + swapArgs: SwapFeeArgs, + fee: SwapFee, + txSubmission: ExtrinsicSubmission + ) { + // TODO swap history +// val chain = chainRegistry.getChain(chainAsset.chainId) +// +// val localOperation = with(swapArgs) { +// OperationLocal.manualSwap( +// hash = txSubmission.hash, +// originAddress = chain.addressOf(txSubmission.submissionOrigin.requestedOrigin), +// assetId = chainAsset.localId, +// // Insert fee regardless of who actually paid it +// fee = feeAsset.withAmountLocal(fee.networkFee.amount), +// amountIn = assetIn.withAmountLocal(swapLimit.expectedAmountIn), +// amountOut = assetOut.withAmountLocal(swapLimit.expectedAmountOut), +// status = OperationBaseLocal.Status.PENDING, +// source = OperationBaseLocal.Source.APP +// ) +// } +// +// operationDao.insert(localOperation) + } + + private fun Chain.Asset.withAmountLocal(amount: Balance): AssetWithAmount { + return AssetWithAmount( + assetId = localId, + amount = amount + ) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureComponent.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureComponent.kt new file mode 100644 index 0000000..866c6a5 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureComponent.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_swap_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.confirmation.di.SwapConfirmationComponent +import io.novafoundation.nova.feature_swap_impl.presentation.execution.di.SwapExecutionComponent +import io.novafoundation.nova.feature_swap_impl.presentation.fee.di.SwapFeeComponent +import io.novafoundation.nova.feature_swap_impl.presentation.main.di.SwapMainSettingsComponent +import io.novafoundation.nova.feature_swap_impl.presentation.options.di.SwapOptionsComponent +import io.novafoundation.nova.feature_swap_impl.presentation.route.di.SwapRouteComponent +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + SwapFeatureDependencies::class, + ], + modules = [ + SwapFeatureModule::class, + ] +) +@FeatureScope +interface SwapFeatureComponent : SwapFeatureApi { + + fun swapMainSettings(): SwapMainSettingsComponent.Factory + + fun swapConfirmation(): SwapConfirmationComponent.Factory + + fun swapOptions(): SwapOptionsComponent.Factory + + fun swapRoute(): SwapRouteComponent.Factory + + fun swapFee(): SwapFeeComponent.Factory + + fun swapExecution(): SwapExecutionComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + deps: SwapFeatureDependencies, + @BindsInstance router: SwapRouter, + ): SwapFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + WalletFeatureApi::class, + AccountFeatureApi::class, + BuyFeatureApi::class, + DbApi::class, + SwapCoreApi::class, + XcmFeatureApi::class, + ] + ) + interface SwapFeatureDependenciesComponent : SwapFeatureDependencies +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureDependencies.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureDependencies.kt new file mode 100644 index 0000000..96f324c --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureDependencies.kt @@ -0,0 +1,173 @@ +package io.novafoundation.nova.feature_swap_impl.di + +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.common.view.input.chooser.ListChooserMixin +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector +import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin +import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +interface SwapFeatureDependencies { + + val amountFormatter: AmountFormatter + + val validationExecutor: ValidationExecutor + + val preferences: Preferences + + val walletRepository: WalletRepository + + val chainRegistry: ChainRegistry + + val imageLoader: ImageLoader + + val addressIconGenerator: AddressIconGenerator + + val resourceManager: ResourceManager + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val tokenRepository: TokenRepository + + val accountRepository: AccountRepository + + val selectedAccountUseCase: SelectedAccountUseCase + + val storageCache: StorageCache + + val externalAccountActions: ExternalActions.Presentation + + val amountMixinFactory: AmountChooserMixin.Factory + + val extrinsicServiceFactory: ExtrinsicService.Factory + + val resourceHintsMixinFactory: ResourcesHintsMixinFactory + + val walletUiUseCase: WalletUiUseCase + + val computationalCache: ComputationalCache + + val networkApiCreator: NetworkApiCreator + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageDataSource(): StorageDataSource + + val onChainIdentityRepository: OnChainIdentityRepository + + val listChooserMixinFactory: ListChooserMixin.Factory + + val identityMixinFactory: IdentityMixin.Factory + + val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory + + val hydraDxAssetIdConverter: HydraDxAssetIdConverter + + val hydraDxQuotingFactory: HydraDxQuoting.Factory + + val hydrationPriceConversionFallback: HydrationPriceConversionFallback + + val runtimeCallsApi: MultiChainRuntimeCallsApi + + val assetUseCase: ArbitraryAssetUseCase + + val assetSourceRegistry: AssetSourceRegistry + + val chainStateRepository: ChainStateRepository + + val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher + + val crossChainTransfersRepository: CrossChainTransfersRepository + + val buyTokenRegistry: TradeTokenRegistry + + val tradeMixinFactory: TradeMixin.Factory + + val crossChainTransfersUseCase: CrossChainTransfersUseCase + + val operationDao: OperationDao + + val multiLocationConverterFactory: MultiLocationConverterFactory + + val gson: Gson + + val customFeeCapabilityFacade: CustomFeeCapabilityFacade + + val quoterFactory: PathQuoter.Factory + + val hydrationFeeInjector: HydrationFeeInjector + + val defaultFeePaymentRegistry: FeePaymentProviderRegistry + + val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory + + val assetIconProvider: AssetIconProvider + + val assetsValidationContextFactory: AssetsValidationContext.Factory + + val xcmVersionDetector: XcmVersionDetector + + val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + + val signerProvider: SignerProvider + + val accountInfoRepository: AccountInfoRepository + + val enoughAmountValidatorFactory: EnoughAmountValidatorFactory + + val getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory + + val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher + + fun maxActionProviderFactory(): MaxActionProviderFactory +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureHolder.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureHolder.kt new file mode 100644 index 0000000..0d28452 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureHolder.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_swap_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi +import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class SwapFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: SwapRouter, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val accountFeatureDependencies = DaggerSwapFeatureComponent_SwapFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .swapCoreApi(getFeature(SwapCoreApi::class.java)) + .walletFeatureApi(getFeature(WalletFeatureApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .buyFeatureApi(getFeature(BuyFeatureApi::class.java)) + .xcmFeatureApi(getFeature(XcmFeatureApi::class.java)) + .build() + + return DaggerSwapFeatureComponent.factory() + .create(accountFeatureDependencies, router) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureModule.kt new file mode 100644 index 0000000..35434e9 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/SwapFeatureModule.kt @@ -0,0 +1,248 @@ +package io.novafoundation.nova.feature_swap_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Fraction.Companion.percents +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.assetConversion.AssetConversionExchangeFactory +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.crossChain.CrossChainTransferAssetExchangeFactory +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxExchangeFactory +import io.novafoundation.nova.feature_swap_impl.data.network.blockhain.updaters.SwapUpdateSystemFactory +import io.novafoundation.nova.feature_swap_impl.data.repository.RealSwapTransactionHistoryRepository +import io.novafoundation.nova.feature_swap_impl.data.repository.SwapTransactionHistoryRepository +import io.novafoundation.nova.feature_swap_impl.di.exchanges.AssetConversionExchangeModule +import io.novafoundation.nova.feature_swap_impl.di.exchanges.CrossChainTransferExchangeModule +import io.novafoundation.nova.feature_swap_impl.di.exchanges.HydraDxExchangeModule +import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase +import io.novafoundation.nova.feature_swap_impl.domain.RealAssetInAdditionalSwapDeductionUseCase +import io.novafoundation.nova.feature_swap_impl.domain.interactor.RealSwapAvailabilityInteractor +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds +import io.novafoundation.nova.feature_swap_impl.domain.swap.RealSwapService +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.CanReceiveAssetOutValidationFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.PriceImpactFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.RealPriceImpactFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.RealSwapRateFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.details.RealSwapConfirmationDetailsFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.navigation.RealSwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.RealSwapRouteFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.RealSwapSettingsStateProvider +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.RealSwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.updater.AccountInfoUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.repository.ChainStateRepository + +@Module(includes = [HydraDxExchangeModule::class, AssetConversionExchangeModule::class, CrossChainTransferExchangeModule::class]) +class SwapFeatureModule { + + @FeatureScope + @Provides + fun provideSwapService( + assetConversionFactory: AssetConversionExchangeFactory, + hydraDxExchangeFactory: HydraDxExchangeFactory, + crossChainTransferAssetExchangeFactory: CrossChainTransferAssetExchangeFactory, + computationalCache: ComputationalCache, + chainRegistry: ChainRegistry, + quoterFactory: PathQuoter.Factory, + extrinsicServiceFactory: ExtrinsicService.Factory, + defaultFeePaymentRegistry: FeePaymentProviderRegistry, + tokenRepository: TokenRepository, + accountRepository: AccountRepository, + assetSourceRegistry: AssetSourceRegistry, + chainStateRepository: ChainStateRepository, + signerProvider: SignerProvider + ): SwapService { + return RealSwapService( + assetConversionFactory = assetConversionFactory, + hydraDxExchangeFactory = hydraDxExchangeFactory, + crossChainTransferFactory = crossChainTransferAssetExchangeFactory, + computationalCache = computationalCache, + chainRegistry = chainRegistry, + quoterFactory = quoterFactory, + extrinsicServiceFactory = extrinsicServiceFactory, + defaultFeePaymentProviderRegistry = defaultFeePaymentRegistry, + tokenRepository = tokenRepository, + assetSourceRegistry = assetSourceRegistry, + accountRepository = accountRepository, + chainStateRepository = chainStateRepository, + signerProvider = signerProvider + ) + } + + @Provides + @FeatureScope + fun provideSwapAvailabilityInteractor(chainRegistry: ChainRegistry, swapService: SwapService): SwapAvailabilityInteractor { + return RealSwapAvailabilityInteractor(chainRegistry, swapService) + } + + @Provides + @FeatureScope + fun provideSwapTransactionHistoryRepository( + operationDao: OperationDao, + chainRegistry: ChainRegistry, + ): SwapTransactionHistoryRepository { + return RealSwapTransactionHistoryRepository(operationDao, chainRegistry) + } + + @Provides + @FeatureScope + fun provideSwapInteractor( + priceImpactThresholds: PriceImpactThresholds, + swapService: SwapService, + tokenRepository: TokenRepository, + swapUpdateSystemFactory: SwapUpdateSystemFactory, + assetsValidationContextFactory: AssetsValidationContext.Factory, + canReceiveAssetOutValidationFactory: CanReceiveAssetOutValidationFactory, + ): SwapInteractor { + return SwapInteractor( + priceImpactThresholds = priceImpactThresholds, + swapService = swapService, + canReceiveAssetOutValidationFactory = canReceiveAssetOutValidationFactory, + swapUpdateSystemFactory = swapUpdateSystemFactory, + assetsValidationContextFactory = assetsValidationContextFactory, + tokenRepository = tokenRepository + ) + } + + @Provides + @FeatureScope + fun providePriceImpactThresholds() = PriceImpactThresholds( + lowPriceImpact = 1.percents, + mediumPriceImpact = 5.percents, + highPriceImpact = 15.percents + ) + + @Provides + @FeatureScope + fun providePriceImpactFormatter( + priceImpactThresholds: PriceImpactThresholds, + resourceManager: ResourceManager + ): PriceImpactFormatter { + return RealPriceImpactFormatter(priceImpactThresholds, resourceManager) + } + + @Provides + @FeatureScope + fun provideSwapRateFormatter(): SwapRateFormatter { + return RealSwapRateFormatter() + } + + @Provides + @FeatureScope + fun provideSlippageAlertMixinFactory(resourceManager: ResourceManager): SlippageAlertMixinFactory { + return SlippageAlertMixinFactory(resourceManager) + } + + @Provides + @FeatureScope + fun provideSwapSettingsStateProvider( + computationalCache: ComputationalCache, + ): SwapSettingsStateProvider { + return RealSwapSettingsStateProvider(computationalCache) + } + + @Provides + @FeatureScope + fun provideAccountInfoUpdaterFactory( + storageCache: StorageCache, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry + ): AccountInfoUpdaterFactory { + return AccountInfoUpdaterFactory( + storageCache, + accountRepository, + chainRegistry + ) + } + + @Provides + @FeatureScope + fun provideSwapUpdateSystemFactory( + swapSettingsStateProvider: SwapSettingsStateProvider, + chainRegistry: ChainRegistry, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + accountInfoUpdaterFactory: AccountInfoUpdaterFactory + ): SwapUpdateSystemFactory { + return SwapUpdateSystemFactory( + swapSettingsStateProvider = swapSettingsStateProvider, + chainRegistry = chainRegistry, + storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory, + accountInfoUpdaterFactory = accountInfoUpdaterFactory + ) + } + + @Provides + @FeatureScope + fun provideSwapQuoteStoreProvider(computationalCache: ComputationalCache): SwapStateStoreProvider { + return RealSwapStateStoreProvider(computationalCache) + } + + @Provides + @FeatureScope + fun provideSwapRouteFormatter(chainRegistry: ChainRegistry): SwapRouteFormatter { + return RealSwapRouteFormatter(chainRegistry) + } + + @Provides + @FeatureScope + fun provideConfirmationDetailsFormatter( + chainRegistry: ChainRegistry, + assetIconProvider: AssetIconProvider, + tokenRepository: TokenRepository, + swapRouteFormatter: SwapRouteFormatter, + swapRateFormatter: SwapRateFormatter, + priceImpactFormatter: PriceImpactFormatter, + resourceManager: ResourceManager, + amountFormatter: AmountFormatter + ): SwapConfirmationDetailsFormatter { + return RealSwapConfirmationDetailsFormatter( + chainRegistry = chainRegistry, + assetIconProvider = assetIconProvider, + tokenRepository = tokenRepository, + swapRouteFormatter = swapRouteFormatter, + swapRateFormatter = swapRateFormatter, + priceImpactFormatter = priceImpactFormatter, + resourceManager = resourceManager, + amountFormatter = amountFormatter + ) + } + + @Provides + @FeatureScope + fun provideAssetInAdditionalSwapDeductionUseCase( + assetSourceRegistry: AssetSourceRegistry, + chainRegistry: ChainRegistry + ): AssetInAdditionalSwapDeductionUseCase { + return RealAssetInAdditionalSwapDeductionUseCase(assetSourceRegistry, chainRegistry) + } + + @Provides + @FeatureScope + fun provideSwapFlowScopeAggregator(): SwapFlowScopeAggregator { + return RealSwapFlowScopeAggregator() + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/exchanges/AssetConversionExchangeModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/exchanges/AssetConversionExchangeModule.kt new file mode 100644 index 0000000..bfc1b61 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/exchanges/AssetConversionExchangeModule.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_swap_impl.di.exchanges + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.assetConversion.AssetConversionExchangeFactory +import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class AssetConversionExchangeModule { + + @Provides + @FeatureScope + fun provideAssetConversionExchangeFactory( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + runtimeCallsApi: MultiChainRuntimeCallsApi, + multiLocationConverterFactory: MultiLocationConverterFactory, + chainStateRepository: ChainStateRepository, + deductionUseCase: AssetInAdditionalSwapDeductionUseCase, + xcmVersionDetector: XcmVersionDetector + ): AssetConversionExchangeFactory { + return AssetConversionExchangeFactory( + chainStateRepository = chainStateRepository, + remoteStorageSource = remoteStorageSource, + runtimeCallsApi = runtimeCallsApi, + multiLocationConverterFactory = multiLocationConverterFactory, + deductionUseCase = deductionUseCase, + xcmVersionDetector = xcmVersionDetector + ) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/exchanges/CrossChainTransferExchangeModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/exchanges/CrossChainTransferExchangeModule.kt new file mode 100644 index 0000000..69f6ad2 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/exchanges/CrossChainTransferExchangeModule.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_swap_impl.di.exchanges + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.crossChain.CrossChainTransferAssetExchangeFactory +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module +class CrossChainTransferExchangeModule { + + @Provides + @FeatureScope + fun provideAssetConversionExchangeFactory( + crossChainTransfersUseCase: CrossChainTransfersUseCase, + chainRegistry: ChainRegistry, + accountRepository: AccountRepository + ): CrossChainTransferAssetExchangeFactory { + return CrossChainTransferAssetExchangeFactory( + crossChainTransfersUseCase = crossChainTransfersUseCase, + chainRegistry = chainRegistry, + accountRepository = accountRepository + ) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/exchanges/HydraDxExchangeModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/exchanges/HydraDxExchangeModule.kt new file mode 100644 index 0000000..1e26fa4 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/di/exchanges/HydraDxExchangeModule.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.feature_swap_impl.di.exchanges + +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher +import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxExchangeFactory +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.aave.AaveSwapSourceFactory +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.omnipool.OmniPoolSwapSourceFactory +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.HydraDxNovaReferral +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.RealHydraDxNovaReferral +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.stableswap.StableSwapSourceFactory +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.xyk.XYKSwapSourceFactory +import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class HydraDxExchangeModule { + + @Provides + @FeatureScope + fun provideHydraDxNovaReferral(): HydraDxNovaReferral { + return RealHydraDxNovaReferral() + } + + @Provides + @IntoSet + fun provideOmniPoolSourceFactory(): HydraDxSwapSource.Factory<*> { + return OmniPoolSwapSourceFactory() + } + + @Provides + @IntoSet + fun provideAaveSourceFactory(real: AaveSwapSourceFactory): HydraDxSwapSource.Factory<*> { + return real + } + + @Provides + @IntoSet + fun provideStableSwapSourceFactory( + hydraDxAssetIdConverter: HydraDxAssetIdConverter, + ): HydraDxSwapSource.Factory<*> { + return StableSwapSourceFactory(hydraDxAssetIdConverter) + } + + @Provides + @IntoSet + fun provideXykSwapSourceFactory(): HydraDxSwapSource.Factory<*> { + return XYKSwapSourceFactory() + } + + @Provides + @FeatureScope + fun provideHydraDxExchangeFactory( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + hydraDxAssetIdConverter: HydraDxAssetIdConverter, + hydraDxNovaReferral: HydraDxNovaReferral, + swapSourceFactories: Set<@JvmSuppressWildcards HydraDxSwapSource.Factory<*>>, + quotingFactory: HydraDxQuoting.Factory, + hydrationFeeInjector: HydrationFeeInjector, + chainStateRepository: ChainStateRepository, + swapDeductionUseCase: AssetInAdditionalSwapDeductionUseCase, + hydrationPriceConversionFallback: HydrationPriceConversionFallback, + hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher, + ): HydraDxExchangeFactory { + return HydraDxExchangeFactory( + remoteStorageSource = remoteStorageSource, + sharedRequestsBuilderFactory = sharedRequestsBuilderFactory, + hydraDxAssetIdConverter = hydraDxAssetIdConverter, + hydraDxNovaReferral = hydraDxNovaReferral, + swapSourceFactories = swapSourceFactories, + quotingFactory = quotingFactory, + hydrationFeeInjector = hydrationFeeInjector, + chainStateRepository = chainStateRepository, + swapDeductionUseCase = swapDeductionUseCase, + hydrationPriceConversionFallback = hydrationPriceConversionFallback, + hydrationAcceptedFeeCurrenciesFetcher = hydrationAcceptedFeeCurrenciesFetcher + ) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/AssetInAdditionalSwapDeductionUseCase.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/AssetInAdditionalSwapDeductionUseCase.kt new file mode 100644 index 0000000..b3e7991 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/AssetInAdditionalSwapDeductionUseCase.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_swap_impl.domain + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDepositInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.isSelfSufficientAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface AssetInAdditionalSwapDeductionUseCase { + + suspend fun invoke(assetIn: Chain.Asset, assetOut: Chain.Asset): Balance +} + +class RealAssetInAdditionalSwapDeductionUseCase( + private val assetSourceRegistry: AssetSourceRegistry, + private val chainRegistry: ChainRegistry +) : AssetInAdditionalSwapDeductionUseCase { + + override suspend fun invoke(assetIn: Chain.Asset, assetOut: Chain.Asset): Balance { + val assetInBalanceCanDropBelowEd = assetSourceRegistry.sourceFor(assetIn) + .transfers + .totalCanDropBelowMinimumBalance(assetIn) + + val sameChain = assetIn.chainId == assetOut.chainId + + val assetOutCanProvideSufficiency = sameChain && assetSourceRegistry.isSelfSufficientAsset(assetOut) + + val canDustAssetIn = assetInBalanceCanDropBelowEd || assetOutCanProvideSufficiency + val shouldKeepEdForAssetIn = !canDustAssetIn + + return if (shouldKeepEdForAssetIn) { + assetSourceRegistry.existentialDepositInPlanks(assetIn) + } else { + Balance.ZERO + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/interactor/RealSwapAvailabilityInteractor.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/interactor/RealSwapAvailabilityInteractor.kt new file mode 100644 index 0000000..fc967b3 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/interactor/RealSwapAvailabilityInteractor.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_swap_impl.domain.interactor + +import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.runtime.ext.isSwapSupported +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.enabledChainsFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RealSwapAvailabilityInteractor( + private val chainRegistry: ChainRegistry, + private val swapService: SwapService +) : SwapAvailabilityInteractor { + + override suspend fun sync(coroutineScope: CoroutineScope) { + swapService.sync(coroutineScope) + } + + override suspend fun warmUpCommonlyUsedChains(computationScope: CoroutineScope) { + swapService.warmUpCommonChains(computationScope) + } + + override fun anySwapAvailableFlow(): Flow { + return chainRegistry.enabledChainsFlow().map { it.any(Chain::isSwapSupported) } + } + + override suspend fun swapAvailableFlow(asset: Chain.Asset, coroutineScope: CoroutineScope): Flow { + return swapService.hasAvailableSwapDirections(asset, coroutineScope) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/interactor/SwapInteractor.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/interactor/SwapInteractor.kt new file mode 100644 index 0000000..f8fad55 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/interactor/SwapInteractor.kt @@ -0,0 +1,180 @@ +package io.novafoundation.nova.feature_swap_impl.domain.interactor + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger +import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFeeArgs +import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgress +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs +import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult +import io.novafoundation.nova.feature_swap_api.domain.model.allBasicFees +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_swap_impl.data.network.blockhain.updaters.SwapUpdateSystemFactory +import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystem +import io.novafoundation.nova.feature_swap_impl.domain.validation.availableSlippage +import io.novafoundation.nova.feature_swap_impl.domain.validation.canPayAllFees +import io.novafoundation.nova.feature_swap_impl.domain.validation.enoughAssetInToPayForSwap +import io.novafoundation.nova.feature_swap_impl.domain.validation.enoughAssetInToPayForSwapAndFee +import io.novafoundation.nova.feature_swap_impl.domain.validation.enoughLiquidity +import io.novafoundation.nova.feature_swap_impl.domain.validation.positiveAmountIn +import io.novafoundation.nova.feature_swap_impl.domain.validation.positiveAmountOut +import io.novafoundation.nova.feature_swap_impl.domain.validation.priceImpactValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.rateNotExceedSlippage +import io.novafoundation.nova.feature_swap_impl.domain.validation.sufficientAmountOutToStayAboveED +import io.novafoundation.nova.feature_swap_impl.domain.validation.sufficientBalanceConsideringConsumersValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.swapFeeSufficientBalance +import io.novafoundation.nova.feature_swap_impl.domain.validation.swapSmallRemainingBalance +import io.novafoundation.nova.feature_swap_impl.domain.validation.utils.SharedQuoteValidationRetriever +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.CanReceiveAssetOutValidationFactory +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.intermediateReceivesMeetEDValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.sufficientBalanceConsideringNonSufficientAssetsValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.sufficientNativeBalanceToPayFeeConsideringED +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.FiatAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import java.math.BigDecimal + +class SwapInteractor( + private val priceImpactThresholds: PriceImpactThresholds, + private val swapService: SwapService, + private val tokenRepository: TokenRepository, + private val swapUpdateSystemFactory: SwapUpdateSystemFactory, + private val assetsValidationContextFactory: AssetsValidationContext.Factory, + private val canReceiveAssetOutValidationFactory: CanReceiveAssetOutValidationFactory, +) { + + suspend fun getAllFeeTokens(swapFee: SwapFee): Map { + val basicFees = swapFee.allBasicFees() + val chainAssets = basicFees.map { it.asset } + + return tokenRepository.getTokens(chainAssets) + } + + suspend fun calculateSegmentFiatPrices(swapFee: SwapFee): List { + return withContext(Dispatchers.Default) { + val basicFeesBySegment = swapFee.segments.map { it.fee.allBasicFees() } + val chainAssets = basicFeesBySegment.flatMap { segmentFees -> segmentFees.map { it.asset } } + + val tokens = tokenRepository.getTokens(chainAssets) + val currency = tokens.values.first().currency + + basicFeesBySegment.map { segmentBasicFees -> + val totalSegmentFees = segmentBasicFees.sumOf { basicFee -> + val token = tokens[basicFee.asset.fullId] + token?.planksToFiat(basicFee.amount) ?: BigDecimal.ZERO + } + + FiatAmount(currency, totalSegmentFees) + } + } + } + + suspend fun calculateTotalFiatPrice(swapFee: SwapFee): FiatAmount { + return withContext(Dispatchers.Default) { + val basicFees = swapFee.allBasicFees() + val chainAssets = basicFees.map { it.asset } + val tokens = tokenRepository.getTokens(chainAssets) + + val totalFiat = basicFees.sumOf { basicFee -> + val token = tokens[basicFee.asset.fullId] ?: return@sumOf BigDecimal.ZERO + token.planksToFiat(basicFee.amount) + } + + FiatAmount( + currency = tokens.values.first().currency, + price = totalFiat + ) + } + } + + suspend fun sync(coroutineScope: CoroutineScope) { + swapService.sync(coroutineScope) + } + + suspend fun getUpdateSystem(chainFlow: Flow, coroutineScope: CoroutineScope): UpdateSystem { + return swapUpdateSystemFactory.create(chainFlow, coroutineScope) + } + + suspend fun quote(quoteArgs: SwapQuoteArgs, computationalScope: CoroutineScope): Result { + return swapService.quote(quoteArgs, computationalScope) + } + + suspend fun executeSwap(calculatedFee: SwapFee): Flow = swapService.swap(calculatedFee) + + suspend fun submitFirstSwapStep(calculatedFee: SwapFee): Result = swapService.submitFirstSwapStep(calculatedFee) + + suspend fun warmUpSwapCommonlyUsedChains(computationalScope: CoroutineScope) { + swapService.warmUpCommonChains(computationalScope) + } + + suspend fun estimateFee(executeArgs: SwapFeeArgs): SwapFee { + return swapService.estimateFee(executeArgs) + } + + suspend fun slippageConfig(chainId: ChainId): SlippageConfig { + return swapService.defaultSlippageConfig(chainId) + } + + fun runSubscriptions(metaAccount: MetaAccount): Flow { + return swapService.runSubscriptions(metaAccount) + } + + suspend fun isDeepSwapAvailable(): Boolean { + return swapService.isDeepSwapAllowed() + } + + fun validationSystem(): SwapValidationSystem { + val assetsValidationContext = assetsValidationContextFactory.create() + val sharedQuoteValidationRetriever = SharedQuoteValidationRetriever(swapService, assetsValidationContext) + + return ValidationSystem { + positiveAmountIn() + + positiveAmountOut() + + canPayAllFees(assetsValidationContext) + + enoughAssetInToPayForSwap(assetsValidationContext) + + enoughAssetInToPayForSwapAndFee(assetsValidationContext) + + sufficientNativeBalanceToPayFeeConsideringED(assetsValidationContext) + + canReceiveAssetOutValidationFactory.canReceiveAssetOut(assetsValidationContext) + + availableSlippage(swapService) + + enoughLiquidity(sharedQuoteValidationRetriever) + + rateNotExceedSlippage(sharedQuoteValidationRetriever) + + intermediateReceivesMeetEDValidation(assetsValidationContext) + + swapFeeSufficientBalance(assetsValidationContext) + + sufficientBalanceConsideringNonSufficientAssetsValidation(assetsValidationContext) + + sufficientBalanceConsideringConsumersValidation(assetsValidationContext) + + swapSmallRemainingBalance(assetsValidationContext) + + sufficientAmountOutToStayAboveED(assetsValidationContext) + + priceImpactValidation(priceImpactThresholds) + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/swap/PriceImpactThresholds.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/swap/PriceImpactThresholds.kt new file mode 100644 index 0000000..ceb3a37 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/swap/PriceImpactThresholds.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_swap_impl.domain.swap + +import io.novafoundation.nova.common.utils.Fraction + +class PriceImpactThresholds( + val lowPriceImpact: Fraction, + val mediumPriceImpact: Fraction, + val highPriceImpact: Fraction +) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/swap/RealSwapService.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/swap/RealSwapService.kt new file mode 100644 index 0000000..4560aec --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/swap/RealSwapService.kt @@ -0,0 +1,991 @@ +package io.novafoundation.nova.feature_swap_impl.domain.swap + +import android.util.Log +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.memory.SharedFlowCache +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.fractions +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.forEachAsync +import io.novafoundation.nova.common.utils.graph.EdgeVisitFilter +import io.novafoundation.nova.common.utils.graph.Graph +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.common.utils.graph.allEdges +import io.novafoundation.nova.common.utils.graph.create +import io.novafoundation.nova.common.utils.graph.findAllPossibleDestinations +import io.novafoundation.nova.common.utils.graph.hasOutcomingDirections +import io.novafoundation.nova.common.utils.graph.numberOfEdges +import io.novafoundation.nova.common.utils.graph.vertices +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.mapAsync +import io.novafoundation.nova.common.utils.measureExecution +import io.novafoundation.nova.common.utils.mergeIfMultiple +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.withFlowScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProvider +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability +import io.novafoundation.nova.feature_account_api.data.fee.fastLookupCustomFeeCapabilityOrDefault +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFeeBase +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperation +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationPrototype +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationSubmissionArgs +import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger +import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig +import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionCorrection +import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionEstimate +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFeeArgs +import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraph +import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge +import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit +import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgress +import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgressStep +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs +import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult +import io.novafoundation.nova.feature_swap_api.domain.model.UsdConverter +import io.novafoundation.nova.feature_swap_api.domain.model.amountToLeaveOnOriginToPayTxFees +import io.novafoundation.nova.feature_swap_api.domain.model.replaceAmountIn +import io.novafoundation.nova.feature_swap_api.domain.model.totalFeeEnsuringSubmissionAsset +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathFeeEstimator +import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.PathRoughFeeEstimation +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedEdge +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedPath +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.WeightBreakdown +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.firstSegmentQuote +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.firstSegmentQuotedAmount +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.lastSegmentQuote +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.lastSegmentQuotedAmount +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_impl.BuildConfig +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.AssetExchange +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.FeePaymentProviderOverride +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.ParentQuoterArgs +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.SharedSwapSubscriptions +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.assetConversion.AssetConversionExchangeFactory +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.crossChain.CrossChainTransferAssetExchangeFactory +import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxExchangeFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromFiatOrZero +import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.ext.assetConversionSupported +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.hydraDxSupported +import io.novafoundation.nova.runtime.ext.isUtility +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.ext.utilityAssetOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainsById +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chainWithAssetOrNull +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import io.novafoundation.nova.runtime.multiNetwork.enabledChainById +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novasama.substrate_sdk_android.hash.isPositive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.withContext +import java.math.BigDecimal +import java.math.BigInteger +import java.math.MathContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private const val ALL_DIRECTIONS_CACHE = "RealSwapService.ALL_DIRECTIONS" +private const val EXCHANGES_CACHE = "RealSwapService.EXCHANGES" +private const val EXTRINSIC_SERVICE_CACHE = "RealSwapService.ExtrinsicService" +private const val QUOTER_CACHE = "RealSwapService.QUOTER" +private const val NODE_VISIT_FILTER = "RealSwapService.NodeVisitFilter" +private const val SHARED_SUBSCRIPTIONS = "RealSwapService.SharedSubscriptions" + +private val ADDITIONAL_ESTIMATE_BUFFER = 3.seconds + +private val PEZKUWI_CHAIN_IDS = setOf( + Chain.Geneses.PEZKUWI, + Chain.Geneses.PEZKUWI_ASSET_HUB, + Chain.Geneses.PEZKUWI_PEOPLE +) + +internal class RealSwapService( + private val assetConversionFactory: AssetConversionExchangeFactory, + private val hydraDxExchangeFactory: HydraDxExchangeFactory, + private val crossChainTransferFactory: CrossChainTransferAssetExchangeFactory, + private val computationalCache: ComputationalCache, + private val chainRegistry: ChainRegistry, + private val quoterFactory: PathQuoter.Factory, + private val extrinsicServiceFactory: ExtrinsicService.Factory, + private val defaultFeePaymentProviderRegistry: FeePaymentProviderRegistry, + private val assetSourceRegistry: AssetSourceRegistry, + private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, + private val chainStateRepository: ChainStateRepository, + private val signerProvider: SignerProvider, + private val debug: Boolean = BuildConfig.DEBUG +) : SwapService { + + override suspend fun warmUpCommonChains(computationScope: CoroutineScope): Result { + return runCatching { + withContext(Dispatchers.Default) { + // Warm up each chain independently - failures shouldn't affect other chains + warmUpChainSafely(Chain.Geneses.HYDRA_DX, computationScope) + warmUpChainSafely(Chain.Geneses.POLKADOT_ASSET_HUB, computationScope) + warmUpChainSafely(Chain.Geneses.PEZKUWI_ASSET_HUB, computationScope) + } + } + } + + private suspend fun warmUpChainSafely(chainId: ChainId, computationScope: CoroutineScope) { + try { + nodeVisitFilter(computationScope).warmUpChain(chainId) + } catch (e: Exception) { + Log.w("SwapService", "Failed to warm up chain $chainId: ${e.message}") + } + } + + override suspend fun sync(coroutineScope: CoroutineScope) { + exchangeRegistry(coroutineScope) + .allExchanges() + .forEachAsync { it.sync() } + } + + override suspend fun assetsAvailableForSwap( + computationScope: CoroutineScope + ): Flow> { + return directionsGraph(computationScope).map { it.vertices() } + } + + override suspend fun availableSwapDirectionsFor( + asset: Chain.Asset, + computationScope: CoroutineScope + ): Flow> { + return directionsGraph(computationScope).map { + val filter = nodeVisitFilter(computationScope) + measureExecution("findAllPossibleDestinations") { + it.findAllPossibleDestinations(asset.fullId, filter) - asset.fullId + } + } + } + + override suspend fun hasAvailableSwapDirections(asset: Chain.Asset, computationScope: CoroutineScope): Flow { + return directionsGraph(computationScope).map { it.hasOutcomingDirections(asset.fullId) } + } + + override suspend fun quote( + args: SwapQuoteArgs, + computationSharingScope: CoroutineScope + ): Result { + return withContext(Dispatchers.Default) { + runCatching { + quoteInternal(args, computationSharingScope) + }.onFailure { + Log.e("RealSwapService", "Error while quoting", it) + } + } + } + + override suspend fun estimateFee(executeArgs: SwapFeeArgs): SwapFee { + val atomicOperations = executeArgs.constructAtomicOperations() + + val fees = atomicOperations.mapAsync { SwapFee.SwapSegment(it.estimateFee(), it) } + val convertedFees = fees.convertIntermediateSegmentsFeesToAssetIn(executeArgs.assetIn) + + val firstOperation = atomicOperations.first() + + return SwapFee( + segments = fees, + intermediateSegmentFeesInAssetIn = convertedFees, + additionalMaxAmountDeduction = firstOperation.additionalMaxAmountDeduction(), + ).also(::logFee) + } + + override suspend fun swap(calculatedFee: SwapFee): Flow { + val segments = calculatedFee.segments + + val initialCorrection: Result = Result.success(null) + + return flow { + segments.withIndex().fold(initialCorrection) { prevStepCorrection, (index, segment) -> + val (segmentFee, operation) = segment + + prevStepCorrection.flatMap { correction -> + val displayData = operation.constructDisplayData() + val step = SwapProgressStep(index, displayData, operation) + + emit(SwapProgress.StepStarted(step)) + + val newAmountIn = if (correction != null) { + correction.actualReceivedAmount - segmentFee.amountToLeaveOnOriginToPayTxFees() + } else { + val amountIn = operation.estimatedSwapLimit.estimatedAmountIn() + amountIn + calculatedFee.additionalAmountForSwap.amount + } + + // We cannot execute buy for segments after first one since we deal with actualReceivedAmount there + val shouldReplaceBuyWithSell = correction != null + val actualSwapLimit = operation.estimatedSwapLimit.replaceAmountIn(newAmountIn, shouldReplaceBuyWithSell) + val segmentSubmissionArgs = AtomicSwapOperationSubmissionArgs(actualSwapLimit) + + if (debug) Log.d("SwapSubmission", "$displayData with $actualSwapLimit") + + operation.execute(segmentSubmissionArgs).onFailure { + Log.e("SwapSubmission", "Swap failed on stage '$displayData'", it) + + emit(SwapProgress.Failure(it, attemptedStep = step)) + } + } + }.onSuccess { + emit(SwapProgress.Done) + } + } + } + + override suspend fun submitFirstSwapStep(calculatedFee: SwapFee): Result { + val (_, operation) = calculatedFee.segments.firstOrNull() ?: return Result.failure(IllegalStateException("No segments")) + + val amountIn = operation.estimatedSwapLimit.estimatedAmountIn() + calculatedFee.additionalAmountForSwap.amount + val actualSwapLimit = operation.estimatedSwapLimit.replaceAmountIn(amountIn, false) + + val segmentSubmissionArgs = AtomicSwapOperationSubmissionArgs(actualSwapLimit) + + return operation.submit(segmentSubmissionArgs) + } + + private fun SwapLimit.estimatedAmountIn(): Balance { + return when (this) { + is SwapLimit.SpecifiedIn -> amountIn + is SwapLimit.SpecifiedOut -> amountInQuote + } + } + + private suspend fun List.convertIntermediateSegmentsFeesToAssetIn(assetIn: Chain.Asset): FeeBase { + val convertedFees = foldRightIndexed(BigInteger.ZERO) { index, (operationFee, swapOperation), futureFeePlanks -> + val amountInToGetFeesForOut = if (futureFeePlanks.isPositive()) { + swapOperation.requiredAmountInToGetAmountOut(futureFeePlanks) + } else { + BigInteger.ZERO + } + + amountInToGetFeesForOut + if (index != 0) { + // Ensure everything is in the same asset + operationFee.totalFeeEnsuringSubmissionAsset() + } else { + // First segment is not included + BigInteger.ZERO + } + } + + return SubstrateFeeBase(convertedFees, assetIn) + } + + private suspend fun SwapFeeArgs.constructAtomicOperations(): List { + var currentSwapTx: AtomicSwapOperation? = null + val finishedSwapTxs = mutableListOf() + + executionPath.forEachIndexed { index, segmentExecuteArgs -> + val quotedEdge = segmentExecuteArgs.quotedSwapEdge + + val operationArgs = AtomicSwapOperationArgs( + estimatedSwapLimit = SwapLimit(direction, quotedEdge.quotedAmount, slippage, quotedEdge.quote), + feePaymentCurrency = segmentExecuteArgs.quotedSwapEdge.edge.identifySegmentCurrency( + isFirstSegment = index == 0, + firstSegmentFees = firstSegmentFees, + ), + ) + + // Initial case - begin first operation + if (currentSwapTx == null) { + currentSwapTx = quotedEdge.edge.beginOperation(operationArgs) + return@forEachIndexed + } + + // Try to append segment to current swap tx + val maybeAppendedCurrentTx = quotedEdge.edge.appendToOperation(currentSwapTx!!, operationArgs) + + currentSwapTx = if (maybeAppendedCurrentTx == null) { + finishedSwapTxs.add(currentSwapTx!!) + quotedEdge.edge.beginOperation(operationArgs) + } else { + maybeAppendedCurrentTx + } + } + + finishedSwapTxs.add(currentSwapTx!!) + + return finishedSwapTxs + } + + private suspend fun Path>.constructAtomicOperationPrototypes(): List { + var currentSwapTx: AtomicSwapOperationPrototype? = null + val finishedSwapTxs = mutableListOf() + + forEach { quotedEdge -> + // Initial case - begin first operation + if (currentSwapTx == null) { + currentSwapTx = quotedEdge.edge.beginOperationPrototype() + return@forEach + } + + // Try to append segment to current swap tx + val maybeAppendedCurrentTx = quotedEdge.edge.appendToOperationPrototype(currentSwapTx!!) + + currentSwapTx = if (maybeAppendedCurrentTx == null) { + finishedSwapTxs.add(currentSwapTx!!) + quotedEdge.edge.beginOperationPrototype() + } else { + maybeAppendedCurrentTx + } + } + + finishedSwapTxs.add(currentSwapTx!!) + + return finishedSwapTxs + } + + private suspend fun SwapGraphEdge.identifySegmentCurrency( + isFirstSegment: Boolean, + firstSegmentFees: FeePaymentCurrency + ): FeePaymentCurrency { + return if (isFirstSegment) { + firstSegmentFees + } else { + // When executing intermediate segments, always pay in sending asset + chainRegistry.asset(from).toFeePaymentCurrency() + } + } + + private suspend fun quoteInternal( + args: SwapQuoteArgs, + computationSharingScope: CoroutineScope + ): SwapQuote { + val quotedTrade = quoteTrade( + chainAssetIn = args.tokenIn.configuration, + chainAssetOut = args.tokenOut.configuration, + amount = args.amount, + swapDirection = args.swapDirection, + computationSharingScope = computationSharingScope + ) + + val amountIn = quotedTrade.amountIn() + val amountOut = quotedTrade.amountOut() + + val atomicOperationsEstimates = quotedTrade.estimateOperationsMaximumExecutionTime() + + return SwapQuote( + amountIn = args.tokenIn.configuration.withAmount(amountIn), + amountOut = args.tokenOut.configuration.withAmount(amountOut), + priceImpact = args.calculatePriceImpact(amountIn, amountOut), + quotedPath = quotedTrade, + executionEstimate = SwapExecutionEstimate(atomicOperationsEstimates, ADDITIONAL_ESTIMATE_BUFFER), + direction = args.swapDirection, + ) + } + + private suspend fun QuotedTrade.estimateOperationsMaximumExecutionTime(): List { + return path.constructAtomicOperationPrototypes() + .map { it.maximumExecutionTime() } + } + + override suspend fun defaultSlippageConfig(chainId: ChainId): SlippageConfig { + return SlippageConfig.default() + } + + override fun runSubscriptions(metaAccount: MetaAccount): Flow { + return withFlowScope { scope -> + val exchangeRegistry = exchangeRegistry(scope) + + exchangeRegistry.allExchanges() + .map { it.runSubscriptions(metaAccount) } + .mergeIfMultiple() + }.debounce(500.milliseconds) + } + + override suspend fun isDeepSwapAllowed(): Boolean { + val signer = signerProvider.rootSignerFor(accountRepository.getSelectedMetaAccount()) + return when (signer.callExecutionType()) { + CallExecutionType.IMMEDIATE -> true + CallExecutionType.DELAYED -> false + } + } + + private fun SwapQuoteArgs.calculatePriceImpact(amountIn: Balance, amountOut: Balance): Fraction { + val fiatIn = tokenIn.planksToFiat(amountIn) + val fiatOut = tokenOut.planksToFiat(amountOut) + + return calculatePriceImpact(fiatIn, fiatOut) + } + + private fun QuotedTrade.amountIn(): Balance { + return when (direction) { + SwapDirection.SPECIFIED_IN -> firstSegmentQuotedAmount + SwapDirection.SPECIFIED_OUT -> firstSegmentQuote + } + } + + private fun QuotedTrade.amountOut(): Balance { + return when (direction) { + SwapDirection.SPECIFIED_IN -> lastSegmentQuote + SwapDirection.SPECIFIED_OUT -> lastSegmentQuotedAmount + } + } + + private fun QuotedTrade.finalQuote(): Balance { + return when (direction) { + SwapDirection.SPECIFIED_IN -> lastSegmentQuote + SwapDirection.SPECIFIED_OUT -> firstSegmentQuote + } + } + + private fun calculatePriceImpact(fiatIn: BigDecimal, fiatOut: BigDecimal): Fraction { + if (fiatIn.isZero || fiatOut.isZero) return Fraction.ZERO + + val priceImpact = (BigDecimal.ONE - fiatOut / fiatIn).atLeastZero() + + return priceImpact.fractions + } + + private suspend fun directionsGraph(computationScope: CoroutineScope): Flow { + return computationalCache.useSharedFlow(ALL_DIRECTIONS_CACHE, computationScope) { + val exchangeRegistry = exchangeRegistry(computationScope) + + val directionsByExchange = exchangeRegistry.allExchanges().map { exchange -> + flowOf { exchange.availableDirectSwapConnections() } + .catch { + emit(emptyList()) + + Log.e("RealSwapService", "Failed to fetch directions for exchange ${exchange::class.simpleName}", it) + } + } + + directionsByExchange + .accumulateLists() + .filter { it.isNotEmpty() } + .map { Graph.create(it) } + .onEach { printGraphStats(it) } + } + } + + private fun printGraphStats(graph: SwapGraph) { + if (!BuildConfig.DEBUG) return + + val allEdges = graph.numberOfEdges() + val edgesByType = graph.allEdges().groupBy { it::class.simpleName } + val edgesByTypeStats = edgesByType.entries.joinToString { (type, typeEdges) -> + "$type: ${typeEdges.size}" + } + + val message = """ + === Swap Graph Stats === + All swap directions: $allEdges + $edgesByTypeStats + === Swap Graph Stats === + """.trimIndent() + + Log.d("SwapService", message) + } + + private suspend fun exchangeRegistry(computationScope: CoroutineScope): ExchangeRegistry { + return computationalCache.useCache(EXCHANGES_CACHE, computationScope) { + createExchangeRegistry(this) + } + } + + private suspend fun nodeVisitFilter(computationScope: CoroutineScope): NodeVisitFilter { + return computationalCache.useCache(NODE_VISIT_FILTER, computationScope) { + NodeVisitFilter( + computationScope = this, + chainsById = chainRegistry.chainsById(), + selectedAccount = accountRepository.getSelectedMetaAccount() + ) + } + } + + private suspend fun extrinsicService(computationScope: CoroutineScope): ExtrinsicService { + return computationalCache.useCache(EXTRINSIC_SERVICE_CACHE, computationScope) { + createExtrinsicService(this) + } + } + + private suspend fun createExchangeRegistry(coroutineScope: CoroutineScope): ExchangeRegistry { + return ExchangeRegistry( + singleChainExchanges = createIndividualChainExchanges(coroutineScope), + multiChainExchanges = listOf( + crossChainTransferFactory.create(createInnerSwapHost(coroutineScope)) + ) + ) + } + + private suspend fun createExtrinsicService(coroutineScope: CoroutineScope): ExtrinsicService { + val exchangeRegistry = exchangeRegistry(coroutineScope) + val feePaymentRegistry = exchangeRegistry.getFeePaymentRegistry() + + return extrinsicServiceFactory.create( + ExtrinsicService.FeePaymentConfig( + coroutineScope = coroutineScope, + customFeePaymentRegistry = feePaymentRegistry + ) + ) + } + + private suspend fun createIndividualChainExchanges(coroutineScope: CoroutineScope): Map { + val host = createInnerSwapHost(coroutineScope) + + return chainRegistry.enabledChainById().mapValues { (_, chain) -> + createSingleExchange(chain, host) + } + .filterNotNull() + } + + private suspend fun createSingleExchange( + chain: Chain, + host: AssetExchange.SwapHost + ): AssetExchange? { + val factory = when { + chain.swap.assetConversionSupported() -> assetConversionFactory + chain.swap.hydraDxSupported() -> hydraDxExchangeFactory + else -> null + } + + return factory?.create(chain, host) + } + + private suspend fun createInnerSwapHost(computationScope: CoroutineScope): InnerSwapHost { + val subscriptions = sharedSwapSubscriptions(computationScope) + return InnerSwapHost(computationScope, subscriptions) + } + + private suspend fun sharedSwapSubscriptions(computationScope: CoroutineScope): SharedSwapSubscriptions { + return computationalCache.useCache(SHARED_SUBSCRIPTIONS, computationScope) { + RealSharedSwapSubscriptions(computationScope) + } + } + + // Assumes each flow will have only single element + private fun List>>.accumulateLists(): Flow> { + return mergeIfMultiple() + .runningFold(emptyList()) { acc, directions -> acc + directions } + } + + private suspend fun quoteTrade( + chainAssetIn: Chain.Asset, + chainAssetOut: Chain.Asset, + amount: Balance, + swapDirection: SwapDirection, + computationSharingScope: CoroutineScope, + logQuotes: Boolean = true + ): QuotedTrade { + val quoter = getPathQuoter(computationSharingScope) + + val bestPathQuote = quoter.findBestPath(chainAssetIn, chainAssetOut, amount, swapDirection) + if (debug && logQuotes) { + logQuotes(bestPathQuote.candidates) + } + + return bestPathQuote.bestPath + } + + private suspend fun getPathQuoter(computationScope: CoroutineScope): PathQuoter { + return computationalCache.useCache(QUOTER_CACHE, computationScope) { + val graphFlow = directionsGraph(computationScope) + val filter = nodeVisitFilter(computationScope) + + quoterFactory.create(graphFlow, this, SwapPathFeeEstimator(), filter) + } + } + + private inner class SwapPathFeeEstimator : PathFeeEstimator { + + override suspend fun roughlyEstimateFee(path: Path>): PathRoughFeeEstimation { + // USDT is used to determine usd to selected currency rate without making a separate request to price api + val usdtOnAssetHub = chainRegistry.getUSDTOnAssetHub(path) ?: return PathRoughFeeEstimation.zero() + + val operationPrototypes = path.constructAtomicOperationPrototypes() + + val nativeAssetsSegments = operationPrototypes.allNativeAssets() + val assetIn = chainRegistry.asset(path.first().edge.from) + val assetOut = chainRegistry.asset(path.last().edge.to) + + val prices = getTokens(assetIn = assetIn, assetOut = assetOut, usdTiedAsset = usdtOnAssetHub, fees = nativeAssetsSegments) + + val totalFiat = operationPrototypes.estimateTotalFeeInFiat(prices, usdtOnAssetHub.fullId) + + return PathRoughFeeEstimation( + inAssetIn = prices.fiatToPlanks(totalFiat, assetIn), + inAssetOut = prices.fiatToPlanks(totalFiat, assetOut) + ) + } + + private suspend fun ChainRegistry.getUSDTOnAssetHub(path: Path>): Chain.Asset? { + // Determine which ecosystem the swap is in based on the path + val involvesPezkuwi = path.any { edge -> + edge.edge.from.chainId in PEZKUWI_CHAIN_IDS || edge.edge.to.chainId in PEZKUWI_CHAIN_IDS + } + + val assetHubGenesis = if (involvesPezkuwi) { + Chain.Geneses.PEZKUWI_ASSET_HUB + } else { + Chain.Geneses.POLKADOT_ASSET_HUB + } + + return try { + val assetHub = getChain(assetHubGenesis) + assetHub.assets.find { it.symbol.value == "USDT" || it.symbol.value == "wUSDT" } + } catch (e: Exception) { + // Fallback to Polkadot Asset Hub if Pezkuwi Asset Hub is not available + val assetHub = getChain(Chain.Geneses.POLKADOT_ASSET_HUB) + assetHub.assets.find { it.symbol.value == "USDT" } + } + } + + private fun Map.fiatToPlanks(fiat: BigDecimal, chainAsset: Chain.Asset): Balance { + val token = get(chainAsset.fullId) ?: return Balance.ZERO + + return token.planksFromFiatOrZero(fiat) + } + + private suspend fun getTokens( + assetIn: Chain.Asset, + assetOut: Chain.Asset, + usdTiedAsset: Chain.Asset, + fees: List + ): Map { + val allTokensToRequestPrices = buildList { + addAll(fees) + add(assetIn) + add(usdTiedAsset) + add(assetOut) + } + + return tokenRepository.getTokens(allTokensToRequestPrices) + } + + private suspend fun List.allNativeAssets(): List { + return map { + val chain = chainRegistry.getChain(it.fromChain) + chain.utilityAsset + } + } + + private suspend fun List.estimateTotalFeeInFiat( + prices: Map, + usdTiedAsset: FullChainAssetId + ): BigDecimal { + return sumOf { + val nativeAssetId = FullChainAssetId.utilityAssetOf(it.fromChain) + val token = prices[nativeAssetId] ?: return@sumOf BigDecimal.ZERO + + val usdConverter = PriceBasedUsdConverter(prices, nativeAssetId, usdTiedAsset) + + val roughFee = it.roughlyEstimateNativeFee(usdConverter) + token.amountToFiat(roughFee) + } + } + + private inner class PriceBasedUsdConverter( + private val prices: Map, + private val nativeAsset: FullChainAssetId, + private val usdTiedAsset: FullChainAssetId, + ) : UsdConverter { + + val currencyToUsdRate = determineCurrencyToUsdRate() + + override suspend fun nativeAssetEquivalentOf(usdAmount: Double): BigDecimal { + val priceInCurrency = prices[nativeAsset]?.coinRate?.rate ?: return BigDecimal.ZERO + val priceInUsd = priceInCurrency * currencyToUsdRate + return usdAmount.toBigDecimal() / priceInUsd + } + + private fun determineCurrencyToUsdRate(): BigDecimal { + val usdTiedAssetPrice = prices[usdTiedAsset] ?: return BigDecimal.ZERO + val rate = usdTiedAssetPrice.coinRate?.rate.orZero() + if (rate.isZero) return BigDecimal.ZERO + + return BigDecimal.ONE.divide(rate, MathContext.DECIMAL64) + } + } + } + + private inner class InnerSwapHost( + override val scope: CoroutineScope, + override val sharedSubscriptions: SharedSwapSubscriptions + ) : AssetExchange.SwapHost { + + override suspend fun quote(quoteArgs: ParentQuoterArgs): Balance { + return quoteTrade( + chainAssetIn = quoteArgs.chainAssetIn, + chainAssetOut = quoteArgs.chainAssetOut, + amount = quoteArgs.amount, + swapDirection = quoteArgs.swapDirection, + computationSharingScope = scope, + logQuotes = false + ).finalQuote() + } + + override suspend fun extrinsicService(): ExtrinsicService { + return extrinsicService(scope) + } + } + + private fun logFee(fee: SwapFee) { + if (!debug) return + + val route = fee.segments.joinToString(separator = "\n") { segment -> + val allFees = buildList { + add(segment.fee.submissionFee) + addAll(segment.fee.postSubmissionFees.paidByAccount) + addAll(segment.fee.postSubmissionFees.paidFromAmount) + } + + allFees.joinToString { "${it.amount.formatPlanks(it.asset)} (${it.debugLabel})" } + } + + Log.d("Swaps", "---- Fees -----") + Log.d("Swaps", route) + Log.d("Swaps", "---- End Fees -----") + } + + private suspend fun logQuotes(quotedTrades: List) { + if (!debug) return + + val allCandidates = quotedTrades.sortedDescending() + .map { trade -> formatTrade(trade) } + .joinToString(separator = "\n") + + Log.d("RealSwapService", "-------- New quote ----------") + Log.d("RealSwapService", allCandidates) + Log.d("RealSwapService", "-------- Done quote ----------\n\n\n") + } + + private suspend fun formatTrade(trade: QuotedTrade): String { + return buildString { + val weightBreakdown = WeightBreakdown.fromQuotedPath(trade) + + trade.path.zip(weightBreakdown.individualWeights).onEachIndexed { index, (quotedSwapEdge, weight) -> + val amountIn: Balance + val amountOut: Balance + + when (trade.direction) { + SwapDirection.SPECIFIED_IN -> { + amountIn = quotedSwapEdge.quotedAmount + amountOut = quotedSwapEdge.quote + } + + SwapDirection.SPECIFIED_OUT -> { + amountIn = quotedSwapEdge.quote + amountOut = quotedSwapEdge.quotedAmount + } + } + + if (index == 0) { + val assetIn = chainRegistry.asset(quotedSwapEdge.edge.from) + val initialAmount = amountIn.formatPlanks(assetIn) + append(initialAmount) + + if (trade.direction == SwapDirection.SPECIFIED_OUT) { + val roughFeesInAssetIn = trade.roughFeeEstimation.inAssetIn + val roughFeesInAssetInAmount = roughFeesInAssetIn.formatPlanks(assetIn) + + append(" (+$roughFeesInAssetInAmount fees) ") + } + } + + append(" --- ${quotedSwapEdge.edge.debugLabel()} (w: $weight)---> ") + + val assetOut = chainRegistry.asset(quotedSwapEdge.edge.to) + val outAmount = amountOut.formatPlanks(assetOut) + + append(outAmount) + + if (index == trade.path.size - 1) { + if (trade.direction == SwapDirection.SPECIFIED_IN) { + val roughFeesInAssetOut = trade.roughFeeEstimation.inAssetOut + val roughFeesInAssetOutAmount = roughFeesInAssetOut.formatPlanks(assetOut) + + append(" (-$roughFeesInAssetOutAmount fees, w: ${weightBreakdown.total})") + } + } + } + } + } + + private inner class ExchangeRegistry( + private val singleChainExchanges: Map, + private val multiChainExchanges: List, + ) { + + private val feePaymentRegistry = SwapFeePaymentRegistry() + + fun getFeePaymentRegistry(): FeePaymentProviderRegistry { + return feePaymentRegistry + } + + fun allExchanges(): List { + return buildList { + addAll(singleChainExchanges.values) + addAll(multiChainExchanges) + } + } + + private inner class SwapFeePaymentRegistry : FeePaymentProviderRegistry { + + private val paymentRegistryOverrides = createFeePaymentOverrides() + + override suspend fun providerFor(chainId: ChainId): FeePaymentProvider { + return paymentRegistryOverrides.find { it.chain.id == chainId }?.provider + ?: defaultFeePaymentProviderRegistry.providerFor(chainId) + } + + private fun createFeePaymentOverrides(): List { + return buildList { + singleChainExchanges.values.onEach { singleChainExchange -> + addAll(singleChainExchange.feePaymentOverrides()) + } + + multiChainExchanges.onEach { multiChainExchange -> + addAll(multiChainExchange.feePaymentOverrides()) + } + } + } + } + } + + /** + * Check that it is possible to pay fees in moving asset + */ + private inner class NodeVisitFilter( + val computationScope: CoroutineScope, + val chainsById: ChainsById, + val selectedAccount: MetaAccount, + ) : EdgeVisitFilter { + + private val feePaymentCapabilityCache: MutableMap = mutableMapOf() + private val callExecutionType = lazyAsync { + signerProvider.rootSignerFor(selectedAccount) + .callExecutionType() + } + + suspend fun warmUpChain(chainId: ChainId) { + getFeeCustomFeeCapability(chainId) + } + + override suspend fun shouldVisit(edge: SwapGraphEdge, pathPredecessor: SwapGraphEdge?): Boolean { + val chainAndAssetOut = chainsById.chainWithAssetOrNull(edge.to) ?: return false + + // User should have account on destination + if (!selectedAccount.hasAccountIn(chainAndAssetOut.chain)) return false + + // First path segments don't have any extra restrictions + if (pathPredecessor == null) return true + + // Second and subsequent edges are subject to checking whether we can execute them one by one immediately + if (!canExecuteIntermediateEdgeSequentially(edge, pathPredecessor)) return false + + // We don't (yet) handle edges that doesn't allow to transfer whole account balance out + if (!edge.canTransferOutWholeAccountBalance()) return false + + // Destination asset must be sufficient + if (!isSufficient(chainAndAssetOut)) return false + + val chainAndAssetIn = chainsById.chainWithAssetOrNull(edge.from) ?: return false + + // Since we allow insufficient asset out in paths with length 1, we want to reject paths with length > 1 + // by checking sufficiency of assetIn (which was assetOut in the previous segment) + if (!isSufficient(chainAndAssetIn)) return false + + // Besides checks above, utility assets don't have any other restrictions + if (edge.from.isUtility) return true + + // Edge might request us to ignore the default requirement based on its direct predecessor + if (edge.predecessorHandlesFees(pathPredecessor)) return true + + val feeCapability = getFeeCustomFeeCapability(edge.from.chainId) + + return feeCapability != null && feeCapability.canPayFeeInNonUtilityToken(edge.from.assetId) && + edge.canPayNonNativeFeesInIntermediatePosition() + } + + private suspend fun canExecuteIntermediateEdgeSequentially(edge: SwapGraphEdge, predecessor: SwapGraphEdge): Boolean { + // If account can execute operations immediately - we can execute anything sequentially + if (callExecutionType.get() == CallExecutionType.IMMEDIATE) return true + + // Otherwise it is only possible to do when the edges is merged with predecessor. If it does not - it will require a separate operation + // And doing a separate operation is not possible since execution type is DELAYED + return edge.canAppendToPredecessor(predecessor) + } + + private fun isSufficient(chainAndAsset: ChainWithAsset): Boolean { + val balance = assetSourceRegistry.sourceFor(chainAndAsset.asset).balance + return balance.isSelfSufficient(chainAndAsset.asset) + } + + private suspend fun getFeeCustomFeeCapability(chainId: ChainId): FastLookupCustomFeeCapability { + return feePaymentCapabilityCache.getOrPut(chainId) { + createFastLookupFeeCapability(chainId, computationScope) + } + } + + private suspend fun createFastLookupFeeCapability(chainId: ChainId, computationScope: CoroutineScope): FastLookupCustomFeeCapability { + val feePaymentRegistry = exchangeRegistry(computationScope).getFeePaymentRegistry() + return feePaymentRegistry.providerFor(chainId).fastLookupCustomFeeCapabilityOrDefault() + } + } + + private inner class RealSharedSwapSubscriptions( + private val coroutineScope: CoroutineScope, + ) : SharedSwapSubscriptions, CoroutineScope by coroutineScope { + + private val blockNumberCache = SharedFlowCache(coroutineScope) { chainId -> + chainStateRepository.currentRemoteBlockNumberFlow(chainId) + } + + override suspend fun blockNumber(chainId: ChainId): Flow { + return blockNumberCache.getOrCompute(chainId) + } + } +} + +private typealias QuotedTrade = QuotedPath + +abstract class BaseSwapGraphEdge( + val fromAsset: Chain.Asset, + val toAsset: Chain.Asset +) : SwapGraphEdge { + + final override val from: FullChainAssetId = fromAsset.fullId + + final override val to: FullChainAssetId = toAsset.fullId +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/SwapPayloadValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/SwapPayloadValidation.kt new file mode 100644 index 0000000..e9ec3e1 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/SwapPayloadValidation.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_api.domain.model.createAggregated +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapState +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount + +data class SwapValidationPayload( + val fee: SwapFee, + val swapQuote: SwapQuote, + val slippage: Fraction, +) { + + val amountIn: ChainAssetWithAmount = swapQuote.amountIn + + val amountOut: ChainAssetWithAmount = swapQuote.amountOut +} + +fun SwapValidationPayload.estimatedSwapLimit(): SwapLimit { + val firstLimit = fee.segments.first().operation.estimatedSwapLimit + val lastLimit = fee.segments.last().operation.estimatedSwapLimit + + return SwapLimit.createAggregated(firstLimit, lastLimit) +} + +fun SwapValidationPayload.toSwapState(): SwapState { + return SwapState(swapQuote, fee, slippage) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/SwapValidationFailure.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/SwapValidationFailure.kt new file mode 100644 index 0000000..ed0f160 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/SwapValidationFailure.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +sealed class SwapValidationFailure { + + object NonPositiveAmount : SwapValidationFailure() + + class HighPriceImpact(val priceImpact: Fraction) : SwapValidationFailure() + + class InvalidSlippage(val minSlippage: Fraction, val maxSlippage: Fraction) : SwapValidationFailure() + + class NewRateExceededSlippage( + val assetIn: Chain.Asset, + val assetOut: Chain.Asset, + val selectedRate: BigDecimal, + val newRate: BigDecimal + ) : SwapValidationFailure() + + object NotEnoughLiquidity : SwapValidationFailure() + + sealed class NotEnoughFunds : SwapValidationFailure() { + + class ToPayFeeAndStayAboveED( + override val asset: Chain.Asset, + override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel + ) : NotEnoughFunds(), InsufficientBalanceToStayAboveEDError + + object InUsedAsset : NotEnoughFunds() + } + + class AmountOutIsTooLowToStayAboveED( + val asset: Chain.Asset, + val amountInPlanks: Balance, + val existentialDeposit: Balance + ) : SwapValidationFailure() + + class IntermediateAmountOutIsTooLowToStayAboveED( + val asset: Chain.Asset, + val existentialDeposit: Balance, + val amount: Balance + ) : SwapValidationFailure() + + class CannotReceiveAssetOut( + val destination: ChainWithAsset, + val requiredNativeAssetOnChainOut: ChainAssetWithAmount + ) : SwapValidationFailure() + + sealed class InsufficientBalance : SwapValidationFailure() { + + class BalanceNotConsiderInsufficientReceiveAsset( + val assetIn: Chain.Asset, + val assetOut: Chain.Asset, + val existentialDeposit: Balance + ) : SwapValidationFailure() + + class BalanceNotConsiderConsumers( + val assetIn: Chain.Asset, + val assetInED: Balance, + val feeAsset: Chain.Asset, + val fee: Balance + ) : SwapValidationFailure() + + class CannotPayFeeDueToAmount( + val assetIn: Chain.Asset, + val feeAmount: BigDecimal, + val maxSwapAmount: BigDecimal + ) : SwapValidationFailure() + + class CannotPayFee( + val feeAsset: Chain.Asset, + val balance: Balance, + val fee: Balance + ) : SwapValidationFailure() + } + + class TooSmallRemainingBalance( + val assetIn: Chain.Asset, + val remainingBalance: Balance, + val assetInExistentialDeposit: Balance + ) : SwapValidationFailure() +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/SwapValidations.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/SwapValidations.kt new file mode 100644 index 0000000..aef4aea --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/SwapValidations.kt @@ -0,0 +1,126 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation + +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds +import io.novafoundation.nova.feature_swap_impl.domain.validation.utils.SharedQuoteValidationRetriever +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapCanPayExtraFeesValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapDoNotLooseAssetInDustValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapEnoughLiquidityValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapPriceImpactValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapRateChangesValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapSlippageRangeValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.sufficientAmountOutToStayAboveEDValidation +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.decimalAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalanceConsideringConsumersValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalanceGeneric + +typealias SwapValidationSystem = ValidationSystem +typealias SwapValidation = Validation +typealias SwapValidationSystemBuilder = ValidationSystemBuilder + +fun SwapValidationSystemBuilder.priceImpactValidation( + priceImpactThresholds: PriceImpactThresholds +) = validate(SwapPriceImpactValidation(priceImpactThresholds)) + +fun SwapValidationSystemBuilder.availableSlippage( + swapService: SwapService +) = validate(SwapSlippageRangeValidation(swapService)) + +fun SwapValidationSystemBuilder.swapFeeSufficientBalance( + assetsValidationContext: AssetsValidationContext +) = validate(SwapCanPayExtraFeesValidation(assetsValidationContext)) + +fun SwapValidationSystemBuilder.swapSmallRemainingBalance( + assetsValidationContext: AssetsValidationContext +) = validate( + SwapDoNotLooseAssetInDustValidation(assetsValidationContext) +) + +fun SwapValidationSystemBuilder.sufficientBalanceConsideringConsumersValidation( + assetsValidationContext: AssetsValidationContext +) = sufficientBalanceConsideringConsumersValidation( + assetsValidationContext = assetsValidationContext, + assetExtractor = { it.amountIn.chainAsset }, + feeExtractor = { it.fee.totalFeeAmount(it.amountIn.chainAsset) }, + amountExtractor = { it.amountIn.amount }, + error = { payload, existentialDeposit -> + SwapValidationFailure.InsufficientBalance.BalanceNotConsiderConsumers( + assetIn = payload.amountIn.chainAsset, + assetInED = existentialDeposit, + fee = payload.fee.initialSubmissionFee.amountByExecutingAccount, + feeAsset = payload.fee.initialSubmissionFee.asset + ) + } +) + +fun SwapValidationSystemBuilder.rateNotExceedSlippage( + sharedQuoteValidationRetriever: SharedQuoteValidationRetriever +) = validate( + SwapRateChangesValidation(sharedQuoteValidationRetriever) +) + +fun SwapValidationSystemBuilder.enoughLiquidity( + sharedQuoteValidationRetriever: SharedQuoteValidationRetriever +) = validate( + SwapEnoughLiquidityValidation(sharedQuoteValidationRetriever) +) + +fun SwapValidationSystemBuilder.canPayAllFees( + assetsValidationContext: AssetsValidationContext +) = validate( + SwapCanPayExtraFeesValidation(assetsValidationContext) +) + +fun SwapValidationSystemBuilder.enoughAssetInToPayForSwap( + assetsValidationContext: AssetsValidationContext +) = sufficientBalanceGeneric( + available = { assetsValidationContext.getAsset(it.amountIn.chainAsset).transferable }, + amount = { it.amountIn.decimalAmount }, + error = { SwapValidationFailure.NotEnoughFunds.InUsedAsset } +) + +fun SwapValidationSystemBuilder.enoughAssetInToPayForSwapAndFee( + assetsValidationContext: AssetsValidationContext +) = sufficientBalanceGeneric( + available = { + val transferable = assetsValidationContext.getAsset(it.amountIn.chainAsset).transferable + val extraDeductionPlanks = it.fee.additionalMaxAmountDeduction.fromCountedTowardsEd + val extraDeduction = it.amountIn.chainAsset.amountFromPlanks(extraDeductionPlanks) + + (transferable - extraDeduction).atLeastZero() + }, + amount = { it.amountIn.decimalAmount }, + fee = { + val planks = it.fee.totalFeeAmount(it.amountIn.chainAsset) + it.amountIn.chainAsset.amountFromPlanks(planks) + }, + error = { + SwapValidationFailure.InsufficientBalance.CannotPayFeeDueToAmount( + assetIn = it.payload.amountIn.chainAsset, + feeAmount = it.fee, + maxSwapAmount = it.maxUsable + ) + } +) + +fun SwapValidationSystemBuilder.sufficientAmountOutToStayAboveED( + assetsValidationContext: AssetsValidationContext +) = sufficientAmountOutToStayAboveEDValidation(assetsValidationContext) + +fun SwapValidationSystemBuilder.positiveAmountIn() = positiveAmount( + amount = { it.amountIn.decimalAmount }, + error = { SwapValidationFailure.NonPositiveAmount } +) + +fun SwapValidationSystemBuilder.positiveAmountOut() = positiveAmount( + amount = { it.amountOut.decimalAmount }, + error = { SwapValidationFailure.NonPositiveAmount } +) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/utils/SharedQuoteValidationRetriever.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/utils/SharedQuoteValidationRetriever.kt new file mode 100644 index 0000000..b15a40c --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/utils/SharedQuoteValidationRetriever.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.utils + +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.quotedAmount +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.coroutineContext + +class SharedQuoteValidationRetriever( + private val swapService: SwapService, + private val assetsValidationContext: AssetsValidationContext, +) { + + private var result: Result? = null + + suspend fun retrieveQuote(value: SwapValidationPayload): Result { + if (result == null) { + val assetIn = assetsValidationContext.getAsset(value.amountIn.chainAsset) + val assetOut = assetsValidationContext.getAsset(value.amountOut.chainAsset) + + val amount = value.swapQuote.quotedPath.quotedAmount + val direction = value.swapQuote.direction + + val quoteArgs = SwapQuoteArgs(assetIn.token, assetOut.token, amount, direction) + + result = swapService.quote(quoteArgs, CoroutineScope(coroutineContext)) + } + + return result!! + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/CanReceiveAssetOutValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/CanReceiveAssetOutValidation.kt new file mode 100644 index 0000000..e5a0074 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/CanReceiveAssetOutValidation.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.getExistentialDeposit +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import javax.inject.Inject + +@FeatureScope +class CanReceiveAssetOutValidationFactory @Inject constructor( + private val accountInfoRepository: AccountInfoRepository, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, +) { + + context(SwapValidationSystemBuilder) + fun canReceiveAssetOut(validationContext: AssetsValidationContext) { + validate(CanReceiveAssetOutValidation(accountInfoRepository, chainRegistry, accountRepository, validationContext)) + } +} + +/** + * 1. asset out is sufficient OR + * + * 2. remaining providers (minus 1 if asset in is on the same chain, sufficient and dusted) is positive + * + * Otherwise it is not possible to receive insufficient assets on destination + */ +class CanReceiveAssetOutValidation( + private val accountInfoRepository: AccountInfoRepository, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val assetsValidationContext: AssetsValidationContext, +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + val isAssetOutSufficient = assetsValidationContext.isAssetSufficient(value.amountOut.chainAsset) + if (isAssetOutSufficient) return valid() + + val chainAssetOut = value.amountOut.chainAsset + val chainOut = chainRegistry.getChain(chainAssetOut.chainId) + + val metaAccount = accountRepository.getSelectedMetaAccount() + val recipientAccountId = metaAccount.accountIdIn(chainOut) ?: return valid() + + val destinationAccountInfo = accountInfoRepository.getAccountInfo(chainOut.id, recipientAccountId) + + val providersDecrease = if (swapDecreasesProviders(value)) 1 else 0 + val remainingProviders = destinationAccountInfo.providers.toInt() - providersDecrease + + return (remainingProviders > 0) isTrueOrError { + val destinationChainNativeAsset = chainOut.utilityAsset + val destinationChainNativeAssetEd = assetsValidationContext.getExistentialDeposit(destinationChainNativeAsset) + + SwapValidationFailure.CannotReceiveAssetOut( + destination = ChainWithAsset(chainOut, chainAssetOut), + requiredNativeAssetOnChainOut = destinationChainNativeAsset.withAmount(destinationChainNativeAssetEd) + ) + } + } + + private suspend fun swapDecreasesProviders( + value: SwapValidationPayload + ): Boolean { + val assetIn = value.amountIn.chainAsset + val assetOut = value.amountOut.chainAsset + + // Asset in does not affect providers on destination chain when its on different chain + if (assetIn.chainId != assetOut.chainId) return false + + // If asset in is not sufficient, it cannot influence number of providers even if dusted + if (assetsValidationContext.isAssetSufficient(assetIn)) return false + + val assetInBalance = assetsValidationContext.getAsset(assetIn) + val assetInEd = assetsValidationContext.getExistentialDeposit(assetIn) + + val swapDustsAssetIn = assetInBalance.balanceCountedTowardsEDInPlanks - value.amountIn.amount < assetInEd + + return swapDustsAssetIn + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapCanPayExtraFeesValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapCanPayExtraFeesValidation.kt new file mode 100644 index 0000000..107138d --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapCanPayExtraFeesValidation.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_swap_api.domain.model.allFeeAssets +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.InsufficientBalance +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.runtime.ext.fullId + +/** + * Validation that checks whether the user can pay all the fees in assets other then assetIn + * Asset in fees is checked in a separate validation that also takes swap amount into account + */ +class SwapCanPayExtraFeesValidation( + private val assetsValidationContext: AssetsValidationContext +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + val extraFeeAssets = value.fee.firstSegmentFee.allFeeAssets() + // asset in fee is checked in a separate validation + .filter { it.fullId != value.amountIn.chainAsset.fullId } + + extraFeeAssets.onEach { feeChainAsset -> + val feeAsset = assetsValidationContext.getAsset(feeChainAsset) + val totalFee = value.fee.totalFeeAmount(feeChainAsset) + + val availableBalance = feeAsset.transferableInPlanks + + if (availableBalance < totalFee) { + return InsufficientBalance.CannotPayFee(feeChainAsset, availableBalance, totalFee) + .validationError() + } + } + + return valid() + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapDoNotLooseAssetInDustValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapDoNotLooseAssetInDustValidation.kt new file mode 100644 index 0000000..551067b --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapDoNotLooseAssetInDustValidation.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.TooSmallRemainingBalance +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.getExistentialDeposit +import io.novasama.substrate_sdk_android.hash.isPositive + +class SwapDoNotLooseAssetInDustValidation( + private val assetsValidationContext: AssetsValidationContext, +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + val chainAssetIn = value.amountIn.chainAsset + + val balanceCountedTowardsEd = assetsValidationContext.getAsset(chainAssetIn).balanceCountedTowardsEDInPlanks + val swapAmount = value.amountIn.amount + val assetInExistentialDeposit = assetsValidationContext.getExistentialDeposit(chainAssetIn) + + val totalFees = value.fee.totalFeeAmount(chainAssetIn) + val remainingBalance = balanceCountedTowardsEd - swapAmount - totalFees + + if (remainingBalance.isPositive() && remainingBalance < assetInExistentialDeposit) { + return TooSmallRemainingBalance(chainAssetIn, remainingBalance, assetInExistentialDeposit).validationError() + } + + return valid() + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapEnoughLiquidityValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapEnoughLiquidityValidation.kt new file mode 100644 index 0000000..b00f1d4 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapEnoughLiquidityValidation.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NotEnoughLiquidity +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_swap_impl.domain.validation.utils.SharedQuoteValidationRetriever + +class SwapEnoughLiquidityValidation( + private val sharedQuoteValidationRetriever: SharedQuoteValidationRetriever +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + val quoteResult = sharedQuoteValidationRetriever.retrieveQuote(value) + + return validOrError(quoteResult.isSuccess) { + NotEnoughLiquidity + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapEnoughNativeAssetBalanceToPassSubmissionChecks.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapEnoughNativeAssetBalanceToPassSubmissionChecks.kt new file mode 100644 index 0000000..ad81583 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapEnoughNativeAssetBalanceToPassSubmissionChecks.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NotEnoughFunds +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.getExistentialDeposit +import io.novafoundation.nova.runtime.ext.isUtilityAsset + +/** + * Checks that operation can pass submission checks on node side + * In particular, it checks that there is enough native asset to pay fee and remain above ED + * This only applies when fee is paid in native asset + */ +class SwapEnoughNativeAssetBalanceToPayFeeConsideringEDValidation( + private val assetsValidationContext: AssetsValidationContext, +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + val initialSubmissionFee = value.fee.initialSubmissionFee + val initialSubmissionFeeChainAsset = initialSubmissionFee.asset + + if (!initialSubmissionFeeChainAsset.isUtilityAsset) return valid() + + val initialSubmissionFeeAsset = assetsValidationContext.getAsset(initialSubmissionFeeChainAsset) + val existentialDeposit = assetsValidationContext.getExistentialDeposit(initialSubmissionFeeChainAsset) + + val availableBalance = initialSubmissionFeeAsset.balanceCountedTowardsEDInPlanks + val fee = initialSubmissionFee.amountByExecutingAccount + + return validOrError(availableBalance - fee >= existentialDeposit) { + val minRequiredBalance = existentialDeposit + fee + + NotEnoughFunds.ToPayFeeAndStayAboveED( + asset = initialSubmissionFeeChainAsset, + errorModel = InsufficientBalanceToStayAboveEDError.ErrorModel( + minRequiredBalance = initialSubmissionFeeChainAsset.amountFromPlanks(minRequiredBalance), + availableBalance = initialSubmissionFeeChainAsset.amountFromPlanks(availableBalance), + balanceToAdd = initialSubmissionFeeChainAsset.amountFromPlanks(minRequiredBalance - availableBalance) + ) + ) + } + } +} + +fun SwapValidationSystemBuilder.sufficientNativeBalanceToPayFeeConsideringED( + assetsValidationContext: AssetsValidationContext +) { + validate( + SwapEnoughNativeAssetBalanceToPayFeeConsideringEDValidation( + assetsValidationContext = assetsValidationContext + ) + ) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapIntermediateReceivesMeetEDValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapIntermediateReceivesMeetEDValidation.kt new file mode 100644 index 0000000..bcb862b --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapIntermediateReceivesMeetEDValidation.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_api.domain.model.amountOutMin +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext + +class SwapIntermediateReceivesMeetEDValidation(private val assetsValidationContext: AssetsValidationContext) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + value.fee.segments + .dropLast(1) // Last segment destination is verified in a separate validation + .onEach { swapSegment -> + val status = checkSegmentDestinationMeetsEd(swapSegment) + if (status is ValidationStatus.NotValid) return status + } + + return valid() + } + + private suspend fun checkSegmentDestinationMeetsEd(segment: SwapFee.SwapSegment): ValidationStatus? { + val amountOutMin = segment.operation.estimatedSwapLimit.amountOutMin + val assetOut = segment.operation.assetOut + + val existentialDeposit = assetsValidationContext.getExistentialDeposit(assetOut) + val outAssetBalance = assetsValidationContext.getAsset(assetOut) + + return validOrError(outAssetBalance.balanceCountedTowardsEDInPlanks + amountOutMin >= existentialDeposit) { + SwapValidationFailure.IntermediateAmountOutIsTooLowToStayAboveED( + asset = outAssetBalance.token.configuration, + existentialDeposit = existentialDeposit, + amount = amountOutMin + ) + } + } +} + +fun SwapValidationSystemBuilder.intermediateReceivesMeetEDValidation( + assetsValidationContext: AssetsValidationContext +) = validate(SwapIntermediateReceivesMeetEDValidation(assetsValidationContext)) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapKeepsNecessaryAmountOfAssetIn.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapKeepsNecessaryAmountOfAssetIn.kt new file mode 100644 index 0000000..05e0fde --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapKeepsNecessaryAmountOfAssetIn.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novasama.substrate_sdk_android.hash.isPositive + +/** + * Checks that spending assetIn to swap for assetOut wont dust account and result in assetOut being lost + */ +class SwapKeepsNecessaryAmountOfAssetIn( + private val assetsValidationContext: AssetsValidationContext, +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + val chainAssetIn = value.amountIn.chainAsset + val chainAssetOut = value.amountOut.chainAsset + + val amount = value.amountIn.amount + + val minimumCountedTowardsEd = value.fee.additionalMaxAmountDeduction.fromCountedTowardsEd + + if (minimumCountedTowardsEd.isPositive()) { + val assetIn = assetsValidationContext.getAsset(chainAssetIn) + val fee = value.fee.totalFeeAmount(chainAssetIn) + + val assetInStaysAboveEd = assetIn.balanceCountedTowardsEDInPlanks - amount - fee >= minimumCountedTowardsEd + + return validOrError(assetInStaysAboveEd) { + SwapValidationFailure.InsufficientBalance.BalanceNotConsiderInsufficientReceiveAsset( + assetIn = chainAssetIn, + assetOut = chainAssetOut, + existentialDeposit = minimumCountedTowardsEd + ) + } + } + + return valid() + } +} + +fun SwapValidationSystemBuilder.sufficientBalanceConsideringNonSufficientAssetsValidation(assetsValidationContext: AssetsValidationContext) = validate( + SwapKeepsNecessaryAmountOfAssetIn(assetsValidationContext) +) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapPriceImpactValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapPriceImpactValidation.kt new file mode 100644 index 0000000..e69554b --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapPriceImpactValidation.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload + +class SwapPriceImpactValidation( + private val priceImpactThresholds: PriceImpactThresholds +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + if (value.swapQuote.priceImpact > priceImpactThresholds.mediumPriceImpact) { + return SwapValidationFailure.HighPriceImpact(value.swapQuote.priceImpact).validationError() + } + + return valid() + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapRateChangesValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapRateChangesValidation.kt new file mode 100644 index 0000000..e1c7eaa --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapRateChangesValidation.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit +import io.novafoundation.nova.feature_swap_api.domain.model.swapRate +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.quote +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NewRateExceededSlippage +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_swap_impl.domain.validation.estimatedSwapLimit +import io.novafoundation.nova.feature_swap_impl.domain.validation.utils.SharedQuoteValidationRetriever +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class SwapRateChangesValidation( + private val quoteValidationRetriever: SharedQuoteValidationRetriever, +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + val newQuote = quoteValidationRetriever.retrieveQuote(value).getOrThrow() + val swapLimit = value.estimatedSwapLimit() + + return validOrError(swapLimit.isBalanceInSwapLimits(newQuote.quotedPath.quote)) { + NewRateExceededSlippage( + assetIn = value.amountIn.chainAsset, + assetOut = value.amountOut.chainAsset, + selectedRate = value.swapQuote.swapRate(), + newRate = newQuote.swapRate() + ) + } + } +} + +private fun SwapLimit.isBalanceInSwapLimits(quotedBalance: Balance): Boolean { + return when (this) { + is SwapLimit.SpecifiedIn -> quotedBalance >= amountOutMin + is SwapLimit.SpecifiedOut -> quotedBalance <= amountInMax + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapSlippageRangeValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapSlippageRangeValidation.kt new file mode 100644 index 0000000..037d5c5 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapSlippageRangeValidation.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.InvalidSlippage +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload + +class SwapSlippageRangeValidation( + private val swapService: SwapService +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + val slippageConfig = swapService.defaultSlippageConfig(value.amountIn.chainAsset.chainId) + + if (value.slippage !in slippageConfig.minAvailableSlippage..slippageConfig.maxAvailableSlippage) { + return InvalidSlippage(slippageConfig.minAvailableSlippage, slippageConfig.maxAvailableSlippage).validationError() + } + + return valid() + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapSufficientAmountOutToStayAboveEDValidation.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapSufficientAmountOutToStayAboveEDValidation.kt new file mode 100644 index 0000000..0859d92 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/domain/validation/validations/SwapSufficientAmountOutToStayAboveEDValidation.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_swap_impl.domain.validation.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.getExistentialDeposit + +class SwapSufficientAmountOutToStayAboveEDValidation( + private val assetsValidationContext: AssetsValidationContext, +) : SwapValidation { + + override suspend fun validate(value: SwapValidationPayload): ValidationStatus { + val (chainAssetOut, amountOut) = value.amountOut + + val assetOut = assetsValidationContext.getAsset(chainAssetOut) + val existentialDeposit = assetsValidationContext.getExistentialDeposit(assetOut.token.configuration) + + val remainingAmountStaysAboveED = assetOut.balanceCountedTowardsEDInPlanks + amountOut >= existentialDeposit + + return validOrError(remainingAmountStaysAboveED) { + SwapValidationFailure.AmountOutIsTooLowToStayAboveED( + asset = assetOut.token.configuration, + amountInPlanks = amountOut, + existentialDeposit = existentialDeposit + ) + } + } +} + +fun SwapValidationSystemBuilder.sufficientAmountOutToStayAboveEDValidation(assetsValidationContext: AssetsValidationContext) = validate( + SwapSufficientAmountOutToStayAboveEDValidation(assetsValidationContext) +) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/SwapRouter.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/SwapRouter.kt new file mode 100644 index 0000000..1cdf7f4 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/SwapRouter.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_swap_impl.presentation + +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload + +interface SwapRouter : ReturnableRouter { + + fun openSwapConfirmation() + + fun openSwapRoute() + + fun openSwapFee() + + fun openSwapExecution() + + fun selectAssetIn(selectedAsset: AssetPayload?) + + fun selectAssetOut(selectedAsset: AssetPayload?) + + fun openSwapOptions() + + fun openRetrySwap(payload: SwapSettingsPayload) + + fun openBalanceDetails(assetPayload: AssetPayload) + + fun openMain() +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/PriceImpactFormatter.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/PriceImpactFormatter.kt new file mode 100644 index 0000000..b1309c4 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/PriceImpactFormatter.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.colorSpan +import io.novafoundation.nova.common.utils.formatting.formatPercents +import io.novafoundation.nova.common.utils.toSpannable +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds + +interface PriceImpactFormatter { + + fun format(priceImpact: Fraction): CharSequence? + + fun formatWithBrackets(priceImpact: Fraction): CharSequence? +} + +class RealPriceImpactFormatter( + private val priceImpactThresholds: PriceImpactThresholds, + private val resourceManager: ResourceManager +) : PriceImpactFormatter { + + private val thresholdsToColors = listOf( + priceImpactThresholds.highPriceImpact to R.color.text_negative, + priceImpactThresholds.mediumPriceImpact to R.color.text_warning, + priceImpactThresholds.lowPriceImpact to R.color.text_secondary, + ) + + override fun format(priceImpact: Fraction): CharSequence? { + val color = getColor(priceImpact) ?: return null + return priceImpact.formatPercents().toSpannable(colorSpan(resourceManager.getColor(color))) + } + + override fun formatWithBrackets(priceImpact: Fraction): CharSequence? { + val color = getColor(priceImpact) ?: return null + + val formattedImpact = "(${priceImpact.formatPercents()})" + return formattedImpact.toSpannable(colorSpan(resourceManager.getColor(color))) + } + + private fun getColor(priceImpact: Fraction): Int? { + return thresholdsToColors.firstOrNull { priceImpact > it.first } + ?.second + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/SlippageAlertMixin.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/SlippageAlertMixin.kt new file mode 100644 index 0000000..d7458f6 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/SlippageAlertMixin.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig +import io.novafoundation.nova.feature_swap_impl.R +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class SlippageAlertMixinFactory( + private val resourceManager: ResourceManager +) { + + fun create(slippageConfig: Flow, slippageFlow: Flow): SlippageAlertMixin { + return RealSlippageAlertMixin( + resourceManager, + slippageConfig, + slippageFlow + ) + } +} + +interface SlippageAlertMixin { + + val slippageAlertMessage: Flow +} + +class RealSlippageAlertMixin( + val resourceManager: ResourceManager, + slippageConfig: Flow, + slippageFlow: Flow +) : SlippageAlertMixin { + + override val slippageAlertMessage: Flow = combine(slippageConfig, slippageFlow) { slippageConfig, slippage -> + when { + slippage == null -> null + + slippage !in slippageConfig.minAvailableSlippage..slippageConfig.maxAvailableSlippage -> null + + slippage > slippageConfig.bigSlippage -> { + resourceManager.getString(R.string.swap_slippage_warning_too_big) + } + + slippage < slippageConfig.smallSlippage -> { + resourceManager.getString(R.string.swap_slippage_warning_too_small) + } + + else -> null + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/SwapRateFormatter.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/SwapRateFormatter.kt new file mode 100644 index 0000000..547d336 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/SwapRateFormatter.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common + +import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +class RealSwapRateFormatter : SwapRateFormatter { + + override fun format(rate: BigDecimal, assetIn: Chain.Asset, assetOut: Chain.Asset): String { + val assetInUnitFormatted = BigDecimal.ONE.formatTokenAmount(assetIn) + val rateAmountFormatted = rate.formatTokenAmount(assetOut) + + return "$assetInUnitFormatted ≈ $rateAmountFormatted" + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/details/SwapConfirmationDetailsFormatter.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/details/SwapConfirmationDetailsFormatter.kt new file mode 100644 index 0000000..6849865 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/details/SwapConfirmationDetailsFormatter.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.details + +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.formatting.formatPercents +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_api.domain.model.swapRate +import io.novafoundation.nova.feature_swap_api.domain.model.totalTime +import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter +import io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetView +import io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetsView +import io.novafoundation.nova.feature_swap_impl.presentation.common.PriceImpactFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.details.model.SwapConfirmationDetailsModel +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteFormatter +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +interface SwapConfirmationDetailsFormatter { + + suspend fun format(quote: SwapQuote, slippage: Fraction): SwapConfirmationDetailsModel +} + +class RealSwapConfirmationDetailsFormatter( + private val chainRegistry: ChainRegistry, + private val assetIconProvider: AssetIconProvider, + private val tokenRepository: TokenRepository, + private val swapRouteFormatter: SwapRouteFormatter, + private val swapRateFormatter: SwapRateFormatter, + private val priceImpactFormatter: PriceImpactFormatter, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter +) : SwapConfirmationDetailsFormatter { + + override suspend fun format(quote: SwapQuote, slippage: Fraction): SwapConfirmationDetailsModel { + val assetIn = quote.assetIn + val assetOut = quote.assetOut + val chainIn = chainRegistry.getChain(assetIn.chainId) + val chainOut = chainRegistry.getChain(assetOut.chainId) + + return SwapConfirmationDetailsModel( + assets = SwapAssetsView.Model( + assetIn = formatAssetDetails(chainIn, assetIn, quote.planksIn), + assetOut = formatAssetDetails(chainOut, assetOut, quote.planksOut) + ), + rate = formatRate(quote.swapRate(), assetIn, assetOut), + priceDifference = formatPriceDifference(quote.priceImpact), + slippage = slippage.formatPercents(), + swapRouteModel = swapRouteFormatter.formatSwapRoute(quote), + estimatedExecutionTime = resourceManager.formatDuration(quote.executionEstimate.totalTime(), estimated = true) + ) + } + + private suspend fun formatAssetDetails( + chain: Chain, + chainAsset: Chain.Asset, + amountInPlanks: BigInteger + ): SwapAssetView.Model { + val amount = formatAmount(chainAsset, amountInPlanks) + + return SwapAssetView.Model( + assetIcon = assetIconProvider.getAssetIconOrFallback(chainAsset), + amount = amount, + chainUi = mapChainToUi(chain), + ) + } + + private fun formatRate(rate: BigDecimal, assetIn: Chain.Asset, assetOut: Chain.Asset): String { + return swapRateFormatter.format(rate, assetIn, assetOut) + } + + private fun formatPriceDifference(priceDifference: Fraction): CharSequence? { + return priceImpactFormatter.format(priceDifference) + } + + private suspend fun formatAmount(chainAsset: Chain.Asset, amount: BigInteger): AmountModel { + val token = tokenRepository.getToken(chainAsset) + return amountFormatter.formatAmountToAmountModel(amount, token, AmountConfig(includeZeroFiat = false, estimatedFiat = true)) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/details/model/SwapConfirmationDetailsModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/details/model/SwapConfirmationDetailsModel.kt new file mode 100644 index 0000000..e8d8bfc --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/details/model/SwapConfirmationDetailsModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.details.model + +import io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetsView +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteModel + +class SwapConfirmationDetailsModel( + val assets: SwapAssetsView.Model, + val rate: String, + val priceDifference: CharSequence?, + val slippage: String, + val swapRouteModel: SwapRouteModel?, + val estimatedExecutionTime: String, +) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fee/Factory.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fee/Factory.kt new file mode 100644 index 0000000..96c197d --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fee/Factory.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.fee + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.asFeeContextFromChain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +typealias SwapFeeLoaderMixin = FeeLoaderMixinV2.Presentation + +context(BaseViewModel) +fun FeeLoaderMixinV2.Factory.createForSwap( + chainAssetIn: Flow, + interactor: SwapInteractor, + configuration: FeeLoaderMixinV2.Configuration = FeeLoaderMixinV2.Configuration() +): SwapFeeLoaderMixin { + return create( + scope = viewModelScope, + feeContextFlow = chainAssetIn.asFeeContextFromChain(), + feeFormatter = SwapFeeFormatter(interactor), + feeInspector = SwapFeeInspector(), + configuration = configuration + ) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fee/SwapFeeFormatter.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fee/SwapFeeFormatter.kt new file mode 100644 index 0000000..f778521 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fee/SwapFeeFormatter.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.fee + +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.FeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus + +class SwapFeeFormatter( + private val swapInteractor: SwapInteractor, +) : FeeFormatter { + + override suspend fun formatFee( + fee: SwapFee, + configuration: FeeFormatter.Configuration, + context: FeeFormatter.Context + ): FeeDisplay { + val totalFiatFee = swapInteractor.calculateTotalFiatPrice(fee) + val formattedFiatFee = totalFiatFee.formatAsCurrency() + return FeeDisplay( + title = formattedFiatFee, + subtitle = null + ) + } + + override suspend fun createLoadingStatus(): FeeStatus.Loading { + return FeeStatus.Loading(visibleDuringProgress = true) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fee/SwapFeeInspector.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fee/SwapFeeInspector.kt new file mode 100644 index 0000000..85ce500 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fee/SwapFeeInspector.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.fee + +import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector.InspectedFeeAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class SwapFeeInspector : FeeInspector { + + override fun inspectFeeAmount(fee: SwapFee): InspectedFeeAmount { + return InspectedFeeAmount( + checkedAgainstMinimumBalance = fee.initialSubmissionFee.amountByExecutingAccount, + deductedFromTransferable = fee.maxAmountDeductionFor(fee.initialSubmissionFee.asset) + ) + } + + override fun getSubmissionFeeAsset(fee: SwapFee): Chain.Asset { + return fee.initialSubmissionFee.asset + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fieldValidation/LiquidityFieldValidator.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fieldValidation/LiquidityFieldValidator.kt new file mode 100644 index 0000000..fd0f6a0 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fieldValidation/LiquidityFieldValidator.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.presentation.main.QuotingState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class LiquidityFieldValidatorFactory( + private val resourceManager: ResourceManager +) { + + fun create(quotingStateFlow: Flow): LiquidityFieldValidator { + return LiquidityFieldValidator(resourceManager, quotingStateFlow) + } +} + +class LiquidityFieldValidator( + private val resourceManager: ResourceManager, + private val quotingStateFlow: Flow +) : FieldValidator { + + override fun observe(inputStream: Flow): Flow { + return quotingStateFlow.map { quotingState -> + if (quotingState is QuotingState.Error) { + FieldValidationResult.Error( + resourceManager.getString(R.string.swap_field_validation_not_enough_liquidity) + ) + } else { + FieldValidationResult.Ok + } + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fieldValidation/SlippageFieldValidator.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fieldValidation/SlippageFieldValidator.kt new file mode 100644 index 0000000..e8901a8 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fieldValidation/SlippageFieldValidator.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.percents +import io.novafoundation.nova.common.utils.formatting.formatPercents +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.validation.MapFieldValidator +import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig +import io.novafoundation.nova.feature_swap_impl.R + +class SlippageFieldValidatorFactory(private val resourceManager: ResourceManager) { + + fun create(slippageConfig: SlippageConfig): SlippageFieldValidator { + return SlippageFieldValidator(slippageConfig, resourceManager) + } +} + +class SlippageFieldValidator( + private val slippageConfig: SlippageConfig, + private val resourceManager: ResourceManager +) : MapFieldValidator() { + + override suspend fun validate(input: String): FieldValidationResult { + val value = input.toPercent() + + return when { + input.isEmpty() -> FieldValidationResult.Ok + + value == null -> FieldValidationResult.Ok + + value !in slippageConfig.minAvailableSlippage..slippageConfig.maxAvailableSlippage -> { + FieldValidationResult.Error( + resourceManager.getString( + R.string.swap_slippage_error_not_in_available_range, + slippageConfig.minAvailableSlippage.formatPercents(), + slippageConfig.maxAvailableSlippage.formatPercents() + ) + ) + } + + else -> FieldValidationResult.Ok + } + } + + private fun String.toPercent(): Fraction? { + return toDoubleOrNull()?.percents + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fieldValidation/SwapReceiveAmountAboveEDFieldValidator.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fieldValidation/SwapReceiveAmountAboveEDFieldValidator.kt new file mode 100644 index 0000000..6f33856 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/fieldValidation/SwapReceiveAmountAboveEDFieldValidator.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDeposit +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.map +import java.math.BigDecimal + +class SwapReceiveAmountAboveEDFieldValidatorFactory( + private val resourceManager: ResourceManager, + private val chainRegistry: ChainRegistry, + private val assetSourceRegistry: AssetSourceRegistry +) { + + fun create(assetFlow: Flow): SwapReceiveAmountAboveEDFieldValidator { + return SwapReceiveAmountAboveEDFieldValidator(resourceManager, chainRegistry, assetSourceRegistry, assetFlow) + } +} + +class SwapReceiveAmountAboveEDFieldValidator( + private val resourceManager: ResourceManager, + private val chainRegistry: ChainRegistry, + private val assetSourceRegistry: AssetSourceRegistry, + private val assetFlow: Flow +) : FieldValidator { + + override fun observe(inputStream: Flow): Flow { + return combine(inputStream, assetWithExistentialDeposit()) { input, assetWithExistentialDeposit -> + val amount = input.toBigDecimalOrNull() ?: return@combine FieldValidationResult.Ok + val asset = assetWithExistentialDeposit?.first ?: return@combine FieldValidationResult.Ok + val existentialDeposit = assetWithExistentialDeposit.second + + when { + amount >= BigDecimal.ZERO && asset.balanceCountedTowardsED() + amount < existentialDeposit -> { + val formattedExistentialDeposit = existentialDeposit.formatTokenAmount(asset.token.configuration) + FieldValidationResult.Error( + resourceManager.getString(R.string.swap_field_validation_to_low_amount_out, formattedExistentialDeposit) + ) + } + + else -> FieldValidationResult.Ok + } + } + } + + private fun assetWithExistentialDeposit(): Flow?> { + return assetFlow + .map { asset -> + asset?.let { + val existentialDeposit = assetSourceRegistry.existentialDeposit(asset.token.configuration) + asset to existentialDeposit + } + } + .distinctUntilChangedBy { it?.first?.token?.configuration?.fullId } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/navigation/SwapFlowScope.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/navigation/SwapFlowScope.kt new file mode 100644 index 0000000..df78ada --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/navigation/SwapFlowScope.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.navigation + +import android.util.Log +import io.novafoundation.nova.common.utils.invokeOnCompletion +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlin.coroutines.EmptyCoroutineContext + +class RealSwapFlowScopeAggregator : SwapFlowScopeAggregator { + + private var aggregatedScope: CoroutineScope? = null + private val scopes = mutableSetOf() + + private val lock = Any() + + override fun getFlowScope(screenScope: CoroutineScope): CoroutineScope { + synchronized(lock) { + if (aggregatedScope == null) { + aggregatedScope = CoroutineScope(EmptyCoroutineContext) + } + + scopes.add(screenScope) + + Log.d("Swaps", "Registering new swap screen scope, total count: ${scopes.size}") + } + + screenScope.invokeOnCompletion { + synchronized(lock) { + scopes -= screenScope + + if (scopes.isEmpty()) { + Log.d("Swaps", "Last swap screen scope was cancelled, cancelling flow scope") + + aggregatedScope!!.cancel() + aggregatedScope = null + } else { + Log.d("Swaps", "Swap screen scope was cancelled, remaining count: ${scopes.size}") + } + } + } + + return aggregatedScope!! + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteFormatter.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteFormatter.kt new file mode 100644 index 0000000..618aeaf --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteFormatter.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.route + +import io.novafoundation.nova.common.utils.graph.Path +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedEdge +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainsById + +interface SwapRouteFormatter { + + suspend fun formatSwapRoute(quote: SwapQuote): SwapRouteModel? +} + +class RealSwapRouteFormatter( + private val chainRegistry: ChainRegistry +) : SwapRouteFormatter { + + override suspend fun formatSwapRoute(quote: SwapQuote): SwapRouteModel? { + val routeChainIds = determinePathChains(quote.quotedPath.path) ?: return null + + val allKnownChains = chainRegistry.chainsById() + val chainModels = routeChainIds.map { mapChainToUi(allKnownChains[it]!!) } + + return SwapRouteModel(chainModels) + } + + private fun determinePathChains(path: Path>): List? { + if (path.isEmpty()) return null + + val firstEdge = path.first().edge + val firstChain = firstEdge.from.chainId + + var currentChainId = firstChain + val foundChains = mutableListOf(currentChainId) + + path.forEach { + val nextChainId = it.edge.to.chainId + if (nextChainId != currentChainId) { + currentChainId = nextChainId + foundChains.add(nextChainId) + } + } + + return foundChains.takeIf { foundChains.size >= 2 } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteModel.kt new file mode 100644 index 0000000..95f57b9 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteModel.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.route + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi + +class SwapRouteModel( + val chains: List +) + +typealias SwapRouteState = ExtendedLoadingState diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteTableCellView.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteTableCellView.kt new file mode 100644 index 0000000..e862b5f --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteTableCellView.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.route + +import android.content.Context +import android.util.AttributeSet +import io.novafoundation.nova.common.domain.isError +import io.novafoundation.nova.common.domain.isLoading +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.GenericTableCellView +import io.novafoundation.nova.feature_swap_impl.R + +class SwapRouteTableCellView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, + defStyleRes: Int = 0, +) : GenericTableCellView(context, attrs, defStyle, defStyleRes) { + + init { + setValueView(SwapRouteView(context)) + setTitle(R.string.swap_route) + } + + fun setSwapRouteState(routeState: SwapRouteState) { + setVisible(!routeState.isError) + + showProgress(routeState.isLoading) + + routeState.onLoaded(::setSwapRouteModel) + } + + fun setShowChainNames(showChainNames: Boolean) { + valueView.setShowChainNames(showChainNames) + } + + fun setSwapRouteModel(model: SwapRouteModel?) { + setVisible(model != null) + + model?.let(valueView::setModel) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteView.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteView.kt new file mode 100644 index 0000000..e22ae38 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/route/SwapRouteView.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.route + +import android.content.Context +import android.text.TextUtils +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT +import android.widget.TextView +import androidx.core.view.updateMargins +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.setImageTintRes +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon +import io.novafoundation.nova.feature_swap_impl.R + +private const val SHOW_CHAIN_NAMES_DEFAULT = false + +class SwapRouteView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + private var shouldShowChainNames: Boolean = SHOW_CHAIN_NAMES_DEFAULT + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + + attrs?.let(::applyAttrs) + } + + fun setModel(model: SwapRouteModel) { + removeAllViews() + + addViewsFor(model) + } + + fun setShowChainNames(showChainNames: Boolean) { + shouldShowChainNames = showChainNames + } + + private fun addViewsFor(model: SwapRouteModel) { + model.chains.forEachIndexed { index, chainUi -> + val hasNext = index < model.chains.size - 1 + + addChainIcon(chainUi) + if (shouldShowChainNames) { + addChainName(chainUi) + } + + if (hasNext) { + addArrow() + } + } + } + + private fun addChainIcon(chainUi: ChainUi) { + ImageView(context).apply { + layoutParams = LayoutParams(16.dp, 16.dp) + + loadChainIcon(chainUi.icon, imageLoader) + }.also(::addView) + } + + private fun addChainName(chainUi: ChainUi) { + TextView(context).apply { + layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { + updateMargins(left = 8.dp) + } + + ellipsize = TextUtils.TruncateAt.END + setTextAppearance(R.style.TextAppearance_NovaFoundation_Regular_Footnote) + setTextColorRes(R.color.text_primary) + + text = chainUi.name + }.also(::addView) + } + + private fun addArrow() { + ImageView(context).apply { + layoutParams = LayoutParams(12.dp, 12.dp).apply { + updateMargins(left = 4.dp, right = 4.dp) + } + + setImageResource(R.drawable.ic_arrow_right) + setImageTintRes(R.color.icon_secondary) + }.also(::addView) + } + + private fun applyAttrs(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.SwapRouteView) { + val shouldShowChainNames = it.getBoolean(R.styleable.SwapRouteView_SwapRouteView_displayChainName, SHOW_CHAIN_NAMES_DEFAULT) + setShowChainNames(shouldShowChainNames) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/RealSwapSettingsState.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/RealSwapSettingsState.kt new file mode 100644 index 0000000..e7dda60 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/RealSwapSettingsState.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.state + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettings +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsState +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.flip +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.MutableStateFlow + +class RealSwapSettingsState( + initialValue: SwapSettings, +) : SwapSettingsState { + + override val selectedOption = MutableStateFlow(initialValue) + + override suspend fun setAssetIn(asset: Chain.Asset) { + val current = selectedOption.value + + val newPlanks = current.convertedAmountForNewAssetIn(asset) + val new = current.copy(assetIn = asset, amount = newPlanks) + + selectedOption.value = new + } + + override fun setAssetOut(asset: Chain.Asset) { + val current = selectedOption.value + + val newPlanks = current.convertedAmountForNewAssetOut(asset) + + selectedOption.value = selectedOption.value.copy(assetOut = asset, amount = newPlanks) + } + + override fun setAmount(amount: Balance?, swapDirection: SwapDirection) { + selectedOption.value = selectedOption.value.copy(amount = amount, swapDirection = swapDirection) + } + + override fun setSlippage(slippage: Fraction) { + selectedOption.value = selectedOption.value.copy(slippage = slippage) + } + + override suspend fun flipAssets(): SwapSettings { + val currentSettings = selectedOption.value + + val newSettings = currentSettings.copy( + assetIn = currentSettings.assetOut, + assetOut = currentSettings.assetIn, + swapDirection = currentSettings.swapDirection?.flip() + ) + selectedOption.value = newSettings + + return newSettings + } + + override fun setSwapSettings(swapSettings: SwapSettings) { + selectedOption.value = swapSettings + } + + private fun SwapSettings.convertedAmountForNewAssetIn(newAssetIn: Chain.Asset): Balance? { + val shouldConvertAsset = assetIn != null && amount != null && swapDirection == SwapDirection.SPECIFIED_IN + + return if (shouldConvertAsset) { + val decimalAmount = assetIn!!.amountFromPlanks(amount!!) + newAssetIn.planksFromAmount(decimalAmount) + } else { + amount + } + } + + private fun SwapSettings.convertedAmountForNewAssetOut(newAssetOut: Chain.Asset): Balance? { + val shouldConvertAsset = assetOut != null && amount != null && swapDirection == SwapDirection.SPECIFIED_OUT + + return if (shouldConvertAsset) { + val decimalAmount = assetOut!!.amountFromPlanks(amount!!) + newAssetOut.planksFromAmount(decimalAmount) + } else { + amount + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapSettingsStateProvider.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapSettingsStateProvider.kt new file mode 100644 index 0000000..e26b6e3 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapSettingsStateProvider.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.state + +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_swap_api.presentation.state.DEFAULT_SLIPPAGE +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettings +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +fun SwapSettingsStateProvider.swapSettingsFlow(coroutineScope: CoroutineScope): Flow { + return flowOfAll { + getSwapSettingsState(coroutineScope).selectedOption + } +} + +class RealSwapSettingsStateProvider( + private val computationalCache: ComputationalCache, +) : SwapSettingsStateProvider { + + override suspend fun getSwapSettingsState(coroutineScope: CoroutineScope): RealSwapSettingsState { + return computationalCache.useCache("SwapSettingsState", coroutineScope) { + RealSwapSettingsState(SwapSettings(slippage = DEFAULT_SLIPPAGE)) + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapState.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapState.kt new file mode 100644 index 0000000..cdfbdfe --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapState.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.state + +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote + +class SwapState( + val quote: SwapQuote, + val fee: SwapFee, + val slippage: Fraction, +) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapStateStore.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapStateStore.kt new file mode 100644 index 0000000..650885e --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapStateStore.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.state + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull + +interface SwapStateStore { + + fun setState(state: SwapState) + + fun resetState() + + fun getState(): SwapState? + + fun stateFlow(): Flow +} + +fun SwapStateStore.getStateOrThrow(): SwapState { + return requireNotNull(getState()) { + "Quote was not set" + } +} + +class InMemorySwapStateStore() : SwapStateStore { + + private var swapState = MutableStateFlow(null) + + override fun setState(state: SwapState) { + this.swapState.value = state + } + + override fun resetState() { + swapState.value = null + } + + override fun getState(): SwapState? { + return swapState.value + } + + override fun stateFlow(): Flow { + return swapState.filterNotNull() + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapStateStoreProvider.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapStateStoreProvider.kt new file mode 100644 index 0000000..542a12a --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/state/SwapStateStoreProvider.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.state + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.utils.flowOfAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface SwapStateStoreProvider { + + suspend fun getStore(computationScope: CoroutineScope): SwapStateStore +} + +class RealSwapStateStoreProvider( + private val computationalCache: ComputationalCache +) : SwapStateStoreProvider { + + companion object { + private const val CACHE_TAG = "RealSwapQuoteStoreProvider" + } + + override suspend fun getStore(computationScope: CoroutineScope): SwapStateStore { + return computationalCache.useCache(CACHE_TAG, computationScope) { + InMemorySwapStateStore() + } + } +} + +suspend fun SwapStateStoreProvider.getStateOrThrow(computationScope: CoroutineScope): SwapState { + return getStore(computationScope).getStateOrThrow() +} + +fun SwapStateStoreProvider.stateFlow(computationScope: CoroutineScope): Flow { + return flowOfAll { getStore(computationScope).stateFlow() } +} + +context(BaseViewModel) +suspend fun SwapStateStoreProvider.setState(state: SwapState) { + getStore(viewModelScope).setState(state) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/views/SwapAmountInputView.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/views/SwapAmountInputView.kt new file mode 100644 index 0000000..3b116fc --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/common/views/SwapAmountInputView.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.common.views + +import android.content.Context +import android.util.AttributeSet +import android.widget.EditText +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIconOrMakeGone +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setImageTint +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.validation.getReasonOrNull +import io.novafoundation.nova.common.view.shape.getInputBackground +import io.novafoundation.nova.common.view.shape.getInputBackgroundError +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.databinding.ViewSwapAmountInputBinding +import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixin.SwapInputAssetModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountInputView + +class SwapAmountInputView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), + WithContextExtensions by WithContextExtensions(context), + AmountInputView { + + private val binder = ViewSwapAmountInputBinding.inflate(inflater(), this) + + override val amountInput: EditText + get() = binder.swapAmountInputField + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + binder.swapAmountInputContainer.background = context.getInputBackground() + binder.swapAmountInputContainer.setAddStatesFromChildren(true) + } + + fun setSelectTokenClickListener(listener: OnClickListener) { + binder.swapAmountInputContainer.setOnClickListener(listener) + } + + fun setModel(model: SwapInputAssetModel) { + setAssetIcon(model.assetIcon) + setTitle(model.title) + setSubtitle(model.subtitleIcon, model.subtitle) + binder.swapAmountInputFiat.isVisible = model.showInput + amountInput.isVisible = model.showInput + } + + override fun setFiatAmount(fiat: CharSequence?) { + binder.swapAmountInputFiat.text = fiat + } + + override fun setError(errorState: FieldValidationResult) { + binder.swapAmountInputError.text = errorState.getReasonOrNull() + setErrorEnabled(errorState is FieldValidationResult.Error) + } + + private fun setTitle(title: CharSequence) { + binder.swapAmountInputToken.text = title + } + + private fun setSubtitle(icon: Icon?, subtitle: CharSequence) { + binder.swapAmountInputSubtitleImage.setIconOrMakeGone(icon, imageLoader) + binder.swapAmountInputSubtitle.text = subtitle + } + + private fun setAssetIcon(icon: SwapInputAssetModel.SwapAssetIcon) { + return when (icon) { + is SwapInputAssetModel.SwapAssetIcon.Chosen -> { + binder.swapAmountInputImage.setImageTint(null) + binder.swapAmountInputImage.setTokenIcon(icon.assetIcon, imageLoader) + binder.swapAmountInputImage.setBackgroundResource(R.drawable.bg_token_container) + } + + SwapInputAssetModel.SwapAssetIcon.NotChosen -> { + binder.swapAmountInputImage.setImageTint(context.getColor(R.color.icon_accent)) + binder.swapAmountInputImage.setImageResource(R.drawable.ic_add) + binder.swapAmountInputImage.setBackgroundResource(R.drawable.ic_swap_asset_default_background) + } + } + } + + fun setErrorEnabled(enabled: Boolean) { + binder.swapAmountInputError.isVisible = enabled + if (enabled) { + amountInput.setTextColor(context.getColor(R.color.text_negative)) + binder.swapAmountInputContainer.background = context.getInputBackgroundError() + } else { + amountInput.setTextColor(context.getColor(R.color.text_primary)) + binder.swapAmountInputContainer.background = context.getInputBackground() + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/SwapConfirmationFragment.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/SwapConfirmationFragment.kt new file mode 100644 index 0000000..7b4fad1 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/SwapConfirmationFragment.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.confirmation + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription +import io.novafoundation.nova.common.view.setMessageOrHide +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_swap_impl.databinding.FragmentSwapConfirmationBinding +import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading + +class SwapConfirmationFragment : BaseFragment() { + + override fun createBinding() = FragmentSwapConfirmationBinding.inflate(layoutInflater) + + override fun initViews() { + binder.swapConfirmationToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.swapConfirmationButton.prepareForProgress(this) + + binder.swapConfirmationRate.setOnClickListener { viewModel.rateClicked() } + binder.swapConfirmationPriceDifference.setOnClickListener { viewModel.priceDifferenceClicked() } + binder.swapConfirmationSlippage.setOnClickListener { viewModel.slippageClicked() } + binder.swapConfirmationNetworkFee.setOnClickListener { viewModel.networkFeeClicked() } + binder.swapConfirmationAccount.setOnClickListener { viewModel.accountClicked() } + binder.swapConfirmationButton.setOnClickListener { viewModel.confirmButtonClicked() } + binder.swapConfirmationRoute.setOnClickListener { viewModel.routeClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SwapFeatureApi::class.java + ) + .swapConfirmation() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SwapConfirmationViewModel) { + observeValidations(viewModel) + setupExternalActions(viewModel) + observeDescription(viewModel) + + viewModel.feeMixin.setupFeeLoading(binder.swapConfirmationNetworkFee) + + viewModel.swapDetails.observe { + binder.swapConfirmationAssets.setModel(it.assets) + binder.swapConfirmationRate.showValue(it.rate) + binder.swapConfirmationPriceDifference.showValueOrHide(it.priceDifference) + binder.swapConfirmationSlippage.showValue(it.slippage) + binder.swapConfirmationRoute.setSwapRouteModel(it.swapRouteModel) + binder.swapConfirmationExecutionTime.showValue(it.estimatedExecutionTime) + } + + viewModel.wallet.observe { binder.swapConfirmationWallet.showWallet(it) } + viewModel.addressFlow.observe { binder.swapConfirmationAccount.showAddress(it) } + + viewModel.slippageAlertMixin.slippageAlertMessage.observe { binder.swapConfirmationAlert.setMessageOrHide(it) } + + viewModel.validationInProgress.observe(binder.swapConfirmationButton::setProgressState) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/SwapConfirmationViewModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/SwapConfirmationViewModel.kt new file mode 100644 index 0000000..99cd0d8 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/SwapConfirmationViewModel.kt @@ -0,0 +1,397 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.confirmation + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_api.domain.model.SwapOperationSubmissionException +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs +import io.novafoundation.nova.feature_swap_api.domain.model.toExecuteArgs +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchPriceDifferenceDescription +import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSlippageDescription +import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSwapRateDescription +import io.novafoundation.nova.feature_swap_core_api.data.paths.model.quotedAmount +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_swap_impl.domain.validation.toSwapState +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.fee.createForSwap +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.getStateOrThrow +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.setState +import io.novafoundation.nova.feature_swap_impl.presentation.main.mapSwapValidationFailureToUI +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.create +import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +private data class SwapConfirmationState( + val swapQuoteArgs: SwapQuoteArgs, + val swapQuote: SwapQuote, +) + +enum class MaxAction { + ACTIVE, + DISABLED +} + +class SwapConfirmationViewModel( + private val swapRouter: SwapRouter, + private val swapInteractor: SwapInteractor, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val walletUiUseCase: WalletUiUseCase, + private val slippageAlertMixinFactory: SlippageAlertMixinFactory, + private val addressIconGenerator: AddressIconGenerator, + private val validationExecutor: ValidationExecutor, + private val tokenRepository: TokenRepository, + private val externalActions: ExternalActions.Presentation, + private val swapStateStoreProvider: SwapStateStoreProvider, + private val feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + private val arbitraryAssetUseCase: ArbitraryAssetUseCase, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val swapConfirmationDetailsFormatter: SwapConfirmationDetailsFormatter, + private val resourceManager: ResourceManager, + private val swapFlowScopeAggregator: SwapFlowScopeAggregator, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper +) : BaseViewModel(), + ExternalActions by externalActions, + Validatable by validationExecutor, + DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val swapFlowScope = swapFlowScopeAggregator.getFlowScope(viewModelScope) + + private val confirmationStateFlow = singleReplaySharedFlow() + + private val metaAccountFlow = accountRepository.selectedMetaAccountFlow() + .shareInBackground() + + private val slippageConfigFlow = confirmationStateFlow + .mapNotNull { swapInteractor.slippageConfig(it.swapQuote.assetIn.chainId) } + .shareInBackground() + + private val initialSwapState = flowOf { swapStateStoreProvider.getStateOrThrow(swapFlowScope) } + + private val slippageFlow = initialSwapState.map { it.slippage } + .shareInBackground() + + val slippageAlertMixin = slippageAlertMixinFactory.create(slippageConfigFlow, slippageFlow) + + private val chainIn = initialSwapState.map { + chainRegistry.getChain(it.quote.assetIn.chainId) + } + .shareInBackground() + + private val assetInFlow = initialSwapState.flatMapLatest { + arbitraryAssetUseCase.assetFlow(it.quote.assetIn) + } + .shareInBackground() + + private val assetOutFlow = initialSwapState.flatMapLatest { + arbitraryAssetUseCase.assetFlow(it.quote.assetOut) + } + .shareInBackground() + + private val maxActionFlow = MutableStateFlow(MaxAction.DISABLED) + + val feeMixin = feeLoaderMixinFactory.createForSwap( + chainAssetIn = initialSwapState.map { it.quote.assetIn }, + interactor = swapInteractor + ) + + private val maxActionProvider = createMaxActionProvider() + + private val _validationInProgress = MutableStateFlow(false) + + val validationInProgress = _validationInProgress + + val swapDetails = confirmationStateFlow.map { + swapConfirmationDetailsFormatter.format(it.swapQuote, slippageFlow.first()) + } + + val wallet: Flow = walletUiUseCase.selectedWalletUiFlow() + + val addressFlow: Flow = combine(chainIn, metaAccountFlow) { chainId, metaAccount -> + addressIconGenerator.createAccountAddressModel(chainId, metaAccount) + } + + init { + initConfirmationState() + + handleMaxClick() + } + + fun backClicked() { + swapRouter.back() + } + + fun rateClicked() { + launchSwapRateDescription() + } + + fun priceDifferenceClicked() { + launchPriceDifferenceDescription() + } + + fun slippageClicked() { + launchSlippageDescription() + } + + fun networkFeeClicked() = setSwapStateAndThen { + swapRouter.openSwapFee() + } + + fun routeClicked() = setSwapStateAfter { + swapRouter.openSwapRoute() + } + + fun accountClicked() { + launch { + val chainIn = chainIn.first() + val addressModel = addressFlow.first() + + externalActions.showAddressActions(addressModel.address, chainIn) + } + } + + fun confirmButtonClicked() { + launch { + _validationInProgress.value = true + + val validationSystem = swapInteractor.validationSystem() + val payload = getValidationPayload() + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + progressConsumer = _validationInProgress.progressConsumer(), + validationFailureTransformerCustom = ::formatValidationFailure, + block = ::executeSwap + ) + } + } + + private fun setSwapStateAndThen(action: () -> Unit) { + launch { + updateSwapStateInStore() + + action() + } + } + + private fun setSwapStateAfter(action: () -> Unit) { + launch { + val store = swapStateStoreProvider.getStore(swapFlowScope) + store.resetState() + + action() + + updateSwapStateInStore() + } + } + + private suspend fun updateSwapStateInStore() { + swapStateStoreProvider.setState(getValidationPayload().toSwapState()) + } + + private fun createMaxActionProvider(): MaxActionProvider { + return maxActionProviderFactory.create( + viewModelScope = viewModelScope, + assetInFlow = assetInFlow, + feeLoaderMixin = feeMixin, + ) + } + + private fun executeSwap(validPayload: SwapValidationPayload) = launchUnit { + if (swapInteractor.isDeepSwapAvailable()) { + swapStateStoreProvider.setState(validPayload.toSwapState()) + + swapRouter.openSwapExecution() + } else { + executeFirstSwapStep(validPayload.fee) + } + } + + private suspend fun executeFirstSwapStep(fee: SwapFee) { + swapInteractor.submitFirstSwapStep(fee) + .onSuccess { + _validationInProgress.value = false + + this.showToast(resourceManager.getString(R.string.common_transaction_submitted)) + + startNavigation(it.submissionHierarchy) { + val asset = assetOutFlow.first() + swapRouter.openBalanceDetails(asset.token.configuration.toAssetPayload()) + } + }.onFailure { + _validationInProgress.value = false + + showFirstSwapStepFailure(it) + } + } + + private fun showFirstSwapStepFailure(error: Throwable) { + if (error !is SwapOperationSubmissionException) { + showError(resourceManager.getString(R.string.common_undefined_error_message)) + return + } + + when (error) { + is SwapOperationSubmissionException.SimulationFailed -> showError( + title = resourceManager.getString(R.string.common_dry_run_failed_title), + text = resourceManager.getText(R.string.common_dry_run_failed_message) + ) + } + } + + private suspend fun getValidationPayload(): SwapValidationPayload { + val confirmationState = confirmationStateFlow.first() + val swapFee = feeMixin.awaitFee() + + return SwapValidationPayload( + swapQuote = confirmationState.swapQuote, + fee = swapFee, + slippage = slippageFlow.first() + ) + } + + private fun formatValidationFailure( + status: ValidationStatus.NotValid, + actions: ValidationFlowActions + ): TransformedFailure { + return mapSwapValidationFailureToUI( + resourceManager, + status, + actions, + amountInSwapMaxAction = ::setMaxAmountIn, + amountOutSwapMinAction = { _, amount -> setMinAmountOut(amount) } + ) + } + + private fun setMaxAmountIn() { + launch { + maxActionFlow.value = MaxAction.ACTIVE + } + } + + private fun setMinAmountOut(amount: Balance) = launchUnit { + maxActionFlow.value = MaxAction.DISABLED + + val confirmationState = confirmationStateFlow.first() + + calculateQuote( + confirmationState.swapQuoteArgs.copy( + amount = amount, + swapDirection = SwapDirection.SPECIFIED_OUT + ) + ) + } + + private fun calculateQuote(newSwapQuoteArgs: SwapQuoteArgs) { + launch { + val confirmationState = confirmationStateFlow.first() + val swapQuote = swapInteractor.quote(newSwapQuoteArgs, swapFlowScope) + .onFailure { } + .getOrNull() ?: return@launch + + feeMixin.loadFee { feePaymentCurrency -> + val executeArgs = swapQuote.toExecuteArgs( + slippage = slippageFlow.first(), + firstSegmentFees = feePaymentCurrency + ) + + swapInteractor.estimateFee(executeArgs) + } + + val newState = confirmationState.copy(swapQuoteArgs = newSwapQuoteArgs, swapQuote = swapQuote) + confirmationStateFlow.emit(newState) + } + } + + private fun initConfirmationState() { + launch { + val swapState = initialSwapState.first() + + val swapQuote = swapState.quote + + val assetIn = swapQuote.assetIn + val assetOut = swapQuote.assetOut + + val quoteArgs = SwapQuoteArgs( + tokenIn = tokenRepository.getToken(assetIn), + tokenOut = tokenRepository.getToken(assetOut), + amount = swapQuote.quotedPath.quotedAmount, + swapDirection = swapQuote.quotedPath.direction, + ) + + feeMixin.setFee(swapState.fee) + + val newState = SwapConfirmationState(quoteArgs, swapQuote) + confirmationStateFlow.emit(newState) + } + } + + private fun handleMaxClick() { + combineToPair(maxActionFlow, maxActionProvider.maxAvailableBalance) + .filter { (maxAction, _) -> maxAction == MaxAction.ACTIVE } + .mapNotNull { it.second.actualBalance } + .distinctUntilChanged() + .onEach { + val confirmationState = confirmationStateFlow.first() + calculateQuote( + confirmationState.swapQuoteArgs.copy( + amount = it, + swapDirection = SwapDirection.SPECIFIED_IN + ) + ) + } + .launchIn(viewModelScope) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/di/SwapConfirmationComponent.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/di/SwapConfirmationComponent.kt new file mode 100644 index 0000000..49bed22 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/di/SwapConfirmationComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.confirmation.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_swap_impl.presentation.confirmation.SwapConfirmationFragment + +@Subcomponent( + modules = [ + SwapConfirmationModule::class + ] +) +@ScreenScope +interface SwapConfirmationComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): SwapConfirmationComponent + } + + fun inject(fragment: SwapConfirmationFragment) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/di/SwapConfirmationModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/di/SwapConfirmationModule.kt new file mode 100644 index 0000000..215e4c5 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/confirmation/di/SwapConfirmationModule.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.confirmation.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.confirmation.SwapConfirmationViewModel +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SwapConfirmationModule { + + @Provides + @IntoMap + @ViewModelKey(SwapConfirmationViewModel::class) + fun provideViewModel( + swapRouter: SwapRouter, + swapInteractor: SwapInteractor, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + walletUiUseCase: WalletUiUseCase, + slippageAlertMixinFactory: SlippageAlertMixinFactory, + addressIconGenerator: AddressIconGenerator, + validationExecutor: ValidationExecutor, + tokenRepository: TokenRepository, + externalActions: ExternalActions.Presentation, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + assetUseCase: ArbitraryAssetUseCase, + maxActionProviderFactory: MaxActionProviderFactory, + swapStateStoreProvider: SwapStateStoreProvider, + confirmationDetailsFormatter: SwapConfirmationDetailsFormatter, + resourceManager: ResourceManager, + swapFlowScopeAggregator: SwapFlowScopeAggregator, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper + ): ViewModel { + return SwapConfirmationViewModel( + swapRouter = swapRouter, + swapInteractor = swapInteractor, + accountRepository = accountRepository, + chainRegistry = chainRegistry, + walletUiUseCase = walletUiUseCase, + slippageAlertMixinFactory = slippageAlertMixinFactory, + addressIconGenerator = addressIconGenerator, + validationExecutor = validationExecutor, + tokenRepository = tokenRepository, + externalActions = externalActions, + swapStateStoreProvider = swapStateStoreProvider, + feeLoaderMixinFactory = feeLoaderMixinFactory, + descriptionBottomSheetLauncher = descriptionBottomSheetLauncher, + arbitraryAssetUseCase = assetUseCase, + maxActionProviderFactory = maxActionProviderFactory, + swapConfirmationDetailsFormatter = confirmationDetailsFormatter, + resourceManager = resourceManager, + swapFlowScopeAggregator = swapFlowScopeAggregator, + extrinsicNavigationWrapper = extrinsicNavigationWrapper + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SwapConfirmationViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SwapConfirmationViewModel::class.java) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/ExecutionTimerView.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/ExecutionTimerView.kt new file mode 100644 index 0000000..1520728 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/ExecutionTimerView.kt @@ -0,0 +1,221 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.execution + +import android.content.Context +import android.os.CountDownTimer +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.view.animation.BaseInterpolator +import android.view.animation.DecelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.view.animation.RotateAnimation +import android.widget.TextSwitcher +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.databinding.ViewExecutionTimerBinding +import kotlin.math.cos +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private const val SECOND_MILLIS = 1000L +private const val HIDE_SCALE = 0.7f + +class ExecutionTimerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) { + + sealed interface State { + + object Success : State + + object Error : State + + class CountdownTimer(val duration: Duration) : State + } + + private val binder = ViewExecutionTimerBinding.inflate(inflater(), this) + + private var currentState: State? = null + + private val slideTopInAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_slide_bottom_in) + private val slideTopOutAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_slide_bottom_out) + + private var currentTimer: CountDownTimer? = null + + init { + setupTimerSwitcher() + } + + fun setState(state: State) { + currentState = state + + currentTimer?.cancel() + + when (state) { + State.Success -> { + hideTimerWithAnimation() + binder.executionResult.setImageResource(R.drawable.ic_execution_result_success) + binder.executionResult.fadeInWithScale() + } + + State.Error -> { + hideTimerWithAnimation() + binder.executionResult.setImageResource(R.drawable.ic_execution_result_error) + binder.executionResult.fadeInWithScale() + } + + is State.CountdownTimer -> { + binder.executionResult.fadeOutWithScale() + showTimerWithAnimation() + + binder.executionProgress.runInfinityRotationAnimation() + + // We add delay to match progress animation perfectly + // Text should be switched in the middle of a progress animation with small offset + val middleOfAnimation = SECOND_MILLIS / 2 + val switchAnimationOffset = slideTopOutAnimation.duration / 2 + val delay = middleOfAnimation - switchAnimationOffset + currentTimer = CountdownSwitcherTimer(binder.executionTimeSwitcher, state.duration) + runTimerWithDelay(delay, currentTimer!!) + } + } + } + + private fun hideTimerWithAnimation() { + binder.executionProgress.fadeOut() + binder.executionTimeSwitcher.fadeOutWithScale() + binder.executionTimeSeconds.fadeOutWithScale() + } + + private fun showTimerWithAnimation() { + binder.executionProgress.fadeIn() + binder.executionTimeSwitcher.fadeInWithScale() + binder.executionTimeSeconds.fadeInWithScale() + } + + private fun runTimerWithDelay(delay: Long, timer: CountDownTimer) { + postDelayed({ timer.start() }, delay) + } + + private fun setupTimerSwitcher() { + binder.executionTimeSwitcher.setFactory { + val textView = TextView(context, null, 0, R.style.TextAppearance_NovaFoundation_Bold_Title3) + textView.setGravity(Gravity.CENTER) + textView.setTextColorRes(R.color.text_primary) + textView.includeFontPadding = false + textView + } + + binder.executionTimeSwitcher.inAnimation = slideTopInAnimation + binder.executionTimeSwitcher.outAnimation = slideTopOutAnimation + } + + private fun View.runInfinityRotationAnimation() { + val anim = RotateAnimation( + 0f, + -360f, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ) + + anim.duration = SECOND_MILLIS + anim.repeatCount = Animation.INFINITE + anim.interpolator = StartSpeedAccelerateDecelerateInterpolator() + startAnimation(anim) + } + + private fun View.fadeOut() { + animate() + .alpha(0f) + .setDuration(400) + .withEndAction { makeGone() } + .setInterpolator(DecelerateInterpolator()) + .start() + } + + private fun View.fadeIn() { + alpha = 0f + makeVisible() + animate() + .alpha(1f) + .setDuration(400) + .setInterpolator(DecelerateInterpolator()) + .start() + } + + private fun View.fadeOutWithScale() { + animate() + .alpha(0f) + .scaleX(HIDE_SCALE) + .scaleY(HIDE_SCALE) + .setDuration(400) + .withEndAction { makeGone() } + .setInterpolator(DecelerateInterpolator()) + .start() + } + + private fun View.fadeInWithScale() { + scaleX = HIDE_SCALE + scaleY = HIDE_SCALE + alpha = 0f + makeVisible() + animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(400) + .setInterpolator(OvershootInterpolator()) + .start() + } +} + +private class StartSpeedAccelerateDecelerateInterpolator : BaseInterpolator() { + + override fun getInterpolation(input: Float): Float { + val speed = 0.085 // A constant + val result = input + cos((input * 2 * Math.PI) + Math.PI / 2) * speed + return result.toFloat() + } +} + +private class CountdownSwitcherTimer(val switcher: TextSwitcher, duration: Duration) : + CountDownTimer( + duration.inWholeMilliseconds + SECOND_MILLIS, // Add a seconds to show max value to user + SECOND_MILLIS + ) { + + init { + switcher.setText(duration.inWholeSeconds.toString()) + } + + override fun onTick(millisUntilFinished: Long) { + val duration = millisUntilFinished.milliseconds + val seconds = duration.inWholeSeconds.toString() + + if (shouldPlayAnimation(seconds)) { + switcher.setText(seconds) + } + } + + override fun onFinish() { + // Nothing to do + } + + private fun shouldPlayAnimation(seconds: String): Boolean { + val currentTextView = switcher.currentView as? TextView ?: return true + + return currentTextView.text != seconds + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionFragment.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionFragment.kt new file mode 100644 index 0000000..da85e2b --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionFragment.kt @@ -0,0 +1,152 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.execution + +import android.text.TextUtils +import android.view.Gravity +import android.view.animation.AnimationUtils +import android.widget.TextSwitcher +import android.widget.TextView +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setCurrentText +import io.novafoundation.nova.common.utils.setText +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.databinding.FragmentSwapExecutionBinding +import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent +import io.novafoundation.nova.feature_swap_impl.presentation.execution.model.SwapProgressModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading + +class SwapExecutionFragment : BaseFragment() { + + override fun createBinding() = FragmentSwapExecutionBinding.inflate(layoutInflater) + + override fun initViews() { + binder.swapExecutionRate.setOnClickListener { viewModel.rateClicked() } + binder.swapExecutionPriceDifference.setOnClickListener { viewModel.priceDifferenceClicked() } + binder.swapExecutionSlippage.setOnClickListener { viewModel.slippageClicked() } + binder.swapExecutionNetworkFee.setOnClickListener { viewModel.networkFeeClicked() } + binder.swapExecutionRoute.setOnClickListener { viewModel.routeClicked() } + + binder.swapExecutionDetails.collapseImmediate() + + onBackPressed { /* suppress back presses */ } + + binder.swapExecutionTitleSwitcher.applyTitleFactory() + binder.swapExecutionSubtitleSwitcher.applySubtitleFactory() + + binder.swapExecutionTitleSwitcher.applyAnimators() + binder.swapExecutionSubtitleSwitcher.applyAnimators() + + binder.swapExecutionToolbar.setHomeButtonVisibility(false) + } + + override fun inject() { + FeatureUtils.getFeature(requireContext(), SwapFeatureApi::class.java) + .swapExecution() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SwapExecutionViewModel) { + observeDescription(viewModel) + + viewModel.swapProgressModel.observe(::setSwapProgress) + + viewModel.feeMixin.setupFeeLoading(binder.swapExecutionNetworkFee) + + viewModel.confirmationDetailsFlow.observe { + binder.swapExecutionAssets.setModel(it.assets) + binder.swapExecutionRate.showValue(it.rate) + binder.swapExecutionPriceDifference.showValueOrHide(it.priceDifference) + binder.swapExecutionSlippage.showValue(it.slippage) + binder.swapExecutionRoute.setSwapRouteModel(it.swapRouteModel) + } + } + + private fun setSwapProgress(model: SwapProgressModel) { + when (model) { + is SwapProgressModel.Completed -> setSwapCompleted(model) + is SwapProgressModel.Failed -> setSwapFailed(model) + is SwapProgressModel.InProgress -> setSwapInProgress(model) + } + } + + private fun setSwapCompleted(model: SwapProgressModel.Completed) { + binder.swapExecutionTimer.setState(ExecutionTimerView.State.Success) + + binder.swapExecutionTitleSwitcher.setText(getString(R.string.common_completed), colorRes = R.color.text_positive) + binder.swapExecutionSubtitleSwitcher.setText(model.at, colorRes = R.color.text_secondary) + + binder.swapExecutionStepLabel.text = model.operationsLabel + binder.swapExecutionStepLabel.setTextColorRes(R.color.text_secondary) + + binder.swapExecutionStepShimmer.hideShimmer() + binder.swapExecutionStepContainer.background = requireContext().getBlockDrawable() + + binder.swapExecutionActionButton.makeVisible() + binder.swapExecutionActionButton.setText(R.string.common_done) + binder.swapExecutionActionButton.setOnClickListener { viewModel.doneClicked() } + } + + private fun setSwapFailed(model: SwapProgressModel.Failed) { + binder.swapExecutionTimer.setState(ExecutionTimerView.State.Error) + + binder.swapExecutionTitleSwitcher.setText(getString(R.string.common_failed), colorRes = R.color.text_negative) + binder.swapExecutionSubtitleSwitcher.setText(model.at, colorRes = R.color.text_secondary) + + binder.swapExecutionStepLabel.text = model.reason + binder.swapExecutionStepLabel.setTextColorRes(R.color.text_primary) + + binder.swapExecutionStepShimmer.hideShimmer() + binder.swapExecutionStepContainer.background = requireContext().getRoundedCornerDrawable(R.color.error_block_background) + + binder.swapExecutionActionButton.makeVisible() + binder.swapExecutionActionButton.setText(R.string.common_try_again) + binder.swapExecutionActionButton.setOnClickListener { viewModel.retryClicked() } + } + + private fun setSwapInProgress(model: SwapProgressModel.InProgress) { + binder.swapExecutionTimer.setState(ExecutionTimerView.State.CountdownTimer(model.remainingTime)) + + binder.swapExecutionTitleSwitcher.setCurrentText(getString(R.string.common_do_not_close_app), colorRes = R.color.text_primary) + binder.swapExecutionSubtitleSwitcher.setCurrentText(model.stepDescription, colorRes = R.color.button_text_accent) + + binder.swapExecutionStepLabel.text = model.operationsLabel + binder.swapExecutionStepLabel.setTextColorRes(R.color.text_secondary) + + binder.swapExecutionStepShimmer.showShimmer(true) + binder.swapExecutionStepContainer.background = requireContext().getBlockDrawable() + + binder.swapExecutionActionButton.makeGone() + } + + private fun TextSwitcher.applyTitleFactory() { + setFactory { + val textView = TextView(context, null, 0, R.style.TextAppearance_NovaFoundation_Bold_Title1) + textView.setGravity(Gravity.CENTER) + textView + } + } + + private fun TextSwitcher.applySubtitleFactory() { + setFactory { + val textView = TextView(context, null, 0, R.style.TextAppearance_NovaFoundation_SemiBold_Body) + textView.setGravity(Gravity.CENTER) + textView.setSingleLine() + textView.ellipsize = TextUtils.TruncateAt.END + textView + } + } + + private fun TextSwitcher.applyAnimators() { + inAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in) + outAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionViewModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionViewModel.kt new file mode 100644 index 0000000..f352ee9 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/SwapExecutionViewModel.kt @@ -0,0 +1,275 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.execution + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.SwapOperationSubmissionException +import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgress +import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgressStep +import io.novafoundation.nova.feature_swap_api.domain.model.quotedAmount +import io.novafoundation.nova.feature_swap_api.domain.model.remainingTimeWhenExecuting +import io.novafoundation.nova.feature_swap_api.domain.model.swapDirection +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_swap_api.presentation.model.toParcel +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchPriceDifferenceDescription +import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSlippageDescription +import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSwapRateDescription +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.fee.createForSwap +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapState +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.getStateOrThrow +import io.novafoundation.nova.feature_swap_impl.presentation.execution.model.SwapProgressModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +class SwapExecutionViewModel( + private val swapStateStoreProvider: SwapStateStoreProvider, + private val swapInteractor: SwapInteractor, + private val resourceManager: ResourceManager, + private val router: SwapRouter, + private val chainRegistry: ChainRegistry, + private val feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + private val confirmationDetailsFormatter: SwapConfirmationDetailsFormatter, + private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + private val swapFlowScopeAggregator: SwapFlowScopeAggregator, + private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, +) : BaseViewModel(), + DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher, + ExtrinsicNavigationWrapper by extrinsicNavigationWrapper { + + private val swapFlowScope = swapFlowScopeAggregator.getFlowScope(viewModelScope) + + private val swapStateFlow = flowOf { swapStateStoreProvider.getStateOrThrow(swapFlowScope) } + + private val swapProgressFlow = singleReplaySharedFlow() + + private val totalSteps = swapStateFlow.map { it.fee.segments.size } + + val swapProgressModel = combine(swapStateFlow, swapProgressFlow) { swapState, swapProgress -> + swapProgress.toUi(swapState) + }.shareInBackground() + + val feeMixin = feeLoaderMixinFactory.createForSwap( + chainAssetIn = swapStateFlow.map { it.quote.assetIn }, + interactor = swapInteractor + ) + + val confirmationDetailsFlow = swapStateFlow.map { + confirmationDetailsFormatter.format(it.quote, it.slippage) + }.shareInBackground() + + init { + setFee() + + executeSwap() + } + + fun rateClicked() { + launchSwapRateDescription() + } + + fun priceDifferenceClicked() { + launchPriceDifferenceDescription() + } + + fun slippageClicked() { + launchSlippageDescription() + } + + fun networkFeeClicked() { + router.openSwapFee() + } + + fun routeClicked() { + router.openSwapRoute() + } + + fun retryClicked() = launchUnit { + val swapFailure = swapProgressFlow.first() as? SwapProgress.Failure ?: return@launchUnit + val failedStep = swapFailure.attemptedStep + + val retrySwapPayload = retrySwapPayload(failedStep) + + router.openRetrySwap(retrySwapPayload) + } + + fun doneClicked() = launchUnit { + val assetOut = swapStateFlow.first().quote.assetOut.fullId.toAssetPayload() + router.openBalanceDetails(assetOut) + } + + private fun retrySwapPayload(failedStep: SwapProgressStep): SwapSettingsPayload { + val failedOperation = failedStep.operation + + return SwapSettingsPayload.RepeatOperation( + assetIn = failedOperation.assetIn.toAssetPayload(), + assetOut = failedOperation.assetOut.toAssetPayload(), + amount = failedOperation.estimatedSwapLimit.quotedAmount, + direction = failedOperation.estimatedSwapLimit.swapDirection.toParcel(), + ) + } + + private fun setFee() = launchUnit { + feeMixin.setFee(swapStateFlow.first().fee) + } + + private fun executeSwap() = launchUnit { + val fee = swapStateFlow.first().fee + + swapInteractor.executeSwap(fee) + .onEach(swapProgressFlow::emit) + .inBackground() + .collect() + } + + private suspend fun SwapProgress.toUi(swapState: SwapState): SwapProgressModel { + return when (this) { + is SwapProgress.Done -> createCompletedStatus() + is SwapProgress.Failure -> toUi() + is SwapProgress.StepStarted -> toUi(swapState) + } + } + + private suspend fun SwapProgress.StepStarted.toUi(swapState: SwapState): SwapProgressModel.InProgress { + val stepDescription = step.displayData.createInProgressLabel() + val remainingExecutionTime = swapState.quote.executionEstimate.remainingTimeWhenExecuting(step.index) + + return SwapProgressModel.InProgress( + stepDescription = stepDescription, + remainingTime = remainingExecutionTime, + operationsLabel = swapState.inProgressLabelForStep(step.index) + ) + } + + private fun SwapState.inProgressLabelForStep(stepIndex: Int): String { + val totalSteps = fee.segments.size + val currentStepNumber = stepIndex + 1 + return resourceManager.getString(R.string.swap_execution_operations_progress, currentStepNumber, totalSteps) + } + + private suspend fun createCompletedStatus(): SwapProgressModel.Completed { + val totalSteps = totalSteps.first() + val stepsLabel = resourceManager.getQuantityString(R.plurals.swap_execution_operations_completed, totalSteps, totalSteps) + + return SwapProgressModel.Completed(at = createAtLabel(), operationsLabel = stepsLabel) + } + + private fun createAtLabel(): String { + val currentTime = System.currentTimeMillis() + return resourceManager.formatDateTime(currentTime) + } + + private suspend fun SwapProgress.Failure.toUi(): SwapProgressModel.Failed { + return SwapProgressModel.Failed( + reason = createSwapFailureMessage(), + at = createAtLabel() + ) + } + + private suspend fun SwapProgress.Failure.createSwapFailureMessage(): String { + val failedStepNumber = attemptedStep.index + 1 + val label = attemptedStep.displayData.createErrorLabel() + + val genericErrorMessage = resourceManager.getString( + R.string.swap_execution_failure, + failedStepNumber.format(), + label + ) + + val errorFormatted = formatThrowable() + + return if (errorFormatted != null) { + "$genericErrorMessage: $errorFormatted" + } else { + genericErrorMessage + } + } + + private fun SwapProgress.Failure.formatThrowable(): String? { + if (error !is SwapOperationSubmissionException) return null + + // For some reason smart-cast does not work here + return when (error as SwapOperationSubmissionException) { + is SwapOperationSubmissionException.SimulationFailed -> resourceManager.getString(R.string.swap_dry_run_failed_inline_message) + } + } + + private suspend fun AtomicOperationDisplayData.createErrorLabel(): String { + return when (this) { + is AtomicOperationDisplayData.Swap -> { + val fromAsset = chainRegistry.asset(from.chainAssetId) + val toAsset = chainRegistry.asset(to.chainAssetId) + val on = chainRegistry.getChain(fromAsset.chainId) + + resourceManager.getString( + R.string.swap_execution_failure_swap_label, + fromAsset.symbol.value, + toAsset.symbol.value, + on.name + ) + } + + is AtomicOperationDisplayData.Transfer -> { + val (chainFrom, assetFrom) = chainRegistry.chainWithAsset(from) + val chainTo = chainRegistry.getChain(to.chainId) + + resourceManager.getString( + R.string.swap_execution_failure_transfer_label, + assetFrom.symbol.value, + chainFrom.name, + chainTo.name, + ) + } + } + } + + private suspend fun AtomicOperationDisplayData.createInProgressLabel(): String { + return when (this) { + is AtomicOperationDisplayData.Swap -> { + val fromAsset = chainRegistry.asset(from.chainAssetId) + val toAsset = chainRegistry.asset(to.chainAssetId) + val on = chainRegistry.getChain(fromAsset.chainId) + + resourceManager.getString( + R.string.swap_execution_progress_swap_label, + fromAsset.symbol.value, + toAsset.symbol.value, + on.name + ) + } + + is AtomicOperationDisplayData.Transfer -> { + val (_, assetFrom) = chainRegistry.chainWithAsset(from) + val chainTo = chainRegistry.getChain(to.chainId) + + resourceManager.getString( + R.string.swap_execution_progress_transfer_label, + assetFrom.symbol.value, + chainTo.name + ) + } + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/di/SwapExecutionComponent.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/di/SwapExecutionComponent.kt new file mode 100644 index 0000000..d71e9c9 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/di/SwapExecutionComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.execution.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_swap_impl.presentation.execution.SwapExecutionFragment + +@Subcomponent( + modules = [ + SwapExecutionModule::class + ] +) +@ScreenScope +interface SwapExecutionComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): SwapExecutionComponent + } + + fun inject(fragment: SwapExecutionFragment) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/di/SwapExecutionModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/di/SwapExecutionModule.kt new file mode 100644 index 0000000..d7661c2 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/di/SwapExecutionModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.execution.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.execution.SwapExecutionViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SwapExecutionModule { + + @Provides + @IntoMap + @ViewModelKey(SwapExecutionViewModel::class) + fun provideViewModel( + swapStateStoreProvider: SwapStateStoreProvider, + swapInteractor: SwapInteractor, + resourceManager: ResourceManager, + router: SwapRouter, + chainRegistry: ChainRegistry, + confirmationDetailsFormatter: SwapConfirmationDetailsFormatter, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + swapFlowScopeAggregator: SwapFlowScopeAggregator, + extrinsicNavigationWrapper: ExtrinsicNavigationWrapper, + ): ViewModel { + return SwapExecutionViewModel( + swapStateStoreProvider = swapStateStoreProvider, + swapInteractor = swapInteractor, + resourceManager = resourceManager, + router = router, + chainRegistry = chainRegistry, + confirmationDetailsFormatter = confirmationDetailsFormatter, + feeLoaderMixinFactory = feeLoaderMixinFactory, + descriptionBottomSheetLauncher = descriptionBottomSheetLauncher, + swapFlowScopeAggregator = swapFlowScopeAggregator, + extrinsicNavigationWrapper = extrinsicNavigationWrapper + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SwapExecutionViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SwapExecutionViewModel::class.java) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/model/SwapExecutionDetailsModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/model/SwapExecutionDetailsModel.kt new file mode 100644 index 0000000..9dc35a2 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/model/SwapExecutionDetailsModel.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.execution.model + +import io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetsView +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteModel + +class SwapExecutionDetailsModel( + val assets: SwapAssetsView.Model, + val rate: String, + val priceDifference: CharSequence?, + val slippage: String, + val swapRouteModel: SwapRouteModel?, +) diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/model/SwapProgressModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/model/SwapProgressModel.kt new file mode 100644 index 0000000..ccb3eee --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/execution/model/SwapProgressModel.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.execution.model + +import kotlin.time.Duration + +sealed class SwapProgressModel { + + class InProgress( + val stepDescription: String, + val remainingTime: Duration, + val operationsLabel: String + ) : SwapProgressModel() + + class Completed(val at: String, val operationsLabel: String) : SwapProgressModel() + + class Failed(val reason: String, val at: String) : SwapProgressModel() +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/SwapFeeFragment.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/SwapFeeFragment.kt new file mode 100644 index 0000000..1dd5894 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/SwapFeeFragment.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.fee + +import android.view.View +import android.widget.LinearLayout.LayoutParams +import android.widget.LinearLayout.LayoutParams.MATCH_PARENT +import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT +import androidx.core.view.updateMargins +import io.novafoundation.nova.common.base.BaseBottomSheetFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.view.TableView +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_swap_impl.databinding.FragmentSwapFeeBinding +import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteTableCellView +import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel +import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel.FeeOperationModel +import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel.SwapComponentFeeModel +import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView + +class SwapFeeFragment : BaseBottomSheetFragment() { + + override fun createBinding() = FragmentSwapFeeBinding.inflate(layoutInflater) + + override fun initViews() {} + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SwapFeatureApi::class.java + ) + .swapFee() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SwapFeeViewModel) { + viewModel.swapFeeSegments.observe { feeState -> + feeState.onLoaded(::showFeeSegments) + } + + viewModel.totalFee.observe(binder.swapFeeTotal::setText) + } + + private fun showFeeSegments(feeSegments: List) { + binder.swapFeeContent.removeAllViews() + + return feeSegments.forEachIndexed { index, swapSegmentFeeModel -> + showFeeSegment( + feeSegment = swapSegmentFeeModel, + isFirst = index == 0, + isLast = index == feeSegments.size - 1 + ) + } + } + + private fun showFeeSegment( + feeSegment: SwapSegmentFeeModel, + isFirst: Boolean, + isLast: Boolean + ) { + val segmentTable = TableView(requireContext()).apply { + layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + updateMargins( + top = if (isFirst) 8.dp else 12.dp, + bottom = if (isLast) 8.dp else 0 + ) + } + } + + with(segmentTable) { + addView(createSegmentOperation(feeSegment.operation)) + + feeSegment.feeComponents.forEach { + val componentViews = createFeeComponentViews(it) + componentViews.forEach(::addView) + } + } + + binder.swapFeeContent.addView(segmentTable) + } + + private fun createFeeComponentViews(model: SwapComponentFeeModel): List { + return model.individualFees.mapIndexed { index, feeDisplay -> + val isFirst = index == 0 + val isLast = index == model.individualFees.size - 1 + + val label = model.label.takeIf { isFirst } + + FeeView(requireContext()).apply { + setShouldDrawDivider(isLast) + setTitle(label) + setFeeDisplay(feeDisplay) + } + } + } + + private fun createSegmentOperation(model: FeeOperationModel): View { + return SwapRouteTableCellView(requireContext()).apply { + setShowChainNames(true) + setTitle(model.label) + setSwapRouteModel(model.swapRoute) + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/SwapFeeViewModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/SwapFeeViewModel.kt new file mode 100644 index 0000000..f5a1ee7 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/SwapFeeViewModel.kt @@ -0,0 +1,113 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.fee + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData.SwapFeeType +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteModel +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.stateFlow +import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel +import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel.FeeOperationModel +import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel.SwapComponentFeeModel +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.toFeeDisplay +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.map + +class SwapFeeViewModel( + private val swapInteractor: SwapInteractor, + private val chainRegistry: ChainRegistry, + private val resourceManager: ResourceManager, + private val amountFormatter: AmountFormatter, + swapStateStoreProvider: SwapStateStoreProvider +) : BaseViewModel() { + + private val swapStateFlow = swapStateStoreProvider.stateFlow(viewModelScope) + + val swapFeeSegments = swapStateFlow + .map { it.fee.toSwapFeeSegments() } + .withSafeLoading() + .shareInBackground() + + val totalFee = swapStateFlow.map { + val fee = swapInteractor.calculateTotalFiatPrice(it.fee) + fee.formatAsCurrency() + }.shareInBackground() + + private suspend fun SwapFee.toSwapFeeSegments(): List { + val allTokens = swapInteractor.getAllFeeTokens(this) + + return segments.map { segment -> + val operationData = segment.operation.constructDisplayData() + val feeDisplayData = segment.fee.constructDisplayData() + + SwapSegmentFeeModel( + operation = operationData.toFeeOperationModel(), + feeComponents = feeDisplayData.toFeeComponentModels(allTokens) + ) + } + } + + private fun AtomicOperationFeeDisplayData.toFeeComponentModels( + tokens: Map + ): List { + return components.map { feeDisplaySegment -> + SwapComponentFeeModel( + label = feeDisplaySegment.type.formatLabel(), + individualFees = feeDisplaySegment.fees.map { individualFee -> + val token = tokens.getValue(individualFee.asset.fullId) + amountFormatter.formatAmountToAmountModel(individualFee.amount, token).toFeeDisplay() + } + ) + } + } + + private fun SwapFeeType.formatLabel(): String { + return when (this) { + SwapFeeType.NETWORK -> resourceManager.getString(R.string.network_fee) + SwapFeeType.CROSS_CHAIN -> resourceManager.getString(R.string.wallet_send_cross_chain_fee) + } + } + + private suspend fun AtomicOperationDisplayData.toFeeOperationModel(): FeeOperationModel { + return when (this) { + is AtomicOperationDisplayData.Swap -> toFeeOperationModel() + is AtomicOperationDisplayData.Transfer -> toFeeOperationModel() + } + } + + private suspend fun AtomicOperationDisplayData.Swap.toFeeOperationModel(): FeeOperationModel { + val chain = chainRegistry.getChain(from.chainAssetId.chainId) + val chains = listOf(mapChainToUi(chain)) + + return FeeOperationModel( + label = resourceManager.getString(R.string.swap_route_segment_swap_title), + swapRoute = SwapRouteModel(chains) + ) + } + + private suspend fun AtomicOperationDisplayData.Transfer.toFeeOperationModel(): FeeOperationModel { + val chainFrom = chainRegistry.getChain(from.chainId) + val chainTo = chainRegistry.getChain(to.chainId) + + val chains = listOf(mapChainToUi(chainFrom), mapChainToUi(chainTo)) + + return FeeOperationModel( + label = resourceManager.getString(R.string.swap_route_segment_transfer_title), + swapRoute = SwapRouteModel(chains) + ) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/di/SwapFeeComponent.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/di/SwapFeeComponent.kt new file mode 100644 index 0000000..dfc6134 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/di/SwapFeeComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.fee.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_swap_impl.presentation.fee.SwapFeeFragment + +@Subcomponent( + modules = [ + SwapFeeModule::class + ] +) +@ScreenScope +interface SwapFeeComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): SwapFeeComponent + } + + fun inject(fragment: SwapFeeFragment) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/di/SwapFeeModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/di/SwapFeeModule.kt new file mode 100644 index 0000000..89d72c4 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/di/SwapFeeModule.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.fee.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.fee.SwapFeeViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SwapFeeModule { + + @Provides + @IntoMap + @ViewModelKey(SwapFeeViewModel::class) + fun provideViewModel( + swapInteractor: SwapInteractor, + chainRegistry: ChainRegistry, + resourceManager: ResourceManager, + swapStateStoreProvider: SwapStateStoreProvider, + amountFormatter: AmountFormatter + ): ViewModel { + return SwapFeeViewModel( + swapInteractor = swapInteractor, + chainRegistry = chainRegistry, + resourceManager = resourceManager, + swapStateStoreProvider = swapStateStoreProvider, + amountFormatter = amountFormatter + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SwapFeeViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SwapFeeViewModel::class.java) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/model/SwapSegmentFeeModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/model/SwapSegmentFeeModel.kt new file mode 100644 index 0000000..06f65a7 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/fee/model/SwapSegmentFeeModel.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.fee.model + +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay + +class SwapSegmentFeeModel( + val operation: FeeOperationModel, + val feeComponents: List +) { + + class SwapComponentFeeModel(val label: String, val individualFees: List) + + class FeeOperationModel(val label: String, val swapRoute: SwapRouteModel) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/QuotingState.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/QuotingState.kt new file mode 100644 index 0000000..7ecfe02 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/QuotingState.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main + +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs + +sealed class QuotingState { + + object Default : QuotingState() + + object Loading : QuotingState() + + data class Error(val error: Throwable) : QuotingState() + + data class Loaded(val quote: SwapQuote, val quoteArgs: SwapQuoteArgs) : QuotingState() +} + +inline fun QuotingState.toLoadingState(onLoaded: (SwapQuote) -> T?): ExtendedLoadingState { + return when (this) { + QuotingState.Default -> ExtendedLoadingState.Loaded(null) + is QuotingState.Error -> ExtendedLoadingState.Error(error) + is QuotingState.Loaded -> ExtendedLoadingState.Loaded(onLoaded(quote)) + QuotingState.Loading -> ExtendedLoadingState.Loading + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/SwapMainSettingsFragment.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/SwapMainSettingsFragment.kt new file mode 100644 index 0000000..5aedf64 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/SwapMainSettingsFragment.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main + +import android.os.Bundle + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.mixin.impl.observeValidations +import io.novafoundation.nova.common.utils.hideKeyboard +import io.novafoundation.nova.common.utils.postToUiThread +import io.novafoundation.nova.common.utils.setSelectionEnd +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription +import io.novafoundation.nova.common.view.setProgressState +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.common.view.showLoadingValue +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent +import io.novafoundation.nova.feature_swap_impl.presentation.main.input.setupSwapAmountInput +import io.novafoundation.nova.feature_swap_impl.databinding.FragmentMainSwapSettingsBinding +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.bindGetAsset + +class SwapMainSettingsFragment : BaseFragment() { + + companion object { + + private const val KEY_PAYLOAD = "SwapMainSettingsFragment.payload" + + fun getBundle(payload: SwapSettingsPayload): Bundle { + return Bundle().apply { + putParcelable(KEY_PAYLOAD, payload) + } + } + } + + override fun createBinding() = FragmentMainSwapSettingsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.swapMainSettingsContinue.prepareForProgress(this) + binder.swapMainSettingsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.swapMainSettingsToolbar.setRightActionClickListener { viewModel.openOptions() } + + binder.swapMainSettingsPayInput.setSelectTokenClickListener { viewModel.selectPayToken() } + binder.swapMainSettingsReceiveInput.setSelectTokenClickListener { viewModel.selectReceiveToken() } + binder.swapMainSettingsFlip.setOnClickListener { + viewModel.flipAssets() + } + binder.swapMainSettingsDetailsRate.setOnClickListener { viewModel.rateDetailsClicked() } + binder.swapMainSettingsDetailsNetworkFee.setOnClickListener { viewModel.networkFeeClicked() } + binder.swapMainSettingsContinue.setOnClickListener { viewModel.continueButtonClicked() } + binder.swapMainSettingsContinue.prepareForProgress(this) + binder.swapMainSettingsRoute.setOnClickListener { + viewModel.routeClicked() + + hideKeyboard() + } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SwapFeatureApi::class.java + ) + .swapMainSettings() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: SwapMainSettingsViewModel) { + observeDescription(viewModel) + observeValidations(viewModel) + setupSwapAmountInput(viewModel.amountInInput, binder.swapMainSettingsPayInput, binder.swapMainSettingsMaxAmount) + setupSwapAmountInput(viewModel.amountOutInput, binder.swapMainSettingsReceiveInput, maxAvailableView = null) + viewModel.getAssetOptionsMixin.bindGetAsset(binder.swapMainSettingsGetAssetIn) + + viewModel.feeMixin.setupFeeLoading(binder.swapMainSettingsDetailsNetworkFee) + + viewModel.rateDetails.observe { binder.swapMainSettingsDetailsRate.showLoadingValue(it) } + viewModel.swapRouteState.observe(binder.swapMainSettingsRoute::setSwapRouteState) + viewModel.swapExecutionTime.observe(binder.swapMainSettingsExecutionTime::showLoadingValue) + viewModel.showDetails.observe { binder.swapMainSettingsDetails.setVisible(it) } + viewModel.buttonState.observe(binder.swapMainSettingsContinue::setState) + + viewModel.swapDirectionFlipped.observeEvent { + postToUiThread { + val field = when (it) { + SwapDirection.SPECIFIED_IN -> binder.swapMainSettingsPayInput + SwapDirection.SPECIFIED_OUT -> binder.swapMainSettingsReceiveInput + } + + field.requestFocus() + field.amountInput.setSelectionEnd() + } + } + + viewModel.validationProgress.observe(binder.swapMainSettingsContinue::setProgressState) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/SwapMainSettingsViewModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/SwapMainSettingsViewModel.kt new file mode 100644 index 0000000..cf566e0 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/SwapMainSettingsViewModel.kt @@ -0,0 +1,737 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.Validatable +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.accumulate +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.formatting.CompoundNumberFormatter +import io.novafoundation.nova.common.utils.formatting.DynamicPrecisionFormatter +import io.novafoundation.nova.common.utils.formatting.FixedPrecisionFormatter +import io.novafoundation.nova.common.utils.formatting.NumberAbbreviation +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.nullOnStart +import io.novafoundation.nova.common.utils.sendEvent +import io.novafoundation.nova.common.utils.skipFirst +import io.novafoundation.nova.common.utils.zipWithPrevious +import io.novafoundation.nova.common.validation.CompoundFieldValidator +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isErrorWithTag +import io.novafoundation.nova.common.validation.progressConsumer +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote +import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs +import io.novafoundation.nova.feature_swap_api.domain.model.swapRate +import io.novafoundation.nova.feature_swap_api.domain.model.toExecuteArgs +import io.novafoundation.nova.feature_swap_api.domain.model.totalTime +import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_swap_api.presentation.model.mapFromModel +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettings +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSwapRateDescription +import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload +import io.novafoundation.nova.feature_swap_impl.domain.validation.toSwapState +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.fee.createForSwap +import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.LiquidityFieldValidatorFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.SwapReceiveAmountAboveEDFieldValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteState +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapState +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.setState +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.swapSettingsFlow +import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixin +import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixinFactory +import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapInputMixinPriceImpactFiatFormatterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountFieldValidator +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase.InputState +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase.InputState.InputKind +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.invokeMaxClick +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.PaymentCurrencySelectionMode +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2.Configuration +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitOptionalFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.create +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_api.presentation.model.fullChainAssetId +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import java.math.BigDecimal +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SwapMainSettingsViewModel( + private val swapRouter: SwapRouter, + private val swapInteractor: SwapInteractor, + private val swapSettingsStateProvider: SwapSettingsStateProvider, + private val resourceManager: ResourceManager, + private val chainRegistry: ChainRegistry, + private val assetUseCase: ArbitraryAssetUseCase, + private val payload: SwapSettingsPayload, + private val validationExecutor: ValidationExecutor, + private val liquidityFieldValidatorFactory: LiquidityFieldValidatorFactory, + private val swapReceiveAmountAboveEDFieldValidatorFactory: SwapReceiveAmountAboveEDFieldValidatorFactory, + private val enoughAmountValidatorFactory: EnoughAmountValidatorFactory, + private val swapInputMixinPriceImpactFiatFormatterFactory: SwapInputMixinPriceImpactFiatFormatterFactory, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + private val swapRateFormatter: SwapRateFormatter, + private val swapRouteFormatter: SwapRouteFormatter, + private val maxActionProviderFactory: MaxActionProviderFactory, + private val swapStateStoreProvider: SwapStateStoreProvider, + private val swapFlowScopeAggregator: SwapFlowScopeAggregator, + private val getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory, + swapAmountInputMixinFactory: SwapAmountInputMixinFactory, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + actionAwaitableFactory: ActionAwaitableMixin.Factory, +) : BaseViewModel(), + DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher, + Validatable by validationExecutor { + + private val swapFlowScope = swapFlowScopeAggregator.getFlowScope(viewModelScope) + + private val swapSettingState = async { + swapSettingsStateProvider.getSwapSettingsState(swapFlowScope) + } + + private val swapSettings = swapSettingsStateProvider.swapSettingsFlow(swapFlowScope) + .share() + + private val chainAssetIn = swapSettings + .map { it.assetIn } + .distinctUntilChanged() + .shareInBackground() + + private val quotingState = MutableStateFlow(QuotingState.Default) + + private val assetOutFlow = swapSettings.assetFlowOf(SwapSettings::assetOut) + private val assetInFlow = swapSettings.assetFlowOf(SwapSettings::assetIn) + + private val priceImpact = quotingState.map { quoteState -> + when (quoteState) { + is QuotingState.Error, QuotingState.Loading, QuotingState.Default -> null + is QuotingState.Loaded -> quoteState.quote.priceImpact + } + } + + val swapRouteState = quotingState + .map { quoteState -> quoteState.toSwapRouteState() } + .shareInBackground() + + val swapExecutionTime = quotingState + .map { it.toExecutionEstimate() } + .shareInBackground() + + private val originChainFlow = swapSettings + .mapNotNull { it.assetIn?.chainId } + .distinctUntilChanged() + .map { chainRegistry.getChain(it) } + .shareInBackground() + + val feeMixin = feeLoaderMixinFactory.createForSwap( + chainAssetIn = swapSettings.mapNotNull { it.assetIn }, + interactor = swapInteractor, + configuration = Configuration( + initialState = Configuration.InitialState( + paymentCurrencySelectionMode = PaymentCurrencySelectionMode.AUTOMATIC_ONLY + ) + ) + ) + + private val maxAssetInProvider = createMaxActionProvider() + + val amountInInput = swapAmountInputMixinFactory.create( + coroutineScope = viewModelScope, + tokenFlow = assetInFlow.token().nullOnStart(), + emptyAssetTitle = R.string.swap_field_asset_from_title, + maxActionProvider = maxAssetInProvider, + fieldValidator = getAmountInFieldValidator() + ) + + val amountOutInput = swapAmountInputMixinFactory.create( + coroutineScope = viewModelScope, + tokenFlow = assetOutFlow.token().nullOnStart(), + emptyAssetTitle = R.string.swap_field_asset_to_title, + fiatFormatter = swapInputMixinPriceImpactFiatFormatterFactory.create(priceImpact), + fieldValidator = getAmountOutFieldValidator() + ) + + val rateDetails: Flow> = quotingState.map { + when (it) { + is QuotingState.Loaded -> ExtendedLoadingState.Loaded(formatRate(it.quote)) + else -> ExtendedLoadingState.Loading + } + } + .shareInBackground() + + val showDetails: Flow = quotingState.mapNotNull { + when (it) { + is QuotingState.Loaded -> true + is QuotingState.Default, + is QuotingState.Error -> false + + else -> null // Don't do anything if it's loading state + } + } + .distinctUntilChanged() + .shareInBackground() + + private val _validationProgress = MutableStateFlow(false) + + val validationProgress = _validationProgress + + val buttonState: Flow = combine( + quotingState, + accumulate(amountInInput.fieldError, amountOutInput.fieldError), + accumulate(amountInInput.inputState, amountOutInput.inputState), + assetInFlow, + assetOutFlow, + ::formatButtonStates + ) + .distinctUntilChanged() + .debounce(100) + .shareInBackground() + + val swapDirectionFlipped: MutableLiveData> = MutableLiveData() + + private val notEnoughAmountErrorFlow = amountInInput.fieldError.map { it.isErrorWithTag(EnoughAmountFieldValidator.ERROR_TAG) } + val getAssetOptionsMixin = getAssetOptionsMixinFactory.create( + assetFlow = chainAssetIn, + additionalButtonFilter = notEnoughAmountErrorFlow, + scope = viewModelScope, + ) + + private val amountInputFormatter = CompoundNumberFormatter( + abbreviations = listOf( + NumberAbbreviation( + threshold = BigDecimal.ZERO, + divisor = BigDecimal.ONE, + suffix = "", + formatter = DynamicPrecisionFormatter(minScale = 5, minPrecision = 3) + ), + NumberAbbreviation( + threshold = BigDecimal.ONE, + divisor = BigDecimal.ONE, + suffix = "", + formatter = FixedPrecisionFormatter(precision = 5) + ), + ) + ) + + private var quotingJob: Job? = null + + init { + initPayload() + + launch { swapInteractor.warmUpSwapCommonlyUsedChains(swapFlowScope) } + + handleInputChanges(amountInInput, SwapSettings::assetIn, SwapDirection.SPECIFIED_IN) + handleInputChanges(amountOutInput, SwapSettings::assetOut, SwapDirection.SPECIFIED_OUT) + + setupQuoting() + + setupUpdateSystem() + + feeMixin.setupFees() + + launch { + swapInteractor.sync(swapFlowScope) + } + } + + fun selectPayToken() { + launch { + val outAsset = assetOutFlow.firstOrNull() + ?.token + ?.configuration + val payload = outAsset?.let { AssetPayload(it.chainId, it.id) } + + swapRouter.selectAssetIn(payload) + } + } + + fun selectReceiveToken() { + launch { + val inAsset = assetInFlow.firstOrNull() + ?.token + ?.configuration + val payload = inAsset?.let { AssetPayload(it.chainId, it.id) } + + swapRouter.selectAssetOut(payload) + } + } + + fun routeClicked() = setSwapStateAfter { + swapRouter.openSwapRoute() + } + + fun networkFeeClicked() = setSwapStateAndThen { + swapRouter.openSwapFee() + } + + fun continueButtonClicked() = launchUnit { + val validationSystem = swapInteractor.validationSystem() + + val payload = getValidationPayload() ?: return@launchUnit + + validationExecutor.requireValid( + validationSystem = validationSystem, + payload = payload, + progressConsumer = _validationProgress.progressConsumer(), + validationFailureTransformerCustom = ::formatValidationFailure, + ) { validPayload -> + _validationProgress.value = false + + openSwapConfirmation(validPayload) + } + } + + fun rateDetailsClicked() { + launchSwapRateDescription() + } + + fun flipAssets() = launch { + val previousSettings = swapSettings.first() + val newSettings = swapSettingState().flipAssets() + + applyFlipToUi(previousSettings, newSettings) + } + + fun openOptions() { + swapRouter.openSwapOptions() + } + + fun backClicked() { + swapRouter.back() + } + + private fun openSwapConfirmation(validPayload: SwapValidationPayload) = launchUnit { + swapStateStoreProvider.setState(validPayload.toSwapState()) + + swapRouter.openSwapConfirmation() + } + + private fun setSwapStateAndThen(action: suspend () -> Unit) { + launch { + val quotingState = quotingState.value + if (quotingState !is QuotingState.Loaded) return@launch + + val swapState = SwapState( + quote = quotingState.quote, + fee = feeMixin.awaitFee(), + slippage = swapSettings.first().slippage + ) + swapStateStoreProvider.getStore(swapFlowScope).setState(swapState) + action() + } + } + + private fun setSwapStateAfter(action: () -> Unit) { + launch { + val quotingState = quotingState.value + if (quotingState !is QuotingState.Loaded) return@launch + + val store = swapStateStoreProvider.getStore(swapFlowScope) + store.resetState() + + action() + + val swapState = SwapState( + quote = quotingState.quote, + fee = feeMixin.awaitFee(), + slippage = swapSettings.first().slippage + ) + + store.setState(swapState) + } + } + + private fun createMaxActionProvider(): MaxActionProvider { + return maxActionProviderFactory.create( + viewModelScope = viewModelScope, + assetInFlow = assetInFlow.filterNotNull(), + feeLoaderMixin = feeMixin, + ) + } + + private fun initPayload() { + launch { + val assetIn = chainRegistry.asset(payload.assetIn.fullChainAssetId) + val swapSettingsState = swapSettingState.await() + when (payload) { + is SwapSettingsPayload.DefaultFlow -> swapSettingState().setAssetIn(assetIn) + + is SwapSettingsPayload.RepeatOperation -> { + val assetOut = chainRegistry.asset(payload.assetOut.fullChainAssetId) + val oldSwapSettings = swapSettingsState.selectedOption.first() + val direction = payload.direction.mapFromModel() + + val swapSettings = SwapSettings( + assetIn = assetIn, + assetOut = assetOut, + amount = payload.amount, + swapDirection = direction, + slippage = oldSwapSettings.slippage + ) + + swapSettingsState.setSwapSettings(swapSettings) + + initInputSilently(direction, assetIn, assetOut, payload.amount) + } + } + } + } + + private fun initInputSilently(direction: SwapDirection, assetIn: Chain.Asset, assetOut: Chain.Asset, amount: Balance) { + when (direction) { + SwapDirection.SPECIFIED_IN -> { + amountInInput.updateInput(assetIn, amount) + } + + SwapDirection.SPECIFIED_OUT -> { + amountOutInput.updateInput(assetOut, amount) + } + } + } + + private fun setupUpdateSystem() = launch { + swapInteractor.getUpdateSystem(originChainFlow, swapFlowScope) + .start() + .launchIn(viewModelScope) + } + + private fun FeeLoaderMixinV2.Presentation.setupFees() { + quotingState + .onEach { + when (it) { + is QuotingState.Loading -> setFeeLoading() + is QuotingState.Error -> setFeeStatus(FeeStatus.NoFee) + else -> {} + } + } + .filterIsInstance() + .debounce(300.milliseconds) + .zipWithPrevious() + .mapNotNull { (previous, current) -> + current.takeIf { + // allow same value in case user quickly switched from this value to another and back without waiting for fee loading + previous != current || feeMixin.fee.value !is FeeStatus.Loaded + } + } + .onEach { quoteState -> + loadFee { feePaymentCurrency -> + val swapArgs = quoteState.quote.toExecuteArgs( + slippage = swapSettings.first().slippage, + firstSegmentFees = feePaymentCurrency + ) + + swapInteractor.estimateFee(swapArgs) + } + } + .inBackground() + .launchIn(viewModelScope) + } + + private fun applyFlipToUi(previousSettings: SwapSettings, newSettings: SwapSettings) { + val amount = previousSettings.amount ?: return + val swapDirection = previousSettings.swapDirection ?: return + + when (swapDirection) { + SwapDirection.SPECIFIED_IN -> { + val previousIn = previousSettings.assetIn ?: return + amountOutInput.updateInput(previousIn, amount) + amountInInput.clearInput() + } + + SwapDirection.SPECIFIED_OUT -> { + val previousOut = previousSettings.assetOut ?: return + amountInInput.updateInput(previousOut, amount) + amountOutInput.clearInput() + } + } + + swapDirectionFlipped.value = newSettings.swapDirection!!.event() + } + + private fun formatRate(swapQuote: SwapQuote): String { + return swapRateFormatter.format(swapQuote.swapRate(), swapQuote.assetIn, swapQuote.assetOut) + } + + private fun formatButtonStates( + quotingState: QuotingState, + errorStates: List, + inputs: List>, + assetIn: Asset?, + assetOut: Asset?, + ): DescriptiveButtonState { + return when { + assetIn == null -> { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.swap_main_settings_asset_in_not_selecting_button_state)) + } + + assetOut == null -> { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.swap_main_settings_asset_out_not_selecting_button_state)) + } + + inputs.all { it.value.isEmpty() } -> { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.swap_main_settings_enter_amount_disabled_button_state)) + } + + errorStates.any { it is FieldValidationResult.Error } -> { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.swap_main_settings_wrong_amount_disabled_button_state)) + } + + quotingState is QuotingState.Loading -> DescriptiveButtonState.Loading + + quotingState is QuotingState.Error || inputs.any { it.value.isEmpty() } -> { + DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_continue)) + } + + else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue)) + } + } + + private fun setupQuoting() { + setupPerSwapSettingQuoting() + + setupSubscriptionQuoting() + } + + private fun setupPerSwapSettingQuoting() { + swapSettings.mapLatest { performQuote(it, shouldShowLoading = true) } + .launchIn(viewModelScope) + } + + private fun setupSubscriptionQuoting() { + flowOfAll { + swapInteractor.runSubscriptions(selectedAccountUseCase.getSelectedMetaAccount()) + .catch { Log.e(this@SwapMainSettingsViewModel.LOG_TAG, "Failure during subscriptions run", it) } + }.onEach { + Log.d("Swap", "ReQuote triggered from subscription") + + val currentSwapSettings = swapSettings.first() + + performQuote(currentSwapSettings, shouldShowLoading = false) + }.launchIn(viewModelScope) + } + + private fun performQuote(swapSettings: SwapSettings, shouldShowLoading: Boolean) { + quotingJob?.cancel() + quotingJob = launch { + val swapQuoteArgs = swapSettings.toQuoteArgs( + tokenIn = { assetInFlow.ensureToken(it) }, + tokenOut = { assetOutFlow.ensureToken(it) }, + ) ?: return@launch + + if (shouldShowLoading) { + quotingState.value = QuotingState.Loading + } + + val quote = swapInteractor.quote(swapQuoteArgs, swapFlowScope) + + quotingState.value = quote.fold( + onSuccess = { QuotingState.Loaded(it, swapQuoteArgs) }, + onFailure = { + if (it is CancellationException) { + QuotingState.Loading + } else { + QuotingState.Error(it) + } + } + ) + + handleNewQuote(quote, swapSettings) + } + } + + private suspend fun QuotingState.toSwapRouteState(): SwapRouteState { + return toLoadingState { swapRouteFormatter.formatSwapRoute(it) } + } + + private fun QuotingState.toExecutionEstimate(): ExtendedLoadingState { + return toLoadingState { + val estimatedDuration = it.executionEstimate.totalTime() + resourceManager.formatDuration(estimatedDuration, estimated = true) + } + } + + private fun handleNewQuote(quoteResult: Result, swapSettings: SwapSettings) { + quoteResult.onSuccess { quote -> + when (swapSettings.swapDirection!!) { + SwapDirection.SPECIFIED_IN -> amountOutInput.updateInput(quote.assetOut, quote.planksOut) + SwapDirection.SPECIFIED_OUT -> amountInInput.updateInput(quote.assetIn, quote.planksIn) + } + }.onFailure { + when (swapSettings.swapDirection!!) { + SwapDirection.SPECIFIED_OUT -> amountInInput.clearInput() + SwapDirection.SPECIFIED_IN -> amountOutInput.clearInput() + } + } + } + + private inline fun SwapSettings.toQuoteArgs( + tokenIn: (Chain.Asset) -> Token, + tokenOut: (Chain.Asset) -> Token + ): SwapQuoteArgs? { + return if (assetIn != null && assetOut != null && amount != null && swapDirection != null) { + SwapQuoteArgs( + tokenIn = tokenIn(assetIn!!), + tokenOut = tokenOut(assetOut!!), + amount = amount!!, + swapDirection = swapDirection!!, + ) + } else { + null + } + } + + private fun handleInputChanges( + amountInput: SwapAmountInputMixin.Presentation, + chainAsset: (SwapSettings) -> Chain.Asset?, + swapDirection: SwapDirection + ) { + amountInput.amountState + .filter { it.initiatedByUser } + .skipFirst() + .onEach { state -> + val asset = chainAsset(swapSettings.first()) ?: return@onEach + val planks = state.value?.let(asset::planksFromAmount) + swapSettingState().setAmount(planks, swapDirection) + }.launchIn(viewModelScope) + } + + private fun SwapAmountInputMixin.clearInput() { + inputState.value = InputState(value = "", initiatedByUser = false, inputKind = InputKind.REGULAR) + } + + private fun SwapAmountInputMixin.updateInput(chainAsset: Chain.Asset, planks: Balance) { + val amount = chainAsset.amountFromPlanks(planks) + inputState.value = InputState(amountInputFormatter.format(amount), initiatedByUser = false, InputKind.REGULAR) + } + + private fun Flow.assetFlowOf(extractor: (SwapSettings) -> Chain.Asset?): Flow { + return map { extractor(it) } + .transformLatest { chainAsset -> + if (chainAsset == null) { + emit(null) + } else { + emitAll(assetUseCase.assetFlow(chainAsset)) + } + } + .shareInBackground() + } + + private suspend fun Flow.ensureToken(asset: Chain.Asset): Token { + return filterNotNull() + .first { it.token.configuration.fullId == asset.fullId } + .token + } + + private fun getAmountInFieldValidator(): FieldValidator { + return CompoundFieldValidator( + enoughAmountValidatorFactory.create(maxAssetInProvider, errorMessageRes = R.string.swap_field_validation_not_enough_amount_to_swap), + liquidityFieldValidatorFactory.create(quotingState) + ) + } + + private fun getAmountOutFieldValidator(): FieldValidator { + return swapReceiveAmountAboveEDFieldValidatorFactory.create(assetOutFlow) + } + + private suspend fun getValidationPayload(): SwapValidationPayload? { + val quotingState = quotingState.value + if (quotingState !is QuotingState.Loaded) return null + + val fee = feeMixin.awaitOptionalFee() ?: return null + val slippage = swapSettings.first().slippage + + return SwapValidationPayload(fee, quotingState.quote, slippage) + } + + private fun formatValidationFailure( + status: ValidationStatus.NotValid, + actions: ValidationFlowActions + ) = mapSwapValidationFailureToUI( + resourceManager = resourceManager, + status = status, + actions = actions, + amountInSwapMaxAction = ::setMaxAvailableAmountIn, + amountOutSwapMinAction = ::setMinAmountOut, + ) + + private fun setMaxAvailableAmountIn() { + launch { + amountInInput.invokeMaxClick() + } + } + + private fun setMinAmountOut(chainAsset: Chain.Asset, amountInPlanks: Balance) { + amountOutInput.requestFocusLiveData.sendEvent() + amountOutInput.setAmount(chainAsset.amountFromPlanks(amountInPlanks)) + } + + private fun Flow.token(): Flow = map { it?.token } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/SwapValidationFailureUi.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/SwapValidationFailureUi.kt new file mode 100644 index 0000000..1d6bad9 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/SwapValidationFailureUi.kt @@ -0,0 +1,243 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.formatPercents +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.asDefault +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.AmountOutIsTooLowToStayAboveED +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.InsufficientBalance +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.InvalidSlippage +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NewRateExceededSlippage +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NonPositiveAmount +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NotEnoughFunds +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NotEnoughLiquidity +import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.TooSmallRemainingBalance +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.validation.amountIsTooBig +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleNonPositiveAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +fun mapSwapValidationFailureToUI( + resourceManager: ResourceManager, + status: ValidationStatus.NotValid, + actions: ValidationFlowActions<*>, + amountInSwapMaxAction: () -> Unit, + amountOutSwapMinAction: (Chain.Asset, Balance) -> Unit +): TransformedFailure { + return when (val reason = status.reason) { + is NotEnoughFunds.ToPayFeeAndStayAboveED -> handleInsufficientBalanceCommission(reason, resourceManager).asDefault() + + NotEnoughFunds.InUsedAsset -> resourceManager.amountIsTooBig().asDefault() + + is InvalidSlippage -> TitleAndMessage( + resourceManager.getString(R.string.swap_invalid_slippage_failure_title), + resourceManager.getString(R.string.swap_invalid_slippage_failure_message, reason.minSlippage.formatPercents(), reason.maxSlippage.formatPercents()) + ).asDefault() + + is SwapValidationFailure.HighPriceImpact -> highPriceImpact(reason, resourceManager, actions) + + is NewRateExceededSlippage -> TitleAndMessage( + resourceManager.getString(R.string.swap_rate_was_updated_failure_title), + resourceManager.getString( + R.string.swap_rate_was_updated_failure_message, + BigDecimal.ONE.formatTokenAmount(reason.assetIn), + reason.selectedRate.formatTokenAmount(reason.assetOut), + reason.newRate.formatTokenAmount(reason.assetOut) + ) + ).asDefault() + + NonPositiveAmount -> handleNonPositiveAmount(resourceManager).asDefault() + + NotEnoughLiquidity -> TitleAndMessage(resourceManager.getString(R.string.swap_not_enought_liquidity_failure), second = null).asDefault() + + is AmountOutIsTooLowToStayAboveED -> handleErrorToSwapMin(reason, resourceManager, amountOutSwapMinAction) + + is InsufficientBalance.CannotPayFeeDueToAmount -> handleInsufficientBalance( + title = resourceManager.getString(R.string.common_not_enough_funds_title), + message = resourceManager.getString( + R.string.swap_failure_insufficient_balance_message, + reason.maxSwapAmount.formatTokenAmount(reason.assetIn), + reason.feeAmount.formatTokenAmount(reason.assetIn) + ), + resourceManager = resourceManager, + positiveButtonClick = amountInSwapMaxAction + ) + + is InsufficientBalance.BalanceNotConsiderConsumers -> TitleAndMessage( + resourceManager.getString(R.string.common_not_enough_funds_title), + resourceManager.getString( + R.string.swap_failure_balance_not_consider_consumers, + reason.assetInED.formatPlanks(reason.assetIn), + reason.fee.formatPlanks(reason.feeAsset) + ) + ).asDefault() + + is InsufficientBalance.BalanceNotConsiderInsufficientReceiveAsset -> TitleAndMessage( + resourceManager.getString(R.string.common_not_enough_funds_title), + resourceManager.getString( + R.string.swap_failure_balance_not_consider_non_sufficient_assets, + reason.existentialDeposit.formatPlanks(reason.assetIn), + reason.assetOut.symbol + ) + ).asDefault() + + is TooSmallRemainingBalance -> handleTooSmallRemainingBalance( + title = resourceManager.getString(R.string.swap_failure_too_small_remaining_balance_title), + message = resourceManager.getString( + R.string.swap_failure_too_small_remaining_balance_message, + reason.assetInExistentialDeposit.formatPlanks(reason.assetIn), + reason.remainingBalance.formatPlanks(reason.assetIn) + ), + resourceManager = resourceManager, + actions = actions, + positiveButtonClick = amountInSwapMaxAction + ) + + is InsufficientBalance.CannotPayFee -> TitleAndMessage( + resourceManager.getString(R.string.common_not_enough_funds_title), + resourceManager.getString( + R.string.common_not_enough_to_pay_fee_message, + reason.fee.formatPlanks(reason.feeAsset), + reason.balance.formatPlanks(reason.feeAsset) + ) + ).asDefault() + + is SwapValidationFailure.IntermediateAmountOutIsTooLowToStayAboveED -> TitleAndMessage( + resourceManager.getString(R.string.swap_too_low_amount_to_stay_abow_ed_title), + resourceManager.getString( + R.string.swap_intermediate_too_low_amount_to_stay_abow_ed_message, + reason.amount.formatPlanks(reason.asset), + reason.existentialDeposit.formatPlanks(reason.asset) + ) + ).asDefault() + + is SwapValidationFailure.CannotReceiveAssetOut -> TitleAndMessage( + resourceManager.getString(R.string.common_not_enough_funds_title), + resourceManager.getString( + R.string.swap_failure_cannot_receive_insufficient_asset_out, + reason.requiredNativeAssetOnChainOut.formatPlanks(), + reason.destination.chain.name, + reason.destination.asset.symbol + ) + ).asDefault() + } +} + +fun handleInsufficientBalance( + title: String, + message: String, + resourceManager: ResourceManager, + positiveButtonClick: () -> Unit +): TransformedFailure { + return handleErrorToSwapMax( + title = title, + message = message, + resourceManager = resourceManager, + negativeButtonText = resourceManager.getString(R.string.common_cancel), + positiveButtonClick = positiveButtonClick, + negativeButtonClick = { } + ) +} + +fun handleTooSmallRemainingBalance( + title: String, + message: String, + resourceManager: ResourceManager, + actions: ValidationFlowActions<*>, + positiveButtonClick: () -> Unit +): TransformedFailure { + return handleErrorToSwapMax( + title = title, + message = message, + resourceManager = resourceManager, + negativeButtonText = resourceManager.getString(R.string.common_proceed), + positiveButtonClick = positiveButtonClick, + negativeButtonClick = { actions.resumeFlow() } + ) +} + +fun handleErrorToSwapMax( + title: String, + message: String, + resourceManager: ResourceManager, + negativeButtonText: String, + positiveButtonClick: () -> Unit, + negativeButtonClick: () -> Unit +): TransformedFailure { + return TransformedFailure.Custom( + CustomDialogDisplayer.Payload( + title = title, + message = message, + customStyle = R.style.AccentAlertDialogTheme, + okAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.swap_failure_swap_max_button), + action = positiveButtonClick + ), + cancelAction = CustomDialogDisplayer.Payload.DialogAction( + title = negativeButtonText, + action = negativeButtonClick + ) + ) + ) +} + +fun handleErrorToSwapMin( + reason: AmountOutIsTooLowToStayAboveED, + resourceManager: ResourceManager, + swapMinAmountAction: (Chain.Asset, BigInteger) -> Unit +): TransformedFailure { + return TransformedFailure.Custom( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.swap_too_low_amount_to_stay_abow_ed_title), + message = resourceManager.getString( + R.string.swap_too_low_amount_to_stay_abow_ed_message, + reason.amountInPlanks.formatPlanks(reason.asset), + reason.existentialDeposit.formatPlanks(reason.asset), + ), + customStyle = R.style.AccentAlertDialogTheme, + okAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.swap_failure_swap_min_button), + action = { swapMinAmountAction(reason.asset, reason.existentialDeposit) } + ), + cancelAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_cancel), + action = { } + ) + ) + ) +} + +fun highPriceImpact( + reason: SwapValidationFailure.HighPriceImpact, + resourceManager: ResourceManager, + actions: ValidationFlowActions<*> +): TransformedFailure { + return TransformedFailure.Custom( + CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.high_price_impact_detacted_title, reason.priceImpact.formatPercents()), + message = resourceManager.getString(R.string.high_price_impact_detacted_message), + customStyle = R.style.AccentNegativeAlertDialogTheme_Reversed, + okAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_continue), + action = { + actions.resumeFlow() + } + ), + cancelAction = CustomDialogDisplayer.Payload.DialogAction( + title = resourceManager.getString(R.string.common_cancel), + action = { } + ) + ) + ) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/di/SwapMainSettingsComponent.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/di/SwapMainSettingsComponent.kt new file mode 100644 index 0000000..0e1b807 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/di/SwapMainSettingsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_swap_impl.presentation.main.SwapMainSettingsFragment +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload + +@Subcomponent( + modules = [ + SwapMainSettingsModule::class + ] +) +@ScreenScope +interface SwapMainSettingsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: SwapSettingsPayload + ): SwapMainSettingsComponent + } + + fun inject(fragment: SwapMainSettingsFragment) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/di/SwapMainSettingsModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/di/SwapMainSettingsModule.kt new file mode 100644 index 0000000..3c5e2d9 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/di/SwapMainSettingsModule.kt @@ -0,0 +1,135 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter +import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload +import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.PriceImpactFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.LiquidityFieldValidatorFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.SwapReceiveAmountAboveEDFieldValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteFormatter +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.main.SwapMainSettingsViewModel +import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixinFactory +import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapInputMixinPriceImpactFiatFormatterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SwapMainSettingsModule { + + @Provides + @ScreenScope + fun provideSwapAmountMixinFactory( + chainRegistry: ChainRegistry, + resourceManager: ResourceManager, + assetIconProvider: AssetIconProvider + ) = SwapAmountInputMixinFactory(chainRegistry, resourceManager, assetIconProvider) + + @Provides + @ScreenScope + fun provideSwapInputMixinPriceImpactFiatFormatterFactory( + priceImpactFormatter: PriceImpactFormatter + ) = SwapInputMixinPriceImpactFiatFormatterFactory(priceImpactFormatter) + + @Provides + @ScreenScope + fun provideLiquidityFieldValidatorFactory(resourceManager: ResourceManager): LiquidityFieldValidatorFactory { + return LiquidityFieldValidatorFactory(resourceManager) + } + + @Provides + @ScreenScope + fun provideSwapAmountAboveEDFieldValidatorFactory( + resourceManager: ResourceManager, + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry + ): SwapReceiveAmountAboveEDFieldValidatorFactory { + return SwapReceiveAmountAboveEDFieldValidatorFactory(resourceManager, chainRegistry, assetSourceRegistry) + } + + @Provides + @IntoMap + @ViewModelKey(SwapMainSettingsViewModel::class) + fun provideViewModel( + swapRouter: SwapRouter, + swapInteractor: SwapInteractor, + resourceManager: ResourceManager, + swapSettingsStateProvider: SwapSettingsStateProvider, + swapAmountInputMixinFactory: SwapAmountInputMixinFactory, + chainRegistry: ChainRegistry, + assetUseCase: ArbitraryAssetUseCase, + feeLoaderMixinFactory: FeeLoaderMixinV2.Factory, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + liquidityFieldValidatorFactory: LiquidityFieldValidatorFactory, + swapReceiveAmountAboveEDFieldValidatorFactory: SwapReceiveAmountAboveEDFieldValidatorFactory, + payload: SwapSettingsPayload, + swapInputMixinPriceImpactFiatFormatterFactory: SwapInputMixinPriceImpactFiatFormatterFactory, + accountUseCase: SelectedAccountUseCase, + validationExecutor: ValidationExecutor, + descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + swapRateFormatter: SwapRateFormatter, + maxActionProviderFactory: MaxActionProviderFactory, + swapStateStoreProvider: SwapStateStoreProvider, + swapRouteFormatter: SwapRouteFormatter, + swapFlowScopeAggregator: SwapFlowScopeAggregator, + enoughAmountValidatorFactory: EnoughAmountValidatorFactory, + getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory, + ): ViewModel { + return SwapMainSettingsViewModel( + swapRouter = swapRouter, + swapInteractor = swapInteractor, + swapSettingsStateProvider = swapSettingsStateProvider, + resourceManager = resourceManager, + swapAmountInputMixinFactory = swapAmountInputMixinFactory, + chainRegistry = chainRegistry, + assetUseCase = assetUseCase, + feeLoaderMixinFactory = feeLoaderMixinFactory, + actionAwaitableFactory = actionAwaitableMixinFactory, + payload = payload, + swapInputMixinPriceImpactFiatFormatterFactory = swapInputMixinPriceImpactFiatFormatterFactory, + validationExecutor = validationExecutor, + liquidityFieldValidatorFactory = liquidityFieldValidatorFactory, + swapReceiveAmountAboveEDFieldValidatorFactory = swapReceiveAmountAboveEDFieldValidatorFactory, + descriptionBottomSheetLauncher = descriptionBottomSheetLauncher, + getAssetOptionsMixinFactory = getAssetOptionsMixinFactory, + swapRateFormatter = swapRateFormatter, + selectedAccountUseCase = accountUseCase, + enoughAmountValidatorFactory = enoughAmountValidatorFactory, + swapStateStoreProvider = swapStateStoreProvider, + maxActionProviderFactory = maxActionProviderFactory, + swapRouteFormatter = swapRouteFormatter, + swapFlowScopeAggregator = swapFlowScopeAggregator + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SwapMainSettingsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SwapMainSettingsViewModel::class.java) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapAmountInputMixin.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapAmountInputMixin.kt new file mode 100644 index 0000000..2f18f47 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapAmountInputMixin.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main.input + +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase +import kotlinx.coroutines.flow.Flow + +interface SwapAmountInputMixin : AmountChooserMixinBase { + + val assetModel: Flow + + interface Presentation : SwapAmountInputMixin, AmountChooserMixinBase.Presentation + + class SwapInputAssetModel( + val assetIcon: SwapAssetIcon, + val title: String, + val subtitleIcon: Icon?, + val subtitle: String, + val showInput: Boolean, + ) { + sealed class SwapAssetIcon { + + class Chosen(val assetIcon: Icon) : SwapAssetIcon() + + object NotChosen : SwapAssetIcon() + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapAmountInputMixinFactory.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapAmountInputMixinFactory.kt new file mode 100644 index 0000000..35f1f0b --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapAmountInputMixinFactory.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main.input + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_account_api.presenatation.chain.iconOrFallback +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixin.SwapInputAssetModel +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.BaseAmountChooserProvider +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.DefaultFiatFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class SwapAmountInputMixinFactory( + private val chainRegistry: ChainRegistry, + private val resourceManager: ResourceManager, + private val assetIconProvider: AssetIconProvider +) { + + fun create( + coroutineScope: CoroutineScope, + tokenFlow: Flow, + @StringRes emptyAssetTitle: Int, + maxActionProvider: MaxActionProvider? = null, + fiatFormatter: AmountChooserMixinBase.FiatFormatter = DefaultFiatFormatter(), + fieldValidator: FieldValidator + ): SwapAmountInputMixin.Presentation { + return RealSwapAmountInputMixin( + coroutineScope = coroutineScope, + tokenFlow = tokenFlow, + emptyAssetTitle = emptyAssetTitle, + chainRegistry = chainRegistry, + resourceManager = resourceManager, + maxActionProvider = maxActionProvider, + fiatFormatter = fiatFormatter, + fieldValidator = fieldValidator, + assetIconProvider = assetIconProvider + ) + } +} + +private class RealSwapAmountInputMixin( + coroutineScope: CoroutineScope, + tokenFlow: Flow, + @StringRes private val emptyAssetTitle: Int, + private val chainRegistry: ChainRegistry, + private val resourceManager: ResourceManager, + maxActionProvider: MaxActionProvider?, + fiatFormatter: AmountChooserMixinBase.FiatFormatter, + fieldValidator: FieldValidator, + private val assetIconProvider: AssetIconProvider +) : BaseAmountChooserProvider( + coroutineScope = coroutineScope, + tokenFlow = tokenFlow, + maxActionProvider = maxActionProvider, + fiatFormatter = fiatFormatter, + fieldValidator = fieldValidator +), + SwapAmountInputMixin.Presentation { + + override val assetModel: Flow = tokenFlow.map { + val chainAsset = it?.configuration + + if (chainAsset != null) { + formatInputAsset(chainAsset) + } else { + defaultInputModel() + } + } + + private suspend fun formatInputAsset(chainAsset: Chain.Asset): SwapInputAssetModel { + val chain = chainRegistry.getChain(chainAsset.chainId) + + return SwapInputAssetModel( + assetIcon = SwapInputAssetModel.SwapAssetIcon.Chosen(assetIconProvider.getAssetIconOrFallback(chainAsset)), + title = chainAsset.symbol.value, + subtitleIcon = chain.iconOrFallback(), + subtitle = chain.name, + showInput = true, + ) + } + + private fun defaultInputModel(): SwapInputAssetModel { + return SwapInputAssetModel( + assetIcon = SwapInputAssetModel.SwapAssetIcon.NotChosen, + title = resourceManager.getString(emptyAssetTitle), + subtitleIcon = null, + subtitle = resourceManager.getString(R.string.fragment_swap_main_settings_select_token), + showInput = false, + ) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapAmountInputUi.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapAmountInputUi.kt new file mode 100644 index 0000000..d2aa43d --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapAmountInputUi.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main.input + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.feature_swap_impl.presentation.common.views.SwapAmountInputView +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxAvailableView +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooserBase + +fun BaseFragment<*, *>.setupSwapAmountInput( + mixin: SwapAmountInputMixin, + amountView: SwapAmountInputView, + maxAvailableView: MaxAvailableView? +) { + setupAmountChooserBase(mixin, amountView, maxAvailableView) + + mixin.assetModel.observe(amountView::setModel) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapInputMixinPriceImpactFiatFormatter.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapInputMixinPriceImpactFiatFormatter.kt new file mode 100644 index 0000000..c1857c3 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/main/input/SwapInputMixinPriceImpactFiatFormatter.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.main.input + +import android.text.SpannableStringBuilder +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_swap_impl.presentation.common.PriceImpactFormatter +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import java.math.BigDecimal + +class SwapInputMixinPriceImpactFiatFormatterFactory( + private val priceImpactFormatter: PriceImpactFormatter, +) { + + fun create(priceImpactFlow: Flow): AmountChooserMixinBase.FiatFormatter { + return SwapInputMixinPriceImpactFiatFormatter(priceImpactFormatter, priceImpactFlow) + } +} + +class SwapInputMixinPriceImpactFiatFormatter( + private val priceImpactFormatter: PriceImpactFormatter, + private val priceImpactFlow: Flow, +) : AmountChooserMixinBase.FiatFormatter { + + override fun formatFlow(tokenFlow: Flow, amountFlow: Flow): Flow { + return combine(tokenFlow, amountFlow, priceImpactFlow) { token, amount, priceImpact -> + val formattedFiatAmount = token.amountToFiat(amount).formatAsCurrency(token.currency) + val formattedPriceImpact = priceImpact?.let(priceImpactFormatter::formatWithBrackets) + + if (formattedPriceImpact != null) { + SpannableStringBuilder().apply { + append("$formattedFiatAmount ") + append(formattedPriceImpact) + } + } else { + formattedFiatAmount + } + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/SwapOptionsFragment.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/SwapOptionsFragment.kt new file mode 100644 index 0000000..686568d --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/SwapOptionsFragment.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.options + +import androidx.lifecycle.viewModelScope + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.validation.observeErrors +import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.common.view.setMessageOrHide +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.databinding.FragmentSwapOptionsBinding +import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent + +class SwapOptionsFragment : BaseFragment() { + + override fun createBinding() = FragmentSwapOptionsBinding.inflate(layoutInflater) + + override fun initViews() { + binder.swapOptionsToolbar.setHomeButtonListener { viewModel.backClicked() } + binder.swapOptionsToolbar.setRightActionClickListener { viewModel.resetClicked() } + binder.swapOptionsApplyButton.setOnClickListener { viewModel.applyClicked() } + binder.swapOptionsSlippageTitle.setOnClickListener { viewModel.slippageInfoClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SwapFeatureApi::class.java + ) + .swapOptions() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SwapOptionsViewModel) { + observeDescription(viewModel) + binder.swapOptionsSlippageInput.content.bindTo(viewModel.slippageInput, viewModel.viewModelScope, moveSelectionToEndOnInsertion = true) + viewModel.defaultSlippage.observe { binder.swapOptionsSlippageInput.setHint(it) } + viewModel.slippageTips.observe { + binder.swapOptionsSlippageInput.clearTips() + it.forEachIndexed { index, text -> + binder.swapOptionsSlippageInput.addTextTip(text, R.color.text_primary) { viewModel.tipClicked(index) } + } + } + viewModel.buttonState.observe { binder.swapOptionsApplyButton.setState(it) } + binder.swapOptionsSlippageInput.observeErrors(viewModel.slippageInputValidationResult, viewModel.viewModelScope) + viewModel.slippageWarningState.observe { binder.swapOptionsAlert.setMessageOrHide(it) } + viewModel.resetButtonEnabled.observe { binder.swapOptionsToolbar.setRightActionEnabled(it) } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/SwapOptionsViewModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/SwapOptionsViewModel.kt new file mode 100644 index 0000000..832b06a --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/SwapOptionsViewModel.kt @@ -0,0 +1,150 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.options + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.presentation.DescriptiveButtonState.Disabled +import io.novafoundation.nova.common.presentation.DescriptiveButtonState.Enabled +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Fraction +import io.novafoundation.nova.common.utils.Fraction.Companion.percents +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.formatting.formatPercents +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettings +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.SlippageFieldValidatorFactory +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch + +class SwapOptionsViewModel( + private val swapRouter: SwapRouter, + private val resourceManager: ResourceManager, + private val swapSettingsStateProvider: SwapSettingsStateProvider, + private val slippageFieldValidatorFactory: SlippageFieldValidatorFactory, + private val swapInteractor: SwapInteractor, + private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + private val slippageAlertMixinFactory: SlippageAlertMixinFactory +) : BaseViewModel(), DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher { + + private val swapSettingState = async { + swapSettingsStateProvider.getSwapSettingsState(viewModelScope) + } + + private val swapSettingsStateFlow = flowOfAll { swapSettingState.await().selectedOption } + .shareInBackground() + + private val slippageConfig = swapSettingsStateFlow + .mapNotNull { it.assetIn ?: it.assetOut } + .mapNotNull { swapInteractor.slippageConfig(it.chainId) } + .shareInBackground() + + val slippageInput = MutableStateFlow("") + + private val slippageFieldValidator = slippageConfig.map { slippageFieldValidatorFactory.create(it) } + .shareInBackground() + + val slippageInputValidationResult = slippageFieldValidator.flatMapLatest { it.observe(slippageInput) } + .shareInBackground() + + private val slippageAlertMixin = slippageAlertMixinFactory.create( + slippageConfig, + slippageInput.map { it.formatToPercent() } + ) + + val slippageWarningState = slippageAlertMixin.slippageAlertMessage + + val resetButtonEnabled = combine(slippageInput, slippageConfig) { input, slippageConfig -> + formatResetButtonVisibility(input, slippageConfig) + } + + val buttonState = combine(slippageInput, swapSettingsStateFlow, slippageInputValidationResult) { input, state, validationStatus -> + formatButtonState(input, state, validationStatus) + } + + val defaultSlippage = slippageConfig.map { it.defaultSlippage } + .map { it.formatPercents() } + + val slippageTips = slippageConfig.map { it.slippageTips } + .mapList { it.formatPercents() } + + init { + launch { + val selectedSlippage = swapSettingsStateFlow.first().slippage + val defaultSlippage = slippageConfig.first().defaultSlippage + if (selectedSlippage != defaultSlippage) { + slippageInput.value = selectedSlippage.formatPercents(includeSymbol = false) + } + } + } + + fun slippageInfoClicked() { + launchDescriptionBottomSheet( + titleRes = R.string.swap_slippage_title, + descriptionRes = R.string.swap_slippage_description + ) + } + + fun tipClicked(index: Int) { + launch { + val slippageTips = slippageConfig.first().slippageTips + slippageInput.value = slippageTips[index].formatPercents(includeSymbol = false) + } + } + + fun applyClicked() { + launch { + val slippage = slippageInput.value.formatToPercent() ?: return@launch + swapSettingState.await().setSlippage(slippage) + swapRouter.back() + } + } + + fun resetClicked() { + slippageInput.value = "" + } + + fun backClicked() { + swapRouter.back() + } + + private suspend fun String.formatToPercent(): Fraction? { + val defaultSlippage = slippageConfig.first().defaultSlippage + + return if (isEmpty()) { + defaultSlippage + } else { + return toDoubleOrNull()?.percents + } + } + + private suspend fun formatButtonState( + insertedSlippage: String, + settings: SwapSettings, + validationResult: FieldValidationResult + ): DescriptiveButtonState { + val slippage = insertedSlippage.formatToPercent() + return when { + validationResult is FieldValidationResult.Error -> Disabled(resourceManager.getString(R.string.swap_slippage_disabled_button_state)) + slippage != settings.slippage -> Enabled(resourceManager.getString(R.string.common_apply)) + else -> Disabled(resourceManager.getString(R.string.common_apply)) + } + } + + private suspend fun formatResetButtonVisibility(slippageInput: String, slippageConfig: SlippageConfig): Boolean { + return slippageInput.formatToPercent() != slippageConfig.defaultSlippage + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/di/SwapOptionsComponent.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/di/SwapOptionsComponent.kt new file mode 100644 index 0000000..b6943aa --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/di/SwapOptionsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.options.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_swap_impl.presentation.options.SwapOptionsFragment + +@Subcomponent( + modules = [ + SwapOptionsModule::class + ] +) +@ScreenScope +interface SwapOptionsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): SwapOptionsComponent + } + + fun inject(fragment: SwapOptionsFragment) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/di/SwapOptionsModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/di/SwapOptionsModule.kt new file mode 100644 index 0000000..b9eb481 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/options/di/SwapOptionsModule.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.options.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher +import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory +import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.SlippageFieldValidatorFactory +import io.novafoundation.nova.feature_swap_impl.presentation.options.SwapOptionsViewModel + +@Module(includes = [ViewModelModule::class]) +class SwapOptionsModule { + + @Provides + fun provideSlippageFieldValidatorFactory( + resourceManager: ResourceManager + ): SlippageFieldValidatorFactory { + return SlippageFieldValidatorFactory(resourceManager) + } + + @Provides + @IntoMap + @ViewModelKey(SwapOptionsViewModel::class) + fun provideViewModel( + swapRouter: SwapRouter, + resourceManager: ResourceManager, + swapSettingsStateProvider: SwapSettingsStateProvider, + slippageFieldValidatorFactory: SlippageFieldValidatorFactory, + swapInteractor: SwapInteractor, + descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher, + slippageAlertMixinFactory: SlippageAlertMixinFactory + ): ViewModel { + return SwapOptionsViewModel( + swapRouter, + resourceManager, + swapSettingsStateProvider, + slippageFieldValidatorFactory, + swapInteractor, + descriptionBottomSheetLauncher, + slippageAlertMixinFactory + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SwapOptionsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SwapOptionsViewModel::class.java) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/SwapRouteFragment.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/SwapRouteFragment.kt new file mode 100644 index 0000000..88c0830 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/SwapRouteFragment.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.route + +import androidx.recyclerview.widget.ConcatAdapter +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.domain.onLoaded +import io.novafoundation.nova.common.domain.onNotLoaded +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi +import io.novafoundation.nova.feature_swap_impl.databinding.FragmentRouteBinding +import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent +import io.novafoundation.nova.feature_swap_impl.presentation.route.list.SwapRouteAdapter +import io.novafoundation.nova.feature_swap_impl.presentation.route.list.SwapRouteHeaderAdapter +import io.novafoundation.nova.feature_swap_impl.presentation.route.list.SwapRouteViewHolder +import io.novafoundation.nova.feature_swap_impl.presentation.route.list.TimelineItemDecoration + +class SwapRouteFragment : BaseFragment(), SwapRouteHeaderAdapter.Handler { + + override fun createBinding() = FragmentRouteBinding.inflate(layoutInflater) + + private lateinit var headerAdapter: SwapRouteHeaderAdapter + private lateinit var routeAdapter: SwapRouteAdapter + + override fun initViews() { + binder.swapRouteContent.setHasFixedSize(true) + binder.swapRouteContent.itemAnimator = null + + val timelineDecoration = TimelineItemDecoration( + context = requireContext(), + shouldDecorate = { it is SwapRouteViewHolder } + ) + binder.swapRouteContent.addItemDecoration(timelineDecoration) + + headerAdapter = SwapRouteHeaderAdapter(this) + routeAdapter = SwapRouteAdapter() + + binder.swapRouteContent.adapter = ConcatAdapter(headerAdapter, routeAdapter) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + SwapFeatureApi::class.java + ) + .swapRoute() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: SwapRouteViewModel) { + viewModel.swapRoute.observe { routeState -> + routeState.onLoaded { + binder.swapRouteProgress.makeGone() + routeAdapter.submitList(it) + }.onNotLoaded { + binder.swapRouteProgress.makeVisible() + routeAdapter.submitList(emptyList()) + } + } + } + + override fun backClicked() { + viewModel.backClicked() + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/SwapRouteViewModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/SwapRouteViewModel.kt new file mode 100644 index 0000000..4e8a86c --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/SwapRouteViewModel.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.route + +import androidx.lifecycle.viewModelScope +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.withSafeLoading +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData +import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.stateFlow +import io.novafoundation.nova.feature_swap_impl.presentation.route.model.SwapRouteItemModel +import io.novafoundation.nova.feature_swap_impl.presentation.route.view.TokenAmountModel +import io.novafoundation.nova.feature_wallet_api.domain.model.FiatAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.flow.map + +class SwapRouteViewModel( + private val swapInteractor: SwapInteractor, + private val swapStateStoreProvider: SwapStateStoreProvider, + private val chainRegistry: ChainRegistry, + private val assetIconProvider: AssetIconProvider, + private val router: SwapRouter, + private val resourceManager: ResourceManager +) : BaseViewModel() { + + val swapRoute = swapStateStoreProvider.stateFlow(viewModelScope) + .map { it.fee.toSwapRouteUi() } + .withSafeLoading() + .shareInBackground() + + private suspend fun SwapFee.toSwapRouteUi(): List { + val pricedFees = swapInteractor.calculateSegmentFiatPrices(this) + + return pricedFees.zip(segments).mapIndexed { index, (pricedFee, segment) -> + val displayData = segment.operation.constructDisplayData() + displayData.toUi(pricedFee, id = index) + } + } + + private suspend fun AtomicOperationDisplayData.toUi( + fee: FiatAmount, + id: Int + ): SwapRouteItemModel { + val formattedFee = fee.formatAsCurrency() + val feeWithLabel = resourceManager.getString(R.string.common_fee_with_label, formattedFee) + + return when (this) { + is AtomicOperationDisplayData.Swap -> toUi(feeWithLabel, id) + is AtomicOperationDisplayData.Transfer -> toUi(feeWithLabel, id) + } + } + + private suspend fun AtomicOperationDisplayData.Transfer.toUi(fee: String, id: Int): SwapRouteItemModel.Transfer { + val (chainFrom, assetFrom) = chainRegistry.chainWithAsset(from) + val assetFromIcon = assetIconProvider.getAssetIconOrFallback(assetFrom) + + val chainTo = chainRegistry.getChain(to.chainId) + + return SwapRouteItemModel.Transfer( + id = id, + amount = TokenAmountModel.from(assetFrom, assetFromIcon, amount), + fee = fee, + originChainName = chainFrom.name, + destinationChainName = chainTo.name + ) + } + + private suspend fun AtomicOperationDisplayData.Swap.toUi(fee: String, id: Int): SwapRouteItemModel.Swap { + val (chainFrom, assetFrom) = chainRegistry.chainWithAsset(from.chainAssetId) + val assetFromIcon = assetIconProvider.getAssetIconOrFallback(assetFrom) + + val assetTo = chainRegistry.asset(to.chainAssetId) + val assetToIcon = assetIconProvider.getAssetIconOrFallback(assetTo) + + return SwapRouteItemModel.Swap( + id = id, + amountFrom = TokenAmountModel.from(assetFrom, assetFromIcon, from.amount), + amountTo = TokenAmountModel.from(assetTo, assetToIcon, to.amount), + fee = fee, + chain = chainFrom.name + ) + } + + fun backClicked() { + router.back() + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/di/SwapRouteComponent.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/di/SwapRouteComponent.kt new file mode 100644 index 0000000..e86b868 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/di/SwapRouteComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.route.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_swap_impl.presentation.route.SwapRouteFragment + +@Subcomponent( + modules = [ + SwapRouteModule::class + ] +) +@ScreenScope +interface SwapRouteComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): SwapRouteComponent + } + + fun inject(fragment: SwapRouteFragment) +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/di/SwapRouteModule.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/di/SwapRouteModule.kt new file mode 100644 index 0000000..4e5850c --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/di/SwapRouteModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.route.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor +import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter +import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider +import io.novafoundation.nova.feature_swap_impl.presentation.route.SwapRouteViewModel +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +@Module(includes = [ViewModelModule::class]) +class SwapRouteModule { + + @Provides + @IntoMap + @ViewModelKey(SwapRouteViewModel::class) + fun provideViewModel( + swapInteractor: SwapInteractor, + swapStateStoreProvider: SwapStateStoreProvider, + chainRegistry: ChainRegistry, + assetIconProvider: AssetIconProvider, + resourceManager: ResourceManager, + router: SwapRouter, + ): ViewModel { + return SwapRouteViewModel( + swapInteractor = swapInteractor, + swapStateStoreProvider = swapStateStoreProvider, + chainRegistry = chainRegistry, + assetIconProvider = assetIconProvider, + router = router, + resourceManager = resourceManager + ) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): SwapRouteViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(SwapRouteViewModel::class.java) + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/list/SwapRouteAdapter.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/list/SwapRouteAdapter.kt new file mode 100644 index 0000000..c037c0f --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/list/SwapRouteAdapter.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.route.list + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_swap_impl.R +import io.novafoundation.nova.feature_swap_impl.databinding.ItemRouteSwapBinding +import io.novafoundation.nova.feature_swap_impl.databinding.ItemRouteTransferBinding +import io.novafoundation.nova.feature_swap_impl.presentation.route.model.SwapRouteItemModel + +class SwapRouteAdapter : BaseListAdapter(SwapRouteDiffCallback()) { + + override fun onBindViewHolder(holder: SwapRouteViewHolder, position: Int) { + when (val item = getItem(position)) { + is SwapRouteItemModel.Swap -> (holder as SwapRouteSwapViewHolder).bind(item) + is SwapRouteItemModel.Transfer -> (holder as SwapRouteTransferViewHolder).bind(item) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SwapRouteViewHolder { + return when (viewType) { + R.layout.item_route_swap -> SwapRouteSwapViewHolder(ItemRouteSwapBinding.inflate(parent.inflater(), parent, false)) + R.layout.item_route_transfer -> SwapRouteTransferViewHolder(ItemRouteTransferBinding.inflate(parent.inflater(), parent, false)) + else -> error("Unknown viewType: $viewType") + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is SwapRouteItemModel.Swap -> R.layout.item_route_swap + is SwapRouteItemModel.Transfer -> R.layout.item_route_transfer + } + } +} + +sealed class SwapRouteViewHolder(itemView: View) : BaseViewHolder(itemView) { + + init { + with(itemView) { + background = context.getRoundedCornerDrawable(R.color.input_background) + } + } +} + +class SwapRouteTransferViewHolder(private val binder: ItemRouteTransferBinding) : SwapRouteViewHolder(binder.root) { + + fun bind(model: SwapRouteItemModel.Transfer) = with(binder) { + itemRouteTransferAmount.setModel(model.amount) + itemRouteTransferFee.text = model.fee + itemRouteTransferFrom.text = model.originChainName + itemRouteTransferTo.text = model.destinationChainName + } + + override fun unbind() {} +} + +class SwapRouteSwapViewHolder(private val binder: ItemRouteSwapBinding) : SwapRouteViewHolder(binder.root) { + + fun bind(model: SwapRouteItemModel.Swap) = with(binder) { + itemRouteSwapAmountFrom.setModel(model.amountFrom) + itemRouteSwapAmountTo.setModel(model.amountTo) + itemRouteSwapFee.text = model.fee + itemRouteSwapChain.text = model.chain + } + + override fun unbind() {} +} + +private class SwapRouteDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: SwapRouteItemModel, newItem: SwapRouteItemModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: SwapRouteItemModel, newItem: SwapRouteItemModel): Boolean { + return oldItem == newItem + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/list/SwapRouteHeaderAdapter.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/list/SwapRouteHeaderAdapter.kt new file mode 100644 index 0000000..453463c --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/list/SwapRouteHeaderAdapter.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.route.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_swap_impl.databinding.ItemRouteHeaderBinding + +class SwapRouteHeaderAdapter( + private val handler: Handler +) : RecyclerView.Adapter() { + + interface Handler { + + fun backClicked() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SwapRouteHeaderViewHolder { + return SwapRouteHeaderViewHolder(ItemRouteHeaderBinding.inflate(parent.inflater(), parent, false), handler) + } + + override fun getItemCount(): Int { + return 1 + } + + override fun onBindViewHolder(holder: SwapRouteHeaderViewHolder, position: Int) {} +} + +class SwapRouteHeaderViewHolder( + binder: ItemRouteHeaderBinding, + handler: SwapRouteHeaderAdapter.Handler +) : RecyclerView.ViewHolder(binder.root) { + + init { + binder.swapRouteBack.setHomeButtonListener { handler.backClicked() } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/list/TimelineItemDecoration.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/list/TimelineItemDecoration.kt new file mode 100644 index 0000000..b30df71 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/list/TimelineItemDecoration.kt @@ -0,0 +1,101 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.route.list + +import android.content.Context +import android.graphics.Canvas +import android.graphics.DashPathEffect +import android.graphics.Paint +import android.graphics.Rect +import android.view.View +import android.widget.TextView +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.ContextCompat +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.feature_swap_impl.R + +class TimelineItemDecoration( + context: Context, + private val shouldDecorate: (RecyclerView.ViewHolder) -> Boolean +) : RecyclerView.ItemDecoration(), + WithContextExtensions by WithContextExtensions(context) { + + private val linePaint = Paint().apply { + color = ContextCompat.getColor(context, R.color.timeline_line_color) + strokeWidth = 1.dpF + style = Paint.Style.STROKE + pathEffect = DashPathEffect(floatArrayOf(4.dpF, 4.dpF), 0f) + } + + private val circlePaint = Paint().apply { + color = ContextCompat.getColor(context, R.color.timeline_circle_color) + isAntiAlias = true + } + + private val textPaint = createTextPaint() + + private val circleRadius = 10.dp + + private val itemTopMargin = 12.dp + + private val lineHorizontalMargin = 18.dp + + private val lineToCircleMargin = 4.dp + + private val itemStartMargin = (lineHorizontalMargin + circleRadius) * 2 + private val itemEndMargin = 16.dp + + private val circleTopMargin = 10.dp + + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val childCount = parent.childCount + for (i in 0 until childCount) { + val child = parent.getChildAt(i) + val viewHolder = parent.getChildViewHolder(child) + if (!shouldDecorate(viewHolder)) continue + + val position = viewHolder.bindingAdapterPosition + val centerX = lineHorizontalMargin + circleRadius.toFloat() + + val circleCenterY = (child.top + circleTopMargin + circleRadius).toFloat() + + if (position < viewHolder.bindingAdapter!!.itemCount - 1) { + val childSpaceEndY = child.bottom + itemTopMargin + val nextCircleTopY = childSpaceEndY + circleTopMargin + + canvas.drawLine( + centerX, + circleCenterY + circleRadius + lineToCircleMargin, + centerX, + nextCircleTopY - lineToCircleMargin.toFloat(), + linePaint + ) + } + + canvas.drawCircle(centerX, circleCenterY, circleRadius.toFloat(), circlePaint) + + val text = (position + 1).toString() + val textY = circleCenterY - (textPaint.descent() + textPaint.ascent()) / 2 + + canvas.drawText(text, centerX, textY, textPaint) + } + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val viewHolder = parent.getChildViewHolder(view) + if (shouldDecorate(viewHolder)) { + outRect.left = itemStartMargin + outRect.top = itemTopMargin + outRect.right = itemEndMargin + } + } + + private fun createTextPaint(): Paint { + val textView: TextView = AppCompatTextView(providedContext) + TextViewCompat.setTextAppearance(textView, R.style.TextAppearance_NovaFoundation_SemiBold_Caps1) + return textView.paint.apply { + color = ContextCompat.getColor(providedContext, R.color.text_secondary) + textAlign = Paint.Align.CENTER + } + } +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/model/SwapRouteItemModel.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/model/SwapRouteItemModel.kt new file mode 100644 index 0000000..65196d7 --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/model/SwapRouteItemModel.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.route.model + +import io.novafoundation.nova.feature_swap_impl.presentation.route.view.TokenAmountModel + +sealed class SwapRouteItemModel { + + abstract val id: Int + + data class Transfer( + override val id: Int, + val amount: TokenAmountModel, + val fee: String, + val originChainName: String, + val destinationChainName: String, + ) : SwapRouteItemModel() + + data class Swap( + override val id: Int, + val amountFrom: TokenAmountModel, + val amountTo: TokenAmountModel, + val fee: String, + val chain: String + ) : SwapRouteItemModel() +} diff --git a/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/view/TokenAmountView.kt b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/view/TokenAmountView.kt new file mode 100644 index 0000000..278f86a --- /dev/null +++ b/feature-swap-impl/src/main/java/io/novafoundation/nova/feature_swap_impl/presentation/route/view/TokenAmountView.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_swap_impl.presentation.route.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_swap_impl.databinding.ViewTokenAmountBinding +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class TokenAmountModel( + val amount: String, + val tokenIcon: Icon +) { + + companion object { + + fun from(chainAsset: Chain.Asset, assetIcon: Icon, amount: Balance): TokenAmountModel { + return TokenAmountModel( + amount = amount.formatPlanks(chainAsset), + tokenIcon = assetIcon + ) + } + } +} + +class TokenAmountView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr) { + + private val binder = ViewTokenAmountBinding.inflate(inflater(), this) + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + + fun setModel(model: TokenAmountModel) { + binder.viewTokenAmountAmount.text = model.amount + binder.viewTokenAmountIcon.setIcon(model.tokenIcon, imageLoader) + } +} diff --git a/feature-swap-impl/src/main/res/drawable/spinner.png b/feature-swap-impl/src/main/res/drawable/spinner.png new file mode 100644 index 0000000..85fd044 Binary files /dev/null and b/feature-swap-impl/src/main/res/drawable/spinner.png differ diff --git a/feature-swap-impl/src/main/res/layout/fragment_main_swap_settings.xml b/feature-swap-impl/src/main/res/layout/fragment_main_swap_settings.xml new file mode 100644 index 0000000..a1a8622 --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/fragment_main_swap_settings.xml @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/fragment_route.xml b/feature-swap-impl/src/main/res/layout/fragment_route.xml new file mode 100644 index 0000000..b55209e --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/fragment_route.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/fragment_swap_confirmation.xml b/feature-swap-impl/src/main/res/layout/fragment_swap_confirmation.xml new file mode 100644 index 0000000..fb3bdb2 --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/fragment_swap_confirmation.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/fragment_swap_execution.xml b/feature-swap-impl/src/main/res/layout/fragment_swap_execution.xml new file mode 100644 index 0000000..d11ca29 --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/fragment_swap_execution.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/fragment_swap_fee.xml b/feature-swap-impl/src/main/res/layout/fragment_swap_fee.xml new file mode 100644 index 0000000..088f8cf --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/fragment_swap_fee.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/fragment_swap_options.xml b/feature-swap-impl/src/main/res/layout/fragment_swap_options.xml new file mode 100644 index 0000000..e781125 --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/fragment_swap_options.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/item_route_header.xml b/feature-swap-impl/src/main/res/layout/item_route_header.xml new file mode 100644 index 0000000..eea1189 --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/item_route_header.xml @@ -0,0 +1,33 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/item_route_swap.xml b/feature-swap-impl/src/main/res/layout/item_route_swap.xml new file mode 100644 index 0000000..0653b3b --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/item_route_swap.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/item_route_transfer.xml b/feature-swap-impl/src/main/res/layout/item_route_transfer.xml new file mode 100644 index 0000000..9765d10 --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/item_route_transfer.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/view_execution_timer.xml b/feature-swap-impl/src/main/res/layout/view_execution_timer.xml new file mode 100644 index 0000000..dd1197e --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/view_execution_timer.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/view_swap_amount_input.xml b/feature-swap-impl/src/main/res/layout/view_swap_amount_input.xml new file mode 100644 index 0000000..666cbf4 --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/view_swap_amount_input.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/layout/view_token_amount.xml b/feature-swap-impl/src/main/res/layout/view_token_amount.xml new file mode 100644 index 0000000..113c5b4 --- /dev/null +++ b/feature-swap-impl/src/main/res/layout/view_token_amount.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/feature-swap-impl/src/main/res/values/attrs.xml b/feature-swap-impl/src/main/res/values/attrs.xml new file mode 100644 index 0000000..7767890 --- /dev/null +++ b/feature-swap-impl/src/main/res/values/attrs.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-versions-api/.gitignore b/feature-versions-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-versions-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-versions-api/build.gradle b/feature-versions-api/build.gradle new file mode 100644 index 0000000..5373d10 --- /dev/null +++ b/feature-versions-api/build.gradle @@ -0,0 +1,41 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + + defaultConfig { + + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.feature_versions_api' +} + +dependencies { + implementation project(':common') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation daggerDep + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler +} \ No newline at end of file diff --git a/feature-versions-api/consumer-rules.pro b/feature-versions-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-versions-api/proguard-rules.pro b/feature-versions-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-versions-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-versions-api/src/main/AndroidManifest.xml b/feature-versions-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/feature-versions-api/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/di/VersionsFeatureApi.kt b/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/di/VersionsFeatureApi.kt new file mode 100644 index 0000000..145345f --- /dev/null +++ b/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/di/VersionsFeatureApi.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_versions_api.di + +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor + +interface VersionsFeatureApi { + + fun interactor(): UpdateNotificationsInteractor +} diff --git a/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/domain/UpdateNotification.kt b/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/domain/UpdateNotification.kt new file mode 100644 index 0000000..32b133a --- /dev/null +++ b/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/domain/UpdateNotification.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_versions_api.domain + +import java.util.Date + +class UpdateNotification( + val version: Version, + val changelog: String?, + val severity: Severity, + val time: Date +) + +class Version( + val major: Long, + val minor: Long, + val patch: Long +) : Comparable { + + companion object { + fun getComparator(): Comparator { + return compareBy { it.major } + .thenBy { it.minor } + .thenBy { it.patch } + } + } + + override operator fun compareTo(other: Version): Int { + return getComparator() + .compare(this, other) + } + + override fun toString(): String { + return "$major.$minor.$patch" + } +} + +enum class Severity { + NORMAL, MAJOR, CRITICAL +} + +fun Version.toUnderscoreString(): String { + return "${major}_${minor}_$patch" +} diff --git a/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/domain/UpdateNotificationsInteractor.kt b/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/domain/UpdateNotificationsInteractor.kt new file mode 100644 index 0000000..638dea2 --- /dev/null +++ b/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/domain/UpdateNotificationsInteractor.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_versions_api.domain + +interface UpdateNotificationsInteractor { + + suspend fun waitPermissionToUpdate() + + fun allowInAppUpdateCheck() + + suspend fun hasImportantUpdates(): Boolean + + suspend fun getUpdateNotifications(): List + + suspend fun skipNewUpdates() + + suspend fun loadVersions() +} diff --git a/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/presentation/VersionsRouter.kt b/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/presentation/VersionsRouter.kt new file mode 100644 index 0000000..a37fd7e --- /dev/null +++ b/feature-versions-api/src/main/java/io/novafoundation/nova/feature_versions_api/presentation/VersionsRouter.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_versions_api.presentation + +interface VersionsRouter { + + fun openAppUpdater() + + fun closeUpdateNotifications() +} diff --git a/feature-versions-impl/.gitignore b/feature-versions-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-versions-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-versions-impl/build.gradle b/feature-versions-impl/build.gradle new file mode 100644 index 0000000..679653c --- /dev/null +++ b/feature-versions-impl/build.gradle @@ -0,0 +1,58 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + + defaultConfig { + + buildConfigField "String", "NOTIFICATIONS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-android-releases/master/updates/v1/entrypoint_dev.json\"" + buildConfigField "String", "NOTIFICATION_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-android-releases/master/updates/changelogs/dev/\"" + + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + buildConfigField "String", "NOTIFICATIONS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-android-releases/master/updates/v1/entrypoint_release.json\"" + buildConfigField "String", "NOTIFICATION_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-android-releases/master/updates/changelogs/release/\"" + } + } + namespace 'io.novafoundation.nova.feature_versions_impl' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':common') + implementation project(':feature-versions-api') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation markwonDep + + implementation daggerDep + ksp daggerCompiler + + implementation retrofitDep + + implementation lifecycleDep + ksp lifecycleCompiler + + testImplementation project(":test-shared") +} \ No newline at end of file diff --git a/feature-versions-impl/consumer-rules.pro b/feature-versions-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-versions-impl/proguard-rules.pro b/feature-versions-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-versions-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-versions-impl/src/main/AndroidManifest.xml b/feature-versions-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/feature-versions-impl/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionMapper.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionMapper.kt new file mode 100644 index 0000000..0589972 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionMapper.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_versions_impl.data + +import io.novafoundation.nova.common.utils.formatting.parseDateISO_8601_NoMs +import io.novafoundation.nova.feature_versions_api.domain.Severity +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotification +import io.novafoundation.nova.feature_versions_api.domain.Version + +const val REMOTE_SEVERITY_CRITICAL = "Critical" +const val REMOTE_SEVERITY_MAJOR = "Major" +const val REMOTE_SEVERITY_NORMAL = "Normal" + +fun mapFromRemoteVersion(version: Version, versionResponse: VersionResponse, changelog: String?): UpdateNotification { + return UpdateNotification( + version, + changelog, + mapSeverity(versionResponse.severity), + parseDateISO_8601_NoMs(versionResponse.time)!! + ) +} + +fun mapSeverity(severity: String): Severity { + return when (severity) { + REMOTE_SEVERITY_CRITICAL -> Severity.CRITICAL + REMOTE_SEVERITY_MAJOR -> Severity.MAJOR + else -> Severity.NORMAL + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionRepository.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionRepository.kt new file mode 100644 index 0000000..b8755ae --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionRepository.kt @@ -0,0 +1,143 @@ +package io.novafoundation.nova.feature_versions_impl.data + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotification +import io.novafoundation.nova.feature_versions_api.domain.Version +import io.novafoundation.nova.feature_versions_api.domain.toUnderscoreString +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface VersionRepository { + suspend fun hasImportantUpdates(): Boolean + + suspend fun getNewUpdateNotifications(): List + + suspend fun skipCurrentUpdates() + + fun inAppUpdatesCheckAllowedFlow(): Flow + + fun allowUpdate() + + suspend fun loadVersions() +} + +class RealVersionRepository( + private val appVersionProvider: AppVersionProvider, + private val preferences: Preferences, + private val versionsFetcher: VersionsFetcher +) : VersionRepository { + + companion object { + private const val PREF_VERSION_CHECKPOINT = "PREF_VERSION_CHECKPOINT" + } + + private val mutex = Mutex(false) + + private val appVersion = getAppVersion() + + private var versions = mapOf() + + private val _inAppUpdatesCheckAllowed = MutableStateFlow(false) + + override fun allowUpdate() { + _inAppUpdatesCheckAllowed.value = true + } + + override suspend fun loadVersions() { + syncAndGetVersions() + } + + override suspend fun hasImportantUpdates(): Boolean { + val lastSkippedVersion = getRecentVersionCheckpoint() + + return syncAndGetVersions().any { it.shouldPresentUpdate(appVersion, lastSkippedVersion) } + } + + private fun Map.Entry.shouldPresentUpdate( + appVersion: Version, + latestSkippedVersion: Version?, + ): Boolean { + val (updateVersion, updateInfo) = this + + val alreadyUpdated = appVersion >= updateVersion + if (alreadyUpdated) return false + + val notImportantUpdate = updateInfo.severity == REMOTE_SEVERITY_NORMAL + if (notImportantUpdate) return false + + val hasSkippedThisUpdate = latestSkippedVersion != null && latestSkippedVersion >= updateVersion + val canBypassSkip = updateInfo.severity == REMOTE_SEVERITY_CRITICAL + + if (hasSkippedThisUpdate && !canBypassSkip) return false + + return true + } + + override suspend fun getNewUpdateNotifications(): List { + return syncAndGetVersions() + .filter { appVersion < it.key } + .map { getChangelogAsync(it.key, it.value) } + .awaitAll() + .filterNotNull() + } + + override suspend fun skipCurrentUpdates() { + val latestUpdateNotification = syncAndGetVersions() + .maxWith { first, second -> first.key.compareTo(second.key) } + preferences.putString(PREF_VERSION_CHECKPOINT, latestUpdateNotification.key.toString()) + } + + override fun inAppUpdatesCheckAllowedFlow(): Flow { + return _inAppUpdatesCheckAllowed + } + + private fun getRecentVersionCheckpoint(): Version? { + val checkpointVersion = preferences.getString(PREF_VERSION_CHECKPOINT) + return checkpointVersion?.toVersion() + } + + private suspend fun getChangelogAsync(version: Version, versionResponse: VersionResponse): Deferred { + return coroutineScope { + async(Dispatchers.Default) { + val versionFileName = version.toUnderscoreString() + val changelog = runCatching { versionsFetcher.getChangelog(versionFileName) }.getOrNull() + mapFromRemoteVersion(version, versionResponse, changelog) + } + } + } + + private suspend fun syncAndGetVersions(): Map { + return mutex.withLock { + if (versions.isEmpty()) { + versions = runCatching { fetchVersions() } + .getOrElse { emptyMap() } + } + versions + } + } + + private suspend fun fetchVersions(): Map { + return versionsFetcher.getVersions() + .associateBy { it.version.toVersion() } + } + + @Suppress("DEPRECATION") + private fun getAppVersion(): Version { + return appVersionProvider.versionName.toVersion() + } + + private fun String.toVersion(): Version { + val cleanedVersion = replace("[^\\d.]".toRegex(), "") + val (major, minor, patch) = cleanedVersion.split(".") + .map { it.toLong() } + return Version(major, minor, patch) + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionsFetcher.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionsFetcher.kt new file mode 100644 index 0000000..60d8451 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionsFetcher.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_versions_impl.data + +import io.novafoundation.nova.feature_versions_impl.BuildConfig +import retrofit2.http.GET +import retrofit2.http.Path + +interface VersionsFetcher { + + @GET(BuildConfig.NOTIFICATIONS_URL) + suspend fun getVersions(): List + + @GET(BuildConfig.NOTIFICATION_DETAILS_URL + "{version}.md") + suspend fun getChangelog(@Path("version") version: String): String +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionsResponse.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionsResponse.kt new file mode 100644 index 0000000..c398c71 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/data/VersionsResponse.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_versions_impl.data + +class VersionResponse( + val version: String, + val severity: String, + val time: String +) diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureComponent.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureComponent.kt new file mode 100644 index 0000000..a4071cd --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureComponent.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_versions_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter +import io.novafoundation.nova.feature_versions_impl.presentation.update.di.UpdateNotificationsComponent + +@Component( + dependencies = [ + VersionsFeatureDependencies::class + ], + modules = [ + VersionsFeatureModule::class + ] +) +@FeatureScope +interface VersionsFeatureComponent : VersionsFeatureApi { + + fun updateNotificationsFragmentComponentFactory(): UpdateNotificationsComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: VersionsRouter, + deps: VersionsFeatureDependencies + ): VersionsFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class + ] + ) + interface StakingFeatureDependenciesComponent : VersionsFeatureDependencies +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureDependencies.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureDependencies.kt new file mode 100644 index 0000000..67bc711 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureDependencies.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_versions_impl.di + +import android.content.Context +import coil.ImageLoader +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.common.resources.ResourceManager + +interface VersionsFeatureDependencies { + + fun resourceManager(): ResourceManager + + fun networkApiCreator(): NetworkApiCreator + + fun imageLoader(): ImageLoader + + fun preferences(): Preferences + + fun context(): Context + + fun appVersionProvider(): AppVersionProvider +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureHolder.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureHolder.kt new file mode 100644 index 0000000..585ae9e --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureHolder.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_versions_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter +import javax.inject.Inject + +@ApplicationScope +class VersionsFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: VersionsRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerVersionsFeatureComponent_StakingFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .build() + + return DaggerVersionsFeatureComponent.factory() + .create( + router = router, + deps = dependencies + ) + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureModule.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureModule.kt new file mode 100644 index 0000000..59eed8d --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/di/VersionsFeatureModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_versions_impl.di + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor +import io.novafoundation.nova.feature_versions_impl.data.RealVersionRepository +import io.novafoundation.nova.feature_versions_impl.data.VersionRepository +import io.novafoundation.nova.feature_versions_impl.data.VersionsFetcher +import io.novafoundation.nova.feature_versions_impl.domain.RealUpdateNotificationsInteractor + +@Module +class VersionsFeatureModule { + + @Provides + fun provideVersionsFetcher( + networkApiCreator: NetworkApiCreator, + ) = networkApiCreator.create(VersionsFetcher::class.java) + + @Provides + fun provideVersionService( + appVersionProvider: AppVersionProvider, + preferences: Preferences, + versionsFetcher: VersionsFetcher + ): VersionRepository = RealVersionRepository(appVersionProvider, preferences, versionsFetcher) + + @Provides + @FeatureScope + fun provideUpdateNotificationsInteractor( + versionRepository: VersionRepository + ): UpdateNotificationsInteractor = RealUpdateNotificationsInteractor(versionRepository) +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/domain/UpdateNotificationsInteractor.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/domain/UpdateNotificationsInteractor.kt new file mode 100644 index 0000000..75f9155 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/domain/UpdateNotificationsInteractor.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_versions_impl.domain + +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotification +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor +import io.novafoundation.nova.feature_versions_impl.data.VersionRepository +import kotlinx.coroutines.flow.first + +class RealUpdateNotificationsInteractor( + private val versionRepository: VersionRepository +) : UpdateNotificationsInteractor { + + override suspend fun loadVersions() { + versionRepository.loadVersions() + } + + override suspend fun waitPermissionToUpdate() { + versionRepository.inAppUpdatesCheckAllowedFlow().first { allowed -> allowed } + } + + override fun allowInAppUpdateCheck() { + versionRepository.allowUpdate() + } + + override suspend fun hasImportantUpdates(): Boolean { + return versionRepository.hasImportantUpdates() + } + + override suspend fun getUpdateNotifications(): List { + return versionRepository.getNewUpdateNotifications() + .sortedByDescending { it.version } + } + + override suspend fun skipNewUpdates() { + versionRepository.skipCurrentUpdates() + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationFragment.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationFragment.kt new file mode 100644 index 0000000..bcebf45 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationFragment.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update + +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.recyclerview.widget.ConcatAdapter +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.LoadingState +import io.novafoundation.nova.feature_versions_api.di.VersionsFeatureApi +import io.novafoundation.nova.feature_versions_impl.databinding.FragmentUpdateNotificationsBinding +import io.novafoundation.nova.feature_versions_impl.di.VersionsFeatureComponent +import io.novafoundation.nova.feature_versions_impl.presentation.update.adapters.UpdateNotificationsAdapter +import io.novafoundation.nova.feature_versions_impl.presentation.update.adapters.UpdateNotificationsBannerAdapter +import io.novafoundation.nova.feature_versions_impl.presentation.update.adapters.UpdateNotificationsSeeAllAdapter + +class UpdateNotificationFragment : + BaseFragment(), + UpdateNotificationsSeeAllAdapter.SeeAllClickedListener { + + override fun createBinding() = FragmentUpdateNotificationsBinding.inflate(layoutInflater) + + private val bannerAdapter = UpdateNotificationsBannerAdapter() + private val listAdapter = UpdateNotificationsAdapter() + private val seeAllAdapter = UpdateNotificationsSeeAllAdapter(this) + private val adapter = ConcatAdapter(bannerAdapter, listAdapter, seeAllAdapter) + + override fun initViews() { + binder.updatesList.adapter = adapter + val decoration = UpdateNotificationsItemDecoration(requireContext()) + binder.updatesList.addItemDecoration(decoration) + binder.updatesToolbar.setRightActionClickListener { viewModel.skipClicked() } + binder.updatesApply.setOnClickListener { viewModel.installUpdateClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(this, VersionsFeatureApi::class.java) + .updateNotificationsFragmentComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: UpdateNotificationViewModel) { + viewModel.bannerModel.observe { + bannerAdapter.setModel(it) + } + + viewModel.notificationModels.observe { + binder.updateNotificationsProgress.isVisible = it is LoadingState.Loading + binder.updatesList.isGone = it is LoadingState.Loading + if (it is LoadingState.Loaded) { + listAdapter.submitList(it.data) + } + } + + viewModel.seeAllButtonVisible.observe { + seeAllAdapter.showButton(it) + } + } + + override fun onSeeAllClicked() { + viewModel.showAllNotifications() + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationModel.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationModel.kt new file mode 100644 index 0000000..589356b --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationModel.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes + +class UpdateNotificationBannerModel( + @DrawableRes val iconRes: Int, + @DrawableRes val backgroundRes: Int, + val title: String, + val message: String +) + +class UpdateNotificationModel( + val version: String, + val changelog: String, + val isLatestUpdate: Boolean, + val severity: String?, + @ColorRes val severityColorRes: Int?, + @ColorRes val severityBackgroundRes: Int?, + val date: String +) + +class SeeAllButtonModel diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationViewModel.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationViewModel.kt new file mode 100644 index 0000000..dfad7a7 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationViewModel.kt @@ -0,0 +1,134 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update + +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.formatting.formatDateSinceEpoch +import io.novafoundation.nova.common.utils.withLoading +import io.novafoundation.nova.feature_versions_api.domain.Severity +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotification +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor +import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter +import io.novafoundation.nova.feature_versions_impl.R +import io.novafoundation.nova.feature_versions_impl.presentation.update.models.UpdateNotificationBannerModel +import io.novafoundation.nova.feature_versions_impl.presentation.update.models.UpdateNotificationModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class UpdateNotificationViewModel( + private val router: VersionsRouter, + private val interactor: UpdateNotificationsInteractor, + private val resourceManager: ResourceManager, + private val markwon: Markwon, +) : BaseViewModel() { + + private val showAllNotifications = MutableStateFlow(false) + + private val notifications = flowOf { interactor.getUpdateNotifications() } + + val bannerModel = notifications.map { getBannerOrNull(it) } + .shareInBackground() + + val notificationModels = combine(showAllNotifications, notifications) { shouldShowAll, notifications -> + val result = if (shouldShowAll) { + notifications + } else { + notifications.take(1) + } + mapUpdateNotificationsToModels(result) + } + .withLoading() + .shareInBackground() + + val seeAllButtonVisible = combine(showAllNotifications, notifications) { shouldShowAll, notifications -> + notifications.size > 1 && !shouldShowAll + } + .shareInBackground() + + fun skipClicked() = launch { + interactor.skipNewUpdates() + router.closeUpdateNotifications() + } + + fun installUpdateClicked() { + router.closeUpdateNotifications() + router.openAppUpdater() + } + + fun showAllNotifications() { + showAllNotifications.value = true + } + + private fun hasCriticalUpdates(list: List): Boolean { + return list.any { + it.severity == Severity.CRITICAL + } + } + + private fun hasMajorUpdates(list: List): Boolean { + return list.any { + it.severity == Severity.MAJOR + } + } + + private fun mapUpdateNotificationsToModels(list: List): List { + return list.mapIndexed { index, version -> + UpdateNotificationModel( + version = version.version.toString(), + changelog = version.changelog?.let { markwon.toMarkdown(it) }, + isLatestUpdate = index == 0, + severity = mapSeverity(version.severity), + severityColorRes = mapSeverityColor(version.severity), + severityBackgroundRes = mapSeverityBackground(version.severity), + date = version.time.formatDateSinceEpoch(resourceManager) + ) + } + } + + private fun mapSeverity(severity: Severity): String? { + return when (severity) { + Severity.CRITICAL -> resourceManager.getString(R.string.update_notifications_severity_critical) + Severity.MAJOR -> resourceManager.getString(R.string.update_notifications_severity_major) + Severity.NORMAL -> null + } + } + + private fun mapSeverityColor(severity: Severity): Int? { + return when (severity) { + Severity.CRITICAL -> R.color.critical_update_chip_text + Severity.MAJOR -> R.color.major_update_chip_text + Severity.NORMAL -> null + } + } + + private fun mapSeverityBackground(severity: Severity): Int? { + return when (severity) { + Severity.CRITICAL -> R.color.critical_update_chip_background + Severity.MAJOR -> R.color.major_update_chip_background + Severity.NORMAL -> null + } + } + + private fun getBannerOrNull(notifications: List): UpdateNotificationBannerModel? { + if (hasCriticalUpdates(notifications)) { + return UpdateNotificationBannerModel( + R.drawable.ic_critical_update, + R.drawable.ic_banner_yellow_gradient, + resourceManager.getString(R.string.update_notifications_critical_update_alert_titile), + resourceManager.getString(R.string.update_notifications_critical_update_alert_subtitile) + ) + } else if (hasMajorUpdates(notifications)) { + return UpdateNotificationBannerModel( + R.drawable.ic_major_update, + R.drawable.ic_banner_turquoise_gradient, + resourceManager.getString(R.string.update_notifications_major_update_alert_titile), + resourceManager.getString(R.string.update_notifications_major_update_alert_subtitile) + ) + } + + return null + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationsAdapter.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationsAdapter.kt new file mode 100644 index 0000000..f646c3d --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationsAdapter.kt @@ -0,0 +1,108 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update + +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_versions_impl.R +import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationBinding +import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationHeaderBinding +import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationSeeAllBinding + +class UpdateNotificationsAdapter(private val seeAllClickedListener: SeeAllClickedListener) : ListAdapter( + DiffCallback +) { + + interface SeeAllClickedListener { + fun onSeeAllClicked() + } + + companion object { + private const val TYPE_HEADER = 0 + private const val TYPE_VERSION = 1 + private const val TYPE_SEE_ALL = 2 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return when (viewType) { + TYPE_HEADER -> UpdateNotificationBannerHolder(ItemUpdateNotificationHeaderBinding.inflate(parent.inflater(), parent, false)) + TYPE_VERSION -> UpdateNotificationHolder(ItemUpdateNotificationBinding.inflate(parent.inflater(), parent, false)) + TYPE_SEE_ALL -> SeeAllButtonHolder(ItemUpdateNotificationSeeAllBinding.inflate(parent.inflater(), parent, false), seeAllClickedListener) + else -> throw IllegalStateException() + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = getItem(position) + when (holder) { + is UpdateNotificationBannerHolder -> holder.bind(item as UpdateNotificationBannerModel) + is UpdateNotificationHolder -> holder.bind(item as UpdateNotificationModel) + } + } + + override fun getItemViewType(position: Int): Int { + val item = getItem(position) + return when (item) { + is UpdateNotificationBannerModel -> TYPE_HEADER + is UpdateNotificationModel -> TYPE_VERSION + is SeeAllButtonModel -> TYPE_SEE_ALL + else -> throw IllegalStateException() + } + } +} + +private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean { + return true + } + + override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { + return true + } +} + +class UpdateNotificationBannerHolder(private val binder: ItemUpdateNotificationHeaderBinding) : GroupedListHolder(binder.root) { + + fun bind(item: UpdateNotificationBannerModel) { + binder.itemUpdateNotificationBanner.setImage(item.iconRes) + binder.itemUpdateNotificationBanner.setBannerBackground(item.backgroundRes) + binder.itemUpdateNotificationAlertTitle.text = item.title + binder.itemUpdateNotificationAlertSubtitle.text = item.message + } +} + +class UpdateNotificationHolder(private val binder: ItemUpdateNotificationBinding) : GroupedListHolder(binder.root) { + + init { + binder.itemNotificationLatest.background = binder.root.context.getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 6) + } + + fun bind(item: UpdateNotificationModel) { + binder.itemNotificationVersion.text = itemView.context.getString(R.string.update_notification_item_version, item.version) + binder.itemNotificationDescription.text = item.changelog + + binder.itemNotificationSeverity.isGone = item.severity == null + binder.itemNotificationSeverity.text = item.severity + item.severityColorRes?.let { binder.itemNotificationSeverity.setTextColorRes(it) } + item.severityBackgroundRes?.let { binder.itemNotificationSeverity.background = itemView.context.getRoundedCornerDrawable(it, cornerSizeInDp = 6) } + + binder.itemNotificationLatest.isVisible = item.isLatestUpdate + binder.itemNotificationDate.text = item.date + } +} + +class SeeAllButtonHolder( + private val binder: ItemUpdateNotificationSeeAllBinding, + seeAllClickedListener: UpdateNotificationsAdapter.SeeAllClickedListener +) : GroupedListHolder(binder.root) { + + init { + binder.root.setOnClickListener { seeAllClickedListener.onSeeAllClicked() } + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationsItemDecoration.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationsItemDecoration.kt new file mode 100644 index 0000000..71a7af8 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/UpdateNotificationsItemDecoration.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.view.View +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.utils.dpF +import io.novafoundation.nova.feature_versions_impl.R +import io.novafoundation.nova.feature_versions_impl.presentation.update.adapters.UpdateNotificationHolder +import kotlin.math.roundToInt + +class UpdateNotificationsItemDecoration( + val context: Context +) : RecyclerView.ItemDecoration() { + + private val dividerColor: Int = context.getColor(R.color.divider) + private val dividerSize: Float = 1.dpF(context) + private val paddingHorizontal: Float = 16.dpF(context) + private val dividerOffset = dividerSize / 2 + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + strokeWidth = dividerSize + color = dividerColor + style = Paint.Style.STROKE + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val index = parent.layoutManager!!.getPosition(view) + if (!parent.shouldApplyDecoration(parent, index, view)) return + + outRect.set(0, 0, 0, dividerSize.roundToInt()) + } + + override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + val children = filterItems(parent) + children.forEach { view -> + val dividerY = view.bottom + dividerOffset + canvas.drawLine(paddingHorizontal, dividerY, view.right - paddingHorizontal, dividerY, paint) + } + } + + private fun filterItems(parent: RecyclerView): List { + return parent.children + .toList() + .filterIndexed { index, view -> parent.shouldApplyDecoration(parent, index, view) } + } + + private fun RecyclerView.shouldApplyDecoration(parent: RecyclerView, index: Int, view: View): Boolean { + val thisViewHolder = getChildViewHolder(view) + val nextViewHolder = getChildAt(index + 1)?.let { getChildViewHolder(it) } + return thisViewHolder is UpdateNotificationHolder && nextViewHolder is UpdateNotificationHolder + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/adapters/UpdateNotificationsAdapter.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/adapters/UpdateNotificationsAdapter.kt new file mode 100644 index 0000000..b520212 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/adapters/UpdateNotificationsAdapter.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update.adapters + +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable +import io.novafoundation.nova.feature_versions_impl.R +import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationBinding +import io.novafoundation.nova.feature_versions_impl.presentation.update.models.UpdateNotificationModel + +class UpdateNotificationsAdapter : ListAdapter( + UpdateNotificationsDiffCallback +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UpdateNotificationHolder { + return UpdateNotificationHolder(ItemUpdateNotificationBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: UpdateNotificationHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +private object UpdateNotificationsDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: UpdateNotificationModel, newItem: UpdateNotificationModel): Boolean { + return oldItem.version == newItem.version + } + + override fun areContentsTheSame(oldItem: UpdateNotificationModel, newItem: UpdateNotificationModel): Boolean { + return true + } +} + +class UpdateNotificationHolder(private val binder: ItemUpdateNotificationBinding) : GroupedListHolder(binder.root) { + + init { + binder.itemNotificationLatest.background = binder.root.context.getRoundedCornerDrawable(R.color.chips_background, cornerSizeInDp = 6) + } + + fun bind(item: UpdateNotificationModel) { + binder.itemNotificationVersion.text = itemView.context.getString(R.string.update_notification_item_version, item.version) + binder.itemNotificationDescription.text = item.changelog + binder.itemNotificationDescription.isVisible = item.changelog != null + + binder.itemNotificationSeverity.isGone = item.severity == null + binder.itemNotificationSeverity.text = item.severity + item.severityColorRes?.let { binder.itemNotificationSeverity.setTextColorRes(it) } + item.severityBackgroundRes?.let { binder.itemNotificationSeverity.background = itemView.context.getRoundedCornerDrawable(it, cornerSizeInDp = 6) } + + binder.itemNotificationLatest.isVisible = item.isLatestUpdate + binder.itemNotificationDate.text = item.date + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/adapters/UpdateNotificationsBannerAdapter.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/adapters/UpdateNotificationsBannerAdapter.kt new file mode 100644 index 0000000..c4b176b --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/adapters/UpdateNotificationsBannerAdapter.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update.adapters + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_versions_impl.databinding.ItemUpdateNotificationHeaderBinding +import io.novafoundation.nova.feature_versions_impl.presentation.update.models.UpdateNotificationBannerModel + +class UpdateNotificationsBannerAdapter : RecyclerView.Adapter() { + + private var bannerModel: UpdateNotificationBannerModel? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UpdateNotificationBannerHolder { + return UpdateNotificationBannerHolder(ItemUpdateNotificationHeaderBinding.inflate(parent.inflater(), parent, false)) + } + + override fun onBindViewHolder(holder: UpdateNotificationBannerHolder, position: Int) { + holder.bind(bannerModel ?: return) + } + + override fun getItemCount(): Int { + return if (bannerModel != null) 1 else 0 + } + + fun setModel(model: UpdateNotificationBannerModel?) { + val newNotNull = model != null + val oldNotNull = bannerModel != null + + bannerModel = model + + if (newNotNull != oldNotNull) { + if (newNotNull) { + notifyItemInserted(0) + } else { + notifyItemRemoved(0) + } + } + } +} + +class UpdateNotificationBannerHolder(private val binder: ItemUpdateNotificationHeaderBinding) : GroupedListHolder(binder.root) { + + fun bind(item: UpdateNotificationBannerModel) { + binder.itemUpdateNotificationBanner.setImage(item.iconRes) + binder.itemUpdateNotificationBanner.setBannerBackground(item.backgroundRes) + binder.itemUpdateNotificationAlertTitle.text = item.title + binder.itemUpdateNotificationAlertSubtitle.text = item.message + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/adapters/UpdateNotificationsSeeAllAdapter.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/adapters/UpdateNotificationsSeeAllAdapter.kt new file mode 100644 index 0000000..7b7ae9f --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/adapters/UpdateNotificationsSeeAllAdapter.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update.adapters + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.inflateChild +import io.novafoundation.nova.feature_versions_impl.R + +class UpdateNotificationsSeeAllAdapter(private val seeAllClickedListener: SeeAllClickedListener) : RecyclerView.Adapter() { + + interface SeeAllClickedListener { + fun onSeeAllClicked() + } + + private var showBanner: Boolean = false + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SeeAllButtonHolder { + return SeeAllButtonHolder(parent.inflateChild(R.layout.item_update_notification_see_all), seeAllClickedListener) + } + + override fun getItemCount(): Int { + return if (showBanner) 1 else 0 + } + + override fun onBindViewHolder(holder: SeeAllButtonHolder, position: Int) {} + + fun showButton(show: Boolean) { + if (showBanner != show) { + showBanner = show + if (showBanner) { + notifyItemInserted(0) + } else { + notifyItemRemoved(0) + } + } + } +} + +class SeeAllButtonHolder(view: View, seeAllClickedListener: UpdateNotificationsSeeAllAdapter.SeeAllClickedListener) : GroupedListHolder(view) { + + init { + view.setOnClickListener { seeAllClickedListener.onSeeAllClicked() } + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/di/UpdateNotificationsComponent.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/di/UpdateNotificationsComponent.kt new file mode 100644 index 0000000..0165d92 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/di/UpdateNotificationsComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_versions_impl.presentation.update.UpdateNotificationFragment + +@Subcomponent( + modules = [ + UpdateNotificationsModule::class + ] +) +@ScreenScope +interface UpdateNotificationsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): UpdateNotificationsComponent + } + + fun inject(fragment: UpdateNotificationFragment) +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/di/UpdateNotificationsModule.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/di/UpdateNotificationsModule.kt new file mode 100644 index 0000000..6fbecc0 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/di/UpdateNotificationsModule.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.noties.markwon.Markwon +import io.novafoundation.nova.common.di.modules.shared.MarkdownFullModule +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_versions_api.domain.UpdateNotificationsInteractor +import io.novafoundation.nova.feature_versions_api.presentation.VersionsRouter +import io.novafoundation.nova.feature_versions_impl.presentation.update.UpdateNotificationViewModel + +@Module(includes = [ViewModelModule::class, MarkdownFullModule::class]) +class UpdateNotificationsModule { + + @Provides + @IntoMap + @ViewModelKey(UpdateNotificationViewModel::class) + fun provideViewModel( + router: VersionsRouter, + interactor: UpdateNotificationsInteractor, + resourceManager: ResourceManager, + markwon: Markwon, + ): ViewModel { + return UpdateNotificationViewModel(router, interactor, resourceManager, markwon) + } + + @Provides + fun provideViewModelCreator( + fragment: Fragment, + viewModelFactory: ViewModelProvider.Factory + ): UpdateNotificationViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(UpdateNotificationViewModel::class.java) + } +} diff --git a/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/models/UpdateNotificationModel.kt b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/models/UpdateNotificationModel.kt new file mode 100644 index 0000000..39b8986 --- /dev/null +++ b/feature-versions-impl/src/main/java/io/novafoundation/nova/feature_versions_impl/presentation/update/models/UpdateNotificationModel.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_versions_impl.presentation.update.models + +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes + +class UpdateNotificationBannerModel( + @DrawableRes val iconRes: Int, + @DrawableRes val backgroundRes: Int, + val title: String, + val message: String +) + +class UpdateNotificationModel( + val version: String, + val changelog: CharSequence?, + val isLatestUpdate: Boolean, + val severity: String?, + @ColorRes val severityColorRes: Int?, + @ColorRes val severityBackgroundRes: Int?, + val date: String +) diff --git a/feature-versions-impl/src/main/res/layout/fragment_update_notifications.xml b/feature-versions-impl/src/main/res/layout/fragment_update_notifications.xml new file mode 100644 index 0000000..5df6fc7 --- /dev/null +++ b/feature-versions-impl/src/main/res/layout/fragment_update_notifications.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-versions-impl/src/main/res/layout/item_update_notification.xml b/feature-versions-impl/src/main/res/layout/item_update_notification.xml new file mode 100644 index 0000000..9c89720 --- /dev/null +++ b/feature-versions-impl/src/main/res/layout/item_update_notification.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-versions-impl/src/main/res/layout/item_update_notification_header.xml b/feature-versions-impl/src/main/res/layout/item_update_notification_header.xml new file mode 100644 index 0000000..b34d0b2 --- /dev/null +++ b/feature-versions-impl/src/main/res/layout/item_update_notification_header.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/feature-versions-impl/src/main/res/layout/item_update_notification_see_all.xml b/feature-versions-impl/src/main/res/layout/item_update_notification_see_all.xml new file mode 100644 index 0000000..53f4aff --- /dev/null +++ b/feature-versions-impl/src/main/res/layout/item_update_notification_see_all.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/feature-versions-impl/src/test/java/io/novafoundation/nova/feature_versions_impl/data/RealVersionRepositoryTest.kt b/feature-versions-impl/src/test/java/io/novafoundation/nova/feature_versions_impl/data/RealVersionRepositoryTest.kt new file mode 100644 index 0000000..30dadf0 --- /dev/null +++ b/feature-versions-impl/src/test/java/io/novafoundation/nova/feature_versions_impl/data/RealVersionRepositoryTest.kt @@ -0,0 +1,131 @@ +package io.novafoundation.nova.feature_versions_impl.data + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.resources.AppVersionProvider +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.whenever +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +internal class RealVersionRepositoryTest { + + @Test + fun `should not show updates if app is updated`() = runBlocking { + runHasImportantUpdatesTest( + appVersion = "1.2.0", + updates = listOf(critical("1.2.0")), + latestSkippedVersion = null, + isImportant = false + ) + + runHasImportantUpdatesTest( + appVersion = "1.2.0", + updates = listOf(normal("1.2.0")), + latestSkippedVersion = null, + isImportant = false + ) + + runHasImportantUpdatesTest( + appVersion = "1.2.0", + updates = listOf(critical("1.1.0"), major("1.2.0")), + latestSkippedVersion = null, + isImportant = false + ) + } + + @Test + fun `should not show updates if there are no updates`() = runBlocking { + runHasImportantUpdatesTest( + appVersion = "1.2.0", + updates = emptyList(), + latestSkippedVersion = null, + isImportant = false + ) + } + + @Test + fun `should show updates if there is a critical update`() = runBlocking { + runHasImportantUpdatesTest( + appVersion = "1.0.0", + updates = listOf(critical("1.1.0")), + latestSkippedVersion = null, + isImportant = true + ) + + // even if it is skipped + runHasImportantUpdatesTest( + appVersion = "1.0.0", + updates = listOf(critical("1.1.0")), + latestSkippedVersion = "1.1.0", + isImportant = true + ) + } + + @Test + fun `should not show updates if there is only a normal update`() = runBlocking { + runHasImportantUpdatesTest( + appVersion = "1.0.0", + updates = listOf(normal("1.0.1")), + latestSkippedVersion = null, + isImportant = false + ) + } + + @Test + fun `should not show updates if major update was skipped`() = runBlocking { + runHasImportantUpdatesTest( + appVersion = "1.0.0", + updates = listOf(major("1.1.0")), + latestSkippedVersion = "1.1.0", + isImportant = false + ) + } + + @Test + fun `should not show updates if critical update is installed and others are skipped major`() = runBlocking { + runHasImportantUpdatesTest( + appVersion = "1.1.0", + updates = listOf(critical("1.1.0"), major("1.2.0")), + latestSkippedVersion = "1.2.0", + isImportant = false + ) + } + + + private suspend fun runHasImportantUpdatesTest( + appVersion: String, + updates: List>, // [(version, priority)] + latestSkippedVersion: String?, + isImportant: Boolean + ) { + val preferences = Mockito.mock(Preferences::class.java).also { + whenever(it.getString(any())).thenReturn(latestSkippedVersion) + } + + val versionResponses = updates.map { (version, severity) -> VersionResponse(version, severity, "2022-12-22T06:46:07Z") } + val fetcher = Mockito.mock(VersionsFetcher::class.java).also { + whenever(it.getVersions()).thenReturn(versionResponses) + } + + val appVersionProvider = Mockito.mock(AppVersionProvider::class.java).also { + whenever(it.versionName).thenReturn(appVersion) + } + + val repository = RealVersionRepository(appVersionProvider, preferences, fetcher) + + val actualIsImportant = repository.hasImportantUpdates() + + assertEquals(isImportant, actualIsImportant) + } + + private fun critical(version: String) = version to REMOTE_SEVERITY_CRITICAL + + private fun normal(version: String) = version to REMOTE_SEVERITY_NORMAL + + private fun major(version: String) = version to REMOTE_SEVERITY_MAJOR +} diff --git a/feature-vote/.gitignore b/feature-vote/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-vote/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-vote/build.gradle b/feature-vote/build.gradle new file mode 100644 index 0000000..8373d76 --- /dev/null +++ b/feature-vote/build.gradle @@ -0,0 +1,50 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + namespace 'io.novafoundation.nova.feature_vote' + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':common') + implementation project(':feature-account-api') + + implementation kotlinDep + + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation lifeCycleKtxDep + + implementation daggerDep + ksp daggerCompiler + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation insetterDep + + implementation shimmerDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} \ No newline at end of file diff --git a/feature-vote/consumer-rules.pro b/feature-vote/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-vote/proguard-rules.pro b/feature-vote/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-vote/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-vote/src/main/AndroidManifest.xml b/feature-vote/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-vote/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureApi.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureApi.kt new file mode 100644 index 0000000..28d723c --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureApi.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_vote.di + +interface VoteFeatureApi diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureComponent.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureComponent.kt new file mode 100644 index 0000000..9ae8d9b --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureComponent.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_vote.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_vote.presentation.VoteRouter +import io.novafoundation.nova.feature_vote.presentation.vote.di.VoteComponent + +@Component( + dependencies = [ + VoteFeatureDependencies::class + ], + modules = [ + VoteFeatureModule::class, + ] +) +@FeatureScope +interface VoteFeatureComponent : VoteFeatureApi { + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance voteRouter: VoteRouter, + deps: VoteFeatureDependencies + ): VoteFeatureComponent + } + + fun voteComponentFactory(): VoteComponent.Factory + + @Component( + dependencies = [ + CommonApi::class, + AccountFeatureApi::class + ] + ) + interface VoteFeatureDependenciesComponent : VoteFeatureDependencies +} diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureDependencies.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureDependencies.kt new file mode 100644 index 0000000..97b483a --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureDependencies.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_vote.di + +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase + +interface VoteFeatureDependencies { + + val selectedAccountUseCase: SelectedAccountUseCase +} diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureHolder.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureHolder.kt new file mode 100644 index 0000000..a7bd84f --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureHolder.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_vote.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_vote.presentation.VoteRouter +import javax.inject.Inject + +@ApplicationScope +class VoteFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: VoteRouter +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerVoteFeatureComponent_VoteFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .build() + return DaggerVoteFeatureComponent.factory() + .create(router, dependencies) + } +} diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureModule.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureModule.kt new file mode 100644 index 0000000..5425837 --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/di/VoteFeatureModule.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_vote.di + +import dagger.Module + +@Module +class VoteFeatureModule diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/VoteRouter.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/VoteRouter.kt new file mode 100644 index 0000000..82a1367 --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/VoteRouter.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_vote.presentation + +import androidx.fragment.app.Fragment + +interface VoteRouter { + + fun getDemocracyFragment(): Fragment + + fun getCrowdloansFragment(): Fragment + + fun openSwitchWallet() +} diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/VoteFragment.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/VoteFragment.kt new file mode 100644 index 0000000..cedec06 --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/VoteFragment.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_vote.presentation.vote + +import android.view.View +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.setupWithViewPager2 +import io.novafoundation.nova.feature_vote.databinding.FragmentVoteBinding +import io.novafoundation.nova.feature_vote.di.VoteFeatureApi +import io.novafoundation.nova.feature_vote.di.VoteFeatureComponent +import io.novafoundation.nova.feature_vote.presentation.VoteRouter + +import javax.inject.Inject + +class VoteFragment : BaseFragment() { + + override fun createBinding() = FragmentVoteBinding.inflate(layoutInflater) + + @Inject + lateinit var router: VoteRouter + + override fun applyInsets(rootView: View) { + binder.voteContainer.applyStatusBarInsets() + } + + override fun initViews() { + val adapter = VotePagerAdapter(this, router) + binder.voteViewPager.adapter = adapter + binder.voteTabs.setupWithViewPager2(binder.voteViewPager, adapter::getPageTitle) + + binder.voteAvatar.setOnClickListener { viewModel.avatarClicked() } + } + + override fun inject() { + FeatureUtils.getFeature(this, VoteFeatureApi::class.java) + .voteComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: VoteViewModel) { + viewModel.selectedWalletModel.observe(binder.voteAvatar::setModel) + } +} diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/VotePagerAdapter.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/VotePagerAdapter.kt new file mode 100644 index 0000000..dd53d05 --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/VotePagerAdapter.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_vote.presentation.vote + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import io.novafoundation.nova.feature_vote.R +import io.novafoundation.nova.feature_vote.presentation.VoteRouter + +class VotePagerAdapter(private val fragment: Fragment, private val router: VoteRouter) : FragmentStateAdapter(fragment) { + + override fun getItemCount(): Int { + return 2 + } + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> router.getDemocracyFragment() + 1 -> router.getCrowdloansFragment() + else -> throw IllegalArgumentException("Invalid position") + } + } + + fun getPageTitle(position: Int): CharSequence { + return when (position) { + 0 -> fragment.getString(R.string.common_governance) + 1 -> fragment.getString(R.string.crowdloan_crowdloan) + else -> throw IllegalArgumentException("Invalid position") + } + } +} diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/VoteViewModel.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/VoteViewModel.kt new file mode 100644 index 0000000..88117f7 --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/VoteViewModel.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_vote.presentation.vote + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_vote.presentation.VoteRouter + +class VoteViewModel( + private val router: VoteRouter, + private val selectedAccountUseCase: SelectedAccountUseCase, +) : BaseViewModel() { + + val selectedWalletModel = selectedAccountUseCase.selectedWalletModelFlow() + .shareInBackground() + + fun avatarClicked() { + router.openSwitchWallet() + } +} diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/di/VoteComponent.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/di/VoteComponent.kt new file mode 100644 index 0000000..254e703 --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/di/VoteComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_vote.presentation.vote.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_vote.presentation.vote.VoteFragment + +@Subcomponent( + modules = [ + VoteModule::class + ] +) +@ScreenScope +interface VoteComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment + ): VoteComponent + } + + fun inject(fragment: VoteFragment) +} diff --git a/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/di/VoteModule.kt b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/di/VoteModule.kt new file mode 100644 index 0000000..e2b9edf --- /dev/null +++ b/feature-vote/src/main/java/io/novafoundation/nova/feature_vote/presentation/vote/di/VoteModule.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_vote.presentation.vote.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_vote.presentation.VoteRouter +import io.novafoundation.nova.feature_vote.presentation.vote.VoteViewModel + +@Module(includes = [ViewModelModule::class]) +class VoteModule { + + @Provides + internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): VoteViewModel { + return ViewModelProvider(fragment, factory).get(VoteViewModel::class.java) + } + + @Provides + @IntoMap + @ViewModelKey(VoteViewModel::class) + fun provideViewModel( + voteRouter: VoteRouter, + selectedAccountUseCase: SelectedAccountUseCase + ): ViewModel { + return VoteViewModel( + router = voteRouter, + selectedAccountUseCase = selectedAccountUseCase + ) + } +} diff --git a/feature-vote/src/main/res/layout/fragment_vote.xml b/feature-vote/src/main/res/layout/fragment_vote.xml new file mode 100644 index 0000000..44755c7 --- /dev/null +++ b/feature-vote/src/main/res/layout/fragment_vote.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-vote/src/test/java/io/novafoundation/nova/feature_vote/ExampleUnitTest.kt b/feature-vote/src/test/java/io/novafoundation/nova/feature_vote/ExampleUnitTest.kt new file mode 100644 index 0000000..c546c07 --- /dev/null +++ b/feature-vote/src/test/java/io/novafoundation/nova/feature_vote/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_vote + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/feature-wallet-api/.gitignore b/feature-wallet-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-wallet-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-wallet-api/build.gradle b/feature-wallet-api/build.gradle new file mode 100644 index 0000000..69c8bfe --- /dev/null +++ b/feature-wallet-api/build.gradle @@ -0,0 +1,37 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_wallet_api' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":feature-account-api") + implementation project(":feature-currency-api") + implementation project(':runtime') + implementation project(":common") + + implementation androidDep + implementation materialDep + + implementation daggerDep + implementation project(':feature-xcm:api') + ksp daggerCompiler + + implementation substrateSdkDep + + implementation constraintDep + + implementation lifeCycleKtxDep + + api project(':core-api') + api project(':core-db') + + + testImplementation project(':test-shared') +} \ No newline at end of file diff --git a/feature-wallet-api/src/main/AndroidManifest.xml b/feature-wallet-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/feature-wallet-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/cache/AssetCache.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/cache/AssetCache.kt new file mode 100644 index 0000000..79e55e0 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/cache/AssetCache.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_wallet_api.data.cache + +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.AssetReadOnlyCache +import io.novafoundation.nova.core_db.dao.ClearAssetsParams +import io.novafoundation.nova.core_db.dao.TokenDao +import io.novafoundation.nova.core_db.model.AssetLocal +import io.novafoundation.nova.core_db.model.TokenLocal +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.ext.enabledAssets +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class AssetCache( + private val tokenDao: TokenDao, + private val accountRepository: AccountRepository, + private val assetDao: AssetDao, +) : AssetReadOnlyCache by assetDao { + + private val assetUpdateMutex = Mutex() + + /** + * @return true if asset was changed. false if it remained the same + */ + suspend fun updateAsset( + metaId: Long, + chainAsset: Chain.Asset, + builder: (local: AssetLocal) -> AssetLocal, + ): Boolean = withContext(Dispatchers.IO) { + val assetId = chainAsset.id + val chainId = chainAsset.chainId + + assetUpdateMutex.withLock { + val cachedAsset = assetDao.getAsset(metaId, chainId, assetId) ?: AssetLocal.createEmpty(assetId, chainId, metaId) + + val newAsset = builder.invoke(cachedAsset) + + assetDao.insertAsset(newAsset) + + cachedAsset != newAsset + } + } + + /** + * @see updateAsset + */ + suspend fun updateAsset( + accountId: AccountId, + chainAsset: Chain.Asset, + builder: (local: AssetLocal) -> AssetLocal, + ): Boolean = withContext(Dispatchers.IO) { + val applicableMetaAccount = accountRepository.findMetaAccount(accountId, chainAsset.chainId) + + applicableMetaAccount?.let { + updateAsset(it.id, chainAsset, builder) + } ?: false + } + + suspend fun updateAssetsByChain( + metaAccount: MetaAccount, + chain: Chain, + builder: (Chain.Asset) -> AssetLocal + ): CollectionDiffer.Diff = withContext(Dispatchers.IO) { + val oldAssetsLocal = getAssetsInChain(metaAccount.id, chain.id) + val newAssetsLocal = chain.enabledAssets().map { builder(it) } + val diff = CollectionDiffer.findDiff(newAssetsLocal, oldAssetsLocal, forceUseNewItems = false) + assetDao.insertAssets(diff.newOrUpdated) + diff + } + + suspend fun clearAssets(assetIds: List) = withContext(Dispatchers.IO) { + val localAssetIds = assetIds.map { ClearAssetsParams(it.chainId, it.assetId) } + assetDao.clearAssets(localAssetIds) + } + + suspend fun deleteAllTokens() { + tokenDao.deleteAll() + } + + suspend fun updateTokens(newTokens: List) { + val oldTokens = tokenDao.getTokens() + val diff = CollectionDiffer.findDiff(newTokens, oldTokens, forceUseNewItems = false) + tokenDao.applyDiff(diff) + } + + suspend fun insertToken(tokens: TokenLocal) = tokenDao.insertToken(tokens) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/cache/AssetCacheExt.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/cache/AssetCacheExt.kt new file mode 100644 index 0000000..900332e --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/cache/AssetCacheExt.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_wallet_api.data.cache + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountInfo +import io.novafoundation.nova.common.domain.balance.EDCountingMode +import io.novafoundation.nova.common.domain.balance.TransferableMode +import io.novafoundation.nova.core_db.model.AssetLocal +import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal +import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot + +suspend fun AssetCache.updateAsset( + metaId: Long, + chainAsset: Chain.Asset, + accountInfo: AccountInfo, +) = updateAsset(metaId, chainAsset, nativeBalanceUpdater(accountInfo)) + +suspend fun AssetCache.updateAsset( + accountId: AccountId, + chainAsset: Chain.Asset, + accountInfo: AccountInfo, +) = updateAsset(accountId, chainAsset, nativeBalanceUpdater(accountInfo)) + +suspend fun AssetCache.updateNonLockableAsset( + metaId: Long, + chainAsset: Chain.Asset, + assetBalance: Balance, +) { + updateAsset(metaId, chainAsset) { + it.copy( + freeInPlanks = assetBalance, + frozenInPlanks = Balance.ZERO, + reservedInPlanks = Balance.ZERO, + transferableMode = TransferableModeLocal.REGULAR, + edCountingMode = EDCountingModeLocal.TOTAL, + ) + } +} + +suspend fun AssetCache.updateFromChainBalance( + metaId: Long, + chainAssetBalance: ChainAssetBalance +) { + updateAsset(metaId, chainAssetBalance.chainAsset) { + it.copy( + freeInPlanks = chainAssetBalance.free, + frozenInPlanks = chainAssetBalance.frozen, + reservedInPlanks = chainAssetBalance.reserved, + transferableMode = chainAssetBalance.transferableMode.toLocal(), + edCountingMode = chainAssetBalance.edCountingMode.toLocal() + ) + } +} + +fun TransferableMode.toLocal(): TransferableModeLocal { + return when (this) { + TransferableMode.REGULAR -> TransferableModeLocal.REGULAR + TransferableMode.HOLDS_AND_FREEZES -> TransferableModeLocal.HOLDS_AND_FREEZES + } +} + +fun EDCountingMode.toLocal(): EDCountingModeLocal { + return when (this) { + EDCountingMode.TOTAL -> EDCountingModeLocal.TOTAL + EDCountingMode.FREE -> EDCountingModeLocal.FREE + } +} + +private fun nativeBalanceUpdater(accountInfo: AccountInfo) = { asset: AssetLocal -> + val data = accountInfo.data + + val transferableMode: TransferableModeLocal + val edCountingMode: EDCountingModeLocal + + if (data.flags.holdsAndFreezesEnabled()) { + transferableMode = TransferableModeLocal.HOLDS_AND_FREEZES + edCountingMode = EDCountingModeLocal.FREE + } else { + transferableMode = TransferableModeLocal.REGULAR + edCountingMode = EDCountingModeLocal.TOTAL + } + + asset.copy( + freeInPlanks = data.free, + frozenInPlanks = data.frozen, + reservedInPlanks = data.reserved, + transferableMode = transferableMode, + edCountingMode = edCountingMode + ) +} + +fun bindAccountInfoOrDefault(hex: String?, runtime: RuntimeSnapshot): AccountInfo { + return hex?.let { bindAccountInfo(it, runtime) } ?: AccountInfo.empty() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/cache/CoinPriceLocalDataSourceImpl.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/cache/CoinPriceLocalDataSourceImpl.kt new file mode 100644 index 0000000..469a236 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/cache/CoinPriceLocalDataSourceImpl.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_wallet_api.data.cache + +import io.novafoundation.nova.core_db.dao.CoinPriceDao +import io.novafoundation.nova.core_db.model.CoinPriceLocal +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.source.CoinPriceLocalDataSource +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate + +class CoinPriceLocalDataSourceImpl( + private val coinPriceDao: CoinPriceDao +) : CoinPriceLocalDataSource { + + override suspend fun getFloorCoinPriceAtTime(priceId: String, currency: Currency, timestamp: Long): HistoricalCoinRate? { + val coinPriceLocal = coinPriceDao.getFloorCoinPriceAtTime(priceId, currency.code, timestamp) + return coinPriceLocal?.let { mapCoinPriceFromLocal(it) } + } + + override suspend fun hasCeilingCoinPriceAtTime(priceId: String, currency: Currency, timestamp: Long): Boolean { + return coinPriceDao.hasCeilingCoinPriceAtTime(priceId, currency.code, timestamp) + } + + override suspend fun getCoinPriceRange(priceId: String, currency: Currency, fromTimestamp: Long, toTimestamp: Long): List { + return coinPriceDao.getCoinPriceRange(priceId, currency.code, fromTimestamp, toTimestamp) + .map { mapCoinPriceFromLocal(it) } + } + + override suspend fun updateCoinPrice(priceId: String, currency: Currency, coinRate: List) { + coinPriceDao.updateCoinPrices(priceId, currency.code, coinRate.map { mapCoinPriceToLocal(priceId, currency, it) }) + } + + private fun mapCoinPriceFromLocal(coinPriceLocal: CoinPriceLocal): HistoricalCoinRate { + return HistoricalCoinRate( + timestamp = coinPriceLocal.timestamp, + rate = coinPriceLocal.rate + ) + } + + private fun mapCoinPriceToLocal(priceId: String, currency: Currency, coinPrice: HistoricalCoinRate): CoinPriceLocal { + return CoinPriceLocal( + priceId = priceId, + currencyId = currency.code, + timestamp = coinPrice.timestamp, + rate = coinPrice.rate + ) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/mappers/Asset.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/mappers/Asset.kt new file mode 100644 index 0000000..de7b152 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/mappers/Asset.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_wallet_api.data.mappers + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.presentation.masking.map +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetModel + +@Deprecated("Create and use special formatter for that") +fun mapAssetToAssetModel( + assetIconProvider: AssetIconProvider, + asset: Asset, + resourceManager: ResourceManager, + maskableBalance: MaskableModel, + icon: Icon = assetIconProvider.getAssetIconOrFallback(asset.token.configuration), + @StringRes patternId: Int? = R.string.common_available_format +): AssetModel { + val formattedAmount = maskableBalance.map { it.formatPlanks(asset.token.configuration) } + .map { amount -> patternId?.let { resourceManager.getString(patternId, amount) } ?: amount } + + return with(asset) { + AssetModel( + chainId = asset.token.configuration.chainId, + chainAssetId = asset.token.configuration.id, + icon = icon, + tokenName = token.configuration.name, + tokenSymbol = token.configuration.symbol.value, + assetBalance = formattedAmount + ) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/mappers/Fee.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/mappers/Fee.kt new file mode 100644 index 0000000..81cbe65 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/mappers/Fee.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_api.data.mappers + +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.toFeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig + +@Suppress("DeprecatedCallableAddReplaceWith") +@Deprecated("This is a internal logic related to fee mixin. To access or set the fee use corresponding methods from FeeLoaderMixinV2.Presentation") +fun mapFeeToFeeModel( + fee: F, + token: Token, + includeZeroFiat: Boolean = true, + amountFormatter: AmountFormatter +): FeeModel = FeeModel( + display = amountFormatter.formatAmountToAmountModel( + amountInPlanks = fee.amount, + token = token, + AmountConfig(includeZeroFiat = includeZeroFiat) + ).toFeeDisplay(), + fee = fee +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/mappers/Operation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/mappers/Operation.kt new file mode 100644 index 0000000..3cf5811 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/mappers/Operation.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_wallet_api.data.mappers + +import io.novafoundation.nova.core_db.model.AssetAndChainId +import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal +import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation + +fun mapOperationStatusToOperationLocalStatus(status: Operation.Status) = when (status) { + Operation.Status.PENDING -> OperationBaseLocal.Status.PENDING + Operation.Status.COMPLETED -> OperationBaseLocal.Status.COMPLETED + Operation.Status.FAILED -> OperationBaseLocal.Status.FAILED +} + +fun mapAssetWithAmountToLocal( + chainAssetWithAmount: ChainAssetWithAmount +): SwapTypeLocal.AssetWithAmount = with(chainAssetWithAmount) { + return SwapTypeLocal.AssetWithAmount( + assetId = AssetAndChainId(chainAsset.chainId, chainAsset.id), + amount = amount + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/ExtrinsicBuilderExt.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/ExtrinsicBuilderExt.kt new file mode 100644 index 0000000..d6ca30b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/ExtrinsicBuilderExt.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.argumentType +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.common.utils.firstExistingCallName +import io.novafoundation.nova.common.utils.hasCall +import io.novafoundation.nova.runtime.util.constructAccountLookupInstance +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import io.novasama.substrate_sdk_android.runtime.metadata.call +import java.math.BigInteger + +enum class TransferMode { + KEEP_ALIVE, ALLOW_DEATH, ALL +} + +fun ExtrinsicBuilder.nativeTransfer(accountId: AccountId, amount: BigInteger, mode: TransferMode = TransferMode.ALLOW_DEATH): ExtrinsicBuilder { + when (mode) { + TransferMode.KEEP_ALIVE -> transferKeepAlive(accountId, amount) + TransferMode.ALLOW_DEATH -> transferAllowDeath(accountId, amount) + TransferMode.ALL -> transferAll(accountId, amount) + } + + return this +} + +private fun ExtrinsicBuilder.transferKeepAlive(accountId: AccountId, amount: BigInteger) { + val destType = runtime.metadata.balances().call("transfer_keep_alive").argumentType("dest") + + call( + moduleName = Modules.BALANCES, + callName = "transfer_keep_alive", + arguments = mapOf( + "dest" to destType.constructAccountLookupInstance(accountId), + "value" to amount + ) + ) +} + +private fun ExtrinsicBuilder.transferAllowDeath(accountId: AccountId, amount: BigInteger) { + val callName = runtime.metadata.balances().firstExistingCallName("transfer_allow_death", "transfer") + val destType = runtime.metadata.balances().call(callName).argumentType("dest") + + call( + moduleName = Modules.BALANCES, + callName = callName, + arguments = mapOf( + "dest" to destType.constructAccountLookupInstance(accountId), + "value" to amount + ) + ) +} + +private fun ExtrinsicBuilder.transferAll(accountId: AccountId, amount: BigInteger) { + val transferAllPresent = runtime.metadata.balances().hasCall("transfer_all") + + if (transferAllPresent) { + val destType = runtime.metadata.balances().call("transfer_all").argumentType("dest") + + call( + moduleName = Modules.BALANCES, + callName = "transfer_all", + arguments = mapOf( + "dest" to destType.constructAccountLookupInstance(accountId), + "keep_alive" to false + ) + ) + } else { + transferAllowDeath(accountId, amount) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/AssetSource.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/AssetSource.kt new file mode 100644 index 0000000..78cdaa1 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/AssetSource.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers + +interface AssetSource { + + val transfers: AssetTransfers + + val balance: AssetBalance + + val history: AssetHistory +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/AssetSourceRegistry.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/AssetSourceRegistry.kt new file mode 100644 index 0000000..bbe4960 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/AssetSourceRegistry.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface AssetSourceRegistry { + + fun sourceFor(chainAsset: Chain.Asset): AssetSource + + fun allSources(): List + + suspend fun getEventDetector(chainAsset: Chain.Asset): AssetEventDetector +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/AssetSourceRegistryExt.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/AssetSourceRegistryExt.kt new file mode 100644 index 0000000..6975007 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/AssetSourceRegistryExt.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets + +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +suspend fun AssetSourceRegistry.existentialDeposit(chainAsset: Chain.Asset): BigDecimal { + return chainAsset.amountFromPlanks(existentialDepositInPlanks(chainAsset)) +} + +suspend fun AssetSourceRegistry.existentialDepositInPlanks(chainAsset: Chain.Asset): BigInteger { + return sourceFor(chainAsset).balance.existentialDeposit(chainAsset) +} + +suspend fun AssetSourceRegistry.totalCanBeDroppedBelowMinimumBalance(chainAsset: Chain.Asset): Boolean { + return sourceFor(chainAsset).transfers.totalCanDropBelowMinimumBalance(chainAsset) +} + +fun AssetSourceRegistry.isSelfSufficientAsset(chainAsset: Chain.Asset): Boolean { + return sourceFor(chainAsset).balance.isSelfSufficient(chainAsset) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/AssetBalance.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/AssetBalance.kt new file mode 100644 index 0000000..e4b277e --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/AssetBalance.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.domain.validation.balance.ValidatingBalance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import java.math.BigInteger + +sealed class BalanceSyncUpdate { + + class CauseFetchable(val blockHash: BlockHash) : BalanceSyncUpdate() + + class CauseFetched(val cause: RealtimeHistoryUpdate) : BalanceSyncUpdate() + + object NoCause : BalanceSyncUpdate() +} + +interface AssetBalance { + + suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> + + suspend fun startSyncingBalanceHolds( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> = emptyFlow() + + fun isSelfSufficient(chainAsset: Chain.Asset): Boolean + + suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger + + suspend fun queryAccountBalance( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId + ): ChainAssetBalance + + suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow + + /** + * @return emits hash of the blocks where changes occurred. If no change were detected based on the upstream event - should emit null + */ + suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow +} + +suspend fun AssetBalance.queryAccountBalanceCatching( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId +): Result { + return runCatching { queryAccountBalance(chain, chainAsset, accountId) } +} + +suspend fun AssetBalance.accountBalanceForValidation( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId +): ValidatingBalance { + val assetBalance = queryAccountBalance(chain, chainAsset, accountId) + val ed = existentialDeposit(chainAsset) + return ValidatingBalance(assetBalance, ed) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/model/ChainAssetBalance.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/model/ChainAssetBalance.kt new file mode 100644 index 0000000..9bd3c67 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/model/ChainAssetBalance.kt @@ -0,0 +1,89 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance +import io.novafoundation.nova.common.data.network.runtime.binding.AccountData +import io.novafoundation.nova.common.data.network.runtime.binding.edCountingMode +import io.novafoundation.nova.common.data.network.runtime.binding.transferableMode +import io.novafoundation.nova.common.domain.balance.EDCountingMode +import io.novafoundation.nova.common.domain.balance.TransferableMode +import io.novafoundation.nova.common.domain.balance.calculateBalanceCountedTowardsEd +import io.novafoundation.nova.common.domain.balance.calculateReservable +import io.novafoundation.nova.common.domain.balance.calculateTransferable +import io.novafoundation.nova.common.domain.balance.reservedPreventsDusting +import io.novafoundation.nova.common.domain.balance.totalBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +data class ChainAssetBalance( + val chainAsset: Chain.Asset, + val free: Balance, + val reserved: Balance, + val frozen: Balance, + val transferableMode: TransferableMode, + val edCountingMode: EDCountingMode +) { + + companion object { + + fun default(chainAsset: Chain.Asset, free: Balance, reserved: Balance, frozen: Balance): ChainAssetBalance { + return ChainAssetBalance(chainAsset, free, reserved, frozen, TransferableMode.REGULAR, EDCountingMode.TOTAL) + } + + fun default(chainAsset: Chain.Asset, accountBalance: AccountBalance): ChainAssetBalance { + return default(chainAsset, free = accountBalance.free, reserved = accountBalance.reserved, frozen = accountBalance.free) + } + + fun fromFree(chainAsset: Chain.Asset, free: Balance): ChainAssetBalance { + return default(chainAsset, free = free, reserved = BigInteger.ZERO, frozen = BigInteger.ZERO) + } + } + + /** + * Can be used to view current balance from the legacy perspective + * Useful for pallets that still use old Currencies implementation instead of Fungibles + */ + fun legacyAdapter(): ChainAssetBalance { + return copy(transferableMode = TransferableMode.REGULAR, edCountingMode = EDCountingMode.TOTAL) + } + + val total = totalBalance(free, reserved) + + val transferable = transferableMode.calculateTransferable(free, frozen, reserved) + + val countedTowardsEd = edCountingMode.calculateBalanceCountedTowardsEd(free, reserved) + + fun reservable(existentialDeposit: Balance): Balance { + return transferableMode.calculateReservable(free = free, frozen = frozen, ed = existentialDeposit) + } + + fun shouldBeDusted(existentialDeposit: Balance): Boolean { + // https://github.com/paritytech/polkadot-sdk/blob/e5ac83cd28610bd10a85638d90a8ee082ef2d908/substrate/frame/balances/src/lib.rs#L1096 + return free < existentialDeposit && !edCountingMode.reservedPreventsDusting(reserved) + } +} + +fun ChainAssetBalance.ensureMeetsEdOrDust(existentialDeposit: Balance): ChainAssetBalance { + return if (shouldBeDusted(existentialDeposit)) { + copy(free = BigInteger.ZERO, reserved = BigInteger.ZERO, frozen = BigInteger.ZERO) + } else { + this + } +} + +fun ChainAssetBalance.transferableAmount(): BigDecimal = chainAsset.amountFromPlanks(transferable) + +fun ChainAssetBalance.countedTowardsEdAmount(): BigDecimal = chainAsset.amountFromPlanks(countedTowardsEd) + +fun AccountData.toChainAssetBalance(chainAsset: Chain.Asset): ChainAssetBalance { + return ChainAssetBalance( + chainAsset = chainAsset, + free = free, + reserved = reserved, + frozen = frozen, + transferableMode = flags.transferableMode(), + edCountingMode = flags.edCountingMode() + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/model/StatemineAssetDetails.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/model/StatemineAssetDetails.kt new file mode 100644 index 0000000..457e13e --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/model/StatemineAssetDetails.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model + +import io.novafoundation.nova.common.address.AccountIdKey +import java.math.BigInteger + +class StatemineAssetDetails( + val status: Status, + val isSufficient: Boolean, + val minimumBalance: BigInteger, + val issuer: AccountIdKey +) { + + enum class Status { + Live, Frozen, Destroying + } +} + +val StatemineAssetDetails.Status.transfersFrozen: Boolean + get() = this != StatemineAssetDetails.Status.Live diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/model/TransferableBalanceUpdatePoint.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/model/TransferableBalanceUpdatePoint.kt new file mode 100644 index 0000000..481fb03 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/balances/model/TransferableBalanceUpdatePoint.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash + +data class TransferableBalanceUpdatePoint( + val updatedAt: BlockHash +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/events/AssetEventDetector.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/events/AssetEventDetector.kt new file mode 100644 index 0000000..18679dd --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/events/AssetEventDetector.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events + +import android.util.Log +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +interface AssetEventDetector { + + fun detectDeposit(event: GenericEvent.Instance): DepositEvent? +} + +fun AssetEventDetector.tryDetectDeposit(event: GenericEvent.Instance): DepositEvent? { + return runCatching { detectDeposit(event) } + .onFailure { Log.w("AssetEventDetector", "Failed to parse event $event", it) } + .getOrNull() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/events/model/DepositEvent.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/events/model/DepositEvent.kt new file mode 100644 index 0000000..6399e0a --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/events/model/DepositEvent.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class DepositEvent( + val destination: AccountIdKey, + val amount: Balance, +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/events/model/TransferEvent.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/events/model/TransferEvent.kt new file mode 100644 index 0000000..95084e0 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/events/model/TransferEvent.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.AccountId + +class TransferEvent( + val from: AccountId, + val to: AccountId, + val amount: Balance +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/history/AssetHistory.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/history/AssetHistory.kt new file mode 100644 index 0000000..cccf659 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/history/AssetHistory.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history + +import io.novafoundation.nova.common.data.model.DataPage +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface AssetHistory { + + suspend fun fetchOperationsForBalanceChange( + chain: Chain, + chainAsset: Chain.Asset, + blockHash: String, + accountId: AccountId, + ): List + + fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set + + suspend fun additionalFirstPageSync( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + page: Result> + ) + + suspend fun getOperations( + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency, + ): DataPage + + suspend fun getSyncedPageOffset( + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset + ): PageOffset + + /** + * Checks if operation is not a phishing one + */ + fun isOperationSafe(operation: Operation): Boolean +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/history/realtime/RealtimeHistoryUpdate.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/history/realtime/RealtimeHistoryUpdate.kt new file mode 100644 index 0000000..449b480 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/history/realtime/RealtimeHistoryUpdate.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class RealtimeHistoryUpdate( + val txHash: String, + val status: Operation.Status, + val type: Type, +) { + + sealed class Type { + + abstract fun relates(accountId: AccountId): Boolean + + class Transfer( + val senderId: AccountId, + val recipientId: AccountId, + val amountInPlanks: Balance, + val chainAsset: Chain.Asset, + ) : Type() { + + override fun relates(accountId: AccountId): Boolean { + return senderId contentEquals accountId || recipientId contentEquals accountId + } + } + + class Swap( + val amountIn: ChainAssetWithAmount, + val amountOut: ChainAssetWithAmount, + val amountFee: ChainAssetWithAmount, + val senderId: AccountId, + val receiverId: AccountId + ) : Type() { + + override fun relates(accountId: AccountId): Boolean { + return senderId contentEquals accountId || receiverId contentEquals accountId + } + } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/history/realtime/substrate/SubstrateRealtimeOperationFetcher.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/history/realtime/substrate/SubstrateRealtimeOperationFetcher.kt new file mode 100644 index 0000000..d6703b5 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/history/realtime/substrate/SubstrateRealtimeOperationFetcher.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Extractor +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface SubstrateRealtimeOperationFetcher { + + suspend fun extractRealtimeHistoryUpdates( + chain: Chain, + chainAsset: Chain.Asset, + blockHash: String, + ): List + + interface Extractor { + + suspend fun extractRealtimeHistoryUpdates( + extrinsicVisit: ExtrinsicVisit, + chain: Chain, + chainAsset: Chain.Asset + ): RealtimeHistoryUpdate.Type? + } + + interface Factory { + + sealed class Source { + + class FromExtractor(val extractor: Extractor) : Source() + + class Known(val id: Id) : Source() { + + enum class Id { + ASSET_CONVERSION_SWAP, HYDRA_DX_SWAP + } + } + } + + fun create(sources: List): SubstrateRealtimeOperationFetcher + } +} + +fun Extractor.asSource(): Factory.Source { + return Factory.Source.FromExtractor(this) +} + +fun Factory.Source.Known.Id.asSource(): Factory.Source { + return Factory.Source.Known(this) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/AssetTransferExt.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/AssetTransferExt.kt new file mode 100644 index 0000000..50b775d --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/AssetTransferExt.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers + +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import java.math.BigDecimal + +fun buildAssetTransfer( + metaAccount: MetaAccount, + feePaymentCurrency: FeePaymentCurrency, + origin: ChainWithAsset, + destination: ChainWithAsset, + amount: BigDecimal, + transferringMaxAmount: Boolean, + address: String, +): AssetTransfer { + return BaseAssetTransfer( + sender = metaAccount, + recipient = address, + originChain = origin.chain, + originChainAsset = origin.asset, + destinationChain = destination.chain, + destinationChainAsset = destination.asset, + amount = amount, + transferringMaxAmount = transferringMaxAmount, + feePaymentCurrency = feePaymentCurrency + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/AssetTransferValidations.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/AssetTransferValidations.kt new file mode 100644 index 0000000..41ffad1 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/AssetTransferValidations.kt @@ -0,0 +1,125 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee +import io.novafoundation.nova.feature_wallet_api.domain.model.intoFeeList +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.FeeChangeDetectedFailure +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.feature_wallet_api.domain.validation.NotEnoughToPayFeesError +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +typealias AssetTransfersValidationSystem = ValidationSystem +typealias AssetTransfersValidation = Validation +typealias AssetTransfersValidationSystemBuilder = ValidationSystemBuilder + +sealed class AssetTransferValidationFailure { + + sealed class WillRemoveAccount : AssetTransferValidationFailure() { + object WillBurnDust : WillRemoveAccount() + + class WillTransferDust(val dust: BigDecimal) : WillRemoveAccount() + } + + sealed class DeadRecipient : AssetTransferValidationFailure() { + + object InUsedAsset : DeadRecipient() + + class InCommissionAsset(val commissionAsset: Chain.Asset) : DeadRecipient() + } + + sealed class NotEnoughFunds : AssetTransferValidationFailure() { + object InUsedAsset : NotEnoughFunds() + + class InCommissionAsset( + override val chainAsset: Chain.Asset, + override val maxUsable: BigDecimal, + override val fee: BigDecimal + ) : NotEnoughFunds(), NotEnoughToPayFeesError + + class ToStayAboveED(override val asset: Chain.Asset, override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel) : + NotEnoughFunds(), + InsufficientBalanceToStayAboveEDError + + class ToPayCrossChainFee( + val usedAsset: Chain.Asset, + val fee: BigDecimal, + val remainingBalanceAfterTransfer: BigDecimal, + ) : NotEnoughFunds() + + class ToStayAboveEdBeforePayingDeliveryFees( + val maxPossibleTransferAmount: Balance, + val chainAsset: Chain.Asset, + ) : NotEnoughFunds() + } + + class InvalidRecipientAddress(val chain: Chain) : AssetTransferValidationFailure() + + class PhishingRecipient(val address: String) : AssetTransferValidationFailure() + + object NonPositiveAmount : AssetTransferValidationFailure() + + object RecipientCannotAcceptTransfer : AssetTransferValidationFailure() + + class FeeChangeDetected( + override val payload: FeeChangeDetectedFailure.Payload + ) : AssetTransferValidationFailure(), FeeChangeDetectedFailure + + object RecipientIsSystemAccount : AssetTransferValidationFailure() + + object DryRunFailed : AssetTransferValidationFailure() +} + +data class AssetTransferPayload( + val transfer: WeightedAssetTransfer, + val originFee: OriginFee, + val crossChainFee: FeeBase?, + val originCommissionAsset: Asset, + val originUsedAsset: Asset +) + +val AssetTransferPayload.commissionChainAsset: Chain.Asset + get() = originCommissionAsset.token.configuration + +val AssetTransferPayload.originFeeList: List + get() = originFee.intoFeeList() + +val AssetTransferPayload.originFeeListInUsedAsset: List + get() = if (isSendingCommissionAsset) { + originFeeList + } else { + emptyList() + } + +val AssetTransferPayload.isSendingCommissionAsset + get() = transfer.originChainAsset == commissionChainAsset + +val AssetTransferPayload.isReceivingCommissionAsset + get() = transfer.destinationChainAsset == transfer.destinationChain.commissionAsset + +val AssetTransferPayload.receivingAmountInCommissionAsset: BigInteger + get() = if (isReceivingCommissionAsset) { + transfer.amountInPlanks + } else { + BigInteger.ZERO + } + +val AssetTransferPayload.sendingAmountInCommissionAsset: BigDecimal + get() = if (isSendingCommissionAsset) { + transfer.amount + } else { + 0.toBigDecimal() + } + +val AssetTransfer.amountInPlanks + get() = originChainAsset.planksFromAmount(amount) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/AssetTransfers.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/AssetTransfers.kt new file mode 100644 index 0000000..6b1b61b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/AssetTransfers.kt @@ -0,0 +1,187 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.OriginFee +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.ext.accountIdOrNull +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope +import org.jetbrains.annotations.ApiStatus.Internal +import java.math.BigDecimal + +interface AssetTransferDirection { + + val originChain: Chain + + val originChainAsset: Chain.Asset + + val destinationChain: Chain + + val destinationChainAsset: Chain.Asset +} + +interface AssetTransferBase : AssetTransferDirection { + + val recipientAccountId: AccountIdKey + get() = destinationChain.accountIdOrDefault(recipient).intoKey() + + val recipient: String + + val feePaymentCurrency: FeePaymentCurrency + + val amountPlanks: Balance +} + +fun AssetTransferBase.amount(): BigDecimal { + return originChainAsset.amountFromPlanks(amountPlanks) +} + +fun AssetTransferBase.replaceAmount(newAmount: Balance): AssetTransferBase { + return AssetTransferBase(recipient, originChain, originChainAsset, destinationChain, destinationChainAsset, feePaymentCurrency, newAmount) +} + +// TODO this is too specialized for this module +interface AssetTransfer : AssetTransferBase { + + val sender: MetaAccount + + val amount: BigDecimal + + val transferringMaxAmount: Boolean + + override val amountPlanks: Balance + get() = originChainAsset.planksFromAmount(amount) +} + +fun AssetTransferDirection( + originChain: Chain, + originChainAsset: Chain.Asset, + destinationChain: Chain, + destinationChainAsset: Chain.Asset +): AssetTransferDirection { + return object : AssetTransferDirection { + override val originChain: Chain = originChain + override val originChainAsset: Chain.Asset = originChainAsset + override val destinationChain: Chain = destinationChain + override val destinationChainAsset: Chain.Asset = destinationChainAsset + } +} + +fun AssetTransferBase( + recipient: String, + originChain: Chain, + originChainAsset: Chain.Asset, + destinationChain: Chain, + destinationChainAsset: Chain.Asset, + feePaymentCurrency: FeePaymentCurrency, + amountPlanks: Balance +): AssetTransferBase { + return object : AssetTransferBase { + override val recipient: String = recipient + override val originChain: Chain = originChain + override val originChainAsset: Chain.Asset = originChainAsset + override val destinationChain: Chain = destinationChain + override val destinationChainAsset: Chain.Asset = destinationChainAsset + override val feePaymentCurrency: FeePaymentCurrency = feePaymentCurrency + override val amountPlanks: Balance = amountPlanks + } +} + +data class BaseAssetTransfer( + override val sender: MetaAccount, + override val recipient: String, + override val originChain: Chain, + override val originChainAsset: Chain.Asset, + override val destinationChain: Chain, + override val destinationChainAsset: Chain.Asset, + override val feePaymentCurrency: FeePaymentCurrency, + override val amount: BigDecimal, + override val transferringMaxAmount: Boolean +) : AssetTransfer + +data class WeightedAssetTransfer( + override val sender: MetaAccount, + override val recipient: String, + override val originChain: Chain, + override val originChainAsset: Chain.Asset, + override val destinationChain: Chain, + override val destinationChainAsset: Chain.Asset, + override val feePaymentCurrency: FeePaymentCurrency, + override val amount: BigDecimal, + override val transferringMaxAmount: Boolean, + val fee: OriginFee, +) : AssetTransfer { + + constructor(assetTransfer: AssetTransfer, fee: OriginFee) : this( + sender = assetTransfer.sender, + recipient = assetTransfer.recipient, + originChain = assetTransfer.originChain, + originChainAsset = assetTransfer.originChainAsset, + destinationChain = assetTransfer.destinationChain, + destinationChainAsset = assetTransfer.destinationChainAsset, + feePaymentCurrency = assetTransfer.feePaymentCurrency, + amount = assetTransfer.amount, + transferringMaxAmount = assetTransfer.transferringMaxAmount, + fee = fee + ) +} + +val AssetTransfer.isCrossChain + get() = originChain.id != destinationChain.id + +fun AssetTransfer.recipientOrNull(): AccountId? { + return destinationChain.accountIdOrNull(recipient) +} + +val AssetTransfer.senderAccountId: AccountIdKey + get() = sender.requireAccountIdKeyIn(originChain) + +interface AssetTransfers { + + fun getValidationSystem(coroutineScope: CoroutineScope): AssetTransfersValidationSystem + + suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee + + suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result + + suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result + + suspend fun totalCanDropBelowMinimumBalance(chainAsset: Chain.Asset): Boolean { + return true + } + + suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean + + suspend fun recipientCanAcceptTransfer(chainAsset: Chain.Asset, recipient: AccountId): Boolean { + return true + } + + /** + * Parses the transfer from the given call + * This function might throw - do not use it directly. For fail-safe version use [tryParseTransfer] + */ + @Internal + suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? +} + +suspend fun AssetTransfers.tryParseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return runCatching { parseTransfer(call, chain) } + .onFailure { Log.e(LOG_TAG, "Failed to parse call: $call", it) } + .getOrNull() +} + +fun AssetTransfer.asWeighted(fee: OriginFee) = WeightedAssetTransfer(this, fee) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt new file mode 100644 index 0000000..b4ec88b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EthereumTransactionExecution +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult + +sealed interface TransactionExecution { + + class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution + + class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/model/TransferParsedFromCall.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/model/TransferParsedFromCall.kt new file mode 100644 index 0000000..2d3db97 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/model/TransferParsedFromCall.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount + +class TransferParsedFromCall( + val amount: ChainAssetWithAmount, + val destination: AccountIdKey +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/types/Balance.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/types/Balance.kt new file mode 100644 index 0000000..3385629 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/types/Balance.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types + +import java.math.BigInteger + +typealias Balance = BigInteger diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/updaters/BalanceLocksUpdaterFactory.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/updaters/BalanceLocksUpdaterFactory.kt new file mode 100644 index 0000000..d54091c --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/updaters/BalanceLocksUpdaterFactory.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters + +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface BalanceLocksUpdaterFactory { + + fun create(chain: Chain): Updater +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/updaters/PaymentUpdaterFactory.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/updaters/PaymentUpdaterFactory.kt new file mode 100644 index 0000000..b76e795 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/updaters/PaymentUpdaterFactory.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters + +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface PaymentUpdaterFactory { + + fun createFullSync(chain: Chain): Updater + + fun createLightSync(chain: Chain): Updater +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainFeeModel.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainFeeModel.kt new file mode 100644 index 0000000..35e9c8d --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainFeeModel.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.crosschain + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.hash.isPositive +import java.math.BigInteger + +data class CrossChainFeeModel( + val paidByAccount: Balance = BigInteger.ZERO, + val paidFromHolding: Balance = BigInteger.ZERO +) { + companion object +} + +fun CrossChainFeeModel.paidByAccountOrNull(): Balance? { + return paidByAccount.takeIf { paidByAccount.isPositive() } +} + +fun CrossChainFeeModel.Companion.zero() = CrossChainFeeModel() + +operator fun CrossChainFeeModel.plus(other: CrossChainFeeModel) = CrossChainFeeModel( + paidByAccount = paidByAccount + other.paidByAccount, + paidFromHolding = paidFromHolding + other.paidFromHolding +) + +fun CrossChainFeeModel?.orZero() = this ?: CrossChainFeeModel.zero() diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainTransactor.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainTransactor.kt new file mode 100644 index 0000000..c2fb485 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainTransactor.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.crosschain + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferFeatures +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlin.time.Duration + +interface CrossChainTransactor { + + context(ExtrinsicService) + suspend fun estimateOriginFee( + configuration: CrossChainTransferConfiguration, + transfer: AssetTransferBase + ): Fee + + context(ExtrinsicService) + suspend fun performTransfer( + configuration: CrossChainTransferConfiguration, + transfer: AssetTransferBase, + crossChainFee: Balance + ): Result + + suspend fun requiredRemainingAmountAfterTransfer(configuration: CrossChainTransferConfiguration): Balance + + /** + * @return result of actual received balance on destination + */ + context(ExtrinsicService) + suspend fun performAndTrackTransfer( + configuration: CrossChainTransferConfiguration, + transfer: AssetTransferBase, + ): Result + + suspend fun supportsXcmExecute( + originChainId: ChainId, + features: DynamicCrossChainTransferFeatures + ): Boolean + + suspend fun estimateMaximumExecutionTime(configuration: CrossChainTransferConfiguration): Duration +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainTransfersRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainTransfersRepository.kt new file mode 100644 index 0000000..5555b66 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainTransfersRepository.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.crosschain + +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransfersConfiguration +import kotlinx.coroutines.flow.Flow + +interface CrossChainTransfersRepository { + + suspend fun syncConfiguration() + + fun configurationFlow(): Flow + + suspend fun getConfiguration(): CrossChainTransfersConfiguration +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainValidationSystemProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainValidationSystemProvider.kt new file mode 100644 index 0000000..98b6cb1 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainValidationSystemProvider.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.crosschain + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystem + +interface CrossChainValidationSystemProvider { + + fun createValidationSystem(): AssetTransfersValidationSystem +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainWeighter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainWeighter.kt new file mode 100644 index 0000000..32dba66 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/CrossChainWeighter.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.crosschain + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfiguration + +interface CrossChainWeigher { + + suspend fun estimateFee(transfer: AssetTransferBase, config: CrossChainTransferConfiguration): CrossChainFeeModel +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/XcmTransferDryRunOrigin.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/XcmTransferDryRunOrigin.kt new file mode 100644 index 0000000..1dc75e1 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/crosschain/XcmTransferDryRunOrigin.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.crosschain + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +sealed class XcmTransferDryRunOrigin { + + /** + * Use fake signed origin that will be topped up to perform the dry run + * Useful for dry running as the part of fee calculation process + */ + data object Fake : XcmTransferDryRunOrigin() + + /** + * Use [accountId] as a origin for simulation. Simulation will be done on the current state of the account, + * without preliminary top ups e.t.c. + * [crossChainFee] will be added to the transfer amount + * Useful for final dry run, when all transfer parameters are known and finalized + */ + class Signed(val accountId: AccountIdKey, val crossChainFee: Balance) : XcmTransferDryRunOrigin() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/priceApi/CoinRangeResponse.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/priceApi/CoinRangeResponse.kt new file mode 100644 index 0000000..16a7085 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/priceApi/CoinRangeResponse.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.priceApi + +import java.math.BigDecimal + +class CoinRangeResponse(val prices: List>) { + + class Price(val millis: Long, val price: BigDecimal) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/priceApi/CoingeckoApi.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/priceApi/CoingeckoApi.kt new file mode 100644 index 0000000..ac18668 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/priceApi/CoingeckoApi.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.priceApi + +import retrofit2.http.GET +import retrofit2.http.Query + +interface CoingeckoApi { + + companion object { + const val BASE_URL = "https://api.coingecko.com" + + fun getRecentRateFieldName(priceId: String): String { + return priceId + "_24h_change" + } + } + + @GET("/api/v3/simple/price") + suspend fun getAssetPrice( + @Query("ids") priceIds: String, + @Query("vs_currencies") currency: String, + @Query("include_24hr_change") includeRateChange: Boolean + ): Map> +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/priceApi/ProxyPriceApi.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/priceApi/ProxyPriceApi.kt new file mode 100644 index 0000000..9aa8072 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/priceApi/ProxyPriceApi.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_wallet_api.data.network.priceApi + +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface ProxyPriceApi { + + companion object { + const val BASE_URL = "https://tokens-price.novasama-tech.org" + } + + @GET("/api/v3/coins/{id}/market_chart") + suspend fun getLastCoinRange( + @Path("id") id: String, + @Query("vs_currency") currency: String, + @Query("days") days: String + ): CoinRangeResponse +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/AccountInfoRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/AccountInfoRepository.kt new file mode 100644 index 0000000..7fe9d9d --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/AccountInfoRepository.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_api.data.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId + +interface AccountInfoRepository { + + suspend fun getAccountInfo( + chainId: ChainId, + accountId: AccountId + ): AccountInfo +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/BalanceHoldsRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/BalanceHoldsRepository.kt new file mode 100644 index 0000000..fd3d3b8 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/BalanceHoldsRepository.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_wallet_api.data.repository + +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface BalanceHoldsRepository { + + suspend fun chainHasHoldId(chainId: ChainId, holdId: BalanceHold.HoldId): Boolean + + suspend fun observeBalanceHolds(metaInt: Long, chainAsset: Chain.Asset): Flow> + + fun observeHoldsForMetaAccount(metaInt: Long): Flow> +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/BalanceLocksRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/BalanceLocksRepository.kt new file mode 100644 index 0000000..de9c369 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/BalanceLocksRepository.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_wallet_api.data.repository + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +interface BalanceLocksRepository { + + fun observeBalanceLocks(metaId: Long, chain: Chain, chainAsset: Chain.Asset): Flow> + + suspend fun getBalanceLocks(metaId: Long, chainAsset: Chain.Asset): List + + suspend fun getBiggestLock(chain: Chain, chainAsset: Chain.Asset): BalanceLock? + + suspend fun observeBalanceLock(chainAsset: Chain.Asset, lockId: BalanceLockId): Flow + + fun observeLocksForMetaAccount(metaAccount: MetaAccount): Flow> +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/CoinPriceRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/CoinPriceRepository.kt new file mode 100644 index 0000000..c9d0385 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/CoinPriceRepository.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_wallet_api.data.repository + +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate +import retrofit2.HttpException +import kotlin.jvm.Throws +import kotlin.time.Duration + +interface CoinPriceRepository { + + @Throws(HttpException::class) + suspend fun getCoinPriceAtTime(priceId: String, currency: Currency, timestamp: Duration): HistoricalCoinRate? + + @Throws(HttpException::class) + suspend fun getLastHistoryForPeriod(priceId: String, currency: Currency, range: PricePeriod): List +} + +@Throws(HttpException::class) +suspend fun CoinPriceRepository.getAllCoinPriceHistory(priceId: String, currency: Currency): List { + return getLastHistoryForPeriod(priceId, currency, PricePeriod.MAX) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/ExternalBalanceRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/ExternalBalanceRepository.kt new file mode 100644 index 0000000..ee2d7d4 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/ExternalBalanceRepository.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_api.data.repository + +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.Flow + +interface ExternalBalanceRepository { + + fun observeAccountExternalBalances(metaId: Long): Flow> + + fun observeAccountChainExternalBalances(metaId: Long, assetId: FullChainAssetId): Flow> + + suspend fun deleteExternalBalances(assetIds: List) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/ParachainInfoExt.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/ParachainInfoExt.kt new file mode 100644 index 0000000..8377a37 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/ParachainInfoExt.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_wallet_api.data.repository + +import io.novafoundation.nova.feature_xcm_api.chain.XcmChain +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId.Companion.JUNCTION_TYPE_PARACHAIN +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId.Companion.JUNCTION_TYPE_TEYRCHAIN +import io.novafoundation.nova.feature_xcm_api.multiLocation.chainLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository + +// Pezkuwi chain IDs - these chains use "Teyrchain" instead of "Parachain" in XCM +private val PEZKUWI_CHAIN_IDS = setOf( + "bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75", // PEZKUWI + "00d0e1d0581c3cd5c5768652d52f4520184018b44f56a2ae1e0dc9d65c00c948", // PEZKUWI_ASSET_HUB + "58269e9c184f721e0309332d90cafc410df1519a5dc27a5fd9b3bf5fd2d129f8" // PEZKUWI_PEOPLE +) + +private fun junctionTypeNameForChain(chainId: ChainId): String { + return if (chainId in PEZKUWI_CHAIN_IDS) JUNCTION_TYPE_TEYRCHAIN else JUNCTION_TYPE_PARACHAIN +} + +suspend fun ParachainInfoRepository.getXcmChain(chain: Chain): XcmChain { + return XcmChain(paraId(chain.id), chain) +} + +suspend fun ParachainInfoRepository.getChainLocation(chainId: ChainId): ChainLocation { + val junctionTypeName = junctionTypeNameForChain(chainId) + val location = AbsoluteMultiLocation.chainLocation(paraId(chainId), junctionTypeName) + return ChainLocation(chainId, location) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/PricePeriod.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/PricePeriod.kt new file mode 100644 index 0000000..7f125b7 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/PricePeriod.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_wallet_api.data.repository + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days + +enum class PricePeriod { + DAY, WEEK, MONTH, YEAR, MAX +} + +fun PricePeriod.duration(): Duration { + return when (this) { + PricePeriod.DAY -> 1.days + PricePeriod.WEEK -> 7.days + PricePeriod.MONTH -> 30.days + PricePeriod.YEAR -> 365.days + PricePeriod.MAX -> Duration.INFINITE + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/StatemineAssetsRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/StatemineAssetsRepository.kt new file mode 100644 index 0000000..92d5a37 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/repository/StatemineAssetsRepository.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_wallet_api.data.repository + +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.StatemineAssetDetails +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow + +interface StatemineAssetsRepository { + + suspend fun getAssetDetails( + chainId: ChainId, + assetType: Chain.Asset.Type.Statemine, + ): StatemineAssetDetails + + suspend fun subscribeAndSyncAssetDetails( + chainId: ChainId, + assetType: Chain.Asset.Type.Statemine, + subscriptionBuilder: SharedRequestsBuilder + ): Flow +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/source/CoinPriceLocalDataSource.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/source/CoinPriceLocalDataSource.kt new file mode 100644 index 0000000..8dcadc3 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/source/CoinPriceLocalDataSource.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_wallet_api.data.source + +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate + +interface CoinPriceLocalDataSource { + + suspend fun getFloorCoinPriceAtTime(priceId: String, currency: Currency, timestamp: Long): HistoricalCoinRate? + + suspend fun hasCeilingCoinPriceAtTime(priceId: String, currency: Currency, timestamp: Long): Boolean + + suspend fun getCoinPriceRange(priceId: String, currency: Currency, fromTimestamp: Long, toTimestamp: Long): List + + suspend fun updateCoinPrice(priceId: String, currency: Currency, coinRate: List) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/source/CoinPriceRemoteDataSource.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/source/CoinPriceRemoteDataSource.kt new file mode 100644 index 0000000..a09a7cd --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/source/CoinPriceRemoteDataSource.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_wallet_api.data.source + +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod +import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRateChange +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate + +interface CoinPriceRemoteDataSource { + + suspend fun getLastCoinPriceRange(priceId: String, currency: Currency, range: PricePeriod): List + + suspend fun getCoinRates(priceIds: Set, currency: Currency): Map + + suspend fun getCoinRate(priceId: String, currency: Currency): CoinRateChange? +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/BalanceLocks.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/BalanceLocks.kt new file mode 100644 index 0000000..c187230 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/BalanceLocks.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_wallet_api.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.SOURCE) +annotation class BalanceLocks diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/WalletFeatureApi.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/WalletFeatureApi.kt new file mode 100644 index 0000000..653fa70 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/WalletFeatureApi.kt @@ -0,0 +1,131 @@ +package io.novafoundation.nova.feature_wallet_api.di + +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainWeigher +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.CoingeckoApi +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.ProxyPriceApi +import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.AssetGetOptionsUseCase +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.MultisigExtrinsicValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.ProxyHaveEnoughFeeValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.AssetModelFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard + +interface WalletFeatureApi { + + val assetSourceRegistry: AssetSourceRegistry + + val phishingValidationFactory: PhishingValidationFactory + + val crossChainTransfersRepository: CrossChainTransfersRepository + + val crossChainWeigher: CrossChainWeigher + + val crossChainTransactor: CrossChainTransactor + + val crossChainValidationSystemProvider: CrossChainValidationSystemProvider + + val balanceLocksRepository: BalanceLocksRepository + + val chainAssetRepository: ChainAssetRepository + + val erc20Standard: Erc20Standard + + val arbitraryAssetUseCase: ArbitraryAssetUseCase + + val externalBalancesRepository: ExternalBalanceRepository + + val paymentUpdaterFactory: PaymentUpdaterFactory + + val balanceLocksUpdaterFactory: BalanceLocksUpdaterFactory + + val coinPriceRepository: CoinPriceRepository + + val crossChainTransfersUseCase: CrossChainTransfersUseCase + + val arbitraryTokenUseCase: ArbitraryTokenUseCase + + val holdsRepository: BalanceHoldsRepository + + val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory + + val assetsValidationContextFactory: AssetsValidationContext.Factory + + val statemineAssetsRepository: StatemineAssetsRepository + + val multisigExtrinsicValidationFactory: MultisigExtrinsicValidationFactory + + val accountInfoRepository: AccountInfoRepository + + val amountFormatter: AmountFormatter + + val fiatFormatter: FiatFormatter + + val tokenFormatter: TokenFormatter + + val assetModelFormatter: AssetModelFormatter + + val assetGetOptionsUseCase: AssetGetOptionsUseCase + + val enoughAmountValidatorFactory: EnoughAmountValidatorFactory + + val minAmountFieldValidatorFactory: MinAmountFieldValidatorFactory + + val getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory + + val sendUseCase: SendUseCase + + fun provideWalletRepository(): WalletRepository + + fun provideTokenRepository(): TokenRepository + + fun provideAssetCache(): AssetCache + + fun provideWallConstants(): WalletConstants + + fun provideFeeLoaderMixinFactory(): FeeLoaderMixin.Factory + + fun provideAmountChooserFactory(): AmountChooserMixin.Factory + + fun proxyPriceApi(): ProxyPriceApi + + fun coingeckoApi(): CoingeckoApi + + fun enoughTotalToStayAboveEDValidationFactory(): EnoughTotalToStayAboveEDValidationFactory + + fun proxyHaveEnoughFeeValidationFactory(): ProxyHaveEnoughFeeValidationFactory + + fun maxActionProviderFactory(): MaxActionProviderFactory +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/common/AssetUseCaseModule.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/common/AssetUseCaseModule.kt new file mode 100644 index 0000000..99b65c6 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/common/AssetUseCaseModule.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_api.di.common + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.implementations.AssetUseCaseImpl +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState + +@Module(includes = [TokenUseCaseModule::class]) +class AssetUseCaseModule { + + @Provides + @FeatureScope + fun provideAssetUseCase( + walletRepository: WalletRepository, + accountRepository: AccountRepository, + sharedState: SelectedAssetOptionSharedState<*>, + ): AssetUseCase = AssetUseCaseImpl( + walletRepository, + accountRepository, + sharedState + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/common/SelectableAssetUseCaseModule.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/common/SelectableAssetUseCaseModule.kt new file mode 100644 index 0000000..60573f5 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/common/SelectableAssetUseCaseModule.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_wallet_api.di.common + +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.domain.AssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.SelectableAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.implementations.SelectableAssetUseCaseImpl +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.AssetModelFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.AssetSelectorFactory +import io.novafoundation.nova.runtime.state.SelectableSingleAssetSharedState +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState + +@Module(includes = [SelectableAssetUseCaseModule.BindsModule::class, TokenUseCaseModule::class]) +class SelectableAssetUseCaseModule { + + @Provides + @FeatureScope + fun provideAssetUseCase( + walletRepository: WalletRepository, + accountRepository: AccountRepository, + sharedState: SelectableSingleAssetSharedState<*>, + ): SelectableAssetUseCase<*> = SelectableAssetUseCaseImpl( + walletRepository, + accountRepository, + sharedState, + ) + + @Provides + @FeatureScope + fun provideAssetSelectorMixinFactory( + assetUseCase: SelectableAssetUseCase<*>, + singleAssetSharedState: SelectableSingleAssetSharedState<*>, + maskableValueFormatterProvider: MaskableValueFormatterProvider, + maskableValueFormatterFactory: MaskableValueFormatterFactory, + resourceManager: ResourceManager, + assetModelFormatter: AssetModelFormatter + ) = AssetSelectorFactory( + assetUseCase, + singleAssetSharedState, + resourceManager, + maskableValueFormatterProvider, + maskableValueFormatterFactory, + assetModelFormatter + ) + + @Module + interface BindsModule { + + @Binds + fun bindAssetUseCase(selectableAssetUseCase: SelectableAssetUseCase<*>): AssetUseCase + + @Binds + fun bindSelectedAssetState(selectableSingleAssetSharedState: SelectableSingleAssetSharedState<*>): SelectedAssetOptionSharedState<*> + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/common/TokenUseCaseModule.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/common/TokenUseCaseModule.kt new file mode 100644 index 0000000..94f2cac --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/di/common/TokenUseCaseModule.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_api.di.common + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.implementations.SharedStateTokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState + +@Module +class TokenUseCaseModule { + + @Provides + @FeatureScope + fun provideTokenUseCase( + tokenRepository: TokenRepository, + sharedState: SelectedAssetOptionSharedState<*>, + chainRegistry: ChainRegistry + ): TokenUseCase = SharedStateTokenUseCase( + tokenRepository = tokenRepository, + chainRegistry = chainRegistry, + sharedState = sharedState, + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/ArbitraryAssetUseCase.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/ArbitraryAssetUseCase.kt new file mode 100644 index 0000000..8541f6b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/ArbitraryAssetUseCase.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_wallet_api.domain + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +interface ArbitraryAssetUseCase { + + fun assetFlow(chainId: ChainId, assetId: ChainAssetId): Flow + + fun assetFlow(chainAsset: Chain.Asset): Flow + + suspend fun getAsset(chainAsset: Chain.Asset): Asset? +} + +class RealArbitraryAssetUseCase( + private val accountRepository: AccountRepository, + private val walletRepository: WalletRepository, + private val chainRegistry: ChainRegistry +) : ArbitraryAssetUseCase { + + override fun assetFlow(chainId: ChainId, assetId: ChainAssetId): Flow { + return flowOfAll { + val chainAsset = chainRegistry.asset(chainId, assetId) + + assetFlow(chainAsset) + } + } + + override fun assetFlow(chainAsset: Chain.Asset): Flow { + return accountRepository.selectedMetaAccountFlow().flatMapLatest { metaAccount -> + walletRepository.assetFlow(metaAccount.id, chainAsset) + } + } + + override suspend fun getAsset(chainAsset: Chain.Asset): Asset? { + val account = accountRepository.getSelectedMetaAccount() + return walletRepository.getAsset(account.id, chainAsset) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/ArbitraryTokenCase.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/ArbitraryTokenCase.kt new file mode 100644 index 0000000..e8a79f5 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/ArbitraryTokenCase.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_wallet_api.domain + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalToken +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.time.Duration + +interface ArbitraryTokenUseCase { + + fun historicalTokenFlow(chainAsset: Chain.Asset, at: Duration): Flow + + suspend fun historicalToken(chainAsset: Chain.Asset, at: Duration): HistoricalToken + + suspend fun getToken(chainAssetId: FullChainAssetId): Token +} + +@FeatureScope +class RealArbitraryTokenUseCase @Inject constructor( + private val coinPriceRepository: CoinPriceRepository, + private val currencyRepository: CurrencyRepository, + private val tokenRepository: TokenRepository, + private val chainRegistry: ChainRegistry, +) : ArbitraryTokenUseCase { + + override fun historicalTokenFlow(chainAsset: Chain.Asset, at: Duration): Flow { + return flowOf { historicalToken(chainAsset, at) } + } + + override suspend fun historicalToken(chainAsset: Chain.Asset, at: Duration): HistoricalToken = withContext(Dispatchers.IO) { + val currency = currencyRepository.getSelectedCurrency() + val priceId = chainAsset.priceId + + val rate = if (priceId != null) { + runCatching { coinPriceRepository.getCoinPriceAtTime(priceId, currency, at) } + .getOrNull() + } else { + null + } + + HistoricalToken(currency, rate, chainAsset) + } + + override suspend fun getToken(chainAssetId: FullChainAssetId): Token { + return tokenRepository.getToken(chainRegistry.asset(chainAssetId)) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/AssetGetOptionsUseCase.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/AssetGetOptionsUseCase.kt new file mode 100644 index 0000000..e591148 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/AssetGetOptionsUseCase.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_wallet_api.domain + +import io.novafoundation.nova.feature_wallet_api.domain.model.GetAssetOption +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +interface AssetGetOptionsUseCase { + + fun observeAssetGetOptionsForSelectedAccount(chainAssetFlow: Flow): Flow> +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/AssetUseCase.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/AssetUseCase.kt new file mode 100644 index 0000000..067d0c1 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/AssetUseCase.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_wallet_api.domain + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.state.SelectableAssetAdditionalData +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState.SupportedAssetOption +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +interface GenericAssetUseCase { + fun currentAssetAndOptionFlow(): Flow> + + fun currentAssetFlow(): Flow { + return currentAssetAndOptionFlow().map { it.asset } + } +} + +interface SelectableAssetUseCase : GenericAssetUseCase { + suspend fun availableAssetsToSelect(): List> +} + +data class AssetAndOption(val asset: Asset, val option: SupportedAssetOption) + +typealias AssetUseCase = GenericAssetUseCase<*> + +typealias SelectableAssetAndOption = AssetAndOption + +suspend fun AssetUseCase.getCurrentAsset() = currentAssetFlow().first() diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/SendUseCase.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/SendUseCase.kt new file mode 100644 index 0000000..ed45e72 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/SendUseCase.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_wallet_api.domain + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import kotlinx.coroutines.CoroutineScope + +interface SendUseCase { + + suspend fun performOnChainTransfer(transfer: WeightedAssetTransfer, fee: SubmissionFee, coroutineScope: CoroutineScope): Result + + suspend fun performOnChainTransferAndAwaitExecution( + transfer: WeightedAssetTransfer, + fee: SubmissionFee, + coroutineScope: CoroutineScope + ): Result +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/TokenUseCase.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/TokenUseCase.kt new file mode 100644 index 0000000..dd36621 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/TokenUseCase.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_wallet_api.domain + +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface TokenUseCase { + + suspend fun currentToken(): Token + + fun currentTokenFlow(): Flow + + suspend fun getToken(chainAssetId: FullChainAssetId): Token +} + +fun TokenUseCase.currentAssetFlow(): Flow { + return currentTokenFlow().map { it.configuration } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/fee/FeeInteractor.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/fee/FeeInteractor.kt new file mode 100644 index 0000000..b581b6a --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/fee/FeeInteractor.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_wallet_api.domain.fee + +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +interface FeeInteractor { + + suspend fun canPayFeeInAsset(chainAsset: Chain.Asset): Boolean + + suspend fun assetFlow(asset: Chain.Asset): Flow + + suspend fun hasEnoughBalanceToPayFee(feeAsset: Asset, inspectedFeeAmount: FeeInspector.InspectedFeeAmount): Boolean + + suspend fun getToken(chainAsset: Chain.Asset): Token +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/implementations/AssetUseCaseImpl.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/implementations/AssetUseCaseImpl.kt new file mode 100644 index 0000000..5a83290 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/implementations/AssetUseCaseImpl.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_wallet_api.domain.implementations + +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.domain.AssetAndOption +import io.novafoundation.nova.feature_wallet_api.domain.GenericAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map + +class AssetUseCaseImpl( + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, + private val sharedState: SelectedAssetOptionSharedState, +) : GenericAssetUseCase { + + override fun currentAssetAndOptionFlow(): Flow> = combineToPair( + accountRepository.selectedMetaAccountFlow(), + sharedState.selectedOption, + ).flatMapLatest { (selectedMetaAccount, selectedOption) -> + val (_, chainAsset) = selectedOption.assetWithChain + + walletRepository.assetFlow( + metaId = selectedMetaAccount.id, + chainAsset = chainAsset + ).map { + AssetAndOption(it, selectedOption) + } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/implementations/SelectableAssetUseCaseImpl.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/implementations/SelectableAssetUseCaseImpl.kt new file mode 100644 index 0000000..f2f8162 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/implementations/SelectableAssetUseCaseImpl.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_wallet_api.domain.implementations + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.domain.AssetAndOption +import io.novafoundation.nova.feature_wallet_api.domain.GenericAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.SelectableAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.ext.alphabeticalOrder +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.mainChainsFirstAscendingOrder +import io.novafoundation.nova.runtime.ext.testnetsLastAscendingOrder +import io.novafoundation.nova.runtime.state.SelectableAssetAdditionalData +import io.novafoundation.nova.runtime.state.SelectableSingleAssetSharedState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class SelectableAssetUseCaseImpl( + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, + private val sharedState: SelectableSingleAssetSharedState, +) : GenericAssetUseCase by AssetUseCaseImpl(walletRepository, accountRepository, sharedState), + SelectableAssetUseCase { + + override suspend fun availableAssetsToSelect(): List> = withContext(Dispatchers.Default) { + val metaAccount = accountRepository.getSelectedMetaAccount() + + val balancesByChainAssets = walletRepository.getSupportedAssets(metaAccount.id).associateBy { it.token.configuration.fullId } + + sharedState.availableToSelect() + .mapNotNull { supportedOption -> + val asset = balancesByChainAssets[supportedOption.assetWithChain.asset.fullId] + + asset?.let { AssetAndOption(asset, supportedOption) } + } + .sortedWith(assetsComparator()) + } + + private fun assetsComparator(): Comparator> { + return compareBy> { it.option.assetWithChain.chain.mainChainsFirstAscendingOrder } + .thenBy { it.option.assetWithChain.chain.testnetsLastAscendingOrder } + .thenByDescending { it.asset.token.amountToFiat(it.asset.transferable) } + .thenByDescending { it.asset.transferable } + .thenBy { it.option.assetWithChain.chain.alphabeticalOrder } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/implementations/SharedStateTokenUseCase.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/implementations/SharedStateTokenUseCase.kt new file mode 100644 index 0000000..58e9fd8 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/implementations/SharedStateTokenUseCase.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_wallet_api.domain.implementations + +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chainAsset +import io.novafoundation.nova.runtime.state.selectedAssetFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +class SharedStateTokenUseCase( + private val tokenRepository: TokenRepository, + private val chainRegistry: ChainRegistry, + private val sharedState: SelectedAssetOptionSharedState<*>, +) : TokenUseCase { + + override suspend fun currentToken(): Token { + val chainAsset = sharedState.chainAsset() + + return tokenRepository.getToken(chainAsset) + } + + override fun currentTokenFlow(): Flow { + return sharedState.selectedAssetFlow().flatMapLatest { chainAsset -> + tokenRepository.observeToken(chainAsset) + } + } + + override suspend fun getToken(chainAssetId: FullChainAssetId): Token { + return tokenRepository.getToken(chainRegistry.asset(chainAssetId)) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/ChainAssetRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/ChainAssetRepository.kt new file mode 100644 index 0000000..09f2768 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/ChainAssetRepository.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_api.domain.interfaces + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +interface ChainAssetRepository { + + suspend fun setAssetsEnabled(enabled: Boolean, assetIds: List) + + suspend fun insertCustomAsset(chainAsset: Chain.Asset) + + suspend fun getEnabledAssets(): List +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/CrossChainTransfersUseCase.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/CrossChainTransfersUseCase.kt new file mode 100644 index 0000000..2786ab6 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/CrossChainTransfersUseCase.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_wallet_api.domain.interfaces + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferDirection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.XcmTransferDryRunOrigin +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.CrossChainTransferFee +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransfersConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferFeatures +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlin.time.Duration + +class IncomingDirection( + val asset: Asset, + val chain: Chain +) + +typealias OutcomingDirection = ChainWithAsset + +interface CrossChainTransfersUseCase { + + suspend fun syncCrossChainConfig() + + fun incomingCrossChainDirections(destination: Flow): Flow> + + fun outcomingCrossChainDirectionsFlow(origin: Chain.Asset): Flow> + + suspend fun getConfiguration(): CrossChainTransfersConfiguration + + suspend fun requiredRemainingAmountAfterTransfer( + originChain: Chain, + sendingAsset: Chain.Asset, + destinationChain: Chain, + ): Balance + + /** + * @param cachingScope - a scope that will be registered as a dependency for internal caching. If null is passed, no caching will be used + */ + suspend fun ExtrinsicService.estimateFee( + transfer: AssetTransferBase, + cachingScope: CoroutineScope? + ): CrossChainTransferFee + + suspend fun ExtrinsicService.performTransferOfExactAmount(transfer: AssetTransferBase, computationalScope: CoroutineScope): Result + + /** + * @return result of actual received balance on destination + */ + suspend fun ExtrinsicService.performTransferAndTrackTransfer( + transfer: AssetTransferBase, + computationalScope: CoroutineScope + ): Result + + suspend fun maximumExecutionTime( + assetTransferDirection: AssetTransferDirection, + computationalScope: CoroutineScope + ): Duration + + suspend fun dryRunTransferIfPossible( + transfer: AssetTransferBase, + origin: XcmTransferDryRunOrigin, + computationalScope: CoroutineScope + ): Result + + suspend fun supportsXcmExecute( + originChainId: ChainId, + features: DynamicCrossChainTransferFeatures + ): Boolean +} + +fun CrossChainTransfersUseCase.incomingCrossChainDirectionsAvailable(destination: Flow): Flow { + return incomingCrossChainDirections(destination).map { it.isNotEmpty() } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/TokenRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/TokenRepository.kt new file mode 100644 index 0000000..dd9150b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/TokenRepository.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_wallet_api.domain.interfaces + +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.Flow + +interface TokenRepository { + + /** + * Observes tokens for given [chainAssets] associated by [FullChainAssetId]. + * Emitted map will contain keys for all supplied [chainAssets], even if some prices are currently unknown + */ + suspend fun observeTokens(chainAssets: List): Flow> + + suspend fun getTokens(chainAsset: List): Map + + suspend fun getToken(chainAsset: Chain.Asset): Token + + suspend fun getTokenOrNull(chainAsset: Chain.Asset): Token? + + fun observeToken(chainAsset: Chain.Asset): Flow +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/TransactionFilter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/TransactionFilter.kt new file mode 100644 index 0000000..2d3de35 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/TransactionFilter.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_wallet_api.domain.interfaces + +enum class TransactionFilter { + EXTRINSIC, REWARD, TRANSFER, SWAP +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/WalletConstants.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/WalletConstants.kt new file mode 100644 index 0000000..979a1bf --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/WalletConstants.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_wallet_api.domain.interfaces + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger + +interface WalletConstants { + + suspend fun existentialDeposit(chainId: ChainId): BigInteger +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/WalletRepository.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/WalletRepository.kt new file mode 100644 index 0000000..ced33aa --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/interfaces/WalletRepository.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_wallet_api.domain.interfaces + +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow + +interface WalletRepository { + + fun syncedAssetsFlow(metaId: Long): Flow> + + suspend fun getSyncedAssets(metaId: Long): List + suspend fun getSupportedAssets(metaId: Long): List + + fun supportedAssetsFlow(metaId: Long, chainAssets: List): Flow> + + suspend fun syncAssetsRates(currency: Currency) + suspend fun syncAssetRates(asset: Chain.Asset, currency: Currency) + + fun assetFlow( + accountId: AccountId, + chainAsset: Chain.Asset + ): Flow + + fun assetFlow( + metaId: Long, + chainAsset: Chain.Asset + ): Flow + + fun assetFlowOrNull( + metaId: Long, + chainAsset: Chain.Asset + ): Flow + + fun assetsFlow( + metaId: Long, + chainAssets: List + ): Flow> + + suspend fun getAsset( + accountId: AccountId, + chainAsset: Chain.Asset + ): Asset? + + suspend fun getAsset( + metaId: Long, + chainAsset: Chain.Asset + ): Asset? + + suspend fun insertPendingTransfer( + hash: String, + assetTransfer: AssetTransfer, + fee: SubmissionFee + ) + + suspend fun clearAssets(assetIds: List) + + suspend fun updatePhishingAddresses() + + suspend fun isAccountIdFromPhishingList(accountId: AccountId): Boolean +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/Asset.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/Asset.kt new file mode 100644 index 0000000..a6e971f --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/Asset.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.common.domain.balance.EDCountingMode +import io.novafoundation.nova.common.domain.balance.TransferableMode +import io.novafoundation.nova.common.domain.balance.calculateBalanceCountedTowardsEd +import io.novafoundation.nova.common.domain.balance.calculateTransferable +import io.novafoundation.nova.common.domain.balance.totalBalance +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigDecimal + +// TODO we should remove duplication between Asset and ChainAssetBalance in regard to calculation of different balance types +data class Asset( + val token: Token, + + // Non-reserved part of the balance. There may still be restrictions on + // this, but it is the total pool what may in principle be transferred, + // reserved. + val freeInPlanks: Balance, + + // Balance which is reserved and may not be used at all. + // This balance is a 'reserve' balance that different subsystems use in + // order to set aside tokens that are still 'owned' by the account + // holder, but which are suspendable + val reservedInPlanks: Balance, + + // / The amount that `free` may not drop below when withdrawing. + val frozenInPlanks: Balance, + + val transferableMode: TransferableMode, + val edCountingMode: EDCountingMode, + + // TODO move to runtime storage + val bondedInPlanks: Balance, + val redeemableInPlanks: Balance, + val unbondingInPlanks: Balance +) { + + /** + * Liquid balance that can be transferred from an account + * There are multiple ways it is identified, see [legacyTransferable] and [holdAndFreezesTransferable] + */ + val transferableInPlanks: Balance = transferableMode.calculateTransferable(freeInPlanks, frozenInPlanks, reservedInPlanks) + + /** + * Balance that is counted towards meeting the requirement of Existential Deposit + * When the balance + */ + val balanceCountedTowardsEDInPlanks: Balance = edCountingMode.calculateBalanceCountedTowardsEd(freeInPlanks, reservedInPlanks) + + // Non-reserved plus reserved + val totalInPlanks = totalBalance(freeInPlanks, reservedInPlanks) + + // balance that cannot be used for transfers (non-transferable) for any reason + val lockedInPlanks = totalInPlanks - transferableInPlanks + + // TODO maybe move to extension fields? + // Check affect on performance, if those fields will be recalculated on each usage + val total = token.amountFromPlanks(totalInPlanks) + val reserved = token.amountFromPlanks(reservedInPlanks) + val locked = token.amountFromPlanks(lockedInPlanks) + val transferable = token.amountFromPlanks(transferableInPlanks) + + val free = token.amountFromPlanks(freeInPlanks) + val frozen = token.amountFromPlanks(frozenInPlanks) + + // TODO move to runtime storage + val bonded = token.amountFromPlanks(bondedInPlanks) + val redeemable = token.amountFromPlanks(redeemableInPlanks) + val unbonding = token.amountFromPlanks(unbondingInPlanks) +} + +fun Asset.unlabeledReserves(holds: Collection): Balance { + return unlabeledReserves(holds.sumByBigInteger { it.amountInPlanks }) +} + +fun Asset.unlabeledReserves(labeledReserves: Balance): Balance { + return reservedInPlanks - labeledReserves +} + +fun Asset.balanceCountedTowardsED(): BigDecimal { + return token.amountFromPlanks(balanceCountedTowardsEDInPlanks) +} + +fun Asset.transferableReplacingFrozen(newFrozen: Balance): Balance { + return transferableMode.calculateTransferable(freeInPlanks, newFrozen, reservedInPlanks) +} + +fun Asset.regularTransferableBalance(): Balance { + return TransferableMode.REGULAR.calculateTransferable(freeInPlanks, frozenInPlanks, reservedInPlanks) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/BalanceBreakdownIds.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/BalanceBreakdownIds.kt new file mode 100644 index 0000000..880c2d7 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/BalanceBreakdownIds.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +object BalanceBreakdownIds { + + const val RESERVED = "reserved" + + const val CROWDLOAN = "crowdloan" + + const val NOMINATION_POOL = "nomination-pool" + + const val NOMINATION_POOL_DELEGATED = "DelegatedStaking: StakingDelegation" +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/BalanceHold.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/BalanceHold.kt new file mode 100644 index 0000000..047fd17 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/BalanceHold.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.core_db.model.BalanceHoldLocal +import io.novafoundation.nova.core_db.model.BalanceHoldLocal.HoldIdLocal +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold.HoldId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class BalanceHold( + val id: HoldId, + val amountInPlanks: Balance, + val chainAsset: Chain.Asset +) : Identifiable { + + class HoldId(val module: String, val reason: String) + + // Keep in tact with `BalanceBreakdownIds` + override val identifier: String = "${id.module}: ${id.reason}" +} + +fun mapBalanceHoldFromLocal( + asset: Chain.Asset, + hold: BalanceHoldLocal +): BalanceHold { + return BalanceHold( + id = hold.id.toDomain(), + amountInPlanks = hold.amount, + chainAsset = asset + ) +} + +private fun HoldIdLocal.toDomain(): HoldId { + return HoldId(module, reason) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/BalanceLocks.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/BalanceLocks.kt new file mode 100644 index 0000000..6cf7a0e --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/BalanceLocks.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.core_db.model.BalanceLockLocal +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class BalanceLock( + val id: BalanceLockId, + val amountInPlanks: Balance, + val chainAsset: Chain.Asset +) : Identifiable by id + +@JvmInline +value class BalanceLockId private constructor(val value: String) : Identifiable { + + override val identifier: String + get() = value + + companion object { + + fun fromPath(vararg pathSegments: String): BalanceLockId { + val fullId = pathSegments.joinToString(separator = ": ") + return fromFullId(fullId) + } + + fun fromFullId(fullId: String): BalanceLockId { + return BalanceLockId(fullId) + } + } +} + +fun mapBalanceLockFromLocal( + asset: Chain.Asset, + lock: BalanceLockLocal +): BalanceLock { + return BalanceLock( + id = BalanceLockId.fromFullId(lock.type), + amountInPlanks = lock.amount, + chainAsset = asset + ) +} + +fun List.maxLockReplacing(lockId: BalanceLockId, replaceWith: Balance): Balance { + return maxOfOrNull { + if (it.id == lockId) replaceWith else it.amountInPlanks + }.orZero() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/CoinRate.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/CoinRate.kt new file mode 100644 index 0000000..7c8df64 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/CoinRate.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.common.utils.binarySearchFloor +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +interface CoinRate { + val rate: BigDecimal +} + +data class CoinRateChange(val recentRateChange: BigDecimal, override val rate: BigDecimal) : CoinRate + +class HistoricalCoinRate(val timestamp: Long, override val rate: BigDecimal) : CoinRate + +fun CoinRate.convertAmount(amount: BigDecimal) = amount * rate + +fun CoinRate.convertFiatToAmount(fiat: BigDecimal): BigDecimal { + if (rate.isZero) return BigDecimal.ZERO + + return fiat / rate +} + +fun CoinRate.convertFiatToPlanks(asset: Chain.Asset, fiat: BigDecimal): BigInteger { + return asset.planksFromAmount(convertFiatToAmount(fiat)) +} + +fun CoinRate.convertPlanks(asset: Chain.Asset, amount: BigInteger) = convertAmount(asset.amountFromPlanks(amount)) + +fun List.findNearestCoinRate(timestamp: Long): HistoricalCoinRate? { + if (isEmpty()) return null + if (first().timestamp > timestamp) return null // To support the case when the token started trading later than the desired coin rate + + val index = binarySearchFloor { it.timestamp.compareTo(timestamp) } + return getOrNull(index) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/CrossChainFee.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/CrossChainFee.kt new file mode 100644 index 0000000..81b1f6e --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/CrossChainFee.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class CrossChainTransferFee( + /** + * Deducted upon initial transaction submission from the origin chain. Asset can be controlled with [FeePaymentCurrency] + */ + val submissionFee: SubmissionFee, + + /** + * Deducted upon initial transaction submission from the origin chain. Cannot be controlled with [FeePaymentCurrency] + * and is always paid in native currency + */ + val postSubmissionByAccount: SubmissionFee?, + + /** + * Total sum of all execution and delivery fees paid from holding register throughout xcm transfer + * Paid (at the moment) in a sending asset. There might be multiple [Chain.Asset] that represent the same logical asset, + * the asset here indicates the first one, on the origin chain + */ + val postSubmissionFromAmount: FeeBase, +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/ExternalBalance.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/ExternalBalance.kt new file mode 100644 index 0000000..5f18948 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/ExternalBalance.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.common.utils.sumByBigInteger +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +class ExternalBalance( + val chainAssetId: FullChainAssetId, + val amount: Balance, + val type: Type +) { + + enum class Type { + CROWDLOAN, NOMINATION_POOL + } +} + +fun List.aggregatedBalanceByAsset(): Map = groupBy { it.chainAssetId } + .mapValues { (_, assetExternalBalances) -> assetExternalBalances.sumByBigInteger(ExternalBalance::amount) } diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/FiatAmount.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/FiatAmount.kt new file mode 100644 index 0000000..03e5054 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/FiatAmount.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import java.math.BigDecimal + +class FiatAmount( + val currency: Currency, + val price: BigDecimal +) { + + companion object { + + fun zero(currency: Currency): FiatAmount { + return FiatAmount(currency, BigDecimal.ZERO) + } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/GetAssetOption.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/GetAssetOption.kt new file mode 100644 index 0000000..1b86df4 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/GetAssetOption.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +enum class GetAssetOption { + RECEIVE, CROSS_CHAIN, BUY +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/Operation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/Operation.kt new file mode 100644 index 0000000..22b4ee4 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/Operation.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import java.math.BigDecimal +import java.math.BigInteger + +data class Operation( + val id: String, + val address: String, + val type: Type, + val time: Long, + val chainAsset: Chain.Asset, + val extrinsicHash: String?, + val status: Status, +) { + + sealed class Type { + + data class Extrinsic( + val content: Content, + val fee: BigInteger, + val fiatFee: BigDecimal?, + ) : Type() { + + sealed class Content { + + class SubstrateCall(val module: String, val call: String) : Content() + + class ContractCall(val contractAddress: String, val function: String?) : Content() + } + } + + data class Reward( + val amount: BigInteger, + val fiatAmount: BigDecimal?, + val isReward: Boolean, + val eventId: String, + val kind: RewardKind + ) : Type() { + + sealed class RewardKind { + + class Direct(val era: Int?, val validator: String?) : RewardKind() + + class Pool(val poolId: Int) : RewardKind() + } + } + + data class Transfer( + val myAddress: String, + val amount: BigInteger, + val fiatAmount: BigDecimal?, + val receiver: String, + val sender: String, + val fee: BigInteger? + ) : Type() + + data class Swap( + val fee: ChainAssetWithAmount, + val amountIn: ChainAssetWithAmount, + val amountOut: ChainAssetWithAmount, + val fiatAmount: BigDecimal? + ) : Type() + } + + enum class Status { + PENDING, COMPLETED, FAILED; + + companion object { + fun fromSuccess(success: Boolean): Status { + return if (success) COMPLETED else FAILED + } + } + } +} + +data class ChainAssetWithAmount( + val chainAsset: Chain.Asset, + val amount: Balance, +) + +val ChainAssetWithAmount.decimalAmount: BigDecimal + get() = chainAsset.amountFromPlanks(amount) + +fun ChainAssetWithAmount.toIdWithAmount(): ChainAssetIdWithAmount { + return chainAsset.fullId.withAmount(amount) +} + +data class ChainAssetIdWithAmount( + val chainAssetId: FullChainAssetId, + val amount: Balance, +) + +fun FullChainAssetId.withAmount(amount: Balance): ChainAssetIdWithAmount { + return ChainAssetIdWithAmount(this, amount) +} + +fun Chain.Asset.withAmount(amount: Balance): ChainAssetWithAmount { + return ChainAssetWithAmount(this, amount) +} + +fun Operation.Type.satisfies(filters: Set): Boolean { + return matchingTransactionFilter() in filters +} + +fun Operation.isZeroTransfer(): Boolean { + return type is Operation.Type.Transfer && type.amount.isZero +} + +private fun Operation.Type.matchingTransactionFilter(): TransactionFilter { + return when (this) { + is Operation.Type.Extrinsic -> TransactionFilter.EXTRINSIC + is Operation.Type.Reward -> TransactionFilter.REWARD + is Operation.Type.Transfer -> TransactionFilter.TRANSFER + is Operation.Type.Swap -> TransactionFilter.SWAP + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/OperationsPageChange.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/OperationsPageChange.kt new file mode 100644 index 0000000..5d8b836 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/OperationsPageChange.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.common.data.model.DataPage + +data class OperationsPageChange( + val cursorPage: DataPage, + val accountChanged: Boolean +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/OriginFee.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/OriginFee.kt new file mode 100644 index 0000000..d89ac6a --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/OriginFee.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFeeBase +import io.novafoundation.nova.feature_account_api.data.model.getAmount + +data class OriginFee( + val submissionFee: SubmissionFee, + val deliveryFee: SubmissionFee?, +) { + + val totalInSubmissionAsset: FeeBase = createTotalFeeInSubmissionAsset() + + fun replaceSubmissionFee(submissionFee: SubmissionFee): OriginFee { + return copy(submissionFee = submissionFee) + } + + private fun createTotalFeeInSubmissionAsset(): FeeBase { + val submissionAsset = submissionFee.asset + val totalAmount = submissionFee.amount + deliveryFee?.getAmount(submissionAsset).orZero() + return SubstrateFeeBase(totalAmount, submissionAsset) + } +} + +fun OriginFee.intoFeeList(): List { + return listOfNotNull(submissionFee, deliveryFee) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/PricedAmount.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/PricedAmount.kt new file mode 100644 index 0000000..41f3648 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/PricedAmount.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import java.math.BigDecimal + +class PricedAmount( + val amount: BigDecimal, + val price: BigDecimal, + val currency: Currency +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/RecipientSearchResult.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/RecipientSearchResult.kt new file mode 100644 index 0000000..c3cefa7 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/RecipientSearchResult.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +class RecipientSearchResult( + val myAccounts: List, + val contacts: List +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/Token.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/Token.kt new file mode 100644 index 0000000..547d0b7 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/Token.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +import io.novafoundation.nova.common.utils.amountFromPlanks +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.planksFromAmount +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +interface TokenBase { + + val currency: Currency + + val coinRate: CoinRate? + + val configuration: Chain.Asset + + fun amountToFiat(tokenAmount: BigDecimal): BigDecimal = toFiatOrNull(tokenAmount).orZero() + + fun planksToFiat(tokenAmountPlanks: BigInteger): BigDecimal = planksToFiatOrNull(tokenAmountPlanks).orZero() +} + +data class Token( + override val currency: Currency, + override val coinRate: CoinRateChange?, + override val configuration: Chain.Asset +) : TokenBase { + // TODO move out of the class when Context Receivers will be stable + fun BigDecimal.toPlanks() = planksFromAmount(this) + fun BigInteger.toAmount() = amountFromPlanks(this) +} + +fun Token.fiatAmountOf(planks: Balance): FiatAmount { + return FiatAmount( + currency = currency, + price = planksToFiat(planks) + ) +} + +data class HistoricalToken( + override val currency: Currency, + override val coinRate: HistoricalCoinRate?, + override val configuration: Chain.Asset +) : TokenBase + +fun TokenBase.toFiatOrNull(tokenAmount: BigDecimal): BigDecimal? = coinRate?.convertAmount(tokenAmount) + +fun TokenBase.planksFromFiatOrZero(fiat: BigDecimal): Balance = coinRate?.convertFiatToPlanks(configuration, fiat).orZero() + +fun TokenBase.planksToFiatOrNull(tokenAmountPlanks: BigInteger): BigDecimal? = coinRate?.convertPlanks(configuration, tokenAmountPlanks) + +fun TokenBase.amountFromPlanks(amountInPlanks: BigInteger) = configuration.amountFromPlanks(amountInPlanks) + +fun TokenBase.planksFromAmount(amount: BigDecimal): BigInteger = configuration.planksFromAmount(amount) + +fun Chain.Asset.amountFromPlanks(amountInPlanks: BigInteger) = amountInPlanks.amountFromPlanks(precision) + +fun Chain.Asset.planksFromAmount(amount: BigDecimal): BigInteger = amount.planksFromAmount(precision) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/WalletAccount.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/WalletAccount.kt new file mode 100644 index 0000000..1c7a873 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/WalletAccount.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model + +class WalletAccount( + val address: String, + val name: String?, +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/CrossChainTransferConfiguration.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/CrossChainTransferConfiguration.kt new file mode 100644 index 0000000..fee3cef --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/CrossChainTransferConfiguration.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm + +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransferConfiguration +import io.novafoundation.nova.feature_xcm_api.chain.XcmChain +import io.novafoundation.nova.feature_xcm_api.chain.chainLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +sealed interface CrossChainTransferConfiguration : CrossChainTransferConfigurationBase { + + class Legacy(val config: LegacyCrossChainTransferConfiguration) : + CrossChainTransferConfiguration, + CrossChainTransferConfigurationBase by config + + class Dynamic(val config: DynamicCrossChainTransferConfiguration) : + CrossChainTransferConfiguration, + CrossChainTransferConfigurationBase by config +} + +interface CrossChainTransferConfigurationBase { + + val originChain: XcmChain + + val destinationChain: XcmChain + + val originChainAsset: Chain.Asset + + val transferType: XcmTransferType + + /** + * Any info usefully for logging besides fields [CrossChainTransferConfigurationBase] already expose + */ + fun debugExtraInfo(): String +} + +val CrossChainTransferConfigurationBase.originChainLocation: ChainLocation + get() = originChain.chainLocation() + +val CrossChainTransferConfigurationBase.destinationChainLocation: ChainLocation + get() = destinationChain.chainLocation() + +val CrossChainTransferConfigurationBase.originChainId: ChainId + get() = originChainLocation.chainId + +val CrossChainTransferConfigurationBase.destinationChainId: ChainId + get() = destinationChainLocation.chainId + +fun CrossChainTransferConfigurationBase.assetLocationOnOrigin(): RelativeMultiLocation { + return transferType.assetAbsoluteLocation.fromPointOfViewOf(originChainLocation.location) +} + +fun CrossChainTransferConfigurationBase.destinationChainLocationOnOrigin(): RelativeMultiLocation { + return destinationChainLocation.location.fromPointOfViewOf(originChainLocation.location) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/CrossChainTransfersConfiguration.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/CrossChainTransfersConfiguration.kt new file mode 100644 index 0000000..db20959 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/CrossChainTransfersConfiguration.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm + +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration + +class CrossChainTransfersConfiguration( + val dynamic: DynamicCrossChainTransfersConfiguration, + val legacy: LegacyCrossChainTransfersConfiguration +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/CrossChainTransfersConfigurationExt.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/CrossChainTransfersConfigurationExt.kt new file mode 100644 index 0000000..1a9479a --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/CrossChainTransfersConfigurationExt.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm + +import android.util.Log +import io.novafoundation.nova.common.utils.graph.Edge +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.availableInDestinations +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.availableOutDestinations +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.hasDeliveryFee +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.transferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.availableInDestinations +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.availableOutDestinations +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.hasDeliveryFee +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.transferConfiguration +import io.novafoundation.nova.feature_xcm_api.chain.XcmChain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +fun CrossChainTransfersConfiguration.availableOutDestinations(origin: Chain.Asset): List { + val combined = dynamic.availableOutDestinations(origin) + legacy.availableOutDestinations(origin) + return combined.distinct() +} + +fun CrossChainTransfersConfiguration.availableInDestinations(destination: Chain.Asset): List { + val combined = dynamic.availableInDestinations(destination) + legacy.availableInDestinations(destination) + return combined.distinct() +} + +fun CrossChainTransfersConfiguration.availableInDestinations(): List> { + val combined = dynamic.availableInDestinations() + legacy.availableInDestinations() + return combined.distinct() +} + +fun CrossChainTransfersConfiguration.hasDeliveryFee( + origin: FullChainAssetId, + destination: FullChainAssetId +): Boolean { + return dynamic.hasDeliveryFee(origin, destination) ?: legacy.hasDeliveryFee(origin.chainId) +} + +suspend fun CrossChainTransfersConfiguration.transferConfiguration( + originChain: XcmChain, + originAsset: Chain.Asset, + destinationChain: XcmChain, +): CrossChainTransferConfiguration? { + val result = dynamic.transferConfiguration(originChain, originAsset, destinationChain)?.let(CrossChainTransferConfiguration::Dynamic) + ?: legacy.transferConfiguration(originChain, originAsset, destinationChain)?.let(CrossChainTransferConfiguration::Legacy) + + logTransferConfiguration(originAsset, originChain, destinationChain, result) + + return result +} + +private fun logTransferConfiguration( + originAsset: Chain.Asset, + originChain: XcmChain, + destinationChain: XcmChain, + result: CrossChainTransferConfiguration? +) { + val logDirectionLabel = "${originAsset.symbol} ${originChain.chain.name} -> ${destinationChain.chain.name}" + if (result == null) { + Log.d("CrossChainTransfersConfiguration", "Found no configuration for direction $logDirectionLabel") + } else { + val message = """ + Using ${result::class.simpleName} configuration for direction $logDirectionLabel + Transfer type: ${result.transferType} + ${result.debugExtraInfo()} + + """.trimIndent() + Log.d("CrossChainTransfersConfiguration", message) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransferConfiguration.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransferConfiguration.kt new file mode 100644 index 0000000..4122797 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransferConfiguration.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic + +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfigurationBase +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType +import io.novafoundation.nova.feature_xcm_api.chain.XcmChain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class DynamicCrossChainTransferConfiguration( + override val originChain: XcmChain, + override val destinationChain: XcmChain, + override val transferType: XcmTransferType, + override val originChainAsset: Chain.Asset, + val features: DynamicCrossChainTransferFeatures, +) : CrossChainTransferConfigurationBase { + + override fun debugExtraInfo(): String { + return "features=$features" + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransferFeatures.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransferFeatures.kt new file mode 100644 index 0000000..0165bc3 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransferFeatures.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic + +data class DynamicCrossChainTransferFeatures( + val hasDeliveryFee: Boolean, + val supportsXcmExecute: Boolean, +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransfersConfiguration.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransfersConfiguration.kt new file mode 100644 index 0000000..d8754c1 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransfersConfiguration.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic + +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +class DynamicCrossChainTransfersConfiguration( + val reserveRegistry: TokenReserveRegistry, + val customTeleports: Set, + val chains: Map> +) { + + class AssetTransfers( + val assetId: ChainAssetId, + val destinations: List + ) + + class TransferDestination( + val fullChainAssetId: FullChainAssetId, + val hasDeliveryFee: Boolean, + val supportsXcmExecute: Boolean, + ) + + data class CustomTeleportEntry(val originChainAssetId: FullChainAssetId, val destinationChainId: ChainId) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransfersConfigurationExt.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransfersConfigurationExt.kt new file mode 100644 index 0000000..4c79af8 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/DynamicCrossChainTransfersConfigurationExt.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic + +import io.novafoundation.nova.common.utils.graph.Edge +import io.novafoundation.nova.common.utils.graph.SimpleEdge +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration.CustomTeleportEntry +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration.TransferDestination +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType +import io.novafoundation.nova.feature_xcm_api.chain.XcmChain +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +fun DynamicCrossChainTransfersConfiguration.availableOutDestinations(origin: Chain.Asset): List { + val assetTransfers = outComingAssetTransfers(origin.fullId) ?: return emptyList() + return assetTransfers.destinations.map { it.fullChainAssetId } +} + +fun DynamicCrossChainTransfersConfiguration.availableInDestinations(destination: Chain.Asset): List { + val requiredDestinationId = destination.fullId + + return chains.flatMap { (originChainId, chainTransfers) -> + chainTransfers.mapNotNull { originAssetTransfers -> + val hasDestination = originAssetTransfers.destinations.any { it.fullChainAssetId == requiredDestinationId } + + if (hasDestination) { + FullChainAssetId(originChainId, originAssetTransfers.assetId) + } else { + null + } + } + } +} + +fun DynamicCrossChainTransfersConfiguration.availableInDestinations(): List> { + return chains.flatMap { (originChainId, chainTransfers) -> + chainTransfers.flatMap { originAssetTransfers -> + originAssetTransfers.destinations.map { + val from = FullChainAssetId(originChainId, originAssetTransfers.assetId) + val to = it.fullChainAssetId + + SimpleEdge(from, to) + } + } + } +} + +fun DynamicCrossChainTransfersConfiguration.transferFeatures( + originAsset: FullChainAssetId, + destinationChainId: ChainId +): DynamicCrossChainTransferFeatures? { + return outComingAssetTransfers(originAsset)?.getDestination(destinationChainId)?.getTransferFeatures() +} + +suspend fun DynamicCrossChainTransfersConfiguration.transferConfiguration( + originXcmChain: XcmChain, + originAsset: Chain.Asset, + destinationXcmChain: XcmChain, +): DynamicCrossChainTransferConfiguration? { + val destinationChain = destinationXcmChain.chain + + val assetTransfers = outComingAssetTransfers(originAsset.fullId) ?: return null + val targetTransfer = assetTransfers.getDestination(destinationChain.id) ?: return null + + val reserve = reserveRegistry.getReserve(originAsset) + + return DynamicCrossChainTransferConfiguration( + originChain = originXcmChain, + destinationChain = destinationXcmChain, + originChainAsset = originAsset, + transferType = XcmTransferType.determineTransferType( + usesTeleports = canUseTeleport(originXcmChain, originAsset, destinationXcmChain), + originChain = originXcmChain, + destinationChain = destinationXcmChain, + reserve = reserve + ), + features = targetTransfer.getTransferFeatures(), + ) +} + +private fun DynamicCrossChainTransfersConfiguration.canUseTeleport( + originXcmChain: XcmChain, + originAsset: Chain.Asset, + destinationXcmChain: XcmChain, +): Boolean { + val customTeleportEntry = CustomTeleportEntry(originAsset.fullId, destinationXcmChain.chain.id) + if (customTeleportEntry in customTeleports) return true + + return XcmTransferType.isSystemTeleport(originXcmChain, destinationXcmChain) +} + +private fun AssetTransfers.getDestination(destinationChainId: ChainId): TransferDestination? { + return destinations.find { it.fullChainAssetId.chainId == destinationChainId } +} + +private fun TransferDestination.getTransferFeatures(): DynamicCrossChainTransferFeatures { + return DynamicCrossChainTransferFeatures( + hasDeliveryFee = hasDeliveryFee, + supportsXcmExecute = supportsXcmExecute, + ) +} + +/** + * @return null if transfer is unknown, true if delivery fee has to be paid, false otherwise + */ +fun DynamicCrossChainTransfersConfiguration.hasDeliveryFee( + origin: FullChainAssetId, + destination: FullChainAssetId +): Boolean? { + val transfers = outComingAssetTransfers(origin) ?: return null + val destinationConfig = transfers.destinations.find { it.fullChainAssetId == destination } ?: return null + + return destinationConfig.hasDeliveryFee +} + +private fun DynamicCrossChainTransfersConfiguration.outComingAssetTransfers(origin: FullChainAssetId): AssetTransfers? { + return chains[origin.chainId]?.find { it.assetId == origin.assetId } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserve.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserve.kt new file mode 100644 index 0000000..c83a076 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserve.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve + +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class TokenReserve( + val reserveChainLocation: ChainLocation, + val tokenLocation: AbsoluteMultiLocation +) + +fun TokenReserve.isRemote(origin: ChainId, destination: ChainId): Boolean { + return origin != reserveChainLocation.chainId && destination != reserveChainLocation.chainId +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserveConfig.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserveConfig.kt new file mode 100644 index 0000000..e19106c --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserveConfig.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve + +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class TokenReserveConfig( + val reserveChainId: ChainId, + val tokenReserveLocation: AbsoluteMultiLocation, +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserveId.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserveId.kt new file mode 100644 index 0000000..013f145 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserveId.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve + +typealias TokenReserveId = String diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserveRegistry.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserveRegistry.kt new file mode 100644 index 0000000..f0cff00 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/TokenReserveRegistry.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve + +import io.novafoundation.nova.feature_wallet_api.data.repository.getChainLocation +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.normalizeSymbol +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository + +class TokenReserveRegistry( + private val parachainInfoRepository: ParachainInfoRepository, + + private val reservesById: Map, + + // By default, asset reserve id is equal to its symbol + // This mapping allows to override that for cases like multiple reserves (Statemine & Polkadot for DOT) + private val assetToReserveIdOverrides: Map +) { + + suspend fun getReserve(chainAsset: Chain.Asset): TokenReserve { + val reserveId = getReserveId(chainAsset) + val reserve = reservesById.getValue(reserveId) + return TokenReserve( + reserveChainLocation = parachainInfoRepository.getChainLocation(reserve.reserveChainId), + tokenLocation = reserve.tokenReserveLocation + ) + } + + private fun getReserveId(chainAsset: Chain.Asset): TokenReserveId { + return assetToReserveIdOverrides[chainAsset.fullId] ?: chainAsset.normalizeSymbol() + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/XcmTransferType.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/XcmTransferType.kt new file mode 100644 index 0000000..c8dcfc3 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/dynamic/reserve/XcmTransferType.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve + +import io.novafoundation.nova.feature_xcm_api.chain.XcmChain +import io.novafoundation.nova.feature_xcm_api.chain.isRelay +import io.novafoundation.nova.feature_xcm_api.chain.isSystemChain +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation + +sealed interface XcmTransferType { + + companion object { + + fun determineTransferType( + usesTeleports: Boolean, + originChain: XcmChain, + destinationChain: XcmChain, + reserve: TokenReserve + ): XcmTransferType { + val assetAbsoluteLocation = reserve.tokenLocation + + return when { + usesTeleports -> Teleport(assetAbsoluteLocation) + originChain.chain.id == reserve.reserveChainLocation.chainId -> Reserve.Origin(assetAbsoluteLocation) + destinationChain.chain.id == reserve.reserveChainLocation.chainId -> Reserve.Destination(assetAbsoluteLocation) + else -> Reserve.Remote(assetAbsoluteLocation, reserve.reserveChainLocation) + } + } + + fun isSystemTeleport(originXcmChain: XcmChain, destinationXcmChain: XcmChain): Boolean { + val systemToRelay = originXcmChain.isSystemChain() && destinationXcmChain.isRelay() + val relayToSystem = originXcmChain.isRelay() && destinationXcmChain.isSystemChain() + val systemToSystem = originXcmChain.isSystemChain() && destinationXcmChain.isSystemChain() + + return systemToRelay || relayToSystem || systemToSystem + } + } + + val assetAbsoluteLocation: AbsoluteMultiLocation + + data class Teleport(override val assetAbsoluteLocation: AbsoluteMultiLocation) : XcmTransferType + + sealed interface Reserve : XcmTransferType { + + data class Origin(override val assetAbsoluteLocation: AbsoluteMultiLocation) : Reserve + + data class Destination(override val assetAbsoluteLocation: AbsoluteMultiLocation) : Reserve + + data class Remote( + override val assetAbsoluteLocation: AbsoluteMultiLocation, + val remoteReserveLocation: ChainLocation + ) : Reserve + } +} + +fun XcmTransferType.remoteReserveLocation(): ChainLocation? { + return (this as? XcmTransferType.Reserve.Remote)?.remoteReserveLocation +} + +fun XcmTransferType.isRemoteReserve(): Boolean { + return this is XcmTransferType.Reserve.Remote +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/legacy/LegacyCrossChainTransferConfiguration.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/legacy/LegacyCrossChainTransferConfiguration.kt new file mode 100644 index 0000000..5c6609f --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/legacy/LegacyCrossChainTransferConfiguration.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy + +import io.novafoundation.nova.common.data.network.runtime.binding.Weight +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfigurationBase +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.XcmFee +import io.novafoundation.nova.feature_xcm_api.chain.XcmChain +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class LegacyCrossChainTransferConfiguration( + override val originChain: XcmChain, + override val destinationChain: XcmChain, + override val originChainAsset: Chain.Asset, + override val transferType: XcmTransferType, + + // Those 3 fields are duplicated by CrossChainTransferConfigurationBase extensions + // But we do not refactor it to avoid unnecessary scope exposure + val assetLocation: RelativeMultiLocation, + val reserveChainLocation: RelativeMultiLocation, + val destinationChainLocation: RelativeMultiLocation, + + val destinationFee: CrossChainFeeConfiguration, + val reserveFee: CrossChainFeeConfiguration?, + val transferMethod: LegacyXcmTransferMethod, +) : CrossChainTransferConfigurationBase { + + override fun debugExtraInfo(): String { + return "transferMethod=$transferMethod" + } +} + +class CrossChainFeeConfiguration( + val from: From, + val to: To +) { + + class From(val chainId: ChainId, val deliveryFeeConfiguration: DeliveryFeeConfiguration?) + + class To( + val chainId: ChainId, + val instructionWeight: Weight, + val xcmFeeType: XcmFee> + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/legacy/LegacyCrossChainTransfersConfiguration.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/legacy/LegacyCrossChainTransfersConfiguration.kt new file mode 100644 index 0000000..f5b3753 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/legacy/LegacyCrossChainTransfersConfiguration.kt @@ -0,0 +1,101 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy + +import io.novafoundation.nova.common.data.network.runtime.binding.Weight +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveRegistry +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger + +class LegacyCrossChainTransfersConfiguration( + // Reserves locations from the Relaychain point of view + val assetLocations: Map, + val feeInstructions: Map>, + val deliveryFeeConfigurations: Map, + val instructionBaseWeights: Map, + val chains: Map>, + val reserveRegistry: TokenReserveRegistry, +) { + + class ReserveLocation( + val chainId: ChainId, + val reserveFee: XcmFee?, + val multiLocation: RelativeMultiLocation + ) + + class AssetTransfers( + val assetId: ChainAssetId, + val assetLocation: String, + val assetLocationPath: AssetLocationPath, + val xcmTransfers: List + ) + + class XcmTransfer( + val destination: XcmDestination, + val type: LegacyXcmTransferMethod + ) + + class XcmDestination( + val chainId: ChainId, + val assetId: ChainAssetId, + val fee: XcmFee, + ) + + class XcmFee( + val mode: Mode, + val instructions: I + ) { + sealed class Mode { + object Standard : Mode() + + class Proportional(val unitsPerSecond: BigInteger) : Mode() + + object Unknown : Mode() + } + } +} + +sealed class AssetLocationPath { + + object Relative : AssetLocationPath() + + object Absolute : AssetLocationPath() + + class Concrete(val multiLocation: RelativeMultiLocation) : AssetLocationPath() +} + +enum class LegacyXcmTransferMethod { + X_TOKENS, + XCM_PALLET_RESERVE, + XCM_PALLET_TELEPORT, + XCM_PALLET_TRANSFER_ASSETS, + UNKNOWN +} + +enum class XCMInstructionType { + ReserveAssetDeposited, + ClearOrigin, + BuyExecution, + DepositAsset, + WithdrawAsset, + DepositReserveAsset, + ReceiveTeleportedAsset, + UNKNOWN +} + +class DeliveryFeeConfiguration( + val toParent: Type?, + val toParachain: Type? +) { + + sealed interface Type { + class Exponential( + val factorPallet: String, + val sizeBase: BigInteger, + val sizeFactor: BigInteger, + val alwaysHoldingPays: Boolean + ) : Type + + object Undefined : Type + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/legacy/LegacyCrossChainTransfersConfigurationExt.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/legacy/LegacyCrossChainTransfersConfigurationExt.kt new file mode 100644 index 0000000..dae6ac1 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/model/xcm/legacy/LegacyCrossChainTransfersConfigurationExt.kt @@ -0,0 +1,215 @@ +package io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.data.network.runtime.binding.Weight +import io.novafoundation.nova.common.utils.graph.Edge +import io.novafoundation.nova.common.utils.graph.SimpleEdge +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.AssetTransfers +import io.novafoundation.nova.feature_xcm_api.chain.XcmChain +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Interior +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId.Companion.JUNCTION_TYPE_PARACHAIN +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId.Companion.JUNCTION_TYPE_TEYRCHAIN +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.toInterior +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.isParachain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import java.math.BigInteger +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType as XcmReserveTransferType + +// Pezkuwi chain IDs - these chains use "Teyrchain" instead of "Parachain" in XCM +private val PEZKUWI_CHAIN_IDS = setOf( + "bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75", // PEZKUWI + "00d0e1d0581c3cd5c5768652d52f4520184018b44f56a2ae1e0dc9d65c00c948", // PEZKUWI_ASSET_HUB + "58269e9c184f721e0309332d90cafc410df1519a5dc27a5fd9b3bf5fd2d129f8" // PEZKUWI_PEOPLE +) + +private fun junctionTypeNameForChain(chainId: ChainId): String { + return if (chainId in PEZKUWI_CHAIN_IDS) JUNCTION_TYPE_TEYRCHAIN else JUNCTION_TYPE_PARACHAIN +} + +fun LegacyCrossChainTransfersConfiguration.XcmFee.Mode.Proportional.weightToFee(weight: Weight): BigInteger { + val pico = BigInteger.TEN.pow(12) + + // Weight is an amount of picoseconds operation suppose to execute + return weight * unitsPerSecond / pico +} + +fun LegacyCrossChainTransfersConfiguration.availableOutDestinations(origin: Chain.Asset): List { + val assetTransfers = outComingAssetTransfers(origin) ?: return emptyList() + + return assetTransfers.xcmTransfers + .filter { it.type != LegacyXcmTransferMethod.UNKNOWN } + .map { it.destination.fullDestinationAssetId } +} + +fun LegacyCrossChainTransfersConfiguration.availableInDestinations(destination: Chain.Asset): List { + val requiredDestinationId = destination.fullId + + return chains.flatMap { (originChainId, chainTransfers) -> + chainTransfers.mapNotNull { originAssetTransfers -> + val hasDestination = originAssetTransfers.xcmTransfers + .any { it.type != LegacyXcmTransferMethod.UNKNOWN && it.destination.fullDestinationAssetId == requiredDestinationId } + + FullChainAssetId(originChainId, originAssetTransfers.assetId).takeIf { hasDestination } + } + } +} + +fun LegacyCrossChainTransfersConfiguration.availableInDestinations(): List> { + return chains.flatMap { (originChainId, chainTransfers) -> + chainTransfers.flatMap { originAssetTransfers -> + originAssetTransfers.xcmTransfers.mapNotNull { + if (it.type == LegacyXcmTransferMethod.UNKNOWN) return@mapNotNull null + + val from = FullChainAssetId(originChainId, originAssetTransfers.assetId) + val to = FullChainAssetId(it.destination.chainId, it.destination.assetId) + + SimpleEdge(from, to) + } + } + } +} + +suspend fun LegacyCrossChainTransfersConfiguration.transferConfiguration( + originXcmChain: XcmChain, + originAsset: Chain.Asset, + destinationXcmChain: XcmChain, +): LegacyCrossChainTransferConfiguration? { + val originChain = originXcmChain.chain + val destinationChain = destinationXcmChain.chain + + val assetTransfers = outComingAssetTransfers(originAsset) ?: return null + val destination = assetTransfers.xcmTransfers.find { it.destination.chainId == destinationChain.id } ?: return null + + val reserveAssetLocation = assetLocations.getValue(assetTransfers.assetLocation) + val hasReserveFee = reserveAssetLocation.chainId !in setOf(originChain.id, destinationChain.id) + val reserveFee = if (hasReserveFee) { + // reserve fee must be present if there is at least one non-reserve transfer + matchInstructions(reserveAssetLocation.reserveFee!!, originChain.id, reserveAssetLocation.chainId) + } else { + null + } + + val destinationFee = matchInstructions( + destination.destination.fee, + if (hasReserveFee) reserveAssetLocation.chainId else originChain.id, + destination.destination.chainId + ) + + return LegacyCrossChainTransferConfiguration( + originChain = originXcmChain, + destinationChain = destinationXcmChain, + transferType = XcmReserveTransferType.determineTransferType( + usesTeleports = XcmReserveTransferType.isSystemTeleport(originXcmChain, destinationXcmChain), + originChain = originXcmChain, + destinationChain = destinationXcmChain, + reserve = reserveRegistry.getReserve(originAsset) + ), + assetLocation = originAssetLocationOf(assetTransfers), + reserveChainLocation = reserveAssetLocation.multiLocation, + destinationChainLocation = destinationLocation(originChain, destinationXcmChain.parachainId, destinationChain.id), + destinationFee = destinationFee, + reserveFee = reserveFee, + originChainAsset = originAsset, + transferMethod = destination.type + ) +} + +fun LegacyCrossChainTransfersConfiguration.hasDeliveryFee(chainId: ChainId): Boolean { + return chainId in deliveryFeeConfigurations +} + +private fun LegacyCrossChainTransfersConfiguration.matchInstructions( + xcmFee: LegacyCrossChainTransfersConfiguration.XcmFee, + fromChainId: ChainId, + toChainId: ChainId, +): CrossChainFeeConfiguration { + return CrossChainFeeConfiguration( + from = CrossChainFeeConfiguration.From( + chainId = fromChainId, + deliveryFeeConfiguration = deliveryFeeConfigurations[fromChainId], + ), + to = CrossChainFeeConfiguration.To( + chainId = toChainId, + instructionWeight = instructionBaseWeights.getValue(toChainId), + xcmFeeType = LegacyCrossChainTransfersConfiguration.XcmFee( + mode = xcmFee.mode, + instructions = feeInstructions.getValue(xcmFee.instructions), + ) + ) + ) +} + +private fun destinationLocation( + originChain: Chain, + destinationParaId: ParaId?, + destinationChainId: ChainId +) = when { + // parachain -> parachain + originChain.isParachain && destinationParaId != null -> SiblingParachain(destinationParaId, destinationChainId) + + // parachain -> relaychain + originChain.isParachain -> ParentChain() + + // relaychain -> parachain + destinationParaId != null -> ChildParachain(destinationParaId, destinationChainId) + + // relaychain -> relaychain ? + else -> throw UnsupportedOperationException("Unsupported cross-chain transfer") +} + +private fun ChildParachain(paraId: ParaId, destinationChainId: ChainId): RelativeMultiLocation { + val junctionTypeName = junctionTypeNameForChain(destinationChainId) + return RelativeMultiLocation( + parents = 0, + interior = listOf(Junction.ParachainId(paraId, junctionTypeName)).toInterior() + ) +} + +private fun ParentChain(): RelativeMultiLocation { + return RelativeMultiLocation( + parents = 1, + interior = Interior.Here + ) +} + +private fun SiblingParachain(paraId: ParaId, destinationChainId: ChainId): RelativeMultiLocation { + val junctionTypeName = junctionTypeNameForChain(destinationChainId) + return RelativeMultiLocation( + parents = 1, + listOf(Junction.ParachainId(paraId, junctionTypeName)).toInterior() + ) +} + +private fun LegacyCrossChainTransfersConfiguration.originAssetLocationOf(assetTransfers: AssetTransfers): RelativeMultiLocation { + return when (val path = assetTransfers.assetLocationPath) { + is AssetLocationPath.Absolute -> assetLocations.getValue(assetTransfers.assetLocation).multiLocation.childView() + is AssetLocationPath.Relative -> assetLocations.getValue(assetTransfers.assetLocation).multiLocation.localView() + is AssetLocationPath.Concrete -> path.multiLocation + } +} + +private fun LegacyCrossChainTransfersConfiguration.outComingAssetTransfers(origin: Chain.Asset): AssetTransfers? { + return chains[origin.chainId]?.find { it.assetId == origin.id } +} + +private val LegacyCrossChainTransfersConfiguration.XcmDestination.fullDestinationAssetId: FullChainAssetId + get() = FullChainAssetId(chainId, assetId) + +private fun RelativeMultiLocation.localView(): RelativeMultiLocation { + return RelativeMultiLocation( + parents = 0, + interior = when (val interior = interior) { + Interior.Here -> interior + + is Interior.Junctions -> interior.junctions.takeLastWhile { it !is Junction.ParachainId } + .toInterior() + } + ) +} + +private fun RelativeMultiLocation.childView() = RelativeMultiLocation(parents + 1, interior) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/updater/AccountInfoUpdater.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/updater/AccountInfoUpdater.kt new file mode 100644 index 0000000..d572a49 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/updater/AccountInfoUpdater.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.feature_wallet_api.domain.updater + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.updaters.ChainUpdateScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.network.updaters.SingleStorageKeyUpdater +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class AccountInfoUpdaterFactory( + private val storageCache: StorageCache, + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry +) { + + fun create(chainUpdateScope: ChainUpdateScope, sharedState: SelectedAssetOptionSharedState<*>): AccountInfoUpdater { + return AccountInfoUpdater( + chainUpdateScope = chainUpdateScope, + storageCache = storageCache, + sharedState = sharedState, + accountRepository = accountRepository, + chainRegistry = chainRegistry + ) + } +} + +class AccountInfoUpdater( + chainUpdateScope: ChainUpdateScope, + storageCache: StorageCache, + sharedState: SelectedAssetOptionSharedState<*>, + chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, +) : SingleStorageKeyUpdater(chainUpdateScope, sharedState, chainRegistry, storageCache) { + + override val requiredModules: List = listOf(Modules.SYSTEM) + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Chain): String? { + val metaAccount = accountRepository.getSelectedMetaAccount() + val accountId = metaAccount.accountIdIn(scopeValue) ?: return null + return runtime.metadata.module(Modules.SYSTEM).storage("Account").storageKey(runtime, accountId) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/AddressValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/AddressValidation.kt new file mode 100644 index 0000000..fe51114 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/AddressValidation.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.runtime.ext.isValidAddress +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class AddressValidation( + private val address: (P) -> String, + private val chain: (P) -> Chain, + private val error: (P) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + return validOrError(chain(value).isValidAddress(address(value))) { + error(value) + } + } +} + +fun ValidationSystemBuilder.validAddress( + address: (P) -> String, + chain: (P) -> Chain, + error: (P) -> E +) = validate( + AddressValidation( + address = address, + chain = chain, + error = error + ) +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/CrossMinimumBalanceValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/CrossMinimumBalanceValidation.kt new file mode 100644 index 0000000..2ef88e8 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/CrossMinimumBalanceValidation.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer.Payload.DialogAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validOrWarning +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.domain.validation.CrossMinimumBalanceValidation.ErrorContext +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +class CrossMinimumBalanceValidation( + private val minimumBalance: suspend (P) -> Balance, + private val chainAsset: (P) -> Chain.Asset, + private val currentBalance: (P) -> BigDecimal, + private val deductingAmount: (P) -> BigDecimal, + private val error: (ErrorContext) -> E +) : Validation { + + class ErrorContext( + val balanceAfterDeduction: BigDecimal, + val minimumBalance: BigDecimal, + val wholeAmount: BigDecimal, + val chainAsset: Chain.Asset + ) + + override suspend fun validate(value: P): ValidationStatus { + val existentialBalanceInPlanks = minimumBalance(value) + val chainAsset = chainAsset(value) + val existentialBalance = chainAsset.amountFromPlanks(existentialBalanceInPlanks) + + val currentBalance = currentBalance(value) + val balanceAfterDeduction = currentBalance - deductingAmount(value) + + val resultGreaterThanExistential = balanceAfterDeduction >= existentialBalance + val resultIsZero = balanceAfterDeduction.atLeastZero().isZero + + return validOrWarning(resultGreaterThanExistential || resultIsZero) { + val errorContext = ErrorContext( + balanceAfterDeduction = balanceAfterDeduction, + minimumBalance = existentialBalance, + wholeAmount = currentBalance, + chainAsset = chainAsset + ) + error(errorContext) + } + } +} + +interface CrossMinimumBalanceValidationFailure { + + val errorContext: ErrorContext +} + +fun

CrossMinimumBalanceValidationFailure.handleWith( + resourceManager: ResourceManager, + flowActions: ValidationFlowActions

, + modifyPayload: (old: P, newAmount: BigDecimal) -> P, +): TransformedFailure.Custom = with(errorContext) { + val balanceAfterDeductionFormatted = balanceAfterDeduction.formatTokenAmount(chainAsset) + val minimumBalanceFormatted = minimumBalance.formatTokenAmount(chainAsset) + + val dialogPayload = CustomDialogDisplayer.Payload( + title = resourceManager.getString(R.string.staking_unbond_crossed_existential_title), + message = resourceManager.getString(R.string.staking_unbond_crossed_existential, minimumBalanceFormatted, balanceAfterDeductionFormatted), + okAction = DialogAction( + title = resourceManager.getString(R.string.staking_unstake_all), + action = { + flowActions.resumeFlow { modifyPayload(it, wholeAmount) } + } + ), + cancelAction = DialogAction.noOp(resourceManager.getString(R.string.common_cancel)) + ) + + return TransformedFailure.Custom(dialogPayload) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EnoughAmountToTransferValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EnoughAmountToTransferValidation.kt new file mode 100644 index 0000000..d3deea8 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EnoughAmountToTransferValidation.kt @@ -0,0 +1,139 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.decimalAmountByExecutingAccount +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +interface NotEnoughToPayFeesError { + val chainAsset: Chain.Asset + val maxUsable: BigDecimal + val fee: BigDecimal +} + +class EnoughAmountToTransferValidation( + private val feeExtractor: AmountProducer

, + private val availableBalanceProducer: AmountProducer

, + private val errorProducer: (ErrorContext

) -> E, + private val skippable: Boolean = false, + private val extraAmountExtractor: AmountProducer

= { BigDecimal.ZERO }, +) : Validation { + + class ErrorContext

( + + val payload: P, + + val maxUsable: BigDecimal, + + val fee: BigDecimal, + ) + + companion object; + + override suspend fun validate(value: P): ValidationStatus { + val fee = feeExtractor(value) + val available = availableBalanceProducer(value) + val amount = extraAmountExtractor(value) + + return if (fee + amount <= available) { + ValidationStatus.Valid() + } else { + val maxUsable = (available - fee).coerceAtLeast(BigDecimal.ZERO) + + val failureLevel = if (skippable) DefaultFailureLevel.WARNING else DefaultFailureLevel.ERROR + + ValidationStatus.NotValid(failureLevel, errorProducer(ErrorContext(value, maxUsable, fee))) + } + } +} + +fun EnoughAmountToTransferValidationGeneric( + feeExtractor: SimpleFeeProducer

= { null }, + extraAmountExtractor: AmountProducer

= { BigDecimal.ZERO }, + availableBalanceProducer: AmountProducer

, + errorProducer: (EnoughAmountToTransferValidation.ErrorContext

) -> E, + skippable: Boolean = false +): EnoughAmountToTransferValidation { + return EnoughAmountToTransferValidation( + feeExtractor = { feeExtractor(it)?.decimalAmountByExecutingAccount.orZero() }, + extraAmountExtractor = extraAmountExtractor, + errorProducer = errorProducer, + skippable = skippable, + availableBalanceProducer = availableBalanceProducer + ) +} + +fun ValidationSystemBuilder.sufficientBalanceMultiFee( + feeExtractor: FeeListProducer = { emptyList() }, + amount: AmountProducer

= { BigDecimal.ZERO }, + available: AmountProducer

, + error: (EnoughAmountToTransferValidation.ErrorContext

) -> E, + skippable: Boolean = false +) = validate( + EnoughAmountToTransferValidation( + feeExtractor = { payload -> feeExtractor(payload).sumOf { it.decimalAmountByExecutingAccount } }, + extraAmountExtractor = amount, + errorProducer = error, + skippable = skippable, + availableBalanceProducer = available + ) +) + +fun ValidationSystemBuilder.sufficientBalance( + fee: SimpleFeeProducer

= { null }, + amount: AmountProducer

= { BigDecimal.ZERO }, + available: AmountProducer

, + error: (EnoughAmountToTransferValidation.ErrorContext

) -> E, + skippable: Boolean = false +) = validate( + EnoughAmountToTransferValidationGeneric( + feeExtractor = fee, + extraAmountExtractor = amount, + availableBalanceProducer = available, + errorProducer = error, + skippable = skippable + ) +) +fun ValidationSystemBuilder.sufficientBalanceGeneric( + fee: AmountProducer

= { BigDecimal.ZERO }, + amount: AmountProducer

= { BigDecimal.ZERO }, + available: AmountProducer

, + error: (EnoughAmountToTransferValidation.ErrorContext

) -> E, + skippable: Boolean = false +) = validate( + EnoughAmountToTransferValidation( + feeExtractor = fee, + extraAmountExtractor = amount, + errorProducer = error, + skippable = skippable, + availableBalanceProducer = available + ) +) + +fun ResourceManager.notSufficientBalanceToPayFeeErrorMessage() = getString(R.string.common_not_enough_funds_title) to + getString(R.string.common_not_enough_funds_message) + +fun ResourceManager.amountIsTooBig() = getString(R.string.common_not_enough_funds_title) to + getString(R.string.choose_amount_error_too_big) + +fun ResourceManager.zeroAmount() = getString(R.string.common_amount_low) to + getString(R.string.common_zero_amount_error) + +fun handleNotEnoughFeeError(error: NotEnoughToPayFeesError, resourceManager: ResourceManager): TitleAndMessage { + val title = resourceManager.getString(R.string.common_not_enough_funds_title) + + val maxUsable = error.maxUsable.formatTokenAmount(error.chainAsset) + val fee = error.fee.formatTokenAmount(error.chainAsset) + val message = resourceManager.getString(R.string.common_cannot_pay_network_fee_message, maxUsable, fee) + + return title to message +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EnoughBalanceToStayAboveEDValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EnoughBalanceToStayAboveEDValidation.kt new file mode 100644 index 0000000..faa651b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EnoughBalanceToStayAboveEDValidation.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.decimalAmountByExecutingAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDeposit +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError.ErrorModel +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +interface InsufficientBalanceToStayAboveEDError { + + val asset: Chain.Asset + val errorModel: ErrorModel + + class ErrorModel( + val minRequiredBalance: BigDecimal, + val availableBalance: BigDecimal, + val balanceToAdd: BigDecimal + ) +} + +class EnoughBalanceToStayAboveEDValidation( + private val assetSourceRegistry: AssetSourceRegistry, + private val fee: OptionalFeeProducer, + private val balance: AmountProducer

, + private val chainWithAsset: (P) -> ChainWithAsset, + private val error: (P, ErrorModel) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val asset = chainWithAsset(value).asset + val existentialDeposit = assetSourceRegistry.existentialDeposit(asset) + val balance = balance(value) + val fee = fee(value)?.decimalAmountByExecutingAccount.orZero() + return validOrError(balance - fee >= existentialDeposit) { + val minRequired = existentialDeposit + fee + error( + value, + ErrorModel( + minRequiredBalance = minRequired, + availableBalance = balance, + balanceToAdd = minRequired - balance + ) + ) + } + } +} + +class EnoughTotalToStayAboveEDValidationFactory(private val assetSourceRegistry: AssetSourceRegistry) { + + fun create( + fee: OptionalFeeProducer, + balance: AmountProducer

, + chainWithAsset: (P) -> ChainWithAsset, + error: (P, ErrorModel) -> E + ): EnoughBalanceToStayAboveEDValidation { + return EnoughBalanceToStayAboveEDValidation( + assetSourceRegistry = assetSourceRegistry, + fee = fee, + balance = balance, + chainWithAsset = chainWithAsset, + error = error + ) + } +} + +context(ValidationSystemBuilder) +fun EnoughTotalToStayAboveEDValidationFactory.validate( + fee: OptionalFeeProducer, + balance: AmountProducer

, + chainWithAsset: (P) -> ChainWithAsset, + error: (P, ErrorModel) -> E +) { + validate(create(fee, balance, chainWithAsset, error)) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EvmAssetExistenceValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EvmAssetExistenceValidation.kt new file mode 100644 index 0000000..e1e510f --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EvmAssetExistenceValidation.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.common.validation.validationWarning +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.assetOrNull +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.chainAssetIdOfErc20Token +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Source +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +typealias AssetNotExistError = (existingSymbol: String, canModify: Boolean) -> E + +class EvmAssetExistenceValidation( + private val chainRegistry: ChainRegistry, + private val chain: (P) -> Chain, + private val contractAddress: (P) -> String, + private val assetAlreadyExists: AssetNotExistError, + private val addressMappingError: (P) -> E, +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + return try { + val assetId = chainAssetIdOfErc20Token(contractAddress(value)) + val fullAssetId = FullChainAssetId(chain(value).id, assetId) + val alreadyExistingAsset = chainRegistry.assetOrNull(fullAssetId) + + when { + alreadyExistingAsset == null -> valid() + // we only allow to modify manually added tokens. Default tokens should remain unchanged + alreadyExistingAsset.source == Source.MANUAL -> assetAlreadyExists(alreadyExistingAsset.symbol.value, true).validationWarning() + else -> assetAlreadyExists(alreadyExistingAsset.symbol.value, false).validationError() + } + } catch (e: Exception) { + validationError(addressMappingError(value)) + } + } +} + +fun ValidationSystemBuilder.evmAssetNotExists( + chainRegistry: ChainRegistry, + chain: (P) -> Chain, + address: (P) -> String, + assetNotExistError: AssetNotExistError, + addressMappingError: (P) -> E +) = validate( + EvmAssetExistenceValidation( + chainRegistry = chainRegistry, + chain = chain, + contractAddress = address, + assetAlreadyExists = assetNotExistError, + addressMappingError = addressMappingError + ) +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EvmTokenContractValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EvmTokenContractValidation.kt new file mode 100644 index 0000000..459f403 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/EvmTokenContractValidation.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.address.format.EthereumAddressFormat +import io.novafoundation.nova.common.address.format.asAddress +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.runtime.ethereum.contract.base.querySingle +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class EvmTokenContractValidation( + private val ethereumAddressFormat: EthereumAddressFormat, + private val erc20Standard: Erc20Standard, + private val chainRegistry: ChainRegistry, + private val chain: (P) -> Chain, + private val address: (P) -> String, + private val error: (P) -> E, +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val evmAddress = address(value).asAddress() + val isEvmAddress = ethereumAddressFormat.isValidAddress(evmAddress) + return if (isEvmAddress) { + isTokenContract(value).isTrueOrError { error(value) } + } else { + validationError(error(value)) + } + } + + private suspend fun isTokenContract(value: P): Boolean { + val ethApi = chainRegistry.getCallEthereumApiOrThrow(chain(value).id) + return try { + erc20Standard.querySingle(address(value), ethApi) + .totalSupply() + true + } catch (e: Exception) { + false + } + } +} + +fun ValidationSystemBuilder.validErc20Contract( + ethereumAddressFormat: EthereumAddressFormat, + erc20Standard: Erc20Standard, + chainRegistry: ChainRegistry, + chain: (P) -> Chain, + address: (P) -> String, + error: (P) -> E, +) = validate( + EvmTokenContractValidation( + ethereumAddressFormat = ethereumAddressFormat, + erc20Standard = erc20Standard, + chainRegistry = chainRegistry, + chain = chain, + address = address, + error = error + ) +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/ExistentialDepositValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/ExistentialDepositValidation.kt new file mode 100644 index 0000000..9cbf2b4 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/ExistentialDepositValidation.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrWarning +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.decimalAmountByExecutingAccount +import java.math.BigDecimal + +typealias ExistentialDepositError = (remainingAmount: BigDecimal, payload: P) -> E + +class ExistentialDepositValidation( + private val countableTowardsEdBalance: AmountProducer

, + private val feeProducer: FeeListProducer, + private val extraAmountProducer: AmountProducer

, + private val errorProducer: ExistentialDepositError, + private val existentialDeposit: AmountProducer

+) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val existentialDeposit = existentialDeposit(value) + + val countableTowardsEd = countableTowardsEdBalance(value) + val fee = feeProducer(value).sumOf { it.decimalAmountByExecutingAccount } + val extraAmount = extraAmountProducer(value) + + val remainingAmount = countableTowardsEd - fee - extraAmount + + return validOrWarning(remainingAmount >= existentialDeposit) { + errorProducer(remainingAmount, value) + } + } +} + +fun ValidationSystemBuilder.doNotCrossExistentialDepositMultiFee( + countableTowardsEdBalance: AmountProducer

, + fee: FeeListProducer = { emptyList() }, + extraAmount: AmountProducer

= { BigDecimal.ZERO }, + existentialDeposit: AmountProducer

, + error: ExistentialDepositError, +) = validate( + ExistentialDepositValidation( + countableTowardsEdBalance = countableTowardsEdBalance, + feeProducer = fee, + extraAmountProducer = extraAmount, + errorProducer = error, + existentialDeposit = existentialDeposit + ) +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/FeeChangeValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/FeeChangeValidation.kt new file mode 100644 index 0000000..85b2d37 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/FeeChangeValidation.kt @@ -0,0 +1,133 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer.Payload +import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer.Payload.DialogAction +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.hasTheSaveValueAs +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.data.model.decimalAmountByExecutingAccount +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.SetFee +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.math.BigDecimal + +private val FEE_RATIO_THRESHOLD = 1.5.toBigDecimal() + +class FeeChangeValidation( + private val calculateFee: FeeProducer, + private val currentFee: OptionalFeeProducer, + private val chainAsset: (P) -> Chain.Asset, + private val error: (FeeChangeDetectedFailure.Payload) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val oldFee = currentFee(value) + val newFee = calculateFee(value) + + val oldAmount = oldFee?.decimalAmountByExecutingAccount.orZero() + val newAmount = newFee.decimalAmountByExecutingAccount + + val areFeesSame = oldAmount hasTheSaveValueAs newAmount + + return areFeesSame isTrueOrError { + val payload = FeeChangeDetectedFailure.Payload( + needsUserAttention = newAmount / oldAmount > FEE_RATIO_THRESHOLD, + oldFee = oldAmount, + newFee = newFee, + chainAsset = chainAsset(value) + ) + + error(payload) + } + } +} + +interface FeeChangeDetectedFailure { + + class Payload( + val needsUserAttention: Boolean, + val oldFee: BigDecimal, + val newFee: F, + val chainAsset: Chain.Asset, + ) + + val payload: Payload +} + +fun ValidationSystemBuilder.checkForFeeChanges( + calculateFee: suspend (P) -> F, + currentFee: OptionalFeeProducer, + chainAsset: (P) -> Chain.Asset, + error: (FeeChangeDetectedFailure.Payload) -> E +) = validate( + FeeChangeValidation( + calculateFee = calculateFee, + currentFee = currentFee, + error = error, + chainAsset = chainAsset + ) +) + +fun CoroutineScope.handleFeeSpikeDetected( + error: FeeChangeDetectedFailure, + resourceManager: ResourceManager, + setFee: SetFee, + actions: ValidationFlowActions<*> +): TransformedFailure? = handleFeeSpikeDetected( + error = error, + resourceManager = resourceManager, + actions = actions, + setFee = { setFee.setFee(it.newFee) } +) + +fun CoroutineScope.handleFeeSpikeDetected( + error: FeeChangeDetectedFailure, + resourceManager: ResourceManager, + actions: ValidationFlowActions<*>, + setFee: suspend (error: FeeChangeDetectedFailure.Payload) -> Unit, +): TransformedFailure? { + if (!error.payload.needsUserAttention) { + actions.resumeFlow() + return null + } + + val chainAsset = error.payload.chainAsset + val oldFee = error.payload.oldFee.formatTokenAmount(chainAsset) + val newFee = error.payload.newFee.decimalAmountByExecutingAccount.formatTokenAmount(chainAsset) + + return TransformedFailure.Custom( + Payload( + title = resourceManager.getString(R.string.common_fee_changed_title), + message = resourceManager.getString(R.string.common_fee_changed_message, newFee, oldFee), + customStyle = R.style.AccentNegativeAlertDialogTheme_Reversed, + okAction = DialogAction( + title = resourceManager.getString(R.string.common_proceed), + action = { + launch { + setFee(error.payload) + actions.resumeFlow() + } + } + ), + cancelAction = DialogAction( + title = resourceManager.getString(R.string.common_refresh_fee), + action = { + launch { + setFee(error.payload) + } + } + ) + ) + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/HasEnoughBalanceValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/HasEnoughBalanceValidation.kt new file mode 100644 index 0000000..2db2749 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/HasEnoughBalanceValidation.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_account_api.data.model.decimalAmountByExecutingAccount +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +typealias HasEnoughFreeBalanceErrorProducer = (chainAsset: Chain.Asset, freeBalanceAfterFees: BigDecimal) -> E + +interface NotEnoughFreeBalanceError { + val chainAsset: Chain.Asset + val freeAfterFees: BigDecimal +} + +class HasEnoughBalanceValidation( + private val chainAsset: (P) -> Chain.Asset, + private val availableBalance: AmountProducer

, + private val requestedAmount: AmountProducer

, + private val error: HasEnoughFreeBalanceErrorProducer +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val balanceAfterFees = availableBalance(value) + + return (balanceAfterFees >= requestedAmount(value)) isTrueOrError { + error(chainAsset(value), balanceAfterFees.atLeastZero()) + } + } +} + +fun ValidationSystemBuilder.hasEnoughFreeBalance( + asset: (P) -> Asset, + fee: SimpleFeeProducer

, + requestedAmount: AmountProducer

, + error: HasEnoughFreeBalanceErrorProducer +) { + hasEnoughBalance( + chainAsset = { asset(it).token.configuration }, + requestedAmount = requestedAmount, + error = error, + availableBalance = { asset(it).free - fee(it)?.decimalAmountByExecutingAccount.orZero() } + ) +} + +fun ValidationSystemBuilder.hasEnoughBalance( + chainAsset: (P) -> Chain.Asset, + availableBalance: AmountProducer

, + requestedAmount: AmountProducer

, + error: HasEnoughFreeBalanceErrorProducer +) { + validate( + HasEnoughBalanceValidation( + chainAsset = chainAsset, + availableBalance = availableBalance, + requestedAmount = requestedAmount, + error = error + ) + ) +} + +fun handleNotEnoughFreeBalanceError( + error: NotEnoughFreeBalanceError, + resourceManager: ResourceManager, + @StringRes descriptionFormat: Int +): TitleAndMessage { + val feeAfterFees = error.freeAfterFees.formatTokenAmount(error.chainAsset) + + return resourceManager.getString(R.string.common_amount_too_big) to + resourceManager.getString(descriptionFormat, feeAfterFees) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/MultisigExtrinsicValidationFactory.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/MultisigExtrinsicValidationFactory.kt new file mode 100644 index 0000000..411acba --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/MultisigExtrinsicValidationFactory.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationBuilder + +interface MultisigExtrinsicValidationFactory { + + context(MultisigExtrinsicValidationBuilder) + fun multisigSignatoryHasEnoughBalance() + + context(MultisigExtrinsicValidationBuilder) + fun noPendingMultisigWithSameCallData() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/PhishingValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/PhishingValidation.kt new file mode 100644 index 0000000..3d5ded2 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/PhishingValidation.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.isFalseOrWarning +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class PhishingValidationFactory( + private val walletRepository: WalletRepository, +) { + + fun create( + address: (P) -> String, + chain: (P) -> Chain, + warning: (address: String) -> E + ) = PhishingValidation( + walletRepository = walletRepository, + address = address, + chain = chain, + waring = warning + ) +} + +class PhishingValidation( + private val walletRepository: WalletRepository, + private val address: (P) -> String, + private val chain: (P) -> Chain, + private val waring: (address: String) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val addressValue = address(value) + val accountId = chain(value).accountIdOf(addressValue) + + val isPhishingAddress = walletRepository.isAccountIdFromPhishingList(accountId) + + return isPhishingAddress isFalseOrWarning { waring(addressValue) } + } +} + +fun ValidationSystemBuilder.notPhishingAccount( + factory: PhishingValidationFactory, + address: (P) -> String, + chain: (P) -> Chain, + warning: (address: String) -> E +) = validate( + factory.create(address, chain, warning) +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/PositiveAmountValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/PositiveAmountValidation.kt new file mode 100644 index 0000000..f68f904 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/PositiveAmountValidation.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.validation.DefaultFailureLevel +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import java.math.BigDecimal + +class PositiveAmountValidation( + val amountExtractor: (P) -> BigDecimal, + val errorProvider: () -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + return if (amountExtractor(value) > BigDecimal.ZERO) { + ValidationStatus.Valid() + } else { + ValidationStatus.NotValid(DefaultFailureLevel.ERROR, errorProvider()) + } + } +} + +fun ValidationSystemBuilder.positiveAmount( + amount: (P) -> BigDecimal, + error: () -> E +) = validate( + PositiveAmountValidation( + amountExtractor = amount, + errorProvider = error + ) +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/Producers.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/Producers.kt new file mode 100644 index 0000000..093b887 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/Producers.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.feature_account_api.data.model.Fee +import java.math.BigDecimal +import java.math.BigInteger + +typealias AmountProducer

= suspend (P) -> BigDecimal + +typealias PlanksProducer

= suspend (P) -> BigInteger + +typealias SimpleFeeProducer

= OptionalFeeProducer + +typealias OptionalFeeProducer = suspend (P) -> F? + +typealias FeeProducer = suspend (P) -> F + +typealias FeeListProducer = suspend (P) -> List diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/ProxyHaveEnoughFeeValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/ProxyHaveEnoughFeeValidation.kt new file mode 100644 index 0000000..86e087c --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/ProxyHaveEnoughFeeValidation.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.domain.model.ProxiedMetaAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import javax.inject.Inject + +@FeatureScope +class ProxyHaveEnoughFeeValidationFactory @Inject constructor( + private val assetSourceRegistry: AssetSourceRegistry, + private val extrinsicService: ExtrinsicService +) { + fun create( + proxyAccountId: (P) -> AccountId, + proxiedMetaAccount: (P) -> ProxiedMetaAccount, + proxiedCall: (P) -> GenericCall.Instance, + chainWithAsset: (P) -> ChainWithAsset, + proxyNotEnoughFee: (payload: P, availableBalance: Balance, fee: Fee) -> E, + ): ProxyHaveEnoughFeeValidation { + return ProxyHaveEnoughFeeValidation( + assetSourceRegistry, + extrinsicService, + proxiedMetaAccount, + proxyAccountId, + proxiedCall, + chainWithAsset, + proxyNotEnoughFee + ) + } +} + +class ProxyHaveEnoughFeeValidation( + private val assetSourceRegistry: AssetSourceRegistry, + private val extrinsicService: ExtrinsicService, + private val proxiedMetaAccount: (P) -> ProxiedMetaAccount, + private val proxyAccountId: (P) -> AccountId, + private val proxiedCall: (P) -> GenericCall.Instance, + private val chainWithAsset: (P) -> ChainWithAsset, + private val proxyNotEnoughFee: (payload: P, availableBalance: Balance, fee: Fee) -> E, +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val chain = chainWithAsset(value).chain + val chainAsset = chainWithAsset(value).asset + val fee = calculateFee(proxiedMetaAccount(value), chain, proxiedCall(value)) + + val assetSource = assetSourceRegistry.sourceFor(chainAsset) + val assetBalanceSource = assetSource.balance + + val balance = assetBalanceSource.queryAccountBalance(chain, chainAsset, proxyAccountId(value)) + + val existentialDeposit = assetBalanceSource.existentialDeposit(chainAsset) + val balanceWithoutEd = (balance.countedTowardsEd - existentialDeposit).atLeastZero() + + return validOrError(balance.transferable >= fee.amount && balanceWithoutEd >= fee.amount) { + proxyNotEnoughFee(value, balance.transferable, fee) + } + } + + private suspend fun calculateFee( + proxiedMetaAccount: ProxiedMetaAccount, + chain: Chain, + proxiedCall: GenericCall.Instance + ): Fee { + return extrinsicService.estimateFee(chain, proxiedMetaAccount.intoOrigin()) { + call(proxiedCall) + } + } +} + +fun ValidationSystemBuilder.proxyHasEnoughFeeValidation( + factory: ProxyHaveEnoughFeeValidationFactory, + proxiedMetaAccount: (P) -> ProxiedMetaAccount, + proxyAccountId: (P) -> AccountId, + proxiedCall: (P) -> GenericCall.Instance, + chainWithAsset: (P) -> ChainWithAsset, + proxyNotEnoughFee: (payload: P, availableBalance: Balance, fee: Fee) -> E, +) = validate( + factory.create( + proxyAccountId = proxyAccountId, + proxiedMetaAccount = proxiedMetaAccount, + proxiedCall = proxiedCall, + chainWithAsset = chainWithAsset, + proxyNotEnoughFee = proxyNotEnoughFee + ) +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/SufficientBalanceConsideringConsumersValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/SufficientBalanceConsideringConsumersValidation.kt new file mode 100644 index 0000000..0646967 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/SufficientBalanceConsideringConsumersValidation.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.getExistentialDeposit +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class SufficientBalanceConsideringConsumersValidation( + private val assetsValidationContext: AssetsValidationContext, + private val assetExtractor: (P) -> Chain.Asset, + private val feeExtractor: (P) -> Balance, + private val amountExtractor: (P) -> Balance, + private val error: (P, existentialDeposit: Balance) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + val chainAsset = assetExtractor(value) + + val totalCanDropBelowMinimumBalance = assetsValidationContext.canTotalDropBelowEd(chainAsset) + if (totalCanDropBelowMinimumBalance) return valid() + + val balance = assetsValidationContext.getAsset(chainAsset).balanceCountedTowardsEDInPlanks + val amount = amountExtractor(value) + val fee = feeExtractor(value) + + val existentialDeposit = assetsValidationContext.getExistentialDeposit(chainAsset) + return validOrError(balance - existentialDeposit >= amount + fee) { + error(value, existentialDeposit) + } + } +} + +fun ValidationSystemBuilder.sufficientBalanceConsideringConsumersValidation( + assetsValidationContext: AssetsValidationContext, + assetExtractor: (P) -> Chain.Asset, + feeExtractor: (P) -> Balance, + amountExtractor: (P) -> Balance, + error: (P, existentialDeposit: Balance) -> E +) = validate( + SufficientBalanceConsideringConsumersValidation( + assetsValidationContext = assetsValidationContext, + assetExtractor = assetExtractor, + feeExtractor = feeExtractor, + amountExtractor = amountExtractor, + error = error + ) +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/TokenDecimalsValidation.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/TokenDecimalsValidation.kt new file mode 100644 index 0000000..78af5d3 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/TokenDecimalsValidation.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation + +import io.novafoundation.nova.common.validation.Validation +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.ValidationSystemBuilder +import io.novafoundation.nova.common.validation.validOrError + +class TokenDecimalsValidation( + private val decimals: (P) -> Int, + private val error: (P) -> E +) : Validation { + + override suspend fun validate(value: P): ValidationStatus { + return validOrError(decimals(value) in 0..36) { + error(value) + } + } +} + +fun ValidationSystemBuilder.validTokenDecimals( + decimals: (P) -> Int, + error: (P) -> E +) = validate( + TokenDecimalsValidation(decimals, error) +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/BalanceValidationResult.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/BalanceValidationResult.kt new file mode 100644 index 0000000..c28b969 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/BalanceValidationResult.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation.balance + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.validation.balance.ValidatingBalance.BalancePreservation + +sealed class BalanceValidationResult { + + class Success(val newBalance: ValidatingBalance) : BalanceValidationResult() + + class Failure(val negativeImbalance: NegativeImbalance, val newBalanceAfterFixingImbalance: ValidatingBalance) : BalanceValidationResult() +} + +fun ValidatingBalance.beginValidation(): BalanceValidationResult { + return BalanceValidationResult.Success(newBalance = this) +} + +fun BalanceValidationResult.tryWithdraw(amount: Balance, preservation: BalancePreservation): BalanceValidationResult { + return tryDeduct { balance -> balance.tryWithdraw(amount, preservation) } +} + +fun BalanceValidationResult.tryReserve(amount: Balance): BalanceValidationResult { + return tryDeduct { balance -> balance.tryReserve(amount) } +} + +fun BalanceValidationResult.tryFreeze(amount: Balance): BalanceValidationResult { + return tryDeduct { balance -> balance.tryFreeze(amount) } +} + +fun BalanceValidationResult.tryWithdrawFee(fee: FeeBase): BalanceValidationResult { + return tryWithdraw(fee.amount, BalancePreservation.KEEP_ALIVE) +} + +fun BalanceValidationResult.toValidationStatus(onError: (BalanceValidationResult.Failure) -> ValidationStatus): ValidationStatus { + return when (this) { + is BalanceValidationResult.Failure -> onError(this) + is BalanceValidationResult.Success -> valid() + } +} + +private fun BalanceValidationResult.tryDeduct(deduct: (ValidatingBalance) -> BalanceValidationResult): BalanceValidationResult { + return when (this) { + is BalanceValidationResult.Failure -> deduct(newBalanceAfterFixingImbalance).increaseImbalance(negativeImbalance) + is BalanceValidationResult.Success -> deduct(newBalance) + } +} + +private fun BalanceValidationResult.increaseImbalance(increase: NegativeImbalance): BalanceValidationResult { + return when (this) { + is BalanceValidationResult.Failure -> BalanceValidationResult.Failure( + newBalanceAfterFixingImbalance = newBalanceAfterFixingImbalance, + negativeImbalance = negativeImbalance + increase + ) + + is BalanceValidationResult.Success -> BalanceValidationResult.Failure( + newBalanceAfterFixingImbalance = newBalance, + negativeImbalance = increase + ) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/NegativeImbalance.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/NegativeImbalance.kt new file mode 100644 index 0000000..b3a8ae6 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/NegativeImbalance.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation.balance + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.hash.isPositive + +@JvmInline +value class NegativeImbalance private constructor(val value: Balance) : Comparable { + + init { + require(value.isPositive()) { + "Imbalance cannot be negative" + } + } + + companion object { + + /** + * @return How much should be added to [have] to match [want]. null in case there is no imbalance + */ + fun from(have: Balance, want: Balance): NegativeImbalance? { + return if (have >= want) { + null + } else { + NegativeImbalance((want - have)) + } + } + } + + operator fun plus(other: NegativeImbalance): NegativeImbalance { + return NegativeImbalance(value + other.value) + } + + override fun compareTo(other: NegativeImbalance): Int { + return value.compareTo(other.value) + } +} + +fun NegativeImbalance?.max(other: NegativeImbalance?): NegativeImbalance? { + return when { + this != null && other != null -> maxOf(this, other) + this != null -> this + other != null -> other + else -> null + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/ValidatingBalance.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/ValidatingBalance.kt new file mode 100644 index 0000000..1d44467 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/ValidatingBalance.kt @@ -0,0 +1,165 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation.balance + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ensureMeetsEdOrDust +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +data class ValidatingBalance( + val assetBalance: ChainAssetBalance, + val existentialDeposit: Balance, +) { + + fun tryReserve(amount: Balance): BalanceValidationResult { + return safeDeduct(checkCanReserve(amount)) { balance -> + balance.copy( + free = balance.free - amount, + reserved = balance.reserved + amount + ) + } + } + + fun tryFreeze(amount: Balance): BalanceValidationResult { + return safeDeduct(checkCanFreeze(amount)) { balance -> + balance.copy( + frozen = balance.frozen.max(amount) + ) + } + } + + fun tryWithdraw( + amount: Balance, + preservation: BalancePreservation + ): BalanceValidationResult { + return safeDeduct(checkCanWithdraw(amount, preservation)) { balance -> + balance.copy( + free = balance.free - amount + ) + } + } + + fun legacyAdapter(): ValidatingBalance { + return copy(assetBalance = assetBalance.legacyAdapter()) + } + + private fun safeDeduct( + checkResult: BalanceCheckResult, + unsafeDeduct: (ChainAssetBalance) -> ChainAssetBalance + ): BalanceValidationResult { + return when (checkResult) { + is BalanceCheckResult.NotEnoughBalance -> { + BalanceValidationResult.Failure( + checkResult.negativeImbalance, + checkResult.newBalanceAfterFixingImbalance + ) + } + + BalanceCheckResult.Ok -> { + val newBalance = unsafeDeduct(assetBalance).ensureMeetsEdOrDust() + BalanceValidationResult.Success(copy(assetBalance = newBalance)) + } + } + } + + enum class BalancePreservation { + /** + * We do not want account's balance to become lower than ED + */ + KEEP_ALIVE, + + /** + * We do not care about account's balance becoming lower than ED + */ + ALLOW_DEATH + } + + private fun checkCanReserve(amount: Balance): BalanceCheckResult { + val imbalance = NegativeImbalance.from( + have = assetBalance.reservable(existentialDeposit), + want = amount + ) + + return createBalanceCheckResult( + imbalance = imbalance, + deductOnResolvedImbalance = { balance -> balance.tryReserve(amount) } + ) + } + + private fun checkCanWithdraw( + amount: Balance, + preservation: BalancePreservation + ): BalanceCheckResult { + val transferableImbalance = NegativeImbalance.from( + have = assetBalance.transferable, + want = amount + ) + + val countedTowardsEdImbalance = when (preservation) { + BalancePreservation.KEEP_ALIVE -> { + NegativeImbalance.from( + have = assetBalance.countedTowardsEd, + want = existentialDeposit + amount + ) + } + + BalancePreservation.ALLOW_DEATH -> null + } + + val totalImbalance = transferableImbalance.max(countedTowardsEdImbalance) + + return createBalanceCheckResult( + imbalance = totalImbalance, + deductOnResolvedImbalance = { balance -> balance.tryWithdraw(amount, preservation) } + ) + } + + private fun checkCanFreeze(amount: Balance): BalanceCheckResult { + val imbalance = NegativeImbalance.from( + have = assetBalance.total, + want = amount + ) + + return createBalanceCheckResult( + imbalance = imbalance, + deductOnResolvedImbalance = { balance -> balance.tryFreeze(amount) } + ) + } + + private fun ChainAssetBalance.ensureMeetsEdOrDust(): ChainAssetBalance { + return ensureMeetsEdOrDust(existentialDeposit) + } + + private fun createBalanceCheckResult( + imbalance: NegativeImbalance?, + deductOnResolvedImbalance: (ValidatingBalance) -> BalanceValidationResult + ): BalanceCheckResult { + return if (imbalance != null) { + val withImbalanceResolved = copy( + assetBalance = assetBalance.copy( + free = assetBalance.free + imbalance.value + ) + ) + + val newBalanceAfterImbalanceResolved = deductOnResolvedImbalance(withImbalanceResolved) + require(newBalanceAfterImbalanceResolved is BalanceValidationResult.Success) { + "Calculated imbalance was not enough to result in successfully execution" + } + + BalanceCheckResult.NotEnoughBalance( + imbalance, + newBalanceAfterImbalanceResolved.newBalance + ) + } else { + BalanceCheckResult.Ok + } + } + + private sealed class BalanceCheckResult { + + data object Ok : BalanceCheckResult() + + class NotEnoughBalance( + val negativeImbalance: NegativeImbalance, + val newBalanceAfterFixingImbalance: ValidatingBalance + ) : BalanceCheckResult() + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/context/AssetsValidationContext.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/context/AssetsValidationContext.kt new file mode 100644 index 0000000..b97ad01 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/domain/validation/context/AssetsValidationContext.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation.context + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +/** + * Abstraction over asset-related lookups that can be used across multiple validations in the same validation system + * When constructing ValidationSystem, create an instance via [AssetsValidationContext.Factory] and pass as the dependency to the necessary validation + * + * Implementation may cache certain results to avoid duplicated network or db lookups + */ +interface AssetsValidationContext { + + interface Factory { + + fun create(): AssetsValidationContext + } + + suspend fun getAsset(chainAsset: Chain.Asset): Asset + + suspend fun getAsset(chainAssetId: FullChainAssetId): Asset + + suspend fun getExistentialDeposit(chainAssetId: FullChainAssetId): Balance + + suspend fun isAssetSufficient(chainAsset: Chain.Asset): Boolean + + suspend fun canTotalDropBelowEd(chainAsset: Chain.Asset): Boolean +} + +suspend fun AssetsValidationContext.getExistentialDeposit(chainAsset: Chain.Asset): Balance { + return getExistentialDeposit(chainAsset.fullId) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/common/MinAmountProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/common/MinAmountProvider.kt new file mode 100644 index 0000000..cd74db3 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/common/MinAmountProvider.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.common + +import java.math.BigDecimal +import kotlinx.coroutines.flow.Flow + +interface MinAmountProvider { + fun provideMinAmount(): Flow +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/common/fieldValidator/EnoughAmountValidatorFactory.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/common/fieldValidator/EnoughAmountValidatorFactory.kt new file mode 100644 index 0000000..0ad6d3d --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/common/fieldValidator/EnoughAmountValidatorFactory.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator + +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider + +interface EnoughAmountValidatorFactory { + fun create( + maxAvailableProvider: MaxActionProvider, + errorMessageRes: Int = R.string.common_error_not_enough_tokens + ): EnoughAmountFieldValidator +} + +interface EnoughAmountFieldValidator : FieldValidator { + companion object { + + const val ERROR_TAG = "EnoughAmountFieldValidator" + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/common/fieldValidator/MinAmountFieldValidatorFactory.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/common/fieldValidator/MinAmountFieldValidatorFactory.kt new file mode 100644 index 0000000..1bcb489 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/common/fieldValidator/MinAmountFieldValidatorFactory.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator + +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.feature_wallet_api.presentation.common.MinAmountProvider +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +interface MinAmountFieldValidatorFactory { + fun create( + chainAsset: Flow, + minAmountProvider: MinAmountProvider, + errorMessageRes: Int + ): MinAmountFieldValidator +} + +interface MinAmountFieldValidator : FieldValidator { + companion object { + + const val ERROR_TAG = "MinAmountValidator" + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/AssetModelFormatter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/AssetModelFormatter.kt new file mode 100644 index 0000000..11ee498 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/AssetModelFormatter.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.formatters + +import androidx.annotation.StringRes +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface AssetModelFormatter { + suspend fun formatAsset( + chainAsset: Chain.Asset, + balance: MaskableModel, + @StringRes patternId: Int? = R.string.common_available_format + ): AssetModel +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/BalanceIdMapper.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/BalanceIdMapper.kt new file mode 100644 index 0000000..2defe8e --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/BalanceIdMapper.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.formatters + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.capitalize +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceBreakdownIds +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance + +fun mapBalanceIdToUi(resourceManager: ResourceManager, id: String): String { + return when (id.trim()) { + "staking" -> resourceManager.getString(R.string.assets_balance_details_locks_staking) + "democrac" -> resourceManager.getString(R.string.assets_balance_details_locks_democrac_v1) + "pyconvot" -> resourceManager.getString(R.string.assets_balance_details_locks_democrac_v2) + "vesting" -> resourceManager.getString(R.string.assets_balance_details_locks_vesting) + "phrelect" -> resourceManager.getString(R.string.assets_balance_details_locks_phrelect) + BalanceBreakdownIds.RESERVED -> resourceManager.getString(R.string.wallet_balance_reserved) + BalanceBreakdownIds.CROWDLOAN -> resourceManager.getString(R.string.assets_balance_details_locks_crowdloans) + BalanceBreakdownIds.NOMINATION_POOL, + BalanceBreakdownIds.NOMINATION_POOL_DELEGATED -> resourceManager.getString(R.string.setup_staking_type_pool_staking) + else -> id.capitalize() + } +} + +val ExternalBalance.Type.balanceId: String + get() = when (this) { + ExternalBalance.Type.CROWDLOAN -> BalanceBreakdownIds.CROWDLOAN + ExternalBalance.Type.NOMINATION_POOL -> BalanceBreakdownIds.NOMINATION_POOL + } diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/FiatAmount.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/FiatAmount.kt new file mode 100644 index 0000000..cfe2b1b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/FiatAmount.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.formatters + +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_wallet_api.domain.model.FiatAmount + +fun FiatAmount.formatAsCurrency() = price.formatAsCurrency(currency) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/Formatters.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/Formatters.kt new file mode 100644 index 0000000..6f6ae69 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/Formatters.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.formatters + +import io.novafoundation.nova.common.utils.SemiUnboundedRange +import io.novafoundation.nova.common.utils.asTokenSymbol +import io.novafoundation.nova.common.utils.formatTokenAmount +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.withTokenSymbol +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +@Deprecated("Use TokenFormatter instead") +fun BigInteger.formatPlanks(chainAsset: Chain.Asset, roundingMode: RoundingMode = RoundingMode.FLOOR): String { + return chainAsset.amountFromPlanks(this).formatTokenAmount(chainAsset, roundingMode) +} + +@Deprecated("Use TokenFormatter instead") +fun ChainAssetWithAmount.formatPlanks(roundingMode: RoundingMode = RoundingMode.FLOOR): String { + return amount.formatPlanks(chainAsset, roundingMode) +} + +@Deprecated("Use TokenFormatter instead") +fun SemiUnboundedRange.formatPlanksRange(chainAsset: Chain.Asset): String { + val end = endInclusive + val startFormatted = chainAsset.amountFromPlanks(start).format() + + return if (end != null) { + val endFormatted = end.formatPlanks(chainAsset) + + "$startFormatted — $endFormatted" + } else { + "$startFormatted+".withTokenSymbol(chainAsset.symbol) + } +} + +@Deprecated("Use TokenFormatter instead") +fun BigDecimal.formatTokenAmount( + chainAsset: Chain.Asset, + roundingMode: RoundingMode = RoundingMode.FLOOR +): String { + return formatTokenAmount(chainAsset.symbol, roundingMode) +} + +@Deprecated("Use TokenFormatter instead") +fun BigDecimal.formatTokenAmount( + tokenSymbol: String, + roundingMode: RoundingMode = RoundingMode.FLOOR +): String { + return formatTokenAmount( + tokenSymbol.asTokenSymbol(), + roundingMode + ) +} + +@Deprecated("Use TokenFormatter instead") +fun BigDecimal.formatTokenChange(chainAsset: Chain.Asset, isIncome: Boolean): String { + val withoutSign = formatTokenAmount(chainAsset) + val sign = if (isIncome) '+' else '-' + + return sign + withoutSign +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/AmountFormatter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/AmountFormatter.kt new file mode 100644 index 0000000..d43bb69 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/AmountFormatter.kt @@ -0,0 +1,115 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount + +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.PricedAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.TokenBase +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel +import java.math.BigDecimal +import java.math.BigInteger + +interface AmountFormatter { + + fun formatAmountToAmountModel( + pricedAmount: PricedAmount, + token: TokenSymbol, + config: AmountConfig = AmountConfig() + ): AmountModel +} + +class RealAmountFormatter( + private val tokenFormatter: TokenFormatter, + private val fiatFormatter: FiatFormatter +) : AmountFormatter { + + override fun formatAmountToAmountModel( + pricedAmount: PricedAmount, + token: TokenSymbol, + config: AmountConfig + ): AmountModel { + return AmountModel( + token = tokenFormatter.formatToken(pricedAmount.amount, token, config.tokenConfig), + fiat = formatFiat(pricedAmount.price, pricedAmount.currency, config) + ) + } + + private fun formatFiat( + fiatAmount: BigDecimal, + currency: Currency, + config: AmountConfig + ): CharSequence? { + if (fiatAmount == BigDecimal.ZERO && !config.includeZeroFiat) return null + + return fiatFormatter.formatFiat(fiatAmount, currency, config.fiatConfig) + } +} + +fun AmountFormatter.formatAmountToAmountModel( + amountInPlanks: BigInteger, + asset: Asset, + config: AmountConfig = AmountConfig() +): AmountModel { + val amount = asset.token.amountFromPlanks(amountInPlanks) + return formatAmountToAmountModel( + pricedAmount = PricedAmount( + amount = amount, + price = asset.token.amountToFiat(amount), + currency = asset.token.currency + ), + token = asset.token.configuration.symbol, + config = config + ) +} + +fun AmountFormatter.formatAmountToAmountModel( + amountInPlanks: BigInteger, + token: TokenBase, + config: AmountConfig = AmountConfig() +): AmountModel { + val amount = token.amountFromPlanks(amountInPlanks) + return formatAmountToAmountModel( + pricedAmount = PricedAmount( + amount = amount, + price = token.amountToFiat(amount), + currency = token.currency + ), + token = token.configuration.symbol, + config = config + ) +} + +fun AmountFormatter.formatAmountToAmountModel( + amount: BigDecimal, + asset: Asset, + config: AmountConfig = AmountConfig() +) = formatAmountToAmountModel( + pricedAmount = PricedAmount( + amount = amount, + price = asset.token.amountToFiat(amount), + currency = asset.token.currency + ), + token = asset.token.configuration.symbol, + config = config +) + +fun AmountFormatter.formatAmountToAmountModel( + amount: BigDecimal, + token: TokenBase, + config: AmountConfig = AmountConfig() +) = formatAmountToAmountModel( + pricedAmount = PricedAmount( + amount = amount, + price = token.amountToFiat(amount), + currency = token.currency + ), + token = token.configuration.symbol, + config = config +) + +fun Asset.transferableAmountModel(amountFormatter: AmountFormatter) = amountFormatter.formatAmountToAmountModel(transferable, this) + +fun transferableAmountModelOf(amountFormatter: AmountFormatter, asset: Asset) = + amountFormatter.formatAmountToAmountModel(asset.transferable, asset) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/FiatFormatter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/FiatFormatter.kt new file mode 100644 index 0000000..c7c2c58 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/FiatFormatter.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount + +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrencyNoAbbreviation +import io.novafoundation.nova.feature_currency_api.presentation.formatters.simpleFormatAsCurrency +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.FiatConfig +import java.math.BigDecimal + +interface FiatFormatter { + + fun formatFiat(fiatAmount: BigDecimal, currency: Currency, config: FiatConfig = FiatConfig()): CharSequence +} + +class RealFiatFormatter( + private val fractionStylingFormatter: FractionStylingFormatter +) : FiatFormatter { + + override fun formatFiat(fiatAmount: BigDecimal, currency: Currency, config: FiatConfig): CharSequence { + var formattedFiat = when (config.abbreviationStyle) { + FiatConfig.AbbreviationStyle.DEFAULT_ABBREVIATION -> fiatAmount.formatAsCurrency(currency, config.roundingMode) + FiatConfig.AbbreviationStyle.NO_ABBREVIATION -> fiatAmount.formatAsCurrencyNoAbbreviation(currency) + FiatConfig.AbbreviationStyle.SIMPLE_ABBREVIATION -> fiatAmount.simpleFormatAsCurrency(currency, config.roundingMode) + } + + if (config.estimatedFiat) { + formattedFiat = "~$formattedFiat" + } + + return formattedFiat.applyFractionStyling(fractionStylingFormatter, config.fractionPartStyling) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/FractionStylingFormatter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/FractionStylingFormatter.kt new file mode 100644 index 0000000..aece1ab --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/FractionStylingFormatter.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount + +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.AbsoluteSizeSpan +import android.text.style.ForegroundColorSpan +import androidx.annotation.ColorRes +import androidx.annotation.DimenRes +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.formatting.toAmountWithFraction +import io.novafoundation.nova.feature_wallet_api.presentation.model.FractionPartStyling + +interface FractionStylingFormatter { + + fun formatFraction( + unformattedAmount: CharSequence, + @DimenRes floatAmountSize: Int, + @ColorRes textColorRes: Int + ): CharSequence +} + +class RealFractionStylingFormatter( + private val resourceManager: ResourceManager +) : FractionStylingFormatter { + + override fun formatFraction( + unformattedAmount: CharSequence, + @DimenRes floatAmountSize: Int, + @ColorRes textColorRes: Int + ): CharSequence { + val amountWithFraction = unformattedAmount.toAmountWithFraction() + + val textColor = resourceManager.getColor(textColorRes) + val colorSpan = ForegroundColorSpan(textColor) + val sizeSpan = AbsoluteSizeSpan(resourceManager.getDimensionPixelSize(floatAmountSize)) + + return with(amountWithFraction) { + val decimalAmount = amountWithFraction.amount + + val spannableBuilder = SpannableStringBuilder() + .append(decimalAmount) + if (fraction != null) { + spannableBuilder.append(separator + fraction) + val startIndex = decimalAmount.length + val endIndex = decimalAmount.length + separator.length + fraction!!.length + spannableBuilder.setSpan(colorSpan, startIndex, endIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + spannableBuilder.setSpan(sizeSpan, startIndex, endIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + spannableBuilder + } + } +} + +fun CharSequence.applyFractionStyling(fractionStylingFormatter: FractionStylingFormatter, fractionPartStyling: FractionPartStyling): CharSequence { + return when (fractionPartStyling) { + FractionPartStyling.NoStyle -> this + is FractionPartStyling.Styled -> fractionStylingFormatter.formatFraction(this, fractionPartStyling.sizeRes, fractionPartStyling.colorRes) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/TokenFormatter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/TokenFormatter.kt new file mode 100644 index 0000000..41f2ef5 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/TokenFormatter.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount + +import androidx.core.text.buildSpannedString +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.common.utils.formatTokenAmount +import io.novafoundation.nova.common.utils.formatting.format +import io.novafoundation.nova.common.utils.formatting.formatWithFullAmount +import io.novafoundation.nova.common.utils.withTokenSymbol +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.TokenConfig +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal +import java.math.BigInteger + +interface TokenFormatter { + + fun formatToken( + amount: BigDecimal, + token: TokenSymbol, + config: TokenConfig = TokenConfig() + ): CharSequence +} + +class RealTokenFormatter( + private val fractionStylingFormatter: FractionStylingFormatter +) : TokenFormatter { + + override fun formatToken( + amount: BigDecimal, + token: TokenSymbol, + config: TokenConfig + ): CharSequence { + return buildSpannedString { + append(config.tokenAmountSign.signSymbol) + append(formatAmount(amount, token, config)) + } + } + + private fun formatAmount( + amount: BigDecimal, + token: TokenSymbol, + config: TokenConfig + ): CharSequence { + val unsignedTokenAmount = if (config.useAbbreviation) { + if (config.includeAssetTicker) { + amount.formatTokenAmount(token, config.roundingMode) + } else { + amount.format(config.roundingMode) + } + } else { + val unformattedAmount = amount.formatWithFullAmount() + + if (config.includeAssetTicker) { + unformattedAmount.withTokenSymbol(token) + } else { + unformattedAmount + } + } + + return unsignedTokenAmount.applyFractionStyling(fractionStylingFormatter, config.fractionPartStyling) + } +} + +fun TokenFormatter.formatToken(amountInPlanks: BigInteger, asset: Asset, config: TokenConfig = TokenConfig()): CharSequence { + return formatToken(asset.token.amountFromPlanks(amountInPlanks), asset.token.configuration.symbol, config) +} + +fun TokenFormatter.formatToken(amountInPlanks: BigInteger, chainAsset: Chain.Asset, config: TokenConfig = TokenConfig()): CharSequence { + return formatToken(chainAsset.amountFromPlanks(amountInPlanks), chainAsset.symbol, config) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/model/Configs.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/model/Configs.kt new file mode 100644 index 0000000..cf15777 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/formatters/amount/model/Configs.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model + +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.FiatConfig.AbbreviationStyle +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountSign +import io.novafoundation.nova.feature_wallet_api.presentation.model.FractionPartStyling +import java.math.RoundingMode + +private const val INCLUDE_ZERO_FIAT = true +private const val INCLUDE_ASSET_TICKER: Boolean = true +private const val USE_TOKEN_ABBREVIATION: Boolean = true +private const val ESTIMATED_FIAT: Boolean = false +private val FIAT_ABBREVIATION: AbbreviationStyle = AbbreviationStyle.DEFAULT_ABBREVIATION +private val TOKEN_AMOUNT_SIGN: AmountSign = AmountSign.NONE +private val ROUNDING_MODE: RoundingMode = RoundingMode.FLOOR +private val TOKEN_FRACTION_STYLING_SIZE: FractionPartStyling = FractionPartStyling.NoStyle + +class FiatConfig( + val roundingMode: RoundingMode = ROUNDING_MODE, + val abbreviationStyle: AbbreviationStyle = FIAT_ABBREVIATION, + val estimatedFiat: Boolean = ESTIMATED_FIAT, + val fractionPartStyling: FractionPartStyling = TOKEN_FRACTION_STYLING_SIZE +) { + enum class AbbreviationStyle { + DEFAULT_ABBREVIATION, NO_ABBREVIATION, SIMPLE_ABBREVIATION + } +} + +class TokenConfig( + val includeAssetTicker: Boolean = INCLUDE_ASSET_TICKER, + val useAbbreviation: Boolean = USE_TOKEN_ABBREVIATION, + val tokenAmountSign: AmountSign = TOKEN_AMOUNT_SIGN, + val roundingMode: RoundingMode = ROUNDING_MODE, + val fractionPartStyling: FractionPartStyling = TOKEN_FRACTION_STYLING_SIZE +) + +class AmountConfig( + val includeZeroFiat: Boolean = INCLUDE_ZERO_FIAT, + val tokenConfig: TokenConfig = TokenConfig(), + val fiatConfig: FiatConfig = FiatConfig() +) { + + constructor( + includeZeroFiat: Boolean = INCLUDE_ZERO_FIAT, + includeAssetTicker: Boolean = INCLUDE_ASSET_TICKER, + useTokenAbbreviation: Boolean = USE_TOKEN_ABBREVIATION, + fiatAbbreviation: AbbreviationStyle = FIAT_ABBREVIATION, + tokenAmountSign: AmountSign = TOKEN_AMOUNT_SIGN, + roundingMode: RoundingMode = ROUNDING_MODE, + estimatedFiat: Boolean = ESTIMATED_FIAT, + tokenFractionPartStyling: FractionPartStyling = TOKEN_FRACTION_STYLING_SIZE, + fiatFractionPartStyling: FractionPartStyling = TOKEN_FRACTION_STYLING_SIZE, + ) : this( + includeZeroFiat, + TokenConfig( + includeAssetTicker = includeAssetTicker, + useAbbreviation = useTokenAbbreviation, + tokenAmountSign = tokenAmountSign, + roundingMode = roundingMode, + fractionPartStyling = tokenFractionPartStyling + ), + FiatConfig( + roundingMode = roundingMode, + abbreviationStyle = fiatAbbreviation, + estimatedFiat = estimatedFiat, + fractionPartStyling = fiatFractionPartStyling, + ), + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/AmountChooserMixin.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/AmountChooserMixin.kt new file mode 100644 index 0000000..57bf95f --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/AmountChooserMixin.kt @@ -0,0 +1,138 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.formatting.toStripTrailingZerosString +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase.InputState +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase.InputState.InputKind +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider +import io.novafoundation.nova.feature_wallet_api.presentation.model.ChooseAmountModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import java.math.BigDecimal + +typealias MaxClick = () -> Unit + +interface AmountChooserMixinBase : CoroutineScope { + + val fiatAmount: Flow + + val inputState: MutableStateFlow> + + @Deprecated( + message = "Use `inputState` instead", + replaceWith = ReplaceWith( + expression = "inputState.map { it.value }", + imports = ["kotlinx.coroutines.flow.map"] + ) + ) + val amountInput: StateFlow + + val fieldError: Flow + + val maxAction: MaxAction + + val requestFocusLiveData: MutableLiveData> + + interface Presentation : AmountChooserMixinBase { + + val amount: Flow + + val amountState: Flow> + + val backPressuredAmountState: Flow> + + val backPressuredPlanks: Flow + } + + interface FiatFormatter { + + fun formatFlow(tokenFlow: Flow, amountFlow: Flow): Flow + } + + data class InputState( + val value: T, + // TODO revisit this flag. Seems to only be used in SwapMainSettingsViewModel and its purpose and semantics looks unclear + val initiatedByUser: Boolean, + val inputKind: InputKind + ) { + + enum class InputKind { + REGULAR, MAX_ACTION, BLOCKED + } + } + + interface MaxAction { + + val display: Flow + + val maxClick: Flow + } +} + +interface AmountChooserMixin : AmountChooserMixinBase { + + val usedAssetFlow: Flow + + val assetModel: Flow + + interface Presentation : AmountChooserMixin, AmountChooserMixinBase.Presentation + + interface Factory { + + fun create( + scope: CoroutineScope, + assetFlow: Flow, + maxActionProvider: MaxActionProvider?, + fieldValidator: FieldValidator? = null + ): Presentation + } +} + +fun AmountChooserMixinBase.Presentation.setAmount(amount: BigDecimal, initiatedByUser: Boolean = false) { + inputState.value = InputState(value = amount.toStripTrailingZerosString(), initiatedByUser, inputKind = InputKind.REGULAR) +} + +fun AmountChooserMixinBase.Presentation.setAmountInput(amountInput: String, initiatedByUser: Boolean = false) { + inputState.value = InputState(value = amountInput, initiatedByUser, inputKind = InputKind.REGULAR) +} + +fun AmountChooserMixinBase.Presentation.setInputBlocked() { + inputState.value = inputState.value.copy(inputKind = InputKind.BLOCKED) +} + +fun AmountChooserMixinBase.Presentation.setBlockedAmount(amount: BigDecimal) { + inputState.value = InputState(value = amount.toStripTrailingZerosString(), initiatedByUser = false, inputKind = InputKind.BLOCKED) +} + +suspend fun AmountChooserMixinBase.Presentation.invokeMaxClick() { + maxAction.maxClick.first()?.invoke() +} + +fun InputKind.isInputBlocked(): Boolean { + return this == InputKind.BLOCKED +} + +fun InputKind.isInputAllowed(): Boolean { + return !isInputBlocked() +} + +fun InputKind.isMaxAction(): Boolean { + return this == InputKind.MAX_ACTION +} + +fun InputState.map(transform: (T) -> R): InputState { + return InputState( + value = transform(value), + inputKind = inputKind, + initiatedByUser = initiatedByUser + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/AmountChooserProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/AmountChooserProvider.kt new file mode 100644 index 0000000..13f021b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/AmountChooserProvider.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser + +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider +import io.novafoundation.nova.feature_wallet_api.presentation.model.ChooseAmountModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class AmountChooserProviderFactory( + private val assetIconProvider: AssetIconProvider, +) : AmountChooserMixin.Factory { + + override fun create( + scope: CoroutineScope, + assetFlow: Flow, + maxActionProvider: MaxActionProvider?, + fieldValidator: FieldValidator? + ): AmountChooserProvider { + return AmountChooserProvider( + coroutineScope = scope, + usedAssetFlow = assetFlow, + assetIconProvider = assetIconProvider, + maxActionProvider = maxActionProvider, + fieldValidator = fieldValidator + ) + } +} + +class AmountChooserProvider( + coroutineScope: CoroutineScope, + override val usedAssetFlow: Flow, + private val assetIconProvider: AssetIconProvider, + maxActionProvider: MaxActionProvider?, + fieldValidator: FieldValidator? +) : BaseAmountChooserProvider( + coroutineScope = coroutineScope, + tokenFlow = usedAssetFlow.map { it.token }, + maxActionProvider = maxActionProvider, + fieldValidator = fieldValidator +), + AmountChooserMixin.Presentation { + + override val assetModel = usedAssetFlow.map { asset -> + ChooseAmountModel(asset, assetIconProvider) + } + .shareInBackground() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/AmountChooserUi.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/AmountChooserUi.kt new file mode 100644 index 0000000..66750f0 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/AmountChooserUi.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser + +import android.view.View.OnClickListener +import android.widget.EditText +import androidx.lifecycle.lifecycleScope +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.utils.bindTo +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase.InputState +import io.novafoundation.nova.feature_wallet_api.presentation.view.amount.ChooseAmountView +import io.novafoundation.nova.feature_wallet_api.presentation.view.amount.setChooseAmountModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow + +interface AmountInputView { + + val amountInput: EditText + + fun setFiatAmount(fiat: CharSequence?) + + fun setError(errorState: FieldValidationResult) + + fun setEnabled(enabled: Boolean) +} + +interface MaxAvailableView { + + fun setMaxAmountDisplay(maxAmountDisplay: String?) + + fun setMaxActionAvailability(availability: MaxActionAvailability) +} + +sealed class MaxActionAvailability { + + class Available(val onMaxClicked: OnClickListener) : MaxActionAvailability() + + object NotAvailable : MaxActionAvailability() +} + +fun BaseFragment<*, *>.setupAmountChooser( + mixin: AmountChooserMixin, + amountView: ChooseAmountView, +) { + setupAmountChooserBase(mixin, amountView) + + mixin.assetModel.observe(amountView::setChooseAmountModel) +} + +fun BaseFragment<*, *>.setupAmountChooserBase( + mixin: AmountChooserMixinBase, + view: T, +) where T : AmountInputView, T : MaxAvailableView { + setupAmountChooserBase(mixin, view, view) +} + +fun BaseFragment<*, *>.setupAmountChooserBase( + mixin: AmountChooserMixinBase, + amountInputView: AmountInputView, + maxAvailableView: MaxAvailableView? +) { + bindInputStateToField(amountInputView, mixin.inputState, lifecycleScope) + mixin.fiatAmount.observe(amountInputView::setFiatAmount) + mixin.fieldError.observe(amountInputView::setError) + + mixin.requestFocusLiveData.observeEvent { + amountInputView.amountInput.requestFocus() + } + + if (maxAvailableView == null) return + + mixin.maxAction.display.observe(maxAvailableView::setMaxAmountDisplay) + mixin.maxAction.maxClick.observe { maxClick -> + val maxActionAvailability = if (maxClick != null) { + MaxActionAvailability.Available { + amountInputView.amountInput.requestFocus() + + maxClick() + } + } else { + MaxActionAvailability.NotAvailable + } + maxAvailableView.setMaxActionAvailability(maxActionAvailability) + } +} + +private fun BaseFragment<*, *>.bindInputStateToField( + amountInputView: AmountInputView, + flow: MutableSharedFlow>, + scope: CoroutineScope +) { + amountInputView.amountInput.bindTo(flow, scope, toT = { InputState(it, initiatedByUser = true, InputState.InputKind.REGULAR) }, fromT = { it.value }) + + flow.observe { amountInputView.setEnabled(it.inputKind.isInputAllowed()) } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/BaseAmountChooserProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/BaseAmountChooserProvider.kt new file mode 100644 index 0000000..af4ed0e --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/BaseAmountChooserProvider.kt @@ -0,0 +1,213 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.firstNotNull +import io.novafoundation.nova.common.utils.formatting.toStripTrailingZerosString +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.mapNullable +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.validation.FieldValidator +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase.InputState +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase.InputState.InputKind +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxAvailableBalance +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.actualAmount +import io.novafoundation.nova.runtime.ext.fullId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.math.BigDecimal +import kotlin.time.Duration.Companion.milliseconds + +private val DEBOUNCE_DURATION = 500.milliseconds + +@OptIn(FlowPreview::class) +@Suppress("LeakingThis") +open class BaseAmountChooserProvider( + coroutineScope: CoroutineScope, + tokenFlow: Flow, + private val maxActionProvider: MaxActionProvider?, + fiatFormatter: AmountChooserMixinBase.FiatFormatter = DefaultFiatFormatter(), + private val fieldValidator: FieldValidator? = null, +) : AmountChooserMixinBase.Presentation, + CoroutineScope by coroutineScope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(coroutineScope) { + + final override val inputState = MutableStateFlow(defaultState()) + + @Deprecated( + message = "Use `inputState` instead", + replaceWith = ReplaceWith( + expression = "inputState.map { it.value }", + imports = ["kotlinx.coroutines.flow.map"] + ) + ) + final override val amountInput = inputState.map { it.value } + .stateIn(this, SharingStarted.Eagerly, initialValue = "") + + @Suppress("DEPRECATION") + override val fieldError: Flow = fieldValidator?.observe(amountInput) + ?: flowOf(FieldValidationResult.Ok) + + final override val amountState: Flow> = inputState + .map { inputState -> + inputState.map { input -> input.parseBigDecimalOrNull() } + }.share() + + private val _amountStateOrZero: Flow> = amountState.map { inputState -> + inputState.map { it.orZero() } + }.share() + + private val _amount: Flow = _amountStateOrZero + .map { it.value } + .share() + + @Deprecated("Use amountState instead") + override val amount: Flow = _amount + + override val fiatAmount: Flow = fiatFormatter.formatFlow(tokenFlow.filterNotNull(), _amount) + .shareInBackground() + + override val backPressuredAmountState: Flow> + get() = _amountStateOrZero.debounce(DEBOUNCE_DURATION) + + private val chainAssetFlow = tokenFlow.filterNotNull() + .map { it.configuration } + .distinctUntilChangedBy { it.fullId } + .shareInBackground() + + override val backPressuredPlanks: Flow = combine(chainAssetFlow, _amount) { chainAsset, amount -> + chainAsset.planksFromAmount(amount) + } + .debounce(DEBOUNCE_DURATION) + + override val maxAction: AmountChooserMixinBase.MaxAction = RealMaxAction() + + override val requestFocusLiveData: MutableLiveData> = MutableLiveData() + + private fun String.parseBigDecimalOrNull(): BigDecimal? { + if (isEmpty()) return null + + return replace(",", "").toBigDecimalOrNull() + } + + private fun defaultState(): InputState = InputState(value = "", initiatedByUser = true, inputKind = InputKind.REGULAR) + + private fun InputState.map(valueTransform: (T) -> R): InputState { + return InputState(valueTransform(value), initiatedByUser, inputKind) + } + + private inner class RealMaxAction : AmountChooserMixinBase.MaxAction { + + private var activeDelayedClick: Job? = null + + private val maxAvailableBalance = maxActionProvider.maxAvailableBalanceOrNull() + .shareInBackground() + + private val maxAvailableForActionAmount = maxAvailableBalance.mapNullable { maxAvailableBalance -> + maxAvailableBalance.actualAmount + }.shareInBackground() + + override val display: Flow = maxAvailableBalance.mapNullable { maxAvailableBalance -> + maxAvailableBalance.displayedBalance.formatPlanks(maxAvailableBalance.chainAsset) + }.inBackground() + + override val maxClick: Flow = maxAvailableForActionAmount.map { maxAvailableForAction -> + getMaxClickAction(maxAvailableForAction) + }.shareInBackground() + + init { + setupAutoUpdates() + + cancelDelayedInputOnInputChange() + } + + private fun createImmediateMaxClicked(amount: BigDecimal): MaxClick { + val potentialState = maxAmountInputState(amount) + + return { + cancelActiveDelayedClick() + + inputState.value = potentialState + } + } + + private fun createDelayedMaxClicked(): MaxClick { + return { + cancelActiveDelayedClick() + + activeDelayedClick = launch { + val amount = maxAvailableForActionAmount.firstNotNull() + val potentialState = maxAmountInputState(amount) + inputState.value = potentialState + } + } + } + + private fun setupAutoUpdates() { + maxAvailableForActionAmount + .filterNotNull() + .filter { amount -> + val currentState = amountState.first() + currentState.inputKind == InputKind.MAX_ACTION && amount != currentState.value + } + .onEach { newAmount -> + inputState.value = maxAmountInputState(newAmount) + } + .launchIn(this@BaseAmountChooserProvider) + } + + private fun getMaxClickAction(maxAvailableForAction: BigDecimal?): MaxClick { + return if (maxAvailableForAction != null) { + createImmediateMaxClicked(maxAvailableForAction) + } else { + createDelayedMaxClicked() + } + } + + private fun cancelDelayedInputOnInputChange() { + amountState.onEach { + if (it.inputKind != InputKind.MAX_ACTION) cancelActiveDelayedClick() + }.launchIn(this@BaseAmountChooserProvider) + } + + private fun maxAmountInputState(amount: BigDecimal): InputState { + return InputState(amount.toStripTrailingZerosString(), initiatedByUser = true, inputKind = InputKind.MAX_ACTION) + } + + private fun MaxActionProvider?.maxAvailableBalanceOrNull(): Flow { + if (this == null) return flowOf(null) + + return maxAvailableBalance + .onStart { emit(null) } + } + + private fun cancelActiveDelayedClick() { + activeDelayedClick?.cancel() + activeDelayedClick = null + } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/DefaultFiatFormatter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/DefaultFiatFormatter.kt new file mode 100644 index 0000000..60d5aff --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/DefaultFiatFormatter.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser + +import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import java.math.BigDecimal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class DefaultFiatFormatter : AmountChooserMixinBase.FiatFormatter { + + override fun formatFlow(tokenFlow: Flow, amountFlow: Flow): Flow { + return combine(tokenFlow, amountFlow) { token, amount -> + token.amountToFiat(amount).formatAsCurrency(token.currency) + } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/AssetMaxActionProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/AssetMaxActionProvider.kt new file mode 100644 index 0000000..9874faa --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/AssetMaxActionProvider.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class AssetMaxActionProvider( + private val assetFlow: Flow, + private val assetField: suspend (Asset) -> Balance, +) : MaxActionProvider { + + override val maxAvailableBalance: Flow = assetFlow.map { asset -> + val extractedBalance = assetField(asset) + MaxAvailableBalance.fromSingle(asset.token.configuration, extractedBalance) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/BalanceMaxActionProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/BalanceMaxActionProvider.kt new file mode 100644 index 0000000..53f7bdb --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/BalanceMaxActionProvider.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy + +class BalanceMaxActionProvider( + private val chainAssetFlow: Flow, + private val balanceFlow: Flow, +) : MaxActionProvider { + + override val maxAvailableBalance: Flow = combine( + chainAssetFlow.distinctUntilChangedBy { it.fullId }, + balanceFlow + ) { chainAsset, balance -> + MaxAvailableBalance.fromSingle(chainAsset, balance) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/ChainAssetWithAmountMaxActionProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/ChainAssetWithAmountMaxActionProvider.kt new file mode 100644 index 0000000..2953dbd --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/ChainAssetWithAmountMaxActionProvider.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction + +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ChainAssetWithAmountMaxActionProvider( + private val balance: Flow +) : MaxActionProvider { + + override val maxAvailableBalance: Flow = balance.map { + MaxAvailableBalance.fromSingle(it.chainAsset, it.amount) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/DeductAmountMaxActionProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/DeductAmountMaxActionProvider.kt new file mode 100644 index 0000000..a416c4b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/DeductAmountMaxActionProvider.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction + +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged + +class DeductAmountMaxActionProvider( + amount: Flow, + inner: MaxActionProvider, +) : MaxActionProvider { + + override val maxAvailableBalance: Flow = combine( + inner.maxAvailableBalance, + amount + ) { maxAvailable, lastRecordedAmount -> + val actualAvailableBalance = (maxAvailable.actualBalance - lastRecordedAmount).atLeastZero() + + maxAvailable.copy( + displayedBalance = actualAvailableBalance, + actualBalance = actualAvailableBalance + ) + }.distinctUntilChanged() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/ExistentialDepositDeductionMaxActionProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/ExistentialDepositDeductionMaxActionProvider.kt new file mode 100644 index 0000000..9773293 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/ExistentialDepositDeductionMaxActionProvider.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction + +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDepositInPlanks +import io.novafoundation.nova.runtime.ext.fullId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.map + +class ExistentialDepositDeductionMaxActionProvider( + private val assetSourceRegistry: AssetSourceRegistry, + private val inner: MaxActionProvider, + private val deductEdFlow: Flow +) : MaxActionProvider { + + private val usedChainAsset = inner.maxAvailableBalance.map { it.chainAsset } + .distinctUntilChangedBy { it.fullId } + + private val existentialDeposit = usedChainAsset.map { + assetSourceRegistry.existentialDepositInPlanks(it) + } + + override val maxAvailableBalance: Flow = combine( + inner.maxAvailableBalance, + existentialDeposit, + deductEdFlow + ) { maxAvailable, existentialDeposit, deductEd -> + if (!deductEd) return@combine maxAvailable + + val actualAvailableBalance = (maxAvailable.actualBalance - existentialDeposit).atLeastZero() + + maxAvailable.copy( + displayedBalance = actualAvailableBalance, + actualBalance = actualAvailableBalance + ) + }.distinctUntilChanged() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/FeeAwareMaxActionProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/FeeAwareMaxActionProvider.kt new file mode 100644 index 0000000..f5ef7ce --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/FeeAwareMaxActionProvider.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction + +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.zipWithLastNonNull +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class ComplexFeeAwareMaxActionProvider( + feeMixin: FeeLoaderMixinV2, + inner: MaxActionProvider, +) : MaxActionProvider { + + private val usedChainAsset = inner.maxAvailableBalance.map { it.chainAsset } + .distinctUntilChangedBy { it.fullId } + + private val lastRecordedFeeAmount = combine(usedChainAsset, feeMixin.fee) { usedChainAsset, feeStatus -> + feeStatus.feePlanksOrNull(usedChainAsset) + } + .zipWithLastNonNull() + .map { (lastNonNullFee, currentFee) -> + // Use last non null fee while we are calculating new one + currentFee ?: lastNonNullFee ?: BigInteger.ZERO + } + + override val maxAvailableBalance: Flow = combine( + inner.maxAvailableBalance, + lastRecordedFeeAmount + ) { maxAvailable, lastRecordedFeeAmount -> + val actualAvailableBalance = (maxAvailable.actualBalance - lastRecordedFeeAmount).atLeastZero() + + maxAvailable.copy( + displayedBalance = actualAvailableBalance, + actualBalance = actualAvailableBalance + ) + }.distinctUntilChanged() + + private fun FeeStatus.feePlanksOrNull(feeChainAsset: Chain.Asset): Balance? { + if (this !is FeeStatus.Loaded) return null + + return feeModel.fee.maxAmountDeductionFor(feeChainAsset) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/MaxActionProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/MaxActionProvider.kt new file mode 100644 index 0000000..7a1859e --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/MaxActionProvider.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction + +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProviderDsl.Companion.share +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +interface MaxActionProvider { + + companion object + + val maxAvailableBalance: Flow +} + +fun MaxActionProvider.Companion.create( + coroutineScope: CoroutineScope, + builder: MaxActionProviderDsl.() -> MaxActionProvider +): MaxActionProvider { + return builder(MaxActionProviderDsl).share(coroutineScope) +} + +interface MaxActionProviderDsl { + + companion object : MaxActionProviderDsl + + fun Flow.providingMaxOfAsync(field: suspend (Asset) -> Balance): MaxActionProvider { + return AssetMaxActionProvider(this, field) + } + + fun Flow.providingMaxOf(field: (Asset) -> Balance): MaxActionProvider { + return AssetMaxActionProvider(this, field) + } + + fun Flow.asMaxAmountProvider(): MaxActionProvider { + return ChainAssetWithAmountMaxActionProvider(this) + } + + fun MaxActionProvider.deductEd(assetSourceRegistry: AssetSourceRegistry, deductEdFlow: Flow): MaxActionProvider { + return ExistentialDepositDeductionMaxActionProvider(assetSourceRegistry, this, deductEdFlow) + } + + fun MaxActionProvider.deductFee( + feeLoaderMixin: FeeLoaderMixinV2, + ): MaxActionProvider { + return ComplexFeeAwareMaxActionProvider(feeLoaderMixin, inner = this) + } + + fun MaxActionProvider.deductAmount( + amount: Flow + ): MaxActionProvider { + return DeductAmountMaxActionProvider(amount, inner = this) + } + + fun Flow.providingBalance(balanceFlow: Flow): MaxActionProvider { + return BalanceMaxActionProvider(this, balanceFlow) + } + + fun MaxActionProvider.share(coroutineScope: CoroutineScope): MaxActionProvider { + return SharingMaxActionProvider(this, coroutineScope) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/MaxAvailableBalance.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/MaxAvailableBalance.kt new file mode 100644 index 0000000..292bb95 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/MaxAvailableBalance.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigDecimal + +data class MaxAvailableBalance(val chainAsset: Chain.Asset, val displayedBalance: Balance, val actualBalance: Balance) { + + companion object { + + fun fromSingle(chainAsset: Chain.Asset, balance: Balance): MaxAvailableBalance { + return MaxAvailableBalance(chainAsset, balance, balance) + } + } +} + +val MaxAvailableBalance.actualAmount: BigDecimal + get() = chainAsset.amountFromPlanks(actualBalance) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/SharingMaxActionProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/SharingMaxActionProvider.kt new file mode 100644 index 0000000..b3b8eea --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/amountChooser/maxAction/SharingMaxActionProvider.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction + +import io.novafoundation.nova.common.utils.shareInBackground +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +class SharingMaxActionProvider( + inner: MaxActionProvider, + coroutineScope: CoroutineScope +) : MaxActionProvider, CoroutineScope by coroutineScope { + + override val maxAvailableBalance: Flow = inner.maxAvailableBalance.shareInBackground() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/assetSelector/AssetSelectorMixin.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/assetSelector/AssetSelectorMixin.kt new file mode 100644 index 0000000..6b3b537 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/assetSelector/AssetSelectorMixin.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetModel +import kotlinx.coroutines.flow.Flow + +interface AssetSelectorMixin { + + val showAssetChooser: LiveData>> + + fun assetSelectorClicked() + + fun assetChosen(selectorModel: AssetSelectorModel) + + val selectedAssetModelFlow: Flow + + interface Presentation : AssetSelectorMixin { + + val selectedAssetFlow: Flow + } +} + +data class AssetSelectorModel( + val assetModel: AssetModel, + val title: String, + val additionalIdentifier: String, +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/assetSelector/AssetSelectorProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/assetSelector/AssetSelectorProvider.kt new file mode 100644 index 0000000..6265222 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/assetSelector/AssetSelectorProvider.kt @@ -0,0 +1,131 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.data.model.MaskingMode +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.SelectableAssetAndOption +import io.novafoundation.nova.feature_wallet_api.domain.SelectableAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatter +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterFactory +import io.novafoundation.nova.common.presentation.masking.formatter.MaskableValueFormatterProvider +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.AssetModelFormatter +import io.novafoundation.nova.runtime.state.SingleAssetSharedState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch + +class AssetSelectorFactory( + private val assetUseCase: SelectableAssetUseCase<*>, + private val singleAssetSharedState: SingleAssetSharedState, + private val resourceManager: ResourceManager, + private val maskableValueFormatterProvider: MaskableValueFormatterProvider, + private val maskableValueFormatterFactory: MaskableValueFormatterFactory, + private val assetModelFormatter: AssetModelFormatter +) { + + fun create( + scope: CoroutineScope, + amountProvider: suspend (SelectableAssetAndOption) -> Balance + ): AssetSelectorMixin.Presentation { + return AssetSelectorProvider( + assetUseCase, + resourceManager, + singleAssetSharedState, + scope, + amountProvider, + maskableValueFormatterProvider, + maskableValueFormatterFactory.create(MaskingMode.DISABLED), // To format values without masking in asset list + assetModelFormatter + ) + } +} + +private class AssetSelectorProvider( + private val assetUseCase: SelectableAssetUseCase<*>, + private val resourceManager: ResourceManager, + private val singleAssetSharedState: SingleAssetSharedState, + private val scope: CoroutineScope, + private val amountProvider: suspend (SelectableAssetAndOption) -> Balance, + private val maskableValueFormatterProvider: MaskableValueFormatterProvider, + private val noMaskingValueFormatter: MaskableValueFormatter, // To format values without masking in asset list + private val assetModelFormatter: AssetModelFormatter +) : AssetSelectorMixin.Presentation, CoroutineScope by scope, WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(scope) { + + private val maskableValueFormatterFlow = maskableValueFormatterProvider.provideFormatter() + .shareInBackground() + + override val showAssetChooser = MutableLiveData>>() + + private val selectedAssetAndOptionFlow = assetUseCase.currentAssetAndOptionFlow() + .shareInBackground(SharingStarted.Eagerly) + + override val selectedAssetFlow: Flow = selectedAssetAndOptionFlow.map { it.asset } + + override val selectedAssetModelFlow: Flow = combine(selectedAssetAndOptionFlow, maskableValueFormatterFlow) { option, formatter -> + mapAssetAndOptionToSelectorModel(option, formatter) + }.shareIn(this, SharingStarted.Eagerly, replay = 1) + + override fun assetSelectorClicked() { + launch { + val availableToSelect = assetUseCase.availableAssetsToSelect() + + val models = availableToSelect.map { mapAssetAndOptionToSelectorModel(it, noMaskingValueFormatter) } + val selectedOption = selectedAssetAndOptionFlow.first() + val selectedChainAsset = selectedOption.asset.token.configuration + + val selectedModel = models.firstOrNull { + it.assetModel.chainAssetId == selectedChainAsset.id && + it.assetModel.chainId == selectedChainAsset.chainId && + it.additionalIdentifier == selectedOption.option.additional.identifier + } + + showAssetChooser.value = Event(DynamicListBottomSheet.Payload(models, selectedModel)) + } + } + + override fun assetChosen(selectorModel: AssetSelectorModel) { + singleAssetSharedState.update( + chainId = selectorModel.assetModel.chainId, + chainAssetId = selectorModel.assetModel.chainAssetId, + optionIdentifier = selectorModel.additionalIdentifier + ) + } + + private suspend fun mapAssetAndOptionToSelectorModel( + assetAndOption: SelectableAssetAndOption, + maskableValueFormatter: MaskableValueFormatter + ): AssetSelectorModel { + val balance = amountProvider(assetAndOption) + + val assetModel = assetModelFormatter.formatAsset( + assetAndOption.option.assetWithChain.asset, + balance = maskableValueFormatter.format { balance }, + patternId = null + ) + + val title = assetAndOption.formatTitle() + + return AssetSelectorModel(assetModel, title, assetAndOption.option.additional.identifier) + } + + private fun SelectableAssetAndOption.formatTitle(): String { + val formattedOptionLabel = option.additional.format(resourceManager) + val tokenName = asset.token.configuration.name + + return if (formattedOptionLabel != null) { + "$tokenName $formattedOptionLabel" + } else { + tokenName + } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/assetSelector/AssetSelectorUi.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/assetSelector/AssetSelectorUi.kt new file mode 100644 index 0000000..781da02 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/assetSelector/AssetSelectorUi.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.feature_wallet_api.presentation.view.AssetSelectorBottomSheet + +interface WithAssetSelector { + + val assetSelectorMixin: AssetSelectorMixin +} + +fun BaseFragment.subscribeOnAssetChange( + selectorMixin: AssetSelectorMixin, + onAssetChanged: (AssetSelectorModel) -> Unit +) where V : BaseViewModel { + selectorMixin.selectedAssetModelFlow.observe { + onAssetChanged(it) + } +} + +fun BaseFragment.subscribeOnAssetClick( + title: String, + selectorMixin: AssetSelectorMixin, + imageLoader: ImageLoader +) where V : BaseViewModel { + selectorMixin.showAssetChooser.observeEvent { + AssetSelectorBottomSheet( + title = title, + imageLoader = imageLoader, + context = requireContext(), + payload = it, + onClicked = { _, item -> selectorMixin.assetChosen(item) } + ).show() + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeLoaderMixin.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeLoaderMixin.kt new file mode 100644 index 0000000..386a6c0 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeLoaderMixin.kt @@ -0,0 +1,130 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee + +import androidx.lifecycle.LiveData +import androidx.lifecycle.asFlow +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_wallet_api.domain.TokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.GenericFeeLoaderMixin.Configuration +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transform + +interface GenericFeeLoaderMixin : Retriable { + + class Configuration( + val showZeroFiat: Boolean = true, + val initialState: InitialState = InitialState() + ) { + + class InitialState( + val feeStatus: FeeStatus? = null + ) + } + + val feeLiveData: LiveData> + + interface Presentation : GenericFeeLoaderMixin, SetFee { + + suspend fun loadFeeSuspending( + retryScope: CoroutineScope, + feeConstructor: suspend (Token) -> F?, + onRetryCancelled: () -> Unit, + ) + + fun loadFee( + coroutineScope: CoroutineScope, + feeConstructor: suspend (Token) -> F?, + onRetryCancelled: () -> Unit, + ) + + suspend fun setFeeOrHide(fee: F?) + suspend fun setFeeStatus(feeStatus: FeeStatus) + + suspend fun invalidateFee() + } +} + +@Deprecated("Use FeeLoaderMixinV2 instead") +interface FeeLoaderMixin : GenericFeeLoaderMixin { + + interface Presentation : GenericFeeLoaderMixin.Presentation, FeeLoaderMixin + + interface Factory { + + fun create( + tokenFlow: Flow, + configuration: Configuration = Configuration() + ): Presentation + } +} + +suspend fun GenericFeeLoaderMixin.awaitFee(): F = feeLiveData.asFlow() + .filterIsInstance>() + .first() + .feeModel.fee + +suspend fun GenericFeeLoaderMixin.awaitOptionalFee(): F? = feeLiveData.asFlow() + .transform { feeStatus -> + when (feeStatus) { + is FeeStatus.Loaded -> emit(feeStatus.feeModel.fee) + FeeStatus.NoFee -> emit(null) + else -> {} // skip + } + }.first() + +@Deprecated("Use createChangeableFee instead") +fun FeeLoaderMixin.Factory.create(assetFlow: Flow) = create(assetFlow.map { it.token }) + +@Deprecated("Use createChangeableFee instead") +fun FeeLoaderMixin.Factory.create(tokenUseCase: TokenUseCase) = create(tokenUseCase.currentTokenFlow()) + +fun FeeLoaderMixin.Presentation.connectWith( + inputSource: Flow, + scope: CoroutineScope, + feeConstructor: suspend Token.(input: I) -> Fee?, + onRetryCancelled: () -> Unit = {} +) { + inputSource.onEach { input -> + this.loadFee( + coroutineScope = scope, + feeConstructor = { token -> token.feeConstructor(input) }, + onRetryCancelled = onRetryCancelled + ) + } + .inBackground() + .launchIn(scope) +} + +fun FeeLoaderMixin.Presentation.connectWith( + inputSource1: Flow, + inputSource2: Flow, + scope: CoroutineScope, + feeConstructor: suspend Token.(input1: I1, input2: I2) -> Fee, + onRetryCancelled: () -> Unit = {} +) { + combine( + inputSource1, + inputSource2 + ) { input1, input2 -> + this.loadFee( + coroutineScope = scope, + feeConstructor = { token -> token.feeConstructor(input1, input2) }, + onRetryCancelled = onRetryCancelled + ) + } + .inBackground() + .launchIn(scope) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeParcelModel.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeParcelModel.kt new file mode 100644 index 0000000..3af21db --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeParcelModel.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee + +import android.os.Parcelable +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.model.EvmFee +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFee +import io.novafoundation.nova.runtime.util.ChainAssetParcel +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.parcelize.Parcelize +import java.math.BigInteger + +sealed interface FeeParcelModel : Parcelable { + + val amount: BigInteger + + val submissionOrigin: SubmissionOriginParcelModel + + val asset: ChainAssetParcel +} + +@Parcelize +class SubmissionOriginParcelModel( + val executingAccount: AccountId, + val signingAccount: AccountId +) : Parcelable + +@Parcelize +class EvmFeeParcelModel( + val gasLimit: BigInteger, + val gasPrice: BigInteger, + override val amount: BigInteger, + override val submissionOrigin: SubmissionOriginParcelModel, + override val asset: ChainAssetParcel +) : FeeParcelModel + +@Parcelize +class SimpleFeeParcelModel( + override val amount: BigInteger, + override val submissionOrigin: SubmissionOriginParcelModel, + override val asset: ChainAssetParcel +) : FeeParcelModel + +fun mapFeeToParcel(fee: Fee): FeeParcelModel { + val submissionOrigin = mapSubmissionOriginToParcel(fee.submissionOrigin) + val assetParcel = ChainAssetParcel(fee.asset) + + return when (fee) { + is EvmFee -> EvmFeeParcelModel( + gasLimit = fee.gasLimit, + gasPrice = fee.gasPrice, + amount = fee.amount, + submissionOrigin = submissionOrigin, + asset = assetParcel + ) + + else -> SimpleFeeParcelModel( + amount = fee.amount, + submissionOrigin = submissionOrigin, + asset = assetParcel + ) + } +} + +fun Fee.toParcel(): FeeParcelModel { + return mapFeeToParcel(this) +} + +fun FeeParcelModel.toDomain(): Fee { + return mapFeeFromParcel(this) +} + +private fun mapSubmissionOriginToParcel(submissionOrigin: SubmissionOrigin): SubmissionOriginParcelModel { + return with(submissionOrigin) { SubmissionOriginParcelModel(executingAccount = executingAccount, signingAccount = signingAccount) } +} + +fun mapFeeFromParcel(parcelFee: FeeParcelModel): Fee { + val submissionOrigin = mapSubmissionOriginFromParcel(parcelFee.submissionOrigin) + + return when (parcelFee) { + is EvmFeeParcelModel -> EvmFee( + gasLimit = parcelFee.gasLimit, + gasPrice = parcelFee.gasPrice, + submissionOrigin, + parcelFee.asset.value + ) + + is SimpleFeeParcelModel -> SubstrateFee(parcelFee.amount, submissionOrigin, parcelFee.asset.value) + } +} + +private fun mapSubmissionOriginFromParcel(submissionOrigin: SubmissionOriginParcelModel): SubmissionOrigin { + return with(submissionOrigin) { SubmissionOrigin(executingAccount = executingAccount, signingAccount = signingAccount) } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeUI.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeUI.kt new file mode 100644 index 0000000..eb146cf --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeUI.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee + +import io.novafoundation.nova.common.base.BaseFragmentMixin +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView + +interface WithFeeLoaderMixin { + + val originFeeMixin: GenericFeeLoaderMixin<*>? +} + +fun BaseFragmentMixin.setupFeeLoading(viewModel: V, feeView: FeeView) where V : BaseViewModel, V : GenericFeeLoaderMixin<*> { + observeRetries(viewModel) + + viewModel.feeLiveData.observe(feeView::setFeeStatus) +} + +fun BaseFragmentMixin<*>.setupFeeLoading(withFeeLoaderMixin: WithFeeLoaderMixin, feeView: FeeView) { + val mixin = withFeeLoaderMixin.originFeeMixin + + if (mixin != null) { + setupFeeLoading(mixin, feeView) + } else { + feeView.makeGone() + } +} + +fun BaseFragmentMixin<*>.setupFeeLoading(mixin: GenericFeeLoaderMixin<*>, feeView: FeeView) { + observeRetries(mixin) + + mixin.feeLiveData.observe(feeView::setFeeStatus) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeUI2.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeUI2.kt new file mode 100644 index 0000000..612430d --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/FeeUI2.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading +import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView + +fun BaseFragment<*, *>.setupFeeLoading(mixin: FeeLoaderMixinV2.Presentation<*, FeeDisplay>, feeView: FeeView) { + observeRetries(mixin) + + mixin.setupFeeLoading(feeView) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/SetFee.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/SetFee.kt new file mode 100644 index 0000000..1989252 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/SetFee.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee + +fun interface SetFee { + + suspend fun setFee(fee: F) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/amount/DefaultFeeInspector.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/amount/DefaultFeeInspector.kt new file mode 100644 index 0000000..3cc422d --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/amount/DefaultFeeInspector.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount + +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class DefaultFeeInspector : FeeInspector { + + override fun inspectFeeAmount(fee: F): FeeInspector.InspectedFeeAmount { + val amount = fee.amountByExecutingAccount + + return FeeInspector.InspectedFeeAmount( + checkedAgainstMinimumBalance = amount, + deductedFromTransferable = amount + ) + } + + override fun getSubmissionFeeAsset(fee: F): Chain.Asset { + return fee.asset + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/amount/FeeInspector.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/amount/FeeInspector.kt new file mode 100644 index 0000000..c25462d --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/amount/FeeInspector.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface FeeInspector { + + class InspectedFeeAmount( + /** + * The amount that is checked against [Asset.balanceCountedTowardsEDInPlanks] by the node before transaction execution + * This is always the submission fee amount + */ + val checkedAgainstMinimumBalance: Balance, + + /** + * The total amount that will either be directly paid from or required to be kept on [Asset.transferable] + * For example, some fees might deduct only submission fee but still require additional balance to be kept on transferable + */ + val deductedFromTransferable: Balance + ) + + fun inspectFeeAmount(fee: F): InspectedFeeAmount + + fun getSubmissionFeeAsset(fee: F): Chain.Asset +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/formatter/DefaultFeeFormatter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/formatter/DefaultFeeFormatter.kt new file mode 100644 index 0000000..e903e2c --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/formatter/DefaultFeeFormatter.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter + +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.toFeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig + +class DefaultFeeFormatter( + private val amountFormatter: AmountFormatter +) : FeeFormatter { + + override suspend fun formatFee( + fee: F, + configuration: FeeFormatter.Configuration, + context: FeeFormatter.Context + ): FeeDisplay { + return amountFormatter.formatAmountToAmountModel( + amountInPlanks = fee.amount, + token = context.token(fee.asset), + AmountConfig(includeZeroFiat = configuration.showZeroFiat) + ).toFeeDisplay() + } + + override suspend fun createLoadingStatus(): FeeStatus.Loading { + return FeeStatus.Loading(visibleDuringProgress = true) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/formatter/FeeFormatter.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/formatter/FeeFormatter.kt new file mode 100644 index 0000000..129254b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/formatter/FeeFormatter.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter + +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.FeeFormatter.Context +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeModel +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface FeeFormatter { + + class Configuration( + val showZeroFiat: Boolean + ) + + interface Context { + + suspend fun token(chainAsset: Chain.Asset): Token + } + + suspend fun formatFee( + fee: F, + configuration: Configuration, + context: Context, + ): D + + suspend fun createLoadingStatus(): FeeStatus.Loading +} + +context(Context) +suspend fun FeeFormatter.formatFeeStatus( + fee: F?, + configuration: FeeFormatter.Configuration, +): FeeStatus { + return if (fee != null) { + val display = formatFee(fee, configuration, this@Context) + val feeModel = FeeModel(fee, display) + FeeStatus.Loaded(feeModel) + } else { + FeeStatus.NoFee + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/ChooseFeeCurrencyPayload.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/ChooseFeeCurrencyPayload.kt new file mode 100644 index 0000000..e33157b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/ChooseFeeCurrencyPayload.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ChooseFeeCurrencyPayload(val selectedCommissionAsset: Chain.Asset, val availableAssets: List) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/FeeModel.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/FeeModel.kt new file mode 100644 index 0000000..7791cb1 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/FeeModel.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model + +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class FeeModel( + val fee: F, + val display: D, +) + +class FeeDisplay( + val title: CharSequence, + val subtitle: CharSequence? +) + +fun AmountModel.toFeeDisplay(): FeeDisplay { + return FeeDisplay(title = token, subtitle = fiat) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/FeeStatus.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/FeeStatus.kt new file mode 100644 index 0000000..42b2f5a --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/FeeStatus.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model + +sealed class FeeStatus { + class Loading(val visibleDuringProgress: Boolean) : FeeStatus() + + class Loaded(val feeModel: FeeModel) : FeeStatus() + + object NoFee : FeeStatus() + + object Error : FeeStatus() +} + +fun FeeStatus.mapDisplay(map: (D1) -> D2?): FeeStatus { + return when (this) { + FeeStatus.Error -> FeeStatus.Error + + is FeeStatus.Loaded -> { + val newFeeDisplay = map(feeModel.display) + + if (newFeeDisplay == null) { + FeeStatus.NoFee + } else { + FeeStatus.Loaded(FeeModel(feeModel.fee, newFeeDisplay)) + } + } + + is FeeStatus.Loading -> this + + FeeStatus.NoFee -> FeeStatus.NoFee + } +} + +fun FeeStatus.mapProgress(map: (Boolean) -> Boolean): FeeStatus { + return when (this) { + FeeStatus.Error -> FeeStatus.Error + + is FeeStatus.Loaded -> this + + is FeeStatus.Loading -> FeeStatus.Loading(map(visibleDuringProgress)) + + FeeStatus.NoFee -> FeeStatus.NoFee + } +} + +inline fun FeeStatus.onLoaded(action: (FeeModel) -> Unit) { + if (this is FeeStatus.Loaded) { + action(feeModel) + } +} + +fun FeeStatus.loadedFeeOrNull(): F? { + if (this is FeeStatus.Loaded) { + return this.feeModel.fee + } + + return null +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/PaymentCurrencySelectionMode.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/PaymentCurrencySelectionMode.kt new file mode 100644 index 0000000..6b5ea2f --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/model/PaymentCurrencySelectionMode.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model + +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 + +enum class PaymentCurrencySelectionMode { + + /** + * Payment currency cannot be changed and is always equal to [FeePaymentCurrency.Native] + */ + DISABLED, + + /** + * Payment currency can be changed by automatic internal logic, e.g. when there is not enough balance + */ + AUTOMATIC_ONLY, + + /** + * Payment currency can be changed both by automatic internal logic and the user + */ + ENABLED, + + /** + * Selected payment currency is dictated by the [Fee] returned by [FeeLoaderMixinV2.Presentation.loadFee] + * User cannot change that + */ + DETECT_FROM_FEE +} + +/** + * Whether the current mode allows to switch fee asset automatically, e.g. when mixin detects that there is not enough + * tokens in current fee token + */ +fun PaymentCurrencySelectionMode.automaticChangeEnabled(): Boolean { + return when (this) { + PaymentCurrencySelectionMode.AUTOMATIC_ONLY, + PaymentCurrencySelectionMode.ENABLED -> true + + PaymentCurrencySelectionMode.DISABLED, + PaymentCurrencySelectionMode.DETECT_FROM_FEE -> false + } +} + +/** + * Whether the current mode allows user to switch fee asset manually + */ +fun PaymentCurrencySelectionMode.userCanChangeFee(): Boolean { + return when (this) { + PaymentCurrencySelectionMode.ENABLED -> true + + PaymentCurrencySelectionMode.AUTOMATIC_ONLY, + PaymentCurrencySelectionMode.DISABLED, + PaymentCurrencySelectionMode.DETECT_FROM_FEE -> false + } +} + +/** + * Whether only native fee is allowed + */ +fun PaymentCurrencySelectionMode.onlyNativeFeeEnabled(): Boolean { + return when (this) { + PaymentCurrencySelectionMode.DISABLED -> true + + PaymentCurrencySelectionMode.AUTOMATIC_ONLY, + PaymentCurrencySelectionMode.ENABLED, + PaymentCurrencySelectionMode.DETECT_FROM_FEE -> false + } +} + +fun PaymentCurrencySelectionMode.shouldDetectFeeAssetFromFee(): Boolean { + return when (this) { + PaymentCurrencySelectionMode.DETECT_FROM_FEE -> true + + PaymentCurrencySelectionMode.AUTOMATIC_ONLY, + PaymentCurrencySelectionMode.ENABLED, + PaymentCurrencySelectionMode.DISABLED -> false + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/provider/FeeLoaderProviderFactory.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/provider/FeeLoaderProviderFactory.kt new file mode 100644 index 0000000..6f5e6c3 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/provider/FeeLoaderProviderFactory.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.provider + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.domain.fee.FeeInteractor +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.GenericFeeLoaderMixin +import kotlinx.coroutines.flow.Flow + +class FeeLoaderProviderFactory( + private val resourceManager: ResourceManager, + private val interactor: FeeInteractor, + private val amountFormatter: AmountFormatter +) : FeeLoaderMixin.Factory { + + override fun create( + tokenFlow: Flow, + configuration: GenericFeeLoaderMixin.Configuration + ): FeeLoaderMixin.Presentation { + return GenericFeeLoaderProviderPresentation(interactor, resourceManager, configuration, tokenFlow, amountFormatter) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/provider/GenericFeeLoaderProvider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/provider/GenericFeeLoaderProvider.kt new file mode 100644 index 0000000..40c757c --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/provider/GenericFeeLoaderProvider.kt @@ -0,0 +1,144 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.provider + +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.mixin.api.RetryPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.firstNotNull +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.domain.fee.FeeInteractor +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.GenericFeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.DefaultFeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.FeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.formatFeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Deprecated("Use ChangeableFeeLoaderProviderPresentation instead") +internal class GenericFeeLoaderProviderPresentation( + interactor: FeeInteractor, + resourceManager: ResourceManager, + configuration: GenericFeeLoaderMixin.Configuration, + tokenFlow: Flow, + amountFormatter: AmountFormatter +) : GenericFeeLoaderProvider( + resourceManager = resourceManager, + interactor = interactor, + configuration = configuration, + tokenFlow = tokenFlow, + feeFormatter = DefaultFeeFormatter(amountFormatter) +), + FeeLoaderMixin.Presentation + +@Deprecated("Use ChangeableFeeLoaderProvider instead") +internal open class GenericFeeLoaderProvider( + private val resourceManager: ResourceManager, + private val interactor: FeeInteractor, + private val configuration: GenericFeeLoaderMixin.Configuration, + private val tokenFlow: Flow, + private val feeFormatter: FeeFormatter, +) : GenericFeeLoaderMixin.Presentation, FeeFormatter.Context { + + private val feeFormatterConfiguration = configuration.toFeeFormatterConfiguration() + + final override val feeLiveData = MutableLiveData>() + + override val retryEvent = MutableLiveData>() + + init { + configuration.initialState.feeStatus?.let(feeLiveData::postValue) + } + + override suspend fun loadFeeSuspending( + retryScope: CoroutineScope, + feeConstructor: suspend (Token) -> F?, + onRetryCancelled: () -> Unit, + ): Unit = withContext(Dispatchers.IO) { + feeLiveData.postValue(FeeStatus.Loading(visibleDuringProgress = true)) + + val token = tokenFlow.firstNotNull() + + val value = runCatching { + feeConstructor(token) + }.fold( + onSuccess = { fee -> feeFormatter.formatFeeStatus(fee, feeFormatterConfiguration) }, + onFailure = { exception -> onError(exception, retryScope, feeConstructor, onRetryCancelled) } + ) + + value?.run { feeLiveData.postValue(this) } + } + + override fun loadFee( + coroutineScope: CoroutineScope, + feeConstructor: suspend (Token) -> F?, + onRetryCancelled: () -> Unit + ) { + coroutineScope.launch { + loadFeeSuspending( + retryScope = coroutineScope, + feeConstructor = feeConstructor, + onRetryCancelled = onRetryCancelled + ) + } + } + + override suspend fun setFeeOrHide(fee: F?) { + val feeStatus = feeFormatter.formatFeeStatus(fee, feeFormatterConfiguration) + feeLiveData.postValue(feeStatus) + } + + override suspend fun setFee(fee: F) { + setFeeOrHide(fee as F?) + } + + override suspend fun setFeeStatus(feeStatus: FeeStatus) { + feeLiveData.postValue(feeStatus) + } + + override suspend fun invalidateFee() { + feeLiveData.postValue(FeeStatus.Loading(visibleDuringProgress = true)) + } + + private fun onError( + exception: Throwable, + retryScope: CoroutineScope, + feeConstructor: suspend (Token) -> F?, + onRetryCancelled: () -> Unit, + ) = if (exception !is CancellationException) { + retryEvent.postValue( + Event( + RetryPayload( + title = resourceManager.getString(R.string.choose_amount_network_error), + message = resourceManager.getString(R.string.choose_amount_error_fee), + onRetry = { loadFee(retryScope, feeConstructor, onRetryCancelled) }, + onCancel = onRetryCancelled + ) + ) + ) + + exception.printStackTrace() + + FeeStatus.Error + } else { + null + } + + private fun GenericFeeLoaderMixin.Configuration<*>.toFeeFormatterConfiguration(): FeeFormatter.Configuration { + return FeeFormatter.Configuration(showZeroFiat) + } + + override suspend fun token(chainAsset: Chain.Asset): Token { + return interactor.getToken(chainAsset) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeContext.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeContext.kt new file mode 100644 index 0000000..572ae69 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeContext.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2 + +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class FeeContext( + /** + * Logical asset of the operation. For example, when sending USDT and paying fee in DOT, + * operationAsset will be USDT + */ + val operationAsset: Chain.Asset, + /** + * Utility asset of the logical of the operation. For example, when sending USDT on Hydration, + * operationChainUtilityAsset will be HDX + */ + val operationChainUtilityAssetSource: OperationUtilityAssetSource +) { + + /** + * Determines how [FeeLoaderMixinV2] should detect utility asset for the chain. + * This utility asset is used as a default fee payment asset + */ + sealed interface OperationUtilityAssetSource { + + /** + * Utility asset should be detected based on the chain of the [FeeContext.operationAsset] + * This source can be used when chain of the [FeeContext.operationAsset] is known to Nova, + * i.e. it is present in [ChainRegistry] + */ + object DetectFromOperationChain : OperationUtilityAssetSource + + /** + * The specified [operationChainUtilityAsset] should be used as utility asset + * This mode is usefull when we cannot provide guarantees that a [FeeContext.operationAsset] is present in ChainRegistry + * + * This might be the case for some logic that construct [Chain.Asset] on the fly to use components such as [FeeLoaderMixinV2] + * For example, Dapp Browser tx signing flow might do so for unknown EVM chains + */ + class Specified(val operationChainUtilityAsset: Chain.Asset) : OperationUtilityAssetSource + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderMixinV2.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderMixinV2.kt new file mode 100644 index 0000000..b6698db --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderMixinV2.kt @@ -0,0 +1,121 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2 + +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.Retriable +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.SetFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.FeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.ChooseFeeCurrencyPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.PaymentCurrencySelectionMode +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeContext.OperationUtilityAssetSource +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2.Configuration +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2.Factory +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map + +interface FeeLoaderMixinV2 : Retriable { + + class Configuration( + val showZeroFiat: Boolean = true, + val initialState: InitialState = InitialState(), + val onRetryCancelled: () -> Unit = {} + ) { + + class InitialState( + val feePaymentCurrency: FeePaymentCurrency = FeePaymentCurrency.Native, + val paymentCurrencySelectionMode: PaymentCurrencySelectionMode = PaymentCurrencySelectionMode.DISABLED, + val feeStatus: FeeStatus = FeeStatus.NoFee + ) + } + + val fee: StateFlow> + + val userCanChangeFeeAsset: Flow + + val chooseFeeAsset: ActionAwaitableMixin + + fun changePaymentCurrencyClicked() + + interface Presentation : FeeLoaderMixinV2, SetFee { + + suspend fun feeAsset(): Asset + + val feeChainAssetFlow: Flow + + suspend fun feePaymentCurrency(): FeePaymentCurrency + + fun loadFee(feeConstructor: FeeConstructor) + + suspend fun setPaymentCurrencySelectionMode(mode: PaymentCurrencySelectionMode) + + suspend fun setFeeOrHide(fee: F?) + + suspend fun setFeeLoading() + + suspend fun setFeeStatus(feeStatus: FeeStatus) + } + + interface Factory { + + fun create( + scope: CoroutineScope, + feeContextFlow: Flow, + feeFormatter: FeeFormatter, + feeInspector: FeeInspector, + configuration: Configuration = Configuration() + ): Presentation + + fun createDefault( + scope: CoroutineScope, + feeContextFlow: Flow, + configuration: Configuration = Configuration() + ): Presentation + } +} + +typealias FeeConstructor = suspend (FeePaymentCurrency) -> F? + +fun Factory.createDefault( + scope: CoroutineScope, + selectedChainAssetFlow: Flow, + configuration: Configuration = Configuration() +): FeeLoaderMixinV2.Presentation = createDefaultBy(scope, selectedChainAssetFlow.asFeeContextFromChain(), configuration) + +fun Factory.createDefaultBy( + scope: CoroutineScope, + feeContext: Flow, + configuration: Configuration = Configuration() +): FeeLoaderMixinV2.Presentation { + return createDefault( + scope = scope, + feeContextFlow = feeContext, + configuration = configuration + ) +} + +fun Flow.asFeeContextFromChain(): Flow { + return map { operationAsset -> + FeeContext( + operationAsset = operationAsset, + operationChainUtilityAssetSource = OperationUtilityAssetSource.DetectFromOperationChain + ) + } +} + +fun Flow.asFeeContextFromSelf(): Flow { + return map { operationAsset -> + FeeContext( + operationAsset = operationAsset, + operationChainUtilityAssetSource = OperationUtilityAssetSource.Specified(operationAsset) + ) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderMixinV2Ext.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderMixinV2Ext.kt new file mode 100644 index 0000000..592d2b6 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderMixinV2Ext.kt @@ -0,0 +1,101 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2 + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transform + +suspend fun FeeLoaderMixinV2.awaitFee(): F = fee + .filterIsInstance>() + .first() + .feeModel.fee + +suspend fun FeeLoaderMixinV2.awaitOptionalFee(): F? = fee + .transform { feeStatus -> + when (feeStatus) { + is FeeStatus.Loaded -> emit(feeStatus.feeModel.fee) + FeeStatus.NoFee -> emit(null) + else -> {} // skip + } + }.first() + +context(BaseViewModel) +fun FeeLoaderMixinV2.Presentation.connectWith( + inputSource1: Flow, + feeConstructor: suspend (FeePaymentCurrency, input1: I1) -> F? +) { + inputSource1.map { input1 -> + loadFee( + feeConstructor = { paymentCurrency -> feeConstructor(paymentCurrency, input1) }, + ) + } + .inBackground() + .launchIn(this@BaseViewModel) +} + +context(BaseViewModel) +fun FeeLoaderMixinV2.Presentation.connectWith( + inputSource1: Flow, + inputSource2: Flow, + feeConstructor: suspend (FeePaymentCurrency, input1: I1, input2: I2) -> F? +) { + combine( + inputSource1, + inputSource2 + ) { input1, input2 -> + loadFee( + feeConstructor = { paymentCurrency -> feeConstructor(paymentCurrency, input1, input2) }, + ) + } + .inBackground() + .launchIn(this@BaseViewModel) +} + +context(BaseViewModel) +fun FeeLoaderMixinV2.Presentation.connectWith( + inputSource1: Flow, + inputSource2: Flow, + inputSource3: Flow, + feeConstructor: suspend (FeePaymentCurrency, input1: I1, input2: I2, input3: I3) -> F, +) { + combine( + inputSource1, + inputSource2, + inputSource3 + ) { input1, input2, input3 -> + loadFee( + feeConstructor = { paymentCurrency -> feeConstructor(paymentCurrency, input1, input2, input3) }, + ) + } + .inBackground() + .launchIn(this@BaseViewModel) +} + +context(BaseViewModel) +fun FeeLoaderMixinV2.Presentation.connectWith( + inputSource1: Flow, + inputSource2: Flow, + inputSource3: Flow, + inputSource4: Flow, + feeConstructor: suspend (FeePaymentCurrency, input1: I1, input2: I2, input3: I3, input4: I4) -> F, +) { + combine( + inputSource1, + inputSource2, + inputSource3, + inputSource4 + ) { input1, input2, input3, input4 -> + loadFee( + feeConstructor = { paymentCurrency -> feeConstructor(paymentCurrency, input1, input2, input3, input4) }, + ) + } + .inBackground() + .launchIn(this@BaseViewModel) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderV2Factory.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderV2Factory.kt new file mode 100644 index 0000000..189c109 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderV2Factory.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2 + +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_wallet_api.domain.fee.FeeInteractor +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.DefaultFeeInspector +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.DefaultFeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.FeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow + +class FeeLoaderV2Factory( + private val chainRegistry: ChainRegistry, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val resourceManager: ResourceManager, + private val interactor: FeeInteractor, + private val amountFormatter: AmountFormatter +) : FeeLoaderMixinV2.Factory { + + override fun create( + scope: CoroutineScope, + feeContextFlow: Flow, + feeFormatter: FeeFormatter, + feeInspector: FeeInspector, + configuration: FeeLoaderMixinV2.Configuration + ): FeeLoaderMixinV2.Presentation { + return FeeLoaderV2Provider( + chainRegistry = chainRegistry, + actionAwaitableMixinFactory = actionAwaitableMixinFactory, + resourceManager = resourceManager, + interactor = interactor, + feeFormatter = feeFormatter, + configuration = configuration, + feeInspector = feeInspector, + feeContextFlow = feeContextFlow, + coroutineScope = scope, + ) + } + + override fun createDefault( + scope: CoroutineScope, + feeContextFlow: Flow, + configuration: FeeLoaderMixinV2.Configuration + ): FeeLoaderMixinV2.Presentation { + return create( + scope = scope, + feeContextFlow = feeContextFlow, + feeFormatter = DefaultFeeFormatter(amountFormatter), + feeInspector = DefaultFeeInspector(), + configuration = configuration + ) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderV2Provider.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderV2Provider.kt new file mode 100644 index 0000000..bb7ee62 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeLoaderV2Provider.kt @@ -0,0 +1,396 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2 + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.mixin.api.RetryPayload +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.toChainAsset +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.domain.fee.FeeInteractor +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.FeeFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.formatFeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.ChooseFeeCurrencyPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.PaymentCurrencySelectionMode +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.automaticChangeEnabled +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.onLoaded +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.onlyNativeFeeEnabled +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.shouldDetectFeeAssetFromFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.userCanChangeFee +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeContext.OperationUtilityAssetSource +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.withIndex +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resume + +internal class FeeLoaderV2Provider( + private val chainRegistry: ChainRegistry, + private val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + private val resourceManager: ResourceManager, + private val interactor: FeeInteractor, + + private val feeFormatter: FeeFormatter, + private val configuration: FeeLoaderMixinV2.Configuration, + private val feeInspector: FeeInspector, + private val feeContextFlow: Flow, + coroutineScope: CoroutineScope +) : FeeLoaderMixinV2.Presentation, + CoroutineScope by coroutineScope, + FeeFormatter.Context { + + private val feeFormatterConfiguration = configuration.toFeeFormatterConfiguration() + + private val selectedTokenInfo = feeContextFlow + .distinctUntilChangedBy { it.operationAsset.fullId } + .map(::constructSelectedTokenInfo) + .shareInBackground() + + private val paymentCurrencySelectionModeFlow = MutableStateFlow(configuration.initialState.paymentCurrencySelectionMode) + + override val feeChainAssetFlow = singleReplaySharedFlow() + + private val feeAsset: Flow = feeChainAssetFlow + .distinctUntilChangedBy { it.fullId } + .flatMapLatest { interactor.assetFlow(it) } + .shareInBackground() + + private val userModifiedFeeInCurrentAsset = MutableStateFlow(false) + + private val feeSwitchCapabilityFlow = combine(selectedTokenInfo, feeChainAssetFlow) { selectedTokenInfo, feeAsset -> + val selectedSupported = selectedTokenInfo.feePaymentSupported + + val feeInNative = feeAsset.fullId == selectedTokenInfo.chainUtilityAsset.fullId + val feeInSelected = feeAsset.fullId == selectedTokenInfo.chainAsset.fullId + + FeeSwitchCapability( + canSwitchToSelected = selectedSupported && !feeInSelected, + canSwitchToNative = !feeInNative + ) + } + .distinctUntilChanged() + .shareInBackground() + + private val canChangeFeeToSelectedAutomatically = combine( + userModifiedFeeInCurrentAsset, + paymentCurrencySelectionModeFlow, + feeSwitchCapabilityFlow + ) { userModifiedFee, selectionMode, feeSwitchCapability -> + feeSwitchCapability.canSwitchToSelected && selectionMode.automaticChangeEnabled() && !userModifiedFee + }.shareInBackground() + + override val userCanChangeFeeAsset: Flow = combine( + paymentCurrencySelectionModeFlow, + feeSwitchCapabilityFlow + ) { selectionMode, feeSwitchCapability -> + feeSwitchCapability.canSwitch && selectionMode.userCanChangeFee() + }.shareInBackground() + + override val chooseFeeAsset = actionAwaitableMixinFactory.create() + + override val fee = MutableStateFlow(configuration.initialState.feeStatus) + + override val retryEvent = MutableLiveData>() + + private var latestLoadFeeJob: Job? = null + private var latestFeeConstructor: FeeConstructor? = null + + init { + observeSelectedAssetChanges() + } + + override fun loadFee(feeConstructor: FeeConstructor) { + latestLoadFeeJob?.cancel() + latestLoadFeeJob = launch(Dispatchers.IO) { + latestFeeConstructor = feeConstructor + fee.emit(feeFormatter.createLoadingStatus()) + + val feePaymentCurrency = feeChainAssetFlow.first().toFeePaymentCurrency() + + runCatching { feeConstructor(feePaymentCurrency) } + .mapCatching { onFeeLoaded(it, feePaymentCurrency, feeConstructor) } + .onFailure { onFeeError(it, feeConstructor) } + } + } + + private suspend fun onFeeError(error: Throwable, feeConstructor: FeeConstructor) { + if (error !is CancellationException) { + // Build full error chain for debugging + val errorChain = buildString { + var current: Throwable? = error + var depth = 0 + while (current != null && depth < 5) { + if (depth > 0) append(" -> ") + append("${current.javaClass.simpleName}: ${current.message}") + current = current.cause + depth++ + } + } + val errorMsg = errorChain + Log.e(LOG_TAG, "Failed to sync fee: $errorMsg", error) + + fee.emit(FeeStatus.Error) + + // Show detailed error in retry dialog with runtime diagnostics + val diagnostics = try { + io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFactory.lastDiagnostics + } catch (e: Exception) { "N/A" } + retryEvent.postValue( + Event( + RetryPayload( + title = resourceManager.getString(R.string.choose_amount_network_error), + message = "DEBUG: $errorMsg | Runtime: $diagnostics", + onRetry = { loadFee(feeConstructor) }, + onCancel = { } + ) + ) + ) + } + } + + private suspend fun onFeeLoaded( + newFee: F?, + requestedFeePaymentCurrency: FeePaymentCurrency, + feeConstructor: FeeConstructor + ) { + if (newFee != null) { + setLoadedFee(newFee, requestedFeePaymentCurrency, feeConstructor) + } else { + fee.emit(FeeStatus.NoFee) + } + } + + private suspend fun awaitFeeRetry() { + return suspendCancellableCoroutine { continuation -> + retryEvent.postValue( + Event( + RetryPayload( + title = resourceManager.getString(R.string.choose_amount_network_error), + message = resourceManager.getString(R.string.choose_amount_error_fee), + onRetry = { continuation.resume(Unit) }, + onCancel = { continuation.cancel() } + ) + ) + ) + } + } + + override suspend fun feeAsset(): Asset { + return feeAsset.filterNotNull().first() + } + + override suspend fun token(chainAsset: Chain.Asset): Token { + val submissionFeeToken = feeAsset().token + if (submissionFeeToken.configuration.fullId == chainAsset.fullId) { + return submissionFeeToken + } + + return interactor.getToken(chainAsset) + } + + override suspend fun feePaymentCurrency(): FeePaymentCurrency { + return feeChainAssetFlow.first().toFeePaymentCurrency() + } + + override suspend fun setPaymentCurrencySelectionMode(mode: PaymentCurrencySelectionMode) { + paymentCurrencySelectionModeFlow.value = mode + + val isCustomFee = !feeChainAssetFlow.first().isUtilityAsset + + if (isCustomFee && mode.onlyNativeFeeEnabled()) { + val utilityAsset = selectedTokenInfo.first().chainUtilityAsset + feeChainAssetFlow.emit(utilityAsset) + + reloadFeeWithLatestConstructor() + } + } + + override suspend fun setFeeLoading() { + fee.emit(feeFormatter.createLoadingStatus()) + } + + override suspend fun setFeeStatus(feeStatus: FeeStatus) { + feeStatus.onLoaded { feeModel -> + val feeAsset = feeInspector.getSubmissionFeeAsset(feeModel.fee) + feeChainAssetFlow.emit(feeAsset) + } + + fee.emit(feeStatus) + } + + override suspend fun setFee(fee: F) { + setFeeOrHide(fee as F?) + } + + override suspend fun setFeeOrHide(fee: F?) { + val feeStatus = feeFormatter.formatFeeStatus(fee, feeFormatterConfiguration) + setFeeStatus(feeStatus) + } + + private suspend fun setLoadedFee( + newFee: F, + requestedFeePaymentCurrency: FeePaymentCurrency, + feeConstructor: FeeConstructor + ) { + val selectionMode = paymentCurrencySelectionModeFlow.first() + val actualFeePaymentAsset = feeInspector.getSubmissionFeeAsset(newFee) + + if (selectionMode.shouldDetectFeeAssetFromFee()) { + feeChainAssetFlow.emit(actualFeePaymentAsset) + fee.value = feeFormatter.formatFeeStatus(newFee, feeFormatterConfiguration) + } else { + val actualPaymentCurrency = actualFeePaymentAsset.toFeePaymentCurrency() + + require(requestedFeePaymentCurrency == actualPaymentCurrency) { + """ + Fee with loaded with different fee payment currency that was requested. + Requested: $requestedFeePaymentCurrency. Actual: $actualPaymentCurrency. + Please check you are using the passed FeePaymentCurrency to load the fee. + """.trimIndent() + } + + setFeeWithAutomaticChange(newFee, feeConstructor) + } + } + + private suspend fun setFeeWithAutomaticChange( + newFee: F, + feeConstructor: suspend (FeePaymentCurrency) -> F? + ) { + val feeStatus = feeFormatter.formatFeeStatus(newFee, feeFormatterConfiguration) + + val canChangeFeeAutomatically = canChangeFeeToSelectedAutomatically.first() + if (!canChangeFeeAutomatically) { + fee.value = feeStatus + return + } + + val feeAsset = feeAsset.first() + if (feeAsset == null || feeAsset.canPayFee(newFee)) { + fee.value = feeStatus + } else { + val selectedChainAsset = feeContextFlow.first().operationAsset + feeChainAssetFlow.emit(selectedChainAsset) + + loadFee(feeConstructor) + } + } + + private fun observeSelectedAssetChanges() { + selectedTokenInfo.distinctUntilChangedBy { it.chainAsset.fullId } + .withIndex() + .onEach { (index, tokenInfo) -> + userModifiedFeeInCurrentAsset.value = false + + if (index == 0) { + // First emission - we have loaded initial chain + val initialFeeAsset = configuration.initialState.feePaymentCurrency.toChainAsset(tokenInfo.chainUtilityAsset) + feeChainAssetFlow.emit(initialFeeAsset) + } else { + // Subsequent emissions - chain changed, set the utility asset + feeChainAssetFlow.emit(tokenInfo.chainUtilityAsset) + + reloadFeeWithLatestConstructor() + } + } + .launchIn(this) + } + + private fun reloadFeeWithLatestConstructor() { + latestFeeConstructor?.let(::loadFee) + } + + override fun changePaymentCurrencyClicked() { + launch { + val userCanChangeFee = userCanChangeFeeAsset.first() + if (!userCanChangeFee) return@launch + + val payload = constructChooseFeeCurrencyPayload() + val chosenFeeAsset = chooseFeeAsset.awaitAction(payload) + + feeChainAssetFlow.emit(chosenFeeAsset) + userModifiedFeeInCurrentAsset.value = true + reloadFeeWithLatestConstructor() + } + } + + private suspend fun constructChooseFeeCurrencyPayload(): ChooseFeeCurrencyPayload { + val selectedTokenInfo = selectedTokenInfo.first() + val feeChainAsset = feeChainAssetFlow.first() + + val availableFeeTokens = listOf(selectedTokenInfo.chainUtilityAsset, selectedTokenInfo.chainAsset) + return ChooseFeeCurrencyPayload( + selectedCommissionAsset = feeChainAsset, + availableAssets = availableFeeTokens + ) + } + + private suspend fun Asset.canPayFee(fee: F): Boolean { + val inspectedFeeAmount = feeInspector.inspectFeeAmount(fee) + + return interactor.hasEnoughBalanceToPayFee(this, inspectedFeeAmount) + } + + private suspend fun constructSelectedTokenInfo(feeContext: FeeContext): SelectedAssetInfo { + val canPayFee = canPayFeeIn(feeContext.operationAsset) + return SelectedAssetInfo(feeContext.operationAsset, feeContext.operationChainUtilityAsset(), canPayFee) + } + + private suspend fun FeeContext.operationChainUtilityAsset(): Chain.Asset { + return when (val source = operationChainUtilityAssetSource) { + OperationUtilityAssetSource.DetectFromOperationChain -> chainRegistry.getChain(operationAsset.chainId).utilityAsset + is OperationUtilityAssetSource.Specified -> source.operationChainUtilityAsset + } + } + + private suspend fun canPayFeeIn(chainAsset: Chain.Asset): Boolean { + return interactor.canPayFeeInAsset(chainAsset) + } + + private fun FeeLoaderMixinV2.Configuration<*, *>.toFeeFormatterConfiguration(): FeeFormatter.Configuration { + return FeeFormatter.Configuration(showZeroFiat) + } + + private class SelectedAssetInfo( + val chainAsset: Chain.Asset, + val chainUtilityAsset: Chain.Asset, + val feePaymentSupported: Boolean + ) + + private class FeeSwitchCapability( + val canSwitchToSelected: Boolean, + val canSwitchToNative: Boolean + ) { + + val canSwitch = canSwitchToSelected || canSwitchToNative + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeUI.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeUI.kt new file mode 100644 index 0000000..bb51141 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/fee/v2/FeeUI.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2 + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin.Action +import io.novafoundation.nova.common.mixin.impl.observeRetries +import io.novafoundation.nova.feature_account_api.presenatation.fee.select.FeeAssetSelectorBottomSheet +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.ChooseFeeCurrencyPayload +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +// TODO We use here since star projections cause 1.7.21 compiler to fail +// https://youtrack.jetbrains.com/issue/KT-51277/NoSuchElementException-Collection-contains-no-element-matching-the-predicate-with-context-receivers-and-star-projection +// We can return to star projections after upgrading Kotlin to at least 1.8.20 +context(BaseFragment) +fun FeeLoaderMixinV2.setupFeeLoading( + setFeeStatus: (FeeStatus) -> Unit, + setUserCanChangeFeeAsset: (Boolean) -> Unit +) { + observeRetries(this) + + fee.observe(setFeeStatus) + + userCanChangeFeeAsset.observe(setUserCanChangeFeeAsset) + + chooseFeeAsset.awaitableActionLiveData.observeEvent { + openEditFeeBottomSheet(it) + } +} + +context(BaseFragment) +fun FeeLoaderMixinV2.setupFeeLoading(feeView: FeeView) { + setupFeeLoading( + setFeeStatus = { feeView.setFeeStatus(it) }, + setUserCanChangeFeeAsset = { + feeView.setFeeEditable(it) { + changePaymentCurrencyClicked() + } + } + ) +} + +context(BaseFragment) +private fun openEditFeeBottomSheet(action: Action) { + val payload = FeeAssetSelectorBottomSheet.Payload( + options = action.payload.availableAssets, + selectedOption = action.payload.selectedCommissionAsset + ) + + FeeAssetSelectorBottomSheet( + context = providedContext, + payload = payload, + onOptionClicked = action.onSuccess, + onCancel = action.onCancel + ).show() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/getAsset/GetAssetBottomSheet.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/getAsset/GetAssetBottomSheet.kt new file mode 100644 index 0000000..302c294 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/getAsset/GetAssetBottomSheet.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset + +import android.content.Context +import android.os.Bundle +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import io.novafoundation.nova.common.databinding.BottomSheeetFixedListBinding +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.FixedListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.fixed.textWithDescriptionItem +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.domain.model.GetAssetOption +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class GetAssetBottomSheet( + context: Context, + onCancel: () -> Unit, + private val payload: Payload, + private val onClicked: (GetAssetOption) -> Unit, +) : FixedListBottomSheet( + context, + viewConfiguration = ViewConfiguration.default(context), + onCancel = onCancel +) { + + class Payload( + val chainAsset: Chain.Asset, + val availableOptions: Set + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(formatWithAssetSymbol(R.string.swap_get_token_using)) + + getAssetInItem( + title = context.getString(R.string.wallet_cross_chain_transfer), + description = formatWithAssetSymbol(R.string.swap_get_token_cross_chain_description), + iconRes = R.drawable.ic_cross_chain, + option = GetAssetOption.CROSS_CHAIN + ) + + getAssetInItem( + title = context.getString(R.string.wallet_asset_receive), + description = formatWithAssetSymbol(R.string.swap_get_token_receive_description), + iconRes = R.drawable.ic_arrow_down, + option = GetAssetOption.RECEIVE + ) + + getAssetInItem( + title = context.getString(R.string.wallet_asset_buy), + description = formatWithAssetSymbol(R.string.swap_get_token_buy_description), + iconRes = R.drawable.ic_buy, + option = GetAssetOption.BUY + ) + } + + private fun getAssetInItem( + title: String, + description: String, + @DrawableRes iconRes: Int, + option: GetAssetOption + ) { + textWithDescriptionItem( + title = title, + description = description, + iconRes = iconRes, + enabled = option in payload.availableOptions, + showArrowWhenEnabled = true + ) { + onClicked(option) + } + } + + private fun formatWithAssetSymbol(@StringRes resId: Int) = context.getString(resId, payload.chainAsset.symbol) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/getAsset/GetAssetOptionMixinUI.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/getAsset/GetAssetOptionMixinUI.kt new file mode 100644 index 0000000..e8afacf --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/getAsset/GetAssetOptionMixinUI.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset + +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.view.PrimaryButton +import io.novafoundation.nova.common.view.setState + +context(BaseFragment<*, *>) +fun GetAssetOptionsMixin.bindGetAsset( + button: PrimaryButton +) { + button.setOnClickListener { openAssetOptions() } + + getAssetOptionsButtonState.observe(button::setState) + observeGetAssetAction.awaitableActionLiveData.observeEvent { + GetAssetBottomSheet( + context = requireContext(), + onCancel = it.onCancel, + payload = it.payload, + onClicked = it.onSuccess + ).show() + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/getAsset/GetAssetOptionsMixin.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/getAsset/GetAssetOptionsMixin.kt new file mode 100644 index 0000000..b13a841 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/getAsset/GetAssetOptionsMixin.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset + +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.feature_wallet_api.domain.model.GetAssetOption +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +interface GetAssetOptionsMixin { + + interface Factory { + fun create( + assetFlow: Flow, + scope: CoroutineScope, + additionalButtonFilter: Flow = flowOf(true) + ): GetAssetOptionsMixin + } + + val getAssetOptionsButtonState: Flow + + val observeGetAssetAction: ActionAwaitableMixin.Presentation + + fun openAssetOptions() +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/maxAction/MaxActionProviderFactory.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/maxAction/MaxActionProviderFactory.kt new file mode 100644 index 0000000..004f3fa --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/mixin/maxAction/MaxActionProviderFactory.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction + +import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProviderDsl +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.create +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import java.math.BigInteger + +enum class MaxBalanceType { + TRANSFERABLE, TOTAL, FREE +} + +class MaxActionProviderFactory( + private val assetSourceRegistry: AssetSourceRegistry +) { + + fun create( + viewModelScope: CoroutineScope, + assetInFlow: Flow, + feeLoaderMixin: FeeLoaderMixinV2, + balance: suspend (Asset) -> BigInteger, + deductEd: Flow = flowOf(false) + ): MaxActionProvider { + return MaxActionProvider.create(viewModelScope) { + assetInFlow.providingMaxOfAsync(balance) + .deductFee(feeLoaderMixin) + .deductEd(assetSourceRegistry, deductEd) + } + } + + fun createCustom( + viewModelScope: CoroutineScope, + builder: MaxActionProviderDsl.() -> MaxActionProvider + ): MaxActionProvider { + return MaxActionProvider.create(viewModelScope) { + builder() + } + } +} + +fun MaxActionProviderFactory.create( + viewModelScope: CoroutineScope, + assetInFlow: Flow, + feeLoaderMixin: FeeLoaderMixinV2, + maxBalanceType: MaxBalanceType = MaxBalanceType.TRANSFERABLE, + deductEd: Flow = flowOf(false) +): MaxActionProvider { + return create( + viewModelScope = viewModelScope, + assetInFlow = assetInFlow, + feeLoaderMixin = feeLoaderMixin, + // Due to internal bug in IR compiler cannot use Asset::transferableInPlanks e.t.c. here + balance = when (maxBalanceType) { + MaxBalanceType.TRANSFERABLE -> { it -> it.transferableInPlanks } + MaxBalanceType.TOTAL -> { it -> it.totalInPlanks } + MaxBalanceType.FREE -> { it -> it.freeInPlanks } + }, + deductEd = deductEd + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AmountModel.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AmountModel.kt new file mode 100644 index 0000000..46ac2f1 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AmountModel.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.model + +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.presentation.masking.map + +data class AmountModel( + val token: CharSequence, + val fiat: CharSequence? +) { + + // Override it since SpannableString is not equals by content + override fun equals(other: Any?): Boolean { + return other is AmountModel && + other.token.toString() == token.toString() && + other.fiat?.toString() == fiat?.toString() + } +} + +fun MaskableModel.maskableToken() = map { it.token } +fun MaskableModel.maskableFiat() = map { it.fiat } diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AmountSign.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AmountSign.kt new file mode 100644 index 0000000..36a0fb9 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AmountSign.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.model + +enum class AmountSign(val signSymbol: String) { + NONE(""), NEGATIVE("-"), POSITIVE("+") +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AssetModel.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AssetModel.kt new file mode 100644 index 0000000..33bb975 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AssetModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.model + +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.utils.images.Icon + +data class AssetModel( + val chainId: String, + val chainAssetId: Int, + val icon: Icon, + val tokenName: String, + val tokenSymbol: String, + val assetBalance: MaskableModel +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AssetPayload.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AssetPayload.kt new file mode 100644 index 0000000..1514e71 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/AssetPayload.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.model + +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.parcelize.Parcelize + +@Parcelize +class AssetPayload(val chainId: ChainId, val chainAssetId: Int) : Parcelable + +val AssetPayload.fullChainAssetId: FullChainAssetId + get() = FullChainAssetId(chainId, chainAssetId) + +fun FullChainAssetId.toAssetPayload(): AssetPayload = AssetPayload(chainId, assetId) + +fun Chain.Asset.toAssetPayload(): AssetPayload = AssetPayload(chainId, id) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/ChooseAmountModel.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/ChooseAmountModel.kt new file mode 100644 index 0000000..d074203 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/ChooseAmountModel.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.model + +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ChooseAmountModel( + val input: ChooseAmountInputModel +) + +class ChooseAmountInputModel( + val tokenSymbol: String, + val tokenIcon: Icon, +) + +internal fun ChooseAmountModel( + asset: Asset, + assetIconProvider: AssetIconProvider +): ChooseAmountModel = ChooseAmountModel( + input = ChooseAmountInputModel(asset.token.configuration, assetIconProvider) +) + +internal fun ChooseAmountInputModel(chainAsset: Chain.Asset, assetIconProvider: AssetIconProvider): ChooseAmountInputModel = ChooseAmountInputModel( + tokenSymbol = chainAsset.symbol.value, + tokenIcon = assetIconProvider.getAssetIconOrFallback(chainAsset), +) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/FractionPartStyling.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/FractionPartStyling.kt new file mode 100644 index 0000000..8a17836 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/model/FractionPartStyling.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.model + +import androidx.annotation.ColorRes +import androidx.annotation.DimenRes +import io.novafoundation.nova.feature_wallet_api.R + +sealed interface FractionPartStyling { + data object NoStyle : FractionPartStyling + data class Styled( + @DimenRes val sizeRes: Int, + @ColorRes val colorRes: Int = R.color.text_secondary + ) : FractionPartStyling +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/validation/ValidationMessagesFormat.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/validation/ValidationMessagesFormat.kt new file mode 100644 index 0000000..314fa6f --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/validation/ValidationMessagesFormat.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.validation + +import io.novafoundation.nova.common.base.TitleAndMessage +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount + +fun handleInsufficientBalanceCommission(failure: InsufficientBalanceToStayAboveEDError, resourceManager: ResourceManager): TitleAndMessage { + return resourceManager.getString(R.string.common_too_small_balance_title) to + resourceManager.getString( + R.string.wallet_send_insufficient_balance_commission, + failure.errorModel.minRequiredBalance.formatTokenAmount(failure.asset), + failure.errorModel.availableBalance.formatTokenAmount(failure.asset), + failure.errorModel.balanceToAdd.formatTokenAmount(failure.asset), + ) +} + +fun handleNonPositiveAmount(resourceManager: ResourceManager): TitleAndMessage { + return TitleAndMessage( + resourceManager.getString(R.string.common_error_general_title), + resourceManager.getString(R.string.common_zero_amount_error) + ) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/validation/transfers/TransferAssetValidationFailureUi.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/validation/transfers/TransferAssetValidationFailureUi.kt new file mode 100644 index 0000000..77be79b --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/validation/transfers/TransferAssetValidationFailureUi.kt @@ -0,0 +1,133 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.validation.transfers + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.TransformedFailure +import io.novafoundation.nova.common.validation.TransformedFailure.Default +import io.novafoundation.nova.common.validation.ValidationFlowActions +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.asDefault +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.domain.validation.handleSystemAccountValidationFailure +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleFeeSpikeDetected +import io.novafoundation.nova.feature_wallet_api.domain.validation.handleNotEnoughFeeError +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.SetFee +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission +import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleNonPositiveAmount +import kotlinx.coroutines.CoroutineScope + +fun CoroutineScope.mapAssetTransferValidationFailureToUI( + resourceManager: ResourceManager, + status: ValidationStatus.NotValid, + actions: ValidationFlowActions<*>, + setFee: SetFee, +): TransformedFailure? { + return when (val reason = status.reason) { + is AssetTransferValidationFailure.DeadRecipient.InCommissionAsset -> Default( + resourceManager.getString(R.string.wallet_send_dead_recipient_commission_asset_title) to + resourceManager.getString(R.string.wallet_send_dead_recipient_commission_asset_message, reason.commissionAsset.symbol) + ) + + AssetTransferValidationFailure.DeadRecipient.InUsedAsset -> Default( + resourceManager.getString(R.string.common_amount_low) to + resourceManager.getString(R.string.wallet_send_dead_recipient_message) + ) + + AssetTransferValidationFailure.NotEnoughFunds.InUsedAsset -> Default( + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString(R.string.choose_amount_error_too_big) + ) + + is AssetTransferValidationFailure.NotEnoughFunds.InCommissionAsset -> Default( + handleNotEnoughFeeError(reason, resourceManager) + ) + + AssetTransferValidationFailure.WillRemoveAccount.WillBurnDust -> Default( + resourceManager.getString(R.string.wallet_send_existential_warning_title) to + resourceManager.getString(R.string.wallet_send_existential_warning_message_v2_2_0) + ) + + is AssetTransferValidationFailure.WillRemoveAccount.WillTransferDust -> Default( + resourceManager.getString(R.string.wallet_send_existential_warning_title) to + resourceManager.getString(R.string.wallet_send_existential_warnining_transfer_dust) + ) + + is AssetTransferValidationFailure.InvalidRecipientAddress -> Default( + resourceManager.getString(R.string.common_validation_invalid_address_title) to + resourceManager.getString(R.string.common_validation_invalid_address_message, reason.chain.name) + ) + + is AssetTransferValidationFailure.PhishingRecipient -> Default( + resourceManager.getString(R.string.wallet_send_phishing_warning_title) to + resourceManager.getString(R.string.wallet_send_phishing_warning_text, reason.address) + ) + + AssetTransferValidationFailure.NonPositiveAmount -> handleNonPositiveAmount(resourceManager).asDefault() + + is AssetTransferValidationFailure.NotEnoughFunds.ToPayCrossChainFee -> Default( + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString( + R.string.wallet_send_cannot_pay_cross_chain_fee, + reason.fee.formatTokenAmount(reason.usedAsset), + reason.remainingBalanceAfterTransfer.formatTokenAmount(reason.usedAsset) + ) + ) + + is AssetTransferValidationFailure.NotEnoughFunds.ToStayAboveEdBeforePayingDeliveryFees -> Default( + resourceManager.getString(R.string.common_not_enough_funds_title) to + resourceManager.getString( + R.string.wallet_send_cannot_dust_before_delivery_fee_message, + reason.maxPossibleTransferAmount.formatPlanks(reason.chainAsset) + ) + ) + + is AssetTransferValidationFailure.NotEnoughFunds.ToStayAboveED -> handleInsufficientBalanceCommission( + reason, + resourceManager + ).asDefault() + + AssetTransferValidationFailure.RecipientCannotAcceptTransfer -> Default( + resourceManager.getString(R.string.wallet_send_recipient_blocked_title) to + resourceManager.getString(R.string.wallet_send_recipient_blocked_message) + ) + + is AssetTransferValidationFailure.FeeChangeDetected -> handleFeeSpikeDetected( + error = reason, + resourceManager = resourceManager, + setFee = setFee, + actions = actions, + ) + + AssetTransferValidationFailure.RecipientIsSystemAccount -> Default( + handleSystemAccountValidationFailure(resourceManager) + ) + + AssetTransferValidationFailure.DryRunFailed -> Default( + resourceManager.getString(R.string.common_dry_run_failed_title) to + resourceManager.getString(R.string.common_dry_run_failed_message) + ) + } +} + +fun autoFixSendValidationPayload( + payload: AssetTransferPayload, + failureReason: AssetTransferValidationFailure +) = when (failureReason) { + is AssetTransferValidationFailure.WillRemoveAccount.WillTransferDust -> payload.copy( + transfer = payload.transfer.copy( + amount = payload.transfer.amount + failureReason.dust + ) + ) + + is AssetTransferValidationFailure.FeeChangeDetected -> payload.copy( + transfer = payload.transfer.copy( + fee = payload.transfer.fee.replaceSubmissionFee(failureReason.payload.newFee) + ) + ) + + else -> payload +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/AssetSelectorBottomSheet.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/AssetSelectorBottomSheet.kt new file mode 100644 index 0000000..ecb8dd0 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/AssetSelectorBottomSheet.kt @@ -0,0 +1,74 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view + +import android.content.Context +import android.os.Bundle +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.ClickHandler +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_wallet_api.databinding.ItemAssetSelectorBinding +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.AssetSelectorModel + +class AssetSelectorBottomSheet( + private val title: String, + private val imageLoader: ImageLoader, + context: Context, + payload: Payload, + onClicked: ClickHandler +) : DynamicListBottomSheet( + context, + payload, + DiffCallback(), + onClicked +) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(title) + setSubtitle(null) + } + + override fun holderCreator(): HolderCreator = { parent -> + AssetSelectorHolder(ItemAssetSelectorBinding.inflate(parent.inflater(), parent, false), imageLoader) + } +} + +private class AssetSelectorHolder( + private val binder: ItemAssetSelectorBinding, + private val imageLoader: ImageLoader, +) : DynamicListSheetAdapter.Holder(binder.root) { + + override fun bind( + item: AssetSelectorModel, + isSelected: Boolean, + handler: DynamicListSheetAdapter.Handler + ) { + super.bind(item, isSelected, handler) + + with(itemView) { + binder.itemAssetSelectorBalance.setMaskableText(item.assetModel.assetBalance) + binder.itemAssetSelectorTokenName.text = item.title + binder.itemAssetSelectorIcon.setIcon(item.assetModel.icon, imageLoader) + binder.itemAssetSelectorRadioButton.isChecked = isSelected + } + } +} + +private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: AssetSelectorModel, newItem: AssetSelectorModel): Boolean { + return oldItem.assetModel.chainId == newItem.assetModel.chainId && + oldItem.assetModel.chainAssetId == newItem.assetModel.chainAssetId && + oldItem.additionalIdentifier == newItem.additionalIdentifier + } + + override fun areContentsTheSame(oldItem: AssetSelectorModel, newItem: AssetSelectorModel): Boolean { + return oldItem == newItem + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/AssetSelectorView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/AssetSelectorView.kt new file mode 100644 index 0000000..afec94c --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/AssetSelectorView.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import io.novafoundation.nova.common.presentation.masking.setMaskableText +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.getEnum +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.images.setIcon +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.shape.addRipple +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.common.view.shape.getIdleDrawable +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.databinding.ViewAssetSelectorBinding +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.assetSelector.AssetSelectorModel + +class AssetSelectorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + enum class BackgroundStyle { + BLURRED, BORDERED + } + + private val binder = ViewAssetSelectorBinding.inflate(inflater(), this) + + init { + attrs?.let { + applyAttributes(it) + } + } + + private fun applyAttributes(attributes: AttributeSet) = context.useAttributes(attributes, R.styleable.AssetSelectorView) { + val backgroundStyle: BackgroundStyle = it.getEnum(R.styleable.AssetSelectorView_backgroundStyle, BackgroundStyle.BORDERED) + + val actionIcon = it.getDrawable(R.styleable.AssetSelectorView_actionIcon) + actionIcon?.let(::setActionIcon) + + setBackgroundStyle(backgroundStyle) + } + + fun setActionIcon(drawable: Drawable) { + binder.assetSelectorAction.setImageDrawable(drawable) + } + + fun setBackgroundStyle(style: BackgroundStyle) = with(context) { + val baseBackground = when (style) { + BackgroundStyle.BLURRED -> getBlockDrawable() + BackgroundStyle.BORDERED -> getIdleDrawable() + } + + background = addRipple(baseBackground) + } + + fun onClick(action: (View) -> Unit) { + setOnClickListener(action) + } + + fun setState( + imageLoader: ImageLoader, + assetSelectorModel: AssetSelectorModel + ) { + with(assetSelectorModel) { + binder.assetSelectorBalance.setMaskableText(assetModel.assetBalance) + binder.assetSelectorTokenName.text = title + binder.assetSelectorIcon.setIcon(assetModel.icon, imageLoader) + } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/BalancesView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/BalancesView.kt new file mode 100644 index 0000000..729a5e7 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/BalancesView.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.annotation.StringRes +import io.novafoundation.nova.common.domain.ExtendedLoadingState +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.common.view.shape.getBlockDrawable +import io.novafoundation.nova.common.view.showLoadingState +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.databinding.ViewBalancesBinding +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +abstract class BalancesView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewBalancesBinding.inflate(inflater(), this) + + protected val expandableView + get() = binder.viewBalanceExpandableView + + init { + orientation = VERTICAL + + background = context.getBlockDrawable() + } + + fun setChain(chain: ChainUi) { + binder.viewBalanceChain.setChain(chain) + } + + fun setTotalBalance(token: CharSequence, fiat: CharSequence?) { + binder.viewBalanceToken.text = token + binder.viewBalanceFiat.setTextOrHide(fiat) + } + + protected fun item(@StringRes titleRes: Int): TableCellView { + val item = TableCellView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + + valueSecondary.setTextColorRes(R.color.text_secondary) + title.setTextColorRes(R.color.text_secondary) + setPadding(16.dp, 0, 16.dp, 0) + + isClickable = true // To not propagate parent state to children. isDuplicateParentState not working in this case + setTitle(titleRes) + } + + binder.viewBalanceExpandableContainer.addView(item) + + return item + } +} + +fun BalancesView.setTotalAmount(amountModel: AmountModel) { + setTotalBalance(amountModel.token, amountModel.fiat) +} + +fun TableCellView.showAmount(amountModel: AmountModel) { + showValue(amountModel.token, amountModel.fiat) +} + +fun TableCellView.showAmountOrHide(amountModel: AmountModel?) { + showValueOrHide(amountModel?.token, amountModel?.fiat) +} + +fun TableCellView.showLoadingAmount(model: ExtendedLoadingState) = showLoadingState(model, ::showAmountOrHide) diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/ConfirmTransactionView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/ConfirmTransactionView.kt new file mode 100644 index 0000000..94470b2 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/ConfirmTransactionView.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setBackgroundColorRes +import io.novafoundation.nova.common.view.PrimaryButton +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.databinding.ViewConfirmTransactionBinding + +class ConfirmTransactionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle) { + + private val binder = ViewConfirmTransactionBinding.inflate(inflater(), this) + + val fee: FeeView + get() = binder.confirmTransactionFee + + val submit: PrimaryButton + get() = binder.confirmTransactionAction + + init { + orientation = VERTICAL + + setBackgroundColorRes(R.color.secondary_screen_background) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/FeeView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/FeeView.kt new file mode 100644 index 0000000..d2c8260 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/FeeView.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view + +import android.content.Context +import android.util.AttributeSet +import io.novafoundation.nova.common.R +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus + +class FeeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : TableCellView(context, attrs, defStyle) { + + init { + // Super constructor might have set the title based on attrs + // We want to obey to attrs so only set the default fee label if attr value was not supplied + if (title.text.isNullOrBlank()) { + setTitle(R.string.network_fee) + } + } + + fun setFeeStatus(feeStatus: FeeStatus<*, FeeDisplay>) { + setVisible(feeStatus !is FeeStatus.NoFee) + + when (feeStatus) { + is FeeStatus.Loading -> { + if (feeStatus.visibleDuringProgress) { + showProgress() + } else { + setVisible(false) + } + } + + is FeeStatus.Error -> { + showValue(context.getString(R.string.common_error_general_title)) + } + + is FeeStatus.Loaded -> { + showFeeDisplay(feeStatus.feeModel.display) + } + + FeeStatus.NoFee -> { } + } + } + + fun setFeeDisplay(feeDisplay: FeeDisplay) { + setVisible(true) + showFeeDisplay(feeDisplay) + } + + private fun showFeeDisplay(feeDisplay: FeeDisplay) { + showValue(feeDisplay.title, feeDisplay.subtitle) + } + + fun setFeeEditable(editable: Boolean, onEditTokenClick: OnClickListener) { + if (editable) { + setPrimaryValueStartIcon(R.drawable.ic_pencil_edit, R.color.icon_secondary) + setOnValueClickListener(onEditTokenClick) + } else { + setPrimaryValueStartIcon(null) + setOnValueClickListener(null) + } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/PriceSectionView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/PriceSectionView.kt new file mode 100644 index 0000000..c740752 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/PriceSectionView.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view + +import android.content.Context +import android.util.AttributeSet +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.view.section.SectionView +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.databinding.SectionPriceBinding + +class PriceSectionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : SectionView(context, attrs, defStyleAttr) { + + private val binder = SectionPriceBinding.inflate(inflater(), this) + + init { + attrs?.let(::applyAttrs) + } + + fun setPrice(token: CharSequence, fiat: CharSequence?) { + binder.sectionPriceToken.text = token + binder.sectionPriceFiat.setTextOrHide(fiat) + } + + fun setTitle(title: String) { + binder.sectionTitle.text = title + } + + private fun applyAttrs(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.PriceSectionView) { + val title = it.getString(R.styleable.PriceSectionView_sectionTitle) + title?.let(::setTitle) + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/TotalAmountView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/TotalAmountView.kt new file mode 100644 index 0000000..9e5c995 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/TotalAmountView.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.databinding.ViewTotalAmountBinding +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class TotalAmountView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewTotalAmountBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + + attrs?.let(::applyAttributes) + } + + fun setAmount(amountModel: AmountModel?) { + setAmount(amountModel?.token, amountModel?.fiat) + } + + fun setAmount(token: CharSequence?, fiat: CharSequence?) { + binder.totalAmountToken.text = token + binder.totalAmountFiat.text = fiat + } + + private fun applyAttributes(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.TotalAmountView) { + val title = it.getString(R.styleable.TotalAmountView_title) + binder.totalAmountTitle.text = title + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/ChooseAmountInputView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/ChooseAmountInputView.kt new file mode 100644 index 0000000..09dac58 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/ChooseAmountInputView.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view.amount + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.EditText +import androidx.constraintlayout.widget.ConstraintLayout +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.view.shape.getInputBackground +import io.novafoundation.nova.common.view.shape.getInputBackgroundError +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.databinding.ViewChooseAmountInputBinding +import io.novafoundation.nova.feature_wallet_api.presentation.model.ChooseAmountInputModel + +class ChooseAmountInputView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle) { + + private val binder = ViewChooseAmountInputBinding.inflate(inflater(), this) + + val amountInput: EditText + get() = binder.chooseAmountInputField + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + background = context.getInputBackground() + } + + // To propagate state_focused from chooseAmountInputField + override fun childDrawableStateChanged(child: View) { + refreshDrawableState() + } + + // Allocate all the state chooseAmountInputField can have, e.g. state_focused and state_enabled + override fun onCreateDrawableState(extraSpace: Int): IntArray { + val fieldState: IntArray? = amountInput.drawableState + + val need = fieldState?.size ?: 0 + + val selfState = super.onCreateDrawableState(extraSpace + need) + + return mergeDrawableStates(selfState, fieldState) + } + + // Propagate state_enabled to children + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + binder.chooseAmountInputImage.alpha = if (enabled) 1f else 0.48f + + binder.chooseAmountInputField.isEnabled = enabled + binder.chooseAmountInputToken.isEnabled = enabled + binder.chooseAmountInputFiat.isEnabled = enabled + } + + fun loadAssetImage(icon: Icon) { + binder.chooseAmountInputImage.setTokenIcon(icon, imageLoader) + } + + fun setAssetName(name: String) { + binder.chooseAmountInputToken.text = name + } + + fun setFiatAmount(priceAmount: CharSequence?) { + binder.chooseAmountInputFiat.setTextOrHide(priceAmount) + } + + fun setErrorEnabled(enabled: Boolean) { + if (enabled) { + amountInput.setTextColor(context.getColor(R.color.text_negative)) + background = context.getInputBackgroundError() + } else { + amountInput.setTextColor(context.getColor(R.color.text_primary)) + background = context.getInputBackground() + } + } +} + +fun ChooseAmountInputView.setChooseAmountInputModel(model: ChooseAmountInputModel) { + loadAssetImage(model.tokenIcon) + setAssetName(model.tokenSymbol) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/ChooseAmountView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/ChooseAmountView.kt new file mode 100644 index 0000000..a0acae9 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/ChooseAmountView.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view.amount + +import android.content.Context +import android.util.AttributeSet +import android.widget.EditText +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.common.utils.useAttributes +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.common.validation.getReasonOrNull +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.databinding.ViewChooseAmountBinding +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountInputView +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxActionAvailability +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxAvailableView +import io.novafoundation.nova.feature_wallet_api.presentation.model.ChooseAmountModel + +class ChooseAmountView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : ConstraintLayout(context, attrs, defStyle), MaxAvailableView, AmountInputView { + + val binder = ViewChooseAmountBinding.inflate(inflater(), this) + + override val amountInput: EditText + get() = binder.chooseAmountInput.amountInput + + init { + attrs?.let(::applyAttrs) + } + + fun loadAssetImage(icon: Icon) { + binder.chooseAmountInput.loadAssetImage(icon) + } + + fun setTitle(title: String?) { + binder.chooseAmountTitle.setTextOrHide(title) + } + + fun setAssetName(name: String) { + binder.chooseAmountInput.setAssetName(name) + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + binder.chooseAmountInput.isEnabled = enabled + } + + override fun setFiatAmount(fiat: CharSequence?) { + binder.chooseAmountInput.setFiatAmount(fiat) + } + + override fun setError(errorState: FieldValidationResult) { + val isErrorEnabled = errorState is FieldValidationResult.Error + binder.chooseAmountInputError.isVisible = isErrorEnabled + binder.chooseAmountInputError.text = errorState.getReasonOrNull() + binder.chooseAmountInput.setErrorEnabled(errorState is FieldValidationResult.Error) + } + + override fun setMaxAmountDisplay(maxAmountDisplay: String?) { + binder.chooseAmountMaxButton.setMaxAmountDisplay(maxAmountDisplay) + } + + override fun setMaxActionAvailability(availability: MaxActionAvailability) { + binder.chooseAmountMaxButton.isVisible = availability is MaxActionAvailability.Available + + binder.chooseAmountMaxButton.setMaxActionAvailability(availability) + } + + private fun applyAttrs(attrs: AttributeSet) = context.useAttributes(attrs, R.styleable.ChooseAmountView) { + isEnabled = it.getBoolean(R.styleable.ChooseAmountView_android_enabled, true) + + val title = it.getString(R.styleable.ChooseAmountView_title) ?: context.getString(R.string.common_amount) + setTitle(title) + } +} + +fun ChooseAmountView.setChooseAmountModel(chooseAmountModel: ChooseAmountModel) { + binder.chooseAmountInput.setChooseAmountInputModel(chooseAmountModel.input) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/MaxAmountView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/MaxAmountView.kt new file mode 100644 index 0000000..6e12d31 --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/MaxAmountView.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view.amount + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.databinding.ViewMaxAmountBinding +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxActionAvailability +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxAvailableView + +class MaxAmountView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), MaxAvailableView { + + private val binder = ViewMaxAmountBinding.inflate(inflater(), this) + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + + override fun setMaxAmountDisplay(maxAmountDisplay: String?) = letOrHide(maxAmountDisplay) { display -> + binder.viewMaxAmountValue.text = display + } + + override fun setMaxActionAvailability(availability: MaxActionAvailability) { + when (availability) { + is MaxActionAvailability.Available -> { + setOnClickListener(availability.onMaxClicked) + binder.viewMaxAmountAction.setTextColorRes(R.color.button_text_accent) + } + + MaxActionAvailability.NotAvailable -> { + setOnClickListener(null) + binder.viewMaxAmountAction.setTextColorRes(R.color.text_primary) + } + } + } +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/PrimaryAmountView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/PrimaryAmountView.kt new file mode 100644 index 0000000..df7aefb --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/amount/PrimaryAmountView.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view.amount + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import androidx.annotation.ColorRes +import io.novafoundation.nova.common.presentation.LoadingView +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.letOrHide +import io.novafoundation.nova.common.utils.makeGone +import io.novafoundation.nova.common.utils.makeVisible +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.utils.setTextOrHide +import io.novafoundation.nova.feature_wallet_api.databinding.ViewPrimaryAmountBinding +import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel + +class PrimaryAmountView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : LinearLayout(context, attrs, defStyle), LoadingView { + + private val binder = ViewPrimaryAmountBinding.inflate(inflater(), this) + + init { + orientation = VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + } + + fun setAmount(amountModel: AmountModel) { + binder.primaryAmountToken.makeVisible() + binder.primaryAmountFiat.makeVisible() + binder.primaryAmountProgress.makeGone() + + binder.primaryAmountToken.text = amountModel.token + binder.primaryAmountFiat.setTextOrHide(amountModel.fiat) + } + + fun setTokenAmountTextColor(@ColorRes textColor: Int) { + binder.primaryAmountToken.setTextColorRes(textColor) + } + + override fun showLoading() { + binder.primaryAmountToken.makeGone() + binder.primaryAmountFiat.makeGone() + binder.primaryAmountProgress.makeVisible() + } + + override fun showData(data: AmountModel) = setAmount(data) +} + +fun PrimaryAmountView.setAmountOrHide(model: AmountModel?) = letOrHide(model) { nonNullModel -> + setAmount(nonNullModel) +} diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/extrinsic/GenericExtrinsicInformationView.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/extrinsic/GenericExtrinsicInformationView.kt new file mode 100644 index 0000000..5c968cd --- /dev/null +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/presentation/view/extrinsic/GenericExtrinsicInformationView.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_wallet_api.presentation.view.extrinsic + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import io.novafoundation.nova.common.address.AddressModel +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.TableView +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.view.showAddress +import io.novafoundation.nova.feature_wallet_api.databinding.ViewGenericExtrinsicInformationBinding +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus +import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView + +class GenericExtrinsicInformationView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0, +) : TableView(context, attrs, defStyle) { + + private val binder = ViewGenericExtrinsicInformationBinding.inflate(inflater(), this) + + val fee: FeeView + get() = binder.viewGenericExtrinsicInformationFee + + fun setOnAccountClickedListener(action: (View) -> Unit) { + binder.viewGenericExtrinsicInformationAccount.setOnClickListener(action) + } + + fun setWallet(walletModel: WalletModel) { + binder.viewGenericExtrinsicInformationWallet.showWallet(walletModel) + } + + fun setAccount(addressModel: AddressModel) { + binder.viewGenericExtrinsicInformationAccount.showAddress(addressModel) + } + + fun setFeeStatus(feeStatus: FeeStatus<*, FeeDisplay>) { + binder.viewGenericExtrinsicInformationFee.setFeeStatus(feeStatus) + } +} diff --git a/feature-wallet-api/src/main/res/layout/item_asset_selector.xml b/feature-wallet-api/src/main/res/layout/item_asset_selector.xml new file mode 100644 index 0000000..135ce2f --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/item_asset_selector.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/section_price.xml b/feature-wallet-api/src/main/res/layout/section_price.xml new file mode 100644 index 0000000..e9b76a0 --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/section_price.xml @@ -0,0 +1,50 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/view_asset_selector.xml b/feature-wallet-api/src/main/res/layout/view_asset_selector.xml new file mode 100644 index 0000000..d1399db --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/view_asset_selector.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/view_balances.xml b/feature-wallet-api/src/main/res/layout/view_balances.xml new file mode 100644 index 0000000..0d5936c --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/view_balances.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/view_choose_amount.xml b/feature-wallet-api/src/main/res/layout/view_choose_amount.xml new file mode 100644 index 0000000..129b5f1 --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/view_choose_amount.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/view_choose_amount_input.xml b/feature-wallet-api/src/main/res/layout/view_choose_amount_input.xml new file mode 100644 index 0000000..3a08414 --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/view_choose_amount_input.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/view_confirm_transaction.xml b/feature-wallet-api/src/main/res/layout/view_confirm_transaction.xml new file mode 100644 index 0000000..47d5afd --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/view_confirm_transaction.xml @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/view_generic_extrinsic_information.xml b/feature-wallet-api/src/main/res/layout/view_generic_extrinsic_information.xml new file mode 100644 index 0000000..b61ba5c --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/view_generic_extrinsic_information.xml @@ -0,0 +1,26 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/view_max_amount.xml b/feature-wallet-api/src/main/res/layout/view_max_amount.xml new file mode 100644 index 0000000..ddaa526 --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/view_max_amount.xml @@ -0,0 +1,27 @@ + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/view_primary_amount.xml b/feature-wallet-api/src/main/res/layout/view_primary_amount.xml new file mode 100644 index 0000000..258d905 --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/view_primary_amount.xml @@ -0,0 +1,35 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/layout/view_total_amount.xml b/feature-wallet-api/src/main/res/layout/view_total_amount.xml new file mode 100644 index 0000000..af8e6b7 --- /dev/null +++ b/feature-wallet-api/src/main/res/layout/view_total_amount.xml @@ -0,0 +1,44 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/main/res/values/attrs.xml b/feature-wallet-api/src/main/res/values/attrs.xml new file mode 100644 index 0000000..f5b7dde --- /dev/null +++ b/feature-wallet-api/src/main/res/values/attrs.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-api/src/test/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/ValidatingBalanceTest.kt b/feature-wallet-api/src/test/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/ValidatingBalanceTest.kt new file mode 100644 index 0000000..f454301 --- /dev/null +++ b/feature-wallet-api/src/test/java/io/novafoundation/nova/feature_wallet_api/domain/validation/balance/ValidatingBalanceTest.kt @@ -0,0 +1,212 @@ +package io.novafoundation.nova.feature_wallet_api.domain.validation.balance + +import io.novafoundation.nova.common.domain.balance.EDCountingMode +import io.novafoundation.nova.common.domain.balance.TransferableMode +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.validation.balance.ValidatingBalance.BalancePreservation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner + +private val TEST_ED = 10.toBigInteger() +private val ED_COUNTING_MODE = EDCountingMode.FREE +private val TRANSFERABLE_MODE = TransferableMode.HOLDS_AND_FREEZES + +@RunWith(MockitoJUnitRunner::class) +class ValidatingBalanceTest { + + @Mock + lateinit var chainAsset: Chain.Asset + + @Test + fun shouldWithdrawWithoutCrossingEd() { + val initial = createBalance(free = 100, frozen = 0, reserved = 0) + val actual = initial.tryWithdraw(10, BalancePreservation.KEEP_ALIVE) + + assertDeductSuccess(free = 90, reserved = 0, frozen = 0, actual = actual) + } + + @Test + fun shouldWithdrawCrossingEd() { + val initial = createBalance(free = 11, frozen = 0, reserved = 0) + val actual = initial.tryWithdraw(2, BalancePreservation.ALLOW_DEATH) + + assertDeductSuccess(free = 0, reserved = 0, frozen = 0, actual = actual) + } + + @Test + fun shouldWithdrawFailingToCrossEd() { + val initial = createBalance(free = 11, frozen = 0, reserved = 0) + val actual = initial.tryWithdraw(2, BalancePreservation.KEEP_ALIVE) + + // After fixing ed-related imbalance, the resulting free should just be ed + assertDeductFailure(free = 10, reserved = 0, frozen = 0, negativeImbalance = 1, actual = actual) + } + + @Test + fun withdrawShouldChooseMaxOfTwoImbalances() { + val initial = createBalance(free = 20, frozen = 10, reserved = 5) + assetBalanceEquals(20, initial.assetBalance.countedTowardsEd) + // See holdAndFreezesTransferable + assetBalanceEquals(15, initial.assetBalance.transferable) + + var actual = initial.tryWithdraw(25, BalancePreservation.ALLOW_DEATH) + // Since AllowDeath is used, countedTowardsEd check does not apply - we are just 10 tokens short due to transferable + // Note: free < ed since reserved > 0. See reservedPreventsDusting + assertDeductFailure(negativeImbalance = 10, free = 5, frozen = 10, reserved = 5, actual) + + actual = initial.tryWithdraw(25, BalancePreservation.KEEP_ALIVE) + // Now due to KEEP_ALIVE we are 15 tokens short due to countedTowardsEd + assertDeductFailure(negativeImbalance = 15, free = 10, frozen = 10, reserved = 5, actual) + } + + @Test + fun shouldDoSimpleReserve() { + val initial = createBalance(free = 20, frozen = 0, reserved = 0) + val actual = initial.tryReserve(5) + + assertDeductSuccess(free = 15, frozen = 0, reserved = 5, actual) + } + + @Test + fun shouldFailReserve() { + val initial = createBalance(free = 20, frozen = 0, reserved = 0) + assetBalanceEquals(10, initial.reservable()) + + val actual = initial.tryReserve(15) + + assertDeductFailure(negativeImbalance = 5, free = 10, frozen = 0, reserved = 15, actual) + } + + @Test + fun shouldDoAroundEdReserve() { + val initial = createBalance(free = 20, frozen = 5, reserved = 10) + val actual = initial.tryReserve(10) + + assertDeductSuccess(free = 10, frozen = 5, reserved = 20, actual) + } + + @Test + fun shouldFreeze() { + val initial = createBalance(free = 20, frozen = 5, reserved = 10) + assetBalanceEquals(30, initial.assetBalance.total) + val actual = initial.tryFreeze(30) + + assertDeductSuccess(free = 20, frozen = 30, reserved = 10, actual) + } + + @Test + fun shouldFailFreeze() { + val initial = createBalance(free = 20, frozen = 5, reserved = 10) + assetBalanceEquals(30, initial.assetBalance.total) + val actual = initial.tryFreeze(35) + + assertDeductFailure(negativeImbalance = 5, free = 25, frozen = 35, reserved = 10, actual) + } + + // This test describe cross-chain transfer case where we first pay fee, then withdraw the transfer amount and then withdraw the delivery fee + @Test + fun shouldCombineMultipleWithdraws() { + val initial = createBalance(free = 20, frozen = 0, reserved = 0) + + val actual = initial + .tryWithdraw(5, BalancePreservation.KEEP_ALIVE) // aka withdraw fee. New free is 15 + .tryWithdraw(20, BalancePreservation.KEEP_ALIVE) // aka withdraw transfer amount. We are 15 tokens short here. Fixed imbalance results in ed (10) in free + .tryWithdraw(15, BalancePreservation.ALLOW_DEATH) // aka withdraw delivery fee. We are 5 tokens short here + + assertDeductFailure(negativeImbalance = 20, free = 0, frozen = 0, reserved = 0, actual) + } + + // This test describe cross-chain transfer case where we first pay fee, then withdraw the transfer amount and then withdraw the delivery fee + @Test + fun shouldCombineMultipleDifferentDeductions() { + val initial = createBalance(free = 20, frozen = 0, reserved = 0) + + val actual = initial + .tryWithdraw(5, BalancePreservation.KEEP_ALIVE) // aka withdraw fee. New free is 15 + .tryReserve(10) // we are 5 tokens short here (reservable = 15) + .tryFreeze(10) // this succeeds after fixing previous imbalance + + assertDeductFailure(negativeImbalance = 5, free = 10, frozen = 10, reserved = 10, actual) + } + + private fun ValidatingBalance.reservable(): Balance { + return assetBalance.reservable(existentialDeposit) + } + + private fun BalanceValidationResult.tryWithdraw(amount: Int, preservation: BalancePreservation): BalanceValidationResult { + return tryWithdraw(amount.toBigInteger(), preservation) + } + + private fun BalanceValidationResult.tryFreeze(amount: Int): BalanceValidationResult { + return tryFreeze(amount.toBigInteger()) + } + + private fun BalanceValidationResult.tryReserve(amount: Int): BalanceValidationResult { + return tryReserve(amount.toBigInteger()) + } + + private fun ValidatingBalance.tryWithdraw(amount: Int, preservation: BalancePreservation): BalanceValidationResult { + return tryWithdraw(amount.toBigInteger(), preservation) + } + + private fun ValidatingBalance.tryReserve(amount: Int): BalanceValidationResult { + return tryReserve(amount.toBigInteger()) + } + + private fun ValidatingBalance.tryFreeze(amount: Int): BalanceValidationResult { + return tryFreeze(amount.toBigInteger()) + } + + private fun assetBalanceEquals(expected: Int, actual: Balance) { + assertEquals(expected.toBigInteger(), actual) + } + + private fun assertDeductSuccess( + free: Int, + frozen: Int, + reserved: Int, + actual: BalanceValidationResult + ) { + assert(actual is BalanceValidationResult.Success) + actual as BalanceValidationResult.Success + + assertEquals(free.toBigInteger(), actual.newBalance.assetBalance.free) + assertEquals(frozen.toBigInteger(), actual.newBalance.assetBalance.frozen) + assertEquals(reserved.toBigInteger(), actual.newBalance.assetBalance.reserved) + } + + // free, frozen, reserved - amount of successful deduction when negativeImbalance is satisfied + private fun assertDeductFailure( + negativeImbalance: Int, + free: Int, + frozen: Int, + reserved: Int, + actual: BalanceValidationResult + ) { + assert(actual is BalanceValidationResult.Failure) + actual as BalanceValidationResult.Failure + + assertEquals(negativeImbalance.toBigInteger(), actual.negativeImbalance.value) + assertEquals(free.toBigInteger(), actual.newBalanceAfterFixingImbalance.assetBalance.free) + assertEquals(frozen.toBigInteger(), actual.newBalanceAfterFixingImbalance.assetBalance.frozen) + assertEquals(reserved.toBigInteger(), actual.newBalanceAfterFixingImbalance.assetBalance.reserved) + } + + private fun createBalance(free: Int, frozen: Int, reserved: Int) : ValidatingBalance { + val balance = ChainAssetBalance( + chainAsset = chainAsset, + free = free.toBigInteger(), + reserved = reserved.toBigInteger(), + frozen = frozen.toBigInteger(), + transferableMode = TRANSFERABLE_MODE, + edCountingMode = ED_COUNTING_MODE + ) + + return ValidatingBalance(balance, TEST_ED) + } +} diff --git a/feature-wallet-connect-api/.gitignore b/feature-wallet-connect-api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-wallet-connect-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-wallet-connect-api/build.gradle b/feature-wallet-connect-api/build.gradle new file mode 100644 index 0000000..c415142 --- /dev/null +++ b/feature-wallet-connect-api/build.gradle @@ -0,0 +1,23 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_wallet_connect_api' + +} + +dependencies { + implementation project(':feature-account-api') + implementation project(':feature-external-sign-api') + implementation project(':feature-deep-linking') + + implementation project(':common') + + implementation coroutinesDep + + implementation androidDep + implementation materialDep + implementation constraintDep + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-wallet-connect-api/consumer-rules.pro b/feature-wallet-connect-api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-wallet-connect-api/proguard-rules.pro b/feature-wallet-connect-api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-wallet-connect-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-wallet-connect-api/src/main/AndroidManifest.xml b/feature-wallet-connect-api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-wallet-connect-api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/di/WalletConnectFeatureApi.kt b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/di/WalletConnectFeatureApi.kt new file mode 100644 index 0000000..dd532d5 --- /dev/null +++ b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/di/WalletConnectFeatureApi.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_connect_api.di + +import io.novafoundation.nova.feature_wallet_connect_api.di.deeplinks.WalletConnectDeepLinks +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService + +interface WalletConnectFeatureApi { + + val walletConnectService: WalletConnectService + + val sessionsUseCase: WalletConnectSessionsUseCase + + val walletConnectDeepLinks: WalletConnectDeepLinks +} diff --git a/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/di/deeplinks/WalletConnectDeepLinks.kt b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/di/deeplinks/WalletConnectDeepLinks.kt new file mode 100644 index 0000000..c12ca46 --- /dev/null +++ b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/di/deeplinks/WalletConnectDeepLinks.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.feature_wallet_connect_api.di.deeplinks + +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler + +class WalletConnectDeepLinks(val deepLinkHandlers: List) diff --git a/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/domain/sessions/WalletConnectSessionsUseCase.kt b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/domain/sessions/WalletConnectSessionsUseCase.kt new file mode 100644 index 0000000..f496b79 --- /dev/null +++ b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/domain/sessions/WalletConnectSessionsUseCase.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_wallet_connect_api.domain.sessions + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import kotlinx.coroutines.flow.Flow + +interface WalletConnectSessionsUseCase { + + fun activeSessionsNumberFlow(): Flow + + fun activeSessionsNumberFlow(metaAccount: MetaAccount): Flow + + suspend fun activeSessionsNumber(): Int + + suspend fun syncActiveSessions() +} diff --git a/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/presentation/WalletConnectService.kt b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/presentation/WalletConnectService.kt new file mode 100644 index 0000000..d6237a5 --- /dev/null +++ b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/presentation/WalletConnectService.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_wallet_connect_api.presentation + +import androidx.lifecycle.LiveData +import io.novafoundation.nova.common.utils.Event + +interface WalletConnectService { + + val onPairErrorLiveData: LiveData> + + fun connect() + + fun disconnect() + + fun pair(uri: String) +} diff --git a/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/presentation/WalletConnectSessionsModel.kt b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/presentation/WalletConnectSessionsModel.kt new file mode 100644 index 0000000..3cd6b96 --- /dev/null +++ b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/presentation/WalletConnectSessionsModel.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_connect_api.presentation + +import io.novafoundation.nova.common.utils.formatting.format + +class WalletConnectSessionsModel(val connections: String?) + +fun mapNumberOfActiveSessionsToUi(activeSessions: Int): WalletConnectSessionsModel { + return if (activeSessions > 0) { + WalletConnectSessionsModel(activeSessions.format()) + } else { + WalletConnectSessionsModel(null) + } +} diff --git a/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/presentation/utils/WalletConnectUtils.kt b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/presentation/utils/WalletConnectUtils.kt new file mode 100644 index 0000000..7460234 --- /dev/null +++ b/feature-wallet-connect-api/src/main/java/io/novafoundation/nova/feature_wallet_connect_api/presentation/utils/WalletConnectUtils.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_connect_api.presentation.utils + +import android.net.Uri + +object WalletConnectUtils { + fun isWalletConnectPairingLink(data: Uri): Boolean { + val isPezkuwiLink = data.scheme == "pezkuwiwallet" && data.host == "wc" + val isLinkFromOtherSource = data.scheme == "wc" + val isWalletConnectLink = isPezkuwiLink || isLinkFromOtherSource + + val isPairing = "symKey" in data.toString() + return isWalletConnectLink && isPairing + } +} diff --git a/feature-wallet-connect-impl/.gitignore b/feature-wallet-connect-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-wallet-connect-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-wallet-connect-impl/build.gradle b/feature-wallet-connect-impl/build.gradle new file mode 100644 index 0000000..54e8fff --- /dev/null +++ b/feature-wallet-connect-impl/build.gradle @@ -0,0 +1,79 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: "../scripts/secrets.gradle" + +android { + namespace 'io.novafoundation.nova.feature_wallet_connect_impl' + + + defaultConfig { + + + + buildConfigField "String", "WALLET_CONNECT_PROJECT_ID", readStringSecret("WALLET_CONNECT_PROJECT_ID") + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + packagingOptions { + resources.excludes.add("META-INF/NOTICE.md") + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-account-api') + implementation project(':feature-wallet-api') + implementation project(':feature-external-sign-api') + implementation project(':feature-currency-api') + implementation project(':feature-wallet-connect-api') + implementation project(':feature-dapp-api') + implementation project(':caip') + implementation project(':runtime') + implementation project(':feature-deep-linking') + + implementation kotlinDep + + + implementation androidDep + implementation materialDep + implementation constraintDep + + implementation shimmerDep + + implementation coroutinesDep + + implementation gsonDep + + implementation daggerDep + + ksp daggerCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation retrofitDep + + implementation web3jDep + implementation coroutinesFutureDep + + implementation walletConnectCoreDep, withoutTransitiveAndroidX + implementation walletConnectWalletDep, withoutTransitiveAndroidX + + testImplementation jUnitDep + testImplementation mockitoDep +} diff --git a/feature-wallet-connect-impl/consumer-rules.pro b/feature-wallet-connect-impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-wallet-connect-impl/proguard-rules.pro b/feature-wallet-connect-impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-wallet-connect-impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-wallet-connect-impl/src/main/AndroidManifest.xml b/feature-wallet-connect-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/WalletConnectRouter.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/WalletConnectRouter.kt new file mode 100644 index 0000000..637fa96 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/WalletConnectRouter.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_wallet_connect_impl + +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsPayload +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsPayload + +interface WalletConnectRouter : ReturnableRouter { + + fun openSessionDetails(payload: WalletConnectSessionDetailsPayload) + + fun openScanPairingQrCode() + + fun backToSettings() + + fun openWalletConnectSessions(payload: WalletConnectSessionsPayload) +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/data/model/evm/WalletConnectEvmTransaction.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/data/model/evm/WalletConnectEvmTransaction.kt new file mode 100644 index 0000000..3fe82c7 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/data/model/evm/WalletConnectEvmTransaction.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.data.model.evm + +import com.google.gson.annotations.SerializedName + +class WalletConnectEvmTransaction( + val from: String, + val to: String, + val data: String?, + val nonce: String?, + val gasPrice: String?, + @SerializedName("gasLimit", alternate = ["gas"]) + val gasLimit: String?, + val value: String?, +) diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/data/repository/WalletConnectPairingRepository.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/data/repository/WalletConnectPairingRepository.kt new file mode 100644 index 0000000..ea28f32 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/data/repository/WalletConnectPairingRepository.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.data.repository + +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.WalletConnectSessionsDao +import io.novafoundation.nova.core_db.model.WalletConnectPairingLocal +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectPairingAccount +import kotlinx.coroutines.flow.Flow + +interface WalletConnectPairingRepository { + + suspend fun addPairingAccount(pairingAccount: WalletConnectPairingAccount) + + suspend fun getPairingAccount(pairingTopic: String): WalletConnectPairingAccount? + + fun allPairingAccountsFlow(): Flow> + + fun pairingAccountsByMetaIdFlow(metaId: Long): Flow> + + suspend fun removeAllPairingsOtherThan(activePairingTopics: List) +} + +class RealWalletConnectPairingRepository( + private val dao: WalletConnectSessionsDao, +) : WalletConnectPairingRepository { + + override suspend fun addPairingAccount(pairingAccount: WalletConnectPairingAccount) { + dao.insertPairing(mapSessionToLocal(pairingAccount)) + } + + override suspend fun getPairingAccount(pairingTopic: String): WalletConnectPairingAccount? { + return dao.getPairing(pairingTopic)?.let(::mapSessionFromLocal) + } + + override fun allPairingAccountsFlow(): Flow> { + return dao.allPairingsFlow().mapList(::mapSessionFromLocal) + } + + override fun pairingAccountsByMetaIdFlow(metaId: Long): Flow> { + return dao.pairingsByMetaIdFlow(metaId).mapList(::mapSessionFromLocal) + } + + override suspend fun removeAllPairingsOtherThan(activePairingTopics: List) { + dao.removeAllPairingsOtherThan(activePairingTopics) + } + + private fun mapSessionToLocal(session: WalletConnectPairingAccount): WalletConnectPairingLocal { + return with(session) { + WalletConnectPairingLocal(pairingTopic = pairingTopic, metaId = metaId) + } + } + + private fun mapSessionFromLocal(sessionLocal: WalletConnectPairingLocal): WalletConnectPairingAccount { + return with(sessionLocal) { + WalletConnectPairingAccount(pairingTopic = pairingTopic, metaId = metaId) + } + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/data/repository/WalletConnectSessionRepository.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/data/repository/WalletConnectSessionRepository.kt new file mode 100644 index 0000000..aa221b6 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/data/repository/WalletConnectSessionRepository.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.data.repository + +import com.walletconnect.web3.wallet.client.Wallet.Model.Session +import io.novafoundation.nova.common.utils.added +import io.novafoundation.nova.common.utils.removed +import io.novafoundation.nova.common.utils.singleReplaySharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +interface WalletConnectSessionRepository { + + suspend fun setSessions(sessions: List) + + suspend fun getSession(sessionTopic: String): Session? + + fun allSessionsFlow(): Flow> + + fun sessionFlow(sessionTopic: String): Flow + + suspend fun addSession(session: Session) + + suspend fun removeSession(sessionTopic: String) + + fun numberOfSessionsFlow(): Flow + + suspend fun numberOfSessionAccounts(): Int + + fun numberOfSessionsFlow(pairingTopics: Set): Flow +} + +class InMemoryWalletConnectSessionRepository : WalletConnectSessionRepository { + + private val state = singleReplaySharedFlow>() + + override suspend fun setSessions(sessions: List) { + state.emit(sessions) + } + + override suspend fun getSession(sessionTopic: String): Session? { + return state.first().find { it.topic == sessionTopic } + } + + override fun allSessionsFlow(): Flow> { + return state + } + + override fun sessionFlow(sessionTopic: String): Flow { + return state.map { allSessions -> allSessions.find { it.topic == sessionTopic } } + } + + override suspend fun addSession(session: Session) { + modifyState { current -> + current.added(session) + } + } + + override suspend fun removeSession(sessionTopic: String) { + modifyState { current -> + current.removed { it.topic == sessionTopic } + } + } + + override fun numberOfSessionsFlow(): Flow { + return state.map { it.size } + } + + override fun numberOfSessionsFlow(pairingTopics: Set): Flow { + return state.map { sessions -> + sessions.filter { it.pairingTopic in pairingTopics }.size + } + } + + override suspend fun numberOfSessionAccounts(): Int { + return state.first().size + } + + private suspend fun modifyState(modify: (List) -> List) { + state.emit(modify(state.first())) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureComponent.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureComponent.kt new file mode 100644 index 0000000..f88feab --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureComponent.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.caip.di.CaipApi +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan.di.WalletConnectScanComponent +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.di.WalletConnectApproveSessionComponent +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.di.WalletConnectSessionDetailsComponent +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.di.WalletConnectSessionsComponent +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + WalletConnectFeatureDependencies::class + ], + modules = [ + WalletConnectFeatureModule::class + ] +) +@FeatureScope +interface WalletConnectFeatureComponent : WalletConnectFeatureApi { + + fun walletConnectSessionsComponentFactory(): WalletConnectSessionsComponent.Factory + + fun walletConnectSessionDetailsComponentFactory(): WalletConnectSessionDetailsComponent.Factory + + fun walletConnectApproveSessionComponentFactory(): WalletConnectApproveSessionComponent.Factory + + fun walletConnectScanComponentFactory(): WalletConnectScanComponent.Factory + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance router: WalletConnectRouter, + @BindsInstance signCommunicator: ExternalSignCommunicator, + @BindsInstance approveSessionCommunicator: ApproveSessionCommunicator, + deps: WalletConnectFeatureDependencies + ): WalletConnectFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + AccountFeatureApi::class, + RuntimeApi::class, + CaipApi::class, + ExternalSignFeatureApi::class + ] + ) + interface WalletConnectFeatureDependenciesComponent : WalletConnectFeatureDependencies +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureDependencies.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureDependencies.kt new file mode 100644 index 0000000..20f5394 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureDependencies.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.di + +import android.content.Context +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.caip.caip2.Caip2Parser +import io.novafoundation.nova.caip.caip2.Caip2Resolver +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.core_db.dao.WalletConnectSessionsDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin +import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +interface WalletConnectFeatureDependencies { + + val accountRepository: AccountRepository + + val resourceManager: ResourceManager + + val selectedAccountUseCase: SelectedAccountUseCase + + val addressIconGenerator: AddressIconGenerator + + val gson: Gson + + val chainRegistry: ChainRegistry + + val imageLoader: ImageLoader + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val walletUiUseCase: WalletUiUseCase + + val permissionsAskerFactory: PermissionsAskerFactory + + val caip2Resolver: Caip2Resolver + + val caip2Parser: Caip2Parser + + val evmTypedMessageParser: EvmTypedMessageParser + + val sessionsDao: WalletConnectSessionsDao + + val selectWalletMixinFactory: SelectWalletMixin.Factory + + val appContext: Context + + val automaticInteractionGate: AutomaticInteractionGate + + fun rootScope(): RootScope +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureHolder.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureHolder.kt new file mode 100644 index 0000000..b85ba5a --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureHolder.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.di + +import io.novafoundation.nova.caip.di.CaipApi +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class WalletConnectFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, + private val router: WalletConnectRouter, + private val signCommunicator: ExternalSignCommunicator, + private val approveSessionCommunicator: ApproveSessionCommunicator, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val deps = DaggerWalletConnectFeatureComponent_WalletConnectFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .caipApi(getFeature(CaipApi::class.java)) + .externalSignFeatureApi(getFeature(ExternalSignFeatureApi::class.java)) + .build() + + return DaggerWalletConnectFeatureComponent.factory() + .create(router, signCommunicator, approveSessionCommunicator, deps) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureModule.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureModule.kt new file mode 100644 index 0000000..015b7a7 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/WalletConnectFeatureModule.kt @@ -0,0 +1,124 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.di + +import android.content.Context +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.caip.caip2.Caip2Parser +import io.novafoundation.nova.caip.caip2.Caip2Resolver +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.coroutines.RootScope +import io.novafoundation.nova.core_db.dao.WalletConnectSessionsDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.InMemoryWalletConnectSessionRepository +import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.RealWalletConnectPairingRepository +import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectPairingRepository +import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectSessionRepository +import io.novafoundation.nova.feature_wallet_connect_impl.di.deeplinks.DeepLinkModule +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.RealWalletConnectSessionInteractor +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.RealWalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.CompoundWalletConnectRequestFactory +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm.EvmWalletConnectRequestFactory +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.polkadot.PolkadotWalletConnectRequestFactory +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.service.RealWalletConnectService +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.RealWalletConnectSessionMapper +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper + +@Module(includes = [DeepLinkModule::class]) +class WalletConnectFeatureModule { + + @Provides + @FeatureScope + fun providePolkadotRequestFactory( + gson: Gson, + caip2Parser: Caip2Parser, + appContext: Context + ): PolkadotWalletConnectRequestFactory { + return PolkadotWalletConnectRequestFactory(gson, caip2Parser, appContext) + } + + @Provides + @FeatureScope + fun provideEvmRequestFactory( + gson: Gson, + caip2Parser: Caip2Parser, + typedMessageParser: EvmTypedMessageParser, + appContext: Context + ): EvmWalletConnectRequestFactory { + return EvmWalletConnectRequestFactory(gson, caip2Parser, typedMessageParser, appContext) + } + + @Provides + @FeatureScope + fun provideRequestFactory( + polkadotFactory: PolkadotWalletConnectRequestFactory, + evmFactory: EvmWalletConnectRequestFactory, + ): WalletConnectRequest.Factory { + return CompoundWalletConnectRequestFactory(polkadotFactory, evmFactory) + } + + @Provides + @FeatureScope + fun providePairingRepository(dao: WalletConnectSessionsDao): WalletConnectPairingRepository = RealWalletConnectPairingRepository(dao) + + @Provides + @FeatureScope + fun provideSessionRepository(): WalletConnectSessionRepository = InMemoryWalletConnectSessionRepository() + + @Provides + @FeatureScope + fun provideInteractor( + caip2Resolver: Caip2Resolver, + requestFactory: WalletConnectRequest.Factory, + walletConnectPairingRepository: WalletConnectPairingRepository, + walletConnectSessionRepository: WalletConnectSessionRepository, + accountRepository: AccountRepository, + caip2Parser: Caip2Parser + ): WalletConnectSessionInteractor = RealWalletConnectSessionInteractor( + caip2Resolver = caip2Resolver, + walletConnectRequestFactory = requestFactory, + walletConnectPairingRepository = walletConnectPairingRepository, + accountRepository = accountRepository, + caip2Parser = caip2Parser, + walletConnectSessionRepository = walletConnectSessionRepository + ) + + @Provides + @FeatureScope + fun provideWalletConnectService( + rootScope: RootScope, + interactor: WalletConnectSessionInteractor, + dAppSignRequester: ExternalSignCommunicator, + approveSessionCommunicator: ApproveSessionCommunicator, + ): WalletConnectService { + return RealWalletConnectService( + parentScope = rootScope, + interactor = interactor, + dAppSignRequester = dAppSignRequester, + approveSessionRequester = approveSessionCommunicator + ) + } + + @Provides + @FeatureScope + fun provideSessionMapper(resourceManager: ResourceManager): WalletConnectSessionMapper { + return RealWalletConnectSessionMapper(resourceManager) + } + + @Provides + @FeatureScope + fun provideSessionUseCase( + pairingRepository: WalletConnectPairingRepository, + sessionRepository: WalletConnectSessionRepository + ): WalletConnectSessionsUseCase { + return RealWalletConnectSessionsUseCase(pairingRepository, sessionRepository) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/deeplinks/DeepLinkModule.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/deeplinks/DeepLinkModule.kt new file mode 100644 index 0000000..2590ef4 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/di/deeplinks/DeepLinkModule.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.di.deeplinks + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.feature_wallet_connect_api.di.deeplinks.WalletConnectDeepLinks +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.deeplink.WalletConnectPairDeeplinkHandler + +@Module +class DeepLinkModule { + + @Provides + @FeatureScope + fun provideWalletConnectDeepLinkHandler( + walletConnectService: WalletConnectService, + automaticInteractionGate: AutomaticInteractionGate + ) = WalletConnectPairDeeplinkHandler( + walletConnectService, + automaticInteractionGate + ) + + @Provides + @FeatureScope + fun provideDeepLinks(buyCallback: WalletConnectPairDeeplinkHandler): WalletConnectDeepLinks { + return WalletConnectDeepLinks(listOf(buyCallback)) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/SessionChains.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/SessionChains.kt new file mode 100644 index 0000000..36a9a1f --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/SessionChains.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.model + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class SessionChains( + val required: ResolvedChains, + val optional: ResolvedChains, +) { + + class ResolvedChains(val knownChains: Set, val unknownChains: Set) +} + +fun SessionChains.allKnownChains(): Set { + return required.knownChains + optional.knownChains +} + +fun SessionChains.allUnknownChains(): Set { + return required.unknownChains + optional.unknownChains +} + +fun SessionChains.ResolvedChains.hasUnknown(): Boolean { + return unknownChains.isNotEmpty() +} + +fun SessionChains.ResolvedChains.hasKnown(): Boolean { + return knownChains.isNotEmpty() +} + +fun SessionChains.ResolvedChains.hasAny(): Boolean { + return hasUnknown() || hasKnown() +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/WalletConnectPairingAccount.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/WalletConnectPairingAccount.kt new file mode 100644 index 0000000..cc2d933 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/WalletConnectPairingAccount.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.model + +class WalletConnectPairingAccount( + val metaId: Long, + val pairingTopic: String +) diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/WalletConnectSession.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/WalletConnectSession.kt new file mode 100644 index 0000000..569033b --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/WalletConnectSession.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.model + +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class WalletConnectSession( + val connectedMetaAccount: MetaAccount, + val dappMetadata: SessionDappMetadata?, + val sessionTopic: String, +) + +class SessionDappMetadata( + val dAppUrl: String, + val icon: String?, + val name: String? +) + +val SessionDappMetadata.dAppTitle: String + get() = name ?: dAppUrl + +class WalletConnectSessionDetails( + val connectedMetaAccount: MetaAccount, + val dappMetadata: SessionDappMetadata?, + val chains: Set, + val sessionTopic: String, + val status: SessionStatus, +) { + + enum class SessionStatus { + + ACTIVE, EXPIRED + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/WalletConnectSessionProposal.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/WalletConnectSessionProposal.kt new file mode 100644 index 0000000..280b684 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/model/WalletConnectSessionProposal.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.model + +class WalletConnectSessionProposal( + val resolvedChains: SessionChains, + val dappMetadata: SessionDappMetadata +) diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/sdk/WalletConnectExt.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/sdk/WalletConnectExt.kt new file mode 100644 index 0000000..b07dd9d --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/sdk/WalletConnectExt.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk + +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Wallet.Model.Namespace.Session +import com.walletconnect.web3.wallet.client.Wallet.Model.SessionProposal +import com.walletconnect.web3.wallet.client.Wallet.Params.SessionApprove +import com.walletconnect.web3.wallet.client.Web3Wallet +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +fun SessionProposal.approved(namespaces: Map): SessionApprove { + return SessionApprove( + proposerPublicKey = proposerPublicKey, + namespaces = namespaces, + relayProtocol = relayProtocol + ) +} + +fun SessionProposal.rejected(reason: String): Wallet.Params.SessionReject { + return Wallet.Params.SessionReject( + proposerPublicKey = proposerPublicKey, + reason = reason + ) +} + +fun Wallet.Model.SessionRequest.approved(result: String): Wallet.Params.SessionRequestResponse { + return Wallet.Params.SessionRequestResponse( + sessionTopic = topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult( + id = request.id, + result = result + ) + ) +} + +class WalletConnectError(val code: Int, override val message: String) : Throwable() { + + companion object { + val REJECTED = WalletConnectError(5000, "Rejected by user") + + val GENERAL_FAILURE = WalletConnectError(0, "Unknown error") + + val NO_SESSION_FOR_TOPIC = WalletConnectError(7001, "No session for topic") + + val UNAUTHORIZED_METHOD = WalletConnectError(3001, "Unauthorized method") + + val CHAIN_MISMATCH = WalletConnectError(1001, "Wrong chain id passed by dApp") + + fun UnknownMethod(method: String) = WalletConnectError(3001, "$method is not supported") + } +} + +fun Wallet.Model.SessionRequest.failed(error: WalletConnectError): Wallet.Params.SessionRequestResponse { + return Wallet.Params.SessionRequestResponse( + sessionTopic = topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( + id = request.id, + code = error.code, + message = error.message + ) + ) +} + +fun Wallet.Model.SessionRequest.rejected(): Wallet.Params.SessionRequestResponse { + return failed(WalletConnectError.REJECTED) +} + +suspend fun Web3Wallet.approveSession(approve: SessionApprove): Result { + return suspendCoroutine { continuation -> + approveSession( + params = approve, + onSuccess = { continuation.resume(Result.success(Unit)) }, + onError = { continuation.resume(Result.failure(it.throwable)) } + ) + } +} + +suspend fun Web3Wallet.rejectSession(reject: Wallet.Params.SessionReject): Result { + return suspendCoroutine { continuation -> + rejectSession( + params = reject, + onSuccess = { continuation.resume(Result.success(Unit)) }, + onError = { continuation.resume(Result.failure(it.throwable)) } + ) + } +} + +suspend fun Web3Wallet.disconnectSession(sessionTopic: String): Result { + return suspendCoroutine { continuation -> + disconnectSession( + params = Wallet.Params.SessionDisconnect(sessionTopic), + onSuccess = { continuation.resume(Result.success(Unit)) }, + onError = { continuation.resume(Result.failure(it.throwable)) } + ) + } +} + +suspend fun Web3Wallet.respondSessionRequest(response: Wallet.Params.SessionRequestResponse): Result { + return suspendCoroutine { continuation -> + respondSessionRequest( + params = response, + onSuccess = { continuation.resume(Result.success(Unit)) }, + onError = { continuation.resume(Result.failure(it.throwable)) } + ) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/RealWalletConnectSessionInteractor.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/RealWalletConnectSessionInteractor.kt new file mode 100644 index 0000000..40d3ece --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/RealWalletConnectSessionInteractor.kt @@ -0,0 +1,312 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session + +import com.walletconnect.android.Core +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Wallet.Model.Namespace +import com.walletconnect.web3.wallet.client.Wallet.Model.SessionProposal +import com.walletconnect.web3.wallet.client.Web3Wallet +import io.novafoundation.nova.caip.caip2.Caip2Parser +import io.novafoundation.nova.caip.caip2.Caip2Resolver +import io.novafoundation.nova.caip.caip2.isValidCaip2 +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.common.utils.mapValuesNotNull +import io.novafoundation.nova.common.utils.toImmutable +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectPairingRepository +import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectSessionRepository +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.SessionChains +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.SessionDappMetadata +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectPairingAccount +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSession +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionDetails +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionDetails.SessionStatus +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionProposal +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.WalletConnectError +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approveSession +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.disconnectSession +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.rejectSession +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.rejected +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap + +private typealias KnownChainsBuilder = MutableSet +private typealias UnknownChainsBuilder = MutableSet +private typealias Caip2ChainId = String + +class RealWalletConnectSessionInteractor( + private val caip2Resolver: Caip2Resolver, + private val caip2Parser: Caip2Parser, + private val walletConnectRequestFactory: WalletConnectRequest.Factory, + private val walletConnectPairingRepository: WalletConnectPairingRepository, + private val walletConnectSessionRepository: WalletConnectSessionRepository, + private val accountRepository: AccountRepository, +) : WalletConnectSessionInteractor { + + private val pendingSessionSettlementsByPairingTopic = ConcurrentHashMap() + + override suspend fun approveSession( + sessionProposal: SessionProposal, + metaAccount: MetaAccount + ): Result = withContext(Dispatchers.Default) { + val requestedNameSpaces = sessionProposal.requiredNamespaces mergeWith sessionProposal.optionalNamespaces + + val chainsByCaip2 = caip2Resolver.chainsByCaip2() + + val namespaceSessions = requestedNameSpaces.mapValuesNotNull { (namespaceRaw, namespaceProposal) -> + val requestedChains = if (caip2Parser.isValidCaip2(namespaceRaw)) { + listOf(namespaceRaw) + } else { + namespaceProposal.chains + } ?: return@mapValuesNotNull null + + val supportedChainsWithAccounts = requestedChains.mapNotNull { requestedChain -> + val chain = chainsByCaip2[requestedChain] ?: return@mapNotNull null + val address = metaAccount.addressIn(chain) ?: return@mapNotNull null + + formatWalletConnectAccount(address, requestedChain) to requestedChain + } + + Namespace.Session( + chains = supportedChainsWithAccounts.map { (_, chain) -> chain }, + accounts = supportedChainsWithAccounts.map { (address, _) -> address }, + methods = namespaceProposal.methods, + events = namespaceProposal.events + ) + } + + val response = sessionProposal.approved(namespaceSessions) + + Web3Wallet.approveSession(response) + .onSuccess { registerPendingSettlement(sessionProposal, metaAccount) } + } + + override suspend fun resolveSessionProposal(sessionProposal: SessionProposal): WalletConnectSessionProposal = withContext(Dispatchers.Default) { + val chainsByCaip2 = caip2Resolver.chainsByCaip2() + + WalletConnectSessionProposal( + resolvedChains = SessionChains( + required = chainsByCaip2.resolveChains(sessionProposal.requiredNamespaces.caip2ChainsByNamespace()), + optional = chainsByCaip2.resolveChains(sessionProposal.optionalNamespaces.caip2ChainsByNamespace()) + ), + dappMetadata = SessionDappMetadata( + dAppUrl = sessionProposal.url, + icon = sessionProposal.icons.firstOrNull()?.toString(), + name = sessionProposal.name + ) + ) + } + + override suspend fun rejectSession(proposal: SessionProposal): Result = withContext(Dispatchers.Default) { + val response = proposal.rejected("Rejected by user") + + Web3Wallet.rejectSession(response) + } + + override suspend fun parseSessionRequest(request: Wallet.Model.SessionRequest): Result = runCatching { + withContext(Dispatchers.Default) { + walletConnectRequestFactory.create(request) ?: throw WalletConnectError.UnknownMethod(request.request.method) + } + } + + override suspend fun onSessionSettled(settledSessionResponse: Wallet.Model.SettledSessionResponse) { + if (settledSessionResponse !is Wallet.Model.SettledSessionResponse.Result) return + + val pairingTopic = settledSessionResponse.session.pairingTopic + val pendingSessionSettlement = pendingSessionSettlementsByPairingTopic[pairingTopic] ?: return + + val walletConnectPairingAccount = WalletConnectPairingAccount(metaId = pendingSessionSettlement.metaId, pairingTopic = pairingTopic) + walletConnectPairingRepository.addPairingAccount(walletConnectPairingAccount) + walletConnectSessionRepository.addSession(settledSessionResponse.session) + } + + override suspend fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { + if (sessionDelete !is Wallet.Model.SessionDelete.Success) return + + walletConnectSessionRepository.removeSession(sessionDelete.topic) + } + + override suspend fun getSession(sessionTopic: String): Wallet.Model.Session? { + return walletConnectSessionRepository.getSession(sessionTopic) + } + + override suspend fun getPairingAccount(pairingTopic: String): WalletConnectPairingAccount? { + return walletConnectPairingRepository.getPairingAccount(pairingTopic) + } + + override fun activeSessionsFlow(metaId: Long?): Flow> { + return combine( + walletConnectSessionRepository.allSessionsFlow(), + walletConnectPairingRepository.allPairingAccountsFlow() + ) { activeSessions, pairingAccounts -> + val metaAccounts = if (metaId == null) { + accountRepository.getActiveMetaAccounts() + } else { + listOf(accountRepository.getMetaAccount(metaId)) + } + + createWalletSessions(activeSessions, metaAccounts, pairingAccounts) + } + } + + override fun activeSessionFlow(sessionTopic: String): Flow { + val sessionFlow = walletConnectSessionRepository.sessionFlow(sessionTopic) + val chainsWrappedFlow = flowOf { caip2Resolver.chainsByCaip2() } + + return combine(sessionFlow, chainsWrappedFlow) { session, chainsByCaip2 -> + if (session == null) return@combine null + + val pairingAccount = walletConnectPairingRepository.getPairingAccount(session.pairingTopic) ?: return@combine null + val metaAccount = accountRepository.getMetaAccount(pairingAccount.metaId) + + createWalletSessionDetails(session, metaAccount, chainsByCaip2) + } + } + + override suspend fun disconnect(sessionTopic: String): Result<*> { + return withContext(Dispatchers.Default) { + Web3Wallet.disconnectSession(sessionTopic).onSuccess { + walletConnectSessionRepository.removeSession(sessionTopic) + } + } + } + + private infix fun Map.mergeWith(other: Map): Map { + val allNamespaceKeys = keys + other.keys + + return allNamespaceKeys.associateWith { namespace -> + val thisProposal = get(namespace) + val otherProposal = other[namespace] + + thisProposal.orEmpty() + otherProposal.orEmpty() + } + } + + private operator fun Namespace.Proposal.plus(other: Namespace.Proposal): Namespace.Proposal { + val mergedChains = chains.orEmpty() + other.chains.orEmpty() + val mergedMethods = methods + other.methods + val mergedEvents = events + other.events + return Namespace.Proposal( + chains = mergedChains.distinct(), + methods = mergedMethods.distinct(), + events = mergedEvents.distinct() + ) + } + + private fun Namespace.Proposal?.orEmpty(): Namespace.Proposal { + return this ?: Namespace.Proposal( + chains = null, + methods = emptyList(), + events = emptyList() + ) + } + + private fun formatWalletConnectAccount(address: String, chainCaip2: String): String { + return "$chainCaip2:$address" + } + + private fun registerPendingSettlement(sessionProposal: SessionProposal, metaAccount: MetaAccount) { + pendingSessionSettlementsByPairingTopic[sessionProposal.pairingTopic] = PendingSessionSettlement(metaAccount.id) + } + + private class PendingSessionSettlement(val metaId: Long) + + private fun createWalletSessions( + sessions: List, + metaAccounts: List, + pairingAccounts: List + ): List { + val metaAccountsById = metaAccounts.associateBy(MetaAccount::id) + val pairingAccountByPairingTopic = pairingAccounts.associateBy(WalletConnectPairingAccount::pairingTopic) + + return sessions.mapNotNull { session -> + val pairingAccount = pairingAccountByPairingTopic[session.pairingTopic] ?: return@mapNotNull null + val metaAccount = metaAccountsById[pairingAccount.metaId] ?: return@mapNotNull null + + WalletConnectSession( + connectedMetaAccount = metaAccount, + dappMetadata = session.metaData?.let(::mapAppMetadataToSessionMetadata), + sessionTopic = session.topic + ) + } + } + + private fun createWalletSessionDetails( + session: Wallet.Model.Session, + metaAccount: MetaAccount, + chainsByCaip2: Map, + ): WalletConnectSessionDetails { + return WalletConnectSessionDetails( + connectedMetaAccount = metaAccount, + dappMetadata = session.metaData?.let(::mapAppMetadataToSessionMetadata), + sessionTopic = session.topic, + chains = chainsByCaip2.resolveChains(session.namespaces.caip2ChainsByNamespace()).knownChains, + status = determineSessionStatus(session) + ) + } + + private fun Map.resolveChains(namespaces: Map?>): SessionChains.ResolvedChains { + val knownChainsBuilder = mutableSetOf() + val unknownChainsBuilder = mutableSetOf() + + namespaces.forEach { (namespaceName, namespaceChains) -> + if (caip2Parser.isValidCaip2(namespaceName)) { + resolveChain(namespaceName, knownChainsBuilder, unknownChainsBuilder) + return@forEach + } + + namespaceChains.orEmpty().forEach { chainCaip2 -> + resolveChain(chainCaip2, knownChainsBuilder, unknownChainsBuilder) + } + } + + return SessionChains.ResolvedChains(knownChainsBuilder.toImmutable(), unknownChainsBuilder.toImmutable()) + } + + private fun Map.resolveChain( + chainCaip2: String, + knownChainsBuilder: KnownChainsBuilder, + unknownChainsBuilder: UnknownChainsBuilder + ) { + val newChain = get(chainCaip2) + + if (newChain != null) { + knownChainsBuilder.add(newChain) + } else { + unknownChainsBuilder.add(chainCaip2) + } + } + + private fun mapAppMetadataToSessionMetadata(metadata: Core.Model.AppMetaData): SessionDappMetadata { + return SessionDappMetadata( + dAppUrl = metadata.url, + icon = metadata.icons.firstOrNull(), + name = metadata.name + ) + } + + private fun determineSessionStatus(session: Wallet.Model.Session): SessionStatus { + return if (session.expiry > System.currentTimeMillis()) { + SessionStatus.EXPIRED + } else { + SessionStatus.ACTIVE + } + } + + @JvmName("caip2ChainsByNamespaceForProposal") + private fun Map.caip2ChainsByNamespace(): Map?> { + return mapValues { it.value.chains } + } + + @JvmName("caip2ChainsByNamespaceForSession") + private fun Map.caip2ChainsByNamespace(): Map?> { + return mapValues { it.value.chains } + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/RealWalletConnectSessionsUseCase.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/RealWalletConnectSessionsUseCase.kt new file mode 100644 index 0000000..86690ec --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/RealWalletConnectSessionsUseCase.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session + +import com.walletconnect.android.CoreClient +import com.walletconnect.web3.wallet.client.Web3Wallet +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectPairingRepository +import io.novafoundation.nova.feature_wallet_connect_impl.data.repository.WalletConnectSessionRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.withContext + +internal class RealWalletConnectSessionsUseCase( + private val pairingRepository: WalletConnectPairingRepository, + private val sessionRepository: WalletConnectSessionRepository, +) : WalletConnectSessionsUseCase { + + override fun activeSessionsNumberFlow(): Flow { + return sessionRepository.numberOfSessionsFlow() + } + + override fun activeSessionsNumberFlow(metaAccount: MetaAccount): Flow { + return pairingRepository.pairingAccountsByMetaIdFlow(metaAccount.id).flatMapLatest { pairings -> + val pairingTopics = pairings.mapToSet { it.pairingTopic } + sessionRepository.numberOfSessionsFlow(pairingTopics) + } + } + + override suspend fun activeSessionsNumber(): Int { + return sessionRepository.numberOfSessionAccounts() + } + + override suspend fun syncActiveSessions() = withContext(Dispatchers.Default) { + val activePairingTopics = CoreClient.Pairing.getPairings() + .filter { it.isActive } + .map { it.topic } + + pairingRepository.removeAllPairingsOtherThan(activePairingTopics) + + val activeSessionTopics = Web3Wallet.getListOfActiveSessions() + sessionRepository.setSessions(activeSessionTopics) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/WalletConnectSessionInteractor.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/WalletConnectSessionInteractor.kt new file mode 100644 index 0000000..7c3aab6 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/WalletConnectSessionInteractor.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session + +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Wallet.Model.SessionProposal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectPairingAccount +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSession +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionDetails +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionProposal +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest +import kotlinx.coroutines.flow.Flow + +interface WalletConnectSessionInteractor { + + suspend fun approveSession( + sessionProposal: SessionProposal, + metaAccount: MetaAccount + ): Result + + suspend fun resolveSessionProposal(sessionProposal: SessionProposal): WalletConnectSessionProposal + + suspend fun rejectSession(proposal: SessionProposal): Result + + suspend fun parseSessionRequest(request: Wallet.Model.SessionRequest): Result + + suspend fun onSessionSettled(settledSessionResponse: Wallet.Model.SettledSessionResponse) + + suspend fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) + + suspend fun getSession(sessionTopic: String): Wallet.Model.Session? + + suspend fun getPairingAccount(pairingTopic: String): WalletConnectPairingAccount? + + fun activeSessionsFlow(metaId: Long?): Flow> + + fun activeSessionFlow(sessionTopic: String): Flow + + suspend fun disconnect(sessionTopic: String): Result<*> +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/Base.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/Base.kt new file mode 100644 index 0000000..36a9934 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/Base.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests + +import android.content.Context +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Web3Wallet +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.WalletConnectError +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.failed +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.rejected +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.respondSessionRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +abstract class BaseWalletConnectRequest( + private val sessionRequest: Wallet.Model.SessionRequest, + private val context: Context, +) : WalletConnectRequest { + + override val id: String = sessionRequest.request.id.toString() + + abstract suspend fun sentResponse(response: ExternalSignCommunicator.Response.Sent): Wallet.Params.SessionRequestResponse + + abstract suspend fun signedResponse(response: ExternalSignCommunicator.Response.Signed): Wallet.Params.SessionRequestResponse + + override suspend fun respondWith(response: ExternalSignCommunicator.Response): Result<*> = kotlin.runCatching { + withContext(Dispatchers.Default) { + val walletConnectResponse = when (response) { + is ExternalSignCommunicator.Response.Rejected -> sessionRequest.rejected() + is ExternalSignCommunicator.Response.Sent -> sentResponse(response) + is ExternalSignCommunicator.Response.Signed -> signedResponse(response) + is ExternalSignCommunicator.Response.SigningFailed -> sessionRequest.failed(WalletConnectError.GENERAL_FAILURE) + } + + Web3Wallet.respondSessionRequest(walletConnectResponse).getOrThrow() + + // TODO this code is untested since no dapp currently use redirect param + // We cant really enable this code without testing since we need to verify a corner-case when wc is used with redirect param inside dapp browser + // This might potentially break user flow since it might direct user to external browser instead of staying in our dapp browser + +// val redirect = sessionRequest.peerMetaData?.redirect +// if (!redirect.isNullOrEmpty()) { +// context.startActivity(Intent(Intent.ACTION_VIEW, redirect.toUri())) +// } + } + } +} + +abstract class SignWalletConnectRequest( + sessionRequest: Wallet.Model.SessionRequest, + context: Context +) : BaseWalletConnectRequest(sessionRequest, context) { + + override suspend fun sentResponse(response: ExternalSignCommunicator.Response.Sent): Wallet.Params.SessionRequestResponse { + error("Expected Signed response, got: Sent") + } +} + +abstract class SendTxWalletConnectRequest( + sessionRequest: Wallet.Model.SessionRequest, + context: Context +) : BaseWalletConnectRequest(sessionRequest, context) { + + override suspend fun signedResponse(response: ExternalSignCommunicator.Response.Signed): Wallet.Params.SessionRequestResponse { + error("Expected Sent response, got: Signed") + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/CompoundWalletConnectRequestFactory.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/CompoundWalletConnectRequestFactory.kt new file mode 100644 index 0000000..28144f7 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/CompoundWalletConnectRequestFactory.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests + +import com.walletconnect.web3.wallet.client.Wallet +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull + +class CompoundWalletConnectRequestFactory( + private val nestedFactories: List +) : WalletConnectRequest.Factory { + + override fun create(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest? { + return nestedFactories.tryFindNonNull { it.create(sessionRequest) } + } +} + +fun CompoundWalletConnectRequestFactory(vararg factories: WalletConnectRequest.Factory): CompoundWalletConnectRequestFactory { + return CompoundWalletConnectRequestFactory(factories.toList()) +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/WalletConnectRequest.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/WalletConnectRequest.kt new file mode 100644 index 0000000..aa5f4df --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/WalletConnectRequest.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests + +import com.walletconnect.web3.wallet.client.Wallet +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest + +interface WalletConnectRequest { + + interface Factory { + + fun create(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest? + } + + val id: String + + suspend fun respondWith(response: ExternalSignCommunicator.Response): Result<*> + + fun toExternalSignRequest(): ExternalSignRequest +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmPersonalSignRequest.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmPersonalSignRequest.kt new file mode 100644 index 0000000..99b283e --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmPersonalSignRequest.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm + +import android.content.Context +import com.walletconnect.web3.wallet.client.Wallet +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmPersonalSignMessage +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SignWalletConnectRequest + +class EvmPersonalSignRequest( + private val originAddress: String, + private val message: EvmPersonalSignMessage, + private val sessionRequest: Wallet.Model.SessionRequest, + context: Context +) : SignWalletConnectRequest(sessionRequest, context) { + + override suspend fun signedResponse(response: ExternalSignCommunicator.Response.Signed): Wallet.Params.SessionRequestResponse { + return sessionRequest.approved(response.signature) + } + + override fun toExternalSignRequest(): ExternalSignRequest { + val signPayload = EvmSignPayload.PersonalSign(message, originAddress) + + return ExternalSignRequest.Evm(id, signPayload) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmSendTransactionRequest.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmSendTransactionRequest.kt new file mode 100644 index 0000000..b5f7fdd --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmSendTransactionRequest.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm + +import android.content.Context +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Wallet.Params.SessionRequestResponse +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator.Response +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTransaction +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SendTxWalletConnectRequest + +class EvmSendTransactionRequest( + private val transaction: EvmTransaction.Struct, + private val chainId: Int, + private val sessionRequest: Wallet.Model.SessionRequest, + context: Context +) : SendTxWalletConnectRequest(sessionRequest, context) { + + override suspend fun sentResponse(response: Response.Sent): SessionRequestResponse { + return sessionRequest.approved(response.txHash) + } + + override fun toExternalSignRequest(): ExternalSignRequest { + val signPayload = EvmSignPayload.ConfirmTx( + transaction = transaction, + originAddress = transaction.from, + chainSource = EvmChainSource(chainId, EvmChainSource.UnknownChainOptions.MustBeKnown), + action = EvmSignPayload.ConfirmTx.Action.SEND + ) + + return ExternalSignRequest.Evm(id, signPayload) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmSignTransactionRequest.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmSignTransactionRequest.kt new file mode 100644 index 0000000..b50765a --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmSignTransactionRequest.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm + +import android.content.Context +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Wallet.Params.SessionRequestResponse +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator.Response +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTransaction +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SignWalletConnectRequest + +class EvmSignTransactionRequest( + private val transaction: EvmTransaction.Struct, + private val chainId: Int, + private val sessionRequest: Wallet.Model.SessionRequest, + context: Context +) : SignWalletConnectRequest(sessionRequest, context) { + + override suspend fun signedResponse(response: Response.Signed): SessionRequestResponse { + return sessionRequest.approved(response.signature) + } + + override fun toExternalSignRequest(): ExternalSignRequest { + val signPayload = EvmSignPayload.ConfirmTx( + transaction = transaction, + originAddress = transaction.from, + chainSource = EvmChainSource(chainId, EvmChainSource.UnknownChainOptions.MustBeKnown), + action = EvmSignPayload.ConfirmTx.Action.SIGN + ) + + return ExternalSignRequest.Evm(id, signPayload) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmSignTypedDataRequest.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmSignTypedDataRequest.kt new file mode 100644 index 0000000..a52b2d7 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmSignTypedDataRequest.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm + +import android.content.Context +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Wallet.Params.SessionRequestResponse +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator.Response +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SignWalletConnectRequest + +class EvmSignTypedDataRequest( + private val originAddress: String, + private val typedMessage: EvmTypedMessage, + private val sessionRequest: Wallet.Model.SessionRequest, + context: Context +) : SignWalletConnectRequest(sessionRequest, context) { + + override suspend fun signedResponse(response: Response.Signed): SessionRequestResponse { + return sessionRequest.approved(response.signature) + } + + override fun toExternalSignRequest(): ExternalSignRequest { + val signPayload = EvmSignPayload.SignTypedMessage(typedMessage, originAddress) + + return ExternalSignRequest.Evm(id, signPayload) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmWalletConnectRequestFactory.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmWalletConnectRequestFactory.kt new file mode 100644 index 0000000..61a6de7 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/evm/EvmWalletConnectRequestFactory.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.evm + +import android.content.Context +import com.google.gson.Gson +import com.walletconnect.web3.wallet.client.Wallet +import io.novafoundation.nova.caip.caip2.Caip2Parser +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmPersonalSignMessage +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTransaction +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage +import io.novafoundation.nova.feature_wallet_connect_impl.data.model.evm.WalletConnectEvmTransaction +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest + +class EvmWalletConnectRequestFactory( + private val gson: Gson, + private val caip2Parser: Caip2Parser, + private val typedMessageParser: EvmTypedMessageParser, + private val context: Context +) : WalletConnectRequest.Factory { + + override fun create(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest? { + val request = sessionRequest.request + + return when (request.method) { + "eth_sendTransaction" -> parseEvmSendTx(sessionRequest, sessionRequest.eipChainId()) + + "eth_signTransaction" -> parseEvmSignTx(sessionRequest, sessionRequest.eipChainId()) + + "eth_signTypedData", "eth_signTypedData_v4" -> parseEvmSignTypedMessage(sessionRequest) + + "personal_sign" -> parsePersonalSign(sessionRequest) + + else -> null + } + } + + private fun parsePersonalSign(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest { + val (message, address) = gson.fromJson>(sessionRequest.request.params) + val personalSignMessage = EvmPersonalSignMessage(message) + + return EvmPersonalSignRequest(address, personalSignMessage, sessionRequest, context) + } + + private fun parseEvmSignTypedMessage(sessionRequest: Wallet.Model.SessionRequest): WalletConnectRequest { + val (address, typedMessage) = parseEvmSignTypedDataParams(sessionRequest.request.params) + + return EvmSignTypedDataRequest(address, typedMessage, sessionRequest, context) + } + + private fun parseEvmSendTx(sessionRequest: Wallet.Model.SessionRequest, chainId: Int): WalletConnectRequest { + val transaction = parseStructTransaction(sessionRequest.request.params) + + return EvmSendTransactionRequest(transaction, chainId, sessionRequest, context) + } + + private fun parseEvmSignTx(sessionRequest: Wallet.Model.SessionRequest, chainId: Int): WalletConnectRequest { + val transaction = parseStructTransaction(sessionRequest.request.params) + + return EvmSignTransactionRequest(transaction, chainId, sessionRequest, context) + } + + private fun Wallet.Model.SessionRequest.eipChainId(): Int { + return chainId?.let(::extractEvmChainId) + ?: error("No chain id supplied for ${request.method}") + } + + private fun parseStructTransaction(params: String): EvmTransaction.Struct { + val parsed: WalletConnectEvmTransaction = parseSingleEvmParameter(params) + + return with(parsed) { + EvmTransaction.Struct( + gas = gasLimit, + gasPrice = gasPrice, + from = from, + to = to, + data = data, + value = value, + nonce = nonce + ) + } + } + + private fun parseEvmSignTypedDataParams(params: String): Pair { + // params = ["addressParam", structuredDataObject] + val (addressParam, structuredData) = params.removeSurrounding("[", "]").split(',', limit = 2) + val address = addressParam.removeSurrounding("\"") + + val evmTypedMessage = typedMessageParser.parseEvmTypedMessage(structuredData) + + return address to evmTypedMessage + } + + private inline fun , reified I> parseSingleEvmParameter(params: String): I { + // gson.fromJson>(params) does not work even with inlining - gson ignores inner list types and creates hash map instead + val parsed = gson.fromJson(params) + + return parsed.first() + } + + private fun extractEvmChainId(caip2: String): Int? { + return caip2Parser.parseCaip2(caip2) + .getOrNull() + ?.castOrNull() + ?.chainId?.toInt() + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/polkadot/PolkadotSignRequest.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/polkadot/PolkadotSignRequest.kt new file mode 100644 index 0000000..bee96dc --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/polkadot/PolkadotSignRequest.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.polkadot + +import android.content.Context +import com.google.gson.Gson +import com.walletconnect.web3.wallet.client.Wallet +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignerResult +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.approved +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.SignWalletConnectRequest + +class PolkadotSignRequest( + private val gson: Gson, + private val polkadotSignPayload: PolkadotSignPayload, + private val sessionRequest: Wallet.Model.SessionRequest, + context: Context +) : SignWalletConnectRequest(sessionRequest, context) { + + override suspend fun signedResponse(response: ExternalSignCommunicator.Response.Signed): Wallet.Params.SessionRequestResponse { + val responseData = PolkadotSignerResult(id, signature = response.signature, response.modifiedTransaction) + val responseJson = gson.toJson(responseData) + + return sessionRequest.approved(responseJson) + } + + override fun toExternalSignRequest(): ExternalSignRequest { + return ExternalSignRequest.Polkadot(id, polkadotSignPayload) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/polkadot/PolkadotWalletConnectRequestFactory.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/polkadot/PolkadotWalletConnectRequestFactory.kt new file mode 100644 index 0000000..7161afb --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/domain/session/requests/polkadot/PolkadotWalletConnectRequestFactory.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.polkadot + +import android.content.Context +import com.google.gson.Gson +import com.walletconnect.web3.wallet.client.Wallet.Model.SessionRequest +import io.novafoundation.nova.caip.caip2.Caip2Parser +import io.novafoundation.nova.caip.caip2.identifier.Caip2Identifier +import io.novafoundation.nova.caip.caip2.parseCaip2OrThrow +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignPayload +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.WalletConnectError +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.requests.WalletConnectRequest + +class PolkadotWalletConnectRequestFactory( + private val gson: Gson, + private val caip2Parser: Caip2Parser, + private val context: Context +) : WalletConnectRequest.Factory { + + override fun create(sessionRequest: SessionRequest): WalletConnectRequest? { + val request = sessionRequest.request + + return when (request.method) { + "polkadot_signTransaction" -> parseSignTransactionRequest(sessionRequest) + + "polkadot_signMessage" -> parseSignMessageRequest(sessionRequest) + + else -> null + } + } + + private fun parseSignTransactionRequest(sessionRequest: SessionRequest): WalletConnectRequest { + val signTxPayload = gson.fromJson(sessionRequest.request.params) + + val caip2FromPayload = Caip2Identifier.Polkadot(signTxPayload.transactionPayload.genesisHash) + val caip2FromChainId = caip2Parser.parseCaip2OrThrow(requireNotNull(sessionRequest.chainId)) + + if (caip2FromChainId != caip2FromPayload) throw WalletConnectError.CHAIN_MISMATCH + + return PolkadotSignRequest( + gson = gson, + polkadotSignPayload = signTxPayload.transactionPayload, + sessionRequest = sessionRequest, + context = context + ) + } + + private fun parseSignMessageRequest(sessionRequest: SessionRequest): WalletConnectRequest { + val signMessagePayload = gson.fromJson(sessionRequest.request.params) + + return PolkadotSignRequest( + gson = gson, + polkadotSignPayload = PolkadotSignPayload.Raw( + data = signMessagePayload.message, + address = signMessagePayload.address, + type = null + ), + sessionRequest = sessionRequest, + context = context + ) + } +} + +private class SignTransaction(val transactionPayload: PolkadotSignPayload.Json) + +private class SignMessage(val address: String, val message: String) diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/deeplink/WalletConnectPairDeeplinkHandler.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/deeplink/WalletConnectPairDeeplinkHandler.kt new file mode 100644 index 0000000..b36003b --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/deeplink/WalletConnectPairDeeplinkHandler.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.deeplink + +import android.net.Uri +import io.novafoundation.nova.common.utils.sequrity.AutomaticInteractionGate +import io.novafoundation.nova.common.utils.sequrity.awaitInteractionAllowed +import io.novafoundation.nova.feature_deep_linking.presentation.handling.CallbackEvent +import io.novafoundation.nova.feature_deep_linking.presentation.handling.DeepLinkHandler +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.feature_wallet_connect_api.presentation.utils.WalletConnectUtils +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class WalletConnectPairDeeplinkHandler( + private val walletConnectService: WalletConnectService, + private val automaticInteractionGate: AutomaticInteractionGate +) : DeepLinkHandler { + + override val callbackFlow: Flow = emptyFlow() + + override suspend fun matches(data: Uri): Boolean { + return WalletConnectUtils.isWalletConnectPairingLink(data) + } + + override suspend fun handleDeepLink(data: Uri) = runCatching { + automaticInteractionGate.awaitInteractionAllowed() + walletConnectService.pair(data.toString()) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/WalletConnectScanFragment.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/WalletConnectScanFragment.kt new file mode 100644 index 0000000..25a5355 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/WalletConnectScanFragment.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan + +import android.view.View +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.presentation.scan.ScanQrFragment +import io.novafoundation.nova.common.presentation.scan.ScanView +import io.novafoundation.nova.common.utils.insets.applyStatusBarInsets +import io.novafoundation.nova.common.utils.setDrawableStart +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.feature_wallet_connect_impl.R +import io.novafoundation.nova.feature_wallet_connect_impl.databinding.FragmentWcScanBinding +import io.novafoundation.nova.feature_wallet_connect_impl.di.WalletConnectFeatureComponent + +class WalletConnectScanFragment : ScanQrFragment() { + + override fun createBinding() = FragmentWcScanBinding.inflate(layoutInflater) + + override fun inject() { + FeatureUtils.getFeature(requireContext(), WalletConnectFeatureApi::class.java) + .walletConnectScanComponentFactory() + .create(this) + .inject(this) + } + + override fun applyInsets(rootView: View) { + binder.walletConnectScanToolbar.applyStatusBarInsets() + } + + override fun initViews() { + super.initViews() + + binder.walletConnectScanToolbar.setHomeButtonListener { viewModel.backClicked() } + + scanView.subtitle.setDrawableStart(R.drawable.ic_wallet_connect, widthInDp = 24, paddingInDp = 2, tint = R.color.icon_primary) + scanView.subtitle.setTextAppearance(R.style.TextAppearance_NovaFoundation_SemiBold_SubHeadline) + scanView.subtitle.setTextColorRes(R.color.text_primary) + } + + override val scanView: ScanView + get() = binder.walletConnectScan +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/WalletConnectScanViewModel.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/WalletConnectScanViewModel.kt new file mode 100644 index 0000000..7a91540 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/WalletConnectScanViewModel.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan + +import io.novafoundation.nova.common.navigation.ReturnableRouter +import io.novafoundation.nova.common.presentation.scan.ScanQrViewModel +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService + +class WalletConnectScanViewModel( + private val router: ReturnableRouter, + private val permissionsAsker: PermissionsAsker.Presentation, + private val walletConnectService: WalletConnectService +) : ScanQrViewModel(permissionsAsker) { + + fun backClicked() { + router.back() + } + + override suspend fun scanned(result: String) { + initiatePairing(result) + + router.back() + } + + private fun initiatePairing(uri: String) { + walletConnectService.pair(uri) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/di/WalletConnectScanComponent.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/di/WalletConnectScanComponent.kt new file mode 100644 index 0000000..5e0545c --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/di/WalletConnectScanComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan.WalletConnectScanFragment + +@Subcomponent( + modules = [ + WalletConnectScanModule::class + ] +) +@ScreenScope +interface WalletConnectScanComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): WalletConnectScanComponent + } + + fun inject(fragment: WalletConnectScanFragment) +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/di/WalletConnectScanModule.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/di/WalletConnectScanModule.kt new file mode 100644 index 0000000..a5c38c1 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/scan/di/WalletConnectScanModule.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.utils.permissions.PermissionsAsker +import io.novafoundation.nova.common.utils.permissions.PermissionsAskerFactory +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.scan.WalletConnectScanViewModel + +@Module(includes = [ViewModelModule::class]) +class WalletConnectScanModule { + + @Provides + fun providePermissionAsker( + permissionsAskerFactory: PermissionsAskerFactory, + fragment: Fragment, + router: WalletConnectRouter + ) = permissionsAskerFactory.createReturnable(fragment, router) + + @Provides + @IntoMap + @ViewModelKey(WalletConnectScanViewModel::class) + fun provideViewModel( + router: WalletConnectRouter, + permissionsAsker: PermissionsAsker.Presentation, + walletConnectService: WalletConnectService + ): ViewModel { + return WalletConnectScanViewModel(router, permissionsAsker, walletConnectService) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): WalletConnectScanViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(WalletConnectScanViewModel::class.java) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/service/RealWalletConnectService.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/service/RealWalletConnectService.kt new file mode 100644 index 0000000..bccf4aa --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/service/RealWalletConnectService.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.service + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import com.walletconnect.android.Core +import com.walletconnect.android.CoreClient +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Web3Wallet +import io.novafoundation.nova.common.navigation.awaitResponse +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.WithCoroutineScopeExtensions +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignRequester +import io.novafoundation.nova.feature_external_sign_api.model.awaitConfirmation +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet +import io.novafoundation.nova.feature_external_sign_api.model.signPayload.SigningDappMetadata +import io.novafoundation.nova.feature_wallet_connect_api.presentation.WalletConnectService +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.WalletConnectError +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.failed +import io.novafoundation.nova.feature_wallet_connect_impl.domain.sdk.respondSessionRequest +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionRequester +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsEvent +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.sessionEventsFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext + +internal class RealWalletConnectService( + parentScope: CoroutineScope, + private val interactor: WalletConnectSessionInteractor, + private val dAppSignRequester: ExternalSignRequester, + private val approveSessionRequester: ApproveSessionRequester, +) : WalletConnectService, + CoroutineScope by parentScope, + WithCoroutineScopeExtensions by WithCoroutineScopeExtensions(parentScope) { + + private val events = Web3Wallet.sessionEventsFlow(scope = this) + + override val onPairErrorLiveData: MutableLiveData> = MutableLiveData() + + init { + events.onEach { + when (it) { + is WalletConnectSessionsEvent.SessionProposal -> handleSessionProposal(it.proposal) + is WalletConnectSessionsEvent.SessionRequest -> handleSessionRequest(it.request) + is WalletConnectSessionsEvent.SessionSettlement -> handleSessionSettlement(it.settlement) + is WalletConnectSessionsEvent.SessionDeleted -> handleSessionDelete(it.delete) + } + } + .inBackground() + .launchIn(this) + } + + override fun connect() { + CoreClient.Relay.connect { error: Core.Model.Error -> + Log.d(LOG_TAG, "Failed to connect to Wallet Connect: ", error.throwable) + } + } + + override fun disconnect() { + CoreClient.Relay.disconnect { error: Core.Model.Error -> + Log.d(LOG_TAG, "Failed to disconnect to Wallet Connect: ", error.throwable) + } + } + + override fun pair(uri: String) { + Web3Wallet.pair(Wallet.Params.Pair(uri), onError = { onPairErrorLiveData.postValue(Event(it.throwable)) }) + } + + private suspend fun handleSessionProposal(proposal: Wallet.Model.SessionProposal) = withContext(Dispatchers.Main) { + approveSessionRequester.awaitResponse(proposal) + } + + private suspend fun handleSessionRequest(sessionRequest: Wallet.Model.SessionRequest) { + val sdkSession = interactor.getSession(sessionRequest.topic) ?: run { respondNoSession(sessionRequest); return } + val appPairing = interactor.getPairingAccount(sdkSession.pairingTopic) ?: run { respondNoSession(sessionRequest); return } + + val walletConnectRequest = interactor.parseSessionRequest(sessionRequest) + .onFailure { error -> + Log.e("WalletConnect", "Failed to parse session request $sessionRequest", error) + + respondWithError(sessionRequest, error) + + return + }.getOrThrow() + + val externalSignResponse = withContext(Dispatchers.Main) { + dAppSignRequester.awaitConfirmation( + ExternalSignPayload( + signRequest = walletConnectRequest.toExternalSignRequest(), + dappMetadata = mapWalletConnectSessionToSignDAppMetadata(sdkSession), + wallet = ExternalSignWallet.WithId(appPairing.metaId) + ) + ) + } + + walletConnectRequest.respondWith(externalSignResponse) + } + + private suspend fun handleSessionSettlement(settlement: Wallet.Model.SettledSessionResponse) { + interactor.onSessionSettled(settlement) + } + + private suspend fun handleSessionDelete(settlement: Wallet.Model.SessionDelete) { + interactor.onSessionDelete(settlement) + } + + private fun mapWalletConnectSessionToSignDAppMetadata(session: Wallet.Model.Session): SigningDappMetadata? { + return session.metaData?.run { + SigningDappMetadata( + icon = icons.firstOrNull(), + name = name, + url = url + ) + } + } + + private suspend fun respondNoSession( + sessionRequest: Wallet.Model.SessionRequest, + ): Result<*> { + val response = sessionRequest.failed(WalletConnectError.NO_SESSION_FOR_TOPIC) + + return Web3Wallet.respondSessionRequest(response) + } + + private suspend fun respondWithError( + sessionRequest: Wallet.Model.SessionRequest, + exception: Throwable + ): Result<*> { + val error = exception as? WalletConnectError ?: WalletConnectError.GENERAL_FAILURE + val response = sessionRequest.failed(error) + + return Web3Wallet.respondSessionRequest(response) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/ApproveSessionCommunicator.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/ApproveSessionCommunicator.kt new file mode 100644 index 0000000..900a559 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/ApproveSessionCommunicator.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve + +import com.walletconnect.web3.wallet.client.Wallet.Model.SessionProposal +import io.novafoundation.nova.common.navigation.InterScreenRequester +import io.novafoundation.nova.common.navigation.InterScreenResponder + +interface ApproveSessionRequester : InterScreenRequester + +interface ApproveSessionResponder : InterScreenResponder + +interface ApproveSessionCommunicator : ApproveSessionRequester, ApproveSessionResponder diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/WalletConnectApproveSessionFragment.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/WalletConnectApproveSessionFragment.kt new file mode 100644 index 0000000..46edbd7 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/WalletConnectApproveSessionFragment.kt @@ -0,0 +1,75 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.common.view.setMessageOrHide +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.presenatation.chain.showChainsOverview +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.setupSelectWalletMixin +import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.feature_wallet_connect_impl.databinding.FragmentWcSessionApproveBinding +import io.novafoundation.nova.feature_wallet_connect_impl.di.WalletConnectFeatureComponent +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view.WCNetworksBottomSheet + +import javax.inject.Inject + +class WalletConnectApproveSessionFragment : BaseFragment() { + + override fun createBinding() = FragmentWcSessionApproveBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun initViews() { + onBackPressed { viewModel.exit() } + + binder.wcApproveSessionToolbar.setHomeButtonListener { viewModel.exit() } + + binder.wcApproveSessionReject.setOnClickListener { viewModel.rejectClicked() } + binder.wcApproveSessionReject.prepareForProgress(viewLifecycleOwner) + + binder.wcApproveSessionAllow.prepareForProgress(viewLifecycleOwner) + binder.wcApproveSessionAllow.setOnClickListener { viewModel.approveClicked() } + + binder.wcApproveSessionNetworks.setOnClickListener { viewModel.networksClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + WalletConnectFeatureApi::class.java + ) + .walletConnectApproveSessionComponentFactory() + .create(this) + .inject(this) + } + + override fun subscribe(viewModel: WalletConnectApproveSessionViewModel) { + setupSelectWalletMixin(viewModel.selectWalletMixin, binder.wcApproveSessionWallet) + + viewModel.sessionMetadata.observe { sessionMetadata -> + binder.wcApproveSessionDApp.showValueOrHide(sessionMetadata.dAppUrl) + binder.wcApproveSessionIcon.showDAppIcon(sessionMetadata.icon, imageLoader) + } + + viewModel.chainsOverviewFlow.observe(binder.wcApproveSessionNetworks::showChainsOverview) + + viewModel.title.observe(binder.wcApproveSessionTitle::setText) + + viewModel.sessionAlerts.observe { sessionAlerts -> + binder.wcApproveSessionChainsAlert.setMessageOrHide(sessionAlerts.unsupportedChains?.alertContent) + binder.wcApproveSessionAccountsAlert.setMessageOrHide(sessionAlerts.missingAccounts?.alertContent) + } + + viewModel.allowButtonState.observe(binder.wcApproveSessionAllow::setState) + viewModel.rejectButtonState.observe(binder.wcApproveSessionReject::setState) + + viewModel.showNetworksBottomSheet.observeEvent { data -> + WCNetworksBottomSheet(context = requireContext(), data = data) + .show() + } + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/WalletConnectApproveSessionViewModel.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/WalletConnectApproveSessionViewModel.kt new file mode 100644 index 0000000..747be1e --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/WalletConnectApproveSessionViewModel.kt @@ -0,0 +1,279 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve + +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.navigation.requireLastInput +import io.novafoundation.nova.common.navigation.respond +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.resources.formatListPreview +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.flowOf +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainListOverview +import io.novafoundation.nova.feature_account_api.presenatation.chain.iconOrFallback +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.selectedMetaAccount +import io.novafoundation.nova.feature_wallet_connect_impl.R +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.SessionChains +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionProposal +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.allKnownChains +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.allUnknownChains +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.dAppTitle +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.hasAny +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.hasUnknown +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.model.SessionAlerts +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.model.hasBlockingAlerts +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view.WCNetworkListModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +private const val MISSING_ACCOUNTS_PREVIEW_SIZE = 3 + +class WalletConnectApproveSessionViewModel( + private val router: WalletConnectRouter, + private val interactor: WalletConnectSessionInteractor, + private val responder: ApproveSessionResponder, + private val resourceManager: ResourceManager, + private val selectWalletMixinFactory: SelectWalletMixin.Factory +) : BaseViewModel() { + + private val proposal = responder.requireLastInput() + + val selectWalletMixin = selectWalletMixinFactory.create( + coroutineScope = this, + selectionParams = ::walletSelectionParams + ) + + private val processState = MutableStateFlow(ProgressState.IDLE) + + private val sessionProposalFlow = flowOf { + interactor.resolveSessionProposal(proposal) + }.shareInBackground() + + val sessionMetadata = sessionProposalFlow.map { it.dappMetadata } + + val title = sessionMetadata.map { sessionDAppMetadata -> + val dAppTitle = sessionDAppMetadata.dAppTitle + + resourceManager.getString(R.string.dapp_confirm_authorize_title_format, dAppTitle) + }.shareInBackground() + + val chainsOverviewFlow = sessionProposalFlow.map { sessionProposal -> + createSessionNetworksModel(sessionProposal.resolvedChains) + }.shareInBackground() + + val sessionAlerts = combine(selectWalletMixin.selectedMetaAccountFlow, sessionProposalFlow) { metaAccount, sessionProposal -> + constructSessionAlerts(metaAccount, sessionProposal) + }.shareInBackground() + + val networksListFlow = sessionProposalFlow.map { constructNetworksList(it.resolvedChains) } + .shareInBackground() + + val allowButtonState = allowButtonState().shareInBackground() + val rejectButtonState = rejectButtonState().shareInBackground() + + private val _showNetworksBottomSheet = MutableLiveData>>() + val showNetworksBottomSheet: LiveData>> = _showNetworksBottomSheet + + fun exit() { + rejectClicked() + } + + fun rejectClicked() = launch { + if (isInProgress()) return@launch + processState.value = ProgressState.REJECTING + + val proposal = responder.requireLastInput() + + interactor.rejectSession(proposal) + responder.respond() + router.back() + } + + fun approveClicked() = launch { + if (isInProgress()) return@launch + processState.value = ProgressState.CONFIRMING + + val proposal = responder.requireLastInput() + val metaAccount = selectWalletMixin.selectedMetaAccount() + + interactor.approveSession(proposal, metaAccount) + .onFailure { + Log.d("WalletConnect", "Session approve failed", it) + } + + responder.respond() + router.back() + } + + fun networksClicked() = launch { + _showNetworksBottomSheet.value = networksListFlow.first().event() + } + + private suspend fun walletSelectionParams(): SelectWalletMixin.SelectionParams { + val pairingAccount = interactor.getPairingAccount(proposal.pairingTopic) + + return if (pairingAccount != null) { + SelectWalletMixin.SelectionParams( + selectionAllowed = false, + initialSelection = SelectWalletMixin.InitialSelection.SpecificWallet(pairingAccount.metaId) + ) + } else { + SelectWalletMixin.SelectionParams( + selectionAllowed = true, + initialSelection = SelectWalletMixin.InitialSelection.ActiveWallet + ) + } + } + + private fun constructSessionAlerts(metaAccount: MetaAccount, sessionProposal: WalletConnectSessionProposal): SessionAlerts { + val chains = sessionProposal.resolvedChains + + val unsupportedChainsAlert = if (chains.required.hasUnknown()) { + val content = resourceManager.getString(R.string.wallet_connect_session_approve_unsupported_chains_alert, sessionProposal.dappMetadata.dAppTitle) + + SessionAlerts.UnsupportedChains(content) + } else { + null + } + + val chainsWithMissingAccounts = metaAccount.findMissingAccountsFor(chains.required.knownChains) + + val missingAccountsAlert = if (chainsWithMissingAccounts.isNotEmpty()) { + val missingChainNames = chainsWithMissingAccounts.map { it.name } + val missingChains = resourceManager.formatListPreview(missingChainNames, maxPreviewItems = MISSING_ACCOUNTS_PREVIEW_SIZE) + val content = resourceManager.getQuantityString( + R.plurals.wallet_connect_session_approve_missing_accounts_alert, + chainsWithMissingAccounts.size, + missingChains + ) + + SessionAlerts.MissingAccounts(content) + } else { + null + } + + return SessionAlerts( + missingAccounts = missingAccountsAlert, + unsupportedChains = unsupportedChainsAlert + ) + } + + private fun rejectButtonState(): Flow { + return processState.map { progressState -> + when (progressState) { + ProgressState.IDLE -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_reject)) + + ProgressState.REJECTING -> DescriptiveButtonState.Loading + + else -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_reject)) + } + } + } + + private fun allowButtonState(): Flow { + return combine(processState, sessionAlerts) { progressState, sessionAlerts -> + when { + sessionAlerts.hasBlockingAlerts() -> DescriptiveButtonState.Gone + + progressState == ProgressState.IDLE -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_allow)) + + progressState == ProgressState.CONFIRMING -> DescriptiveButtonState.Loading + + else -> DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_allow)) + } + } + } + + private fun isInProgress(): Boolean { + return processState.value != ProgressState.IDLE + } + + @Suppress("KotlinConstantConditions") + private fun createSessionNetworksModel(sessionChains: SessionChains): ChainListOverview { + val allKnownChains = sessionChains.allKnownChains() + val allUnknownChains = sessionChains.allUnknownChains() + + val allChainsCount = allKnownChains.size + allUnknownChains.size + + val value = when { + // no chains + allKnownChains.isEmpty() && allUnknownChains.isEmpty() -> resourceManager.getString(R.string.common_none) + + // only unknown chains + allKnownChains.isEmpty() && allUnknownChains.isNotEmpty() -> { + resourceManager.getQuantityString(R.plurals.common_unknown_chains, allUnknownChains.size, allUnknownChains.size) + } + + // single known chain + allKnownChains.size == 1 && allUnknownChains.isEmpty() -> { + allKnownChains.single().name + } + + // multiple known and unknown chains + else -> { + val previewItem = allKnownChains.first().name + val othersCount = allChainsCount - 1 + + resourceManager.getString(R.string.common_element_and_more_format, previewItem, othersCount) + } + } + + val multipleChainsRequested = allChainsCount > 1 + val hasUnsupportedWarningsToShow = allUnknownChains.isNotEmpty() + + val firstKnownIcon = allKnownChains.firstOrNull()?.iconOrFallback() + + return ChainListOverview( + icon = firstKnownIcon?.takeUnless { multipleChainsRequested }, + value = value, + label = resourceManager.getQuantityString(R.plurals.common_networks_plural, allChainsCount), + hasMoreElements = multipleChainsRequested || hasUnsupportedWarningsToShow + ) + } + + private fun MetaAccount.findMissingAccountsFor(chains: Collection): List { + return chains.filterNot(::hasAccountIn) + } + + private fun constructNetworksList(sessionChains: SessionChains): List { + return buildList { + addCategory(sessionChains.required, R.string.common_required) + addCategory(sessionChains.optional, R.string.common_optional) + } + } + + private fun MutableList.addCategory(resolvedChains: SessionChains.ResolvedChains, @StringRes categoryNameRes: Int) { + if (resolvedChains.hasAny()) { + val element = WCNetworkListModel.Label(name = resourceManager.getString(categoryNameRes), needsAdditionalSeparator = true) + add(element) + } + + val knownChainsUi = resolvedChains.knownChains.map { WCNetworkListModel.Chain(mapChainToUi(it)) } + addAll(knownChainsUi) + + if (resolvedChains.hasUnknown()) { + val unknownCount = resolvedChains.unknownChains.size + val unknownLabel = resourceManager.getQuantityString(R.plurals.wallet_connect_unsupported_networks_hidden, unknownCount, unknownCount) + val element = WCNetworkListModel.Label(name = unknownLabel, needsAdditionalSeparator = false) + add(element) + } + } +} + +private enum class ProgressState { + IDLE, CONFIRMING, REJECTING +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/di/WalletConnectApproveSessionComponent.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/di/WalletConnectApproveSessionComponent.kt new file mode 100644 index 0000000..9458c82 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/di/WalletConnectApproveSessionComponent.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.WalletConnectApproveSessionFragment + +@Subcomponent( + modules = [ + WalletConnectApproveSessionModule::class + ] +) +@ScreenScope +interface WalletConnectApproveSessionComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + ): WalletConnectApproveSessionComponent + } + + fun inject(fragment: WalletConnectApproveSessionFragment) +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/di/WalletConnectApproveSessionModule.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/di/WalletConnectApproveSessionModule.kt new file mode 100644 index 0000000..5e984a2 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/di/WalletConnectApproveSessionModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.ApproveSessionCommunicator +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.WalletConnectApproveSessionViewModel + +@Module(includes = [ViewModelModule::class]) +class WalletConnectApproveSessionModule { + + @Provides + @IntoMap + @ViewModelKey(WalletConnectApproveSessionViewModel::class) + fun provideViewModel( + router: WalletConnectRouter, + interactor: WalletConnectSessionInteractor, + communicator: ApproveSessionCommunicator, + resourceManager: ResourceManager, + selectWalletMixinFactory: SelectWalletMixin.Factory + ): ViewModel { + return WalletConnectApproveSessionViewModel( + router = router, + interactor = interactor, + responder = communicator, + resourceManager = resourceManager, + selectWalletMixinFactory = selectWalletMixinFactory + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): WalletConnectApproveSessionViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(WalletConnectApproveSessionViewModel::class.java) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/model/SessionAlerts.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/model/SessionAlerts.kt new file mode 100644 index 0000000..5b9b04c --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/model/SessionAlerts.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.model + +class SessionAlerts( + val missingAccounts: MissingAccounts?, + val unsupportedChains: UnsupportedChains? +) { + + class MissingAccounts(val alertContent: String) + + class UnsupportedChains(val alertContent: String) +} + +fun SessionAlerts.hasBlockingAlerts(): Boolean { + return missingAccounts != null || unsupportedChains != null +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/model/SessionNetworksModel.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/model/SessionNetworksModel.kt new file mode 100644 index 0000000..ed56fe5 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/model/SessionNetworksModel.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.model + +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainListOverview + +class SessionNetworksModel( + val label: String, + val value: ChainListOverview +) diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/view/WCNetworkListModel.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/view/WCNetworkListModel.kt new file mode 100644 index 0000000..13424e4 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/view/WCNetworkListModel.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view + +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi + +sealed class WCNetworkListModel { + + class Label(val name: String, val needsAdditionalSeparator: Boolean) : WCNetworkListModel() + + class Chain(val chainUi: ChainUi) : WCNetworkListModel() +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/view/WCNetworksAdapter.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/view/WCNetworksAdapter.kt new file mode 100644 index 0000000..5dae228 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/view/WCNetworksAdapter.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view + +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import androidx.core.view.updateLayoutParams +import io.novafoundation.nova.common.list.BaseGroupedDiffCallback +import io.novafoundation.nova.common.list.GroupedListAdapter +import io.novafoundation.nova.common.list.GroupedListHolder +import io.novafoundation.nova.common.utils.dp +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.feature_account_api.databinding.ItemBottomSheetChainListBinding +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_account_api.view.ChainChipView +import io.novafoundation.nova.feature_wallet_connect_impl.databinding.ItemBottomSheetWcNetworksLabelBinding + +class WCNetworksAdapter : GroupedListAdapter(WCNetworksDiffCallback()) { + + override fun createGroupViewHolder(parent: ViewGroup): GroupedListHolder { + return WcNetworksLabelHolder(ItemBottomSheetWcNetworksLabelBinding.inflate(parent.inflater(), parent, false)) + } + + override fun createChildViewHolder(parent: ViewGroup): GroupedListHolder { + return WcNetworksChainHolder(ItemBottomSheetChainListBinding.inflate(parent.inflater(), parent, false)) + } + + override fun bindChild(holder: GroupedListHolder, child: WCNetworkListModel.Chain) { + (holder as WcNetworksChainHolder).bind(child.chainUi) + } + + override fun bindGroup(holder: GroupedListHolder, group: WCNetworkListModel.Label) { + (holder as WcNetworksLabelHolder).bind(group) + } +} + +private class WcNetworksChainHolder(private val binder: ItemBottomSheetChainListBinding) : GroupedListHolder(binder.root) { + + private val chainChipView = containerView as ChainChipView + + fun bind(item: ChainUi) { + chainChipView.setChain(item) + } +} + +private class WcNetworksLabelHolder(private val binder: ItemBottomSheetWcNetworksLabelBinding) : GroupedListHolder(binder.root) { + + fun bind(item: WCNetworkListModel.Label) = with(binder.root) { + updateLayoutParams { + if (item.needsAdditionalSeparator) { + setMargins(16.dp(context), 12.dp(context), 16.dp(context), 4.dp(context)) + } else { + setMargins(16.dp(context), 7.dp(context), 16.dp(context), 7.dp(context)) + } + } + + text = item.name + } +} + +private class WCNetworksDiffCallback : BaseGroupedDiffCallback(WCNetworkListModel.Label::class.java) { + + override fun areGroupItemsTheSame(oldItem: WCNetworkListModel.Label, newItem: WCNetworkListModel.Label): Boolean { + return oldItem.name == newItem.name + } + + override fun areGroupContentsTheSame(oldItem: WCNetworkListModel.Label, newItem: WCNetworkListModel.Label): Boolean { + return true + } + + override fun areChildItemsTheSame(oldItem: WCNetworkListModel.Chain, newItem: WCNetworkListModel.Chain): Boolean { + return oldItem.chainUi.id == newItem.chainUi.id + } + + override fun areChildContentsTheSame(oldItem: WCNetworkListModel.Chain, newItem: WCNetworkListModel.Chain): Boolean { + return oldItem.chainUi == newItem.chainUi + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/view/WCNetworksBottomSheet.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/view/WCNetworksBottomSheet.kt new file mode 100644 index 0000000..411ff0d --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/approve/view/WCNetworksBottomSheet.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.approve.view + +import android.content.Context +import android.os.Bundle +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.BaseDynamicListBottomSheet +import io.novafoundation.nova.feature_wallet_connect_impl.R + +class WCNetworksBottomSheet( + context: Context, + private val data: List, +) : BaseDynamicListBottomSheet(context) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.common_networks) + + recyclerView.setHasFixedSize(true) + + val adapter = WCNetworksAdapter() + recyclerView.adapter = adapter + adapter.submitList(data) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/common/WalletConnectSessionMapper.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/common/WalletConnectSessionMapper.kt new file mode 100644 index 0000000..b1743da --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/common/WalletConnectSessionMapper.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_wallet_connect_impl.R +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.SessionDappMetadata +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.dAppTitle + +interface WalletConnectSessionMapper { + + fun formatSessionDAppTitle(metadata: SessionDappMetadata?): String +} + +class RealWalletConnectSessionMapper( + private val resourceManager: ResourceManager +) : WalletConnectSessionMapper { + + override fun formatSessionDAppTitle(metadata: SessionDappMetadata?): String { + return metadata?.dAppTitle ?: resourceManager.getString(R.string.wallet_connect_unknown_dapp) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/WalletConnectSessionDetailsFragment.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/WalletConnectSessionDetailsFragment.kt new file mode 100644 index 0000000..6bc6e29 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/WalletConnectSessionDetailsFragment.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details + +import androidx.core.os.bundleOf + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.view.setState +import io.novafoundation.nova.common.view.showValueOrHide +import io.novafoundation.nova.feature_account_api.view.showWallet +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainListBottomSheet +import io.novafoundation.nova.feature_account_api.presenatation.chain.showChainsOverview +import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.feature_wallet_connect_impl.R +import io.novafoundation.nova.feature_wallet_connect_impl.databinding.FragmentWcSessionDetailsBinding +import io.novafoundation.nova.feature_wallet_connect_impl.di.WalletConnectFeatureComponent + +import javax.inject.Inject + +class WalletConnectSessionDetailsFragment : BaseFragment() { + + companion object { + + private const val KEY_PAYLOAD = "WalletConnectSessionsFragment.Payload" + fun getBundle(payload: WalletConnectSessionDetailsPayload) = bundleOf(KEY_PAYLOAD to payload) + } + + override fun createBinding() = FragmentWcSessionDetailsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + override fun initViews() { + binder.wcSessionDetailsToolbar.setHomeButtonListener { viewModel.exit() } + + binder.wcSessionDetailsDisconnect.setOnClickListener { viewModel.disconnect() } + binder.wcSessionDetailsDisconnect.prepareForProgress(viewLifecycleOwner) + binder.wcSessionDetailsNetworks.setOnClickListener { viewModel.networksClicked() } + + binder.wcSessionDetailsStatus.showValue(getString(R.string.common_active)) + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + WalletConnectFeatureApi::class.java + ) + .walletConnectSessionDetailsComponentFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: WalletConnectSessionDetailsViewModel) { + viewModel.sessionUi.observe { sessionUi -> + binder.wcSessionDetailsWallet.showWallet(sessionUi.wallet) + binder.wcSessionDetailsDApp.showValueOrHide(sessionUi.dappUrl) + binder.wcSessionDetailsNetworks.showChainsOverview(sessionUi.networksOverview) + + binder.wcSessionDetailsTitle.text = sessionUi.dappTitle + binder.wcSessionDetailsIcon.showDAppIcon(sessionUi.dappIcon, imageLoader) + + with(sessionUi.status) { + binder.wcSessionDetailsStatus.setImage(icon, sizeDp = 14) + binder.wcSessionDetailsStatus.setPrimaryValueStyle(labelStyle) + binder.wcSessionDetailsStatus.showValue(label) + } + } + + viewModel.showChainBottomSheet.observeEvent { chainList -> + ChainListBottomSheet( + context = requireContext(), + data = chainList + ).show() + } + + viewModel.disconnectButtonState.observe(binder.wcSessionDetailsDisconnect::setState) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/WalletConnectSessionDetailsPayload.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/WalletConnectSessionDetailsPayload.kt new file mode 100644 index 0000000..3a640eb --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/WalletConnectSessionDetailsPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class WalletConnectSessionDetailsPayload(val sessionTopic: String) : Parcelable diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/WalletConnectSessionDetailsViewModel.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/WalletConnectSessionDetailsViewModel.kt new file mode 100644 index 0000000..966b360 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/WalletConnectSessionDetailsViewModel.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.event +import io.novafoundation.nova.common.utils.withFlagSet +import io.novafoundation.nova.common.view.TableCellView.FieldStyle +import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.formatChainListOverview +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_impl.R +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSessionDetails +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.model.WalletConnectSessionDetailsUi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class WalletConnectSessionDetailsViewModel( + private val router: WalletConnectRouter, + private val interactor: WalletConnectSessionInteractor, + private val resourceManager: ResourceManager, + private val walletUiUseCase: WalletUiUseCase, + private val walletConnectSessionMapper: WalletConnectSessionMapper, + private val payload: WalletConnectSessionDetailsPayload, + private val walletConnectSessionsUseCase: WalletConnectSessionsUseCase, +) : BaseViewModel() { + + private val _showChainBottomSheet = MutableLiveData>>() + val showChainBottomSheet: LiveData>> = _showChainBottomSheet + + private val disconnectInProgressFlow = MutableStateFlow(false) + + val disconnectButtonState = disconnectInProgressFlow.map { disconnectInProgress -> + if (disconnectInProgress) { + DescriptiveButtonState.Loading + } else { + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_disconnect)) + } + }.shareInBackground() + + private val sessionFlow = interactor.activeSessionFlow(payload.sessionTopic) + .shareInBackground() + + val sessionUi = sessionFlow + .filterNotNull() + .map(::mapSessionDetailsToUi) + .shareInBackground() + + init { + watchSessionDisconnect() + } + + fun exit() { + router.back() + } + + fun networksClicked() = launch { + _showChainBottomSheet.value = sessionUi.first().networks.event() + } + + fun disconnect() = launch { + val sessionTopic = sessionFlow.first()?.sessionTopic ?: return@launch + + disconnectInProgressFlow.withFlagSet { + interactor.disconnect(sessionTopic) + .onFailure(::showError) + } + } + + private fun watchSessionDisconnect() { + sessionFlow + .distinctUntilChanged() + .onEach { if (it == null) closeSessionsScreen() } + .launchIn(this) + } + + private suspend fun closeSessionsScreen() { + val numberOfActiveSessions = walletConnectSessionsUseCase.activeSessionsNumber() + + if (numberOfActiveSessions > 0) { + router.back() + } else { + router.backToSettings() + } + } + + private suspend fun mapSessionDetailsToUi(session: WalletConnectSessionDetails): WalletConnectSessionDetailsUi { + val chainUis = session.chains.map(::mapChainToUi) + + return WalletConnectSessionDetailsUi( + dappTitle = walletConnectSessionMapper.formatSessionDAppTitle(session.dappMetadata), + dappUrl = session.dappMetadata?.dAppUrl, + dappIcon = session.dappMetadata?.icon, + networksOverview = resourceManager.formatChainListOverview(chainUis), + networks = chainUis, + wallet = walletUiUseCase.walletUiFor(session.connectedMetaAccount), + status = mapSessionStatusToUi(session.status) + ) + } + + private fun mapSessionStatusToUi(status: WalletConnectSessionDetails.SessionStatus): WalletConnectSessionDetailsUi.SessionStatus { + return when (status) { + WalletConnectSessionDetails.SessionStatus.ACTIVE -> WalletConnectSessionDetailsUi.SessionStatus( + label = resourceManager.getString(R.string.common_active), + labelStyle = FieldStyle.POSITIVE, + icon = R.drawable.ic_indicator_positive_pulse + ) + WalletConnectSessionDetails.SessionStatus.EXPIRED -> WalletConnectSessionDetailsUi.SessionStatus( + label = resourceManager.getString(R.string.common_expired), + labelStyle = FieldStyle.SECONDARY, + icon = R.drawable.ic_indicator_inactive_pulse + ) + } + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/di/WalletConnectSessionDetailsComponent.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/di/WalletConnectSessionDetailsComponent.kt new file mode 100644 index 0000000..f4b3fac --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/di/WalletConnectSessionDetailsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsFragment +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsPayload + +@Subcomponent( + modules = [ + WalletConnectSessionDetailsModule::class + ] +) +@ScreenScope +interface WalletConnectSessionDetailsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: WalletConnectSessionDetailsPayload, + ): WalletConnectSessionDetailsComponent + } + + fun inject(fragment: WalletConnectSessionDetailsFragment) +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/di/WalletConnectSessionDetailsModule.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/di/WalletConnectSessionDetailsModule.kt new file mode 100644 index 0000000..b1f0b67 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/di/WalletConnectSessionDetailsModule.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_wallet_connect_api.domain.sessions.WalletConnectSessionsUseCase +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsPayload +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsViewModel + +@Module(includes = [ViewModelModule::class]) +class WalletConnectSessionDetailsModule { + + @Provides + @IntoMap + @ViewModelKey(WalletConnectSessionDetailsViewModel::class) + fun provideViewModel( + router: WalletConnectRouter, + walletConnectSessionMapper: WalletConnectSessionMapper, + interactor: WalletConnectSessionInteractor, + resourceManager: ResourceManager, + walletUiUseCase: WalletUiUseCase, + payload: WalletConnectSessionDetailsPayload, + walletConnectSessionsUseCase: WalletConnectSessionsUseCase + ): ViewModel { + return WalletConnectSessionDetailsViewModel( + router = router, + interactor = interactor, + resourceManager = resourceManager, + walletUiUseCase = walletUiUseCase, + walletConnectSessionMapper = walletConnectSessionMapper, + payload = payload, + walletConnectSessionsUseCase = walletConnectSessionsUseCase + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): WalletConnectSessionDetailsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(WalletConnectSessionDetailsViewModel::class.java) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/model/WalletConnectSessionDetailsUi.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/model/WalletConnectSessionDetailsUi.kt new file mode 100644 index 0000000..1aaee40 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/details/model/WalletConnectSessionDetailsUi.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.model + +import androidx.annotation.DrawableRes +import io.novafoundation.nova.common.view.TableCellView +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainListOverview +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi + +class WalletConnectSessionDetailsUi( + val dappTitle: String, + val dappUrl: String?, + val dappIcon: String?, + val networksOverview: ChainListOverview, + val networks: List, + val wallet: WalletModel, + val status: SessionStatus +) { + + class SessionStatus( + val label: String, + val labelStyle: TableCellView.FieldStyle, + @DrawableRes val icon: Int + ) +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionDetailsPayload.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionDetailsPayload.kt new file mode 100644 index 0000000..5e157ca --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionDetailsPayload.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class WalletConnectSessionsPayload(val metaId: Long?) : Parcelable diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsAdapter.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsAdapter.kt new file mode 100644 index 0000000..dc90dcf --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsAdapter.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import io.novafoundation.nova.common.list.BaseListAdapter +import io.novafoundation.nova.common.list.BaseViewHolder +import io.novafoundation.nova.feature_dapp_api.presentation.view.DAppView +import io.novafoundation.nova.feature_wallet_connect_impl.R +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.model.SessionListModel + +class WalletConnectSessionsAdapter( + private val handler: Handler +) : BaseListAdapter(SessionDiffCallback()) { + + interface Handler { + + fun itemClicked(item: SessionListModel) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SessionHolder { + return SessionHolder(DAppView.createUsingMathParentWidth(parent.context), handler) + } + + override fun onBindViewHolder(holder: SessionHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +class SessionHolder( + private val dAppView: DAppView, + private val itemHandler: WalletConnectSessionsAdapter.Handler, +) : BaseViewHolder(dAppView) { + + override fun unbind() { + dAppView.clearIcon() + } + + fun bind(item: SessionListModel) = with(dAppView) { + setIconUrl(item.iconUrl) + setTitle(item.dappTitle) + setSubtitle(item.walletModel.name) + enableSubtitleIcon().setImageDrawable(item.walletModel.icon) + + setActionResource(iconRes = R.drawable.ic_chevron_right, colorRes = R.color.icon_secondary) + + setOnClickListener { itemHandler.itemClicked(item) } + } +} + +class SessionDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: SessionListModel, newItem: SessionListModel): Boolean { + return oldItem.sessionTopic == newItem.sessionTopic + } + + override fun areContentsTheSame(oldItem: SessionListModel, newItem: SessionListModel): Boolean { + return oldItem == newItem + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsEvent.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsEvent.kt new file mode 100644 index 0000000..e5fb8cc --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsEvent.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list + +import android.util.Log +import com.walletconnect.web3.wallet.client.Wallet +import com.walletconnect.web3.wallet.client.Web3Wallet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.shareIn + +sealed class WalletConnectSessionsEvent { + + data class SessionProposal(val proposal: Wallet.Model.SessionProposal) : WalletConnectSessionsEvent() + + data class SessionRequest(val request: Wallet.Model.SessionRequest) : WalletConnectSessionsEvent() + + data class SessionSettlement(val settlement: Wallet.Model.SettledSessionResponse) : WalletConnectSessionsEvent() + + data class SessionDeleted(val delete: Wallet.Model.SessionDelete) : WalletConnectSessionsEvent() +} + +fun Web3Wallet.sessionEventsFlow(scope: CoroutineScope): Flow { + return callbackFlow { + setWalletDelegate(object : Web3Wallet.WalletDelegate { + + override fun onAuthRequest(authRequest: Wallet.Model.AuthRequest, verifyContext: Wallet.Model.VerifyContext) { + Log.d("WalletConnect", "Auth request: $authRequest") + } + + override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) { + Log.d("WalletConnect", "on connection state change: $state") + } + + override fun onError(error: Wallet.Model.Error) { + Log.e("WalletConnect", "Wallet Connect error", error.throwable) + } + + override fun onProposalExpired(proposal: Wallet.Model.ExpiredProposal) { + Log.d("WalletConnect", "Proposal expired: $proposal") + } + + override fun onRequestExpired(request: Wallet.Model.ExpiredRequest) { + Log.d("WalletConnect", "Request expired: $request") + } + + override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { + Log.d("WalletConnect", "on session delete: $sessionDelete") + channel.trySend(WalletConnectSessionsEvent.SessionDeleted(sessionDelete)) + } + + override fun onSessionExtend(session: Wallet.Model.Session) { + Log.d("WalletConnect", "On session extend: $session") + } + + override fun onSessionProposal(sessionProposal: Wallet.Model.SessionProposal, verifyContext: Wallet.Model.VerifyContext) { + Log.d("WalletConnect", "on session proposal: $sessionProposal") + channel.trySend(WalletConnectSessionsEvent.SessionProposal(sessionProposal)) + } + + override fun onSessionRequest(sessionRequest: Wallet.Model.SessionRequest, verifyContext: Wallet.Model.VerifyContext) { + Log.d("WalletConnect", "on session request: $sessionRequest") + channel.trySend(WalletConnectSessionsEvent.SessionRequest(sessionRequest)) + } + + override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { + Log.d("WalletConnect", "on session settled: $settleSessionResponse") + channel.trySend(WalletConnectSessionsEvent.SessionSettlement(settleSessionResponse)) + } + + override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) { + Log.d("WalletConnect", "on session update: $sessionUpdateResponse") + } + }) + + awaitClose { } + }.shareIn(scope, SharingStarted.Eagerly) +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsFragment.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsFragment.kt new file mode 100644 index 0000000..2338b42 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsFragment.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list + +import androidx.core.os.bundleOf + +import coil.ImageLoader +import io.novafoundation.nova.common.base.BaseFragment +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.feature_wallet_connect_api.di.WalletConnectFeatureApi +import io.novafoundation.nova.feature_wallet_connect_impl.databinding.FragmentWcSessionsBinding +import io.novafoundation.nova.feature_wallet_connect_impl.di.WalletConnectFeatureComponent +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.model.SessionListModel + +import javax.inject.Inject + +class WalletConnectSessionsFragment : BaseFragment(), WalletConnectSessionsAdapter.Handler { + + companion object { + + private const val KEY_PAYLOAD = "WalletConnectSessionsFragment.Payload" + fun getBundle(payload: WalletConnectSessionsPayload) = bundleOf(KEY_PAYLOAD to payload) + } + + override fun createBinding() = FragmentWcSessionsBinding.inflate(layoutInflater) + + @Inject + lateinit var imageLoader: ImageLoader + + private val sessionsAdapter = WalletConnectSessionsAdapter(handler = this) + + override fun initViews() { + binder.wcSessionsToolbar.setHomeButtonListener { viewModel.exit() } + + binder.wcSessionsConnectionsList.setHasFixedSize(true) + binder.wcSessionsConnectionsList.adapter = sessionsAdapter + + binder.wcSessionsNewConnection.setOnClickListener { viewModel.newSessionClicked() } + } + + override fun inject() { + FeatureUtils.getFeature( + requireContext(), + WalletConnectFeatureApi::class.java + ) + .walletConnectSessionsComponentFactory() + .create(this, argument(KEY_PAYLOAD)) + .inject(this) + } + + override fun subscribe(viewModel: WalletConnectSessionsViewModel) { + viewModel.sessionsFlow.observe { sessions -> + sessionsAdapter.submitList(sessions) + + binder.wcSessionsConnectionsList.setVisible(sessions.isNotEmpty()) + binder.wcSessionsConnectionsPlaceholder.setVisible(sessions.isEmpty()) + } + } + + override fun itemClicked(item: SessionListModel) { + viewModel.sessionClicked(item) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsViewModel.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsViewModel.kt new file mode 100644 index 0000000..ca41042 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/WalletConnectSessionsViewModel.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list + +import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.domain.model.WalletConnectSession +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.details.WalletConnectSessionDetailsPayload +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.model.SessionListModel + +class WalletConnectSessionsViewModel( + private val router: WalletConnectRouter, + private val interactor: WalletConnectSessionInteractor, + private val walletUiUseCase: WalletUiUseCase, + private val walletConnectSessionMapper: WalletConnectSessionMapper, + private val walletConnectSessionsPayload: WalletConnectSessionsPayload +) : BaseViewModel() { + + val sessionsFlow = interactor.activeSessionsFlow(walletConnectSessionsPayload.metaId) + .mapList(::mapSessionToUi) + .shareInBackground() + + fun exit() { + router.back() + } + + fun newSessionClicked() { + router.openScanPairingQrCode() + } + + fun sessionClicked(item: SessionListModel) { + router.openSessionDetails(WalletConnectSessionDetailsPayload(item.sessionTopic)) + } + + private suspend fun mapSessionToUi(session: WalletConnectSession): SessionListModel { + val title = walletConnectSessionMapper.formatSessionDAppTitle(session.dappMetadata) + + return SessionListModel( + dappTitle = title, + walletModel = walletUiUseCase.walletUiFor(session.connectedMetaAccount), + iconUrl = session.dappMetadata?.icon, + sessionTopic = session.sessionTopic + ) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/di/WalletConnectSessionsComponent.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/di/WalletConnectSessionsComponent.kt new file mode 100644 index 0000000..7956a16 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/di/WalletConnectSessionsComponent.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.di + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import io.novafoundation.nova.common.di.scope.ScreenScope +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsFragment +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsPayload + +@Subcomponent( + modules = [ + WalletConnectSessionsModule::class + ] +) +@ScreenScope +interface WalletConnectSessionsComponent { + + @Subcomponent.Factory + interface Factory { + + fun create( + @BindsInstance fragment: Fragment, + @BindsInstance payload: WalletConnectSessionsPayload + ): WalletConnectSessionsComponent + } + + fun inject(fragment: WalletConnectSessionsFragment) +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/di/WalletConnectSessionsModule.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/di/WalletConnectSessionsModule.kt new file mode 100644 index 0000000..a8012a3 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/di/WalletConnectSessionsModule.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.di + +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap +import io.novafoundation.nova.common.di.viewmodel.ViewModelKey +import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase +import io.novafoundation.nova.feature_wallet_connect_impl.WalletConnectRouter +import io.novafoundation.nova.feature_wallet_connect_impl.domain.session.WalletConnectSessionInteractor +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.common.WalletConnectSessionMapper +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsPayload +import io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.WalletConnectSessionsViewModel + +@Module(includes = [ViewModelModule::class]) +class WalletConnectSessionsModule { + + @Provides + @IntoMap + @ViewModelKey(WalletConnectSessionsViewModel::class) + fun provideViewModel( + router: WalletConnectRouter, + interactor: WalletConnectSessionInteractor, + walletUiUseCase: WalletUiUseCase, + walletConnectSessionMapper: WalletConnectSessionMapper, + walletConnectSessionsPayload: WalletConnectSessionsPayload + ): ViewModel { + return WalletConnectSessionsViewModel( + router = router, + interactor = interactor, + walletUiUseCase = walletUiUseCase, + walletConnectSessionMapper = walletConnectSessionMapper, + walletConnectSessionsPayload = walletConnectSessionsPayload + ) + } + + @Provides + fun provideViewModelCreator(fragment: Fragment, viewModelFactory: ViewModelProvider.Factory): WalletConnectSessionsViewModel { + return ViewModelProvider(fragment, viewModelFactory).get(WalletConnectSessionsViewModel::class.java) + } +} diff --git a/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/model/SessionListModel.kt b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/model/SessionListModel.kt new file mode 100644 index 0000000..285d6f7 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/java/io/novafoundation/nova/feature_wallet_connect_impl/presentation/sessions/list/model/SessionListModel.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.feature_wallet_connect_impl.presentation.sessions.list.model + +import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel + +data class SessionListModel( + val dappTitle: String, + val walletModel: WalletModel, + val iconUrl: String?, + val sessionTopic: String, +) diff --git a/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_scan.xml b/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_scan.xml new file mode 100644 index 0000000..9b61822 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_scan.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_session_approve.xml b/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_session_approve.xml new file mode 100644 index 0000000..fc69dbc --- /dev/null +++ b/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_session_approve.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_session_details.xml b/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_session_details.xml new file mode 100644 index 0000000..f2d5628 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_session_details.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_sessions.xml b/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_sessions.xml new file mode 100644 index 0000000..f1dc34e --- /dev/null +++ b/feature-wallet-connect-impl/src/main/res/layout/fragment_wc_sessions.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/feature-wallet-connect-impl/src/main/res/layout/item_bottom_sheet_wc_networks_label.xml b/feature-wallet-connect-impl/src/main/res/layout/item_bottom_sheet_wc_networks_label.xml new file mode 100644 index 0000000..eef1ed6 --- /dev/null +++ b/feature-wallet-connect-impl/src/main/res/layout/item_bottom_sheet_wc_networks_label.xml @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/feature-wallet-impl/.gitignore b/feature-wallet-impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-wallet-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-wallet-impl/build.gradle b/feature-wallet-impl/build.gradle new file mode 100644 index 0000000..efa0ef1 --- /dev/null +++ b/feature-wallet-impl/build.gradle @@ -0,0 +1,92 @@ +apply plugin: 'kotlin-parcelize' +apply from: '../tests.gradle' +apply from: '../scripts/secrets.gradle' + +android { + + defaultConfig { + + + + buildConfigField "String", "EHTERSCAN_API_KEY_MOONBEAM", readStringSecret("EHTERSCAN_API_KEY_MOONBEAM") + buildConfigField "String", "EHTERSCAN_API_KEY_MOONRIVER", readStringSecret("EHTERSCAN_API_KEY_MOONRIVER") + buildConfigField "String", "EHTERSCAN_API_KEY_ETHEREUM", readStringSecret("EHTERSCAN_API_KEY_ETHEREUM") + + buildConfigField "String", "LEGACY_CROSS_CHAIN_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/refs/heads/master/xcm/v8/transfers_dev.json\"" + buildConfigField "String", "DYNAMIC_CROSS_CHAIN_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/refs/heads/master/xcm/v8/transfers_dynamic_dev.json\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + buildConfigField "String", "LEGACY_CROSS_CHAIN_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/xcm/v8/transfers.json\"" + buildConfigField "String", "DYNAMIC_CROSS_CHAIN_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/xcm/v8/transfers_dynamic.json\"" + } + } + namespace 'io.novafoundation.nova.feature_wallet_impl' + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation project(':core-db') + implementation project(':common') + implementation project(':feature-wallet-api') + implementation project(':feature-account-api') + implementation project(':feature-currency-api') + implementation project(":feature-swap-core") + implementation project(':runtime') + implementation project(':feature-xcm:api') + + implementation kotlinDep + + implementation androidDep + implementation materialDep + implementation cardViewDep + implementation constraintDep + + implementation permissionsDep + + implementation coroutinesDep + implementation coroutinesAndroidDep + implementation viewModelKtxDep + implementation liveDataKtxDep + implementation lifeCycleKtxDep + + implementation daggerDep + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + ksp daggerCompiler + + implementation roomDep + ksp roomCompiler + + implementation lifecycleDep + ksp lifecycleCompiler + + implementation bouncyCastleDep + + testImplementation jUnitDep + testImplementation mockitoDep + + implementation substrateSdkDep + + implementation gsonDep + implementation retrofitDep + + implementation wsDep + + implementation zXingCoreDep + implementation zXingEmbeddedDep + + implementation insetterDep + + implementation shimmerDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} \ No newline at end of file diff --git a/feature-wallet-impl/src/main/AndroidManifest.xml b/feature-wallet-impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..099dea4 --- /dev/null +++ b/feature-wallet-impl/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/AssetMappers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/AssetMappers.kt new file mode 100644 index 0000000..7e8286e --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/AssetMappers.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_wallet_impl.data.mappers + +import io.novafoundation.nova.common.domain.balance.EDCountingMode +import io.novafoundation.nova.common.domain.balance.TransferableMode +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.core_db.model.AssetLocal +import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal +import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal +import io.novafoundation.nova.core_db.model.AssetWithToken +import io.novafoundation.nova.core_db.model.CurrencyLocal +import io.novafoundation.nova.core_db.model.TokenLocal +import io.novafoundation.nova.core_db.model.TokenWithCurrency +import io.novafoundation.nova.feature_currency_api.presentation.mapper.mapCurrencyFromLocal +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRateChange +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun mapTokenWithCurrencyToToken( + tokenWithCurrency: TokenWithCurrency, + chainAsset: Chain.Asset, +): Token { + return mapTokenLocalToToken( + tokenWithCurrency.token ?: TokenLocal.createEmpty(chainAsset.symbol.value, tokenWithCurrency.currency.id), + tokenWithCurrency.currency, + chainAsset + ) +} + +fun mapTokenLocalToToken( + tokenLocal: TokenLocal?, + currencyLocal: CurrencyLocal, + chainAsset: Chain.Asset, +): Token { + return Token( + currency = mapCurrencyFromLocal(currencyLocal), + coinRate = tokenLocal?.recentRateChange?.let { CoinRateChange(tokenLocal.recentRateChange.orZero(), tokenLocal.rate.orZero()) }, + configuration = chainAsset + ) +} + +fun mapAssetLocalToAsset( + assetLocal: AssetWithToken, + chainAsset: Chain.Asset +): Asset { + return with(assetLocal) { + Asset( + token = mapTokenLocalToToken(token, assetLocal.currency, chainAsset), + frozenInPlanks = asset?.frozenInPlanks.orZero(), + freeInPlanks = asset?.freeInPlanks.orZero(), + reservedInPlanks = asset?.reservedInPlanks.orZero(), + bondedInPlanks = asset?.bondedInPlanks.orZero(), + unbondingInPlanks = asset?.unbondingInPlanks.orZero(), + redeemableInPlanks = asset?.redeemableInPlanks.orZero(), + transferableMode = mapTransferableModeFromLocal(asset?.transferableMode), + edCountingMode = mapEdCountingModeFromLocal(asset?.edCountingMode) + ) + } +} + +private fun mapTransferableModeFromLocal(modeLocal: TransferableModeLocal?): TransferableMode { + return when (modeLocal ?: AssetLocal.defaultTransferableMode()) { + TransferableModeLocal.REGULAR -> TransferableMode.REGULAR + TransferableModeLocal.HOLDS_AND_FREEZES -> TransferableMode.HOLDS_AND_FREEZES + } +} + +private fun mapEdCountingModeFromLocal(modeLocal: EDCountingModeLocal?): EDCountingMode { + return when (modeLocal ?: AssetLocal.defaultEdCountingMode()) { + EDCountingModeLocal.TOTAL -> EDCountingMode.TOTAL + EDCountingModeLocal.FREE -> EDCountingMode.FREE + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/crosschain/Common.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/crosschain/Common.kt new file mode 100644 index 0000000..82a24e0 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/crosschain/Common.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_wallet_impl.data.mappers.crosschain + +import io.novafoundation.nova.common.utils.asGsonParsedNumber +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.JunctionsRemote +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.toInterior + +private const val PARENTS = "parents" + +fun mapJunctionsRemoteToMultiLocation( + junctionsRemote: JunctionsRemote +): RelativeMultiLocation { + return if (PARENTS in junctionsRemote) { + val parents = junctionsRemote.getValue(PARENTS).asGsonParsedNumber().toInt() + val withoutParents = junctionsRemote - PARENTS + + RelativeMultiLocation( + parents = parents, + interior = mapJunctionsRemoteToInterior(withoutParents) + ) + } else { + RelativeMultiLocation( + parents = 0, + interior = mapJunctionsRemoteToInterior(junctionsRemote) + ) + } +} + +fun JunctionsRemote.toAbsoluteLocation(): AbsoluteMultiLocation { + return AbsoluteMultiLocation(mapJunctionsRemoteToInterior(this)) +} + +private fun mapJunctionsRemoteToInterior( + junctionsRemote: JunctionsRemote +): MultiLocation.Interior { + return junctionsRemote.map { (type, value) -> mapJunctionFromRemote(type, value) } + .toInterior() +} + +fun mapJunctionFromRemote(type: String, value: Any?): Junction { + return when (type) { + "parachainId" -> Junction.ParachainId(value.asGsonParsedNumber()) + "generalKey" -> Junction.GeneralKey(value as String) + "palletInstance" -> Junction.PalletInstance(value.asGsonParsedNumber()) + "generalIndex" -> Junction.GeneralIndex(value.asGsonParsedNumber()) + else -> throw IllegalArgumentException("Unknown junction type: $type") + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/crosschain/Dynamic.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/crosschain/Dynamic.kt new file mode 100644 index 0000000..950b5b9 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/crosschain/Dynamic.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_wallet_impl.data.mappers.crosschain + +import io.novafoundation.nova.common.utils.flattenKeys +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration.CustomTeleportEntry +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration.TransferDestination +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveConfig +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveRegistry +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.CustomTeleportEntryRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainOriginChainRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainTransfersConfigRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicReserveLocationRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository + +fun DynamicCrossChainTransfersConfigRemote.toDomain(parachainInfoRepository: ParachainInfoRepository): DynamicCrossChainTransfersConfiguration { + return DynamicCrossChainTransfersConfiguration( + reserveRegistry = constructReserveRegistry(parachainInfoRepository, assetsLocation, reserveIdOverrides), + chains = constructChains(chains), + customTeleports = constructCustomTeleports(customTeleports) + ) +} + +private fun constructCustomTeleports( + customTeleports: List? +): Set { + return customTeleports.orEmpty().mapToSet { entry -> + with(entry) { + CustomTeleportEntry(FullChainAssetId(originChain, originAsset), destChain) + } + } +} + +private fun constructReserveRegistry( + parachainInfoRepository: ParachainInfoRepository, + assetsLocation: Map?, + reserveIdOverrides: Map>?, +): TokenReserveRegistry { + return TokenReserveRegistry( + parachainInfoRepository = parachainInfoRepository, + reservesById = assetsLocation.orEmpty().mapValues { (_, reserve) -> + reserve.toDomain() + }, + assetToReserveIdOverrides = reserveIdOverrides.orEmpty().flattenKeys(::FullChainAssetId) + ) +} + +private fun constructChains( + chains: List? +): Map> { + return chains.orEmpty().associateBy( + keySelector = DynamicCrossChainOriginChainRemote::chainId, + valueTransform = ::constructTransfersForChain + ) +} + +private fun constructTransfersForChain(configRemote: DynamicCrossChainOriginChainRemote): List { + return configRemote.assets.map { assetConfig -> + AssetTransfers( + assetId = assetConfig.assetId, + destinations = assetConfig.xcmTransfers.map { transfer -> + TransferDestination( + fullChainAssetId = FullChainAssetId( + transfer.getDestinationChainId(), + transfer.getDestinationAssetId() + ), + hasDeliveryFee = transfer.hasDeliveryFee ?: false, + supportsXcmExecute = transfer.supportsXcmExecute ?: false, + ) + } + ) + } +} + +private fun DynamicReserveLocationRemote.toDomain(): TokenReserveConfig { + return TokenReserveConfig( + reserveChainId = chainId, + tokenReserveLocation = multiLocation.toAbsoluteLocation() + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/crosschain/Legacy.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/crosschain/Legacy.kt new file mode 100644 index 0000000..0eb7cf4 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/mappers/crosschain/Legacy.kt @@ -0,0 +1,180 @@ +package io.novafoundation.nova.feature_wallet_impl.data.mappers.crosschain + +import io.novafoundation.nova.common.utils.asGsonParsedNumber +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveConfig +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveRegistry +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.AssetLocationPath +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.DeliveryFeeConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.ReserveLocation +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.XcmDestination +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.XcmFee +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.XcmTransfer +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.XCMInstructionType +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyXcmTransferMethod +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainOriginAssetRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainTransfersConfigRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyDeliveryFeeConfigRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyNetworkDeliveryFeeRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyReserveLocationRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyXcmDestinationRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyXcmFeeRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyXcmTransferRemote +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository + +fun LegacyCrossChainTransfersConfigRemote.toDomain( + parachainInfoRepository: ParachainInfoRepository +): LegacyCrossChainTransfersConfiguration { + val assetsLocations = assetsLocation.orEmpty().mapValues { (_, reserveLocationRemote) -> + mapReserveLocationFromRemote(reserveLocationRemote) + } + + val feeInstructions = instructions.orEmpty().mapValues { (_, instructionsRemote) -> + instructionsRemote.map(::mapXcmInstructionFromRemote) + } + + val chains = chains.orEmpty().associateBy( + keySelector = { it.chainId }, + valueTransform = { it.assets.map(::mapAssetTransfersFromRemote) } + ) + + val networkDeliveryFee = networkDeliveryFee.orEmpty().mapValues { (_, networkDeliveryFeeRemote) -> + mapNetworkDeliveryFeeFromRemote(networkDeliveryFeeRemote) + } + + return LegacyCrossChainTransfersConfiguration( + assetLocations = assetsLocations, + feeInstructions = feeInstructions, + instructionBaseWeights = networkBaseWeight.orEmpty(), + deliveryFeeConfigurations = networkDeliveryFee, + chains = chains, + reserveRegistry = constructLegacyReserveRegistry(parachainInfoRepository, assetsLocations, chains) + ) +} + +private fun constructLegacyReserveRegistry( + parachainInfoRepository: ParachainInfoRepository, + assetLocations: Map, + chains: Map> +): TokenReserveRegistry { + return TokenReserveRegistry( + parachainInfoRepository = parachainInfoRepository, + reservesById = assetLocations.mapValues { (_, reserve) -> + TokenReserveConfig( + reserveChainId = reserve.chainId, + // Legacy config uses relative location for reserve, however in fact they are absolute + // I decided to not to refactor it but rather simply perform conversion here in-place + tokenReserveLocation = AbsoluteMultiLocation(reserve.multiLocation.interior) + ) + }, + assetToReserveIdOverrides = buildMap { + chains.forEach { (chainId, chainAssets) -> + chainAssets.map { chainAssetConfig -> + val key = FullChainAssetId(chainId, chainAssetConfig.assetId) + // We could check that the `assetLocation` differs from the asset symbol to avoid placing redundant overrides... + // But we don't since it does not matter much anyway + put(key, chainAssetConfig.assetLocation) + } + } + } + ) +} + +private fun mapNetworkDeliveryFeeFromRemote(networkDeliveryFeeRemote: LegacyNetworkDeliveryFeeRemote): DeliveryFeeConfiguration { + return DeliveryFeeConfiguration( + toParent = mapDeliveryFeeConfigFromRemote(networkDeliveryFeeRemote.toParent), + toParachain = mapDeliveryFeeConfigFromRemote(networkDeliveryFeeRemote.toParachain) + ) +} + +private fun mapDeliveryFeeConfigFromRemote(config: LegacyDeliveryFeeConfigRemote?): DeliveryFeeConfiguration.Type? { + if (config == null) return null + + return when (config.type) { + "exponential" -> DeliveryFeeConfiguration.Type.Exponential( + factorPallet = config.factorPallet, + sizeBase = config.sizeBase, + sizeFactor = config.sizeFactor, + alwaysHoldingPays = config.alwaysHoldingPays ?: false + ) + + else -> throw IllegalArgumentException("Unknown delivery fee config type: ${config.type}") + } +} + +private fun mapReserveLocationFromRemote(reserveLocationRemote: LegacyReserveLocationRemote): ReserveLocation { + return ReserveLocation( + chainId = reserveLocationRemote.chainId, + reserveFee = reserveLocationRemote.reserveFee?.let(::mapXcmFeeFromRemote), + multiLocation = mapJunctionsRemoteToMultiLocation(reserveLocationRemote.multiLocation) + ) +} + +private fun mapAssetTransfersFromRemote(remote: LegacyCrossChainOriginAssetRemote): AssetTransfers { + val assetLocationPath = when (remote.assetLocationPath.type) { + "absolute" -> AssetLocationPath.Absolute + "relative" -> AssetLocationPath.Relative + "concrete" -> { + val junctionsRemote = remote.assetLocationPath.path!! + + AssetLocationPath.Concrete(mapJunctionsRemoteToMultiLocation(junctionsRemote)) + } + + else -> throw IllegalArgumentException("Unknown asset type") + } + + return AssetTransfers( + assetId = remote.assetId, + assetLocationPath = assetLocationPath, + assetLocation = remote.assetLocation, + xcmTransfers = remote.xcmTransfers.map(::mapXcmTransferFromRemote) + ) +} + +private fun mapXcmTransferFromRemote(remote: LegacyXcmTransferRemote): XcmTransfer { + return XcmTransfer( + destination = mapXcmDestinationFromRemote(remote.destination), + type = mapXcmTransferTypeFromRemote(remote.type) + ) +} + +private fun mapXcmTransferTypeFromRemote(remote: String): LegacyXcmTransferMethod { + return when (remote) { + "xtokens" -> LegacyXcmTransferMethod.X_TOKENS + "xcmpallet" -> LegacyXcmTransferMethod.XCM_PALLET_RESERVE + "xcmpallet-teleport" -> LegacyXcmTransferMethod.XCM_PALLET_TELEPORT + "xcmpallet-transferAssets" -> LegacyXcmTransferMethod.XCM_PALLET_TRANSFER_ASSETS + else -> LegacyXcmTransferMethod.UNKNOWN + } +} + +private fun mapXcmDestinationFromRemote(remote: LegacyXcmDestinationRemote): XcmDestination { + return XcmDestination( + chainId = remote.chainId, + assetId = remote.assetId, + fee = mapXcmFeeFromRemote(remote.fee) + ) +} + +private fun mapXcmFeeFromRemote( + remote: LegacyXcmFeeRemote +): XcmFee { + val mode = when (remote.mode.type) { + "proportional" -> XcmFee.Mode.Proportional(remote.mode.value.asGsonParsedNumber()) + "standard" -> XcmFee.Mode.Standard + else -> XcmFee.Mode.Unknown + } + + return XcmFee( + mode = mode, + instructions = remote.instructions + ) +} + +private fun mapXcmInstructionFromRemote(instruction: String): XCMInstructionType = runCatching { + enumValueOf(instruction) +}.getOrDefault(XCMInstructionType.UNKNOWN) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/RealAccountInfoRepository.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/RealAccountInfoRepository.kt new file mode 100644 index 0000000..4106b4f --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/RealAccountInfoRepository.kt @@ -0,0 +1,32 @@ +@file:Suppress("EXPERIMENTAL_API_USAGE") + +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull +import io.novafoundation.nova.runtime.storage.typed.account +import io.novafoundation.nova.runtime.storage.typed.system +import io.novasama.substrate_sdk_android.runtime.AccountId +import javax.inject.Inject +import javax.inject.Named + +@FeatureScope +internal class RealAccountInfoRepository @Inject constructor( + @Named(REMOTE_STORAGE_SOURCE) private val remoteStorageSource: StorageDataSource, +) : AccountInfoRepository { + + override suspend fun getAccountInfo( + chainId: ChainId, + accountId: AccountId, + ): AccountInfo { + return remoteStorageSource.query(chainId, applyStorageDefault = true) { + metadata.system.account.queryNonNull(accountId) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/api/UntypedAssetsAssetId.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/api/UntypedAssetsAssetId.kt new file mode 100644 index 0000000..c3f95b8 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/api/UntypedAssetsAssetId.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.api + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.StatemineAssetDetails +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.bindAssetDetails +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +typealias UntypedAssetsAssetId = Any + +@JvmInline +value class AssetsApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +fun RuntimeMetadata.assets(palletName: String): AssetsApi { + return AssetsApi(module(palletName)) +} + +context(StorageQueryContext) +val AssetsApi.asset: QueryableStorageEntry1 + get() = storage1("Asset", binding = { decoded, _ -> bindAssetDetails(decoded) }) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt new file mode 100644 index 0000000..6b37314 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets + +import dagger.Lazy +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.orml.OrmlAssetSourceFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.UnsupportedEventDetector +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.evmErc20.EvmErc20EventDetectorFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.orml.OrmlAssetEventDetectorFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.statemine.StatemineAssetEventDetectorFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.utility.NativeAssetEventDetector +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class StaticAssetSource( + override val transfers: AssetTransfers, + override val balance: AssetBalance, + override val history: AssetHistory, +) : AssetSource + +// Use lazy to resolve possible circular dependencies +class TypeBasedAssetSourceRegistry( + private val nativeSource: Lazy, + private val statemineSource: Lazy, + private val ormlSourceFactory: Lazy, + private val evmErc20Source: Lazy, + private val evmNativeSource: Lazy, + private val equilibriumAssetSource: Lazy, + private val unsupportedBalanceSource: AssetSource, + + private val nativeAssetEventDetector: NativeAssetEventDetector, + private val ormlAssetEventDetectorFactory: OrmlAssetEventDetectorFactory, + private val statemineAssetEventDetectorFactory: StatemineAssetEventDetectorFactory, + private val erc20EventDetectorFactory: EvmErc20EventDetectorFactory +) : AssetSourceRegistry { + + override fun sourceFor(chainAsset: Chain.Asset): AssetSource { + return when (val type = chainAsset.type) { + is Chain.Asset.Type.Native -> nativeSource.get() + is Chain.Asset.Type.Statemine -> statemineSource.get() + is Chain.Asset.Type.Orml -> ormlSourceFactory.get().getSourceBySubtype(type.subType) + is Chain.Asset.Type.EvmErc20 -> evmErc20Source.get() + is Chain.Asset.Type.EvmNative -> evmNativeSource.get() + is Chain.Asset.Type.Equilibrium -> equilibriumAssetSource.get() + Chain.Asset.Type.Unsupported -> unsupportedBalanceSource + } + } + + override fun allSources(): List { + return buildList { + add(nativeSource.get()) + add(statemineSource.get()) + addAll(ormlSourceFactory.get().allSources()) + add(evmNativeSource.get()) + add(evmErc20Source.get()) + add(equilibriumAssetSource.get()) + } + } + + override suspend fun getEventDetector(chainAsset: Chain.Asset): AssetEventDetector { + return when (chainAsset.type) { + is Chain.Asset.Type.Equilibrium, + Chain.Asset.Type.EvmNative, + + Chain.Asset.Type.Unsupported -> UnsupportedEventDetector() + + is Chain.Asset.Type.Statemine -> statemineAssetEventDetectorFactory.create(chainAsset) + + is Chain.Asset.Type.Orml -> ormlAssetEventDetectorFactory.create(chainAsset) + + Chain.Asset.Type.Native -> nativeAssetEventDetector + + is Chain.Asset.Type.EvmErc20 -> erc20EventDetectorFactory.create(chainAsset) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/BlockchainLock.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/BlockchainLock.kt new file mode 100644 index 0000000..9f9518a --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/BlockchainLock.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances + +import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindString +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.second +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.model.BalanceLockLocal +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class BlockchainLock( + val id: BalanceLockId, + val amount: Balance +) + +@HelperBinding +fun bindEquilibriumBalanceLocks(dynamicInstance: Any?): List? { + if (dynamicInstance == null) return null + + return bindList(dynamicInstance) { items -> + val item = items.castToList() + BlockchainLock( + bindLockIdString(item.first().cast()), + bindNumber(item.second().cast()) + ) + } +} + +@HelperBinding +fun bindBalanceLocks(dynamicInstance: Any?): List { + if (dynamicInstance == null) return emptyList() + + return bindList(dynamicInstance) { + BlockchainLock( + bindLockIdString(it.castToStruct()["id"]), + bindNumber(it.castToStruct()["amount"]) + ) + } +} + +fun bindBalanceFreezes(dynamicInstance: Any?): List { + if (dynamicInstance == null) return emptyList() + + return bindList(dynamicInstance) { item -> + val asStruct = item.castToStruct() + + BlockchainLock( + bindFreezeId(asStruct["id"]), + bindNumber(asStruct["amount"]) + ) + } +} + +private fun bindFreezeId(dynamicInstance: Any?): BalanceLockId { + val asEnum = dynamicInstance.castToDictEnum() + val module = asEnum.name + val moduleReason = asEnum.value.castToDictEnum().name + + return BalanceLockId.fromPath(module, moduleReason) +} + +private fun bindLockIdString(dynamicInstance: Any?): BalanceLockId { + val asString = bindString(dynamicInstance) + return BalanceLockId.fromFullId(asString) +} + +fun mapBlockchainLockToLocal( + metaId: Long, + chainId: ChainId, + assetId: ChainAssetId, + lock: BlockchainLock +): BalanceLockLocal { + return BalanceLockLocal(metaId, chainId, assetId, lock.id.value, lock.amount) +} + +suspend fun LockDao.updateLocks(locks: List, metaId: Long, chainId: ChainId, chainAssetId: ChainAssetId) { + val balanceLocksLocal = locks.map { mapBlockchainLockToLocal(metaId, chainId, chainAssetId, it) } + updateLocks(balanceLocksLocal, metaId, chainId, chainAssetId) +} + +suspend fun LockDao.updateLock(lock: BlockchainLock, metaId: Long, chainId: ChainId, chainAssetId: ChainAssetId) { + val balanceLocksLocal = mapBlockchainLockToLocal(metaId, chainId, chainAssetId, lock) + updateLocks(listOf(balanceLocksLocal), metaId, chainId, chainAssetId) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/UnsupportedAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/UnsupportedAssetBalance.kt new file mode 100644 index 0000000..c327bdb --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/UnsupportedAssetBalance.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances + +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +class UnsupportedAssetBalance : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ) = unsupported() + + override fun isSelfSufficient(chainAsset: Chain.Asset) = unsupported() + + override suspend fun existentialDeposit(chainAsset: Chain.Asset) = unsupported() + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId) = unsupported() + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow = unsupported() + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return emptyFlow() + } + + private fun unsupported(): Nothing = throw UnsupportedOperationException("Unsupported balance source") +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/equilibrium/EquilibriumAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/equilibrium/EquilibriumAssetBalance.kt new file mode 100644 index 0000000..03e8cfb --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/equilibrium/EquilibriumAssetBalance.kt @@ -0,0 +1,284 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.equilibrium + +import android.util.Log +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.getList +import io.novafoundation.nova.common.data.network.runtime.binding.returnType +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.combine +import io.novafoundation.nova.common.utils.constantOrNull +import io.novafoundation.nova.common.utils.decodeValue +import io.novafoundation.nova.common.utils.eqBalances +import io.novafoundation.nova.common.utils.getAs +import io.novafoundation.nova.common.utils.hasUpdated +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.second +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.model.AssetLocal +import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal +import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bindEquilibriumBalanceLocks +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.updateLocks +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.ext.requireEquilibrium +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novafoundation.nova.runtime.network.binding.number +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class EquilibriumAssetBalance( + private val chainRegistry: ChainRegistry, + private val assetCache: AssetCache, + private val lockDao: LockDao, + private val assetDao: AssetDao, + private val remoteStorageSource: StorageDataSource, +) : AssetBalance { + + private class ReservedAssetBalanceWithBlock(val assetId: Int, val reservedBalance: BigInteger, val block: BlockHash) + + private class FreeAssetBalancesWithBlock(val lock: BigInteger?, val assets: List) + + private class FreeAssetBalance(val assetId: Int, val balance: BigInteger) + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + if (!chainAsset.isUtilityAsset) return emptyFlow() + + val runtime = chainRegistry.getRuntime(chain.id) + val storage = runtime.metadata.eqBalances().storage("Locked") + val key = storage.storageKey(runtime, accountId) + + return subscriptionBuilder.subscribe(key) + .map { change -> + val balanceLocks = bindEquilibriumBalanceLocks(storage.decodeValue(change.value, runtime)).orEmpty() + lockDao.updateLocks(balanceLocks, metaAccount.id, chain.id, chainAsset.id) + } + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + return if (chainAsset.isUtilityAsset) { + chainRegistry.withRuntime(chainAsset.chainId) { + runtime.metadata.eqBalances().constantOrNull("ExistentialDepositBasic")?.getAs(number()) + .orZero() + } + } else { + BigInteger.ZERO + } + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + val assetBalances = remoteStorageSource.query( + chain.id, + keyBuilder = { it.getAccountStorage().storageKey(it, accountId) }, + binding = { scale, runtimeSnapshot -> bindEquilibriumBalances(chain, scale, runtimeSnapshot) } + ) + + val onChainAssetId = chainAsset.requireEquilibrium().id + val reservedBalance = remoteStorageSource.query( + chain.id, + keyBuilder = { it.getReservedStorage().storageKey(it, accountId, onChainAssetId) }, + binding = { scale, runtimeSnapshot -> bindReservedBalance(scale, runtimeSnapshot) } + ) + + val assetBalance = assetBalances.assets + .firstOrNull { it.assetId == chainAsset.id } + ?.balance + .orZero() + + val lockedBalance = assetBalances.lock.orZero().takeIf { chainAsset.isUtilityAsset } ?: BigInteger.ZERO + + return ChainAssetBalance.default( + chainAsset = chainAsset, + free = assetBalance, + reserved = reservedBalance, + frozen = lockedBalance + ) + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + TODO("Not yet implemented") + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + if (!chainAsset.isUtilityAsset) return emptyFlow() + + val assetBalancesFlow = subscriptionBuilder.subscribeOnSystemAccount(chain, accountId) + val reservedBalanceFlow = subscriptionBuilder.subscribeOnReservedBalance(chain, accountId) + + var oldBlockHash: String? = null + + return combine(assetBalancesFlow, reservedBalanceFlow) { (blockHash, assetBalances), reservedBalancesWithBlocks -> + val freeByAssetId = assetBalances.assets.associateBy { it.assetId } + val reservedByAssetId = reservedBalancesWithBlocks.associateBy { it.assetId } + + val diff = assetCache.updateAssetsByChain(metaAccount, chain) { asset: Chain.Asset -> + val free = freeByAssetId[asset.id]?.balance.orZero() + val reserved = reservedByAssetId[asset.id]?.reservedBalance.orZero() + val locks = if (asset.isUtilityAsset) assetBalances.lock.orZero() else BigInteger.ZERO + AssetLocal( + assetId = asset.id, + chainId = asset.chainId, + metaId = metaAccount.id, + freeInPlanks = free, + reservedInPlanks = reserved, + frozenInPlanks = locks, + transferableMode = TransferableModeLocal.REGULAR, + edCountingMode = EDCountingModeLocal.TOTAL, + redeemableInPlanks = BigInteger.ZERO, + bondedInPlanks = BigInteger.ZERO, + unbondingInPlanks = BigInteger.ZERO + ) + } + + if (diff.hasUpdated() && oldBlockHash != blockHash) { + oldBlockHash = blockHash + BalanceSyncUpdate.CauseFetchable(blockHash) + } else { + BalanceSyncUpdate.NoCause + } + } + } + + private suspend fun SharedRequestsBuilder.subscribeOnSystemAccount(chain: Chain, accountId: AccountId): Flow> { + val runtime = chainRegistry.getRuntime(chain.id) + + val key = try { + runtime.getAccountStorage().storageKey(runtime, accountId) + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to construct account storage key: ${e.message} in ${chain.name}") + + return emptyFlow() + } + + return subscribe(key) + .map { it.block to bindEquilibriumBalances(chain, it.value, runtime) } + } + + private suspend fun SharedRequestsBuilder.subscribeOnReservedBalance(chain: Chain, accountId: AccountId): Flow> { + val runtime = chainRegistry.getRuntime(chain.id) + + return chain.assets + .filter { it.type is Chain.Asset.Type.Equilibrium } + .map { asset -> + val equilibriumType = asset.requireEquilibrium() + + val key = try { + runtime.getReservedStorage().storageKey(runtime, accountId, equilibriumType.id) + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to construct key: ${e.message} in ${chain.name}") + + return@map flowOf(null) + } + + subscribe(key) + .map { ReservedAssetBalanceWithBlock(asset.id, bindReservedBalance(it.value, runtime), it.block) } + .catch { emit(null) } + }.combine() + .map { it.filterNotNull() } + } + + private fun bindReservedBalance(raw: String?, runtime: RuntimeSnapshot): BigInteger { + val type = runtime.getReservedStorage().returnType() + + return raw?.let { type.fromHexOrNull(runtime, it).cast() } ?: BigInteger.ZERO + } + + @UseCaseBinding + private fun bindEquilibriumBalances(chain: Chain, scale: String?, runtime: RuntimeSnapshot): FreeAssetBalancesWithBlock { + if (scale == null) { + return FreeAssetBalancesWithBlock(null, emptyList()) + } + + val type = runtime.getAccountStorage().returnType() + val data = type.fromHexOrNull(runtime, scale) + .castToStruct() + .get("data").castToDictEnum() + .value + .castToStruct() + + val lock = data.get("lock") + val balances = data.getList("balance") + + val onChainAssetIdToAsset = chain.assets + .associateBy { it.requireEquilibrium().id } + + val assetBalances = balances.mapNotNull { assetBalance -> + val (onChainAssetId, balance) = bindAssetBalance(assetBalance.castToList()) + val asset = onChainAssetIdToAsset[onChainAssetId] + + asset?.let { FreeAssetBalance(it.id, balance) } + } + + return FreeAssetBalancesWithBlock(lock, assetBalances) + } + + @HelperBinding + private fun bindAssetBalance(dynamicInstance: List): Pair { + val onChainAssetId = bindNumber(dynamicInstance.first()) + val balance = dynamicInstance.second().castToDictEnum() + val amount = if (balance.name == "Positive") { + bindNumber(balance.value) + } else { + BigInteger.ZERO + } + return onChainAssetId to amount + } + + private fun RuntimeSnapshot.getAccountStorage(): StorageEntry { + return metadata.system().storage("Account") + } + + private fun RuntimeSnapshot.getReservedStorage(): StorageEntry { + return metadata.eqBalances().storage("Reserved") + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/evmErc20/EvmErc20AssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/evmErc20/EvmErc20AssetBalance.kt new file mode 100644 index 0000000..3345caf --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/evmErc20/EvmErc20AssetBalance.kt @@ -0,0 +1,258 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.evmErc20 + +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.core.ethereum.log.Topic +import io.novafoundation.nova.core.updater.EthereumSharedRequestsBuilder +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.callApiOrThrow +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.runtime.ethereum.contract.base.queryBatched +import io.novafoundation.nova.runtime.ethereum.contract.base.querySingle +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Queries +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.requireErc20 +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow +import io.novafoundation.nova.runtime.multiNetwork.getSubscriptionEthereumApiOrThrow +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novasama.substrate_sdk_android.extensions.asEthereumAddress +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import org.web3j.abi.EventEncoder +import org.web3j.abi.TypeEncoder +import org.web3j.abi.datatypes.Address +import org.web3j.protocol.websocket.events.Log +import org.web3j.protocol.websocket.events.LogNotification +import java.math.BigInteger + +private const val BATCH_ID = "EvmAssetBalance.InitialBalance" + +class EvmErc20AssetBalance( + private val chainRegistry: ChainRegistry, + private val assetCache: AssetCache, + private val erc20Standard: Erc20Standard, + private val rpcCalls: RpcCalls, +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + // ERC20 tokens doe not support locks + return emptyFlow() + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + // ERC20 tokens do not have ED + return BigInteger.ZERO + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + val erc20Type = chainAsset.requireErc20() + val ethereumApi = chainRegistry.getCallEthereumApiOrThrow(chain.id) + val accountAddress = chain.addressOf(accountId) + val balance = erc20Standard.querySingle(erc20Type.contractAddress, ethereumApi) + .balanceOfAsync(accountAddress) + .await() + return ChainAssetBalance.fromFree(chainAsset, free = balance) + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + val ethereumApi = chainRegistry.getSubscriptionEthereumApiOrThrow(chain.id) + + val address = chain.addressOf(accountId) + val erc20Type = chainAsset.requireErc20() + + return merge( + ethereumApi.incomingErcTransfersFlow(address, erc20Type.contractAddress), + ethereumApi.outComingErcTransfersFlow(address, erc20Type.contractAddress) + ).mapLatest { logNotification -> + val blockNumber = logNotification.params.result.parsedBlockNumber() + val substrateHash = rpcCalls.getBlockHash(chain.id, blockNumber) + + TransferableBalanceUpdatePoint(updatedAt = substrateHash) + } + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + val address = chain.addressOf(accountId) + + val erc20Type = chainAsset.requireErc20() + + val initialBalanceAsync = erc20Standard.queryBatched(erc20Type.contractAddress, BATCH_ID, subscriptionBuilder) + .balanceOfAsync(address) + + return subscriptionBuilder.erc20BalanceFlow(address, chainAsset, initialBalanceAsync) + .map { balanceUpdate -> + assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balanceUpdate.newBalance) + + if (balanceUpdate.cause != null) { + BalanceSyncUpdate.CauseFetched(balanceUpdate.cause) + } else { + BalanceSyncUpdate.NoCause + } + } + } + + private fun EthereumSharedRequestsBuilder.erc20BalanceFlow( + account: String, + chainAsset: Chain.Asset, + initialBalanceAsync: Deferred + ): Flow { + val contractAddress = chainAsset.requireErc20().contractAddress + + val changes = accountErcTransfersFlow(account, contractAddress, chainAsset).map { erc20Transfer -> + val newBalance = erc20Standard.querySingle(contractAddress, callApiOrThrow) + .balanceOfAsync(account) + .await() + + Erc20BalanceUpdate(newBalance, cause = erc20Transfer) + } + + return flow { + val initialBalance = initialBalanceAsync.await() + + emit(Erc20BalanceUpdate(initialBalance, cause = null)) + + emitAll(changes) + } + } + + private fun Web3Api.incomingErcTransfersFlow( + accountAddress: String, + contractAddress: String, + ): Flow { + return logsNotifications( + addresses = listOf(contractAddress), + topics = createErc20ReceiveTopics(accountAddress) + ) + } + + private fun Web3Api.outComingErcTransfersFlow( + accountAddress: String, + contractAddress: String, + ): Flow { + return logsNotifications( + addresses = listOf(contractAddress), + topics = createErc20SendTopics(accountAddress) + ) + } + + private fun createErc20ReceiveTopics(accountAddress: String): List { + val addressTopic = TypeEncoder.encode(Address(accountAddress)) + val transferEventSignature = EventEncoder.encode(Erc20Queries.TRANSFER_EVENT) + + return createErc20ReceiveTopics(transferEventSignature, addressTopic) + } + + private fun createErc20ReceiveTopics( + transferEventSignature: String, + addressTopic: String, + ): List { + return listOf( + Topic.Single(transferEventSignature), // zero-th topic is event signature + Topic.Any, // anyone is `from` + Topic.AnyOf(addressTopic) // our account as `to` + ) + } + + private fun createErc20SendTopics(accountAddress: String): List { + val addressTopic = TypeEncoder.encode(Address(accountAddress)) + val transferEventSignature = EventEncoder.encode(Erc20Queries.TRANSFER_EVENT) + + return createErc20SendTopics(transferEventSignature, addressTopic) + } + + private fun createErc20SendTopics( + transferEventSignature: String, + addressTopic: String, + ): List { + return listOf( + Topic.Single(transferEventSignature), // zero-th topic is event signature + Topic.AnyOf(addressTopic), // our account as `from` + ) + } + + private fun EthereumSharedRequestsBuilder.accountErcTransfersFlow( + accountAddress: String, + contractAddress: String, + chainAsset: Chain.Asset, + ): Flow { + val addressTopic = TypeEncoder.encode(Address(accountAddress)) + + val transferEvent = Erc20Queries.TRANSFER_EVENT + val transferEventSignature = EventEncoder.encode(transferEvent) + + val erc20SendTopic = createErc20SendTopics(transferEventSignature, addressTopic) + val erc20ReceiveTopic = createErc20ReceiveTopics(transferEventSignature, addressTopic) + + val receiveTransferNotifications = subscribeEthLogs(contractAddress, erc20ReceiveTopic) + val sendTransferNotifications = subscribeEthLogs(contractAddress, erc20SendTopic) + + val transferNotifications = merge(receiveTransferNotifications, sendTransferNotifications) + + return transferNotifications.map { logNotification -> + val log = logNotification.params.result + val event = Erc20Queries.parseTransferEvent(log) + + RealtimeHistoryUpdate( + status = Operation.Status.COMPLETED, + txHash = log.transactionHash, + type = RealtimeHistoryUpdate.Type.Transfer( + senderId = event.from.accountId(), + recipientId = event.to.accountId(), + amountInPlanks = event.amount.value, + chainAsset = chainAsset, + ) + ) + } + } + + private fun Log.parsedBlockNumber(): BigInteger { + return BigInteger(blockNumber.removeHexPrefix(), 16) + } +} + +private fun Address.accountId() = value.asEthereumAddress().toAccountId().value + +private class Erc20BalanceUpdate( + val newBalance: Balance, + val cause: RealtimeHistoryUpdate? +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/evmNative/EvmNativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/evmNative/EvmNativeAssetBalance.kt new file mode 100644 index 0000000..2b117b6 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/evmNative/EvmNativeAssetBalance.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.evmNative + +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow +import io.novafoundation.nova.runtime.multiNetwork.getSubscriptionEthereumApiOrThrow +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transform +import org.web3j.protocol.core.DefaultBlockParameterName +import java.math.BigInteger + +class EvmNativeAssetBalance( + private val assetCache: AssetCache, + private val chainRegistry: ChainRegistry, +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + // Evm native tokens doe not support locks + return emptyFlow() + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + // Evm native tokens do not have ED + return BigInteger.ZERO + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + val ethereumApi = chainRegistry.getCallEthereumApiOrThrow(chain.id) + + val balance = ethereumApi.getLatestNativeBalance(chain.addressOf(accountId)) + return ChainAssetBalance.fromFree(chainAsset, balance) + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + TODO("Not yet implemented") + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + val subscriptionApi = chainRegistry.getSubscriptionEthereumApiOrThrow(chain.id) + val callApi = chainRegistry.getCallEthereumApiOrThrow(chain.id) + + val address = chain.addressOf(accountId) + + return balanceSyncUpdateFlow(address, subscriptionApi, callApi).map { balanceUpdate -> + assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balanceUpdate.newBalance) + + balanceUpdate.syncUpdate + } + } + + private fun balanceSyncUpdateFlow( + address: String, + subscriptionApi: Web3Api, + callApi: Web3Api + ): Flow { + return flow { + val initialBalance = callApi.getLatestNativeBalance(address) + emit(EvmNativeBalanceUpdate(initialBalance, BalanceSyncUpdate.NoCause)) + + var currentBalance = initialBalance + + val realtimeUpdates = subscriptionApi.newHeadsFlow().transform { newHead -> + val blockHash = newHead.params.result.hash + val newBalance = callApi.getLatestNativeBalance(address) + + if (newBalance != currentBalance) { + currentBalance = newBalance + val update = EvmNativeBalanceUpdate(newBalance, BalanceSyncUpdate.CauseFetchable(blockHash)) + emit(update) + } + } + + emitAll(realtimeUpdates) + } + } + + private suspend fun Web3Api.getLatestNativeBalance(address: String): Balance { + return ethGetBalance(address, DefaultBlockParameterName.LATEST).sendSuspend().balance + } +} + +private class EvmNativeBalanceUpdate( + val newBalance: Balance, + val syncUpdate: BalanceSyncUpdate, +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/orml/OrmlAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/orml/OrmlAssetBalance.kt new file mode 100644 index 0000000..2c52daa --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/orml/OrmlAssetBalance.kt @@ -0,0 +1,141 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance +import io.novafoundation.nova.common.data.network.runtime.binding.bindOrmlAccountBalanceOrEmpty +import io.novafoundation.nova.common.utils.decodeValue +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.tokens +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal +import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bindBalanceLocks +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.updateLocks +import io.novafoundation.nova.runtime.ext.ormlCurrencyId +import io.novafoundation.nova.runtime.ext.requireOrml +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class OrmlAssetBalance( + private val assetCache: AssetCache, + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry, + private val lockDao: LockDao +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + val runtime = chainRegistry.getRuntime(chain.id) + val storage = runtime.metadata.tokens().storage("Locks") + + val currencyId = chainAsset.ormlCurrencyId(runtime) + val key = storage.storageKey(runtime, accountId, currencyId) + + return subscriptionBuilder.subscribe(key) + .map { change -> + val balanceLocks = bindBalanceLocks(storage.decodeValue(change.value, runtime)) + lockDao.updateLocks(balanceLocks, metaAccount.id, chain.id, chainAsset.id) + } + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + return chainAsset.requireOrml().existentialDeposit + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + val balance = remoteStorageSource.query( + chainId = chain.id, + keyBuilder = { it.ormlBalanceKey(accountId, chainAsset) }, + binding = { scale, runtime -> bindOrmlAccountBalanceOrEmpty(scale, runtime) } + ) + + return ChainAssetBalance.default(chainAsset, balance) + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + return remoteStorageSource.subscribe(chain.id) { + metadata.tokens().storage("Accounts").observeWithRaw( + accountId, + chainAsset.ormlCurrencyId(runtime), + binding = ::bindOrmlAccountBalanceOrEmpty + ).map { + TransferableBalanceUpdatePoint(it.at!!) + } + } + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + val runtime = chainRegistry.getRuntime(chain.id) + + return subscriptionBuilder.subscribe(runtime.ormlBalanceKey(accountId, chainAsset)) + .map { + val ormlAccountData = bindOrmlAccountBalanceOrEmpty(it.value, runtime) + + val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, ormlAccountData) + + if (assetChanged) { + BalanceSyncUpdate.CauseFetchable(it.block) + } else { + BalanceSyncUpdate.NoCause + } + } + } + + private suspend fun updateAssetBalance( + metaId: Long, + chainAsset: Chain.Asset, + ormlAccountData: AccountBalance + ) = assetCache.updateAsset(metaId, chainAsset) { local -> + with(ormlAccountData) { + local.copy( + frozenInPlanks = frozen, + freeInPlanks = free, + reservedInPlanks = reserved, + transferableMode = TransferableModeLocal.REGULAR, + edCountingMode = EDCountingModeLocal.TOTAL, + ) + } + } + + private fun RuntimeSnapshot.ormlBalanceKey(accountId: AccountId, chainAsset: Chain.Asset): String { + return metadata.tokens().storage("Accounts").storageKey(this, accountId, chainAsset.ormlCurrencyId(this)) + } + + private fun bindOrmlAccountBalanceOrEmpty(scale: String?, runtime: RuntimeSnapshot): AccountBalance { + return scale?.let { bindOrmlAccountData(it, runtime) } ?: AccountBalance.empty() + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/orml/OrmlBalanceBinding.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/orml/OrmlBalanceBinding.kt new file mode 100644 index 0000000..30bd1bf --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/orml/OrmlBalanceBinding.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindOrmlAccountData +import io.novafoundation.nova.common.data.network.runtime.binding.returnType +import io.novafoundation.nova.common.utils.tokens +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +@UseCaseBinding +fun bindOrmlAccountData(scale: String, runtime: RuntimeSnapshot): AccountBalance { + val type = runtime.metadata.tokens().storage("Accounts").returnType() + + val dynamicInstance = type.fromHexOrNull(runtime, scale) + + return bindOrmlAccountData(dynamicInstance) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/orml/hydrationEvm/HydrationEvmOrmlAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/orml/hydrationEvm/HydrationEvmOrmlAssetBalance.kt new file mode 100644 index 0000000..ccaed93 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/orml/hydrationEvm/HydrationEvmOrmlAssetBalance.kt @@ -0,0 +1,158 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml.hydrationEvm + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.domain.balance.EDCountingMode +import io.novafoundation.nova.common.domain.balance.TransferableMode +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.updateFromChainBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml.OrmlAssetBalance +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.call.RuntimeCallsApi +import io.novafoundation.nova.runtime.ext.currencyId +import io.novafoundation.nova.runtime.ext.requireOrml +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.currentRemoteBlockNumberFlow +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import java.math.BigInteger +import javax.inject.Inject + +/** + * Balance source implementation for Hydration ERC20 tokens that are "mostly exposed" via Orml on Substrate side + * In particular, we can transact, listen for events, but cannot subscribe storage for balance updates + * as balance stays in the smart-contract storage on evm-side. Instead, we use runtime api to poll update once a block + */ +@FeatureScope +class HydrationEvmOrmlAssetBalance @Inject constructor( + private val defaultDelegate: OrmlAssetBalance, + private val runtimeCallsApi: MultiChainRuntimeCallsApi, + private val chainRegistry: ChainRegistry, + private val chainStateRepository: ChainStateRepository, + private val assetCache: AssetCache, + private val rpcCalls: RpcCalls, +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + return emptyFlow() + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return defaultDelegate.isSelfSufficient(chainAsset) + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + return defaultDelegate.existentialDeposit(chainAsset) + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + return fetchBalance(chainAsset, accountId) + } + + override suspend fun subscribeAccountBalanceUpdatePoint(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): Flow { + return balanceUpdateFlow(chainAsset, accountId, subscriptionBuilder = null) + .mapNotNull { (it.update as? BalanceSyncUpdate.CauseFetchable)?.blockHash } + .map(::TransferableBalanceUpdatePoint) + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return balanceUpdateFlow(chainAsset, accountId, subscriptionBuilder).map { (balance, syncUpdate) -> + assetCache.updateFromChainBalance(metaAccount.id, balance) + + syncUpdate + } + } + + private suspend fun balanceUpdateFlow( + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder? + ): Flow { + val blockNumberFlow = chainStateRepository.currentRemoteBlockNumberFlow(chainAsset.chainId, subscriptionBuilder) + + return flow { + val initialBalance = fetchBalance(chainAsset, accountId) + val initialBalanceUpdate = BalancePollingUpdate(initialBalance, BalanceSyncUpdate.NoCause) + emit(initialBalanceUpdate) + + var currentBalance = initialBalance + + blockNumberFlow.collect { blockNumber -> + val newBalance = fetchBalance(chainAsset, accountId) + + if (currentBalance != newBalance) { + currentBalance = newBalance + + val balanceUpdatedAt = rpcCalls.getBlockHash(chainAsset.chainId, blockNumber) + val syncUpdate = BalanceSyncUpdate.CauseFetchable(balanceUpdatedAt) + val balanceUpdate = BalancePollingUpdate(newBalance, syncUpdate) + emit(balanceUpdate) + } + } + } + } + + private suspend fun fetchBalance(chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + return runtimeCallsApi.forChain(chainAsset.chainId).fetchBalance(chainAsset, accountId) + } + + private suspend fun RuntimeCallsApi.fetchBalance(chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + val runtime = chainRegistry.getRuntime(chainAsset.chainId) + val assetId = chainAsset.requireOrml().currencyId(runtime) + + return call( + section = "CurrenciesApi", + method = "account", + arguments = mapOf( + "asset_id" to assetId, + "who" to accountId + ), + returnBinding = { bindAssetBalance(it, chainAsset) } + ) + } + + private fun bindAssetBalance(decoded: Any?, chainAsset: Chain.Asset): ChainAssetBalance { + val asStruct = decoded.castToStruct() + + return ChainAssetBalance( + chainAsset = chainAsset, + free = bindNumber(asStruct["free"]), + frozen = bindNumber(asStruct["frozen"]), + reserved = bindNumber(asStruct["reserved"]), + transferableMode = TransferableMode.REGULAR, + edCountingMode = EDCountingMode.TOTAL + ) + } + + private data class BalancePollingUpdate( + val assetBalance: ChainAssetBalance, + val update: BalanceSyncUpdate + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt new file mode 100644 index 0000000..8735685 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt @@ -0,0 +1,203 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine + +import android.util.Log +import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.decodeValue +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal +import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.StatemineAssetDetails +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.transfersFrozen +import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.bindAssetAccountOrEmpty +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.statemineModule +import io.novafoundation.nova.runtime.ext.requireStatemine +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.prepareIdForEncoding +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +// TODO Migrate low-level subscription to storage to AssetsApi +class StatemineAssetBalance( + private val chainRegistry: ChainRegistry, + private val assetCache: AssetCache, + private val remoteStorage: StorageDataSource, + private val statemineAssetsRepository: StatemineAssetsRepository, +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow> { + return emptyFlow() + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return runCatching { + chainAsset.requireStatemine().isSufficient + }.getOrDefault(false) + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + return runCatching { + queryAssetDetails(chainAsset).minimumBalance + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to query existential deposit for ${chainAsset.symbol}: ${error.message}") + BigInteger.ZERO + } + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + return runCatching { + val statemineType = chainAsset.requireStatemine() + + val assetAccount = remoteStorage.query(chain.id) { + val encodableId = statemineType.prepareIdForEncoding(runtime) + + runtime.metadata.statemineModule(statemineType).storage("Account").query( + encodableId, + accountId, + binding = ::bindAssetAccountOrEmpty + ) + } + + val accountBalance = assetAccount.toAccountBalance() + ChainAssetBalance.default(chainAsset, accountBalance) + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to query balance for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + ChainAssetBalance.fromFree(chainAsset, BigInteger.ZERO) + } + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + return runCatching { + val statemineType = chainAsset.requireStatemine() + + remoteStorage.subscribe(chain.id) { + val encodableId = statemineType.prepareIdForEncoding(runtime) + + runtime.metadata.statemineModule(statemineType).storage("Account").observeWithRaw( + encodableId, + accountId, + binding = ::bindAssetAccountOrEmpty + ).map { + TransferableBalanceUpdatePoint(it.at!!) + } + }.catch { error -> + Log.e(LOG_TAG, "Balance subscription failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + } + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to setup balance subscription for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + emptyFlow() + } + } + + private fun AssetAccount.toAccountBalance(): AccountBalance { + val frozenBalance = if (isBalanceFrozen) { + balance + } else { + BigInteger.ZERO + } + + return AccountBalance( + free = balance, + reserved = BigInteger.ZERO, + frozen = frozenBalance + ) + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return runCatching { + val runtime = chainRegistry.getRuntime(chain.id) + + val statemineType = chainAsset.requireStatemine() + val encodableAssetId = statemineType.prepareIdForEncoding(runtime) + + val module = runtime.metadata.statemineModule(statemineType) + + val assetAccountStorage = module.storage("Account") + val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId) + + val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder) + + combine( + subscriptionBuilder.subscribe(assetAccountKey), + assetDetailsFlow.map { it.status.transfersFrozen } + ) { balanceStorageChange, isAssetFrozen -> + val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime) + val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded) + + val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount) + + if (assetChanged) { + BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block) + } else { + BalanceSyncUpdate.NoCause + } + }.catch { error -> + Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + emit(BalanceSyncUpdate.NoCause) + } + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to start balance sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + emptyFlow() + } + } + + private suspend fun queryAssetDetails(chainAsset: Chain.Asset): StatemineAssetDetails { + val statemineType = chainAsset.requireStatemine() + return statemineAssetsRepository.getAssetDetails(chainAsset.chainId, statemineType) + } + + private suspend fun updateAssetBalance( + metaId: Long, + chainAsset: Chain.Asset, + isAssetFrozen: Boolean, + assetAccount: AssetAccount + ) = assetCache.updateAsset(metaId, chainAsset) { + val frozenBalance = if (isAssetFrozen || assetAccount.isBalanceFrozen) { + assetAccount.balance + } else { + BigInteger.ZERO + } + val freeBalance = assetAccount.balance + + it.copy( + frozenInPlanks = frozenBalance, + freeInPlanks = freeBalance, + transferableMode = TransferableModeLocal.REGULAR, + edCountingMode = EDCountingModeLocal.TOTAL, + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssets.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssets.kt new file mode 100644 index 0000000..06774de --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssets.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine + +import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindBoolean +import io.novafoundation.nova.common.data.network.runtime.binding.bindCollectionEnum +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.StatemineAssetDetails +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.AssetAccount.AccountStatus +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import java.math.BigInteger + +@UseCaseBinding +fun bindAssetDetails(decoded: Any?): StatemineAssetDetails { + val dynamicInstance = decoded.cast() + + return StatemineAssetDetails( + status = bindAssetStatus(dynamicInstance), + isSufficient = bindBoolean(dynamicInstance["isSufficient"]), + minimumBalance = bindNumber(dynamicInstance["minBalance"]), + issuer = bindAccountIdKey(dynamicInstance["issuer"]) + ) +} + +private fun bindAssetStatus(assetStruct: Struct.Instance): StatemineAssetDetails.Status { + return when { + assetStruct.get("isFrozen") != null -> bindIsFrozen(bindBoolean(assetStruct["isFrozen"])) + assetStruct.get("status") != null -> bindCollectionEnum(assetStruct["status"]) + else -> incompatible() + } +} + +private fun bindIsFrozen(isFrozen: Boolean): StatemineAssetDetails.Status { + return if (isFrozen) StatemineAssetDetails.Status.Frozen else StatemineAssetDetails.Status.Live +} + +class AssetAccount( + val balance: BigInteger, + val status: AccountStatus +) { + + enum class AccountStatus { + Liquid, Frozen, Blocked + } + + companion object { + + fun empty() = AssetAccount( + balance = BigInteger.ZERO, + status = AccountStatus.Liquid + ) + } +} + +val AssetAccount.isBalanceFrozen: Boolean + get() = status == AccountStatus.Blocked || status == AccountStatus.Frozen + +val AssetAccount.canAcceptFunds: Boolean + get() = status != AccountStatus.Blocked + +@UseCaseBinding +fun bindAssetAccount(decoded: Any): AssetAccount { + val dynamicInstance = decoded.cast() + + val status = when { + // old version of assets pallet - isFrozen flag + "isFrozen" in dynamicInstance.mapping -> { + val isFrozen = bindBoolean(dynamicInstance["isFrozen"]) + if (isFrozen) AccountStatus.Frozen else AccountStatus.Liquid + } + + // new version of assets pallet - status enum + "status" in dynamicInstance.mapping -> { + bindCollectionEnum(dynamicInstance["status"]) + } + + else -> incompatible() + } + + return AssetAccount( + balance = bindNumber(dynamicInstance["balance"]), + status = status, + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/BalancesApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/BalancesApi.kt new file mode 100644 index 0000000..0fa7f09 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/BalancesApi.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility + +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.BlockchainLock +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bindBalanceFreezes +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bindBalanceLocks +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module + +@JvmInline +value class BalancesRuntimeApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.balances: BalancesRuntimeApi + get() = BalancesRuntimeApi(balances()) + +context(StorageQueryContext) +val BalancesRuntimeApi.locks: QueryableStorageEntry1> + get() = storage1("Locks", binding = { decoded, _ -> bindBalanceLocks(decoded) }) + +context(StorageQueryContext) +val BalancesRuntimeApi.freezes: QueryableStorageEntry1> + get() = storage1("Freezes", binding = { decoded, _ -> bindBalanceFreezes(decoded) }) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt new file mode 100644 index 0000000..f5f14bb --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt @@ -0,0 +1,220 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility + +import android.util.Log +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.common.utils.decodeValue +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core_db.dao.HoldsDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.model.BalanceHoldLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.bindAccountInfoOrDefault +import io.novafoundation.nova.feature_wallet_api.data.cache.updateAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.toChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.updateLocks +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.typed.account +import io.novafoundation.nova.runtime.storage.typed.system +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +class NativeAssetBalance( + private val chainRegistry: ChainRegistry, + private val assetCache: AssetCache, + private val accountInfoRepository: AccountInfoRepository, + private val remoteStorage: StorageDataSource, + private val lockDao: LockDao, + private val holdsDao: HoldsDao, +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + return runCatching { + remoteStorage.subscribe(chain.id, subscriptionBuilder) { + combine( + metadata.balances.locks.observe(accountId), + metadata.balances.freezes.observe(accountId) + ) { locks, freezes -> + val all = locks.orEmpty() + freezes.orEmpty() + + lockDao.updateLocks(all, metaAccount.id, chain.id, chainAsset.id) + } + }.catch { error -> + Log.e(LOG_TAG, "Balance locks sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + } + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to start balance locks sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + emptyFlow() + } + } + + override suspend fun startSyncingBalanceHolds( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + return runCatching { + val runtime = chainRegistry.getRuntime(chain.id) + val storage = runtime.metadata.balances().storageOrNull("Holds") ?: return emptyFlow() + val key = storage.storageKey(runtime, accountId) + + subscriptionBuilder.subscribe(key) + .map { change -> + val holds = bindBalanceHolds(storage.decodeValue(change.value, runtime)).orEmpty() + holdsDao.updateHolds(holds, metaAccount.id, chain.id, chainAsset.id) + } + .catch { error -> + Log.e(LOG_TAG, "Balance holds sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + } + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to start balance holds sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + emptyFlow() + } + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + return runCatching { + val runtime = chainRegistry.getRuntime(chainAsset.chainId) + runtime.metadata.balances().numberConstant("ExistentialDeposit", runtime) + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to query existential deposit for ${chainAsset.symbol}: ${error.message}") + BigInteger.ZERO + } + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + return runCatching { + accountInfoRepository.getAccountInfo(chain.id, accountId).data.toChainAssetBalance(chainAsset) + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to query balance for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + ChainAssetBalance.fromFree(chainAsset, BigInteger.ZERO) + } + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + return runCatching { + remoteStorage.subscribe(chain.id) { + metadata.system.account.observeWithRaw(accountId).map { + TransferableBalanceUpdatePoint(it.at!!) + } + }.catch { error -> + Log.e(LOG_TAG, "Balance subscription failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + } + }.getOrElse { error -> + Log.e(LOG_TAG, "Failed to setup balance subscription for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + emptyFlow() + } + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + val runtime = chainRegistry.getRuntime(chain.id) + + val key = try { + runtime.metadata.system().storage("Account").storageKey(runtime, accountId) + } catch (e: Exception) { + Log.e(LOG_TAG, "Failed to construct account storage key: ${e.message} in ${chain.name}") + + return emptyFlow() + } + + return subscriptionBuilder.subscribe(key) + .map { change -> + val accountInfo = bindAccountInfoOrDefault(change.value, runtime) + val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo) + + if (assetChanged) { + BalanceSyncUpdate.CauseFetchable(change.block) + } else { + BalanceSyncUpdate.NoCause + } + } + .catch { error -> + Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") + emit(BalanceSyncUpdate.NoCause) + } + } + + private fun bindBalanceHolds(dynamicInstance: Any?): List? { + if (dynamicInstance == null) return null + + return bindList(dynamicInstance) { + BlockchainHold( + id = bindHoldId(it.castToStruct()["id"]), + amount = bindNumber(it.castToStruct()["amount"]) + ) + } + } + + private fun bindHoldId(id: Any?): BalanceHold.HoldId { + val module = id.castToDictEnum() + val reason = module.value.castToDictEnum() + + return BalanceHold.HoldId(module.name, reason.name) + } + + private suspend fun HoldsDao.updateHolds(holds: List, metaId: Long, chainId: ChainId, chainAssetId: ChainAssetId) { + val balanceLocksLocal = holds.map { + BalanceHoldLocal( + metaId = metaId, + chainId = chainId, + assetId = chainAssetId, + id = BalanceHoldLocal.HoldIdLocal(module = it.id.module, reason = it.id.reason), + amount = it.amount + ) + } + updateHolds(balanceLocksLocal, metaId, chainId, chainAssetId) + } + + private class BlockchainHold(val id: BalanceHold.HoldId, val amount: Balance) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/common/Statemine.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/common/Statemine.kt new file mode 100644 index 0000000..4fe0888 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/common/Statemine.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common + +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.AssetAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.bindAssetAccount +import io.novafoundation.nova.runtime.ext.palletNameOrDefault +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module + +fun RuntimeMetadata.statemineModule(statemineType: Chain.Asset.Type.Statemine) = module(statemineType.palletNameOrDefault()) + +fun bindAssetAccountOrEmpty(decoded: Any?): AssetAccount { + return decoded?.let(::bindAssetAccount) ?: AssetAccount.empty() +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/common/orml/OrmlAssetSourceFactory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/common/orml/OrmlAssetSourceFactory.kt new file mode 100644 index 0000000..f44a0d1 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/common/orml/OrmlAssetSourceFactory.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.orml + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml.OrmlAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml.hydrationEvm.HydrationEvmOrmlAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.orml.OrmlAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml.OrmlAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml.hydrationEvm.HydrationEvmAssetTransfers +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import javax.inject.Inject + +@FeatureScope +class OrmlAssetSourceFactory @Inject constructor( + defaultBalance: OrmlAssetBalance, + defaultHistory: OrmlAssetHistory, + defaultTransfers: OrmlAssetTransfers, + + hydrationEvmOrmlAssetBalance: HydrationEvmOrmlAssetBalance, + hydrationEvmAssetTransfers: HydrationEvmAssetTransfers +) { + + private val defaultSource = StaticAssetSource(defaultTransfers, defaultBalance, defaultHistory) + private val hydrationEvmSource = StaticAssetSource(hydrationEvmAssetTransfers, hydrationEvmOrmlAssetBalance, defaultHistory) + + fun allSources(): List { + return listOf(defaultSource, hydrationEvmSource) + } + + fun getSourceBySubtype(subType: Chain.Asset.Type.Orml.SubType): AssetSource { + return when (subType) { + Chain.Asset.Type.Orml.SubType.DEFAULT -> defaultSource + Chain.Asset.Type.Orml.SubType.HYDRATION_EVM -> hydrationEvmSource + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/UnsupportedEventDetector.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/UnsupportedEventDetector.kt new file mode 100644 index 0000000..ca5e954 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/UnsupportedEventDetector.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +class UnsupportedEventDetector : AssetEventDetector { + + override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? { + return null + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/evmErc20/EvmErc20EventDetector.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/evmErc20/EvmErc20EventDetector.kt new file mode 100644 index 0000000..a127156 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/evmErc20/EvmErc20EventDetector.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.evmErc20 + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindByteArray +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.ethereumAddressToAccountId +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Queries +import io.novafoundation.nova.runtime.ext.requireErc20 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import javax.inject.Inject + +@FeatureScope +class EvmErc20EventDetectorFactory @Inject constructor() { + + fun create(chainAsset: Chain.Asset): AssetEventDetector { + return EvmErc20EventDetector(chainAsset.requireErc20()) + } +} + +class EvmErc20EventDetector( + private val erc20AssetType: Chain.Asset.Type.EvmErc20 +) : AssetEventDetector { + + override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? { + return parseEvmLogEvent(event)?.toDepositEvent() + } + + private fun parseEvmLogEvent(event: GenericEvent.Instance): Erc20Queries.Transfer? { + if (!event.instanceOf(Modules.EVM, "Log")) return null + + val args = event.arguments.first().castToStruct() + + val address = bindAccountIdKey(args["address"]) + val contractAddress = erc20AssetType.contractAddress.ethereumAddressToAccountId().intoKey() + + if (contractAddress != address) return null + + val topics = bindList(args["topics"], ::bindHexString) + + val eventSignature = topics[0] + if (eventSignature != Erc20Queries.transferEventSignature()) return null + + return Erc20Queries.parseTransferEvent( + topic1 = topics[1], + topic2 = topics[2], + data = bindHexString(args["data"]) + ) + } + + private fun Erc20Queries.Transfer.toDepositEvent(): DepositEvent { + return DepositEvent( + destination = to.value.ethereumAddressToAccountId().intoKey(), + amount = amount.value + ) + } + + private fun bindHexString(decoded: Any?): String { + return bindByteArray(decoded).toHexString(withPrefix = true) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/orml/OrmlAssetEventDetector.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/orml/OrmlAssetEventDetector.kt new file mode 100644 index 0000000..537b223 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/orml/OrmlAssetEventDetector.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.orml + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent +import io.novafoundation.nova.runtime.ext.requireOrml +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.extensions.requireHexPrefix +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.toHexUntyped + +class OrmlAssetEventDetectorFactory( + private val chainRegistry: ChainRegistry, +) { + + suspend fun create(chainAsset: Chain.Asset): AssetEventDetector { + val ormlType = chainAsset.requireOrml() + val runtime = chainRegistry.getRuntime(chainAsset.chainId) + + return OrmlAssetEventDetector(runtime, ormlType) + } +} + +private class OrmlAssetEventDetector( + private val runtimeSnapshot: RuntimeSnapshot, + private val ormlType: Chain.Asset.Type.Orml, +) : AssetEventDetector { + + private val targetCurrencyId = ormlType.currencyIdScale.requireHexPrefix() + + override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? { + return detectTokensDeposited(event) + } + + private fun detectTokensDeposited(event: GenericEvent.Instance): DepositEvent? { + if (!event.instanceOf(Modules.TOKENS, "Deposited")) return null + + val (currencyId, who, amount) = event.arguments + + val currencyIdType = runtimeSnapshot.typeRegistry[ormlType.currencyIdType]!! + val currencyIdEncoded = currencyIdType.toHexUntyped(runtimeSnapshot, currencyId).requireHexPrefix() + if (currencyIdEncoded != targetCurrencyId) return null + + return DepositEvent( + destination = bindAccountIdKey(who), + amount = bindNumber(amount) + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/statemine/StatemineAssetEventDetector.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/statemine/StatemineAssetEventDetector.kt new file mode 100644 index 0000000..a9798d6 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/statemine/StatemineAssetEventDetector.kt @@ -0,0 +1,73 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.statemine + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent +import io.novafoundation.nova.runtime.ext.palletNameOrDefault +import io.novafoundation.nova.runtime.ext.requireStatemine +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.StatemineAssetId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.extensions.requireHexPrefix +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.RuntimeType +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.toHexUntyped +import java.math.BigInteger + +class StatemineAssetEventDetectorFactory( + private val chainRegistry: ChainRegistry, +) { + + suspend fun create(chainAsset: Chain.Asset): AssetEventDetector { + val assetType = chainAsset.requireStatemine() + val runtime = chainRegistry.getRuntime(chainAsset.chainId) + + return StatemineAssetEventDetector(runtime, assetType) + } +} + +class StatemineAssetEventDetector( + private val runtimeSnapshot: RuntimeSnapshot, + private val assetType: Chain.Asset.Type.Statemine, +) : AssetEventDetector { + + private val targetAssetId = assetType.id.stringAssetId() + + override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? { + return detectTokensDeposited(event) + } + + private fun detectTokensDeposited(event: GenericEvent.Instance): DepositEvent? { + if (!event.instanceOf(assetType.palletNameOrDefault(), "Issued")) return null + + val (assetId, who, amount) = event.arguments + + val assetIdType = event.event.arguments.first()!! + val assetIdAsString = decodedAssetItToString(assetId, assetIdType) + if (assetIdAsString != targetAssetId) return null + + return DepositEvent( + destination = bindAccountIdKey(who), + amount = bindNumber(amount) + ) + } + + private fun decodedAssetItToString(assetId: Any?, assetIdType: RuntimeType<*, *>): String { + return if (assetId is BigInteger) { + assetId.toString() + } else { + assetIdType.toHexUntyped(runtimeSnapshot, assetId).requireHexPrefix() + } + } + + private fun StatemineAssetId.stringAssetId(): String { + return when (this) { + is StatemineAssetId.Number -> value.toString() + is StatemineAssetId.ScaleEncoded -> scaleHex + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/utility/NativeAssetEventDetector.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/utility/NativeAssetEventDetector.kt new file mode 100644 index 0000000..44ab960 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/events/utility/NativeAssetEventDetector.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.utility + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +class NativeAssetEventDetector : AssetEventDetector { + + override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? { + return detectMinted(event) + ?: detectBalancesDeposit(event) + } + + private fun detectMinted(event: GenericEvent.Instance): DepositEvent? { + if (!event.instanceOf(Modules.BALANCES, "Minted")) return null + + val (who, amount) = event.arguments + + return DepositEvent( + destination = bindAccountIdKey(who), + amount = bindNumber(amount) + ) + } + + private fun detectBalancesDeposit(event: GenericEvent.Instance): DepositEvent? { + if (!event.instanceOf(Modules.BALANCES, "Deposit")) return null + + val (who, amount) = event.arguments + + return DepositEvent( + destination = bindAccountIdKey(who), + amount = bindNumber(amount) + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/BaseAssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/BaseAssetHistory.kt new file mode 100644 index 0000000..651cd4a --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/BaseAssetHistory.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history + +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.getAllCoinPriceHistory +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +abstract class BaseAssetHistory(internal val coinPriceRepository: CoinPriceRepository) : AssetHistory { + + protected suspend fun getPriceHistory( + chainAsset: Chain.Asset, + currency: Currency + ): List { + return runCatching { coinPriceRepository.getAllCoinPriceHistory(chainAsset.priceId!!, currency) } + .getOrNull() + ?: emptyList() + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/EvmAssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/EvmAssetHistory.kt new file mode 100644 index 0000000..82ca3e0 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/EvmAssetHistory.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history + +import io.novafoundation.nova.common.data.model.DataPage +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.satisfies +import io.novafoundation.nova.runtime.ext.externalApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +private const val FIRST_PAGE_INDEX = 1 +private const val SECOND_PAGE_INDEX = 2 + +abstract class EvmAssetHistory( + coinPriceRepository: CoinPriceRepository +) : BaseAssetHistory(coinPriceRepository) { + + abstract suspend fun fetchEtherscanOperations( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + apiUrl: String, + page: Int, + pageSize: Int, + currency: Currency + ): List + + override suspend fun additionalFirstPageSync( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + page: Result> + ) { + // nothing to do + } + + override suspend fun getOperations( + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency + ): DataPage { + val evmTransfersApi = chain.evmTransfersApi() ?: return DataPage.empty() + + return getOperationsEtherscan( + pageSize, + pageOffset, + filters, + accountId, + chain, + chainAsset, + evmTransfersApi.url, + currency + ) + } + + override suspend fun getSyncedPageOffset( + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset + ): PageOffset { + val evmTransfersApi = chain.evmTransfersApi() + + return if (evmTransfersApi != null) { + PageOffset.Loadable.PageNumber(page = SECOND_PAGE_INDEX) + } else { + PageOffset.FullData + } + } + + private suspend fun getOperationsEtherscan( + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + apiUrl: String, + currency: Currency + ): DataPage { + val page = when (pageOffset) { + PageOffset.Loadable.FirstPage -> FIRST_PAGE_INDEX + is PageOffset.Loadable.PageNumber -> pageOffset.page + else -> error("Etherscan requires page number pagination") + } + + val operations = fetchEtherscanOperations(chain, chainAsset, accountId, apiUrl, page, pageSize, currency) + + val newPageOffset = if (operations.size < pageSize) { + PageOffset.FullData + } else { + PageOffset.Loadable.PageNumber(page + 1) + } + + val filteredOperations = operations.filter { it.type.satisfies(filters) } + + return DataPage(newPageOffset, filteredOperations) + } + + fun Chain.evmTransfersApi(): Chain.ExternalApi.Transfers.Evm? { + return externalApi() + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/SubstrateAssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/SubstrateAssetHistory.kt new file mode 100644 index 0000000..52698e9 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/SubstrateAssetHistory.kt @@ -0,0 +1,279 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history + +import io.novafoundation.nova.common.data.model.CursorOrFull +import io.novafoundation.nova.common.data.model.DataPage +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.common.data.model.asCursorOrNull +import io.novafoundation.nova.common.utils.nullIfEmpty +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRate +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.convertPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.findNearestCoinRate +import io.novafoundation.nova.feature_wallet_impl.data.network.model.AssetsBySubQueryId +import io.novafoundation.nova.feature_wallet_impl.data.network.model.assetsBySubQueryId +import io.novafoundation.nova.feature_wallet_impl.data.network.model.request.SubqueryHistoryRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.model.response.SubqueryHistoryElementResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.externalApi +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.ext.legacyAddressOfOrNull +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlin.time.Duration.Companion.seconds + +abstract class SubstrateAssetHistory( + private val subqueryApi: SubQueryOperationsApi, + private val cursorStorage: TransferCursorStorage, + private val realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory, + coinPriceRepository: CoinPriceRepository +) : BaseAssetHistory(coinPriceRepository) { + + abstract fun realtimeFetcherSources(chain: Chain): List + + override suspend fun fetchOperationsForBalanceChange( + chain: Chain, + chainAsset: Chain.Asset, + blockHash: String, + accountId: AccountId + ): List { + val sources = realtimeFetcherSources(chain) + val realtimeOperationFetcher = realtimeOperationFetcherFactory.create(sources) + + return realtimeOperationFetcher.extractRealtimeHistoryUpdates(chain, chainAsset, blockHash) + } + + override suspend fun additionalFirstPageSync( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + pageResult: Result> + ) { + pageResult + .onSuccess { page -> + val newCursor = page.nextOffset.asCursorOrNull()?.value + cursorStorage.saveCursor(chain.id, chainAsset.id, accountId, newCursor) + } + .onFailure { + // Empty cursor means we haven't yet synced any data for this asset + // However we still want to store null cursor on failure to show items + // that came not from the remote (e.g. local pending operations) + if (!cursorStorage.hasCursor(chain.id, chainAsset.id, accountId)) { + cursorStorage.saveCursor(chain.id, chainAsset.id, accountId, cursor = null) + } + } + } + + override suspend fun getOperations( + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency, + ): DataPage { + val substrateTransfersApi = chain.substrateTransfersApi() + + return if (substrateTransfersApi != null) { + getOperationsInternal( + pageSize = pageSize, + pageOffset = pageOffset, + filters = filters, + accountId = accountId, + apiUrl = substrateTransfersApi.url, + chainAsset = chainAsset, + chain = chain, + currency = currency + ) + } else { + DataPage.empty() + } + } + + override suspend fun getSyncedPageOffset(accountId: AccountId, chain: Chain, chainAsset: Chain.Asset): PageOffset { + val substrateTransfersApi = chain.substrateTransfersApi() + + return if (substrateTransfersApi != null) { + val cursor = cursorStorage.awaitCursor(chain.id, chainAsset.id, accountId) + + PageOffset.CursorOrFull(cursor) + } else { + PageOffset.FullData + } + } + + override fun isOperationSafe(operation: Operation): Boolean { + return true + } + + private suspend fun getOperationsInternal( + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency, + apiUrl: String + ): DataPage { + val cursor = when (pageOffset) { + is PageOffset.Loadable.Cursor -> pageOffset.value + PageOffset.Loadable.FirstPage -> null + else -> error("SubQuery requires cursor pagination") + } + + val request = SubqueryHistoryRequest( + accountAddress = chain.addressOf(accountId), + legacyAccountAddress = chain.legacyAddressOfOrNull(accountId), + pageSize = pageSize, + cursor = cursor, + filters = filters, + asset = chainAsset, + chain = chain + ) + + val subqueryResponse = subqueryApi.getOperationsHistory(apiUrl, request).data.query + + val priceHistory = getPriceHistory(chainAsset, currency) + + val assetsBySubQueryId = chain.assetsBySubQueryId() + + val operations = subqueryResponse.historyElements.nodes.mapNotNull { node -> + val coinRate = priceHistory.findNearestCoinRate(node.timestamp) + mapNodeToOperation(node, coinRate, chainAsset, assetsBySubQueryId) + } + + val pageInfo = subqueryResponse.historyElements.pageInfo + val newPageOffset = PageOffset.CursorOrFull(pageInfo.endCursor) + + return DataPage(newPageOffset, operations) + } + + private fun Chain.substrateTransfersApi(): Chain.ExternalApi.Transfers.Substrate? { + return externalApi() + } + + private fun mapNodeToOperation( + node: SubqueryHistoryElementResponse.Query.HistoryElements.Node, + coinRate: CoinRate?, + chainAsset: Chain.Asset, + chainAssetsBySubQueryId: AssetsBySubQueryId + ): Operation? { + val type: Operation.Type + val status: Operation.Status + + when { + node.reward != null -> with(node.reward) { + val planks = amount?.toBigIntegerOrNull().orZero() + + type = Operation.Type.Reward( + amount = planks, + fiatAmount = coinRate?.convertPlanks(chainAsset, planks), + isReward = isReward, + kind = Operation.Type.Reward.RewardKind.Direct( + era = era, + validator = validator.nullIfEmpty(), + ), + eventId = eventId(node.blockNumber, node.reward.eventIdx) + ) + status = Operation.Status.COMPLETED + } + + node.poolReward != null -> with(node.poolReward) { + type = Operation.Type.Reward( + amount = amount, + fiatAmount = coinRate?.convertPlanks(chainAsset, amount), + isReward = isReward, + kind = Operation.Type.Reward.RewardKind.Pool( + poolId = poolId + ), + eventId = eventId(node.blockNumber, node.poolReward.eventIdx) + ) + status = Operation.Status.COMPLETED + } + + node.extrinsic != null -> with(node.extrinsic) { + type = Operation.Type.Extrinsic( + content = Operation.Type.Extrinsic.Content.SubstrateCall(module, call), + fee = fee, + fiatFee = coinRate?.convertPlanks(chainAsset, fee), + ) + status = Operation.Status.fromSuccess(success) + } + + node.transfer != null -> with(node.transfer) { + type = Operation.Type.Transfer( + myAddress = node.address, + amount = amount, + fiatAmount = coinRate?.convertPlanks(chainAsset, amount), + receiver = to, + sender = from, + fee = fee, + ) + status = Operation.Status.fromSuccess(success) + } + + node.assetTransfer != null -> with(node.assetTransfer) { + type = Operation.Type.Transfer( + myAddress = node.address, + amount = amount, + fiatAmount = coinRate?.convertPlanks(chainAsset, amount), + receiver = to, + sender = from, + fee = fee, + ) + status = Operation.Status.fromSuccess(success) + } + + node.swap != null -> with(node.swap) { + val assetIn = chainAssetsBySubQueryId[assetIdIn] ?: return null + val assetOut = chainAssetsBySubQueryId[assetIdOut] ?: return null + val assetFee = chainAssetsBySubQueryId[assetIdFee] ?: return null + + val amount = if (assetIn.fullId == chainAsset.fullId) amountIn else amountOut + + type = Operation.Type.Swap( + fee = ChainAssetWithAmount( + chainAsset = assetFee, + amount = fee, + ), + amountIn = ChainAssetWithAmount( + chainAsset = assetIn, + amount = amountIn, + ), + amountOut = ChainAssetWithAmount( + chainAsset = assetOut, + amount = amountOut, + ), + fiatAmount = coinRate?.convertPlanks(chainAsset, amount) + ) + status = Operation.Status.fromSuccess(success ?: true) + } + + else -> return null + } + + return Operation( + id = node.id, + address = node.address, + type = type, + time = node.timestamp.seconds.inWholeMilliseconds, + chainAsset = chainAsset, + extrinsicHash = node.extrinsicHash, + status = status + ) + } + + private fun eventId(blockNumber: Long, eventIdx: Int): String { + return "$blockNumber-$eventIdx" + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/UnsupportedAssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/UnsupportedAssetHistory.kt new file mode 100644 index 0000000..92f1db8 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/UnsupportedAssetHistory.kt @@ -0,0 +1,51 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history + +import io.novafoundation.nova.common.data.model.DataPage +import io.novafoundation.nova.common.data.model.PageOffset +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId + +class UnsupportedAssetHistory : AssetHistory { + + override suspend fun fetchOperationsForBalanceChange( + chain: Chain, + chainAsset: Chain.Asset, + blockHash: String, + accountId: AccountId + ): List { + return emptyList() + } + + override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set { + return emptySet() + } + + override suspend fun additionalFirstPageSync(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId, page: Result>) { + // do nothing + } + + override suspend fun getOperations( + pageSize: Int, + pageOffset: PageOffset.Loadable, + filters: Set, + accountId: AccountId, + chain: Chain, + chainAsset: Chain.Asset, + currency: Currency + ): DataPage { + return DataPage.empty() + } + + override suspend fun getSyncedPageOffset(accountId: AccountId, chain: Chain, chainAsset: Chain.Asset): PageOffset { + return PageOffset.FullData + } + + override fun isOperationSafe(operation: Operation): Boolean { + return false + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/equilibrium/EquilibriumAssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/equilibrium/EquilibriumAssetHistory.kt new file mode 100644 index 0000000..ee1eefc --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/equilibrium/EquilibriumAssetHistory.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.equilibrium + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.eqBalances +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory.Source +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.asSource +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.SubstrateAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.metadata.call + +class EquilibriumAssetHistory( + private val chainRegistry: ChainRegistry, + walletOperationsApi: SubQueryOperationsApi, + cursorStorage: TransferCursorStorage, + coinPriceRepository: CoinPriceRepository, + realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory +) : SubstrateAssetHistory(walletOperationsApi, cursorStorage, realtimeOperationFetcherFactory, coinPriceRepository) { + + override fun realtimeFetcherSources(chain: Chain): List { + return listOf(TransferExtractor().asSource()) + } + + override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set { + return setOfNotNull( + TransactionFilter.TRANSFER, + TransactionFilter.EXTRINSIC.takeIf { asset.isUtilityAsset } + ) + } + + private inner class TransferExtractor : SubstrateRealtimeOperationFetcher.Extractor { + + override suspend fun extractRealtimeHistoryUpdates( + extrinsicVisit: ExtrinsicVisit, + chain: Chain, + chainAsset: Chain.Asset + ): RealtimeHistoryUpdate.Type? { + val runtime = chainRegistry.getRuntime(chain.id) + + val call = extrinsicVisit.call + if (!call.isTransfer(runtime)) return null + + val amount = bindNumber(call.arguments["value"]) + + return RealtimeHistoryUpdate.Type.Transfer( + senderId = extrinsicVisit.origin, + recipientId = bindAccountIdentifier(call.arguments["to"]), + amountInPlanks = amount, + chainAsset = chainAsset, + ) + } + + private fun GenericCall.Instance.isTransfer(runtime: RuntimeSnapshot): Boolean { + val balances = runtime.metadata.eqBalances() + + return instanceOf(balances.call("transfer")) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/evmErc20/EvmErc20AssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/evmErc20/EvmErc20AssetHistory.kt new file mode 100644 index 0000000..d2cc847 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/evmErc20/EvmErc20AssetHistory.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.evmErc20 + +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRate +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.convertPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.findNearestCoinRate +import io.novafoundation.nova.feature_wallet_api.domain.model.isZeroTransfer +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.EvmAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.EtherscanTransactionsApi +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanAccountTransfer +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.feeUsed +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.requireErc20 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlin.time.Duration.Companion.seconds + +class EvmErc20AssetHistory( + private val etherscanTransactionsApi: EtherscanTransactionsApi, + coinPriceRepository: CoinPriceRepository +) : EvmAssetHistory(coinPriceRepository) { + + override suspend fun fetchEtherscanOperations( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + apiUrl: String, + page: Int, + pageSize: Int, + currency: Currency + ): List { + val erc20Config = chainAsset.requireErc20() + val accountAddress = chain.addressOf(accountId) + + val response = etherscanTransactionsApi.getErc20Transfers( + baseUrl = apiUrl, + contractAddress = erc20Config.contractAddress, + accountAddress = accountAddress, + pageNumber = page, + pageSize = pageSize, + chainId = chain.id + ) + + val priceHistory = getPriceHistory(chainAsset, currency) + + return response.result.map { + val coinRate = priceHistory.findNearestCoinRate(it.timeStamp) + mapRemoteTransferToOperation(it, chainAsset, accountAddress, coinRate) + } + } + + override suspend fun fetchOperationsForBalanceChange( + chain: Chain, + chainAsset: Chain.Asset, + blockHash: String, + accountId: AccountId, + ): List { + // we fetch transfers alongside with balance updates in EvmAssetBalance + return emptyList() + } + + override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set { + return setOf(TransactionFilter.TRANSFER) + } + + override fun isOperationSafe(operation: Operation): Boolean { + return !operation.isZeroTransfer() + } + + private fun mapRemoteTransferToOperation( + remote: EtherscanAccountTransfer, + chainAsset: Chain.Asset, + accountAddress: String, + coinRate: CoinRate? + ): Operation { + return Operation( + id = remote.hash, + address = accountAddress, + extrinsicHash = remote.hash, + type = Operation.Type.Transfer( + myAddress = accountAddress, + amount = remote.value, + fiatAmount = coinRate?.convertPlanks(chainAsset, remote.value), + receiver = remote.to, + sender = remote.from, + fee = remote.feeUsed + ), + time = remote.timeStamp.seconds.inWholeMilliseconds, + chainAsset = chainAsset, + status = Operation.Status.COMPLETED, + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/evmNative/EvmNativeAssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/evmNative/EvmNativeAssetHistory.kt new file mode 100644 index 0000000..91b4648 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/evmNative/EvmNativeAssetHistory.kt @@ -0,0 +1,189 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.evmNative + +import io.novafoundation.nova.common.utils.ethereumAddressToAccountId +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRate +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type.Extrinsic.Content +import io.novafoundation.nova.feature_wallet_api.domain.model.convertPlanks +import io.novafoundation.nova.feature_wallet_api.domain.model.findNearestCoinRate +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.EvmAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.EtherscanTransactionsApi +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanNormalTxResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.feeUsed +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.isTransfer +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import org.web3j.protocol.core.methods.response.EthBlock +import org.web3j.protocol.core.methods.response.EthBlock.TransactionResult +import org.web3j.protocol.core.methods.response.Transaction +import org.web3j.protocol.core.methods.response.TransactionReceipt +import java.math.BigInteger +import kotlin.jvm.optionals.getOrNull +import kotlin.time.Duration.Companion.seconds + +class EvmNativeAssetHistory( + private val chainRegistry: ChainRegistry, + private val etherscanTransactionsApi: EtherscanTransactionsApi, + coinPriceRepository: CoinPriceRepository +) : EvmAssetHistory(coinPriceRepository) { + + override suspend fun fetchEtherscanOperations( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + apiUrl: String, + page: Int, + pageSize: Int, + currency: Currency + ): List { + val accountAddress = chain.addressOf(accountId) + + val response = etherscanTransactionsApi.getNormalTxsHistory( + baseUrl = apiUrl, + accountAddress = accountAddress, + pageNumber = page, + pageSize = pageSize, + chainId = chain.id + ) + + val priceHistory = getPriceHistory(chainAsset, currency) + + return response.result + .map { + val coinRate = priceHistory.findNearestCoinRate(it.timeStamp) + mapRemoteNormalTxToOperation(it, chainAsset, accountAddress, coinRate) + } + } + + @OptIn(ExperimentalStdlibApi::class) + @Suppress("UNCHECKED_CAST") + override suspend fun fetchOperationsForBalanceChange( + chain: Chain, + chainAsset: Chain.Asset, + blockHash: String, + accountId: AccountId, + ): List { + val ethereumApi = chainRegistry.getCallEthereumApiOrThrow(chain.id) + + val block = ethereumApi.ethGetBlockByHash(blockHash, true).sendSuspend() + val txs = block.block.transactions as List> + + return txs.mapNotNull { + val tx = it.get() + + val isTransfer = tx.input.removeHexPrefix().isEmpty() + val relatesToUs = tx.relatesTo(accountId) + + if (!(isTransfer && relatesToUs)) return@mapNotNull null + + val txReceipt = ethereumApi.ethGetTransactionReceipt(tx.hash).sendSuspend().transactionReceipt.getOrNull() + + RealtimeHistoryUpdate( + status = txReceipt.extrinsicStatus(), + txHash = tx.hash, + type = RealtimeHistoryUpdate.Type.Transfer( + senderId = chain.accountIdOf(tx.from), + recipientId = chain.accountIdOf(tx.to), + amountInPlanks = tx.value, + chainAsset = chainAsset, + ) + ) + } + } + + override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set { + return setOf(TransactionFilter.TRANSFER, TransactionFilter.EXTRINSIC) + } + + override fun isOperationSafe(operation: Operation): Boolean { + return true + } + + private fun TransactionReceipt?.extrinsicStatus(): Operation.Status { + return when (this?.isStatusOK) { + true -> Operation.Status.COMPLETED + false -> Operation.Status.FAILED + null -> Operation.Status.PENDING + } + } + + private fun Transaction.relatesTo(accountId: AccountId): Boolean { + return from.ethAccountIdMatches(accountId) || to.ethAccountIdMatches(accountId) + } + + private fun String?.ethAccountIdMatches(other: AccountId): Boolean { + return other.contentEquals(this?.ethereumAddressToAccountId()) + } + + private fun mapRemoteNormalTxToOperation( + remote: EtherscanNormalTxResponse, + chainAsset: Chain.Asset, + accountAddress: String, + coinRate: CoinRate? + ): Operation { + val type = if (remote.isTransfer) { + mapNativeTransferToTransfer(remote, accountAddress, chainAsset, coinRate) + } else { + mapContractCallToExtrinsic(remote, chainAsset, coinRate) + } + + return Operation( + id = remote.hash, + address = accountAddress, + type = type, + time = remote.timeStamp.seconds.inWholeMilliseconds, + chainAsset = chainAsset, + extrinsicHash = remote.hash, + status = remote.operationStatus(), + ) + } + + private fun mapNativeTransferToTransfer( + remote: EtherscanNormalTxResponse, + accountAddress: String, + chainAsset: Chain.Asset, + coinRate: CoinRate? + ): Operation.Type.Transfer { + return Operation.Type.Transfer( + myAddress = accountAddress, + amount = remote.value, + fiatAmount = coinRate?.convertPlanks(chainAsset, remote.value), + receiver = remote.to, + sender = remote.from, + fee = remote.feeUsed + ) + } + + private fun mapContractCallToExtrinsic( + remote: EtherscanNormalTxResponse, + chainAsset: Chain.Asset, + coinRate: CoinRate? + ): Operation.Type.Extrinsic { + return Operation.Type.Extrinsic( + content = Content.ContractCall( + contractAddress = remote.to, + function = remote.functionName, + ), + fee = remote.feeUsed, + fiatFee = coinRate?.convertPlanks(chainAsset, remote.feeUsed), + ) + } + + private fun EtherscanNormalTxResponse.operationStatus(): Operation.Status { + return if (txReceiptStatus == BigInteger.ONE) { + Operation.Status.COMPLETED + } else { + Operation.Status.FAILED + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/orml/OrmlAssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/orml/OrmlAssetHistory.kt new file mode 100644 index 0000000..771a399 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/orml/OrmlAssetHistory.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.orml + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.currenciesOrNull +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.common.utils.tokens +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory.Source +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.asSource +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.SubstrateAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.runtime.ext.findAssetByOrmlCurrencyId +import io.novafoundation.nova.runtime.ext.hydraDxSupported +import io.novafoundation.nova.runtime.ext.isSwapSupported +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.metadata.call +import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull + +class OrmlAssetHistory( + private val chainRegistry: ChainRegistry, + realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory, + walletOperationsApi: SubQueryOperationsApi, + cursorStorage: TransferCursorStorage, + coinPriceRepository: CoinPriceRepository +) : SubstrateAssetHistory(walletOperationsApi, cursorStorage, realtimeOperationFetcherFactory, coinPriceRepository) { + + override fun realtimeFetcherSources(chain: Chain): List { + return buildList { + add(TransferExtractor().asSource()) + + if (chain.swap.hydraDxSupported()) { + add(Source.Known.Id.HYDRA_DX_SWAP.asSource()) + } + } + } + + override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set { + return setOfNotNull( + TransactionFilter.TRANSFER, + TransactionFilter.EXTRINSIC.takeIf { asset.isUtilityAsset }, + TransactionFilter.SWAP.takeIf { chain.isSwapSupported() } + ) + } + + private inner class TransferExtractor : SubstrateRealtimeOperationFetcher.Extractor { + + override suspend fun extractRealtimeHistoryUpdates( + extrinsicVisit: ExtrinsicVisit, + chain: Chain, + chainAsset: Chain.Asset + ): RealtimeHistoryUpdate.Type? { + val runtime = chainRegistry.getRuntime(chain.id) + + val call = extrinsicVisit.call + if (!call.isTransfer(runtime)) return null + + val inferredAsset = chain.findAssetByOrmlCurrencyId(runtime, call.arguments["currency_id"]) ?: return null + val amount = bindNumber(call.arguments["amount"]) + + return RealtimeHistoryUpdate.Type.Transfer( + senderId = extrinsicVisit.origin, + recipientId = bindAccountIdentifier(call.arguments["dest"]), + amountInPlanks = amount, + chainAsset = inferredAsset, + ) + } + + private fun GenericCall.Instance.isTransfer(runtime: RuntimeSnapshot): Boolean { + val transferCall = runtime.metadata.currenciesOrNull()?.callOrNull("transfer") + ?: runtime.metadata.tokens().call("transfer") + + return instanceOf(transferCall) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/AssetConversionSwapExtractor.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/AssetConversionSwapExtractor.kt new file mode 100644 index 0000000..c523afc --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/AssetConversionSwapExtractor.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate + +import io.novafoundation.nova.feature_xcm_api.multiLocation.bindMultiLocation +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.assetTxFeePaidEvent +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvents +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.requireNativeFee +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.coroutineContext + +class AssetConversionSwapExtractor( + private val multiLocationConverterFactory: MultiLocationConverterFactory, +) : SubstrateRealtimeOperationFetcher.Extractor { + + private val calls = listOf("swap_exact_tokens_for_tokens", "swap_tokens_for_exact_tokens") + + override suspend fun extractRealtimeHistoryUpdates( + extrinsicVisit: ExtrinsicVisit, + chain: Chain, + chainAsset: Chain.Asset + ): RealtimeHistoryUpdate.Type? { + val call = extrinsicVisit.call + val callArgs = call.arguments + + if (!call.isSwap()) return null + + val scope = CoroutineScope(coroutineContext) + val multiLocationConverter = multiLocationConverterFactory.defaultAsync(chain, scope) + + val path = bindList(callArgs["path"], ::bindMultiLocation) + val assetIn = multiLocationConverter.toChainAsset(path.first()) ?: return null + val assetOut = multiLocationConverter.toChainAsset(path.last()) ?: return null + + val (amountIn, amountOut) = extrinsicVisit.extractSwapAmounts() + + val sendTo = bindAccountId(callArgs["send_to"]) + + val fee = extrinsicVisit.extractFee(chain, multiLocationConverter) + + return RealtimeHistoryUpdate.Type.Swap( + amountIn = ChainAssetWithAmount(assetIn, amountIn), + amountOut = ChainAssetWithAmount(assetOut, amountOut), + amountFee = fee, + senderId = extrinsicVisit.origin, + receiverId = sendTo + ) + } + + private fun ExtrinsicVisit.extractSwapAmounts(): Pair { + // We check for custom fee usage from root extrinsic since `extrinsicVisit` will cut it out when nested calls are present + val isCustomFeeTokenUsed = rootExtrinsic.events.assetTxFeePaidEvent() != null + val allSwaps = events.findEvents(Modules.ASSET_CONVERSION, "SwapExecuted") + + val swapExecutedEvent = when { + !success -> null // we wont be able to extract swap from event + + isCustomFeeTokenUsed -> { + // Swaps with custom fee token produce up to free SwapExecuted events, in the following order: + // SwapExecuted (Swap custom token fee to native token) - always present + // SwapExecuted (Real swap) - always present + // SwapExecuted (Refund remaining fee back to custom token) + // So we need to take the middle one + + allSwaps.getOrNull(1) + } + + else -> { + // Only one swap is possible in case + allSwaps.firstOrNull() + } + } + + return when { + // successful swap, extract from event + swapExecutedEvent != null -> { + val (_, _, amountIn, amountOut) = swapExecutedEvent.arguments + + bindNumber(amountIn) to bindNumber(amountOut) + } + + // failed swap, extract from call args + call.function.name == "swap_exact_tokens_for_tokens" -> { + val amountIn = bindNumber(call.arguments["amount_in"]) + val amountOutMin = bindNumber(call.arguments["amount_out_min"]) + + amountIn to amountOutMin + } + + call.function.name == "swap_tokens_for_exact_tokens" -> { + val amountOut = bindNumber(call.arguments["amount_out"]) + val amountInMax = bindNumber(call.arguments["amount_in_max"]) + + amountInMax to amountOut + } + + else -> error("Unknown call") + } + } + + private suspend fun ExtrinsicVisit.extractFee( + chain: Chain, + multiLocationConverter: MultiLocationConverter + ): ChainAssetWithAmount { + // We check for fee usage from root extrinsic since `extrinsicVisit` will cut it out when nested calls are present + val assetFee = rootExtrinsic.events.assetFee(multiLocationConverter) + if (assetFee != null) return assetFee + + val nativeFee = rootExtrinsic.events.requireNativeFee() + return ChainAssetWithAmount(chain.commissionAsset, nativeFee) + } + + private fun GenericCall.Instance.isSwap(): Boolean { + return module.name == Modules.ASSET_CONVERSION && + function.name in calls + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/Common.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/Common.kt new file mode 100644 index 0000000..5a6662c --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/Common.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate + +import io.novafoundation.nova.feature_xcm_api.multiLocation.bindMultiLocation +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.assetTxFeePaidEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +suspend fun List.assetFee(multiLocationConverter: MultiLocationConverter): ChainAssetWithAmount? { + val event = assetTxFeePaidEvent() ?: return null + val (_, actualFee, tip, assetId) = event.arguments + val totalFee = bindNumber(actualFee) + bindNumber(tip) + val chainAsset = multiLocationConverter.toChainAsset(bindMultiLocation(assetId)) ?: return null + + return ChainAssetWithAmount(chainAsset, totalFee) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/RealSubstrateRealtimeOperationFetcher.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/RealSubstrateRealtimeOperationFetcher.kt new file mode 100644 index 0000000..4b7a4a0 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/RealSubstrateRealtimeOperationFetcher.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate + +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Extractor +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory +import io.novafoundation.nova.feature_wallet_api.domain.model.Operation +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx.HydraDxOmniPoolSwapExtractor +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx.HydraDxRouterSwapExtractor +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.walkToList +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository + +internal class SubstrateRealtimeOperationFetcherFactory( + private val multiLocationConverterFactory: MultiLocationConverterFactory, + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, + private val eventsRepository: EventsRepository, + private val extrinsicWalk: ExtrinsicWalk, +) : Factory { + + override fun create(sources: List): SubstrateRealtimeOperationFetcher { + val extractors = sources.flatMap { it.extractors() } + + return RealSubstrateRealtimeOperationFetcher(eventsRepository, extractors, extrinsicWalk) + } + + private fun Factory.Source.extractors(): List { + return when (this) { + is Factory.Source.FromExtractor -> listOf(extractor) + is Factory.Source.Known -> id.extractors() + } + } + + private fun Factory.Source.Known.Id.extractors(): List { + return when (this) { + Factory.Source.Known.Id.ASSET_CONVERSION_SWAP -> listOf(assetConversionSwap()) + Factory.Source.Known.Id.HYDRA_DX_SWAP -> listOf(hydraDxOmniPoolSwap(), hydraDxRouterSwap()) + } + } + + private fun assetConversionSwap(): Extractor { + return AssetConversionSwapExtractor(multiLocationConverterFactory) + } + + private fun hydraDxOmniPoolSwap(): Extractor { + return HydraDxOmniPoolSwapExtractor(hydraDxAssetIdConverter) + } + + private fun hydraDxRouterSwap(): Extractor { + return HydraDxRouterSwapExtractor(hydraDxAssetIdConverter) + } +} + +private class RealSubstrateRealtimeOperationFetcher( + private val repository: EventsRepository, + private val extractors: List, + private val callWalk: ExtrinsicWalk, +) : SubstrateRealtimeOperationFetcher { + + override suspend fun extractRealtimeHistoryUpdates( + chain: Chain, + chainAsset: Chain.Asset, + blockHash: String + ): List { + val extrinsicWithEvents = repository.getBlockEvents(chain.id, blockHash).applyExtrinsic + + return extrinsicWithEvents.flatMap { extrinsic -> + val visits = runCatching { callWalk.walkToList(extrinsic, chain.id) }.getOrElse { emptyList() } + + visits.flatMap { extrinsicVisit -> + extractors.mapNotNull { + val type = runCatching { it.extractRealtimeHistoryUpdates(extrinsicVisit, chain, chainAsset) }.getOrNull() ?: return@mapNotNull null + + RealtimeHistoryUpdate( + txHash = extrinsic.extrinsicHash, + status = Operation.Status.fromSuccess(extrinsicVisit.success), + type = type + ) + } + } + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/hydraDx/BaseHydraDxSwapExtractor.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/hydraDx/BaseHydraDxSwapExtractor.kt new file mode 100644 index 0000000..93465f4 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/hydraDx/BaseHydraDxSwapExtractor.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findLastEvent +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.requireNativeFee +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +abstract class BaseHydraDxSwapExtractor( + private val hydraDxAssetIdConverter: HydraDxAssetIdConverter, +) : SubstrateRealtimeOperationFetcher.Extractor { + + abstract fun isSwap(call: GenericCall.Instance): Boolean + + protected abstract fun ExtrinsicVisit.extractSwapArgs(): SwapArgs + + override suspend fun extractRealtimeHistoryUpdates( + extrinsicVisit: ExtrinsicVisit, + chain: Chain, + chainAsset: Chain.Asset + ): RealtimeHistoryUpdate.Type? { + if (!isSwap(extrinsicVisit.call)) return null + + val (assetIdIn, assetIdOut, amountIn, amountOut) = extrinsicVisit.extractSwapArgs() + + val assetIn = hydraDxAssetIdConverter.toChainAssetOrNull(chain, assetIdIn) ?: return null + val assetOut = hydraDxAssetIdConverter.toChainAssetOrNull(chain, assetIdOut) ?: return null + + val fee = extrinsicVisit.extractFee(chain) + + return RealtimeHistoryUpdate.Type.Swap( + amountIn = ChainAssetWithAmount(assetIn, amountIn), + amountOut = ChainAssetWithAmount(assetOut, amountOut), + amountFee = fee, + senderId = extrinsicVisit.origin, + receiverId = extrinsicVisit.origin + ) + } + + private suspend fun ExtrinsicVisit.extractFee(chain: Chain): ChainAssetWithAmount { + val feeDepositEvent = rootExtrinsic.events.findLastEvent(Modules.CURRENCIES, "Deposited") ?: return nativeFee(chain) + + val (currencyIdRaw, _, amountRaw) = feeDepositEvent.arguments + val currencyId = bindNumber(currencyIdRaw) + + val feeAsset = hydraDxAssetIdConverter.toChainAssetOrNull(chain, currencyId) ?: return nativeFee(chain) + + return ChainAssetWithAmount(feeAsset, bindNumber(amountRaw)) + } + + private fun ExtrinsicVisit.nativeFee(chain: Chain): ChainAssetWithAmount { + return ChainAssetWithAmount(chain.utilityAsset, rootExtrinsic.events.requireNativeFee()) + } + + protected data class SwapArgs( + val assetIn: HydraDxAssetId, + val assetOut: HydraDxAssetId, + val amountIn: HydraDxAssetId, + val amountOut: HydraDxAssetId + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/hydraDx/HydraDxOmniPoolSwapExtractor.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/hydraDx/HydraDxOmniPoolSwapExtractor.kt new file mode 100644 index 0000000..526eb2b --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/hydraDx/HydraDxOmniPoolSwapExtractor.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class HydraDxOmniPoolSwapExtractor( + hydraDxAssetIdConverter: HydraDxAssetIdConverter, +) : BaseHydraDxSwapExtractor(hydraDxAssetIdConverter) { + + private val calls = listOf("buy", "sell") + + override fun isSwap(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.OMNIPOOL && + call.function.name in calls + } + + override fun ExtrinsicVisit.extractSwapArgs(): SwapArgs { + val swapExecutedEvent = events.findEvent(Modules.OMNIPOOL, "BuyExecuted") + ?: events.findEvent(Modules.OMNIPOOL, "SellExecuted") + + return when { + // successful swap, extract from event + swapExecutedEvent != null -> { + val (_, assetIn, assetOut, amountIn, amountOut) = swapExecutedEvent.arguments + + SwapArgs( + assetIn = bindNumber(assetIn), + assetOut = bindNumber(assetOut), + amountIn = bindNumber(amountIn), + amountOut = bindNumber(amountOut) + ) + } + + // failed swap, extract from call args + call.function.name == "sell" -> { + SwapArgs( + assetIn = bindNumber(call.arguments["asset_in"]), + assetOut = bindNumber(call.arguments["asset_out"]), + amountIn = bindNumber(call.arguments["amount"]), + amountOut = bindNumber(call.arguments["min_buy_amount"]) + ) + } + + call.function.name == "buy" -> { + SwapArgs( + assetIn = bindNumber(call.arguments["asset_in"]), + assetOut = bindNumber(call.arguments["asset_out"]), + amountIn = bindNumber(call.arguments["max_sell_amount"]), + amountOut = bindNumber(call.arguments["amount"]) + ) + } + + else -> error("Unknown call") + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/hydraDx/HydraDxRouterSwapExtractor.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/hydraDx/HydraDxRouterSwapExtractor.kt new file mode 100644 index 0000000..2eeba92 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/realtime/substrate/hydraDx/HydraDxRouterSwapExtractor.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +class HydraDxRouterSwapExtractor( + hydraDxAssetIdConverter: HydraDxAssetIdConverter, +) : BaseHydraDxSwapExtractor(hydraDxAssetIdConverter) { + + private val calls = listOf("buy", "sell") + + override fun isSwap(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.ROUTER && + call.function.name in calls + } + + override fun ExtrinsicVisit.extractSwapArgs(): SwapArgs { + val swapExecutedEvent = events.findEvent(Modules.ROUTER, "RouteExecuted") + ?: events.findEvent(Modules.ROUTER, "Executed") + + return when { + // successful swap, extract from event + swapExecutedEvent != null -> { + val (assetIn, assetOut, amountIn, amountOut) = swapExecutedEvent.arguments + + SwapArgs( + assetIn = bindNumber(assetIn), + assetOut = bindNumber(assetOut), + amountIn = bindNumber(amountIn), + amountOut = bindNumber(amountOut) + ) + } + + // failed swap, extract from call args + call.function.name == "sell" -> { + SwapArgs( + assetIn = bindNumber(call.arguments["asset_in"]), + assetOut = bindNumber(call.arguments["asset_out"]), + amountIn = bindNumber(call.arguments["amount_in"]), + amountOut = bindNumber(call.arguments["min_amount_out"]) + ) + } + + call.function.name == "buy" -> { + SwapArgs( + assetIn = bindNumber(call.arguments["asset_in"]), + assetOut = bindNumber(call.arguments["asset_out"]), + amountIn = bindNumber(call.arguments["max_amount_in"]), + amountOut = bindNumber(call.arguments["amount_out"]) + ) + } + + else -> error("Unknown call") + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/statemine/StatemineAssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/statemine/StatemineAssetHistory.kt new file mode 100644 index 0000000..44c613a --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/statemine/StatemineAssetHistory.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.statemine + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.oneOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory.Source +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.asSource +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.SubstrateAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.runtime.ext.assetConversionSupported +import io.novafoundation.nova.runtime.ext.isSwapSupported +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.ext.palletNameOrDefault +import io.novafoundation.nova.runtime.ext.requireStatemine +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.hasSameId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.metadata.call +import io.novasama.substrate_sdk_android.runtime.metadata.module + +class StatemineAssetHistory( + private val chainRegistry: ChainRegistry, + realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory, + walletOperationsApi: SubQueryOperationsApi, + cursorStorage: TransferCursorStorage, + coinPriceRepository: CoinPriceRepository +) : SubstrateAssetHistory(walletOperationsApi, cursorStorage, realtimeOperationFetcherFactory, coinPriceRepository) { + + override fun realtimeFetcherSources(chain: Chain): List { + return buildList { + add(TransferExtractor().asSource()) + + if (chain.swap.assetConversionSupported()) { + add(Source.Known.Id.ASSET_CONVERSION_SWAP.asSource()) + } + } + } + + override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set { + return setOfNotNull( + TransactionFilter.TRANSFER, + TransactionFilter.EXTRINSIC.takeIf { asset.isUtilityAsset }, + TransactionFilter.SWAP.takeIf { chain.isSwapSupported() } + ) + } + + private inner class TransferExtractor : SubstrateRealtimeOperationFetcher.Extractor { + + override suspend fun extractRealtimeHistoryUpdates( + extrinsicVisit: ExtrinsicVisit, + chain: Chain, + chainAsset: Chain.Asset + ): RealtimeHistoryUpdate.Type? { + val runtime = chainRegistry.getRuntime(chain.id) + + val call = extrinsicVisit.call + if (!call.isTransfer(runtime, chainAsset)) return null + + val amount = bindNumber(call.arguments["amount"]) + + return RealtimeHistoryUpdate.Type.Transfer( + senderId = extrinsicVisit.origin, + recipientId = bindAccountIdentifier(call.arguments["target"]), + amountInPlanks = amount, + chainAsset = chainAsset, + ) + } + + private fun GenericCall.Instance.isTransfer(runtime: RuntimeSnapshot, chainAsset: Chain.Asset): Boolean { + val statemineType = chainAsset.requireStatemine() + val moduleName = statemineType.palletNameOrDefault() + val module = runtime.metadata.module(moduleName) + + val matchingCall = oneOf( + module.call("transfer"), + module.call("transfer_keep_alive"), + ) + + return matchingCall && statemineType.hasSameId(runtime, dynamicInstanceId = arguments["id"]) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/utility/NativeAssetHistory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/utility/NativeAssetHistory.kt new file mode 100644 index 0000000..28c482d --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/history/utility/NativeAssetHistory.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.utility + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.common.utils.oneOf +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory.Source +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.asSource +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.SubstrateAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.runtime.ext.assetConversionSupported +import io.novafoundation.nova.runtime.ext.hydraDxSupported +import io.novafoundation.nova.runtime.ext.isSwapSupported +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull + +class NativeAssetHistory( + private val chainRegistry: ChainRegistry, + realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory, + walletOperationsApi: SubQueryOperationsApi, + cursorStorage: TransferCursorStorage, + coinPriceRepository: CoinPriceRepository +) : SubstrateAssetHistory(walletOperationsApi, cursorStorage, realtimeOperationFetcherFactory, coinPriceRepository) { + + override fun realtimeFetcherSources(chain: Chain): List { + return buildList { + add(TransferExtractor().asSource()) + + if (chain.swap.assetConversionSupported()) { + Source.Known.Id.ASSET_CONVERSION_SWAP.asSource() + } + + if (chain.swap.hydraDxSupported()) { + add(Source.Known.Id.HYDRA_DX_SWAP.asSource()) + } + } + } + + override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set { + return setOfNotNull( + TransactionFilter.TRANSFER, + TransactionFilter.EXTRINSIC, + TransactionFilter.REWARD, + TransactionFilter.SWAP.takeIf { chain.isSwapSupported() } + ) + } + + private inner class TransferExtractor : SubstrateRealtimeOperationFetcher.Extractor { + + override suspend fun extractRealtimeHistoryUpdates( + extrinsicVisit: ExtrinsicVisit, + chain: Chain, + chainAsset: Chain.Asset + ): RealtimeHistoryUpdate.Type? { + val runtime = chainRegistry.getRuntime(chain.id) + + val call = extrinsicVisit.call + if (!call.isTransfer(runtime)) return null + + val transferEvent = extrinsicVisit.events.findEvent(Modules.BALANCES, "Transfer") ?: return null + val (_, toRaw, amountRaw) = transferEvent.arguments + + return RealtimeHistoryUpdate.Type.Transfer( + senderId = extrinsicVisit.origin, + recipientId = bindAccountIdentifier(toRaw), + amountInPlanks = bindNumber(amountRaw), + chainAsset = chainAsset, + ) + } + + private fun GenericCall.Instance.isTransfer(runtime: RuntimeSnapshot): Boolean { + val balances = runtime.metadata.balances() + + return oneOf( + balances.callOrNull("transfer"), + balances.callOrNull("transfer_keep_alive"), + balances.callOrNull("transfer_allow_death"), + balances.callOrNull("transfer_all") + ) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/BaseAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/BaseAssetTransfers.kt new file mode 100644 index 0000000..29dbec3 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/BaseAssetTransfers.kt @@ -0,0 +1,118 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.createDefault +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.doNotCrossExistentialDepositInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInCommissionAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notPhishingRecipient +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientCommissionBalanceToStayAboveED +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.moduleOrNull +import kotlinx.coroutines.CoroutineScope + +abstract class BaseAssetTransfers( + internal val chainRegistry: ChainRegistry, + private val assetSourceRegistry: AssetSourceRegistry, + private val extrinsicServiceFactory: ExtrinsicService.Factory, + private val phishingValidationFactory: PhishingValidationFactory, + private val enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) : AssetTransfers { + + protected abstract fun ExtrinsicBuilder.transfer(transfer: AssetTransfer) + + /** + * Format: [(Module, Function)] + * Transfers will be enabled if at least one function exists + */ + protected abstract suspend fun transferFunctions(chainAsset: Chain.Asset): List> + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + val submissionOptions = ExtrinsicService.SubmissionOptions(transfer.feePaymentCurrency) + + return extrinsicServiceFactory + .createDefault(coroutineScope) + .submitExtrinsic(transfer.originChain, transfer.sender.intoOrigin(), submissionOptions = submissionOptions) { + transfer(transfer) + } + } + + override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + val submissionOptions = ExtrinsicService.SubmissionOptions(transfer.feePaymentCurrency) + + return extrinsicServiceFactory + .createDefault(coroutineScope) + .submitExtrinsicAndAwaitExecution(transfer.originChain, transfer.sender.intoOrigin(), submissionOptions = submissionOptions) { + transfer(transfer) + } + .requireOk() + .map(TransactionExecution::Substrate) + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + val submissionOptions = ExtrinsicService.SubmissionOptions(transfer.feePaymentCurrency) + + return extrinsicServiceFactory + .createDefault(coroutineScope) + .estimateFee(transfer.originChain, transfer.sender.intoOrigin(), submissionOptions = submissionOptions) { + transfer(transfer) + } + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + val runtime = chainRegistry.getRuntime(chainAsset.chainId) + + return transferFunctions(chainAsset).any { (module, function) -> + runtime.metadata.moduleOrNull(module)?.callOrNull(function) != null + } + } + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + notPhishingRecipient(phishingValidationFactory) + + positiveAmount() + + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + sufficientCommissionBalanceToStayAboveED(enoughTotalToStayAboveEDValidationFactory) + + notDeadRecipientInUsedAsset(assetSourceRegistry) + notDeadRecipientInCommissionAsset(assetSourceRegistry) + + doNotCrossExistentialDeposit() + + recipientCanAcceptTransfer(assetSourceRegistry) + } + + private fun AssetTransfersValidationSystemBuilder.doNotCrossExistentialDeposit() = doNotCrossExistentialDepositInUsedAsset( + assetSourceRegistry = assetSourceRegistry, + extraAmount = { it.transfer.amount }, + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/UnsupportedAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/UnsupportedAssetTransfers.kt new file mode 100644 index 0000000..e552341 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/UnsupportedAssetTransfers.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystem +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope + +open class UnsupportedAssetTransfers : AssetTransfers { + + override fun getValidationSystem(coroutineScope: CoroutineScope): AssetTransfersValidationSystem { + throw UnsupportedOperationException("Unsupported") + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + throw UnsupportedOperationException("Unsupported") + } + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return Result.failure(UnsupportedOperationException("Unsupported")) + } + + override suspend fun performTransferAndAwaitExecution( + transfer: WeightedAssetTransfer, + coroutineScope: CoroutineScope + ): Result { + return Result.failure(UnsupportedOperationException("Unsupported")) + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + return false + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/equilibrium/EquilibriumAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/equilibrium/EquilibriumAssetTransfers.kt new file mode 100644 index 0000000..60ccb24 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/equilibrium/EquilibriumAssetTransfers.kt @@ -0,0 +1,107 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.equilibrium + +import io.novafoundation.nova.common.data.network.runtime.binding.bindBoolean +import io.novafoundation.nova.common.data.network.runtime.binding.returnType +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.eqBalances +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.BaseAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.ext.requireEquilibrium +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.CoroutineScope + +private const val TRANSFER_CALL = "transfer" + +class EquilibriumAssetTransfers( + chainRegistry: ChainRegistry, + private val assetSourceRegistry: AssetSourceRegistry, + extrinsicServiceFactory: ExtrinsicService.Factory, + phishingValidationFactory: PhishingValidationFactory, + private val remoteStorageSource: StorageDataSource, + private val enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) : BaseAssetTransfers(chainRegistry, assetSourceRegistry, extrinsicServiceFactory, phishingValidationFactory, enoughTotalToStayAboveEDValidationFactory) { + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + positiveAmount() + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + notDeadRecipientInUsedAsset(assetSourceRegistry) + recipientCanAcceptTransfer(assetSourceRegistry) + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } + + override fun ExtrinsicBuilder.transfer(transfer: AssetTransfer) { + if (transfer.originChainAsset.type !is Chain.Asset.Type.Equilibrium) return + + val accountId = transfer.originChain.accountIdOrDefault(transfer.recipient) + val amount = transfer.originChainAsset.planksFromAmount(transfer.amount) + + call( + moduleName = Modules.EQ_BALANCES, + callName = TRANSFER_CALL, + arguments = mapOf( + "asset" to transfer.originChainAsset.requireEquilibrium().id, + "to" to accountId, + "value" to amount + ) + ) + } + + override suspend fun transferFunctions(chainAsset: Chain.Asset): List> { + return listOf(Modules.EQ_BALANCES to TRANSFER_CALL) + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + if (chainAsset.type !is Chain.Asset.Type.Equilibrium) return false + + return queryIsTransferEnabledStorage(chainAsset) ?: super.areTransfersEnabled(chainAsset) + } + + private suspend fun queryIsTransferEnabledStorage(chainAsset: Chain.Asset): Boolean? { + return remoteStorageSource.query( + chainAsset.chainId, + keyBuilder = { it.getTransferEnabledStorage().storageKey() }, + binding = { scale, runtimeSnapshot -> + if (scale == null) return@query null + val returnType = runtimeSnapshot.getTransferEnabledStorage().returnType() + bindBoolean(returnType.fromHexOrNull(runtimeSnapshot, scale)) + } + ) + } + + private fun RuntimeSnapshot.getTransferEnabledStorage(): StorageEntry { + return metadata.eqBalances().storage("IsTransfersEnabled") + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/evmErc20/EvmErc20AssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/evmErc20/EvmErc20AssetTransfers.kt new file mode 100644 index 0000000..9994077 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/evmErc20/EvmErc20AssetTransfers.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.evmErc20 + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard +import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder +import io.novafoundation.nova.runtime.ethereum.transaction.builder.contractCall +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.ext.requireErc20 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope + +// a conservative upper limit. Usually transfer takes around 30-50k +private val ERC_20_UPPER_GAS_LIMIT = 200_000.toBigInteger() + +class EvmErc20AssetTransfers( + private val evmTransactionService: EvmTransactionService, + private val erc20Standard: Erc20Standard, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetTransfers { + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + positiveAmount() + + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + recipientCanAcceptTransfer(assetSourceRegistry) + + checkForFeeChanges(assetSourceRegistry, coroutineScope) + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + return evmTransactionService.calculateFee( + transfer.originChain.id, + fallbackGasLimit = ERC_20_UPPER_GAS_LIMIT, + origin = transfer.sender.intoOrigin() + ) { + transfer(transfer) + } + } + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return evmTransactionService.transact( + chainId = transfer.originChain.id, + presetFee = transfer.fee.submissionFee, + fallbackGasLimit = ERC_20_UPPER_GAS_LIMIT, + origin = transfer.sender.intoOrigin() + ) { + transfer(transfer) + } + } + + override suspend fun performTransferAndAwaitExecution( + transfer: WeightedAssetTransfer, + coroutineScope: CoroutineScope + ): Result { + return evmTransactionService.transactAndAwaitExecution( + chainId = transfer.originChain.id, + presetFee = transfer.fee.submissionFee, + fallbackGasLimit = ERC_20_UPPER_GAS_LIMIT, + origin = transfer.sender.intoOrigin() + ) { + transfer(transfer) + }.map { TransactionExecution.Ethereum(it) } + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } + + private fun EvmTransactionBuilder.transfer(transfer: AssetTransfer) { + val erc20 = transfer.originChainAsset.requireErc20() + val recipient = transfer.originChain.accountIdOrDefault(transfer.recipient) + + contractCall(erc20.contractAddress, erc20Standard) { + transfer(recipient = recipient, amount = transfer.amountInPlanks) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/evmNative/EvmNativeAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/evmNative/EvmNativeAssetTransfers.kt new file mode 100644 index 0000000..0a0d3d5 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/evmNative/EvmNativeAssetTransfers.kt @@ -0,0 +1,95 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.evmNative + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope + +// native coin transfer has a fixed fee +private val NATIVE_COIN_TRANSFER_GAS_LIMIT = 21_000.toBigInteger() + +class EvmNativeAssetTransfers( + private val evmTransactionService: EvmTransactionService, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetTransfers { + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + positiveAmount() + + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + recipientCanAcceptTransfer(assetSourceRegistry) + + checkForFeeChanges(assetSourceRegistry, coroutineScope) + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + return evmTransactionService.calculateFee( + transfer.originChain.id, + fallbackGasLimit = NATIVE_COIN_TRANSFER_GAS_LIMIT, + origin = transfer.sender.intoOrigin() + ) { + nativeTransfer(transfer) + } + } + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return evmTransactionService.transact( + chainId = transfer.originChain.id, + fallbackGasLimit = NATIVE_COIN_TRANSFER_GAS_LIMIT, + presetFee = transfer.fee.submissionFee, + origin = transfer.sender.intoOrigin() + ) { + nativeTransfer(transfer) + } + } + + override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return evmTransactionService.transactAndAwaitExecution( + chainId = transfer.originChain.id, + fallbackGasLimit = NATIVE_COIN_TRANSFER_GAS_LIMIT, + presetFee = transfer.fee.submissionFee, + origin = transfer.sender.intoOrigin() + ) { + nativeTransfer(transfer) + }.map { TransactionExecution.Ethereum(it) } + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } + + private fun EvmTransactionBuilder.nativeTransfer(transfer: AssetTransfer) { + val recipient = transfer.originChain.accountIdOrDefault(transfer.recipient) + + nativeTransfer(transfer.amountInPlanks, recipient) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/orml/OrmlAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/orml/OrmlAssetTransfers.kt new file mode 100644 index 0000000..83dea09 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/orml/OrmlAssetTransfers.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.firstExistingCall +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.BaseAssetTransfers +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.ext.findAssetByOrmlCurrencyId +import io.novafoundation.nova.runtime.ext.ormlCurrencyId +import io.novafoundation.nova.runtime.ext.requireOrml +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import java.math.BigInteger + +open class OrmlAssetTransfers( + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + extrinsicServiceFactory: ExtrinsicService.Factory, + phishingValidationFactory: PhishingValidationFactory, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) : BaseAssetTransfers(chainRegistry, assetSourceRegistry, extrinsicServiceFactory, phishingValidationFactory, enoughTotalToStayAboveEDValidationFactory) { + + open val transferFunctions = listOf( + Modules.CURRENCIES to "transfer", + Modules.TOKENS to "transfer" + ) + + override fun ExtrinsicBuilder.transfer(transfer: AssetTransfer) { + ormlTransfer( + chainAsset = transfer.originChainAsset, + target = transfer.originChain.accountIdOrDefault(transfer.recipient), + amount = transfer.amountInPlanks + ) + } + + override suspend fun transferFunctions(chainAsset: Chain.Asset) = transferFunctions + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + // flag from chains json AND existence of module & function in runtime metadata + return chainAsset.requireOrml().transfersEnabled && super.areTransfersEnabled(chainAsset) + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + val isOurs = transferFunctions.any { call.instanceOf(it.first, it.second) } + if (!isOurs) return null + + val onChainAssetId = call.arguments["currency_id"] + val chainAsset = determineAsset(chain, onChainAssetId) ?: return null + val amount = bindNumber(call.arguments["amount"]) + val destination = bindAccountIdentifier(call.arguments["dest"]).intoKey() + + return TransferParsedFromCall( + amount = chainAsset.withAmount(amount), + destination = destination + ) + } + + private suspend fun determineAsset(chain: Chain, onChainAssetId: Any?): Chain.Asset? { + val runtime = chainRegistry.getRuntime(chain.id) + return chain.findAssetByOrmlCurrencyId(runtime, onChainAssetId) + } + + private fun ExtrinsicBuilder.ormlTransfer( + chainAsset: Chain.Asset, + target: AccountId, + amount: BigInteger + ) { + val (moduleIndex, callIndex) = runtime.metadata.firstExistingCall(transferFunctions).index + + call( + moduleIndex = moduleIndex, + callIndex = callIndex, + arguments = mapOf( + "dest" to PezkuwiAddressConstructor.constructInstance(runtime.typeRegistry, target), + "currency_id" to chainAsset.ormlCurrencyId(runtime), + "amount" to amount + ) + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/orml/hydrationEvm/HydrationEvmAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/orml/hydrationEvm/HydrationEvmAssetTransfers.kt new file mode 100644 index 0000000..c3d3217 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/orml/hydrationEvm/HydrationEvmAssetTransfers.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml.hydrationEvm + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml.OrmlAssetTransfers +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import javax.inject.Inject + +@FeatureScope +class HydrationEvmAssetTransfers @Inject constructor( + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + extrinsicServiceFactory: ExtrinsicService.Factory, + phishingValidationFactory: PhishingValidationFactory, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) : OrmlAssetTransfers( + chainRegistry = chainRegistry, + assetSourceRegistry = assetSourceRegistry, + extrinsicServiceFactory = extrinsicServiceFactory, + phishingValidationFactory = phishingValidationFactory, + enoughTotalToStayAboveEDValidationFactory = enoughTotalToStayAboveEDValidationFactory +) { + + // Force Hydration Evm implementation to always use Currencies.transfer + // Since Tokens.transfer fail for such tokens + override val transferFunctions = listOf( + Modules.CURRENCIES to "transfer", + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/statemine/StatemineAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/statemine/StatemineAssetTransfers.kt new file mode 100644 index 0000000..62ac3ff --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/statemine/StatemineAssetTransfers.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.statemine + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.canAcceptFunds +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.bindAssetAccountOrEmpty +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.statemineModule +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.BaseAssetTransfers +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.ext.findAssetByStatemineAssetId +import io.novafoundation.nova.runtime.ext.findStatemineAssets +import io.novafoundation.nova.runtime.ext.palletNameOrDefault +import io.novafoundation.nova.runtime.ext.requireStatemine +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.prepareIdForEncoding +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import java.math.BigInteger + +class StatemineAssetTransfers( + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + extrinsicServiceFactory: ExtrinsicService.Factory, + phishingValidationFactory: PhishingValidationFactory, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + private val remoteStorage: StorageDataSource +) : BaseAssetTransfers(chainRegistry, assetSourceRegistry, extrinsicServiceFactory, phishingValidationFactory, enoughTotalToStayAboveEDValidationFactory) { + + override suspend fun transferFunctions(chainAsset: Chain.Asset): List> { + val type = chainAsset.requireStatemine() + + return listOf(type.palletNameOrDefault() to "transfer") + } + + override fun ExtrinsicBuilder.transfer(transfer: AssetTransfer) { + val chainAssetType = transfer.originChainAsset.type + require(chainAssetType is Chain.Asset.Type.Statemine) + + statemineTransfer( + assetType = chainAssetType, + target = transfer.originChain.accountIdOrDefault(transfer.recipient), + amount = transfer.amountInPlanks + ) + } + + override suspend fun recipientCanAcceptTransfer(chainAsset: Chain.Asset, recipient: AccountId): Boolean { + val statemineType = chainAsset.requireStatemine() + + val assetAccount = remoteStorage.query(chainAsset.chainId) { + runtime.metadata.statemineModule(statemineType).storage("Account").query( + statemineType.prepareIdForEncoding(runtime), + recipient, + binding = ::bindAssetAccountOrEmpty + ) + } + + return assetAccount.canAcceptFunds + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + if (!checkIsOurCall(call, chain)) return null + + val onChainAssetId = call.arguments["id"] + val chainAsset = determineAsset(chain, onChainAssetId) ?: return null + val amount = bindNumber(call.arguments["amount"]) + val destination = bindAccountIdentifier(call.arguments["target"]).intoKey() + + return TransferParsedFromCall( + amount = chainAsset.withAmount(amount), + destination = destination + ) + } + + private suspend fun determineAsset(chain: Chain, onChainAssetId: Any?): Chain.Asset? { + val runtime = chainRegistry.getRuntime(chain.id) + return chain.findAssetByStatemineAssetId(runtime, onChainAssetId) + } + + private fun checkIsOurCall(call: GenericCall.Instance, chain: Chain): Boolean { + if (call.function.name != "transfer") return false + + val allStatemineAssetsOnChain = chain.findStatemineAssets() + return allStatemineAssetsOnChain.any { it.requireStatemine().palletNameOrDefault() == call.module.name } + } + + private fun ExtrinsicBuilder.statemineTransfer( + assetType: Chain.Asset.Type.Statemine, + target: AccountId, + amount: BigInteger + ) { + call( + moduleName = assetType.palletNameOrDefault(), + callName = "transfer", + arguments = mapOf( + "id" to assetType.prepareIdForEncoding(runtime), + "target" to PezkuwiAddressConstructor.constructInstance(runtime.typeRegistry, target), + "amount" to amount + ) + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/utility/NativeAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/utility/NativeAssetTransfers.kt new file mode 100644 index 0000000..1c24c4c --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/utility/NativeAssetTransfers.kt @@ -0,0 +1,105 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.utility + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountInfo +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.TransferMode +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.nativeTransfer +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.BaseAssetTransfers +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class NativeAssetTransfers( + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + extrinsicServiceFactory: ExtrinsicService.Factory, + phishingValidationFactory: PhishingValidationFactory, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + private val storageDataSource: StorageDataSource, + private val accountRepository: AccountRepository, +) : BaseAssetTransfers(chainRegistry, assetSourceRegistry, extrinsicServiceFactory, phishingValidationFactory, enoughTotalToStayAboveEDValidationFactory) { + + companion object { + + private const val TRANSFER_ALL = "transfer_all" + private const val TRANSFER = "transfer" + private const val TRANSFER_KEEP_ALIVE = "transfer_keep_alive" + private const val TRANSFER_ALLOW_DEATH = "transfer_allow_death" + } + + private val parsableCalls = listOf(TRANSFER, TRANSFER_KEEP_ALIVE, TRANSFER_ALLOW_DEATH) + + override suspend fun totalCanDropBelowMinimumBalance(chainAsset: Chain.Asset): Boolean { + val chain = chainRegistry.getChain(chainAsset.chainId) + val metaAccount = accountRepository.getSelectedMetaAccount() + + val accountInfo = storageDataSource.query( + chainAsset.chainId, + keyBuilder = { getAccountInfoStorageKey(metaAccount, chain, it) }, + binding = { it, runtime -> it?.let { bindAccountInfo(it, runtime) } } + ) + + return accountInfo != null && accountInfo.consumers.isZero + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + val isOurs = parsableCalls.any { call.instanceOf(Modules.BALANCES, it) } + if (!isOurs) return null + + val asset = chain.utilityAsset + val amount = bindNumber(call.arguments["value"]) + val recipient = bindAccountIdentifier(call.arguments["dest"]).intoKey() + + return TransferParsedFromCall(asset.withAmount(amount), recipient) + } + + override fun ExtrinsicBuilder.transfer(transfer: AssetTransfer) { + nativeTransfer( + accountId = transfer.originChain.accountIdOrDefault(transfer.recipient), + amount = transfer.originChainAsset.planksFromAmount(transfer.amount), + mode = transfer.transferMode + ) + } + + override suspend fun transferFunctions(chainAsset: Chain.Asset) = listOf( + Modules.BALANCES to TRANSFER, + Modules.BALANCES to TRANSFER_ALLOW_DEATH, + Modules.BALANCES to TRANSFER_ALL + ) + + private fun getAccountInfoStorageKey(metaAccount: MetaAccount, chain: Chain, runtime: RuntimeSnapshot): String { + val accountId = metaAccount.requireAccountIdIn(chain) + return runtime.metadata.module(Modules.SYSTEM).storage("Account").storageKey(runtime, accountId) + } + + private val AssetTransfer.transferMode: TransferMode + get() = if (transferringMaxAmount) { + TransferMode.ALL + } else { + TransferMode.ALLOW_DEATH + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt new file mode 100644 index 0000000..d41e618 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt @@ -0,0 +1,125 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations + +import io.novafoundation.nova.feature_account_api.domain.validation.notSystemAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDeposit +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure.WillRemoveAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.commissionChainAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.originFeeList +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.originFeeListInUsedAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.recipientOrNull +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.sendingAmountInCommissionAsset +import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED +import io.novafoundation.nova.feature_wallet_api.domain.validation.AmountProducer +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.checkForFeeChanges +import io.novafoundation.nova.feature_wallet_api.domain.validation.doNotCrossExistentialDepositMultiFee +import io.novafoundation.nova.feature_wallet_api.domain.validation.notPhishingAccount +import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance +import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalanceMultiFee +import io.novafoundation.nova.feature_wallet_api.domain.validation.validAddress +import io.novafoundation.nova.feature_wallet_api.domain.validation.validate +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type +import kotlinx.coroutines.CoroutineScope +import java.math.BigDecimal + +fun AssetTransfersValidationSystemBuilder.positiveAmount() = positiveAmount( + amount = { it.transfer.amount }, + error = { AssetTransferValidationFailure.NonPositiveAmount } +) + +fun AssetTransfersValidationSystemBuilder.notPhishingRecipient( + factory: PhishingValidationFactory +) = notPhishingAccount( + factory = factory, + address = { it.transfer.recipient }, + chain = { it.transfer.destinationChain }, + warning = AssetTransferValidationFailure::PhishingRecipient +) + +fun AssetTransfersValidationSystemBuilder.validAddress() = validAddress( + address = { it.transfer.recipient }, + chain = { it.transfer.destinationChain }, + error = { AssetTransferValidationFailure.InvalidRecipientAddress(it.transfer.destinationChain) } +) + +fun AssetTransfersValidationSystemBuilder.sufficientCommissionBalanceToStayAboveED( + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory +) { + enoughTotalToStayAboveEDValidationFactory.validate( + fee = { it.originFee.submissionFee }, + balance = { it.originCommissionAsset.balanceCountedTowardsED() }, + chainWithAsset = { ChainWithAsset(it.transfer.originChain, it.commissionChainAsset) }, + error = { payload, error -> AssetTransferValidationFailure.NotEnoughFunds.ToStayAboveED(payload.commissionChainAsset, error) } + ) +} + +fun AssetTransfersValidationSystemBuilder.checkForFeeChanges( + assetSourceRegistry: AssetSourceRegistry, + coroutineScope: CoroutineScope +) = checkForFeeChanges( + calculateFee = { payload -> + val transfers = assetSourceRegistry.sourceFor(payload.transfer.originChainAsset).transfers + transfers.calculateFee(payload.transfer, coroutineScope) + }, + currentFee = { it.originFee.submissionFee }, + chainAsset = { it.commissionChainAsset }, + error = AssetTransferValidationFailure::FeeChangeDetected +) + +fun AssetTransfersValidationSystemBuilder.doNotCrossExistentialDepositInUsedAsset( + assetSourceRegistry: AssetSourceRegistry, + extraAmount: AmountProducer, +) = doNotCrossExistentialDepositMultiFee( + countableTowardsEdBalance = { it.originUsedAsset.balanceCountedTowardsED() }, + fee = { it.originFeeListInUsedAsset }, + extraAmount = extraAmount, + existentialDeposit = { assetSourceRegistry.existentialDepositForUsedAsset(it.transfer) }, + error = { remainingAmount, payload -> payload.transfer.originChainAsset.existentialDepositError(remainingAmount) } +) + +fun AssetTransfersValidationSystemBuilder.sufficientTransferableBalanceToPayOriginFee() = sufficientBalanceMultiFee( + available = { it.originCommissionAsset.transferable }, + amount = { it.sendingAmountInCommissionAsset }, + feeExtractor = { it.originFeeList }, + error = { context -> + AssetTransferValidationFailure.NotEnoughFunds.InCommissionAsset( + chainAsset = context.payload.commissionChainAsset, + fee = context.fee, + maxUsable = context.maxUsable + ) + } +) + +fun AssetTransfersValidationSystemBuilder.sufficientBalanceInUsedAsset() = sufficientBalance( + available = { it.originUsedAsset.transferable }, + amount = { it.transfer.amount }, + fee = { null }, + error = { AssetTransferValidationFailure.NotEnoughFunds.InUsedAsset } +) + +fun AssetTransfersValidationSystemBuilder.recipientIsNotSystemAccount() = notSystemAccount( + accountId = { it.transfer.recipientOrNull() }, + error = { AssetTransferValidationFailure.RecipientIsSystemAccount } +) + +private suspend fun AssetSourceRegistry.existentialDepositForUsedAsset(transfer: AssetTransfer): BigDecimal { + return existentialDeposit(transfer.originChainAsset) +} + +private fun Chain.Asset.existentialDepositError(amount: BigDecimal): WillRemoveAccount = when (type) { + is Type.Native -> WillRemoveAccount.WillBurnDust + is Type.Orml -> WillRemoveAccount.WillBurnDust + is Type.Statemine -> WillRemoveAccount.WillTransferDust(amount) + is Type.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust + is Type.Equilibrium -> WillRemoveAccount.WillBurnDust + Type.Unsupported -> throw IllegalArgumentException("Unsupported") +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/DeadRecipientValidation.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/DeadRecipientValidation.kt new file mode 100644 index 0000000..67a89cd --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/DeadRecipientValidation.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validOrError +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.receivingAmountInCommissionAsset +import io.novafoundation.nova.feature_wallet_api.domain.validation.PlanksProducer +import io.novafoundation.nova.runtime.ext.accountIdOf +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +class DeadRecipientValidation( + private val assetSourceRegistry: AssetSourceRegistry, + private val addingAmount: PlanksProducer, + private val assetToCheck: (AssetTransferPayload) -> Chain.Asset, + private val skipIf: suspend (AssetTransferPayload) -> Boolean, + private val failure: (AssetTransferPayload) -> AssetTransferValidationFailure.DeadRecipient, +) : AssetTransfersValidation { + + override suspend fun validate(value: AssetTransferPayload): ValidationStatus { + if (skipIf(value)) { + return valid() + } + + val chain = value.transfer.destinationChain + val chainAsset = assetToCheck(value) + + val balanceSource = assetSourceRegistry.sourceFor(chainAsset).balance + + val existentialDeposit = balanceSource.existentialDeposit(chainAsset) + val recipientAccountId = value.transfer.destinationChain.accountIdOf(value.transfer.recipient) + + val recipientBalance = balanceSource.queryAccountBalance(chain, chainAsset, recipientAccountId).countedTowardsEd + + return validOrError(recipientBalance + addingAmount(value) >= existentialDeposit) { + failure(value) + } + } +} + +fun AssetTransfersValidationSystemBuilder.notDeadRecipientInCommissionAsset( + assetSourceRegistry: AssetSourceRegistry +) = notDeadRecipient( + assetSourceRegistry = assetSourceRegistry, + assetToCheck = { it.transfer.destinationChain.commissionAsset }, + addingAmount = { it.receivingAmountInCommissionAsset }, + skipIf = { assetSourceRegistry.isAssetSelfSufficient(it.transfer.destinationChainAsset) }, + failure = { AssetTransferValidationFailure.DeadRecipient.InCommissionAsset(commissionAsset = it.transfer.destinationChain.commissionAsset) } +) + +private suspend fun AssetSourceRegistry.isAssetSelfSufficient(asset: Chain.Asset) = sourceFor(asset).balance.isSelfSufficient(asset) + +fun AssetTransfersValidationSystemBuilder.notDeadRecipientInUsedAsset( + assetSourceRegistry: AssetSourceRegistry +) = notDeadRecipient( + assetSourceRegistry = assetSourceRegistry, + assetToCheck = { it.transfer.destinationChainAsset }, + addingAmount = { it.transfer.amountInPlanks }, + failure = { AssetTransferValidationFailure.DeadRecipient.InUsedAsset } +) + +fun AssetTransfersValidationSystemBuilder.notDeadRecipient( + assetSourceRegistry: AssetSourceRegistry, + failure: (AssetTransferPayload) -> AssetTransferValidationFailure.DeadRecipient, + assetToCheck: (AssetTransferPayload) -> Chain.Asset, + addingAmount: PlanksProducer = { BigInteger.ZERO }, + skipIf: suspend (AssetTransferPayload) -> Boolean = { false } +) = validate( + DeadRecipientValidation( + assetSourceRegistry = assetSourceRegistry, + addingAmount = addingAmount, + assetToCheck = assetToCheck, + failure = failure, + skipIf = skipIf + ) +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/calls/UtilityCalls.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/calls/UtilityCalls.kt new file mode 100644 index 0000000..8251616 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/calls/UtilityCalls.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.calls + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.composeCall +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +fun RuntimeSnapshot.composeDispatchAs( + call: GenericCall.Instance, + origin: OriginCaller +): GenericCall.Instance { + return composeCall( + moduleName = Modules.UTILITY, + callName = "dispatch_as", + arguments = mapOf( + "as_origin" to origin.toEncodableInstance(), + "call" to call + ) + ) +} + +fun RuntimeSnapshot.composeBatchAll( + calls: List, +): GenericCall.Instance { + return composeCall( + moduleName = Modules.UTILITY, + callName = "batch_all", + arguments = mapOf( + "calls" to calls + ) + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt new file mode 100644 index 0000000..56a18dd --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/FullSyncPaymentUpdater.kt @@ -0,0 +1,155 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.balance + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.mergeIfMultiple +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal +import io.novafoundation.nova.core_db.model.operation.OperationLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapAssetWithAmountToLocal +import io.novafoundation.nova.feature_wallet_api.data.mappers.mapOperationStatusToOperationLocalStatus +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.enabledAssets +import io.novafoundation.nova.runtime.ext.localId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onEach + +internal class FullSyncPaymentUpdater( + private val operationDao: OperationDao, + private val assetSourceRegistry: AssetSourceRegistry, + override val scope: AccountUpdateScope, + private val chain: Chain, +) : Updater { + + override val requiredModules: List = emptyList() + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: MetaAccount, + ): Flow { + val accountId = scopeValue.requireAccountIdIn(chain) + + return chain.enabledAssets().mapNotNull { chainAsset -> + syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder) + } + .mergeIfMultiple() + .noSideAffects() + } + + private suspend fun syncAsset( + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + storageSubscriptionBuilder: SharedRequestsBuilder + ): Flow? { + val assetSource = assetSourceRegistry.sourceFor(chainAsset) + + val assetUpdateFlow = runCatching { + assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder) + } + .onFailure { logSyncError(chain, chainAsset, error = it) } + .getOrNull() + ?: return null + + return assetUpdateFlow.onEach { balanceUpdate -> + assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId) + } + .catch { logSyncError(chain, chainAsset, error = it) } + } + + private fun logSyncError(chain: Chain, chainAsset: Chain.Asset, error: Throwable) { + Log.e(LOG_TAG, "Failed to sync balance for ${chainAsset.symbol} in ${chain.name}", error) + } + + private suspend fun AssetHistory.syncOperationsForBalanceChange( + chainAsset: Chain.Asset, + balanceSyncUpdate: BalanceSyncUpdate, + accountId: AccountId, + ) { + when (balanceSyncUpdate) { + is BalanceSyncUpdate.CauseFetchable -> runCatching { fetchOperationsForBalanceChange(chain, chainAsset, balanceSyncUpdate.blockHash, accountId) } + .onSuccess { blockOperations -> + val localOperations = blockOperations + .filter { it.type.relates(accountId) } + .map { operation -> createOperationLocal(chainAsset, operation, accountId) } + + operationDao.insertAll(localOperations) + }.onFailure { + Log.e(LOG_TAG, "Failed to retrieve transactions from block (${chain.name}.${chainAsset.symbol})", it) + } + + is BalanceSyncUpdate.CauseFetched -> { + val local = createOperationLocal(chainAsset, balanceSyncUpdate.cause, accountId) + operationDao.insert(local) + } + + BalanceSyncUpdate.NoCause -> {} + } + } + + private suspend fun createOperationLocal( + chainAsset: Chain.Asset, + historyUpdate: RealtimeHistoryUpdate, + accountId: ByteArray, + ): OperationLocal { + return when (val type = historyUpdate.type) { + is RealtimeHistoryUpdate.Type.Swap -> createSwapOperation(chainAsset, historyUpdate, type, accountId) + is RealtimeHistoryUpdate.Type.Transfer -> createTransferOperation(chainAsset, historyUpdate, type, accountId) + } + } + + private fun createSwapOperation( + chainAsset: Chain.Asset, + historyUpdate: RealtimeHistoryUpdate, + swap: RealtimeHistoryUpdate.Type.Swap, + accountId: ByteArray, + ): OperationLocal { + return OperationLocal.manualSwap( + hash = historyUpdate.txHash, + originAddress = chain.addressOf(accountId), + assetId = chainAsset.localId, + fee = mapAssetWithAmountToLocal(swap.amountFee), + amountIn = mapAssetWithAmountToLocal(swap.amountIn), + amountOut = mapAssetWithAmountToLocal(swap.amountOut), + status = mapOperationStatusToOperationLocalStatus(historyUpdate.status), + source = OperationBaseLocal.Source.BLOCKCHAIN + ) + } + + private suspend fun createTransferOperation( + chainAsset: Chain.Asset, + historyUpdate: RealtimeHistoryUpdate, + transfer: RealtimeHistoryUpdate.Type.Transfer, + accountId: ByteArray, + ): OperationLocal { + val localStatus = mapOperationStatusToOperationLocalStatus(historyUpdate.status) + val address = chain.addressOf(accountId) + + val localCopy = operationDao.getTransferType(historyUpdate.txHash, address, chain.id, chainAsset.id) + + return OperationLocal.manualTransfer( + hash = historyUpdate.txHash, + chainId = chain.id, + address = address, + chainAssetId = chainAsset.id, + amount = transfer.amountInPlanks, + senderAddress = chain.addressOf(transfer.senderId), + receiverAddress = chain.addressOf(transfer.recipientId), + fee = localCopy?.fee, + status = localStatus, + source = OperationBaseLocal.Source.BLOCKCHAIN, + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/LightSyncPaymentUpdater.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/LightSyncPaymentUpdater.kt new file mode 100644 index 0000000..b293fd6 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/LightSyncPaymentUpdater.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.balance + +import android.util.Log +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.model.AssetLocal +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b128Concat +import io.novasama.substrate_sdk_android.hash.Hasher.xxHash128 +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.onEach + +/** + * Runtime-independent updater that watches on-chain account presence and switches to full sync mode if account is present + */ +class LightSyncPaymentUpdater( + override val scope: AccountUpdateScope, + private val chainRegistry: ChainRegistry, + private val assetCache: AssetCache, + private val chain: Chain +) : Updater { + + override val requiredModules: List = emptyList() + + override suspend fun listenForUpdates(storageSubscriptionBuilder: SharedRequestsBuilder, scopeValue: MetaAccount): Flow { + val accountId = scopeValue.accountIdIn(chain) ?: return emptyFlow() + val storageKey = systemAccountStorageKey(accountId) + + return storageSubscriptionBuilder.subscribe(storageKey).onEach { storageChange -> + if (storageChange.value != null) { + switchToFullSync() + + Log.d("ConnectionState", "Detected balance during light sync for ${chain.name}, switching to full sync mode") + } else { + insertEmptyBalances(scopeValue) + } + }.noSideAffects() + } + + private suspend fun insertEmptyBalances(metaAccount: MetaAccount) { + assetCache.updateAssetsByChain(metaAccount, chain) { chainAsset -> + AssetLocal.createEmpty(chainAsset.id, chainAsset.chainId, metaAccount.id) + } + } + + private suspend fun switchToFullSync() { + chainRegistry.enableFullSync(chain.id) + } + + private fun systemAccountStorageKey(accountId: AccountId): String { + val keyBytes = "System".xxHash128() + "Account".xxHash128() + accountId.blake2b128Concat() + + return keyBytes.toHexString(withPrefix = true) + } + + private fun String.xxHash128() = toByteArray().xxHash128() +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/PaymentUpdaterFactory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/PaymentUpdaterFactory.kt new file mode 100644 index 0000000..cb3441f --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/balance/PaymentUpdaterFactory.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.balance + +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class RealPaymentUpdaterFactory( + private val operationDao: OperationDao, + private val assetSourceRegistry: AssetSourceRegistry, + private val scope: AccountUpdateScope, + private val chainRegistry: ChainRegistry, + private val assetCache: AssetCache +) : PaymentUpdaterFactory { + + override fun createFullSync(chain: Chain): Updater { + return FullSyncPaymentUpdater( + operationDao = operationDao, + assetSourceRegistry = assetSourceRegistry, + scope = scope, + chain = chain, + ) + } + + override fun createLightSync(chain: Chain): Updater { + return LightSyncPaymentUpdater( + scope = scope, + chainRegistry = chainRegistry, + chain = chain, + assetCache = assetCache + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/locks/BalanceLocksUpdater.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/locks/BalanceLocksUpdater.kt new file mode 100644 index 0000000..1bb2f84 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/updaters/locks/BalanceLocksUpdater.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.locks + +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory +import io.novafoundation.nova.runtime.ext.enabledAssets +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.merge + +class BalanceLocksUpdaterFactoryImpl( + private val scope: AccountUpdateScope, + private val assetSourceRegistry: AssetSourceRegistry, +) : BalanceLocksUpdaterFactory { + + override fun create(chain: Chain): Updater { + return BalanceLocksUpdater( + scope, + assetSourceRegistry, + chain + ) + } +} + +class BalanceLocksUpdater( + override val scope: AccountUpdateScope, + private val assetSourceRegistry: AssetSourceRegistry, + private val chain: Chain +) : Updater { + + override val requiredModules: List = emptyList() + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: MetaAccount, + ): Flow { + val metaAccount = scopeValue + val accountId = metaAccount.accountIdIn(chain) ?: return emptyFlow() + + val flows = buildList { + chain.enabledAssets().forEach { chainAsset -> + val assetSource = assetSourceRegistry.sourceFor(chainAsset) + + val locksFlow = assetSource.balance.startSyncingBalanceLocks(metaAccount, chain, chainAsset, accountId, storageSubscriptionBuilder) + val holdsFlow = assetSource.balance.startSyncingBalanceHolds(metaAccount, chain, chainAsset, accountId, storageSubscriptionBuilder) + + add(locksFlow) + add(holdsFlow) + } + } + + return flows + .merge() + .noSideAffects() + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/CommonRemote.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/CommonRemote.kt new file mode 100644 index 0000000..973b265 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/CommonRemote.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain + +typealias JunctionsRemote = Map diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/CrossChainConfigApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/CrossChainConfigApi.kt new file mode 100644 index 0000000..56b0b4d --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/CrossChainConfigApi.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain + +import io.novafoundation.nova.feature_wallet_impl.BuildConfig +import retrofit2.http.GET + +interface CrossChainConfigApi { + + @GET(BuildConfig.LEGACY_CROSS_CHAIN_CONFIG_URL) + suspend fun getLegacyCrossChainConfig(): String + + @GET(BuildConfig.DYNAMIC_CROSS_CHAIN_CONFIG_URL) + suspend fun getDynamicCrossChainConfig(): String +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/ExtrinsicBuilderExt.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/ExtrinsicBuilderExt.kt new file mode 100644 index 0000000..7ee1395 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/ExtrinsicBuilderExt.kt @@ -0,0 +1,54 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain + +import io.novafoundation.nova.common.data.network.runtime.binding.Weight +import io.novafoundation.nova.common.utils.argument +import io.novafoundation.nova.common.utils.requireActualType +import io.novafoundation.nova.common.utils.structOf +import io.novafoundation.nova.common.utils.xcmPalletName +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.Type +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.NumberType +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import io.novasama.substrate_sdk_android.runtime.metadata.call +import io.novasama.substrate_sdk_android.runtime.metadata.module + +fun ExtrinsicBuilder.xcmExecute( + message: VersionedXcmMessage, + maxWeight: Weight, +): ExtrinsicBuilder { + return call( + moduleName = runtime.metadata.xcmPalletName(), + callName = "execute", + arguments = mapOf( + "message" to message.toEncodableInstance(), + "max_weight" to runtime.prepareWeightForEncoding(maxWeight) + ) + ) +} + +private fun RuntimeSnapshot.prepareWeightForEncoding(weight: Weight): Any { + val moduleName = metadata.xcmPalletName() + + val weightArgumentType = metadata.module(moduleName) + .call("execute") + .argument("max_weight") + .requireActualType() + + return when { + weightArgumentType.isWeightV1() -> weight + else -> weight.encodeWeightV2() + } +} + +private fun Weight.encodeWeightV2(): Struct.Instance { + return structOf("refTime" to this, "proofSize" to Balance.ZERO) +} + +private fun Type<*>.isWeightV1(): Boolean { + return this is NumberType +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/RealCrossChainTransactor.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/RealCrossChainTransactor.kt new file mode 100644 index 0000000..a3d1383 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/RealCrossChainTransactor.kt @@ -0,0 +1,241 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain + +import android.util.Log +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.flatMap +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.common.utils.transformResult +import io.novafoundation.nova.common.utils.wrapInResult +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult +import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.tryDetectDeposit +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.destinationChainId +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferFeatures +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.remoteReserveLocation +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainTransactor +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainTransactor +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.findRelayChainOrThrow +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.BlockEvents +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.hasEvent +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.expectedBlockTime +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.seconds + +class RealCrossChainTransactor( + private val assetSourceRegistry: AssetSourceRegistry, + private val eventsRepository: EventsRepository, + private val chainStateRepository: ChainStateRepository, + private val chainRegistry: ChainRegistry, + private val dynamic: DynamicCrossChainTransactor, + private val legacy: LegacyCrossChainTransactor, +) : CrossChainTransactor { + + context(ExtrinsicService) + override suspend fun estimateOriginFee( + configuration: CrossChainTransferConfiguration, + transfer: AssetTransferBase + ): Fee { + return estimateFee( + chain = transfer.originChain, + origin = TransactionOrigin.SelectedWallet, + submissionOptions = ExtrinsicService.SubmissionOptions( + feePaymentCurrency = transfer.feePaymentCurrency + ) + ) { + crossChainTransfer(configuration, transfer, crossChainFee = Balance.ZERO) + } + } + + context(ExtrinsicService) + override suspend fun performTransfer( + configuration: CrossChainTransferConfiguration, + transfer: AssetTransferBase, + crossChainFee: Balance + ): Result { + return submitExtrinsic( + chain = transfer.originChain, + origin = TransactionOrigin.SelectedWallet, + submissionOptions = ExtrinsicService.SubmissionOptions( + feePaymentCurrency = transfer.feePaymentCurrency + ) + ) { + crossChainTransfer(configuration, transfer, crossChainFee) + } + } + + override suspend fun requiredRemainingAmountAfterTransfer(configuration: CrossChainTransferConfiguration): Balance { + return when (configuration) { + is CrossChainTransferConfiguration.Dynamic -> dynamic.requiredRemainingAmountAfterTransfer(configuration.config) + is CrossChainTransferConfiguration.Legacy -> legacy.requiredRemainingAmountAfterTransfer(configuration.config) + } + } + + context(ExtrinsicService) + override suspend fun performAndTrackTransfer( + configuration: CrossChainTransferConfiguration, + transfer: AssetTransferBase, + ): Result { + // Start balances updates eagerly to not to miss events in case tx has been included to block right after submission + val balancesUpdates = observeTransferableBalance(transfer) + .wrapInResult() + .shareIn(CoroutineScope(coroutineContext), SharingStarted.Eagerly, replay = 100) + + Log.d("CrossChain", "Starting cross-chain transfer") + + return performTransferOfExactAmount(configuration, transfer) + .requireOk() + .flatMap { + Log.d("CrossChain", "Cross chain transfer for successfully executed on origin, waiting for destination") + + balancesUpdates.awaitCrossChainArrival(transfer) + } + } + + override suspend fun supportsXcmExecute( + originChainId: ChainId, + features: DynamicCrossChainTransferFeatures + ): Boolean { + return dynamic.supportsXcmExecute(originChainId, features) + } + + override suspend fun estimateMaximumExecutionTime(configuration: CrossChainTransferConfiguration): Duration { + val originChainId = configuration.originChainId + val remoteReserveChainId = configuration.transferType.remoteReserveLocation()?.chainId + val destinationChainId = configuration.destinationChainId + + val relayId = chainRegistry.findRelayChainOrThrow(originChainId) + + var totalDuration = ZERO + + if (remoteReserveChainId != null) { + totalDuration += maxTimeToTransmitMessage(originChainId, remoteReserveChainId, relayId) + totalDuration += maxTimeToTransmitMessage(remoteReserveChainId, destinationChainId, relayId) + } else { + totalDuration += maxTimeToTransmitMessage(originChainId, destinationChainId, relayId) + } + + return totalDuration + } + + private suspend fun maxTimeToTransmitMessage(from: ChainId, to: ChainId, relay: ChainId): Duration { + val toProduceBlockOnOrigin = chainStateRepository.expectedBlockTime(from) + val toProduceBlockOnDestination = chainStateRepository.expectedBlockTime(to) + val toProduceBlockOnRelay = if (from != relay && to != relay) chainStateRepository.expectedBlockTime(relay) else ZERO + + return toProduceBlockOnOrigin + toProduceBlockOnRelay + toProduceBlockOnDestination + } + + private suspend fun Flow>.awaitCrossChainArrival(transfer: AssetTransferBase): Result { + return runCatching { + withTimeout(60.seconds) { + transformResult { balanceUpdate -> + Log.d("CrossChain", "Destination balance update detected: $balanceUpdate") + + val updatedAt = balanceUpdate.updatedAt + + val blockEvents = eventsRepository.getBlockEvents(transfer.destinationChain.id, updatedAt) + + val xcmArrivedDeposit = searchForXcmArrival(blockEvents.initialization, transfer) + ?: searchForXcmArrival(blockEvents.finalization, transfer) + ?: searchForXcmArrival(blockEvents.findSetValidationDataEvents(), transfer) + + if (xcmArrivedDeposit != null) { + Log.d("CrossChain", "Found destination xcm arrival event, amount is $xcmArrivedDeposit") + + emit(xcmArrivedDeposit) + } else { + Log.d("CrossChain", "No destination xcm arrival event found for the received balance update") + } + } + .first() + .getOrThrow() + } + } + } + + private fun BlockEvents.findSetValidationDataEvents(): List { + val setValidationDataExtrinsic = applyExtrinsic.find { it.extrinsic.call.instanceOf(Modules.PARACHAIN_SYSTEM, "set_validation_data") } + + return setValidationDataExtrinsic?.events.orEmpty() + } + + private suspend fun searchForXcmArrival( + events: List, + transfer: AssetTransferBase + ): Balance? { + if (!events.hasXcmArrivalEvent()) return null + + val eventDetector = assetSourceRegistry.getEventDetector(transfer.destinationChainAsset) + + val depositEvent = events.mapNotNull { event -> eventDetector.tryDetectDeposit(event) } + .find { it.destination == transfer.recipientAccountId } + + return depositEvent?.amount + } + + private fun List.hasXcmArrivalEvent(): Boolean { + return hasEvent("MessageQueue", "Processed") or hasEvent("XcmpQueue", "Success") + } + + private suspend fun ExtrinsicService.performTransferOfExactAmount( + configuration: CrossChainTransferConfiguration, + transfer: AssetTransferBase, + ): Result { + return submitExtrinsicAndAwaitExecution( + chain = transfer.originChain, + origin = TransactionOrigin.SelectedWallet, + submissionOptions = ExtrinsicService.SubmissionOptions( + feePaymentCurrency = transfer.feePaymentCurrency + ) + ) { + // We are transferring the exact amount, so we should add nothing on top of the transfer amount + crossChainTransfer(configuration, transfer, crossChainFee = Balance.ZERO) + } + } + + private suspend fun observeTransferableBalance(transfer: AssetTransferBase): Flow { + val destinationAssetBalances = assetSourceRegistry.sourceFor(transfer.destinationChainAsset) + + return destinationAssetBalances.balance.subscribeAccountBalanceUpdatePoint( + chain = transfer.destinationChain, + chainAsset = transfer.destinationChainAsset, + accountId = transfer.recipientAccountId.value, + ) + } + + private suspend fun ExtrinsicBuilder.crossChainTransfer( + configuration: CrossChainTransferConfiguration, + transfer: AssetTransferBase, + crossChainFee: Balance + ) { + when (configuration) { + is CrossChainTransferConfiguration.Dynamic -> dynamic.crossChainTransfer(configuration.config, transfer, crossChainFee) + is CrossChainTransferConfiguration.Legacy -> legacy.crossChainTransfer(configuration.config, transfer, crossChainFee) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/RealCrossChainWeigher.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/RealCrossChainWeigher.kt new file mode 100644 index 0000000..172e26f --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/RealCrossChainWeigher.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainFeeModel +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainWeigher +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainWeigher +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainWeigher + +class RealCrossChainWeigher( + private val dynamic: DynamicCrossChainWeigher, + private val legacy: LegacyCrossChainWeigher +) : CrossChainWeigher { + + override suspend fun estimateFee(transfer: AssetTransferBase, config: CrossChainTransferConfiguration): CrossChainFeeModel { + return when (config) { + is CrossChainTransferConfiguration.Dynamic -> dynamic.estimateFee(config.config, transfer) + is CrossChainTransferConfiguration.Legacy -> legacy.estimateFee(transfer.amountPlanks, config.config) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/common/TransferAssetUsingTypeTransactor.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/common/TransferAssetUsingTypeTransactor.kt new file mode 100644 index 0000000..3006ba3 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/common/TransferAssetUsingTypeTransactor.kt @@ -0,0 +1,118 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.common + +import android.util.Log +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.composeCall +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.xcmPalletName +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfigurationBase +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.assetLocationOnOrigin +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.destinationChainLocationOnOrigin +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainLocation +import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetId +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.builder.buildXcmWithoutFeesMeasurement +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.feature_xcm_api.versions.orDefault +import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance +import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm +import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import javax.inject.Inject + +@FeatureScope +class TransferAssetUsingTypeTransactor @Inject constructor( + private val chainRegistry: ChainRegistry, + private val xcmBuilderFactory: XcmBuilder.Factory, + private val xcmVersionDetector: XcmVersionDetector, +) { + + suspend fun composeCall( + configuration: CrossChainTransferConfigurationBase, + transfer: AssetTransferBase, + crossChainFee: Balance, + forceXcmVersion: XcmVersion? = null + ): GenericCall.Instance { + val totalTransferAmount = transfer.amountPlanks + crossChainFee + val multiAsset = MultiAsset.from(configuration.assetLocationOnOrigin(), totalTransferAmount) + val multiAssetId = MultiAssetId(configuration.assetLocationOnOrigin()) + + val multiLocationVersion = forceXcmVersion ?: xcmVersionDetector.lowestPresentMultiLocationVersion(transfer.originChain.id).orDefault() + val multiAssetsVersion = forceXcmVersion ?: xcmVersionDetector.lowestPresentMultiAssetsVersion(transfer.originChain.id).orDefault() + val multiAssetIdVersion = forceXcmVersion ?: xcmVersionDetector.lowestPresentMultiAssetIdVersion(transfer.originChain.id).orDefault() + + val transferTypeParam = configuration.transferTypeParam(multiAssetsVersion) + + // Debug logging for XCM transfer + val destLocation = configuration.destinationChainLocationOnOrigin() + Log.d("XCM_TRANSFER", "=== XCM TRANSFER DEBUG ===") + Log.d("XCM_TRANSFER", "Origin chain: ${configuration.originChain.chain.name} (${configuration.originChain.chain.id})") + Log.d("XCM_TRANSFER", "Origin parachainId: ${configuration.originChain.parachainId}") + Log.d("XCM_TRANSFER", "Destination chain: ${configuration.destinationChain.chain.name} (${configuration.destinationChain.chain.id})") + Log.d("XCM_TRANSFER", "Destination parachainId: ${configuration.destinationChain.parachainId}") + Log.d("XCM_TRANSFER", "Destination location (relative): parents=${destLocation.parents}, interior=${destLocation.interior}") + Log.d("XCM_TRANSFER", "Destination junctions: ${destLocation.interior}") + Log.d("XCM_TRANSFER", "Transfer type: ${configuration.transferType}") + Log.d("XCM_TRANSFER", "XCM Version: $multiLocationVersion") + Log.d("XCM_TRANSFER", "==========================") + + return chainRegistry.withRuntime(configuration.originChainId) { + composeCall( + moduleName = metadata.xcmPalletName(), + callName = "transfer_assets_using_type_and_then", + arguments = mapOf( + "dest" to configuration.destinationChainLocationOnOrigin().versionedXcm(multiLocationVersion).toEncodableInstance(), + "assets" to MultiAssets(multiAsset).versionedXcm(multiAssetsVersion).toEncodableInstance(), + "assets_transfer_type" to transferTypeParam, + "remote_fees_id" to multiAssetId.versionedXcm(multiAssetIdVersion).toEncodableInstance(), + "fees_transfer_type" to transferTypeParam, + "custom_xcm_on_dest" to constructCustomXcmOnDest(configuration, transfer, multiLocationVersion).toEncodableInstance(), + "weight_limit" to WeightLimit.Unlimited.toEncodableInstance() + ) + ) + } + } + + private fun CrossChainTransferConfigurationBase.transferTypeParam(locationXcmVersion: XcmVersion): Any { + return when (val type = transferType) { + is XcmTransferType.Teleport -> DictEnum.Entry("Teleport", null) + + is XcmTransferType.Reserve.Destination -> DictEnum.Entry("DestinationReserve", null) + + is XcmTransferType.Reserve.Origin -> DictEnum.Entry("LocalReserve", null) + + is XcmTransferType.Reserve.Remote -> { + val reserveChainRelative = type.remoteReserveLocation.location.fromPointOfViewOf(originChainLocation.location) + val remoteReserveEncodable = reserveChainRelative.versionedXcm(locationXcmVersion).toEncodableInstance() + + DictEnum.Entry("RemoteReserve", remoteReserveEncodable) + } + } + } + + private suspend fun constructCustomXcmOnDest( + configuration: CrossChainTransferConfigurationBase, + transfer: AssetTransferBase, + minDetectedXcmVersion: XcmVersion + ): VersionedXcmMessage { + return xcmBuilderFactory.buildXcmWithoutFeesMeasurement( + initial = configuration.originChainLocation, + // singleCounted is only available from V3 + xcmVersion = minDetectedXcmVersion.coerceAtLeast(XcmVersion.V3) + ) { + depositAsset(MultiAssetFilter.singleCounted(), transfer.recipientAccountId) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/DynamicCrossChainConfigRemote.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/DynamicCrossChainConfigRemote.kt new file mode 100644 index 0000000..d270050 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/DynamicCrossChainConfigRemote.kt @@ -0,0 +1,65 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic + +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.JunctionsRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class DynamicCrossChainTransfersConfigRemote( + val assetsLocation: Map?, + // (ChainId, AssetId) -> ReserveId + val reserveIdOverrides: Map>, + val chains: List?, + val customTeleports: List?, +) + +class CustomTeleportEntryRemote( + val originChain: String, + val destChain: String, + val originAsset: Int +) + +class DynamicReserveLocationRemote( + val chainId: ChainId, + val multiLocation: JunctionsRemote +) + +class DynamicCrossChainOriginChainRemote( + val chainId: ChainId, + val assets: List +) + +class DynamicCrossChainOriginAssetRemote( + val assetId: Int, + val xcmTransfers: List, +) + +class DynamicXcmTransferRemote( + // New format: nested destination object + val destination: XcmTransferDestinationRemote?, + // Legacy format: chainId and assetId at root level + val chainId: ChainId?, + val assetId: Int?, + val type: String?, + val hasDeliveryFee: Boolean?, + val supportsXcmExecute: Boolean?, +) { + /** + * Get the destination chainId, supporting both new and legacy formats. + */ + fun getDestinationChainId(): ChainId { + return destination?.chainId ?: chainId + ?: throw IllegalStateException("XCM transfer has no destination chainId") + } + + /** + * Get the destination assetId, supporting both new and legacy formats. + */ + fun getDestinationAssetId(): Int { + return destination?.assetId ?: assetId + ?: throw IllegalStateException("XCM transfer has no destination assetId") + } +} + +class XcmTransferDestinationRemote( + val chainId: ChainId, + val assetId: Int, +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/DynamicCrossChainTransactor.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/DynamicCrossChainTransactor.kt new file mode 100644 index 0000000..b7d0297 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/DynamicCrossChainTransactor.kt @@ -0,0 +1,263 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.destinationChainLocation +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferFeatures +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainLocation +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.common.TransferAssetUsingTypeTransactor +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.builder.buyExecution +import io.novafoundation.nova.feature_xcm_api.builder.createWithoutFeesMeasurement +import io.novafoundation.nova.feature_xcm_api.builder.withdrawAsset +import io.novafoundation.nova.feature_xcm_api.extrinsic.composeXcmExecute +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.feature_xcm_api.runtimeApi.getInnerSuccessOrThrow +import io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment.XcmPaymentApi +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import java.math.BigInteger +import javax.inject.Inject + +private val USED_XCM_VERSION = XcmVersion.V4 + +@FeatureScope +class DynamicCrossChainTransactor @Inject constructor( + private val chainRegistry: ChainRegistry, + private val xcmBuilderFactory: XcmBuilder.Factory, + private val xcmPaymentApi: XcmPaymentApi, + private val assetSourceRegistry: AssetSourceRegistry, + private val usingTypeTransactor: TransferAssetUsingTypeTransactor, +) { + + context(ExtrinsicBuilder) + suspend fun crossChainTransfer( + configuration: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + crossChainFee: Balance + ) { + val call = composeCrossChainTransferCall(configuration, transfer, crossChainFee) + call(call) + } + + suspend fun requiredRemainingAmountAfterTransfer( + configuration: DynamicCrossChainTransferConfiguration + ): Balance { + return if (supportsXcmExecute(configuration)) { + BigInteger.ZERO + } else { + val chainAsset = configuration.originChainAsset + assetSourceRegistry.sourceFor(chainAsset).balance.existentialDeposit(chainAsset) + } + } + + suspend fun composeCrossChainTransferCall( + configuration: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + crossChainFee: Balance + ): GenericCall.Instance { + return if (supportsXcmExecute(configuration)) { + composeXcmExecuteCall(configuration, transfer, crossChainFee) + } else { + usingTypeTransactor.composeCall(configuration, transfer, crossChainFee, forceXcmVersion = USED_XCM_VERSION) + } + } + + suspend fun supportsXcmExecute(originChainId: ChainId, features: DynamicCrossChainTransferFeatures): Boolean { + val supportsXcmExecute = features.supportsXcmExecute + val hasXcmPaymentApi = xcmPaymentApi.isSupported(originChainId) + + // For now, only enable xcm execute approach for the directions that will hugely benefit from it + // In particular, xcm execute allows us to pay delivery fee from the holding register and not in JIT mode (from account) + val hasDeliveryFee = features.hasDeliveryFee + + return supportsXcmExecute && hasXcmPaymentApi && hasDeliveryFee + } + + private suspend fun supportsXcmExecute(configuration: DynamicCrossChainTransferConfiguration): Boolean { + return supportsXcmExecute(configuration.originChainId, configuration.features) + } + + private suspend fun composeXcmExecuteCall( + configuration: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + crossChainFee: Balance + ): GenericCall.Instance { + val xcmProgram = buildXcmProgram(configuration, transfer, crossChainFee) + val weight = xcmPaymentApi.queryXcmWeight(configuration.originChainId, xcmProgram) + .getInnerSuccessOrThrow("DynamicCrossChainTransactor") + + return chainRegistry.withRuntime(configuration.originChainId) { + composeXcmExecute(xcmProgram, weight) + } + } + + private suspend fun buildXcmProgram( + configuration: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + crossChainFee: Balance + ): VersionedXcmMessage { + val builder = xcmBuilderFactory.createWithoutFeesMeasurement( + initial = configuration.originChainLocation, + xcmVersion = USED_XCM_VERSION + ) + + builder.buildTransferProgram(configuration, transfer, crossChainFee) + + return builder.build() + } + + private fun XcmBuilder.buildTransferProgram( + configuration: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + crossChainFee: Balance + ) { + val totalTransferAmount = transfer.amountPlanks + crossChainFee + val assetAbsoluteMultiLocation = configuration.transferType.assetAbsoluteLocation + + // Debug logging for Dynamic XCM transfer + Log.d("XCM_DYNAMIC", "=== DYNAMIC XCM TRANSFER DEBUG ===") + Log.d("XCM_DYNAMIC", "Origin chain: ${configuration.originChain.chain.name} (${configuration.originChain.chain.id})") + Log.d("XCM_DYNAMIC", "Origin parachainId: ${configuration.originChain.parachainId}") + Log.d("XCM_DYNAMIC", "Destination chain: ${configuration.destinationChain.chain.name} (${configuration.destinationChain.chain.id})") + Log.d("XCM_DYNAMIC", "Destination parachainId: ${configuration.destinationChain.parachainId}") + Log.d("XCM_DYNAMIC", "Destination location: ${configuration.destinationChainLocation}") + Log.d("XCM_DYNAMIC", "Transfer type: ${configuration.transferType}") + Log.d("XCM_DYNAMIC", "================================") + + when (val transferType = configuration.transferType) { + is XcmTransferType.Teleport -> buildTeleportProgram( + assetLocation = assetAbsoluteMultiLocation, + destinationChainLocation = configuration.destinationChainLocation, + beneficiary = transfer.recipientAccountId, + amount = totalTransferAmount + ) + + is XcmTransferType.Reserve.Origin -> buildOriginReserveProgram( + assetLocation = assetAbsoluteMultiLocation, + destinationChainLocation = configuration.destinationChainLocation, + beneficiary = transfer.recipientAccountId, + amount = totalTransferAmount + ) + + is XcmTransferType.Reserve.Destination -> buildDestinationReserveProgram( + assetLocation = assetAbsoluteMultiLocation, + destinationChainLocation = configuration.destinationChainLocation, + beneficiary = transfer.recipientAccountId, + amount = totalTransferAmount + ) + + is XcmTransferType.Reserve.Remote -> buildRemoteReserveProgram( + assetLocation = assetAbsoluteMultiLocation, + remoteReserveLocation = transferType.remoteReserveLocation, + destinationChainLocation = configuration.destinationChainLocation, + beneficiary = transfer.recipientAccountId, + amount = totalTransferAmount + ) + } + } + + private fun XcmBuilder.buildTeleportProgram( + assetLocation: AbsoluteMultiLocation, + destinationChainLocation: ChainLocation, + beneficiary: AccountIdKey, + amount: Balance, + ) { + val feesAmount = deriveBuyExecutionUpperBoundAmount(amount) + + // Origin + withdrawAsset(assetLocation, amount) + // Here and onward: we use buy execution for the very first segment to be able to pay delivery fees in sending asset + // WeightLimit.one() is used since it doesn't matter anyways as the message on origin is already weighted + // The only restriction is that it cannot be zero or Unlimited + buyExecution(assetLocation, feesAmount, WeightLimit.one()) + initiateTeleport(MultiAssetFilter.singleCounted(), destinationChainLocation) + + // Destination + buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited) + depositAsset(MultiAssetFilter.singleCounted(), beneficiary) + } + + private fun XcmBuilder.buildOriginReserveProgram( + assetLocation: AbsoluteMultiLocation, + destinationChainLocation: ChainLocation, + beneficiary: AccountIdKey, + amount: Balance, + ) { + val feesAmount = deriveBuyExecutionUpperBoundAmount(amount) + + // Origin + withdrawAsset(assetLocation, amount) + buyExecution(assetLocation, feesAmount, WeightLimit.one()) + depositReserveAsset(MultiAssetFilter.singleCounted(), destinationChainLocation) + + // Destination + buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited) + depositAsset(MultiAssetFilter.singleCounted(), beneficiary) + } + + private fun XcmBuilder.buildDestinationReserveProgram( + assetLocation: AbsoluteMultiLocation, + destinationChainLocation: ChainLocation, + beneficiary: AccountIdKey, + amount: Balance, + ) { + val feesAmount = deriveBuyExecutionUpperBoundAmount(amount) + + // Origin + withdrawAsset(assetLocation, amount) + buyExecution(assetLocation, feesAmount, WeightLimit.one()) + initiateReserveWithdraw(MultiAssetFilter.singleCounted(), destinationChainLocation) + + // Destination + buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited) + depositAsset(MultiAssetFilter.singleCounted(), beneficiary) + } + + private fun XcmBuilder.buildRemoteReserveProgram( + assetLocation: AbsoluteMultiLocation, + remoteReserveLocation: ChainLocation, + destinationChainLocation: ChainLocation, + beneficiary: AccountIdKey, + amount: Balance, + ) { + val feesAmount = deriveBuyExecutionUpperBoundAmount(amount) + + // Origin + withdrawAsset(assetLocation, amount) + buyExecution(assetLocation, feesAmount, WeightLimit.one()) + initiateReserveWithdraw(MultiAssetFilter.singleCounted(), remoteReserveLocation) + + // Remote reserve + buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited) + depositReserveAsset(MultiAssetFilter.singleCounted(), destinationChainLocation) + + // Destination + buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited) + depositAsset(MultiAssetFilter.singleCounted(), beneficiary) + } + + private fun deriveBuyExecutionUpperBoundAmount(transferringAmount: Balance): Balance { + return transferringAmount / 2.toBigInteger() + } + + private fun WeightLimit.Companion.one(): WeightLimit.Limited { + return WeightLimit.Limited(WeightV2(1.toBigInteger(), 1.toBigInteger())) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/DynamicCrossChainWeigher.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/DynamicCrossChainWeigher.kt new file mode 100644 index 0000000..714afc8 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/DynamicCrossChainWeigher.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic + +import android.util.Log +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.replaceAmount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainFeeModel +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.XcmTransferDryRunOrigin +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunResult +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunner +import javax.inject.Inject + +private const val MINIMUM_SEND_AMOUNT = 100 + +@FeatureScope +class DynamicCrossChainWeigher @Inject constructor( + private val xcmTransferDryRunner: XcmTransferDryRunner, +) { + + suspend fun estimateFee( + config: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase + ): CrossChainFeeModel { + val safeTransfer = transfer.ensureSafeAmount() + val result = xcmTransferDryRunner.dryRunXcmTransfer(config, safeTransfer, XcmTransferDryRunOrigin.Fake) + + return result.getOrNull()?.let { dryRunResult -> + CrossChainFeeModel.fromDryRunResult( + initialAmount = safeTransfer.amountPlanks, + transferDryRunResult = dryRunResult + ) + } ?: run { + // Dry run failed - use fallback fee estimation + // For teleport transfers, dry run often doesn't produce forwarded XCMs + Log.w(LOG_TAG, "Dry run failed for ${config.transferType}, using fallback fee estimation") + estimateFallbackFee(config, transfer) + } + } + + /** + * Fallback fee estimation when dry run fails. + * Uses a conservative percentage of the transfer amount as fee buffer. + */ + private fun estimateFallbackFee( + config: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase + ): CrossChainFeeModel { + // Use 1% of transfer amount as conservative fee estimate for all transfer types + // This covers execution fees on destination chain + val estimatedFee = transfer.amountPlanks / 100.toBigInteger() + return CrossChainFeeModel(paidFromHolding = estimatedFee.coerceAtLeast(Balance.ZERO)) + } + + // Ensure we can calculate fee regardless of what user entered + private fun AssetTransferBase.ensureSafeAmount(): AssetTransferBase { + val minimumSendAmount = destinationChainAsset.planksFromAmount(MINIMUM_SEND_AMOUNT.toBigDecimal()) + val safeAmount = amountPlanks.coerceAtLeast(minimumSendAmount) + return replaceAmount(newAmount = safeAmount) + } + + private fun CrossChainFeeModel.Companion.fromDryRunResult( + initialAmount: Balance, + transferDryRunResult: XcmTransferDryRunResult + ): CrossChainFeeModel { + return with(transferDryRunResult) { + // We do not add `remoteReserve.deliveryFee` since it is paid from holding and not by account + val paidByAccount = origin.deliveryFee + + val trapped = origin.trapped + remoteReserve?.trapped.orZero() + val totalFee = initialAmount - destination.depositedAmount - trapped + + // We do not subtract `origin.deliveryFee` since it is paid directly from the origin account and thus do not contribute towards execution fee + // We do not subtract `remoteReserve.deliveryFee` since it is paid from holding and thus is already accounted in totalFee + val executionFee = totalFee + + CrossChainFeeModel(paidByAccount = paidByAccount, paidFromHolding = executionFee) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/XcmTransferDryRunResult.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/XcmTransferDryRunResult.kt new file mode 100644 index 0000000..d952ea2 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/XcmTransferDryRunResult.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance + +class XcmTransferDryRunResult( + val origin: IntermediateSegment, + val remoteReserve: IntermediateSegment?, + val destination: FinalSegment, +) { + + class IntermediateSegment( + val deliveryFee: Balance, + val trapped: Balance, + ) + + class FinalSegment( + val depositedAmount: Balance + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/XcmTransferDryRunner.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/XcmTransferDryRunner.kt new file mode 100644 index 0000000..3fdb4a0 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/XcmTransferDryRunner.kt @@ -0,0 +1,347 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.xcmPalletNameOrNull +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.XcmTransferDryRunOrigin +import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.destinationChainLocation +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.isRemoteReserve +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.remoteReserveLocation +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainLocation +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.calls.composeBatchAll +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.calls.composeDispatchAs +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainTransactor +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunResult.FinalSegment +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunResult.IntermediateSegment +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing.AssetIssuerRegistry +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets +import io.novafoundation.nova.feature_xcm_api.asset.requireFungible +import io.novafoundation.nova.feature_xcm_api.message.VersionedRawXcmMessage +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.DryRunApi +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.DryRunEffects +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.getByLocation +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.senderXcmVersion +import io.novafoundation.nova.feature_xcm_api.runtimeApi.getInnerSuccessOrThrow +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm +import io.novafoundation.nova.runtime.ext.emptyAccountIdKey +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import javax.inject.Inject + +interface XcmTransferDryRunner { + + suspend fun dryRunXcmTransfer( + config: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + origin: XcmTransferDryRunOrigin + ): Result +} + +@FeatureScope +class RealXcmTransferDryRunner @Inject constructor( + private val dryRunApi: DryRunApi, + private val chainRegistry: ChainRegistry, + private val assetIssuerRegistry: AssetIssuerRegistry, + private val assetSourceRegistry: AssetSourceRegistry, + private val dynamicCrossChainTransactor: DynamicCrossChainTransactor, +) : XcmTransferDryRunner { + + companion object { + + private const val MINIMUM_FUND_AMOUNT = 100 + + private const val FEES_PAID_FEES_ARGUMENT_INDEX = 1 + private const val ASSETS_TRAPPED_ARGUMENT_INDEX = 2 + } + + override suspend fun dryRunXcmTransfer( + config: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + origin: XcmTransferDryRunOrigin + ): Result { + return runCatching { + val originResult = dryRunOnOrigin(config, transfer, origin) + val remoteReserveResult = dryRunOnRemoteReserve(config, originResult.forwardedXcm) + val destinationResult = dryRunOnDestination(config, transfer, remoteReserveResult.forwardedXcm) + + XcmTransferDryRunResult( + origin = originResult.toPublicResult(), + remoteReserve = remoteReserveResult.takeIfRemoteReserve(config)?.toPublicResult(), + destination = destinationResult.toPublicResult() + ) + } + .onFailure { Log.w(LOG_TAG, "Dry run failed", it) } + } + + private suspend fun dryRunOnOrigin( + config: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + origin: XcmTransferDryRunOrigin, + ): IntermediateDryRunResult { + val runtime = chainRegistry.getRuntime(config.originChainId) + val xcmResultsVersion = XcmVersion.V4 + + val (dryRunCall, dryRunOrigin) = constructDryRunCallParams(config, transfer, origin, runtime) + val dryRunResult = dryRunApi.dryRunCall(dryRunOrigin, dryRunCall, xcmResultsVersion, config.originChainId) + .getInnerSuccessOrThrow(LOG_TAG) + + val nextHopLocation = (config.transferType.remoteReserveLocation() ?: config.destinationChainLocation).location + + val forwardedXcm = searchForwardedXcm( + dryRunEffects = dryRunResult, + destination = nextHopLocation.fromPointOfViewOf(config.originChainLocation.location), + ) + val deliveryFee = searchDeliveryFee(dryRunResult, runtime) + val trappedAssets = searchTrappedAssets(dryRunResult, runtime) + + return IntermediateDryRunResult(forwardedXcm, deliveryFee, trappedAssets) + } + + private suspend fun constructDryRunCallParams( + config: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + origin: XcmTransferDryRunOrigin, + runtime: RuntimeSnapshot + ): OriginCallParams { + return when (origin) { + XcmTransferDryRunOrigin.Fake -> constructDryRunCallFromFakeOrigin(transfer, config, runtime) + + is XcmTransferDryRunOrigin.Signed -> constructDryRunCallFromRealOrigin(transfer, config, origin) + } + } + + private suspend fun constructDryRunCallFromRealOrigin( + transfer: AssetTransferBase, + config: DynamicCrossChainTransferConfiguration, + origin: XcmTransferDryRunOrigin.Signed, + ): OriginCallParams { + val callOnOrigin = dynamicCrossChainTransactor.composeCrossChainTransferCall(config, transfer, crossChainFee = origin.crossChainFee) + + return OriginCallParams( + call = callOnOrigin, + origin = OriginCaller.System.Signed(origin.accountId) + ) + } + + private suspend fun constructDryRunCallFromFakeOrigin( + transfer: AssetTransferBase, + config: DynamicCrossChainTransferConfiguration, + runtime: RuntimeSnapshot, + ): OriginCallParams { + val callOnOrigin = dynamicCrossChainTransactor.composeCrossChainTransferCall(config, transfer, crossChainFee = Balance.ZERO) + + val dryRunAccount = transfer.originChain.emptyAccountIdKey() + val transferOrigin = OriginCaller.System.Signed(dryRunAccount) + + val calls = buildList { + addFundCalls(transfer, dryRunAccount) + + val transferCallFromOrigin = runtime.composeDispatchAs(callOnOrigin, transferOrigin) + add(transferCallFromOrigin) + } + + val finalOriginCall = runtime.composeBatchAll(calls) + return OriginCallParams(finalOriginCall, OriginCaller.System.Root) + } + + private suspend fun MutableList.addFundCalls(transfer: AssetTransferBase, dryRunAccount: AccountIdKey) { + val fundAmount = determineFundAmount(transfer) + + // Fund native asset first so we can later fund potentially non-sufficient assets + if (!transfer.originChainAsset.isUtilityAsset) { + // Additionally fund native asset to pay delivery fees + val nativeAsset = transfer.originChain.utilityAsset + val planks = nativeAsset.planksFromAmount(MINIMUM_FUND_AMOUNT.toBigDecimal()) + val fundNativeAssetCall = assetIssuerRegistry.create(nativeAsset).composeIssueCall(planks, dryRunAccount) + add(fundNativeAssetCall) + } + + val fundSendingAssetCall = assetIssuerRegistry.create(transfer.originChainAsset).composeIssueCall(fundAmount, dryRunAccount) + add(fundSendingAssetCall) + } + + private suspend fun dryRunOnRemoteReserve( + config: DynamicCrossChainTransferConfiguration, + forwardedFromOrigin: VersionedRawXcmMessage, + ): IntermediateDryRunResult { + // No remote reserve - nothing to dry run, return unchanged value + val remoteReserveLocation = config.transferType.remoteReserveLocation() + ?: return IntermediateDryRunResult(forwardedFromOrigin, Balance.ZERO, Balance.ZERO) + + val runtime = chainRegistry.getRuntime(remoteReserveLocation.chainId) + + val originLocation = config.originChainLocation.location + val destinationLocation = config.destinationChainLocation.location + + val usedXcmVersion = forwardedFromOrigin.version + + val dryRunOrigin = originLocation.fromPointOfViewOf(remoteReserveLocation.location).versionedXcm(usedXcmVersion) + val dryRunResult = dryRunApi.dryRunXcm(dryRunOrigin, forwardedFromOrigin, remoteReserveLocation.chainId) + .getInnerSuccessOrThrow(LOG_TAG) + + val destinationOnRemoteReserve = destinationLocation.fromPointOfViewOf(remoteReserveLocation.location) + + val forwardedXcm = searchForwardedXcm( + dryRunEffects = dryRunResult, + destination = destinationOnRemoteReserve, + ) + val deliveryFee = searchDeliveryFee(dryRunResult, runtime) + val trappedAssets = searchTrappedAssets(dryRunResult, runtime) + + return IntermediateDryRunResult(forwardedXcm, deliveryFee, trappedAssets) + } + + private suspend fun dryRunOnDestination( + config: DynamicCrossChainTransferConfiguration, + transfer: AssetTransferBase, + forwardedFromPrevious: VersionedRawXcmMessage, + ): FinalDryRunResult { + val previousLocation = (config.transferType.remoteReserveLocation() ?: config.originChainLocation).location + val destinationLocation = config.destinationChainLocation + + val usedXcmVersion = forwardedFromPrevious.version + + val dryRunOrigin = previousLocation.fromPointOfViewOf(destinationLocation.location).versionedXcm(usedXcmVersion) + val dryRunResult = dryRunApi.dryRunXcm(dryRunOrigin, forwardedFromPrevious, destinationLocation.chainId) + .getInnerSuccessOrThrow(LOG_TAG) + + val depositedAmount = searchDepositAmount(dryRunResult, transfer.destinationChainAsset, transfer.recipientAccountId) + + return FinalDryRunResult(depositedAmount) + } + + private fun searchForwardedXcm( + dryRunEffects: DryRunEffects, + destination: RelativeMultiLocation, + ): VersionedRawXcmMessage { + return searchForwardedXcmInQueues(dryRunEffects, destination) + } + + private suspend fun searchDepositAmount( + dryRunEffects: DryRunEffects, + chainAsset: Chain.Asset, + recipientAccountId: AccountIdKey, + ): Balance { + val depositDetector = assetSourceRegistry.getEventDetector(chainAsset) + + val deposits = dryRunEffects.emittedEvents.mapNotNull { depositDetector.detectDeposit(it) } + .filter { it.destination == recipientAccountId } + + if (deposits.isEmpty()) error("No deposits detected") + + return deposits.sumOf { it.amount } + } + + private fun searchDeliveryFee( + dryRunEffects: DryRunEffects, + runtimeSnapshot: RuntimeSnapshot, + ): Balance { + val xcmPalletName = runtimeSnapshot.metadata.xcmPalletNameOrNull() ?: return Balance.ZERO + val event = dryRunEffects.emittedEvents.findEvent(xcmPalletName, "FeesPaid") ?: return Balance.ZERO + + val usedXcmVersion = dryRunEffects.senderXcmVersion() + + val feesDecoded = event.arguments[FEES_PAID_FEES_ARGUMENT_INDEX] + val multiAssets = MultiAssets.bind(feesDecoded, usedXcmVersion) + + return multiAssets.extractFirstAmount() + } + + private fun searchTrappedAssets( + dryRunEffects: DryRunEffects, + runtimeSnapshot: RuntimeSnapshot, + ): Balance { + val xcmPalletName = runtimeSnapshot.metadata.xcmPalletNameOrNull() ?: return Balance.ZERO + val event = dryRunEffects.emittedEvents.findEvent(xcmPalletName, "AssetsTrapped") ?: return Balance.ZERO + + val feesDecoded = event.arguments[ASSETS_TRAPPED_ARGUMENT_INDEX] + val multiAssets = MultiAssets.bindVersioned(feesDecoded).xcm + + return multiAssets.extractFirstAmount() + } + + private fun MultiAssets.extractFirstAmount(): Balance { + return if (value.isNotEmpty()) { + value.first().requireFungible().amount + } else { + Balance.ZERO + } + } + + private fun searchForwardedXcmInQueues( + dryRunEffects: DryRunEffects, + destination: RelativeMultiLocation + ): VersionedRawXcmMessage { + val forwardedXcms = dryRunEffects.forwardedXcms + + // For teleport transfers, forwarded XCMs might be empty or structured differently + if (forwardedXcms.isEmpty()) { + error("Dry run did not produce any forwarded XCMs. This transfer type may not support dry run fee estimation.") + } + + val usedXcmVersion = dryRunEffects.senderXcmVersion() + val versionedDestination = destination.versionedXcm(usedXcmVersion) + + val forwardedXcmsToDestination = forwardedXcms.getByLocation(versionedDestination) + + // If destination location not found, try first available forwarded XCM + if (forwardedXcmsToDestination.isEmpty()) { + Log.w(LOG_TAG, "No forwarded XCM found for destination $destination, using first available") + val firstAvailable = forwardedXcms.firstOrNull()?.second?.firstOrNull() + return firstAvailable ?: error("No forwarded XCMs available for dry run") + } + + // There should only be one forwarded message during dry run + return forwardedXcmsToDestination.first() + } + + private fun determineFundAmount(transfer: AssetTransferBase): Balance { + val amount = (transfer.amount() * 2.toBigDecimal()).coerceAtLeast(MINIMUM_FUND_AMOUNT.toBigDecimal()) + return transfer.originChainAsset.planksFromAmount(amount) + } + + private fun IntermediateDryRunResult.toPublicResult(): IntermediateSegment { + return IntermediateSegment( + deliveryFee = deliveryFee, + trapped = trapped + ) + } + + private fun FinalDryRunResult.toPublicResult(): FinalSegment { + return FinalSegment(depositedAmount = depositedAmount) + } + + private fun IntermediateDryRunResult.takeIfRemoteReserve(config: DynamicCrossChainTransferConfiguration): IntermediateDryRunResult? { + return takeIf { config.transferType.isRemoteReserve() } + } + + private class IntermediateDryRunResult( + val forwardedXcm: VersionedRawXcmMessage, + val deliveryFee: Balance, + val trapped: Balance, + ) + + private class FinalDryRunResult( + val depositedAmount: Balance + ) + + private data class OriginCallParams(val call: GenericCall.Instance, val origin: OriginCaller) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/AssetIssuer.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/AssetIssuer.kt new file mode 100644 index 0000000..26a8169 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/AssetIssuer.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +interface AssetIssuer { + + /** + * Compose a call to issue [amount] of tokens to [destination] + * Implementation can assume execution happens under [OriginCaller.System.Root] + */ + suspend fun composeIssueCall(amount: Balance, destination: AccountIdKey): GenericCall.Instance +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/AssetIssuerRegistry.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/AssetIssuerRegistry.kt new file mode 100644 index 0000000..a6b3a75 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/AssetIssuerRegistry.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import javax.inject.Inject + +interface AssetIssuerRegistry { + + suspend fun create(chainAsset: Chain.Asset): AssetIssuer +} + +@FeatureScope +class RealAssetIssuerRegistry @Inject constructor( + private val chainRegistry: ChainRegistry, + private val statemineAssetsRepository: StatemineAssetsRepository, +) : AssetIssuerRegistry { + + override suspend fun create(chainAsset: Chain.Asset): AssetIssuer { + val runtime = chainRegistry.getRuntime(chainAsset.chainId) + + return when (val type = chainAsset.type) { + is Type.Native -> NativeAssetIssuer(runtime) + is Type.Statemine -> StatemineAssetIssuer(chainAsset.chainId, type, runtime, statemineAssetsRepository) + is Type.Orml -> OrmlAssetIssuer(type, runtime) + else -> error("Unsupported asset type: $type for ${chainAsset.symbol} on ${chainAsset.chainId}") + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/NativeAssetIssuer.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/NativeAssetIssuer.kt new file mode 100644 index 0000000..d254e79 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/NativeAssetIssuer.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.composeCall +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor + +class NativeAssetIssuer( + private val runtimeSnapshot: RuntimeSnapshot +) : AssetIssuer { + + override suspend fun composeIssueCall(amount: Balance, destination: AccountIdKey): GenericCall.Instance { + return runtimeSnapshot.composeCall( + moduleName = Modules.BALANCES, + callName = "force_set_balance", + arguments = mapOf( + "who" to PezkuwiAddressConstructor.constructInstance(runtimeSnapshot.typeRegistry, destination.value), + "new_free" to amount + ) + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/OrmlAssetIssuer.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/OrmlAssetIssuer.kt new file mode 100644 index 0000000..c374a53 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/OrmlAssetIssuer.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.composeCall +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.runtime.ext.currencyId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor + +class OrmlAssetIssuer( + private val ormlType: Chain.Asset.Type.Orml, + private val runtimeSnapshot: RuntimeSnapshot +) : AssetIssuer { + + override suspend fun composeIssueCall(amount: Balance, destination: AccountIdKey): GenericCall.Instance { + return runtimeSnapshot.composeCall( + moduleName = Modules.TOKENS, + callName = "set_balance", + arguments = mapOf( + "who" to PezkuwiAddressConstructor.constructInstance(runtimeSnapshot.typeRegistry, destination.value), + "currency_id" to ormlType.currencyId(runtimeSnapshot), + "new_free" to amount, + "new_reserved" to Balance.ZERO + ) + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/StatemineAssetIssuer.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/StatemineAssetIssuer.kt new file mode 100644 index 0000000..f4c26c1 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/dynamic/dryRun/issuing/StatemineAssetIssuer.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.composeCall +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.calls.composeDispatchAs +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller +import io.novafoundation.nova.runtime.ext.palletNameOrDefault +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.prepareIdForEncoding +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor + +class StatemineAssetIssuer( + private val chainId: ChainId, + private val assetType: Chain.Asset.Type.Statemine, + private val runtimeSnapshot: RuntimeSnapshot, + private val statemineAssetsRepository: StatemineAssetsRepository, +) : AssetIssuer { + + override suspend fun composeIssueCall(amount: Balance, destination: AccountIdKey): GenericCall.Instance { + val assetDetails = statemineAssetsRepository.getAssetDetails(chainId, assetType) + val issuer = assetDetails.issuer + + // We're dispatching as issuer since only issuer is allowed to mint tokens + return runtimeSnapshot.composeDispatchAs( + call = composeMint(amount, destination), + origin = OriginCaller.System.Signed(issuer) + ) + } + + private fun composeMint(amount: Balance, destination: AccountIdKey): GenericCall.Instance { + return runtimeSnapshot.composeCall( + moduleName = assetType.palletNameOrDefault(), + callName = "mint", + arguments = mapOf( + "id" to assetType.prepareIdForEncoding(runtimeSnapshot), + "beneficiary" to PezkuwiAddressConstructor.constructInstance(runtimeSnapshot.typeRegistry, destination.value), + "amount" to amount + ) + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/legacy/LegacyCrossChainConfigRemote.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/legacy/LegacyCrossChainConfigRemote.kt new file mode 100644 index 0000000..77dc011 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/legacy/LegacyCrossChainConfigRemote.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy + +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.JunctionsRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import java.math.BigInteger + +class LegacyCrossChainTransfersConfigRemote( + val assetsLocation: Map?, + val instructions: Map>?, + val networkDeliveryFee: Map?, + val networkBaseWeight: Map?, + val chains: List? +) + +class LegacyReserveLocationRemote( + val chainId: ChainId, + val reserveFee: LegacyXcmFeeRemote?, + val multiLocation: JunctionsRemote +) + +class LegacyNetworkDeliveryFeeRemote( + val toParent: LegacyDeliveryFeeConfigRemote?, + val toParachain: LegacyDeliveryFeeConfigRemote? +) + +class LegacyDeliveryFeeConfigRemote( + val type: String, + val factorPallet: String, + val sizeBase: BigInteger, + val sizeFactor: BigInteger, + val alwaysHoldingPays: Boolean? +) + +class LegacyCrossChainOriginChainRemote( + val chainId: ChainId, + val assets: List +) + +class LegacyCrossChainOriginAssetRemote( + val assetId: Int, + val assetLocation: String, + val assetLocationPath: LegacyAssetLocationPathRemote, + val xcmTransfers: List, +) + +class LegacyXcmTransferRemote( + val destination: LegacyXcmDestinationRemote, + val type: String, +) + +class LegacyXcmDestinationRemote( + val chainId: ChainId, + val assetId: Int, + val fee: LegacyXcmFeeRemote +) + +class LegacyXcmFeeRemote( + val mode: Mode, + val instructions: String +) { + + class Mode( + val type: String, + val value: String? + ) +} + +class LegacyAssetLocationPathRemote( + val type: String, + val path: JunctionsRemote? +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/legacy/LegacyCrossChainTransactor.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/legacy/LegacyCrossChainTransactor.kt new file mode 100644 index 0000000..21c2d3d --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/legacy/LegacyCrossChainTransactor.kt @@ -0,0 +1,160 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.Weight +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.xTokensName +import io.novafoundation.nova.common.utils.xcmPalletName +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyXcmTransferMethod +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.common.TransferAssetUsingTypeTransactor +import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.plus +import io.novafoundation.nova.feature_xcm_api.multiLocation.toMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.feature_xcm_api.versions.orDefault +import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance +import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm +import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call +import java.math.BigInteger +import javax.inject.Inject + +@FeatureScope +class LegacyCrossChainTransactor @Inject constructor( + private val weigher: LegacyCrossChainWeigher, + private val xcmVersionDetector: XcmVersionDetector, + private val assetSourceRegistry: AssetSourceRegistry, + private val usingTypeTransactor: TransferAssetUsingTypeTransactor, +) { + + context(ExtrinsicBuilder) + suspend fun crossChainTransfer( + configuration: LegacyCrossChainTransferConfiguration, + transfer: AssetTransferBase, + crossChainFee: Balance + ) { + when (configuration.transferMethod) { + LegacyXcmTransferMethod.X_TOKENS -> xTokensTransfer(configuration, transfer, crossChainFee) + LegacyXcmTransferMethod.XCM_PALLET_RESERVE -> xcmPalletReserveTransfer(configuration, transfer, crossChainFee) + LegacyXcmTransferMethod.XCM_PALLET_TELEPORT -> xcmPalletTeleport(configuration, transfer, crossChainFee) + LegacyXcmTransferMethod.XCM_PALLET_TRANSFER_ASSETS -> xcmPalletTransferAssets(configuration, transfer, crossChainFee) + LegacyXcmTransferMethod.UNKNOWN -> throw IllegalArgumentException("Unknown transfer type") + } + } + + suspend fun requiredRemainingAmountAfterTransfer( + configuration: LegacyCrossChainTransferConfiguration + ): Balance { + val chainAsset = configuration.originChainAsset + return assetSourceRegistry.sourceFor(chainAsset).balance.existentialDeposit(chainAsset) + } + + private suspend fun ExtrinsicBuilder.xTokensTransfer( + configuration: LegacyCrossChainTransferConfiguration, + assetTransfer: AssetTransferBase, + crossChainFee: Balance + ) { + val multiAsset = configuration.multiAssetFor(assetTransfer, crossChainFee) + val fullDestinationLocation = configuration.destinationChainLocation + assetTransfer.beneficiaryLocation() + val requiredDestWeight = weigher.estimateRequiredDestWeight(configuration) + + val lowestMultiLocationVersion = xcmVersionDetector.lowestPresentMultiLocationVersion(assetTransfer.originChain.id).orDefault() + val lowestMultiAssetVersion = xcmVersionDetector.lowestPresentMultiAssetVersion(assetTransfer.originChain.id).orDefault() + + call( + moduleName = runtime.metadata.xTokensName(), + callName = "transfer_multiasset", + arguments = mapOf( + "asset" to multiAsset.versionedXcm(lowestMultiAssetVersion).toEncodableInstance(), + "dest" to fullDestinationLocation.versionedXcm(lowestMultiLocationVersion).toEncodableInstance(), + + // depending on the version of the pallet, only one of weights arguments going to be encoded + "dest_weight" to destWeightEncodable(requiredDestWeight), + "dest_weight_limit" to WeightLimit.Unlimited.toEncodableInstance() + ) + ) + } + + private fun destWeightEncodable(weight: Weight): Any = weight + + private suspend fun ExtrinsicBuilder.xcmPalletTransferAssets( + configuration: LegacyCrossChainTransferConfiguration, + assetTransfer: AssetTransferBase, + crossChainFee: Balance + ) { + val call = usingTypeTransactor.composeCall(configuration, assetTransfer, crossChainFee) + call(call) + } + + private suspend fun ExtrinsicBuilder.xcmPalletReserveTransfer( + configuration: LegacyCrossChainTransferConfiguration, + assetTransfer: AssetTransferBase, + crossChainFee: Balance + ) { + xcmPalletTransfer( + configuration = configuration, + assetTransfer = assetTransfer, + crossChainFee = crossChainFee, + callName = "limited_reserve_transfer_assets" + ) + } + + private suspend fun ExtrinsicBuilder.xcmPalletTeleport( + configuration: LegacyCrossChainTransferConfiguration, + assetTransfer: AssetTransferBase, + crossChainFee: Balance + ) { + xcmPalletTransfer( + configuration = configuration, + assetTransfer = assetTransfer, + crossChainFee = crossChainFee, + callName = "limited_teleport_assets" + ) + } + + private suspend fun ExtrinsicBuilder.xcmPalletTransfer( + configuration: LegacyCrossChainTransferConfiguration, + assetTransfer: AssetTransferBase, + crossChainFee: Balance, + callName: String + ) { + val lowestMultiLocationVersion = xcmVersionDetector.lowestPresentMultiLocationVersion(assetTransfer.originChain.id).orDefault() + val lowestMultiAssetsVersion = xcmVersionDetector.lowestPresentMultiAssetsVersion(assetTransfer.originChain.id).orDefault() + + val multiAsset = configuration.multiAssetFor(assetTransfer, crossChainFee) + + call( + moduleName = runtime.metadata.xcmPalletName(), + callName = callName, + arguments = mapOf( + "dest" to configuration.destinationChainLocation.versionedXcm(lowestMultiLocationVersion).toEncodableInstance(), + "beneficiary" to assetTransfer.beneficiaryLocation().versionedXcm(lowestMultiLocationVersion).toEncodableInstance(), + "assets" to MultiAssets(multiAsset).versionedXcm(lowestMultiAssetsVersion).toEncodableInstance(), + "fee_asset_item" to BigInteger.ZERO, + "weight_limit" to WeightLimit.Unlimited.toEncodableInstance() + ) + ) + } + + private fun LegacyCrossChainTransferConfiguration.multiAssetFor( + transfer: AssetTransferBase, + crossChainFee: Balance + ): MultiAsset { + // we add cross chain fee top of entered amount so received amount will be no less than entered one + val planks = transfer.amountPlanks + crossChainFee + return MultiAsset.from(assetLocation, planks) + } + + private fun AssetTransferBase.beneficiaryLocation(): RelativeMultiLocation { + val accountId = destinationChain.accountIdOrDefault(recipient).intoKey() + return accountId.toMultiLocation() + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/legacy/LegacyCrossChainWeigher.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/legacy/LegacyCrossChainWeigher.kt new file mode 100644 index 0000000..916231e --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/legacy/LegacyCrossChainWeigher.kt @@ -0,0 +1,303 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.data.network.runtime.binding.Weight +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.BigRational +import io.novafoundation.nova.common.utils.argument +import io.novafoundation.nova.common.utils.fixedU128 +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.requireActualType +import io.novafoundation.nova.common.utils.xcmPalletName +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainFeeModel +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.orZero +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.plus +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.zero +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.CrossChainFeeConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.DeliveryFeeConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.XcmFee.Mode +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.XCMInstructionType +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.weightToFee +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.xcmExecute +import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets +import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction +import io.novafoundation.nova.feature_xcm_api.message.XcmMessage +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.isHere +import io.novafoundation.nova.feature_xcm_api.multiLocation.paraIdOrNull +import io.novafoundation.nova.feature_xcm_api.multiLocation.toMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcm +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.feature_xcm_api.versions.orDefault +import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance +import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm +import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ext.emptyAccountIdKey +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.definitions.types.bytes +import io.novasama.substrate_sdk_android.runtime.metadata.call +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import java.math.BigInteger +import javax.inject.Inject +import javax.inject.Named + +// TODO: Currently message doesn't contain setTopic command in the end. It will come with XCMv3 support +private const val SET_TOPIC_SIZE = 33 + +@FeatureScope +class LegacyCrossChainWeigher @Inject constructor( + @Named(REMOTE_STORAGE_SOURCE) + private val storageDataSource: StorageDataSource, + private val extrinsicService: ExtrinsicService, + private val chainRegistry: ChainRegistry, + private val xcmVersionDetector: XcmVersionDetector +) { + + fun estimateRequiredDestWeight(transferConfiguration: LegacyCrossChainTransferConfiguration): Weight { + val destinationWeight = transferConfiguration.destinationFee.estimatedWeight() + val reserveWeight = transferConfiguration.reserveFee?.estimatedWeight().orZero() + + return destinationWeight.max(reserveWeight) + } + + suspend fun estimateFee(amount: Balance, config: LegacyCrossChainTransferConfiguration): CrossChainFeeModel = with(config) { + // Reserve fee may be zero if xcm transfer doesn't reserve tokens + val reserveFeeAmount = calculateFee(amount, reserveFee, reserveChainLocation) + val destinationFeeAmount = calculateFee(amount, destinationFee, destinationChainLocation) + + return reserveFeeAmount + destinationFeeAmount + } + + private suspend fun LegacyCrossChainTransferConfiguration.calculateFee( + amount: Balance, + feeConfig: CrossChainFeeConfiguration?, + chainLocation: RelativeMultiLocation + ): CrossChainFeeModel { + return when (feeConfig) { + null -> CrossChainFeeModel.zero() + else -> { + val isSendingFromOrigin = originChainId == feeConfig.from.chainId + val feeAmount = feeFor(amount, feeConfig) + val deliveryFee = deliveryFeeFor(amount, feeConfig, chainLocation, isSendingFromOrigin = isSendingFromOrigin) + feeAmount.orZero() + deliveryFee.orZero() + } + } + } + + private suspend fun LegacyCrossChainTransferConfiguration.feeFor(amount: Balance, feeConfig: CrossChainFeeConfiguration): CrossChainFeeModel { + val chain = chainRegistry.getChain(feeConfig.to.chainId) + val maxWeight = feeConfig.estimatedWeight() + + return when (val mode = feeConfig.to.xcmFeeType.mode) { + is Mode.Proportional -> CrossChainFeeModel(paidFromHolding = mode.weightToFee(maxWeight)) + + Mode.Standard -> { + val xcmMessage = xcmMessage(feeConfig.to.xcmFeeType.instructions, chain, amount) + + val paymentInfo = extrinsicService.paymentInfo( + chain, + TransactionOrigin.SelectedWallet + ) { + xcmExecute(xcmMessage, maxWeight) + } + + CrossChainFeeModel(paidFromHolding = paymentInfo.partialFee) + } + + Mode.Unknown -> CrossChainFeeModel.zero() + } + } + + private suspend fun LegacyCrossChainTransferConfiguration.deliveryFeeFor( + amount: Balance, + config: CrossChainFeeConfiguration, + destinationChainLocation: RelativeMultiLocation, + isSendingFromOrigin: Boolean + ): CrossChainFeeModel { + val deliveryFeeConfiguration = config.from.deliveryFeeConfiguration ?: return CrossChainFeeModel.zero() + + val deliveryConfig = deliveryFeeConfiguration.getDeliveryConfig(destinationChainLocation) + + val deliveryFeeFactor: BigInteger = queryDeliveryFeeFactor(config.from.chainId, deliveryConfig.factorPallet, destinationChainLocation) + + val xcmMessageSize = getXcmMessageSize(amount, config) + val xcmMessageSizeWithTopic = xcmMessageSize + SET_TOPIC_SIZE.toBigInteger() + + val feeSize = (deliveryConfig.sizeBase + xcmMessageSizeWithTopic * deliveryConfig.sizeFactor) + val deliveryFee = BigRational.fixedU128(deliveryFeeFactor * feeSize).integralQuotient + + val isSenderPaysOriginDelivery = !deliveryConfig.alwaysHoldingPays + return if (isSenderPaysOriginDelivery && isSendingFromOrigin) { + CrossChainFeeModel(paidByAccount = deliveryFee) + } else { + CrossChainFeeModel(paidFromHolding = deliveryFee) + } + } + + private suspend fun LegacyCrossChainTransferConfiguration.getXcmMessageSize(amount: Balance, config: CrossChainFeeConfiguration): BigInteger { + val chain = chainRegistry.getChain(config.to.chainId) + val runtime = chainRegistry.getRuntime(config.to.chainId) + val xcmMessage = xcmMessage(config.to.xcmFeeType.instructions, chain, amount) + .toEncodableInstance() + + return runtime.metadata + .module(runtime.metadata.xcmPalletName()) + .call("execute") + .argument("message") + .requireActualType() + .bytes(runtime, xcmMessage) + .size.toBigInteger() + } + + private fun DeliveryFeeConfiguration.getDeliveryConfig(destinationChainLocation: RelativeMultiLocation): DeliveryFeeConfiguration.Type.Exponential { + val isParent = destinationChainLocation.interior.isHere() + + val configType = when { + isParent -> toParent + else -> toParachain + } + + return configType.asExponentialOrThrow() + } + + private fun DeliveryFeeConfiguration.Type?.asExponentialOrThrow(): DeliveryFeeConfiguration.Type.Exponential { + return this as? DeliveryFeeConfiguration.Type.Exponential ?: throw IllegalStateException("Unknown delivery fee type") + } + + private suspend fun queryDeliveryFeeFactor( + chainId: ChainId, + pallet: String, + destinationMultiLocation: RelativeMultiLocation, + ): BigInteger { + return when { + destinationMultiLocation.interior.isHere() -> xcmParentDeliveryFeeFactor(chainId, pallet) + else -> { + val paraId = destinationMultiLocation.paraIdOrNull() ?: throw IllegalStateException("ParaId must be not null") + xcmParachainDeliveryFeeFactor(chainId, pallet, paraId) + } + } + } + + private fun CrossChainFeeConfiguration.estimatedWeight(): Weight { + val instructionTypes = to.xcmFeeType.instructions + + return to.instructionWeight * instructionTypes.size.toBigInteger() + } + + private suspend fun LegacyCrossChainTransferConfiguration.xcmMessage( + instructionTypes: List, + chain: Chain, + amount: Balance + ): VersionedXcm { + val instructions = instructionTypes.mapNotNull { instructionType -> xcmInstruction(instructionType, chain, amount) } + val message = XcmMessage(instructions) + val xcmVersion = xcmVersionDetector.lowestPresentMultiLocationVersion(chain.id).orDefault() + + return message.versionedXcm(xcmVersion) + } + + private fun LegacyCrossChainTransferConfiguration.xcmInstruction( + instructionType: XCMInstructionType, + chain: Chain, + amount: Balance + ): XcmInstruction? { + return when (instructionType) { + XCMInstructionType.ReserveAssetDeposited -> reserveAssetDeposited(amount) + XCMInstructionType.ClearOrigin -> clearOrigin() + XCMInstructionType.BuyExecution -> buyExecution(amount) + XCMInstructionType.DepositAsset -> depositAsset(chain) + XCMInstructionType.WithdrawAsset -> withdrawAsset(amount) + XCMInstructionType.DepositReserveAsset -> depositReserveAsset() + XCMInstructionType.ReceiveTeleportedAsset -> receiveTeleportedAsset(amount) + XCMInstructionType.UNKNOWN -> null + } + } + + private fun LegacyCrossChainTransferConfiguration.reserveAssetDeposited(amount: Balance) = + XcmInstruction.ReserveAssetDeposited( + assets = MultiAssets( + sendingAssetAmountOf(amount) + ) + ) + + private fun LegacyCrossChainTransferConfiguration.receiveTeleportedAsset(amount: Balance) = + XcmInstruction.ReceiveTeleportedAsset( + assets = MultiAssets( + sendingAssetAmountOf(amount) + ) + ) + + @Suppress("unused") + private fun LegacyCrossChainTransferConfiguration.clearOrigin() = XcmInstruction.ClearOrigin + + private fun LegacyCrossChainTransferConfiguration.buyExecution(amount: Balance): XcmInstruction.BuyExecution { + return XcmInstruction.BuyExecution( + fees = sendingAssetAmountOf(amount), + weightLimit = WeightLimit.Unlimited + ) + } + + @Suppress("unused") + private fun LegacyCrossChainTransferConfiguration.depositAsset(chain: Chain): XcmInstruction.DepositAsset { + return XcmInstruction.DepositAsset( + assets = MultiAssetFilter.Wild.All, + beneficiary = chain.emptyBeneficiaryMultiLocation() + ) + } + + private fun LegacyCrossChainTransferConfiguration.withdrawAsset(amount: Balance): XcmInstruction.WithdrawAsset { + return XcmInstruction.WithdrawAsset( + assets = MultiAssets( + sendingAssetAmountOf(amount) + ) + ) + } + + private fun LegacyCrossChainTransferConfiguration.depositReserveAsset(): XcmInstruction { + return XcmInstruction.DepositReserveAsset( + assets = MultiAssetFilter.Wild.All, + dest = destinationChainLocation, + xcm = XcmMessage(emptyList()) + ) + } + + private fun LegacyCrossChainTransferConfiguration.sendingAssetAmountOf(planks: Balance): MultiAsset { + return MultiAsset.from( + amount = planks, + multiLocation = assetLocation, + ) + } + + private fun Chain.emptyBeneficiaryMultiLocation(): RelativeMultiLocation = emptyAccountIdKey().toMultiLocation() + + private suspend fun xcmParachainDeliveryFeeFactor(chainId: ChainId, moduleName: String, paraId: ParaId): BigInteger { + return storageDataSource.query(chainId, applyStorageDefault = true) { + runtime.metadata.module(moduleName).storage("DeliveryFeeFactor") + .query( + paraId, + binding = ::bindNumber + ) + } + } + + private suspend fun xcmParentDeliveryFeeFactor(chainId: ChainId, moduleName: String): BigInteger { + return storageDataSource.query(chainId) { + runtime.metadata.module(moduleName).storage("UpwardDeliveryFeeFactor") + .query(binding = ::bindNumber) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/CannotDropBelowEdWhenPayingDeliveryFeeValidation.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/CannotDropBelowEdWhenPayingDeliveryFeeValidation.kt new file mode 100644 index 0000000..7e8caba --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/CannotDropBelowEdWhenPayingDeliveryFeeValidation.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.validations + +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDepositInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure.NotEnoughFunds.ToStayAboveEdBeforePayingDeliveryFees +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.isSendingCommissionAsset +import io.novasama.substrate_sdk_android.hash.isPositive + +class CannotDropBelowEdWhenPayingDeliveryFeeValidation( + private val assetSourceRegistry: AssetSourceRegistry +) : AssetTransfersValidation { + + override suspend fun validate(value: AssetTransferPayload): ValidationStatus { + if (!value.isSendingCommissionAsset) return valid() + + val existentialDeposit = assetSourceRegistry.existentialDepositInPlanks(value.transfer.originChainAsset) + + val deliveryFeePart = value.originFee.deliveryFee?.amount.orZero() + val paysDeliveryFee = deliveryFeePart.isPositive() + + val networkFeePlanks = value.originFee.submissionFee.amountByExecutingAccount + val crossChainFeePlanks = value.crossChainFee?.amount.orZero() + + val sendingAmount = value.transfer.amountInPlanks + crossChainFeePlanks + val requiredAmountWhenPayingDeliveryFee = sendingAmount + networkFeePlanks + deliveryFeePart + existentialDeposit + + val balanceCountedTowardsEd = value.originUsedAsset.balanceCountedTowardsEDInPlanks + + return when { + !paysDeliveryFee -> valid() + + requiredAmountWhenPayingDeliveryFee <= balanceCountedTowardsEd -> valid() + + else -> { + val availableBalance = (balanceCountedTowardsEd - networkFeePlanks - deliveryFeePart - crossChainFeePlanks - existentialDeposit).atLeastZero() + + validationError( + ToStayAboveEdBeforePayingDeliveryFees( + maxPossibleTransferAmount = availableBalance, + chainAsset = value.transfer.originChainAsset + ) + ) + } + } + } +} + +fun AssetTransfersValidationSystemBuilder.cannotDropBelowEdBeforePayingDeliveryFee( + assetSourceRegistry: AssetSourceRegistry +) = validate(CannotDropBelowEdWhenPayingDeliveryFeeValidation(assetSourceRegistry)) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/CrossChainFeeValidation.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/CrossChainFeeValidation.kt new file mode 100644 index 0000000..4835743 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/CrossChainFeeValidation.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.validations + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.feature_account_api.data.model.decimalAmount +import io.novafoundation.nova.feature_account_api.data.model.decimalAmountByExecutingAccount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.originFeeListInUsedAsset + +class CrossChainFeeValidation : AssetTransfersValidation { + + override suspend fun validate(value: AssetTransferPayload): ValidationStatus { + val originFeeSum = value.originFeeListInUsedAsset.sumOf { it.decimalAmountByExecutingAccount } + + val remainingBalanceAfterTransfer = value.originUsedAsset.transferable - value.transfer.amount - originFeeSum + + val crossChainFee = value.crossChainFee?.decimalAmount.orZero() + val remainsEnoughToPayCrossChainFees = remainingBalanceAfterTransfer >= crossChainFee + + return remainsEnoughToPayCrossChainFees isTrueOrError { + AssetTransferValidationFailure.NotEnoughFunds.ToPayCrossChainFee( + usedAsset = value.transfer.originChainAsset, + fee = crossChainFee, + remainingBalanceAfterTransfer = remainingBalanceAfterTransfer + ) + } + } +} + +fun AssetTransfersValidationSystemBuilder.canPayCrossChainFee() = validate(CrossChainFeeValidation()) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/CrossChainValidationSystemProvider.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/CrossChainValidationSystemProvider.kt new file mode 100644 index 0000000..8c57ad2 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/CrossChainValidationSystemProvider.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.validations + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.model.decimalAmount +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystem +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.doNotCrossExistentialDepositInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInCommissionAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notPhishingRecipient +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientCommissionBalanceToStayAboveED +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import javax.inject.Inject + +@FeatureScope +class RealCrossChainValidationSystemProvider @Inject constructor( + private val phishingValidationFactory: PhishingValidationFactory, + private val enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + private val dryRunSucceedsValidationFactory: DryRunSucceedsValidationFactory, + private val assetSourceRegistry: AssetSourceRegistry, +) : CrossChainValidationSystemProvider { + + override fun createValidationSystem(): AssetTransfersValidationSystem = ValidationSystem { + positiveAmount() + recipientIsNotSystemAccount() + + validAddress() + notPhishingRecipient(phishingValidationFactory) + + notDeadRecipientInCommissionAsset(assetSourceRegistry) + notDeadRecipientInUsedAsset(assetSourceRegistry) + + sufficientCommissionBalanceToStayAboveED(enoughTotalToStayAboveEDValidationFactory) + + sufficientTransferableBalanceToPayOriginFee() + canPayCrossChainFee() + + cannotDropBelowEdBeforePayingDeliveryFee(assetSourceRegistry) + + doNotCrossExistentialDepositInUsedAsset( + assetSourceRegistry = assetSourceRegistry, + extraAmount = { it.transfer.amount + it.crossChainFee?.decimalAmount.orZero() } + ) + + dryRunSucceedsValidationFactory.dryRunSucceeds() + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/DryRunSucceedsValidation.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/DryRunSucceedsValidation.kt new file mode 100644 index 0000000..92590c6 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/crosschain/validations/DryRunSucceedsValidation.kt @@ -0,0 +1,47 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.validations + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.senderAccountId +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.XcmTransferDryRunOrigin +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject +import kotlin.coroutines.coroutineContext + +@FeatureScope +class DryRunSucceedsValidationFactory @Inject constructor( + private val crossChainTransfersUseCase: CrossChainTransfersUseCase, +) { + + context(AssetTransfersValidationSystemBuilder) + fun dryRunSucceeds() { + validate(DryRunSucceedsValidation(crossChainTransfersUseCase)) + } +} + +private class DryRunSucceedsValidation( + private val crossChainTransfersUseCase: CrossChainTransfersUseCase, +) : AssetTransfersValidation { + + override suspend fun validate(value: AssetTransferPayload): ValidationStatus { + // Skip validation if it is not a cross chain transfer + val crossChainFee = value.crossChainFee ?: return valid() + + val dryRunResult = crossChainTransfersUseCase.dryRunTransferIfPossible( + transfer = value.transfer, + origin = XcmTransferDryRunOrigin.Signed(value.transfer.senderAccountId, crossChainFee.amount), + computationalScope = CoroutineScope(coroutineContext) + ) + + return dryRunResult.isSuccess isTrueOrError { + AssetTransferValidationFailure.DryRunFailed + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/EtherscanApiKeys.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/EtherscanApiKeys.kt new file mode 100644 index 0000000..1ecba17 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/EtherscanApiKeys.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan + +import io.novafoundation.nova.feature_wallet_impl.BuildConfig +import io.novafoundation.nova.runtime.ext.Ids +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class EtherscanApiKeys(private val keys: Map) { + + companion object { + + fun default(): EtherscanApiKeys { + return EtherscanApiKeys( + mapOf( + Chain.Ids.MOONBEAM to BuildConfig.EHTERSCAN_API_KEY_MOONBEAM, + Chain.Ids.MOONRIVER to BuildConfig.EHTERSCAN_API_KEY_MOONRIVER, + Chain.Ids.ETHEREUM to BuildConfig.EHTERSCAN_API_KEY_ETHEREUM + ) + ) + } + } + + fun keyFor(chainId: ChainId): String? = keys[chainId] +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/EtherscanTransactionsApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/EtherscanTransactionsApi.kt new file mode 100644 index 0000000..e8338ca --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/EtherscanTransactionsApi.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan + +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanAccountTransfer +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanNormalTxResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanResponse +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface EtherscanTransactionsApi { + + suspend fun getErc20Transfers( + chainId: ChainId, + baseUrl: String, + contractAddress: String, + accountAddress: String, + pageNumber: Int, + pageSize: Int + ): EtherscanResponse> + + suspend fun getNormalTxsHistory( + chainId: ChainId, + baseUrl: String, + accountAddress: String, + pageNumber: Int, + pageSize: Int + ): EtherscanResponse> +} + +class RealEtherscanTransactionsApi( + private val retrofitApi: RetrofitEtherscanTransactionsApi, + private val apiKeys: EtherscanApiKeys +) : EtherscanTransactionsApi { + + override suspend fun getErc20Transfers( + chainId: ChainId, + baseUrl: String, + contractAddress: String, + accountAddress: String, + pageNumber: Int, + pageSize: Int + ): EtherscanResponse> { + val apiKey = apiKeys.keyFor(chainId) + + return retrofitApi.getErc20Transfers( + baseUrl = baseUrl, + contractAddress = contractAddress, + accountAddress = accountAddress, + pageNumber = pageNumber, + pageSize = pageSize, + apiKey = apiKey + ) + } + + override suspend fun getNormalTxsHistory( + chainId: ChainId, + baseUrl: String, + accountAddress: String, + pageNumber: Int, + pageSize: Int + ): EtherscanResponse> { + val apiKey = apiKeys.keyFor(chainId) + + return retrofitApi.getNormalTxsHistory( + baseUrl = baseUrl, + accountAddress = accountAddress, + pageNumber = pageNumber, + pageSize = pageSize, + apiKey = apiKey + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/RetrofitEtherscanTransactionsApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/RetrofitEtherscanTransactionsApi.kt new file mode 100644 index 0000000..1d74370 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/RetrofitEtherscanTransactionsApi.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan + +import io.novafoundation.nova.common.data.network.UserAgent +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanAccountTransfer +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanNormalTxResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanResponse +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Query +import retrofit2.http.Url + +interface RetrofitEtherscanTransactionsApi { + + @GET + @Headers(UserAgent.NOVA) + suspend fun getErc20Transfers( + @Url baseUrl: String, + @Query("contractaddress") contractAddress: String, + @Query("address") accountAddress: String, + @Query("page") pageNumber: Int, + @Query("offset") pageSize: Int, + @Query("apikey") apiKey: String?, + @Query("module") module: String = "account", + @Query("action") action: String = "tokentx", + @Query("sort") sort: String = "desc" + ): EtherscanResponse> + + @GET + @Headers(UserAgent.NOVA) + suspend fun getNormalTxsHistory( + @Url baseUrl: String, + @Query("address") accountAddress: String, + @Query("page") pageNumber: Int, + @Query("offset") pageSize: Int, + @Query("apikey") apiKey: String?, + @Query("module") module: String = "account", + @Query("action") action: String = "txlist", + @Query("sort") sort: String = "desc" + ): EtherscanResponse> +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/EtherscanAccountTransferReponse.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/EtherscanAccountTransferReponse.kt new file mode 100644 index 0000000..b4ec702 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/EtherscanAccountTransferReponse.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model + +import java.math.BigInteger + +class EtherscanAccountTransfer( + val timeStamp: Long, + val hash: String, + val from: String, + val to: String, + val value: BigInteger, + override val gasPrice: BigInteger, + override val gasUsed: BigInteger, +) : WithEvmFee diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/EtherscanNormalTxResponse.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/EtherscanNormalTxResponse.kt new file mode 100644 index 0000000..12777bb --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/EtherscanNormalTxResponse.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model + +import com.google.gson.annotations.SerializedName +import io.novafoundation.nova.common.utils.removeHexPrefix +import java.math.BigInteger + +class EtherscanNormalTxResponse( + val timeStamp: Long, + val hash: String, + val from: String, + val to: String, + val value: BigInteger, + val input: String, + val functionName: String, + @SerializedName("txreceipt_status")val txReceiptStatus: BigInteger, + override val gasPrice: BigInteger, + override val gasUsed: BigInteger, +) : WithEvmFee + +val EtherscanNormalTxResponse.isTransfer + get() = input.removeHexPrefix().isEmpty() diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/EtherscanReponse.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/EtherscanReponse.kt new file mode 100644 index 0000000..60be5ec --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/EtherscanReponse.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model + +class EtherscanResponse( + val status: String, + val message: String, + val result: T +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/WithEvmFee.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/WithEvmFee.kt new file mode 100644 index 0000000..c0fed81 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/etherscan/model/WithEvmFee.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model + +import java.math.BigInteger + +interface WithEvmFee { + + val gasPrice: BigInteger + + val gasUsed: BigInteger +} + +val WithEvmFee.feeUsed + get() = gasUsed * gasPrice diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/model/SubqueryAssetId.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/model/SubqueryAssetId.kt new file mode 100644 index 0000000..f92352c --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/model/SubqueryAssetId.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.model + +import io.novafoundation.nova.runtime.ext.onChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun Chain.Asset.Type.subQueryAssetId(): String { + return when (this) { + is Chain.Asset.Type.Equilibrium -> id.toString() + Chain.Asset.Type.Native -> "native" + is Chain.Asset.Type.Orml -> currencyIdScale + is Chain.Asset.Type.Statemine -> id.onChainAssetId() + else -> error("Unsupported assetId type for SubQuery request: ${this::class.simpleName}") + } +} + +fun Chain.Asset.Type.isSupportedBySubQuery(): Boolean { + return when (this) { + is Chain.Asset.Type.Equilibrium, + Chain.Asset.Type.Native, + is Chain.Asset.Type.Orml, + is Chain.Asset.Type.Statemine -> true + + else -> false + } +} + +typealias AssetsBySubQueryId = Map + +fun Chain.assetsBySubQueryId(): AssetsBySubQueryId { + return assets + .filter { it.type.isSupportedBySubQuery() } + .associateBy { it.type.subQueryAssetId() } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/model/request/SubqueryHistoryRequest.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/model/request/SubqueryHistoryRequest.kt new file mode 100644 index 0000000..70685ad --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/model/request/SubqueryHistoryRequest.kt @@ -0,0 +1,197 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.model.request + +import io.novafoundation.nova.common.data.network.subquery.SubQueryFilters +import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.and +import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.anyOf +import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.not +import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.or +import io.novafoundation.nova.common.utils.nullIfEmpty +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter +import io.novafoundation.nova.feature_wallet_impl.data.network.model.subQueryAssetId +import io.novafoundation.nova.runtime.ext.StakingTypeGroup +import io.novafoundation.nova.runtime.ext.group +import io.novafoundation.nova.runtime.ext.isSwapSupported +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset + +private class ModuleRestriction( + val moduleName: String, + val restrictedCalls: List +) { + companion object +} + +private fun ModuleRestriction.Companion.ignoreSpecialOperationTypesExtrinsics() = listOf( + ModuleRestriction( + moduleName = "balances", + restrictedCalls = listOf( + "transfer", + "transferKeepAlive", + "transferAllowDeath", + "forceTransfer", + "transferAll" + ) + ), + ModuleRestriction( + moduleName = "assetConversion", + restrictedCalls = listOf( + "swapExactTokensForTokens", + "swapTokensForExactTokens", + ) + ) +) + +class SubqueryHistoryRequest( + accountAddress: String, + legacyAccountAddress: String?, + pageSize: Int = 1, + cursor: String? = null, + filters: Set, + asset: Asset, + chain: Chain, +) : SubQueryFilters { + val query = """ + { + query { + historyElements( + after: ${if (cursor == null) null else "\"$cursor\""}, + first: $pageSize, + orderBy: TIMESTAMP_DESC, + filter: { + ${addressFilter(accountAddress, legacyAccountAddress)} + ${filters.toQueryFilter(asset, chain)} + } + ) { + pageInfo { + startCursor, + endCursor + }, + nodes { + id + timestamp + extrinsicHash + blockNumber + address + ${rewardsResponseSections(asset)} + extrinsic + ${transferResponseSection(asset.type)} + ${swapResponseSection(chain)} + } + } + } + } + + """.trimIndent() + + private fun addressFilter(accountAddress: String, legacyAccountAddress: String?): String { + return if (legacyAccountAddress != null) { + """address: { in: ["$accountAddress", "$legacyAccountAddress"] }""" + } else { + """address: { equalTo: "$accountAddress" }""" + } + } + + private fun Set.toQueryFilter(asset: Asset, chain: Chain): String { + val additionalFilters = not(isIgnoredExtrinsic()) + + val filtersExpressions = mapNotNull { it.filterExpression(asset, chain) } + val userFilters = anyOf(filtersExpressions) + + return userFilters and additionalFilters + } + + private fun TransactionFilter.filterExpression(asset: Asset, chain: Chain): String? { + return when (this) { + TransactionFilter.TRANSFER -> transfersFilter(asset.type) + TransactionFilter.REWARD -> rewardsFilter(asset) + TransactionFilter.EXTRINSIC -> hasExtrinsic() + TransactionFilter.SWAP -> swapFilter(chain, asset) + }.nullIfEmpty() + } + + private fun transferResponseSection(assetType: Asset.Type): String { + return when (assetType) { + Asset.Type.Native -> "transfer" + else -> "assetTransfer" + } + } + + private fun swapResponseSection(chain: Chain): String { + return if (chain.isSwapSupported()) { + "swap" + } else { + "" + } + } + + private fun rewardsResponseSections(asset: Asset): String { + return rewardsSections(asset).joinToString(separator = "\n") + } + + private fun rewardsSections(asset: Asset): List { + return asset.staking.mapNotNull { it.rewardSection() } + } + + private fun Asset.StakingType.rewardSection(): String? { + return when (group()) { + StakingTypeGroup.RELAYCHAIN, + StakingTypeGroup.PARACHAIN, + StakingTypeGroup.MYTHOS -> "reward" + + StakingTypeGroup.NOMINATION_POOL -> "poolReward" + StakingTypeGroup.UNSUPPORTED -> null + } + } + + private fun rewardsFilter(asset: Asset): String { + return anyOf(rewardsSections(asset).map { hasType(it) }) + } + + private fun transfersFilter(assetType: Asset.Type): String { + return if (assetType == Asset.Type.Native) { + hasType("transfer") + } else { + transferAssetHasId(assetType.subQueryAssetId()) + } + } + + private fun swapFilter(chain: Chain, asset: Asset): String? { + if (!chain.isSwapSupported()) return null + + val subQueryAssetId = asset.type.subQueryAssetId() + return or( + "swap".containsFilter("assetIdIn", subQueryAssetId), + "swap".containsFilter("assetIdOut", subQueryAssetId) + ) + } + + private fun hasExtrinsic() = hasType("extrinsic") + + private fun isIgnoredExtrinsic(): String { + val exists = hasExtrinsic() + + val restrictedModulesList = ModuleRestriction.ignoreSpecialOperationTypesExtrinsics().map { + val restrictedCallsExpressions = it.restrictedCalls.map(::callNamed) + + and( + moduleNamed(it.moduleName), + anyOf(restrictedCallsExpressions) + ) + } + + val hasRestrictedModules = anyOf(restrictedModulesList) + + return and( + exists, + hasRestrictedModules + ) + } + + private fun callNamed(callName: String) = "extrinsic: {contains: {call: \"$callName\"}}" + private fun moduleNamed(moduleName: String) = "extrinsic: {contains: {module: \"$moduleName\"}}" + private fun hasType(typeName: String) = "$typeName: {isNull: false}" + + private fun transferAssetHasId(assetId: String?): String { + return "assetTransfer".containsFilter("assetId", assetId) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/model/response/SubqueryHistoryElementResponse.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/model/response/SubqueryHistoryElementResponse.kt new file mode 100644 index 0000000..ffbb7ef --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/model/response/SubqueryHistoryElementResponse.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.model.response + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import java.math.BigInteger + +class SubqueryHistoryElementResponse(val query: Query) { + class Query(val historyElements: HistoryElements) { + + class HistoryElements(val nodes: Array, val pageInfo: PageInfo) { + class PageInfo( + val startCursor: String, + val endCursor: String? + ) + + class Node( + val id: String, + val timestamp: Long, + val extrinsicHash: String?, + val address: String, + val reward: Reward?, + val blockNumber: Long, + val poolReward: PoolReward?, + val transfer: Transfer?, + val extrinsic: Extrinsic?, + val assetTransfer: AssetTransfer?, + val swap: Swap? + ) { + class Reward( + val era: Int?, + val amount: String?, + val eventIdx: Int, + val isReward: Boolean, + val validator: String?, + ) + + class PoolReward( + val amount: BigInteger, + val eventIdx: Int, + val poolId: Int, + val isReward: Boolean, + ) + + class Transfer( + val amount: BigInteger, + val to: String, + val from: String, + val fee: BigInteger, + val success: Boolean + ) + + class Extrinsic( + val module: String, + val call: String, + val fee: BigInteger, + val success: Boolean + ) + + class AssetTransfer( + val assetId: String, + val amount: BigInteger, + val to: String, + val from: String, + val fee: BigInteger, + val success: Boolean + ) + + class Swap( + val assetIdIn: String?, + val amountIn: Balance, + val assetIdOut: String?, + val amountOut: Balance, + val sender: String, + val receiver: String, + val fee: Balance, + val assetIdFee: String?, + val success: Boolean? + ) + } + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/phishing/PhishingApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/phishing/PhishingApi.kt new file mode 100644 index 0000000..140eb8d --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/phishing/PhishingApi.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.phishing + +import retrofit2.http.GET + +interface PhishingApi { + + @GET("https://polkadot.js.org/phishing/address.json") + suspend fun getPhishingAddresses(): Map> +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/subquery/SubQueryOperationsApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/subquery/SubQueryOperationsApi.kt new file mode 100644 index 0000000..cb7bd92 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/subquery/SubQueryOperationsApi.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.subquery + +import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.model.request.SubqueryHistoryRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.model.response.SubqueryHistoryElementResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url + +interface SubQueryOperationsApi { + + @POST + suspend fun getOperationsHistory( + @Url url: String, + @Body body: SubqueryHistoryRequest + ): SubQueryResponse +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/CoinPriceRepositoryImpl.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/CoinPriceRepositoryImpl.kt new file mode 100644 index 0000000..dd7584b --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/CoinPriceRepositoryImpl.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import io.novafoundation.nova.common.utils.binarySearchFloor +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.source.CoinPriceLocalDataSource +import io.novafoundation.nova.feature_wallet_api.data.source.CoinPriceRemoteDataSource +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate +import kotlin.time.Duration + +class CoinPriceRepositoryImpl( + private val cacheCoinPriceDataSource: CoinPriceLocalDataSource, + private val remoteCoinPriceDataSource: CoinPriceRemoteDataSource +) : CoinPriceRepository { + + override suspend fun getCoinPriceAtTime(priceId: String, currency: Currency, timestamp: Duration): HistoricalCoinRate? { + val timestampInSeconds = timestamp.inWholeSeconds + var coinRate = cacheCoinPriceDataSource.getFloorCoinPriceAtTime(priceId, currency, timestampInSeconds) + val hasCeilingItem = cacheCoinPriceDataSource.hasCeilingCoinPriceAtTime(priceId, currency, timestampInSeconds) + if (coinRate == null && !hasCeilingItem) { + val coinRateForAllTime = getLastHistoryForPeriod(priceId, currency, PricePeriod.MAX) + val index = coinRateForAllTime.binarySearchFloor { it.timestamp.compareTo(timestampInSeconds) } + coinRate = coinRateForAllTime.getOrNull(index) + + // If nearest coin rate timestamp is bigger than target timestamp it means that coingecko doesn't have data before coin rate timestamp + // so in this case we should return null + if (coinRate != null && coinRate.timestamp > timestampInSeconds) { + return null + } + } + + return coinRate + } + + override suspend fun getLastHistoryForPeriod(priceId: String, currency: Currency, range: PricePeriod): List { + return remoteCoinPriceDataSource.getLastCoinPriceRange(priceId, currency, range) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealBalanceHoldsRepository.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealBalanceHoldsRepository.kt new file mode 100644 index 0000000..d9d32dd --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealBalanceHoldsRepository.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.common.utils.getOrNull +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.HoldsDao +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold +import io.novafoundation.nova.feature_wallet_api.domain.model.mapBalanceHoldFromLocal +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Vec +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RealBalanceHoldsRepository( + private val chainRegistry: ChainRegistry, + private val holdsDao: HoldsDao, +) : BalanceHoldsRepository { + + override suspend fun chainHasHoldId(chainId: ChainId, holdId: BalanceHold.HoldId): Boolean { + return runCatching { + val holdReasonType = getHoldReasonType(chainId) ?: return false + holdReasonType.hasHoldId(holdId).also { + Log.d(LOG_TAG, "chainHasHoldId for $chainId: $it") + } + } + .onFailure { Log.w(LOG_TAG, "Failed to get hold reason type", it) } + .getOrDefault(false) + } + + override suspend fun observeBalanceHolds(metaInt: Long, chainAsset: Chain.Asset): Flow> { + return holdsDao.observeBalanceHolds(metaInt, chainAsset.chainId, chainAsset.id).mapList { hold -> + mapBalanceHoldFromLocal(chainAsset, hold) + } + } + + override fun observeHoldsForMetaAccount(metaInt: Long): Flow> { + return holdsDao.observeHoldsForMetaAccount(metaInt).map { holds -> + val chainsById = chainRegistry.chainsById() + holds.mapNotNull { holdLocal -> + val asset = chainsById[holdLocal.chainId]?.assetsById?.get(holdLocal.assetId) ?: return@mapNotNull null + + mapBalanceHoldFromLocal(asset, holdLocal) + } + } + } + + private fun DictEnum.hasHoldId(holdId: BalanceHold.HoldId): Boolean { + val moduleReasons = getOrNull(holdId.module) as? DictEnum ?: return false + return moduleReasons[holdId.reason] != null + } + + private suspend fun getHoldReasonType(chainId: ChainId): DictEnum? { + val runtime = chainRegistry.getRuntime(chainId) + + val storage = runtime.metadata.balances().storageOrNull("Holds") ?: return null + val storageReturnType = storage.type.value as Vec + + return storageReturnType + .innerType()!! + .get("id") + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealBalanceLocksRepository.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealBalanceLocksRepository.kt new file mode 100644 index 0000000..73bc8c6 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealBalanceLocksRepository.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock +import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId +import io.novafoundation.nova.feature_wallet_api.domain.model.mapBalanceLockFromLocal +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +class RealBalanceLocksRepository( + // TODO refactoring - repository should not depend on other repository. MetaId should be passed to repository arguments + private val accountRepository: AccountRepository, + private val chainRegistry: ChainRegistry, + private val lockDao: LockDao +) : BalanceLocksRepository { + + override fun observeBalanceLocks(metaId: Long, chain: Chain, chainAsset: Chain.Asset): Flow> { + return lockDao.observeBalanceLocks(metaId, chain.id, chainAsset.id) + .mapList { lock -> mapBalanceLockFromLocal(chainAsset, lock) } + } + + override suspend fun getBalanceLocks(metaId: Long, chainAsset: Chain.Asset): List { + return lockDao.getBalanceLocks(metaId, chainAsset.chainId, chainAsset.id) + .map { lock -> mapBalanceLockFromLocal(chainAsset, lock) } + } + + override suspend fun getBiggestLock(chain: Chain, chainAsset: Chain.Asset): BalanceLock? { + val metaAccount = accountRepository.getSelectedMetaAccount() + + return lockDao.getBiggestBalanceLock(metaAccount.id, chain.id, chainAsset.id)?.let { + mapBalanceLockFromLocal(chainAsset, it) + } + } + override suspend fun observeBalanceLock(chainAsset: Chain.Asset, lockId: BalanceLockId): Flow { + val metaAccount = accountRepository.getSelectedMetaAccount() + + return lockDao.observeBalanceLock(metaAccount.id, chainAsset.chainId, chainAsset.id, lockId.value).map { lockLocal -> + lockLocal?.let { mapBalanceLockFromLocal(chainAsset, it) } + } + } + + override fun observeLocksForMetaAccount(metaAccount: MetaAccount): Flow> { + return combine(lockDao.observeLocksForMetaAccount(metaAccount.id), chainRegistry.chainsById) { locks, chains -> + locks.map { + val asset = chains.getValue(it.chainId) + .assetsById.getValue(it.assetId) + mapBalanceLockFromLocal(asset, it) + } + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealChainAssetRepository.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealChainAssetRepository.kt new file mode 100644 index 0000000..51fdb23 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealChainAssetRepository.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import com.google.gson.Gson +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.SetAssetEnabledParams +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainAssetLocalToAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainAssetToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +class RealChainAssetRepository( + private val chainAssetDao: ChainAssetDao, + private val gson: Gson +) : ChainAssetRepository { + + override suspend fun setAssetsEnabled(enabled: Boolean, assetIds: List) { + val updateParams = assetIds.map { SetAssetEnabledParams(enabled, it.chainId, it.assetId) } + + chainAssetDao.setAssetsEnabled(updateParams) + } + + override suspend fun insertCustomAsset(chainAsset: Chain.Asset) { + val localAsset = mapChainAssetToLocal(chainAsset, gson) + chainAssetDao.insertAsset(localAsset) + } + + override suspend fun getEnabledAssets(): List { + return chainAssetDao.getEnabledAssets().map { mapChainAssetLocalToAsset(it, gson) } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealCrossChainTransfersRepository.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealCrossChainTransfersRepository.kt new file mode 100644 index 0000000..69d699b --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealCrossChainTransfersRepository.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import com.google.gson.Gson +import io.novafoundation.nova.common.interfaces.FileCache +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.common.utils.retryUntilDone +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransfersConfiguration +import io.novafoundation.nova.feature_wallet_impl.data.mappers.crosschain.toDomain +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.CrossChainConfigApi +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainTransfersConfigRemote +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainTransfersConfigRemote +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.zip +import kotlinx.coroutines.withContext + +private const val LEGACY_CACHE_NAME = "RealCrossChainTransfersRepository.CrossChainConfig" +private const val DYNAMIC_CACHE_NAME = "RealCrossChainTransfersRepository.DynamicCrossChainConfig" + +class RealCrossChainTransfersRepository( + private val api: CrossChainConfigApi, + private val fileCache: FileCache, + private val gson: Gson, + private val parachainInfoRepository: ParachainInfoRepository, +) : CrossChainTransfersRepository { + + override suspend fun syncConfiguration() = withContext(Dispatchers.IO) { + val legacy = syncConfiguration(LEGACY_CACHE_NAME) { api.getLegacyCrossChainConfig() } + val dynamic = syncConfiguration(DYNAMIC_CACHE_NAME) { api.getDynamicCrossChainConfig() } + + legacy.await() + dynamic.await() + } + + override fun configurationFlow(): Flow { + val legacyFlow = fileCache.observeCachedValue(LEGACY_CACHE_NAME).map { + val remote = gson.fromJson(it) + remote.toDomain(parachainInfoRepository) + } + + val dynamicFlow = fileCache.observeCachedValue(DYNAMIC_CACHE_NAME).map { + val remote = gson.fromJson(it) + remote.toDomain(parachainInfoRepository) + } + + return dynamicFlow.zip(legacyFlow, ::CrossChainTransfersConfiguration) + } + + override suspend fun getConfiguration(): CrossChainTransfersConfiguration { + return withContext(Dispatchers.Default) { + configurationFlow().first() + } + } + + private fun CoroutineScope.syncConfiguration(cacheFileName: String, load: suspend () -> String): Deferred { + return async { + val raw = retryUntilDone { load() } + fileCache.updateCache(cacheFileName, raw) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealExternalBalanceRepository.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealExternalBalanceRepository.kt new file mode 100644 index 0000000..85942d1 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealExternalBalanceRepository.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core_db.dao.ExternalBalanceAssetDeleteParams +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.model.AggregatedExternalBalanceLocal +import io.novafoundation.nova.core_db.model.ExternalBalanceLocal +import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.flow.Flow + +internal class RealExternalBalanceRepository( + private val externalBalanceDao: ExternalBalanceDao, +) : ExternalBalanceRepository { + + override fun observeAccountExternalBalances(metaId: Long): Flow> { + return externalBalanceDao.observeAggregatedExternalBalances(metaId).mapList(::mapExternalBalanceFromLocal) + } + + override fun observeAccountChainExternalBalances(metaId: Long, assetId: FullChainAssetId): Flow> { + return externalBalanceDao.observeChainAggregatedExternalBalances(metaId, assetId.chainId, assetId.assetId) + .mapList(::mapExternalBalanceFromLocal) + } + + override suspend fun deleteExternalBalances(assetIds: List) { + val params = assetIds.map { ExternalBalanceAssetDeleteParams(it.chainId, it.assetId) } + + return externalBalanceDao.deleteAssetExternalBalances(params) + } + + private fun mapExternalBalanceFromLocal(externalBalance: AggregatedExternalBalanceLocal): ExternalBalance { + return ExternalBalance( + chainAssetId = FullChainAssetId(externalBalance.chainId, externalBalance.assetId), + amount = externalBalance.aggregatedAmount, + type = mapExternalBalanceTypeFromLocal(externalBalance.type) + ) + } + + private fun mapExternalBalanceTypeFromLocal(local: ExternalBalanceLocal.Type): ExternalBalance.Type { + return when (local) { + ExternalBalanceLocal.Type.CROWDLOAN -> ExternalBalance.Type.CROWDLOAN + ExternalBalanceLocal.Type.NOMINATION_POOL -> ExternalBalance.Type.NOMINATION_POOL + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealStatemineAssetsRepository.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealStatemineAssetsRepository.kt new file mode 100644 index 0000000..77df798 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RealStatemineAssetsRepository.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.StatemineAssetDetails +import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.api.asset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.api.assets +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ext.palletNameOrDefault +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.prepareIdForEncoding +import io.novafoundation.nova.runtime.storage.cache.StorageCachingContext +import io.novafoundation.nova.runtime.storage.cache.cacheValues +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import javax.inject.Inject +import javax.inject.Named + +@FeatureScope +class RealStatemineAssetsRepository @Inject constructor( + @Named(LOCAL_STORAGE_SOURCE) + private val localStorageSource: StorageDataSource, + + @Named(REMOTE_STORAGE_SOURCE) + private val remoteStorageSource: StorageDataSource, + + override val storageCache: StorageCache, +) : StatemineAssetsRepository, + StorageCachingContext by StorageCachingContext(storageCache) { + + override suspend fun getAssetDetails(chainId: ChainId, assetType: Chain.Asset.Type.Statemine): StatemineAssetDetails { + return localStorageSource.query(chainId) { + val encodableAssetId = assetType.prepareIdForEncoding(runtime) + metadata.assets(assetType.palletNameOrDefault()).asset.queryNonNull(encodableAssetId) + } + } + + override suspend fun subscribeAndSyncAssetDetails( + chainId: ChainId, + assetType: Chain.Asset.Type.Statemine, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + return remoteStorageSource.subscribe(chainId, subscriptionBuilder) { + val encodableAssetId = assetType.prepareIdForEncoding(runtime) + + metadata.assets(assetType.palletNameOrDefault()).asset.observeWithRaw(encodableAssetId) + .cacheValues() + .filterNotNull() + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RuntimeWalletConstants.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RuntimeWalletConstants.kt new file mode 100644 index 0000000..e03e21a --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/RuntimeWalletConstants.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import java.math.BigInteger + +class RuntimeWalletConstants( + private val chainRegistry: ChainRegistry +) : WalletConstants { + + override suspend fun existentialDeposit(chainId: ChainId): BigInteger { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.balances().numberConstant("ExistentialDeposit", runtime) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/TokenRepositoryImpl.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/TokenRepositoryImpl.kt new file mode 100644 index 0000000..e4b2c8c --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/TokenRepositoryImpl.kt @@ -0,0 +1,80 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.core_db.dao.TokenDao +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_impl.data.mappers.mapTokenLocalToToken +import io.novafoundation.nova.feature_wallet_impl.data.mappers.mapTokenWithCurrencyToToken +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class TokenRepositoryImpl( + private val tokenDao: TokenDao +) : TokenRepository { + + override suspend fun observeTokens(chainAssets: List): Flow> { + if (chainAssets.isEmpty()) return flowOf(emptyMap()) + + val symbols = chainAssets.map { it.symbol.value }.distinct() + + return tokenDao.observeTokensWithCurrency(symbols).map { tokens -> + val tokensBySymbol = tokens.associateBy { it.token?.tokenSymbol } + val currency = tokens.first().currency + + chainAssets.associateBy( + keySelector = { chainAsset -> chainAsset.fullId }, + valueTransform = { chainAsset -> + mapTokenLocalToToken( + tokenLocal = tokensBySymbol[chainAsset.symbol.value]?.token, + currencyLocal = currency, + chainAsset = chainAsset + ) + } + ) + } + } + + override suspend fun getTokens(chainAssets: List): Map { + if (chainAssets.isEmpty()) return emptyMap() + + val symbols = chainAssets.mapToSet { it.symbol.value }.toList() + + val tokens = tokenDao.getTokensWithCurrency(symbols) + + val tokensBySymbol = tokens.associateBy { it.token?.tokenSymbol } + val currency = tokens.first().currency + + return chainAssets.associateBy( + keySelector = { chainAsset -> chainAsset.fullId }, + valueTransform = { chainAsset -> + mapTokenLocalToToken( + tokenLocal = tokensBySymbol[chainAsset.symbol.value]?.token, + currencyLocal = currency, + chainAsset = chainAsset + ) + } + ) + } + + override suspend fun getToken(chainAsset: Chain.Asset): Token = getTokenOrNull(chainAsset)!! + + override suspend fun getTokenOrNull(chainAsset: Chain.Asset): Token? = withContext(Dispatchers.Default) { + val tokenLocal = tokenDao.getTokenWithCurrency(chainAsset.symbol.value) + + tokenLocal?.let { mapTokenWithCurrencyToToken(tokenLocal, chainAsset) } + } + + override fun observeToken(chainAsset: Chain.Asset): Flow { + return tokenDao.observeTokenWithCurrency(chainAsset.symbol.value) + .map { + mapTokenWithCurrencyToToken(it, chainAsset) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/WalletRepositoryImpl.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/WalletRepositoryImpl.kt new file mode 100644 index 0000000..dc830ce --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/repository/WalletRepositoryImpl.kt @@ -0,0 +1,248 @@ +package io.novafoundation.nova.feature_wallet_impl.data.repository + +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.core_db.dao.PhishingAddressDao +import io.novafoundation.nova.core_db.model.AssetAndChainId +import io.novafoundation.nova.core_db.model.PhishingAddressLocal +import io.novafoundation.nova.core_db.model.TokenLocal +import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal +import io.novafoundation.nova.core_db.model.operation.OperationLocal +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.findMetaAccountOrThrow +import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.source.CoinPriceRemoteDataSource +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRateChange +import io.novafoundation.nova.feature_wallet_impl.data.mappers.mapAssetLocalToAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.phishing.PhishingApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class WalletRepositoryImpl( + private val operationDao: OperationDao, + private val phishingApi: PhishingApi, + private val accountRepository: AccountRepository, + private val assetCache: AssetCache, + private val phishingAddressDao: PhishingAddressDao, + private val coinPriceRemoteDataSource: CoinPriceRemoteDataSource, + private val chainRegistry: ChainRegistry, +) : WalletRepository { + + override fun syncedAssetsFlow(metaId: Long): Flow> { + return combine( + chainRegistry.chainsById, + assetCache.observeSyncedAssets(metaId) + ) { chainsById, assetsLocal -> + assetsLocal.mapNotNull { asset -> + chainsById.chainAsset(asset.assetAndChainId)?.let { mapAssetLocalToAsset(asset, it) } + } + } + } + + override suspend fun getSyncedAssets(metaId: Long): List = withContext(Dispatchers.Default) { + val chainsById = chainRegistry.chainsById.first() + val assetsLocal = assetCache.getSyncedAssets(metaId) + + assetsLocal.mapNotNull { asset -> + chainsById.chainAsset(asset.assetAndChainId)?.let { mapAssetLocalToAsset(asset, it) } + } + } + + override suspend fun getSupportedAssets(metaId: Long): List = withContext(Dispatchers.Default) { + val chainsById = chainRegistry.chainsById.first() + val assetsLocal = assetCache.getSupportedAssets(metaId) + + assetsLocal.mapNotNull { asset -> + chainsById.chainAsset(asset.assetAndChainId)?.let { mapAssetLocalToAsset(asset, it) } + } + } + + override fun supportedAssetsFlow(metaId: Long, chainAssets: List): Flow> = flowOfAll { + val chainAssetsById = chainAssets.associateBy { AssetAndChainId(it.chainId, it.id) } + + assetCache.observeSupportedAssets(metaId).map { supportedAssets -> + supportedAssets.mapNotNull { assetWithToken -> + val chainAsset = chainAssetsById[assetWithToken.assetAndChainId] ?: return@mapNotNull null + + mapAssetLocalToAsset(assetWithToken, chainAsset) + } + }.distinctUntilChanged() + } + + override suspend fun syncAssetsRates(currency: Currency) { + val chains = chainRegistry.currentChains.first() + + val syncingPriceIdsToSymbols = chains.flatMap(Chain::assets) + .filter { it.priceId != null } + .groupBy( + keySelector = { it.priceId!! }, + valueTransform = { it.symbol } + ) + + if (syncingPriceIdsToSymbols.isNotEmpty()) { + val coinPriceChanges = getAssetPrices(syncingPriceIdsToSymbols.keys, currency) + + val newTokens = coinPriceChanges.flatMap { (priceId, coinPriceChange) -> + syncingPriceIdsToSymbols[priceId]?.let { symbols -> + symbols.map { symbol -> + TokenLocal(symbol.value, coinPriceChange?.rate, currency.id, coinPriceChange?.recentRateChange) + } + } ?: emptyList() + } + + assetCache.updateTokens(newTokens) + } else { + assetCache.deleteAllTokens() + } + } + + override suspend fun syncAssetRates(asset: Chain.Asset, currency: Currency) { + val priceId = asset.priceId ?: return + + val coinPriceChange = getAssetPrice(priceId, currency) + + val token = TokenLocal(asset.symbol.value, coinPriceChange?.rate, currency.id, coinPriceChange?.recentRateChange) + + assetCache.insertToken(token) + } + + override fun assetFlow(accountId: AccountId, chainAsset: Chain.Asset): Flow { + return flow { + val metaAccount = accountRepository.findMetaAccountOrThrow(accountId, chainAsset.chainId) + + emitAll(assetFlow(metaAccount.id, chainAsset)) + } + } + + override fun assetFlowOrNull(metaId: Long, chainAsset: Chain.Asset): Flow { + return assetCache.observeAssetOrNull(metaId, chainAsset.chainId, chainAsset.id) + .map { assetLocal -> assetLocal?.let { mapAssetLocalToAsset(assetLocal, chainAsset) } } + .distinctUntilChanged() + } + + override fun assetFlow(metaId: Long, chainAsset: Chain.Asset): Flow { + return assetCache.observeAsset(metaId, chainAsset.chainId, chainAsset.id) + .map { mapAssetLocalToAsset(it, chainAsset) } + .distinctUntilChanged() + } + + override fun assetsFlow(metaId: Long, chainAssets: List): Flow> = flowOfAll { + val chainAssetsById = chainAssets.associateBy { AssetAndChainId(it.chainId, it.id) } + + assetCache.observeAssets(metaId, chainAssetsById.keys).map { dbAssets -> + dbAssets.mapNotNull { assetWithToken -> + val chainAsset = chainAssetsById[assetWithToken.assetAndChainId] ?: return@mapNotNull null + + mapAssetLocalToAsset(assetWithToken, chainAsset) + } + }.distinctUntilChanged() + } + + override suspend fun getAsset(accountId: AccountId, chainAsset: Chain.Asset): Asset? { + val assetLocal = getAsset(accountId, chainAsset.chainId, chainAsset.id) + + return assetLocal?.let { mapAssetLocalToAsset(it, chainAsset) } + } + + override suspend fun getAsset(metaId: Long, chainAsset: Chain.Asset): Asset? { + val assetLocal = assetCache.getAssetWithToken(metaId, chainAsset.chainId, chainAsset.id) + + return assetLocal?.let { mapAssetLocalToAsset(it, chainAsset) } + } + + override suspend fun insertPendingTransfer( + hash: String, + assetTransfer: AssetTransfer, + fee: SubmissionFee + ) { + val operation = createAppOperation( + hash = hash, + transfer = assetTransfer, + fee = fee, + ) + + operationDao.insert(operation) + } + + override suspend fun clearAssets(assetIds: List) { + assetCache.clearAssets(assetIds) + } + + // TODO adapt for ethereum chains + override suspend fun updatePhishingAddresses() = withContext(Dispatchers.Default) { + val accountIds = phishingApi.getPhishingAddresses().values.flatten() + .map { it.toAccountId().toHexString(withPrefix = true) } + + val phishingAddressesLocal = accountIds.map(::PhishingAddressLocal) + + phishingAddressDao.clearTable() + phishingAddressDao.insert(phishingAddressesLocal) + } + + // TODO adapt for ethereum chains + override suspend fun isAccountIdFromPhishingList(accountId: AccountId) = withContext(Dispatchers.Default) { + val phishingAddresses = phishingAddressDao.getAllAddresses() + + phishingAddresses.contains(accountId.toHexString(withPrefix = true)) + } + + private fun createAppOperation( + hash: String, + transfer: AssetTransfer, + fee: SubmissionFee, + ): OperationLocal { + val senderAddress = transfer.sender.requireAddressIn(transfer.originChain) + + return OperationLocal.manualTransfer( + hash = hash, + address = senderAddress, + chainAssetId = transfer.originChainAsset.id, + chainId = transfer.originChainAsset.chainId, + amount = transfer.amountInPlanks, + senderAddress = senderAddress, + receiverAddress = transfer.recipient, + fee = fee.amount, + status = OperationBaseLocal.Status.PENDING, + source = OperationBaseLocal.Source.APP + ) + } + + private suspend fun getAssetPrices(priceIds: Set, currency: Currency): Map { + return coinPriceRemoteDataSource.getCoinRates(priceIds, currency) + } + + private suspend fun getAssetPrice(priceId: String, currency: Currency): CoinRateChange? { + return coinPriceRemoteDataSource.getCoinRate(priceId, currency) + } + + private suspend fun getAsset(accountId: AccountId, chainId: String, assetId: Int) = withContext(Dispatchers.Default) { + val metaAccount = accountRepository.findMetaAccountOrThrow(accountId, chainId) + + assetCache.getAssetWithToken(metaAccount.id, chainId, assetId) + } + + private fun Map.chainAsset(ids: AssetAndChainId): Chain.Asset? { + return get(ids.chainId)?.assetsById?.get(ids.assetId) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/source/RealCoinPriceDataSource.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/source/RealCoinPriceDataSource.kt new file mode 100644 index 0000000..e624102 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/source/RealCoinPriceDataSource.kt @@ -0,0 +1,109 @@ +package io.novafoundation.nova.feature_wallet_impl.data.source + +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.utils.KeyMutex +import io.novafoundation.nova.common.utils.asQueryParam +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.feature_currency_api.domain.model.Currency +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.CoingeckoApi +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.ProxyPriceApi +import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod +import io.novafoundation.nova.feature_wallet_api.data.source.CoinPriceRemoteDataSource +import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRateChange +import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate +import java.math.BigDecimal +import kotlin.time.Duration.Companion.milliseconds + +private const val PRICE_ID_HEZ = "hezkurd" +private const val PRICE_ID_PEZ = "pezkuwi" +private const val PRICE_ID_DOT = "polkadot" +private val HEZ_DOT_DIVISOR = BigDecimal(3) +private val PEZ_DOT_DIVISOR = BigDecimal(10) + +class RealCoinPriceDataSource( + private val priceApi: ProxyPriceApi, + private val coingeckoApi: CoingeckoApi, + private val httpExceptionHandler: HttpExceptionHandler +) : CoinPriceRemoteDataSource { + + private val mutex = KeyMutex() + + override suspend fun getLastCoinPriceRange(priceId: String, currency: Currency, range: PricePeriod): List { + val key = "${priceId}_${currency.id}_$range" + val response = mutex.withKeyLock(key) { + val days = mapRangeToDays(range) + + priceApi.getLastCoinRange(priceId, currency.coingeckoId, days) + } + + return response.prices.map { (timestampRaw, rateRaw) -> + HistoricalCoinRate( + timestamp = timestampRaw.toLong().milliseconds.inWholeSeconds, + rate = rateRaw + ) + } + } + + override suspend fun getCoinRates(priceIds: Set, currency: Currency): Map { + // Ensure DOT is included for fallback calculation if HEZ or PEZ is requested + val needsFallback = priceIds.contains(PRICE_ID_HEZ) || priceIds.contains(PRICE_ID_PEZ) + val allPriceIds = if (needsFallback) priceIds + PRICE_ID_DOT else priceIds + + val sortedPriceIds = allPriceIds.toList().sorted() + val rawRates = apiCall { coingeckoApi.getAssetPrice(sortedPriceIds.asQueryParam(), currency = currency.coingeckoId, includeRateChange = true) } + + val rates = rawRates.mapValues { + val price = it.value[currency.coingeckoId].orZero() + val recentRate = it.value[CoingeckoApi.getRecentRateFieldName(currency.coingeckoId)].orZero() + CoinRateChange( + recentRate.toBigDecimal(), + price.toBigDecimal() + ) + }.toMutableMap() + + // Apply fallback pricing for HEZ and PEZ if their prices are zero or missing + val dotRate = rates[PRICE_ID_DOT] + if (dotRate != null && dotRate.rate > BigDecimal.ZERO) { + // HEZ fallback: 1 HEZ = DOT / 3 + if (priceIds.contains(PRICE_ID_HEZ)) { + val hezRate = rates[PRICE_ID_HEZ] + if (hezRate == null || hezRate.rate <= BigDecimal.ZERO) { + rates[PRICE_ID_HEZ] = CoinRateChange( + recentRateChange = dotRate.recentRateChange, + rate = dotRate.rate.divide(HEZ_DOT_DIVISOR, 10, java.math.RoundingMode.HALF_UP) + ) + } + } + + // PEZ fallback: 1 PEZ = DOT / 10 + if (priceIds.contains(PRICE_ID_PEZ)) { + val pezRate = rates[PRICE_ID_PEZ] + if (pezRate == null || pezRate.rate <= BigDecimal.ZERO) { + rates[PRICE_ID_PEZ] = CoinRateChange( + recentRateChange = dotRate.recentRateChange, + rate = dotRate.rate.divide(PEZ_DOT_DIVISOR, 10, java.math.RoundingMode.HALF_UP) + ) + } + } + } + + // Return only requested priceIds + return rates.filterKeys { it in priceIds } + } + + override suspend fun getCoinRate(priceId: String, currency: Currency): CoinRateChange? { + return getCoinRates(priceIds = setOf(priceId), currency = currency) + .values + .firstOrNull() + } + + private suspend fun apiCall(block: suspend () -> T): T = httpExceptionHandler.wrap(block) + + private fun mapRangeToDays(range: PricePeriod) = when (range) { + PricePeriod.DAY -> "1" + PricePeriod.WEEK -> "7" + PricePeriod.MONTH -> "30" + PricePeriod.YEAR -> "365" + PricePeriod.MAX -> "max" + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/storage/TransferCursorStorage.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/storage/TransferCursorStorage.kt new file mode 100644 index 0000000..9123567 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/storage/TransferCursorStorage.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_wallet_impl.data.storage + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private const val TRANSACTIONS_CURSOR_KEY = "TRANSACTIONS_CURSOR_KEY" + +// to distinguish between no cursor and null cursor introduce a separate value for `null` cursor. +// Null value in preferences will correspond to `no cursor` state +private const val NULL_CURSOR = "NULL_CURSOR" + +class TransferCursorStorage( + private val preferences: Preferences, +) { + + fun saveCursor( + chainId: ChainId, + chainAssetId: Int, + accountId: AccountId, + cursor: String?, + ) { + val toSave = cursor ?: NULL_CURSOR + + preferences.putString(cursorKey(chainId, chainAssetId, accountId), toSave) + } + fun hasCursor( + chainId: ChainId, + chainAssetId: Int, + accountId: AccountId, + ): Boolean { + return preferences.contains(cursorKey(chainId, chainAssetId, accountId)) + } + + suspend fun awaitCursor( + chainId: ChainId, + chainAssetId: Int, + accountId: AccountId, + ) = preferences.stringFlow(cursorKey(chainId, chainAssetId, accountId)) + .filterNotNull() // suspends until cursor is inserted + .map { + if (it == NULL_CURSOR) { + null + } else { + it + } + }.first() + + private fun cursorKey(chainId: String, chainAssetId: Int, accountId: AccountId): String { + return "$TRANSACTIONS_CURSOR_KEY:${accountId.toHexString()}:$chainId:$chainAssetId" + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureComponent.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureComponent.kt new file mode 100644 index 0000000..b2931f9 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureComponent.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.feature_wallet_impl.di + +import dagger.BindsInstance +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi +import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi +import io.novafoundation.nova.feature_wallet_impl.di.modules.AssetsModule +import io.novafoundation.nova.feature_wallet_impl.di.modules.BalanceLocksModule +import io.novafoundation.nova.feature_wallet_impl.di.modules.ValidationsModule +import io.novafoundation.nova.feature_wallet_impl.di.modules.WalletBindsModule +import io.novafoundation.nova.feature_wallet_impl.presentation.WalletRouter +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + WalletFeatureDependencies::class + ], + modules = [ + WalletFeatureModule::class, + WalletBindsModule::class, + ValidationsModule::class, + AssetsModule::class, + BalanceLocksModule::class, + ] +) +@FeatureScope +interface WalletFeatureComponent : WalletFeatureApi { + + @Component.Factory + interface Factory { + + fun create( + @BindsInstance walletRouter: WalletRouter, + deps: WalletFeatureDependencies + ): WalletFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + RuntimeApi::class, + AccountFeatureApi::class, + CurrencyFeatureApi::class, + SwapCoreApi::class, + XcmFeatureApi::class + ] + ) + interface WalletFeatureDependenciesComponent : WalletFeatureDependencies +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt new file mode 100644 index 0000000..dd10279 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt @@ -0,0 +1,196 @@ +package io.novafoundation.nova.feature_wallet_impl.di + +import android.content.ContentResolver +import coil.ImageLoader +import com.google.gson.Gson +import io.novafoundation.nova.common.address.AddressIconGenerator +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.AppLinksProvider +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences +import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase +import io.novafoundation.nova.common.interfaces.FileCache +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ClipboardManager +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.QrCodeGenerator +import io.novafoundation.nova.common.validation.ValidationExecutor +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.CoinPriceDao +import io.novafoundation.nova.core_db.dao.ContributionDao +import io.novafoundation.nova.core_db.dao.CurrencyDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.dao.HoldsDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.core_db.dao.PhishingAddressDao +import io.novafoundation.nova.core_db.dao.TokenDao +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_account_api.presenatation.account.AddressDisplayUseCase +import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions +import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.DryRunApi +import io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment.XcmPaymentApi +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.encrypt.Signer +import io.novasama.substrate_sdk_android.icon.IconGenerator +import io.novasama.substrate_sdk_android.wsrpc.logging.Logger +import javax.inject.Named + +interface WalletFeatureDependencies { + + val maskingModeUseCase: MaskingModeUseCase + + val fileCache: FileCache + + val storageCache: StorageCache + + val evmTransactionService: EvmTransactionService + + val chainAssetDao: ChainAssetDao + + val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory + + val currencyRepository: CurrencyRepository + + val externalBalanceDao: ExternalBalanceDao + + val computationalCache: ComputationalCache + + val multiLocationConverterFactory: io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory + + val extrinsicWalk: ExtrinsicWalk + + val holdsDao: HoldsDao + + val feePaymentProviderRegistry: FeePaymentProviderRegistry + + val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory + + val customFeeCapabilityFacade: CustomFeeCapabilityFacade + + val assetIconProvider: AssetIconProvider + + val parachainInfoRepository: ParachainInfoRepository + + val chainStateRepository: ChainStateRepository + + val xcmVersionDetector: XcmVersionDetector + + val dryRunApi: DryRunApi + + val xcmPaymentApi: XcmPaymentApi + + val xcmBuilderFactory: XcmBuilder.Factory + + val multisigValidationsRepository: MultisigValidationsRepository + + val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi + + fun preferences(): Preferences + + fun encryptedPreferences(): EncryptedPreferences + + fun resourceManager(): ResourceManager + + fun iconGenerator(): IconGenerator + + fun clipboardManager(): ClipboardManager + + fun contentResolver(): ContentResolver + + fun accountRepository(): AccountRepository + + fun assetsDao(): AssetDao + + fun tokenDao(): TokenDao + + fun provideLocksDao(): LockDao + + fun operationDao(): OperationDao + + fun currencyDao(): CurrencyDao + + fun contributionDao(): ContributionDao + + fun networkCreator(): NetworkApiCreator + + fun signer(): Signer + + fun logger(): Logger + + fun jsonMapper(): Gson + + fun addressIconGenerator(): AddressIconGenerator + + fun appLinksProvider(): AppLinksProvider + + fun qrCodeGenerator(): QrCodeGenerator + + fun fileProvider(): FileProvider + + fun externalAccountActions(): ExternalActions.Presentation + + fun httpExceptionHandler(): HttpExceptionHandler + + fun phishingAddressesDao(): PhishingAddressDao + + fun rpcCalls(): RpcCalls + + fun accountUpdateScope(): AccountUpdateScope + + fun addressDisplayUseCase(): AddressDisplayUseCase + + fun chainRegistry(): ChainRegistry + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource + + @Named(LOCAL_STORAGE_SOURCE) + fun localStorageSource(): StorageDataSource + + fun extrinsicService(): ExtrinsicService + + fun extrinsicServiceFactory(): ExtrinsicService.Factory + + fun imageLoader(): ImageLoader + + fun selectedAccountUseCase(): SelectedAccountUseCase + + fun validationExecutor(): ValidationExecutor + + fun eventsRepository(): EventsRepository + + fun coinPriceDao(): CoinPriceDao + + fun hydraDxAssetIdConverter(): HydraDxAssetIdConverter + + fun assetsIconModeService(): AssetsIconModeRepository +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureHolder.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureHolder.kt new file mode 100644 index 0000000..35a1467 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureHolder.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.feature_wallet_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi +import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi +import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi +import io.novafoundation.nova.feature_wallet_impl.presentation.WalletRouter +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class WalletFeatureHolder @Inject constructor( + private val walletRouter: WalletRouter, + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dependencies = DaggerWalletFeatureComponent_WalletFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .swapCoreApi(getFeature(SwapCoreApi::class.java)) + .accountFeatureApi(getFeature(AccountFeatureApi::class.java)) + .currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java)) + .xcmFeatureApi(getFeature(XcmFeatureApi::class.java)) + .build() + return DaggerWalletFeatureComponent.factory() + .create(walletRouter, dependencies) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureModule.kt new file mode 100644 index 0000000..52dec6a --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureModule.kt @@ -0,0 +1,521 @@ +package io.novafoundation.nova.feature_wallet_impl.di + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.data.network.HttpExceptionHandler +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.interfaces.FileCache +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.CoinPriceDao +import io.novafoundation.nova.core_db.dao.ExternalBalanceDao +import io.novafoundation.nova.core_db.dao.HoldsDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.core_db.dao.OperationDao +import io.novafoundation.nova.core_db.dao.PhishingAddressDao +import io.novafoundation.nova.core_db.dao.TokenDao +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.CoinPriceLocalDataSourceImpl +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainWeigher +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.CoingeckoApi +import io.novafoundation.nova.feature_wallet_api.data.network.priceApi.ProxyPriceApi +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceLocksRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository +import io.novafoundation.nova.feature_wallet_api.data.source.CoinPriceLocalDataSource +import io.novafoundation.nova.feature_wallet_api.data.source.CoinPriceRemoteDataSource +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.AssetGetOptionsUseCase +import io.novafoundation.nova.feature_wallet_api.domain.RealArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.feature_wallet_api.domain.fee.FeeInteractor +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.AssetModelFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FiatFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.FractionStylingFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.RealAmountFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.RealFiatFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.RealFractionStylingFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.RealTokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.provider.FeeLoaderProviderFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2 +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderV2Factory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcherFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.balance.RealPaymentUpdaterFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.CrossChainConfigApi +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.RealCrossChainTransactor +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.RealCrossChainWeigher +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainTransactor +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainWeigher +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunner +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainTransactor +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainWeigher +import io.novafoundation.nova.feature_wallet_impl.data.network.phishing.PhishingApi +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.repository.CoinPriceRepositoryImpl +import io.novafoundation.nova.feature_wallet_impl.data.repository.RealBalanceHoldsRepository +import io.novafoundation.nova.feature_wallet_impl.data.repository.RealBalanceLocksRepository +import io.novafoundation.nova.feature_wallet_impl.data.repository.RealChainAssetRepository +import io.novafoundation.nova.feature_wallet_impl.data.repository.RealCrossChainTransfersRepository +import io.novafoundation.nova.feature_wallet_impl.data.repository.RealExternalBalanceRepository +import io.novafoundation.nova.feature_wallet_impl.data.repository.RuntimeWalletConstants +import io.novafoundation.nova.feature_wallet_impl.data.repository.TokenRepositoryImpl +import io.novafoundation.nova.feature_wallet_impl.data.repository.WalletRepositoryImpl +import io.novafoundation.nova.feature_wallet_impl.data.source.RealCoinPriceDataSource +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.feature_wallet_impl.domain.RealCrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_impl.domain.RealSendUseCase +import io.novafoundation.nova.feature_wallet_impl.domain.asset.RealAssetGetOptionsUseCase +import io.novafoundation.nova.feature_wallet_impl.domain.fee.RealFeeInteractor +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.context.AssetValidationContextFactory +import io.novafoundation.nova.feature_wallet_impl.presentation.WalletRouter +import io.novafoundation.nova.feature_wallet_impl.presentation.common.fieldValidation.RealEnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_impl.presentation.common.fieldValidation.RealMinAmountFieldValidatorFactory +import io.novafoundation.nova.feature_wallet_impl.presentation.formatters.RealAssetModelFormatter +import io.novafoundation.nova.feature_wallet_impl.presentation.getAsset.RealGetAssetOptionsMixinFactory +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository + +@Module +class WalletFeatureModule { + + @Provides + @FeatureScope + fun provideExternalBalancesRepository( + externalBalanceDao: ExternalBalanceDao + ): ExternalBalanceRepository { + return RealExternalBalanceRepository(externalBalanceDao) + } + + @Provides + @FeatureScope + fun provideSubQueryApi(networkApiCreator: NetworkApiCreator): SubQueryOperationsApi { + return networkApiCreator.create(SubQueryOperationsApi::class.java) + } + + @Provides + @FeatureScope + fun provideProxyPriceApi(networkApiCreator: NetworkApiCreator): ProxyPriceApi { + return networkApiCreator.create(ProxyPriceApi::class.java, ProxyPriceApi.BASE_URL) + } + + @Provides + @FeatureScope + fun provideCoingeckoApi(networkApiCreator: NetworkApiCreator): CoingeckoApi { + return networkApiCreator.create(CoingeckoApi::class.java, CoingeckoApi.BASE_URL) + } + + @Provides + @FeatureScope + fun provideCoinPriceRemoteDataSource( + priceApi: ProxyPriceApi, + coingeckoApi: CoingeckoApi, + httpExceptionHandler: HttpExceptionHandler + ): CoinPriceRemoteDataSource { + return RealCoinPriceDataSource(priceApi, coingeckoApi, httpExceptionHandler) + } + + @Provides + @FeatureScope + fun provideCoinPriceLocalDataSource( + coinPriceDao: CoinPriceDao + ): CoinPriceLocalDataSource { + return CoinPriceLocalDataSourceImpl(coinPriceDao) + } + + @Provides + @FeatureScope + fun provideAssetCache( + tokenDao: TokenDao, + assetDao: AssetDao, + accountRepository: AccountRepository, + ): AssetCache { + return AssetCache(tokenDao, accountRepository, assetDao) + } + + @Provides + @FeatureScope + fun providePhishingApi(networkApiCreator: NetworkApiCreator): PhishingApi { + return networkApiCreator.create(PhishingApi::class.java) + } + + @Provides + @FeatureScope + fun provideTokenRepository( + tokenDao: TokenDao, + ): TokenRepository = TokenRepositoryImpl( + tokenDao + ) + + @Provides + @FeatureScope + fun provideCursorStorage(preferences: Preferences) = TransferCursorStorage(preferences) + + @Provides + @FeatureScope + fun provideWalletRepository( + operationsDao: OperationDao, + phishingApi: PhishingApi, + phishingAddressDao: PhishingAddressDao, + assetCache: AssetCache, + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + coinPriceRemoteDataSource: CoinPriceRemoteDataSource + ): WalletRepository = WalletRepositoryImpl( + operationsDao, + phishingApi, + accountRepository, + assetCache, + phishingAddressDao, + coinPriceRemoteDataSource, + chainRegistry, + ) + + @Provides + @FeatureScope + fun providePaymentUpdaterFactory( + operationDao: OperationDao, + assetSourceRegistry: AssetSourceRegistry, + accountUpdateScope: AccountUpdateScope, + chainRegistry: ChainRegistry, + assetCache: AssetCache + ): PaymentUpdaterFactory = RealPaymentUpdaterFactory( + operationDao, + assetSourceRegistry, + accountUpdateScope, + chainRegistry, + assetCache + ) + + @Provides + @FeatureScope + fun provideWalletConstants( + chainRegistry: ChainRegistry, + ): WalletConstants = RuntimeWalletConstants(chainRegistry) + + @Provides + @FeatureScope + fun provideAmountChooserFactory( + assetIconProvider: AssetIconProvider + ): AmountChooserMixin.Factory = AmountChooserProviderFactory(assetIconProvider) + + @Provides + @FeatureScope + fun provideCustomFeeInteractor( + chainRegistry: ChainRegistry, + walletRepository: WalletRepository, + accountRepository: AccountRepository, + assetSourceRegistry: AssetSourceRegistry, + customFeeCapabilityFacade: CustomFeeCapabilityFacade, + tokenRepository: TokenRepository, + ): FeeInteractor { + return RealFeeInteractor( + chainRegistry = chainRegistry, + walletRepository = walletRepository, + accountRepository = accountRepository, + tokenRepository = tokenRepository, + assetSourceRegistry = assetSourceRegistry, + customFeeCapabilityFacade = customFeeCapabilityFacade, + ) + } + + @Provides + @FeatureScope + fun provideAssetModelFormatter( + assetIconProvider: AssetIconProvider, + resourceManager: ResourceManager + ): AssetModelFormatter { + return RealAssetModelFormatter( + assetIconProvider, + resourceManager + ) + } + + @Provides + @FeatureScope + fun provideFeeLoaderMixinFactory( + resourceManager: ResourceManager, + feeInteractor: FeeInteractor, + amountFormatter: AmountFormatter + ): FeeLoaderMixin.Factory { + return FeeLoaderProviderFactory(resourceManager, feeInteractor, amountFormatter) + } + + @Provides + @FeatureScope + fun provideFractionStylingFormatter(resourceManager: ResourceManager): FractionStylingFormatter { + return RealFractionStylingFormatter(resourceManager) + } + + @Provides + @FeatureScope + fun provideAmountFormatter( + tokenFormatter: TokenFormatter, + fiatFormatter: FiatFormatter + ): AmountFormatter { + return RealAmountFormatter(tokenFormatter, fiatFormatter) + } + + @Provides + @FeatureScope + fun provideFiatFormatter(fractionStylingFormatter: FractionStylingFormatter): FiatFormatter { + return RealFiatFormatter(fractionStylingFormatter) + } + + @Provides + @FeatureScope + fun provideTokenFormatter(fractionStylingFormatter: FractionStylingFormatter): TokenFormatter = RealTokenFormatter(fractionStylingFormatter) + + @Provides + @FeatureScope + fun provideFeeLoaderV2MixinFactory( + chainRegistry: ChainRegistry, + actionAwaitableMixinFactory: ActionAwaitableMixin.Factory, + resourceManager: ResourceManager, + interactor: FeeInteractor, + amountFormatter: AmountFormatter + ): FeeLoaderMixinV2.Factory { + return FeeLoaderV2Factory(chainRegistry, actionAwaitableMixinFactory, resourceManager, interactor, amountFormatter) + } + + @Provides + @FeatureScope + fun provideCrossChainConfigApi( + apiCreator: NetworkApiCreator + ): CrossChainConfigApi = apiCreator.create(CrossChainConfigApi::class.java) + + @Provides + @FeatureScope + fun provideCrossChainRepository( + api: CrossChainConfigApi, + fileCache: FileCache, + gson: Gson, + parachainInfoRepository: ParachainInfoRepository, + ): CrossChainTransfersRepository = RealCrossChainTransfersRepository(api, fileCache, gson, parachainInfoRepository) + + @Provides + @FeatureScope + fun provideCrossChainWeigher( + dynamic: DynamicCrossChainWeigher, + legacy: LegacyCrossChainWeigher + ): CrossChainWeigher = RealCrossChainWeigher(dynamic, legacy) + + @Provides + @FeatureScope + fun provideCrossChainTransactor( + assetSourceRegistry: AssetSourceRegistry, + eventsRepository: EventsRepository, + chainStateRepository: ChainStateRepository, + chainRegistry: ChainRegistry, + dynamic: DynamicCrossChainTransactor, + legacy: LegacyCrossChainTransactor, + ): CrossChainTransactor = RealCrossChainTransactor( + assetSourceRegistry = assetSourceRegistry, + eventsRepository = eventsRepository, + chainStateRepository = chainStateRepository, + chainRegistry = chainRegistry, + dynamic = dynamic, + legacy = legacy, + ) + + @Provides + @FeatureScope + fun provideBalanceLocksRepository( + accountRepository: AccountRepository, + chainRegistry: ChainRegistry, + lockDao: LockDao + ): BalanceLocksRepository { + return RealBalanceLocksRepository(accountRepository, chainRegistry, lockDao) + } + + @Provides + @FeatureScope + fun provideBalanceHoldsRepository( + chainRegistry: ChainRegistry, + holdsDao: HoldsDao + ): BalanceHoldsRepository { + return RealBalanceHoldsRepository(chainRegistry, holdsDao) + } + + @Provides + @FeatureScope + fun provideChainAssetRepository( + chainAssetDao: ChainAssetDao, + gson: Gson + ): ChainAssetRepository = RealChainAssetRepository(chainAssetDao, gson) + + @Provides + @FeatureScope + fun provideCoinPriceRepository( + cacheDataSource: CoinPriceLocalDataSource, + remoteDataSource: CoinPriceRemoteDataSource + ): CoinPriceRepository = CoinPriceRepositoryImpl(cacheDataSource, remoteDataSource) + + @Provides + @FeatureScope + fun provideArbitraryAssetUseCase( + accountRepository: AccountRepository, + walletRepository: WalletRepository, + chainRegistry: ChainRegistry + ): ArbitraryAssetUseCase = RealArbitraryAssetUseCase(accountRepository, walletRepository, chainRegistry) + + @Provides + @FeatureScope + fun provideEnoughTotalToStayAboveEDValidationFactory( + assetSourceRegistry: AssetSourceRegistry + ): EnoughTotalToStayAboveEDValidationFactory { + return EnoughTotalToStayAboveEDValidationFactory( + assetSourceRegistry + ) + } + + @Provides + @FeatureScope + fun provideCrossChainTransfersUseCase( + crossChainTransfersRepository: CrossChainTransfersRepository, + walletRepository: WalletRepository, + chainRegistry: ChainRegistry, + accountRepository: AccountRepository, + computationalCache: ComputationalCache, + crossChainWeigher: CrossChainWeigher, + crossChainTransactor: CrossChainTransactor, + parachainInfoRepository: ParachainInfoRepository, + xcmTransferDryRunner: XcmTransferDryRunner, + ): CrossChainTransfersUseCase { + return RealCrossChainTransfersUseCase( + crossChainTransfersRepository = crossChainTransfersRepository, + walletRepository = walletRepository, + chainRegistry = chainRegistry, + accountRepository = accountRepository, + computationalCache = computationalCache, + crossChainWeigher = crossChainWeigher, + crossChainTransactor = crossChainTransactor, + parachainInfoRepository = parachainInfoRepository, + assetTransferDryRunner = xcmTransferDryRunner + ) + } + + @Provides + @FeatureScope + fun provideSubstrateRealtimeOperationFetcherFactory( + multiLocationConverterFactory: io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory, + eventsRepository: EventsRepository, + extrinsicWalk: ExtrinsicWalk, + hydraDxAssetIdConverter: HydraDxAssetIdConverter + ): SubstrateRealtimeOperationFetcher.Factory { + return SubstrateRealtimeOperationFetcherFactory( + multiLocationConverterFactory = multiLocationConverterFactory, + hydraDxAssetIdConverter = hydraDxAssetIdConverter, + eventsRepository = eventsRepository, + extrinsicWalk = extrinsicWalk + ) + } + + @Provides + @FeatureScope + fun provideAssetsValidationContextFactory( + arbitraryAssetUseCase: ArbitraryAssetUseCase, + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + ): AssetsValidationContext.Factory { + return AssetValidationContextFactory(arbitraryAssetUseCase, chainRegistry, assetSourceRegistry) + } + + @Provides + @FeatureScope + fun provideMaxActionProviderFactory(assetSourceRegistry: AssetSourceRegistry): MaxActionProviderFactory { + return MaxActionProviderFactory(assetSourceRegistry) + } + + @Provides + @FeatureScope + fun provideAssetGetOptionsUseCase( + crossChainTransfersUseCase: CrossChainTransfersUseCase, + accountRepository: AccountRepository + ): AssetGetOptionsUseCase { + return RealAssetGetOptionsUseCase( + crossChainTransfersUseCase, + accountRepository + ) + } + + @Provides + @FeatureScope + fun provideGetAssetOptionsMixinFactory( + assetGetOptionsUseCase: AssetGetOptionsUseCase, + walletRouter: WalletRouter, + resourceManager: ResourceManager, + selectedAccountUseCase: SelectedAccountUseCase, + chainRegistry: ChainRegistry, + actionAwaitableFactory: ActionAwaitableMixin.Factory, + ): GetAssetOptionsMixin.Factory { + return RealGetAssetOptionsMixinFactory( + assetGetOptionsUseCase, + walletRouter, + resourceManager, + selectedAccountUseCase, + chainRegistry, + actionAwaitableFactory + ) + } + + @Provides + @FeatureScope + fun provideEnoughAmountValidatorFactory(resourceManager: ResourceManager): EnoughAmountValidatorFactory { + return RealEnoughAmountValidatorFactory(resourceManager) + } + + @Provides + @FeatureScope + fun provideMinAmountFieldValidatorFactory(resourceManager: ResourceManager, tokenFormatter: TokenFormatter): MinAmountFieldValidatorFactory { + return RealMinAmountFieldValidatorFactory(resourceManager, tokenFormatter) + } + + @Provides + @FeatureScope + fun provideSendUseCase( + walletRepository: WalletRepository, + assetSourceRegistry: AssetSourceRegistry + ): SendUseCase { + return RealSendUseCase( + walletRepository, + assetSourceRegistry + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt new file mode 100644 index 0000000..aa0513d --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Lazy +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.TypeBasedAssetSourceRegistry +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.orml.OrmlAssetSourceFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.evmErc20.EvmErc20EventDetectorFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.orml.OrmlAssetEventDetectorFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.statemine.StatemineAssetEventDetectorFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.utility.NativeAssetEventDetector + +@Module( + includes = [ + NativeAssetsModule::class, + StatemineAssetsModule::class, + OrmlAssetsModule::class, + EvmErc20AssetsModule::class, + EvmNativeAssetsModule::class, + EquilibriumAssetsModule::class, + UnsupportedAssetsModule::class + ] +) +class AssetsModule { + + @Provides + @FeatureScope + fun provideAssetSourceRegistry( + @NativeAsset native: Lazy, + @StatemineAssets statemine: Lazy, + ormlFactory: Lazy, + @EvmErc20Assets evmErc20: Lazy, + @EvmNativeAssets evmNative: Lazy, + @EquilibriumAsset equilibrium: Lazy, + @UnsupportedAssets unsupported: AssetSource, + + nativeAssetEventDetector: NativeAssetEventDetector, + ormlAssetEventDetectorFactory: OrmlAssetEventDetectorFactory, + statemineAssetEventDetectorFactory: StatemineAssetEventDetectorFactory, + erc20EventDetectorFactory: EvmErc20EventDetectorFactory + ): AssetSourceRegistry = TypeBasedAssetSourceRegistry( + nativeSource = native, + statemineSource = statemine, + ormlSourceFactory = ormlFactory, + evmErc20Source = evmErc20, + evmNativeSource = evmNative, + equilibriumAssetSource = equilibrium, + unsupportedBalanceSource = unsupported, + + nativeAssetEventDetector = nativeAssetEventDetector, + ormlAssetEventDetectorFactory = ormlAssetEventDetectorFactory, + statemineAssetEventDetectorFactory = statemineAssetEventDetectorFactory, + erc20EventDetectorFactory = erc20EventDetectorFactory + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BalanceLocksModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BalanceLocksModule.kt new file mode 100644 index 0000000..963e9d1 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BalanceLocksModule.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.locks.BalanceLocksUpdaterFactoryImpl + +@Module +class BalanceLocksModule { + + @Provides + @FeatureScope + fun provideBalanceLocksUpdaterFactory( + scope: AccountUpdateScope, + assetSourceRegistry: AssetSourceRegistry, + ): BalanceLocksUpdaterFactory { + return BalanceLocksUpdaterFactoryImpl( + scope, + assetSourceRegistry + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/EquilibriumAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/EquilibriumAssetsModule.kt new file mode 100644 index 0000000..85fc61b --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/EquilibriumAssetsModule.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.equilibrium.EquilibriumAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.equilibrium.EquilibriumAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.equilibrium.EquilibriumAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named +import javax.inject.Qualifier + +@Qualifier +annotation class EquilibriumAsset + +@Module +class EquilibriumAssetsModule { + + @Provides + @FeatureScope + fun provideBalance( + chainRegistry: ChainRegistry, + assetCache: AssetCache, + lockDao: LockDao, + assetDao: AssetDao, + @Named(REMOTE_STORAGE_SOURCE) + remoteStorageSource: StorageDataSource + ) = EquilibriumAssetBalance(chainRegistry, assetCache, lockDao, assetDao, remoteStorageSource) + + @Provides + @FeatureScope + fun provideTransfers( + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + extrinsicServiceFactory: ExtrinsicService.Factory, + phishingValidationFactory: PhishingValidationFactory, + @Named(REMOTE_STORAGE_SOURCE) + remoteStorageSource: StorageDataSource, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ) = EquilibriumAssetTransfers( + chainRegistry, + assetSourceRegistry, + extrinsicServiceFactory, + phishingValidationFactory, + remoteStorageSource, + enoughTotalToStayAboveEDValidationFactory + ) + + @Provides + @FeatureScope + fun provideHistory( + chainRegistry: ChainRegistry, + realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory, + subQueryOperationsApi: SubQueryOperationsApi, + cursorStorage: TransferCursorStorage, + coinPriceRepository: CoinPriceRepository + ) = EquilibriumAssetHistory( + chainRegistry = chainRegistry, + walletOperationsApi = subQueryOperationsApi, + cursorStorage = cursorStorage, + coinPriceRepository = coinPriceRepository, + realtimeOperationFetcherFactory = realtimeOperationFetcherFactory + ) + + @Provides + @EquilibriumAsset + @FeatureScope + fun provideAssetSource( + assetBalance: EquilibriumAssetBalance, + assetTransfers: EquilibriumAssetTransfers, + assetHistory: EquilibriumAssetHistory, + ): AssetSource = StaticAssetSource( + transfers = assetTransfers, + balance = assetBalance, + history = assetHistory + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/EvmErc20AssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/EvmErc20AssetsModule.kt new file mode 100644 index 0000000..74881be --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/EvmErc20AssetsModule.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.evmErc20.EvmErc20AssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.evmErc20.EvmErc20AssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.evmErc20.EvmErc20AssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.EtherscanApiKeys +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.EtherscanTransactionsApi +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.RealEtherscanTransactionsApi +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.RetrofitEtherscanTransactionsApi +import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import javax.inject.Qualifier + +@Qualifier +annotation class EvmErc20Assets + +@Module +class EvmErc20AssetsModule { + + @Provides + @FeatureScope + fun provideErc20Standard() = Erc20Standard() + + @Provides + @FeatureScope + fun provideBalance( + chainRegistry: ChainRegistry, + assetCache: AssetCache, + erc20Standard: Erc20Standard, + rpcCalls: RpcCalls + ) = EvmErc20AssetBalance(chainRegistry, assetCache, erc20Standard, rpcCalls) + + @Provides + @FeatureScope + fun provideTransfers( + evmTransactionService: EvmTransactionService, + erc20Standard: Erc20Standard, + assetSourceRegistry: AssetSourceRegistry + ) = EvmErc20AssetTransfers(evmTransactionService, erc20Standard, assetSourceRegistry) + + @Provides + @FeatureScope + fun provideEtherscanTransfersApi( + networkApiCreator: NetworkApiCreator + ): RetrofitEtherscanTransactionsApi = networkApiCreator.create(RetrofitEtherscanTransactionsApi::class.java) + + @Provides + @FeatureScope + fun provideEtherscanApiKeys() = EtherscanApiKeys.default() + + @Provides + @FeatureScope + fun provideEtherscanTransactionsApi( + retrofitEtherscanTransactionsApi: RetrofitEtherscanTransactionsApi, + etherscanApiKeys: EtherscanApiKeys, + ): EtherscanTransactionsApi = RealEtherscanTransactionsApi(retrofitEtherscanTransactionsApi, etherscanApiKeys) + + @Provides + @FeatureScope + fun provideHistory( + etherscanTransactionsApi: EtherscanTransactionsApi, + coinPriceRepository: CoinPriceRepository + ) = EvmErc20AssetHistory(etherscanTransactionsApi, coinPriceRepository) + + @Provides + @EvmErc20Assets + @FeatureScope + fun provideAssetSource( + evmErc20AssetBalance: EvmErc20AssetBalance, + evmErc20AssetTransfers: EvmErc20AssetTransfers, + evmErc20AssetHistory: EvmErc20AssetHistory, + ): AssetSource = StaticAssetSource( + transfers = evmErc20AssetTransfers, + balance = evmErc20AssetBalance, + history = evmErc20AssetHistory + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/EvmNativeAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/EvmNativeAssetsModule.kt new file mode 100644 index 0000000..ecf20d7 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/EvmNativeAssetsModule.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.evmNative.EvmNativeAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.evmNative.EvmNativeAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.evmNative.EvmNativeAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.EtherscanTransactionsApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import javax.inject.Qualifier + +@Qualifier +annotation class EvmNativeAssets + +@Module +class EvmNativeAssetsModule { + + @Provides + @FeatureScope + fun provideBalance( + assetCache: AssetCache, + chainRegistry: ChainRegistry, + ) = EvmNativeAssetBalance(assetCache, chainRegistry) + + @Provides + @FeatureScope + fun provideTransfers( + evmTransactionService: EvmTransactionService, + assetSourceRegistry: AssetSourceRegistry + ) = EvmNativeAssetTransfers(evmTransactionService, assetSourceRegistry) + + @Provides + @FeatureScope + fun provideHistory( + chainRegistry: ChainRegistry, + etherscanTransactionsApi: EtherscanTransactionsApi, + coinPriceRepository: CoinPriceRepository + ) = EvmNativeAssetHistory(chainRegistry, etherscanTransactionsApi, coinPriceRepository) + + @Provides + @EvmNativeAssets + @FeatureScope + fun provideAssetSource( + evmNativeAssetBalance: EvmNativeAssetBalance, + evmNativeAssetTransfers: EvmNativeAssetTransfers, + evmNativeAssetHistory: EvmNativeAssetHistory, + ): AssetSource = StaticAssetSource( + transfers = evmNativeAssetTransfers, + balance = evmNativeAssetBalance, + history = evmNativeAssetHistory + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/NativeAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/NativeAssetsModule.kt new file mode 100644 index 0000000..c0c6755 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/NativeAssetsModule.kt @@ -0,0 +1,108 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.HoldsDao +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility.NativeAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.utility.NativeAssetEventDetector +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.utility.NativeAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.utility.NativeAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.runtime.di.LOCAL_STORAGE_SOURCE +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named +import javax.inject.Qualifier + +@Qualifier +annotation class NativeAsset + +@Module +class NativeAssetsModule { + + @Provides + @FeatureScope + fun provideBalance( + chainRegistry: ChainRegistry, + assetCache: AssetCache, + accountInfoRepository: AccountInfoRepository, + @Named(REMOTE_STORAGE_SOURCE) remoteSource: StorageDataSource, + lockDao: LockDao, + holdsDao: HoldsDao, + ) = NativeAssetBalance( + chainRegistry = chainRegistry, + assetCache = assetCache, + accountInfoRepository = accountInfoRepository, + remoteStorage = remoteSource, + lockDao = lockDao, + holdsDao = holdsDao + ) + + @Provides + @FeatureScope + fun provideTransfers( + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + extrinsicServiceFactory: ExtrinsicService.Factory, + phishingValidationFactory: PhishingValidationFactory, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory, + @Named(LOCAL_STORAGE_SOURCE) storageDataSource: StorageDataSource, + accountRepository: AccountRepository, + ) = NativeAssetTransfers( + chainRegistry, + assetSourceRegistry, + extrinsicServiceFactory, + phishingValidationFactory, + enoughTotalToStayAboveEDValidationFactory, + storageDataSource, + accountRepository + ) + + @Provides + @FeatureScope + fun provideHistory( + chainRegistry: ChainRegistry, + realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory, + subQueryOperationsApi: SubQueryOperationsApi, + cursorStorage: TransferCursorStorage, + coinPriceRepository: CoinPriceRepository + ) = NativeAssetHistory( + chainRegistry = chainRegistry, + realtimeOperationFetcherFactory = realtimeOperationFetcherFactory, + walletOperationsApi = subQueryOperationsApi, + cursorStorage = cursorStorage, + coinPriceRepository = coinPriceRepository + ) + + @Provides + @NativeAsset + @FeatureScope + fun provideAssetSource( + nativeAssetBalance: NativeAssetBalance, + nativeAssetTransfers: NativeAssetTransfers, + nativeAssetHistory: NativeAssetHistory, + ): AssetSource = StaticAssetSource( + transfers = nativeAssetTransfers, + balance = nativeAssetBalance, + history = nativeAssetHistory + ) + + @Provides + @FeatureScope + fun provideNativeAssetEventsDetector() = NativeAssetEventDetector() +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/OrmlAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/OrmlAssetsModule.kt new file mode 100644 index 0000000..9d8d841 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/OrmlAssetsModule.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.dao.LockDao +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml.OrmlAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.orml.OrmlAssetEventDetectorFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.orml.OrmlAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml.OrmlAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +@Module +class OrmlAssetsModule { + + @Provides + @FeatureScope + fun provideBalance( + chainRegistry: ChainRegistry, + assetCache: AssetCache, + @Named(REMOTE_STORAGE_SOURCE) remoteDataSource: StorageDataSource, + lockDao: LockDao + ) = OrmlAssetBalance(assetCache, remoteDataSource, chainRegistry, lockDao) + + @Provides + @FeatureScope + fun provideTransfers( + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + extrinsicServiceFactory: ExtrinsicService.Factory, + phishingValidationFactory: PhishingValidationFactory, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ) = OrmlAssetTransfers(chainRegistry, assetSourceRegistry, extrinsicServiceFactory, phishingValidationFactory, enoughTotalToStayAboveEDValidationFactory) + + @Provides + @FeatureScope + fun provideHistory( + chainRegistry: ChainRegistry, + realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory, + subQueryOperationsApi: SubQueryOperationsApi, + cursorStorage: TransferCursorStorage, + coinPriceRepository: CoinPriceRepository + ) = OrmlAssetHistory( + chainRegistry = chainRegistry, + realtimeOperationFetcherFactory = realtimeOperationFetcherFactory, + walletOperationsApi = subQueryOperationsApi, + cursorStorage = cursorStorage, + coinPriceRepository = coinPriceRepository + ) + + @Provides + @FeatureScope + fun provideOrmlAssetEventDetectorFactory(chainRegistry: ChainRegistry) = OrmlAssetEventDetectorFactory(chainRegistry) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/StatemineAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/StatemineAssetsModule.kt new file mode 100644 index 0000000..1314561 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/StatemineAssetsModule.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher +import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.StatemineAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.statemine.StatemineAssetEventDetectorFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.statemine.StatemineAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.statemine.StatemineAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi +import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named +import javax.inject.Qualifier + +@Qualifier +annotation class StatemineAssets + +@Module +class StatemineAssetsModule { + + @Provides + @FeatureScope + fun provideBalance( + chainRegistry: ChainRegistry, + assetCache: AssetCache, + @Named(REMOTE_STORAGE_SOURCE) remoteStorage: StorageDataSource, + statemineAssetsRepository: StatemineAssetsRepository, + ) = StatemineAssetBalance( + chainRegistry = chainRegistry, + assetCache = assetCache, + remoteStorage = remoteStorage, + statemineAssetsRepository = statemineAssetsRepository + ) + + @Provides + @FeatureScope + fun provideTransfers( + chainRegistry: ChainRegistry, + assetSourceRegistry: AssetSourceRegistry, + extrinsicServiceFactory: ExtrinsicService.Factory, + phishingValidationFactory: PhishingValidationFactory, + @Named(REMOTE_STORAGE_SOURCE) remoteStorage: StorageDataSource, + enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory + ) = StatemineAssetTransfers( + chainRegistry, + assetSourceRegistry, + extrinsicServiceFactory, + phishingValidationFactory, + enoughTotalToStayAboveEDValidationFactory, + remoteStorage + ) + + @Provides + @FeatureScope + fun provideHistory( + chainRegistry: ChainRegistry, + realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory, + subQueryOperationsApi: SubQueryOperationsApi, + cursorStorage: TransferCursorStorage, + coinPriceRepository: CoinPriceRepository + ) = StatemineAssetHistory( + chainRegistry = chainRegistry, + realtimeOperationFetcherFactory = realtimeOperationFetcherFactory, + walletOperationsApi = subQueryOperationsApi, + cursorStorage = cursorStorage, + coinPriceRepository = coinPriceRepository + ) + + @Provides + @StatemineAssets + @FeatureScope + fun provideAssetSource( + statemineAssetBalance: StatemineAssetBalance, + statemineAssetTransfers: StatemineAssetTransfers, + statemineAssetHistory: StatemineAssetHistory, + ): AssetSource = StaticAssetSource( + transfers = statemineAssetTransfers, + balance = statemineAssetBalance, + history = statemineAssetHistory + ) + + @Provides + @FeatureScope + fun provideStatemineAssetEventDetectorFactory(chainRegistry: ChainRegistry) = StatemineAssetEventDetectorFactory(chainRegistry) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/UnsupportedAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/UnsupportedAssetsModule.kt new file mode 100644 index 0000000..5130142 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/UnsupportedAssetsModule.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.UnsupportedAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.UnsupportedAssetTransfers +import javax.inject.Qualifier + +@Qualifier +annotation class UnsupportedAssets + +@Module +class UnsupportedAssetsModule { + + @Provides + @FeatureScope + fun provideBalance() = UnsupportedAssetBalance() + + @Provides + @FeatureScope + fun provideTransfers() = UnsupportedAssetTransfers() + + @Provides + @FeatureScope + fun provideHistory() = UnsupportedAssetHistory() + + @Provides + @FeatureScope + @UnsupportedAssets + fun provideSource( + unsupportedAssetBalance: UnsupportedAssetBalance, + unsupportedAssetTransfers: UnsupportedAssetTransfers, + unsupportedAssetHistory: UnsupportedAssetHistory, + ): AssetSource = StaticAssetSource( + transfers = unsupportedAssetTransfers, + balance = unsupportedAssetBalance, + history = unsupportedAssetHistory + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/ValidationsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/ValidationsModule.kt new file mode 100644 index 0000000..65b9fc3 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/ValidationsModule.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory + +@Module +class ValidationsModule { + + @Provides + @FeatureScope + fun providePhishingValidationFactory( + walletRepository: WalletRepository + ) = PhishingValidationFactory(walletRepository) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/WalletBindsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/WalletBindsModule.kt new file mode 100644 index 0000000..494825a --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/WalletBindsModule.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Binds +import dagger.Module +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider +import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository +import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.RealArbitraryTokenUseCase +import io.novafoundation.nova.feature_wallet_api.domain.validation.MultisigExtrinsicValidationFactory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.RealAccountInfoRepository +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.RealXcmTransferDryRunner +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunner +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing.AssetIssuerRegistry +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing.RealAssetIssuerRegistry +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.validations.RealCrossChainValidationSystemProvider +import io.novafoundation.nova.feature_wallet_impl.data.repository.RealStatemineAssetsRepository +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.multisig.RealMultisigExtrinsicValidationFactory + +@Module +internal interface WalletBindsModule { + + @Binds + fun bindStatemineAssetRepository(implementation: RealStatemineAssetsRepository): StatemineAssetsRepository + + @Binds + fun bindAssetIssuerRegistry(implementation: RealAssetIssuerRegistry): AssetIssuerRegistry + + @Binds + fun bindMultisigExtrinsicValidationFactory(implementation: RealMultisigExtrinsicValidationFactory): MultisigExtrinsicValidationFactory + + @Binds + fun bindAccountInfoRepository(implementation: RealAccountInfoRepository): AccountInfoRepository + + @Binds + fun bindXcmTransferDryRunner(implementation: RealXcmTransferDryRunner): XcmTransferDryRunner + + @Binds + fun bindCrossChainValidationSystemProvider(implementation: RealCrossChainValidationSystemProvider): CrossChainValidationSystemProvider + + @Binds + fun bindArbitraryTokenUseCase(implementation: RealArbitraryTokenUseCase): ArbitraryTokenUseCase +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/RealCrossChainTransfersUseCase.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/RealCrossChainTransfersUseCase.kt new file mode 100644 index 0000000..3563445 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/RealCrossChainTransfersUseCase.kt @@ -0,0 +1,249 @@ +package io.novafoundation.nova.feature_wallet_impl.domain + +import android.util.Log +import io.novafoundation.nova.common.data.memory.ComputationalCache +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.coerceToUnit +import io.novafoundation.nova.common.utils.combineToPair +import io.novafoundation.nova.common.utils.isPositive +import io.novafoundation.nova.common.utils.withFlowScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFee +import io.novafoundation.nova.feature_account_api.data.model.SubstrateFeeBase +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferDirection +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainWeigher +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.XcmTransferDryRunOrigin +import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.paidByAccountOrNull +import io.novafoundation.nova.feature_wallet_api.data.repository.getXcmChain +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.IncomingDirection +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.OutcomingDirection +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.CrossChainTransferFee +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransfersConfiguration +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.availableInDestinations +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.availableOutDestinations +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferFeatures +import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.transferConfiguration +import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunner +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.assets +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chainsById +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration + +private const val INCOMING_DIRECTIONS = "RealCrossChainTransfersUseCase.INCOMING_DIRECTIONS" +private const val CONFIGURATION_CACHE = "RealCrossChainTransfersUseCase.CONFIGURATION" + +internal class RealCrossChainTransfersUseCase( + private val crossChainTransfersRepository: CrossChainTransfersRepository, + private val walletRepository: WalletRepository, + private val chainRegistry: ChainRegistry, + private val accountRepository: AccountRepository, + private val computationalCache: ComputationalCache, + private val crossChainWeigher: CrossChainWeigher, + private val crossChainTransactor: CrossChainTransactor, + private val parachainInfoRepository: ParachainInfoRepository, + private val assetTransferDryRunner: XcmTransferDryRunner, +) : CrossChainTransfersUseCase { + + override suspend fun syncCrossChainConfig() { + crossChainTransfersRepository.syncConfiguration() + } + + override fun incomingCrossChainDirections(destination: Flow): Flow> { + return withFlowScope { scope -> + computationalCache.useSharedFlow(INCOMING_DIRECTIONS, scope) { + scope.launch { crossChainTransfersRepository.syncConfiguration() } + + combineToPair(destination, cachedConfigurationFlow(scope)).flatMapLatest { (destinationAsset, crossChainConfig) -> + if (destinationAsset == null) return@flatMapLatest flowOf(emptyList()) + + val selectedMetaAccountId = accountRepository.getSelectedMetaAccount().id + val availableDirections = crossChainConfig.availableInDestinations(destinationAsset) + + val chains = chainRegistry.chainsById() + val availableDirectionChainAssets = chains.assets(availableDirections) + + walletRepository.assetsFlow(selectedMetaAccountId, availableDirectionChainAssets).map { balances -> + balances + .filter { it.transferable.isPositive } + .map { IncomingDirection(it, chains.getValue(it.token.configuration.chainId)) } + } + } + } + }.catch { emit(emptyList()) } + } + + override fun outcomingCrossChainDirectionsFlow(origin: Chain.Asset): Flow> { + return withFlowScope { scope -> + scope.launch { crossChainTransfersRepository.syncConfiguration() } + + crossChainTransfersRepository.configurationFlow().map { configuration -> + val chainsById = chainRegistry.chainsById.first() + + configuration.availableOutDestinations(origin).mapNotNull { (chainId, assetId) -> + val chain = chainsById[chainId] ?: return@mapNotNull null + val asset = chain.assetsById[assetId] ?: return@mapNotNull null + + ChainWithAsset(chain, asset) + } + } + }.catch { emit(emptyList()) } + } + + override suspend fun getConfiguration(): CrossChainTransfersConfiguration { + return crossChainTransfersRepository.getConfiguration() + } + + override suspend fun requiredRemainingAmountAfterTransfer( + originChain: Chain, + sendingAsset: Chain.Asset, + destinationChain: Chain, + ): Balance { + val cachingScope = CoroutineScope(coroutineContext) + val transferConfig = transferConfigurationFor(originChain, sendingAsset, destinationChain, cachingScope) + + return crossChainTransactor.requiredRemainingAmountAfterTransfer(transferConfig) + } + + override suspend fun ExtrinsicService.estimateFee( + transfer: AssetTransferBase, + cachingScope: CoroutineScope? + ): CrossChainTransferFee = withContext(Dispatchers.IO) { + val transferConfiguration = transferConfigurationFor(transfer, cachingScope) + + val originFeeAsync = async { crossChainTransactor.estimateOriginFee(transferConfiguration, transfer) } + val crossChainFeeAsync = async { crossChainWeigher.estimateFee(transfer, transferConfiguration) } + + val originFee = originFeeAsync.await() + val crossChainFee = crossChainFeeAsync.await() + + CrossChainTransferFee( + submissionFee = originFee, + postSubmissionByAccount = crossChainFee.paidByAccountOrNull()?.let { + val submissionOrigin = SubmissionOrigin.singleOrigin(originFee.submissionOrigin.signingAccount) + SubstrateFee(it, submissionOrigin, transfer.originChain.commissionAsset) + }, + postSubmissionFromAmount = SubstrateFeeBase( + amount = crossChainFee.paidFromHolding, + asset = transfer.originChainAsset, + ), + ) + } + + override suspend fun ExtrinsicService.performTransferOfExactAmount( + transfer: AssetTransferBase, + computationalScope: CoroutineScope + ): Result { + val transferConfiguration = transferConfigurationFor(transfer, computationalScope) + return crossChainTransactor.performTransfer(transferConfiguration, transfer, crossChainFee = Balance.ZERO) + } + + override suspend fun ExtrinsicService.performTransferAndTrackTransfer( + transfer: AssetTransferBase, + computationalScope: CoroutineScope + ): Result { + val transferConfiguration = transferConfigurationFor(transfer, computationalScope) + return crossChainTransactor.performAndTrackTransfer(transferConfiguration, transfer) + } + + override suspend fun maximumExecutionTime( + assetTransferDirection: AssetTransferDirection, + computationalScope: CoroutineScope + ): Duration { + val transferConfiguration = transferConfigurationFor(assetTransferDirection, computationalScope) + return crossChainTransactor.estimateMaximumExecutionTime(transferConfiguration) + } + + override suspend fun dryRunTransferIfPossible( + transfer: AssetTransferBase, + origin: XcmTransferDryRunOrigin, + computationalScope: CoroutineScope + ): Result { + val transferConfiguration = transferConfigurationFor(transfer, computationalScope) + + if (transferConfiguration !is CrossChainTransferConfiguration.Dynamic) { + Log.d(LOG_TAG, "Transfer dry run is not available - skipping") + return Result.success(Unit) + } + + return assetTransferDryRunner.dryRunXcmTransfer( + config = transferConfiguration.config, + transfer = transfer, + origin = origin + ) + .coerceToUnit() + .recoverCatching { error -> + // Dry run is optional - if it fails, log the error but don't block the transfer + // Some chains (like Pezkuwi) don't support dry run properly + Log.w(LOG_TAG, "Dry run failed but continuing with transfer: ${error.message}") + Unit + } + } + + override suspend fun supportsXcmExecute(originChainId: ChainId, features: DynamicCrossChainTransferFeatures): Boolean { + return crossChainTransactor.supportsXcmExecute(originChainId, features) + } + + private suspend fun transferConfigurationFor( + transfer: AssetTransferDirection, + cachingScope: CoroutineScope? + ): CrossChainTransferConfiguration { + return transferConfigurationFor( + originChain = transfer.originChain, + sendingAsset = transfer.originChainAsset, + destinationChain = transfer.destinationChain, + cachingScope = cachingScope + ) + } + + private suspend fun transferConfigurationFor( + originChain: Chain, + sendingAsset: Chain.Asset, + destinationChain: Chain, + cachingScope: CoroutineScope? + ): CrossChainTransferConfiguration { + val configuration = cachedConfigurationFlow(cachingScope).first() + + return configuration.transferConfiguration( + originChain = parachainInfoRepository.getXcmChain(originChain), + originAsset = sendingAsset, + destinationChain = parachainInfoRepository.getXcmChain(destinationChain), + )!! + } + + private fun cachedConfigurationFlow(cachingScope: CoroutineScope?): Flow { + if (cachingScope == null) { + return crossChainTransfersRepository.configurationFlow() + } + + return computationalCache.useSharedFlow(CONFIGURATION_CACHE, cachingScope) { + crossChainTransfersRepository.configurationFlow() + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/RealSendUseCase.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/RealSendUseCase.kt new file mode 100644 index 0000000..5369de2 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/RealSendUseCase.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_wallet_impl.domain + +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee +import io.novafoundation.nova.feature_account_api.data.signer.isImmediate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.isCrossChain +import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import java.security.InvalidParameterException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class RealSendUseCase( + private val walletRepository: WalletRepository, + private val assetSourceRegistry: AssetSourceRegistry +) : SendUseCase { + + override suspend fun performOnChainTransfer( + transfer: WeightedAssetTransfer, + fee: SubmissionFee, + coroutineScope: CoroutineScope + ): Result = withContext(Dispatchers.Default) { + if (transfer.isCrossChain) throw InvalidParameterException("Cross chain transfers are not supported") + getAssetTransfers(transfer).performTransfer(transfer, coroutineScope) + .onSuccess { submission -> + // Only add pending history items for calls that are executed immediately + if (submission.callExecutionType.isImmediate()) { + // Insert used fee regardless of who paid it + walletRepository.insertPendingTransfer(submission.hash, transfer, fee) + } + } + } + + override suspend fun performOnChainTransferAndAwaitExecution( + transfer: WeightedAssetTransfer, + fee: SubmissionFee, + coroutineScope: CoroutineScope + ): Result = withContext(Dispatchers.Default) { + if (transfer.isCrossChain) throw InvalidParameterException("Cross chain transfers are not supported") + getAssetTransfers(transfer).performTransferAndAwaitExecution(transfer, coroutineScope) + } + + private fun getAssetTransfers(transfer: AssetTransfer) = assetSourceRegistry.sourceFor(transfer.originChainAsset).transfers +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/asset/RealAssetGetOptionsUseCase.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/asset/RealAssetGetOptionsUseCase.kt new file mode 100644 index 0000000..1bf1e02 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/asset/RealAssetGetOptionsUseCase.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_wallet_impl.domain.asset + +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.LightMetaAccount +import io.novafoundation.nova.feature_wallet_api.domain.AssetGetOptionsUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.incomingCrossChainDirectionsAvailable +import io.novafoundation.nova.feature_wallet_api.domain.model.GetAssetOption +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +class RealAssetGetOptionsUseCase( + private val crossChainTransfersUseCase: CrossChainTransfersUseCase, + private val accountRepository: AccountRepository +) : AssetGetOptionsUseCase { + + override fun observeAssetGetOptionsForSelectedAccount(chainAssetFlow: Flow): Flow> { + return combine( + crossChainTransfersUseCase.incomingCrossChainDirectionsAvailable(chainAssetFlow), + receiveAvailable(chainAssetFlow), + buyAvailable(chainAssetFlow), + ) { crossChainTransfersAvailable, receiveAvailable, buyAvailable -> + setOfNotNull( + GetAssetOption.CROSS_CHAIN.takeIf { crossChainTransfersAvailable }, + GetAssetOption.RECEIVE.takeIf { receiveAvailable }, + GetAssetOption.BUY.takeIf { buyAvailable } + ) + } + } + + private fun buyAvailable(chainAssetFlow: Flow): Flow { + return chainAssetFlow.map { it != null && it.buyProviders.isNotEmpty() } + } + + private fun receiveAvailable(chainAssetFlow: Flow): Flow { + return combine(accountRepository.selectedMetaAccountFlow(), chainAssetFlow) { metaAccout, asset -> + metaAccout.type != LightMetaAccount.Type.WATCH_ONLY && asset != null + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/fee/RealFeeInteractor.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/fee/RealFeeInteractor.kt new file mode 100644 index 0000000..6f1be54 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/fee/RealFeeInteractor.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.feature_wallet_impl.domain.fee + +import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency +import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDepositInPlanks +import io.novafoundation.nova.feature_wallet_api.domain.fee.FeeInteractor +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository +import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.model.Token +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow + +class RealFeeInteractor( + private val chainRegistry: ChainRegistry, + private val walletRepository: WalletRepository, + private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, + private val assetSourceRegistry: AssetSourceRegistry, + private val customFeeCapabilityFacade: CustomFeeCapabilityFacade, +) : FeeInteractor { + + override suspend fun canPayFeeInAsset(chainAsset: Chain.Asset): Boolean { + return customFeeCapabilityFacade.canPayFeeInCurrency(chainAsset.toFeePaymentCurrency()) + } + + override suspend fun assetFlow(asset: Chain.Asset): Flow { + val selectedMetaAccount = accountRepository.getSelectedMetaAccount() + return walletRepository.assetFlowOrNull(selectedMetaAccount.id, asset) + } + + override suspend fun hasEnoughBalanceToPayFee(feeAsset: Asset, inspectedFeeAmount: FeeInspector.InspectedFeeAmount): Boolean { + val feeChainAsset = feeAsset.token.configuration + + val existentialBalance = assetSourceRegistry.existentialDepositInPlanks(feeChainAsset) + val passEdFeeCheck = feeAsset.balanceCountedTowardsEDInPlanks - inspectedFeeAmount.checkedAgainstMinimumBalance >= existentialBalance + + val hasEnoughTransferable = feeAsset.transferableInPlanks >= inspectedFeeAmount.deductedFromTransferable + + return passEdFeeCheck && hasEnoughTransferable + } + + override suspend fun getToken(chainAsset: Chain.Asset): Token { + return tokenRepository.getToken(chainAsset) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/RecipientCanAcceptTransferValidation.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/RecipientCanAcceptTransferValidation.kt new file mode 100644 index 0000000..bd39356 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/RecipientCanAcceptTransferValidation.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.feature_wallet_impl.domain.validaiton + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isTrueOrError +import io.novafoundation.nova.common.validation.valid +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.recipientOrNull + +class RecipientCanAcceptTransferValidation( + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetTransfersValidation { + + override suspend fun validate(value: AssetTransferPayload): ValidationStatus { + val destinationAsset = value.transfer.destinationChainAsset + val source = assetSourceRegistry.sourceFor(destinationAsset) + + val recipientAccountId = value.transfer.recipientOrNull() ?: return valid() + + return source.transfers.recipientCanAcceptTransfer(destinationAsset, recipientAccountId) isTrueOrError { + AssetTransferValidationFailure.RecipientCannotAcceptTransfer + } + } +} + +fun AssetTransfersValidationSystemBuilder.recipientCanAcceptTransfer( + assetSourceRegistry: AssetSourceRegistry +) { + validate(RecipientCanAcceptTransferValidation(assetSourceRegistry)) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/context/RealAssetValidationContext.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/context/RealAssetValidationContext.kt new file mode 100644 index 0000000..af0ff93 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/context/RealAssetValidationContext.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.feature_wallet_impl.domain.validaiton.context + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDepositInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.isSelfSufficientAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.totalCanBeDroppedBelowMinimumBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.Asset +import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext +import io.novafoundation.nova.runtime.ext.fullId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class AssetValidationContextFactory( + private val arbitraryAssetUseCase: ArbitraryAssetUseCase, + private val chainRegistry: ChainRegistry, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetsValidationContext.Factory { + + override fun create(): AssetsValidationContext { + return RealAssetValidationContext(arbitraryAssetUseCase, chainRegistry, assetSourceRegistry) + } +} + +private class RealAssetValidationContext( + private val arbitraryAssetUseCase: ArbitraryAssetUseCase, + private val chainRegistry: ChainRegistry, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetsValidationContext { + + private val balanceCache = mutableMapOf() + private val balanceMutex = Mutex() + + private val edCache = mutableMapOf() + private val edMutex = Mutex() + + override suspend fun getAsset(chainAsset: Chain.Asset): Asset { + return balanceMutex.withLock { + balanceCache.getOrPut(chainAsset.fullId) { + arbitraryAssetUseCase.getAsset(chainAsset)!! + } + } + } + + override suspend fun getAsset(chainAssetId: FullChainAssetId): Asset { + val chainAsset = chainRegistry.asset(chainAssetId) + return getAsset(chainAsset) + } + + override suspend fun getExistentialDeposit(chainAssetId: FullChainAssetId): Balance { + return edMutex.withLock { + edCache.getOrPut(chainAssetId) { + val (chain, asset) = chainRegistry.chainWithAsset(chainAssetId) + assetSourceRegistry.existentialDepositInPlanks(asset) + } + } + } + + override suspend fun isAssetSufficient(chainAsset: Chain.Asset): Boolean { + return assetSourceRegistry.isSelfSufficientAsset(chainAsset) + } + + override suspend fun canTotalDropBelowEd(chainAsset: Chain.Asset): Boolean { + return assetSourceRegistry.totalCanBeDroppedBelowMinimumBalance(chainAsset) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/multisig/MultisigNoPendingCallHashValidaiton.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/multisig/MultisigNoPendingCallHashValidaiton.kt new file mode 100644 index 0000000..f8f80b5 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/multisig/MultisigNoPendingCallHashValidaiton.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_wallet_impl.domain.validaiton.multisig + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.callHash +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.isFalseOrError +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidation +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationFailure +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationPayload +import io.novafoundation.nova.feature_account_api.data.multisig.validation.multisigAccountId +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime + +class MultisigNoPendingCallHashValidation( + private val chainRegistry: ChainRegistry, + private val multisigValidationsRepository: MultisigValidationsRepository, +) : MultisigExtrinsicValidation { + + override suspend fun validate(value: MultisigExtrinsicValidationPayload): ValidationStatus { + val runtime = chainRegistry.getRuntime(value.chain.id) + val callHash = value.callInsideAsMulti.callHash(runtime).intoKey() + + val hasPendingCallHash = multisigValidationsRepository.hasPendingCallHash(value.chain.id, value.multisigAccountId(), callHash) + + return hasPendingCallHash isFalseOrError { + MultisigExtrinsicValidationFailure.OperationAlreadyExists(value.multisig) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/multisig/MultisigSignatoryHasEnoughBalanceValidation.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/multisig/MultisigSignatoryHasEnoughBalanceValidation.kt new file mode 100644 index 0000000..fa6b3cd --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/multisig/MultisigSignatoryHasEnoughBalanceValidation.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_wallet_impl.domain.validaiton.multisig + +import io.novafoundation.nova.common.validation.ValidationStatus +import io.novafoundation.nova.common.validation.validationError +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.FeeBase +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.repository.getMultisigDeposit +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidation +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationFailure +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationPayload +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationStatus +import io.novafoundation.nova.feature_account_api.data.multisig.validation.SignatoryFeePaymentMode +import io.novafoundation.nova.feature_account_api.data.multisig.validation.signatoryAccountId +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.accountBalanceForValidation +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.domain.validation.balance.BalanceValidationResult +import io.novafoundation.nova.feature_wallet_api.domain.validation.balance.beginValidation +import io.novafoundation.nova.feature_wallet_api.domain.validation.balance.toValidationStatus +import io.novafoundation.nova.feature_wallet_api.domain.validation.balance.tryReserve +import io.novafoundation.nova.feature_wallet_api.domain.validation.balance.tryWithdrawFee +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novasama.substrate_sdk_android.hash.isPositive + +class MultisigSignatoryHasEnoughBalanceValidation( + private val assetSourceRegistry: AssetSourceRegistry, + private val extrinsicService: ExtrinsicService, + private val multisigValidationsRepository: MultisigValidationsRepository, +) : MultisigExtrinsicValidation { + + override suspend fun validate(value: MultisigExtrinsicValidationPayload): ValidationStatus { + val chain = value.chain + val chainAsset = chain.utilityAsset + + val fee = calculateFeeForMode(value) + val deposit = determineNeededDeposit(value) + + var balanceValidation = assetSourceRegistry.sourceFor(chainAsset).balance + .accountBalanceForValidation(value.chain, chainAsset, value.signatoryAccountId()) + .legacyAdapter() // Multisig pallet still uses legacy logic to calculate balances + .beginValidation() + + if (fee != null) { + balanceValidation = balanceValidation.tryWithdrawFee(fee) + } + + if (deposit != null) { + balanceValidation = balanceValidation.tryReserve(deposit) + } + + return balanceValidation.toValidationStatus { prepareError(value, fee, deposit, it) } + } + + private suspend fun calculateFeeForMode(payload: MultisigExtrinsicValidationPayload): Fee? { + return when (payload.signatoryFeePaymentMode) { + SignatoryFeePaymentMode.NothingToPay -> null + + is SignatoryFeePaymentMode.PaysSubmissionFee -> calculateFee(payload) + } + } + + private suspend fun determineNeededDeposit(payload: MultisigExtrinsicValidationPayload): Balance? { + return multisigValidationsRepository.getMultisigDeposit(payload.chain.id, payload.multisig.threshold) + .takeIf { it.isPositive() } + } + + private fun prepareError( + payload: MultisigExtrinsicValidationPayload, + fee: FeeBase?, + deposit: Balance?, + balanceError: BalanceValidationResult.Failure + ): MultisigExtrinsicValidationStatus { + return MultisigExtrinsicValidationFailure.NotEnoughSignatoryBalance( + signatory = payload.signatory, + asset = payload.chain.utilityAsset, + fee = fee?.amount, + deposit = deposit, + balanceToAdd = balanceError.negativeImbalance.value + ).validationError() + } + + private suspend fun calculateFee(value: MultisigExtrinsicValidationPayload): Fee { + return extrinsicService.estimateFee(value.chain, value.multisig.intoOrigin()) { + call(value.callInsideAsMulti) + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/multisig/RealMultisigExtrinsicValidationFactory.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/multisig/RealMultisigExtrinsicValidationFactory.kt new file mode 100644 index 0000000..f9f47c7 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/domain/validaiton/multisig/RealMultisigExtrinsicValidationFactory.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_wallet_impl.domain.validaiton.multisig + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService +import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.data.multisig.validation.MultisigExtrinsicValidationBuilder +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.domain.validation.MultisigExtrinsicValidationFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import javax.inject.Inject + +@FeatureScope +class RealMultisigExtrinsicValidationFactory @Inject constructor( + private val assetSourceRegistry: AssetSourceRegistry, + private val extrinsicService: ExtrinsicService, + private val multisigValidationsRepository: MultisigValidationsRepository, + private val chainRegistry: ChainRegistry, +) : MultisigExtrinsicValidationFactory { + + context(MultisigExtrinsicValidationBuilder) + override fun multisigSignatoryHasEnoughBalance() { + validate(MultisigSignatoryHasEnoughBalanceValidation(assetSourceRegistry, extrinsicService, multisigValidationsRepository)) + } + + context(MultisigExtrinsicValidationBuilder) + override fun noPendingMultisigWithSameCallData() { + validate(MultisigNoPendingCallHashValidation(chainRegistry, multisigValidationsRepository)) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/WalletRouter.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/WalletRouter.kt new file mode 100644 index 0000000..9982961 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/WalletRouter.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.feature_wallet_impl.presentation + +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload + +interface WalletRouter { + + fun openSendCrossChain(destination: AssetPayload, recipientAddress: String?) + + fun openReceive(assetPayload: AssetPayload) + + fun openBuyToken(chainId: String, assetId: Int) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/common/fieldValidation/RealEnoughAmountFieldValidator.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/common/fieldValidation/RealEnoughAmountFieldValidator.kt new file mode 100644 index 0000000..417d697 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/common/fieldValidation/RealEnoughAmountFieldValidator.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.feature_wallet_impl.presentation.common.fieldValidation + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.isZero +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountFieldValidator +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.actualAmount +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class RealEnoughAmountValidatorFactory( + private val resourceManager: ResourceManager +) : EnoughAmountValidatorFactory { + + override fun create( + maxAvailableProvider: MaxActionProvider, + errorMessageRes: Int + ): EnoughAmountFieldValidator { + return RealEnoughAmountFieldValidator(resourceManager, maxAvailableProvider, errorMessageRes) + } +} + +class RealEnoughAmountFieldValidator( + private val resourceManager: ResourceManager, + private val maxAvailableProvider: MaxActionProvider, + private val errorMessageRes: Int, +) : EnoughAmountFieldValidator { + + override fun observe(inputStream: Flow): Flow { + return combine(inputStream, maxAvailableProvider.maxAvailableBalance) { input, maxAvailable -> + val inputAmount = input.toBigDecimalOrNull() ?: return@combine FieldValidationResult.Ok + val maxAvailableAmount = maxAvailable.actualAmount + + when { + maxAvailableAmount.isZero || maxAvailableAmount < inputAmount -> FieldValidationResult.Error( + reason = resourceManager.getString(errorMessageRes), + tag = EnoughAmountFieldValidator.ERROR_TAG + ) + + else -> FieldValidationResult.Ok + } + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/common/fieldValidation/RealMinAmountFieldValidator.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/common/fieldValidation/RealMinAmountFieldValidator.kt new file mode 100644 index 0000000..7f1142b --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/common/fieldValidation/RealMinAmountFieldValidator.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_wallet_impl.presentation.common.fieldValidation + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.validation.FieldValidationResult +import io.novafoundation.nova.feature_wallet_api.presentation.common.MinAmountProvider +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidator +import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.MinAmountFieldValidatorFactory +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.TokenFormatter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first + +class RealMinAmountFieldValidatorFactory( + private val resourceManager: ResourceManager, + private val tokenFormatter: TokenFormatter, +) : MinAmountFieldValidatorFactory { + + override fun create( + chainAsset: Flow, + minAmountProvider: MinAmountProvider, + errorMessageRes: Int + ): MinAmountFieldValidator { + return RealMinAmountFieldValidator(resourceManager, chainAsset, tokenFormatter, minAmountProvider, errorMessageRes) + } +} + +class RealMinAmountFieldValidator( + private val resourceManager: ResourceManager, + private val chainAssetFlow: Flow, + private val tokenFormatter: TokenFormatter, + private val minAmountProvider: MinAmountProvider, + private val errorMessageRes: Int, +) : MinAmountFieldValidator { + + override fun observe(inputStream: Flow): Flow { + return combine(inputStream, minAmountProvider.provideMinAmount()) { input, minAmount -> + val inputAmount = input.toBigDecimalOrNull() ?: return@combine FieldValidationResult.Ok + + when { + minAmount > inputAmount -> { + val chainAsset = chainAssetFlow.first() + FieldValidationResult.Error( + reason = resourceManager.getString(errorMessageRes, tokenFormatter.formatToken(minAmount, chainAsset.symbol)), + tag = MinAmountFieldValidator.ERROR_TAG + ) + } + + else -> FieldValidationResult.Ok + } + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/formatters/RealAssetModelFormatter.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/formatters/RealAssetModelFormatter.kt new file mode 100644 index 0000000..11bebbd --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/formatters/RealAssetModelFormatter.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.feature_wallet_impl.presentation.formatters + +import io.novafoundation.nova.common.presentation.AssetIconProvider +import io.novafoundation.nova.common.presentation.getAssetIconOrFallback +import io.novafoundation.nova.common.presentation.masking.MaskableModel +import io.novafoundation.nova.common.presentation.masking.map +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.AssetModelFormatter +import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetModel +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class RealAssetModelFormatter( + private val assetIconProvider: AssetIconProvider, + private val resourceManager: ResourceManager +) : AssetModelFormatter { + override suspend fun formatAsset( + chainAsset: Chain.Asset, + balance: MaskableModel, + patternId: Int? + ): AssetModel { + val icon = assetIconProvider.getAssetIconOrFallback(chainAsset.icon) + val formattedAmount = balance.map { it.formatPlanks(chainAsset) } + .map { amount -> patternId?.let { resourceManager.getString(patternId, amount) } ?: amount } + + return AssetModel( + chainId = chainAsset.chainId, + chainAssetId = chainAsset.id, + icon = icon, + tokenName = chainAsset.name, + tokenSymbol = chainAsset.symbol.value, + assetBalance = formattedAmount + ) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/getAsset/RealGetAssetOptionsMixin.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/getAsset/RealGetAssetOptionsMixin.kt new file mode 100644 index 0000000..0ea52ac --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/presentation/getAsset/RealGetAssetOptionsMixin.kt @@ -0,0 +1,128 @@ +package io.novafoundation.nova.feature_wallet_impl.presentation.getAsset + +import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin +import io.novafoundation.nova.common.presentation.DescriptiveButtonState +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.launchUnit +import io.novafoundation.nova.common.utils.shareInBackground +import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_account_api.domain.model.addressIn +import io.novafoundation.nova.feature_wallet_api.R +import io.novafoundation.nova.feature_wallet_api.domain.AssetGetOptionsUseCase +import io.novafoundation.nova.feature_wallet_api.domain.model.GetAssetOption +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetBottomSheet +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin +import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload +import io.novafoundation.nova.feature_wallet_impl.presentation.WalletRouter +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +class RealGetAssetOptionsMixinFactory( + private val assetGetOptionsUseCase: AssetGetOptionsUseCase, + private val walletRouter: WalletRouter, + private val resourceManager: ResourceManager, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val chainRegistry: ChainRegistry, + private val actionAwaitableFactory: ActionAwaitableMixin.Factory, +) : GetAssetOptionsMixin.Factory { + + override fun create( + assetFlow: Flow, + scope: CoroutineScope, + additionalButtonFilter: Flow + ): GetAssetOptionsMixin { + return RealGetAssetOptionsMixin( + assetFlow, + assetGetOptionsUseCase, + walletRouter, + resourceManager, + selectedAccountUseCase, + chainRegistry, + actionAwaitableFactory, + additionalButtonFilter, + scope + ) + } +} + +class RealGetAssetOptionsMixin( + private val assetFlow: Flow, + private val assetGetOptionsUseCase: AssetGetOptionsUseCase, + private val walletRouter: WalletRouter, + private val resourceManager: ResourceManager, + private val selectedAccountUseCase: SelectedAccountUseCase, + private val chainRegistry: ChainRegistry, + actionAwaitableFactory: ActionAwaitableMixin.Factory, + additionalButtonFilter: Flow, + scope: CoroutineScope +) : GetAssetOptionsMixin, CoroutineScope by scope { + + private val getAssetOptionsFlow = assetGetOptionsUseCase.observeAssetGetOptionsForSelectedAccount(assetFlow) + .shareInBackground() + + override val getAssetOptionsButtonState = combine( + assetFlow.filterNotNull(), + getAssetOptionsFlow, + additionalButtonFilter + ) { assetIn, getAssetInOptions, shouldShownButton -> + if (shouldShownButton && getAssetInOptions.isNotEmpty()) { + val symbol = assetIn.symbol + DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_get_token_format, symbol)) + } else { + DescriptiveButtonState.Gone + } + } + .onStart { emit(DescriptiveButtonState.Gone) } + .distinctUntilChanged() + .shareInBackground() + + override val observeGetAssetAction = actionAwaitableFactory.create() + + override fun openAssetOptions() = launchUnit { + val assetIn = assetFlow.first() ?: return@launchUnit + val availableOptions = getAssetOptionsFlow.first() + + val payload = GetAssetBottomSheet.Payload( + chainAsset = assetIn, + availableOptions = availableOptions + ) + + val selectedOption = observeGetAssetAction.awaitAction(payload) + onAssetOptionSelected(selectedOption) + } + + private fun onAssetOptionSelected(option: GetAssetOption) { + when (option) { + GetAssetOption.RECEIVE -> receiveSelected() + GetAssetOption.CROSS_CHAIN -> onCrossChainTransferSelected() + GetAssetOption.BUY -> buySelected() + } + } + + private fun onCrossChainTransferSelected() = launch { + val chainAssetIn = assetFlow.first() ?: return@launch + val assetInChain = chainRegistry.getChain(chainAssetIn.chainId) + + val currentAddress = selectedAccountUseCase.getSelectedMetaAccount().addressIn(assetInChain) + + walletRouter.openSendCrossChain(AssetPayload(chainAssetIn.chainId, chainAssetIn.id), currentAddress) + } + + private fun buySelected() = launch { + val chainAssetIn = assetFlow.first() ?: return@launch + walletRouter.openBuyToken(chainAssetIn.chainId, chainAssetIn.id) + } + + private fun receiveSelected() = launch { + val chainAssetIn = assetFlow.first() ?: return@launch + walletRouter.openReceive(AssetPayload(chainAssetIn.chainId, chainAssetIn.id)) + } +} diff --git a/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/integration/Common.kt b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/integration/Common.kt new file mode 100644 index 0000000..2d57873 --- /dev/null +++ b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/integration/Common.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.integration + +import io.novasama.substrate_sdk_android.wsrpc.logging.Logger + +class StdoutLogger : Logger { + override fun log(message: String?) { + println(message) + } + + override fun log(throwable: Throwable?) { + throwable?.printStackTrace() + } +} + +class NoOpLogger: Logger { + override fun log(message: String?) { + // pass + } + + override fun log(throwable: Throwable?) { + // pass + } +} diff --git a/feature-xcm/api/.gitignore b/feature-xcm/api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-xcm/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-xcm/api/build.gradle b/feature-xcm/api/build.gradle new file mode 100644 index 0000000..115c5f7 --- /dev/null +++ b/feature-xcm/api/build.gradle @@ -0,0 +1,26 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_xcm_api' + + + defaultConfig { + + + } +} + +dependencies { + implementation coroutinesDep + implementation project(':runtime') + implementation project(":common") + + implementation daggerDep + ksp daggerCompiler + + api substrateSdkDep + + api project(':core-api') + + testImplementation project(":test-shared") +} \ No newline at end of file diff --git a/feature-xcm/api/consumer-rules.pro b/feature-xcm/api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-xcm/api/proguard-rules.pro b/feature-xcm/api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-xcm/api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-xcm/api/src/main/AndroidManifest.xml b/feature-xcm/api/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-xcm/api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/Encoding.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/Encoding.kt new file mode 100644 index 0000000..6bcee8b --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/Encoding.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_xcm_api.asset + +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.feature_xcm_api.multiLocation.bindMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcm +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.bindVersionedXcm + +fun bindVersionedLocatableMultiAsset(decoded: Any?): VersionedXcm { + return bindVersionedXcm(decoded, ::bindLocatableMultiAsset) +} + +fun bindLocatableMultiAsset(decoded: Any?, xcmVersion: XcmVersion): LocatableMultiAsset { + val asStruct = decoded.castToStruct() + + return LocatableMultiAsset( + location = bindMultiLocation(asStruct["location"]), + assetId = bindMultiAssetId(asStruct["asset_id"], xcmVersion) + ) +} + +fun bindMultiAssetId(decoded: Any?, xcmVersion: XcmVersion): MultiAssetId { + // V4 removed variants of MultiAssetId, leaving only flattened value of Concrete + val locationInstance = if (xcmVersion >= XcmVersion.V4) { + decoded + } else { + extractConcreteLocation(decoded) + } + + return MultiAssetId(bindMultiLocation(locationInstance)) +} + +private fun extractConcreteLocation(decoded: Any?): Any? { + val variant = decoded.castToDictEnum() + require(variant.name == "Concrete") { + "Asset ids besides Concrete are not supported" + } + + return variant.value +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/LocatableMultiAsset.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/LocatableMultiAsset.kt new file mode 100644 index 0000000..6a02904 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/LocatableMultiAsset.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_xcm_api.asset + +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcm + +class LocatableMultiAsset( + + val location: RelativeMultiLocation, + + val assetId: MultiAssetId +) + +typealias VersionedLocatableMultiAsset = VersionedXcm diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/MultiAsset.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/MultiAsset.kt new file mode 100644 index 0000000..c5f0548 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/MultiAsset.kt @@ -0,0 +1,114 @@ +package io.novafoundation.nova.feature_xcm_api.asset + +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.utils.scale.ToDynamicScaleInstance +import io.novafoundation.nova.common.utils.structOf +import io.novafoundation.nova.feature_xcm_api.versions.VersionedToDynamicScaleInstance +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcm +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.bindVersionedXcm +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import java.math.BigInteger + +data class MultiAsset private constructor( + val id: MultiAssetId, + val fungibility: Fungibility, +) : VersionedToDynamicScaleInstance { + + companion object { + + fun bind(decodedInstance: Any?, xcmVersion: XcmVersion): MultiAsset { + val asStruct = decodedInstance.castToStruct() + return MultiAsset( + id = bindMultiAssetId(asStruct["id"], xcmVersion), + fungibility = Fungibility.bind(asStruct["fun"]) + ) + } + + fun from( + multiLocation: RelativeMultiLocation, + amount: BalanceOf + ): MultiAsset { + // Substrate doesn't allow zero balance starting from xcm v3 + val positiveAmount = amount.coerceAtLeast(BigInteger.ONE) + + return MultiAsset( + id = MultiAssetId(multiLocation), + fungibility = Fungibility.Fungible(positiveAmount) + ) + } + } + + sealed class Fungibility : ToDynamicScaleInstance { + + companion object { + + fun bind(decodedInstance: Any?): Fungibility { + val asEnum = decodedInstance.castToDictEnum() + + return when (asEnum.name) { + "Fungible" -> Fungible(bindNumber(asEnum.value)) + else -> incompatible() + } + } + } + + data class Fungible(val amount: BalanceOf) : Fungibility() { + + override fun toEncodableInstance(): Any { + return DictEnum.Entry(name = "Fungible", value = amount) + } + } + } + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return structOf( + "fun" to fungibility.toEncodableInstance(), + "id" to id.toEncodableInstance(xcmVersion) + ) + } +} + +fun MultiAsset.requireFungible(): MultiAsset.Fungibility.Fungible { + return fungibility.cast() +} + +@JvmInline +value class MultiAssets(val value: List) : VersionedToDynamicScaleInstance { + + companion object { + + fun bind(decodedInstance: Any?, xcmVersion: XcmVersion): MultiAssets { + val assets = bindList(decodedInstance) { MultiAsset.bind(it, xcmVersion) } + return MultiAssets(assets) + } + + fun bindVersioned(decodedInstance: Any?): VersionedMultiAssets { + return bindVersionedXcm(decodedInstance, MultiAssets::bind) + } + } + + constructor(vararg assets: MultiAsset) : this(assets.toList()) + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return value.map { it.toEncodableInstance(xcmVersion) } + } +} + +fun List.intoMultiAssets(): MultiAssets { + return MultiAssets(this) +} + +fun MultiAsset.intoMultiAssets(): MultiAssets { + return MultiAssets(listOf(this)) +} + +typealias VersionedMultiAsset = VersionedXcm +typealias VersionedMultiAssets = VersionedXcm diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/MultiAssetFilter.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/MultiAssetFilter.kt new file mode 100644 index 0000000..287cead --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/MultiAssetFilter.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.feature_xcm_api.asset + +import io.novafoundation.nova.feature_xcm_api.versions.VersionedToDynamicScaleInstance +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum + +sealed class MultiAssetFilter : VersionedToDynamicScaleInstance { + + companion object { + + fun singleCounted(): Wild.AllCounted { + return Wild.AllCounted(assetsCount = 1) + } + } + + class Definite(val assets: MultiAssets) : MultiAssetFilter() { + + constructor(asset: MultiAsset) : this(asset.intoMultiAssets()) + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any? { + return DictEnum.Entry("Definite", assets.toEncodableInstance(xcmVersion)) + } + } + + sealed class Wild : MultiAssetFilter() { + + /** + * Filter to use all assets from the holding register + * + * !!! Important !!! + * Weight of this instruction is bounded by maximum number of assets usable per instruction, + * which can be 100 in some runtimes. + * This might result in sever overestimation of instruction weight and thus, the fee. + * + * Please use other variations that put explicit desired bound like [AllCounted] whenever possible + */ + object All : Wild() { + + override fun toString(): String { + return "All" + } + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "Wild", + value = DictEnum.Entry( + name = "All", + value = null + ) + ) + } + } + + /** + * Filter to use first [assetsCount] assets from the holding register + */ + data class AllCounted(val assetsCount: Int) : Wild() { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "Wild", + value = DictEnum.Entry( + name = "AllCounted", + value = assetsCount.toBigInteger() + ) + ) + } + } + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/MultiAssetId.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/MultiAssetId.kt new file mode 100644 index 0000000..3f75984 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/asset/MultiAssetId.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_xcm_api.asset + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.VersionedToDynamicScaleInstance +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum + +@JvmInline +value class MultiAssetId(val multiLocation: RelativeMultiLocation) : VersionedToDynamicScaleInstance { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any? { + // V4 removed variants of MultiAssetId, leaving only flattened value of Concrete + return if (xcmVersion >= XcmVersion.V4) { + multiLocation.toEncodableInstance(xcmVersion) + } else { + DictEnum.Entry( + name = "Concrete", + value = multiLocation.toEncodableInstance(xcmVersion) + ) + } + } +} + +fun MultiAssetId.withAmount(amount: BalanceOf): MultiAsset { + return MultiAsset.from(multiLocation, amount) +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/XcmBuilder.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/XcmBuilder.kt new file mode 100644 index 0000000..ef5529d --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/XcmBuilder.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_xcm_api.builder + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter.Wild.All +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetId +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets +import io.novafoundation.nova.feature_xcm_api.asset.intoMultiAssets +import io.novafoundation.nova.feature_xcm_api.asset.withAmount +import io.novafoundation.nova.feature_xcm_api.builder.fees.MeasureXcmFees +import io.novafoundation.nova.feature_xcm_api.builder.fees.PayFeesMode +import io.novafoundation.nova.feature_xcm_api.builder.fees.UnsupportedMeasureXcmFees +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.AssetLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit + +interface XcmBuilder : XcmContext { + + interface Factory { + + fun create( + initial: ChainLocation, + xcmVersion: XcmVersion, + measureXcmFees: MeasureXcmFees + ): XcmBuilder + } + + fun payFees(payFeesMode: PayFeesMode) + + fun withdrawAsset(assets: MultiAssets) + + fun buyExecution(fees: MultiAsset, weightLimit: WeightLimit) + + // We only support depositing to a accountId. We might extend it in the future with no issues + // but we keep the support limited to simplify implementation + fun depositAsset(assets: MultiAssetFilter, beneficiary: AccountIdKey) + + // Performs context change + fun transferReserveAsset(assets: MultiAssets, dest: ChainLocation) + + // Performs context change + fun initiateReserveWithdraw(assets: MultiAssetFilter, reserve: ChainLocation) + + // Performs context change + fun depositReserveAsset(assets: MultiAssetFilter, dest: ChainLocation) + + fun initiateTeleport(assets: MultiAssetFilter, dest: ChainLocation) + + suspend fun build(): VersionedXcmMessage +} + +/** + * Can be used when `payFees` is not expected to be used + */ +fun XcmBuilder.Factory.createWithoutFeesMeasurement( + initial: ChainLocation, + xcmVersion: XcmVersion, +): XcmBuilder { + return create(initial, xcmVersion, UnsupportedMeasureXcmFees()) +} + +suspend fun XcmBuilder.Factory.buildXcmWithoutFeesMeasurement( + initial: ChainLocation, + xcmVersion: XcmVersion, + building: XcmBuilder.() -> Unit +): VersionedXcmMessage { + return createWithoutFeesMeasurement(initial, xcmVersion) + .apply(building) + .build() +} + +fun XcmBuilder.withdrawAsset(asset: AbsoluteMultiLocation, amount: BalanceOf) { + withdrawAsset(MultiAsset.from(asset.relativeToLocal(), amount).intoMultiAssets()) +} + +fun XcmBuilder.transferReserveAsset(asset: AbsoluteMultiLocation, amount: BalanceOf, dest: ChainLocation) { + transferReserveAsset(MultiAsset.from(asset.relativeToLocal(), amount).intoMultiAssets(), dest) +} + +fun XcmBuilder.buyExecution(asset: AbsoluteMultiLocation, amount: BalanceOf, weightLimit: WeightLimit) { + buyExecution(MultiAsset.from(asset.relativeToLocal(), amount), weightLimit) +} + +fun XcmBuilder.depositAllAssetsTo(beneficiary: AccountIdKey) { + depositAsset(All, beneficiary) +} + +fun XcmBuilder.payFeesIn(assetId: AssetLocation) { + payFees(PayFeesMode.Measured(assetId)) +} + +fun XcmBuilder.payFees(assetId: MultiAssetId, exactFees: BalanceOf) { + payFees(PayFeesMode.Exact(assetId.withAmount(exactFees))) +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/XcmContext.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/XcmContext.kt new file mode 100644 index 0000000..e8d5788 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/XcmContext.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_xcm_api.builder + +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion + +interface XcmContext { + + val xcmVersion: XcmVersion + + val currentLocation: ChainLocation +} + +fun XcmContext.localViewOf(location: AbsoluteMultiLocation): RelativeMultiLocation { + return location.fromPointOfViewOf(currentLocation.location) +} + +context(XcmContext) +fun AbsoluteMultiLocation.relativeToLocal(): RelativeMultiLocation { + return localViewOf(this) +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/fees/MeasureXcmFees.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/fees/MeasureXcmFees.kt new file mode 100644 index 0000000..cf6569c --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/fees/MeasureXcmFees.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_xcm_api.builder.fees + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.multiLocation.AssetLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation + +/** + * Measure fees for a given xcm message. Used by [XcmBuilder] when processing [XcmBuilder.payFees] + * with [PayFeesMode.Measured] specified + */ +interface MeasureXcmFees { + + suspend fun measureFees( + message: VersionedXcmMessage, + feeAsset: AssetLocation, + chainLocation: ChainLocation, + ): BalanceOf +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/fees/PayFeesMode.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/fees/PayFeesMode.kt new file mode 100644 index 0000000..729ed39 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/fees/PayFeesMode.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_xcm_api.builder.fees + +import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.multiLocation.AssetLocation + +/** + * Specifies how [XcmBuilder] should specify fees for PayFees instruction + */ +sealed class PayFeesMode { + + /** + * Fees should be measured when building the xcm by calling provided [MeasureXcmFees] implementation + */ + class Measured(val feeAssetId: AssetLocation) : PayFeesMode() + + /** + * Should use exactly [fee] when specifying fees + */ + class Exact(val fee: MultiAsset) : PayFeesMode() +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/fees/UnsupportedMeasureXcmFees.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/fees/UnsupportedMeasureXcmFees.kt new file mode 100644 index 0000000..59e038f --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/builder/fees/UnsupportedMeasureXcmFees.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.feature_xcm_api.builder.fees + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.multiLocation.AssetLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation + +class UnsupportedMeasureXcmFees : MeasureXcmFees { + override suspend fun measureFees( + message: VersionedXcmMessage, + feeAsset: AssetLocation, + chainLocation: ChainLocation + ): BalanceOf { + error("Measurement not supported") + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/chain/XcmChain.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/chain/XcmChain.kt new file mode 100644 index 0000000..b73008f --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/chain/XcmChain.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_xcm_api.chain + +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId.Companion.JUNCTION_TYPE_PARACHAIN +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId.Companion.JUNCTION_TYPE_TEYRCHAIN +import io.novafoundation.nova.feature_xcm_api.multiLocation.chainLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +// Pezkuwi chain IDs - these chains use "Teyrchain" instead of "Parachain" in XCM +private val PEZKUWI_CHAIN_IDS = setOf( + "bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75", // PEZKUWI + "00d0e1d0581c3cd5c5768652d52f4520184018b44f56a2ae1e0dc9d65c00c948", // PEZKUWI_ASSET_HUB + "58269e9c184f721e0309332d90cafc410df1519a5dc27a5fd9b3bf5fd2d129f8" // PEZKUWI_PEOPLE +) + +class XcmChain( + val parachainId: BigInteger?, + val chain: Chain +) + +fun XcmChain.absoluteLocation(): AbsoluteMultiLocation { + val junctionTypeName = if (chain.id in PEZKUWI_CHAIN_IDS) JUNCTION_TYPE_TEYRCHAIN else JUNCTION_TYPE_PARACHAIN + return AbsoluteMultiLocation.chainLocation(parachainId, junctionTypeName) +} + +fun XcmChain.isRelay(): Boolean { + return parachainId == null +} + +fun XcmChain.isSystemChain(): Boolean { + return parachainId != null && parachainId.toInt() in 1000 until 2000 +} + +fun XcmChain.chainLocation(): ChainLocation { + return ChainLocation(chain.id, absoluteLocation()) +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/MultiLocationConverter.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/MultiLocationConverter.kt new file mode 100644 index 0000000..881be53 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/MultiLocationConverter.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_xcm_api.converter + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation + +interface MultiLocationConverter { + + suspend fun toMultiLocation(chainAsset: Chain.Asset): RelativeMultiLocation? + + suspend fun toChainAsset(multiLocation: RelativeMultiLocation): Chain.Asset? +} + +suspend fun MultiLocationConverter.toMultiLocationOrThrow(chainAsset: Chain.Asset): RelativeMultiLocation { + return toMultiLocation(chainAsset) ?: error("Failed to convert asset location") +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/MultiLocationConverterFactory.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/MultiLocationConverterFactory.kt new file mode 100644 index 0000000..f0a0378 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/MultiLocationConverterFactory.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.feature_xcm_api.converter + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.CoroutineScope + +interface MultiLocationConverterFactory { + + fun defaultAsync(chain: Chain, coroutineScope: CoroutineScope): MultiLocationConverter + + suspend fun defaultSync(chain: Chain): MultiLocationConverter + + suspend fun resolveLocalAssets(chain: Chain): MultiLocationConverter +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/chain/ChainMultiLocationConverter.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/chain/ChainMultiLocationConverter.kt new file mode 100644 index 0000000..9e1eb2c --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/chain/ChainMultiLocationConverter.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_xcm_api.converter.chain + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation + +interface ChainMultiLocationConverter { + + suspend fun toChain(multiLocation: RelativeMultiLocation): Chain? +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/chain/ChainMultiLocationConverterFactory.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/chain/ChainMultiLocationConverterFactory.kt new file mode 100644 index 0000000..675d84f --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/converter/chain/ChainMultiLocationConverterFactory.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_xcm_api.converter.chain + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface ChainMultiLocationConverterFactory { + + fun resolveSelfAndChildrenParachains(self: Chain): ChainMultiLocationConverter +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/di/XcmFeatureApi.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/di/XcmFeatureApi.kt new file mode 100644 index 0000000..1d36e08 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/di/XcmFeatureApi.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_xcm_api.di + +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.DryRunApi +import io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment.XcmPaymentApi +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector + +interface XcmFeatureApi { + + val assetMultiLocationConverterFactory: MultiLocationConverterFactory + + val chainMultiLocationConverterFactory: ChainMultiLocationConverterFactory + + val xcmVersionDetector: XcmVersionDetector + + val dryRunApi: DryRunApi + + val xcmPaymentApi: XcmPaymentApi + + val xcmBuilderFactory: XcmBuilder.Factory +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/extrinsic/XcmCalls.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/extrinsic/XcmCalls.kt new file mode 100644 index 0000000..38cacc9 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/extrinsic/XcmCalls.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.feature_xcm_api.extrinsic + +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.composeCall +import io.novafoundation.nova.common.utils.xcmPalletName +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +context(RuntimeContext) +fun composeXcmExecute( + message: VersionedXcmMessage, + maxWeight: WeightV2, +): GenericCall.Instance { + return composeCall( + moduleName = runtime.metadata.xcmPalletName(), + callName = "execute", + arguments = mapOf( + "message" to message.toEncodableInstance(), + "max_weight" to maxWeight.toEncodableInstance() + ) + ) +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/message/XcmMessage.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/message/XcmMessage.kt new file mode 100644 index 0000000..ec57c50 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/message/XcmMessage.kt @@ -0,0 +1,188 @@ +package io.novafoundation.nova.feature_xcm_api.message + +import io.novafoundation.nova.common.utils.structOf +import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.VersionedToDynamicScaleInstance +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcm +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm +import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import java.math.BigInteger + +@JvmInline +value class XcmMessage(val instructions: List) : VersionedToDynamicScaleInstance { + + constructor(vararg instructions: XcmInstruction) : this(instructions.toList()) + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any? { + return instructions.map { it.toEncodableInstance(xcmVersion) } + } +} + +fun List.asXcmMessage(): XcmMessage = XcmMessage(this) + +fun List.asVersionedXcmMessage(version: XcmVersion): VersionedXcmMessage = asXcmMessage().versionedXcm(version) + +sealed class XcmInstruction : VersionedToDynamicScaleInstance { + + data class WithdrawAsset(val assets: MultiAssets) : XcmInstruction() { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any? { + return DictEnum.Entry( + name = "WithdrawAsset", + value = assets.toEncodableInstance(xcmVersion) + ) + } + } + + data class DepositAsset( + val assets: MultiAssetFilter, + val beneficiary: RelativeMultiLocation + ) : XcmInstruction() { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any? { + return DictEnum.Entry( + name = "DepositAsset", + value = structOf( + "assets" to assets.toEncodableInstance(xcmVersion), + "beneficiary" to beneficiary.toEncodableInstance(xcmVersion), + // Used in XCM V2 and below. We put 1 here since we only support cases for transferring a single asset + "max_assets" to BigInteger.ONE, + ) + ) + } + } + + data class BuyExecution(val fees: MultiAsset, val weightLimit: WeightLimit) : XcmInstruction() { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "BuyExecution", + value = structOf( + "fees" to fees.toEncodableInstance(xcmVersion), + // xcm v2 always uses v1 weights + "weight_limit" to weightLimit.toEncodableInstance() + ) + ) + } + } + + object ClearOrigin : XcmInstruction() { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "ClearOrigin", + value = null + ) + } + } + + data class ReserveAssetDeposited(val assets: MultiAssets) : XcmInstruction() { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "ReserveAssetDeposited", + value = assets.toEncodableInstance(xcmVersion) + ) + } + } + + data class ReceiveTeleportedAsset(val assets: MultiAssets) : XcmInstruction() { + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "ReceiveTeleportedAsset", + value = assets.toEncodableInstance(xcmVersion) + ) + } + } + + data class InitiateReserveWithdraw( + val assets: MultiAssetFilter, + val reserve: RelativeMultiLocation, + val xcm: XcmMessage + ) : XcmInstruction() { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "InitiateReserveWithdraw", + value = structOf( + "assets" to assets.toEncodableInstance(xcmVersion), + "reserve" to reserve.toEncodableInstance(xcmVersion), + "xcm" to xcm.toEncodableInstance(xcmVersion) + ) + ) + } + } + + data class TransferReserveAsset( + val assets: MultiAssets, + val dest: RelativeMultiLocation, + val xcm: XcmMessage + ) : XcmInstruction() { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "TransferReserveAsset", + value = structOf( + "assets" to assets.toEncodableInstance(xcmVersion), + "dest" to dest.toEncodableInstance(xcmVersion), + "xcm" to xcm.toEncodableInstance(xcmVersion) + ) + ) + } + } + + data class DepositReserveAsset( + val assets: MultiAssetFilter, + val dest: RelativeMultiLocation, + val xcm: XcmMessage + ) : XcmInstruction() { + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "DepositReserveAsset", + value = structOf( + "assets" to assets.toEncodableInstance(xcmVersion), + // Used in XCM V2 and below. We put 1 here since we only support cases for transferring a single asset + "max_assets" to BigInteger.ONE, + "dest" to dest.toEncodableInstance(xcmVersion), + "xcm" to xcm.toEncodableInstance(xcmVersion) + ) + ) + } + } + + data class PayFees(val fees: MultiAsset) : XcmInstruction() { + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "PayFees", + value = structOf( + "fees" to fees.toEncodableInstance(xcmVersion) + ) + ) + } + } + + data class InitiateTeleport( + val assets: MultiAssetFilter, + val dest: RelativeMultiLocation, + val xcm: XcmMessage + ) : XcmInstruction() { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return DictEnum.Entry( + name = "InitiateTeleport", + value = structOf( + "assets" to assets.toEncodableInstance(xcmVersion), + "dest" to dest.toEncodableInstance(xcmVersion), + "xcm" to xcm.toEncodableInstance(xcmVersion) + ) + ) + } + } +} + +typealias VersionedXcmMessage = VersionedXcm diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/message/XcmMessageRaw.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/message/XcmMessageRaw.kt new file mode 100644 index 0000000..e94b6a0 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/message/XcmMessageRaw.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_xcm_api.message + +import io.novafoundation.nova.common.utils.scale.DynamicScaleInstance +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcm +import io.novafoundation.nova.feature_xcm_api.versions.bindVersionedXcm + +typealias XcmMessageRaw = DynamicScaleInstance +typealias VersionedRawXcmMessage = VersionedXcm + +fun bindVersionedRawXcmMessage(decodedInstance: Any?) = bindVersionedXcm(decodedInstance) { inner, _ -> + DynamicScaleInstance(inner) +} + +fun bindRawXcmMessage(decodedInstance: Any?) = DynamicScaleInstance(decodedInstance) diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/AbsoluteLocation.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/AbsoluteLocation.kt new file mode 100644 index 0000000..54a3d08 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/AbsoluteLocation.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_xcm_api.multiLocation + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.utils.collectionIndexOrNull + +class AbsoluteMultiLocation( + interior: Interior, +) : MultiLocation(interior) { + + companion object; + + constructor(vararg junctions: Junction) : this(junctions.toList().toInterior()) + + fun toRelative(): RelativeMultiLocation { + return RelativeMultiLocation(parents = 0, interior = interior) + } + + /** + * Reanchor given location to a point of view of given `pov` location + * Basic algorithm idea: + * We find the last common ancestor and consider the target location to be "up to ancestor and down to self": + * 1. Find last common ancestor between `this` and `pov` + * 2. Use all junctions after common ancestor as result junctions + * 3. Use difference between len(target.junctions) and common_ancestor_idx + * to determine how many "up" hops are needed to reach common ancestor + */ + fun fromPointOfViewOf(pov: AbsoluteMultiLocation): RelativeMultiLocation { + val lastCommonIndex = findLastCommonJunctionIndex(pov) + val firstDistinctIndex = lastCommonIndex?.let { it + 1 } ?: 0 + + val parents = pov.junctions.size - firstDistinctIndex + val junctions = junctions.drop(firstDistinctIndex) + + return RelativeMultiLocation(parents, junctions.toInterior()) + } + + private fun findLastCommonJunctionIndex(other: AbsoluteMultiLocation): Int? { + return junctions.zip(other.junctions).indexOfLast { (selfJunction, otherJunction) -> + selfJunction == otherJunction + }.collectionIndexOrNull() + } +} + +fun AbsoluteMultiLocation.Companion.chainLocation( + parachainId: ParaId?, + junctionTypeName: String = MultiLocation.Junction.ParachainId.JUNCTION_TYPE_PARACHAIN +): AbsoluteMultiLocation { + return listOfNotNull(parachainId?.let { MultiLocation.Junction.ParachainId(it, junctionTypeName) }).asLocation() +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/AssetLocation.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/AssetLocation.kt new file mode 100644 index 0000000..1c3d77f --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/AssetLocation.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_xcm_api.multiLocation + +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +class AssetLocation( + val assetId: FullChainAssetId, + val location: AbsoluteMultiLocation +) + +fun AssetLocation.multiAssetIdOn(chainLocation: ChainLocation): MultiAssetId { + val relativeMultiLocation = location.fromPointOfViewOf(chainLocation.location) + return MultiAssetId(relativeMultiLocation) +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/ChainLocation.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/ChainLocation.kt new file mode 100644 index 0000000..ef6e3c5 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/ChainLocation.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.feature_xcm_api.multiLocation + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class ChainLocation( + val chainId: ChainId, + val location: AbsoluteMultiLocation +) diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/MultiLocation.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/MultiLocation.kt new file mode 100644 index 0000000..afdaa97 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/MultiLocation.kt @@ -0,0 +1,167 @@ +package io.novafoundation.nova.feature_xcm_api.multiLocation + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.utils.HexString +import io.novafoundation.nova.common.utils.isAscending +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull +import java.math.BigInteger + +abstract class MultiLocation( + open val interior: Interior +) { + + sealed class Interior { + + object Here : Interior() + + class Junctions(junctions: List) : Interior() { + val junctions = junctions.sorted() + + override fun equals(other: Any?): Boolean { + if (other !is Junctions) return false + return junctions == other.junctions + } + + override fun hashCode(): Int { + return junctions.hashCode() + } + + override fun toString(): String { + return junctions.toString() + } + } + } + + sealed class Junction { + + data class ParachainId( + val id: ParaId, + val junctionTypeName: String = JUNCTION_TYPE_PARACHAIN + ) : Junction() { + + constructor(id: Int, junctionTypeName: String = JUNCTION_TYPE_PARACHAIN) : this(id.toBigInteger(), junctionTypeName) + + companion object { + const val JUNCTION_TYPE_PARACHAIN = "Parachain" + const val JUNCTION_TYPE_TEYRCHAIN = "Teyrchain" + } + } + + data class GeneralKey(val key: HexString) : Junction() + + data class PalletInstance(val index: BigInteger) : Junction() + + data class GeneralIndex(val index: BigInteger) : Junction() + + data class AccountKey20(val accountId: AccountIdKey) : Junction() + + data class AccountId32(val accountId: AccountIdKey) : Junction() + + data class GlobalConsensus(val networkId: NetworkId) : Junction() + + object Unsupported : Junction() + } + + sealed class NetworkId { + + data class Substrate(val genesisHash: ChainId) : NetworkId() + + data class Ethereum(val chainId: Int) : NetworkId() + } +} + +val Junction.order + get() = when (this) { + is Junction.GlobalConsensus -> 0 + + is Junction.ParachainId -> 1 + + // All of these are on the same "level" - mutually exhaustive + is Junction.PalletInstance, + is Junction.AccountKey20, + is Junction.AccountId32 -> 2 + + is Junction.GeneralKey, + is Junction.GeneralIndex -> 3 + + Junction.Unsupported -> Int.MAX_VALUE + } + +val MultiLocation.junctions: List + get() = when (val interior = interior) { + MultiLocation.Interior.Here -> emptyList() + is MultiLocation.Interior.Junctions -> interior.junctions + } + +fun List.toInterior() = when (size) { + 0 -> MultiLocation.Interior.Here + else -> MultiLocation.Interior.Junctions(this) +} + +fun Junction.toInterior() = MultiLocation.Interior.Junctions(listOf(this)) + +fun MultiLocation.Interior.isHere() = this is MultiLocation.Interior.Here + +fun MultiLocation.accountId(): AccountIdKey? { + return junctions.tryFindNonNull { + when (it) { + is Junction.AccountId32 -> it.accountId + is Junction.AccountKey20 -> it.accountId + else -> null + } + } +} + +fun MultiLocation.Interior.asLocation(): AbsoluteMultiLocation { + return AbsoluteMultiLocation(this) +} + +fun List.asLocation(): AbsoluteMultiLocation { + return toInterior().asLocation() +} + +fun Junction.asLocation(): AbsoluteMultiLocation { + return toInterior().asLocation() +} + +operator fun RelativeMultiLocation.plus(suffix: RelativeMultiLocation): RelativeMultiLocation { + require(suffix.parents == 0) { + "Appending multi location that has parents is not supported" + } + + val newJunctions = junctions + suffix.junctions + require(newJunctions.isAscending(compareBy { it.order })) { + "Cannot append this multi location due to conflicting junctions" + } + + return RelativeMultiLocation( + parents = parents, + interior = newJunctions.toInterior() + ) +} + +fun AccountIdKey.toMultiLocation() = RelativeMultiLocation( + parents = 0, + interior = Junctions( + when (value.size) { + 32 -> Junction.AccountId32(this) + 20 -> Junction.AccountKey20(this) + else -> throw IllegalArgumentException("Unsupported account id length: ${value.size}") + } + ) +) + +fun Junctions(vararg junctions: Junction) = MultiLocation.Interior.Junctions(junctions.toList()) + +fun MultiLocation.paraIdOrNull(): ParaId? { + return junctions.filterIsInstance() + .firstOrNull() + ?.id +} + +private fun List.sorted(): List { + return sortedBy(Junction::order) +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/MultiLocationEncoding.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/MultiLocationEncoding.kt new file mode 100644 index 0000000..4d32399 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/MultiLocationEncoding.kt @@ -0,0 +1,211 @@ +package io.novafoundation.nova.feature_xcm_api.multiLocation + +import android.util.Log +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId +import io.novafoundation.nova.common.data.network.runtime.binding.bindByteArray +import io.novafoundation.nova.common.data.network.runtime.binding.bindInt +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.HexString +import io.novafoundation.nova.common.utils.padEnd +import io.novafoundation.nova.common.utils.structOf +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.NetworkId +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcm +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.bindVersionedXcm +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.ext.Ids +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.encrypt.json.copyBytes +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct + +// ------ Decode ------ + +fun bindMultiLocation(instance: Any?): RelativeMultiLocation { + val asStruct = instance.castToStruct() + + return RelativeMultiLocation( + parents = bindInt(asStruct["parents"]), + interior = bindInterior((asStruct["interior"])) + ) +} + +fun bindVersionedMultiLocation(instance: Any?): VersionedXcm { + return bindVersionedXcm(instance) { inner, _ -> bindMultiLocation(inner) } +} + +private fun bindInterior(instance: Any?): MultiLocation.Interior { + val asDictEnum = instance.castToDictEnum() + + return when (asDictEnum.name) { + "Here" -> MultiLocation.Interior.Here + + else -> { + val junctions = bindJunctions(asDictEnum.value) + MultiLocation.Interior.Junctions(junctions) + } + } +} + +private fun bindJunctions(instance: Any?): List { + // Note that Interior.X1 is encoded differently in XCM v3 (a single junction) and V4 (single-element list) + if (instance is List<*>) { + return bindList(instance, ::bindJunction) + } else { + return listOf(bindJunction(instance)) + } +} + +private fun bindJunction(instance: Any?): Junction { + val asDictEnum = instance.castToDictEnum() + + return when (asDictEnum.name) { + "GeneralKey" -> Junction.GeneralKey(bindGeneralKey(asDictEnum.value)) + "PalletInstance" -> Junction.PalletInstance(bindNumber(asDictEnum.value)) + // Accept both "Parachain" (Polkadot ecosystem) and "Teyrchain" (Pezkuwi ecosystem) + "Parachain", "Teyrchain" -> Junction.ParachainId(bindNumber(asDictEnum.value), asDictEnum.name) + "GeneralIndex" -> Junction.GeneralIndex(bindNumber(asDictEnum.value)) + "GlobalConsensus" -> bindGlobalConsensusJunction(asDictEnum.value) + "AccountKey20" -> Junction.AccountKey20(bindAccountIdJunction(asDictEnum.value, accountIdKey = "key")) + "AccountId32" -> Junction.AccountId32(bindAccountIdJunction(asDictEnum.value, accountIdKey = "id")) + + else -> Junction.Unsupported + } +} + +private fun bindGeneralKey(instance: Any?): HexString { + val keyBytes = if (instance is Struct.Instance) { + // v3+ structure + val keyLength = bindInt(instance["length"]) + val keyPadded = bindByteArray(instance["data"]) + + keyPadded.copyBytes(0, keyLength) + } else { + bindByteArray(instance) + } + + return keyBytes.toHexString(withPrefix = true) +} + +private fun bindAccountIdJunction(instance: Any?, accountIdKey: String): AccountIdKey { + val asStruct = instance.castToStruct() + + return bindAccountId(asStruct[accountIdKey]).intoKey() +} + +private fun bindGlobalConsensusJunction(instance: Any?): Junction { + val asDictEnum = instance.castToDictEnum() + + return when (asDictEnum.name) { + "ByGenesis" -> { + val genesis = bindByteArray(asDictEnum.value).toHexString(withPrefix = false) + Junction.GlobalConsensus(networkId = NetworkId.Substrate(genesis)) + } + + "Polkadot" -> Junction.GlobalConsensus(NetworkId.Substrate(Chain.Geneses.POLKADOT)) + "Kusama" -> Junction.GlobalConsensus(NetworkId.Substrate(Chain.Geneses.KUSAMA)) + "Westend" -> Junction.GlobalConsensus(NetworkId.Substrate(Chain.Geneses.WESTEND)) + "Ethereum" -> { + val chainId = bindInt(asDictEnum.value.castToStruct()["chain_id"]) + Junction.GlobalConsensus(NetworkId.Ethereum(chainId)) + } + else -> Junction.Unsupported + } +} + +// ------ Encode ------ + +internal fun RelativeMultiLocation.toEncodableInstanceExt(xcmVersion: XcmVersion) = structOf( + "parents" to parents.toBigInteger(), + "interior" to interior.toEncodableInstance(xcmVersion) +) + +private fun MultiLocation.Interior.toEncodableInstance(xcmVersion: XcmVersion) = when (this) { + MultiLocation.Interior.Here -> DictEnum.Entry("Here", null) + + is MultiLocation.Interior.Junctions -> if (junctions.size == 1 && xcmVersion <= XcmVersion.V3) { + // X1 is encoded as a single junction in V3 and prior + DictEnum.Entry( + name = "X1", + value = junctions.single().toEncodableInstance(xcmVersion) + ) + } else { + DictEnum.Entry( + name = "X${junctions.size}", + value = junctions.map { it.toEncodableInstance(xcmVersion) } + ) + } +} + +private fun Junction.toEncodableInstance(xcmVersion: XcmVersion) = when (this) { + is Junction.GeneralKey -> DictEnum.Entry("GeneralKey", encodableGeneralKey(xcmVersion, key)) + is Junction.PalletInstance -> DictEnum.Entry("PalletInstance", index) + is Junction.ParachainId -> { + Log.d("XCM_ENCODE", "Encoding ParachainId: id=$id, junctionTypeName=$junctionTypeName") + DictEnum.Entry(junctionTypeName, id) + } + is Junction.AccountKey20 -> DictEnum.Entry("AccountKey20", accountId.toJunctionAccountIdInstance(accountIdKey = "key", xcmVersion)) + is Junction.AccountId32 -> DictEnum.Entry("AccountId32", accountId.toJunctionAccountIdInstance(accountIdKey = "id", xcmVersion)) + is Junction.GeneralIndex -> DictEnum.Entry("GeneralIndex", index) + is Junction.GlobalConsensus -> toEncodableInstance() + Junction.Unsupported -> error("Unsupported junction") +} + +private fun encodableGeneralKey(xcmVersion: XcmVersion, generalKey: HexString): Any { + val bytes = generalKey.fromHex() + + return if (xcmVersion >= XcmVersion.V3) { + structOf( + "length" to bytes.size.toBigInteger(), + "data" to bytes.padEnd(expectedSize = 32, padding = 0) + ) + } else { + bytes + } +} + +private fun Junction.GlobalConsensus.toEncodableInstance(): Any { + val innerValue = when (networkId) { + is NetworkId.Ethereum -> networkId.toEncodableInstance() + is NetworkId.Substrate -> networkId.toEncodableInstance() + } + + return DictEnum.Entry("GlobalConsensus", innerValue) +} + +private fun NetworkId.Ethereum.toEncodableInstance(): Any { + return DictEnum.Entry("Ethereum", structOf("chain_id" to chainId.toBigInteger())) +} + +private fun NetworkId.Substrate.toEncodableInstance(): Any { + return when (genesisHash) { + Chain.Geneses.POLKADOT -> DictEnum.Entry("Polkadot", null) + Chain.Geneses.KUSAMA -> DictEnum.Entry("Kusama", null) + Chain.Geneses.WESTEND -> DictEnum.Entry("Westend", null) + Chain.Ids.ETHEREUM -> DictEnum.Entry("Ethereum", null) + else -> DictEnum.Entry("ByGenesis", genesisHash.fromHex()) + } +} + +private fun AccountIdKey.toJunctionAccountIdInstance(accountIdKey: String, xcmVersion: XcmVersion) = structOf( + "network" to emptyNetworkField(xcmVersion), + accountIdKey to value +) + +private fun emptyNetworkField(xcmVersion: XcmVersion): Any? { + return if (xcmVersion >= XcmVersion.V3) { + // Network in V3+ is encoded as Option + null + } else { + // Network in V2- is encoded as Enum with Any variant + DictEnum.Entry("Any", null) + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/RelativeMultiLocation.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/RelativeMultiLocation.kt new file mode 100644 index 0000000..44ade6f --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/multiLocation/RelativeMultiLocation.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_xcm_api.multiLocation + +import io.novafoundation.nova.feature_xcm_api.versions.VersionedToDynamicScaleInstance +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion + +data class RelativeMultiLocation( + val parents: Int, + override val interior: Interior +) : MultiLocation(interior), VersionedToDynamicScaleInstance { + + override fun toEncodableInstance(xcmVersion: XcmVersion): Any { + return toEncodableInstanceExt(xcmVersion) + } +} + +fun RelativeMultiLocation.isHere(): Boolean { + return parents == 0 && interior.isHere() +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/XcmPaymentApi.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/XcmPaymentApi.kt new file mode 100644 index 0000000..d471fee --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/XcmPaymentApi.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi + +import android.util.Log +import io.novafoundation.nova.common.data.network.runtime.binding.ScaleResult +import io.novafoundation.nova.common.data.network.runtime.binding.ScaleResultError +import io.novafoundation.nova.common.data.network.runtime.binding.toResult + +fun Result>.getInnerSuccessOrThrow(errorLogTag: String?): T { + return getOrThrow() + .toResult() + .onFailure { + Log.e(errorLogTag, "Xcm api call failed: ${(it as ScaleResultError).content}") + }.getOrThrow() +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/DryRunApi.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/DryRunApi.kt new file mode 100644 index 0000000..9c3b36e --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/DryRunApi.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun + +import io.novafoundation.nova.common.data.network.runtime.binding.ScaleResult +import io.novafoundation.nova.feature_xcm_api.message.VersionedRawXcmMessage +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.CallDryRunEffects +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.DryRunEffectsResultErr +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.XcmDryRunEffects +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcmLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +interface DryRunApi { + + suspend fun dryRunXcm( + originLocation: VersionedXcmLocation, + xcm: VersionedRawXcmMessage, + chainId: ChainId + ): Result> + + suspend fun dryRunCall( + originCaller: OriginCaller, + call: GenericCall.Instance, + xcmResultsVersion: XcmVersion, + chainId: ChainId + ): Result> +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/CallDryRunEffects.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/CallDryRunEffects.kt new file mode 100644 index 0000000..ba8d410 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/CallDryRunEffects.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model + +import io.novafoundation.nova.common.data.network.runtime.binding.ScaleResult +import io.novafoundation.nova.common.data.network.runtime.binding.bindEvent +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.feature_xcm_api.message.VersionedRawXcmMessage +import io.novafoundation.nova.feature_xcm_api.message.bindVersionedRawXcmMessage +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +class CallDryRunEffects( + val executionResult: ScaleResult, + override val emittedEvents: List, + // We don't need to fully decode/encode intermediate xcm messages + val localXcm: VersionedRawXcmMessage?, + override val forwardedXcms: ForwardedXcms +) : DryRunEffects { + + companion object { + + context(RuntimeContext) + fun bind(decodedInstance: Any?): CallDryRunEffects { + val asStruct = decodedInstance.castToStruct() + return CallDryRunEffects( + executionResult = ScaleResult.bind( + dynamicInstance = asStruct["executionResult"], + bindOk = DispatchPostInfo::bind, + bindError = { DispatchErrorWithPostInfo.bind(it) } + ), + emittedEvents = bindList(asStruct["emittedEvents"], ::bindEvent), + localXcm = asStruct.get("localXcm")?.let { bindVersionedRawXcmMessage(it) }, + forwardedXcms = bindForwardedXcms(asStruct["forwardedXcms"]) + ) + } + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DispatchErrorWithPostInfo.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DispatchErrorWithPostInfo.kt new file mode 100644 index 0000000..2393b41 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DispatchErrorWithPostInfo.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model + +import io.novafoundation.nova.common.data.network.runtime.binding.DispatchError +import io.novafoundation.nova.common.data.network.runtime.binding.bindDispatchError +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.RuntimeContext + +class DispatchErrorWithPostInfo( + val postInfo: DispatchPostInfo, + val error: DispatchError +) { + + companion object { + + context(RuntimeContext) + fun bind(decodedInstance: Any?): DispatchErrorWithPostInfo { + val asStruct = decodedInstance.castToStruct() + + return DispatchErrorWithPostInfo( + postInfo = DispatchPostInfo.bind(asStruct["post_info"]), + error = bindDispatchError(asStruct["error"]) + ) + } + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DispatchPostInfo.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DispatchPostInfo.kt new file mode 100644 index 0000000..d6111d5 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DispatchPostInfo.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model + +// Info not needed yet +object DispatchPostInfo { + + fun bind(decodedInstance: Any?): DispatchPostInfo { + return this + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DryRunEffects.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DryRunEffects.kt new file mode 100644 index 0000000..f4a266d --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DryRunEffects.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model + +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +interface DryRunEffects { + + val emittedEvents: List + + val forwardedXcms: ForwardedXcms +} + +fun DryRunEffects.senderXcmVersion(): XcmVersion { + // For referencing destination, dry run uses sender's xcm version + val firstForwarded = forwardedXcms.firstOrNull() + return firstForwarded?.first?.version ?: XcmVersion.GLOBAL_DEFAULT +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DryRunEffectsResultErr.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DryRunEffectsResultErr.kt new file mode 100644 index 0000000..ea0b7b0 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/DryRunEffectsResultErr.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model + +// Info not needed yet +object DryRunEffectsResultErr { + + fun bind(decodedInstance: Any?): DryRunEffectsResultErr { + return this + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/ForwardedXcms.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/ForwardedXcms.kt new file mode 100644 index 0000000..9e86d7d --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/ForwardedXcms.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.castToList +import io.novafoundation.nova.feature_xcm_api.message.VersionedRawXcmMessage +import io.novafoundation.nova.feature_xcm_api.message.bindVersionedRawXcmMessage +import io.novafoundation.nova.feature_xcm_api.multiLocation.bindVersionedMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcmLocation + +typealias ForwardedXcms = List>> + +fun bindForwardedXcms(decodedInstance: Any?): ForwardedXcms { + return bindList(decodedInstance) { inner -> + val (locationRaw, messagesRaw) = inner.castToList() + val messages = bindList(messagesRaw, ::bindVersionedRawXcmMessage) + val location = bindVersionedMultiLocation(locationRaw) + + location to messages + } +} +fun ForwardedXcms.getByLocation(location: VersionedXcmLocation): List { + return find { it.first == location }?.second.orEmpty() +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/OriginCaller.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/OriginCaller.kt new file mode 100644 index 0000000..b66123a --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/OriginCaller.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.utils.scale.ToDynamicScaleInstance +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum + +sealed class OriginCaller : ToDynamicScaleInstance { + + sealed class System : OriginCaller() { + + object Root : System() { + + override fun toEncodableInstance(): Any? { + return wrapInSystemDict(DictEnum.Entry("Root", null)) + } + } + + class Signed(val accountId: AccountIdKey) : System() { + + override fun toEncodableInstance(): Any? { + return wrapInSystemDict(DictEnum.Entry("Signed", accountId.value)) + } + } + + protected fun wrapInSystemDict(inner: Any): DictEnum.Entry<*> { + return DictEnum.Entry("system", inner) + } + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/XcmDryRunEffects.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/XcmDryRunEffects.kt new file mode 100644 index 0000000..775f98b --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/XcmDryRunEffects.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindEvent +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +class XcmDryRunEffects( + val executionResult: XcmOutcome, + override val emittedEvents: List, + override val forwardedXcms: ForwardedXcms +) : DryRunEffects { + + companion object { + + context(RuntimeContext) + fun bind(decodedInstance: Any?): XcmDryRunEffects { + val asStruct = decodedInstance.castToStruct() + return XcmDryRunEffects( + executionResult = XcmOutcome.bind(asStruct["executionResult"]), + emittedEvents = bindList(asStruct["emittedEvents"], ::bindEvent), + forwardedXcms = bindForwardedXcms(asStruct["forwardedXcms"]) + ) + } + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/XcmOutcome.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/XcmOutcome.kt new file mode 100644 index 0000000..ee1cb6a --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/dryRun/model/XcmOutcome.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model + +import io.novafoundation.nova.common.data.network.runtime.binding.Weight +import io.novafoundation.nova.common.data.network.runtime.binding.bindWeight +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.utils.scale.DynamicScaleInstance + +sealed class XcmOutcome { + + companion object { + + fun bind(decodedInstance: Any?): XcmOutcome { + val asEnum = decodedInstance.castToDictEnum() + val value = asEnum.value.castToStruct() + + return when (asEnum.name) { + "Complete" -> Complete( + used = bindWeight(value["used"]) + ) + + "Incomplete" -> Incomplete( + used = bindWeight(value["used"]), + error = DynamicScaleInstance(value["error"]) + ) + + "Error" -> Error( + error = DynamicScaleInstance(value["error"]) + ) + + else -> incompatible() + } + } + } + + class Complete(val used: Weight) : XcmOutcome() + + class Incomplete(val used: Weight, val error: DynamicScaleInstance) : XcmOutcome() + + class Error(val error: DynamicScaleInstance) : XcmOutcome() +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/xcmPayment/XcmPaymentApi.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/xcmPayment/XcmPaymentApi.kt new file mode 100644 index 0000000..5ed01a2 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/xcmPayment/XcmPaymentApi.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment + +import io.novafoundation.nova.common.data.network.runtime.binding.ScaleResult +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment.model.QueryXcmWeightErr +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface XcmPaymentApi { + + suspend fun queryXcmWeight( + chainId: ChainId, + xcm: VersionedXcmMessage, + ): Result> + + suspend fun isSupported(chainId: ChainId): Boolean +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/xcmPayment/model/QueryXcmWeightErr.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/xcmPayment/model/QueryXcmWeightErr.kt new file mode 100644 index 0000000..635b97e --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/runtimeApi/xcmPayment/model/QueryXcmWeightErr.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment.model + +// Info not needed yet +object QueryXcmWeightErr { + + fun bind(decodedInstance: Any?): QueryXcmWeightErr { + return this + } +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/VersionedToDynamicScaleInstance.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/VersionedToDynamicScaleInstance.kt new file mode 100644 index 0000000..76eaebd --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/VersionedToDynamicScaleInstance.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.feature_xcm_api.versions + +interface VersionedToDynamicScaleInstance { + + fun toEncodableInstance(xcmVersion: XcmVersion): Any? +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/VersionedXcm.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/VersionedXcm.kt new file mode 100644 index 0000000..b262417 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/VersionedXcm.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.feature_xcm_api.versions + +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.utils.scale.DynamicScaleInstance +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum + +data class VersionedXcm( + val xcm: T, + val version: XcmVersion +) + +@JvmName("toEncodableInstanceVersioned") +fun VersionedXcm.toEncodableInstance(): DictEnum.Entry<*> { + return DictEnum.Entry( + name = version.enumerationKey(), + value = xcm.toEncodableInstance(version) + ) +} + +fun VersionedXcm.toEncodableInstance(): DictEnum.Entry<*> { + return DictEnum.Entry( + name = version.enumerationKey(), + value = xcm.toEncodableInstance() + ) +} + +fun bindVersionedXcm(instance: Any?, inner: (Any?, xcmVersion: XcmVersion) -> T): VersionedXcm { + val versionEnum = instance.castToDictEnum() + val xcmVersion = XcmVersion.fromEnumerationKey(versionEnum.name) + + return VersionedXcm(inner(versionEnum.value, xcmVersion), xcmVersion) +} + +fun T.versionedXcm(xcmVersion: XcmVersion): VersionedXcm { + return VersionedXcm(this, xcmVersion) +} + +typealias VersionedXcmLocation = VersionedXcm diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/XcmVersion.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/XcmVersion.kt new file mode 100644 index 0000000..1b40be9 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/XcmVersion.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_xcm_api.versions + +enum class XcmVersion(val version: Int) { + V0(0), V1(1), V2(2), V3(3), V4(4), V5(5); + + companion object { + + fun fromVersion(version: Int): XcmVersion { + return values().find { it.version == version } + ?: error("Unknown xcm version: $version") + } + + val GLOBAL_DEFAULT = V2 + } +} + +// Return xcm version from a enumeration key in form of "V{version}" +fun XcmVersion.Companion.fromEnumerationKey(enumerationKey: String): XcmVersion { + val withoutPrefix = enumerationKey.removePrefix("V") + val version = withoutPrefix.toInt() + return fromVersion(version) +} + +fun XcmVersion?.orDefault(): XcmVersion { + return this ?: XcmVersion.GLOBAL_DEFAULT +} + +fun XcmVersion.enumerationKey(): String { + return "V$version" +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/detector/XcmVersionDetector.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/detector/XcmVersionDetector.kt new file mode 100644 index 0000000..b63d0b9 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/versions/detector/XcmVersionDetector.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.feature_xcm_api.versions.detector + +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.definitions.types.RuntimeType + +interface XcmVersionDetector { + + suspend fun lowestPresentMultiLocationVersion(chainId: ChainId): XcmVersion? + + suspend fun lowestPresentMultiAssetsVersion(chainId: ChainId): XcmVersion? + + suspend fun lowestPresentMultiAssetIdVersion(chainId: ChainId): XcmVersion? + + suspend fun lowestPresentMultiAssetVersion(chainId: ChainId): XcmVersion? + + suspend fun detectMultiLocationVersion(chainId: ChainId, multiLocationType: RuntimeType<*, *>?): XcmVersion? +} diff --git a/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/weight/WeightLimit.kt b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/weight/WeightLimit.kt new file mode 100644 index 0000000..a7f7290 --- /dev/null +++ b/feature-xcm/api/src/main/java/io/novafoundation/nova/feature_xcm_api/weight/WeightLimit.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.feature_xcm_api.weight + +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.utils.scale.ToDynamicScaleInstance +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum + +sealed class WeightLimit : ToDynamicScaleInstance { + + companion object { + + fun zero(): WeightLimit { + return Limited(WeightV2.zero()) + } + } + + object Unlimited : WeightLimit() { + + override fun toEncodableInstance(): Any? { + return DictEnum.Entry("Unlimited", null) + } + } + + class Limited(val weightV2: WeightV2) : WeightLimit() { + + override fun toEncodableInstance(): Any? { + return DictEnum.Entry("Limited", weightV2.toEncodableInstance()) + } + } +} diff --git a/feature-xcm/api/src/test/java/io/novafoundation/nova/feature_xcm_api/multiLocation/AbsoluteMultiLocationTest.kt b/feature-xcm/api/src/test/java/io/novafoundation/nova/feature_xcm_api/multiLocation/AbsoluteMultiLocationTest.kt new file mode 100644 index 0000000..f7c0150 --- /dev/null +++ b/feature-xcm/api/src/test/java/io/novafoundation/nova/feature_xcm_api/multiLocation/AbsoluteMultiLocationTest.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_xcm_api.multiLocation + +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Interior +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId +import org.junit.Assert.assertEquals +import org.junit.Test + + +class AbsoluteMultiLocationTest { + + @Test + fun `reanchor global pov should remain unchanged`() { + val initial = AbsoluteMultiLocation(ParachainId(1000)) + val pov = AbsoluteMultiLocation(Interior.Here) + val expected = initial.toRelative() + + val result = initial.fromPointOfViewOf(pov) + assertEquals(expected, result) + } + + @Test + fun `reanchor no common junctions`() { + val initial = AbsoluteMultiLocation(ParachainId(1000)) + val pov = AbsoluteMultiLocation(ParachainId(2000)) + val expected = RelativeMultiLocation(parents=1, interior = Junctions(ParachainId(1000))) + + val result = initial.fromPointOfViewOf(pov) + + assertEquals(expected, result) + } + + @Test + fun `reanchor one common junction`() { + val initial = AbsoluteMultiLocation(ParachainId(1000), ParachainId(2000)) + val pov = AbsoluteMultiLocation(ParachainId(1000), ParachainId(3000)) + val expected = RelativeMultiLocation(parents=1, interior = Junctions(ParachainId(2000))) + + val result = initial.fromPointOfViewOf(pov) + + assertEquals(expected, result) + } + + @Test + fun `reanchor all common junction`() { + val initial = AbsoluteMultiLocation(ParachainId(1000), ParachainId(2000)) + val pov = AbsoluteMultiLocation(ParachainId(1000), ParachainId(2000)) + val expected = RelativeMultiLocation(parents=0, interior = Interior.Here) + + val result = initial.fromPointOfViewOf(pov) + + assertEquals(expected, result) + } + + @Test + fun `reanchor global to global`() { + val initial = AbsoluteMultiLocation(Interior.Here) + val pov = AbsoluteMultiLocation(Interior.Here) + val expected = initial.toRelative() + + val result = initial.fromPointOfViewOf(pov) + + assertEquals(expected, result) + } + + @Test + fun `reanchor pov is successor of initial`() { + val initial = AbsoluteMultiLocation(Interior.Here) + val pov = AbsoluteMultiLocation(ParachainId(1000)) + val expected = RelativeMultiLocation(parents=1, interior = Interior.Here) + + val result = initial.fromPointOfViewOf(pov) + + assertEquals(expected, result) + } + + @Test + fun `reanchor initial is successor of pov`() { + val initial = AbsoluteMultiLocation(ParachainId(1000), ParachainId(2000)) + val pov = AbsoluteMultiLocation(ParachainId(1000)) + val expected = RelativeMultiLocation(parents=0, interior = Junctions(ParachainId(2000))) + + val result = initial.fromPointOfViewOf(pov) + + assertEquals(expected, result) + } +} diff --git a/feature-xcm/impl/.gitignore b/feature-xcm/impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature-xcm/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature-xcm/impl/build.gradle b/feature-xcm/impl/build.gradle new file mode 100644 index 0000000..c326d8c --- /dev/null +++ b/feature-xcm/impl/build.gradle @@ -0,0 +1,29 @@ +apply plugin: 'kotlin-parcelize' + +android { + namespace 'io.novafoundation.nova.feature_xcm_impl' + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation project(':common') + implementation project(':runtime') + api project(":feature-xcm:api") + + implementation kotlinDep + + implementation substrateSdkDep + + implementation daggerDep + ksp daggerCompiler + + testImplementation jUnitDep + testImplementation mockitoDep +} \ No newline at end of file diff --git a/feature-xcm/impl/consumer-rules.pro b/feature-xcm/impl/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature-xcm/impl/proguard-rules.pro b/feature-xcm/impl/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature-xcm/impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature-xcm/impl/src/main/AndroidManifest.xml b/feature-xcm/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature-xcm/impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/builder/RealXcmBuilder.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/builder/RealXcmBuilder.kt new file mode 100644 index 0000000..94a607f --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/builder/RealXcmBuilder.kt @@ -0,0 +1,194 @@ +package io.novafoundation.nova.feature_xcm_impl.builder + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetId +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets +import io.novafoundation.nova.feature_xcm_api.asset.withAmount +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.builder.fees.MeasureXcmFees +import io.novafoundation.nova.feature_xcm_api.builder.fees.PayFeesMode +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction +import io.novafoundation.nova.feature_xcm_api.message.XcmMessage +import io.novafoundation.nova.feature_xcm_api.message.asVersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.message.asXcmMessage +import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.AssetLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.multiAssetIdOn +import io.novafoundation.nova.feature_xcm_api.multiLocation.toMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm +import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit + +internal class RealXcmBuilder( + initialLocation: ChainLocation, + override val xcmVersion: XcmVersion, + private val measureXcmFees: MeasureXcmFees, +) : XcmBuilder { + + override var currentLocation: ChainLocation = initialLocation + + private val previousContexts: MutableList = mutableListOf() + private val currentLocationInstructions: MutableList = mutableListOf() + + override fun payFees(payFeesMode: PayFeesMode) { + currentLocationInstructions.add(PendingInstruction.PayFees(payFeesMode)) + } + + override fun withdrawAsset(assets: MultiAssets) { + addRegularInstruction(XcmInstruction.WithdrawAsset(assets)) + } + + override fun buyExecution(fees: MultiAsset, weightLimit: WeightLimit) { + addRegularInstruction(XcmInstruction.BuyExecution(fees, weightLimit)) + } + + override fun depositAsset(assets: MultiAssetFilter, beneficiary: AccountIdKey) { + addRegularInstruction(XcmInstruction.DepositAsset(assets, beneficiary.toMultiLocation())) + } + + override fun transferReserveAsset(assets: MultiAssets, dest: ChainLocation) { + performContextSwitch(dest) { forwardedMessage, forwardingFrom -> + XcmInstruction.TransferReserveAsset(assets, dest.location.fromPointOfViewOf(forwardingFrom), forwardedMessage) + } + } + + override fun initiateReserveWithdraw(assets: MultiAssetFilter, reserve: ChainLocation) { + performContextSwitch(reserve) { forwardedMessage, forwardingFrom -> + XcmInstruction.InitiateReserveWithdraw(assets, reserve.location.fromPointOfViewOf(forwardingFrom), forwardedMessage) + } + } + + override fun depositReserveAsset(assets: MultiAssetFilter, dest: ChainLocation) { + performContextSwitch(dest) { forwardedMessage, forwardingFrom -> + XcmInstruction.DepositReserveAsset(assets, dest.location.fromPointOfViewOf(forwardingFrom), forwardedMessage) + } + } + + override fun initiateTeleport(assets: MultiAssetFilter, dest: ChainLocation) { + performContextSwitch(dest) { forwardedMessage, forwardingFrom -> + XcmInstruction.InitiateTeleport(assets, dest.location.fromPointOfViewOf(forwardingFrom), forwardedMessage) + } + } + + override suspend fun build(): VersionedXcmMessage { + val lastMessage = createXcmMessage(currentLocationInstructions, currentLocation) + + return previousContexts.foldRight(lastMessage) { context, forwardedMessage -> + createXcmMessage(context, forwardedMessage) + }.versionedXcm(xcmVersion) + } + + private fun addRegularInstruction(instruction: XcmInstruction) { + currentLocationInstructions.add(PendingInstruction.Regular(instruction)) + } + + private suspend fun createXcmMessage( + pendingContextInstructions: PendingContextInstructions, + forwardedMessage: XcmMessage + ): XcmMessage { + val switchInstruction = pendingContextInstructions.contextSwitch(forwardedMessage, pendingContextInstructions.chainLocation.location) + val allInstructions = pendingContextInstructions.instructions + PendingInstruction.Regular(switchInstruction) + + return createXcmMessage(allInstructions, pendingContextInstructions.chainLocation) + } + + private suspend fun createXcmMessage( + pendingInstructions: List, + chainLocation: ChainLocation, + ): XcmMessage { + return pendingInstructions.map { pendingInstruction -> + pendingInstruction.constructSubmissionInstruction(pendingInstructions, chainLocation) + }.asXcmMessage() + } + + private suspend fun PendingInstruction.constructSubmissionInstruction( + allInstructions: List, + chainLocation: ChainLocation, + ): XcmInstruction { + return when (this) { + is PendingInstruction.Regular -> instruction + is PendingInstruction.PayFees -> constructSubmissionInstruction(allInstructions, chainLocation) + } + } + + private suspend fun PendingInstruction.PayFees.constructSubmissionInstruction( + allInstructions: List, + chainLocation: ChainLocation, + ): XcmInstruction.PayFees { + val fees = when (val mode = mode) { + is PayFeesMode.Exact -> mode.fee + is PayFeesMode.Measured -> measureFees(allInstructions, mode.feeAssetId, chainLocation) + } + + return XcmInstruction.PayFees(fees) + } + + private suspend fun measureFees( + allInstructions: List, + feeAssetIdLocation: AssetLocation, + chainLocation: ChainLocation, + ): MultiAsset { + val feeAssetId = feeAssetIdLocation.multiAssetIdOn(chainLocation) + + val messageForEstimation = allInstructions.map { pendingInstruction -> + pendingInstruction.constructEstimationInstruction(feeAssetId) + }.asVersionedXcmMessage(xcmVersion) + + require(chainLocation.chainId == feeAssetIdLocation.assetId.chainId) { + """ + Supplied fee asset does not belong to the current chain. + Expected: ${chainLocation.chainId} + Got: ${feeAssetIdLocation.assetId.chainId} (Asset id: ${feeAssetIdLocation.assetId.assetId}) + """.trimIndent() + } + + val measuredFees = measureXcmFees.measureFees(messageForEstimation, feeAssetIdLocation, chainLocation) + + return feeAssetId.withAmount(measuredFees) + } + + private fun PendingInstruction.constructEstimationInstruction(feeAssetId: MultiAssetId): XcmInstruction { + return when (this) { + is PendingInstruction.Regular -> instruction + is PendingInstruction.PayFees -> constructEstimationInstruction(feeAssetId) + } + } + + private fun PendingInstruction.PayFees.constructEstimationInstruction(feeAssetId: MultiAssetId): XcmInstruction.PayFees { + val fees = when (val mode = mode) { + is PayFeesMode.Exact -> mode.fee + is PayFeesMode.Measured -> feeAssetId.withAmount(BalanceOf.ONE) // Use fake amount in pay fees instruction for fee estimation + } + + return XcmInstruction.PayFees(fees) + } + + private fun performContextSwitch(newLocation: ChainLocation, switch: PendingContextSwitch) { + val instructionsInCurrentContext = currentLocationInstructions.toList() + val pendingContextInstructions = PendingContextInstructions(instructionsInCurrentContext, currentLocation, switch) + + previousContexts.add(pendingContextInstructions) + currentLocationInstructions.clear() + currentLocation = newLocation + } + + private class PendingContextInstructions( + val instructions: List, + val chainLocation: ChainLocation, + val contextSwitch: PendingContextSwitch + ) + + private sealed class PendingInstruction { + + class Regular(val instruction: XcmInstruction) : PendingInstruction() + + class PayFees(val mode: PayFeesMode) : PendingInstruction() + } +} + +private typealias PendingContextSwitch = (forwardedXcm: XcmMessage, forwardingFrom: AbsoluteMultiLocation) -> XcmInstruction diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/builder/RealXcmBuilderFactory.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/builder/RealXcmBuilderFactory.kt new file mode 100644 index 0000000..cb7d506 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/builder/RealXcmBuilderFactory.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.feature_xcm_impl.builder + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.builder.fees.MeasureXcmFees +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import javax.inject.Inject + +@FeatureScope +internal class RealXcmBuilderFactory @Inject constructor() : XcmBuilder.Factory { + + override fun create( + initial: ChainLocation, + xcmVersion: XcmVersion, + measureXcmFees: MeasureXcmFees + ): XcmBuilder { + return RealXcmBuilder(initial, xcmVersion, measureXcmFees) + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/CompoundMultiLocationConverter.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/CompoundMultiLocationConverter.kt new file mode 100644 index 0000000..ef0efb3 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/CompoundMultiLocationConverter.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.feature_xcm_impl.converter + +import io.novafoundation.nova.common.utils.tryFindNonNull +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +internal class CompoundMultiLocationConverter( + private vararg val delegates: MultiLocationConverter +) : MultiLocationConverter { + + override suspend fun toMultiLocation(chainAsset: Chain.Asset): RelativeMultiLocation? { + return delegates.tryFindNonNull { it.toMultiLocation(chainAsset) } + } + + override suspend fun toChainAsset(multiLocation: RelativeMultiLocation): Chain.Asset? { + return delegates.tryFindNonNull { it.toChainAsset(multiLocation) } + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/ForeignAssetsLocationConverter.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/ForeignAssetsLocationConverter.kt new file mode 100644 index 0000000..71aaa6e --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/ForeignAssetsLocationConverter.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.feature_xcm_impl.converter + +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.bindMultiLocation +import io.novafoundation.nova.common.utils.lazyAsync +import io.novafoundation.nova.common.utils.toHexUntypedOrNull +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.feature_xcm_api.versions.orDefault +import io.novafoundation.nova.runtime.ext.requireStatemine +import io.novafoundation.nova.runtime.ext.statemineOrNull +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.StatemineAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.asScaleEncodedOrThrow +import io.novafoundation.nova.runtime.multiNetwork.chain.model.isScaleEncoded +import io.novafoundation.nova.runtime.multiNetwork.chain.model.prepareIdForEncoding +import io.novafoundation.nova.runtime.multiNetwork.chain.model.statemineAssetIdScaleType +import io.novasama.substrate_sdk_android.runtime.definitions.types.RuntimeType + +private typealias ScaleEncodedMultiLocation = String +private typealias ForeignAssetsAssetId = ScaleEncodedMultiLocation +private typealias ForeignAssetsMappingKey = ForeignAssetsAssetId // we only allow one pallet so no need to include pallet name into key +private typealias ForeignAssetsMapping = Map + +private const val FOREIGN_ASSETS_PALLET_NAME = "ForeignAssets" + +internal class ForeignAssetsLocationConverter( + private val chain: Chain, + private val runtime: RuntimeSource, + private val xcmVersionDetector: XcmVersionDetector, +) : MultiLocationConverter { + + private val assetIdToAssetMapping by lazy { constructAssetIdToAssetMapping() } + + private var assetIdEncodingContext = lazyAsync { constructAssetIdEncodingContext() } + + override suspend fun toMultiLocation(chainAsset: Chain.Asset): RelativeMultiLocation? { + if (chainAsset.chainId != chain.id) return null + + return chainAsset.extractMultiLocation() + } + + override suspend fun toChainAsset(multiLocation: RelativeMultiLocation): Chain.Asset? { + val (xcmVersion, assetIdType) = assetIdEncodingContext.get() ?: return null + + val encodableInstance = multiLocation.toEncodableInstance(xcmVersion) + val multiLocationHex = assetIdType.toHexUntypedOrNull(runtime.getRuntime(), encodableInstance) ?: return null + + return assetIdToAssetMapping[multiLocationHex] + } + + private fun constructAssetIdToAssetMapping(): ForeignAssetsMapping { + return chain.assets + .filter { + val type = it.type + type is Chain.Asset.Type.Statemine && + type.palletName == FOREIGN_ASSETS_PALLET_NAME && + type.id is StatemineAssetId.ScaleEncoded + } + .associateBy { statemineAsset -> + val assetsType = statemineAsset.requireStatemine() + assetsType.id.asScaleEncodedOrThrow() + } + } + + private suspend fun Chain.Asset.extractMultiLocation(): RelativeMultiLocation? { + val assetsType = statemineOrNull() ?: return null + if (!assetsType.id.isScaleEncoded()) return null + + return runCatching { + val encodableMultiLocation = assetsType.prepareIdForEncoding(runtime.getRuntime()) + bindMultiLocation(encodableMultiLocation) + }.getOrNull() + } + + private suspend fun constructAssetIdEncodingContext(): AssetIdEncodingContext? { + val assetIdType = statemineAssetIdScaleType( + runtime.getRuntime(), + FOREIGN_ASSETS_PALLET_NAME + ) ?: return null + val xcmVersion = xcmVersionDetector.detectMultiLocationVersion(chain.id, assetIdType).orDefault() + + return AssetIdEncodingContext(xcmVersion, assetIdType) + } + + private data class AssetIdEncodingContext( + val xcmVersion: XcmVersion, + val assetIdType: RuntimeType<*, *> + ) +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/LocalAssetsLocationConverter.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/LocalAssetsLocationConverter.kt new file mode 100644 index 0000000..71d5fe1 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/LocalAssetsLocationConverter.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_xcm_impl.converter + +import io.novafoundation.nova.common.utils.PalletName +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.multiLocation.Junctions +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.junctions +import io.novafoundation.nova.runtime.ext.palletNameOrDefault +import io.novafoundation.nova.runtime.ext.requireStatemine +import io.novafoundation.nova.runtime.ext.statemineOrNull +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.StatemineAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.asNumberOrNull +import io.novafoundation.nova.runtime.multiNetwork.chain.model.asNumberOrThrow +import io.novasama.substrate_sdk_android.runtime.metadata.moduleOrNull +import java.math.BigInteger + +private typealias LocalAssetsAssetId = BigInteger +private typealias LocalAssetsMappingKey = Pair +private typealias LocalAssetsMapping = Map + +class LocalAssetsLocationConverter( + private val chain: Chain, + private val runtimeSource: RuntimeSource +) : MultiLocationConverter { + + private val assetIdToAssetMapping by lazy { constructAssetIdToAssetMapping() } + + override suspend fun toMultiLocation(chainAsset: Chain.Asset): RelativeMultiLocation? { + if (chainAsset.chainId != chain.id) return null + + val assetsType = chainAsset.statemineOrNull() ?: return null + // LocalAssets converter only supports number ids to use as GeneralIndex + val index = assetsType.id.asNumberOrNull() ?: return null + val pallet = runtimeSource.getRuntime().metadata.moduleOrNull(assetsType.palletNameOrDefault()) ?: return null + + return RelativeMultiLocation( + parents = 0, // For Local Assets chain serves as a reserve + interior = Junctions( + MultiLocation.Junction.PalletInstance(pallet.index), + MultiLocation.Junction.GeneralIndex(index) + ) + ) + } + + override suspend fun toChainAsset(multiLocation: RelativeMultiLocation): Chain.Asset? { + // We only consider local reserves for LocalAssets + if (multiLocation.parents > 0) return null + + val junctions = multiLocation.junctions + if (junctions.size != 2) return null + + val (maybePalletInstance, maybeGeneralIndex) = junctions + if (maybePalletInstance !is MultiLocation.Junction.PalletInstance || maybeGeneralIndex !is MultiLocation.Junction.GeneralIndex) return null + + val pallet = runtimeSource.getRuntime().metadata.moduleOrNull(maybePalletInstance.index.toInt()) ?: return null + val assetId = maybeGeneralIndex.index + + return assetIdToAssetMapping[pallet.name to assetId] + } + + private fun constructAssetIdToAssetMapping(): LocalAssetsMapping { + return chain.assets + .filter { + val type = it.type + type is Chain.Asset.Type.Statemine && type.id is StatemineAssetId.Number + } + .associateBy { statemineAsset -> + val assetsType = statemineAsset.requireStatemine() + val palletName = assetsType.palletNameOrDefault() + + palletName to assetsType.id.asNumberOrThrow() + } + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/NativeAssetLocationConverter.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/NativeAssetLocationConverter.kt new file mode 100644 index 0000000..b110bc8 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/NativeAssetLocationConverter.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_xcm_impl.converter + +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.isHere +import io.novafoundation.nova.runtime.ext.isUtilityAsset +import io.novafoundation.nova.runtime.ext.relaychainAsNative +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +internal class NativeAssetLocationConverter( + private val chain: Chain, +) : MultiLocationConverter { + + override suspend fun toMultiLocation(chainAsset: Chain.Asset): RelativeMultiLocation? { + return if (chainAsset.chainId == chain.id && chainAsset.isUtilityAsset) { + RelativeMultiLocation( + parents = chain.expectedParentsInNativeInterior(), + interior = MultiLocation.Interior.Here + ) + } else { + null + } + } + + override suspend fun toChainAsset(multiLocation: RelativeMultiLocation): Chain.Asset? { + return if (chain.isNativeMultiLocation(multiLocation)) { + chain.utilityAsset + } else { + null + } + } + + private fun Chain.expectedParentsInNativeInterior(): Int { + return if (additional.relaychainAsNative()) 1 else 0 + } + + private fun Chain.isNativeMultiLocation(multiLocation: RelativeMultiLocation): Boolean { + return multiLocation.interior.isHere() && multiLocation.parents == expectedParentsInNativeInterior() + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/RealMultiLocationConverterFactory.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/RealMultiLocationConverterFactory.kt new file mode 100644 index 0000000..e5ef952 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/RealMultiLocationConverterFactory.kt @@ -0,0 +1,53 @@ +package io.novafoundation.nova.feature_xcm_impl.converter + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import javax.inject.Inject + +@FeatureScope +class RealMultiLocationConverterFactory @Inject constructor( + private val chainRegistry: ChainRegistry, + private val xcmVersionDetector: XcmVersionDetector, +) : MultiLocationConverterFactory { + + override fun defaultAsync(chain: Chain, coroutineScope: CoroutineScope): MultiLocationConverter { + val runtimeAsync = coroutineScope.async { chainRegistry.getRuntime(chain.id) } + val runtimeSource = RuntimeSource.Async(runtimeAsync) + + return CompoundMultiLocationConverter( + NativeAssetLocationConverter(chain), + LocalAssetsLocationConverter(chain, runtimeSource), + ForeignAssetsLocationConverter(chain, runtimeSource, xcmVersionDetector) + ) + } + + override suspend fun defaultSync(chain: Chain): MultiLocationConverter { + val runtimeAsync = chainRegistry.getRuntime(chain.id) + val runtimeSource = RuntimeSource.Sync(runtimeAsync) + + return CompoundMultiLocationConverter( + NativeAssetLocationConverter(chain), + LocalAssetsLocationConverter(chain, runtimeSource), + ForeignAssetsLocationConverter(chain, runtimeSource, xcmVersionDetector) + ) + } + + override suspend fun resolveLocalAssets(chain: Chain): MultiLocationConverter { + val runtime = chainRegistry.getRuntime(chain.id) + + return CompoundMultiLocationConverter( + NativeAssetLocationConverter(chain), + LocalAssetsLocationConverter( + chain, + RuntimeSource.Sync(runtime) + ), + ) + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/RuntimeSource.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/RuntimeSource.kt new file mode 100644 index 0000000..764fd7b --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/RuntimeSource.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.feature_xcm_impl.converter + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.Deferred + +sealed interface RuntimeSource { + + suspend fun getRuntime(): RuntimeSnapshot + + class Sync(private val runtimeSnapshot: RuntimeSnapshot) : RuntimeSource { + + override suspend fun getRuntime(): RuntimeSnapshot { + return runtimeSnapshot + } + } + + class Async(private val runtimeSnapshotAsync: Deferred) : RuntimeSource { + + override suspend fun getRuntime(): RuntimeSnapshot { + return runtimeSnapshotAsync.await() + } + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/ChildParachainLocationConverter.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/ChildParachainLocationConverter.kt new file mode 100644 index 0000000..0be1f27 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/ChildParachainLocationConverter.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.feature_xcm_impl.converter.chain + +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.junctions +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getChainOrNull +import java.math.BigInteger + +internal class ChildParachainLocationConverter( + private val relayChain: Chain, + private val chainRegistry: ChainRegistry +) : ChainMultiLocationConverter { + + private val parachainIdToChainIdByRelay = mapOf( + Chain.Geneses.POLKADOT to mapOf( + 1000 to Chain.Geneses.POLKADOT_ASSET_HUB + ) + ) + + override suspend fun toChain(multiLocation: RelativeMultiLocation): Chain? { + // This is not a child parachain from relay point + if (multiLocation.parents != 0) return null + + val junctions = multiLocation.junctions + // Child parachain has only 1 ParachainId junction + if (junctions.size != 1) return null + val parachainId = junctions.single() as? ParachainId ?: return null + + val parachainChainId = getParachainChainId(parachainId.id) ?: return null + + return chainRegistry.getChainOrNull(parachainChainId) + } + + private fun getParachainChainId(parachainId: BigInteger): ChainId? { + return parachainIdToChainIdByRelay[relayChain.id]?.get(parachainId.toInt()) + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/CompoundChainLocationConverter.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/CompoundChainLocationConverter.kt new file mode 100644 index 0000000..9ec44de --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/CompoundChainLocationConverter.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_xcm_impl.converter.chain + +import io.novafoundation.nova.common.utils.tryFindNonNull +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverter +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation + +internal class CompoundChainLocationConverter( + private vararg val delegates: ChainMultiLocationConverter +) : ChainMultiLocationConverter { + + override suspend fun toChain(multiLocation: RelativeMultiLocation): Chain? { + return delegates.tryFindNonNull { it.toChain(multiLocation) } + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/LocalChainMultiLocationConverter.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/LocalChainMultiLocationConverter.kt new file mode 100644 index 0000000..41faa73 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/LocalChainMultiLocationConverter.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.feature_xcm_impl.converter.chain + +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.isHere +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +internal class LocalChainMultiLocationConverter( + val chain: Chain +) : ChainMultiLocationConverter { + + override suspend fun toChain(multiLocation: RelativeMultiLocation): Chain? { + return chain.takeIf { multiLocation.isHere() } + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/RealChainMultiLocationConverterFactory.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/RealChainMultiLocationConverterFactory.kt new file mode 100644 index 0000000..d065e51 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/converter/chain/RealChainMultiLocationConverterFactory.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.feature_xcm_impl.converter.chain + +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverter +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverterFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import javax.inject.Inject + +@FeatureScope +class RealChainMultiLocationConverterFactory @Inject constructor( + private val chainRegistry: ChainRegistry +) : ChainMultiLocationConverterFactory { + + override fun resolveSelfAndChildrenParachains(self: Chain): ChainMultiLocationConverter { + return CompoundChainLocationConverter( + LocalChainMultiLocationConverter(self), + ChildParachainLocationConverter(self, chainRegistry) + ) + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureComponent.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureComponent.kt new file mode 100644 index 0000000..84d1d30 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureComponent.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.feature_xcm_impl.di + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + dependencies = [ + XcmFeatureDependencies::class, + ], + modules = [ + XcmFeatureModule::class + ] +) +@FeatureScope +interface XcmFeatureComponent : XcmFeatureApi { + + @Component.Factory + interface Factory { + + fun create( + deps: XcmFeatureDependencies + ): XcmFeatureComponent + } + + @Component( + dependencies = [ + CommonApi::class, + RuntimeApi::class, + ] + ) + interface XcmFeatureDependenciesComponent : XcmFeatureDependencies +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureDependencies.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureDependencies.kt new file mode 100644 index 0000000..b5cb968 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureDependencies.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_xcm_impl.di + +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry + +interface XcmFeatureDependencies { + + val chainRegistry: ChainRegistry + + val runtimeCallApi: MultiChainRuntimeCallsApi +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureHolder.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureHolder.kt new file mode 100644 index 0000000..5f15e43 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureHolder.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.feature_xcm_impl.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class XcmFeatureHolder @Inject constructor( + featureContainer: FeatureContainer, +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val xcmFeatureDependencies = DaggerXcmFeatureComponent_XcmFeatureDependenciesComponent.builder() + .commonApi(commonApi()) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .build() + + return DaggerXcmFeatureComponent.factory() + .create( + deps = xcmFeatureDependencies + ) + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureModule.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureModule.kt new file mode 100644 index 0000000..6c3e322 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/XcmFeatureModule.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.feature_xcm_impl.di + +import dagger.Module +import io.novafoundation.nova.feature_xcm_impl.di.modules.BindsModule + +@Module( + includes = [ + BindsModule::class + ] +) +class XcmFeatureModule diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/modules/BindsModule.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/modules/BindsModule.kt new file mode 100644 index 0000000..d3331ef --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/di/modules/BindsModule.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.feature_xcm_impl.di.modules + +import dagger.Binds +import dagger.Module +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.converter.chain.ChainMultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.DryRunApi +import io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment.XcmPaymentApi +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.feature_xcm_impl.builder.RealXcmBuilderFactory +import io.novafoundation.nova.feature_xcm_impl.converter.RealMultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_impl.converter.chain.RealChainMultiLocationConverterFactory +import io.novafoundation.nova.feature_xcm_impl.runtimeApi.dryRun.RealDryRunApi +import io.novafoundation.nova.feature_xcm_impl.runtimeApi.xcmPayment.RealXcmPaymentApi +import io.novafoundation.nova.feature_xcm_impl.versions.detector.RealXcmVersionDetector + +@Module +internal interface BindsModule { + + @Binds + fun bindXcmVersionDetector(real: RealXcmVersionDetector): XcmVersionDetector + + @Binds + fun bindChainMultiLocationConverterFactory(real: RealChainMultiLocationConverterFactory): ChainMultiLocationConverterFactory + + @Binds + fun bindAssetMultiLocationConverterFactory(real: RealMultiLocationConverterFactory): MultiLocationConverterFactory + + @Binds + fun bindDryRunApi(real: RealDryRunApi): DryRunApi + + @Binds + fun bindXcmPaymentApi(real: RealXcmPaymentApi): XcmPaymentApi + + @Binds + fun bindXcmBuilderFactory(real: RealXcmBuilderFactory): XcmBuilder.Factory +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/runtimeApi/dryRun/RealDryRunApi.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/runtimeApi/dryRun/RealDryRunApi.kt new file mode 100644 index 0000000..62df499 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/runtimeApi/dryRun/RealDryRunApi.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.feature_xcm_impl.runtimeApi.dryRun + +import io.novafoundation.nova.common.data.network.runtime.binding.ScaleResult +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.provideContext +import io.novafoundation.nova.feature_xcm_api.message.VersionedRawXcmMessage +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.DryRunApi +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.CallDryRunEffects +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.DryRunEffectsResultErr +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller +import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.XcmDryRunEffects +import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcmLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.call.RuntimeCallsApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import javax.inject.Inject + +@FeatureScope +class RealDryRunApi @Inject constructor( + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi +) : DryRunApi { + + override suspend fun dryRunXcm( + originLocation: VersionedXcmLocation, + xcm: VersionedRawXcmMessage, + chainId: ChainId + ): Result> { + return multiChainRuntimeCallsApi.forChain(chainId).dryRunXcm(xcm, originLocation) + } + + override suspend fun dryRunCall( + originCaller: OriginCaller, + call: GenericCall.Instance, + xcmResultsVersion: XcmVersion, + chainId: ChainId + ): Result> { + return multiChainRuntimeCallsApi.forChain(chainId).dryRunCall(originCaller, call, xcmResultsVersion) + } + + private suspend fun RuntimeCallsApi.dryRunXcm( + xcm: VersionedRawXcmMessage, + origin: VersionedXcmLocation, + ): Result> { + return runCatching { + call( + section = "DryRunApi", + method = "dry_run_xcm", + arguments = mapOf( + "origin_location" to origin.toEncodableInstance(), + "xcm" to xcm.toEncodableInstance() + ), + returnBinding = { + runtime.provideContext { + ScaleResult.bind( + dynamicInstance = it, + bindOk = { XcmDryRunEffects.bind(it) }, + bindError = DryRunEffectsResultErr::bind + ) + } + } + ) + } + } + + private suspend fun RuntimeCallsApi.dryRunCall( + originCaller: OriginCaller, + call: GenericCall.Instance, + xcmResultsVersion: XcmVersion, + ): Result> { + return runCatching { + call( + section = "DryRunApi", + method = "dry_run_call", + arguments = mapOf( + "origin" to originCaller.toEncodableInstance(), + "call" to call, + "result_xcms_version" to xcmResultsVersion.version.toBigInteger() + ), + returnBinding = { + runtime.provideContext { + ScaleResult.bind( + dynamicInstance = it, + bindOk = { CallDryRunEffects.bind(it) }, + bindError = DryRunEffectsResultErr::bind + ) + } + } + ) + } + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/runtimeApi/xcmPayment/RealXcmPaymentApi.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/runtimeApi/xcmPayment/RealXcmPaymentApi.kt new file mode 100644 index 0000000..21ff166 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/runtimeApi/xcmPayment/RealXcmPaymentApi.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_xcm_impl.runtimeApi.xcmPayment + +import io.novafoundation.nova.common.data.network.runtime.binding.ScaleResult +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.data.network.runtime.binding.bindWeightV2 +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment.XcmPaymentApi +import io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment.model.QueryXcmWeightErr +import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.call.RuntimeCallsApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import javax.inject.Inject + +@FeatureScope +class RealXcmPaymentApi @Inject constructor( + private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, +) : XcmPaymentApi { + + override suspend fun queryXcmWeight( + chainId: ChainId, + xcm: VersionedXcmMessage + ): Result> { + return multiChainRuntimeCallsApi.forChain(chainId).queryXcmWeight(xcm) + } + + override suspend fun isSupported(chainId: ChainId): Boolean { + return multiChainRuntimeCallsApi.isSupported(chainId, "XcmPaymentApi", "query_xcm_weight") + } + + private suspend fun RuntimeCallsApi.queryXcmWeight( + xcm: VersionedXcmMessage, + ): Result> { + return runCatching { + call( + section = "XcmPaymentApi", + method = "query_xcm_weight", + arguments = mapOf( + "message" to xcm.toEncodableInstance() + ), + returnBinding = { + ScaleResult.bind( + dynamicInstance = it, + bindOk = ::bindWeightV2, + bindError = QueryXcmWeightErr::bind + ) + } + ) + } + } +} diff --git a/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/versions/detector/RealXcmVersionDetector.kt b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/versions/detector/RealXcmVersionDetector.kt new file mode 100644 index 0000000..b9c8a41 --- /dev/null +++ b/feature-xcm/impl/src/main/java/io/novafoundation/nova/feature_xcm_impl/versions/detector/RealXcmVersionDetector.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.feature_xcm_impl.versions.detector + +import android.util.Log +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.common.utils.enumValueOfOrNull +import io.novafoundation.nova.common.utils.xcmPalletNameOrNull +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novasama.substrate_sdk_android.runtime.definitions.types.RuntimeType +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.skipAliases +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.module.MetadataFunction +import io.novasama.substrate_sdk_android.runtime.metadata.moduleOrNull +import javax.inject.Inject + +@FeatureScope +class RealXcmVersionDetector @Inject constructor( + private val chainRegistry: ChainRegistry +) : XcmVersionDetector { + + override suspend fun lowestPresentMultiLocationVersion(chainId: ChainId): XcmVersion? { + return lowestPresentXcmTypeVersionFromCallArgument( + chainId = chainId, + getCall = { it.xcmPalletNameOrNull()?.let { pallet -> it.moduleOrNull(pallet) }?.callOrNull("reserve_transfer_assets") }, + argumentName = "dest" + ) + } + + override suspend fun lowestPresentMultiAssetsVersion(chainId: ChainId): XcmVersion? { + return lowestPresentXcmTypeVersionFromCallArgument( + chainId = chainId, + getCall = { it.xcmPalletNameOrNull()?.let { pallet -> it.moduleOrNull(pallet) }?.callOrNull("reserve_transfer_assets") }, + argumentName = "assets" + ) + } + + override suspend fun lowestPresentMultiAssetIdVersion(chainId: ChainId): XcmVersion? { + return lowestPresentXcmTypeVersionFromCallArgument( + chainId = chainId, + getCall = { it.xcmPalletNameOrNull()?.let { pallet -> it.moduleOrNull(pallet) }?.callOrNull("transfer_assets_using_type_and_then") }, + argumentName = "remote_fees_id" + ) + } + + override suspend fun lowestPresentMultiAssetVersion(chainId: ChainId): XcmVersion? { + return lowestPresentMultiAssetsVersion(chainId) + } + + override suspend fun detectMultiLocationVersion(chainId: ChainId, multiLocationType: RuntimeType<*, *>?): XcmVersion? { + val actualCheckedType = multiLocationType?.skipAliases() ?: return null + val versionedType = getVersionedType( + chainId = chainId, + getCall = { xcmPalletNameOrNull()?.let { moduleOrNull(it) }?.callOrNull("reserve_transfer_assets") }, + argumentName = "dest" + ) ?: return null + + val matchingEnumEntry = versionedType.elements.values.find { enumEntry -> enumEntry.value.skipAliases().value === actualCheckedType } + ?: run { + Log.w("RealPalletXcmRepository", "Failed to find matching variant in versioned multiplication for type ${actualCheckedType.name}") + + return null + } + + return enumValueOfOrNull(matchingEnumEntry.name)?.also { + Log.d("RealPalletXcmRepository", "Identified xcm version for ${actualCheckedType.name} to be ${it.name}") + } + } + + private suspend fun lowestPresentXcmTypeVersionFromCallArgument( + chainId: ChainId, + getCall: (RuntimeMetadata) -> MetadataFunction?, + argumentName: String, + ): XcmVersion? { + val type = getVersionedType(chainId, getCall, argumentName) ?: return null + + val allSupportedVersions = type.elements.values.map { it.name } + val leastSupportedVersion = allSupportedVersions.min() + + return enumValueOfOrNull(leastSupportedVersion) + } + + private suspend fun getVersionedType( + chainId: ChainId, + getCall: RuntimeMetadata.() -> MetadataFunction?, + argumentName: String, + ): DictEnum? { + return chainRegistry.withRuntime(chainId) { + val call = getCall(runtime.metadata) ?: return@withRuntime null + val argument = call.arguments.find { it.name == argumentName } ?: return@withRuntime null + argument.type?.skipAliases() as? DictEnum + } + } +} diff --git a/feature-xcm/impl/src/test/java/io/novafoundation/nova/feature_xcm_impl/builder/RealXcmBuilderTest.kt b/feature-xcm/impl/src/test/java/io/novafoundation/nova/feature_xcm_impl/builder/RealXcmBuilderTest.kt new file mode 100644 index 0000000..fcdddd1 --- /dev/null +++ b/feature-xcm/impl/src/test/java/io/novafoundation/nova/feature_xcm_impl/builder/RealXcmBuilderTest.kt @@ -0,0 +1,215 @@ +package io.novafoundation.nova.feature_xcm_impl.builder + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter.Wild.All +import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetId +import io.novafoundation.nova.feature_xcm_api.asset.intoMultiAssets +import io.novafoundation.nova.feature_xcm_api.asset.withAmount +import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder +import io.novafoundation.nova.feature_xcm_api.builder.buyExecution +import io.novafoundation.nova.feature_xcm_api.builder.depositAllAssetsTo +import io.novafoundation.nova.feature_xcm_api.builder.fees.MeasureXcmFees +import io.novafoundation.nova.feature_xcm_api.builder.payFees +import io.novafoundation.nova.feature_xcm_api.builder.payFeesIn +import io.novafoundation.nova.feature_xcm_api.builder.transferReserveAsset +import io.novafoundation.nova.feature_xcm_api.builder.withdrawAsset +import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage +import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction.BuyExecution +import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction.DepositAsset +import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction.DepositReserveAsset +import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction.InitiateReserveWithdraw +import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction.PayFees +import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction.TransferReserveAsset +import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction.WithdrawAsset +import io.novafoundation.nova.feature_xcm_api.message.XcmMessage +import io.novafoundation.nova.feature_xcm_api.multiLocation.AssetLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Interior.Here +import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId +import io.novafoundation.nova.feature_xcm_api.multiLocation.asLocation +import io.novafoundation.nova.feature_xcm_api.multiLocation.toMultiLocation +import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion +import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm +import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.math.BigInteger + + +class RealXcmBuilderTest { + + val amount = 100000000000000.toBigInteger() + val buyExecutionFees = amount / 2.toBigInteger() + + val testMeasuredFees = 10.toBigInteger() + val testExactFees = 11.toBigInteger() + + val recipient = ByteArray(32) { 1 }.intoKey() + + val polkadot = ChainLocation(Chain.Geneses.POLKADOT, Here.asLocation()) + val pah = ChainLocation(Chain.Geneses.POLKADOT_ASSET_HUB, ParachainId(1000).asLocation()) + val hydration = ChainLocation(Chain.Geneses.HYDRA_DX, ParachainId(2034).asLocation()) + + val dot = Here.asLocation() + val dotLocation = AssetLocation(FullChainAssetId(Chain.Geneses.POLKADOT, 0), dot) + val dotOnPolkadot = MultiAssetId(dot.fromPointOfViewOf(polkadot.location)) + val dotOnPah = MultiAssetId(dot.fromPointOfViewOf(pah.location)) + val dotOnHydration = MultiAssetId(dot.fromPointOfViewOf(hydration.location)) + + + val xcmVersion = XcmVersion.V4 + + @Test + fun `should build empty message`() = runBlocking { + val expected = XcmMessage(emptyList()) + + val result = createBuilder(polkadot).build() + + assertXcmMessageEquals(expected, result) + } + + @Test + fun `should build single chain message`() = runBlocking { + val expected = XcmMessage( + BuyExecution(dotOnPolkadot.withAmount(amount), WeightLimit.Unlimited), + DepositAsset(All, recipient.toMultiLocation()) + ) + + val result = createBuilder(polkadot).apply { + buyExecution(dot, amount, WeightLimit.Unlimited) + depositAllAssetsTo(recipient) + }.build() + + assertXcmMessageEquals(expected, result) + } + + @Test + fun `should perform single context switch`() = runBlocking { + val forwardedToHydration = XcmMessage( + BuyExecution(dotOnHydration.withAmount(buyExecutionFees), WeightLimit.Unlimited), + DepositAsset(All, recipient.toMultiLocation()) + ) + val expectedOnPolkadot = XcmMessage( + TransferReserveAsset( + assets = dotOnPolkadot.withAmount(amount).intoMultiAssets(), + dest = hydration.location.fromPointOfViewOf(polkadot.location), + xcm = forwardedToHydration + ) + ) + + val result = createBuilder(polkadot).apply { + // polkadot + transferReserveAsset(dot, amount, hydration) + + // hydration + buyExecution(dot, buyExecutionFees, WeightLimit.Unlimited) + depositAllAssetsTo(recipient) + }.build() + + assertXcmMessageEquals(expectedOnPolkadot, result) + } + + @Test + fun `should perform multiple context switches`() = runBlocking { + val forwardedToHydration = XcmMessage( + BuyExecution(dotOnHydration.withAmount(buyExecutionFees), WeightLimit.Unlimited), + DepositAsset(All, recipient.toMultiLocation()) + ) + val forwardedToPolkadot = XcmMessage( + BuyExecution(dotOnPolkadot.withAmount(buyExecutionFees), WeightLimit.Unlimited), + DepositReserveAsset( + assets = All, + dest = hydration.location.fromPointOfViewOf(polkadot.location), + xcm = forwardedToHydration + ) + ) + val expectedOnPah = XcmMessage( + WithdrawAsset(dotOnPah.withAmount(amount).intoMultiAssets()), + InitiateReserveWithdraw( + assets = All, + reserve = polkadot.location.fromPointOfViewOf(pah.location), + xcm = forwardedToPolkadot + ) + ) + + val result = createBuilder(pah).apply { + // on Pah + withdrawAsset(dot, amount) + initiateReserveWithdraw(All, reserve = polkadot) + + // on Polkadot + buyExecution(dot, buyExecutionFees, WeightLimit.Unlimited) + depositReserveAsset(All, dest = hydration) + + // on Hydration + buyExecution(dot, buyExecutionFees, WeightLimit.Unlimited) + depositAsset(All, recipient) + }.build() + + assertXcmMessageEquals(expectedOnPah, result) + } + + @Test + fun `should set PayFees in exact mode`() = runBlocking { + val expected = XcmMessage( + PayFees(dotOnPolkadot.withAmount(testExactFees)), + DepositAsset(All, recipient.toMultiLocation()) + ) + + val result = createBuilder(polkadot).apply { + payFees(dotOnPolkadot, testExactFees) + depositAllAssetsTo(recipient) + }.build() + + assertXcmMessageEquals(expected, result) + } + + @Test + fun `should set PayFees in measured mode`() = runBlocking { + val expected = XcmMessage( + PayFees(dotOnPolkadot.withAmount(testMeasuredFees)), + DepositAsset(All, recipient.toMultiLocation()) + ) + val expectedForMeasure = XcmMessage( + PayFees(dotOnPolkadot.withAmount(BigInteger.ONE)), + DepositAsset(All, recipient.toMultiLocation()) + ) + + val result = createBuilder(polkadot, validateMeasuringMessage = expectedForMeasure).apply { + payFeesIn(dotLocation) + depositAllAssetsTo(recipient) + }.build() + + assertXcmMessageEquals(expected, result) + } + + private fun assertXcmMessageEquals(expected: XcmMessage, actual: VersionedXcmMessage) { + assertEquals(expected.versionedXcm(xcmVersion), actual) + } + + private fun createBuilder( + origin: ChainLocation, + validateMeasuringMessage: XcmMessage? = null + ): XcmBuilder { + return RealXcmBuilder(origin, XcmVersion.V4, TestMeasureFees(validateMeasuringMessage)) + } + + private inner class TestMeasureFees( + private val validateMeasuringMessage: XcmMessage? + ) : MeasureXcmFees { + + override suspend fun measureFees( + message: VersionedXcmMessage, + feeAsset: AssetLocation, + chainLocation: ChainLocation + ): BalanceOf { + validateMeasuringMessage?.let { assertXcmMessageEquals(validateMeasuringMessage, message) } + return testMeasuredFees + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b3b3205 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,20 @@ +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +#Fri Feb 16 12:00:34 MSK 2024 +org.gradle.parallel=true +org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" +android.useAndroidX=true +android.enableJetifier=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..91766d3 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Feb 24 09:57:38 CET 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/key/fake.json b/key/fake.json new file mode 100644 index 0000000..3c5c546 --- /dev/null +++ b/key/fake.json @@ -0,0 +1,12 @@ +{ + "type": "null", + "project_id": "null", + "private_key_id": "null", + "private_key": "-----BEGIN PRIVATE KEY-----\nnull\n-----END PRIVATE KEY-----\n", + "client_email": "null@null.iam.gserviceaccount.com", + "client_id": "null", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/null.iam.gserviceaccount.com" +} diff --git a/lint.xml b/lint.xml new file mode 100644 index 0000000..5add53a --- /dev/null +++ b/lint.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/pezkuwi-config/chains.json b/pezkuwi-config/chains.json new file mode 100644 index 0000000..a1f0e91 --- /dev/null +++ b/pezkuwi-config/chains.json @@ -0,0 +1,9275 @@ +[ + { + "chainId": "bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75", + "name": "Pezkuwi", + "icon": "HEZ.png", + "addressPrefix": 42, + "options": [ + "crowdloans", + "governance-v2", + "proxy", + "multisig", + "pushSupport", + "fullSyncByDefault" + ], + "nodeSelectionStrategy": "roundRobin", + "nodes": [ + { + "url": "wss://rpc.pezkuwichain.io", + "name": "Pezkuwi Mainnet" + }, + { + "url": "wss://mainnet.pezkuwichain.io", + "name": "Pezkuwi Mainnet (Alt)" + } + ], + "explorers": [ + { + "name": "Pezkuwi Explorer", + "extrinsic": "https://explorer.pezkuwichain.io/extrinsic/{value}", + "account": "https://explorer.pezkuwichain.io/account/{value}", + "event": "https://explorer.pezkuwichain.io/event/{value}" + } + ], + "externalApi": { + "history": [], + "staking": [] + }, + "assets": [ + { + "assetId": 0, + "symbol": "HEZ", + "precision": 12, + "name": "HEZkurd", + "priceId": "hezkurd", + "staking": null, + "type": "native", + "icon": "HEZ.png", + "buyProviders": {}, + "sellProviders": {}, + "typeExtras": null + } + ], + "additional": { + "themeColor": "#009639", + "defaultBlockTimeMillis": 6000, + "feeViaRuntimeCall": true, + "disabledCheckMetadataHash": true, + "stakingMaxElectingVoters": 22500 + } + }, + { + "chainId": "96eb58af1bb7288115b5e4ff1590422533e749293f231974536dc6672417d06f", + "name": "Zagros Testnet", + "icon": "https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/main/icons/chains/Pezkuwi.png", + "addressPrefix": 42, + "options": [ + "testnet" + ], + "nodes": [ + { + "url": "wss://zagros-rpc.pezkuwichain.io", + "name": "Zagros Node" + } + ], + "assets": [ + { + "assetId": 0, + "symbol": "HEZ", + "precision": 12, + "name": "HEZkurd", + "priceId": "hezkurd", + "staking": [ + "relaychain" + ], + "type": "native", + "icon": "HEZ.png" + } + ], + "additional": { + "themeColor": "#009639", + "feeViaRuntimeCall": true, + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "00d0e1d0581c3cd5c5768652d52f4520184018b44f56a2ae1e0dc9d65c00c948", + "parentId": "bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75", + "name": "Pezkuwi Asset Hub", + "icon": "PEZ.png", + "addressPrefix": 42, + "options": [ + "swap-hub", + "assethub-fees", + "proxy", + "multisig", + "fullSyncByDefault" + ], + "nodeSelectionStrategy": "roundRobin", + "nodes": [ + { + "url": "wss://asset-hub-rpc.pezkuwichain.io", + "name": "Pezkuwi Asset Hub" + } + ], + "explorers": [ + { + "name": "Pezkuwi Explorer", + "extrinsic": "https://explorer.pezkuwichain.io/asset-hub/extrinsic/{value}", + "account": "https://explorer.pezkuwichain.io/asset-hub/account/{value}", + "event": "https://explorer.pezkuwichain.io/asset-hub/event/{value}" + } + ], + "externalApi": {}, + "assets": [ + { + "assetId": 0, + "symbol": "HEZ", + "precision": 12, + "name": "HEZkurd", + "priceId": "hezkurd", + "staking": [ + "relaychain", + "nomination-pools" + ], + "type": "native", + "icon": "HEZ.png", + "buyProviders": {}, + "sellProviders": {}, + "typeExtras": null + }, + { + "assetId": 1, + "symbol": "PEZ", + "precision": 12, + "name": "Pezkuwi", + "priceId": "pezkuwi", + "staking": null, + "type": "statemine", + "icon": "PEZ.png", + "buyProviders": {}, + "sellProviders": {}, + "typeExtras": { + "assetId": "1" + } + }, + { + "assetId": 1000, + "symbol": "USDT", + "precision": 6, + "name": "Tether USD", + "priceId": "tether", + "staking": null, + "type": "statemine", + "icon": "wUSDT.png", + "buyProviders": {}, + "sellProviders": {}, + "typeExtras": { + "assetId": "1000" + } + }, + { + "assetId": 1001, + "symbol": "DOT", + "precision": 10, + "name": "Polkadot", + "priceId": "polkadot", + "staking": null, + "type": "statemine", + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/tokens/white/DOT.svg", + "buyProviders": {}, + "sellProviders": {}, + "typeExtras": { + "assetId": "1001" + } + }, + { + "assetId": 1002, + "symbol": "ETH", + "precision": 18, + "name": "Ethereum", + "priceId": "ethereum", + "staking": null, + "type": "statemine", + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/tokens/white/ETH.svg", + "buyProviders": {}, + "sellProviders": {}, + "typeExtras": { + "assetId": "1002" + } + }, + { + "assetId": 1003, + "symbol": "BTC", + "precision": 8, + "name": "Bitcoin", + "priceId": "bitcoin", + "staking": null, + "type": "statemine", + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/tokens/white/WBTC.svg", + "buyProviders": {}, + "sellProviders": {}, + "typeExtras": { + "assetId": "1003" + } + } + ], + "additional": { + "themeColor": "#009639", + "defaultBlockTimeMillis": 6000, + "feeViaRuntimeCall": true, + "disabledCheckMetadataHash": true, + "relaychainAsNative": true, + "stakingMaxElectingVoters": 22500, + "timelineChain": "bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75" + } + }, + { + "chainId": "58269e9c184f721e0309332d90cafc410df1519a5dc27a5fd9b3bf5fd2d129f8", + "parentId": "bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75", + "name": "Pezkuwi People", + "icon": "https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/main/icons/chains/PezkuwiPeople.png", + "addressPrefix": 42, + "options": [ + "proxy", + "multisig", + "fullSyncByDefault" + ], + "nodeSelectionStrategy": "roundRobin", + "nodes": [ + { + "url": "wss://people-rpc.pezkuwichain.io", + "name": "Pezkuwi People Chain" + } + ], + "explorers": [ + { + "name": "Pezkuwi Explorer", + "extrinsic": "https://explorer.pezkuwichain.io/people/extrinsic/{value}", + "account": "https://explorer.pezkuwichain.io/people/account/{value}", + "event": "https://explorer.pezkuwichain.io/people/event/{value}" + } + ], + "externalApi": {}, + "assets": [ + { + "assetId": 0, + "symbol": "HEZ", + "precision": 12, + "name": "HEZkurd", + "priceId": "hezkurd", + "staking": null, + "type": "native", + "icon": "HEZ.png", + "buyProviders": {}, + "sellProviders": {}, + "typeExtras": null + } + ], + "additional": { + "themeColor": "#009639", + "defaultBlockTimeMillis": 6000, + "feeViaRuntimeCall": true, + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Polkadot Relay", + "assets": [ + { + "assetId": 0, + "symbol": "DOT", + "precision": 10, + "name": "Polkadot", + "priceId": "polkadot", + "icon": "DOT.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc-polkadot-1.novasama-tech.org", + "name": "Novasama node" + }, + { + "url": "wss://rpc-polkadot-2.novasama-tech.org", + "name": "Novasama node" + }, + { + "url": "wss://rpc.ibp.network/polkadot", + "name": "IBP1 node" + }, + { + "url": "wss://polkadot.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://apps-rpc.polkadot.io", + "name": "Public node", + "features": [ + "noTls12" + ] + }, + { + "url": "wss://dot-rpc.stakeworld.io", + "name": "Stakeworld node" + }, + { + "url": "wss://polkadot.public.curie.radiumblock.co/ws", + "name": "Radium node" + }, + { + "url": "wss://1rpc.io/dot", + "name": "Automata 1RPC node" + }, + { + "url": "wss://rpc-polkadot.luckyfriday.io", + "name": "LuckyFriday node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://polkadot.subscan.io/extrinsic/{hash}", + "account": "https://polkadot.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://polkadot.statescan.io/#/accounts/{address}", + "extrinsic": "https://polkadot.statescan.io/#/extrinsics/{hash}", + "event": "https://polkadot.statescan.io/#/events/{event}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Polkadot.svg", + "addressPrefix": 0, + "externalApi": { + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadot-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadot-prod.novasama-tech.org" + } + ], + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadot-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://polkadot-api.subsquare.io" + } + ], + "governance-delegations": [ + { + "type": "subquery", + "url": "https://subquery-governance-polkadot-prod.novasama-tech.org" + } + ], + "referendum-summary": [ + { + "type": "novasama", + "url": "https://opengov-backend.novasama-tech.org/api/v1/referendum-summaries/list" + } + ] + }, + "options": [ + "proxy", + "fullSyncByDefault", + "pushSupport", + "multisig" + ], + "additional": { + "themeColor": "#E6007A", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/polkadot-dot-staking", + "stakingMaxElectingVoters": 22500, + "identityChain": "67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008", + "feeViaRuntimeCall": true, + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Kusama Relay", + "assets": [ + { + "assetId": 0, + "symbol": "KSM", + "precision": 12, + "name": "Kusama", + "priceId": "kusama", + "icon": "KSM.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc-kusama-1.novasama-tech.org", + "name": "Novasama node" + }, + { + "url": "wss://rpc-kusama-2.novasama-tech.org", + "name": "Novasama node" + }, + { + "url": "wss://rpc.ibp.network/kusama", + "name": "IBP1 node" + }, + { + "url": "wss://kusama.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://apps-kusama-rpc.polkadot.io", + "name": "Public node", + "features": [ + "noTls12" + ] + }, + { + "url": "wss://rpc-kusama.luckyfriday.io", + "name": "LuckyFriday node" + }, + { + "url": "wss://1rpc.io/ksm", + "name": "Automata 1RPC node" + }, + { + "url": "wss://kusama.public.curie.radiumblock.co/ws", + "name": "Radium node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://kusama.subscan.io/extrinsic/{hash}", + "account": "https://kusama.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://kusama.statescan.io/#/accounts/{address}", + "extrinsic": "https://kusama.statescan.io/#/extrinsics/{hash}", + "event": "https://kusama.statescan.io/#/events/{event}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Kusama.svg", + "addressPrefix": 2, + "externalApi": { + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-kusama-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-kusama-prod.novasama-tech.org" + } + ], + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-kusama-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://kusama-api.subsquare.io" + } + ], + "governance-delegations": [ + { + "type": "subquery", + "url": "https://subquery-governance-kusama-prod.novasama-tech.org" + } + ], + "referendum-summary": [ + { + "type": "novasama", + "url": "https://opengov-backend.novasama-tech.org/api/v1/referendum-summaries/list" + } + ] + }, + "options": [ + "proxy", + "fullSyncByDefault", + "pushSupport", + "multisig" + ], + "additional": { + "themeColor": "#1F78FF", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/kusama-ksm-staking", + "stakingMaxElectingVoters": 12500, + "identityChain": "c1af4cb4eb3918e5db15086c0cc5ec17fb334f728b7c65dd44bfe1e174ff8b3f", + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "e143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e", + "name": "Westend (TESTNET)", + "assets": [ + { + "assetId": 0, + "symbol": "WND", + "precision": 12, + "icon": "WND.svg", + "staking": [ + "relaychain", + "nomination-pools" + ] + } + ], + "nodes": [ + { + "url": "wss://westend-rpc.polkadot.io", + "name": "Parity node" + }, + { + "url": "wss://westend.public.curie.radiumblock.co/ws", + "name": "RadiumBlock node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://westend.subscan.io/extrinsic/{hash}", + "account": "https://westend.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://westend.statescan.io/#/accounts/{address}", + "extrinsic": "https://westend.statescan.io/#/extrinsics/{hash}", + "event": "https://westend.statescan.io/#/events/{event}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Westend_Testnet.svg", + "addressPrefix": 42, + "externalApi": { + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-westend-prod.novasama-tech.org/" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-westend-prod.novasama-tech.org/" + } + ], + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-westend-prod.novasama-tech.org/" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://westend.subsquare.io/api" + } + ] + }, + "options": [ + "testnet", + "proxy" + ], + "additional": { + "themeColor": "#1F78FF", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/polkadot-and-kusama", + "stakingMaxElectingVoters": 22500, + "feeViaRuntimeCall": true, + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Kusama Asset Hub", + "assets": [ + { + "assetId": 0, + "symbol": "KSM", + "precision": 12, + "name": "Kusama", + "icon": "KSM.svg", + "staking": [ + "relaychain", + "nomination-pools" + ], + "priceId": "kusama", + "buyProviders": { + "mercuryo": {} + }, + "sellProviders": { + "mercuryo": {} + } + }, + { + "assetId": 1, + "symbol": "RMRK (old)", + "precision": 10, + "priceId": "rmrk", + "type": "statemine", + "icon": "RMRK.svg", + "typeExtras": { + "assetId": "8", + "isSufficient": true + } + }, + { + "assetId": 2, + "symbol": "CHAOS", + "precision": 10, + "type": "statemine", + "icon": "CHAOS.svg", + "typeExtras": { + "assetId": "69420" + } + }, + { + "assetId": 3, + "symbol": "CHRWNA", + "precision": 10, + "type": "statemine", + "icon": "CHRWNA.svg", + "typeExtras": { + "assetId": "567" + } + }, + { + "assetId": 4, + "symbol": "SHIBATALES", + "precision": 0, + "type": "statemine", + "icon": "Default.svg", + "typeExtras": { + "assetId": "88888" + } + }, + { + "assetId": 5, + "symbol": "BILLCOIN", + "precision": 8, + "type": "statemine", + "icon": "BILLCOIN.svg", + "typeExtras": { + "assetId": "223" + } + }, + { + "assetId": 7, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "statemine", + "icon": "USDT.svg", + "typeExtras": { + "assetId": "1984", + "isSufficient": true + } + }, + { + "assetId": 8, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "type": "statemine", + "icon": "DOT.svg", + "typeExtras": { + "assetId": "0x02010902", + "palletName": "ForeignAssets" + } + } + ], + "nodes": [ + { + "url": "wss://sys.ibp.network/statemine", + "name": "IBP1 node" + }, + { + "url": "wss://asset-hub-kusama.dotters.network", + "name": "IBP2 node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://assethub-kusama.subscan.io/extrinsic/{hash}", + "account": "https://assethub-kusama.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://assethub-kusama.statescan.io/#/accounts/{address}", + "extrinsic": "https://assethub-kusama.statescan.io/#/extrinsics/{hash}", + "event": "https://assethub-kusama.statescan.io/#/events/{event}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-kusama-ah-prod.novasama-tech.org" + } + ], + "governance-delegations": [ + { + "type": "subquery", + "url": "https://subquery-governance-kusama-ah-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-kusama-prod.novasama-tech.org" + }, + { + "type": "subquery", + "url": "https://subquery-history-kusama-ah-prod.novasama-tech.org" + } + ], + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-kusama-ah-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://kusama-api.subsquare.io" + } + ], + "referendum-summary": [ + { + "type": "novasama", + "url": "https://opengov-backend.novasama-tech.org/api/v1/referendum-summaries/list" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Kusama_Asset_Hub.svg", + "addressPrefix": 2, + "options": [ + "swap-hub", + "assethub-fees", + "proxy", + "multisig", + "governance", + "pushSupport" + ], + "additional": { + "themeColor": "#1F78FF", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/kusama-ksm-staking", + "stakingMaxElectingVoters": 12500, + "relaychainAsNative": true, + "feeViaRuntimeCall": true, + "supportsGenericLedgerApp": true, + "identityChain": "c1af4cb4eb3918e5db15086c0cc5ec17fb334f728b7c65dd44bfe1e174ff8b3f", + "timelineChain": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe" + } + }, + { + "chainId": "67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Polkadot People", + "assets": [ + { + "assetId": 0, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "icon": "DOT.svg" + } + ], + "nodes": [ + { + "url": "wss://polkadot-people-rpc.polkadot.io", + "name": "Parity node", + "features": [ + "noTls12" + ] + }, + { + "url": "wss://sys.ibp.network/people-polkadot", + "name": "IBP1 node" + }, + { + "url": "wss://people-polkadot.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://rpc-people-polkadot.luckyfriday.io", + "name": "LuckyFriday node" + }, + { + "url": "wss://people-polkadot.public.curie.radiumblock.co/ws", + "name": "Radium node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://people-polkadot.subscan.io/extrinsic/{hash}", + "account": "https://people-polkadot.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://people-polkadot.statescan.io/#/accounts/{address}", + "extrinsic": "https://people-polkadot.statescan.io/#/extrinsics/{hash}", + "event": "https://people-polkadot.statescan.io/#/events/{event}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Polkadot_People.svg", + "addressPrefix": 0, + "additional": { + "supportsGenericLedgerApp": true, + "identityChain": "67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008" + }, + "options": [ + "multisig" + ] + }, + { + "chainId": "c1af4cb4eb3918e5db15086c0cc5ec17fb334f728b7c65dd44bfe1e174ff8b3f", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Kusama People", + "assets": [ + { + "assetId": 0, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "icon": "KSM.svg" + } + ], + "nodes": [ + { + "url": "wss://kusama-people-rpc.polkadot.io", + "name": "Parity node", + "features": [ + "noTls12" + ] + }, + { + "url": "wss://sys.ibp.network/people-kusama", + "name": "IBP1 node" + }, + { + "url": "wss://people-kusama.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://ksm-rpc.stakeworld.io/people", + "name": "Stakeworld node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://people-kusama.subscan.io/extrinsic/{hash}", + "account": "https://people-kusama.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://people-kusama.statescan.io/#/accounts/{address}", + "extrinsic": "https://people-kusama.statescan.io/#/extrinsics/{hash}", + "event": "https://people-kusama.statescan.io/#/events/{event}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Kusama_People.svg", + "addressPrefix": 2, + "additional": { + "disabledCheckMetadataHash": true, + "identityChain": "c1af4cb4eb3918e5db15086c0cc5ec17fb334f728b7c65dd44bfe1e174ff8b3f" + }, + "options": [ + "multisig" + ] + }, + { + "chainId": "baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Karura", + "assets": [ + { + "assetId": 0, + "symbol": "KAR", + "precision": 12, + "icon": "KAR.svg", + "priceId": "karura", + "buyProviders": { + "banxa": { + "coinType": "KAR", + "blockchain": "KAR" + } + } + }, + { + "assetId": 1, + "symbol": "aSEEDk", + "precision": 12, + "icon": "aSEEDk.svg", + "priceId": "ausd-seed-karura", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0081", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "10000000000", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "KSM", + "precision": 12, + "icon": "KSM.svg", + "priceId": "kusama", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0082", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "RMRK (old)", + "precision": 10, + "type": "orml", + "icon": "RMRK.svg", + "priceId": "rmrk", + "typeExtras": { + "currencyIdScale": "0x050000", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "BNC", + "precision": 12, + "type": "orml", + "icon": "BNC.svg", + "priceId": "bifrost-native-coin", + "typeExtras": { + "currencyIdScale": "0x00a8", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "8000000000", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "LKSM", + "precision": 12, + "priceId": "liquid-ksm", + "type": "orml", + "icon": "LKSM.svg", + "typeExtras": { + "currencyIdScale": "0x0083", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "500000000", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "PHA", + "precision": 12, + "priceId": "pha", + "type": "orml", + "icon": "PHA.svg", + "typeExtras": { + "currencyIdScale": "0x00aa", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "40000000000", + "transfersEnabled": true + } + }, + { + "assetId": 7, + "symbol": "KINT", + "precision": 12, + "priceId": "kintsugi", + "type": "orml", + "icon": "KINT.svg", + "typeExtras": { + "currencyIdScale": "0x00ab", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "133330000", + "transfersEnabled": true + } + }, + { + "assetId": 8, + "symbol": "kBTC", + "precision": 8, + "priceId": "bitcoin", + "type": "orml", + "icon": "kBTC.svg", + "typeExtras": { + "currencyIdScale": "0x00ac", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "660000", + "transfersEnabled": true + } + }, + { + "assetId": 9, + "symbol": "TAI", + "precision": 12, + "type": "orml", + "icon": "TAI.svg", + "typeExtras": { + "currencyIdScale": "0x0084", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 10, + "symbol": "vsKSM", + "precision": 12, + "type": "orml", + "icon": "vsKSM.svg", + "typeExtras": { + "currencyIdScale": "0x00a9", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 11, + "symbol": "taiKSM", + "precision": 12, + "type": "orml", + "icon": "taiKSM.svg", + "typeExtras": { + "currencyIdScale": "0x0300000000", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 12, + "symbol": "QTZ", + "precision": 18, + "priceId": "quartz", + "type": "orml", + "icon": "QTZ.svg", + "typeExtras": { + "currencyIdScale": "0x050200", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 14, + "symbol": "MOVR", + "precision": 18, + "priceId": "moonriver", + "type": "orml", + "icon": "MOVR.svg", + "typeExtras": { + "currencyIdScale": "0x050300", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 15, + "symbol": "HKO", + "precision": 12, + "type": "orml", + "icon": "HKO.svg", + "typeExtras": { + "currencyIdScale": "0x050400", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000", + "transfersEnabled": true + } + }, + { + "assetId": 16, + "symbol": "CSM", + "precision": 12, + "priceId": "crust-storage-market", + "type": "orml", + "icon": "CSM.svg", + "typeExtras": { + "currencyIdScale": "0x050500", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 17, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "orml", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x050700", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "10000", + "transfersEnabled": true + } + }, + { + "assetId": 18, + "symbol": "KMA", + "precision": 12, + "priceId": "calamari-network", + "type": "orml", + "icon": "KMA.svg", + "typeExtras": { + "currencyIdScale": "0x050a00", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000", + "transfersEnabled": true + } + }, + { + "assetId": 19, + "symbol": "TEER", + "precision": 12, + "priceId": "integritee", + "type": "orml", + "icon": "TEER.svg", + "typeExtras": { + "currencyIdScale": "0x050800", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000", + "transfersEnabled": true + } + }, + { + "assetId": 20, + "symbol": "KICO", + "precision": 14, + "type": "orml", + "icon": "KICO.svg", + "typeExtras": { + "currencyIdScale": "0x050600", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 21, + "symbol": "NEER", + "precision": 18, + "priceId": "metaverse-network-pioneer", + "type": "orml", + "icon": "NEER.svg", + "typeExtras": { + "currencyIdScale": "0x050900", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 22, + "symbol": "BSX", + "precision": 12, + "priceId": "basilisk", + "type": "orml", + "icon": "BSX.svg", + "typeExtras": { + "currencyIdScale": "0x050b00", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 23, + "symbol": "AIR", + "precision": 18, + "priceId": "altair", + "type": "orml", + "icon": "AIR.svg", + "typeExtras": { + "currencyIdScale": "0x050c00", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 24, + "symbol": "GENS", + "precision": 9, + "type": "orml", + "priceId": "genshiro", + "icon": "GENS.svg", + "typeExtras": { + "currencyIdScale": "0x050e00", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 25, + "symbol": "EQD", + "precision": 9, + "priceId": "tether", + "type": "orml", + "icon": "EQD.svg", + "typeExtras": { + "currencyIdScale": "0x050f00", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "10000000000", + "transfersEnabled": true + } + }, + { + "assetId": 26, + "symbol": "CRAB", + "precision": 18, + "type": "orml", + "priceId": "darwinia-crab-network", + "icon": "CRAB.svg", + "typeExtras": { + "currencyIdScale": "0x050d00", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000000000", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://karura-rpc-0.aca-api.network", + "name": "Acala Foundation 0 node" + }, + { + "url": "wss://karura-rpc-1.aca-api.network", + "name": "Acala Foundation 1 node" + }, + { + "url": "wss://karura-rpc-2.aca-api.network/ws", + "name": "Acala Foundation 2 node" + }, + { + "url": "wss://karura-rpc-3.aca-api.network/ws", + "name": "Acala Foundation 3 node" + }, + { + "url": "wss://karura-rpc.n.dwellir.com", + "name": "Dwellir node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://karura.subscan.io/extrinsic/{hash}", + "account": "https://karura.subscan.io/account/{address}" + } + ], + "externalApi": { + "governance": [ + { + "type": "subsquare", + "url": "https://karura.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Karura.svg", + "addressPrefix": 8, + "options": [ + "governance-v1" + ], + "additional": { + "feeViaRuntimeCall": true, + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Moonbeam", + "assets": [ + { + "assetId": 0, + "symbol": "GLMR", + "precision": 18, + "priceId": "moonbeam", + "icon": "GLMR.svg", + "staking": [ + "parachain" + ], + "buyProviders": { + "transak": { + "network": "MAINNET" + }, + "banxa": { + "coinType": "GLMR", + "blockchain": "GLMR" + } + }, + "sellProviders": { + "transak": { + "network": "MAINNET" + } + } + }, + { + "assetId": 1, + "symbol": "xcDOT", + "precision": 10, + "type": "evm", + "priceId": "polkadot", + "icon": "DOT.svg", + "typeExtras": { + "contractAddress": "0xFfFFfFff1FcaCBd218EDc0EbA20Fc2308C778080" + } + }, + { + "assetId": 2, + "symbol": "xcaSEEDp", + "precision": 12, + "type": "statemine", + "priceId": "ausd-seed-acala", + "icon": "aSEEDp.svg", + "typeExtras": { + "assetId": "110021739665376159354538090254163045594", + "isSufficient": true + } + }, + { + "assetId": 3, + "symbol": "xcACA", + "precision": 12, + "type": "evm", + "priceId": "acala", + "icon": "ACA.svg", + "typeExtras": { + "contractAddress": "0xffffFFffa922Fef94566104a6e5A35a4fCDDAA9f" + } + }, + { + "assetId": 4, + "symbol": "xcPARA", + "precision": 12, + "type": "evm", + "icon": "PARA.svg", + "typeExtras": { + "contractAddress": "0xFfFffFFF18898CB5Fe1E88E668152B4f4052A947" + } + }, + { + "assetId": 5, + "symbol": "xcINTR", + "precision": 10, + "type": "evm", + "priceId": "interlay", + "icon": "INTR.svg", + "typeExtras": { + "contractAddress": "0xFffFFFFF4C1cbCd97597339702436d4F18a375Ab" + } + }, + { + "assetId": 6, + "symbol": "xciBTC", + "precision": 8, + "type": "evm", + "priceId": "bitcoin", + "icon": "iBTC.svg", + "typeExtras": { + "contractAddress": "0xFFFFFfFf5AC1f9A51A93F5C527385edF7Fe98A52" + } + }, + { + "assetId": 8, + "symbol": "xcASTR", + "precision": 18, + "type": "evm", + "priceId": "astar", + "icon": "ASTR.svg", + "typeExtras": { + "contractAddress": "0xFfFFFfffA893AD19e540E172C10d78D4d479B5Cf" + } + }, + { + "assetId": 9, + "symbol": "xcPHA", + "precision": 12, + "type": "evm", + "priceId": "pha", + "icon": "PHA.svg", + "typeExtras": { + "contractAddress": "0xFFFfFfFf63d24eCc8eB8a7b5D0803e900F7b6cED" + } + }, + { + "assetId": 10, + "symbol": "xcUSDT", + "precision": 6, + "type": "evm", + "priceId": "tether", + "icon": "USDT.svg", + "typeExtras": { + "contractAddress": "0xFFFFFFfFea09FB06d082fd1275CD48b191cbCD1d" + } + }, + { + "assetId": 11, + "symbol": "xcCFG", + "precision": 18, + "type": "evm", + "priceId": "centrifuge", + "icon": "CFG.svg", + "typeExtras": { + "contractAddress": "0xFFfFfFff44bD9D2FFEE20B25D1Cf9E78Edb6Eae3" + } + }, + { + "assetId": 12, + "symbol": "xcBNC", + "precision": 12, + "type": "evm", + "priceId": "bifrost-native-coin", + "icon": "BNC.svg", + "typeExtras": { + "contractAddress": "0xFFffffFf7cC06abdF7201b350A1265c62C8601d2" + } + }, + { + "assetId": 13, + "symbol": "xcEQ", + "precision": 9, + "type": "evm", + "priceId": "equilibrium-token", + "icon": "EQ.svg", + "typeExtras": { + "contractAddress": "0xFffFFfFf8f6267e040D8a0638C576dfBa4F0F6D6" + } + }, + { + "assetId": 14, + "symbol": "xcEQD", + "precision": 9, + "type": "evm", + "priceId": "tether", + "icon": "EQD.svg", + "typeExtras": { + "contractAddress": "0xFFffFfFF8cdA1707bAF23834d211B08726B1E499" + } + }, + { + "assetId": 15, + "symbol": "xcHDX", + "precision": 12, + "type": "evm", + "priceId": "hydradx", + "icon": "HDX.svg", + "typeExtras": { + "contractAddress": "0xFFFfFfff345Dc44DDAE98Df024Eb494321E73FcC" + } + }, + { + "assetId": 16, + "symbol": "xcNODL", + "precision": 11, + "type": "evm", + "priceId": "nodle-network", + "icon": "NODL.svg", + "typeExtras": { + "contractAddress": "0xfffffffFe896ba7Cb118b9Fa571c6dC0a99dEfF1" + } + }, + { + "assetId": 17, + "symbol": "xcRING", + "precision": 18, + "type": "evm", + "priceId": "darwinia-network-native-token", + "icon": "RING.svg", + "typeExtras": { + "contractAddress": "0xFfffFfff5e90e365eDcA87fB4c8306Df1E91464f" + } + }, + { + "assetId": 18, + "symbol": "xcOTP", + "precision": 12, + "type": "statemine", + "icon": "OTP.svg", + "typeExtras": { + "assetId": "238111524681612888331172110363070489924", + "isSufficient": true + } + }, + { + "assetId": 19, + "symbol": "xcvDOT", + "precision": 10, + "type": "evm", + "priceId": "voucher-dot", + "icon": "vDOT.svg", + "typeExtras": { + "contractAddress": "0xFFFfffFf15e1b7E3dF971DD813Bc394deB899aBf" + } + }, + { + "assetId": 20, + "symbol": "xcvFIL", + "precision": 18, + "type": "evm", + "icon": "vFIL.svg", + "typeExtras": { + "contractAddress": "0xFffffFffCd0aD0EA6576B7b285295c85E94cf4c1" + } + }, + { + "assetId": 21, + "symbol": "xcvGLMR", + "precision": 18, + "priceId": "voucher-glmr", + "type": "evm", + "icon": "vGLMR.svg", + "typeExtras": { + "contractAddress": "0xFfFfFFff99dABE1a8De0EA22bAa6FD48fdE96F6c" + } + }, + { + "assetId": 22, + "symbol": "xcMANTA", + "precision": 18, + "type": "evm", + "priceId": "manta-network", + "icon": "MANTA.svg", + "typeExtras": { + "contractAddress": "0xfFFffFFf7D3875460d4509eb8d0362c611B4E841" + } + }, + { + "assetId": 23, + "symbol": "xcUSDC", + "precision": 6, + "type": "evm", + "priceId": "usd-coin", + "icon": "USDC.svg", + "typeExtras": { + "contractAddress": "0xFFfffffF7D2B0B761Af01Ca8e25242976ac0aD7D" + } + } + ], + "nodes": [ + { + "url": "wss://wss.api.moonbeam.network", + "name": "Moonbeam Foundation node" + }, + { + "url": "wss://moonbeam.ibp.network", + "name": "IBP1 node" + }, + { + "url": "wss://moonbeam.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://moonbeam.public.curie.radiumblock.co/ws", + "name": "RadiumBlock node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://moonbeam.subscan.io/extrinsic/{hash}", + "account": "https://moonbeam.subscan.io/account/{address}" + }, + { + "name": "Moonscan", + "extrinsic": "https://moonbeam.moonscan.io/tx/{hash}", + "account": "https://moonbeam.moonscan.io/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-moonbeam-v2-prod.novasama-tech.org" + }, + { + "type": "etherscan", + "url": "https://api-moonbeam.moonscan.io/api", + "parameters": { + "assetType": "evm" + } + } + ], + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-moonbeam-v2-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-moonbeam-v2-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "polkassembly", + "url": "https://api.moonbeam.polkassembly.network/v1/graphql" + } + ], + "governance-delegations": [ + { + "type": "subquery", + "url": "https://subquery-governance-moonbeam-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/moonbeam.svg", + "addressPrefix": 1284, + "options": [ + "ethereumBased", + "governance", + "proxy", + "multisig" + ], + "additional": { + "themeColor": "#9968CE", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/moonbeam-glmr-staking", + "defaultBlockTime": 6000, + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "eip155:1", + "name": "Ethereum", + "assets": [ + { + "assetId": 0, + "symbol": "ETH", + "priceId": "ethereum", + "type": "evmNative", + "icon": "ETH.svg", + "precision": 18, + "buyProviders": { + "transak": { + "network": "ETHEREUM" + }, + "mercuryo": { + "network": "ETHEREUM" + }, + "banxa": { + "coinType": "ETH", + "blockchain": "ETH" + } + }, + "sellProviders": { + "transak": { + "network": "ETHEREUM" + }, + "mercuryo": { + "network": "ETHEREUM" + } + } + } + ], + "nodeSelectionStrategy": "roundRobin", + "nodes": [ + { + "url": "https://ethereum-rpc.publicnode.com", + "name": "Allnodes rpc node" + }, + { + "url": "https://1rpc.io/eth", + "name": "One rpc node" + }, + { + "url": "wss://mainnet.infura.io/ws/v3/32a2be59297444c9bcb2b61bb700c6fe", + "name": "Infura node 5" + }, + { + "url": "wss://mainnet.infura.io/ws/v3/{INFURA_API_KEY}", + "name": "Infura node" + }, + { + "url": "wss://mainnet.infura.io/ws/v3/1e69544301064ef19edb194a14fb75f3", + "name": "Infura node 2" + }, + { + "url": "wss://mainnet.infura.io/ws/v3/9dddd77ac74043dc9a8dc48f82822c7d", + "name": "Infura node 4" + }, + { + "url": "wss://mainnet.infura.io/ws/v3/82fd5b2925e341719f10b7ed4376a646", + "name": "Infura node 6" + } + ], + "explorers": [ + { + "name": "Etherscan", + "extrinsic": "https://etherscan.io/tx/{hash}", + "account": "https://etherscan.io/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "etherscan", + "url": "https://api.etherscan.io/api", + "parameters": { + "assetType": "evm" + } + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Ethereum.svg", + "addressPrefix": 1, + "options": [ + "ethereumBased", + "noSubstrateRuntime" + ] + }, + { + "chainId": "401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Moonriver", + "assets": [ + { + "assetId": 0, + "symbol": "MOVR", + "precision": 18, + "priceId": "moonriver", + "icon": "MOVR.svg", + "staking": [ + "parachain" + ], + "buyProviders": { + "banxa": { + "coinType": "MOVR", + "blockchain": "MOVR" + } + } + }, + { + "assetId": 1, + "symbol": "xcRMRK (old)", + "precision": 10, + "priceId": "rmrk", + "type": "evm", + "icon": "RMRK.svg", + "typeExtras": { + "contractAddress": "0xffffffFF893264794d9d57E1E0E21E0042aF5A0A" + } + }, + { + "assetId": 2, + "symbol": "xcKSM", + "precision": 12, + "priceId": "kusama", + "type": "evm", + "icon": "KSM.svg", + "typeExtras": { + "contractAddress": "0xFfFFfFff1FcaCBd218EDc0EbA20Fc2308C778080" + } + }, + { + "assetId": 3, + "symbol": "xcKINT", + "precision": 12, + "priceId": "kintsugi", + "type": "evm", + "icon": "KINT.svg", + "typeExtras": { + "contractAddress": "0xfffFFFFF83F4f317d3cbF6EC6250AeC3697b3fF2" + } + }, + { + "assetId": 4, + "symbol": "xcKAR", + "precision": 12, + "priceId": "karura", + "type": "evm", + "icon": "KAR.svg", + "typeExtras": { + "contractAddress": "0xFfFFFFfF08220AD2E6e157f26eD8bD22A336A0A5" + } + }, + { + "assetId": 5, + "symbol": "xcBNC", + "precision": 12, + "priceId": "bifrost-native-coin", + "type": "evm", + "icon": "BNC.svg", + "typeExtras": { + "contractAddress": "0xFFfFFfFFF075423be54811EcB478e911F22dDe7D" + } + }, + { + "assetId": 6, + "symbol": "xckBTC", + "precision": 8, + "priceId": "bitcoin", + "type": "evm", + "icon": "kBTC.svg", + "typeExtras": { + "contractAddress": "0xFFFfFfFfF6E528AD57184579beeE00c5d5e646F0" + } + }, + { + "assetId": 7, + "symbol": "xcUSDT", + "precision": 6, + "priceId": "tether", + "type": "evm", + "icon": "USDT.svg", + "typeExtras": { + "contractAddress": "0xFFFFFFfFea09FB06d082fd1275CD48b191cbCD1d" + } + }, + { + "assetId": 8, + "symbol": "xcaSEEDk", + "precision": 12, + "priceId": "ausd-seed-karura", + "type": "statemine", + "icon": "aSEEDk.svg", + "typeExtras": { + "assetId": "214920334981412447805621250067209749032", + "isSufficient": true + } + }, + { + "assetId": 9, + "symbol": "xcCSM", + "precision": 12, + "priceId": "crust-storage-market", + "type": "evm", + "icon": "CSM.svg", + "typeExtras": { + "contractAddress": "0xffFfFFFf519811215E05eFA24830Eebe9c43aCD7" + } + }, + { + "assetId": 10, + "symbol": "xcPHA", + "precision": 12, + "priceId": "pha", + "type": "evm", + "icon": "PHA.svg", + "typeExtras": { + "contractAddress": "0xffFfFFff8E6b63d9e447B6d4C45BDA8AF9dc9603" + } + }, + { + "assetId": 11, + "symbol": "xcHKO", + "precision": 12, + "type": "evm", + "icon": "HKO.svg", + "typeExtras": { + "contractAddress": "0xffffffFF394054BCDa1902B6A6436840435655a3" + } + }, + { + "assetId": 12, + "symbol": "xcKMA", + "precision": 12, + "priceId": "calamari-network", + "type": "evm", + "icon": "KMA.svg", + "typeExtras": { + "contractAddress": "0xFFffFffFA083189f870640b141ae1E882c2b5bad" + } + }, + { + "assetId": 13, + "symbol": "xcCRAB", + "precision": 18, + "priceId": "darwinia-crab-network", + "type": "evm", + "icon": "CRAB.svg", + "typeExtras": { + "contractAddress": "0xFFFffFfF8283448b3cB519Ca4732F2ddDC6A6165" + } + }, + { + "assetId": 14, + "symbol": "xcTEER", + "precision": 12, + "priceId": "integritee", + "type": "evm", + "icon": "TEER.svg", + "typeExtras": { + "contractAddress": "0xFfFfffFf4F0CD46769550E5938F6beE2F5d4ef1e" + } + }, + { + "assetId": 15, + "symbol": "xcLIT", + "precision": 12, + "priceId": "litentry", + "type": "evm", + "icon": "LIT.svg", + "typeExtras": { + "contractAddress": "0xfffFFfFF31103d490325BB0a8E40eF62e2F614C0" + } + }, + { + "assetId": 16, + "symbol": "xcSDN", + "precision": 18, + "priceId": "shiden", + "type": "evm", + "icon": "SDN.svg", + "typeExtras": { + "contractAddress": "0xFFFfffFF0Ca324C842330521525E7De111F38972" + } + }, + { + "assetId": 17, + "symbol": "xcXRT", + "precision": 9, + "priceId": "robonomics-network", + "type": "evm", + "icon": "XRT.svg", + "typeExtras": { + "contractAddress": "0xFffFFffF51470Dca3dbe535bD2880a9CcDBc6Bd9" + } + }, + { + "assetId": 18, + "symbol": "xcvKSM", + "precision": 12, + "priceId": "voucher-ksm", + "type": "evm", + "icon": "vKSM.svg", + "typeExtras": { + "contractAddress": "0xFFffffFFC6DEec7Fc8B11A2C8ddE9a59F8c62EFe" + } + }, + { + "assetId": 19, + "symbol": "xcvBNC", + "precision": 12, + "type": "evm", + "icon": "vBNC.svg", + "typeExtras": { + "contractAddress": "0xFFffffff3646A00f78caDf8883c5A2791BfCDdc4" + } + }, + { + "assetId": 20, + "symbol": "xcvMOVR", + "precision": 18, + "type": "evm", + "icon": "vMOVR.svg", + "typeExtras": { + "contractAddress": "0xfFfffFfF98e37bF6a393504b5aDC5B53B4D0ba11" + } + }, + { + "assetId": 21, + "symbol": "xcMGX", + "precision": 18, + "type": "evm", + "priceId": "mangata-x", + "icon": "MGX.svg", + "typeExtras": { + "contractAddress": "0xffFfFffF58d867EEa1Ce5126A4769542116324e9" + } + }, + { + "assetId": 22, + "symbol": "xcTUR", + "precision": 10, + "type": "evm", + "priceId": "turing-network", + "icon": "TUR.svg", + "typeExtras": { + "contractAddress": "0xfFffffFf6448d0746f2a66342B67ef9CAf89478E" + } + } + ], + "nodes": [ + { + "url": "wss://wss.api.moonriver.moonbeam.network", + "name": "Moonbeam Foundation node" + }, + { + "url": "wss://moonriver-rpc.publicnode.com", + "name": "Allnodes node" + }, + { + "url": "wss://moonriver.public.curie.radiumblock.co/ws", + "name": "RadiumBlock node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://moonriver.subscan.io/extrinsic/{hash}", + "account": "https://moonriver.subscan.io/account/{address}" + }, + { + "name": "Moonscan", + "extrinsic": "https://moonriver.moonscan.io/tx/{hash}", + "account": "https://moonriver.moonscan.io/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-moonriver-prod.novasama-tech.org" + }, + { + "type": "etherscan", + "url": "https://api-moonriver.moonscan.io/api", + "parameters": { + "assetType": "evm" + } + } + ], + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-moonriver-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-moonriver-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://moonriver.subsquare.io/api" + } + ], + "governance-delegations": [ + { + "type": "subquery", + "url": "https://subquery-governance-moonriver-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/moonriver.svg", + "addressPrefix": 1285, + "options": [ + "ethereumBased", + "governance", + "proxy" + ], + "additional": { + "themeColor": "#20A0B6", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/moonriver-movr-staking", + "defaultBlockTime": 6000, + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "f1cf9022c7ebb34b162d5b5e34e705a5a740b2d0ecc1009fb89023e62a488108", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Shiden", + "assets": [ + { + "assetId": 0, + "symbol": "SDN", + "precision": 18, + "icon": "SDN.svg", + "priceId": "shiden" + }, + { + "assetId": 1, + "symbol": "PHA", + "precision": 12, + "type": "statemine", + "priceId": "pha", + "icon": "PHA.svg", + "typeExtras": { + "assetId": "18446744073709551623", + "isSufficient": true + } + }, + { + "assetId": 2, + "symbol": "LKSM", + "precision": 12, + "priceId": "liquid-ksm", + "type": "statemine", + "icon": "LKSM.svg", + "typeExtras": { + "assetId": "18446744073709551619", + "isSufficient": true + } + }, + { + "assetId": 3, + "symbol": "MOVR", + "precision": 18, + "type": "statemine", + "priceId": "moonriver", + "icon": "MOVR.svg", + "typeExtras": { + "assetId": "18446744073709551620", + "isSufficient": true + } + }, + { + "assetId": 4, + "symbol": "kBTC", + "precision": 8, + "type": "statemine", + "priceId": "bitcoin", + "icon": "kBTC.svg", + "typeExtras": { + "assetId": "18446744073709551621", + "isSufficient": true + } + }, + { + "assetId": 5, + "symbol": "KINT", + "precision": 12, + "type": "statemine", + "priceId": "kintsugi", + "icon": "KINT.svg", + "typeExtras": { + "assetId": "18446744073709551622", + "isSufficient": true + } + }, + { + "assetId": 6, + "symbol": "KSM", + "precision": 12, + "type": "statemine", + "priceId": "kusama", + "icon": "KSM.svg", + "typeExtras": { + "assetId": "340282366920938463463374607431768211455", + "isSufficient": true + } + }, + { + "assetId": 7, + "symbol": "aSEEDk", + "precision": 12, + "type": "statemine", + "priceId": "ausd-seed-karura", + "icon": "aSEEDk.svg", + "typeExtras": { + "assetId": "18446744073709551616", + "isSufficient": true + } + }, + { + "assetId": 8, + "symbol": "CSM", + "precision": 12, + "type": "statemine", + "priceId": "crust-storage-market", + "icon": "CSM.svg", + "typeExtras": { + "assetId": "18446744073709551624", + "isSufficient": true + } + }, + { + "assetId": 9, + "symbol": "KAR", + "precision": 12, + "type": "statemine", + "priceId": "karura", + "icon": "KAR.svg", + "typeExtras": { + "assetId": "18446744073709551618", + "isSufficient": true + } + }, + { + "assetId": 10, + "symbol": "USDT", + "precision": 6, + "type": "statemine", + "priceId": "tether", + "icon": "USDT.svg", + "typeExtras": { + "assetId": "4294969280", + "isSufficient": true + } + }, + { + "assetId": 11, + "symbol": "vKSM", + "precision": 12, + "priceId": "voucher-ksm", + "type": "statemine", + "icon": "vKSM.svg", + "typeExtras": { + "assetId": "18446744073709551628", + "isSufficient": true + } + }, + { + "assetId": 12, + "symbol": "BNC", + "precision": 12, + "type": "statemine", + "priceId": "bifrost-native-coin", + "icon": "BNC.svg", + "typeExtras": { + "assetId": "18446744073709551627", + "isSufficient": true + } + } + ], + "nodes": [ + { + "url": "wss://rpc.shiden.astar.network", + "name": "StakeTechnologies node" + }, + { + "url": "wss://shiden-rpc.n.dwellir.com", + "name": "Dwellir node" + }, + { + "url": "wss://shiden.public.curie.radiumblock.co/ws", + "name": "RadiumBlock node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://shiden.subscan.io/extrinsic/{hash}", + "account": "https://shiden.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Shiden.svg", + "addressPrefix": 5, + "additional": { + "feeViaRuntimeCall": true + } + }, + { + "chainId": "9f28c6a68e0fc9646eff64935684f6eeeece527e37bbe1f213d22caa1d9d6bed", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Bifrost Kusama", + "assets": [ + { + "assetId": 0, + "symbol": "BNC", + "priceId": "bifrost-native-coin", + "precision": 12, + "icon": "BNC.svg", + "buyProviders": { + "banxa": { + "coinType": "BNC", + "blockchain": "BNC" + } + } + }, + { + "assetId": 1, + "symbol": "KSM", + "precision": 12, + "icon": "KSM.svg", + "priceId": "kusama", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0204", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "RMRK (old)", + "precision": 10, + "type": "orml", + "icon": "RMRK.svg", + "priceId": "rmrk", + "typeExtras": { + "currencyIdScale": "0x0209", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "10000", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "ZLK", + "precision": 18, + "priceId": "zenlink-network-token", + "type": "orml", + "icon": "ZLK.svg", + "typeExtras": { + "currencyIdScale": "0x0207", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "KAR", + "precision": 12, + "priceId": "karura", + "type": "orml", + "icon": "KAR.svg", + "typeExtras": { + "currencyIdScale": "0x0206", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "aSEEDk", + "precision": 12, + "priceId": "ausd-seed-karura", + "type": "orml", + "icon": "aSEEDk.svg", + "typeExtras": { + "currencyIdScale": "0x0302", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "vsKSM", + "precision": 12, + "type": "orml", + "icon": "vsKSM.svg", + "typeExtras": { + "currencyIdScale": "0x0404", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 7, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "orml", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x0800", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 8, + "symbol": "MOVR", + "precision": 18, + "type": "orml", + "priceId": "moonriver", + "icon": "MOVR.svg", + "typeExtras": { + "currencyIdScale": "0x020a", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 9, + "symbol": "PHA", + "precision": 12, + "type": "orml", + "priceId": "pha", + "icon": "PHA.svg", + "typeExtras": { + "currencyIdScale": "0x0208", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "40000000000", + "transfersEnabled": true + } + }, + { + "assetId": 10, + "symbol": "vKSM", + "precision": 12, + "priceId": "voucher-ksm", + "type": "orml", + "icon": "vKSM.svg", + "typeExtras": { + "currencyIdScale": "0x0104", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 11, + "symbol": "kBTC", + "precision": 8, + "type": "orml", + "priceId": "bitcoin", + "icon": "kBTC.svg", + "typeExtras": { + "currencyIdScale": "0x0802", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "100", + "transfersEnabled": true + } + }, + { + "assetId": 12, + "symbol": "vBNC", + "precision": 12, + "type": "orml", + "icon": "vBNC.svg", + "typeExtras": { + "currencyIdScale": "0x0101", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "10000000000", + "transfersEnabled": true + } + }, + { + "assetId": 13, + "symbol": "vMOVR", + "precision": 18, + "type": "orml", + "icon": "vMOVR.svg", + "typeExtras": { + "currencyIdScale": "0x010a", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://bifrost-rpc.liebi.com/ws", + "name": "Liebi node" + }, + { + "url": "wss://us.bifrost-rpc.liebi.com/ws", + "name": "LiebiUS node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://bifrost-kusama.subscan.io/extrinsic/{hash}", + "account": "https://bifrost-kusama.subscan.io/account/{address}" + } + ], + "externalApi": { + "governance": [ + { + "type": "subsquare", + "url": "https://bifrost-kusama.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Bifrost_Kusama.svg", + "addressPrefix": 0, + "legacyAddressPrefix": 6, + "options": [ + "governance" + ], + "additional": { + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "a85cfb9b9fd4d622a5b28289a02347af987d8f73fa3108450e2b4a11c1ce5755", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Basilisk", + "assets": [ + { + "assetId": 0, + "symbol": "BSX", + "precision": 12, + "priceId": "basilisk", + "icon": "BSX.svg", + "buyProviders": { + "banxa": { + "coinType": "BSX", + "blockchain": "BSX" + } + } + }, + { + "assetId": 1, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "type": "orml", + "icon": "KSM.svg", + "typeExtras": { + "currencyIdScale": "0x01000000", + "currencyIdType": "u32", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "aSEEDk", + "precision": 12, + "priceId": "ausd-seed-karura", + "type": "orml", + "icon": "aSEEDk.svg", + "typeExtras": { + "currencyIdScale": "0x02000000", + "currencyIdType": "u32", + "existentialDeposit": "10000000000", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "TNKR", + "precision": 12, + "type": "orml", + "icon": "TNKR.svg", + "typeExtras": { + "currencyIdScale": "0x06000000", + "currencyIdType": "u32", + "existentialDeposit": "1000000000", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "USDT", + "precision": 6, + "type": "orml", + "priceId": "tether", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x0e000000", + "currencyIdType": "u32", + "existentialDeposit": "10000", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "XRT", + "precision": 9, + "type": "orml", + "priceId": "robonomics-network", + "icon": "XRT.svg", + "typeExtras": { + "currencyIdScale": "0x10000000", + "currencyIdType": "u32", + "existentialDeposit": "1683502", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://basilisk-rpc.n.dwellir.com", + "name": "Dwellir node" + }, + { + "url": "wss://rpc.basilisk.cloud", + "name": "Basilisk node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://basilisk.subscan.io/extrinsic/{hash}", + "account": "https://basilisk.subscan.io/account/{address}" + } + ], + "externalApi": { + "governance": [ + { + "type": "subsquare", + "url": "https://basilisk.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Basilisk.svg", + "addressPrefix": 10041, + "options": [ + "governance-v1" + ], + "types": { + "url": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v2/types/basilisk.json", + "overridesCommon": true + }, + "additional": { + "feeViaRuntimeCall": true + } + }, + { + "chainId": "aa3876c1dc8a1afcc2e9a685a49ff7704cfd36ad8c90bf2702b9d1b00cc40011", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Altair", + "assets": [ + { + "assetId": 0, + "symbol": "AIR", + "precision": 18, + "icon": "AIR.svg", + "priceId": "altair" + } + ], + "nodes": [ + { + "url": "wss://altair.api.onfinality.io/public-ws", + "name": "OnFinality node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://altair.subscan.io/extrinsic/{hash}", + "account": "https://altair.subscan.io/account/{address}" + } + ], + "externalApi": { + "governance": [ + { + "type": "subsquare", + "url": "https://altair.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Altair.svg", + "addressPrefix": 136, + "options": [ + "governance-v1" + ], + "additional": { + "feeViaRuntimeCall": true, + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "742a2ca70c2fda6cee4f8df98d64c4c670a052d9568058982dad9d5a7a135c5b", + "name": "Edgeware", + "assets": [ + { + "assetId": 0, + "symbol": "EDG", + "precision": 18, + "priceId": "edgeware", + "icon": "EDG.svg" + } + ], + "nodes": [ + { + "url": "wss://edgeware-rpc3.jelliedowl.net", + "name": "JelliedOwl node" + } + ], + "explorers": [ + { + "name": "Edgescan", + "extrinsic": "https://edgscan.ink/#/extrinsics/{hash}", + "event": "https://edgscan.ink/#/events/{event}", + "account": "https://edgscan.ink/#/accounts/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Edgeware.svg", + "addressPrefix": 7 + }, + { + "chainId": "411f057b9107718c9624d6aa4a3f23c1653898297f3d4d529d9bb6511a39dd21", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "KILT", + "assets": [ + { + "assetId": 0, + "symbol": "KILT", + "priceId": "kilt-protocol", + "icon": "KILT.svg", + "precision": 15, + "buyProviders": { + "banxa": { + "coinType": "KILT", + "blockchain": "KILT" + } + } + } + ], + "nodes": [ + { + "url": "wss://kilt.ibp.network", + "name": "IBP1 node" + }, + { + "url": "wss://kilt.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://spiritnet.kilt.io/", + "name": "KILT Protocol node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://spiritnet.subscan.io/extrinsic/{hash}", + "account": "https://spiritnet.subscan.io/account/{address}" + } + ], + "externalApi": { + "governance": [ + { + "type": "polkassembly", + "url": "https://kilt-hasura.herokuapp.com/v1/graphql" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/KILT_Spiritnet.svg", + "addressPrefix": 38, + "options": [ + "governance-v1" + ], + "additional": { + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "cd4d732201ebe5d6b014edda071c4203e16867305332301dc8d092044b28e554", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "QUARTZ", + "assets": [ + { + "assetId": 0, + "symbol": "QTZ", + "priceId": "quartz", + "icon": "QTZ.svg", + "precision": 18 + } + ], + "nodes": [ + { + "url": "wss://quartz.unique.network", + "name": "Unique node" + }, + { + "url": "wss://eu-ws-quartz.unique.network", + "name": "Unique Europe node" + }, + { + "url": "wss://us-ws-quartz.unique.network", + "name": "Unique US node" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Quartz.svg", + "addressPrefix": 255, + "additional": { + "feeViaRuntimeCall": true + } + }, + { + "chainId": "fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Acala", + "assets": [ + { + "assetId": 0, + "symbol": "ACA", + "priceId": "acala", + "precision": 12, + "icon": "ACA.svg", + "buyProviders": { + "banxa": { + "coinType": "ACA", + "blockchain": "ACA" + } + } + }, + { + "assetId": 1, + "symbol": "LDOT", + "precision": 10, + "priceId": "liquid-staking-dot", + "type": "orml", + "icon": "LDOT.svg", + "typeExtras": { + "currencyIdScale": "0x0003", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "500000000", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "aSEEDp", + "precision": 12, + "priceId": "ausd-seed-acala", + "type": "orml", + "icon": "aSEEDp.svg", + "typeExtras": { + "currencyIdScale": "0x0001", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "type": "orml", + "icon": "DOT.svg", + "typeExtras": { + "currencyIdScale": "0x0002", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "lcDOT", + "precision": 10, + "priceId": "polkadot", + "type": "orml", + "icon": "lcDOT.svg", + "typeExtras": { + "currencyIdScale": "0x040d000000", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "GLMR", + "precision": 18, + "priceId": "moonbeam", + "type": "orml", + "icon": "GLMR.svg", + "typeExtras": { + "currencyIdScale": "0x050000", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "PARA", + "precision": 12, + "type": "orml", + "icon": "PARA.svg", + "typeExtras": { + "currencyIdScale": "0x050100", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000", + "transfersEnabled": true + } + }, + { + "assetId": 7, + "symbol": "TAP", + "precision": 12, + "type": "orml", + "icon": "TAP.svg", + "typeExtras": { + "currencyIdScale": "0x0004", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 8, + "symbol": "tDOT", + "precision": 10, + "type": "orml", + "icon": "tDOT.svg", + "typeExtras": { + "currencyIdScale": "0x0300000000", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000", + "transfersEnabled": true + } + }, + { + "assetId": 9, + "symbol": "INTR", + "precision": 10, + "type": "orml", + "priceId": "interlay", + "icon": "INTR.svg", + "typeExtras": { + "currencyIdScale": "0x050400", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000", + "transfersEnabled": true + } + }, + { + "assetId": 10, + "symbol": "ASTR", + "precision": 18, + "type": "orml", + "priceId": "astar", + "icon": "ASTR.svg", + "typeExtras": { + "currencyIdScale": "0x050200", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 11, + "symbol": "EQ", + "precision": 9, + "priceId": "equilibrium-token", + "type": "orml", + "icon": "EQ.svg", + "typeExtras": { + "currencyIdScale": "0x050700", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000", + "transfersEnabled": true + } + }, + { + "assetId": 12, + "symbol": "iBTC", + "precision": 8, + "type": "orml", + "priceId": "bitcoin", + "icon": "iBTC.svg", + "typeExtras": { + "currencyIdScale": "0x050300", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "100", + "transfersEnabled": true + } + }, + { + "assetId": 13, + "symbol": "DAI", + "precision": 18, + "type": "orml", + "priceId": "dai", + "icon": "DAI.svg", + "typeExtras": { + "currencyIdScale": "0x0254a37a01cd75b616d63e0ab665bffdb0143c52ae", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "10000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 14, + "symbol": "USDT", + "precision": 6, + "type": "orml", + "priceId": "tether", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x050c00", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "10000", + "transfersEnabled": true + } + }, + { + "assetId": 15, + "symbol": "PINK", + "precision": 10, + "type": "orml", + "icon": "PINK.svg", + "typeExtras": { + "currencyIdScale": "0x050d00", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000", + "transfersEnabled": true + } + }, + { + "assetId": 16, + "symbol": "NEMO", + "precision": 18, + "type": "orml", + "icon": "Default.svg", + "typeExtras": { + "currencyIdScale": "0x02fa904c86b73fd041d6cc2aeed9e6ec0148fd51da", + "currencyIdType": "acala_primitives.currency.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://acala-rpc-0.aca-api.network", + "name": "Acala Foundation 0 node" + }, + { + "url": "wss://acala-rpc-1.aca-api.network", + "name": "Acala Foundation 1 node" + }, + { + "url": "wss://acala-rpc-2.aca-api.network/ws", + "name": "Acala Foundation 2 node" + }, + { + "url": "wss://acala-rpc-3.aca-api.network/ws", + "name": "Acala Foundation 3 node" + }, + { + "url": "wss://acala-rpc.n.dwellir.com", + "name": "Dwellir node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://acala.subscan.io/extrinsic/{hash}", + "account": "https://acala.subscan.io/account/{address}" + } + ], + "externalApi": { + "governance": [ + { + "type": "subsquare", + "url": "https://acala.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Acala.svg", + "addressPrefix": 10, + "additional": { + "feeViaRuntimeCall": true, + "supportsGenericLedgerApp": true + }, + "options": [ + "governance-v1" + ] + }, + { + "chainId": "9eb76c5184c4ab8679d2d5d819fdf90b9c001403e9e17da2e14b6d8aec4029c6", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Astar", + "assets": [ + { + "assetId": 0, + "symbol": "ASTR", + "priceId": "astar", + "icon": "ASTR.svg", + "precision": 18, + "buyProviders": { + "banxa": { + "coinType": "ASTR", + "blockchain": "ASTR" + } + } + }, + { + "assetId": 1, + "symbol": "DOT", + "precision": 10, + "type": "statemine", + "priceId": "polkadot", + "icon": "DOT.svg", + "typeExtras": { + "assetId": "340282366920938463463374607431768211455", + "isSufficient": true + } + }, + { + "assetId": 2, + "symbol": "GLMR", + "precision": 18, + "type": "statemine", + "priceId": "moonbeam", + "icon": "GLMR.svg", + "typeExtras": { + "assetId": "18446744073709551619", + "isSufficient": true + } + }, + { + "assetId": 3, + "symbol": "iBTC", + "precision": 8, + "type": "statemine", + "priceId": "bitcoin", + "icon": "iBTC.svg", + "typeExtras": { + "assetId": "18446744073709551620", + "isSufficient": true + } + }, + { + "assetId": 4, + "symbol": "INTR", + "precision": 10, + "type": "statemine", + "priceId": "interlay", + "icon": "INTR.svg", + "typeExtras": { + "assetId": "18446744073709551621", + "isSufficient": true + } + }, + { + "assetId": 5, + "symbol": "PHA", + "precision": 12, + "type": "statemine", + "priceId": "pha", + "icon": "PHA.svg", + "typeExtras": { + "assetId": "18446744073709551622", + "isSufficient": true + } + }, + { + "assetId": 6, + "symbol": "ACA", + "precision": 12, + "type": "statemine", + "priceId": "acala", + "icon": "ACA.svg", + "typeExtras": { + "assetId": "18446744073709551616", + "isSufficient": true + } + }, + { + "assetId": 7, + "symbol": "LDOT", + "precision": 10, + "type": "statemine", + "priceId": "liquid-staking-dot", + "icon": "LDOT.svg", + "typeExtras": { + "assetId": "18446744073709551618", + "isSufficient": true + } + }, + { + "assetId": 8, + "symbol": "aSEEDp", + "precision": 12, + "type": "statemine", + "priceId": "ausd-seed-acala", + "icon": "aSEEDp.svg", + "typeExtras": { + "assetId": "18446744073709551617", + "isSufficient": true + } + }, + { + "assetId": 9, + "symbol": "USDT", + "precision": 6, + "type": "statemine", + "priceId": "tether", + "icon": "USDT.svg", + "typeExtras": { + "assetId": "4294969280", + "isSufficient": true + } + }, + { + "assetId": 10, + "symbol": "BNC", + "precision": 12, + "type": "statemine", + "priceId": "bifrost-native-coin", + "icon": "BNC.svg", + "typeExtras": { + "assetId": "18446744073709551623", + "isSufficient": true + } + }, + { + "assetId": 11, + "symbol": "UNQ", + "precision": 18, + "type": "statemine", + "priceId": "unique-network", + "icon": "UNQ.svg", + "typeExtras": { + "assetId": "18446744073709551631", + "isSufficient": true + } + }, + { + "assetId": 12, + "symbol": "vDOT", + "precision": 10, + "type": "statemine", + "priceId": "voucher-dot", + "icon": "vDOT.svg", + "typeExtras": { + "assetId": "18446744073709551624", + "isSufficient": true + } + }, + { + "assetId": 13, + "symbol": "EQD", + "precision": 9, + "type": "statemine", + "priceId": "tether", + "icon": "EQD.svg", + "typeExtras": { + "assetId": "18446744073709551629", + "isSufficient": true + } + } + ], + "nodes": [ + { + "url": "wss://rpc.astar.network", + "name": "Astar node" + }, + { + "url": "wss://astar-rpc.n.dwellir.com", + "name": "Dwellir node" + }, + { + "url": "wss://astar.public.curie.radiumblock.co/ws", + "name": "RadiumBlock node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://astar.subscan.io/extrinsic/{hash}", + "account": "https://astar.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-astar-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Astar.svg", + "addressPrefix": 5, + "additional": { + "defaultTip": "1000000", + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Polkadot Asset Hub", + "assets": [ + { + "assetId": 0, + "symbol": "DOT", + "precision": 10, + "name": "Polkadot", + "priceId": "polkadot", + "icon": "DOT.svg", + "staking": [ + "relaychain", + "nomination-pools" + ], + "buyProviders": { + "mercuryo": {} + }, + "sellProviders": { + "mercuryo": {} + } + }, + { + "assetId": 1, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "statemine", + "icon": "USDT.svg", + "typeExtras": { + "assetId": "1984", + "isSufficient": true + } + }, + { + "assetId": 2, + "symbol": "USDC", + "precision": 6, + "priceId": "usd-coin", + "type": "statemine", + "icon": "USDC.svg", + "typeExtras": { + "assetId": "1337", + "isSufficient": true + } + }, + { + "assetId": 3, + "symbol": "DED", + "precision": 10, + "priceId": "dot-is-ded", + "type": "statemine", + "icon": "DED.svg", + "typeExtras": { + "assetId": "30" + } + }, + { + "assetId": 4, + "symbol": "PINK", + "precision": 10, + "type": "statemine", + "icon": "PINK.svg", + "typeExtras": { + "assetId": "23" + } + }, + { + "assetId": 5, + "symbol": "DOTA", + "precision": 4, + "type": "statemine", + "icon": "DOTA.svg", + "typeExtras": { + "assetId": "18" + } + }, + { + "assetId": 6, + "symbol": "STINK", + "precision": 10, + "type": "statemine", + "icon": "STINK.svg", + "typeExtras": { + "assetId": "42069" + } + }, + { + "assetId": 7, + "symbol": "GABE", + "precision": 20, + "type": "statemine", + "icon": "GABE.svg", + "typeExtras": { + "assetId": "69420" + } + }, + { + "assetId": 8, + "symbol": "WUD", + "precision": 10, + "priceId": "gavun-wud", + "type": "statemine", + "icon": "WUD.svg", + "typeExtras": { + "assetId": "31337" + } + }, + { + "assetId": 9, + "symbol": "WETH-Snowbridge", + "precision": 18, + "priceId": "ethereum-wormhole", + "type": "statemine", + "icon": "WETH-Snowbridge.svg", + "typeExtras": { + "assetId": "0x02020907040300c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "palletName": "ForeignAssets", + "isSufficient": true + } + }, + { + "assetId": 10, + "symbol": "WBTC-Snowbridge", + "precision": 8, + "priceId": "wrapped-bitcoin", + "type": "statemine", + "icon": "WBTC-Snowbridge.svg", + "typeExtras": { + "assetId": "0x020209070403002260fac5e5542a773aa44fbcfedf7c193bc2c599", + "palletName": "ForeignAssets" + } + }, + { + "assetId": 11, + "symbol": "WIFD", + "precision": 10, + "type": "statemine", + "icon": "WIFD.svg", + "typeExtras": { + "assetId": "17" + } + }, + { + "assetId": 12, + "symbol": "BORK", + "precision": 10, + "type": "statemine", + "icon": "BORK.svg", + "typeExtras": { + "assetId": "690" + } + }, + { + "assetId": 13, + "symbol": "BUNS", + "precision": 10, + "type": "statemine", + "icon": "BUNS.svg", + "typeExtras": { + "assetId": "1234" + } + }, + { + "assetId": 14, + "symbol": "KOL", + "precision": 12, + "type": "statemine", + "icon": "KOL.svg", + "typeExtras": { + "assetId": "86" + } + }, + { + "assetId": 15, + "symbol": "MYTH", + "precision": 18, + "priceId": "mythos", + "type": "statemine", + "icon": "MYTH.svg", + "typeExtras": { + "assetId": "0x010100a534", + "palletName": "ForeignAssets" + } + }, + { + "assetId": 16, + "symbol": "MYTH-Snowbridge", + "precision": 18, + "priceId": "mythos", + "type": "statemine", + "icon": "MYTH-Snowbridge.svg", + "typeExtras": { + "assetId": "0x02020907040300ba41ddf06b7ffd89d1267b5a93bfef2424eb2003", + "palletName": "ForeignAssets" + } + }, + { + "assetId": 17, + "symbol": "USDC-Snowbridge", + "precision": 6, + "priceId": "usd-coin", + "type": "statemine", + "icon": "USDC-Snowbridge.svg", + "typeExtras": { + "assetId": "0x02020907040300a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "palletName": "ForeignAssets" + } + }, + { + "assetId": 18, + "symbol": "USDT-Snowbridge", + "precision": 6, + "priceId": "tether", + "type": "statemine", + "icon": "USDT-Snowbridge.svg", + "typeExtras": { + "assetId": "0x02020907040300dac17f958d2ee523a2206206994597c13d831ec7", + "palletName": "ForeignAssets" + } + }, + { + "assetId": 19, + "symbol": "DAI-Snowbridge", + "precision": 18, + "priceId": "dai", + "type": "statemine", + "icon": "DAI-Snowbridge.svg", + "typeExtras": { + "assetId": "0x020209070403006b175474e89094c44da98b954eedeac495271d0f", + "palletName": "ForeignAssets" + } + }, + { + "assetId": 20, + "symbol": "BASTI", + "precision": 12, + "type": "statemine", + "icon": "Default.svg", + "typeExtras": { + "assetId": "22222015" + } + }, + { + "assetId": 21, + "symbol": "DAMN", + "precision": 12, + "type": "statemine", + "icon": "DAMN.svg", + "typeExtras": { + "assetId": "22222012" + } + }, + { + "assetId": 22, + "symbol": "BILLCOIN", + "precision": 12, + "type": "statemine", + "icon": "BILLCOIN.svg", + "typeExtras": { + "assetId": "50000075" + } + }, + { + "assetId": 23, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "type": "statemine", + "icon": "KSM.svg", + "typeExtras": { + "assetId": "0x02010903", + "palletName": "ForeignAssets" + } + }, + { + "assetId": 24, + "symbol": "ETH-Snowbridge", + "precision": 18, + "priceId": "ethereum", + "type": "statemine", + "icon": "WETH-Snowbridge.svg", + "typeExtras": { + "assetId": "0x0201090704", + "palletName": "ForeignAssets" + } + }, + { + "assetId": 26, + "symbol": "MPC", + "precision": 12, + "priceId": "my-paqman-coin", + "icon": "MPC.png", + "type": "statemine", + "typeExtras": { + "assetId": "50000103" + } + }, + { + "assetId": 27, + "symbol": "DON", + "precision": 12, + "priceId": "paydon", + "icon": "DON.png", + "type": "statemine", + "typeExtras": { + "assetId": "50000111" + } + } + ], + "nodes": [ + { + "url": "wss://asset-hub-polkadot-rpc.n.dwellir.com", + "name": "Dwellir node" + }, + { + "url": "wss://sys.ibp.network/asset-hub-polkadot", + "name": "IBP1 node" + }, + { + "url": "wss://asset-hub-polkadot.dotters.network", + "name": "IBP2 node" + } + ], + "explorers": [ + { + "name": "Subscan", + "account": "https://assethub-polkadot.subscan.io/account/{address}", + "extrinsic": "https://assethub-polkadot.subscan.io/extrinsic/{hash}" + }, + { + "name": "Statescan", + "account": "https://assethub-polkadot.statescan.io/#/accounts/{address}", + "extrinsic": "https://assethub-polkadot.statescan.io/#/extrinsics/{hash}", + "event": "https://assethub-polkadot.statescan.io/#/events/{event}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadot-ah-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadot-prod.novasama-tech.org" + }, + { + "type": "subquery", + "url": "https://subquery-history-polkadot-ah-prod.novasama-tech.org" + } + ], + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadot-ah-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://polkadot-api.subsquare.io" + } + ], + "governance-delegations": [ + { + "type": "subquery", + "url": "https://subquery-governance-polkadot-ah-prod.novasama-tech.org" + } + ], + "referendum-summary": [ + { + "type": "novasama", + "url": "https://opengov-backend.novasama-tech.org/api/v1/referendum-summaries/list" + } + ], + "crowdloans": [ + { + "type": "github", + "url": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/crowdloan/polkadot.json" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Polkadot_Asset_Hub.svg", + "addressPrefix": 0, + "options": [ + "swap-hub", + "assethub-fees", + "proxy", + "multisig", + "governance", + "fullSyncByDefault", + "crowdloans", + "pushSupport" + ], + "additional": { + "themeColor": "#E6007A", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/polkadot-dot-staking", + "stakingMaxElectingVoters": 22500, + "relaychainAsNative": true, + "feeViaRuntimeCall": true, + "supportsGenericLedgerApp": true, + "identityChain": "67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008", + "timelineChain": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "defaultBlockTime": 2000 + } + }, + { + "chainId": "631ccc82a078481584041656af292834e1ae6daab61d2875b4dd0c14bb9b17bc", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Robonomics", + "assets": [ + { + "assetId": 0, + "symbol": "XRT", + "precision": 9, + "icon": "XRT.svg", + "priceId": "robonomics-network" + } + ], + "nodes": [ + { + "url": "wss://kusama.rpc.robonomics.network/", + "name": "Airalab node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://robonomics-freemium.subscan.io/extrinsic/{hash}", + "account": "https://robonomics-freemium.subscan.io/account/{address}" + } + ], + "externalApi": { + "governance": [ + { + "type": "polkassembly", + "url": "https://polkassembly-hasura.herokuapp.com/v1/graphql", + "parameters": { + "network": "robonomics" + } + } + ] + }, + "types": { + "url": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v2/types/robonomics.json", + "overridesCommon": true + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Robonomics.svg", + "addressPrefix": 32, + "options": [ + "governance-v1" + ], + "additional": { + "feeViaRuntimeCall": true + } + }, + { + "chainId": "7dd99936c1e9e6d1ce7d90eb6f33bea8393b4bf87677d675aa63c9cb3e8c5b5b", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Encointer", + "assets": [ + { + "assetId": 0, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "icon": "KSM.svg" + } + ], + "nodes": [ + { + "url": "wss://sys.ibp.network/encointer-kusama", + "name": "IBP1 node" + }, + { + "url": "wss://encointer-kusama.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://api.kusama.encointer.org", + "name": "Encointer association node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://encointer.subscan.io/extrinsic/{hash}", + "account": "https://encointer.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-encointer-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Encointer.svg", + "addressPrefix": 2, + "additional": { + "feeViaRuntimeCall": true + } + }, + { + "chainId": "9af9a64e6e4da8e3073901c3ff0cc4c3aad9563786d89daf6ad820b6e14a0b8b", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Kintsugi", + "assets": [ + { + "assetId": 0, + "symbol": "KINT", + "precision": 12, + "type": "orml", + "priceId": "kintsugi", + "icon": "KINT.svg", + "typeExtras": { + "currencyIdScale": "0x000c", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + }, + "buyProviders": { + "banxa": { + "coinType": "KINT", + "blockchain": "KINT" + } + } + }, + { + "assetId": 1, + "symbol": "kBTC", + "precision": 8, + "type": "orml", + "priceId": "bitcoin", + "icon": "kBTC.svg", + "typeExtras": { + "currencyIdScale": "0x000b", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "type": "orml", + "icon": "KSM.svg", + "typeExtras": { + "currencyIdScale": "0x000a", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "LKSM", + "precision": 12, + "priceId": "liquid-ksm", + "type": "orml", + "icon": "LKSM.svg", + "typeExtras": { + "currencyIdScale": "0x0102000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "orml", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x0103000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "aSEEDk", + "precision": 12, + "priceId": "ausd-seed-karura", + "type": "orml", + "icon": "aSEEDk.svg", + "typeExtras": { + "currencyIdScale": "0x0101000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "LP KSM-kBTC", + "precision": 18, + "type": "orml", + "icon": "KSM-kBTC.svg", + "typeExtras": { + "currencyIdScale": "0x03000a000b", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 7, + "symbol": "LP kBTC-USDT", + "precision": 18, + "type": "orml", + "icon": "kBTC-USDT.svg", + "typeExtras": { + "currencyIdScale": "0x03000b0103000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 8, + "symbol": "LP KSM-KINT", + "precision": 18, + "type": "orml", + "icon": "KSM-KINT.svg", + "typeExtras": { + "currencyIdScale": "0x03000a000c", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 9, + "symbol": "qkBTC", + "precision": 8, + "type": "orml", + "icon": "qkBTC.svg", + "typeExtras": { + "currencyIdScale": "0x0201000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 10, + "symbol": "qKSM", + "precision": 12, + "type": "orml", + "icon": "qKSM.svg", + "typeExtras": { + "currencyIdScale": "0x0202000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 11, + "symbol": "qUSDT", + "precision": 6, + "type": "orml", + "icon": "qUSDT.svg", + "typeExtras": { + "currencyIdScale": "0x0203000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + } + ], + "nodes": [ + { + "url": "wss://api-kusama.interlay.io/parachain", + "name": "Kintsugi Labs node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://kintsugi.subscan.io/extrinsic/{hash}", + "account": "https://kintsugi.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-kintsugi-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Kintsugi.svg", + "addressPrefix": 2092 + }, + { + "chainId": "6811a339673c9daa897944dcdac99c6e2939cc88245ed21951a0a3c9a2be75bc", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Picasso (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "PICA", + "precision": 12, + "priceId": "picasso", + "icon": "PICA.svg" + }, + { + "assetId": 1, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "type": "orml", + "icon": "DOT.svg", + "typeExtras": { + "currencyIdScale": "0x06000000000000000000000000000000", + "currencyIdType": "u128", + "existentialDeposit": "21430000", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "type": "orml", + "icon": "KSM.svg", + "typeExtras": { + "currencyIdScale": "0x04000000000000000000000000000000", + "currencyIdType": "u128", + "existentialDeposit": "375000000", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "orml", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x82000000000000000000000000000000", + "currencyIdType": "u128", + "existentialDeposit": "1500", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://rpc.composablenodes.tech", + "name": "Composable node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://picasso.subscan.io/extrinsic/{hash}", + "account": "https://picasso.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Picasso.svg", + "addressPrefix": 49 + }, + { + "chainId": "1bf2a2ecb4a868de66ea8610f2ce7c8c43706561b6476031315f6640fe38e060", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Zeitgeist", + "assets": [ + { + "assetId": 0, + "symbol": "ZTG", + "staking": [ + "parachain" + ], + "precision": 10, + "priceId": "zeitgeist", + "icon": "ZTG.svg" + } + ], + "nodes": [ + { + "url": "wss://zeitgeist.api.onfinality.io/public-ws", + "name": "OnFinality node" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-zeitgeist-prod.novasama-tech.org" + } + ], + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-zeitgeist-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-zeitgeist-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://zeitgeist.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Zeitgeist.svg", + "addressPrefix": 73, + "options": [ + "governance-v1" + ], + "additional": { + "themeColor": "#1F78FF", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/zeitgeist-ztg-staking", + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "4a12be580bb959937a1c7a61d5cf24428ed67fa571974b4007645d1886e7c89f", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Subsocial (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "SUB", + "precision": 10, + "priceId": "subsocial", + "icon": "SUB.svg" + } + ], + "nodes": [ + { + "url": "wss://para.f3joule.space", + "name": "Subsocial node" + }, + { + "url": "wss://para.subsocial.network", + "name": "Dappforce node" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-subsocial-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Subsocial_Parachain.svg", + "addressPrefix": 28 + }, + { + "chainId": "d4c0c08ca49dc7c680c3dac71a7c0703e5b222f4b6c03fe4c5219bb8f22c18dc", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Crust Shadow", + "assets": [ + { + "assetId": 0, + "symbol": "CSM", + "precision": 12, + "priceId": "crust-storage-market", + "icon": "CSM.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc-sha-subscan.crustnetwork.xyz", + "name": "Subscan node" + }, + { + "url": "wss://rpc2-shadow.crust.network/", + "name": "Crust node" + }, + { + "url": "wss://rpc-shadow.crust.network/", + "name": "Public Crust node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://shadow.statescan.io/#/accounts/{address}", + "extrinsic": "https://shadow.statescan.io/#/extrinsics/{hash}", + "event": "https://shadow.statescan.io/#/events/{event}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-shadow-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Crust_Shadow.svg", + "addressPrefix": 66, + "additional": { + "feeViaRuntimeCall": true + } + }, + { + "chainId": "cdedc8eadbfa209d3f207bba541e57c3c58a667b05a2e1d1e86353c9000758da", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Integritee Parachain (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "TEER", + "precision": 12, + "priceId": "integritee", + "icon": "TEER.svg" + } + ], + "nodes": [ + { + "url": "wss://kusama.api.integritee.network", + "name": "Integritee node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://integritee.subscan.io/extrinsic/{hash}", + "account": "https://integritee.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-integritee-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Integritee.svg", + "addressPrefix": 13 + }, + { + "chainId": "b3db41421702df9a7fcac62b53ffeac85f7853cc4e689e0b93aeb3db18c09d82", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Centrifuge Parachain", + "assets": [ + { + "assetId": 0, + "symbol": "CFG", + "precision": 18, + "priceId": "centrifuge", + "icon": "CFG.svg" + } + ], + "nodes": [ + { + "url": "wss://fullnode.centrifuge.io", + "name": "Centrifuge node" + }, + { + "url": "wss://rpc-centrifuge.luckyfriday.io", + "name": "LuckyFriday node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://centrifuge-parachain.subscan.io/extrinsic/{hash}", + "account": "https://centrifuge-parachain.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-centrifuge-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://centrifuge.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Centrifuge.svg", + "addressPrefix": 36, + "options": [ + "governance-v1" + ], + "additional": { + "feeViaRuntimeCall": true, + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "afdc188f45c71dacbaa0b62e16a91f726c7b8699a9748cdf715459de6b7f366d", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Hydration", + "assets": [ + { + "assetId": 0, + "symbol": "HDX", + "precision": 12, + "priceId": "hydradx", + "icon": "HDX.svg", + "buyProviders": { + "banxa": { + "coinType": "HDX", + "blockchain": "HDX" + } + } + }, + { + "assetId": 1, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "icon": "DOT.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x05000000", + "currencyIdType": "u32", + "existentialDeposit": "17540000", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "DAI-Acala", + "precision": 18, + "priceId": "dai", + "type": "orml", + "icon": "DAI-Acala.svg", + "typeExtras": { + "currencyIdScale": "0x02000000", + "currencyIdType": "u32", + "existentialDeposit": "10000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "LRNA", + "precision": 12, + "type": "orml", + "icon": "LRNA.svg", + "typeExtras": { + "currencyIdScale": "0x01000000", + "currencyIdType": "u32", + "existentialDeposit": "400000000", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "WETH-Acala", + "precision": 18, + "priceId": "ethereum-wormhole", + "type": "orml", + "icon": "WETH-Acala.svg", + "typeExtras": { + "currencyIdScale": "0x04000000", + "currencyIdType": "u32", + "existentialDeposit": "7000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "WBTC-Acala", + "precision": 8, + "priceId": "wrapped-bitcoin", + "type": "orml", + "icon": "WBTC-Acala.svg", + "typeExtras": { + "currencyIdScale": "0x03000000", + "currencyIdType": "u32", + "existentialDeposit": "44", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "iBTC", + "precision": 8, + "priceId": "bitcoin", + "type": "orml", + "icon": "iBTC.svg", + "typeExtras": { + "currencyIdScale": "0x0b000000", + "currencyIdType": "u32", + "existentialDeposit": "36", + "transfersEnabled": true + } + }, + { + "assetId": 7, + "symbol": "ZTG", + "precision": 10, + "priceId": "zeitgeist", + "type": "orml", + "icon": "ZTG.svg", + "typeExtras": { + "currencyIdScale": "0x0c000000", + "currencyIdType": "u32", + "existentialDeposit": "1204151916", + "transfersEnabled": true + } + }, + { + "assetId": 8, + "symbol": "ASTR", + "precision": 18, + "priceId": "astar", + "type": "orml", + "icon": "ASTR.svg", + "typeExtras": { + "currencyIdScale": "0x09000000", + "currencyIdType": "u32", + "existentialDeposit": "147058823529412000", + "transfersEnabled": true + } + }, + { + "assetId": 9, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "orml", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x0a000000", + "currencyIdType": "u32", + "existentialDeposit": "10000", + "transfersEnabled": true + } + }, + { + "assetId": 10, + "symbol": "CFG", + "precision": 18, + "priceId": "centrifuge", + "type": "orml", + "icon": "CFG.svg", + "typeExtras": { + "currencyIdScale": "0x0d000000", + "currencyIdType": "u32", + "existentialDeposit": "32467532467532500", + "transfersEnabled": true + } + }, + { + "assetId": 11, + "symbol": "BNC", + "precision": 12, + "priceId": "bifrost-native-coin", + "type": "orml", + "icon": "BNC.svg", + "typeExtras": { + "currencyIdScale": "0x0e000000", + "currencyIdType": "u32", + "existentialDeposit": "68795189840", + "transfersEnabled": true + } + }, + { + "assetId": 12, + "symbol": "DAI-Moonbeam", + "precision": 18, + "priceId": "dai", + "type": "orml", + "icon": "DAI-Moonbeam.svg", + "typeExtras": { + "currencyIdScale": "0x12000000", + "currencyIdType": "u32", + "existentialDeposit": "10000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 13, + "symbol": "WBTC-Moonbeam", + "precision": 8, + "priceId": "wrapped-bitcoin", + "type": "orml", + "icon": "WBTC-Moonbeam.svg", + "typeExtras": { + "currencyIdScale": "0x13000000", + "currencyIdType": "u32", + "existentialDeposit": "34", + "transfersEnabled": true + } + }, + { + "assetId": 14, + "symbol": "WETH-Moonbeam", + "precision": 18, + "priceId": "ethereum-wormhole", + "type": "orml", + "icon": "WETH-Moonbeam.svg", + "typeExtras": { + "currencyIdScale": "0x14000000", + "currencyIdType": "u32", + "existentialDeposit": "7000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 15, + "symbol": "USDC", + "precision": 6, + "priceId": "usd-coin", + "type": "orml", + "icon": "USDC.svg", + "typeExtras": { + "currencyIdScale": "0x16000000", + "currencyIdType": "u32", + "existentialDeposit": "10000", + "transfersEnabled": true + } + }, + { + "assetId": 16, + "symbol": "GLMR", + "precision": 18, + "priceId": "moonbeam", + "type": "orml", + "icon": "GLMR.svg", + "typeExtras": { + "currencyIdScale": "0x10000000", + "currencyIdType": "u32", + "existentialDeposit": "34854864344868000", + "transfersEnabled": true + } + }, + { + "assetId": 17, + "symbol": "INTR", + "precision": 10, + "priceId": "interlay", + "icon": "INTR.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x11000000", + "currencyIdType": "u32", + "existentialDeposit": "6164274209", + "transfersEnabled": true + } + }, + { + "assetId": 18, + "symbol": "SUB", + "precision": 10, + "priceId": "subsocial", + "icon": "SUB.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x18000000", + "currencyIdType": "u32", + "existentialDeposit": "20000000", + "transfersEnabled": true + } + }, + { + "assetId": 19, + "symbol": "vDOT", + "precision": 10, + "priceId": "voucher-dot", + "icon": "vDOT.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0f000000", + "currencyIdType": "u32", + "existentialDeposit": "18761726", + "transfersEnabled": true + } + }, + { + "assetId": 20, + "symbol": "PHA", + "precision": 12, + "priceId": "pha", + "icon": "PHA.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x08000000", + "currencyIdType": "u32", + "existentialDeposit": "54945054945", + "transfersEnabled": true + } + }, + { + "assetId": 21, + "symbol": "USDC-Moonbeam", + "precision": 6, + "priceId": "usd-coin", + "type": "orml", + "icon": "USDC-Moonbeam.svg", + "typeExtras": { + "currencyIdScale": "0x15000000", + "currencyIdType": "u32", + "existentialDeposit": "10000", + "transfersEnabled": true + } + }, + { + "assetId": 22, + "symbol": "4-Pool", + "precision": 18, + "type": "orml", + "icon": "4-Pool.svg", + "typeExtras": { + "currencyIdScale": "0x64000000", + "currencyIdType": "u32", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 23, + "symbol": "2-Pool", + "precision": 18, + "type": "orml", + "icon": "2-Pool.svg", + "typeExtras": { + "currencyIdScale": "0x65000000", + "currencyIdType": "u32", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 24, + "symbol": "2-Pool-Stbl", + "precision": 18, + "type": "orml", + "icon": "2-Pool-Stbl.svg", + "typeExtras": { + "currencyIdScale": "0x66000000", + "currencyIdType": "u32", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 25, + "symbol": "UNQ", + "precision": 18, + "priceId": "unique-network", + "type": "orml", + "icon": "UNQ.svg", + "typeExtras": { + "currencyIdScale": "0x19000000", + "currencyIdType": "u32", + "existentialDeposit": "1224384348939740000", + "transfersEnabled": true + } + }, + { + "assetId": 26, + "symbol": "NODL", + "precision": 11, + "priceId": "nodle-network", + "type": "orml", + "icon": "NODL.svg", + "typeExtras": { + "currencyIdScale": "0x1a000000", + "currencyIdType": "u32", + "existentialDeposit": "109890109890", + "transfersEnabled": true + } + }, + { + "assetId": 27, + "symbol": "CRU", + "precision": 12, + "priceId": "crust-network", + "type": "orml", + "icon": "CRU.svg", + "typeExtras": { + "currencyIdScale": "0x1b000000", + "currencyIdType": "u32", + "existentialDeposit": "7874015748", + "transfersEnabled": true + } + }, + { + "assetId": 28, + "symbol": "USDT-Moonbeam", + "precision": 6, + "priceId": "tether", + "type": "orml", + "icon": "USDT-Moonbeam.svg", + "typeExtras": { + "currencyIdScale": "0x17000000", + "currencyIdType": "u32", + "existentialDeposit": "10000", + "transfersEnabled": true + } + }, + { + "assetId": 29, + "symbol": "DED", + "precision": 10, + "priceId": "dot-is-ded", + "type": "orml", + "icon": "DED.svg", + "typeExtras": { + "currencyIdScale": "0x53420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 30, + "symbol": "PINK", + "precision": 10, + "type": "orml", + "icon": "PINK.svg", + "typeExtras": { + "currencyIdScale": "0x55420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 31, + "symbol": "STINK", + "precision": 10, + "type": "orml", + "icon": "STINK.svg", + "typeExtras": { + "currencyIdScale": "0x62420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 32, + "symbol": "DOTA", + "precision": 4, + "type": "orml", + "icon": "DOTA.svg", + "typeExtras": { + "currencyIdScale": "0x66420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 33, + "symbol": "GABE", + "precision": 20, + "type": "orml", + "icon": "GABE.svg", + "typeExtras": { + "currencyIdScale": "0x7e420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 34, + "symbol": "KILT", + "precision": 15, + "type": "orml", + "priceId": "kilt-protocol", + "icon": "KILT.svg", + "typeExtras": { + "currencyIdScale": "0x1c000000", + "currencyIdType": "u32", + "existentialDeposit": "21358393848783", + "transfersEnabled": true + } + }, + { + "assetId": 35, + "symbol": "WUD", + "precision": 10, + "priceId": "gavun-wud", + "type": "orml", + "icon": "WUD.svg", + "typeExtras": { + "currencyIdScale": "0x95420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 36, + "symbol": "MYTH", + "precision": 18, + "priceId": "mythos", + "type": "orml", + "icon": "MYTH.svg", + "typeExtras": { + "currencyIdScale": "0x1e000000", + "currencyIdType": "u32", + "existentialDeposit": "21367521367521400", + "transfersEnabled": true + } + }, + { + "assetId": 37, + "symbol": "WETH-Snowbridge", + "precision": 18, + "priceId": "ethereum-wormhole", + "type": "orml", + "icon": "WETH-Snowbridge.svg", + "typeExtras": { + "currencyIdScale": "0x06430f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 38, + "symbol": "WBTC-Snowbridge", + "precision": 8, + "priceId": "wrapped-bitcoin", + "type": "orml", + "icon": "WBTC-Snowbridge.svg", + "typeExtras": { + "currencyIdScale": "0xfe420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 39, + "symbol": "WIFD", + "precision": 10, + "type": "orml", + "icon": "WIFD.svg", + "typeExtras": { + "currencyIdScale": "0x92420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 40, + "symbol": "BORK", + "precision": 10, + "type": "orml", + "icon": "BORK.svg", + "typeExtras": { + "currencyIdScale": "0xd4420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 41, + "symbol": "BUNS", + "precision": 10, + "type": "orml", + "icon": "BUNS.svg", + "typeExtras": { + "currencyIdScale": "0xf1420f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 42, + "symbol": "KOL", + "precision": 12, + "type": "orml", + "icon": "KOL.svg", + "typeExtras": { + "currencyIdScale": "0x07430f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 43, + "symbol": "vASTR", + "precision": 18, + "priceId": "bifrost-voucher-astr", + "type": "orml", + "icon": "vASTR.svg", + "typeExtras": { + "currencyIdScale": "0x21000000", + "currencyIdType": "u32", + "existentialDeposit": "133689839572193000", + "transfersEnabled": true + } + }, + { + "assetId": 44, + "symbol": "AJUN", + "precision": 12, + "priceId": "ajuna-network-2", + "type": "orml", + "icon": "AJUN.svg", + "typeExtras": { + "currencyIdScale": "0x20000000", + "currencyIdType": "u32", + "existentialDeposit": "100786131828", + "transfersEnabled": true + } + }, + { + "assetId": 45, + "symbol": "AAVE", + "precision": 18, + "priceId": "aave", + "type": "orml", + "icon": "AAVE.svg", + "typeExtras": { + "currencyIdScale": "0xb0440f00", + "currencyIdType": "u32", + "existentialDeposit": "59084194977843", + "transfersEnabled": true + } + }, + { + "assetId": 46, + "symbol": "BASTI", + "precision": 12, + "type": "orml", + "icon": "Default.svg", + "typeExtras": { + "currencyIdScale": "0x64430f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 47, + "symbol": "DAMN", + "precision": 12, + "type": "orml", + "icon": "DAMN.svg", + "typeExtras": { + "currencyIdScale": "0x5e430f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 48, + "symbol": "SOL-Wormhole", + "precision": 9, + "priceId": "solana", + "type": "orml", + "icon": "SOL-Wormhole.svg", + "typeExtras": { + "currencyIdScale": "0x30450f00", + "currencyIdType": "u32", + "existentialDeposit": "46339", + "transfersEnabled": true + } + }, + { + "assetId": 49, + "symbol": "SUI-Wormhole", + "precision": 9, + "type": "orml", + "icon": "SUI-Wormhole.svg", + "typeExtras": { + "currencyIdScale": "0x31450f00", + "currencyIdType": "u32", + "existentialDeposit": "2652520", + "transfersEnabled": true + } + }, + { + "assetId": 50, + "symbol": "tBTC", + "precision": 18, + "priceId": "tbtc", + "type": "orml", + "icon": "tBTC.svg", + "typeExtras": { + "currencyIdScale": "0x3d450f00", + "currencyIdType": "u32", + "existentialDeposit": "106803374987", + "transfersEnabled": true + } + }, + { + "assetId": 51, + "symbol": "BILLCOIN", + "precision": 12, + "type": "orml", + "icon": "BILLCOIN.svg", + "typeExtras": { + "currencyIdScale": "0x3a450f00", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 52, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "type": "orml", + "icon": "KSM.svg", + "typeExtras": { + "currencyIdScale": "0x43450f00", + "currencyIdType": "u32", + "existentialDeposit": "313283208", + "transfersEnabled": true + } + }, + { + "assetId": 53, + "symbol": "EWT", + "precision": 18, + "priceId": "energy-web-token", + "type": "orml", + "icon": "EWT.svg", + "typeExtras": { + "currencyIdScale": "0x6dda0300", + "currencyIdType": "u32", + "existentialDeposit": "3244646333550", + "transfersEnabled": true + } + }, + { + "assetId": 54, + "symbol": "LINK", + "precision": 18, + "priceId": "chainlink", + "type": "orml", + "icon": "LINK.svg", + "typeExtras": { + "currencyIdScale": "0x5a450f00", + "currencyIdType": "u32", + "existentialDeposit": "436681222707424", + "transfersEnabled": true + } + }, + { + "assetId": 55, + "symbol": "LDO", + "precision": 18, + "priceId": "lido-dao", + "type": "orml", + "icon": "LDO.svg", + "typeExtras": { + "currencyIdScale": "0x5c450f00", + "currencyIdType": "u32", + "existentialDeposit": "5102040816326530", + "transfersEnabled": true + } + }, + { + "assetId": 56, + "symbol": "SKY", + "precision": 18, + "type": "orml", + "icon": "SKY.svg", + "typeExtras": { + "currencyIdScale": "0x5b450f00", + "currencyIdType": "u32", + "existentialDeposit": "211685012701101000", + "transfersEnabled": true + } + }, + { + "assetId": 57, + "symbol": "ETH-Snowbridge", + "precision": 18, + "priceId": "ethereum", + "type": "orml", + "icon": "WETH-Snowbridge.svg", + "typeExtras": { + "currencyIdScale": "0x22000000", + "currencyIdType": "u32", + "existentialDeposit": "5373455131650", + "transfersEnabled": true + } + }, + { + "assetId": 58, + "symbol": "GDOT", + "precision": 18, + "priceId": "gigadot", + "type": "orml-hydration-evm", + "icon": "GIGADOT.svg", + "typeExtras": { + "currencyIdScale": "0x45000000", + "currencyIdType": "u32", + "existentialDeposit": "5290724013368937", + "transfersEnabled": true + } + }, + { + "assetId": 59, + "symbol": "2-Pool-GDOT", + "precision": 18, + "type": "orml", + "icon": "2-Pool-GDOT.svg", + "typeExtras": { + "currencyIdScale": "0xb2020000", + "currencyIdType": "u32", + "existentialDeposit": "1", + "transfersEnabled": true + } + }, + { + "assetId": 60, + "symbol": "aDOT", + "precision": 10, + "type": "orml-hydration-evm", + "icon": "DOT.svg", + "typeExtras": { + "currencyIdScale": "0xe9030000", + "currencyIdType": "u32", + "existentialDeposit": "54125333", + "transfersEnabled": true + } + }, + { + "assetId": 61, + "symbol": "TRAC", + "precision": 18, + "priceId": "origintrail", + "type": "orml", + "icon": "TRAC.svg", + "typeExtras": { + "currencyIdScale": "0x23000000", + "currencyIdType": "u32", + "existentialDeposit": "27777777777777800", + "transfersEnabled": true + } + }, + { + "assetId": 62, + "symbol": "NEURO", + "precision": 12, + "priceId": "neurowebai", + "type": "orml", + "icon": "NEURO.svg", + "typeExtras": { + "currencyIdScale": "0x24000000", + "currencyIdType": "u32", + "existentialDeposit": "588235294118", + "transfersEnabled": true + } + }, + { + "assetId": 63, + "symbol": "GETH", + "precision": 18, + "priceId": "gigaeth", + "type": "orml-hydration-evm", + "icon": "GIGAETH.svg", + "typeExtras": { + "currencyIdScale": "0xa4010000", + "currencyIdType": "u32", + "existentialDeposit": "8202803876747", + "transfersEnabled": true + } + }, + { + "assetId": 64, + "symbol": "2-Pool-GETH", + "precision": 18, + "type": "orml", + "icon": "2-Pool-GETH.svg", + "typeExtras": { + "currencyIdScale": "0x68100000", + "currencyIdType": "u32", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 65, + "symbol": "HOLLAR", + "precision": 18, + "priceId": "tether", + "type": "orml-hydration-evm", + "icon": "HOLLAR.svg", + "typeExtras": { + "currencyIdScale": "0xde000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 66, + "symbol": "aUSDT", + "precision": 6, + "priceId": "tether", + "type": "orml-hydration-evm", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0xea030000", + "currencyIdType": "u32", + "existentialDeposit": "22409", + "transfersEnabled": true + } + }, + { + "assetId": 67, + "symbol": "aUSDC", + "precision": 6, + "priceId": "usd-coin", + "type": "orml-hydration-evm", + "icon": "USDC.svg", + "typeExtras": { + "currencyIdScale": "0xeb030000", + "currencyIdType": "u32", + "existentialDeposit": "22409", + "transfersEnabled": true + } + }, + { + "assetId": 68, + "symbol": "2-Pool-HUSDC", + "precision": 18, + "type": "orml", + "icon": "2-Pool-HUSDC.svg", + "typeExtras": { + "currencyIdScale": "0x6e000000", + "currencyIdType": "u32", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 69, + "symbol": "2-Pool-HUSDT", + "precision": 18, + "type": "orml", + "icon": "2-Pool-HUSDT.svg", + "typeExtras": { + "currencyIdScale": "0x6f000000", + "currencyIdType": "u32", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 70, + "symbol": "PAXG", + "precision": 18, + "priceId": "pax-gold", + "type": "orml", + "icon": "PAXG.svg", + "typeExtras": { + "currencyIdScale": "0x27000000", + "currencyIdType": "u32", + "existentialDeposit": "2374169040836", + "transfersEnabled": true + } + }, + { + "assetId": 71, + "symbol": "PEN", + "precision": 12, + "priceId": "pendulum-chain", + "type": "orml", + "icon": "PEN.svg", + "typeExtras": { + "currencyIdScale": "0x91420f00", + "currencyIdType": "u32", + "existentialDeposit": "153256704981", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://rpc-hydra.novasama-tech.org", + "name": "Novasama node" + }, + { + "url": "wss://rpc.hydradx.cloud", + "name": "Galactic Council node" + }, + { + "url": "wss://hydration.ibp.network", + "name": "IBP1 node" + }, + { + "url": "wss://hydration.dotters.network", + "name": "IBP2 node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://hydradx.subscan.io/extrinsic/{hash}", + "account": "https://hydradx.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-hydra-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://hydradx.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Hydration.svg", + "addressPrefix": 0, + "options": [ + "governance", + "governance-v1", + "hydradx-swaps", + "hydration-fees", + "proxy", + "multisig" + ], + "additional": { + "feeViaRuntimeCall": true, + "supportsGenericLedgerApp": true, + "defaultBlockTime": 6000 + }, + "legacyAddressPrefix": 63 + }, + { + "chainId": "bf88efe70e9e0e916416e8bed61f2b45717f517d7f3523e33c7b001e5ffcbc72", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Interlay", + "assets": [ + { + "assetId": 0, + "symbol": "INTR", + "precision": 10, + "priceId": "interlay", + "icon": "INTR.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0002", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + }, + "buyProviders": { + "banxa": { + "coinType": "INTR", + "blockchain": "INTR" + } + } + }, + { + "assetId": 1, + "symbol": "iBTC", + "precision": 8, + "priceId": "bitcoin", + "icon": "iBTC.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0001", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "icon": "DOT.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "KINT", + "precision": 12, + "priceId": "kintsugi", + "icon": "KINT.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x000c", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "kBTC", + "precision": 8, + "priceId": "bitcoin", + "icon": "kBTC.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x000b", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "icon": "KSM.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x000a", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "LDOT", + "precision": 10, + "priceId": "liquid-staking-dot", + "icon": "LDOT.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0101000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 7, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "icon": "USDT.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0102000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 8, + "symbol": "LP iBTC-USDT", + "precision": 18, + "type": "orml", + "icon": "iBTC-USDT.svg", + "typeExtras": { + "currencyIdScale": "0x0300010102000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 9, + "symbol": "LP DOT-iBTC", + "precision": 18, + "type": "orml", + "icon": "DOT-iBTC.svg", + "typeExtras": { + "currencyIdScale": "0x0300000001", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 10, + "symbol": "LP INTR-USDT", + "precision": 18, + "type": "orml", + "icon": "INTR-USDT.svg", + "typeExtras": { + "currencyIdScale": "0x0300020102000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 11, + "symbol": "qiBTC", + "precision": 8, + "icon": "qiBTC.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0201000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 12, + "symbol": "qUSDT", + "precision": 6, + "icon": "qUSDT.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0203000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + }, + { + "assetId": 13, + "symbol": "qDOT", + "precision": 10, + "icon": "qDOT.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0202000000", + "currencyIdType": "interbtc_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": false + } + } + ], + "nodes": [ + { + "url": "wss://api.interlay.io/parachain", + "name": "Kintsugi Labs node" + }, + { + "url": "wss://rpc-interlay.luckyfriday.io/", + "name": "LuckyFriday node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://interlay.statescan.io/#/accounts/{address}", + "event": "https://interlay.statescan.io/#/events/{event}", + "extrinsic": "https://interlay.statescan.io/#/extrinsics/{hash}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-interlay-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Interlay.svg", + "addressPrefix": 2032 + }, + { + "chainId": "97da7ede98d7bad4e36b4d734b6055425a3be036da2a332ea5a7037656427a21", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Nodle Parachain", + "assets": [ + { + "assetId": 0, + "symbol": "NODL", + "precision": 11, + "priceId": "nodle-network", + "icon": "NODL.svg" + } + ], + "nodes": [ + { + "url": "wss://nodle-parachain.api.onfinality.io/public-ws", + "name": "OnFinality node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://nodle.subscan.io/extrinsic/{hash}", + "account": "https://nodle.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-nodle-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Nodle.svg", + "addressPrefix": 37, + "additional": { + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "1bb969d85965e4bb5a651abbedf21a54b6b31a21f66b5401cc3f1e286268d736", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Phala", + "assets": [ + { + "assetId": 0, + "symbol": "PHA", + "precision": 12, + "priceId": "pha", + "icon": "PHA.svg" + } + ], + "nodes": [ + { + "url": "wss://phala-rpc.n.dwellir.com", + "name": "Dwellir node" + }, + { + "url": "wss://api.phala.network/ws", + "name": "Phala node" + }, + { + "url": "wss://phala.public.curie.radiumblock.co/ws", + "name": "RadiumBlock node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://phala.subscan.io/extrinsic/{hash}", + "account": "https://phala.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-phala-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://phala.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Phala.svg", + "addressPrefix": 30, + "options": [ + "governance-v1" + ], + "additional": { + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "70255b4d28de0fc4e1a193d7e175ad1ccef431598211c55538f1018651a0344e", + "name": "Aleph Zero", + "assets": [ + { + "assetId": 0, + "symbol": "AZERO", + "priceId": "aleph-zero", + "staking": [ + "aleph-zero", + "nomination-pools" + ], + "precision": 12, + "icon": "AZERO.svg", + "buyProviders": { + "banxa": { + "coinType": "AZERO", + "blockchain": "AZERO" + } + } + } + ], + "nodes": [ + { + "url": "wss://ws.azero.dev", + "name": "Aleph Zero node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://alephzero.subscan.io/extrinsic/{hash}", + "account": "https://alephzero.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-aleph-zero-prod.novasama-tech.org" + } + ], + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-aleph-zero-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-aleph-zero-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/AlephZero.svg", + "addressPrefix": 42, + "additional": { + "themeColor": "#10B6B1", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/aleph-zero-azero-staking", + "defaultBlockTime": 1000, + "feeViaRuntimeCall": true + }, + "options": [ + "fullSyncByDefault", + "pushSupport", + "multisig" + ] + }, + { + "chainId": "3920bcb4960a1eef5580cd5367ff3f430eef052774f78468852f7b9cb39f8a3c", + "name": "Polkadex", + "assets": [ + { + "assetId": 0, + "symbol": "PDEX", + "priceId": "polkadex", + "staking": [ + "relaychain" + ], + "precision": 12, + "icon": "PDEX.svg" + }, + { + "assetId": 1, + "symbol": "DOT", + "precision": 12, + "priceId": "polkadot", + "type": "statemine", + "icon": "DOT.svg", + "typeExtras": { + "assetId": "95930534000017180603917534864279132680", + "isSufficient": true + } + }, + { + "assetId": 2, + "symbol": "USDT", + "precision": 12, + "priceId": "tether", + "type": "statemine", + "icon": "USDT.svg", + "typeExtras": { + "assetId": "3496813586714279103986568049643838918", + "isSufficient": true + } + }, + { + "assetId": 3, + "symbol": "ASTR", + "precision": 12, + "priceId": "astar", + "type": "statemine", + "icon": "ASTR.svg", + "typeExtras": { + "assetId": "222121451965151777636299756141619631150", + "isSufficient": true + } + }, + { + "assetId": 4, + "symbol": "PHA", + "precision": 12, + "priceId": "pha", + "type": "statemine", + "icon": "PHA.svg", + "typeExtras": { + "assetId": "193492391581201937291053139015355410612", + "isSufficient": true + } + }, + { + "assetId": 5, + "symbol": "iBTC", + "precision": 12, + "priceId": "bitcoin", + "type": "statemine", + "icon": "iBTC.svg", + "typeExtras": { + "assetId": "226557799181424065994173367616174607641", + "isSufficient": true + } + }, + { + "assetId": 6, + "symbol": "DED", + "precision": 12, + "priceId": "dot-is-ded", + "type": "statemine", + "icon": "DED.svg", + "typeExtras": { + "assetId": "119367686984583275840673742485354142551", + "isSufficient": true + } + }, + { + "assetId": 7, + "symbol": "PINK", + "precision": 12, + "type": "statemine", + "icon": "PINK.svg", + "typeExtras": { + "assetId": "339306133874233608313826294843504252047", + "isSufficient": true + } + }, + { + "assetId": 8, + "symbol": "GLMR", + "precision": 12, + "priceId": "moonbeam", + "type": "statemine", + "icon": "GLMR.svg", + "typeExtras": { + "assetId": "182269558229932594457975666948556356791", + "isSufficient": true + } + } + ], + "nodes": [ + { + "url": "wss://so.polkadex.ee", + "name": "PolkadexSup node" + }, + { + "url": "wss://polkadex.public.curie.radiumblock.co/ws", + "name": "RadiumBlock node" + } + ], + "externalApi": { + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadex-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadex-prod.novasama-tech.org" + } + ], + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadex-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Polkadex.svg", + "addressPrefix": 88, + "options": [ + "governance-v1" + ], + "additional": { + "themeColor": "#1F78FF", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/polkadex-pdex-staking", + "feeViaRuntimeCall": true, + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "e7e0962324a3b86c83404dbea483f25fb5dab4c224791c81b756cfc948006174", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "NeuroWeb", + "assets": [ + { + "assetId": 0, + "symbol": "NEURO", + "precision": 12, + "priceId": "neurowebai", + "icon": "NEURO.svg" + }, + { + "assetId": 1, + "symbol": "TRAC", + "precision": 18, + "priceId": "origintrail", + "type": "statemine", + "icon": "TRAC.svg", + "typeExtras": { + "assetId": "1" + } + } + ], + "nodes": [ + { + "url": "wss://parachain-rpc.origin-trail.network", + "name": "TraceLabs node" + } + ], + "explorers": [ + { + "name": "Subscan", + "account": "https://neuroweb.subscan.io/account/{address}", + "extrinsic": "https://neuroweb.subscan.io/extrinsic/{hash}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/NeuroWeb.svg", + "addressPrefix": 101, + "additional": { + "feeViaRuntimeCall": true + } + }, + { + "chainId": "262e1b2ad728475fd6fe88e62d34c200abe6fd693931ddad144059b1eb884e5b", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Bifrost Polkadot", + "assets": [ + { + "assetId": 0, + "symbol": "BNC", + "precision": 12, + "priceId": "bifrost-native-coin", + "icon": "BNC.svg", + "buyProviders": { + "banxa": { + "coinType": "BNC", + "blockchain": "BNCPOLKA" + } + } + }, + { + "assetId": 1, + "symbol": "GLMR", + "precision": 18, + "priceId": "moonbeam", + "type": "orml", + "icon": "GLMR.svg", + "typeExtras": { + "currencyIdScale": "0x0801", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "type": "orml", + "icon": "DOT.svg", + "typeExtras": { + "currencyIdScale": "0x0800", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000000", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "orml", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x0802", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "ASTR", + "precision": 18, + "priceId": "astar", + "type": "orml", + "icon": "ASTR.svg", + "typeExtras": { + "currencyIdScale": "0x0803", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "10000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "vDOT", + "precision": 10, + "priceId": "voucher-dot", + "type": "orml", + "icon": "vDOT.svg", + "typeExtras": { + "currencyIdScale": "0x0900", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000000", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "vGLMR", + "precision": 18, + "priceId": "voucher-glmr", + "type": "orml", + "icon": "vGLMR.svg", + "typeExtras": { + "currencyIdScale": "0x0901", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 7, + "symbol": "vFIL", + "precision": 18, + "type": "orml", + "icon": "vFIL.svg", + "typeExtras": { + "currencyIdScale": "0x0904", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 8, + "symbol": "vASTR", + "precision": 18, + "priceId": "bifrost-voucher-astr", + "type": "orml", + "icon": "vASTR.svg", + "typeExtras": { + "currencyIdScale": "0x0903", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "10000000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 9, + "symbol": "vsDOT", + "precision": 10, + "type": "orml", + "icon": "vsDOT.svg", + "typeExtras": { + "currencyIdScale": "0x0a00", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "1000000", + "transfersEnabled": true + } + }, + { + "assetId": 10, + "symbol": "MANTA", + "precision": 18, + "priceId": "manta-network", + "type": "orml", + "icon": "MANTA.svg", + "typeExtras": { + "currencyIdScale": "0x0808", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "10000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 11, + "symbol": "vMANTA", + "precision": 18, + "type": "orml", + "icon": "vMANTA.svg", + "typeExtras": { + "currencyIdScale": "0x0908", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "10000000000000", + "transfersEnabled": true + } + }, + { + "assetId": 12, + "symbol": "vBNC", + "precision": 12, + "type": "orml", + "icon": "vBNC.svg", + "typeExtras": { + "currencyIdScale": "0x0101", + "currencyIdType": "bifrost_primitives.currency.CurrencyId", + "existentialDeposit": "10000000000", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://bifrost-polkadot.ibp.network", + "name": "IBP1 node" + }, + { + "url": "wss://bifrost-polkadot.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://hk.p.bifrost-rpc.liebi.com/ws", + "name": "Liebi node" + }, + { + "url": "wss://eu.bifrost-polkadot-rpc.liebi.com/ws", + "name": "LiebiEU node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://bifrost.subscan.io/extrinsic/{hash}", + "account": "https://bifrost.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-bifrost-polkadot-prod.novasama-tech.org" + } + ], + "governance": [ + { + "type": "subsquare", + "url": "https://bifrost.subsquare.io/api" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Bifrost_Polkadot.svg", + "addressPrefix": 0, + "legacyAddressPrefix": 6, + "options": [ + "governance" + ], + "additional": { + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "2fc8bb6ed7c0051bdcf4866c322ed32b6276572713607e3297ccf411b8f14aa9", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Heima", + "assets": [ + { + "assetId": 0, + "symbol": "HEI", + "precision": 18, + "icon": "HEI.svg", + "priceId": "heima" + } + ], + "nodes": [ + { + "url": "wss://rpc.litentry-parachain.litentry.io", + "name": "Litentry node" + }, + { + "url": "wss://heima-rpc.n.dwellir.com", + "name": "Dwellir node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://heima.statescan.io/#/accounts/{address}", + "extrinsic": "https://heima.statescan.io/#/extrinsics/{hash}", + "event": "https://heima.statescan.io/#/events/{event}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-litentry-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Heima.svg", + "addressPrefix": 31 + }, + { + "chainId": "84322d9cddbf35088f1e54e9a85c967a41a56a4f43445768125e61af166c7d31", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "UNIQUE", + "assets": [ + { + "assetId": 0, + "symbol": "UNQ", + "precision": 18, + "priceId": "unique-network", + "icon": "UNQ.svg" + } + ], + "nodes": [ + { + "url": "wss://eu-ws.unique.network/", + "name": "Unique Europe node" + }, + { + "url": "wss://us-ws.unique.network/", + "name": "Unique America node" + }, + { + "url": "wss://asia-ws.unique.network/", + "name": "Unique Asia node" + }, + { + "url": "wss://unique.ibp.network", + "name": "IBP1 node" + }, + { + "url": "wss://unique.dotters.network", + "name": "IBP2 node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://unique.subscan.io/extrinsic/{hash}", + "account": "https://unique.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-unique-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Unique.svg", + "addressPrefix": 7391, + "options": [ + "fullSyncByDefault" + ], + "additional": { + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "d611f22d291c5b7b69f1e105cca03352984c344c4421977efaa4cbdd1834e2aa", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Mangata X (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "MGX", + "priceId": "mangata-x", + "precision": 18, + "icon": "MGX.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x00000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 1, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "icon": "KSM.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x04000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "ETH", + "precision": 18, + "priceId": "ethereum", + "icon": "ETH.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x01000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "TUR", + "precision": 10, + "priceId": "turing-network", + "icon": "TUR.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x07000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "BNC", + "precision": 12, + "priceId": "bifrost-native-coin", + "icon": "BNC.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0e000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "RMRK (old)", + "precision": 10, + "priceId": "rmrk", + "icon": "RMRK.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x1f000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "ZLK", + "precision": 18, + "priceId": "zenlink-network-token", + "icon": "ZLK.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x1a000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 7, + "symbol": "vsKSM", + "precision": 12, + "icon": "vsKSM.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x10000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 8, + "symbol": "vKSM", + "precision": 12, + "priceId": "voucher-ksm", + "icon": "vKSM.svg", + "type": "orml", + "typeExtras": { + "currencyIdScale": "0x0f000000", + "currencyIdType": "u32", + "existentialDeposit": "0", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://kusama-rpc.mangata.online", + "name": "Mangata node" + }, + { + "url": "wss://kusama-archive.mangata.online", + "name": "Mangata archive node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://mangatax.subscan.io/extrinsic/{hash}", + "account": "https://mangatax.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/MangataX.svg", + "addressPrefix": 42 + }, + { + "chainId": "feb426ca713f0f46c96465b8f039890370cf6bfd687c9076ea2843f58a6ae8a7", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Kabocha (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "KAB", + "precision": 12, + "icon": "KAB.svg" + } + ], + "nodes": [ + { + "url": "wss://kabocha.jelliedowl.com", + "name": "JelliedOwl node" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Kabocha.svg", + "addressPrefix": 27 + }, + { + "chainId": "cceae7f3b9947cdb67369c026ef78efa5f34a08fe5808d373c04421ecf4f1aaf", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Amplitude", + "assets": [ + { + "assetId": 0, + "symbol": "AMPE", + "precision": 12, + "icon": "AMPE.svg" + }, + { + "assetId": 1, + "symbol": "KSM", + "precision": 12, + "type": "orml", + "priceId": "kusama", + "icon": "KSM.svg", + "typeExtras": { + "currencyIdScale": "0x0100", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "orml", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x0101", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "0", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "USDC.s", + "precision": 12, + "priceId": "usdc", + "type": "orml", + "icon": "USDC.svg", + "typeExtras": { + "currencyIdScale": "0x0201555344433b9911380efe988ba0a8900eb1cfe44f366f7dbe946bed077240f7f624df15c5", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "XLM.s", + "precision": 12, + "priceId": "stellar", + "type": "orml", + "icon": "XLM.svg", + "typeExtras": { + "currencyIdScale": "0x0200", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "TZS.s", + "precision": 12, + "type": "orml", + "icon": "TZS.svg", + "typeExtras": { + "currencyIdScale": "0x0201545a530034c94b2a4ba9e8b57b22547dcbb30f443c4cb02da3829a89aa1bd4780e4466ba", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "BRL.s", + "precision": 12, + "type": "orml", + "icon": "BRL.svg", + "typeExtras": { + "currencyIdScale": "0x020142524c00eaac68d4d0e37b4c24c2536916e830735f032d0d6b2a1c8fca3bc5a25e083e3a", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 7, + "symbol": "AUDD.s", + "precision": 12, + "priceId": "novatti-australian-digital-dollar", + "type": "orml", + "icon": "AUDD.svg", + "typeExtras": { + "currencyIdScale": "0x020141554444c5fbe9979e240552860221f4fe2f2219f35e40458b8b58fc32da520a154a561d", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 8, + "symbol": "EURC.s", + "precision": 12, + "type": "orml", + "icon": "EURM.svg", + "typeExtras": { + "currencyIdScale": "0x0201455552432112ee863867e4e219fe254c0918b00bc9ea400775bfc3ab4430971ce505877c", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 9, + "symbol": "NGNC.s", + "precision": 12, + "type": "orml", + "icon": "NGNC.svg", + "typeExtras": { + "currencyIdScale": "0x02014e474e43241afadf31883f79972545fc64f3b5b0c95704c6fb4917474e42b0057841606b", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://rpc-amplitude.pendulumchain.tech", + "name": "PendulumChain node" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-amplitude-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Amplitude.svg", + "addressPrefix": 0, + "additional": { + "feeViaRuntimeCall": true, + "supportsGenericLedgerApp": true + }, + "legacyAddressPrefix": 57 + }, + { + "chainId": "6859c81ca95ef624c9dfe4dc6e3381c33e5d6509e35e147092bfbc780f777c4e", + "name": "Ternoa", + "assets": [ + { + "assetId": 0, + "symbol": "CAPS", + "priceId": "coin-capsule", + "staking": [ + "relaychain" + ], + "precision": 18, + "icon": "CAPS.svg", + "buyProviders": { + "banxa": { + "coinType": "CAPS", + "blockchain": "TERNOA" + } + } + } + ], + "nodes": [ + { + "url": "wss://mainnet.ternoa.network", + "name": "CapsuleCorp node" + } + ], + "explorers": [ + { + "name": "Ternoa explorer", + "extrinsic": "https://explorer.ternoa.com/extrinsic/{hash}", + "account": "https://explorer.ternoa.com/account/{address}" + } + ], + "externalApi": { + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-ternoa-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-ternoa-prod.novasama-tech.org" + } + ], + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-ternoa-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Ternoa.svg", + "addressPrefix": 42, + "additional": { + "themeColor": "#EB2B6C", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/ternoa-caps-staking", + "feeViaRuntimeCall": true + } + }, + { + "chainId": "6fbd74e5e1d0a61d52ccfe9d4adaed16dd3a7caa37c6bc4d0c2fa12e8b2f4063", + "name": "Polymesh", + "assets": [ + { + "assetId": 0, + "symbol": "POLYX", + "precision": 6, + "priceId": "polymesh", + "icon": "POLYX.svg", + "buyProviders": { + "banxa": { + "coinType": "POLYX", + "blockchain": "Polymesh" + } + } + } + ], + "nodes": [ + { + "url": "wss://mainnet-rpc.polymesh.network", + "name": "Polymesh node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://polymesh.subscan.io/extrinsic/{hash}", + "account": "https://polymesh.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Polymesh.svg", + "addressPrefix": 12, + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-polymesh-prod.novasama-tech.org" + } + ] + } + }, + { + "chainId": "6f0f071506de39058fe9a95bbca983ac0e9c5da3443909574e95d52eb078d348", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "DAO IPCI", + "assets": [ + { + "assetId": 0, + "symbol": "MITO", + "precision": 12, + "icon": "MITO.svg" + } + ], + "nodes": [ + { + "url": "wss://ipci.rpc.robonomics.network", + "name": "Airalab node" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-dao-ipci-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/DAOIPCI.svg", + "addressPrefix": 32 + }, + { + "chainId": "74ed91fbc18497f011290f9119a2217908649170337b6414a2d44923ade07063", + "name": "Myriad (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "MYRIA", + "precision": 18, + "priceId": "myriad-social", + "icon": "MYRIA.svg" + } + ], + "nodes": [ + { + "url": "wss://gateway.mainnet.octopus.network/myriad/a4cb0a6e30ff5233a3567eb4e8cb71e0", + "name": "Octopus node" + } + ], + "explorers": [ + { + "name": "Explorer", + "account": "https://explorer.mainnet.oct.network/myriad/accounts/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Myriad.svg", + "addressPrefix": 42 + }, + { + "chainId": "50dd5d206917bf10502c68fb4d18a59fc8aa31586f4e8856b493e43544aa82aa", + "name": "XX network", + "assets": [ + { + "assetId": 0, + "symbol": "XX", + "precision": 9, + "priceId": "xxcoin", + "icon": "XX.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.xx.network", + "name": "xx Foundation 1 node" + }, + { + "url": "wss://rpc-hetzner.xx.network", + "name": "xx Foundation 2 node" + } + ], + "explorers": [ + { + "name": "XX explorer", + "extrinsic": "https://explorer.xx.network/extrinsics/{hash}", + "account": "https://explorer.xx.network/accounts/{address}" + }, + { + "name": "Polkastats", + "extrinsic": "https://xx.polkastats.io/extrinsic/{hash}", + "account": "https://xx.polkastats.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/XX_network.svg", + "addressPrefix": 55, + "additional": { + "feeViaRuntimeCall": true + } + }, + { + "chainId": "5d3c298622d5634ed019bf61ea4b71655030015bde9beb0d6a24743714462c86", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Pendulum", + "assets": [ + { + "assetId": 0, + "symbol": "PEN", + "precision": 12, + "priceId": "pendulum-chain", + "icon": "PEN.svg" + }, + { + "assetId": 1, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "type": "orml", + "icon": "DOT.svg", + "typeExtras": { + "currencyIdScale": "0x0100", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 2, + "symbol": "USDT", + "precision": 6, + "priceId": "tether", + "type": "orml", + "icon": "USDT.svg", + "typeExtras": { + "currencyIdScale": "0x0101", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 3, + "symbol": "USDC", + "precision": 6, + "priceId": "usd-coin", + "type": "orml", + "icon": "USDC.svg", + "typeExtras": { + "currencyIdScale": "0x0102", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 4, + "symbol": "USDC.s", + "precision": 12, + "priceId": "usdc", + "type": "orml", + "icon": "USDC.svg", + "typeExtras": { + "currencyIdScale": "0x0201555344433b9911380efe988ba0a8900eb1cfe44f366f7dbe946bed077240f7f624df15c5", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 5, + "symbol": "XLM.s", + "precision": 12, + "priceId": "stellar", + "type": "orml", + "icon": "XLM.svg", + "typeExtras": { + "currencyIdScale": "0x0200", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 6, + "symbol": "TZS.s", + "precision": 12, + "type": "orml", + "icon": "TZS.svg", + "typeExtras": { + "currencyIdScale": "0x0201545a530034c94b2a4ba9e8b57b22547dcbb30f443c4cb02da3829a89aa1bd4780e4466ba", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 7, + "symbol": "BRL.s", + "precision": 12, + "type": "orml", + "icon": "BRL.svg", + "typeExtras": { + "currencyIdScale": "0x020142524c00eaac68d4d0e37b4c24c2536916e830735f032d0d6b2a1c8fca3bc5a25e083e3a", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 8, + "symbol": "AUDD.s", + "precision": 12, + "priceId": "novatti-australian-digital-dollar", + "type": "orml", + "icon": "AUDD.svg", + "typeExtras": { + "currencyIdScale": "0x020141554444c5fbe9979e240552860221f4fe2f2219f35e40458b8b58fc32da520a154a561d", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 9, + "symbol": "EURC.s", + "precision": 12, + "type": "orml", + "icon": "EURM.svg", + "priceId": "euro-coin", + "typeExtras": { + "currencyIdScale": "0x0201455552432112ee863867e4e219fe254c0918b00bc9ea400775bfc3ab4430971ce505877c", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 10, + "symbol": "NGNC.s", + "precision": 12, + "type": "orml", + "icon": "NGNC.svg", + "typeExtras": { + "currencyIdScale": "0x02014e474e43241afadf31883f79972545fc64f3b5b0c95704c6fb4917474e42b0057841606b", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 11, + "symbol": "GLMR", + "precision": 18, + "priceId": "moonbeam", + "type": "orml", + "icon": "GLMR.svg", + "typeExtras": { + "currencyIdScale": "0x0106", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 12, + "symbol": "PINK", + "precision": 10, + "type": "orml", + "icon": "PINK.svg", + "typeExtras": { + "currencyIdScale": "0x0107", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 13, + "symbol": "HDX", + "precision": 12, + "type": "orml", + "priceId": "hydradx", + "icon": "HDX.svg", + "typeExtras": { + "currencyIdScale": "0x0108", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 14, + "symbol": "ASTR", + "precision": 18, + "type": "orml", + "priceId": "astar", + "icon": "ASTR.svg", + "typeExtras": { + "currencyIdScale": "0x0109", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000000", + "transfersEnabled": true + } + }, + { + "assetId": 15, + "symbol": "vDOT", + "precision": 10, + "type": "orml", + "priceId": "voucher-dot", + "icon": "vDOT.svg", + "typeExtras": { + "currencyIdScale": "0x010a", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000000", + "transfersEnabled": true + } + }, + { + "assetId": 16, + "symbol": "BNC", + "precision": 12, + "type": "orml", + "priceId": "bifrost-native-coin", + "icon": "BNC.svg", + "typeExtras": { + "currencyIdScale": "0x010b", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "10000000000", + "transfersEnabled": true + } + }, + { + "assetId": 17, + "symbol": "USDC.axl", + "precision": 6, + "type": "orml", + "priceId": "axlusdc", + "icon": "USDCaxl.svg", + "typeExtras": { + "currencyIdScale": "0x010c", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + }, + { + "assetId": 18, + "symbol": "EURC.s", + "precision": 12, + "type": "orml", + "priceId": "euro-coin", + "icon": "EURC.svg", + "typeExtras": { + "currencyIdScale": "0x020145555243cf4f5a26e2090bb3adcf02c7a9d73dbfe6659cc690461475b86437fa49c71136", + "currencyIdType": "spacewalk_primitives.CurrencyId", + "existentialDeposit": "1000", + "transfersEnabled": true + } + } + ], + "nodes": [ + { + "url": "wss://rpc-pendulum.prd.pendulumchain.tech", + "name": "Pendulum node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://pendulum.subscan.io/extrinsic/{hash}", + "account": "https://pendulum.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-pendulum-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Pendulum.svg", + "addressPrefix": 0, + "options": [ + "governance-v1" + ], + "additional": { + "supportsGenericLedgerApp": true + }, + "legacyAddressPrefix": 56 + }, + { + "chainId": "4319cc49ee79495b57a1fec4d2bd43f59052dcc690276de566c2691d6df4f7b8", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Crust Polkadot Parachain", + "assets": [ + { + "assetId": 0, + "symbol": "CRU", + "precision": 12, + "priceId": "crust-network", + "icon": "CRU.svg" + } + ], + "nodes": [ + { + "url": "wss://crust-parachain.crustapps.net", + "name": "Crust node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://crust-parachain.subscan.io/extrinsic/{hash}", + "account": "https://crust-parachain.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-crust-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Crust_Parachain.svg", + "addressPrefix": 0, + "additional": { + "feeViaRuntimeCall": true + }, + "legacyAddressPrefix": 88 + }, + { + "chainId": "8b5c955b5c8fd7112562327e3859473df4e3dff49bd72a113dbb668d2cfa20d7", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Aventus", + "assets": [ + { + "assetId": 0, + "symbol": "AVT", + "precision": 18, + "priceId": "aventus", + "icon": "AVT.svg" + } + ], + "nodes": [ + { + "url": "wss://avn-parachain.mainnet.aventus.io", + "name": "Aventus node 1" + }, + { + "url": "wss://public-rpc.mainnet.aventus.io", + "name": "Aventus node 2" + } + ], + "explorers": [ + { + "name": "Aventus explorer", + "extrinsic": "https://explorer.mainnet.aventus.io/transaction/{hash}", + "account": "https://explorer.mainnet.aventus.io/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-aventus-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Aventus.svg", + "addressPrefix": 42 + }, + { + "chainId": "dce5477cfca571c2cb652f38bbb70429004be3cf9649dd2b4ad9455b2251fe43", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Hashed Network (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "HASH", + "precision": 18, + "icon": "HASH.svg" + } + ], + "nodes": [ + { + "url": "wss://c1.hashed.network", + "name": "Hashed systems 1 node" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Hashed.svg", + "addressPrefix": 42 + }, + { + "chainId": "eip155:2109", + "name": "Exosama", + "assets": [ + { + "assetId": 0, + "symbol": "SAMA", + "priceId": "exosama-network", + "type": "evmNative", + "icon": "SAMA.svg", + "precision": 18 + } + ], + "nodes": [ + { + "url": "wss://rpc.exosama.com", + "name": "Exosama node" + } + ], + "explorers": [ + { + "name": "Exosama explorer", + "extrinsic": "https://explorer.exosama.com/tx/{hash}", + "account": "https://explorer.exosama.com/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "etherscan", + "url": "https://explorer.exosama.com/api", + "parameters": { + "assetType": "evm" + } + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Exosama.svg", + "addressPrefix": 2109, + "options": [ + "ethereumBased", + "noSubstrateRuntime" + ] + }, + { + "chainId": "2f0555cc76fc2840a25a6ea3b9637146806f1f44b090c175ffde2a7e5ab36c03", + "name": "Bittensor", + "assets": [ + { + "assetId": 0, + "symbol": "TAO", + "precision": 9, + "priceId": "bittensor", + "icon": "TAO_bittensor.svg" + } + ], + "nodes": [ + { + "url": "wss://lite.sub.latent.to:443", + "name": "Latent Holdings (Lite) node" + }, + { + "url": "wss://bittensor-finney.api.onfinality.io/public-ws", + "name": "OnFinality (Archive) node" + }, + { + "url": "wss://entrypoint-finney.opentensor.ai:443", + "name": "Opentensor Fdn node" + } + ], + "explorers": [ + { + "name": "SCAN", + "account": "https://bittensor.com/scan/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-bittensor-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Bittensor.svg", + "addressPrefix": 42, + "additional": { + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "e358eb1d11b31255a286c12e44fe6780b7edb171d657905a97e39f71d9c6c3ee", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Ajuna", + "assets": [ + { + "assetId": 0, + "symbol": "AJUN", + "precision": 12, + "priceId": "ajuna-network-2", + "icon": "AJUN.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc-para.ajuna.network", + "name": "Ajuna node" + }, + { + "url": "wss://ajuna.ibp.network", + "name": "IBP1 node" + }, + { + "url": "wss://ajuna.dotters.network", + "name": "IBP2 node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://ajuna.statescan.io/#/accounts/{address}", + "extrinsic": "https://ajuna.statescan.io/#/extrinsics/{hash}", + "event": "https://ajuna.statescan.io/#/events/{event}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-ajuna-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Ajuna.svg", + "addressPrefix": 1328 + }, + { + "chainId": "6c5894837ad89b6d92b114a2fb3eafa8fe3d26a54848e3447015442cd6ef4e66", + "name": "3DPass", + "assets": [ + { + "assetId": 0, + "symbol": "P3D", + "precision": 12, + "priceId": "3dpass", + "icon": "P3D.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.3dpass.org", + "name": "3DPass node" + }, + { + "url": "wss://rpc.p3d.top", + "name": "Lzmz node" + } + ], + "explorers": [ + { + "name": "3DPass explorer", + "extrinsic": "https://explorer.3dpass.org/extrinsic/{hash}", + "account": "https://explorer.3dpass.org/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-3dpass-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/3DPass.svg", + "addressPrefix": 71 + }, + { + "chainId": "f0b8924b12e8108550d28870bc03f7b45a947e1b2b9abf81bfb0b89ecb60570e", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Darwinia", + "assets": [ + { + "assetId": 0, + "symbol": "RING", + "precision": 18, + "priceId": "darwinia-network-native-token", + "icon": "RING.svg" + }, + { + "assetId": 1, + "symbol": "KTON", + "precision": 18, + "type": "statemine", + "icon": "KTON.svg", + "typeExtras": { + "assetId": "1026", + "isSufficient": true + } + } + ], + "nodes": [ + { + "url": "wss://rpc.darwinia.network", + "name": "Darwinia node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://darwinia.subscan.io/extrinsic/{hash}", + "account": "https://darwinia.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Darwinia.svg", + "addressPrefix": 18, + "options": [ + "ethereumBased" + ] + }, + { + "chainId": "86e49c195aeae7c5c4a86ced251f1a28c67b3c35d8289c387ede1776cdd88b24", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Crab", + "assets": [ + { + "assetId": 0, + "symbol": "CRAB", + "precision": 18, + "priceId": "darwinia-network-native-token", + "icon": "CRAB.svg" + }, + { + "assetId": 1, + "symbol": "CKTON", + "precision": 18, + "type": "statemine", + "icon": "CKTON.svg", + "typeExtras": { + "assetId": "1026", + "isSufficient": true + } + } + ], + "nodes": [ + { + "url": "wss://crab-rpc.darwinia.network/", + "name": "Darwinia node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://crab.subscan.io/extrinsic/{hash}", + "account": "https://crab.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-crab-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Crab.svg", + "addressPrefix": 42, + "options": [ + "ethereumBased" + ] + }, + { + "chainId": "4a587bf17a404e3572747add7aab7bbe56e805a5479c6c436f07f36fcc8d3ae1", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Frequency", + "assets": [ + { + "assetId": 0, + "symbol": "FRQCY", + "precision": 8, + "icon": "FRQCY.svg" + } + ], + "nodes": [ + { + "url": "wss://0.rpc.frequency.xyz", + "name": "Frequency0 node" + }, + { + "url": "wss://1.rpc.frequency.xyz", + "name": "Frequency1 node" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-frequency-prod.novasama-tech.org" + } + ] + }, + "types": { + "url": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v2/types/frequency.json", + "overridesCommon": true + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Frequency.svg", + "addressPrefix": 90 + }, + { + "chainId": "fe1b4c55fd4d668101126434206571a7838a8b6b93a6d1b95d607e78e6c53763", + "name": "Vara", + "assets": [ + { + "assetId": 0, + "symbol": "VARA", + "precision": 12, + "priceId": "vara-network", + "staking": [ + "relaychain", + "nomination-pools" + ], + "icon": "VARA.svg", + "buyProviders": { + "banxa": { + "coinType": "VARA", + "blockchain": "VARA" + } + } + } + ], + "nodes": [ + { + "url": "wss://rpc.vara-network.io", + "name": "Gear Tech node" + } + ], + "explorers": [ + { + "name": "Varascan", + "account": "https://explorer.vara-network.io/account/{address}", + "event": "https://explorer.vara-network.io/event/{event}" + }, + { + "name": "Subscan", + "extrinsic": "https://vara.subscan.io/extrinsic/{hash}", + "account": "https://vara.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-vara-prod.novasama-tech.org" + } + ], + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-vara-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-vara-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Vara.svg", + "addressPrefix": 137, + "additional": { + "themeColor": "#10B6B1", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/vara-vara-staking", + "supportsGenericLedgerApp": true + }, + "options": [ + "pushSupport" + ] + }, + { + "chainId": "49f4849244114177544d73d2412289f6d73a892b9eb1ca97cef81c9c0c9ec0ff", + "name": "GIANT", + "assets": [ + { + "assetId": 0, + "symbol": "GIANT", + "precision": 12, + "icon": "GIANT.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc-anchor-1-mainnet-az-ue1.giantprotocol.org:443", + "name": "Anchor node 1" + }, + { + "url": "wss://rpc-anchor-2-mainnet-az-ue1.giantprotocol.org:443", + "name": "Anchor node 2" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-giant-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Giant.svg", + "addressPrefix": 42, + "additional": { + "feeViaRuntimeCall": true + } + }, + { + "chainId": "dcf691b5a3fbe24adc99ddc959c0561b973e329b1aef4c4b22e7bb2ddecb4464", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Polkadot Bridge Hub", + "assets": [ + { + "assetId": 0, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "icon": "DOT.svg" + } + ], + "nodes": [ + { + "url": "wss://sys.ibp.network/bridgehub-polkadot", + "name": "IBP1 node" + }, + { + "url": "wss://bridge-hub-polkadot.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://dot-rpc.stakeworld.io/bridgehub", + "name": "Stakeworld node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://bridgehub-polkadot.subscan.io/extrinsic/{hash}", + "account": "https://bridgehub-polkadot.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://bridgehub-polkadot.statescan.io/#/accounts/{address}", + "extrinsic": "https://bridgehub-polkadot.statescan.io/#/extrinsics/{hash}", + "event": "https://bridgehub-polkadot.statescan.io/#/events/{event}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadot-bh-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Polkadot_Bridge_Hub.svg", + "addressPrefix": 0, + "additional": { + "identityChain": "67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008" + } + }, + { + "chainId": "00dcb981df86429de8bbacf9803401f09485366c44efbf53af9ecfab03adc7e5", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Kusama Bridge Hub", + "assets": [ + { + "assetId": 0, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "icon": "KSM.svg" + } + ], + "nodes": [ + { + "url": "wss://sys.ibp.network/bridgehub-kusama", + "name": "IBP1 node" + }, + { + "url": "wss://bridge-hub-kusama.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://ksm-rpc.stakeworld.io/bridgehub", + "name": "Stakeworld node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://bridgehub-kusama.subscan.io/extrinsic/{hash}", + "account": "https://bridgehub-kusama.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://bridgehub-kusama.statescan.io/#/accounts/{address}", + "extrinsic": "https://bridgehub-kusama.statescan.io/#/extrinsics/{hash}", + "event": "https://bridgehub-kusama.statescan.io/#/events/{event}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-kusama-bh-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Kusama_Bridge_Hub.svg", + "addressPrefix": 2, + "additional": { + "identityChain": "c1af4cb4eb3918e5db15086c0cc5ec17fb334f728b7c65dd44bfe1e174ff8b3f" + } + }, + { + "chainId": "46ee89aa2eedd13e988962630ec9fb7565964cf5023bb351f2b6b25c1b68b0b2", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Polkadot Collectives", + "assets": [ + { + "assetId": 0, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "icon": "DOT.svg" + } + ], + "nodes": [ + { + "url": "wss://sys.ibp.network/collectives-polkadot", + "name": "IBP1 node" + }, + { + "url": "wss://collectives-polkadot.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://collectives.public.curie.radiumblock.co/ws", + "name": "Radium node" + }, + { + "url": "wss://dot-rpc.stakeworld.io/collectives", + "name": "Stakeworld node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://collectives.statescan.io/#/accounts/{address}", + "event": "https://collectives.statescan.io/#/events/{event}", + "extrinsic": "https://collectives.statescan.io/#/extrinsics/{hash}" + }, + { + "name": "Subscan", + "extrinsic": "https://collectives-polkadot.subscan.io/extrinsic/{hash}", + "account": "https://collectives-polkadot.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-polkadot-col-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Polkadot_Collectives.svg", + "addressPrefix": 0 + }, + { + "chainId": "b3dd5ad6a82872b30aabaede8f41dfd4ff6c32ff82f8757d034a45be63cf104c", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "krest", + "assets": [ + { + "assetId": 0, + "symbol": "KREST", + "precision": 18, + "priceId": "krest", + "icon": "KREST.svg" + } + ], + "nodes": [ + { + "url": "wss://krest.api.onfinality.io/public-ws'", + "name": "OnFinality node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://krest.subscan.io/extrinsic/{hash}", + "account": "https://krest.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/krest.svg", + "addressPrefix": 42 + }, + { + "chainId": "3af4ff48ec76d2efc8476730f423ac07e25ad48f5f4c9dc39c778b164d808615", + "name": "Enjin Matrix", + "assets": [ + { + "assetId": 0, + "symbol": "ENJ", + "precision": 18, + "priceId": "enjincoin", + "icon": "ENJ.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.matrix.blockchain.enjin.io", + "name": "Enjin node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://matrix.subscan.io/extrinsic/{hash}", + "account": "https://matrix.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-enjin-matrixc-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Enjin.svg", + "addressPrefix": 1110, + "additional": { + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "6b5e488e0fa8f9821110d5c13f4c468abcd43ce5e297e62b34c53c3346465956", + "name": "Joystream", + "assets": [ + { + "assetId": 0, + "symbol": "JOY", + "priceId": "joystream", + "precision": 10, + "icon": "JOY.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.joystream.org", + "name": "Jsgenesis node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://joystream.subscan.io/extrinsic/{hash}", + "account": "https://joystream.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-joystream-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Joystream.svg", + "addressPrefix": 126 + }, + { + "chainId": "6bfe24dca2a3be10f22212678ac13a6446ec764103c0f3471c71609eac384aae", + "name": "Dock (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "DOCK", + "priceId": "dock", + "precision": 6, + "icon": "DOCK.svg" + } + ], + "nodes": [ + { + "url": "wss://mainnet-node.dock.io", + "name": "Dock Association node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://dock.subscan.io/extrinsic/{hash}", + "account": "https://dock.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Dock.svg", + "addressPrefix": 22 + }, + { + "chainId": "eip155:246", + "name": "Energy Web Chain", + "assets": [ + { + "assetId": 0, + "symbol": "EWT", + "priceId": "energy-web-token", + "type": "evmNative", + "icon": "EWT.svg", + "precision": 18 + } + ], + "nodeSelectionStrategy": "uniform", + "nodes": [ + { + "url": "https://consortia-rpc.energyweb.org", + "name": "Public EWC http node" + }, + { + "url": "https://rpc.energyweb.org", + "name": "Energy web http node" + }, + { + "url": "https://archive-rpc.energyweb.org", + "name": "Archive http node" + }, + { + "url": "wss://rpc.energyweb.org/ws", + "name": "Energy web wss node" + } + ], + "explorers": [ + { + "name": "Energy Web Explorer", + "extrinsic": "https://explorer.energyweb.org/tx/{hash}", + "account": "https://explorer.energyweb.org/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "etherscan", + "url": "https://explorer.energyweb.org/api", + "parameters": { + "assetType": "evm" + } + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Energy_Web.svg", + "addressPrefix": 246, + "options": [ + "ethereumBased", + "noSubstrateRuntime" + ] + }, + { + "chainId": "5a51e04b88a4784d205091aa7bada002f3e5da3045e5b05655ee4db2589c33b5", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Energy Web X", + "assets": [ + { + "assetId": 0, + "symbol": "EWT", + "priceId": "energy-web-token", + "precision": 18, + "icon": "EWT.svg" + } + ], + "nodes": [ + { + "url": "wss://public-rpc.mainnet.energywebx.com/", + "name": "Energywebx node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://energywebx.subscan.io/extrinsic/{hash}", + "account": "https://energywebx.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-energywebx-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Energy_Web_X.svg", + "addressPrefix": 42 + }, + { + "chainId": "31a7d8914fb31c249b972f18c115f1e22b4b039abbcb03c73b6774c5642f9efe", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "InvArch (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "VARCH", + "precision": 12, + "icon": "VARCH.svg" + } + ], + "nodes": [ + { + "url": "wss://invarch.ibp.network", + "name": "IBP1 node" + }, + { + "url": "wss://invarch.dotters.network", + "name": "IBP2 node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://invarch.statescan.io/#/accounts/{address}", + "event": "https://invarch.statescan.io/#/events/{event}", + "extrinsic": "https://invarch.statescan.io/#/extrinsics/{hash}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/InvArch.svg", + "addressPrefix": 117, + "additional": { + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "f3c7ad88f6a80f366c4be216691411ef0622e8b809b1046ea297ef106058d4eb", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Manta Atlantic", + "assets": [ + { + "assetId": 0, + "symbol": "MANTA", + "precision": 18, + "priceId": "manta-network", + "icon": "MANTA.svg", + "staking": [ + "parachain" + ] + }, + { + "assetId": 1, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "type": "statemine", + "icon": "DOT.svg", + "typeExtras": { + "assetId": "8", + "isSufficient": true + } + } + ], + "nodes": [ + { + "url": "wss://ws.manta.systems", + "name": "Manta node" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-manta-prod.novasama-tech.org" + } + ], + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-manta-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-manta-prod.novasama-tech.org" + } + ] + }, + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://manta.subscan.io/extrinsic/{hash}", + "account": "https://manta.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Manta.svg", + "addressPrefix": 77, + "additional": { + "themeColor": "#1F78FF", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/manta-manta-staking", + "defaultBlockTime": 12000, + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "eip155:169", + "name": "Manta Pacific", + "assets": [ + { + "assetId": 0, + "symbol": "ETH", + "priceId": "ethereum", + "type": "evmNative", + "icon": "ETH.svg", + "precision": 18 + } + ], + "nodeSelectionStrategy": "uniform", + "nodes": [ + { + "url": "https://pacific-rpc.manta.network/http", + "name": "Manta rpc 1 node" + }, + { + "url": "https://1rpc.io/manta", + "name": "Manta rpc 2 node" + }, + { + "url": "wss://pacific-rpc.manta.network/ws", + "name": "Manta wss node" + } + ], + "externalApi": { + "history": [ + { + "type": "etherscan", + "url": "https://pacific-explorer.manta.network/api", + "parameters": { + "assetType": "evm" + } + } + ] + }, + "explorers": [ + { + "name": "Manta Pacific Block Explorer", + "extrinsic": "https://pacific-explorer.manta.network/tx/{hash}", + "account": "https://pacific-explorer.manta.network/address/{address}" + }, + { + "name": "Manta Socialscan", + "extrinsic": "https://manta.socialscan.io/tx/{hash}", + "account": "https://manta.socialscan.io/address/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Manta.svg", + "addressPrefix": 169, + "options": [ + "ethereumBased", + "noSubstrateRuntime" + ] + }, + { + "chainId": "d8761d3c88f26dc12875c00d3165f7d67243d56fc85b4cf19937601a7916e5a9", + "name": "Enjin Relay", + "assets": [ + { + "assetId": 0, + "symbol": "ENJ", + "precision": 18, + "priceId": "enjincoin", + "icon": "ENJ.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.relay.blockchain.enjin.io", + "name": "Enjin node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://enjin.subscan.io/extrinsic/{hash}", + "account": "https://enjin.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Enjin.svg", + "addressPrefix": 2135, + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-enjin-relaychain-prod.novasama-tech.org" + } + ] + }, + "additional": { + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "1d73b9f5dc392744e0dee00a6d6254fcfa2305386cceba60315894fa4807053a", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Curio", + "assets": [ + { + "assetId": 0, + "symbol": "CGT", + "precision": 18, + "priceId": "curio-governance", + "icon": "CGT.svg" + } + ], + "nodes": [ + { + "url": "wss://archive.parachain.curioinvest.com", + "name": "Curio 1 node" + }, + { + "url": "wss://parachain.curioinvest.com/", + "name": "Curio 2 node" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-curio-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Curio.svg", + "addressPrefix": 777 + }, + { + "chainId": "f6ee56e9c5277df5b4ce6ae9983ee88f3cbed27d31beeb98f9f84f997a1ab0b9", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Mythos", + "assets": [ + { + "assetId": 0, + "symbol": "MYTH", + "precision": 18, + "priceId": "mythos", + "icon": "MYTH.svg", + "staking": [ + "mythos" + ] + } + ], + "nodes": [ + { + "url": "wss://polkadot-mythos-rpc.polkadot.io", + "name": "Parity node", + "features": [ + "noTls12" + ] + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://mythos.subscan.io/extrinsic/{hash}", + "account": "https://mythos.subscan.io/account/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-mythos-prod.novasama-tech.org" + } + ], + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-mythos-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-mythos-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Mythos.svg", + "addressPrefix": 29972, + "options": [ + "ethereumBased", + "governance-v1", + "proxy", + "multisig" + ], + "additional": { + "themeColor": "#ED3A47", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/mythos-myth-staking", + "feeViaRuntimeCall": true, + "sessionLength": 14400, + "defaultBlockTime": 6000, + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "ce7681fb12aa8f7265d229a9074be0ea1d5e99b53eedcec2deade43857901808", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Acurast Canary", + "assets": [ + { + "assetId": 0, + "symbol": "cACU", + "precision": 12, + "icon": "cACU.svg" + } + ], + "nodes": [ + { + "url": "wss://public-rpc.canary.acurast.com", + "name": "Acurast node" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Acurast_Canary.svg", + "addressPrefix": 42, + "additional": { + "feeViaRuntimeCall": true + }, + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-acurast-prod.novasama-tech.org" + } + ] + } + }, + { + "chainId": "c710a5f16adc17bcd212cff0aedcbf1c1212a043cdc0fb2dcba861efe5305b01", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Kreivo", + "assets": [ + { + "assetId": 0, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "icon": "KSM.svg" + } + ], + "nodes": [ + { + "url": "wss://kreivo.kippu.rocks/", + "name": "Kippu node" + } + ], + "externalApi": { + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-kreivo-prod.novasama-tech.org" + } + ] + }, + "types": { + "url": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v2/types/kreivo.json", + "overridesCommon": true + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Kreivo.svg", + "addressPrefix": 2 + }, + { + "chainId": "61ea8a51fd4a058ee8c0e86df0a89cc85b8b67a0a66432893d09719050c9f540", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Hyperbridge Nexus", + "assets": [ + { + "assetId": 0, + "symbol": "BRIDGE", + "precision": 12, + "priceId": "hyperbridge-2", + "icon": "BRIDGE.svg" + } + ], + "nodes": [ + { + "url": "wss://nexus.ibp.network", + "name": "IBP1 node" + }, + { + "url": "wss://nexus.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://hyperbridge-nexus-rpc.blockops.network", + "name": "BlockOps node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://nexus.statescan.io/#/accounts/{address}", + "event": "https://nexus.statescan.io/#/events/{event}", + "extrinsic": "https://nexus.statescan.io/#/extrinsics/{hash}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Hyperbridge_Nexus.svg", + "addressPrefix": 0, + "legacyAddressPrefix": 42, + "additional": { + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "81443836a9a24caaa23f1241897d1235717535711d1d3fe24eae4fdc942c092c", + "name": "Cere", + "assets": [ + { + "assetId": 0, + "symbol": "CERE", + "precision": 10, + "priceId": "cere-network", + "icon": "CERE.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.mainnet.cere.network/ws", + "name": "Cere node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://cere.statescan.io/#/accounts/{address}", + "event": "https://cere.statescan.io/#/events/{event}", + "extrinsic": "https://cere.statescan.io/#/extrinsics/{hash}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Cere.svg", + "addressPrefix": 54, + "additional": { + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "b91746b45e0346cc2f815a520b9c6cb4d5c0902af848db0a80f85932d2e8276a", + "name": "Avail", + "assets": [ + { + "assetId": 0, + "symbol": "AVAIL", + "precision": 18, + "priceId": "avail", + "icon": "AVAIL.svg", + "staking": [ + "relaychain", + "nomination-pools" + ] + } + ], + "nodes": [ + { + "url": "wss://avail.api.onfinality.io/public-ws", + "name": "Avail primary node" + }, + { + "url": "wss://mainnet.avail-rpc.com/", + "name": "Avail secondary node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://avail.subscan.io/extrinsic/{hash}", + "account": "https://avail.subscan.io/account/{address}" + } + ], + "types": { + "url": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v2/types/avail.json", + "overridesCommon": true + }, + "externalApi": { + "staking": [ + { + "type": "subquery", + "url": "https://subquery-history-avail-prod.novasama-tech.org" + } + ], + "staking-rewards": [ + { + "type": "subquery", + "url": "https://subquery-history-avail-prod.novasama-tech.org" + } + ], + "history": [ + { + "type": "subquery", + "url": "https://subquery-history-avail-prod.novasama-tech.org" + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Avail.svg", + "addressPrefix": 42, + "options": [ + "pushSupport", + "multisig" + ], + "additional": { + "themeColor": "#58C8F6", + "stakingWiki": "https://docs.novawallet.io/nova-wallet-wiki/staking/avail-avail-staking" + } + }, + { + "chainId": "0313f6a011d128d22f996703cbab05162e2fdc9e031493314fe6db79979c5ca7", + "name": "DENTNet", + "assets": [ + { + "assetId": 0, + "symbol": "DENTX", + "precision": 18, + "icon": "DENTX.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.dentnet.io/ws", + "name": "DENTNet node" + } + ], + "explorers": [ + { + "name": "DENTNet Explorer", + "account": "https://main.dentnet.io/explorer/dentnet/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/DENTNet.svg", + "addressPrefix": 9807 + }, + { + "chainId": "eip155:8453", + "name": "Base", + "assets": [ + { + "assetId": 0, + "symbol": "ETH", + "priceId": "ethereum", + "type": "evmNative", + "icon": "ETH.svg", + "precision": 18 + } + ], + "nodeSelectionStrategy": "uniform", + "nodes": [ + { + "url": "https://base-rpc.publicnode.com", + "name": "Base http node" + }, + { + "url": "https://base-pokt.nodies.app", + "name": "Pokt node" + }, + { + "url": "wss://base-rpc.publicnode.com", + "name": "Base public node" + }, + { + "url": "wss://base-mainnet.infura.io/ws/v3/82fd5b2925e341719f10b7ed4376a646", + "name": "Ifura node" + } + ], + "explorers": [ + { + "name": "Basescan", + "extrinsic": "https://basescan.org/tx/{hash}", + "account": "https://basescan.org/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "etherscan", + "url": "https://api.basescan.org/api", + "parameters": { + "assetType": "evm" + } + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Base.svg", + "addressPrefix": 8453, + "options": [ + "ethereumBased", + "noSubstrateRuntime" + ] + }, + { + "chainId": "eip155:324", + "name": "ZKsync Era", + "assets": [ + { + "assetId": 0, + "symbol": "ETH", + "priceId": "ethereum", + "type": "evmNative", + "icon": "ETH.svg", + "precision": 18 + } + ], + "nodeSelectionStrategy": "uniform", + "nodes": [ + { + "url": "https://mainnet.era.zksync.io", + "name": "http node" + }, + { + "url": "https://1rpc.io/zksync2-era", + "name": "http node 2" + }, + { + "url": "https://endpoints.omniatech.io/v1/zksync-era/mainnet/public", + "name": "http node 3" + }, + { + "url": "wss://mainnet.era.zksync.io/ws", + "name": "wss node" + } + ], + "explorers": [ + { + "name": "ZKsync Era explorer", + "extrinsic": "https://era.zksync.network/tx/{hash}", + "account": "https://era.zksync.network/address/{address}" + }, + { + "name": "Block explorer", + "extrinsic": "https://explorer.zksync.io/tx/{hash}", + "account": "https://explorer.zksync.io/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "etherscan", + "url": "https://api-era.zksync.network/api", + "parameters": { + "assetType": "evm" + } + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/ZKsync_Mainnet.svg", + "addressPrefix": 324, + "options": [ + "ethereumBased", + "noSubstrateRuntime" + ] + }, + { + "chainId": "dd954cbf4000542ef1a15bca509cd89684330bee5e23766c527cdb0d7275e9c2", + "name": "CC Enterprise", + "assets": [ + { + "assetId": 0, + "symbol": "CTC", + "precision": 18, + "priceId": "creditcoin-2", + "icon": "CTC.svg" + } + ], + "nodes": [ + { + "url": "wss://mainnet.creditcoin.network/ws", + "name": "Creditcoin Foundation node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://cc-enterprise.subscan.io/extrinsic/{hash}", + "account": "https://cc-enterprise.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Creditcoin.svg", + "addressPrefix": 42 + }, + { + "chainId": "4436a7d64e363df85e065a894721002a86643283f9707338bf195d360ba2ee71", + "name": "Creditcoin", + "assets": [ + { + "assetId": 0, + "symbol": "CTC", + "precision": 18, + "priceId": "creditcoin-2", + "icon": "CTC.svg" + } + ], + "nodes": [ + { + "url": "wss://mainnet3.creditcoin.network", + "name": "Creditcoin Foundation node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://creditcoin.subscan.io/extrinsic/{hash}", + "account": "https://creditcoin.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Creditcoin.svg", + "addressPrefix": 42 + }, + { + "chainId": "c56fa32442b2dad76f214b3ae07998e4ca09736e4813724bfb0717caae2c8bee", + "name": "Humanode", + "assets": [ + { + "assetId": 0, + "symbol": "HMND", + "precision": 18, + "priceId": "humanode", + "icon": "HMND.svg" + } + ], + "nodes": [ + { + "url": "wss://explorer-rpc-ws.mainnet.stages.humanode.io", + "name": "Humanode node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://humanode.subscan.io/extrinsic/{hash}", + "account": "https://humanode.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Humanode.svg", + "addressPrefix": 5234 + }, + { + "chainId": "03aa6b475a03f8baf7f83e448513b00eaab03aefa4ed64bd1d31160dce028add", + "name": "DeepBrain", + "assets": [ + { + "assetId": 0, + "symbol": "DBC", + "precision": 15, + "priceId": "deepbrain-chain", + "icon": "DBC.svg" + } + ], + "nodes": [ + { + "url": "wss://info.dbcwallet.io", + "name": "DBC node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://dbc.subscan.io/extrinsic/{hash}", + "account": "https://dbc.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/DeepBrain.svg", + "addressPrefix": 42 + }, + { + "chainId": "d2a5d385932d1f650dae03ef8e2748983779ee342c614f80854d32b8cd8fa48c", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "peaq", + "assets": [ + { + "assetId": 0, + "symbol": "PEAQ", + "precision": 18, + "priceId": "peaq-2", + "icon": "PEAQ.svg" + } + ], + "nodes": [ + { + "url": "wss://peaq-rpc.publicnode.com", + "name": "Peaq node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://peaq.subscan.io/extrinsic/{hash}", + "account": "https://peaq.subscan.io/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/peaq.svg", + "addressPrefix": 42 + }, + { + "chainId": "e8aecc950e82f1a375cf650fa72d07e0ad9bef7118f49b92283b63e88b1de88b", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Laos", + "assets": [ + { + "assetId": 0, + "symbol": "LAOS", + "precision": 18, + "priceId": "laos-network", + "icon": "LAOS.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.laos.laosfoundation.io", + "name": "freeverse.io node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://laos.statescan.io/#/accounts/{address}", + "event": "https://laos.statescan.io/#/events/{event}" + }, + { + "name": "LAOS explorer", + "account": "https://explorer.laosnetwork.io/address/{address}", + "extrinsic": "https://explorer.laosnetwork.io/tx/{hash}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/LAOS.svg", + "addressPrefix": 42, + "options": [ + "ethereumBased" + ] + }, + { + "chainId": "28cc1df52619f4edd9f0389a7e910a636276075ecc429600f1dd434e281a04e9", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Xode (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "XON", + "precision": 12, + "icon": "XON.svg" + }, + { + "assetId": 1, + "symbol": "XGM", + "precision": 12, + "icon": "XGM.svg", + "type": "statemine", + "typeExtras": { + "assetId": "1" + } + }, + { + "assetId": 2, + "symbol": "XAV", + "precision": 12, + "icon": "XAV.svg", + "type": "statemine", + "typeExtras": { + "assetId": "2" + } + }, + { + "assetId": 3, + "symbol": "AZK", + "precision": 12, + "icon": "AZK.png", + "type": "statemine", + "typeExtras": { + "assetId": "3" + } + }, + { + "assetId": 4, + "symbol": "IXON", + "precision": 12, + "icon": "IXON.svg", + "type": "statemine", + "typeExtras": { + "assetId": "4" + } + }, + { + "assetId": 5, + "symbol": "IXAV", + "precision": 12, + "icon": "IXAV.svg", + "type": "statemine", + "typeExtras": { + "assetId": "5" + } + }, + { + "assetId": 6, + "symbol": "IDON", + "precision": 12, + "icon": "IDON.png", + "type": "statemine", + "typeExtras": { + "assetId": "6" + } + }, + { + "assetId": 7, + "symbol": "MPC", + "precision": 12, + "icon": "MPC.png", + "type": "statemine", + "typeExtras": { + "assetId": "7" + } + }, + { + "assetId": 8, + "symbol": "IMPC", + "precision": 12, + "icon": "IMPC.png", + "type": "statemine", + "typeExtras": { + "assetId": "8" + } + }, + { + "assetId": 9, + "symbol": "DON", + "precision": 12, + "icon": "DON.png", + "type": "statemine", + "typeExtras": { + "assetId": "9" + } + } + ], + "nodes": [ + { + "url": "wss://rpc-kr.xode.net", + "name": "Xode archive node" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Xode.svg", + "addressPrefix": 42 + }, + { + "chainId": "bb9233e202ec014707f82ddb90e84ee9efece8fefee287ad4ad646d869a6c24a", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "JAMTON", + "assets": [ + { + "assetId": 0, + "symbol": "DOTON", + "precision": 18, + "icon": "DOTON.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.jamton.network", + "name": "Jamton node" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Jamton.svg", + "addressPrefix": 5589, + "types": { + "url": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/chains/v2/types/jamton.json", + "overridesCommon": true + } + }, + { + "chainId": "eip155:41455", + "name": "Aleph Zero EVM (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "AZERO", + "priceId": "aleph-zero", + "type": "evmNative", + "icon": "AZERO.svg", + "precision": 18 + } + ], + "nodeSelectionStrategy": "uniform", + "nodes": [ + { + "url": "https://rpc.alephzero.raas.gelato.cloud", + "name": "Aleph Zero EVM rpc node" + }, + { + "url": "wss://ws.alephzero.raas.gelato.cloud", + "name": "Aleph Zero EVM wss node" + } + ], + "externalApi": { + "history": [ + { + "type": "etherscan", + "url": "https://evm-explorer.alephzero.org/api", + "parameters": { + "assetType": "evm" + } + } + ] + }, + "explorers": [ + { + "name": "Aleph Zero EVM Explorer", + "extrinsic": "https://evm-explorer.alephzero.org/tx/{hash}", + "account": "https://evm-explorer.alephzero.org/address/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/AlephZero.svg", + "addressPrefix": 41455, + "options": [ + "ethereumBased", + "noSubstrateRuntime" + ] + }, + { + "chainId": "44f68476df71ebf765b630bf08dc1e0fedb2bf614a1aa0563b3f74f20e47b3e0", + "name": "Tangle", + "assets": [ + { + "assetId": 0, + "symbol": "TNT", + "precision": 18, + "priceId": "tangle-network", + "icon": "TNT.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.tangle.tools", + "name": "Parity node" + } + ], + "explorers": [ + { + "name": "Statescan", + "account": "https://tangle.statescan.io/#/accounts/{address}", + "event": "https://tangle.statescan.io/#/events/{event}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Tangle.svg", + "addressPrefix": 5845, + "additional": { + "disabledCheckMetadataHash": true + } + }, + { + "chainId": "efb56e30d9b4a24099f88820987d0f45fb645992416535d87650d98e00f46fc4", + "parentId": "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", + "name": "Polkadot Coretime", + "assets": [ + { + "assetId": 0, + "symbol": "DOT", + "precision": 10, + "priceId": "polkadot", + "icon": "DOT.svg" + } + ], + "nodes": [ + { + "url": "wss://sys.ibp.network/coretime-polkadot", + "name": "IBP1 node" + }, + { + "url": "wss://coretime-polkadot.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://rpc-coretime-polkadot.luckyfriday.io", + "name": "LuckyFriday node" + }, + { + "url": "wss://polkadot-coretime-rpc.polkadot.io", + "name": "Parity node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://coretime-polkadot.subscan.io/extrinsic/{hash}", + "account": "https://coretime-polkadot.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://coretime-polkadot.statescan.io/#/accounts/{address}", + "extrinsic": "https://coretime-polkadot.statescan.io/#/extrinsics/{hash}", + "event": "https://coretime-polkadot.statescan.io/#/events/{event}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Polkadot_Coretime.svg", + "addressPrefix": 0, + "additional": { + "supportsGenericLedgerApp": true, + "identityChain": "67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008" + }, + "options": [ + "multisig" + ] + }, + { + "chainId": "638cd2b9af4b3bb54b8c1f0d22711fc89924ca93300f0caf25a580432b29d050", + "parentId": "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe", + "name": "Kusama Coretime", + "assets": [ + { + "assetId": 0, + "symbol": "KSM", + "precision": 12, + "priceId": "kusama", + "icon": "KSM.svg" + } + ], + "nodes": [ + { + "url": "wss://sys.ibp.network/coretime-kusama", + "name": "IBP1 node" + }, + { + "url": "wss://coretime-kusama.dotters.network", + "name": "IBP2 node" + }, + { + "url": "wss://rpc-coretime-kusama.luckyfriday.io", + "name": "LuckyFriday node" + }, + { + "url": "wss://kusama-coretime-rpc.polkadot.io", + "name": "Parity node" + }, + { + "url": "wss://ksm-rpc.stakeworld.io/coretime", + "name": "Stakeworld node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://coretime-kusama.subscan.io/extrinsic/{hash}", + "account": "https://coretime-kusama.subscan.io/account/{address}" + }, + { + "name": "Statescan", + "account": "https://coretime-kusama.statescan.io/#/accounts/{address}", + "extrinsic": "https://coretime-kusama.statescan.io/#/extrinsics/{hash}", + "event": "https://coretime-kusama.statescan.io/#/events/{event}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Kusama_Coretime.svg", + "addressPrefix": 2, + "additional": { + "supportsGenericLedgerApp": true, + "identityChain": "c1af4cb4eb3918e5db15086c0cc5ec17fb334f728b7c65dd44bfe1e174ff8b3f" + }, + "options": [ + "multisig" + ] + }, + { + "chainId": "dffb39a66d80b9adb6bdbd7564a9215a1606596062578bf536480de6cc780c2d", + "name": "Argochain (PAUSED)", + "assets": [ + { + "assetId": 0, + "symbol": "AGC", + "precision": 18, + "priceId": "argocoin-2", + "icon": "AGC.svg" + } + ], + "nodes": [ + { + "url": "wss://rpc.devolvedai.com", + "name": "ArgoChain node" + } + ], + "explorers": [ + { + "name": "Argochain Scanner", + "account": "https://scanner.argoscan.net/address/{address}", + "extrinsic": "https://scanner.argoscan.net/tx/{hash}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Argochain.svg", + "addressPrefix": 42 + }, + { + "chainId": "dd6d086f75ec041b66e20c4186d327b23c8af244c534a2418de6574e8c041a60", + "name": "Tanssi", + "assets": [ + { + "assetId": 0, + "symbol": "TANSSI", + "precision": 12, + "priceId": "tanssi", + "icon": "TANSSI.svg" + } + ], + "nodes": [ + { + "url": "wss://services.tanssi-mainnet.network/tanssi", + "name": "Tanssi Foundation node" + } + ], + "explorers": [ + { + "name": "Subscan", + "extrinsic": "https://tanssi.subscan.io/extrinsic/{hash}", + "account": "https://tanssi.subscan.io/account/account/{address}" + } + ], + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Tanssi.svg", + "addressPrefix": 42, + "additional": { + "supportsGenericLedgerApp": true + } + }, + { + "chainId": "eip155:420420422", + "name": "Polkadot Hub TestNet", + "assets": [ + { + "assetId": 0, + "symbol": "PAS", + "type": "evmNative", + "icon": "PAS.svg", + "precision": 18 + } + ], + "nodeSelectionStrategy": "uniform", + "nodes": [ + { + "url": "https://testnet-passet-hub-eth-rpc.polkadot.io", + "name": "Polkadot Hub rpc node" + }, + { + "url": "wss://passet-hub-paseo.ibp.network", + "name": "Polkadot Hub wss node" + } + ], + "explorers": [ + { + "name": "Blockscan", + "extrinsic": "https://blockscout-passet-hub.parity-testnet.parity.io/tx/{hash}", + "account": "https://blockscout-passet-hub.parity-testnet.parity.io/address/{address}" + } + ], + "externalApi": { + "history": [ + { + "type": "etherscan", + "url": "https://blockscout-passet-hub.parity-testnet.parity.io/api/v2/", + "parameters": { + "assetType": "evm" + } + } + ] + }, + "icon": "https://raw.githubusercontent.com/novasamatech/nova-utils/master/icons/chains/gradient/Paseo_Testnet.svg", + "addressPrefix": 420420422, + "options": [ + "ethereumBased", + "noSubstrateRuntime", + "testnet" + ] + } +] diff --git a/runtime/.gitignore b/runtime/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/runtime/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/runtime/build.gradle b/runtime/build.gradle new file mode 100644 index 0000000..207a852 --- /dev/null +++ b/runtime/build.gradle @@ -0,0 +1,69 @@ +apply from: '../scripts/secrets.gradle' +apply plugin: 'kotlin-parcelize' + +android { + + defaultConfig { + + + + buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" + buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" + + buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\"" + + buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY") + buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY") + } + + buildTypes { + debug { + + } + + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + + buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" + buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" + } + } + namespace 'io.novafoundation.nova.runtime' +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation project(":common") + implementation project(":core-db") + + implementation project(":core-api") + + implementation project(":bindings:metadata_shortener") + implementation project(":bindings:sr25519-bizinikiwi") + + implementation gsonDep + implementation substrateSdkDep + + implementation kotlinDep + + implementation coroutinesDep + + implementation retrofitDep + + implementation daggerDep + ksp daggerCompiler + + testImplementation project(':test-shared') + + implementation lifeCycleKtxDep + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} \ No newline at end of file diff --git a/runtime/src/main/AndroidManifest.xml b/runtime/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/runtime/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/runtime/src/main/assets/metadata/kusama b/runtime/src/main/assets/metadata/kusama new file mode 100644 index 0000000..cfe2ff8 --- /dev/null +++ b/runtime/src/main/assets/metadata/kusama @@ -0,0 +1 @@ +0x6d6574610c841853797374656d011853797374656d401c4163636f756e7401010230543a3a4163636f756e744964944163636f756e74496e666f3c543a3a496e6465782c20543a3a4163636f756e74446174613e004101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004e8205468652066756c6c206163636f756e7420696e666f726d6174696f6e20666f72206120706172746963756c6172206163636f756e742049442e3845787472696e736963436f756e7400000c753332040004b820546f74616c2065787472696e7369637320636f756e7420666f72207468652063757272656e7420626c6f636b2e2c426c6f636b576569676874010038436f6e73756d6564576569676874600000000000000000000000000000000000000000000000000488205468652063757272656e742077656967687420666f722074686520626c6f636b2e40416c6c45787472696e736963734c656e00000c753332040004410120546f74616c206c656e6774682028696e2062797465732920666f7220616c6c2065787472696e736963732070757420746f6765746865722c20666f72207468652063757272656e7420626c6f636b2e24426c6f636b4861736801010538543a3a426c6f636b4e756d6265721c543a3a48617368008000000000000000000000000000000000000000000000000000000000000000000498204d6170206f6620626c6f636b206e756d6265727320746f20626c6f636b206861736865732e3445787472696e736963446174610101050c7533321c5665633c75383e000400043d012045787472696e73696373206461746120666f72207468652063757272656e7420626c6f636b20286d61707320616e2065787472696e736963277320696e64657820746f206974732064617461292e184e756d626572010038543a3a426c6f636b4e756d6265721000000000040901205468652063757272656e7420626c6f636b206e756d626572206265696e672070726f6365737365642e205365742062792060657865637574655f626c6f636b602e28506172656e744861736801001c543a3a4861736880000000000000000000000000000000000000000000000000000000000000000004702048617368206f66207468652070726576696f757320626c6f636b2e1844696765737401002c4469676573744f663c543e040004f020446967657374206f66207468652063757272656e7420626c6f636b2c20616c736f2070617274206f662074686520626c6f636b206865616465722e184576656e747301008c5665633c4576656e745265636f72643c543a3a4576656e742c20543a3a486173683e3e040004a0204576656e7473206465706f736974656420666f72207468652063757272656e7420626c6f636b2e284576656e74436f756e740100284576656e74496e646578100000000004b820546865206e756d626572206f66206576656e747320696e2074686520604576656e74733c543e60206c6973742e2c4576656e74546f706963730101021c543a3a48617368845665633c28543a3a426c6f636b4e756d6265722c204576656e74496e646578293e000400282501204d617070696e67206265747765656e206120746f7069632028726570726573656e74656420627920543a3a486173682920616e64206120766563746f72206f6620696e646578657394206f66206576656e747320696e2074686520603c4576656e74733c543e3e60206c6973742e00510120416c6c20746f70696320766563746f727320686176652064657465726d696e69737469632073746f72616765206c6f636174696f6e7320646570656e64696e67206f6e2074686520746f7069632e2054686973450120616c6c6f7773206c696768742d636c69656e747320746f206c6576657261676520746865206368616e67657320747269652073746f7261676520747261636b696e67206d656368616e69736d20616e64e420696e2063617365206f66206368616e67657320666574636820746865206c697374206f66206576656e7473206f6620696e7465726573742e004d01205468652076616c756520686173207468652074797065206028543a3a426c6f636b4e756d6265722c204576656e74496e646578296020626563617573652069662077652075736564206f6e6c79206a7573744d012074686520604576656e74496e64657860207468656e20696e20636173652069662074686520746f70696320686173207468652073616d6520636f6e74656e7473206f6e20746865206e65787420626c6f636b0101206e6f206e6f74696669636174696f6e2077696c6c20626520747269676765726564207468757320746865206576656e74206d69676874206265206c6f73742e484c61737452756e74696d65557067726164650000584c61737452756e74696d6555706772616465496e666f04000455012053746f726573207468652060737065635f76657273696f6e6020616e642060737065635f6e616d6560206f66207768656e20746865206c6173742072756e74696d6520757067726164652068617070656e65642e545570677261646564546f553332526566436f756e74010010626f6f6c0400044d012054727565206966207765206861766520757067726164656420736f207468617420607479706520526566436f756e74602069732060753332602e2046616c7365202864656661756c7429206966206e6f742e605570677261646564546f547269706c65526566436f756e74010010626f6f6c0400085d012054727565206966207765206861766520757067726164656420736f2074686174204163636f756e74496e666f20636f6e7461696e73207468726565207479706573206f662060526566436f756e74602e2046616c736548202864656661756c7429206966206e6f742e38457865637574696f6e50686173650000145068617365040004882054686520657865637574696f6e207068617365206f662074686520626c6f636b2e01282866696c6c5f626c6f636b04185f726174696f1c50657262696c6c040901204120646973706174636820746861742077696c6c2066696c6c2074686520626c6f636b2077656967687420757020746f2074686520676976656e20726174696f2e1872656d61726b041c5f72656d61726b1c5665633c75383e146c204d616b6520736f6d65206f6e2d636861696e2072656d61726b2e002c2023203c7765696768743e24202d20604f28312960302023203c2f7765696768743e387365745f686561705f7061676573041470616765730c75363420fc2053657420746865206e756d626572206f6620706167657320696e2074686520576562417373656d626c7920656e7669726f6e6d656e74277320686561702e002c2023203c7765696768743e24202d20604f283129604c202d20312073746f726167652077726974652e64202d2042617365205765696768743a20312e34303520c2b57360202d203120777269746520746f20484541505f5041474553302023203c2f7765696768743e207365745f636f64650410636f64651c5665633c75383e28682053657420746865206e65772072756e74696d6520636f64652e002c2023203c7765696768743e3501202d20604f2843202b2053296020776865726520604360206c656e677468206f662060636f64656020616e642060536020636f6d706c6578697479206f66206063616e5f7365745f636f64656088202d20312073746f726167652077726974652028636f64656320604f28432960292e7901202d20312063616c6c20746f206063616e5f7365745f636f6465603a20604f28532960202863616c6c73206073705f696f3a3a6d6973633a3a72756e74696d655f76657273696f6e6020776869636820697320657870656e73697665292e2c202d2031206576656e742e7d012054686520776569676874206f6620746869732066756e6374696f6e20697320646570656e64656e74206f6e207468652072756e74696d652c206275742067656e6572616c6c792074686973206973207665727920657870656e736976652e902057652077696c6c207472656174207468697320617320612066756c6c20626c6f636b2e302023203c2f7765696768743e5c7365745f636f64655f776974686f75745f636865636b730410636f64651c5665633c75383e201d012053657420746865206e65772072756e74696d6520636f646520776974686f757420646f696e6720616e7920636865636b73206f662074686520676976656e2060636f6465602e002c2023203c7765696768743e90202d20604f2843296020776865726520604360206c656e677468206f662060636f64656088202d20312073746f726167652077726974652028636f64656320604f28432960292e2c202d2031206576656e742e75012054686520776569676874206f6620746869732066756e6374696f6e20697320646570656e64656e74206f6e207468652072756e74696d652e2057652077696c6c207472656174207468697320617320612066756c6c20626c6f636b2e302023203c2f7765696768743e5c7365745f6368616e6765735f747269655f636f6e666967044c6368616e6765735f747269655f636f6e666967804f7074696f6e3c4368616e67657354726965436f6e66696775726174696f6e3e28a02053657420746865206e6577206368616e676573207472696520636f6e66696775726174696f6e2e002c2023203c7765696768743e24202d20604f28312960b0202d20312073746f72616765207772697465206f722064656c6574652028636f64656320604f28312960292ed8202d20312063616c6c20746f20606465706f7369745f6c6f67603a20557365732060617070656e6460204150492c20736f204f28312964202d2042617365205765696768743a20372e32313820c2b57334202d204442205765696768743aa820202020202d205772697465733a204368616e67657320547269652c2053797374656d20446967657374302023203c2f7765696768743e2c7365745f73746f7261676504146974656d73345665633c4b657956616c75653e206c2053657420736f6d65206974656d73206f662073746f726167652e002c2023203c7765696768743e94202d20604f2849296020776865726520604960206c656e677468206f6620606974656d73607c202d206049602073746f72616765207772697465732028604f28312960292e74202d2042617365205765696768743a20302e353638202a206920c2b57368202d205772697465733a204e756d626572206f66206974656d73302023203c2f7765696768743e306b696c6c5f73746f7261676504106b657973205665633c4b65793e2078204b696c6c20736f6d65206974656d732066726f6d2073746f726167652e002c2023203c7765696768743efc202d20604f28494b296020776865726520604960206c656e677468206f6620606b6579736020616e6420604b60206c656e677468206f66206f6e65206b657964202d206049602073746f726167652064656c6574696f6e732e70202d2042617365205765696768743a202e333738202a206920c2b57368202d205772697465733a204e756d626572206f66206974656d73302023203c2f7765696768743e2c6b696c6c5f70726566697808187072656669780c4b6579205f7375626b6579730c7533322c1501204b696c6c20616c6c2073746f72616765206974656d7320776974682061206b657920746861742073746172747320776974682074686520676976656e207072656669782e003d01202a2a4e4f54453a2a2a2057652072656c79206f6e2074686520526f6f74206f726967696e20746f2070726f7669646520757320746865206e756d626572206f66207375626b65797320756e64657241012074686520707265666978207765206172652072656d6f76696e6720746f2061636375726174656c792063616c63756c6174652074686520776569676874206f6620746869732066756e6374696f6e2e002c2023203c7765696768743edc202d20604f285029602077686572652060506020616d6f756e74206f66206b65797320776974682070726566697820607072656669786064202d206050602073746f726167652064656c6574696f6e732e74202d2042617365205765696768743a20302e383334202a205020c2b57380202d205772697465733a204e756d626572206f66207375626b657973202b2031302023203c2f7765696768743e4472656d61726b5f776974685f6576656e74041872656d61726b1c5665633c75383e18a8204d616b6520736f6d65206f6e2d636861696e2072656d61726b20616e6420656d6974206576656e742e002c2023203c7765696768743eb8202d20604f28622960207768657265206220697320746865206c656e677468206f66207468652072656d61726b2e2c202d2031206576656e742e302023203c2f7765696768743e01184045787472696e7369635375636365737304304469737061746368496e666f04b820416e2065787472696e73696320636f6d706c65746564207375636365737366756c6c792e205c5b696e666f5c5d3c45787472696e7369634661696c6564083444697370617463684572726f72304469737061746368496e666f049420416e2065787472696e736963206661696c65642e205c5b6572726f722c20696e666f5c5d2c436f64655570646174656400045420603a636f6465602077617320757064617465642e284e65774163636f756e7404244163636f756e744964047c2041206e6577205c5b6163636f756e745c5d2077617320637265617465642e344b696c6c65644163636f756e7404244163636f756e744964046c20416e205c5b6163636f756e745c5d20776173207265617065642e2052656d61726b656408244163636f756e744964104861736804d4204f6e206f6e2d636861696e2072656d61726b2068617070656e65642e205c5b6f726967696e2c2072656d61726b5f686173685c5d1830426c6f636b57656967687473506c696d6974733a3a426c6f636b57656967687473850100f2052a0100000000204aa9d1010000405973070000000001c0766c8f58010000010098f73e5d010000010000000000000000405973070000000001c0febef9cc0100000100204aa9d1010000010088526a74000000405973070000000000000004d020426c6f636b20262065787472696e7369637320776569676874733a20626173652076616c75657320616e64206c696d6974732e2c426c6f636b4c656e6774684c6c696d6974733a3a426c6f636b4c656e6774683000003c00000050000000500004a820546865206d6178696d756d206c656e677468206f66206120626c6f636b2028696e206279746573292e38426c6f636b48617368436f756e7438543a3a426c6f636b4e756d6265721060090000045501204d6178696d756d206e756d626572206f6620626c6f636b206e756d62657220746f20626c6f636b2068617368206d617070696e677320746f206b65657020286f6c64657374207072756e6564206669727374292e2044625765696768743c52756e74696d6544625765696768744040787d010000000000e1f505000000000409012054686520776569676874206f662072756e74696d65206461746162617365206f7065726174696f6e73207468652072756e74696d652063616e20696e766f6b652e1c56657273696f6e3852756e74696d6556657273696f6ed902186b7573616d61347061726974792d6b7573616d6102000000ee0700000000000030df6acb689907609b0300000037e397fc7c91f5e40100000040fe3ad401f8959a04000000d2bc9897eed08f1502000000f78b278be53f454c02000000af2c0297a23e6d3d01000000ed99c5acb25eedf502000000cbca25e39f14238702000000687ad44ad37f03c201000000ab3c0572291feb8b01000000bc9d89904f5b923f0100000037c8bb1350a9a2a801000000050000000484204765742074686520636861696e27732063757272656e742076657273696f6e2e2853533538507265666978087538040214a8205468652064657369676e61746564205353383520707265666978206f66207468697320636861696e2e0039012054686973207265706c6163657320746865202273733538466f726d6174222070726f7065727479206465636c6172656420696e2074686520636861696e20737065632e20526561736f6e20697331012074686174207468652072756e74696d652073686f756c64206b6e6f772061626f7574207468652070726566697820696e206f7264657220746f206d616b6520757365206f662069742061737020616e206964656e746966696572206f662074686520636861696e2e143c496e76616c6964537065634e616d6508150120546865206e616d65206f662073706563696669636174696f6e20646f6573206e6f74206d61746368206265747765656e207468652063757272656e742072756e74696d655420616e6420746865206e65772072756e74696d652e685370656356657273696f6e4e65656473546f496e637265617365084501205468652073706563696669636174696f6e2076657273696f6e206973206e6f7420616c6c6f77656420746f206465637265617365206265747765656e207468652063757272656e742072756e74696d655420616e6420746865206e65772072756e74696d652e744661696c6564546f4578747261637452756e74696d6556657273696f6e0cf0204661696c656420746f2065787472616374207468652072756e74696d652076657273696f6e2066726f6d20746865206e65772072756e74696d652e000d01204569746865722063616c6c696e672060436f72655f76657273696f6e60206f72206465636f64696e67206052756e74696d6556657273696f6e60206661696c65642e4c4e6f6e44656661756c74436f6d706f7369746504010120537569636964652063616c6c6564207768656e20746865206163636f756e7420686173206e6f6e2d64656661756c7420636f6d706f7369746520646174612e3c4e6f6e5a65726f526566436f756e740439012054686572652069732061206e6f6e2d7a65726f207265666572656e636520636f756e742070726576656e74696e6720746865206163636f756e742066726f6d206265696e67207075726765642e006052616e646f6d6e657373436f6c6c656374697665466c6970016052616e646f6d6e657373436f6c6c656374697665466c6970043852616e646f6d4d6174657269616c0100305665633c543a3a486173683e04000c610120536572696573206f6620626c6f636b20686561646572732066726f6d20746865206c61737420383120626c6f636b73207468617420616374732061732072616e646f6d2073656564206d6174657269616c2e2054686973610120697320617272616e67656420617320612072696e672062756666657220776974682060626c6f636b5f6e756d626572202520383160206265696e672074686520696e64657820696e746f20746865206056656360206f664420746865206f6c6465737420686173682e00000000201042616265011042616265402845706f6368496e64657801000c75363420000000000000000004542043757272656e742065706f636820696e6465782e2c417574686f72697469657301009c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e0400046c2043757272656e742065706f636820617574686f7269746965732e2c47656e65736973536c6f74010010536c6f7420000000000000000008f82054686520736c6f74206174207768696368207468652066697273742065706f63682061637475616c6c7920737461727465642e205468697320697320309020756e74696c2074686520666972737420626c6f636b206f662074686520636861696e2e2c43757272656e74536c6f74010010536c6f7420000000000000000004542043757272656e7420736c6f74206e756d6265722e2852616e646f6d6e6573730100587363686e6f72726b656c3a3a52616e646f6d6e65737380000000000000000000000000000000000000000000000000000000000000000028b8205468652065706f63682072616e646f6d6e65737320666f7220746865202a63757272656e742a2065706f63682e002c20232053656375726974790005012054686973204d555354204e4f54206265207573656420666f722067616d626c696e672c2061732069742063616e20626520696e666c75656e6365642062792061f8206d616c6963696f75732076616c696461746f7220696e207468652073686f7274207465726d2e204974204d4159206265207573656420696e206d616e7915012063727970746f677261706869632070726f746f636f6c732c20686f77657665722c20736f206c6f6e67206173206f6e652072656d656d6265727320746861742074686973150120286c696b652065766572797468696e6720656c7365206f6e2d636861696e29206974206973207075626c69632e20466f72206578616d706c652c2069742063616e206265050120757365642077686572652061206e756d626572206973206e656564656420746861742063616e6e6f742068617665206265656e2063686f73656e20627920616e0d01206164766572736172792c20666f7220707572706f7365732073756368206173207075626c69632d636f696e207a65726f2d6b6e6f776c656467652070726f6f66732e6050656e64696e6745706f6368436f6e6669674368616e67650000504e657874436f6e66696744657363726970746f7204000461012050656e64696e672065706f636820636f6e66696775726174696f6e206368616e676520746861742077696c6c206265206170706c696564207768656e20746865206e6578742065706f636820697320656e61637465642e384e65787452616e646f6d6e6573730100587363686e6f72726b656c3a3a52616e646f6d6e657373800000000000000000000000000000000000000000000000000000000000000000045c204e6578742065706f63682072616e646f6d6e6573732e3c4e657874417574686f72697469657301009c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e04000460204e6578742065706f636820617574686f7269746965732e305365676d656e74496e64657801000c7533321000000000247c2052616e646f6d6e65737320756e64657220636f6e737472756374696f6e2e00f4205765206d616b6520612074726164656f6666206265747765656e2073746f7261676520616363657373657320616e64206c697374206c656e6774682e01012057652073746f72652074686520756e6465722d636f6e737472756374696f6e2072616e646f6d6e65737320696e207365676d656e7473206f6620757020746f942060554e4445525f434f4e535452554354494f4e5f5345474d454e545f4c454e475448602e00ec204f6e63652061207365676d656e7420726561636865732074686973206c656e6774682c20776520626567696e20746865206e657874206f6e652e090120576520726573657420616c6c207365676d656e747320616e642072657475726e20746f206030602061742074686520626567696e6e696e67206f662065766572791c2065706f63682e44556e646572436f6e737472756374696f6e0101050c7533326c5665633c7363686e6f72726b656c3a3a52616e646f6d6e6573733e0004000415012054574f582d4e4f54453a20605365676d656e74496e6465786020697320616e20696e6372656173696e6720696e74656765722c20736f2074686973206973206f6b61792e2c496e697469616c697a656400003c4d6179626552616e646f6d6e65737304000801012054656d706f726172792076616c75652028636c656172656420617420626c6f636b2066696e616c697a6174696f6e292077686963682069732060536f6d65601d01206966207065722d626c6f636b20696e697469616c697a6174696f6e2068617320616c7265616479206265656e2063616c6c656420666f722063757272656e7420626c6f636b2e4c417574686f7256726652616e646f6d6e65737301003c4d6179626552616e646f6d6e65737304000c5d012054656d706f726172792076616c75652028636c656172656420617420626c6f636b2066696e616c697a6174696f6e29207468617420696e636c756465732074686520565246206f75747075742067656e6572617465645101206174207468697320626c6f636b2e2054686973206669656c642073686f756c6420616c7761797320626520706f70756c6174656420647572696e6720626c6f636b2070726f63657373696e6720756e6c6573731901207365636f6e6461727920706c61696e20736c6f74732061726520656e61626c65642028776869636820646f6e277420636f6e7461696e206120565246206f7574707574292e2845706f6368537461727401008028543a3a426c6f636b4e756d6265722c20543a3a426c6f636b4e756d62657229200000000000000000145d012054686520626c6f636b206e756d62657273207768656e20746865206c61737420616e642063757272656e742065706f6368206861766520737461727465642c20726573706563746976656c7920604e2d316020616e641420604e602e4901204e4f54453a20576520747261636b207468697320697320696e206f7264657220746f20616e6e6f746174652074686520626c6f636b206e756d626572207768656e206120676976656e20706f6f6c206f66590120656e74726f7079207761732066697865642028692e652e20697420776173206b6e6f776e20746f20636861696e206f6273657276657273292e2053696e63652065706f6368732061726520646566696e656420696e590120736c6f74732c207768696368206d617920626520736b69707065642c2074686520626c6f636b206e756d62657273206d6179206e6f74206c696e6520757020776974682074686520736c6f74206e756d626572732e204c6174656e657373010038543a3a426c6f636b4e756d626572100000000014d820486f77206c617465207468652063757272656e7420626c6f636b20697320636f6d706172656420746f2069747320706172656e742e001501205468697320656e74727920697320706f70756c617465642061732070617274206f6620626c6f636b20657865637574696f6e20616e6420697320636c65616e65642075701101206f6e20626c6f636b2066696e616c697a6174696f6e2e205175657279696e6720746869732073746f7261676520656e747279206f757473696465206f6620626c6f636bb020657865637574696f6e20636f6e746578742073686f756c6420616c77617973207969656c64207a65726f2e2c45706f6368436f6e6669670000584261626545706f6368436f6e66696775726174696f6e04000485012054686520636f6e66696775726174696f6e20666f72207468652063757272656e742065706f63682e2053686f756c64206e6576657220626520604e6f6e656020617320697420697320696e697469616c697a656420696e2067656e657369732e3c4e65787445706f6368436f6e6669670000584261626545706f6368436f6e66696775726174696f6e0400082d012054686520636f6e66696775726174696f6e20666f7220746865206e6578742065706f63682c20604e6f6e65602069662074686520636f6e6669672077696c6c206e6f74206368616e6765e82028796f752063616e2066616c6c6261636b20746f206045706f6368436f6e6669676020696e737465616420696e20746861742063617365292e010c4c7265706f72745f65717569766f636174696f6e084865717569766f636174696f6e5f70726f6f667045717569766f636174696f6e50726f6f663c543a3a4865616465723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66100d01205265706f727420617574686f726974792065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c207665726966790901207468652065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66110120616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e63652077696c6c34206265207265706f727465642e707265706f72745f65717569766f636174696f6e5f756e7369676e6564084865717569766f636174696f6e5f70726f6f667045717569766f636174696f6e50726f6f663c543a3a4865616465723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66200d01205265706f727420617574686f726974792065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c207665726966790901207468652065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66110120616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e63652077696c6c34206265207265706f727465642e110120546869732065787472696e736963206d7573742062652063616c6c656420756e7369676e656420616e642069742069732065787065637465642074686174206f6e6c79190120626c6f636b20617574686f72732077696c6c2063616c6c206974202876616c69646174656420696e206056616c6964617465556e7369676e656460292c206173207375636819012069662074686520626c6f636b20617574686f7220697320646566696e65642069742077696c6c20626520646566696e6564206173207468652065717569766f636174696f6e28207265706f727465722e48706c616e5f636f6e6669675f6368616e67650418636f6e666967504e657874436f6e66696744657363726970746f7210610120506c616e20616e2065706f636820636f6e666967206368616e67652e205468652065706f636820636f6e666967206368616e6765206973207265636f7264656420616e642077696c6c20626520656e6163746564206f6e550120746865206e6578742063616c6c20746f2060656e6163745f65706f63685f6368616e6765602e2054686520636f6e6669672077696c6c20626520616374697661746564206f6e652065706f63682061667465722e5d01204d756c7469706c652063616c6c7320746f2074686973206d6574686f642077696c6c207265706c61636520616e79206578697374696e6720706c616e6e656420636f6e666967206368616e676520746861742068616458206e6f74206265656e20656e6163746564207965742e00083445706f63684475726174696f6e0c7536342058020000000000000cec2054686520616d6f756e74206f662074696d652c20696e20736c6f74732c207468617420656163682065706f63682073686f756c64206c6173742e1901204e4f54453a2043757272656e746c79206974206973206e6f7420706f737369626c6520746f206368616e6765207468652065706f6368206475726174696f6e20616674657221012074686520636861696e2068617320737461727465642e20417474656d7074696e6720746f20646f20736f2077696c6c20627269636b20626c6f636b2070726f64756374696f6e2e444578706563746564426c6f636b54696d6524543a3a4d6f6d656e7420701700000000000014050120546865206578706563746564206176657261676520626c6f636b2074696d6520617420776869636820424142452073686f756c64206265206372656174696e67110120626c6f636b732e2053696e636520424142452069732070726f626162696c6973746963206974206973206e6f74207472697669616c20746f20666967757265206f75740501207768617420746865206578706563746564206176657261676520626c6f636b2074696d652073686f756c64206265206261736564206f6e2074686520736c6f740901206475726174696f6e20616e642074686520736563757269747920706172616d657465722060636020287768657265206031202d20636020726570726573656e7473a0207468652070726f626162696c697479206f66206120736c6f74206265696e6720656d707479292e0c60496e76616c696445717569766f636174696f6e50726f6f6604350120416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c69644b65794f776e65727368697050726f6f660435012041206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f7274041901204120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e012454696d657374616d70012454696d657374616d70080c4e6f77010024543a3a4d6f6d656e7420000000000000000004902043757272656e742074696d6520666f72207468652063757272656e7420626c6f636b2e24446964557064617465010010626f6f6c040004b420446964207468652074696d657374616d7020676574207570646174656420696e207468697320626c6f636b3f01040c736574040c6e6f7748436f6d706163743c543a3a4d6f6d656e743e3c5820536574207468652063757272656e742074696d652e00590120546869732063616c6c2073686f756c6420626520696e766f6b65642065786163746c79206f6e63652070657220626c6f636b2e2049742077696c6c2070616e6963206174207468652066696e616c697a6174696f6ed82070686173652c20696620746869732063616c6c206861736e2774206265656e20696e766f6b656420627920746861742074696d652e004501205468652074696d657374616d702073686f756c642062652067726561746572207468616e207468652070726576696f7573206f6e652062792074686520616d6f756e74207370656369666965642062794420604d696e696d756d506572696f64602e00d820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652060496e686572656e74602e002c2023203c7765696768743e3501202d20604f2831296020284e6f7465207468617420696d706c656d656e746174696f6e73206f6620604f6e54696d657374616d7053657460206d75737420616c736f20626520604f2831296029a101202d20312073746f72616765207265616420616e6420312073746f72616765206d75746174696f6e2028636f64656320604f28312960292e202862656361757365206f6620604469645570646174653a3a74616b656020696e20606f6e5f66696e616c697a656029d8202d2031206576656e742068616e646c657220606f6e5f74696d657374616d705f736574602e204d75737420626520604f283129602e302023203c2f7765696768743e0004344d696e696d756d506572696f6424543a3a4d6f6d656e7420b80b00000000000010690120546865206d696e696d756d20706572696f64206265747765656e20626c6f636b732e204265776172652074686174207468697320697320646966666572656e7420746f20746865202a65787065637465642a20706572696f64690120746861742074686520626c6f636b2070726f64756374696f6e206170706172617475732070726f76696465732e20596f75722063686f73656e20636f6e73656e7375732073797374656d2077696c6c2067656e6572616c6c79650120776f726b2077697468207468697320746f2064657465726d696e6520612073656e7369626c6520626c6f636b2074696d652e20652e672e20466f7220417572612c2069742077696c6c20626520646f75626c6520746869737020706572696f64206f6e2064656661756c742073657474696e67732e00021c496e6469636573011c496e646963657304204163636f756e74730001023c543a3a4163636f756e74496e6465788828543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20626f6f6c29000400048820546865206c6f6f6b75702066726f6d20696e64657820746f206163636f756e742e011414636c61696d0414696e6465783c543a3a4163636f756e74496e646578489c2041737369676e20616e2070726576696f75736c7920756e61737369676e656420696e6465782e00e0205061796d656e743a20604465706f736974602069732072657365727665642066726f6d207468652073656e646572206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e00f4202d2060696e646578603a2074686520696e64657820746f20626520636c61696d65642e2054686973206d757374206e6f7420626520696e207573652e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e64202d204f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e207472616e73666572080c6e657730543a3a4163636f756e74496414696e6465783c543a3a4163636f756e74496e6465785061012041737369676e20616e20696e64657820616c7265616479206f776e6564206279207468652073656e64657220746f20616e6f74686572206163636f756e742e205468652062616c616e6365207265736572766174696f6ebc206973206566666563746976656c79207472616e7366657272656420746f20746865206e6577206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002901202d2060696e646578603a2074686520696e64657820746f2062652072652d61737369676e65642e2054686973206d757374206265206f776e6564206279207468652073656e6465722e6101202d20606e6577603a20746865206e6577206f776e6572206f662074686520696e6465782e20546869732066756e6374696f6e2069732061206e6f2d6f7020696620697420697320657175616c20746f2073656e6465722e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e68202d204f6e65207472616e73666572206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743ae4202020202d2052656164733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e742028726563697069656e7429e8202020202d205772697465733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e742028726563697069656e7429302023203c2f7765696768743e10667265650414696e6465783c543a3a4163636f756e74496e6465784898204672656520757020616e20696e646578206f776e6564206279207468652073656e6465722e006101205061796d656e743a20416e792070726576696f7573206465706f73697420706c6163656420666f722074686520696e64657820697320756e726573657276656420696e207468652073656e646572206163636f756e742e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d757374206f776e2074686520696e6465782e001101202d2060696e646578603a2074686520696e64657820746f2062652066726565642e2054686973206d757374206265206f776e6564206279207468652073656e6465722e008820456d6974732060496e646578467265656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e64202d204f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e38666f7263655f7472616e736665720c0c6e657730543a3a4163636f756e74496414696e6465783c543a3a4163636f756e74496e64657818667265657a6510626f6f6c54590120466f72636520616e20696e64657820746f20616e206163636f756e742e205468697320646f65736e277420726571756972652061206465706f7369742e2049662074686520696e64657820697320616c7265616479ec2068656c642c207468656e20616e79206465706f736974206973207265696d62757273656420746f206974732063757272656e74206f776e65722e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f526f6f745f2e00a8202d2060696e646578603a2074686520696e64657820746f206265202872652d2961737369676e65642e6101202d20606e6577603a20746865206e6577206f776e6572206f662074686520696e6465782e20546869732066756e6374696f6e2069732061206e6f2d6f7020696620697420697320657175616c20746f2073656e6465722e4501202d2060667265657a65603a2069662073657420746f206074727565602c2077696c6c20667265657a652074686520696e64657820736f2069742063616e6e6f74206265207472616e736665727265642e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e7c202d20557020746f206f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743af8202020202d2052656164733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e7420286f726967696e616c206f776e657229fc202020202d205772697465733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e7420286f726967696e616c206f776e657229302023203c2f7765696768743e18667265657a650414696e6465783c543a3a4163636f756e74496e64657844690120467265657a6520616e20696e64657820736f2069742077696c6c20616c7761797320706f696e7420746f207468652073656e646572206163636f756e742e205468697320636f6e73756d657320746865206465706f7369742e005d0120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d7573742068617665206170206e6f6e2d66726f7a656e206163636f756e742060696e646578602e00b0202d2060696e646578603a2074686520696e64657820746f2062652066726f7a656e20696e20706c6163652e008c20456d6974732060496e64657846726f7a656e60206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e74202d20557020746f206f6e6520736c617368206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e010c34496e64657841737369676e656408244163636f756e744964304163636f756e74496e64657804b42041206163636f756e7420696e646578207761732061737369676e65642e205c5b696e6465782c2077686f5c5d28496e646578467265656404304163636f756e74496e64657804e82041206163636f756e7420696e64657820686173206265656e2066726565642075702028756e61737369676e6564292e205c5b696e6465785c5d2c496e64657846726f7a656e08304163636f756e74496e646578244163636f756e7449640429012041206163636f756e7420696e64657820686173206265656e2066726f7a656e20746f206974732063757272656e74206163636f756e742049442e205c5b696e6465782c2077686f5c5d041c4465706f7369743042616c616e63654f663c543e40aa821bce26000000000000000000000004ac20546865206465706f736974206e656564656420666f7220726573657276696e6720616e20696e6465782e00032042616c616e636573012042616c616e6365731034546f74616c49737375616e6365010028543a3a42616c616e6365400000000000000000000000000000000004982054686520746f74616c20756e6974732069737375656420696e207468652073797374656d2e1c4163636f756e7401010230543a3a4163636f756e7449645c4163636f756e74446174613c543a3a42616c616e63653e000101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6c205468652062616c616e6365206f6620616e206163636f756e742e004101204e4f54453a2054686973206973206f6e6c79207573656420696e207468652063617365207468617420746869732070616c6c6574206973207573656420746f2073746f72652062616c616e6365732e144c6f636b7301010230543a3a4163636f756e744964705665633c42616c616e63654c6f636b3c543a3a42616c616e63653e3e00040008b820416e79206c6971756964697479206c6f636b73206f6e20736f6d65206163636f756e742062616c616e6365732e2501204e4f54453a2053686f756c64206f6e6c79206265206163636573736564207768656e2073657474696e672c206368616e67696e6720616e642066726565696e672061206c6f636b2e3853746f7261676556657273696f6e01002052656c656173657304000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e00a020546869732069732073657420746f2076322e302e3020666f72206e6577206e6574776f726b732e0110207472616e736665720810646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e6cd8205472616e7366657220736f6d65206c697175696420667265652062616c616e636520746f20616e6f74686572206163636f756e742e00090120607472616e73666572602077696c6c207365742074686520604672656542616c616e636560206f66207468652073656e64657220616e642072656365697665722e21012049742077696c6c2064656372656173652074686520746f74616c2069737375616e6365206f66207468652073797374656d2062792074686520605472616e73666572466565602e1501204966207468652073656e6465722773206163636f756e742069732062656c6f7720746865206578697374656e7469616c206465706f736974206173206120726573756c74b4206f6620746865207472616e736665722c20746865206163636f756e742077696c6c206265207265617065642e00190120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d75737420626520605369676e65646020627920746865207472616e736163746f722e002c2023203c7765696768743e3101202d20446570656e64656e74206f6e20617267756d656e747320627574206e6f7420637269746963616c2c20676976656e2070726f70657220696d706c656d656e746174696f6e7320666f72cc202020696e70757420636f6e6669672074797065732e205365652072656c617465642066756e6374696f6e732062656c6f772e6901202d20497420636f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e642077726974657320696e7465726e616c6c7920616e64206e6f20636f6d706c657820636f6d7075746174696f6e2e004c2052656c617465642066756e6374696f6e733a0051012020202d2060656e737572655f63616e5f77697468647261776020697320616c776179732063616c6c656420696e7465726e616c6c792062757420686173206120626f756e64656420636f6d706c65786974792e2d012020202d205472616e7366657272696e672062616c616e63657320746f206163636f756e7473207468617420646964206e6f74206578697374206265666f72652077696c6c206361757365d420202020202060543a3a4f6e4e65774163636f756e743a3a6f6e5f6e65775f6163636f756e746020746f2062652063616c6c65642e61012020202d2052656d6f76696e6720656e6f7567682066756e64732066726f6d20616e206163636f756e742077696c6c20747269676765722060543a3a4475737452656d6f76616c3a3a6f6e5f756e62616c616e636564602e49012020202d20607472616e736665725f6b6565705f616c6976656020776f726b73207468652073616d652077617920617320607472616e73666572602c206275742068617320616e206164646974696f6e616cf82020202020636865636b207468617420746865207472616e736665722077696c6c206e6f74206b696c6c20746865206f726967696e206163636f756e742e88202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d4501202d2042617365205765696768743a2037332e363420c2b5732c20776f7273742063617365207363656e6172696f20286163636f756e7420637265617465642c206163636f756e742072656d6f76656429dc202d204442205765696768743a2031205265616420616e64203120577269746520746f2064657374696e6174696f6e206163636f756e741501202d204f726967696e206163636f756e7420697320616c726561647920696e206d656d6f72792c20736f206e6f204442206f7065726174696f6e7320666f72207468656d2e302023203c2f7765696768743e2c7365745f62616c616e63650c0c77686f8c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365206e65775f667265654c436f6d706163743c543a3a42616c616e63653e306e65775f72657365727665644c436f6d706163743c543a3a42616c616e63653e489420536574207468652062616c616e636573206f66206120676976656e206163636f756e742e00210120546869732077696c6c20616c74657220604672656542616c616e63656020616e642060526573657276656442616c616e63656020696e2073746f726167652e2069742077696c6c090120616c736f2064656372656173652074686520746f74616c2069737375616e6365206f66207468652073797374656d202860546f74616c49737375616e636560292e190120496620746865206e65772066726565206f722072657365727665642062616c616e63652069732062656c6f7720746865206578697374656e7469616c206465706f7369742c01012069742077696c6c20726573657420746865206163636f756e74206e6f6e63652028606672616d655f73797374656d3a3a4163636f756e744e6f6e636560292e00b420546865206469737061746368206f726967696e20666f7220746869732063616c6c2069732060726f6f74602e002c2023203c7765696768743e80202d20496e646570656e64656e74206f662074686520617267756d656e74732ec4202d20436f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e64207772697465732e58202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d3c202d2042617365205765696768743a6820202020202d204372656174696e673a2032372e353620c2b5736420202020202d204b696c6c696e673a2033352e313120c2b57398202d204442205765696768743a203120526561642c203120577269746520746f206077686f60302023203c2f7765696768743e38666f7263655f7472616e736665720c18736f757263658c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f7572636510646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e1851012045786163746c7920617320607472616e73666572602c2065786365707420746865206f726967696e206d75737420626520726f6f7420616e642074686520736f75726365206163636f756e74206d61792062652c207370656369666965642e2c2023203c7765696768743e4101202d2053616d65206173207472616e736665722c20627574206164646974696f6e616c207265616420616e6420777269746520626563617573652074686520736f75726365206163636f756e74206973902020206e6f7420617373756d656420746f20626520696e20746865206f7665726c61792e302023203c2f7765696768743e4c7472616e736665725f6b6565705f616c6976650810646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e2c51012053616d6520617320746865205b607472616e73666572605d2063616c6c2c206275742077697468206120636865636b207468617420746865207472616e736665722077696c6c206e6f74206b696c6c2074686540206f726967696e206163636f756e742e00bc20393925206f66207468652074696d6520796f752077616e74205b607472616e73666572605d20696e73746561642e00c4205b607472616e73666572605d3a207374727563742e50616c6c65742e68746d6c236d6574686f642e7472616e736665722c2023203c7765696768743ee8202d2043686561706572207468616e207472616e736665722062656361757365206163636f756e742063616e6e6f74206265206b696c6c65642e60202d2042617365205765696768743a2035312e3420c2b5731d01202d204442205765696768743a2031205265616420616e64203120577269746520746f2064657374202873656e64657220697320696e206f7665726c617920616c7265616479292c20233c2f7765696768743e01201c456e646f77656408244163636f756e7449641c42616c616e636504250120416e206163636f756e74207761732063726561746564207769746820736f6d6520667265652062616c616e63652e205c5b6163636f756e742c20667265655f62616c616e63655c5d20447573744c6f737408244163636f756e7449641c42616c616e636508410120416e206163636f756e74207761732072656d6f7665642077686f73652062616c616e636520776173206e6f6e2d7a65726f206275742062656c6f77204578697374656e7469616c4465706f7369742cd020726573756c74696e6720696e20616e206f75747269676874206c6f73732e205c5b6163636f756e742c2062616c616e63655c5d205472616e736665720c244163636f756e744964244163636f756e7449641c42616c616e636504a0205472616e73666572207375636365656465642e205c5b66726f6d2c20746f2c2076616c75655c5d2842616c616e63655365740c244163636f756e7449641c42616c616e63651c42616c616e636504cc20412062616c616e6365207761732073657420627920726f6f742e205c5b77686f2c20667265652c2072657365727665645c5d1c4465706f73697408244163636f756e7449641c42616c616e636504210120536f6d6520616d6f756e7420776173206465706f73697465642028652e672e20666f72207472616e73616374696f6e2066656573292e205c5b77686f2c206465706f7369745c5d20526573657276656408244163636f756e7449641c42616c616e636504210120536f6d652062616c616e63652077617320726573657276656420286d6f7665642066726f6d206672656520746f207265736572766564292e205c5b77686f2c2076616c75655c5d28556e726573657276656408244163636f756e7449641c42616c616e636504290120536f6d652062616c616e63652077617320756e726573657276656420286d6f7665642066726f6d20726573657276656420746f2066726565292e205c5b77686f2c2076616c75655c5d4852657365727665526570617472696174656410244163636f756e744964244163636f756e7449641c42616c616e6365185374617475730c510120536f6d652062616c616e636520776173206d6f7665642066726f6d207468652072657365727665206f6620746865206669727374206163636f756e7420746f20746865207365636f6e64206163636f756e742edc2046696e616c20617267756d656e7420696e64696361746573207468652064657374696e6174696f6e2062616c616e636520747970652ea8205c5b66726f6d2c20746f2c2062616c616e63652c2064657374696e6174696f6e5f7374617475735c5d04484578697374656e7469616c4465706f73697428543a3a42616c616e636540aa50576300000000000000000000000004d420546865206d696e696d756d20616d6f756e7420726571756972656420746f206b65657020616e206163636f756e74206f70656e2e203856657374696e6742616c616e6365049c2056657374696e672062616c616e636520746f6f206869676820746f2073656e642076616c7565544c69717569646974795265737472696374696f6e7304c8204163636f756e74206c6971756964697479207265737472696374696f6e732070726576656e74207769746864726177616c204f766572666c6f77047420476f7420616e206f766572666c6f7720616674657220616464696e674c496e73756666696369656e7442616c616e636504782042616c616e636520746f6f206c6f7720746f2073656e642076616c7565484578697374656e7469616c4465706f73697404ec2056616c756520746f6f206c6f7720746f20637265617465206163636f756e742064756520746f206578697374656e7469616c206465706f736974244b656570416c6976650490205472616e736665722f7061796d656e7420776f756c64206b696c6c206163636f756e745c4578697374696e6756657374696e675363686564756c6504cc20412076657374696e67207363686564756c6520616c72656164792065786973747320666f722074686973206163636f756e742c446561644163636f756e74048c2042656e6566696369617279206163636f756e74206d757374207072652d657869737404485472616e73616374696f6e5061796d656e7401485472616e73616374696f6e5061796d656e7408444e6578744665654d756c7469706c6965720100284d756c7469706c69657240000064a7b3b6e00d0000000000000000003853746f7261676556657273696f6e01002052656c6561736573040000000008485472616e73616374696f6e427974654665653042616c616e63654f663c543e402450fe00000000000000000000000000040d01205468652066656520746f206265207061696420666f72206d616b696e672061207472616e73616374696f6e3b20746865207065722d6279746520706f7274696f6e2e2c576569676874546f466565a45665633c576569676874546f466565436f656666696369656e743c42616c616e63654f663c543e3e3e5c04010000000000000000000000000000005443de130001040d012054686520706f6c796e6f6d69616c2074686174206973206170706c69656420696e206f7264657220746f20646572697665206665652066726f6d207765696768742e002128417574686f72736869700128417574686f72736869700c18556e636c65730100e85665633c556e636c65456e7472794974656d3c543a3a426c6f636b4e756d6265722c20543a3a486173682c20543a3a4163636f756e7449643e3e0400041c20556e636c657318417574686f72000030543a3a4163636f756e7449640400046420417574686f72206f662063757272656e7420626c6f636b2e30446964536574556e636c6573010010626f6f6c040004bc205768657468657220756e636c6573207765726520616c72656164792073657420696e207468697320626c6f636b2e0104287365745f756e636c657304286e65775f756e636c6573385665633c543a3a4865616465723e04642050726f76696465206120736574206f6620756e636c65732e00001c48496e76616c6964556e636c65506172656e74048c2054686520756e636c6520706172656e74206e6f7420696e2074686520636861696e2e40556e636c6573416c7265616479536574048420556e636c657320616c72656164792073657420696e2074686520626c6f636b2e34546f6f4d616e79556e636c6573044420546f6f206d616e7920756e636c65732e3047656e65736973556e636c6504582054686520756e636c652069732067656e657369732e30546f6f48696768556e636c6504802054686520756e636c6520697320746f6f206869676820696e20636861696e2e50556e636c65416c7265616479496e636c75646564047c2054686520756e636c6520697320616c726561647920696e636c756465642e204f6c64556e636c6504b82054686520756e636c652069736e277420726563656e7420656e6f75676820746f20626520696e636c756465642e051c5374616b696e67011c5374616b696e677830486973746f7279446570746801000c75333210540000001c8c204e756d626572206f66206572617320746f206b65657020696e20686973746f72792e00390120496e666f726d6174696f6e206973206b65707420666f72206572617320696e20605b63757272656e745f657261202d20686973746f72795f64657074683b2063757272656e745f6572615d602e006101204d757374206265206d6f7265207468616e20746865206e756d626572206f6620657261732064656c617965642062792073657373696f6e206f74686572776973652e20492e652e2061637469766520657261206d757374390120616c7761797320626520696e20686973746f72792e20492e652e20606163746976655f657261203e2063757272656e745f657261202d20686973746f72795f646570746860206d757374206265302067756172616e746565642e3856616c696461746f72436f756e7401000c753332100000000004a82054686520696465616c206e756d626572206f66207374616b696e67207061727469636970616e74732e544d696e696d756d56616c696461746f72436f756e7401000c7533321000000000044101204d696e696d756d206e756d626572206f66207374616b696e67207061727469636970616e7473206265666f726520656d657267656e637920636f6e646974696f6e732061726520696d706f7365642e34496e76756c6e657261626c65730100445665633c543a3a4163636f756e7449643e04000c590120416e792076616c696461746f72732074686174206d6179206e6576657220626520736c6173686564206f7220666f726369626c79206b69636b65642e20497427732061205665632073696e636520746865792772654d01206561737920746f20696e697469616c697a6520616e642074686520706572666f726d616e636520686974206973206d696e696d616c2028776520657870656374206e6f206d6f7265207468616e20666f7572ac20696e76756c6e657261626c65732920616e64207265737472696374656420746f20746573746e6574732e18426f6e64656400010530543a3a4163636f756e74496430543a3a4163636f756e744964000400040101204d61702066726f6d20616c6c206c6f636b65642022737461736822206163636f756e747320746f2074686520636f6e74726f6c6c6572206163636f756e742e184c656467657200010230543a3a4163636f756e744964a45374616b696e674c65646765723c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e000400044501204d61702066726f6d20616c6c2028756e6c6f636b6564292022636f6e74726f6c6c657222206163636f756e747320746f2074686520696e666f20726567617264696e6720746865207374616b696e672e14506179656501010530543a3a4163636f756e7449647c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e00040004e42057686572652074686520726577617264207061796d656e742073686f756c64206265206d6164652e204b657965642062792073746173682e2856616c696461746f727301010530543a3a4163636f756e7449643856616c696461746f7250726566730008000004450120546865206d61702066726f6d202877616e6e616265292076616c696461746f72207374617368206b657920746f2074686520707265666572656e636573206f6620746861742076616c696461746f722e284e6f6d696e61746f727300010530543a3a4163636f756e744964644e6f6d696e6174696f6e733c543a3a4163636f756e7449643e00040004650120546865206d61702066726f6d206e6f6d696e61746f72207374617368206b657920746f2074686520736574206f66207374617368206b657973206f6620616c6c2076616c696461746f727320746f206e6f6d696e6174652e2843757272656e74457261000020457261496e6465780400105c205468652063757272656e742065726120696e6465782e006501205468697320697320746865206c617465737420706c616e6e6564206572612c20646570656e64696e67206f6e20686f77207468652053657373696f6e2070616c6c657420717565756573207468652076616c696461746f7280207365742c206974206d6967687420626520616374697665206f72206e6f742e24416374697665457261000034416374697665457261496e666f040010d820546865206163746976652065726120696e666f726d6174696f6e2c20697420686f6c647320696e64657820616e642073746172742e0059012054686520616374697665206572612069732074686520657261206265696e672063757272656e746c792072657761726465642e2056616c696461746f7220736574206f66207468697320657261206d757374206265ac20657175616c20746f205b6053657373696f6e496e746572666163653a3a76616c696461746f7273605d2e5445726173537461727453657373696f6e496e64657800010520457261496e6465783053657373696f6e496e646578000400103101205468652073657373696f6e20696e646578206174207768696368207468652065726120737461727420666f7220746865206c6173742060484953544f52595f44455054486020657261732e006101204e6f74653a205468697320747261636b7320746865207374617274696e672073657373696f6e2028692e652e2073657373696f6e20696e646578207768656e20657261207374617274206265696e672061637469766529f020666f7220746865206572617320696e20605b43757272656e74457261202d20484953544f52595f44455054482c2043757272656e744572615d602e2c457261735374616b65727301020520457261496e64657830543a3a4163636f756e744964904578706f737572653c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e050c0000001878204578706f73757265206f662076616c696461746f72206174206572612e0061012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00a82049732069742072656d6f7665642061667465722060484953544f52595f44455054486020657261732e4101204966207374616b657273206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e20656d707479206578706f737572652069732072657475726e65642e48457261735374616b657273436c697070656401020520457261496e64657830543a3a4163636f756e744964904578706f737572653c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e050c0000002c9820436c6970706564204578706f73757265206f662076616c696461746f72206174206572612e00590120546869732069732073696d696c617220746f205b60457261735374616b657273605d20627574206e756d626572206f66206e6f6d696e61746f7273206578706f736564206973207265647563656420746f20746865dc2060543a3a4d61784e6f6d696e61746f72526577617264656450657256616c696461746f72602062696767657374207374616b6572732e1d0120284e6f74653a20746865206669656c642060746f74616c6020616e6420606f776e60206f6620746865206578706f737572652072656d61696e7320756e6368616e676564292ef42054686973206973207573656420746f206c696d69742074686520692f6f20636f737420666f7220746865206e6f6d696e61746f72207061796f75742e005d012054686973206973206b657965642066697374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00a82049732069742072656d6f7665642061667465722060484953544f52595f44455054486020657261732e4101204966207374616b657273206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e20656d707479206578706f737572652069732072657475726e65642e484572617356616c696461746f72507265667301020520457261496e64657830543a3a4163636f756e7449643856616c696461746f725072656673050800001411012053696d696c617220746f2060457261735374616b657273602c207468697320686f6c64732074686520707265666572656e636573206f662076616c696461746f72732e0061012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00a82049732069742072656d6f7665642061667465722060484953544f52595f44455054486020657261732e4c4572617356616c696461746f7252657761726400010520457261496e6465783042616c616e63654f663c543e0004000c09012054686520746f74616c2076616c696461746f7220657261207061796f757420666f7220746865206c6173742060484953544f52595f44455054486020657261732e0021012045726173207468617420686176656e27742066696e697368656420796574206f7220686173206265656e2072656d6f76656420646f65736e27742068617665207265776172642e4045726173526577617264506f696e747301010520457261496e64657874457261526577617264506f696e74733c543a3a4163636f756e7449643e0014000000000008ac205265776172647320666f7220746865206c6173742060484953544f52595f44455054486020657261732e250120496620726577617264206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e2030207265776172642069732072657475726e65642e3845726173546f74616c5374616b6501010520457261496e6465783042616c616e63654f663c543e00400000000000000000000000000000000008ec2054686520746f74616c20616d6f756e74207374616b656420666f7220746865206c6173742060484953544f52595f44455054486020657261732e1d0120496620746f74616c206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e2030207374616b652069732072657475726e65642e20466f72636545726101001c466f7263696e6704000454204d6f6465206f662065726120666f7263696e672e4c536c6173685265776172644672616374696f6e01001c50657262696c6c10000000000cf8205468652070657263656e74616765206f662074686520736c617368207468617420697320646973747269627574656420746f207265706f72746572732e00e4205468652072657374206f662074686520736c61736865642076616c75652069732068616e646c6564206279207468652060536c617368602e4c43616e63656c6564536c6173685061796f757401003042616c616e63654f663c543e40000000000000000000000000000000000815012054686520616d6f756e74206f662063757272656e637920676976656e20746f207265706f7274657273206f66206120736c617368206576656e7420776869636820776173ec2063616e63656c65642062792065787472616f7264696e6172792063697263756d7374616e6365732028652e672e20676f7665726e616e6365292e40556e6170706c696564536c617368657301010520457261496e646578bc5665633c556e6170706c696564536c6173683c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e3e00040004c420416c6c20756e6170706c69656420736c61736865732074686174206172652071756575656420666f72206c617465722e28426f6e646564457261730100745665633c28457261496e6465782c2053657373696f6e496e646578293e04001025012041206d617070696e672066726f6d207374696c6c2d626f6e646564206572617320746f207468652066697273742073657373696f6e20696e646578206f662074686174206572612e00c8204d75737420636f6e7461696e7320696e666f726d6174696f6e20666f72206572617320666f72207468652072616e67653abc20605b6163746976655f657261202d20626f756e64696e675f6475726174696f6e3b206163746976655f6572615d604c56616c696461746f72536c617368496e45726100020520457261496e64657830543a3a4163636f756e7449645c2850657262696c6c2c2042616c616e63654f663c543e2905040008450120416c6c20736c617368696e67206576656e7473206f6e2076616c696461746f72732c206d61707065642062792065726120746f20746865206869676865737420736c6173682070726f706f7274696f6e7020616e6420736c6173682076616c7565206f6620746865206572612e4c4e6f6d696e61746f72536c617368496e45726100020520457261496e64657830543a3a4163636f756e7449643042616c616e63654f663c543e05040004610120416c6c20736c617368696e67206576656e7473206f6e206e6f6d696e61746f72732c206d61707065642062792065726120746f20746865206869676865737420736c6173682076616c7565206f6620746865206572612e34536c617368696e675370616e7300010530543a3a4163636f756e7449645c736c617368696e673a3a536c617368696e675370616e73000400048c20536c617368696e67207370616e7320666f72207374617368206163636f756e74732e245370616e536c6173680101058c28543a3a4163636f756e7449642c20736c617368696e673a3a5370616e496e6465782988736c617368696e673a3a5370616e5265636f72643c42616c616e63654f663c543e3e00800000000000000000000000000000000000000000000000000000000000000000083d01205265636f72647320696e666f726d6174696f6e2061626f757420746865206d6178696d756d20736c617368206f6620612073746173682077697468696e206120736c617368696e67207370616e2cb82061732077656c6c20617320686f77206d7563682072657761726420686173206265656e2070616964206f75742e584561726c69657374556e6170706c696564536c617368000020457261496e646578040004fc20546865206561726c696573742065726120666f72207768696368207765206861766520612070656e64696e672c20756e6170706c69656420736c6173682e5443757272656e74506c616e6e656453657373696f6e01003053657373696f6e496e64657810000000000ce820546865206c61737420706c616e6e65642073657373696f6e207363686564756c6564206279207468652073657373696f6e2070616c6c65742e0031012054686973206973206261736963616c6c7920696e2073796e632077697468207468652063616c6c20746f205b6053657373696f6e4d616e616765723a3a6e65775f73657373696f6e605d2e3853746f7261676556657273696f6e01002052656c6561736573040510cc2054727565206966206e6574776f726b20686173206265656e20757067726164656420746f20746869732076657273696f6e2e7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e00a020546869732069732073657420746f2076362e302e3020666f72206e6577206e6574776f726b732e015c10626f6e640c28636f6e74726f6c6c65728c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c756554436f6d706163743c42616c616e63654f663c543e3e1470617965657c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e5865012054616b6520746865206f726967696e206163636f756e74206173206120737461736820616e64206c6f636b207570206076616c756560206f66206974732062616c616e63652e2060636f6e74726f6c6c6572602077696c6c8420626520746865206163636f756e74207468617420636f6e74726f6c732069742e003101206076616c756560206d757374206265206d6f7265207468616e2074686520606d696e696d756d5f62616c616e636560207370656369666965642062792060543a3a43757272656e6379602e00250120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20627920746865207374617368206163636f756e742e004020456d6974732060426f6e646564602e002c2023203c7765696768743ed4202d20496e646570656e64656e74206f662074686520617267756d656e74732e204d6f64657261746520636f6d706c65786974792e20202d204f2831292e68202d20546872656520657874726120444220656e74726965732e005101204e4f54453a2054776f206f66207468652073746f726167652077726974657320286053656c663a3a626f6e646564602c206053656c663a3a7061796565602920617265205f6e657665725f20636c65616e6564410120756e6c6573732074686520606f726967696e602066616c6c732062656c6f77205f6578697374656e7469616c206465706f7369745f20616e6420676574732072656d6f76656420617320647573742e4c202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a3101202d20526561643a20426f6e6465642c204c65646765722c205b4f726967696e204163636f756e745d2c2043757272656e74204572612c20486973746f72792044657074682c204c6f636b73e0202d2057726974653a20426f6e6465642c2050617965652c205b4f726967696e204163636f756e745d2c204c6f636b732c204c6564676572302023203c2f7765696768743e28626f6e645f657874726104386d61785f6164646974696f6e616c54436f6d706163743c42616c616e63654f663c543e3e5465012041646420736f6d6520657874726120616d6f756e742074686174206861766520617070656172656420696e207468652073746173682060667265655f62616c616e63656020696e746f207468652062616c616e63652075703420666f72207374616b696e672e00510120557365207468697320696620746865726520617265206164646974696f6e616c2066756e647320696e20796f7572207374617368206163636f756e74207468617420796f75207769736820746f20626f6e642e650120556e6c696b65205b60626f6e64605d206f72205b60756e626f6e64605d20746869732066756e6374696f6e20646f6573206e6f7420696d706f736520616e79206c696d69746174696f6e206f6e2074686520616d6f756e744c20746861742063616e2062652061646465642e00610120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f206279207468652073746173682c206e6f742074686520636f6e74726f6c6c657220616e64f82069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e004020456d6974732060426f6e646564602e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e20202d204f2831292e40202d204f6e6520444220656e7472792e34202d2d2d2d2d2d2d2d2d2d2d2d2c204442205765696768743a1501202d20526561643a2045726120456c656374696f6e205374617475732c20426f6e6465642c204c65646765722c205b4f726967696e204163636f756e745d2c204c6f636b73a4202d2057726974653a205b4f726967696e204163636f756e745d2c204c6f636b732c204c6564676572302023203c2f7765696768743e18756e626f6e64041476616c756554436f6d706163743c42616c616e63654f663c543e3e805501205363686564756c65206120706f7274696f6e206f662074686520737461736820746f20626520756e6c6f636b656420726561647920666f72207472616e73666572206f75742061667465722074686520626f6e64010120706572696f6420656e64732e2049662074686973206c656176657320616e20616d6f756e74206163746976656c7920626f6e646564206c657373207468616e250120543a3a43757272656e63793a3a6d696e696d756d5f62616c616e636528292c207468656e20697420697320696e6372656173656420746f207468652066756c6c20616d6f756e742e004901204f6e63652074686520756e6c6f636b20706572696f6420697320646f6e652c20796f752063616e2063616c6c206077697468647261775f756e626f6e6465646020746f2061637475616c6c79206d6f7665c0207468652066756e6473206f7574206f66206d616e6167656d656e7420726561647920666f72207472616e736665722e003d01204e6f206d6f7265207468616e2061206c696d69746564206e756d626572206f6620756e6c6f636b696e67206368756e6b73202873656520604d41585f554e4c4f434b494e475f4348554e4b5360293d012063616e20636f2d657869737473206174207468652073616d652074696d652e20496e207468617420636173652c205b6043616c6c3a3a77697468647261775f756e626f6e646564605d206e656564fc20746f2062652063616c6c656420666972737420746f2072656d6f766520736f6d65206f6620746865206368756e6b732028696620706f737369626c65292e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e004820456d6974732060556e626f6e646564602e00982053656520616c736f205b6043616c6c3a3a77697468647261775f756e626f6e646564605d2e002c2023203c7765696768743e4101202d20496e646570656e64656e74206f662074686520617267756d656e74732e204c696d697465642062757420706f74656e7469616c6c79206578706c6f697461626c6520636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732e6501202d20456163682063616c6c20287265717569726573207468652072656d61696e646572206f662074686520626f6e6465642062616c616e636520746f2062652061626f766520606d696e696d756d5f62616c616e63656029710120202077696c6c2063617573652061206e657720656e74727920746f20626520696e73657274656420696e746f206120766563746f722028604c65646765722e756e6c6f636b696e676029206b65707420696e2073746f726167652e5101202020546865206f6e6c792077617920746f20636c65616e207468652061666f72656d656e74696f6e65642073746f72616765206974656d20697320616c736f20757365722d636f6e74726f6c6c6564207669615c2020206077697468647261775f756e626f6e646564602e40202d204f6e6520444220656e7472792e2c202d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a1d01202d20526561643a20457261456c656374696f6e5374617475732c204c65646765722c2043757272656e744572612c204c6f636b732c2042616c616e63654f662053746173682ca4202d2057726974653a204c6f636b732c204c65646765722c2042616c616e63654f662053746173682c28203c2f7765696768743e4477697468647261775f756e626f6e64656404486e756d5f736c617368696e675f7370616e730c7533327c2d012052656d6f766520616e7920756e6c6f636b6564206368756e6b732066726f6d207468652060756e6c6f636b696e67602071756575652066726f6d206f7572206d616e6167656d656e742e003501205468697320657373656e7469616c6c7920667265657320757020746861742062616c616e636520746f206265207573656420627920746865207374617368206163636f756e7420746f20646f4c2077686174657665722069742077616e74732e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e004c20456d697473206057697468647261776e602e006c2053656520616c736f205b6043616c6c3a3a756e626f6e64605d2e002c2023203c7765696768743e5501202d20436f756c6420626520646570656e64656e74206f6e2074686520606f726967696e6020617267756d656e7420616e6420686f77206d7563682060756e6c6f636b696e6760206368756e6b732065786973742e45012020497420696d706c6965732060636f6e736f6c69646174655f756e6c6f636b656460207768696368206c6f6f7073206f76657220604c65646765722e756e6c6f636b696e67602c207768696368206973f42020696e6469726563746c7920757365722d636f6e74726f6c6c65642e20536565205b60756e626f6e64605d20666f72206d6f72652064657461696c2e7901202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732c20796574207468652073697a65206f6620776869636820636f756c64206265206c61726765206261736564206f6e20606c6564676572602ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e40202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d090120436f6d706c6578697479204f285329207768657265205320697320746865206e756d626572206f6620736c617368696e67207370616e7320746f2072656d6f766520205570646174653a2501202d2052656164733a20457261456c656374696f6e5374617475732c204c65646765722c2043757272656e74204572612c204c6f636b732c205b4f726967696e204163636f756e745da8202d205772697465733a205b4f726967696e204163636f756e745d2c204c6f636b732c204c656467657218204b696c6c3a4501202d2052656164733a20457261456c656374696f6e5374617475732c204c65646765722c2043757272656e74204572612c20426f6e6465642c20536c617368696e67205370616e732c205b4f726967696e8c2020204163636f756e745d2c204c6f636b732c2042616c616e63654f662073746173685101202d205772697465733a20426f6e6465642c20536c617368696e67205370616e73202869662053203e2030292c204c65646765722c2050617965652c2056616c696461746f72732c204e6f6d696e61746f72732cb02020205b4f726967696e204163636f756e745d2c204c6f636b732c2042616c616e63654f662073746173682e74202d2057726974657320456163683a205370616e536c617368202a20530d01204e4f54453a2057656967687420616e6e6f746174696f6e20697320746865206b696c6c207363656e6172696f2c20776520726566756e64206f74686572776973652e302023203c2f7765696768743e2076616c6964617465041470726566733856616c696461746f72507265667344e8204465636c617265207468652064657369726520746f2076616c696461746520666f7220746865206f726967696e20636f6e74726f6c6c65722e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e30202d2d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a90202d20526561643a2045726120456c656374696f6e205374617475732c204c656467657280202d2057726974653a204e6f6d696e61746f72732c2056616c696461746f7273302023203c2f7765696768743e206e6f6d696e617465041c74617267657473a05665633c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653e4c1101204465636c617265207468652064657369726520746f206e6f6d696e6174652060746172676574736020666f7220746865206f726967696e20636f6e74726f6c6c65722e00510120456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e20546869732063616e206f6e6c792062652063616c6c6564207768656e8c205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743e3101202d20546865207472616e73616374696f6e277320636f6d706c65786974792069732070726f706f7274696f6e616c20746f207468652073697a65206f662060746172676574736020284e2901012077686963682069732063617070656420617420436f6d7061637441737369676e6d656e74733a3a4c494d495420284d41585f4e4f4d494e4154494f4e53292ed8202d20426f74682074686520726561647320616e642077726974657320666f6c6c6f7720612073696d696c6172207061747465726e2e28202d2d2d2d2d2d2d2d2d34205765696768743a204f284e2984207768657265204e20697320746865206e756d626572206f6620746172676574732c204442205765696768743ac8202d2052656164733a2045726120456c656374696f6e205374617475732c204c65646765722c2043757272656e742045726184202d205772697465733a2056616c696461746f72732c204e6f6d696e61746f7273302023203c2f7765696768743e146368696c6c0044c8204465636c617265206e6f2064657369726520746f206569746865722076616c6964617465206f72206e6f6d696e6174652e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e54202d20436f6e7461696e73206f6e6520726561642ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e24202d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a88202d20526561643a20457261456c656374696f6e5374617475732c204c656467657280202d2057726974653a2056616c696461746f72732c204e6f6d696e61746f7273302023203c2f7765696768743e247365745f7061796565041470617965657c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e40b8202852652d2973657420746865207061796d656e742074617267657420666f72206120636f6e74726f6c6c65722e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e28202d2d2d2d2d2d2d2d2d3c202d205765696768743a204f28312934202d204442205765696768743a4c20202020202d20526561643a204c65646765724c20202020202d2057726974653a205061796565302023203c2f7765696768743e387365745f636f6e74726f6c6c65720428636f6e74726f6c6c65728c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263654090202852652d297365742074686520636f6e74726f6c6c6572206f6620612073746173682e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f206279207468652073746173682c206e6f742074686520636f6e74726f6c6c65722e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e2c202d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743af4202d20526561643a20426f6e6465642c204c6564676572204e657720436f6e74726f6c6c65722c204c6564676572204f6c6420436f6e74726f6c6c6572f8202d2057726974653a20426f6e6465642c204c6564676572204e657720436f6e74726f6c6c65722c204c6564676572204f6c6420436f6e74726f6c6c6572302023203c2f7765696768743e4c7365745f76616c696461746f725f636f756e74040c6e657730436f6d706163743c7533323e209420536574732074686520696465616c206e756d626572206f662076616c696461746f72732e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e34205765696768743a204f2831295c2057726974653a2056616c696461746f7220436f756e74302023203c2f7765696768743e60696e6372656173655f76616c696461746f725f636f756e7404286164646974696f6e616c30436f6d706163743c7533323e1cac20496e6372656d656e74732074686520696465616c206e756d626572206f662076616c696461746f72732e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e842053616d65206173205b607365745f76616c696461746f725f636f756e74605d2e302023203c2f7765696768743e547363616c655f76616c696461746f725f636f756e740418666163746f721c50657263656e741cd4205363616c652075702074686520696465616c206e756d626572206f662076616c696461746f7273206279206120666163746f722e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e842053616d65206173205b607365745f76616c696461746f725f636f756e74605d2e302023203c2f7765696768743e34666f7263655f6e6f5f657261730024b020466f72636520746865726520746f206265206e6f206e6577206572617320696e646566696e6974656c792e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e40202d204e6f20617267756d656e74732e3c202d205765696768743a204f28312948202d2057726974653a20466f726365457261302023203c2f7765696768743e34666f7263655f6e65775f65726100284d0120466f72636520746865726520746f2062652061206e6577206572612061742074686520656e64206f6620746865206e6578742073657373696f6e2e20416674657220746869732c2069742077696c6c206265a020726573657420746f206e6f726d616c20286e6f6e2d666f7263656429206265686176696f75722e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e40202d204e6f20617267756d656e74732e3c202d205765696768743a204f28312944202d20577269746520466f726365457261302023203c2f7765696768743e447365745f696e76756c6e657261626c65730434696e76756c6e657261626c6573445665633c543a3a4163636f756e7449643e20cc20536574207468652076616c696461746f72732077686f2063616e6e6f7420626520736c61736865642028696620616e79292e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e1c202d204f2856295c202d2057726974653a20496e76756c6e657261626c6573302023203c2f7765696768743e34666f7263655f756e7374616b650814737461736830543a3a4163636f756e744964486e756d5f736c617368696e675f7370616e730c753332280d0120466f72636520612063757272656e74207374616b657220746f206265636f6d6520636f6d706c6574656c7920756e7374616b65642c20696d6d6564696174656c792e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743eec204f285329207768657265205320697320746865206e756d626572206f6620736c617368696e67207370616e7320746f2062652072656d6f766564b82052656164733a20426f6e6465642c20536c617368696e67205370616e732c204163636f756e742c204c6f636b738501205772697465733a20426f6e6465642c20536c617368696e67205370616e73202869662053203e2030292c204c65646765722c2050617965652c2056616c696461746f72732c204e6f6d696e61746f72732c204163636f756e742c204c6f636b736c2057726974657320456163683a205370616e536c617368202a2053302023203c2f7765696768743e50666f7263655f6e65775f6572615f616c776179730020050120466f72636520746865726520746f2062652061206e6577206572612061742074686520656e64206f662073657373696f6e7320696e646566696e6974656c792e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e3c202d205765696768743a204f28312948202d2057726974653a20466f726365457261302023203c2f7765696768743e5463616e63656c5f64656665727265645f736c617368080c65726120457261496e64657834736c6173685f696e6469636573205665633c7533323e34982043616e63656c20656e6163746d656e74206f66206120646566657272656420736c6173682e00b42043616e2062652063616c6c6564206279207468652060543a3a536c61736843616e63656c4f726967696e602e00050120506172616d65746572733a2065726120616e6420696e6469636573206f662074686520736c617368657320666f7220746861742065726120746f206b696c6c2e002c2023203c7765696768743e5420436f6d706c65786974793a204f2855202b205329b82077697468205520756e6170706c69656420736c6173686573207765696768746564207769746820553d31303030d420616e64205320697320746865206e756d626572206f6620736c61736820696e646963657320746f2062652063616e63656c65642e68202d20526561643a20556e6170706c69656420536c61736865736c202d2057726974653a20556e6170706c69656420536c6173686573302023203c2f7765696768743e387061796f75745f7374616b657273083c76616c696461746f725f737461736830543a3a4163636f756e7449640c65726120457261496e64657870110120506179206f757420616c6c20746865207374616b65727320626568696e6420612073696e676c652076616c696461746f7220666f7220612073696e676c65206572612e004d01202d206076616c696461746f725f73746173686020697320746865207374617368206163636f756e74206f66207468652076616c696461746f722e205468656972206e6f6d696e61746f72732c20757020746f290120202060543a3a4d61784e6f6d696e61746f72526577617264656450657256616c696461746f72602c2077696c6c20616c736f207265636569766520746865697220726577617264732e3501202d206065726160206d617920626520616e7920657261206265747765656e20605b63757272656e745f657261202d20686973746f72795f64657074683b2063757272656e745f6572615d602e00590120546865206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e20416e79206163636f756e742063616e2063616c6c20746869732066756e6374696f6e2c206576656e20696678206974206973206e6f74206f6e65206f6620746865207374616b6572732e00010120546869732063616e206f6e6c792062652063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743e0101202d2054696d6520636f6d706c65786974793a206174206d6f7374204f284d61784e6f6d696e61746f72526577617264656450657256616c696461746f72292ec4202d20436f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e64207772697465732e30202d2d2d2d2d2d2d2d2d2d2d1d01204e20697320746865204e756d626572206f66207061796f75747320666f72207468652076616c696461746f722028696e636c7564696e67207468652076616c696461746f722920205765696768743a88202d205265776172642044657374696e6174696f6e205374616b65643a204f284e29c4202d205265776172642044657374696e6174696f6e20436f6e74726f6c6c657220284372656174696e67293a204f284e292c204442205765696768743a2901202d20526561643a20457261456c656374696f6e5374617475732c2043757272656e744572612c20486973746f727944657074682c204572617356616c696461746f725265776172642c2d01202020202020202020457261735374616b657273436c69707065642c2045726173526577617264506f696e74732c204572617356616c696461746f725072656673202838206974656d73291101202d205265616420456163683a20426f6e6465642c204c65646765722c2050617965652c204c6f636b732c2053797374656d204163636f756e74202835206974656d7329d8202d20577269746520456163683a2053797374656d204163636f756e742c204c6f636b732c204c6564676572202833206974656d73290051012020204e4f54453a20776569676874732061726520617373756d696e672074686174207061796f75747320617265206d61646520746f20616c697665207374617368206163636f756e7420285374616b6564292e5901202020506179696e67206576656e2061206465616420636f6e74726f6c6c65722069732063686561706572207765696768742d776973652e20576520646f6e277420646f20616e7920726566756e647320686572652e302023203c2f7765696768743e187265626f6e64041476616c756554436f6d706163743c42616c616e63654f663c543e3e38e0205265626f6e64206120706f7274696f6e206f6620746865207374617368207363686564756c656420746f20626520756e6c6f636b65642e00550120546865206469737061746368206f726967696e206d757374206265207369676e65642062792074686520636f6e74726f6c6c65722c20616e642069742063616e206265206f6e6c792063616c6c6564207768656e8c205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743ed4202d2054696d6520636f6d706c65786974793a204f284c292c207768657265204c20697320756e6c6f636b696e67206368756e6b7394202d20426f756e64656420627920604d41585f554e4c4f434b494e475f4348554e4b53602ef4202d2053746f72616765206368616e6765733a2043616e277420696e6372656173652073746f726167652c206f6e6c792064656372656173652069742e40202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743a010120202020202d2052656164733a20457261456c656374696f6e5374617475732c204c65646765722c204c6f636b732c205b4f726967696e204163636f756e745db820202020202d205772697465733a205b4f726967696e204163636f756e745d2c204c6f636b732c204c6564676572302023203c2f7765696768743e447365745f686973746f72795f646570746808446e65775f686973746f72795f646570746844436f6d706163743c457261496e6465783e485f6572615f6974656d735f64656c6574656430436f6d706163743c7533323e543101205365742060486973746f72794465707468602076616c75652e20546869732066756e6374696f6e2077696c6c2064656c65746520616e7920686973746f727920696e666f726d6174696f6e80207768656e2060486973746f727944657074686020697320726564756365642e003020506172616d65746572733a1101202d20606e65775f686973746f72795f6465707468603a20546865206e657720686973746f727920646570746820796f7520776f756c64206c696b6520746f207365742e4901202d20606572615f6974656d735f64656c65746564603a20546865206e756d626572206f66206974656d7320746861742077696c6c2062652064656c6574656420627920746869732064697370617463682e450120202020546869732073686f756c64207265706f727420616c6c207468652073746f72616765206974656d7320746861742077696c6c2062652064656c6574656420627920636c656172696e67206f6c6445012020202065726120686973746f72792e204e656564656420746f207265706f727420616e2061636375726174652077656967687420666f72207468652064697370617463682e2054727573746564206279a02020202060526f6f746020746f207265706f727420616e206163637572617465206e756d6265722e0054204f726967696e206d75737420626520726f6f742e002c2023203c7765696768743ee0202d20453a204e756d626572206f6620686973746f7279206465707468732072656d6f7665642c20692e652e203130202d3e2037203d20333c202d205765696768743a204f28452934202d204442205765696768743aa020202020202d2052656164733a2043757272656e74204572612c20486973746f72792044657074687020202020202d205772697465733a20486973746f7279204465707468310120202020202d20436c6561722050726566697820456163683a20457261205374616b6572732c204572615374616b657273436c69707065642c204572617356616c696461746f725072656673810120202020202d2057726974657320456163683a204572617356616c696461746f725265776172642c2045726173526577617264506f696e74732c2045726173546f74616c5374616b652c2045726173537461727453657373696f6e496e646578302023203c2f7765696768743e28726561705f73746173680814737461736830543a3a4163636f756e744964486e756d5f736c617368696e675f7370616e730c7533323c61012052656d6f766520616c6c20646174612073747275637475726520636f6e6365726e696e672061207374616b65722f7374617368206f6e6365206974732062616c616e636520697320617420746865206d696e696d756d2e6101205468697320697320657373656e7469616c6c79206571756976616c656e7420746f206077697468647261775f756e626f6e64656460206578636570742069742063616e2062652063616c6c656420627920616e796f6e65f820616e6420746865207461726765742060737461736860206d7573742068617665206e6f2066756e6473206c656674206265796f6e64207468652045442e009020546869732063616e2062652063616c6c65642066726f6d20616e79206f726967696e2e000101202d20607374617368603a20546865207374617368206163636f756e7420746f20726561702e204974732062616c616e6365206d757374206265207a65726f2e002c2023203c7765696768743e250120436f6d706c65786974793a204f285329207768657265205320697320746865206e756d626572206f6620736c617368696e67207370616e73206f6e20746865206163636f756e742e2c204442205765696768743ad8202d2052656164733a205374617368204163636f756e742c20426f6e6465642c20536c617368696e67205370616e732c204c6f636b73a501202d205772697465733a20426f6e6465642c20536c617368696e67205370616e73202869662053203e2030292c204c65646765722c2050617965652c2056616c696461746f72732c204e6f6d696e61746f72732c205374617368204163636f756e742c204c6f636b7374202d2057726974657320456163683a205370616e536c617368202a2053302023203c2f7765696768743e106b69636b040c77686fa05665633c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653e34e42052656d6f76652074686520676976656e206e6f6d696e6174696f6e732066726f6d207468652063616c6c696e672076616c696461746f722e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e490120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e2054686520636f6e74726f6c6c657298206163636f756e742073686f756c6420726570726573656e7420612076616c696461746f722e005101202d206077686f603a2041206c697374206f66206e6f6d696e61746f72207374617368206163636f756e74732077686f20617265206e6f6d696e6174696e6720746869732076616c696461746f72207768696368c420202073686f756c64206e6f206c6f6e676572206265206e6f6d696e6174696e6720746869732076616c696461746f722e005901204e6f74653a204d616b696e6720746869732063616c6c206f6e6c79206d616b65732073656e736520696620796f7520666972737420736574207468652076616c696461746f7220707265666572656e63657320746f7c20626c6f636b20616e792066757274686572206e6f6d696e6174696f6e732e0124244572615061796f75740c20457261496e6465781c42616c616e63651c42616c616e63650c59012054686520657261207061796f757420686173206265656e207365743b207468652066697273742062616c616e6365206973207468652076616c696461746f722d7061796f75743b20746865207365636f6e64206973c4207468652072656d61696e6465722066726f6d20746865206d6178696d756d20616d6f756e74206f66207265776172642eac205c5b6572615f696e6465782c2076616c696461746f725f7061796f75742c2072656d61696e6465725c5d1852657761726408244163636f756e7449641c42616c616e636504fc20546865207374616b657220686173206265656e207265776172646564206279207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d14536c61736808244163636f756e7449641c42616c616e6365082501204f6e652076616c696461746f722028616e6420697473206e6f6d696e61746f72732920686173206265656e20736c61736865642062792074686520676976656e20616d6f756e742e58205c5b76616c696461746f722c20616d6f756e745c5d684f6c64536c617368696e675265706f7274446973636172646564043053657373696f6e496e646578081d0120416e206f6c6420736c617368696e67207265706f72742066726f6d2061207072696f72206572612077617320646973636172646564206265636175736520697420636f756c6490206e6f742062652070726f6365737365642e205c5b73657373696f6e5f696e6465785c5d3c5374616b696e67456c656374696f6e0004882041206e657720736574206f66207374616b6572732077617320656c65637465642e18426f6e64656408244163636f756e7449641c42616c616e636510d420416e206163636f756e742068617320626f6e646564207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d005101204e4f54453a2054686973206576656e74206973206f6e6c7920656d6974746564207768656e2066756e64732061726520626f6e64656420766961206120646973706174636861626c652e204e6f7461626c792c25012069742077696c6c206e6f7420626520656d697474656420666f72207374616b696e672072657761726473207768656e20746865792061726520616464656420746f207374616b652e20556e626f6e64656408244163636f756e7449641c42616c616e636504dc20416e206163636f756e742068617320756e626f6e646564207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d2457697468647261776e08244163636f756e7449641c42616c616e6365085d0120416e206163636f756e74206861732063616c6c6564206077697468647261775f756e626f6e6465646020616e642072656d6f76656420756e626f6e64696e67206368756e6b7320776f727468206042616c616e636560b02066726f6d2074686520756e6c6f636b696e672071756575652e205c5b73746173682c20616d6f756e745c5d184b69636b656408244163636f756e744964244163636f756e744964040d012041206e6f6d696e61746f7220686173206265656e206b69636b65642066726f6d20612076616c696461746f722e205c5b6e6f6d696e61746f722c2073746173685c5d143853657373696f6e735065724572613053657373696f6e496e64657810060000000470204e756d626572206f662073657373696f6e7320706572206572612e3c426f6e64696e674475726174696f6e20457261496e646578101c00000004e4204e756d626572206f6620657261732074686174207374616b65642066756e6473206d7573742072656d61696e20626f6e64656420666f722e48536c61736844656665724475726174696f6e20457261496e646578101b000000140101204e756d626572206f662065726173207468617420736c6173686573206172652064656665727265642062792c20616674657220636f6d7075746174696f6e2e00bc20546869732073686f756c64206265206c657373207468616e2074686520626f6e64696e67206475726174696f6e2e2d012053657420746f203020696620736c61736865732073686f756c64206265206170706c69656420696d6d6564696174656c792c20776974686f7574206f70706f7274756e69747920666f723820696e74657276656e74696f6e2e804d61784e6f6d696e61746f72526577617264656450657256616c696461746f720c753332100001000010f820546865206d6178696d756d206e756d626572206f66206e6f6d696e61746f727320726577617264656420666f7220656163682076616c696461746f722e00690120466f7220656163682076616c696461746f72206f6e6c79207468652060244d61784e6f6d696e61746f72526577617264656450657256616c696461746f72602062696767657374207374616b6572732063616e20636c61696d2101207468656972207265776172642e2054686973207573656420746f206c696d69742074686520692f6f20636f737420666f7220746865206e6f6d696e61746f72207061796f75742e384d61784e6f6d696e6174696f6e730c753332101000000004b4204d6178696d756d206e756d626572206f66206e6f6d696e6174696f6e7320706572206e6f6d696e61746f722e50344e6f74436f6e74726f6c6c65720468204e6f74206120636f6e74726f6c6c6572206163636f756e742e204e6f7453746173680454204e6f742061207374617368206163636f756e742e34416c7265616479426f6e646564046420537461736820697320616c726561647920626f6e6465642e34416c7265616479506169726564047820436f6e74726f6c6c657220697320616c7265616479207061697265642e30456d70747954617267657473046420546172676574732063616e6e6f7420626520656d7074792e384475706c6963617465496e6465780444204475706c696361746520696e6465782e44496e76616c6964536c617368496e646578048820536c617368207265636f726420696e646578206f7574206f6620626f756e64732e44496e73756666696369656e7456616c756504cc2043616e206e6f7420626f6e6420776974682076616c7565206c657373207468616e206d696e696d756d2062616c616e63652e304e6f4d6f72654368756e6b7304942043616e206e6f74207363686564756c65206d6f726520756e6c6f636b206368756e6b732e344e6f556e6c6f636b4368756e6b04a42043616e206e6f74207265626f6e6420776974686f757420756e6c6f636b696e67206368756e6b732e3046756e64656454617267657404cc20417474656d7074696e6720746f2074617267657420612073746173682074686174207374696c6c206861732066756e64732e48496e76616c6964457261546f526577617264045c20496e76616c69642065726120746f207265776172642e68496e76616c69644e756d6265724f664e6f6d696e6174696f6e73047c20496e76616c6964206e756d626572206f66206e6f6d696e6174696f6e732e484e6f74536f72746564416e64556e697175650484204974656d7320617265206e6f7420736f7274656420616e6420756e697175652e38416c7265616479436c61696d6564040d01205265776172647320666f72207468697320657261206861766520616c7265616479206265656e20636c61696d656420666f7220746869732076616c696461746f722e54496e636f7272656374486973746f7279446570746804c420496e636f72726563742070726576696f757320686973746f727920646570746820696e7075742070726f76696465642e58496e636f7272656374536c617368696e675370616e7304b420496e636f7272656374206e756d626572206f6620736c617368696e67207370616e732070726f76696465642e204261645374617465043d0120496e7465726e616c20737461746520686173206265636f6d6520736f6d65686f7720636f7272757074656420616e6420746865206f7065726174696f6e2063616e6e6f7420636f6e74696e75652e38546f6f4d616e7954617267657473049820546f6f206d616e79206e6f6d696e6174696f6e207461726765747320737570706c6965642e244261645461726765740441012041206e6f6d696e6174696f6e207461726765742077617320737570706c69656420746861742077617320626c6f636b6564206f72206f7468657277697365206e6f7420612076616c696461746f722e06204f6666656e63657301204f6666656e636573101c5265706f727473000105345265706f727449644f663c543ed04f6666656e636544657461696c733c543a3a4163636f756e7449642c20543a3a4964656e74696669636174696f6e5475706c653e00040004490120546865207072696d61727920737472756374757265207468617420686f6c647320616c6c206f6666656e6365207265636f726473206b65796564206279207265706f7274206964656e746966696572732e4044656665727265644f6666656e6365730100645665633c44656665727265644f6666656e63654f663c543e3e0400086501204465666572726564207265706f72747320746861742068617665206265656e2072656a656374656420627920746865206f6666656e63652068616e646c657220616e64206e65656420746f206265207375626d6974746564442061742061206c617465722074696d652e58436f6e63757272656e745265706f727473496e646578010205104b696e64384f706171756554696d65536c6f74485665633c5265706f727449644f663c543e3e050400042901204120766563746f72206f66207265706f727473206f66207468652073616d65206b696e6420746861742068617070656e6564206174207468652073616d652074696d6520736c6f742e485265706f72747342794b696e64496e646578010105104b696e641c5665633c75383e00040018110120456e756d65726174657320616c6c207265706f727473206f662061206b696e6420616c6f6e672077697468207468652074696d6520746865792068617070656e65642e00bc20416c6c207265706f7274732061726520736f72746564206279207468652074696d65206f66206f6666656e63652e004901204e6f74652074686174207468652061637475616c2074797065206f662074686973206d617070696e6720697320605665633c75383e602c207468697320697320626563617573652076616c756573206f66690120646966666572656e7420747970657320617265206e6f7420737570706f7274656420617420746865206d6f6d656e7420736f2077652061726520646f696e6720746865206d616e75616c2073657269616c697a6174696f6e2e010001041c4f6666656e63650c104b696e64384f706171756554696d65536c6f7410626f6f6c10550120546865726520697320616e206f6666656e6365207265706f72746564206f662074686520676976656e20606b696e64602068617070656e656420617420746865206073657373696f6e5f696e6465786020616e644d0120286b696e642d7370656369666963292074696d6520736c6f742e2054686973206576656e74206973206e6f74206465706f736974656420666f72206475706c696361746520736c61736865732e206c617374190120656c656d656e7420696e64696361746573206f6620746865206f6666656e636520776173206170706c69656420287472756529206f7220717565756564202866616c73652974205c5b6b696e642c2074696d65736c6f742c206170706c6965645c5d2e00000728486973746f726963616c0000000000221c53657373696f6e011c53657373696f6e1c2856616c696461746f727301004c5665633c543a3a56616c696461746f7249643e0400047c205468652063757272656e7420736574206f662076616c696461746f72732e3043757272656e74496e64657801003053657373696f6e496e646578100000000004782043757272656e7420696e646578206f66207468652073657373696f6e2e345175657565644368616e676564010010626f6f6c040008390120547275652069662074686520756e6465726c79696e672065636f6e6f6d6963206964656e746974696573206f7220776569676874696e6720626568696e64207468652076616c696461746f7273a420686173206368616e67656420696e20746865207175657565642076616c696461746f72207365742e285175657565644b6579730100785665633c28543a3a56616c696461746f7249642c20543a3a4b657973293e0400083d012054686520717565756564206b65797320666f7220746865206e6578742073657373696f6e2e205768656e20746865206e6578742073657373696f6e20626567696e732c207468657365206b657973e02077696c6c206265207573656420746f2064657465726d696e65207468652076616c696461746f7227732073657373696f6e206b6579732e4844697361626c656456616c696461746f72730100205665633c7533323e04000c8020496e6469636573206f662064697361626c65642076616c696461746f72732e003501205468652073657420697320636c6561726564207768656e20606f6e5f73657373696f6e5f656e64696e67602072657475726e732061206e657720736574206f66206964656e7469746965732e204e6578744b65797300010538543a3a56616c696461746f7249641c543a3a4b657973000400049c20546865206e6578742073657373696f6e206b65797320666f7220612076616c696461746f722e204b65794f776e657200010550284b65795479706549642c205665633c75383e2938543a3a56616c696461746f72496400040004090120546865206f776e6572206f662061206b65792e20546865206b65792069732074686520604b657954797065496460202b2074686520656e636f646564206b65792e0108207365745f6b65797308106b6579731c543a3a4b6579731470726f6f661c5665633c75383e38e82053657473207468652073657373696f6e206b6579287329206f66207468652066756e6374696f6e2063616c6c657220746f20606b657973602e210120416c6c6f777320616e206163636f756e7420746f20736574206974732073657373696f6e206b6579207072696f7220746f206265636f6d696e6720612076616c696461746f722ec4205468697320646f65736e27742074616b652065666665637420756e74696c20746865206e6578742073657373696f6e2e00d420546865206469737061746368206f726967696e206f6620746869732066756e6374696f6e206d757374206265207369676e65642e002c2023203c7765696768743e54202d20436f6d706c65786974793a20604f28312960590120202041637475616c20636f737420646570656e6473206f6e20746865206e756d626572206f66206c656e677468206f662060543a3a4b6579733a3a6b65795f6964732829602077686963682069732066697865642ef0202d20446252656164733a20606f726967696e206163636f756e74602c2060543a3a56616c696461746f7249644f66602c20604e6578744b65797360a4202d2044625772697465733a20606f726967696e206163636f756e74602c20604e6578744b6579736084202d204462526561647320706572206b65792069643a20604b65794f776e65726088202d20446257726974657320706572206b65792069643a20604b65794f776e657260302023203c2f7765696768743e2870757267655f6b6579730030cc2052656d6f76657320616e792073657373696f6e206b6579287329206f66207468652066756e6374696f6e2063616c6c65722ec4205468697320646f65736e27742074616b652065666665637420756e74696c20746865206e6578742073657373696f6e2e00d420546865206469737061746368206f726967696e206f6620746869732066756e6374696f6e206d757374206265207369676e65642e002c2023203c7765696768743eb4202d20436f6d706c65786974793a20604f2831296020696e206e756d626572206f66206b65792074797065732e590120202041637475616c20636f737420646570656e6473206f6e20746865206e756d626572206f66206c656e677468206f662060543a3a4b6579733a3a6b65795f6964732829602077686963682069732066697865642ef0202d20446252656164733a2060543a3a56616c696461746f7249644f66602c20604e6578744b657973602c20606f726967696e206163636f756e7460a4202d2044625772697465733a20604e6578744b657973602c20606f726967696e206163636f756e74608c202d20446257726974657320706572206b65792069643a20604b65794f776e64657260302023203c2f7765696768743e0104284e657753657373696f6e043053657373696f6e496e646578086501204e65772073657373696f6e206861732068617070656e65642e204e6f746520746861742074686520617267756d656e7420697320746865205c5b73657373696f6e5f696e6465785c5d2c206e6f742074686520626c6f636b88206e756d626572206173207468652074797065206d6967687420737567676573742e001430496e76616c696450726f6f66046420496e76616c6964206f776e6572736869702070726f6f662e5c4e6f4173736f63696174656456616c696461746f72496404a0204e6f206173736f6369617465642076616c696461746f7220494420666f72206163636f756e742e344475706c6963617465644b657904682052656769737465726564206475706c6963617465206b65792e184e6f4b65797304a8204e6f206b65797320617265206173736f63696174656420776974682074686973206163636f756e742e244e6f4163636f756e74041d01204b65792073657474696e67206163636f756e74206973206e6f74206c6976652c20736f206974277320696d706f737369626c6520746f206173736f6369617465206b6579732e081c4772616e647061013c4772616e64706146696e616c6974791814537461746501006c53746f72656453746174653c543a3a426c6f636b4e756d6265723e04000490205374617465206f66207468652063757272656e7420617574686f72697479207365742e3450656e64696e674368616e676500008c53746f72656450656e64696e674368616e67653c543a3a426c6f636b4e756d6265723e040004c42050656e64696e67206368616e67653a20287369676e616c65642061742c207363686564756c6564206368616e6765292e284e657874466f72636564000038543a3a426c6f636b4e756d626572040004bc206e65787420626c6f636b206e756d6265722077686572652077652063616e20666f7263652061206368616e67652e1c5374616c6c656400008028543a3a426c6f636b4e756d6265722c20543a3a426c6f636b4e756d626572290400049020607472756560206966207765206172652063757272656e746c79207374616c6c65642e3043757272656e7453657449640100145365744964200000000000000000085d0120546865206e756d626572206f66206368616e6765732028626f746820696e207465726d73206f66206b65797320616e6420756e6465726c79696e672065636f6e6f6d696320726573706f6e736962696c697469657329c420696e20746865202273657422206f66204772616e6470612076616c696461746f72732066726f6d2067656e657369732e30536574496453657373696f6e0001051453657449643053657373696f6e496e6465780004001059012041206d617070696e672066726f6d206772616e6470612073657420494420746f2074686520696e646578206f6620746865202a6d6f737420726563656e742a2073657373696f6e20666f722077686963682069747368206d656d62657273207765726520726573706f6e7369626c652e00b82054574f582d4e4f54453a2060536574496460206973206e6f7420756e646572207573657220636f6e74726f6c2e010c4c7265706f72745f65717569766f636174696f6e084865717569766f636174696f6e5f70726f6f66a845717569766f636174696f6e50726f6f663c543a3a486173682c20543a3a426c6f636b4e756d6265723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66100d01205265706f727420766f7465722065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c2076657269667920746865f82065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66fc20616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e6365482077696c6c206265207265706f727465642e707265706f72745f65717569766f636174696f6e5f756e7369676e6564084865717569766f636174696f6e5f70726f6f66a845717569766f636174696f6e50726f6f663c543a3a486173682c20543a3a426c6f636b4e756d6265723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66240d01205265706f727420766f7465722065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c2076657269667920746865f82065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66fc20616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e6365482077696c6c206265207265706f727465642e00110120546869732065787472696e736963206d7573742062652063616c6c656420756e7369676e656420616e642069742069732065787065637465642074686174206f6e6c79190120626c6f636b20617574686f72732077696c6c2063616c6c206974202876616c69646174656420696e206056616c6964617465556e7369676e656460292c206173207375636819012069662074686520626c6f636b20617574686f7220697320646566696e65642069742077696c6c20626520646566696e6564206173207468652065717569766f636174696f6e28207265706f727465722e306e6f74655f7374616c6c6564081464656c617938543a3a426c6f636b4e756d6265726c626573745f66696e616c697a65645f626c6f636b5f6e756d62657238543a3a426c6f636b4e756d6265721c1d01204e6f74652074686174207468652063757272656e7420617574686f7269747920736574206f6620746865204752414e4450412066696e616c69747920676164676574206861732901207374616c6c65642e20546869732077696c6c2074726967676572206120666f7263656420617574686f7269747920736574206368616e67652061742074686520626567696e6e696e672101206f6620746865206e6578742073657373696f6e2c20746f20626520656e6163746564206064656c61796020626c6f636b7320616674657220746861742e205468652064656c617915012073686f756c64206265206869676820656e6f75676820746f20736166656c7920617373756d6520746861742074686520626c6f636b207369676e616c6c696e6720746865290120666f72636564206368616e67652077696c6c206e6f742062652072652d6f726765642028652e672e203130303020626c6f636b73292e20546865204752414e44504120766f7465727329012077696c6c20737461727420746865206e657720617574686f7269747920736574207573696e672074686520676976656e2066696e616c697a656420626c6f636b20617320626173652e5c204f6e6c792063616c6c61626c6520627920726f6f742e010c384e6577417574686f7269746965730434417574686f726974794c69737404d8204e657720617574686f726974792073657420686173206265656e206170706c6965642e205c5b617574686f726974795f7365745c5d1850617573656400049c2043757272656e7420617574686f726974792073657420686173206265656e207061757365642e1c526573756d65640004a02043757272656e7420617574686f726974792073657420686173206265656e20726573756d65642e001c2c50617573654661696c656408090120417474656d707420746f207369676e616c204752414e445041207061757365207768656e2074686520617574686f72697479207365742069736e2774206c697665a8202865697468657220706175736564206f7220616c72656164792070656e64696e67207061757365292e30526573756d654661696c656408150120417474656d707420746f207369676e616c204752414e44504120726573756d65207768656e2074686520617574686f72697479207365742069736e277420706175736564a42028656974686572206c697665206f7220616c72656164792070656e64696e6720726573756d65292e344368616e676550656e64696e6704ec20417474656d707420746f207369676e616c204752414e445041206368616e67652077697468206f6e6520616c72656164792070656e64696e672e1c546f6f536f6f6e04c02043616e6e6f74207369676e616c20666f72636564206368616e676520736f20736f6f6e206166746572206c6173742e60496e76616c69644b65794f776e65727368697050726f6f660435012041206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c696445717569766f636174696f6e50726f6f6604350120416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f7274041901204120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e0a20496d4f6e6c696e650120496d4f6e6c696e6510384865617274626561744166746572010038543a3a426c6f636b4e756d62657210000000002c1d012054686520626c6f636b206e756d6265722061667465722077686963682069742773206f6b20746f2073656e64206865617274626561747320696e207468652063757272656e74242073657373696f6e2e0025012041742074686520626567696e6e696e67206f6620656163682073657373696f6e20776520736574207468697320746f20612076616c756520746861742073686f756c642066616c6c350120726f7567686c7920696e20746865206d6964646c65206f66207468652073657373696f6e206475726174696f6e2e20546865206964656120697320746f206669727374207761697420666f721901207468652076616c696461746f727320746f2070726f64756365206120626c6f636b20696e207468652063757272656e742073657373696f6e2c20736f207468617420746865a820686561727462656174206c61746572206f6e2077696c6c206e6f74206265206e65636573736172792e00390120546869732076616c75652077696c6c206f6e6c79206265207573656420617320612066616c6c6261636b206966207765206661696c20746f2067657420612070726f7065722073657373696f6e2d012070726f677265737320657374696d6174652066726f6d20604e65787453657373696f6e526f746174696f6e602c2061732074686f736520657374696d617465732073686f756c642062650101206d6f7265206163637572617465207468656e207468652076616c75652077652063616c63756c61746520666f7220604865617274626561744166746572602e104b65797301004c5665633c543a3a417574686f7269747949643e040004d0205468652063757272656e7420736574206f66206b6579732074686174206d61792069737375652061206865617274626561742e485265636569766564486561727462656174730002053053657373696f6e496e6465782441757468496e6465781c5665633c75383e05040008f020466f7220656163682073657373696f6e20696e6465782c207765206b6565702061206d617070696e67206f66206041757468496e6465786020746f8020606f6666636861696e3a3a4f70617175654e6574776f726b5374617465602e38417574686f726564426c6f636b730102053053657373696f6e496e6465783856616c696461746f7249643c543e0c75333205100000000008150120466f7220656163682073657373696f6e20696e6465782c207765206b6565702061206d617070696e67206f66206056616c696461746f7249643c543e6020746f20746865c8206e756d626572206f6620626c6f636b7320617574686f7265642062792074686520676976656e20617574686f726974792e0104246865617274626561740824686561727462656174644865617274626561743c543a3a426c6f636b4e756d6265723e285f7369676e6174757265bc3c543a3a417574686f7269747949642061732052756e74696d654170705075626c69633e3a3a5369676e6174757265242c2023203c7765696768743e4101202d20436f6d706c65786974793a20604f284b202b20452960207768657265204b206973206c656e677468206f6620604b6579736020286865617274626561742e76616c696461746f72735f6c656e290101202020616e642045206973206c656e677468206f6620606865617274626561742e6e6574776f726b5f73746174652e65787465726e616c5f61646472657373608c2020202d20604f284b29603a206465636f64696e67206f66206c656e67746820604b60b02020202d20604f284529603a206465636f64696e672f656e636f64696e67206f66206c656e677468206045603d01202d20446252656164733a2070616c6c65745f73657373696f6e206056616c696461746f7273602c2070616c6c65745f73657373696f6e206043757272656e74496e646578602c20604b657973602c5c202020605265636569766564486561727462656174736084202d2044625772697465733a206052656365697665644865617274626561747360302023203c2f7765696768743e010c444865617274626561745265636569766564042c417574686f7269747949640405012041206e657720686561727462656174207761732072656365697665642066726f6d2060417574686f72697479496460205c5b617574686f726974795f69645c5d1c416c6c476f6f640004d42041742074686520656e64206f66207468652073657373696f6e2c206e6f206f6666656e63652077617320636f6d6d69747465642e2c536f6d654f66666c696e6504605665633c4964656e74696669636174696f6e5475706c653e043d012041742074686520656e64206f66207468652073657373696f6e2c206174206c65617374206f6e652076616c696461746f722077617320666f756e6420746f206265205c5b6f66666c696e655c5d2e000828496e76616c69644b65790464204e6f6e206578697374656e74207075626c6963206b65792e4c4475706c6963617465644865617274626561740458204475706c696361746564206865617274626561742e0b48417574686f72697479446973636f766572790001000000000c2444656d6f6372616379012444656d6f6372616379383c5075626c696350726f70436f756e7401002450726f70496e646578100000000004f420546865206e756d626572206f6620287075626c6963292070726f706f73616c7320746861742068617665206265656e206d61646520736f206661722e2c5075626c696350726f707301009c5665633c2850726f70496e6465782c20543a3a486173682c20543a3a4163636f756e744964293e040004210120546865207075626c69632070726f706f73616c732e20556e736f727465642e20546865207365636f6e64206974656d206973207468652070726f706f73616c277320686173682e244465706f7369744f660001052450726f70496e64657884285665633c543a3a4163636f756e7449643e2c2042616c616e63654f663c543e290004000c842054686f73652077686f2068617665206c6f636b65642061206465706f7369742e00d82054574f582d4e4f54453a20536166652c20617320696e6372656173696e6720696e7465676572206b6579732061726520736166652e24507265696d616765730001061c543a3a48617368e8507265696d6167655374617475733c543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e000400086101204d6170206f662068617368657320746f207468652070726f706f73616c20707265696d6167652c20616c6f6e6720776974682077686f207265676973746572656420697420616e64207468656972206465706f7369742ee42054686520626c6f636b206e756d6265722069732074686520626c6f636b20617420776869636820697420776173206465706f73697465642e3c5265666572656e64756d436f756e7401003c5265666572656e64756d496e646578100000000004310120546865206e6578742066726565207265666572656e64756d20696e6465782c20616b6120746865206e756d626572206f66207265666572656e6461207374617274656420736f206661722e344c6f77657374556e62616b656401003c5265666572656e64756d496e646578100000000008250120546865206c6f77657374207265666572656e64756d20696e64657820726570726573656e74696e6720616e20756e62616b6564207265666572656e64756d2e20457175616c20746fdc20605265666572656e64756d436f756e74602069662074686572652069736e2774206120756e62616b6564207265666572656e64756d2e405265666572656e64756d496e666f4f660001053c5265666572656e64756d496e646578d45265666572656e64756d496e666f3c543a3a426c6f636b4e756d6265722c20543a3a486173682c2042616c616e63654f663c543e3e0004000cb420496e666f726d6174696f6e20636f6e6365726e696e6720616e7920676976656e207265666572656e64756d2e0009012054574f582d4e4f54453a205341464520617320696e646578657320617265206e6f7420756e64657220616e2061747461636b6572e280997320636f6e74726f6c2e20566f74696e674f6601010530543a3a4163636f756e744964c8566f74696e673c42616c616e63654f663c543e2c20543a3a4163636f756e7449642c20543a3a426c6f636b4e756d6265723e00d8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000105d0120416c6c20766f74657320666f72206120706172746963756c617220766f7465722e2057652073746f7265207468652062616c616e636520666f7220746865206e756d626572206f6620766f74657320746861742077655d012068617665207265636f726465642e20546865207365636f6e64206974656d2069732074686520746f74616c20616d6f756e74206f662064656c65676174696f6e732c20746861742077696c6c2062652061646465642e00e82054574f582d4e4f54453a205341464520617320604163636f756e7449646073206172652063727970746f2068617368657320616e797761792e144c6f636b7300010530543a3a4163636f756e74496438543a3a426c6f636b4e756d626572000400105d01204163636f756e747320666f7220776869636820746865726520617265206c6f636b7320696e20616374696f6e207768696368206d61792062652072656d6f76656420617420736f6d6520706f696e7420696e207468655101206675747572652e205468652076616c75652069732074686520626c6f636b206e756d62657220617420776869636820746865206c6f636b206578706972657320616e64206d61792062652072656d6f7665642e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e544c6173745461626c656457617345787465726e616c010010626f6f6c0400085901205472756520696620746865206c617374207265666572656e64756d207461626c656420776173207375626d69747465642065787465726e616c6c792e2046616c7365206966206974207761732061207075626c6963282070726f706f73616c2e304e65787445787465726e616c00006028543a3a486173682c20566f74655468726573686f6c6429040010590120546865207265666572656e64756d20746f206265207461626c6564207768656e6576657220697420776f756c642062652076616c696420746f207461626c6520616e2065787465726e616c2070726f706f73616c2e550120546869732068617070656e73207768656e2061207265666572656e64756d206e6565647320746f206265207461626c656420616e64206f6e65206f662074776f20636f6e646974696f6e7320617265206d65743aa4202d20604c6173745461626c656457617345787465726e616c60206973206066616c7365603b206f7268202d20605075626c696350726f70736020697320656d7074792e24426c61636b6c6973740001061c543a3a486173688c28543a3a426c6f636b4e756d6265722c205665633c543a3a4163636f756e7449643e290004000851012041207265636f7264206f662077686f207665746f656420776861742e204d6170732070726f706f73616c206861736820746f206120706f737369626c65206578697374656e7420626c6f636b206e756d626572e82028756e74696c207768656e206974206d6179206e6f742062652072657375626d69747465642920616e642077686f207665746f65642069742e3443616e63656c6c6174696f6e730101061c543a3a4861736810626f6f6c000400042901205265636f7264206f6620616c6c2070726f706f73616c7320746861742068617665206265656e207375626a65637420746f20656d657267656e63792063616e63656c6c6174696f6e2e3853746f7261676556657273696f6e00002052656c656173657304000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e0098204e6577206e6574776f726b732073746172742077697468206c6173742076657273696f6e2e01641c70726f706f7365083470726f706f73616c5f686173681c543a3a486173681476616c756554436f6d706163743c42616c616e63654f663c543e3e2ca02050726f706f736520612073656e73697469766520616374696f6e20746f2062652074616b656e2e00190120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573748420686176652066756e647320746f20636f76657220746865206465706f7369742e00d8202d206070726f706f73616c5f68617368603a205468652068617368206f66207468652070726f706f73616c20707265696d6167652e1901202d206076616c7565603a2054686520616d6f756e74206f66206465706f73697420286d757374206265206174206c6561737420604d696e696d756d4465706f73697460292e004820456d697473206050726f706f736564602e003c205765696768743a20604f28702960187365636f6e64082070726f706f73616c48436f6d706163743c50726f70496e6465783e4c7365636f6e64735f75707065725f626f756e6430436f6d706163743c7533323e28b8205369676e616c732061677265656d656e742077697468206120706172746963756c61722070726f706f73616c2e00050120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e6465721501206d75737420686176652066756e647320746f20636f76657220746865206465706f7369742c20657175616c20746f20746865206f726967696e616c206465706f7369742e00cc202d206070726f706f73616c603a2054686520696e646578206f66207468652070726f706f73616c20746f207365636f6e642e4501202d20607365636f6e64735f75707065725f626f756e64603a20616e20757070657220626f756e64206f6e207468652063757272656e74206e756d626572206f66207365636f6e6473206f6e2074686973290120202070726f706f73616c2e2045787472696e736963206973207765696768746564206163636f7264696e6720746f20746869732076616c75652077697468206e6f20726566756e642e002101205765696768743a20604f28532960207768657265205320697320746865206e756d626572206f66207365636f6e647320612070726f706f73616c20616c7265616479206861732e10766f746508247265665f696e64657860436f6d706163743c5265666572656e64756d496e6465783e10766f7465644163636f756e74566f74653c42616c616e63654f663c543e3e24350120566f746520696e2061207265666572656e64756d2e2049662060766f74652e69735f6179652829602c2074686520766f746520697320746f20656e616374207468652070726f706f73616c3bbc206f7468657277697365206974206973206120766f746520746f206b65657020746865207374617475732071756f2e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00e0202d20607265665f696e646578603a2054686520696e646578206f6620746865207265666572656e64756d20746f20766f746520666f722e88202d2060766f7465603a2054686520766f746520636f6e66696775726174696f6e2e003101205765696768743a20604f28522960207768657265205220697320746865206e756d626572206f66207265666572656e64756d732074686520766f7465722068617320766f746564206f6e2e40656d657267656e63795f63616e63656c04247265665f696e6465783c5265666572656e64756d496e646578205101205363686564756c6520616e20656d657267656e63792063616e63656c6c6174696f6e206f662061207265666572656e64756d2e2043616e6e6f742068617070656e20747769636520746f207468652073616d6530207265666572656e64756d2e00fc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265206043616e63656c6c6174696f6e4f726967696e602e00d4202d607265665f696e646578603a2054686520696e646578206f6620746865207265666572656e64756d20746f2063616e63656c2e0040205765696768743a20604f283129602e4065787465726e616c5f70726f706f7365043470726f706f73616c5f686173681c543a3a48617368243101205363686564756c652061207265666572656e64756d20746f206265207461626c6564206f6e6365206974206973206c6567616c20746f207363686564756c6520616e2065787465726e616c30207265666572656e64756d2e00ec20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265206045787465726e616c4f726967696e602e00d8202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f66207468652070726f706f73616c2e001901205765696768743a20604f2856296020776974682056206e756d626572206f66207665746f65727320696e2074686520626c61636b6c697374206f662070726f706f73616c2ebc2020204465636f64696e6720766563206f66206c656e67746820562e2043686172676564206173206d6178696d756d6465787465726e616c5f70726f706f73655f6d616a6f72697479043470726f706f73616c5f686173681c543a3a486173682c5901205363686564756c652061206d616a6f726974792d63617272696573207265666572656e64756d20746f206265207461626c6564206e657874206f6e6365206974206973206c6567616c20746f207363686564756c656020616e2065787465726e616c207265666572656e64756d2e00f020546865206469737061746368206f6620746869732063616c6c206d757374206265206045787465726e616c4d616a6f726974794f726967696e602e00d8202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f66207468652070726f706f73616c2e004d0120556e6c696b65206065787465726e616c5f70726f706f7365602c20626c61636b6c697374696e6720686173206e6f20656666656374206f6e207468697320616e64206974206d6179207265706c61636520619c207072652d7363686564756c6564206065787465726e616c5f70726f706f7365602063616c6c2e003c205765696768743a20604f283129606065787465726e616c5f70726f706f73655f64656661756c74043470726f706f73616c5f686173681c543a3a486173682c4901205363686564756c652061206e656761746976652d7475726e6f75742d62696173207265666572656e64756d20746f206265207461626c6564206e657874206f6e6365206974206973206c6567616c20746f84207363686564756c6520616e2065787465726e616c207265666572656e64756d2e00ec20546865206469737061746368206f6620746869732063616c6c206d757374206265206045787465726e616c44656661756c744f726967696e602e00d8202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f66207468652070726f706f73616c2e004d0120556e6c696b65206065787465726e616c5f70726f706f7365602c20626c61636b6c697374696e6720686173206e6f20656666656374206f6e207468697320616e64206974206d6179207265706c61636520619c207072652d7363686564756c6564206065787465726e616c5f70726f706f7365602063616c6c2e003c205765696768743a20604f2831296028666173745f747261636b0c3470726f706f73616c5f686173681c543a3a4861736834766f74696e675f706572696f6438543a3a426c6f636b4e756d6265721464656c617938543a3a426c6f636b4e756d6265723c5101205363686564756c65207468652063757272656e746c792065787465726e616c6c792d70726f706f736564206d616a6f726974792d63617272696573207265666572656e64756d20746f206265207461626c6564650120696d6d6564696174656c792e204966207468657265206973206e6f2065787465726e616c6c792d70726f706f736564207265666572656e64756d2063757272656e746c792c206f72206966207468657265206973206f6e65ec20627574206974206973206e6f742061206d616a6f726974792d63617272696573207265666572656e64756d207468656e206974206661696c732e00d420546865206469737061746368206f6620746869732063616c6c206d757374206265206046617374547261636b4f726967696e602e00f8202d206070726f706f73616c5f68617368603a205468652068617368206f66207468652063757272656e742065787465726e616c2070726f706f73616c2e6101202d2060766f74696e675f706572696f64603a2054686520706572696f64207468617420697320616c6c6f77656420666f7220766f74696e67206f6e20746869732070726f706f73616c2e20496e6372656173656420746f982020206046617374547261636b566f74696e67506572696f646020696620746f6f206c6f772e5501202d206064656c6179603a20546865206e756d626572206f6620626c6f636b20616674657220766f74696e672068617320656e64656420696e20617070726f76616c20616e6420746869732073686f756c64206265bc202020656e61637465642e205468697320646f65736e277420686176652061206d696e696d756d20616d6f756e742e004420456d697473206053746172746564602e003c205765696768743a20604f28312960347665746f5f65787465726e616c043470726f706f73616c5f686173681c543a3a4861736824bc205665746f20616e6420626c61636b6c697374207468652065787465726e616c2070726f706f73616c20686173682e00dc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d75737420626520605665746f4f726967696e602e003101202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f66207468652070726f706f73616c20746f207665746f20616e6420626c61636b6c6973742e004020456d69747320605665746f6564602e000101205765696768743a20604f2856202b206c6f6728562929602077686572652056206973206e756d626572206f6620606578697374696e67207665746f657273604463616e63656c5f7265666572656e64756d04247265665f696e64657860436f6d706163743c5265666572656e64756d496e6465783e1c542052656d6f76652061207265666572656e64756d2e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f526f6f745f2e00d8202d20607265665f696e646578603a2054686520696e646578206f6620746865207265666572656e64756d20746f2063616e63656c2e00482023205765696768743a20604f283129602e3463616e63656c5f717565756564041477686963683c5265666572656e64756d496e6465781ca02043616e63656c20612070726f706f73616c2071756575656420666f7220656e6163746d656e742e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f526f6f745f2e00c8202d20607768696368603a2054686520696e646578206f6620746865207265666572656e64756d20746f2063616e63656c2e004d01205765696768743a20604f284429602077686572652060446020697320746865206974656d7320696e207468652064697370617463682071756575652e205765696768746564206173206044203d203130602e2064656c65676174650c08746f30543a3a4163636f756e74496428636f6e76696374696f6e28436f6e76696374696f6e1c62616c616e63653042616c616e63654f663c543e503d012044656c65676174652074686520766f74696e6720706f77657220287769746820736f6d6520676976656e20636f6e76696374696f6e29206f66207468652073656e64696e67206163636f756e742e005901205468652062616c616e63652064656c656761746564206973206c6f636b656420666f72206173206c6f6e6720617320697427732064656c6567617465642c20616e64207468657265616674657220666f7220746865cc2074696d6520617070726f70726961746520666f722074686520636f6e76696374696f6e2773206c6f636b20706572696f642e00610120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2c20616e6420746865207369676e696e67206163636f756e74206d757374206569746865723a782020202d2062652064656c65676174696e6720616c72656164793b206f725d012020202d2068617665206e6f20766f74696e67206163746976697479202869662074686572652069732c207468656e2069742077696c6c206e65656420746f2062652072656d6f7665642f636f6e736f6c6964617465649820202020207468726f7567682060726561705f766f746560206f722060756e766f746560292e004901202d2060746f603a20546865206163636f756e742077686f736520766f74696e6720746865206074617267657460206163636f756e74277320766f74696e6720706f7765722077696c6c20666f6c6c6f772e5901202d2060636f6e76696374696f6e603a2054686520636f6e76696374696f6e20746861742077696c6c20626520617474616368656420746f207468652064656c65676174656420766f7465732e205768656e2074686545012020206163636f756e7420697320756e64656c6567617465642c207468652066756e64732077696c6c206265206c6f636b656420666f722074686520636f72726573706f6e64696e6720706572696f642e5501202d206062616c616e6365603a2054686520616d6f756e74206f6620746865206163636f756e7427732062616c616e636520746f206265207573656420696e2064656c65676174696e672e2054686973206d757374c82020206e6f74206265206d6f7265207468616e20746865206163636f756e7427732063757272656e742062616c616e63652e004c20456d697473206044656c656761746564602e004101205765696768743a20604f28522960207768657265205220697320746865206e756d626572206f66207265666572656e64756d732074686520766f7465722064656c65676174696e6720746f20686173cc202020766f746564206f6e2e205765696768742069732063686172676564206173206966206d6178696d756d20766f7465732e28756e64656c65676174650030d020556e64656c65676174652074686520766f74696e6720706f776572206f66207468652073656e64696e67206163636f756e742e00610120546f6b656e73206d617920626520756e6c6f636b656420666f6c6c6f77696e67206f6e636520616e20616d6f756e74206f662074696d6520636f6e73697374656e74207769746820746865206c6f636b20706572696f64e0206f662074686520636f6e76696374696f6e2077697468207768696368207468652064656c65676174696f6e20776173206973737565642e00490120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d757374206265582063757272656e746c792064656c65676174696e672e005420456d6974732060556e64656c656761746564602e004101205765696768743a20604f28522960207768657265205220697320746865206e756d626572206f66207265666572656e64756d732074686520766f7465722064656c65676174696e6720746f20686173cc202020766f746564206f6e2e205765696768742069732063686172676564206173206966206d6178696d756d20766f7465732e58636c6561725f7075626c69635f70726f706f73616c7300147420436c6561727320616c6c207075626c69632070726f706f73616c732e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f526f6f745f2e0040205765696768743a20604f283129602e346e6f74655f707265696d6167650440656e636f6465645f70726f706f73616c1c5665633c75383e2861012052656769737465722074686520707265696d61676520666f7220616e207570636f6d696e672070726f706f73616c2e205468697320646f65736e27742072657175697265207468652070726f706f73616c20746f206265250120696e207468652064697370617463682071756575652062757420646f657320726571756972652061206465706f7369742c2072657475726e6564206f6e636520656e61637465642e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00c8202d2060656e636f6465645f70726f706f73616c603a2054686520707265696d616765206f6620612070726f706f73616c2e005c20456d6974732060507265696d6167654e6f746564602e005101205765696768743a20604f28452960207769746820452073697a65206f662060656e636f6465645f70726f706f73616c60202870726f7465637465642062792061207265717569726564206465706f736974292e646e6f74655f707265696d6167655f6f7065726174696f6e616c0440656e636f6465645f70726f706f73616c1c5665633c75383e040d012053616d6520617320606e6f74655f707265696d6167656020627574206f726967696e20697320604f7065726174696f6e616c507265696d6167654f726967696e602e586e6f74655f696d6d696e656e745f707265696d6167650440656e636f6465645f70726f706f73616c1c5665633c75383e3045012052656769737465722074686520707265696d61676520666f7220616e207570636f6d696e672070726f706f73616c2e2054686973207265717569726573207468652070726f706f73616c20746f206265410120696e207468652064697370617463682071756575652e204e6f206465706f736974206973206e65656465642e205768656e20746869732063616c6c206973207375636365737366756c2c20692e652e39012074686520707265696d61676520686173206e6f74206265656e2075706c6f61646564206265666f726520616e64206d61746368657320736f6d6520696d6d696e656e742070726f706f73616c2c40206e6f2066656520697320706169642e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00c8202d2060656e636f6465645f70726f706f73616c603a2054686520707265696d616765206f6620612070726f706f73616c2e005c20456d6974732060507265696d6167654e6f746564602e005101205765696768743a20604f28452960207769746820452073697a65206f662060656e636f6465645f70726f706f73616c60202870726f7465637465642062792061207265717569726564206465706f736974292e886e6f74655f696d6d696e656e745f707265696d6167655f6f7065726174696f6e616c0440656e636f6465645f70726f706f73616c1c5665633c75383e0431012053616d6520617320606e6f74655f696d6d696e656e745f707265696d6167656020627574206f726967696e20697320604f7065726174696f6e616c507265696d6167654f726967696e602e34726561705f707265696d616765083470726f706f73616c5f686173681c543a3a486173686070726f706f73616c5f6c656e5f75707065725f626f756e6430436f6d706163743c7533323e3cf42052656d6f766520616e20657870697265642070726f706f73616c20707265696d61676520616e6420636f6c6c65637420746865206465706f7369742e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00d0202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f6620612070726f706f73616c2e2d01202d206070726f706f73616c5f6c656e6774685f75707065725f626f756e64603a20616e20757070657220626f756e64206f6e206c656e677468206f66207468652070726f706f73616c2e010120202045787472696e736963206973207765696768746564206163636f7264696e6720746f20746869732076616c75652077697468206e6f20726566756e642e00510120546869732077696c6c206f6e6c7920776f726b2061667465722060566f74696e67506572696f646020626c6f636b732066726f6d207468652074696d6520746861742074686520707265696d616765207761735d01206e6f7465642c2069662069742773207468652073616d65206163636f756e7420646f696e672069742e2049662069742773206120646966666572656e74206163636f756e742c207468656e206974276c6c206f6e6c79b020776f726b20616e206164646974696f6e616c2060456e6163746d656e74506572696f6460206c617465722e006020456d6974732060507265696d616765526561706564602e00b8205765696768743a20604f284429602077686572652044206973206c656e677468206f662070726f706f73616c2e18756e6c6f636b041874617267657430543a3a4163636f756e7449641ca420556e6c6f636b20746f6b656e732074686174206861766520616e2065787069726564206c6f636b2e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00bc202d2060746172676574603a20546865206163636f756e7420746f2072656d6f766520746865206c6f636b206f6e2e00c0205765696768743a20604f2852296020776974682052206e756d626572206f6620766f7465206f66207461726765742e2c72656d6f76655f766f74650414696e6465783c5265666572656e64756d496e6465786c802052656d6f7665206120766f746520666f722061207265666572656e64756d2e00102049663a8c202d20746865207265666572656e64756d207761732063616e63656c6c65642c206f7280202d20746865207265666572656e64756d206973206f6e676f696e672c206f7294202d20746865207265666572656e64756d2068617320656e6465642073756368207468617401012020202d2074686520766f7465206f6620746865206163636f756e742077617320696e206f70706f736974696f6e20746f2074686520726573756c743b206f72d82020202d20746865726520776173206e6f20636f6e76696374696f6e20746f20746865206163636f756e74277320766f74653b206f72882020202d20746865206163636f756e74206d61646520612073706c697420766f74656101202e2e2e7468656e2074686520766f74652069732072656d6f76656420636c65616e6c7920616e64206120666f6c6c6f77696e672063616c6c20746f2060756e6c6f636b60206d617920726573756c7420696e206d6f72655c2066756e6473206265696e6720617661696c61626c652e00ac2049662c20686f77657665722c20746865207265666572656e64756d2068617320656e64656420616e643af0202d2069742066696e697368656420636f72726573706f6e64696e6720746f2074686520766f7465206f6620746865206163636f756e742c20616e64e0202d20746865206163636f756e74206d6164652061207374616e6461726420766f7465207769746820636f6e76696374696f6e2c20616e64c0202d20746865206c6f636b20706572696f64206f662074686520636f6e76696374696f6e206973206e6f74206f7665725d01202e2e2e7468656e20746865206c6f636b2077696c6c206265206167677265676174656420696e746f20746865206f766572616c6c206163636f756e742773206c6f636b2c207768696368206d617920696e766f6c76655d01202a6f7665726c6f636b696e672a20287768657265207468652074776f206c6f636b732061726520636f6d62696e656420696e746f20612073696e676c65206c6f636b207468617420697320746865206d6178696d756de8206f6620626f74682074686520616d6f756e74206c6f636b656420616e64207468652074696d65206973206974206c6f636b656420666f72292e004d0120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2c20616e6420746865207369676e6572206d7573742068617665206120766f74658c207265676973746572656420666f72207265666572656e64756d2060696e646578602e00f8202d2060696e646578603a2054686520696e646578206f66207265666572656e64756d206f662074686520766f746520746f2062652072656d6f7665642e005901205765696768743a20604f2852202b206c6f6720522960207768657265205220697320746865206e756d626572206f66207265666572656e646120746861742060746172676574602068617320766f746564206f6e2edc2020205765696768742069732063616c63756c6174656420666f7220746865206d6178696d756d206e756d626572206f6620766f74652e4472656d6f76655f6f746865725f766f7465081874617267657430543a3a4163636f756e74496414696e6465783c5265666572656e64756d496e6465783c802052656d6f7665206120766f746520666f722061207265666572656e64756d2e0051012049662074686520607461726765746020697320657175616c20746f20746865207369676e65722c207468656e20746869732066756e6374696f6e2069732065786163746c79206571756976616c656e7420746f3101206072656d6f76655f766f7465602e204966206e6f7420657175616c20746f20746865207369676e65722c207468656e2074686520766f7465206d757374206861766520657870697265642c590120656974686572206265636175736520746865207265666572656e64756d207761732063616e63656c6c65642c20626563617573652074686520766f746572206c6f737420746865207265666572656e64756d206f729c20626563617573652074686520636f6e76696374696f6e20706572696f64206973206f7665722e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e005101202d2060746172676574603a20546865206163636f756e74206f662074686520766f746520746f2062652072656d6f7665643b2074686973206163636f756e74206d757374206861766520766f74656420666f72582020207265666572656e64756d2060696e646578602ef8202d2060696e646578603a2054686520696e646578206f66207265666572656e64756d206f662074686520766f746520746f2062652072656d6f7665642e005901205765696768743a20604f2852202b206c6f6720522960207768657265205220697320746865206e756d626572206f66207265666572656e646120746861742060746172676574602068617320766f746564206f6e2edc2020205765696768742069732063616c63756c6174656420666f7220746865206d6178696d756d206e756d626572206f6620766f74652e38656e6163745f70726f706f73616c083470726f706f73616c5f686173681c543a3a4861736814696e6465783c5265666572656e64756d496e64657804510120456e61637420612070726f706f73616c2066726f6d2061207265666572656e64756d2e20466f72206e6f77207765206a757374206d616b65207468652077656967687420626520746865206d6178696d756d2e24626c61636b6c697374083470726f706f73616c5f686173681c543a3a486173683c6d617962655f7265665f696e6465785c4f7074696f6e3c5265666572656e64756d496e6465783e3c4901205065726d616e656e746c7920706c61636520612070726f706f73616c20696e746f2074686520626c61636b6c6973742e20546869732070726576656e74732069742066726f6d2065766572206265696e67402070726f706f73656420616761696e2e0055012049662063616c6c6564206f6e206120717565756564207075626c6963206f722065787465726e616c2070726f706f73616c2c207468656e20746869732077696c6c20726573756c7420696e206974206265696e6755012072656d6f7665642e2049662074686520607265665f696e6465786020737570706c69656420697320616e20616374697665207265666572656e64756d2077697468207468652070726f706f73616c20686173682c6c207468656e2069742077696c6c2062652063616e63656c6c65642e00f020546865206469737061746368206f726967696e206f6620746869732063616c6c206d7573742062652060426c61636b6c6973744f726967696e602e00fc202d206070726f706f73616c5f68617368603a205468652070726f706f73616c206861736820746f20626c61636b6c697374207065726d616e656e746c792e4901202d20607265665f696e646578603a20416e206f6e676f696e67207265666572656e64756d2077686f73652068617368206973206070726f706f73616c5f68617368602c2077686963682077696c6c2062652c2063616e63656c6c65642e004501205765696768743a20604f28702960202874686f756768206173207468697320697320616e20686967682d70726976696c6567652064697370617463682c20776520617373756d6520697420686173206154202020726561736f6e61626c652076616c7565292e3c63616e63656c5f70726f706f73616c042870726f705f696e64657848436f6d706163743c50726f70496e6465783e1c4c2052656d6f766520612070726f706f73616c2e00050120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265206043616e63656c50726f706f73616c4f726967696e602e00d4202d206070726f705f696e646578603a2054686520696e646578206f66207468652070726f706f73616c20746f2063616e63656c2e00e8205765696768743a20604f28702960207768657265206070203d205075626c696350726f70733a3a3c543e3a3a6465636f64655f6c656e28296001482050726f706f736564082450726f70496e6465781c42616c616e63650431012041206d6f74696f6e20686173206265656e2070726f706f7365642062792061207075626c6963206163636f756e742e205c5b70726f706f73616c5f696e6465782c206465706f7369745c5d185461626c65640c2450726f70496e6465781c42616c616e6365385665633c4163636f756e7449643e047d012041207075626c69632070726f706f73616c20686173206265656e207461626c656420666f72207265666572656e64756d20766f74652e205c5b70726f706f73616c5f696e6465782c206465706f7369742c206465706f7369746f72735c5d3845787465726e616c5461626c656400049820416e2065787465726e616c2070726f706f73616c20686173206265656e207461626c65642e1c53746172746564083c5265666572656e64756d496e64657834566f74655468726573686f6c6404c42041207265666572656e64756d2068617320626567756e2e205c5b7265665f696e6465782c207468726573686f6c645c5d18506173736564043c5265666572656e64756d496e64657804e820412070726f706f73616c20686173206265656e20617070726f766564206279207265666572656e64756d2e205c5b7265665f696e6465785c5d244e6f74506173736564043c5265666572656e64756d496e64657804e820412070726f706f73616c20686173206265656e2072656a6563746564206279207265666572656e64756d2e205c5b7265665f696e6465785c5d2443616e63656c6c6564043c5265666572656e64756d496e64657804bc2041207265666572656e64756d20686173206265656e2063616e63656c6c65642e205c5b7265665f696e6465785c5d204578656375746564083c5265666572656e64756d496e64657810626f6f6c04c820412070726f706f73616c20686173206265656e20656e61637465642e205c5b7265665f696e6465782c2069735f6f6b5c5d2444656c65676174656408244163636f756e744964244163636f756e74496404210120416e206163636f756e74206861732064656c65676174656420746865697220766f746520746f20616e6f74686572206163636f756e742e205c5b77686f2c207461726765745c5d2c556e64656c65676174656404244163636f756e74496404f820416e205c5b6163636f756e745c5d206861732063616e63656c6c656420612070726576696f75732064656c65676174696f6e206f7065726174696f6e2e185665746f65640c244163636f756e74496410486173682c426c6f636b4e756d62657204110120416e2065787465726e616c2070726f706f73616c20686173206265656e207665746f65642e205c5b77686f2c2070726f706f73616c5f686173682c20756e74696c5c5d34507265696d6167654e6f7465640c1048617368244163636f756e7449641c42616c616e636504610120412070726f706f73616c277320707265696d61676520776173206e6f7465642c20616e6420746865206465706f7369742074616b656e2e205c5b70726f706f73616c5f686173682c2077686f2c206465706f7369745c5d30507265696d616765557365640c1048617368244163636f756e7449641c42616c616e636508150120412070726f706f73616c20707265696d616765207761732072656d6f76656420616e6420757365642028746865206465706f736974207761732072657475726e6564292e94205c5b70726f706f73616c5f686173682c2070726f76696465722c206465706f7369745c5d3c507265696d616765496e76616c69640810486173683c5265666572656e64756d496e646578080d0120412070726f706f73616c20636f756c64206e6f7420626520657865637574656420626563617573652069747320707265696d6167652077617320696e76616c69642e74205c5b70726f706f73616c5f686173682c207265665f696e6465785c5d3c507265696d6167654d697373696e670810486173683c5265666572656e64756d496e646578080d0120412070726f706f73616c20636f756c64206e6f7420626520657865637574656420626563617573652069747320707265696d61676520776173206d697373696e672e74205c5b70726f706f73616c5f686173682c207265665f696e6465785c5d38507265696d616765526561706564101048617368244163636f756e7449641c42616c616e6365244163636f756e744964082d012041207265676973746572656420707265696d616765207761732072656d6f76656420616e6420746865206465706f73697420636f6c6c656374656420627920746865207265617065722eb4205c5b70726f706f73616c5f686173682c2070726f76696465722c206465706f7369742c207265617065725c5d20556e6c6f636b656404244163636f756e74496404bc20416e205c5b6163636f756e745c5d20686173206265656e20756e6c6f636b6564207375636365737366756c6c792e2c426c61636b6c697374656404104861736804d820412070726f706f73616c205c5b686173685c5d20686173206265656e20626c61636b6c6973746564207065726d616e656e746c792e203c456e6163746d656e74506572696f6438543a3a426c6f636b4e756d6265721000c2010014710120546865206d696e696d756d20706572696f64206f66206c6f636b696e6720616e642074686520706572696f64206265747765656e20612070726f706f73616c206265696e6720617070726f76656420616e6420656e61637465642e0031012049742073686f756c642067656e6572616c6c792062652061206c6974746c65206d6f7265207468616e2074686520756e7374616b6520706572696f6420746f20656e737572652074686174690120766f74696e67207374616b657273206861766520616e206f70706f7274756e69747920746f2072656d6f7665207468656d73656c7665732066726f6d207468652073797374656d20696e2074686520636173652077686572659c207468657920617265206f6e20746865206c6f73696e672073696465206f66206120766f74652e304c61756e6368506572696f6438543a3a426c6f636b4e756d62657210c089010004e420486f77206f6674656e2028696e20626c6f636b7329206e6577207075626c6963207265666572656e646120617265206c61756e636865642e30566f74696e67506572696f6438543a3a426c6f636b4e756d62657210c089010004b820486f77206f6674656e2028696e20626c6f636b732920746f20636865636b20666f72206e657720766f7465732e384d696e696d756d4465706f7369743042616c616e63654f663c543e40aa821bce26000000000000000000000004350120546865206d696e696d756d20616d6f756e7420746f20626520757365642061732061206465706f73697420666f722061207075626c6963207265666572656e64756d2070726f706f73616c2e5446617374547261636b566f74696e67506572696f6438543a3a426c6f636b4e756d626572100807000004ec204d696e696d756d20766f74696e6720706572696f6420616c6c6f77656420666f7220616e20656d657267656e6379207265666572656e64756d2e34436f6f6c6f6666506572696f6438543a3a426c6f636b4e756d62657210c089010004610120506572696f6420696e20626c6f636b7320776865726520616e2065787465726e616c2070726f706f73616c206d6179206e6f742062652072652d7375626d6974746564206166746572206265696e67207665746f65642e4c507265696d616765427974654465706f7369743042616c616e63654f663c543e402450fe000000000000000000000000000429012054686520616d6f756e74206f662062616c616e63652074686174206d757374206265206465706f7369746564207065722062797465206f6620707265696d6167652073746f7265642e204d6178566f7465730c753332106400000004b020546865206d6178696d756d206e756d626572206f6620766f74657320666f7220616e206163636f756e742e8c2056616c75654c6f7704382056616c756520746f6f206c6f773c50726f706f73616c4d697373696e6704602050726f706f73616c20646f6573206e6f7420657869737420426164496e646578043820556e6b6e6f776e20696e6465783c416c726561647943616e63656c656404982043616e6e6f742063616e63656c207468652073616d652070726f706f73616c207477696365444475706c696361746550726f706f73616c04582050726f706f73616c20616c7265616479206d6164654c50726f706f73616c426c61636b6c6973746564046c2050726f706f73616c207374696c6c20626c61636b6c6973746564444e6f7453696d706c654d616a6f7269747904ac204e6578742065787465726e616c2070726f706f73616c206e6f742073696d706c65206d616a6f726974792c496e76616c696448617368043420496e76616c69642068617368284e6f50726f706f73616c0454204e6f2065787465726e616c2070726f706f73616c34416c72656164795665746f6564049c204964656e74697479206d6179206e6f74207665746f20612070726f706f73616c207477696365304e6f7444656c6567617465640438204e6f742064656c656761746564444475706c6963617465507265696d616765045c20507265696d61676520616c7265616479206e6f7465642c4e6f74496d6d696e656e740434204e6f7420696d6d696e656e7420546f6f4561726c79042820546f6f206561726c7920496d6d696e656e74042420496d6d696e656e743c507265696d6167654d697373696e67044c20507265696d616765206e6f7420666f756e64445265666572656e64756d496e76616c6964048820566f746520676976656e20666f7220696e76616c6964207265666572656e64756d3c507265696d616765496e76616c6964044420496e76616c696420707265696d6167652c4e6f6e6557616974696e670454204e6f2070726f706f73616c732077616974696e67244e6f744c6f636b656404a42054686520746172676574206163636f756e7420646f6573206e6f7420686176652061206c6f636b2e284e6f744578706972656404f020546865206c6f636b206f6e20746865206163636f756e7420746f20626520756e6c6f636b656420686173206e6f742079657420657870697265642e204e6f74566f74657204c82054686520676976656e206163636f756e7420646964206e6f7420766f7465206f6e20746865207265666572656e64756d2e304e6f5065726d697373696f6e04cc20546865206163746f7220686173206e6f207065726d697373696f6e20746f20636f6e647563742074686520616374696f6e2e44416c726561647944656c65676174696e67048c20546865206163636f756e7420697320616c72656164792064656c65676174696e672e204f766572666c6f7704a420416e20756e657870656374656420696e7465676572206f766572666c6f77206f636375727265642e24556e646572666c6f7704a820416e20756e657870656374656420696e746567657220756e646572666c6f77206f636375727265642e44496e73756666696369656e7446756e647304010120546f6f206869676820612062616c616e6365207761732070726f7669646564207468617420746865206163636f756e742063616e6e6f74206166666f72642e344e6f7444656c65676174696e6704a420546865206163636f756e74206973206e6f742063757272656e746c792064656c65676174696e672e28566f746573457869737408590120546865206163636f756e742063757272656e746c792068617320766f74657320617474616368656420746f20697420616e6420746865206f7065726174696f6e2063616e6e6f74207375636365656420756e74696cec207468657365206172652072656d6f7665642c20656974686572207468726f7567682060756e766f746560206f722060726561705f766f7465602e44496e7374616e744e6f74416c6c6f77656404dc2054686520696e7374616e74207265666572656e64756d206f726967696e2069732063757272656e746c7920646973616c6c6f7765642e204e6f6e73656e736504982044656c65676174696f6e20746f206f6e6573656c66206d616b6573206e6f2073656e73652e3c57726f6e675570706572426f756e64045420496e76616c696420757070657220626f756e642e3c4d6178566f746573526561636865640484204d6178696d756d206e756d626572206f6620766f74657320726561636865642e38496e76616c69645769746e6573730490205468652070726f7669646564207769746e65737320646174612069732077726f6e672e40546f6f4d616e7950726f706f73616c730494204d6178696d756d206e756d626572206f662070726f706f73616c7320726561636865642e0d1c436f756e63696c014c496e7374616e636531436f6c6c656374697665182450726f706f73616c730100305665633c543a3a486173683e040004902054686520686173686573206f6620746865206163746976652070726f706f73616c732e2850726f706f73616c4f660001061c543a3a48617368683c5420617320436f6e6669673c493e3e3a3a50726f706f73616c00040004cc2041637475616c2070726f706f73616c20666f72206120676976656e20686173682c20696620697427732063757272656e742e18566f74696e670001061c543a3a486173688c566f7465733c543a3a4163636f756e7449642c20543a3a426c6f636b4e756d6265723e00040004b420566f746573206f6e206120676976656e2070726f706f73616c2c206966206974206973206f6e676f696e672e3450726f706f73616c436f756e7401000c753332100000000004482050726f706f73616c7320736f206661722e1c4d656d626572730100445665633c543a3a4163636f756e7449643e0400043901205468652063757272656e74206d656d62657273206f662074686520636f6c6c6563746976652e20546869732069732073746f72656420736f7274656420286a7573742062792076616c7565292e145072696d65000030543a3a4163636f756e744964040004650120546865207072696d65206d656d62657220746861742068656c70732064657465726d696e65207468652064656661756c7420766f7465206265686176696f7220696e2063617365206f6620616273656e746174696f6e732e01182c7365745f6d656d626572730c2c6e65775f6d656d62657273445665633c543a3a4163636f756e7449643e147072696d65504f7074696f6e3c543a3a4163636f756e7449643e246f6c645f636f756e742c4d656d626572436f756e746084205365742074686520636f6c6c6563746976652773206d656d626572736869702e004901202d20606e65775f6d656d62657273603a20546865206e6577206d656d626572206c6973742e204265206e69636520746f2074686520636861696e20616e642070726f7669646520697420736f727465642ee4202d20607072696d65603a20546865207072696d65206d656d6265722077686f736520766f74652073657473207468652064656661756c742e3901202d20606f6c645f636f756e74603a2054686520757070657220626f756e6420666f72207468652070726576696f7573206e756d626572206f66206d656d6265727320696e2073746f726167652eac202020202020202020202020202020205573656420666f722077656967687420657374696d6174696f6e2e005820526571756972657320726f6f74206f726967696e2e005501204e4f54453a20446f6573206e6f7420656e666f7263652074686520657870656374656420604d61784d656d6265727360206c696d6974206f6e2074686520616d6f756e74206f66206d656d626572732c206275742501202020202020207468652077656967687420657374696d6174696f6e732072656c79206f6e20697420746f20657374696d61746520646973706174636861626c65207765696768742e002c2023203c7765696768743e282023232057656967687454202d20604f284d50202b204e29602077686572653ae42020202d20604d60206f6c642d6d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429e42020202d20604e60206e65772d6d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e646564299c2020202d206050602070726f706f73616c732d636f756e742028636f64652d626f756e6465642918202d2044423a75012020202d20312073746f72616765206d75746174696f6e2028636f64656320604f284d296020726561642c20604f284e29602077726974652920666f722072656164696e6720616e642077726974696e6720746865206d656d62657273f02020202d20312073746f7261676520726561642028636f64656320604f285029602920666f722072656164696e67207468652070726f706f73616c7349012020202d206050602073746f72616765206d75746174696f6e732028636f64656320604f284d29602920666f72207570646174696e672074686520766f74657320666f7220656163682070726f706f73616c61012020202d20312073746f726167652077726974652028636f64656320604f283129602920666f722064656c6574696e6720746865206f6c6420607072696d656020616e642073657474696e6720746865206e6577206f6e65302023203c2f7765696768743e1c65786563757465082070726f706f73616c7c426f783c3c5420617320436f6e6669673c493e3e3a3a50726f706f73616c3e306c656e6774685f626f756e6430436f6d706163743c7533323e28f420446973706174636820612070726f706f73616c2066726f6d2061206d656d626572207573696e672074686520604d656d62657260206f726967696e2e00ac204f726967696e206d7573742062652061206d656d626572206f662074686520636f6c6c6563746976652e002c2023203c7765696768743e28202323205765696768748501202d20604f284d202b2050296020776865726520604d60206d656d626572732d636f756e742028636f64652d626f756e6465642920616e642060506020636f6d706c6578697479206f66206469737061746368696e67206070726f706f73616c60d8202d2044423a203120726561642028636f64656320604f284d296029202b20444220616363657373206f66206070726f706f73616c6028202d2031206576656e74302023203c2f7765696768743e1c70726f706f73650c247468726573686f6c6450436f6d706163743c4d656d626572436f756e743e2070726f706f73616c7c426f783c3c5420617320436f6e6669673c493e3e3a3a50726f706f73616c3e306c656e6774685f626f756e6430436f6d706163743c7533323e6cfc204164642061206e65772070726f706f73616c20746f2065697468657220626520766f746564206f6e206f72206578656375746564206469726563746c792e0088205265717569726573207468652073656e64657220746f206265206d656d6265722e00450120607468726573686f6c64602064657465726d696e65732077686574686572206070726f706f73616c60206973206578656375746564206469726563746c792028607468726573686f6c64203c2032602958206f722070757420757020666f7220766f74696e672e002c2023203c7765696768743e2820232320576569676874b0202d20604f2842202b204d202b2050312960206f7220604f2842202b204d202b20503229602077686572653ae42020202d20604260206973206070726f706f73616c602073697a6520696e20627974657320286c656e6774682d6665652d626f756e64656429e02020202d20604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429c82020202d206272616e6368696e6720697320696e666c75656e63656420627920607468726573686f6c64602077686572653af820202020202d20605031602069732070726f706f73616c20657865637574696f6e20636f6d706c65786974792028607468726573686f6c64203c20326029010120202020202d20605032602069732070726f706f73616c732d636f756e742028636f64652d626f756e646564292028607468726573686f6c64203e3d2032602918202d2044423ab82020202d20312073746f726167652072656164206069735f6d656d626572602028636f64656320604f284d296029f42020202d20312073746f726167652072656164206050726f706f73616c4f663a3a636f6e7461696e735f6b6579602028636f64656320604f2831296029ac2020202d20444220616363657373657320696e666c75656e63656420627920607468726573686f6c64603a0d0120202020202d204549544845522073746f7261676520616363657373657320646f6e65206279206070726f706f73616c602028607468726573686f6c64203c20326029bc20202020202d204f522070726f706f73616c20696e73657274696f6e2028607468726573686f6c64203c3d20326029dc202020202020202d20312073746f72616765206d75746174696f6e206050726f706f73616c73602028636f64656320604f285032296029e8202020202020202d20312073746f72616765206d75746174696f6e206050726f706f73616c436f756e74602028636f64656320604f2831296029d0202020202020202d20312073746f72616765207772697465206050726f706f73616c4f66602028636f64656320604f2842296029c0202020202020202d20312073746f726167652077726974652060566f74696e67602028636f64656320604f284d296029302020202d2031206576656e74302023203c2f7765696768743e10766f74650c2070726f706f73616c1c543a3a4861736814696e64657858436f6d706163743c50726f706f73616c496e6465783e1c617070726f766510626f6f6c38f42041646420616e20617965206f72206e617920766f746520666f72207468652073656e64657220746f2074686520676976656e2070726f706f73616c2e0090205265717569726573207468652073656e64657220746f2062652061206d656d6265722e004d01205472616e73616374696f6e20666565732077696c6c2062652077616976656420696620746865206d656d62657220697320766f74696e67206f6e20616e7920706172746963756c61722070726f706f73616c690120666f72207468652066697273742074696d6520616e64207468652063616c6c206973207375636365737366756c2e2053756273657175656e7420766f7465206368616e6765732077696c6c206368617267652061206665652e2c2023203c7765696768743e28202323205765696768740d01202d20604f284d296020776865726520604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e6465642918202d2044423ab02020202d20312073746f72616765207265616420604d656d62657273602028636f64656320604f284d296029bc2020202d20312073746f72616765206d75746174696f6e2060566f74696e67602028636f64656320604f284d29602928202d2031206576656e74302023203c2f7765696768743e14636c6f7365103470726f706f73616c5f686173681c543a3a4861736814696e64657858436f6d706163743c50726f706f73616c496e6465783e5470726f706f73616c5f7765696768745f626f756e643c436f6d706163743c5765696768743e306c656e6774685f626f756e6430436f6d706163743c7533323e78510120436c6f7365206120766f746520746861742069732065697468657220617070726f7665642c20646973617070726f766564206f722077686f736520766f74696e6720706572696f642068617320656e6465642e005901204d61792062652063616c6c656420627920616e79207369676e6564206163636f756e7420696e206f7264657220746f2066696e69736820766f74696e6720616e6420636c6f7365207468652070726f706f73616c2e004d012049662063616c6c6564206265666f72652074686520656e64206f662074686520766f74696e6720706572696f642069742077696c6c206f6e6c7920636c6f73652074686520766f7465206966206974206973c02068617320656e6f75676820766f74657320746f20626520617070726f766564206f7220646973617070726f7665642e004d012049662063616c6c65642061667465722074686520656e64206f662074686520766f74696e6720706572696f642061627374656e74696f6e732061726520636f756e7465642061732072656a656374696f6e73290120756e6c6573732074686572652069732061207072696d65206d656d6265722073657420616e6420746865207072696d65206d656d626572206361737420616e20617070726f76616c2e0065012049662074686520636c6f7365206f7065726174696f6e20636f6d706c65746573207375636365737366756c6c79207769746820646973617070726f76616c2c20746865207472616e73616374696f6e206665652077696c6c6101206265207761697665642e204f746865727769736520657865637574696f6e206f662074686520617070726f766564206f7065726174696f6e2077696c6c206265206368617267656420746f207468652063616c6c65722e008d01202b206070726f706f73616c5f7765696768745f626f756e64603a20546865206d6178696d756d20616d6f756e74206f662077656967687420636f6e73756d656420627920657865637574696e672074686520636c6f7365642070726f706f73616c2e6501202b20606c656e6774685f626f756e64603a2054686520757070657220626f756e6420666f7220746865206c656e677468206f66207468652070726f706f73616c20696e2073746f726167652e20436865636b6564207669618101202020202020202020202020202020202020206073746f726167653a3a726561646020736f206974206973206073697a655f6f663a3a3c7533323e2829203d3d203460206c6172676572207468616e207468652070757265206c656e6774682e002c2023203c7765696768743e282023232057656967687478202d20604f2842202b204d202b205031202b20503229602077686572653ae42020202d20604260206973206070726f706f73616c602073697a6520696e20627974657320286c656e6774682d6665652d626f756e64656429e02020202d20604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429cc2020202d20605031602069732074686520636f6d706c6578697479206f66206070726f706f73616c6020707265696d6167652ea82020202d20605032602069732070726f706f73616c2d636f756e742028636f64652d626f756e6465642918202d2044423a110120202d20322073746f726167652072656164732028604d656d62657273603a20636f64656320604f284d29602c20605072696d65603a20636f64656320604f2831296029810120202d2033206d75746174696f6e73202860566f74696e67603a20636f64656320604f284d29602c206050726f706f73616c4f66603a20636f64656320604f284229602c206050726f706f73616c73603a20636f64656320604f285032296029e020202d20616e79206d75746174696f6e7320646f6e65207768696c6520657865637574696e67206070726f706f73616c602028605031602944202d20757020746f2033206576656e7473302023203c2f7765696768743e4c646973617070726f76655f70726f706f73616c043470726f706f73616c5f686173681c543a3a4861736834790120446973617070726f766520612070726f706f73616c2c20636c6f73652c20616e642072656d6f76652069742066726f6d207468652073797374656d2c207265676172646c657373206f66206974732063757272656e742073746174652e008c204d7573742062652063616c6c65642062792074686520526f6f74206f726967696e2e003020506172616d65746572733a2101202a206070726f706f73616c5f68617368603a205468652068617368206f66207468652070726f706f73616c20746861742073686f756c6420626520646973617070726f7665642e002c2023203c7765696768743ee020436f6d706c65786974793a204f285029207768657265205020697320746865206e756d626572206f66206d61782070726f706f73616c732c204442205765696768743a4c202a2052656164733a2050726f706f73616c73a0202a205772697465733a20566f74696e672c2050726f706f73616c732c2050726f706f73616c4f66302023203c2f7765696768743e011c2050726f706f73656410244163636f756e7449643450726f706f73616c496e64657810486173682c4d656d626572436f756e740c4d012041206d6f74696f6e2028676976656e20686173682920686173206265656e2070726f706f7365642028627920676976656e206163636f756e742920776974682061207468726573686f6c642028676976656e4020604d656d626572436f756e7460292ed8205c5b6163636f756e742c2070726f706f73616c5f696e6465782c2070726f706f73616c5f686173682c207468726573686f6c645c5d14566f74656414244163636f756e744964104861736810626f6f6c2c4d656d626572436f756e742c4d656d626572436f756e740c09012041206d6f74696f6e2028676976656e20686173682920686173206265656e20766f746564206f6e20627920676976656e206163636f756e742c206c656176696e67190120612074616c6c79202879657320766f74657320616e64206e6f20766f74657320676976656e20726573706563746976656c7920617320604d656d626572436f756e7460292eac205c5b6163636f756e742c2070726f706f73616c5f686173682c20766f7465642c207965732c206e6f5c5d20417070726f76656404104861736808c42041206d6f74696f6e2077617320617070726f76656420627920746865207265717569726564207468726573686f6c642e48205c5b70726f706f73616c5f686173685c5d2c446973617070726f76656404104861736808d42041206d6f74696f6e20776173206e6f7420617070726f76656420627920746865207265717569726564207468726573686f6c642e48205c5b70726f706f73616c5f686173685c5d204578656375746564081048617368384469737061746368526573756c740825012041206d6f74696f6e207761732065786563757465643b20726573756c742077696c6c20626520604f6b602069662069742072657475726e656420776974686f7574206572726f722e68205c5b70726f706f73616c5f686173682c20726573756c745c5d384d656d6265724578656375746564081048617368384469737061746368526573756c74084d0120412073696e676c65206d656d6265722064696420736f6d6520616374696f6e3b20726573756c742077696c6c20626520604f6b602069662069742072657475726e656420776974686f7574206572726f722e68205c5b70726f706f73616c5f686173682c20726573756c745c5d18436c6f7365640c10486173682c4d656d626572436f756e742c4d656d626572436f756e7408590120412070726f706f73616c2077617320636c6f736564206265636175736520697473207468726573686f6c64207761732072656163686564206f7220616674657220697473206475726174696f6e207761732075702e6c205c5b70726f706f73616c5f686173682c207965732c206e6f5c5d0028244e6f744d656d6265720460204163636f756e74206973206e6f742061206d656d626572444475706c696361746550726f706f73616c0480204475706c69636174652070726f706f73616c73206e6f7420616c6c6f7765643c50726f706f73616c4d697373696e6704502050726f706f73616c206d7573742065786973742857726f6e67496e6465780444204d69736d61746368656420696e646578344475706c6963617465566f7465045c204475706c696361746520766f74652069676e6f72656448416c7265616479496e697469616c697a65640484204d656d626572732061726520616c726561647920696e697469616c697a65642120546f6f4561726c790405012054686520636c6f73652063616c6c20776173206d61646520746f6f206561726c792c206265666f72652074686520656e64206f662074686520766f74696e672e40546f6f4d616e7950726f706f73616c730401012054686572652063616e206f6e6c792062652061206d6178696d756d206f6620604d617850726f706f73616c7360206163746976652070726f706f73616c732e4c57726f6e6750726f706f73616c57656967687404d42054686520676976656e2077656967687420626f756e6420666f72207468652070726f706f73616c2077617320746f6f206c6f772e4c57726f6e6750726f706f73616c4c656e67746804d42054686520676976656e206c656e67746820626f756e6420666f72207468652070726f706f73616c2077617320746f6f206c6f772e0e48546563686e6963616c436f6d6d6974746565014c496e7374616e636532436f6c6c656374697665182450726f706f73616c730100305665633c543a3a486173683e040004902054686520686173686573206f6620746865206163746976652070726f706f73616c732e2850726f706f73616c4f660001061c543a3a48617368683c5420617320436f6e6669673c493e3e3a3a50726f706f73616c00040004cc2041637475616c2070726f706f73616c20666f72206120676976656e20686173682c20696620697427732063757272656e742e18566f74696e670001061c543a3a486173688c566f7465733c543a3a4163636f756e7449642c20543a3a426c6f636b4e756d6265723e00040004b420566f746573206f6e206120676976656e2070726f706f73616c2c206966206974206973206f6e676f696e672e3450726f706f73616c436f756e7401000c753332100000000004482050726f706f73616c7320736f206661722e1c4d656d626572730100445665633c543a3a4163636f756e7449643e0400043901205468652063757272656e74206d656d62657273206f662074686520636f6c6c6563746976652e20546869732069732073746f72656420736f7274656420286a7573742062792076616c7565292e145072696d65000030543a3a4163636f756e744964040004650120546865207072696d65206d656d62657220746861742068656c70732064657465726d696e65207468652064656661756c7420766f7465206265686176696f7220696e2063617365206f6620616273656e746174696f6e732e01182c7365745f6d656d626572730c2c6e65775f6d656d62657273445665633c543a3a4163636f756e7449643e147072696d65504f7074696f6e3c543a3a4163636f756e7449643e246f6c645f636f756e742c4d656d626572436f756e746084205365742074686520636f6c6c6563746976652773206d656d626572736869702e004901202d20606e65775f6d656d62657273603a20546865206e6577206d656d626572206c6973742e204265206e69636520746f2074686520636861696e20616e642070726f7669646520697420736f727465642ee4202d20607072696d65603a20546865207072696d65206d656d6265722077686f736520766f74652073657473207468652064656661756c742e3901202d20606f6c645f636f756e74603a2054686520757070657220626f756e6420666f72207468652070726576696f7573206e756d626572206f66206d656d6265727320696e2073746f726167652eac202020202020202020202020202020205573656420666f722077656967687420657374696d6174696f6e2e005820526571756972657320726f6f74206f726967696e2e005501204e4f54453a20446f6573206e6f7420656e666f7263652074686520657870656374656420604d61784d656d6265727360206c696d6974206f6e2074686520616d6f756e74206f66206d656d626572732c206275742501202020202020207468652077656967687420657374696d6174696f6e732072656c79206f6e20697420746f20657374696d61746520646973706174636861626c65207765696768742e002c2023203c7765696768743e282023232057656967687454202d20604f284d50202b204e29602077686572653ae42020202d20604d60206f6c642d6d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429e42020202d20604e60206e65772d6d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e646564299c2020202d206050602070726f706f73616c732d636f756e742028636f64652d626f756e6465642918202d2044423a75012020202d20312073746f72616765206d75746174696f6e2028636f64656320604f284d296020726561642c20604f284e29602077726974652920666f722072656164696e6720616e642077726974696e6720746865206d656d62657273f02020202d20312073746f7261676520726561642028636f64656320604f285029602920666f722072656164696e67207468652070726f706f73616c7349012020202d206050602073746f72616765206d75746174696f6e732028636f64656320604f284d29602920666f72207570646174696e672074686520766f74657320666f7220656163682070726f706f73616c61012020202d20312073746f726167652077726974652028636f64656320604f283129602920666f722064656c6574696e6720746865206f6c6420607072696d656020616e642073657474696e6720746865206e6577206f6e65302023203c2f7765696768743e1c65786563757465082070726f706f73616c7c426f783c3c5420617320436f6e6669673c493e3e3a3a50726f706f73616c3e306c656e6774685f626f756e6430436f6d706163743c7533323e28f420446973706174636820612070726f706f73616c2066726f6d2061206d656d626572207573696e672074686520604d656d62657260206f726967696e2e00ac204f726967696e206d7573742062652061206d656d626572206f662074686520636f6c6c6563746976652e002c2023203c7765696768743e28202323205765696768748501202d20604f284d202b2050296020776865726520604d60206d656d626572732d636f756e742028636f64652d626f756e6465642920616e642060506020636f6d706c6578697479206f66206469737061746368696e67206070726f706f73616c60d8202d2044423a203120726561642028636f64656320604f284d296029202b20444220616363657373206f66206070726f706f73616c6028202d2031206576656e74302023203c2f7765696768743e1c70726f706f73650c247468726573686f6c6450436f6d706163743c4d656d626572436f756e743e2070726f706f73616c7c426f783c3c5420617320436f6e6669673c493e3e3a3a50726f706f73616c3e306c656e6774685f626f756e6430436f6d706163743c7533323e6cfc204164642061206e65772070726f706f73616c20746f2065697468657220626520766f746564206f6e206f72206578656375746564206469726563746c792e0088205265717569726573207468652073656e64657220746f206265206d656d6265722e00450120607468726573686f6c64602064657465726d696e65732077686574686572206070726f706f73616c60206973206578656375746564206469726563746c792028607468726573686f6c64203c2032602958206f722070757420757020666f7220766f74696e672e002c2023203c7765696768743e2820232320576569676874b0202d20604f2842202b204d202b2050312960206f7220604f2842202b204d202b20503229602077686572653ae42020202d20604260206973206070726f706f73616c602073697a6520696e20627974657320286c656e6774682d6665652d626f756e64656429e02020202d20604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429c82020202d206272616e6368696e6720697320696e666c75656e63656420627920607468726573686f6c64602077686572653af820202020202d20605031602069732070726f706f73616c20657865637574696f6e20636f6d706c65786974792028607468726573686f6c64203c20326029010120202020202d20605032602069732070726f706f73616c732d636f756e742028636f64652d626f756e646564292028607468726573686f6c64203e3d2032602918202d2044423ab82020202d20312073746f726167652072656164206069735f6d656d626572602028636f64656320604f284d296029f42020202d20312073746f726167652072656164206050726f706f73616c4f663a3a636f6e7461696e735f6b6579602028636f64656320604f2831296029ac2020202d20444220616363657373657320696e666c75656e63656420627920607468726573686f6c64603a0d0120202020202d204549544845522073746f7261676520616363657373657320646f6e65206279206070726f706f73616c602028607468726573686f6c64203c20326029bc20202020202d204f522070726f706f73616c20696e73657274696f6e2028607468726573686f6c64203c3d20326029dc202020202020202d20312073746f72616765206d75746174696f6e206050726f706f73616c73602028636f64656320604f285032296029e8202020202020202d20312073746f72616765206d75746174696f6e206050726f706f73616c436f756e74602028636f64656320604f2831296029d0202020202020202d20312073746f72616765207772697465206050726f706f73616c4f66602028636f64656320604f2842296029c0202020202020202d20312073746f726167652077726974652060566f74696e67602028636f64656320604f284d296029302020202d2031206576656e74302023203c2f7765696768743e10766f74650c2070726f706f73616c1c543a3a4861736814696e64657858436f6d706163743c50726f706f73616c496e6465783e1c617070726f766510626f6f6c38f42041646420616e20617965206f72206e617920766f746520666f72207468652073656e64657220746f2074686520676976656e2070726f706f73616c2e0090205265717569726573207468652073656e64657220746f2062652061206d656d6265722e004d01205472616e73616374696f6e20666565732077696c6c2062652077616976656420696620746865206d656d62657220697320766f74696e67206f6e20616e7920706172746963756c61722070726f706f73616c690120666f72207468652066697273742074696d6520616e64207468652063616c6c206973207375636365737366756c2e2053756273657175656e7420766f7465206368616e6765732077696c6c206368617267652061206665652e2c2023203c7765696768743e28202323205765696768740d01202d20604f284d296020776865726520604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e6465642918202d2044423ab02020202d20312073746f72616765207265616420604d656d62657273602028636f64656320604f284d296029bc2020202d20312073746f72616765206d75746174696f6e2060566f74696e67602028636f64656320604f284d29602928202d2031206576656e74302023203c2f7765696768743e14636c6f7365103470726f706f73616c5f686173681c543a3a4861736814696e64657858436f6d706163743c50726f706f73616c496e6465783e5470726f706f73616c5f7765696768745f626f756e643c436f6d706163743c5765696768743e306c656e6774685f626f756e6430436f6d706163743c7533323e78510120436c6f7365206120766f746520746861742069732065697468657220617070726f7665642c20646973617070726f766564206f722077686f736520766f74696e6720706572696f642068617320656e6465642e005901204d61792062652063616c6c656420627920616e79207369676e6564206163636f756e7420696e206f7264657220746f2066696e69736820766f74696e6720616e6420636c6f7365207468652070726f706f73616c2e004d012049662063616c6c6564206265666f72652074686520656e64206f662074686520766f74696e6720706572696f642069742077696c6c206f6e6c7920636c6f73652074686520766f7465206966206974206973c02068617320656e6f75676820766f74657320746f20626520617070726f766564206f7220646973617070726f7665642e004d012049662063616c6c65642061667465722074686520656e64206f662074686520766f74696e6720706572696f642061627374656e74696f6e732061726520636f756e7465642061732072656a656374696f6e73290120756e6c6573732074686572652069732061207072696d65206d656d6265722073657420616e6420746865207072696d65206d656d626572206361737420616e20617070726f76616c2e0065012049662074686520636c6f7365206f7065726174696f6e20636f6d706c65746573207375636365737366756c6c79207769746820646973617070726f76616c2c20746865207472616e73616374696f6e206665652077696c6c6101206265207761697665642e204f746865727769736520657865637574696f6e206f662074686520617070726f766564206f7065726174696f6e2077696c6c206265206368617267656420746f207468652063616c6c65722e008d01202b206070726f706f73616c5f7765696768745f626f756e64603a20546865206d6178696d756d20616d6f756e74206f662077656967687420636f6e73756d656420627920657865637574696e672074686520636c6f7365642070726f706f73616c2e6501202b20606c656e6774685f626f756e64603a2054686520757070657220626f756e6420666f7220746865206c656e677468206f66207468652070726f706f73616c20696e2073746f726167652e20436865636b6564207669618101202020202020202020202020202020202020206073746f726167653a3a726561646020736f206974206973206073697a655f6f663a3a3c7533323e2829203d3d203460206c6172676572207468616e207468652070757265206c656e6774682e002c2023203c7765696768743e282023232057656967687478202d20604f2842202b204d202b205031202b20503229602077686572653ae42020202d20604260206973206070726f706f73616c602073697a6520696e20627974657320286c656e6774682d6665652d626f756e64656429e02020202d20604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429cc2020202d20605031602069732074686520636f6d706c6578697479206f66206070726f706f73616c6020707265696d6167652ea82020202d20605032602069732070726f706f73616c2d636f756e742028636f64652d626f756e6465642918202d2044423a110120202d20322073746f726167652072656164732028604d656d62657273603a20636f64656320604f284d29602c20605072696d65603a20636f64656320604f2831296029810120202d2033206d75746174696f6e73202860566f74696e67603a20636f64656320604f284d29602c206050726f706f73616c4f66603a20636f64656320604f284229602c206050726f706f73616c73603a20636f64656320604f285032296029e020202d20616e79206d75746174696f6e7320646f6e65207768696c6520657865637574696e67206070726f706f73616c602028605031602944202d20757020746f2033206576656e7473302023203c2f7765696768743e4c646973617070726f76655f70726f706f73616c043470726f706f73616c5f686173681c543a3a4861736834790120446973617070726f766520612070726f706f73616c2c20636c6f73652c20616e642072656d6f76652069742066726f6d207468652073797374656d2c207265676172646c657373206f66206974732063757272656e742073746174652e008c204d7573742062652063616c6c65642062792074686520526f6f74206f726967696e2e003020506172616d65746572733a2101202a206070726f706f73616c5f68617368603a205468652068617368206f66207468652070726f706f73616c20746861742073686f756c6420626520646973617070726f7665642e002c2023203c7765696768743ee020436f6d706c65786974793a204f285029207768657265205020697320746865206e756d626572206f66206d61782070726f706f73616c732c204442205765696768743a4c202a2052656164733a2050726f706f73616c73a0202a205772697465733a20566f74696e672c2050726f706f73616c732c2050726f706f73616c4f66302023203c2f7765696768743e011c2050726f706f73656410244163636f756e7449643450726f706f73616c496e64657810486173682c4d656d626572436f756e740c4d012041206d6f74696f6e2028676976656e20686173682920686173206265656e2070726f706f7365642028627920676976656e206163636f756e742920776974682061207468726573686f6c642028676976656e4020604d656d626572436f756e7460292ed8205c5b6163636f756e742c2070726f706f73616c5f696e6465782c2070726f706f73616c5f686173682c207468726573686f6c645c5d14566f74656414244163636f756e744964104861736810626f6f6c2c4d656d626572436f756e742c4d656d626572436f756e740c09012041206d6f74696f6e2028676976656e20686173682920686173206265656e20766f746564206f6e20627920676976656e206163636f756e742c206c656176696e67190120612074616c6c79202879657320766f74657320616e64206e6f20766f74657320676976656e20726573706563746976656c7920617320604d656d626572436f756e7460292eac205c5b6163636f756e742c2070726f706f73616c5f686173682c20766f7465642c207965732c206e6f5c5d20417070726f76656404104861736808c42041206d6f74696f6e2077617320617070726f76656420627920746865207265717569726564207468726573686f6c642e48205c5b70726f706f73616c5f686173685c5d2c446973617070726f76656404104861736808d42041206d6f74696f6e20776173206e6f7420617070726f76656420627920746865207265717569726564207468726573686f6c642e48205c5b70726f706f73616c5f686173685c5d204578656375746564081048617368384469737061746368526573756c740825012041206d6f74696f6e207761732065786563757465643b20726573756c742077696c6c20626520604f6b602069662069742072657475726e656420776974686f7574206572726f722e68205c5b70726f706f73616c5f686173682c20726573756c745c5d384d656d6265724578656375746564081048617368384469737061746368526573756c74084d0120412073696e676c65206d656d6265722064696420736f6d6520616374696f6e3b20726573756c742077696c6c20626520604f6b602069662069742072657475726e656420776974686f7574206572726f722e68205c5b70726f706f73616c5f686173682c20726573756c745c5d18436c6f7365640c10486173682c4d656d626572436f756e742c4d656d626572436f756e7408590120412070726f706f73616c2077617320636c6f736564206265636175736520697473207468726573686f6c64207761732072656163686564206f7220616674657220697473206475726174696f6e207761732075702e6c205c5b70726f706f73616c5f686173682c207965732c206e6f5c5d0028244e6f744d656d6265720460204163636f756e74206973206e6f742061206d656d626572444475706c696361746550726f706f73616c0480204475706c69636174652070726f706f73616c73206e6f7420616c6c6f7765643c50726f706f73616c4d697373696e6704502050726f706f73616c206d7573742065786973742857726f6e67496e6465780444204d69736d61746368656420696e646578344475706c6963617465566f7465045c204475706c696361746520766f74652069676e6f72656448416c7265616479496e697469616c697a65640484204d656d626572732061726520616c726561647920696e697469616c697a65642120546f6f4561726c790405012054686520636c6f73652063616c6c20776173206d61646520746f6f206561726c792c206265666f72652074686520656e64206f662074686520766f74696e672e40546f6f4d616e7950726f706f73616c730401012054686572652063616e206f6e6c792062652061206d6178696d756d206f6620604d617850726f706f73616c7360206163746976652070726f706f73616c732e4c57726f6e6750726f706f73616c57656967687404d42054686520676976656e2077656967687420626f756e6420666f72207468652070726f706f73616c2077617320746f6f206c6f772e4c57726f6e6750726f706f73616c4c656e67746804d42054686520676976656e206c656e67746820626f756e6420666f72207468652070726f706f73616c2077617320746f6f206c6f772e0f44456c656374696f6e7350687261676d656e014050687261676d656e456c656374696f6e141c4d656d626572730100ac5665633c53656174486f6c6465723c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e3e04000c74205468652063757272656e7420656c6563746564206d656d626572732e00b820496e76617269616e743a20416c7761797320736f72746564206261736564206f6e206163636f756e742069642e2452756e6e65727355700100ac5665633c53656174486f6c6465723c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e3e04001084205468652063757272656e742072657365727665642072756e6e6572732d75702e00590120496e76617269616e743a20416c7761797320736f72746564206261736564206f6e2072616e6b2028776f72736520746f2062657374292e2055706f6e2072656d6f76616c206f662061206d656d6265722c20746865bc206c6173742028692e652e205f626573745f292072756e6e65722d75702077696c6c206265207265706c616365642e2843616e646964617465730100845665633c28543a3a4163636f756e7449642c2042616c616e63654f663c543e293e0400185901205468652070726573656e742063616e646964617465206c6973742e20412063757272656e74206d656d626572206f722072756e6e65722d75702063616e206e6576657220656e746572207468697320766563746f72d020616e6420697320616c7761797320696d706c696369746c7920617373756d656420746f20626520612063616e6469646174652e007c205365636f6e6420656c656d656e7420697320746865206465706f7369742e00b820496e76617269616e743a20416c7761797320736f72746564206261736564206f6e206163636f756e742069642e38456c656374696f6e526f756e647301000c75333210000000000441012054686520746f74616c206e756d626572206f6620766f746520726f756e6473207468617420686176652068617070656e65642c206578636c7564696e6720746865207570636f6d696e67206f6e652e18566f74696e6701010530543a3a4163636f756e74496484566f7465723c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e00840000000000000000000000000000000000000000000000000000000000000000000cb820566f74657320616e64206c6f636b6564207374616b65206f66206120706172746963756c617220766f7465722e00c42054574f582d4e4f54453a205341464520617320604163636f756e7449646020697320612063727970746f20686173682e011810766f74650814766f746573445665633c543a3a4163636f756e7449643e1476616c756554436f6d706163743c42616c616e63654f663c543e3e5c5d0120566f746520666f72206120736574206f662063616e6469646174657320666f7220746865207570636f6d696e6720726f756e64206f6620656c656374696f6e2e20546869732063616e2062652063616c6c656420746fe4207365742074686520696e697469616c20766f7465732c206f722075706461746520616c7265616479206578697374696e6720766f7465732e0061012055706f6e20696e697469616c20766f74696e672c206076616c75656020756e697473206f66206077686f6027732062616c616e6365206973206c6f636b656420616e642061206465706f73697420616d6f756e7420697351012072657365727665642e20546865206465706f736974206973206261736564206f6e20746865206e756d626572206f6620766f74657320616e642063616e2062652075706461746564206f7665722074696d652e0050205468652060766f746573602073686f756c643a482020202d206e6f7420626520656d7074792e59012020202d206265206c657373207468616e20746865206e756d626572206f6620706f737369626c652063616e646964617465732e204e6f7465207468617420616c6c2063757272656e74206d656d6265727320616e641501202020202072756e6e6572732d75702061726520616c736f206175746f6d61746963616c6c792063616e6469646174657320666f7220746865206e65787420726f756e642e005101204966206076616c756560206973206d6f7265207468616e206077686f60277320746f74616c2062616c616e63652c207468656e20746865206d6178696d756d206f66207468652074776f20697320757365642e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265207369676e65642e003020232323205761726e696e670059012049742069732074686520726573706f6e736962696c697479206f66207468652063616c6c657220746f202a2a4e4f542a2a20706c61636520616c6c206f662074686569722062616c616e636520696e746f20746865ac206c6f636b20616e64206b65657020736f6d6520666f722066757274686572206f7065726174696f6e732e002c2023203c7765696768743e550120576520617373756d6520746865206d6178696d756d2077656967687420616d6f6e6720616c6c20332063617365733a20766f74655f657175616c2c20766f74655f6d6f726520616e6420766f74655f6c6573732e302023203c2f7765696768743e3072656d6f76655f766f7465720014702052656d6f766520606f726967696e60206173206120766f7465722e00bc20546869732072656d6f76657320746865206c6f636b20616e642072657475726e7320746865206465706f7369742e00010120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265207369676e656420616e64206265206120766f7465722e407375626d69745f63616e646964616379043c63616e6469646174655f636f756e7430436f6d706163743c7533323e3c1501205375626d6974206f6e6573656c6620666f722063616e6469646163792e204120666978656420616d6f756e74206f66206465706f736974206973207265636f726465642e00610120416c6c2063616e64696461746573206172652077697065642061742074686520656e64206f6620746865207465726d2e205468657920656974686572206265636f6d652061206d656d6265722f72756e6e65722d75702cd0206f72206c65617665207468652073797374656d207768696c65207468656972206465706f73697420697320736c61736865642e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265207369676e65642e003020232323205761726e696e67006101204576656e20696620612063616e64696461746520656e6473207570206265696e672061206d656d6265722c2074686579206d7573742063616c6c205b6043616c6c3a3a72656e6f756e63655f63616e646964616379605d5d0120746f20676574207468656972206465706f736974206261636b2e204c6f73696e67207468652073706f7420696e20616e20656c656374696f6e2077696c6c20616c77617973206c65616420746f206120736c6173682e002c2023203c7765696768743e0d0120546865206e756d626572206f662063757272656e742063616e64696461746573206d7573742062652070726f7669646564206173207769746e65737320646174612e302023203c2f7765696768743e4872656e6f756e63655f63616e646964616379042872656e6f756e63696e672852656e6f756e63696e674451012052656e6f756e6365206f6e65277320696e74656e74696f6e20746f20626520612063616e64696461746520666f7220746865206e65787420656c656374696f6e20726f756e642e203320706f74656e7469616c40206f7574636f6d65732065786973743a004d01202d20606f726967696e6020697320612063616e64696461746520616e64206e6f7420656c656374656420696e20616e79207365742e20496e207468697320636173652c20746865206465706f736974206973f4202020756e72657365727665642c2072657475726e656420616e64206f726967696e2069732072656d6f76656420617320612063616e6469646174652e6501202d20606f726967696e6020697320612063757272656e742072756e6e65722d75702e20496e207468697320636173652c20746865206465706f73697420697320756e72657365727665642c2072657475726e656420616e64902020206f726967696e2069732072656d6f76656420617320612072756e6e65722d75702e5901202d20606f726967696e6020697320612063757272656e74206d656d6265722e20496e207468697320636173652c20746865206465706f73697420697320756e726573657276656420616e64206f726967696e206973590120202072656d6f7665642061732061206d656d6265722c20636f6e73657175656e746c79206e6f74206265696e6720612063616e64696461746520666f7220746865206e65787420726f756e6420616e796d6f72652e6d0120202053696d696c617220746f205b6072656d6f76655f6d656d62657273605d2c206966207265706c6163656d656e742072756e6e657273206578697374732c20746865792061726520696d6d6564696174656c7920757365642e3501202020496620746865207072696d652069732072656e6f756e63696e672c207468656e206e6f207072696d652077696c6c20657869737420756e74696c20746865206e65787420726f756e642e00490120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265207369676e65642c20616e642068617665206f6e65206f66207468652061626f766520726f6c65732e002c2023203c7765696768743ee4205468652074797065206f662072656e6f756e63696e67206d7573742062652070726f7669646564206173207769746e65737320646174612e302023203c2f7765696768743e3472656d6f76655f6d656d626572080c77686f8c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653c6861735f7265706c6163656d656e7410626f6f6c385d012052656d6f7665206120706172746963756c6172206d656d6265722066726f6d20746865207365742e20546869732069732065666665637469766520696d6d6564696174656c7920616e642074686520626f6e64206f668020746865206f7574676f696e67206d656d62657220697320736c61736865642e00590120496620612072756e6e65722d757020697320617661696c61626c652c207468656e2074686520626573742072756e6e65722d75702077696c6c2062652072656d6f76656420616e64207265706c61636573207468650101206f7574676f696e67206d656d6265722e204f74686572776973652c2061206e65772070687261676d656e20656c656374696f6e20697320737461727465642e00bc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d75737420626520726f6f742e004501204e6f74652074686174207468697320646f6573206e6f7420616666656374207468652064657369676e6174656420626c6f636b206e756d626572206f6620746865206e65787420656c656374696f6e2e002c2023203c7765696768743e550120496620776520686176652061207265706c6163656d656e742c20776520757365206120736d616c6c207765696768742e20456c73652c2073696e63652074686973206973206120726f6f742063616c6c20616e64d42077696c6c20676f20696e746f2070687261676d656e2c20776520617373756d652066756c6c20626c6f636b20666f72206e6f772e302023203c2f7765696768743e50636c65616e5f646566756e63745f766f74657273082c5f6e756d5f766f746572730c753332305f6e756d5f646566756e63740c75333228490120436c65616e20616c6c20766f746572732077686f2061726520646566756e63742028692e652e207468657920646f206e6f7420736572766520616e7920707572706f736520617420616c6c292e20546865b0206465706f736974206f66207468652072656d6f76656420766f74657273206172652072657475726e65642e000501205468697320697320616e20726f6f742066756e6374696f6e20746f2062652075736564206f6e6c7920666f7220636c65616e696e67207468652073746174652e00bc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d75737420626520726f6f742e002c2023203c7765696768743e61012054686520746f74616c206e756d626572206f6620766f7465727320616e642074686f736520746861742061726520646566756e6374206d7573742062652070726f7669646564206173207769746e65737320646174612e302023203c2f7765696768743e011c1c4e65775465726d04645665633c284163636f756e7449642c2042616c616e6365293e1069012041206e6577207465726d2077697468205c5b6e65775f6d656d626572735c5d2e205468697320696e64696361746573207468617420656e6f7567682063616e64696461746573206578697374656420746f2072756e20746865590120656c656374696f6e2c206e6f74207468617420656e6f756768206861766520686173206265656e20656c65637465642e2054686520696e6e65722076616c7565206d757374206265206578616d696e656420666f726901207468697320707572706f73652e204120604e65775465726d285c5b5c5d296020696e64696361746573207468617420736f6d652063616e6469646174657320676f7420746865697220626f6e6420736c617368656420616e645901206e6f6e65207765726520656c65637465642c207768696c73742060456d7074795465726d60206d65616e732074686174206e6f2063616e64696461746573206578697374656420746f20626567696e20776974682e24456d7074795465726d00083501204e6f20286f72206e6f7420656e6f756768292063616e64696461746573206578697374656420666f72207468697320726f756e642e205468697320697320646966666572656e742066726f6dcc20604e65775465726d285c5b5c5d29602e2053656520746865206465736372697074696f6e206f6620604e65775465726d602e34456c656374696f6e4572726f720004e820496e7465726e616c206572726f722068617070656e6564207768696c6520747279696e6720746f20706572666f726d20656c656374696f6e2e304d656d6265724b69636b656404244163636f756e7449640855012041205c5b6d656d6265725c5d20686173206265656e2072656d6f7665642e20546869732073686f756c6420616c7761797320626520666f6c6c6f7765642062792065697468657220604e65775465726d60206f72342060456d7074795465726d602e2452656e6f756e63656404244163636f756e744964049c20536f6d656f6e65206861732072656e6f756e6365642074686569722063616e6469646163792e4043616e646964617465536c617368656408244163636f756e7449641c42616c616e6365105d012041205c5b63616e6469646174655c5d2077617320736c6173686564206279205c5b616d6f756e745c5d2064756520746f206661696c696e6720746f206f627461696e20612073656174206173206d656d626572206f722c2072756e6e65722d75702e00e8204e6f74652074686174206f6c64206d656d6265727320616e642072756e6e6572732d75702061726520616c736f2063616e646964617465732e4453656174486f6c646572536c617368656408244163636f756e7449641c42616c616e63650459012041205c5b7365617420686f6c6465725c5d2077617320736c6173686564206279205c5b616d6f756e745c5d206279206265696e6720666f72636566756c6c792072656d6f7665642066726f6d20746865207365742e1c3443616e646964616379426f6e643042616c616e63654f663c543e40aa821bce2600000000000000000000000038566f74696e67426f6e64426173653042616c616e63654f663c543e40488fee950a03000000000000000000000040566f74696e67426f6e64466163746f723042616c616e63654f663c543e40002de43d0100000000000000000000000038446573697265644d656d626572730c753332101300000000404465736972656452756e6e65727355700c753332101300000000305465726d4475726174696f6e38543a3a426c6f636b4e756d626572104038000000204d6f64756c654964384c6f636b4964656e74696669657220706872656c656374004430556e61626c65546f566f746504c42043616e6e6f7420766f7465207768656e206e6f2063616e64696461746573206f72206d656d626572732065786973742e1c4e6f566f7465730498204d75737420766f746520666f72206174206c65617374206f6e652063616e6469646174652e30546f6f4d616e79566f74657304882043616e6e6f7420766f7465206d6f7265207468616e2063616e646964617465732e504d6178696d756d566f7465734578636565646564049c2043616e6e6f7420766f7465206d6f7265207468616e206d6178696d756d20616c6c6f7765642e284c6f7742616c616e636504c82043616e6e6f7420766f74652077697468207374616b65206c657373207468616e206d696e696d756d2062616c616e63652e3c556e61626c65546f506179426f6e64047c20566f7465722063616e206e6f742070617920766f74696e6720626f6e642e2c4d7573744265566f7465720444204d757374206265206120766f7465722e285265706f727453656c6604502043616e6e6f74207265706f72742073656c662e4c4475706c69636174656443616e6469646174650484204475706c6963617465642063616e646964617465207375626d697373696f6e2e304d656d6265725375626d6974048c204d656d6265722063616e6e6f742072652d7375626d69742063616e6469646163792e3852756e6e657255705375626d6974048c2052756e6e65722063616e6e6f742072652d7375626d69742063616e6469646163792e68496e73756666696369656e7443616e64696461746546756e647304982043616e64696461746520646f6573206e6f74206861766520656e6f7567682066756e64732e244e6f744d656d6265720438204e6f742061206d656d6265722e48496e76616c69645769746e6573734461746104e4205468652070726f766964656420636f756e74206f66206e756d626572206f662063616e6469646174657320697320696e636f72726563742e40496e76616c6964566f7465436f756e7404d0205468652070726f766964656420636f756e74206f66206e756d626572206f6620766f74657320697320696e636f72726563742e44496e76616c696452656e6f756e63696e67040101205468652072656e6f756e63696e67206f726967696e2070726573656e74656420612077726f6e67206052656e6f756e63696e676020706172616d657465722e48496e76616c69645265706c6163656d656e740401012050726564696374696f6e20726567617264696e67207265706c6163656d656e74206166746572206d656d6265722072656d6f76616c2069732077726f6e672e104c546563686e6963616c4d656d62657273686970014c496e7374616e6365314d656d62657273686970081c4d656d626572730100445665633c543a3a4163636f756e7449643e040004c8205468652063757272656e74206d656d626572736869702c2073746f72656420617320616e206f726465726564205665632e145072696d65000030543a3a4163636f756e744964040004a4205468652063757272656e74207072696d65206d656d6265722c206966206f6e65206578697374732e011c286164645f6d656d626572040c77686f30543a3a4163636f756e7449640c7c204164642061206d656d626572206077686f6020746f20746865207365742e00a0204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a4164644f726967696e602e3472656d6f76655f6d656d626572040c77686f30543a3a4163636f756e7449640c902052656d6f76652061206d656d626572206077686f602066726f6d20746865207365742e00ac204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a52656d6f76654f726967696e602e2c737761705f6d656d626572081872656d6f766530543a3a4163636f756e7449640c61646430543a3a4163636f756e74496414c02053776170206f7574206f6e65206d656d626572206072656d6f76656020666f7220616e6f746865722060616464602e00a4204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a537761704f726967696e602e001101205072696d65206d656d62657273686970206973202a6e6f742a207061737365642066726f6d206072656d6f76656020746f2060616464602c20696620657874616e742e3472657365745f6d656d62657273041c6d656d62657273445665633c543a3a4163636f756e7449643e105901204368616e676520746865206d656d6265727368697020746f2061206e6577207365742c20646973726567617264696e6720746865206578697374696e67206d656d626572736869702e204265206e69636520616e646c207061737320606d656d6265727360207072652d736f727465642e00a8204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a52657365744f726967696e602e286368616e67655f6b6579040c6e657730543a3a4163636f756e74496414d82053776170206f7574207468652073656e64696e67206d656d62657220666f7220736f6d65206f74686572206b657920606e6577602e00f4204d6179206f6e6c792062652063616c6c65642066726f6d20605369676e656460206f726967696e206f6620612063757272656e74206d656d6265722e002101205072696d65206d656d62657273686970206973207061737365642066726f6d20746865206f726967696e206163636f756e7420746f20606e6577602c20696620657874616e742e247365745f7072696d65040c77686f30543a3a4163636f756e7449640cc02053657420746865207072696d65206d656d6265722e204d75737420626520612063757272656e74206d656d6265722e00a8204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a5072696d654f726967696e602e2c636c6561725f7072696d65000c982052656d6f766520746865207072696d65206d656d626572206966206974206578697374732e00a8204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a5072696d654f726967696e602e01182c4d656d62657241646465640004e42054686520676976656e206d656d626572207761732061646465643b2073656520746865207472616e73616374696f6e20666f722077686f2e344d656d62657252656d6f7665640004ec2054686520676976656e206d656d626572207761732072656d6f7665643b2073656520746865207472616e73616374696f6e20666f722077686f2e384d656d62657273537761707065640004dc2054776f206d656d62657273207765726520737761707065643b2073656520746865207472616e73616374696f6e20666f722077686f2e304d656d6265727352657365740004190120546865206d656d62657273686970207761732072657365743b2073656520746865207472616e73616374696f6e20666f722077686f20746865206e6577207365742069732e284b65794368616e676564000488204f6e65206f6620746865206d656d6265727327206b657973206368616e6765642e1444756d6d7904bc73705f7374643a3a6d61726b65723a3a5068616e746f6d446174613c284163636f756e7449642c204576656e74293e0470205068616e746f6d206d656d6265722c206e6576657220757365642e000011205472656173757279012054726561737572790c3450726f706f73616c436f756e7401003450726f706f73616c496e646578100000000004a4204e756d626572206f662070726f706f73616c7320746861742068617665206265656e206d6164652e2450726f706f73616c730001053450726f706f73616c496e6465789c50726f706f73616c3c543a3a4163636f756e7449642c2042616c616e63654f663c542c20493e3e000400047c2050726f706f73616c7320746861742068617665206265656e206d6164652e24417070726f76616c730100485665633c50726f706f73616c496e6465783e040004f82050726f706f73616c20696e646963657320746861742068617665206265656e20617070726f76656420627574206e6f742079657420617761726465642e010c3470726f706f73655f7370656e64081476616c756560436f6d706163743c42616c616e63654f663c542c20493e3e2c62656e65666963696172798c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365242d012050757420666f727761726420612073756767657374696f6e20666f72207370656e64696e672e2041206465706f7369742070726f706f7274696f6e616c20746f207468652076616c7565350120697320726573657276656420616e6420736c6173686564206966207468652070726f706f73616c2069732072656a65637465642e2049742069732072657475726e6564206f6e636520746865542070726f706f73616c20697320617761726465642e002c2023203c7765696768743e4c202d20436f6d706c65786974793a204f283129b4202d20446252656164733a206050726f706f73616c436f756e74602c20606f726967696e206163636f756e7460ec202d2044625772697465733a206050726f706f73616c436f756e74602c206050726f706f73616c73602c20606f726967696e206163636f756e7460302023203c2f7765696768743e3c72656a6563745f70726f706f73616c042c70726f706f73616c5f696458436f6d706163743c50726f706f73616c496e6465783e24fc2052656a65637420612070726f706f736564207370656e642e20546865206f726967696e616c206465706f7369742077696c6c20626520736c61736865642e00ac204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a52656a6563744f726967696e602e002c2023203c7765696768743e4c202d20436f6d706c65786974793a204f283129d0202d20446252656164733a206050726f706f73616c73602c206072656a65637465642070726f706f736572206163636f756e7460d4202d2044625772697465733a206050726f706f73616c73602c206072656a65637465642070726f706f736572206163636f756e7460302023203c2f7765696768743e40617070726f76655f70726f706f73616c042c70726f706f73616c5f696458436f6d706163743c50726f706f73616c496e6465783e285d0120417070726f766520612070726f706f73616c2e2041742061206c617465722074696d652c207468652070726f706f73616c2077696c6c20626520616c6c6f636174656420746f207468652062656e6566696369617279ac20616e6420746865206f726967696e616c206465706f7369742077696c6c2062652072657475726e65642e00b0204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a417070726f76654f726967696e602e002c2023203c7765696768743e50202d20436f6d706c65786974793a204f2831292e90202d20446252656164733a206050726f706f73616c73602c2060417070726f76616c73605c202d20446257726974653a2060417070726f76616c7360302023203c2f7765696768743e011c2050726f706f736564043450726f706f73616c496e6465780484204e65772070726f706f73616c2e205c5b70726f706f73616c5f696e6465785c5d205370656e64696e67041c42616c616e6365043d01205765206861766520656e6465642061207370656e6420706572696f6420616e642077696c6c206e6f7720616c6c6f636174652066756e64732e205c5b6275646765745f72656d61696e696e675c5d1c417761726465640c3450726f706f73616c496e6465781c42616c616e6365244163636f756e744964041d0120536f6d652066756e64732068617665206265656e20616c6c6f63617465642e205c5b70726f706f73616c5f696e6465782c2061776172642c2062656e65666963696172795c5d2052656a6563746564083450726f706f73616c496e6465781c42616c616e636504250120412070726f706f73616c207761732072656a65637465643b2066756e6473207765726520736c61736865642e205c5b70726f706f73616c5f696e6465782c20736c61736865645c5d144275726e74041c42616c616e636504b020536f6d65206f66206f75722066756e64732068617665206265656e206275726e742e205c5b6275726e5c5d20526f6c6c6f766572041c42616c616e6365083101205370656e64696e67206861732066696e69736865643b20746869732069732074686520616d6f756e74207468617420726f6c6c73206f76657220756e74696c206e657874207370656e642e54205c5b6275646765745f72656d61696e696e675c5d1c4465706f736974041c42616c616e636504b020536f6d652066756e64732068617665206265656e206465706f73697465642e205c5b6465706f7369745c5d143050726f706f73616c426f6e641c5065726d696c6c1050c30000085501204672616374696f6e206f6620612070726f706f73616c27732076616c756520746861742073686f756c6420626520626f6e64656420696e206f7264657220746f20706c616365207468652070726f706f73616c2e110120416e2061636365707465642070726f706f73616c2067657473207468657365206261636b2e20412072656a65637465642070726f706f73616c20646f6573206e6f742e4c50726f706f73616c426f6e644d696e696d756d3c42616c616e63654f663c542c20493e404835261a080300000000000000000000044901204d696e696d756d20616d6f756e74206f662066756e647320746861742073686f756c6420626520706c6163656420696e2061206465706f73697420666f72206d616b696e6720612070726f706f73616c2e2c5370656e64506572696f6438543a3a426c6f636b4e756d6265721080510100048820506572696f64206265747765656e2073756363657373697665207370656e64732e104275726e1c5065726d696c6c10d00700000411012050657263656e74616765206f662073706172652066756e64732028696620616e7929207468617420617265206275726e7420706572207370656e6420706572696f642e204d6f64756c654964204d6f64756c6549642070792f7472737279041901205468652074726561737572792773206d6f64756c652069642c207573656420666f72206465726976696e672069747320736f7665726569676e206163636f756e742049442e0870496e73756666696369656e7450726f706f7365727342616c616e6365047c2050726f706f73657227732062616c616e636520697320746f6f206c6f772e30496e76616c6964496e6465780494204e6f2070726f706f73616c206f7220626f756e7479206174207468617420696e6465782e1218436c61696d730118436c61696d731418436c61696d730001063c457468657265756d416464726573733042616c616e63654f663c543e0004000014546f74616c01003042616c616e63654f663c543e4000000000000000000000000000000000001c56657374696e670001063c457468657265756d41646472657373b02842616c616e63654f663c543e2c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265722900040010782056657374696e67207363686564756c6520666f72206120636c61696d2e0d012046697273742062616c616e63652069732074686520746f74616c20616d6f756e7420746861742073686f756c642062652068656c6420666f722076657374696e672ee4205365636f6e642062616c616e636520697320686f77206d7563682073686f756c6420626520756e6c6f636b65642070657220626c6f636b2ecc2054686520626c6f636b206e756d626572206973207768656e207468652076657374696e672073686f756c642073746172742e1c5369676e696e670001063c457468657265756d416464726573733453746174656d656e744b696e6400040004c0205468652073746174656d656e74206b696e642074686174206d757374206265207369676e65642c20696620616e792e24507265636c61696d7300010630543a3a4163636f756e7449643c457468657265756d41646472657373000400042d01205072652d636c61696d656420457468657265756d206163636f756e74732c20627920746865204163636f756e74204944207468617420746865792061726520636c61696d656420746f2e011414636c61696d08106465737430543a3a4163636f756e74496448657468657265756d5f7369676e61747572653845636473615369676e6174757265608c204d616b65206120636c61696d20746f20636f6c6c65637420796f757220444f54732e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f4e6f6e655f2e005420556e7369676e65642056616c69646174696f6e3a090120412063616c6c20746f20636c61696d206973206465656d65642076616c696420696620746865207369676e61747572652070726f7669646564206d6174636865738020746865206578706563746564207369676e6564206d657373616765206f663a006c203e20457468657265756d205369676e6564204d6573736167653a98203e2028636f6e666967757265642070726566697820737472696e672928616464726573732900a820616e6420606164647265737360206d6174636865732074686520606465737460206163636f756e742e003020506172616d65746572733adc202d206064657374603a205468652064657374696e6174696f6e206163636f756e7420746f207061796f75742074686520636c61696d2e1101202d2060657468657265756d5f7369676e6174757265603a20546865207369676e6174757265206f6620616e20657468657265756d207369676e6564206d657373616765a0202020206d61746368696e672074686520666f726d6174206465736372696265642061626f76652e0024203c7765696768743e01012054686520776569676874206f6620746869732063616c6c20697320696e76617269616e74206f7665722074686520696e70757420706172616d65746572732ee42057656967687420696e636c75646573206c6f67696320746f2076616c696461746520756e7369676e65642060636c61696d602063616c6c2e005c20546f74616c20436f6d706c65786974793a204f28312928203c2f7765696768743e286d696e745f636c61696d100c77686f3c457468657265756d416464726573731476616c75653042616c616e63654f663c543e4076657374696e675f7363686564756c65d04f7074696f6e3c2842616c616e63654f663c543e2c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d626572293e2473746174656d656e74544f7074696f6e3c53746174656d656e744b696e643e3c88204d696e742061206e657720636c61696d20746f20636f6c6c65637420444f54732e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f526f6f745f2e003020506172616d65746572733af4202d206077686f603a2054686520457468657265756d206164647265737320616c6c6f77656420746f20636f6c6c656374207468697320636c61696d2ed0202d206076616c7565603a20546865206e756d626572206f6620444f547320746861742077696c6c20626520636c61696d65642e0d01202d206076657374696e675f7363686564756c65603a20416e206f7074696f6e616c2076657374696e67207363686564756c6520666f7220746865736520444f54732e0024203c7765696768743e01012054686520776569676874206f6620746869732063616c6c20697320696e76617269616e74206f7665722074686520696e70757420706172616d65746572732e210120576520617373756d6520776f7273742063617365207468617420626f74682076657374696e6720616e642073746174656d656e74206973206265696e6720696e7365727465642e005c20546f74616c20436f6d706c65786974793a204f28312928203c2f7765696768743e30636c61696d5f6174746573740c106465737430543a3a4163636f756e74496448657468657265756d5f7369676e61747572653845636473615369676e61747572652473746174656d656e741c5665633c75383e68e8204d616b65206120636c61696d20746f20636f6c6c65637420796f757220444f5473206279207369676e696e6720612073746174656d656e742e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f4e6f6e655f2e005420556e7369676e65642056616c69646174696f6e3a2d0120412063616c6c20746f2060636c61696d5f61747465737460206973206465656d65642076616c696420696620746865207369676e61747572652070726f7669646564206d6174636865738020746865206578706563746564207369676e6564206d657373616765206f663a006c203e20457468657265756d205369676e6564204d6573736167653ac4203e2028636f6e666967757265642070726566697820737472696e67292861646472657373292873746174656d656e7429004d0120616e6420606164647265737360206d6174636865732074686520606465737460206163636f756e743b20746865206073746174656d656e7460206d757374206d617463682074686174207768696368206973c4206578706563746564206163636f7264696e6720746f20796f757220707572636861736520617272616e67656d656e742e003020506172616d65746572733adc202d206064657374603a205468652064657374696e6174696f6e206163636f756e7420746f207061796f75742074686520636c61696d2e1101202d2060657468657265756d5f7369676e6174757265603a20546865207369676e6174757265206f6620616e20657468657265756d207369676e6564206d657373616765a0202020206d61746368696e672074686520666f726d6174206465736372696265642061626f76652e6901202d206073746174656d656e74603a20546865206964656e74697479206f66207468652073746174656d656e74207768696368206973206265696e6720617474657374656420746f20696e20746865207369676e61747572652e0024203c7765696768743e01012054686520776569676874206f6620746869732063616c6c20697320696e76617269616e74206f7665722074686520696e70757420706172616d65746572732e01012057656967687420696e636c75646573206c6f67696320746f2076616c696461746520756e7369676e65642060636c61696d5f617474657374602063616c6c2e005c20546f74616c20436f6d706c65786974793a204f28312928203c2f7765696768743e18617474657374042473746174656d656e741c5665633c75383e44f82041747465737420746f20612073746174656d656e742c206e656564656420746f2066696e616c697a652074686520636c61696d732070726f636573732e006901205741524e494e473a20496e73656375726520756e6c65737320796f757220636861696e20696e636c75646573206050726576616c69646174654174746573747360206173206120605369676e6564457874656e73696f6e602e005420556e7369676e65642056616c69646174696f6e3a2d0120412063616c6c20746f20617474657374206973206465656d65642076616c6964206966207468652073656e6465722068617320612060507265636c61696d602072656769737465726564f820616e642070726f76696465732061206073746174656d656e746020776869636820697320657870656374656420666f7220746865206163636f756e742e003020506172616d65746572733a6901202d206073746174656d656e74603a20546865206964656e74697479206f66207468652073746174656d656e74207768696368206973206265696e6720617474657374656420746f20696e20746865207369676e61747572652e0024203c7765696768743e01012054686520776569676874206f6620746869732063616c6c20697320696e76617269616e74206f7665722074686520696e70757420706172616d65746572732ef42057656967687420696e636c75646573206c6f67696320746f20646f207072652d76616c69646174696f6e206f6e2060617474657374602063616c6c2e005c20546f74616c20436f6d706c65786974793a204f28312928203c2f7765696768743e286d6f76655f636c61696d0c0c6f6c643c457468657265756d416464726573730c6e65773c457468657265756d41646472657373386d617962655f707265636c61696d504f7074696f6e3c543a3a4163636f756e7449643e0001041c436c61696d65640c244163636f756e7449643c457468657265756d416464726573731c42616c616e636504ec20536f6d656f6e6520636c61696d656420736f6d6520444f54732e205b77686f2c20657468657265756d5f616464726573732c20616d6f756e745d041850726566697814265b75385d807c506179204b534d7320746f20746865204b7573616d61206163636f756e743a04150120546865205072656669782074686174206973207573656420696e207369676e656420457468657265756d206d6573736167657320666f722074686973206e6574776f726b1860496e76616c6964457468657265756d5369676e6174757265047020496e76616c696420457468657265756d207369676e61747572652e405369676e65724861734e6f436c61696d047c20457468657265756d206164647265737320686173206e6f20636c61696d2e4053656e6465724861734e6f436c61696d0490204163636f756e742049442073656e64696e6720747820686173206e6f20636c61696d2e30506f74556e646572666c6f770865012054686572652773206e6f7420656e6f75676820696e2074686520706f7420746f20706179206f757420736f6d6520756e76657374656420616d6f756e742e2047656e6572616c6c7920696d706c6965732061206c6f6769631c206572726f722e40496e76616c696453746174656d656e7404942041206e65656465642073746174656d656e7420776173206e6f7420696e636c756465642e4c56657374656442616c616e636545786973747304a820546865206163636f756e7420616c7265616479206861732061207665737465642062616c616e63652e131c5574696c69747900010c146261746368041463616c6c73605665633c3c5420617320436f6e6669673e3a3a43616c6c3e48802053656e642061206261746368206f662064697370617463682063616c6c732e007c204d61792062652063616c6c65642066726f6d20616e79206f726967696e2e00f0202d206063616c6c73603a205468652063616c6c7320746f20626520646973706174636865642066726f6d207468652073616d65206f726967696e2e006101204966206f726967696e20697320726f6f74207468656e2063616c6c2061726520646973706174636820776974686f757420636865636b696e67206f726967696e2066696c7465722e20285468697320696e636c75646573cc20627970617373696e6720606672616d655f73797374656d3a3a436f6e6669673a3a4261736543616c6c46696c74657260292e002c2023203c7765696768743e0501202d20436f6d706c65786974793a204f284329207768657265204320697320746865206e756d626572206f662063616c6c7320746f20626520626174636865642e302023203c2f7765696768743e00590120546869732077696c6c2072657475726e20604f6b6020696e20616c6c2063697263756d7374616e6365732e20546f2064657465726d696e65207468652073756363657373206f66207468652062617463682c20616e3501206576656e74206973206465706f73697465642e20496620612063616c6c206661696c656420616e64207468652062617463682077617320696e7465727275707465642c207468656e20746865590120604261746368496e74657272757074656460206576656e74206973206465706f73697465642c20616c6f6e67207769746820746865206e756d626572206f66207375636365737366756c2063616c6c73206d616465510120616e6420746865206572726f72206f6620746865206661696c65642063616c6c2e20496620616c6c2077657265207375636365737366756c2c207468656e2074686520604261746368436f6d706c657465646050206576656e74206973206465706f73697465642e3461735f646572697661746976650814696e6465780c7531361063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e34e02053656e6420612063616c6c207468726f75676820616e20696e64657865642070736575646f6e796d206f66207468652073656e6465722e0059012046696c7465722066726f6d206f726967696e206172652070617373656420616c6f6e672e205468652063616c6c2077696c6c2062652064697370617463686564207769746820616e206f726967696e207768696368c020757365207468652073616d652066696c74657220617320746865206f726967696e206f6620746869732063616c6c2e004901204e4f54453a20496620796f75206e65656420746f20656e73757265207468617420616e79206163636f756e742d62617365642066696c746572696e67206973206e6f7420686f6e6f7265642028692e652e6501206265636175736520796f7520657870656374206070726f78796020746f2068617665206265656e2075736564207072696f7220696e207468652063616c6c20737461636b20616e6420796f7520646f206e6f742077616e745501207468652063616c6c207265737472696374696f6e7320746f206170706c7920746f20616e79207375622d6163636f756e7473292c207468656e20757365206061735f6d756c74695f7468726573686f6c645f31608020696e20746865204d756c74697369672070616c6c657420696e73746561642e00f8204e4f54453a205072696f7220746f2076657273696f6e202a31322c2074686973207761732063616c6c6564206061735f6c696d697465645f737562602e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e2462617463685f616c6c041463616c6c73605665633c3c5420617320436f6e6669673e3a3a43616c6c3e34f02053656e642061206261746368206f662064697370617463682063616c6c7320616e642061746f6d6963616c6c792065786563757465207468656d2e2501205468652077686f6c65207472616e73616374696f6e2077696c6c20726f6c6c6261636b20616e64206661696c20696620616e79206f66207468652063616c6c73206661696c65642e007c204d61792062652063616c6c65642066726f6d20616e79206f726967696e2e00f0202d206063616c6c73603a205468652063616c6c7320746f20626520646973706174636865642066726f6d207468652073616d65206f726967696e2e006101204966206f726967696e20697320726f6f74207468656e2063616c6c2061726520646973706174636820776974686f757420636865636b696e67206f726967696e2066696c7465722e20285468697320696e636c75646573cc20627970617373696e6720606672616d655f73797374656d3a3a436f6e6669673a3a4261736543616c6c46696c74657260292e002c2023203c7765696768743e0501202d20436f6d706c65786974793a204f284329207768657265204320697320746865206e756d626572206f662063616c6c7320746f20626520626174636865642e302023203c2f7765696768743e0108404261746368496e746572727570746564080c7533323444697370617463684572726f72085901204261746368206f66206469737061746368657320646964206e6f7420636f6d706c6574652066756c6c792e20496e646578206f66206669727374206661696c696e6720646973706174636820676976656e2c206173902077656c6c20617320746865206572726f722e205c5b696e6465782c206572726f725c5d384261746368436f6d706c657465640004cc204261746368206f66206469737061746368657320636f6d706c657465642066756c6c792077697468206e6f206572726f722e000018204964656e7469747901204964656e7469747910284964656e746974794f6600010530543a3a4163636f756e74496468526567697374726174696f6e3c42616c616e63654f663c543e3e0004000c210120496e666f726d6174696f6e20746861742069732070657274696e656e7420746f206964656e746966792074686520656e7469747920626568696e6420616e206163636f756e742e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e1c53757065724f6600010230543a3a4163636f756e7449645028543a3a4163636f756e7449642c204461746129000400086101205468652073757065722d6964656e74697479206f6620616e20616c7465726e6174697665202273756222206964656e7469747920746f676574686572207769746820697473206e616d652c2077697468696e2074686174510120636f6e746578742e20496620746865206163636f756e74206973206e6f7420736f6d65206f74686572206163636f756e742773207375622d6964656e746974792c207468656e206a75737420604e6f6e65602e18537562734f6601010530543a3a4163636f756e744964842842616c616e63654f663c543e2c205665633c543a3a4163636f756e7449643e290044000000000000000000000000000000000014b820416c7465726e6174697665202273756222206964656e746974696573206f662074686973206163636f756e742e001d0120546865206669727374206974656d20697320746865206465706f7369742c20746865207365636f6e64206973206120766563746f72206f6620746865206163636f756e74732e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e28526567697374726172730100d85665633c4f7074696f6e3c526567697374726172496e666f3c42616c616e63654f663c543e2c20543a3a4163636f756e7449643e3e3e0400104d012054686520736574206f6620726567697374726172732e204e6f7420657870656374656420746f206765742076657279206269672061732063616e206f6e6c79206265206164646564207468726f7567682061a8207370656369616c206f726967696e20286c696b656c79206120636f756e63696c206d6f74696f6e292e0029012054686520696e64657820696e746f20746869732063616e206265206361737420746f2060526567697374726172496e6465786020746f2067657420612076616c69642076616c75652e013c346164645f726567697374726172041c6163636f756e7430543a3a4163636f756e744964347c2041646420612072656769737472617220746f207468652073797374656d2e00010120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652060543a3a5265676973747261724f726967696e602e00ac202d20606163636f756e74603a20746865206163636f756e74206f6620746865207265676973747261722e009820456d6974732060526567697374726172416464656460206966207375636365737366756c2e002c2023203c7765696768743e2901202d20604f2852296020776865726520605260207265676973747261722d636f756e742028676f7665726e616e63652d626f756e64656420616e6420636f64652d626f756e646564292e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28522960292e34202d204f6e65206576656e742e302023203c2f7765696768743e307365745f6964656e746974790410696e666f304964656e74697479496e666f4c2d012053657420616e206163636f756e742773206964656e7469747920696e666f726d6174696f6e20616e6420726573657276652074686520617070726f707269617465206465706f7369742e00590120496620746865206163636f756e7420616c726561647920686173206964656e7469747920696e666f726d6174696f6e2c20746865206465706f7369742069732074616b656e2061732070617274207061796d656e745420666f7220746865206e6577206465706f7369742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e0090202d2060696e666f603a20546865206964656e7469747920696e666f726d6174696f6e2e008c20456d69747320604964656e7469747953657460206966207375636365737366756c2e002c2023203c7765696768743e48202d20604f2858202b205827202b2052296021012020202d20776865726520605860206164646974696f6e616c2d6669656c642d636f756e7420286465706f7369742d626f756e64656420616e6420636f64652d626f756e64656429e42020202d20776865726520605260206a756467656d656e74732d636f756e7420287265676973747261722d636f756e742d626f756e6465642984202d204f6e652062616c616e63652072657365727665206f7065726174696f6e2e2501202d204f6e652073746f72616765206d75746174696f6e2028636f6465632d7265616420604f285827202b205229602c20636f6465632d777269746520604f2858202b20522960292e34202d204f6e65206576656e742e302023203c2f7765696768743e207365745f73756273041073756273645665633c28543a3a4163636f756e7449642c2044617461293e54902053657420746865207375622d6163636f756e7473206f66207468652073656e6465722e005901205061796d656e743a20416e79206167677265676174652062616c616e63652072657365727665642062792070726576696f757320607365745f73756273602063616c6c732077696c6c2062652072657475726e6564310120616e6420616e20616d6f756e7420605375624163636f756e744465706f736974602077696c6c20626520726573657276656420666f722065616368206974656d20696e206073756273602e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061207265676973746572656428206964656e746974792e00b4202d206073756273603a20546865206964656e74697479277320286e657729207375622d6163636f756e74732e002c2023203c7765696768743e34202d20604f2850202b20532960e82020202d20776865726520605060206f6c642d737562732d636f756e742028686172642d20616e64206465706f7369742d626f756e646564292ed82020202d2077686572652060536020737562732d636f756e742028686172642d20616e64206465706f7369742d626f756e646564292e88202d204174206d6f7374206f6e652062616c616e6365206f7065726174696f6e732e18202d2044423ae02020202d206050202b2053602073746f72616765206d75746174696f6e732028636f64656320636f6d706c657869747920604f2831296029c02020202d204f6e652073746f7261676520726561642028636f64656320636f6d706c657869747920604f28502960292ec42020202d204f6e652073746f726167652077726974652028636f64656320636f6d706c657869747920604f28532960292ed42020202d204f6e652073746f726167652d6578697374732028604964656e746974794f663a3a636f6e7461696e735f6b657960292e302023203c2f7765696768743e38636c6561725f6964656e7469747900483d0120436c65617220616e206163636f756e742773206964656e7469747920696e666f20616e6420616c6c207375622d6163636f756e747320616e642072657475726e20616c6c206465706f736974732e00f0205061796d656e743a20416c6c2072657365727665642062616c616e636573206f6e20746865206163636f756e74206172652072657475726e65642e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061207265676973746572656428206964656e746974792e009c20456d69747320604964656e74697479436c656172656460206966207375636365737366756c2e002c2023203c7765696768743e44202d20604f2852202b2053202b20582960d02020202d20776865726520605260207265676973747261722d636f756e742028676f7665726e616e63652d626f756e646564292ed82020202d2077686572652060536020737562732d636f756e742028686172642d20616e64206465706f7369742d626f756e646564292e25012020202d20776865726520605860206164646974696f6e616c2d6669656c642d636f756e7420286465706f7369742d626f756e64656420616e6420636f64652d626f756e646564292e8c202d204f6e652062616c616e63652d756e72657365727665206f7065726174696f6e2ecc202d206032602073746f7261676520726561647320616e64206053202b2032602073746f726167652064656c6574696f6e732e34202d204f6e65206576656e742e302023203c2f7765696768743e44726571756573745f6a756467656d656e7408247265675f696e6465785c436f6d706163743c526567697374726172496e6465783e1c6d61785f66656554436f6d706163743c42616c616e63654f663c543e3e5c9820526571756573742061206a756467656d656e742066726f6d2061207265676973747261722e005901205061796d656e743a204174206d6f737420606d61785f666565602077696c6c20626520726573657276656420666f72207061796d656e7420746f2074686520726567697374726172206966206a756467656d656e741c20676976656e2e00390120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061542072656769737465726564206964656e746974792e002101202d20607265675f696e646578603a2054686520696e646578206f6620746865207265676973747261722077686f7365206a756467656d656e74206973207265717565737465642e5901202d20606d61785f666565603a20546865206d6178696d756d206665652074686174206d617920626520706169642e20546869732073686f756c64206a757374206265206175746f2d706f70756c617465642061733a0034206060606e6f636f6d70696c65bc2053656c663a3a7265676973747261727328292e676574287265675f696e646578292e756e7772617028292e666565102060606000a820456d69747320604a756467656d656e7452657175657374656460206966207375636365737366756c2e002c2023203c7765696768743e38202d20604f2852202b205829602e84202d204f6e652062616c616e63652d72657365727665206f7065726174696f6e2ebc202d2053746f726167653a2031207265616420604f285229602c2031206d757461746520604f2858202b205229602e34202d204f6e65206576656e742e302023203c2f7765696768743e3863616e63656c5f7265717565737404247265675f696e64657838526567697374726172496e646578446c2043616e63656c20612070726576696f757320726571756573742e00fc205061796d656e743a20412070726576696f75736c79207265736572766564206465706f7369742069732072657475726e6564206f6e20737563636573732e00390120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061542072656769737465726564206964656e746974792e004901202d20607265675f696e646578603a2054686520696e646578206f6620746865207265676973747261722077686f7365206a756467656d656e74206973206e6f206c6f6e676572207265717565737465642e00b020456d69747320604a756467656d656e74556e72657175657374656460206966207375636365737366756c2e002c2023203c7765696768743e38202d20604f2852202b205829602e84202d204f6e652062616c616e63652d72657365727665206f7065726174696f6e2e8c202d204f6e652073746f72616765206d75746174696f6e20604f2852202b205829602e30202d204f6e65206576656e74302023203c2f7765696768743e1c7365745f6665650814696e6465785c436f6d706163743c526567697374726172496e6465783e0c66656554436f6d706163743c42616c616e63654f663c543e3e341d0120536574207468652066656520726571756972656420666f722061206a756467656d656e7420746f206265207265717565737465642066726f6d2061207265676973747261722e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74a4206f6620746865207265676973747261722077686f736520696e6465782069732060696e646578602e00f8202d2060696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f73652066656520697320746f206265207365742e58202d2060666565603a20746865206e6577206665652e002c2023203c7765696768743e28202d20604f285229602e7c202d204f6e652073746f72616765206d75746174696f6e20604f285229602ee8202d2042656e63686d61726b3a20372e333135202b2052202a20302e33323920c2b57320286d696e207371756172657320616e616c7973697329302023203c2f7765696768743e387365745f6163636f756e745f69640814696e6465785c436f6d706163743c526567697374726172496e6465783e0c6e657730543a3a4163636f756e74496434c0204368616e676520746865206163636f756e74206173736f63696174656420776974682061207265676973747261722e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74a4206f6620746865207265676973747261722077686f736520696e6465782069732060696e646578602e00f8202d2060696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f73652066656520697320746f206265207365742e74202d20606e6577603a20746865206e6577206163636f756e742049442e002c2023203c7765696768743e28202d20604f285229602e7c202d204f6e652073746f72616765206d75746174696f6e20604f285229602ee4202d2042656e63686d61726b3a20382e383233202b2052202a20302e333220c2b57320286d696e207371756172657320616e616c7973697329302023203c2f7765696768743e287365745f6669656c64730814696e6465785c436f6d706163743c526567697374726172496e6465783e186669656c6473384964656e746974794669656c647334ac2053657420746865206669656c6420696e666f726d6174696f6e20666f722061207265676973747261722e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74a4206f6620746865207265676973747261722077686f736520696e6465782069732060696e646578602e00f8202d2060696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f73652066656520697320746f206265207365742e1101202d20606669656c6473603a20746865206669656c64732074686174207468652072656769737472617220636f6e6365726e73207468656d73656c76657320776974682e002c2023203c7765696768743e28202d20604f285229602e7c202d204f6e652073746f72616765206d75746174696f6e20604f285229602ee8202d2042656e63686d61726b3a20372e343634202b2052202a20302e33323520c2b57320286d696e207371756172657320616e616c7973697329302023203c2f7765696768743e4470726f766964655f6a756467656d656e740c247265675f696e6465785c436f6d706163743c526567697374726172496e6465783e187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365246a756467656d656e745c4a756467656d656e743c42616c616e63654f663c543e3e4cbc2050726f766964652061206a756467656d656e7420666f7220616e206163636f756e742773206964656e746974792e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74b4206f6620746865207265676973747261722077686f736520696e64657820697320607265675f696e646578602e002501202d20607265675f696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f7365206a756467656d656e74206973206265696e67206d6164652e5901202d2060746172676574603a20746865206163636f756e742077686f7365206964656e7469747920746865206a756467656d656e742069732075706f6e2e2054686973206d75737420626520616e206163636f756e74782020207769746820612072656769737465726564206964656e746974792e4d01202d20606a756467656d656e74603a20746865206a756467656d656e74206f662074686520726567697374726172206f6620696e64657820607265675f696e646578602061626f75742060746172676574602e009820456d69747320604a756467656d656e74476976656e60206966207375636365737366756c2e002c2023203c7765696768743e38202d20604f2852202b205829602e88202d204f6e652062616c616e63652d7472616e73666572206f7065726174696f6e2e98202d20557020746f206f6e65206163636f756e742d6c6f6f6b7570206f7065726174696f6e2ebc202d2053746f726167653a2031207265616420604f285229602c2031206d757461746520604f2852202b205829602e34202d204f6e65206576656e742e302023203c2f7765696768743e346b696c6c5f6964656e7469747904187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263654c45012052656d6f766520616e206163636f756e742773206964656e7469747920616e64207375622d6163636f756e7420696e666f726d6174696f6e20616e6420736c61736820746865206465706f736974732e006501205061796d656e743a2052657365727665642062616c616e6365732066726f6d20607365745f737562736020616e6420607365745f6964656e74697479602061726520736c617368656420616e642068616e646c656420627949012060536c617368602e20566572696669636174696f6e2072657175657374206465706f7369747320617265206e6f742072657475726e65643b20746865792073686f756c642062652063616e63656c6c656484206d616e75616c6c79207573696e67206063616e63656c5f72657175657374602e00fc20546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206d617463682060543a3a466f7263654f726967696e602e005901202d2060746172676574603a20746865206163636f756e742077686f7365206964656e7469747920746865206a756467656d656e742069732075706f6e2e2054686973206d75737420626520616e206163636f756e74782020207769746820612072656769737465726564206964656e746974792e009820456d69747320604964656e746974794b696c6c656460206966207375636365737366756c2e002c2023203c7765696768743e48202d20604f2852202b2053202b205829602e84202d204f6e652062616c616e63652d72657365727665206f7065726174696f6e2e74202d206053202b2032602073746f72616765206d75746174696f6e732e34202d204f6e65206576656e742e302023203c2f7765696768743e1c6164645f737562080c7375628c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365106461746110446174611cb0204164642074686520676976656e206163636f756e7420746f207468652073656e646572277320737562732e006101205061796d656e743a2042616c616e636520726573657276656420627920612070726576696f757320607365745f73756273602063616c6c20666f72206f6e65207375622077696c6c2062652072657061747269617465643c20746f207468652073656e6465722e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573742068617665206120726567697374657265645c20737562206964656e74697479206f662060737562602e2872656e616d655f737562080c7375628c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651064617461104461746110d020416c74657220746865206173736f636961746564206e616d65206f662074686520676976656e207375622d6163636f756e742e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573742068617665206120726567697374657265645c20737562206964656e74697479206f662060737562602e2872656d6f76655f737562040c7375628c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651cc42052656d6f76652074686520676976656e206163636f756e742066726f6d207468652073656e646572277320737562732e006101205061796d656e743a2042616c616e636520726573657276656420627920612070726576696f757320607365745f73756273602063616c6c20666f72206f6e65207375622077696c6c2062652072657061747269617465643c20746f207468652073656e6465722e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573742068617665206120726567697374657265645c20737562206964656e74697479206f662060737562602e20717569745f7375620028902052656d6f7665207468652073656e6465722061732061207375622d6163636f756e742e006101205061796d656e743a2042616c616e636520726573657276656420627920612070726576696f757320607365745f73756273602063616c6c20666f72206f6e65207375622077696c6c206265207265706174726961746564b820746f207468652073656e64657220282a6e6f742a20746865206f726967696e616c206465706f7369746f72292e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d757374206861766520612072656769737465726564402073757065722d6964656e746974792e004901204e4f54453a20546869732073686f756c64206e6f74206e6f726d616c6c7920626520757365642c206275742069732070726f766964656420696e207468652063617365207468617420746865206e6f6e2d150120636f6e74726f6c6c6572206f6620616e206163636f756e74206973206d616c6963696f75736c7920726567697374657265642061732061207375622d6163636f756e742e01282c4964656e7469747953657404244163636f756e7449640411012041206e616d652077617320736574206f72207265736574202877686963682077696c6c2072656d6f766520616c6c206a756467656d656e7473292e205c5b77686f5c5d3c4964656e74697479436c656172656408244163636f756e7449641c42616c616e63650415012041206e616d652077617320636c65617265642c20616e642074686520676976656e2062616c616e63652072657475726e65642e205c5b77686f2c206465706f7369745c5d384964656e746974794b696c6c656408244163636f756e7449641c42616c616e6365040d012041206e616d65207761732072656d6f76656420616e642074686520676976656e2062616c616e636520736c61736865642e205c5b77686f2c206465706f7369745c5d484a756467656d656e7452657175657374656408244163636f756e74496438526567697374726172496e6465780405012041206a756467656d656e74207761732061736b65642066726f6d2061207265676973747261722e205c5b77686f2c207265676973747261725f696e6465785c5d504a756467656d656e74556e72657175657374656408244163636f756e74496438526567697374726172496e64657804f02041206a756467656d656e74207265717565737420776173207265747261637465642e205c5b77686f2c207265676973747261725f696e6465785c5d384a756467656d656e74476976656e08244163636f756e74496438526567697374726172496e6465780409012041206a756467656d656e742077617320676976656e2062792061207265676973747261722e205c5b7461726765742c207265676973747261725f696e6465785c5d3852656769737472617241646465640438526567697374726172496e64657804ac204120726567697374726172207761732061646465642e205c5b7265676973747261725f696e6465785c5d405375624964656e7469747941646465640c244163636f756e744964244163636f756e7449641c42616c616e63650455012041207375622d6964656e746974792077617320616464656420746f20616e206964656e7469747920616e6420746865206465706f73697420706169642e205c5b7375622c206d61696e2c206465706f7369745c5d485375624964656e7469747952656d6f7665640c244163636f756e744964244163636f756e7449641c42616c616e6365080d012041207375622d6964656e74697479207761732072656d6f7665642066726f6d20616e206964656e7469747920616e6420746865206465706f7369742066726565642e5c205c5b7375622c206d61696e2c206465706f7369745c5d485375624964656e746974795265766f6b65640c244163636f756e744964244163636f756e7449641c42616c616e6365081d012041207375622d6964656e746974792077617320636c65617265642c20616e642074686520676976656e206465706f7369742072657061747269617465642066726f6d207468652901206d61696e206964656e74697479206163636f756e7420746f20746865207375622d6964656e74697479206163636f756e742e205c5b7375622c206d61696e2c206465706f7369745c5d183042617369634465706f7369743042616c616e63654f663c543e40a41a130d84010000000000000000000004d82054686520616d6f756e742068656c64206f6e206465706f73697420666f7220612072656769737465726564206964656e746974792e304669656c644465706f7369743042616c616e63654f663c543e4004c64403610000000000000000000000042d012054686520616d6f756e742068656c64206f6e206465706f73697420706572206164646974696f6e616c206669656c6420666f7220612072656769737465726564206964656e746974792e445375624163636f756e744465706f7369743042616c616e63654f663c543e405405379c4d00000000000000000000000c65012054686520616d6f756e742068656c64206f6e206465706f73697420666f7220612072656769737465726564207375626163636f756e742e20546869732073686f756c64206163636f756e7420666f7220746865206661637471012074686174206f6e652073746f72616765206974656d27732076616c75652077696c6c20696e637265617365206279207468652073697a65206f6620616e206163636f756e742049442c20616e642074686572652077696c6c206265290120616e6f746865722074726965206974656d2077686f73652076616c7565206973207468652073697a65206f6620616e206163636f756e7420494420706c75732033322062797465732e384d61785375624163636f756e74730c7533321064000000040d0120546865206d6178696d756d206e756d626572206f66207375622d6163636f756e747320616c6c6f77656420706572206964656e746966696564206163636f756e742e4c4d61784164646974696f6e616c4669656c64730c7533321064000000086501204d6178696d756d206e756d626572206f66206164646974696f6e616c206669656c64732074686174206d61792062652073746f72656420696e20616e2049442e204e656564656420746f20626f756e642074686520492f4fe020726571756972656420746f2061636365737320616e206964656e746974792c206275742063616e2062652070726574747920686967682e344d6178526567697374726172730c7533321014000000085101204d61786d696d756d206e756d626572206f66207265676973747261727320616c6c6f77656420696e207468652073797374656d2e204e656564656420746f20626f756e642074686520636f6d706c65786974797c206f662c20652e672e2c207570646174696e67206a756467656d656e74732e4048546f6f4d616e795375624163636f756e7473046020546f6f206d616e7920737562732d6163636f756e74732e204e6f74466f756e640454204163636f756e742069736e277420666f756e642e204e6f744e616d65640454204163636f756e742069736e2774206e616d65642e28456d707479496e646578043420456d70747920696e6465782e284665654368616e676564044020466565206973206368616e6765642e284e6f4964656e74697479044c204e6f206964656e7469747920666f756e642e3c537469636b794a756467656d656e74044820537469636b79206a756467656d656e742e384a756467656d656e74476976656e0444204a756467656d656e7420676976656e2e40496e76616c69644a756467656d656e74044c20496e76616c6964206a756467656d656e742e30496e76616c6964496e64657804582054686520696e64657820697320696e76616c69642e34496e76616c6964546172676574045c205468652074617267657420697320696e76616c69642e34546f6f4d616e794669656c6473047020546f6f206d616e79206164646974696f6e616c206669656c64732e44546f6f4d616e795265676973747261727304ec204d6178696d756d20616d6f756e74206f66207265676973747261727320726561636865642e2043616e6e6f742061646420616e79206d6f72652e38416c7265616479436c61696d65640474204163636f756e7420494420697320616c7265616479206e616d65642e184e6f7453756204742053656e646572206973206e6f742061207375622d6163636f756e742e204e6f744f776e6564048c205375622d6163636f756e742069736e2774206f776e65642062792073656e6465722e191c536f6369657479011c536f6369657479401c466f756e646572000030543a3a4163636f756e7449640400044820546865206669727374206d656d6265722e1452756c657300001c543a3a48617368040008510120412068617368206f66207468652072756c6573206f66207468697320736f636965747920636f6e6365726e696e67206d656d626572736869702e2043616e206f6e6c7920626520736574206f6e636520616e6454206f6e6c792062792074686520666f756e6465722e2843616e6469646174657301009c5665633c4269643c543a3a4163636f756e7449642c2042616c616e63654f663c542c20493e3e3e0400043901205468652063757272656e7420736574206f662063616e646964617465733b206269646465727320746861742061726520617474656d7074696e6720746f206265636f6d65206d656d626572732e4c53757370656e64656443616e6469646174657300010530543a3a4163636f756e744964e42842616c616e63654f663c542c20493e2c204269644b696e643c543a3a4163636f756e7449642c2042616c616e63654f663c542c20493e3e2900040004842054686520736574206f662073757370656e6465642063616e646964617465732e0c506f7401003c42616c616e63654f663c542c20493e400000000000000000000000000000000004410120416d6f756e74206f66206f7572206163636f756e742062616c616e63652074686174206973207370656369666963616c6c7920666f7220746865206e65787420726f756e642773206269642873292e1048656164000030543a3a4163636f756e744964040004e820546865206d6f7374207072696d6172792066726f6d20746865206d6f737420726563656e746c7920617070726f766564206d656d626572732e1c4d656d626572730100445665633c543a3a4163636f756e7449643e04000494205468652063757272656e7420736574206f66206d656d626572732c206f7264657265642e4053757370656e6465644d656d6265727301010530543a3a4163636f756e74496410626f6f6c00040004782054686520736574206f662073757370656e646564206d656d626572732e104269647301009c5665633c4269643c543a3a4163636f756e7449642c2042616c616e63654f663c542c20493e3e3e040004e8205468652063757272656e7420626964732c2073746f726564206f726465726564206279207468652076616c7565206f6620746865206269642e20566f756368696e6700010530543a3a4163636f756e74496438566f756368696e6753746174757300040004e4204d656d626572732063757272656e746c7920766f756368696e67206f722062616e6e65642066726f6d20766f756368696e6720616761696e1c5061796f75747301010530543a3a4163636f756e744964985665633c28543a3a426c6f636b4e756d6265722c2042616c616e63654f663c542c20493e293e000400044d012050656e64696e67207061796f7574733b206f72646572656420627920626c6f636b206e756d6265722c20776974682074686520616d6f756e7420746861742073686f756c642062652070616964206f75742e1c537472696b657301010530543a3a4163636f756e7449642c537472696b65436f756e7400100000000004dc20546865206f6e676f696e67206e756d626572206f66206c6f73696e6720766f746573206361737420627920746865206d656d6265722e14566f74657300020530543a3a4163636f756e74496430543a3a4163636f756e74496410566f746505040004d020446f75626c65206d61702066726f6d2043616e646964617465202d3e20566f746572202d3e20284d617962652920566f74652e20446566656e646572000030543a3a4163636f756e744964040004c42054686520646566656e64696e67206d656d6265722063757272656e746c79206265696e67206368616c6c656e6765642e34446566656e646572566f74657300010530543a3a4163636f756e74496410566f7465000400046020566f74657320666f722074686520646566656e6465722e284d61784d656d6265727301000c753332100000000004dc20546865206d6178206e756d626572206f66206d656d6265727320666f722074686520736f6369657479206174206f6e652074696d652e01300c626964041476616c75653c42616c616e63654f663c542c20493e84e020412075736572206f757473696465206f662074686520736f63696574792063616e206d616b6520612062696420666f7220656e7472792e003901205061796d656e743a206043616e6469646174654465706f736974602077696c6c20626520726573657276656420666f72206d616b696e672061206269642e2049742069732072657475726e6564f0207768656e2074686520626964206265636f6d65732061206d656d6265722c206f7220696620746865206269642063616c6c732060756e626964602e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a5901202d206076616c7565603a2041206f6e652074696d65207061796d656e74207468652062696420776f756c64206c696b6520746f2072656365697665207768656e206a6f696e696e672074686520736f63696574792e002c2023203c7765696768743e5501204b65793a204220286c656e206f662062696473292c204320286c656e206f662063616e64696461746573292c204d20286c656e206f66206d656d62657273292c2058202862616c616e636520726573657276652944202d2053746f726167652052656164733aec20092d204f6e652073746f72616765207265616420746f20636865636b20666f722073757370656e6465642063616e6469646174652e204f283129e020092d204f6e652073746f72616765207265616420746f20636865636b20666f722073757370656e646564206d656d6265722e204f283129dc20092d204f6e652073746f72616765207265616420746f20726574726965766520616c6c2063757272656e7420626964732e204f284229f420092d204f6e652073746f72616765207265616420746f20726574726965766520616c6c2063757272656e742063616e646964617465732e204f284329c820092d204f6e652073746f72616765207265616420746f20726574726965766520616c6c206d656d626572732e204f284d2948202d2053746f72616765205772697465733a810120092d204f6e652073746f72616765206d757461746520746f206164642061206e65772062696420746f2074686520766563746f72204f2842292028544f444f3a20706f737369626c65206f7074696d697a6174696f6e20772f207265616429010120092d20557020746f206f6e652073746f726167652072656d6f76616c206966206269642e6c656e2829203e204d41585f4249445f434f554e542e204f2831295c202d204e6f7461626c6520436f6d7075746174696f6e3a2d0120092d204f2842202b2043202b206c6f67204d292073656172636820746f20636865636b2075736572206973206e6f7420616c726561647920612070617274206f6620736f63696574792ec420092d204f286c6f672042292073656172636820746f20696e7365727420746865206e65772062696420736f727465642e78202d2045787465726e616c204d6f64756c65204f7065726174696f6e733a9c20092d204f6e652062616c616e63652072657365727665206f7065726174696f6e2e204f285829210120092d20557020746f206f6e652062616c616e636520756e72657365727665206f7065726174696f6e20696620626964732e6c656e2829203e204d41585f4249445f434f554e542e28202d204576656e74733a6820092d204f6e65206576656e7420666f72206e6577206269642efc20092d20557020746f206f6e65206576656e7420666f72204175746f556e626964206966206269642e6c656e2829203e204d41585f4249445f434f554e542e00c420546f74616c20436f6d706c65786974793a204f284d202b2042202b2043202b206c6f674d202b206c6f6742202b205829302023203c2f7765696768743e14756e626964040c706f730c7533324cd82041206269646465722063616e2072656d6f76652074686569722062696420666f7220656e74727920696e746f20736f63696574792e010120427920646f696e6720736f2c20746865792077696c6c20686176652074686569722063616e646964617465206465706f7369742072657475726e6564206f728420746865792077696c6c20756e766f75636820746865697220766f75636865722e00fc205061796d656e743a2054686520626964206465706f73697420697320756e7265736572766564206966207468652075736572206d6164652061206269642e00050120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e642061206269646465722e003020506172616d65746572733a1901202d2060706f73603a20506f736974696f6e20696e207468652060426964736020766563746f72206f6620746865206269642077686f2077616e747320746f20756e6269642e002c2023203c7765696768743eb0204b65793a204220286c656e206f662062696473292c2058202862616c616e636520756e72657365727665290d01202d204f6e652073746f72616765207265616420616e6420777269746520746f20726574726965766520616e64207570646174652074686520626964732e204f2842294501202d20456974686572206f6e6520756e726573657276652062616c616e636520616374696f6e204f285829206f72206f6e6520766f756368696e672073746f726167652072656d6f76616c2e204f28312934202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2842202b205829302023203c2f7765696768743e14766f7563680c0c77686f30543a3a4163636f756e7449641476616c75653c42616c616e63654f663c542c20493e0c7469703c42616c616e63654f663c542c20493eb045012041732061206d656d6265722c20766f75636820666f7220736f6d656f6e6520746f206a6f696e20736f636965747920627920706c6163696e67206120626964206f6e20746865697220626568616c662e005501205468657265206973206e6f206465706f73697420726571756972656420746f20766f75636820666f722061206e6577206269642c206275742061206d656d6265722063616e206f6e6c7920766f75636820666f725d01206f6e652062696420617420612074696d652e2049662074686520626964206265636f6d657320612073757370656e6465642063616e64696461746520616e6420756c74696d6174656c792072656a65637465642062794101207468652073757370656e73696f6e206a756467656d656e74206f726967696e2c20746865206d656d6265722077696c6c2062652062616e6e65642066726f6d20766f756368696e6720616761696e2e005901204173206120766f756368696e67206d656d6265722c20796f752063616e20636c61696d206120746970206966207468652063616e6469646174652069732061636365707465642e2054686973207469702077696c6c51012062652070616964206173206120706f7274696f6e206f66207468652072657761726420746865206d656d6265722077696c6c207265636569766520666f72206a6f696e696e672074686520736f63696574792e00050120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e642061206d656d6265722e003020506172616d65746572733acc202d206077686f603a2054686520757365722077686f20796f7520776f756c64206c696b6520746f20766f75636820666f722e5101202d206076616c7565603a2054686520746f74616c2072657761726420746f2062652070616964206265747765656e20796f7520616e64207468652063616e6469646174652069662074686579206265636f6d65642061206d656d62657220696e2074686520736f63696574792e4901202d2060746970603a20596f757220637574206f662074686520746f74616c206076616c756560207061796f7574207768656e207468652063616e64696461746520697320696e64756374656420696e746f15012074686520736f63696574792e2054697073206c6172676572207468616e206076616c7565602077696c6c206265207361747572617465642075706f6e207061796f75742e002c2023203c7765696768743e0101204b65793a204220286c656e206f662062696473292c204320286c656e206f662063616e64696461746573292c204d20286c656e206f66206d656d626572732944202d2053746f726167652052656164733ac820092d204f6e652073746f72616765207265616420746f20726574726965766520616c6c206d656d626572732e204f284d29090120092d204f6e652073746f72616765207265616420746f20636865636b206d656d626572206973206e6f7420616c726561647920766f756368696e672e204f283129ec20092d204f6e652073746f72616765207265616420746f20636865636b20666f722073757370656e6465642063616e6469646174652e204f283129e020092d204f6e652073746f72616765207265616420746f20636865636b20666f722073757370656e646564206d656d6265722e204f283129dc20092d204f6e652073746f72616765207265616420746f20726574726965766520616c6c2063757272656e7420626964732e204f284229f420092d204f6e652073746f72616765207265616420746f20726574726965766520616c6c2063757272656e742063616e646964617465732e204f28432948202d2053746f72616765205772697465733a0d0120092d204f6e652073746f7261676520777269746520746f20696e7365727420766f756368696e672073746174757320746f20746865206d656d6265722e204f283129810120092d204f6e652073746f72616765206d757461746520746f206164642061206e65772062696420746f2074686520766563746f72204f2842292028544f444f3a20706f737369626c65206f7074696d697a6174696f6e20772f207265616429010120092d20557020746f206f6e652073746f726167652072656d6f76616c206966206269642e6c656e2829203e204d41585f4249445f434f554e542e204f2831295c202d204e6f7461626c6520436f6d7075746174696f6e3ac020092d204f286c6f67204d292073656172636820746f20636865636b2073656e6465722069732061206d656d6265722e2d0120092d204f2842202b2043202b206c6f67204d292073656172636820746f20636865636b2075736572206973206e6f7420616c726561647920612070617274206f6620736f63696574792ec420092d204f286c6f672042292073656172636820746f20696e7365727420746865206e65772062696420736f727465642e78202d2045787465726e616c204d6f64756c65204f7065726174696f6e733a9c20092d204f6e652062616c616e63652072657365727665206f7065726174696f6e2e204f285829210120092d20557020746f206f6e652062616c616e636520756e72657365727665206f7065726174696f6e20696620626964732e6c656e2829203e204d41585f4249445f434f554e542e28202d204576656e74733a6020092d204f6e65206576656e7420666f7220766f7563682efc20092d20557020746f206f6e65206576656e7420666f72204175746f556e626964206966206269642e6c656e2829203e204d41585f4249445f434f554e542e00c420546f74616c20436f6d706c65786974793a204f284d202b2042202b2043202b206c6f674d202b206c6f6742202b205829302023203c2f7765696768743e1c756e766f756368040c706f730c753332442d01204173206120766f756368696e67206d656d6265722c20756e766f7563682061206269642e2054686973206f6e6c7920776f726b73207768696c6520766f7563686564207573657220697394206f6e6c792061206269646465722028616e64206e6f7420612063616e646964617465292e00290120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64206120766f756368696e67206d656d6265722e003020506172616d65746572733a2d01202d2060706f73603a20506f736974696f6e20696e207468652060426964736020766563746f72206f6620746865206269642077686f2073686f756c6420626520756e766f75636865642e002c2023203c7765696768743e54204b65793a204220286c656e206f662062696473290901202d204f6e652073746f726167652072656164204f28312920746f20636865636b20746865207369676e6572206973206120766f756368696e67206d656d6265722eec202d204f6e652073746f72616765206d757461746520746f20726574726965766520616e64207570646174652074686520626964732e204f28422994202d204f6e6520766f756368696e672073746f726167652072656d6f76616c2e204f28312934202d204f6e65206576656e742e005c20546f74616c20436f6d706c65786974793a204f284229302023203c2f7765696768743e10766f7465082463616e6469646174658c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651c617070726f766510626f6f6c4c882041732061206d656d6265722c20766f7465206f6e20612063616e6469646174652e00050120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e642061206d656d6265722e003020506172616d65746572733a0d01202d206063616e646964617465603a205468652063616e646964617465207468617420746865206d656d62657220776f756c64206c696b6520746f20626964206f6e2ef4202d2060617070726f7665603a204120626f6f6c65616e2077686963682073617973206966207468652063616e6469646174652073686f756c64206265d82020202020202020202020202020617070726f766564202860747275656029206f722072656a656374656420286066616c736560292e002c2023203c7765696768743ebc204b65793a204320286c656e206f662063616e64696461746573292c204d20286c656e206f66206d656d62657273291d01202d204f6e652073746f726167652072656164204f284d2920616e64204f286c6f67204d292073656172636820746f20636865636b20757365722069732061206d656d6265722e58202d204f6e65206163636f756e74206c6f6f6b75702e2d01202d204f6e652073746f726167652072656164204f28432920616e64204f2843292073656172636820746f20636865636b2074686174207573657220697320612063616e6469646174652ebc202d204f6e652073746f7261676520777269746520746f2061646420766f746520746f20766f7465732e204f28312934202d204f6e65206576656e742e008820546f74616c20436f6d706c65786974793a204f284d202b206c6f674d202b204329302023203c2f7765696768743e34646566656e6465725f766f7465041c617070726f766510626f6f6c408c2041732061206d656d6265722c20766f7465206f6e2074686520646566656e6465722e00050120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e642061206d656d6265722e003020506172616d65746572733af4202d2060617070726f7665603a204120626f6f6c65616e2077686963682073617973206966207468652063616e6469646174652073686f756c64206265a420617070726f766564202860747275656029206f722072656a656374656420286066616c736560292e002c2023203c7765696768743e68202d204b65793a204d20286c656e206f66206d656d62657273291d01202d204f6e652073746f726167652072656164204f284d2920616e64204f286c6f67204d292073656172636820746f20636865636b20757365722069732061206d656d6265722ebc202d204f6e652073746f7261676520777269746520746f2061646420766f746520746f20766f7465732e204f28312934202d204f6e65206576656e742e007820546f74616c20436f6d706c65786974793a204f284d202b206c6f674d29302023203c2f7765696768743e187061796f757400504501205472616e7366657220746865206669727374206d617475726564207061796f757420666f72207468652073656e64657220616e642072656d6f76652069742066726f6d20746865207265636f7264732e006901204e4f54453a20546869732065787472696e736963206e6565647320746f2062652063616c6c6564206d756c7469706c652074696d657320746f20636c61696d206d756c7469706c65206d617475726564207061796f7574732e002101205061796d656e743a20546865206d656d6265722077696c6c20726563656976652061207061796d656e7420657175616c20746f207468656972206669727374206d61747572656478207061796f757420746f20746865697220667265652062616c616e63652e00150120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e642061206d656d62657220776974684c207061796f7574732072656d61696e696e672e002c2023203c7765696768743e1d01204b65793a204d20286c656e206f66206d656d62657273292c205020286e756d626572206f66207061796f75747320666f72206120706172746963756c6172206d656d626572292501202d204f6e652073746f726167652072656164204f284d2920616e64204f286c6f67204d292073656172636820746f20636865636b207369676e65722069732061206d656d6265722ee4202d204f6e652073746f726167652072656164204f28502920746f2067657420616c6c207061796f75747320666f722061206d656d6265722ee4202d204f6e652073746f726167652072656164204f28312920746f20676574207468652063757272656e7420626c6f636b206e756d6265722e8c202d204f6e652063757272656e6379207472616e736665722063616c6c2e204f2858291101202d204f6e652073746f72616765207772697465206f722072656d6f76616c20746f2075706461746520746865206d656d6265722773207061796f7574732e204f285029009820546f74616c20436f6d706c65786974793a204f284d202b206c6f674d202b2050202b205829302023203c2f7765696768743e14666f756e640c1c666f756e64657230543a3a4163636f756e7449642c6d61785f6d656d626572730c7533321472756c65731c5665633c75383e4c4c20466f756e642074686520736f63696574792e00f0205468697320697320646f6e65206173206120646973637265746520616374696f6e20696e206f7264657220746f20616c6c6f7720666f72207468651901206d6f64756c6520746f20626520696e636c7564656420696e746f20612072756e6e696e6720636861696e20616e642063616e206f6e6c7920626520646f6e65206f6e63652e001d0120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652066726f6d20746865205f466f756e6465725365744f726967696e5f2e003020506172616d65746572733a1901202d2060666f756e64657260202d20546865206669727374206d656d62657220616e642068656164206f6620746865206e65776c7920666f756e64656420736f63696574792e1501202d20606d61785f6d656d6265727360202d2054686520696e697469616c206d6178206e756d626572206f66206d656d6265727320666f722074686520736f63696574792ef4202d206072756c657360202d205468652072756c6573206f66207468697320736f636965747920636f6e6365726e696e67206d656d626572736869702e002c2023203c7765696768743ee0202d2054776f2073746f72616765206d75746174657320746f207365742060486561646020616e642060466f756e646572602e204f283129f4202d204f6e652073746f7261676520777269746520746f2061646420746865206669727374206d656d62657220746f20736f63696574792e204f28312934202d204f6e65206576656e742e005c20546f74616c20436f6d706c65786974793a204f283129302023203c2f7765696768743e1c756e666f756e6400348c20416e6e756c2074686520666f756e64696e67206f662074686520736f63696574792e005d0120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205369676e65642c20616e6420746865207369676e696e67206163636f756e74206d75737420626520626f74685901207468652060466f756e6465726020616e6420746865206048656164602e205468697320696d706c6965732074686174206974206d6179206f6e6c7920626520646f6e65207768656e207468657265206973206f6e6520206d656d6265722e002c2023203c7765696768743e68202d2054776f2073746f72616765207265616473204f2831292e78202d20466f75722073746f726167652072656d6f76616c73204f2831292e34202d204f6e65206576656e742e005c20546f74616c20436f6d706c65786974793a204f283129302023203c2f7765696768743e586a756467655f73757370656e6465645f6d656d626572080c77686f30543a3a4163636f756e7449641c666f726769766510626f6f6c6c2d0120416c6c6f772073757370656e73696f6e206a756467656d656e74206f726967696e20746f206d616b65206a756467656d656e74206f6e20612073757370656e646564206d656d6265722e00590120496620612073757370656e646564206d656d62657220697320666f72676976656e2c2077652073696d706c7920616464207468656d206261636b2061732061206d656d6265722c206e6f7420616666656374696e67cc20616e79206f6620746865206578697374696e672073746f72616765206974656d7320666f722074686174206d656d6265722e00490120496620612073757370656e646564206d656d6265722069732072656a65637465642c2072656d6f766520616c6c206173736f6369617465642073746f72616765206974656d732c20696e636c7564696e670101207468656972207061796f7574732c20616e642072656d6f766520616e7920766f7563686564206269647320746865792063757272656e746c7920686176652e00410120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652066726f6d20746865205f53757370656e73696f6e4a756467656d656e744f726967696e5f2e003020506172616d65746572733ab4202d206077686f60202d205468652073757370656e646564206d656d62657220746f206265206a75646765642e3501202d2060666f726769766560202d204120626f6f6c65616e20726570726573656e74696e672077686574686572207468652073757370656e73696f6e206a756467656d656e74206f726967696e2501202020202020202020202020202020666f726769766573202860747275656029206f722072656a6563747320286066616c7365602920612073757370656e646564206d656d6265722e002c2023203c7765696768743ea4204b65793a204220286c656e206f662062696473292c204d20286c656e206f66206d656d6265727329f8202d204f6e652073746f72616765207265616420746f20636865636b206077686f6020697320612073757370656e646564206d656d6265722e204f2831297101202d20557020746f206f6e652073746f72616765207772697465204f284d292077697468204f286c6f67204d292062696e6172792073656172636820746f206164642061206d656d626572206261636b20746f20736f63696574792ef8202d20557020746f20332073746f726167652072656d6f76616c73204f28312920746f20636c65616e20757020612072656d6f766564206d656d6265722e4501202d20557020746f206f6e652073746f72616765207772697465204f2842292077697468204f2842292073656172636820746f2072656d6f766520766f7563686564206269642066726f6d20626964732ed4202d20557020746f206f6e65206164646974696f6e616c206576656e7420696620756e766f7563682074616b657320706c6163652e70202d204f6e652073746f726167652072656d6f76616c2e204f2831297c202d204f6e65206576656e7420666f7220746865206a756467656d656e742e008820546f74616c20436f6d706c65786974793a204f284d202b206c6f674d202b204229302023203c2f7765696768743e646a756467655f73757370656e6465645f63616e646964617465080c77686f30543a3a4163636f756e744964246a756467656d656e74244a756467656d656e74a0350120416c6c6f772073757370656e646564206a756467656d656e74206f726967696e20746f206d616b65206a756467656d656e74206f6e20612073757370656e6465642063616e6469646174652e005d0120496620746865206a756467656d656e742069732060417070726f7665602c20776520616464207468656d20746f20736f63696574792061732061206d656d62657220776974682074686520617070726f70726961746574207061796d656e7420666f72206a6f696e696e6720736f63696574792e00550120496620746865206a756467656d656e74206973206052656a656374602c2077652065697468657220736c61736820746865206465706f736974206f6620746865206269642c20676976696e67206974206261636b110120746f2074686520736f63696574792074726561737572792c206f722077652062616e2074686520766f75636865722066726f6d20766f756368696e6720616761696e2e005d0120496620746865206a756467656d656e7420697320605265626964602c20776520707574207468652063616e646964617465206261636b20696e207468652062696420706f6f6c20616e64206c6574207468656d20676f94207468726f7567682074686520696e64756374696f6e2070726f6365737320616761696e2e00410120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652066726f6d20746865205f53757370656e73696f6e4a756467656d656e744f726967696e5f2e003020506172616d65746572733ac0202d206077686f60202d205468652073757370656e6465642063616e64696461746520746f206265206a75646765642ec4202d20606a756467656d656e7460202d2060417070726f7665602c206052656a656374602c206f7220605265626964602e002c2023203c7765696768743ef4204b65793a204220286c656e206f662062696473292c204d20286c656e206f66206d656d62657273292c2058202862616c616e636520616374696f6e29f0202d204f6e652073746f72616765207265616420746f20636865636b206077686f6020697320612073757370656e6465642063616e6469646174652ec8202d204f6e652073746f726167652072656d6f76616c206f66207468652073757370656e6465642063616e6469646174652e40202d20417070726f7665204c6f676963150120092d204f6e652073746f72616765207265616420746f206765742074686520617661696c61626c6520706f7420746f2070617920757365727320776974682e204f283129dc20092d204f6e652073746f7261676520777269746520746f207570646174652074686520617661696c61626c6520706f742e204f283129e820092d204f6e652073746f72616765207265616420746f20676574207468652063757272656e7420626c6f636b206e756d6265722e204f283129b420092d204f6e652073746f72616765207265616420746f2067657420616c6c206d656d626572732e204f284d29a020092d20557020746f206f6e6520756e726573657276652063757272656e637920616374696f6e2eb020092d20557020746f2074776f206e65772073746f726167652077726974657320746f207061796f7574732e4d0120092d20557020746f206f6e652073746f726167652077726974652077697468204f286c6f67204d292062696e6172792073656172636820746f206164642061206d656d62657220746f20736f63696574792e3c202d2052656a656374204c6f676963dc20092d20557020746f206f6e6520726570617472696174652072657365727665642063757272656e637920616374696f6e2e204f2858292d0120092d20557020746f206f6e652073746f7261676520777269746520746f2062616e2074686520766f756368696e67206d656d6265722066726f6d20766f756368696e6720616761696e2e38202d205265626964204c6f676963410120092d2053746f72616765206d75746174652077697468204f286c6f672042292062696e6172792073656172636820746f20706c616365207468652075736572206261636b20696e746f20626964732ed4202d20557020746f206f6e65206164646974696f6e616c206576656e7420696620756e766f7563682074616b657320706c6163652e5c202d204f6e652073746f726167652072656d6f76616c2e7c202d204f6e65206576656e7420666f7220746865206a756467656d656e742e009820546f74616c20436f6d706c65786974793a204f284d202b206c6f674d202b2042202b205829302023203c2f7765696768743e3c7365745f6d61785f6d656d62657273040c6d61780c753332381d0120416c6c6f777320726f6f74206f726967696e20746f206368616e676520746865206d6178696d756d206e756d626572206f66206d656d6265727320696e20736f63696574792eb4204d6178206d656d6265727368697020636f756e74206d7573742062652067726561746572207468616e20312e00dc20546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652066726f6d205f524f4f545f2e003020506172616d65746572733ae4202d20606d617860202d20546865206d6178696d756d206e756d626572206f66206d656d6265727320666f722074686520736f63696574792e002c2023203c7765696768743eb0202d204f6e652073746f7261676520777269746520746f2075706461746520746865206d61782e204f28312934202d204f6e65206576656e742e005c20546f74616c20436f6d706c65786974793a204f283129302023203c2f7765696768743e01401c466f756e64656404244163636f756e74496404e82054686520736f636965747920697320666f756e6465642062792074686520676976656e206964656e746974792e205c5b666f756e6465725c5d0c42696408244163636f756e7449641c42616c616e63650861012041206d656d6265727368697020626964206a7573742068617070656e65642e2054686520676976656e206163636f756e74206973207468652063616e646964617465277320494420616e64207468656972206f666665729c20697320746865207365636f6e642e205c5b63616e6469646174655f69642c206f666665725c5d14566f7563680c244163636f756e7449641c42616c616e6365244163636f756e7449640861012041206d656d6265727368697020626964206a7573742068617070656e656420627920766f756368696e672e2054686520676976656e206163636f756e74206973207468652063616e646964617465277320494420616e647901207468656972206f6666657220697320746865207365636f6e642e2054686520766f756368696e67207061727479206973207468652074686972642e205c5b63616e6469646174655f69642c206f666665722c20766f756368696e675c5d244175746f556e62696404244163636f756e7449640419012041205c5b63616e6469646174655c5d207761732064726f70706564202864756520746f20616e20657863657373206f66206269647320696e207468652073797374656d292e14556e62696404244163636f756e74496404c02041205c5b63616e6469646174655c5d207761732064726f70706564202862792074686569722072657175657374292e1c556e766f75636804244163636f756e7449640409012041205c5b63616e6469646174655c5d207761732064726f70706564202862792072657175657374206f662077686f20766f756368656420666f72207468656d292e20496e64756374656408244163636f756e744964385665633c4163636f756e7449643e08590120412067726f7570206f662063616e646964617465732068617665206265656e20696e6475637465642e205468652062617463682773207072696d617279206973207468652066697273742076616c75652c20746865d420626174636820696e2066756c6c20697320746865207365636f6e642e205c5b7072696d6172792c2063616e646964617465735c5d6053757370656e6465644d656d6265724a756467656d656e7408244163636f756e74496410626f6f6c04d020412073757370656e646564206d656d62657220686173206265656e206a75646765642e205c5b77686f2c206a75646765645c5d4843616e64696461746553757370656e64656404244163636f756e744964048c2041205c5b63616e6469646174655c5d20686173206265656e2073757370656e6465643c4d656d62657253757370656e64656404244163636f756e74496404802041205c5b6d656d6265725c5d20686173206265656e2073757370656e646564284368616c6c656e67656404244163636f756e74496404842041205c5b6d656d6265725c5d20686173206265656e206368616c6c656e67656410566f74650c244163636f756e744964244163636f756e74496410626f6f6c04c8204120766f746520686173206265656e20706c61636564205c5b63616e6469646174652c20766f7465722c20766f74655c5d30446566656e646572566f746508244163636f756e74496410626f6f6c04f8204120766f746520686173206265656e20706c6163656420666f72206120646566656e64696e67206d656d626572205c5b766f7465722c20766f74655c5d344e65774d61784d656d62657273040c75333204a02041206e6577205c5b6d61785c5d206d656d62657220636f756e7420686173206265656e2073657424556e666f756e64656404244163636f756e744964048820536f636965747920697320756e666f756e6465642e205c5b666f756e6465725c5d1c4465706f736974041c42616c616e636504f820536f6d652066756e64732077657265206465706f736974656420696e746f2074686520736f6369657479206163636f756e742e205c5b76616c75655c5d204043616e6469646174654465706f7369743c42616c616e63654f663c542c20493e40a41a130d84010000000000000000000004fc20546865206d696e696d756d20616d6f756e74206f662061206465706f73697420726571756972656420666f7220612062696420746f206265206d6164652e4857726f6e6753696465446564756374696f6e3c42616c616e63654f663c542c20493e405405379c4d00000000000000000000000855012054686520616d6f756e74206f662074686520756e70616964207265776172642074686174206765747320646564756374656420696e207468652063617365207468617420656974686572206120736b6570746963c020646f65736e277420766f7465206f7220736f6d656f6e6520766f74657320696e207468652077726f6e67207761792e284d6178537472696b65730c753332100a00000008750120546865206e756d626572206f662074696d65732061206d656d626572206d617920766f7465207468652077726f6e672077617920286f72206e6f7420617420616c6c2c207768656e207468657920617265206120736b65707469632978206265666f72652074686579206265636f6d652073757370656e6465642e2c506572696f645370656e643c42616c616e63654f663c542c20493e400834bb8dca4b00000000000000000000042d012054686520616d6f756e74206f6620696e63656e7469766520706169642077697468696e206561636820706572696f642e20446f65736e277420696e636c75646520566f7465725469702e38526f746174696f6e506572696f6438543a3a426c6f636b4e756d6265721080bb000004110120546865206e756d626572206f6620626c6f636b73206265747765656e2063616e6469646174652f6d656d6265727368697020726f746174696f6e20706572696f64732e3c4368616c6c656e6765506572696f6438543a3a426c6f636b4e756d62657210c089010004d020546865206e756d626572206f6620626c6f636b73206265747765656e206d656d62657273686970206368616c6c656e6765732e204d6f64756c654964204d6f64756c6549642070792f736f63696504682054686520736f636965746965732773206d6f64756c65206964484d617843616e646964617465496e74616b650c75333210010000000490204d6178696d756d2063616e64696461746520696e74616b652070657220726f756e642e482c426164506f736974696f6e049020416e20696e636f727265637420706f736974696f6e207761732070726f76696465642e244e6f744d656d62657204582055736572206973206e6f742061206d656d6265722e34416c72656164794d656d6265720468205573657220697320616c72656164792061206d656d6265722e2453757370656e646564044c20557365722069732073757370656e6465642e304e6f7453757370656e646564045c2055736572206973206e6f742073757370656e6465642e204e6f5061796f7574044c204e6f7468696e6720746f207061796f75742e38416c7265616479466f756e646564046420536f636965747920616c726561647920666f756e6465642e3c496e73756666696369656e74506f74049c204e6f7420656e6f75676820696e20706f7420746f206163636570742063616e6469646174652e3c416c7265616479566f756368696e6704e8204d656d62657220697320616c726561647920766f756368696e67206f722062616e6e65642066726f6d20766f756368696e6720616761696e2e2c4e6f74566f756368696e670460204d656d626572206973206e6f7420766f756368696e672e104865616404942043616e6e6f742072656d6f7665207468652068656164206f662074686520636861696e2e1c466f756e646572046c2043616e6e6f742072656d6f76652074686520666f756e6465722e28416c7265616479426964047420557365722068617320616c7265616479206d6164652061206269642e40416c726561647943616e6469646174650474205573657220697320616c726561647920612063616e6469646174652e304e6f7443616e64696461746504642055736572206973206e6f7420612063616e6469646174652e284d61784d656d62657273048420546f6f206d616e79206d656d6265727320696e2074686520736f63696574792e284e6f74466f756e646572047c205468652063616c6c6572206973206e6f742074686520666f756e6465722e1c4e6f74486561640470205468652063616c6c6572206973206e6f742074686520686561642e1a205265636f7665727901205265636f766572790c2c5265636f76657261626c6500010530543a3a4163636f756e744964e85265636f76657279436f6e6669673c543a3a426c6f636b4e756d6265722c2042616c616e63654f663c543e2c20543a3a4163636f756e7449643e0004000409012054686520736574206f66207265636f76657261626c65206163636f756e747320616e64207468656972207265636f7665727920636f6e66696775726174696f6e2e404163746976655265636f76657269657300020530543a3a4163636f756e74496430543a3a4163636f756e744964e84163746976655265636f766572793c543a3a426c6f636b4e756d6265722c2042616c616e63654f663c543e2c20543a3a4163636f756e7449643e050400106820416374697665207265636f7665727920617474656d7074732e001501204669727374206163636f756e7420697320746865206163636f756e7420746f206265207265636f76657265642c20616e6420746865207365636f6e64206163636f756e74ac20697320746865207573657220747279696e6720746f207265636f76657220746865206163636f756e742e1450726f787900010230543a3a4163636f756e74496430543a3a4163636f756e7449640004000c9020546865206c697374206f6620616c6c6f7765642070726f7879206163636f756e74732e00f8204d61702066726f6d2074686520757365722077686f2063616e2061636365737320697420746f20746865207265636f7665726564206163636f756e742e01243061735f7265636f7665726564081c6163636f756e7430543a3a4163636f756e7449641063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e34a42053656e6420612063616c6c207468726f7567682061207265636f7665726564206163636f756e742e00150120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207265676973746572656420746fe82062652061626c6520746f206d616b652063616c6c73206f6e20626568616c66206f6620746865207265636f7665726564206163636f756e742e003020506172616d65746572733a2501202d20606163636f756e74603a20546865207265636f7665726564206163636f756e7420796f752077616e7420746f206d616b6520612063616c6c206f6e2d626568616c662d6f662e0101202d206063616c6c603a205468652063616c6c20796f752077616e7420746f206d616b65207769746820746865207265636f7665726564206163636f756e742e002c2023203c7765696768743e94202d2054686520776569676874206f6620746865206063616c6c60202b2031302c3030302e0901202d204f6e652073746f72616765206c6f6f6b757020746f20636865636b206163636f756e74206973207265636f7665726564206279206077686f602e204f283129302023203c2f7765696768743e347365745f7265636f766572656408106c6f737430543a3a4163636f756e7449641c7265736375657230543a3a4163636f756e744964341d0120416c6c6f7720524f4f5420746f2062797061737320746865207265636f766572792070726f6365737320616e642073657420616e20612072657363756572206163636f756e747420666f722061206c6f7374206163636f756e74206469726563746c792e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f524f4f545f2e003020506172616d65746572733ab8202d20606c6f7374603a2054686520226c6f7374206163636f756e742220746f206265207265636f76657265642e1d01202d206072657363756572603a20546865202272657363756572206163636f756e74222077686963682063616e2063616c6c20617320746865206c6f7374206163636f756e742e002c2023203c7765696768743e64202d204f6e652073746f72616765207772697465204f28312930202d204f6e65206576656e74302023203c2f7765696768743e3c6372656174655f7265636f766572790c1c667269656e6473445665633c543a3a4163636f756e7449643e247468726573686f6c640c7531363064656c61795f706572696f6438543a3a426c6f636b4e756d6265726c5d01204372656174652061207265636f7665727920636f6e66696775726174696f6e20666f7220796f7572206163636f756e742e2054686973206d616b657320796f7572206163636f756e74207265636f76657261626c652e003101205061796d656e743a2060436f6e6669674465706f7369744261736560202b2060467269656e644465706f736974466163746f7260202a20235f6f665f667269656e64732062616c616e636549012077696c6c20626520726573657276656420666f722073746f72696e6720746865207265636f7665727920636f6e66696775726174696f6e2e2054686973206465706f7369742069732072657475726e6564bc20696e2066756c6c207768656e2074686520757365722063616c6c73206072656d6f76655f7265636f76657279602e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a2501202d2060667269656e6473603a2041206c697374206f6620667269656e647320796f7520747275737420746f20766f75636820666f72207265636f7665727920617474656d7074732ed420202053686f756c64206265206f72646572656420616e6420636f6e7461696e206e6f206475706c69636174652076616c7565732e3101202d20607468726573686f6c64603a20546865206e756d626572206f6620667269656e64732074686174206d75737420766f75636820666f722061207265636f7665727920617474656d70741d012020206265666f726520746865206163636f756e742063616e206265207265636f76657265642e2053686f756c64206265206c657373207468616e206f7220657175616c20746f94202020746865206c656e677468206f6620746865206c697374206f6620667269656e64732e3d01202d206064656c61795f706572696f64603a20546865206e756d626572206f6620626c6f636b732061667465722061207265636f7665727920617474656d707420697320696e697469616c697a6564e820202074686174206e6565647320746f2070617373206265666f726520746865206163636f756e742063616e206265207265636f76657265642e002c2023203c7765696768743e68202d204b65793a204620286c656e206f6620667269656e6473292d01202d204f6e652073746f72616765207265616420746f20636865636b2074686174206163636f756e74206973206e6f7420616c7265616479207265636f76657261626c652e204f2831292eec202d204120636865636b20746861742074686520667269656e6473206c69737420697320736f7274656420616e6420756e697175652e204f2846299c202d204f6e652063757272656e63792072657365727665206f7065726174696f6e2e204f2858299c202d204f6e652073746f726167652077726974652e204f2831292e20436f646563204f2846292e34202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2846202b205829302023203c2f7765696768743e44696e6974696174655f7265636f76657279041c6163636f756e7430543a3a4163636f756e74496458ec20496e697469617465207468652070726f6365737320666f72207265636f766572696e672061207265636f76657261626c65206163636f756e742e001d01205061796d656e743a20605265636f766572794465706f736974602062616c616e63652077696c6c20626520726573657276656420666f7220696e6974696174696e67207468652501207265636f766572792070726f636573732e2054686973206465706f7369742077696c6c20616c7761797320626520726570617472696174656420746f20746865206163636f756e74b820747279696e6720746f206265207265636f76657265642e205365652060636c6f73655f7265636f76657279602e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1501202d20606163636f756e74603a20546865206c6f7374206163636f756e74207468617420796f752077616e7420746f207265636f7665722e2054686973206163636f756e7401012020206e6565647320746f206265207265636f76657261626c652028692e652e20686176652061207265636f7665727920636f6e66696775726174696f6e292e002c2023203c7765696768743ef8202d204f6e652073746f72616765207265616420746f20636865636b2074686174206163636f756e74206973207265636f76657261626c652e204f2846295101202d204f6e652073746f72616765207265616420746f20636865636b20746861742074686973207265636f766572792070726f63657373206861736e277420616c726561647920737461727465642e204f2831299c202d204f6e652063757272656e63792072657365727665206f7065726174696f6e2e204f285829e4202d204f6e652073746f72616765207265616420746f20676574207468652063757272656e7420626c6f636b206e756d6265722e204f2831296c202d204f6e652073746f726167652077726974652e204f2831292e34202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2846202b205829302023203c2f7765696768743e38766f7563685f7265636f7665727908106c6f737430543a3a4163636f756e7449641c7265736375657230543a3a4163636f756e74496464290120416c6c6f7720612022667269656e6422206f662061207265636f76657261626c65206163636f756e7420746f20766f75636820666f7220616e20616374697665207265636f76657279682070726f6365737320666f722074686174206163636f756e742e00290120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64206d75737420626520612022667269656e64227420666f7220746865207265636f76657261626c65206163636f756e742e003020506172616d65746572733ad4202d20606c6f7374603a20546865206c6f7374206163636f756e74207468617420796f752077616e7420746f207265636f7665722e1101202d206072657363756572603a20546865206163636f756e7420747279696e6720746f2072657363756520746865206c6f7374206163636f756e74207468617420796f755420202077616e7420746f20766f75636820666f722e0025012054686520636f6d62696e6174696f6e206f662074686573652074776f20706172616d6574657273206d75737420706f696e7420746f20616e20616374697665207265636f76657279242070726f636573732e002c2023203c7765696768743efc204b65793a204620286c656e206f6620667269656e647320696e20636f6e666967292c205620286c656e206f6620766f756368696e6720667269656e6473291d01202d204f6e652073746f72616765207265616420746f2067657420746865207265636f7665727920636f6e66696775726174696f6e2e204f2831292c20436f646563204f2846292101202d204f6e652073746f72616765207265616420746f206765742074686520616374697665207265636f766572792070726f636573732e204f2831292c20436f646563204f285629ec202d204f6e652062696e6172792073656172636820746f20636f6e6669726d2063616c6c6572206973206120667269656e642e204f286c6f6746291d01202d204f6e652062696e6172792073656172636820746f20636f6e6669726d2063616c6c657220686173206e6f7420616c726561647920766f75636865642e204f286c6f6756299c202d204f6e652073746f726167652077726974652e204f2831292c20436f646563204f2856292e34202d204f6e65206576656e742e00a420546f74616c20436f6d706c65786974793a204f2846202b206c6f6746202b2056202b206c6f675629302023203c2f7765696768743e38636c61696d5f7265636f76657279041c6163636f756e7430543a3a4163636f756e74496450f420416c6c6f772061207375636365737366756c207265736375657220746f20636c61696d207468656972207265636f7665726564206163636f756e742e002d0120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64206d7573742062652061202272657363756572221d012077686f20686173207375636365737366756c6c7920636f6d706c6574656420746865206163636f756e74207265636f766572792070726f636573733a20636f6c6c6563746564310120607468726573686f6c6460206f72206d6f726520766f75636865732c20776169746564206064656c61795f706572696f646020626c6f636b732073696e636520696e6974696174696f6e2e003020506172616d65746572733a2d01202d20606163636f756e74603a20546865206c6f7374206163636f756e74207468617420796f752077616e7420746f20636c61696d20686173206265656e207375636365737366756c6c79502020207265636f766572656420627920796f752e002c2023203c7765696768743efc204b65793a204620286c656e206f6620667269656e647320696e20636f6e666967292c205620286c656e206f6620766f756368696e6720667269656e6473291d01202d204f6e652073746f72616765207265616420746f2067657420746865207265636f7665727920636f6e66696775726174696f6e2e204f2831292c20436f646563204f2846292101202d204f6e652073746f72616765207265616420746f206765742074686520616374697665207265636f766572792070726f636573732e204f2831292c20436f646563204f285629e4202d204f6e652073746f72616765207265616420746f20676574207468652063757272656e7420626c6f636b206e756d6265722e204f2831299c202d204f6e652073746f726167652077726974652e204f2831292c20436f646563204f2856292e34202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2846202b205629302023203c2f7765696768743e38636c6f73655f7265636f76657279041c7265736375657230543a3a4163636f756e7449645015012041732074686520636f6e74726f6c6c6572206f662061207265636f76657261626c65206163636f756e742c20636c6f736520616e20616374697665207265636f76657279682070726f6365737320666f7220796f7572206163636f756e742e002101205061796d656e743a2042792063616c6c696e6720746869732066756e6374696f6e2c20746865207265636f76657261626c65206163636f756e742077696c6c2072656365697665f820746865207265636f76657279206465706f73697420605265636f766572794465706f7369746020706c616365642062792074686520726573637565722e00050120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64206d7573742062652061f0207265636f76657261626c65206163636f756e74207769746820616e20616374697665207265636f766572792070726f6365737320666f722069742e003020506172616d65746572733a1101202d206072657363756572603a20546865206163636f756e7420747279696e6720746f207265736375652074686973207265636f76657261626c65206163636f756e742e002c2023203c7765696768743e84204b65793a205620286c656e206f6620766f756368696e6720667269656e6473293d01202d204f6e652073746f7261676520726561642f72656d6f766520746f206765742074686520616374697665207265636f766572792070726f636573732e204f2831292c20436f646563204f285629c0202d204f6e652062616c616e63652063616c6c20746f20726570617472696174652072657365727665642e204f28582934202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2856202b205829302023203c2f7765696768743e3c72656d6f76655f7265636f7665727900545d012052656d6f766520746865207265636f766572792070726f6365737320666f7220796f7572206163636f756e742e205265636f7665726564206163636f756e747320617265207374696c6c2061636365737369626c652e001501204e4f54453a205468652075736572206d757374206d616b65207375726520746f2063616c6c2060636c6f73655f7265636f7665727960206f6e20616c6c206163746976650901207265636f7665727920617474656d707473206265666f72652063616c6c696e6720746869732066756e6374696f6e20656c73652069742077696c6c206661696c2e002501205061796d656e743a2042792063616c6c696e6720746869732066756e6374696f6e20746865207265636f76657261626c65206163636f756e742077696c6c20756e7265736572766598207468656972207265636f7665727920636f6e66696775726174696f6e206465706f7369742ef4202860436f6e6669674465706f7369744261736560202b2060467269656e644465706f736974466163746f7260202a20235f6f665f667269656e64732900050120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64206d7573742062652061e4207265636f76657261626c65206163636f756e742028692e652e206861732061207265636f7665727920636f6e66696775726174696f6e292e002c2023203c7765696768743e60204b65793a204620286c656e206f6620667269656e6473292901202d204f6e652073746f72616765207265616420746f206765742074686520707265666978206974657261746f7220666f7220616374697665207265636f7665726965732e204f2831293901202d204f6e652073746f7261676520726561642f72656d6f766520746f2067657420746865207265636f7665727920636f6e66696775726174696f6e2e204f2831292c20436f646563204f2846299c202d204f6e652062616c616e63652063616c6c20746f20756e72657365727665642e204f28582934202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2846202b205829302023203c2f7765696768743e4063616e63656c5f7265636f7665726564041c6163636f756e7430543a3a4163636f756e7449642ce02043616e63656c20746865206162696c69747920746f20757365206061735f7265636f76657265646020666f7220606163636f756e74602e00150120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207265676973746572656420746fe82062652061626c6520746f206d616b652063616c6c73206f6e20626568616c66206f6620746865207265636f7665726564206163636f756e742e003020506172616d65746572733a1901202d20606163636f756e74603a20546865207265636f7665726564206163636f756e7420796f75206172652061626c6520746f2063616c6c206f6e2d626568616c662d6f662e002c2023203c7765696768743e1101202d204f6e652073746f72616765206d75746174696f6e20746f20636865636b206163636f756e74206973207265636f7665726564206279206077686f602e204f283129302023203c2f7765696768743e01183c5265636f766572794372656174656404244163636f756e74496404dc2041207265636f766572792070726f6365737320686173206265656e2073657420757020666f7220616e205c5b6163636f756e745c5d2e445265636f76657279496e6974696174656408244163636f756e744964244163636f756e744964082d012041207265636f766572792070726f6365737320686173206265656e20696e6974696174656420666f72206c6f7374206163636f756e742062792072657363756572206163636f756e742e48205c5b6c6f73742c20726573637565725c5d3c5265636f76657279566f75636865640c244163636f756e744964244163636f756e744964244163636f756e744964085d012041207265636f766572792070726f6365737320666f72206c6f7374206163636f756e742062792072657363756572206163636f756e7420686173206265656e20766f756368656420666f722062792073656e6465722e68205c5b6c6f73742c20726573637565722c2073656e6465725c5d385265636f76657279436c6f73656408244163636f756e744964244163636f756e7449640821012041207265636f766572792070726f6365737320666f72206c6f7374206163636f756e742062792072657363756572206163636f756e7420686173206265656e20636c6f7365642e48205c5b6c6f73742c20726573637565725c5d404163636f756e745265636f766572656408244163636f756e744964244163636f756e744964080501204c6f7374206163636f756e7420686173206265656e207375636365737366756c6c79207265636f76657265642062792072657363756572206163636f756e742e48205c5b6c6f73742c20726573637565725c5d3c5265636f7665727952656d6f76656404244163636f756e74496404e02041207265636f766572792070726f6365737320686173206265656e2072656d6f76656420666f7220616e205c5b6163636f756e745c5d2e1044436f6e6669674465706f736974426173653042616c616e63654f663c543e40528d8906c2000000000000000000000004550120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e672061207265636f7665727920636f6e66696775726174696f6e2e4c467269656e644465706f736974466163746f723042616c616e63654f663c543e4034c10d671300000000000000000000000469012054686520616d6f756e74206f662063757272656e6379206e656564656420706572206164646974696f6e616c2075736572207768656e206372656174696e672061207265636f7665727920636f6e66696775726174696f6e2e284d6178467269656e64730c753136080900040d0120546865206d6178696d756d20616d6f756e74206f6620667269656e647320616c6c6f77656420696e2061207265636f7665727920636f6e66696775726174696f6e2e3c5265636f766572794465706f7369743042616c616e63654f663c543e40528d8906c20000000000000000000000041d0120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72207374617274696e672061207265636f766572792e44284e6f74416c6c6f77656404f42055736572206973206e6f7420616c6c6f77656420746f206d616b6520612063616c6c206f6e20626568616c66206f662074686973206163636f756e74345a65726f5468726573686f6c640490205468726573686f6c64206d7573742062652067726561746572207468616e207a65726f404e6f74456e6f756768467269656e647304d420467269656e6473206c697374206d7573742062652067726561746572207468616e207a65726f20616e64207468726573686f6c64284d6178467269656e647304ac20467269656e6473206c697374206d757374206265206c657373207468616e206d617820667269656e6473244e6f74536f7274656404cc20467269656e6473206c697374206d75737420626520736f7274656420616e642066726565206f66206475706c696361746573384e6f745265636f76657261626c6504a02054686973206163636f756e74206973206e6f742073657420757020666f72207265636f7665727948416c72656164795265636f76657261626c6504b02054686973206163636f756e7420697320616c72656164792073657420757020666f72207265636f7665727938416c72656164795374617274656404e02041207265636f766572792070726f636573732068617320616c7265616479207374617274656420666f722074686973206163636f756e74284e6f745374617274656404d02041207265636f766572792070726f6365737320686173206e6f74207374617274656420666f7220746869732072657363756572244e6f74467269656e6404ac2054686973206163636f756e74206973206e6f74206120667269656e642077686f2063616e20766f7563682c44656c6179506572696f64041d012054686520667269656e64206d757374207761697420756e74696c207468652064656c617920706572696f6420746f20766f75636820666f722074686973207265636f7665727938416c7265616479566f756368656404c0205468697320757365722068617320616c726561647920766f756368656420666f722074686973207265636f76657279245468726573686f6c6404ec20546865207468726573686f6c6420666f72207265636f766572696e672074686973206163636f756e7420686173206e6f74206265656e206d65742c5374696c6c41637469766504010120546865726520617265207374696c6c20616374697665207265636f7665727920617474656d7074732074686174206e65656420746f20626520636c6f736564204f766572666c6f77049c2054686572652077617320616e206f766572666c6f7720696e20612063616c63756c6174696f6e30416c726561647950726f787904b02054686973206163636f756e7420697320616c72656164792073657420757020666f72207265636f76657279204261645374617465047c20536f6d6520696e7465726e616c2073746174652069732062726f6b656e2e1b1c56657374696e67011c56657374696e67041c56657374696e6700010230543a3a4163636f756e744964a456657374696e67496e666f3c42616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e00040004d820496e666f726d6174696f6e20726567617264696e67207468652076657374696e67206f66206120676976656e206163636f756e742e011010766573740034bc20556e6c6f636b20616e79207665737465642066756e6473206f66207468652073656e646572206163636f756e742e00610120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652066756e6473207374696c6c68206c6f636b656420756e64657220746869732070616c6c65742e00d420456d69747320656974686572206056657374696e67436f6d706c6574656460206f72206056657374696e6755706461746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20322052656164732c203220577269746573fc20202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c205b53656e646572204163636f756e745d010120202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c205b53656e646572204163636f756e745d302023203c2f7765696768743e28766573745f6f7468657204187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653cbc20556e6c6f636b20616e79207665737465642066756e6473206f662061206074617267657460206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005501202d2060746172676574603a20546865206163636f756e742077686f7365207665737465642066756e64732073686f756c6420626520756e6c6f636b65642e204d75737420686176652066756e6473207374696c6c68206c6f636b656420756e64657220746869732070616c6c65742e00d420456d69747320656974686572206056657374696e67436f6d706c6574656460206f72206056657374696e6755706461746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20332052656164732c203320577269746573f420202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e74f820202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e74302023203c2f7765696768743e3c7665737465645f7472616e7366657208187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365207363686564756c65a456657374696e67496e666f3c42616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e406820437265617465206120766573746564207472616e736665722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e001501202d2060746172676574603a20546865206163636f756e7420746861742073686f756c64206265207472616e7366657272656420746865207665737465642066756e64732e0101202d2060616d6f756e74603a2054686520616d6f756e74206f662066756e647320746f207472616e7366657220616e642077696c6c206265207665737465642ef4202d20607363686564756c65603a205468652076657374696e67207363686564756c6520617474616368656420746f20746865207472616e736665722e006020456d697473206056657374696e6743726561746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20332052656164732c2033205772697465733d0120202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c205b53656e646572204163636f756e745d410120202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c205b53656e646572204163636f756e745d302023203c2f7765696768743e54666f7263655f7665737465645f7472616e736665720c18736f757263658c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365207363686564756c65a456657374696e67496e666f3c42616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e446420466f726365206120766573746564207472616e736665722e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f526f6f745f2e00ec202d2060736f75726365603a20546865206163636f756e742077686f73652066756e64732073686f756c64206265207472616e736665727265642e1501202d2060746172676574603a20546865206163636f756e7420746861742073686f756c64206265207472616e7366657272656420746865207665737465642066756e64732e0101202d2060616d6f756e74603a2054686520616d6f756e74206f662066756e647320746f207472616e7366657220616e642077696c6c206265207665737465642ef4202d20607363686564756c65603a205468652076657374696e67207363686564756c6520617474616368656420746f20746865207472616e736665722e006020456d697473206056657374696e6743726561746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20342052656164732c203420577269746573350120202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c20536f75726365204163636f756e74390120202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c20536f75726365204163636f756e74302023203c2f7765696768743e01083856657374696e675570646174656408244163636f756e7449641c42616c616e63650c59012054686520616d6f756e742076657374656420686173206265656e20757064617465642e205468697320636f756c6420696e646963617465206d6f72652066756e64732061726520617661696c61626c652e2054686519012062616c616e636520676976656e2069732074686520616d6f756e74207768696368206973206c65667420756e7665737465642028616e642074687573206c6f636b6564292e58205c5b6163636f756e742c20756e7665737465645c5d4056657374696e67436f6d706c6574656404244163636f756e744964041d0120416e205c5b6163636f756e745c5d20686173206265636f6d652066756c6c79207665737465642e204e6f20667572746865722076657374696e672063616e2068617070656e2e04444d696e5665737465645472616e736665723042616c616e63654f663c543e40680abf82280f0000000000000000000004e820546865206d696e696d756d20616d6f756e74207472616e7366657272656420746f2063616c6c20607665737465645f7472616e73666572602e0c284e6f7456657374696e67048820546865206163636f756e7420676976656e206973206e6f742076657374696e672e5c4578697374696e6756657374696e675363686564756c65045d0120416e206578697374696e672076657374696e67207363686564756c6520616c72656164792065786973747320666f722074686973206163636f756e7420746861742063616e6e6f7420626520636c6f6262657265642e24416d6f756e744c6f7704090120416d6f756e74206265696e67207472616e7366657272656420697320746f6f206c6f7720746f2063726561746520612076657374696e67207363686564756c652e1c245363686564756c657201245363686564756c65720c184167656e646101010538543a3a426c6f636b4e756d62657271015665633c4f7074696f6e3c5363686564756c65643c3c5420617320436f6e6669673e3a3a43616c6c2c20543a3a426c6f636b4e756d6265722c20543a3a0a50616c6c6574734f726967696e2c20543a3a4163636f756e7449643e3e3e000400044d01204974656d7320746f2062652065786563757465642c20696e64657865642062792074686520626c6f636b206e756d626572207468617420746865792073686f756c64206265206578656375746564206f6e2e184c6f6f6b75700001051c5665633c75383e6c5461736b416464726573733c543a3a426c6f636b4e756d6265723e000400040101204c6f6f6b75702066726f6d206964656e7469747920746f2074686520626c6f636b206e756d62657220616e6420696e646578206f6620746865207461736b2e3853746f7261676556657273696f6e01002052656c656173657304000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e0098204e6577206e6574776f726b732073746172742077697468206c6173742076657273696f6e2e0118207363686564756c6510107768656e38543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e287420416e6f6e796d6f75736c79207363686564756c652061207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c7390202d2042617365205765696768743a2032322e3239202b202e313236202a205320c2b57334202d204442205765696768743a4c20202020202d20526561643a204167656e64615020202020202d2057726974653a204167656e64613d01202d2057696c6c20757365206261736520776569676874206f662032352077686963682073686f756c6420626520676f6f6420666f7220757020746f203330207363686564756c65642063616c6c73302023203c2f7765696768743e1863616e63656c08107768656e38543a3a426c6f636b4e756d62657214696e6465780c75333228982043616e63656c20616e20616e6f6e796d6f75736c79207363686564756c6564207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c7394202d2042617365205765696768743a2032322e3135202b20322e383639202a205320c2b57334202d204442205765696768743a4c20202020202d20526561643a204167656e64617020202020202d2057726974653a204167656e64612c204c6f6f6b75704101202d2057696c6c20757365206261736520776569676874206f66203130302077686963682073686f756c6420626520676f6f6420666f7220757020746f203330207363686564756c65642063616c6c73302023203c2f7765696768743e387363686564756c655f6e616d6564140869641c5665633c75383e107768656e38543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e285c205363686564756c652061206e616d6564207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c738c202d2042617365205765696768743a2032392e36202b202e313539202a205320c2b57334202d204442205765696768743a6c20202020202d20526561643a204167656e64612c204c6f6f6b75707020202020202d2057726974653a204167656e64612c204c6f6f6b75704d01202d2057696c6c20757365206261736520776569676874206f662033352077686963682073686f756c6420626520676f6f6420666f72206d6f7265207468616e203330207363686564756c65642063616c6c73302023203c2f7765696768743e3063616e63656c5f6e616d6564040869641c5665633c75383e287c2043616e63656c2061206e616d6564207363686564756c6564207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c7394202d2042617365205765696768743a2032342e3931202b20322e393037202a205320c2b57334202d204442205765696768743a6c20202020202d20526561643a204167656e64612c204c6f6f6b75707020202020202d2057726974653a204167656e64612c204c6f6f6b75704101202d2057696c6c20757365206261736520776569676874206f66203130302077686963682073686f756c6420626520676f6f6420666f7220757020746f203330207363686564756c65642063616c6c73302023203c2f7765696768743e387363686564756c655f61667465721014616674657238543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e14ac20416e6f6e796d6f75736c79207363686564756c652061207461736b20616674657220612064656c61792e002c2023203c7765696768743e582053616d65206173205b607363686564756c65605d2e302023203c2f7765696768743e507363686564756c655f6e616d65645f6166746572140869641c5665633c75383e14616674657238543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e1494205363686564756c652061206e616d6564207461736b20616674657220612064656c61792e002c2023203c7765696768743e702053616d65206173205b607363686564756c655f6e616d6564605d2e302023203c2f7765696768743e010c245363686564756c6564082c426c6f636b4e756d6265720c7533320494205363686564756c656420736f6d65207461736b2e205c5b7768656e2c20696e6465785c5d2043616e63656c6564082c426c6f636b4e756d6265720c75333204902043616e63656c656420736f6d65207461736b2e205c5b7768656e2c20696e6465785c5d28446973706174636865640c605461736b416464726573733c426c6f636b4e756d6265723e3c4f7074696f6e3c5665633c75383e3e384469737061746368526573756c7404ac204469737061746368656420736f6d65207461736b2e205c5b7461736b2c2069642c20726573756c745c5d0010404661696c6564546f5363686564756c650468204661696c656420746f207363686564756c6520612063616c6c204e6f74466f756e6404802043616e6e6f742066696e6420746865207363686564756c65642063616c6c2e5c546172676574426c6f636b4e756d626572496e5061737404a820476976656e2074617267657420626c6f636b206e756d62657220697320696e2074686520706173742e4852657363686564756c654e6f4368616e676504f42052657363686564756c65206661696c6564206265636175736520697420646f6573206e6f74206368616e6765207363686564756c65642074696d652e1d1450726f7879011450726f7879081c50726f7869657301010530543a3a4163636f756e7449644501285665633c50726f7879446566696e6974696f6e3c543a3a4163636f756e7449642c20543a3a50726f7879547970652c20543a3a426c6f636b4e756d6265723e3e2c0a2042616c616e63654f663c543e29004400000000000000000000000000000000000845012054686520736574206f66206163636f756e742070726f786965732e204d61707320746865206163636f756e74207768696368206861732064656c65676174656420746f20746865206163636f756e7473210120776869636820617265206265696e672064656c65676174656420746f2c20746f67657468657220776974682074686520616d6f756e742068656c64206f6e206465706f7369742e34416e6e6f756e63656d656e747301010530543a3a4163636f756e7449643d01285665633c416e6e6f756e63656d656e743c543a3a4163636f756e7449642c2043616c6c486173684f663c543e2c20543a3a426c6f636b4e756d6265723e3e2c0a2042616c616e63654f663c543e290044000000000000000000000000000000000004ac2054686520616e6e6f756e63656d656e7473206d616465206279207468652070726f787920286b6579292e01281470726f78790c107265616c30543a3a4163636f756e74496440666f7263655f70726f78795f74797065504f7074696f6e3c543a3a50726f7879547970653e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e3c51012044697370617463682074686520676976656e206063616c6c602066726f6d20616e206163636f756e742074686174207468652073656e64657220697320617574686f726973656420666f72207468726f7567683420606164645f70726f7879602e00ac2052656d6f76657320616e7920636f72726573706f6e64696e6720616e6e6f756e63656d656e742873292e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e6501202d2060666f7263655f70726f78795f74797065603a2053706563696679207468652065786163742070726f7879207479706520746f206265207573656420616e6420636865636b656420666f7220746869732063616c6c2ed4202d206063616c6c603a205468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e246164645f70726f78790c2064656c656761746530543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d62657234490120526567697374657220612070726f7879206163636f756e7420666f72207468652073656e64657220746861742069732061626c6520746f206d616b652063616c6c73206f6e2069747320626568616c662e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1501202d206070726f7879603a20546865206163636f756e74207468617420746865206063616c6c65726020776f756c64206c696b6520746f206d616b6520612070726f78792e0101202d206070726f78795f74797065603a20546865207065726d697373696f6e7320616c6c6f77656420666f7220746869732070726f7879206163636f756e742e5101202d206064656c6179603a2054686520616e6e6f756e63656d656e7420706572696f64207265717569726564206f662074686520696e697469616c2070726f78792e2057696c6c2067656e6572616c6c7920626518207a65726f2e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e3072656d6f76655f70726f78790c2064656c656761746530543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d6265722cac20556e726567697374657220612070726f7879206163636f756e7420666f72207468652073656e6465722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a2901202d206070726f7879603a20546865206163636f756e74207468617420746865206063616c6c65726020776f756c64206c696b6520746f2072656d6f766520617320612070726f78792e4501202d206070726f78795f74797065603a20546865207065726d697373696f6e732063757272656e746c7920656e61626c656420666f72207468652072656d6f7665642070726f7879206163636f756e742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e3872656d6f76655f70726f786965730028b820556e726567697374657220616c6c2070726f7879206163636f756e747320666f72207468652073656e6465722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901205741524e494e473a2054686973206d61792062652063616c6c6564206f6e206163636f756e747320637265617465642062792060616e6f6e796d6f7573602c20686f776576657220696620646f6e652c207468656e5d012074686520756e726573657276656420666565732077696c6c20626520696e61636365737369626c652e202a2a416c6c2061636365737320746f2074686973206163636f756e742077696c6c206265206c6f73742e2a2a002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e24616e6f6e796d6f75730c2870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d62657214696e6465780c7531365c3d0120537061776e2061206672657368206e6577206163636f756e7420746861742069732067756172616e7465656420746f206265206f746865727769736520696e61636365737369626c652c20616e64010120696e697469616c697a65206974207769746820612070726f7879206f66206070726f78795f747970656020666f7220606f726967696e602073656e6465722e0070205265717569726573206120605369676e656460206f726967696e2e005501202d206070726f78795f74797065603a205468652074797065206f66207468652070726f78792074686174207468652073656e6465722077696c6c2062652072656769737465726564206173206f766572207468655101206e6577206163636f756e742e20546869732077696c6c20616c6d6f737420616c7761797320626520746865206d6f7374207065726d697373697665206050726f7879547970656020706f737369626c6520746f7c20616c6c6f7720666f72206d6178696d756d20666c65786962696c6974792e5501202d2060696e646578603a204120646973616d626967756174696f6e20696e6465782c20696e206361736520746869732069732063616c6c6564206d756c7469706c652074696d657320696e207468652073616d656101207472616e73616374696f6e2028652e672e207769746820607574696c6974793a3a626174636860292e20556e6c65737320796f75277265207573696e67206062617463686020796f752070726f6261626c79206a757374442077616e7420746f20757365206030602e5101202d206064656c6179603a2054686520616e6e6f756e63656d656e7420706572696f64207265717569726564206f662074686520696e697469616c2070726f78792e2057696c6c2067656e6572616c6c7920626518207a65726f2e005501204661696c73207769746820604475706c69636174656020696620746869732068617320616c7265616479206265656e2063616c6c656420696e2074686973207472616e73616374696f6e2c2066726f6d207468659c2073616d652073656e6465722c2077697468207468652073616d6520706172616d65746572732e00e8204661696c732069662074686572652061726520696e73756666696369656e742066756e647320746f2070617920666f72206465706f7369742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e9020544f444f3a204d69676874206265206f76657220636f756e74696e6720312072656164386b696c6c5f616e6f6e796d6f7573141c737061776e657230543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f78795479706514696e6465780c753136186865696768745c436f6d706163743c543a3a426c6f636b4e756d6265723e246578745f696e64657830436f6d706163743c7533323e50b82052656d6f76657320612070726576696f75736c7920737061776e656420616e6f6e796d6f75732070726f78792e004d01205741524e494e473a202a2a416c6c2061636365737320746f2074686973206163636f756e742077696c6c206265206c6f73742e2a2a20416e792066756e64732068656c6420696e2069742077696c6c2062653820696e61636365737369626c652e005d01205265717569726573206120605369676e656460206f726967696e2c20616e64207468652073656e646572206163636f756e74206d7573742068617665206265656e206372656174656420627920612063616c6c20746fac2060616e6f6e796d6f757360207769746820636f72726573706f6e64696e6720706172616d65746572732e005101202d2060737061776e6572603a20546865206163636f756e742074686174206f726967696e616c6c792063616c6c65642060616e6f6e796d6f75736020746f206372656174652074686973206163636f756e742e5101202d2060696e646578603a2054686520646973616d626967756174696f6e20696e646578206f726967696e616c6c792070617373656420746f2060616e6f6e796d6f7573602e2050726f6261626c79206030602e0501202d206070726f78795f74797065603a205468652070726f78792074797065206f726967696e616c6c792070617373656420746f2060616e6f6e796d6f7573602e4101202d2060686569676874603a2054686520686569676874206f662074686520636861696e207768656e207468652063616c6c20746f2060616e6f6e796d6f757360207761732070726f6365737365642e4d01202d20606578745f696e646578603a205468652065787472696e73696320696e64657820696e207768696368207468652063616c6c20746f2060616e6f6e796d6f757360207761732070726f6365737365642e004d01204661696c73207769746820604e6f5065726d697373696f6e6020696e2063617365207468652063616c6c6572206973206e6f7420612070726576696f75736c79206372656174656420616e6f6e796d6f7573f4206163636f756e742077686f73652060616e6f6e796d6f7573602063616c6c2068617320636f72726573706f6e64696e6720706172616d65746572732e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e20616e6e6f756e636508107265616c30543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e540901205075626c697368207468652068617368206f6620612070726f78792d63616c6c20746861742077696c6c206265206d61646520696e20746865206675747572652e0061012054686973206d7573742062652063616c6c656420736f6d65206e756d626572206f6620626c6f636b73206265666f72652074686520636f72726573706f6e64696e67206070726f78796020697320617474656d707465642901206966207468652064656c6179206173736f6369617465642077697468207468652070726f78792072656c6174696f6e736869702069732067726561746572207468616e207a65726f2e001501204e6f206d6f7265207468616e20604d617850656e64696e676020616e6e6f756e63656d656e7473206d6179206265206d61646520617420616e79206f6e652074696d652e000d0120546869732077696c6c2074616b652061206465706f736974206f662060416e6e6f756e63656d656e744465706f736974466163746f72602061732077656c6c2061731d012060416e6e6f756e63656d656e744465706f736974426173656020696620746865726520617265206e6f206f746865722070656e64696e6720616e6e6f756e63656d656e74732e00290120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420612070726f7879206f6620607265616c602e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e1901202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e4c72656d6f76655f616e6e6f756e63656d656e7408107265616c30543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e40742052656d6f7665206120676976656e20616e6e6f756e63656d656e742e005d01204d61792062652063616c6c656420627920612070726f7879206163636f756e7420746f2072656d6f766520612063616c6c20746865792070726576696f75736c7920616e6e6f756e63656420616e642072657475726e3420746865206465706f7369742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e1901202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e4c72656a6563745f616e6e6f756e63656d656e74082064656c656761746530543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e40b42052656d6f76652074686520676976656e20616e6e6f756e63656d656e74206f6620612064656c65676174652e006501204d61792062652063616c6c6564206279206120746172676574202870726f7869656429206163636f756e7420746f2072656d6f766520612063616c6c2074686174206f6e65206f662074686569722064656c656761746573290120286064656c656761746560292068617320616e6e6f756e63656420746865792077616e7420746f20657865637574652e20546865206465706f7369742069732072657475726e65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733af8202d206064656c6567617465603a20546865206163636f756e7420746861742070726576696f75736c7920616e6e6f756e636564207468652063616c6c2ec0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e3c70726f78795f616e6e6f756e636564102064656c656761746530543a3a4163636f756e744964107265616c30543a3a4163636f756e74496440666f7263655f70726f78795f74797065504f7074696f6e3c543a3a50726f7879547970653e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e4451012044697370617463682074686520676976656e206063616c6c602066726f6d20616e206163636f756e742074686174207468652073656e64657220697320617574686f72697a656420666f72207468726f7567683420606164645f70726f7879602e00ac2052656d6f76657320616e7920636f72726573706f6e64696e6720616e6e6f756e63656d656e742873292e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e6501202d2060666f7263655f70726f78795f74797065603a2053706563696679207468652065786163742070726f7879207479706520746f206265207573656420616e6420636865636b656420666f7220746869732063616c6c2ed4202d206063616c6c603a205468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e010c3450726f7879457865637574656404384469737061746368526573756c7404ec20412070726f78792077617320657865637574656420636f72726563746c792c20776974682074686520676976656e205c5b726573756c745c5d2e40416e6f6e796d6f75734372656174656410244163636f756e744964244163636f756e7449642450726f7879547970650c75313608ec20416e6f6e796d6f7573206163636f756e7420686173206265656e2063726561746564206279206e65772070726f7879207769746820676976656e690120646973616d626967756174696f6e20696e64657820616e642070726f787920747970652e205c5b616e6f6e796d6f75732c2077686f2c2070726f78795f747970652c20646973616d626967756174696f6e5f696e6465785c5d24416e6e6f756e6365640c244163636f756e744964244163636f756e744964104861736804510120416e20616e6e6f756e63656d656e742077617320706c6163656420746f206d616b6520612063616c6c20696e20746865206675747572652e205c5b7265616c2c2070726f78792c2063616c6c5f686173685c5d184050726f78794465706f736974426173653042616c616e63654f663c543e4088409f6908030000000000000000000010110120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720612070726f78792e00010120546869732069732068656c6420666f7220616e206164646974696f6e616c2073746f72616765206974656d2077686f73652076616c75652073697a652069732501206073697a656f662842616c616e6365296020627974657320616e642077686f7365206b65792073697a65206973206073697a656f66284163636f756e74496429602062797465732e4850726f78794465706f736974466163746f723042616c616e63654f663c543e40684ed34701000000000000000000000014bc2054686520616d6f756e74206f662063757272656e6379206e6565646564207065722070726f78792061646465642e00690120546869732069732068656c6420666f7220616464696e6720333220627974657320706c757320616e20696e7374616e6365206f66206050726f78795479706560206d6f726520696e746f2061207072652d6578697374696e6761012073746f726167652076616c75652e20546875732c207768656e20636f6e6669677572696e67206050726f78794465706f736974466163746f7260206f6e652073686f756c642074616b6520696e746f206163636f756e74c020603332202b2070726f78795f747970652e656e636f646528292e6c656e282960206279746573206f6620646174612e284d617850726f786965730c75313608200004f020546865206d6178696d756d20616d6f756e74206f662070726f7869657320616c6c6f77656420666f7220612073696e676c65206163636f756e742e284d617850656e64696e670c753332102000000004450120546865206d6178696d756d20616d6f756e74206f662074696d652d64656c6179656420616e6e6f756e63656d656e747320746861742061726520616c6c6f77656420746f2062652070656e64696e672e5c416e6e6f756e63656d656e744465706f736974426173653042616c616e63654f663c543e4088409f690803000000000000000000000c310120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720616e20616e6e6f756e63656d656e742e00690120546869732069732068656c64207768656e2061206e65772073746f72616765206974656d20686f6c64696e672061206042616c616e636560206973206372656174656420287479706963616c6c79203136206279746573292e64416e6e6f756e63656d656e744465706f736974466163746f723042616c616e63654f663c543e40d09ca68f02000000000000000000000010d42054686520616d6f756e74206f662063757272656e6379206e65656465642070657220616e6e6f756e63656d656e74206d6164652e00590120546869732069732068656c6420666f7220616464696e6720616e20604163636f756e744964602c2060486173686020616e642060426c6f636b4e756d6265726020287479706963616c6c79203638206279746573298c20696e746f2061207072652d6578697374696e672073746f726167652076616c75652e201c546f6f4d616e790425012054686572652061726520746f6f206d616e792070726f786965732072656769737465726564206f7220746f6f206d616e7920616e6e6f756e63656d656e74732070656e64696e672e204e6f74466f756e6404782050726f787920726567697374726174696f6e206e6f7420666f756e642e204e6f7450726f787904d02053656e646572206973206e6f7420612070726f7879206f6620746865206163636f756e7420746f2062652070726f786965642e2c556e70726f787961626c6504250120412063616c6c20776869636820697320696e636f6d70617469626c652077697468207468652070726f7879207479706527732066696c7465722077617320617474656d707465642e244475706c69636174650470204163636f756e7420697320616c726561647920612070726f78792e304e6f5065726d697373696f6e0419012043616c6c206d6179206e6f74206265206d6164652062792070726f78792062656361757365206974206d617920657363616c617465206974732070726976696c656765732e2c556e616e6e6f756e63656404d420416e6e6f756e63656d656e742c206966206d61646520617420616c6c2c20776173206d61646520746f6f20726563656e746c792e2c4e6f53656c6650726f787904682043616e6e6f74206164642073656c662061732070726f78792e1e204d756c746973696701204d756c746973696708244d756c74697369677300020530543a3a4163636f756e744964205b75383b2033325dd04d756c74697369673c543a3a426c6f636b4e756d6265722c2042616c616e63654f663c543e2c20543a3a4163636f756e7449643e02040004942054686520736574206f66206f70656e206d756c7469736967206f7065726174696f6e732e1443616c6c73000106205b75383b2033325da0284f706171756543616c6c2c20543a3a4163636f756e7449642c2042616c616e63654f663c543e290004000001105061735f6d756c74695f7468726573686f6c645f3108446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e40550120496d6d6564696174656c792064697370617463682061206d756c74692d7369676e61747572652063616c6c207573696e6720612073696e676c6520617070726f76616c2066726f6d207468652063616c6c65722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e004101202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f206172652070617274206f66207468650501206d756c74692d7369676e61747572652c2062757420646f206e6f7420706172746963697061746520696e2074686520617070726f76616c2070726f636573732e8c202d206063616c6c603a205468652063616c6c20746f2062652065786563757465642e00bc20526573756c74206973206571756976616c656e7420746f20746865206469737061746368656420726573756c742e002c2023203c7765696768743e1d01204f285a202b204329207768657265205a20697320746865206c656e677468206f66207468652063616c6c20616e6420432069747320657865637574696f6e207765696768742e80202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d48202d204442205765696768743a204e6f6e654c202d20506c75732043616c6c20576569676874302023203c2f7765696768743e2061735f6d756c746918247468726573686f6c640c753136446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e3c6d617962655f74696d65706f696e74844f7074696f6e3c54696d65706f696e743c543a3a426c6f636b4e756d6265723e3e1063616c6c284f706171756543616c6c2873746f72655f63616c6c10626f6f6c286d61785f77656967687418576569676874b8590120526567697374657220617070726f76616c20666f72206120646973706174636820746f206265206d6164652066726f6d20612064657465726d696e697374696320636f6d706f73697465206163636f756e74206966fc20617070726f766564206279206120746f74616c206f6620607468726573686f6c64202d203160206f6620606f746865725f7369676e61746f72696573602e00b42049662074686572652061726520656e6f7567682c207468656e206469737061746368207468652063616c6c2e003101205061796d656e743a20604465706f73697442617365602077696c6c20626520726573657276656420696620746869732069732074686520666972737420617070726f76616c2c20706c7573410120607468726573686f6c64602074696d657320604465706f736974466163746f72602e2049742069732072657475726e6564206f6e636520746869732064697370617463682068617070656e73206f72382069732063616e63656c6c65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901202d20607468726573686f6c64603a2054686520746f74616c206e756d626572206f6620617070726f76616c7320666f722074686973206469737061746368206265666f72652069742069732065786563757465642e4501202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f2063616e20617070726f76652074686973702064697370617463682e204d6179206e6f7420626520656d7074792e5d01202d20606d617962655f74696d65706f696e74603a20496620746869732069732074686520666972737420617070726f76616c2c207468656e2074686973206d75737420626520604e6f6e65602e2049662069742069735501206e6f742074686520666972737420617070726f76616c2c207468656e206974206d7573742062652060536f6d65602c2077697468207468652074696d65706f696e742028626c6f636b206e756d62657220616e64d8207472616e73616374696f6e20696e64657829206f662074686520666972737420617070726f76616c207472616e73616374696f6e2e8c202d206063616c6c603a205468652063616c6c20746f2062652065786563757465642e002101204e4f54453a20556e6c6573732074686973206973207468652066696e616c20617070726f76616c2c20796f752077696c6c2067656e6572616c6c792077616e7420746f207573651d012060617070726f76655f61735f6d756c74696020696e73746561642c2073696e6365206974206f6e6c7920726571756972657320612068617368206f66207468652063616c6c2e005d0120526573756c74206973206571756976616c656e7420746f20746865206469737061746368656420726573756c7420696620607468726573686f6c64602069732065786163746c79206031602e204f74686572776973655901206f6e20737563636573732c20726573756c7420697320604f6b6020616e642074686520726573756c742066726f6d2074686520696e746572696f722063616c6c2c206966206974207761732065786563757465642ce0206d617920626520666f756e6420696e20746865206465706f736974656420604d756c7469736967457865637574656460206576656e742e002c2023203c7765696768743e54202d20604f2853202b205a202b2043616c6c29602ed0202d20557020746f206f6e652062616c616e63652d72657365727665206f7220756e72657365727665206f7065726174696f6e2e4101202d204f6e6520706173737468726f756768206f7065726174696f6e2c206f6e6520696e736572742c20626f746820604f285329602077686572652060536020697320746865206e756d626572206f6649012020207369676e61746f726965732e206053602069732063617070656420627920604d61785369676e61746f72696573602c207769746820776569676874206265696e672070726f706f7274696f6e616c2e2501202d204f6e652063616c6c20656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285a296020776865726520605a602069732074782d6c656e2ec0202d204f6e6520656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285329602ed8202d20557020746f206f6e652062696e6172792073656172636820616e6420696e736572742028604f286c6f6753202b20532960292efc202d20492f4f3a2031207265616420604f285329602c20757020746f2031206d757461746520604f285329602e20557020746f206f6e652072656d6f76652e34202d204f6e65206576656e742e70202d2054686520776569676874206f6620746865206063616c6c602e3101202d2053746f726167653a20696e7365727473206f6e65206974656d2c2076616c75652073697a6520626f756e64656420627920604d61785369676e61746f72696573602c20776974682061902020206465706f7369742074616b656e20666f7220697473206c69666574696d65206f66b4202020604465706f73697442617365202b207468726573686f6c64202a204465706f736974466163746f72602e80202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743a250120202020202d2052656164733a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c2043616c6c7320286966206073746f72655f63616c6c6029290120202020202d205772697465733a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c2043616c6c7320286966206073746f72655f63616c6c60294c202d20506c75732043616c6c20576569676874302023203c2f7765696768743e40617070726f76655f61735f6d756c746914247468726573686f6c640c753136446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e3c6d617962655f74696d65706f696e74844f7074696f6e3c54696d65706f696e743c543a3a426c6f636b4e756d6265723e3e2463616c6c5f68617368205b75383b2033325d286d61785f7765696768741857656967687490590120526567697374657220617070726f76616c20666f72206120646973706174636820746f206265206d6164652066726f6d20612064657465726d696e697374696320636f6d706f73697465206163636f756e74206966fc20617070726f766564206279206120746f74616c206f6620607468726573686f6c64202d203160206f6620606f746865725f7369676e61746f72696573602e003101205061796d656e743a20604465706f73697442617365602077696c6c20626520726573657276656420696620746869732069732074686520666972737420617070726f76616c2c20706c7573410120607468726573686f6c64602074696d657320604465706f736974466163746f72602e2049742069732072657475726e6564206f6e636520746869732064697370617463682068617070656e73206f72382069732063616e63656c6c65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901202d20607468726573686f6c64603a2054686520746f74616c206e756d626572206f6620617070726f76616c7320666f722074686973206469737061746368206265666f72652069742069732065786563757465642e4501202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f2063616e20617070726f76652074686973702064697370617463682e204d6179206e6f7420626520656d7074792e5d01202d20606d617962655f74696d65706f696e74603a20496620746869732069732074686520666972737420617070726f76616c2c207468656e2074686973206d75737420626520604e6f6e65602e2049662069742069735501206e6f742074686520666972737420617070726f76616c2c207468656e206974206d7573742062652060536f6d65602c2077697468207468652074696d65706f696e742028626c6f636b206e756d62657220616e64d8207472616e73616374696f6e20696e64657829206f662074686520666972737420617070726f76616c207472616e73616374696f6e2ed0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f2062652065786563757465642e003901204e4f54453a2049662074686973206973207468652066696e616c20617070726f76616c2c20796f752077696c6c2077616e7420746f20757365206061735f6d756c74696020696e73746561642e002c2023203c7765696768743e28202d20604f285329602ed0202d20557020746f206f6e652062616c616e63652d72657365727665206f7220756e72657365727665206f7065726174696f6e2e4101202d204f6e6520706173737468726f756768206f7065726174696f6e2c206f6e6520696e736572742c20626f746820604f285329602077686572652060536020697320746865206e756d626572206f6649012020207369676e61746f726965732e206053602069732063617070656420627920604d61785369676e61746f72696573602c207769746820776569676874206265696e672070726f706f7274696f6e616c2ec0202d204f6e6520656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285329602ed8202d20557020746f206f6e652062696e6172792073656172636820616e6420696e736572742028604f286c6f6753202b20532960292efc202d20492f4f3a2031207265616420604f285329602c20757020746f2031206d757461746520604f285329602e20557020746f206f6e652072656d6f76652e34202d204f6e65206576656e742e3101202d2053746f726167653a20696e7365727473206f6e65206974656d2c2076616c75652073697a6520626f756e64656420627920604d61785369676e61746f72696573602c20776974682061902020206465706f7369742074616b656e20666f7220697473206c69666574696d65206f66b4202020604465706f73697442617365202b207468726573686f6c64202a204465706f736974466163746f72602e8c202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743abc20202020202d20526561643a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745dc020202020202d2057726974653a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d302023203c2f7765696768743e3c63616e63656c5f61735f6d756c746910247468726573686f6c640c753136446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e2474696d65706f696e746454696d65706f696e743c543a3a426c6f636b4e756d6265723e2463616c6c5f68617368205b75383b2033325d6859012043616e63656c2061207072652d6578697374696e672c206f6e2d676f696e67206d756c7469736967207472616e73616374696f6e2e20416e79206465706f7369742072657365727665642070726576696f75736c79c820666f722074686973206f7065726174696f6e2077696c6c20626520756e7265736572766564206f6e20737563636573732e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901202d20607468726573686f6c64603a2054686520746f74616c206e756d626572206f6620617070726f76616c7320666f722074686973206469737061746368206265666f72652069742069732065786563757465642e4501202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f2063616e20617070726f76652074686973702064697370617463682e204d6179206e6f7420626520656d7074792e6101202d206074696d65706f696e74603a205468652074696d65706f696e742028626c6f636b206e756d62657220616e64207472616e73616374696f6e20696e64657829206f662074686520666972737420617070726f76616c7c207472616e73616374696f6e20666f7220746869732064697370617463682ed0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f2062652065786563757465642e002c2023203c7765696768743e28202d20604f285329602ed0202d20557020746f206f6e652062616c616e63652d72657365727665206f7220756e72657365727665206f7065726174696f6e2e4101202d204f6e6520706173737468726f756768206f7065726174696f6e2c206f6e6520696e736572742c20626f746820604f285329602077686572652060536020697320746865206e756d626572206f6649012020207369676e61746f726965732e206053602069732063617070656420627920604d61785369676e61746f72696573602c207769746820776569676874206265696e672070726f706f7274696f6e616c2ec0202d204f6e6520656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285329602e34202d204f6e65206576656e742e88202d20492f4f3a2031207265616420604f285329602c206f6e652072656d6f76652e74202d2053746f726167653a2072656d6f766573206f6e65206974656d2e8c202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743a190120202020202d20526561643a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c20526566756e64204163636f756e742c2043616c6c731d0120202020202d2057726974653a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c20526566756e64204163636f756e742c2043616c6c73302023203c2f7765696768743e01102c4e65774d756c74697369670c244163636f756e744964244163636f756e7449642043616c6c48617368041d012041206e6577206d756c7469736967206f7065726174696f6e2068617320626567756e2e205c5b617070726f76696e672c206d756c74697369672c2063616c6c5f686173685c5d404d756c7469736967417070726f76616c10244163636f756e7449645854696d65706f696e743c426c6f636b4e756d6265723e244163636f756e7449642043616c6c4861736808cc2041206d756c7469736967206f7065726174696f6e20686173206265656e20617070726f76656420627920736f6d656f6e652eb8205c5b617070726f76696e672c2074696d65706f696e742c206d756c74697369672c2063616c6c5f686173685c5d404d756c7469736967457865637574656414244163636f756e7449645854696d65706f696e743c426c6f636b4e756d6265723e244163636f756e7449642043616c6c48617368384469737061746368526573756c740459012041206d756c7469736967206f7065726174696f6e20686173206265656e2065786563757465642e205c5b617070726f76696e672c2074696d65706f696e742c206d756c74697369672c2063616c6c5f686173685c5d444d756c746973696743616e63656c6c656410244163636f756e7449645854696d65706f696e743c426c6f636b4e756d6265723e244163636f756e7449642043616c6c486173680461012041206d756c7469736967206f7065726174696f6e20686173206265656e2063616e63656c6c65642e205c5b63616e63656c6c696e672c2074696d65706f696e742c206d756c74697369672c2063616c6c5f686173685c5d0c2c4465706f736974426173653042616c616e63654f663c543e4008b159840b030000000000000000000008710120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e672061206d756c746973696720657865637574696f6e206f7220746f2073746f72656c20612064697370617463682063616c6c20666f72206c617465722e344465706f736974466163746f723042616c616e63654f663c543e40002de43d0100000000000000000000000455012054686520616d6f756e74206f662063757272656e6379206e65656465642070657220756e6974207468726573686f6c64207768656e206372656174696e672061206d756c746973696720657865637574696f6e2e384d61785369676e61746f726965730c75313608640004010120546865206d6178696d756d20616d6f756e74206f66207369676e61746f7269657320616c6c6f77656420666f72206120676976656e206d756c74697369672e38404d696e696d756d5468726573686f6c640480205468726573686f6c64206d7573742062652032206f7220677265617465722e3c416c7265616479417070726f76656404b02043616c6c20697320616c726561647920617070726f7665642062792074686973207369676e61746f72792e444e6f417070726f76616c734e656564656404a02043616c6c20646f65736e2774206e65656420616e7920286d6f72652920617070726f76616c732e44546f6f4665775369676e61746f7269657304ac2054686572652061726520746f6f20666577207369676e61746f7269657320696e20746865206c6973742e48546f6f4d616e795369676e61746f7269657304b02054686572652061726520746f6f206d616e79207369676e61746f7269657320696e20746865206c6973742e545369676e61746f726965734f75744f664f7264657204110120546865207369676e61746f7269657320776572652070726f7669646564206f7574206f66206f726465723b20746865792073686f756c64206265206f7264657265642e4c53656e646572496e5369676e61746f72696573041101205468652073656e6465722077617320636f6e7461696e656420696e20746865206f74686572207369676e61746f726965733b2069742073686f756c646e27742062652e204e6f74466f756e6404e0204d756c7469736967206f7065726174696f6e206e6f7420666f756e64207768656e20617474656d7074696e6720746f2063616e63656c2e204e6f744f776e6572043101204f6e6c7920746865206163636f756e742074686174206f726967696e616c6c79206372656174656420746865206d756c74697369672069732061626c6520746f2063616e63656c2069742e2c4e6f54696d65706f696e74042101204e6f2074696d65706f696e742077617320676976656e2c2079657420746865206d756c7469736967206f7065726174696f6e20697320616c726561647920756e6465727761792e3857726f6e6754696d65706f696e74043101204120646966666572656e742074696d65706f696e742077617320676976656e20746f20746865206d756c7469736967206f7065726174696f6e207468617420697320756e6465727761792e4c556e657870656374656454696d65706f696e7404f820412074696d65706f696e742077617320676976656e2c20796574206e6f206d756c7469736967206f7065726174696f6e20697320756e6465727761792e3c4d6178576569676874546f6f4c6f7704d420546865206d6178696d756d2077656967687420696e666f726d6174696f6e2070726f76696465642077617320746f6f206c6f772e34416c726561647953746f72656404a420546865206461746120746f2062652073746f72656420697320616c72656164792073746f7265642e1f20426f756e7469657301205472656173757279102c426f756e7479436f756e7401002c426f756e7479496e646578100000000004c0204e756d626572206f6620626f756e74792070726f706f73616c7320746861742068617665206265656e206d6164652e20426f756e746965730001052c426f756e7479496e646578c8426f756e74793c543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e000400047820426f756e7469657320746861742068617665206265656e206d6164652e48426f756e74794465736372697074696f6e730001052c426f756e7479496e6465781c5665633c75383e000400048020546865206465736372697074696f6e206f66206561636820626f756e74792e3c426f756e7479417070726f76616c730100405665633c426f756e7479496e6465783e040004ec20426f756e747920696e646963657320746861742068617665206265656e20617070726f76656420627574206e6f74207965742066756e6465642e01243870726f706f73655f626f756e7479081476616c756554436f6d706163743c42616c616e63654f663c543e3e2c6465736372697074696f6e1c5665633c75383e30582050726f706f73652061206e657720626f756e74792e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005501205061796d656e743a20605469705265706f72744465706f73697442617365602077696c6c2062652072657365727665642066726f6d20746865206f726967696e206163636f756e742c2061732077656c6c20617355012060446174614465706f736974506572427974656020666f722065616368206279746520696e2060726561736f6e602e2049742077696c6c20626520756e72657365727665642075706f6e20617070726f76616c2c68206f7220736c6173686564207768656e2072656a65637465642e00fc202d206063757261746f72603a205468652063757261746f72206163636f756e742077686f6d2077696c6c206d616e616765207468697320626f756e74792e68202d2060666565603a205468652063757261746f72206665652e2901202d206076616c7565603a2054686520746f74616c207061796d656e7420616d6f756e74206f66207468697320626f756e74792c2063757261746f722066656520696e636c756465642ec4202d20606465736372697074696f6e603a20546865206465736372697074696f6e206f66207468697320626f756e74792e38617070726f76655f626f756e74790424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e20610120417070726f7665206120626f756e74792070726f706f73616c2e2041742061206c617465722074696d652c2074686520626f756e74792077696c6c2062652066756e64656420616e64206265636f6d6520616374697665ac20616e6420746865206f726967696e616c206465706f7369742077696c6c2062652072657475726e65642e00b0204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a417070726f76654f726967696e602e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e3c70726f706f73655f63757261746f720c24626f756e74795f696450436f6d706163743c426f756e7479496e6465783e1c63757261746f728c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263650c66656554436f6d706163743c42616c616e63654f663c543e3e1c942041737369676e20612063757261746f7220746f20612066756e64656420626f756e74792e00b0204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a417070726f76654f726967696e602e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e40756e61737369676e5f63757261746f720424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e488020556e61737369676e2063757261746f722066726f6d206120626f756e74792e00210120546869732066756e6374696f6e2063616e206f6e6c792062652063616c6c656420627920746865206052656a6563744f726967696e602061207369676e6564206f726967696e2e00690120496620746869732066756e6374696f6e2069732063616c6c656420627920746865206052656a6563744f726967696e602c20776520617373756d652074686174207468652063757261746f72206973206d616c6963696f75730d01206f7220696e6163746976652e204173206120726573756c742c2077652077696c6c20736c617368207468652063757261746f72207768656e20706f737369626c652e00650120496620746865206f726967696e206973207468652063757261746f722c2077652074616b6520746869732061732061207369676e20746865792061726520756e61626c6520746f20646f207468656972206a6f6220616e64610120746865792077696c6c696e676c7920676976652075702e20576520636f756c6420736c617368207468656d2c2062757420666f72206e6f7720776520616c6c6f77207468656d20746f207265636f7665722074686569723901206465706f73697420616e64206578697420776974686f75742069737375652e20285765206d61792077616e7420746f206368616e67652074686973206966206974206973206162757365642e290061012046696e616c6c792c20746865206f726967696e2063616e20626520616e796f6e6520696620616e64206f6e6c79206966207468652063757261746f722069732022696e616374697665222e205468697320616c6c6f7773650120616e796f6e6520696e2074686520636f6d6d756e69747920746f2063616c6c206f7574207468617420612063757261746f72206973206e6f7420646f696e67207468656972206475652064696c6967656e63652c20616e643d012077652073686f756c64207069636b2061206e65772063757261746f722e20496e20746869732063617365207468652063757261746f722073686f756c6420616c736f20626520736c61736865642e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e386163636570745f63757261746f720424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e209820416363657074207468652063757261746f7220726f6c6520666f72206120626f756e74792e2d012041206465706f7369742077696c6c2062652072657365727665642066726f6d2063757261746f7220616e6420726566756e642075706f6e207375636365737366756c207061796f75742e0094204d6179206f6e6c792062652063616c6c65642066726f6d207468652063757261746f722e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e3061776172645f626f756e74790824626f756e74795f696450436f6d706163743c426f756e7479496e6465783e2c62656e65666963696172798c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f7572636528990120417761726420626f756e747920746f20612062656e6566696369617279206163636f756e742e205468652062656e65666963696172792077696c6c2062652061626c6520746f20636c61696d207468652066756e647320616674657220612064656c61792e00190120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265207468652063757261746f72206f66207468697320626f756e74792e008c202d2060626f756e74795f6964603a20426f756e747920494420746f2061776172642e1d01202d206062656e6566696369617279603a205468652062656e6566696369617279206163636f756e742077686f6d2077696c6c207265636569766520746865207061796f75742e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e30636c61696d5f626f756e74790424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e24f020436c61696d20746865207061796f75742066726f6d20616e206177617264656420626f756e7479206166746572207061796f75742064656c61792e00290120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265207468652062656e6566696369617279206f66207468697320626f756e74792e008c202d2060626f756e74795f6964603a20426f756e747920494420746f20636c61696d2e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e30636c6f73655f626f756e74790424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e283d012043616e63656c20612070726f706f736564206f722061637469766520626f756e74792e20416c6c207468652066756e64732077696c6c2062652073656e7420746f20747265617375727920616e64d0207468652063757261746f72206465706f7369742077696c6c20626520756e726573657276656420696620706f737369626c652e00cc204f6e6c792060543a3a52656a6563744f726967696e602069732061626c6520746f2063616e63656c206120626f756e74792e0090202d2060626f756e74795f6964603a20426f756e747920494420746f2063616e63656c2e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e50657874656e645f626f756e74795f6578706972790824626f756e74795f696450436f6d706163743c426f756e7479496e6465783e1c5f72656d61726b1c5665633c75383e28b020457874656e6420746865206578706972792074696d65206f6620616e2061637469766520626f756e74792e00190120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265207468652063757261746f72206f66207468697320626f756e74792e0090202d2060626f756e74795f6964603a20426f756e747920494420746f20657874656e642e90202d206072656d61726b603a206164646974696f6e616c20696e666f726d6174696f6e2e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e011c38426f756e747950726f706f736564042c426f756e7479496e646578047c204e657720626f756e74792070726f706f73616c2e205c5b696e6465785c5d38426f756e747952656a6563746564082c426f756e7479496e6465781c42616c616e6365041101204120626f756e74792070726f706f73616c207761732072656a65637465643b2066756e6473207765726520736c61736865642e205c5b696e6465782c20626f6e645c5d48426f756e7479426563616d65416374697665042c426f756e7479496e64657804e4204120626f756e74792070726f706f73616c2069732066756e64656420616e6420626563616d65206163746976652e205c5b696e6465785c5d34426f756e747941776172646564082c426f756e7479496e646578244163636f756e74496404f4204120626f756e7479206973206177617264656420746f20612062656e65666963696172792e205c5b696e6465782c2062656e65666963696172795c5d34426f756e7479436c61696d65640c2c426f756e7479496e6465781c42616c616e6365244163636f756e744964040d01204120626f756e747920697320636c61696d65642062792062656e65666963696172792e205c5b696e6465782c207061796f75742c2062656e65666963696172795c5d38426f756e747943616e63656c6564042c426f756e7479496e6465780484204120626f756e74792069732063616e63656c6c65642e205c5b696e6465785c5d38426f756e7479457874656e646564042c426f756e7479496e646578049c204120626f756e74792065787069727920697320657874656e6465642e205c5b696e6465785c5d1c48446174614465706f736974506572427974653042616c616e63654f663c543e40aa50576300000000000000000000000004fc2054686520616d6f756e742068656c64206f6e206465706f7369742070657220627974652077697468696e20626f756e7479206465736372697074696f6e2e44426f756e74794465706f736974426173653042616c616e63654f663c543e40aa821bce26000000000000000000000004e82054686520616d6f756e742068656c64206f6e206465706f73697420666f7220706c6163696e67206120626f756e74792070726f706f73616c2e60426f756e74794465706f7369745061796f757444656c617938543a3a426c6f636b4e756d6265721000e10000045901205468652064656c617920706572696f6420666f72207768696368206120626f756e74792062656e6566696369617279206e65656420746f2077616974206265666f726520636c61696d20746865207061796f75742e48426f756e7479557064617465506572696f6438543a3a426c6f636b4e756d6265721080c61300046c20426f756e7479206475726174696f6e20696e20626c6f636b732e50426f756e747943757261746f724465706f7369741c5065726d696c6c1020a10700046d012050657263656e74616765206f66207468652063757261746f722066656520746861742077696c6c20626520726573657276656420757066726f6e74206173206465706f73697420666f7220626f756e74792063757261746f722e48426f756e747956616c75654d696e696d756d3042616c616e63654f663c543e405405379c4d00000000000000000000000470204d696e696d756d2076616c756520666f72206120626f756e74792e4c4d6178696d756d526561736f6e4c656e6774680c75333210004000000488204d6178696d756d2061636365707461626c6520726561736f6e206c656e6774682e2470496e73756666696369656e7450726f706f7365727342616c616e6365047c2050726f706f73657227732062616c616e636520697320746f6f206c6f772e30496e76616c6964496e6465780494204e6f2070726f706f73616c206f7220626f756e7479206174207468617420696e6465782e30526561736f6e546f6f42696704882054686520726561736f6e20676976656e206973206a75737420746f6f206269672e40556e657870656374656453746174757304842054686520626f756e74792073746174757320697320756e65787065637465642e385265717569726543757261746f720460205265717569726520626f756e74792063757261746f722e30496e76616c696456616c7565045820496e76616c696420626f756e74792076616c75652e28496e76616c6964466565045020496e76616c696420626f756e7479206665652e3450656e64696e675061796f75740870204120626f756e7479207061796f75742069732070656e64696e672efc20546f2063616e63656c2074686520626f756e74792c20796f75206d75737420756e61737369676e20616e6420736c617368207468652063757261746f722e245072656d61747572650449012054686520626f756e746965732063616e6e6f7420626520636c61696d65642f636c6f73656420626563617573652069742773207374696c6c20696e2074686520636f756e74646f776e20706572696f642e231054697073012054726561737572790810546970730001051c543a3a48617368f04f70656e5469703c543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265722c20543a3a486173683e0004000c650120546970734d6170207468617420617265206e6f742079657420636f6d706c657465642e204b65796564206279207468652068617368206f66206028726561736f6e2c2077686f29602066726f6d207468652076616c75652e3d012054686973206861732074686520696e73656375726520656e756d657261626c6520686173682066756e6374696f6e2073696e636520746865206b657920697473656c6620697320616c7265616479802067756172616e7465656420746f20626520612073656375726520686173682e1c526561736f6e730001061c543a3a486173681c5665633c75383e0004000849012053696d706c6520707265696d616765206c6f6f6b75702066726f6d2074686520726561736f6e2773206861736820746f20746865206f726967696e616c20646174612e20416761696e2c2068617320616e610120696e73656375726520656e756d657261626c6520686173682073696e636520746865206b65792069732067756172616e7465656420746f2062652074686520726573756c74206f6620612073656375726520686173682e0118387265706f72745f617765736f6d650818726561736f6e1c5665633c75383e0c77686f30543a3a4163636f756e7449644c5d01205265706f727420736f6d657468696e672060726561736f6e60207468617420646573657276657320612074697020616e6420636c61696d20616e79206576656e7475616c207468652066696e6465722773206665652e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005501205061796d656e743a20605469705265706f72744465706f73697442617365602077696c6c2062652072657365727665642066726f6d20746865206f726967696e206163636f756e742c2061732077656c6c206173c02060446174614465706f736974506572427974656020666f722065616368206279746520696e2060726561736f6e602e006101202d2060726561736f6e603a2054686520726561736f6e20666f722c206f7220746865207468696e6720746861742064657365727665732c20746865207469703b2067656e6572616c6c7920746869732077696c6c2062655c20202061205554462d382d656e636f6465642055524c2eec202d206077686f603a20546865206163636f756e742077686963682073686f756c6420626520637265646974656420666f7220746865207469702e007820456d69747320604e657754697060206966207375636365737366756c2e002c2023203c7765696768743ecc202d20436f6d706c65786974793a20604f2852296020776865726520605260206c656e677468206f662060726561736f6e602e942020202d20656e636f64696e6720616e642068617368696e67206f662027726561736f6e2774202d20446252656164733a2060526561736f6e73602c2060546970736078202d2044625772697465733a2060526561736f6e73602c20605469707360302023203c2f7765696768743e2c726574726163745f7469700410686173681c543a3a486173684c550120526574726163742061207072696f72207469702d7265706f72742066726f6d20607265706f72745f617765736f6d65602c20616e642063616e63656c207468652070726f63657373206f662074697070696e672e00e0204966207375636365737366756c2c20746865206f726967696e616c206465706f7369742077696c6c20626520756e72657365727665642e00510120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e642074686520746970206964656e746966696564206279206068617368604501206d7573742068617665206265656e207265706f7274656420627920746865207369676e696e67206163636f756e74207468726f75676820607265706f72745f617765736f6d65602028616e64206e6f7450207468726f75676820607469705f6e657760292e006501202d206068617368603a20546865206964656e74697479206f6620746865206f70656e2074697020666f722077686963682061207469702076616c7565206973206465636c617265642e205468697320697320666f726d656461012020206173207468652068617368206f6620746865207475706c65206f6620746865206f726967696e616c207469702060726561736f6e6020616e64207468652062656e6566696369617279206163636f756e742049442e009020456d697473206054697052657472616374656460206966207375636365737366756c2e002c2023203c7765696768743e54202d20436f6d706c65786974793a20604f28312960dc2020202d20446570656e6473206f6e20746865206c656e677468206f662060543a3a48617368602077686963682069732066697865642e90202d20446252656164733a206054697073602c20606f726967696e206163636f756e7460c0202d2044625772697465733a2060526561736f6e73602c206054697073602c20606f726967696e206163636f756e7460302023203c2f7765696768743e1c7469705f6e65770c18726561736f6e1c5665633c75383e0c77686f30543a3a4163636f756e744964247469705f76616c756554436f6d706163743c42616c616e63654f663c543e3e58f4204769766520612074697020666f7220736f6d657468696e67206e65773b206e6f2066696e6465722773206665652077696c6c2062652074616b656e2e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d757374206265206174206d656d626572206f662074686520605469707065727360207365742e006101202d2060726561736f6e603a2054686520726561736f6e20666f722c206f7220746865207468696e6720746861742064657365727665732c20746865207469703b2067656e6572616c6c7920746869732077696c6c2062655c20202061205554462d382d656e636f6465642055524c2eec202d206077686f603a20546865206163636f756e742077686963682073686f756c6420626520637265646974656420666f7220746865207469702e5101202d20607469705f76616c7565603a2054686520616d6f756e74206f66207469702074686174207468652073656e64657220776f756c64206c696b6520746f20676976652e20546865206d656469616e20746970d820202076616c7565206f662061637469766520746970706572732077696c6c20626520676976656e20746f20746865206077686f602e007820456d69747320604e657754697060206966207375636365737366756c2e002c2023203c7765696768743e5501202d20436f6d706c65786974793a20604f2852202b2054296020776865726520605260206c656e677468206f662060726561736f6e602c2060546020697320746865206e756d626572206f6620746970706572732ec02020202d20604f285429603a206465636f64696e6720605469707065726020766563206f66206c656e6774682060546009012020202020605460206973206368617267656420617320757070657220626f756e6420676976656e2062792060436f6e7461696e734c656e677468426f756e64602e0d0120202020205468652061637475616c20636f737420646570656e6473206f6e2074686520696d706c656d656e746174696f6e206f662060543a3a54697070657273602ee42020202d20604f285229603a2068617368696e6720616e6420656e636f64696e67206f6620726561736f6e206f66206c656e6774682060526080202d20446252656164733a206054697070657273602c2060526561736f6e736078202d2044625772697465733a2060526561736f6e73602c20605469707360302023203c2f7765696768743e0c7469700810686173681c543a3a48617368247469705f76616c756554436f6d706163743c42616c616e63654f663c543e3e64b4204465636c6172652061207469702076616c756520666f7220616e20616c72656164792d6f70656e207469702e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d757374206265206174206d656d626572206f662074686520605469707065727360207365742e006501202d206068617368603a20546865206964656e74697479206f6620746865206f70656e2074697020666f722077686963682061207469702076616c7565206973206465636c617265642e205468697320697320666f726d656461012020206173207468652068617368206f6620746865207475706c65206f66207468652068617368206f6620746865206f726967696e616c207469702060726561736f6e6020616e64207468652062656e6566696369617279382020206163636f756e742049442e5101202d20607469705f76616c7565603a2054686520616d6f756e74206f66207469702074686174207468652073656e64657220776f756c64206c696b6520746f20676976652e20546865206d656469616e20746970d820202076616c7565206f662061637469766520746970706572732077696c6c20626520676976656e20746f20746865206077686f602e00650120456d6974732060546970436c6f73696e676020696620746865207468726573686f6c64206f66207469707065727320686173206265656e207265616368656420616e642074686520636f756e74646f776e20706572696f64342068617320737461727465642e002c2023203c7765696768743ee4202d20436f6d706c65786974793a20604f285429602077686572652060546020697320746865206e756d626572206f6620746970706572732e15012020206465636f64696e6720605469707065726020766563206f66206c656e677468206054602c20696e736572742074697020616e6420636865636b20636c6f73696e672c0101202020605460206973206368617267656420617320757070657220626f756e6420676976656e2062792060436f6e7461696e734c656e677468426f756e64602e05012020205468652061637475616c20636f737420646570656e6473206f6e2074686520696d706c656d656e746174696f6e206f662060543a3a54697070657273602e00610120202041637475616c6c792077656967687420636f756c64206265206c6f77657220617320697420646570656e6473206f6e20686f77206d616e7920746970732061726520696e20604f70656e5469706020627574206974d4202020697320776569676874656420617320696620616c6d6f73742066756c6c20692e65206f66206c656e6774682060542d31602e74202d20446252656164733a206054697070657273602c206054697073604c202d2044625772697465733a20605469707360302023203c2f7765696768743e24636c6f73655f7469700410686173681c543a3a48617368446020436c6f736520616e64207061796f75742061207469702e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e0019012054686520746970206964656e74696669656420627920606861736860206d75737420686176652066696e69736865642069747320636f756e74646f776e20706572696f642e006501202d206068617368603a20546865206964656e74697479206f6620746865206f70656e2074697020666f722077686963682061207469702076616c7565206973206465636c617265642e205468697320697320666f726d656461012020206173207468652068617368206f6620746865207475706c65206f6620746865206f726967696e616c207469702060726561736f6e6020616e64207468652062656e6566696369617279206163636f756e742049442e002c2023203c7765696768743ee4202d20436f6d706c65786974793a20604f285429602077686572652060546020697320746865206e756d626572206f6620746970706572732e9c2020206465636f64696e6720605469707065726020766563206f66206c656e677468206054602e0101202020605460206973206368617267656420617320757070657220626f756e6420676976656e2062792060436f6e7461696e734c656e677468426f756e64602e05012020205468652061637475616c20636f737420646570656e6473206f6e2074686520696d706c656d656e746174696f6e206f662060543a3a54697070657273602eac202d20446252656164733a206054697073602c206054697070657273602c20607469702066696e64657260dc202d2044625772697465733a2060526561736f6e73602c206054697073602c206054697070657273602c20607469702066696e64657260302023203c2f7765696768743e24736c6173685f7469700410686173681c543a3a4861736830982052656d6f766520616e6420736c61736820616e20616c72656164792d6f70656e207469702e00ac204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a52656a6563744f726967696e602e00f8204173206120726573756c742c207468652066696e64657220697320736c617368656420616e6420746865206465706f7369747320617265206c6f73742e008820456d6974732060546970536c617368656460206966207375636365737366756c2e002c2023203c7765696768743e0101202020605460206973206368617267656420617320757070657220626f756e6420676976656e2062792060436f6e7461696e734c656e677468426f756e64602e05012020205468652061637475616c20636f737420646570656e6473206f6e2074686520696d706c656d656e746174696f6e206f662060543a3a54697070657273602e302023203c2f7765696768743e0114184e657754697004104861736804cc2041206e6577207469702073756767657374696f6e20686173206265656e206f70656e65642e205c5b7469705f686173685c5d28546970436c6f73696e670410486173680411012041207469702073756767657374696f6e206861732072656163686564207468726573686f6c6420616e6420697320636c6f73696e672e205c5b7469705f686173685c5d24546970436c6f7365640c1048617368244163636f756e7449641c42616c616e636504f02041207469702073756767657374696f6e20686173206265656e20636c6f7365642e205c5b7469705f686173682c2077686f2c207061796f75745c5d3054697052657472616374656404104861736804c82041207469702073756767657374696f6e20686173206265656e207265747261637465642e205c5b7469705f686173685c5d28546970536c61736865640c1048617368244163636f756e7449641c42616c616e63650405012041207469702073756767657374696f6e20686173206265656e20736c61736865642e205c5b7469705f686173682c2066696e6465722c206465706f7369745c5d1430546970436f756e74646f776e38543a3a426c6f636b4e756d62657210403800000445012054686520706572696f6420666f722077686963682061207469702072656d61696e73206f70656e20616674657220697320686173206163686965766564207468726573686f6c6420746970706572732e3454697046696e646572734665651c50657263656e7404140431012054686520616d6f756e74206f66207468652066696e616c2074697020776869636820676f657320746f20746865206f726967696e616c207265706f72746572206f6620746865207469702e505469705265706f72744465706f736974426173653042616c616e63654f663c543e40aa821bce26000000000000000000000004d42054686520616d6f756e742068656c64206f6e206465706f73697420666f7220706c6163696e67206120746970207265706f72742e48446174614465706f736974506572427974653042616c616e63654f663c543e40aa5057630000000000000000000000000409012054686520616d6f756e742068656c64206f6e206465706f7369742070657220627974652077697468696e2074686520746970207265706f727420726561736f6e2e4c4d6178696d756d526561736f6e4c656e6774680c75333210004000000488204d6178696d756d2061636365707461626c6520726561736f6e206c656e6774682e1830526561736f6e546f6f42696704882054686520726561736f6e20676976656e206973206a75737420746f6f206269672e30416c72656164794b6e6f776e048c20546865207469702077617320616c726561647920666f756e642f737461727465642e28556e6b6e6f776e54697004642054686520746970206861736820697320756e6b6e6f776e2e244e6f7446696e64657204210120546865206163636f756e7420617474656d7074696e6720746f20726574726163742074686520746970206973206e6f74207468652066696e646572206f6620746865207469702e245374696c6c4f70656e042d0120546865207469702063616e6e6f7420626520636c61696d65642f636c6f736564206265636175736520746865726520617265206e6f7420656e6f7567682074697070657273207965742e245072656d617475726504350120546865207469702063616e6e6f7420626520636c61696d65642f636c6f73656420626563617573652069742773207374696c6c20696e2074686520636f756e74646f776e20706572696f642e2468456c656374696f6e50726f76696465724d756c746950686173650168456c656374696f6e50726f76696465724d756c746950686173651814526f756e6401000c753332100100000018ac20496e7465726e616c20636f756e74657220666f7220746865206e756d626572206f6620726f756e64732e00550120546869732069732075736566756c20666f722064652d6475706c69636174696f6e206f66207472616e73616374696f6e73207375626d697474656420746f2074686520706f6f6c2c20616e642067656e6572616c6c20646961676e6f7374696373206f66207468652070616c6c65742e004d012054686973206973206d6572656c7920696e6372656d656e746564206f6e6365207065722065766572792074696d65207468617420616e20757073747265616d2060656c656374602069732063616c6c65642e3043757272656e74506861736501005450686173653c543a3a426c6f636b4e756d6265723e0400043c2043757272656e742070686173652e38517565756564536f6c7574696f6e00006c5265616479536f6c7574696f6e3c543a3a4163636f756e7449643e0400043d012043757272656e74206265737420736f6c7574696f6e2c207369676e6564206f7220756e7369676e65642c2071756575656420746f2062652072657475726e65642075706f6e2060656c656374602e20536e617073686f7400006c526f756e64536e617073686f743c543a3a4163636f756e7449643e04000c7020536e617073686f742064617461206f662074686520726f756e642e005d01205468697320697320637265617465642061742074686520626567696e6e696e67206f6620746865207369676e656420706861736520616e6420636c65617265642075706f6e2063616c6c696e672060656c656374602e38446573697265645461726765747300000c75333204000ccc2044657369726564206e756d626572206f66207461726765747320746f20656c65637420666f72207468697320726f756e642e00a8204f6e6c7920657869737473207768656e205b60536e617073686f74605d2069732070726573656e742e40536e617073686f744d65746164617461000058536f6c7574696f6e4f72536e617073686f7453697a6504000c9820546865206d65746164617461206f6620746865205b60526f756e64536e617073686f74605d00a8204f6e6c7920657869737473207768656e205b60536e617073686f74605d2069732070726573656e742e01043c7375626d69745f756e7369676e65640820736f6c7574696f6e64526177536f6c7574696f6e3c436f6d706163744f663c543e3e1c7769746e65737358536f6c7574696f6e4f72536e617073686f7453697a6538a8205375626d6974206120736f6c7574696f6e20666f722074686520756e7369676e65642070686173652e00cc20546865206469737061746368206f726967696e20666f20746869732063616c6c206d757374206265205f5f6e6f6e655f5f2e0041012054686973207375626d697373696f6e20697320636865636b6564206f6e2074686520666c792e204d6f72656f7665722c207468697320756e7369676e656420736f6c7574696f6e206973206f6e6c7959012076616c696461746564207768656e207375626d697474656420746f2074686520706f6f6c2066726f6d20746865202a2a6c6f63616c2a2a206e6f64652e204566666563746976656c792c2074686973206d65616e7361012074686174206f6e6c79206163746976652076616c696461746f72732063616e207375626d69742074686973207472616e73616374696f6e207768656e20617574686f72696e67206120626c6f636b202873696d696c61724420746f20616e20696e686572656e74292e005d0120546f2070726576656e7420616e7920696e636f727265637420736f6c7574696f6e2028616e642074687573207761737465642074696d652f776569676874292c2074686973207472616e73616374696f6e2077696c6c51012070616e69632069662074686520736f6c7574696f6e207375626d6974746564206279207468652076616c696461746f7220697320696e76616c696420696e20616e79207761792c206566666563746976656c79a02070757474696e6720746865697220617574686f72696e6720726577617264206174207269736b2e00e4204e6f206465706f736974206f7220726577617264206973206173736f63696174656420776974682074686973207375626d697373696f6e2e011838536f6c7574696f6e53746f726564043c456c656374696f6e436f6d7075746510b8204120736f6c7574696f6e207761732073746f72656420776974682074686520676976656e20636f6d707574652e0041012049662074686520736f6c7574696f6e206973207369676e65642c2074686973206d65616e732074686174206974206861736e277420796574206265656e2070726f6365737365642e20496620746865090120736f6c7574696f6e20697320756e7369676e65642c2074686973206d65616e7320746861742069742068617320616c736f206265656e2070726f6365737365642e44456c656374696f6e46696e616c697a6564045c4f7074696f6e3c456c656374696f6e436f6d707574653e0859012054686520656c656374696f6e20686173206265656e2066696e616c697a65642c20776974682060536f6d6560206f662074686520676976656e20636f6d7075746174696f6e2c206f7220656c7365206966207468656420656c656374696f6e206661696c65642c20604e6f6e65602e20526577617264656404244163636f756e74496404290120416e206163636f756e7420686173206265656e20726577617264656420666f72207468656972207369676e6564207375626d697373696f6e206265696e672066696e616c697a65642e1c536c617368656404244163636f756e74496404250120416e206163636f756e7420686173206265656e20736c617368656420666f72207375626d697474696e6720616e20696e76616c6964207369676e6564207375626d697373696f6e2e485369676e6564506861736553746172746564040c75333204c420546865207369676e6564207068617365206f662074686520676976656e20726f756e642068617320737461727465642e50556e7369676e6564506861736553746172746564040c75333204cc2054686520756e7369676e6564207068617365206f662074686520676976656e20726f756e642068617320737461727465642e0c34556e7369676e6564506861736538543a3a426c6f636b4e756d62657210960000000480204475726174696f6e206f662074686520756e7369676e65642070686173652e2c5369676e6564506861736538543a3a426c6f636b4e756d62657210000000000478204475726174696f6e206f6620746865207369676e65642070686173652e70536f6c7574696f6e496d70726f76656d656e745468726573686f6c641c50657262696c6c1020a10700084d0120546865206d696e696d756d20616d6f756e74206f6620696d70726f76656d656e7420746f2074686520736f6c7574696f6e2073636f7265207468617420646566696e6573206120736f6c7574696f6e206173642022626574746572222028696e20616e79207068617365292e0c6850726544697370617463684561726c795375626d697373696f6e0468205375626d697373696f6e2077617320746f6f206561726c792e6c507265446973706174636857726f6e6757696e6e6572436f756e74048c2057726f6e67206e756d626572206f662077696e6e6572732070726573656e7465642e6450726544697370617463685765616b5375626d697373696f6e0494205375626d697373696f6e2077617320746f6f207765616b2c2073636f72652d776973652e25041c40436865636b5370656356657273696f6e38436865636b547856657273696f6e30436865636b47656e6573697338436865636b4d6f7274616c69747928436865636b4e6f6e63652c436865636b576569676874604368617267655472616e73616374696f6e5061796d656e74 \ No newline at end of file diff --git a/runtime/src/main/assets/metadata/polkadot b/runtime/src/main/assets/metadata/polkadot new file mode 100644 index 0000000..821d248 --- /dev/null +++ b/runtime/src/main/assets/metadata/polkadot @@ -0,0 +1 @@ +0x6d6574610c7c1853797374656d011853797374656d401c4163636f756e7401010230543a3a4163636f756e744964944163636f756e74496e666f3c543a3a496e6465782c20543a3a4163636f756e74446174613e004101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004e8205468652066756c6c206163636f756e7420696e666f726d6174696f6e20666f72206120706172746963756c6172206163636f756e742049442e3845787472696e736963436f756e7400000c753332040004b820546f74616c2065787472696e7369637320636f756e7420666f72207468652063757272656e7420626c6f636b2e2c426c6f636b576569676874010038436f6e73756d6564576569676874600000000000000000000000000000000000000000000000000488205468652063757272656e742077656967687420666f722074686520626c6f636b2e40416c6c45787472696e736963734c656e00000c753332040004410120546f74616c206c656e6774682028696e2062797465732920666f7220616c6c2065787472696e736963732070757420746f6765746865722c20666f72207468652063757272656e7420626c6f636b2e24426c6f636b4861736801010538543a3a426c6f636b4e756d6265721c543a3a48617368008000000000000000000000000000000000000000000000000000000000000000000498204d6170206f6620626c6f636b206e756d6265727320746f20626c6f636b206861736865732e3445787472696e736963446174610101050c7533321c5665633c75383e000400043d012045787472696e73696373206461746120666f72207468652063757272656e7420626c6f636b20286d61707320616e2065787472696e736963277320696e64657820746f206974732064617461292e184e756d626572010038543a3a426c6f636b4e756d6265721000000000040901205468652063757272656e7420626c6f636b206e756d626572206265696e672070726f6365737365642e205365742062792060657865637574655f626c6f636b602e28506172656e744861736801001c543a3a4861736880000000000000000000000000000000000000000000000000000000000000000004702048617368206f66207468652070726576696f757320626c6f636b2e1844696765737401002c4469676573744f663c543e040004f020446967657374206f66207468652063757272656e7420626c6f636b2c20616c736f2070617274206f662074686520626c6f636b206865616465722e184576656e747301008c5665633c4576656e745265636f72643c543a3a4576656e742c20543a3a486173683e3e040004a0204576656e7473206465706f736974656420666f72207468652063757272656e7420626c6f636b2e284576656e74436f756e740100284576656e74496e646578100000000004b820546865206e756d626572206f66206576656e747320696e2074686520604576656e74733c543e60206c6973742e2c4576656e74546f706963730101021c543a3a48617368845665633c28543a3a426c6f636b4e756d6265722c204576656e74496e646578293e000400282501204d617070696e67206265747765656e206120746f7069632028726570726573656e74656420627920543a3a486173682920616e64206120766563746f72206f6620696e646578657394206f66206576656e747320696e2074686520603c4576656e74733c543e3e60206c6973742e00510120416c6c20746f70696320766563746f727320686176652064657465726d696e69737469632073746f72616765206c6f636174696f6e7320646570656e64696e67206f6e2074686520746f7069632e2054686973450120616c6c6f7773206c696768742d636c69656e747320746f206c6576657261676520746865206368616e67657320747269652073746f7261676520747261636b696e67206d656368616e69736d20616e64e420696e2063617365206f66206368616e67657320666574636820746865206c697374206f66206576656e7473206f6620696e7465726573742e004d01205468652076616c756520686173207468652074797065206028543a3a426c6f636b4e756d6265722c204576656e74496e646578296020626563617573652069662077652075736564206f6e6c79206a7573744d012074686520604576656e74496e64657860207468656e20696e20636173652069662074686520746f70696320686173207468652073616d6520636f6e74656e7473206f6e20746865206e65787420626c6f636b0101206e6f206e6f74696669636174696f6e2077696c6c20626520747269676765726564207468757320746865206576656e74206d69676874206265206c6f73742e484c61737452756e74696d65557067726164650000584c61737452756e74696d6555706772616465496e666f04000455012053746f726573207468652060737065635f76657273696f6e6020616e642060737065635f6e616d6560206f66207768656e20746865206c6173742072756e74696d6520757067726164652068617070656e65642e545570677261646564546f553332526566436f756e74010010626f6f6c0400044d012054727565206966207765206861766520757067726164656420736f207468617420607479706520526566436f756e74602069732060753332602e2046616c7365202864656661756c7429206966206e6f742e605570677261646564546f547269706c65526566436f756e74010010626f6f6c0400085d012054727565206966207765206861766520757067726164656420736f2074686174204163636f756e74496e666f20636f6e7461696e73207468726565207479706573206f662060526566436f756e74602e2046616c736548202864656661756c7429206966206e6f742e38457865637574696f6e50686173650000145068617365040004882054686520657865637574696f6e207068617365206f662074686520626c6f636b2e01282866696c6c5f626c6f636b04185f726174696f1c50657262696c6c040901204120646973706174636820746861742077696c6c2066696c6c2074686520626c6f636b2077656967687420757020746f2074686520676976656e20726174696f2e1872656d61726b041c5f72656d61726b1c5665633c75383e146c204d616b6520736f6d65206f6e2d636861696e2072656d61726b2e002c2023203c7765696768743e24202d20604f28312960302023203c2f7765696768743e387365745f686561705f7061676573041470616765730c75363420fc2053657420746865206e756d626572206f6620706167657320696e2074686520576562417373656d626c7920656e7669726f6e6d656e74277320686561702e002c2023203c7765696768743e24202d20604f283129604c202d20312073746f726167652077726974652e64202d2042617365205765696768743a20312e34303520c2b57360202d203120777269746520746f20484541505f5041474553302023203c2f7765696768743e207365745f636f64650410636f64651c5665633c75383e28682053657420746865206e65772072756e74696d6520636f64652e002c2023203c7765696768743e3501202d20604f2843202b2053296020776865726520604360206c656e677468206f662060636f64656020616e642060536020636f6d706c6578697479206f66206063616e5f7365745f636f64656088202d20312073746f726167652077726974652028636f64656320604f28432960292e7901202d20312063616c6c20746f206063616e5f7365745f636f6465603a20604f28532960202863616c6c73206073705f696f3a3a6d6973633a3a72756e74696d655f76657273696f6e6020776869636820697320657870656e73697665292e2c202d2031206576656e742e7d012054686520776569676874206f6620746869732066756e6374696f6e20697320646570656e64656e74206f6e207468652072756e74696d652c206275742067656e6572616c6c792074686973206973207665727920657870656e736976652e902057652077696c6c207472656174207468697320617320612066756c6c20626c6f636b2e302023203c2f7765696768743e5c7365745f636f64655f776974686f75745f636865636b730410636f64651c5665633c75383e201d012053657420746865206e65772072756e74696d6520636f646520776974686f757420646f696e6720616e7920636865636b73206f662074686520676976656e2060636f6465602e002c2023203c7765696768743e90202d20604f2843296020776865726520604360206c656e677468206f662060636f64656088202d20312073746f726167652077726974652028636f64656320604f28432960292e2c202d2031206576656e742e75012054686520776569676874206f6620746869732066756e6374696f6e20697320646570656e64656e74206f6e207468652072756e74696d652e2057652077696c6c207472656174207468697320617320612066756c6c20626c6f636b2e302023203c2f7765696768743e5c7365745f6368616e6765735f747269655f636f6e666967044c6368616e6765735f747269655f636f6e666967804f7074696f6e3c4368616e67657354726965436f6e66696775726174696f6e3e28a02053657420746865206e6577206368616e676573207472696520636f6e66696775726174696f6e2e002c2023203c7765696768743e24202d20604f28312960b0202d20312073746f72616765207772697465206f722064656c6574652028636f64656320604f28312960292ed8202d20312063616c6c20746f20606465706f7369745f6c6f67603a20557365732060617070656e6460204150492c20736f204f28312964202d2042617365205765696768743a20372e32313820c2b57334202d204442205765696768743aa820202020202d205772697465733a204368616e67657320547269652c2053797374656d20446967657374302023203c2f7765696768743e2c7365745f73746f7261676504146974656d73345665633c4b657956616c75653e206c2053657420736f6d65206974656d73206f662073746f726167652e002c2023203c7765696768743e94202d20604f2849296020776865726520604960206c656e677468206f6620606974656d73607c202d206049602073746f72616765207772697465732028604f28312960292e74202d2042617365205765696768743a20302e353638202a206920c2b57368202d205772697465733a204e756d626572206f66206974656d73302023203c2f7765696768743e306b696c6c5f73746f7261676504106b657973205665633c4b65793e2078204b696c6c20736f6d65206974656d732066726f6d2073746f726167652e002c2023203c7765696768743efc202d20604f28494b296020776865726520604960206c656e677468206f6620606b6579736020616e6420604b60206c656e677468206f66206f6e65206b657964202d206049602073746f726167652064656c6574696f6e732e70202d2042617365205765696768743a202e333738202a206920c2b57368202d205772697465733a204e756d626572206f66206974656d73302023203c2f7765696768743e2c6b696c6c5f70726566697808187072656669780c4b6579205f7375626b6579730c7533322c1501204b696c6c20616c6c2073746f72616765206974656d7320776974682061206b657920746861742073746172747320776974682074686520676976656e207072656669782e003d01202a2a4e4f54453a2a2a2057652072656c79206f6e2074686520526f6f74206f726967696e20746f2070726f7669646520757320746865206e756d626572206f66207375626b65797320756e64657241012074686520707265666978207765206172652072656d6f76696e6720746f2061636375726174656c792063616c63756c6174652074686520776569676874206f6620746869732066756e6374696f6e2e002c2023203c7765696768743edc202d20604f285029602077686572652060506020616d6f756e74206f66206b65797320776974682070726566697820607072656669786064202d206050602073746f726167652064656c6574696f6e732e74202d2042617365205765696768743a20302e383334202a205020c2b57380202d205772697465733a204e756d626572206f66207375626b657973202b2031302023203c2f7765696768743e4472656d61726b5f776974685f6576656e74041872656d61726b1c5665633c75383e18a8204d616b6520736f6d65206f6e2d636861696e2072656d61726b20616e6420656d6974206576656e742e002c2023203c7765696768743eb8202d20604f28622960207768657265206220697320746865206c656e677468206f66207468652072656d61726b2e2c202d2031206576656e742e302023203c2f7765696768743e01184045787472696e7369635375636365737304304469737061746368496e666f04b820416e2065787472696e73696320636f6d706c65746564207375636365737366756c6c792e205c5b696e666f5c5d3c45787472696e7369634661696c6564083444697370617463684572726f72304469737061746368496e666f049420416e2065787472696e736963206661696c65642e205c5b6572726f722c20696e666f5c5d2c436f64655570646174656400045420603a636f6465602077617320757064617465642e284e65774163636f756e7404244163636f756e744964047c2041206e6577205c5b6163636f756e745c5d2077617320637265617465642e344b696c6c65644163636f756e7404244163636f756e744964046c20416e205c5b6163636f756e745c5d20776173207265617065642e2052656d61726b656408244163636f756e744964104861736804d4204f6e206f6e2d636861696e2072656d61726b2068617070656e65642e205c5b6f726967696e2c2072656d61726b5f686173685c5d1830426c6f636b57656967687473506c696d6974733a3a426c6f636b57656967687473850100f2052a0100000000204aa9d1010000405973070000000001c0766c8f58010000010098f73e5d010000010000000000000000405973070000000001c0febef9cc0100000100204aa9d1010000010088526a74000000405973070000000000000004d020426c6f636b20262065787472696e7369637320776569676874733a20626173652076616c75657320616e64206c696d6974732e2c426c6f636b4c656e6774684c6c696d6974733a3a426c6f636b4c656e6774683000003c00000050000000500004a820546865206d6178696d756d206c656e677468206f66206120626c6f636b2028696e206279746573292e38426c6f636b48617368436f756e7438543a3a426c6f636b4e756d6265721060090000045501204d6178696d756d206e756d626572206f6620626c6f636b206e756d62657220746f20626c6f636b2068617368206d617070696e677320746f206b65657020286f6c64657374207072756e6564206669727374292e2044625765696768743c52756e74696d6544625765696768744040787d010000000000e1f505000000000409012054686520776569676874206f662072756e74696d65206461746162617365206f7065726174696f6e73207468652072756e74696d652063616e20696e766f6b652e1c56657273696f6e3852756e74696d6556657273696f6ee90220706f6c6b61646f743c7061726974792d706f6c6b61646f74000000001e0000000000000030df6acb689907609b0300000037e397fc7c91f5e40100000040fe3ad401f8959a04000000d2bc9897eed08f1502000000f78b278be53f454c02000000af2c0297a23e6d3d01000000ed99c5acb25eedf502000000cbca25e39f14238702000000687ad44ad37f03c201000000ab3c0572291feb8b01000000bc9d89904f5b923f0100000037c8bb1350a9a2a801000000070000000484204765742074686520636861696e27732063757272656e742076657273696f6e2e2853533538507265666978087538040014a8205468652064657369676e61746564205353383520707265666978206f66207468697320636861696e2e0039012054686973207265706c6163657320746865202273733538466f726d6174222070726f7065727479206465636c6172656420696e2074686520636861696e20737065632e20526561736f6e20697331012074686174207468652072756e74696d652073686f756c64206b6e6f772061626f7574207468652070726566697820696e206f7264657220746f206d616b6520757365206f662069742061737020616e206964656e746966696572206f662074686520636861696e2e143c496e76616c6964537065634e616d6508150120546865206e616d65206f662073706563696669636174696f6e20646f6573206e6f74206d61746368206265747765656e207468652063757272656e742072756e74696d655420616e6420746865206e65772072756e74696d652e685370656356657273696f6e4e65656473546f496e637265617365084501205468652073706563696669636174696f6e2076657273696f6e206973206e6f7420616c6c6f77656420746f206465637265617365206265747765656e207468652063757272656e742072756e74696d655420616e6420746865206e65772072756e74696d652e744661696c6564546f4578747261637452756e74696d6556657273696f6e0cf0204661696c656420746f2065787472616374207468652072756e74696d652076657273696f6e2066726f6d20746865206e65772072756e74696d652e000d01204569746865722063616c6c696e672060436f72655f76657273696f6e60206f72206465636f64696e67206052756e74696d6556657273696f6e60206661696c65642e4c4e6f6e44656661756c74436f6d706f7369746504010120537569636964652063616c6c6564207768656e20746865206163636f756e7420686173206e6f6e2d64656661756c7420636f6d706f7369746520646174612e3c4e6f6e5a65726f526566436f756e740439012054686572652069732061206e6f6e2d7a65726f207265666572656e636520636f756e742070726576656e74696e6720746865206163636f756e742066726f6d206265696e67207075726765642e006052616e646f6d6e657373436f6c6c656374697665466c6970016052616e646f6d6e657373436f6c6c656374697665466c6970043852616e646f6d4d6174657269616c0100305665633c543a3a486173683e04000c610120536572696573206f6620626c6f636b20686561646572732066726f6d20746865206c61737420383120626c6f636b73207468617420616374732061732072616e646f6d2073656564206d6174657269616c2e2054686973610120697320617272616e67656420617320612072696e672062756666657220776974682060626c6f636b5f6e756d626572202520383160206265696e672074686520696e64657820696e746f20746865206056656360206f664420746865206f6c6465737420686173682e000000001f245363686564756c657201245363686564756c65720c184167656e646101010538543a3a426c6f636b4e756d62657271015665633c4f7074696f6e3c5363686564756c65643c3c5420617320436f6e6669673e3a3a43616c6c2c20543a3a426c6f636b4e756d6265722c20543a3a0a50616c6c6574734f726967696e2c20543a3a4163636f756e7449643e3e3e000400044d01204974656d7320746f2062652065786563757465642c20696e64657865642062792074686520626c6f636b206e756d626572207468617420746865792073686f756c64206265206578656375746564206f6e2e184c6f6f6b75700001051c5665633c75383e6c5461736b416464726573733c543a3a426c6f636b4e756d6265723e000400040101204c6f6f6b75702066726f6d206964656e7469747920746f2074686520626c6f636b206e756d62657220616e6420696e646578206f6620746865207461736b2e3853746f7261676556657273696f6e01002052656c656173657304000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e0098204e6577206e6574776f726b732073746172742077697468206c6173742076657273696f6e2e0118207363686564756c6510107768656e38543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e287420416e6f6e796d6f75736c79207363686564756c652061207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c7390202d2042617365205765696768743a2032322e3239202b202e313236202a205320c2b57334202d204442205765696768743a4c20202020202d20526561643a204167656e64615020202020202d2057726974653a204167656e64613d01202d2057696c6c20757365206261736520776569676874206f662032352077686963682073686f756c6420626520676f6f6420666f7220757020746f203330207363686564756c65642063616c6c73302023203c2f7765696768743e1863616e63656c08107768656e38543a3a426c6f636b4e756d62657214696e6465780c75333228982043616e63656c20616e20616e6f6e796d6f75736c79207363686564756c6564207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c7394202d2042617365205765696768743a2032322e3135202b20322e383639202a205320c2b57334202d204442205765696768743a4c20202020202d20526561643a204167656e64617020202020202d2057726974653a204167656e64612c204c6f6f6b75704101202d2057696c6c20757365206261736520776569676874206f66203130302077686963682073686f756c6420626520676f6f6420666f7220757020746f203330207363686564756c65642063616c6c73302023203c2f7765696768743e387363686564756c655f6e616d6564140869641c5665633c75383e107768656e38543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e285c205363686564756c652061206e616d6564207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c738c202d2042617365205765696768743a2032392e36202b202e313539202a205320c2b57334202d204442205765696768743a6c20202020202d20526561643a204167656e64612c204c6f6f6b75707020202020202d2057726974653a204167656e64612c204c6f6f6b75704d01202d2057696c6c20757365206261736520776569676874206f662033352077686963682073686f756c6420626520676f6f6420666f72206d6f7265207468616e203330207363686564756c65642063616c6c73302023203c2f7765696768743e3063616e63656c5f6e616d6564040869641c5665633c75383e287c2043616e63656c2061206e616d6564207363686564756c6564207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c7394202d2042617365205765696768743a2032342e3931202b20322e393037202a205320c2b57334202d204442205765696768743a6c20202020202d20526561643a204167656e64612c204c6f6f6b75707020202020202d2057726974653a204167656e64612c204c6f6f6b75704101202d2057696c6c20757365206261736520776569676874206f66203130302077686963682073686f756c6420626520676f6f6420666f7220757020746f203330207363686564756c65642063616c6c73302023203c2f7765696768743e387363686564756c655f61667465721014616674657238543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e14ac20416e6f6e796d6f75736c79207363686564756c652061207461736b20616674657220612064656c61792e002c2023203c7765696768743e582053616d65206173205b607363686564756c65605d2e302023203c2f7765696768743e507363686564756c655f6e616d65645f6166746572140869641c5665633c75383e14616674657238543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e1494205363686564756c652061206e616d6564207461736b20616674657220612064656c61792e002c2023203c7765696768743e702053616d65206173205b607363686564756c655f6e616d6564605d2e302023203c2f7765696768743e010c245363686564756c6564082c426c6f636b4e756d6265720c7533320494205363686564756c656420736f6d65207461736b2e205c5b7768656e2c20696e6465785c5d2043616e63656c6564082c426c6f636b4e756d6265720c75333204902043616e63656c656420736f6d65207461736b2e205c5b7768656e2c20696e6465785c5d28446973706174636865640c605461736b416464726573733c426c6f636b4e756d6265723e3c4f7074696f6e3c5665633c75383e3e384469737061746368526573756c7404ac204469737061746368656420736f6d65207461736b2e205c5b7461736b2c2069642c20726573756c745c5d0010404661696c6564546f5363686564756c650468204661696c656420746f207363686564756c6520612063616c6c204e6f74466f756e6404802043616e6e6f742066696e6420746865207363686564756c65642063616c6c2e5c546172676574426c6f636b4e756d626572496e5061737404a820476976656e2074617267657420626c6f636b206e756d62657220697320696e2074686520706173742e4852657363686564756c654e6f4368616e676504f42052657363686564756c65206661696c6564206265636175736520697420646f6573206e6f74206368616e6765207363686564756c65642074696d652e011042616265011042616265402845706f6368496e64657801000c75363420000000000000000004542043757272656e742065706f636820696e6465782e2c417574686f72697469657301009c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e0400046c2043757272656e742065706f636820617574686f7269746965732e2c47656e65736973536c6f74010010536c6f7420000000000000000008f82054686520736c6f74206174207768696368207468652066697273742065706f63682061637475616c6c7920737461727465642e205468697320697320309020756e74696c2074686520666972737420626c6f636b206f662074686520636861696e2e2c43757272656e74536c6f74010010536c6f7420000000000000000004542043757272656e7420736c6f74206e756d6265722e2852616e646f6d6e6573730100587363686e6f72726b656c3a3a52616e646f6d6e65737380000000000000000000000000000000000000000000000000000000000000000028b8205468652065706f63682072616e646f6d6e65737320666f7220746865202a63757272656e742a2065706f63682e002c20232053656375726974790005012054686973204d555354204e4f54206265207573656420666f722067616d626c696e672c2061732069742063616e20626520696e666c75656e6365642062792061f8206d616c6963696f75732076616c696461746f7220696e207468652073686f7274207465726d2e204974204d4159206265207573656420696e206d616e7915012063727970746f677261706869632070726f746f636f6c732c20686f77657665722c20736f206c6f6e67206173206f6e652072656d656d6265727320746861742074686973150120286c696b652065766572797468696e6720656c7365206f6e2d636861696e29206974206973207075626c69632e20466f72206578616d706c652c2069742063616e206265050120757365642077686572652061206e756d626572206973206e656564656420746861742063616e6e6f742068617665206265656e2063686f73656e20627920616e0d01206164766572736172792c20666f7220707572706f7365732073756368206173207075626c69632d636f696e207a65726f2d6b6e6f776c656467652070726f6f66732e6050656e64696e6745706f6368436f6e6669674368616e67650000504e657874436f6e66696744657363726970746f7204000461012050656e64696e672065706f636820636f6e66696775726174696f6e206368616e676520746861742077696c6c206265206170706c696564207768656e20746865206e6578742065706f636820697320656e61637465642e384e65787452616e646f6d6e6573730100587363686e6f72726b656c3a3a52616e646f6d6e657373800000000000000000000000000000000000000000000000000000000000000000045c204e6578742065706f63682072616e646f6d6e6573732e3c4e657874417574686f72697469657301009c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e04000460204e6578742065706f636820617574686f7269746965732e305365676d656e74496e64657801000c7533321000000000247c2052616e646f6d6e65737320756e64657220636f6e737472756374696f6e2e00f4205765206d616b6520612074726164656f6666206265747765656e2073746f7261676520616363657373657320616e64206c697374206c656e6774682e01012057652073746f72652074686520756e6465722d636f6e737472756374696f6e2072616e646f6d6e65737320696e207365676d656e7473206f6620757020746f942060554e4445525f434f4e535452554354494f4e5f5345474d454e545f4c454e475448602e00ec204f6e63652061207365676d656e7420726561636865732074686973206c656e6774682c20776520626567696e20746865206e657874206f6e652e090120576520726573657420616c6c207365676d656e747320616e642072657475726e20746f206030602061742074686520626567696e6e696e67206f662065766572791c2065706f63682e44556e646572436f6e737472756374696f6e0101050c7533326c5665633c7363686e6f72726b656c3a3a52616e646f6d6e6573733e0004000415012054574f582d4e4f54453a20605365676d656e74496e6465786020697320616e20696e6372656173696e6720696e74656765722c20736f2074686973206973206f6b61792e2c496e697469616c697a656400003c4d6179626552616e646f6d6e65737304000801012054656d706f726172792076616c75652028636c656172656420617420626c6f636b2066696e616c697a6174696f6e292077686963682069732060536f6d65601d01206966207065722d626c6f636b20696e697469616c697a6174696f6e2068617320616c7265616479206265656e2063616c6c656420666f722063757272656e7420626c6f636b2e4c417574686f7256726652616e646f6d6e65737301003c4d6179626552616e646f6d6e65737304000c5d012054656d706f726172792076616c75652028636c656172656420617420626c6f636b2066696e616c697a6174696f6e29207468617420696e636c756465732074686520565246206f75747075742067656e6572617465645101206174207468697320626c6f636b2e2054686973206669656c642073686f756c6420616c7761797320626520706f70756c6174656420647572696e6720626c6f636b2070726f63657373696e6720756e6c6573731901207365636f6e6461727920706c61696e20736c6f74732061726520656e61626c65642028776869636820646f6e277420636f6e7461696e206120565246206f7574707574292e2845706f6368537461727401008028543a3a426c6f636b4e756d6265722c20543a3a426c6f636b4e756d62657229200000000000000000145d012054686520626c6f636b206e756d62657273207768656e20746865206c61737420616e642063757272656e742065706f6368206861766520737461727465642c20726573706563746976656c7920604e2d316020616e641420604e602e4901204e4f54453a20576520747261636b207468697320697320696e206f7264657220746f20616e6e6f746174652074686520626c6f636b206e756d626572207768656e206120676976656e20706f6f6c206f66590120656e74726f7079207761732066697865642028692e652e20697420776173206b6e6f776e20746f20636861696e206f6273657276657273292e2053696e63652065706f6368732061726520646566696e656420696e590120736c6f74732c207768696368206d617920626520736b69707065642c2074686520626c6f636b206e756d62657273206d6179206e6f74206c696e6520757020776974682074686520736c6f74206e756d626572732e204c6174656e657373010038543a3a426c6f636b4e756d626572100000000014d820486f77206c617465207468652063757272656e7420626c6f636b20697320636f6d706172656420746f2069747320706172656e742e001501205468697320656e74727920697320706f70756c617465642061732070617274206f6620626c6f636b20657865637574696f6e20616e6420697320636c65616e65642075701101206f6e20626c6f636b2066696e616c697a6174696f6e2e205175657279696e6720746869732073746f7261676520656e747279206f757473696465206f6620626c6f636bb020657865637574696f6e20636f6e746578742073686f756c6420616c77617973207969656c64207a65726f2e2c45706f6368436f6e6669670000584261626545706f6368436f6e66696775726174696f6e04000485012054686520636f6e66696775726174696f6e20666f72207468652063757272656e742065706f63682e2053686f756c64206e6576657220626520604e6f6e656020617320697420697320696e697469616c697a656420696e2067656e657369732e3c4e65787445706f6368436f6e6669670000584261626545706f6368436f6e66696775726174696f6e0400082d012054686520636f6e66696775726174696f6e20666f7220746865206e6578742065706f63682c20604e6f6e65602069662074686520636f6e6669672077696c6c206e6f74206368616e6765e82028796f752063616e2066616c6c6261636b20746f206045706f6368436f6e6669676020696e737465616420696e20746861742063617365292e010c4c7265706f72745f65717569766f636174696f6e084865717569766f636174696f6e5f70726f6f667045717569766f636174696f6e50726f6f663c543a3a4865616465723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66100d01205265706f727420617574686f726974792065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c207665726966790901207468652065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66110120616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e63652077696c6c34206265207265706f727465642e707265706f72745f65717569766f636174696f6e5f756e7369676e6564084865717569766f636174696f6e5f70726f6f667045717569766f636174696f6e50726f6f663c543a3a4865616465723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66200d01205265706f727420617574686f726974792065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c207665726966790901207468652065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66110120616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e63652077696c6c34206265207265706f727465642e110120546869732065787472696e736963206d7573742062652063616c6c656420756e7369676e656420616e642069742069732065787065637465642074686174206f6e6c79190120626c6f636b20617574686f72732077696c6c2063616c6c206974202876616c69646174656420696e206056616c6964617465556e7369676e656460292c206173207375636819012069662074686520626c6f636b20617574686f7220697320646566696e65642069742077696c6c20626520646566696e6564206173207468652065717569766f636174696f6e28207265706f727465722e48706c616e5f636f6e6669675f6368616e67650418636f6e666967504e657874436f6e66696744657363726970746f7210610120506c616e20616e2065706f636820636f6e666967206368616e67652e205468652065706f636820636f6e666967206368616e6765206973207265636f7264656420616e642077696c6c20626520656e6163746564206f6e550120746865206e6578742063616c6c20746f2060656e6163745f65706f63685f6368616e6765602e2054686520636f6e6669672077696c6c20626520616374697661746564206f6e652065706f63682061667465722e5d01204d756c7469706c652063616c6c7320746f2074686973206d6574686f642077696c6c207265706c61636520616e79206578697374696e6720706c616e6e656420636f6e666967206368616e676520746861742068616458206e6f74206265656e20656e6163746564207965742e00083445706f63684475726174696f6e0c7536342060090000000000000cec2054686520616d6f756e74206f662074696d652c20696e20736c6f74732c207468617420656163682065706f63682073686f756c64206c6173742e1901204e4f54453a2043757272656e746c79206974206973206e6f7420706f737369626c6520746f206368616e6765207468652065706f6368206475726174696f6e20616674657221012074686520636861696e2068617320737461727465642e20417474656d7074696e6720746f20646f20736f2077696c6c20627269636b20626c6f636b2070726f64756374696f6e2e444578706563746564426c6f636b54696d6524543a3a4d6f6d656e7420701700000000000014050120546865206578706563746564206176657261676520626c6f636b2074696d6520617420776869636820424142452073686f756c64206265206372656174696e67110120626c6f636b732e2053696e636520424142452069732070726f626162696c6973746963206974206973206e6f74207472697669616c20746f20666967757265206f75740501207768617420746865206578706563746564206176657261676520626c6f636b2074696d652073686f756c64206265206261736564206f6e2074686520736c6f740901206475726174696f6e20616e642074686520736563757269747920706172616d657465722060636020287768657265206031202d20636020726570726573656e7473a0207468652070726f626162696c697479206f66206120736c6f74206265696e6720656d707479292e0c60496e76616c696445717569766f636174696f6e50726f6f6604350120416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c69644b65794f776e65727368697050726f6f660435012041206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f7274041901204120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e022454696d657374616d70012454696d657374616d70080c4e6f77010024543a3a4d6f6d656e7420000000000000000004902043757272656e742074696d6520666f72207468652063757272656e7420626c6f636b2e24446964557064617465010010626f6f6c040004b420446964207468652074696d657374616d7020676574207570646174656420696e207468697320626c6f636b3f01040c736574040c6e6f7748436f6d706163743c543a3a4d6f6d656e743e3c5820536574207468652063757272656e742074696d652e00590120546869732063616c6c2073686f756c6420626520696e766f6b65642065786163746c79206f6e63652070657220626c6f636b2e2049742077696c6c2070616e6963206174207468652066696e616c697a6174696f6ed82070686173652c20696620746869732063616c6c206861736e2774206265656e20696e766f6b656420627920746861742074696d652e004501205468652074696d657374616d702073686f756c642062652067726561746572207468616e207468652070726576696f7573206f6e652062792074686520616d6f756e74207370656369666965642062794420604d696e696d756d506572696f64602e00d820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652060496e686572656e74602e002c2023203c7765696768743e3501202d20604f2831296020284e6f7465207468617420696d706c656d656e746174696f6e73206f6620604f6e54696d657374616d7053657460206d75737420616c736f20626520604f2831296029a101202d20312073746f72616765207265616420616e6420312073746f72616765206d75746174696f6e2028636f64656320604f28312960292e202862656361757365206f6620604469645570646174653a3a74616b656020696e20606f6e5f66696e616c697a656029d8202d2031206576656e742068616e646c657220606f6e5f74696d657374616d705f736574602e204d75737420626520604f283129602e302023203c2f7765696768743e0004344d696e696d756d506572696f6424543a3a4d6f6d656e7420b80b00000000000010690120546865206d696e696d756d20706572696f64206265747765656e20626c6f636b732e204265776172652074686174207468697320697320646966666572656e7420746f20746865202a65787065637465642a20706572696f64690120746861742074686520626c6f636b2070726f64756374696f6e206170706172617475732070726f76696465732e20596f75722063686f73656e20636f6e73656e7375732073797374656d2077696c6c2067656e6572616c6c79650120776f726b2077697468207468697320746f2064657465726d696e6520612073656e7369626c6520626c6f636b2074696d652e20652e672e20466f7220417572612c2069742077696c6c20626520646f75626c6520746869737020706572696f64206f6e2064656661756c742073657474696e67732e00031c496e6469636573011c496e646963657304204163636f756e74730001023c543a3a4163636f756e74496e6465788828543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20626f6f6c29000400048820546865206c6f6f6b75702066726f6d20696e64657820746f206163636f756e742e011414636c61696d0414696e6465783c543a3a4163636f756e74496e646578489c2041737369676e20616e2070726576696f75736c7920756e61737369676e656420696e6465782e00e0205061796d656e743a20604465706f736974602069732072657365727665642066726f6d207468652073656e646572206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e00f4202d2060696e646578603a2074686520696e64657820746f20626520636c61696d65642e2054686973206d757374206e6f7420626520696e207573652e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e64202d204f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e207472616e73666572080c6e657730543a3a4163636f756e74496414696e6465783c543a3a4163636f756e74496e6465785061012041737369676e20616e20696e64657820616c7265616479206f776e6564206279207468652073656e64657220746f20616e6f74686572206163636f756e742e205468652062616c616e6365207265736572766174696f6ebc206973206566666563746976656c79207472616e7366657272656420746f20746865206e6577206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002901202d2060696e646578603a2074686520696e64657820746f2062652072652d61737369676e65642e2054686973206d757374206265206f776e6564206279207468652073656e6465722e6101202d20606e6577603a20746865206e6577206f776e6572206f662074686520696e6465782e20546869732066756e6374696f6e2069732061206e6f2d6f7020696620697420697320657175616c20746f2073656e6465722e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e68202d204f6e65207472616e73666572206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743ae4202020202d2052656164733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e742028726563697069656e7429e8202020202d205772697465733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e742028726563697069656e7429302023203c2f7765696768743e10667265650414696e6465783c543a3a4163636f756e74496e6465784898204672656520757020616e20696e646578206f776e6564206279207468652073656e6465722e006101205061796d656e743a20416e792070726576696f7573206465706f73697420706c6163656420666f722074686520696e64657820697320756e726573657276656420696e207468652073656e646572206163636f756e742e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d757374206f776e2074686520696e6465782e001101202d2060696e646578603a2074686520696e64657820746f2062652066726565642e2054686973206d757374206265206f776e6564206279207468652073656e6465722e008820456d6974732060496e646578467265656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e64202d204f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e38666f7263655f7472616e736665720c0c6e657730543a3a4163636f756e74496414696e6465783c543a3a4163636f756e74496e64657818667265657a6510626f6f6c54590120466f72636520616e20696e64657820746f20616e206163636f756e742e205468697320646f65736e277420726571756972652061206465706f7369742e2049662074686520696e64657820697320616c7265616479ec2068656c642c207468656e20616e79206465706f736974206973207265696d62757273656420746f206974732063757272656e74206f776e65722e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f526f6f745f2e00a8202d2060696e646578603a2074686520696e64657820746f206265202872652d2961737369676e65642e6101202d20606e6577603a20746865206e6577206f776e6572206f662074686520696e6465782e20546869732066756e6374696f6e2069732061206e6f2d6f7020696620697420697320657175616c20746f2073656e6465722e4501202d2060667265657a65603a2069662073657420746f206074727565602c2077696c6c20667265657a652074686520696e64657820736f2069742063616e6e6f74206265207472616e736665727265642e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e7c202d20557020746f206f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743af8202020202d2052656164733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e7420286f726967696e616c206f776e657229fc202020202d205772697465733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e7420286f726967696e616c206f776e657229302023203c2f7765696768743e18667265657a650414696e6465783c543a3a4163636f756e74496e64657844690120467265657a6520616e20696e64657820736f2069742077696c6c20616c7761797320706f696e7420746f207468652073656e646572206163636f756e742e205468697320636f6e73756d657320746865206465706f7369742e005d0120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d7573742068617665206170206e6f6e2d66726f7a656e206163636f756e742060696e646578602e00b0202d2060696e646578603a2074686520696e64657820746f2062652066726f7a656e20696e20706c6163652e008c20456d6974732060496e64657846726f7a656e60206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e74202d20557020746f206f6e6520736c617368206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e010c34496e64657841737369676e656408244163636f756e744964304163636f756e74496e64657804b42041206163636f756e7420696e646578207761732061737369676e65642e205c5b696e6465782c2077686f5c5d28496e646578467265656404304163636f756e74496e64657804e82041206163636f756e7420696e64657820686173206265656e2066726565642075702028756e61737369676e6564292e205c5b696e6465785c5d2c496e64657846726f7a656e08304163636f756e74496e646578244163636f756e7449640429012041206163636f756e7420696e64657820686173206265656e2066726f7a656e20746f206974732063757272656e74206163636f756e742049442e205c5b696e6465782c2077686f5c5d041c4465706f7369743042616c616e63654f663c543e4000e8764817000000000000000000000004ac20546865206465706f736974206e656564656420666f7220726573657276696e6720616e20696e6465782e00042042616c616e636573012042616c616e6365731034546f74616c49737375616e6365010028543a3a42616c616e6365400000000000000000000000000000000004982054686520746f74616c20756e6974732069737375656420696e207468652073797374656d2e1c4163636f756e7401010230543a3a4163636f756e7449645c4163636f756e74446174613c543a3a42616c616e63653e000101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6c205468652062616c616e6365206f6620616e206163636f756e742e004101204e4f54453a2054686973206973206f6e6c79207573656420696e207468652063617365207468617420746869732070616c6c6574206973207573656420746f2073746f72652062616c616e6365732e144c6f636b7301010230543a3a4163636f756e744964705665633c42616c616e63654c6f636b3c543a3a42616c616e63653e3e00040008b820416e79206c6971756964697479206c6f636b73206f6e20736f6d65206163636f756e742062616c616e6365732e2501204e4f54453a2053686f756c64206f6e6c79206265206163636573736564207768656e2073657474696e672c206368616e67696e6720616e642066726565696e672061206c6f636b2e3853746f7261676556657273696f6e01002052656c656173657304000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e00a020546869732069732073657420746f2076322e302e3020666f72206e6577206e6574776f726b732e0110207472616e736665720810646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e6cd8205472616e7366657220736f6d65206c697175696420667265652062616c616e636520746f20616e6f74686572206163636f756e742e00090120607472616e73666572602077696c6c207365742074686520604672656542616c616e636560206f66207468652073656e64657220616e642072656365697665722e21012049742077696c6c2064656372656173652074686520746f74616c2069737375616e6365206f66207468652073797374656d2062792074686520605472616e73666572466565602e1501204966207468652073656e6465722773206163636f756e742069732062656c6f7720746865206578697374656e7469616c206465706f736974206173206120726573756c74b4206f6620746865207472616e736665722c20746865206163636f756e742077696c6c206265207265617065642e00190120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d75737420626520605369676e65646020627920746865207472616e736163746f722e002c2023203c7765696768743e3101202d20446570656e64656e74206f6e20617267756d656e747320627574206e6f7420637269746963616c2c20676976656e2070726f70657220696d706c656d656e746174696f6e7320666f72cc202020696e70757420636f6e6669672074797065732e205365652072656c617465642066756e6374696f6e732062656c6f772e6901202d20497420636f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e642077726974657320696e7465726e616c6c7920616e64206e6f20636f6d706c657820636f6d7075746174696f6e2e004c2052656c617465642066756e6374696f6e733a0051012020202d2060656e737572655f63616e5f77697468647261776020697320616c776179732063616c6c656420696e7465726e616c6c792062757420686173206120626f756e64656420636f6d706c65786974792e2d012020202d205472616e7366657272696e672062616c616e63657320746f206163636f756e7473207468617420646964206e6f74206578697374206265666f72652077696c6c206361757365d420202020202060543a3a4f6e4e65774163636f756e743a3a6f6e5f6e65775f6163636f756e746020746f2062652063616c6c65642e61012020202d2052656d6f76696e6720656e6f7567682066756e64732066726f6d20616e206163636f756e742077696c6c20747269676765722060543a3a4475737452656d6f76616c3a3a6f6e5f756e62616c616e636564602e49012020202d20607472616e736665725f6b6565705f616c6976656020776f726b73207468652073616d652077617920617320607472616e73666572602c206275742068617320616e206164646974696f6e616cf82020202020636865636b207468617420746865207472616e736665722077696c6c206e6f74206b696c6c20746865206f726967696e206163636f756e742e88202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d4501202d2042617365205765696768743a2037332e363420c2b5732c20776f7273742063617365207363656e6172696f20286163636f756e7420637265617465642c206163636f756e742072656d6f76656429dc202d204442205765696768743a2031205265616420616e64203120577269746520746f2064657374696e6174696f6e206163636f756e741501202d204f726967696e206163636f756e7420697320616c726561647920696e206d656d6f72792c20736f206e6f204442206f7065726174696f6e7320666f72207468656d2e302023203c2f7765696768743e2c7365745f62616c616e63650c0c77686f8c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365206e65775f667265654c436f6d706163743c543a3a42616c616e63653e306e65775f72657365727665644c436f6d706163743c543a3a42616c616e63653e489420536574207468652062616c616e636573206f66206120676976656e206163636f756e742e00210120546869732077696c6c20616c74657220604672656542616c616e63656020616e642060526573657276656442616c616e63656020696e2073746f726167652e2069742077696c6c090120616c736f2064656372656173652074686520746f74616c2069737375616e6365206f66207468652073797374656d202860546f74616c49737375616e636560292e190120496620746865206e65772066726565206f722072657365727665642062616c616e63652069732062656c6f7720746865206578697374656e7469616c206465706f7369742c01012069742077696c6c20726573657420746865206163636f756e74206e6f6e63652028606672616d655f73797374656d3a3a4163636f756e744e6f6e636560292e00b420546865206469737061746368206f726967696e20666f7220746869732063616c6c2069732060726f6f74602e002c2023203c7765696768743e80202d20496e646570656e64656e74206f662074686520617267756d656e74732ec4202d20436f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e64207772697465732e58202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d3c202d2042617365205765696768743a6820202020202d204372656174696e673a2032372e353620c2b5736420202020202d204b696c6c696e673a2033352e313120c2b57398202d204442205765696768743a203120526561642c203120577269746520746f206077686f60302023203c2f7765696768743e38666f7263655f7472616e736665720c18736f757263658c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f7572636510646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e1851012045786163746c7920617320607472616e73666572602c2065786365707420746865206f726967696e206d75737420626520726f6f7420616e642074686520736f75726365206163636f756e74206d61792062652c207370656369666965642e2c2023203c7765696768743e4101202d2053616d65206173207472616e736665722c20627574206164646974696f6e616c207265616420616e6420777269746520626563617573652074686520736f75726365206163636f756e74206973902020206e6f7420617373756d656420746f20626520696e20746865206f7665726c61792e302023203c2f7765696768743e4c7472616e736665725f6b6565705f616c6976650810646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e2c51012053616d6520617320746865205b607472616e73666572605d2063616c6c2c206275742077697468206120636865636b207468617420746865207472616e736665722077696c6c206e6f74206b696c6c2074686540206f726967696e206163636f756e742e00bc20393925206f66207468652074696d6520796f752077616e74205b607472616e73666572605d20696e73746561642e00c4205b607472616e73666572605d3a207374727563742e50616c6c65742e68746d6c236d6574686f642e7472616e736665722c2023203c7765696768743ee8202d2043686561706572207468616e207472616e736665722062656361757365206163636f756e742063616e6e6f74206265206b696c6c65642e60202d2042617365205765696768743a2035312e3420c2b5731d01202d204442205765696768743a2031205265616420616e64203120577269746520746f2064657374202873656e64657220697320696e206f7665726c617920616c7265616479292c20233c2f7765696768743e01201c456e646f77656408244163636f756e7449641c42616c616e636504250120416e206163636f756e74207761732063726561746564207769746820736f6d6520667265652062616c616e63652e205c5b6163636f756e742c20667265655f62616c616e63655c5d20447573744c6f737408244163636f756e7449641c42616c616e636508410120416e206163636f756e74207761732072656d6f7665642077686f73652062616c616e636520776173206e6f6e2d7a65726f206275742062656c6f77204578697374656e7469616c4465706f7369742cd020726573756c74696e6720696e20616e206f75747269676874206c6f73732e205c5b6163636f756e742c2062616c616e63655c5d205472616e736665720c244163636f756e744964244163636f756e7449641c42616c616e636504a0205472616e73666572207375636365656465642e205c5b66726f6d2c20746f2c2076616c75655c5d2842616c616e63655365740c244163636f756e7449641c42616c616e63651c42616c616e636504cc20412062616c616e6365207761732073657420627920726f6f742e205c5b77686f2c20667265652c2072657365727665645c5d1c4465706f73697408244163636f756e7449641c42616c616e636504210120536f6d6520616d6f756e7420776173206465706f73697465642028652e672e20666f72207472616e73616374696f6e2066656573292e205c5b77686f2c206465706f7369745c5d20526573657276656408244163636f756e7449641c42616c616e636504210120536f6d652062616c616e63652077617320726573657276656420286d6f7665642066726f6d206672656520746f207265736572766564292e205c5b77686f2c2076616c75655c5d28556e726573657276656408244163636f756e7449641c42616c616e636504290120536f6d652062616c616e63652077617320756e726573657276656420286d6f7665642066726f6d20726573657276656420746f2066726565292e205c5b77686f2c2076616c75655c5d4852657365727665526570617472696174656410244163636f756e744964244163636f756e7449641c42616c616e6365185374617475730c510120536f6d652062616c616e636520776173206d6f7665642066726f6d207468652072657365727665206f6620746865206669727374206163636f756e7420746f20746865207365636f6e64206163636f756e742edc2046696e616c20617267756d656e7420696e64696361746573207468652064657374696e6174696f6e2062616c616e636520747970652ea8205c5b66726f6d2c20746f2c2062616c616e63652c2064657374696e6174696f6e5f7374617475735c5d04484578697374656e7469616c4465706f73697428543a3a42616c616e63654000e40b5402000000000000000000000004d420546865206d696e696d756d20616d6f756e7420726571756972656420746f206b65657020616e206163636f756e74206f70656e2e203856657374696e6742616c616e6365049c2056657374696e672062616c616e636520746f6f206869676820746f2073656e642076616c7565544c69717569646974795265737472696374696f6e7304c8204163636f756e74206c6971756964697479207265737472696374696f6e732070726576656e74207769746864726177616c204f766572666c6f77047420476f7420616e206f766572666c6f7720616674657220616464696e674c496e73756666696369656e7442616c616e636504782042616c616e636520746f6f206c6f7720746f2073656e642076616c7565484578697374656e7469616c4465706f73697404ec2056616c756520746f6f206c6f7720746f20637265617465206163636f756e742064756520746f206578697374656e7469616c206465706f736974244b656570416c6976650490205472616e736665722f7061796d656e7420776f756c64206b696c6c206163636f756e745c4578697374696e6756657374696e675363686564756c6504cc20412076657374696e67207363686564756c6520616c72656164792065786973747320666f722074686973206163636f756e742c446561644163636f756e74048c2042656e6566696369617279206163636f756e74206d757374207072652d657869737405485472616e73616374696f6e5061796d656e7401485472616e73616374696f6e5061796d656e7408444e6578744665654d756c7469706c6965720100284d756c7469706c69657240000064a7b3b6e00d0000000000000000003853746f7261676556657273696f6e01002052656c6561736573040000000008485472616e73616374696f6e427974654665653042616c616e63654f663c543e4040420f00000000000000000000000000040d01205468652066656520746f206265207061696420666f72206d616b696e672061207472616e73616374696f6e3b20746865207065722d6279746520706f7274696f6e2e2c576569676874546f466565a45665633c576569676874546f466565436f656666696369656e743c42616c616e63654f663c543e3e3e5c040000000000000000000000000000000000b4c4040001040d012054686520706f6c796e6f6d69616c2074686174206973206170706c69656420696e206f7264657220746f20646572697665206665652066726f6d207765696768742e002028417574686f72736869700128417574686f72736869700c18556e636c65730100e85665633c556e636c65456e7472794974656d3c543a3a426c6f636b4e756d6265722c20543a3a486173682c20543a3a4163636f756e7449643e3e0400041c20556e636c657318417574686f72000030543a3a4163636f756e7449640400046420417574686f72206f662063757272656e7420626c6f636b2e30446964536574556e636c6573010010626f6f6c040004bc205768657468657220756e636c6573207765726520616c72656164792073657420696e207468697320626c6f636b2e0104287365745f756e636c657304286e65775f756e636c6573385665633c543a3a4865616465723e04642050726f76696465206120736574206f6620756e636c65732e00001c48496e76616c6964556e636c65506172656e74048c2054686520756e636c6520706172656e74206e6f7420696e2074686520636861696e2e40556e636c6573416c7265616479536574048420556e636c657320616c72656164792073657420696e2074686520626c6f636b2e34546f6f4d616e79556e636c6573044420546f6f206d616e7920756e636c65732e3047656e65736973556e636c6504582054686520756e636c652069732067656e657369732e30546f6f48696768556e636c6504802054686520756e636c6520697320746f6f206869676820696e20636861696e2e50556e636c65416c7265616479496e636c75646564047c2054686520756e636c6520697320616c726561647920696e636c756465642e204f6c64556e636c6504b82054686520756e636c652069736e277420726563656e7420656e6f75676820746f20626520696e636c756465642e061c5374616b696e67011c5374616b696e677830486973746f7279446570746801000c75333210540000001c8c204e756d626572206f66206572617320746f206b65657020696e20686973746f72792e00390120496e666f726d6174696f6e206973206b65707420666f72206572617320696e20605b63757272656e745f657261202d20686973746f72795f64657074683b2063757272656e745f6572615d602e006101204d757374206265206d6f7265207468616e20746865206e756d626572206f6620657261732064656c617965642062792073657373696f6e206f74686572776973652e20492e652e2061637469766520657261206d757374390120616c7761797320626520696e20686973746f72792e20492e652e20606163746976655f657261203e2063757272656e745f657261202d20686973746f72795f646570746860206d757374206265302067756172616e746565642e3856616c696461746f72436f756e7401000c753332100000000004a82054686520696465616c206e756d626572206f66207374616b696e67207061727469636970616e74732e544d696e696d756d56616c696461746f72436f756e7401000c7533321000000000044101204d696e696d756d206e756d626572206f66207374616b696e67207061727469636970616e7473206265666f726520656d657267656e637920636f6e646974696f6e732061726520696d706f7365642e34496e76756c6e657261626c65730100445665633c543a3a4163636f756e7449643e04000c590120416e792076616c696461746f72732074686174206d6179206e6576657220626520736c6173686564206f7220666f726369626c79206b69636b65642e20497427732061205665632073696e636520746865792772654d01206561737920746f20696e697469616c697a6520616e642074686520706572666f726d616e636520686974206973206d696e696d616c2028776520657870656374206e6f206d6f7265207468616e20666f7572ac20696e76756c6e657261626c65732920616e64207265737472696374656420746f20746573746e6574732e18426f6e64656400010530543a3a4163636f756e74496430543a3a4163636f756e744964000400040101204d61702066726f6d20616c6c206c6f636b65642022737461736822206163636f756e747320746f2074686520636f6e74726f6c6c6572206163636f756e742e184c656467657200010230543a3a4163636f756e744964a45374616b696e674c65646765723c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e000400044501204d61702066726f6d20616c6c2028756e6c6f636b6564292022636f6e74726f6c6c657222206163636f756e747320746f2074686520696e666f20726567617264696e6720746865207374616b696e672e14506179656501010530543a3a4163636f756e7449647c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e00040004e42057686572652074686520726577617264207061796d656e742073686f756c64206265206d6164652e204b657965642062792073746173682e2856616c696461746f727301010530543a3a4163636f756e7449643856616c696461746f7250726566730008000004450120546865206d61702066726f6d202877616e6e616265292076616c696461746f72207374617368206b657920746f2074686520707265666572656e636573206f6620746861742076616c696461746f722e284e6f6d696e61746f727300010530543a3a4163636f756e744964644e6f6d696e6174696f6e733c543a3a4163636f756e7449643e00040004650120546865206d61702066726f6d206e6f6d696e61746f72207374617368206b657920746f2074686520736574206f66207374617368206b657973206f6620616c6c2076616c696461746f727320746f206e6f6d696e6174652e2843757272656e74457261000020457261496e6465780400105c205468652063757272656e742065726120696e6465782e006501205468697320697320746865206c617465737420706c616e6e6564206572612c20646570656e64696e67206f6e20686f77207468652053657373696f6e2070616c6c657420717565756573207468652076616c696461746f7280207365742c206974206d6967687420626520616374697665206f72206e6f742e24416374697665457261000034416374697665457261496e666f040010d820546865206163746976652065726120696e666f726d6174696f6e2c20697420686f6c647320696e64657820616e642073746172742e0059012054686520616374697665206572612069732074686520657261206265696e672063757272656e746c792072657761726465642e2056616c696461746f7220736574206f66207468697320657261206d757374206265ac20657175616c20746f205b6053657373696f6e496e746572666163653a3a76616c696461746f7273605d2e5445726173537461727453657373696f6e496e64657800010520457261496e6465783053657373696f6e496e646578000400103101205468652073657373696f6e20696e646578206174207768696368207468652065726120737461727420666f7220746865206c6173742060484953544f52595f44455054486020657261732e006101204e6f74653a205468697320747261636b7320746865207374617274696e672073657373696f6e2028692e652e2073657373696f6e20696e646578207768656e20657261207374617274206265696e672061637469766529f020666f7220746865206572617320696e20605b43757272656e74457261202d20484953544f52595f44455054482c2043757272656e744572615d602e2c457261735374616b65727301020520457261496e64657830543a3a4163636f756e744964904578706f737572653c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e050c0000001878204578706f73757265206f662076616c696461746f72206174206572612e0061012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00a82049732069742072656d6f7665642061667465722060484953544f52595f44455054486020657261732e4101204966207374616b657273206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e20656d707479206578706f737572652069732072657475726e65642e48457261735374616b657273436c697070656401020520457261496e64657830543a3a4163636f756e744964904578706f737572653c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e050c0000002c9820436c6970706564204578706f73757265206f662076616c696461746f72206174206572612e00590120546869732069732073696d696c617220746f205b60457261735374616b657273605d20627574206e756d626572206f66206e6f6d696e61746f7273206578706f736564206973207265647563656420746f20746865dc2060543a3a4d61784e6f6d696e61746f72526577617264656450657256616c696461746f72602062696767657374207374616b6572732e1d0120284e6f74653a20746865206669656c642060746f74616c6020616e6420606f776e60206f6620746865206578706f737572652072656d61696e7320756e6368616e676564292ef42054686973206973207573656420746f206c696d69742074686520692f6f20636f737420666f7220746865206e6f6d696e61746f72207061796f75742e005d012054686973206973206b657965642066697374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00a82049732069742072656d6f7665642061667465722060484953544f52595f44455054486020657261732e4101204966207374616b657273206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e20656d707479206578706f737572652069732072657475726e65642e484572617356616c696461746f72507265667301020520457261496e64657830543a3a4163636f756e7449643856616c696461746f725072656673050800001411012053696d696c617220746f2060457261735374616b657273602c207468697320686f6c64732074686520707265666572656e636573206f662076616c696461746f72732e0061012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00a82049732069742072656d6f7665642061667465722060484953544f52595f44455054486020657261732e4c4572617356616c696461746f7252657761726400010520457261496e6465783042616c616e63654f663c543e0004000c09012054686520746f74616c2076616c696461746f7220657261207061796f757420666f7220746865206c6173742060484953544f52595f44455054486020657261732e0021012045726173207468617420686176656e27742066696e697368656420796574206f7220686173206265656e2072656d6f76656420646f65736e27742068617665207265776172642e4045726173526577617264506f696e747301010520457261496e64657874457261526577617264506f696e74733c543a3a4163636f756e7449643e0014000000000008ac205265776172647320666f7220746865206c6173742060484953544f52595f44455054486020657261732e250120496620726577617264206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e2030207265776172642069732072657475726e65642e3845726173546f74616c5374616b6501010520457261496e6465783042616c616e63654f663c543e00400000000000000000000000000000000008ec2054686520746f74616c20616d6f756e74207374616b656420666f7220746865206c6173742060484953544f52595f44455054486020657261732e1d0120496620746f74616c206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e2030207374616b652069732072657475726e65642e20466f72636545726101001c466f7263696e6704000454204d6f6465206f662065726120666f7263696e672e4c536c6173685265776172644672616374696f6e01001c50657262696c6c10000000000cf8205468652070657263656e74616765206f662074686520736c617368207468617420697320646973747269627574656420746f207265706f72746572732e00e4205468652072657374206f662074686520736c61736865642076616c75652069732068616e646c6564206279207468652060536c617368602e4c43616e63656c6564536c6173685061796f757401003042616c616e63654f663c543e40000000000000000000000000000000000815012054686520616d6f756e74206f662063757272656e637920676976656e20746f207265706f7274657273206f66206120736c617368206576656e7420776869636820776173ec2063616e63656c65642062792065787472616f7264696e6172792063697263756d7374616e6365732028652e672e20676f7665726e616e6365292e40556e6170706c696564536c617368657301010520457261496e646578bc5665633c556e6170706c696564536c6173683c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e3e00040004c420416c6c20756e6170706c69656420736c61736865732074686174206172652071756575656420666f72206c617465722e28426f6e646564457261730100745665633c28457261496e6465782c2053657373696f6e496e646578293e04001025012041206d617070696e672066726f6d207374696c6c2d626f6e646564206572617320746f207468652066697273742073657373696f6e20696e646578206f662074686174206572612e00c8204d75737420636f6e7461696e7320696e666f726d6174696f6e20666f72206572617320666f72207468652072616e67653abc20605b6163746976655f657261202d20626f756e64696e675f6475726174696f6e3b206163746976655f6572615d604c56616c696461746f72536c617368496e45726100020520457261496e64657830543a3a4163636f756e7449645c2850657262696c6c2c2042616c616e63654f663c543e2905040008450120416c6c20736c617368696e67206576656e7473206f6e2076616c696461746f72732c206d61707065642062792065726120746f20746865206869676865737420736c6173682070726f706f7274696f6e7020616e6420736c6173682076616c7565206f6620746865206572612e4c4e6f6d696e61746f72536c617368496e45726100020520457261496e64657830543a3a4163636f756e7449643042616c616e63654f663c543e05040004610120416c6c20736c617368696e67206576656e7473206f6e206e6f6d696e61746f72732c206d61707065642062792065726120746f20746865206869676865737420736c6173682076616c7565206f6620746865206572612e34536c617368696e675370616e7300010530543a3a4163636f756e7449645c736c617368696e673a3a536c617368696e675370616e73000400048c20536c617368696e67207370616e7320666f72207374617368206163636f756e74732e245370616e536c6173680101058c28543a3a4163636f756e7449642c20736c617368696e673a3a5370616e496e6465782988736c617368696e673a3a5370616e5265636f72643c42616c616e63654f663c543e3e00800000000000000000000000000000000000000000000000000000000000000000083d01205265636f72647320696e666f726d6174696f6e2061626f757420746865206d6178696d756d20736c617368206f6620612073746173682077697468696e206120736c617368696e67207370616e2cb82061732077656c6c20617320686f77206d7563682072657761726420686173206265656e2070616964206f75742e584561726c69657374556e6170706c696564536c617368000020457261496e646578040004fc20546865206561726c696573742065726120666f72207768696368207765206861766520612070656e64696e672c20756e6170706c69656420736c6173682e5443757272656e74506c616e6e656453657373696f6e01003053657373696f6e496e64657810000000000ce820546865206c61737420706c616e6e65642073657373696f6e207363686564756c6564206279207468652073657373696f6e2070616c6c65742e0031012054686973206973206261736963616c6c7920696e2073796e632077697468207468652063616c6c20746f205b6053657373696f6e4d616e616765723a3a6e65775f73657373696f6e605d2e3853746f7261676556657273696f6e01002052656c6561736573040510cc2054727565206966206e6574776f726b20686173206265656e20757067726164656420746f20746869732076657273696f6e2e7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e00a020546869732069732073657420746f2076362e302e3020666f72206e6577206e6574776f726b732e015c10626f6e640c28636f6e74726f6c6c65728c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c756554436f6d706163743c42616c616e63654f663c543e3e1470617965657c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e5865012054616b6520746865206f726967696e206163636f756e74206173206120737461736820616e64206c6f636b207570206076616c756560206f66206974732062616c616e63652e2060636f6e74726f6c6c6572602077696c6c8420626520746865206163636f756e74207468617420636f6e74726f6c732069742e003101206076616c756560206d757374206265206d6f7265207468616e2074686520606d696e696d756d5f62616c616e636560207370656369666965642062792060543a3a43757272656e6379602e00250120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20627920746865207374617368206163636f756e742e004020456d6974732060426f6e646564602e002c2023203c7765696768743ed4202d20496e646570656e64656e74206f662074686520617267756d656e74732e204d6f64657261746520636f6d706c65786974792e20202d204f2831292e68202d20546872656520657874726120444220656e74726965732e005101204e4f54453a2054776f206f66207468652073746f726167652077726974657320286053656c663a3a626f6e646564602c206053656c663a3a7061796565602920617265205f6e657665725f20636c65616e6564410120756e6c6573732074686520606f726967696e602066616c6c732062656c6f77205f6578697374656e7469616c206465706f7369745f20616e6420676574732072656d6f76656420617320647573742e4c202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a3101202d20526561643a20426f6e6465642c204c65646765722c205b4f726967696e204163636f756e745d2c2043757272656e74204572612c20486973746f72792044657074682c204c6f636b73e0202d2057726974653a20426f6e6465642c2050617965652c205b4f726967696e204163636f756e745d2c204c6f636b732c204c6564676572302023203c2f7765696768743e28626f6e645f657874726104386d61785f6164646974696f6e616c54436f6d706163743c42616c616e63654f663c543e3e5465012041646420736f6d6520657874726120616d6f756e742074686174206861766520617070656172656420696e207468652073746173682060667265655f62616c616e63656020696e746f207468652062616c616e63652075703420666f72207374616b696e672e00510120557365207468697320696620746865726520617265206164646974696f6e616c2066756e647320696e20796f7572207374617368206163636f756e74207468617420796f75207769736820746f20626f6e642e650120556e6c696b65205b60626f6e64605d206f72205b60756e626f6e64605d20746869732066756e6374696f6e20646f6573206e6f7420696d706f736520616e79206c696d69746174696f6e206f6e2074686520616d6f756e744c20746861742063616e2062652061646465642e00610120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f206279207468652073746173682c206e6f742074686520636f6e74726f6c6c657220616e64f82069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e004020456d6974732060426f6e646564602e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e20202d204f2831292e40202d204f6e6520444220656e7472792e34202d2d2d2d2d2d2d2d2d2d2d2d2c204442205765696768743a1501202d20526561643a2045726120456c656374696f6e205374617475732c20426f6e6465642c204c65646765722c205b4f726967696e204163636f756e745d2c204c6f636b73a4202d2057726974653a205b4f726967696e204163636f756e745d2c204c6f636b732c204c6564676572302023203c2f7765696768743e18756e626f6e64041476616c756554436f6d706163743c42616c616e63654f663c543e3e805501205363686564756c65206120706f7274696f6e206f662074686520737461736820746f20626520756e6c6f636b656420726561647920666f72207472616e73666572206f75742061667465722074686520626f6e64010120706572696f6420656e64732e2049662074686973206c656176657320616e20616d6f756e74206163746976656c7920626f6e646564206c657373207468616e250120543a3a43757272656e63793a3a6d696e696d756d5f62616c616e636528292c207468656e20697420697320696e6372656173656420746f207468652066756c6c20616d6f756e742e004901204f6e63652074686520756e6c6f636b20706572696f6420697320646f6e652c20796f752063616e2063616c6c206077697468647261775f756e626f6e6465646020746f2061637475616c6c79206d6f7665c0207468652066756e6473206f7574206f66206d616e6167656d656e7420726561647920666f72207472616e736665722e003d01204e6f206d6f7265207468616e2061206c696d69746564206e756d626572206f6620756e6c6f636b696e67206368756e6b73202873656520604d41585f554e4c4f434b494e475f4348554e4b5360293d012063616e20636f2d657869737473206174207468652073616d652074696d652e20496e207468617420636173652c205b6043616c6c3a3a77697468647261775f756e626f6e646564605d206e656564fc20746f2062652063616c6c656420666972737420746f2072656d6f766520736f6d65206f6620746865206368756e6b732028696620706f737369626c65292e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e004820456d6974732060556e626f6e646564602e00982053656520616c736f205b6043616c6c3a3a77697468647261775f756e626f6e646564605d2e002c2023203c7765696768743e4101202d20496e646570656e64656e74206f662074686520617267756d656e74732e204c696d697465642062757420706f74656e7469616c6c79206578706c6f697461626c6520636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732e6501202d20456163682063616c6c20287265717569726573207468652072656d61696e646572206f662074686520626f6e6465642062616c616e636520746f2062652061626f766520606d696e696d756d5f62616c616e63656029710120202077696c6c2063617573652061206e657720656e74727920746f20626520696e73657274656420696e746f206120766563746f722028604c65646765722e756e6c6f636b696e676029206b65707420696e2073746f726167652e5101202020546865206f6e6c792077617920746f20636c65616e207468652061666f72656d656e74696f6e65642073746f72616765206974656d20697320616c736f20757365722d636f6e74726f6c6c6564207669615c2020206077697468647261775f756e626f6e646564602e40202d204f6e6520444220656e7472792e2c202d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a1d01202d20526561643a20457261456c656374696f6e5374617475732c204c65646765722c2043757272656e744572612c204c6f636b732c2042616c616e63654f662053746173682ca4202d2057726974653a204c6f636b732c204c65646765722c2042616c616e63654f662053746173682c28203c2f7765696768743e4477697468647261775f756e626f6e64656404486e756d5f736c617368696e675f7370616e730c7533327c2d012052656d6f766520616e7920756e6c6f636b6564206368756e6b732066726f6d207468652060756e6c6f636b696e67602071756575652066726f6d206f7572206d616e6167656d656e742e003501205468697320657373656e7469616c6c7920667265657320757020746861742062616c616e636520746f206265207573656420627920746865207374617368206163636f756e7420746f20646f4c2077686174657665722069742077616e74732e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e004c20456d697473206057697468647261776e602e006c2053656520616c736f205b6043616c6c3a3a756e626f6e64605d2e002c2023203c7765696768743e5501202d20436f756c6420626520646570656e64656e74206f6e2074686520606f726967696e6020617267756d656e7420616e6420686f77206d7563682060756e6c6f636b696e6760206368756e6b732065786973742e45012020497420696d706c6965732060636f6e736f6c69646174655f756e6c6f636b656460207768696368206c6f6f7073206f76657220604c65646765722e756e6c6f636b696e67602c207768696368206973f42020696e6469726563746c7920757365722d636f6e74726f6c6c65642e20536565205b60756e626f6e64605d20666f72206d6f72652064657461696c2e7901202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732c20796574207468652073697a65206f6620776869636820636f756c64206265206c61726765206261736564206f6e20606c6564676572602ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e40202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d090120436f6d706c6578697479204f285329207768657265205320697320746865206e756d626572206f6620736c617368696e67207370616e7320746f2072656d6f766520205570646174653a2501202d2052656164733a20457261456c656374696f6e5374617475732c204c65646765722c2043757272656e74204572612c204c6f636b732c205b4f726967696e204163636f756e745da8202d205772697465733a205b4f726967696e204163636f756e745d2c204c6f636b732c204c656467657218204b696c6c3a4501202d2052656164733a20457261456c656374696f6e5374617475732c204c65646765722c2043757272656e74204572612c20426f6e6465642c20536c617368696e67205370616e732c205b4f726967696e8c2020204163636f756e745d2c204c6f636b732c2042616c616e63654f662073746173685101202d205772697465733a20426f6e6465642c20536c617368696e67205370616e73202869662053203e2030292c204c65646765722c2050617965652c2056616c696461746f72732c204e6f6d696e61746f72732cb02020205b4f726967696e204163636f756e745d2c204c6f636b732c2042616c616e63654f662073746173682e74202d2057726974657320456163683a205370616e536c617368202a20530d01204e4f54453a2057656967687420616e6e6f746174696f6e20697320746865206b696c6c207363656e6172696f2c20776520726566756e64206f74686572776973652e302023203c2f7765696768743e2076616c6964617465041470726566733856616c696461746f72507265667344e8204465636c617265207468652064657369726520746f2076616c696461746520666f7220746865206f726967696e20636f6e74726f6c6c65722e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e30202d2d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a90202d20526561643a2045726120456c656374696f6e205374617475732c204c656467657280202d2057726974653a204e6f6d696e61746f72732c2056616c696461746f7273302023203c2f7765696768743e206e6f6d696e617465041c74617267657473a05665633c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653e4c1101204465636c617265207468652064657369726520746f206e6f6d696e6174652060746172676574736020666f7220746865206f726967696e20636f6e74726f6c6c65722e00510120456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e20546869732063616e206f6e6c792062652063616c6c6564207768656e8c205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743e3101202d20546865207472616e73616374696f6e277320636f6d706c65786974792069732070726f706f7274696f6e616c20746f207468652073697a65206f662060746172676574736020284e2901012077686963682069732063617070656420617420436f6d7061637441737369676e6d656e74733a3a4c494d495420284d41585f4e4f4d494e4154494f4e53292ed8202d20426f74682074686520726561647320616e642077726974657320666f6c6c6f7720612073696d696c6172207061747465726e2e28202d2d2d2d2d2d2d2d2d34205765696768743a204f284e2984207768657265204e20697320746865206e756d626572206f6620746172676574732c204442205765696768743ac8202d2052656164733a2045726120456c656374696f6e205374617475732c204c65646765722c2043757272656e742045726184202d205772697465733a2056616c696461746f72732c204e6f6d696e61746f7273302023203c2f7765696768743e146368696c6c0044c8204465636c617265206e6f2064657369726520746f206569746865722076616c6964617465206f72206e6f6d696e6174652e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e54202d20436f6e7461696e73206f6e6520726561642ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e24202d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a88202d20526561643a20457261456c656374696f6e5374617475732c204c656467657280202d2057726974653a2056616c696461746f72732c204e6f6d696e61746f7273302023203c2f7765696768743e247365745f7061796565041470617965657c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e40b8202852652d2973657420746865207061796d656e742074617267657420666f72206120636f6e74726f6c6c65722e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e28202d2d2d2d2d2d2d2d2d3c202d205765696768743a204f28312934202d204442205765696768743a4c20202020202d20526561643a204c65646765724c20202020202d2057726974653a205061796565302023203c2f7765696768743e387365745f636f6e74726f6c6c65720428636f6e74726f6c6c65728c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263654090202852652d297365742074686520636f6e74726f6c6c6572206f6620612073746173682e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f206279207468652073746173682c206e6f742074686520636f6e74726f6c6c65722e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e2c202d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743af4202d20526561643a20426f6e6465642c204c6564676572204e657720436f6e74726f6c6c65722c204c6564676572204f6c6420436f6e74726f6c6c6572f8202d2057726974653a20426f6e6465642c204c6564676572204e657720436f6e74726f6c6c65722c204c6564676572204f6c6420436f6e74726f6c6c6572302023203c2f7765696768743e4c7365745f76616c696461746f725f636f756e74040c6e657730436f6d706163743c7533323e209420536574732074686520696465616c206e756d626572206f662076616c696461746f72732e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e34205765696768743a204f2831295c2057726974653a2056616c696461746f7220436f756e74302023203c2f7765696768743e60696e6372656173655f76616c696461746f725f636f756e7404286164646974696f6e616c30436f6d706163743c7533323e1cac20496e6372656d656e74732074686520696465616c206e756d626572206f662076616c696461746f72732e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e842053616d65206173205b607365745f76616c696461746f725f636f756e74605d2e302023203c2f7765696768743e547363616c655f76616c696461746f725f636f756e740418666163746f721c50657263656e741cd4205363616c652075702074686520696465616c206e756d626572206f662076616c696461746f7273206279206120666163746f722e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e842053616d65206173205b607365745f76616c696461746f725f636f756e74605d2e302023203c2f7765696768743e34666f7263655f6e6f5f657261730024b020466f72636520746865726520746f206265206e6f206e6577206572617320696e646566696e6974656c792e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e40202d204e6f20617267756d656e74732e3c202d205765696768743a204f28312948202d2057726974653a20466f726365457261302023203c2f7765696768743e34666f7263655f6e65775f65726100284d0120466f72636520746865726520746f2062652061206e6577206572612061742074686520656e64206f6620746865206e6578742073657373696f6e2e20416674657220746869732c2069742077696c6c206265a020726573657420746f206e6f726d616c20286e6f6e2d666f7263656429206265686176696f75722e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e40202d204e6f20617267756d656e74732e3c202d205765696768743a204f28312944202d20577269746520466f726365457261302023203c2f7765696768743e447365745f696e76756c6e657261626c65730434696e76756c6e657261626c6573445665633c543a3a4163636f756e7449643e20cc20536574207468652076616c696461746f72732077686f2063616e6e6f7420626520736c61736865642028696620616e79292e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e1c202d204f2856295c202d2057726974653a20496e76756c6e657261626c6573302023203c2f7765696768743e34666f7263655f756e7374616b650814737461736830543a3a4163636f756e744964486e756d5f736c617368696e675f7370616e730c753332280d0120466f72636520612063757272656e74207374616b657220746f206265636f6d6520636f6d706c6574656c7920756e7374616b65642c20696d6d6564696174656c792e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743eec204f285329207768657265205320697320746865206e756d626572206f6620736c617368696e67207370616e7320746f2062652072656d6f766564b82052656164733a20426f6e6465642c20536c617368696e67205370616e732c204163636f756e742c204c6f636b738501205772697465733a20426f6e6465642c20536c617368696e67205370616e73202869662053203e2030292c204c65646765722c2050617965652c2056616c696461746f72732c204e6f6d696e61746f72732c204163636f756e742c204c6f636b736c2057726974657320456163683a205370616e536c617368202a2053302023203c2f7765696768743e50666f7263655f6e65775f6572615f616c776179730020050120466f72636520746865726520746f2062652061206e6577206572612061742074686520656e64206f662073657373696f6e7320696e646566696e6974656c792e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e3c202d205765696768743a204f28312948202d2057726974653a20466f726365457261302023203c2f7765696768743e5463616e63656c5f64656665727265645f736c617368080c65726120457261496e64657834736c6173685f696e6469636573205665633c7533323e34982043616e63656c20656e6163746d656e74206f66206120646566657272656420736c6173682e00b42043616e2062652063616c6c6564206279207468652060543a3a536c61736843616e63656c4f726967696e602e00050120506172616d65746572733a2065726120616e6420696e6469636573206f662074686520736c617368657320666f7220746861742065726120746f206b696c6c2e002c2023203c7765696768743e5420436f6d706c65786974793a204f2855202b205329b82077697468205520756e6170706c69656420736c6173686573207765696768746564207769746820553d31303030d420616e64205320697320746865206e756d626572206f6620736c61736820696e646963657320746f2062652063616e63656c65642e68202d20526561643a20556e6170706c69656420536c61736865736c202d2057726974653a20556e6170706c69656420536c6173686573302023203c2f7765696768743e387061796f75745f7374616b657273083c76616c696461746f725f737461736830543a3a4163636f756e7449640c65726120457261496e64657870110120506179206f757420616c6c20746865207374616b65727320626568696e6420612073696e676c652076616c696461746f7220666f7220612073696e676c65206572612e004d01202d206076616c696461746f725f73746173686020697320746865207374617368206163636f756e74206f66207468652076616c696461746f722e205468656972206e6f6d696e61746f72732c20757020746f290120202060543a3a4d61784e6f6d696e61746f72526577617264656450657256616c696461746f72602c2077696c6c20616c736f207265636569766520746865697220726577617264732e3501202d206065726160206d617920626520616e7920657261206265747765656e20605b63757272656e745f657261202d20686973746f72795f64657074683b2063757272656e745f6572615d602e00590120546865206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e20416e79206163636f756e742063616e2063616c6c20746869732066756e6374696f6e2c206576656e20696678206974206973206e6f74206f6e65206f6620746865207374616b6572732e00010120546869732063616e206f6e6c792062652063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743e0101202d2054696d6520636f6d706c65786974793a206174206d6f7374204f284d61784e6f6d696e61746f72526577617264656450657256616c696461746f72292ec4202d20436f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e64207772697465732e30202d2d2d2d2d2d2d2d2d2d2d1d01204e20697320746865204e756d626572206f66207061796f75747320666f72207468652076616c696461746f722028696e636c7564696e67207468652076616c696461746f722920205765696768743a88202d205265776172642044657374696e6174696f6e205374616b65643a204f284e29c4202d205265776172642044657374696e6174696f6e20436f6e74726f6c6c657220284372656174696e67293a204f284e292c204442205765696768743a2901202d20526561643a20457261456c656374696f6e5374617475732c2043757272656e744572612c20486973746f727944657074682c204572617356616c696461746f725265776172642c2d01202020202020202020457261735374616b657273436c69707065642c2045726173526577617264506f696e74732c204572617356616c696461746f725072656673202838206974656d73291101202d205265616420456163683a20426f6e6465642c204c65646765722c2050617965652c204c6f636b732c2053797374656d204163636f756e74202835206974656d7329d8202d20577269746520456163683a2053797374656d204163636f756e742c204c6f636b732c204c6564676572202833206974656d73290051012020204e4f54453a20776569676874732061726520617373756d696e672074686174207061796f75747320617265206d61646520746f20616c697665207374617368206163636f756e7420285374616b6564292e5901202020506179696e67206576656e2061206465616420636f6e74726f6c6c65722069732063686561706572207765696768742d776973652e20576520646f6e277420646f20616e7920726566756e647320686572652e302023203c2f7765696768743e187265626f6e64041476616c756554436f6d706163743c42616c616e63654f663c543e3e38e0205265626f6e64206120706f7274696f6e206f6620746865207374617368207363686564756c656420746f20626520756e6c6f636b65642e00550120546865206469737061746368206f726967696e206d757374206265207369676e65642062792074686520636f6e74726f6c6c65722c20616e642069742063616e206265206f6e6c792063616c6c6564207768656e8c205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743ed4202d2054696d6520636f6d706c65786974793a204f284c292c207768657265204c20697320756e6c6f636b696e67206368756e6b7394202d20426f756e64656420627920604d41585f554e4c4f434b494e475f4348554e4b53602ef4202d2053746f72616765206368616e6765733a2043616e277420696e6372656173652073746f726167652c206f6e6c792064656372656173652069742e40202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743a010120202020202d2052656164733a20457261456c656374696f6e5374617475732c204c65646765722c204c6f636b732c205b4f726967696e204163636f756e745db820202020202d205772697465733a205b4f726967696e204163636f756e745d2c204c6f636b732c204c6564676572302023203c2f7765696768743e447365745f686973746f72795f646570746808446e65775f686973746f72795f646570746844436f6d706163743c457261496e6465783e485f6572615f6974656d735f64656c6574656430436f6d706163743c7533323e543101205365742060486973746f72794465707468602076616c75652e20546869732066756e6374696f6e2077696c6c2064656c65746520616e7920686973746f727920696e666f726d6174696f6e80207768656e2060486973746f727944657074686020697320726564756365642e003020506172616d65746572733a1101202d20606e65775f686973746f72795f6465707468603a20546865206e657720686973746f727920646570746820796f7520776f756c64206c696b6520746f207365742e4901202d20606572615f6974656d735f64656c65746564603a20546865206e756d626572206f66206974656d7320746861742077696c6c2062652064656c6574656420627920746869732064697370617463682e450120202020546869732073686f756c64207265706f727420616c6c207468652073746f72616765206974656d7320746861742077696c6c2062652064656c6574656420627920636c656172696e67206f6c6445012020202065726120686973746f72792e204e656564656420746f207265706f727420616e2061636375726174652077656967687420666f72207468652064697370617463682e2054727573746564206279a02020202060526f6f746020746f207265706f727420616e206163637572617465206e756d6265722e0054204f726967696e206d75737420626520726f6f742e002c2023203c7765696768743ee0202d20453a204e756d626572206f6620686973746f7279206465707468732072656d6f7665642c20692e652e203130202d3e2037203d20333c202d205765696768743a204f28452934202d204442205765696768743aa020202020202d2052656164733a2043757272656e74204572612c20486973746f72792044657074687020202020202d205772697465733a20486973746f7279204465707468310120202020202d20436c6561722050726566697820456163683a20457261205374616b6572732c204572615374616b657273436c69707065642c204572617356616c696461746f725072656673810120202020202d2057726974657320456163683a204572617356616c696461746f725265776172642c2045726173526577617264506f696e74732c2045726173546f74616c5374616b652c2045726173537461727453657373696f6e496e646578302023203c2f7765696768743e28726561705f73746173680814737461736830543a3a4163636f756e744964486e756d5f736c617368696e675f7370616e730c7533323c61012052656d6f766520616c6c20646174612073747275637475726520636f6e6365726e696e672061207374616b65722f7374617368206f6e6365206974732062616c616e636520697320617420746865206d696e696d756d2e6101205468697320697320657373656e7469616c6c79206571756976616c656e7420746f206077697468647261775f756e626f6e64656460206578636570742069742063616e2062652063616c6c656420627920616e796f6e65f820616e6420746865207461726765742060737461736860206d7573742068617665206e6f2066756e6473206c656674206265796f6e64207468652045442e009020546869732063616e2062652063616c6c65642066726f6d20616e79206f726967696e2e000101202d20607374617368603a20546865207374617368206163636f756e7420746f20726561702e204974732062616c616e6365206d757374206265207a65726f2e002c2023203c7765696768743e250120436f6d706c65786974793a204f285329207768657265205320697320746865206e756d626572206f6620736c617368696e67207370616e73206f6e20746865206163636f756e742e2c204442205765696768743ad8202d2052656164733a205374617368204163636f756e742c20426f6e6465642c20536c617368696e67205370616e732c204c6f636b73a501202d205772697465733a20426f6e6465642c20536c617368696e67205370616e73202869662053203e2030292c204c65646765722c2050617965652c2056616c696461746f72732c204e6f6d696e61746f72732c205374617368204163636f756e742c204c6f636b7374202d2057726974657320456163683a205370616e536c617368202a2053302023203c2f7765696768743e106b69636b040c77686fa05665633c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653e34e42052656d6f76652074686520676976656e206e6f6d696e6174696f6e732066726f6d207468652063616c6c696e672076616c696461746f722e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e490120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e2054686520636f6e74726f6c6c657298206163636f756e742073686f756c6420726570726573656e7420612076616c696461746f722e005101202d206077686f603a2041206c697374206f66206e6f6d696e61746f72207374617368206163636f756e74732077686f20617265206e6f6d696e6174696e6720746869732076616c696461746f72207768696368c420202073686f756c64206e6f206c6f6e676572206265206e6f6d696e6174696e6720746869732076616c696461746f722e005901204e6f74653a204d616b696e6720746869732063616c6c206f6e6c79206d616b65732073656e736520696620796f7520666972737420736574207468652076616c696461746f7220707265666572656e63657320746f7c20626c6f636b20616e792066757274686572206e6f6d696e6174696f6e732e0124244572615061796f75740c20457261496e6465781c42616c616e63651c42616c616e63650c59012054686520657261207061796f757420686173206265656e207365743b207468652066697273742062616c616e6365206973207468652076616c696461746f722d7061796f75743b20746865207365636f6e64206973c4207468652072656d61696e6465722066726f6d20746865206d6178696d756d20616d6f756e74206f66207265776172642eac205c5b6572615f696e6465782c2076616c696461746f725f7061796f75742c2072656d61696e6465725c5d1852657761726408244163636f756e7449641c42616c616e636504fc20546865207374616b657220686173206265656e207265776172646564206279207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d14536c61736808244163636f756e7449641c42616c616e6365082501204f6e652076616c696461746f722028616e6420697473206e6f6d696e61746f72732920686173206265656e20736c61736865642062792074686520676976656e20616d6f756e742e58205c5b76616c696461746f722c20616d6f756e745c5d684f6c64536c617368696e675265706f7274446973636172646564043053657373696f6e496e646578081d0120416e206f6c6420736c617368696e67207265706f72742066726f6d2061207072696f72206572612077617320646973636172646564206265636175736520697420636f756c6490206e6f742062652070726f6365737365642e205c5b73657373696f6e5f696e6465785c5d3c5374616b696e67456c656374696f6e0004882041206e657720736574206f66207374616b6572732077617320656c65637465642e18426f6e64656408244163636f756e7449641c42616c616e636510d420416e206163636f756e742068617320626f6e646564207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d005101204e4f54453a2054686973206576656e74206973206f6e6c7920656d6974746564207768656e2066756e64732061726520626f6e64656420766961206120646973706174636861626c652e204e6f7461626c792c25012069742077696c6c206e6f7420626520656d697474656420666f72207374616b696e672072657761726473207768656e20746865792061726520616464656420746f207374616b652e20556e626f6e64656408244163636f756e7449641c42616c616e636504dc20416e206163636f756e742068617320756e626f6e646564207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d2457697468647261776e08244163636f756e7449641c42616c616e6365085d0120416e206163636f756e74206861732063616c6c6564206077697468647261775f756e626f6e6465646020616e642072656d6f76656420756e626f6e64696e67206368756e6b7320776f727468206042616c616e636560b02066726f6d2074686520756e6c6f636b696e672071756575652e205c5b73746173682c20616d6f756e745c5d184b69636b656408244163636f756e744964244163636f756e744964040d012041206e6f6d696e61746f7220686173206265656e206b69636b65642066726f6d20612076616c696461746f722e205c5b6e6f6d696e61746f722c2073746173685c5d143853657373696f6e735065724572613053657373696f6e496e64657810060000000470204e756d626572206f662073657373696f6e7320706572206572612e3c426f6e64696e674475726174696f6e20457261496e646578101c00000004e4204e756d626572206f6620657261732074686174207374616b65642066756e6473206d7573742072656d61696e20626f6e64656420666f722e48536c61736844656665724475726174696f6e20457261496e646578101b000000140101204e756d626572206f662065726173207468617420736c6173686573206172652064656665727265642062792c20616674657220636f6d7075746174696f6e2e00bc20546869732073686f756c64206265206c657373207468616e2074686520626f6e64696e67206475726174696f6e2e2d012053657420746f203020696620736c61736865732073686f756c64206265206170706c69656420696d6d6564696174656c792c20776974686f7574206f70706f7274756e69747920666f723820696e74657276656e74696f6e2e804d61784e6f6d696e61746f72526577617264656450657256616c696461746f720c753332100001000010f820546865206d6178696d756d206e756d626572206f66206e6f6d696e61746f727320726577617264656420666f7220656163682076616c696461746f722e00690120466f7220656163682076616c696461746f72206f6e6c79207468652060244d61784e6f6d696e61746f72526577617264656450657256616c696461746f72602062696767657374207374616b6572732063616e20636c61696d2101207468656972207265776172642e2054686973207573656420746f206c696d69742074686520692f6f20636f737420666f7220746865206e6f6d696e61746f72207061796f75742e384d61784e6f6d696e6174696f6e730c753332101000000004b4204d6178696d756d206e756d626572206f66206e6f6d696e6174696f6e7320706572206e6f6d696e61746f722e50344e6f74436f6e74726f6c6c65720468204e6f74206120636f6e74726f6c6c6572206163636f756e742e204e6f7453746173680454204e6f742061207374617368206163636f756e742e34416c7265616479426f6e646564046420537461736820697320616c726561647920626f6e6465642e34416c7265616479506169726564047820436f6e74726f6c6c657220697320616c7265616479207061697265642e30456d70747954617267657473046420546172676574732063616e6e6f7420626520656d7074792e384475706c6963617465496e6465780444204475706c696361746520696e6465782e44496e76616c6964536c617368496e646578048820536c617368207265636f726420696e646578206f7574206f6620626f756e64732e44496e73756666696369656e7456616c756504cc2043616e206e6f7420626f6e6420776974682076616c7565206c657373207468616e206d696e696d756d2062616c616e63652e304e6f4d6f72654368756e6b7304942043616e206e6f74207363686564756c65206d6f726520756e6c6f636b206368756e6b732e344e6f556e6c6f636b4368756e6b04a42043616e206e6f74207265626f6e6420776974686f757420756e6c6f636b696e67206368756e6b732e3046756e64656454617267657404cc20417474656d7074696e6720746f2074617267657420612073746173682074686174207374696c6c206861732066756e64732e48496e76616c6964457261546f526577617264045c20496e76616c69642065726120746f207265776172642e68496e76616c69644e756d6265724f664e6f6d696e6174696f6e73047c20496e76616c6964206e756d626572206f66206e6f6d696e6174696f6e732e484e6f74536f72746564416e64556e697175650484204974656d7320617265206e6f7420736f7274656420616e6420756e697175652e38416c7265616479436c61696d6564040d01205265776172647320666f72207468697320657261206861766520616c7265616479206265656e20636c61696d656420666f7220746869732076616c696461746f722e54496e636f7272656374486973746f7279446570746804c420496e636f72726563742070726576696f757320686973746f727920646570746820696e7075742070726f76696465642e58496e636f7272656374536c617368696e675370616e7304b420496e636f7272656374206e756d626572206f6620736c617368696e67207370616e732070726f76696465642e204261645374617465043d0120496e7465726e616c20737461746520686173206265636f6d6520736f6d65686f7720636f7272757074656420616e6420746865206f7065726174696f6e2063616e6e6f7420636f6e74696e75652e38546f6f4d616e7954617267657473049820546f6f206d616e79206e6f6d696e6174696f6e207461726765747320737570706c6965642e244261645461726765740441012041206e6f6d696e6174696f6e207461726765742077617320737570706c69656420746861742077617320626c6f636b6564206f72206f7468657277697365206e6f7420612076616c696461746f722e07204f6666656e63657301204f6666656e636573101c5265706f727473000105345265706f727449644f663c543ed04f6666656e636544657461696c733c543a3a4163636f756e7449642c20543a3a4964656e74696669636174696f6e5475706c653e00040004490120546865207072696d61727920737472756374757265207468617420686f6c647320616c6c206f6666656e6365207265636f726473206b65796564206279207265706f7274206964656e746966696572732e4044656665727265644f6666656e6365730100645665633c44656665727265644f6666656e63654f663c543e3e0400086501204465666572726564207265706f72747320746861742068617665206265656e2072656a656374656420627920746865206f6666656e63652068616e646c657220616e64206e65656420746f206265207375626d6974746564442061742061206c617465722074696d652e58436f6e63757272656e745265706f727473496e646578010205104b696e64384f706171756554696d65536c6f74485665633c5265706f727449644f663c543e3e050400042901204120766563746f72206f66207265706f727473206f66207468652073616d65206b696e6420746861742068617070656e6564206174207468652073616d652074696d6520736c6f742e485265706f72747342794b696e64496e646578010105104b696e641c5665633c75383e00040018110120456e756d65726174657320616c6c207265706f727473206f662061206b696e6420616c6f6e672077697468207468652074696d6520746865792068617070656e65642e00bc20416c6c207265706f7274732061726520736f72746564206279207468652074696d65206f66206f6666656e63652e004901204e6f74652074686174207468652061637475616c2074797065206f662074686973206d617070696e6720697320605665633c75383e602c207468697320697320626563617573652076616c756573206f66690120646966666572656e7420747970657320617265206e6f7420737570706f7274656420617420746865206d6f6d656e7420736f2077652061726520646f696e6720746865206d616e75616c2073657269616c697a6174696f6e2e010001041c4f6666656e63650c104b696e64384f706171756554696d65536c6f7410626f6f6c10550120546865726520697320616e206f6666656e6365207265706f72746564206f662074686520676976656e20606b696e64602068617070656e656420617420746865206073657373696f6e5f696e6465786020616e644d0120286b696e642d7370656369666963292074696d6520736c6f742e2054686973206576656e74206973206e6f74206465706f736974656420666f72206475706c696361746520736c61736865732e206c617374190120656c656d656e7420696e64696361746573206f6620746865206f6666656e636520776173206170706c69656420287472756529206f7220717565756564202866616c73652974205c5b6b696e642c2074696d65736c6f742c206170706c6965645c5d2e00000828486973746f726963616c0000000000211c53657373696f6e011c53657373696f6e1c2856616c696461746f727301004c5665633c543a3a56616c696461746f7249643e0400047c205468652063757272656e7420736574206f662076616c696461746f72732e3043757272656e74496e64657801003053657373696f6e496e646578100000000004782043757272656e7420696e646578206f66207468652073657373696f6e2e345175657565644368616e676564010010626f6f6c040008390120547275652069662074686520756e6465726c79696e672065636f6e6f6d6963206964656e746974696573206f7220776569676874696e6720626568696e64207468652076616c696461746f7273a420686173206368616e67656420696e20746865207175657565642076616c696461746f72207365742e285175657565644b6579730100785665633c28543a3a56616c696461746f7249642c20543a3a4b657973293e0400083d012054686520717565756564206b65797320666f7220746865206e6578742073657373696f6e2e205768656e20746865206e6578742073657373696f6e20626567696e732c207468657365206b657973e02077696c6c206265207573656420746f2064657465726d696e65207468652076616c696461746f7227732073657373696f6e206b6579732e4844697361626c656456616c696461746f72730100205665633c7533323e04000c8020496e6469636573206f662064697361626c65642076616c696461746f72732e003501205468652073657420697320636c6561726564207768656e20606f6e5f73657373696f6e5f656e64696e67602072657475726e732061206e657720736574206f66206964656e7469746965732e204e6578744b65797300010538543a3a56616c696461746f7249641c543a3a4b657973000400049c20546865206e6578742073657373696f6e206b65797320666f7220612076616c696461746f722e204b65794f776e657200010550284b65795479706549642c205665633c75383e2938543a3a56616c696461746f72496400040004090120546865206f776e6572206f662061206b65792e20546865206b65792069732074686520604b657954797065496460202b2074686520656e636f646564206b65792e0108207365745f6b65797308106b6579731c543a3a4b6579731470726f6f661c5665633c75383e38e82053657473207468652073657373696f6e206b6579287329206f66207468652066756e6374696f6e2063616c6c657220746f20606b657973602e210120416c6c6f777320616e206163636f756e7420746f20736574206974732073657373696f6e206b6579207072696f7220746f206265636f6d696e6720612076616c696461746f722ec4205468697320646f65736e27742074616b652065666665637420756e74696c20746865206e6578742073657373696f6e2e00d420546865206469737061746368206f726967696e206f6620746869732066756e6374696f6e206d757374206265207369676e65642e002c2023203c7765696768743e54202d20436f6d706c65786974793a20604f28312960590120202041637475616c20636f737420646570656e6473206f6e20746865206e756d626572206f66206c656e677468206f662060543a3a4b6579733a3a6b65795f6964732829602077686963682069732066697865642ef0202d20446252656164733a20606f726967696e206163636f756e74602c2060543a3a56616c696461746f7249644f66602c20604e6578744b65797360a4202d2044625772697465733a20606f726967696e206163636f756e74602c20604e6578744b6579736084202d204462526561647320706572206b65792069643a20604b65794f776e65726088202d20446257726974657320706572206b65792069643a20604b65794f776e657260302023203c2f7765696768743e2870757267655f6b6579730030cc2052656d6f76657320616e792073657373696f6e206b6579287329206f66207468652066756e6374696f6e2063616c6c65722ec4205468697320646f65736e27742074616b652065666665637420756e74696c20746865206e6578742073657373696f6e2e00d420546865206469737061746368206f726967696e206f6620746869732066756e6374696f6e206d757374206265207369676e65642e002c2023203c7765696768743eb4202d20436f6d706c65786974793a20604f2831296020696e206e756d626572206f66206b65792074797065732e590120202041637475616c20636f737420646570656e6473206f6e20746865206e756d626572206f66206c656e677468206f662060543a3a4b6579733a3a6b65795f6964732829602077686963682069732066697865642ef0202d20446252656164733a2060543a3a56616c696461746f7249644f66602c20604e6578744b657973602c20606f726967696e206163636f756e7460a4202d2044625772697465733a20604e6578744b657973602c20606f726967696e206163636f756e74608c202d20446257726974657320706572206b65792069643a20604b65794f776e64657260302023203c2f7765696768743e0104284e657753657373696f6e043053657373696f6e496e646578086501204e65772073657373696f6e206861732068617070656e65642e204e6f746520746861742074686520617267756d656e7420697320746865205c5b73657373696f6e5f696e6465785c5d2c206e6f742074686520626c6f636b88206e756d626572206173207468652074797065206d6967687420737567676573742e001430496e76616c696450726f6f66046420496e76616c6964206f776e6572736869702070726f6f662e5c4e6f4173736f63696174656456616c696461746f72496404a0204e6f206173736f6369617465642076616c696461746f7220494420666f72206163636f756e742e344475706c6963617465644b657904682052656769737465726564206475706c6963617465206b65792e184e6f4b65797304a8204e6f206b65797320617265206173736f63696174656420776974682074686973206163636f756e742e244e6f4163636f756e74041d01204b65792073657474696e67206163636f756e74206973206e6f74206c6976652c20736f206974277320696d706f737369626c6520746f206173736f6369617465206b6579732e091c4772616e647061013c4772616e64706146696e616c6974791814537461746501006c53746f72656453746174653c543a3a426c6f636b4e756d6265723e04000490205374617465206f66207468652063757272656e7420617574686f72697479207365742e3450656e64696e674368616e676500008c53746f72656450656e64696e674368616e67653c543a3a426c6f636b4e756d6265723e040004c42050656e64696e67206368616e67653a20287369676e616c65642061742c207363686564756c6564206368616e6765292e284e657874466f72636564000038543a3a426c6f636b4e756d626572040004bc206e65787420626c6f636b206e756d6265722077686572652077652063616e20666f7263652061206368616e67652e1c5374616c6c656400008028543a3a426c6f636b4e756d6265722c20543a3a426c6f636b4e756d626572290400049020607472756560206966207765206172652063757272656e746c79207374616c6c65642e3043757272656e7453657449640100145365744964200000000000000000085d0120546865206e756d626572206f66206368616e6765732028626f746820696e207465726d73206f66206b65797320616e6420756e6465726c79696e672065636f6e6f6d696320726573706f6e736962696c697469657329c420696e20746865202273657422206f66204772616e6470612076616c696461746f72732066726f6d2067656e657369732e30536574496453657373696f6e0001051453657449643053657373696f6e496e6465780004001059012041206d617070696e672066726f6d206772616e6470612073657420494420746f2074686520696e646578206f6620746865202a6d6f737420726563656e742a2073657373696f6e20666f722077686963682069747368206d656d62657273207765726520726573706f6e7369626c652e00b82054574f582d4e4f54453a2060536574496460206973206e6f7420756e646572207573657220636f6e74726f6c2e010c4c7265706f72745f65717569766f636174696f6e084865717569766f636174696f6e5f70726f6f66a845717569766f636174696f6e50726f6f663c543a3a486173682c20543a3a426c6f636b4e756d6265723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66100d01205265706f727420766f7465722065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c2076657269667920746865f82065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66fc20616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e6365482077696c6c206265207265706f727465642e707265706f72745f65717569766f636174696f6e5f756e7369676e6564084865717569766f636174696f6e5f70726f6f66a845717569766f636174696f6e50726f6f663c543a3a486173682c20543a3a426c6f636b4e756d6265723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66240d01205265706f727420766f7465722065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c2076657269667920746865f82065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66fc20616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e6365482077696c6c206265207265706f727465642e00110120546869732065787472696e736963206d7573742062652063616c6c656420756e7369676e656420616e642069742069732065787065637465642074686174206f6e6c79190120626c6f636b20617574686f72732077696c6c2063616c6c206974202876616c69646174656420696e206056616c6964617465556e7369676e656460292c206173207375636819012069662074686520626c6f636b20617574686f7220697320646566696e65642069742077696c6c20626520646566696e6564206173207468652065717569766f636174696f6e28207265706f727465722e306e6f74655f7374616c6c6564081464656c617938543a3a426c6f636b4e756d6265726c626573745f66696e616c697a65645f626c6f636b5f6e756d62657238543a3a426c6f636b4e756d6265721c1d01204e6f74652074686174207468652063757272656e7420617574686f7269747920736574206f6620746865204752414e4450412066696e616c69747920676164676574206861732901207374616c6c65642e20546869732077696c6c2074726967676572206120666f7263656420617574686f7269747920736574206368616e67652061742074686520626567696e6e696e672101206f6620746865206e6578742073657373696f6e2c20746f20626520656e6163746564206064656c61796020626c6f636b7320616674657220746861742e205468652064656c617915012073686f756c64206265206869676820656e6f75676820746f20736166656c7920617373756d6520746861742074686520626c6f636b207369676e616c6c696e6720746865290120666f72636564206368616e67652077696c6c206e6f742062652072652d6f726765642028652e672e203130303020626c6f636b73292e20546865204752414e44504120766f7465727329012077696c6c20737461727420746865206e657720617574686f7269747920736574207573696e672074686520676976656e2066696e616c697a656420626c6f636b20617320626173652e5c204f6e6c792063616c6c61626c6520627920726f6f742e010c384e6577417574686f7269746965730434417574686f726974794c69737404d8204e657720617574686f726974792073657420686173206265656e206170706c6965642e205c5b617574686f726974795f7365745c5d1850617573656400049c2043757272656e7420617574686f726974792073657420686173206265656e207061757365642e1c526573756d65640004a02043757272656e7420617574686f726974792073657420686173206265656e20726573756d65642e001c2c50617573654661696c656408090120417474656d707420746f207369676e616c204752414e445041207061757365207768656e2074686520617574686f72697479207365742069736e2774206c697665a8202865697468657220706175736564206f7220616c72656164792070656e64696e67207061757365292e30526573756d654661696c656408150120417474656d707420746f207369676e616c204752414e44504120726573756d65207768656e2074686520617574686f72697479207365742069736e277420706175736564a42028656974686572206c697665206f7220616c72656164792070656e64696e6720726573756d65292e344368616e676550656e64696e6704ec20417474656d707420746f207369676e616c204752414e445041206368616e67652077697468206f6e6520616c72656164792070656e64696e672e1c546f6f536f6f6e04c02043616e6e6f74207369676e616c20666f72636564206368616e676520736f20736f6f6e206166746572206c6173742e60496e76616c69644b65794f776e65727368697050726f6f660435012041206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c696445717569766f636174696f6e50726f6f6604350120416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f7274041901204120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e0b20496d4f6e6c696e650120496d4f6e6c696e6510384865617274626561744166746572010038543a3a426c6f636b4e756d62657210000000002c1d012054686520626c6f636b206e756d6265722061667465722077686963682069742773206f6b20746f2073656e64206865617274626561747320696e207468652063757272656e74242073657373696f6e2e0025012041742074686520626567696e6e696e67206f6620656163682073657373696f6e20776520736574207468697320746f20612076616c756520746861742073686f756c642066616c6c350120726f7567686c7920696e20746865206d6964646c65206f66207468652073657373696f6e206475726174696f6e2e20546865206964656120697320746f206669727374207761697420666f721901207468652076616c696461746f727320746f2070726f64756365206120626c6f636b20696e207468652063757272656e742073657373696f6e2c20736f207468617420746865a820686561727462656174206c61746572206f6e2077696c6c206e6f74206265206e65636573736172792e00390120546869732076616c75652077696c6c206f6e6c79206265207573656420617320612066616c6c6261636b206966207765206661696c20746f2067657420612070726f7065722073657373696f6e2d012070726f677265737320657374696d6174652066726f6d20604e65787453657373696f6e526f746174696f6e602c2061732074686f736520657374696d617465732073686f756c642062650101206d6f7265206163637572617465207468656e207468652076616c75652077652063616c63756c61746520666f7220604865617274626561744166746572602e104b65797301004c5665633c543a3a417574686f7269747949643e040004d0205468652063757272656e7420736574206f66206b6579732074686174206d61792069737375652061206865617274626561742e485265636569766564486561727462656174730002053053657373696f6e496e6465782441757468496e6465781c5665633c75383e05040008f020466f7220656163682073657373696f6e20696e6465782c207765206b6565702061206d617070696e67206f66206041757468496e6465786020746f8020606f6666636861696e3a3a4f70617175654e6574776f726b5374617465602e38417574686f726564426c6f636b730102053053657373696f6e496e6465783856616c696461746f7249643c543e0c75333205100000000008150120466f7220656163682073657373696f6e20696e6465782c207765206b6565702061206d617070696e67206f66206056616c696461746f7249643c543e6020746f20746865c8206e756d626572206f6620626c6f636b7320617574686f7265642062792074686520676976656e20617574686f726974792e0104246865617274626561740824686561727462656174644865617274626561743c543a3a426c6f636b4e756d6265723e285f7369676e6174757265bc3c543a3a417574686f7269747949642061732052756e74696d654170705075626c69633e3a3a5369676e6174757265242c2023203c7765696768743e4101202d20436f6d706c65786974793a20604f284b202b20452960207768657265204b206973206c656e677468206f6620604b6579736020286865617274626561742e76616c696461746f72735f6c656e290101202020616e642045206973206c656e677468206f6620606865617274626561742e6e6574776f726b5f73746174652e65787465726e616c5f61646472657373608c2020202d20604f284b29603a206465636f64696e67206f66206c656e67746820604b60b02020202d20604f284529603a206465636f64696e672f656e636f64696e67206f66206c656e677468206045603d01202d20446252656164733a2070616c6c65745f73657373696f6e206056616c696461746f7273602c2070616c6c65745f73657373696f6e206043757272656e74496e646578602c20604b657973602c5c202020605265636569766564486561727462656174736084202d2044625772697465733a206052656365697665644865617274626561747360302023203c2f7765696768743e010c444865617274626561745265636569766564042c417574686f7269747949640405012041206e657720686561727462656174207761732072656365697665642066726f6d2060417574686f72697479496460205c5b617574686f726974795f69645c5d1c416c6c476f6f640004d42041742074686520656e64206f66207468652073657373696f6e2c206e6f206f6666656e63652077617320636f6d6d69747465642e2c536f6d654f66666c696e6504605665633c4964656e74696669636174696f6e5475706c653e043d012041742074686520656e64206f66207468652073657373696f6e2c206174206c65617374206f6e652076616c696461746f722077617320666f756e6420746f206265205c5b6f66666c696e655c5d2e000828496e76616c69644b65790464204e6f6e206578697374656e74207075626c6963206b65792e4c4475706c6963617465644865617274626561740458204475706c696361746564206865617274626561742e0c48417574686f72697479446973636f766572790001000000000d2444656d6f6372616379012444656d6f6372616379383c5075626c696350726f70436f756e7401002450726f70496e646578100000000004f420546865206e756d626572206f6620287075626c6963292070726f706f73616c7320746861742068617665206265656e206d61646520736f206661722e2c5075626c696350726f707301009c5665633c2850726f70496e6465782c20543a3a486173682c20543a3a4163636f756e744964293e040004210120546865207075626c69632070726f706f73616c732e20556e736f727465642e20546865207365636f6e64206974656d206973207468652070726f706f73616c277320686173682e244465706f7369744f660001052450726f70496e64657884285665633c543a3a4163636f756e7449643e2c2042616c616e63654f663c543e290004000c842054686f73652077686f2068617665206c6f636b65642061206465706f7369742e00d82054574f582d4e4f54453a20536166652c20617320696e6372656173696e6720696e7465676572206b6579732061726520736166652e24507265696d616765730001061c543a3a48617368e8507265696d6167655374617475733c543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e000400086101204d6170206f662068617368657320746f207468652070726f706f73616c20707265696d6167652c20616c6f6e6720776974682077686f207265676973746572656420697420616e64207468656972206465706f7369742ee42054686520626c6f636b206e756d6265722069732074686520626c6f636b20617420776869636820697420776173206465706f73697465642e3c5265666572656e64756d436f756e7401003c5265666572656e64756d496e646578100000000004310120546865206e6578742066726565207265666572656e64756d20696e6465782c20616b6120746865206e756d626572206f66207265666572656e6461207374617274656420736f206661722e344c6f77657374556e62616b656401003c5265666572656e64756d496e646578100000000008250120546865206c6f77657374207265666572656e64756d20696e64657820726570726573656e74696e6720616e20756e62616b6564207265666572656e64756d2e20457175616c20746fdc20605265666572656e64756d436f756e74602069662074686572652069736e2774206120756e62616b6564207265666572656e64756d2e405265666572656e64756d496e666f4f660001053c5265666572656e64756d496e646578d45265666572656e64756d496e666f3c543a3a426c6f636b4e756d6265722c20543a3a486173682c2042616c616e63654f663c543e3e0004000cb420496e666f726d6174696f6e20636f6e6365726e696e6720616e7920676976656e207265666572656e64756d2e0009012054574f582d4e4f54453a205341464520617320696e646578657320617265206e6f7420756e64657220616e2061747461636b6572e280997320636f6e74726f6c2e20566f74696e674f6601010530543a3a4163636f756e744964c8566f74696e673c42616c616e63654f663c543e2c20543a3a4163636f756e7449642c20543a3a426c6f636b4e756d6265723e00d8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000105d0120416c6c20766f74657320666f72206120706172746963756c617220766f7465722e2057652073746f7265207468652062616c616e636520666f7220746865206e756d626572206f6620766f74657320746861742077655d012068617665207265636f726465642e20546865207365636f6e64206974656d2069732074686520746f74616c20616d6f756e74206f662064656c65676174696f6e732c20746861742077696c6c2062652061646465642e00e82054574f582d4e4f54453a205341464520617320604163636f756e7449646073206172652063727970746f2068617368657320616e797761792e144c6f636b7300010530543a3a4163636f756e74496438543a3a426c6f636b4e756d626572000400105d01204163636f756e747320666f7220776869636820746865726520617265206c6f636b7320696e20616374696f6e207768696368206d61792062652072656d6f76656420617420736f6d6520706f696e7420696e207468655101206675747572652e205468652076616c75652069732074686520626c6f636b206e756d62657220617420776869636820746865206c6f636b206578706972657320616e64206d61792062652072656d6f7665642e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e544c6173745461626c656457617345787465726e616c010010626f6f6c0400085901205472756520696620746865206c617374207265666572656e64756d207461626c656420776173207375626d69747465642065787465726e616c6c792e2046616c7365206966206974207761732061207075626c6963282070726f706f73616c2e304e65787445787465726e616c00006028543a3a486173682c20566f74655468726573686f6c6429040010590120546865207265666572656e64756d20746f206265207461626c6564207768656e6576657220697420776f756c642062652076616c696420746f207461626c6520616e2065787465726e616c2070726f706f73616c2e550120546869732068617070656e73207768656e2061207265666572656e64756d206e6565647320746f206265207461626c656420616e64206f6e65206f662074776f20636f6e646974696f6e7320617265206d65743aa4202d20604c6173745461626c656457617345787465726e616c60206973206066616c7365603b206f7268202d20605075626c696350726f70736020697320656d7074792e24426c61636b6c6973740001061c543a3a486173688c28543a3a426c6f636b4e756d6265722c205665633c543a3a4163636f756e7449643e290004000851012041207265636f7264206f662077686f207665746f656420776861742e204d6170732070726f706f73616c206861736820746f206120706f737369626c65206578697374656e7420626c6f636b206e756d626572e82028756e74696c207768656e206974206d6179206e6f742062652072657375626d69747465642920616e642077686f207665746f65642069742e3443616e63656c6c6174696f6e730101061c543a3a4861736810626f6f6c000400042901205265636f7264206f6620616c6c2070726f706f73616c7320746861742068617665206265656e207375626a65637420746f20656d657267656e63792063616e63656c6c6174696f6e2e3853746f7261676556657273696f6e00002052656c656173657304000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e0098204e6577206e6574776f726b732073746172742077697468206c6173742076657273696f6e2e01641c70726f706f7365083470726f706f73616c5f686173681c543a3a486173681476616c756554436f6d706163743c42616c616e63654f663c543e3e2ca02050726f706f736520612073656e73697469766520616374696f6e20746f2062652074616b656e2e00190120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573748420686176652066756e647320746f20636f76657220746865206465706f7369742e00d8202d206070726f706f73616c5f68617368603a205468652068617368206f66207468652070726f706f73616c20707265696d6167652e1901202d206076616c7565603a2054686520616d6f756e74206f66206465706f73697420286d757374206265206174206c6561737420604d696e696d756d4465706f73697460292e004820456d697473206050726f706f736564602e003c205765696768743a20604f28702960187365636f6e64082070726f706f73616c48436f6d706163743c50726f70496e6465783e4c7365636f6e64735f75707065725f626f756e6430436f6d706163743c7533323e28b8205369676e616c732061677265656d656e742077697468206120706172746963756c61722070726f706f73616c2e00050120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e6465721501206d75737420686176652066756e647320746f20636f76657220746865206465706f7369742c20657175616c20746f20746865206f726967696e616c206465706f7369742e00cc202d206070726f706f73616c603a2054686520696e646578206f66207468652070726f706f73616c20746f207365636f6e642e4501202d20607365636f6e64735f75707065725f626f756e64603a20616e20757070657220626f756e64206f6e207468652063757272656e74206e756d626572206f66207365636f6e6473206f6e2074686973290120202070726f706f73616c2e2045787472696e736963206973207765696768746564206163636f7264696e6720746f20746869732076616c75652077697468206e6f20726566756e642e002101205765696768743a20604f28532960207768657265205320697320746865206e756d626572206f66207365636f6e647320612070726f706f73616c20616c7265616479206861732e10766f746508247265665f696e64657860436f6d706163743c5265666572656e64756d496e6465783e10766f7465644163636f756e74566f74653c42616c616e63654f663c543e3e24350120566f746520696e2061207265666572656e64756d2e2049662060766f74652e69735f6179652829602c2074686520766f746520697320746f20656e616374207468652070726f706f73616c3bbc206f7468657277697365206974206973206120766f746520746f206b65657020746865207374617475732071756f2e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00e0202d20607265665f696e646578603a2054686520696e646578206f6620746865207265666572656e64756d20746f20766f746520666f722e88202d2060766f7465603a2054686520766f746520636f6e66696775726174696f6e2e003101205765696768743a20604f28522960207768657265205220697320746865206e756d626572206f66207265666572656e64756d732074686520766f7465722068617320766f746564206f6e2e40656d657267656e63795f63616e63656c04247265665f696e6465783c5265666572656e64756d496e646578205101205363686564756c6520616e20656d657267656e63792063616e63656c6c6174696f6e206f662061207265666572656e64756d2e2043616e6e6f742068617070656e20747769636520746f207468652073616d6530207265666572656e64756d2e00fc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265206043616e63656c6c6174696f6e4f726967696e602e00d4202d607265665f696e646578603a2054686520696e646578206f6620746865207265666572656e64756d20746f2063616e63656c2e0040205765696768743a20604f283129602e4065787465726e616c5f70726f706f7365043470726f706f73616c5f686173681c543a3a48617368243101205363686564756c652061207265666572656e64756d20746f206265207461626c6564206f6e6365206974206973206c6567616c20746f207363686564756c6520616e2065787465726e616c30207265666572656e64756d2e00ec20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265206045787465726e616c4f726967696e602e00d8202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f66207468652070726f706f73616c2e001901205765696768743a20604f2856296020776974682056206e756d626572206f66207665746f65727320696e2074686520626c61636b6c697374206f662070726f706f73616c2ebc2020204465636f64696e6720766563206f66206c656e67746820562e2043686172676564206173206d6178696d756d6465787465726e616c5f70726f706f73655f6d616a6f72697479043470726f706f73616c5f686173681c543a3a486173682c5901205363686564756c652061206d616a6f726974792d63617272696573207265666572656e64756d20746f206265207461626c6564206e657874206f6e6365206974206973206c6567616c20746f207363686564756c656020616e2065787465726e616c207265666572656e64756d2e00f020546865206469737061746368206f6620746869732063616c6c206d757374206265206045787465726e616c4d616a6f726974794f726967696e602e00d8202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f66207468652070726f706f73616c2e004d0120556e6c696b65206065787465726e616c5f70726f706f7365602c20626c61636b6c697374696e6720686173206e6f20656666656374206f6e207468697320616e64206974206d6179207265706c61636520619c207072652d7363686564756c6564206065787465726e616c5f70726f706f7365602063616c6c2e003c205765696768743a20604f283129606065787465726e616c5f70726f706f73655f64656661756c74043470726f706f73616c5f686173681c543a3a486173682c4901205363686564756c652061206e656761746976652d7475726e6f75742d62696173207265666572656e64756d20746f206265207461626c6564206e657874206f6e6365206974206973206c6567616c20746f84207363686564756c6520616e2065787465726e616c207265666572656e64756d2e00ec20546865206469737061746368206f6620746869732063616c6c206d757374206265206045787465726e616c44656661756c744f726967696e602e00d8202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f66207468652070726f706f73616c2e004d0120556e6c696b65206065787465726e616c5f70726f706f7365602c20626c61636b6c697374696e6720686173206e6f20656666656374206f6e207468697320616e64206974206d6179207265706c61636520619c207072652d7363686564756c6564206065787465726e616c5f70726f706f7365602063616c6c2e003c205765696768743a20604f2831296028666173745f747261636b0c3470726f706f73616c5f686173681c543a3a4861736834766f74696e675f706572696f6438543a3a426c6f636b4e756d6265721464656c617938543a3a426c6f636b4e756d6265723c5101205363686564756c65207468652063757272656e746c792065787465726e616c6c792d70726f706f736564206d616a6f726974792d63617272696573207265666572656e64756d20746f206265207461626c6564650120696d6d6564696174656c792e204966207468657265206973206e6f2065787465726e616c6c792d70726f706f736564207265666572656e64756d2063757272656e746c792c206f72206966207468657265206973206f6e65ec20627574206974206973206e6f742061206d616a6f726974792d63617272696573207265666572656e64756d207468656e206974206661696c732e00d420546865206469737061746368206f6620746869732063616c6c206d757374206265206046617374547261636b4f726967696e602e00f8202d206070726f706f73616c5f68617368603a205468652068617368206f66207468652063757272656e742065787465726e616c2070726f706f73616c2e6101202d2060766f74696e675f706572696f64603a2054686520706572696f64207468617420697320616c6c6f77656420666f7220766f74696e67206f6e20746869732070726f706f73616c2e20496e6372656173656420746f982020206046617374547261636b566f74696e67506572696f646020696620746f6f206c6f772e5501202d206064656c6179603a20546865206e756d626572206f6620626c6f636b20616674657220766f74696e672068617320656e64656420696e20617070726f76616c20616e6420746869732073686f756c64206265bc202020656e61637465642e205468697320646f65736e277420686176652061206d696e696d756d20616d6f756e742e004420456d697473206053746172746564602e003c205765696768743a20604f28312960347665746f5f65787465726e616c043470726f706f73616c5f686173681c543a3a4861736824bc205665746f20616e6420626c61636b6c697374207468652065787465726e616c2070726f706f73616c20686173682e00dc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d75737420626520605665746f4f726967696e602e003101202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f66207468652070726f706f73616c20746f207665746f20616e6420626c61636b6c6973742e004020456d69747320605665746f6564602e000101205765696768743a20604f2856202b206c6f6728562929602077686572652056206973206e756d626572206f6620606578697374696e67207665746f657273604463616e63656c5f7265666572656e64756d04247265665f696e64657860436f6d706163743c5265666572656e64756d496e6465783e1c542052656d6f76652061207265666572656e64756d2e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f526f6f745f2e00d8202d20607265665f696e646578603a2054686520696e646578206f6620746865207265666572656e64756d20746f2063616e63656c2e00482023205765696768743a20604f283129602e3463616e63656c5f717565756564041477686963683c5265666572656e64756d496e6465781ca02043616e63656c20612070726f706f73616c2071756575656420666f7220656e6163746d656e742e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f526f6f745f2e00c8202d20607768696368603a2054686520696e646578206f6620746865207265666572656e64756d20746f2063616e63656c2e004d01205765696768743a20604f284429602077686572652060446020697320746865206974656d7320696e207468652064697370617463682071756575652e205765696768746564206173206044203d203130602e2064656c65676174650c08746f30543a3a4163636f756e74496428636f6e76696374696f6e28436f6e76696374696f6e1c62616c616e63653042616c616e63654f663c543e503d012044656c65676174652074686520766f74696e6720706f77657220287769746820736f6d6520676976656e20636f6e76696374696f6e29206f66207468652073656e64696e67206163636f756e742e005901205468652062616c616e63652064656c656761746564206973206c6f636b656420666f72206173206c6f6e6720617320697427732064656c6567617465642c20616e64207468657265616674657220666f7220746865cc2074696d6520617070726f70726961746520666f722074686520636f6e76696374696f6e2773206c6f636b20706572696f642e00610120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2c20616e6420746865207369676e696e67206163636f756e74206d757374206569746865723a782020202d2062652064656c65676174696e6720616c72656164793b206f725d012020202d2068617665206e6f20766f74696e67206163746976697479202869662074686572652069732c207468656e2069742077696c6c206e65656420746f2062652072656d6f7665642f636f6e736f6c6964617465649820202020207468726f7567682060726561705f766f746560206f722060756e766f746560292e004901202d2060746f603a20546865206163636f756e742077686f736520766f74696e6720746865206074617267657460206163636f756e74277320766f74696e6720706f7765722077696c6c20666f6c6c6f772e5901202d2060636f6e76696374696f6e603a2054686520636f6e76696374696f6e20746861742077696c6c20626520617474616368656420746f207468652064656c65676174656420766f7465732e205768656e2074686545012020206163636f756e7420697320756e64656c6567617465642c207468652066756e64732077696c6c206265206c6f636b656420666f722074686520636f72726573706f6e64696e6720706572696f642e5501202d206062616c616e6365603a2054686520616d6f756e74206f6620746865206163636f756e7427732062616c616e636520746f206265207573656420696e2064656c65676174696e672e2054686973206d757374c82020206e6f74206265206d6f7265207468616e20746865206163636f756e7427732063757272656e742062616c616e63652e004c20456d697473206044656c656761746564602e004101205765696768743a20604f28522960207768657265205220697320746865206e756d626572206f66207265666572656e64756d732074686520766f7465722064656c65676174696e6720746f20686173cc202020766f746564206f6e2e205765696768742069732063686172676564206173206966206d6178696d756d20766f7465732e28756e64656c65676174650030d020556e64656c65676174652074686520766f74696e6720706f776572206f66207468652073656e64696e67206163636f756e742e00610120546f6b656e73206d617920626520756e6c6f636b656420666f6c6c6f77696e67206f6e636520616e20616d6f756e74206f662074696d6520636f6e73697374656e74207769746820746865206c6f636b20706572696f64e0206f662074686520636f6e76696374696f6e2077697468207768696368207468652064656c65676174696f6e20776173206973737565642e00490120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d757374206265582063757272656e746c792064656c65676174696e672e005420456d6974732060556e64656c656761746564602e004101205765696768743a20604f28522960207768657265205220697320746865206e756d626572206f66207265666572656e64756d732074686520766f7465722064656c65676174696e6720746f20686173cc202020766f746564206f6e2e205765696768742069732063686172676564206173206966206d6178696d756d20766f7465732e58636c6561725f7075626c69635f70726f706f73616c7300147420436c6561727320616c6c207075626c69632070726f706f73616c732e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f526f6f745f2e0040205765696768743a20604f283129602e346e6f74655f707265696d6167650440656e636f6465645f70726f706f73616c1c5665633c75383e2861012052656769737465722074686520707265696d61676520666f7220616e207570636f6d696e672070726f706f73616c2e205468697320646f65736e27742072657175697265207468652070726f706f73616c20746f206265250120696e207468652064697370617463682071756575652062757420646f657320726571756972652061206465706f7369742c2072657475726e6564206f6e636520656e61637465642e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00c8202d2060656e636f6465645f70726f706f73616c603a2054686520707265696d616765206f6620612070726f706f73616c2e005c20456d6974732060507265696d6167654e6f746564602e005101205765696768743a20604f28452960207769746820452073697a65206f662060656e636f6465645f70726f706f73616c60202870726f7465637465642062792061207265717569726564206465706f736974292e646e6f74655f707265696d6167655f6f7065726174696f6e616c0440656e636f6465645f70726f706f73616c1c5665633c75383e040d012053616d6520617320606e6f74655f707265696d6167656020627574206f726967696e20697320604f7065726174696f6e616c507265696d6167654f726967696e602e586e6f74655f696d6d696e656e745f707265696d6167650440656e636f6465645f70726f706f73616c1c5665633c75383e3045012052656769737465722074686520707265696d61676520666f7220616e207570636f6d696e672070726f706f73616c2e2054686973207265717569726573207468652070726f706f73616c20746f206265410120696e207468652064697370617463682071756575652e204e6f206465706f736974206973206e65656465642e205768656e20746869732063616c6c206973207375636365737366756c2c20692e652e39012074686520707265696d61676520686173206e6f74206265656e2075706c6f61646564206265666f726520616e64206d61746368657320736f6d6520696d6d696e656e742070726f706f73616c2c40206e6f2066656520697320706169642e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00c8202d2060656e636f6465645f70726f706f73616c603a2054686520707265696d616765206f6620612070726f706f73616c2e005c20456d6974732060507265696d6167654e6f746564602e005101205765696768743a20604f28452960207769746820452073697a65206f662060656e636f6465645f70726f706f73616c60202870726f7465637465642062792061207265717569726564206465706f736974292e886e6f74655f696d6d696e656e745f707265696d6167655f6f7065726174696f6e616c0440656e636f6465645f70726f706f73616c1c5665633c75383e0431012053616d6520617320606e6f74655f696d6d696e656e745f707265696d6167656020627574206f726967696e20697320604f7065726174696f6e616c507265696d6167654f726967696e602e34726561705f707265696d616765083470726f706f73616c5f686173681c543a3a486173686070726f706f73616c5f6c656e5f75707065725f626f756e6430436f6d706163743c7533323e3cf42052656d6f766520616e20657870697265642070726f706f73616c20707265696d61676520616e6420636f6c6c65637420746865206465706f7369742e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00d0202d206070726f706f73616c5f68617368603a2054686520707265696d6167652068617368206f6620612070726f706f73616c2e2d01202d206070726f706f73616c5f6c656e6774685f75707065725f626f756e64603a20616e20757070657220626f756e64206f6e206c656e677468206f66207468652070726f706f73616c2e010120202045787472696e736963206973207765696768746564206163636f7264696e6720746f20746869732076616c75652077697468206e6f20726566756e642e00510120546869732077696c6c206f6e6c7920776f726b2061667465722060566f74696e67506572696f646020626c6f636b732066726f6d207468652074696d6520746861742074686520707265696d616765207761735d01206e6f7465642c2069662069742773207468652073616d65206163636f756e7420646f696e672069742e2049662069742773206120646966666572656e74206163636f756e742c207468656e206974276c6c206f6e6c79b020776f726b20616e206164646974696f6e616c2060456e6163746d656e74506572696f6460206c617465722e006020456d6974732060507265696d616765526561706564602e00b8205765696768743a20604f284429602077686572652044206973206c656e677468206f662070726f706f73616c2e18756e6c6f636b041874617267657430543a3a4163636f756e7449641ca420556e6c6f636b20746f6b656e732074686174206861766520616e2065787069726564206c6f636b2e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e00bc202d2060746172676574603a20546865206163636f756e7420746f2072656d6f766520746865206c6f636b206f6e2e00c0205765696768743a20604f2852296020776974682052206e756d626572206f6620766f7465206f66207461726765742e2c72656d6f76655f766f74650414696e6465783c5265666572656e64756d496e6465786c802052656d6f7665206120766f746520666f722061207265666572656e64756d2e00102049663a8c202d20746865207265666572656e64756d207761732063616e63656c6c65642c206f7280202d20746865207265666572656e64756d206973206f6e676f696e672c206f7294202d20746865207265666572656e64756d2068617320656e6465642073756368207468617401012020202d2074686520766f7465206f6620746865206163636f756e742077617320696e206f70706f736974696f6e20746f2074686520726573756c743b206f72d82020202d20746865726520776173206e6f20636f6e76696374696f6e20746f20746865206163636f756e74277320766f74653b206f72882020202d20746865206163636f756e74206d61646520612073706c697420766f74656101202e2e2e7468656e2074686520766f74652069732072656d6f76656420636c65616e6c7920616e64206120666f6c6c6f77696e672063616c6c20746f2060756e6c6f636b60206d617920726573756c7420696e206d6f72655c2066756e6473206265696e6720617661696c61626c652e00ac2049662c20686f77657665722c20746865207265666572656e64756d2068617320656e64656420616e643af0202d2069742066696e697368656420636f72726573706f6e64696e6720746f2074686520766f7465206f6620746865206163636f756e742c20616e64e0202d20746865206163636f756e74206d6164652061207374616e6461726420766f7465207769746820636f6e76696374696f6e2c20616e64c0202d20746865206c6f636b20706572696f64206f662074686520636f6e76696374696f6e206973206e6f74206f7665725d01202e2e2e7468656e20746865206c6f636b2077696c6c206265206167677265676174656420696e746f20746865206f766572616c6c206163636f756e742773206c6f636b2c207768696368206d617920696e766f6c76655d01202a6f7665726c6f636b696e672a20287768657265207468652074776f206c6f636b732061726520636f6d62696e656420696e746f20612073696e676c65206c6f636b207468617420697320746865206d6178696d756de8206f6620626f74682074686520616d6f756e74206c6f636b656420616e64207468652074696d65206973206974206c6f636b656420666f72292e004d0120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2c20616e6420746865207369676e6572206d7573742068617665206120766f74658c207265676973746572656420666f72207265666572656e64756d2060696e646578602e00f8202d2060696e646578603a2054686520696e646578206f66207265666572656e64756d206f662074686520766f746520746f2062652072656d6f7665642e005901205765696768743a20604f2852202b206c6f6720522960207768657265205220697320746865206e756d626572206f66207265666572656e646120746861742060746172676574602068617320766f746564206f6e2edc2020205765696768742069732063616c63756c6174656420666f7220746865206d6178696d756d206e756d626572206f6620766f74652e4472656d6f76655f6f746865725f766f7465081874617267657430543a3a4163636f756e74496414696e6465783c5265666572656e64756d496e6465783c802052656d6f7665206120766f746520666f722061207265666572656e64756d2e0051012049662074686520607461726765746020697320657175616c20746f20746865207369676e65722c207468656e20746869732066756e6374696f6e2069732065786163746c79206571756976616c656e7420746f3101206072656d6f76655f766f7465602e204966206e6f7420657175616c20746f20746865207369676e65722c207468656e2074686520766f7465206d757374206861766520657870697265642c590120656974686572206265636175736520746865207265666572656e64756d207761732063616e63656c6c65642c20626563617573652074686520766f746572206c6f737420746865207265666572656e64756d206f729c20626563617573652074686520636f6e76696374696f6e20706572696f64206973206f7665722e00cc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e005101202d2060746172676574603a20546865206163636f756e74206f662074686520766f746520746f2062652072656d6f7665643b2074686973206163636f756e74206d757374206861766520766f74656420666f72582020207265666572656e64756d2060696e646578602ef8202d2060696e646578603a2054686520696e646578206f66207265666572656e64756d206f662074686520766f746520746f2062652072656d6f7665642e005901205765696768743a20604f2852202b206c6f6720522960207768657265205220697320746865206e756d626572206f66207265666572656e646120746861742060746172676574602068617320766f746564206f6e2edc2020205765696768742069732063616c63756c6174656420666f7220746865206d6178696d756d206e756d626572206f6620766f74652e38656e6163745f70726f706f73616c083470726f706f73616c5f686173681c543a3a4861736814696e6465783c5265666572656e64756d496e64657804510120456e61637420612070726f706f73616c2066726f6d2061207265666572656e64756d2e20466f72206e6f77207765206a757374206d616b65207468652077656967687420626520746865206d6178696d756d2e24626c61636b6c697374083470726f706f73616c5f686173681c543a3a486173683c6d617962655f7265665f696e6465785c4f7074696f6e3c5265666572656e64756d496e6465783e3c4901205065726d616e656e746c7920706c61636520612070726f706f73616c20696e746f2074686520626c61636b6c6973742e20546869732070726576656e74732069742066726f6d2065766572206265696e67402070726f706f73656420616761696e2e0055012049662063616c6c6564206f6e206120717565756564207075626c6963206f722065787465726e616c2070726f706f73616c2c207468656e20746869732077696c6c20726573756c7420696e206974206265696e6755012072656d6f7665642e2049662074686520607265665f696e6465786020737570706c69656420697320616e20616374697665207265666572656e64756d2077697468207468652070726f706f73616c20686173682c6c207468656e2069742077696c6c2062652063616e63656c6c65642e00f020546865206469737061746368206f726967696e206f6620746869732063616c6c206d7573742062652060426c61636b6c6973744f726967696e602e00fc202d206070726f706f73616c5f68617368603a205468652070726f706f73616c206861736820746f20626c61636b6c697374207065726d616e656e746c792e4901202d20607265665f696e646578603a20416e206f6e676f696e67207265666572656e64756d2077686f73652068617368206973206070726f706f73616c5f68617368602c2077686963682077696c6c2062652c2063616e63656c6c65642e004501205765696768743a20604f28702960202874686f756768206173207468697320697320616e20686967682d70726976696c6567652064697370617463682c20776520617373756d6520697420686173206154202020726561736f6e61626c652076616c7565292e3c63616e63656c5f70726f706f73616c042870726f705f696e64657848436f6d706163743c50726f70496e6465783e1c4c2052656d6f766520612070726f706f73616c2e00050120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265206043616e63656c50726f706f73616c4f726967696e602e00d4202d206070726f705f696e646578603a2054686520696e646578206f66207468652070726f706f73616c20746f2063616e63656c2e00e8205765696768743a20604f28702960207768657265206070203d205075626c696350726f70733a3a3c543e3a3a6465636f64655f6c656e28296001482050726f706f736564082450726f70496e6465781c42616c616e63650431012041206d6f74696f6e20686173206265656e2070726f706f7365642062792061207075626c6963206163636f756e742e205c5b70726f706f73616c5f696e6465782c206465706f7369745c5d185461626c65640c2450726f70496e6465781c42616c616e6365385665633c4163636f756e7449643e047d012041207075626c69632070726f706f73616c20686173206265656e207461626c656420666f72207265666572656e64756d20766f74652e205c5b70726f706f73616c5f696e6465782c206465706f7369742c206465706f7369746f72735c5d3845787465726e616c5461626c656400049820416e2065787465726e616c2070726f706f73616c20686173206265656e207461626c65642e1c53746172746564083c5265666572656e64756d496e64657834566f74655468726573686f6c6404c42041207265666572656e64756d2068617320626567756e2e205c5b7265665f696e6465782c207468726573686f6c645c5d18506173736564043c5265666572656e64756d496e64657804e820412070726f706f73616c20686173206265656e20617070726f766564206279207265666572656e64756d2e205c5b7265665f696e6465785c5d244e6f74506173736564043c5265666572656e64756d496e64657804e820412070726f706f73616c20686173206265656e2072656a6563746564206279207265666572656e64756d2e205c5b7265665f696e6465785c5d2443616e63656c6c6564043c5265666572656e64756d496e64657804bc2041207265666572656e64756d20686173206265656e2063616e63656c6c65642e205c5b7265665f696e6465785c5d204578656375746564083c5265666572656e64756d496e64657810626f6f6c04c820412070726f706f73616c20686173206265656e20656e61637465642e205c5b7265665f696e6465782c2069735f6f6b5c5d2444656c65676174656408244163636f756e744964244163636f756e74496404210120416e206163636f756e74206861732064656c65676174656420746865697220766f746520746f20616e6f74686572206163636f756e742e205c5b77686f2c207461726765745c5d2c556e64656c65676174656404244163636f756e74496404f820416e205c5b6163636f756e745c5d206861732063616e63656c6c656420612070726576696f75732064656c65676174696f6e206f7065726174696f6e2e185665746f65640c244163636f756e74496410486173682c426c6f636b4e756d62657204110120416e2065787465726e616c2070726f706f73616c20686173206265656e207665746f65642e205c5b77686f2c2070726f706f73616c5f686173682c20756e74696c5c5d34507265696d6167654e6f7465640c1048617368244163636f756e7449641c42616c616e636504610120412070726f706f73616c277320707265696d61676520776173206e6f7465642c20616e6420746865206465706f7369742074616b656e2e205c5b70726f706f73616c5f686173682c2077686f2c206465706f7369745c5d30507265696d616765557365640c1048617368244163636f756e7449641c42616c616e636508150120412070726f706f73616c20707265696d616765207761732072656d6f76656420616e6420757365642028746865206465706f736974207761732072657475726e6564292e94205c5b70726f706f73616c5f686173682c2070726f76696465722c206465706f7369745c5d3c507265696d616765496e76616c69640810486173683c5265666572656e64756d496e646578080d0120412070726f706f73616c20636f756c64206e6f7420626520657865637574656420626563617573652069747320707265696d6167652077617320696e76616c69642e74205c5b70726f706f73616c5f686173682c207265665f696e6465785c5d3c507265696d6167654d697373696e670810486173683c5265666572656e64756d496e646578080d0120412070726f706f73616c20636f756c64206e6f7420626520657865637574656420626563617573652069747320707265696d61676520776173206d697373696e672e74205c5b70726f706f73616c5f686173682c207265665f696e6465785c5d38507265696d616765526561706564101048617368244163636f756e7449641c42616c616e6365244163636f756e744964082d012041207265676973746572656420707265696d616765207761732072656d6f76656420616e6420746865206465706f73697420636f6c6c656374656420627920746865207265617065722eb4205c5b70726f706f73616c5f686173682c2070726f76696465722c206465706f7369742c207265617065725c5d20556e6c6f636b656404244163636f756e74496404bc20416e205c5b6163636f756e745c5d20686173206265656e20756e6c6f636b6564207375636365737366756c6c792e2c426c61636b6c697374656404104861736804d820412070726f706f73616c205c5b686173685c5d20686173206265656e20626c61636b6c6973746564207065726d616e656e746c792e203c456e6163746d656e74506572696f6438543a3a426c6f636b4e756d626572100027060014710120546865206d696e696d756d20706572696f64206f66206c6f636b696e6720616e642074686520706572696f64206265747765656e20612070726f706f73616c206265696e6720617070726f76656420616e6420656e61637465642e0031012049742073686f756c642067656e6572616c6c792062652061206c6974746c65206d6f7265207468616e2074686520756e7374616b6520706572696f6420746f20656e737572652074686174690120766f74696e67207374616b657273206861766520616e206f70706f7274756e69747920746f2072656d6f7665207468656d73656c7665732066726f6d207468652073797374656d20696e2074686520636173652077686572659c207468657920617265206f6e20746865206c6f73696e672073696465206f66206120766f74652e304c61756e6368506572696f6438543a3a426c6f636b4e756d626572100027060004e420486f77206f6674656e2028696e20626c6f636b7329206e6577207075626c6963207265666572656e646120617265206c61756e636865642e30566f74696e67506572696f6438543a3a426c6f636b4e756d626572100027060004b820486f77206f6674656e2028696e20626c6f636b732920746f20636865636b20666f72206e657720766f7465732e384d696e696d756d4465706f7369743042616c616e63654f663c543e400010a5d4e8000000000000000000000004350120546865206d696e696d756d20616d6f756e7420746f20626520757365642061732061206465706f73697420666f722061207075626c6963207265666572656e64756d2070726f706f73616c2e5446617374547261636b566f74696e67506572696f6438543a3a426c6f636b4e756d626572100807000004ec204d696e696d756d20766f74696e6720706572696f6420616c6c6f77656420666f7220616e20656d657267656e6379207265666572656e64756d2e34436f6f6c6f6666506572696f6438543a3a426c6f636b4e756d62657210c089010004610120506572696f6420696e20626c6f636b7320776865726520616e2065787465726e616c2070726f706f73616c206d6179206e6f742062652072652d7375626d6974746564206166746572206265696e67207665746f65642e4c507265696d616765427974654465706f7369743042616c616e63654f663c543e4000e1f5050000000000000000000000000429012054686520616d6f756e74206f662062616c616e63652074686174206d757374206265206465706f7369746564207065722062797465206f6620707265696d6167652073746f7265642e204d6178566f7465730c753332106400000004b020546865206d6178696d756d206e756d626572206f6620766f74657320666f7220616e206163636f756e742e8c2056616c75654c6f7704382056616c756520746f6f206c6f773c50726f706f73616c4d697373696e6704602050726f706f73616c20646f6573206e6f7420657869737420426164496e646578043820556e6b6e6f776e20696e6465783c416c726561647943616e63656c656404982043616e6e6f742063616e63656c207468652073616d652070726f706f73616c207477696365444475706c696361746550726f706f73616c04582050726f706f73616c20616c7265616479206d6164654c50726f706f73616c426c61636b6c6973746564046c2050726f706f73616c207374696c6c20626c61636b6c6973746564444e6f7453696d706c654d616a6f7269747904ac204e6578742065787465726e616c2070726f706f73616c206e6f742073696d706c65206d616a6f726974792c496e76616c696448617368043420496e76616c69642068617368284e6f50726f706f73616c0454204e6f2065787465726e616c2070726f706f73616c34416c72656164795665746f6564049c204964656e74697479206d6179206e6f74207665746f20612070726f706f73616c207477696365304e6f7444656c6567617465640438204e6f742064656c656761746564444475706c6963617465507265696d616765045c20507265696d61676520616c7265616479206e6f7465642c4e6f74496d6d696e656e740434204e6f7420696d6d696e656e7420546f6f4561726c79042820546f6f206561726c7920496d6d696e656e74042420496d6d696e656e743c507265696d6167654d697373696e67044c20507265696d616765206e6f7420666f756e64445265666572656e64756d496e76616c6964048820566f746520676976656e20666f7220696e76616c6964207265666572656e64756d3c507265696d616765496e76616c6964044420496e76616c696420707265696d6167652c4e6f6e6557616974696e670454204e6f2070726f706f73616c732077616974696e67244e6f744c6f636b656404a42054686520746172676574206163636f756e7420646f6573206e6f7420686176652061206c6f636b2e284e6f744578706972656404f020546865206c6f636b206f6e20746865206163636f756e7420746f20626520756e6c6f636b656420686173206e6f742079657420657870697265642e204e6f74566f74657204c82054686520676976656e206163636f756e7420646964206e6f7420766f7465206f6e20746865207265666572656e64756d2e304e6f5065726d697373696f6e04cc20546865206163746f7220686173206e6f207065726d697373696f6e20746f20636f6e647563742074686520616374696f6e2e44416c726561647944656c65676174696e67048c20546865206163636f756e7420697320616c72656164792064656c65676174696e672e204f766572666c6f7704a420416e20756e657870656374656420696e7465676572206f766572666c6f77206f636375727265642e24556e646572666c6f7704a820416e20756e657870656374656420696e746567657220756e646572666c6f77206f636375727265642e44496e73756666696369656e7446756e647304010120546f6f206869676820612062616c616e6365207761732070726f7669646564207468617420746865206163636f756e742063616e6e6f74206166666f72642e344e6f7444656c65676174696e6704a420546865206163636f756e74206973206e6f742063757272656e746c792064656c65676174696e672e28566f746573457869737408590120546865206163636f756e742063757272656e746c792068617320766f74657320617474616368656420746f20697420616e6420746865206f7065726174696f6e2063616e6e6f74207375636365656420756e74696cec207468657365206172652072656d6f7665642c20656974686572207468726f7567682060756e766f746560206f722060726561705f766f7465602e44496e7374616e744e6f74416c6c6f77656404dc2054686520696e7374616e74207265666572656e64756d206f726967696e2069732063757272656e746c7920646973616c6c6f7765642e204e6f6e73656e736504982044656c65676174696f6e20746f206f6e6573656c66206d616b6573206e6f2073656e73652e3c57726f6e675570706572426f756e64045420496e76616c696420757070657220626f756e642e3c4d6178566f746573526561636865640484204d6178696d756d206e756d626572206f6620766f74657320726561636865642e38496e76616c69645769746e6573730490205468652070726f7669646564207769746e65737320646174612069732077726f6e672e40546f6f4d616e7950726f706f73616c730494204d6178696d756d206e756d626572206f662070726f706f73616c7320726561636865642e0e1c436f756e63696c014c496e7374616e636531436f6c6c656374697665182450726f706f73616c730100305665633c543a3a486173683e040004902054686520686173686573206f6620746865206163746976652070726f706f73616c732e2850726f706f73616c4f660001061c543a3a48617368683c5420617320436f6e6669673c493e3e3a3a50726f706f73616c00040004cc2041637475616c2070726f706f73616c20666f72206120676976656e20686173682c20696620697427732063757272656e742e18566f74696e670001061c543a3a486173688c566f7465733c543a3a4163636f756e7449642c20543a3a426c6f636b4e756d6265723e00040004b420566f746573206f6e206120676976656e2070726f706f73616c2c206966206974206973206f6e676f696e672e3450726f706f73616c436f756e7401000c753332100000000004482050726f706f73616c7320736f206661722e1c4d656d626572730100445665633c543a3a4163636f756e7449643e0400043901205468652063757272656e74206d656d62657273206f662074686520636f6c6c6563746976652e20546869732069732073746f72656420736f7274656420286a7573742062792076616c7565292e145072696d65000030543a3a4163636f756e744964040004650120546865207072696d65206d656d62657220746861742068656c70732064657465726d696e65207468652064656661756c7420766f7465206265686176696f7220696e2063617365206f6620616273656e746174696f6e732e01182c7365745f6d656d626572730c2c6e65775f6d656d62657273445665633c543a3a4163636f756e7449643e147072696d65504f7074696f6e3c543a3a4163636f756e7449643e246f6c645f636f756e742c4d656d626572436f756e746084205365742074686520636f6c6c6563746976652773206d656d626572736869702e004901202d20606e65775f6d656d62657273603a20546865206e6577206d656d626572206c6973742e204265206e69636520746f2074686520636861696e20616e642070726f7669646520697420736f727465642ee4202d20607072696d65603a20546865207072696d65206d656d6265722077686f736520766f74652073657473207468652064656661756c742e3901202d20606f6c645f636f756e74603a2054686520757070657220626f756e6420666f72207468652070726576696f7573206e756d626572206f66206d656d6265727320696e2073746f726167652eac202020202020202020202020202020205573656420666f722077656967687420657374696d6174696f6e2e005820526571756972657320726f6f74206f726967696e2e005501204e4f54453a20446f6573206e6f7420656e666f7263652074686520657870656374656420604d61784d656d6265727360206c696d6974206f6e2074686520616d6f756e74206f66206d656d626572732c206275742501202020202020207468652077656967687420657374696d6174696f6e732072656c79206f6e20697420746f20657374696d61746520646973706174636861626c65207765696768742e002c2023203c7765696768743e282023232057656967687454202d20604f284d50202b204e29602077686572653ae42020202d20604d60206f6c642d6d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429e42020202d20604e60206e65772d6d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e646564299c2020202d206050602070726f706f73616c732d636f756e742028636f64652d626f756e6465642918202d2044423a75012020202d20312073746f72616765206d75746174696f6e2028636f64656320604f284d296020726561642c20604f284e29602077726974652920666f722072656164696e6720616e642077726974696e6720746865206d656d62657273f02020202d20312073746f7261676520726561642028636f64656320604f285029602920666f722072656164696e67207468652070726f706f73616c7349012020202d206050602073746f72616765206d75746174696f6e732028636f64656320604f284d29602920666f72207570646174696e672074686520766f74657320666f7220656163682070726f706f73616c61012020202d20312073746f726167652077726974652028636f64656320604f283129602920666f722064656c6574696e6720746865206f6c6420607072696d656020616e642073657474696e6720746865206e6577206f6e65302023203c2f7765696768743e1c65786563757465082070726f706f73616c7c426f783c3c5420617320436f6e6669673c493e3e3a3a50726f706f73616c3e306c656e6774685f626f756e6430436f6d706163743c7533323e28f420446973706174636820612070726f706f73616c2066726f6d2061206d656d626572207573696e672074686520604d656d62657260206f726967696e2e00ac204f726967696e206d7573742062652061206d656d626572206f662074686520636f6c6c6563746976652e002c2023203c7765696768743e28202323205765696768748501202d20604f284d202b2050296020776865726520604d60206d656d626572732d636f756e742028636f64652d626f756e6465642920616e642060506020636f6d706c6578697479206f66206469737061746368696e67206070726f706f73616c60d8202d2044423a203120726561642028636f64656320604f284d296029202b20444220616363657373206f66206070726f706f73616c6028202d2031206576656e74302023203c2f7765696768743e1c70726f706f73650c247468726573686f6c6450436f6d706163743c4d656d626572436f756e743e2070726f706f73616c7c426f783c3c5420617320436f6e6669673c493e3e3a3a50726f706f73616c3e306c656e6774685f626f756e6430436f6d706163743c7533323e6cfc204164642061206e65772070726f706f73616c20746f2065697468657220626520766f746564206f6e206f72206578656375746564206469726563746c792e0088205265717569726573207468652073656e64657220746f206265206d656d6265722e00450120607468726573686f6c64602064657465726d696e65732077686574686572206070726f706f73616c60206973206578656375746564206469726563746c792028607468726573686f6c64203c2032602958206f722070757420757020666f7220766f74696e672e002c2023203c7765696768743e2820232320576569676874b0202d20604f2842202b204d202b2050312960206f7220604f2842202b204d202b20503229602077686572653ae42020202d20604260206973206070726f706f73616c602073697a6520696e20627974657320286c656e6774682d6665652d626f756e64656429e02020202d20604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429c82020202d206272616e6368696e6720697320696e666c75656e63656420627920607468726573686f6c64602077686572653af820202020202d20605031602069732070726f706f73616c20657865637574696f6e20636f6d706c65786974792028607468726573686f6c64203c20326029010120202020202d20605032602069732070726f706f73616c732d636f756e742028636f64652d626f756e646564292028607468726573686f6c64203e3d2032602918202d2044423ab82020202d20312073746f726167652072656164206069735f6d656d626572602028636f64656320604f284d296029f42020202d20312073746f726167652072656164206050726f706f73616c4f663a3a636f6e7461696e735f6b6579602028636f64656320604f2831296029ac2020202d20444220616363657373657320696e666c75656e63656420627920607468726573686f6c64603a0d0120202020202d204549544845522073746f7261676520616363657373657320646f6e65206279206070726f706f73616c602028607468726573686f6c64203c20326029bc20202020202d204f522070726f706f73616c20696e73657274696f6e2028607468726573686f6c64203c3d20326029dc202020202020202d20312073746f72616765206d75746174696f6e206050726f706f73616c73602028636f64656320604f285032296029e8202020202020202d20312073746f72616765206d75746174696f6e206050726f706f73616c436f756e74602028636f64656320604f2831296029d0202020202020202d20312073746f72616765207772697465206050726f706f73616c4f66602028636f64656320604f2842296029c0202020202020202d20312073746f726167652077726974652060566f74696e67602028636f64656320604f284d296029302020202d2031206576656e74302023203c2f7765696768743e10766f74650c2070726f706f73616c1c543a3a4861736814696e64657858436f6d706163743c50726f706f73616c496e6465783e1c617070726f766510626f6f6c38f42041646420616e20617965206f72206e617920766f746520666f72207468652073656e64657220746f2074686520676976656e2070726f706f73616c2e0090205265717569726573207468652073656e64657220746f2062652061206d656d6265722e004d01205472616e73616374696f6e20666565732077696c6c2062652077616976656420696620746865206d656d62657220697320766f74696e67206f6e20616e7920706172746963756c61722070726f706f73616c690120666f72207468652066697273742074696d6520616e64207468652063616c6c206973207375636365737366756c2e2053756273657175656e7420766f7465206368616e6765732077696c6c206368617267652061206665652e2c2023203c7765696768743e28202323205765696768740d01202d20604f284d296020776865726520604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e6465642918202d2044423ab02020202d20312073746f72616765207265616420604d656d62657273602028636f64656320604f284d296029bc2020202d20312073746f72616765206d75746174696f6e2060566f74696e67602028636f64656320604f284d29602928202d2031206576656e74302023203c2f7765696768743e14636c6f7365103470726f706f73616c5f686173681c543a3a4861736814696e64657858436f6d706163743c50726f706f73616c496e6465783e5470726f706f73616c5f7765696768745f626f756e643c436f6d706163743c5765696768743e306c656e6774685f626f756e6430436f6d706163743c7533323e78510120436c6f7365206120766f746520746861742069732065697468657220617070726f7665642c20646973617070726f766564206f722077686f736520766f74696e6720706572696f642068617320656e6465642e005901204d61792062652063616c6c656420627920616e79207369676e6564206163636f756e7420696e206f7264657220746f2066696e69736820766f74696e6720616e6420636c6f7365207468652070726f706f73616c2e004d012049662063616c6c6564206265666f72652074686520656e64206f662074686520766f74696e6720706572696f642069742077696c6c206f6e6c7920636c6f73652074686520766f7465206966206974206973c02068617320656e6f75676820766f74657320746f20626520617070726f766564206f7220646973617070726f7665642e004d012049662063616c6c65642061667465722074686520656e64206f662074686520766f74696e6720706572696f642061627374656e74696f6e732061726520636f756e7465642061732072656a656374696f6e73290120756e6c6573732074686572652069732061207072696d65206d656d6265722073657420616e6420746865207072696d65206d656d626572206361737420616e20617070726f76616c2e0065012049662074686520636c6f7365206f7065726174696f6e20636f6d706c65746573207375636365737366756c6c79207769746820646973617070726f76616c2c20746865207472616e73616374696f6e206665652077696c6c6101206265207761697665642e204f746865727769736520657865637574696f6e206f662074686520617070726f766564206f7065726174696f6e2077696c6c206265206368617267656420746f207468652063616c6c65722e008d01202b206070726f706f73616c5f7765696768745f626f756e64603a20546865206d6178696d756d20616d6f756e74206f662077656967687420636f6e73756d656420627920657865637574696e672074686520636c6f7365642070726f706f73616c2e6501202b20606c656e6774685f626f756e64603a2054686520757070657220626f756e6420666f7220746865206c656e677468206f66207468652070726f706f73616c20696e2073746f726167652e20436865636b6564207669618101202020202020202020202020202020202020206073746f726167653a3a726561646020736f206974206973206073697a655f6f663a3a3c7533323e2829203d3d203460206c6172676572207468616e207468652070757265206c656e6774682e002c2023203c7765696768743e282023232057656967687478202d20604f2842202b204d202b205031202b20503229602077686572653ae42020202d20604260206973206070726f706f73616c602073697a6520696e20627974657320286c656e6774682d6665652d626f756e64656429e02020202d20604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429cc2020202d20605031602069732074686520636f6d706c6578697479206f66206070726f706f73616c6020707265696d6167652ea82020202d20605032602069732070726f706f73616c2d636f756e742028636f64652d626f756e6465642918202d2044423a110120202d20322073746f726167652072656164732028604d656d62657273603a20636f64656320604f284d29602c20605072696d65603a20636f64656320604f2831296029810120202d2033206d75746174696f6e73202860566f74696e67603a20636f64656320604f284d29602c206050726f706f73616c4f66603a20636f64656320604f284229602c206050726f706f73616c73603a20636f64656320604f285032296029e020202d20616e79206d75746174696f6e7320646f6e65207768696c6520657865637574696e67206070726f706f73616c602028605031602944202d20757020746f2033206576656e7473302023203c2f7765696768743e4c646973617070726f76655f70726f706f73616c043470726f706f73616c5f686173681c543a3a4861736834790120446973617070726f766520612070726f706f73616c2c20636c6f73652c20616e642072656d6f76652069742066726f6d207468652073797374656d2c207265676172646c657373206f66206974732063757272656e742073746174652e008c204d7573742062652063616c6c65642062792074686520526f6f74206f726967696e2e003020506172616d65746572733a2101202a206070726f706f73616c5f68617368603a205468652068617368206f66207468652070726f706f73616c20746861742073686f756c6420626520646973617070726f7665642e002c2023203c7765696768743ee020436f6d706c65786974793a204f285029207768657265205020697320746865206e756d626572206f66206d61782070726f706f73616c732c204442205765696768743a4c202a2052656164733a2050726f706f73616c73a0202a205772697465733a20566f74696e672c2050726f706f73616c732c2050726f706f73616c4f66302023203c2f7765696768743e011c2050726f706f73656410244163636f756e7449643450726f706f73616c496e64657810486173682c4d656d626572436f756e740c4d012041206d6f74696f6e2028676976656e20686173682920686173206265656e2070726f706f7365642028627920676976656e206163636f756e742920776974682061207468726573686f6c642028676976656e4020604d656d626572436f756e7460292ed8205c5b6163636f756e742c2070726f706f73616c5f696e6465782c2070726f706f73616c5f686173682c207468726573686f6c645c5d14566f74656414244163636f756e744964104861736810626f6f6c2c4d656d626572436f756e742c4d656d626572436f756e740c09012041206d6f74696f6e2028676976656e20686173682920686173206265656e20766f746564206f6e20627920676976656e206163636f756e742c206c656176696e67190120612074616c6c79202879657320766f74657320616e64206e6f20766f74657320676976656e20726573706563746976656c7920617320604d656d626572436f756e7460292eac205c5b6163636f756e742c2070726f706f73616c5f686173682c20766f7465642c207965732c206e6f5c5d20417070726f76656404104861736808c42041206d6f74696f6e2077617320617070726f76656420627920746865207265717569726564207468726573686f6c642e48205c5b70726f706f73616c5f686173685c5d2c446973617070726f76656404104861736808d42041206d6f74696f6e20776173206e6f7420617070726f76656420627920746865207265717569726564207468726573686f6c642e48205c5b70726f706f73616c5f686173685c5d204578656375746564081048617368384469737061746368526573756c740825012041206d6f74696f6e207761732065786563757465643b20726573756c742077696c6c20626520604f6b602069662069742072657475726e656420776974686f7574206572726f722e68205c5b70726f706f73616c5f686173682c20726573756c745c5d384d656d6265724578656375746564081048617368384469737061746368526573756c74084d0120412073696e676c65206d656d6265722064696420736f6d6520616374696f6e3b20726573756c742077696c6c20626520604f6b602069662069742072657475726e656420776974686f7574206572726f722e68205c5b70726f706f73616c5f686173682c20726573756c745c5d18436c6f7365640c10486173682c4d656d626572436f756e742c4d656d626572436f756e7408590120412070726f706f73616c2077617320636c6f736564206265636175736520697473207468726573686f6c64207761732072656163686564206f7220616674657220697473206475726174696f6e207761732075702e6c205c5b70726f706f73616c5f686173682c207965732c206e6f5c5d0028244e6f744d656d6265720460204163636f756e74206973206e6f742061206d656d626572444475706c696361746550726f706f73616c0480204475706c69636174652070726f706f73616c73206e6f7420616c6c6f7765643c50726f706f73616c4d697373696e6704502050726f706f73616c206d7573742065786973742857726f6e67496e6465780444204d69736d61746368656420696e646578344475706c6963617465566f7465045c204475706c696361746520766f74652069676e6f72656448416c7265616479496e697469616c697a65640484204d656d626572732061726520616c726561647920696e697469616c697a65642120546f6f4561726c790405012054686520636c6f73652063616c6c20776173206d61646520746f6f206561726c792c206265666f72652074686520656e64206f662074686520766f74696e672e40546f6f4d616e7950726f706f73616c730401012054686572652063616e206f6e6c792062652061206d6178696d756d206f6620604d617850726f706f73616c7360206163746976652070726f706f73616c732e4c57726f6e6750726f706f73616c57656967687404d42054686520676976656e2077656967687420626f756e6420666f72207468652070726f706f73616c2077617320746f6f206c6f772e4c57726f6e6750726f706f73616c4c656e67746804d42054686520676976656e206c656e67746820626f756e6420666f72207468652070726f706f73616c2077617320746f6f206c6f772e0f48546563686e6963616c436f6d6d6974746565014c496e7374616e636532436f6c6c656374697665182450726f706f73616c730100305665633c543a3a486173683e040004902054686520686173686573206f6620746865206163746976652070726f706f73616c732e2850726f706f73616c4f660001061c543a3a48617368683c5420617320436f6e6669673c493e3e3a3a50726f706f73616c00040004cc2041637475616c2070726f706f73616c20666f72206120676976656e20686173682c20696620697427732063757272656e742e18566f74696e670001061c543a3a486173688c566f7465733c543a3a4163636f756e7449642c20543a3a426c6f636b4e756d6265723e00040004b420566f746573206f6e206120676976656e2070726f706f73616c2c206966206974206973206f6e676f696e672e3450726f706f73616c436f756e7401000c753332100000000004482050726f706f73616c7320736f206661722e1c4d656d626572730100445665633c543a3a4163636f756e7449643e0400043901205468652063757272656e74206d656d62657273206f662074686520636f6c6c6563746976652e20546869732069732073746f72656420736f7274656420286a7573742062792076616c7565292e145072696d65000030543a3a4163636f756e744964040004650120546865207072696d65206d656d62657220746861742068656c70732064657465726d696e65207468652064656661756c7420766f7465206265686176696f7220696e2063617365206f6620616273656e746174696f6e732e01182c7365745f6d656d626572730c2c6e65775f6d656d62657273445665633c543a3a4163636f756e7449643e147072696d65504f7074696f6e3c543a3a4163636f756e7449643e246f6c645f636f756e742c4d656d626572436f756e746084205365742074686520636f6c6c6563746976652773206d656d626572736869702e004901202d20606e65775f6d656d62657273603a20546865206e6577206d656d626572206c6973742e204265206e69636520746f2074686520636861696e20616e642070726f7669646520697420736f727465642ee4202d20607072696d65603a20546865207072696d65206d656d6265722077686f736520766f74652073657473207468652064656661756c742e3901202d20606f6c645f636f756e74603a2054686520757070657220626f756e6420666f72207468652070726576696f7573206e756d626572206f66206d656d6265727320696e2073746f726167652eac202020202020202020202020202020205573656420666f722077656967687420657374696d6174696f6e2e005820526571756972657320726f6f74206f726967696e2e005501204e4f54453a20446f6573206e6f7420656e666f7263652074686520657870656374656420604d61784d656d6265727360206c696d6974206f6e2074686520616d6f756e74206f66206d656d626572732c206275742501202020202020207468652077656967687420657374696d6174696f6e732072656c79206f6e20697420746f20657374696d61746520646973706174636861626c65207765696768742e002c2023203c7765696768743e282023232057656967687454202d20604f284d50202b204e29602077686572653ae42020202d20604d60206f6c642d6d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429e42020202d20604e60206e65772d6d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e646564299c2020202d206050602070726f706f73616c732d636f756e742028636f64652d626f756e6465642918202d2044423a75012020202d20312073746f72616765206d75746174696f6e2028636f64656320604f284d296020726561642c20604f284e29602077726974652920666f722072656164696e6720616e642077726974696e6720746865206d656d62657273f02020202d20312073746f7261676520726561642028636f64656320604f285029602920666f722072656164696e67207468652070726f706f73616c7349012020202d206050602073746f72616765206d75746174696f6e732028636f64656320604f284d29602920666f72207570646174696e672074686520766f74657320666f7220656163682070726f706f73616c61012020202d20312073746f726167652077726974652028636f64656320604f283129602920666f722064656c6574696e6720746865206f6c6420607072696d656020616e642073657474696e6720746865206e6577206f6e65302023203c2f7765696768743e1c65786563757465082070726f706f73616c7c426f783c3c5420617320436f6e6669673c493e3e3a3a50726f706f73616c3e306c656e6774685f626f756e6430436f6d706163743c7533323e28f420446973706174636820612070726f706f73616c2066726f6d2061206d656d626572207573696e672074686520604d656d62657260206f726967696e2e00ac204f726967696e206d7573742062652061206d656d626572206f662074686520636f6c6c6563746976652e002c2023203c7765696768743e28202323205765696768748501202d20604f284d202b2050296020776865726520604d60206d656d626572732d636f756e742028636f64652d626f756e6465642920616e642060506020636f6d706c6578697479206f66206469737061746368696e67206070726f706f73616c60d8202d2044423a203120726561642028636f64656320604f284d296029202b20444220616363657373206f66206070726f706f73616c6028202d2031206576656e74302023203c2f7765696768743e1c70726f706f73650c247468726573686f6c6450436f6d706163743c4d656d626572436f756e743e2070726f706f73616c7c426f783c3c5420617320436f6e6669673c493e3e3a3a50726f706f73616c3e306c656e6774685f626f756e6430436f6d706163743c7533323e6cfc204164642061206e65772070726f706f73616c20746f2065697468657220626520766f746564206f6e206f72206578656375746564206469726563746c792e0088205265717569726573207468652073656e64657220746f206265206d656d6265722e00450120607468726573686f6c64602064657465726d696e65732077686574686572206070726f706f73616c60206973206578656375746564206469726563746c792028607468726573686f6c64203c2032602958206f722070757420757020666f7220766f74696e672e002c2023203c7765696768743e2820232320576569676874b0202d20604f2842202b204d202b2050312960206f7220604f2842202b204d202b20503229602077686572653ae42020202d20604260206973206070726f706f73616c602073697a6520696e20627974657320286c656e6774682d6665652d626f756e64656429e02020202d20604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429c82020202d206272616e6368696e6720697320696e666c75656e63656420627920607468726573686f6c64602077686572653af820202020202d20605031602069732070726f706f73616c20657865637574696f6e20636f6d706c65786974792028607468726573686f6c64203c20326029010120202020202d20605032602069732070726f706f73616c732d636f756e742028636f64652d626f756e646564292028607468726573686f6c64203e3d2032602918202d2044423ab82020202d20312073746f726167652072656164206069735f6d656d626572602028636f64656320604f284d296029f42020202d20312073746f726167652072656164206050726f706f73616c4f663a3a636f6e7461696e735f6b6579602028636f64656320604f2831296029ac2020202d20444220616363657373657320696e666c75656e63656420627920607468726573686f6c64603a0d0120202020202d204549544845522073746f7261676520616363657373657320646f6e65206279206070726f706f73616c602028607468726573686f6c64203c20326029bc20202020202d204f522070726f706f73616c20696e73657274696f6e2028607468726573686f6c64203c3d20326029dc202020202020202d20312073746f72616765206d75746174696f6e206050726f706f73616c73602028636f64656320604f285032296029e8202020202020202d20312073746f72616765206d75746174696f6e206050726f706f73616c436f756e74602028636f64656320604f2831296029d0202020202020202d20312073746f72616765207772697465206050726f706f73616c4f66602028636f64656320604f2842296029c0202020202020202d20312073746f726167652077726974652060566f74696e67602028636f64656320604f284d296029302020202d2031206576656e74302023203c2f7765696768743e10766f74650c2070726f706f73616c1c543a3a4861736814696e64657858436f6d706163743c50726f706f73616c496e6465783e1c617070726f766510626f6f6c38f42041646420616e20617965206f72206e617920766f746520666f72207468652073656e64657220746f2074686520676976656e2070726f706f73616c2e0090205265717569726573207468652073656e64657220746f2062652061206d656d6265722e004d01205472616e73616374696f6e20666565732077696c6c2062652077616976656420696620746865206d656d62657220697320766f74696e67206f6e20616e7920706172746963756c61722070726f706f73616c690120666f72207468652066697273742074696d6520616e64207468652063616c6c206973207375636365737366756c2e2053756273657175656e7420766f7465206368616e6765732077696c6c206368617267652061206665652e2c2023203c7765696768743e28202323205765696768740d01202d20604f284d296020776865726520604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e6465642918202d2044423ab02020202d20312073746f72616765207265616420604d656d62657273602028636f64656320604f284d296029bc2020202d20312073746f72616765206d75746174696f6e2060566f74696e67602028636f64656320604f284d29602928202d2031206576656e74302023203c2f7765696768743e14636c6f7365103470726f706f73616c5f686173681c543a3a4861736814696e64657858436f6d706163743c50726f706f73616c496e6465783e5470726f706f73616c5f7765696768745f626f756e643c436f6d706163743c5765696768743e306c656e6774685f626f756e6430436f6d706163743c7533323e78510120436c6f7365206120766f746520746861742069732065697468657220617070726f7665642c20646973617070726f766564206f722077686f736520766f74696e6720706572696f642068617320656e6465642e005901204d61792062652063616c6c656420627920616e79207369676e6564206163636f756e7420696e206f7264657220746f2066696e69736820766f74696e6720616e6420636c6f7365207468652070726f706f73616c2e004d012049662063616c6c6564206265666f72652074686520656e64206f662074686520766f74696e6720706572696f642069742077696c6c206f6e6c7920636c6f73652074686520766f7465206966206974206973c02068617320656e6f75676820766f74657320746f20626520617070726f766564206f7220646973617070726f7665642e004d012049662063616c6c65642061667465722074686520656e64206f662074686520766f74696e6720706572696f642061627374656e74696f6e732061726520636f756e7465642061732072656a656374696f6e73290120756e6c6573732074686572652069732061207072696d65206d656d6265722073657420616e6420746865207072696d65206d656d626572206361737420616e20617070726f76616c2e0065012049662074686520636c6f7365206f7065726174696f6e20636f6d706c65746573207375636365737366756c6c79207769746820646973617070726f76616c2c20746865207472616e73616374696f6e206665652077696c6c6101206265207761697665642e204f746865727769736520657865637574696f6e206f662074686520617070726f766564206f7065726174696f6e2077696c6c206265206368617267656420746f207468652063616c6c65722e008d01202b206070726f706f73616c5f7765696768745f626f756e64603a20546865206d6178696d756d20616d6f756e74206f662077656967687420636f6e73756d656420627920657865637574696e672074686520636c6f7365642070726f706f73616c2e6501202b20606c656e6774685f626f756e64603a2054686520757070657220626f756e6420666f7220746865206c656e677468206f66207468652070726f706f73616c20696e2073746f726167652e20436865636b6564207669618101202020202020202020202020202020202020206073746f726167653a3a726561646020736f206974206973206073697a655f6f663a3a3c7533323e2829203d3d203460206c6172676572207468616e207468652070757265206c656e6774682e002c2023203c7765696768743e282023232057656967687478202d20604f2842202b204d202b205031202b20503229602077686572653ae42020202d20604260206973206070726f706f73616c602073697a6520696e20627974657320286c656e6774682d6665652d626f756e64656429e02020202d20604d60206973206d656d626572732d636f756e742028636f64652d20616e6420676f7665726e616e63652d626f756e64656429cc2020202d20605031602069732074686520636f6d706c6578697479206f66206070726f706f73616c6020707265696d6167652ea82020202d20605032602069732070726f706f73616c2d636f756e742028636f64652d626f756e6465642918202d2044423a110120202d20322073746f726167652072656164732028604d656d62657273603a20636f64656320604f284d29602c20605072696d65603a20636f64656320604f2831296029810120202d2033206d75746174696f6e73202860566f74696e67603a20636f64656320604f284d29602c206050726f706f73616c4f66603a20636f64656320604f284229602c206050726f706f73616c73603a20636f64656320604f285032296029e020202d20616e79206d75746174696f6e7320646f6e65207768696c6520657865637574696e67206070726f706f73616c602028605031602944202d20757020746f2033206576656e7473302023203c2f7765696768743e4c646973617070726f76655f70726f706f73616c043470726f706f73616c5f686173681c543a3a4861736834790120446973617070726f766520612070726f706f73616c2c20636c6f73652c20616e642072656d6f76652069742066726f6d207468652073797374656d2c207265676172646c657373206f66206974732063757272656e742073746174652e008c204d7573742062652063616c6c65642062792074686520526f6f74206f726967696e2e003020506172616d65746572733a2101202a206070726f706f73616c5f68617368603a205468652068617368206f66207468652070726f706f73616c20746861742073686f756c6420626520646973617070726f7665642e002c2023203c7765696768743ee020436f6d706c65786974793a204f285029207768657265205020697320746865206e756d626572206f66206d61782070726f706f73616c732c204442205765696768743a4c202a2052656164733a2050726f706f73616c73a0202a205772697465733a20566f74696e672c2050726f706f73616c732c2050726f706f73616c4f66302023203c2f7765696768743e011c2050726f706f73656410244163636f756e7449643450726f706f73616c496e64657810486173682c4d656d626572436f756e740c4d012041206d6f74696f6e2028676976656e20686173682920686173206265656e2070726f706f7365642028627920676976656e206163636f756e742920776974682061207468726573686f6c642028676976656e4020604d656d626572436f756e7460292ed8205c5b6163636f756e742c2070726f706f73616c5f696e6465782c2070726f706f73616c5f686173682c207468726573686f6c645c5d14566f74656414244163636f756e744964104861736810626f6f6c2c4d656d626572436f756e742c4d656d626572436f756e740c09012041206d6f74696f6e2028676976656e20686173682920686173206265656e20766f746564206f6e20627920676976656e206163636f756e742c206c656176696e67190120612074616c6c79202879657320766f74657320616e64206e6f20766f74657320676976656e20726573706563746976656c7920617320604d656d626572436f756e7460292eac205c5b6163636f756e742c2070726f706f73616c5f686173682c20766f7465642c207965732c206e6f5c5d20417070726f76656404104861736808c42041206d6f74696f6e2077617320617070726f76656420627920746865207265717569726564207468726573686f6c642e48205c5b70726f706f73616c5f686173685c5d2c446973617070726f76656404104861736808d42041206d6f74696f6e20776173206e6f7420617070726f76656420627920746865207265717569726564207468726573686f6c642e48205c5b70726f706f73616c5f686173685c5d204578656375746564081048617368384469737061746368526573756c740825012041206d6f74696f6e207761732065786563757465643b20726573756c742077696c6c20626520604f6b602069662069742072657475726e656420776974686f7574206572726f722e68205c5b70726f706f73616c5f686173682c20726573756c745c5d384d656d6265724578656375746564081048617368384469737061746368526573756c74084d0120412073696e676c65206d656d6265722064696420736f6d6520616374696f6e3b20726573756c742077696c6c20626520604f6b602069662069742072657475726e656420776974686f7574206572726f722e68205c5b70726f706f73616c5f686173682c20726573756c745c5d18436c6f7365640c10486173682c4d656d626572436f756e742c4d656d626572436f756e7408590120412070726f706f73616c2077617320636c6f736564206265636175736520697473207468726573686f6c64207761732072656163686564206f7220616674657220697473206475726174696f6e207761732075702e6c205c5b70726f706f73616c5f686173682c207965732c206e6f5c5d0028244e6f744d656d6265720460204163636f756e74206973206e6f742061206d656d626572444475706c696361746550726f706f73616c0480204475706c69636174652070726f706f73616c73206e6f7420616c6c6f7765643c50726f706f73616c4d697373696e6704502050726f706f73616c206d7573742065786973742857726f6e67496e6465780444204d69736d61746368656420696e646578344475706c6963617465566f7465045c204475706c696361746520766f74652069676e6f72656448416c7265616479496e697469616c697a65640484204d656d626572732061726520616c726561647920696e697469616c697a65642120546f6f4561726c790405012054686520636c6f73652063616c6c20776173206d61646520746f6f206561726c792c206265666f72652074686520656e64206f662074686520766f74696e672e40546f6f4d616e7950726f706f73616c730401012054686572652063616e206f6e6c792062652061206d6178696d756d206f6620604d617850726f706f73616c7360206163746976652070726f706f73616c732e4c57726f6e6750726f706f73616c57656967687404d42054686520676976656e2077656967687420626f756e6420666f72207468652070726f706f73616c2077617320746f6f206c6f772e4c57726f6e6750726f706f73616c4c656e67746804d42054686520676976656e206c656e67746820626f756e6420666f72207468652070726f706f73616c2077617320746f6f206c6f772e1044456c656374696f6e7350687261676d656e014050687261676d656e456c656374696f6e141c4d656d626572730100ac5665633c53656174486f6c6465723c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e3e04000c74205468652063757272656e7420656c6563746564206d656d626572732e00b820496e76617269616e743a20416c7761797320736f72746564206261736564206f6e206163636f756e742069642e2452756e6e65727355700100ac5665633c53656174486f6c6465723c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e3e04001084205468652063757272656e742072657365727665642072756e6e6572732d75702e00590120496e76617269616e743a20416c7761797320736f72746564206261736564206f6e2072616e6b2028776f72736520746f2062657374292e2055706f6e2072656d6f76616c206f662061206d656d6265722c20746865bc206c6173742028692e652e205f626573745f292072756e6e65722d75702077696c6c206265207265706c616365642e2843616e646964617465730100845665633c28543a3a4163636f756e7449642c2042616c616e63654f663c543e293e0400185901205468652070726573656e742063616e646964617465206c6973742e20412063757272656e74206d656d626572206f722072756e6e65722d75702063616e206e6576657220656e746572207468697320766563746f72d020616e6420697320616c7761797320696d706c696369746c7920617373756d656420746f20626520612063616e6469646174652e007c205365636f6e6420656c656d656e7420697320746865206465706f7369742e00b820496e76617269616e743a20416c7761797320736f72746564206261736564206f6e206163636f756e742069642e38456c656374696f6e526f756e647301000c75333210000000000441012054686520746f74616c206e756d626572206f6620766f746520726f756e6473207468617420686176652068617070656e65642c206578636c7564696e6720746865207570636f6d696e67206f6e652e18566f74696e6701010530543a3a4163636f756e74496484566f7465723c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e00840000000000000000000000000000000000000000000000000000000000000000000cb820566f74657320616e64206c6f636b6564207374616b65206f66206120706172746963756c617220766f7465722e00c42054574f582d4e4f54453a205341464520617320604163636f756e7449646020697320612063727970746f20686173682e011810766f74650814766f746573445665633c543a3a4163636f756e7449643e1476616c756554436f6d706163743c42616c616e63654f663c543e3e5c5d0120566f746520666f72206120736574206f662063616e6469646174657320666f7220746865207570636f6d696e6720726f756e64206f6620656c656374696f6e2e20546869732063616e2062652063616c6c656420746fe4207365742074686520696e697469616c20766f7465732c206f722075706461746520616c7265616479206578697374696e6720766f7465732e0061012055706f6e20696e697469616c20766f74696e672c206076616c75656020756e697473206f66206077686f6027732062616c616e6365206973206c6f636b656420616e642061206465706f73697420616d6f756e7420697351012072657365727665642e20546865206465706f736974206973206261736564206f6e20746865206e756d626572206f6620766f74657320616e642063616e2062652075706461746564206f7665722074696d652e0050205468652060766f746573602073686f756c643a482020202d206e6f7420626520656d7074792e59012020202d206265206c657373207468616e20746865206e756d626572206f6620706f737369626c652063616e646964617465732e204e6f7465207468617420616c6c2063757272656e74206d656d6265727320616e641501202020202072756e6e6572732d75702061726520616c736f206175746f6d61746963616c6c792063616e6469646174657320666f7220746865206e65787420726f756e642e005101204966206076616c756560206973206d6f7265207468616e206077686f60277320746f74616c2062616c616e63652c207468656e20746865206d6178696d756d206f66207468652074776f20697320757365642e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265207369676e65642e003020232323205761726e696e670059012049742069732074686520726573706f6e736962696c697479206f66207468652063616c6c657220746f202a2a4e4f542a2a20706c61636520616c6c206f662074686569722062616c616e636520696e746f20746865ac206c6f636b20616e64206b65657020736f6d6520666f722066757274686572206f7065726174696f6e732e002c2023203c7765696768743e550120576520617373756d6520746865206d6178696d756d2077656967687420616d6f6e6720616c6c20332063617365733a20766f74655f657175616c2c20766f74655f6d6f726520616e6420766f74655f6c6573732e302023203c2f7765696768743e3072656d6f76655f766f7465720014702052656d6f766520606f726967696e60206173206120766f7465722e00bc20546869732072656d6f76657320746865206c6f636b20616e642072657475726e7320746865206465706f7369742e00010120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265207369676e656420616e64206265206120766f7465722e407375626d69745f63616e646964616379043c63616e6469646174655f636f756e7430436f6d706163743c7533323e3c1501205375626d6974206f6e6573656c6620666f722063616e6469646163792e204120666978656420616d6f756e74206f66206465706f736974206973207265636f726465642e00610120416c6c2063616e64696461746573206172652077697065642061742074686520656e64206f6620746865207465726d2e205468657920656974686572206265636f6d652061206d656d6265722f72756e6e65722d75702cd0206f72206c65617665207468652073797374656d207768696c65207468656972206465706f73697420697320736c61736865642e00c420546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265207369676e65642e003020232323205761726e696e67006101204576656e20696620612063616e64696461746520656e6473207570206265696e672061206d656d6265722c2074686579206d7573742063616c6c205b6043616c6c3a3a72656e6f756e63655f63616e646964616379605d5d0120746f20676574207468656972206465706f736974206261636b2e204c6f73696e67207468652073706f7420696e20616e20656c656374696f6e2077696c6c20616c77617973206c65616420746f206120736c6173682e002c2023203c7765696768743e0d0120546865206e756d626572206f662063757272656e742063616e64696461746573206d7573742062652070726f7669646564206173207769746e65737320646174612e302023203c2f7765696768743e4872656e6f756e63655f63616e646964616379042872656e6f756e63696e672852656e6f756e63696e674451012052656e6f756e6365206f6e65277320696e74656e74696f6e20746f20626520612063616e64696461746520666f7220746865206e65787420656c656374696f6e20726f756e642e203320706f74656e7469616c40206f7574636f6d65732065786973743a004d01202d20606f726967696e6020697320612063616e64696461746520616e64206e6f7420656c656374656420696e20616e79207365742e20496e207468697320636173652c20746865206465706f736974206973f4202020756e72657365727665642c2072657475726e656420616e64206f726967696e2069732072656d6f76656420617320612063616e6469646174652e6501202d20606f726967696e6020697320612063757272656e742072756e6e65722d75702e20496e207468697320636173652c20746865206465706f73697420697320756e72657365727665642c2072657475726e656420616e64902020206f726967696e2069732072656d6f76656420617320612072756e6e65722d75702e5901202d20606f726967696e6020697320612063757272656e74206d656d6265722e20496e207468697320636173652c20746865206465706f73697420697320756e726573657276656420616e64206f726967696e206973590120202072656d6f7665642061732061206d656d6265722c20636f6e73657175656e746c79206e6f74206265696e6720612063616e64696461746520666f7220746865206e65787420726f756e6420616e796d6f72652e6d0120202053696d696c617220746f205b6072656d6f76655f6d656d62657273605d2c206966207265706c6163656d656e742072756e6e657273206578697374732c20746865792061726520696d6d6564696174656c7920757365642e3501202020496620746865207072696d652069732072656e6f756e63696e672c207468656e206e6f207072696d652077696c6c20657869737420756e74696c20746865206e65787420726f756e642e00490120546865206469737061746368206f726967696e206f6620746869732063616c6c206d757374206265207369676e65642c20616e642068617665206f6e65206f66207468652061626f766520726f6c65732e002c2023203c7765696768743ee4205468652074797065206f662072656e6f756e63696e67206d7573742062652070726f7669646564206173207769746e65737320646174612e302023203c2f7765696768743e3472656d6f76655f6d656d626572080c77686f8c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653c6861735f7265706c6163656d656e7410626f6f6c385d012052656d6f7665206120706172746963756c6172206d656d6265722066726f6d20746865207365742e20546869732069732065666665637469766520696d6d6564696174656c7920616e642074686520626f6e64206f668020746865206f7574676f696e67206d656d62657220697320736c61736865642e00590120496620612072756e6e65722d757020697320617661696c61626c652c207468656e2074686520626573742072756e6e65722d75702077696c6c2062652072656d6f76656420616e64207265706c61636573207468650101206f7574676f696e67206d656d6265722e204f74686572776973652c2061206e65772070687261676d656e20656c656374696f6e20697320737461727465642e00bc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d75737420626520726f6f742e004501204e6f74652074686174207468697320646f6573206e6f7420616666656374207468652064657369676e6174656420626c6f636b206e756d626572206f6620746865206e65787420656c656374696f6e2e002c2023203c7765696768743e550120496620776520686176652061207265706c6163656d656e742c20776520757365206120736d616c6c207765696768742e20456c73652c2073696e63652074686973206973206120726f6f742063616c6c20616e64d42077696c6c20676f20696e746f2070687261676d656e2c20776520617373756d652066756c6c20626c6f636b20666f72206e6f772e302023203c2f7765696768743e50636c65616e5f646566756e63745f766f74657273082c5f6e756d5f766f746572730c753332305f6e756d5f646566756e63740c75333228490120436c65616e20616c6c20766f746572732077686f2061726520646566756e63742028692e652e207468657920646f206e6f7420736572766520616e7920707572706f736520617420616c6c292e20546865b0206465706f736974206f66207468652072656d6f76656420766f74657273206172652072657475726e65642e000501205468697320697320616e20726f6f742066756e6374696f6e20746f2062652075736564206f6e6c7920666f7220636c65616e696e67207468652073746174652e00bc20546865206469737061746368206f726967696e206f6620746869732063616c6c206d75737420626520726f6f742e002c2023203c7765696768743e61012054686520746f74616c206e756d626572206f6620766f7465727320616e642074686f736520746861742061726520646566756e6374206d7573742062652070726f7669646564206173207769746e65737320646174612e302023203c2f7765696768743e011c1c4e65775465726d04645665633c284163636f756e7449642c2042616c616e6365293e1069012041206e6577207465726d2077697468205c5b6e65775f6d656d626572735c5d2e205468697320696e64696361746573207468617420656e6f7567682063616e64696461746573206578697374656420746f2072756e20746865590120656c656374696f6e2c206e6f74207468617420656e6f756768206861766520686173206265656e20656c65637465642e2054686520696e6e65722076616c7565206d757374206265206578616d696e656420666f726901207468697320707572706f73652e204120604e65775465726d285c5b5c5d296020696e64696361746573207468617420736f6d652063616e6469646174657320676f7420746865697220626f6e6420736c617368656420616e645901206e6f6e65207765726520656c65637465642c207768696c73742060456d7074795465726d60206d65616e732074686174206e6f2063616e64696461746573206578697374656420746f20626567696e20776974682e24456d7074795465726d00083501204e6f20286f72206e6f7420656e6f756768292063616e64696461746573206578697374656420666f72207468697320726f756e642e205468697320697320646966666572656e742066726f6dcc20604e65775465726d285c5b5c5d29602e2053656520746865206465736372697074696f6e206f6620604e65775465726d602e34456c656374696f6e4572726f720004e820496e7465726e616c206572726f722068617070656e6564207768696c6520747279696e6720746f20706572666f726d20656c656374696f6e2e304d656d6265724b69636b656404244163636f756e7449640855012041205c5b6d656d6265725c5d20686173206265656e2072656d6f7665642e20546869732073686f756c6420616c7761797320626520666f6c6c6f7765642062792065697468657220604e65775465726d60206f72342060456d7074795465726d602e2452656e6f756e63656404244163636f756e744964049c20536f6d656f6e65206861732072656e6f756e6365642074686569722063616e6469646163792e4043616e646964617465536c617368656408244163636f756e7449641c42616c616e6365105d012041205c5b63616e6469646174655c5d2077617320736c6173686564206279205c5b616d6f756e745c5d2064756520746f206661696c696e6720746f206f627461696e20612073656174206173206d656d626572206f722c2072756e6e65722d75702e00e8204e6f74652074686174206f6c64206d656d6265727320616e642072756e6e6572732d75702061726520616c736f2063616e646964617465732e4453656174486f6c646572536c617368656408244163636f756e7449641c42616c616e63650459012041205c5b7365617420686f6c6465725c5d2077617320736c6173686564206279205c5b616d6f756e745c5d206279206265696e6720666f72636566756c6c792072656d6f7665642066726f6d20746865207365742e1c3443616e646964616379426f6e643042616c616e63654f663c543e400010a5d4e800000000000000000000000038566f74696e67426f6e64426173653042616c616e63654f663c543e40007013b72e00000000000000000000000040566f74696e67426f6e64466163746f723042616c616e63654f663c543e4000d012130000000000000000000000000038446573697265644d656d626572730c753332100d00000000404465736972656452756e6e65727355700c753332101400000000305465726d4475726174696f6e38543a3a426c6f636b4e756d62657210c089010000204d6f64756c654964384c6f636b4964656e74696669657220706872656c656374004430556e61626c65546f566f746504c42043616e6e6f7420766f7465207768656e206e6f2063616e64696461746573206f72206d656d626572732065786973742e1c4e6f566f7465730498204d75737420766f746520666f72206174206c65617374206f6e652063616e6469646174652e30546f6f4d616e79566f74657304882043616e6e6f7420766f7465206d6f7265207468616e2063616e646964617465732e504d6178696d756d566f7465734578636565646564049c2043616e6e6f7420766f7465206d6f7265207468616e206d6178696d756d20616c6c6f7765642e284c6f7742616c616e636504c82043616e6e6f7420766f74652077697468207374616b65206c657373207468616e206d696e696d756d2062616c616e63652e3c556e61626c65546f506179426f6e64047c20566f7465722063616e206e6f742070617920766f74696e6720626f6e642e2c4d7573744265566f7465720444204d757374206265206120766f7465722e285265706f727453656c6604502043616e6e6f74207265706f72742073656c662e4c4475706c69636174656443616e6469646174650484204475706c6963617465642063616e646964617465207375626d697373696f6e2e304d656d6265725375626d6974048c204d656d6265722063616e6e6f742072652d7375626d69742063616e6469646163792e3852756e6e657255705375626d6974048c2052756e6e65722063616e6e6f742072652d7375626d69742063616e6469646163792e68496e73756666696369656e7443616e64696461746546756e647304982043616e64696461746520646f6573206e6f74206861766520656e6f7567682066756e64732e244e6f744d656d6265720438204e6f742061206d656d6265722e48496e76616c69645769746e6573734461746104e4205468652070726f766964656420636f756e74206f66206e756d626572206f662063616e6469646174657320697320696e636f72726563742e40496e76616c6964566f7465436f756e7404d0205468652070726f766964656420636f756e74206f66206e756d626572206f6620766f74657320697320696e636f72726563742e44496e76616c696452656e6f756e63696e67040101205468652072656e6f756e63696e67206f726967696e2070726573656e74656420612077726f6e67206052656e6f756e63696e676020706172616d657465722e48496e76616c69645265706c6163656d656e740401012050726564696374696f6e20726567617264696e67207265706c6163656d656e74206166746572206d656d6265722072656d6f76616c2069732077726f6e672e114c546563686e6963616c4d656d62657273686970014c496e7374616e6365314d656d62657273686970081c4d656d626572730100445665633c543a3a4163636f756e7449643e040004c8205468652063757272656e74206d656d626572736869702c2073746f72656420617320616e206f726465726564205665632e145072696d65000030543a3a4163636f756e744964040004a4205468652063757272656e74207072696d65206d656d6265722c206966206f6e65206578697374732e011c286164645f6d656d626572040c77686f30543a3a4163636f756e7449640c7c204164642061206d656d626572206077686f6020746f20746865207365742e00a0204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a4164644f726967696e602e3472656d6f76655f6d656d626572040c77686f30543a3a4163636f756e7449640c902052656d6f76652061206d656d626572206077686f602066726f6d20746865207365742e00ac204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a52656d6f76654f726967696e602e2c737761705f6d656d626572081872656d6f766530543a3a4163636f756e7449640c61646430543a3a4163636f756e74496414c02053776170206f7574206f6e65206d656d626572206072656d6f76656020666f7220616e6f746865722060616464602e00a4204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a537761704f726967696e602e001101205072696d65206d656d62657273686970206973202a6e6f742a207061737365642066726f6d206072656d6f76656020746f2060616464602c20696620657874616e742e3472657365745f6d656d62657273041c6d656d62657273445665633c543a3a4163636f756e7449643e105901204368616e676520746865206d656d6265727368697020746f2061206e6577207365742c20646973726567617264696e6720746865206578697374696e67206d656d626572736869702e204265206e69636520616e646c207061737320606d656d6265727360207072652d736f727465642e00a8204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a52657365744f726967696e602e286368616e67655f6b6579040c6e657730543a3a4163636f756e74496414d82053776170206f7574207468652073656e64696e67206d656d62657220666f7220736f6d65206f74686572206b657920606e6577602e00f4204d6179206f6e6c792062652063616c6c65642066726f6d20605369676e656460206f726967696e206f6620612063757272656e74206d656d6265722e002101205072696d65206d656d62657273686970206973207061737365642066726f6d20746865206f726967696e206163636f756e7420746f20606e6577602c20696620657874616e742e247365745f7072696d65040c77686f30543a3a4163636f756e7449640cc02053657420746865207072696d65206d656d6265722e204d75737420626520612063757272656e74206d656d6265722e00a8204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a5072696d654f726967696e602e2c636c6561725f7072696d65000c982052656d6f766520746865207072696d65206d656d626572206966206974206578697374732e00a8204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a5072696d654f726967696e602e01182c4d656d62657241646465640004e42054686520676976656e206d656d626572207761732061646465643b2073656520746865207472616e73616374696f6e20666f722077686f2e344d656d62657252656d6f7665640004ec2054686520676976656e206d656d626572207761732072656d6f7665643b2073656520746865207472616e73616374696f6e20666f722077686f2e384d656d62657273537761707065640004dc2054776f206d656d62657273207765726520737761707065643b2073656520746865207472616e73616374696f6e20666f722077686f2e304d656d6265727352657365740004190120546865206d656d62657273686970207761732072657365743b2073656520746865207472616e73616374696f6e20666f722077686f20746865206e6577207365742069732e284b65794368616e676564000488204f6e65206f6620746865206d656d6265727327206b657973206368616e6765642e1444756d6d7904bc73705f7374643a3a6d61726b65723a3a5068616e746f6d446174613c284163636f756e7449642c204576656e74293e0470205068616e746f6d206d656d6265722c206e6576657220757365642e000012205472656173757279012054726561737572790c3450726f706f73616c436f756e7401003450726f706f73616c496e646578100000000004a4204e756d626572206f662070726f706f73616c7320746861742068617665206265656e206d6164652e2450726f706f73616c730001053450726f706f73616c496e6465789c50726f706f73616c3c543a3a4163636f756e7449642c2042616c616e63654f663c542c20493e3e000400047c2050726f706f73616c7320746861742068617665206265656e206d6164652e24417070726f76616c730100485665633c50726f706f73616c496e6465783e040004f82050726f706f73616c20696e646963657320746861742068617665206265656e20617070726f76656420627574206e6f742079657420617761726465642e010c3470726f706f73655f7370656e64081476616c756560436f6d706163743c42616c616e63654f663c542c20493e3e2c62656e65666963696172798c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365242d012050757420666f727761726420612073756767657374696f6e20666f72207370656e64696e672e2041206465706f7369742070726f706f7274696f6e616c20746f207468652076616c7565350120697320726573657276656420616e6420736c6173686564206966207468652070726f706f73616c2069732072656a65637465642e2049742069732072657475726e6564206f6e636520746865542070726f706f73616c20697320617761726465642e002c2023203c7765696768743e4c202d20436f6d706c65786974793a204f283129b4202d20446252656164733a206050726f706f73616c436f756e74602c20606f726967696e206163636f756e7460ec202d2044625772697465733a206050726f706f73616c436f756e74602c206050726f706f73616c73602c20606f726967696e206163636f756e7460302023203c2f7765696768743e3c72656a6563745f70726f706f73616c042c70726f706f73616c5f696458436f6d706163743c50726f706f73616c496e6465783e24fc2052656a65637420612070726f706f736564207370656e642e20546865206f726967696e616c206465706f7369742077696c6c20626520736c61736865642e00ac204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a52656a6563744f726967696e602e002c2023203c7765696768743e4c202d20436f6d706c65786974793a204f283129d0202d20446252656164733a206050726f706f73616c73602c206072656a65637465642070726f706f736572206163636f756e7460d4202d2044625772697465733a206050726f706f73616c73602c206072656a65637465642070726f706f736572206163636f756e7460302023203c2f7765696768743e40617070726f76655f70726f706f73616c042c70726f706f73616c5f696458436f6d706163743c50726f706f73616c496e6465783e285d0120417070726f766520612070726f706f73616c2e2041742061206c617465722074696d652c207468652070726f706f73616c2077696c6c20626520616c6c6f636174656420746f207468652062656e6566696369617279ac20616e6420746865206f726967696e616c206465706f7369742077696c6c2062652072657475726e65642e00b0204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a417070726f76654f726967696e602e002c2023203c7765696768743e50202d20436f6d706c65786974793a204f2831292e90202d20446252656164733a206050726f706f73616c73602c2060417070726f76616c73605c202d20446257726974653a2060417070726f76616c7360302023203c2f7765696768743e011c2050726f706f736564043450726f706f73616c496e6465780484204e65772070726f706f73616c2e205c5b70726f706f73616c5f696e6465785c5d205370656e64696e67041c42616c616e6365043d01205765206861766520656e6465642061207370656e6420706572696f6420616e642077696c6c206e6f7720616c6c6f636174652066756e64732e205c5b6275646765745f72656d61696e696e675c5d1c417761726465640c3450726f706f73616c496e6465781c42616c616e6365244163636f756e744964041d0120536f6d652066756e64732068617665206265656e20616c6c6f63617465642e205c5b70726f706f73616c5f696e6465782c2061776172642c2062656e65666963696172795c5d2052656a6563746564083450726f706f73616c496e6465781c42616c616e636504250120412070726f706f73616c207761732072656a65637465643b2066756e6473207765726520736c61736865642e205c5b70726f706f73616c5f696e6465782c20736c61736865645c5d144275726e74041c42616c616e636504b020536f6d65206f66206f75722066756e64732068617665206265656e206275726e742e205c5b6275726e5c5d20526f6c6c6f766572041c42616c616e6365083101205370656e64696e67206861732066696e69736865643b20746869732069732074686520616d6f756e74207468617420726f6c6c73206f76657220756e74696c206e657874207370656e642e54205c5b6275646765745f72656d61696e696e675c5d1c4465706f736974041c42616c616e636504b020536f6d652066756e64732068617665206265656e206465706f73697465642e205c5b6465706f7369745c5d143050726f706f73616c426f6e641c5065726d696c6c1050c30000085501204672616374696f6e206f6620612070726f706f73616c27732076616c756520746861742073686f756c6420626520626f6e64656420696e206f7264657220746f20706c616365207468652070726f706f73616c2e110120416e2061636365707465642070726f706f73616c2067657473207468657365206261636b2e20412072656a65637465642070726f706f73616c20646f6573206e6f742e4c50726f706f73616c426f6e644d696e696d756d3c42616c616e63654f663c542c20493e400010a5d4e80000000000000000000000044901204d696e696d756d20616d6f756e74206f662066756e647320746861742073686f756c6420626520706c6163656420696e2061206465706f73697420666f72206d616b696e6720612070726f706f73616c2e2c5370656e64506572696f6438543a3a426c6f636b4e756d6265721000460500048820506572696f64206265747765656e2073756363657373697665207370656e64732e104275726e1c5065726d696c6c10102700000411012050657263656e74616765206f662073706172652066756e64732028696620616e7929207468617420617265206275726e7420706572207370656e6420706572696f642e204d6f64756c654964204d6f64756c6549642070792f7472737279041901205468652074726561737572792773206d6f64756c652069642c207573656420666f72206465726976696e672069747320736f7665726569676e206163636f756e742049442e0870496e73756666696369656e7450726f706f7365727342616c616e6365047c2050726f706f73657227732062616c616e636520697320746f6f206c6f772e30496e76616c6964496e6465780494204e6f2070726f706f73616c206f7220626f756e7479206174207468617420696e6465782e1318436c61696d730118436c61696d731418436c61696d730001063c457468657265756d416464726573733042616c616e63654f663c543e0004000014546f74616c01003042616c616e63654f663c543e4000000000000000000000000000000000001c56657374696e670001063c457468657265756d41646472657373b02842616c616e63654f663c543e2c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265722900040010782056657374696e67207363686564756c6520666f72206120636c61696d2e0d012046697273742062616c616e63652069732074686520746f74616c20616d6f756e7420746861742073686f756c642062652068656c6420666f722076657374696e672ee4205365636f6e642062616c616e636520697320686f77206d7563682073686f756c6420626520756e6c6f636b65642070657220626c6f636b2ecc2054686520626c6f636b206e756d626572206973207768656e207468652076657374696e672073686f756c642073746172742e1c5369676e696e670001063c457468657265756d416464726573733453746174656d656e744b696e6400040004c0205468652073746174656d656e74206b696e642074686174206d757374206265207369676e65642c20696620616e792e24507265636c61696d7300010630543a3a4163636f756e7449643c457468657265756d41646472657373000400042d01205072652d636c61696d656420457468657265756d206163636f756e74732c20627920746865204163636f756e74204944207468617420746865792061726520636c61696d656420746f2e011414636c61696d08106465737430543a3a4163636f756e74496448657468657265756d5f7369676e61747572653845636473615369676e6174757265608c204d616b65206120636c61696d20746f20636f6c6c65637420796f757220444f54732e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f4e6f6e655f2e005420556e7369676e65642056616c69646174696f6e3a090120412063616c6c20746f20636c61696d206973206465656d65642076616c696420696620746865207369676e61747572652070726f7669646564206d6174636865738020746865206578706563746564207369676e6564206d657373616765206f663a006c203e20457468657265756d205369676e6564204d6573736167653a98203e2028636f6e666967757265642070726566697820737472696e672928616464726573732900a820616e6420606164647265737360206d6174636865732074686520606465737460206163636f756e742e003020506172616d65746572733adc202d206064657374603a205468652064657374696e6174696f6e206163636f756e7420746f207061796f75742074686520636c61696d2e1101202d2060657468657265756d5f7369676e6174757265603a20546865207369676e6174757265206f6620616e20657468657265756d207369676e6564206d657373616765a0202020206d61746368696e672074686520666f726d6174206465736372696265642061626f76652e0024203c7765696768743e01012054686520776569676874206f6620746869732063616c6c20697320696e76617269616e74206f7665722074686520696e70757420706172616d65746572732ee42057656967687420696e636c75646573206c6f67696320746f2076616c696461746520756e7369676e65642060636c61696d602063616c6c2e005c20546f74616c20436f6d706c65786974793a204f28312928203c2f7765696768743e286d696e745f636c61696d100c77686f3c457468657265756d416464726573731476616c75653042616c616e63654f663c543e4076657374696e675f7363686564756c65d04f7074696f6e3c2842616c616e63654f663c543e2c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d626572293e2473746174656d656e74544f7074696f6e3c53746174656d656e744b696e643e3c88204d696e742061206e657720636c61696d20746f20636f6c6c65637420444f54732e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f526f6f745f2e003020506172616d65746572733af4202d206077686f603a2054686520457468657265756d206164647265737320616c6c6f77656420746f20636f6c6c656374207468697320636c61696d2ed0202d206076616c7565603a20546865206e756d626572206f6620444f547320746861742077696c6c20626520636c61696d65642e0d01202d206076657374696e675f7363686564756c65603a20416e206f7074696f6e616c2076657374696e67207363686564756c6520666f7220746865736520444f54732e0024203c7765696768743e01012054686520776569676874206f6620746869732063616c6c20697320696e76617269616e74206f7665722074686520696e70757420706172616d65746572732e210120576520617373756d6520776f7273742063617365207468617420626f74682076657374696e6720616e642073746174656d656e74206973206265696e6720696e7365727465642e005c20546f74616c20436f6d706c65786974793a204f28312928203c2f7765696768743e30636c61696d5f6174746573740c106465737430543a3a4163636f756e74496448657468657265756d5f7369676e61747572653845636473615369676e61747572652473746174656d656e741c5665633c75383e68e8204d616b65206120636c61696d20746f20636f6c6c65637420796f757220444f5473206279207369676e696e6720612073746174656d656e742e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f4e6f6e655f2e005420556e7369676e65642056616c69646174696f6e3a2d0120412063616c6c20746f2060636c61696d5f61747465737460206973206465656d65642076616c696420696620746865207369676e61747572652070726f7669646564206d6174636865738020746865206578706563746564207369676e6564206d657373616765206f663a006c203e20457468657265756d205369676e6564204d6573736167653ac4203e2028636f6e666967757265642070726566697820737472696e67292861646472657373292873746174656d656e7429004d0120616e6420606164647265737360206d6174636865732074686520606465737460206163636f756e743b20746865206073746174656d656e7460206d757374206d617463682074686174207768696368206973c4206578706563746564206163636f7264696e6720746f20796f757220707572636861736520617272616e67656d656e742e003020506172616d65746572733adc202d206064657374603a205468652064657374696e6174696f6e206163636f756e7420746f207061796f75742074686520636c61696d2e1101202d2060657468657265756d5f7369676e6174757265603a20546865207369676e6174757265206f6620616e20657468657265756d207369676e6564206d657373616765a0202020206d61746368696e672074686520666f726d6174206465736372696265642061626f76652e6901202d206073746174656d656e74603a20546865206964656e74697479206f66207468652073746174656d656e74207768696368206973206265696e6720617474657374656420746f20696e20746865207369676e61747572652e0024203c7765696768743e01012054686520776569676874206f6620746869732063616c6c20697320696e76617269616e74206f7665722074686520696e70757420706172616d65746572732e01012057656967687420696e636c75646573206c6f67696320746f2076616c696461746520756e7369676e65642060636c61696d5f617474657374602063616c6c2e005c20546f74616c20436f6d706c65786974793a204f28312928203c2f7765696768743e18617474657374042473746174656d656e741c5665633c75383e44f82041747465737420746f20612073746174656d656e742c206e656564656420746f2066696e616c697a652074686520636c61696d732070726f636573732e006901205741524e494e473a20496e73656375726520756e6c65737320796f757220636861696e20696e636c75646573206050726576616c69646174654174746573747360206173206120605369676e6564457874656e73696f6e602e005420556e7369676e65642056616c69646174696f6e3a2d0120412063616c6c20746f20617474657374206973206465656d65642076616c6964206966207468652073656e6465722068617320612060507265636c61696d602072656769737465726564f820616e642070726f76696465732061206073746174656d656e746020776869636820697320657870656374656420666f7220746865206163636f756e742e003020506172616d65746572733a6901202d206073746174656d656e74603a20546865206964656e74697479206f66207468652073746174656d656e74207768696368206973206265696e6720617474657374656420746f20696e20746865207369676e61747572652e0024203c7765696768743e01012054686520776569676874206f6620746869732063616c6c20697320696e76617269616e74206f7665722074686520696e70757420706172616d65746572732ef42057656967687420696e636c75646573206c6f67696320746f20646f207072652d76616c69646174696f6e206f6e2060617474657374602063616c6c2e005c20546f74616c20436f6d706c65786974793a204f28312928203c2f7765696768743e286d6f76655f636c61696d0c0c6f6c643c457468657265756d416464726573730c6e65773c457468657265756d41646472657373386d617962655f707265636c61696d504f7074696f6e3c543a3a4163636f756e7449643e0001041c436c61696d65640c244163636f756e7449643c457468657265756d416464726573731c42616c616e636504ec20536f6d656f6e6520636c61696d656420736f6d6520444f54732e205b77686f2c20657468657265756d5f616464726573732c20616d6f756e745d041850726566697814265b75385d888450617920444f547320746f2074686520506f6c6b61646f74206163636f756e743a04150120546865205072656669782074686174206973207573656420696e207369676e656420457468657265756d206d6573736167657320666f722074686973206e6574776f726b1860496e76616c6964457468657265756d5369676e6174757265047020496e76616c696420457468657265756d207369676e61747572652e405369676e65724861734e6f436c61696d047c20457468657265756d206164647265737320686173206e6f20636c61696d2e4053656e6465724861734e6f436c61696d0490204163636f756e742049442073656e64696e6720747820686173206e6f20636c61696d2e30506f74556e646572666c6f770865012054686572652773206e6f7420656e6f75676820696e2074686520706f7420746f20706179206f757420736f6d6520756e76657374656420616d6f756e742e2047656e6572616c6c7920696d706c6965732061206c6f6769631c206572726f722e40496e76616c696453746174656d656e7404942041206e65656465642073746174656d656e7420776173206e6f7420696e636c756465642e4c56657374656442616c616e636545786973747304a820546865206163636f756e7420616c7265616479206861732061207665737465642062616c616e63652e181c56657374696e67011c56657374696e67041c56657374696e6700010230543a3a4163636f756e744964a456657374696e67496e666f3c42616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e00040004d820496e666f726d6174696f6e20726567617264696e67207468652076657374696e67206f66206120676976656e206163636f756e742e011010766573740034bc20556e6c6f636b20616e79207665737465642066756e6473206f66207468652073656e646572206163636f756e742e00610120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652066756e6473207374696c6c68206c6f636b656420756e64657220746869732070616c6c65742e00d420456d69747320656974686572206056657374696e67436f6d706c6574656460206f72206056657374696e6755706461746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20322052656164732c203220577269746573fc20202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c205b53656e646572204163636f756e745d010120202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c205b53656e646572204163636f756e745d302023203c2f7765696768743e28766573745f6f7468657204187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653cbc20556e6c6f636b20616e79207665737465642066756e6473206f662061206074617267657460206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005501202d2060746172676574603a20546865206163636f756e742077686f7365207665737465642066756e64732073686f756c6420626520756e6c6f636b65642e204d75737420686176652066756e6473207374696c6c68206c6f636b656420756e64657220746869732070616c6c65742e00d420456d69747320656974686572206056657374696e67436f6d706c6574656460206f72206056657374696e6755706461746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20332052656164732c203320577269746573f420202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e74f820202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e74302023203c2f7765696768743e3c7665737465645f7472616e7366657208187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365207363686564756c65a456657374696e67496e666f3c42616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e406820437265617465206120766573746564207472616e736665722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e001501202d2060746172676574603a20546865206163636f756e7420746861742073686f756c64206265207472616e7366657272656420746865207665737465642066756e64732e0101202d2060616d6f756e74603a2054686520616d6f756e74206f662066756e647320746f207472616e7366657220616e642077696c6c206265207665737465642ef4202d20607363686564756c65603a205468652076657374696e67207363686564756c6520617474616368656420746f20746865207472616e736665722e006020456d697473206056657374696e6743726561746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20332052656164732c2033205772697465733d0120202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c205b53656e646572204163636f756e745d410120202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c205b53656e646572204163636f756e745d302023203c2f7765696768743e54666f7263655f7665737465645f7472616e736665720c18736f757263658c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365207363686564756c65a456657374696e67496e666f3c42616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e446420466f726365206120766573746564207472616e736665722e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f526f6f745f2e00ec202d2060736f75726365603a20546865206163636f756e742077686f73652066756e64732073686f756c64206265207472616e736665727265642e1501202d2060746172676574603a20546865206163636f756e7420746861742073686f756c64206265207472616e7366657272656420746865207665737465642066756e64732e0101202d2060616d6f756e74603a2054686520616d6f756e74206f662066756e647320746f207472616e7366657220616e642077696c6c206265207665737465642ef4202d20607363686564756c65603a205468652076657374696e67207363686564756c6520617474616368656420746f20746865207472616e736665722e006020456d697473206056657374696e6743726561746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20342052656164732c203420577269746573350120202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c20536f75726365204163636f756e74390120202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c20536f75726365204163636f756e74302023203c2f7765696768743e01083856657374696e675570646174656408244163636f756e7449641c42616c616e63650c59012054686520616d6f756e742076657374656420686173206265656e20757064617465642e205468697320636f756c6420696e646963617465206d6f72652066756e64732061726520617661696c61626c652e2054686519012062616c616e636520676976656e2069732074686520616d6f756e74207768696368206973206c65667420756e7665737465642028616e642074687573206c6f636b6564292e58205c5b6163636f756e742c20756e7665737465645c5d4056657374696e67436f6d706c6574656404244163636f756e744964041d0120416e205c5b6163636f756e745c5d20686173206265636f6d652066756c6c79207665737465642e204e6f20667572746865722076657374696e672063616e2068617070656e2e04444d696e5665737465645472616e736665723042616c616e63654f663c543e400010a5d4e8000000000000000000000004e820546865206d696e696d756d20616d6f756e74207472616e7366657272656420746f2063616c6c20607665737465645f7472616e73666572602e0c284e6f7456657374696e67048820546865206163636f756e7420676976656e206973206e6f742076657374696e672e5c4578697374696e6756657374696e675363686564756c65045d0120416e206578697374696e672076657374696e67207363686564756c6520616c72656164792065786973747320666f722074686973206163636f756e7420746861742063616e6e6f7420626520636c6f6262657265642e24416d6f756e744c6f7704090120416d6f756e74206265696e67207472616e7366657272656420697320746f6f206c6f7720746f2063726561746520612076657374696e67207363686564756c652e191c5574696c69747900010c146261746368041463616c6c73605665633c3c5420617320436f6e6669673e3a3a43616c6c3e48802053656e642061206261746368206f662064697370617463682063616c6c732e007c204d61792062652063616c6c65642066726f6d20616e79206f726967696e2e00f0202d206063616c6c73603a205468652063616c6c7320746f20626520646973706174636865642066726f6d207468652073616d65206f726967696e2e006101204966206f726967696e20697320726f6f74207468656e2063616c6c2061726520646973706174636820776974686f757420636865636b696e67206f726967696e2066696c7465722e20285468697320696e636c75646573cc20627970617373696e6720606672616d655f73797374656d3a3a436f6e6669673a3a4261736543616c6c46696c74657260292e002c2023203c7765696768743e0501202d20436f6d706c65786974793a204f284329207768657265204320697320746865206e756d626572206f662063616c6c7320746f20626520626174636865642e302023203c2f7765696768743e00590120546869732077696c6c2072657475726e20604f6b6020696e20616c6c2063697263756d7374616e6365732e20546f2064657465726d696e65207468652073756363657373206f66207468652062617463682c20616e3501206576656e74206973206465706f73697465642e20496620612063616c6c206661696c656420616e64207468652062617463682077617320696e7465727275707465642c207468656e20746865590120604261746368496e74657272757074656460206576656e74206973206465706f73697465642c20616c6f6e67207769746820746865206e756d626572206f66207375636365737366756c2063616c6c73206d616465510120616e6420746865206572726f72206f6620746865206661696c65642063616c6c2e20496620616c6c2077657265207375636365737366756c2c207468656e2074686520604261746368436f6d706c657465646050206576656e74206973206465706f73697465642e3461735f646572697661746976650814696e6465780c7531361063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e34e02053656e6420612063616c6c207468726f75676820616e20696e64657865642070736575646f6e796d206f66207468652073656e6465722e0059012046696c7465722066726f6d206f726967696e206172652070617373656420616c6f6e672e205468652063616c6c2077696c6c2062652064697370617463686564207769746820616e206f726967696e207768696368c020757365207468652073616d652066696c74657220617320746865206f726967696e206f6620746869732063616c6c2e004901204e4f54453a20496620796f75206e65656420746f20656e73757265207468617420616e79206163636f756e742d62617365642066696c746572696e67206973206e6f7420686f6e6f7265642028692e652e6501206265636175736520796f7520657870656374206070726f78796020746f2068617665206265656e2075736564207072696f7220696e207468652063616c6c20737461636b20616e6420796f7520646f206e6f742077616e745501207468652063616c6c207265737472696374696f6e7320746f206170706c7920746f20616e79207375622d6163636f756e7473292c207468656e20757365206061735f6d756c74695f7468726573686f6c645f31608020696e20746865204d756c74697369672070616c6c657420696e73746561642e00f8204e4f54453a205072696f7220746f2076657273696f6e202a31322c2074686973207761732063616c6c6564206061735f6c696d697465645f737562602e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e2462617463685f616c6c041463616c6c73605665633c3c5420617320436f6e6669673e3a3a43616c6c3e34f02053656e642061206261746368206f662064697370617463682063616c6c7320616e642061746f6d6963616c6c792065786563757465207468656d2e2501205468652077686f6c65207472616e73616374696f6e2077696c6c20726f6c6c6261636b20616e64206661696c20696620616e79206f66207468652063616c6c73206661696c65642e007c204d61792062652063616c6c65642066726f6d20616e79206f726967696e2e00f0202d206063616c6c73603a205468652063616c6c7320746f20626520646973706174636865642066726f6d207468652073616d65206f726967696e2e006101204966206f726967696e20697320726f6f74207468656e2063616c6c2061726520646973706174636820776974686f757420636865636b696e67206f726967696e2066696c7465722e20285468697320696e636c75646573cc20627970617373696e6720606672616d655f73797374656d3a3a436f6e6669673a3a4261736543616c6c46696c74657260292e002c2023203c7765696768743e0501202d20436f6d706c65786974793a204f284329207768657265204320697320746865206e756d626572206f662063616c6c7320746f20626520626174636865642e302023203c2f7765696768743e0108404261746368496e746572727570746564080c7533323444697370617463684572726f72085901204261746368206f66206469737061746368657320646964206e6f7420636f6d706c6574652066756c6c792e20496e646578206f66206669727374206661696c696e6720646973706174636820676976656e2c206173902077656c6c20617320746865206572726f722e205c5b696e6465782c206572726f725c5d384261746368436f6d706c657465640004cc204261746368206f66206469737061746368657320636f6d706c657465642066756c6c792077697468206e6f206572726f722e00001a204964656e7469747901204964656e7469747910284964656e746974794f6600010530543a3a4163636f756e74496468526567697374726174696f6e3c42616c616e63654f663c543e3e0004000c210120496e666f726d6174696f6e20746861742069732070657274696e656e7420746f206964656e746966792074686520656e7469747920626568696e6420616e206163636f756e742e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e1c53757065724f6600010230543a3a4163636f756e7449645028543a3a4163636f756e7449642c204461746129000400086101205468652073757065722d6964656e74697479206f6620616e20616c7465726e6174697665202273756222206964656e7469747920746f676574686572207769746820697473206e616d652c2077697468696e2074686174510120636f6e746578742e20496620746865206163636f756e74206973206e6f7420736f6d65206f74686572206163636f756e742773207375622d6964656e746974792c207468656e206a75737420604e6f6e65602e18537562734f6601010530543a3a4163636f756e744964842842616c616e63654f663c543e2c205665633c543a3a4163636f756e7449643e290044000000000000000000000000000000000014b820416c7465726e6174697665202273756222206964656e746974696573206f662074686973206163636f756e742e001d0120546865206669727374206974656d20697320746865206465706f7369742c20746865207365636f6e64206973206120766563746f72206f6620746865206163636f756e74732e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e28526567697374726172730100d85665633c4f7074696f6e3c526567697374726172496e666f3c42616c616e63654f663c543e2c20543a3a4163636f756e7449643e3e3e0400104d012054686520736574206f6620726567697374726172732e204e6f7420657870656374656420746f206765742076657279206269672061732063616e206f6e6c79206265206164646564207468726f7567682061a8207370656369616c206f726967696e20286c696b656c79206120636f756e63696c206d6f74696f6e292e0029012054686520696e64657820696e746f20746869732063616e206265206361737420746f2060526567697374726172496e6465786020746f2067657420612076616c69642076616c75652e013c346164645f726567697374726172041c6163636f756e7430543a3a4163636f756e744964347c2041646420612072656769737472617220746f207468652073797374656d2e00010120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652060543a3a5265676973747261724f726967696e602e00ac202d20606163636f756e74603a20746865206163636f756e74206f6620746865207265676973747261722e009820456d6974732060526567697374726172416464656460206966207375636365737366756c2e002c2023203c7765696768743e2901202d20604f2852296020776865726520605260207265676973747261722d636f756e742028676f7665726e616e63652d626f756e64656420616e6420636f64652d626f756e646564292e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28522960292e34202d204f6e65206576656e742e302023203c2f7765696768743e307365745f6964656e746974790410696e666f304964656e74697479496e666f4c2d012053657420616e206163636f756e742773206964656e7469747920696e666f726d6174696f6e20616e6420726573657276652074686520617070726f707269617465206465706f7369742e00590120496620746865206163636f756e7420616c726561647920686173206964656e7469747920696e666f726d6174696f6e2c20746865206465706f7369742069732074616b656e2061732070617274207061796d656e745420666f7220746865206e6577206465706f7369742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e0090202d2060696e666f603a20546865206964656e7469747920696e666f726d6174696f6e2e008c20456d69747320604964656e7469747953657460206966207375636365737366756c2e002c2023203c7765696768743e48202d20604f2858202b205827202b2052296021012020202d20776865726520605860206164646974696f6e616c2d6669656c642d636f756e7420286465706f7369742d626f756e64656420616e6420636f64652d626f756e64656429e42020202d20776865726520605260206a756467656d656e74732d636f756e7420287265676973747261722d636f756e742d626f756e6465642984202d204f6e652062616c616e63652072657365727665206f7065726174696f6e2e2501202d204f6e652073746f72616765206d75746174696f6e2028636f6465632d7265616420604f285827202b205229602c20636f6465632d777269746520604f2858202b20522960292e34202d204f6e65206576656e742e302023203c2f7765696768743e207365745f73756273041073756273645665633c28543a3a4163636f756e7449642c2044617461293e54902053657420746865207375622d6163636f756e7473206f66207468652073656e6465722e005901205061796d656e743a20416e79206167677265676174652062616c616e63652072657365727665642062792070726576696f757320607365745f73756273602063616c6c732077696c6c2062652072657475726e6564310120616e6420616e20616d6f756e7420605375624163636f756e744465706f736974602077696c6c20626520726573657276656420666f722065616368206974656d20696e206073756273602e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061207265676973746572656428206964656e746974792e00b4202d206073756273603a20546865206964656e74697479277320286e657729207375622d6163636f756e74732e002c2023203c7765696768743e34202d20604f2850202b20532960e82020202d20776865726520605060206f6c642d737562732d636f756e742028686172642d20616e64206465706f7369742d626f756e646564292ed82020202d2077686572652060536020737562732d636f756e742028686172642d20616e64206465706f7369742d626f756e646564292e88202d204174206d6f7374206f6e652062616c616e6365206f7065726174696f6e732e18202d2044423ae02020202d206050202b2053602073746f72616765206d75746174696f6e732028636f64656320636f6d706c657869747920604f2831296029c02020202d204f6e652073746f7261676520726561642028636f64656320636f6d706c657869747920604f28502960292ec42020202d204f6e652073746f726167652077726974652028636f64656320636f6d706c657869747920604f28532960292ed42020202d204f6e652073746f726167652d6578697374732028604964656e746974794f663a3a636f6e7461696e735f6b657960292e302023203c2f7765696768743e38636c6561725f6964656e7469747900483d0120436c65617220616e206163636f756e742773206964656e7469747920696e666f20616e6420616c6c207375622d6163636f756e747320616e642072657475726e20616c6c206465706f736974732e00f0205061796d656e743a20416c6c2072657365727665642062616c616e636573206f6e20746865206163636f756e74206172652072657475726e65642e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061207265676973746572656428206964656e746974792e009c20456d69747320604964656e74697479436c656172656460206966207375636365737366756c2e002c2023203c7765696768743e44202d20604f2852202b2053202b20582960d02020202d20776865726520605260207265676973747261722d636f756e742028676f7665726e616e63652d626f756e646564292ed82020202d2077686572652060536020737562732d636f756e742028686172642d20616e64206465706f7369742d626f756e646564292e25012020202d20776865726520605860206164646974696f6e616c2d6669656c642d636f756e7420286465706f7369742d626f756e64656420616e6420636f64652d626f756e646564292e8c202d204f6e652062616c616e63652d756e72657365727665206f7065726174696f6e2ecc202d206032602073746f7261676520726561647320616e64206053202b2032602073746f726167652064656c6574696f6e732e34202d204f6e65206576656e742e302023203c2f7765696768743e44726571756573745f6a756467656d656e7408247265675f696e6465785c436f6d706163743c526567697374726172496e6465783e1c6d61785f66656554436f6d706163743c42616c616e63654f663c543e3e5c9820526571756573742061206a756467656d656e742066726f6d2061207265676973747261722e005901205061796d656e743a204174206d6f737420606d61785f666565602077696c6c20626520726573657276656420666f72207061796d656e7420746f2074686520726567697374726172206966206a756467656d656e741c20676976656e2e00390120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061542072656769737465726564206964656e746974792e002101202d20607265675f696e646578603a2054686520696e646578206f6620746865207265676973747261722077686f7365206a756467656d656e74206973207265717565737465642e5901202d20606d61785f666565603a20546865206d6178696d756d206665652074686174206d617920626520706169642e20546869732073686f756c64206a757374206265206175746f2d706f70756c617465642061733a0034206060606e6f636f6d70696c65bc2053656c663a3a7265676973747261727328292e676574287265675f696e646578292e756e7772617028292e666565102060606000a820456d69747320604a756467656d656e7452657175657374656460206966207375636365737366756c2e002c2023203c7765696768743e38202d20604f2852202b205829602e84202d204f6e652062616c616e63652d72657365727665206f7065726174696f6e2ebc202d2053746f726167653a2031207265616420604f285229602c2031206d757461746520604f2858202b205229602e34202d204f6e65206576656e742e302023203c2f7765696768743e3863616e63656c5f7265717565737404247265675f696e64657838526567697374726172496e646578446c2043616e63656c20612070726576696f757320726571756573742e00fc205061796d656e743a20412070726576696f75736c79207265736572766564206465706f7369742069732072657475726e6564206f6e20737563636573732e00390120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061542072656769737465726564206964656e746974792e004901202d20607265675f696e646578603a2054686520696e646578206f6620746865207265676973747261722077686f7365206a756467656d656e74206973206e6f206c6f6e676572207265717565737465642e00b020456d69747320604a756467656d656e74556e72657175657374656460206966207375636365737366756c2e002c2023203c7765696768743e38202d20604f2852202b205829602e84202d204f6e652062616c616e63652d72657365727665206f7065726174696f6e2e8c202d204f6e652073746f72616765206d75746174696f6e20604f2852202b205829602e30202d204f6e65206576656e74302023203c2f7765696768743e1c7365745f6665650814696e6465785c436f6d706163743c526567697374726172496e6465783e0c66656554436f6d706163743c42616c616e63654f663c543e3e341d0120536574207468652066656520726571756972656420666f722061206a756467656d656e7420746f206265207265717565737465642066726f6d2061207265676973747261722e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74a4206f6620746865207265676973747261722077686f736520696e6465782069732060696e646578602e00f8202d2060696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f73652066656520697320746f206265207365742e58202d2060666565603a20746865206e6577206665652e002c2023203c7765696768743e28202d20604f285229602e7c202d204f6e652073746f72616765206d75746174696f6e20604f285229602ee8202d2042656e63686d61726b3a20372e333135202b2052202a20302e33323920c2b57320286d696e207371756172657320616e616c7973697329302023203c2f7765696768743e387365745f6163636f756e745f69640814696e6465785c436f6d706163743c526567697374726172496e6465783e0c6e657730543a3a4163636f756e74496434c0204368616e676520746865206163636f756e74206173736f63696174656420776974682061207265676973747261722e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74a4206f6620746865207265676973747261722077686f736520696e6465782069732060696e646578602e00f8202d2060696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f73652066656520697320746f206265207365742e74202d20606e6577603a20746865206e6577206163636f756e742049442e002c2023203c7765696768743e28202d20604f285229602e7c202d204f6e652073746f72616765206d75746174696f6e20604f285229602ee4202d2042656e63686d61726b3a20382e383233202b2052202a20302e333220c2b57320286d696e207371756172657320616e616c7973697329302023203c2f7765696768743e287365745f6669656c64730814696e6465785c436f6d706163743c526567697374726172496e6465783e186669656c6473384964656e746974794669656c647334ac2053657420746865206669656c6420696e666f726d6174696f6e20666f722061207265676973747261722e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74a4206f6620746865207265676973747261722077686f736520696e6465782069732060696e646578602e00f8202d2060696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f73652066656520697320746f206265207365742e1101202d20606669656c6473603a20746865206669656c64732074686174207468652072656769737472617220636f6e6365726e73207468656d73656c76657320776974682e002c2023203c7765696768743e28202d20604f285229602e7c202d204f6e652073746f72616765206d75746174696f6e20604f285229602ee8202d2042656e63686d61726b3a20372e343634202b2052202a20302e33323520c2b57320286d696e207371756172657320616e616c7973697329302023203c2f7765696768743e4470726f766964655f6a756467656d656e740c247265675f696e6465785c436f6d706163743c526567697374726172496e6465783e187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365246a756467656d656e745c4a756467656d656e743c42616c616e63654f663c543e3e4cbc2050726f766964652061206a756467656d656e7420666f7220616e206163636f756e742773206964656e746974792e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74b4206f6620746865207265676973747261722077686f736520696e64657820697320607265675f696e646578602e002501202d20607265675f696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f7365206a756467656d656e74206973206265696e67206d6164652e5901202d2060746172676574603a20746865206163636f756e742077686f7365206964656e7469747920746865206a756467656d656e742069732075706f6e2e2054686973206d75737420626520616e206163636f756e74782020207769746820612072656769737465726564206964656e746974792e4d01202d20606a756467656d656e74603a20746865206a756467656d656e74206f662074686520726567697374726172206f6620696e64657820607265675f696e646578602061626f75742060746172676574602e009820456d69747320604a756467656d656e74476976656e60206966207375636365737366756c2e002c2023203c7765696768743e38202d20604f2852202b205829602e88202d204f6e652062616c616e63652d7472616e73666572206f7065726174696f6e2e98202d20557020746f206f6e65206163636f756e742d6c6f6f6b7570206f7065726174696f6e2ebc202d2053746f726167653a2031207265616420604f285229602c2031206d757461746520604f2852202b205829602e34202d204f6e65206576656e742e302023203c2f7765696768743e346b696c6c5f6964656e7469747904187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263654c45012052656d6f766520616e206163636f756e742773206964656e7469747920616e64207375622d6163636f756e7420696e666f726d6174696f6e20616e6420736c61736820746865206465706f736974732e006501205061796d656e743a2052657365727665642062616c616e6365732066726f6d20607365745f737562736020616e6420607365745f6964656e74697479602061726520736c617368656420616e642068616e646c656420627949012060536c617368602e20566572696669636174696f6e2072657175657374206465706f7369747320617265206e6f742072657475726e65643b20746865792073686f756c642062652063616e63656c6c656484206d616e75616c6c79207573696e67206063616e63656c5f72657175657374602e00fc20546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206d617463682060543a3a466f7263654f726967696e602e005901202d2060746172676574603a20746865206163636f756e742077686f7365206964656e7469747920746865206a756467656d656e742069732075706f6e2e2054686973206d75737420626520616e206163636f756e74782020207769746820612072656769737465726564206964656e746974792e009820456d69747320604964656e746974794b696c6c656460206966207375636365737366756c2e002c2023203c7765696768743e48202d20604f2852202b2053202b205829602e84202d204f6e652062616c616e63652d72657365727665206f7065726174696f6e2e74202d206053202b2032602073746f72616765206d75746174696f6e732e34202d204f6e65206576656e742e302023203c2f7765696768743e1c6164645f737562080c7375628c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365106461746110446174611cb0204164642074686520676976656e206163636f756e7420746f207468652073656e646572277320737562732e006101205061796d656e743a2042616c616e636520726573657276656420627920612070726576696f757320607365745f73756273602063616c6c20666f72206f6e65207375622077696c6c2062652072657061747269617465643c20746f207468652073656e6465722e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573742068617665206120726567697374657265645c20737562206964656e74697479206f662060737562602e2872656e616d655f737562080c7375628c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651064617461104461746110d020416c74657220746865206173736f636961746564206e616d65206f662074686520676976656e207375622d6163636f756e742e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573742068617665206120726567697374657265645c20737562206964656e74697479206f662060737562602e2872656d6f76655f737562040c7375628c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651cc42052656d6f76652074686520676976656e206163636f756e742066726f6d207468652073656e646572277320737562732e006101205061796d656e743a2042616c616e636520726573657276656420627920612070726576696f757320607365745f73756273602063616c6c20666f72206f6e65207375622077696c6c2062652072657061747269617465643c20746f207468652073656e6465722e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573742068617665206120726567697374657265645c20737562206964656e74697479206f662060737562602e20717569745f7375620028902052656d6f7665207468652073656e6465722061732061207375622d6163636f756e742e006101205061796d656e743a2042616c616e636520726573657276656420627920612070726576696f757320607365745f73756273602063616c6c20666f72206f6e65207375622077696c6c206265207265706174726961746564b820746f207468652073656e64657220282a6e6f742a20746865206f726967696e616c206465706f7369746f72292e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d757374206861766520612072656769737465726564402073757065722d6964656e746974792e004901204e4f54453a20546869732073686f756c64206e6f74206e6f726d616c6c7920626520757365642c206275742069732070726f766964656420696e207468652063617365207468617420746865206e6f6e2d150120636f6e74726f6c6c6572206f6620616e206163636f756e74206973206d616c6963696f75736c7920726567697374657265642061732061207375622d6163636f756e742e01282c4964656e7469747953657404244163636f756e7449640411012041206e616d652077617320736574206f72207265736574202877686963682077696c6c2072656d6f766520616c6c206a756467656d656e7473292e205c5b77686f5c5d3c4964656e74697479436c656172656408244163636f756e7449641c42616c616e63650415012041206e616d652077617320636c65617265642c20616e642074686520676976656e2062616c616e63652072657475726e65642e205c5b77686f2c206465706f7369745c5d384964656e746974794b696c6c656408244163636f756e7449641c42616c616e6365040d012041206e616d65207761732072656d6f76656420616e642074686520676976656e2062616c616e636520736c61736865642e205c5b77686f2c206465706f7369745c5d484a756467656d656e7452657175657374656408244163636f756e74496438526567697374726172496e6465780405012041206a756467656d656e74207761732061736b65642066726f6d2061207265676973747261722e205c5b77686f2c207265676973747261725f696e6465785c5d504a756467656d656e74556e72657175657374656408244163636f756e74496438526567697374726172496e64657804f02041206a756467656d656e74207265717565737420776173207265747261637465642e205c5b77686f2c207265676973747261725f696e6465785c5d384a756467656d656e74476976656e08244163636f756e74496438526567697374726172496e6465780409012041206a756467656d656e742077617320676976656e2062792061207265676973747261722e205c5b7461726765742c207265676973747261725f696e6465785c5d3852656769737472617241646465640438526567697374726172496e64657804ac204120726567697374726172207761732061646465642e205c5b7265676973747261725f696e6465785c5d405375624964656e7469747941646465640c244163636f756e744964244163636f756e7449641c42616c616e63650455012041207375622d6964656e746974792077617320616464656420746f20616e206964656e7469747920616e6420746865206465706f73697420706169642e205c5b7375622c206d61696e2c206465706f7369745c5d485375624964656e7469747952656d6f7665640c244163636f756e744964244163636f756e7449641c42616c616e6365080d012041207375622d6964656e74697479207761732072656d6f7665642066726f6d20616e206964656e7469747920616e6420746865206465706f7369742066726565642e5c205c5b7375622c206d61696e2c206465706f7369745c5d485375624964656e746974795265766f6b65640c244163636f756e744964244163636f756e7449641c42616c616e6365081d012041207375622d6964656e746974792077617320636c65617265642c20616e642074686520676976656e206465706f7369742072657061747269617465642066726f6d207468652901206d61696e206964656e74697479206163636f756e7420746f20746865207375622d6964656e74697479206163636f756e742e205c5b7375622c206d61696e2c206465706f7369745c5d183042617369634465706f7369743042616c616e63654f663c543e40007db52a2f000000000000000000000004d82054686520616d6f756e742068656c64206f6e206465706f73697420666f7220612072656769737465726564206964656e746974792e304669656c644465706f7369743042616c616e63654f663c543e4000cd5627000000000000000000000000042d012054686520616d6f756e742068656c64206f6e206465706f73697420706572206164646974696f6e616c206669656c6420666f7220612072656769737465726564206964656e746974792e445375624163636f756e744465706f7369743042616c616e63654f663c543e4080f884b02e00000000000000000000000c65012054686520616d6f756e742068656c64206f6e206465706f73697420666f7220612072656769737465726564207375626163636f756e742e20546869732073686f756c64206163636f756e7420666f7220746865206661637471012074686174206f6e652073746f72616765206974656d27732076616c75652077696c6c20696e637265617365206279207468652073697a65206f6620616e206163636f756e742049442c20616e642074686572652077696c6c206265290120616e6f746865722074726965206974656d2077686f73652076616c7565206973207468652073697a65206f6620616e206163636f756e7420494420706c75732033322062797465732e384d61785375624163636f756e74730c7533321064000000040d0120546865206d6178696d756d206e756d626572206f66207375622d6163636f756e747320616c6c6f77656420706572206964656e746966696564206163636f756e742e4c4d61784164646974696f6e616c4669656c64730c7533321064000000086501204d6178696d756d206e756d626572206f66206164646974696f6e616c206669656c64732074686174206d61792062652073746f72656420696e20616e2049442e204e656564656420746f20626f756e642074686520492f4fe020726571756972656420746f2061636365737320616e206964656e746974792c206275742063616e2062652070726574747920686967682e344d6178526567697374726172730c7533321014000000085101204d61786d696d756d206e756d626572206f66207265676973747261727320616c6c6f77656420696e207468652073797374656d2e204e656564656420746f20626f756e642074686520636f6d706c65786974797c206f662c20652e672e2c207570646174696e67206a756467656d656e74732e4048546f6f4d616e795375624163636f756e7473046020546f6f206d616e7920737562732d6163636f756e74732e204e6f74466f756e640454204163636f756e742069736e277420666f756e642e204e6f744e616d65640454204163636f756e742069736e2774206e616d65642e28456d707479496e646578043420456d70747920696e6465782e284665654368616e676564044020466565206973206368616e6765642e284e6f4964656e74697479044c204e6f206964656e7469747920666f756e642e3c537469636b794a756467656d656e74044820537469636b79206a756467656d656e742e384a756467656d656e74476976656e0444204a756467656d656e7420676976656e2e40496e76616c69644a756467656d656e74044c20496e76616c6964206a756467656d656e742e30496e76616c6964496e64657804582054686520696e64657820697320696e76616c69642e34496e76616c6964546172676574045c205468652074617267657420697320696e76616c69642e34546f6f4d616e794669656c6473047020546f6f206d616e79206164646974696f6e616c206669656c64732e44546f6f4d616e795265676973747261727304ec204d6178696d756d20616d6f756e74206f66207265676973747261727320726561636865642e2043616e6e6f742061646420616e79206d6f72652e38416c7265616479436c61696d65640474204163636f756e7420494420697320616c7265616479206e616d65642e184e6f7453756204742053656e646572206973206e6f742061207375622d6163636f756e742e204e6f744f776e6564048c205375622d6163636f756e742069736e2774206f776e65642062792073656e6465722e1c1450726f7879011450726f7879081c50726f7869657301010530543a3a4163636f756e7449644501285665633c50726f7879446566696e6974696f6e3c543a3a4163636f756e7449642c20543a3a50726f7879547970652c20543a3a426c6f636b4e756d6265723e3e2c0a2042616c616e63654f663c543e29004400000000000000000000000000000000000845012054686520736574206f66206163636f756e742070726f786965732e204d61707320746865206163636f756e74207768696368206861732064656c65676174656420746f20746865206163636f756e7473210120776869636820617265206265696e672064656c65676174656420746f2c20746f67657468657220776974682074686520616d6f756e742068656c64206f6e206465706f7369742e34416e6e6f756e63656d656e747301010530543a3a4163636f756e7449643d01285665633c416e6e6f756e63656d656e743c543a3a4163636f756e7449642c2043616c6c486173684f663c543e2c20543a3a426c6f636b4e756d6265723e3e2c0a2042616c616e63654f663c543e290044000000000000000000000000000000000004ac2054686520616e6e6f756e63656d656e7473206d616465206279207468652070726f787920286b6579292e01281470726f78790c107265616c30543a3a4163636f756e74496440666f7263655f70726f78795f74797065504f7074696f6e3c543a3a50726f7879547970653e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e3c51012044697370617463682074686520676976656e206063616c6c602066726f6d20616e206163636f756e742074686174207468652073656e64657220697320617574686f726973656420666f72207468726f7567683420606164645f70726f7879602e00ac2052656d6f76657320616e7920636f72726573706f6e64696e6720616e6e6f756e63656d656e742873292e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e6501202d2060666f7263655f70726f78795f74797065603a2053706563696679207468652065786163742070726f7879207479706520746f206265207573656420616e6420636865636b656420666f7220746869732063616c6c2ed4202d206063616c6c603a205468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e246164645f70726f78790c2064656c656761746530543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d62657234490120526567697374657220612070726f7879206163636f756e7420666f72207468652073656e64657220746861742069732061626c6520746f206d616b652063616c6c73206f6e2069747320626568616c662e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1501202d206070726f7879603a20546865206163636f756e74207468617420746865206063616c6c65726020776f756c64206c696b6520746f206d616b6520612070726f78792e0101202d206070726f78795f74797065603a20546865207065726d697373696f6e7320616c6c6f77656420666f7220746869732070726f7879206163636f756e742e5101202d206064656c6179603a2054686520616e6e6f756e63656d656e7420706572696f64207265717569726564206f662074686520696e697469616c2070726f78792e2057696c6c2067656e6572616c6c7920626518207a65726f2e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e3072656d6f76655f70726f78790c2064656c656761746530543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d6265722cac20556e726567697374657220612070726f7879206163636f756e7420666f72207468652073656e6465722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a2901202d206070726f7879603a20546865206163636f756e74207468617420746865206063616c6c65726020776f756c64206c696b6520746f2072656d6f766520617320612070726f78792e4501202d206070726f78795f74797065603a20546865207065726d697373696f6e732063757272656e746c7920656e61626c656420666f72207468652072656d6f7665642070726f7879206163636f756e742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e3872656d6f76655f70726f786965730028b820556e726567697374657220616c6c2070726f7879206163636f756e747320666f72207468652073656e6465722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901205741524e494e473a2054686973206d61792062652063616c6c6564206f6e206163636f756e747320637265617465642062792060616e6f6e796d6f7573602c20686f776576657220696620646f6e652c207468656e5d012074686520756e726573657276656420666565732077696c6c20626520696e61636365737369626c652e202a2a416c6c2061636365737320746f2074686973206163636f756e742077696c6c206265206c6f73742e2a2a002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e24616e6f6e796d6f75730c2870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d62657214696e6465780c7531365c3d0120537061776e2061206672657368206e6577206163636f756e7420746861742069732067756172616e7465656420746f206265206f746865727769736520696e61636365737369626c652c20616e64010120696e697469616c697a65206974207769746820612070726f7879206f66206070726f78795f747970656020666f7220606f726967696e602073656e6465722e0070205265717569726573206120605369676e656460206f726967696e2e005501202d206070726f78795f74797065603a205468652074797065206f66207468652070726f78792074686174207468652073656e6465722077696c6c2062652072656769737465726564206173206f766572207468655101206e6577206163636f756e742e20546869732077696c6c20616c6d6f737420616c7761797320626520746865206d6f7374207065726d697373697665206050726f7879547970656020706f737369626c6520746f7c20616c6c6f7720666f72206d6178696d756d20666c65786962696c6974792e5501202d2060696e646578603a204120646973616d626967756174696f6e20696e6465782c20696e206361736520746869732069732063616c6c6564206d756c7469706c652074696d657320696e207468652073616d656101207472616e73616374696f6e2028652e672e207769746820607574696c6974793a3a626174636860292e20556e6c65737320796f75277265207573696e67206062617463686020796f752070726f6261626c79206a757374442077616e7420746f20757365206030602e5101202d206064656c6179603a2054686520616e6e6f756e63656d656e7420706572696f64207265717569726564206f662074686520696e697469616c2070726f78792e2057696c6c2067656e6572616c6c7920626518207a65726f2e005501204661696c73207769746820604475706c69636174656020696620746869732068617320616c7265616479206265656e2063616c6c656420696e2074686973207472616e73616374696f6e2c2066726f6d207468659c2073616d652073656e6465722c2077697468207468652073616d6520706172616d65746572732e00e8204661696c732069662074686572652061726520696e73756666696369656e742066756e647320746f2070617920666f72206465706f7369742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e9020544f444f3a204d69676874206265206f76657220636f756e74696e6720312072656164386b696c6c5f616e6f6e796d6f7573141c737061776e657230543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f78795479706514696e6465780c753136186865696768745c436f6d706163743c543a3a426c6f636b4e756d6265723e246578745f696e64657830436f6d706163743c7533323e50b82052656d6f76657320612070726576696f75736c7920737061776e656420616e6f6e796d6f75732070726f78792e004d01205741524e494e473a202a2a416c6c2061636365737320746f2074686973206163636f756e742077696c6c206265206c6f73742e2a2a20416e792066756e64732068656c6420696e2069742077696c6c2062653820696e61636365737369626c652e005d01205265717569726573206120605369676e656460206f726967696e2c20616e64207468652073656e646572206163636f756e74206d7573742068617665206265656e206372656174656420627920612063616c6c20746fac2060616e6f6e796d6f757360207769746820636f72726573706f6e64696e6720706172616d65746572732e005101202d2060737061776e6572603a20546865206163636f756e742074686174206f726967696e616c6c792063616c6c65642060616e6f6e796d6f75736020746f206372656174652074686973206163636f756e742e5101202d2060696e646578603a2054686520646973616d626967756174696f6e20696e646578206f726967696e616c6c792070617373656420746f2060616e6f6e796d6f7573602e2050726f6261626c79206030602e0501202d206070726f78795f74797065603a205468652070726f78792074797065206f726967696e616c6c792070617373656420746f2060616e6f6e796d6f7573602e4101202d2060686569676874603a2054686520686569676874206f662074686520636861696e207768656e207468652063616c6c20746f2060616e6f6e796d6f757360207761732070726f6365737365642e4d01202d20606578745f696e646578603a205468652065787472696e73696320696e64657820696e207768696368207468652063616c6c20746f2060616e6f6e796d6f757360207761732070726f6365737365642e004d01204661696c73207769746820604e6f5065726d697373696f6e6020696e2063617365207468652063616c6c6572206973206e6f7420612070726576696f75736c79206372656174656420616e6f6e796d6f7573f4206163636f756e742077686f73652060616e6f6e796d6f7573602063616c6c2068617320636f72726573706f6e64696e6720706172616d65746572732e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e20616e6e6f756e636508107265616c30543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e540901205075626c697368207468652068617368206f6620612070726f78792d63616c6c20746861742077696c6c206265206d61646520696e20746865206675747572652e0061012054686973206d7573742062652063616c6c656420736f6d65206e756d626572206f6620626c6f636b73206265666f72652074686520636f72726573706f6e64696e67206070726f78796020697320617474656d707465642901206966207468652064656c6179206173736f6369617465642077697468207468652070726f78792072656c6174696f6e736869702069732067726561746572207468616e207a65726f2e001501204e6f206d6f7265207468616e20604d617850656e64696e676020616e6e6f756e63656d656e7473206d6179206265206d61646520617420616e79206f6e652074696d652e000d0120546869732077696c6c2074616b652061206465706f736974206f662060416e6e6f756e63656d656e744465706f736974466163746f72602061732077656c6c2061731d012060416e6e6f756e63656d656e744465706f736974426173656020696620746865726520617265206e6f206f746865722070656e64696e6720616e6e6f756e63656d656e74732e00290120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420612070726f7879206f6620607265616c602e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e1901202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e4c72656d6f76655f616e6e6f756e63656d656e7408107265616c30543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e40742052656d6f7665206120676976656e20616e6e6f756e63656d656e742e005d01204d61792062652063616c6c656420627920612070726f7879206163636f756e7420746f2072656d6f766520612063616c6c20746865792070726576696f75736c7920616e6e6f756e63656420616e642072657475726e3420746865206465706f7369742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e1901202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e4c72656a6563745f616e6e6f756e63656d656e74082064656c656761746530543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e40b42052656d6f76652074686520676976656e20616e6e6f756e63656d656e74206f6620612064656c65676174652e006501204d61792062652063616c6c6564206279206120746172676574202870726f7869656429206163636f756e7420746f2072656d6f766520612063616c6c2074686174206f6e65206f662074686569722064656c656761746573290120286064656c656761746560292068617320616e6e6f756e63656420746865792077616e7420746f20657865637574652e20546865206465706f7369742069732072657475726e65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733af8202d206064656c6567617465603a20546865206163636f756e7420746861742070726576696f75736c7920616e6e6f756e636564207468652063616c6c2ec0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e3c70726f78795f616e6e6f756e636564102064656c656761746530543a3a4163636f756e744964107265616c30543a3a4163636f756e74496440666f7263655f70726f78795f74797065504f7074696f6e3c543a3a50726f7879547970653e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e4451012044697370617463682074686520676976656e206063616c6c602066726f6d20616e206163636f756e742074686174207468652073656e64657220697320617574686f72697a656420666f72207468726f7567683420606164645f70726f7879602e00ac2052656d6f76657320616e7920636f72726573706f6e64696e6720616e6e6f756e63656d656e742873292e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e6501202d2060666f7263655f70726f78795f74797065603a2053706563696679207468652065786163742070726f7879207479706520746f206265207573656420616e6420636865636b656420666f7220746869732063616c6c2ed4202d206063616c6c603a205468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e010c3450726f7879457865637574656404384469737061746368526573756c7404ec20412070726f78792077617320657865637574656420636f72726563746c792c20776974682074686520676976656e205c5b726573756c745c5d2e40416e6f6e796d6f75734372656174656410244163636f756e744964244163636f756e7449642450726f7879547970650c75313608ec20416e6f6e796d6f7573206163636f756e7420686173206265656e2063726561746564206279206e65772070726f7879207769746820676976656e690120646973616d626967756174696f6e20696e64657820616e642070726f787920747970652e205c5b616e6f6e796d6f75732c2077686f2c2070726f78795f747970652c20646973616d626967756174696f6e5f696e6465785c5d24416e6e6f756e6365640c244163636f756e744964244163636f756e744964104861736804510120416e20616e6e6f756e63656d656e742077617320706c6163656420746f206d616b6520612063616c6c20696e20746865206675747572652e205c5b7265616c2c2070726f78792c2063616c6c5f686173685c5d184050726f78794465706f736974426173653042616c616e63654f663c543e400084b2952e000000000000000000000010110120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720612070726f78792e00010120546869732069732068656c6420666f7220616e206164646974696f6e616c2073746f72616765206974656d2077686f73652076616c75652073697a652069732501206073697a656f662842616c616e6365296020627974657320616e642077686f7365206b65792073697a65206973206073697a656f66284163636f756e74496429602062797465732e4850726f78794465706f736974466163746f723042616c616e63654f663c543e408066ab1300000000000000000000000014bc2054686520616d6f756e74206f662063757272656e6379206e6565646564207065722070726f78792061646465642e00690120546869732069732068656c6420666f7220616464696e6720333220627974657320706c757320616e20696e7374616e6365206f66206050726f78795479706560206d6f726520696e746f2061207072652d6578697374696e6761012073746f726167652076616c75652e20546875732c207768656e20636f6e6669677572696e67206050726f78794465706f736974466163746f7260206f6e652073686f756c642074616b6520696e746f206163636f756e74c020603332202b2070726f78795f747970652e656e636f646528292e6c656e282960206279746573206f6620646174612e284d617850726f786965730c75313608200004f020546865206d6178696d756d20616d6f756e74206f662070726f7869657320616c6c6f77656420666f7220612073696e676c65206163636f756e742e284d617850656e64696e670c753332102000000004450120546865206d6178696d756d20616d6f756e74206f662074696d652d64656c6179656420616e6e6f756e63656d656e747320746861742061726520616c6c6f77656420746f2062652070656e64696e672e5c416e6e6f756e63656d656e744465706f736974426173653042616c616e63654f663c543e400084b2952e00000000000000000000000c310120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720616e20616e6e6f756e63656d656e742e00690120546869732069732068656c64207768656e2061206e65772073746f72616765206974656d20686f6c64696e672061206042616c616e636560206973206372656174656420287479706963616c6c79203136206279746573292e64416e6e6f756e63656d656e744465706f736974466163746f723042616c616e63654f663c543e4000cd562700000000000000000000000010d42054686520616d6f756e74206f662063757272656e6379206e65656465642070657220616e6e6f756e63656d656e74206d6164652e00590120546869732069732068656c6420666f7220616464696e6720616e20604163636f756e744964602c2060486173686020616e642060426c6f636b4e756d6265726020287479706963616c6c79203638206279746573298c20696e746f2061207072652d6578697374696e672073746f726167652076616c75652e201c546f6f4d616e790425012054686572652061726520746f6f206d616e792070726f786965732072656769737465726564206f7220746f6f206d616e7920616e6e6f756e63656d656e74732070656e64696e672e204e6f74466f756e6404782050726f787920726567697374726174696f6e206e6f7420666f756e642e204e6f7450726f787904d02053656e646572206973206e6f7420612070726f7879206f6620746865206163636f756e7420746f2062652070726f786965642e2c556e70726f787961626c6504250120412063616c6c20776869636820697320696e636f6d70617469626c652077697468207468652070726f7879207479706527732066696c7465722077617320617474656d707465642e244475706c69636174650470204163636f756e7420697320616c726561647920612070726f78792e304e6f5065726d697373696f6e0419012043616c6c206d6179206e6f74206265206d6164652062792070726f78792062656361757365206974206d617920657363616c617465206974732070726976696c656765732e2c556e616e6e6f756e63656404d420416e6e6f756e63656d656e742c206966206d61646520617420616c6c2c20776173206d61646520746f6f20726563656e746c792e2c4e6f53656c6650726f787904682043616e6e6f74206164642073656c662061732070726f78792e1d204d756c746973696701204d756c746973696708244d756c74697369677300020530543a3a4163636f756e744964205b75383b2033325dd04d756c74697369673c543a3a426c6f636b4e756d6265722c2042616c616e63654f663c543e2c20543a3a4163636f756e7449643e02040004942054686520736574206f66206f70656e206d756c7469736967206f7065726174696f6e732e1443616c6c73000106205b75383b2033325da0284f706171756543616c6c2c20543a3a4163636f756e7449642c2042616c616e63654f663c543e290004000001105061735f6d756c74695f7468726573686f6c645f3108446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e40550120496d6d6564696174656c792064697370617463682061206d756c74692d7369676e61747572652063616c6c207573696e6720612073696e676c6520617070726f76616c2066726f6d207468652063616c6c65722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e004101202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f206172652070617274206f66207468650501206d756c74692d7369676e61747572652c2062757420646f206e6f7420706172746963697061746520696e2074686520617070726f76616c2070726f636573732e8c202d206063616c6c603a205468652063616c6c20746f2062652065786563757465642e00bc20526573756c74206973206571756976616c656e7420746f20746865206469737061746368656420726573756c742e002c2023203c7765696768743e1d01204f285a202b204329207768657265205a20697320746865206c656e677468206f66207468652063616c6c20616e6420432069747320657865637574696f6e207765696768742e80202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d48202d204442205765696768743a204e6f6e654c202d20506c75732043616c6c20576569676874302023203c2f7765696768743e2061735f6d756c746918247468726573686f6c640c753136446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e3c6d617962655f74696d65706f696e74844f7074696f6e3c54696d65706f696e743c543a3a426c6f636b4e756d6265723e3e1063616c6c284f706171756543616c6c2873746f72655f63616c6c10626f6f6c286d61785f77656967687418576569676874b8590120526567697374657220617070726f76616c20666f72206120646973706174636820746f206265206d6164652066726f6d20612064657465726d696e697374696320636f6d706f73697465206163636f756e74206966fc20617070726f766564206279206120746f74616c206f6620607468726573686f6c64202d203160206f6620606f746865725f7369676e61746f72696573602e00b42049662074686572652061726520656e6f7567682c207468656e206469737061746368207468652063616c6c2e003101205061796d656e743a20604465706f73697442617365602077696c6c20626520726573657276656420696620746869732069732074686520666972737420617070726f76616c2c20706c7573410120607468726573686f6c64602074696d657320604465706f736974466163746f72602e2049742069732072657475726e6564206f6e636520746869732064697370617463682068617070656e73206f72382069732063616e63656c6c65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901202d20607468726573686f6c64603a2054686520746f74616c206e756d626572206f6620617070726f76616c7320666f722074686973206469737061746368206265666f72652069742069732065786563757465642e4501202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f2063616e20617070726f76652074686973702064697370617463682e204d6179206e6f7420626520656d7074792e5d01202d20606d617962655f74696d65706f696e74603a20496620746869732069732074686520666972737420617070726f76616c2c207468656e2074686973206d75737420626520604e6f6e65602e2049662069742069735501206e6f742074686520666972737420617070726f76616c2c207468656e206974206d7573742062652060536f6d65602c2077697468207468652074696d65706f696e742028626c6f636b206e756d62657220616e64d8207472616e73616374696f6e20696e64657829206f662074686520666972737420617070726f76616c207472616e73616374696f6e2e8c202d206063616c6c603a205468652063616c6c20746f2062652065786563757465642e002101204e4f54453a20556e6c6573732074686973206973207468652066696e616c20617070726f76616c2c20796f752077696c6c2067656e6572616c6c792077616e7420746f207573651d012060617070726f76655f61735f6d756c74696020696e73746561642c2073696e6365206974206f6e6c7920726571756972657320612068617368206f66207468652063616c6c2e005d0120526573756c74206973206571756976616c656e7420746f20746865206469737061746368656420726573756c7420696620607468726573686f6c64602069732065786163746c79206031602e204f74686572776973655901206f6e20737563636573732c20726573756c7420697320604f6b6020616e642074686520726573756c742066726f6d2074686520696e746572696f722063616c6c2c206966206974207761732065786563757465642ce0206d617920626520666f756e6420696e20746865206465706f736974656420604d756c7469736967457865637574656460206576656e742e002c2023203c7765696768743e54202d20604f2853202b205a202b2043616c6c29602ed0202d20557020746f206f6e652062616c616e63652d72657365727665206f7220756e72657365727665206f7065726174696f6e2e4101202d204f6e6520706173737468726f756768206f7065726174696f6e2c206f6e6520696e736572742c20626f746820604f285329602077686572652060536020697320746865206e756d626572206f6649012020207369676e61746f726965732e206053602069732063617070656420627920604d61785369676e61746f72696573602c207769746820776569676874206265696e672070726f706f7274696f6e616c2e2501202d204f6e652063616c6c20656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285a296020776865726520605a602069732074782d6c656e2ec0202d204f6e6520656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285329602ed8202d20557020746f206f6e652062696e6172792073656172636820616e6420696e736572742028604f286c6f6753202b20532960292efc202d20492f4f3a2031207265616420604f285329602c20757020746f2031206d757461746520604f285329602e20557020746f206f6e652072656d6f76652e34202d204f6e65206576656e742e70202d2054686520776569676874206f6620746865206063616c6c602e3101202d2053746f726167653a20696e7365727473206f6e65206974656d2c2076616c75652073697a6520626f756e64656420627920604d61785369676e61746f72696573602c20776974682061902020206465706f7369742074616b656e20666f7220697473206c69666574696d65206f66b4202020604465706f73697442617365202b207468726573686f6c64202a204465706f736974466163746f72602e80202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743a250120202020202d2052656164733a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c2043616c6c7320286966206073746f72655f63616c6c6029290120202020202d205772697465733a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c2043616c6c7320286966206073746f72655f63616c6c60294c202d20506c75732043616c6c20576569676874302023203c2f7765696768743e40617070726f76655f61735f6d756c746914247468726573686f6c640c753136446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e3c6d617962655f74696d65706f696e74844f7074696f6e3c54696d65706f696e743c543a3a426c6f636b4e756d6265723e3e2463616c6c5f68617368205b75383b2033325d286d61785f7765696768741857656967687490590120526567697374657220617070726f76616c20666f72206120646973706174636820746f206265206d6164652066726f6d20612064657465726d696e697374696320636f6d706f73697465206163636f756e74206966fc20617070726f766564206279206120746f74616c206f6620607468726573686f6c64202d203160206f6620606f746865725f7369676e61746f72696573602e003101205061796d656e743a20604465706f73697442617365602077696c6c20626520726573657276656420696620746869732069732074686520666972737420617070726f76616c2c20706c7573410120607468726573686f6c64602074696d657320604465706f736974466163746f72602e2049742069732072657475726e6564206f6e636520746869732064697370617463682068617070656e73206f72382069732063616e63656c6c65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901202d20607468726573686f6c64603a2054686520746f74616c206e756d626572206f6620617070726f76616c7320666f722074686973206469737061746368206265666f72652069742069732065786563757465642e4501202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f2063616e20617070726f76652074686973702064697370617463682e204d6179206e6f7420626520656d7074792e5d01202d20606d617962655f74696d65706f696e74603a20496620746869732069732074686520666972737420617070726f76616c2c207468656e2074686973206d75737420626520604e6f6e65602e2049662069742069735501206e6f742074686520666972737420617070726f76616c2c207468656e206974206d7573742062652060536f6d65602c2077697468207468652074696d65706f696e742028626c6f636b206e756d62657220616e64d8207472616e73616374696f6e20696e64657829206f662074686520666972737420617070726f76616c207472616e73616374696f6e2ed0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f2062652065786563757465642e003901204e4f54453a2049662074686973206973207468652066696e616c20617070726f76616c2c20796f752077696c6c2077616e7420746f20757365206061735f6d756c74696020696e73746561642e002c2023203c7765696768743e28202d20604f285329602ed0202d20557020746f206f6e652062616c616e63652d72657365727665206f7220756e72657365727665206f7065726174696f6e2e4101202d204f6e6520706173737468726f756768206f7065726174696f6e2c206f6e6520696e736572742c20626f746820604f285329602077686572652060536020697320746865206e756d626572206f6649012020207369676e61746f726965732e206053602069732063617070656420627920604d61785369676e61746f72696573602c207769746820776569676874206265696e672070726f706f7274696f6e616c2ec0202d204f6e6520656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285329602ed8202d20557020746f206f6e652062696e6172792073656172636820616e6420696e736572742028604f286c6f6753202b20532960292efc202d20492f4f3a2031207265616420604f285329602c20757020746f2031206d757461746520604f285329602e20557020746f206f6e652072656d6f76652e34202d204f6e65206576656e742e3101202d2053746f726167653a20696e7365727473206f6e65206974656d2c2076616c75652073697a6520626f756e64656420627920604d61785369676e61746f72696573602c20776974682061902020206465706f7369742074616b656e20666f7220697473206c69666574696d65206f66b4202020604465706f73697442617365202b207468726573686f6c64202a204465706f736974466163746f72602e8c202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743abc20202020202d20526561643a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745dc020202020202d2057726974653a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d302023203c2f7765696768743e3c63616e63656c5f61735f6d756c746910247468726573686f6c640c753136446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e2474696d65706f696e746454696d65706f696e743c543a3a426c6f636b4e756d6265723e2463616c6c5f68617368205b75383b2033325d6859012043616e63656c2061207072652d6578697374696e672c206f6e2d676f696e67206d756c7469736967207472616e73616374696f6e2e20416e79206465706f7369742072657365727665642070726576696f75736c79c820666f722074686973206f7065726174696f6e2077696c6c20626520756e7265736572766564206f6e20737563636573732e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901202d20607468726573686f6c64603a2054686520746f74616c206e756d626572206f6620617070726f76616c7320666f722074686973206469737061746368206265666f72652069742069732065786563757465642e4501202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f2063616e20617070726f76652074686973702064697370617463682e204d6179206e6f7420626520656d7074792e6101202d206074696d65706f696e74603a205468652074696d65706f696e742028626c6f636b206e756d62657220616e64207472616e73616374696f6e20696e64657829206f662074686520666972737420617070726f76616c7c207472616e73616374696f6e20666f7220746869732064697370617463682ed0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f2062652065786563757465642e002c2023203c7765696768743e28202d20604f285329602ed0202d20557020746f206f6e652062616c616e63652d72657365727665206f7220756e72657365727665206f7065726174696f6e2e4101202d204f6e6520706173737468726f756768206f7065726174696f6e2c206f6e6520696e736572742c20626f746820604f285329602077686572652060536020697320746865206e756d626572206f6649012020207369676e61746f726965732e206053602069732063617070656420627920604d61785369676e61746f72696573602c207769746820776569676874206265696e672070726f706f7274696f6e616c2ec0202d204f6e6520656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285329602e34202d204f6e65206576656e742e88202d20492f4f3a2031207265616420604f285329602c206f6e652072656d6f76652e74202d2053746f726167653a2072656d6f766573206f6e65206974656d2e8c202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743a190120202020202d20526561643a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c20526566756e64204163636f756e742c2043616c6c731d0120202020202d2057726974653a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c20526566756e64204163636f756e742c2043616c6c73302023203c2f7765696768743e01102c4e65774d756c74697369670c244163636f756e744964244163636f756e7449642043616c6c48617368041d012041206e6577206d756c7469736967206f7065726174696f6e2068617320626567756e2e205c5b617070726f76696e672c206d756c74697369672c2063616c6c5f686173685c5d404d756c7469736967417070726f76616c10244163636f756e7449645854696d65706f696e743c426c6f636b4e756d6265723e244163636f756e7449642043616c6c4861736808cc2041206d756c7469736967206f7065726174696f6e20686173206265656e20617070726f76656420627920736f6d656f6e652eb8205c5b617070726f76696e672c2074696d65706f696e742c206d756c74697369672c2063616c6c5f686173685c5d404d756c7469736967457865637574656414244163636f756e7449645854696d65706f696e743c426c6f636b4e756d6265723e244163636f756e7449642043616c6c48617368384469737061746368526573756c740459012041206d756c7469736967206f7065726174696f6e20686173206265656e2065786563757465642e205c5b617070726f76696e672c2074696d65706f696e742c206d756c74697369672c2063616c6c5f686173685c5d444d756c746973696743616e63656c6c656410244163636f756e7449645854696d65706f696e743c426c6f636b4e756d6265723e244163636f756e7449642043616c6c486173680461012041206d756c7469736967206f7065726174696f6e20686173206265656e2063616e63656c6c65642e205c5b63616e63656c6c696e672c2074696d65706f696e742c206d756c74697369672c2063616c6c5f686173685c5d0c2c4465706f736974426173653042616c616e63654f663c543e40008c61c52e000000000000000000000008710120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e672061206d756c746973696720657865637574696f6e206f7220746f2073746f72656c20612064697370617463682063616c6c20666f72206c617465722e344465706f736974466163746f723042616c616e63654f663c543e4000d012130000000000000000000000000455012054686520616d6f756e74206f662063757272656e6379206e65656465642070657220756e6974207468726573686f6c64207768656e206372656174696e672061206d756c746973696720657865637574696f6e2e384d61785369676e61746f726965730c75313608640004010120546865206d6178696d756d20616d6f756e74206f66207369676e61746f7269657320616c6c6f77656420666f72206120676976656e206d756c74697369672e38404d696e696d756d5468726573686f6c640480205468726573686f6c64206d7573742062652032206f7220677265617465722e3c416c7265616479417070726f76656404b02043616c6c20697320616c726561647920617070726f7665642062792074686973207369676e61746f72792e444e6f417070726f76616c734e656564656404a02043616c6c20646f65736e2774206e65656420616e7920286d6f72652920617070726f76616c732e44546f6f4665775369676e61746f7269657304ac2054686572652061726520746f6f20666577207369676e61746f7269657320696e20746865206c6973742e48546f6f4d616e795369676e61746f7269657304b02054686572652061726520746f6f206d616e79207369676e61746f7269657320696e20746865206c6973742e545369676e61746f726965734f75744f664f7264657204110120546865207369676e61746f7269657320776572652070726f7669646564206f7574206f66206f726465723b20746865792073686f756c64206265206f7264657265642e4c53656e646572496e5369676e61746f72696573041101205468652073656e6465722077617320636f6e7461696e656420696e20746865206f74686572207369676e61746f726965733b2069742073686f756c646e27742062652e204e6f74466f756e6404e0204d756c7469736967206f7065726174696f6e206e6f7420666f756e64207768656e20617474656d7074696e6720746f2063616e63656c2e204e6f744f776e6572043101204f6e6c7920746865206163636f756e742074686174206f726967696e616c6c79206372656174656420746865206d756c74697369672069732061626c6520746f2063616e63656c2069742e2c4e6f54696d65706f696e74042101204e6f2074696d65706f696e742077617320676976656e2c2079657420746865206d756c7469736967206f7065726174696f6e20697320616c726561647920756e6465727761792e3857726f6e6754696d65706f696e74043101204120646966666572656e742074696d65706f696e742077617320676976656e20746f20746865206d756c7469736967206f7065726174696f6e207468617420697320756e6465727761792e4c556e657870656374656454696d65706f696e7404f820412074696d65706f696e742077617320676976656e2c20796574206e6f206d756c7469736967206f7065726174696f6e20697320756e6465727761792e3c4d6178576569676874546f6f4c6f7704d420546865206d6178696d756d2077656967687420696e666f726d6174696f6e2070726f76696465642077617320746f6f206c6f772e34416c726561647953746f72656404a420546865206461746120746f2062652073746f72656420697320616c72656164792073746f7265642e1e20426f756e7469657301205472656173757279102c426f756e7479436f756e7401002c426f756e7479496e646578100000000004c0204e756d626572206f6620626f756e74792070726f706f73616c7320746861742068617665206265656e206d6164652e20426f756e746965730001052c426f756e7479496e646578c8426f756e74793c543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e000400047820426f756e7469657320746861742068617665206265656e206d6164652e48426f756e74794465736372697074696f6e730001052c426f756e7479496e6465781c5665633c75383e000400048020546865206465736372697074696f6e206f66206561636820626f756e74792e3c426f756e7479417070726f76616c730100405665633c426f756e7479496e6465783e040004ec20426f756e747920696e646963657320746861742068617665206265656e20617070726f76656420627574206e6f74207965742066756e6465642e01243870726f706f73655f626f756e7479081476616c756554436f6d706163743c42616c616e63654f663c543e3e2c6465736372697074696f6e1c5665633c75383e30582050726f706f73652061206e657720626f756e74792e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005501205061796d656e743a20605469705265706f72744465706f73697442617365602077696c6c2062652072657365727665642066726f6d20746865206f726967696e206163636f756e742c2061732077656c6c20617355012060446174614465706f736974506572427974656020666f722065616368206279746520696e2060726561736f6e602e2049742077696c6c20626520756e72657365727665642075706f6e20617070726f76616c2c68206f7220736c6173686564207768656e2072656a65637465642e00fc202d206063757261746f72603a205468652063757261746f72206163636f756e742077686f6d2077696c6c206d616e616765207468697320626f756e74792e68202d2060666565603a205468652063757261746f72206665652e2901202d206076616c7565603a2054686520746f74616c207061796d656e7420616d6f756e74206f66207468697320626f756e74792c2063757261746f722066656520696e636c756465642ec4202d20606465736372697074696f6e603a20546865206465736372697074696f6e206f66207468697320626f756e74792e38617070726f76655f626f756e74790424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e20610120417070726f7665206120626f756e74792070726f706f73616c2e2041742061206c617465722074696d652c2074686520626f756e74792077696c6c2062652066756e64656420616e64206265636f6d6520616374697665ac20616e6420746865206f726967696e616c206465706f7369742077696c6c2062652072657475726e65642e00b0204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a417070726f76654f726967696e602e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e3c70726f706f73655f63757261746f720c24626f756e74795f696450436f6d706163743c426f756e7479496e6465783e1c63757261746f728c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263650c66656554436f6d706163743c42616c616e63654f663c543e3e1c942041737369676e20612063757261746f7220746f20612066756e64656420626f756e74792e00b0204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a417070726f76654f726967696e602e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e40756e61737369676e5f63757261746f720424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e488020556e61737369676e2063757261746f722066726f6d206120626f756e74792e00210120546869732066756e6374696f6e2063616e206f6e6c792062652063616c6c656420627920746865206052656a6563744f726967696e602061207369676e6564206f726967696e2e00690120496620746869732066756e6374696f6e2069732063616c6c656420627920746865206052656a6563744f726967696e602c20776520617373756d652074686174207468652063757261746f72206973206d616c6963696f75730d01206f7220696e6163746976652e204173206120726573756c742c2077652077696c6c20736c617368207468652063757261746f72207768656e20706f737369626c652e00650120496620746865206f726967696e206973207468652063757261746f722c2077652074616b6520746869732061732061207369676e20746865792061726520756e61626c6520746f20646f207468656972206a6f6220616e64610120746865792077696c6c696e676c7920676976652075702e20576520636f756c6420736c617368207468656d2c2062757420666f72206e6f7720776520616c6c6f77207468656d20746f207265636f7665722074686569723901206465706f73697420616e64206578697420776974686f75742069737375652e20285765206d61792077616e7420746f206368616e67652074686973206966206974206973206162757365642e290061012046696e616c6c792c20746865206f726967696e2063616e20626520616e796f6e6520696620616e64206f6e6c79206966207468652063757261746f722069732022696e616374697665222e205468697320616c6c6f7773650120616e796f6e6520696e2074686520636f6d6d756e69747920746f2063616c6c206f7574207468617420612063757261746f72206973206e6f7420646f696e67207468656972206475652064696c6967656e63652c20616e643d012077652073686f756c64207069636b2061206e65772063757261746f722e20496e20746869732063617365207468652063757261746f722073686f756c6420616c736f20626520736c61736865642e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e386163636570745f63757261746f720424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e209820416363657074207468652063757261746f7220726f6c6520666f72206120626f756e74792e2d012041206465706f7369742077696c6c2062652072657365727665642066726f6d2063757261746f7220616e6420726566756e642075706f6e207375636365737366756c207061796f75742e0094204d6179206f6e6c792062652063616c6c65642066726f6d207468652063757261746f722e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e3061776172645f626f756e74790824626f756e74795f696450436f6d706163743c426f756e7479496e6465783e2c62656e65666963696172798c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f7572636528990120417761726420626f756e747920746f20612062656e6566696369617279206163636f756e742e205468652062656e65666963696172792077696c6c2062652061626c6520746f20636c61696d207468652066756e647320616674657220612064656c61792e00190120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265207468652063757261746f72206f66207468697320626f756e74792e008c202d2060626f756e74795f6964603a20426f756e747920494420746f2061776172642e1d01202d206062656e6566696369617279603a205468652062656e6566696369617279206163636f756e742077686f6d2077696c6c207265636569766520746865207061796f75742e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e30636c61696d5f626f756e74790424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e24f020436c61696d20746865207061796f75742066726f6d20616e206177617264656420626f756e7479206166746572207061796f75742064656c61792e00290120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265207468652062656e6566696369617279206f66207468697320626f756e74792e008c202d2060626f756e74795f6964603a20426f756e747920494420746f20636c61696d2e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e30636c6f73655f626f756e74790424626f756e74795f696450436f6d706163743c426f756e7479496e6465783e283d012043616e63656c20612070726f706f736564206f722061637469766520626f756e74792e20416c6c207468652066756e64732077696c6c2062652073656e7420746f20747265617375727920616e64d0207468652063757261746f72206465706f7369742077696c6c20626520756e726573657276656420696620706f737369626c652e00cc204f6e6c792060543a3a52656a6563744f726967696e602069732061626c6520746f2063616e63656c206120626f756e74792e0090202d2060626f756e74795f6964603a20426f756e747920494420746f2063616e63656c2e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e50657874656e645f626f756e74795f6578706972790824626f756e74795f696450436f6d706163743c426f756e7479496e6465783e1c5f72656d61726b1c5665633c75383e28b020457874656e6420746865206578706972792074696d65206f6620616e2061637469766520626f756e74792e00190120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265207468652063757261746f72206f66207468697320626f756e74792e0090202d2060626f756e74795f6964603a20426f756e747920494420746f20657874656e642e90202d206072656d61726b603a206164646974696f6e616c20696e666f726d6174696f6e2e002c2023203c7765696768743e20202d204f2831292e302023203c2f7765696768743e011c38426f756e747950726f706f736564042c426f756e7479496e646578047c204e657720626f756e74792070726f706f73616c2e205c5b696e6465785c5d38426f756e747952656a6563746564082c426f756e7479496e6465781c42616c616e6365041101204120626f756e74792070726f706f73616c207761732072656a65637465643b2066756e6473207765726520736c61736865642e205c5b696e6465782c20626f6e645c5d48426f756e7479426563616d65416374697665042c426f756e7479496e64657804e4204120626f756e74792070726f706f73616c2069732066756e64656420616e6420626563616d65206163746976652e205c5b696e6465785c5d34426f756e747941776172646564082c426f756e7479496e646578244163636f756e74496404f4204120626f756e7479206973206177617264656420746f20612062656e65666963696172792e205c5b696e6465782c2062656e65666963696172795c5d34426f756e7479436c61696d65640c2c426f756e7479496e6465781c42616c616e6365244163636f756e744964040d01204120626f756e747920697320636c61696d65642062792062656e65666963696172792e205c5b696e6465782c207061796f75742c2062656e65666963696172795c5d38426f756e747943616e63656c6564042c426f756e7479496e6465780484204120626f756e74792069732063616e63656c6c65642e205c5b696e6465785c5d38426f756e7479457874656e646564042c426f756e7479496e646578049c204120626f756e74792065787069727920697320657874656e6465642e205c5b696e6465785c5d1c48446174614465706f736974506572427974653042616c616e63654f663c543e4000e1f50500000000000000000000000004fc2054686520616d6f756e742068656c64206f6e206465706f7369742070657220627974652077697468696e20626f756e7479206465736372697074696f6e2e44426f756e74794465706f736974426173653042616c616e63654f663c543e4000e40b5402000000000000000000000004e82054686520616d6f756e742068656c64206f6e206465706f73697420666f7220706c6163696e67206120626f756e74792070726f706f73616c2e60426f756e74794465706f7369745061796f757444656c617938543a3a426c6f636b4e756d6265721000c20100045901205468652064656c617920706572696f6420666f72207768696368206120626f756e74792062656e6566696369617279206e65656420746f2077616974206265666f726520636c61696d20746865207061796f75742e48426f756e7479557064617465506572696f6438543a3a426c6f636b4e756d6265721080c61300046c20426f756e7479206475726174696f6e20696e20626c6f636b732e50426f756e747943757261746f724465706f7369741c5065726d696c6c1020a10700046d012050657263656e74616765206f66207468652063757261746f722066656520746861742077696c6c20626520726573657276656420757066726f6e74206173206465706f73697420666f7220626f756e74792063757261746f722e48426f756e747956616c75654d696e696d756d3042616c616e63654f663c543e4000e876481700000000000000000000000470204d696e696d756d2076616c756520666f72206120626f756e74792e4c4d6178696d756d526561736f6e4c656e6774680c75333210004000000488204d6178696d756d2061636365707461626c6520726561736f6e206c656e6774682e2470496e73756666696369656e7450726f706f7365727342616c616e6365047c2050726f706f73657227732062616c616e636520697320746f6f206c6f772e30496e76616c6964496e6465780494204e6f2070726f706f73616c206f7220626f756e7479206174207468617420696e6465782e30526561736f6e546f6f42696704882054686520726561736f6e20676976656e206973206a75737420746f6f206269672e40556e657870656374656453746174757304842054686520626f756e74792073746174757320697320756e65787065637465642e385265717569726543757261746f720460205265717569726520626f756e74792063757261746f722e30496e76616c696456616c7565045820496e76616c696420626f756e74792076616c75652e28496e76616c6964466565045020496e76616c696420626f756e7479206665652e3450656e64696e675061796f75740870204120626f756e7479207061796f75742069732070656e64696e672efc20546f2063616e63656c2074686520626f756e74792c20796f75206d75737420756e61737369676e20616e6420736c617368207468652063757261746f722e245072656d61747572650449012054686520626f756e746965732063616e6e6f7420626520636c61696d65642f636c6f73656420626563617573652069742773207374696c6c20696e2074686520636f756e74646f776e20706572696f642e221054697073012054726561737572790810546970730001051c543a3a48617368f04f70656e5469703c543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265722c20543a3a486173683e0004000c650120546970734d6170207468617420617265206e6f742079657420636f6d706c657465642e204b65796564206279207468652068617368206f66206028726561736f6e2c2077686f29602066726f6d207468652076616c75652e3d012054686973206861732074686520696e73656375726520656e756d657261626c6520686173682066756e6374696f6e2073696e636520746865206b657920697473656c6620697320616c7265616479802067756172616e7465656420746f20626520612073656375726520686173682e1c526561736f6e730001061c543a3a486173681c5665633c75383e0004000849012053696d706c6520707265696d616765206c6f6f6b75702066726f6d2074686520726561736f6e2773206861736820746f20746865206f726967696e616c20646174612e20416761696e2c2068617320616e610120696e73656375726520656e756d657261626c6520686173682073696e636520746865206b65792069732067756172616e7465656420746f2062652074686520726573756c74206f6620612073656375726520686173682e0118387265706f72745f617765736f6d650818726561736f6e1c5665633c75383e0c77686f30543a3a4163636f756e7449644c5d01205265706f727420736f6d657468696e672060726561736f6e60207468617420646573657276657320612074697020616e6420636c61696d20616e79206576656e7475616c207468652066696e6465722773206665652e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005501205061796d656e743a20605469705265706f72744465706f73697442617365602077696c6c2062652072657365727665642066726f6d20746865206f726967696e206163636f756e742c2061732077656c6c206173c02060446174614465706f736974506572427974656020666f722065616368206279746520696e2060726561736f6e602e006101202d2060726561736f6e603a2054686520726561736f6e20666f722c206f7220746865207468696e6720746861742064657365727665732c20746865207469703b2067656e6572616c6c7920746869732077696c6c2062655c20202061205554462d382d656e636f6465642055524c2eec202d206077686f603a20546865206163636f756e742077686963682073686f756c6420626520637265646974656420666f7220746865207469702e007820456d69747320604e657754697060206966207375636365737366756c2e002c2023203c7765696768743ecc202d20436f6d706c65786974793a20604f2852296020776865726520605260206c656e677468206f662060726561736f6e602e942020202d20656e636f64696e6720616e642068617368696e67206f662027726561736f6e2774202d20446252656164733a2060526561736f6e73602c2060546970736078202d2044625772697465733a2060526561736f6e73602c20605469707360302023203c2f7765696768743e2c726574726163745f7469700410686173681c543a3a486173684c550120526574726163742061207072696f72207469702d7265706f72742066726f6d20607265706f72745f617765736f6d65602c20616e642063616e63656c207468652070726f63657373206f662074697070696e672e00e0204966207375636365737366756c2c20746865206f726967696e616c206465706f7369742077696c6c20626520756e72657365727665642e00510120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e642074686520746970206964656e746966696564206279206068617368604501206d7573742068617665206265656e207265706f7274656420627920746865207369676e696e67206163636f756e74207468726f75676820607265706f72745f617765736f6d65602028616e64206e6f7450207468726f75676820607469705f6e657760292e006501202d206068617368603a20546865206964656e74697479206f6620746865206f70656e2074697020666f722077686963682061207469702076616c7565206973206465636c617265642e205468697320697320666f726d656461012020206173207468652068617368206f6620746865207475706c65206f6620746865206f726967696e616c207469702060726561736f6e6020616e64207468652062656e6566696369617279206163636f756e742049442e009020456d697473206054697052657472616374656460206966207375636365737366756c2e002c2023203c7765696768743e54202d20436f6d706c65786974793a20604f28312960dc2020202d20446570656e6473206f6e20746865206c656e677468206f662060543a3a48617368602077686963682069732066697865642e90202d20446252656164733a206054697073602c20606f726967696e206163636f756e7460c0202d2044625772697465733a2060526561736f6e73602c206054697073602c20606f726967696e206163636f756e7460302023203c2f7765696768743e1c7469705f6e65770c18726561736f6e1c5665633c75383e0c77686f30543a3a4163636f756e744964247469705f76616c756554436f6d706163743c42616c616e63654f663c543e3e58f4204769766520612074697020666f7220736f6d657468696e67206e65773b206e6f2066696e6465722773206665652077696c6c2062652074616b656e2e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d757374206265206174206d656d626572206f662074686520605469707065727360207365742e006101202d2060726561736f6e603a2054686520726561736f6e20666f722c206f7220746865207468696e6720746861742064657365727665732c20746865207469703b2067656e6572616c6c7920746869732077696c6c2062655c20202061205554462d382d656e636f6465642055524c2eec202d206077686f603a20546865206163636f756e742077686963682073686f756c6420626520637265646974656420666f7220746865207469702e5101202d20607469705f76616c7565603a2054686520616d6f756e74206f66207469702074686174207468652073656e64657220776f756c64206c696b6520746f20676976652e20546865206d656469616e20746970d820202076616c7565206f662061637469766520746970706572732077696c6c20626520676976656e20746f20746865206077686f602e007820456d69747320604e657754697060206966207375636365737366756c2e002c2023203c7765696768743e5501202d20436f6d706c65786974793a20604f2852202b2054296020776865726520605260206c656e677468206f662060726561736f6e602c2060546020697320746865206e756d626572206f6620746970706572732ec02020202d20604f285429603a206465636f64696e6720605469707065726020766563206f66206c656e6774682060546009012020202020605460206973206368617267656420617320757070657220626f756e6420676976656e2062792060436f6e7461696e734c656e677468426f756e64602e0d0120202020205468652061637475616c20636f737420646570656e6473206f6e2074686520696d706c656d656e746174696f6e206f662060543a3a54697070657273602ee42020202d20604f285229603a2068617368696e6720616e6420656e636f64696e67206f6620726561736f6e206f66206c656e6774682060526080202d20446252656164733a206054697070657273602c2060526561736f6e736078202d2044625772697465733a2060526561736f6e73602c20605469707360302023203c2f7765696768743e0c7469700810686173681c543a3a48617368247469705f76616c756554436f6d706163743c42616c616e63654f663c543e3e64b4204465636c6172652061207469702076616c756520666f7220616e20616c72656164792d6f70656e207469702e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d757374206265206174206d656d626572206f662074686520605469707065727360207365742e006501202d206068617368603a20546865206964656e74697479206f6620746865206f70656e2074697020666f722077686963682061207469702076616c7565206973206465636c617265642e205468697320697320666f726d656461012020206173207468652068617368206f6620746865207475706c65206f66207468652068617368206f6620746865206f726967696e616c207469702060726561736f6e6020616e64207468652062656e6566696369617279382020206163636f756e742049442e5101202d20607469705f76616c7565603a2054686520616d6f756e74206f66207469702074686174207468652073656e64657220776f756c64206c696b6520746f20676976652e20546865206d656469616e20746970d820202076616c7565206f662061637469766520746970706572732077696c6c20626520676976656e20746f20746865206077686f602e00650120456d6974732060546970436c6f73696e676020696620746865207468726573686f6c64206f66207469707065727320686173206265656e207265616368656420616e642074686520636f756e74646f776e20706572696f64342068617320737461727465642e002c2023203c7765696768743ee4202d20436f6d706c65786974793a20604f285429602077686572652060546020697320746865206e756d626572206f6620746970706572732e15012020206465636f64696e6720605469707065726020766563206f66206c656e677468206054602c20696e736572742074697020616e6420636865636b20636c6f73696e672c0101202020605460206973206368617267656420617320757070657220626f756e6420676976656e2062792060436f6e7461696e734c656e677468426f756e64602e05012020205468652061637475616c20636f737420646570656e6473206f6e2074686520696d706c656d656e746174696f6e206f662060543a3a54697070657273602e00610120202041637475616c6c792077656967687420636f756c64206265206c6f77657220617320697420646570656e6473206f6e20686f77206d616e7920746970732061726520696e20604f70656e5469706020627574206974d4202020697320776569676874656420617320696620616c6d6f73742066756c6c20692e65206f66206c656e6774682060542d31602e74202d20446252656164733a206054697070657273602c206054697073604c202d2044625772697465733a20605469707360302023203c2f7765696768743e24636c6f73655f7469700410686173681c543a3a48617368446020436c6f736520616e64207061796f75742061207469702e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e0019012054686520746970206964656e74696669656420627920606861736860206d75737420686176652066696e69736865642069747320636f756e74646f776e20706572696f642e006501202d206068617368603a20546865206964656e74697479206f6620746865206f70656e2074697020666f722077686963682061207469702076616c7565206973206465636c617265642e205468697320697320666f726d656461012020206173207468652068617368206f6620746865207475706c65206f6620746865206f726967696e616c207469702060726561736f6e6020616e64207468652062656e6566696369617279206163636f756e742049442e002c2023203c7765696768743ee4202d20436f6d706c65786974793a20604f285429602077686572652060546020697320746865206e756d626572206f6620746970706572732e9c2020206465636f64696e6720605469707065726020766563206f66206c656e677468206054602e0101202020605460206973206368617267656420617320757070657220626f756e6420676976656e2062792060436f6e7461696e734c656e677468426f756e64602e05012020205468652061637475616c20636f737420646570656e6473206f6e2074686520696d706c656d656e746174696f6e206f662060543a3a54697070657273602eac202d20446252656164733a206054697073602c206054697070657273602c20607469702066696e64657260dc202d2044625772697465733a2060526561736f6e73602c206054697073602c206054697070657273602c20607469702066696e64657260302023203c2f7765696768743e24736c6173685f7469700410686173681c543a3a4861736830982052656d6f766520616e6420736c61736820616e20616c72656164792d6f70656e207469702e00ac204d6179206f6e6c792062652063616c6c65642066726f6d2060543a3a52656a6563744f726967696e602e00f8204173206120726573756c742c207468652066696e64657220697320736c617368656420616e6420746865206465706f7369747320617265206c6f73742e008820456d6974732060546970536c617368656460206966207375636365737366756c2e002c2023203c7765696768743e0101202020605460206973206368617267656420617320757070657220626f756e6420676976656e2062792060436f6e7461696e734c656e677468426f756e64602e05012020205468652061637475616c20636f737420646570656e6473206f6e2074686520696d706c656d656e746174696f6e206f662060543a3a54697070657273602e302023203c2f7765696768743e0114184e657754697004104861736804cc2041206e6577207469702073756767657374696f6e20686173206265656e206f70656e65642e205c5b7469705f686173685c5d28546970436c6f73696e670410486173680411012041207469702073756767657374696f6e206861732072656163686564207468726573686f6c6420616e6420697320636c6f73696e672e205c5b7469705f686173685c5d24546970436c6f7365640c1048617368244163636f756e7449641c42616c616e636504f02041207469702073756767657374696f6e20686173206265656e20636c6f7365642e205c5b7469705f686173682c2077686f2c207061796f75745c5d3054697052657472616374656404104861736804c82041207469702073756767657374696f6e20686173206265656e207265747261637465642e205c5b7469705f686173685c5d28546970536c61736865640c1048617368244163636f756e7449641c42616c616e63650405012041207469702073756767657374696f6e20686173206265656e20736c61736865642e205c5b7469705f686173682c2066696e6465722c206465706f7369745c5d1430546970436f756e74646f776e38543a3a426c6f636b4e756d62657210403800000445012054686520706572696f6420666f722077686963682061207469702072656d61696e73206f70656e20616674657220697320686173206163686965766564207468726573686f6c6420746970706572732e3454697046696e646572734665651c50657263656e7404140431012054686520616d6f756e74206f66207468652066696e616c2074697020776869636820676f657320746f20746865206f726967696e616c207265706f72746572206f6620746865207469702e505469705265706f72744465706f736974426173653042616c616e63654f663c543e4000e40b5402000000000000000000000004d42054686520616d6f756e742068656c64206f6e206465706f73697420666f7220706c6163696e67206120746970207265706f72742e48446174614465706f736974506572427974653042616c616e63654f663c543e4000e1f5050000000000000000000000000409012054686520616d6f756e742068656c64206f6e206465706f7369742070657220627974652077697468696e2074686520746970207265706f727420726561736f6e2e4c4d6178696d756d526561736f6e4c656e6774680c75333210004000000488204d6178696d756d2061636365707461626c6520726561736f6e206c656e6774682e1830526561736f6e546f6f42696704882054686520726561736f6e20676976656e206973206a75737420746f6f206269672e30416c72656164794b6e6f776e048c20546865207469702077617320616c726561647920666f756e642f737461727465642e28556e6b6e6f776e54697004642054686520746970206861736820697320756e6b6e6f776e2e244e6f7446696e64657204210120546865206163636f756e7420617474656d7074696e6720746f20726574726163742074686520746970206973206e6f74207468652066696e646572206f6620746865207469702e245374696c6c4f70656e042d0120546865207469702063616e6e6f7420626520636c61696d65642f636c6f736564206265636175736520746865726520617265206e6f7420656e6f7567682074697070657273207965742e245072656d617475726504350120546865207469702063616e6e6f7420626520636c61696d65642f636c6f73656420626563617573652069742773207374696c6c20696e2074686520636f756e74646f776e20706572696f642e2368456c656374696f6e50726f76696465724d756c746950686173650168456c656374696f6e50726f76696465724d756c746950686173651814526f756e6401000c753332100100000018ac20496e7465726e616c20636f756e74657220666f7220746865206e756d626572206f6620726f756e64732e00550120546869732069732075736566756c20666f722064652d6475706c69636174696f6e206f66207472616e73616374696f6e73207375626d697474656420746f2074686520706f6f6c2c20616e642067656e6572616c6c20646961676e6f7374696373206f66207468652070616c6c65742e004d012054686973206973206d6572656c7920696e6372656d656e746564206f6e6365207065722065766572792074696d65207468617420616e20757073747265616d2060656c656374602069732063616c6c65642e3043757272656e74506861736501005450686173653c543a3a426c6f636b4e756d6265723e0400043c2043757272656e742070686173652e38517565756564536f6c7574696f6e00006c5265616479536f6c7574696f6e3c543a3a4163636f756e7449643e0400043d012043757272656e74206265737420736f6c7574696f6e2c207369676e6564206f7220756e7369676e65642c2071756575656420746f2062652072657475726e65642075706f6e2060656c656374602e20536e617073686f7400006c526f756e64536e617073686f743c543a3a4163636f756e7449643e04000c7020536e617073686f742064617461206f662074686520726f756e642e005d01205468697320697320637265617465642061742074686520626567696e6e696e67206f6620746865207369676e656420706861736520616e6420636c65617265642075706f6e2063616c6c696e672060656c656374602e38446573697265645461726765747300000c75333204000ccc2044657369726564206e756d626572206f66207461726765747320746f20656c65637420666f72207468697320726f756e642e00a8204f6e6c7920657869737473207768656e205b60536e617073686f74605d2069732070726573656e742e40536e617073686f744d65746164617461000058536f6c7574696f6e4f72536e617073686f7453697a6504000c9820546865206d65746164617461206f6620746865205b60526f756e64536e617073686f74605d00a8204f6e6c7920657869737473207768656e205b60536e617073686f74605d2069732070726573656e742e01043c7375626d69745f756e7369676e65640820736f6c7574696f6e64526177536f6c7574696f6e3c436f6d706163744f663c543e3e1c7769746e65737358536f6c7574696f6e4f72536e617073686f7453697a6538a8205375626d6974206120736f6c7574696f6e20666f722074686520756e7369676e65642070686173652e00cc20546865206469737061746368206f726967696e20666f20746869732063616c6c206d757374206265205f5f6e6f6e655f5f2e0041012054686973207375626d697373696f6e20697320636865636b6564206f6e2074686520666c792e204d6f72656f7665722c207468697320756e7369676e656420736f6c7574696f6e206973206f6e6c7959012076616c696461746564207768656e207375626d697474656420746f2074686520706f6f6c2066726f6d20746865202a2a6c6f63616c2a2a206e6f64652e204566666563746976656c792c2074686973206d65616e7361012074686174206f6e6c79206163746976652076616c696461746f72732063616e207375626d69742074686973207472616e73616374696f6e207768656e20617574686f72696e67206120626c6f636b202873696d696c61724420746f20616e20696e686572656e74292e005d0120546f2070726576656e7420616e7920696e636f727265637420736f6c7574696f6e2028616e642074687573207761737465642074696d652f776569676874292c2074686973207472616e73616374696f6e2077696c6c51012070616e69632069662074686520736f6c7574696f6e207375626d6974746564206279207468652076616c696461746f7220697320696e76616c696420696e20616e79207761792c206566666563746976656c79a02070757474696e6720746865697220617574686f72696e6720726577617264206174207269736b2e00e4204e6f206465706f736974206f7220726577617264206973206173736f63696174656420776974682074686973207375626d697373696f6e2e011838536f6c7574696f6e53746f726564043c456c656374696f6e436f6d7075746510b8204120736f6c7574696f6e207761732073746f72656420776974682074686520676976656e20636f6d707574652e0041012049662074686520736f6c7574696f6e206973207369676e65642c2074686973206d65616e732074686174206974206861736e277420796574206265656e2070726f6365737365642e20496620746865090120736f6c7574696f6e20697320756e7369676e65642c2074686973206d65616e7320746861742069742068617320616c736f206265656e2070726f6365737365642e44456c656374696f6e46696e616c697a6564045c4f7074696f6e3c456c656374696f6e436f6d707574653e0859012054686520656c656374696f6e20686173206265656e2066696e616c697a65642c20776974682060536f6d6560206f662074686520676976656e20636f6d7075746174696f6e2c206f7220656c7365206966207468656420656c656374696f6e206661696c65642c20604e6f6e65602e20526577617264656404244163636f756e74496404290120416e206163636f756e7420686173206265656e20726577617264656420666f72207468656972207369676e6564207375626d697373696f6e206265696e672066696e616c697a65642e1c536c617368656404244163636f756e74496404250120416e206163636f756e7420686173206265656e20736c617368656420666f72207375626d697474696e6720616e20696e76616c6964207369676e6564207375626d697373696f6e2e485369676e6564506861736553746172746564040c75333204c420546865207369676e6564207068617365206f662074686520676976656e20726f756e642068617320737461727465642e50556e7369676e6564506861736553746172746564040c75333204cc2054686520756e7369676e6564207068617365206f662074686520676976656e20726f756e642068617320737461727465642e0c34556e7369676e6564506861736538543a3a426c6f636b4e756d62657210580200000480204475726174696f6e206f662074686520756e7369676e65642070686173652e2c5369676e6564506861736538543a3a426c6f636b4e756d62657210000000000478204475726174696f6e206f6620746865207369676e65642070686173652e70536f6c7574696f6e496d70726f76656d656e745468726573686f6c641c50657262696c6c1020a10700084d0120546865206d696e696d756d20616d6f756e74206f6620696d70726f76656d656e7420746f2074686520736f6c7574696f6e2073636f7265207468617420646566696e6573206120736f6c7574696f6e206173642022626574746572222028696e20616e79207068617365292e0c6850726544697370617463684561726c795375626d697373696f6e0468205375626d697373696f6e2077617320746f6f206561726c792e6c507265446973706174636857726f6e6757696e6e6572436f756e74048c2057726f6e67206e756d626572206f662077696e6e6572732070726573656e7465642e6450726544697370617463685765616b5375626d697373696f6e0494205375626d697373696f6e2077617320746f6f207765616b2c2073636f72652d776973652e24042040436865636b5370656356657273696f6e38436865636b547856657273696f6e30436865636b47656e6573697338436865636b4d6f7274616c69747928436865636b4e6f6e63652c436865636b576569676874604368617267655472616e73616374696f6e5061796d656e744850726576616c696461746541747465737473 \ No newline at end of file diff --git a/runtime/src/main/assets/metadata/rococo b/runtime/src/main/assets/metadata/rococo new file mode 100644 index 0000000..9dbafe9 --- /dev/null +++ b/runtime/src/main/assets/metadata/rococo @@ -0,0 +1 @@ +0x6d6574610c941853797374656d011853797374656d401c4163636f756e7401010230543a3a4163636f756e744964944163636f756e74496e666f3c543a3a496e6465782c20543a3a4163636f756e74446174613e004101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004e8205468652066756c6c206163636f756e7420696e666f726d6174696f6e20666f72206120706172746963756c6172206163636f756e742049442e3845787472696e736963436f756e7400000c753332040004b820546f74616c2065787472696e7369637320636f756e7420666f72207468652063757272656e7420626c6f636b2e2c426c6f636b576569676874010038436f6e73756d6564576569676874600000000000000000000000000000000000000000000000000488205468652063757272656e742077656967687420666f722074686520626c6f636b2e40416c6c45787472696e736963734c656e00000c753332040004410120546f74616c206c656e6774682028696e2062797465732920666f7220616c6c2065787472696e736963732070757420746f6765746865722c20666f72207468652063757272656e7420626c6f636b2e24426c6f636b4861736801010538543a3a426c6f636b4e756d6265721c543a3a48617368008000000000000000000000000000000000000000000000000000000000000000000498204d6170206f6620626c6f636b206e756d6265727320746f20626c6f636b206861736865732e3445787472696e736963446174610101050c7533321c5665633c75383e000400043d012045787472696e73696373206461746120666f72207468652063757272656e7420626c6f636b20286d61707320616e2065787472696e736963277320696e64657820746f206974732064617461292e184e756d626572010038543a3a426c6f636b4e756d6265721000000000040901205468652063757272656e7420626c6f636b206e756d626572206265696e672070726f6365737365642e205365742062792060657865637574655f626c6f636b602e28506172656e744861736801001c543a3a4861736880000000000000000000000000000000000000000000000000000000000000000004702048617368206f66207468652070726576696f757320626c6f636b2e1844696765737401002c4469676573744f663c543e040004f020446967657374206f66207468652063757272656e7420626c6f636b2c20616c736f2070617274206f662074686520626c6f636b206865616465722e184576656e747301008c5665633c4576656e745265636f72643c543a3a4576656e742c20543a3a486173683e3e040004a0204576656e7473206465706f736974656420666f72207468652063757272656e7420626c6f636b2e284576656e74436f756e740100284576656e74496e646578100000000004b820546865206e756d626572206f66206576656e747320696e2074686520604576656e74733c543e60206c6973742e2c4576656e74546f706963730101021c543a3a48617368845665633c28543a3a426c6f636b4e756d6265722c204576656e74496e646578293e000400282501204d617070696e67206265747765656e206120746f7069632028726570726573656e74656420627920543a3a486173682920616e64206120766563746f72206f6620696e646578657394206f66206576656e747320696e2074686520603c4576656e74733c543e3e60206c6973742e00510120416c6c20746f70696320766563746f727320686176652064657465726d696e69737469632073746f72616765206c6f636174696f6e7320646570656e64696e67206f6e2074686520746f7069632e2054686973450120616c6c6f7773206c696768742d636c69656e747320746f206c6576657261676520746865206368616e67657320747269652073746f7261676520747261636b696e67206d656368616e69736d20616e64e420696e2063617365206f66206368616e67657320666574636820746865206c697374206f66206576656e7473206f6620696e7465726573742e004d01205468652076616c756520686173207468652074797065206028543a3a426c6f636b4e756d6265722c204576656e74496e646578296020626563617573652069662077652075736564206f6e6c79206a7573744d012074686520604576656e74496e64657860207468656e20696e20636173652069662074686520746f70696320686173207468652073616d6520636f6e74656e7473206f6e20746865206e65787420626c6f636b0101206e6f206e6f74696669636174696f6e2077696c6c20626520747269676765726564207468757320746865206576656e74206d69676874206265206c6f73742e484c61737452756e74696d65557067726164650000584c61737452756e74696d6555706772616465496e666f04000455012053746f726573207468652060737065635f76657273696f6e6020616e642060737065635f6e616d6560206f66207768656e20746865206c6173742072756e74696d6520757067726164652068617070656e65642e545570677261646564546f553332526566436f756e74010010626f6f6c0400044d012054727565206966207765206861766520757067726164656420736f207468617420607479706520526566436f756e74602069732060753332602e2046616c7365202864656661756c7429206966206e6f742e605570677261646564546f547269706c65526566436f756e74010010626f6f6c0400085d012054727565206966207765206861766520757067726164656420736f2074686174204163636f756e74496e666f20636f6e7461696e73207468726565207479706573206f662060526566436f756e74602e2046616c736548202864656661756c7429206966206e6f742e38457865637574696f6e50686173650000145068617365040004882054686520657865637574696f6e207068617365206f662074686520626c6f636b2e01282866696c6c5f626c6f636b04185f726174696f1c50657262696c6c040901204120646973706174636820746861742077696c6c2066696c6c2074686520626c6f636b2077656967687420757020746f2074686520676976656e20726174696f2e1872656d61726b041c5f72656d61726b1c5665633c75383e146c204d616b6520736f6d65206f6e2d636861696e2072656d61726b2e002c2023203c7765696768743e24202d20604f28312960302023203c2f7765696768743e387365745f686561705f7061676573041470616765730c75363420fc2053657420746865206e756d626572206f6620706167657320696e2074686520576562417373656d626c7920656e7669726f6e6d656e74277320686561702e002c2023203c7765696768743e24202d20604f283129604c202d20312073746f726167652077726974652e64202d2042617365205765696768743a20312e34303520c2b57360202d203120777269746520746f20484541505f5041474553302023203c2f7765696768743e207365745f636f64650410636f64651c5665633c75383e28682053657420746865206e65772072756e74696d6520636f64652e002c2023203c7765696768743e3501202d20604f2843202b2053296020776865726520604360206c656e677468206f662060636f64656020616e642060536020636f6d706c6578697479206f66206063616e5f7365745f636f64656088202d20312073746f726167652077726974652028636f64656320604f28432960292e7901202d20312063616c6c20746f206063616e5f7365745f636f6465603a20604f28532960202863616c6c73206073705f696f3a3a6d6973633a3a72756e74696d655f76657273696f6e6020776869636820697320657870656e73697665292e2c202d2031206576656e742e7d012054686520776569676874206f6620746869732066756e6374696f6e20697320646570656e64656e74206f6e207468652072756e74696d652c206275742067656e6572616c6c792074686973206973207665727920657870656e736976652e902057652077696c6c207472656174207468697320617320612066756c6c20626c6f636b2e302023203c2f7765696768743e5c7365745f636f64655f776974686f75745f636865636b730410636f64651c5665633c75383e201d012053657420746865206e65772072756e74696d6520636f646520776974686f757420646f696e6720616e7920636865636b73206f662074686520676976656e2060636f6465602e002c2023203c7765696768743e90202d20604f2843296020776865726520604360206c656e677468206f662060636f64656088202d20312073746f726167652077726974652028636f64656320604f28432960292e2c202d2031206576656e742e75012054686520776569676874206f6620746869732066756e6374696f6e20697320646570656e64656e74206f6e207468652072756e74696d652e2057652077696c6c207472656174207468697320617320612066756c6c20626c6f636b2e302023203c2f7765696768743e5c7365745f6368616e6765735f747269655f636f6e666967044c6368616e6765735f747269655f636f6e666967804f7074696f6e3c4368616e67657354726965436f6e66696775726174696f6e3e28a02053657420746865206e6577206368616e676573207472696520636f6e66696775726174696f6e2e002c2023203c7765696768743e24202d20604f28312960b0202d20312073746f72616765207772697465206f722064656c6574652028636f64656320604f28312960292ed8202d20312063616c6c20746f20606465706f7369745f6c6f67603a20557365732060617070656e6460204150492c20736f204f28312964202d2042617365205765696768743a20372e32313820c2b57334202d204442205765696768743aa820202020202d205772697465733a204368616e67657320547269652c2053797374656d20446967657374302023203c2f7765696768743e2c7365745f73746f7261676504146974656d73345665633c4b657956616c75653e206c2053657420736f6d65206974656d73206f662073746f726167652e002c2023203c7765696768743e94202d20604f2849296020776865726520604960206c656e677468206f6620606974656d73607c202d206049602073746f72616765207772697465732028604f28312960292e74202d2042617365205765696768743a20302e353638202a206920c2b57368202d205772697465733a204e756d626572206f66206974656d73302023203c2f7765696768743e306b696c6c5f73746f7261676504106b657973205665633c4b65793e2078204b696c6c20736f6d65206974656d732066726f6d2073746f726167652e002c2023203c7765696768743efc202d20604f28494b296020776865726520604960206c656e677468206f6620606b6579736020616e6420604b60206c656e677468206f66206f6e65206b657964202d206049602073746f726167652064656c6574696f6e732e70202d2042617365205765696768743a202e333738202a206920c2b57368202d205772697465733a204e756d626572206f66206974656d73302023203c2f7765696768743e2c6b696c6c5f70726566697808187072656669780c4b6579205f7375626b6579730c7533322c1501204b696c6c20616c6c2073746f72616765206974656d7320776974682061206b657920746861742073746172747320776974682074686520676976656e207072656669782e003d01202a2a4e4f54453a2a2a2057652072656c79206f6e2074686520526f6f74206f726967696e20746f2070726f7669646520757320746865206e756d626572206f66207375626b65797320756e64657241012074686520707265666978207765206172652072656d6f76696e6720746f2061636375726174656c792063616c63756c6174652074686520776569676874206f6620746869732066756e6374696f6e2e002c2023203c7765696768743edc202d20604f285029602077686572652060506020616d6f756e74206f66206b65797320776974682070726566697820607072656669786064202d206050602073746f726167652064656c6574696f6e732e74202d2042617365205765696768743a20302e383334202a205020c2b57380202d205772697465733a204e756d626572206f66207375626b657973202b2031302023203c2f7765696768743e4472656d61726b5f776974685f6576656e74041872656d61726b1c5665633c75383e18a8204d616b6520736f6d65206f6e2d636861696e2072656d61726b20616e6420656d6974206576656e742e002c2023203c7765696768743eb8202d20604f28622960207768657265206220697320746865206c656e677468206f66207468652072656d61726b2e2c202d2031206576656e742e302023203c2f7765696768743e01184045787472696e7369635375636365737304304469737061746368496e666f04b820416e2065787472696e73696320636f6d706c65746564207375636365737366756c6c792e205c5b696e666f5c5d3c45787472696e7369634661696c6564083444697370617463684572726f72304469737061746368496e666f049420416e2065787472696e736963206661696c65642e205c5b6572726f722c20696e666f5c5d2c436f64655570646174656400045420603a636f6465602077617320757064617465642e284e65774163636f756e7404244163636f756e744964047c2041206e6577205c5b6163636f756e745c5d2077617320637265617465642e344b696c6c65644163636f756e7404244163636f756e744964046c20416e205c5b6163636f756e745c5d20776173207265617065642e2052656d61726b656408244163636f756e744964104861736804d4204f6e206f6e2d636861696e2072656d61726b2068617070656e65642e205c5b6f726967696e2c2072656d61726b5f686173685c5d1830426c6f636b57656967687473506c696d6974733a3a426c6f636b57656967687473850100f2052a0100000000204aa9d1010000405973070000000001c0766c8f58010000010098f73e5d010000010000000000000000405973070000000001c0febef9cc0100000100204aa9d1010000010088526a74000000405973070000000000000004d020426c6f636b20262065787472696e7369637320776569676874733a20626173652076616c75657320616e64206c696d6974732e2c426c6f636b4c656e6774684c6c696d6974733a3a426c6f636b4c656e6774683000003c00000050000000500004a820546865206d6178696d756d206c656e677468206f66206120626c6f636b2028696e206279746573292e38426c6f636b48617368436f756e7438543a3a426c6f636b4e756d6265721060090000045501204d6178696d756d206e756d626572206f6620626c6f636b206e756d62657220746f20626c6f636b2068617368206d617070696e677320746f206b65657020286f6c64657374207072756e6564206669727374292e2044625765696768743c52756e74696d6544625765696768744040787d010000000000e1f505000000000409012054686520776569676874206f662072756e74696d65206461746162617365206f7065726174696f6e73207468652072756e74696d652063616e20696e766f6b652e1c56657273696f6e3852756e74696d6556657273696f6e4d0318726f636f636f487061726974792d726f636f636f2d76312e3200000000e60000000000000038df6acb689907609b0300000037e397fc7c91f5e40100000040fe3ad401f8959a04000000d2bc9897eed08f1502000000f78b278be53f454c02000000af2c0297a23e6d3d01000000ed99c5acb25eedf502000000cbca25e39f14238702000000687ad44ad37f03c201000000ab3c0572291feb8b0100000049eaaf1b548a0cb00100000091d5df18b0d2cf5801000000bc9d89904f5b923f0100000037c8bb1350a9a2a801000000000000000484204765742074686520636861696e27732063757272656e742076657273696f6e2e2853533538507265666978087538042a14a8205468652064657369676e61746564205353383520707265666978206f66207468697320636861696e2e0039012054686973207265706c6163657320746865202273733538466f726d6174222070726f7065727479206465636c6172656420696e2074686520636861696e20737065632e20526561736f6e20697331012074686174207468652072756e74696d652073686f756c64206b6e6f772061626f7574207468652070726566697820696e206f7264657220746f206d616b6520757365206f662069742061737020616e206964656e746966696572206f662074686520636861696e2e143c496e76616c6964537065634e616d6508150120546865206e616d65206f662073706563696669636174696f6e20646f6573206e6f74206d61746368206265747765656e207468652063757272656e742072756e74696d655420616e6420746865206e65772072756e74696d652e685370656356657273696f6e4e65656473546f496e637265617365084501205468652073706563696669636174696f6e2076657273696f6e206973206e6f7420616c6c6f77656420746f206465637265617365206265747765656e207468652063757272656e742072756e74696d655420616e6420746865206e65772072756e74696d652e744661696c6564546f4578747261637452756e74696d6556657273696f6e0cf0204661696c656420746f2065787472616374207468652072756e74696d652076657273696f6e2066726f6d20746865206e65772072756e74696d652e000d01204569746865722063616c6c696e672060436f72655f76657273696f6e60206f72206465636f64696e67206052756e74696d6556657273696f6e60206661696c65642e4c4e6f6e44656661756c74436f6d706f7369746504010120537569636964652063616c6c6564207768656e20746865206163636f756e7420686173206e6f6e2d64656661756c7420636f6d706f7369746520646174612e3c4e6f6e5a65726f526566436f756e740439012054686572652069732061206e6f6e2d7a65726f207265666572656e636520636f756e742070726576656e74696e6720746865206163636f756e742066726f6d206265696e67207075726765642e001042616265011042616265402845706f6368496e64657801000c75363420000000000000000004542043757272656e742065706f636820696e6465782e2c417574686f72697469657301009c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e0400046c2043757272656e742065706f636820617574686f7269746965732e2c47656e65736973536c6f74010010536c6f7420000000000000000008f82054686520736c6f74206174207768696368207468652066697273742065706f63682061637475616c6c7920737461727465642e205468697320697320309020756e74696c2074686520666972737420626c6f636b206f662074686520636861696e2e2c43757272656e74536c6f74010010536c6f7420000000000000000004542043757272656e7420736c6f74206e756d6265722e2852616e646f6d6e6573730100587363686e6f72726b656c3a3a52616e646f6d6e65737380000000000000000000000000000000000000000000000000000000000000000028b8205468652065706f63682072616e646f6d6e65737320666f7220746865202a63757272656e742a2065706f63682e002c20232053656375726974790005012054686973204d555354204e4f54206265207573656420666f722067616d626c696e672c2061732069742063616e20626520696e666c75656e6365642062792061f8206d616c6963696f75732076616c696461746f7220696e207468652073686f7274207465726d2e204974204d4159206265207573656420696e206d616e7915012063727970746f677261706869632070726f746f636f6c732c20686f77657665722c20736f206c6f6e67206173206f6e652072656d656d6265727320746861742074686973150120286c696b652065766572797468696e6720656c7365206f6e2d636861696e29206974206973207075626c69632e20466f72206578616d706c652c2069742063616e206265050120757365642077686572652061206e756d626572206973206e656564656420746861742063616e6e6f742068617665206265656e2063686f73656e20627920616e0d01206164766572736172792c20666f7220707572706f7365732073756368206173207075626c69632d636f696e207a65726f2d6b6e6f776c656467652070726f6f66732e6050656e64696e6745706f6368436f6e6669674368616e67650000504e657874436f6e66696744657363726970746f7204000461012050656e64696e672065706f636820636f6e66696775726174696f6e206368616e676520746861742077696c6c206265206170706c696564207768656e20746865206e6578742065706f636820697320656e61637465642e384e65787452616e646f6d6e6573730100587363686e6f72726b656c3a3a52616e646f6d6e657373800000000000000000000000000000000000000000000000000000000000000000045c204e6578742065706f63682072616e646f6d6e6573732e3c4e657874417574686f72697469657301009c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e04000460204e6578742065706f636820617574686f7269746965732e305365676d656e74496e64657801000c7533321000000000247c2052616e646f6d6e65737320756e64657220636f6e737472756374696f6e2e00f4205765206d616b6520612074726164656f6666206265747765656e2073746f7261676520616363657373657320616e64206c697374206c656e6774682e01012057652073746f72652074686520756e6465722d636f6e737472756374696f6e2072616e646f6d6e65737320696e207365676d656e7473206f6620757020746f942060554e4445525f434f4e535452554354494f4e5f5345474d454e545f4c454e475448602e00ec204f6e63652061207365676d656e7420726561636865732074686973206c656e6774682c20776520626567696e20746865206e657874206f6e652e090120576520726573657420616c6c207365676d656e747320616e642072657475726e20746f206030602061742074686520626567696e6e696e67206f662065766572791c2065706f63682e44556e646572436f6e737472756374696f6e0101050c7533326c5665633c7363686e6f72726b656c3a3a52616e646f6d6e6573733e0004000415012054574f582d4e4f54453a20605365676d656e74496e6465786020697320616e20696e6372656173696e6720696e74656765722c20736f2074686973206973206f6b61792e2c496e697469616c697a656400003c4d6179626552616e646f6d6e65737304000801012054656d706f726172792076616c75652028636c656172656420617420626c6f636b2066696e616c697a6174696f6e292077686963682069732060536f6d65601d01206966207065722d626c6f636b20696e697469616c697a6174696f6e2068617320616c7265616479206265656e2063616c6c656420666f722063757272656e7420626c6f636b2e4c417574686f7256726652616e646f6d6e65737301003c4d6179626552616e646f6d6e65737304000c5d012054656d706f726172792076616c75652028636c656172656420617420626c6f636b2066696e616c697a6174696f6e29207468617420696e636c756465732074686520565246206f75747075742067656e6572617465645101206174207468697320626c6f636b2e2054686973206669656c642073686f756c6420616c7761797320626520706f70756c6174656420647572696e6720626c6f636b2070726f63657373696e6720756e6c6573731901207365636f6e6461727920706c61696e20736c6f74732061726520656e61626c65642028776869636820646f6e277420636f6e7461696e206120565246206f7574707574292e2845706f6368537461727401008028543a3a426c6f636b4e756d6265722c20543a3a426c6f636b4e756d62657229200000000000000000145d012054686520626c6f636b206e756d62657273207768656e20746865206c61737420616e642063757272656e742065706f6368206861766520737461727465642c20726573706563746976656c7920604e2d316020616e641420604e602e4901204e4f54453a20576520747261636b207468697320697320696e206f7264657220746f20616e6e6f746174652074686520626c6f636b206e756d626572207768656e206120676976656e20706f6f6c206f66590120656e74726f7079207761732066697865642028692e652e20697420776173206b6e6f776e20746f20636861696e206f6273657276657273292e2053696e63652065706f6368732061726520646566696e656420696e590120736c6f74732c207768696368206d617920626520736b69707065642c2074686520626c6f636b206e756d62657273206d6179206e6f74206c696e6520757020776974682074686520736c6f74206e756d626572732e204c6174656e657373010038543a3a426c6f636b4e756d626572100000000014d820486f77206c617465207468652063757272656e7420626c6f636b20697320636f6d706172656420746f2069747320706172656e742e001501205468697320656e74727920697320706f70756c617465642061732070617274206f6620626c6f636b20657865637574696f6e20616e6420697320636c65616e65642075701101206f6e20626c6f636b2066696e616c697a6174696f6e2e205175657279696e6720746869732073746f7261676520656e747279206f757473696465206f6620626c6f636bb020657865637574696f6e20636f6e746578742073686f756c6420616c77617973207969656c64207a65726f2e2c45706f6368436f6e6669670000584261626545706f6368436f6e66696775726174696f6e04000485012054686520636f6e66696775726174696f6e20666f72207468652063757272656e742065706f63682e2053686f756c64206e6576657220626520604e6f6e656020617320697420697320696e697469616c697a656420696e2067656e657369732e3c4e65787445706f6368436f6e6669670000584261626545706f6368436f6e66696775726174696f6e0400082d012054686520636f6e66696775726174696f6e20666f7220746865206e6578742065706f63682c20604e6f6e65602069662074686520636f6e6669672077696c6c206e6f74206368616e6765e82028796f752063616e2066616c6c6261636b20746f206045706f6368436f6e6669676020696e737465616420696e20746861742063617365292e010c4c7265706f72745f65717569766f636174696f6e084865717569766f636174696f6e5f70726f6f667045717569766f636174696f6e50726f6f663c543a3a4865616465723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66100d01205265706f727420617574686f726974792065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c207665726966790901207468652065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66110120616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e63652077696c6c34206265207265706f727465642e707265706f72745f65717569766f636174696f6e5f756e7369676e6564084865717569766f636174696f6e5f70726f6f667045717569766f636174696f6e50726f6f663c543a3a4865616465723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66200d01205265706f727420617574686f726974792065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c207665726966790901207468652065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66110120616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e63652077696c6c34206265207265706f727465642e110120546869732065787472696e736963206d7573742062652063616c6c656420756e7369676e656420616e642069742069732065787065637465642074686174206f6e6c79190120626c6f636b20617574686f72732077696c6c2063616c6c206974202876616c69646174656420696e206056616c6964617465556e7369676e656460292c206173207375636819012069662074686520626c6f636b20617574686f7220697320646566696e65642069742077696c6c20626520646566696e6564206173207468652065717569766f636174696f6e28207265706f727465722e48706c616e5f636f6e6669675f6368616e67650418636f6e666967504e657874436f6e66696744657363726970746f7210610120506c616e20616e2065706f636820636f6e666967206368616e67652e205468652065706f636820636f6e666967206368616e6765206973207265636f7264656420616e642077696c6c20626520656e6163746564206f6e550120746865206e6578742063616c6c20746f2060656e6163745f65706f63685f6368616e6765602e2054686520636f6e6669672077696c6c20626520616374697661746564206f6e652065706f63682061667465722e5d01204d756c7469706c652063616c6c7320746f2074686973206d6574686f642077696c6c207265706c61636520616e79206578697374696e6720706c616e6e656420636f6e666967206368616e676520746861742068616458206e6f74206265656e20656e6163746564207965742e00083445706f63684475726174696f6e0c7536342032000000000000000cec2054686520616d6f756e74206f662074696d652c20696e20736c6f74732c207468617420656163682065706f63682073686f756c64206c6173742e1901204e4f54453a2043757272656e746c79206974206973206e6f7420706f737369626c6520746f206368616e6765207468652065706f6368206475726174696f6e20616674657221012074686520636861696e2068617320737461727465642e20417474656d7074696e6720746f20646f20736f2077696c6c20627269636b20626c6f636b2070726f64756374696f6e2e444578706563746564426c6f636b54696d6524543a3a4d6f6d656e7420701700000000000014050120546865206578706563746564206176657261676520626c6f636b2074696d6520617420776869636820424142452073686f756c64206265206372656174696e67110120626c6f636b732e2053696e636520424142452069732070726f626162696c6973746963206974206973206e6f74207472697669616c20746f20666967757265206f75740501207768617420746865206578706563746564206176657261676520626c6f636b2074696d652073686f756c64206265206261736564206f6e2074686520736c6f740901206475726174696f6e20616e642074686520736563757269747920706172616d657465722060636020287768657265206031202d20636020726570726573656e7473a0207468652070726f626162696c697479206f66206120736c6f74206265696e6720656d707479292e0c60496e76616c696445717569766f636174696f6e50726f6f6604350120416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c69644b65794f776e65727368697050726f6f660435012041206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f7274041901204120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e012454696d657374616d70012454696d657374616d70080c4e6f77010024543a3a4d6f6d656e7420000000000000000004902043757272656e742074696d6520666f72207468652063757272656e7420626c6f636b2e24446964557064617465010010626f6f6c040004b420446964207468652074696d657374616d7020676574207570646174656420696e207468697320626c6f636b3f01040c736574040c6e6f7748436f6d706163743c543a3a4d6f6d656e743e3c5820536574207468652063757272656e742074696d652e00590120546869732063616c6c2073686f756c6420626520696e766f6b65642065786163746c79206f6e63652070657220626c6f636b2e2049742077696c6c2070616e6963206174207468652066696e616c697a6174696f6ed82070686173652c20696620746869732063616c6c206861736e2774206265656e20696e766f6b656420627920746861742074696d652e004501205468652074696d657374616d702073686f756c642062652067726561746572207468616e207468652070726576696f7573206f6e652062792074686520616d6f756e74207370656369666965642062794420604d696e696d756d506572696f64602e00d820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652060496e686572656e74602e002c2023203c7765696768743e3501202d20604f2831296020284e6f7465207468617420696d706c656d656e746174696f6e73206f6620604f6e54696d657374616d7053657460206d75737420616c736f20626520604f2831296029a101202d20312073746f72616765207265616420616e6420312073746f72616765206d75746174696f6e2028636f64656320604f28312960292e202862656361757365206f6620604469645570646174653a3a74616b656020696e20606f6e5f66696e616c697a656029d8202d2031206576656e742068616e646c657220606f6e5f74696d657374616d705f736574602e204d75737420626520604f283129602e302023203c2f7765696768743e0004344d696e696d756d506572696f6424543a3a4d6f6d656e7420b80b00000000000010690120546865206d696e696d756d20706572696f64206265747765656e20626c6f636b732e204265776172652074686174207468697320697320646966666572656e7420746f20746865202a65787065637465642a20706572696f64690120746861742074686520626c6f636b2070726f64756374696f6e206170706172617475732070726f76696465732e20596f75722063686f73656e20636f6e73656e7375732073797374656d2077696c6c2067656e6572616c6c79650120776f726b2077697468207468697320746f2064657465726d696e6520612073656e7369626c6520626c6f636b2074696d652e20652e672e20466f7220417572612c2069742077696c6c20626520646f75626c6520746869737020706572696f64206f6e2064656661756c742073657474696e67732e00021c496e6469636573011c496e646963657304204163636f756e74730001023c543a3a4163636f756e74496e6465788828543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20626f6f6c29000400048820546865206c6f6f6b75702066726f6d20696e64657820746f206163636f756e742e011414636c61696d0414696e6465783c543a3a4163636f756e74496e646578489c2041737369676e20616e2070726576696f75736c7920756e61737369676e656420696e6465782e00e0205061796d656e743a20604465706f736974602069732072657365727665642066726f6d207468652073656e646572206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e00f4202d2060696e646578603a2074686520696e64657820746f20626520636c61696d65642e2054686973206d757374206e6f7420626520696e207573652e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e64202d204f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e207472616e73666572080c6e657730543a3a4163636f756e74496414696e6465783c543a3a4163636f756e74496e6465785061012041737369676e20616e20696e64657820616c7265616479206f776e6564206279207468652073656e64657220746f20616e6f74686572206163636f756e742e205468652062616c616e6365207265736572766174696f6ebc206973206566666563746976656c79207472616e7366657272656420746f20746865206e6577206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002901202d2060696e646578603a2074686520696e64657820746f2062652072652d61737369676e65642e2054686973206d757374206265206f776e6564206279207468652073656e6465722e6101202d20606e6577603a20746865206e6577206f776e6572206f662074686520696e6465782e20546869732066756e6374696f6e2069732061206e6f2d6f7020696620697420697320657175616c20746f2073656e6465722e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e68202d204f6e65207472616e73666572206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743ae4202020202d2052656164733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e742028726563697069656e7429e8202020202d205772697465733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e742028726563697069656e7429302023203c2f7765696768743e10667265650414696e6465783c543a3a4163636f756e74496e6465784898204672656520757020616e20696e646578206f776e6564206279207468652073656e6465722e006101205061796d656e743a20416e792070726576696f7573206465706f73697420706c6163656420666f722074686520696e64657820697320756e726573657276656420696e207468652073656e646572206163636f756e742e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d757374206f776e2074686520696e6465782e001101202d2060696e646578603a2074686520696e64657820746f2062652066726565642e2054686973206d757374206265206f776e6564206279207468652073656e6465722e008820456d6974732060496e646578467265656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e64202d204f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e38666f7263655f7472616e736665720c0c6e657730543a3a4163636f756e74496414696e6465783c543a3a4163636f756e74496e64657818667265657a6510626f6f6c54590120466f72636520616e20696e64657820746f20616e206163636f756e742e205468697320646f65736e277420726571756972652061206465706f7369742e2049662074686520696e64657820697320616c7265616479ec2068656c642c207468656e20616e79206465706f736974206973207265696d62757273656420746f206974732063757272656e74206f776e65722e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f526f6f745f2e00a8202d2060696e646578603a2074686520696e64657820746f206265202872652d2961737369676e65642e6101202d20606e6577603a20746865206e6577206f776e6572206f662074686520696e6465782e20546869732066756e6374696f6e2069732061206e6f2d6f7020696620697420697320657175616c20746f2073656e6465722e4501202d2060667265657a65603a2069662073657420746f206074727565602c2077696c6c20667265657a652074686520696e64657820736f2069742063616e6e6f74206265207472616e736665727265642e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e7c202d20557020746f206f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743af8202020202d2052656164733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e7420286f726967696e616c206f776e657229fc202020202d205772697465733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e7420286f726967696e616c206f776e657229302023203c2f7765696768743e18667265657a650414696e6465783c543a3a4163636f756e74496e64657844690120467265657a6520616e20696e64657820736f2069742077696c6c20616c7761797320706f696e7420746f207468652073656e646572206163636f756e742e205468697320636f6e73756d657320746865206465706f7369742e005d0120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d7573742068617665206170206e6f6e2d66726f7a656e206163636f756e742060696e646578602e00b0202d2060696e646578603a2074686520696e64657820746f2062652066726f7a656e20696e20706c6163652e008c20456d6974732060496e64657846726f7a656e60206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e74202d20557020746f206f6e6520736c617368206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e010c34496e64657841737369676e656408244163636f756e744964304163636f756e74496e64657804b42041206163636f756e7420696e646578207761732061737369676e65642e205c5b696e6465782c2077686f5c5d28496e646578467265656404304163636f756e74496e64657804e82041206163636f756e7420696e64657820686173206265656e2066726565642075702028756e61737369676e6564292e205c5b696e6465785c5d2c496e64657846726f7a656e08304163636f756e74496e646578244163636f756e7449640429012041206163636f756e7420696e64657820686173206265656e2066726f7a656e20746f206974732063757272656e74206163636f756e742049442e205c5b696e6465782c2077686f5c5d041c4465706f7369743042616c616e63654f663c543e400010a5d4e8000000000000000000000004ac20546865206465706f736974206e656564656420666f7220726573657276696e6720616e20696e6465782e142c4e6f7441737369676e656404902054686520696e64657820776173206e6f7420616c72656164792061737369676e65642e204e6f744f776e657204a82054686520696e6465782069732061737369676e656420746f20616e6f74686572206163636f756e742e14496e55736504742054686520696e64657820776173206e6f7420617661696c61626c652e2c4e6f745472616e7366657204cc2054686520736f7572636520616e642064657374696e6174696f6e206163636f756e747320617265206964656e746963616c2e245065726d616e656e7404d42054686520696e646578206973207065726d616e656e7420616e64206d6179206e6f742062652066726565642f6368616e6765642e032042616c616e636573012042616c616e6365731034546f74616c49737375616e6365010028543a3a42616c616e6365400000000000000000000000000000000004982054686520746f74616c20756e6974732069737375656420696e207468652073797374656d2e1c4163636f756e7401010230543a3a4163636f756e7449645c4163636f756e74446174613c543a3a42616c616e63653e000101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6c205468652062616c616e6365206f6620616e206163636f756e742e004101204e4f54453a2054686973206973206f6e6c79207573656420696e207468652063617365207468617420746869732070616c6c6574206973207573656420746f2073746f72652062616c616e6365732e144c6f636b7301010230543a3a4163636f756e744964705665633c42616c616e63654c6f636b3c543a3a42616c616e63653e3e00040008b820416e79206c6971756964697479206c6f636b73206f6e20736f6d65206163636f756e742062616c616e6365732e2501204e4f54453a2053686f756c64206f6e6c79206265206163636573736564207768656e2073657474696e672c206368616e67696e6720616e642066726565696e672061206c6f636b2e3853746f7261676556657273696f6e01002052656c656173657304000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e00a020546869732069732073657420746f2076322e302e3020666f72206e6577206e6574776f726b732e0110207472616e736665720810646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e6cd8205472616e7366657220736f6d65206c697175696420667265652062616c616e636520746f20616e6f74686572206163636f756e742e00090120607472616e73666572602077696c6c207365742074686520604672656542616c616e636560206f66207468652073656e64657220616e642072656365697665722e21012049742077696c6c2064656372656173652074686520746f74616c2069737375616e6365206f66207468652073797374656d2062792074686520605472616e73666572466565602e1501204966207468652073656e6465722773206163636f756e742069732062656c6f7720746865206578697374656e7469616c206465706f736974206173206120726573756c74b4206f6620746865207472616e736665722c20746865206163636f756e742077696c6c206265207265617065642e00190120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d75737420626520605369676e65646020627920746865207472616e736163746f722e002c2023203c7765696768743e3101202d20446570656e64656e74206f6e20617267756d656e747320627574206e6f7420637269746963616c2c20676976656e2070726f70657220696d706c656d656e746174696f6e7320666f72cc202020696e70757420636f6e6669672074797065732e205365652072656c617465642066756e6374696f6e732062656c6f772e6901202d20497420636f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e642077726974657320696e7465726e616c6c7920616e64206e6f20636f6d706c657820636f6d7075746174696f6e2e004c2052656c617465642066756e6374696f6e733a0051012020202d2060656e737572655f63616e5f77697468647261776020697320616c776179732063616c6c656420696e7465726e616c6c792062757420686173206120626f756e64656420636f6d706c65786974792e2d012020202d205472616e7366657272696e672062616c616e63657320746f206163636f756e7473207468617420646964206e6f74206578697374206265666f72652077696c6c206361757365d420202020202060543a3a4f6e4e65774163636f756e743a3a6f6e5f6e65775f6163636f756e746020746f2062652063616c6c65642e61012020202d2052656d6f76696e6720656e6f7567682066756e64732066726f6d20616e206163636f756e742077696c6c20747269676765722060543a3a4475737452656d6f76616c3a3a6f6e5f756e62616c616e636564602e49012020202d20607472616e736665725f6b6565705f616c6976656020776f726b73207468652073616d652077617920617320607472616e73666572602c206275742068617320616e206164646974696f6e616cf82020202020636865636b207468617420746865207472616e736665722077696c6c206e6f74206b696c6c20746865206f726967696e206163636f756e742e88202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d4501202d2042617365205765696768743a2037332e363420c2b5732c20776f7273742063617365207363656e6172696f20286163636f756e7420637265617465642c206163636f756e742072656d6f76656429dc202d204442205765696768743a2031205265616420616e64203120577269746520746f2064657374696e6174696f6e206163636f756e741501202d204f726967696e206163636f756e7420697320616c726561647920696e206d656d6f72792c20736f206e6f204442206f7065726174696f6e7320666f72207468656d2e302023203c2f7765696768743e2c7365745f62616c616e63650c0c77686f8c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365206e65775f667265654c436f6d706163743c543a3a42616c616e63653e306e65775f72657365727665644c436f6d706163743c543a3a42616c616e63653e489420536574207468652062616c616e636573206f66206120676976656e206163636f756e742e00210120546869732077696c6c20616c74657220604672656542616c616e63656020616e642060526573657276656442616c616e63656020696e2073746f726167652e2069742077696c6c090120616c736f2064656372656173652074686520746f74616c2069737375616e6365206f66207468652073797374656d202860546f74616c49737375616e636560292e190120496620746865206e65772066726565206f722072657365727665642062616c616e63652069732062656c6f7720746865206578697374656e7469616c206465706f7369742c01012069742077696c6c20726573657420746865206163636f756e74206e6f6e63652028606672616d655f73797374656d3a3a4163636f756e744e6f6e636560292e00b420546865206469737061746368206f726967696e20666f7220746869732063616c6c2069732060726f6f74602e002c2023203c7765696768743e80202d20496e646570656e64656e74206f662074686520617267756d656e74732ec4202d20436f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e64207772697465732e58202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d3c202d2042617365205765696768743a6820202020202d204372656174696e673a2032372e353620c2b5736420202020202d204b696c6c696e673a2033352e313120c2b57398202d204442205765696768743a203120526561642c203120577269746520746f206077686f60302023203c2f7765696768743e38666f7263655f7472616e736665720c18736f757263658c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f7572636510646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e1851012045786163746c7920617320607472616e73666572602c2065786365707420746865206f726967696e206d75737420626520726f6f7420616e642074686520736f75726365206163636f756e74206d61792062652c207370656369666965642e2c2023203c7765696768743e4101202d2053616d65206173207472616e736665722c20627574206164646974696f6e616c207265616420616e6420777269746520626563617573652074686520736f75726365206163636f756e74206973902020206e6f7420617373756d656420746f20626520696e20746865206f7665726c61792e302023203c2f7765696768743e4c7472616e736665725f6b6565705f616c6976650810646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e2c51012053616d6520617320746865205b607472616e73666572605d2063616c6c2c206275742077697468206120636865636b207468617420746865207472616e736665722077696c6c206e6f74206b696c6c2074686540206f726967696e206163636f756e742e00bc20393925206f66207468652074696d6520796f752077616e74205b607472616e73666572605d20696e73746561642e00c4205b607472616e73666572605d3a207374727563742e50616c6c65742e68746d6c236d6574686f642e7472616e736665722c2023203c7765696768743ee8202d2043686561706572207468616e207472616e736665722062656361757365206163636f756e742063616e6e6f74206265206b696c6c65642e60202d2042617365205765696768743a2035312e3420c2b5731d01202d204442205765696768743a2031205265616420616e64203120577269746520746f2064657374202873656e64657220697320696e206f7665726c617920616c7265616479292c20233c2f7765696768743e01201c456e646f77656408244163636f756e7449641c42616c616e636504250120416e206163636f756e74207761732063726561746564207769746820736f6d6520667265652062616c616e63652e205c5b6163636f756e742c20667265655f62616c616e63655c5d20447573744c6f737408244163636f756e7449641c42616c616e636508410120416e206163636f756e74207761732072656d6f7665642077686f73652062616c616e636520776173206e6f6e2d7a65726f206275742062656c6f77204578697374656e7469616c4465706f7369742cd020726573756c74696e6720696e20616e206f75747269676874206c6f73732e205c5b6163636f756e742c2062616c616e63655c5d205472616e736665720c244163636f756e744964244163636f756e7449641c42616c616e636504a0205472616e73666572207375636365656465642e205c5b66726f6d2c20746f2c2076616c75655c5d2842616c616e63655365740c244163636f756e7449641c42616c616e63651c42616c616e636504cc20412062616c616e6365207761732073657420627920726f6f742e205c5b77686f2c20667265652c2072657365727665645c5d1c4465706f73697408244163636f756e7449641c42616c616e636504210120536f6d6520616d6f756e7420776173206465706f73697465642028652e672e20666f72207472616e73616374696f6e2066656573292e205c5b77686f2c206465706f7369745c5d20526573657276656408244163636f756e7449641c42616c616e636504210120536f6d652062616c616e63652077617320726573657276656420286d6f7665642066726f6d206672656520746f207265736572766564292e205c5b77686f2c2076616c75655c5d28556e726573657276656408244163636f756e7449641c42616c616e636504290120536f6d652062616c616e63652077617320756e726573657276656420286d6f7665642066726f6d20726573657276656420746f2066726565292e205c5b77686f2c2076616c75655c5d4852657365727665526570617472696174656410244163636f756e744964244163636f756e7449641c42616c616e6365185374617475730c510120536f6d652062616c616e636520776173206d6f7665642066726f6d207468652072657365727665206f6620746865206669727374206163636f756e7420746f20746865207365636f6e64206163636f756e742edc2046696e616c20617267756d656e7420696e64696361746573207468652064657374696e6174696f6e2062616c616e636520747970652ea8205c5b66726f6d2c20746f2c2062616c616e63652c2064657374696e6174696f6e5f7374617475735c5d04484578697374656e7469616c4465706f73697428543a3a42616c616e63654000e40b5402000000000000000000000004d420546865206d696e696d756d20616d6f756e7420726571756972656420746f206b65657020616e206163636f756e74206f70656e2e203856657374696e6742616c616e6365049c2056657374696e672062616c616e636520746f6f206869676820746f2073656e642076616c7565544c69717569646974795265737472696374696f6e7304c8204163636f756e74206c6971756964697479207265737472696374696f6e732070726576656e74207769746864726177616c204f766572666c6f77047420476f7420616e206f766572666c6f7720616674657220616464696e674c496e73756666696369656e7442616c616e636504782042616c616e636520746f6f206c6f7720746f2073656e642076616c7565484578697374656e7469616c4465706f73697404ec2056616c756520746f6f206c6f7720746f20637265617465206163636f756e742064756520746f206578697374656e7469616c206465706f736974244b656570416c6976650490205472616e736665722f7061796d656e7420776f756c64206b696c6c206163636f756e745c4578697374696e6756657374696e675363686564756c6504cc20412076657374696e67207363686564756c6520616c72656164792065786973747320666f722074686973206163636f756e742c446561644163636f756e74048c2042656e6566696369617279206163636f756e74206d757374207072652d657869737404485472616e73616374696f6e5061796d656e7401485472616e73616374696f6e5061796d656e7408444e6578744665654d756c7469706c6965720100284d756c7469706c69657240000064a7b3b6e00d0000000000000000003853746f7261676556657273696f6e01002052656c6561736573040000000008485472616e73616374696f6e427974654665653042616c616e63654f663c543e4000e1f505000000000000000000000000040d01205468652066656520746f206265207061696420666f72206d616b696e672061207472616e73616374696f6e3b20746865207065722d6279746520706f7274696f6e2e2c576569676874546f466565a45665633c576569676874546f466565436f656666696369656e743c42616c616e63654f663c543e3e3e5c0408000000000000000000000000000000000000000001040d012054686520706f6c796e6f6d69616c2074686174206973206170706c69656420696e206f7264657220746f20646572697665206665652066726f6d207765696768742e000528417574686f72736869700128417574686f72736869700c18556e636c65730100e85665633c556e636c65456e7472794974656d3c543a3a426c6f636b4e756d6265722c20543a3a486173682c20543a3a4163636f756e7449643e3e0400041c20556e636c657318417574686f72000030543a3a4163636f756e7449640400046420417574686f72206f662063757272656e7420626c6f636b2e30446964536574556e636c6573010010626f6f6c040004bc205768657468657220756e636c6573207765726520616c72656164792073657420696e207468697320626c6f636b2e0104287365745f756e636c657304286e65775f756e636c6573385665633c543a3a4865616465723e04642050726f76696465206120736574206f6620756e636c65732e00001c48496e76616c6964556e636c65506172656e74048c2054686520756e636c6520706172656e74206e6f7420696e2074686520636861696e2e40556e636c6573416c7265616479536574048420556e636c657320616c72656164792073657420696e2074686520626c6f636b2e34546f6f4d616e79556e636c6573044420546f6f206d616e7920756e636c65732e3047656e65736973556e636c6504582054686520756e636c652069732067656e657369732e30546f6f48696768556e636c6504802054686520756e636c6520697320746f6f206869676820696e20636861696e2e50556e636c65416c7265616479496e636c75646564047c2054686520756e636c6520697320616c726561647920696e636c756465642e204f6c64556e636c6504b82054686520756e636c652069736e277420726563656e7420656e6f75676820746f20626520696e636c756465642e06204f6666656e63657301204f6666656e636573101c5265706f727473000105345265706f727449644f663c543ed04f6666656e636544657461696c733c543a3a4163636f756e7449642c20543a3a4964656e74696669636174696f6e5475706c653e00040004490120546865207072696d61727920737472756374757265207468617420686f6c647320616c6c206f6666656e6365207265636f726473206b65796564206279207265706f7274206964656e746966696572732e4044656665727265644f6666656e6365730100645665633c44656665727265644f6666656e63654f663c543e3e0400086501204465666572726564207265706f72747320746861742068617665206265656e2072656a656374656420627920746865206f6666656e63652068616e646c657220616e64206e65656420746f206265207375626d6974746564442061742061206c617465722074696d652e58436f6e63757272656e745265706f727473496e646578010205104b696e64384f706171756554696d65536c6f74485665633c5265706f727449644f663c543e3e050400042901204120766563746f72206f66207265706f727473206f66207468652073616d65206b696e6420746861742068617070656e6564206174207468652073616d652074696d6520736c6f742e485265706f72747342794b696e64496e646578010105104b696e641c5665633c75383e00040018110120456e756d65726174657320616c6c207265706f727473206f662061206b696e6420616c6f6e672077697468207468652074696d6520746865792068617070656e65642e00bc20416c6c207265706f7274732061726520736f72746564206279207468652074696d65206f66206f6666656e63652e004901204e6f74652074686174207468652061637475616c2074797065206f662074686973206d617070696e6720697320605665633c75383e602c207468697320697320626563617573652076616c756573206f66690120646966666572656e7420747970657320617265206e6f7420737570706f7274656420617420746865206d6f6d656e7420736f2077652061726520646f696e6720746865206d616e75616c2073657269616c697a6174696f6e2e010001041c4f6666656e63650c104b696e64384f706171756554696d65536c6f7410626f6f6c10550120546865726520697320616e206f6666656e6365207265706f72746564206f662074686520676976656e20606b696e64602068617070656e656420617420746865206073657373696f6e5f696e6465786020616e644d0120286b696e642d7370656369666963292074696d6520736c6f742e2054686973206576656e74206973206e6f74206465706f736974656420666f72206475706c696361746520736c61736865732e206c617374190120656c656d656e7420696e64696361746573206f6620746865206f6666656e636520776173206170706c69656420287472756529206f7220717565756564202866616c73652974205c5b6b696e642c2074696d65736c6f742c206170706c6965645c5d2e00000728486973746f726963616c0000000000081c53657373696f6e011c53657373696f6e1c2856616c696461746f727301004c5665633c543a3a56616c696461746f7249643e0400047c205468652063757272656e7420736574206f662076616c696461746f72732e3043757272656e74496e64657801003053657373696f6e496e646578100000000004782043757272656e7420696e646578206f66207468652073657373696f6e2e345175657565644368616e676564010010626f6f6c040008390120547275652069662074686520756e6465726c79696e672065636f6e6f6d6963206964656e746974696573206f7220776569676874696e6720626568696e64207468652076616c696461746f7273a420686173206368616e67656420696e20746865207175657565642076616c696461746f72207365742e285175657565644b6579730100785665633c28543a3a56616c696461746f7249642c20543a3a4b657973293e0400083d012054686520717565756564206b65797320666f7220746865206e6578742073657373696f6e2e205768656e20746865206e6578742073657373696f6e20626567696e732c207468657365206b657973e02077696c6c206265207573656420746f2064657465726d696e65207468652076616c696461746f7227732073657373696f6e206b6579732e4844697361626c656456616c696461746f72730100205665633c7533323e04000c8020496e6469636573206f662064697361626c65642076616c696461746f72732e003501205468652073657420697320636c6561726564207768656e20606f6e5f73657373696f6e5f656e64696e67602072657475726e732061206e657720736574206f66206964656e7469746965732e204e6578744b65797300010538543a3a56616c696461746f7249641c543a3a4b657973000400049c20546865206e6578742073657373696f6e206b65797320666f7220612076616c696461746f722e204b65794f776e657200010550284b65795479706549642c205665633c75383e2938543a3a56616c696461746f72496400040004090120546865206f776e6572206f662061206b65792e20546865206b65792069732074686520604b657954797065496460202b2074686520656e636f646564206b65792e0108207365745f6b65797308106b6579731c543a3a4b6579731470726f6f661c5665633c75383e38e82053657473207468652073657373696f6e206b6579287329206f66207468652066756e6374696f6e2063616c6c657220746f20606b657973602e210120416c6c6f777320616e206163636f756e7420746f20736574206974732073657373696f6e206b6579207072696f7220746f206265636f6d696e6720612076616c696461746f722ec4205468697320646f65736e27742074616b652065666665637420756e74696c20746865206e6578742073657373696f6e2e00d420546865206469737061746368206f726967696e206f6620746869732066756e6374696f6e206d757374206265207369676e65642e002c2023203c7765696768743e54202d20436f6d706c65786974793a20604f28312960590120202041637475616c20636f737420646570656e6473206f6e20746865206e756d626572206f66206c656e677468206f662060543a3a4b6579733a3a6b65795f6964732829602077686963682069732066697865642ef0202d20446252656164733a20606f726967696e206163636f756e74602c2060543a3a56616c696461746f7249644f66602c20604e6578744b65797360a4202d2044625772697465733a20606f726967696e206163636f756e74602c20604e6578744b6579736084202d204462526561647320706572206b65792069643a20604b65794f776e65726088202d20446257726974657320706572206b65792069643a20604b65794f776e657260302023203c2f7765696768743e2870757267655f6b6579730030cc2052656d6f76657320616e792073657373696f6e206b6579287329206f66207468652066756e6374696f6e2063616c6c65722ec4205468697320646f65736e27742074616b652065666665637420756e74696c20746865206e6578742073657373696f6e2e00d420546865206469737061746368206f726967696e206f6620746869732066756e6374696f6e206d757374206265207369676e65642e002c2023203c7765696768743eb4202d20436f6d706c65786974793a20604f2831296020696e206e756d626572206f66206b65792074797065732e590120202041637475616c20636f737420646570656e6473206f6e20746865206e756d626572206f66206c656e677468206f662060543a3a4b6579733a3a6b65795f6964732829602077686963682069732066697865642ef0202d20446252656164733a2060543a3a56616c696461746f7249644f66602c20604e6578744b657973602c20606f726967696e206163636f756e7460a4202d2044625772697465733a20604e6578744b657973602c20606f726967696e206163636f756e74608c202d20446257726974657320706572206b65792069643a20604b65794f776e64657260302023203c2f7765696768743e0104284e657753657373696f6e043053657373696f6e496e646578086501204e65772073657373696f6e206861732068617070656e65642e204e6f746520746861742074686520617267756d656e7420697320746865205c5b73657373696f6e5f696e6465785c5d2c206e6f742074686520626c6f636b88206e756d626572206173207468652074797065206d6967687420737567676573742e001430496e76616c696450726f6f66046420496e76616c6964206f776e6572736869702070726f6f662e5c4e6f4173736f63696174656456616c696461746f72496404a0204e6f206173736f6369617465642076616c696461746f7220494420666f72206163636f756e742e344475706c6963617465644b657904682052656769737465726564206475706c6963617465206b65792e184e6f4b65797304a8204e6f206b65797320617265206173736f63696174656420776974682074686973206163636f756e742e244e6f4163636f756e74041d01204b65792073657474696e67206163636f756e74206973206e6f74206c6976652c20736f206974277320696d706f737369626c6520746f206173736f6369617465206b6579732e091c4772616e647061013c4772616e64706146696e616c6974791814537461746501006c53746f72656453746174653c543a3a426c6f636b4e756d6265723e04000490205374617465206f66207468652063757272656e7420617574686f72697479207365742e3450656e64696e674368616e676500008c53746f72656450656e64696e674368616e67653c543a3a426c6f636b4e756d6265723e040004c42050656e64696e67206368616e67653a20287369676e616c65642061742c207363686564756c6564206368616e6765292e284e657874466f72636564000038543a3a426c6f636b4e756d626572040004bc206e65787420626c6f636b206e756d6265722077686572652077652063616e20666f7263652061206368616e67652e1c5374616c6c656400008028543a3a426c6f636b4e756d6265722c20543a3a426c6f636b4e756d626572290400049020607472756560206966207765206172652063757272656e746c79207374616c6c65642e3043757272656e7453657449640100145365744964200000000000000000085d0120546865206e756d626572206f66206368616e6765732028626f746820696e207465726d73206f66206b65797320616e6420756e6465726c79696e672065636f6e6f6d696320726573706f6e736962696c697469657329c420696e20746865202273657422206f66204772616e6470612076616c696461746f72732066726f6d2067656e657369732e30536574496453657373696f6e0001051453657449643053657373696f6e496e6465780004001059012041206d617070696e672066726f6d206772616e6470612073657420494420746f2074686520696e646578206f6620746865202a6d6f737420726563656e742a2073657373696f6e20666f722077686963682069747368206d656d62657273207765726520726573706f6e7369626c652e00b82054574f582d4e4f54453a2060536574496460206973206e6f7420756e646572207573657220636f6e74726f6c2e010c4c7265706f72745f65717569766f636174696f6e084865717569766f636174696f6e5f70726f6f66a845717569766f636174696f6e50726f6f663c543a3a486173682c20543a3a426c6f636b4e756d6265723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66100d01205265706f727420766f7465722065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c2076657269667920746865f82065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66fc20616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e6365482077696c6c206265207265706f727465642e707265706f72745f65717569766f636174696f6e5f756e7369676e6564084865717569766f636174696f6e5f70726f6f66a845717569766f636174696f6e50726f6f663c543a3a486173682c20543a3a426c6f636b4e756d6265723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66240d01205265706f727420766f7465722065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c2076657269667920746865f82065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66fc20616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e6365482077696c6c206265207265706f727465642e00110120546869732065787472696e736963206d7573742062652063616c6c656420756e7369676e656420616e642069742069732065787065637465642074686174206f6e6c79190120626c6f636b20617574686f72732077696c6c2063616c6c206974202876616c69646174656420696e206056616c6964617465556e7369676e656460292c206173207375636819012069662074686520626c6f636b20617574686f7220697320646566696e65642069742077696c6c20626520646566696e6564206173207468652065717569766f636174696f6e28207265706f727465722e306e6f74655f7374616c6c6564081464656c617938543a3a426c6f636b4e756d6265726c626573745f66696e616c697a65645f626c6f636b5f6e756d62657238543a3a426c6f636b4e756d6265721c1d01204e6f74652074686174207468652063757272656e7420617574686f7269747920736574206f6620746865204752414e4450412066696e616c69747920676164676574206861732901207374616c6c65642e20546869732077696c6c2074726967676572206120666f7263656420617574686f7269747920736574206368616e67652061742074686520626567696e6e696e672101206f6620746865206e6578742073657373696f6e2c20746f20626520656e6163746564206064656c61796020626c6f636b7320616674657220746861742e205468652064656c617915012073686f756c64206265206869676820656e6f75676820746f20736166656c7920617373756d6520746861742074686520626c6f636b207369676e616c6c696e6720746865290120666f72636564206368616e67652077696c6c206e6f742062652072652d6f726765642028652e672e203130303020626c6f636b73292e20546865204752414e44504120766f7465727329012077696c6c20737461727420746865206e657720617574686f7269747920736574207573696e672074686520676976656e2066696e616c697a656420626c6f636b20617320626173652e5c204f6e6c792063616c6c61626c6520627920726f6f742e010c384e6577417574686f7269746965730434417574686f726974794c69737404d8204e657720617574686f726974792073657420686173206265656e206170706c6965642e205c5b617574686f726974795f7365745c5d1850617573656400049c2043757272656e7420617574686f726974792073657420686173206265656e207061757365642e1c526573756d65640004a02043757272656e7420617574686f726974792073657420686173206265656e20726573756d65642e001c2c50617573654661696c656408090120417474656d707420746f207369676e616c204752414e445041207061757365207768656e2074686520617574686f72697479207365742069736e2774206c697665a8202865697468657220706175736564206f7220616c72656164792070656e64696e67207061757365292e30526573756d654661696c656408150120417474656d707420746f207369676e616c204752414e44504120726573756d65207768656e2074686520617574686f72697479207365742069736e277420706175736564a42028656974686572206c697665206f7220616c72656164792070656e64696e6720726573756d65292e344368616e676550656e64696e6704ec20417474656d707420746f207369676e616c204752414e445041206368616e67652077697468206f6e6520616c72656164792070656e64696e672e1c546f6f536f6f6e04c02043616e6e6f74207369676e616c20666f72636564206368616e676520736f20736f6f6e206166746572206c6173742e60496e76616c69644b65794f776e65727368697050726f6f660435012041206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c696445717569766f636174696f6e50726f6f6604350120416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f7274041901204120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e0a20496d4f6e6c696e650120496d4f6e6c696e6510384865617274626561744166746572010038543a3a426c6f636b4e756d62657210000000002c1d012054686520626c6f636b206e756d6265722061667465722077686963682069742773206f6b20746f2073656e64206865617274626561747320696e207468652063757272656e74242073657373696f6e2e0025012041742074686520626567696e6e696e67206f6620656163682073657373696f6e20776520736574207468697320746f20612076616c756520746861742073686f756c642066616c6c350120726f7567686c7920696e20746865206d6964646c65206f66207468652073657373696f6e206475726174696f6e2e20546865206964656120697320746f206669727374207761697420666f721901207468652076616c696461746f727320746f2070726f64756365206120626c6f636b20696e207468652063757272656e742073657373696f6e2c20736f207468617420746865a820686561727462656174206c61746572206f6e2077696c6c206e6f74206265206e65636573736172792e00390120546869732076616c75652077696c6c206f6e6c79206265207573656420617320612066616c6c6261636b206966207765206661696c20746f2067657420612070726f7065722073657373696f6e2d012070726f677265737320657374696d6174652066726f6d20604e65787453657373696f6e526f746174696f6e602c2061732074686f736520657374696d617465732073686f756c642062650101206d6f7265206163637572617465207468656e207468652076616c75652077652063616c63756c61746520666f7220604865617274626561744166746572602e104b65797301004c5665633c543a3a417574686f7269747949643e040004d0205468652063757272656e7420736574206f66206b6579732074686174206d61792069737375652061206865617274626561742e485265636569766564486561727462656174730002053053657373696f6e496e6465782441757468496e6465781c5665633c75383e05040008f020466f7220656163682073657373696f6e20696e6465782c207765206b6565702061206d617070696e67206f66206041757468496e6465786020746f8020606f6666636861696e3a3a4f70617175654e6574776f726b5374617465602e38417574686f726564426c6f636b730102053053657373696f6e496e6465783856616c696461746f7249643c543e0c75333205100000000008150120466f7220656163682073657373696f6e20696e6465782c207765206b6565702061206d617070696e67206f66206056616c696461746f7249643c543e6020746f20746865c8206e756d626572206f6620626c6f636b7320617574686f7265642062792074686520676976656e20617574686f726974792e0104246865617274626561740824686561727462656174644865617274626561743c543a3a426c6f636b4e756d6265723e285f7369676e6174757265bc3c543a3a417574686f7269747949642061732052756e74696d654170705075626c69633e3a3a5369676e6174757265242c2023203c7765696768743e4101202d20436f6d706c65786974793a20604f284b202b20452960207768657265204b206973206c656e677468206f6620604b6579736020286865617274626561742e76616c696461746f72735f6c656e290101202020616e642045206973206c656e677468206f6620606865617274626561742e6e6574776f726b5f73746174652e65787465726e616c5f61646472657373608c2020202d20604f284b29603a206465636f64696e67206f66206c656e67746820604b60b02020202d20604f284529603a206465636f64696e672f656e636f64696e67206f66206c656e677468206045603d01202d20446252656164733a2070616c6c65745f73657373696f6e206056616c696461746f7273602c2070616c6c65745f73657373696f6e206043757272656e74496e646578602c20604b657973602c5c202020605265636569766564486561727462656174736084202d2044625772697465733a206052656365697665644865617274626561747360302023203c2f7765696768743e010c444865617274626561745265636569766564042c417574686f7269747949640405012041206e657720686561727462656174207761732072656365697665642066726f6d2060417574686f72697479496460205c5b617574686f726974795f69645c5d1c416c6c476f6f640004d42041742074686520656e64206f66207468652073657373696f6e2c206e6f206f6666656e63652077617320636f6d6d69747465642e2c536f6d654f66666c696e6504605665633c4964656e74696669636174696f6e5475706c653e043d012041742074686520656e64206f66207468652073657373696f6e2c206174206c65617374206f6e652076616c696461746f722077617320666f756e6420746f206265205c5b6f66666c696e655c5d2e000828496e76616c69644b65790464204e6f6e206578697374656e74207075626c6963206b65792e4c4475706c6963617465644865617274626561740458204475706c696361746564206865617274626561742e0b48417574686f72697479446973636f766572790001000000000c4050617261636861696e734f726967696e00000000000d5c50617261636861696e73436f6e66696775726174696f6e0134436f6e66696775726174696f6e0830416374697665436f6e666967010084486f7374436f6e66696775726174696f6e3c543a3a426c6f636b4e756d6265723ed9020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000100000001000000000000000000060000006400000002000000c8000000010000000000000000000000000000000000000004c8205468652061637469766520636f6e66696775726174696f6e20666f72207468652063757272656e742073657373696f6e2e3450656e64696e67436f6e6669670001053053657373696f6e496e64657884486f7374436f6e66696775726174696f6e3c543a3a426c6f636b4e756d6265723e00040004d42050656e64696e6720636f6e66696775726174696f6e2028696620616e792920666f7220746865206e6578742073657373696f6e2e01a0807365745f76616c69646174696f6e5f757067726164655f6672657175656e6379040c6e657738543a3a426c6f636b4e756d626572049820536574207468652076616c69646174696f6e2075706772616465206672657175656e63792e707365745f76616c69646174696f6e5f757067726164655f64656c6179040c6e657738543a3a426c6f636b4e756d626572048820536574207468652076616c69646174696f6e20757067726164652064656c61792e647365745f636f64655f726574656e74696f6e5f706572696f64040c6e657738543a3a426c6f636b4e756d62657204d4205365742074686520616363657074616e636520706572696f6420666f7220616e20696e636c756465642063616e6469646174652e447365745f6d61785f636f64655f73697a65040c6e65770c75333204e02053657420746865206d61782076616c69646174696f6e20636f64652073697a6520666f7220696e636f6d696e672075706772616465732e407365745f6d61785f706f765f73697a65040c6e65770c75333204c82053657420746865206d617820504f5620626c6f636b2073697a6520666f7220696e636f6d696e672075706772616465732e587365745f6d61785f686561645f646174615f73697a65040c6e65770c75333204982053657420746865206d6178206865616420646174612073697a6520666f722070617261732e507365745f706172617468726561645f636f726573040c6e65770c75333204b82053657420746865206e756d626572206f66207061726174687265616420657865637574696f6e20636f7265732e587365745f706172617468726561645f72657472696573040c6e65770c75333204dc2053657420746865206e756d626572206f66207265747269657320666f72206120706172746963756c617220706172617468726561642e707365745f67726f75705f726f746174696f6e5f6672657175656e6379040c6e657738543a3a426c6f636b4e756d62657204d420536574207468652070617261636861696e2076616c696461746f722d67726f757020726f746174696f6e206672657175656e6379747365745f636861696e5f617661696c6162696c6974795f706572696f64040c6e657738543a3a426c6f636b4e756d62657204b0205365742074686520617661696c6162696c69747920706572696f6420666f722070617261636861696e732e787365745f7468726561645f617661696c6162696c6974795f706572696f64040c6e657738543a3a426c6f636b4e756d62657204b4205365742074686520617661696c6162696c69747920706572696f6420666f722070617261746872656164732e607365745f7363686564756c696e675f6c6f6f6b6168656164040c6e65770c753332043d012053657420746865207363686564756c696e67206c6f6f6b61686561642c20696e206578706563746564206e756d626572206f6620626c6f636b73206174207065616b207468726f7567687075742e6c7365745f6d61785f76616c696461746f72735f7065725f636f7265040c6e65772c4f7074696f6e3c7533323e04f02053657420746865206d6178696d756d206e756d626572206f662076616c696461746f727320746f2061737369676e20746f20616e7920636f72652e487365745f6d61785f76616c696461746f7273040c6e65772c4f7074696f6e3c7533323e0411012053657420746865206d6178696d756d206e756d626572206f662076616c696461746f727320746f2075736520696e2070617261636861696e20636f6e73656e7375732e487365745f646973707574655f706572696f64040c6e65773053657373696f6e496e6465780411012053657420746865206469737075746520706572696f642c20696e206e756d626572206f662073657373696f6e7320746f206b65657020666f722064697370757465732eb47365745f646973707574655f706f73745f636f6e636c7573696f6e5f616363657074616e63655f706572696f64040c6e657738543a3a426c6f636b4e756d62657204cc2053657420746865206469737075746520706f737420636f6e636c7573696f6e20616363657074616e636520706572696f642e687365745f646973707574655f6d61785f7370616d5f736c6f7473040c6e65770c75333204b82053657420746865206d6178696d756d206e756d626572206f662064697370757465207370616d20736c6f74732ea47365745f646973707574655f636f6e636c7573696f6e5f62795f74696d655f6f75745f706572696f64040c6e657738543a3a426c6f636b4e756d62657204bc2053657420746865206469737075746520636f6e636c7573696f6e2062792074696d65206f757420706572696f642e447365745f6e6f5f73686f775f736c6f7473040c6e65770c75333208fc2053657420746865206e6f2073686f7720736c6f74732c20696e206e756d626572206f66206e756d626572206f6620636f6e73656e73757320736c6f74732e50204d757374206265206174206c6561737420312e507365745f6e5f64656c61795f7472616e63686573040c6e65770c75333204a0205365742074686520746f74616c206e756d626572206f662064656c6179207472616e636865732e787365745f7a65726f74685f64656c61795f7472616e6368655f7769647468040c6e65770c75333204902053657420746865207a65726f74682064656c6179207472616e6368652077696474682e507365745f6e65656465645f617070726f76616c73040c6e65770c75333204e02053657420746865206e756d626572206f662076616c696461746f7273206e656564656420746f20617070726f7665206120626c6f636b2e707365745f72656c61795f7672665f6d6f64756c6f5f73616d706c6573040c6e65770c7533320455012053657420746865206e756d626572206f662073616d706c657320746f20646f206f66207468652052656c61795652464d6f64756c6f20617070726f76616c2061737369676e6d656e7420637269746572696f6e2e687365745f6d61785f7570776172645f71756575655f636f756e74040c6e65770c753332043101205365747320746865206d6178696d756d206974656d7320746861742063616e2070726573656e7420696e206120757077617264206469737061746368207175657565206174206f6e63652e647365745f6d61785f7570776172645f71756575655f73697a65040c6e65770c753332046901205365747320746865206d6178696d756d20746f74616c2073697a65206f66206974656d7320746861742063616e2070726573656e7420696e206120757077617264206469737061746368207175657565206174206f6e63652e747365745f6d61785f646f776e776172645f6d6573736167655f73697a65040c6e65770c75333204a0205365742074686520637269746963616c20646f776e77617264206d6573736167652073697a652ed87365745f7072656665727265645f646973706174636861626c655f7570776172645f6d657373616765735f737465705f776569676874040c6e657718576569676874043d0120536574732074686520736f6674206c696d697420666f7220746865207068617365206f66206469737061746368696e6720646973706174636861626c6520757077617264206d657373616765732e6c7365745f6d61785f7570776172645f6d6573736167655f73697a65040c6e65770c753332043101205365747320746865206d6178696d756d2073697a65206f6620616e20757077617264206d65737361676520746861742063616e2062652073656e7420627920612063616e6469646174652ea07365745f6d61785f7570776172645f6d6573736167655f6e756d5f7065725f63616e646964617465040c6e65770c753332040901205365747320746865206d6178696d756d206e756d626572206f66206d65737361676573207468617420612063616e6469646174652063616e20636f6e7461696e2e647365745f68726d705f6f70656e5f726571756573745f74746c040c6e65770c753332043901205365747320746865206e756d626572206f662073657373696f6e7320616674657220776869636820616e2048524d50206f70656e206368616e6e656c207265717565737420657870697265732e5c7365745f68726d705f73656e6465725f6465706f736974040c6e65771c42616c616e636504550120536574732074686520616d6f756e74206f662066756e64732074686174207468652073656e6465722073686f756c642070726f7669646520666f72206f70656e696e6720616e2048524d50206368616e6e656c2e687365745f68726d705f726563697069656e745f6465706f736974040c6e65771c42616c616e636508650120536574732074686520616d6f756e74206f662066756e647320746861742074686520726563697069656e742073686f756c642070726f7669646520666f7220616363657074696e67206f70656e696e6720616e2048524d5024206368616e6e656c2e747365745f68726d705f6368616e6e656c5f6d61785f6361706163697479040c6e65770c753332042101205365747320746865206d6178696d756d206e756d626572206f66206d6573736167657320616c6c6f77656420696e20616e2048524d50206368616e6e656c206174206f6e63652e7c7365745f68726d705f6368616e6e656c5f6d61785f746f74616c5f73697a65040c6e65770c753332045501205365747320746865206d6178696d756d20746f74616c2073697a65206f66206d6573736167657320696e20627974657320616c6c6f77656420696e20616e2048524d50206368616e6e656c206174206f6e63652e9c7365745f68726d705f6d61785f70617261636861696e5f696e626f756e645f6368616e6e656c73040c6e65770c753332044d01205365747320746865206d6178696d756d206e756d626572206f6620696e626f756e642048524d50206368616e6e656c7320612070617261636861696e20697320616c6c6f77656420746f206163636570742ea07365745f68726d705f6d61785f706172617468726561645f696e626f756e645f6368616e6e656c73040c6e65770c753332045101205365747320746865206d6178696d756d206e756d626572206f6620696e626f756e642048524d50206368616e6e656c732061207061726174687265616420697320616c6c6f77656420746f206163636570742e847365745f68726d705f6368616e6e656c5f6d61785f6d6573736167655f73697a65040c6e65770c753332044101205365747320746865206d6178696d756d2073697a65206f662061206d657373616765207468617420636f756c6420657665722062652070757420696e746f20616e2048524d50206368616e6e656c2ea07365745f68726d705f6d61785f70617261636861696e5f6f7574626f756e645f6368616e6e656c73040c6e65770c753332044901205365747320746865206d6178696d756d206e756d626572206f66206f7574626f756e642048524d50206368616e6e656c7320612070617261636861696e20697320616c6c6f77656420746f206f70656e2ea47365745f68726d705f6d61785f706172617468726561645f6f7574626f756e645f6368616e6e656c73040c6e65770c753332044d01205365747320746865206d6178696d756d206e756d626572206f66206f7574626f756e642048524d50206368616e6e656c732061207061726174687265616420697320616c6c6f77656420746f206f70656e2e987365745f68726d705f6d61785f6d6573736167655f6e756d5f7065725f63616e646964617465040c6e65770c753332043901205365747320746865206d6178696d756d206e756d626572206f66206f7574626f756e642048524d50206d657373616765732063616e2062652073656e7420627920612063616e6469646174652e0000043c496e76616c69644e657756616c756504e020546865206e65772076616c756520666f72206120636f6e66696775726174696f6e20706172616d6574657220697320696e76616c69642e0e18536861726564012c50617261735368617265640c4c43757272656e7453657373696f6e496e64657801003053657373696f6e496e6465781000000000046c205468652063757272656e742073657373696f6e20696e6465782e5841637469766556616c696461746f72496e646963657301004c5665633c56616c696461746f72496e6465783e040008090120416c6c207468652076616c696461746f7273206163746976656c792070617274696369706174696e6720696e2070617261636861696e20636f6e73656e7375732eb020496e64696365732061726520696e746f207468652062726f616465722076616c696461746f72207365742e4c41637469766556616c696461746f724b6579730100405665633c56616c696461746f7249643e0400088101205468652070617261636861696e206174746573746174696f6e206b657973206f66207468652076616c696461746f7273206163746976656c792070617274696369706174696e6720696e2070617261636861696e20636f6e73656e7375732ef020546869732073686f756c64206265207468652073616d65206c656e677468206173206041637469766556616c696461746f72496e6469636573602e01000000000f24496e636c7573696f6e013450617261496e636c7573696f6e0c54417661696c6162696c6974794269746669656c64730001053856616c696461746f72496e646578a8417661696c6162696c6974794269746669656c645265636f72643c543a3a426c6f636b4e756d6265723e00040004650120546865206c6174657374206269746669656c6420666f7220656163682076616c696461746f722c20726566657272656420746f20627920746865697220696e64657820696e207468652076616c696461746f72207365742e4c50656e64696e67417661696c6162696c69747900010518506172614964d443616e64696461746550656e64696e67417661696c6162696c6974793c543a3a486173682c20543a3a426c6f636b4e756d6265723e00040004b42043616e646964617465732070656e64696e6720617661696c6162696c6974792062792060506172614964602e7850656e64696e67417661696c6162696c697479436f6d6d69746d656e7473000105185061726149645043616e646964617465436f6d6d69746d656e747300040004fc2054686520636f6d6d69746d656e7473206f662063616e646964617465732070656e64696e6720617661696c6162696c6974792c206279205061726149642e0100010c3c43616e6469646174654261636b6564105843616e646964617465526563656970743c486173683e20486561644461746124436f7265496e6465782847726f7570496e64657804bc20412063616e64696461746520776173206261636b65642e205b63616e6469646174652c20686561645f646174615d4443616e646964617465496e636c75646564105843616e646964617465526563656970743c486173683e20486561644461746124436f7265496e6465782847726f7570496e64657804c420412063616e6469646174652077617320696e636c756465642e205b63616e6469646174652c20686561645f646174615d4443616e64696461746554696d65644f75740c5843616e646964617465526563656970743c486173683e20486561644461746124436f7265496e64657804b820412063616e6469646174652074696d6564206f75742e205b63616e6469646174652c20686561645f646174615d00604457726f6e674269746669656c6453697a6504ac20417661696c6162696c697479206269746669656c642068617320756e65787065637465642073697a652e704269746669656c644475706c69636174654f72556e6f726465726564045101204d756c7469706c65206269746669656c6473207375626d69747465642062792073616d652076616c696461746f72206f722076616c696461746f7273206f7574206f66206f7264657220627920696e6465782e6456616c696461746f72496e6465784f75744f66426f756e6473047c2056616c696461746f7220696e646578206f7574206f6620626f756e64732e60496e76616c69644269746669656c645369676e6174757265044820496e76616c6964207369676e617475726550556e7363686564756c656443616e64696461746504b02043616e646964617465207375626d6974746564206275742070617261206e6f74207363686564756c65642e8043616e6469646174655363686564756c65644265666f726550617261467265650435012043616e646964617465207363686564756c656420646573706974652070656e64696e672063616e64696461746520616c7265616479206578697374696e6720666f722074686520706172612e3457726f6e67436f6c6c61746f7204b02043616e64696461746520696e636c756465642077697468207468652077726f6e6720636f6c6c61746f722e4c5363686564756c65644f75744f664f726465720478205363686564756c656420636f726573206f7574206f66206f726465722e404865616444617461546f6f4c6172676504a82048656164206461746120657863656564732074686520636f6e66696775726564206d6178696d756d2e505072656d6174757265436f646555706772616465046820436f64652075706772616465207072656d61747572656c792e3c4e6577436f6465546f6f4c617267650464204f757470757420636f646520697320746f6f206c617267656c43616e6469646174654e6f74496e506172656e74436f6e7465787404842043616e646964617465206e6f7420696e20706172656e7420636f6e746578742e5c556e6f63637570696564426974496e4269746669656c6404250120546865206269746669656c6420636f6e7461696e732061206269742072656c6174696e6720746f20616e20756e61737369676e656420617661696c6162696c69747920636f72652e44496e76616c696447726f7570496e64657804a020496e76616c69642067726f757020696e64657820696e20636f72652061737369676e6d656e742e4c496e73756666696369656e744261636b696e67049420496e73756666696369656e7420286e6f6e2d6d616a6f7269747929206261636b696e672e38496e76616c69644261636b696e6704e820496e76616c69642028626164207369676e61747572652c20756e6b6e6f776e2076616c696461746f722c206574632e29206261636b696e672e444e6f74436f6c6c61746f725369676e6564046c20436f6c6c61746f7220646964206e6f74207369676e20506f562e6856616c69646174696f6e44617461486173684d69736d6174636804c8205468652076616c69646174696f6e2064617461206861736820646f6573206e6f74206d617463682065787065637465642e34496e7465726e616c4572726f7204090120496e7465726e616c206572726f72206f6e6c792072657475726e6564207768656e20636f6d70696c6564207769746820646562756720617373657274696f6e732e80496e636f7272656374446f776e776172644d65737361676548616e646c696e6704dc2054686520646f776e77617264206d657373616765207175657565206973206e6f742070726f63657373656420636f72726563746c792e54496e76616c69645570776172644d65737361676573042101204174206c65617374206f6e6520757077617264206d6573736167652073656e7420646f6573206e6f7420706173732074686520616363657074616e63652063726974657269612e6048726d7057617465726d61726b4d697368616e646c696e67041501205468652063616e646964617465206469646e277420666f6c6c6f77207468652072756c6573206f662048524d502077617465726d61726b20616476616e63656d656e742e4c496e76616c69644f7574626f756e6448726d7004d8205468652048524d50206d657373616765732073656e74206279207468652063616e646964617465206973206e6f742076616c69642e64496e76616c696456616c69646174696f6e436f64654861736804e0205468652076616c69646174696f6e20636f64652068617368206f66207468652063616e646964617465206973206e6f742076616c69642e10345061726173496e686572656e74013050617261496e686572656e740420496e636c756465640000082829040018ec20576865746865722074686520706172617320696e686572656e742077617320696e636c756465642077697468696e207468697320626c6f636b2e0061012054686520604f7074696f6e3c28293e60206973206566666563746976656c79206120626f6f6c2c20627574206974206e6576657220686974732073746f7261676520696e2074686520604e6f6e65602076617269616e74bc2064756520746f207468652067756172616e74656573206f66204652414d4527732073746f7261676520415049732e004901204966207468697320697320604e6f6e65602061742074686520656e64206f662074686520626c6f636b2c2077652070616e696320616e642072656e6465722074686520626c6f636b20696e76616c69642e010414656e7465720410646174618450617261636861696e73496e686572656e74446174613c543a3a4865616465723e04350120456e7465722074686520706172617320696e686572656e742e20546869732077696c6c2070726f63657373206269746669656c647320616e64206261636b65642063616e646964617465732e00000864546f6f4d616e79496e636c7573696f6e496e686572656e747304d020496e636c7573696f6e20696e686572656e742063616c6c6564206d6f7265207468616e206f6e63652070657220626c6f636b2e4c496e76616c6964506172656e74486561646572085901205468652068617368206f6620746865207375626d697474656420706172656e742068656164657220646f65736e277420636f72726573706f6e6420746f2074686520736176656420626c6f636b2068617368206f66302074686520706172656e742e11245363686564756c65720134506172615363686564756c6572183c56616c696461746f7247726f7570730100605665633c5665633c56616c696461746f72496e6465783e3e0400186d0120416c6c207468652076616c696461746f722067726f7570732e204f6e6520666f72206561636820636f72652e20496e64696365732061726520696e746f206041637469766556616c696461746f727360202d206e6f74207468656d012062726f6164657220736574206f6620506f6c6b61646f742076616c696461746f72732c2062757420696e7374656164206a7573742074686520737562736574207573656420666f722070617261636861696e7320647572696e673820746869732073657373696f6e2e00810120426f756e643a20546865206e756d626572206f6620636f726573206973207468652073756d206f6620746865206e756d62657273206f662070617261636861696e7320616e642070617261746872656164206d756c7469706c65786572732e810120526561736f6e61626c792c203130302d313030302e2054686520646f6d696e616e7420666163746f7220697320746865206e756d626572206f662076616c696461746f72733a207361666520757070657220626f756e642061742031306b2e3c50617261746872656164517565756501005050617261746872656164436c61696d51756575651400000000001019012041207175657565206f66207570636f6d696e6720636c61696d7320616e6420776869636820636f726520746865792073686f756c64206265206d6170706564206f6e746f2e00150120546865206e756d626572206f662071756575656420636c61696d7320697320626f756e6465642061742074686520607363686564756c696e675f6c6f6f6b6168656164605501206d756c7469706c69656420627920746865206e756d626572206f662070617261746872656164206d756c7469706c6578657220636f7265732e20526561736f6e61626c792c203130202a203530203d203530302e44417661696c6162696c697479436f7265730100645665633c4f7074696f6e3c436f72654f636375706965643e3e0400209d01204f6e6520656e74727920666f72206561636820617661696c6162696c69747920636f72652e20456e74726965732061726520604e6f6e65602069662074686520636f7265206973206e6f742063757272656e746c79206f636375706965642e2043616e206265c82074656d706f726172696c792060536f6d6560206966207363686564756c656420627574206e6f74206f636375706965642e41012054686520692774682070617261636861696e2062656c6f6e677320746f20746865206927746820636f72652c2077697468207468652072656d61696e696e6720636f72657320616c6c206265696e676420706172617468726561642d6d756c7469706c65786572732e00d820426f756e64656420627920746865206d6178696d756d206f6620656974686572206f662074686573652074776f2076616c7565733ae42020202a20546865206e756d626572206f662070617261636861696e7320616e642070617261746872656164206d756c7469706c657865727345012020202a20546865206e756d626572206f662076616c696461746f727320646976696465642062792060636f6e66696775726174696f6e2e6d61785f76616c696461746f72735f7065725f636f7265602e5050617261746872656164436c61696d496e64657801002c5665633c5061726149643e040010590120416e20696e646578207573656420746f20656e737572652074686174206f6e6c79206f6e6520636c61696d206f6e206120706172617468726561642065786973747320696e20746865207175657565206f72206973b42063757272656e746c79206265696e672068616e646c656420627920616e206f6363757069656420636f72652e007d0120426f756e64656420627920746865206e756d626572206f66207061726174687265616420636f72657320616e64207363686564756c696e67206c6f6f6b61686561642e20526561736f6e61626c792c203130202a203530203d203530302e4453657373696f6e5374617274426c6f636b010038543a3a426c6f636b4e756d626572100000000018a5012054686520626c6f636b206e756d626572207768657265207468652073657373696f6e207374617274206f636375727265642e205573656420746f20747261636b20686f77206d616e792067726f757020726f746174696f6e732068617665206f636375727265642e005901204e6f7465207468617420696e2074686520636f6e74657874206f662070617261636861696e73206d6f64756c6573207468652073657373696f6e206368616e6765206973207369676e616c6c656420647572696e6761012074686520626c6f636b20616e6420656e61637465642061742074686520656e64206f662074686520626c6f636b20286174207468652066696e616c697a6174696f6e2073746167652c20746f206265206578616374292e5901205468757320666f7220616c6c20696e74656e747320616e6420707572706f7365732074686520656666656374206f66207468652073657373696f6e206368616e6765206973206f6273657276656420617420746865650120626c6f636b20666f6c6c6f77696e67207468652073657373696f6e206368616e67652c20626c6f636b206e756d626572206f66207768696368207765207361766520696e20746869732073746f726167652076616c75652e245363686564756c656401004c5665633c436f726541737369676e6d656e743e040018e02043757272656e746c79207363686564756c656420636f726573202d20667265652062757420757020746f206265206f636375706965642e004d0120426f756e64656420627920746865206e756d626572206f6620636f7265733a206f6e6520666f7220656163682070617261636861696e20616e642070617261746872656164206d756c7469706c657865722e00fd01205468652076616c756520636f6e7461696e656420686572652077696c6c206e6f742062652076616c69642061667465722074686520656e64206f66206120626c6f636b2e2052756e74696d6520415049732073686f756c64206265207573656420746f2064657465726d696e65207363686564756c656420636f7265732f6020666f7220746865207570636f6d696e6720626c6f636b2e01000000001214506172617301145061726173342850617261636861696e7301002c5665633c5061726149643e0400042d0120416c6c2070617261636861696e732e204f72646572656420617363656e64696e67206279205061726149642e20506172617468726561647320617265206e6f7420696e636c756465642e38506172614c6966656379636c65730001051850617261496434506172614c6966656379636c6500040004bc205468652063757272656e74206c6966656379636c65206f66206120616c6c206b6e6f776e2050617261204944732e1448656164730001051850617261496420486561644461746100040004a02054686520686561642d64617461206f66206576657279207265676973746572656420706172612e3c43757272656e74436f6465486173680001051850617261496410486173680004000cb4205468652076616c69646174696f6e20636f64652068617368206f66206576657279206c69766520706172612e00e420436f72726573706f6e64696e6720636f64652063616e206265207265747269657665642077697468205b60436f6465427948617368605d2e3050617374436f64654861736800010560285061726149642c20543a3a426c6f636b4e756d6265722910486173680004001061012041637475616c207061737420636f646520686173682c20696e646963617465642062792074686520706172612069642061732077656c6c2061732074686520626c6f636b206e756d6265722061742077686963682069744420626563616d65206f757464617465642e00e420436f72726573706f6e64696e6720636f64652063616e206265207265747269657665642077697468205b60436f6465427948617368605d2e3050617374436f64654d65746101010518506172614964805061726150617374436f64654d6574613c543a3a426c6f636b4e756d6265723e000800000c4901205061737420636f6465206f662070617261636861696e732e205468652070617261636861696e73207468656d73656c766573206d6179206e6f74206265207265676973746572656420616e796d6f72652c49012062757420776520616c736f206b65657020746865697220636f6465206f6e2d636861696e20666f72207468652073616d6520616d6f756e74206f662074696d65206173206f7574646174656420636f6465b420746f206b65657020697420617661696c61626c6520666f72207365636f6e6461727920636865636b6572732e3c50617374436f64655072756e696e670100745665633c285061726149642c20543a3a426c6f636b4e756d626572293e040018a1012057686963682070617261732068617665207061737420636f64652074686174206e65656473207072756e696e6720616e64207468652072656c61792d636861696e20626c6f636b2061742077686963682074686520636f646520776173207265706c616365642e8101204e6f746520746861742074686973206973207468652061637475616c20686569676874206f662074686520696e636c7564656420626c6f636b2c206e6f74207468652065787065637465642068656967687420617420776869636820746865ec20636f6465207570677261646520776f756c64206265206170706c6965642c20616c74686f7567682074686579206d617920626520657175616c2e9101205468697320697320746f20656e737572652074686520656e7469726520616363657074616e636520706572696f6420697320636f76657265642c206e6f7420616e206f666673657420616363657074616e636520706572696f64207374617274696e6749012066726f6d207468652074696d65206174207768696368207468652070617261636861696e20706572636569766573206120636f6465207570677261646520617320686176696e67206f636375727265642e5501204d756c7469706c6520656e747269657320666f7220612073696e676c65207061726120617265207065726d69747465642e204f72646572656420617363656e64696e6720627920626c6f636b206e756d6265722e48467574757265436f646555706772616465730001051850617261496438543a3a426c6f636b4e756d6265720004000c29012054686520626c6f636b206e756d6265722061742077686963682074686520706c616e6e656420636f6465206368616e676520697320657870656374656420666f72206120706172612e650120546865206368616e67652077696c6c206265206170706c696564206166746572207468652066697273742070617261626c6f636b20666f72207468697320494420696e636c75646564207768696368206578656375746573190120696e2074686520636f6e74657874206f6620612072656c617920636861696e20626c6f636b20776974682061206e756d626572203e3d206065787065637465645f6174602e38467574757265436f6465486173680001051850617261496410486173680004000c9c205468652061637475616c2066757475726520636f64652068617368206f66206120706172612e00e420436f72726573706f6e64696e6720636f64652063616e206265207265747269657665642077697468205b60436f6465427948617368605d2e30416374696f6e7351756575650101053053657373696f6e496e6465782c5665633c5061726149643e0004000415012054686520616374696f6e7320746f20706572666f726d20647572696e6720746865207374617274206f6620612073706563696669632073657373696f6e20696e6465782e505570636f6d696e67506172617347656e65736973000105185061726149643c5061726147656e657369734172677300040004a0205570636f6d696e6720706172617320696e7374616e74696174696f6e20617267756d656e74732e38436f64654279486173685265667301010610486173680c75333200100000000004290120546865206e756d626572206f66207265666572656e6365206f6e207468652076616c69646174696f6e20636f646520696e205b60436f6465427948617368605d2073746f726167652e28436f646542794861736800010610486173683856616c69646174696f6e436f646500040010902056616c69646174696f6e20636f64652073746f7265642062792069747320686173682e00310120546869732073746f7261676520697320636f6e73697374656e742077697468205b60467574757265436f646548617368605d2c205b6043757272656e74436f646548617368605d20616e6448205b6050617374436f646548617368605d2e011458666f7263655f7365745f63757272656e745f636f646508107061726118506172614964206e65775f636f64653856616c69646174696f6e436f646504fc20536574207468652073746f7261676520666f72207468652070617261636861696e2076616c69646174696f6e20636f646520696d6d6564696174656c792e58666f7263655f7365745f63757272656e745f6865616408107061726118506172614964206e65775f6865616420486561644461746104050120536574207468652073746f7261676520666f72207468652063757272656e742070617261636861696e2068656164206461746120696d6d6564696174656c792e6c666f7263655f7363686564756c655f636f64655f757067726164650c107061726118506172614964206e65775f636f64653856616c69646174696f6e436f64652c65787065637465645f617438543a3a426c6f636b4e756d62657204c4205363686564756c65206120636f6465207570677261646520666f7220626c6f636b206065787065637465645f6174602e4c666f7263655f6e6f74655f6e65775f6865616408107061726118506172614964206e65775f68656164204865616444617461042101204e6f74652061206e657720626c6f636b206865616420666f7220706172612077697468696e2074686520636f6e74657874206f66207468652063757272656e7420626c6f636b2e48666f7263655f71756575655f616374696f6e041070617261185061726149640cfc2050757420612070617261636861696e206469726563746c7920696e746f20746865206e6578742073657373696f6e277320616374696f6e2071756575652ef82057652063616e277420717565756520697420616e7920736f6f6e6572207468616e207468697320776974686f757420676f696e6720696e746f207468653c20696e697469616c697a65722e2e2e01144843757272656e74436f646555706461746564041850617261496404d82043757272656e7420636f646520686173206265656e207570646174656420666f72206120506172612e205c5b706172615f69645c5d4843757272656e744865616455706461746564041850617261496404d82043757272656e74206865616420686173206265656e207570646174656420666f72206120506172612e205c5b706172615f69645c5d50436f6465557067726164655363686564756c6564041850617261496404e8204120636f6465207570677261646520686173206265656e207363686564756c656420666f72206120506172612e205c5b706172615f69645c5d304e6577486561644e6f746564041850617261496404c82041206e6577206865616420686173206265656e206e6f74656420666f72206120506172612e205c5b706172615f69645c5d30416374696f6e51756575656408185061726149643053657373696f6e496e64657804fc2041207061726120686173206265656e2071756575656420746f20657865637574652070656e64696e6720616374696f6e732e205c5b706172615f69645c5d0014344e6f745265676973746572656404982050617261206973206e6f74207265676973746572656420696e206f75722073797374656d2e3443616e6e6f744f6e626f61726404190120506172612063616e6e6f74206265206f6e626f6172646564206265636175736520697420697320616c726561647920747261636b6564206279206f75722073797374656d2e3843616e6e6f744f6666626f61726404a020506172612063616e6e6f74206265206f6666626f617264656420617420746869732074696d652e3443616e6e6f745570677261646504a020506172612063616e6e6f7420626520757067726164656420746f20612070617261636861696e2e3c43616e6e6f74446f776e677261646504ac20506172612063616e6e6f7420626520646f776e67726164656420746f206120706172617468726561642e132c496e697469616c697a6572012c496e697469616c697a65720838486173496e697469616c697a6564000008282904002021012057686574686572207468652070617261636861696e73206d6f64756c65732068617665206265656e20696e697469616c697a65642077697468696e207468697320626c6f636b2e001d012053656d616e746963616c6c79206120626f6f6c2c2062757420746869732067756172616e746565732069742073686f756c64206e65766572206869742074686520747269652c6901206173207468697320697320636c656172656420696e20606f6e5f66696e616c697a656020616e64204672616d65206f7074696d697a657320604e6f6e65602076616c75657320746f20626520656d7074792076616c7565732e007501204173206120626f6f6c2c20607365742866616c7365296020616e64206072656d6f766528296020626f7468206c65616420746f20746865206e6578742060676574282960206265696e672066616c73652c20627574206f6e65206f667901207468656d2077726974657320746f20746865207472696520616e64206f6e6520646f6573206e6f742e205468697320636f6e667573696f6e206d616b657320604f7074696f6e3c28293e60206d6f7265207375697461626c6520666f7280207468652073656d616e74696373206f662074686973207661726961626c652e58427566666572656453657373696f6e4368616e6765730100685665633c427566666572656453657373696f6e4368616e67653e04001c59012042756666657265642073657373696f6e206368616e67657320616c6f6e6720776974682074686520626c6f636b206e756d62657220617420776869636820746865792073686f756c64206265206170706c6965642e005d01205479706963616c6c7920746869732077696c6c20626520656d707479206f72206f6e6520656c656d656e74206c6f6e672e2041706172742066726f6d20746861742074686973206974656d206e65766572206869747334207468652073746f726167652e00690120486f776576657220746869732069732061206056656360207265676172646c65737320746f2068616e646c6520766172696f757320656467652063617365732074686174206d6179206f636375722061742072756e74696d65c0207570677261646520626f756e646172696573206f7220696620676f7665726e616e636520696e74657276656e65732e010434666f7263655f617070726f7665041475705f746f2c426c6f636b4e756d6265720c3d012049737375652061207369676e616c20746f2074686520636f6e73656e73757320656e67696e6520746f20666f726369626c79206163742061732074686f75676820616c6c2070617261636861696e550120626c6f636b7320696e20616c6c2072656c617920636861696e20626c6f636b7320757020746f20616e6420696e636c7564696e672074686520676976656e206e756d62657220696e207468652063757272656e74a420636861696e206172652076616c696420616e642073686f756c642062652066696e616c697a65642e000000140c446d70010c446d700854446f776e776172644d65737361676551756575657301010518506172614964ac5665633c496e626f756e64446f776e776172644d6573736167653c543a3a426c6f636b4e756d6265723e3e00040004d02054686520646f776e77617264206d657373616765732061646472657373656420666f722061206365727461696e20706172612e64446f776e776172644d65737361676551756575654865616473010105185061726149641048617368008000000000000000000000000000000000000000000000000000000000000000001c25012041206d617070696e6720746861742073746f7265732074686520646f776e77617264206d657373616765207175657565204d5143206865616420666f72206561636820706172612e00902045616368206c696e6b20696e207468697320636861696e20686173206120666f726d3a78206028707265765f686561642c20422c2048284d2929602c207768657265e8202d2060707265765f68656164603a206973207468652070726576696f757320686561642068617368206f72207a65726f206966206e6f6e652e2101202d206042603a206973207468652072656c61792d636861696e20626c6f636b206e756d62657220696e2077686963682061206d6573736167652077617320617070656e6465642ed4202d206048284d29603a206973207468652068617368206f6620746865206d657373616765206265696e6720617070656e6465642e0100000000150c556d70010c556d70104c52656c61794469737061746368517565756573010105185061726149645c56656344657175653c5570776172644d6573736167653e00040018710120546865206d657373616765732077616974696e6720746f2062652068616e646c6564206279207468652072656c61792d636861696e206f726967696e6174696e672066726f6d2061206365727461696e2070617261636861696e2e007901204e6f7465207468617420736f6d6520757077617264206d65737361676573206d696768742068617665206265656e20616c72656164792070726f6365737365642062792074686520696e636c7573696f6e206c6f6769632e20452e672e74206368616e6e656c206d616e6167656d656e74206d657373616765732e00a820546865206d65737361676573206172652070726f63657373656420696e204649464f206f726465722e5852656c61794469737061746368517565756553697a650101051850617261496428287533322c2075333229002000000000000000002c45012053697a65206f6620746865206469737061746368207175657565732e204361636865732073697a6573206f66207468652071756575657320696e206052656c617944697370617463685175657565602e00f0204669727374206974656d20696e20746865207475706c652069732074686520636f756e74206f66206d6573736167657320616e64207365636f6e64e02069732074686520746f74616c206c656e6774682028696e20627974657329206f6620746865206d657373616765207061796c6f6164732e007501204e6f74652074686174207468697320697320616e20617578696c617279206d617070696e673a206974277320706f737369626c6520746f2074656c6c2074686520627974652073697a6520616e6420746865206e756d626572206f667901206d65737361676573206f6e6c79206c6f6f6b696e67206174206052656c61794469737061746368517565756573602e2054686973206d617070696e6720697320736570617261746520746f2061766f69642074686520636f7374206f663d01206c6f6164696e67207468652077686f6c65206d657373616765207175657565206966206f6e6c792074686520746f74616c2073697a6520616e6420636f756e74206172652072657175697265642e002c20496e76617269616e743a4501202d2054686520736574206f66206b6579732073686f756c642065786163746c79206d617463682074686520736574206f66206b657973206f66206052656c61794469737061746368517565756573602e344e65656473446973706174636801002c5665633c5061726149643e040014190120546865206f726465726564206c697374206f6620605061726149646073207468617420686176652061206052656c6179446973706174636851756575656020656e7472792e002c20496e76617269616e743a3501202d2054686520736574206f66206974656d732066726f6d207468697320766563746f722073686f756c642062652065786163746c792074686520736574206f6620746865206b65797320696ed82020206052656c617944697370617463685175657565736020616e64206052656c61794469737061746368517565756553697a65602e684e6578744469737061746368526f756e645374617274576974680000185061726149640400147d012054686973206973207468652070617261207468617420676574732077696c6c20676574206469737061746368656420666972737420647572696e6720746865206e6578742075707761726420646973706174636861626c652071756575654420657865637574696f6e20726f756e642e002c20496e76617269616e743a0d01202d2049662060536f6d65287061726129602c207468656e20607061726160206d7573742062652070726573656e7420696e20604e656564734469737061746368602e0100000000161048726d70011048726d70305c48726d704f70656e4368616e6e656c52657175657374730001053448726d704368616e6e656c49645848726d704f70656e4368616e6e656c5265717565737400040018bc2054686520736574206f662070656e64696e672048524d50206f70656e206368616e6e656c2072657175657374732e00c02054686520736574206973206163636f6d70616e6965642062792061206c69737420666f7220697465726174696f6e2e002c20496e76617269616e743a3d01202d20546865726520617265206e6f206368616e6e656c7320746861742065786973747320696e206c69737420627574206e6f7420696e207468652073657420616e6420766963652076657273612e6c48726d704f70656e4368616e6e656c52657175657374734c6973740100485665633c48726d704368616e6e656c49643e0400006c48726d704f70656e4368616e6e656c52657175657374436f756e74010105185061726149640c7533320010000000000c69012054686973206d617070696e6720747261636b7320686f77206d616e79206f70656e206368616e6e656c2072657175657374732061726520696e6974697461746564206279206120676976656e2073656e64657220706172612e7d0120496e76617269616e743a206048726d704f70656e4368616e6e656c5265717565737473602073686f756c6420636f6e7461696e207468652073616d65206e756d626572206f66206974656d73207468617420686173206028582c205f2960e020617320746865206e756d626572206f66206048726d704f70656e4368616e6e656c52657175657374436f756e746020666f72206058602e7c48726d7041636365707465644368616e6e656c52657175657374436f756e74010105185061726149640c7533320010000000000c71012054686973206d617070696e6720747261636b7320686f77206d616e79206f70656e206368616e6e656c2072657175657374732077657265206163636570746564206279206120676976656e20726563697069656e7420706172612e6d0120496e76617269616e743a206048726d704f70656e4368616e6e656c5265717565737473602073686f756c6420636f6e7461696e207468652073616d65206e756d626572206f66206974656d732060285f2c20582960207769746855012060636f6e6669726d6564602073657420746f20747275652c20617320746865206e756d626572206f66206048726d7041636365707465644368616e6e656c52657175657374436f756e746020666f72206058602e6048726d70436c6f73654368616e6e656c52657175657374730001053448726d704368616e6e656c49640828290004001c9101204120736574206f662070656e64696e672048524d5020636c6f7365206368616e6e656c20726571756573747320746861742061726520676f696e6720746f20626520636c6f73656420647572696e67207468652073657373696f6e206368616e67652e0101205573656420666f7220636865636b696e67206966206120676976656e206368616e6e656c206973207265676973746572656420666f7220636c6f737572652e00c02054686520736574206973206163636f6d70616e6965642062792061206c69737420666f7220697465726174696f6e2e002c20496e76617269616e743a3d01202d20546865726520617265206e6f206368616e6e656c7320746861742065786973747320696e206c69737420627574206e6f7420696e207468652073657420616e6420766963652076657273612e7048726d70436c6f73654368616e6e656c52657175657374734c6973740100485665633c48726d704368616e6e656c49643e0400003848726d7057617465726d61726b730001051850617261496438543a3a426c6f636b4e756d6265720004000cb8205468652048524d502077617465726d61726b206173736f6369617465642077697468206561636820706172612e2c20496e76617269616e743a7901202d2065616368207061726120605060207573656420686572652061732061206b65792073686f756c642073617469736679206050617261733a3a69735f76616c69645f70617261285029602077697468696e20612073657373696f6e2e3048726d704368616e6e656c730001053448726d704368616e6e656c49642c48726d704368616e6e656c0004000cb42048524d50206368616e6e656c2064617461206173736f6369617465642077697468206561636820706172612e2c20496e76617269616e743a7501202d2065616368207061727469636970616e7420696e20746865206368616e6e656c2073686f756c642073617469736679206050617261733a3a69735f76616c69645f70617261285029602077697468696e20612073657373696f6e2e6048726d70496e67726573734368616e6e656c73496e646578010105185061726149642c5665633c5061726149643e00040034590120496e67726573732f65677265737320696e646578657320616c6c6f7720746f2066696e6420616c6c207468652073656e6465727320616e642072656365697665727320676976656e20746865206f70706f736974652c20736964652e20492e652e0021012028612920696e677265737320696e64657820616c6c6f777320746f2066696e6420616c6c207468652073656e6465727320666f72206120676976656e20726563697069656e742e1d01202862292065677265737320696e64657820616c6c6f777320746f2066696e6420616c6c2074686520726563697069656e747320666f72206120676976656e2073656e6465722e003020496e76617269616e74733a8d01202d20666f72206561636820696e677265737320696e64657820656e74727920666f72206050602065616368206974656d2060496020696e2074686520696e6465782073686f756c642070726573656e7420696e206048726d704368616e6e656c73603c2020206173206028492c205029602e8901202d20666f7220656163682065677265737320696e64657820656e74727920666f72206050602065616368206974656d2060456020696e2074686520696e6465782073686f756c642070726573656e7420696e206048726d704368616e6e656c73603c2020206173206028502c204529602e0101202d2074686572652073686f756c64206265206e6f206f746865722064616e676c696e67206368616e6e656c7320696e206048726d704368616e6e656c73602e68202d2074686520766563746f72732061726520736f727465642e5c48726d704567726573734368616e6e656c73496e646578010105185061726149642c5665633c5061726149643e000400004c48726d704368616e6e656c436f6e74656e74730101053448726d704368616e6e656c49649c5665633c496e626f756e6448726d704d6573736167653c543a3a426c6f636b4e756d6265723e3e00040008ac2053746f7261676520666f7220746865206d6573736167657320666f722065616368206368616e6e656c2e650120496e76617269616e743a2063616e6e6f74206265206e6f6e2d656d7074792069662074686520636f72726573706f6e64696e67206368616e6e656c20696e206048726d704368616e6e656c736020697320604e6f6e65602e4848726d704368616e6e656c4469676573747301010518506172614964885665633c28543a3a426c6f636b4e756d6265722c205665633c5061726149643e293e0004001cf4204d61696e7461696e732061206d617070696e6720746861742063616e206265207573656420746f20616e7377657220746865207175657374696f6e3a290120576861742070617261732073656e742061206d6573736167652061742074686520676976656e20626c6f636b206e756d62657220666f72206120676976656e2072656369657665722e3020496e76617269616e74733aa8202d2054686520696e6e657220605665633c5061726149643e60206973206e6576657220656d7074792ee8202d2054686520696e6e657220605665633c5061726149643e602063616e6e6f742073746f72652074776f2073616d652060506172614964602e8101202d20546865206f7574657220766563746f7220697320736f7274656420617363656e64696e6720627920626c6f636b206e756d62657220616e642063616e6e6f742073746f72652074776f206974656d732077697468207468652073616d6540202020626c6f636b206e756d6265722e01185868726d705f696e69745f6f70656e5f6368616e6e656c0c24726563697069656e74185061726149645470726f706f7365645f6d61785f63617061636974790c7533326470726f706f7365645f6d61785f6d6573736167655f73697a650c75333228510120496e697469617465206f70656e696e672061206368616e6e656c2066726f6d20612070617261636861696e20746f206120676976656e20726563697069656e74207769746820676976656e206368616e6e656c3020706172616d65746572732e005d01202d206070726f706f7365645f6d61785f636170616369747960202d2073706563696669657320686f77206d616e79206d657373616765732063616e20626520696e20746865206368616e6e656c206174206f6e63652e4d01202d206070726f706f7365645f6d61785f6d6573736167655f73697a6560202d2073706563696669657320746865206d6178696d756d2073697a65206f6620616e79206f6620746865206d657373616765732e001501205468657365206e756d62657273206172652061207375626a65637420746f207468652072656c61792d636861696e20636f6e66696775726174696f6e206c696d6974732e00550120546865206368616e6e656c2063616e206265206f70656e6564206f6e6c792061667465722074686520726563697069656e7420636f6e6669726d7320697420616e64206f6e6c79206f6e20612073657373696f6e20206368616e67652e6068726d705f6163636570745f6f70656e5f6368616e6e656c041873656e646572185061726149640cf42041636365707420612070656e64696e67206f70656e206368616e6e656c20726571756573742066726f6d2074686520676976656e2073656e6465722e00f820546865206368616e6e656c2077696c6c206265206f70656e6564206f6e6c79206f6e20746865206e6578742073657373696f6e20626f756e646172792e4868726d705f636c6f73655f6368616e6e656c04286368616e6e656c5f69643448726d704368616e6e656c496410590120496e69746961746520756e696c61746572616c20636c6f73696e67206f662061206368616e6e656c2e20546865206f726967696e206d75737420626520656974686572207468652073656e646572206f72207468659c20726563697069656e7420696e20746865206368616e6e656c206265696e6720636c6f7365642e00c42054686520636c6f737572652063616e206f6e6c792068617070656e206f6e20612073657373696f6e206368616e67652e40666f7263655f636c65616e5f68726d7004107061726118506172614964141d0120546869732065787472696e7369632074726967676572732074686520636c65616e7570206f6620616c6c207468652048524d502073746f72616765206974656d732074686174250120612070617261206d617920686176652e204e6f726d616c6c7920746869732068617070656e73206f6e6365207065722073657373696f6e2c20627574207468697320616c6c6f7773050120796f7520746f20747269676765722074686520636c65616e757020696d6d6564696174656c7920666f7220612073706563696669632070617261636861696e2e0054204f726967696e206d75737420626520526f6f742e5c666f7263655f70726f636573735f68726d705f6f70656e0010a820466f7263652070726f636573732068726d70206f70656e206368616e6e656c2072657175657374732e000901204966207468657265206172652070656e64696e672048524d50206f70656e206368616e6e656c2072657175657374732c20796f752063616e207573652074686973d02066756e6374696f6e2070726f6365737320616c6c206f662074686f736520726571756573747320696d6d6564696174656c792e60666f7263655f70726f636573735f68726d705f636c6f73650010ac20466f7263652070726f636573732068726d7020636c6f7365206368616e6e656c2072657175657374732e000d01204966207468657265206172652070656e64696e672048524d5020636c6f7365206368616e6e656c2072657175657374732c20796f752063616e207573652074686973d02066756e6374696f6e2070726f6365737320616c6c206f662074686f736520726571756573747320696d6d6564696174656c792e010c504f70656e4368616e6e656c5265717565737465641018506172614964185061726149640c7533320c7533320874204f70656e2048524d50206368616e6e656c207265717565737465642e2101205c5b73656e6465722c20726563697069656e742c2070726f706f7365645f6d61785f63617061636974792c2070726f706f7365645f6d61785f6d6573736167655f73697a655c5d4c4f70656e4368616e6e656c416363657074656408185061726149641850617261496404c8204f70656e2048524d50206368616e6e656c2061636365707465642e205c5b73656e6465722c20726563697069656e745c5d344368616e6e656c436c6f73656408185061726149643448726d704368616e6e656c496404c82048524d50206368616e6e656c20636c6f7365642e205c5b62795f70617261636861696e2c206368616e6e656c5f69645c5d003c544f70656e48726d704368616e6e656c546f53656c6604c8205468652073656e64657220747269656420746f206f70656e2061206368616e6e656c20746f207468656d73656c7665732e7c4f70656e48726d704368616e6e656c496e76616c6964526563697069656e74048c2054686520726563697069656e74206973206e6f7420612076616c696420706172612e6c4f70656e48726d704368616e6e656c5a65726f436170616369747904802054686520726571756573746564206361706163697479206973207a65726f2e8c4f70656e48726d704368616e6e656c4361706163697479457863656564734c696d697404c4205468652072657175657374656420636170616369747920657863656564732074686520676c6f62616c206c696d69742e784f70656e48726d704368616e6e656c5a65726f4d65737361676553697a6504a42054686520726571756573746564206d6178696d756d206d6573736167652073697a6520697320302e984f70656e48726d704368616e6e656c4d65737361676553697a65457863656564734c696d6974042d0120546865206f70656e20726571756573742072657175657374656420746865206d6573736167652073697a65207468617420657863656564732074686520676c6f62616c206c696d69742e704f70656e48726d704368616e6e656c416c7265616479457869737473046c20546865206368616e6e656c20616c7265616479206578697374737c4f70656e48726d704368616e6e656c416c726561647952657175657374656404d420546865726520697320616c72656164792061207265717565737420746f206f70656e207468652073616d65206368616e6e656c2e704f70656e48726d704368616e6e656c4c696d69744578636565646564042101205468652073656e64657220616c72656164792068617320746865206d6178696d756d206e756d626572206f6620616c6c6f776564206f7574626f756e64206368616e6e656c732e7041636365707448726d704368616e6e656c446f65736e74457869737404e420546865206368616e6e656c2066726f6d207468652073656e64657220746f20746865206f726967696e20646f65736e27742065786973742e8441636365707448726d704368616e6e656c416c7265616479436f6e6669726d6564048820546865206368616e6e656c20697320616c726561647920636f6e6669726d65642e7841636365707448726d704368616e6e656c4c696d697445786365656465640429012054686520726563697069656e7420616c72656164792068617320746865206d6178696d756d206e756d626572206f6620616c6c6f77656420696e626f756e64206368616e6e656c732e70436c6f736548726d704368616e6e656c556e617574686f72697a656404590120546865206f726967696e20747269657320746f20636c6f73652061206368616e6e656c207768657265206974206973206e656974686572207468652073656e646572206e6f722074686520726563697069656e742e6c436c6f736548726d704368616e6e656c446f65736e74457869737404a020546865206368616e6e656c20746f20626520636c6f73656420646f65736e27742065786973742e7c436c6f736548726d704368616e6e656c416c7265616479556e64657277617904c020546865206368616e6e656c20636c6f7365207265717565737420697320616c7265616479207265717565737465642e172c53657373696f6e496e666f013c5061726153657373696f6e496e666f0c5041737369676e6d656e744b657973556e736166650100445665633c41737369676e6d656e7449643e04000ca42041737369676e6d656e74206b65797320666f72207468652063757272656e742073657373696f6e2e6d01204e6f7465207468617420746869732041504920697320707269766174652064756520746f206974206265696e672070726f6e6520746f20276f66662d62792d6f6e65272061742073657373696f6e20626f756e6461726965732eac205768656e20696e20646f7562742c20757365206053657373696f6e73602041504920696e73746561642e544561726c6965737453746f72656453657373696f6e01003053657373696f6e496e646578100000000004010120546865206561726c696573742073657373696f6e20666f722077686963682070726576696f75732073657373696f6e20696e666f2069732073746f7265642e2053657373696f6e730001063053657373696f6e496e6465782c53657373696f6e496e666f0004000ca42053657373696f6e20696e666f726d6174696f6e20696e206120726f6c6c696e672077696e646f772e35012053686f756c64206861766520616e20656e74727920696e2072616e676520604561726c6965737453746f72656453657373696f6e2e2e3d43757272656e7453657373696f6e496e646578602e750120446f6573206e6f74206861766520616e7920656e7472696573206265666f7265207468652073657373696f6e20696e64657820696e207468652066697273742073657373696f6e206368616e6765206e6f74696669636174696f6e2e010000000018245265676973747261720124526567697374726172082c50656e64696e6753776170000105185061726149641850617261496400040004642050656e64696e672073776170206f7065726174696f6e732e145061726173000105185061726149649050617261496e666f3c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e00040010050120416d6f756e742068656c64206f6e206465706f73697420666f722065616368207061726120616e6420746865206f726967696e616c206465706f7369746f722e0091012054686520676976656e206163636f756e7420494420697320726573706f6e7369626c6520666f72207265676973746572696e672074686520636f646520616e6420696e697469616c206865616420646174612c20627574206d6179206f6e6c7920646f350120736f2069662069742069736e27742079657420726567697374657265642e2028416674657220746861742c206974277320757020746f20676f7665726e616e636520746f20646f20736f2e2901142072656769737465720c086964185061726149643067656e657369735f686561642048656164446174613c76616c69646174696f6e5f636f64653856616c69646174696f6e436f64652c9c20526567697374657220612050617261204964206f6e207468652072656c617920636861696e2e00f420546869732066756e6374696f6e2077696c6c20717565756520746865206e6577205061726120496420746f206265206120706172617468726561642e0d01205573696e672074686520536c6f74732070616c6c65742c206120706172617468726561642063616e207468656e20626520757067726164656420746f206765742061402070617261636861696e20736c6f742e00c420546869732066756e6374696f6e206d7573742062652063616c6c65642062792061207369676e6564206f726967696e2e00010120546865206f726967696e206d757374207061792061206465706f73697420666f722074686520726567697374726174696f6e20696e666f726d6174696f6e2cf820696e636c7564696e67207468652067656e6573697320696e666f726d6174696f6e20616e642076616c69646174696f6e20636f64652e205061726149649c206d7573742062652067726561746572207468616e206f7220657175616c20746f20313030302e38666f7263655f7265676973746572140c77686f30543a3a4163636f756e7449641c6465706f7369743042616c616e63654f663c543e086964185061726149643067656e657369735f686561642048656164446174613c76616c69646174696f6e5f636f64653856616c69646174696f6e436f646518e020466f7263652074686520726567697374726174696f6e206f6620612050617261204964206f6e207468652072656c617920636861696e2e00bc20546869732066756e6374696f6e206d7573742062652063616c6c6564206279206120526f6f74206f726967696e2e00150120546865206465706f7369742074616b656e2063616e2062652073706563696669656420666f72207468697320726567697374726174696f6e2e20416e79205061726149641d012063616e20626520726567697374657265642c20696e636c7564696e67207375622d3130303020494473207768696368206172652053797374656d2050617261636861696e732e286465726567697374657204086964185061726149640c09012044657265676973746572206120506172612049642c2066726565696e6720616c6c206461746120616e642072657475726e696e6720616e79206465706f7369742e008101205468652063616c6c6572206d75737420626520526f6f742c2074686520607061726160206f776e65722c206f72207468652060706172616020697473656c662e205468652070617261206d757374206265206120706172617468726561642e10737761700808696418506172614964146f74686572185061726149642cdc205377617020612070617261636861696e207769746820616e6f746865722070617261636861696e206f7220706172617468726561642e00050120546865206f726967696e206d75737420626520526f6f742c2074686520607061726160206f776e65722c206f72207468652060706172616020697473656c662e0065012054686520737761702077696c6c2068617070656e206f6e6c7920696620746865726520697320616c726561647920616e206f70706f7369746520737761702070656e64696e672e204966207468657265206973206e6f742c5d012074686520737761702077696c6c2062652073746f72656420696e207468652070656e64696e67207377617073206d61702c20726561647920666f722061206c6174657220636f6e6669726d61746f727920737761702e00610120546865206050617261496460732072656d61696e206d617070656420746f207468652073616d652068656164206461746120616e6420636f646520736f2065787465726e616c20636f64652063616e2072656c79206f6e410120605061726149646020746f2062652061206c6f6e672d7465726d206964656e746966696572206f662061206e6f74696f6e616c202270617261636861696e222e20486f77657665722c2074686569725901207363686564756c696e6720696e666f2028692e652e2077686574686572207468657927726520612070617261746872656164206f722070617261636861696e292c2061756374696f6e20696e666f726d6174696f6e9820616e64207468652061756374696f6e206465706f736974206172652073776974636865642e44666f7263655f72656d6f76655f6c6f636b041070617261185061726149641011012052656d6f76652061206d616e61676572206c6f636b2066726f6d206120706172612e20546869732077696c6c20616c6c6f7720746865206d616e61676572206f66206139012070726576696f75736c79206c6f636b6564207061726120746f2064657265676973746572206f7220737761702061207061726120776974686f7574207573696e6720676f7665726e616e63652e009c2043616e206f6e6c792062652063616c6c65642062792074686520526f6f74206f726967696e2e010828526567697374657265640818506172614964244163636f756e7449640030446572656769737465726564041850617261496400102c506172614465706f7369743042616c616e63654f663c543e40005039278c04000000000000000000000048446174614465706f736974506572427974653042616c616e63654f663c543e4080f0fa02000000000000000000000000002c4d6178436f646553697a650c753332100000a000002c4d61784865616453697a650c75333210005000000030344e6f7452656769737465726564046820546865204944206973206e6f7420726567697374657265642e44416c72656164795265676973746572656404782054686520494420697320616c726561647920726567697374657265642e204e6f744f776e657204a0205468652063616c6c6572206973206e6f7420746865206f776e6572206f6620746869732049642e30436f6465546f6f4c61726765046020496e76616c6964207061726120636f64652073697a652e404865616444617461546f6f4c61726765047420496e76616c69642070617261206865616420646174612073697a652e304e6f7450617261636861696e04642050617261206973206e6f7420612050617261636861696e2e344e6f745061726174687265616404682050617261206973206e6f74206120506172617468726561642e4043616e6e6f7444657265676973746572045c2043616e6e6f74206465726567697374657220706172613c43616e6e6f74446f776e677261646504d42043616e6e6f74207363686564756c6520646f776e6772616465206f662070617261636861696e20746f20706172617468726561643443616e6e6f745570677261646504cc2043616e6e6f74207363686564756c652075706772616465206f66207061726174687265616420746f2070617261636861696e28506172614c6f636b6564047d012050617261206973206c6f636b65642066726f6d206d616e6970756c6174696f6e20627920746865206d616e616765722e204d757374207573652070617261636861696e206f722072656c617920636861696e20676f7665726e616e63652e34496e76616c69645061726149640415012054686520696420796f752061726520747279696e6720746f20726567697374657220697320726573657276656420666f722073797374656d2070617261636861696e732e192041756374696f6e73012041756374696f6e73103841756374696f6e436f756e74657201003041756374696f6e496e6465781000000000048c204e756d626572206f662061756374696f6e73207374617274656420736f206661722e2c41756374696f6e496e666f000088284c65617365506572696f644f663c543e2c20543a3a426c6f636b4e756d62657229040014f820496e666f726d6174696f6e2072656c6174696e6720746f207468652063757272656e742061756374696f6e2c206966207468657265206973206f6e652e00450120546865206669727374206974656d20696e20746865207475706c6520697320746865206c6561736520706572696f6420696e646578207468617420746865206669727374206f662074686520666f7572510120636f6e746967756f7573206c6561736520706572696f6473206f6e2061756374696f6e20697320666f722e20546865207365636f6e642069732074686520626c6f636b206e756d626572207768656e207468655d012061756374696f6e2077696c6c2022626567696e20746f20656e64222c20692e652e2074686520666972737420626c6f636b206f662074686520456e64696e6720506572696f64206f66207468652061756374696f6e2e3c5265736572766564416d6f756e74730001055828543a3a4163636f756e7449642c20506172614964293042616c616e63654f663c543e00040008310120416d6f756e74732063757272656e746c7920726573657276656420696e20746865206163636f756e7473206f662074686520626964646572732063757272656e746c792077696e6e696e673820287375622d2972616e6765732e1c57696e6e696e6700010538543a3a426c6f636b4e756d6265723857696e6e696e67446174613c543e0004000c6101205468652077696e6e696e67206269647320666f722065616368206f66207468652031302072616e67657320617420656163682073616d706c6520696e207468652066696e616c20456e64696e6720506572696f64206f664901207468652063757272656e742061756374696f6e2e20546865206d61702773206b65792069732074686520302d626173656420696e64657820696e746f207468652053616d706c652053697a652e205468651d012066697273742073616d706c65206f662074686520656e64696e6720706572696f6420697320303b20746865206c617374206973206053616d706c652053697a65202d2031602e010c2c6e65775f61756374696f6e08206475726174696f6e5c436f6d706163743c543a3a426c6f636b4e756d6265723e486c656173655f706572696f645f696e64657864436f6d706163743c4c65617365506572696f644f663c543e3e1458204372656174652061206e65772061756374696f6e2e00550120546869732063616e206f6e6c792068617070656e207768656e2074686572652069736e277420616c726561647920616e2061756374696f6e20696e2070726f677265737320616e64206d6179206f6e6c7920626529012063616c6c65642062792074686520726f6f74206f726967696e2e20416363657074732074686520606475726174696f6e60206f6620746869732061756374696f6e20616e64207468655d0120606c656173655f706572696f645f696e64657860206f662074686520696e697469616c206c6561736520706572696f64206f662074686520666f757220746861742061726520746f2062652061756374696f6e65642e0c6269641410706172613c436f6d706163743c5061726149643e3461756374696f6e5f696e64657854436f6d706163743c41756374696f6e496e6465783e2866697273745f736c6f7464436f6d706163743c4c65617365506572696f644f663c543e3e246c6173745f736c6f7464436f6d706163743c4c65617365506572696f644f663c543e3e18616d6f756e7454436f6d706163743c42616c616e63654f663c543e3e404d01204d616b652061206e6577206269642066726f6d20616e206163636f756e742028696e636c7564696e6720612070617261636861696e206163636f756e742920666f72206465706c6f79696e672061206e65772c2070617261636861696e2e005d01204d756c7469706c652073696d756c74616e656f757320626964732066726f6d207468652073616d65206269646465722061726520616c6c6f776564206f6e6c79206173206c6f6e6720617320616c6c2061637469766541012062696473206f7665726c61702065616368206f746865722028692e652e20617265206d757475616c6c79206578636c7573697665292e20426964732063616e6e6f742062652072656461637465642e005901202d20607375626020697320746865207375622d6269646465722049442c20616c6c6f77696e6720666f72206d756c7469706c6520636f6d706574696e67206269647320746f206265206d6164652062792028616e64742066756e64656420627929207468652073616d65206163636f756e742e5101202d206061756374696f6e5f696e646578602069732074686520696e646578206f66207468652061756374696f6e20746f20626964206f6e2e2053686f756c64206a757374206265207468652070726573656e746c2076616c7565206f66206041756374696f6e436f756e746572602e4d01202d206066697273745f736c6f746020697320746865206669727374206c6561736520706572696f6420696e646578206f66207468652072616e676520746f20626964206f6e2e2054686973206973207468650d01206162736f6c757465206c6561736520706572696f6420696e6465782076616c75652c206e6f7420616e2061756374696f6e2d7370656369666963206f66667365742e4501202d20606c6173745f736c6f746020697320746865206c617374206c6561736520706572696f6420696e646578206f66207468652072616e676520746f20626964206f6e2e2054686973206973207468650d01206162736f6c757465206c6561736520706572696f6420696e6465782076616c75652c206e6f7420616e2061756374696f6e2d7370656369666963206f66667365742e4d01202d2060616d6f756e74602069732074686520616d6f756e7420746f2062696420746f2062652068656c64206173206465706f73697420666f72207468652070617261636861696e2073686f756c6420746865cc206269642077696e2e205468697320616d6f756e742069732068656c64207468726f7567686f7574207468652072616e67652e3863616e63656c5f61756374696f6e000c7c2043616e63656c20616e20696e2d70726f67726573732061756374696f6e2e008c2043616e206f6e6c792062652063616c6c656420627920526f6f74206f726967696e2e01243841756374696f6e537461727465640c3041756374696f6e496e6465782c4c65617365506572696f642c426c6f636b4e756d6265720c4d0120416e2061756374696f6e20737461727465642e2050726f76696465732069747320696e64657820616e642074686520626c6f636b206e756d6265722077686572652069742077696c6c20626567696e20746f190120636c6f736520616e6420746865206669727374206c6561736520706572696f64206f662074686520717561647275706c657420746861742069732061756374696f6e65642e98205b61756374696f6e5f696e6465782c206c656173655f706572696f642c20656e64696e675d3441756374696f6e436c6f736564043041756374696f6e496e64657804fc20416e2061756374696f6e20656e6465642e20416c6c2066756e6473206265636f6d6520756e72657365727665642e205b61756374696f6e5f696e6465785d24576f6e4465706c6f7910244163636f756e74496424536c6f7452616e6765185061726149641c42616c616e636508550120536f6d656f6e6520776f6e2074686520726967687420746f206465706c6f7920612070617261636861696e2e2042616c616e636520616d6f756e7420697320646564756374656420666f72206465706f7369742e98205b6269646465722c2072616e67652c2070617261636861696e5f69642c20616d6f756e745d28576f6e52656e6577616c10185061726149642c4c65617365506572696f642c4c65617365506572696f641c42616c616e63650cc420416e206578697374696e672070617261636861696e20776f6e2074686520726967687420746f20636f6e74696e75652e41012046697273742062616c616e63652069732074686520657874726120616d6f756e7420726573657665642e205365636f6e642069732074686520746f74616c20616d6f756e742072657365727665642eac205b70617261636861696e5f69642c20626567696e2c20636f756e742c20746f74616c5f616d6f756e745d2052657365727665640c244163636f756e7449641c42616c616e63651c42616c616e6365084d012046756e6473207765726520726573657276656420666f7220612077696e6e696e67206269642e2046697273742062616c616e63652069732074686520657874726120616d6f756e742072657365727665642ef0205365636f6e642069732074686520746f74616c2e205b6269646465722c2065787472615f72657365727665642c20746f74616c5f616d6f756e745d28556e726573657276656408244163636f756e7449641c42616c616e63650425012046756e6473207765726520756e72657365727665642073696e636520626964646572206973206e6f206c6f6e676572206163746976652e205b6269646465722c20616d6f756e745d4852657365727665436f6e66697363617465640c18506172614964244163636f756e7449641c42616c616e63650c790120536f6d656f6e6520617474656d7074656420746f206c65617365207468652073616d6520736c6f7420747769636520666f7220612070617261636861696e2e2054686520616d6f756e742069732068656c6420696e20726573657276659c20627574206e6f2070617261636861696e20736c6f7420686173206265656e206c65617365642e84205c5b70617261636861696e5f69642c206c65617365722c20616d6f756e745c5d2c426964416363657074656414244163636f756e744964185061726149641c42616c616e63652c4c65617365506572696f642c4c65617365506572696f6408cc2041206e65772062696420686173206265656e206163636570746564206173207468652063757272656e742077696e6e65722ec0205c5b77686f2c20706172615f69642c20616d6f756e742c2066697273745f736c6f742c206c6173745f736c6f745c5d3457696e6e696e674f6666736574083041756374696f6e496e6465782c426c6f636b4e756d626572087101205468652077696e6e696e67206f6666736574207761732063686f73656e20666f7220616e2061756374696f6e2e20546869732077696c6c206d617020696e746f20746865206057696e6e696e67602073746f72616765206d61702e80205c5b61756374696f6e5f696e6465782c20626c6f636b5f6e756d6265725c5d0430456e64696e67506572696f6438543a3a426c6f636b4e756d626572105802000000384441756374696f6e496e50726f6772657373049420546869732061756374696f6e20697320616c726561647920696e2070726f67726573732e444c65617365506572696f64496e50617374048420546865206c6561736520706572696f6420697320696e2074686520706173742e344e6f74506172614f726967696e04b820546865206f726967696e20666f7220746869732063616c6c206d75737420626520612070617261636861696e2e44506172614e6f7452656769737465726564045c2050617261206973206e6f74207265676973746572656444506172614e6f744f6e626f617264696e670494205468652070617261636861696e204944206973206e6f74206f6e2d626f617264696e672e34496e76616c69644f726967696e04290120546865206f726967696e20666f7220746869732063616c6c206d75737420626520746865206f726967696e2077686f2072656769737465726564207468652070617261636861696e2e44416c72656164795265676973746572656404842050617261636861696e20697320616c726561647920726567697374657265642e2c496e76616c6964436f646504982054686520636f6465206d75737420636f72726573706f6e6420746f2074686520686173682e3c556e7365744465706c6f794461746104d4204465706c6f796d656e74206461746120686173206e6f74206265656e2073657420666f7220746869732070617261636861696e2e444e6f7443757272656e7441756374696f6e045c204e6f7420612063757272656e742061756374696f6e2e284e6f7441756374696f6e0440204e6f7420616e2061756374696f6e2e30436f6465546f6f4c61726765047820476976656e20636f64652073697a6520697320746f6f206c617267652e404865616444617461546f6f4c61726765049820476976656e20696e697469616c2068656164206461746120697320746f6f206c617267652e3041756374696f6e456e646564046c2041756374696f6e2068617320616c726561647920656e6465642e1a2443726f77646c6f616e012443726f77646c6f616e101446756e6473000105185061726149641d0146756e64496e666f3c543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265722c204c65617365506572696f644f663c0a543e3e000400046820496e666f206f6e20616c6c206f66207468652066756e64732e204e6577526169736501002c5665633c5061726149643e0400085501205468652066756e64732074686174206861766520686164206164646974696f6e616c20636f6e747269627574696f6e7320647572696e6720746865206c61737420626c6f636b2e20546869732069732075736564150120696e206f7264657220746f2064657465726d696e652077686963682066756e64732073686f756c64207375626d6974206e6577206f72207570646174656420626964732e30456e64696e6773436f756e7401000c753332100000000004290120546865206e756d626572206f662061756374696f6e732074686174206861766520656e746572656420696e746f20746865697220656e64696e6720706572696f6420736f206661722e344e65787454726965496e64657801000c753332100000000004a820547261636b657220666f7220746865206e65787420617661696c61626c65207472696520696e6465780120186372656174651814696e6465783c436f6d706163743c5061726149643e0c63617054436f6d706163743c42616c616e63654f663c543e3e3066697273745f706572696f6464436f6d706163743c4c65617365506572696f644f663c543e3e2c6c6173745f706572696f6464436f6d706163743c4c65617365506572696f644f663c543e3e0c656e645c436f6d706163743c543a3a426c6f636b4e756d6265723e2076657269666965724c4f7074696f6e3c4d756c74695369676e65723e106d01204372656174652061206e65772063726f77646c6f616e696e672063616d706169676e20666f7220612070617261636861696e20736c6f7420776974682074686520676976656e206c6561736520706572696f642072616e67652e0061012054686973206170706c6965732061206c6f636b20746f20796f75722070617261636861696e20636f6e66696775726174696f6e2c20656e737572696e6720746861742069742063616e6e6f74206265206368616e67656468206279207468652070617261636861696e206d616e616765722e28636f6e747269627574650c14696e6465783c436f6d706163743c5061726149643e1476616c756554436f6d706163743c42616c616e63654f663c543e3e247369676e6174757265584f7074696f6e3c4d756c74695369676e61747572653e08550120436f6e7472696275746520746f20612063726f77642073616c652e20546869732077696c6c207472616e7366657220736f6d652062616c616e6365206f76657220746f2066756e6420612070617261636861696e550120736c6f742e2049742077696c6c20626520776974686472617761626c65207768656e207468652063726f77646c6f616e2068617320656e64656420616e64207468652066756e64732061726520756e757365642e207769746864726177080c77686f30543a3a4163636f756e74496414696e6465783c436f6d706163743c5061726149643e44c42057697468647261772066756c6c2062616c616e6365206f66206120737065636966696320636f6e7472696275746f722e00c4204f726967696e206d757374206265207369676e65642c206275742063616e20636f6d652066726f6d20616e796f6e652e00b101205468652066756e64206d7573742062652065697468657220696e2c206f7220726561647920666f722c207265746972656d656e742e20466f7220612066756e6420746f206265202a696e2a207265746972656d656e742c207468656e20746865207265746972656d656e74fc20666c6167206d757374206265207365742e20466f7220612066756e6420746f20626520726561647920666f72207265746972656d656e742c207468656e3aa0202d206974206d757374206e6f7420616c726561647920626520696e207265746972656d656e743b5101202d2074686520616d6f756e74206f66207261697365642066756e6473206d75737420626520626967676572207468616e20746865205f667265655f2062616c616e6365206f6620746865206163636f756e743b38202d20616e64206569746865723ac02020202d2074686520626c6f636b206e756d626572206d757374206265206174206c656173742060656e64603b206f7231012020202d207468652063757272656e74206c6561736520706572696f64206d7573742062652067726561746572207468616e207468652066756e64277320606c6173745f706572696f64602e00710120496e207468697320636173652c207468652066756e642773207265746972656d656e7420666c61672069732073657420616e64206974732060656e646020697320726573657420746f207468652063757272656e7420626c6f636b20206e756d6265722e00f4202d206077686f603a20546865206163636f756e742077686f736520636f6e747269627574696f6e2073686f756c642062652077697468647261776e2e1d01202d2060696e646578603a205468652070617261636861696e20746f2077686f73652063726f77646c6f616e2074686520636f6e747269627574696f6e20776173206d6164652e18726566756e640414696e6465783c436f6d706163743c5061726149643e14e4204175746f6d61746963616c6c7920726566756e6420636f6e7472696275746f7273206f6620616e20656e6465642063726f77646c6f616e2e25012044756520746f20776569676874207265737472696374696f6e732c20746869732066756e6374696f6e206d6179206e65656420746f2062652063616c6c6564206d756c7469706c654d012074696d657320746f2066756c6c7920726566756e6420616c6c2075736572732e2057652077696c6c20726566756e64206052656d6f76654b6579734c696d69746020757365727320617420612074696d652e00c4204f726967696e206d757374206265207369676e65642c206275742063616e20636f6d652066726f6d20616e796f6e652e20646973736f6c76650414696e6465783c436f6d706163743c5061726149643e0459012052656d6f766520612066756e6420616674657220746865207265746972656d656e7420706572696f642068617320656e64656420616e6420616c6c2066756e64732068617665206265656e2072657475726e65642e10656469741814696e6465783c436f6d706163743c5061726149643e0c63617054436f6d706163743c42616c616e63654f663c543e3e3066697273745f706572696f6464436f6d706163743c4c65617365506572696f644f663c543e3e2c6c6173745f706572696f6464436f6d706163743c4c65617365506572696f644f663c543e3e0c656e645c436f6d706163743c543a3a426c6f636b4e756d6265723e2076657269666965724c4f7074696f6e3c4d756c74695369676e65723e0cd420456469742074686520636f6e66696775726174696f6e20666f7220616e20696e2d70726f67726573732063726f77646c6f616e2e008c2043616e206f6e6c792062652063616c6c656420627920526f6f74206f726967696e2e206164645f6d656d6f0814696e64657818506172614964106d656d6f1c5665633c75383e0cf02041646420616e206f7074696f6e616c206d656d6f20746f20616e206578697374696e672063726f77646c6f616e20636f6e747269627574696f6e2e003101204f726967696e206d757374206265205369676e65642c20616e64207468652075736572206d757374206861766520636f6e747269627574656420746f207468652063726f77646c6f616e2e10706f6b650414696e646578185061726149640c7020506f6b65207468652066756e6420696e746f204e6577526169736500e0204f726967696e206d757374206265205369676e65642c20616e64207468652066756e6420686173206e6f6e2d7a65726f2072616973652e01301c43726561746564041850617261496404c4204372656174652061206e65772063726f77646c6f616e696e672063616d706169676e2e205b66756e645f696e6465785d2c436f6e74726962757465640c244163636f756e744964185061726149641c42616c616e636504dc20436f6e747269627574656420746f20612063726f77642073616c652e205b77686f2c2066756e645f696e6465782c20616d6f756e745d2057697468647265770c244163636f756e744964185061726149641c42616c616e63650409012057697468647265772066756c6c2062616c616e6365206f66206120636f6e7472696275746f722e205b77686f2c2066756e645f696e6465782c20616d6f756e745d445061727469616c6c79526566756e646564041850617261496408310120546865206c6f616e7320696e20612066756e642068617665206265656e207061727469616c6c7920646973736f6c7665642c20692e652e2074686572652061726520736f6d65206c656674ec206f766572206368696c64206b6579732074686174207374696c6c206e65656420746f206265206b696c6c65642e205b66756e645f696e6465785d2c416c6c526566756e646564041850617261496404d420416c6c206c6f616e7320696e20612066756e642068617665206265656e20726566756e6465642e205b66756e645f696e6465785d24446973736f6c766564041850617261496404802046756e6420697320646973736f6c7665642e205b66756e645f696e6465785d3c4465706c6f79446174614669786564041850617261496404f420546865206465706c6f792064617461206f66207468652066756e6465642070617261636861696e206973207365742e205b66756e645f696e6465785d244f6e626f6172646564081850617261496418506172614964046901204f6e2d626f617264696e672070726f6365737320666f7220612077696e6e696e672070617261636861696e2066756e6420697320636f6d706c657465642e205b66696e645f696e6465782c2070617261636861696e5f69645d3c48616e646c65426964526573756c740818506172614964384469737061746368526573756c7404f82054686520726573756c74206f6620747279696e6720746f207375626d69742061206e65772062696420746f2074686520536c6f74732070616c6c65742e18456469746564041850617261496404fc2054686520636f6e66696775726174696f6e20746f20612063726f77646c6f616e20686173206265656e206564697465642e205b66756e645f696e6465785d2c4d656d6f557064617465640c244163636f756e744964185061726149641c5665633c75383e04c42041206d656d6f20686173206265656e20757064617465642e205b77686f2c2066756e645f696e6465782c206d656d6f5d3c4164646564546f4e657752616973650418506172614964049c20412070617261636861696e20686173206265656e206d6f76656420746f204e657752616973650c2050616c6c657449642050616c6c657449642070792f6366756e64003c4d696e436f6e747269627574696f6e3042616c616e63654f663c543e400010a5d4e80000000000000000000000003c52656d6f76654b6579734c696d69740c75333210f4010000005c444669727374506572696f64496e5061737404f8205468652063757272656e74206c6561736520706572696f64206973206d6f7265207468616e20746865206669727374206c6561736520706572696f642e644669727374506572696f64546f6f466172496e46757475726504150120546865206669727374206c6561736520706572696f64206e6565647320746f206174206c65617374206265206c657373207468616e203320606d61785f76616c7565602e6c4c617374506572696f644265666f72654669727374506572696f6404ec204c617374206c6561736520706572696f64206d7573742062652067726561746572207468616e206669727374206c6561736520706572696f642e604c617374506572696f64546f6f466172496e46757475726504310120546865206c617374206c6561736520706572696f642063616e6e6f74206265206d6f7265207468656e203320706572696f64732061667465722074686520666972737420706572696f642e3c43616e6e6f74456e64496e50617374044901205468652063616d706169676e20656e6473206265666f7265207468652063757272656e7420626c6f636b206e756d6265722e2054686520656e64206d75737420626520696e20746865206675747572652e44456e64546f6f466172496e46757475726504c42054686520656e64206461746520666f7220746869732063726f77646c6f616e206973206e6f742073656e7369626c652e204f766572666c6f77045c2054686572652077617320616e206f766572666c6f772e50436f6e747269627574696f6e546f6f536d616c6c04ec2054686520636f6e747269627574696f6e207761732062656c6f7720746865206d696e696d756d2c20604d696e436f6e747269627574696f6e602e34496e76616c6964506172614964045020496e76616c69642066756e6420696e6465782e2c4361704578636565646564049420436f6e747269627574696f6e7320657863656564206d6178696d756d20616d6f756e742e58436f6e747269627574696f6e506572696f644f76657204ac2054686520636f6e747269627574696f6e20706572696f642068617320616c726561647920656e6465642e34496e76616c69644f726967696e049020546865206f726967696e206f6620746869732063616c6c20697320696e76616c69642e304e6f7450617261636861696e04cc20546869732063726f77646c6f616e20646f6573206e6f7420636f72726573706f6e6420746f20612070617261636861696e2e2c4c6561736541637469766504190120546869732070617261636861696e206c65617365206973207374696c6c2061637469766520616e64207265746972656d656e742063616e6e6f742079657420626567696e2e404269644f724c6561736541637469766504350120546869732070617261636861696e277320626964206f72206c65617365206973207374696c6c2061637469766520616e642077697468647261772063616e6e6f742079657420626567696e2e4046756e64734e6f7452657475726e656404882046756e64732068617665206e6f7420796574206265656e2072657475726e65642e3046756e644e6f74456e6465640484205468652063726f77646c6f616e20686173206e6f742079657420656e6465642e3c4e6f436f6e747269627574696f6e7304d420546865726520617265206e6f20636f6e747269627574696f6e732073746f72656420696e20746869732063726f77646c6f616e2e4848617341637469766550617261636861696e04010120546869732063726f77646c6f616e2068617320616e206163746976652070617261636861696e20616e642063616e6e6f7420626520646973736f6c7665642e484e6f745265616479546f446973736f6c7665047901205468652063726f77646c6f616e206973206e6f7420726561647920746f20646973736f6c76652e20506f74656e7469616c6c79207374696c6c20686173206120736c6f74206f7220696e207265746972656d656e7420706572696f642e40496e76616c69645369676e6174757265044c20496e76616c6964207369676e61747572652e304d656d6f546f6f4c617267650480205468652070726f7669646564206d656d6f20697320746f6f206c617267652e44416c7265616479496e4e657752616973650480205468652066756e6420697320616c726561647920696e204e657752616973651b14536c6f74730114536c6f747304184c656173657301010518506172614964a45665633c4f7074696f6e3c28543a3a4163636f756e7449642c2042616c616e63654f663c543e293e3e00040040150120416d6f756e74732068656c64206f6e206465706f73697420666f7220656163682028706f737369626c792066757475726529206c65617365642070617261636861696e2e009901205468652061637475616c20616d6f756e74206c6f636b6564206f6e2069747320626568616c6620627920616e79206163636f756e7420617420616e792074696d6520697320746865206d6178696d756d206f6620746865207365636f6e642076616c756573f0206f6620746865206974656d7320696e2074686973206c6973742077686f73652066697273742076616c756520697320746865206163636f756e742e00610120546865206669727374206974656d20696e20746865206c6973742069732074686520616d6f756e74206c6f636b656420666f72207468652063757272656e74204c6561736520506572696f642e20466f6c6c6f77696e67b0206974656d732061726520666f72207468652073756273657175656e74206c6561736520706572696f64732e006101205468652064656661756c742076616c75652028616e20656d707479206c6973742920696d706c6965732074686174207468652070617261636861696e206e6f206c6f6e6765722065786973747320286f72206e65766572b4206578697374656429206173206661722061732074686973206d6f64756c6520697320636f6e6365726e65642e00510120496620612070617261636861696e20646f65736e2774206578697374202a7965742a20627574206973207363686564756c656420746f20657869737420696e20746865206675747572652c207468656e20697461012077696c6c206265206c6566742d7061646465642077697468206f6e65206f72206d6f726520604e6f6e65607320746f2064656e6f74652074686520666163742074686174206e6f7468696e672069732068656c64206f6e5d01206465706f73697420666f7220746865206e6f6e2d6578697374656e7420636861696e2063757272656e746c792c206275742069732068656c6420617420736f6d6520706f696e7420696e20746865206675747572652e00dc20497420697320696c6c6567616c20666f72206120604e6f6e65602076616c756520746f20747261696c20696e20746865206c6973742e010c2c666f7263655f6c6561736514107061726118506172614964186c656173657230543a3a4163636f756e74496418616d6f756e743042616c616e63654f663c543e30706572696f645f626567696e404c65617365506572696f644f663c543e30706572696f645f636f756e74404c65617365506572696f644f663c543e106d01204a757374206120686f747769726520696e746f2074686520606c656173655f6f7574602063616c6c2c20696e206361736520526f6f742077616e747320746f20666f72636520736f6d65206c6561736520746f2068617070656ee420696e646570656e64656e746c79206f6620616e79206f74686572206f6e2d636861696e206d656368616e69736d20746f207573652069742e009c2043616e206f6e6c792062652063616c6c65642062792074686520526f6f74206f726967696e2e40636c6561725f616c6c5f6c6561736573041070617261185061726149640c510120436c65617220616c6c206c656173657320666f72206120506172612049642c20726566756e64696e6720616e79206465706f73697473206261636b20746f20746865206f726967696e616c206f776e6572732e009c2043616e206f6e6c792062652063616c6c65642062792074686520526f6f74206f726967696e2e3c747269676765725f6f6e626f617264041070617261185061726149641c29012054727920746f206f6e626f61726420612070617261636861696e2074686174206861732061206c6561736520666f72207468652063757272656e74206c6561736520706572696f642e00490120546869732066756e6374696f6e2063616e2062652075736566756c2069662074686572652077617320736f6d6520737461746520697373756520776974682061207061726120746861742073686f756c643d012068617665206f6e626f61726465642c206275742077617320756e61626c6520746f2e204173206c6f6e67206173207468657920686176652061206c6561736520706572696f642c2077652063616e70206c6574207468656d206f6e626f6172642066726f6d20686572652e00d0204f726967696e206d757374206265207369676e65642c206275742063616e2062652063616c6c656420627920616e796f6e652e0108384e65774c65617365506572696f64042c4c65617365506572696f64048c2041206e6577205b6c656173655f706572696f645d20697320626567696e6e696e672e184c65617365641818506172614964244163636f756e7449642c4c65617365506572696f642c4c65617365506572696f641c42616c616e63651c42616c616e63650cc420416e206578697374696e672070617261636861696e20776f6e2074686520726967687420746f20636f6e74696e75652e41012046697273742062616c616e63652069732074686520657874726120616d6f756e7420726573657665642e205365636f6e642069732074686520746f74616c20616d6f756e742072657365727665642e4901205c5b70617261636861696e5f69642c206c65617365722c20706572696f645f626567696e2c20706572696f645f636f756e742c2065787472615f726573657665642c20746f74616c5f616d6f756e745c5d042c4c65617365506572696f6438543a3a426c6f636b4e756d6265721040380000000844506172614e6f744f6e626f617264696e670490205468652070617261636861696e204944206973206e6f74206f6e626f617264696e672e284c656173654572726f72048c2054686572652077617320616e206572726f72207769746820746865206c656173652e1c4050617261735375646f57726170706572000118747375646f5f7363686564756c655f706172615f696e697469616c697a6508086964185061726149641c67656e657369733c5061726147656e6573697341726773041101205363686564756c652061207061726120746f20626520696e697469616c697a656420617420746865207374617274206f6620746865206e6578742073657373696f6e2e687375646f5f7363686564756c655f706172615f636c65616e75700408696418506172614964040d01205363686564756c652061207061726120746f20626520636c65616e656420757020617420746865207374617274206f6620746865206e6578742073657373696f6e2e807375646f5f7363686564756c655f706172617468726561645f757067726164650408696418506172614964049020557067726164652061207061726174687265616420746f20612070617261636861696e847375646f5f7363686564756c655f70617261636861696e5f646f776e67726164650408696418506172614964049820446f776e677261646520612070617261636861696e20746f206120706172617468726561645c7375646f5f71756575655f646f776e776172645f78636d08086964185061726149640c78636d4478636d3a3a56657273696f6e656458636d109c2053656e64206120646f776e776172642058434d20746f2074686520676976656e20706172612e0069012054686520676976656e2070617261636861696e2073686f756c6420657869737420616e6420746865207061796c6f61642073686f756c64206e6f74206578636565642074686520707265636f6e666967757265642073697a65902060636f6e6669672e6d61785f646f776e776172645f6d6573736167655f73697a65602e6c7375646f5f65737461626c6973685f68726d705f6368616e6e656c101873656e6465721850617261496424726563697069656e7418506172614964306d61785f63617061636974790c753332406d61785f6d6573736167655f73697a650c75333210050120466f72636566756c6c792065737461626c6973682061206368616e6e656c2066726f6d207468652073656e64657220746f2074686520726563697069656e742e0059012054686973206973206571756976616c656e7420746f2073656e64696e6720616e206048726d703a3a68726d705f696e69745f6f70656e5f6368616e6e656c602065787472696e73696320666f6c6c6f77656420627988206048726d703a3a68726d705f6163636570745f6f70656e5f6368616e6e656c602e0000203c50617261446f65736e74457869737404e420546865207370656369666965642070617261636861696e206f722070617261746872656164206973206e6f7420726567697374657265642e4450617261416c726561647945786973747304f420546865207370656369666965642070617261636861696e206f72207061726174687265616420697320616c726561647920726567697374657265642e54457863656564734d61784d65737361676553697a65086901204120444d50206d65737361676520636f756c646e27742062652073656e742062656361757365206974206578636565647320746865206d6178696d756d2073697a6520616c6c6f77656420666f72206120646f776e7761726424206d6573736167652e38436f756c646e74436c65616e7570048420436f756c64206e6f74207363686564756c65207061726120636c65616e75702e344e6f74506172617468726561640448204e6f74206120706172617468726561642e304e6f7450617261636861696e0444204e6f7420612070617261636861696e2e3443616e6e6f7455706772616465046c2043616e6e6f74207570677261646520706172617468726561642e3c43616e6e6f74446f776e677261646504702043616e6e6f7420646f776e67726164652070617261636861696e2e1d105375646f01105375646f040c4b6579010030543a3a4163636f756e74496480000000000000000000000000000000000000000000000000000000000000000004842054686520604163636f756e74496460206f6620746865207375646f206b65792e0110107375646f041063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e2839012041757468656e7469636174657320746865207375646f206b657920616e64206469737061746368657320612066756e6374696f6e2063616c6c20776974682060526f6f7460206f726967696e2e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002c2023203c7765696768743e20202d204f2831292e64202d204c696d697465642073746f726167652072656164732e60202d204f6e6520444220777269746520286576656e74292ec8202d20576569676874206f662064657269766174697665206063616c6c6020657865637574696f6e202b2031302c3030302e302023203c2f7765696768743e547375646f5f756e636865636b65645f776569676874081063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e1c5f776569676874185765696768742839012041757468656e7469636174657320746865207375646f206b657920616e64206469737061746368657320612066756e6374696f6e2063616c6c20776974682060526f6f7460206f726967696e2e310120546869732066756e6374696f6e20646f6573206e6f7420636865636b2074686520776569676874206f66207468652063616c6c2c20616e6420696e737465616420616c6c6f777320746865b4205375646f207573657220746f20737065636966792074686520776569676874206f66207468652063616c6c2e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002c2023203c7765696768743e20202d204f2831292ed0202d2054686520776569676874206f6620746869732063616c6c20697320646566696e6564206279207468652063616c6c65722e302023203c2f7765696768743e1c7365745f6b6579040c6e65778c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263652475012041757468656e74696361746573207468652063757272656e74207375646f206b657920616e6420736574732074686520676976656e204163636f756e7449642028606e6577602920617320746865206e6577207375646f206b65792e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002c2023203c7765696768743e20202d204f2831292e64202d204c696d697465642073746f726167652072656164732e44202d204f6e65204442206368616e67652e302023203c2f7765696768743e1c7375646f5f6173080c77686f8c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e2c51012041757468656e7469636174657320746865207375646f206b657920616e64206469737061746368657320612066756e6374696f6e2063616c6c207769746820605369676e656460206f726967696e2066726f6d44206120676976656e206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002c2023203c7765696768743e20202d204f2831292e64202d204c696d697465642073746f726167652072656164732e60202d204f6e6520444220777269746520286576656e74292ec8202d20576569676874206f662064657269766174697665206063616c6c6020657865637574696f6e202b2031302c3030302e302023203c2f7765696768743e010c14537564696404384469737061746368526573756c74048c2041207375646f206a75737420746f6f6b20706c6163652e205c5b726573756c745c5d284b65794368616e67656404244163636f756e74496404010120546865205c5b7375646f65725c5d206a757374207377697463686564206964656e746974793b20746865206f6c64206b657920697320737570706c6965642e285375646f4173446f6e6504384469737061746368526573756c74048c2041207375646f206a75737420746f6f6b20706c6163652e205c5b726573756c745c5d00042c526571756972655375646f04802053656e646572206d75737420626520746865205375646f206163636f756e741e0c4d6d72014c4d65726b6c654d6f756e7461696e52616e67650c20526f6f74486173680100583c5420617320436f6e6669673c493e3e3a3a486173688000000000000000000000000000000000000000000000000000000000000000000458204c6174657374204d4d5220526f6f7420686173682e384e756d6265724f664c656176657301000c75363420000000000000000004b02043757272656e742073697a65206f6620746865204d4d5220286e756d626572206f66206c6561766573292e144e6f6465730001060c753634583c5420617320436f6e6669673c493e3e3a3a48617368000400108020486173686573206f6620746865206e6f64657320696e20746865204d4d522e002d01204e6f7465207468697320636f6c6c656374696f6e206f6e6c7920636f6e7461696e73204d4d52207065616b732c2074686520696e6e6572206e6f6465732028616e64206c656176657329bc20617265207072756e656420616e64206f6e6c792073746f72656420696e20746865204f6666636861696e2044422e01000000001f144265656679011442656566790c2c417574686f72697469657301004c5665633c543a3a417574686f7269747949643e04000470205468652063757272656e7420617574686f726974696573207365743856616c696461746f72536574496401008062656566795f7072696d6974697665733a3a56616c696461746f7253657449642000000000000000000474205468652063757272656e742076616c696461746f72207365742069643c4e657874417574686f72697469657301004c5665633c543a3a417574686f7269747949643e040004ec20417574686f72697469657320736574207363686564756c656420746f2062652075736564207769746820746865206e6578742073657373696f6e00000000201c4d6d724c65616601144265656679045042656566794e657874417574686f72697469657301009842656566794e657874417574686f726974795365743c4d65726b6c65526f6f744f663c543e3eb000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c942044657461696c73206f66206e65787420424545465920617574686f72697479207365742e00590120546869732073746f7261676520656e747279206973207573656420617320636163686520666f722063616c6c7320746f205b607570646174655f62656566795f6e6578745f617574686f726974795f736574605d2e00000000214056616c696461746f724d616e61676572014450617261636861696e50726f706f736572084856616c696461746f7273546f52657469726501004c5665633c543a3a56616c696461746f7249643e04000435012056616c696461746f727320746861742073686f756c6420626520726574697265642c20626563617573652074686569722050617261636861696e20776173206465726567697374657265642e3c56616c696461746f7273546f41646401004c5665633c543a3a56616c696461746f7249643e040004842056616c696461746f727320746861742073686f756c642062652061646465642e01084c72656769737465725f76616c696461746f7273042876616c696461746f72734c5665633c543a3a56616c696461746f7249643e0c7c20416464206e65772076616c696461746f727320746f20746865207365742e00f020546865206e65772076616c696461746f72732077696c6c206265206163746976652066726f6d2063757272656e742073657373696f6e202b20322e54646572656769737465725f76616c696461746f7273042876616c696461746f72734c5665633c543a3a56616c696461746f7249643e0c802052656d6f76652076616c696461746f72732066726f6d20746865207365742e001501205468652072656d6f7665642076616c696461746f72732077696c6c2062652064656163746976617465642066726f6d2063757272656e742073657373696f6e202b20322e01085056616c696461746f72735265676973746572656404405665633c56616c696461746f7249643e0498204e65772076616c696461746f7273207765726520616464656420746f20746865207365742e5856616c696461746f727344657265676973746572656404405665633c56616c696461746f7249643e04982056616c696461746f727320776572652072656d6f7665642066726f6d20746865207365742e0000221c5574696c69747900010c146261746368041463616c6c73605665633c3c5420617320436f6e6669673e3a3a43616c6c3e48802053656e642061206261746368206f662064697370617463682063616c6c732e007c204d61792062652063616c6c65642066726f6d20616e79206f726967696e2e00f0202d206063616c6c73603a205468652063616c6c7320746f20626520646973706174636865642066726f6d207468652073616d65206f726967696e2e006101204966206f726967696e20697320726f6f74207468656e2063616c6c2061726520646973706174636820776974686f757420636865636b696e67206f726967696e2066696c7465722e20285468697320696e636c75646573cc20627970617373696e6720606672616d655f73797374656d3a3a436f6e6669673a3a4261736543616c6c46696c74657260292e002c2023203c7765696768743e0501202d20436f6d706c65786974793a204f284329207768657265204320697320746865206e756d626572206f662063616c6c7320746f20626520626174636865642e302023203c2f7765696768743e00590120546869732077696c6c2072657475726e20604f6b6020696e20616c6c2063697263756d7374616e6365732e20546f2064657465726d696e65207468652073756363657373206f66207468652062617463682c20616e3501206576656e74206973206465706f73697465642e20496620612063616c6c206661696c656420616e64207468652062617463682077617320696e7465727275707465642c207468656e20746865590120604261746368496e74657272757074656460206576656e74206973206465706f73697465642c20616c6f6e67207769746820746865206e756d626572206f66207375636365737366756c2063616c6c73206d616465510120616e6420746865206572726f72206f6620746865206661696c65642063616c6c2e20496620616c6c2077657265207375636365737366756c2c207468656e2074686520604261746368436f6d706c657465646050206576656e74206973206465706f73697465642e3461735f646572697661746976650814696e6465780c7531361063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e34e02053656e6420612063616c6c207468726f75676820616e20696e64657865642070736575646f6e796d206f66207468652073656e6465722e0059012046696c7465722066726f6d206f726967696e206172652070617373656420616c6f6e672e205468652063616c6c2077696c6c2062652064697370617463686564207769746820616e206f726967696e207768696368c020757365207468652073616d652066696c74657220617320746865206f726967696e206f6620746869732063616c6c2e004901204e4f54453a20496620796f75206e65656420746f20656e73757265207468617420616e79206163636f756e742d62617365642066696c746572696e67206973206e6f7420686f6e6f7265642028692e652e6501206265636175736520796f7520657870656374206070726f78796020746f2068617665206265656e2075736564207072696f7220696e207468652063616c6c20737461636b20616e6420796f7520646f206e6f742077616e745501207468652063616c6c207265737472696374696f6e7320746f206170706c7920746f20616e79207375622d6163636f756e7473292c207468656e20757365206061735f6d756c74695f7468726573686f6c645f31608020696e20746865204d756c74697369672070616c6c657420696e73746561642e00f8204e4f54453a205072696f7220746f2076657273696f6e202a31322c2074686973207761732063616c6c6564206061735f6c696d697465645f737562602e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e2462617463685f616c6c041463616c6c73605665633c3c5420617320436f6e6669673e3a3a43616c6c3e34f02053656e642061206261746368206f662064697370617463682063616c6c7320616e642061746f6d6963616c6c792065786563757465207468656d2e2501205468652077686f6c65207472616e73616374696f6e2077696c6c20726f6c6c6261636b20616e64206661696c20696620616e79206f66207468652063616c6c73206661696c65642e007c204d61792062652063616c6c65642066726f6d20616e79206f726967696e2e00f0202d206063616c6c73603a205468652063616c6c7320746f20626520646973706174636865642066726f6d207468652073616d65206f726967696e2e006101204966206f726967696e20697320726f6f74207468656e2063616c6c2061726520646973706174636820776974686f757420636865636b696e67206f726967696e2066696c7465722e20285468697320696e636c75646573cc20627970617373696e6720606672616d655f73797374656d3a3a436f6e6669673a3a4261736543616c6c46696c74657260292e002c2023203c7765696768743e0501202d20436f6d706c65786974793a204f284329207768657265204320697320746865206e756d626572206f662063616c6c7320746f20626520626174636865642e302023203c2f7765696768743e0108404261746368496e746572727570746564080c7533323444697370617463684572726f72085901204261746368206f66206469737061746368657320646964206e6f7420636f6d706c6574652066756c6c792e20496e646578206f66206669727374206661696c696e6720646973706174636820676976656e2c206173902077656c6c20617320746865206572726f722e205c5b696e6465782c206572726f725c5d384261746368436f6d706c657465640004cc204261746368206f66206469737061746368657320636f6d706c657465642066756c6c792077697468206e6f206572726f722e00005a1450726f7879011450726f7879081c50726f7869657301010530543a3a4163636f756e7449644501285665633c50726f7879446566696e6974696f6e3c543a3a4163636f756e7449642c20543a3a50726f7879547970652c20543a3a426c6f636b4e756d6265723e3e2c0a2042616c616e63654f663c543e29004400000000000000000000000000000000000845012054686520736574206f66206163636f756e742070726f786965732e204d61707320746865206163636f756e74207768696368206861732064656c65676174656420746f20746865206163636f756e7473210120776869636820617265206265696e672064656c65676174656420746f2c20746f67657468657220776974682074686520616d6f756e742068656c64206f6e206465706f7369742e34416e6e6f756e63656d656e747301010530543a3a4163636f756e7449643d01285665633c416e6e6f756e63656d656e743c543a3a4163636f756e7449642c2043616c6c486173684f663c543e2c20543a3a426c6f636b4e756d6265723e3e2c0a2042616c616e63654f663c543e290044000000000000000000000000000000000004ac2054686520616e6e6f756e63656d656e7473206d616465206279207468652070726f787920286b6579292e01281470726f78790c107265616c30543a3a4163636f756e74496440666f7263655f70726f78795f74797065504f7074696f6e3c543a3a50726f7879547970653e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e3c51012044697370617463682074686520676976656e206063616c6c602066726f6d20616e206163636f756e742074686174207468652073656e64657220697320617574686f726973656420666f72207468726f7567683420606164645f70726f7879602e00ac2052656d6f76657320616e7920636f72726573706f6e64696e6720616e6e6f756e63656d656e742873292e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e6501202d2060666f7263655f70726f78795f74797065603a2053706563696679207468652065786163742070726f7879207479706520746f206265207573656420616e6420636865636b656420666f7220746869732063616c6c2ed4202d206063616c6c603a205468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e246164645f70726f78790c2064656c656761746530543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d62657234490120526567697374657220612070726f7879206163636f756e7420666f72207468652073656e64657220746861742069732061626c6520746f206d616b652063616c6c73206f6e2069747320626568616c662e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1501202d206070726f7879603a20546865206163636f756e74207468617420746865206063616c6c65726020776f756c64206c696b6520746f206d616b6520612070726f78792e0101202d206070726f78795f74797065603a20546865207065726d697373696f6e7320616c6c6f77656420666f7220746869732070726f7879206163636f756e742e5101202d206064656c6179603a2054686520616e6e6f756e63656d656e7420706572696f64207265717569726564206f662074686520696e697469616c2070726f78792e2057696c6c2067656e6572616c6c7920626518207a65726f2e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e3072656d6f76655f70726f78790c2064656c656761746530543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d6265722cac20556e726567697374657220612070726f7879206163636f756e7420666f72207468652073656e6465722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a2901202d206070726f7879603a20546865206163636f756e74207468617420746865206063616c6c65726020776f756c64206c696b6520746f2072656d6f766520617320612070726f78792e4501202d206070726f78795f74797065603a20546865207065726d697373696f6e732063757272656e746c7920656e61626c656420666f72207468652072656d6f7665642070726f7879206163636f756e742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e3872656d6f76655f70726f786965730028b820556e726567697374657220616c6c2070726f7879206163636f756e747320666f72207468652073656e6465722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901205741524e494e473a2054686973206d61792062652063616c6c6564206f6e206163636f756e747320637265617465642062792060616e6f6e796d6f7573602c20686f776576657220696620646f6e652c207468656e5d012074686520756e726573657276656420666565732077696c6c20626520696e61636365737369626c652e202a2a416c6c2061636365737320746f2074686973206163636f756e742077696c6c206265206c6f73742e2a2a002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e24616e6f6e796d6f75730c2870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d62657214696e6465780c7531365c3d0120537061776e2061206672657368206e6577206163636f756e7420746861742069732067756172616e7465656420746f206265206f746865727769736520696e61636365737369626c652c20616e64010120696e697469616c697a65206974207769746820612070726f7879206f66206070726f78795f747970656020666f7220606f726967696e602073656e6465722e0070205265717569726573206120605369676e656460206f726967696e2e005501202d206070726f78795f74797065603a205468652074797065206f66207468652070726f78792074686174207468652073656e6465722077696c6c2062652072656769737465726564206173206f766572207468655101206e6577206163636f756e742e20546869732077696c6c20616c6d6f737420616c7761797320626520746865206d6f7374207065726d697373697665206050726f7879547970656020706f737369626c6520746f7c20616c6c6f7720666f72206d6178696d756d20666c65786962696c6974792e5501202d2060696e646578603a204120646973616d626967756174696f6e20696e6465782c20696e206361736520746869732069732063616c6c6564206d756c7469706c652074696d657320696e207468652073616d656101207472616e73616374696f6e2028652e672e207769746820607574696c6974793a3a626174636860292e20556e6c65737320796f75277265207573696e67206062617463686020796f752070726f6261626c79206a757374442077616e7420746f20757365206030602e5101202d206064656c6179603a2054686520616e6e6f756e63656d656e7420706572696f64207265717569726564206f662074686520696e697469616c2070726f78792e2057696c6c2067656e6572616c6c7920626518207a65726f2e005501204661696c73207769746820604475706c69636174656020696620746869732068617320616c7265616479206265656e2063616c6c656420696e2074686973207472616e73616374696f6e2c2066726f6d207468659c2073616d652073656e6465722c2077697468207468652073616d6520706172616d65746572732e00e8204661696c732069662074686572652061726520696e73756666696369656e742066756e647320746f2070617920666f72206465706f7369742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e9020544f444f3a204d69676874206265206f76657220636f756e74696e6720312072656164386b696c6c5f616e6f6e796d6f7573141c737061776e657230543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f78795479706514696e6465780c753136186865696768745c436f6d706163743c543a3a426c6f636b4e756d6265723e246578745f696e64657830436f6d706163743c7533323e50b82052656d6f76657320612070726576696f75736c7920737061776e656420616e6f6e796d6f75732070726f78792e004d01205741524e494e473a202a2a416c6c2061636365737320746f2074686973206163636f756e742077696c6c206265206c6f73742e2a2a20416e792066756e64732068656c6420696e2069742077696c6c2062653820696e61636365737369626c652e005d01205265717569726573206120605369676e656460206f726967696e2c20616e64207468652073656e646572206163636f756e74206d7573742068617665206265656e206372656174656420627920612063616c6c20746fac2060616e6f6e796d6f757360207769746820636f72726573706f6e64696e6720706172616d65746572732e005101202d2060737061776e6572603a20546865206163636f756e742074686174206f726967696e616c6c792063616c6c65642060616e6f6e796d6f75736020746f206372656174652074686973206163636f756e742e5101202d2060696e646578603a2054686520646973616d626967756174696f6e20696e646578206f726967696e616c6c792070617373656420746f2060616e6f6e796d6f7573602e2050726f6261626c79206030602e0501202d206070726f78795f74797065603a205468652070726f78792074797065206f726967696e616c6c792070617373656420746f2060616e6f6e796d6f7573602e4101202d2060686569676874603a2054686520686569676874206f662074686520636861696e207768656e207468652063616c6c20746f2060616e6f6e796d6f757360207761732070726f6365737365642e4d01202d20606578745f696e646578603a205468652065787472696e73696320696e64657820696e207768696368207468652063616c6c20746f2060616e6f6e796d6f757360207761732070726f6365737365642e004d01204661696c73207769746820604e6f5065726d697373696f6e6020696e2063617365207468652063616c6c6572206973206e6f7420612070726576696f75736c79206372656174656420616e6f6e796d6f7573f4206163636f756e742077686f73652060616e6f6e796d6f7573602063616c6c2068617320636f72726573706f6e64696e6720706172616d65746572732e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e20616e6e6f756e636508107265616c30543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e540901205075626c697368207468652068617368206f6620612070726f78792d63616c6c20746861742077696c6c206265206d61646520696e20746865206675747572652e0061012054686973206d7573742062652063616c6c656420736f6d65206e756d626572206f6620626c6f636b73206265666f72652074686520636f72726573706f6e64696e67206070726f78796020697320617474656d707465642901206966207468652064656c6179206173736f6369617465642077697468207468652070726f78792072656c6174696f6e736869702069732067726561746572207468616e207a65726f2e001501204e6f206d6f7265207468616e20604d617850656e64696e676020616e6e6f756e63656d656e7473206d6179206265206d61646520617420616e79206f6e652074696d652e000d0120546869732077696c6c2074616b652061206465706f736974206f662060416e6e6f756e63656d656e744465706f736974466163746f72602061732077656c6c2061731d012060416e6e6f756e63656d656e744465706f736974426173656020696620746865726520617265206e6f206f746865722070656e64696e6720616e6e6f756e63656d656e74732e00290120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420612070726f7879206f6620607265616c602e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e1901202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e4c72656d6f76655f616e6e6f756e63656d656e7408107265616c30543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e40742052656d6f7665206120676976656e20616e6e6f756e63656d656e742e005d01204d61792062652063616c6c656420627920612070726f7879206163636f756e7420746f2072656d6f766520612063616c6c20746865792070726576696f75736c7920616e6e6f756e63656420616e642072657475726e3420746865206465706f7369742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e1901202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e4c72656a6563745f616e6e6f756e63656d656e74082064656c656761746530543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e40b42052656d6f76652074686520676976656e20616e6e6f756e63656d656e74206f6620612064656c65676174652e006501204d61792062652063616c6c6564206279206120746172676574202870726f7869656429206163636f756e7420746f2072656d6f766520612063616c6c2074686174206f6e65206f662074686569722064656c656761746573290120286064656c656761746560292068617320616e6e6f756e63656420746865792077616e7420746f20657865637574652e20546865206465706f7369742069732072657475726e65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733af8202d206064656c6567617465603a20546865206163636f756e7420746861742070726576696f75736c7920616e6e6f756e636564207468652063616c6c2ec0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e3c70726f78795f616e6e6f756e636564102064656c656761746530543a3a4163636f756e744964107265616c30543a3a4163636f756e74496440666f7263655f70726f78795f74797065504f7074696f6e3c543a3a50726f7879547970653e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e4451012044697370617463682074686520676976656e206063616c6c602066726f6d20616e206163636f756e742074686174207468652073656e64657220697320617574686f72697a656420666f72207468726f7567683420606164645f70726f7879602e00ac2052656d6f76657320616e7920636f72726573706f6e64696e6720616e6e6f756e63656d656e742873292e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e6501202d2060666f7263655f70726f78795f74797065603a2053706563696679207468652065786163742070726f7879207479706520746f206265207573656420616e6420636865636b656420666f7220746869732063616c6c2ed4202d206063616c6c603a205468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e010c3450726f7879457865637574656404384469737061746368526573756c7404ec20412070726f78792077617320657865637574656420636f72726563746c792c20776974682074686520676976656e205c5b726573756c745c5d2e40416e6f6e796d6f75734372656174656410244163636f756e744964244163636f756e7449642450726f7879547970650c75313608ec20416e6f6e796d6f7573206163636f756e7420686173206265656e2063726561746564206279206e65772070726f7879207769746820676976656e690120646973616d626967756174696f6e20696e64657820616e642070726f787920747970652e205c5b616e6f6e796d6f75732c2077686f2c2070726f78795f747970652c20646973616d626967756174696f6e5f696e6465785c5d24416e6e6f756e6365640c244163636f756e744964244163636f756e744964104861736804510120416e20616e6e6f756e63656d656e742077617320706c6163656420746f206d616b6520612063616c6c20696e20746865206675747572652e205c5b7265616c2c2070726f78792c2063616c6c5f686173685c5d184050726f78794465706f736974426173653042616c616e63654f663c543e400a00000000000000000000000000000010110120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720612070726f78792e00010120546869732069732068656c6420666f7220616e206164646974696f6e616c2073746f72616765206974656d2077686f73652076616c75652073697a652069732501206073697a656f662842616c616e6365296020627974657320616e642077686f7365206b65792073697a65206973206073697a656f66284163636f756e74496429602062797465732e4850726f78794465706f736974466163746f723042616c616e63654f663c543e400a00000000000000000000000000000014bc2054686520616d6f756e74206f662063757272656e6379206e6565646564207065722070726f78792061646465642e00690120546869732069732068656c6420666f7220616464696e6720333220627974657320706c757320616e20696e7374616e6365206f66206050726f78795479706560206d6f726520696e746f2061207072652d6578697374696e6761012073746f726167652076616c75652e20546875732c207768656e20636f6e6669677572696e67206050726f78794465706f736974466163746f7260206f6e652073686f756c642074616b6520696e746f206163636f756e74c020603332202b2070726f78795f747970652e656e636f646528292e6c656e282960206279746573206f6620646174612e284d617850726f786965730c75313608200004f020546865206d6178696d756d20616d6f756e74206f662070726f7869657320616c6c6f77656420666f7220612073696e676c65206163636f756e742e284d617850656e64696e670c753332102000000004450120546865206d6178696d756d20616d6f756e74206f662074696d652d64656c6179656420616e6e6f756e63656d656e747320746861742061726520616c6c6f77656420746f2062652070656e64696e672e5c416e6e6f756e63656d656e744465706f736974426173653042616c616e63654f663c543e400a0000000000000000000000000000000c310120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720616e20616e6e6f756e63656d656e742e00690120546869732069732068656c64207768656e2061206e65772073746f72616765206974656d20686f6c64696e672061206042616c616e636560206973206372656174656420287479706963616c6c79203136206279746573292e64416e6e6f756e63656d656e744465706f736974466163746f723042616c616e63654f663c543e400a00000000000000000000000000000010d42054686520616d6f756e74206f662063757272656e6379206e65656465642070657220616e6e6f756e63656d656e74206d6164652e00590120546869732069732068656c6420666f7220616464696e6720616e20604163636f756e744964602c2060486173686020616e642060426c6f636b4e756d6265726020287479706963616c6c79203638206279746573298c20696e746f2061207072652d6578697374696e672073746f726167652076616c75652e201c546f6f4d616e790425012054686572652061726520746f6f206d616e792070726f786965732072656769737465726564206f7220746f6f206d616e7920616e6e6f756e63656d656e74732070656e64696e672e204e6f74466f756e6404782050726f787920726567697374726174696f6e206e6f7420666f756e642e204e6f7450726f787904d02053656e646572206973206e6f7420612070726f7879206f6620746865206163636f756e7420746f2062652070726f786965642e2c556e70726f787961626c6504250120412063616c6c20776869636820697320696e636f6d70617469626c652077697468207468652070726f7879207479706527732066696c7465722077617320617474656d707465642e244475706c69636174650470204163636f756e7420697320616c726561647920612070726f78792e304e6f5065726d697373696f6e0419012043616c6c206d6179206e6f74206265206d6164652062792070726f78792062656361757365206974206d617920657363616c617465206974732070726976696c656765732e2c556e616e6e6f756e63656404d420416e6e6f756e63656d656e742c206966206d61646520617420616c6c2c20776173206d61646520746f6f20726563656e746c792e2c4e6f53656c6650726f787904682043616e6e6f74206164642073656c662061732070726f78792e5b041c40436865636b5370656356657273696f6e38436865636b547856657273696f6e30436865636b47656e6573697338436865636b4d6f7274616c69747928436865636b4e6f6e63652c436865636b576569676874604368617267655472616e73616374696f6e5061796d656e74 \ No newline at end of file diff --git a/runtime/src/main/assets/metadata/westend b/runtime/src/main/assets/metadata/westend new file mode 100644 index 0000000..c0bc52b --- /dev/null +++ b/runtime/src/main/assets/metadata/westend @@ -0,0 +1 @@ +0x6d6574610ca01853797374656d011853797374656d401c4163636f756e7401010230543a3a4163636f756e744964944163636f756e74496e666f3c543a3a496e6465782c20543a3a4163636f756e74446174613e004101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004e8205468652066756c6c206163636f756e7420696e666f726d6174696f6e20666f72206120706172746963756c6172206163636f756e742049442e3845787472696e736963436f756e7400000c753332040004b820546f74616c2065787472696e7369637320636f756e7420666f72207468652063757272656e7420626c6f636b2e2c426c6f636b576569676874010038436f6e73756d6564576569676874600000000000000000000000000000000000000000000000000488205468652063757272656e742077656967687420666f722074686520626c6f636b2e40416c6c45787472696e736963734c656e00000c753332040004410120546f74616c206c656e6774682028696e2062797465732920666f7220616c6c2065787472696e736963732070757420746f6765746865722c20666f72207468652063757272656e7420626c6f636b2e24426c6f636b4861736801010538543a3a426c6f636b4e756d6265721c543a3a48617368008000000000000000000000000000000000000000000000000000000000000000000498204d6170206f6620626c6f636b206e756d6265727320746f20626c6f636b206861736865732e3445787472696e736963446174610101050c7533321c5665633c75383e000400043d012045787472696e73696373206461746120666f72207468652063757272656e7420626c6f636b20286d61707320616e2065787472696e736963277320696e64657820746f206974732064617461292e184e756d626572010038543a3a426c6f636b4e756d6265721000000000040901205468652063757272656e7420626c6f636b206e756d626572206265696e672070726f6365737365642e205365742062792060657865637574655f626c6f636b602e28506172656e744861736801001c543a3a4861736880000000000000000000000000000000000000000000000000000000000000000004702048617368206f66207468652070726576696f757320626c6f636b2e1844696765737401002c4469676573744f663c543e040004f020446967657374206f66207468652063757272656e7420626c6f636b2c20616c736f2070617274206f662074686520626c6f636b206865616465722e184576656e747301008c5665633c4576656e745265636f72643c543a3a4576656e742c20543a3a486173683e3e040004a0204576656e7473206465706f736974656420666f72207468652063757272656e7420626c6f636b2e284576656e74436f756e740100284576656e74496e646578100000000004b820546865206e756d626572206f66206576656e747320696e2074686520604576656e74733c543e60206c6973742e2c4576656e74546f706963730101021c543a3a48617368845665633c28543a3a426c6f636b4e756d6265722c204576656e74496e646578293e000400282501204d617070696e67206265747765656e206120746f7069632028726570726573656e74656420627920543a3a486173682920616e64206120766563746f72206f6620696e646578657394206f66206576656e747320696e2074686520603c4576656e74733c543e3e60206c6973742e00510120416c6c20746f70696320766563746f727320686176652064657465726d696e69737469632073746f72616765206c6f636174696f6e7320646570656e64696e67206f6e2074686520746f7069632e2054686973450120616c6c6f7773206c696768742d636c69656e747320746f206c6576657261676520746865206368616e67657320747269652073746f7261676520747261636b696e67206d656368616e69736d20616e64e420696e2063617365206f66206368616e67657320666574636820746865206c697374206f66206576656e7473206f6620696e7465726573742e004d01205468652076616c756520686173207468652074797065206028543a3a426c6f636b4e756d6265722c204576656e74496e646578296020626563617573652069662077652075736564206f6e6c79206a7573744d012074686520604576656e74496e64657860207468656e20696e20636173652069662074686520746f70696320686173207468652073616d6520636f6e74656e7473206f6e20746865206e65787420626c6f636b0101206e6f206e6f74696669636174696f6e2077696c6c20626520747269676765726564207468757320746865206576656e74206d69676874206265206c6f73742e484c61737452756e74696d65557067726164650000584c61737452756e74696d6555706772616465496e666f04000455012053746f726573207468652060737065635f76657273696f6e6020616e642060737065635f6e616d6560206f66207768656e20746865206c6173742072756e74696d6520757067726164652068617070656e65642e545570677261646564546f553332526566436f756e74010010626f6f6c0400044d012054727565206966207765206861766520757067726164656420736f207468617420607479706520526566436f756e74602069732060753332602e2046616c7365202864656661756c7429206966206e6f742e605570677261646564546f547269706c65526566436f756e74010010626f6f6c0400085d012054727565206966207765206861766520757067726164656420736f2074686174204163636f756e74496e666f20636f6e7461696e73207468726565207479706573206f662060526566436f756e74602e2046616c736548202864656661756c7429206966206e6f742e38457865637574696f6e50686173650000145068617365040004882054686520657865637574696f6e207068617365206f662074686520626c6f636b2e01282866696c6c5f626c6f636b04185f726174696f1c50657262696c6c040901204120646973706174636820746861742077696c6c2066696c6c2074686520626c6f636b2077656967687420757020746f2074686520676976656e20726174696f2e1872656d61726b041c5f72656d61726b1c5665633c75383e146c204d616b6520736f6d65206f6e2d636861696e2072656d61726b2e002c2023203c7765696768743e24202d20604f28312960302023203c2f7765696768743e387365745f686561705f7061676573041470616765730c75363420fc2053657420746865206e756d626572206f6620706167657320696e2074686520576562417373656d626c7920656e7669726f6e6d656e74277320686561702e002c2023203c7765696768743e24202d20604f283129604c202d20312073746f726167652077726974652e64202d2042617365205765696768743a20312e34303520c2b57360202d203120777269746520746f20484541505f5041474553302023203c2f7765696768743e207365745f636f64650410636f64651c5665633c75383e28682053657420746865206e65772072756e74696d6520636f64652e002c2023203c7765696768743e3501202d20604f2843202b2053296020776865726520604360206c656e677468206f662060636f64656020616e642060536020636f6d706c6578697479206f66206063616e5f7365745f636f64656088202d20312073746f726167652077726974652028636f64656320604f28432960292e7901202d20312063616c6c20746f206063616e5f7365745f636f6465603a20604f28532960202863616c6c73206073705f696f3a3a6d6973633a3a72756e74696d655f76657273696f6e6020776869636820697320657870656e73697665292e2c202d2031206576656e742e7d012054686520776569676874206f6620746869732066756e6374696f6e20697320646570656e64656e74206f6e207468652072756e74696d652c206275742067656e6572616c6c792074686973206973207665727920657870656e736976652e902057652077696c6c207472656174207468697320617320612066756c6c20626c6f636b2e302023203c2f7765696768743e5c7365745f636f64655f776974686f75745f636865636b730410636f64651c5665633c75383e201d012053657420746865206e65772072756e74696d6520636f646520776974686f757420646f696e6720616e7920636865636b73206f662074686520676976656e2060636f6465602e002c2023203c7765696768743e90202d20604f2843296020776865726520604360206c656e677468206f662060636f64656088202d20312073746f726167652077726974652028636f64656320604f28432960292e2c202d2031206576656e742e75012054686520776569676874206f6620746869732066756e6374696f6e20697320646570656e64656e74206f6e207468652072756e74696d652e2057652077696c6c207472656174207468697320617320612066756c6c20626c6f636b2e302023203c2f7765696768743e5c7365745f6368616e6765735f747269655f636f6e666967044c6368616e6765735f747269655f636f6e666967804f7074696f6e3c4368616e67657354726965436f6e66696775726174696f6e3e28a02053657420746865206e6577206368616e676573207472696520636f6e66696775726174696f6e2e002c2023203c7765696768743e24202d20604f28312960b0202d20312073746f72616765207772697465206f722064656c6574652028636f64656320604f28312960292ed8202d20312063616c6c20746f20606465706f7369745f6c6f67603a20557365732060617070656e6460204150492c20736f204f28312964202d2042617365205765696768743a20372e32313820c2b57334202d204442205765696768743aa820202020202d205772697465733a204368616e67657320547269652c2053797374656d20446967657374302023203c2f7765696768743e2c7365745f73746f7261676504146974656d73345665633c4b657956616c75653e206c2053657420736f6d65206974656d73206f662073746f726167652e002c2023203c7765696768743e94202d20604f2849296020776865726520604960206c656e677468206f6620606974656d73607c202d206049602073746f72616765207772697465732028604f28312960292e74202d2042617365205765696768743a20302e353638202a206920c2b57368202d205772697465733a204e756d626572206f66206974656d73302023203c2f7765696768743e306b696c6c5f73746f7261676504106b657973205665633c4b65793e2078204b696c6c20736f6d65206974656d732066726f6d2073746f726167652e002c2023203c7765696768743efc202d20604f28494b296020776865726520604960206c656e677468206f6620606b6579736020616e6420604b60206c656e677468206f66206f6e65206b657964202d206049602073746f726167652064656c6574696f6e732e70202d2042617365205765696768743a202e333738202a206920c2b57368202d205772697465733a204e756d626572206f66206974656d73302023203c2f7765696768743e2c6b696c6c5f70726566697808187072656669780c4b6579205f7375626b6579730c7533322c1501204b696c6c20616c6c2073746f72616765206974656d7320776974682061206b657920746861742073746172747320776974682074686520676976656e207072656669782e003d01202a2a4e4f54453a2a2a2057652072656c79206f6e2074686520526f6f74206f726967696e20746f2070726f7669646520757320746865206e756d626572206f66207375626b65797320756e64657241012074686520707265666978207765206172652072656d6f76696e6720746f2061636375726174656c792063616c63756c6174652074686520776569676874206f6620746869732066756e6374696f6e2e002c2023203c7765696768743edc202d20604f285029602077686572652060506020616d6f756e74206f66206b65797320776974682070726566697820607072656669786064202d206050602073746f726167652064656c6574696f6e732e74202d2042617365205765696768743a20302e383334202a205020c2b57380202d205772697465733a204e756d626572206f66207375626b657973202b2031302023203c2f7765696768743e4472656d61726b5f776974685f6576656e74041872656d61726b1c5665633c75383e18a8204d616b6520736f6d65206f6e2d636861696e2072656d61726b20616e6420656d6974206576656e742e002c2023203c7765696768743eb8202d20604f28622960207768657265206220697320746865206c656e677468206f66207468652072656d61726b2e2c202d2031206576656e742e302023203c2f7765696768743e01184045787472696e7369635375636365737304304469737061746368496e666f04b820416e2065787472696e73696320636f6d706c65746564207375636365737366756c6c792e205c5b696e666f5c5d3c45787472696e7369634661696c6564083444697370617463684572726f72304469737061746368496e666f049420416e2065787472696e736963206661696c65642e205c5b6572726f722c20696e666f5c5d2c436f64655570646174656400045420603a636f6465602077617320757064617465642e284e65774163636f756e7404244163636f756e744964047c2041206e6577205c5b6163636f756e745c5d2077617320637265617465642e344b696c6c65644163636f756e7404244163636f756e744964046c20416e205c5b6163636f756e745c5d20776173207265617065642e2052656d61726b656408244163636f756e744964104861736804d4204f6e206f6e2d636861696e2072656d61726b2068617070656e65642e205c5b6f726967696e2c2072656d61726b5f686173685c5d1830426c6f636b57656967687473506c696d6974733a3a426c6f636b57656967687473850100f2052a0100000000204aa9d1010000405973070000000001c0766c8f58010000010098f73e5d010000010000000000000000405973070000000001c0febef9cc0100000100204aa9d1010000010088526a74000000405973070000000000000004d020426c6f636b20262065787472696e7369637320776569676874733a20626173652076616c75657320616e64206c696d6974732e2c426c6f636b4c656e6774684c6c696d6974733a3a426c6f636b4c656e6774683000003c00000050000000500004a820546865206d6178696d756d206c656e677468206f66206120626c6f636b2028696e206279746573292e38426c6f636b48617368436f756e7438543a3a426c6f636b4e756d6265721060090000045501204d6178696d756d206e756d626572206f6620626c6f636b206e756d62657220746f20626c6f636b2068617368206d617070696e677320746f206b65657020286f6c64657374207072756e6564206669727374292e2044625765696768743c52756e74696d6544625765696768744040787d010000000000e1f505000000000409012054686520776569676874206f662072756e74696d65206461746162617365206f7065726174696f6e73207468652072756e74696d652063616e20696e766f6b652e1c56657273696f6e3852756e74696d6556657273696f6e41031c77657374656e64387061726974792d77657374656e6402000000282300000000000038df6acb689907609b0300000037e397fc7c91f5e40100000040fe3ad401f8959a04000000d2bc9897eed08f1502000000f78b278be53f454c02000000af2c0297a23e6d3d0100000049eaaf1b548a0cb00100000091d5df18b0d2cf5801000000ed99c5acb25eedf502000000cbca25e39f14238702000000687ad44ad37f03c201000000ab3c0572291feb8b01000000bc9d89904f5b923f0100000037c8bb1350a9a2a801000000050000000484204765742074686520636861696e27732063757272656e742076657273696f6e2e2853533538507265666978087538042a14a8205468652064657369676e61746564205353383520707265666978206f66207468697320636861696e2e0039012054686973207265706c6163657320746865202273733538466f726d6174222070726f7065727479206465636c6172656420696e2074686520636861696e20737065632e20526561736f6e20697331012074686174207468652072756e74696d652073686f756c64206b6e6f772061626f7574207468652070726566697820696e206f7264657220746f206d616b6520757365206f662069742061737020616e206964656e746966696572206f662074686520636861696e2e143c496e76616c6964537065634e616d6508150120546865206e616d65206f662073706563696669636174696f6e20646f6573206e6f74206d61746368206265747765656e207468652063757272656e742072756e74696d655420616e6420746865206e65772072756e74696d652e685370656356657273696f6e4e65656473546f496e637265617365084501205468652073706563696669636174696f6e2076657273696f6e206973206e6f7420616c6c6f77656420746f206465637265617365206265747765656e207468652063757272656e742072756e74696d655420616e6420746865206e65772072756e74696d652e744661696c6564546f4578747261637452756e74696d6556657273696f6e0cf0204661696c656420746f2065787472616374207468652072756e74696d652076657273696f6e2066726f6d20746865206e65772072756e74696d652e000d01204569746865722063616c6c696e672060436f72655f76657273696f6e60206f72206465636f64696e67206052756e74696d6556657273696f6e60206661696c65642e4c4e6f6e44656661756c74436f6d706f7369746504010120537569636964652063616c6c6564207768656e20746865206163636f756e7420686173206e6f6e2d64656661756c7420636f6d706f7369746520646174612e3c4e6f6e5a65726f526566436f756e740439012054686572652069732061206e6f6e2d7a65726f207265666572656e636520636f756e742070726576656e74696e6720746865206163636f756e742066726f6d206265696e67207075726765642e006052616e646f6d6e657373436f6c6c656374697665466c6970016052616e646f6d6e657373436f6c6c656374697665466c6970043852616e646f6d4d6174657269616c0100305665633c543a3a486173683e04000c610120536572696573206f6620626c6f636b20686561646572732066726f6d20746865206c61737420383120626c6f636b73207468617420616374732061732072616e646f6d2073656564206d6174657269616c2e2054686973610120697320617272616e67656420617320612072696e672062756666657220776974682060626c6f636b5f6e756d626572202520383160206265696e672074686520696e64657820696e746f20746865206056656360206f664420746865206f6c6465737420686173682e00000000191042616265011042616265402845706f6368496e64657801000c75363420000000000000000004542043757272656e742065706f636820696e6465782e2c417574686f72697469657301009c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e0400046c2043757272656e742065706f636820617574686f7269746965732e2c47656e65736973536c6f74010010536c6f7420000000000000000008f82054686520736c6f74206174207768696368207468652066697273742065706f63682061637475616c6c7920737461727465642e205468697320697320309020756e74696c2074686520666972737420626c6f636b206f662074686520636861696e2e2c43757272656e74536c6f74010010536c6f7420000000000000000004542043757272656e7420736c6f74206e756d6265722e2852616e646f6d6e6573730100587363686e6f72726b656c3a3a52616e646f6d6e65737380000000000000000000000000000000000000000000000000000000000000000028b8205468652065706f63682072616e646f6d6e65737320666f7220746865202a63757272656e742a2065706f63682e002c20232053656375726974790005012054686973204d555354204e4f54206265207573656420666f722067616d626c696e672c2061732069742063616e20626520696e666c75656e6365642062792061f8206d616c6963696f75732076616c696461746f7220696e207468652073686f7274207465726d2e204974204d4159206265207573656420696e206d616e7915012063727970746f677261706869632070726f746f636f6c732c20686f77657665722c20736f206c6f6e67206173206f6e652072656d656d6265727320746861742074686973150120286c696b652065766572797468696e6720656c7365206f6e2d636861696e29206974206973207075626c69632e20466f72206578616d706c652c2069742063616e206265050120757365642077686572652061206e756d626572206973206e656564656420746861742063616e6e6f742068617665206265656e2063686f73656e20627920616e0d01206164766572736172792c20666f7220707572706f7365732073756368206173207075626c69632d636f696e207a65726f2d6b6e6f776c656467652070726f6f66732e6050656e64696e6745706f6368436f6e6669674368616e67650000504e657874436f6e66696744657363726970746f7204000461012050656e64696e672065706f636820636f6e66696775726174696f6e206368616e676520746861742077696c6c206265206170706c696564207768656e20746865206e6578742065706f636820697320656e61637465642e384e65787452616e646f6d6e6573730100587363686e6f72726b656c3a3a52616e646f6d6e657373800000000000000000000000000000000000000000000000000000000000000000045c204e6578742065706f63682072616e646f6d6e6573732e3c4e657874417574686f72697469657301009c5665633c28417574686f7269747949642c2042616265417574686f72697479576569676874293e04000460204e6578742065706f636820617574686f7269746965732e305365676d656e74496e64657801000c7533321000000000247c2052616e646f6d6e65737320756e64657220636f6e737472756374696f6e2e00f4205765206d616b6520612074726164656f6666206265747765656e2073746f7261676520616363657373657320616e64206c697374206c656e6774682e01012057652073746f72652074686520756e6465722d636f6e737472756374696f6e2072616e646f6d6e65737320696e207365676d656e7473206f6620757020746f942060554e4445525f434f4e535452554354494f4e5f5345474d454e545f4c454e475448602e00ec204f6e63652061207365676d656e7420726561636865732074686973206c656e6774682c20776520626567696e20746865206e657874206f6e652e090120576520726573657420616c6c207365676d656e747320616e642072657475726e20746f206030602061742074686520626567696e6e696e67206f662065766572791c2065706f63682e44556e646572436f6e737472756374696f6e0101050c7533326c5665633c7363686e6f72726b656c3a3a52616e646f6d6e6573733e0004000415012054574f582d4e4f54453a20605365676d656e74496e6465786020697320616e20696e6372656173696e6720696e74656765722c20736f2074686973206973206f6b61792e2c496e697469616c697a656400003c4d6179626552616e646f6d6e65737304000801012054656d706f726172792076616c75652028636c656172656420617420626c6f636b2066696e616c697a6174696f6e292077686963682069732060536f6d65601d01206966207065722d626c6f636b20696e697469616c697a6174696f6e2068617320616c7265616479206265656e2063616c6c656420666f722063757272656e7420626c6f636b2e4c417574686f7256726652616e646f6d6e65737301003c4d6179626552616e646f6d6e65737304000c5d012054656d706f726172792076616c75652028636c656172656420617420626c6f636b2066696e616c697a6174696f6e29207468617420696e636c756465732074686520565246206f75747075742067656e6572617465645101206174207468697320626c6f636b2e2054686973206669656c642073686f756c6420616c7761797320626520706f70756c6174656420647572696e6720626c6f636b2070726f63657373696e6720756e6c6573731901207365636f6e6461727920706c61696e20736c6f74732061726520656e61626c65642028776869636820646f6e277420636f6e7461696e206120565246206f7574707574292e2845706f6368537461727401008028543a3a426c6f636b4e756d6265722c20543a3a426c6f636b4e756d62657229200000000000000000145d012054686520626c6f636b206e756d62657273207768656e20746865206c61737420616e642063757272656e742065706f6368206861766520737461727465642c20726573706563746976656c7920604e2d316020616e641420604e602e4901204e4f54453a20576520747261636b207468697320697320696e206f7264657220746f20616e6e6f746174652074686520626c6f636b206e756d626572207768656e206120676976656e20706f6f6c206f66590120656e74726f7079207761732066697865642028692e652e20697420776173206b6e6f776e20746f20636861696e206f6273657276657273292e2053696e63652065706f6368732061726520646566696e656420696e590120736c6f74732c207768696368206d617920626520736b69707065642c2074686520626c6f636b206e756d62657273206d6179206e6f74206c696e6520757020776974682074686520736c6f74206e756d626572732e204c6174656e657373010038543a3a426c6f636b4e756d626572100000000014d820486f77206c617465207468652063757272656e7420626c6f636b20697320636f6d706172656420746f2069747320706172656e742e001501205468697320656e74727920697320706f70756c617465642061732070617274206f6620626c6f636b20657865637574696f6e20616e6420697320636c65616e65642075701101206f6e20626c6f636b2066696e616c697a6174696f6e2e205175657279696e6720746869732073746f7261676520656e747279206f757473696465206f6620626c6f636bb020657865637574696f6e20636f6e746578742073686f756c6420616c77617973207969656c64207a65726f2e2c45706f6368436f6e6669670000584261626545706f6368436f6e66696775726174696f6e04000485012054686520636f6e66696775726174696f6e20666f72207468652063757272656e742065706f63682e2053686f756c64206e6576657220626520604e6f6e656020617320697420697320696e697469616c697a656420696e2067656e657369732e3c4e65787445706f6368436f6e6669670000584261626545706f6368436f6e66696775726174696f6e0400082d012054686520636f6e66696775726174696f6e20666f7220746865206e6578742065706f63682c20604e6f6e65602069662074686520636f6e6669672077696c6c206e6f74206368616e6765e82028796f752063616e2066616c6c6261636b20746f206045706f6368436f6e6669676020696e737465616420696e20746861742063617365292e010c4c7265706f72745f65717569766f636174696f6e084865717569766f636174696f6e5f70726f6f667045717569766f636174696f6e50726f6f663c543a3a4865616465723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66100d01205265706f727420617574686f726974792065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c207665726966790901207468652065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66110120616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e63652077696c6c34206265207265706f727465642e707265706f72745f65717569766f636174696f6e5f756e7369676e6564084865717569766f636174696f6e5f70726f6f667045717569766f636174696f6e50726f6f663c543a3a4865616465723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66200d01205265706f727420617574686f726974792065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c207665726966790901207468652065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66110120616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e63652077696c6c34206265207265706f727465642e110120546869732065787472696e736963206d7573742062652063616c6c656420756e7369676e656420616e642069742069732065787065637465642074686174206f6e6c79190120626c6f636b20617574686f72732077696c6c2063616c6c206974202876616c69646174656420696e206056616c6964617465556e7369676e656460292c206173207375636819012069662074686520626c6f636b20617574686f7220697320646566696e65642069742077696c6c20626520646566696e6564206173207468652065717569766f636174696f6e28207265706f727465722e48706c616e5f636f6e6669675f6368616e67650418636f6e666967504e657874436f6e66696744657363726970746f7210610120506c616e20616e2065706f636820636f6e666967206368616e67652e205468652065706f636820636f6e666967206368616e6765206973207265636f7264656420616e642077696c6c20626520656e6163746564206f6e550120746865206e6578742063616c6c20746f2060656e6163745f65706f63685f6368616e6765602e2054686520636f6e6669672077696c6c20626520616374697661746564206f6e652065706f63682061667465722e5d01204d756c7469706c652063616c6c7320746f2074686973206d6574686f642077696c6c207265706c61636520616e79206578697374696e6720706c616e6e656420636f6e666967206368616e676520746861742068616458206e6f74206265656e20656e6163746564207965742e00083445706f63684475726174696f6e0c7536342058020000000000000cec2054686520616d6f756e74206f662074696d652c20696e20736c6f74732c207468617420656163682065706f63682073686f756c64206c6173742e1901204e4f54453a2043757272656e746c79206974206973206e6f7420706f737369626c6520746f206368616e6765207468652065706f6368206475726174696f6e20616674657221012074686520636861696e2068617320737461727465642e20417474656d7074696e6720746f20646f20736f2077696c6c20627269636b20626c6f636b2070726f64756374696f6e2e444578706563746564426c6f636b54696d6524543a3a4d6f6d656e7420701700000000000014050120546865206578706563746564206176657261676520626c6f636b2074696d6520617420776869636820424142452073686f756c64206265206372656174696e67110120626c6f636b732e2053696e636520424142452069732070726f626162696c6973746963206974206973206e6f74207472697669616c20746f20666967757265206f75740501207768617420746865206578706563746564206176657261676520626c6f636b2074696d652073686f756c64206265206261736564206f6e2074686520736c6f740901206475726174696f6e20616e642074686520736563757269747920706172616d657465722060636020287768657265206031202d20636020726570726573656e7473a0207468652070726f626162696c697479206f66206120736c6f74206265696e6720656d707479292e0c60496e76616c696445717569766f636174696f6e50726f6f6604350120416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c69644b65794f776e65727368697050726f6f660435012041206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f7274041901204120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e012454696d657374616d70012454696d657374616d70080c4e6f77010024543a3a4d6f6d656e7420000000000000000004902043757272656e742074696d6520666f72207468652063757272656e7420626c6f636b2e24446964557064617465010010626f6f6c040004b420446964207468652074696d657374616d7020676574207570646174656420696e207468697320626c6f636b3f01040c736574040c6e6f7748436f6d706163743c543a3a4d6f6d656e743e3c5820536574207468652063757272656e742074696d652e00590120546869732063616c6c2073686f756c6420626520696e766f6b65642065786163746c79206f6e63652070657220626c6f636b2e2049742077696c6c2070616e6963206174207468652066696e616c697a6174696f6ed82070686173652c20696620746869732063616c6c206861736e2774206265656e20696e766f6b656420627920746861742074696d652e004501205468652074696d657374616d702073686f756c642062652067726561746572207468616e207468652070726576696f7573206f6e652062792074686520616d6f756e74207370656369666965642062794420604d696e696d756d506572696f64602e00d820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652060496e686572656e74602e002c2023203c7765696768743e3501202d20604f2831296020284e6f7465207468617420696d706c656d656e746174696f6e73206f6620604f6e54696d657374616d7053657460206d75737420616c736f20626520604f2831296029a101202d20312073746f72616765207265616420616e6420312073746f72616765206d75746174696f6e2028636f64656320604f28312960292e202862656361757365206f6620604469645570646174653a3a74616b656020696e20606f6e5f66696e616c697a656029d8202d2031206576656e742068616e646c657220606f6e5f74696d657374616d705f736574602e204d75737420626520604f283129602e302023203c2f7765696768743e0004344d696e696d756d506572696f6424543a3a4d6f6d656e7420b80b00000000000010690120546865206d696e696d756d20706572696f64206265747765656e20626c6f636b732e204265776172652074686174207468697320697320646966666572656e7420746f20746865202a65787065637465642a20706572696f64690120746861742074686520626c6f636b2070726f64756374696f6e206170706172617475732070726f76696465732e20596f75722063686f73656e20636f6e73656e7375732073797374656d2077696c6c2067656e6572616c6c79650120776f726b2077697468207468697320746f2064657465726d696e6520612073656e7369626c6520626c6f636b2074696d652e20652e672e20466f7220417572612c2069742077696c6c20626520646f75626c6520746869737020706572696f64206f6e2064656661756c742073657474696e67732e00021c496e6469636573011c496e646963657304204163636f756e74730001023c543a3a4163636f756e74496e6465788828543a3a4163636f756e7449642c2042616c616e63654f663c543e2c20626f6f6c29000400048820546865206c6f6f6b75702066726f6d20696e64657820746f206163636f756e742e011414636c61696d0414696e6465783c543a3a4163636f756e74496e646578489c2041737369676e20616e2070726576696f75736c7920756e61737369676e656420696e6465782e00e0205061796d656e743a20604465706f736974602069732072657365727665642066726f6d207468652073656e646572206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e00f4202d2060696e646578603a2074686520696e64657820746f20626520636c61696d65642e2054686973206d757374206e6f7420626520696e207573652e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e64202d204f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e207472616e73666572080c6e657730543a3a4163636f756e74496414696e6465783c543a3a4163636f756e74496e6465785061012041737369676e20616e20696e64657820616c7265616479206f776e6564206279207468652073656e64657220746f20616e6f74686572206163636f756e742e205468652062616c616e6365207265736572766174696f6ebc206973206566666563746976656c79207472616e7366657272656420746f20746865206e6577206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002901202d2060696e646578603a2074686520696e64657820746f2062652072652d61737369676e65642e2054686973206d757374206265206f776e6564206279207468652073656e6465722e6101202d20606e6577603a20746865206e6577206f776e6572206f662074686520696e6465782e20546869732066756e6374696f6e2069732061206e6f2d6f7020696620697420697320657175616c20746f2073656e6465722e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e68202d204f6e65207472616e73666572206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743ae4202020202d2052656164733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e742028726563697069656e7429e8202020202d205772697465733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e742028726563697069656e7429302023203c2f7765696768743e10667265650414696e6465783c543a3a4163636f756e74496e6465784898204672656520757020616e20696e646578206f776e6564206279207468652073656e6465722e006101205061796d656e743a20416e792070726576696f7573206465706f73697420706c6163656420666f722074686520696e64657820697320756e726573657276656420696e207468652073656e646572206163636f756e742e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d757374206f776e2074686520696e6465782e001101202d2060696e646578603a2074686520696e64657820746f2062652066726565642e2054686973206d757374206265206f776e6564206279207468652073656e6465722e008820456d6974732060496e646578467265656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e64202d204f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e38666f7263655f7472616e736665720c0c6e657730543a3a4163636f756e74496414696e6465783c543a3a4163636f756e74496e64657818667265657a6510626f6f6c54590120466f72636520616e20696e64657820746f20616e206163636f756e742e205468697320646f65736e277420726571756972652061206465706f7369742e2049662074686520696e64657820697320616c7265616479ec2068656c642c207468656e20616e79206465706f736974206973207265696d62757273656420746f206974732063757272656e74206f776e65722e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f526f6f745f2e00a8202d2060696e646578603a2074686520696e64657820746f206265202872652d2961737369676e65642e6101202d20606e6577603a20746865206e6577206f776e6572206f662074686520696e6465782e20546869732066756e6374696f6e2069732061206e6f2d6f7020696620697420697320657175616c20746f2073656e6465722e4501202d2060667265657a65603a2069662073657420746f206074727565602c2077696c6c20667265657a652074686520696e64657820736f2069742063616e6e6f74206265207472616e736665727265642e009420456d6974732060496e64657841737369676e656460206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e7c202d20557020746f206f6e652072657365727665206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743af8202020202d2052656164733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e7420286f726967696e616c206f776e657229fc202020202d205772697465733a20496e6469636573204163636f756e74732c2053797374656d204163636f756e7420286f726967696e616c206f776e657229302023203c2f7765696768743e18667265657a650414696e6465783c543a3a4163636f756e74496e64657844690120467265657a6520616e20696e64657820736f2069742077696c6c20616c7761797320706f696e7420746f207468652073656e646572206163636f756e742e205468697320636f6e73756d657320746865206465706f7369742e005d0120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420746865207369676e696e67206163636f756e74206d7573742068617665206170206e6f6e2d66726f7a656e206163636f756e742060696e646578602e00b0202d2060696e646578603a2074686520696e64657820746f2062652066726f7a656e20696e20706c6163652e008c20456d6974732060496e64657846726f7a656e60206966207375636365737366756c2e002c2023203c7765696768743e28202d20604f283129602e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28312960292e74202d20557020746f206f6e6520736c617368206f7065726174696f6e2e34202d204f6e65206576656e742e50202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d94202d204442205765696768743a203120526561642f577269746520284163636f756e747329302023203c2f7765696768743e010c34496e64657841737369676e656408244163636f756e744964304163636f756e74496e64657804b42041206163636f756e7420696e646578207761732061737369676e65642e205c5b696e6465782c2077686f5c5d28496e646578467265656404304163636f756e74496e64657804e82041206163636f756e7420696e64657820686173206265656e2066726565642075702028756e61737369676e6564292e205c5b696e6465785c5d2c496e64657846726f7a656e08304163636f756e74496e646578244163636f756e7449640429012041206163636f756e7420696e64657820686173206265656e2066726f7a656e20746f206974732063757272656e74206163636f756e742049442e205c5b696e6465782c2077686f5c5d041c4465706f7369743042616c616e63654f663c543e400010a5d4e8000000000000000000000004ac20546865206465706f736974206e656564656420666f7220726573657276696e6720616e20696e6465782e142c4e6f7441737369676e656404902054686520696e64657820776173206e6f7420616c72656164792061737369676e65642e204e6f744f776e657204a82054686520696e6465782069732061737369676e656420746f20616e6f74686572206163636f756e742e14496e55736504742054686520696e64657820776173206e6f7420617661696c61626c652e2c4e6f745472616e7366657204cc2054686520736f7572636520616e642064657374696e6174696f6e206163636f756e747320617265206964656e746963616c2e245065726d616e656e7404d42054686520696e646578206973207065726d616e656e7420616e64206d6179206e6f742062652066726565642f6368616e6765642e032042616c616e636573012042616c616e6365731034546f74616c49737375616e6365010028543a3a42616c616e6365400000000000000000000000000000000004982054686520746f74616c20756e6974732069737375656420696e207468652073797374656d2e1c4163636f756e7401010230543a3a4163636f756e7449645c4163636f756e74446174613c543a3a42616c616e63653e000101000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c6c205468652062616c616e6365206f6620616e206163636f756e742e004101204e4f54453a2054686973206973206f6e6c79207573656420696e207468652063617365207468617420746869732070616c6c6574206973207573656420746f2073746f72652062616c616e6365732e144c6f636b7301010230543a3a4163636f756e744964705665633c42616c616e63654c6f636b3c543a3a42616c616e63653e3e00040008b820416e79206c6971756964697479206c6f636b73206f6e20736f6d65206163636f756e742062616c616e6365732e2501204e4f54453a2053686f756c64206f6e6c79206265206163636573736564207768656e2073657474696e672c206368616e67696e6720616e642066726565696e672061206c6f636b2e3853746f7261676556657273696f6e01002052656c656173657304000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e00a020546869732069732073657420746f2076322e302e3020666f72206e6577206e6574776f726b732e0110207472616e736665720810646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e6cd8205472616e7366657220736f6d65206c697175696420667265652062616c616e636520746f20616e6f74686572206163636f756e742e00090120607472616e73666572602077696c6c207365742074686520604672656542616c616e636560206f66207468652073656e64657220616e642072656365697665722e21012049742077696c6c2064656372656173652074686520746f74616c2069737375616e6365206f66207468652073797374656d2062792074686520605472616e73666572466565602e1501204966207468652073656e6465722773206163636f756e742069732062656c6f7720746865206578697374656e7469616c206465706f736974206173206120726573756c74b4206f6620746865207472616e736665722c20746865206163636f756e742077696c6c206265207265617065642e00190120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d75737420626520605369676e65646020627920746865207472616e736163746f722e002c2023203c7765696768743e3101202d20446570656e64656e74206f6e20617267756d656e747320627574206e6f7420637269746963616c2c20676976656e2070726f70657220696d706c656d656e746174696f6e7320666f72cc202020696e70757420636f6e6669672074797065732e205365652072656c617465642066756e6374696f6e732062656c6f772e6901202d20497420636f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e642077726974657320696e7465726e616c6c7920616e64206e6f20636f6d706c657820636f6d7075746174696f6e2e004c2052656c617465642066756e6374696f6e733a0051012020202d2060656e737572655f63616e5f77697468647261776020697320616c776179732063616c6c656420696e7465726e616c6c792062757420686173206120626f756e64656420636f6d706c65786974792e2d012020202d205472616e7366657272696e672062616c616e63657320746f206163636f756e7473207468617420646964206e6f74206578697374206265666f72652077696c6c206361757365d420202020202060543a3a4f6e4e65774163636f756e743a3a6f6e5f6e65775f6163636f756e746020746f2062652063616c6c65642e61012020202d2052656d6f76696e6720656e6f7567682066756e64732066726f6d20616e206163636f756e742077696c6c20747269676765722060543a3a4475737452656d6f76616c3a3a6f6e5f756e62616c616e636564602e49012020202d20607472616e736665725f6b6565705f616c6976656020776f726b73207468652073616d652077617920617320607472616e73666572602c206275742068617320616e206164646974696f6e616cf82020202020636865636b207468617420746865207472616e736665722077696c6c206e6f74206b696c6c20746865206f726967696e206163636f756e742e88202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d4501202d2042617365205765696768743a2037332e363420c2b5732c20776f7273742063617365207363656e6172696f20286163636f756e7420637265617465642c206163636f756e742072656d6f76656429dc202d204442205765696768743a2031205265616420616e64203120577269746520746f2064657374696e6174696f6e206163636f756e741501202d204f726967696e206163636f756e7420697320616c726561647920696e206d656d6f72792c20736f206e6f204442206f7065726174696f6e7320666f72207468656d2e302023203c2f7765696768743e2c7365745f62616c616e63650c0c77686f8c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365206e65775f667265654c436f6d706163743c543a3a42616c616e63653e306e65775f72657365727665644c436f6d706163743c543a3a42616c616e63653e489420536574207468652062616c616e636573206f66206120676976656e206163636f756e742e00210120546869732077696c6c20616c74657220604672656542616c616e63656020616e642060526573657276656442616c616e63656020696e2073746f726167652e2069742077696c6c090120616c736f2064656372656173652074686520746f74616c2069737375616e6365206f66207468652073797374656d202860546f74616c49737375616e636560292e190120496620746865206e65772066726565206f722072657365727665642062616c616e63652069732062656c6f7720746865206578697374656e7469616c206465706f7369742c01012069742077696c6c20726573657420746865206163636f756e74206e6f6e63652028606672616d655f73797374656d3a3a4163636f756e744e6f6e636560292e00b420546865206469737061746368206f726967696e20666f7220746869732063616c6c2069732060726f6f74602e002c2023203c7765696768743e80202d20496e646570656e64656e74206f662074686520617267756d656e74732ec4202d20436f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e64207772697465732e58202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d3c202d2042617365205765696768743a6820202020202d204372656174696e673a2032372e353620c2b5736420202020202d204b696c6c696e673a2033352e313120c2b57398202d204442205765696768743a203120526561642c203120577269746520746f206077686f60302023203c2f7765696768743e38666f7263655f7472616e736665720c18736f757263658c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f7572636510646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e1851012045786163746c7920617320607472616e73666572602c2065786365707420746865206f726967696e206d75737420626520726f6f7420616e642074686520736f75726365206163636f756e74206d61792062652c207370656369666965642e2c2023203c7765696768743e4101202d2053616d65206173207472616e736665722c20627574206164646974696f6e616c207265616420616e6420777269746520626563617573652074686520736f75726365206163636f756e74206973902020206e6f7420617373756d656420746f20626520696e20746865206f7665726c61792e302023203c2f7765696768743e4c7472616e736665725f6b6565705f616c6976650810646573748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c75654c436f6d706163743c543a3a42616c616e63653e2c51012053616d6520617320746865205b607472616e73666572605d2063616c6c2c206275742077697468206120636865636b207468617420746865207472616e736665722077696c6c206e6f74206b696c6c2074686540206f726967696e206163636f756e742e00bc20393925206f66207468652074696d6520796f752077616e74205b607472616e73666572605d20696e73746561642e00c4205b607472616e73666572605d3a207374727563742e50616c6c65742e68746d6c236d6574686f642e7472616e736665722c2023203c7765696768743ee8202d2043686561706572207468616e207472616e736665722062656361757365206163636f756e742063616e6e6f74206265206b696c6c65642e60202d2042617365205765696768743a2035312e3420c2b5731d01202d204442205765696768743a2031205265616420616e64203120577269746520746f2064657374202873656e64657220697320696e206f7665726c617920616c7265616479292c20233c2f7765696768743e01201c456e646f77656408244163636f756e7449641c42616c616e636504250120416e206163636f756e74207761732063726561746564207769746820736f6d6520667265652062616c616e63652e205c5b6163636f756e742c20667265655f62616c616e63655c5d20447573744c6f737408244163636f756e7449641c42616c616e636508410120416e206163636f756e74207761732072656d6f7665642077686f73652062616c616e636520776173206e6f6e2d7a65726f206275742062656c6f77204578697374656e7469616c4465706f7369742cd020726573756c74696e6720696e20616e206f75747269676874206c6f73732e205c5b6163636f756e742c2062616c616e63655c5d205472616e736665720c244163636f756e744964244163636f756e7449641c42616c616e636504a0205472616e73666572207375636365656465642e205c5b66726f6d2c20746f2c2076616c75655c5d2842616c616e63655365740c244163636f756e7449641c42616c616e63651c42616c616e636504cc20412062616c616e6365207761732073657420627920726f6f742e205c5b77686f2c20667265652c2072657365727665645c5d1c4465706f73697408244163636f756e7449641c42616c616e636504210120536f6d6520616d6f756e7420776173206465706f73697465642028652e672e20666f72207472616e73616374696f6e2066656573292e205c5b77686f2c206465706f7369745c5d20526573657276656408244163636f756e7449641c42616c616e636504210120536f6d652062616c616e63652077617320726573657276656420286d6f7665642066726f6d206672656520746f207265736572766564292e205c5b77686f2c2076616c75655c5d28556e726573657276656408244163636f756e7449641c42616c616e636504290120536f6d652062616c616e63652077617320756e726573657276656420286d6f7665642066726f6d20726573657276656420746f2066726565292e205c5b77686f2c2076616c75655c5d4852657365727665526570617472696174656410244163636f756e744964244163636f756e7449641c42616c616e6365185374617475730c510120536f6d652062616c616e636520776173206d6f7665642066726f6d207468652072657365727665206f6620746865206669727374206163636f756e7420746f20746865207365636f6e64206163636f756e742edc2046696e616c20617267756d656e7420696e64696361746573207468652064657374696e6174696f6e2062616c616e636520747970652ea8205c5b66726f6d2c20746f2c2062616c616e63652c2064657374696e6174696f6e5f7374617475735c5d04484578697374656e7469616c4465706f73697428543a3a42616c616e63654000e40b5402000000000000000000000004d420546865206d696e696d756d20616d6f756e7420726571756972656420746f206b65657020616e206163636f756e74206f70656e2e203856657374696e6742616c616e6365049c2056657374696e672062616c616e636520746f6f206869676820746f2073656e642076616c7565544c69717569646974795265737472696374696f6e7304c8204163636f756e74206c6971756964697479207265737472696374696f6e732070726576656e74207769746864726177616c204f766572666c6f77047420476f7420616e206f766572666c6f7720616674657220616464696e674c496e73756666696369656e7442616c616e636504782042616c616e636520746f6f206c6f7720746f2073656e642076616c7565484578697374656e7469616c4465706f73697404ec2056616c756520746f6f206c6f7720746f20637265617465206163636f756e742064756520746f206578697374656e7469616c206465706f736974244b656570416c6976650490205472616e736665722f7061796d656e7420776f756c64206b696c6c206163636f756e745c4578697374696e6756657374696e675363686564756c6504cc20412076657374696e67207363686564756c6520616c72656164792065786973747320666f722074686973206163636f756e742c446561644163636f756e74048c2042656e6566696369617279206163636f756e74206d757374207072652d657869737404485472616e73616374696f6e5061796d656e7401485472616e73616374696f6e5061796d656e7408444e6578744665654d756c7469706c6965720100284d756c7469706c69657240000064a7b3b6e00d0000000000000000003853746f7261676556657273696f6e01002052656c6561736573040000000008485472616e73616374696f6e427974654665653042616c616e63654f663c543e4000e1f505000000000000000000000000040d01205468652066656520746f206265207061696420666f72206d616b696e672061207472616e73616374696f6e3b20746865207065722d6279746520706f7274696f6e2e2c576569676874546f466565a45665633c576569676874546f466565436f656666696369656e743c42616c616e63654f663c543e3e3e5c0408000000000000000000000000000000000000000001040d012054686520706f6c796e6f6d69616c2074686174206973206170706c69656420696e206f7264657220746f20646572697665206665652066726f6d207765696768742e001a28417574686f72736869700128417574686f72736869700c18556e636c65730100e85665633c556e636c65456e7472794974656d3c543a3a426c6f636b4e756d6265722c20543a3a486173682c20543a3a4163636f756e7449643e3e0400041c20556e636c657318417574686f72000030543a3a4163636f756e7449640400046420417574686f72206f662063757272656e7420626c6f636b2e30446964536574556e636c6573010010626f6f6c040004bc205768657468657220756e636c6573207765726520616c72656164792073657420696e207468697320626c6f636b2e0104287365745f756e636c657304286e65775f756e636c6573385665633c543a3a4865616465723e04642050726f76696465206120736574206f6620756e636c65732e00001c48496e76616c6964556e636c65506172656e74048c2054686520756e636c6520706172656e74206e6f7420696e2074686520636861696e2e40556e636c6573416c7265616479536574048420556e636c657320616c72656164792073657420696e2074686520626c6f636b2e34546f6f4d616e79556e636c6573044420546f6f206d616e7920756e636c65732e3047656e65736973556e636c6504582054686520756e636c652069732067656e657369732e30546f6f48696768556e636c6504802054686520756e636c6520697320746f6f206869676820696e20636861696e2e50556e636c65416c7265616479496e636c75646564047c2054686520756e636c6520697320616c726561647920696e636c756465642e204f6c64556e636c6504b82054686520756e636c652069736e277420726563656e7420656e6f75676820746f20626520696e636c756465642e051c5374616b696e67011c5374616b696e677830486973746f7279446570746801000c75333210540000001c8c204e756d626572206f66206572617320746f206b65657020696e20686973746f72792e00390120496e666f726d6174696f6e206973206b65707420666f72206572617320696e20605b63757272656e745f657261202d20686973746f72795f64657074683b2063757272656e745f6572615d602e006101204d757374206265206d6f7265207468616e20746865206e756d626572206f6620657261732064656c617965642062792073657373696f6e206f74686572776973652e20492e652e2061637469766520657261206d757374390120616c7761797320626520696e20686973746f72792e20492e652e20606163746976655f657261203e2063757272656e745f657261202d20686973746f72795f646570746860206d757374206265302067756172616e746565642e3856616c696461746f72436f756e7401000c753332100000000004a82054686520696465616c206e756d626572206f66207374616b696e67207061727469636970616e74732e544d696e696d756d56616c696461746f72436f756e7401000c7533321000000000044101204d696e696d756d206e756d626572206f66207374616b696e67207061727469636970616e7473206265666f726520656d657267656e637920636f6e646974696f6e732061726520696d706f7365642e34496e76756c6e657261626c65730100445665633c543a3a4163636f756e7449643e04000c590120416e792076616c696461746f72732074686174206d6179206e6576657220626520736c6173686564206f7220666f726369626c79206b69636b65642e20497427732061205665632073696e636520746865792772654d01206561737920746f20696e697469616c697a6520616e642074686520706572666f726d616e636520686974206973206d696e696d616c2028776520657870656374206e6f206d6f7265207468616e20666f7572ac20696e76756c6e657261626c65732920616e64207265737472696374656420746f20746573746e6574732e18426f6e64656400010530543a3a4163636f756e74496430543a3a4163636f756e744964000400040101204d61702066726f6d20616c6c206c6f636b65642022737461736822206163636f756e747320746f2074686520636f6e74726f6c6c6572206163636f756e742e184c656467657200010230543a3a4163636f756e744964a45374616b696e674c65646765723c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e000400044501204d61702066726f6d20616c6c2028756e6c6f636b6564292022636f6e74726f6c6c657222206163636f756e747320746f2074686520696e666f20726567617264696e6720746865207374616b696e672e14506179656501010530543a3a4163636f756e7449647c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e00040004e42057686572652074686520726577617264207061796d656e742073686f756c64206265206d6164652e204b657965642062792073746173682e2856616c696461746f727301010530543a3a4163636f756e7449643856616c696461746f7250726566730008000004450120546865206d61702066726f6d202877616e6e616265292076616c696461746f72207374617368206b657920746f2074686520707265666572656e636573206f6620746861742076616c696461746f722e284e6f6d696e61746f727300010530543a3a4163636f756e744964644e6f6d696e6174696f6e733c543a3a4163636f756e7449643e00040004650120546865206d61702066726f6d206e6f6d696e61746f72207374617368206b657920746f2074686520736574206f66207374617368206b657973206f6620616c6c2076616c696461746f727320746f206e6f6d696e6174652e2843757272656e74457261000020457261496e6465780400105c205468652063757272656e742065726120696e6465782e006501205468697320697320746865206c617465737420706c616e6e6564206572612c20646570656e64696e67206f6e20686f77207468652053657373696f6e2070616c6c657420717565756573207468652076616c696461746f7280207365742c206974206d6967687420626520616374697665206f72206e6f742e24416374697665457261000034416374697665457261496e666f040010d820546865206163746976652065726120696e666f726d6174696f6e2c20697420686f6c647320696e64657820616e642073746172742e0059012054686520616374697665206572612069732074686520657261206265696e672063757272656e746c792072657761726465642e2056616c696461746f7220736574206f66207468697320657261206d757374206265ac20657175616c20746f205b6053657373696f6e496e746572666163653a3a76616c696461746f7273605d2e5445726173537461727453657373696f6e496e64657800010520457261496e6465783053657373696f6e496e646578000400103101205468652073657373696f6e20696e646578206174207768696368207468652065726120737461727420666f7220746865206c6173742060484953544f52595f44455054486020657261732e006101204e6f74653a205468697320747261636b7320746865207374617274696e672073657373696f6e2028692e652e2073657373696f6e20696e646578207768656e20657261207374617274206265696e672061637469766529f020666f7220746865206572617320696e20605b43757272656e74457261202d20484953544f52595f44455054482c2043757272656e744572615d602e2c457261735374616b65727301020520457261496e64657830543a3a4163636f756e744964904578706f737572653c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e050c0000001878204578706f73757265206f662076616c696461746f72206174206572612e0061012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00a82049732069742072656d6f7665642061667465722060484953544f52595f44455054486020657261732e4101204966207374616b657273206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e20656d707479206578706f737572652069732072657475726e65642e48457261735374616b657273436c697070656401020520457261496e64657830543a3a4163636f756e744964904578706f737572653c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e050c0000002c9820436c6970706564204578706f73757265206f662076616c696461746f72206174206572612e00590120546869732069732073696d696c617220746f205b60457261735374616b657273605d20627574206e756d626572206f66206e6f6d696e61746f7273206578706f736564206973207265647563656420746f20746865dc2060543a3a4d61784e6f6d696e61746f72526577617264656450657256616c696461746f72602062696767657374207374616b6572732e1d0120284e6f74653a20746865206669656c642060746f74616c6020616e6420606f776e60206f6620746865206578706f737572652072656d61696e7320756e6368616e676564292ef42054686973206973207573656420746f206c696d69742074686520692f6f20636f737420666f7220746865206e6f6d696e61746f72207061796f75742e005d012054686973206973206b657965642066697374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00a82049732069742072656d6f7665642061667465722060484953544f52595f44455054486020657261732e4101204966207374616b657273206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e20656d707479206578706f737572652069732072657475726e65642e484572617356616c696461746f72507265667301020520457261496e64657830543a3a4163636f756e7449643856616c696461746f725072656673050800001411012053696d696c617220746f2060457261735374616b657273602c207468697320686f6c64732074686520707265666572656e636573206f662076616c696461746f72732e0061012054686973206973206b65796564206669727374206279207468652065726120696e64657820746f20616c6c6f772062756c6b2064656c6574696f6e20616e64207468656e20746865207374617368206163636f756e742e00a82049732069742072656d6f7665642061667465722060484953544f52595f44455054486020657261732e4c4572617356616c696461746f7252657761726400010520457261496e6465783042616c616e63654f663c543e0004000c09012054686520746f74616c2076616c696461746f7220657261207061796f757420666f7220746865206c6173742060484953544f52595f44455054486020657261732e0021012045726173207468617420686176656e27742066696e697368656420796574206f7220686173206265656e2072656d6f76656420646f65736e27742068617665207265776172642e4045726173526577617264506f696e747301010520457261496e64657874457261526577617264506f696e74733c543a3a4163636f756e7449643e0014000000000008ac205265776172647320666f7220746865206c6173742060484953544f52595f44455054486020657261732e250120496620726577617264206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e2030207265776172642069732072657475726e65642e3845726173546f74616c5374616b6501010520457261496e6465783042616c616e63654f663c543e00400000000000000000000000000000000008ec2054686520746f74616c20616d6f756e74207374616b656420666f7220746865206c6173742060484953544f52595f44455054486020657261732e1d0120496620746f74616c206861736e2774206265656e20736574206f7220686173206265656e2072656d6f766564207468656e2030207374616b652069732072657475726e65642e20466f72636545726101001c466f7263696e6704000454204d6f6465206f662065726120666f7263696e672e4c536c6173685265776172644672616374696f6e01001c50657262696c6c10000000000cf8205468652070657263656e74616765206f662074686520736c617368207468617420697320646973747269627574656420746f207265706f72746572732e00e4205468652072657374206f662074686520736c61736865642076616c75652069732068616e646c6564206279207468652060536c617368602e4c43616e63656c6564536c6173685061796f757401003042616c616e63654f663c543e40000000000000000000000000000000000815012054686520616d6f756e74206f662063757272656e637920676976656e20746f207265706f7274657273206f66206120736c617368206576656e7420776869636820776173ec2063616e63656c65642062792065787472616f7264696e6172792063697263756d7374616e6365732028652e672e20676f7665726e616e6365292e40556e6170706c696564536c617368657301010520457261496e646578bc5665633c556e6170706c696564536c6173683c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e3e00040004c420416c6c20756e6170706c69656420736c61736865732074686174206172652071756575656420666f72206c617465722e28426f6e646564457261730100745665633c28457261496e6465782c2053657373696f6e496e646578293e04001025012041206d617070696e672066726f6d207374696c6c2d626f6e646564206572617320746f207468652066697273742073657373696f6e20696e646578206f662074686174206572612e00c8204d75737420636f6e7461696e7320696e666f726d6174696f6e20666f72206572617320666f72207468652072616e67653abc20605b6163746976655f657261202d20626f756e64696e675f6475726174696f6e3b206163746976655f6572615d604c56616c696461746f72536c617368496e45726100020520457261496e64657830543a3a4163636f756e7449645c2850657262696c6c2c2042616c616e63654f663c543e2905040008450120416c6c20736c617368696e67206576656e7473206f6e2076616c696461746f72732c206d61707065642062792065726120746f20746865206869676865737420736c6173682070726f706f7274696f6e7020616e6420736c6173682076616c7565206f6620746865206572612e4c4e6f6d696e61746f72536c617368496e45726100020520457261496e64657830543a3a4163636f756e7449643042616c616e63654f663c543e05040004610120416c6c20736c617368696e67206576656e7473206f6e206e6f6d696e61746f72732c206d61707065642062792065726120746f20746865206869676865737420736c6173682076616c7565206f6620746865206572612e34536c617368696e675370616e7300010530543a3a4163636f756e7449645c736c617368696e673a3a536c617368696e675370616e73000400048c20536c617368696e67207370616e7320666f72207374617368206163636f756e74732e245370616e536c6173680101058c28543a3a4163636f756e7449642c20736c617368696e673a3a5370616e496e6465782988736c617368696e673a3a5370616e5265636f72643c42616c616e63654f663c543e3e00800000000000000000000000000000000000000000000000000000000000000000083d01205265636f72647320696e666f726d6174696f6e2061626f757420746865206d6178696d756d20736c617368206f6620612073746173682077697468696e206120736c617368696e67207370616e2cb82061732077656c6c20617320686f77206d7563682072657761726420686173206265656e2070616964206f75742e584561726c69657374556e6170706c696564536c617368000020457261496e646578040004fc20546865206561726c696573742065726120666f72207768696368207765206861766520612070656e64696e672c20756e6170706c69656420736c6173682e5443757272656e74506c616e6e656453657373696f6e01003053657373696f6e496e64657810000000000ce820546865206c61737420706c616e6e65642073657373696f6e207363686564756c6564206279207468652073657373696f6e2070616c6c65742e0031012054686973206973206261736963616c6c7920696e2073796e632077697468207468652063616c6c20746f205b6053657373696f6e4d616e616765723a3a6e65775f73657373696f6e605d2e3853746f7261676556657273696f6e01002052656c6561736573040510cc2054727565206966206e6574776f726b20686173206265656e20757067726164656420746f20746869732076657273696f6e2e7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e00a020546869732069732073657420746f2076362e302e3020666f72206e6577206e6574776f726b732e015c10626f6e640c28636f6e74726f6c6c65728c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651476616c756554436f6d706163743c42616c616e63654f663c543e3e1470617965657c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e5865012054616b6520746865206f726967696e206163636f756e74206173206120737461736820616e64206c6f636b207570206076616c756560206f66206974732062616c616e63652e2060636f6e74726f6c6c6572602077696c6c8420626520746865206163636f756e74207468617420636f6e74726f6c732069742e003101206076616c756560206d757374206265206d6f7265207468616e2074686520606d696e696d756d5f62616c616e636560207370656369666965642062792060543a3a43757272656e6379602e00250120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20627920746865207374617368206163636f756e742e004020456d6974732060426f6e646564602e002c2023203c7765696768743ed4202d20496e646570656e64656e74206f662074686520617267756d656e74732e204d6f64657261746520636f6d706c65786974792e20202d204f2831292e68202d20546872656520657874726120444220656e74726965732e005101204e4f54453a2054776f206f66207468652073746f726167652077726974657320286053656c663a3a626f6e646564602c206053656c663a3a7061796565602920617265205f6e657665725f20636c65616e6564410120756e6c6573732074686520606f726967696e602066616c6c732062656c6f77205f6578697374656e7469616c206465706f7369745f20616e6420676574732072656d6f76656420617320647573742e4c202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a3101202d20526561643a20426f6e6465642c204c65646765722c205b4f726967696e204163636f756e745d2c2043757272656e74204572612c20486973746f72792044657074682c204c6f636b73e0202d2057726974653a20426f6e6465642c2050617965652c205b4f726967696e204163636f756e745d2c204c6f636b732c204c6564676572302023203c2f7765696768743e28626f6e645f657874726104386d61785f6164646974696f6e616c54436f6d706163743c42616c616e63654f663c543e3e5465012041646420736f6d6520657874726120616d6f756e742074686174206861766520617070656172656420696e207468652073746173682060667265655f62616c616e63656020696e746f207468652062616c616e63652075703420666f72207374616b696e672e00510120557365207468697320696620746865726520617265206164646974696f6e616c2066756e647320696e20796f7572207374617368206163636f756e74207468617420796f75207769736820746f20626f6e642e650120556e6c696b65205b60626f6e64605d206f72205b60756e626f6e64605d20746869732066756e6374696f6e20646f6573206e6f7420696d706f736520616e79206c696d69746174696f6e206f6e2074686520616d6f756e744c20746861742063616e2062652061646465642e00610120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f206279207468652073746173682c206e6f742074686520636f6e74726f6c6c657220616e64f82069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e004020456d6974732060426f6e646564602e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e20202d204f2831292e40202d204f6e6520444220656e7472792e34202d2d2d2d2d2d2d2d2d2d2d2d2c204442205765696768743a1501202d20526561643a2045726120456c656374696f6e205374617475732c20426f6e6465642c204c65646765722c205b4f726967696e204163636f756e745d2c204c6f636b73a4202d2057726974653a205b4f726967696e204163636f756e745d2c204c6f636b732c204c6564676572302023203c2f7765696768743e18756e626f6e64041476616c756554436f6d706163743c42616c616e63654f663c543e3e805501205363686564756c65206120706f7274696f6e206f662074686520737461736820746f20626520756e6c6f636b656420726561647920666f72207472616e73666572206f75742061667465722074686520626f6e64010120706572696f6420656e64732e2049662074686973206c656176657320616e20616d6f756e74206163746976656c7920626f6e646564206c657373207468616e250120543a3a43757272656e63793a3a6d696e696d756d5f62616c616e636528292c207468656e20697420697320696e6372656173656420746f207468652066756c6c20616d6f756e742e004901204f6e63652074686520756e6c6f636b20706572696f6420697320646f6e652c20796f752063616e2063616c6c206077697468647261775f756e626f6e6465646020746f2061637475616c6c79206d6f7665c0207468652066756e6473206f7574206f66206d616e6167656d656e7420726561647920666f72207472616e736665722e003d01204e6f206d6f7265207468616e2061206c696d69746564206e756d626572206f6620756e6c6f636b696e67206368756e6b73202873656520604d41585f554e4c4f434b494e475f4348554e4b5360293d012063616e20636f2d657869737473206174207468652073616d652074696d652e20496e207468617420636173652c205b6043616c6c3a3a77697468647261775f756e626f6e646564605d206e656564fc20746f2062652063616c6c656420666972737420746f2072656d6f766520736f6d65206f6620746865206368756e6b732028696620706f737369626c65292e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e004820456d6974732060556e626f6e646564602e00982053656520616c736f205b6043616c6c3a3a77697468647261775f756e626f6e646564605d2e002c2023203c7765696768743e4101202d20496e646570656e64656e74206f662074686520617267756d656e74732e204c696d697465642062757420706f74656e7469616c6c79206578706c6f697461626c6520636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732e6501202d20456163682063616c6c20287265717569726573207468652072656d61696e646572206f662074686520626f6e6465642062616c616e636520746f2062652061626f766520606d696e696d756d5f62616c616e63656029710120202077696c6c2063617573652061206e657720656e74727920746f20626520696e73657274656420696e746f206120766563746f722028604c65646765722e756e6c6f636b696e676029206b65707420696e2073746f726167652e5101202020546865206f6e6c792077617920746f20636c65616e207468652061666f72656d656e74696f6e65642073746f72616765206974656d20697320616c736f20757365722d636f6e74726f6c6c6564207669615c2020206077697468647261775f756e626f6e646564602e40202d204f6e6520444220656e7472792e2c202d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a1d01202d20526561643a20457261456c656374696f6e5374617475732c204c65646765722c2043757272656e744572612c204c6f636b732c2042616c616e63654f662053746173682ca4202d2057726974653a204c6f636b732c204c65646765722c2042616c616e63654f662053746173682c28203c2f7765696768743e4477697468647261775f756e626f6e64656404486e756d5f736c617368696e675f7370616e730c7533327c2d012052656d6f766520616e7920756e6c6f636b6564206368756e6b732066726f6d207468652060756e6c6f636b696e67602071756575652066726f6d206f7572206d616e6167656d656e742e003501205468697320657373656e7469616c6c7920667265657320757020746861742062616c616e636520746f206265207573656420627920746865207374617368206163636f756e7420746f20646f4c2077686174657665722069742077616e74732e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e004c20456d697473206057697468647261776e602e006c2053656520616c736f205b6043616c6c3a3a756e626f6e64605d2e002c2023203c7765696768743e5501202d20436f756c6420626520646570656e64656e74206f6e2074686520606f726967696e6020617267756d656e7420616e6420686f77206d7563682060756e6c6f636b696e6760206368756e6b732065786973742e45012020497420696d706c6965732060636f6e736f6c69646174655f756e6c6f636b656460207768696368206c6f6f7073206f76657220604c65646765722e756e6c6f636b696e67602c207768696368206973f42020696e6469726563746c7920757365722d636f6e74726f6c6c65642e20536565205b60756e626f6e64605d20666f72206d6f72652064657461696c2e7901202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732c20796574207468652073697a65206f6620776869636820636f756c64206265206c61726765206261736564206f6e20606c6564676572602ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e40202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d090120436f6d706c6578697479204f285329207768657265205320697320746865206e756d626572206f6620736c617368696e67207370616e7320746f2072656d6f766520205570646174653a2501202d2052656164733a20457261456c656374696f6e5374617475732c204c65646765722c2043757272656e74204572612c204c6f636b732c205b4f726967696e204163636f756e745da8202d205772697465733a205b4f726967696e204163636f756e745d2c204c6f636b732c204c656467657218204b696c6c3a4501202d2052656164733a20457261456c656374696f6e5374617475732c204c65646765722c2043757272656e74204572612c20426f6e6465642c20536c617368696e67205370616e732c205b4f726967696e8c2020204163636f756e745d2c204c6f636b732c2042616c616e63654f662073746173685101202d205772697465733a20426f6e6465642c20536c617368696e67205370616e73202869662053203e2030292c204c65646765722c2050617965652c2056616c696461746f72732c204e6f6d696e61746f72732cb02020205b4f726967696e204163636f756e745d2c204c6f636b732c2042616c616e63654f662073746173682e74202d2057726974657320456163683a205370616e536c617368202a20530d01204e4f54453a2057656967687420616e6e6f746174696f6e20697320746865206b696c6c207363656e6172696f2c20776520726566756e64206f74686572776973652e302023203c2f7765696768743e2076616c6964617465041470726566733856616c696461746f72507265667344e8204465636c617265207468652064657369726520746f2076616c696461746520666f7220746865206f726967696e20636f6e74726f6c6c65722e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e30202d2d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a90202d20526561643a2045726120456c656374696f6e205374617475732c204c656467657280202d2057726974653a204e6f6d696e61746f72732c2056616c696461746f7273302023203c2f7765696768743e206e6f6d696e617465041c74617267657473a05665633c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653e4c1101204465636c617265207468652064657369726520746f206e6f6d696e6174652060746172676574736020666f7220746865206f726967696e20636f6e74726f6c6c65722e00510120456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e20546869732063616e206f6e6c792062652063616c6c6564207768656e8c205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743e3101202d20546865207472616e73616374696f6e277320636f6d706c65786974792069732070726f706f7274696f6e616c20746f207468652073697a65206f662060746172676574736020284e2901012077686963682069732063617070656420617420436f6d7061637441737369676e6d656e74733a3a4c494d495420284d41585f4e4f4d494e4154494f4e53292ed8202d20426f74682074686520726561647320616e642077726974657320666f6c6c6f7720612073696d696c6172207061747465726e2e28202d2d2d2d2d2d2d2d2d34205765696768743a204f284e2984207768657265204e20697320746865206e756d626572206f6620746172676574732c204442205765696768743ac8202d2052656164733a2045726120456c656374696f6e205374617475732c204c65646765722c2043757272656e742045726184202d205772697465733a2056616c696461746f72732c204e6f6d696e61746f7273302023203c2f7765696768743e146368696c6c0044c8204465636c617265206e6f2064657369726520746f206569746865722076616c6964617465206f72206e6f6d696e6174652e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e0d0120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e54202d20436f6e7461696e73206f6e6520726561642ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e24202d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743a88202d20526561643a20457261456c656374696f6e5374617475732c204c656467657280202d2057726974653a2056616c696461746f72732c204e6f6d696e61746f7273302023203c2f7765696768743e247365745f7061796565041470617965657c52657761726444657374696e6174696f6e3c543a3a4163636f756e7449643e40b8202852652d2973657420746865207061796d656e742074617267657420666f72206120636f6e74726f6c6c65722e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e28202d2d2d2d2d2d2d2d2d3c202d205765696768743a204f28312934202d204442205765696768743a4c20202020202d20526561643a204c65646765724c20202020202d2057726974653a205061796565302023203c2f7765696768743e387365745f636f6e74726f6c6c65720428636f6e74726f6c6c65728c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263654090202852652d297365742074686520636f6e74726f6c6c6572206f6620612073746173682e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f206279207468652073746173682c206e6f742074686520636f6e74726f6c6c65722e002c2023203c7765696768743ee8202d20496e646570656e64656e74206f662074686520617267756d656e74732e20496e7369676e69666963616e7420636f6d706c65786974792e98202d20436f6e7461696e732061206c696d69746564206e756d626572206f662072656164732ec8202d2057726974657320617265206c696d6974656420746f2074686520606f726967696e60206163636f756e74206b65792e2c202d2d2d2d2d2d2d2d2d2d34205765696768743a204f2831292c204442205765696768743af4202d20526561643a20426f6e6465642c204c6564676572204e657720436f6e74726f6c6c65722c204c6564676572204f6c6420436f6e74726f6c6c6572f8202d2057726974653a20426f6e6465642c204c6564676572204e657720436f6e74726f6c6c65722c204c6564676572204f6c6420436f6e74726f6c6c6572302023203c2f7765696768743e4c7365745f76616c696461746f725f636f756e74040c6e657730436f6d706163743c7533323e209420536574732074686520696465616c206e756d626572206f662076616c696461746f72732e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e34205765696768743a204f2831295c2057726974653a2056616c696461746f7220436f756e74302023203c2f7765696768743e60696e6372656173655f76616c696461746f725f636f756e7404286164646974696f6e616c30436f6d706163743c7533323e1cac20496e6372656d656e74732074686520696465616c206e756d626572206f662076616c696461746f72732e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e842053616d65206173205b607365745f76616c696461746f725f636f756e74605d2e302023203c2f7765696768743e547363616c655f76616c696461746f725f636f756e740418666163746f721c50657263656e741cd4205363616c652075702074686520696465616c206e756d626572206f662076616c696461746f7273206279206120666163746f722e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e842053616d65206173205b607365745f76616c696461746f725f636f756e74605d2e302023203c2f7765696768743e34666f7263655f6e6f5f657261730024b020466f72636520746865726520746f206265206e6f206e6577206572617320696e646566696e6974656c792e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e40202d204e6f20617267756d656e74732e3c202d205765696768743a204f28312948202d2057726974653a20466f726365457261302023203c2f7765696768743e34666f7263655f6e65775f65726100284d0120466f72636520746865726520746f2062652061206e6577206572612061742074686520656e64206f6620746865206e6578742073657373696f6e2e20416674657220746869732c2069742077696c6c206265a020726573657420746f206e6f726d616c20286e6f6e2d666f7263656429206265686176696f75722e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e40202d204e6f20617267756d656e74732e3c202d205765696768743a204f28312944202d20577269746520466f726365457261302023203c2f7765696768743e447365745f696e76756c6e657261626c65730434696e76756c6e657261626c6573445665633c543a3a4163636f756e7449643e20cc20536574207468652076616c696461746f72732077686f2063616e6e6f7420626520736c61736865642028696620616e79292e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e1c202d204f2856295c202d2057726974653a20496e76756c6e657261626c6573302023203c2f7765696768743e34666f7263655f756e7374616b650814737461736830543a3a4163636f756e744964486e756d5f736c617368696e675f7370616e730c753332280d0120466f72636520612063757272656e74207374616b657220746f206265636f6d6520636f6d706c6574656c7920756e7374616b65642c20696d6d6564696174656c792e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743eec204f285329207768657265205320697320746865206e756d626572206f6620736c617368696e67207370616e7320746f2062652072656d6f766564b82052656164733a20426f6e6465642c20536c617368696e67205370616e732c204163636f756e742c204c6f636b738501205772697465733a20426f6e6465642c20536c617368696e67205370616e73202869662053203e2030292c204c65646765722c2050617965652c2056616c696461746f72732c204e6f6d696e61746f72732c204163636f756e742c204c6f636b736c2057726974657320456163683a205370616e536c617368202a2053302023203c2f7765696768743e50666f7263655f6e65775f6572615f616c776179730020050120466f72636520746865726520746f2062652061206e6577206572612061742074686520656e64206f662073657373696f6e7320696e646566696e6974656c792e008820546865206469737061746368206f726967696e206d75737420626520526f6f742e002c2023203c7765696768743e3c202d205765696768743a204f28312948202d2057726974653a20466f726365457261302023203c2f7765696768743e5463616e63656c5f64656665727265645f736c617368080c65726120457261496e64657834736c6173685f696e6469636573205665633c7533323e34982043616e63656c20656e6163746d656e74206f66206120646566657272656420736c6173682e00b42043616e2062652063616c6c6564206279207468652060543a3a536c61736843616e63656c4f726967696e602e00050120506172616d65746572733a2065726120616e6420696e6469636573206f662074686520736c617368657320666f7220746861742065726120746f206b696c6c2e002c2023203c7765696768743e5420436f6d706c65786974793a204f2855202b205329b82077697468205520756e6170706c69656420736c6173686573207765696768746564207769746820553d31303030d420616e64205320697320746865206e756d626572206f6620736c61736820696e646963657320746f2062652063616e63656c65642e68202d20526561643a20556e6170706c69656420536c61736865736c202d2057726974653a20556e6170706c69656420536c6173686573302023203c2f7765696768743e387061796f75745f7374616b657273083c76616c696461746f725f737461736830543a3a4163636f756e7449640c65726120457261496e64657870110120506179206f757420616c6c20746865207374616b65727320626568696e6420612073696e676c652076616c696461746f7220666f7220612073696e676c65206572612e004d01202d206076616c696461746f725f73746173686020697320746865207374617368206163636f756e74206f66207468652076616c696461746f722e205468656972206e6f6d696e61746f72732c20757020746f290120202060543a3a4d61784e6f6d696e61746f72526577617264656450657256616c696461746f72602c2077696c6c20616c736f207265636569766520746865697220726577617264732e3501202d206065726160206d617920626520616e7920657261206265747765656e20605b63757272656e745f657261202d20686973746f72795f64657074683b2063757272656e745f6572615d602e00590120546865206f726967696e206f6620746869732063616c6c206d757374206265205f5369676e65645f2e20416e79206163636f756e742063616e2063616c6c20746869732066756e6374696f6e2c206576656e20696678206974206973206e6f74206f6e65206f6620746865207374616b6572732e00010120546869732063616e206f6e6c792062652063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743e0101202d2054696d6520636f6d706c65786974793a206174206d6f7374204f284d61784e6f6d696e61746f72526577617264656450657256616c696461746f72292ec4202d20436f6e7461696e732061206c696d69746564206e756d626572206f6620726561647320616e64207772697465732e30202d2d2d2d2d2d2d2d2d2d2d1d01204e20697320746865204e756d626572206f66207061796f75747320666f72207468652076616c696461746f722028696e636c7564696e67207468652076616c696461746f722920205765696768743a88202d205265776172642044657374696e6174696f6e205374616b65643a204f284e29c4202d205265776172642044657374696e6174696f6e20436f6e74726f6c6c657220284372656174696e67293a204f284e292c204442205765696768743a2901202d20526561643a20457261456c656374696f6e5374617475732c2043757272656e744572612c20486973746f727944657074682c204572617356616c696461746f725265776172642c2d01202020202020202020457261735374616b657273436c69707065642c2045726173526577617264506f696e74732c204572617356616c696461746f725072656673202838206974656d73291101202d205265616420456163683a20426f6e6465642c204c65646765722c2050617965652c204c6f636b732c2053797374656d204163636f756e74202835206974656d7329d8202d20577269746520456163683a2053797374656d204163636f756e742c204c6f636b732c204c6564676572202833206974656d73290051012020204e4f54453a20776569676874732061726520617373756d696e672074686174207061796f75747320617265206d61646520746f20616c697665207374617368206163636f756e7420285374616b6564292e5901202020506179696e67206576656e2061206465616420636f6e74726f6c6c65722069732063686561706572207765696768742d776973652e20576520646f6e277420646f20616e7920726566756e647320686572652e302023203c2f7765696768743e187265626f6e64041476616c756554436f6d706163743c42616c616e63654f663c543e3e38e0205265626f6e64206120706f7274696f6e206f6620746865207374617368207363686564756c656420746f20626520756e6c6f636b65642e00550120546865206469737061746368206f726967696e206d757374206265207369676e65642062792074686520636f6e74726f6c6c65722c20616e642069742063616e206265206f6e6c792063616c6c6564207768656e8c205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e002c2023203c7765696768743ed4202d2054696d6520636f6d706c65786974793a204f284c292c207768657265204c20697320756e6c6f636b696e67206368756e6b7394202d20426f756e64656420627920604d41585f554e4c4f434b494e475f4348554e4b53602ef4202d2053746f72616765206368616e6765733a2043616e277420696e6372656173652073746f726167652c206f6e6c792064656372656173652069742e40202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743a010120202020202d2052656164733a20457261456c656374696f6e5374617475732c204c65646765722c204c6f636b732c205b4f726967696e204163636f756e745db820202020202d205772697465733a205b4f726967696e204163636f756e745d2c204c6f636b732c204c6564676572302023203c2f7765696768743e447365745f686973746f72795f646570746808446e65775f686973746f72795f646570746844436f6d706163743c457261496e6465783e485f6572615f6974656d735f64656c6574656430436f6d706163743c7533323e543101205365742060486973746f72794465707468602076616c75652e20546869732066756e6374696f6e2077696c6c2064656c65746520616e7920686973746f727920696e666f726d6174696f6e80207768656e2060486973746f727944657074686020697320726564756365642e003020506172616d65746572733a1101202d20606e65775f686973746f72795f6465707468603a20546865206e657720686973746f727920646570746820796f7520776f756c64206c696b6520746f207365742e4901202d20606572615f6974656d735f64656c65746564603a20546865206e756d626572206f66206974656d7320746861742077696c6c2062652064656c6574656420627920746869732064697370617463682e450120202020546869732073686f756c64207265706f727420616c6c207468652073746f72616765206974656d7320746861742077696c6c2062652064656c6574656420627920636c656172696e67206f6c6445012020202065726120686973746f72792e204e656564656420746f207265706f727420616e2061636375726174652077656967687420666f72207468652064697370617463682e2054727573746564206279a02020202060526f6f746020746f207265706f727420616e206163637572617465206e756d6265722e0054204f726967696e206d75737420626520726f6f742e002c2023203c7765696768743ee0202d20453a204e756d626572206f6620686973746f7279206465707468732072656d6f7665642c20692e652e203130202d3e2037203d20333c202d205765696768743a204f28452934202d204442205765696768743aa020202020202d2052656164733a2043757272656e74204572612c20486973746f72792044657074687020202020202d205772697465733a20486973746f7279204465707468310120202020202d20436c6561722050726566697820456163683a20457261205374616b6572732c204572615374616b657273436c69707065642c204572617356616c696461746f725072656673810120202020202d2057726974657320456163683a204572617356616c696461746f725265776172642c2045726173526577617264506f696e74732c2045726173546f74616c5374616b652c2045726173537461727453657373696f6e496e646578302023203c2f7765696768743e28726561705f73746173680814737461736830543a3a4163636f756e744964486e756d5f736c617368696e675f7370616e730c7533323c61012052656d6f766520616c6c20646174612073747275637475726520636f6e6365726e696e672061207374616b65722f7374617368206f6e6365206974732062616c616e636520697320617420746865206d696e696d756d2e6101205468697320697320657373656e7469616c6c79206571756976616c656e7420746f206077697468647261775f756e626f6e64656460206578636570742069742063616e2062652063616c6c656420627920616e796f6e65f820616e6420746865207461726765742060737461736860206d7573742068617665206e6f2066756e6473206c656674206265796f6e64207468652045442e009020546869732063616e2062652063616c6c65642066726f6d20616e79206f726967696e2e000101202d20607374617368603a20546865207374617368206163636f756e7420746f20726561702e204974732062616c616e6365206d757374206265207a65726f2e002c2023203c7765696768743e250120436f6d706c65786974793a204f285329207768657265205320697320746865206e756d626572206f6620736c617368696e67207370616e73206f6e20746865206163636f756e742e2c204442205765696768743ad8202d2052656164733a205374617368204163636f756e742c20426f6e6465642c20536c617368696e67205370616e732c204c6f636b73a501202d205772697465733a20426f6e6465642c20536c617368696e67205370616e73202869662053203e2030292c204c65646765722c2050617965652c2056616c696461746f72732c204e6f6d696e61746f72732c205374617368204163636f756e742c204c6f636b7374202d2057726974657320456163683a205370616e536c617368202a2053302023203c2f7765696768743e106b69636b040c77686fa05665633c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653e34e42052656d6f76652074686520676976656e206e6f6d696e6174696f6e732066726f6d207468652063616c6c696e672076616c696461746f722e00dc20456666656374732077696c6c2062652066656c742061742074686520626567696e6e696e67206f6620746865206e657874206572612e00550120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2062792074686520636f6e74726f6c6c65722c206e6f74207468652073746173682e490120416e642c2069742063616e206265206f6e6c792063616c6c6564207768656e205b60457261456c656374696f6e537461747573605d2069732060436c6f736564602e2054686520636f6e74726f6c6c657298206163636f756e742073686f756c6420726570726573656e7420612076616c696461746f722e005101202d206077686f603a2041206c697374206f66206e6f6d696e61746f72207374617368206163636f756e74732077686f20617265206e6f6d696e6174696e6720746869732076616c696461746f72207768696368c420202073686f756c64206e6f206c6f6e676572206265206e6f6d696e6174696e6720746869732076616c696461746f722e005901204e6f74653a204d616b696e6720746869732063616c6c206f6e6c79206d616b65732073656e736520696620796f7520666972737420736574207468652076616c696461746f7220707265666572656e63657320746f7c20626c6f636b20616e792066757274686572206e6f6d696e6174696f6e732e0124244572615061796f75740c20457261496e6465781c42616c616e63651c42616c616e63650c59012054686520657261207061796f757420686173206265656e207365743b207468652066697273742062616c616e6365206973207468652076616c696461746f722d7061796f75743b20746865207365636f6e64206973c4207468652072656d61696e6465722066726f6d20746865206d6178696d756d20616d6f756e74206f66207265776172642eac205c5b6572615f696e6465782c2076616c696461746f725f7061796f75742c2072656d61696e6465725c5d1852657761726408244163636f756e7449641c42616c616e636504fc20546865207374616b657220686173206265656e207265776172646564206279207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d14536c61736808244163636f756e7449641c42616c616e6365082501204f6e652076616c696461746f722028616e6420697473206e6f6d696e61746f72732920686173206265656e20736c61736865642062792074686520676976656e20616d6f756e742e58205c5b76616c696461746f722c20616d6f756e745c5d684f6c64536c617368696e675265706f7274446973636172646564043053657373696f6e496e646578081d0120416e206f6c6420736c617368696e67207265706f72742066726f6d2061207072696f72206572612077617320646973636172646564206265636175736520697420636f756c6490206e6f742062652070726f6365737365642e205c5b73657373696f6e5f696e6465785c5d3c5374616b696e67456c656374696f6e0004882041206e657720736574206f66207374616b6572732077617320656c65637465642e18426f6e64656408244163636f756e7449641c42616c616e636510d420416e206163636f756e742068617320626f6e646564207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d005101204e4f54453a2054686973206576656e74206973206f6e6c7920656d6974746564207768656e2066756e64732061726520626f6e64656420766961206120646973706174636861626c652e204e6f7461626c792c25012069742077696c6c206e6f7420626520656d697474656420666f72207374616b696e672072657761726473207768656e20746865792061726520616464656420746f207374616b652e20556e626f6e64656408244163636f756e7449641c42616c616e636504dc20416e206163636f756e742068617320756e626f6e646564207468697320616d6f756e742e205c5b73746173682c20616d6f756e745c5d2457697468647261776e08244163636f756e7449641c42616c616e6365085d0120416e206163636f756e74206861732063616c6c6564206077697468647261775f756e626f6e6465646020616e642072656d6f76656420756e626f6e64696e67206368756e6b7320776f727468206042616c616e636560b02066726f6d2074686520756e6c6f636b696e672071756575652e205c5b73746173682c20616d6f756e745c5d184b69636b656408244163636f756e744964244163636f756e744964040d012041206e6f6d696e61746f7220686173206265656e206b69636b65642066726f6d20612076616c696461746f722e205c5b6e6f6d696e61746f722c2073746173685c5d143853657373696f6e735065724572613053657373696f6e496e64657810060000000470204e756d626572206f662073657373696f6e7320706572206572612e3c426f6e64696e674475726174696f6e20457261496e646578101c00000004e4204e756d626572206f6620657261732074686174207374616b65642066756e6473206d7573742072656d61696e20626f6e64656420666f722e48536c61736844656665724475726174696f6e20457261496e646578101b000000140101204e756d626572206f662065726173207468617420736c6173686573206172652064656665727265642062792c20616674657220636f6d7075746174696f6e2e00bc20546869732073686f756c64206265206c657373207468616e2074686520626f6e64696e67206475726174696f6e2e2d012053657420746f203020696620736c61736865732073686f756c64206265206170706c69656420696d6d6564696174656c792c20776974686f7574206f70706f7274756e69747920666f723820696e74657276656e74696f6e2e804d61784e6f6d696e61746f72526577617264656450657256616c696461746f720c753332104000000010f820546865206d6178696d756d206e756d626572206f66206e6f6d696e61746f727320726577617264656420666f7220656163682076616c696461746f722e00690120466f7220656163682076616c696461746f72206f6e6c79207468652060244d61784e6f6d696e61746f72526577617264656450657256616c696461746f72602062696767657374207374616b6572732063616e20636c61696d2101207468656972207265776172642e2054686973207573656420746f206c696d69742074686520692f6f20636f737420666f7220746865206e6f6d696e61746f72207061796f75742e384d61784e6f6d696e6174696f6e730c753332101000000004b4204d6178696d756d206e756d626572206f66206e6f6d696e6174696f6e7320706572206e6f6d696e61746f722e50344e6f74436f6e74726f6c6c65720468204e6f74206120636f6e74726f6c6c6572206163636f756e742e204e6f7453746173680454204e6f742061207374617368206163636f756e742e34416c7265616479426f6e646564046420537461736820697320616c726561647920626f6e6465642e34416c7265616479506169726564047820436f6e74726f6c6c657220697320616c7265616479207061697265642e30456d70747954617267657473046420546172676574732063616e6e6f7420626520656d7074792e384475706c6963617465496e6465780444204475706c696361746520696e6465782e44496e76616c6964536c617368496e646578048820536c617368207265636f726420696e646578206f7574206f6620626f756e64732e44496e73756666696369656e7456616c756504cc2043616e206e6f7420626f6e6420776974682076616c7565206c657373207468616e206d696e696d756d2062616c616e63652e304e6f4d6f72654368756e6b7304942043616e206e6f74207363686564756c65206d6f726520756e6c6f636b206368756e6b732e344e6f556e6c6f636b4368756e6b04a42043616e206e6f74207265626f6e6420776974686f757420756e6c6f636b696e67206368756e6b732e3046756e64656454617267657404cc20417474656d7074696e6720746f2074617267657420612073746173682074686174207374696c6c206861732066756e64732e48496e76616c6964457261546f526577617264045c20496e76616c69642065726120746f207265776172642e68496e76616c69644e756d6265724f664e6f6d696e6174696f6e73047c20496e76616c6964206e756d626572206f66206e6f6d696e6174696f6e732e484e6f74536f72746564416e64556e697175650484204974656d7320617265206e6f7420736f7274656420616e6420756e697175652e38416c7265616479436c61696d6564040d01205265776172647320666f72207468697320657261206861766520616c7265616479206265656e20636c61696d656420666f7220746869732076616c696461746f722e54496e636f7272656374486973746f7279446570746804c420496e636f72726563742070726576696f757320686973746f727920646570746820696e7075742070726f76696465642e58496e636f7272656374536c617368696e675370616e7304b420496e636f7272656374206e756d626572206f6620736c617368696e67207370616e732070726f76696465642e204261645374617465043d0120496e7465726e616c20737461746520686173206265636f6d6520736f6d65686f7720636f7272757074656420616e6420746865206f7065726174696f6e2063616e6e6f7420636f6e74696e75652e38546f6f4d616e7954617267657473049820546f6f206d616e79206e6f6d696e6174696f6e207461726765747320737570706c6965642e244261645461726765740441012041206e6f6d696e6174696f6e207461726765742077617320737570706c69656420746861742077617320626c6f636b6564206f72206f7468657277697365206e6f7420612076616c696461746f722e06204f6666656e63657301204f6666656e636573101c5265706f727473000105345265706f727449644f663c543ed04f6666656e636544657461696c733c543a3a4163636f756e7449642c20543a3a4964656e74696669636174696f6e5475706c653e00040004490120546865207072696d61727920737472756374757265207468617420686f6c647320616c6c206f6666656e6365207265636f726473206b65796564206279207265706f7274206964656e746966696572732e4044656665727265644f6666656e6365730100645665633c44656665727265644f6666656e63654f663c543e3e0400086501204465666572726564207265706f72747320746861742068617665206265656e2072656a656374656420627920746865206f6666656e63652068616e646c657220616e64206e65656420746f206265207375626d6974746564442061742061206c617465722074696d652e58436f6e63757272656e745265706f727473496e646578010205104b696e64384f706171756554696d65536c6f74485665633c5265706f727449644f663c543e3e050400042901204120766563746f72206f66207265706f727473206f66207468652073616d65206b696e6420746861742068617070656e6564206174207468652073616d652074696d6520736c6f742e485265706f72747342794b696e64496e646578010105104b696e641c5665633c75383e00040018110120456e756d65726174657320616c6c207265706f727473206f662061206b696e6420616c6f6e672077697468207468652074696d6520746865792068617070656e65642e00bc20416c6c207265706f7274732061726520736f72746564206279207468652074696d65206f66206f6666656e63652e004901204e6f74652074686174207468652061637475616c2074797065206f662074686973206d617070696e6720697320605665633c75383e602c207468697320697320626563617573652076616c756573206f66690120646966666572656e7420747970657320617265206e6f7420737570706f7274656420617420746865206d6f6d656e7420736f2077652061726520646f696e6720746865206d616e75616c2073657269616c697a6174696f6e2e010001041c4f6666656e63650c104b696e64384f706171756554696d65536c6f7410626f6f6c10550120546865726520697320616e206f6666656e6365207265706f72746564206f662074686520676976656e20606b696e64602068617070656e656420617420746865206073657373696f6e5f696e6465786020616e644d0120286b696e642d7370656369666963292074696d6520736c6f742e2054686973206576656e74206973206e6f74206465706f736974656420666f72206475706c696361746520736c61736865732e206c617374190120656c656d656e7420696e64696361746573206f6620746865206f6666656e636520776173206170706c69656420287472756529206f7220717565756564202866616c73652974205c5b6b696e642c2074696d65736c6f742c206170706c6965645c5d2e00000728486973746f726963616c00000000001b1c53657373696f6e011c53657373696f6e1c2856616c696461746f727301004c5665633c543a3a56616c696461746f7249643e0400047c205468652063757272656e7420736574206f662076616c696461746f72732e3043757272656e74496e64657801003053657373696f6e496e646578100000000004782043757272656e7420696e646578206f66207468652073657373696f6e2e345175657565644368616e676564010010626f6f6c040008390120547275652069662074686520756e6465726c79696e672065636f6e6f6d6963206964656e746974696573206f7220776569676874696e6720626568696e64207468652076616c696461746f7273a420686173206368616e67656420696e20746865207175657565642076616c696461746f72207365742e285175657565644b6579730100785665633c28543a3a56616c696461746f7249642c20543a3a4b657973293e0400083d012054686520717565756564206b65797320666f7220746865206e6578742073657373696f6e2e205768656e20746865206e6578742073657373696f6e20626567696e732c207468657365206b657973e02077696c6c206265207573656420746f2064657465726d696e65207468652076616c696461746f7227732073657373696f6e206b6579732e4844697361626c656456616c696461746f72730100205665633c7533323e04000c8020496e6469636573206f662064697361626c65642076616c696461746f72732e003501205468652073657420697320636c6561726564207768656e20606f6e5f73657373696f6e5f656e64696e67602072657475726e732061206e657720736574206f66206964656e7469746965732e204e6578744b65797300010538543a3a56616c696461746f7249641c543a3a4b657973000400049c20546865206e6578742073657373696f6e206b65797320666f7220612076616c696461746f722e204b65794f776e657200010550284b65795479706549642c205665633c75383e2938543a3a56616c696461746f72496400040004090120546865206f776e6572206f662061206b65792e20546865206b65792069732074686520604b657954797065496460202b2074686520656e636f646564206b65792e0108207365745f6b65797308106b6579731c543a3a4b6579731470726f6f661c5665633c75383e38e82053657473207468652073657373696f6e206b6579287329206f66207468652066756e6374696f6e2063616c6c657220746f20606b657973602e210120416c6c6f777320616e206163636f756e7420746f20736574206974732073657373696f6e206b6579207072696f7220746f206265636f6d696e6720612076616c696461746f722ec4205468697320646f65736e27742074616b652065666665637420756e74696c20746865206e6578742073657373696f6e2e00d420546865206469737061746368206f726967696e206f6620746869732066756e6374696f6e206d757374206265207369676e65642e002c2023203c7765696768743e54202d20436f6d706c65786974793a20604f28312960590120202041637475616c20636f737420646570656e6473206f6e20746865206e756d626572206f66206c656e677468206f662060543a3a4b6579733a3a6b65795f6964732829602077686963682069732066697865642ef0202d20446252656164733a20606f726967696e206163636f756e74602c2060543a3a56616c696461746f7249644f66602c20604e6578744b65797360a4202d2044625772697465733a20606f726967696e206163636f756e74602c20604e6578744b6579736084202d204462526561647320706572206b65792069643a20604b65794f776e65726088202d20446257726974657320706572206b65792069643a20604b65794f776e657260302023203c2f7765696768743e2870757267655f6b6579730030cc2052656d6f76657320616e792073657373696f6e206b6579287329206f66207468652066756e6374696f6e2063616c6c65722ec4205468697320646f65736e27742074616b652065666665637420756e74696c20746865206e6578742073657373696f6e2e00d420546865206469737061746368206f726967696e206f6620746869732066756e6374696f6e206d757374206265207369676e65642e002c2023203c7765696768743eb4202d20436f6d706c65786974793a20604f2831296020696e206e756d626572206f66206b65792074797065732e590120202041637475616c20636f737420646570656e6473206f6e20746865206e756d626572206f66206c656e677468206f662060543a3a4b6579733a3a6b65795f6964732829602077686963682069732066697865642ef0202d20446252656164733a2060543a3a56616c696461746f7249644f66602c20604e6578744b657973602c20606f726967696e206163636f756e7460a4202d2044625772697465733a20604e6578744b657973602c20606f726967696e206163636f756e74608c202d20446257726974657320706572206b65792069643a20604b65794f776e64657260302023203c2f7765696768743e0104284e657753657373696f6e043053657373696f6e496e646578086501204e65772073657373696f6e206861732068617070656e65642e204e6f746520746861742074686520617267756d656e7420697320746865205c5b73657373696f6e5f696e6465785c5d2c206e6f742074686520626c6f636b88206e756d626572206173207468652074797065206d6967687420737567676573742e001430496e76616c696450726f6f66046420496e76616c6964206f776e6572736869702070726f6f662e5c4e6f4173736f63696174656456616c696461746f72496404a0204e6f206173736f6369617465642076616c696461746f7220494420666f72206163636f756e742e344475706c6963617465644b657904682052656769737465726564206475706c6963617465206b65792e184e6f4b65797304a8204e6f206b65797320617265206173736f63696174656420776974682074686973206163636f756e742e244e6f4163636f756e74041d01204b65792073657474696e67206163636f756e74206973206e6f74206c6976652c20736f206974277320696d706f737369626c6520746f206173736f6369617465206b6579732e081c4772616e647061013c4772616e64706146696e616c6974791814537461746501006c53746f72656453746174653c543a3a426c6f636b4e756d6265723e04000490205374617465206f66207468652063757272656e7420617574686f72697479207365742e3450656e64696e674368616e676500008c53746f72656450656e64696e674368616e67653c543a3a426c6f636b4e756d6265723e040004c42050656e64696e67206368616e67653a20287369676e616c65642061742c207363686564756c6564206368616e6765292e284e657874466f72636564000038543a3a426c6f636b4e756d626572040004bc206e65787420626c6f636b206e756d6265722077686572652077652063616e20666f7263652061206368616e67652e1c5374616c6c656400008028543a3a426c6f636b4e756d6265722c20543a3a426c6f636b4e756d626572290400049020607472756560206966207765206172652063757272656e746c79207374616c6c65642e3043757272656e7453657449640100145365744964200000000000000000085d0120546865206e756d626572206f66206368616e6765732028626f746820696e207465726d73206f66206b65797320616e6420756e6465726c79696e672065636f6e6f6d696320726573706f6e736962696c697469657329c420696e20746865202273657422206f66204772616e6470612076616c696461746f72732066726f6d2067656e657369732e30536574496453657373696f6e0001051453657449643053657373696f6e496e6465780004001059012041206d617070696e672066726f6d206772616e6470612073657420494420746f2074686520696e646578206f6620746865202a6d6f737420726563656e742a2073657373696f6e20666f722077686963682069747368206d656d62657273207765726520726573706f6e7369626c652e00b82054574f582d4e4f54453a2060536574496460206973206e6f7420756e646572207573657220636f6e74726f6c2e010c4c7265706f72745f65717569766f636174696f6e084865717569766f636174696f6e5f70726f6f66a845717569766f636174696f6e50726f6f663c543a3a486173682c20543a3a426c6f636b4e756d6265723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66100d01205265706f727420766f7465722065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c2076657269667920746865f82065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66fc20616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e6365482077696c6c206265207265706f727465642e707265706f72745f65717569766f636174696f6e5f756e7369676e6564084865717569766f636174696f6e5f70726f6f66a845717569766f636174696f6e50726f6f663c543a3a486173682c20543a3a426c6f636b4e756d6265723e3c6b65795f6f776e65725f70726f6f6640543a3a4b65794f776e657250726f6f66240d01205265706f727420766f7465722065717569766f636174696f6e2f6d69736265686176696f722e2054686973206d6574686f642077696c6c2076657269667920746865f82065717569766f636174696f6e2070726f6f6620616e642076616c69646174652074686520676976656e206b6579206f776e6572736869702070726f6f66fc20616761696e73742074686520657874726163746564206f6666656e6465722e20496620626f7468206172652076616c69642c20746865206f6666656e6365482077696c6c206265207265706f727465642e00110120546869732065787472696e736963206d7573742062652063616c6c656420756e7369676e656420616e642069742069732065787065637465642074686174206f6e6c79190120626c6f636b20617574686f72732077696c6c2063616c6c206974202876616c69646174656420696e206056616c6964617465556e7369676e656460292c206173207375636819012069662074686520626c6f636b20617574686f7220697320646566696e65642069742077696c6c20626520646566696e6564206173207468652065717569766f636174696f6e28207265706f727465722e306e6f74655f7374616c6c6564081464656c617938543a3a426c6f636b4e756d6265726c626573745f66696e616c697a65645f626c6f636b5f6e756d62657238543a3a426c6f636b4e756d6265721c1d01204e6f74652074686174207468652063757272656e7420617574686f7269747920736574206f6620746865204752414e4450412066696e616c69747920676164676574206861732901207374616c6c65642e20546869732077696c6c2074726967676572206120666f7263656420617574686f7269747920736574206368616e67652061742074686520626567696e6e696e672101206f6620746865206e6578742073657373696f6e2c20746f20626520656e6163746564206064656c61796020626c6f636b7320616674657220746861742e205468652064656c617915012073686f756c64206265206869676820656e6f75676820746f20736166656c7920617373756d6520746861742074686520626c6f636b207369676e616c6c696e6720746865290120666f72636564206368616e67652077696c6c206e6f742062652072652d6f726765642028652e672e203130303020626c6f636b73292e20546865204752414e44504120766f7465727329012077696c6c20737461727420746865206e657720617574686f7269747920736574207573696e672074686520676976656e2066696e616c697a656420626c6f636b20617320626173652e5c204f6e6c792063616c6c61626c6520627920726f6f742e010c384e6577417574686f7269746965730434417574686f726974794c69737404d8204e657720617574686f726974792073657420686173206265656e206170706c6965642e205c5b617574686f726974795f7365745c5d1850617573656400049c2043757272656e7420617574686f726974792073657420686173206265656e207061757365642e1c526573756d65640004a02043757272656e7420617574686f726974792073657420686173206265656e20726573756d65642e001c2c50617573654661696c656408090120417474656d707420746f207369676e616c204752414e445041207061757365207768656e2074686520617574686f72697479207365742069736e2774206c697665a8202865697468657220706175736564206f7220616c72656164792070656e64696e67207061757365292e30526573756d654661696c656408150120417474656d707420746f207369676e616c204752414e44504120726573756d65207768656e2074686520617574686f72697479207365742069736e277420706175736564a42028656974686572206c697665206f7220616c72656164792070656e64696e6720726573756d65292e344368616e676550656e64696e6704ec20417474656d707420746f207369676e616c204752414e445041206368616e67652077697468206f6e6520616c72656164792070656e64696e672e1c546f6f536f6f6e04c02043616e6e6f74207369676e616c20666f72636564206368616e676520736f20736f6f6e206166746572206c6173742e60496e76616c69644b65794f776e65727368697050726f6f660435012041206b6579206f776e6572736869702070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e60496e76616c696445717569766f636174696f6e50726f6f6604350120416e2065717569766f636174696f6e2070726f6f662070726f76696465642061732070617274206f6620616e2065717569766f636174696f6e207265706f727420697320696e76616c69642e584475706c69636174654f6666656e63655265706f7274041901204120676976656e2065717569766f636174696f6e207265706f72742069732076616c69642062757420616c72656164792070726576696f75736c79207265706f727465642e0a20496d4f6e6c696e650120496d4f6e6c696e6510384865617274626561744166746572010038543a3a426c6f636b4e756d62657210000000002c1d012054686520626c6f636b206e756d6265722061667465722077686963682069742773206f6b20746f2073656e64206865617274626561747320696e207468652063757272656e74242073657373696f6e2e0025012041742074686520626567696e6e696e67206f6620656163682073657373696f6e20776520736574207468697320746f20612076616c756520746861742073686f756c642066616c6c350120726f7567686c7920696e20746865206d6964646c65206f66207468652073657373696f6e206475726174696f6e2e20546865206964656120697320746f206669727374207761697420666f721901207468652076616c696461746f727320746f2070726f64756365206120626c6f636b20696e207468652063757272656e742073657373696f6e2c20736f207468617420746865a820686561727462656174206c61746572206f6e2077696c6c206e6f74206265206e65636573736172792e00390120546869732076616c75652077696c6c206f6e6c79206265207573656420617320612066616c6c6261636b206966207765206661696c20746f2067657420612070726f7065722073657373696f6e2d012070726f677265737320657374696d6174652066726f6d20604e65787453657373696f6e526f746174696f6e602c2061732074686f736520657374696d617465732073686f756c642062650101206d6f7265206163637572617465207468656e207468652076616c75652077652063616c63756c61746520666f7220604865617274626561744166746572602e104b65797301004c5665633c543a3a417574686f7269747949643e040004d0205468652063757272656e7420736574206f66206b6579732074686174206d61792069737375652061206865617274626561742e485265636569766564486561727462656174730002053053657373696f6e496e6465782441757468496e6465781c5665633c75383e05040008f020466f7220656163682073657373696f6e20696e6465782c207765206b6565702061206d617070696e67206f66206041757468496e6465786020746f8020606f6666636861696e3a3a4f70617175654e6574776f726b5374617465602e38417574686f726564426c6f636b730102053053657373696f6e496e6465783856616c696461746f7249643c543e0c75333205100000000008150120466f7220656163682073657373696f6e20696e6465782c207765206b6565702061206d617070696e67206f66206056616c696461746f7249643c543e6020746f20746865c8206e756d626572206f6620626c6f636b7320617574686f7265642062792074686520676976656e20617574686f726974792e0104246865617274626561740824686561727462656174644865617274626561743c543a3a426c6f636b4e756d6265723e285f7369676e6174757265bc3c543a3a417574686f7269747949642061732052756e74696d654170705075626c69633e3a3a5369676e6174757265242c2023203c7765696768743e4101202d20436f6d706c65786974793a20604f284b202b20452960207768657265204b206973206c656e677468206f6620604b6579736020286865617274626561742e76616c696461746f72735f6c656e290101202020616e642045206973206c656e677468206f6620606865617274626561742e6e6574776f726b5f73746174652e65787465726e616c5f61646472657373608c2020202d20604f284b29603a206465636f64696e67206f66206c656e67746820604b60b02020202d20604f284529603a206465636f64696e672f656e636f64696e67206f66206c656e677468206045603d01202d20446252656164733a2070616c6c65745f73657373696f6e206056616c696461746f7273602c2070616c6c65745f73657373696f6e206043757272656e74496e646578602c20604b657973602c5c202020605265636569766564486561727462656174736084202d2044625772697465733a206052656365697665644865617274626561747360302023203c2f7765696768743e010c444865617274626561745265636569766564042c417574686f7269747949640405012041206e657720686561727462656174207761732072656365697665642066726f6d2060417574686f72697479496460205c5b617574686f726974795f69645c5d1c416c6c476f6f640004d42041742074686520656e64206f66207468652073657373696f6e2c206e6f206f6666656e63652077617320636f6d6d69747465642e2c536f6d654f66666c696e6504605665633c4964656e74696669636174696f6e5475706c653e043d012041742074686520656e64206f66207468652073657373696f6e2c206174206c65617374206f6e652076616c696461746f722077617320666f756e6420746f206265205c5b6f66666c696e655c5d2e000828496e76616c69644b65790464204e6f6e206578697374656e74207075626c6963206b65792e4c4475706c6963617465644865617274626561740458204475706c696361746564206865617274626561742e0b48417574686f72697479446973636f766572790001000000000c1c5574696c69747900010c146261746368041463616c6c73605665633c3c5420617320436f6e6669673e3a3a43616c6c3e48802053656e642061206261746368206f662064697370617463682063616c6c732e007c204d61792062652063616c6c65642066726f6d20616e79206f726967696e2e00f0202d206063616c6c73603a205468652063616c6c7320746f20626520646973706174636865642066726f6d207468652073616d65206f726967696e2e006101204966206f726967696e20697320726f6f74207468656e2063616c6c2061726520646973706174636820776974686f757420636865636b696e67206f726967696e2066696c7465722e20285468697320696e636c75646573cc20627970617373696e6720606672616d655f73797374656d3a3a436f6e6669673a3a4261736543616c6c46696c74657260292e002c2023203c7765696768743e0501202d20436f6d706c65786974793a204f284329207768657265204320697320746865206e756d626572206f662063616c6c7320746f20626520626174636865642e302023203c2f7765696768743e00590120546869732077696c6c2072657475726e20604f6b6020696e20616c6c2063697263756d7374616e6365732e20546f2064657465726d696e65207468652073756363657373206f66207468652062617463682c20616e3501206576656e74206973206465706f73697465642e20496620612063616c6c206661696c656420616e64207468652062617463682077617320696e7465727275707465642c207468656e20746865590120604261746368496e74657272757074656460206576656e74206973206465706f73697465642c20616c6f6e67207769746820746865206e756d626572206f66207375636365737366756c2063616c6c73206d616465510120616e6420746865206572726f72206f6620746865206661696c65642063616c6c2e20496620616c6c2077657265207375636365737366756c2c207468656e2074686520604261746368436f6d706c657465646050206576656e74206973206465706f73697465642e3461735f646572697661746976650814696e6465780c7531361063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e34e02053656e6420612063616c6c207468726f75676820616e20696e64657865642070736575646f6e796d206f66207468652073656e6465722e0059012046696c7465722066726f6d206f726967696e206172652070617373656420616c6f6e672e205468652063616c6c2077696c6c2062652064697370617463686564207769746820616e206f726967696e207768696368c020757365207468652073616d652066696c74657220617320746865206f726967696e206f6620746869732063616c6c2e004901204e4f54453a20496620796f75206e65656420746f20656e73757265207468617420616e79206163636f756e742d62617365642066696c746572696e67206973206e6f7420686f6e6f7265642028692e652e6501206265636175736520796f7520657870656374206070726f78796020746f2068617665206265656e2075736564207072696f7220696e207468652063616c6c20737461636b20616e6420796f7520646f206e6f742077616e745501207468652063616c6c207265737472696374696f6e7320746f206170706c7920746f20616e79207375622d6163636f756e7473292c207468656e20757365206061735f6d756c74695f7468726573686f6c645f31608020696e20746865204d756c74697369672070616c6c657420696e73746561642e00f8204e4f54453a205072696f7220746f2076657273696f6e202a31322c2074686973207761732063616c6c6564206061735f6c696d697465645f737562602e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e2462617463685f616c6c041463616c6c73605665633c3c5420617320436f6e6669673e3a3a43616c6c3e34f02053656e642061206261746368206f662064697370617463682063616c6c7320616e642061746f6d6963616c6c792065786563757465207468656d2e2501205468652077686f6c65207472616e73616374696f6e2077696c6c20726f6c6c6261636b20616e64206661696c20696620616e79206f66207468652063616c6c73206661696c65642e007c204d61792062652063616c6c65642066726f6d20616e79206f726967696e2e00f0202d206063616c6c73603a205468652063616c6c7320746f20626520646973706174636865642066726f6d207468652073616d65206f726967696e2e006101204966206f726967696e20697320726f6f74207468656e2063616c6c2061726520646973706174636820776974686f757420636865636b696e67206f726967696e2066696c7465722e20285468697320696e636c75646573cc20627970617373696e6720606672616d655f73797374656d3a3a436f6e6669673a3a4261736543616c6c46696c74657260292e002c2023203c7765696768743e0501202d20436f6d706c65786974793a204f284329207768657265204320697320746865206e756d626572206f662063616c6c7320746f20626520626174636865642e302023203c2f7765696768743e0108404261746368496e746572727570746564080c7533323444697370617463684572726f72085901204261746368206f66206469737061746368657320646964206e6f7420636f6d706c6574652066756c6c792e20496e646578206f66206669727374206661696c696e6720646973706174636820676976656e2c206173902077656c6c20617320746865206572726f722e205c5b696e6465782c206572726f725c5d384261746368436f6d706c657465640004cc204261746368206f66206469737061746368657320636f6d706c657465642066756c6c792077697468206e6f206572726f722e000010204964656e7469747901204964656e7469747910284964656e746974794f6600010530543a3a4163636f756e74496468526567697374726174696f6e3c42616c616e63654f663c543e3e0004000c210120496e666f726d6174696f6e20746861742069732070657274696e656e7420746f206964656e746966792074686520656e7469747920626568696e6420616e206163636f756e742e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e1c53757065724f6600010230543a3a4163636f756e7449645028543a3a4163636f756e7449642c204461746129000400086101205468652073757065722d6964656e74697479206f6620616e20616c7465726e6174697665202273756222206964656e7469747920746f676574686572207769746820697473206e616d652c2077697468696e2074686174510120636f6e746578742e20496620746865206163636f756e74206973206e6f7420736f6d65206f74686572206163636f756e742773207375622d6964656e746974792c207468656e206a75737420604e6f6e65602e18537562734f6601010530543a3a4163636f756e744964842842616c616e63654f663c543e2c205665633c543a3a4163636f756e7449643e290044000000000000000000000000000000000014b820416c7465726e6174697665202273756222206964656e746974696573206f662074686973206163636f756e742e001d0120546865206669727374206974656d20697320746865206465706f7369742c20746865207365636f6e64206973206120766563746f72206f6620746865206163636f756e74732e00c02054574f582d4e4f54453a204f4b20e2809520604163636f756e7449646020697320612073656375726520686173682e28526567697374726172730100d85665633c4f7074696f6e3c526567697374726172496e666f3c42616c616e63654f663c543e2c20543a3a4163636f756e7449643e3e3e0400104d012054686520736574206f6620726567697374726172732e204e6f7420657870656374656420746f206765742076657279206269672061732063616e206f6e6c79206265206164646564207468726f7567682061a8207370656369616c206f726967696e20286c696b656c79206120636f756e63696c206d6f74696f6e292e0029012054686520696e64657820696e746f20746869732063616e206265206361737420746f2060526567697374726172496e6465786020746f2067657420612076616c69642076616c75652e013c346164645f726567697374726172041c6163636f756e7430543a3a4163636f756e744964347c2041646420612072656769737472617220746f207468652073797374656d2e00010120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d7573742062652060543a3a5265676973747261724f726967696e602e00ac202d20606163636f756e74603a20746865206163636f756e74206f6620746865207265676973747261722e009820456d6974732060526567697374726172416464656460206966207375636365737366756c2e002c2023203c7765696768743e2901202d20604f2852296020776865726520605260207265676973747261722d636f756e742028676f7665726e616e63652d626f756e64656420616e6420636f64652d626f756e646564292e9c202d204f6e652073746f72616765206d75746174696f6e2028636f64656320604f28522960292e34202d204f6e65206576656e742e302023203c2f7765696768743e307365745f6964656e746974790410696e666f304964656e74697479496e666f4c2d012053657420616e206163636f756e742773206964656e7469747920696e666f726d6174696f6e20616e6420726573657276652074686520617070726f707269617465206465706f7369742e00590120496620746865206163636f756e7420616c726561647920686173206964656e7469747920696e666f726d6174696f6e2c20746865206465706f7369742069732074616b656e2061732070617274207061796d656e745420666f7220746865206e6577206465706f7369742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e0090202d2060696e666f603a20546865206964656e7469747920696e666f726d6174696f6e2e008c20456d69747320604964656e7469747953657460206966207375636365737366756c2e002c2023203c7765696768743e48202d20604f2858202b205827202b2052296021012020202d20776865726520605860206164646974696f6e616c2d6669656c642d636f756e7420286465706f7369742d626f756e64656420616e6420636f64652d626f756e64656429e42020202d20776865726520605260206a756467656d656e74732d636f756e7420287265676973747261722d636f756e742d626f756e6465642984202d204f6e652062616c616e63652072657365727665206f7065726174696f6e2e2501202d204f6e652073746f72616765206d75746174696f6e2028636f6465632d7265616420604f285827202b205229602c20636f6465632d777269746520604f2858202b20522960292e34202d204f6e65206576656e742e302023203c2f7765696768743e207365745f73756273041073756273645665633c28543a3a4163636f756e7449642c2044617461293e54902053657420746865207375622d6163636f756e7473206f66207468652073656e6465722e005901205061796d656e743a20416e79206167677265676174652062616c616e63652072657365727665642062792070726576696f757320607365745f73756273602063616c6c732077696c6c2062652072657475726e6564310120616e6420616e20616d6f756e7420605375624163636f756e744465706f736974602077696c6c20626520726573657276656420666f722065616368206974656d20696e206073756273602e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061207265676973746572656428206964656e746974792e00b4202d206073756273603a20546865206964656e74697479277320286e657729207375622d6163636f756e74732e002c2023203c7765696768743e34202d20604f2850202b20532960e82020202d20776865726520605060206f6c642d737562732d636f756e742028686172642d20616e64206465706f7369742d626f756e646564292ed82020202d2077686572652060536020737562732d636f756e742028686172642d20616e64206465706f7369742d626f756e646564292e88202d204174206d6f7374206f6e652062616c616e6365206f7065726174696f6e732e18202d2044423ae02020202d206050202b2053602073746f72616765206d75746174696f6e732028636f64656320636f6d706c657869747920604f2831296029c02020202d204f6e652073746f7261676520726561642028636f64656320636f6d706c657869747920604f28502960292ec42020202d204f6e652073746f726167652077726974652028636f64656320636f6d706c657869747920604f28532960292ed42020202d204f6e652073746f726167652d6578697374732028604964656e746974794f663a3a636f6e7461696e735f6b657960292e302023203c2f7765696768743e38636c6561725f6964656e7469747900483d0120436c65617220616e206163636f756e742773206964656e7469747920696e666f20616e6420616c6c207375622d6163636f756e747320616e642072657475726e20616c6c206465706f736974732e00f0205061796d656e743a20416c6c2072657365727665642062616c616e636573206f6e20746865206163636f756e74206172652072657475726e65642e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061207265676973746572656428206964656e746974792e009c20456d69747320604964656e74697479436c656172656460206966207375636365737366756c2e002c2023203c7765696768743e44202d20604f2852202b2053202b20582960d02020202d20776865726520605260207265676973747261722d636f756e742028676f7665726e616e63652d626f756e646564292ed82020202d2077686572652060536020737562732d636f756e742028686172642d20616e64206465706f7369742d626f756e646564292e25012020202d20776865726520605860206164646974696f6e616c2d6669656c642d636f756e7420286465706f7369742d626f756e64656420616e6420636f64652d626f756e646564292e8c202d204f6e652062616c616e63652d756e72657365727665206f7065726174696f6e2ecc202d206032602073746f7261676520726561647320616e64206053202b2032602073746f726167652064656c6574696f6e732e34202d204f6e65206576656e742e302023203c2f7765696768743e44726571756573745f6a756467656d656e7408247265675f696e6465785c436f6d706163743c526567697374726172496e6465783e1c6d61785f66656554436f6d706163743c42616c616e63654f663c543e3e5c9820526571756573742061206a756467656d656e742066726f6d2061207265676973747261722e005901205061796d656e743a204174206d6f737420606d61785f666565602077696c6c20626520726573657276656420666f72207061796d656e7420746f2074686520726567697374726172206966206a756467656d656e741c20676976656e2e00390120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061542072656769737465726564206964656e746974792e002101202d20607265675f696e646578603a2054686520696e646578206f6620746865207265676973747261722077686f7365206a756467656d656e74206973207265717565737465642e5901202d20606d61785f666565603a20546865206d6178696d756d206665652074686174206d617920626520706169642e20546869732073686f756c64206a757374206265206175746f2d706f70756c617465642061733a0034206060606e6f636f6d70696c65bc2053656c663a3a7265676973747261727328292e676574287265675f696e646578292e756e7772617028292e666565102060606000a820456d69747320604a756467656d656e7452657175657374656460206966207375636365737366756c2e002c2023203c7765696768743e38202d20604f2852202b205829602e84202d204f6e652062616c616e63652d72657365727665206f7065726174696f6e2ebc202d2053746f726167653a2031207265616420604f285229602c2031206d757461746520604f2858202b205229602e34202d204f6e65206576656e742e302023203c2f7765696768743e3863616e63656c5f7265717565737404247265675f696e64657838526567697374726172496e646578446c2043616e63656c20612070726576696f757320726571756573742e00fc205061796d656e743a20412070726576696f75736c79207265736572766564206465706f7369742069732072657475726e6564206f6e20737563636573732e00390120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652061542072656769737465726564206964656e746974792e004901202d20607265675f696e646578603a2054686520696e646578206f6620746865207265676973747261722077686f7365206a756467656d656e74206973206e6f206c6f6e676572207265717565737465642e00b020456d69747320604a756467656d656e74556e72657175657374656460206966207375636365737366756c2e002c2023203c7765696768743e38202d20604f2852202b205829602e84202d204f6e652062616c616e63652d72657365727665206f7065726174696f6e2e8c202d204f6e652073746f72616765206d75746174696f6e20604f2852202b205829602e30202d204f6e65206576656e74302023203c2f7765696768743e1c7365745f6665650814696e6465785c436f6d706163743c526567697374726172496e6465783e0c66656554436f6d706163743c42616c616e63654f663c543e3e341d0120536574207468652066656520726571756972656420666f722061206a756467656d656e7420746f206265207265717565737465642066726f6d2061207265676973747261722e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74a4206f6620746865207265676973747261722077686f736520696e6465782069732060696e646578602e00f8202d2060696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f73652066656520697320746f206265207365742e58202d2060666565603a20746865206e6577206665652e002c2023203c7765696768743e28202d20604f285229602e7c202d204f6e652073746f72616765206d75746174696f6e20604f285229602ee8202d2042656e63686d61726b3a20372e333135202b2052202a20302e33323920c2b57320286d696e207371756172657320616e616c7973697329302023203c2f7765696768743e387365745f6163636f756e745f69640814696e6465785c436f6d706163743c526567697374726172496e6465783e0c6e657730543a3a4163636f756e74496434c0204368616e676520746865206163636f756e74206173736f63696174656420776974682061207265676973747261722e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74a4206f6620746865207265676973747261722077686f736520696e6465782069732060696e646578602e00f8202d2060696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f73652066656520697320746f206265207365742e74202d20606e6577603a20746865206e6577206163636f756e742049442e002c2023203c7765696768743e28202d20604f285229602e7c202d204f6e652073746f72616765206d75746174696f6e20604f285229602ee4202d2042656e63686d61726b3a20382e383233202b2052202a20302e333220c2b57320286d696e207371756172657320616e616c7973697329302023203c2f7765696768743e287365745f6669656c64730814696e6465785c436f6d706163743c526567697374726172496e6465783e186669656c6473384964656e746974794669656c647334ac2053657420746865206669656c6420696e666f726d6174696f6e20666f722061207265676973747261722e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74a4206f6620746865207265676973747261722077686f736520696e6465782069732060696e646578602e00f8202d2060696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f73652066656520697320746f206265207365742e1101202d20606669656c6473603a20746865206669656c64732074686174207468652072656769737472617220636f6e6365726e73207468656d73656c76657320776974682e002c2023203c7765696768743e28202d20604f285229602e7c202d204f6e652073746f72616765206d75746174696f6e20604f285229602ee8202d2042656e63686d61726b3a20372e343634202b2052202a20302e33323520c2b57320286d696e207371756172657320616e616c7973697329302023203c2f7765696768743e4470726f766964655f6a756467656d656e740c247265675f696e6465785c436f6d706163743c526567697374726172496e6465783e187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365246a756467656d656e745c4a756467656d656e743c42616c616e63654f663c543e3e4cbc2050726f766964652061206a756467656d656e7420666f7220616e206163636f756e742773206964656e746974792e00590120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420626520746865206163636f756e74b4206f6620746865207265676973747261722077686f736520696e64657820697320607265675f696e646578602e002501202d20607265675f696e646578603a2074686520696e646578206f6620746865207265676973747261722077686f7365206a756467656d656e74206973206265696e67206d6164652e5901202d2060746172676574603a20746865206163636f756e742077686f7365206964656e7469747920746865206a756467656d656e742069732075706f6e2e2054686973206d75737420626520616e206163636f756e74782020207769746820612072656769737465726564206964656e746974792e4d01202d20606a756467656d656e74603a20746865206a756467656d656e74206f662074686520726567697374726172206f6620696e64657820607265675f696e646578602061626f75742060746172676574602e009820456d69747320604a756467656d656e74476976656e60206966207375636365737366756c2e002c2023203c7765696768743e38202d20604f2852202b205829602e88202d204f6e652062616c616e63652d7472616e73666572206f7065726174696f6e2e98202d20557020746f206f6e65206163636f756e742d6c6f6f6b7570206f7065726174696f6e2ebc202d2053746f726167653a2031207265616420604f285229602c2031206d757461746520604f2852202b205829602e34202d204f6e65206576656e742e302023203c2f7765696768743e346b696c6c5f6964656e7469747904187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263654c45012052656d6f766520616e206163636f756e742773206964656e7469747920616e64207375622d6163636f756e7420696e666f726d6174696f6e20616e6420736c61736820746865206465706f736974732e006501205061796d656e743a2052657365727665642062616c616e6365732066726f6d20607365745f737562736020616e6420607365745f6964656e74697479602061726520736c617368656420616e642068616e646c656420627949012060536c617368602e20566572696669636174696f6e2072657175657374206465706f7369747320617265206e6f742072657475726e65643b20746865792073686f756c642062652063616e63656c6c656484206d616e75616c6c79207573696e67206063616e63656c5f72657175657374602e00fc20546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206d617463682060543a3a466f7263654f726967696e602e005901202d2060746172676574603a20746865206163636f756e742077686f7365206964656e7469747920746865206a756467656d656e742069732075706f6e2e2054686973206d75737420626520616e206163636f756e74782020207769746820612072656769737465726564206964656e746974792e009820456d69747320604964656e746974794b696c6c656460206966207375636365737366756c2e002c2023203c7765696768743e48202d20604f2852202b2053202b205829602e84202d204f6e652062616c616e63652d72657365727665206f7065726174696f6e2e74202d206053202b2032602073746f72616765206d75746174696f6e732e34202d204f6e65206576656e742e302023203c2f7765696768743e1c6164645f737562080c7375628c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365106461746110446174611cb0204164642074686520676976656e206163636f756e7420746f207468652073656e646572277320737562732e006101205061796d656e743a2042616c616e636520726573657276656420627920612070726576696f757320607365745f73756273602063616c6c20666f72206f6e65207375622077696c6c2062652072657061747269617465643c20746f207468652073656e6465722e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573742068617665206120726567697374657265645c20737562206964656e74697479206f662060737562602e2872656e616d655f737562080c7375628c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651064617461104461746110d020416c74657220746865206173736f636961746564206e616d65206f662074686520676976656e207375622d6163636f756e742e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573742068617665206120726567697374657265645c20737562206964656e74697479206f662060737562602e2872656d6f76655f737562040c7375628c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651cc42052656d6f76652074686520676976656e206163636f756e742066726f6d207468652073656e646572277320737562732e006101205061796d656e743a2042616c616e636520726573657276656420627920612070726576696f757320607365745f73756273602063616c6c20666f72206f6e65207375622077696c6c2062652072657061747269617465643c20746f207468652073656e6465722e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d7573742068617665206120726567697374657265645c20737562206964656e74697479206f662060737562602e20717569745f7375620028902052656d6f7665207468652073656e6465722061732061207375622d6163636f756e742e006101205061796d656e743a2042616c616e636520726573657276656420627920612070726576696f757320607365745f73756273602063616c6c20666f72206f6e65207375622077696c6c206265207265706174726961746564b820746f207468652073656e64657220282a6e6f742a20746865206f726967696e616c206465706f7369746f72292e00650120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d757374206861766520612072656769737465726564402073757065722d6964656e746974792e004901204e4f54453a20546869732073686f756c64206e6f74206e6f726d616c6c7920626520757365642c206275742069732070726f766964656420696e207468652063617365207468617420746865206e6f6e2d150120636f6e74726f6c6c6572206f6620616e206163636f756e74206973206d616c6963696f75736c7920726567697374657265642061732061207375622d6163636f756e742e01282c4964656e7469747953657404244163636f756e7449640411012041206e616d652077617320736574206f72207265736574202877686963682077696c6c2072656d6f766520616c6c206a756467656d656e7473292e205c5b77686f5c5d3c4964656e74697479436c656172656408244163636f756e7449641c42616c616e63650415012041206e616d652077617320636c65617265642c20616e642074686520676976656e2062616c616e63652072657475726e65642e205c5b77686f2c206465706f7369745c5d384964656e746974794b696c6c656408244163636f756e7449641c42616c616e6365040d012041206e616d65207761732072656d6f76656420616e642074686520676976656e2062616c616e636520736c61736865642e205c5b77686f2c206465706f7369745c5d484a756467656d656e7452657175657374656408244163636f756e74496438526567697374726172496e6465780405012041206a756467656d656e74207761732061736b65642066726f6d2061207265676973747261722e205c5b77686f2c207265676973747261725f696e6465785c5d504a756467656d656e74556e72657175657374656408244163636f756e74496438526567697374726172496e64657804f02041206a756467656d656e74207265717565737420776173207265747261637465642e205c5b77686f2c207265676973747261725f696e6465785c5d384a756467656d656e74476976656e08244163636f756e74496438526567697374726172496e6465780409012041206a756467656d656e742077617320676976656e2062792061207265676973747261722e205c5b7461726765742c207265676973747261725f696e6465785c5d3852656769737472617241646465640438526567697374726172496e64657804ac204120726567697374726172207761732061646465642e205c5b7265676973747261725f696e6465785c5d405375624964656e7469747941646465640c244163636f756e744964244163636f756e7449641c42616c616e63650455012041207375622d6964656e746974792077617320616464656420746f20616e206964656e7469747920616e6420746865206465706f73697420706169642e205c5b7375622c206d61696e2c206465706f7369745c5d485375624964656e7469747952656d6f7665640c244163636f756e744964244163636f756e7449641c42616c616e6365080d012041207375622d6964656e74697479207761732072656d6f7665642066726f6d20616e206964656e7469747920616e6420746865206465706f7369742066726565642e5c205c5b7375622c206d61696e2c206465706f7369745c5d485375624964656e746974795265766f6b65640c244163636f756e744964244163636f756e7449641c42616c616e6365081d012041207375622d6964656e746974792077617320636c65617265642c20616e642074686520676976656e206465706f7369742072657061747269617465642066726f6d207468652901206d61696e206964656e74697479206163636f756e7420746f20746865207375622d6964656e74697479206163636f756e742e205c5b7375622c206d61696e2c206465706f7369745c5d183042617369634465706f7369743042616c616e63654f663c543e4000a0724e18090000000000000000000004d82054686520616d6f756e742068656c64206f6e206465706f73697420666f7220612072656769737465726564206964656e746974792e304669656c644465706f7369743042616c616e63654f663c543e4000a89c13460200000000000000000000042d012054686520616d6f756e742068656c64206f6e206465706f73697420706572206164646974696f6e616c206669656c6420666f7220612072656769737465726564206964656e746974792e445375624163636f756e744465706f7369743042616c616e63654f663c543e4000204aa9d101000000000000000000000c65012054686520616d6f756e742068656c64206f6e206465706f73697420666f7220612072656769737465726564207375626163636f756e742e20546869732073686f756c64206163636f756e7420666f7220746865206661637471012074686174206f6e652073746f72616765206974656d27732076616c75652077696c6c20696e637265617365206279207468652073697a65206f6620616e206163636f756e742049442c20616e642074686572652077696c6c206265290120616e6f746865722074726965206974656d2077686f73652076616c7565206973207468652073697a65206f6620616e206163636f756e7420494420706c75732033322062797465732e384d61785375624163636f756e74730c7533321064000000040d0120546865206d6178696d756d206e756d626572206f66207375622d6163636f756e747320616c6c6f77656420706572206964656e746966696564206163636f756e742e4c4d61784164646974696f6e616c4669656c64730c7533321064000000086501204d6178696d756d206e756d626572206f66206164646974696f6e616c206669656c64732074686174206d61792062652073746f72656420696e20616e2049442e204e656564656420746f20626f756e642074686520492f4fe020726571756972656420746f2061636365737320616e206964656e746974792c206275742063616e2062652070726574747920686967682e344d6178526567697374726172730c7533321014000000085101204d61786d696d756d206e756d626572206f66207265676973747261727320616c6c6f77656420696e207468652073797374656d2e204e656564656420746f20626f756e642074686520636f6d706c65786974797c206f662c20652e672e2c207570646174696e67206a756467656d656e74732e4048546f6f4d616e795375624163636f756e7473046020546f6f206d616e7920737562732d6163636f756e74732e204e6f74466f756e640454204163636f756e742069736e277420666f756e642e204e6f744e616d65640454204163636f756e742069736e2774206e616d65642e28456d707479496e646578043420456d70747920696e6465782e284665654368616e676564044020466565206973206368616e6765642e284e6f4964656e74697479044c204e6f206964656e7469747920666f756e642e3c537469636b794a756467656d656e74044820537469636b79206a756467656d656e742e384a756467656d656e74476976656e0444204a756467656d656e7420676976656e2e40496e76616c69644a756467656d656e74044c20496e76616c6964206a756467656d656e742e30496e76616c6964496e64657804582054686520696e64657820697320696e76616c69642e34496e76616c6964546172676574045c205468652074617267657420697320696e76616c69642e34546f6f4d616e794669656c6473047020546f6f206d616e79206164646974696f6e616c206669656c64732e44546f6f4d616e795265676973747261727304ec204d6178696d756d20616d6f756e74206f66207265676973747261727320726561636865642e2043616e6e6f742061646420616e79206d6f72652e38416c7265616479436c61696d65640474204163636f756e7420494420697320616c7265616479206e616d65642e184e6f7453756204742053656e646572206973206e6f742061207375622d6163636f756e742e204e6f744f776e6564048c205375622d6163636f756e742069736e2774206f776e65642062792073656e6465722e11205265636f7665727901205265636f766572790c2c5265636f76657261626c6500010530543a3a4163636f756e744964e85265636f76657279436f6e6669673c543a3a426c6f636b4e756d6265722c2042616c616e63654f663c543e2c20543a3a4163636f756e7449643e0004000409012054686520736574206f66207265636f76657261626c65206163636f756e747320616e64207468656972207265636f7665727920636f6e66696775726174696f6e2e404163746976655265636f76657269657300020530543a3a4163636f756e74496430543a3a4163636f756e744964e84163746976655265636f766572793c543a3a426c6f636b4e756d6265722c2042616c616e63654f663c543e2c20543a3a4163636f756e7449643e050400106820416374697665207265636f7665727920617474656d7074732e001501204669727374206163636f756e7420697320746865206163636f756e7420746f206265207265636f76657265642c20616e6420746865207365636f6e64206163636f756e74ac20697320746865207573657220747279696e6720746f207265636f76657220746865206163636f756e742e1450726f787900010230543a3a4163636f756e74496430543a3a4163636f756e7449640004000c9020546865206c697374206f6620616c6c6f7765642070726f7879206163636f756e74732e00f8204d61702066726f6d2074686520757365722077686f2063616e2061636365737320697420746f20746865207265636f7665726564206163636f756e742e01243061735f7265636f7665726564081c6163636f756e7430543a3a4163636f756e7449641063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e34a42053656e6420612063616c6c207468726f7567682061207265636f7665726564206163636f756e742e00150120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207265676973746572656420746fe82062652061626c6520746f206d616b652063616c6c73206f6e20626568616c66206f6620746865207265636f7665726564206163636f756e742e003020506172616d65746572733a2501202d20606163636f756e74603a20546865207265636f7665726564206163636f756e7420796f752077616e7420746f206d616b6520612063616c6c206f6e2d626568616c662d6f662e0101202d206063616c6c603a205468652063616c6c20796f752077616e7420746f206d616b65207769746820746865207265636f7665726564206163636f756e742e002c2023203c7765696768743e94202d2054686520776569676874206f6620746865206063616c6c60202b2031302c3030302e0901202d204f6e652073746f72616765206c6f6f6b757020746f20636865636b206163636f756e74206973207265636f7665726564206279206077686f602e204f283129302023203c2f7765696768743e347365745f7265636f766572656408106c6f737430543a3a4163636f756e7449641c7265736375657230543a3a4163636f756e744964341d0120416c6c6f7720524f4f5420746f2062797061737320746865207265636f766572792070726f6365737320616e642073657420616e20612072657363756572206163636f756e747420666f722061206c6f7374206163636f756e74206469726563746c792e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f524f4f545f2e003020506172616d65746572733ab8202d20606c6f7374603a2054686520226c6f7374206163636f756e742220746f206265207265636f76657265642e1d01202d206072657363756572603a20546865202272657363756572206163636f756e74222077686963682063616e2063616c6c20617320746865206c6f7374206163636f756e742e002c2023203c7765696768743e64202d204f6e652073746f72616765207772697465204f28312930202d204f6e65206576656e74302023203c2f7765696768743e3c6372656174655f7265636f766572790c1c667269656e6473445665633c543a3a4163636f756e7449643e247468726573686f6c640c7531363064656c61795f706572696f6438543a3a426c6f636b4e756d6265726c5d01204372656174652061207265636f7665727920636f6e66696775726174696f6e20666f7220796f7572206163636f756e742e2054686973206d616b657320796f7572206163636f756e74207265636f76657261626c652e003101205061796d656e743a2060436f6e6669674465706f7369744261736560202b2060467269656e644465706f736974466163746f7260202a20235f6f665f667269656e64732062616c616e636549012077696c6c20626520726573657276656420666f722073746f72696e6720746865207265636f7665727920636f6e66696775726174696f6e2e2054686973206465706f7369742069732072657475726e6564bc20696e2066756c6c207768656e2074686520757365722063616c6c73206072656d6f76655f7265636f76657279602e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a2501202d2060667269656e6473603a2041206c697374206f6620667269656e647320796f7520747275737420746f20766f75636820666f72207265636f7665727920617474656d7074732ed420202053686f756c64206265206f72646572656420616e6420636f6e7461696e206e6f206475706c69636174652076616c7565732e3101202d20607468726573686f6c64603a20546865206e756d626572206f6620667269656e64732074686174206d75737420766f75636820666f722061207265636f7665727920617474656d70741d012020206265666f726520746865206163636f756e742063616e206265207265636f76657265642e2053686f756c64206265206c657373207468616e206f7220657175616c20746f94202020746865206c656e677468206f6620746865206c697374206f6620667269656e64732e3d01202d206064656c61795f706572696f64603a20546865206e756d626572206f6620626c6f636b732061667465722061207265636f7665727920617474656d707420697320696e697469616c697a6564e820202074686174206e6565647320746f2070617373206265666f726520746865206163636f756e742063616e206265207265636f76657265642e002c2023203c7765696768743e68202d204b65793a204620286c656e206f6620667269656e6473292d01202d204f6e652073746f72616765207265616420746f20636865636b2074686174206163636f756e74206973206e6f7420616c7265616479207265636f76657261626c652e204f2831292eec202d204120636865636b20746861742074686520667269656e6473206c69737420697320736f7274656420616e6420756e697175652e204f2846299c202d204f6e652063757272656e63792072657365727665206f7065726174696f6e2e204f2858299c202d204f6e652073746f726167652077726974652e204f2831292e20436f646563204f2846292e34202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2846202b205829302023203c2f7765696768743e44696e6974696174655f7265636f76657279041c6163636f756e7430543a3a4163636f756e74496458ec20496e697469617465207468652070726f6365737320666f72207265636f766572696e672061207265636f76657261626c65206163636f756e742e001d01205061796d656e743a20605265636f766572794465706f736974602062616c616e63652077696c6c20626520726573657276656420666f7220696e6974696174696e67207468652501207265636f766572792070726f636573732e2054686973206465706f7369742077696c6c20616c7761797320626520726570617472696174656420746f20746865206163636f756e74b820747279696e6720746f206265207265636f76657265642e205365652060636c6f73655f7265636f76657279602e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1501202d20606163636f756e74603a20546865206c6f7374206163636f756e74207468617420796f752077616e7420746f207265636f7665722e2054686973206163636f756e7401012020206e6565647320746f206265207265636f76657261626c652028692e652e20686176652061207265636f7665727920636f6e66696775726174696f6e292e002c2023203c7765696768743ef8202d204f6e652073746f72616765207265616420746f20636865636b2074686174206163636f756e74206973207265636f76657261626c652e204f2846295101202d204f6e652073746f72616765207265616420746f20636865636b20746861742074686973207265636f766572792070726f63657373206861736e277420616c726561647920737461727465642e204f2831299c202d204f6e652063757272656e63792072657365727665206f7065726174696f6e2e204f285829e4202d204f6e652073746f72616765207265616420746f20676574207468652063757272656e7420626c6f636b206e756d6265722e204f2831296c202d204f6e652073746f726167652077726974652e204f2831292e34202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2846202b205829302023203c2f7765696768743e38766f7563685f7265636f7665727908106c6f737430543a3a4163636f756e7449641c7265736375657230543a3a4163636f756e74496464290120416c6c6f7720612022667269656e6422206f662061207265636f76657261626c65206163636f756e7420746f20766f75636820666f7220616e20616374697665207265636f76657279682070726f6365737320666f722074686174206163636f756e742e00290120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64206d75737420626520612022667269656e64227420666f7220746865207265636f76657261626c65206163636f756e742e003020506172616d65746572733ad4202d20606c6f7374603a20546865206c6f7374206163636f756e74207468617420796f752077616e7420746f207265636f7665722e1101202d206072657363756572603a20546865206163636f756e7420747279696e6720746f2072657363756520746865206c6f7374206163636f756e74207468617420796f755420202077616e7420746f20766f75636820666f722e0025012054686520636f6d62696e6174696f6e206f662074686573652074776f20706172616d6574657273206d75737420706f696e7420746f20616e20616374697665207265636f76657279242070726f636573732e002c2023203c7765696768743efc204b65793a204620286c656e206f6620667269656e647320696e20636f6e666967292c205620286c656e206f6620766f756368696e6720667269656e6473291d01202d204f6e652073746f72616765207265616420746f2067657420746865207265636f7665727920636f6e66696775726174696f6e2e204f2831292c20436f646563204f2846292101202d204f6e652073746f72616765207265616420746f206765742074686520616374697665207265636f766572792070726f636573732e204f2831292c20436f646563204f285629ec202d204f6e652062696e6172792073656172636820746f20636f6e6669726d2063616c6c6572206973206120667269656e642e204f286c6f6746291d01202d204f6e652062696e6172792073656172636820746f20636f6e6669726d2063616c6c657220686173206e6f7420616c726561647920766f75636865642e204f286c6f6756299c202d204f6e652073746f726167652077726974652e204f2831292c20436f646563204f2856292e34202d204f6e65206576656e742e00a420546f74616c20436f6d706c65786974793a204f2846202b206c6f6746202b2056202b206c6f675629302023203c2f7765696768743e38636c61696d5f7265636f76657279041c6163636f756e7430543a3a4163636f756e74496450f420416c6c6f772061207375636365737366756c207265736375657220746f20636c61696d207468656972207265636f7665726564206163636f756e742e002d0120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64206d7573742062652061202272657363756572221d012077686f20686173207375636365737366756c6c7920636f6d706c6574656420746865206163636f756e74207265636f766572792070726f636573733a20636f6c6c6563746564310120607468726573686f6c6460206f72206d6f726520766f75636865732c20776169746564206064656c61795f706572696f646020626c6f636b732073696e636520696e6974696174696f6e2e003020506172616d65746572733a2d01202d20606163636f756e74603a20546865206c6f7374206163636f756e74207468617420796f752077616e7420746f20636c61696d20686173206265656e207375636365737366756c6c79502020207265636f766572656420627920796f752e002c2023203c7765696768743efc204b65793a204620286c656e206f6620667269656e647320696e20636f6e666967292c205620286c656e206f6620766f756368696e6720667269656e6473291d01202d204f6e652073746f72616765207265616420746f2067657420746865207265636f7665727920636f6e66696775726174696f6e2e204f2831292c20436f646563204f2846292101202d204f6e652073746f72616765207265616420746f206765742074686520616374697665207265636f766572792070726f636573732e204f2831292c20436f646563204f285629e4202d204f6e652073746f72616765207265616420746f20676574207468652063757272656e7420626c6f636b206e756d6265722e204f2831299c202d204f6e652073746f726167652077726974652e204f2831292c20436f646563204f2856292e34202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2846202b205629302023203c2f7765696768743e38636c6f73655f7265636f76657279041c7265736375657230543a3a4163636f756e7449645015012041732074686520636f6e74726f6c6c6572206f662061207265636f76657261626c65206163636f756e742c20636c6f736520616e20616374697665207265636f76657279682070726f6365737320666f7220796f7572206163636f756e742e002101205061796d656e743a2042792063616c6c696e6720746869732066756e6374696f6e2c20746865207265636f76657261626c65206163636f756e742077696c6c2072656365697665f820746865207265636f76657279206465706f73697420605265636f766572794465706f7369746020706c616365642062792074686520726573637565722e00050120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64206d7573742062652061f0207265636f76657261626c65206163636f756e74207769746820616e20616374697665207265636f766572792070726f6365737320666f722069742e003020506172616d65746572733a1101202d206072657363756572603a20546865206163636f756e7420747279696e6720746f207265736375652074686973207265636f76657261626c65206163636f756e742e002c2023203c7765696768743e84204b65793a205620286c656e206f6620766f756368696e6720667269656e6473293d01202d204f6e652073746f7261676520726561642f72656d6f766520746f206765742074686520616374697665207265636f766572792070726f636573732e204f2831292c20436f646563204f285629c0202d204f6e652062616c616e63652063616c6c20746f20726570617472696174652072657365727665642e204f28582934202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2856202b205829302023203c2f7765696768743e3c72656d6f76655f7265636f7665727900545d012052656d6f766520746865207265636f766572792070726f6365737320666f7220796f7572206163636f756e742e205265636f7665726564206163636f756e747320617265207374696c6c2061636365737369626c652e001501204e4f54453a205468652075736572206d757374206d616b65207375726520746f2063616c6c2060636c6f73655f7265636f7665727960206f6e20616c6c206163746976650901207265636f7665727920617474656d707473206265666f72652063616c6c696e6720746869732066756e6374696f6e20656c73652069742077696c6c206661696c2e002501205061796d656e743a2042792063616c6c696e6720746869732066756e6374696f6e20746865207265636f76657261626c65206163636f756e742077696c6c20756e7265736572766598207468656972207265636f7665727920636f6e66696775726174696f6e206465706f7369742ef4202860436f6e6669674465706f7369744261736560202b2060467269656e644465706f736974466163746f7260202a20235f6f665f667269656e64732900050120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64206d7573742062652061e4207265636f76657261626c65206163636f756e742028692e652e206861732061207265636f7665727920636f6e66696775726174696f6e292e002c2023203c7765696768743e60204b65793a204620286c656e206f6620667269656e6473292901202d204f6e652073746f72616765207265616420746f206765742074686520707265666978206974657261746f7220666f7220616374697665207265636f7665726965732e204f2831293901202d204f6e652073746f7261676520726561642f72656d6f766520746f2067657420746865207265636f7665727920636f6e66696775726174696f6e2e204f2831292c20436f646563204f2846299c202d204f6e652062616c616e63652063616c6c20746f20756e72657365727665642e204f28582934202d204f6e65206576656e742e006c20546f74616c20436f6d706c65786974793a204f2846202b205829302023203c2f7765696768743e4063616e63656c5f7265636f7665726564041c6163636f756e7430543a3a4163636f756e7449642ce02043616e63656c20746865206162696c69747920746f20757365206061735f7265636f76657265646020666f7220606163636f756e74602e00150120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207265676973746572656420746fe82062652061626c6520746f206d616b652063616c6c73206f6e20626568616c66206f6620746865207265636f7665726564206163636f756e742e003020506172616d65746572733a1901202d20606163636f756e74603a20546865207265636f7665726564206163636f756e7420796f75206172652061626c6520746f2063616c6c206f6e2d626568616c662d6f662e002c2023203c7765696768743e1101202d204f6e652073746f72616765206d75746174696f6e20746f20636865636b206163636f756e74206973207265636f7665726564206279206077686f602e204f283129302023203c2f7765696768743e01183c5265636f766572794372656174656404244163636f756e74496404dc2041207265636f766572792070726f6365737320686173206265656e2073657420757020666f7220616e205c5b6163636f756e745c5d2e445265636f76657279496e6974696174656408244163636f756e744964244163636f756e744964082d012041207265636f766572792070726f6365737320686173206265656e20696e6974696174656420666f72206c6f7374206163636f756e742062792072657363756572206163636f756e742e48205c5b6c6f73742c20726573637565725c5d3c5265636f76657279566f75636865640c244163636f756e744964244163636f756e744964244163636f756e744964085d012041207265636f766572792070726f6365737320666f72206c6f7374206163636f756e742062792072657363756572206163636f756e7420686173206265656e20766f756368656420666f722062792073656e6465722e68205c5b6c6f73742c20726573637565722c2073656e6465725c5d385265636f76657279436c6f73656408244163636f756e744964244163636f756e7449640821012041207265636f766572792070726f6365737320666f72206c6f7374206163636f756e742062792072657363756572206163636f756e7420686173206265656e20636c6f7365642e48205c5b6c6f73742c20726573637565725c5d404163636f756e745265636f766572656408244163636f756e744964244163636f756e744964080501204c6f7374206163636f756e7420686173206265656e207375636365737366756c6c79207265636f76657265642062792072657363756572206163636f756e742e48205c5b6c6f73742c20726573637565725c5d3c5265636f7665727952656d6f76656404244163636f756e74496404e02041207265636f766572792070726f6365737320686173206265656e2072656d6f76656420666f7220616e205c5b6163636f756e745c5d2e1044436f6e6669674465706f736974426173653042616c616e63654f663c543e40005039278c040000000000000000000004550120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e672061207265636f7665727920636f6e66696775726174696f6e2e4c467269656e644465706f736974466163746f723042616c616e63654f663c543e400088526a7400000000000000000000000469012054686520616d6f756e74206f662063757272656e6379206e656564656420706572206164646974696f6e616c2075736572207768656e206372656174696e672061207265636f7665727920636f6e66696775726174696f6e2e284d6178467269656e64730c753136080900040d0120546865206d6178696d756d20616d6f756e74206f6620667269656e647320616c6c6f77656420696e2061207265636f7665727920636f6e66696775726174696f6e2e3c5265636f766572794465706f7369743042616c616e63654f663c543e40005039278c0400000000000000000000041d0120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72207374617274696e672061207265636f766572792e44284e6f74416c6c6f77656404f42055736572206973206e6f7420616c6c6f77656420746f206d616b6520612063616c6c206f6e20626568616c66206f662074686973206163636f756e74345a65726f5468726573686f6c640490205468726573686f6c64206d7573742062652067726561746572207468616e207a65726f404e6f74456e6f756768467269656e647304d420467269656e6473206c697374206d7573742062652067726561746572207468616e207a65726f20616e64207468726573686f6c64284d6178467269656e647304ac20467269656e6473206c697374206d757374206265206c657373207468616e206d617820667269656e6473244e6f74536f7274656404cc20467269656e6473206c697374206d75737420626520736f7274656420616e642066726565206f66206475706c696361746573384e6f745265636f76657261626c6504a02054686973206163636f756e74206973206e6f742073657420757020666f72207265636f7665727948416c72656164795265636f76657261626c6504b02054686973206163636f756e7420697320616c72656164792073657420757020666f72207265636f7665727938416c72656164795374617274656404e02041207265636f766572792070726f636573732068617320616c7265616479207374617274656420666f722074686973206163636f756e74284e6f745374617274656404d02041207265636f766572792070726f6365737320686173206e6f74207374617274656420666f7220746869732072657363756572244e6f74467269656e6404ac2054686973206163636f756e74206973206e6f74206120667269656e642077686f2063616e20766f7563682c44656c6179506572696f64041d012054686520667269656e64206d757374207761697420756e74696c207468652064656c617920706572696f6420746f20766f75636820666f722074686973207265636f7665727938416c7265616479566f756368656404c0205468697320757365722068617320616c726561647920766f756368656420666f722074686973207265636f76657279245468726573686f6c6404ec20546865207468726573686f6c6420666f72207265636f766572696e672074686973206163636f756e7420686173206e6f74206265656e206d65742c5374696c6c41637469766504010120546865726520617265207374696c6c20616374697665207265636f7665727920617474656d7074732074686174206e65656420746f20626520636c6f736564204f766572666c6f77049c2054686572652077617320616e206f766572666c6f7720696e20612063616c63756c6174696f6e30416c726561647950726f787904b02054686973206163636f756e7420697320616c72656164792073657420757020666f72207265636f76657279204261645374617465047c20536f6d6520696e7465726e616c2073746174652069732062726f6b656e2e121c56657374696e67011c56657374696e67041c56657374696e6700010230543a3a4163636f756e744964a456657374696e67496e666f3c42616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e00040004d820496e666f726d6174696f6e20726567617264696e67207468652076657374696e67206f66206120676976656e206163636f756e742e011010766573740034bc20556e6c6f636b20616e79207665737465642066756e6473206f66207468652073656e646572206163636f756e742e00610120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e64207468652073656e646572206d75737420686176652066756e6473207374696c6c68206c6f636b656420756e64657220746869732070616c6c65742e00d420456d69747320656974686572206056657374696e67436f6d706c6574656460206f72206056657374696e6755706461746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20322052656164732c203220577269746573fc20202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c205b53656e646572204163636f756e745d010120202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c205b53656e646572204163636f756e745d302023203c2f7765696768743e28766573745f6f7468657204187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263653cbc20556e6c6f636b20616e79207665737465642066756e6473206f662061206074617267657460206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005501202d2060746172676574603a20546865206163636f756e742077686f7365207665737465642066756e64732073686f756c6420626520756e6c6f636b65642e204d75737420686176652066756e6473207374696c6c68206c6f636b656420756e64657220746869732070616c6c65742e00d420456d69747320656974686572206056657374696e67436f6d706c6574656460206f72206056657374696e6755706461746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20332052656164732c203320577269746573f420202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e74f820202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e74302023203c2f7765696768743e3c7665737465645f7472616e7366657208187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365207363686564756c65a456657374696e67496e666f3c42616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e406820437265617465206120766573746564207472616e736665722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e001501202d2060746172676574603a20546865206163636f756e7420746861742073686f756c64206265207472616e7366657272656420746865207665737465642066756e64732e0101202d2060616d6f756e74603a2054686520616d6f756e74206f662066756e647320746f207472616e7366657220616e642077696c6c206265207665737465642ef4202d20607363686564756c65603a205468652076657374696e67207363686564756c6520617474616368656420746f20746865207472616e736665722e006020456d697473206056657374696e6743726561746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20332052656164732c2033205772697465733d0120202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c205b53656e646572204163636f756e745d410120202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c205b53656e646572204163636f756e745d302023203c2f7765696768743e54666f7263655f7665737465645f7472616e736665720c18736f757263658c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365187461726765748c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f75726365207363686564756c65a456657374696e67496e666f3c42616c616e63654f663c543e2c20543a3a426c6f636b4e756d6265723e446420466f726365206120766573746564207472616e736665722e00c820546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f526f6f745f2e00ec202d2060736f75726365603a20546865206163636f756e742077686f73652066756e64732073686f756c64206265207472616e736665727265642e1501202d2060746172676574603a20546865206163636f756e7420746861742073686f756c64206265207472616e7366657272656420746865207665737465642066756e64732e0101202d2060616d6f756e74603a2054686520616d6f756e74206f662066756e647320746f207472616e7366657220616e642077696c6c206265207665737465642ef4202d20607363686564756c65603a205468652076657374696e67207363686564756c6520617474616368656420746f20746865207472616e736665722e006020456d697473206056657374696e6743726561746564602e002c2023203c7765696768743e28202d20604f283129602e78202d2044625765696768743a20342052656164732c203420577269746573350120202020202d2052656164733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c20536f75726365204163636f756e74390120202020202d205772697465733a2056657374696e672053746f726167652c2042616c616e636573204c6f636b732c20546172676574204163636f756e742c20536f75726365204163636f756e74302023203c2f7765696768743e01083856657374696e675570646174656408244163636f756e7449641c42616c616e63650c59012054686520616d6f756e742076657374656420686173206265656e20757064617465642e205468697320636f756c6420696e646963617465206d6f72652066756e64732061726520617661696c61626c652e2054686519012062616c616e636520676976656e2069732074686520616d6f756e74207768696368206973206c65667420756e7665737465642028616e642074687573206c6f636b6564292e58205c5b6163636f756e742c20756e7665737465645c5d4056657374696e67436f6d706c6574656404244163636f756e744964041d0120416e205c5b6163636f756e745c5d20686173206265636f6d652066756c6c79207665737465642e204e6f20667572746865722076657374696e672063616e2068617070656e2e04444d696e5665737465645472616e736665723042616c616e63654f663c543e400010a5d4e8000000000000000000000004e820546865206d696e696d756d20616d6f756e74207472616e7366657272656420746f2063616c6c20607665737465645f7472616e73666572602e0c284e6f7456657374696e67048820546865206163636f756e7420676976656e206973206e6f742076657374696e672e5c4578697374696e6756657374696e675363686564756c65045d0120416e206578697374696e672076657374696e67207363686564756c6520616c72656164792065786973747320666f722074686973206163636f756e7420746861742063616e6e6f7420626520636c6f6262657265642e24416d6f756e744c6f7704090120416d6f756e74206265696e67207472616e7366657272656420697320746f6f206c6f7720746f2063726561746520612076657374696e67207363686564756c652e13245363686564756c657201245363686564756c65720c184167656e646101010538543a3a426c6f636b4e756d62657271015665633c4f7074696f6e3c5363686564756c65643c3c5420617320436f6e6669673e3a3a43616c6c2c20543a3a426c6f636b4e756d6265722c20543a3a0a50616c6c6574734f726967696e2c20543a3a4163636f756e7449643e3e3e000400044d01204974656d7320746f2062652065786563757465642c20696e64657865642062792074686520626c6f636b206e756d626572207468617420746865792073686f756c64206265206578656375746564206f6e2e184c6f6f6b75700001051c5665633c75383e6c5461736b416464726573733c543a3a426c6f636b4e756d6265723e000400040101204c6f6f6b75702066726f6d206964656e7469747920746f2074686520626c6f636b206e756d62657220616e6420696e646578206f6620746865207461736b2e3853746f7261676556657273696f6e01002052656c656173657304000c7c2053746f726167652076657273696f6e206f66207468652070616c6c65742e0098204e6577206e6574776f726b732073746172742077697468206c6173742076657273696f6e2e0118207363686564756c6510107768656e38543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e287420416e6f6e796d6f75736c79207363686564756c652061207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c7390202d2042617365205765696768743a2032322e3239202b202e313236202a205320c2b57334202d204442205765696768743a4c20202020202d20526561643a204167656e64615020202020202d2057726974653a204167656e64613d01202d2057696c6c20757365206261736520776569676874206f662032352077686963682073686f756c6420626520676f6f6420666f7220757020746f203330207363686564756c65642063616c6c73302023203c2f7765696768743e1863616e63656c08107768656e38543a3a426c6f636b4e756d62657214696e6465780c75333228982043616e63656c20616e20616e6f6e796d6f75736c79207363686564756c6564207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c7394202d2042617365205765696768743a2032322e3135202b20322e383639202a205320c2b57334202d204442205765696768743a4c20202020202d20526561643a204167656e64617020202020202d2057726974653a204167656e64612c204c6f6f6b75704101202d2057696c6c20757365206261736520776569676874206f66203130302077686963682073686f756c6420626520676f6f6420666f7220757020746f203330207363686564756c65642063616c6c73302023203c2f7765696768743e387363686564756c655f6e616d6564140869641c5665633c75383e107768656e38543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e285c205363686564756c652061206e616d6564207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c738c202d2042617365205765696768743a2032392e36202b202e313539202a205320c2b57334202d204442205765696768743a6c20202020202d20526561643a204167656e64612c204c6f6f6b75707020202020202d2057726974653a204167656e64612c204c6f6f6b75704d01202d2057696c6c20757365206261736520776569676874206f662033352077686963682073686f756c6420626520676f6f6420666f72206d6f7265207468616e203330207363686564756c65642063616c6c73302023203c2f7765696768743e3063616e63656c5f6e616d6564040869641c5665633c75383e287c2043616e63656c2061206e616d6564207363686564756c6564207461736b2e002c2023203c7765696768743ea0202d2053203d204e756d626572206f6620616c7265616479207363686564756c65642063616c6c7394202d2042617365205765696768743a2032342e3931202b20322e393037202a205320c2b57334202d204442205765696768743a6c20202020202d20526561643a204167656e64612c204c6f6f6b75707020202020202d2057726974653a204167656e64612c204c6f6f6b75704101202d2057696c6c20757365206261736520776569676874206f66203130302077686963682073686f756c6420626520676f6f6420666f7220757020746f203330207363686564756c65642063616c6c73302023203c2f7765696768743e387363686564756c655f61667465721014616674657238543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e14ac20416e6f6e796d6f75736c79207363686564756c652061207461736b20616674657220612064656c61792e002c2023203c7765696768743e582053616d65206173205b607363686564756c65605d2e302023203c2f7765696768743e507363686564756c655f6e616d65645f6166746572140869641c5665633c75383e14616674657238543a3a426c6f636b4e756d626572386d617962655f706572696f646963a04f7074696f6e3c7363686564756c653a3a506572696f643c543a3a426c6f636b4e756d6265723e3e207072696f72697479487363686564756c653a3a5072696f726974791063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e1494205363686564756c652061206e616d6564207461736b20616674657220612064656c61792e002c2023203c7765696768743e702053616d65206173205b607363686564756c655f6e616d6564605d2e302023203c2f7765696768743e010c245363686564756c6564082c426c6f636b4e756d6265720c7533320494205363686564756c656420736f6d65207461736b2e205c5b7768656e2c20696e6465785c5d2043616e63656c6564082c426c6f636b4e756d6265720c75333204902043616e63656c656420736f6d65207461736b2e205c5b7768656e2c20696e6465785c5d28446973706174636865640c605461736b416464726573733c426c6f636b4e756d6265723e3c4f7074696f6e3c5665633c75383e3e384469737061746368526573756c7404ac204469737061746368656420736f6d65207461736b2e205c5b7461736b2c2069642c20726573756c745c5d0010404661696c6564546f5363686564756c650468204661696c656420746f207363686564756c6520612063616c6c204e6f74466f756e6404802043616e6e6f742066696e6420746865207363686564756c65642063616c6c2e5c546172676574426c6f636b4e756d626572496e5061737404a820476976656e2074617267657420626c6f636b206e756d62657220697320696e2074686520706173742e4852657363686564756c654e6f4368616e676504f42052657363686564756c65206661696c6564206265636175736520697420646f6573206e6f74206368616e6765207363686564756c65642074696d652e14105375646f01105375646f040c4b6579010030543a3a4163636f756e74496480000000000000000000000000000000000000000000000000000000000000000004842054686520604163636f756e74496460206f6620746865207375646f206b65792e0110107375646f041063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e2839012041757468656e7469636174657320746865207375646f206b657920616e64206469737061746368657320612066756e6374696f6e2063616c6c20776974682060526f6f7460206f726967696e2e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002c2023203c7765696768743e20202d204f2831292e64202d204c696d697465642073746f726167652072656164732e60202d204f6e6520444220777269746520286576656e74292ec8202d20576569676874206f662064657269766174697665206063616c6c6020657865637574696f6e202b2031302c3030302e302023203c2f7765696768743e547375646f5f756e636865636b65645f776569676874081063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e1c5f776569676874185765696768742839012041757468656e7469636174657320746865207375646f206b657920616e64206469737061746368657320612066756e6374696f6e2063616c6c20776974682060526f6f7460206f726967696e2e310120546869732066756e6374696f6e20646f6573206e6f7420636865636b2074686520776569676874206f66207468652063616c6c2c20616e6420696e737465616420616c6c6f777320746865b4205375646f207573657220746f20737065636966792074686520776569676874206f66207468652063616c6c2e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002c2023203c7765696768743e20202d204f2831292ed0202d2054686520776569676874206f6620746869732063616c6c20697320646566696e6564206279207468652063616c6c65722e302023203c2f7765696768743e1c7365745f6b6579040c6e65778c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263652475012041757468656e74696361746573207468652063757272656e74207375646f206b657920616e6420736574732074686520676976656e204163636f756e7449642028606e6577602920617320746865206e6577207375646f206b65792e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002c2023203c7765696768743e20202d204f2831292e64202d204c696d697465642073746f726167652072656164732e44202d204f6e65204442206368616e67652e302023203c2f7765696768743e1c7375646f5f6173080c77686f8c3c543a3a4c6f6f6b7570206173205374617469634c6f6f6b75703e3a3a536f757263651063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e2c51012041757468656e7469636174657320746865207375646f206b657920616e64206469737061746368657320612066756e6374696f6e2063616c6c207769746820605369676e656460206f726967696e2066726f6d44206120676976656e206163636f756e742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e002c2023203c7765696768743e20202d204f2831292e64202d204c696d697465642073746f726167652072656164732e60202d204f6e6520444220777269746520286576656e74292ec8202d20576569676874206f662064657269766174697665206063616c6c6020657865637574696f6e202b2031302c3030302e302023203c2f7765696768743e010c14537564696404384469737061746368526573756c74048c2041207375646f206a75737420746f6f6b20706c6163652e205c5b726573756c745c5d284b65794368616e67656404244163636f756e74496404010120546865205c5b7375646f65725c5d206a757374207377697463686564206964656e746974793b20746865206f6c64206b657920697320737570706c6965642e285375646f4173446f6e6504384469737061746368526573756c74048c2041207375646f206a75737420746f6f6b20706c6163652e205c5b726573756c745c5d00042c526571756972655375646f04802053656e646572206d75737420626520746865205375646f206163636f756e74151450726f7879011450726f7879081c50726f7869657301010530543a3a4163636f756e7449644501285665633c50726f7879446566696e6974696f6e3c543a3a4163636f756e7449642c20543a3a50726f7879547970652c20543a3a426c6f636b4e756d6265723e3e2c0a2042616c616e63654f663c543e29004400000000000000000000000000000000000845012054686520736574206f66206163636f756e742070726f786965732e204d61707320746865206163636f756e74207768696368206861732064656c65676174656420746f20746865206163636f756e7473210120776869636820617265206265696e672064656c65676174656420746f2c20746f67657468657220776974682074686520616d6f756e742068656c64206f6e206465706f7369742e34416e6e6f756e63656d656e747301010530543a3a4163636f756e7449643d01285665633c416e6e6f756e63656d656e743c543a3a4163636f756e7449642c2043616c6c486173684f663c543e2c20543a3a426c6f636b4e756d6265723e3e2c0a2042616c616e63654f663c543e290044000000000000000000000000000000000004ac2054686520616e6e6f756e63656d656e7473206d616465206279207468652070726f787920286b6579292e01281470726f78790c107265616c30543a3a4163636f756e74496440666f7263655f70726f78795f74797065504f7074696f6e3c543a3a50726f7879547970653e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e3c51012044697370617463682074686520676976656e206063616c6c602066726f6d20616e206163636f756e742074686174207468652073656e64657220697320617574686f726973656420666f72207468726f7567683420606164645f70726f7879602e00ac2052656d6f76657320616e7920636f72726573706f6e64696e6720616e6e6f756e63656d656e742873292e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e6501202d2060666f7263655f70726f78795f74797065603a2053706563696679207468652065786163742070726f7879207479706520746f206265207573656420616e6420636865636b656420666f7220746869732063616c6c2ed4202d206063616c6c603a205468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e246164645f70726f78790c2064656c656761746530543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d62657234490120526567697374657220612070726f7879206163636f756e7420666f72207468652073656e64657220746861742069732061626c6520746f206d616b652063616c6c73206f6e2069747320626568616c662e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1501202d206070726f7879603a20546865206163636f756e74207468617420746865206063616c6c65726020776f756c64206c696b6520746f206d616b6520612070726f78792e0101202d206070726f78795f74797065603a20546865207065726d697373696f6e7320616c6c6f77656420666f7220746869732070726f7879206163636f756e742e5101202d206064656c6179603a2054686520616e6e6f756e63656d656e7420706572696f64207265717569726564206f662074686520696e697469616c2070726f78792e2057696c6c2067656e6572616c6c7920626518207a65726f2e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e3072656d6f76655f70726f78790c2064656c656761746530543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d6265722cac20556e726567697374657220612070726f7879206163636f756e7420666f72207468652073656e6465722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a2901202d206070726f7879603a20546865206163636f756e74207468617420746865206063616c6c65726020776f756c64206c696b6520746f2072656d6f766520617320612070726f78792e4501202d206070726f78795f74797065603a20546865207065726d697373696f6e732063757272656e746c7920656e61626c656420666f72207468652072656d6f7665642070726f7879206163636f756e742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e3872656d6f76655f70726f786965730028b820556e726567697374657220616c6c2070726f7879206163636f756e747320666f72207468652073656e6465722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901205741524e494e473a2054686973206d61792062652063616c6c6564206f6e206163636f756e747320637265617465642062792060616e6f6e796d6f7573602c20686f776576657220696620646f6e652c207468656e5d012074686520756e726573657276656420666565732077696c6c20626520696e61636365737369626c652e202a2a416c6c2061636365737320746f2074686973206163636f756e742077696c6c206265206c6f73742e2a2a002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e24616e6f6e796d6f75730c2870726f78795f7479706530543a3a50726f7879547970651464656c617938543a3a426c6f636b4e756d62657214696e6465780c7531365c3d0120537061776e2061206672657368206e6577206163636f756e7420746861742069732067756172616e7465656420746f206265206f746865727769736520696e61636365737369626c652c20616e64010120696e697469616c697a65206974207769746820612070726f7879206f66206070726f78795f747970656020666f7220606f726967696e602073656e6465722e0070205265717569726573206120605369676e656460206f726967696e2e005501202d206070726f78795f74797065603a205468652074797065206f66207468652070726f78792074686174207468652073656e6465722077696c6c2062652072656769737465726564206173206f766572207468655101206e6577206163636f756e742e20546869732077696c6c20616c6d6f737420616c7761797320626520746865206d6f7374207065726d697373697665206050726f7879547970656020706f737369626c6520746f7c20616c6c6f7720666f72206d6178696d756d20666c65786962696c6974792e5501202d2060696e646578603a204120646973616d626967756174696f6e20696e6465782c20696e206361736520746869732069732063616c6c6564206d756c7469706c652074696d657320696e207468652073616d656101207472616e73616374696f6e2028652e672e207769746820607574696c6974793a3a626174636860292e20556e6c65737320796f75277265207573696e67206062617463686020796f752070726f6261626c79206a757374442077616e7420746f20757365206030602e5101202d206064656c6179603a2054686520616e6e6f756e63656d656e7420706572696f64207265717569726564206f662074686520696e697469616c2070726f78792e2057696c6c2067656e6572616c6c7920626518207a65726f2e005501204661696c73207769746820604475706c69636174656020696620746869732068617320616c7265616479206265656e2063616c6c656420696e2074686973207472616e73616374696f6e2c2066726f6d207468659c2073616d652073656e6465722c2077697468207468652073616d6520706172616d65746572732e00e8204661696c732069662074686572652061726520696e73756666696369656e742066756e647320746f2070617920666f72206465706f7369742e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e9020544f444f3a204d69676874206265206f76657220636f756e74696e6720312072656164386b696c6c5f616e6f6e796d6f7573141c737061776e657230543a3a4163636f756e7449642870726f78795f7479706530543a3a50726f78795479706514696e6465780c753136186865696768745c436f6d706163743c543a3a426c6f636b4e756d6265723e246578745f696e64657830436f6d706163743c7533323e50b82052656d6f76657320612070726576696f75736c7920737061776e656420616e6f6e796d6f75732070726f78792e004d01205741524e494e473a202a2a416c6c2061636365737320746f2074686973206163636f756e742077696c6c206265206c6f73742e2a2a20416e792066756e64732068656c6420696e2069742077696c6c2062653820696e61636365737369626c652e005d01205265717569726573206120605369676e656460206f726967696e2c20616e64207468652073656e646572206163636f756e74206d7573742068617665206265656e206372656174656420627920612063616c6c20746fac2060616e6f6e796d6f757360207769746820636f72726573706f6e64696e6720706172616d65746572732e005101202d2060737061776e6572603a20546865206163636f756e742074686174206f726967696e616c6c792063616c6c65642060616e6f6e796d6f75736020746f206372656174652074686973206163636f756e742e5101202d2060696e646578603a2054686520646973616d626967756174696f6e20696e646578206f726967696e616c6c792070617373656420746f2060616e6f6e796d6f7573602e2050726f6261626c79206030602e0501202d206070726f78795f74797065603a205468652070726f78792074797065206f726967696e616c6c792070617373656420746f2060616e6f6e796d6f7573602e4101202d2060686569676874603a2054686520686569676874206f662074686520636861696e207768656e207468652063616c6c20746f2060616e6f6e796d6f757360207761732070726f6365737365642e4d01202d20606578745f696e646578603a205468652065787472696e73696320696e64657820696e207768696368207468652063616c6c20746f2060616e6f6e796d6f757360207761732070726f6365737365642e004d01204661696c73207769746820604e6f5065726d697373696f6e6020696e2063617365207468652063616c6c6572206973206e6f7420612070726576696f75736c79206372656174656420616e6f6e796d6f7573f4206163636f756e742077686f73652060616e6f6e796d6f7573602063616c6c2068617320636f72726573706f6e64696e6720706172616d65746572732e002c2023203c7765696768743e01012057656967687420697320612066756e6374696f6e206f6620746865206e756d626572206f662070726f7869657320746865207573657220686173202850292e302023203c2f7765696768743e20616e6e6f756e636508107265616c30543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e540901205075626c697368207468652068617368206f6620612070726f78792d63616c6c20746861742077696c6c206265206d61646520696e20746865206675747572652e0061012054686973206d7573742062652063616c6c656420736f6d65206e756d626572206f6620626c6f636b73206265666f72652074686520636f72726573706f6e64696e67206070726f78796020697320617474656d707465642901206966207468652064656c6179206173736f6369617465642077697468207468652070726f78792072656c6174696f6e736869702069732067726561746572207468616e207a65726f2e001501204e6f206d6f7265207468616e20604d617850656e64696e676020616e6e6f756e63656d656e7473206d6179206265206d61646520617420616e79206f6e652074696d652e000d0120546869732077696c6c2074616b652061206465706f736974206f662060416e6e6f756e63656d656e744465706f736974466163746f72602061732077656c6c2061731d012060416e6e6f756e63656d656e744465706f736974426173656020696620746865726520617265206e6f206f746865722070656e64696e6720616e6e6f756e63656d656e74732e00290120546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f20616e6420612070726f7879206f6620607265616c602e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e1901202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e4c72656d6f76655f616e6e6f756e63656d656e7408107265616c30543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e40742052656d6f7665206120676976656e20616e6e6f756e63656d656e742e005d01204d61792062652063616c6c656420627920612070726f7879206163636f756e7420746f2072656d6f766520612063616c6c20746865792070726576696f75736c7920616e6e6f756e63656420616e642072657475726e3420746865206465706f7369742e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e1901202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e4c72656a6563745f616e6e6f756e63656d656e74082064656c656761746530543a3a4163636f756e7449642463616c6c5f686173683443616c6c486173684f663c543e40b42052656d6f76652074686520676976656e20616e6e6f756e63656d656e74206f6620612064656c65676174652e006501204d61792062652063616c6c6564206279206120746172676574202870726f7869656429206163636f756e7420746f2072656d6f766520612063616c6c2074686174206f6e65206f662074686569722064656c656761746573290120286064656c656761746560292068617320616e6e6f756e63656420746865792077616e7420746f20657865637574652e20546865206465706f7369742069732072657475726e65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733af8202d206064656c6567617465603a20546865206163636f756e7420746861742070726576696f75736c7920616e6e6f756e636564207468652063616c6c2ec0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f206265206d6164652e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e3c70726f78795f616e6e6f756e636564102064656c656761746530543a3a4163636f756e744964107265616c30543a3a4163636f756e74496440666f7263655f70726f78795f74797065504f7074696f6e3c543a3a50726f7879547970653e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e4451012044697370617463682074686520676976656e206063616c6c602066726f6d20616e206163636f756e742074686174207468652073656e64657220697320617574686f72697a656420666f72207468726f7567683420606164645f70726f7879602e00ac2052656d6f76657320616e7920636f72726573706f6e64696e6720616e6e6f756e63656d656e742873292e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e003020506172616d65746572733a1101202d20607265616c603a20546865206163636f756e742074686174207468652070726f78792077696c6c206d616b6520612063616c6c206f6e20626568616c66206f662e6501202d2060666f7263655f70726f78795f74797065603a2053706563696679207468652065786163742070726f7879207479706520746f206265207573656420616e6420636865636b656420666f7220746869732063616c6c2ed4202d206063616c6c603a205468652063616c6c20746f206265206d6164652062792074686520607265616c60206163636f756e742e002c2023203c7765696768743e642057656967687420697320612066756e6374696f6e206f663a9c202d20413a20746865206e756d626572206f6620616e6e6f756e63656d656e7473206d6164652ea4202d20503a20746865206e756d626572206f662070726f78696573207468652075736572206861732e302023203c2f7765696768743e010c3450726f7879457865637574656404384469737061746368526573756c7404ec20412070726f78792077617320657865637574656420636f72726563746c792c20776974682074686520676976656e205c5b726573756c745c5d2e40416e6f6e796d6f75734372656174656410244163636f756e744964244163636f756e7449642450726f7879547970650c75313608ec20416e6f6e796d6f7573206163636f756e7420686173206265656e2063726561746564206279206e65772070726f7879207769746820676976656e690120646973616d626967756174696f6e20696e64657820616e642070726f787920747970652e205c5b616e6f6e796d6f75732c2077686f2c2070726f78795f747970652c20646973616d626967756174696f6e5f696e6465785c5d24416e6e6f756e6365640c244163636f756e744964244163636f756e744964104861736804510120416e20616e6e6f756e63656d656e742077617320706c6163656420746f206d616b6520612063616c6c20696e20746865206675747572652e205c5b7265616c2c2070726f78792c2063616c6c5f686173685c5d184050726f78794465706f736974426173653042616c616e63654f663c543e4000947cece8000000000000000000000010110120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720612070726f78792e00010120546869732069732068656c6420666f7220616e206164646974696f6e616c2073746f72616765206974656d2077686f73652076616c75652073697a652069732501206073697a656f662842616c616e6365296020627974657320616e642077686f7365206b65792073697a65206973206073697a656f66284163636f756e74496429602062797465732e4850726f78794465706f736974466163746f723042616c616e63654f663c543e408000596200000000000000000000000014bc2054686520616d6f756e74206f662063757272656e6379206e6565646564207065722070726f78792061646465642e00690120546869732069732068656c6420666f7220616464696e6720333220627974657320706c757320616e20696e7374616e6365206f66206050726f78795479706560206d6f726520696e746f2061207072652d6578697374696e6761012073746f726167652076616c75652e20546875732c207768656e20636f6e6669677572696e67206050726f78794465706f736974466163746f7260206f6e652073686f756c642074616b6520696e746f206163636f756e74c020603332202b2070726f78795f747970652e656e636f646528292e6c656e282960206279746573206f6620646174612e284d617850726f786965730c75313608200004f020546865206d6178696d756d20616d6f756e74206f662070726f7869657320616c6c6f77656420666f7220612073696e676c65206163636f756e742e284d617850656e64696e670c753332102000000004450120546865206d6178696d756d20616d6f756e74206f662074696d652d64656c6179656420616e6e6f756e63656d656e747320746861742061726520616c6c6f77656420746f2062652070656e64696e672e5c416e6e6f756e63656d656e744465706f736974426173653042616c616e63654f663c543e4000947cece800000000000000000000000c310120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e6720616e20616e6e6f756e63656d656e742e00690120546869732069732068656c64207768656e2061206e65772073746f72616765206974656d20686f6c64696e672061206042616c616e636560206973206372656174656420287479706963616c6c79203136206279746573292e64416e6e6f756e63656d656e744465706f736974466163746f723042616c616e63654f663c543e400001b2c400000000000000000000000010d42054686520616d6f756e74206f662063757272656e6379206e65656465642070657220616e6e6f756e63656d656e74206d6164652e00590120546869732069732068656c6420666f7220616464696e6720616e20604163636f756e744964602c2060486173686020616e642060426c6f636b4e756d6265726020287479706963616c6c79203638206279746573298c20696e746f2061207072652d6578697374696e672073746f726167652076616c75652e201c546f6f4d616e790425012054686572652061726520746f6f206d616e792070726f786965732072656769737465726564206f7220746f6f206d616e7920616e6e6f756e63656d656e74732070656e64696e672e204e6f74466f756e6404782050726f787920726567697374726174696f6e206e6f7420666f756e642e204e6f7450726f787904d02053656e646572206973206e6f7420612070726f7879206f6620746865206163636f756e7420746f2062652070726f786965642e2c556e70726f787961626c6504250120412063616c6c20776869636820697320696e636f6d70617469626c652077697468207468652070726f7879207479706527732066696c7465722077617320617474656d707465642e244475706c69636174650470204163636f756e7420697320616c726561647920612070726f78792e304e6f5065726d697373696f6e0419012043616c6c206d6179206e6f74206265206d6164652062792070726f78792062656361757365206974206d617920657363616c617465206974732070726976696c656765732e2c556e616e6e6f756e63656404d420416e6e6f756e63656d656e742c206966206d61646520617420616c6c2c20776173206d61646520746f6f20726563656e746c792e2c4e6f53656c6650726f787904682043616e6e6f74206164642073656c662061732070726f78792e16204d756c746973696701204d756c746973696708244d756c74697369677300020530543a3a4163636f756e744964205b75383b2033325dd04d756c74697369673c543a3a426c6f636b4e756d6265722c2042616c616e63654f663c543e2c20543a3a4163636f756e7449643e02040004942054686520736574206f66206f70656e206d756c7469736967206f7065726174696f6e732e1443616c6c73000106205b75383b2033325da0284f706171756543616c6c2c20543a3a4163636f756e7449642c2042616c616e63654f663c543e290004000001105061735f6d756c74695f7468726573686f6c645f3108446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e1063616c6c60426f783c3c5420617320436f6e6669673e3a3a43616c6c3e40550120496d6d6564696174656c792064697370617463682061206d756c74692d7369676e61747572652063616c6c207573696e6720612073696e676c6520617070726f76616c2066726f6d207468652063616c6c65722e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e004101202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f206172652070617274206f66207468650501206d756c74692d7369676e61747572652c2062757420646f206e6f7420706172746963697061746520696e2074686520617070726f76616c2070726f636573732e8c202d206063616c6c603a205468652063616c6c20746f2062652065786563757465642e00bc20526573756c74206973206571756976616c656e7420746f20746865206469737061746368656420726573756c742e002c2023203c7765696768743e1d01204f285a202b204329207768657265205a20697320746865206c656e677468206f66207468652063616c6c20616e6420432069747320657865637574696f6e207765696768742e80202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d48202d204442205765696768743a204e6f6e654c202d20506c75732043616c6c20576569676874302023203c2f7765696768743e2061735f6d756c746918247468726573686f6c640c753136446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e3c6d617962655f74696d65706f696e74844f7074696f6e3c54696d65706f696e743c543a3a426c6f636b4e756d6265723e3e1063616c6c284f706171756543616c6c2873746f72655f63616c6c10626f6f6c286d61785f77656967687418576569676874b8590120526567697374657220617070726f76616c20666f72206120646973706174636820746f206265206d6164652066726f6d20612064657465726d696e697374696320636f6d706f73697465206163636f756e74206966fc20617070726f766564206279206120746f74616c206f6620607468726573686f6c64202d203160206f6620606f746865725f7369676e61746f72696573602e00b42049662074686572652061726520656e6f7567682c207468656e206469737061746368207468652063616c6c2e003101205061796d656e743a20604465706f73697442617365602077696c6c20626520726573657276656420696620746869732069732074686520666972737420617070726f76616c2c20706c7573410120607468726573686f6c64602074696d657320604465706f736974466163746f72602e2049742069732072657475726e6564206f6e636520746869732064697370617463682068617070656e73206f72382069732063616e63656c6c65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901202d20607468726573686f6c64603a2054686520746f74616c206e756d626572206f6620617070726f76616c7320666f722074686973206469737061746368206265666f72652069742069732065786563757465642e4501202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f2063616e20617070726f76652074686973702064697370617463682e204d6179206e6f7420626520656d7074792e5d01202d20606d617962655f74696d65706f696e74603a20496620746869732069732074686520666972737420617070726f76616c2c207468656e2074686973206d75737420626520604e6f6e65602e2049662069742069735501206e6f742074686520666972737420617070726f76616c2c207468656e206974206d7573742062652060536f6d65602c2077697468207468652074696d65706f696e742028626c6f636b206e756d62657220616e64d8207472616e73616374696f6e20696e64657829206f662074686520666972737420617070726f76616c207472616e73616374696f6e2e8c202d206063616c6c603a205468652063616c6c20746f2062652065786563757465642e002101204e4f54453a20556e6c6573732074686973206973207468652066696e616c20617070726f76616c2c20796f752077696c6c2067656e6572616c6c792077616e7420746f207573651d012060617070726f76655f61735f6d756c74696020696e73746561642c2073696e6365206974206f6e6c7920726571756972657320612068617368206f66207468652063616c6c2e005d0120526573756c74206973206571756976616c656e7420746f20746865206469737061746368656420726573756c7420696620607468726573686f6c64602069732065786163746c79206031602e204f74686572776973655901206f6e20737563636573732c20726573756c7420697320604f6b6020616e642074686520726573756c742066726f6d2074686520696e746572696f722063616c6c2c206966206974207761732065786563757465642ce0206d617920626520666f756e6420696e20746865206465706f736974656420604d756c7469736967457865637574656460206576656e742e002c2023203c7765696768743e54202d20604f2853202b205a202b2043616c6c29602ed0202d20557020746f206f6e652062616c616e63652d72657365727665206f7220756e72657365727665206f7065726174696f6e2e4101202d204f6e6520706173737468726f756768206f7065726174696f6e2c206f6e6520696e736572742c20626f746820604f285329602077686572652060536020697320746865206e756d626572206f6649012020207369676e61746f726965732e206053602069732063617070656420627920604d61785369676e61746f72696573602c207769746820776569676874206265696e672070726f706f7274696f6e616c2e2501202d204f6e652063616c6c20656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285a296020776865726520605a602069732074782d6c656e2ec0202d204f6e6520656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285329602ed8202d20557020746f206f6e652062696e6172792073656172636820616e6420696e736572742028604f286c6f6753202b20532960292efc202d20492f4f3a2031207265616420604f285329602c20757020746f2031206d757461746520604f285329602e20557020746f206f6e652072656d6f76652e34202d204f6e65206576656e742e70202d2054686520776569676874206f6620746865206063616c6c602e3101202d2053746f726167653a20696e7365727473206f6e65206974656d2c2076616c75652073697a6520626f756e64656420627920604d61785369676e61746f72696573602c20776974682061902020206465706f7369742074616b656e20666f7220697473206c69666574696d65206f66b4202020604465706f73697442617365202b207468726573686f6c64202a204465706f736974466163746f72602e80202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743a250120202020202d2052656164733a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c2043616c6c7320286966206073746f72655f63616c6c6029290120202020202d205772697465733a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c2043616c6c7320286966206073746f72655f63616c6c60294c202d20506c75732043616c6c20576569676874302023203c2f7765696768743e40617070726f76655f61735f6d756c746914247468726573686f6c640c753136446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e3c6d617962655f74696d65706f696e74844f7074696f6e3c54696d65706f696e743c543a3a426c6f636b4e756d6265723e3e2463616c6c5f68617368205b75383b2033325d286d61785f7765696768741857656967687490590120526567697374657220617070726f76616c20666f72206120646973706174636820746f206265206d6164652066726f6d20612064657465726d696e697374696320636f6d706f73697465206163636f756e74206966fc20617070726f766564206279206120746f74616c206f6620607468726573686f6c64202d203160206f6620606f746865725f7369676e61746f72696573602e003101205061796d656e743a20604465706f73697442617365602077696c6c20626520726573657276656420696620746869732069732074686520666972737420617070726f76616c2c20706c7573410120607468726573686f6c64602074696d657320604465706f736974466163746f72602e2049742069732072657475726e6564206f6e636520746869732064697370617463682068617070656e73206f72382069732063616e63656c6c65642e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901202d20607468726573686f6c64603a2054686520746f74616c206e756d626572206f6620617070726f76616c7320666f722074686973206469737061746368206265666f72652069742069732065786563757465642e4501202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f2063616e20617070726f76652074686973702064697370617463682e204d6179206e6f7420626520656d7074792e5d01202d20606d617962655f74696d65706f696e74603a20496620746869732069732074686520666972737420617070726f76616c2c207468656e2074686973206d75737420626520604e6f6e65602e2049662069742069735501206e6f742074686520666972737420617070726f76616c2c207468656e206974206d7573742062652060536f6d65602c2077697468207468652074696d65706f696e742028626c6f636b206e756d62657220616e64d8207472616e73616374696f6e20696e64657829206f662074686520666972737420617070726f76616c207472616e73616374696f6e2ed0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f2062652065786563757465642e003901204e4f54453a2049662074686973206973207468652066696e616c20617070726f76616c2c20796f752077696c6c2077616e7420746f20757365206061735f6d756c74696020696e73746561642e002c2023203c7765696768743e28202d20604f285329602ed0202d20557020746f206f6e652062616c616e63652d72657365727665206f7220756e72657365727665206f7065726174696f6e2e4101202d204f6e6520706173737468726f756768206f7065726174696f6e2c206f6e6520696e736572742c20626f746820604f285329602077686572652060536020697320746865206e756d626572206f6649012020207369676e61746f726965732e206053602069732063617070656420627920604d61785369676e61746f72696573602c207769746820776569676874206265696e672070726f706f7274696f6e616c2ec0202d204f6e6520656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285329602ed8202d20557020746f206f6e652062696e6172792073656172636820616e6420696e736572742028604f286c6f6753202b20532960292efc202d20492f4f3a2031207265616420604f285329602c20757020746f2031206d757461746520604f285329602e20557020746f206f6e652072656d6f76652e34202d204f6e65206576656e742e3101202d2053746f726167653a20696e7365727473206f6e65206974656d2c2076616c75652073697a6520626f756e64656420627920604d61785369676e61746f72696573602c20776974682061902020206465706f7369742074616b656e20666f7220697473206c69666574696d65206f66b4202020604465706f73697442617365202b207468726573686f6c64202a204465706f736974466163746f72602e8c202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743abc20202020202d20526561643a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745dc020202020202d2057726974653a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d302023203c2f7765696768743e3c63616e63656c5f61735f6d756c746910247468726573686f6c640c753136446f746865725f7369676e61746f72696573445665633c543a3a4163636f756e7449643e2474696d65706f696e746454696d65706f696e743c543a3a426c6f636b4e756d6265723e2463616c6c5f68617368205b75383b2033325d6859012043616e63656c2061207072652d6578697374696e672c206f6e2d676f696e67206d756c7469736967207472616e73616374696f6e2e20416e79206465706f7369742072657365727665642070726576696f75736c79c820666f722074686973206f7065726174696f6e2077696c6c20626520756e7265736572766564206f6e20737563636573732e00d020546865206469737061746368206f726967696e20666f7220746869732063616c6c206d757374206265205f5369676e65645f2e005901202d20607468726573686f6c64603a2054686520746f74616c206e756d626572206f6620617070726f76616c7320666f722074686973206469737061746368206265666f72652069742069732065786563757465642e4501202d20606f746865725f7369676e61746f72696573603a20546865206163636f756e747320286f74686572207468616e207468652073656e646572292077686f2063616e20617070726f76652074686973702064697370617463682e204d6179206e6f7420626520656d7074792e6101202d206074696d65706f696e74603a205468652074696d65706f696e742028626c6f636b206e756d62657220616e64207472616e73616374696f6e20696e64657829206f662074686520666972737420617070726f76616c7c207472616e73616374696f6e20666f7220746869732064697370617463682ed0202d206063616c6c5f68617368603a205468652068617368206f66207468652063616c6c20746f2062652065786563757465642e002c2023203c7765696768743e28202d20604f285329602ed0202d20557020746f206f6e652062616c616e63652d72657365727665206f7220756e72657365727665206f7065726174696f6e2e4101202d204f6e6520706173737468726f756768206f7065726174696f6e2c206f6e6520696e736572742c20626f746820604f285329602077686572652060536020697320746865206e756d626572206f6649012020207369676e61746f726965732e206053602069732063617070656420627920604d61785369676e61746f72696573602c207769746820776569676874206265696e672070726f706f7274696f6e616c2ec0202d204f6e6520656e636f6465202620686173682c20626f7468206f6620636f6d706c657869747920604f285329602e34202d204f6e65206576656e742e88202d20492f4f3a2031207265616420604f285329602c206f6e652072656d6f76652e74202d2053746f726167653a2072656d6f766573206f6e65206974656d2e8c202d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d34202d204442205765696768743a190120202020202d20526561643a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c20526566756e64204163636f756e742c2043616c6c731d0120202020202d2057726974653a204d756c74697369672053746f726167652c205b43616c6c6572204163636f756e745d2c20526566756e64204163636f756e742c2043616c6c73302023203c2f7765696768743e01102c4e65774d756c74697369670c244163636f756e744964244163636f756e7449642043616c6c48617368041d012041206e6577206d756c7469736967206f7065726174696f6e2068617320626567756e2e205c5b617070726f76696e672c206d756c74697369672c2063616c6c5f686173685c5d404d756c7469736967417070726f76616c10244163636f756e7449645854696d65706f696e743c426c6f636b4e756d6265723e244163636f756e7449642043616c6c4861736808cc2041206d756c7469736967206f7065726174696f6e20686173206265656e20617070726f76656420627920736f6d656f6e652eb8205c5b617070726f76696e672c2074696d65706f696e742c206d756c74697369672c2063616c6c5f686173685c5d404d756c7469736967457865637574656414244163636f756e7449645854696d65706f696e743c426c6f636b4e756d6265723e244163636f756e7449642043616c6c48617368384469737061746368526573756c740459012041206d756c7469736967206f7065726174696f6e20686173206265656e2065786563757465642e205c5b617070726f76696e672c2074696d65706f696e742c206d756c74697369672c2063616c6c5f686173685c5d444d756c746973696743616e63656c6c656410244163636f756e7449645854696d65706f696e743c426c6f636b4e756d6265723e244163636f756e7449642043616c6c486173680461012041206d756c7469736967206f7065726174696f6e20686173206265656e2063616e63656c6c65642e205c5b63616e63656c6c696e672c2074696d65706f696e742c206d756c74697369672c2063616c6c5f686173685c5d0c2c4465706f736974426173653042616c616e63654f663c543e4000bce7dae9000000000000000000000008710120546865206261736520616d6f756e74206f662063757272656e6379206e656564656420746f207265736572766520666f72206372656174696e672061206d756c746973696720657865637574696f6e206f7220746f2073746f72656c20612064697370617463682063616c6c20666f72206c617465722e344465706f736974466163746f723042616c616e63654f663c543e4000105e5f0000000000000000000000000455012054686520616d6f756e74206f662063757272656e6379206e65656465642070657220756e6974207468726573686f6c64207768656e206372656174696e672061206d756c746973696720657865637574696f6e2e384d61785369676e61746f726965730c75313608640004010120546865206d6178696d756d20616d6f756e74206f66207369676e61746f7269657320616c6c6f77656420666f72206120676976656e206d756c74697369672e38404d696e696d756d5468726573686f6c640480205468726573686f6c64206d7573742062652032206f7220677265617465722e3c416c7265616479417070726f76656404b02043616c6c20697320616c726561647920617070726f7665642062792074686973207369676e61746f72792e444e6f417070726f76616c734e656564656404a02043616c6c20646f65736e2774206e65656420616e7920286d6f72652920617070726f76616c732e44546f6f4665775369676e61746f7269657304ac2054686572652061726520746f6f20666577207369676e61746f7269657320696e20746865206c6973742e48546f6f4d616e795369676e61746f7269657304b02054686572652061726520746f6f206d616e79207369676e61746f7269657320696e20746865206c6973742e545369676e61746f726965734f75744f664f7264657204110120546865207369676e61746f7269657320776572652070726f7669646564206f7574206f66206f726465723b20746865792073686f756c64206265206f7264657265642e4c53656e646572496e5369676e61746f72696573041101205468652073656e6465722077617320636f6e7461696e656420696e20746865206f74686572207369676e61746f726965733b2069742073686f756c646e27742062652e204e6f74466f756e6404e0204d756c7469736967206f7065726174696f6e206e6f7420666f756e64207768656e20617474656d7074696e6720746f2063616e63656c2e204e6f744f776e6572043101204f6e6c7920746865206163636f756e742074686174206f726967696e616c6c79206372656174656420746865206d756c74697369672069732061626c6520746f2063616e63656c2069742e2c4e6f54696d65706f696e74042101204e6f2074696d65706f696e742077617320676976656e2c2079657420746865206d756c7469736967206f7065726174696f6e20697320616c726561647920756e6465727761792e3857726f6e6754696d65706f696e74043101204120646966666572656e742074696d65706f696e742077617320676976656e20746f20746865206d756c7469736967206f7065726174696f6e207468617420697320756e6465727761792e4c556e657870656374656454696d65706f696e7404f820412074696d65706f696e742077617320676976656e2c20796574206e6f206d756c7469736967206f7065726174696f6e20697320756e6465727761792e3c4d6178576569676874546f6f4c6f7704d420546865206d6178696d756d2077656967687420696e666f726d6174696f6e2070726f76696465642077617320746f6f206c6f772e34416c726561647953746f72656404a420546865206461746120746f2062652073746f72656420697320616c72656164792073746f7265642e1768456c656374696f6e50726f76696465724d756c746950686173650168456c656374696f6e50726f76696465724d756c746950686173651814526f756e6401000c753332100100000018ac20496e7465726e616c20636f756e74657220666f7220746865206e756d626572206f6620726f756e64732e00550120546869732069732075736566756c20666f722064652d6475706c69636174696f6e206f66207472616e73616374696f6e73207375626d697474656420746f2074686520706f6f6c2c20616e642067656e6572616c6c20646961676e6f7374696373206f66207468652070616c6c65742e004d012054686973206973206d6572656c7920696e6372656d656e746564206f6e6365207065722065766572792074696d65207468617420616e20757073747265616d2060656c656374602069732063616c6c65642e3043757272656e74506861736501005450686173653c543a3a426c6f636b4e756d6265723e0400043c2043757272656e742070686173652e38517565756564536f6c7574696f6e00006c5265616479536f6c7574696f6e3c543a3a4163636f756e7449643e0400043d012043757272656e74206265737420736f6c7574696f6e2c207369676e6564206f7220756e7369676e65642c2071756575656420746f2062652072657475726e65642075706f6e2060656c656374602e20536e617073686f7400006c526f756e64536e617073686f743c543a3a4163636f756e7449643e04000c7020536e617073686f742064617461206f662074686520726f756e642e005d01205468697320697320637265617465642061742074686520626567696e6e696e67206f6620746865207369676e656420706861736520616e6420636c65617265642075706f6e2063616c6c696e672060656c656374602e38446573697265645461726765747300000c75333204000ccc2044657369726564206e756d626572206f66207461726765747320746f20656c65637420666f72207468697320726f756e642e00a8204f6e6c7920657869737473207768656e205b60536e617073686f74605d2069732070726573656e742e40536e617073686f744d65746164617461000058536f6c7574696f6e4f72536e617073686f7453697a6504000c9820546865206d65746164617461206f6620746865205b60526f756e64536e617073686f74605d00a8204f6e6c7920657869737473207768656e205b60536e617073686f74605d2069732070726573656e742e01043c7375626d69745f756e7369676e65640820736f6c7574696f6e64526177536f6c7574696f6e3c436f6d706163744f663c543e3e1c7769746e65737358536f6c7574696f6e4f72536e617073686f7453697a6538a8205375626d6974206120736f6c7574696f6e20666f722074686520756e7369676e65642070686173652e00cc20546865206469737061746368206f726967696e20666f20746869732063616c6c206d757374206265205f5f6e6f6e655f5f2e0041012054686973207375626d697373696f6e20697320636865636b6564206f6e2074686520666c792e204d6f72656f7665722c207468697320756e7369676e656420736f6c7574696f6e206973206f6e6c7959012076616c696461746564207768656e207375626d697474656420746f2074686520706f6f6c2066726f6d20746865202a2a6c6f63616c2a2a206e6f64652e204566666563746976656c792c2074686973206d65616e7361012074686174206f6e6c79206163746976652076616c696461746f72732063616e207375626d69742074686973207472616e73616374696f6e207768656e20617574686f72696e67206120626c6f636b202873696d696c61724420746f20616e20696e686572656e74292e005d0120546f2070726576656e7420616e7920696e636f727265637420736f6c7574696f6e2028616e642074687573207761737465642074696d652f776569676874292c2074686973207472616e73616374696f6e2077696c6c51012070616e69632069662074686520736f6c7574696f6e207375626d6974746564206279207468652076616c696461746f7220697320696e76616c696420696e20616e79207761792c206566666563746976656c79a02070757474696e6720746865697220617574686f72696e6720726577617264206174207269736b2e00e4204e6f206465706f736974206f7220726577617264206973206173736f63696174656420776974682074686973207375626d697373696f6e2e011838536f6c7574696f6e53746f726564043c456c656374696f6e436f6d7075746510b8204120736f6c7574696f6e207761732073746f72656420776974682074686520676976656e20636f6d707574652e0041012049662074686520736f6c7574696f6e206973207369676e65642c2074686973206d65616e732074686174206974206861736e277420796574206265656e2070726f6365737365642e20496620746865090120736f6c7574696f6e20697320756e7369676e65642c2074686973206d65616e7320746861742069742068617320616c736f206265656e2070726f6365737365642e44456c656374696f6e46696e616c697a6564045c4f7074696f6e3c456c656374696f6e436f6d707574653e0859012054686520656c656374696f6e20686173206265656e2066696e616c697a65642c20776974682060536f6d6560206f662074686520676976656e20636f6d7075746174696f6e2c206f7220656c7365206966207468656420656c656374696f6e206661696c65642c20604e6f6e65602e20526577617264656404244163636f756e74496404290120416e206163636f756e7420686173206265656e20726577617264656420666f72207468656972207369676e6564207375626d697373696f6e206265696e672066696e616c697a65642e1c536c617368656404244163636f756e74496404250120416e206163636f756e7420686173206265656e20736c617368656420666f72207375626d697474696e6720616e20696e76616c6964207369676e6564207375626d697373696f6e2e485369676e6564506861736553746172746564040c75333204c420546865207369676e6564207068617365206f662074686520676976656e20726f756e642068617320737461727465642e50556e7369676e6564506861736553746172746564040c75333204cc2054686520756e7369676e6564207068617365206f662074686520676976656e20726f756e642068617320737461727465642e0c34556e7369676e6564506861736538543a3a426c6f636b4e756d62657210960000000480204475726174696f6e206f662074686520756e7369676e65642070686173652e2c5369676e6564506861736538543a3a426c6f636b4e756d62657210000000000478204475726174696f6e206f6620746865207369676e65642070686173652e70536f6c7574696f6e496d70726f76656d656e745468726573686f6c641c50657262696c6c1020a10700084d0120546865206d696e696d756d20616d6f756e74206f6620696d70726f76656d656e7420746f2074686520736f6c7574696f6e2073636f7265207468617420646566696e6573206120736f6c7574696f6e206173642022626574746572222028696e20616e79207068617365292e0c6850726544697370617463684561726c795375626d697373696f6e0468205375626d697373696f6e2077617320746f6f206561726c792e6c507265446973706174636857726f6e6757696e6e6572436f756e74048c2057726f6e67206e756d626572206f662077696e6e6572732070726573656e7465642e6450726544697370617463685765616b5375626d697373696f6e0494205375626d697373696f6e2077617320746f6f207765616b2c2073636f72652d776973652e184050617261636861696e734f726967696e0000000000295c50617261636861696e73436f6e66696775726174696f6e0134436f6e66696775726174696f6e0830416374697665436f6e666967010084486f7374436f6e66696775726174696f6e3c543a3a426c6f636b4e756d6265723ed9020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000100000001000000000000000000060000006400000002000000c8000000010000000000000000000000000000000000000004c8205468652061637469766520636f6e66696775726174696f6e20666f72207468652063757272656e742073657373696f6e2e3450656e64696e67436f6e6669670001053053657373696f6e496e64657884486f7374436f6e66696775726174696f6e3c543a3a426c6f636b4e756d6265723e00040004d42050656e64696e6720636f6e66696775726174696f6e2028696620616e792920666f7220746865206e6578742073657373696f6e2e01a0807365745f76616c69646174696f6e5f757067726164655f6672657175656e6379040c6e657738543a3a426c6f636b4e756d626572049820536574207468652076616c69646174696f6e2075706772616465206672657175656e63792e707365745f76616c69646174696f6e5f757067726164655f64656c6179040c6e657738543a3a426c6f636b4e756d626572048820536574207468652076616c69646174696f6e20757067726164652064656c61792e647365745f636f64655f726574656e74696f6e5f706572696f64040c6e657738543a3a426c6f636b4e756d62657204d4205365742074686520616363657074616e636520706572696f6420666f7220616e20696e636c756465642063616e6469646174652e447365745f6d61785f636f64655f73697a65040c6e65770c75333204e02053657420746865206d61782076616c69646174696f6e20636f64652073697a6520666f7220696e636f6d696e672075706772616465732e407365745f6d61785f706f765f73697a65040c6e65770c75333204c82053657420746865206d617820504f5620626c6f636b2073697a6520666f7220696e636f6d696e672075706772616465732e587365745f6d61785f686561645f646174615f73697a65040c6e65770c75333204982053657420746865206d6178206865616420646174612073697a6520666f722070617261732e507365745f706172617468726561645f636f726573040c6e65770c75333204b82053657420746865206e756d626572206f66207061726174687265616420657865637574696f6e20636f7265732e587365745f706172617468726561645f72657472696573040c6e65770c75333204dc2053657420746865206e756d626572206f66207265747269657320666f72206120706172746963756c617220706172617468726561642e707365745f67726f75705f726f746174696f6e5f6672657175656e6379040c6e657738543a3a426c6f636b4e756d62657204d420536574207468652070617261636861696e2076616c696461746f722d67726f757020726f746174696f6e206672657175656e6379747365745f636861696e5f617661696c6162696c6974795f706572696f64040c6e657738543a3a426c6f636b4e756d62657204b0205365742074686520617661696c6162696c69747920706572696f6420666f722070617261636861696e732e787365745f7468726561645f617661696c6162696c6974795f706572696f64040c6e657738543a3a426c6f636b4e756d62657204b4205365742074686520617661696c6162696c69747920706572696f6420666f722070617261746872656164732e607365745f7363686564756c696e675f6c6f6f6b6168656164040c6e65770c753332043d012053657420746865207363686564756c696e67206c6f6f6b61686561642c20696e206578706563746564206e756d626572206f6620626c6f636b73206174207065616b207468726f7567687075742e6c7365745f6d61785f76616c696461746f72735f7065725f636f7265040c6e65772c4f7074696f6e3c7533323e04f02053657420746865206d6178696d756d206e756d626572206f662076616c696461746f727320746f2061737369676e20746f20616e7920636f72652e487365745f6d61785f76616c696461746f7273040c6e65772c4f7074696f6e3c7533323e0411012053657420746865206d6178696d756d206e756d626572206f662076616c696461746f727320746f2075736520696e2070617261636861696e20636f6e73656e7375732e487365745f646973707574655f706572696f64040c6e65773053657373696f6e496e6465780411012053657420746865206469737075746520706572696f642c20696e206e756d626572206f662073657373696f6e7320746f206b65657020666f722064697370757465732eb47365745f646973707574655f706f73745f636f6e636c7573696f6e5f616363657074616e63655f706572696f64040c6e657738543a3a426c6f636b4e756d62657204cc2053657420746865206469737075746520706f737420636f6e636c7573696f6e20616363657074616e636520706572696f642e687365745f646973707574655f6d61785f7370616d5f736c6f7473040c6e65770c75333204b82053657420746865206d6178696d756d206e756d626572206f662064697370757465207370616d20736c6f74732ea47365745f646973707574655f636f6e636c7573696f6e5f62795f74696d655f6f75745f706572696f64040c6e657738543a3a426c6f636b4e756d62657204bc2053657420746865206469737075746520636f6e636c7573696f6e2062792074696d65206f757420706572696f642e447365745f6e6f5f73686f775f736c6f7473040c6e65770c75333208fc2053657420746865206e6f2073686f7720736c6f74732c20696e206e756d626572206f66206e756d626572206f6620636f6e73656e73757320736c6f74732e50204d757374206265206174206c6561737420312e507365745f6e5f64656c61795f7472616e63686573040c6e65770c75333204a0205365742074686520746f74616c206e756d626572206f662064656c6179207472616e636865732e787365745f7a65726f74685f64656c61795f7472616e6368655f7769647468040c6e65770c75333204902053657420746865207a65726f74682064656c6179207472616e6368652077696474682e507365745f6e65656465645f617070726f76616c73040c6e65770c75333204e02053657420746865206e756d626572206f662076616c696461746f7273206e656564656420746f20617070726f7665206120626c6f636b2e707365745f72656c61795f7672665f6d6f64756c6f5f73616d706c6573040c6e65770c7533320455012053657420746865206e756d626572206f662073616d706c657320746f20646f206f66207468652052656c61795652464d6f64756c6f20617070726f76616c2061737369676e6d656e7420637269746572696f6e2e687365745f6d61785f7570776172645f71756575655f636f756e74040c6e65770c753332043101205365747320746865206d6178696d756d206974656d7320746861742063616e2070726573656e7420696e206120757077617264206469737061746368207175657565206174206f6e63652e647365745f6d61785f7570776172645f71756575655f73697a65040c6e65770c753332046901205365747320746865206d6178696d756d20746f74616c2073697a65206f66206974656d7320746861742063616e2070726573656e7420696e206120757077617264206469737061746368207175657565206174206f6e63652e747365745f6d61785f646f776e776172645f6d6573736167655f73697a65040c6e65770c75333204a0205365742074686520637269746963616c20646f776e77617264206d6573736167652073697a652ed87365745f7072656665727265645f646973706174636861626c655f7570776172645f6d657373616765735f737465705f776569676874040c6e657718576569676874043d0120536574732074686520736f6674206c696d697420666f7220746865207068617365206f66206469737061746368696e6720646973706174636861626c6520757077617264206d657373616765732e6c7365745f6d61785f7570776172645f6d6573736167655f73697a65040c6e65770c753332043101205365747320746865206d6178696d756d2073697a65206f6620616e20757077617264206d65737361676520746861742063616e2062652073656e7420627920612063616e6469646174652ea07365745f6d61785f7570776172645f6d6573736167655f6e756d5f7065725f63616e646964617465040c6e65770c753332040901205365747320746865206d6178696d756d206e756d626572206f66206d65737361676573207468617420612063616e6469646174652063616e20636f6e7461696e2e647365745f68726d705f6f70656e5f726571756573745f74746c040c6e65770c753332043901205365747320746865206e756d626572206f662073657373696f6e7320616674657220776869636820616e2048524d50206f70656e206368616e6e656c207265717565737420657870697265732e5c7365745f68726d705f73656e6465725f6465706f736974040c6e65771c42616c616e636504550120536574732074686520616d6f756e74206f662066756e64732074686174207468652073656e6465722073686f756c642070726f7669646520666f72206f70656e696e6720616e2048524d50206368616e6e656c2e687365745f68726d705f726563697069656e745f6465706f736974040c6e65771c42616c616e636508650120536574732074686520616d6f756e74206f662066756e647320746861742074686520726563697069656e742073686f756c642070726f7669646520666f7220616363657074696e67206f70656e696e6720616e2048524d5024206368616e6e656c2e747365745f68726d705f6368616e6e656c5f6d61785f6361706163697479040c6e65770c753332042101205365747320746865206d6178696d756d206e756d626572206f66206d6573736167657320616c6c6f77656420696e20616e2048524d50206368616e6e656c206174206f6e63652e7c7365745f68726d705f6368616e6e656c5f6d61785f746f74616c5f73697a65040c6e65770c753332045501205365747320746865206d6178696d756d20746f74616c2073697a65206f66206d6573736167657320696e20627974657320616c6c6f77656420696e20616e2048524d50206368616e6e656c206174206f6e63652e9c7365745f68726d705f6d61785f70617261636861696e5f696e626f756e645f6368616e6e656c73040c6e65770c753332044d01205365747320746865206d6178696d756d206e756d626572206f6620696e626f756e642048524d50206368616e6e656c7320612070617261636861696e20697320616c6c6f77656420746f206163636570742ea07365745f68726d705f6d61785f706172617468726561645f696e626f756e645f6368616e6e656c73040c6e65770c753332045101205365747320746865206d6178696d756d206e756d626572206f6620696e626f756e642048524d50206368616e6e656c732061207061726174687265616420697320616c6c6f77656420746f206163636570742e847365745f68726d705f6368616e6e656c5f6d61785f6d6573736167655f73697a65040c6e65770c753332044101205365747320746865206d6178696d756d2073697a65206f662061206d657373616765207468617420636f756c6420657665722062652070757420696e746f20616e2048524d50206368616e6e656c2ea07365745f68726d705f6d61785f70617261636861696e5f6f7574626f756e645f6368616e6e656c73040c6e65770c753332044901205365747320746865206d6178696d756d206e756d626572206f66206f7574626f756e642048524d50206368616e6e656c7320612070617261636861696e20697320616c6c6f77656420746f206f70656e2ea47365745f68726d705f6d61785f706172617468726561645f6f7574626f756e645f6368616e6e656c73040c6e65770c753332044d01205365747320746865206d6178696d756d206e756d626572206f66206f7574626f756e642048524d50206368616e6e656c732061207061726174687265616420697320616c6c6f77656420746f206f70656e2e987365745f68726d705f6d61785f6d6573736167655f6e756d5f7065725f63616e646964617465040c6e65770c753332043901205365747320746865206d6178696d756d206e756d626572206f66206f7574626f756e642048524d50206d657373616765732063616e2062652073656e7420627920612063616e6469646174652e0000043c496e76616c69644e657756616c756504e020546865206e65772076616c756520666f72206120636f6e66696775726174696f6e20706172616d6574657220697320696e76616c69642e2a2c5061726173536861726564012c50617261735368617265640c4c43757272656e7453657373696f6e496e64657801003053657373696f6e496e6465781000000000046c205468652063757272656e742073657373696f6e20696e6465782e5841637469766556616c696461746f72496e646963657301004c5665633c56616c696461746f72496e6465783e040008090120416c6c207468652076616c696461746f7273206163746976656c792070617274696369706174696e6720696e2070617261636861696e20636f6e73656e7375732eb020496e64696365732061726520696e746f207468652062726f616465722076616c696461746f72207365742e4c41637469766556616c696461746f724b6579730100405665633c56616c696461746f7249643e0400088101205468652070617261636861696e206174746573746174696f6e206b657973206f66207468652076616c696461746f7273206163746976656c792070617274696369706174696e6720696e2070617261636861696e20636f6e73656e7375732ef020546869732073686f756c64206265207468652073616d65206c656e677468206173206041637469766556616c696461746f72496e6469636573602e01000000002b385061726173496e636c7573696f6e013450617261496e636c7573696f6e0c54417661696c6162696c6974794269746669656c64730001053856616c696461746f72496e646578a8417661696c6162696c6974794269746669656c645265636f72643c543a3a426c6f636b4e756d6265723e00040004650120546865206c6174657374206269746669656c6420666f7220656163682076616c696461746f722c20726566657272656420746f20627920746865697220696e64657820696e207468652076616c696461746f72207365742e4c50656e64696e67417661696c6162696c69747900010518506172614964d443616e64696461746550656e64696e67417661696c6162696c6974793c543a3a486173682c20543a3a426c6f636b4e756d6265723e00040004b42043616e646964617465732070656e64696e6720617661696c6162696c6974792062792060506172614964602e7850656e64696e67417661696c6162696c697479436f6d6d69746d656e7473000105185061726149645043616e646964617465436f6d6d69746d656e747300040004fc2054686520636f6d6d69746d656e7473206f662063616e646964617465732070656e64696e6720617661696c6162696c6974792c206279205061726149642e0100010c3c43616e6469646174654261636b6564105843616e646964617465526563656970743c486173683e20486561644461746124436f7265496e6465782847726f7570496e64657804bc20412063616e64696461746520776173206261636b65642e205b63616e6469646174652c20686561645f646174615d4443616e646964617465496e636c75646564105843616e646964617465526563656970743c486173683e20486561644461746124436f7265496e6465782847726f7570496e64657804c420412063616e6469646174652077617320696e636c756465642e205b63616e6469646174652c20686561645f646174615d4443616e64696461746554696d65644f75740c5843616e646964617465526563656970743c486173683e20486561644461746124436f7265496e64657804b820412063616e6469646174652074696d6564206f75742e205b63616e6469646174652c20686561645f646174615d00604457726f6e674269746669656c6453697a6504ac20417661696c6162696c697479206269746669656c642068617320756e65787065637465642073697a652e704269746669656c644475706c69636174654f72556e6f726465726564045101204d756c7469706c65206269746669656c6473207375626d69747465642062792073616d652076616c696461746f72206f722076616c696461746f7273206f7574206f66206f7264657220627920696e6465782e6456616c696461746f72496e6465784f75744f66426f756e6473047c2056616c696461746f7220696e646578206f7574206f6620626f756e64732e60496e76616c69644269746669656c645369676e6174757265044820496e76616c6964207369676e617475726550556e7363686564756c656443616e64696461746504b02043616e646964617465207375626d6974746564206275742070617261206e6f74207363686564756c65642e8043616e6469646174655363686564756c65644265666f726550617261467265650435012043616e646964617465207363686564756c656420646573706974652070656e64696e672063616e64696461746520616c7265616479206578697374696e6720666f722074686520706172612e3457726f6e67436f6c6c61746f7204b02043616e64696461746520696e636c756465642077697468207468652077726f6e6720636f6c6c61746f722e4c5363686564756c65644f75744f664f726465720478205363686564756c656420636f726573206f7574206f66206f726465722e404865616444617461546f6f4c6172676504a82048656164206461746120657863656564732074686520636f6e66696775726564206d6178696d756d2e505072656d6174757265436f646555706772616465046820436f64652075706772616465207072656d61747572656c792e3c4e6577436f6465546f6f4c617267650464204f757470757420636f646520697320746f6f206c617267656c43616e6469646174654e6f74496e506172656e74436f6e7465787404842043616e646964617465206e6f7420696e20706172656e7420636f6e746578742e5c556e6f63637570696564426974496e4269746669656c6404250120546865206269746669656c6420636f6e7461696e732061206269742072656c6174696e6720746f20616e20756e61737369676e656420617661696c6162696c69747920636f72652e44496e76616c696447726f7570496e64657804a020496e76616c69642067726f757020696e64657820696e20636f72652061737369676e6d656e742e4c496e73756666696369656e744261636b696e67049420496e73756666696369656e7420286e6f6e2d6d616a6f7269747929206261636b696e672e38496e76616c69644261636b696e6704e820496e76616c69642028626164207369676e61747572652c20756e6b6e6f776e2076616c696461746f722c206574632e29206261636b696e672e444e6f74436f6c6c61746f725369676e6564046c20436f6c6c61746f7220646964206e6f74207369676e20506f562e6856616c69646174696f6e44617461486173684d69736d6174636804c8205468652076616c69646174696f6e2064617461206861736820646f6573206e6f74206d617463682065787065637465642e34496e7465726e616c4572726f7204090120496e7465726e616c206572726f72206f6e6c792072657475726e6564207768656e20636f6d70696c6564207769746820646562756720617373657274696f6e732e80496e636f7272656374446f776e776172644d65737361676548616e646c696e6704dc2054686520646f776e77617264206d657373616765207175657565206973206e6f742070726f63657373656420636f72726563746c792e54496e76616c69645570776172644d65737361676573042101204174206c65617374206f6e6520757077617264206d6573736167652073656e7420646f6573206e6f7420706173732074686520616363657074616e63652063726974657269612e6048726d7057617465726d61726b4d697368616e646c696e67041501205468652063616e646964617465206469646e277420666f6c6c6f77207468652072756c6573206f662048524d502077617465726d61726b20616476616e63656d656e742e4c496e76616c69644f7574626f756e6448726d7004d8205468652048524d50206d657373616765732073656e74206279207468652063616e646964617465206973206e6f742076616c69642e64496e76616c696456616c69646174696f6e436f64654861736804e0205468652076616c69646174696f6e20636f64652068617368206f66207468652063616e646964617465206973206e6f742076616c69642e2c345061726173496e686572656e74013050617261496e686572656e740420496e636c756465640000082829040018ec20576865746865722074686520706172617320696e686572656e742077617320696e636c756465642077697468696e207468697320626c6f636b2e0061012054686520604f7074696f6e3c28293e60206973206566666563746976656c79206120626f6f6c2c20627574206974206e6576657220686974732073746f7261676520696e2074686520604e6f6e65602076617269616e74bc2064756520746f207468652067756172616e74656573206f66204652414d4527732073746f7261676520415049732e004901204966207468697320697320604e6f6e65602061742074686520656e64206f662074686520626c6f636b2c2077652070616e696320616e642072656e6465722074686520626c6f636b20696e76616c69642e010414656e7465720410646174618450617261636861696e73496e686572656e74446174613c543a3a4865616465723e04350120456e7465722074686520706172617320696e686572656e742e20546869732077696c6c2070726f63657373206269746669656c647320616e64206261636b65642063616e646964617465732e00000864546f6f4d616e79496e636c7573696f6e496e686572656e747304d020496e636c7573696f6e20696e686572656e742063616c6c6564206d6f7265207468616e206f6e63652070657220626c6f636b2e4c496e76616c6964506172656e74486561646572085901205468652068617368206f6620746865207375626d697474656420706172656e742068656164657220646f65736e277420636f72726573706f6e6420746f2074686520736176656420626c6f636b2068617368206f66302074686520706172656e742e2d3850617261735363686564756c65720134506172615363686564756c6572183c56616c696461746f7247726f7570730100605665633c5665633c56616c696461746f72496e6465783e3e0400186d0120416c6c207468652076616c696461746f722067726f7570732e204f6e6520666f72206561636820636f72652e20496e64696365732061726520696e746f206041637469766556616c696461746f727360202d206e6f74207468656d012062726f6164657220736574206f6620506f6c6b61646f742076616c696461746f72732c2062757420696e7374656164206a7573742074686520737562736574207573656420666f722070617261636861696e7320647572696e673820746869732073657373696f6e2e00810120426f756e643a20546865206e756d626572206f6620636f726573206973207468652073756d206f6620746865206e756d62657273206f662070617261636861696e7320616e642070617261746872656164206d756c7469706c65786572732e810120526561736f6e61626c792c203130302d313030302e2054686520646f6d696e616e7420666163746f7220697320746865206e756d626572206f662076616c696461746f72733a207361666520757070657220626f756e642061742031306b2e3c50617261746872656164517565756501005050617261746872656164436c61696d51756575651400000000001019012041207175657565206f66207570636f6d696e6720636c61696d7320616e6420776869636820636f726520746865792073686f756c64206265206d6170706564206f6e746f2e00150120546865206e756d626572206f662071756575656420636c61696d7320697320626f756e6465642061742074686520607363686564756c696e675f6c6f6f6b6168656164605501206d756c7469706c69656420627920746865206e756d626572206f662070617261746872656164206d756c7469706c6578657220636f7265732e20526561736f6e61626c792c203130202a203530203d203530302e44417661696c6162696c697479436f7265730100645665633c4f7074696f6e3c436f72654f636375706965643e3e0400209d01204f6e6520656e74727920666f72206561636820617661696c6162696c69747920636f72652e20456e74726965732061726520604e6f6e65602069662074686520636f7265206973206e6f742063757272656e746c79206f636375706965642e2043616e206265c82074656d706f726172696c792060536f6d6560206966207363686564756c656420627574206e6f74206f636375706965642e41012054686520692774682070617261636861696e2062656c6f6e677320746f20746865206927746820636f72652c2077697468207468652072656d61696e696e6720636f72657320616c6c206265696e676420706172617468726561642d6d756c7469706c65786572732e00d820426f756e64656420627920746865206d6178696d756d206f6620656974686572206f662074686573652074776f2076616c7565733ae42020202a20546865206e756d626572206f662070617261636861696e7320616e642070617261746872656164206d756c7469706c657865727345012020202a20546865206e756d626572206f662076616c696461746f727320646976696465642062792060636f6e66696775726174696f6e2e6d61785f76616c696461746f72735f7065725f636f7265602e5050617261746872656164436c61696d496e64657801002c5665633c5061726149643e040010590120416e20696e646578207573656420746f20656e737572652074686174206f6e6c79206f6e6520636c61696d206f6e206120706172617468726561642065786973747320696e20746865207175657565206f72206973b42063757272656e746c79206265696e672068616e646c656420627920616e206f6363757069656420636f72652e007d0120426f756e64656420627920746865206e756d626572206f66207061726174687265616420636f72657320616e64207363686564756c696e67206c6f6f6b61686561642e20526561736f6e61626c792c203130202a203530203d203530302e4453657373696f6e5374617274426c6f636b010038543a3a426c6f636b4e756d626572100000000018a5012054686520626c6f636b206e756d626572207768657265207468652073657373696f6e207374617274206f636375727265642e205573656420746f20747261636b20686f77206d616e792067726f757020726f746174696f6e732068617665206f636375727265642e005901204e6f7465207468617420696e2074686520636f6e74657874206f662070617261636861696e73206d6f64756c6573207468652073657373696f6e206368616e6765206973207369676e616c6c656420647572696e6761012074686520626c6f636b20616e6420656e61637465642061742074686520656e64206f662074686520626c6f636b20286174207468652066696e616c697a6174696f6e2073746167652c20746f206265206578616374292e5901205468757320666f7220616c6c20696e74656e747320616e6420707572706f7365732074686520656666656374206f66207468652073657373696f6e206368616e6765206973206f6273657276656420617420746865650120626c6f636b20666f6c6c6f77696e67207468652073657373696f6e206368616e67652c20626c6f636b206e756d626572206f66207768696368207765207361766520696e20746869732073746f726167652076616c75652e245363686564756c656401004c5665633c436f726541737369676e6d656e743e040018e02043757272656e746c79207363686564756c656420636f726573202d20667265652062757420757020746f206265206f636375706965642e004d0120426f756e64656420627920746865206e756d626572206f6620636f7265733a206f6e6520666f7220656163682070617261636861696e20616e642070617261746872656164206d756c7469706c657865722e00fd01205468652076616c756520636f6e7461696e656420686572652077696c6c206e6f742062652076616c69642061667465722074686520656e64206f66206120626c6f636b2e2052756e74696d6520415049732073686f756c64206265207573656420746f2064657465726d696e65207363686564756c656420636f7265732f6020666f7220746865207570636f6d696e6720626c6f636b2e01000000002e14506172617301145061726173342850617261636861696e7301002c5665633c5061726149643e0400042d0120416c6c2070617261636861696e732e204f72646572656420617363656e64696e67206279205061726149642e20506172617468726561647320617265206e6f7420696e636c756465642e38506172614c6966656379636c65730001051850617261496434506172614c6966656379636c6500040004bc205468652063757272656e74206c6966656379636c65206f66206120616c6c206b6e6f776e2050617261204944732e1448656164730001051850617261496420486561644461746100040004a02054686520686561642d64617461206f66206576657279207265676973746572656420706172612e3c43757272656e74436f6465486173680001051850617261496410486173680004000cb4205468652076616c69646174696f6e20636f64652068617368206f66206576657279206c69766520706172612e00e420436f72726573706f6e64696e6720636f64652063616e206265207265747269657665642077697468205b60436f6465427948617368605d2e3050617374436f64654861736800010560285061726149642c20543a3a426c6f636b4e756d6265722910486173680004001061012041637475616c207061737420636f646520686173682c20696e646963617465642062792074686520706172612069642061732077656c6c2061732074686520626c6f636b206e756d6265722061742077686963682069744420626563616d65206f757464617465642e00e420436f72726573706f6e64696e6720636f64652063616e206265207265747269657665642077697468205b60436f6465427948617368605d2e3050617374436f64654d65746101010518506172614964805061726150617374436f64654d6574613c543a3a426c6f636b4e756d6265723e000800000c4901205061737420636f6465206f662070617261636861696e732e205468652070617261636861696e73207468656d73656c766573206d6179206e6f74206265207265676973746572656420616e796d6f72652c49012062757420776520616c736f206b65657020746865697220636f6465206f6e2d636861696e20666f72207468652073616d6520616d6f756e74206f662074696d65206173206f7574646174656420636f6465b420746f206b65657020697420617661696c61626c6520666f72207365636f6e6461727920636865636b6572732e3c50617374436f64655072756e696e670100745665633c285061726149642c20543a3a426c6f636b4e756d626572293e040018a1012057686963682070617261732068617665207061737420636f64652074686174206e65656473207072756e696e6720616e64207468652072656c61792d636861696e20626c6f636b2061742077686963682074686520636f646520776173207265706c616365642e8101204e6f746520746861742074686973206973207468652061637475616c20686569676874206f662074686520696e636c7564656420626c6f636b2c206e6f74207468652065787065637465642068656967687420617420776869636820746865ec20636f6465207570677261646520776f756c64206265206170706c6965642c20616c74686f7567682074686579206d617920626520657175616c2e9101205468697320697320746f20656e737572652074686520656e7469726520616363657074616e636520706572696f6420697320636f76657265642c206e6f7420616e206f666673657420616363657074616e636520706572696f64207374617274696e6749012066726f6d207468652074696d65206174207768696368207468652070617261636861696e20706572636569766573206120636f6465207570677261646520617320686176696e67206f636375727265642e5501204d756c7469706c6520656e747269657320666f7220612073696e676c65207061726120617265207065726d69747465642e204f72646572656420617363656e64696e6720627920626c6f636b206e756d6265722e48467574757265436f646555706772616465730001051850617261496438543a3a426c6f636b4e756d6265720004000c29012054686520626c6f636b206e756d6265722061742077686963682074686520706c616e6e656420636f6465206368616e676520697320657870656374656420666f72206120706172612e650120546865206368616e67652077696c6c206265206170706c696564206166746572207468652066697273742070617261626c6f636b20666f72207468697320494420696e636c75646564207768696368206578656375746573190120696e2074686520636f6e74657874206f6620612072656c617920636861696e20626c6f636b20776974682061206e756d626572203e3d206065787065637465645f6174602e38467574757265436f6465486173680001051850617261496410486173680004000c9c205468652061637475616c2066757475726520636f64652068617368206f66206120706172612e00e420436f72726573706f6e64696e6720636f64652063616e206265207265747269657665642077697468205b60436f6465427948617368605d2e30416374696f6e7351756575650101053053657373696f6e496e6465782c5665633c5061726149643e0004000415012054686520616374696f6e7320746f20706572666f726d20647572696e6720746865207374617274206f6620612073706563696669632073657373696f6e20696e6465782e505570636f6d696e67506172617347656e65736973000105185061726149643c5061726147656e657369734172677300040004a0205570636f6d696e6720706172617320696e7374616e74696174696f6e20617267756d656e74732e38436f64654279486173685265667301010610486173680c75333200100000000004290120546865206e756d626572206f66207265666572656e6365206f6e207468652076616c69646174696f6e20636f646520696e205b60436f6465427948617368605d2073746f726167652e28436f646542794861736800010610486173683856616c69646174696f6e436f646500040010902056616c69646174696f6e20636f64652073746f7265642062792069747320686173682e00310120546869732073746f7261676520697320636f6e73697374656e742077697468205b60467574757265436f646548617368605d2c205b6043757272656e74436f646548617368605d20616e6448205b6050617374436f646548617368605d2e011458666f7263655f7365745f63757272656e745f636f646508107061726118506172614964206e65775f636f64653856616c69646174696f6e436f646504fc20536574207468652073746f7261676520666f72207468652070617261636861696e2076616c69646174696f6e20636f646520696d6d6564696174656c792e58666f7263655f7365745f63757272656e745f6865616408107061726118506172614964206e65775f6865616420486561644461746104050120536574207468652073746f7261676520666f72207468652063757272656e742070617261636861696e2068656164206461746120696d6d6564696174656c792e6c666f7263655f7363686564756c655f636f64655f757067726164650c107061726118506172614964206e65775f636f64653856616c69646174696f6e436f64652c65787065637465645f617438543a3a426c6f636b4e756d62657204c4205363686564756c65206120636f6465207570677261646520666f7220626c6f636b206065787065637465645f6174602e4c666f7263655f6e6f74655f6e65775f6865616408107061726118506172614964206e65775f68656164204865616444617461042101204e6f74652061206e657720626c6f636b206865616420666f7220706172612077697468696e2074686520636f6e74657874206f66207468652063757272656e7420626c6f636b2e48666f7263655f71756575655f616374696f6e041070617261185061726149640cfc2050757420612070617261636861696e206469726563746c7920696e746f20746865206e6578742073657373696f6e277320616374696f6e2071756575652ef82057652063616e277420717565756520697420616e7920736f6f6e6572207468616e207468697320776974686f757420676f696e6720696e746f207468653c20696e697469616c697a65722e2e2e01144843757272656e74436f646555706461746564041850617261496404d82043757272656e7420636f646520686173206265656e207570646174656420666f72206120506172612e205c5b706172615f69645c5d4843757272656e744865616455706461746564041850617261496404d82043757272656e74206865616420686173206265656e207570646174656420666f72206120506172612e205c5b706172615f69645c5d50436f6465557067726164655363686564756c6564041850617261496404e8204120636f6465207570677261646520686173206265656e207363686564756c656420666f72206120506172612e205c5b706172615f69645c5d304e6577486561644e6f746564041850617261496404c82041206e6577206865616420686173206265656e206e6f74656420666f72206120506172612e205c5b706172615f69645c5d30416374696f6e51756575656408185061726149643053657373696f6e496e64657804fc2041207061726120686173206265656e2071756575656420746f20657865637574652070656e64696e6720616374696f6e732e205c5b706172615f69645c5d0014344e6f745265676973746572656404982050617261206973206e6f74207265676973746572656420696e206f75722073797374656d2e3443616e6e6f744f6e626f61726404190120506172612063616e6e6f74206265206f6e626f6172646564206265636175736520697420697320616c726561647920747261636b6564206279206f75722073797374656d2e3843616e6e6f744f6666626f61726404a020506172612063616e6e6f74206265206f6666626f617264656420617420746869732074696d652e3443616e6e6f745570677261646504a020506172612063616e6e6f7420626520757067726164656420746f20612070617261636861696e2e3c43616e6e6f74446f776e677261646504ac20506172612063616e6e6f7420626520646f776e67726164656420746f206120706172617468726561642e2f405061726173496e697469616c697a6572012c496e697469616c697a65720838486173496e697469616c697a6564000008282904002021012057686574686572207468652070617261636861696e73206d6f64756c65732068617665206265656e20696e697469616c697a65642077697468696e207468697320626c6f636b2e001d012053656d616e746963616c6c79206120626f6f6c2c2062757420746869732067756172616e746565732069742073686f756c64206e65766572206869742074686520747269652c6901206173207468697320697320636c656172656420696e20606f6e5f66696e616c697a656020616e64204672616d65206f7074696d697a657320604e6f6e65602076616c75657320746f20626520656d7074792076616c7565732e007501204173206120626f6f6c2c20607365742866616c7365296020616e64206072656d6f766528296020626f7468206c65616420746f20746865206e6578742060676574282960206265696e672066616c73652c20627574206f6e65206f667901207468656d2077726974657320746f20746865207472696520616e64206f6e6520646f6573206e6f742e205468697320636f6e667573696f6e206d616b657320604f7074696f6e3c28293e60206d6f7265207375697461626c6520666f7280207468652073656d616e74696373206f662074686973207661726961626c652e58427566666572656453657373696f6e4368616e6765730100685665633c427566666572656453657373696f6e4368616e67653e04001c59012042756666657265642073657373696f6e206368616e67657320616c6f6e6720776974682074686520626c6f636b206e756d62657220617420776869636820746865792073686f756c64206265206170706c6965642e005d01205479706963616c6c7920746869732077696c6c20626520656d707479206f72206f6e6520656c656d656e74206c6f6e672e2041706172742066726f6d20746861742074686973206974656d206e65766572206869747334207468652073746f726167652e00690120486f776576657220746869732069732061206056656360207265676172646c65737320746f2068616e646c6520766172696f757320656467652063617365732074686174206d6179206f636375722061742072756e74696d65c0207570677261646520626f756e646172696573206f7220696620676f7665726e616e636520696e74657276656e65732e010434666f7263655f617070726f7665041475705f746f2c426c6f636b4e756d6265720c3d012049737375652061207369676e616c20746f2074686520636f6e73656e73757320656e67696e6520746f20666f726369626c79206163742061732074686f75676820616c6c2070617261636861696e550120626c6f636b7320696e20616c6c2072656c617920636861696e20626c6f636b7320757020746f20616e6420696e636c7564696e672074686520676976656e206e756d62657220696e207468652063757272656e74a420636861696e206172652076616c696420616e642073686f756c642062652066696e616c697a65642e00000030205061726173446d70010c446d700854446f776e776172644d65737361676551756575657301010518506172614964ac5665633c496e626f756e64446f776e776172644d6573736167653c543a3a426c6f636b4e756d6265723e3e00040004d02054686520646f776e77617264206d657373616765732061646472657373656420666f722061206365727461696e20706172612e64446f776e776172644d65737361676551756575654865616473010105185061726149641048617368008000000000000000000000000000000000000000000000000000000000000000001c25012041206d617070696e6720746861742073746f7265732074686520646f776e77617264206d657373616765207175657565204d5143206865616420666f72206561636820706172612e00902045616368206c696e6b20696e207468697320636861696e20686173206120666f726d3a78206028707265765f686561642c20422c2048284d2929602c207768657265e8202d2060707265765f68656164603a206973207468652070726576696f757320686561642068617368206f72207a65726f206966206e6f6e652e2101202d206042603a206973207468652072656c61792d636861696e20626c6f636b206e756d62657220696e2077686963682061206d6573736167652077617320617070656e6465642ed4202d206048284d29603a206973207468652068617368206f6620746865206d657373616765206265696e6720617070656e6465642e010000000031205061726173556d70010c556d70104c52656c61794469737061746368517565756573010105185061726149645c56656344657175653c5570776172644d6573736167653e00040018710120546865206d657373616765732077616974696e6720746f2062652068616e646c6564206279207468652072656c61792d636861696e206f726967696e6174696e672066726f6d2061206365727461696e2070617261636861696e2e007901204e6f7465207468617420736f6d6520757077617264206d65737361676573206d696768742068617665206265656e20616c72656164792070726f6365737365642062792074686520696e636c7573696f6e206c6f6769632e20452e672e74206368616e6e656c206d616e6167656d656e74206d657373616765732e00a820546865206d65737361676573206172652070726f63657373656420696e204649464f206f726465722e5852656c61794469737061746368517565756553697a650101051850617261496428287533322c2075333229002000000000000000002c45012053697a65206f6620746865206469737061746368207175657565732e204361636865732073697a6573206f66207468652071756575657320696e206052656c617944697370617463685175657565602e00f0204669727374206974656d20696e20746865207475706c652069732074686520636f756e74206f66206d6573736167657320616e64207365636f6e64e02069732074686520746f74616c206c656e6774682028696e20627974657329206f6620746865206d657373616765207061796c6f6164732e007501204e6f74652074686174207468697320697320616e20617578696c617279206d617070696e673a206974277320706f737369626c6520746f2074656c6c2074686520627974652073697a6520616e6420746865206e756d626572206f667901206d65737361676573206f6e6c79206c6f6f6b696e67206174206052656c61794469737061746368517565756573602e2054686973206d617070696e6720697320736570617261746520746f2061766f69642074686520636f7374206f663d01206c6f6164696e67207468652077686f6c65206d657373616765207175657565206966206f6e6c792074686520746f74616c2073697a6520616e6420636f756e74206172652072657175697265642e002c20496e76617269616e743a4501202d2054686520736574206f66206b6579732073686f756c642065786163746c79206d617463682074686520736574206f66206b657973206f66206052656c61794469737061746368517565756573602e344e65656473446973706174636801002c5665633c5061726149643e040014190120546865206f726465726564206c697374206f6620605061726149646073207468617420686176652061206052656c6179446973706174636851756575656020656e7472792e002c20496e76617269616e743a3501202d2054686520736574206f66206974656d732066726f6d207468697320766563746f722073686f756c642062652065786163746c792074686520736574206f6620746865206b65797320696ed82020206052656c617944697370617463685175657565736020616e64206052656c61794469737061746368517565756553697a65602e684e6578744469737061746368526f756e645374617274576974680000185061726149640400147d012054686973206973207468652070617261207468617420676574732077696c6c20676574206469737061746368656420666972737420647572696e6720746865206e6578742075707761726420646973706174636861626c652071756575654420657865637574696f6e20726f756e642e002c20496e76617269616e743a0d01202d2049662060536f6d65287061726129602c207468656e20607061726160206d7573742062652070726573656e7420696e20604e656564734469737061746368602e01000000003224506172617348726d70011048726d70305c48726d704f70656e4368616e6e656c52657175657374730001053448726d704368616e6e656c49645848726d704f70656e4368616e6e656c5265717565737400040018bc2054686520736574206f662070656e64696e672048524d50206f70656e206368616e6e656c2072657175657374732e00c02054686520736574206973206163636f6d70616e6965642062792061206c69737420666f7220697465726174696f6e2e002c20496e76617269616e743a3d01202d20546865726520617265206e6f206368616e6e656c7320746861742065786973747320696e206c69737420627574206e6f7420696e207468652073657420616e6420766963652076657273612e6c48726d704f70656e4368616e6e656c52657175657374734c6973740100485665633c48726d704368616e6e656c49643e0400006c48726d704f70656e4368616e6e656c52657175657374436f756e74010105185061726149640c7533320010000000000c69012054686973206d617070696e6720747261636b7320686f77206d616e79206f70656e206368616e6e656c2072657175657374732061726520696e6974697461746564206279206120676976656e2073656e64657220706172612e7d0120496e76617269616e743a206048726d704f70656e4368616e6e656c5265717565737473602073686f756c6420636f6e7461696e207468652073616d65206e756d626572206f66206974656d73207468617420686173206028582c205f2960e020617320746865206e756d626572206f66206048726d704f70656e4368616e6e656c52657175657374436f756e746020666f72206058602e7c48726d7041636365707465644368616e6e656c52657175657374436f756e74010105185061726149640c7533320010000000000c71012054686973206d617070696e6720747261636b7320686f77206d616e79206f70656e206368616e6e656c2072657175657374732077657265206163636570746564206279206120676976656e20726563697069656e7420706172612e6d0120496e76617269616e743a206048726d704f70656e4368616e6e656c5265717565737473602073686f756c6420636f6e7461696e207468652073616d65206e756d626572206f66206974656d732060285f2c20582960207769746855012060636f6e6669726d6564602073657420746f20747275652c20617320746865206e756d626572206f66206048726d7041636365707465644368616e6e656c52657175657374436f756e746020666f72206058602e6048726d70436c6f73654368616e6e656c52657175657374730001053448726d704368616e6e656c49640828290004001c9101204120736574206f662070656e64696e672048524d5020636c6f7365206368616e6e656c20726571756573747320746861742061726520676f696e6720746f20626520636c6f73656420647572696e67207468652073657373696f6e206368616e67652e0101205573656420666f7220636865636b696e67206966206120676976656e206368616e6e656c206973207265676973746572656420666f7220636c6f737572652e00c02054686520736574206973206163636f6d70616e6965642062792061206c69737420666f7220697465726174696f6e2e002c20496e76617269616e743a3d01202d20546865726520617265206e6f206368616e6e656c7320746861742065786973747320696e206c69737420627574206e6f7420696e207468652073657420616e6420766963652076657273612e7048726d70436c6f73654368616e6e656c52657175657374734c6973740100485665633c48726d704368616e6e656c49643e0400003848726d7057617465726d61726b730001051850617261496438543a3a426c6f636b4e756d6265720004000cb8205468652048524d502077617465726d61726b206173736f6369617465642077697468206561636820706172612e2c20496e76617269616e743a7901202d2065616368207061726120605060207573656420686572652061732061206b65792073686f756c642073617469736679206050617261733a3a69735f76616c69645f70617261285029602077697468696e20612073657373696f6e2e3048726d704368616e6e656c730001053448726d704368616e6e656c49642c48726d704368616e6e656c0004000cb42048524d50206368616e6e656c2064617461206173736f6369617465642077697468206561636820706172612e2c20496e76617269616e743a7501202d2065616368207061727469636970616e7420696e20746865206368616e6e656c2073686f756c642073617469736679206050617261733a3a69735f76616c69645f70617261285029602077697468696e20612073657373696f6e2e6048726d70496e67726573734368616e6e656c73496e646578010105185061726149642c5665633c5061726149643e00040034590120496e67726573732f65677265737320696e646578657320616c6c6f7720746f2066696e6420616c6c207468652073656e6465727320616e642072656365697665727320676976656e20746865206f70706f736974652c20736964652e20492e652e0021012028612920696e677265737320696e64657820616c6c6f777320746f2066696e6420616c6c207468652073656e6465727320666f72206120676976656e20726563697069656e742e1d01202862292065677265737320696e64657820616c6c6f777320746f2066696e6420616c6c2074686520726563697069656e747320666f72206120676976656e2073656e6465722e003020496e76617269616e74733a8d01202d20666f72206561636820696e677265737320696e64657820656e74727920666f72206050602065616368206974656d2060496020696e2074686520696e6465782073686f756c642070726573656e7420696e206048726d704368616e6e656c73603c2020206173206028492c205029602e8901202d20666f7220656163682065677265737320696e64657820656e74727920666f72206050602065616368206974656d2060456020696e2074686520696e6465782073686f756c642070726573656e7420696e206048726d704368616e6e656c73603c2020206173206028502c204529602e0101202d2074686572652073686f756c64206265206e6f206f746865722064616e676c696e67206368616e6e656c7320696e206048726d704368616e6e656c73602e68202d2074686520766563746f72732061726520736f727465642e5c48726d704567726573734368616e6e656c73496e646578010105185061726149642c5665633c5061726149643e000400004c48726d704368616e6e656c436f6e74656e74730101053448726d704368616e6e656c49649c5665633c496e626f756e6448726d704d6573736167653c543a3a426c6f636b4e756d6265723e3e00040008ac2053746f7261676520666f7220746865206d6573736167657320666f722065616368206368616e6e656c2e650120496e76617269616e743a2063616e6e6f74206265206e6f6e2d656d7074792069662074686520636f72726573706f6e64696e67206368616e6e656c20696e206048726d704368616e6e656c736020697320604e6f6e65602e4848726d704368616e6e656c4469676573747301010518506172614964885665633c28543a3a426c6f636b4e756d6265722c205665633c5061726149643e293e0004001cf4204d61696e7461696e732061206d617070696e6720746861742063616e206265207573656420746f20616e7377657220746865207175657374696f6e3a290120576861742070617261732073656e742061206d6573736167652061742074686520676976656e20626c6f636b206e756d62657220666f72206120676976656e2072656369657665722e3020496e76617269616e74733aa8202d2054686520696e6e657220605665633c5061726149643e60206973206e6576657220656d7074792ee8202d2054686520696e6e657220605665633c5061726149643e602063616e6e6f742073746f72652074776f2073616d652060506172614964602e8101202d20546865206f7574657220766563746f7220697320736f7274656420617363656e64696e6720627920626c6f636b206e756d62657220616e642063616e6e6f742073746f72652074776f206974656d732077697468207468652073616d6540202020626c6f636b206e756d6265722e01185868726d705f696e69745f6f70656e5f6368616e6e656c0c24726563697069656e74185061726149645470726f706f7365645f6d61785f63617061636974790c7533326470726f706f7365645f6d61785f6d6573736167655f73697a650c75333228510120496e697469617465206f70656e696e672061206368616e6e656c2066726f6d20612070617261636861696e20746f206120676976656e20726563697069656e74207769746820676976656e206368616e6e656c3020706172616d65746572732e005d01202d206070726f706f7365645f6d61785f636170616369747960202d2073706563696669657320686f77206d616e79206d657373616765732063616e20626520696e20746865206368616e6e656c206174206f6e63652e4d01202d206070726f706f7365645f6d61785f6d6573736167655f73697a6560202d2073706563696669657320746865206d6178696d756d2073697a65206f6620616e79206f6620746865206d657373616765732e001501205468657365206e756d62657273206172652061207375626a65637420746f207468652072656c61792d636861696e20636f6e66696775726174696f6e206c696d6974732e00550120546865206368616e6e656c2063616e206265206f70656e6564206f6e6c792061667465722074686520726563697069656e7420636f6e6669726d7320697420616e64206f6e6c79206f6e20612073657373696f6e20206368616e67652e6068726d705f6163636570745f6f70656e5f6368616e6e656c041873656e646572185061726149640cf42041636365707420612070656e64696e67206f70656e206368616e6e656c20726571756573742066726f6d2074686520676976656e2073656e6465722e00f820546865206368616e6e656c2077696c6c206265206f70656e6564206f6e6c79206f6e20746865206e6578742073657373696f6e20626f756e646172792e4868726d705f636c6f73655f6368616e6e656c04286368616e6e656c5f69643448726d704368616e6e656c496410590120496e69746961746520756e696c61746572616c20636c6f73696e67206f662061206368616e6e656c2e20546865206f726967696e206d75737420626520656974686572207468652073656e646572206f72207468659c20726563697069656e7420696e20746865206368616e6e656c206265696e6720636c6f7365642e00c42054686520636c6f737572652063616e206f6e6c792068617070656e206f6e20612073657373696f6e206368616e67652e40666f7263655f636c65616e5f68726d7004107061726118506172614964141d0120546869732065787472696e7369632074726967676572732074686520636c65616e7570206f6620616c6c207468652048524d502073746f72616765206974656d732074686174250120612070617261206d617920686176652e204e6f726d616c6c7920746869732068617070656e73206f6e6365207065722073657373696f6e2c20627574207468697320616c6c6f7773050120796f7520746f20747269676765722074686520636c65616e757020696d6d6564696174656c7920666f7220612073706563696669632070617261636861696e2e0054204f726967696e206d75737420626520526f6f742e5c666f7263655f70726f636573735f68726d705f6f70656e0010a820466f7263652070726f636573732068726d70206f70656e206368616e6e656c2072657175657374732e000901204966207468657265206172652070656e64696e672048524d50206f70656e206368616e6e656c2072657175657374732c20796f752063616e207573652074686973d02066756e6374696f6e2070726f6365737320616c6c206f662074686f736520726571756573747320696d6d6564696174656c792e60666f7263655f70726f636573735f68726d705f636c6f73650010ac20466f7263652070726f636573732068726d7020636c6f7365206368616e6e656c2072657175657374732e000d01204966207468657265206172652070656e64696e672048524d5020636c6f7365206368616e6e656c2072657175657374732c20796f752063616e207573652074686973d02066756e6374696f6e2070726f6365737320616c6c206f662074686f736520726571756573747320696d6d6564696174656c792e010c504f70656e4368616e6e656c5265717565737465641018506172614964185061726149640c7533320c7533320874204f70656e2048524d50206368616e6e656c207265717565737465642e2101205c5b73656e6465722c20726563697069656e742c2070726f706f7365645f6d61785f63617061636974792c2070726f706f7365645f6d61785f6d6573736167655f73697a655c5d4c4f70656e4368616e6e656c416363657074656408185061726149641850617261496404c8204f70656e2048524d50206368616e6e656c2061636365707465642e205c5b73656e6465722c20726563697069656e745c5d344368616e6e656c436c6f73656408185061726149643448726d704368616e6e656c496404c82048524d50206368616e6e656c20636c6f7365642e205c5b62795f70617261636861696e2c206368616e6e656c5f69645c5d003c544f70656e48726d704368616e6e656c546f53656c6604c8205468652073656e64657220747269656420746f206f70656e2061206368616e6e656c20746f207468656d73656c7665732e7c4f70656e48726d704368616e6e656c496e76616c6964526563697069656e74048c2054686520726563697069656e74206973206e6f7420612076616c696420706172612e6c4f70656e48726d704368616e6e656c5a65726f436170616369747904802054686520726571756573746564206361706163697479206973207a65726f2e8c4f70656e48726d704368616e6e656c4361706163697479457863656564734c696d697404c4205468652072657175657374656420636170616369747920657863656564732074686520676c6f62616c206c696d69742e784f70656e48726d704368616e6e656c5a65726f4d65737361676553697a6504a42054686520726571756573746564206d6178696d756d206d6573736167652073697a6520697320302e984f70656e48726d704368616e6e656c4d65737361676553697a65457863656564734c696d6974042d0120546865206f70656e20726571756573742072657175657374656420746865206d6573736167652073697a65207468617420657863656564732074686520676c6f62616c206c696d69742e704f70656e48726d704368616e6e656c416c7265616479457869737473046c20546865206368616e6e656c20616c7265616479206578697374737c4f70656e48726d704368616e6e656c416c726561647952657175657374656404d420546865726520697320616c72656164792061207265717565737420746f206f70656e207468652073616d65206368616e6e656c2e704f70656e48726d704368616e6e656c4c696d69744578636565646564042101205468652073656e64657220616c72656164792068617320746865206d6178696d756d206e756d626572206f6620616c6c6f776564206f7574626f756e64206368616e6e656c732e7041636365707448726d704368616e6e656c446f65736e74457869737404e420546865206368616e6e656c2066726f6d207468652073656e64657220746f20746865206f726967696e20646f65736e27742065786973742e8441636365707448726d704368616e6e656c416c7265616479436f6e6669726d6564048820546865206368616e6e656c20697320616c726561647920636f6e6669726d65642e7841636365707448726d704368616e6e656c4c696d697445786365656465640429012054686520726563697069656e7420616c72656164792068617320746865206d6178696d756d206e756d626572206f6620616c6c6f77656420696e626f756e64206368616e6e656c732e70436c6f736548726d704368616e6e656c556e617574686f72697a656404590120546865206f726967696e20747269657320746f20636c6f73652061206368616e6e656c207768657265206974206973206e656974686572207468652073656e646572206e6f722074686520726563697069656e742e6c436c6f736548726d704368616e6e656c446f65736e74457869737404a020546865206368616e6e656c20746f20626520636c6f73656420646f65736e27742065786973742e7c436c6f736548726d704368616e6e656c416c7265616479556e64657277617904c020546865206368616e6e656c20636c6f7365207265717565737420697320616c7265616479207265717565737465642e3340506172617353657373696f6e496e666f013c5061726153657373696f6e496e666f0c5041737369676e6d656e744b657973556e736166650100445665633c41737369676e6d656e7449643e04000ca42041737369676e6d656e74206b65797320666f72207468652063757272656e742073657373696f6e2e6d01204e6f7465207468617420746869732041504920697320707269766174652064756520746f206974206265696e672070726f6e6520746f20276f66662d62792d6f6e65272061742073657373696f6e20626f756e6461726965732eac205768656e20696e20646f7562742c20757365206053657373696f6e73602041504920696e73746561642e544561726c6965737453746f72656453657373696f6e01003053657373696f6e496e646578100000000004010120546865206561726c696573742073657373696f6e20666f722077686963682070726576696f75732073657373696f6e20696e666f2069732073746f7265642e2053657373696f6e730001063053657373696f6e496e6465782c53657373696f6e496e666f0004000ca42053657373696f6e20696e666f726d6174696f6e20696e206120726f6c6c696e672077696e646f772e35012053686f756c64206861766520616e20656e74727920696e2072616e676520604561726c6965737453746f72656453657373696f6e2e2e3d43757272656e7453657373696f6e496e646578602e750120446f6573206e6f74206861766520616e7920656e7472696573206265666f7265207468652073657373696f6e20696e64657820696e207468652066697273742073657373696f6e206368616e6765206e6f74696669636174696f6e2e010000000034245265676973747261720124526567697374726172082c50656e64696e6753776170000105185061726149641850617261496400040004642050656e64696e672073776170206f7065726174696f6e732e145061726173000105185061726149649050617261496e666f3c543a3a4163636f756e7449642c2042616c616e63654f663c543e3e00040010050120416d6f756e742068656c64206f6e206465706f73697420666f722065616368207061726120616e6420746865206f726967696e616c206465706f7369746f722e0091012054686520676976656e206163636f756e7420494420697320726573706f6e7369626c6520666f72207265676973746572696e672074686520636f646520616e6420696e697469616c206865616420646174612c20627574206d6179206f6e6c7920646f350120736f2069662069742069736e27742079657420726567697374657265642e2028416674657220746861742c206974277320757020746f20676f7665726e616e636520746f20646f20736f2e2901142072656769737465720c086964185061726149643067656e657369735f686561642048656164446174613c76616c69646174696f6e5f636f64653856616c69646174696f6e436f64652c9c20526567697374657220612050617261204964206f6e207468652072656c617920636861696e2e00f420546869732066756e6374696f6e2077696c6c20717565756520746865206e6577205061726120496420746f206265206120706172617468726561642e0d01205573696e672074686520536c6f74732070616c6c65742c206120706172617468726561642063616e207468656e20626520757067726164656420746f206765742061402070617261636861696e20736c6f742e00c420546869732066756e6374696f6e206d7573742062652063616c6c65642062792061207369676e6564206f726967696e2e00010120546865206f726967696e206d757374207061792061206465706f73697420666f722074686520726567697374726174696f6e20696e666f726d6174696f6e2cf820696e636c7564696e67207468652067656e6573697320696e666f726d6174696f6e20616e642076616c69646174696f6e20636f64652e205061726149649c206d7573742062652067726561746572207468616e206f7220657175616c20746f20313030302e38666f7263655f7265676973746572140c77686f30543a3a4163636f756e7449641c6465706f7369743042616c616e63654f663c543e086964185061726149643067656e657369735f686561642048656164446174613c76616c69646174696f6e5f636f64653856616c69646174696f6e436f646518e020466f7263652074686520726567697374726174696f6e206f6620612050617261204964206f6e207468652072656c617920636861696e2e00bc20546869732066756e6374696f6e206d7573742062652063616c6c6564206279206120526f6f74206f726967696e2e00150120546865206465706f7369742074616b656e2063616e2062652073706563696669656420666f72207468697320726567697374726174696f6e2e20416e79205061726149641d012063616e20626520726567697374657265642c20696e636c7564696e67207375622d3130303020494473207768696368206172652053797374656d2050617261636861696e732e286465726567697374657204086964185061726149640c09012044657265676973746572206120506172612049642c2066726565696e6720616c6c206461746120616e642072657475726e696e6720616e79206465706f7369742e008101205468652063616c6c6572206d75737420626520526f6f742c2074686520607061726160206f776e65722c206f72207468652060706172616020697473656c662e205468652070617261206d757374206265206120706172617468726561642e10737761700808696418506172614964146f74686572185061726149642cdc205377617020612070617261636861696e207769746820616e6f746865722070617261636861696e206f7220706172617468726561642e00050120546865206f726967696e206d75737420626520526f6f742c2074686520607061726160206f776e65722c206f72207468652060706172616020697473656c662e0065012054686520737761702077696c6c2068617070656e206f6e6c7920696620746865726520697320616c726561647920616e206f70706f7369746520737761702070656e64696e672e204966207468657265206973206e6f742c5d012074686520737761702077696c6c2062652073746f72656420696e207468652070656e64696e67207377617073206d61702c20726561647920666f722061206c6174657220636f6e6669726d61746f727920737761702e00610120546865206050617261496460732072656d61696e206d617070656420746f207468652073616d652068656164206461746120616e6420636f646520736f2065787465726e616c20636f64652063616e2072656c79206f6e410120605061726149646020746f2062652061206c6f6e672d7465726d206964656e746966696572206f662061206e6f74696f6e616c202270617261636861696e222e20486f77657665722c2074686569725901207363686564756c696e6720696e666f2028692e652e2077686574686572207468657927726520612070617261746872656164206f722070617261636861696e292c2061756374696f6e20696e666f726d6174696f6e9820616e64207468652061756374696f6e206465706f736974206172652073776974636865642e44666f7263655f72656d6f76655f6c6f636b041070617261185061726149641011012052656d6f76652061206d616e61676572206c6f636b2066726f6d206120706172612e20546869732077696c6c20616c6c6f7720746865206d616e61676572206f66206139012070726576696f75736c79206c6f636b6564207061726120746f2064657265676973746572206f7220737761702061207061726120776974686f7574207573696e6720676f7665726e616e63652e009c2043616e206f6e6c792062652063616c6c65642062792074686520526f6f74206f726967696e2e010828526567697374657265640818506172614964244163636f756e7449640030446572656769737465726564041850617261496400102c506172614465706f7369743042616c616e63654f663c543e400040e59c3012000000000000000000000048446174614465706f736974506572427974653042616c616e63654f663c543e4080f0fa02000000000000000000000000002c4d6178436f646553697a650c7533321000005000002c4d61784865616453697a650c75333210005000000030344e6f7452656769737465726564046820546865204944206973206e6f7420726567697374657265642e44416c72656164795265676973746572656404782054686520494420697320616c726561647920726567697374657265642e204e6f744f776e657204a0205468652063616c6c6572206973206e6f7420746865206f776e6572206f6620746869732049642e30436f6465546f6f4c61726765046020496e76616c6964207061726120636f64652073697a652e404865616444617461546f6f4c61726765047420496e76616c69642070617261206865616420646174612073697a652e304e6f7450617261636861696e04642050617261206973206e6f7420612050617261636861696e2e344e6f745061726174687265616404682050617261206973206e6f74206120506172617468726561642e4043616e6e6f7444657265676973746572045c2043616e6e6f74206465726567697374657220706172613c43616e6e6f74446f776e677261646504d42043616e6e6f74207363686564756c6520646f776e6772616465206f662070617261636861696e20746f20706172617468726561643443616e6e6f745570677261646504cc2043616e6e6f74207363686564756c652075706772616465206f66207061726174687265616420746f2070617261636861696e28506172614c6f636b6564047d012050617261206973206c6f636b65642066726f6d206d616e6970756c6174696f6e20627920746865206d616e616765722e204d757374207573652070617261636861696e206f722072656c617920636861696e20676f7665726e616e63652e34496e76616c69645061726149640415012054686520696420796f752061726520747279696e6720746f20726567697374657220697320726573657276656420666f722073797374656d2070617261636861696e732e3c14536c6f74730114536c6f747304184c656173657301010518506172614964a45665633c4f7074696f6e3c28543a3a4163636f756e7449642c2042616c616e63654f663c543e293e3e00040040150120416d6f756e74732068656c64206f6e206465706f73697420666f7220656163682028706f737369626c792066757475726529206c65617365642070617261636861696e2e009901205468652061637475616c20616d6f756e74206c6f636b6564206f6e2069747320626568616c6620627920616e79206163636f756e7420617420616e792074696d6520697320746865206d6178696d756d206f6620746865207365636f6e642076616c756573f0206f6620746865206974656d7320696e2074686973206c6973742077686f73652066697273742076616c756520697320746865206163636f756e742e00610120546865206669727374206974656d20696e20746865206c6973742069732074686520616d6f756e74206c6f636b656420666f72207468652063757272656e74204c6561736520506572696f642e20466f6c6c6f77696e67b0206974656d732061726520666f72207468652073756273657175656e74206c6561736520706572696f64732e006101205468652064656661756c742076616c75652028616e20656d707479206c6973742920696d706c6965732074686174207468652070617261636861696e206e6f206c6f6e6765722065786973747320286f72206e65766572b4206578697374656429206173206661722061732074686973206d6f64756c6520697320636f6e6365726e65642e00510120496620612070617261636861696e20646f65736e2774206578697374202a7965742a20627574206973207363686564756c656420746f20657869737420696e20746865206675747572652c207468656e20697461012077696c6c206265206c6566742d7061646465642077697468206f6e65206f72206d6f726520604e6f6e65607320746f2064656e6f74652074686520666163742074686174206e6f7468696e672069732068656c64206f6e5d01206465706f73697420666f7220746865206e6f6e2d6578697374656e7420636861696e2063757272656e746c792c206275742069732068656c6420617420736f6d6520706f696e7420696e20746865206675747572652e00dc20497420697320696c6c6567616c20666f72206120604e6f6e65602076616c756520746f20747261696c20696e20746865206c6973742e010c2c666f7263655f6c6561736514107061726118506172614964186c656173657230543a3a4163636f756e74496418616d6f756e743042616c616e63654f663c543e30706572696f645f626567696e404c65617365506572696f644f663c543e30706572696f645f636f756e74404c65617365506572696f644f663c543e106d01204a757374206120686f747769726520696e746f2074686520606c656173655f6f7574602063616c6c2c20696e206361736520526f6f742077616e747320746f20666f72636520736f6d65206c6561736520746f2068617070656ee420696e646570656e64656e746c79206f6620616e79206f74686572206f6e2d636861696e206d656368616e69736d20746f207573652069742e009c2043616e206f6e6c792062652063616c6c65642062792074686520526f6f74206f726967696e2e40636c6561725f616c6c5f6c6561736573041070617261185061726149640c510120436c65617220616c6c206c656173657320666f72206120506172612049642c20726566756e64696e6720616e79206465706f73697473206261636b20746f20746865206f726967696e616c206f776e6572732e009c2043616e206f6e6c792062652063616c6c65642062792074686520526f6f74206f726967696e2e3c747269676765725f6f6e626f617264041070617261185061726149641c29012054727920746f206f6e626f61726420612070617261636861696e2074686174206861732061206c6561736520666f72207468652063757272656e74206c6561736520706572696f642e00490120546869732066756e6374696f6e2063616e2062652075736566756c2069662074686572652077617320736f6d6520737461746520697373756520776974682061207061726120746861742073686f756c643d012068617665206f6e626f61726465642c206275742077617320756e61626c6520746f2e204173206c6f6e67206173207468657920686176652061206c6561736520706572696f642c2077652063616e70206c6574207468656d206f6e626f6172642066726f6d20686572652e00d0204f726967696e206d757374206265207369676e65642c206275742063616e2062652063616c6c656420627920616e796f6e652e0108384e65774c65617365506572696f64042c4c65617365506572696f64048c2041206e6577205b6c656173655f706572696f645d20697320626567696e6e696e672e184c65617365641818506172614964244163636f756e7449642c4c65617365506572696f642c4c65617365506572696f641c42616c616e63651c42616c616e63650cc420416e206578697374696e672070617261636861696e20776f6e2074686520726967687420746f20636f6e74696e75652e41012046697273742062616c616e63652069732074686520657874726120616d6f756e7420726573657665642e205365636f6e642069732074686520746f74616c20616d6f756e742072657365727665642e4901205c5b70617261636861696e5f69642c206c65617365722c20706572696f645f626567696e2c20706572696f645f636f756e742c2065787472615f726573657665642c20746f74616c5f616d6f756e745c5d042c4c65617365506572696f6438543a3a426c6f636b4e756d6265721000270600000844506172614e6f744f6e626f617264696e670490205468652070617261636861696e204944206973206e6f74206f6e626f617264696e672e284c656173654572726f72048c2054686572652077617320616e206572726f72207769746820746865206c656173652e3d4050617261735375646f57726170706572000118747375646f5f7363686564756c655f706172615f696e697469616c697a6508086964185061726149641c67656e657369733c5061726147656e6573697341726773041101205363686564756c652061207061726120746f20626520696e697469616c697a656420617420746865207374617274206f6620746865206e6578742073657373696f6e2e687375646f5f7363686564756c655f706172615f636c65616e75700408696418506172614964040d01205363686564756c652061207061726120746f20626520636c65616e656420757020617420746865207374617274206f6620746865206e6578742073657373696f6e2e807375646f5f7363686564756c655f706172617468726561645f757067726164650408696418506172614964049020557067726164652061207061726174687265616420746f20612070617261636861696e847375646f5f7363686564756c655f70617261636861696e5f646f776e67726164650408696418506172614964049820446f776e677261646520612070617261636861696e20746f206120706172617468726561645c7375646f5f71756575655f646f776e776172645f78636d08086964185061726149640c78636d6478636d3a3a6f70617175653a3a56657273696f6e656458636d109c2053656e64206120646f776e776172642058434d20746f2074686520676976656e20706172612e0069012054686520676976656e2070617261636861696e2073686f756c6420657869737420616e6420746865207061796c6f61642073686f756c64206e6f74206578636565642074686520707265636f6e666967757265642073697a65902060636f6e6669672e6d61785f646f776e776172645f6d6573736167655f73697a65602e6c7375646f5f65737461626c6973685f68726d705f6368616e6e656c101873656e6465721850617261496424726563697069656e7418506172614964306d61785f63617061636974790c753332406d61785f6d6573736167655f73697a650c75333210050120466f72636566756c6c792065737461626c6973682061206368616e6e656c2066726f6d207468652073656e64657220746f2074686520726563697069656e742e0059012054686973206973206571756976616c656e7420746f2073656e64696e6720616e206048726d703a3a68726d705f696e69745f6f70656e5f6368616e6e656c602065787472696e73696320666f6c6c6f77656420627988206048726d703a3a68726d705f6163636570745f6f70656e5f6368616e6e656c602e0000203c50617261446f65736e74457869737404e420546865207370656369666965642070617261636861696e206f722070617261746872656164206973206e6f7420726567697374657265642e4450617261416c726561647945786973747304f420546865207370656369666965642070617261636861696e206f72207061726174687265616420697320616c726561647920726567697374657265642e54457863656564734d61784d65737361676553697a65086901204120444d50206d65737361676520636f756c646e27742062652073656e742062656361757365206974206578636565647320746865206d6178696d756d2073697a6520616c6c6f77656420666f72206120646f776e7761726424206d6573736167652e38436f756c646e74436c65616e7570048420436f756c64206e6f74207363686564756c65207061726120636c65616e75702e344e6f74506172617468726561640448204e6f74206120706172617468726561642e304e6f7450617261636861696e0444204e6f7420612070617261636861696e2e3443616e6e6f7455706772616465046c2043616e6e6f74207570677261646520706172617468726561642e3c43616e6e6f74446f776e677261646504702043616e6e6f7420646f776e67726164652070617261636861696e2e3e2458636d50616c6c6574012458636d50616c6c65740001081073656e64081064657374344d756c74694c6f636174696f6e1c6d6573736167651c58636d3c28293e001c65786563757465081c6d65737361676544426f783c58636d3c543a3a43616c6c3e3e286d61785f776569676874185765696768742cd4204578656375746520616e2058434d206d6573736167652066726f6d2061206c6f63616c2c207369676e65642c206f726967696e2e00510120416e206576656e74206973206465706f736974656420696e6469636174696e67207768657468657220606d73676020636f756c6420626520657865637574656420636f6d706c6574656c79206f72206f6e6c792c207061727469616c6c792e007101204e6f206d6f7265207468616e20606d61785f776569676874602077696c6c206265207573656420696e2069747320617474656d7074656420657865637574696f6e2e2049662074686973206973206c657373207468616e207468655d01206d6178696d756d20616d6f756e74206f6620776569676874207468617420746865206d65737361676520636f756c642074616b6520746f2062652065786563757465642c207468656e206e6f20657865637574696f6e5820617474656d70742077696c6c206265206d6164652e007101204e4f54453a2041207375636365737366756c2072657475726e20746f207468697320646f6573202a6e6f742a20696d706c7920746861742074686520606d73676020776173206578656375746564207375636365737366756c6c79d020746f20636f6d706c6574696f6e3b206f6e6c792074686174202a736f6d652a206f66206974207761732065786563757465642e010824417474656d70746564044078636d3a3a76303a3a4f7574636f6d65001053656e740c344d756c74694c6f636174696f6e344d756c74694c6f636174696f6e1c58636d3c28293e00000c2c556e726561636861626c65002c53656e644661696c757265002046696c746572656404a020546865206d65737361676520657865637574696f6e206661696c73207468652066696c7465722e63041c40436865636b5370656356657273696f6e38436865636b547856657273696f6e30436865636b47656e6573697338436865636b4d6f7274616c69747928436865636b4e6f6e63652c436865636b576569676874604368617267655472616e73616374696f6e5061796d656e74 \ No newline at end of file diff --git a/runtime/src/main/assets/types/default.json b/runtime/src/main/assets/types/default.json new file mode 100644 index 0000000..ceb2637 --- /dev/null +++ b/runtime/src/main/assets/types/default.json @@ -0,0 +1,8286 @@ +{ + "types": { + "()": "Null", + "Balance": "u128", + "BalanceOf": "Balance", + "Block": "GenericBlock", + "Call": "GenericCall", + "H32": "[u8; 4]", + "H64": "[u8; 8]", + "H128": "[u8; 16]", + "H1024": "[u8; 128]", + "H2048": "[u8; 256]", + "H160": "H160", + "H256": "H256", + "H512": "H512", + "Hash": "H256", + "CallHash": "Hash", + "CallHashOf": "CallHash", + "Fixed64": "u64", + "Fixed128": "u128", + "AccountId": "GenericAccountId", + "AccountIdOf": "AccountId", + "AccountVoteSplit": { + "type": "struct", + "type_mapping": [ + [ + "aye", + "Balance" + ], + [ + "nay", + "Balance" + ] + ] + }, + "AccountVoteStandard": { + "type": "struct", + "type_mapping": [ + [ + "vote", + "Vote" + ], + [ + "balance", + "Balance" + ] + ] + }, + "AccountVote": { + "type": "enum", + "type_mapping": [ + [ + "Standard", + "AccountVoteStandard" + ], + [ + "Split", + "AccountVoteSplit" + ] + ] + }, + "ArithmeticError": { + "type": "enum", + "value_list": [ + "Underflow", + "Overflow", + "DivisionByZero" + ] + }, + "BlockLength": { + "type": "struct", + "type_mapping": [ + [ + "max", + "PerDispatchClassU32" + ] + ] + }, + "PerDispatchClassU32": { + "type": "struct", + "type_mapping": [ + [ + "normal", + "u32" + ], + [ + "operational", + "u32" + ], + [ + "mandatory", + "u32" + ] + ] + }, + "Delegations": { + "type": "struct", + "type_mapping": [ + [ + "votes", + "Balance" + ], + [ + "capital", + "Balance" + ] + ] + }, + "PriorLock": { + "type": "struct", + "type_mapping": [ + ["blockNumber", "BlockNumber"], + ["balance", "Balance"] + ] + }, + "ReferendumInfoFinished": { + "type": "struct", + "type_mapping": [ + [ + "approved", + "bool" + ], + [ + "end", + "BlockNumber" + ] + ] + }, + "Tally": { + "type": "struct", + "type_mapping": [ + [ + "ayes", + "Balance" + ], + [ + "nays", + "Balance" + ], + [ + "turnout", + "Balance" + ] + ] + }, + "ReferendumStatus": { + "type": "struct", + "type_mapping": [ + [ + "end", + "BlockNumber" + ], + [ + "proposalHash", + "Hash" + ], + [ + "threshold", + "VoteThreshold" + ], + [ + "delay", + "BlockNumber" + ], + [ + "tally", + "Tally" + ] + ] + }, + "ReferendumInfo": { + "type": "enum", + "type_mapping": [ + [ + "Ongoing", + "ReferendumStatus" + ], + [ + "Finished", + "ReferendumInfoFinished" + ] + ] + }, + "VotingDirectVote": { + "type": "struct", + "type_mapping": [ + ["referendumIndex", "ReferendumIndex"], + ["accountVote", "AccountVote"] + ] + }, + "VotingDirect": { + "type": "struct", + "type_mapping": [ + [ + "votes", + "Vec" + ], + [ + "delegations", + "Delegations" + ], + [ + "prior", + "PriorLock" + ] + ] + }, + "VotingDelegating": { + "type": "struct", + "type_mapping": [ + [ + "balance", + "Balance" + ], + [ + "target", + "AccountId" + ], + [ + "conviction", + "Conviction" + ], + [ + "delegations", + "Delegations" + ], + [ + "prior", + "PriorLock" + ] + ] + }, + "Voting": { + "type": "enum", + "type_mapping": [ + [ + "Direct", + "VotingDirect" + ], + [ + "Delegating", + "VotingDelegating" + ] + ] + }, + "(AccountId, Balance)": { + "type": "struct", + "type_mapping": [ + [ + "account", + "AccountId" + ], + [ + "balance", + "Balance" + ] + ] + }, + "(BalanceOf, BidKind>)": { + "type": "struct", + "type_mapping": [ + [ + "balance", + "Balance" + ], + [ + "bidkind", + "BidKind" + ] + ] + }, + "RawOrigin": { + "type": "enum", + "type_mapping": [ + [ + "Root", + "Null" + ], + [ + "Signed", + "AccountId" + ], + [ + "None", + "Null" + ] + ] + }, + "RefCount": "u32", + "RefCountTo259": "u8", + "ExtendedBalance": "u128", + "RawSolution": { + "type": "struct", + "type_mapping": [ + [ + "compact", + "CompactAssignments" + ], + [ + "score", + "ElectionScore" + ], + [ + "round", + "u32" + ] + ] + }, + "ReadySolution": { + "type": "struct", + "type_mapping": [ + [ + "supports", + "SolutionSupports" + ], + [ + "score", + "ElectionScore" + ], + [ + "compute", + "ElectionCompute" + ] + ] + }, + "RoundSnapshot": { + "type": "struct", + "type_mapping": [ + [ + "voters", + "Vec<(AccountId, VoteWeight, Vec)>" + ], + [ + "targets", + "Vec" + ] + ] + }, + "SolutionSupport": { + "type": "struct", + "type_mapping": [ + [ + "total", + "ExtendedBalance" + ], + [ + "voters", + "Vec<(AccountId, ExtendedBalance)>" + ] + ] + }, + "SolutionSupports": "Vec<(AccountId, SolutionSupport)>", + "SolutionOrSnapshotSize": { + "type": "struct", + "type_mapping": [ + [ + "voters", + "Compact" + ], + [ + "targets", + "Compact" + ] + ] + }, + "SyncState": { + "type": "struct", + "type_mapping": [ + [ + "startingBlock", + "BlockNumber" + ], + [ + "currentBlock", + "BlockNumber" + ], + [ + "highestBlock", + "Option" + ] + ] + }, + "SystemOrigin": "RawOrigin", + "TokenError": { + "type": "enum", + "value_list": [ + "NoFunds", + "WouldDie", + "BelowMinimum", + "CannotCreate", + "UnknownAsset", + "Frozen", + "Underflow", + "Overflow" + ] + }, + "Moment": "u64", + "AccountData": { + "type": "struct", + "type_mapping": [ + [ + "free", + "Balance" + ], + [ + "reserved", + "Balance" + ], + [ + "miscFrozen", + "Balance" + ], + [ + "feeFrozen", + "Balance" + ] + ] + }, + "ActiveEraInfo": { + "type": "struct", + "type_mapping": [ + [ + "index", + "EraIndex" + ], + [ + "start", + "Option" + ] + ] + }, + "BlockNumber": "u32", + "ValidityVote": { + "type": "struct", + "type_mapping": [ + [ + "accountId", + "AccountId" + ], + [ + "validityAttestation", + "ValidityAttestation" + ] + ] + }, + "AssignmentId": "AccountId", + "AssignmentKind": { + "type": "enum", + "type_mapping": [ + [ + "Parachain", + "Null" + ], + [ + "Parathread", + "(CollatorId, u32)" + ] + ] + }, + "AttestedCandidate": { + "type": "struct", + "type_mapping": [ + [ + "candidate", + "AbridgedCandidateReceipt" + ], + [ + "validityVotes", + "Vec" + ], + [ + "validatorIndices", + "BitVec" + ] + ] + }, + "AuthorityDiscoveryId": "AccountId", + "AvailabilityBitfield": "BitVec", + "AvailabilityBitfieldRecord": { + "type": "struct", + "type_mapping": [ + [ + "bitfield", + "AvailabilityBitfield" + ], + [ + "submittedTt", + "BlockNumber" + ] + ] + }, + "LockIdentifier": "[u8; 8]", + "TransactionPriority": "u64", + "BalanceLock": { + "type": "struct", + "type_mapping": [ + [ + "id", + "LockIdentifier" + ], + [ + "amount", + "Balance" + ], + [ + "reasons", + "Reasons" + ] + ] + }, + "MessagingStateSnapshot": { + "type": "struct", + "type_mapping": [ + [ + "relayDispatchQueueSize", + "(u32, u32)" + ], + [ + "egressChannels", + "Vec" + ] + ] + }, + "MessagingStateSnapshotEgressEntry": "(ParaId, AbridgedHrmpChannel)", + "BTreeMap": "Vec<(ParaId, VecInboundHrmpMessage)>", + "VecInboundHrmpMessage": "Vec", + "FullIdentification": { + "type": "struct", + "type_mapping": [ + ["total", "Compact"], + ["own", "Compact"], + ["others", "Vec"] + ] + }, + "IdentificationTuple": { + "type": "struct", + "type_mapping": [ + ["validatorId", "ValidatorId"], + ["exposure", "FullIdentification"] + ] + }, + "SetId": "u64", + "Reasons": { + "type": "enum", + "value_list": [ + "Fee", + "Misc", + "All" + ] + }, + "RoundNumber": "U64", + "SessionIndex": "u32", + "AuctionIndex": "u32", + "AuthIndex": "u32", + "AuthorityIndex": "u64", + "Signature": "H512", + "CollatorSignature": "Signature", + "AuthorityWeight": "u64", + "EncodedFinalityProofs": "Bytes", + "NextAuthority": { + "type": "struct", + "type_mapping": [ + ["AuthorityId", "AuthorityId"], + ["weight", "AuthorityWeight"] + ] + }, + "BeefyCommitment": { + "type": "struct", + "type_mapping": [ + [ + "payload", + "BeefyPayload" + ], + [ + "blockNumber", + "BlockNumber" + ], + [ + "validatorSetId", + "ValidatorSetId" + ] + ] + }, + "BeefySignedCommitment": { + "type": "struct", + "type_mapping": [ + [ + "commitment", + "BeefyCommitment" + ], + [ + "signatures", + "Vec>" + ] + ] + }, + "MmrRootHash": "H256", + "BeefyPayload": "MmrRootHash", + "BeefyNextAuthoritySet": { + "type": "struct", + "type_mapping": [ + ["id", "ValidatorSetId"], + ["len", "u32"], + ["root", "MerkleRoot"] + ] + }, + "MerkleRoot": "Hash", + "AuthorityList": "Vec", + "BalanceUpload": { + "type": "struct", + "type_mapping": [ + [ + "accountId", + "AccountId" + ], + [ + "balance", + "u64" + ] + ] + }, + "CollatorId": "H256", + "ContractInfo": { + "type": "enum", + "type_mapping": [ + [ + "Alive", + "AliveContractInfo" + ], + [ + "Tombstone", + "TombstoneContractInfo" + ] + ] + }, + "TrieId": "Bytes", + "Pays": { + "type": "enum", + "value_list": [ + "Yes", + "No" + ] + }, + "DispatchClass": { + "type": "enum", + "value_list": [ + "Normal", + "Operational", + "Mandatory" + ] + }, + "DispatchInfo": { + "type": "struct", + "type_mapping": [ + [ + "weight", + "Weight" + ], + [ + "class", + "DispatchClass" + ], + [ + "paysFee", + "Pays" + ] + ] + }, + "EgressQueueRoot": { + "type": "struct", + "type_mapping": [ + [ + "paraId", + "ParaId" + ], + [ + "hash", + "Hash" + ] + ] + }, + "EventIndex": "u32", + "Extrinsic": "ExtrinsicsDecoder", + "ExtrinsicPayloadValue": { + "type": "struct", + "type_mapping": [ + [ + "call", + "CallBytes" + ], + [ + "era", + "Era" + ], + [ + "nonce", + "Compact" + ], + [ + "tip", + "Compact" + ], + [ + "specVersion", + "u32" + ], + [ + "transactionVersion", + "u32" + ], + [ + "genesisHash", + "Hash" + ], + [ + "blockHash", + "Hash" + ] + ] + }, + "Gas": "u64", + "IdentityFields": { + "type": "set", + "value_type": "u64", + "value_list": { + "Display": 1, + "Legal": 2, + "Web": 4, + "Riot": 8, + "Email": 16, + "PgpFingerprint": 32, + "Image": 64, + "Twitter": 128 + } + }, + "IdentityInfoAdditional": { + "type": "struct", + "type_mapping": [ + ["field", "Data"], + ["value", "Data"] + ] + }, + "IdentityInfo": { + "type": "struct", + "type_mapping": [ + [ + "additional", + "Vec" + ], + [ + "display", + "Data" + ], + [ + "legal", + "Data" + ], + [ + "web", + "Data" + ], + [ + "riot", + "Data" + ], + [ + "email", + "Data" + ], + [ + "pgpFingerprint", + "Option" + ], + [ + "image", + "Data" + ], + [ + "twitter", + "Data" + ] + ] + }, + "IdentityJudgement": { + "type": "enum", + "type_mapping": [ + [ + "Unknown", + "Null" + ], + [ + "FeePaid", + "Balance" + ], + [ + "Reasonable", + "Null" + ], + [ + "KnownGood", + "Null" + ], + [ + "OutOfDate", + "Null" + ], + [ + "LowQuality", + "Null" + ], + [ + "Erroneous", + "Null" + ] + ] + }, + "Judgement": "IdentityJudgement", + "Judgement": "IdentityJudgement", + "LeasePeriod": "BlockNumber", + "LeasePeriodOf": "LeasePeriod", + "(LeasePeriodOf, IncomingParachain)": { + "type": "struct", + "type_mapping": [ + [ + "leasePeriod", + "LeasePeriodOf" + ], + [ + "incomingParachain", + "IncomingParachain" + ] + ] + }, + "(ParaId, Option<(CollatorId, Retriable)>)": { + "type": "struct", + "type_mapping": [ + [ + "ParaId", + "ParaId" + ], + [ + "CollatorId-Retriable", + "Option<(CollatorId, Retriable)>" + ] + ] + }, + "MaybeVrf": "Option", + "MemberCount": "u32", + "CollectiveOrigin": { + "type": "enum", + "type_mapping": [ + [ + "Members", + "(MemberCount, MemberCount)" + ], + [ + "Member", + "AccountId" + ] + ] + }, + "MomentOf": "Moment", + "MoreAttestations": { + "type": "struct", + "type_mapping": [] + }, + "Multiplier": "Fixed128", + "Timepoint": { + "type": "struct", + "type_mapping": [ + [ + "height", + "BlockNumber" + ], + [ + "index", + "u32" + ] + ] + }, + "Multisig": { + "type": "struct", + "type_mapping": [ + [ + "when", + "Timepoint" + ], + [ + "deposit", + "Balance" + ], + [ + "depositor", + "AccountId" + ], + [ + "approvals", + "Vec" + ] + ] + }, + "Offender": "IdentificationTuple", + "PhantomData": "Null", + "sp_std::marker::PhantomData<(AccountId, Event)>": "PhantomData", + "Reporter": "AccountId", + "OffenceDetails": { + "type": "struct", + "type_mapping": [ + [ + "offender", + "Offender" + ], + [ + "reporters", + "Vec" + ] + ] + }, + "BountyStatusActive": { + "type": "struct", + "type_mapping": [ + [ + "curator", + "AccountId" + ], + [ + "updateDue", + "BlockNumber" + ] + ] + }, + "BountyStatusCuratorProposed": { + "type": "struct", + "type_mapping": [ + [ + "curator", + "AccountId" + ] + ] + }, + "BountyStatusPendingPayout": { + "type": "struct", + "type_mapping": [ + [ + "curator", + "AccountId" + ], + [ + "beneficiary", + "AccountId" + ], + [ + "unlockAt", + "BlockNumber" + ] + ] + }, + "BountyIndex": "u32", + "BountyStatus": { + "type": "enum", + "type_mapping": [ + [ + "Proposed", + "Null" + ], + [ + "Approved", + "Null" + ], + [ + "Funded", + "Null" + ], + [ + "CuratorProposed", + "BountyStatusCuratorProposed" + ], + [ + "Active", + "BountyStatusActive" + ], + [ + "PendingPayout", + "BountyStatusPendingPayout" + ] + ] + }, + "Bounty": { + "type": "struct", + "type_mapping": [ + [ + "proposer", + "AccountId" + ], + [ + "value", + "Balance" + ], + [ + "fee", + "Balance" + ], + [ + "curatorDeposit", + "Balance" + ], + [ + "bond", + "Balance" + ], + [ + "status", + "BountyStatus" + ] + ] + }, + "OpenTipFinder": "(AccountId, Balance)", + "OpenTipTip": "(AccountId, Balance)", + "OpenTip": { + "type": "struct", + "type_mapping": [ + [ + "reason", + "Hash" + ], + [ + "who", + "AccountId" + ], + [ + "finder", + "AccountId" + ], + [ + "deposit", + "Balance" + ], + [ + "closes", + "Option" + ], + [ + "tips", + "Vec" + ], + [ + "findersFee", + "bool" + ] + ] + }, + "ParachainsInherentData": { + "type": "struct", + "type_mapping": [ + [ + "bitfields", + "SignedAvailabilityBitfields" + ], + [ + "backedCandidates", + "Vec" + ], + [ + "disputes", + "MultiDisputeStatementSet" + ], + [ + "parentHeader", + "Header" + ] + ] + }, + "ParaGenesisArgs": { + "type": "struct", + "type_mapping": [ + [ + "genesisHead", + "Bytes" + ], + [ + "validationCode", + "Bytes" + ], + [ + "parachain", + "bool" + ] + ] + }, + "ParaId": "u32", + "ParaIdOf": "ParaId", + "ParaScheduling": { + "type": "enum", + "value_list": [ + "Always", + "Dynamic" + ] + }, + "ParathreadClaim": "(ParaId, CollatorId)", + "ParathreadClaimQueue": { + "type": "struct", + "type_mapping": [ + [ + "queue", + "Vec" + ], + [ + "nextCoreOffset", + "u32" + ] + ] + }, + "ParathreadEntry": { + "type": "struct", + "type_mapping": [ + [ + "claim", + "ParathreadClaim" + ], + [ + "retries", + "u32" + ] + ] + }, + "ParaValidatorIndex": "u32", + "PersistedValidationData": { + "type": "struct", + "type_mapping": [ + [ + "parentHead", + "HeadData" + ], + [ + "relayParentNumber", + "RelayChainBlockNumber" + ], + [ + "relayParentStorageRoot", + "Hash" + ], + [ + "maxPovSize", + "u32" + ] + ] + }, + "ParaInfo": { + "type": "struct", + "type_mapping": [ + [ + "manager", + "AccountId" + ], + [ + "deposit", + "Balance" + ], + [ + "locked", + "bool" + ] + ] + }, + "Percent": "u8", + "SlotNumber": "u64", + "VrfData": "[u8; 32]", + "VrfProof": "[u8; 64]", + "RawAuraPreDigest": { + "type": "struct", + "type_mapping": [ + [ + "slotNumber", + "u64" + ] + ] + }, + "RawBabePreDigest": { + "type": "enum", + "type_mapping": [ + [ + "Phantom", + "Null" + ], + [ + "Primary", + "RawBabePreDigestPrimary" + ], + [ + "SecondaryPlain", + "RawBabePreDigestSecondaryPlain" + ], + [ + "SecondaryVRF", + "RawBabePreDigestSecondaryVRF" + ] + ] + }, + "RawBabePreDigestPrimary": { + "type": "struct", + "type_mapping": [ + [ + "authorityIndex", + "u32" + ], + [ + "slotNumber", + "SlotNumber" + ], + [ + "vrfOutput", + "VrfOutput" + ], + [ + "vrfProof", + "VrfProof" + ] + ] + }, + "RawBabePreDigestSecondaryPlain": { + "type": "struct", + "type_mapping": [ + [ + "authorityIndex", + "u32" + ], + [ + "slotNumber", + "SlotNumber" + ] + ] + }, + "RawBabePreDigestSecondaryVRF": { + "type": "struct", + "type_mapping": [ + [ + "authorityIndex", + "u32" + ], + [ + "slotNumber", + "SlotNumber" + ], + [ + "vrfOutput", + "VrfOutput" + ], + [ + "vrfProof", + "VrfProof" + ] + ] + }, + "ReferendumInfo": { + "type": "struct", + "type_mapping": [ + [ + "end", + "BlockNumber" + ], + [ + "proposal", + "Proposal" + ], + [ + "threshold", + "VoteThreshold" + ], + [ + "delay", + "BlockNumber" + ] + ] + }, + "(ReferendumInfo)": "ReferendumInfo", + "ReferendumInfo": { + "type": "struct", + "type_mapping": [ + [ + "end", + "BlockNumber" + ], + [ + "proposalHash", + "Hash" + ], + [ + "threshold", + "VoteThreshold" + ], + [ + "delay", + "BlockNumber" + ] + ] + }, + "(ReferendumInfo)": "ReferendumInfo", + "RegistrarIndex": "u32", + "RegistrarInfo": { + "type": "struct", + "type_mapping": [ + [ + "account", + "AccountId" + ], + [ + "fee", + "Balance" + ], + [ + "fields", + "IdentityFields" + ] + ] + }, + "RegistrationJudgement": { + "type": "struct", + "type_mapping": [ + ["registrarIndex", "RegistrarIndex"], + ["judgement", "Judgement"] + ] + }, + "Registration": { + "type": "struct", + "type_mapping": [ + [ + "judgements", + "Vec" + ], + [ + "deposit", + "Balance" + ], + [ + "info", + "IdentityInfo" + ] + ] + }, + "ReportIdOf": "Hash", + "ScheduleTo258": { + "type": "struct", + "type_mapping": [ + [ + "version", + "u32" + ], + [ + "putCodePerByteCost", + "Gas" + ], + [ + "growMemCost", + "Gas" + ], + [ + "regularOpCost", + "Gas" + ], + [ + "returnDataPerByteCost", + "Gas" + ], + [ + "eventDataPerByteCost", + "Gas" + ], + [ + "eventPerTopicCost", + "Gas" + ], + [ + "eventBaseCost", + "Gas" + ], + [ + "sandboxDataReadCost", + "Gas" + ], + [ + "sandboxDataWriteCost", + "Gas" + ], + [ + "transferCost", + "Gas" + ], + [ + "maxEventTopics", + "u32" + ], + [ + "maxStackHeight", + "u32" + ], + [ + "maxMemoryPages", + "u32" + ], + [ + "enablePrintln", + "bool" + ], + [ + "maxSubjectLen", + "u32" + ] + ] + }, + "Schedule": { + "type": "struct", + "type_mapping": [ + [ + "version", + "u32" + ], + [ + "enablePrintln", + "bool" + ], + [ + "limits", + "Limits" + ], + [ + "instructionWeights", + "InstructionWeights" + ], + [ + "hostFnWeights", + "HostFnWeights" + ] + ] + }, + "SubId": "u32", + "TransientValidationData": { + "type": "struct", + "type_mapping": [ + [ + "maxCodeSize", + "u32" + ], + [ + "maxHeadDataSize", + "u32" + ], + [ + "balance", + "Balance" + ], + [ + "codeUpgradeAllowed", + "Option" + ], + [ + "dmqLength", + "u32" + ] + ] + }, + "UncleEntryItem": { + "type": "enum", + "value_list": [ + "InclusionHeight", + "Uncle" + ] + }, + "VestingSchedule": { + "type": "struct", + "type_mapping": [ + [ + "offset", + "Balance" + ], + [ + "perBlock", + "Balance" + ], + [ + "startingBlock", + "BlockNumber" + ] + ] + }, + "Weight": "u64", + "WeightMultiplier": "Fixed64", + "XcmOriginKind": { + "type": "enum", + "value_list": [ + "Native", + "SovereignAccount", + "Superuser", + "Xcm" + ] + }, + "XcmResponse": { + "type": "enum", + "type_mapping": [ + [ + "Assets", + "Vec" + ] + ] + }, + "XcmError": { + "type": "enum", + "type_mapping": [ + [ + "Undefined", + "Null" + ], + [ + "Overflow", + "Null" + ], + [ + "Unimplemented", + "Null" + ], + [ + "UnhandledXcmVersion", + "Null" + ], + [ + "UnhandledXcmMessage", + "Null" + ], + [ + "UnhandledEffect", + "Null" + ], + [ + "EscalationOfPrivilege", + "Null" + ], + [ + "UntrustedReserveLocation", + "Null" + ], + [ + "UntrustedTeleportLocation", + "Null" + ], + [ + "DestinationBufferOverflow", + "Null" + ], + [ + "SendFailed", + "Null" + ], + [ + "CannotReachDestination", + "(MultiLocation, Xcm)" + ], + [ + "MultiLocationFull", + "Null" + ], + [ + "FailedToDecode", + "Null" + ], + [ + "BadOrigin", + "Null" + ], + [ + "ExceedsMaxMessageSize", + "Null" + ], + [ + "FailedToTransactAsset", + "Null" + ], + [ + "WeightLimitReached", + "Weight" + ], + [ + "Wildcard", + "Null" + ], + [ + "TooMuchWeightRequired", + "Null" + ], + [ + "NotHoldingFees", + "Null" + ], + [ + "WeightNotComputable", + "Null" + ], + [ + "Barrier", + "Null" + ], + [ + "NotWithdrawable", + "Null" + ], + [ + "LocationCannotHold", + "Null" + ], + [ + "TooExpensive", + "Null" + ] + ] + }, + "XcmOutcome": { + "type": "enum", + "type_mapping": [ + [ + "Complete", + "Weight" + ], + [ + "Incomplete", + "(Weight, XcmError)" + ], + [ + "Error", + "XcmError" + ] + ] + }, + "XcmResult": { + "type": "enum", + "type_mapping": [ + [ + "Ok", + "()" + ], + [ + "Err", + "XcmError" + ] + ] + }, + "xcm::v0::Outcome": "XcmOutcome", + "DoubleEncoded": { + "type": "struct", + "type_mapping": [ + [ + "encoded", + "Vec" + ] + ] + }, + "OriginKind": { + "type": "enum", + "value_list": [ + "Native", + "SovereignAccount", + "Superuser" + ] + }, + "NetworkId": { + "type": "enum", + "type_mapping": [ + [ + "Any", + "Null" + ], + [ + "Named", + "Vec" + ], + [ + "Polkadot", + "Null" + ], + [ + "Kusama", + "Null" + ] + ] + }, + "InboundStatus": { + "type": "enum", + "value_list": [ + "Ok", + "Suspended" + ] + }, + "OutboundStatus": { + "type": "enum", + "value_list": [ + "Ok", + "Suspended" + ] + }, + "MultiLocation": { + "type": "enum", + "type_mapping": [ + [ + "Null", + "Null" + ], + [ + "X1", + "Junction" + ], + [ + "X2", + "(Junction, Junction)" + ], + [ + "X3", + "(Junction, Junction, Junction)" + ], + [ + "X4", + "(Junction, Junction, Junction, Junction)" + ], + [ + "X5", + "(Junction, Junction, Junction, Junction, Junction)" + ], + [ + "X6", + "(Junction, Junction, Junction, Junction, Junction, Junction)" + ], + [ + "X7", + "(Junction, Junction, Junction, Junction, Junction, Junction, Junction)" + ], + [ + "X8", + "(Junction, Junction, Junction, Junction, Junction, Junction, Junction, Junction)" + ] + ] + }, + "BodyId": { + "type": "enum", + "type_mapping": [ + [ + "Unit", + "Null" + ], + [ + "Named", + "Vec" + ], + [ + "Index", + "Compact" + ], + [ + "Executive", + "Null" + ], + [ + "Technical", + "Null" + ], + [ + "Legislative", + "Null" + ], + [ + "Judicial", + "Null" + ] + ] + }, + "BodyPart": { + "type": "enum", + "type_mapping": [ + [ + "Voice", + "Null" + ], + [ + "Members", + "Compact" + ], + [ + "Fraction", + "BodyPartFraction" + ], + [ + "AtLeastProportion", + "BodyPartAtLeastProportion" + ], + [ + "MoreThanProportion", + "BodyPartMoreThanProportion" + ] + ] + }, + "BodyPartFraction": { + "type": "struct", + "type_mapping": [ + [ + "nom", + "Compact" + ], + [ + "denom", + "Compact" + ] + ] + }, + "BodyPartAtLeastProportion": { + "type": "struct", + "type_mapping": [ + [ + "nom", + "Compact" + ], + [ + "denom", + "Compact" + ] + ] + }, + "BodyPartMoreThanProportion": { + "type": "struct", + "type_mapping": [ + [ + "nom", + "Compact" + ], + [ + "denom", + "Compact" + ] + ] + }, + "AccountId32Junction": { + "type": "struct", + "type_mapping": [ + [ + "network", + "NetworkId" + ], + [ + "id", + "AccountId" + ] + ] + }, + "AccountIndex64Junction": { + "type": "struct", + "type_mapping": [ + [ + "network", + "NetworkId" + ], + [ + "index", + "Compact" + ] + ] + }, + "AccountKey20Junction": { + "type": "struct", + "type_mapping": [ + [ + "network", + "NetworkId" + ], + [ + "index", + "[u8; 20]" + ] + ] + }, + "PluralityJunction": { + "type": "struct", + "type_mapping": [ + [ + "id", + "BodyId" + ], + [ + "part", + "BodyPart" + ] + ] + }, + "Junction": { + "type": "enum", + "type_mapping": [ + [ + "Parent", + "Null" + ], + [ + "Parachain", + "Compact" + ], + [ + "AccountId32", + "AccountId32Junction" + ], + [ + "AccountIndex64", + "AccountIndex64Junction" + ], + [ + "AccountKey20", + "AccountKey20Junction" + ], + [ + "PalletInstance", + "u8" + ], + [ + "GeneralIndex", + "Compact" + ], + [ + "GeneralKey", + "Vec" + ], + [ + "OnlyChild", + "Null" + ], + [ + "Plurality", + "PluralityJunction" + ] + ] + }, + "QueueConfigData": { + "type": "struct", + "type_mapping": [ + [ + "suspendThreshold", + "u32" + ], + [ + "dropThreshold", + "u32" + ], + [ + "resumeThreshold", + "u32" + ], + [ + "thresholdWeight", + "Weight" + ], + [ + "weightRestrictDecay", + "Weight" + ] + ] + }, + "VersionedMultiLocation": { + "type": "enum", + "type_mapping": [ + [ + "V0", + "MultiLocation" + ] + ] + }, + "AssetInstance": { + "type": "enum", + "type_mapping": [ + [ + "Undefined", + "Null" + ], + [ + "Index8", + "u8" + ], + [ + "Index16", + "Compact" + ], + [ + "Index32", + "Compact" + ], + [ + "Index64", + "Compact" + ], + [ + "Index128", + "Compact" + ], + [ + "Array4", + "[u8; 4]" + ], + [ + "Array8", + "[u8; 8]" + ], + [ + "Array16", + "[u8; 16]" + ], + [ + "Array32", + "[u8; 32]" + ], + [ + "Blob", + "Vec" + ] + ] + }, + "MultiAssetAbstractFungible": { + "type": "struct", + "type_mapping": [ + [ + "id", + "Vec" + ], + [ + "instance", + "Compact" + ] + ] + }, + "MultiAssetAbstractNonFungible": { + "type": "struct", + "type_mapping": [ + [ + "class", + "Vec" + ], + [ + "instance", + "AssetInstance" + ] + ] + }, + "MultiAssetConcreteFungible": { + "type": "struct", + "type_mapping": [ + [ + "id", + "MultiLocation" + ], + [ + "amount", + "Compact" + ] + ] + }, + "MultiAssetConcreteNonFungible": { + "type": "struct", + "type_mapping": [ + [ + "class", + "MultiLocation" + ], + [ + "instance", + "AssetInstance" + ] + ] + }, + "MultiAsset": { + "type": "enum", + "type_mapping": [ + [ + "None", + "Null" + ], + [ + "All", + "Null" + ], + [ + "AllFungible", + "Null" + ], + [ + "AllNonFungible", + "Null" + ], + [ + "AllAbstractFungible", + "Vec" + ], + [ + "AllAbstractNonFungible", + "Vec" + ], + [ + "AllConcreteFungible", + "MultiLocation" + ], + [ + "AllConcreteNonFungible", + "MultiLocation" + ], + [ + "AbstractFungible", + "MultiAssetAbstractFungible" + ], + [ + "AbstractNonFungible", + "MultiAssetAbstractNonFungible" + ], + [ + "ConcreteFungible", + "MultiAssetConcreteFungible" + ], + [ + "ConcreteNonFungible", + "MultiAssetConcreteNonFungible" + ] + ] + }, + "VersionedMultiAsset": { + "type": "enum", + "type_mapping": [ + [ + "V0", + "MultiAsset" + ] + ] + }, + "DepositAsset": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "dest", + "MultiLocation" + ] + ] + }, + "DepositReserveAsset": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "dest", + "MultiLocation" + ], + [ + "effects", + "Vec" + ] + ] + }, + "ExchangeAsset": { + "type": "struct", + "type_mapping": [ + [ + "give", + "Vec" + ], + [ + "receive", + "Vec" + ] + ] + }, + "InitiateReserveWithdraw": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "reserve", + "MultiLocation" + ], + [ + "effects", + "Vec" + ] + ] + }, + "InitiateTeleport": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "dest", + "MultiLocation" + ], + [ + "effects", + "Vec" + ] + ] + }, + "QueryHolding": { + "type": "struct", + "type_mapping": [ + [ + "queryId", + "Compact" + ], + [ + "dest", + "MultiLocation" + ], + [ + "assets", + "Vec" + ] + ] + }, + "Order": { + "type": "enum", + "type_mapping": [ + [ + "Null", + "Null" + ], + [ + "DepositAsset", + "DepositAsset" + ], + [ + "DepositReserveAsset", + "DepositReserveAsset" + ], + [ + "ExchangeAsset", + "ExchangeAsset" + ], + [ + "InitiateReserveWithdraw", + "InitiateReserveWithdraw" + ], + [ + "InitiateTeleport", + "InitiateTeleport" + ], + [ + "QueryHolding", + "QueryHolding" + ] + ] + }, + "SlotRange": { + "type": "enum", + "value_list": [ + "ZeroZero", "ZeroOne", "ZeroTwo", "ZeroThree", "OneOne", "OneTwo", "OneThree", "TwoTwo", "TwoThree", "ThreeThree" + ] + }, + "WinningDataEntry": "Option<(AccountId, ParaId, BalanceOf)>", + "WinningData": "[WinningDataEntry; 10]", + "WinnersData": "Vec", + "WinnersDataTuple": "(AccountId, ParaId, BalanceOf, SlotRange)", + "WithdrawReasons": { + "type": "set", + "value_type": "u64", + "value_list": { + "TransactionPayment": 1, + "Transfer": 2, + "Reserve": 4, + "Fee": 8, + "Tip": 16 + } + }, + "Index": "u32", + "Kind": "[u8; 16]", + "Nominations": { + "type": "struct", + "type_mapping": [ + [ + "targets", + "Vec" + ], + [ + "submittedIn", + "EraIndex" + ], + [ + "suppressed", + "bool" + ] + ] + }, + "OpaqueTimeSlot": "Bytes", + ">::Proposal": "Proposal", + "AuthoritySignature": "Signature", + "::Signature": "AuthoritySignature", + "&[u8]": "Bytes", + "Text": "Bytes", + "Str": "Bytes", + "Forcing": { + "type": "enum", + "value_list": [ + "NotForcing", + "ForceNew", + "ForceNone", + "ForceAlways" + ] + }, + "Heartbeat": { + "type": "struct", + "type_mapping": [ + [ + "blockNumber", + "BlockNumber" + ], + [ + "networkState", + "OpaqueNetworkState" + ], + [ + "sessionIndex", + "SessionIndex" + ], + [ + "authorityIndex", + "AuthIndex" + ], + [ + "validatorsLen", + "u32" + ] + ] + }, + "RewardDestination": { + "type": "enum", + "type_mapping": [ + [ + "Staked", + "Null" + ], + [ + "Stash", + "Null" + ], + [ + "Controller", + "Null" + ], + [ + "Account", + "AccountId" + ], + [ + "None", + "Null" + ] + ] + }, + "RewardDestinationTo257": { + "type": "enum", + "value_list": [ + "Staked", + "Stash", + "Controller" + ] + }, + "ChangesTrieConfiguration": { + "type": "struct", + "type_mapping": [ + [ + "digestInterval", + "u32" + ], + [ + "digestLevels", + "u32" + ] + ] + }, + "ChangesTrieSignal": { + "type": "enum", + "type_mapping": [ + [ + "NewConfiguration", + "Option" + ] + ] + }, + "ConsensusEngineId": "GenericConsensusEngineId", + "DigestItem": { + "type": "enum", + "type_mapping": [ + [ + "Other", + "Bytes" + ], + [ + "AuthoritiesChange", + "Vec" + ], + [ + "ChangesTrieRoot", + "Hash" + ], + [ + "SealV0", + "SealV0" + ], + [ + "Consensus", + "Consensus" + ], + [ + "Seal", + "Seal" + ], + [ + "PreRuntime", + "PreRuntime" + ], + [ + "ChangesTrieSignal", + "ChangesTrieSignal" + ] + ] + }, + "Digest": { + "type": "struct", + "type_mapping": [ + [ + "logs", + "Vec" + ] + ] + }, + "DigestOf": "Digest", + "SpanIndex": "u32", + "slashing::SpanIndex": "SpanIndex", + "SlashingSpans": { + "type": "struct", + "type_mapping": [ + [ + "spanIndex", + "SpanIndex" + ], + [ + "lastStart", + "EraIndex" + ], + [ + "lastNonzeroSlash", + "EraIndex" + ], + [ + "prior", + "Vec" + ] + ] + }, + "slashing::SlashingSpans": "SlashingSpans", + "SpanRecord": { + "type": "struct", + "type_mapping": [ + [ + "slashed", + "Balance" + ], + [ + "paidOut", + "Balance" + ] + ] + }, + "slashing::SpanRecord": "SpanRecord", + "UnappliedSlashOther": { + "type": "struct", + "type_mapping": [ + ["account", "AccountId"], + ["amount", "Balance"] + ] + }, + "UnappliedSlash": { + "type": "struct", + "type_mapping": [ + [ + "validator", + "AccountId" + ], + [ + "own", + "Balance" + ], + [ + "others", + "Vec" + ], + [ + "reporters", + "Vec" + ], + [ + "payout", + "Balance" + ] + ] + }, + "Beefy": "[u8; 33]", + "SessionKeys1": "(AccountId)", + "SessionKeys2": "(AccountId, AccountId)", + "SessionKeys3": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"] + ] + }, + "SessionKeys4": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["authority_discovery", "AccountId"] + ] + }, + "SessionKeys5": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["authority_discovery", "AccountId"], + ["parachains", "AccountId"] + ] + }, + "SessionKeys6B": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["authority_discovery", "AccountId"], + ["parachains", "AccountId"], + ["beefy", "Beefy"] + ] + }, + "SessionKeys6": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["para_validator", "AccountId"], + ["para_assignment", "AccountId"], + ["authority_discovery", "AccountId"] + ] + }, + "SessionKeys7B": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["para_validator", "AccountId"], + ["para_assignment", "AccountId"], + ["authority_discovery", "AccountId"], + ["beefy", "Beefy"] + ] + }, + "Keys": "SessionKeysSubstrate", + "Header": { + "type": "struct", + "type_mapping": [ + [ + "parentHash", + "Hash" + ], + [ + "number", + "Compact" + ], + [ + "stateRoot", + "Hash" + ], + [ + "extrinsicsRoot", + "Hash" + ], + [ + "digest", + "Digest" + ] + ] + }, + "DispatchErrorModule": { + "type": "struct", + "type_mapping": [ + [ + "index", + "u8" + ], + [ + "error", + "u8" + ] + ] + }, + "DispatchError": { + "type": "enum", + "type_mapping": [ + [ + "Other", + "Null" + ], + [ + "CannotLookup", + "Null" + ], + [ + "BadOrigin", + "Null" + ], + [ + "Module", + "DispatchErrorModule" + ], + [ + "ConsumerRemaining", + "Null" + ], + [ + "NoProviders", + "Null" + ], + [ + "Token", + "TokenError" + ], + [ + "Arithmetic", + "ArithmeticError" + ] + ] + }, + "DispatchResult": { + "type": "enum", + "type_mapping": [ + [ + "Ok", + "Null" + ], + [ + "Error", + "DispatchError" + ] + ] + }, + "ActiveRecovery": { + "type": "struct", + "type_mapping": [ + [ + "created", + "BlockNumber" + ], + [ + "deposit", + "Balance" + ], + [ + "friends", + "Vec" + ] + ] + }, + "RecoveryConfig": { + "type": "struct", + "type_mapping": [ + [ + "delayPeriod", + "BlockNumber" + ], + [ + "deposit", + "Balance" + ], + [ + "friends", + "Vec" + ], + [ + "threshold", + "u16" + ] + ] + }, + "BidKindVouch": { + "type": "struct", + "type_mapping": [ + [ + "account", + "AccountId" + ], + [ + "amount", + "Balance" + ] + ] + }, + "BidKind": { + "type": "enum", + "type_mapping": [ + [ + "Deposit", + "Balance" + ], + [ + "Vouch", + "(AccountId, Balance)" + ] + ] + }, + "BidKind": "Bidkind", + "BidKind>": "Bidkind", + "Bid": { + "type": "struct", + "type_mapping": [ + [ + "who", + "AccountId" + ], + [ + "kind", + "BidKind" + ], + [ + "value", + "Balance" + ] + ] + }, + "StrikeCount": "u32", + "VouchingStatus": { + "type": "enum", + "value_list": [ + "Vouching", + "Banned" + ] + }, + "ExtrinsicMetadata": { + "type": "struct", + "type_mapping": [ + [ + "version", + "u8" + ], + [ + "signedExtensions", + "Vec" + ] + ] + }, + "RewardPoint": "u32", + "BTreeMap": "Vec<(AccountId, RewardPoint)>", + "EraRewardPoints": { + "type": "struct", + "type_mapping": [ + [ + "total", + "RewardPoint" + ], + [ + "individual", + "BTreeMap" + ] + ] + }, + "ServiceQuality": { + "type": "enum", + "value_list": [ + "Ordered", + "Fast" + ] + }, + "IncomingParachainDeploy": { + "type": "struct", + "type_mapping": [ + [ + "code", + "ValidationCode" + ], + [ + "initialHeadData", + "HeadData" + ] + ] + }, + "NewBidder": { + "type": "struct", + "type_mapping": [ + [ + "who", + "AccountId" + ], + [ + "sub", + "SubId" + ] + ] + }, + "OutboundHrmpMessage": { + "type": "struct", + "type_mapping": [ + [ + "recipient", + "u32" + ], + [ + "data", + "Bytes" + ] + ] + }, + "IncomingParachainFixed": { + "type": "struct", + "type_mapping": [ + [ + "codeHash", + "Hash" + ], + [ + "codeSize", + "u32" + ], + [ + "initialHeadData", + "HeadData" + ] + ] + }, + "IncomingParachain": { + "type": "enum", + "type_mapping": [ + [ + "Unset", + "NewBidder" + ], + [ + "Fixed", + "IncomingParachainFixed" + ], + [ + "Deploy", + "IncomingParachainDeploy" + ] + ] + }, + "LastRuntimeUpgradeInfo": { + "type": "struct", + "type_mapping": [ + [ + "specVersion", + "Compact" + ], + [ + "specName", + "Text" + ] + ] + }, + "ProxyState": { + "type": "enum", + "type_mapping": [ + [ + "Open", + "AccountId" + ], + [ + "Active", + "AccountId" + ] + ] + }, + "ReleasesBalances": { + "type": "enum", + "value_list": [ + "V1_0_0", + "V2_0_0" + ] + }, + "Releases": { + "type": "enum", + "value_list": [ + "V1", + "V2", + "V3", + "V4", + "V5", + "V6", + "V7", + "V8", + "V9", + "V10" + ] + }, + "ValidityAttestation": { + "type": "enum", + "type_mapping": [ + [ + "Never", + "Null" + ], + [ + "Implicit", + "ValidatorSignature" + ], + [ + "Explicit", + "ValidatorSignature" + ] + ] + }, + "WeightPerClass": { + "type": "struct", + "type_mapping": [ + [ + "baseExtrinsic", + "Weight" + ], + [ + "maxExtrinsic", + "Weight" + ], + [ + "maxTotal", + "Option" + ], + [ + "reserved", + "Option" + ] + ] + }, + "ActiveGilt": { + "type": "struct", + "type_mapping": [ + [ + "proportion", + "Perquintill" + ], + [ + "amount", + "Balance" + ], + [ + "who", + "AccountId" + ], + [ + "expiry", + "BlockNumber" + ] + ] + }, + "ActiveGiltsTotal": { + "type": "struct", + "type_mapping": [ + [ + "frozen", + "Balance" + ], + [ + "proportion", + "Perquintill" + ], + [ + "index", + "ActiveIndex" + ], + [ + "target", + "Perquintill" + ] + ] + }, + "ActiveIndex": "u32", + "GiltBid": { + "type": "struct", + "type_mapping": [ + [ + "amount", + "Balance" + ], + [ + "who", + "AccountId" + ] + ] + }, + "MmrLeafProof": { + "type": "struct", + "type_mapping": [ + [ + "blockHash", + "BlockHash" + ], + [ + "leaf", + "Bytes" + ], + [ + "proof", + "Bytes" + ] + ] + }, + "VestingInfo": { + "type": "struct", + "type_mapping": [ + [ + "locked", + "Balance" + ], + [ + "perBlock", + "Balance" + ], + [ + "startingBlock", + "BlockNumber" + ] + ] + }, + "NominatorIndex": "u32", + "ValidatorIndex": "u16", + "PerU16": "u16", + "ValidatorIndexCompact": "Compact", + "NominatorIndexCompact": "Compact", + "OffchainAccuracy": "PerU16", + "OffchainAccuracyCompact": "Compact", + "CompactScoreCompact": { + "type": "struct", + "type_mapping": [ + ["validatorIndex", "ValidatorIndexCompact"], + ["offchainAccuracy", "OffchainAccuracyCompact"] + ] + }, + "CompactScore": { + "type": "struct", + "type_mapping": [ + ["validatorIndex", "ValidatorIndex"], + ["offchainAccuracy", "OffchainAccuracy"] + ] + }, + "CompactAssignmentsFrom258": { + "type": "struct", + "type_mapping": [ + [ + "votes1", + "Vec<(NominatorIndexCompact, ValidatorIndexCompact)>" + ], + [ + "votes2", + "Vec<(NominatorIndexCompact, CompactScoreCompact, ValidatorIndexCompact)>" + ], + [ + "votes3", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 2], ValidatorIndexCompact)>" + ], + [ + "votes4", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 3], ValidatorIndexCompact)>" + ], + [ + "votes5", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 4], ValidatorIndexCompact)>" + ], + [ + "votes6", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 5], ValidatorIndexCompact)>" + ], + [ + "votes7", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 6], ValidatorIndexCompact)>" + ], + [ + "votes8", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 7], ValidatorIndexCompact)>" + ], + [ + "votes9", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 8], ValidatorIndexCompact)>" + ], + [ + "votes10", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 9], ValidatorIndexCompact)>" + ], + [ + "votes11", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 10], ValidatorIndexCompact)>" + ], + [ + "votes12", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 11], ValidatorIndexCompact)>" + ], + [ + "votes13", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 12], ValidatorIndexCompact)>" + ], + [ + "votes14", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 13], ValidatorIndexCompact)>" + ], + [ + "votes15", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 14], ValidatorIndexCompact)>" + ], + [ + "votes16", + "Vec<(NominatorIndexCompact, [CompactScoreCompact; 15], ValidatorIndexCompact)>" + ] + ] + }, + "CompactAssignmentsTo257": { + "type": "struct", + "type_mapping": [ + [ + "votes1", + "Vec<(NominatorIndex, [CompactScore; 0], ValidatorIndex)>" + ], + [ + "votes2", + "Vec<(NominatorIndex, [CompactScore; 1], ValidatorIndex)>" + ], + [ + "votes3", + "Vec<(NominatorIndex, [CompactScore; 2], ValidatorIndex)>" + ], + [ + "votes4", + "Vec<(NominatorIndex, [CompactScore; 3], ValidatorIndex)>" + ], + [ + "votes5", + "Vec<(NominatorIndex, [CompactScore; 4], ValidatorIndex)>" + ], + [ + "votes6", + "Vec<(NominatorIndex, [CompactScore; 5], ValidatorIndex)>" + ], + [ + "votes7", + "Vec<(NominatorIndex, [CompactScore; 6], ValidatorIndex)>" + ], + [ + "votes8", + "Vec<(NominatorIndex, [CompactScore; 7], ValidatorIndex)>" + ], + [ + "votes9", + "Vec<(NominatorIndex, [CompactScore; 8], ValidatorIndex)>" + ], + [ + "votes10", + "Vec<(NominatorIndex, [CompactScore; 9], ValidatorIndex)>" + ], + [ + "votes11", + "Vec<(NominatorIndex, [CompactScore; 10], ValidatorIndex)>" + ], + [ + "votes12", + "Vec<(NominatorIndex, [CompactScore; 11], ValidatorIndex)>" + ], + [ + "votes13", + "Vec<(NominatorIndex, [CompactScore; 12], ValidatorIndex)>" + ], + [ + "votes14", + "Vec<(NominatorIndex, [CompactScore; 13], ValidatorIndex)>" + ], + [ + "votes15", + "Vec<(NominatorIndex, [CompactScore; 14], ValidatorIndex)>" + ], + [ + "votes16", + "Vec<(NominatorIndex, [CompactScore; 15], ValidatorIndex)>" + ] + ] + }, + "CompactAssignments": "CompactAssignmentsFrom258", + "DeferredOffenceOf": { + "type": "struct", + "type_mapping": [ + ["offences", "Vec"], + ["perc", "Vec"], + ["session", "SessionIndex"] + ] + }, + "Statement": { + "type": "enum", + "type_mapping": [ + [ + "Never", + "Null" + ], + [ + "Candidate", + "Hash" + ], + [ + "Valid", + "Hash" + ], + [ + "Invalid", + "Hash" + ] + ] + }, + "ValidatorSignature": "Signature", + "DoubleVoteReportStatement": { + "type": "struct", + "type_mapping": [ + [ + "statement", + "Statement" + ], + [ + "signature", + "ValidatorSignature" + ] + ] + }, + "DoubleVoteReportProof": { + "type": "struct", + "type_mapping": [ + [ + "session", + "SessionIndex" + ], + [ + "trieNodes", + "Vec" + ] + ] + }, + "SigningContext": { + "type": "struct", + "type_mapping": [ + [ + "sessionIndex", + "SessionIndex" + ], + [ + "parentHash", + "Hash" + ] + ] + }, + "DisputeStatementSet": { + "type": "struct", + "type_mapping": [ + [ + "candidateHash", + "CandidateHash" + ], + [ + "session", + "SessionIndex" + ], + [ + "statements", + "Vec<(DisputeStatement, ValidatorIndex, ValidatorSignature)>" + ] + ] + }, + "MultiDisputeStatementSet": "Vec", + "DisputeStatement": { + "type": "enum", + "type_mapping": [ + [ + "Valid", + "ValidDisputeStatementKind" + ], + [ + "Invalid", + "InvalidDisputeStatementKind" + ] + ] + }, + "ValidDisputeStatementKind": { + "type": "enum", + "value_list": [ + "Explicit", + "BackingSeconded", + "BackingValid", + "ApprovalChecking" + ] + }, + "InvalidDisputeStatementKind": { + "type": "enum", + "value_list": [ + "Explicit" + ] + }, + "ExplicitDisputeStatement": { + "type": "struct", + "type_mapping": [ + [ + "valid", + "bool" + ], + [ + "candidateHash", + "CandidateHash" + ], + [ + "session", + "SessionIndex" + ] + ] + }, + "DoubleVoteReport": { + "type": "struct", + "type_mapping": [ + [ + "identity", + "ValidatorId" + ], + [ + "first", + "(Statement, ValidatorSignature)" + ], + [ + "second", + "(Statement, ValidatorSignature)" + ], + [ + "proof", + "MembershipProof" + ], + [ + "signingContext", + "SigningContext" + ] + ] + }, + "ElectionCompute": { + "type": "enum", + "value_list": [ + "OnChain", + "Signed", + "Authority" + ] + }, + "ElectionResult": { + "type": "struct", + "type_mapping": [ + [ + "compute", + "ElectionCompute" + ], + [ + "slotStake", + "Balance" + ], + [ + "electedStashes", + "Vec" + ], + [ + "exposures", + "Vec<(AccountId, Exposure)>" + ] + ] + }, + "ElectionStatus": { + "type": "enum", + "type_mapping": [ + [ + "Close", + "Null" + ], + [ + "Open", + "BlockNumber" + ] + ] + }, + "PerDispatchClass": { + "type": "struct", + "type_mapping": [ + [ + "normal", + "WeightPerClass" + ], + [ + "operational", + "WeightPerClass" + ], + [ + "mandatory", + "WeightPerClass" + ] + ] + }, + "ConsumedWeight": "PerDispatchClass", + "Phase": { + "type": "enum", + "type_mapping": [ + [ + "ApplyExtrinsic", + "u32" + ], + [ + "Finalization", + "Null" + ], + [ + "Initialization", + "Null" + ] + ] + }, + "PhragmenScore": "[u128; 3]", + "PreimageStatusAvailable": { + "type": "struct", + "type_mapping": [ + [ + "data", + "Bytes" + ], + [ + "provider", + "AccountId" + ], + [ + "deposit", + "Balance" + ], + [ + "since", + "BlockNumber" + ], + [ + "expiry", + "Option" + ] + ] + }, + "PreimageStatus": { + "type": "enum", + "type_mapping": [ + [ + "Missing", + "BlockNumber" + ], + [ + "Available", + "PreimageStatusAvailable" + ] + ] + }, + "Randomness": "Hash", + "MaybeRandomness": "Option", + "schnorrkel::Randomness": "Hash", + "schnorrkel::RawVRFOutput": "[u8; 32]", + "TaskAddress": { + "type": "struct", + "type_mapping": [ + ["blockNumber", "BlockNumber"], + ["index", "u32"] + ] + }, + "ValidationFunctionParams": { + "type": "struct", + "type_mapping": [ + [ + "maxCodeSize", + "u32" + ], + [ + "relayChainHeight", + "RelayChainBlockNumber" + ], + [ + "codeUpgradeAllowed", + "Option" + ] + ] + }, + "ValidationCode": "Bytes", + "ValidationData": { + "type": "struct", + "type_mapping": [ + [ + "persisted", + "PersistedValidationData" + ], + [ + "transient", + "TransientValidationData" + ] + ] + }, + "ValidationDataType": { + "type": "struct", + "type_mapping": [ + [ + "validationData", + "ValidationData" + ], + [ + "relayChainState", + "Vec" + ] + ] + }, + "ValidatorGroup": "Vec", + "ParaLifecycle": { + "type": "enum", + "value_list": [ + "Onboarding", + "Parathread", + "Parachain", + "UpgradingToParachain", + "DowngradingToParathread", + "OutgoingParathread", + "OutgoingParachain" + ] + }, + "ParaPastCodeMeta": { + "type": "struct", + "type_mapping": [ + [ + "upgradeTimes", + "Vec" + ], + [ + "lastPruned", + "Option" + ] + ] + }, + "ModuleId": "LockIdentifier", + "MultiAddress": { + "type": "enum", + "type_mapping": [ + ["Id", "AccountId"], + ["Index", "Compact"], + ["Raw", "Bytes"], + ["Address32", "[u8; 32]"], + ["Address20", "[u8; 20]"] + ] + }, + "MultiSigner": { + "type": "enum", + "type_mapping": [ + [ + "Ed25519", + "[u8; 32]" + ], + [ + "Sr25519", + "[u8; 32]" + ], + [ + "Ecdsa", + "[u8; 33]" + ] + ] + }, + "RuntimeDbWeight": { + "type": "struct", + "type_mapping": [ + [ + "read", + "Weight" + ], + [ + "write", + "Weight" + ] + ] + }, + "Renouncing": { + "type": "enum", + "type_mapping": [ + [ + "Member", + "Null" + ], + [ + "RunnerUp", + "Null" + ], + [ + "Candidate", + "Compact" + ] + ] + }, + "Voter": { + "type": "struct", + "type_mapping": [ + [ + "votes", + "Vec" + ], + [ + "stake", + "Balance" + ], + [ + "deposit", + "Balance" + ] + ] + }, + "SeatHolder": { + "type": "struct", + "type_mapping": [ + [ + "who", + "AccountId" + ], + [ + "stake", + "Balance" + ], + [ + "deposit", + "Balance" + ] + ] + }, + "ExtrinsicsWeight": { + "type": "struct", + "type_mapping": [ + [ + "normal", + "Weight" + ], + [ + "operational", + "Weight" + ] + ] + }, + "weights::ExtrinsicsWeight": "ExtrinsicsWeight", + "ValidatorCount": "u32", + "MembershipProof": { + "type": "struct", + "type_mapping": [ + [ + "session", + "SessionIndex" + ], + [ + "trieNodes", + "Vec>" + ], + [ + "validatorCount", + "ValidatorCount" + ] + ] + }, + "JustificationNotification": "Bytes", + "KeyOwnerProof": "MembershipProof", + "DefunctVoter": { + "type": "struct", + "type_mapping": [ + [ + "who", + "AccountId" + ], + [ + "voteCount", + "Compact" + ], + [ + "candidateCount", + "Compact" + ] + ] + }, + "ElectionScore": "[u128; 3]", + "ElectionSize": { + "type": "struct", + "type_mapping": [ + [ + "validators", + "Compact" + ], + [ + "nominators", + "Compact" + ] + ] + }, + "SiField": { + "type": "struct", + "type_mapping": [ + [ + "name", + "Option" + ], + [ + "type", + "SiLookupTypeId" + ] + ] + }, + "SiLookupTypeId": "u32", + "SiPath": "Vec", + "SiType": { + "type": "struct", + "type_mapping": [ + [ + "path", + "SiPath" + ], + [ + "params", + "Vec" + ], + [ + "def", + "SiTypeDef" + ] + ] + }, + "SiTypeDef": { + "type": "enum", + "type_mapping": [ + [ + "Composite", + "SiTypeDefComposite" + ], + [ + "Variant", + "SiTypeDefVariant" + ], + [ + "Sequence", + "SiTypeDefSequence" + ], + [ + "Array", + "SiTypeDefArray" + ], + [ + "Tuple", + "SiTypeDefTuple" + ], + [ + "Primitive", + "SiTypeDefPrimitive" + ] + ] + }, + "SiTypeDefArray": { + "type": "struct", + "type_mapping": [ + [ + "len", + "u16" + ], + [ + "type", + "SiLookupTypeId" + ] + ] + }, + "SiTypeDefComposite": { + "type": "struct", + "type_mapping": [ + [ + "fields", + "Vec" + ] + ] + }, + "SiTypeDefVariant": { + "type": "struct", + "type_mapping": [ + [ + "variants", + "Vec" + ] + ] + }, + "SiTypeDefPrimitive": { + "type": "enum", + "value_list": [ + "Bool", + "Char", + "Str", + "U8", + "U16", + "U32", + "U64", + "U128", + "U256", + "I8", + "I16", + "I32", + "I64", + "I128", + "I256" + ] + }, + "SiTypeDefSequence": { + "type": "struct", + "type_mapping": [ + [ + "type", + "SiLookupTypeId" + ] + ] + }, + "SiTypeDefTuple": "Vec", + "SiVariant": { + "type": "struct", + "type_mapping": [ + [ + "name", + "Text" + ], + [ + "fields", + "Vec" + ], + [ + "discriminant", + "Option" + ] + ] + }, + "AllowedSlots": { + "type": "enum", + "value_list": [ + "PrimarySlots", + "PrimaryAndSecondaryPlainSlots", + "PrimaryAndSecondaryVRFSlots" + ] + }, + "NextConfigDescriptorV1": { + "type": "struct", + "type_mapping": [ + [ + "c", + "(u64, u64)" + ], + [ + "allowedSlots", + "AllowedSlots" + ] + ] + }, + "NextConfigDescriptor": { + "type": "enum", + "type_mapping": [ + [ + "V0", + "Null" + ], + [ + "V1", + "NextConfigDescriptorV1" + ] + ] + }, + "StatementKind": { + "type": "enum", + "value_list": [ + "Regular", + "Saft" + ] + }, + "schedule::Priority": "u8", + "GrandpaEquivocation": { + "type": "enum", + "type_mapping": [ + [ + "Prevote", + "GrandpaEquivocationValue" + ], + [ + "Precommit", + "GrandpaEquivocationValue" + ] + ] + }, + "GrandpaPrevote": { + "type": "struct", + "type_mapping": [ + [ + "targetHash", + "Hash" + ], + [ + "targetNumber", + "BlockNumber" + ] + ] + }, + "Equivocation": "GrandpaEquivocation", + "EquivocationProof": { + "type": "struct", + "type_mapping": [ + [ + "setId", + "SetId" + ], + [ + "equivocation", + "Equivocation" + ] + ] + }, + "ProxyType": { + "type": "enum", + "value_list": [ + "Any", + "NonTransfer", + "Governance", + "Staking" + ] + }, + "BalanceStatus": { + "type": "enum", + "value_list": [ + "Free", + "Reserved" + ] + }, + "Status": "BalanceStatus", + "EcdsaSignature": "[u8; 65]", + "Ed25519Signature": "H512", + "Sr25519Signature": "H512", + "AnySignature": "H512", + "MultiSignature": { + "type": "enum", + "type_mapping": [ + [ + "Ed25519", + "Ed25519Signature" + ], + [ + "Sr25519", + "Sr25519Signature" + ], + [ + "Ecdsa", + "EcdsaSignature" + ] + ] + }, + "ExtrinsicSignature": "MultiSignature", + "schedule::period": "(BlockNumber, u32)", + "OpaqueCall": "OpaqueCall", + "OriginCaller": { + "type": "enum", + "type_mapping": [ + [ + "System", + "SystemOrigin" + ] + ] + }, + "PalletId": "LockIdentifier", + "PalletsOrigin": "OriginCaller", + "PalletVersion": { + "type": "struct", + "type_mapping": [ + [ + "major", + "u16" + ], + [ + "minor", + "u8" + ], + [ + "patch", + "u8" + ] + ] + }, + "XcmAssetEffects": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "effects", + "Vec" + ] + ] + }, + "XcmWithdrawAsset": "XcmAssetEffects", + "XcmReserveAssetDeposit": "XcmAssetEffects", + "XcmTeleportAsset": "XcmAssetEffects", + "XcmQueryResponse": { + "type": "struct", + "type_mapping": [ + [ + "queryId", + "Compact" + ], + [ + "response", + "XcmResponse" + ] + ] + }, + "XcmTransferAsset": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "dest", + "MultiLocation" + ] + ] + }, + "XcmTransferReserveAsset": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "dest", + "MultiLocation" + ], + [ + "effects", + "Vec" + ] + ] + }, + "XcmTransact": { + "type": "struct", + "type_mapping": [ + [ + "originType", + "XcmOriginKind" + ], + [ + "requireWeightAtMost", + "u64" + ], + [ + "call", + "DoubleEncodedCall" + ] + ] + }, + "XcmHrmpNewChannelOpenRequest": { + "type": "struct", + "type_mapping": [ + [ + "sender", + "Compact" + ], + [ + "maxMessageSize", + "Compact" + ], + [ + "maxCapacity", + "Compact" + ] + ] + }, + "XcmHrmpChannelAccepted": { + "type": "struct", + "type_mapping": [ + [ + "recipient", + "Compact" + ] + ] + }, + "XcmHrmpChannelClosing": { + "type": "struct", + "type_mapping": [ + [ + "initiator", + "Compact" + ], + [ + "sender", + "Compact" + ], + [ + "recipient", + "Compact" + ] + ] + }, + "XcmRelayedFrom": { + "type": "struct", + "type_mapping": [ + [ + "who", + "MultiLocation" + ], + [ + "message", + "Xcm" + ] + ] + }, + "Xcm": { + "type": "enum", + "type_mapping": [ + [ + "WithdrawAsset", + "XcmWithdrawAsset" + ], + [ + "ReserveAssetDeposit", + "XcmReserveAssetDeposit" + ], + [ + "TeleportAsset", + "XcmTeleportAsset" + ], + [ + "QueryResponse", + "XcmQueryResponse" + ], + [ + "TransferAsset", + "XcmTransferAsset" + ], + [ + "TransferReserveAsset", + "XcmTransferReserveAsset" + ], + [ + "Transact", + "XcmTransact" + ], + [ + "HrmpNewChannelOpenRequest", + "XcmHrmpNewChannelOpenRequest" + ], + [ + "HrmpChannelAccepted", + "XcmHrmpChannelAccepted" + ], + [ + "HrmpChannelClosing", + "XcmHrmpChannelClosing" + ], + [ + "RelayedFrom", + "XcmRelayedFrom" + ] + ] + }, + "Xcm": "Xcm", + "XcmpMessageFormat": { + "type": "enum", + "value_list": [ + "ConcatenatedVersionedXcm", + "ConcatenatedEncodedBlob", + "Signals" + ] + }, + "VersionedXcm": { + "type": "enum", + "type_mapping": [ + [ + "V0", + "Xcm" + ] + ] + }, + "xcm::VersionedXcm": "VersionedXcm", + "XcmOrderDepositAsset": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "dest", + "MultiLocation" + ] + ] + }, + "XcmOrderDepositReserveAsset": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "dest", + "MultiLocation" + ], + [ + "effects", + "Vec" + ] + ] + }, + "XcmOrderExchangeAsset": { + "type": "struct", + "type_mapping": [ + [ + "give", + "Vec" + ], + [ + "receive", + "Vec" + ] + ] + }, + "XcmOrderInitiateReserveWithdraw": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "reserve", + "MultiLocation" + ], + [ + "effects", + "Vec" + ] + ] + }, + "XcmOrderInitiateTeleport": { + "type": "struct", + "type_mapping": [ + [ + "assets", + "Vec" + ], + [ + "dest", + "MultiLocation" + ], + [ + "effects", + "Vec" + ] + ] + }, + "XcmOrderQueryHolding": { + "type": "struct", + "type_mapping": [ + [ + "queryId", + "Compact" + ], + [ + "dest", + "MultiLocation" + ], + [ + "assets", + "Vec" + ] + ] + }, + "XcmOrderBuyExecution": { + "type": "struct", + "type_mapping": [ + [ + "fees", + "MultiAsset" + ], + [ + "weight", + "u64" + ], + [ + "debt", + "u64" + ], + [ + "haltOnError", + "bool" + ], + [ + "xcm", + "Vec" + ] + ] + }, + "XcmOrder": { + "type": "enum", + "type_mapping": [ + [ + "Null", + "Null" + ], + [ + "DepositAsset", + "XcmOrderDepositAsset" + ], + [ + "DepositReserveAsset", + "XcmOrderDepositReserveAsset" + ], + [ + "ExchangeAsset", + "XcmOrderExchangeAsset" + ], + [ + "InitiateReserveWithdraw", + "XcmOrderInitiateReserveWithdraw" + ], + [ + "InitiateTeleport", + "XcmOrderInitiateTeleport" + ], + [ + "QueryHolding", + "XcmOrderQueryHolding" + ], + [ + "BuyExecution", + "XcmOrderBuyExecution" + ] + ] + }, + "DoubleEncodedCall": { + "type": "struct", + "type_mapping": [ + [ + "encoded", + "Vec" + ] + ] + }, + "AuthorityId": "AccountId", + "RawVRFOutput": "[u8; 32]", + "BlockAttestations": { + "type": "struct", + "type_mapping": [ + [ + "receipt", + "CandidateReceipt" + ], + [ + "valid", + "Vec" + ], + [ + "invalid", + "Vec" + ] + ] + }, + "IncludedBlocks": { + "type": "struct", + "type_mapping": [ + [ + "actualNumber", + "BlockNumber" + ], + [ + "session", + "SessionIndex" + ], + [ + "randomSeed", + "H256" + ], + [ + "activeParachains", + "Vec" + ], + [ + "paraBlocks", + "Vec" + ] + ] + }, + "HeartbeatTo244": { + "type": "struct", + "type_mapping": [ + [ + "blockNumber", + "BlockNumber" + ], + [ + "networkState", + "OpaqueNetworkState" + ], + [ + "sessionIndex", + "SessionIndex" + ], + [ + "authorityIndex", + "AuthIndex" + ] + ] + }, + "OpaqueMultiaddr": "Bytes", + "OpaquePeerId": "Bytes", + "OpaqueNetworkState": { + "type": "struct", + "type_mapping": [ + [ + "peerId", + "OpaquePeerId" + ], + [ + "externalAddresses", + "Vec" + ] + ] + }, + "ProposalIndex": "u32", + "VotesTo230": { + "type": "struct", + "type_mapping": [ + [ + "index", + "ProposalIndex" + ], + [ + "threshold", + "MemberCount" + ], + [ + "ayes", + "Vec" + ], + [ + "nays", + "Vec" + ] + ] + }, + "Votes": { + "type": "struct", + "type_mapping": [ + [ + "index", + "ProposalIndex" + ], + [ + "threshold", + "MemberCount" + ], + [ + "ayes", + "Vec" + ], + [ + "nays", + "Vec" + ], + [ + "end", + "BlockNumber" + ] + ] + }, + "FeeDetails": { + "type": "struct", + "type_mapping": [ + [ + "inclusionFee", + "Option" + ] + ] + }, + "InclusionFee": { + "type": "struct", + "type_mapping": [ + [ + "baseFee", + "Balance" + ], + [ + "lenFee", + "Balance" + ], + [ + "adjustedWeightFee", + "Balance" + ] + ] + }, + "RuntimeDispatchInfo": { + "type": "struct", + "type_mapping": [ + [ + "weight", + "Weight" + ], + [ + "class", + "DispatchClass" + ], + [ + "partialFee", + "Balance" + ] + ] + }, + "AliveContractInfo": { + "type": "struct", + "type_mapping": [ + [ + "trieId", + "TrieId" + ], + [ + "storageSize", + "u32" + ], + [ + "pairCount", + "u32" + ], + [ + "codeHash", + "CodeHash" + ], + [ + "rentAllowance", + "Balance" + ], + [ + "rentPaid", + "Balance" + ], + [ + "deductBlock", + "BlockNumber" + ], + [ + "lastWrite", + "Option" + ], + [ + "_reserved", + "Option" + ] + ] + }, + "RawAliveContractInfo": "AliveContractInfo", + "CodeHash": "Hash", + "ContractCallRequest": { + "type": "struct", + "type_mapping": [ + [ + "origin", + "AccountId" + ], + [ + "dest", + "AccountId" + ], + [ + "value", + "Balance" + ], + [ + "gasLimit", + "u64" + ], + [ + "inputData", + "Bytes" + ] + ] + }, + "ContractExecResultSuccessTo260": { + "type": "struct", + "type_mapping": [ + [ + "flags", + "u32" + ], + [ + "data", + "Bytes" + ], + [ + "gas_consumed", + "u64" + ] + ] + }, + "ContractExecResultTo260": { + "type": "enum", + "base_class": "GenericContractExecResult", + "type_mapping": [ + [ + "Success", + "ContractExecResultSuccessTo260" + ], + [ + "Error", + "Null" + ] + ] + }, + "ContractExecResultErrModule": { + "type": "struct", + "type_mapping": [ + [ + "index", + "u8" + ], + [ + "error", + "u8" + ], + [ + "message", + "Option" + ] + ] + }, + "ContractExecResultErr": { + "type": "enum", + "type_mapping": [ + [ + "Other", + "Text" + ], + [ + "CannotLookup", + "Null" + ], + [ + "BadOrigin", + "Null" + ], + [ + "Module", + "ContractExecResultErrModule" + ] + ] + }, + "ContractExecResultOk": { + "type": "struct", + "type_mapping": [ + [ + "flags", + "u32" + ], + [ + "data", + "Bytes" + ] + ] + }, + "ContractExecResultResult": { + "type": "enum", + "type_mapping": [ + [ + "Ok", + "ContractExecResultOk" + ], + [ + "Err", + "ContractExecResultErr" + ] + ] + }, + "ContractExecResult": "ContractExecResultTo260", + "ContractStorageKey": "[u8; 32]", + "DeletedContract": { + "type": "struct", + "type_mapping": [ + [ + "pairCount", + "u32" + ], + [ + "trieId", + "TrieId" + ] + ] + }, + "ExecReturnValue": { + "type": "struct", + "type_mapping": [ + [ + "flags", + "u32" + ], + [ + "data", + "Bytes" + ] + ] + }, + "HostFnWeights": { + "type": "struct", + "type_mapping": [ + [ + "caller", + "Weight" + ], + [ + "address", + "Weight" + ], + [ + "gasLeft", + "Weight" + ], + [ + "balance", + "Weight" + ], + [ + "valueTransferred", + "Weight" + ], + [ + "minimumBalance", + "Weight" + ], + [ + "tombstoneDeposit", + "Weight" + ], + [ + "rentAllowance", + "Weight" + ], + [ + "blockNumber", + "Weight" + ], + [ + "now", + "Weight" + ], + [ + "weightToFee", + "Weight" + ], + [ + "gas", + "Weight" + ], + [ + "input", + "Weight" + ], + [ + "inputPerByte", + "Weight" + ], + [ + "return", + "Weight" + ], + [ + "returnPerByte", + "Weight" + ], + [ + "terminate", + "Weight" + ], + [ + "restoreTo", + "Weight" + ], + [ + "restoreToPerDelta", + "Weight" + ], + [ + "random", + "Weight" + ], + [ + "depositEvent", + "Weight" + ], + [ + "depositEventPerTopic", + "Weight" + ], + [ + "depositEventPerByte", + "Weight" + ], + [ + "setRentAllowance", + "Weight" + ], + [ + "setStorage", + "Weight" + ], + [ + "setStoragePerByte", + "Weight" + ], + [ + "clearStorage", + "Weight" + ], + [ + "getStorage", + "Weight" + ], + [ + "getStoragePerByte", + "Weight" + ], + [ + "transfer", + "Weight" + ], + [ + "call", + "Weight" + ], + [ + "callTransferSurcharge", + "Weight" + ], + [ + "callPerInputByte", + "Weight" + ], + [ + "callPerOutputByte", + "Weight" + ], + [ + "instantiate", + "Weight" + ], + [ + "instantiatePerInputByte", + "Weight" + ], + [ + "instantiatePerOutputByte", + "Weight" + ], + [ + "instantiatePerSaltByte", + "Weight" + ], + [ + "hashSha2256", + "Weight" + ], + [ + "hashSha2256PerByte", + "Weight" + ], + [ + "hashKeccak256", + "Weight" + ], + [ + "hashKeccak256PerByte", + "Weight" + ], + [ + "hashBlake2256", + "Weight" + ], + [ + "hashBlake2256PerByte", + "Weight" + ], + [ + "hashBlake2128", + "Weight" + ], + [ + "hashBlake2128PerByte", + "Weight" + ], + [ + "rentParams", + "Weight" + ] + ] + }, + "InstantiateRequest": { + "type": "struct", + "type_mapping": [ + [ + "origin", + "AccountId" + ], + [ + "endowment", + "Balance" + ], + [ + "gasLimit", + "Gas" + ], + [ + "code", + "Bytes" + ], + [ + "data", + "Bytes" + ], + [ + "salt", + "Bytes" + ] + ] + }, + "ContractInstantiateResult": { + "type": "enum", + "type_mapping": [ + [ + "Ok", + "InstantiateReturnValue" + ], + [ + "Err", + "Null" + ] + ] + }, + "InstantiateReturnValue": { + "type": "struct", + "type_mapping": [ + [ + "result", + "ExecReturnValue" + ], + [ + "accountId", + "AccountId" + ], + [ + "rentProjection", + "Option" + ] + ] + }, + "InstructionWeights": { + "type": "struct", + "type_mapping": [ + [ + "i64const", + "u32" + ], + [ + "i64load", + "u32" + ], + [ + "i64store", + "u32" + ], + [ + "select", + "u32" + ], + [ + "rIf", + "u32" + ], + [ + "br", + "u32" + ], + [ + "brIf", + "u32" + ], + [ + "brIable", + "u32" + ], + [ + "brIablePerEntry", + "u32" + ], + [ + "call", + "u32" + ], + [ + "callIndirect", + "u32" + ], + [ + "callIndirectPerParam", + "u32" + ], + [ + "localGet", + "u32" + ], + [ + "localSet", + "u32" + ], + [ + "local_tee", + "u32" + ], + [ + "globalGet", + "u32" + ], + [ + "globalSet", + "u32" + ], + [ + "memoryCurrent", + "u32" + ], + [ + "memoryGrow", + "u32" + ], + [ + "i64clz", + "u32" + ], + [ + "i64ctz", + "u32" + ], + [ + "i64popcnt", + "u32" + ], + [ + "i64eqz", + "u32" + ], + [ + "i64extendsi32", + "u32" + ], + [ + "i64extendui32", + "u32" + ], + [ + "i32wrapi64", + "u32" + ], + [ + "i64eq", + "u32" + ], + [ + "i64ne", + "u32" + ], + [ + "i64lts", + "u32" + ], + [ + "i64ltu", + "u32" + ], + [ + "i64gts", + "u32" + ], + [ + "i64gtu", + "u32" + ], + [ + "i64les", + "u32" + ], + [ + "i64leu", + "u32" + ], + [ + "i64ges", + "u32" + ], + [ + "i64geu", + "u32" + ], + [ + "i64add", + "u32" + ], + [ + "i64sub", + "u32" + ], + [ + "i64mul", + "u32" + ], + [ + "i64divs", + "u32" + ], + [ + "i64divu", + "u32" + ], + [ + "i64rems", + "u32" + ], + [ + "i64remu", + "u32" + ], + [ + "i64and", + "u32" + ], + [ + "i64or", + "u32" + ], + [ + "i64xor", + "u32" + ], + [ + "i64shl", + "u32" + ], + [ + "i64shrs", + "u32" + ], + [ + "i64shru", + "u32" + ], + [ + "i64rotl", + "u32" + ], + [ + "i64rotr", + "u32" + ] + ] + }, + "Limits": { + "type": "struct", + "type_mapping": [ + [ + "eventTopics", + "u32" + ], + [ + "stackHeight", + "u32" + ], + [ + "globals", + "u32" + ], + [ + "parameters", + "u32" + ], + [ + "memoryPages", + "u32" + ], + [ + "tableSize", + "u32" + ], + [ + "brTableSize", + "u32" + ], + [ + "subjectLen", + "u32" + ], + [ + "codeSize", + "u32" + ] + ] + }, + "PrefabWasmModule": { + "type": "struct", + "type_mapping": [ + [ + "scheduleVersion", + "Compact" + ], + [ + "initial", + "Compact" + ], + [ + "maximum", + "Compact" + ], + [ + "refcount", + "Compact" + ], + [ + "_reserved", + "Option" + ], + [ + "code", + "Bytes" + ], + [ + "originalCodeLen", + "u32" + ] + ] + }, + "RentProjection": { + "type": "enum", + "type_mapping": [ + [ + "EvictionAt", + "BlockNumber" + ], + [ + "NoEviction", + "Null" + ] + ] + }, + "ScheduleTo212": { + "type": "struct", + "type_mapping": [ + [ + "version", + "u32" + ], + [ + "putCodePerByteCost", + "Gas" + ], + [ + "growMemCost", + "Gas" + ], + [ + "regularOpCost", + "Gas" + ], + [ + "returnDataPerByteCost", + "Gas" + ], + [ + "eventDataPerByteCost", + "Gas" + ], + [ + "eventPerTopicCost", + "Gas" + ], + [ + "eventBaseCost", + "Gas" + ], + [ + "sandboxDataReadCost", + "Gas" + ], + [ + "sandboxDataWriteCost", + "Gas" + ], + [ + "maxEventTopics", + "u32" + ], + [ + "maxStackHeight", + "u32" + ], + [ + "maxMemoryPages", + "u32" + ], + [ + "enablePrintln", + "bool" + ], + [ + "maxSubjectLen", + "u32" + ] + ] + }, + "SeedOf": "Hash", + "TombstoneContractInfo": "Hash", + "ExtrinsicOrHash": { + "type": "enum", + "type_mapping": [ + [ + "Hash", + "Hash" + ], + [ + "Extrinsic", + "Bytes" + ] + ] + }, + "ExtrinsicStatus": { + "type": "enum", + "type_mapping": [ + [ + "Future", + "Null" + ], + [ + "Ready", + "Null" + ], + [ + "Broadcast", + "Vec" + ], + [ + "InBlock", + "Hash" + ], + [ + "Retracted", + "Hash" + ], + [ + "FinalityTimeout", + "Hash" + ], + [ + "Finalized", + "Hash" + ], + [ + "Usurped", + "Hash" + ], + [ + "Dropped", + "Null" + ], + [ + "Invalid", + "Null" + ] + ] + }, + "StorageKey": "Bytes", + "PrefixedStorageKey": "StorageKey", + "AccountIndex": "GenericAccountIndex", + "Address": "MultiAddress", + "AssetId": "u32", + "Justification": "(ConsensusEngineId, EncodedJustification)", + "EncodedJustification": "Bytes", + "Justifications": "Vec", + "Slot": "u64", + "StorageData": "Bytes", + "StorageProof": { + "type": "struct", + "type_mapping": [ + [ + "trieNodes", + "Vec" + ] + ] + }, + "KeyValue": "(StorageKey, StorageData)", + "KeyTypeId": "u32", + "LookupSource": "Address", + + "LookupTarget": "AccountId", + "Perbill": "u32", + "Permill": "u32", + "Perquintill": "u64", + "Phantom": "Null", + "SignedBlockWithJustification": { + "type": "struct", + "type_mapping": [ + [ + "block", + "Block" + ], + [ + "justification", + "Option" + ] + ] + }, + "SignedBlockWithJustifications": { + "type": "struct", + "type_mapping": [ + [ + "block", + "Block" + ], + [ + "justifications", + "Option" + ] + ] + }, + "SignedBlock": "SignedBlockWithJustifications", + "ValidatorId": "AccountId", + "ValidatorIdOf": "ValidatorId", + "ValidatorSetId": "u64", + "PreRuntime": "GenericPreRuntime", + "SealV0": "GenericSealV0", + "Seal": "GenericSeal", + "Consensus": "GenericConsensus", + "Period": "(BlockNumber, u32)", + "Priority": "u8", + "SchedulePeriod": "Period", + "SchedulePriority": "Priority", + "Scheduled": { + "type": "struct", + "type_mapping": [ + [ + "maybeId", + "Option" + ], + [ + "priority", + "SchedulePriority" + ], + [ + "call", + "Call" + ], + [ + "maybePeriodic", + "Option" + ], + [ + "origin", + "PalletsOrigin" + ] + ] + }, + "ScheduledTo254": { + "type": "struct", + "type_mapping": [ + [ + "maybeId", + "Option" + ], + [ + "priority", + "SchedulePriority" + ], + [ + "call", + "Call" + ], + [ + "maybePeriodic", + "Option" + ] + ] + }, + "SocietyJudgement": { + "type": "enum", + "value_list": [ + "Rebid", + "Reject", + "Approve" + ] + }, + "SocietyVote": { + "type": "enum", + "value_list": [ + "Skeptic", + "Reject", + "Approve" + ] + }, + "BlockHash": "Hash", + "UncleEntryItem": { + "type": "enum", + "type_mapping": [ + [ + "InclusionHeight", + "BlockNumber" + ], + [ + "Uncle", + "(Hash, Option)" + ] + ] + }, + "ApiId": "[u8; 8]", + "BlockTrace": { + "type": "struct", + "type_mapping": [ + [ + "blockHash", + "Text" + ], + [ + "parentHash", + "Text" + ], + [ + "tracingTargets", + "Text" + ], + [ + "storageKeys", + "Text" + ], + [ + "spans", + "Vec" + ], + [ + "events", + "Vec" + ] + ] + }, + "BlockTraceEvent": { + "type": "struct", + "type_mapping": [ + [ + "target", + "Text" + ], + [ + "data", + "BlockTraceEventData" + ], + [ + "parentId", + "Option" + ] + ] + }, + "BlockTraceEventData": { + "type": "struct", + "type_mapping": [ + [ + "stringValues", + "HashMap" + ] + ] + }, + "BlockTraceSpan": { + "type": "struct", + "type_mapping": [ + [ + "id", + "u64" + ], + [ + "parentId", + "Option" + ], + [ + "name", + "Text" + ], + [ + "target", + "Text" + ], + [ + "wasm", + "bool" + ] + ] + }, + "KeyValueOption": "(StorageKey, Option)", + "ReadProof": { + "type": "struct", + "type_mapping": [ + [ + "at", + "Hash" + ], + [ + "proof", + "Vec" + ] + ] + }, + "RuntimeVersionApi": "(ApiId, u32)", + "RuntimeVersion": { + "type": "struct", + "type_mapping": [ + [ + "specName", + "Text" + ], + [ + "implName", + "Text" + ], + [ + "authoringVersion", + "u32" + ], + [ + "specVersion", + "u32" + ], + [ + "implVersion", + "u32" + ], + [ + "apis", + "Vec" + ], + [ + "transactionVersion", + "u32" + ] + ] + }, + "StorageChangeSet": { + "type": "struct", + "type_mapping": [ + [ + "block", + "Hash" + ], + [ + "changes", + "Vec" + ] + ] + }, + "TraceBlockResponse": { + "type": "enum", + "type_mapping": [ + [ + "TraceError", + "TraceError" + ], + [ + "BlockTrace", + "BlockTrace" + ] + ] + }, + "TraceError": { + "type": "struct", + "type_mapping": [ + [ + "error", + "Text" + ] + ] + }, + "FundIndex": "u32", + "LastContribution": { + "type": "enum", + "type_mapping": [ + [ + "Never", + "Null" + ], + [ + "PreEnding", + "u32" + ], + [ + "Ending", + "BlockNumber" + ] + ] + }, + "FundInfo": { + "type": "struct", + "type_mapping": [ + [ + "depositor", + "AccountId" + ], + [ + "verifier", + "Option" + ], + [ + "deposit", + "Balance" + ], + [ + "raised", + "Balance" + ], + [ + "end", + "BlockNumber" + ], + [ + "cap", + "Balance" + ], + [ + "lastContribution", + "LastContribution" + ], + [ + "firstSlot", + "LeasePeriod" + ], + [ + "lastSlot", + "LeasePeriod" + ], + [ + "trieIndex", + "TrieIndex" + ] + ] + }, + "TrieIndex": "u32", + "GrandpaEquivocationProof": { + "type": "struct", + "type_mapping": [ + [ + "setId", + "SetId" + ], + [ + "equivocation", + "GrandpaEquivocation" + ] + ] + }, + "GrandpaEquivocationValue": { + "type": "struct", + "type_mapping": [ + [ + "roundNumber", + "u64" + ], + [ + "identity", + "AuthorityId" + ], + [ + "first", + "(GrandpaPrevote, AuthoritySignature)" + ], + [ + "second", + "(GrandpaPrevote, AuthoritySignature)" + ] + ] + }, + "PendingPause": { + "type": "struct", + "type_mapping": [ + [ + "scheduledAt", + "BlockNumber" + ], + [ + "delay", + "BlockNumber" + ] + ] + }, + "PendingResume": { + "type": "struct", + "type_mapping": [ + [ + "scheduledAt", + "BlockNumber" + ], + [ + "delay", + "BlockNumber" + ] + ] + }, + "BTreeSet": "Vec", + "Precommits": { + "type": "struct", + "type_mapping": [ + [ + "currentWeight", + "u32" + ], + [ + "missing", + "BTreeSet" + ] + ] + }, + "Prevotes": { + "type": "struct", + "type_mapping": [ + [ + "currentWeight", + "u32" + ], + [ + "missing", + "BTreeSet" + ] + ] + }, + "ReportedRoundStates": { + "type": "struct", + "type_mapping": [ + [ + "setId", + "u32" + ], + [ + "best", + "RoundState" + ], + [ + "background", + "Vec" + ] + ] + }, + "RoundState": { + "type": "struct", + "type_mapping": [ + [ + "round", + "u32" + ], + [ + "totalWeight", + "u32" + ], + [ + "thresholdWeight", + "u32" + ], + [ + "prevotes", + "Prevotes" + ], + [ + "precommits", + "Precommits" + ] + ] + }, + "StoredPendingChange": { + "type": "struct", + "type_mapping": [ + [ + "scheduledAt", + "BlockNumber" + ], + [ + "delay", + "BlockNumber" + ], + [ + "nextAuthorities", + "AuthorityList" + ] + ] + }, + "StoredState": { + "type": "enum", + "type_mapping": [ + [ + "Live", + "Null" + ], + [ + "PendingPause", + "PendingPause" + ], + [ + "Paused", + "Null" + ], + [ + "PendingResume", + "PendingResume" + ] + ] + }, + "AccountInfoWithRefCount": { + "type": "struct", + "type_mapping": [ + [ + "nonce", + "Index" + ], + [ + "refcount", + "RefCount" + ], + [ + "data", + "AccountData" + ] + ] + }, + "AccountInfoWithDualRefCount": { + "type": "struct", + "type_mapping": [ + [ + "nonce", + "Index" + ], + [ + "consumers", + "RefCount" + ], + [ + "providers", + "RefCount" + ], + [ + "data", + "AccountData" + ] + ] + }, + "AccountInfoWithProviders": "AccountInfoWithDualRefCount", + "AccountInfoWithTripleRefCount": { + "type": "struct", + "type_mapping": [ + [ + "nonce", + "Index" + ], + [ + "consumers", + "RefCount" + ], + [ + "providers", + "RefCount" + ], + [ + "sufficients", + "RefCount" + ], + [ + "data", + "AccountData" + ] + ] + }, + "AccountInfo": "AccountInfoWithTripleRefCount", + "BlockWeights": { + "type": "struct", + "type_mapping": [ + [ + "baseBlock", + "Weight" + ], + [ + "maxBlock", + "Weight" + ], + [ + "perClass", + "PerDispatchClass" + ] + ] + }, + "ChainProperties": { + "type": "struct", + "type_mapping": [ + [ + "ss58Format", + "Option" + ], + [ + "tokenDecimals", + "Option" + ], + [ + "tokenSymbol", + "Option" + ] + ] + }, + "ChainType": { + "type": "enum", + "type_mapping": [ + [ + "Development", + "Null" + ], + [ + "Local", + "Null" + ], + [ + "Live", + "Null" + ], + [ + "Custom", + "Text" + ] + ] + }, + "DispatchErrorTo198": { + "type": "struct", + "type_mapping": [ + [ + "module", + "Option" + ], + [ + "error", + "u8" + ] + ] + }, + "DispatchInfoTo190": { + "type": "struct", + "type_mapping": [ + [ + "weight", + "Weight" + ], + [ + "class", + "DispatchClass" + ] + ] + }, + "DispatchInfoTo244": { + "type": "struct", + "type_mapping": [ + [ + "weight", + "Weight" + ], + [ + "class", + "DispatchClass" + ], + [ + "paysFee", + "bool" + ] + ] + }, + "DispatchResultOf": "DispatchResult", + "Event": "GenericEvent", + "EventId": "[u8; 2]", + "EventRecord": "EventRecord", + "EventRecordTo76": { + "type": "struct", + "type_mapping": [ + [ + "phase", + "Phase" + ], + [ + "event", + "Event" + ] + ] + }, + "Health": { + "type": "struct", + "type_mapping": [ + [ + "peers", + "u64" + ], + [ + "isSyncing", + "bool" + ], + [ + "shouldHavePeers", + "bool" + ] + ] + }, + "InvalidTransaction": { + "type": "enum", + "type_mapping": [ + [ + "Call", + "Null" + ], + [ + "Payment", + "Null" + ], + [ + "Future", + "Null" + ], + [ + "Stale", + "Null" + ], + [ + "BadProof", + "Null" + ], + [ + "AncientBirthBlock", + "Null" + ], + [ + "ExhaustsResources", + "Null" + ], + [ + "Custom", + "u8" + ], + [ + "BadMandatory", + "Null" + ], + [ + "MandatoryDispatch", + "Null" + ] + ] + }, + "Key": "Bytes", + "TransactionValidityError": { + "type": "enum", + "type_mapping": [ + [ + "Invalid", + "InvalidTransaction" + ], + [ + "Unknown", + "UnknownTransaction" + ] + ] + }, + "UnknownTransaction": { + "type": "enum", + "type_mapping": [ + [ + "CannotLookup", + "Null" + ], + [ + "NoUnsignedValidator", + "Null" + ], + [ + "Custom", + "u8" + ] + ] + }, + "WeightToFeeCoefficient": { + "type": "struct", + "type_mapping": [ + [ + "coeffInteger", + "Balance" + ], + [ + "coeffFrac", + "Perbill" + ], + [ + "negative", + "bool" + ], + [ + "degree", + "u8" + ] + ] + }, + "EraIndex": "u32", + "EraRewards": { + "type": "struct", + "type_mapping": [ + [ + "total", + "u32" + ], + [ + "rewards", + "Vec" + ] + ] + }, + "Exposure": { + "type": "struct", + "type_mapping": [ + [ + "total", + "Compact" + ], + [ + "own", + "Compact" + ], + [ + "others", + "Vec" + ] + ] + }, + "IndividualExposure": { + "type": "struct", + "type_mapping": [ + [ + "who", + "AccountId" + ], + [ + "value", + "Compact" + ] + ] + }, + "KeyType": "AccountId", + "Points": "u32", + "SlashJournalEntry": { + "type": "struct", + "type_mapping": [ + [ + "who", + "AccountId" + ], + [ + "amount", + "Balance" + ], + [ + "ownSlash", + "Balance" + ] + ] + }, + "SlashingSpansTo204": { + "type": "struct", + "type_mapping": [ + [ + "spanIndex", + "SpanIndex" + ], + [ + "lastStart", + "EraIndex" + ], + [ + "prior", + "Vec" + ] + ] + }, + "StakingLedgerTo223": { + "type": "struct", + "type_mapping": [ + [ + "stash", + "AccountId" + ], + [ + "total", + "Compact" + ], + [ + "active", + "Compact" + ], + [ + "unlocking", + "Vec" + ] + ] + }, + "StakingLedgerTo240": { + "type": "struct", + "type_mapping": [ + [ + "stash", + "AccountId" + ], + [ + "total", + "Compact" + ], + [ + "active", + "Compact" + ], + [ + "unlocking", + "Vec" + ], + [ + "lastReward", + "Option" + ] + ] + }, + "StakingLedger": { + "type": "struct", + "type_mapping": [ + [ + "stash", + "AccountId" + ], + [ + "total", + "Compact" + ], + [ + "active", + "Compact" + ], + [ + "unlocking", + "Vec" + ], + [ + "claimedRewards", + "Vec" + ] + ] + }, + "UnappliedSlash": { + "type": "struct", + "type_mapping": [ + [ + "validator", + "AccountId" + ], + [ + "own", + "Balance" + ], + [ + "others", + "Vec" + ], + [ + "reporters", + "Vec" + ], + [ + "payout", + "Balance" + ] + ] + }, + "UnlockChunk": { + "type": "struct", + "type_mapping": [ + [ + "value", + "Compact" + ], + [ + "era", + "Compact" + ] + ] + }, + "ValidatorPrefsWithCommission": { + "type": "struct", + "type_mapping": [ + [ + "commission", + "Compact" + ] + ] + }, + "ValidatorPrefsWithBlocked": { + "type": "struct", + "type_mapping": [ + [ + "commission", + "Compact" + ], + [ + "blocked", + "bool" + ] + ] + }, + "ValidatorPrefs": "ValidatorPrefsWithBlocked", + "ValidatorPrefsTo196": { + "type": "struct", + "type_mapping": [ + [ + "validatorPayment", + "Compact" + ] + ] + }, + "ValidatorPrefsTo145": { + "type": "struct", + "type_mapping": [ + [ + "unstakeThreshold", + "Compact" + ], + [ + "validatorPayment", + "Compact" + ] + ] + }, + "BalanceLockTo212": { + "type": "struct", + "type_mapping": [ + [ + "id", + "LockIdentifier" + ], + [ + "amount", + "Balance" + ], + [ + "until", + "BlockNumber" + ], + [ + "reasons", + "WithdrawReasons" + ] + ] + }, + "VestingSchedule": { + "type": "struct", + "type_mapping": [ + [ + "offset", + "Balance" + ], + [ + "perBlock", + "Balance" + ], + [ + "startingBlock", + "BlockNumber" + ] + ] + }, + "EvmAccount": { + "type": "struct", + "type_mapping": [ + [ + "nonce", + "u256" + ], + [ + "balance", + "u256" + ] + ] + }, + "EvmLog": { + "type": "struct", + "type_mapping": [ + [ + "address", + "H160" + ], + [ + "topics", + "Vec" + ], + [ + "data", + "Bytes" + ] + ] + }, + "EvmVicinity": { + "type": "struct", + "type_mapping": [ + [ + "gasPrice", + "u256" + ], + [ + "origin", + "H160" + ] + ] + }, + "ExitError": { + "type": "enum", + "type_mapping": [ + [ + "StackUnderflow", + "Null" + ], + [ + "StackOverflow", + "Null" + ], + [ + "InvalidJump", + "Null" + ], + [ + "InvalidRange", + "Null" + ], + [ + "DesignatedInvalid", + "Null" + ], + [ + "CallTooDeep", + "Null" + ], + [ + "CreateCollision", + "Null" + ], + [ + "CreateContractLimit", + "Null" + ], + [ + "OutOfOffset", + "Null" + ], + [ + "OutOfGas", + "Null" + ], + [ + "OutOfFund", + "Null" + ], + [ + "PCUnderflow", + "Null" + ], + [ + "CreateEmpty", + "Null" + ], + [ + "Other", + "Text" + ] + ] + }, + "ExitFatal": { + "type": "enum", + "type_mapping": [ + [ + "NotSupported", + "Null" + ], + [ + "UnhandledInterrupt", + "Null" + ], + [ + "CallErrorAsFatal", + "ExitError" + ], + [ + "Other", + "Text" + ] + ] + }, + "ExitReason": { + "type": "enum", + "type_mapping": [ + [ + "Succeed", + "ExitSucceed" + ], + [ + "Error", + "ExitError" + ], + [ + "Revert", + "ExitRevert" + ], + [ + "Fatal", + "ExitFatal" + ] + ] + }, + "ExitRevert": { + "type": "enum", + "value_list": [ + "Reverted" + ] + }, + "ExitSucceed": { + "type": "enum", + "value_list": [ + "Stopped", + "Returned", + "Suicided" + ] + }, + "StorageKind": { + "type": "enum", + "value_list": [ + "__UNUSED", + "PERSISTENT", + "LOCAL" + ] + }, + "ConfigData": { + "type": "struct", + "type_mapping": [ + [ + "maxIndividual", + "Weight" + ] + ] + }, + "MessageId": "[u8; 32]", + "OverweightIndex": "u64", + "PageCounter": "u32", + "PageIndexData": { + "type": "struct", + "type_mapping": [ + [ + "beginUsed", + "PageCounter" + ], + [ + "endUsed", + "PageCounter" + ], + [ + "overweightCount", + "OverweightIndex" + ] + ] + }, + "OpenTipTo225": { + "type": "struct", + "type_mapping": [ + [ + "reason", + "Hash" + ], + [ + "who", + "AccountId" + ], + [ + "finder", + "Option" + ], + [ + "closes", + "Option" + ], + [ + "tips", + "Vec" + ] + ] + }, + "OpenTipFinderTo225": "(AccountId, Balance)", + "TreasuryProposal": { + "type": "struct", + "type_mapping": [ + [ + "proposer", + "AccountId" + ], + [ + "value", + "Balance" + ], + [ + "beneficiary", + "AccountId" + ], + [ + "bond", + "Balance" + ] + ] + }, + "BabeAuthorityWeight": "u64", + "BabeEpochConfiguration": { + "type": "struct", + "type_mapping": [ + [ + "c", + "(u64, u64)" + ], + [ + "allowedSlots", + "AllowedSlots" + ] + ] + }, + "BabeBlockWeight": "u32", + "BabeEquivocationProof": { + "type": "struct", + "type_mapping": [ + [ + "offender", + "AuthorityId" + ], + [ + "slotNumber", + "SlotNumber" + ], + [ + "firstHeader", + "Header" + ], + [ + "secondHeader", + "Header" + ] + ] + }, + "EquivocationProof

": "BabeEquivocationProof", + "BabeWeight": "u64", + "EpochAuthorship": { + "type": "struct", + "type_mapping": [ + [ + "primary", + "Vec" + ], + [ + "secondary", + "Vec" + ], + [ + "secondary_vrf", + "Vec" + ] + ] + }, + "RawBabePreDigestTo159": { + "type": "enum", + "type_mapping": [ + [ + "Primary", + "RawBabePreDigestPrimaryTo159" + ], + [ + "Secondary", + "RawBabePreDigestSecondaryTo159" + ] + ] + }, + "RawBabePreDigestPrimaryTo159": { + "type": "struct", + "type_mapping": [ + [ + "authorityIndex", + "u32" + ], + [ + "slotNumber", + "SlotNumber" + ], + [ + "weight", + "BabeBlockWeight" + ], + [ + "vrfOutput", + "VrfOutput" + ], + [ + "vrfProof", + "VrfProof" + ] + ] + }, + "RawBabePreDigestSecondaryTo159": { + "type": "struct", + "type_mapping": [ + [ + "authorityIndex", + "u32" + ], + [ + "slotNumber", + "SlotNumber" + ], + [ + "weight", + "BabeBlockWeight" + ] + ] + }, + "RawBabePreDigestCompat": { + "type": "enum", + "type_mapping": [ + [ + "Zero", + "u32" + ], + [ + "One", + "u32" + ], + [ + "Two", + "u32" + ], + [ + "Three", + "u32" + ] + ] + }, + "VrfOutput": "[u8; 32]", + "RpcMethods": { + "type": "struct", + "type_mapping": [ + [ + "version", + "u32" + ], + [ + "methods", + "Vec" + ] + ] + }, + "AssetApprovalKey": { + "type": "struct", + "type_mapping": [ + [ + "owner", + "AccountId" + ], + [ + "delegate", + "AccountId" + ] + ] + }, + "AssetApproval": { + "type": "struct", + "type_mapping": [ + [ + "amount", + "TAssetBalance" + ], + [ + "deposit", + "TAssetDepositBalance" + ] + ] + }, + "AssetBalance": { + "type": "struct", + "type_mapping": [ + [ + "balance", + "TAssetBalance" + ], + [ + "isFrozen", + "bool" + ], + [ + "isSufficient", + "bool" + ] + ] + }, + "AssetDestroyWitness": { + "type": "struct", + "type_mapping": [ + [ + "accounts", + "Compact" + ], + [ + "sufficients", + "Compact" + ], + [ + "approvals", + "Compact" + ] + ] + }, + "AssetDetails": { + "type": "struct", + "type_mapping": [ + [ + "owner", + "AccountId" + ], + [ + "issuer", + "AccountId" + ], + [ + "admin", + "AccountId" + ], + [ + "freezer", + "AccountId" + ], + [ + "supply", + "TAssetBalance" + ], + [ + "deposit", + "TAssetDepositBalance" + ], + [ + "minBalance", + "TAssetBalance" + ], + [ + "isSufficient", + "bool" + ], + [ + "accounts", + "u32" + ], + [ + "sufficients", + "u32" + ], + [ + "approvals", + "u32" + ], + [ + "isFrozen", + "bool" + ] + ] + }, + "AssetMetadata": { + "type": "struct", + "type_mapping": [ + [ + "deposit", + "TAssetDepositBalance" + ], + [ + "name", + "Vec" + ], + [ + "symbol", + "Vec" + ], + [ + "decimals", + "u8" + ], + [ + "isFrozen", + "bool" + ] + ] + }, + "TAssetBalance": "u64", + "TAssetDepositBalance": "BalanceOf", + "CreatedBlock": { + "type": "struct", + "type_mapping": [ + [ + "hash", + "BlockHash" + ], + [ + "aux", + "ImportedAux" + ] + ] + }, + "ImportedAux": { + "type": "struct", + "type_mapping": [ + [ + "headerOnly", + "bool" + ], + [ + "clearJustificationRequests", + "bool" + ], + [ + "needsJustification", + "bool" + ], + [ + "badJustification", + "bool" + ], + [ + "needsFinalityProof", + "bool" + ], + [ + "isNewBest", + "bool" + ] + ] + }, + "Conviction": { + "type": "enum", + "value_list": [ + "None", + "Locked1x", + "Locked2x", + "Locked3x", + "Locked4x", + "Locked5x", + "Locked6x" + ] + }, + "PropIndex": "u32", + "Proposal": "Call", + "ReferendumIndex": "u32", + "ReferendumInfoTo239": { + "type": "struct", + "type_mapping": [ + [ + "end", + "BlockNumber" + ], + [ + "proposalHash", + "Hash" + ], + [ + "threshold", + "VoteThreshold" + ], + [ + "delay", + "BlockNumber" + ] + ] + }, + "ApprovalFlag": "u32", + "SetIndex": "u32", + "Vote": "GenericVote", + "VoteIndex": "u32", + "VoterInfo": { + "type": "struct", + "type_mapping": [ + [ + "lastActive", + "VoteIndex" + ], + [ + "lastWin", + "VoteIndex" + ], + [ + "pot", + "Balance" + ], + [ + "stake", + "Balance" + ] + ] + }, + "VoteThreshold": { + "type": "enum", + "value_list": [ + "Super majority approval", + "Super majority rejection", + "Simple majority" + ] + }, + "EthereumAddress": "H160", + "AbridgedCandidateReceipt": { + "type": "struct", + "type_mapping": [ + [ + "parachainIndex", + "ParaId" + ], + [ + "relayParent", + "Hash" + ], + [ + "headData", + "HeadData" + ], + [ + "collator", + "CollatorId" + ], + [ + "signature", + "CollatorSignature" + ], + [ + "povBlockHash", + "Hash" + ], + [ + "commitments", + "CandidateCommitments" + ] + ] + }, + "AbridgedHostConfiguration": { + "type": "struct", + "type_mapping": [ + [ + "maxCodeSize", + "u32" + ], + [ + "maxHeadDataSize", + "u32" + ], + [ + "maxUpwardQueueCount", + "u32" + ], + [ + "maxUpwardQueueSize", + "u32" + ], + [ + "maxUpwardMessageSize", + "u32" + ], + [ + "maxUpwardMessageNumPerCandidate", + "u32" + ], + [ + "hrmpMaxMessageNumPerCandidate", + "u32" + ], + [ + "validationUpgradeFrequency", + "BlockNumber" + ], + [ + "validationUpgradeDelay", + "BlockNumber" + ] + ] + }, + "AbridgedHrmpChannel": { + "type": "struct", + "type_mapping": [ + [ + "maxCapacity", + "u32" + ], + [ + "maxTotalSize", + "u32" + ], + [ + "maxMessageSize", + "u32" + ], + [ + "msgCount", + "u32" + ], + [ + "totalSize", + "u32" + ], + [ + "mqcHead", + "Option" + ] + ] + }, + "Bidder": { + "type": "enum", + "type_mapping": [ + [ + "New", + "NewBidder" + ], + [ + "Existing", + "ParaId" + ] + ] + }, + "BackedCandidate": { + "type": "struct", + "type_mapping": [ + [ + "candidate", + "CommittedCandidateReceipt" + ], + [ + "validityVotes", + "Vec" + ], + [ + "validatorIndices", + "BitVec" + ] + ] + }, + "BufferedSessionChange": { + "type": "struct", + "type_mapping": [ + [ + "applyAt", + "BlockNumber" + ], + [ + "validators", + "Vec" + ], + [ + "queued", + "Vec" + ], + [ + "sessionIndex", + "SessionIndex" + ] + ] + }, + "CandidateCommitments": { + "type": "struct", + "type_mapping": [ + [ + "upwardMessages", + "Vec" + ], + [ + "horizontalMessages", + "Vec" + ], + [ + "newValidationCode", + "Option" + ], + [ + "headData", + "HeadData" + ], + [ + "processedDownwardMessages", + "u32" + ], + [ + "hrmpWatermark", + "BlockNumber" + ] + ] + }, + "CandidateDescriptor": { + "type": "struct", + "type_mapping": [ + [ + "paraId", + "ParaId" + ], + [ + "relayParent", + "RelayChainHash" + ], + [ + "collatorId", + "CollatorId" + ], + [ + "persistedValidationDataHash", + "Hash" + ], + [ + "povHash", + "Hash" + ], + [ + "erasureRoot", + "Hash" + ], + [ + "signature", + "CollatorSignature" + ], + [ + "paraHead", + "Hash" + ], + [ + "validationCodeHash", + "Hash" + ] + ] + }, + "CandidateHash": "Hash", + "CandidatePendingAvailability": { + "type": "struct", + "type_mapping": [ + [ + "core", + "CoreIndex" + ], + [ + "hash", + "CandidateHash" + ], + [ + "descriptor", + "CandidateDescriptor" + ], + [ + "availabilityVotes", + "BitVec" + ], + [ + "backers", + "BitVec" + ], + [ + "relayParentNumber", + "BlockNumber" + ], + [ + "backedInNumber", + "BlockNumber" + ], + [ + "backingGroup", + "GroupIndex" + ] + ] + }, + "CandidateReceipt": { + "type": "struct", + "type_mapping": [ + [ + "descriptor", + "CandidateDescriptor" + ], + [ + "commitmentsHash", + "Hash" + ] + ] + }, + "GlobalValidationData": { + "type": "struct", + "type_mapping": [ + [ + "maxCodeSize", + "u32" + ], + [ + "maxHeadDataSize", + "u32" + ], + [ + "blockNumber", + "BlockNumber" + ] + ] + }, + "CommittedCandidateReceipt": { + "type": "struct", + "type_mapping": [ + [ + "descriptor", + "CandidateDescriptor" + ], + [ + "commitments", + "CandidateCommitments" + ] + ] + }, + "CoreAssignment": { + "type": "struct", + "type_mapping": [ + [ + "core", + "CoreIndex" + ], + [ + "paraId", + "ParaId" + ], + [ + "kind", + "AssignmentKind" + ], + [ + "groupIdx", + "GroupIndex" + ] + ] + }, + "CoreIndex": "u32", + "CoreOccupied": { + "type": "enum", + "type_mapping": [ + [ + "Parathread", + "ParathreadEntry" + ], + [ + "Parachain", + "Null" + ] + ] + }, + "DownwardMessage": "Bytes", + "GroupIndex": "u32", + "GlobalValidationSchedule": { + "type": "struct", + "type_mapping": [ + [ + "maxCodeSize", + "u32" + ], + [ + "maxHeadDataSize", + "u32" + ], + [ + "blockNumber", + "BlockNumber" + ] + ] + }, + "HeadData": "Bytes", + "HostConfiguration": { + "type": "struct", + "type_mapping": [ + [ + "maxCodeSize", + "u32" + ], + [ + "maxHeadDataSize", + "u32" + ], + [ + "maxUpwardQueueCount", + "u32" + ], + [ + "maxUpwardQueueSize", + "u32" + ], + [ + "maxUpwardMessageSize", + "u32" + ], + [ + "maxUpwardMessageNumPerCandidate", + "u32" + ], + [ + "hrmpMaxMessageNumPerCandidate", + "u32" + ], + [ + "validationUpgradeFrequency", + "BlockNumber" + ], + [ + "validationUpgradeDelay", + "BlockNumber" + ], + [ + "maxPovSize", + "u32" + ], + [ + "maxDownwardMessageSize", + "u32" + ], + [ + "preferredDispatchableUpwardMessagesStepWeight", + "Weight" + ], + [ + "hrmpMaxParachainOutboundChannels", + "u32" + ], + [ + "hrmpMaxParathreadOutboundChannels", + "u32" + ], + [ + "hrmpOpenRequestTtl", + "u32" + ], + [ + "hrmpSenderDeposit", + "Balance" + ], + [ + "hrmpRecipientDeposit", + "Balance" + ], + [ + "hrmpChannelMaxCapacity", + "u32" + ], + [ + "hrmpChannelMaxTotalSize", + "u32" + ], + [ + "hrmpMaxParachainInboundChannels", + "u32" + ], + [ + "hrmpMaxParathreadInboundChannels", + "u32" + ], + [ + "hrmpChannelMaxMessageSize", + "u32" + ], + [ + "codeRetentionPeriod", + "BlockNumber" + ], + [ + "parathreadCores", + "u32" + ], + [ + "parathreadRetries", + "u32" + ], + [ + "groupRotationFrequency", + "BlockNumber" + ], + [ + "chainAvailabilityPeriod", + "BlockNumber" + ], + [ + "threadAvailabilityPeriod", + "BlockNumber" + ], + [ + "schedulingLookahead", + "u32" + ], + [ + "maxValidatorsPerCore", + "Option" + ], + [ + "maxValidators", + "Option" + ], + [ + "disputePeriod", + "SessionIndex" + ], + [ + "disputePostConclusionAcceptancePeriod", + "BlockNumber" + ], + [ + "disputeMaxSpamSlots", + "u32" + ], + [ + "disputeConclusionByTimeOutPeriod", + "BlockNumber" + ], + [ + "noShowSlots", + "u32" + ], + [ + "nDelayTranches", + "u32" + ], + [ + "zerothDelayTrancheWidth", + "u32" + ], + [ + "neededApprovals", + "u32" + ], + [ + "relayVrfModuloSamples", + "u32" + ] + ] + }, + "HostConfigurationTo13": { + "type": "struct", + "type_mapping": [ + [ + "validationUpgradeFrequency", + "BlockNumber" + ], + [ + "validationUpgradeDelay", + "BlockNumber" + ], + [ + "acceptancePeriod", + "BlockNumber" + ], + [ + "maxCodeSize", + "u32" + ], + [ + "maxHeadDataSize", + "u32" + ], + [ + "maxPovSize", + "u32" + ], + [ + "parathreadCores", + "u32" + ], + [ + "parathreadRetries", + "u32" + ], + [ + "groupRotationFrequency", + "BlockNumber" + ], + [ + "chainAvailabilityPeriod", + "BlockNumber" + ], + [ + "threadAvailabilityPeriod", + "BlockNumber" + ], + [ + "schedulingLookahead", + "u32" + ], + [ + "maxValidatorsPerCore", + "Option" + ], + [ + "disputePeriod", + "SessionIndex" + ], + [ + "noShowSlots", + "u32" + ], + [ + "nDelayTranches", + "u32" + ], + [ + "zerothDelayTrancheWidth", + "u32" + ], + [ + "neededApprovals", + "u32" + ], + [ + "relayVrfModuloSamples", + "u32" + ], + [ + "maxUpwardQueueCount", + "u32" + ], + [ + "maxUpwardQueueSize", + "u32" + ], + [ + "maxDownwardMessageSize", + "u32" + ], + [ + "preferredDispatchableUpwardMessagesStepWeight", + "Weight" + ], + [ + "maxUpwardMessageSize", + "u32" + ], + [ + "maxUpwardMessageNumPerCandidate", + "u32" + ], + [ + "hrmpOpenRequestTtl", + "u32" + ], + [ + "hrmpSenderDeposit", + "Balance" + ], + [ + "hrmpRecipientDeposit", + "Balance" + ], + [ + "hrmpChannelMaxCapacity", + "u32" + ], + [ + "hrmpChannelMaxTotalSize", + "u32" + ], + [ + "hrmpMaxParachainInboundChannels", + "u32" + ], + [ + "hrmpMaxParathreadInboundChannels", + "u32" + ], + [ + "hrmpChannelMaxMessageSize", + "u32" + ], + [ + "hrmpMaxParachainOutboundChannels", + "u32" + ], + [ + "hrmpMaxParathreadOutboundChannels", + "u32" + ], + [ + "hrmpMaxMessageNumPerCandidate", + "u32" + ] + ] + }, + "HrmpChannel": { + "type": "struct", + "type_mapping": [ + [ + "maxCapacity", + "u32" + ], + [ + "maxTotalSize", + "u32" + ], + [ + "maxMessageSize", + "u32" + ], + [ + "msgCount", + "u32" + ], + [ + "totalSize", + "u32" + ], + [ + "mqcHead", + "Option" + ], + [ + "senderDeposit", + "Balance" + ], + [ + "recipientDeposit", + "Balance" + ] + ] + }, + "HrmpOpenChannelRequest": { + "type": "struct", + "type_mapping": [ + [ + "confirmed", + "bool" + ], + [ + "age", + "SessionIndex" + ], + [ + "senderDeposit", + "Balance" + ], + [ + "maxMessageSize", + "u32" + ], + [ + "maxCapacity", + "u32" + ], + [ + "maxTotalSize", + "u32" + ] + ] + }, + "InboundDownwardMessage": { + "type": "struct", + "type_mapping": [ + [ + "pubSentAt", + "BlockNumber" + ], + [ + "pubMsg", + "DownwardMessage" + ] + ] + }, + "InboundHrmpMessage": { + "type": "struct", + "type_mapping": [ + [ + "sentAt", + "BlockNumber" + ], + [ + "data", + "Bytes" + ] + ] + }, + "InboundHrmpMessages": "Vec", + "HrmpChannelId": { + "type": "struct", + "type_mapping": [ + [ + "sender", + "u32" + ], + [ + "receiver", + "u32" + ] + ] + }, + "LocalValidationData": { + "type": "struct", + "type_mapping": [ + [ + "parentHead", + "HeadData" + ], + [ + "balance", + "Balance" + ], + [ + "codeUpgradeAllowed", + "Option" + ] + ] + }, + "BTreeMap": "Vec<(ParaId, InboundHrmpMessages)>", + "MessageIngestionType": { + "type": "struct", + "type_mapping": [ + [ + "downwardMessages", + "Vec" + ], + [ + "horizontalMessages", + "BTreeMap" + ] + ] + }, + "ParachainDispatchOrigin": { + "type": "enum", + "value_list": [ + "Signed", + "Parachain", + "Root" + ] + }, + "ParachainInherentData": { + "type": "struct", + "type_mapping": [ + [ + "validationData", + "PersistedValidationData" + ], + [ + "relayChainState", + "StorageProof" + ], + [ + "downwardMessages", + "Vec" + ], + [ + "horizontalMessages", + "BTreeMap" + ] + ] + }, + "ParachainProposal": { + "type": "struct", + "type_mapping": [ + [ + "proposer", + "AccountId" + ], + [ + "genesisHead", + "HeadData" + ], + [ + "validators", + "Vec" + ], + [ + "name", + "Bytes" + ], + [ + "balance", + "Balance" + ] + ] + }, + "RegisteredParachainInfo": { + "type": "struct", + "type_mapping": [ + [ + "validators", + "Vec" + ], + [ + "proposer", + "AccountId" + ] + ] + }, + "SystemInherentData": "ParachainInherentData", + "RelayBlockNumber": "u32", + "RelayChainBlockNumber": "RelayBlockNumber", + "RelayHash": "Hash", + "RelayChainHash": "RelayHash", + "QueuedParathread": { + "type": "struct", + "type_mapping": [ + [ + "claim", + "ParathreadEntry" + ], + [ + "coreOffset", + "u32" + ] + ] + }, + "Remark": "[u8; 32]", + "Retriable": { + "type": "enum", + "type_mapping": [ + [ + "Never", + "Null" + ], + [ + "WithRetries", + "u32" + ] + ] + }, + "Scheduling": { + "type": "enum", + "value_list": [ + "Always", + "Dynamic" + ] + }, + "SessionInfoValidatorGroup": "Vec", + "SessionInfo": { + "type": "struct", + "type_mapping": [ + [ + "validators", + "Vec" + ], + [ + "discoveryKeys", + "Vec" + ], + [ + "assignmentKeys", + "Vec" + ], + [ + "validatorGroups", + "Vec" + ], + [ + "nCores", + "u32" + ], + [ + "zerothDelayTrancheWidth", + "u32" + ], + [ + "relayVrfModuloSamples", + "u32" + ], + [ + "nDelayTranches", + "u32" + ], + [ + "noShowSlots", + "u32" + ], + [ + "neededApprovals", + "u32" + ] + ] + }, + "SignedAvailabilityBitfield": { + "type": "struct", + "type_mapping": [ + [ + "payload", + "BitVec" + ], + [ + "validatorIndex", + "ParaValidatorIndex" + ], + [ + "signature", + "ValidatorSignature" + ] + ] + }, + "SignedAvailabilityBitfields": "Vec", + "UpwardMessage": "Bytes", + "CallIndex": "(u8, u8)", + "LotteryConfig": { + "type": "struct", + "type_mapping": [ + [ + "price", + "Balance" + ], + [ + "start", + "BlockNumber" + ], + [ + "length", + "BlockNumber" + ], + [ + "delay", + "BlockNumber" + ], + [ + "repeat", + "bool" + ] + ] + }, + "AssetOptions": { + "type": "struct", + "type_mapping": [ + [ + "initalIssuance", + "Compact" + ], + [ + "permissions", + "PermissionLatest" + ] + ] + }, + "Owner": { + "type": "enum", + "type_mapping": [ + [ + "None", + "Null" + ], + [ + "Address", + "AccountId" + ] + ] + }, + "PermissionsV1": { + "type": "struct", + "type_mapping": [ + [ + "update", + "Owner" + ], + [ + "mint", + "Owner" + ], + [ + "burn", + "Owner" + ] + ] + }, + "PermissionVersions": { + "type": "enum", + "type_mapping": [ + [ + "V1", + "PermissionsV1" + ] + ] + }, + "PermissionLatest": "PermissionsV1", + "Approvals": "[bool; 4]", + "ContractExecResultSuccessTo255": { + "type": "struct", + "type_mapping": [ + [ + "status", + "u8" + ], + [ + "data", + "Bytes" + ] + ] + }, + "ContractExecResultTo255": { + "type": "enum", + "type_mapping": [ + [ + "Success", + "ContractExecResultSuccessTo255" + ], + [ + "Error", + "Null" + ] + ] + }, + "AccountStatus": { + "type": "struct", + "type_mapping": [ + [ + "validity", + "AccountValidity" + ], + [ + "freeBalance", + "Balance" + ], + [ + "lockedBalance", + "Balance" + ], + [ + "signature", + "Vec" + ], + [ + "vat", + "Permill" + ] + ] + }, + "AccountValidity": { + "type": "enum", + "value_list": [ + "Invalid", + "Initiated", + "Pending", + "ValidLow", + "ValidHigh", + "Completed" + ] + }, + "Text, Text": "(Text, Text)", + "DestroyWitness": "AssetDestroyWitness", + "Phase": { + "type": "enum", + "type_mapping": [ + [ + "Off", + "Null" + ], + [ + "Signed", + "Null" + ], + [ + "Unsigned", + "(bool, BlockNumber)" + ] + ] + } + } +} \ No newline at end of file diff --git a/runtime/src/main/assets/types/kusama.json b/runtime/src/main/assets/types/kusama.json new file mode 100644 index 0000000..cde89d9 --- /dev/null +++ b/runtime/src/main/assets/types/kusama.json @@ -0,0 +1,410 @@ +{ + "runtime_id": 2030, + "types": { + "Address": "MultiAddress", + "LookupSource": "MultiAddress", + "AccountInfo": "AccountInfoWithTripleRefCount", + "BlockNumber": "U32", + "LeasePeriod": "BlockNumber", + "Weight": "u64", + "Keys": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["para_validator", "AccountId"], + ["para_assignment", "AccountId"], + ["authority_discovery", "AccountId"] + ] + }, + "DispatchInfo": { + "type": "struct", + "type_mapping": [ + ["weight", "Weight"], + ["class", "DispatchClass"], + ["paysFee", "Pays"] + ] + }, + "ProxyType": { + "type": "enum", + "value_list": [ + "Any", + "NonTransfer", + "Governance", + "Staking", + "IdentityJudgement", + "CancelProxy" + ] + }, + "RefCount": "u32", + "ValidatorPrefs": "ValidatorPrefsWithBlocked" + }, + "versioning": [ + { + "runtime_range": [1019, 1031], + "types": { + "DispatchError": { + "type": "struct", + "type_mapping": [ + ["module", "Option"], + ["error", "u8"] + ] + } + } + }, + { + "runtime_range": [1032, null], + "types": { + "DispatchError": { + "type": "enum", + "type_mapping": [ + ["Other", "Null"], + ["CannotLookup", "Null"], + ["BadOrigin", "Null"], + ["Module", "DispatchErrorModule"] + ] + } + } + }, + { + "runtime_range": [1019, 1037], + "types": { + "IdentityInfo": { + "type": "struct", + "type_mapping": [ + ["additional", "Vec"], + ["display", "Data"], + ["legal", "Data"], + ["web", "Data"], + ["riot", "Data"], + ["email", "Data"], + ["pgpFingerprint", "Option"], + ["image", "Data"] + ] + } + } + }, + { + "runtime_range": [1038, null], + "types": { + "IdentityInfo": { + "type": "struct", + "type_mapping": [ + ["additional", "Vec"], + ["display", "Data"], + ["legal", "Data"], + ["web", "Data"], + ["riot", "Data"], + ["email", "Data"], + ["pgpFingerprint", "Option"], + ["image", "Data"], + ["twitter", "Data"] + ] + } + } + }, + { + "runtime_range": [1019, 1042], + "types": { + "SlashingSpans": { + "type": "struct", + "type_mapping": [ + ["spanIndex", "SpanIndex"], + ["lastStart", "EraIndex"], + ["prior", "Vec"] + ] + } + } + }, + { + "runtime_range": [1043, null], + "types": { + "SlashingSpans": { + "type": "struct", + "type_mapping": [ + ["spanIndex", "SpanIndex"], + ["lastStart", "EraIndex"], + ["lastNonzeroSlash", "EraIndex"], + ["prior", "Vec"] + ] + } + } + }, + { + "runtime_range": [1019, 1045], + "types": { + "StakingLedger": "StakingLedgerTo223", + "BalanceLock": { + "type": "struct", + "type_mapping": [ + ["id", "LockIdentifier"], + ["amount", "Balance"], + ["until", "BlockNumber"], + ["reasons", "WithdrawReasons"] + ] + } + } + }, + { + "runtime_range": [1050, 1056], + "types": { + "StakingLedger": "StakingLedgerTo240", + "BalanceLock": { + "type": "struct", + "type_mapping": [ + ["id", "LockIdentifier"], + ["amount", "Balance"], + ["reasons", "Reasons"] + ] + } + } + }, + { + "runtime_range": [1057, null], + "types": { + "StakingLedger": { + "type": "struct", + "type_mapping": [ + [ + "stash", + "AccountId" + ], + [ + "total", + "Compact" + ], + [ + "active", + "Compact" + ], + [ + "unlocking", + "Vec" + ], + [ + "claimedRewards", + "Vec" + ] + ] + }, + "BalanceLock": { + "type": "struct", + "type_mapping": [ + ["id", "LockIdentifier"], + ["amount", "Balance"], + ["reasons", "Reasons"] + ] + } + } + }, + { + "runtime_range": [1019, 1054], + "types": { + "ReferendumInfo": { + "type": "struct", + "type_mapping": [ + ["end", "BlockNumber"], + ["proposal", "Proposal"], + ["threshold", "VoteThreshold"], + ["delay", "BlockNumber"] + ] + } + } + }, + { + "runtime_range": [1054, null], + "types": { + "ReferendumInfo": { + "type": "enum", + "type_mapping": [ + ["Ongoing", "ReferendumStatus"], + ["Finished", "ReferendumInfoFinished"] + ] + } + } + }, + { + "runtime_range": [1019, 1056], + "types": { + "Weight": "u32" + } + }, + { + "runtime_range": [1057, null], + "types": { + "Weight": "u64" + } + }, + { + "runtime_range": [1019, 1061], + "types": { + "Heartbeat": { + "type": "struct", + "type_mapping": [ + ["blockNumber", "BlockNumber"], + ["networkState", "OpaqueNetworkState"], + ["sessionIndex", "SessionIndex"], + ["authorityIndex", "AuthIndex"] + ] + }, + "DispatchInfo": { + "type": "struct", + "type_mapping": [ + ["weight", "Weight"], + ["class", "DispatchClass"], + ["paysFee", "bool"] + ] + } + } + }, + { + "runtime_range": [1062, null], + "types": { + "Heartbeat": { + "type": "struct", + "type_mapping": [ + ["blockNumber", "BlockNumber"], + ["networkState", "OpaqueNetworkState"], + ["sessionIndex", "SessionIndex"], + ["authorityIndex", "AuthIndex"], + ["validatorsLen", "u32"] + ] + }, + "DispatchInfo": { + "type": "struct", + "type_mapping": [ + ["weight", "Weight"], + ["class", "DispatchClass"], + ["paysFee", "Pays"] + ] + } + } + }, + { + "runtime_range": [1019, 2012], + "types": { + "OpenTip": { + "type": "struct", + "type_mapping": [ + ["reason", "Hash"], + ["who", "AccountId"], + ["finder", "Option"], + ["closes", "Option"], + ["tips", "Vec"] + ] + } + } + }, + { + "runtime_range": [2013, null], + "types": { + "OpenTip": { + "type": "struct", + "type_mapping": [ + ["reason", "Hash"], + ["who", "AccountId"], + ["finder", "AccountId"], + ["deposit", "Balance"], + ["closes", "Option"], + ["tips", "Vec"], + ["findersFee", "bool"] + ] + } + } + }, + { + "runtime_range": [1019, 2022], + "types": { + "CompactAssignments": "CompactAssignmentsTo257" + } + }, + { + "runtime_range": [2023, null], + "types": { + "CompactAssignments": "CompactAssignmentsFrom258" + } + }, + { + "runtime_range": [1019, 2024], + "types": { + "RefCount": "u8" + } + }, + { + "runtime_range": [2025, null], + "types": { + "RefCount": "u32" + } + }, + { + "runtime_range": [1019, 1045], + "types": { + "Address": "RawAddress", + "LookupSource": "RawAddress", + "AccountInfo": "AccountInfoWithRefCount", + "Keys": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["authority_discovery", "AccountId"], + ["parachains", "AccountId"] + ] + }, + "ValidatorPrefs": "ValidatorPrefsWithCommission" + } + }, + { + "runtime_range": [1050, 2027], + "types": { + "Address": "AccountIdAddress", + "LookupSource": "AccountIdAddress", + "AccountInfo": "AccountInfoWithRefCount", + "Keys": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["authority_discovery", "AccountId"], + ["parachains", "AccountId"] + ] + }, + "ValidatorPrefs": "ValidatorPrefsWithCommission" + } + }, + { + "runtime_range": [2028, null], + "types": { + "Address": "MultiAddress", + "LookupSource": "MultiAddress", + "Keys": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["para_validator", "AccountId"], + ["para_assignment", "AccountId"], + ["authority_discovery", "AccountId"] + ] + }, + "ValidatorPrefs": "ValidatorPrefsWithBlocked" + } + }, + { + "runtime_range": [2028, 2029], + "types": { + "AccountInfo": "AccountInfoWithDualRefCount" + } + }, + { + "runtime_range": [2030, null], + "types": { + "AccountInfo": "AccountInfoWithTripleRefCount" + } + } + ] +} \ No newline at end of file diff --git a/runtime/src/main/assets/types/pezkuwi.json b/runtime/src/main/assets/types/pezkuwi.json new file mode 100644 index 0000000..cd90017 --- /dev/null +++ b/runtime/src/main/assets/types/pezkuwi.json @@ -0,0 +1,12 @@ +{ + "types": { + "ExtrinsicSignature": "MultiSignature", + "Address": "MultiAddress", + "LookupSource": "MultiAddress" + }, + "typesAlias": { + "pezsp_runtime.multiaddress.MultiAddress": "MultiAddress", + "pezsp_runtime.MultiSignature": "MultiSignature", + "pezsp_runtime.generic.era.Era": "Era" + } +} diff --git a/runtime/src/main/assets/types/polkadot.json b/runtime/src/main/assets/types/polkadot.json new file mode 100644 index 0000000..bd284ea --- /dev/null +++ b/runtime/src/main/assets/types/polkadot.json @@ -0,0 +1,153 @@ +{ + "runtime_id": 30, + "types": { + "Address": "MultiAddress", + "LookupSource": "MultiAddress", + "AccountInfo": "AccountInfoWithTripleRefCount", + "BlockNumber": "U32", + "LeasePeriod": "BlockNumber", + "Weight": "u64", + "Keys": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["para_validator", "AccountId"], + ["para_assignment", "AccountId"], + ["authority_discovery", "AccountId"] + ] + }, + "ValidatorPrefs": "ValidatorPrefsWithBlocked", + "DispatchInfo": { + "type": "struct", + "type_mapping": [ + ["weight", "Weight"], + ["class", "DispatchClass"], + ["paysFee", "Pays"] + ] + }, + "ProxyType": { + "type": "enum", + "value_list": [ + "Any", + "NonTransfer", + "Governance", + "Staking", + "DeprecatedSudoBalances", + "IdentityJudgement", + "CancelProxy" + ] + }, + "RefCount": "u32" + }, + "versioning": [ + { + "runtime_range": [0, 12], + "types": { + "OpenTip": { + "type": "struct", + "type_mapping": [ + ["reason", "Hash"], + ["who", "AccountId"], + ["finder", "Option"], + ["closes", "Option"], + ["tips", "Vec"] + ] + } + } + }, + { + "runtime_range": [13, null], + "types": { + "OpenTip": { + "type": "struct", + "type_mapping": [ + ["reason", "Hash"], + ["who", "AccountId"], + ["finder", "AccountId"], + ["deposit", "Balance"], + ["closes", "Option"], + ["tips", "Vec"], + ["findersFee", "bool"] + ] + } + } + }, + { + "runtime_range": [0, 22], + "types": { + "CompactAssignments": "CompactAssignmentsTo257" + } + }, + { + "runtime_range": [23, null], + "types": { + "CompactAssignments": "CompactAssignmentsFrom258" + } + }, + { + "runtime_range": [0, 24], + "types": { + "RefCount": "u8" + } + }, + { + "runtime_range": [25, null], + "types": { + "RefCount": "u32" + } + }, + { + "runtime_range": [0, 27], + "types": { + "Address": "AccountIdAddress", + "LookupSource": "AccountIdAddress", + "AccountInfo": "AccountInfoWithRefCount", + "Keys": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["authority_discovery", "AccountId"], + ["parachains", "AccountId"] + ] + }, + "ValidatorPrefs": "ValidatorPrefsWithCommission" + } + }, + { + "runtime_range": [28, null], + "types": { + "Address": "MultiAddress", + "LookupSource": "MultiAddress", + "AccountInfo": "AccountInfoWithDualRefCount", + "Keys": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["para_validator", "AccountId"], + ["para_assignment", "AccountId"], + ["authority_discovery", "AccountId"] + ] + }, + "ValidatorPrefs": "ValidatorPrefsWithBlocked" + } + }, + { + "runtime_range": [28, 29], + "types": { + "AccountInfo": "AccountInfoWithDualRefCount" + } + }, + { + "runtime_range": [30, null], + "types": { + "AccountInfo": "AccountInfoWithTripleRefCount" + } + } + ] +} \ No newline at end of file diff --git a/runtime/src/main/assets/types/rococo.json b/runtime/src/main/assets/types/rococo.json new file mode 100644 index 0000000..d0b3d95 --- /dev/null +++ b/runtime/src/main/assets/types/rococo.json @@ -0,0 +1,53 @@ +{ + "runtime_id": 9001, + "types": { + "Address": "MultiAddress", + "LookupSource": "MultiAddress", + "AccountInfo": "AccountInfoWithTripleRefCount", + "FullIdentification": "()", + "Keys": "SessionKeys7B", + "CompactAssignments": "CompactAssignmentsFrom258" +}, + "versioning": [ + { + "runtime_range": [0, 200], + "types": { + "Address": "AccountIdAddress", + "LookupSource": "AccountIdAddress", + "AccountInfo": "AccountInfoWithDualRefCount" + } + }, + { + "runtime_range": [201, null], + "types": { + "Address": "MultiAddress", + "LookupSource": "MultiAddress", + "AccountInfo": "AccountInfoWithTripleRefCount" + } + }, + { + "runtime_range": [0, 228], + "types": { + "Keys": "SessionKeys6" + } + }, + { + "runtime_range": [229, null], + "types": { + "Keys": "SessionKeys7B" + } + }, + { + "runtime_range": [0, 9009], + "types": { + "CompactAssignments": "CompactAssignmentsFrom258" + } + }, + { + "runtime_range": [9010, null], + "types": { + "CompactAssignments": "CompactAssignmentsFrom265" + } + } + ] +} \ No newline at end of file diff --git a/runtime/src/main/assets/types/westend.json b/runtime/src/main/assets/types/westend.json new file mode 100644 index 0000000..9a3bf8c --- /dev/null +++ b/runtime/src/main/assets/types/westend.json @@ -0,0 +1,184 @@ +{ + "runtime_id": 900, + "types": { + "Address": "MultiAddress", + "LookupSource": "MultiAddress", + "AccountInfo": "AccountInfoWithTripleRefCount", + "BlockNumber": "U32", + "LeasePeriod": "BlockNumber", + "Keys": "SessionKeys6", + "ValidatorPrefs": "ValidatorPrefsWithBlocked", + "ProxyType": { + "type": "enum", + "value_list": [ + "Any", + "NonTransfer", + "Staking", + "SudoBalances", + "IdentityJudgement", + "CancelProxy" + ] + }, + "RefCount": "u32" + }, + "versioning": [ + { + "runtime_range": [3, 22], + "types": { + "OpenTip": { + "type": "struct", + "type_mapping": [ + ["reason", "Hash"], + ["who", "AccountId"], + ["finder", "Option"], + ["closes", "Option"], + ["tips", "Vec"] + ] + } + } + }, + { + "runtime_range": [23, null], + "types": { + "OpenTip": { + "type": "struct", + "type_mapping": [ + ["reason", "Hash"], + ["who", "AccountId"], + ["finder", "AccountId"], + ["deposit", "Balance"], + ["closes", "Option"], + ["tips", "Vec"], + ["findersFee", "bool"] + ] + } + } + }, + { + "runtime_range": [3, 44], + "types": { + "RefCount": "u8" + } + }, + { + "runtime_range": [45, null], + "types": { + "RefCount": "u32" + } + }, + { + "runtime_range": [1, 42], + "types": { + "CompactAssignments": "CompactAssignmentsTo257" + } + }, + { + "runtime_range": [43, null], + "types": { + "CompactAssignments": "CompactAssignmentsFrom258" + } + }, + { + "runtime_range": [1, 44], + "types": { + "Heartbeat": { + "type": "struct", + "type_mapping": [ + ["blockNumber", "BlockNumber"], + ["networkState", "OpaqueNetworkState"], + ["sessionIndex", "SessionIndex"], + ["authorityIndex", "AuthIndex"] + ] + }, + "DispatchInfo": { + "type": "struct", + "type_mapping": [ + ["weight", "Weight"], + ["class", "DispatchClass"], + ["paysFee", "bool"] + ] + } + } + }, + { + "runtime_range": [45, null], + "types": { + "Heartbeat": { + "type": "struct", + "type_mapping": [ + ["blockNumber", "BlockNumber"], + ["networkState", "OpaqueNetworkState"], + ["sessionIndex", "SessionIndex"], + ["authorityIndex", "AuthIndex"], + ["validatorsLen", "u32"] + ] + }, + "DispatchInfo": { + "type": "struct", + "type_mapping": [ + ["weight", "Weight"], + ["class", "DispatchClass"], + ["paysFee", "Pays"] + ] + } + } + }, + { + "runtime_range": [1, 44], + "types": { + "Weight": "u32" + } + }, + { + "runtime_range": [45, null], + "types": { + "Weight": "u64" + } + }, + { + "runtime_range": [0, 47], + "types": { + "Address": "AccountIdAddress", + "LookupSource": "AccountIdAddress", + "AccountInfo": "AccountInfoWithRefCount", + "Keys": { + "type": "struct", + "type_mapping": [ + ["grandpa", "AccountId"], + ["babe", "AccountId"], + ["im_online", "AccountId"], + ["authority_discovery", "AccountId"], + ["parachains", "AccountId"] + ] + }, + "ValidatorPrefs": "ValidatorPrefsWithCommission" + } + }, + { + "runtime_range": [48, null], + "types": { + "Address": "MultiAddress", + "LookupSource": "MultiAddress", + "ValidatorPrefs": "ValidatorPrefsWithBlocked" + } + }, + { + "runtime_range": [48, 49], + "types": { + "AccountInfo": "AccountInfoWithDualRefCount" + } + }, + { + "runtime_range": [50, null], + "types": { + "AccountInfo": "AccountInfoWithTripleRefCount" + } + }, + { + "runtime_range": [48, null], + "types": { + "Keys": "SessionKeys6" + } + } + ] +} \ No newline at end of file diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/call/MultiChainRuntimeCallsApi.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/call/MultiChainRuntimeCallsApi.kt new file mode 100644 index 0000000..e2e1b94 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/call/MultiChainRuntimeCallsApi.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.runtime.call + +import io.novafoundation.nova.common.utils.hasDetectedRuntimeApi +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.multiNetwork.getSocket + +interface MultiChainRuntimeCallsApi { + + suspend fun forChain(chainId: ChainId): RuntimeCallsApi + + suspend fun isSupported(chainId: ChainId, section: String, method: String): Boolean +} + +internal class RealMultiChainRuntimeCallsApi( + private val chainRegistry: ChainRegistry +) : MultiChainRuntimeCallsApi { + + override suspend fun forChain(chainId: ChainId): RuntimeCallsApi { + val runtime = chainRegistry.getRuntime(chainId) + val socket = chainRegistry.getSocket(chainId) + + return RealRuntimeCallsApi(runtime, chainId, socket) + } + + override suspend fun isSupported(chainId: ChainId, section: String, method: String): Boolean { + val runtime = chainRegistry.getRuntime(chainId) + // Avoid extra allocations of RealRuntimeCallsApi and socket retrieval - check directly from the metadata + return runtime.metadata.hasDetectedRuntimeApi(section, method) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/call/RuntimeCallsApi.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/call/RuntimeCallsApi.kt new file mode 100644 index 0000000..3e75384 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/call/RuntimeCallsApi.kt @@ -0,0 +1,139 @@ +package io.novafoundation.nova.runtime.call + +import io.novafoundation.nova.common.data.network.runtime.binding.fromHexOrIncompatible +import io.novafoundation.nova.common.utils.hasDetectedRuntimeApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.rpc.stateCall +import io.novasama.substrate_sdk_android.extensions.requireHexPrefix +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.registry.TypeRegistry +import io.novasama.substrate_sdk_android.runtime.definitions.registry.getOrThrow +import io.novasama.substrate_sdk_android.runtime.definitions.types.bytes +import io.novasama.substrate_sdk_android.runtime.metadata.createRequest +import io.novasama.substrate_sdk_android.runtime.metadata.decodeOutput +import io.novasama.substrate_sdk_android.runtime.metadata.method +import io.novasama.substrate_sdk_android.runtime.metadata.runtimeApi +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.state.StateCallRequest + +typealias RuntimeTypeName = String +typealias RuntimeTypeValue = Any? + +interface RuntimeCallsApi { + + val chainId: ChainId + + val runtime: RuntimeSnapshot + + /** + * @param arguments - list of pairs [runtimeTypeValue, runtimeTypeName], + * where runtimeTypeValue is value to be encoded and runtimeTypeName is type name that can be found in [TypeRegistry] + * It can also be null, in that case argument is considered as already encoded in hex form + * + * This should only be used if automatic decoding via metadata is not possible + * For the other cases use another [call] overload + */ + suspend fun call( + section: String, + method: String, + arguments: List>, + returnType: RuntimeTypeName, + returnBinding: (Any?) -> R + ): R + + suspend fun call( + section: String, + method: String, + arguments: Map, + returnBinding: (Any?) -> R + ): R + + fun isSupported( + section: String, + method: String + ): Boolean +} + +suspend fun RuntimeCallsApi.callCatching( + section: String, + method: String, + arguments: Map, + returnBinding: (Any?) -> R +): Result { + return runCatching { call(section, method, arguments, returnBinding) } +} + +internal class RealRuntimeCallsApi( + override val runtime: RuntimeSnapshot, + override val chainId: ChainId, + private val socketService: SocketService, +) : RuntimeCallsApi { + + override suspend fun call( + section: String, + method: String, + arguments: List>, + returnType: String, + returnBinding: (Any?) -> R + ): R { + val runtimeApiName = createRuntimeApiName(section, method) + val data = encodeArguments(arguments) + + val request = StateCallRequest(runtimeApiName, data) + val response = socketService.stateCall(request) + + val decoded = decodeResponse(response, returnType) + + return returnBinding(decoded) + } + + override suspend fun call( + section: String, + method: String, + arguments: Map, + returnBinding: (Any?) -> R + ): R { + val apiMethod = runtime.metadata.runtimeApi(section).method(method) + val request = apiMethod.createRequest(runtime, arguments) + + val response = socketService.stateCall(request) + + val decoded = response?.let { apiMethod.decodeOutput(runtime, it) } + + return returnBinding(decoded) + } + + override fun isSupported(section: String, method: String): Boolean { + return runtime.metadata.hasDetectedRuntimeApi(section, method) + } + + private fun decodeResponse(responseHex: String?, returnTypeName: String): Any? { + val returnType = runtime.typeRegistry.getOrThrow(returnTypeName) + + return responseHex?.let { + returnType.fromHexOrIncompatible(it, runtime) + } + } + + private fun encodeArguments(arguments: List>): String { + return buildString { + arguments.forEach { (typeValue, runtimeTypeName) -> + val argument = if (runtimeTypeName != null) { + val type = runtime.typeRegistry.getOrThrow(runtimeTypeName) + val encodedArgument = type.bytes(runtime, typeValue) + + encodedArgument.toHexString(withPrefix = false) + } else { + typeValue.toString() + } + + append(argument) + } + }.requireHexPrefix() + } + + private fun createRuntimeApiName(section: String, method: String): String { + return "${section}_$method" + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt new file mode 100644 index 0000000..97e74a2 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt @@ -0,0 +1,244 @@ +package io.novafoundation.nova.runtime.di + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.BuildConfig +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.asset.EvmAssetsSyncService +import io.novafoundation.nova.runtime.multiNetwork.asset.remote.AssetFetcher +import io.novafoundation.nova.runtime.multiNetwork.chain.ChainSyncService +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnectionFactory +import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionPool +import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets +import io.novafoundation.nova.runtime.multiNetwork.connection.Web3ApiPool +import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.NodeAutobalancer +import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSelectionStrategyProvider +import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.AsyncChainSyncDispatcher +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeCacheMigrator +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFilesCache +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeMetadataFetcher +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProviderPool +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeSubscriptionPool +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeSyncService +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchronizer +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.TypesFetcher +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import kotlinx.coroutines.flow.MutableStateFlow +import okhttp3.logging.HttpLoggingInterceptor +import org.web3j.protocol.http.HttpService +import javax.inject.Provider + +@Module +class ChainRegistryModule { + + @Provides + @ApplicationScope + fun provideChainFetcher(apiCreator: NetworkApiCreator) = apiCreator.create(ChainFetcher::class.java) + + @Provides + @ApplicationScope + fun provideChainSyncService( + dao: ChainDao, + chainAssetDao: ChainAssetDao, + chainFetcher: ChainFetcher, + gson: Gson + ) = ChainSyncService(dao, chainFetcher, gson) + + @Provides + @ApplicationScope + fun provideAssetFetcher(apiCreator: NetworkApiCreator) = apiCreator.create(AssetFetcher::class.java) + + @Provides + @ApplicationScope + fun provideAssetSyncService( + chainAssetDao: ChainAssetDao, + chainDao: ChainDao, + assetFetcher: AssetFetcher, + gson: Gson + ) = EvmAssetsSyncService(chainDao, chainAssetDao, assetFetcher, gson) + + @Provides + @ApplicationScope + fun provideRuntimeFactory( + runtimeFilesCache: RuntimeFilesCache, + chainDao: ChainDao, + gson: Gson, + ): RuntimeFactory { + return RuntimeFactory(runtimeFilesCache, chainDao, gson) + } + + @Provides + @ApplicationScope + fun provideRuntimeMetadataFetcher(): RuntimeMetadataFetcher { + return RuntimeMetadataFetcher() + } + + @Provides + @ApplicationScope + fun provideRuntimeCacheMigrator(): RuntimeCacheMigrator { + return RuntimeCacheMigrator() + } + + @Provides + @ApplicationScope + fun provideRuntimeFilesCache( + fileProvider: FileProvider, + preferences: Preferences + ) = RuntimeFilesCache(fileProvider, preferences) + + @Provides + @ApplicationScope + fun provideTypesFetcher( + networkApiCreator: NetworkApiCreator, + ) = networkApiCreator.create(TypesFetcher::class.java) + + @Provides + @ApplicationScope + fun provideRuntimeSyncService( + typesFetcher: TypesFetcher, + runtimeFilesCache: RuntimeFilesCache, + chainDao: ChainDao, + runtimeCacheMigrator: RuntimeCacheMigrator, + runtimeMetadataFetcher: RuntimeMetadataFetcher + ) = RuntimeSyncService( + typesFetcher = typesFetcher, + runtimeFilesCache = runtimeFilesCache, + chainDao = chainDao, + runtimeMetadataFetcher = runtimeMetadataFetcher, + cacheMigrator = runtimeCacheMigrator, + chainSyncDispatcher = AsyncChainSyncDispatcher() + ) + + @Provides + @ApplicationScope + fun provideBaseTypeSynchronizer( + typesFetcher: TypesFetcher, + runtimeFilesCache: RuntimeFilesCache, + ) = BaseTypeSynchronizer(runtimeFilesCache, typesFetcher) + + @Provides + @ApplicationScope + fun provideRuntimeProviderPool( + runtimeFactory: RuntimeFactory, + runtimeSyncService: RuntimeSyncService, + runtimeFilesCache: RuntimeFilesCache, + baseTypeSynchronizer: BaseTypeSynchronizer, + ) = RuntimeProviderPool(runtimeFactory, runtimeSyncService, runtimeFilesCache, baseTypeSynchronizer) + + @Provides + @ApplicationScope + fun provideAutoBalanceProvider( + connectionSecrets: ConnectionSecrets + ) = NodeSelectionStrategyProvider(connectionSecrets) + + @Provides + @ApplicationScope + fun provideNodeAutoBalancer( + nodeSelectionStrategyProvider: NodeSelectionStrategyProvider, + ) = NodeAutobalancer(nodeSelectionStrategyProvider) + + @Provides + @ApplicationScope + fun provideConnectionSecrets(): ConnectionSecrets = ConnectionSecrets.default() + + @Provides + @ApplicationScope + fun provideNodeConnectionFactory( + socketProvider: Provider, + bulkRetriever: BulkRetriever, + connectionSecrets: ConnectionSecrets, + web3ApiFactory: Web3ApiFactory + ) = NodeHealthStateTesterFactory( + socketProvider, + connectionSecrets, + bulkRetriever, + web3ApiFactory + ) + + @Provides + @ApplicationScope + fun provideChainConnectionFactory( + socketProvider: Provider, + externalRequirementsFlow: MutableStateFlow, + nodeAutobalancer: NodeAutobalancer, + ) = ChainConnectionFactory( + externalRequirementsFlow, + nodeAutobalancer, + socketProvider, + ) + + @Provides + @ApplicationScope + fun provideConnectionPool(chainConnectionFactory: ChainConnectionFactory) = ConnectionPool(chainConnectionFactory) + + @Provides + @ApplicationScope + fun provideRuntimeVersionSubscriptionPool( + chainDao: ChainDao, + runtimeSyncService: RuntimeSyncService, + ) = RuntimeSubscriptionPool(chainDao, runtimeSyncService) + + @Provides + @ApplicationScope + fun provideWeb3ApiFactory( + strategyProvider: NodeSelectionStrategyProvider, + ): Web3ApiFactory { + val builder = HttpService.getOkHttpClientBuilder() + builder.interceptors().clear() // getOkHttpClientBuilder() adds logging interceptor which doesn't log into LogCat + + if (BuildConfig.DEBUG) { + builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + } + + val okHttpClient = builder.build() + + return Web3ApiFactory(strategyProvider = strategyProvider, httpClient = okHttpClient) + } + + @Provides + @ApplicationScope + fun provideWeb3ApiPool(web3ApiFactory: Web3ApiFactory) = Web3ApiPool(web3ApiFactory) + + @Provides + @ApplicationScope + fun provideExternalRequirementsFlow() = MutableStateFlow(ChainConnection.ExternalRequirement.ALLOWED) + + @Provides + @ApplicationScope + fun provideChainRegistry( + runtimeProviderPool: RuntimeProviderPool, + chainConnectionPool: ConnectionPool, + runtimeSubscriptionPool: RuntimeSubscriptionPool, + chainDao: ChainDao, + chainSyncService: ChainSyncService, + evmAssetsSyncService: EvmAssetsSyncService, + baseTypeSynchronizer: BaseTypeSynchronizer, + runtimeSyncService: RuntimeSyncService, + web3ApiPool: Web3ApiPool, + gson: Gson + ) = ChainRegistry( + runtimeProviderPool, + chainConnectionPool, + runtimeSubscriptionPool, + chainDao, + chainSyncService, + evmAssetsSyncService, + baseTypeSynchronizer, + runtimeSyncService, + web3ApiPool, + gson + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeApi.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeApi.kt new file mode 100644 index 0000000..97adb6c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeApi.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.runtime.di + +import com.google.gson.Gson +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase +import io.novafoundation.nova.runtime.extrinsic.MortalityConstructor +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.ChainSyncService +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.RemoteToDomainChainMapperFacade +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory +import io.novafoundation.nova.runtime.multiNetwork.qr.MultiChainQrSharingFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFilesCache +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProviderPool +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.repository.BlockLimitsRepository +import io.novafoundation.nova.runtime.repository.ChainNodeRepository +import io.novafoundation.nova.runtime.repository.ChainRepository +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository +import io.novafoundation.nova.runtime.repository.TimestampRepository +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Named +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class ExtrinsicSerialization + +interface RuntimeApi { + + fun provideExtrinsicBuilderFactory(): ExtrinsicBuilderFactory + + fun externalRequirementFlow(): MutableStateFlow + + fun storageCache(): StorageCache + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource + + @Named(LOCAL_STORAGE_SOURCE) + fun localStorageSource(): StorageDataSource + + fun chainSyncService(): ChainSyncService + + fun chainStateRepository(): ChainStateRepository + + fun chainRegistry(): ChainRegistry + + fun rpcCalls(): RpcCalls + + @ExtrinsicSerialization + fun extrinsicGson(): Gson + + fun runtimeVersionsRepository(): RuntimeVersionsRepository + + fun eventsRepository(): EventsRepository + + val multiChainQrSharingFactory: MultiChainQrSharingFactory + + val sampledBlockTime: SampledBlockTimeStorage + + val parachainInfoRepository: ParachainInfoRepository + + val mortalityConstructor: MortalityConstructor + + val extrinsicValidityUseCase: ExtrinsicValidityUseCase + + val timestampRepository: TimestampRepository + + val totalIssuanceRepository: TotalIssuanceRepository + + val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory + + val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi + + val gasPriceProviderFactory: GasPriceProviderFactory + + val extrinsicWalk: ExtrinsicWalk + + val callTraversal: CallTraversal + + val runtimeFilesCache: RuntimeFilesCache + + val metadataShortenerService: MetadataShortenerService + + val runtimeProviderPool: RuntimeProviderPool + + val nodeHealthStateTesterFactory: NodeHealthStateTesterFactory + + val chainNodeRepository: ChainNodeRepository + + val nodeConnectionFactory: NodeConnectionFactory + + val web3ApiFactory: Web3ApiFactory + + val preConfiguredChainsRepository: PreConfiguredChainsRepository + + val chainRepository: ChainRepository + + val remoteToDomainChainMapperFacade: RemoteToDomainChainMapperFacade + + val blockLimitsRepository: BlockLimitsRepository +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeComponent.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeComponent.kt new file mode 100644 index 0000000..0f330d0 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeComponent.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.runtime.di + +import dagger.Component +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi + +@Component( + modules = [ + RuntimeModule::class, + ChainRegistryModule::class + ], + dependencies = [ + RuntimeDependencies::class + ] +) +@ApplicationScope +abstract class RuntimeComponent : RuntimeApi { + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + ] + ) + interface RuntimeDependenciesComponent : RuntimeDependencies +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt new file mode 100644 index 0000000..f56a081 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeDependencies.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.runtime.di + +import android.content.Context +import com.google.gson.Gson +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.interfaces.FileProvider +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.dao.StorageDao +import io.novasama.substrate_sdk_android.wsrpc.SocketService + +interface RuntimeDependencies { + + fun networkApiCreator(): NetworkApiCreator + + fun socketServiceCreator(): SocketService + + fun gson(): Gson + + fun preferences(): Preferences + + fun fileProvider(): FileProvider + + fun context(): Context + + fun storageDao(): StorageDao + + fun chainDao(): ChainDao + + fun chainAssetDao(): ChainAssetDao + + fun assetsIconModeService(): AssetsIconModeRepository +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeHolder.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeHolder.kt new file mode 100644 index 0000000..2c2069b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeHolder.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.runtime.di + +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import javax.inject.Inject + +@ApplicationScope +class RuntimeHolder @Inject constructor( + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dbDependencies = DaggerRuntimeComponent_RuntimeDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .build() + return DaggerRuntimeComponent.builder() + .runtimeDependencies(dbDependencies) + .build() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeModule.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeModule.kt new file mode 100644 index 0000000..c484816 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/RuntimeModule.kt @@ -0,0 +1,283 @@ +package io.novafoundation.nova.runtime.di + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.dao.StorageDao +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.call.RealMultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory +import io.novafoundation.nova.runtime.ethereum.gas.RealGasPriceProviderFactory +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicSerializers +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase +import io.novafoundation.nova.runtime.extrinsic.MortalityConstructor +import io.novafoundation.nova.runtime.extrinsic.RealExtrinsicValidityUseCase +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.extrinsic.metadata.RealMetadataShortenerService +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.RealCallTraversal +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.RealExtrinsicWalk +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.RemoteToDomainChainMapperFacade +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher +import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets +import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory +import io.novafoundation.nova.runtime.multiNetwork.qr.MultiChainQrSharingFactory +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.DbRuntimeVersionsRepository +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RemoteEventsRepository +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.repository.BlockLimitsRepository +import io.novafoundation.nova.runtime.repository.ChainNodeRepository +import io.novafoundation.nova.runtime.repository.ChainRepository +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novafoundation.nova.runtime.repository.ParachainInfoRepository +import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository +import io.novafoundation.nova.runtime.repository.RealBlockLimitsRepository +import io.novafoundation.nova.runtime.repository.RealChainNodeRepository +import io.novafoundation.nova.runtime.repository.RealChainRepository +import io.novafoundation.nova.runtime.repository.RealParachainInfoRepository +import io.novafoundation.nova.runtime.repository.RealPreConfiguredChainsRepository +import io.novafoundation.nova.runtime.repository.RealTotalIssuanceRepository +import io.novafoundation.nova.runtime.repository.RemoteTimestampRepository +import io.novafoundation.nova.runtime.repository.TimestampRepository +import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository +import io.novafoundation.nova.runtime.storage.DbStorageCache +import io.novafoundation.nova.runtime.storage.PrefsSampledBlockTimeStorage +import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage +import io.novafoundation.nova.runtime.storage.source.LocalStorageSource +import io.novafoundation.nova.runtime.storage.source.RemoteStorageSource +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import javax.inject.Named +import javax.inject.Provider + +const val LOCAL_STORAGE_SOURCE = "LOCAL_STORAGE_SOURCE" +const val REMOTE_STORAGE_SOURCE = "REMOTE_STORAGE_SOURCE" + +const val BULK_RETRIEVER_PAGE_SIZE = 1000 + +@Module +class RuntimeModule { + + @Provides + @ApplicationScope + fun provideExtrinsicBuilderFactory( + chainRegistry: ChainRegistry, + mortalityConstructor: MortalityConstructor, + metadataShortenerService: MetadataShortenerService + ) = ExtrinsicBuilderFactory( + chainRegistry, + mortalityConstructor, + metadataShortenerService + ) + + @Provides + @ApplicationScope + fun provideStorageCache( + storageDao: StorageDao, + ): StorageCache = DbStorageCache(storageDao) + + @Provides + @Named(LOCAL_STORAGE_SOURCE) + @ApplicationScope + fun provideLocalStorageSource( + chainRegistry: ChainRegistry, + storageCache: StorageCache, + sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + ): StorageDataSource = LocalStorageSource(chainRegistry, sharedRequestsBuilderFactory, storageCache) + + @Provides + @ApplicationScope + fun provideBulkRetriever(): BulkRetriever { + return BulkRetriever(BULK_RETRIEVER_PAGE_SIZE) + } + + @Provides + @Named(REMOTE_STORAGE_SOURCE) + @ApplicationScope + fun provideRemoteStorageSource( + chainRegistry: ChainRegistry, + sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + bulkRetriever: BulkRetriever, + ): StorageDataSource = RemoteStorageSource(chainRegistry, sharedRequestsBuilderFactory, bulkRetriever) + + @Provides + @ApplicationScope + fun provideSampledBlockTimeStorage( + gson: Gson, + preferences: Preferences, + ): SampledBlockTimeStorage = PrefsSampledBlockTimeStorage(gson, preferences) + + @Provides + @ApplicationScope + fun provideChainStateRepository( + @Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource, + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + sampledBlockTimeStorage: SampledBlockTimeStorage, + chainRegistry: ChainRegistry + ) = ChainStateRepository(localStorageSource, remoteStorageSource, sampledBlockTimeStorage, chainRegistry) + + @Provides + @ApplicationScope + fun provideMortalityProvider( + chainStateRepository: ChainStateRepository, + rpcCalls: RpcCalls, + ) = MortalityConstructor(rpcCalls, chainStateRepository) + + @Provides + @ApplicationScope + fun provideSubstrateCalls( + chainRegistry: ChainRegistry, + multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi + ) = RpcCalls(chainRegistry, multiChainRuntimeCallsApi) + + @Provides + @ApplicationScope + @ExtrinsicSerialization + fun provideExtrinsicGson() = ExtrinsicSerializers.gson() + + @Provides + @ApplicationScope + fun provideRuntimeVersionsRepository( + chainDao: ChainDao + ): RuntimeVersionsRepository = DbRuntimeVersionsRepository(chainDao) + + @Provides + @ApplicationScope + fun provideEventsRepository( + rpcCalls: RpcCalls, + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource + ): EventsRepository = RemoteEventsRepository(rpcCalls, remoteStorageSource) + + @Provides + @ApplicationScope + fun provideMultiChainQrSharingFactory() = MultiChainQrSharingFactory() + + @Provides + @ApplicationScope + fun provideParachainInfoRepository( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource + ): ParachainInfoRepository = RealParachainInfoRepository(remoteStorageSource) + + @Provides + @ApplicationScope + fun provideExtrinsicValidityUseCase( + mortalityConstructor: MortalityConstructor + ): ExtrinsicValidityUseCase = RealExtrinsicValidityUseCase(mortalityConstructor) + + @Provides + @ApplicationScope + fun provideTimestampRepository( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + ): TimestampRepository = RemoteTimestampRepository(remoteStorageSource) + + @Provides + @ApplicationScope + fun provideTotalIssuanceRepository( + @Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource, + ): TotalIssuanceRepository = RealTotalIssuanceRepository(localStorageSource) + + @Provides + @ApplicationScope + fun provideBlockLimitsRepository( + @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, + chainRegistry: ChainRegistry + ): BlockLimitsRepository = RealBlockLimitsRepository(remoteStorageSource, chainRegistry) + + @Provides + @ApplicationScope + fun provideStorageSharedRequestBuilderFactory(chainRegistry: ChainRegistry) = StorageSharedRequestsBuilderFactory(chainRegistry) + + @Provides + @ApplicationScope + fun provideMultiChainRuntimeCallsApi(chainRegistry: ChainRegistry): MultiChainRuntimeCallsApi = RealMultiChainRuntimeCallsApi(chainRegistry) + + @Provides + @ApplicationScope + fun provideGasPriceProviderFactory( + chainRegistry: ChainRegistry + ): GasPriceProviderFactory = RealGasPriceProviderFactory(chainRegistry) + + @Provides + @ApplicationScope + fun provideExtrinsicWalk( + chainRegistry: ChainRegistry, + ): ExtrinsicWalk = RealExtrinsicWalk(chainRegistry) + + @Provides + @ApplicationScope + fun provideCallTraversal(): CallTraversal = RealCallTraversal() + + @Provides + @ApplicationScope + fun provideMetadataShortenerService( + chainRegistry: ChainRegistry, + rpcCalls: RpcCalls, + ): MetadataShortenerService { + return RealMetadataShortenerService(chainRegistry, rpcCalls) + } + + @Provides + @ApplicationScope + fun provideChainNodeRepository( + chainDao: ChainDao, + ): ChainNodeRepository = RealChainNodeRepository(chainDao) + + @Provides + @ApplicationScope + fun provideNodeConnectionFactory( + socketServiceProvider: Provider, + connectionSecrets: ConnectionSecrets + ): NodeConnectionFactory { + return NodeConnectionFactory( + socketServiceProvider, + connectionSecrets + ) + } + + @Provides + @ApplicationScope + fun provideRemoteToDomainChainMapperFacade( + gson: Gson + ): RemoteToDomainChainMapperFacade { + return RemoteToDomainChainMapperFacade( + gson + ) + } + + @Provides + @ApplicationScope + fun providePreConfiguredChainsRepository( + chainFetcher: ChainFetcher, + chainMapperFacade: RemoteToDomainChainMapperFacade + ): PreConfiguredChainsRepository { + return RealPreConfiguredChainsRepository( + chainFetcher, + chainMapperFacade + ) + } + + @Provides + @ApplicationScope + fun provideChainRepository( + chainRegistry: ChainRegistry, + chainDao: ChainDao, + gson: Gson + ): ChainRepository { + return RealChainRepository( + chainRegistry, + chainDao, + gson + ) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/BalancingHttpWeb3jService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/BalancingHttpWeb3jService.kt new file mode 100644 index 0000000..eb62359 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/BalancingHttpWeb3jService.kt @@ -0,0 +1,307 @@ +package io.novafoundation.nova.runtime.ethereum + +import android.util.Log +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import io.novafoundation.nova.common.utils.requireException +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.NodeWithSaturatedUrl +import io.novafoundation.nova.runtime.multiNetwork.connection.UpdatableNodes +import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSelectionStrategyProvider +import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSequenceGenerator +import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.generateNodeIterator +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull +import io.reactivex.Flowable +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.web3j.protocol.ObjectMapperFactory +import org.web3j.protocol.Web3jService +import org.web3j.protocol.core.BatchRequest +import org.web3j.protocol.core.BatchResponse +import org.web3j.protocol.core.Request +import org.web3j.protocol.core.Response +import org.web3j.protocol.exceptions.ClientConnectionException +import org.web3j.protocol.http.HttpService +import org.web3j.protocol.websocket.events.Notification +import java.io.IOException +import java.util.concurrent.CompletableFuture + +class BalancingHttpWeb3jService( + initialNodes: Chain.Nodes, + private val httpClient: OkHttpClient, + private val strategyProvider: NodeSelectionStrategyProvider, + private val objectMapper: ObjectMapper = ObjectMapperFactory.getObjectMapper(), +) : Web3jService, UpdatableNodes { + + private val nodeSwitcher = NodeSwitcher( + initialStrategy = strategyProvider.createHttp(initialNodes), + ) + + override fun updateNodes(nodes: Chain.Nodes) { + val strategy = strategyProvider.createHttp(nodes) + nodeSwitcher.updateNodes(strategy) + } + + override fun > send(request: Request<*, out Response<*>>, responseType: Class): T { + val payload: String = objectMapper.writeValueAsString(request) + + val result = nodeSwitcher.makeRetryingRequest { url -> + val call = createHttpCall(payload, url) + + call.execute().parseSingleResponse(responseType) + } + + return result.throwOnRpcError() + } + + override fun > sendAsync(request: Request<*, out Response<*>>, responseType: Class): CompletableFuture { + val payload: String = objectMapper.writeValueAsString(request) + + return enqueueRetryingRequest( + payload = payload, + retriableProcessResponse = { response -> response.parseSingleResponse(responseType) }, + nonRetriableProcessResponse = { it.throwOnRpcError() } + ) + } + + override fun sendBatch(batchRequest: BatchRequest): BatchResponse { + if (batchRequest.requests.isEmpty()) { + return BatchResponse(emptyList(), emptyList()) + } + + val payload = objectMapper.writeValueAsString(batchRequest.requests) + + val result = nodeSwitcher.makeRetryingRequest { url -> + val call = createHttpCall(payload, url) + + call.execute().parseBatchResponse(batchRequest) + } + + return result.throwOnRpcError() + } + + override fun sendBatchAsync(batchRequest: BatchRequest): CompletableFuture { + val payload: String = objectMapper.writeValueAsString(batchRequest.requests) + + return enqueueRetryingRequest( + payload = payload, + retriableProcessResponse = { response -> response.parseBatchResponse(batchRequest) }, + nonRetriableProcessResponse = { it.throwOnRpcError() } + ) + } + + override fun ?> subscribe( + request: Request<*, out Response<*>>, + unsubscribeMethod: String, + responseType: Class + ): Flowable { + throw UnsupportedOperationException("Http transport does not support subscriptions") + } + + override fun close() { + // nothing to close + } + + private fun enqueueRetryingRequest( + payload: String, + retriableProcessResponse: (okhttp3.Response) -> T, + nonRetriableProcessResponse: (T) -> Unit + ): CompletableFuture { + val completableFuture = CallCancellableFuture() + + enqueueRetryingRequest(completableFuture, payload, retriableProcessResponse, nonRetriableProcessResponse) + + return completableFuture + } + + private fun enqueueRetryingRequest( + future: CallCancellableFuture, + payload: String, + retriableProcessResponse: (okhttp3.Response) -> T, + nonRetriableProcessResponse: (T) -> Unit + ) { + val url = nodeSwitcher.getCurrentNodeUrl() ?: return + + val call = createHttpCall(payload, url) + future.call = call + + call.enqueue(object : Callback { + + override fun onFailure(call: Call, e: IOException) { + if (future.isCancelled) return + + nodeSwitcher.markCurrentNodeNotAccessible() + enqueueRetryingRequest(future, payload, retriableProcessResponse, nonRetriableProcessResponse) + } + + override fun onResponse(call: Call, response: okhttp3.Response) { + if (future.isCancelled) return + + try { + val parsedResponse = retriableProcessResponse(response) + + try { + nonRetriableProcessResponse(parsedResponse) + + future.complete(parsedResponse) + } catch (e: Throwable) { + future.completeExceptionally(e) + } + } catch (_: Exception) { + nodeSwitcher.markCurrentNodeNotAccessible() + enqueueRetryingRequest(future, payload, retriableProcessResponse, nonRetriableProcessResponse) + } + } + }) + } + + private fun createHttpCall(request: String, url: String): Call { + val mediaType = HttpService.JSON_MEDIA_TYPE + val requestBody: RequestBody = request.toRequestBody(mediaType) + + val httpRequest: okhttp3.Request = okhttp3.Request.Builder() + .url(url) + .post(requestBody) + .build() + + return httpClient.newCall(httpRequest) + } + + private fun > T.throwOnRpcError(): T { + val rpcError = error + if (rpcError != null) { + throw EvmRpcException(rpcError.code, rpcError.message) + } + + return this + } + + private fun BatchResponse.throwOnRpcError(): BatchResponse { + val rpcError = responses.tryFindNonNull { it.error } + if (rpcError != null) { + throw EvmRpcException(rpcError.code, rpcError.message) + } + + return this + } + + private fun > okhttp3.Response.parseSingleResponse(responseType: Class): T { + val parsedResponse = runCatching { + body?.let { + objectMapper.readValue(it.bytes(), responseType) + } + }.getOrNull() + + if (!isSuccessful || parsedResponse == null) { + throw ClientConnectionException("Invalid response received: $code; ${body?.string()}") + } + + return parsedResponse + } + + private fun okhttp3.Response.parseBatchResponse(origin: BatchRequest): BatchResponse { + val bodyContent = body?.string() + + val parsedResponseResult = runCatching { + origin.parseResponse(bodyContent!!) + } + + val parsedResponses = parsedResponseResult.getOrNull() + + if (isSuccessful && parsedResponseResult.isFailure) { + throw parsedResponseResult.requireException() + } + + if (!isSuccessful) { + throw ClientConnectionException("Invalid response received: $code; $bodyContent") + } + + return BatchResponse(origin.requests, parsedResponses) + } + + private fun BatchRequest.parseResponse(response: String): List> { + val requestsById = requests.associateBy { it.id } + val nodes = objectMapper.readTree(response) as ArrayNode + + return nodes.map { node -> + val id = (node as ObjectNode).get("id").asLong() + val request = requestsById.getValue(id) + + objectMapper.treeToValue(node, request.responseType) + } + } +} + +private class NodeSwitcher( + initialStrategy: NodeSequenceGenerator, +) { + + @Volatile + private var balanceStrategy: NodeSequenceGenerator = initialStrategy + + @Volatile + private var nodeIterator: Iterator? = null + + @Volatile + private var currentNodeUrl: String? = null + + init { + updateNodes(initialStrategy) + } + + @Synchronized + fun updateNodes(strategy: NodeSequenceGenerator) { + balanceStrategy = strategy + + nodeIterator = balanceStrategy.generateNodeIterator() + selectNextNode() + } + + @Synchronized + fun getCurrentNodeUrl(): String? { + return currentNodeUrl + } + + @Suppress + fun markCurrentNodeNotAccessible() { + selectNextNode() + } + + private fun selectNextNode() { + val iterator = nodeIterator ?: return + if (iterator.hasNext()) { + currentNodeUrl = iterator.next().saturatedUrl + } + } +} + +private fun NodeSwitcher.makeRetryingRequest(request: (url: String) -> T): T { + val url = getCurrentNodeUrl() ?: error("No url present to make a request") + + while (true) { + try { + return request(url) + } catch (e: Throwable) { + Log.w("Failed to execute request for url $url", e) + + markCurrentNodeNotAccessible() + + continue + } + } +} + +private class CallCancellableFuture : CompletableFuture() { + + var call: Call? = null + + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + call?.cancel() + + return super.cancel(mayInterruptIfRunning) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/EvmRpcException.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/EvmRpcException.kt new file mode 100644 index 0000000..21b8a1e --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/EvmRpcException.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.runtime.ethereum + +class EvmRpcException(val type: Type, message: String) : Throwable("${type.name}: $message") { + + enum class Type(val code: Int?) { + EXECUTION_REVERTED(-32603), + INVALID_INPUT(-32000), + UNKNOWN(null); + + companion object { + fun fromCode(code: Int): Type { + return values().firstOrNull { it.code == code } ?: UNKNOWN + } + } + } +} + +fun EvmRpcException(code: Int, message: String): EvmRpcException { + return EvmRpcException(EvmRpcException.Type.fromCode(code), message) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/SocketServiceAsyncAdapter.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/SocketServiceAsyncAdapter.kt new file mode 100644 index 0000000..5f39174 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/SocketServiceAsyncAdapter.kt @@ -0,0 +1,88 @@ +package io.novafoundation.nova.runtime.ethereum + +import io.reactivex.Observable +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.request.DeliveryType +import io.novasama.substrate_sdk_android.wsrpc.request.base.RpcRequest +import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse +import io.novasama.substrate_sdk_android.wsrpc.subscription.response.SubscriptionChange +import kotlinx.coroutines.future.await +import org.web3j.protocol.core.Request +import org.web3j.protocol.core.Response +import java.util.concurrent.CompletableFuture + +fun SocketService.executeRequestAsFuture( + request: RpcRequest, + deliveryType: DeliveryType = DeliveryType.AT_LEAST_ONCE, +): CompletableFuture { + val future = RequestCancellableFuture() + + val callback = object : SocketService.ResponseListener { + override fun onError(throwable: Throwable) { + future.completeExceptionally(throwable) + } + + override fun onNext(response: RpcResponse) { + future.complete(response) + } + } + + future.cancellable = executeRequest(request, deliveryType, callback) + + return future +} + +fun SocketService.executeBatchRequestAsFuture( + requests: List, + deliveryType: DeliveryType = DeliveryType.AT_LEAST_ONCE, +): CompletableFuture> { + val future = RequestCancellableFuture>() + + val callback = object : SocketService.ResponseListener> { + override fun onError(throwable: Throwable) { + future.completeExceptionally(throwable) + } + + override fun onNext(response: List) { + future.complete(response) + } + } + + future.cancellable = executeAccumulatingBatchRequest(requests, deliveryType, callback) + + return future +} + +fun SocketService.subscribeAsObservable( + request: RpcRequest, + unsubscribeMethod: String +): Observable { + return Observable.create { emitter -> + val callback = object : SocketService.ResponseListener { + override fun onError(throwable: Throwable) { + emitter.tryOnError(throwable) + } + + override fun onNext(response: SubscriptionChange) { + emitter.onNext(response) + } + } + + val cancellable = subscribe(request, callback, unsubscribeMethod) + + emitter.setCancellable(cancellable::cancel) + } +} + +private class RequestCancellableFuture : CompletableFuture() { + + var cancellable: SocketService.Cancellable? = null + + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + cancellable?.cancel() + + return super.cancel(mayInterruptIfRunning) + } +} + +suspend fun > Request.sendSuspend(): T = sendAsync().await() diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/StorageSharedRequestsBuilder.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/StorageSharedRequestsBuilder.kt new file mode 100644 index 0000000..cdd9c9f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/StorageSharedRequestsBuilder.kt @@ -0,0 +1,96 @@ +package io.novafoundation.nova.runtime.ethereum + +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.invokeOnCompletion +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.core.ethereum.log.Topic +import io.novafoundation.nova.core.model.StorageChange +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.runtime.ethereum.subscribtion.EthereumRequestsAggregator +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApi +import io.novafoundation.nova.runtime.multiNetwork.getSocketOrNull +import io.novafoundation.nova.runtime.multiNetwork.getSubscriptionEthereumApi +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.StorageSubscriptionMultiplexer +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.subscribeUsing +import io.novasama.substrate_sdk_android.wsrpc.subscribe +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import org.web3j.protocol.core.Request +import org.web3j.protocol.core.Response +import org.web3j.protocol.websocket.events.LogNotification +import java.util.concurrent.CompletableFuture +import kotlin.coroutines.CoroutineContext + +class StorageSharedRequestsBuilderFactory( + private val chainRegistry: ChainRegistry, +) { + + suspend fun create(chainId: ChainId): StorageSharedRequestsBuilder { + val substrateProxy = StorageSubscriptionMultiplexer.Builder() + val ethereumProxy = EthereumRequestsAggregator.Builder() + + val rpcSocket = chainRegistry.getSocketOrNull(chainId) + + val subscriptionApi = chainRegistry.getSubscriptionEthereumApi(chainId) + val callApi = chainRegistry.getCallEthereumApi(chainId) + + return StorageSharedRequestsBuilder( + socketService = rpcSocket, + substrateProxy = substrateProxy, + ethereumProxy = ethereumProxy, + subscriptionApi = subscriptionApi, + callApi = callApi + ) + } +} + +class StorageSharedRequestsBuilder( + override val socketService: SocketService?, + private val substrateProxy: StorageSubscriptionMultiplexer.Builder, + private val ethereumProxy: EthereumRequestsAggregator.Builder, + override val subscriptionApi: Web3Api?, + override val callApi: Web3Api?, +) : SharedRequestsBuilder { + + override fun subscribe(key: String): Flow { + return substrateProxy.subscribe(key) + .map { StorageChange(it.block, it.key, it.value) } + } + + override fun > ethBatchRequestAsync(batchId: String, request: Request): CompletableFuture { + return ethereumProxy.batchRequest(batchId, request) + } + + override fun subscribeEthLogs(address: String, topics: List): Flow { + return ethereumProxy.subscribeLogs(address, topics) + } + + fun subscribe(coroutineScope: CoroutineScope) { + val ethereumRequestsAggregator = ethereumProxy.build() + + subscriptionApi?.let { web3Api -> + ethereumRequestsAggregator.subscribeUsing(web3Api) + .inBackground() + .launchIn(coroutineScope) + } + + callApi?.let { web3Api -> + ethereumRequestsAggregator.executeBatches(coroutineScope, web3Api) + } + + val cancellable = socketService?.subscribeUsing(substrateProxy.build()) + + if (cancellable != null) { + coroutineScope.invokeOnCompletion { cancellable.cancel() } + } + } +} + +fun StorageSharedRequestsBuilder.subscribe(coroutineContext: CoroutineContext) { + subscribe(CoroutineScope(coroutineContext)) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/Web3Api.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/Web3Api.kt new file mode 100644 index 0000000..9d646e3 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/Web3Api.kt @@ -0,0 +1,101 @@ +package io.novafoundation.nova.runtime.ethereum + +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.core.ethereum.log.Topic +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.UpdatableNodes +import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSelectionStrategyProvider +import io.novasama.substrate_sdk_android.extensions.requireHexPrefix +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.reactive.asFlow +import okhttp3.OkHttpClient +import org.web3j.protocol.Web3j +import org.web3j.protocol.Web3jService +import org.web3j.protocol.core.JsonRpc2_0Web3j +import org.web3j.protocol.core.Request +import org.web3j.protocol.core.methods.response.EthSubscribe +import org.web3j.protocol.websocket.events.LogNotification +import org.web3j.protocol.websocket.events.NewHeadsNotification +import org.web3j.utils.Async +import java.util.concurrent.ScheduledExecutorService + +class Web3ApiFactory( + private val requestExecutorService: ScheduledExecutorService = Async.defaultExecutorService(), + private val httpClient: OkHttpClient, + private val strategyProvider: NodeSelectionStrategyProvider, +) { + + fun createWss(socketService: SocketService): Web3Api { + val web3jService = WebSocketWeb3jService(socketService) + + return RealWeb3Api( + web3jService = web3jService, + delegate = Web3j.build(web3jService, JsonRpc2_0Web3j.DEFAULT_BLOCK_TIME.toLong(), requestExecutorService) + ) + } + + fun createHttps(chainNode: Chain.Node): Pair { + val nodes = Chain.Nodes( + autoBalanceStrategy = Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN, + wssNodeSelectionStrategy = Chain.Nodes.NodeSelectionStrategy.AutoBalance, + nodes = listOf(chainNode) + ) + + return createHttps(nodes) + } + + fun createHttps(chainNodes: Chain.Nodes): Pair { + val service = BalancingHttpWeb3jService( + initialNodes = chainNodes, + httpClient = httpClient, + strategyProvider = strategyProvider, + ) + + val api = RealWeb3Api( + web3jService = service, + delegate = Web3j.build(service, JsonRpc2_0Web3j.DEFAULT_BLOCK_TIME.toLong(), requestExecutorService) + ) + + return api to service + } +} + +internal class RealWeb3Api( + private val web3jService: Web3jService, + private val delegate: Web3j +) : Web3Api, Web3j by delegate { + override fun newHeadsFlow(): Flow = newHeadsNotifications().asFlow() + + override fun logsNotifications(addresses: List, topics: List): Flow { + val logParams = createLogParams(addresses, topics) + val requestParams = listOf("logs", logParams) + + val request = Request("eth_subscribe", requestParams, web3jService, EthSubscribe::class.java) + + return web3jService.subscribe(request, "eth_unsubscribe", LogNotification::class.java) + .asFlow() + } + + private fun createLogParams(addresses: List, topics: List): Map { + return buildMap { + if (addresses.isNotEmpty()) { + put("address", addresses) + } + + if (topics.isNotEmpty()) { + put("topics", topics.unifyTopics()) + } + } + } + + private fun List.unifyTopics(): List { + return map { topic -> + when (topic) { + Topic.Any -> null + is Topic.AnyOf -> topic.values.map { it.requireHexPrefix() } + is Topic.Single -> topic.value.requireHexPrefix() + } + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/WebSocketWeb3jService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/WebSocketWeb3jService.kt new file mode 100644 index 0000000..758855c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/WebSocketWeb3jService.kt @@ -0,0 +1,106 @@ +package io.novafoundation.nova.runtime.ethereum + +import com.fasterxml.jackson.databind.ObjectMapper +import io.reactivex.BackpressureStrategy +import io.reactivex.Flowable +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.request.base.RpcRequest +import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse +import org.web3j.protocol.ObjectMapperFactory +import org.web3j.protocol.Web3jService +import org.web3j.protocol.core.BatchRequest +import org.web3j.protocol.core.BatchResponse +import org.web3j.protocol.core.Request +import org.web3j.protocol.core.Response +import org.web3j.protocol.websocket.WebSocketService +import org.web3j.protocol.websocket.events.Notification +import java.io.IOException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException + +class WebSocketWeb3jService( + private val socketService: SocketService, + private val jsonMapper: ObjectMapper = ObjectMapperFactory.getObjectMapper() +) : Web3jService { + + /** + * Implementation based on [WebSocketService.send] + */ + override fun ?> send(request: Request<*, out Response<*>>, responseType: Class): T { + return try { + sendAsync(request, responseType).get() + } catch (e: InterruptedException) { + Thread.interrupted() + throw IOException("Interrupted WebSocket request", e) + } catch (e: ExecutionException) { + if (e.cause is IOException) { + throw e.cause as IOException + } + throw RuntimeException("Unexpected exception", e.cause) + } + } + + override fun ?> sendAsync(request: Request<*, out Response<*>>, responseType: Class): CompletableFuture { + val rpcRequest = request.toRpcRequest() + + return socketService.executeRequestAsFuture(rpcRequest).thenApply { + if (it.error != null) { + throw EvmRpcException(it.error!!.code, it.error!!.message) + } + + jsonMapper.convertValue(it, responseType) + } + } + + override fun ?> subscribe( + request: Request<*, out Response<*>>, + unsubscribeMethod: String, + responseType: Class + ): Flowable { + val rpcRequest = request.toRpcRequest() + + return socketService.subscribeAsObservable(rpcRequest, unsubscribeMethod).map { + jsonMapper.convertValue(it, responseType) + }.toFlowable(BackpressureStrategy.LATEST) + } + + override fun sendBatch(batchRequest: BatchRequest): BatchResponse { + return try { + sendBatchAsync(batchRequest).get() + } catch (e: InterruptedException) { + Thread.interrupted() + throw IOException("Interrupted WebSocket batch request", e) + } catch (e: ExecutionException) { + if (e.cause is IOException) { + throw e.cause as IOException + } + throw RuntimeException("Unexpected exception", e.cause) + } + } + + override fun sendBatchAsync(batchRequest: BatchRequest): CompletableFuture { + val rpcRequests = batchRequest.requests.map { it.toRpcRequest() } + + return socketService.executeBatchRequestAsFuture(rpcRequests).thenApply { responses -> + val responsesById = responses.associateBy(RpcResponse::id) + + val parsedResponses = batchRequest.requests.mapNotNull { request -> + responsesById[request.id.toInt()]?.let { rpcResponse -> + jsonMapper.convertValue(rpcResponse, request.responseType) + } + } + + BatchResponse(batchRequest.requests, parsedResponses) + } + } + + override fun close() { + // other components handle lifecycle of socketService + } + + private fun Request<*, *>.toRpcRequest(): RpcRequest { + val raw = jsonMapper.writeValueAsString(this) + + return RpcRequest.Raw(raw, id.toInt()) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/CallableContract.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/CallableContract.kt new file mode 100644 index 0000000..35669d5 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/CallableContract.kt @@ -0,0 +1,69 @@ +package io.novafoundation.nova.runtime.ethereum.contract.base + +import io.novafoundation.nova.runtime.ethereum.contract.base.caller.ContractCaller +import io.novafoundation.nova.runtime.ethereum.contract.base.caller.ethCallSuspend +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.future.asDeferred +import org.web3j.abi.FunctionEncoder +import org.web3j.abi.FunctionReturnDecoder +import org.web3j.abi.datatypes.Function +import org.web3j.abi.datatypes.Type +import org.web3j.protocol.core.DefaultBlockParameter +import org.web3j.protocol.core.methods.request.Transaction +import org.web3j.protocol.core.methods.response.EthCall +import org.web3j.tx.TransactionManager +import org.web3j.tx.exceptions.ContractCallException + +open class CallableContract( + protected val contractAddress: String, + protected val contractCaller: ContractCaller, + protected val defaultBlockParameter: DefaultBlockParameter, +) { + + @Suppress("UNCHECKED_CAST") + protected fun ?, R> executeCallSingleValueReturnAsync( + function: Function, + extractResult: (T) -> R, + ): Deferred { + val tx = createTx(function) + + return contractCaller.ethCall(tx, defaultBlockParameter).thenApply { ethCall -> + processEthCallResponse(ethCall, function, extractResult) + }.asDeferred() + } + + @Suppress("UNCHECKED_CAST") + protected suspend fun ?, R> executeCallSingleValueReturnSuspend( + function: Function, + extractResult: (T) -> R, + ): R { + val tx = createTx(function) + val ethCall = contractCaller.ethCallSuspend(tx, defaultBlockParameter) + + return processEthCallResponse(ethCall, function, extractResult) + } + + private fun ?> processEthCallResponse( + ethCall: EthCall, + function: Function, + extractResult: (T) -> R + ): R { + assertCallNotReverted(ethCall) + + val args = FunctionReturnDecoder.decode(ethCall.value, function.outputParameters) + val type = args.first() as T + + return extractResult(type) + } + + private fun createTx(function: Function): Transaction { + val encodedFunction = FunctionEncoder.encode(function) + return Transaction.createEthCallTransaction(null, contractAddress, encodedFunction) + } + + private fun assertCallNotReverted(ethCall: EthCall) { + if (ethCall.isReverted) { + throw ContractCallException(String.format(TransactionManager.REVERT_ERR_STR, ethCall.revertReason)) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/ContractStandard.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/ContractStandard.kt new file mode 100644 index 0000000..d27d6c9 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/ContractStandard.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.runtime.ethereum.contract.base + +import io.novafoundation.nova.core.updater.EthereumSharedRequestsBuilder +import io.novafoundation.nova.runtime.ethereum.contract.base.caller.BatchContractCaller +import io.novafoundation.nova.runtime.ethereum.contract.base.caller.ContractCaller +import io.novafoundation.nova.runtime.ethereum.contract.base.caller.SingleContractCaller +import io.novafoundation.nova.runtime.ethereum.subscribtion.BatchId +import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder +import org.web3j.protocol.Web3j +import org.web3j.protocol.core.DefaultBlockParameter +import org.web3j.protocol.core.DefaultBlockParameterName + +interface ContractStandard { + + fun query( + address: String, + caller: ContractCaller, + defaultBlockParameter: DefaultBlockParameter = DefaultBlockParameterName.LATEST + ): Q + + fun transact( + contractAddress: String, + transactionBuilder: EvmTransactionBuilder + ): T +} + +fun ContractStandard.queryBatched( + address: String, + batchId: BatchId, + ethereumSharedRequestsBuilder: EthereumSharedRequestsBuilder, +): C = query(address, BatchContractCaller(batchId, ethereumSharedRequestsBuilder)) + +fun ContractStandard.querySingle( + address: String, + web3j: Web3j, +): C = query(address, SingleContractCaller(web3j)) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/caller/BatchContractCaller.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/caller/BatchContractCaller.kt new file mode 100644 index 0000000..73192b1 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/caller/BatchContractCaller.kt @@ -0,0 +1,21 @@ +package io.novafoundation.nova.runtime.ethereum.contract.base.caller + +import io.novafoundation.nova.core.updater.EthereumSharedRequestsBuilder +import io.novafoundation.nova.core.updater.callApiOrThrow +import io.novafoundation.nova.runtime.ethereum.subscribtion.BatchId +import org.web3j.protocol.core.DefaultBlockParameter +import org.web3j.protocol.core.methods.request.Transaction +import org.web3j.protocol.core.methods.response.EthCall +import java.util.concurrent.CompletableFuture + +class BatchContractCaller( + private val batchId: BatchId, + private val ethereumSharedRequestsBuilder: EthereumSharedRequestsBuilder, +) : ContractCaller { + + override fun ethCall(transaction: Transaction, defaultBlockParameter: DefaultBlockParameter): CompletableFuture { + val request = ethereumSharedRequestsBuilder.callApiOrThrow.ethCall(transaction, defaultBlockParameter) + + return ethereumSharedRequestsBuilder.ethBatchRequestAsync(batchId, request) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/caller/ContractCaller.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/caller/ContractCaller.kt new file mode 100644 index 0000000..0dd6b86 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/caller/ContractCaller.kt @@ -0,0 +1,20 @@ +package io.novafoundation.nova.runtime.ethereum.contract.base.caller + +import kotlinx.coroutines.future.asDeferred +import org.web3j.protocol.core.DefaultBlockParameter +import org.web3j.protocol.core.methods.request.Transaction +import org.web3j.protocol.core.methods.response.EthCall +import java.util.concurrent.CompletableFuture + +interface ContractCaller { + + fun ethCall( + transaction: Transaction, + defaultBlockParameter: DefaultBlockParameter, + ): CompletableFuture +} + +suspend fun ContractCaller.ethCallSuspend( + transaction: Transaction, + defaultBlockParameter: DefaultBlockParameter, +): EthCall = ethCall(transaction, defaultBlockParameter).asDeferred().await() diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/caller/SingleContractCaller.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/caller/SingleContractCaller.kt new file mode 100644 index 0000000..1fd54e2 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/base/caller/SingleContractCaller.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.runtime.ethereum.contract.base.caller + +import org.web3j.protocol.Web3j +import org.web3j.protocol.core.DefaultBlockParameter +import org.web3j.protocol.core.methods.request.Transaction +import org.web3j.protocol.core.methods.response.EthCall +import java.util.concurrent.CompletableFuture + +class SingleContractCaller( + private val web3j: Web3j +) : ContractCaller { + + override fun ethCall(transaction: Transaction, defaultBlockParameter: DefaultBlockParameter): CompletableFuture { + val request = web3j.ethCall(transaction, defaultBlockParameter) + + return request.sendAsync() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/erc20/Erc20Contract.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/erc20/Erc20Contract.kt new file mode 100644 index 0000000..9da0434 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/erc20/Erc20Contract.kt @@ -0,0 +1,94 @@ +package io.novafoundation.nova.runtime.ethereum.contract.erc20 + +import io.novafoundation.nova.common.utils.ethereumAccountIdToAddress +import io.novafoundation.nova.runtime.ethereum.contract.base.CallableContract +import io.novafoundation.nova.runtime.ethereum.contract.base.ContractStandard +import io.novafoundation.nova.runtime.ethereum.contract.base.caller.ContractCaller +import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.Deferred +import org.web3j.abi.TypeReference +import org.web3j.abi.datatypes.Address +import org.web3j.abi.datatypes.Function +import org.web3j.abi.datatypes.Utf8String +import org.web3j.abi.datatypes.generated.Uint256 +import org.web3j.abi.datatypes.generated.Uint8 +import org.web3j.protocol.core.DefaultBlockParameter +import java.math.BigInteger + +class Erc20Standard : ContractStandard { + + override fun query(address: String, caller: ContractCaller, defaultBlockParameter: DefaultBlockParameter): Erc20Queries { + return Erc20QueriesImpl(address, caller, defaultBlockParameter) + } + + override fun transact(contractAddress: String, transactionBuilder: EvmTransactionBuilder): Erc20Transactions { + return Erc20TransactionsImpl(contractAddress, transactionBuilder) + } +} + +private class Erc20TransactionsImpl( + private val contractAddress: String, + private val evmTransactionsBuilder: EvmTransactionBuilder, +) : Erc20Transactions { + + override fun transfer(recipient: AccountId, amount: BigInteger) { + evmTransactionsBuilder.contractCall(contractAddress) { + function = "transfer" + + inputParameter(Address(recipient.ethereumAccountIdToAddress(withChecksum = true))) + inputParameter(Uint256(amount)) + } + } +} + +private class Erc20QueriesImpl( + contractAddress: String, + caller: ContractCaller, + blockParameter: DefaultBlockParameter, +) : CallableContract(contractAddress, caller, blockParameter), Erc20Queries { + + override suspend fun balanceOfAsync(account: String): Deferred { + val function = Function( + /* name = */ + "balanceOf", + /* inputParameters = */ + listOf( + Address(account) + ), + /* outputParameters = */ + listOf( + object : TypeReference() {} + ), + ) + + return executeCallSingleValueReturnAsync(function, Uint256::getValue) + } + + override suspend fun symbol(): String { + val outputParams = listOf( + object : TypeReference() {} + ) + val function = Function("symbol", emptyList(), outputParams) + + return executeCallSingleValueReturnSuspend(function, Utf8String::getValue) + } + + override suspend fun decimals(): BigInteger { + val outputParams = listOf( + object : TypeReference() {} + ) + val function = Function("decimals", emptyList(), outputParams) + + return executeCallSingleValueReturnSuspend(function, Uint8::getValue) + } + + override suspend fun totalSupply(): BigInteger { + val outputParams = listOf( + object : TypeReference() {} + ) + val function = Function("totalSupply", emptyList(), outputParams) + + return executeCallSingleValueReturnSuspend(function, Uint256::getValue) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/erc20/Erc20Queries.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/erc20/Erc20Queries.kt new file mode 100644 index 0000000..cc97428 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/erc20/Erc20Queries.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.runtime.ethereum.contract.erc20 + +import kotlinx.coroutines.Deferred +import org.web3j.abi.EventEncoder +import org.web3j.abi.TypeDecoder +import org.web3j.abi.TypeReference +import org.web3j.abi.datatypes.Address +import org.web3j.abi.datatypes.Event +import org.web3j.abi.datatypes.generated.Uint256 +import org.web3j.protocol.websocket.events.Log +import java.math.BigInteger + +interface Erc20Queries { + + class Transfer(val from: Address, val to: Address, val amount: Uint256) + + companion object { + val TRANSFER_EVENT = Event( + "Transfer", + listOf( + object : TypeReference
(true) {}, + object : TypeReference
(true) {}, + object : TypeReference(false) {} + ) + ) + + fun transferEventSignature(): String { + return EventEncoder.encode(TRANSFER_EVENT) + } + + fun parseTransferEvent(log: Log): Transfer { + return parseTransferEvent( + topic1 = log.topics[1], + topic2 = log.topics[2], + data = log.data + ) + } + + fun parseTransferEvent( + topic1: String, + topic2: String, + data: String + ): Transfer { + return Transfer( + from = TypeDecoder.decodeAddress(topic1), + to = TypeDecoder.decodeAddress(topic2), + amount = TypeDecoder.decodeNumeric(data, Uint256::class.java) + ) + } + } + + suspend fun balanceOfAsync(account: String): Deferred + + suspend fun symbol(): String + + suspend fun decimals(): BigInteger + + suspend fun totalSupply(): BigInteger +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/erc20/Erc20Transactions.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/erc20/Erc20Transactions.kt new file mode 100644 index 0000000..3996a86 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/contract/erc20/Erc20Transactions.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.runtime.ethereum.contract.erc20 + +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +interface Erc20Transactions { + + fun transfer(recipient: AccountId, amount: BigInteger) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/CompoundGasPriceProvider.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/CompoundGasPriceProvider.kt new file mode 100644 index 0000000..0ea2881 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/CompoundGasPriceProvider.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.runtime.ethereum.gas + +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.common.utils.tryFindNonNull +import java.math.BigInteger + +class CompoundGasPriceProvider(vararg val delegates: GasPriceProvider) : GasPriceProvider { + + override suspend fun getGasPrice(): BigInteger { + return delegates.tryFindNonNull { delegate -> + runCatching { delegate.getGasPrice() }.getOrNull() + }.orZero() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/GasPriceProvider.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/GasPriceProvider.kt new file mode 100644 index 0000000..1b31c61 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/GasPriceProvider.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.runtime.ethereum.gas + +import java.math.BigInteger + +interface GasPriceProvider { + + suspend fun getGasPrice(): BigInteger +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/GasPriceProviderFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/GasPriceProviderFactory.kt new file mode 100644 index 0000000..ab87f3c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/GasPriceProviderFactory.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.runtime.ethereum.gas + +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import org.web3j.protocol.Web3j + +interface GasPriceProviderFactory { + + /** + * Creates gas provider for a [chainId] that is known to the app + */ + suspend fun createKnown(chainId: ChainId): GasPriceProvider + + /** + * Creates gas provider for arbitrary EVM chain given instance of [Web3j] + */ + suspend fun create(web3j: Web3j): GasPriceProvider +} + +class RealGasPriceProviderFactory( + private val chainRegistry: ChainRegistry +) : GasPriceProviderFactory { + + override suspend fun createKnown(chainId: ChainId): GasPriceProvider { + val api = chainRegistry.getCallEthereumApiOrThrow(chainId) + + return create(api) + } + + override suspend fun create(web3j: Web3j): GasPriceProvider { + return CompoundGasPriceProvider( + MaxPriorityFeeGasProvider(web3j), + LegacyGasPriceProvider(web3j) + ) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/LegacyGasPriceProvider.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/LegacyGasPriceProvider.kt new file mode 100644 index 0000000..1f3423b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/LegacyGasPriceProvider.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.runtime.ethereum.gas + +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import org.web3j.protocol.Web3j +import java.math.BigInteger + +class LegacyGasPriceProvider(private val api: Web3j) : GasPriceProvider { + + override suspend fun getGasPrice(): BigInteger { + return api.ethGasPrice().sendSuspend().gasPrice + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/MaxPriorityFeeGasProvider.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/MaxPriorityFeeGasProvider.kt new file mode 100644 index 0000000..ff02344 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/gas/MaxPriorityFeeGasProvider.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.runtime.ethereum.gas + +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import org.web3j.protocol.Web3j +import org.web3j.protocol.core.DefaultBlockParameterName +import java.math.BigInteger + +class MaxPriorityFeeGasProvider(private val api: Web3j) : GasPriceProvider { + + override suspend fun getGasPrice(): BigInteger { + val baseFeePerGas = api.getLatestBaseFeePerGas() + val maxPriorityFee = api.ethMaxPriorityFeePerGas().sendSuspend().maxPriorityFeePerGas + + return baseFeePerGas + maxPriorityFee + } + + private suspend fun Web3j.getLatestBaseFeePerGas(): BigInteger { + val block = ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false).sendSuspend() + + return block.block.baseFeePerGas + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/log/Topic.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/log/Topic.kt new file mode 100644 index 0000000..4536260 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/log/Topic.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.runtime.ethereum.log + +sealed class Topic { + + object Any : Topic() + + class Single(val value: String) : Topic() + + class AnyOf(val values: List) : Topic() { + + constructor(vararg values: String) : this(values.toList()) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/subscribtion/RealEthereumSubscriptionBuilder.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/subscribtion/RealEthereumSubscriptionBuilder.kt new file mode 100644 index 0000000..3e25bc4 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/subscribtion/RealEthereumSubscriptionBuilder.kt @@ -0,0 +1,244 @@ +package io.novafoundation.nova.runtime.ethereum.subscribtion + +import io.novafoundation.nova.common.data.network.runtime.binding.cast +import io.novafoundation.nova.common.utils.mergeIfMultiple +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.core.ethereum.log.Topic +import io.novasama.substrate_sdk_android.extensions.asEthereumAddress +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.wsrpc.SocketService.ResponseListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.future.asDeferred +import org.web3j.protocol.core.BatchRequest +import org.web3j.protocol.core.BatchResponse +import org.web3j.protocol.core.Request +import org.web3j.protocol.core.Response +import org.web3j.protocol.websocket.events.LogNotification +import java.util.UUID +import java.util.concurrent.CompletableFuture + +typealias SubscriptionId = String +typealias BatchId = String +typealias RequestId = Int + +sealed class EthereumSubscription(val id: SubscriptionId) { + + class Log(val addresses: List, val topics: List, id: SubscriptionId) : EthereumSubscription(id) +} + +class EthereumRequestsAggregator private constructor( + private val subscriptions: List>, + private val collectors: Map>>, + private val batches: List +) { + + fun subscribeUsing(web3Api: Web3Api): Flow<*> { + return subscriptions.map { + when (it) { + is EthereumSubscription.Log -> web3Api.subscribeLogs(it) + } + }.mergeIfMultiple() + } + + fun executeBatches(scope: CoroutineScope, web3Api: Web3Api) { + batches.forEach { pendingBatchRequest -> + scope.async { + val batch = web3Api.newBatch().apply { + pendingBatchRequest.requests.forEach { + add(it) + } + } + + executeBatch(batch, pendingBatchRequest) + } + } + } + + private fun Web3Api.subscribeLogs(subscription: EthereumSubscription.Log): Flow<*> { + return logsNotifications(subscription.addresses, subscription.topics).onEach { logNotification -> + subscription.dispatchChange(logNotification) + }.catch { + subscription.dispatchError(it) + } + } + + private suspend fun executeBatch( + batch: BatchRequest, + pendingBatchRequest: PendingBatchRequest + ): Result { + return runCatching { batch.sendAsync().asDeferred().await() } + .onSuccess { batchResponse -> + batchResponse.responses.onEach { response -> + val callback = pendingBatchRequest.callbacks[response.id.toInt()] ?: return@onEach + + callback.cast>().onNext(response) + } + } + .onFailure { error -> + pendingBatchRequest.callbacks.values.forEach { + it.onError(error) + } + } + } + + private inline fun EthereumSubscription.dispatchChange(change: S) { + val collectors = collectors[id].orEmpty() + + collectors.forEach { + it.cast>().onNext(change) + } + } + + private fun EthereumSubscription<*>.dispatchError(error: Throwable) { + val collectors = collectors[id].orEmpty() + + collectors.forEach { it.onError(error) } + } + + class Builder { + + // We do not initialize them by default to not to allocate arrays and maps when not needed + private var subscriptions: MutableList>? = null + private var collectors: MutableMap>>? = null + + private var batches: MutableMap? = null + + fun subscribeLogs(address: String, topics: List): Flow { + val subscriptions = ensureSubscriptions() + val existingSubscription = subscriptions.firstOrNull { it is EthereumSubscriptionBuilder.Log && it.topics == topics } + + val subscription = if (existingSubscription != null) { + require(existingSubscription is EthereumSubscriptionBuilder.Log) + existingSubscription.addresses.add(address) + existingSubscription + } else { + val newSubscription = EthereumSubscriptionBuilder.Log(topics = topics, addresses = mutableListOf(address)) + subscriptions.add(newSubscription) + newSubscription + } + + val collector = LogsCallback(address) + val subscriptionCollectors = ensureCollectors().getOrPut(subscription.id, ::mutableListOf) + subscriptionCollectors.add(collector) + + return collector.inner.map { it.getOrThrow() } + } + + fun > batchRequest(batchId: BatchId, request: Request): CompletableFuture { + val batches = ensureBatches() + val batch = batches.getOrPut(batchId, ::PendingBatchRequestBuilder) + + val callback = BatchCallback() + + batch.requests += request + batch.callbacks[request.id.toInt()] = callback + + return callback.future + } + + fun build(): EthereumRequestsAggregator { + return EthereumRequestsAggregator( + subscriptions = subscriptions.orEmpty().map { it.build() }, + collectors = collectors.orEmpty(), + batches = batches.orEmpty().values.map { it.build() } + ) + } + + private fun ensureSubscriptions(): MutableList> { + if (subscriptions == null) { + subscriptions = mutableListOf() + } + + return subscriptions!! + } + + private fun ensureCollectors(): MutableMap>> { + if (collectors == null) { + collectors = mutableMapOf() + } + + return collectors!! + } + + private fun ensureBatches(): MutableMap { + if (batches == null) { + batches = mutableMapOf() + } + + return batches!! + } + } +} + +private sealed class EthereumSubscriptionBuilder { + + val id: SubscriptionId = UUID.randomUUID().toString() + + abstract fun build(): EthereumSubscription + + class Log(val topics: List, val addresses: MutableList = mutableListOf()) : EthereumSubscriptionBuilder() { + + override fun build(): EthereumSubscription { + return EthereumSubscription.Log(addresses, topics, id) + } + } +} + +private class LogsCallback(contractAddress: String) : SubscribeCallback() { + + val contractAccountId = contractAddress.asEthereumAddress().toAccountId().value + + override fun shouldHandle(change: LogNotification): Boolean { + val changeAddress = change.params.result.address + val changeAccountId = changeAddress.asEthereumAddress().toAccountId().value + + return contractAccountId.contentEquals(changeAccountId) + } +} + +private class PendingBatchRequest(val requests: List>, val callbacks: Map>) + +private class PendingBatchRequestBuilder( + val requests: MutableList> = mutableListOf(), + val callbacks: MutableMap> = mutableMapOf() +) { + + fun build(): PendingBatchRequest = PendingBatchRequest(requests, callbacks) +} + +private class BatchCallback : ResponseListener { + + val future = CompletableFuture() + + override fun onError(throwable: Throwable) { + future.completeExceptionally(throwable) + } + + override fun onNext(response: R) { + future.complete(response) + } +} + +private abstract class SubscribeCallback : ResponseListener { + + val inner = MutableSharedFlow>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + abstract fun shouldHandle(change: R): Boolean + + override fun onError(throwable: Throwable) { + inner.tryEmit(Result.failure(throwable)) + } + + override fun onNext(response: R) { + if (shouldHandle(response)) { + inner.tryEmit(Result.success(response)) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/transaction/builder/EvmTransactionBuilder.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/transaction/builder/EvmTransactionBuilder.kt new file mode 100644 index 0000000..b34c479 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/transaction/builder/EvmTransactionBuilder.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.runtime.ethereum.transaction.builder + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.runtime.ethereum.contract.base.ContractStandard +import io.novasama.substrate_sdk_android.runtime.AccountId +import org.web3j.abi.TypeReference +import org.web3j.abi.datatypes.Type as EvmType + +interface EvmTransactionBuilder { + + fun nativeTransfer(amount: BalanceOf, recipient: AccountId) + + fun contractCall(contractAddress: String, builder: EvmContractCallBuilder.() -> Unit) + + interface EvmContractCallBuilder { + + var function: String + + fun inputParameter(value: EvmType) + + fun > outputParameter(typeReference: TypeReference) + } +} + +fun EvmTransactionBuilder() = RealEvmTransactionBuilder() + +inline fun > EvmTransactionBuilder.EvmContractCallBuilder.outputParameter() { + val typeReference = object : TypeReference() {} + + outputParameter(typeReference) +} + +fun EvmTransactionBuilder.contractCall( + contractAddress: String, + contractStandard: ContractStandard<*, T>, + contractStandardAction: T.() -> Unit +) { + val contractTransactions = contractStandard.transact(contractAddress, this) + + contractTransactions.contractStandardAction() +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/transaction/builder/RealEvmTransactionBuilder.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/transaction/builder/RealEvmTransactionBuilder.kt new file mode 100644 index 0000000..e97e80f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ethereum/transaction/builder/RealEvmTransactionBuilder.kt @@ -0,0 +1,124 @@ +package io.novafoundation.nova.runtime.ethereum.transaction.builder + +import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf +import io.novafoundation.nova.common.utils.ethereumAccountIdToAddress +import io.novasama.substrate_sdk_android.runtime.AccountId +import org.web3j.abi.FunctionEncoder +import org.web3j.abi.TypeReference +import org.web3j.abi.datatypes.Type +import org.web3j.crypto.RawTransaction +import org.web3j.protocol.core.methods.request.Transaction +import java.math.BigInteger +import org.web3j.abi.datatypes.Function as EvmFunction + +class RealEvmTransactionBuilder : EvmTransactionBuilder { + + private var transactionData: EvmTransactionData? = null + + override fun nativeTransfer(amount: BalanceOf, recipient: AccountId) { + transactionData = EvmTransactionData.NativeTransfer(amount, recipient.ethereumAccountIdToAddress()) + } + + override fun contractCall(contractAddress: String, builder: EvmTransactionBuilder.EvmContractCallBuilder.() -> Unit) { + transactionData = EvmContractCallBuilder(contractAddress) + .apply(builder) + .build() + } + + fun buildForFee(originAddress: String): Transaction { + return when (val txData = requireNotNull(transactionData)) { + is EvmTransactionData.ContractCall -> { + val data = FunctionEncoder.encode(txData.function) + + Transaction.createFunctionCallTransaction( + originAddress, + null, + null, + null, + txData.contractAddress, + null, + data + ) + } + + is EvmTransactionData.NativeTransfer -> { + Transaction.createEtherTransaction( + originAddress, + null, + null, + null, + txData.recipientAddress, + txData.amount + ) + } + } + } + + fun buildForSign( + nonce: BigInteger, + gasPrice: BigInteger, + gasLimit: BigInteger + ): RawTransaction { + return when (val txData = requireNotNull(transactionData)) { + is EvmTransactionData.ContractCall -> { + val data = FunctionEncoder.encode(txData.function) + + RawTransaction.createTransaction( + nonce, + gasPrice, + gasLimit, + txData.contractAddress, + null, + data + ) + } + + is EvmTransactionData.NativeTransfer -> { + RawTransaction.createEtherTransaction( + nonce, + gasPrice, + gasLimit, + txData.recipientAddress, + txData.amount + ) + } + } + } +} + +private class EvmContractCallBuilder( + private val contractAddress: String +) : EvmTransactionBuilder.EvmContractCallBuilder { + + private var _function: String? = null + private var input: MutableList> = mutableListOf() + private var outputParameters: MutableList> = mutableListOf() + + override var function: String + get() = requireNotNull(_function) + set(value) { + _function = value + } + + override fun inputParameter(value: Type) { + input += value + } + + override fun > outputParameter(typeReference: TypeReference) { + outputParameters += typeReference + } + + fun build(): EvmTransactionData.ContractCall { + return EvmTransactionData.ContractCall( + contractAddress = contractAddress, + function = EvmFunction(function, input, outputParameters) + ) + } +} + +private sealed class EvmTransactionData { + + class NativeTransfer(val amount: BalanceOf, val recipientAddress: String) : EvmTransactionData() + + class ContractCall(val contractAddress: String, val function: EvmFunction) : EvmTransactionData() +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/explorer/BlockExplorerLinkFormatter.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/explorer/BlockExplorerLinkFormatter.kt new file mode 100644 index 0000000..b141b89 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/explorer/BlockExplorerLinkFormatter.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.runtime.explorer + +import io.novafoundation.nova.common.utils.Urls + +class BlockExplorerLinks( + val account: String?, + val event: String?, + val extrinsic: String? +) + +interface BlockExplorerLinkFormatter { + + fun format(link: String): BlockExplorerLinks? +} + +class CommonBlockExplorerLinkFormatter( + private val formatters: List +) : BlockExplorerLinkFormatter { + + override fun format(link: String): BlockExplorerLinks? { + return formatters.firstNotNullOfOrNull { it.format(link) } + } +} + +class SubscanBlockExplorerLinkFormatter() : BlockExplorerLinkFormatter { + + override fun format(link: String): BlockExplorerLinks? { + return try { + require(link.contains("subscan.io")) + + val normalizedUrl = Urls.normalizeUrl(link) + return BlockExplorerLinks( + account = "$normalizedUrl/account/{address}", + event = null, + extrinsic = "$normalizedUrl/account/{hash}", + ) + } catch (e: Exception) { + null + } + } +} + +class StatescanBlockExplorerLinkFormatter() : BlockExplorerLinkFormatter { + + override fun format(link: String): BlockExplorerLinks? { + return try { + require(link.contains("statescan.io")) + + val normalizedUrl = Urls.normalizeUrl(link) + return BlockExplorerLinks( + account = "$normalizedUrl/#/accounts/{address}", + event = "$normalizedUrl/#/events/{event}", + extrinsic = "$normalizedUrl/#/extrinsics/{hash}", + ) + } catch (e: Exception) { + null + } + } +} + +class EtherscanBlockExplorerLinkFormatter() : BlockExplorerLinkFormatter { + + override fun format(link: String): BlockExplorerLinks? { + return try { + require(link.contains("etherscan.io")) + + val normalizedUrl = Urls.normalizeUrl(link) + return BlockExplorerLinks( + account = "$normalizedUrl/address/{address}", + event = null, + extrinsic = "$normalizedUrl/tx/{hash}", + ) + } catch (e: Exception) { + null + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt new file mode 100644 index 0000000..51f5e8c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt @@ -0,0 +1,660 @@ +package io.novafoundation.nova.runtime.ext + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.MultiAddress +import io.novafoundation.nova.common.data.network.runtime.binding.bindOrNull +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.common.utils.Urls +import io.novafoundation.nova.common.utils.asTokenSymbol +import io.novafoundation.nova.common.utils.emptyEthereumAccountId +import io.novafoundation.nova.common.utils.emptySubstrateAccountId +import io.novafoundation.nova.common.utils.findIsInstanceOrNull +import io.novafoundation.nova.common.utils.formatNamed +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.common.utils.substrateAccountId +import io.novafoundation.nova.core_db.model.AssetAndChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.ALEPH_ZERO +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.MYTHOS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.NOMINATION_POOLS +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.PARACHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN_AURA +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.TURING +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.UNSUPPORTED +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ExplorerTemplateExtractor +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.StatemineAssetId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.TypesUsage +import io.novafoundation.nova.runtime.multiNetwork.chain.model.hasSameId +import io.novasama.substrate_sdk_android.encrypt.SignatureVerifier +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.encrypt.Signer +import io.novasama.substrate_sdk_android.extensions.asEthereumAccountId +import io.novasama.substrate_sdk_android.extensions.asEthereumAddress +import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.isValid +import io.novasama.substrate_sdk_android.extensions.requireHexPrefix +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.extensions.toAddress +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.toHexUntyped +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.addressPrefix +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAddress +import java.math.BigInteger + +const val EVM_DEFAULT_TOKEN_DECIMALS = 18 + +private const val EIP_155_PREFIX = "eip155" + +val Chain.autoBalanceEnabled: Boolean + get() = nodes.wssNodeSelectionStrategy is Chain.Nodes.NodeSelectionStrategy.AutoBalance + +val Chain.selectedUnformattedWssNodeUrlOrNull: String? + get() = if (nodes.wssNodeSelectionStrategy is Chain.Nodes.NodeSelectionStrategy.SelectedNode) { + nodes.wssNodeSelectionStrategy.unformattedNodeUrl + } else { + null + } + +val Chain.isCustomNetwork: Boolean + get() = source == Chain.Source.CUSTOM + +val Chain.typesUsage: TypesUsage + get() = when { + types == null -> TypesUsage.NONE + !types.overridesCommon && types.url != null -> TypesUsage.BOTH + !types.overridesCommon && types.url == null -> TypesUsage.BASE + else -> TypesUsage.OWN + } + +val TypesUsage.requiresBaseTypes: Boolean + get() = this == TypesUsage.BASE || this == TypesUsage.BOTH + +val Chain.utilityAsset + get() = assets.first(Chain.Asset::isUtilityAsset) + +val Chain.isSubstrateBased + get() = !isEthereumBased + +val Chain.commissionAsset + get() = utilityAsset + +val Chain.isEnabled + get() = connectionState != Chain.ConnectionState.DISABLED + +val Chain.isDisabled + get() = !isEnabled + +fun Chain.getAssetOrThrow(assetId: ChainAssetId): Chain.Asset { + return assetsById.getValue(assetId) +} + +fun Chain.Asset.supportedStakingOptions(): List { + if (staking.isEmpty()) return emptyList() + + return staking.filter { it != UNSUPPORTED } +} + +fun Chain.networkType(): NetworkType { + return if (hasSubstrateRuntime) { + NetworkType.SUBSTRATE + } else { + NetworkType.EVM + } +} + +fun Chain.evmChainIdOrNull(): BigInteger? { + return if (id.startsWith(EIP_155_PREFIX)) { + id.removePrefix("$EIP_155_PREFIX:") + .toBigIntegerOrNull() + } else { + null + } +} + +fun Chain.isSwapSupported(): Boolean = swap.isNotEmpty() + +fun List.assetConversionSupported(): Boolean { + return Chain.Swap.ASSET_CONVERSION in this +} + +fun List.hydraDxSupported(): Boolean { + return Chain.Swap.HYDRA_DX in this +} + +val Chain.ConnectionState.isFullSync: Boolean + get() = this == Chain.ConnectionState.FULL_SYNC + +val Chain.ConnectionState.isDisabled: Boolean + get() = this == Chain.ConnectionState.DISABLED + +val Chain.ConnectionState.level: Int + get() = when (this) { + Chain.ConnectionState.FULL_SYNC -> 2 + Chain.ConnectionState.LIGHT_SYNC -> 1 + Chain.ConnectionState.DISABLED -> 0 + } + +fun Chain.Additional?.relaychainAsNative(): Boolean { + return this?.relaychainAsNative ?: false +} + +fun Chain.Additional?.feeViaRuntimeCall(): Boolean { + return this?.feeViaRuntimeCall ?: false +} + +fun Chain.Additional?.isGenericLedgerAppSupported(): Boolean { + return this?.supportLedgerGenericApp ?: false +} + +fun Chain.Additional?.shouldDisableMetadataHashCheck(): Boolean { + return this?.disabledCheckMetadataHash ?: false +} + +fun ChainId.chainIdHexPrefix16(): String { + return removeHexPrefix() + .take(32) + .requireHexPrefix() +} + +enum class StakingTypeGroup { + + RELAYCHAIN, PARACHAIN, NOMINATION_POOL, MYTHOS, UNSUPPORTED +} + +fun Chain.Asset.StakingType.group(): StakingTypeGroup { + return when (this) { + UNSUPPORTED -> StakingTypeGroup.UNSUPPORTED + RELAYCHAIN, RELAYCHAIN_AURA, ALEPH_ZERO -> StakingTypeGroup.RELAYCHAIN + PARACHAIN, TURING -> StakingTypeGroup.PARACHAIN + MYTHOS -> StakingTypeGroup.MYTHOS + NOMINATION_POOLS -> StakingTypeGroup.NOMINATION_POOL + } +} + +fun Chain.Asset.StakingType.isDirectStaking(): Boolean { + return when (group()) { + StakingTypeGroup.RELAYCHAIN, StakingTypeGroup.PARACHAIN -> true + else -> false + } +} + +fun Chain.Asset.StakingType.isPoolStaking(): Boolean { + return group() == StakingTypeGroup.NOMINATION_POOL +} + +inline fun Chain.allExternalApis(): List { + return externalApis.filterIsInstance() +} + +inline fun Chain.externalApi(): T? { + return externalApis.findIsInstanceOrNull() +} + +inline fun Chain.hasExternalApi(): Boolean { + return externalApis.any { it is T } +} + +const val UTILITY_ASSET_ID = 0 + +val Chain.Asset.isUtilityAsset: Boolean + get() = id == UTILITY_ASSET_ID + +inline val Chain.Asset.isCommissionAsset: Boolean + get() = isUtilityAsset + +inline val FullChainAssetId.isUtility: Boolean + get() = assetId == UTILITY_ASSET_ID + +private const val XC_PREFIX = "xc" + +fun Chain.Asset.normalizeSymbol(): String { + return normalizeTokenSymbol(this.symbol.value) +} + +fun TokenSymbol.normalize(): TokenSymbol { + return normalizeTokenSymbol(value).asTokenSymbol() +} + +fun normalizeTokenSymbol(symbol: String): String { + return symbol.removePrefix(XC_PREFIX) +} + +val Chain.Node.isWss: Boolean + get() = connectionType == Chain.Node.ConnectionType.WSS + +val Chain.Node.isHttps: Boolean + get() = connectionType == Chain.Node.ConnectionType.HTTPS + +fun Chain.Nodes.wssNodes(): List { + return nodes.filter { it.isWss } +} + +fun Chain.Nodes.httpNodes(): List { + return nodes.filter { it.isHttps } +} + +fun Chain.Nodes.hasHttpNodes(): Boolean { + return nodes.any { it.isHttps } +} + +val Chain.Asset.disabled: Boolean + get() = !enabled + +val Chain.genesisHash: String? + get() = id.takeIf { + runCatching { it.fromHex() }.isSuccess + } + +fun Chain.hasOnlyOneAddressFormat() = legacyAddressPrefix == null + +fun Chain.supportsLegacyAddressFormat() = legacyAddressPrefix != null + +fun Chain.requireGenesisHash() = requireNotNull(genesisHash) + +fun Chain.addressOf(accountId: ByteArray): String { + return if (isEthereumBased) { + accountId.toEthereumAddress() + } else { + accountId.toAddress(addressPrefix.toShort()) + } +} + +fun Chain.addressOf(accountId: AccountIdKey): String { + return addressOf(accountId.value) +} + +fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? { + return if (isEthereumBased) { + null + } else { + legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) } + } +} + +fun ByteArray.toEthereumAddress(): String { + return asEthereumAccountId().toAddress(withChecksum = true).value +} + +fun Chain.accountIdOf(address: String): ByteArray { + return if (isEthereumBased) { + address.asEthereumAddress().toAccountId().value + } else { + address.toAccountId() + } +} + +fun String.toAccountId(chain: Chain): ByteArray { + return chain.accountIdOf(this) +} + +fun String.toAccountIdKey(chain: Chain): AccountIdKey { + return chain.accountIdKeyOf(this) +} + +fun Chain.accountIdKeyOf(address: String): AccountIdKey { + return accountIdOf(address).intoKey() +} + +fun String.anyAddressToAccountId(): ByteArray { + return runCatching { + // Substrate + toAccountId() + }.recoverCatching { + // Evm + asEthereumAddress().toAccountId().value + }.getOrThrow() +} + +fun Chain.accountIdOrNull(address: String): ByteArray? { + return runCatching { accountIdOf(address) }.getOrNull() +} + +fun Chain.emptyAccountId() = if (isEthereumBased) { + emptyEthereumAccountId() +} else { + emptySubstrateAccountId() +} + +fun Chain.emptyAccountIdKey() = emptyAccountId().intoKey() + +fun Chain.accountIdOrDefault(maybeAddress: String): ByteArray { + return accountIdOrNull(maybeAddress) ?: emptyAccountId() +} + +fun Chain.accountIdOf(publicKey: ByteArray): ByteArray { + return if (isEthereumBased) { + publicKey.asEthereumPublicKey().toAccountId().value + } else { + publicKey.substrateAccountId() + } +} + +fun Chain.hexAccountIdOf(address: String): String { + return accountIdOf(address).toHexString() +} + +fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress { + return if (isEthereumBased) { + MultiAddress.Address20(accountId) + } else { + MultiAddress.Id(accountId) + } +} + +fun Chain.isValidAddress(address: String): Boolean { + return runCatching { + if (isEthereumBased) { + address.asEthereumAddress().isValid() + } else { + address.toAccountId() // verify supplied address can be converted to account id + + addressPrefix.toShort() == address.addressPrefix() || + legacyAddressPrefix?.toShort() == address.addressPrefix() + } + }.getOrDefault(false) +} + +fun Chain.isValidEvmAddress(address: String): Boolean { + return runCatching { + if (isEthereumBased) { + address.asEthereumAddress().isValid() + } else { + false + } + }.getOrDefault(false) +} + +val Chain.isParachain + get() = parentId != null + +fun Chain.multiAddressOf(address: String): MultiAddress = multiAddressOf(accountIdOf(address)) + +fun Chain.availableExplorersFor(field: ExplorerTemplateExtractor) = explorers.filter { field(it) != null } + +fun Chain.Explorer.accountUrlOf(address: String): String { + return format(Chain.Explorer::account, "address", address) +} + +fun Chain.Explorer.extrinsicUrlOf(extrinsicHash: String): String { + return format(Chain.Explorer::extrinsic, "hash", extrinsicHash) +} + +fun Chain.Explorer.eventUrlOf(eventId: String): String { + return format(Chain.Explorer::event, "event", eventId) +} + +private inline fun Chain.Explorer.format( + templateExtractor: ExplorerTemplateExtractor, + argumentName: String, + argumentValue: String, +): String { + val template = templateExtractor(this) ?: throw Exception("Cannot find template in the chain explorer: $name") + + return template.formatNamed(argumentName to argumentValue) +} + +object ChainGeneses { + + // Pezkuwi chains (priority) + const val PEZKUWI = "bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75" + const val PEZKUWI_ASSET_HUB = "00d0e1d0581c3cd5c5768652d52f4520184018b44f56a2ae1e0dc9d65c00c948" + const val PEZKUWI_PEOPLE = "58269e9c184f721e0309332d90cafc410df1519a5dc27a5fd9b3bf5fd2d129f8" + + const val KUSAMA = "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe" + const val POLKADOT = "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3" + + // Westend constant now points to Zagros Testnet + const val WESTEND = "96eb58af1bb7288115b5e4ff1590422533e749293f231974536dc6672417d06f" + + const val KUSAMA_ASSET_HUB = "48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a" + + const val ACALA = "fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c" + + const val ROCOCO_ACALA = "a84b46a3e602245284bb9a72c4abd58ee979aa7a5d7f8c4dfdddfaaf0665a4ae" + + const val STATEMINT = "68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f" + const val EDGEWARE = "742a2ca70c2fda6cee4f8df98d64c4c670a052d9568058982dad9d5a7a135c5b" + + const val KARURA = "baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b" + + const val NODLE_PARACHAIN = "97da7ede98d7bad4e36b4d734b6055425a3be036da2a332ea5a7037656427a21" + + const val MOONBEAM = "fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d" + const val MOONRIVER = "401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b" + + const val POLYMESH = "6fbd74e5e1d0a61d52ccfe9d4adaed16dd3a7caa37c6bc4d0c2fa12e8b2f4063" + + const val XX_NETWORK = "50dd5d206917bf10502c68fb4d18a59fc8aa31586f4e8856b493e43544aa82aa" + + const val KILT = "411f057b9107718c9624d6aa4a3f23c1653898297f3d4d529d9bb6511a39dd21" + + const val ASTAR = "9eb76c5184c4ab8679d2d5d819fdf90b9c001403e9e17da2e14b6d8aec4029c6" + + const val ALEPH_ZERO = "70255b4d28de0fc4e1a193d7e175ad1ccef431598211c55538f1018651a0344e" + const val TERNOA = "6859c81ca95ef624c9dfe4dc6e3381c33e5d6509e35e147092bfbc780f777c4e" + + const val POLIMEC = "7eb9354488318e7549c722669dcbdcdc526f1fef1420e7944667212f3601fdbd" + + const val POLKADEX = "3920bcb4960a1eef5580cd5367ff3f430eef052774f78468852f7b9cb39f8a3c" + + const val CALAMARI = "4ac80c99289841dd946ef92765bf659a307d39189b3ce374a92b5f0415ee17a1" + + const val TURING = "0f62b701fb12d02237a33b84818c11f621653d2b1614c777973babf4652b535d" + + const val ZEITGEIST = "1bf2a2ecb4a868de66ea8610f2ce7c8c43706561b6476031315f6640fe38e060" + + const val WESTMINT = "67f9723393ef76214df0118c34bbbd3dbebc8ed46a10973a8c969d48fe7598c9" + + const val HYDRA_DX = "afdc188f45c71dacbaa0b62e16a91f726c7b8699a9748cdf715459de6b7f366d" + + const val AVAIL_TURING_TESTNET = "d3d2f3a3495dc597434a99d7d449ebad6616db45e4e4f178f31cc6fa14378b70" + const val AVAIL = "b91746b45e0346cc2f815a520b9c6cb4d5c0902af848db0a80f85932d2e8276a" + + const val VARA = "fe1b4c55fd4d668101126434206571a7838a8b6b93a6d1b95d607e78e6c53763" + + const val POLKADOT_ASSET_HUB = "68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f" + + const val UNIQUE_NETWORK = "84322d9cddbf35088f1e54e9a85c967a41a56a4f43445768125e61af166c7d31" + + const val POLKADOT_PEOPLE = "67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008" + const val KUSAMA_PEOPLE = "c1af4cb4eb3918e5db15086c0cc5ec17fb334f728b7c65dd44bfe1e174ff8b3f" +} + +object ChainIds { + + const val ETHEREUM = "$EIP_155_PREFIX:1" + + const val MOONBEAM = ChainGeneses.MOONBEAM + const val MOONRIVER = ChainGeneses.MOONRIVER +} + +val Chain.Companion.Geneses + get() = ChainGeneses + +val Chain.Companion.Ids + get() = ChainIds + +fun Chain.Asset.requireStatemine(): Type.Statemine { + require(type is Type.Statemine) + + return type +} + +fun Chain.findStatemineAssets(): List { + return assets.filter { it.type is Type.Statemine } +} + +fun Chain.Asset.statemineOrNull(): Type.Statemine? { + return type as? Type.Statemine +} + +fun Type.Statemine.palletNameOrDefault(): String { + return palletName ?: Modules.ASSETS +} + +fun Chain.Asset.requireOrml(): Type.Orml { + require(type is Type.Orml) + + return type +} + +val Chain.addressScheme: AddressScheme + get() = if (isEthereumBased) AddressScheme.EVM else AddressScheme.SUBSTRATE + +fun Chain.Asset.ormlOrNull(): Type.Orml? { + return type as? Type.Orml +} + +fun Chain.Asset.requireErc20(): Type.EvmErc20 { + require(type is Type.EvmErc20) + + return type +} + +fun Chain.Asset.requireEquilibrium(): Type.Equilibrium { + require(type is Type.Equilibrium) + + return type +} + +fun Chain.Asset.ormlCurrencyId(runtime: RuntimeSnapshot): Any? { + return requireOrml().currencyId(runtime) +} + +fun Type.Orml.currencyId(runtime: RuntimeSnapshot): Any? { + val currencyIdType = runtime.typeRegistry[currencyIdType] + ?: error("Cannot find type $currencyIdType") + + return currencyIdType.fromHex(runtime, currencyIdScale) +} + +val Chain.Asset.fullId: FullChainAssetId + get() = FullChainAssetId(chainId, id) + +fun Chain.enabledAssets(): List = assets.filter { it.enabled } + +fun Chain.disabledAssets(): List = assets.filterNot { it.enabled } + +fun evmChainIdFrom(chainId: Int) = "$EIP_155_PREFIX:$chainId" + +fun evmChainIdFrom(chainId: BigInteger) = "$EIP_155_PREFIX:$chainId" + +fun Chain.findAssetByOrmlCurrencyId(runtime: RuntimeSnapshot, currencyId: Any?): Chain.Asset? { + return assets.find { asset -> + if (asset.type !is Type.Orml) return@find false + val currencyType = runtime.typeRegistry[asset.type.currencyIdType] ?: return@find false + + val currencyIdScale = bindOrNull { currencyType.toHexUntyped(runtime, currencyId) } ?: return@find false + + currencyIdScale == asset.type.currencyIdScale + } +} + +fun Chain.findAssetByStatemineAssetId(runtime: RuntimeSnapshot, assetId: Any?): Chain.Asset? { + return assets.find { asset -> + if (asset.type !is Type.Statemine) return@find false + + asset.type.hasSameId(runtime, assetId) + } +} + +fun Type.Orml.decodeOrNull(runtime: RuntimeSnapshot): Any? { + val currencyType = runtime.typeRegistry[currencyIdType] ?: return null + return currencyType.fromHexOrNull(runtime, currencyIdScale) +} + +val Chain.Asset.localId: AssetAndChainId + get() = AssetAndChainId(chainId, id) + +val Chain.Asset.onChainAssetId: String? + get() = when (this.type) { + is Type.Equilibrium -> this.type.toString() + is Type.Orml -> this.type.currencyIdScale + is Type.Statemine -> this.type.id.onChainAssetId() + is Type.EvmErc20 -> this.type.contractAddress + is Type.Native -> null + is Type.EvmNative -> null + Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}") + } + +fun StatemineAssetId.onChainAssetId(): String { + return when (this) { + is StatemineAssetId.Number -> value.toString() + is StatemineAssetId.ScaleEncoded -> scaleHex + } +} + +fun Chain.openGovIfSupported(): Chain.Governance? { + return Chain.Governance.V2.takeIf { it in governance } +} + +fun Chain.Explorer.normalizedUrl(): String? { + val url = listOfNotNull(extrinsic, account, event).firstOrNull() + return url?.let { Urls.normalizeUrl(it) } +} + +fun Chain.supportTinderGov(): Boolean { + return hasReferendaSummaryApi() +} + +fun Chain.hasReferendaSummaryApi(): Boolean { + return externalApi() != null +} + +fun Chain.summaryApiOrNull(): Chain.ExternalApi.ReferendumSummary? { + return externalApi() +} + +fun Chain.timelineChainId(): ChainId? { + return additional?.timelineChain +} + +fun Chain.timelineChainIdOrSelf(): ChainId { + return timelineChainId() ?: id +} + +fun Chain.hasTimelineChain(): Boolean { + return additional?.timelineChain != null +} + +fun FullChainAssetId.Companion.utilityAssetOf(chainId: ChainId) = FullChainAssetId(chainId, UTILITY_ASSET_ID) + +fun SignatureVerifier.verifyMultiChain( + chain: Chain, + signature: SignatureWrapper, + message: ByteArray, + publicKey: ByteArray +): Boolean { + return if (chain.isEthereumBased) { + verify(signature, Signer.MessageHashing.ETHEREUM, message, publicKey) + } else { + verify(signature, Signer.MessageHashing.SUBSTRATE, message, publicKey) + } +} + +/** + * Check if this chain is part of the Pezkuwi ecosystem. + * Pezkuwi chains use "bizinikiwi" signing context instead of "substrate". + */ +val Chain.isPezkuwiChain: Boolean + get() = id in PEZKUWI_CHAIN_IDS + +private val PEZKUWI_CHAIN_IDS = setOf( + ChainGeneses.PEZKUWI, + ChainGeneses.PEZKUWI_ASSET_HUB, + ChainGeneses.PEZKUWI_PEOPLE +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt new file mode 100644 index 0000000..1b8fcd4 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.runtime.ext + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +val Chain.mainChainsFirstAscendingOrder + get() = when (genesisHash) { + // Pezkuwi ecosystem first + Chain.Geneses.PEZKUWI -> 0 + Chain.Geneses.PEZKUWI_ASSET_HUB -> 1 + Chain.Geneses.PEZKUWI_PEOPLE -> 2 + // Then Polkadot ecosystem + Chain.Geneses.POLKADOT -> 3 + Chain.Geneses.POLKADOT_ASSET_HUB -> 4 + // Then Kusama ecosystem + Chain.Geneses.KUSAMA -> 5 + Chain.Geneses.KUSAMA_ASSET_HUB -> 6 + // Everything else + else -> 7 + } + +val Chain.testnetsLastAscendingOrder + get() = if (isTestNet) { + 1 + } else { + 0 + } + +val Chain.alphabeticalOrder + get() = name + +fun Chain.Companion.defaultComparatorFrom(extractor: (K) -> Chain): Comparator = Comparator.comparing(extractor, defaultComparator()) + +fun Chain.Companion.defaultComparator(): Comparator = compareBy { it.mainChainsFirstAscendingOrder } + .thenBy { it.testnetsLastAscendingOrder } + .thenBy { it.alphabeticalOrder } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt new file mode 100644 index 0000000..4138cac --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.runtime.ext + +import io.novafoundation.nova.common.utils.TokenSymbol + +val TokenSymbol.mainTokensFirstAscendingOrder + get() = when (this.value) { + "HEZ" -> 0 + "PEZ" -> 1 + "USDT" -> 2 + "DOT" -> 3 + "KSM" -> 4 + "USDC" -> 5 + else -> 6 + } + +val TokenSymbol.alphabeticalOrder + get() = value + +fun TokenSymbol.Companion.defaultComparatorFrom(extractor: (K) -> TokenSymbol): Comparator = Comparator.comparing(extractor, defaultComparator()) + +fun TokenSymbol.Companion.defaultComparator(): Comparator = compareBy { it.mainTokensFirstAscendingOrder } + .thenBy { it.alphabeticalOrder } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/CustomTransactionExtensions.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/CustomTransactionExtensions.kt new file mode 100644 index 0000000..317b2fd --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/CustomTransactionExtensions.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.runtime.extrinsic + +import android.util.Log +import io.novafoundation.nova.runtime.extrinsic.extensions.AuthorizeCall +import io.novafoundation.nova.runtime.extrinsic.extensions.ChargeAssetTxPayment +import io.novafoundation.nova.runtime.extrinsic.extensions.CheckAppId +import io.novafoundation.nova.runtime.extrinsic.extensions.CheckNonZeroSender +import io.novafoundation.nova.runtime.extrinsic.extensions.CheckWeight +import io.novafoundation.nova.runtime.extrinsic.extensions.WeightReclaim +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.TransactionExtension + +private const val TAG = "CustomTxExtensions" + +object CustomTransactionExtensions { + + fun applyDefaultValues(builder: ExtrinsicBuilder) { + defaultValues().forEach(builder::setTransactionExtension) + } + + fun applyDefaultValues(builder: ExtrinsicBuilder, runtime: RuntimeSnapshot) { + defaultValues(runtime).forEach(builder::setTransactionExtension) + } + + fun defaultValues(): List { + return listOf( + ChargeAssetTxPayment(), + CheckAppId() + ) + } + + fun defaultValues(runtime: RuntimeSnapshot): List { + val extensions = mutableListOf() + val signedExtIds = runtime.metadata.extrinsic.signedExtensions.map { it.id } + + Log.d(TAG, "Metadata signed extensions: $signedExtIds") + + // Add extensions based on what the metadata requires + if ("AuthorizeCall" in signedExtIds) { + extensions.add(AuthorizeCall()) + } + if ("CheckNonZeroSender" in signedExtIds) { + extensions.add(CheckNonZeroSender()) + } + if ("CheckWeight" in signedExtIds) { + extensions.add(CheckWeight()) + } + if ("WeightReclaim" in signedExtIds || "StorageWeightReclaim" in signedExtIds) { + extensions.add(WeightReclaim()) + } + if ("ChargeAssetTxPayment" in signedExtIds) { + // Default to native fee payment (null assetId) + extensions.add(ChargeAssetTxPayment()) + } + if ("CheckAppId" in signedExtIds) { + extensions.add(CheckAppId()) + } + + Log.d(TAG, "Extensions to add: ${extensions.map { it.name }}") + return extensions + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicBuilderExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicBuilderExt.kt new file mode 100644 index 0000000..7860c3a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicBuilderExt.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.runtime.extrinsic + +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.call + +fun ExtrinsicBuilder.systemRemark(remark: ByteArray): ExtrinsicBuilder { + return call( + moduleName = "System", + callName = "remark", + arguments = mapOf( + "remark" to remark + ) + ) +} + +fun ExtrinsicBuilder.systemRemarkWithEvent(remark: ByteArray): ExtrinsicBuilder { + return call( + moduleName = "System", + callName = "remark_with_event", + arguments = mapOf( + "remark" to remark + ) + ) +} + +fun ExtrinsicBuilder.systemRemarkWithEvent(remark: String): ExtrinsicBuilder { + return systemRemarkWithEvent(remark.encodeToByteArray()) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicBuilderFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicBuilderFactory.kt new file mode 100644 index 0000000..8c628ee --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicBuilderFactory.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.runtime.extrinsic + +import android.util.Log +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.runtime.ext.requireGenesisHash +import io.novafoundation.nova.runtime.extrinsic.extensions.PezkuwiCheckMortality +import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode +import io.novasama.substrate_sdk_android.runtime.extrinsic.ExtrinsicVersion +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.ChargeTransactionPayment +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckGenesis +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckMortality +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckSpecVersion +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckTxVersion +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHash + +private const val TAG = "ExtrinsicBuilderFactory" + +class ExtrinsicBuilderFactory( + private val chainRegistry: ChainRegistry, + private val mortalityConstructor: MortalityConstructor, + private val metadataShortenerService: MetadataShortenerService, +) { + + class Options( + val batchMode: BatchMode, + ) + + suspend fun create( + chain: Chain, + options: Options, + ): ExtrinsicBuilder { + return createMulti(chain, options).first() + } + + suspend fun createMulti( + chain: Chain, + options: Options, + ): Sequence { + val runtime = chainRegistry.getRuntime(chain.id) + + // Log metadata extensions + val metadataExtensions = runtime.metadata.extrinsic.signedExtensions.map { it.id } + Log.d(TAG, "Chain: ${chain.name}, Metadata extensions: $metadataExtensions") + + val mortality = mortalityConstructor.constructMortality(chain.id) + val metadataProof = metadataShortenerService.generateMetadataProof(chain.id) + + // Log custom extensions + val customExtensions = CustomTransactionExtensions.defaultValues(runtime).map { it.name } + Log.d(TAG, "Custom extensions to add: $customExtensions") + + val isPezkuwi = isPezkuwiChain(runtime) + Log.d(TAG, "isPezkuwiChain: $isPezkuwi") + + return generateSequence { + ExtrinsicBuilder( + runtime = runtime, + extrinsicVersion = ExtrinsicVersion.V4, + batchMode = options.batchMode, + ).apply { + // Use custom CheckMortality for Pezkuwi chains to avoid type lookup issues + if (isPezkuwi) { + Log.d(TAG, "Using PezkuwiCheckMortality for ${chain.name}") + setTransactionExtension(PezkuwiCheckMortality(mortality.era, mortality.blockHash.fromHex())) + } else { + setTransactionExtension(CheckMortality(mortality.era, mortality.blockHash.fromHex())) + } + setTransactionExtension(CheckGenesis(chain.requireGenesisHash().fromHex())) + setTransactionExtension(ChargeTransactionPayment(chain.additional?.defaultTip.orZero())) + setTransactionExtension(CheckMetadataHash(metadataProof.checkMetadataHash)) + setTransactionExtension(CheckSpecVersion(metadataProof.usedVersion.specVersion)) + setTransactionExtension(CheckTxVersion(metadataProof.usedVersion.transactionVersion)) + + CustomTransactionExtensions.defaultValues(runtime).forEach(::setTransactionExtension) + + Log.d(TAG, "All extensions set for ${chain.name}") + } + } + } + + private fun isPezkuwiChain(runtime: RuntimeSnapshot): Boolean { + val signedExtIds = runtime.metadata.extrinsic.signedExtensions.map { it.id } + return signedExtIds.any { it == "AuthorizeCall" } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicSerializers.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicSerializers.kt new file mode 100644 index 0000000..e5da03a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicSerializers.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.runtime.extrinsic + +import com.google.gson.GsonBuilder +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import io.novafoundation.nova.common.utils.ByteArrayHexAdapter +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import java.lang.reflect.Type + +private class GenericCallAdapter : JsonSerializer { + + override fun serialize(src: GenericCall.Instance, typeOfSrc: Type, context: JsonSerializationContext): JsonElement { + return JsonObject().apply { + add("module", JsonPrimitive(src.module.name)) + add("function", JsonPrimitive(src.function.name)) + add("args", context.serialize(src.arguments)) + } + } +} + +object ExtrinsicSerializers { + + fun gson() = GsonBuilder() + .registerTypeHierarchyAdapter(ByteArray::class.java, ByteArrayHexAdapter()) + .registerTypeHierarchyAdapter(GenericCall.Instance::class.java, GenericCallAdapter()) + .setPrettyPrinting() + .create() +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicStatus.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicStatus.kt new file mode 100644 index 0000000..f61739c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicStatus.kt @@ -0,0 +1,43 @@ +package io.novafoundation.nova.runtime.extrinsic + +import io.novasama.substrate_sdk_android.wsrpc.subscription.response.SubscriptionChange + +sealed class ExtrinsicStatus(val extrinsicHash: String, val terminal: Boolean) { + + class Ready(extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = false) + + class Broadcast(extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = false) + + class InBlock(val blockHash: String, extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = false) + + class Finalized(val blockHash: String, extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = true) + + class Other(extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = false) +} + +private const val STATUS_READY = "ready" +private const val STATUS_BROADCAST = "broadcast" +private const val STATUS_IN_BLOCK = "inBlock" +private const val STATUS_FINALIZED = "finalized" +private const val STATUS_FINALITY_TIMEOUT = "finalityTimeout" + +fun SubscriptionChange.asExtrinsicStatus(extrinsicHash: String): ExtrinsicStatus { + return when (val result = params.result) { + STATUS_READY -> ExtrinsicStatus.Ready(extrinsicHash) + is Map<*, *> -> when { + STATUS_BROADCAST in result -> ExtrinsicStatus.Broadcast(extrinsicHash) + STATUS_IN_BLOCK in result -> ExtrinsicStatus.InBlock(extractBlockHash(result, STATUS_IN_BLOCK), extrinsicHash) + STATUS_FINALIZED in result -> ExtrinsicStatus.Finalized(extractBlockHash(result, STATUS_FINALIZED), extrinsicHash) + STATUS_FINALITY_TIMEOUT in result -> ExtrinsicStatus.Finalized(extractBlockHash(result, STATUS_FINALITY_TIMEOUT), extrinsicHash) + else -> ExtrinsicStatus.Other(extrinsicHash) + } + + else -> ExtrinsicStatus.Other(extrinsicHash) + } +} + +private fun extractBlockHash(map: Map<*, *>, key: String): String { + return map[key] as? String ?: unknownStructure() +} + +private fun unknownStructure(): Nothing = throw IllegalArgumentException("Unknown extrinsic status structure") diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicValidityUseCase.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicValidityUseCase.kt new file mode 100644 index 0000000..23856f1 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/ExtrinsicValidityUseCase.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.runtime.extrinsic + +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.lifecycle.LifecycleOwner +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.common.utils.formatting.remainingTime +import io.novafoundation.nova.common.utils.setTextColorRes +import io.novafoundation.nova.common.view.startTimer +import io.novafoundation.nova.runtime.R +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +class ValidityPeriod(val period: TimerValue) + +fun ValidityPeriod.remainingTime(): Long { + return period.remainingTime() +} + +fun ValidityPeriod.closeToExpire(): Boolean { + return remainingTime().milliseconds < 1.minutes +} + +fun ValidityPeriod.ended(): Boolean { + return remainingTime() == 0L +} + +interface ExtrinsicValidityUseCase { + + suspend fun extrinsicValidityPeriod(payload: InheritedImplication): ValidityPeriod +} + +internal class RealExtrinsicValidityUseCase( + private val mortalityConstructor: MortalityConstructor +) : ExtrinsicValidityUseCase { + + override suspend fun extrinsicValidityPeriod(payload: InheritedImplication): ValidityPeriod { + // TODO this should calculate remaining time based on Era from payload + val timerValue = TimerValue( + millis = mortalityConstructor.mortalPeriodMillis(), + millisCalculatedAt = System.currentTimeMillis() + ) + + return ValidityPeriod(timerValue) + } +} + +fun LifecycleOwner.startExtrinsicValidityTimer( + validityPeriod: ValidityPeriod, + @StringRes timerFormat: Int, + timerView: TextView, + onTimerFinished: () -> Unit +) { + timerView.startTimer( + value = validityPeriod.period, + customMessageFormat = timerFormat, + lifecycle = lifecycle, + onTick = { view, _ -> + val textColorRes = if (validityPeriod.closeToExpire()) R.color.text_negative else R.color.text_secondary + + view.setTextColorRes(textColorRes) + }, + onFinish = { onTimerFinished() } + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/MortalityConstructor.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/MortalityConstructor.kt new file mode 100644 index 0000000..65f5205 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/MortalityConstructor.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.runtime.extrinsic + +import io.novafoundation.nova.common.utils.atLeastZero +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.repository.ChainStateRepository +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Era +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import java.lang.Integer.min + +private const val FALLBACK_MAX_HASH_COUNT = 250 +private const val MAX_FINALITY_LAG = 5 +private const val MORTAL_PERIOD = 5 * 60 * 1000 + +class Mortality(val era: Era.Mortal, val blockHash: String) + +class MortalityConstructor( + private val rpcCalls: RpcCalls, + private val chainStateRepository: ChainStateRepository, +) { + + fun mortalPeriodMillis(): Long = MORTAL_PERIOD.toLong() + + suspend fun constructMortality(chainId: ChainId): Mortality = withContext(Dispatchers.IO) { + val finalizedHash = async { rpcCalls.getFinalizedHead(chainId) } + + val bestHeader = async { rpcCalls.getBlockHeader(chainId) } + val finalizedHeader = async { rpcCalls.getBlockHeader(chainId, finalizedHash()) } + + val currentHeader = async { bestHeader().parentHash?.let { rpcCalls.getBlockHeader(chainId, it) } ?: bestHeader() } + + val currentNumber = currentHeader().number + val finalizedNumber = finalizedHeader().number + + val finalityLag = (currentNumber - finalizedNumber).atLeastZero() + + val startBlockNumber = finalizedNumber + + val blockHashCount = chainStateRepository.blockHashCount(chainId)?.toInt() + + val blockTime = chainStateRepository.predictedBlockTime(chainId).toInt() + + val mortalPeriod = MORTAL_PERIOD / blockTime + finalityLag + + val unmappedPeriod = min(blockHashCount ?: FALLBACK_MAX_HASH_COUNT, mortalPeriod) + + val era = Era.getEraFromBlockPeriod(startBlockNumber, unmappedPeriod) + val eraBlockNumber = ((startBlockNumber - era.phase) / era.period) * era.period + era.phase + + val eraBlockHash = rpcCalls.getBlockHash(chainId, eraBlockNumber.toBigInteger()) + + Mortality(era, eraBlockHash) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/AuthorizeCall.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/AuthorizeCall.kt new file mode 100644 index 0000000..5b96114 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/AuthorizeCall.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.runtime.extrinsic.extensions + +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension + +/** + * Signed extension for PezkuwiChain that authorizes calls. + * This extension uses PhantomData internally, so it has no payload (empty encoding). + * + * In the runtime, AuthorizeCall is defined as: + * pub struct AuthorizeCall(core::marker::PhantomData); + * + * It's placed first in the TxExtension tuple for PezkuwiChain. + */ +class AuthorizeCall : FixedValueTransactionExtension( + name = "AuthorizeCall", + implicit = null, + explicit = null // PhantomData encodes to nothing +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/ChargeAssetTxPayment.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/ChargeAssetTxPayment.kt new file mode 100644 index 0000000..823cdc0 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/ChargeAssetTxPayment.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.runtime.extrinsic.extensions + +import io.novafoundation.nova.common.utils.structOf +import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension +import java.math.BigInteger + +class ChargeAssetTxPayment( + val assetId: Any? = null, + val tip: BigInteger = BigInteger.ZERO +) : FixedValueTransactionExtension( + name = ID, + implicit = null, + explicit = assetTxPaymentPayload(assetId, tip) +) { + + companion object { + + val ID = "ChargeAssetTxPayment" + + private fun assetTxPaymentPayload(assetId: Any?, tip: BigInteger = BigInteger.ZERO): Any { + return structOf( + "tip" to tip, + "assetId" to assetId + ) + } + + fun ExtrinsicBuilder.chargeAssetTxPayment(assetId: Any?, tip: BigInteger = BigInteger.ZERO) { + setTransactionExtension(ChargeAssetTxPayment(assetId, tip)) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckAppId.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckAppId.kt new file mode 100644 index 0000000..cf7f916 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckAppId.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.runtime.extrinsic.extensions + +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension +import java.math.BigInteger + +// Signed extension for Avail related to Data Availability Transactions. +// We set it to 0 which is the default value provided by Avail team +class CheckAppId(appId: BigInteger = BigInteger.ZERO) : FixedValueTransactionExtension( + name = "CheckAppId", + implicit = null, + explicit = appId +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckNonZeroSender.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckNonZeroSender.kt new file mode 100644 index 0000000..23fcffe --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckNonZeroSender.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.runtime.extrinsic.extensions + +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension + +/** + * Signed extension for PezkuwiChain that checks for non-zero sender. + * This extension ensures the sender is not the zero address. + * + * In the runtime, CheckNonZeroSender is defined as: + * pub struct CheckNonZeroSender(core::marker::PhantomData); + * + * It uses PhantomData internally, so it has no payload (empty encoding). + */ +class CheckNonZeroSender : FixedValueTransactionExtension( + name = "CheckNonZeroSender", + implicit = null, + explicit = null // PhantomData encodes to nothing +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckWeight.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckWeight.kt new file mode 100644 index 0000000..fae124d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/CheckWeight.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.runtime.extrinsic.extensions + +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension + +/** + * Signed extension that checks weight limits. + * This extension uses PhantomData internally, so it has no payload (empty encoding). + * + * In the runtime, CheckWeight is defined as: + * pub struct CheckWeight(core::marker::PhantomData); + */ +class CheckWeight : FixedValueTransactionExtension( + name = "CheckWeight", + implicit = null, + explicit = null // PhantomData encodes to nothing +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckImmortal.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckImmortal.kt new file mode 100644 index 0000000..1cb3872 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckImmortal.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.runtime.extrinsic.extensions + +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension + +/** + * Custom CheckMortality extension for Pezkuwi chains using IMMORTAL era. + * + * Pezkuwi uses pezsp_runtime.generic.era.Era which is a DictEnum with variants: + * - Immortal (encoded as 0x00) + * - Mortal1(u8), Mortal2(u8), ..., Mortal255(u8) + * + * This extension uses Immortal era with genesis hash, which matches how @pezkuwi/api signs. + * + * @param genesisHash The chain's genesis hash (32 bytes) for the signer payload + */ +class PezkuwiCheckImmortal( + genesisHash: ByteArray +) : FixedValueTransactionExtension( + name = "CheckMortality", + implicit = genesisHash, // Genesis hash goes into signer payload for immortal transactions + explicit = DictEnum.Entry("Immortal", null) // Immortal variant - unit type with no value +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckMortality.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckMortality.kt new file mode 100644 index 0000000..520fbe7 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/PezkuwiCheckMortality.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.runtime.extrinsic.extensions + +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Era +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension +import java.math.BigInteger + +/** + * Custom CheckMortality extension for Pezkuwi chains. + * + * Pezkuwi uses pezsp_runtime.generic.era.Era which is a DictEnum with variants: + * - Immortal + * - Mortal1(u8), Mortal2(u8), ..., Mortal255(u8) + * + * The variant name is "MortalX" where X is the first byte of the encoded era, + * and the variant's value is the second byte (u8). + * + * @param era The mortal era from MortalityConstructor + * @param blockHash The block hash (32 bytes) for the signer payload + */ +class PezkuwiCheckMortality( + era: Era.Mortal, + blockHash: ByteArray +) : FixedValueTransactionExtension( + name = "CheckMortality", + implicit = blockHash, // blockHash goes into signer payload + explicit = createEraEntry(era) // Era as DictEnum.Entry +) { + companion object { + /** + * Creates a DictEnum.Entry for the Era. + * + * Standard Era encoding produces 2 bytes: + * - First byte determines the variant name (Mortal1, Mortal2, ..., Mortal255) + * - Second byte is the variant's value (u8) + */ + private fun createEraEntry(era: Era.Mortal): DictEnum.Entry { + val period = era.period.toLong() + val phase = era.phase.toLong() + val quantizeFactor = maxOf(period shr 12, 1) + + // Calculate the two-byte encoding + val encoded = ((countTrailingZeroBits(period) - 1).coerceIn(1, 15)) or + ((phase / quantizeFactor).toInt() shl 4) + + val firstByte = encoded and 0xFF + val secondByte = (encoded shr 8) and 0xFF + + // DictEnum variant: "MortalX" where X is the first byte + // Variant value: second byte as u8 (BigInteger) + return DictEnum.Entry( + name = "Mortal$firstByte", + value = BigInteger.valueOf(secondByte.toLong()) + ) + } + + private fun countTrailingZeroBits(value: Long): Int { + if (value == 0L) return 64 + var n = 0 + var x = value + while ((x and 1L) == 0L) { + n++ + x = x shr 1 + } + return n + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/WeightReclaim.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/WeightReclaim.kt new file mode 100644 index 0000000..3b71228 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/extensions/WeightReclaim.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.runtime.extrinsic.extensions + +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension + +/** + * Signed extension for PezkuwiChain that handles weight reclamation. + * This extension reclaims unused weight after transaction execution. + * + * In the runtime, WeightReclaim is defined as: + * pub struct WeightReclaim(core::marker::PhantomData); + * + * It uses PhantomData internally, so it has no payload (empty encoding). + */ +class WeightReclaim : FixedValueTransactionExtension( + name = "WeightReclaim", + implicit = null, + explicit = null // PhantomData encodes to nothing +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/metadata/ExtrinsicProof.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/metadata/ExtrinsicProof.kt new file mode 100644 index 0000000..ac90c49 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/metadata/ExtrinsicProof.kt @@ -0,0 +1,4 @@ +package io.novafoundation.nova.runtime.extrinsic.metadata + +@JvmInline +value class ExtrinsicProof(val value: ByteArray) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/metadata/MetadataProof.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/metadata/MetadataProof.kt new file mode 100644 index 0000000..a3949cf --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/metadata/MetadataProof.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.runtime.extrinsic.metadata + +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHashMode +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.chain.RuntimeVersion + +class MetadataProof( + val checkMetadataHash: CheckMetadataHashMode, + val usedVersion: RuntimeVersion +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/metadata/MetadataShortenerService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/metadata/MetadataShortenerService.kt new file mode 100644 index 0000000..4090800 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/metadata/MetadataShortenerService.kt @@ -0,0 +1,187 @@ +package io.novafoundation.nova.runtime.extrinsic.metadata + +import android.util.Log +import io.novafoundation.nova.common.utils.hasSignedExtension +import io.novafoundation.nova.metadata_shortener.MetadataShortener +import io.novafoundation.nova.runtime.ext.shouldDisableMetadataHashCheck +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRawMetadata +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.DefaultSignedExtensions +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHashMode +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.getGenesisHashOrThrow +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.chain.RuntimeVersionFull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface MetadataShortenerService { + + suspend fun isCheckMetadataHashAvailable(chainId: ChainId): Boolean + + suspend fun generateExtrinsicProof(inheritedImplication: InheritedImplication): ExtrinsicProof + + suspend fun generateMetadataProof(chainId: ChainId): MetadataProof + + suspend fun generateDisabledMetadataProof(chainId: ChainId): MetadataProof +} + +private const val MINIMUM_METADATA_VERSION_TO_CALCULATE_HASH = 15 + +internal class RealMetadataShortenerService( + private val chainRegistry: ChainRegistry, + private val rpcCalls: RpcCalls, +) : MetadataShortenerService { + + private val cache = MetadataProofCache() + override suspend fun isCheckMetadataHashAvailable(chainId: ChainId): Boolean { + val runtime = chainRegistry.getRuntime(chainId) + val chain = chainRegistry.getChain(chainId) + + return shouldCalculateMetadataHash(runtime.metadata, chain) + } + + override suspend fun generateExtrinsicProof(inheritedImplication: InheritedImplication): ExtrinsicProof { + val chainId = inheritedImplication.getGenesisHashOrThrow().toHexString(withPrefix = false) + + val chain = chainRegistry.getChain(chainId) + val mainAsset = chain.utilityAsset + + val call = inheritedImplication.encodedCall() + val signedExtras = inheritedImplication.encodedExplicits() + val additionalSigned = inheritedImplication.encodedImplicits() + + val metadataVersion = rpcCalls.getRuntimeVersion(chainId) + + val metadata = chainRegistry.getRawMetadata(chainId) + + val proof = MetadataShortener.generate_extrinsic_proof( + call, + signedExtras, + additionalSigned, + metadata.metadataContent, + metadataVersion.specVersion, + metadataVersion.specName, + chain.addressPrefix, + mainAsset.precision.value, + mainAsset.symbol.value + ) + + return ExtrinsicProof(proof) + } + + override suspend fun generateMetadataProof(chainId: ChainId): MetadataProof { + val runtimeVersion = rpcCalls.getRuntimeVersion(chainId) + val chain = chainRegistry.getChain(chainId) + + return cache.getOrCompute(chainId, expectedSpecVersion = runtimeVersion.specVersion) { + val runtimeMetadata = chainRegistry.getRuntime(chainId).metadata + val shouldIncludeHash = shouldCalculateMetadataHash(runtimeMetadata, chain) + + if (shouldIncludeHash) { + generateMetadataProofOrDisabled(chain, runtimeVersion) + } else { + MetadataProof( + checkMetadataHash = CheckMetadataHashMode.Disabled, + usedVersion = runtimeVersion + ) + } + } + } + + override suspend fun generateDisabledMetadataProof(chainId: ChainId): MetadataProof { + val runtimeVersion = rpcCalls.getRuntimeVersion(chainId) + + return MetadataProof( + checkMetadataHash = CheckMetadataHashMode.Disabled, + usedVersion = runtimeVersion, + ) + } + + private suspend fun generateMetadataProofOrDisabled( + chain: Chain, + runtimeVersion: RuntimeVersionFull + ): MetadataProof { + return runCatching { + val hash = generateMetadataHash(chain, runtimeVersion) + + MetadataProof( + checkMetadataHash = CheckMetadataHashMode.Enabled(hash), + usedVersion = runtimeVersion + ) + }.getOrElse { // Fallback to disabled in case something went wrong during hash generation + Log.w("MetadataShortenerService", "Failed to generate metadata hash", it) + + MetadataProof( + checkMetadataHash = CheckMetadataHashMode.Disabled, + usedVersion = runtimeVersion + ) + } + } + + private suspend fun generateMetadataHash( + chain: Chain, + runtimeVersion: RuntimeVersionFull + ): ByteArray { + val rawMetadata = chainRegistry.getRawMetadata(chain.id) + val mainAsset = chain.utilityAsset + + return MetadataShortener.generate_metadata_digest( + rawMetadata.metadataContent, + runtimeVersion.specVersion, + runtimeVersion.specName, + chain.addressPrefix, + mainAsset.precision.value, + mainAsset.symbol.value + ) + } + + private class MetadataProofCache { + + // Quote simple synchronization - we block all chains + // Shouldn't be a problem at the moment since we don't expect a lot of multi-chain txs/fee estimations to happen at once + private val cacheAccessMutex: Mutex = Mutex() + private val cache = mutableMapOf() + + suspend fun getOrCompute( + chainId: ChainId, + expectedSpecVersion: Int, + compute: suspend () -> MetadataProof + ): MetadataProof = cacheAccessMutex.withLock { + val cachedProof = cache[chainId] + + if (cachedProof == null || cachedProof.usedVersion.specVersion != expectedSpecVersion) { + val newValue = compute() + cache[chainId] = newValue + + newValue + } else { + cachedProof + } + } + } + + private fun shouldCalculateMetadataHash(runtimeMetadata: RuntimeMetadata, chain: Chain): Boolean { + val disabledByConfig = chain.additional.shouldDisableMetadataHashCheck() + val canBeEnabled = disabledByConfig.not() + val atLeastMinimumVersion = runtimeMetadata.metadataVersion >= MINIMUM_METADATA_VERSION_TO_CALCULATE_HASH + val hasSignedExtension = runtimeMetadata.extrinsic.hasSignedExtension(DefaultSignedExtensions.CHECK_METADATA_HASH) + + Log.d( + "MetadataShortenerService", + "Chain: ${chain.name}, disabledByConfig=$disabledByConfig, canBeEnabled=$canBeEnabled, " + + "atLeastMinimumVersion=$atLeastMinimumVersion, hasSignedExtension=$hasSignedExtension" + ) + Log.d("MetadataShortenerService", "chain.additional: ${chain.additional}, disabledCheckMetadataHash=${chain.additional?.disabledCheckMetadataHash}") + + val result = canBeEnabled && atLeastMinimumVersion && hasSignedExtension + Log.d("MetadataShortenerService", "shouldCalculateMetadataHash result: $result (will use ${if (result) "ENABLED" else "DISABLED"} mode)") + return result + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/multi/CallBuilder.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/multi/CallBuilder.kt new file mode 100644 index 0000000..d47eedf --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/multi/CallBuilder.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.runtime.extrinsic.multi + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.metadata.call +import io.novasama.substrate_sdk_android.runtime.metadata.module + +interface CallBuilder { + + val runtime: RuntimeSnapshot + + val calls: List + + fun addCall( + moduleName: String, + callName: String, + arguments: Map + ): CallBuilder +} + +class SimpleCallBuilder(override val runtime: RuntimeSnapshot) : CallBuilder { + + override val calls = mutableListOf() + + override fun addCall(moduleName: String, callName: String, arguments: Map): CallBuilder { + val module = runtime.metadata.module(moduleName) + val function = module.call(callName) + + calls.add(GenericCall.Instance(module, function, arguments)) + + return this + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/signer/BizinikiwSr25519Signer.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/signer/BizinikiwSr25519Signer.kt new file mode 100644 index 0000000..13bcf30 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/signer/BizinikiwSr25519Signer.kt @@ -0,0 +1,315 @@ +package io.novafoundation.nova.runtime.extrinsic.signer + +import java.math.BigInteger +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * SR25519 signing implementation for PezkuwiChain using "bizinikiwi" signing context. + * + * This is a port of @pezkuwi/scure-sr25519 which uses Merlin transcripts + * (built on Strobe128) with a custom signing context. + * + * Standard Substrate uses "substrate" context, but Pezkuwi uses "bizinikiwi". + */ +object BizinikiwSr25519Signer { + + private val BIZINIKIWI_CONTEXT = "bizinikiwi".toByteArray(Charsets.UTF_8) + + // Ed25519 curve order + private val CURVE_ORDER = BigInteger("7237005577332262213973186563042994240857116359379907606001950938285454250989") + + // Strobe128 constants + private const val STROBE_R = 166 + + /** + * Sign a message using SR25519 with "bizinikiwi" context. + * + * @param secretKey 64-byte secret key (32-byte scalar + 32-byte nonce) + * @param message The message to sign + * @return 64-byte signature with Schnorrkel marker + */ + fun sign(secretKey: ByteArray, message: ByteArray): ByteArray { + require(secretKey.size == 64) { "Secret key must be 64 bytes" } + + // Create signing transcript + val transcript = SigningContext("SigningContext") + transcript.label(BIZINIKIWI_CONTEXT) + transcript.bytes(message) + + // Extract key components + val keyScalar = decodeScalar(secretKey.copyOfRange(0, 32)) + val nonce = secretKey.copyOfRange(32, 64) + val publicKey = getPublicKey(secretKey) + val pubPoint = RistrettoPoint.fromBytes(publicKey) + + // Schnorrkel signing protocol + transcript.protoName("Schnorr-sig") + transcript.commitPoint("sign:pk", pubPoint) + + val r = transcript.witnessScalar("signing", nonce) + val R = RistrettoPoint.BASE.multiply(r) + + transcript.commitPoint("sign:R", R) + val k = transcript.challengeScalar("sign:c") + + val s = (k.multiply(keyScalar).add(r)).mod(CURVE_ORDER) + + // Build signature + val signature = ByteArray(64) + System.arraycopy(R.toBytes(), 0, signature, 0, 32) + System.arraycopy(scalarToBytes(s), 0, signature, 32, 32) + + // Add Schnorrkel marker + signature[63] = (signature[63].toInt() or 0x80).toByte() + + return signature + } + + /** + * Get public key from secret key. + */ + fun getPublicKey(secretKey: ByteArray): ByteArray { + require(secretKey.size == 64) { "Secret key must be 64 bytes" } + val scalar = decodeScalar(secretKey.copyOfRange(0, 32)) + return RistrettoPoint.BASE.multiply(scalar).toBytes() + } + + /** + * Verify a signature. + */ + fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean { + require(signature.size == 64) { "Signature must be 64 bytes" } + require(publicKey.size == 32) { "Public key must be 32 bytes" } + + // Check Schnorrkel marker + if ((signature[63].toInt() and 0x80) == 0) { + return false + } + + // Extract R and s from signature + val sBytes = signature.copyOfRange(32, 64) + sBytes[31] = (sBytes[31].toInt() and 0x7F).toByte() // Remove marker + + val R = RistrettoPoint.fromBytes(signature.copyOfRange(0, 32)) + val s = bytesToScalar(sBytes) + + // Reconstruct transcript + val transcript = SigningContext("SigningContext") + transcript.label(BIZINIKIWI_CONTEXT) + transcript.bytes(message) + + val pubPoint = RistrettoPoint.fromBytes(publicKey) + if (pubPoint.isZero()) return false + + transcript.protoName("Schnorr-sig") + transcript.commitPoint("sign:pk", pubPoint) + transcript.commitPoint("sign:R", R) + + val k = transcript.challengeScalar("sign:c") + + // Verify: R + k*P == s*G + val left = R.add(pubPoint.multiply(k)) + val right = RistrettoPoint.BASE.multiply(s) + + return left == right + } + + private fun decodeScalar(bytes: ByteArray): BigInteger { + require(bytes.size == 32) { "Scalar must be 32 bytes" } + // Little-endian + val reversed = bytes.reversedArray() + return BigInteger(1, reversed).mod(CURVE_ORDER) + } + + private fun bytesToScalar(bytes: ByteArray): BigInteger { + val reversed = bytes.reversedArray() + return BigInteger(1, reversed).mod(CURVE_ORDER) + } + + private fun scalarToBytes(scalar: BigInteger): ByteArray { + val bytes = scalar.toByteArray() + val result = ByteArray(32) + + // Handle sign byte and padding + val start = if (bytes[0] == 0.toByte() && bytes.size > 32) 1 else 0 + val length = minOf(bytes.size - start, 32) + val offset = 32 - length + + System.arraycopy(bytes, start, result, offset, length) + + // Convert to little-endian + return result.reversedArray() + } + + /** + * Strobe128 implementation for Merlin transcripts. + */ + private class Strobe128(protocolLabel: String) { + private val state = ByteArray(200) + private var pos = 0 + private var posBegin = 0 + private var curFlags = 0 + + init { + // Initialize state + state[0] = 1 + state[1] = (STROBE_R + 2).toByte() + state[2] = 1 + state[3] = 0 + state[4] = 1 + state[5] = 96 + + val strobeVersion = "STROBEv1.0.2".toByteArray(Charsets.UTF_8) + System.arraycopy(strobeVersion, 0, state, 6, strobeVersion.size) + + keccakF1600() + metaAD(protocolLabel.toByteArray(Charsets.UTF_8), false) + } + + private fun keccakF1600() { + // Keccak-f[1600] permutation + // Using BouncyCastle's Keccak implementation would be more efficient, + // but for now we'll use a simplified version + val keccak = org.bouncycastle.crypto.digests.KeccakDigest(1600) + keccak.update(state, 0, state.size) + // This is a placeholder - need proper Keccak-p implementation + } + + fun metaAD(data: ByteArray, more: Boolean) { + absorb(data) + } + + fun AD(data: ByteArray, more: Boolean) { + absorb(data) + } + + fun PRF(length: Int): ByteArray { + val result = ByteArray(length) + squeeze(result) + return result + } + + private fun absorb(data: ByteArray) { + for (byte in data) { + state[pos] = (state[pos].toInt() xor byte.toInt()).toByte() + pos++ + if (pos == STROBE_R) { + runF() + } + } + } + + private fun squeeze(out: ByteArray) { + for (i in out.indices) { + out[i] = state[pos] + state[pos] = 0 + pos++ + if (pos == STROBE_R) { + runF() + } + } + } + + private fun runF() { + state[pos] = (state[pos].toInt() xor posBegin).toByte() + state[pos + 1] = (state[pos + 1].toInt() xor 0x04).toByte() + state[STROBE_R + 1] = (state[STROBE_R + 1].toInt() xor 0x80).toByte() + keccakF1600() + pos = 0 + posBegin = 0 + } + } + + /** + * Merlin signing context/transcript. + */ + private class SigningContext(label: String) { + private val strobe = Strobe128(label) + + fun label(data: ByteArray) { + val lengthBytes = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(data.size).array() + strobe.metaAD(lengthBytes, false) + strobe.metaAD(data, true) + } + + fun bytes(data: ByteArray) { + val lengthBytes = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(data.size).array() + strobe.metaAD(lengthBytes, false) + strobe.AD(data, false) + } + + fun protoName(name: String) { + val data = name.toByteArray(Charsets.UTF_8) + strobe.metaAD(data, false) + } + + fun commitPoint(label: String, point: RistrettoPoint) { + strobe.metaAD(label.toByteArray(Charsets.UTF_8), false) + strobe.AD(point.toBytes(), false) + } + + fun witnessScalar(label: String, nonce: ByteArray): BigInteger { + strobe.metaAD(label.toByteArray(Charsets.UTF_8), false) + strobe.AD(nonce, false) + val bytes = strobe.PRF(64) + return bytesToWideScalar(bytes) + } + + fun challengeScalar(label: String): BigInteger { + strobe.metaAD(label.toByteArray(Charsets.UTF_8), false) + val bytes = strobe.PRF(64) + return bytesToWideScalar(bytes) + } + + private fun bytesToWideScalar(bytes: ByteArray): BigInteger { + // Reduce 64 bytes to a scalar modulo curve order + val reversed = bytes.reversedArray() + return BigInteger(1, reversed).mod(CURVE_ORDER) + } + } + + /** + * Ristretto255 point operations. + * This is a placeholder - full implementation requires Ed25519 curve math. + */ + private class RistrettoPoint private constructor(private val bytes: ByteArray) { + + companion object { + val BASE: RistrettoPoint by lazy { + // Ed25519 base point in Ristretto encoding + val baseBytes = ByteArray(32) + // TODO: Set proper base point bytes + RistrettoPoint(baseBytes) + } + + fun fromBytes(bytes: ByteArray): RistrettoPoint { + require(bytes.size == 32) { "Point must be 32 bytes" } + return RistrettoPoint(bytes.copyOf()) + } + } + + fun toBytes(): ByteArray = bytes.copyOf() + + fun multiply(scalar: BigInteger): RistrettoPoint { + // TODO: Implement scalar multiplication + return this + } + + fun add(other: RistrettoPoint): RistrettoPoint { + // TODO: Implement point addition + return this + } + + fun isZero(): Boolean { + return bytes.all { it == 0.toByte() } + } + + override fun equals(other: Any?): Boolean { + if (other !is RistrettoPoint) return false + return bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = bytes.contentHashCode() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/signer/PezkuwiKeyPairSigner.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/signer/PezkuwiKeyPairSigner.kt new file mode 100644 index 0000000..a5b161c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/signer/PezkuwiKeyPairSigner.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.runtime.extrinsic.signer + +import android.util.Log +import io.novafoundation.nova.sr25519.BizinikiwSr25519 +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.GeneralTransactionSigner +import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.signingPayload + +/** + * SR25519 signer for Pezkuwi chains using "bizinikiwi" signing context. + * + * This signer is used instead of the standard KeyPairSigner for Pezkuwi ecosystem chains + * (Pezkuwi, Pezkuwi Asset Hub, Pezkuwi People) which require signatures with "bizinikiwi" + * context instead of the standard "substrate" context used by Polkadot ecosystem chains. + */ +class PezkuwiKeyPairSigner private constructor( + private val secretKey: ByteArray, + private val publicKey: ByteArray +) : GeneralTransactionSigner { + + companion object { + /** + * Create a PezkuwiKeyPairSigner from a 32-byte seed. + * The seed is expanded to a full keypair using BizinikiwSr25519. + */ + fun fromSeed(seed: ByteArray): PezkuwiKeyPairSigner { + require(seed.size == 32) { "Seed must be 32 bytes, got ${seed.size}" } + + Log.d("PezkuwiSigner", "Creating signer from seed") + + // Expand seed to 96-byte keypair + val expandedKeypair = BizinikiwSr25519.keypairFromSeed(seed) + Log.d("PezkuwiSigner", "Expanded keypair size: ${expandedKeypair.size}") + + // Extract 64-byte secret key and 32-byte public key + val secretKey = BizinikiwSr25519.secretKeyFromKeypair(expandedKeypair) + val publicKey = BizinikiwSr25519.publicKeyFromKeypair(expandedKeypair) + + Log.d("PezkuwiSigner", "Secret key size: ${secretKey.size}") + Log.d("PezkuwiSigner", "Public key: ${publicKey.toHex()}") + + return PezkuwiKeyPairSigner(secretKey, publicKey) + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + } + + override suspend fun signInheritedImplication( + inheritedImplication: InheritedImplication, + accountId: AccountId + ): SignatureWrapper { + val payload = inheritedImplication.signingPayload() + + Log.d("PezkuwiSigner", "=== SIGNING WITH BIZINIKIWI ===") + Log.d("PezkuwiSigner", "Payload size: ${payload.size}") + Log.d("PezkuwiSigner", "Payload: ${payload.toHex()}") + + // Use BizinikiwSr25519 native library with "bizinikiwi" signing context + val signature = BizinikiwSr25519.sign( + publicKey = publicKey, + secretKey = secretKey, + message = payload + ) + + Log.d("PezkuwiSigner", "Signature: ${signature.toHex()}") + + // Verify locally + val verified = BizinikiwSr25519.verify(signature, payload, publicKey) + Log.d("PezkuwiSigner", "Local verification: $verified") + + return SignatureWrapper.Sr25519(signature) + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw { + // Use BizinikiwSr25519 native library with "bizinikiwi" signing context + val signature = BizinikiwSr25519.sign( + publicKey = publicKey, + secretKey = secretKey, + message = payload.message + ) + + return SignedRaw(payload, SignatureWrapper.Sr25519(signature)) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/signer/SignerPayloadRawWithChain.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/signer/SignerPayloadRawWithChain.kt new file mode 100644 index 0000000..86b149e --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/signer/SignerPayloadRawWithChain.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.runtime.extrinsic.signer + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw + +class SignerPayloadRawWithChain( + val chainId: ChainId, + val message: ByteArray, + val accountId: AccountId, +) + +fun SignerPayloadRawWithChain.withoutChain(): SignerPayloadRaw { + return SignerPayloadRaw(message, accountId) +} + +fun SignerPayloadRaw.withChain(chainId: ChainId): SignerPayloadRawWithChain { + return SignerPayloadRawWithChain(chainId = chainId, message = message, accountId = accountId) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/VisitorLogger.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/VisitorLogger.kt new file mode 100644 index 0000000..42ad828 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/VisitorLogger.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor + +import android.util.Log + +internal interface ExtrinsicVisitorLogger { + + fun info(message: String) + + fun error(message: String) +} + +internal class IndentVisitorLogger( + private val tag: String = "ExtrinsicVisitor", + private val indent: Int = 0 +) : ExtrinsicVisitorLogger { + + private val indentPrefix = " ".repeat(indent) + + override fun info(message: String) { + Log.d(tag, indentPrefix + message) + } + + override fun error(message: String) { + Log.e(tag, indentPrefix + message) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/api/CallTraversal.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/api/CallTraversal.kt new file mode 100644 index 0000000..fcc69af --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/api/CallTraversal.kt @@ -0,0 +1,29 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.api + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +interface CallTraversal { + + fun traverse( + source: GenericCall.Instance, + initialOrigin: AccountIdKey, + visitor: CallVisitor + ) +} + +fun interface CallVisitor { + + fun visit(visit: CallVisit) +} + +fun CallTraversal.collect( + source: GenericCall.Instance, + initialOrigin: AccountIdKey, +): List { + return buildList { + traverse(source, initialOrigin) { + add(it) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/api/CallVisit.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/api/CallVisit.kt new file mode 100644 index 0000000..dc4fd85 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/api/CallVisit.kt @@ -0,0 +1,57 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.api + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +interface CallVisit { + /** + * Call that is currently visiting + */ + val call: GenericCall.Instance + + /** + * Origin's account id that this call has been dispatched with + */ + val callOrigin: AccountIdKey +} + +class LeafCallVisit( + override val call: GenericCall.Instance, + override val callOrigin: AccountIdKey +) : CallVisit + +val CallVisit.isLeaf: Boolean + get() = this is LeafCallVisit + +interface BatchCallVisit : CallVisit { + + val batchedCalls: List +} + +interface MultisigCallVisit : CallVisit { + + val signatory: AccountIdKey + get() = callOrigin + + val otherSignatories: List + + val threshold: Int + + val multisig: AccountIdKey + + val nestedCall: GenericCall.Instance +} + +interface ProxyCallVisit : CallVisit { + + val proxy: AccountIdKey + get() = callOrigin + + val proxied: AccountIdKey + + val nestedCall: GenericCall.Instance +} + +fun CallVisit.requireLeafOrNull(): LeafCallVisit? { + return this as? LeafCallVisit +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/NestedCallVisitNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/NestedCallVisitNode.kt new file mode 100644 index 0000000..fec8b49 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/NestedCallVisitNode.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.runtime.extrinsic.visitor.ExtrinsicVisitorLogger +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +internal interface NestedCallVisitNode { + + fun canVisit(call: GenericCall.Instance): Boolean + + fun visit(call: GenericCall.Instance, context: CallVisitingContext) +} + +internal interface CallVisitingContext { + + val origin: AccountIdKey + + val logger: ExtrinsicVisitorLogger + + /** + * Request parent to perform recursive visit of the given call + */ + fun nestedVisit(visit: NestedCallVisit) + + /** + * Call the supplied visitor with the given argument + */ + fun visit(visit: CallVisit) +} + +internal fun CallVisitingContext.nestedVisit(call: GenericCall.Instance, origin: AccountIdKey) { + nestedVisit(NestedCallVisit(call, origin)) +} + +/** + * Version of [CallVisit] intended for nested usage + * + * @see [CallVisit] + */ +internal class NestedCallVisit( + val call: GenericCall.Instance, + + val origin: AccountIdKey +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/RealCallTraversal.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/RealCallTraversal.kt new file mode 100644 index 0000000..cb1186b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/RealCallTraversal.kt @@ -0,0 +1,100 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.runtime.extrinsic.visitor.ExtrinsicVisitorLogger +import io.novafoundation.nova.runtime.extrinsic.visitor.IndentVisitorLogger +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisitor +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.LeafCallVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch.BatchAllCallNode +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch.BatchCallNode +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch.ForceBatchCallNode +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.multisig.MultisigCallNode +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.proxy.ProxyCallNode +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAddress + +internal class RealCallTraversal( + private val knownNodes: List = defaultNodes(), +) : CallTraversal { + + companion object { + + fun defaultNodes(): List = listOf( + BatchCallNode(), + BatchAllCallNode(), + ForceBatchCallNode(), + + ProxyCallNode(), + + MultisigCallNode() + ) + } + + override fun traverse( + source: GenericCall.Instance, + initialOrigin: AccountIdKey, + visitor: CallVisitor + ) { + val rootVisit = NestedCallVisit( + call = source, + origin = initialOrigin + ) + + nestedVisit(visitor, rootVisit) + } + + private fun nestedVisit( + visitor: CallVisitor, + visitedCall: NestedCallVisit + ) { + val nestedNode = findNestedNode(visitedCall.call) + + if (nestedNode == null) { + val publicVisit = visitedCall.toLeafVisit() + + val call = visitedCall.call + val display = "${call.module.name}.${call.function.name}" + val origin = visitedCall.origin + val newLogger = IndentVisitorLogger() + + newLogger.info("Visited leaf: $display, origin: ${origin.value.toAddress(42)}") + + visitor.visit(publicVisit) + } else { + val newLogger = IndentVisitorLogger() + + val context = RealCallVisitingContext( + origin = visitedCall.origin, + visitor = visitor, + logger = newLogger, + ) + + nestedNode.visit(visitedCall.call, context) + } + } + + private fun findNestedNode(call: GenericCall.Instance): NestedCallVisitNode? { + return knownNodes.find { it.canVisit(call) } + } + + private fun NestedCallVisit.toLeafVisit(): CallVisit { + return LeafCallVisit(call, origin) + } + + private inner class RealCallVisitingContext( + override val origin: AccountIdKey, + override val logger: ExtrinsicVisitorLogger, + private val visitor: CallVisitor + ) : CallVisitingContext { + + override fun nestedVisit(visit: NestedCallVisit) { + return this@RealCallTraversal.nestedVisit(visitor, visit) + } + + override fun visit(visit: CallVisit) { + visitor.visit(visit) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/BaseBatchNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/BaseBatchNode.kt new file mode 100644 index 0000000..2da0a8a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/BaseBatchNode.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCallList +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.BatchCallVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.CallVisitingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.NestedCallVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.NestedCallVisitNode +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +internal abstract class BaseBatchNode : NestedCallVisitNode { + + override fun visit(call: GenericCall.Instance, context: CallVisitingContext) { + val innerCalls = bindGenericCallList(call.arguments["calls"]) + + context.logger.info("Visiting ${this::class.simpleName} with ${innerCalls.size} inner calls") + + val batchVisit = RealBatchCallVisit(call, innerCalls, context.origin) + context.visit(batchVisit) + + innerCalls.forEach { inner -> + val nestedVisit = NestedCallVisit(call = inner, origin = context.origin) + context.nestedVisit(nestedVisit) + } + } + + private class RealBatchCallVisit( + override val call: GenericCall.Instance, + override val batchedCalls: List, + override val callOrigin: AccountIdKey + ) : BatchCallVisit +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/BatchAllCallNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/BatchAllCallNode.kt new file mode 100644 index 0000000..cdcff6d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/BatchAllCallNode.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch + +import io.novafoundation.nova.common.utils.Modules +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +internal class BatchAllCallNode : BaseBatchNode() { + + override fun canVisit(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.UTILITY && call.function.name == "batch_all" + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/BatchCallNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/BatchCallNode.kt new file mode 100644 index 0000000..e606ef3 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/BatchCallNode.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch + +import io.novafoundation.nova.common.utils.Modules +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +internal class BatchCallNode : BaseBatchNode() { + + override fun canVisit(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.UTILITY && call.function.name == "batch" + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/ForceBatchCallNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/ForceBatchCallNode.kt new file mode 100644 index 0000000..3f0579c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/batch/ForceBatchCallNode.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch + +import io.novafoundation.nova.common.utils.Modules +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +internal class ForceBatchCallNode : BaseBatchNode() { + + override fun canVisit(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.UTILITY && call.function.name == "force_batch" + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/multisig/MultisigCallNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/multisig/MultisigCallNode.kt new file mode 100644 index 0000000..cb4fc1f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/multisig/MultisigCallNode.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.multisig + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCall +import io.novafoundation.nova.common.data.network.runtime.binding.bindInt +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.MultisigCallVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.CallVisitingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.NestedCallVisitNode +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nestedVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig.generateMultisigAddress +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +internal class MultisigCallNode : NestedCallVisitNode { + + override fun canVisit(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.MULTISIG && call.function.name == "as_multi" + } + + override fun visit(call: GenericCall.Instance, context: CallVisitingContext) { + context.logger.info("Visiting multisig") + + val innerOriginInfo = extractMultisigOriginInfo(call, context.origin) + val innerCall = extractInnerMultisigCall(call) + + val multisigVisit = RealMultisigCallVisit( + call = call, + callOrigin = context.origin, + otherSignatories = innerOriginInfo.otherSignatories, + threshold = innerOriginInfo.threshold, + nestedCall = innerCall + ) + + context.visit(multisigVisit) + context.nestedVisit(multisigVisit.nestedCall, multisigVisit.multisig) + } + + private fun extractInnerMultisigCall(multisigCall: GenericCall.Instance): GenericCall.Instance { + return bindGenericCall(multisigCall.arguments["call"]) + } + + private fun extractMultisigOriginInfo(call: GenericCall.Instance, parentOrigin: AccountIdKey): MultisigOriginInfo { + val threshold = bindInt(call.arguments["threshold"]) + val otherSignatories = bindList(call.arguments["other_signatories"], ::bindAccountIdKey) + + return MultisigOriginInfo(threshold, otherSignatories) + } + + private class MultisigOriginInfo( + val threshold: Int, + val otherSignatories: List, + ) + + private class RealMultisigCallVisit( + override val call: GenericCall.Instance, + override val callOrigin: AccountIdKey, + override val otherSignatories: List, + override val threshold: Int, + override val nestedCall: GenericCall.Instance, + ) : MultisigCallVisit { + + override val multisig: AccountIdKey = generateMultisigAddress( + signatory = callOrigin, + otherSignatories = otherSignatories, + threshold = threshold + ) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/proxy/ProxyCallNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/proxy/ProxyCallNode.kt new file mode 100644 index 0000000..f91473f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/call/impl/nodes/proxy/ProxyCallNode.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.proxy + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCall +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.ProxyCallVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.CallVisitingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.NestedCallVisitNode +import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nestedVisit +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +internal class ProxyCallNode : NestedCallVisitNode { + + private val proxyCalls = arrayOf("proxy", "proxyAnnounced") + + override fun canVisit(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.PROXY && call.function.name in proxyCalls + } + + override fun visit(call: GenericCall.Instance, context: CallVisitingContext) { + context.logger.info("Visiting proxy") + + val proxyVisit = RealProxyVisit( + call = call, + proxied = innerOrigin(call), + nestedCall = innerCall(call), + callOrigin = context.origin + ) + + context.visit(proxyVisit) + context.nestedVisit(proxyVisit.nestedCall, proxyVisit.proxied) + } + + private fun innerOrigin(proxyCall: GenericCall.Instance): AccountIdKey { + return bindAccountIdentifier(proxyCall.arguments["real"]).intoKey() + } + + private fun innerCall(proxyCall: GenericCall.Instance): GenericCall.Instance { + return bindGenericCall(proxyCall.arguments["call"]) + } + + private class RealProxyVisit( + override val call: GenericCall.Instance, + override val proxied: AccountIdKey, + override val nestedCall: GenericCall.Instance, + override val callOrigin: AccountIdKey + ) : ProxyCallVisit +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/api/ExtrinsicWalk.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/api/ExtrinsicWalk.kt new file mode 100644 index 0000000..079c6df --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/api/ExtrinsicWalk.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +interface ExtrinsicWalk { + + suspend fun walk( + source: ExtrinsicWithEvents, + chainId: ChainId, + visitor: ExtrinsicVisitor + ) +} + +fun interface ExtrinsicVisitor { + + fun visit(visit: ExtrinsicVisit) +} + +class ExtrinsicVisit( + + /** + * Whole extrinsic object. Useful for accessing data outside if the current visit scope, e.g. some top-level events + */ + val rootExtrinsic: ExtrinsicWithEvents, + + /** + * Call that is currently visiting + */ + val call: GenericCall.Instance, + + /** + * Whether call succeeded or not. + * Call is considered successful when it succeeds itself as well as its outer parents succeeds + */ + val success: Boolean, + + /** + * All events that are related to this specific call + */ + val events: List, + + /** + * Origin's account id that this call has been dispatched with + */ + val origin: AccountId, + + /** + * Whether this visit is related to a registered node or not + */ + val hasRegisteredNode: Boolean = false +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/api/ExtrinsicWalkExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/api/ExtrinsicWalkExt.kt new file mode 100644 index 0000000..6ac643b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/api/ExtrinsicWalkExt.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents + +suspend fun ExtrinsicWalk.walkToList(source: ExtrinsicWithEvents, chainId: ChainId): List { + return buildList { + walk(source, chainId) { visitedCall -> + add(visitedCall) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/MutableEventQueue.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/MutableEventQueue.kt new file mode 100644 index 0000000..f156b36 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/MutableEventQueue.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl + +import io.novafoundation.nova.common.utils.instanceOf +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event + +internal interface MutableEventQueue : EventQueue { + + /** + * Removes last event matching one of eventTypes + */ + fun popFromEnd(vararg eventTypes: Event) + + /** + * Takes and removes all events that go after last event matching one of eventTypes. If no matched event found, + * all available events are returned + */ + fun takeTail(vararg eventTypes: Event): List + + /** + * Takes and removes all events that go after specified inclusive index + * @param endInclusive + */ + fun takeAllAfterInclusive(endInclusive: Int): List + + /** + * Takes and removes last event matching one of eventTypes + */ + fun takeFromEnd(vararg eventTypes: Event): GenericEvent.Instance? +} + +internal interface EventQueue { + + fun all(): List + + fun peekItemFromEnd(vararg eventTypes: Event, endExclusive: Int): EventWithIndex? + + fun indexOfLast(vararg eventTypes: Event, endExclusive: Int): Int? +} + +@Suppress("NOTHING_TO_INLINE") +internal inline fun EventQueue.peekItemFromEndOrThrow(vararg eventTypes: Event, endExclusive: Int): EventWithIndex { + return requireNotNull(peekItemFromEnd(*eventTypes, endExclusive = endExclusive)) { + "No required event found for types ${eventTypes.joinToString { it.name }}" + } +} + +@Suppress("NOTHING_TO_INLINE") +internal inline fun MutableEventQueue.takeFromEndOrThrow(vararg eventTypes: Event): GenericEvent.Instance { + return requireNotNull(takeFromEnd(*eventTypes)) { + "No required event found for types ${eventTypes.joinToString { it.name }}" + } +} + +@Suppress("NOTHING_TO_INLINE") +internal inline fun EventQueue.indexOfLastOrThrow(vararg eventTypes: Event, endExclusive: Int): Int { + return requireNotNull(indexOfLast(*eventTypes, endExclusive = endExclusive)) { + "No required event found for types ${eventTypes.joinToString { it.name }}" + } +} + +data class EventWithIndex(val event: GenericEvent.Instance, val eventIndex: Int) + +class RealEventQueue(event: List) : MutableEventQueue { + + private val events: MutableList = event.toMutableList() + + override fun all(): List { + return events + } + + override fun peekItemFromEnd(vararg eventTypes: Event, endExclusive: Int): EventWithIndex? { + return findEventAndIndex(eventTypes, endExclusive) + } + + override fun indexOfLast(vararg eventTypes: Event, endExclusive: Int): Int? { + return findEventAndIndex(eventTypes, endExclusive)?.eventIndex + } + + override fun popFromEnd(vararg eventTypes: Event) { + takeFromEnd(*eventTypes) + } + + override fun takeTail(vararg eventTypes: Event): List { + val eventWithIndex = this.findEventAndIndex(eventTypes) + + return if (eventWithIndex != null) { + this.removeAllAfterExclusive(eventWithIndex.eventIndex) + } else { + this.removeAllAfterInclusive(0) + } + } + + override fun takeAllAfterInclusive(endInclusive: Int): List { + return removeAllAfterInclusive(endInclusive) + } + + override fun takeFromEnd(vararg eventTypes: Event): GenericEvent.Instance? { + return findEventAndIndex(eventTypes)?.let { (event, index) -> + removeAllAfterInclusive(index) + + event + } + } + + private fun findEventAndIndex(eventTypes: Array, endExclusive: Int = this.events.size): EventWithIndex? { + val eventsQueue = this.events + val limit = endExclusive.coerceAtMost(eventsQueue.size) + + for (i in (limit - 1) downTo 0) { + val nextEvent = eventsQueue[i] + + eventTypes.forEach { event -> + if (nextEvent.instanceOf(event)) return EventWithIndex(nextEvent, i) + } + } + + return null + } + + private fun removeAllAfterInclusive(index: Int): List { + if (index > this.events.size) return emptyList() + + val subList = this.events.subList(index, this.events.size) + val subListCopy = subList.toList() + + subList.clear() + + return subListCopy + } + + private fun removeAllAfterExclusive(index: Int): List { + val subList = this.events.subList(index + 1, this.events.size) + val subListCopy = subList.toList() + + subList.clear() + + return subListCopy + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/NestedCallNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/NestedCallNode.kt new file mode 100644 index 0000000..b933c86 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/NestedCallNode.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl + +import io.novafoundation.nova.runtime.extrinsic.visitor.ExtrinsicVisitorLogger +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +internal interface NestedCallNode { + + fun canVisit(call: GenericCall.Instance): Boolean + + /** + * Calculates exclusive end index that is needed to skip all internal events related to this nested call + * For example, utility.batch supposed to skip BatchCompleted/BatchInterrupted and all ItemCompleted events + * This function is used by `visit` to skip internal events of nested nodes of the same type (batch inside batch or proxy inside proxy) + * so they wont interfere + * Should not be called on failed nested calls since they emit no events and its trivial to proceed + */ + fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int + + fun visit(call: GenericCall.Instance, context: VisitingContext) +} + +internal interface VisitingContext { + + val rootExtrinsic: ExtrinsicWithEvents + + val runtime: RuntimeSnapshot + + val origin: AccountId + + val callSucceeded: Boolean + + val logger: ExtrinsicVisitorLogger + + val eventQueue: MutableEventQueue + + fun nestedVisit(visit: NestedExtrinsicVisit) + + fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance): Int +} + +/** + * Version of [ExtrinsicVisit] intended for nested usage + * + * @see [ExtrinsicVisit] + */ +internal class NestedExtrinsicVisit( + val rootExtrinsic: ExtrinsicWithEvents, + val call: GenericCall.Instance, + val success: Boolean, + val events: List, + val origin: AccountId, +) + +internal interface EventCountingContext { + + val runtime: RuntimeSnapshot + + val eventQueue: EventQueue + + val endExclusive: Int + + fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, endExclusive: Int): Int +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/RealExtrinsicWalk.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/RealExtrinsicWalk.kt new file mode 100644 index 0000000..be63773 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/RealExtrinsicWalk.kt @@ -0,0 +1,153 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl + +import io.novafoundation.nova.runtime.extrinsic.visitor.ExtrinsicVisitorLogger +import io.novafoundation.nova.runtime.extrinsic.visitor.IndentVisitorLogger +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisitor +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch.BatchAllNode +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch.ForceBatchNode +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig.MultisigNode +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.proxy.ProxyNode +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.isSuccess +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.signer +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAddress + +internal class RealExtrinsicWalk( + private val chainRegistry: ChainRegistry, + private val knownNodes: List = defaultNodes(), +) : ExtrinsicWalk { + + companion object { + + fun defaultNodes() = listOf( + ProxyNode(), + + BatchAllNode(), + ForceBatchNode(), + + MultisigNode() + ) + } + + override suspend fun walk( + source: ExtrinsicWithEvents, + chainId: ChainId, + visitor: ExtrinsicVisitor + ) { + val runtime = chainRegistry.getRuntime(chainId) + + val rootVisit = NestedExtrinsicVisit( + rootExtrinsic = source, + call = source.extrinsic.call, + success = source.isSuccess(), + events = source.events, + origin = source.extrinsic.signer() ?: error("Unsigned extrinsic"), + ) + + nestedVisit(runtime, visitor, rootVisit, depth = 0) + } + + private fun nestedVisit( + runtime: RuntimeSnapshot, + visitor: ExtrinsicVisitor, + visitedCall: NestedExtrinsicVisit, + depth: Int, + ) { + val nestedNode = findNestedNode(visitedCall.call) + val publicVisit = visitedCall.toVisit(hasRegisteredNode = nestedNode != null) + + visitor.visit(publicVisit) + + if (nestedNode == null) { + val call = visitedCall.call + val display = "${call.module.name}.${call.function.name}" + val origin = visitedCall.origin + val newLogger = IndentVisitorLogger(indent = depth + 1) + + newLogger.info("Visited leaf: $display, success: ${visitedCall.success}, origin: ${origin.toAddress(42)}") + } else { + val eventQueue = RealEventQueue(visitedCall.events) + val newLogger = IndentVisitorLogger(indent = depth) + + val context = RealVisitingContext( + rootExtrinsic = visitedCall.rootExtrinsic, + eventsSize = visitedCall.events.size, + depth = depth, + runtime = runtime, + origin = visitedCall.origin, + callSucceeded = visitedCall.success, + visitor = visitor, + logger = newLogger, + eventQueue = eventQueue + ) + + nestedNode.visit(visitedCall.call, context) + } + } + + private fun endExclusiveToSkipInternalEvents( + runtime: RuntimeSnapshot, + call: GenericCall.Instance, + eventQueue: EventQueue, + endExclusive: Int, + ): Int { + val nestedNode = this.findNestedNode(call) + + return if (nestedNode != null) { + val context: EventCountingContext = RealEventCountingContext(runtime, eventQueue, endExclusive) + + nestedNode.endExclusiveToSkipInternalEvents(call, context) + } else { + // no internal events to skip since its a leaf + endExclusive + } + } + + private fun findNestedNode(call: GenericCall.Instance): NestedCallNode? { + return knownNodes.find { it.canVisit(call) } + } + + private fun NestedExtrinsicVisit.toVisit(hasRegisteredNode: Boolean): ExtrinsicVisit { + return ExtrinsicVisit(rootExtrinsic, call, success, events, origin, hasRegisteredNode) + } + + private inner class RealVisitingContext( + private val eventsSize: Int, + private val depth: Int, + override val rootExtrinsic: ExtrinsicWithEvents, + override val runtime: RuntimeSnapshot, + override val origin: AccountId, + override val callSucceeded: Boolean, + override val logger: ExtrinsicVisitorLogger, + override val eventQueue: MutableEventQueue, + private val visitor: ExtrinsicVisitor + ) : VisitingContext { + + override fun nestedVisit(visit: NestedExtrinsicVisit) { + return this@RealExtrinsicWalk.nestedVisit(runtime, visitor, visit, depth + 1) + } + + override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance): Int { + return this@RealExtrinsicWalk.endExclusiveToSkipInternalEvents(runtime, call, eventQueue, endExclusive = eventsSize) + } + } + + private inner class RealEventCountingContext( + override val runtime: RuntimeSnapshot, + override val eventQueue: EventQueue, + override val endExclusive: Int + ) : EventCountingContext { + + override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, endExclusive: Int): Int { + return this@RealExtrinsicWalk.endExclusiveToSkipInternalEvents(runtime, call, eventQueue, endExclusive) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/batch/BatchAllNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/batch/BatchAllNode.kt new file mode 100644 index 0000000..6fb384f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/batch/BatchAllNode.kt @@ -0,0 +1,77 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch + +import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCallList +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.EventCountingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedCallNode +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedExtrinsicVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.indexOfLastOrThrow +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +internal class BatchAllNode : NestedCallNode { + + override fun canVisit(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.UTILITY && call.function.name == "batch_all" + } + + override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int { + val innerCalls = bindGenericCallList(call.arguments["calls"]) + + val batchCompletedEventType = context.runtime.batchCompletedEvent() + val itemCompletedEventType = context.runtime.itemCompletedEvent() + + var endExclusive = context.endExclusive + + // Safe since `endExclusiveToSkipInternalEvents` should not be called on failed items + val indexOfCompletedEvent = context.eventQueue.indexOfLastOrThrow(batchCompletedEventType, endExclusive = endExclusive) + endExclusive = indexOfCompletedEvent + + innerCalls.reversed().forEach { innerCall -> + val itemIdx = context.eventQueue.indexOfLastOrThrow(itemCompletedEventType, endExclusive = endExclusive) + endExclusive = context.endExclusiveToSkipInternalEvents(innerCall, itemIdx) + } + + return endExclusive + } + + override fun visit(call: GenericCall.Instance, context: VisitingContext) { + val innerCalls = bindGenericCallList(call.arguments["calls"]) + val itemCompletedEventType = context.runtime.itemCompletedEvent() + + context.logger.info("Visiting utility.batchAll with ${innerCalls.size} inner calls") + + if (context.callSucceeded) { + context.logger.info("BatchAll succeeded") + } else { + context.logger.info("BatchAll failed") + } + + val subItemsToVisit = innerCalls.reversed().map { innerCall -> + if (context.callSucceeded) { + context.eventQueue.popFromEnd(itemCompletedEventType) + val alNestedEvents = context.takeCompletedBatchItemEvents(innerCall) + + NestedExtrinsicVisit( + rootExtrinsic = context.rootExtrinsic, + call = innerCall, + success = true, + events = alNestedEvents, + origin = context.origin + ) + } else { + NestedExtrinsicVisit( + rootExtrinsic = context.rootExtrinsic, + call = innerCall, + success = false, + events = emptyList(), + origin = context.origin + ) + } + } + + subItemsToVisit.forEach { subItem -> + context.nestedVisit(subItem) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/batch/Common.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/batch/Common.kt new file mode 100644 index 0000000..1df63db --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/batch/Common.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch + +import io.novafoundation.nova.common.utils.utility +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.metadata.event +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event + +internal fun RuntimeSnapshot.batchCompletedEvent(): Event { + return metadata.utility().event("BatchCompleted") +} + +internal fun RuntimeSnapshot.batchCompletedWithErrorsEvent(): Event { + return metadata.utility().event("BatchCompletedWithErrors") +} + +internal fun RuntimeSnapshot.itemCompletedEvent(): Event { + return metadata.utility().event("ItemCompleted") +} + +internal fun RuntimeSnapshot.itemFailedEvent(): Event { + return metadata.utility().event("ItemFailed") +} + +internal fun VisitingContext.takeCompletedBatchItemEvents(call: GenericCall.Instance): List { + val internalEventsEndExclusive = endExclusiveToSkipInternalEvents(call) + + // internalEnd is exclusive => it holds index of last internal event + // thus, we delete them inclusively + val someOfNestedEvents = eventQueue.takeAllAfterInclusive(internalEventsEndExclusive) + + // now it is safe to go until ItemCompleted\ItemFailed since we removed all potential nested events above + val remainingNestedEvents = eventQueue.takeTail(runtime.itemCompletedEvent(), runtime.itemFailedEvent()) + + return remainingNestedEvents + someOfNestedEvents +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/batch/ForceBatchNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/batch/ForceBatchNode.kt new file mode 100644 index 0000000..cf2010c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/batch/ForceBatchNode.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch + +import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCallList +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.EventCountingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedCallNode +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedExtrinsicVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.indexOfLastOrThrow +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.peekItemFromEndOrThrow +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.takeFromEndOrThrow +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall + +internal class ForceBatchNode : NestedCallNode { + + override fun canVisit(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.UTILITY && call.function.name == "force_batch" + } + + override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int { + val innerCalls = bindGenericCallList(call.arguments["calls"]) + + val batchCompletedEventType = context.runtime.batchCompletedEvent() + val batchCompletedWithErrorsType = context.runtime.batchCompletedWithErrorsEvent() + + val itemCompletedEventType = context.runtime.itemCompletedEvent() + val itemFailedEventType = context.runtime.itemFailedEvent() + + var endExclusive = context.endExclusive + + // Safe since batch all always completes + val indexOfCompletedEvent = context.eventQueue.indexOfLastOrThrow(batchCompletedEventType, batchCompletedWithErrorsType, endExclusive = endExclusive) + endExclusive = indexOfCompletedEvent + + innerCalls.reversed().forEach { innerCall -> + val (itemEvent, itemEventIdx) = context.eventQueue.peekItemFromEndOrThrow(itemCompletedEventType, itemFailedEventType, endExclusive = endExclusive) + + endExclusive = if (itemEvent.instanceOf(itemCompletedEventType)) { + // only completed items emit nested events + context.endExclusiveToSkipInternalEvents(innerCall, itemEventIdx) + } else { + itemEventIdx + } + } + + return endExclusive + } + + override fun visit(call: GenericCall.Instance, context: VisitingContext) { + val innerCalls = bindGenericCallList(call.arguments["calls"]) + + val batchCompletedEventType = context.runtime.batchCompletedEvent() + val batchCompletedWithErrorsType = context.runtime.batchCompletedWithErrorsEvent() + + val itemCompletedEventType = context.runtime.itemCompletedEvent() + val itemFailedEventType = context.runtime.itemFailedEvent() + + context.logger.info("Visiting utility.forceBatch with ${innerCalls.size} inner calls") + + if (context.callSucceeded) { + context.logger.info("ForceBatch succeeded") + + context.eventQueue.popFromEnd(batchCompletedEventType, batchCompletedWithErrorsType) + } else { + context.logger.info("ForceBatch failed") + } + + val subItemsToVisit = innerCalls.reversed().map { innerCall -> + if (context.callSucceeded) { + val itemEvent = context.eventQueue.takeFromEndOrThrow(itemCompletedEventType, itemFailedEventType) + + if (itemEvent.instanceOf(itemCompletedEventType)) { + val allEvents = context.takeCompletedBatchItemEvents(innerCall) + + return@map NestedExtrinsicVisit( + rootExtrinsic = context.rootExtrinsic, + call = innerCall, + success = true, + events = allEvents, + origin = context.origin + ) + } + } + + NestedExtrinsicVisit( + rootExtrinsic = context.rootExtrinsic, + call = innerCall, + success = false, + events = emptyList(), + origin = context.origin + ) + } + + subItemsToVisit.forEach { subItem -> + context.nestedVisit(subItem) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/multisig/Common.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/multisig/Common.kt new file mode 100644 index 0000000..08ac713 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/multisig/Common.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.utils.compareTo +import io.novafoundation.nova.common.utils.padEnd +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 +import io.novasama.substrate_sdk_android.runtime.definitions.types.useScaleWriter +import io.novasama.substrate_sdk_android.scale.utils.directWrite + +private val PREFIX = "modlpy/utilisuba".encodeToByteArray() + +fun generateMultisigAddress( + signatory: AccountIdKey, + otherSignatories: List, + threshold: Int +) = generateMultisigAddress(otherSignatories + signatory, threshold) + +fun generateMultisigAddress( + signatories: List, + threshold: Int +): AccountIdKey { + val accountIdSize = signatories.first().value.size + + val sortedAccounts = signatories.sortedWith { a, b -> a.value.compareTo(b.value, unsigned = true) } + + val entropy = useScaleWriter { + directWrite(PREFIX) + + writeCompact(sortedAccounts.size) + sortedAccounts.forEach { + directWrite(it.value) + } + + writeUint16(threshold) + }.blake2b256() + + val result = when { + entropy.size == accountIdSize -> entropy + entropy.size < accountIdSize -> entropy.padEnd(accountIdSize, 0) + else -> entropy.copyOf(accountIdSize) + } + + return result.intoKey() +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/multisig/MultisigNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/multisig/MultisigNode.kt new file mode 100644 index 0000000..f455e2f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/multisig/MultisigNode.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCall +import io.novafoundation.nova.common.data.network.runtime.binding.bindInt +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novafoundation.nova.common.utils.multisig +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.EventCountingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedCallNode +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedExtrinsicVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.peekItemFromEndOrThrow +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.takeFromEndOrThrow +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.metadata.event +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event + +internal class MultisigNode : NestedCallNode { + + override fun canVisit(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.MULTISIG && call.function.name == "as_multi" + } + + override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int { + var endExclusive = context.endExclusive + val completionEventTypes = context.runtime.multisigCompletionEvents() + + val (completionEvent, completionIdx) = context.eventQueue.peekItemFromEndOrThrow(eventTypes = completionEventTypes, endExclusive = endExclusive) + endExclusive = completionIdx + + if (completionEvent.isMultisigExecutionSucceeded()) { + val innerCall = this.extractInnerMultisigCall(call) + endExclusive = context.endExclusiveToSkipInternalEvents(innerCall, endExclusive) + } + + return endExclusive + } + + override fun visit(call: GenericCall.Instance, context: VisitingContext) { + if (!context.callSucceeded) { + visitFailedMultisigCall(call, context) + context.logger.info("asMulti - reverted by outer parent") + return + } + + val completionEventTypes = context.runtime.multisigCompletionEvents() + val completionEvent = context.eventQueue.takeFromEndOrThrow(*completionEventTypes) + + // Not visiting pending mst's + if (!completionEvent.isMultisigExecuted()) return + + if (completionEvent.isMultisigExecutedOk()) { + context.logger.info("asMulti: execution succeeded") + + visitSucceededMultisigCall(call, context) + } else { + context.logger.info("asMulti: execution failed") + + this.visitFailedMultisigCall(call, context) + } + } + + private fun visitFailedMultisigCall(call: GenericCall.Instance, context: VisitingContext) { + this.visitMultisigCall(call, context, success = false, events = emptyList()) + } + + private fun visitSucceededMultisigCall(call: GenericCall.Instance, context: VisitingContext) { + this.visitMultisigCall(call, context, success = true, events = context.eventQueue.all()) + } + + private fun visitMultisigCall( + call: GenericCall.Instance, + context: VisitingContext, + success: Boolean, + events: List + ) { + val innerOrigin = extractMultisigOrigin(call, context.origin.intoKey()) + val innerCall = extractInnerMultisigCall(call) + + val visit = NestedExtrinsicVisit( + rootExtrinsic = context.rootExtrinsic, + call = innerCall, + success = success, + events = events, + origin = innerOrigin.value + ) + + context.nestedVisit(visit) + } + + private fun GenericEvent.Instance.isMultisigExecutionSucceeded(): Boolean { + if (!isMultisigExecuted()) { + // not final execution + return false + } + + return isMultisigExecutedOk() + } + + private fun GenericEvent.Instance.isMultisigExecuted(): Boolean { + return instanceOf(Modules.MULTISIG, "MultisigExecuted") + } + + private fun GenericEvent.Instance.isMultisigExecutedOk(): Boolean { + // dispatch_result in https://github.com/paritytech/polkadot-sdk/blob/fdf3d65e4c2d9094915c7fd7927e651339171edd/substrate/frame/multisig/src/lib.rs#L260 + return arguments[4].castToDictEnum().name == "Ok" + } + + private fun extractInnerMultisigCall(multisigCall: GenericCall.Instance): GenericCall.Instance { + return bindGenericCall(multisigCall.arguments["call"]) + } + + private fun RuntimeSnapshot.multisigCompletionEvents(): Array { + val multisig = metadata.multisig() + + return arrayOf( + multisig.event("MultisigExecuted"), + multisig.event("MultisigApproval"), + multisig.event("NewMultisig"), + ) + } + + private fun extractMultisigOrigin(call: GenericCall.Instance, parentOrigin: AccountIdKey): AccountIdKey { + val threshold = bindInt(call.arguments["threshold"]) + val otherSignatories = bindList(call.arguments["other_signatories"], ::bindAccountIdKey) + + return generateMultisigAddress( + otherSignatories = otherSignatories, + signatory = parentOrigin, + threshold = threshold + ) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/proxy/ProxyNode.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/proxy/ProxyNode.kt new file mode 100644 index 0000000..66f6f8b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/extrinsic/visitor/extrinsic/impl/nodes/proxy/ProxyNode.kt @@ -0,0 +1,103 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.proxy + +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier +import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCall +import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.proxy +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.EventCountingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedCallNode +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedExtrinsicVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.peekItemFromEndOrThrow +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.takeFromEndOrThrow +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.metadata.event +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event + +internal class ProxyNode : NestedCallNode { + + private val proxyCalls = arrayOf("proxy", "proxyAnnounced") + + override fun canVisit(call: GenericCall.Instance): Boolean { + return call.module.name == Modules.PROXY && call.function.name in proxyCalls + } + + override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int { + var endExclusive = context.endExclusive + val completionEventType = context.runtime.proxyCompletionEvent() + + val (completionEvent, completionIdx) = context.eventQueue.peekItemFromEndOrThrow(completionEventType, endExclusive = endExclusive) + endExclusive = completionIdx + + if (completionEvent.isProxySucceeded()) { + val (innerCall) = this.callAndOriginFromProxy(call) + endExclusive = context.endExclusiveToSkipInternalEvents(innerCall, endExclusive) + } + + return endExclusive + } + + override fun visit(call: GenericCall.Instance, context: VisitingContext) { + if (!context.callSucceeded) { + this.visitFailedProxyCall(call, context) + context.logger.info("Proxy: reverted by outer parent") + return + } + + val completionEventType = context.runtime.proxyCompletionEvent() + val completionEvent = context.eventQueue.takeFromEndOrThrow(completionEventType) + + if (completionEvent.isProxySucceeded()) { + context.logger.info("Proxy: execution succeeded") + + this.visitSucceededProxyCall(call, context) + } else { + context.logger.info("Proxy: execution failed") + + this.visitFailedProxyCall(call, context) + } + } + + private fun visitFailedProxyCall(call: GenericCall.Instance, context: VisitingContext) { + this.visitProxyCall(call, context, success = false, events = emptyList()) + } + + private fun visitSucceededProxyCall(call: GenericCall.Instance, context: VisitingContext) { + this.visitProxyCall(call, context, success = true, events = context.eventQueue.all()) + } + + private fun visitProxyCall( + call: GenericCall.Instance, + context: VisitingContext, + success: Boolean, + events: List + ) { + val (innerCall, innerOrigin) = this.callAndOriginFromProxy(call) + + val visit = NestedExtrinsicVisit( + rootExtrinsic = context.rootExtrinsic, + call = innerCall, + success = success, + events = events, + origin = innerOrigin + ) + + context.nestedVisit(visit) + } + + private fun GenericEvent.Instance.isProxySucceeded(): Boolean { + return arguments.first().castToDictEnum().name == "Ok" + } + + private fun callAndOriginFromProxy(proxyCall: GenericCall.Instance): Pair { + return bindGenericCall(proxyCall.arguments["call"]) to bindAccountIdentifier(proxyCall.arguments["real"]) + } + + private fun RuntimeSnapshot.proxyCompletionEvent(): Event { + return metadata.proxy().event("ProxyExecuted") + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/mapper/RuntimeMapperFromLocal.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/mapper/RuntimeMapperFromLocal.kt new file mode 100644 index 0000000..68aedab --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/mapper/RuntimeMapperFromLocal.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.runtime.mapper + +import io.novafoundation.nova.core_db.model.chain.ChainRuntimeInfoLocal +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.chain.RuntimeVersion + +fun ChainRuntimeInfoLocal.toRuntimeVersion(): RuntimeVersion? { + return RuntimeVersion( + specVersion = this.remoteVersion, + transactionVersion = this.transactionVersion ?: return null + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt new file mode 100644 index 0000000..57da123 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt @@ -0,0 +1,397 @@ +package io.novafoundation.nova.runtime.multiNetwork + +import android.util.Log +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.diffed +import io.novafoundation.nova.common.utils.filterList +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.utils.mapNotNullToSet +import io.novafoundation.nova.common.utils.provideContext +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import io.novafoundation.nova.runtime.ext.isDisabled +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.ext.isFullSync +import io.novafoundation.nova.runtime.ext.level +import io.novafoundation.nova.runtime.ext.requiresBaseTypes +import io.novafoundation.nova.runtime.ext.typesUsage +import io.novafoundation.nova.runtime.multiNetwork.asset.EvmAssetsSyncService +import io.novafoundation.nova.runtime.multiNetwork.chain.ChainSyncService +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainLocalToChain +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapConnectionStateToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ConnectionState +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Node.ConnectionType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionPool +import io.novafoundation.nova.runtime.multiNetwork.connection.Web3ApiPool +import io.novafoundation.nova.runtime.multiNetwork.exception.DisabledChainException +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProvider +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProviderPool +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeSubscriptionPool +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeSyncService +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchronizer +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch + +data class ChainWithAsset( + val chain: Chain, + val asset: Chain.Asset +) + +class ChainRegistry( + private val runtimeProviderPool: RuntimeProviderPool, + private val connectionPool: ConnectionPool, + private val runtimeSubscriptionPool: RuntimeSubscriptionPool, + private val chainDao: ChainDao, + private val chainSyncService: ChainSyncService, + private val evmAssetsSyncService: EvmAssetsSyncService, + private val baseTypeSynchronizer: BaseTypeSynchronizer, + private val runtimeSyncService: RuntimeSyncService, + private val web3ApiPool: Web3ApiPool, + private val gson: Gson +) : CoroutineScope by CoroutineScope(Dispatchers.Default) { + + val currentChains = chainDao.joinChainInfoFlow() + .mapList { mapChainLocalToChain(it, gson) } + .diffed() + .map { diff -> + diff.removed.forEach { unregisterChain(it) } + diff.newOrUpdated.forEach { chain -> registerChain(chain) } + + diff.all + } + .filter { it.isNotEmpty() } + .distinctUntilChanged() + .inBackground() + .shareIn(this, SharingStarted.Eagerly, replay = 1) + + val chainsById = currentChains.map { chains -> chains.associateBy { it.id } } + .inBackground() + .shareIn(this, SharingStarted.Eagerly, replay = 1) + + init { + syncChainsAndAssets() + + syncBaseTypesIfNeeded() + } + + fun getConnectionOrNull(chainId: String): ChainConnection? { + return connectionPool.getConnectionOrNull(chainId.removeHexPrefix()) + } + + @Deprecated("Use getActiveConnectionOrNull, since this method may throw an exception if Chain is disabled") + suspend fun getActiveConnection(chainId: String): ChainConnection { + requireConnectionStateAtLeast(chainId, ConnectionState.LIGHT_SYNC) + + return connectionPool.getConnection(chainId.removeHexPrefix()) + } + + suspend fun getActiveConnectionOrNull(chainId: String): ChainConnection? { + return runCatching { + requireConnectionStateAtLeast(chainId, ConnectionState.LIGHT_SYNC) + + return connectionPool.getConnectionOrNull(chainId.removeHexPrefix()) + }.getOrNull() + } + + suspend fun getEthereumApi(chainId: String, connectionType: ConnectionType): Web3Api? { + return runCatching { + requireConnectionStateAtLeast(chainId, ConnectionState.LIGHT_SYNC) + + web3ApiPool.getWeb3Api(chainId, connectionType) + }.getOrNull() + } + + suspend fun getRuntimeProvider(chainId: String): RuntimeProvider { + requireConnectionStateAtLeast(chainId, ConnectionState.FULL_SYNC) + + return runtimeProviderPool.getRuntimeProvider(chainId.removeHexPrefix()) + } + + suspend fun getChain(chainId: String): Chain = chainsById.first().getValue(chainId.removeHexPrefix()) + + suspend fun enableFullSync(chainId: ChainId) { + changeChainConnectionState(chainId, ConnectionState.FULL_SYNC) + } + + suspend fun changeChainConnectionState(chainId: ChainId, state: ConnectionState) { + val connectionState = mapConnectionStateToLocal(state) + chainDao.setConnectionState(chainId, connectionState) + } + + suspend fun setWssNodeSelectionStrategy(chainId: String, strategy: Chain.Nodes.NodeSelectionStrategy) { + return when (strategy) { + Chain.Nodes.NodeSelectionStrategy.AutoBalance -> enableAutoBalance(chainId) + is Chain.Nodes.NodeSelectionStrategy.SelectedNode -> setSelectedNode(chainId, strategy.unformattedNodeUrl) + } + } + + private suspend fun enableAutoBalance(chainId: ChainId) { + chainDao.setNodePreferences(NodeSelectionPreferencesLocal(chainId, autoBalanceEnabled = false, null)) + } + + private suspend fun setSelectedNode(chainId: ChainId, unformattedNodeUrl: String) { + val chain = getChain(chainId) + + val chainSupportsNode = chain.nodes.nodes.any { it.unformattedUrl == unformattedNodeUrl } + require(chainSupportsNode) { "Node with url $unformattedNodeUrl is not found for chain $chainId" } + + chainDao.setNodePreferences(NodeSelectionPreferencesLocal(chainId, false, unformattedNodeUrl)) + } + + private suspend fun requireConnectionStateAtLeast(chainId: ChainId, state: ConnectionState) { + val chain = getChain(chainId) + + if (chain.isDisabled) throw DisabledChainException() + if (chain.connectionState.level >= state.level) return + + Log.d("ConnectionState", "Requested state $state for ${chain.name}, current is ${chain.connectionState}. Triggering state change to $state") + + chainDao.setConnectionState(chainId, mapConnectionStateToLocal(state)) + awaitConnectionStateIsAtLeast(chainId, state) + } + + private fun syncChainsAndAssets() { + launch { + runCatching { + chainSyncService.syncUp() + evmAssetsSyncService.syncUp() + }.onFailure { + Log.e(LOG_TAG, "Failed to sync chains or assets", it) + } + } + } + + private suspend fun awaitConnectionStateIsAtLeast(chainId: ChainId, state: ConnectionState) { + chainsById + .mapNotNull { chainsById -> chainsById[chainId] } + .first { it.connectionState.level >= state.level } + } + + private fun unregisterChain(chain: Chain) { + unregisterSubstrateServices(chain) + unregisterConnections(chain.id) + } + + private suspend fun registerChain(chain: Chain) { + return when (chain.connectionState) { + ConnectionState.FULL_SYNC -> registerFullSyncChain(chain) + ConnectionState.LIGHT_SYNC -> registerLightSyncChain(chain) + ConnectionState.DISABLED -> registerDisabledChain(chain) + } + } + + private fun registerDisabledChain(chain: Chain) { + unregisterSubstrateServices(chain) + unregisterConnections(chain.id) + } + + private suspend fun registerLightSyncChain(chain: Chain) { + registerConnection(chain) + + unregisterSubstrateServices(chain) + } + + private suspend fun registerFullSyncChain(chain: Chain) { + val connection = registerConnection(chain) + + if (chain.hasSubstrateRuntime) { + runtimeProviderPool.setupRuntimeProvider(chain) + runtimeSyncService.registerChain(chain, connection) + runtimeSubscriptionPool.setupRuntimeSubscription(chain, connection) + } + } + + private suspend fun registerConnection(chain: Chain): ChainConnection { + val connection = connectionPool.setupConnection(chain) + + if (chain.isEthereumBased) { + web3ApiPool.setupWssApi(chain.id, connection.socketService) + web3ApiPool.setupHttpsApi(chain) + } + + return connection + } + + private fun syncBaseTypesIfNeeded() = launch { + val chains = currentChains.first() + val needToSyncBaseTypes = chains.any { it.typesUsage.requiresBaseTypes && it.connectionState.shouldSyncRuntime() } + + if (needToSyncBaseTypes) { + baseTypeSynchronizer.sync() + } + } + + private fun unregisterSubstrateServices(chain: Chain) { + if (chain.hasSubstrateRuntime) { + runtimeProviderPool.removeRuntimeProvider(chain.id) + runtimeSubscriptionPool.removeSubscription(chain.id) + runtimeSyncService.unregisterChain(chain.id) + } + } + + private fun unregisterConnections(chainId: ChainId) { + connectionPool.removeConnection(chainId) + web3ApiPool.removeApis(chainId) + } + + private fun ConnectionState.shouldSyncRuntime(): Boolean { + return isFullSync + } +} + +suspend fun ChainRegistry.getChainOrNull(chainId: String): Chain? { + return chainsById.first()[chainId.removeHexPrefix()] +} + +suspend fun ChainRegistry.chainWithAssetOrNull(chainId: String, assetId: Int): ChainWithAsset? { + val chain = getChainOrNull(chainId) ?: return null + val chainAsset = chain.assetsById[assetId] ?: return null + + return ChainWithAsset(chain, chainAsset) +} + +suspend fun ChainRegistry.enabledChainWithAssetOrNull(chainId: String, assetId: Int): ChainWithAsset? { + val chain = getChainOrNull(chainId).takeIf { it?.isEnabled == true } ?: return null + val chainAsset = chain.assetsById[assetId] ?: return null + + return ChainWithAsset(chain, chainAsset) +} + +suspend fun ChainRegistry.assetOrNull(fullChainAssetId: FullChainAssetId): Chain.Asset? { + val chain = getChainOrNull(fullChainAssetId.chainId) ?: return null + + return chain.assetsById[fullChainAssetId.assetId] +} + +suspend fun ChainRegistry.chainWithAsset(chainId: String, assetId: Int): ChainWithAsset { + val chain = chainsById.first().getValue(chainId) + + return ChainWithAsset(chain, chain.assetsById.getValue(assetId)) +} + +suspend fun ChainRegistry.chainWithAsset(fullChainAssetId: FullChainAssetId): ChainWithAsset { + return chainWithAsset(fullChainAssetId.chainId, fullChainAssetId.assetId) +} + +suspend fun ChainRegistry.asset(chainId: String, assetId: Int): Chain.Asset { + val chain = chainsById.first().getValue(chainId) + + return chain.assetsById.getValue(assetId) +} + +suspend fun ChainRegistry.asset(fullChainAssetId: FullChainAssetId): Chain.Asset { + return asset(fullChainAssetId.chainId, fullChainAssetId.assetId) +} + +fun ChainsById.assets(ids: Collection): List { + return ids.map { (chainId, assetId) -> + getValue(chainId).assetsById.getValue(assetId) + } +} + +suspend inline fun ChainRegistry.withRuntime(chainId: ChainId, action: RuntimeContext.() -> R): R { + return getRuntime(chainId).provideContext(action) +} + +suspend inline fun ChainRegistry.findChain(predicate: (Chain) -> Boolean): Chain? = currentChains.first().firstOrNull(predicate) +suspend inline fun ChainRegistry.findChains(predicate: (Chain) -> Boolean): List = currentChains.first().filter(predicate) + +suspend inline fun ChainRegistry.findEnabledChains(predicate: (Chain) -> Boolean): List = enabledChains().filter(predicate) + +suspend inline fun ChainRegistry.findChainIds(predicate: (Chain) -> Boolean): Set = currentChains.first().mapNotNullToSet { chain -> + chain.id.takeIf { predicate(chain) } +} + +suspend inline fun ChainRegistry.findChainsById(predicate: (Chain) -> Boolean): ChainsById { + return chainsById().filterValues { chain -> predicate(chain) }.asChainsById() +} + +suspend fun ChainRegistry.getRuntime(chainId: String) = getRuntimeProvider(chainId).get() + +suspend fun ChainRegistry.getRawMetadata(chainId: String) = getRuntimeProvider(chainId).getRaw() + +suspend fun ChainRegistry.getSocket(chainId: String): SocketService = getActiveConnection(chainId).socketService + +suspend fun ChainRegistry.getSocketOrNull(chainId: String): SocketService? = getActiveConnectionOrNull(chainId)?.socketService + +suspend fun ChainRegistry.getEthereumApiOrThrow(chainId: String, connectionType: ConnectionType): Web3Api { + return requireNotNull(getEthereumApi(chainId, connectionType)) { + "Ethereum Api is not found for chain $chainId and connection type ${connectionType.name}" + } +} + +suspend fun ChainRegistry.getSubscriptionEthereumApiOrThrow(chainId: String): Web3Api { + return getEthereumApiOrThrow(chainId, ConnectionType.WSS) +} + +suspend fun ChainRegistry.getSubscriptionEthereumApi(chainId: String): Web3Api? { + return getEthereumApi(chainId, ConnectionType.WSS) +} + +suspend fun ChainRegistry.getCallEthereumApiOrThrow(chainId: String): Web3Api { + return getEthereumApi(chainId, ConnectionType.HTTPS) + ?: getEthereumApiOrThrow(chainId, ConnectionType.WSS) +} + +suspend fun ChainRegistry.getCallEthereumApi(chainId: String): Web3Api? { + return getEthereumApi(chainId, ConnectionType.HTTPS) + ?: getEthereumApi(chainId, ConnectionType.WSS) +} + +suspend fun ChainRegistry.chainsById(): ChainsById = ChainsById(chainsById.first()) + +suspend fun ChainRegistry.findEvmChain(evmChainId: Int): Chain? { + return findChain { it.isEthereumBased && it.addressPrefix == evmChainId } +} + +suspend fun ChainRegistry.findEvmCallApi(evmChainId: Int): Web3Api? { + return findEvmChain(evmChainId)?.let { + getCallEthereumApi(it.id) + } +} + +suspend fun ChainRegistry.findEvmChainFromHexId(evmChainIdHex: String): Chain? { + val addressPrefix = evmChainIdHex.removeHexPrefix().toIntOrNull(radix = 16) ?: return null + + return findEvmChain(addressPrefix) +} + +suspend fun ChainRegistry.findRelayChainOrThrow(chainId: ChainId): ChainId { + val chain = getChain(chainId) + return chain.parentId ?: chainId +} + +fun ChainRegistry.chainFlow(chainId: ChainId): Flow { + return chainsById.mapNotNull { it[chainId] } +} + +fun ChainRegistry.enabledChainsFlow() = currentChains + .filterList { it.isEnabled } + +suspend fun ChainRegistry.enabledChains() = enabledChainsFlow().first() + +suspend fun ChainRegistry.disabledChains() = currentChains.filterList { it.isDisabled } + .first() + +fun ChainRegistry.enabledChainByIdFlow() = enabledChainsFlow().map { chains -> chains.associateBy { it.id } } + +suspend fun ChainRegistry.enabledChainById() = ChainsById(enabledChainByIdFlow().first()) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainsById.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainsById.kt new file mode 100644 index 0000000..03f821c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainsById.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.runtime.multiNetwork + +import io.novafoundation.nova.common.utils.removeHexPrefix +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId + +@JvmInline +value class ChainsById(val value: Map) : Map by value { + + override operator fun get(key: ChainId): Chain? { + return value[key.removeHexPrefix()] + } +} + +@Suppress("NOTHING_TO_INLINE") +inline fun Map.asChainsById(): ChainsById { + return ChainsById(this) +} + +fun ChainsById.assetOrNull(id: FullChainAssetId): Chain.Asset? { + return get(id.chainId)?.assetsById?.get(id.assetId) +} + +fun ChainsById.chainWithAssetOrNull(id: FullChainAssetId): ChainWithAsset? { + val chain = get(id.chainId) ?: return null + val asset = chain.assetsById[id.assetId] ?: return null + + return ChainWithAsset(chain, asset) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt new file mode 100644 index 0000000..36d84d8 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt @@ -0,0 +1,46 @@ +package io.novafoundation.nova.runtime.multiNetwork.asset + +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.retryUntilDone +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.ext.fullId +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal.Companion.ENABLED_DEFAULT_BOOL +import io.novafoundation.nova.runtime.multiNetwork.asset.remote.AssetFetcher +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapEVMAssetRemoteToLocalAssets +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class EvmAssetsSyncService( + private val chainDao: ChainDao, + private val chainAssetDao: ChainAssetDao, + private val chainFetcher: AssetFetcher, + private val gson: Gson, +) { + + suspend fun syncUp() = withContext(Dispatchers.Default) { + syncEVMAssets() + } + + private suspend fun syncEVMAssets() { + val availableChainIds = chainDao.getAllChainIds().toSet() + + val oldAssets = chainAssetDao.getAssetsBySource(AssetSourceLocal.ERC20) + val associatedOldAssets = oldAssets.associateBy { it.fullId() } + + val newAssets = retryUntilDone { chainFetcher.getEVMAssets() } + .flatMap { mapEVMAssetRemoteToLocalAssets(it, gson) } + .mapNotNull { new -> + // handle misconfiguration between chains.json and assets.json when assets contains asset for chain that is not present in chain + if (new.chainId !in availableChainIds) return@mapNotNull null + + val old = associatedOldAssets[new.fullId()] + new.copy(enabled = old?.enabled ?: ENABLED_DEFAULT_BOOL) + } + + val diff = CollectionDiffer.findDiff(newAssets, oldAssets, forceUseNewItems = false) + chainAssetDao.updateAssets(diff) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/remote/AssetFetcher.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/remote/AssetFetcher.kt new file mode 100644 index 0000000..9620df4 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/remote/AssetFetcher.kt @@ -0,0 +1,11 @@ +package io.novafoundation.nova.runtime.multiNetwork.asset.remote + +import io.novafoundation.nova.runtime.BuildConfig +import io.novafoundation.nova.runtime.multiNetwork.asset.remote.model.EVMAssetRemote +import retrofit2.http.GET + +interface AssetFetcher { + + @GET(BuildConfig.EVM_ASSETS_URL) + suspend fun getEVMAssets(): List +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/remote/model/EVMAssetRemote.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/remote/model/EVMAssetRemote.kt new file mode 100644 index 0000000..d43fc4e --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/remote/model/EVMAssetRemote.kt @@ -0,0 +1,10 @@ +package io.novafoundation.nova.runtime.multiNetwork.asset.remote.model + +data class EVMAssetRemote( + val symbol: String, + val precision: Int, + val priceId: String?, + val name: String, + val icon: String?, + val instances: List +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/remote/model/EVMInstanceRemote.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/remote/model/EVMInstanceRemote.kt new file mode 100644 index 0000000..a13981e --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/remote/model/EVMInstanceRemote.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.runtime.multiNetwork.asset.remote.model + +class EVMInstanceRemote( + val chainId: String, + val contractAddress: String, + val buyProviders: Map>?, + val sellProviders: Map>? +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt new file mode 100644 index 0000000..057cbea --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt @@ -0,0 +1,87 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain + +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.retryUntilDone +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.dao.FullAssetIdLocal +import io.novafoundation.nova.core_db.ext.fullId +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal.Companion.ENABLED_DEFAULT_BOOL +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapExternalApisToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteAssetToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteChainToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteExplorersToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteNodesToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ChainSyncService( + private val chainDao: ChainDao, + private val chainFetcher: ChainFetcher, + private val gson: Gson +) { + + suspend fun syncUp() = withContext(Dispatchers.Default) { + val localChainsJoinedInfo = chainDao.getJoinChainInfo().filter { it.chain.source != ChainLocal.Source.CUSTOM } + val oldChains = localChainsJoinedInfo.map { it.chain } + val oldAssets = localChainsJoinedInfo.flatMap { it.assets }.filter { it.source == AssetSourceLocal.DEFAULT } + val oldNodes = localChainsJoinedInfo.flatMap { it.nodes }.filter { it.source != ChainNodeLocal.Source.CUSTOM } + val oldExplorers = localChainsJoinedInfo.flatMap { it.explorers } + val oldExternalApis = localChainsJoinedInfo.flatMap { it.externalApis } + val oldNodeSelectionPreferences = localChainsJoinedInfo.mapNotNull { it.nodeSelectionPreferences } + + val oldChainsById = oldChains.associateBy { it.id } + val associatedOldAssets = oldAssets.associateBy { it.fullId() } + + val remoteChains = retryUntilDone { chainFetcher.getChains() } + + val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) } + val newAssets = remoteChains.flatMap { chain -> + chain.assets.map { + val fullAssetId = FullAssetIdLocal(chain.chainId, it.assetId) + val oldAsset = associatedOldAssets[fullAssetId] + mapRemoteAssetToLocal(chain, it, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL) + } + } + val newNodes = remoteChains.flatMap(::mapRemoteNodesToLocal) + val newExplorers = remoteChains.flatMap(::mapRemoteExplorersToLocal) + val newExternalApis = remoteChains.flatMap(::mapExternalApisToLocal) + val newNodeSelectionPreferences = nodeSelectionPreferencesFor(newChains, oldNodeSelectionPreferences) + + val chainsDiff = CollectionDiffer.findDiff(newChains, oldChains, forceUseNewItems = false) + val assetDiff = CollectionDiffer.findDiff(newAssets, oldAssets, forceUseNewItems = false) + val nodesDiff = CollectionDiffer.findDiff(newNodes, oldNodes, forceUseNewItems = false) + val explorersDiff = CollectionDiffer.findDiff(newExplorers, oldExplorers, forceUseNewItems = false) + val externalApisDiff = CollectionDiffer.findDiff(newExternalApis, oldExternalApis, forceUseNewItems = false) + val nodeSelectionPreferencesDiff = CollectionDiffer.findDiff(newNodeSelectionPreferences, oldNodeSelectionPreferences, forceUseNewItems = false) + + chainDao.applyDiff( + chainDiff = chainsDiff, + assetsDiff = assetDiff, + nodesDiff = nodesDiff, + explorersDiff = explorersDiff, + externalApisDiff = externalApisDiff, + nodeSelectionPreferencesDiff = nodeSelectionPreferencesDiff + ) + } + + private fun nodeSelectionPreferencesFor( + newChains: List, + oldNodeSelectionPreferences: List + ): List { + val preferencesById = oldNodeSelectionPreferences.associateBy { it.chainId } + return newChains.map { + preferencesById[it.id] + ?: NodeSelectionPreferencesLocal( + chainId = it.id, + autoBalanceEnabled = NodeSelectionPreferencesLocal.DEFAULT_AUTO_BALANCE_BOOLEAN, + selectedNodeUrl = null + ) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt new file mode 100644 index 0000000..ef131be --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers + +const val ASSET_NATIVE = "native" +const val ASSET_STATEMINE = "statemine" +const val ASSET_ORML = "orml" +const val ASSET_ORML_HYDRATION_EVM = "orml-hydration-evm" +const val ASSET_UNSUPPORTED = "unsupported" + +const val ASSET_EVM_ERC20 = "evm" +const val ASSET_EVM_NATIVE = "evmNative" + +const val ASSET_EQUILIBRIUM = "equilibrium" +const val ASSET_EQUILIBRIUM_ON_CHAIN_ID = "assetId" + +const val STATEMINE_EXTRAS_ID = "assetId" +const val STATEMINE_EXTRAS_PALLET_NAME = "palletName" +const val STATEMINE_IS_SUFFICIENT = "isSufficient" + +const val STATEMINE_IS_SUFFICIENT_DEFAULT = false + +const val ORML_EXTRAS_CURRENCY_ID_SCALE = "currencyIdScale" +const val ORML_EXTRAS_CURRENCY_TYPE = "currencyIdType" +const val ORML_EXTRAS_EXISTENTIAL_DEPOSIT = "existentialDeposit" +const val ORML_EXTRAS_TRANSFERS_ENABLED = "transfersEnabled" + +const val EVM_EXTRAS_CONTRACT_ADDRESS = "contractAddress" + +const val ORML_TRANSFERS_ENABLED_DEFAULT = true diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/CommonChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/CommonChainMapper.kt new file mode 100644 index 0000000..2628b33 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/CommonChainMapper.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun mapSwapListToLocal(swap: List) = swap.joinToString(separator = ",", transform = Chain.Swap::name) + +fun mapGovernanceListToLocal(governance: List) = governance.joinToString(separator = ",", transform = Chain.Governance::name) + +fun mapCustomFeeToLocal(customFees: List) = customFees.joinToString(separator = ",", transform = Chain.CustomFee::name) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt new file mode 100644 index 0000000..5bd511e --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt @@ -0,0 +1,287 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers + +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.asGsonParsedNumber +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainExplorerLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal.SourceType +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal.Companion.EMPTY_CHAIN_ICON +import io.novafoundation.nova.core_db.model.chain.ChainLocal.ConnectionStateLocal +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import io.novafoundation.nova.runtime.ext.autoBalanceEnabled +import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils.EVM_TRANSFER_PARAMETER +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils.GovernanceReferendaParameters +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils.SUBSTRATE_TRANSFER_PARAMETER +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils.TransferParameters +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ConnectionState +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ExternalApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.StatemineAssetId + +fun mapStakingTypeToLocal(stakingType: Chain.Asset.StakingType): String = stakingType.name + +fun mapStakingTypesToLocal(stakingTypes: List): String { + return stakingTypes.joinToString(separator = ",", transform = ::mapStakingTypeToLocal) +} + +private fun mapAssetSourceToLocal(source: Chain.Asset.Source): AssetSourceLocal { + return when (source) { + Chain.Asset.Source.DEFAULT -> AssetSourceLocal.DEFAULT + Chain.Asset.Source.ERC20 -> AssetSourceLocal.ERC20 + Chain.Asset.Source.MANUAL -> AssetSourceLocal.MANUAL + } +} + +fun mapChainAssetTypeToRaw(type: Chain.Asset.Type): Pair?> = when (type) { + is Chain.Asset.Type.Native -> ASSET_NATIVE to null + + is Chain.Asset.Type.Statemine -> ASSET_STATEMINE to mapOf( + STATEMINE_EXTRAS_ID to mapStatemineAssetIdToRaw(type.id), + STATEMINE_EXTRAS_PALLET_NAME to type.palletName, + STATEMINE_IS_SUFFICIENT to type.isSufficient + ) + + is Chain.Asset.Type.Orml -> ASSET_ORML to mapOf( + ORML_EXTRAS_CURRENCY_ID_SCALE to type.currencyIdScale, + ORML_EXTRAS_CURRENCY_TYPE to type.currencyIdType, + ORML_EXTRAS_EXISTENTIAL_DEPOSIT to type.existentialDeposit.toString(), + ORML_EXTRAS_TRANSFERS_ENABLED to type.transfersEnabled + ) + + is Chain.Asset.Type.EvmErc20 -> ASSET_EVM_ERC20 to mapOf( + EVM_EXTRAS_CONTRACT_ADDRESS to type.contractAddress + ) + + is Chain.Asset.Type.EvmNative -> ASSET_EVM_NATIVE to null + + is Chain.Asset.Type.Equilibrium -> ASSET_EQUILIBRIUM to mapOf( + ASSET_EQUILIBRIUM_ON_CHAIN_ID to type.id.toString() + ) + + Chain.Asset.Type.Unsupported -> ASSET_UNSUPPORTED to null +} + +private fun mapStatemineAssetIdToRaw(statemineAssetId: StatemineAssetId): String { + return when (statemineAssetId) { + is StatemineAssetId.Number -> statemineAssetId.value.toString() + is StatemineAssetId.ScaleEncoded -> statemineAssetId.scaleHex + } +} + +fun mapStatemineAssetIdFromRaw(rawValue: Any): StatemineAssetId { + val asString = rawValue as? String ?: error("Invalid format") + + return if (asString.startsWith("0x")) { + StatemineAssetId.ScaleEncoded(asString) + } else { + StatemineAssetId.Number(asString.asGsonParsedNumber()) + } +} + +fun mapChainAssetToLocal(asset: Chain.Asset, gson: Gson): ChainAssetLocal { + val (type, typeExtras) = mapChainAssetTypeToRaw(asset.type) + + return ChainAssetLocal( + id = asset.id, + symbol = asset.symbol.value, + precision = asset.precision.value, + chainId = asset.chainId, + name = asset.name, + priceId = asset.priceId, + staking = mapStakingTypesToLocal(asset.staking), + type = type, + source = mapAssetSourceToLocal(asset.source), + buyProviders = gson.toJson(asset.buyProviders), + sellProviders = gson.toJson(asset.sellProviders), + typeExtras = gson.toJson(typeExtras), + icon = asset.icon, + enabled = asset.enabled + ) +} + +fun mapChainToLocal(chain: Chain, gson: Gson): ChainLocal { + val types = chain.types?.let { + ChainLocal.TypesConfig( + url = it.url.orEmpty(), + overridesCommon = it.overridesCommon + ) + } + + return ChainLocal( + id = chain.id, + parentId = chain.parentId, + name = chain.name, + icon = chain.icon ?: EMPTY_CHAIN_ICON, + types = types, + prefix = chain.addressPrefix, + legacyPrefix = chain.legacyAddressPrefix, + isEthereumBased = chain.isEthereumBased, + isTestNet = chain.isTestNet, + hasSubstrateRuntime = chain.hasSubstrateRuntime, + pushSupport = chain.pushSupport, + hasCrowdloans = chain.hasCrowdloans, + multisigSupport = chain.multisigSupport, + supportProxy = chain.supportProxy, + swap = mapSwapListToLocal(chain.swap), + customFee = mapCustomFeeToLocal(chain.customFee), + governance = mapGovernanceListToLocal(chain.governance), + additional = chain.additional?.let { gson.toJson(it) }, + connectionState = mapConnectionStateToLocal(chain.connectionState), + nodeSelectionStrategy = mapNodeSelectionStrategyToLocal(chain), + source = mapChainSourceToLocal(chain.source) + ) +} + +fun mapChainNodeToLocal(node: Chain.Node): ChainNodeLocal { + return ChainNodeLocal( + chainId = node.chainId, + url = node.unformattedUrl, + name = node.name, + orderId = node.orderId, + source = if (node.isCustom) ChainNodeLocal.Source.CUSTOM else ChainNodeLocal.Source.DEFAULT, + ) +} + +fun mapChainExplorerToLocal(explorer: Chain.Explorer): ChainExplorerLocal { + return ChainExplorerLocal( + chainId = explorer.chainId, + name = explorer.name, + extrinsic = explorer.extrinsic, + account = explorer.account, + event = explorer.event, + ) +} + +fun mapNodeSelectionPreferencesToLocal(chain: Chain): NodeSelectionPreferencesLocal { + return NodeSelectionPreferencesLocal( + chainId = chain.id, + autoBalanceEnabled = chain.autoBalanceEnabled, + selectedNodeUrl = chain.selectedUnformattedWssNodeUrlOrNull + ) +} + +fun mapNodeSelectionStrategyToLocal(domain: Chain): ChainLocal.AutoBalanceStrategyLocal { + return when (domain.nodes.autoBalanceStrategy) { + Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN -> ChainLocal.AutoBalanceStrategyLocal.ROUND_ROBIN + Chain.Nodes.AutoBalanceStrategy.UNIFORM -> ChainLocal.AutoBalanceStrategyLocal.UNIFORM + } +} + +fun mapChainSourceToLocal(domain: Chain.Source): ChainLocal.Source { + return when (domain) { + Chain.Source.CUSTOM -> ChainLocal.Source.CUSTOM + Chain.Source.DEFAULT -> ChainLocal.Source.DEFAULT + } +} + +fun mapConnectionStateToLocal(domain: ConnectionState): ConnectionStateLocal { + return when (domain) { + ConnectionState.FULL_SYNC -> ConnectionStateLocal.FULL_SYNC + ConnectionState.LIGHT_SYNC -> ConnectionStateLocal.LIGHT_SYNC + ConnectionState.DISABLED -> ConnectionStateLocal.DISABLED + } +} + +fun mapChainExternalApiToLocal(gson: Gson, chainId: String, api: ExternalApi): ChainExternalApiLocal { + return when (api) { + is ExternalApi.Transfers -> mapExternalApiTransfers(gson, chainId, api) + is ExternalApi.Crowdloans -> mapExternalApiCrowdloans(chainId, api) + is ExternalApi.GovernanceDelegations -> mapExternalApiGovernanceDelegations(chainId, api) + is ExternalApi.GovernanceReferenda -> mapExternalApiGovernanceReferenda(gson, chainId, api) + is ExternalApi.Staking -> mapExternalApiStaking(chainId, api) + is ExternalApi.StakingRewards -> mapExternalApiStakingRewards(chainId, api) + is ExternalApi.ReferendumSummary -> mapExternalApiReferendumSummary(chainId, api) + } +} + +private fun mapExternalApiTransfers(gson: Gson, chainId: String, api: ExternalApi.Transfers): ChainExternalApiLocal { + fun transferParametersByType(gson: Gson, type: String): String { + return gson.toJson(TransferParameters(type)) + } + + val parameters = when (api) { + is ExternalApi.Transfers.Evm -> transferParametersByType(gson, EVM_TRANSFER_PARAMETER) + is ExternalApi.Transfers.Substrate -> transferParametersByType(gson, SUBSTRATE_TRANSFER_PARAMETER) + } + + return ChainExternalApiLocal( + chainId = chainId, + sourceType = SourceType.UNKNOWN, + apiType = ChainExternalApiLocal.ApiType.TRANSFERS, + parameters = parameters, + url = api.url + ) +} + +private fun mapExternalApiCrowdloans(chainId: String, api: ExternalApi.Crowdloans): ChainExternalApiLocal { + return ChainExternalApiLocal( + chainId = chainId, + sourceType = SourceType.GITHUB, + apiType = ChainExternalApiLocal.ApiType.CROWDLOANS, + parameters = null, + url = api.url + ) +} + +private fun mapExternalApiGovernanceDelegations(chainId: String, api: ExternalApi.GovernanceDelegations): ChainExternalApiLocal { + return ChainExternalApiLocal( + chainId = chainId, + sourceType = SourceType.SUBQUERY, + apiType = ChainExternalApiLocal.ApiType.GOVERNANCE_DELEGATIONS, + parameters = null, + url = api.url + ) +} + +private fun mapExternalApiGovernanceReferenda(gson: Gson, chainId: String, api: ExternalApi.GovernanceReferenda): ChainExternalApiLocal { + val (source, parameters) = when (api.source) { + ExternalApi.GovernanceReferenda.Source.SubSquare -> SourceType.SUBSQUARE to null + + is ExternalApi.GovernanceReferenda.Source.Polkassembly -> { + SourceType.POLKASSEMBLY to gson.toJson(GovernanceReferendaParameters(api.source.network)) + } + } + + return ChainExternalApiLocal( + chainId = chainId, + sourceType = source, + apiType = ChainExternalApiLocal.ApiType.GOVERNANCE_REFERENDA, + parameters = parameters, + url = api.url + ) +} + +private fun mapExternalApiStaking(chainId: String, api: ExternalApi.Staking): ChainExternalApiLocal { + return ChainExternalApiLocal( + chainId = chainId, + sourceType = SourceType.SUBQUERY, + apiType = ChainExternalApiLocal.ApiType.STAKING, + parameters = null, + url = api.url + ) +} + +fun mapExternalApiStakingRewards(chainId: String, api: ExternalApi.StakingRewards): ChainExternalApiLocal { + return ChainExternalApiLocal( + chainId = chainId, + sourceType = SourceType.SUBQUERY, + apiType = ChainExternalApiLocal.ApiType.STAKING_REWARDS, + parameters = null, + url = api.url + ) +} + +private fun mapExternalApiReferendumSummary(chainId: String, api: ExternalApi.ReferendumSummary): ChainExternalApiLocal { + return ChainExternalApiLocal( + chainId = chainId, + sourceType = SourceType.UNKNOWN, + apiType = ChainExternalApiLocal.ApiType.REFERENDUM_SUMMARY, + parameters = null, + url = api.url + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/EVMAssetMappers.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/EVMAssetMappers.kt new file mode 100644 index 0000000..ff45691 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/EVMAssetMappers.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers + +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.ethereumAddressToAccountId +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.runtime.multiNetwork.asset.remote.model.EVMAssetRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun chainAssetIdOfErc20Token(contractAddress: String): Int { + return contractAddress.ethereumAddressToAccountId() + .contentHashCode() +} + +fun mapEVMAssetRemoteToLocalAssets(evmAssetRemote: EVMAssetRemote, gson: Gson): List { + return evmAssetRemote.instances.map { + val assetId = chainAssetIdOfErc20Token(it.contractAddress) + + val domainType = Chain.Asset.Type.EvmErc20(it.contractAddress) + val (type, typeExtras) = mapChainAssetTypeToRaw(domainType) + + ChainAssetLocal( + id = assetId, + chainId = it.chainId, + symbol = evmAssetRemote.symbol, + name = evmAssetRemote.name, + precision = evmAssetRemote.precision, + priceId = evmAssetRemote.priceId, + icon = evmAssetRemote.icon, + staking = mapStakingTypeToLocal(Chain.Asset.StakingType.UNSUPPORTED), + source = AssetSourceLocal.ERC20, + type = type, + buyProviders = gson.toJson(it.buyProviders), + sellProviders = gson.toJson(it.sellProviders), + typeExtras = gson.toJson(typeExtras), + enabled = true + ) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt new file mode 100644 index 0000000..14f1333 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt @@ -0,0 +1,341 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers + +import android.util.Log +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.asPrecision +import io.novafoundation.nova.common.utils.asTokenSymbol +import io.novafoundation.nova.common.utils.enumValueOfOrNull +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.common.utils.fromJsonOrNull +import io.novafoundation.nova.common.utils.nullIfEmpty +import io.novafoundation.nova.common.utils.parseArbitraryObject +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainExplorerLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal.ApiType +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal.SourceType +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal.AutoBalanceStrategyLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal.ConnectionStateLocal +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.JoinedChainInfo +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils.EVM_TRANSFER_PARAMETER +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils.GovernanceReferendaParameters +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils.SUBSTRATE_TRANSFER_PARAMETER +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils.TransferParameters +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ConnectionState +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ExternalApi +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Nodes.AutoBalanceStrategy +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Nodes.NodeSelectionStrategy +import io.novafoundation.nova.runtime.multiNetwork.chain.model.TradeProviderArguments +import io.novafoundation.nova.runtime.multiNetwork.chain.model.TradeProviderId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type.Orml.SubType as OrmlSubType + +private fun mapStakingTypeFromLocal(stakingTypesLocal: String): List { + if (stakingTypesLocal.isEmpty()) return emptyList() + + return stakingTypesLocal.split(",").mapNotNull { enumValueOfOrNull(it) } +} + +fun mapAssetSourceFromLocal(source: AssetSourceLocal): Chain.Asset.Source { + return when (source) { + AssetSourceLocal.DEFAULT -> Chain.Asset.Source.DEFAULT + AssetSourceLocal.ERC20 -> Chain.Asset.Source.ERC20 + AssetSourceLocal.MANUAL -> Chain.Asset.Source.MANUAL + } +} + +private inline fun unsupportedOnError(creator: () -> Chain.Asset.Type): Chain.Asset.Type { + return runCatching(creator) + .onFailure { Log.e("ChainMapper", "Failed to construct chain type", it) } + .getOrDefault(Chain.Asset.Type.Unsupported) +} + +private fun mapChainAssetTypeFromRaw(type: String?, typeExtras: Map?): Chain.Asset.Type = unsupportedOnError { + when (type) { + null, ASSET_NATIVE -> Chain.Asset.Type.Native + + ASSET_STATEMINE -> { + val idRaw = typeExtras?.get(STATEMINE_EXTRAS_ID)!! + val id = mapStatemineAssetIdFromRaw(idRaw) + val palletName = typeExtras[STATEMINE_EXTRAS_PALLET_NAME] as String? + val isSufficient = typeExtras[STATEMINE_IS_SUFFICIENT] as Boolean? ?: STATEMINE_IS_SUFFICIENT_DEFAULT + + Chain.Asset.Type.Statemine(id, palletName, isSufficient) + } + + ASSET_ORML, ASSET_ORML_HYDRATION_EVM -> { + Chain.Asset.Type.Orml( + currencyIdScale = typeExtras!![ORML_EXTRAS_CURRENCY_ID_SCALE] as String, + currencyIdType = typeExtras[ORML_EXTRAS_CURRENCY_TYPE] as String, + existentialDeposit = (typeExtras[ORML_EXTRAS_EXISTENTIAL_DEPOSIT] as String).toBigInteger(), + transfersEnabled = typeExtras[ORML_EXTRAS_TRANSFERS_ENABLED] as Boolean? ?: ORML_TRANSFERS_ENABLED_DEFAULT, + subType = determineOrmlSubtype(type) + ) + } + + ASSET_EVM_ERC20 -> { + Chain.Asset.Type.EvmErc20( + contractAddress = typeExtras!![EVM_EXTRAS_CONTRACT_ADDRESS] as String + ) + } + + ASSET_EVM_NATIVE -> Chain.Asset.Type.EvmNative + + ASSET_EQUILIBRIUM -> Chain.Asset.Type.Equilibrium((typeExtras!![ASSET_EQUILIBRIUM_ON_CHAIN_ID] as String).toBigInteger()) + + else -> Chain.Asset.Type.Unsupported + } +} + +private fun determineOrmlSubtype(type: String): OrmlSubType { + return when (type) { + ASSET_ORML -> OrmlSubType.DEFAULT + ASSET_ORML_HYDRATION_EVM -> OrmlSubType.HYDRATION_EVM + else -> error("Unknown orml token subtype: $type") + } +} + +private fun ChainExternalApiLocal.ensureSourceType( + type: SourceType, + action: ChainExternalApiLocal.() -> T +): T? { + return if (sourceType == type) { + action() + } else { + null + } +} + +private inline fun ChainExternalApiLocal.parsedParameters(gson: Gson): T? { + return parameters?.let { gson.fromJson(it) } +} + +private fun mapTransferApiFromLocal(local: ChainExternalApiLocal, gson: Gson): ExternalApi.Transfers? { + val parameters = local.parsedParameters(gson) + + return when (parameters?.assetType) { + null, SUBSTRATE_TRANSFER_PARAMETER -> ExternalApi.Transfers.Substrate(local.url) + EVM_TRANSFER_PARAMETER -> ExternalApi.Transfers.Evm(local.url) + else -> null + } +} + +private fun mapGovernanceReferendaApiFromLocal(local: ChainExternalApiLocal, gson: Gson): ExternalApi.GovernanceReferenda? { + val source = when (local.sourceType) { + SourceType.SUBSQUARE -> ExternalApi.GovernanceReferenda.Source.SubSquare + + SourceType.POLKASSEMBLY -> { + val parameters = local.parsedParameters(gson) + ExternalApi.GovernanceReferenda.Source.Polkassembly(parameters?.network) + } + + else -> return null + } + + return ExternalApi.GovernanceReferenda(local.url, source) +} + +private fun mapExternalApiLocalToExternalApi(externalApiLocal: ChainExternalApiLocal, gson: Gson): ExternalApi? = runCatching { + when (externalApiLocal.apiType) { + ApiType.STAKING -> externalApiLocal.ensureSourceType(SourceType.SUBQUERY) { + ExternalApi.Staking(externalApiLocal.url) + } + + ApiType.STAKING_REWARDS -> externalApiLocal.ensureSourceType(SourceType.SUBQUERY) { + ExternalApi.StakingRewards(externalApiLocal.url) + } + + ApiType.CROWDLOANS -> externalApiLocal.ensureSourceType(SourceType.GITHUB) { + ExternalApi.Crowdloans(externalApiLocal.url) + } + + ApiType.TRANSFERS -> mapTransferApiFromLocal(externalApiLocal, gson) + + ApiType.GOVERNANCE_REFERENDA -> mapGovernanceReferendaApiFromLocal(externalApiLocal, gson) + + ApiType.GOVERNANCE_DELEGATIONS -> externalApiLocal.ensureSourceType(SourceType.SUBQUERY) { + ExternalApi.GovernanceDelegations(externalApiLocal.url) + } + + ApiType.REFERENDUM_SUMMARY -> { + ExternalApi.ReferendumSummary(externalApiLocal.url) + } + + ApiType.UNKNOWN -> null + } +}.getOrNull() + +private fun mapAutoBalanceStrategyFromLocal(local: AutoBalanceStrategyLocal): AutoBalanceStrategy { + return when (local) { + AutoBalanceStrategyLocal.ROUND_ROBIN -> AutoBalanceStrategy.ROUND_ROBIN + AutoBalanceStrategyLocal.UNIFORM -> AutoBalanceStrategy.UNIFORM + AutoBalanceStrategyLocal.UNKNOWN -> AutoBalanceStrategy.ROUND_ROBIN + } +} + +private fun mapNodeSelectionFromLocal(nodeSelectionPreferencesLocal: NodeSelectionPreferencesLocal?): NodeSelectionStrategy { + if (nodeSelectionPreferencesLocal == null) return NodeSelectionStrategy.AutoBalance + + val selectedUnformattedWssUrl = nodeSelectionPreferencesLocal.selectedUnformattedWssNodeUrl + + return if (selectedUnformattedWssUrl != null && !nodeSelectionPreferencesLocal.autoBalanceEnabled) { + NodeSelectionStrategy.SelectedNode(selectedUnformattedWssUrl) + } else { + NodeSelectionStrategy.AutoBalance + } +} + +fun mapChainLocalToChain(chainLocal: JoinedChainInfo, gson: Gson): Chain { + return mapChainLocalToChain( + chainLocal.chain, + chainLocal.nodes, + chainLocal.nodeSelectionPreferences, + chainLocal.assets, + chainLocal.explorers, + chainLocal.externalApis, + gson + ) +} + +fun mapChainLocalToChain( + chainLocal: ChainLocal, + nodesLocal: List, + nodeSelectionPreferences: NodeSelectionPreferencesLocal?, + assetsLocal: List, + explorersLocal: List, + externalApisLocal: List, + gson: Gson +): Chain { + val nodes = nodesLocal.sortedBy { it.orderId }.map { + Chain.Node( + unformattedUrl = it.url, + name = it.name, + chainId = it.chainId, + orderId = it.orderId, + isCustom = it.source == ChainNodeLocal.Source.CUSTOM, + ) + } + + val nodesConfig = Chain.Nodes( + autoBalanceStrategy = mapAutoBalanceStrategyFromLocal(chainLocal.autoBalanceStrategy), + wssNodeSelectionStrategy = mapNodeSelectionFromLocal(nodeSelectionPreferences), + nodes = nodes + ) + + val assets = assetsLocal.map { mapChainAssetLocalToAsset(it, gson) } + + val explorers = explorersLocal.map { + Chain.Explorer( + name = it.name, + account = it.account, + extrinsic = it.extrinsic, + event = it.event, + chainId = it.chainId + ) + } + + val types = chainLocal.types?.let { + Chain.Types( + url = it.url.nullIfEmpty(), + overridesCommon = it.overridesCommon + ) + } + + val externalApis = externalApisLocal.mapNotNull { + mapExternalApiLocalToExternalApi(it, gson) + } + + val additional = chainLocal.additional?.let { raw -> + gson.fromJson(raw) + } + + return with(chainLocal) { + Chain( + id = id, + parentId = parentId, + name = name, + assets = assets, + types = types, + nodes = nodesConfig, + explorers = explorers, + icon = icon.takeIf { it.isNotBlank() }, + externalApis = externalApis, + addressPrefix = prefix, + legacyAddressPrefix = legacyPrefix, + isEthereumBased = isEthereumBased, + isTestNet = isTestNet, + hasCrowdloans = hasCrowdloans, + pushSupport = pushSupport, + supportProxy = supportProxy, + multisigSupport = multisigSupport, + hasSubstrateRuntime = hasSubstrateRuntime, + governance = mapGovernanceListFromLocal(governance), + swap = mapSwapListFromLocal(swap), + customFee = mapCustomFeeFromLocal(customFee), + connectionState = mapConnectionStateFromLocal(connectionState), + additional = additional, + source = mapSourceFromLocal(source) + ) + } +} + +fun mapChainAssetLocalToAsset(local: ChainAssetLocal, gson: Gson): Chain.Asset { + val typeExtrasParsed = local.typeExtras?.let(gson::parseArbitraryObject) + val buyProviders = local.buyProviders?.let?>(gson::fromJsonOrNull).orEmpty() + val sellProviders = local.sellProviders?.let?>(gson::fromJsonOrNull).orEmpty() + + return Chain.Asset( + icon = local.icon, + id = local.id, + symbol = local.symbol.asTokenSymbol(), + precision = local.precision.asPrecision(), + name = local.name, + chainId = local.chainId, + priceId = local.priceId, + buyProviders = buyProviders, + sellProviders = sellProviders, + staking = mapStakingTypeFromLocal(local.staking), + type = mapChainAssetTypeFromRaw(local.type, typeExtrasParsed), + source = mapAssetSourceFromLocal(local.source), + enabled = local.enabled + ) +} + +private fun mapSourceFromLocal(local: ChainLocal.Source): Chain.Source { + return when (local) { + ChainLocal.Source.DEFAULT -> Chain.Source.DEFAULT + ChainLocal.Source.CUSTOM -> Chain.Source.CUSTOM + } +} + +private fun mapConnectionStateFromLocal(local: ConnectionStateLocal): ConnectionState { + return when (local) { + ConnectionStateLocal.FULL_SYNC -> ConnectionState.FULL_SYNC + ConnectionStateLocal.LIGHT_SYNC -> ConnectionState.LIGHT_SYNC + ConnectionStateLocal.DISABLED -> ConnectionState.DISABLED + } +} + +private fun mapGovernanceListFromLocal(governanceLocal: String) = governanceLocal.split(",").mapNotNull { + runCatching { Chain.Governance.valueOf(it) }.getOrNull() +} + +private fun mapSwapListFromLocal(swapLocal: String): List { + if (swapLocal.isEmpty()) return emptyList() + + return swapLocal.split(",").mapNotNull { + enumValueOfOrNull(swapLocal) + } +} + +private fun mapCustomFeeFromLocal(customFee: String): List { + if (customFee.isEmpty()) return emptyList() + + return customFee.split(",").mapNotNull { + enumValueOfOrNull(it) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToDomainChainMapperFacade.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToDomainChainMapperFacade.kt new file mode 100644 index 0000000..0dcaa94 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToDomainChainMapperFacade.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers + +import com.google.gson.Gson +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote + +class RemoteToDomainChainMapperFacade( + private val gson: Gson +) { + + fun mapRemoteChainToDomain(chainRemote: ChainRemote, source: Chain.Source): Chain { + val localSource = when (source) { + Chain.Source.DEFAULT -> ChainLocal.Source.DEFAULT + Chain.Source.CUSTOM -> ChainLocal.Source.CUSTOM + } + val chainLocal = mapRemoteChainToLocal(chainRemote, null, localSource, gson) + val assetsLocal = chainRemote.assets.map { mapRemoteAssetToLocal(chainRemote, it, gson, isEnabled = true) } + val nodesLocal = mapRemoteNodesToLocal(chainRemote) + val explorersLocal = mapRemoteExplorersToLocal(chainRemote) + val externalApisLocal = mapExternalApisToLocal(chainRemote) + + return mapChainLocalToChain( + chainLocal = chainLocal, + nodesLocal = nodesLocal, + nodeSelectionPreferences = NodeSelectionPreferencesLocal(chainLocal.id, autoBalanceEnabled = true, selectedNodeUrl = null), + assetsLocal = assetsLocal, + explorersLocal = explorersLocal, + externalApisLocal = externalApisLocal, + gson = gson + ) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToDomainLightChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToDomainLightChainMapper.kt new file mode 100644 index 0000000..4f46b6b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToDomainLightChainMapper.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.LightChain +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.LightChainRemote + +fun mapRemoteToDomainLightChain(chain: LightChainRemote): LightChain { + return LightChain( + id = chain.chainId, + name = chain.name, + icon = chain.icon, + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt new file mode 100644 index 0000000..ac1d809 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt @@ -0,0 +1,301 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers + +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.asGsonParsedIntOrNull +import io.novafoundation.nova.common.utils.asGsonParsedLongOrNull +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainExplorerLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal.ApiType +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal.SourceType +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal.AutoBalanceStrategyLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal.Companion.EMPTY_CHAIN_ICON +import io.novafoundation.nova.core_db.model.chain.ChainLocal.ConnectionStateLocal +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainAssetRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote + +private const val ETHEREUM_OPTION = "ethereumBased" +private const val CROWDLOAN_OPTION = "crowdloans" +private const val TESTNET_OPTION = "testnet" +private const val PROXY_OPTION = "proxy" + +private const val MULTISIG_SUPPORT = "multisig" +private const val SWAP_HUB = "swap-hub" +private const val HYDRA_DX_SWAPS = "hydradx-swaps" +private const val NO_SUBSTRATE_RUNTIME = "noSubstrateRuntime" +private const val FULL_SYNC_BY_DEFAULT = "fullSyncByDefault" +private const val PUSH_SUPPORT = "pushSupport" +private const val CUSTOM_FEE_ASSET_HUB = "assethub-fees" +private const val CUSTOM_FEE_HYDRA_DX = "hydration-fees" + +private const val CHAIN_ADDITIONAL_TIP = "defaultTip" +private const val CHAIN_THEME_COLOR = "themeColor" +private const val CHAIN_STAKING_WIKI = "stakingWiki" +private const val DEFAULT_BLOCK_TIME = "defaultBlockTime" +private const val RELAYCHAIN_AS_NATIVE = "relaychainAsNative" +private const val MAX_ELECTING_VOTES = "stakingMaxElectingVoters" +private const val FEE_VIA_RUNTIME_CALL = "feeViaRuntimeCall" +private const val SUPPORT_GENERIC_LEDGER_APP = "supportsGenericLedgerApp" +private const val IDENTITY_CHAIN = "identityChain" +private const val DISABLED_CHECK_METADATA_HASH = "disabledCheckMetadataHash" +private const val SESSION_LENGTH = "sessionLength" +private const val SESSIONS_PER_ERA = "sessionsPerEra" +private const val TIMELINE_CHAIN = "timelineChain" + +fun mapRemoteChainToLocal( + chainRemote: ChainRemote, + oldChain: ChainLocal?, + source: ChainLocal.Source, + gson: Gson +): ChainLocal { + val types = chainRemote.types?.let { + ChainLocal.TypesConfig( + url = it.url.orEmpty(), + overridesCommon = it.overridesCommon + ) + } + + val additional = chainRemote.additional?.let { + Chain.Additional( + defaultTip = (it[CHAIN_ADDITIONAL_TIP] as? String)?.toBigInteger(), + themeColor = (it[CHAIN_THEME_COLOR] as? String), + stakingWiki = (it[CHAIN_STAKING_WIKI] as? String), + defaultBlockTimeMillis = it[DEFAULT_BLOCK_TIME].asGsonParsedLongOrNull(), + relaychainAsNative = it[RELAYCHAIN_AS_NATIVE] as? Boolean, + stakingMaxElectingVoters = it[MAX_ELECTING_VOTES].asGsonParsedIntOrNull(), + feeViaRuntimeCall = it[FEE_VIA_RUNTIME_CALL] as? Boolean, + supportLedgerGenericApp = it[SUPPORT_GENERIC_LEDGER_APP] as? Boolean, + identityChain = it[IDENTITY_CHAIN] as? ChainId, + disabledCheckMetadataHash = it[DISABLED_CHECK_METADATA_HASH] as? Boolean, + sessionLength = it[SESSION_LENGTH].asGsonParsedIntOrNull(), + sessionsPerEra = it[SESSIONS_PER_ERA].asGsonParsedIntOrNull(), + timelineChain = it[TIMELINE_CHAIN] as? ChainId + ) + } + + val chainLocal = with(chainRemote) { + val optionsOrEmpty = options.orEmpty() + + ChainLocal( + id = chainId, + parentId = parentId, + name = name, + types = types, + icon = icon ?: EMPTY_CHAIN_ICON, + prefix = addressPrefix, + legacyPrefix = legacyAddressPrefix, + isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty, + isTestNet = TESTNET_OPTION in optionsOrEmpty, + hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty, + supportProxy = PROXY_OPTION in optionsOrEmpty, + multisigSupport = MULTISIG_SUPPORT in optionsOrEmpty, + hasSubstrateRuntime = NO_SUBSTRATE_RUNTIME !in optionsOrEmpty, + pushSupport = PUSH_SUPPORT in optionsOrEmpty, + governance = mapGovernanceRemoteOptionsToLocal(optionsOrEmpty), + swap = mapSwapRemoteOptionsToLocal(optionsOrEmpty), + customFee = mapCustomFeeRemoteOptionsToLocal(optionsOrEmpty), + connectionState = determineConnectionState(chainRemote, oldChain), + additional = gson.toJson(additional), + nodeSelectionStrategy = mapNodeSelectionStrategyToLocal(nodeSelectionStrategy), + source = source + ) + } + + return chainLocal +} + +private fun mapNodeSelectionStrategyToLocal(remote: String?): AutoBalanceStrategyLocal { + return when (remote) { + null, "roundRobin" -> AutoBalanceStrategyLocal.ROUND_ROBIN + "uniform" -> AutoBalanceStrategyLocal.UNIFORM + else -> AutoBalanceStrategyLocal.UNKNOWN + } +} + +private fun determineConnectionState(remoteChain: ChainRemote, oldLocalChain: ChainLocal?): ConnectionStateLocal { + if (oldLocalChain != null && oldLocalChain.connectionState.isNotDefault()) { + return oldLocalChain.connectionState + } + + val options = remoteChain.options.orEmpty() + val fullSyncByDefault = FULL_SYNC_BY_DEFAULT in options + val hasNoSubstrateRuntime = NO_SUBSTRATE_RUNTIME in options + + return if (fullSyncByDefault || hasNoSubstrateRuntime) ConnectionStateLocal.FULL_SYNC else ConnectionStateLocal.LIGHT_SYNC +} + +private fun ConnectionStateLocal.isNotDefault(): Boolean { + return this != ConnectionStateLocal.LIGHT_SYNC +} + +private fun mapGovernanceRemoteOptionsToLocal(remoteOptions: Set): String { + val domainGovernanceTypes = remoteOptions.governanceTypesFromOptions() + + return mapGovernanceListToLocal(domainGovernanceTypes) +} + +private fun mapSwapRemoteOptionsToLocal(remoteOptions: Set): String { + val domainGovernanceTypes = remoteOptions.swapTypesFromOptions() + + return mapSwapListToLocal(domainGovernanceTypes) +} + +private fun mapCustomFeeRemoteOptionsToLocal(remoteOptions: Set): String { + val domainGovernanceTypes = remoteOptions.customFeeTypeFromOptions() + + return mapCustomFeeToLocal(domainGovernanceTypes) +} + +fun mapRemoteAssetToLocal( + chainRemote: ChainRemote, + assetRemote: ChainAssetRemote, + gson: Gson, + isEnabled: Boolean +): ChainAssetLocal { + return ChainAssetLocal( + id = assetRemote.assetId, + symbol = assetRemote.symbol, + precision = assetRemote.precision, + chainId = chainRemote.chainId, + name = assetRemote.name ?: chainRemote.name, + priceId = assetRemote.priceId, + staking = mapRemoteStakingTypesToLocal(assetRemote.staking), + type = assetRemote.type, + source = AssetSourceLocal.DEFAULT, + buyProviders = gson.toJson(assetRemote.buyProviders), + sellProviders = gson.toJson(assetRemote.sellProviders), + typeExtras = gson.toJson(assetRemote.typeExtras), + icon = assetRemote.icon, + enabled = isEnabled + ) +} + +fun mapRemoteNodesToLocal(chainRemote: ChainRemote): List { + return chainRemote.nodes.mapIndexed { index, chainNodeRemote -> + ChainNodeLocal( + url = chainNodeRemote.url, + name = chainNodeRemote.name, + chainId = chainRemote.chainId, + orderId = index, + source = ChainNodeLocal.Source.DEFAULT + ) + } +} + +fun mapRemoteExplorersToLocal(chainRemote: ChainRemote): List { + val explorers = chainRemote.explorers?.map { + ChainExplorerLocal( + chainId = chainRemote.chainId, + name = it.name, + extrinsic = it.extrinsic, + account = it.account, + event = it.event + ) + } + + return explorers.orEmpty() +} + +fun mapExternalApisToLocal(chainRemote: ChainRemote): List { + return chainRemote.externalApi?.flatMap { (apiType, apis) -> + apis.map { api -> + ChainExternalApiLocal( + chainId = chainRemote.chainId, + sourceType = mapSourceTypeRemoteToLocal(api.sourceType), + apiType = mapApiTypeRemoteToLocal(apiType), + parameters = api.parameters, + url = api.url + ) + } + }.orEmpty() +} + +private fun mapApiTypeRemoteToLocal(apiType: String): ApiType = when (apiType) { + "history" -> ApiType.TRANSFERS + "staking" -> ApiType.STAKING + "staking-rewards" -> ApiType.STAKING_REWARDS + "crowdloans" -> ApiType.CROWDLOANS + "governance" -> ApiType.GOVERNANCE_REFERENDA + "governance-delegations" -> ApiType.GOVERNANCE_DELEGATIONS + "referendum-summary" -> ApiType.REFERENDUM_SUMMARY + else -> ApiType.UNKNOWN +} + +private fun mapSourceTypeRemoteToLocal(sourceType: String): SourceType = when (sourceType) { + "subquery" -> SourceType.SUBQUERY + "github" -> SourceType.GITHUB + "polkassembly" -> SourceType.POLKASSEMBLY + "etherscan" -> SourceType.ETHERSCAN + "subsquare" -> SourceType.SUBSQUARE + else -> SourceType.UNKNOWN +} + +private fun mapRemoteStakingTypesToLocal(stakingTypesRemote: List?): String { + return stakingTypesRemote.orEmpty().joinToString(separator = ",") { stakingTypeRemote -> + val stakingType = mapStakingStringToStakingType(stakingTypeRemote) + mapStakingTypeToLocal(stakingType) + } +} + +fun mapStakingStringToStakingType(stakingString: String?): Chain.Asset.StakingType { + return when (stakingString) { + null -> Chain.Asset.StakingType.UNSUPPORTED + "relaychain" -> Chain.Asset.StakingType.RELAYCHAIN + "parachain" -> Chain.Asset.StakingType.PARACHAIN + "nomination-pools" -> Chain.Asset.StakingType.NOMINATION_POOLS + "aura-relaychain" -> Chain.Asset.StakingType.RELAYCHAIN_AURA + "turing" -> Chain.Asset.StakingType.TURING + "aleph-zero" -> Chain.Asset.StakingType.ALEPH_ZERO + "mythos" -> Chain.Asset.StakingType.MYTHOS + else -> Chain.Asset.StakingType.UNSUPPORTED + } +} + +fun mapStakingTypeToStakingString(stakingType: Chain.Asset.StakingType): String? { + return when (stakingType) { + Chain.Asset.StakingType.UNSUPPORTED -> null + Chain.Asset.StakingType.RELAYCHAIN -> "relaychain" + Chain.Asset.StakingType.PARACHAIN -> "parachain" + Chain.Asset.StakingType.RELAYCHAIN_AURA -> "aura-relaychain" + Chain.Asset.StakingType.TURING -> "turing" + Chain.Asset.StakingType.ALEPH_ZERO -> "aleph-zero" + Chain.Asset.StakingType.NOMINATION_POOLS -> "nomination-pools" + Chain.Asset.StakingType.MYTHOS -> "mythos" + } +} + +private fun Set.governanceTypesFromOptions(): List { + return mapNotNull { option -> + when (option) { + "governance" -> Chain.Governance.V2 // for backward compatibility of dev builds. Can be removed once everyone will update dev app + "governance-v2" -> Chain.Governance.V2 + "governance-v1" -> Chain.Governance.V1 + else -> null + } + } +} + +private fun Set.swapTypesFromOptions(): List { + return mapNotNull { option -> + when (option) { + SWAP_HUB -> Chain.Swap.ASSET_CONVERSION + HYDRA_DX_SWAPS -> Chain.Swap.HYDRA_DX + else -> null + } + } +} + +private fun Set.customFeeTypeFromOptions(): List { + return mapNotNull { option -> + when (option) { + CUSTOM_FEE_ASSET_HUB -> Chain.CustomFee.ASSET_HUB + CUSTOM_FEE_HYDRA_DX -> Chain.CustomFee.HYDRA_DX + else -> null + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/utils/GovernanceReferendaParameters.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/utils/GovernanceReferendaParameters.kt new file mode 100644 index 0000000..771a36a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/utils/GovernanceReferendaParameters.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils + +class GovernanceReferendaParameters(val network: String?) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/utils/TransferParameters.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/utils/TransferParameters.kt new file mode 100644 index 0000000..7122b9e --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/utils/TransferParameters.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.mappers.utils + +const val SUBSTRATE_TRANSFER_PARAMETER = "substrate" +const val EVM_TRANSFER_PARAMETER = "evm" + +class TransferParameters(val assetType: String?) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt new file mode 100644 index 0000000..2a961b0 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt @@ -0,0 +1,262 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.model + +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.utils.Precision +import io.novafoundation.nova.common.utils.TokenSymbol +import java.io.Serializable +import java.math.BigInteger + +typealias ChainId = String +typealias ChainAssetId = Int +typealias StringTemplate = String + +typealias ExplorerTemplateExtractor = (Chain.Explorer) -> StringTemplate? + +typealias TradeProviderId = String +typealias TradeProviderArguments = Map + +data class FullChainAssetId(val chainId: ChainId, val assetId: ChainAssetId) { + + companion object +} + +data class Chain( + val id: ChainId, + val name: String, + val assets: List, + val nodes: Nodes, + val explorers: List, + val externalApis: List, + val icon: String?, + val addressPrefix: Int, + val legacyAddressPrefix: Int?, + val types: Types?, + val isEthereumBased: Boolean, + val isTestNet: Boolean, + val source: Source, + val hasSubstrateRuntime: Boolean, + val pushSupport: Boolean, + val hasCrowdloans: Boolean, + val supportProxy: Boolean, + val governance: List, + val swap: List, + val customFee: List, + val multisigSupport: Boolean, + val connectionState: ConnectionState, + val parentId: String?, + val additional: Additional? +) : Identifiable, Serializable { + + companion object // extensions + + val assetsById = assets.associateBy(Asset::id) + + data class Additional( + val defaultTip: BigInteger?, + val themeColor: String?, + val stakingWiki: String?, + val defaultBlockTimeMillis: Long?, + val relaychainAsNative: Boolean?, + val stakingMaxElectingVoters: Int?, + val feeViaRuntimeCall: Boolean?, + val supportLedgerGenericApp: Boolean?, + val identityChain: ChainId?, + val disabledCheckMetadataHash: Boolean?, + val sessionLength: Int?, + val sessionsPerEra: Int?, + val timelineChain: ChainId? + ) + + data class Types( + val url: String?, + val overridesCommon: Boolean, + ) + + data class Asset( + val icon: String?, + val id: ChainAssetId, + val priceId: String?, + val chainId: ChainId, + val symbol: TokenSymbol, + val precision: Precision, + val buyProviders: Map, + val sellProviders: Map, + val staking: List, + val type: Type, + val source: Source, + val name: String, + val enabled: Boolean, + ) : Identifiable, Serializable { + + enum class Source { + DEFAULT, ERC20, MANUAL + } + + sealed class Type { + object Native : Type() + + data class Statemine( + val id: StatemineAssetId, + val palletName: String?, + val isSufficient: Boolean, + ) : Type() + + data class Orml( + val currencyIdScale: String, + val currencyIdType: String, + val existentialDeposit: BigInteger, + val transfersEnabled: Boolean, + val subType: SubType + ) : Type() { + + enum class SubType { + DEFAULT, HYDRATION_EVM + } + } + + data class EvmErc20( + val contractAddress: String + ) : Type() + + object EvmNative : Type() + + data class Equilibrium( + val id: BigInteger + ) : Type() + + object Unsupported : Type() + } + + enum class StakingType { + UNSUPPORTED, + RELAYCHAIN, RELAYCHAIN_AURA, ALEPH_ZERO, // relaychain like + PARACHAIN, TURING, // parachain-staking like + NOMINATION_POOLS, + MYTHOS + } + + override val identifier = "$chainId:$id" + } + + data class Nodes( + val autoBalanceStrategy: AutoBalanceStrategy, + val wssNodeSelectionStrategy: NodeSelectionStrategy, + val nodes: List, + ) { + + enum class AutoBalanceStrategy { + ROUND_ROBIN, UNIFORM + } + + sealed class NodeSelectionStrategy { + + object AutoBalance : NodeSelectionStrategy() + + class SelectedNode(val unformattedNodeUrl: String) : NodeSelectionStrategy() + } + } + + data class Node( + val chainId: ChainId, + val unformattedUrl: String, + val name: String, + val orderId: Int, + val isCustom: Boolean + ) : Identifiable { + + enum class ConnectionType { + HTTPS, WSS, UNKNOWN + } + + val connectionType = when { + unformattedUrl.startsWith("wss://") || unformattedUrl.startsWith("ws://") -> ConnectionType.WSS + unformattedUrl.startsWith("https://") -> ConnectionType.HTTPS + else -> ConnectionType.UNKNOWN + } + + override val identifier: String = "$chainId:$unformattedUrl" + } + + data class Explorer( + val chainId: ChainId, + val name: String, + val account: StringTemplate?, + val extrinsic: StringTemplate?, + val event: StringTemplate? + ) : Identifiable { + + override val identifier = "$chainId:$name" + } + + sealed class ExternalApi { + + abstract val url: String + + sealed class Transfers : ExternalApi() { + + data class Substrate(override val url: String) : Transfers() + + data class Evm(override val url: String) : Transfers() + } + + data class Crowdloans(override val url: String) : ExternalApi() + + data class Staking(override val url: String) : ExternalApi() + + data class StakingRewards(override val url: String) : ExternalApi() + + data class GovernanceReferenda(override val url: String, val source: Source) : ExternalApi() { + + sealed class Source { + + data class Polkassembly(val network: String?) : Source() + + object SubSquare : Source() + } + } + + data class GovernanceDelegations(override val url: String) : ExternalApi() + + data class ReferendumSummary(override val url: String) : ExternalApi() + } + + enum class Governance { + V1, V2 + } + + enum class Swap { + ASSET_CONVERSION, HYDRA_DX + } + + enum class CustomFee { + ASSET_HUB, HYDRA_DX + } + + enum class ConnectionState { + /** + * Runtime sync is performed for the chain and the chain can be considered ready for any operation + */ + FULL_SYNC, + + /** + * Websocket connection is established for the chain, but runtime is not synced. + * Thus, only runtime-independent operations can be performed + */ + LIGHT_SYNC, + + /** + * Chain is completely disabled - it does not initialize websockets not allocates any other resources + */ + DISABLED + } + + enum class Source { + DEFAULT, CUSTOM + } + + override val identifier: String = id +} + +enum class TypesUsage { + BASE, OWN, BOTH, NONE +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/LightChain.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/LightChain.kt new file mode 100644 index 0000000..7d09aea --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/LightChain.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.model + +import io.novafoundation.nova.common.utils.Identifiable + +data class LightChain( + val id: ChainId, + val name: String, + val icon: String? +) : Identifiable { + + override val identifier: String = id +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/NetworkType.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/NetworkType.kt new file mode 100644 index 0000000..3187a96 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/NetworkType.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.model + +enum class NetworkType { + SUBSTRATE, EVM +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/StatemineAssetId.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/StatemineAssetId.kt new file mode 100644 index 0000000..74f3930 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/StatemineAssetId.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.model + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.runtime.ext.palletNameOrDefault +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.RuntimeType +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.toHexUntyped +import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.moduleOrNull +import java.math.BigInteger + +sealed interface StatemineAssetId { + + @JvmInline + value class ScaleEncoded(val scaleHex: String) : StatemineAssetId + + @JvmInline + value class Number(val value: BigInteger) : StatemineAssetId +} + +fun StatemineAssetId.asNumberOrNull(): BigInteger? { + return (this as? StatemineAssetId.Number)?.value +} + +fun StatemineAssetId.asNumberOrThrow(): BigInteger { + return (this as StatemineAssetId.Number).value +} + +fun StatemineAssetId.asScaleEncodedOrThrow(): String { + return (this as StatemineAssetId.ScaleEncoded).scaleHex +} + +fun StatemineAssetId.asScaleEncodedOrNull(): String? { + return (this as? StatemineAssetId.ScaleEncoded)?.scaleHex +} + +fun StatemineAssetId.isScaleEncoded(): Boolean { + return this is StatemineAssetId.ScaleEncoded +} + +fun Chain.Asset.Type.Statemine.prepareIdForEncoding(runtimeSnapshot: RuntimeSnapshot): Any { + return when (val id = id) { + is StatemineAssetId.Number -> id.value + + is StatemineAssetId.ScaleEncoded -> { + val assetIdType = statemineAssetIdScaleType(runtimeSnapshot, palletNameOrDefault()) + + assetIdType!!.fromHex(runtimeSnapshot, id.scaleHex)!! + } + } +} + +fun Chain.Asset.Type.Statemine.hasSameId(runtimeSnapshot: RuntimeSnapshot, dynamicInstanceId: Any?): Boolean { + return runCatching { + when (val id = id) { + is StatemineAssetId.Number -> id.value == bindNumber(dynamicInstanceId) + + is StatemineAssetId.ScaleEncoded -> { + val assetIdType = statemineAssetIdScaleType(runtimeSnapshot, palletNameOrDefault()) + val otherScale = assetIdType!!.toHexUntyped(runtimeSnapshot, dynamicInstanceId) + + id.scaleHex == otherScale + } + } + }.getOrDefault(false) +} + +fun statemineAssetIdScaleType(runtimeSnapshot: RuntimeSnapshot, palletName: String): RuntimeType<*, *>? { + val transferCall = runtimeSnapshot.metadata.moduleOrNull(palletName)?.callOrNull("transfer") + return transferCall?.arguments?.firstOrNull()?.type +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/ChainFetcher.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/ChainFetcher.kt new file mode 100644 index 0000000..b8053bf --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/ChainFetcher.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.remote + +import io.novafoundation.nova.runtime.BuildConfig +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.LightChainRemote +import retrofit2.http.GET +import retrofit2.http.Path + +interface ChainFetcher { + + @GET(BuildConfig.CHAINS_URL) + suspend fun getChains(): List + + @GET(BuildConfig.PRE_CONFIGURED_CHAINS_URL) + suspend fun getPreConfiguredChains(): List + + @GET(BuildConfig.PRE_CONFIGURED_CHAIN_DETAILS_URL + "/{id}.json") + suspend fun getPreConfiguredChainById(@Path("id") id: String): ChainRemote +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainAssetRemote.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainAssetRemote.kt new file mode 100644 index 0000000..d6558b1 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainAssetRemote.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.remote.model + +data class ChainAssetRemote( + val assetId: Int, + val symbol: String, + val precision: Int, + val priceId: String?, + val name: String?, + val staking: List?, + val type: String?, + val icon: String?, + val buyProviders: Map>?, // { "providerName": { arguments map } } + val sellProviders: Map>?, // { "providerName": { arguments map } } + val typeExtras: Map?, +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainExplorerRemote.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainExplorerRemote.kt new file mode 100644 index 0000000..7852487 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainExplorerRemote.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.remote.model + +data class ChainExplorerRemote( + val name: String, + val extrinsic: String?, + val account: String?, + val event: String? +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainExternalApiRemote.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainExternalApiRemote.kt new file mode 100644 index 0000000..e7fcdeb --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainExternalApiRemote.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.remote.model + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import java.lang.reflect.Type + +typealias ChainExternalApisRemote = Map> +typealias ExternalApiTypeRemote = String + +data class ChainExternalApiRemote( + @SerializedName("type") + val sourceType: String, + val url: String, + @JsonAdapter(RawStringTypeAdapter::class) + val parameters: String? +) + +private class RawStringTypeAdapter private constructor() : JsonDeserializer { + override fun deserialize(json: JsonElement?, typeOfT: Type, context: JsonDeserializationContext): String? { + return if (json == null || json is JsonNull) { + null + } else { + json.toString() + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainNodeRemote.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainNodeRemote.kt new file mode 100644 index 0000000..2c62e60 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainNodeRemote.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.remote.model + +data class ChainNodeRemote( + val url: String, + val name: String +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainRemote.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainRemote.kt new file mode 100644 index 0000000..168682e --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainRemote.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.remote.model + +data class ChainRemote( + val chainId: String, + val name: String, + val assets: List, + val nodes: List, + val nodeSelectionStrategy: String?, + val explorers: List?, + val externalApi: ChainExternalApisRemote?, + val icon: String?, + val addressPrefix: Int, + val legacyAddressPrefix: Int?, + val types: ChainTypesInfo?, + val options: Set?, + val parentId: String?, + val additional: Map?, +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainTypesInfo.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainTypesInfo.kt new file mode 100644 index 0000000..be914d0 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/ChainTypesInfo.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.remote.model + +class ChainTypesInfo( + val url: String?, + val overridesCommon: Boolean +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/LightChainRemote.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/LightChainRemote.kt new file mode 100644 index 0000000..fb848ae --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/remote/model/LightChainRemote.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain.remote.model + +data class LightChainRemote( + val chainId: String, + val name: String, + val icon: String? +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ChainConnection.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ChainConnection.kt new file mode 100644 index 0000000..0e73a1c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ChainConnection.kt @@ -0,0 +1,172 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.share +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.NodeAutobalancer +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.interceptor.WebSocketResponseInterceptor +import io.novasama.substrate_sdk_android.wsrpc.interceptor.WebSocketResponseInterceptor.ResponseDelivery +import io.novasama.substrate_sdk_android.wsrpc.networkStateFlow +import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse +import io.novasama.substrate_sdk_android.wsrpc.state.SocketStateMachine.State +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import javax.inject.Provider + +class ChainConnectionFactory( + private val externalRequirementFlow: Flow, + private val nodeAutobalancer: NodeAutobalancer, + private val socketServiceProvider: Provider, +) { + + suspend fun create(chain: Chain): ChainConnection { + val connection = ChainConnection( + socketService = socketServiceProvider.get(), + externalRequirementFlow = externalRequirementFlow, + nodeAutobalancer = nodeAutobalancer, + initialChain = chain + ) + + connection.setup() + + return connection + } +} + +private const val INFURA_ERROR_CODE = -32005 +private const val ALCHEMY_ERROR_CODE = 429 + +private const val BLUST_CAPACITY_ERROR_CODE = -32098 +private const val BLUST_RATE_LIMIT_ERROR_CODE = -32097 + +private const val ON_FINALITY_RATE_LIMIT_ERROR_CODE = -32029 + +private val RATE_LIMIT_ERROR_CODES = listOf( + INFURA_ERROR_CODE, + ALCHEMY_ERROR_CODE, + BLUST_CAPACITY_ERROR_CODE, + BLUST_RATE_LIMIT_ERROR_CODE, + ON_FINALITY_RATE_LIMIT_ERROR_CODE +) + +class ChainConnection internal constructor( + val socketService: SocketService, + private val externalRequirementFlow: Flow, + private val nodeAutobalancer: NodeAutobalancer, + initialChain: Chain, +) : CoroutineScope by CoroutineScope(Dispatchers.Default), + WebSocketResponseInterceptor { + + enum class ExternalRequirement { + ALLOWED, STOPPED + } + + val state = socketService.networkStateFlow() + .stateIn(scope = this, started = SharingStarted.Eagerly, initialValue = State.Disconnected) + + private val responseRequiresNodeChangeFlow = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + + private val nodeChangeSignal = merge( + state.nodeChangeEvents(), + responseRequiresNodeChangeFlow + ).shareIn(scope = this, started = SharingStarted.Eagerly) + + private val chain = MutableStateFlow(initialChain) + + private val availableNodes = chain.map { it.nodes } + .distinctUntilChanged() + .share(SharingStarted.Eagerly) + + val currentUrl = nodeAutobalancer.connectionUrlFlow( + chainId = initialChain.id, + changeConnectionEventFlow = nodeChangeSignal, + availableNodesFlow = availableNodes, + ).share(SharingStarted.Eagerly) + + suspend fun setup() { + socketService.setInterceptor(this) + + observeCurrentNode() + + externalRequirementFlow.onEach { + if (it == ExternalRequirement.ALLOWED) { + socketService.resume() + } else { + socketService.pause() + } + } + .launchIn(this) + } + + private suspend fun observeCurrentNode() { + // Important - this should be awaited first before setting up externalRequirementFlow subscription + // Otherwise there might be a race between both of them + val firstNodeUrl = currentUrl.first()?.saturatedUrl ?: return + socketService.start(firstNodeUrl, remainPaused = true) + + currentUrl.mapNotNull { it?.saturatedUrl } + .filter { nodeUrl -> actualUrl() != nodeUrl } + .onEach { nodeUrl -> socketService.switchUrl(nodeUrl) } + .onEach { nodeUrl -> Log.d(this@ChainConnection.LOG_TAG, "Switching node in ${chain.value.name} to $nodeUrl") } + .launchIn(this) + } + + fun updateChain(chain: Chain) { + this.chain.value = chain + } + + fun finish() { + cancel() + + socketService.stop() + } + + private suspend fun actualUrl(): String? { + return when (val stateSnapshot = state.first()) { + is State.WaitingForReconnect -> stateSnapshot.url + is State.Connecting -> stateSnapshot.url + is State.Connected -> stateSnapshot.url + State.Disconnected -> null + is State.Paused -> stateSnapshot.url + } + } + + private fun Flow.nodeChangeEvents(): Flow { + return mapNotNull { stateValue -> + Unit.takeIf { stateValue.needsAutobalance() } + } + } + + private fun State.needsAutobalance() = this is State.WaitingForReconnect && attempt > 1 + + override fun onRpcResponseReceived(rpcResponse: RpcResponse): ResponseDelivery { + val error = rpcResponse.error + + return if (error != null && error.code in RATE_LIMIT_ERROR_CODES) { + Log.d(LOG_TAG, "Received rate limit exceeded error code in rpc response. Switching to another node") + + responseRequiresNodeChangeFlow.tryEmit(Unit) + + ResponseDelivery.DROP + } else { + ResponseDelivery.DELIVER_TO_SENDER + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ConnectionPool.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ConnectionPool.kt new file mode 100644 index 0000000..2d2dbd1 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ConnectionPool.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.util.concurrent.ConcurrentHashMap + +class ConnectionPool(private val chainConnectionFactory: ChainConnectionFactory) { + + private val pool = ConcurrentHashMap() + + fun getConnection(chainId: String): ChainConnection = pool.getValue(chainId) + + fun getConnectionOrNull(chainId: String): ChainConnection? = pool[chainId] + + suspend fun setupConnection(chain: Chain): ChainConnection { + val connection = pool.getOrPut(chain.id) { + chainConnectionFactory.create(chain) + } + + connection.updateChain(chain) + + return connection + } + + fun removeConnection(chainId: String) { + pool.remove(chainId)?.apply { finish() } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ConnectionSecrets.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ConnectionSecrets.kt new file mode 100644 index 0000000..53fc826 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/ConnectionSecrets.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection + +import android.util.Log +import io.novafoundation.nova.common.utils.formatNamedOrThrow +import io.novafoundation.nova.runtime.BuildConfig +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ConnectionSecrets(private val secretsByName: Map) : Map by secretsByName { + + companion object { + + fun default(): ConnectionSecrets { + return ConnectionSecrets( + mapOf( + "INFURA_API_KEY" to BuildConfig.INFURA_API_KEY, + "DWELLIR_API_KEY" to BuildConfig.DWELLIR_API_KEY + ) + ) + } + } +} + +fun ConnectionSecrets.saturateUrl(url: String): String? { + return runCatching { url.formatNamedOrThrow(this) }.getOrNull() +} + +fun Chain.Node.saturateNodeUrl(connectionSecrets: ConnectionSecrets): NodeWithSaturatedUrl? { + val saturatedUrl = connectionSecrets.saturateUrl(unformattedUrl) ?: run { + Log.w("ConnectionSecrets", "Failed to saturate url $unformattedUrl due to unknown secrets in the url") + return null + } + + return NodeWithSaturatedUrl(this, saturatedUrl) +} + +fun List.saturateNodeUrls(connectionSecrets: ConnectionSecrets): List { + return mapNotNull { node -> node.saturateNodeUrl(connectionSecrets) } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/NodeWithSaturatedUrl.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/NodeWithSaturatedUrl.kt new file mode 100644 index 0000000..18c749b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/NodeWithSaturatedUrl.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class NodeWithSaturatedUrl( + val node: Chain.Node, + val saturatedUrl: String +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/Web3ApiPool.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/Web3ApiPool.kt new file mode 100644 index 0000000..af9a5c7 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/Web3ApiPool.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection + +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.ext.hasHttpNodes +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Node.ConnectionType +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import java.util.concurrent.ConcurrentHashMap + +typealias Web3ApiPoolKey = Pair +typealias Web3ApiPoolValue = Pair + +class Web3ApiPool(private val web3ApiFactory: Web3ApiFactory) { + + private val pool = ConcurrentHashMap() + + fun getWeb3Api(chainId: String, connectionType: ConnectionType): Web3Api? = pool[chainId to connectionType]?.first + + fun setupWssApi(chainId: ChainId, socketService: SocketService): Web3Api { + return pool.getOrPut(chainId to ConnectionType.WSS) { + web3ApiFactory.createWss(socketService) to null + }.first + } + + fun setupHttpsApi(chain: Chain): Web3Api? { + val chainNodes = chain.nodes + + if (!chainNodes.hasHttpNodes()) { + removeApi(chain.id, ConnectionType.HTTPS) + + return null + } + + val (web3Api, updatableNodes) = pool.getOrPut(chain.id to ConnectionType.HTTPS) { + web3ApiFactory.createHttps(chainNodes) + } + + updatableNodes?.updateNodes(chainNodes) + + return web3Api + } + + fun removeApis(chainId: String) { + ConnectionType.values().forEach { connectionType -> + removeApi(chainId, connectionType) + } + } + + private fun removeApi(chainId: String, connectionType: ConnectionType) { + pool.remove(chainId to connectionType) + .also { it?.first?.shutdown() } + } +} + +interface UpdatableNodes { + + fun updateNodes(nodes: Chain.Nodes) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/NodeAutobalancer.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/NodeAutobalancer.kt new file mode 100644 index 0000000..9b92e9c --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/NodeAutobalancer.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.autobalance + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.connection.NodeWithSaturatedUrl +import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSelectionStrategyProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.transformLatest + +class NodeAutobalancer( + private val autobalanceStrategyProvider: NodeSelectionStrategyProvider, +) { + + @OptIn(ExperimentalCoroutinesApi::class) + fun connectionUrlFlow( + chainId: ChainId, + changeConnectionEventFlow: Flow, + availableNodesFlow: Flow, + ): Flow { + return availableNodesFlow.transformLatest { nodesConfig -> + Log.d(this@NodeAutobalancer.LOG_TAG, "Using ${nodesConfig.wssNodeSelectionStrategy} strategy for switching nodes in $chainId") + + val strategy = autobalanceStrategyProvider.createWss(nodesConfig) + + val nodeIterator = strategy.generateNodeSequence().iterator() + if (!nodeIterator.hasNext()) { + Log.w(this@NodeAutobalancer.LOG_TAG, "No wss nodes available for chain $chainId using strategy $strategy") + return@transformLatest + } + + emit(nodeIterator.next()) + + val updates = changeConnectionEventFlow.mapNotNull { + if (nodeIterator.hasNext()) { + nodeIterator.next() + } else { + null + } + } + emitAll(updates) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/NodeSelectionStrategyProvider.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/NodeSelectionStrategyProvider.kt new file mode 100644 index 0000000..b4c72e3 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/NodeSelectionStrategyProvider.kt @@ -0,0 +1,67 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy + +import io.novafoundation.nova.runtime.ext.httpNodes +import io.novafoundation.nova.runtime.ext.wssNodes +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Nodes.NodeSelectionStrategy +import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets +import io.novafoundation.nova.runtime.multiNetwork.connection.saturateNodeUrl +import io.novafoundation.nova.runtime.multiNetwork.connection.saturateNodeUrls + +class NodeSelectionStrategyProvider( + private val connectionSecrets: ConnectionSecrets, +) { + + fun createWss(config: Chain.Nodes): NodeSequenceGenerator { + return createNodeSequenceGenerator( + availableNodes = config.wssNodes(), + autobalanceStrategy = config.autoBalanceStrategy, + nodeSelectionStrategy = config.wssNodeSelectionStrategy + ) + } + + fun createHttp(config: Chain.Nodes): NodeSequenceGenerator { + return createNodeSequenceGenerator( + availableNodes = config.httpNodes(), + autobalanceStrategy = config.autoBalanceStrategy, + // Http nodes disregard selected wss strategy and always use auto balance + nodeSelectionStrategy = NodeSelectionStrategy.AutoBalance + ) + } + + private fun createNodeSequenceGenerator( + availableNodes: List, + autobalanceStrategy: Chain.Nodes.AutoBalanceStrategy, + nodeSelectionStrategy: NodeSelectionStrategy, + ): NodeSequenceGenerator { + return when (nodeSelectionStrategy) { + NodeSelectionStrategy.AutoBalance -> createAutoBalanceGenerator(autobalanceStrategy, availableNodes) + is NodeSelectionStrategy.SelectedNode -> { + createSelectedNodeGenerator(nodeSelectionStrategy.unformattedNodeUrl, availableNodes) + // Fallback to auto balance in case we failed to setup a selected node strategy + ?: createAutoBalanceGenerator(autobalanceStrategy, availableNodes) + } + } + } + + private fun createSelectedNodeGenerator( + selectedUnformattedNodeUrl: String, + availableNodes: List, + ): SelectedNodeGenerator? { + val node = availableNodes.find { it.unformattedUrl == selectedUnformattedNodeUrl } ?: return null + val saturatedNode = node.saturateNodeUrl(connectionSecrets) ?: return null + return SelectedNodeGenerator(saturatedNode) + } + + private fun createAutoBalanceGenerator( + autoBalanceStrategy: Chain.Nodes.AutoBalanceStrategy, + availableNodes: List, + ): NodeSequenceGenerator { + val saturatedUrls = availableNodes.saturateNodeUrls(connectionSecrets) + + return when (autoBalanceStrategy) { + Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN -> RoundRobinGenerator(saturatedUrls) + Chain.Nodes.AutoBalanceStrategy.UNIFORM -> UniformGenerator(saturatedUrls) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/NodeSequenceGenerator.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/NodeSequenceGenerator.kt new file mode 100644 index 0000000..6c1a5ce --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/NodeSequenceGenerator.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy + +import io.novafoundation.nova.runtime.multiNetwork.connection.NodeWithSaturatedUrl + +interface NodeSequenceGenerator { + + fun generateNodeSequence(): Sequence +} + +fun NodeSequenceGenerator.generateNodeIterator(): Iterator { + return generateNodeSequence().iterator() +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/RoundRobinGenerator.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/RoundRobinGenerator.kt new file mode 100644 index 0000000..f36521f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/RoundRobinGenerator.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy + +import io.novafoundation.nova.common.utils.cycle +import io.novafoundation.nova.runtime.multiNetwork.connection.NodeWithSaturatedUrl + +class RoundRobinGenerator( + private val availableNodes: List, +) : NodeSequenceGenerator { + + override fun generateNodeSequence(): Sequence { + return availableNodes.cycle() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/SelectedNodeGenerator.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/SelectedNodeGenerator.kt new file mode 100644 index 0000000..959d213 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/SelectedNodeGenerator.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy + +import io.novafoundation.nova.common.utils.cycle +import io.novafoundation.nova.runtime.multiNetwork.connection.NodeWithSaturatedUrl + +class SelectedNodeGenerator( + private val selectedNode: NodeWithSaturatedUrl, +) : NodeSequenceGenerator { + + override fun generateNodeSequence(): Sequence { + return listOf(selectedNode).cycle() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/UniformGenerator.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/UniformGenerator.kt new file mode 100644 index 0000000..12a09a5 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/UniformGenerator.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy + +import io.novafoundation.nova.common.utils.cycle +import io.novafoundation.nova.runtime.multiNetwork.connection.NodeWithSaturatedUrl + +class UniformGenerator( + private val availableNodes: List, +) : NodeSequenceGenerator { + + override fun generateNodeSequence(): Sequence { + return availableNodes.shuffled().cycle() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/connection/NodeConnection.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/connection/NodeConnection.kt new file mode 100644 index 0000000..6b9fa48 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/connection/NodeConnection.kt @@ -0,0 +1,63 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.node.connection + +import io.novafoundation.nova.common.utils.awaitConnected +import io.novafoundation.nova.common.utils.invokeOnCompletion +import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets +import io.novafoundation.nova.runtime.multiNetwork.connection.saturateUrl +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.interceptor.WebSocketResponseInterceptor +import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse +import javax.inject.Provider +import kotlinx.coroutines.CoroutineScope + +interface NodeConnection { + + suspend fun awaitConnected() + + fun getSocketService(): SocketService + + fun switchUrl(url: String) +} + +class NodeConnectionFactory( + private val socketServiceProvider: Provider, + private val connectionSecrets: ConnectionSecrets +) { + + fun createNodeConnection(nodeUrl: String, coroutineScope: CoroutineScope): NodeConnection { + return RealNodeConnection(nodeUrl, socketServiceProvider.get(), connectionSecrets, coroutineScope) + } +} + +class RealNodeConnection( + private val nodeUrl: String, + private val socketService: SocketService, + private val connectionSecrets: ConnectionSecrets, + private val coroutineScope: CoroutineScope +) : NodeConnection, WebSocketResponseInterceptor { + + init { + socketService.setInterceptor(this) + val saturatedUrlNode = connectionSecrets.saturateUrl(nodeUrl) + saturatedUrlNode?.let { + socketService.start(it) + coroutineScope.invokeOnCompletion { socketService.stop() } + } + } + + override fun getSocketService(): SocketService { + return socketService + } + + override fun switchUrl(url: String) { + socketService.switchUrl(url) + } + + override suspend fun awaitConnected() { + socketService.awaitConnected() + } + + override fun onRpcResponseReceived(rpcResponse: RpcResponse): WebSocketResponseInterceptor.ResponseDelivery { + return WebSocketResponseInterceptor.ResponseDelivery.DELIVER_TO_SENDER + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/EthereumNodeHealthStateTester.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/EthereumNodeHealthStateTester.kt new file mode 100644 index 0000000..4f7767f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/EthereumNodeHealthStateTester.kt @@ -0,0 +1,71 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState + +import io.novafoundation.nova.common.utils.emptyEthereumAddress +import io.novafoundation.nova.common.utils.awaitConnected +import io.novafoundation.nova.common.utils.invokeOnCompletion +import io.novafoundation.nova.core.ethereum.Web3Api +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.ethereum.sendSuspend +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets +import io.novafoundation.nova.runtime.multiNetwork.connection.saturateNodeUrl +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import org.web3j.protocol.core.DefaultBlockParameterName +import io.novasama.substrate_sdk_android.wsrpc.interceptor.WebSocketResponseInterceptor +import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse +import kotlinx.coroutines.CoroutineScope +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +class EthereumNodeHealthStateTester( + private val node: Chain.Node, + private val connectionSecrets: ConnectionSecrets, + private val web3ApiFactory: Web3ApiFactory, + private val socketService: SocketService, + private val coroutineScope: CoroutineScope +) : NodeHealthStateTester, WebSocketResponseInterceptor { + + private val web3Api = createWeb3Api() + + init { + socketService.setInterceptor(this) + + if (node.connectionType == Chain.Node.ConnectionType.WSS) { + val saturatedUrlNode = node.saturateNodeUrl(connectionSecrets) + + saturatedUrlNode?.let { + socketService.start(it.saturatedUrl) + coroutineScope.invokeOnCompletion { + socketService.stop() + } + } + } + } + + @OptIn(ExperimentalTime::class) + override suspend fun testNodeHealthState(): Result { + return runCatching { + if (node.connectionType == Chain.Node.ConnectionType.WSS) { + socketService.awaitConnected() + } + + val duration = measureTime { + web3Api.ethGetBalance(emptyEthereumAddress(), DefaultBlockParameterName.LATEST).sendSuspend() + } + + duration.inWholeMilliseconds + } + } + + private fun createWeb3Api(): Web3Api { + return if (node.connectionType == Chain.Node.ConnectionType.HTTPS) { + web3ApiFactory.createHttps(node).first + } else { + web3ApiFactory.createWss(socketService) + } + } + + override fun onRpcResponseReceived(rpcResponse: RpcResponse): WebSocketResponseInterceptor.ResponseDelivery { + return WebSocketResponseInterceptor.ResponseDelivery.DELIVER_TO_SENDER + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTester.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTester.kt new file mode 100644 index 0000000..84ad930 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTester.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState + +interface NodeHealthStateTester { + + /** + * Should return connection delay in ms + */ + suspend fun testNodeHealthState(): Result +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt new file mode 100644 index 0000000..b9986a1 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState + +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import javax.inject.Provider +import kotlinx.coroutines.CoroutineScope + +class NodeHealthStateTesterFactory( + private val socketServiceProvider: Provider, + private val connectionSecrets: ConnectionSecrets, + private val bulkRetriever: BulkRetriever, + private val web3ApiFactory: Web3ApiFactory +) { + + fun create(chain: Chain, node: Chain.Node, coroutineScope: CoroutineScope): NodeHealthStateTester { + val nodeIsSupported = chain.nodes.nodes.any { it.unformattedUrl == node.unformattedUrl } + require(nodeIsSupported) + + return if (chain.hasSubstrateRuntime) { + SubstrateNodeHealthStateTester( + chain = chain, + socketService = socketServiceProvider.get(), + connectionSecrets = connectionSecrets, + bulkRetriever = bulkRetriever, + node = node, + coroutineScope = coroutineScope + ) + } else { + EthereumNodeHealthStateTester( + socketService = socketServiceProvider.get(), + connectionSecrets = connectionSecrets, + node = node, + web3ApiFactory = web3ApiFactory, + coroutineScope = coroutineScope + ) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/SubstrateNodeHealthStateTester.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/SubstrateNodeHealthStateTester.kt new file mode 100644 index 0000000..0d13a86 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/SubstrateNodeHealthStateTester.kt @@ -0,0 +1,64 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState + +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.data.network.rpc.queryKey +import io.novafoundation.nova.common.utils.awaitConnected +import io.novafoundation.nova.common.utils.invokeOnCompletion +import io.novafoundation.nova.runtime.ext.emptyAccountId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets +import io.novafoundation.nova.runtime.multiNetwork.connection.saturateNodeUrl +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b128Concat +import io.novasama.substrate_sdk_android.hash.Hasher.xxHash128 +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.interceptor.WebSocketResponseInterceptor +import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse +import kotlinx.coroutines.CoroutineScope +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +class SubstrateNodeHealthStateTester( + private val chain: Chain, + private val node: Chain.Node, + private val connectionSecrets: ConnectionSecrets, + private val bulkRetriever: BulkRetriever, + val socketService: SocketService, + private val coroutineScope: CoroutineScope +) : NodeHealthStateTester, WebSocketResponseInterceptor { + + init { + val saturatedUrlNode = node.saturateNodeUrl(connectionSecrets) + socketService.setInterceptor(this) + + saturatedUrlNode?.let { + socketService.start(it.saturatedUrl) + coroutineScope.invokeOnCompletion { + socketService.stop() + } + } + } + + @OptIn(ExperimentalTime::class) + override suspend fun testNodeHealthState(): Result { + val storageKey = systemAccountStorageKey().toHexString(withPrefix = true) + + return runCatching { + socketService.awaitConnected() + + val duration = measureTime { + bulkRetriever.queryKey(socketService, storageKey) + } + + duration.inWholeMilliseconds + } + } + + private fun systemAccountStorageKey(): ByteArray { + return "System".toByteArray().xxHash128() + "Account".toByteArray().xxHash128() + chain.emptyAccountId().blake2b128Concat() + } + + override fun onRpcResponseReceived(rpcResponse: RpcResponse): WebSocketResponseInterceptor.ResponseDelivery { + return WebSocketResponseInterceptor.ResponseDelivery.DELIVER_TO_SENDER + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/exception/DisabledChainException.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/exception/DisabledChainException.kt new file mode 100644 index 0000000..c2fe7b1 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/exception/DisabledChainException.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.runtime.multiNetwork.exception + +class DisabledChainException : Exception() diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/MultiChainQrSharingFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/MultiChainQrSharingFactory.kt new file mode 100644 index 0000000..ec55265 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/MultiChainQrSharingFactory.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.runtime.multiNetwork.qr + +import io.novasama.substrate_sdk_android.encrypt.qr.QrSharing +import io.novasama.substrate_sdk_android.encrypt.qr.formats.AddressQrFormat +import io.novasama.substrate_sdk_android.encrypt.qr.formats.SubstrateQrFormat + +class MultiChainQrSharingFactory { + + fun create(addressValidator: (String) -> Boolean): QrSharing { + val substrateFormat = SubstrateQrFormat() + val onlyAddressFormat = AddressQrFormat(addressValidator) + + val formats = listOf( + substrateFormat, + onlyAddressFormat + ) + + return QrSharing( + decodingFormats = formats, + encodingFormat = onlyAddressFormat + ) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/ChainSyncDispatcher.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/ChainSyncDispatcher.kt new file mode 100644 index 0000000..1f4a557 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/ChainSyncDispatcher.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import io.novafoundation.nova.common.utils.newLimitedThreadPoolExecutor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +interface ChainSyncDispatcher { + + fun isSyncing(chainId: String): Boolean + + fun syncFinished(chainId: String) + + fun cancelExistingSync(chainId: String) + + fun launchSync(chainId: String, action: suspend () -> Unit) +} + +class AsyncChainSyncDispatcher(maxConcurrentUpdates: Int = 8) : ChainSyncDispatcher, CoroutineScope by CoroutineScope(Dispatchers.Default) { + + private val syncDispatcher = newLimitedThreadPoolExecutor(maxConcurrentUpdates).asCoroutineDispatcher() + private val syncingChains = ConcurrentHashMap() + + override fun isSyncing(chainId: String): Boolean { + return syncingChains.contains(chainId) + } + + override fun syncFinished(chainId: String) { + syncingChains.remove(chainId) + } + + override fun cancelExistingSync(chainId: String) { + syncingChains.remove(chainId)?.apply { cancel() } + } + + override fun launchSync(chainId: String, action: suspend () -> Unit) { + syncingChains[chainId] = launch(syncDispatcher) { + action() + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/FileHash.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/FileHash.kt new file mode 100644 index 0000000..cc9ec36 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/FileHash.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +typealias FileHash = String diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RawRuntimeMetadata.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RawRuntimeMetadata.kt new file mode 100644 index 0000000..5a1c32b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RawRuntimeMetadata.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +class RawRuntimeMetadata( + val metadataContent: ByteArray, + + /** + * Whether metadata stored is opaque form + * + * Opaque form of metadata is equivalent to Option> + * So the layout for opaque metadata will have a form + * 1 byte (`Optional` flag) + 2..4 bytes (CompactInt, length of Vec) + regular metadata (content of Vec) + */ + val isOpaque: Boolean +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeCacheMigrator.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeCacheMigrator.kt new file mode 100644 index 0000000..ff22640 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeCacheMigrator.kt @@ -0,0 +1,17 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +class RuntimeCacheMigrator { + + companion object { + + private const val LATEST_VERSION = 2 + } + + fun needsMetadataFetch(localVersion: Int): Boolean { + return localVersion < LATEST_VERSION + } + + fun latestVersion(): Int { + return LATEST_VERSION + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeFactory.kt new file mode 100644 index 0000000..3b4dd8b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeFactory.kt @@ -0,0 +1,238 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import android.util.Log +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.md5 +import io.novafoundation.nova.common.utils.newLimitedThreadPoolExecutor +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.runtime.multiNetwork.chain.model.TypesUsage +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.pezkuwi.PezkuwiPathTypeMapping +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote.SiVoteTypeMapping +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.TypeDefinitionParser.parseBaseDefinitions +import io.novasama.substrate_sdk_android.runtime.definitions.TypeDefinitionParser.parseNetworkVersioning +import io.novasama.substrate_sdk_android.runtime.definitions.TypeDefinitionsTree +import io.novasama.substrate_sdk_android.runtime.definitions.dynamic.DynamicTypeResolver +import io.novasama.substrate_sdk_android.runtime.definitions.dynamic.extentsions.GenericsExtension +import io.novasama.substrate_sdk_android.runtime.definitions.registry.TypePreset +import io.novasama.substrate_sdk_android.runtime.definitions.registry.TypeRegistry +import io.novasama.substrate_sdk_android.runtime.definitions.registry.v13Preset +import io.novasama.substrate_sdk_android.runtime.definitions.registry.v14Preset +import io.novasama.substrate_sdk_android.runtime.definitions.v14.TypesParserV14 +import io.novasama.substrate_sdk_android.runtime.definitions.v14.typeMapping.SiTypeMapping +import io.novasama.substrate_sdk_android.runtime.definitions.v14.typeMapping.default +import io.novasama.substrate_sdk_android.runtime.definitions.v14.typeMapping.plus +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadataReader +import io.novasama.substrate_sdk_android.runtime.metadata.builder.VersionedRuntimeBuilder +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +class ConstructedRuntime( + val runtime: RuntimeSnapshot, + val metadataHash: String, + val baseTypesHash: String?, + val ownTypesHash: String?, + val runtimeVersion: Int, + val typesUsage: TypesUsage, +) + +object BaseTypesNotInCacheException : Exception() +object ChainInfoNotInCacheException : Exception() +object NoRuntimeVersionException : Exception() + +class RuntimeFactory( + private val runtimeFilesCache: RuntimeFilesCache, + private val chainDao: ChainDao, + private val gson: Gson, + private val concurrencyLimit: Int = 1 +) { + companion object { + @Volatile + var lastDiagnostics: String = "not yet initialized" + } + + private val dispatcher = newLimitedThreadPoolExecutor(concurrencyLimit).asCoroutineDispatcher() + private val semaphore = Semaphore(concurrencyLimit) + + suspend fun constructRuntime( + chainId: String, + typesUsage: TypesUsage, + ): ConstructedRuntime = semaphore.withPermit { + constructRuntimeInternal(chainId, typesUsage) + } + + /** + * @throws BaseTypesNotInCacheException + * @throws ChainInfoNotInCacheException + * @throws NoRuntimeVersionException + */ + private suspend fun constructRuntimeInternal( + chainId: String, + typesUsage: TypesUsage, + ): ConstructedRuntime = withContext(dispatcher) { + val runtimeVersion = chainDao.runtimeInfo(chainId)?.syncedVersion ?: throw NoRuntimeVersionException + + val runtimeMetadataRaw = runCatching { runtimeFilesCache.getChainMetadata(chainId) } + .getOrElse { throw ChainInfoNotInCacheException } + + val metadataReader = RuntimeMetadataReader.read(runtimeMetadataRaw) + + Log.d("RuntimeFactory", "Constructing metadata of version ${metadataReader.metadataVersion} for chain $chainId") + + val schema = metadataReader.metadataPostV14.schema + + val typePreset = if (metadataReader.metadataVersion < 14) { + v13Preset() + } else { + TypesParserV14.parse( + lookup = metadataReader.metadata[schema.lookup], + typePreset = v14Preset(), + typeMapping = allSiTypeMappings() + ) + } + + Log.d("RuntimeFactory", "DEBUG: TypesUsage for chain $chainId = $typesUsage") + + val (types, baseHash, ownHash) = when (typesUsage) { + TypesUsage.BASE -> { + Log.d("RuntimeFactory", "DEBUG: Loading BASE types for $chainId") + val (types, baseHash) = constructBaseTypes(typePreset) + Log.d("RuntimeFactory", "DEBUG: BASE types loaded, hash=$baseHash, typeCount=${types.size}") + + Triple(types, baseHash, null) + } + TypesUsage.BOTH -> constructBaseAndChainTypes(chainId, runtimeVersion, typePreset) + TypesUsage.OWN -> { + val (types, ownHash) = constructOwnTypes(chainId, runtimeVersion, typePreset) + + Triple(types, null, ownHash) + } + TypesUsage.NONE -> Triple(typePreset, null, null) + } + + // Add Pezkuwi type aliases for chains that use pezsp_* types + val finalTypes = addPezkuwiTypeAliases(types) + + val typeRegistry = TypeRegistry(finalTypes, DynamicTypeResolver(DynamicTypeResolver.DEFAULT_COMPOUND_EXTENSIONS + GenericsExtension)) + + // Store diagnostic info for error messages + val hasExtrinsicSignature = typeRegistry["ExtrinsicSignature"] != null + val hasAddress = typeRegistry["Address"] != null + lastDiagnostics = "typesUsage=$typesUsage, ExtrinsicSig=$hasExtrinsicSignature, Address=$hasAddress, typeCount=${finalTypes.size}" + + val runtimeMetadata = VersionedRuntimeBuilder.buildMetadata(metadataReader, typeRegistry) + + ConstructedRuntime( + runtime = RuntimeSnapshot(typeRegistry, runtimeMetadata), + metadataHash = runtimeMetadataRaw.metadataContent.md5(), + baseTypesHash = baseHash, + ownTypesHash = ownHash, + runtimeVersion = runtimeVersion, + typesUsage = typesUsage + ) + } + + private fun RuntimeMetadataReader.Companion.read(rawRuntimeMetadata: RawRuntimeMetadata): RuntimeMetadataReader { + return if (rawRuntimeMetadata.isOpaque) { + readOpaque(rawRuntimeMetadata.metadataContent) + } else { + read(rawRuntimeMetadata.metadataContent) + } + } + + private suspend fun constructBaseAndChainTypes( + chainId: String, + runtimeVersion: Int, + initialPreset: TypePreset, + ): Triple { + val (basePreset, baseHash) = constructBaseTypes(initialPreset) + val (chainPreset, ownHash) = constructOwnTypes(chainId, runtimeVersion, basePreset) + + return Triple(chainPreset, baseHash, ownHash) + } + + private suspend fun constructOwnTypes( + chainId: String, + runtimeVersion: Int, + baseTypes: TypePreset, + ): Pair { + val ownTypesRaw = runCatching { runtimeFilesCache.getChainTypes(chainId) } + .getOrElse { throw ChainInfoNotInCacheException } + + val ownTypesTree = fromJson(ownTypesRaw) + + val withoutVersioning = parseBaseDefinitions(ownTypesTree, baseTypes) + + // Try to parse versioning, but if it fails (e.g., no versioning field), use base definitions + val typePreset = try { + parseNetworkVersioning(ownTypesTree, withoutVersioning, runtimeVersion) + } catch (e: IllegalArgumentException) { + Log.w("RuntimeFactory", "No versioning info in chain types for $chainId, using base definitions") + withoutVersioning + } + + return typePreset to ownTypesRaw.md5() + } + + private suspend fun constructBaseTypes(initialPreset: TypePreset): Pair { + val baseTypesRaw = runCatching { runtimeFilesCache.getBaseTypes() } + .getOrElse { + Log.e("RuntimeFactory", "DEBUG: BaseTypes NOT in cache!") + throw BaseTypesNotInCacheException + } + + Log.d("RuntimeFactory", "BaseTypes loaded, len=${baseTypesRaw.length}") + + val typePreset = parseBaseDefinitions(fromJson(baseTypesRaw), initialPreset) + + return typePreset to baseTypesRaw.md5() + } + + private fun fromJson(types: String): TypeDefinitionsTree = gson.fromJson(types, TypeDefinitionsTree::class.java) + + // NOTE: Don't use PezkuwiExtrinsicTypeMapping here - its aliases create broken TypeReferences. + // Instead, addPezkuwiTypeAliases() handles copying actual type instances in the RuntimeFactory. + private fun allSiTypeMappings() = SiTypeMapping.default() + PezkuwiPathTypeMapping() + SiVoteTypeMapping() + + /** + * For Pezkuwi chains that use pezsp_* type paths, copy the actual type instances + * to the standard type names (Address, ExtrinsicSignature, etc.). + * + * Pezkuwi chains use pezsp_* prefixes instead of sp_* prefixes for their type paths. + * This function ensures that code looking for standard type names will find the + * correct Pezkuwi types. + */ + private fun addPezkuwiTypeAliases(types: TypePreset): TypePreset { + val hasPezspTypes = types.keys.any { it.startsWith("pezsp_") || it.startsWith("pezframe_") || it.startsWith("pezpallet_") } + if (!hasPezspTypes) { + return types + } + + val mutableTypes = types.toMutableMap() + + // Map Pezkuwi type paths to standard type names. + // These types are parsed as actual types from metadata (not aliased to built-ins), + // and we copy them to standard names so existing code can find them. + val pezkuwiTypeAliases = listOf( + "pezsp_runtime.multiaddress.MultiAddress" to "Address", + "pezsp_runtime.multiaddress.MultiAddress" to "MultiAddress", + "pezsp_runtime.MultiSignature" to "ExtrinsicSignature", + "pezsp_runtime.MultiSignature" to "MultiSignature", + "pezsp_runtime.generic.era.Era" to "Era", + // Fee-related types + "pezframe_support.dispatch.DispatchInfo" to "DispatchInfo", + "pezpallet_transaction_payment.types.RuntimeDispatchInfo" to "RuntimeDispatchInfo", + "pezframe_support.dispatch.DispatchClass" to "DispatchClass" + ) + + for ((pezspPath, standardName) in pezkuwiTypeAliases) { + types[pezspPath]?.let { pezspType -> + mutableTypes[standardName] = pezspType + } + } + + return mutableTypes + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeFilesCache.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeFilesCache.kt new file mode 100644 index 0000000..8554359 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeFilesCache.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.interfaces.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +private const val DEFAULT_FILE_NAME = "default" + +private const val METADATA_FILE_MASK = "metadata_%s" +private const val TYPE_DEFINITIONS_FILE_MASK = "definitions_%s" + +// Non-migrated versions are stored in non-opaque format +private const val IS_METADATA_OPAQUE_DEFAULT = false + +class RuntimeFilesCache( + private val fileProvider: FileProvider, + private val preferences: Preferences, +) { + + suspend fun getBaseTypes(): String { + return readCacheFile(TYPE_DEFINITIONS_FILE_MASK.format(DEFAULT_FILE_NAME)) + } + + suspend fun getChainTypes(chainId: String): String { + return readCacheFile(TYPE_DEFINITIONS_FILE_MASK.format(chainId)) + } + + suspend fun getChainMetadata(chainId: String): RawRuntimeMetadata { + return RawRuntimeMetadata( + metadataContent = readCacheFileBytes(METADATA_FILE_MASK.format(chainId)), + isOpaque = isMetadataOpaque(chainId) + ) + } + + suspend fun saveBaseTypes(types: String) { + writeToCacheFile(TYPE_DEFINITIONS_FILE_MASK.format(DEFAULT_FILE_NAME), types) + } + + suspend fun saveChainTypes(chainId: String, types: String) { + val fileName = TYPE_DEFINITIONS_FILE_MASK.format(chainId) + + writeToCacheFile(fileName, types) + } + + suspend fun saveChainMetadata(chainId: String, metadata: RawRuntimeMetadata) { + val fileName = METADATA_FILE_MASK.format(chainId) + writeToCacheFile(fileName, metadata.metadataContent) + saveMetadataOpaque(chainId, metadata.isOpaque) + } + + private suspend fun writeToCacheFile(name: String, content: String) { + return withContext(Dispatchers.IO) { + getCacheFile(name).writeText(content) + } + } + + private suspend fun writeToCacheFile(name: String, content: ByteArray) { + return withContext(Dispatchers.IO) { + getCacheFile(name).writeBytes(content) + } + } + + private suspend fun readCacheFile(name: String): String { + return withContext(Dispatchers.IO) { + getCacheFile(name).readText() + } + } + + private suspend fun readCacheFileBytes(name: String): ByteArray { + return withContext(Dispatchers.IO) { + getCacheFile(name).readBytes() + } + } + + private suspend fun getCacheFile(name: String): File { + return withContext(Dispatchers.IO) { fileProvider.getFileInInternalCacheStorage(name) } + } + + private fun isMetadataOpaque(chainId: String): Boolean { + return preferences.getBoolean(isMetadataOpaqueKey(chainId), IS_METADATA_OPAQUE_DEFAULT) + } + + private fun saveMetadataOpaque(chainId: String, isOpaque: Boolean) { + return preferences.putBoolean(isMetadataOpaqueKey(chainId), isOpaque) + } + + private fun isMetadataOpaqueKey(chainId: String): String { + return "RuntimeFilesCache.opaqueMetadata.$chainId" + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeMetadataFetcher.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeMetadataFetcher.kt new file mode 100644 index 0000000..04236ee --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeMetadataFetcher.kt @@ -0,0 +1,70 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import android.util.Log +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.rpc.stateCall +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.metadata.GetMetadataRequest +import io.novasama.substrate_sdk_android.scale.dataType.list +import io.novasama.substrate_sdk_android.scale.dataType.toHex +import io.novasama.substrate_sdk_android.scale.dataType.uint32 +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.executeAsync +import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull +import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.state.StateCallRequest + +private const val LATEST_SUPPORTED_METADATA_VERSION = 15 + +class RuntimeMetadataFetcher { + + suspend fun fetchRawMetadata( + chainId: ChainId, + socketService: SocketService + ): RawRuntimeMetadata { + return runCatching { + socketService.fetchNewestMetadata() + }.onSuccess { + Log.d("RuntimeMetadataFetcher", "Fetched metadata via runtime call for $chainId") + }.getOrElse { + Log.d("RuntimeMetadataFetcher", "Failed to sync metadata via runtime call, fall-backing to legacy rpc call for chain $chainId", it) + + socketService.fetchLegacyMetadata() + } + } + + private suspend fun SocketService.fetchNewestMetadata(): RawRuntimeMetadata { + val availableVersions = getAvailableMetadataVersions() + val latestSupported = availableVersions.sorted().last { it <= LATEST_SUPPORTED_METADATA_VERSION } + + return RawRuntimeMetadata( + metadataContent = getMetadataAtVersion(latestSupported), + isOpaque = true + ) + } + + private suspend fun SocketService.fetchLegacyMetadata(): RawRuntimeMetadata { + val result = executeAsync(GetMetadataRequest, mapper = pojo().nonNull()) + + return RawRuntimeMetadata( + metadataContent = result.fromHex(), + isOpaque = false + ) + } + + private suspend fun SocketService.getAvailableMetadataVersions(): List { + val request = StateCallRequest(runtimeRpcName = "Metadata_metadata_versions", "0x") + return stateCall(request, returnType = list(uint32)).map { it.toInt() } + } + + private suspend fun SocketService.getMetadataAtVersion(version: Int): ByteArray { + val versionEncoded = uint32.toHex(version.toUInt()) + val request = StateCallRequest("Metadata_metadata_at_version", versionEncoded) + val response = stateCall(request) + requireNotNull(response) { + "Non existent metadata" + } + + return response.fromHex() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeProvider.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeProvider.kt new file mode 100644 index 0000000..af68a31 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeProvider.kt @@ -0,0 +1,159 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.runtime.ext.typesUsage +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.TypesUsage +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchronizer +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue + +class RuntimeProvider( + private val runtimeFactory: RuntimeFactory, + private val runtimeSyncService: RuntimeSyncService, + private val baseTypeSynchronizer: BaseTypeSynchronizer, + private val runtimeFilesCache: RuntimeFilesCache, + chain: Chain, +) : CoroutineScope by CoroutineScope(Dispatchers.Default) { + + private val chainId = chain.id + + private var typesUsage = chain.typesUsage + + private val runtimeFlow = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private var currentConstructionJob: Job? = null + + suspend fun get(): RuntimeSnapshot { + val runtime = runtimeFlow.first() + + return runtime.runtime + } + + suspend fun getRaw(): RawRuntimeMetadata { + return runtimeFilesCache.getChainMetadata(chainId) + } + + fun observe(): Flow = runtimeFlow.map { it.runtime } + + init { + baseTypeSynchronizer.syncStatusFlow + .onEach(::considerReconstructingRuntime) + .launchIn(this) + + runtimeSyncService.syncResultFlow(chainId) + .onEach(::considerReconstructingRuntime) + .launchIn(this) + + tryLoadFromCache() + } + + fun finish() { + invalidateRuntime() + + cancel() + } + + fun considerUpdatingTypesUsage(newTypesUsage: TypesUsage) { + if (typesUsage != newTypesUsage) { + typesUsage = newTypesUsage + + constructNewRuntime(typesUsage) + } + } + + private fun tryLoadFromCache() { + constructNewRuntime(typesUsage) + } + + private fun considerReconstructingRuntime(runtimeSyncResult: SyncResult) { + launch { + currentConstructionJob?.join() + + val currentVersion = runtimeFlow.replayCache.firstOrNull() + + if ( + currentVersion == null || + // metadata was synced and new hash is different from current one + (runtimeSyncResult.metadataHash != null && currentVersion.metadataHash != runtimeSyncResult.metadataHash) || + // types were synced and new hash is different from current one + (runtimeSyncResult.typesHash != null && currentVersion.ownTypesHash != runtimeSyncResult.typesHash) + ) { + constructNewRuntime(typesUsage) + } + } + } + + private fun considerReconstructingRuntime(newBaseTypesHash: String) { + launch { + currentConstructionJob?.join() + + val currentVersion = runtimeFlow.replayCache.firstOrNull() + + if (typesUsage == TypesUsage.OWN) { + return@launch + } + + if ( + currentVersion == null || + currentVersion.baseTypesHash != newBaseTypesHash + ) { + constructNewRuntime(typesUsage) + } + } + } + + @OptIn(ExperimentalTime::class) + private fun constructNewRuntime(typesUsage: TypesUsage) { + currentConstructionJob?.cancel() + + currentConstructionJob = launch { + invalidateRuntime() + + runCatching { + val (value, duration) = measureTimedValue { runtimeFactory.constructRuntime(chainId, typesUsage) } + + Log.d(this@RuntimeProvider.LOG_TAG, "Constructed runtime for $chainId in ${duration.inWholeSeconds} seconds") + + runtimeFlow.emit(value) + }.onFailure { + when (it) { + ChainInfoNotInCacheException -> { + runtimeSyncService.cacheNotFound(chainId) + + Log.w(this@RuntimeProvider.LOG_TAG, "Runtime cache was not found for $chainId") + } + BaseTypesNotInCacheException -> { + baseTypeSynchronizer.cacheNotFound() + + Log.w(this@RuntimeProvider.LOG_TAG, "Base types cache were not found") + } + NoRuntimeVersionException -> { + Log.w(this@RuntimeProvider.LOG_TAG, "Runtime version for $chainId was not found in database") + } // pass + else -> Log.e(this@RuntimeProvider.LOG_TAG, "Failed to construct runtime ($chainId)", it) + } + } + + currentConstructionJob = null + } + } + + private fun invalidateRuntime() { + runtimeFlow.resetReplayCache() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeProviderPool.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeProviderPool.kt new file mode 100644 index 0000000..5c18b54 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeProviderPool.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import io.novafoundation.nova.runtime.ext.typesUsage +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchronizer +import java.util.concurrent.ConcurrentHashMap + +class RuntimeProviderPool( + private val runtimeFactory: RuntimeFactory, + private val runtimeSyncService: RuntimeSyncService, + private val runtimeFilesCache: RuntimeFilesCache, + private val baseTypeSynchronizer: BaseTypeSynchronizer +) { + + private val pool = ConcurrentHashMap() + + fun getRuntimeProvider(chainId: String) = pool.getValue(chainId) + + fun setupRuntimeProvider(chain: Chain): RuntimeProvider { + val provider = pool.getOrPut(chain.id) { + RuntimeProvider(runtimeFactory, runtimeSyncService, baseTypeSynchronizer, runtimeFilesCache, chain) + } + + provider.considerUpdatingTypesUsage(chain.typesUsage) + + return provider + } + + fun removeRuntimeProvider(chainId: String) { + pool.remove(chainId)?.apply { finish() } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeSubscriptionPool.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeSubscriptionPool.kt new file mode 100644 index 0000000..138c167 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeSubscriptionPool.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import kotlinx.coroutines.cancel +import java.util.concurrent.ConcurrentHashMap + +class RuntimeSubscriptionPool( + private val chainDao: ChainDao, + private val runtimeSyncService: RuntimeSyncService +) { + + private val pool = ConcurrentHashMap() + + fun setupRuntimeSubscription(chain: Chain, connection: ChainConnection): RuntimeVersionSubscription { + return pool.getOrPut(chain.id) { + RuntimeVersionSubscription(chain.id, connection, chainDao, runtimeSyncService) + } + } + + fun removeSubscription(chainId: String) { + pool.remove(chainId)?.apply { cancel() } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeSyncService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeSyncService.kt new file mode 100644 index 0000000..ce160e0 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeSyncService.kt @@ -0,0 +1,144 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import android.util.Log +import io.novafoundation.nova.common.utils.md5 +import io.novafoundation.nova.common.utils.retryUntilDone +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.model.chain.ChainRuntimeInfoLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.TypesFetcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import java.util.concurrent.ConcurrentHashMap + +data class SyncInfo( + val connection: ChainConnection, + val typesUrl: String?, +) + +class SyncResult( + val chainId: String, + val metadataHash: FileHash?, + val typesHash: FileHash?, +) + +private const val LOG_TAG = "RuntimeSyncService" + +class RuntimeSyncService( + private val typesFetcher: TypesFetcher, + private val runtimeFilesCache: RuntimeFilesCache, + private val chainDao: ChainDao, + private val runtimeMetadataFetcher: RuntimeMetadataFetcher, + private val cacheMigrator: RuntimeCacheMigrator, + private val chainSyncDispatcher: ChainSyncDispatcher, +) { + + private val knownChains = ConcurrentHashMap() + + private val _syncStatusFlow = MutableSharedFlow() + + fun syncResultFlow(forChain: String): Flow { + return _syncStatusFlow.filter { it.chainId == forChain } + } + + fun applyRuntimeVersion(chainId: String) { + launchSync(chainId) + } + + fun registerChain(chain: Chain, connection: ChainConnection) { + val existingSyncInfo = knownChains[chain.id] + + val newSyncInfo = SyncInfo( + connection = connection, + typesUrl = chain.types?.url + ) + + knownChains[chain.id] = newSyncInfo + + if (existingSyncInfo != null && existingSyncInfo != newSyncInfo) { + launchSync(chain.id) + } + } + + fun unregisterChain(chainId: String) { + knownChains.remove(chainId) + + chainSyncDispatcher.cancelExistingSync(chainId) + } + + // Android may clear cache files sometimes so it necessary to have force sync mechanism + fun cacheNotFound(chainId: String) { + if (!chainSyncDispatcher.isSyncing(chainId)) { + launchSync(chainId, forceFullSync = true) + } + } + + fun isSyncing(chainId: String): Boolean { + return chainSyncDispatcher.isSyncing(chainId) + } + + private fun launchSync( + chainId: String, + forceFullSync: Boolean = false, + ) { + chainSyncDispatcher.cancelExistingSync(chainId) + + chainSyncDispatcher.launchSync(chainId) { + val syncResult = runCatching { + sync(chainId, forceFullSync) + }.getOrNull() + + chainSyncDispatcher.syncFinished(chainId) + + syncResult?.let { _syncStatusFlow.emit(it) } + } + } + + private suspend fun sync( + chainId: String, + forceFullSync: Boolean, + ): SyncResult? { + val syncInfo = knownChains[chainId] + + if (syncInfo == null) { + Log.w(LOG_TAG, "Unknown chain with id $chainId requested to be synced") + return null + } + + val runtimeInfo = chainDao.runtimeInfo(chainId) ?: return null + + val shouldSyncMetadata = runtimeInfo.shouldSyncMetadata() || forceFullSync + + val metadataHash = if (shouldSyncMetadata) { + val runtimeMetadata = runtimeMetadataFetcher.fetchRawMetadata(chainId, syncInfo.connection.socketService) + + runtimeFilesCache.saveChainMetadata(chainId, runtimeMetadata) + + chainDao.updateSyncedRuntimeVersion(chainId, runtimeInfo.remoteVersion, cacheMigrator.latestVersion()) + + runtimeMetadata.metadataContent.md5() + } else { + null + } + + val typesHash = syncInfo.typesUrl?.let { typesUrl -> + retryUntilDone { + val types = typesFetcher.getTypes(typesUrl) + + runtimeFilesCache.saveChainTypes(chainId, types) + + types.md5() + } + } + + return SyncResult( + metadataHash = metadataHash, + typesHash = typesHash, + chainId = chainId + ) + } + + private fun ChainRuntimeInfoLocal.shouldSyncMetadata() = syncedVersion != remoteVersion || cacheMigrator.needsMetadataFetch(localMigratorVersion) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeVersionSubscription.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeVersionSubscription.kt new file mode 100644 index 0000000..a6408b6 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeVersionSubscription.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.chain.runtimeVersionChange +import io.novasama.substrate_sdk_android.wsrpc.subscriptionFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +class RuntimeVersionSubscription( + private val chainId: String, + connection: ChainConnection, + private val chainDao: ChainDao, + private val runtimeSyncService: RuntimeSyncService, +) : CoroutineScope by CoroutineScope(Dispatchers.Default) { + + init { + connection.socketService.subscriptionFlow(SubscribeRuntimeVersionRequest) + .map { it.runtimeVersionChange() } + .onEach { runtimeVersion -> + chainDao.updateRemoteRuntimeVersionIfChainExists( + chainId, + runtimeVersion = runtimeVersion.specVersion, + transactionVersion = runtimeVersion.transactionVersion + ) + + runtimeSyncService.applyRuntimeVersion(chainId) + } + .catch { Log.e(this@RuntimeVersionSubscription.LOG_TAG, "Failed to sync runtime version for $chainId", it) } + .launchIn(this) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/SubscribeRuntimeVersionRequest.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/SubscribeRuntimeVersionRequest.kt new file mode 100644 index 0000000..12704d6 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/SubscribeRuntimeVersionRequest.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest + +object SubscribeRuntimeVersionRequest : RuntimeRequest( + method = "state_subscribeRuntimeVersion", + params = listOf() +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/BlockEvents.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/BlockEvents.kt new file mode 100644 index 0000000..cfa25bd --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/BlockEvents.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.repository + +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +class BlockEvents( + val initialization: List, + val applyExtrinsic: List, + val finalization: List +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/EventRecords.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/EventRecords.kt new file mode 100644 index 0000000..cd1b007 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/EventRecords.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.EventRecord +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent + +typealias EventRecords = List + +fun EventRecords.events(): List = map { it.event } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/EventsRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/EventsRepository.kt new file mode 100644 index 0000000..2870f42 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/EventsRepository.kt @@ -0,0 +1,125 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.common.data.network.runtime.binding.EventRecord +import io.novafoundation.nova.common.data.network.runtime.binding.Phase +import io.novafoundation.nova.common.utils.extrinsicHash +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.rpc.RpcCalls +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.runtime.storage.source.query.AtBlock +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNullWithBlockHash +import io.novafoundation.nova.runtime.storage.typed.events +import io.novafoundation.nova.runtime.storage.typed.system +import io.novasama.substrate_sdk_android.extensions.tryFindNonNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Extrinsic +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import kotlinx.coroutines.flow.Flow + +interface EventsRepository { + + /** + * @return events in block corresponding to [blockHash] or in current block, if [blockHash] is null + * Unparsed events are not included + */ + suspend fun getBlockEvents(chainId: ChainId, blockHash: BlockHash? = null): BlockEvents + + suspend fun getExtrinsicWithEvents(chainId: ChainId, extrinsicHash: String, blockHash: BlockHash? = null): ExtrinsicWithEvents? + + fun subscribeEventRecords(chainId: ChainId): Flow>> +} + +internal class RemoteEventsRepository( + private val rpcCalls: RpcCalls, + private val remoteStorageSource: StorageDataSource +) : EventsRepository { + + override suspend fun getBlockEvents(chainId: ChainId, blockHash: BlockHash?): BlockEvents { + return remoteStorageSource.query(chainId, at = blockHash) { + val eventRecords = metadata.system.events.query().orEmpty() + val block = rpcCalls.getBlock(chainId, blockHash) + + BlockEvents( + initialization = eventRecords.mapNotNull { record -> record.event.takeIf { record.phase is Phase.Initialization } }, + applyExtrinsic = groupExtrinsicWithEvents(eventRecords, block.block.extrinsics), + finalization = eventRecords.mapNotNull { record -> record.event.takeIf { record.phase is Phase.Finalization } } + ) + } + } + + context(StorageQueryContext) + private fun groupExtrinsicWithEvents( + eventRecords: List, + extrinsics: List + ): List { + val eventsByExtrinsicIndex: Map> = eventRecords.mapNotNull { eventRecord -> + (eventRecord.phase as? Phase.ApplyExtrinsic)?.let { + it.extrinsicId.toInt() to eventRecord.event + } + }.groupBy( + keySelector = { it.first }, + valueTransform = { it.second } + ) + + return extrinsics.mapIndexedNotNull { index, extrinsicScale -> + val decodedExtrinsic = Extrinsic.fromHexOrNull(runtime, extrinsicScale) + + decodedExtrinsic?.let { + val extrinsicEvents = eventsByExtrinsicIndex[index] ?: emptyList() + + ExtrinsicWithEvents( + extrinsic = decodedExtrinsic, + extrinsicHash = extrinsicScale.extrinsicHash(), + events = extrinsicEvents + ) + } + } + } + + override suspend fun getExtrinsicWithEvents( + chainId: ChainId, + extrinsicHash: String, + blockHash: BlockHash? + ): ExtrinsicWithEvents? { + return remoteStorageSource.query(chainId, at = blockHash) { + val eventRecords = metadata.system.events.query().orEmpty() + val block = rpcCalls.getBlock(chainId, blockHash) + + block.block.extrinsics.withIndex().tryFindNonNull { (index, extrinsicScale) -> + val hash = extrinsicScale.extrinsicHash() + if (hash != extrinsicHash) return@tryFindNonNull null + + val extrinsic = Extrinsic.fromHexOrNull(runtime, extrinsicScale) ?: return@tryFindNonNull null + + val extrinsicEvents = eventRecords.filterByExtrinsicIndex(index) + + ExtrinsicWithEvents( + extrinsicHash = hash, + extrinsic = extrinsic, + events = extrinsicEvents + ) + } + } + } + + override fun subscribeEventRecords(chainId: ChainId): Flow>> { + return remoteStorageSource.subscribe(chainId) { + metadata.system.events.observeNonNullWithBlockHash() + } + } + + private fun List.filterByExtrinsicIndex(index: Int): List { + return mapNotNull { eventRecord -> + val phase = eventRecord.phase + if (phase !is Phase.ApplyExtrinsic) return@mapNotNull null + + val extrinsicIndex = phase.extrinsicId.toInt() + if (extrinsicIndex != index) return@mapNotNull null + + eventRecord.event + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/ExtrinsicWithEvents.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/ExtrinsicWithEvents.kt new file mode 100644 index 0000000..214dbbf --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/ExtrinsicWithEvents.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.instanceOf +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Extrinsic +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import java.math.BigInteger + +private const val SUCCESS_EVENT = "ExtrinsicSuccess" +private const val FAILURE_EVENT = "ExtrinsicFailed" + +enum class ExtrinsicStatus { + SUCCESS, FAILURE +} + +class ExtrinsicWithEvents( + val extrinsic: Extrinsic.Instance, + val extrinsicHash: String, + val events: List +) + +fun ExtrinsicWithEvents.status(): ExtrinsicStatus? { + return events.firstNotNullOfOrNull { + when { + it.instanceOf(Modules.SYSTEM, SUCCESS_EVENT) -> ExtrinsicStatus.SUCCESS + it.instanceOf(Modules.SYSTEM, FAILURE_EVENT) -> ExtrinsicStatus.FAILURE + else -> null + } + } +} + +fun ExtrinsicWithEvents.isSuccess(): Boolean { + val status = requireNotNull(status()) { + "Not able to identify extrinsic status" + } + + return status == ExtrinsicStatus.SUCCESS +} + +fun List.nativeFee(): BigInteger? { + val event = findEvent(Modules.TRANSACTION_PAYMENT, "TransactionFeePaid") ?: return null + val (_, actualFee, tip) = event.arguments + + return bindNumber(actualFee) + bindNumber(tip) +} + +fun List.assetTxFeePaidEvent(): GenericEvent.Instance? { + return findEvent(Modules.ASSET_TX_PAYMENT, "AssetTxFeePaid") +} + +fun List.requireNativeFee(): BigInteger { + return requireNotNull(nativeFee()) { + "No native fee event found" + } +} + +fun List.findExtrinsicFailure(): GenericEvent.Instance? { + return findEvent(Modules.SYSTEM, FAILURE_EVENT) +} + +fun List.findExtrinsicFailureOrThrow(): GenericEvent.Instance { + return requireNotNull(findExtrinsicFailure()) { + "No Extrinsic Failure event found" + } +} + +fun List.findEvent(module: String, event: String): GenericEvent.Instance? { + return find { it.instanceOf(module, event) } +} + +fun List.findEventOrThrow(module: String, event: String): GenericEvent.Instance { + return first { it.instanceOf(module, event) } +} + +fun List.findLastEvent(module: String, event: String): GenericEvent.Instance? { + return findLast { it.instanceOf(module, event) } +} + +fun List.hasEvent(module: String, event: String): Boolean { + return any { it.instanceOf(module, event) } +} + +fun List.findEvents(module: String, event: String): List { + return filter { it.instanceOf(module, event) } +} + +fun List.findEvents(module: String, vararg events: String): List { + return filter { it.instanceOf(module, *events) } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/RuntimeVersion.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/RuntimeVersion.kt new file mode 100644 index 0000000..3a8da04 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/RuntimeVersion.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.repository + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class RuntimeVersion( + val chainId: ChainId, + val specVersion: Int +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/RuntimeVersionsRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/RuntimeVersionsRepository.kt new file mode 100644 index 0000000..8ab6283 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/repository/RuntimeVersionsRepository.kt @@ -0,0 +1,25 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.repository + +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.model.chain.ChainRuntimeInfoLocal + +interface RuntimeVersionsRepository { + + suspend fun getAllRuntimeVersions(): List +} + +internal class DbRuntimeVersionsRepository( + private val chainDao: ChainDao +) : RuntimeVersionsRepository { + + override suspend fun getAllRuntimeVersions(): List { + return chainDao.allRuntimeInfos().map(::mapRuntimeInfoLocalToRuntimeVersion) + } + + private fun mapRuntimeInfoLocalToRuntimeVersion(runtimeInfoLocal: ChainRuntimeInfoLocal): RuntimeVersion { + return RuntimeVersion( + chainId = runtimeInfoLocal.chainId, + specVersion = runtimeInfoLocal.syncedVersion + ) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/BaseTypeSynchronizer.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/BaseTypeSynchronizer.kt new file mode 100644 index 0000000..d251612 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/BaseTypeSynchronizer.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.types + +import io.novafoundation.nova.common.utils.md5 +import io.novafoundation.nova.common.utils.retryUntilDone +import io.novafoundation.nova.runtime.multiNetwork.runtime.FileHash +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFilesCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +class BaseTypeSynchronizer( + private val runtimeFilesCache: RuntimeFilesCache, + private val typesFetcher: TypesFetcher, +) : CoroutineScope by CoroutineScope(Dispatchers.Default) { + + @Volatile + private var syncJob: Job? = null + + private val _syncStatusFlow = MutableSharedFlow() + val syncStatusFlow: Flow = _syncStatusFlow + + @Synchronized + fun sync() { + syncJob?.cancel() + + syncJob = launch { + retryUntilDone { + val definitions = typesFetcher.getBaseTypes() + + runtimeFilesCache.saveBaseTypes(definitions) + + _syncStatusFlow.emit(definitions.md5()) + + syncJob = null + } + } + } + + @Synchronized + fun cacheNotFound() { + if (syncJob == null) { + sync() + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/TypesFetcher.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/TypesFetcher.kt new file mode 100644 index 0000000..8cbe427 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/TypesFetcher.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.types + +import retrofit2.http.GET +import retrofit2.http.Url + +private const val DEFAULT_TYPES_URL = "https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/types/default.json" + +interface TypesFetcher { + + @GET + suspend fun getTypes(@Url url: String): String +} + +suspend fun TypesFetcher.getBaseTypes() = getTypes(DEFAULT_TYPES_URL) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/pezkuwi/PezkuwiExtrinsicTypeMapping.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/pezkuwi/PezkuwiExtrinsicTypeMapping.kt new file mode 100644 index 0000000..89c5701 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/pezkuwi/PezkuwiExtrinsicTypeMapping.kt @@ -0,0 +1,85 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.pezkuwi + +import android.util.Log +import io.novasama.substrate_sdk_android.runtime.definitions.registry.TypePresetBuilder +import io.novasama.substrate_sdk_android.runtime.definitions.registry.alias +import io.novasama.substrate_sdk_android.runtime.definitions.types.Type +import io.novasama.substrate_sdk_android.runtime.definitions.types.instances.ExtrinsicTypes +import io.novasama.substrate_sdk_android.runtime.definitions.v14.typeMapping.SiTypeMapping +import io.novasama.substrate_sdk_android.runtime.metadata.v14.PortableType +import io.novasama.substrate_sdk_android.runtime.metadata.v14.paramType +import io.novasama.substrate_sdk_android.runtime.metadata.v14.type +import io.novasama.substrate_sdk_android.scale.EncodableStruct + +/** + * Custom SiTypeMapping for Pezkuwi chains that use pezsp_* package prefixes + * instead of the standard sp_* prefixes used by Polkadot/Substrate chains. + * + * This mapping detects `pezsp_runtime.generic.unchecked_extrinsic.UncheckedExtrinsic` + * and extracts the Address and Signature type parameters to register them as + * ExtrinsicTypes.ADDRESS and ExtrinsicTypes.SIGNATURE aliases. + * + * Without this mapping, Pezkuwi transactions would fail because the SDK's default + * AddExtrinsicTypesSiTypeMapping only looks for sp_runtime paths. + */ +private const val PEZSP_UNCHECKED_EXTRINSIC_TYPE = "pezsp_runtime.generic.unchecked_extrinsic.UncheckedExtrinsic" + +class PezkuwiExtrinsicTypeMapping : SiTypeMapping { + + override fun map( + originalDefinition: EncodableStruct, + suggestedTypeName: String, + typesBuilder: TypePresetBuilder + ): Type<*>? { + // Log all type names that contain "pezsp" and "extrinsic" for debugging + if (suggestedTypeName.contains("pezsp", ignoreCase = true) && suggestedTypeName.contains("extrinsic", ignoreCase = true)) { + Log.d("PezkuwiExtrinsicMapping", "Seeing type: $suggestedTypeName") + } + + if (suggestedTypeName == PEZSP_UNCHECKED_EXTRINSIC_TYPE) { + Log.d("PezkuwiExtrinsicMapping", "MATCHED! Processing UncheckedExtrinsic type") + + // Extract Address type param and register as "Address" alias + val addressAdded = addTypeFromTypeParams( + originalDefinition = originalDefinition, + typesBuilder = typesBuilder, + typeParamName = "Address", + newTypeName = ExtrinsicTypes.ADDRESS + ) + Log.d("PezkuwiExtrinsicMapping", "Address alias added: $addressAdded") + + // Extract Signature type param and register as "ExtrinsicSignature" alias + val sigAdded = addTypeFromTypeParams( + originalDefinition = originalDefinition, + typesBuilder = typesBuilder, + typeParamName = "Signature", + newTypeName = ExtrinsicTypes.SIGNATURE + ) + Log.d("PezkuwiExtrinsicMapping", "ExtrinsicSignature alias added: $sigAdded") + } + + // We don't modify any existing type, just add aliases + return null + } + + private fun addTypeFromTypeParams( + originalDefinition: EncodableStruct, + typesBuilder: TypePresetBuilder, + typeParamName: String, + newTypeName: String + ): Boolean { + val paramType = originalDefinition.type.paramType(typeParamName) + Log.d("PezkuwiExtrinsicMapping", "Looking for param '$typeParamName', found: $paramType") + + if (paramType == null) { + Log.w("PezkuwiExtrinsicMapping", "Could not find type param '$typeParamName' in UncheckedExtrinsic") + return false + } + + // Type with type-id name is present in the registry as alias to fully qualified name + val targetType = paramType.toString() + Log.d("PezkuwiExtrinsicMapping", "Creating alias: $newTypeName -> $targetType") + typesBuilder.alias(newTypeName, targetType) + return true + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/pezkuwi/PezkuwiPathTypeMapping.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/pezkuwi/PezkuwiPathTypeMapping.kt new file mode 100644 index 0000000..9b5ec34 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/pezkuwi/PezkuwiPathTypeMapping.kt @@ -0,0 +1,31 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.pezkuwi + +import io.novasama.substrate_sdk_android.runtime.definitions.v14.typeMapping.PathMatchTypeMapping +import io.novasama.substrate_sdk_android.runtime.definitions.v14.typeMapping.PathMatchTypeMapping.Replacement.AliasTo + +/** + * PathMatchTypeMapping for Pezkuwi chains that use pezsp_* and pezframe_* package prefixes + * instead of the standard sp_* and frame_* prefixes used by Polkadot/Substrate chains. + * + * This maps specific Pezkuwi type paths to standard type names: + * - RuntimeCall/RuntimeEvent -> GenericCall/GenericEvent + * + * IMPORTANT: Era, MultiSignature, MultiAddress, and Weight types are NOT aliased here. + * They need to be parsed as actual types from metadata. RuntimeFactory.addPezkuwiTypeAliases() + * handles copying these types to standard names after parsing. + * + * NOTE: Weight types (pezsp_weights.weight_v2.Weight) are NOT aliased because the SDK + * doesn't have a WeightV1 type defined. They are parsed as structs from metadata. + */ +fun PezkuwiPathTypeMapping(): PathMatchTypeMapping = PathMatchTypeMapping( + // NOTE: Do NOT alias pezsp_runtime.generic.era.Era, pezsp_runtime.MultiSignature, + // pezsp_runtime.multiaddress.MultiAddress, or pezsp_weights.weight_v2.Weight here. + // These need to be parsed as actual types from metadata. + // RuntimeFactory.addPezkuwiTypeAliases() copies the parsed types to standard names. + + // Runtime call/event types for Pezkuwi + "*.RuntimeCall" to AliasTo("GenericCall"), + "*.RuntimeEvent" to AliasTo("GenericEvent"), + "*_runtime.Call" to AliasTo("GenericCall"), + "*_runtime.Event" to AliasTo("GenericEvent"), +) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/vote/SiVoteTypeMapping.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/vote/SiVoteTypeMapping.kt new file mode 100644 index 0000000..7551ebc --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/vote/SiVoteTypeMapping.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote + +import io.novasama.substrate_sdk_android.runtime.definitions.v14.typeMapping.ReplaceTypesSiTypeMapping + +fun SiVoteTypeMapping(): ReplaceTypesSiTypeMapping { + val voteType = VoteType("NovaWallet.ConvictionVote") + + return ReplaceTypesSiTypeMapping( + "pallet_democracy.vote.Vote" to voteType, + "pallet_conviction_voting.vote.Vote" to voteType + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/vote/VoteType.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/vote/VoteType.kt new file mode 100644 index 0000000..8b85c36 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/runtime/types/custom/vote/VoteType.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime.types.custom.vote + +import io.emeraldpay.polkaj.scale.ScaleCodecReader +import io.emeraldpay.polkaj.scale.ScaleCodecWriter +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.Primitive +import kotlin.experimental.and +import kotlin.experimental.or + +data class Vote( + val aye: Boolean, + val conviction: Conviction +) + +enum class Conviction { + None, + Locked1x, + Locked2x, + Locked3x, + Locked4x, + Locked5x, + Locked6x +} + +fun mapConvictionFromString(conviction: String): Conviction { + return Conviction.values().first { it.name == conviction } +} + +private const val AYE_MASK = 0b1000_0000.toByte() +private const val VOTE_MASK = 0b0111_1111.toByte() + +class VoteType(name: String) : Primitive(name) { + + override fun decode(scaleCodecReader: ScaleCodecReader, runtime: RuntimeSnapshot): Vote { + val compactVote = scaleCodecReader.readByte() + + val isAye = compactVote and AYE_MASK == AYE_MASK + val convictionIdx = compactVote and VOTE_MASK + + val conviction = Conviction.values()[convictionIdx.toInt()] + + return Vote( + aye = isAye, + conviction = conviction + ) + } + + override fun encode(scaleCodecWriter: ScaleCodecWriter, runtime: RuntimeSnapshot, value: Vote) { + val ayeBit = if (value.aye) AYE_MASK else 0 + val compactVote = value.conviction.ordinal.toByte() or ayeBit + + scaleCodecWriter.writeByte(compactVote) + } + + override fun isValidInstance(instance: Any?): Boolean { + return instance is Vote + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/binding/BindingBuilder.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/binding/BindingBuilder.kt new file mode 100644 index 0000000..ba1980d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/binding/BindingBuilder.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.runtime.network.binding + +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.runtime.storage.source.query.DynamicInstanceBinder +import java.math.BigInteger + +fun collectionOf(itemBinder: DynamicInstanceBinder): DynamicInstanceBinder> { + return { bindList(it, itemBinder) } +} + +fun number(): DynamicInstanceBinder = ::bindNumber diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/binding/BlockWeightLimits.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/binding/BlockWeightLimits.kt new file mode 100644 index 0000000..a1947ce --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/binding/BlockWeightLimits.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.runtime.network.binding + +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.data.network.runtime.binding.bindWeightV2 +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct + +class BlockWeightLimits( + val maxBlock: WeightV2, + val perClass: PerClassLimits +) { + + companion object { + + fun bind(decoded: Any?): BlockWeightLimits { + val asStruct = decoded.castToStruct() + return BlockWeightLimits( + maxBlock = bindWeightV2(asStruct["maxBlock"]), + perClass = PerClassLimits.bind(asStruct["perClass"]) + ) + } + } + + class PerClassLimits( + val normal: ClassLimits + ) { + + companion object { + + fun bind(decoded: Any?): PerClassLimits { + val asStruct = decoded.castToStruct() + return PerClassLimits( + normal = ClassLimits.bind(asStruct["normal"]) + ) + } + } + } + + class ClassLimits( + val maxExtrinsic: WeightV2, + val maxTotal: WeightV2 + ) { + + companion object { + + fun bind(decoded: Any?): ClassLimits { + val asStruct = decoded.castToStruct() + return ClassLimits( + maxExtrinsic = bindWeightOrMax(asStruct["maxExtrinsic"]), + maxTotal = bindWeightOrMax(asStruct["maxTotal"]) + ) + } + + private fun bindWeightOrMax(decoded: Any?): WeightV2 { + return if (decoded != null) { + bindWeightV2(decoded) + } else { + WeightV2.max() + } + } + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/binding/PerDispatchClassWeight.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/binding/PerDispatchClassWeight.kt new file mode 100644 index 0000000..261d9b4 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/binding/PerDispatchClassWeight.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.runtime.network.binding + +import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 +import io.novafoundation.nova.common.data.network.runtime.binding.bindWeightV2 +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct + +class PerDispatchClassWeight( + val normal: WeightV2, + val operational: WeightV2, + val mandatory: WeightV2 +) { + companion object { + + fun zero(): PerDispatchClassWeight { + return PerDispatchClassWeight( + normal = WeightV2.zero(), + operational = WeightV2.zero(), + mandatory = WeightV2.zero() + ) + } + + fun bind(decoded: Any?): PerDispatchClassWeight { + val asStruct = decoded.castToStruct() + return PerDispatchClassWeight( + normal = bindWeightV2(asStruct["normal"]), + operational = bindWeightV2(asStruct["operational"]), + mandatory = bindWeightV2(asStruct["mandatory"]), + ) + } + } +} + +fun PerDispatchClassWeight.total(): WeightV2 { + return normal + operational + mandatory +} + +fun PerDispatchClassWeight?.orZero(): PerDispatchClassWeight { + return this ?: PerDispatchClassWeight.zero() +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/rpc/RpcCalls.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/rpc/RpcCalls.kt new file mode 100644 index 0000000..004c259 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/rpc/RpcCalls.kt @@ -0,0 +1,231 @@ +package io.novafoundation.nova.runtime.network.rpc + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindWeightV2 +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.common.data.network.runtime.calls.FeeCalculationRequest +import io.novafoundation.nova.common.data.network.runtime.calls.GetBlockHashRequest +import io.novafoundation.nova.common.data.network.runtime.calls.GetBlockRequest +import io.novafoundation.nova.common.data.network.runtime.calls.GetFinalizedHeadRequest +import io.novafoundation.nova.common.data.network.runtime.calls.GetHeaderRequest +import io.novafoundation.nova.common.data.network.runtime.calls.GetStorageSize +import io.novafoundation.nova.common.data.network.runtime.calls.GetSystemPropertiesRequest +import io.novafoundation.nova.common.data.network.runtime.calls.NextAccountIndexRequest +import io.novafoundation.nova.common.data.network.runtime.model.FeeResponse +import io.novafoundation.nova.common.data.network.runtime.model.SignedBlock +import io.novafoundation.nova.common.data.network.runtime.model.SignedBlock.Block.Header +import io.novafoundation.nova.common.data.network.runtime.model.SystemProperties +import io.novafoundation.nova.common.utils.asGsonParsedNumber +import io.novafoundation.nova.common.utils.extrinsicHash +import io.novafoundation.nova.common.utils.fromHex +import io.novafoundation.nova.common.utils.hasRuntimeApisMetadata +import io.novafoundation.nova.common.utils.hexBytesSize +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi +import io.novafoundation.nova.runtime.ext.feeViaRuntimeCall +import io.novafoundation.nova.runtime.extrinsic.ExtrinsicStatus +import io.novafoundation.nova.runtime.extrinsic.asExtrinsicStatus +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.multiNetwork.getSocket +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.methodOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.runtimeApiOrNull +import io.novasama.substrate_sdk_android.scale.dataType.DataType +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.executeAsync +import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull +import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo +import io.novasama.substrate_sdk_android.wsrpc.request.DeliveryType +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.author.SubmitAndWatchExtrinsicRequest +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.author.SubmitExtrinsicRequest +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.chain.RuntimeVersionFull +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.chain.RuntimeVersionRequest +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.state.StateCallRequest +import io.novasama.substrate_sdk_android.wsrpc.subscriptionFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +private const val FEE_DECODE_TYPE = "RuntimeDispatchInfo" + +@Suppress("EXPERIMENTAL_API_USAGE") +class RpcCalls( + private val chainRegistry: ChainRegistry, + private val runtimeCallsApi: MultiChainRuntimeCallsApi +) { + + suspend fun getExtrinsicFee(chain: Chain, extrinsic: SendableExtrinsic): FeeResponse { + val chainId = chain.id + val runtime = chainRegistry.getRuntime(chainId) + + // Pezkuwi chains have issues with V15 automatic type resolution for RuntimeDispatchInfo + // Use pre-V15 method with explicit return type for Pezkuwi chains + val isPezkuwiChain = runtime.metadata.extrinsic.signedExtensions.any { it.id == "AuthorizeCall" } + + return when { + // For Pezkuwi chains, prefer pre-V15 method which uses explicit return type + isPezkuwiChain && chain.additional.feeViaRuntimeCall() && runtime.hasFeeDecodeType() -> queryFeeViaRuntimeApiPreV15(chainId, extrinsic) + + chain.additional.feeViaRuntimeCall() && runtime.metadata.hasDetectedPaymentApi() -> queryFeeViaRuntimeApiV15(chainId, extrinsic) + + chain.additional.feeViaRuntimeCall() && runtime.hasFeeDecodeType() -> queryFeeViaRuntimeApiPreV15(chainId, extrinsic) + + else -> queryFeeViaRpcCall(chainId, extrinsic) + } + } + + suspend fun submitExtrinsic(chainId: ChainId, extrinsic: SendableExtrinsic): String { + val request = SubmitExtrinsicRequest(extrinsic.extrinsicHex) + + return socketFor(chainId).executeAsync( + request, + mapper = pojo().nonNull(), + deliveryType = DeliveryType.AT_MOST_ONCE + ) + } + + fun submitAndWatchExtrinsic(chainId: ChainId, extrinsic: SendableExtrinsic): Flow { + return flow { + val extrinsicHash = extrinsic.extrinsicHex.extrinsicHash() + val request = SubmitAndWatchExtrinsicRequest(extrinsic.extrinsicHex) + + val inner = socketFor(chainId).subscriptionFlow(request, unsubscribeMethod = "author_unwatchExtrinsic") + .map { it.asExtrinsicStatus(extrinsicHash) } + + emitAll(inner) + } + } + + suspend fun getNonce(chainId: ChainId, accountAddress: String): BigInteger { + val nonceRequest = NextAccountIndexRequest(accountAddress) + + val response = socketFor(chainId).executeAsync(nonceRequest) + val doubleResult = response.result as Double + + return doubleResult.toInt().toBigInteger() + } + + suspend fun getRuntimeVersion(chainId: ChainId): RuntimeVersionFull { + return socketFor(chainId).executeAsync(RuntimeVersionRequest(), mapper = pojo().nonNull()) + } + + /** + * Retrieves the block with given hash + * If hash is null, than the latest block is returned + */ + suspend fun getBlock(chainId: ChainId, hash: String? = null): SignedBlock { + val blockRequest = GetBlockRequest(hash) + + return socketFor(chainId).executeAsync(blockRequest, mapper = pojo().nonNull()) + } + + /** + * Get hash of the last finalized block in the canon chain + */ + suspend fun getFinalizedHead(chainId: ChainId): String { + return socketFor(chainId).executeAsync(GetFinalizedHeadRequest, mapper = pojo().nonNull()) + } + + /** + * Retrieves the header for a specific block + * + * @param hash - hash of the block. If null - then the best pending header is returned + */ + suspend fun getBlockHeader(chainId: ChainId, hash: String? = null): Header { + return socketFor(chainId).executeAsync(GetHeaderRequest(hash), mapper = pojo
().nonNull()) + } + + /** + * Retrieves the hash of a specific block + * + * @param blockNumber - if null, then the best block hash is returned + */ + suspend fun getBlockHash(chainId: ChainId, blockNumber: BlockNumber? = null): String { + return socketFor(chainId).getBlockHash(blockNumber) + } + + suspend fun getStorageSize(chainId: ChainId, storageKey: String): BigInteger { + return socketFor(chainId).executeAsync(GetStorageSize(storageKey)).result?.asGsonParsedNumber().orZero() + } + + private fun RuntimeMetadata.hasDetectedPaymentApi(): Boolean { + return hasRuntimeApisMetadata() && runtimeApiOrNull("TransactionPaymentApi")?.methodOrNull("query_info") != null + } + + private suspend fun socketFor(chainId: ChainId) = chainRegistry.getSocket(chainId) + + private suspend fun queryFeeViaRpcCall(chainId: ChainId, extrinsic: SendableExtrinsic): FeeResponse { + val request = FeeCalculationRequest(extrinsic.extrinsicHex) + return socketFor(chainId).executeAsync(request, mapper = pojo().nonNull()) + } + + private suspend fun queryFeeViaRuntimeApiPreV15(chainId: ChainId, extrinsic: SendableExtrinsic): FeeResponse { + val lengthInBytes = extrinsic.extrinsicHex.hexBytesSize() + + return runtimeCallsApi.forChain(chainId).call( + section = "TransactionPaymentApi", + method = "query_info", + arguments = listOf( + extrinsic.extrinsicHex to null, + lengthInBytes.toBigInteger() to "u32" + ), + returnType = FEE_DECODE_TYPE, + returnBinding = ::bindPartialFee + ) + } + + private suspend fun queryFeeViaRuntimeApiV15(chainId: ChainId, extrinsic: SendableExtrinsic): FeeResponse { + return runtimeCallsApi.forChain(chainId).call( + section = "TransactionPaymentApi", + method = "query_info", + arguments = mapOf( + // rpc needs bytes without length as it adds length in bytes during "uxt" encoding + "uxt" to extrinsic.bytesWithoutLength, + "len" to extrinsic.extrinsicHex.hexBytesSize().toBigInteger() + ), + returnBinding = ::bindPartialFee + ) + } + + private fun RuntimeSnapshot.hasFeeDecodeType(): Boolean { + return typeRegistry[FEE_DECODE_TYPE] != null + } + + private fun bindPartialFee(decoded: Any?): FeeResponse { + val asStruct = decoded.castToStruct() + + return FeeResponse( + partialFee = bindNumber(asStruct["partialFee"]), + weight = bindWeightV2(asStruct["weight"]) + ) + } +} + +suspend fun SocketService.getBlockHash(blockNumber: BlockNumber? = null): String { + return executeAsync(GetBlockHashRequest(blockNumber), mapper = pojo().nonNull()) +} + +suspend fun SocketService.systemProperties(): SystemProperties { + return executeAsync(GetSystemPropertiesRequest(), mapper = pojo().nonNull()) +} + +suspend fun SocketService.stateCall(request: StateCallRequest): String? { + return executeAsync(request, mapper = pojo()).result +} + +suspend fun SocketService.stateCall(request: StateCallRequest, returnType: DataType): T { + val rawResult = stateCall(request) + requireNotNull(rawResult) { + "Unexpected state call null response" + } + + return returnType.fromHex(rawResult) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/BlockNumberUpdater.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/BlockNumberUpdater.kt new file mode 100644 index 0000000..6aff557 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/BlockNumberUpdater.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.runtime.network.updaters + +import android.util.Log +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.EmptyScope +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.UpdateScope +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.onEach + +class BlockNumberUpdater( + private val chainRegistry: ChainRegistry, + private val storageCache: StorageCache +) : Updater { + + override val scope: UpdateScope = EmptyScope() + + override val requiredModules = listOf(Modules.SYSTEM) + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: Chain, + ): Flow { + val runtime = chainRegistry.getRuntime(scopeValue.id) + + val storageKey = runCatching { storageKey(runtime) }.getOrNull() ?: return emptyFlow() + + return storageSubscriptionBuilder.subscribe(storageKey) + .onEach { + Log.d("BlockNumberUpdater", "Block number updated: ${it.value}") + storageCache.insert(it, scopeValue.id) + } + .noSideAffects() + } + + private fun storageKey(runtime: RuntimeSnapshot): String { + return runtime.metadata.system().storage("Number").storageKey() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/BlockTimeUpdater.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/BlockTimeUpdater.kt new file mode 100644 index 0000000..f3ca10a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/BlockTimeUpdater.kt @@ -0,0 +1,93 @@ +package io.novafoundation.nova.runtime.network.updaters + +import android.util.Log +import io.novafoundation.nova.common.data.holders.ChainIdHolder +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.decodeValue +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.common.utils.timestamp +import io.novafoundation.nova.common.utils.zipWithPrevious +import io.novafoundation.nova.core.updater.GlobalScopeUpdater +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import java.math.BigInteger + +data class SampledBlockTime( + val sampleSize: BigInteger, + val averageBlockTime: BigInteger, +) + +private data class BlockTimeUpdate( + val at: BlockHash, + val blockNumber: BlockNumber, + val timestamp: BigInteger, +) + +class BlockTimeUpdater( + private val chainIdHolder: ChainIdHolder, + private val chainRegistry: ChainRegistry, + private val sampledBlockTimeStorage: SampledBlockTimeStorage, + private val remoteStorageSource: StorageDataSource, +) : GlobalScopeUpdater { + + override val requiredModules: List = emptyList() + + override suspend fun listenForUpdates(storageSubscriptionBuilder: SharedRequestsBuilder, scopeValue: Unit): Flow { + val chainId = chainIdHolder.chainId() + val runtime = chainRegistry.getRuntime(chainId) + val storage = runtime.metadata.system().storage("Number") + + val blockNumberKey = storage.storageKey() + + return storageSubscriptionBuilder.subscribe(blockNumberKey) + .drop(1) // ignore fist subscription value since it comes immediately + .map { + val timestamp = remoteStorageSource.query(chainId, at = it.block) { + runtime.metadata.timestamp().storage("Now").query(binding = ::bindNumber) + } + + val blockNumber = bindNumber(storage.decodeValue(it.value, runtime)) + + BlockTimeUpdate(at = it.block, blockNumber = blockNumber, timestamp = timestamp) + } + .zipWithPrevious() + .filter { (previous, current) -> + previous != null && current.blockNumber - previous.blockNumber == BigInteger.ONE + } + .onEach { (previousUpdate, currentUpdate) -> + val blockTime = currentUpdate.timestamp - previousUpdate!!.timestamp + + updateSampledBlockTime(chainId, blockTime) + }.noSideAffects() + } + + private suspend fun updateSampledBlockTime(chainId: ChainId, newSampledTime: BigInteger) { + val current = sampledBlockTimeStorage.get(chainId) + + val adjustedSampleSize = current.sampleSize + BigInteger.ONE + val adjustedAverage = (current.averageBlockTime * current.sampleSize + newSampledTime) / adjustedSampleSize + val adjustedSampledBlockTime = SampledBlockTime( + sampleSize = adjustedSampleSize, + averageBlockTime = adjustedAverage + ) + + Log.d(LOG_TAG, "New block time update: $newSampledTime, adjustedAverage: $adjustedSampledBlockTime") + + sampledBlockTimeStorage.put(chainId, adjustedSampledBlockTime) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt new file mode 100644 index 0000000..87e7df4 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt @@ -0,0 +1,58 @@ +package io.novafoundation.nova.runtime.network.updaters + +import android.util.Log +import io.novafoundation.nova.common.utils.LOG_TAG +import io.novafoundation.nova.common.utils.hasModule +import io.novafoundation.nova.core.updater.UpdateSystem +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ethereum.subscribe +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.merge +import kotlin.coroutines.coroutineContext + +abstract class ChainUpdaterGroupUpdateSystem( + private val chainRegistry: ChainRegistry, + private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : UpdateSystem { + + protected suspend fun runUpdaters(chain: Chain, updaters: Collection>): Flow { + val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata + + val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG + val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName + + val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) -> + scope.invalidationFlow().flatMapLatest { scopeValue -> + val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) + + val updatersFlow = scopeUpdaters + .filter { it.requiredModules.all(runtimeMetadata::hasModule) } + .map { updater -> + @Suppress("UNCHECKED_CAST") + (updater as Updater).listenForUpdates(subscriptionBuilder, scopeValue) + .catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) } + .flowOn(Dispatchers.Default) + } + + if (updatersFlow.isNotEmpty()) { + subscriptionBuilder.subscribe(coroutineContext) + + updatersFlow.merge() + } else { + emptyFlow() + } + } + } + + return scopeFlows.merge() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/InactiveIssuanceUpdater.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/InactiveIssuanceUpdater.kt new file mode 100644 index 0000000..3be1393 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/InactiveIssuanceUpdater.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.runtime.network.updaters + +import io.novafoundation.nova.common.data.holders.ChainIdHolder +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull + +class InactiveIssuanceUpdater( + chainIdHolder: ChainIdHolder, + storageCache: StorageCache, + chainRegistry: ChainRegistry +) : SingleStorageKeyUpdater(GlobalScope, chainIdHolder, chainRegistry, storageCache) { + + override val requiredModules: List = listOf(Modules.BALANCES) + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String? { + return runtime.metadata.balances().storageOrNull("InactiveIssuance")?.storageKey() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/SharedAssetBlockNumberUpdater.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/SharedAssetBlockNumberUpdater.kt new file mode 100644 index 0000000..40269cf --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/SharedAssetBlockNumberUpdater.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.runtime.network.updaters + +import io.novafoundation.nova.common.data.holders.ChainIdHolder +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class SharedAssetBlockNumberUpdater( + chainRegistry: ChainRegistry, + chainIdHolder: ChainIdHolder, + storageCache: StorageCache +) : SingleStorageKeyUpdater(GlobalScope, chainIdHolder, chainRegistry, storageCache) { + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.system().storage("Number").storageKey() + } + + override val requiredModules = listOf(Modules.SYSTEM) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/SingleChainUpdateSystem.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/SingleChainUpdateSystem.kt new file mode 100644 index 0000000..5f552f0 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/SingleChainUpdateSystem.kt @@ -0,0 +1,44 @@ +package io.novafoundation.nova.runtime.network.updaters + +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState.SupportedAssetOption +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.shareIn + +abstract class SingleChainUpdateSystem( + chainRegistry: ChainRegistry, + private val singleAssetSharedState: SelectedAssetOptionSharedState, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : ChainUpdaterGroupUpdateSystem(chainRegistry, storageSharedRequestsBuilderFactory) { + + abstract fun getUpdaters(selectedAssetOption: SupportedAssetOption): Collection> + + private val updateFlow = singleAssetSharedState.selectedOption.flatMapLatest { selectedOption -> + val chain = selectedOption.assetWithChain.chain + + val updaters = getUpdaters(selectedOption) + + runUpdaters(chain, updaters) + }.shareIn(CoroutineScope(Dispatchers.IO), replay = 1, started = SharingStarted.WhileSubscribed()) + + override fun start(): Flow = updateFlow +} + +class ConstantSingleChainUpdateSystem( + private val updaters: List>, + chainRegistry: ChainRegistry, + singleAssetSharedState: SelectedAssetOptionSharedState<*>, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : SingleChainUpdateSystem(chainRegistry, singleAssetSharedState, storageSharedRequestsBuilderFactory) { + + override fun getUpdaters(selectedAssetOption: SupportedAssetOption): List> { + return updaters + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/SingleStorageKeyUpdater.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/SingleStorageKeyUpdater.kt new file mode 100644 index 0000000..940916f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/SingleStorageKeyUpdater.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.runtime.network.updaters + +import io.novafoundation.nova.common.data.holders.ChainIdHolder +import io.novafoundation.nova.core.model.StorageChange +import io.novafoundation.nova.core.model.StorageEntry +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.core.updater.UpdateScope +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +suspend fun StorageCache.insert( + storageChange: StorageChange, + chainId: String, +) { + val storageEntry = StorageEntry( + storageKey = storageChange.key, + content = storageChange.value, + ) + + insert(storageEntry, chainId) +} + +abstract class SingleStorageKeyUpdater( + override val scope: UpdateScope, + private val chainIdHolder: ChainIdHolder, + private val chainRegistry: ChainRegistry, + private val storageCache: StorageCache +) : Updater { + + /** + * @return a storage key to update. null in case updater does not want to update anything + */ + abstract suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: V): String? + + @Suppress("DeprecatedCallableAddReplaceWith") + @Deprecated("Instead of writing fallback value to cache use 'applyStorageDefault' param on StorageDataSource when querying cache") + protected open fun fallbackValue(runtime: RuntimeSnapshot): String? = null + + override suspend fun listenForUpdates( + storageSubscriptionBuilder: SharedRequestsBuilder, + scopeValue: V, + ): Flow { + val chainId = chainIdHolder.chainId() + val runtime = chainRegistry.getRuntime(chainId) + + val storageKey = runCatching { storageKey(runtime, scopeValue) }.getOrNull() ?: return emptyFlow() + + return storageSubscriptionBuilder.subscribe(storageKey) + .map { + if (it.value == null) { + it.copy(value = fallbackValue(runtime)) + } else { + it + } + } + .onEach { storageCache.insert(it, chainId) } + .noSideAffects() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/TotalIssuanceUpdater.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/TotalIssuanceUpdater.kt new file mode 100644 index 0000000..174a74e --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/TotalIssuanceUpdater.kt @@ -0,0 +1,24 @@ +package io.novafoundation.nova.runtime.network.updaters + +import io.novafoundation.nova.common.data.holders.ChainIdHolder +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.GlobalScope +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey + +class TotalIssuanceUpdater( + chainIdHolder: ChainIdHolder, + storageCache: StorageCache, + chainRegistry: ChainRegistry +) : SingleStorageKeyUpdater(GlobalScope, chainIdHolder, chainRegistry, storageCache) { + + override val requiredModules: List = listOf(Modules.BALANCES) + + override suspend fun storageKey(runtime: RuntimeSnapshot, scopeValue: Unit): String { + return runtime.metadata.balances().storage("TotalIssuance").storageKey() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/AsSharedStateUpdater.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/AsSharedStateUpdater.kt new file mode 100644 index 0000000..0cc83b7 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/AsSharedStateUpdater.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.runtime.network.updaters.multiChain + +import io.novafoundation.nova.core.updater.Updater + +class AsSharedStateUpdater(private val delegate: Updater) : Updater by delegate, SharedStateBasedUpdater diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/DelegateToTimeLineChainUpdater.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/DelegateToTimeLineChainUpdater.kt new file mode 100644 index 0000000..7560a48 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/DelegateToTimeLineChainUpdater.kt @@ -0,0 +1,13 @@ +package io.novafoundation.nova.runtime.network.updaters.multiChain + +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class DelegateToTimeLineChainUpdater(private val delegate: Updater) : Updater by delegate, SharedStateBasedUpdater { + + override fun getSyncChainId(sharedStateChain: Chain): ChainId { + return sharedStateChain.timelineChainIdOrSelf() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/DelegateToTimelineChainIdHolder.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/DelegateToTimelineChainIdHolder.kt new file mode 100644 index 0000000..ed1d93a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/DelegateToTimelineChainIdHolder.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.runtime.network.updaters.multiChain + +import io.novafoundation.nova.common.data.holders.ChainIdHolder +import io.novafoundation.nova.runtime.ext.timelineChainIdOrSelf +import io.novafoundation.nova.runtime.state.AnySelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.chain + +class DelegateToTimelineChainIdHolder( + private val sharedState: AnySelectedAssetOptionSharedState +) : ChainIdHolder { + + override suspend fun chainId(): String { + return sharedState.chain().timelineChainIdOrSelf() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/MultiChainUpdateSystem.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/MultiChainUpdateSystem.kt new file mode 100644 index 0000000..a9241e4 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/MultiChainUpdateSystem.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.runtime.network.updaters.multiChain + +import io.novafoundation.nova.common.utils.MultiMap +import io.novafoundation.nova.common.utils.mergeIfMultiple +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.ChainUpdaterGroupUpdateSystem +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState.SupportedAssetOption +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.shareIn + +abstract class MultiChainUpdateSystem( + private val chainRegistry: ChainRegistry, + private val singleAssetSharedState: SelectedAssetOptionSharedState, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : ChainUpdaterGroupUpdateSystem(chainRegistry, storageSharedRequestsBuilderFactory) { + + abstract fun getUpdaters(option: SupportedAssetOption): MultiMap> + + private val updateFlow = singleAssetSharedState.selectedOption.flatMapLatest { selectedOption -> + val updatersByChain = getUpdaters(selectedOption) + + updatersByChain.map { (syncChainId, updaters) -> + val syncChain = chainRegistry.getChain(syncChainId) + runUpdaters(syncChain, updaters) + }.mergeIfMultiple() + }.shareIn(CoroutineScope(Dispatchers.IO), replay = 1, started = SharingStarted.WhileSubscribed()) + + override fun start(): Flow = updateFlow +} + +class GroupBySyncChainMultiChainUpdateSystem( + chainRegistry: ChainRegistry, + singleAssetSharedState: SelectedAssetOptionSharedState, + storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + private val updaters: List> +) : MultiChainUpdateSystem(chainRegistry, singleAssetSharedState, storageSharedRequestsBuilderFactory) { + + override fun getUpdaters(option: SupportedAssetOption): MultiMap> { + return updaters.groupBySyncingChain(option.assetWithChain.chain) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/SharedStateBasedUpdater.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/SharedStateBasedUpdater.kt new file mode 100644 index 0000000..c50e721 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/multiChain/SharedStateBasedUpdater.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.runtime.network.updaters.multiChain + +import io.novafoundation.nova.common.utils.MultiMap +import io.novafoundation.nova.common.utils.groupByIntoSet +import io.novafoundation.nova.core.updater.Updater +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface SharedStateBasedUpdater : Updater { + + /** + * Can be used to override chainId to launch updater on in case desired chain id + * is different from shared state chain itself + */ + fun getSyncChainId(sharedStateChain: Chain): ChainId { + return sharedStateChain.id + } +} + +fun List>.groupBySyncingChain(sharedStateChain: Chain): MultiMap> { + return groupByIntoSet(keySelector = { it.getSyncChainId(sharedStateChain) }) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/repository/BlockLimitsRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/BlockLimitsRepository.kt new file mode 100644 index 0000000..84c02bf --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/BlockLimitsRepository.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.runtime.repository + +import io.novafoundation.nova.common.utils.constant +import io.novafoundation.nova.common.utils.getAs +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.withRuntime +import io.novafoundation.nova.runtime.network.binding.BlockWeightLimits +import io.novafoundation.nova.runtime.network.binding.PerDispatchClassWeight +import io.novafoundation.nova.runtime.network.binding.orZero +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.typed.blockWeight +import io.novafoundation.nova.runtime.storage.typed.system + +interface BlockLimitsRepository { + + suspend fun blockLimits(chainId: ChainId): BlockWeightLimits + + suspend fun lastBlockWeight(chainId: ChainId): PerDispatchClassWeight +} + +class RealBlockLimitsRepository( + private val remoteStorageSource: StorageDataSource, + private val chainRegistry: ChainRegistry +) : BlockLimitsRepository { + + override suspend fun blockLimits(chainId: ChainId): BlockWeightLimits { + return chainRegistry.withRuntime(chainId) { + runtime.metadata.system().constant("BlockWeights").getAs(BlockWeightLimits::bind) + } + } + + override suspend fun lastBlockWeight(chainId: ChainId): PerDispatchClassWeight { + return remoteStorageSource.query(chainId) { + runtime.metadata.system.blockWeight.query().orZero() + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainNodeRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainNodeRepository.kt new file mode 100644 index 0000000..93d52ba --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainNodeRepository.kt @@ -0,0 +1,33 @@ +package io.novafoundation.nova.runtime.repository + +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal + +interface ChainNodeRepository { + + suspend fun createChainNode(chainId: String, url: String, name: String) + + suspend fun saveChainNode(chainId: String, oldUrl: String, url: String, name: String) +} + +class RealChainNodeRepository( + private val chainDao: ChainDao +) : ChainNodeRepository { + + override suspend fun createChainNode(chainId: String, url: String, name: String) { + val lastOrderId = chainDao.getLastChainNodeOrderId(chainId) + chainDao.addChainNode( + ChainNodeLocal( + chainId = chainId, + url = url, + name = name, + orderId = lastOrderId + 1, + source = ChainNodeLocal.Source.CUSTOM + ) + ) + } + + override suspend fun saveChainNode(chainId: String, oldUrl: String, url: String, name: String) { + chainDao.updateChainNode(chainId, oldUrl, url, name) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainRepository.kt new file mode 100644 index 0000000..7afa6ff --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainRepository.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.runtime.repository + +import com.google.gson.Gson +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.runtime.ext.utilityAsset +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainAssetToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainExplorerToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainExternalApiToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainNodeToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapNodeSelectionPreferencesToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +interface ChainRepository { + + suspend fun addChain(chain: Chain) + + suspend fun editChain( + chainId: String, + chainName: String, + tokenSymbol: String, + blockExplorer: Chain.Explorer?, + priceId: String? + ) + + suspend fun deleteNetwork(chainId: String) + + suspend fun deleteNode(chainId: String, nodeUrl: String) +} + +class RealChainRepository( + private val chainRegistry: ChainRegistry, + private val chainDao: ChainDao, + private val gson: Gson +) : ChainRepository { + + override suspend fun addChain(chain: Chain) { + chainDao.addChainOrUpdate( + chain = mapChainToLocal(chain, gson), + assets = chain.assets.map { mapChainAssetToLocal(it, gson) }, + nodes = chain.nodes.nodes.map { mapChainNodeToLocal(it) }, + explorers = chain.explorers.map { mapChainExplorerToLocal(it) }, + externalApis = chain.externalApis.map { mapChainExternalApiToLocal(gson, chain.id, it) }, + nodeSelectionPreferences = mapNodeSelectionPreferencesToLocal(chain) + ) + } + + override suspend fun editChain(chainId: String, chainName: String, tokenSymbol: String, blockExplorer: Chain.Explorer?, priceId: String?) { + val chain = chainRegistry.getChain(chainId) + chainDao.editChain( + chainId, + chain.utilityAsset.id, + chainName, + tokenSymbol, + blockExplorer?.let { mapChainExplorerToLocal(it) }, + priceId + ) + } + + override suspend fun deleteNetwork(chainId: String) { + chainDao.deleteChain(chainId) + } + + override suspend fun deleteNode(chainId: String, nodeUrl: String) { + chainDao.deleteNode(chainId, nodeUrl) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainStateRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainStateRepository.kt new file mode 100644 index 0000000..3482c00 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainStateRepository.kt @@ -0,0 +1,165 @@ +package io.novafoundation.nova.runtime.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindBlockNumber +import io.novafoundation.nova.common.utils.babeOrNull +import io.novafoundation.nova.common.utils.flowOfAll +import io.novafoundation.nova.common.utils.isParachain +import io.novafoundation.nova.common.utils.metadata +import io.novafoundation.nova.common.utils.numberConstant +import io.novafoundation.nova.common.utils.optionalNumberConstant +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.common.utils.timestampOrNull +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.network.updaters.SampledBlockTime +import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.runtime.storage.source.query.api.observeNonNull +import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull +import io.novafoundation.nova.runtime.storage.source.queryNonNull +import io.novafoundation.nova.runtime.storage.typed.number +import io.novafoundation.nova.runtime.storage.typed.system +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private val FALLBACK_BLOCK_TIME_MILLIS_RELAYCHAIN = (6 * 1000).toBigInteger() +private val FALLBACK_BLOCK_TIME_MILLIS_PARACHAIN = 2.toBigInteger() * FALLBACK_BLOCK_TIME_MILLIS_RELAYCHAIN + +private val PERIOD_VALIDITY_THRESHOLD = 100.toBigInteger() + +private val REQUIRED_SAMPLED_BLOCKS = 10.toBigInteger() + +class ChainStateRepository( + private val localStorage: StorageDataSource, + private val remoteStorage: StorageDataSource, + private val sampledBlockTimeStorage: SampledBlockTimeStorage, + private val chainRegistry: ChainRegistry +) { + + suspend fun expectedBlockTimeInMillis(chainId: ChainId): BigInteger { + val runtime = chainRegistry.getRuntime(chainId) + val chain = chainRegistry.getChain(chainId) + + return blockTimeFromConstants(chain, runtime) + } + + suspend fun predictedBlockTime(chainId: ChainId): BigInteger { + val runtime = chainRegistry.getRuntime(chainId) + val chain = chainRegistry.getChain(chainId) + + val blockTimeFromConstants = blockTimeFromConstants(chain, runtime) + val sampledBlockTime = sampledBlockTimeStorage.get(chainId) + + return weightedAverageBlockTime(sampledBlockTime, blockTimeFromConstants) + } + + fun predictedBlockTimeFlow(chainId: ChainId): Flow { + return flowOfAll { + val runtime = chainRegistry.getRuntime(chainId) + val chain = chainRegistry.getChain(chainId) + + val blockTimeFromConstants = blockTimeFromConstants(chain, runtime) + + sampledBlockTimeStorage.observe(chainId).map { + weightedAverageBlockTime(it, blockTimeFromConstants) + } + } + } + + private fun weightedAverageBlockTime( + sampledBlockTime: SampledBlockTime, + blockTimeFromConstants: BigInteger + ): BigInteger { + val cappedSampleSize = sampledBlockTime.sampleSize.min(REQUIRED_SAMPLED_BLOCKS) + val sampledPart = cappedSampleSize * sampledBlockTime.averageBlockTime + val constantsPart = (REQUIRED_SAMPLED_BLOCKS - cappedSampleSize) * blockTimeFromConstants + + return (sampledPart + constantsPart) / REQUIRED_SAMPLED_BLOCKS + } + + private fun blockTimeFromConstants(chain: Chain, runtime: RuntimeSnapshot): BigInteger { + return chain.additional?.defaultBlockTimeMillis?.toBigInteger() + ?: runtime.metadata.babeOrNull()?.numberConstant("ExpectedBlockTime", runtime) + // Some chains incorrectly use these, i.e. it is set to values such as 0 or even 2 + // Use a low minimum validity threshold to check these against + ?: blockTimeFromTimestampPallet(runtime) + ?: fallbackBlockTime(runtime) + } + + private fun blockTimeFromTimestampPallet(runtime: RuntimeSnapshot): BigInteger? { + val blockTime = runtime.metadata.timestampOrNull()?.numberConstant("MinimumPeriod", runtime)?.takeIf { it > PERIOD_VALIDITY_THRESHOLD } + ?: return null + + return blockTime * 2.toBigInteger() + } + + suspend fun blockHashCount(chainId: ChainId): BigInteger? { + val runtime = chainRegistry.getRuntime(chainId) + + return runtime.metadata.system().optionalNumberConstant("BlockHashCount", runtime) + } + + suspend fun currentBlock(chainId: ChainId) = localStorage.queryNonNull( + keyBuilder = ::currentBlockStorageKey, + binding = { scale, runtime -> bindBlockNumber(scale, runtime) }, + chainId = chainId + ) + + fun currentBlockNumberFlow(chainId: ChainId): Flow = localStorage.observeBlockNumber(chainId) + + fun currentRemoteBlockNumberFlow( + chainId: ChainId, + ): Flow = remoteStorage.observeBlockNumber(chainId) + + suspend fun currentRemoteBlockNumberFlow( + chainId: ChainId, + sharedRequestsBuilder: SharedRequestsBuilder + ): Flow { + return remoteStorage.subscribe(chainId, subscriptionBuilder = sharedRequestsBuilder) { + metadata.system.number.observeNonNull() + } + } + + suspend fun currentRemoteBlock(chainId: ChainId) = remoteStorage.query(chainId) { + metadata.system.number.queryNonNull() + } + + private fun StorageDataSource.observeBlockNumber(chainId: ChainId) = subscribe(chainId) { + metadata.system.number.observeNonNull() + } + + private fun currentBlockStorageKey(runtime: RuntimeSnapshot) = runtime.metadata.system().storage("Number").storageKey() + + private fun fallbackBlockTime(runtime: RuntimeSnapshot): BigInteger { + return if (runtime.isParachain()) { + FALLBACK_BLOCK_TIME_MILLIS_PARACHAIN + } else { + FALLBACK_BLOCK_TIME_MILLIS_RELAYCHAIN + } + } +} + +suspend fun ChainStateRepository.currentRemoteBlockNumberFlow( + chainId: ChainId, + sharedRequestsBuilder: SharedRequestsBuilder? +): Flow { + return if (sharedRequestsBuilder != null) { + currentRemoteBlockNumberFlow(chainId, sharedRequestsBuilder) + } else { + currentRemoteBlockNumberFlow(chainId) + } +} + +suspend fun ChainStateRepository.expectedBlockTime(chainId: ChainId): Duration { + return expectedBlockTimeInMillis(chainId).toLong().milliseconds +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainStateRepositoryExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainStateRepositoryExt.kt new file mode 100644 index 0000000..5681a7d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ChainStateRepositoryExt.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.runtime.repository + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.util.BlockDurationEstimator +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +suspend fun ChainStateRepository.blockDurationEstimator(chainId: ChainId): BlockDurationEstimator { + return BlockDurationEstimator( + currentBlock = currentBlock(chainId), + blockTimeMillis = predictedBlockTime(chainId) + ) +} + +fun ChainStateRepository.blockDurationEstimatorFlow(chainId: ChainId): Flow { + return combine(currentBlockNumberFlow(chainId), predictedBlockTimeFlow(chainId)) { currentBlock, blockTime -> + BlockDurationEstimator(currentBlock, blockTime) + } +} + +suspend fun ChainStateRepository.blockDurationEstimatorFromRemote(chainId: ChainId): BlockDurationEstimator { + return BlockDurationEstimator( + currentBlock = currentRemoteBlock(chainId), + blockTimeMillis = predictedBlockTime(chainId), + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ParachainInfoRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ParachainInfoRepository.kt new file mode 100644 index 0000000..dcefb6b --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/ParachainInfoRepository.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.runtime.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.ParaId +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.moduleOrNull +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface ParachainInfoRepository { + + suspend fun paraId(chainId: ChainId): ParaId? +} + +internal class RealParachainInfoRepository( + private val remoteStorageSource: StorageDataSource, +) : ParachainInfoRepository { + + private val paraIdCacheMutex = Mutex() + private val paraIdCache = mutableMapOf() + + override suspend fun paraId(chainId: ChainId): ParaId? = paraIdCacheMutex.withLock { + if (chainId in paraIdCache) { + paraIdCache.getValue(chainId) + } else { + remoteStorageSource.query(chainId) { + // Try Polkadot-style first (ParachainInfo.ParachainId) + // Then try Pezkuwi-style (TeyrchainInfo.TeyrchainId) + val polkadotModule = runtime.metadata.moduleOrNull(Modules.PARACHAIN_INFO) + val pezkuwiModule = runtime.metadata.moduleOrNull(Modules.TEYRCHAIN_INFO) + + polkadotModule?.storageOrNull("ParachainId")?.query(binding = ::bindNumber) + ?: pezkuwiModule?.storageOrNull("TeyrchainId")?.query(binding = ::bindNumber) + } + .also { paraIdCache[chainId] = it } + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/repository/PreConfiguredChainsRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/PreConfiguredChainsRepository.kt new file mode 100644 index 0000000..834f975 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/PreConfiguredChainsRepository.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.runtime.repository + +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.RemoteToDomainChainMapperFacade +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteToDomainLightChain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.LightChain +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher + +interface PreConfiguredChainsRepository { + + suspend fun getPreConfiguredChains(): Result> + + suspend fun getPreconfiguredChainById(id: ChainId): Result +} + +class RealPreConfiguredChainsRepository( + private val chainFetcher: ChainFetcher, + private val chainMapperFacade: RemoteToDomainChainMapperFacade +) : PreConfiguredChainsRepository { + + override suspend fun getPreConfiguredChains(): Result> { + return runCatching { + val remoteChains = chainFetcher.getPreConfiguredChains() + remoteChains.map { mapRemoteToDomainLightChain(it) } + } + } + + override suspend fun getPreconfiguredChainById(id: ChainId): Result { + return runCatching { + val chain = chainFetcher.getPreConfiguredChainById(id) + chainMapperFacade.mapRemoteChainToDomain(chain, Chain.Source.CUSTOM) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/repository/TimestampRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/TimestampRepository.kt new file mode 100644 index 0000000..c29aba7 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/TimestampRepository.kt @@ -0,0 +1,26 @@ +package io.novafoundation.nova.runtime.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.utils.timestamp +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import java.math.BigInteger + +typealias UnixTime = BigInteger + +interface TimestampRepository { + + suspend fun now(chainId: ChainId): UnixTime +} + +class RemoteTimestampRepository( + private val remoteStorageDataSource: StorageDataSource +) : TimestampRepository { + + override suspend fun now(chainId: ChainId): UnixTime { + return remoteStorageDataSource.query(chainId) { + runtime.metadata.timestamp().storage("Now").query(binding = ::bindNumber) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/repository/TotalIssuanceRepository.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/TotalIssuanceRepository.kt new file mode 100644 index 0000000..b95b30e --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/repository/TotalIssuanceRepository.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.runtime.repository + +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumberOrZero +import io.novafoundation.nova.common.utils.balances +import io.novafoundation.nova.common.utils.orZero +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull +import java.math.BigInteger + +interface TotalIssuanceRepository { + + suspend fun getTotalIssuance(chainId: ChainId): BigInteger + + suspend fun getInactiveIssuance(chainId: ChainId): BigInteger +} + +suspend fun TotalIssuanceRepository.getActiveIssuance(chainId: ChainId): BigInteger { + return (getTotalIssuance(chainId) - getInactiveIssuance(chainId)).coerceAtLeast(BigInteger.ZERO) +} + +internal class RealTotalIssuanceRepository( + private val storageDataSource: StorageDataSource +) : TotalIssuanceRepository { + + override suspend fun getTotalIssuance(chainId: ChainId): BigInteger { + return storageDataSource.query(chainId) { + runtime.metadata.balances().storage("TotalIssuance").query(binding = ::bindNumber) + } + } + + override suspend fun getInactiveIssuance(chainId: ChainId): BigInteger { + return storageDataSource.query(chainId) { + runtime.metadata.balances().storageOrNull("InactiveIssuance")?.query(binding = ::bindNumberOrZero).orZero() + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/state/NothingAdditional.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/state/NothingAdditional.kt new file mode 100644 index 0000000..809e90d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/state/NothingAdditional.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.runtime.state + +import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +object NothingAdditional : SelectableAssetAdditionalData { + + override val identifier: String = "Nothing" + + override fun format(resourceManager: ResourceManager): String? { + return null + } +} + +fun uniqueOption(valid: (Chain, Chain.Asset) -> Boolean): SupportedOptionsResolver { + return { chain, asset -> + if (valid(chain, asset)) listOf(NothingAdditional) else emptyList() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/state/SelectableSingleAssetSharedState.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/state/SelectableSingleAssetSharedState.kt new file mode 100644 index 0000000..7434e25 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/state/SelectableSingleAssetSharedState.kt @@ -0,0 +1,114 @@ +package io.novafoundation.nova.runtime.state + +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.Identifiable +import io.novafoundation.nova.common.utils.findById +import io.novafoundation.nova.common.utils.formatting.Formatable +import io.novafoundation.nova.common.utils.inBackground +import io.novafoundation.nova.runtime.ext.isEnabled +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.enabledChainWithAssetOrNull +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState.SupportedAssetOption +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn + +private const val DELIMITER = ":" + +typealias SupportedOptionsResolver = (Chain, Chain.Asset) -> List + +typealias SingleAssetSharedState = SelectableSingleAssetSharedState<*> + +interface SelectableAssetAdditionalData : Formatable, Identifiable + +abstract class SelectableSingleAssetSharedState( + private val preferencesKey: String, + private val chainRegistry: ChainRegistry, + private val supportedOptions: SupportedOptionsResolver, + private val preferences: Preferences +) : SelectedAssetOptionSharedState { + + override val selectedOption: Flow> = preferences.stringFlow( + field = preferencesKey, + initialValueProducer = { + val option = availableToSelect().first() + val chainAsset = option.assetWithChain.asset + val additional = option.additional + + encode(chainAsset.chainId, chainAsset.id, additional.identifier) + } + ) + .filterNotNull() + .map { encoded -> + val (chainId, chainAssetId, additionalIdentifier) = decode(encoded) + + getChainWithAssetOrFallback(chainId, chainAssetId, additionalIdentifier) + } + .inBackground() + .shareIn(GlobalScope, started = SharingStarted.Eagerly, replay = 1) + + suspend fun availableToSelect(): List> { + val allChains = chainRegistry.currentChains.first() + .filter { it.isEnabled } + + return allChains.flatMap { chain -> + chain.assets.flatMap { chainAsset -> + supportedOptions(chain, chainAsset).map { additional -> + SupportedAssetOption( + assetWithChain = ChainWithAsset(chain = chain, asset = chainAsset), + additional = additional + ) + } + } + } + } + + fun update(chainId: ChainId, chainAssetId: Int, optionIdentifier: String) { + preferences.putString(preferencesKey, encode(chainId, chainAssetId, optionIdentifier)) + } + + private suspend fun getChainWithAssetOrFallback(chainId: ChainId, chainAssetId: Int, additionalIdentifier: String?): SupportedAssetOption { + val optionalChainAndAsset = chainRegistry.enabledChainWithAssetOrNull(chainId, chainAssetId) + val supportedOptions = optionalChainAndAsset?.let { + supportedOptions(it.chain, it.asset) + }.orEmpty() + + return when { + // previously used chain asset was removed -> fallback to default + optionalChainAndAsset == null -> availableToSelect().first() + + // previously supported option is no longer supported -> fallback to default + supportedOptions.isEmpty() -> availableToSelect().first() + + // there is no particular additional option specified -> select first one + additionalIdentifier == null -> SupportedAssetOption(optionalChainAndAsset, additional = supportedOptions.first()) + + else -> { + val option = supportedOptions.findById(additionalIdentifier) ?: supportedOptions.first() + + SupportedAssetOption(optionalChainAndAsset, additional = option) + } + } + } + + private fun encode(chainId: ChainId, chainAssetId: Int, additionalIdentifier: String?): String { + val additionalEncoded = additionalIdentifier.orEmpty() + + return "$chainId$DELIMITER$chainAssetId$DELIMITER$additionalEncoded" + } + + private fun decode(value: String): Triple { + val valueComponents = value.split(DELIMITER) + val (chainId, chainAssetRaw) = valueComponents + val additionalIdentifierRaw = valueComponents.getOrNull(2) + + return Triple(chainId, chainAssetRaw.toInt(), additionalIdentifierRaw) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/state/SelectedAssetOptionSharedState.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/state/SelectedAssetOptionSharedState.kt new file mode 100644 index 0000000..ae3ba84 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/state/SelectedAssetOptionSharedState.kt @@ -0,0 +1,45 @@ +package io.novafoundation.nova.runtime.state + +import io.novafoundation.nova.common.data.holders.ChainIdHolder +import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset +import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState.SupportedAssetOption +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +typealias AnySelectedAssetOptionSharedState = SelectedAssetOptionSharedState<*> + +interface SelectedAssetOptionSharedState : SelectedOptionSharedState>, ChainIdHolder { + + override val selectedOption: Flow> + + override suspend fun chainId(): String = chain().id + + data class SupportedAssetOption( + val assetWithChain: ChainWithAsset, + val additional: A + ) +} + +val SelectedAssetOptionSharedState<*>.assetWithChain: Flow + get() = selectedOption.map { it.assetWithChain } + +fun SelectedAssetOptionSharedState<*>.selectedChainFlow() = selectedOption + .map { it.assetWithChain.chain } + .distinctUntilChanged() + +fun SelectedAssetOptionSharedState<*>.selectedAssetFlow() = selectedOption + .map { it.assetWithChain.asset } + +suspend fun SelectedAssetOptionSharedState<*>.chain() = assetWithChain.first().chain + +suspend fun SelectedAssetOptionSharedState<*>.chainAsset() = assetWithChain.first().asset + +suspend fun SelectedAssetOptionSharedState<*>.chainAndAsset() = assetWithChain.first() + +suspend fun SelectedAssetOptionSharedState.selectedOption(): SupportedAssetOption { + return selectedOption.first() +} + +fun SupportedAssetOption(assetWithChain: ChainWithAsset): SupportedAssetOption = SupportedAssetOption(assetWithChain, additional = Unit) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/state/SelectedOptionSharedState.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/state/SelectedOptionSharedState.kt new file mode 100644 index 0000000..62b8852 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/state/SelectedOptionSharedState.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.runtime.state + +import kotlinx.coroutines.flow.Flow + +interface SelectedOptionSharedState { + + val selectedOption: Flow +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/DbStorageCache.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/DbStorageCache.kt new file mode 100644 index 0000000..d4c7645 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/DbStorageCache.kt @@ -0,0 +1,104 @@ +package io.novafoundation.nova.runtime.storage + +import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.core.model.StorageEntry +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core_db.dao.StorageDao +import io.novafoundation.nova.core_db.model.StorageEntryLocal +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +class DbStorageCache( + private val storageDao: StorageDao +) : StorageCache { + + override suspend fun isPrefixInCache(prefixKey: String, chainId: String): Boolean { + return storageDao.isPrefixInCache(chainId, prefixKey) + } + + override suspend fun isFullKeyInCache(fullKey: String, chainId: String): Boolean { + return storageDao.isFullKeyInCache(chainId, fullKey) + } + + override suspend fun insert(entry: StorageEntry, chainId: String) = withContext(Dispatchers.IO) { + storageDao.insert(mapStorageEntryToLocal(entry, chainId)) + } + + override suspend fun insert(entries: List, chainId: String) = withContext(Dispatchers.IO) { + val mapped = entries.map { mapStorageEntryToLocal(it, chainId) } + + storageDao.insert(mapped) + } + + override suspend fun insertPrefixEntries(entries: List, prefixKey: String, chainId: String) { + val mapped = entries.map { mapStorageEntryToLocal(it, chainId) } + + storageDao.insertPrefixedEntries(mapped, prefix = prefixKey, chainId = chainId) + } + + override suspend fun removeByPrefix(prefixKey: String, chainId: String) { + storageDao.removeByPrefix(prefix = prefixKey, chainId = chainId) + } + + override suspend fun removeByPrefixExcept(prefixKey: String, fullKeyExceptions: List, chainId: String) { + storageDao.removeByPrefixExcept(prefixKey, fullKeyExceptions, chainId) + } + + override fun observeEntry(key: String, chainId: String): Flow { + return storageDao.observeEntry(chainId, key) + .filterNotNull() + .map { mapStorageEntryFromLocal(it) } + .distinctUntilChangedBy(StorageEntry::content) + } + + override fun observeEntries(keys: List, chainId: String): Flow> { + return storageDao.observeEntries(chainId, keys) + .filter { it.size == keys.size } + .mapList { mapStorageEntryFromLocal(it) } + } + + override suspend fun observeEntries(keyPrefix: String, chainId: String): Flow> { + return storageDao.observeEntries(chainId, keyPrefix) + .mapList { mapStorageEntryFromLocal(it) } + } + + override suspend fun getEntry(key: String, chainId: String): StorageEntry = observeEntry(key, chainId).first() + + override suspend fun filterKeysInCache(keys: List, chainId: String): List { + return storageDao.filterKeysInCache(chainId, keys) + } + + override suspend fun getEntries(fullKeys: List, chainId: String): List { + return observeEntries(fullKeys, chainId).first() + } + + override suspend fun getKeys(keyPrefix: String, chainId: String): List { + return storageDao.getKeys(chainId, keyPrefix) + } +} + +private fun mapStorageEntryToLocal( + storageEntry: StorageEntry, + chainId: String +) = with(storageEntry) { + StorageEntryLocal( + storageKey = storageKey, + content = content, + chainId = chainId + ) +} + +private fun mapStorageEntryFromLocal( + storageEntryLocal: StorageEntryLocal +) = with(storageEntryLocal) { + StorageEntry( + storageKey = storageKey, + content = content + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/SampledBlockTimeStoroage.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/SampledBlockTimeStoroage.kt new file mode 100644 index 0000000..bcad8b9 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/SampledBlockTimeStoroage.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.runtime.storage + +import com.google.gson.Gson +import io.novafoundation.nova.common.data.storage.Preferences +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.network.updaters.SampledBlockTime +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +interface SampledBlockTimeStorage { + + suspend fun get(chainId: ChainId): SampledBlockTime + + fun observe(chainId: ChainId): Flow + + suspend fun put(chainId: ChainId, sampledBlockTime: SampledBlockTime) +} + +private const val KEY = "SampledBlockTime" + +internal class PrefsSampledBlockTimeStorage( + private val gson: Gson, + private val sharedPreferences: Preferences, +) : SampledBlockTimeStorage { + + override suspend fun get(chainId: ChainId): SampledBlockTime { + val raw = sharedPreferences.getString(key(chainId)) ?: return initial() + + return gson.fromJson(raw) + } + + override fun observe(chainId: ChainId): Flow { + return sharedPreferences.stringFlow(key(chainId)).map { raw -> + raw?.let(gson::fromJson) ?: initial() + } + } + + override suspend fun put(chainId: ChainId, sampledBlockTime: SampledBlockTime) { + val raw = gson.toJson(sampledBlockTime) + + sharedPreferences.putString(key(chainId), raw) + } + + private fun key(chainId: ChainId) = "$KEY::$chainId" + + private fun initial() = SampledBlockTime(sampleSize = BigInteger.ZERO, averageBlockTime = BigInteger.ZERO) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/cache/StorageCachingContext.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/cache/StorageCachingContext.kt new file mode 100644 index 0000000..e6e16c7 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/cache/StorageCachingContext.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.runtime.storage.cache + +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.runtime.storage.source.query.WithRawValue +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface StorageCachingContext { + + val storageCache: StorageCache +} + +context(StorageCachingContext) +fun Flow>.cacheValues(): Flow { + return map { + storageCache.insert(it.raw, it.chainId) + + it.value + } +} + +fun StorageCachingContext(storageCache: StorageCache): StorageCachingContext { + return InlineStorageCachingContext(storageCache) +} + +@JvmInline +private value class InlineStorageCachingContext(override val storageCache: StorageCache) : StorageCachingContext diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/BaseStorageSource.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/BaseStorageSource.kt new file mode 100644 index 0000000..8132808 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/BaseStorageSource.kt @@ -0,0 +1,140 @@ +package io.novafoundation.nova.runtime.storage.source + +import io.novafoundation.nova.common.data.network.rpc.childStateKey +import io.novafoundation.nova.common.data.network.runtime.binding.Binder +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.core.updater.SubstrateSubscriptionBuilder +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.ethereum.subscribe +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getRuntime +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext + +abstract class BaseStorageSource( + protected val chainRegistry: ChainRegistry, + private val sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, +) : StorageDataSource { + + protected abstract suspend fun query(key: String, chainId: String, at: BlockHash?): String? + + protected abstract suspend fun observe(key: String, chainId: String): Flow + + protected abstract suspend fun queryChildState(storageKey: String, childKey: String, chainId: String): String? + + protected abstract suspend fun createQueryContext( + chainId: String, + at: BlockHash?, + runtime: RuntimeSnapshot, + applyStorageDefault: Boolean, + subscriptionBuilder: SubstrateSubscriptionBuilder?, + ): StorageQueryContext + + override suspend fun query( + chainId: String, + keyBuilder: (RuntimeSnapshot) -> String, + at: BlockHash?, + binding: Binder, + ) = withContext(Dispatchers.Default) { + val runtime = chainRegistry.getRuntime(chainId) + + val key = keyBuilder(runtime) + val rawResult = query(key, chainId, at) + + binding(rawResult, runtime) + } + + override fun observe( + chainId: String, + keyBuilder: (RuntimeSnapshot) -> String, + binder: Binder, + ) = flow { + val runtime = chainRegistry.getRuntime(chainId) + val key = keyBuilder(runtime) + + emitAll( + observe(key, chainId).map { binder(it, runtime) } + ) + } + + override suspend fun queryChildState( + chainId: String, + storageKeyBuilder: (RuntimeSnapshot) -> StorageKey, + childKeyBuilder: ChildKeyBuilder, + binder: Binder + ) = withContext(Dispatchers.Default) { + val runtime = chainRegistry.getRuntime(chainId) + + val storageKey = storageKeyBuilder(runtime) + + val childKey = childStateKey { + childKeyBuilder(runtime) + } + + val scaleResult = queryChildState(storageKey, childKey, chainId) + + binder(scaleResult, runtime) + } + + override suspend fun query( + chainId: String, + at: BlockHash?, + applyStorageDefault: Boolean, + query: suspend StorageQueryContext.() -> R + ): R { + val runtime = chainRegistry.getRuntime(chainId) + val context = createQueryContext(chainId, at, runtime, applyStorageDefault, subscriptionBuilder = null) + + return context.query() + } + + override fun subscribe( + chainId: String, + at: BlockHash?, + applyStorageDefault: Boolean, + subscribe: suspend StorageQueryContext.() -> Flow + ): Flow { + return flow { + val runtime = chainRegistry.getRuntime(chainId) + val context = createQueryContext(chainId, at, runtime, applyStorageDefault, subscriptionBuilder = null) + + emitAll(context.subscribe()) + } + } + + override suspend fun subscribe( + chainId: String, + subscriptionBuilder: SubstrateSubscriptionBuilder?, + at: BlockHash?, + applyStorageDefault: Boolean, + subscribe: suspend StorageQueryContext.() -> Flow + ): Flow { + val runtime = chainRegistry.getRuntime(chainId) + val context = createQueryContext(chainId, at, runtime, applyStorageDefault, subscriptionBuilder) + + return subscribe(context) + } + + override suspend fun subscribeBatched( + chainId: String, + at: BlockHash?, + subscribe: suspend StorageQueryContext.() -> Flow + ): Flow { + val runtime = chainRegistry.getRuntime(chainId) + val sharedSubscription = sharedRequestsBuilderFactory.create(chainId) + val context = createQueryContext(chainId, at, runtime, applyStorageDefault = false, sharedSubscription) + + val result = subscribe(context) + + sharedSubscription.subscribe(coroutineContext) + + return result + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/LocalStorageSource.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/LocalStorageSource.kt new file mode 100644 index 0000000..76eef3d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/LocalStorageSource.kt @@ -0,0 +1,48 @@ +package io.novafoundation.nova.runtime.storage.source + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.core.updater.SubstrateSubscriptionBuilder +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.storage.source.query.LocalStorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class LocalStorageSource( + chainRegistry: ChainRegistry, + sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + private val storageCache: StorageCache, +) : BaseStorageSource(chainRegistry, sharedRequestsBuilderFactory) { + + override suspend fun query(key: String, chainId: String, at: BlockHash?): String? { + requireWithoutAt(at) + + return storageCache.getEntry(key, chainId).content + } + + override suspend fun observe(key: String, chainId: String): Flow { + return storageCache.observeEntry(key, chainId) + .map { it.content } + } + + override suspend fun queryChildState(storageKey: String, childKey: String, chainId: String): String? { + throw NotImplementedError("Child state queries are not yet supported in local storage") + } + + override suspend fun createQueryContext( + chainId: String, + at: BlockHash?, + runtime: RuntimeSnapshot, + applyStorageDefault: Boolean, + subscriptionBuilder: SubstrateSubscriptionBuilder? + ): StorageQueryContext { + return LocalStorageQueryContext(storageCache, chainId, at, runtime, applyStorageDefault) + } + + private fun requireWithoutAt(at: BlockHash?) = require(at == null) { + "`At` parameter is not supported in local storage" + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/RemoteStorageSource.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/RemoteStorageSource.kt new file mode 100644 index 0000000..13774bd --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/RemoteStorageSource.kt @@ -0,0 +1,61 @@ +package io.novafoundation.nova.runtime.storage.source + +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.data.network.rpc.queryKey +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.common.data.network.runtime.calls.GetChildStateRequest +import io.novafoundation.nova.core.updater.SubstrateSubscriptionBuilder +import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.getSocket +import io.novafoundation.nova.runtime.storage.source.query.RemoteStorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.wsrpc.executeAsync +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.SubscribeStorageRequest +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.storageChange +import io.novasama.substrate_sdk_android.wsrpc.subscriptionFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RemoteStorageSource( + chainRegistry: ChainRegistry, + sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, + private val bulkRetriever: BulkRetriever, +) : BaseStorageSource(chainRegistry, sharedRequestsBuilderFactory) { + + override suspend fun query(key: String, chainId: String, at: BlockHash?): String? { + return bulkRetriever.queryKey(getSocketService(chainId), key, at) + } + + override suspend fun observe(key: String, chainId: String): Flow { + return getSocketService(chainId).subscriptionFlow(SubscribeStorageRequest(key)) + .map { it.storageChange().getSingleChange() } + } + + override suspend fun queryChildState(storageKey: String, childKey: String, chainId: String): String? { + val response = getSocketService(chainId).executeAsync(GetChildStateRequest(storageKey, childKey)) + + return response.result as? String? + } + + override suspend fun createQueryContext( + chainId: String, + at: BlockHash?, + runtime: RuntimeSnapshot, + applyStorageDefault: Boolean, + subscriptionBuilder: SubstrateSubscriptionBuilder?, + ): StorageQueryContext { + return RemoteStorageQueryContext( + bulkRetriever = bulkRetriever, + socketService = getSocketService(chainId), + subscriptionBuilder = subscriptionBuilder, + chainId = chainId, + at = at, + runtime = runtime, + applyStorageDefault = applyStorageDefault, + ) + } + + private suspend fun getSocketService(chainId: String) = chainRegistry.getSocket(chainId) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/StorageDataSource.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/StorageDataSource.kt new file mode 100644 index 0000000..cae33ac --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/StorageDataSource.kt @@ -0,0 +1,91 @@ +package io.novafoundation.nova.runtime.storage.source + +import io.novafoundation.nova.common.data.network.runtime.binding.Binder +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.common.data.network.runtime.binding.NonNullBinder +import io.novafoundation.nova.core.updater.SubstrateSubscriptionBuilder +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.flow.Flow +import java.io.OutputStream + +typealias StorageKey = String +typealias StorageValue = String? +typealias StorageEntries = Map + +typealias ChildKeyBuilder = suspend OutputStream.(RuntimeSnapshot) -> Unit + +interface StorageDataSource { + + suspend fun query( + chainId: String, + keyBuilder: (RuntimeSnapshot) -> StorageKey, + at: BlockHash? = null, + binding: Binder, + ): T + + fun observe( + chainId: String, + keyBuilder: (RuntimeSnapshot) -> StorageKey, + binder: Binder, + ): Flow + + suspend fun queryChildState( + chainId: String, + storageKeyBuilder: (RuntimeSnapshot) -> StorageKey, + childKeyBuilder: ChildKeyBuilder, + binder: Binder + ): T + + suspend fun query( + chainId: String, + at: BlockHash? = null, + applyStorageDefault: Boolean = false, + query: suspend StorageQueryContext.() -> R + ): R + + fun subscribe( + chainId: String, + at: BlockHash? = null, + applyStorageDefault: Boolean = false, + subscribe: suspend StorageQueryContext.() -> Flow + ): Flow + + suspend fun subscribe( + chainId: String, + subscriptionBuilder: SubstrateSubscriptionBuilder?, + at: BlockHash? = null, + applyStorageDefault: Boolean = false, + subscribe: suspend StorageQueryContext.() -> Flow + ): Flow + + /** + * Aggregates all requests called via [subscribe] block and executes them as a single batch request, if possible + * Lifecycle of subscription is bound to parent coroutine + */ + suspend fun subscribeBatched( + chainId: String, + at: BlockHash? = null, + subscribe: suspend StorageQueryContext.() -> Flow + ): Flow +} + +suspend fun StorageDataSource.queryCatching( + chainId: String, + at: BlockHash? = null, + applyStorageDefault: Boolean = false, + query: suspend StorageQueryContext.() -> R, +): Result = runCatching { query(chainId = chainId, at = at, applyStorageDefault = applyStorageDefault, query = query) } + +suspend inline fun StorageDataSource.queryNonNull( + chainId: String, + noinline keyBuilder: (RuntimeSnapshot) -> String, + crossinline binding: NonNullBinder, + at: BlockHash? = null +) = query(chainId, keyBuilder, at) { scale, runtime -> binding(scale!!, runtime) } + +inline fun StorageDataSource.observeNonNull( + chainId: String, + noinline keyBuilder: (RuntimeSnapshot) -> String, + crossinline binding: NonNullBinder, +) = observe(chainId, keyBuilder) { scale, runtime -> binding(scale!!, runtime) } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/multi/MultiQueryBuilder.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/multi/MultiQueryBuilder.kt new file mode 100644 index 0000000..7eb099d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/multi/MultiQueryBuilder.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.runtime.storage.source.multi + +import io.novafoundation.nova.runtime.storage.source.query.DynamicInstanceBinder +import io.novafoundation.nova.runtime.storage.source.query.StorageKeyComponents +import io.novafoundation.nova.runtime.storage.source.query.wrapSingleArgumentKeys +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry + +interface MultiQueryBuilder { + + interface Descriptor { + + fun parseKey(key: String): K + + fun parseValue(value: String?): V + } + + interface Result { + + operator fun get(descriptor: Descriptor): Map + } + + fun StorageEntry.queryKey( + vararg args: Any?, + binding: DynamicInstanceBinder + ): Descriptor + + fun StorageEntry.queryKeys( + keysArgs: List>, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinder + ): Descriptor + + fun StorageEntry.querySingleArgKeys( + keysArgs: Iterable, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinder + ): Descriptor = queryKeys(keysArgs.wrapSingleArgumentKeys(), keyExtractor, binding) +} + +fun MultiQueryBuilder.Result.singleValueOf(descriptor: MultiQueryBuilder.Descriptor<*, V>): V = get(descriptor).values.first() diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/multi/MultiQueryBuilderImpl.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/multi/MultiQueryBuilderImpl.kt new file mode 100644 index 0000000..29b17db --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/multi/MultiQueryBuilderImpl.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.runtime.storage.source.multi + +import io.novafoundation.nova.common.utils.splitKeyToComponents +import io.novafoundation.nova.runtime.storage.source.multi.MultiQueryBuilder.Descriptor +import io.novafoundation.nova.runtime.storage.source.query.DynamicInstanceBinder +import io.novafoundation.nova.runtime.storage.source.query.StorageKeyComponents +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageKeys + +class MultiQueryBuilderImpl( + private val runtime: RuntimeSnapshot +) : MultiQueryBuilder { + + private val descriptors: MutableMap, List> = mutableMapOf() + private val keys: MutableMap> = mutableMapOf() + + override fun StorageEntry.queryKey( + vararg args: Any?, + binding: DynamicInstanceBinder + ): Descriptor { + val key = storageKey(runtime, *args) + + keysForEntry(this).add(key) + return registerDescriptor(listOf(key), this, keyExtractor = { it }, binding) + } + + override fun StorageEntry.queryKeys( + keysArgs: List>, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinder + ): Descriptor { + val keys = storageKeys(runtime, keysArgs) + + keysForEntry(this).addAll(keys) + return registerDescriptor(keys, this, keyExtractor, binding) + } + + fun descriptors(): Map, List> { + return descriptors + } + + fun keys(): Map> { + return keys + } + + private fun keysForEntry(entry: StorageEntry) = keys.getOrPut(entry, ::mutableListOf) + + private fun registerDescriptor( + keys: List, + storageEntry: StorageEntry, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinder + ): Descriptor { + val newDescriptor = RealDescriptor(storageEntry, keyExtractor, binding) + descriptors[newDescriptor] = keys + + return newDescriptor + } + + private inner class RealDescriptor( + private val storageEntry: StorageEntry, + private val keyExtractor: (StorageKeyComponents) -> K, + private val valueBinding: (decoded: Any?) -> V + ) : Descriptor { + override fun parseKey(key: String): K { + val keyComponents = storageEntry.splitKeyToComponents(runtime, key) + + return keyExtractor(keyComponents) + } + + override fun parseValue(value: String?): V { + val valueType = storageEntry.type.value!! + val decoded = value?.let { valueType.fromHex(runtime, value) } + + return valueBinding(decoded) + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/BaseStorageQueryContext.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/BaseStorageQueryContext.kt new file mode 100644 index 0000000..460a0a8 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/BaseStorageQueryContext.kt @@ -0,0 +1,295 @@ +package io.novafoundation.nova.runtime.storage.source.query + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.common.data.network.runtime.binding.bindNumberOrZero +import io.novafoundation.nova.common.data.network.runtime.binding.fromHexOrIncompatible +import io.novafoundation.nova.common.data.network.runtime.binding.incompatible +import io.novafoundation.nova.common.utils.ComponentHolder +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.createStorageKey +import io.novafoundation.nova.common.utils.mapValuesNotNull +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageEntries +import io.novafoundation.nova.runtime.storage.source.multi.MultiQueryBuilder +import io.novafoundation.nova.runtime.storage.source.multi.MultiQueryBuilderImpl +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.u16 +import io.novasama.substrate_sdk_android.runtime.definitions.types.toByteArray +import io.novasama.substrate_sdk_android.runtime.metadata.StorageEntryModifier +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntryType +import io.novasama.substrate_sdk_android.runtime.metadata.splitKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageKey +import io.novasama.substrate_sdk_android.runtime.metadata.storageKeys +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.math.BigInteger +import io.novafoundation.nova.core.model.StorageEntry as StorageEntryValue + +abstract class BaseStorageQueryContext( + override val chainId: ChainId, + override val runtime: RuntimeSnapshot, + private val at: BlockHash?, + private val applyStorageDefault: Boolean +) : StorageQueryContext { + + protected abstract suspend fun queryKeysByPrefix(prefix: String, at: BlockHash?): List + + protected abstract suspend fun queryEntriesByPrefix(prefix: String, at: BlockHash?): Map + + protected abstract suspend fun queryKeys(keys: List, at: BlockHash?): Map + + protected abstract suspend fun queryKey(key: String, at: BlockHash?): String? + + protected abstract fun observeKey(key: String): Flow + + protected abstract fun observeKeys(keys: List): Flow> + + protected abstract suspend fun observeKeysByPrefix(prefix: String): Flow> + + override suspend fun StorageEntry.keys(vararg prefixArgs: Any?): List { + val prefix = storageKey(runtime, *prefixArgs) + + return queryKeysByPrefix(prefix, at).map { ComponentHolder(splitKey(runtime, it)) } + } + + override suspend fun StorageEntry.entries( + vararg prefixArgs: Any?, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey, + recover: (exception: Exception, rawValue: String?) -> Unit + ): Map { + val prefix = storageKey(runtime, *prefixArgs) + + val entries = queryEntriesByPrefix(prefix, at) + + return applyMappersToEntries( + entries = entries, + storageEntry = this, + keyExtractor = keyExtractor, + binding = binding, + recover = recover + ) + } + + override suspend fun StorageEntry.entries( + keysArguments: List>, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey, + recover: (exception: Exception, rawValue: String?) -> Unit + ): Map { + val entries = queryKeys(storageKeys(runtime, keysArguments), at) + + return applyMappersToEntries( + entries = entries, + storageEntry = this, + keyExtractor = keyExtractor, + binding = binding, + recover = recover + ) + } + + override suspend fun StorageEntry.observeByPrefix( + vararg prefixArgs: Any?, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey + ): Flow> { + val prefixKey = storageKey(runtime, *prefixArgs) + + return observeKeysByPrefix(prefixKey).map { valuesByKey -> + applyMappersToEntries( + entries = valuesByKey, + storageEntry = this, + keyExtractor = keyExtractor, + binding = binding + ) + } + } + + override suspend fun StorageEntry.entriesRaw(vararg prefixArgs: Any?): StorageEntries { + return queryEntriesByPrefix(storageKey(runtime, *prefixArgs), at) + } + + override suspend fun StorageEntry.entriesRaw(keysArguments: List>): StorageEntries { + return queryKeys(storageKeys(runtime, keysArguments), at) + } + + override suspend fun Module.palletVersionOrThrow(): Int { + val manualStorageVersionEntry = StorageEntry( + moduleName = name, + name = ":__STORAGE_VERSION__:", + modifier = StorageEntryModifier.Required, + type = StorageEntryType.Plain(value = u16), + default = u16.toByteArray(runtime, BigInteger.ZERO), + documentation = emptyList() + ) + + return manualStorageVersionEntry.query(binding = ::bindNumberOrZero).toInt() + } + + override suspend fun StorageEntry.query( + vararg keyArguments: Any?, + binding: DynamicInstanceBinder + ): V { + val storageKey = createStorageKeyFromArrayArgs(keyArguments) + val scaleResult = queryKey(storageKey, at) + return decodeStorageValue(scaleResult, binding) + } + + override suspend fun StorageEntry.queryRaw(vararg keyArguments: Any?): String? { + val storageKey = createStorageKeyFromArrayArgs(keyArguments) + + return queryKey(storageKey, at) + } + + override fun StorageEntry.observe( + vararg keyArguments: Any?, + binding: DynamicInstanceBinder + ): Flow { + val storageKey = createStorageKeyFromArrayArgs(keyArguments) + + return observeKey(storageKey).map { storageUpdate -> + decodeStorageValue(storageUpdate.value, binding) + } + } + + override fun StorageEntry.observeWithRaw( + vararg keyArguments: Any?, + binding: DynamicInstanceBinder + ): Flow> { + val storageKey = createStorageKeyFromArrayArgs(keyArguments) + + return observeKey(storageKey).map { storageUpdate -> + val decoded = decodeStorageValue(storageUpdate.value, binding) + + WithRawValue( + raw = StorageEntryValue(storageKey, storageUpdate.value), + chainId = chainId, + value = decoded, + at = storageUpdate.at, + ) + } + } + + override fun StorageEntry.observe( + keysArguments: List>, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey + ): Flow> { + val storageKeys = storageKeys(runtime, keysArguments) + + return observeKeys(storageKeys).map { valuesByKey -> + applyMappersToEntriesNullable( + entries = valuesByKey, + storageEntry = this, + keyExtractor = keyExtractor, + binding = binding + ) + } + } + + @Suppress("OVERRIDE_DEPRECATION", "OverridingDeprecatedMember") + override suspend fun multiInternal( + builderBlock: MultiQueryBuilder.() -> Unit + ): MultiQueryBuilder.Result { + val builder = MultiQueryBuilderImpl(runtime).apply(builderBlock) + + val keys = builder.keys().flatMap { (_, keys) -> keys } + val values = queryKeys(keys, at) + + val delegate = builder.descriptors().mapValues { (descriptor, keys) -> + keys.associateBy( + keySelector = { key -> descriptor.parseKey(key) }, + valueTransform = { key -> descriptor.parseValue(values[key]) } + ) + } + + return MultiQueryResult(delegate) + } + + private fun StorageEntry.decodeStorageValue( + scale: String?, + binding: DynamicInstanceBinder + ): V { + val dynamicInstance = scale?.let { + type.value?.fromHex(runtime, scale) + } ?: takeDefaultIfAllowed() + + return binding(dynamicInstance) + } + + private fun applyMappersToEntries( + entries: Map, + storageEntry: StorageEntry, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey, + recover: (exception: Exception, rawValue: String?) -> Unit = { exception, _ -> throw exception } + ): Map { + val returnType = storageEntry.type.value ?: incompatible() + + return entries.mapKeys { (key, _) -> + val keyComponents = ComponentHolder(storageEntry.splitKey(runtime, key)) + + keyExtractor(keyComponents) + }.mapValuesNotNull { (key, value) -> + try { + val decoded = value?.let { returnType.fromHexOrIncompatible(value, runtime) } + binding(decoded, key) + } catch (e: Exception) { + recover(e, value) + null + } + } + } + + private fun applyMappersToEntriesNullable( + entries: Map, + storageEntry: StorageEntry, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey, + ): Map { + val returnType = storageEntry.type.value ?: incompatible() + + return entries.mapKeys { (key, _) -> + val keyComponents = ComponentHolder(storageEntry.splitKey(runtime, key)) + + keyExtractor(keyComponents) + }.mapValues { (key, value) -> + try { + val decoded = value?.let { returnType.fromHexOrIncompatible(value, runtime) } + binding(decoded, key) + } catch (e: Exception) { + null + } + } + } + + context(RuntimeContext) + private fun StorageEntry.createStorageKeyFromArrayArgs(keyArguments: Array): String { + return createStorageKey(*keyArguments) + } + + protected class StorageUpdate( + val value: String?, + // Might be null in case the source does not support identifying the block at which value was changed + val at: BlockHash? + ) + + private fun StorageEntry.takeDefaultIfAllowed(): Any? { + if (!applyStorageDefault) return null + + return type.value?.fromByteArray(runtime, default) + } + + @JvmInline + private value class MultiQueryResult(val delegate: Map, Map>) : MultiQueryBuilder.Result { + + @Suppress("UNCHECKED_CAST") + override fun get(descriptor: MultiQueryBuilder.Descriptor): Map { + return delegate.getValue(descriptor) as Map + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/LocalStorageQueryContext.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/LocalStorageQueryContext.kt new file mode 100644 index 0000000..76ca7ba --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/LocalStorageQueryContext.kt @@ -0,0 +1,66 @@ +package io.novafoundation.nova.runtime.storage.source.query + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.core.model.StorageEntry +import io.novafoundation.nova.core.storage.StorageCache +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class LocalStorageQueryContext( + private val storageCache: StorageCache, + chainId: ChainId, + at: BlockHash?, + runtime: RuntimeSnapshot, + applyStorageDefault: Boolean +) : BaseStorageQueryContext(chainId, runtime, at, applyStorageDefault) { + + override suspend fun queryKeysByPrefix(prefix: String, at: BlockHash?): List { + return storageCache.getKeys(prefix, chainId) + } + + override suspend fun queryEntriesByPrefix(prefix: String, at: BlockHash?): Map { + return observeKeysByPrefix(prefix) + .filter { it.isNotEmpty() } + .first() + } + + override suspend fun queryKeys(keys: List, at: BlockHash?): Map { + return storageCache.getEntries(keys, chainId).toMap() + } + + override suspend fun queryKey(key: String, at: BlockHash?): String? { + return storageCache.getEntry(key, chainId).content + } + + override fun observeKey(key: String): Flow { + return storageCache.observeEntry(key, chainId).map { + StorageUpdate(it.content, at = null) + } + } + + override fun observeKeys(keys: List): Flow> { + return storageCache.observeEntries(keys, chainId) + .map { it.toMap() } + .distinctUntilChanged() + } + + override suspend fun observeKeysByPrefix(prefix: String): Flow> { + return storageCache.observeEntries(prefix, chainId) + .map { storageEntries -> + storageEntries.associateBy( + keySelector = StorageEntry::storageKey, + valueTransform = StorageEntry::content + ) + } + } + + private fun List.toMap() = associateBy( + keySelector = StorageEntry::storageKey, + valueTransform = StorageEntry::content + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/RemoteStorageQueryContext.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/RemoteStorageQueryContext.kt new file mode 100644 index 0000000..36cdafa --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/RemoteStorageQueryContext.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.runtime.storage.source.query + +import io.novafoundation.nova.common.data.network.rpc.BulkRetriever +import io.novafoundation.nova.common.data.network.rpc.queryKey +import io.novafoundation.nova.common.data.network.rpc.retrieveAllValues +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.common.utils.toMultiSubscription +import io.novafoundation.nova.core.updater.SubstrateSubscriptionBuilder +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.SubscribeStorageRequest +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.storageChange +import io.novasama.substrate_sdk_android.wsrpc.subscriptionFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RemoteStorageQueryContext( + private val bulkRetriever: BulkRetriever, + private val socketService: SocketService, + private val subscriptionBuilder: SubstrateSubscriptionBuilder?, + chainId: ChainId, + at: BlockHash?, + runtime: RuntimeSnapshot, + applyStorageDefault: Boolean +) : BaseStorageQueryContext(chainId, runtime, at, applyStorageDefault) { + + override suspend fun queryKeysByPrefix(prefix: String, at: BlockHash?): List { + return bulkRetriever.retrieveAllKeys(socketService, prefix, at) + } + + override suspend fun queryEntriesByPrefix(prefix: String, at: BlockHash?): Map { + return bulkRetriever.retrieveAllValues(socketService, prefix, at) + } + + override suspend fun queryKeys(keys: List, at: BlockHash?): Map { + return bulkRetriever.queryKeys(socketService, keys, at) + } + + override suspend fun queryKey(key: String, at: BlockHash?): String? { + return bulkRetriever.queryKey(socketService, key, at) + } + + @Suppress("IfThenToElvis") + override fun observeKey(key: String): Flow { + return if (subscriptionBuilder != null) { + subscriptionBuilder.subscribe(key).map { + StorageUpdate( + value = it.value, + at = it.block + ) + } + } else { + socketService.subscriptionFlow(SubscribeStorageRequest(key)) + .map { + val storageChange = it.storageChange() + + StorageUpdate( + value = storageChange.getSingleChange(), + at = storageChange.block + ) + } + } + } + + // TODO To this is not quite efficient implementation as we are de-multiplexing arrived keys into multiple flows (in sdk) and them merging them back + // Instead, we should allow batch subscriptions on sdk level + override fun observeKeys(keys: List): Flow> { + requireNotNull(subscriptionBuilder) { + "Cannot perform batched subscription without a builder. Have you forgot to call 'subscribeBatched()` instead of `subscribe()?`" + } + + return keys.map { key -> + subscriptionBuilder.subscribe(key).map { key to it.value } + }.toMultiSubscription(keys.size) + } + + override suspend fun observeKeysByPrefix(prefix: String): Flow> { + TODO("Not yet supported") + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/StorageQueryContext.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/StorageQueryContext.kt new file mode 100644 index 0000000..3d28130 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/StorageQueryContext.kt @@ -0,0 +1,122 @@ +package io.novafoundation.nova.runtime.storage.source.query + +import io.novafoundation.nova.common.utils.ComponentHolder +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.storage.source.StorageEntries +import io.novafoundation.nova.runtime.storage.source.StorageKey +import io.novafoundation.nova.runtime.storage.source.StorageValue +import io.novafoundation.nova.runtime.storage.source.multi.MultiQueryBuilder +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import kotlinx.coroutines.flow.Flow +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +typealias StorageKeyComponents = ComponentHolder +typealias DynamicInstanceBinder = (dynamicInstance: Any?) -> V +typealias DynamicInstanceBinderWithKey = (dynamicInstance: Any?, key: K) -> V + +interface StorageQueryContext : RuntimeContext { + + val chainId: ChainId + + override val runtime: RuntimeSnapshot + + suspend fun StorageEntry.keys(vararg prefixArgs: Any?): List + + fun StorageEntry.observe( + vararg keyArguments: Any?, + binding: DynamicInstanceBinder + ): Flow + + fun StorageEntry.observeWithRaw( + vararg keyArguments: Any?, + binding: DynamicInstanceBinder + ): Flow> + + fun StorageEntry.observe( + keysArguments: List>, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey + ): Flow> + + suspend fun StorageEntry.observeByPrefix( + vararg prefixArgs: Any?, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey + ): Flow> + + suspend fun StorageEntry.entries( + vararg prefixArgs: Any?, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey, + recover: (exception: Exception, rawValue: String?) -> Unit = { exception, _ -> throw exception } + ): Map + + suspend fun StorageEntry.entriesRaw( + vararg prefixArgs: Any?, + ): Map + + suspend fun StorageEntry.entriesRaw( + keysArguments: List> + ): StorageEntries + + suspend fun StorageEntry.entries( + keysArguments: List>, + keyExtractor: (StorageKeyComponents) -> K, + binding: DynamicInstanceBinderWithKey, + recover: (exception: Exception, rawValue: String?) -> Unit = { exception, _ -> throw exception } + ): Map + + suspend fun StorageEntry.query( + vararg keyArguments: Any?, + binding: DynamicInstanceBinder + ): V + + suspend fun StorageEntry.queryRaw( + vararg keyArguments: Any? + ): StorageValue + + @Deprecated("Use multi for better smart-casting", replaceWith = ReplaceWith(expression = "multi(builderBlock)")) + suspend fun multiInternal( + builderBlock: MultiQueryBuilder.() -> Unit + ): MultiQueryBuilder.Result + + // no keyExtractor short-cut + suspend fun StorageEntry.entries( + vararg prefixArgs: Any?, + binding: (Any?, StorageKeyComponents) -> V + ): Map = entries( + *prefixArgs, + keyExtractor = { it }, + binding = binding + ) + + suspend fun Module.palletVersionOrThrow(): Int + + suspend fun StorageEntry.singleArgumentEntries( + keysArguments: Collection, + binding: DynamicInstanceBinderWithKey + ): Map = entries( + keysArguments = keysArguments.wrapSingleArgumentKeys(), + keyExtractor = { it.component1() as K }, + binding = binding + ) +} + +@Suppress("DEPRECATION") +@OptIn(ExperimentalContracts::class) +suspend fun StorageQueryContext.multi( + builderBlock: MultiQueryBuilder.() -> Unit +): MultiQueryBuilder.Result { + contract { + callsInPlace(builderBlock, InvocationKind.EXACTLY_ONCE) + } + + return multiInternal(builderBlock) +} + +fun Iterable<*>.wrapSingleArgumentKeys(): List> = map(::listOf) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/WithRawValue.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/WithRawValue.kt new file mode 100644 index 0000000..63c1f76 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/WithRawValue.kt @@ -0,0 +1,16 @@ +package io.novafoundation.nova.runtime.storage.source.query + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash +import io.novafoundation.nova.core.model.StorageEntry +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +class WithRawValue(val at: BlockHash?, val raw: StorageEntry, val chainId: ChainId, val value: T) + +data class AtBlock(val value: T, val at: BlockHash) + +fun WithRawValue.toAtBlock(): AtBlock { + return AtBlock( + at = requireNotNull(at) { "Block hash was not specified" }, + value = value + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableModule.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableModule.kt new file mode 100644 index 0000000..c017f1d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableModule.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.runtime.storage.source.query.api + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import io.novasama.substrate_sdk_android.runtime.metadata.storage +import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull + +typealias QueryableStorageKeyFromInternalBinder = (keyInstance: Any) -> K +typealias QueryableStorageKeyToInternalBinder = (key: K) -> Any? + +interface QueryableModule { + + val module: Module +} + +context(RuntimeContext) +fun QueryableModule.storage0(name: String, binding: QueryableStorageBinder0): QueryableStorageEntry0 { + return RealQueryableStorageEntry0(module.storage(name), binding, this@RuntimeContext) +} + +context(RuntimeContext) +fun QueryableModule.storage0OrNull(name: String, binding: QueryableStorageBinder0): QueryableStorageEntry0? { + return module.storageOrNull(name)?.let { RealQueryableStorageEntry0(it, binding, this@RuntimeContext) } +} + +context(RuntimeContext) +fun QueryableModule.storage1( + name: String, + binding: QueryableStorageBinder1, + keyBinding: QueryableStorageKeyFromInternalBinder? = null +): QueryableStorageEntry1 { + return RealQueryableStorageEntry1(module.storage(name), binding, this@RuntimeContext, keyBinding) +} + +context(RuntimeContext) +fun QueryableModule.storage1OrNull( + name: String, + binding: QueryableStorageBinder1, + keyBinding: QueryableStorageKeyFromInternalBinder? = null +): QueryableStorageEntry1? { + return module.storageOrNull(name)?.let { RealQueryableStorageEntry1(it, binding, this@RuntimeContext, keyBinding) } +} + +context(RuntimeContext) +fun QueryableModule.storage2( + name: String, + binding: QueryableStorageBinder2, + key1ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + key2ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + key1FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, + key2FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, +): QueryableStorageEntry2 { + return RealQueryableStorageEntry2( + storageEntry = module.storage(name), + binding = binding, + + key1ToInternalConverter = key1ToInternalConverter, + key2ToInternalConverter = key2ToInternalConverter, + key1FromInternalConverter = key1FromInternalConverter, + key2FromInternalConverter = key2FromInternalConverter + ) +} + +context(RuntimeContext) +fun QueryableModule.storage3( + name: String, + binding: QueryableStorageBinder3, + key1ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + key2ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + key3ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + key1FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, + key2FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, + key3FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, +): QueryableStorageEntry3 { + return RealQueryableStorageEntry3( + storageEntry = module.storage(name), + binding = binding, + + key1ToInternalConverter = key1ToInternalConverter, + key2ToInternalConverter = key2ToInternalConverter, + key3ToInternalConverter = key3ToInternalConverter, + key1FromInternalConverter = key1FromInternalConverter, + key2FromInternalConverter = key2FromInternalConverter, + key3FromInternalConverter = key3FromInternalConverter + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry0.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry0.kt new file mode 100644 index 0000000..563517a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry0.kt @@ -0,0 +1,84 @@ +package io.novafoundation.nova.runtime.storage.source.query.api + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.createStorageKey +import io.novafoundation.nova.runtime.storage.source.query.AtBlock +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.WithRawValue +import io.novafoundation.nova.runtime.storage.source.query.toAtBlock +import io.novasama.substrate_sdk_android.runtime.metadata.fullName +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map + +typealias QueryableStorageBinder0 = (dynamicInstance: Any) -> V + +interface QueryableStorageEntry0 { + + val storageEntry: StorageEntry + + context(StorageQueryContext) + suspend fun query(): T? + + context(StorageQueryContext) + suspend fun queryRaw(): String? + + context(StorageQueryContext) + fun observe(): Flow + + context(StorageQueryContext) + fun observeWithRaw(): Flow> + + fun storageKey(): String +} + +context(StorageQueryContext) +fun QueryableStorageEntry0.observeWithBlockHash(): Flow> { + return observeWithRaw().map { it.toAtBlock() } +} + +context(StorageQueryContext) +@Suppress("UNCHECKED_CAST") +fun QueryableStorageEntry0.observeNonNullWithBlockHash(): Flow> { + return observeWithBlockHash().filter { it.value != null } as Flow> +} + +context(StorageQueryContext) +fun QueryableStorageEntry0.observeNonNull(): Flow = observe().filterNotNull() + +context(StorageQueryContext) +suspend fun QueryableStorageEntry0.queryNonNull(): T = requireNotNull(query()) { + "Null was was not expected when querying ${storageEntry.fullName}" +} + +internal class RealQueryableStorageEntry0( + override val storageEntry: StorageEntry, + private val binding: QueryableStorageBinder0, + runtimeContext: RuntimeContext +) : QueryableStorageEntry0, RuntimeContext by runtimeContext { + + context(StorageQueryContext) + override suspend fun query(): T? { + return storageEntry.query(binding = { decoded -> decoded?.let(binding) }) + } + + context(StorageQueryContext) + override fun observe(): Flow { + return storageEntry.observe(binding = { decoded -> decoded?.let(binding) }) + } + + context(StorageQueryContext) override fun observeWithRaw(): Flow> { + return storageEntry.observeWithRaw(binding = { decoded -> decoded?.let(binding) }) + } + + context(StorageQueryContext) + override suspend fun queryRaw(): String? { + return storageEntry.queryRaw() + } + + override fun storageKey(): String { + return storageEntry.createStorageKey() + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry1.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry1.kt new file mode 100644 index 0000000..7a7c887 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry1.kt @@ -0,0 +1,129 @@ +package io.novafoundation.nova.runtime.storage.source.query.api + +import io.novafoundation.nova.common.utils.RuntimeContext +import io.novafoundation.nova.common.utils.createStorageKey +import io.novafoundation.nova.runtime.storage.source.query.StorageKeyComponents +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.WithRawValue +import io.novafoundation.nova.runtime.storage.source.query.wrapSingleArgumentKeys +import io.novasama.substrate_sdk_android.runtime.metadata.fullName +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull + +typealias QueryableStorageBinder1 = (dynamicInstance: Any, key: K) -> V + +interface QueryableStorageEntry1 { + + val storageEntry: StorageEntry + + context(StorageQueryContext) + suspend fun keys(): List + + context(StorageQueryContext) + suspend fun entries(): Map + + context(StorageQueryContext) + suspend fun query(argument: I): T? + + context(StorageQueryContext) + suspend fun multi(keys: List, keyTransform: (I) -> K): Map + + context(StorageQueryContext) + suspend fun multi(keys: List): Map + + context(StorageQueryContext) + suspend fun queryRaw(argument: I): String? + + context(StorageQueryContext) + fun observe(argument: I): Flow + + context(StorageQueryContext) + fun observeWithRaw(argument: I): Flow> + + fun storageKey(argument: I): String +} + +context(StorageQueryContext) +fun QueryableStorageEntry1.observeNonNull(argument: I): Flow = observe(argument).filterNotNull() + +context(StorageQueryContext) +suspend fun QueryableStorageEntry1.queryNonNull(argument: I): T = requireNotNull(query(argument)) { + "Null value was not expected when querying ${storageEntry.fullName} with $argument" +} + +internal class RealQueryableStorageEntry1( + override val storageEntry: StorageEntry, + private val binding: QueryableStorageBinder1, + runtimeContext: RuntimeContext, + @Suppress("UNCHECKED_CAST") private val keyBinding: QueryableStorageKeyFromInternalBinder? = null +) : QueryableStorageEntry1, RuntimeContext by runtimeContext { + + context(StorageQueryContext) + override suspend fun query(argument: I): T? { + return storageEntry.query(argument, binding = { decoded -> decoded?.let { binding(it, argument) } }) + } + + context(StorageQueryContext) + override fun observe(argument: I): Flow { + return storageEntry.observe(argument, binding = { decoded -> decoded?.let { binding(it, argument) } }) + } + + context(StorageQueryContext) + override suspend fun queryRaw(argument: I): String? { + return storageEntry.queryRaw(argument) + } + + override fun storageKey(argument: I): String { + return storageEntry.createStorageKey(argument) + } + + context(StorageQueryContext) + override fun observeWithRaw(argument: I): Flow> { + return storageEntry.observeWithRaw(argument, binding = { decoded -> decoded?.let { binding(it, argument) } }) + } + + context(StorageQueryContext) + @Suppress("UNCHECKED_CAST") + override suspend fun multi(keys: List, keyTransform: (I) -> K): Map { + val reverseKeyLookup = keys.associateBy(keyTransform) + + return storageEntry.entries( + keysArguments = keys.wrapSingleArgumentKeys(), + keyExtractor = { (key: Any?) -> keyTransform(key as I) }, + binding = { decoded, key -> decoded?.let { binding(it, reverseKeyLookup.getValue(key)) } } + ) + } + + context(StorageQueryContext) + override suspend fun multi(keys: List): Map { + return storageEntry.singleArgumentEntries( + keysArguments = keys, + binding = { decoded, key -> decoded?.let { binding(it, key) } } + ) + } + + context(StorageQueryContext) + override suspend fun keys(): List { + return storageEntry.keys().map(::bindKey) + } + + context(StorageQueryContext) + override suspend fun entries(): Map { + return storageEntry.entries( + keyExtractor = ::bindKey, + binding = { decoded, key -> decoded?.let { binding(it, key) } as T } + ) + } + + private fun bindKey(storageKeyComponents: StorageKeyComponents): I { + val firstComponent = storageKeyComponents.component1() + + @Suppress("UNCHECKED_CAST") + return if (firstComponent != null && keyBinding != null) { + keyBinding.invoke(firstComponent) + } else { + firstComponent as I + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry2.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry2.kt new file mode 100644 index 0000000..1ee5d64 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry2.kt @@ -0,0 +1,133 @@ +@file:Suppress("UNCHECKED_CAST") + +package io.novafoundation.nova.runtime.storage.source.query.api + +import io.novafoundation.nova.common.utils.ComponentHolder +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.runtime.storage.source.StorageEntries +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +typealias QueryableStorageBinder2 = (dynamicInstance: Any, key1: K1, key2: K2) -> V + +interface QueryableStorageEntry2 { + + context(StorageQueryContext) + fun observe(argument1: I1, argument2: I2): Flow + + context(StorageQueryContext) + suspend fun query(argument1: I1, argument2: I2): T? + + context(StorageQueryContext) + suspend fun observe(keys: List>): Flow, T?>> + + context(StorageQueryContext) + suspend fun keys(argument1: I1): List> + + context(StorageQueryContext) + suspend fun entriesRaw(keys: List>): StorageEntries + + context(StorageQueryContext) + suspend fun entries(keys: List>): Map, T> + + context(StorageQueryContext) + suspend fun keys(): Set> +} + +context(StorageQueryContext) +suspend fun QueryableStorageEntry2.observeNotNull(keys: List>): Flow, T>> { + return observe(keys).map { it.filterNotNull() } +} + +internal class RealQueryableStorageEntry2( + private val storageEntry: StorageEntry, + private val binding: QueryableStorageBinder2, + + private val key1ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + private val key2ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + + private val key1FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, + private val key2FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, +) : QueryableStorageEntry2 { + + context(StorageQueryContext) + override fun observe(argument1: I1, argument2: I2): Flow { + return storageEntry.observe( + convertKey1ToInternal(argument1), + convertKey2ToInternal(argument2), + binding = { decoded -> decoded?.let { binding(it, argument1, argument2) } } + ) + } + + context(StorageQueryContext) + override suspend fun observe(keys: List>): Flow, T?>> { + return storageEntry.observe( + keysArguments = keys.toInternal(), + keyExtractor = { components -> convertKeyFromInternal(components) }, + binding = { decoded, key -> decoded?.let { binding(it, key.first, key.second) } } + ) + } + + context(StorageQueryContext) + override suspend fun entriesRaw(keys: List>): StorageEntries { + return storageEntry.entriesRaw(keysArguments = keys.toInternal()) + } + + context(StorageQueryContext) + override suspend fun entries(keys: List>): Map, T> { + return storageEntry.entries( + keysArguments = keys.toInternal(), + keyExtractor = { components -> convertKeyFromInternal(components) }, + binding = { decoded, key -> decoded?.let { binding(it, key.first, key.second) } } + ).filterNotNull() + } + + context(StorageQueryContext) + override suspend fun keys(): Set> { + return storageEntry.keys().mapToSet(::convertKeyFromInternal) + } + + context(StorageQueryContext) + override suspend fun keys(argument1: I1): List> { + return storageEntry.keys(convertKey1ToInternal(argument1)) + .map { convertKeyFromInternal(it) } + } + + context(StorageQueryContext) + override suspend fun query(argument1: I1, argument2: I2): T? { + return storageEntry.query( + convertKey1ToInternal(argument1), + convertKey2ToInternal(argument2), + binding = { decoded -> decoded?.let { binding(it, argument1, argument2) } } + ) + } + + private fun List>.toInternal(): List> { + return map(::convertKeyToInternal) + } + + private fun convertKey1ToInternal(key1: I1): Any? { + return key1ToInternalConverter?.invoke(key1) ?: key1 + } + + private fun convertKey2ToInternal(key2: I2): Any? { + return key2ToInternalConverter?.invoke(key2) ?: key2 + } + + private fun convertKeyToInternal(key: Pair): List { + return listOf( + convertKey1ToInternal(key.first), + convertKey2ToInternal(key.second) + ) + } + + private fun convertKeyFromInternal(componentHolder: ComponentHolder): Pair { + val first = key1FromInternalConverter?.invoke(componentHolder.component1()) ?: componentHolder.component1() as I1 + val second = key2FromInternalConverter?.invoke(componentHolder.component2()) ?: componentHolder.component2() as I2 + + return first to second + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry3.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry3.kt new file mode 100644 index 0000000..ec1d026 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/QueryableStorageEntry3.kt @@ -0,0 +1,80 @@ +@file:Suppress("UNCHECKED_CAST") + +package io.novafoundation.nova.runtime.storage.source.query.api + +import io.novafoundation.nova.common.utils.ComponentHolder +import io.novafoundation.nova.common.utils.filterNotNull +import io.novafoundation.nova.common.utils.mapToSet +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry + +typealias QueryableStorageBinder3 = (dynamicInstance: Any, key1: K1, key2: K2, key3: K3) -> V + +interface QueryableStorageEntry3 { + + context(StorageQueryContext) + suspend fun entries(keys: List>): Map, T> + + context(StorageQueryContext) + suspend fun keys(): Set> +} + +internal class RealQueryableStorageEntry3( + private val storageEntry: StorageEntry, + private val binding: QueryableStorageBinder3, + + private val key1ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + private val key2ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + private val key3ToInternalConverter: QueryableStorageKeyToInternalBinder? = null, + + private val key1FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, + private val key2FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, + private val key3FromInternalConverter: QueryableStorageKeyFromInternalBinder? = null, +) : QueryableStorageEntry3 { + + context(StorageQueryContext) + override suspend fun entries(keys: List>): Map, T> { + return storageEntry.entries( + keysArguments = keys.toInternal(), + keyExtractor = { components -> convertKeyFromInternal(components) }, + binding = { decoded, key -> decoded?.let { binding(it, key.first, key.second, key.third) } } + ).filterNotNull() + } + + context(StorageQueryContext) + override suspend fun keys(): Set> { + return storageEntry.keys().mapToSet(::convertKeyFromInternal) + } + + private fun List>.toInternal(): List> { + return map(::convertKeyToInternal) + } + + private fun convertKey1ToInternal(key1: I1): Any? { + return key1ToInternalConverter?.invoke(key1) ?: key1 + } + + private fun convertKey2ToInternal(key2: I2): Any? { + return key2ToInternalConverter?.invoke(key2) ?: key2 + } + + private fun convertKey3ToInternal(key3: I3): Any? { + return key3ToInternalConverter?.invoke(key3) ?: key3 + } + + private fun convertKeyToInternal(key: Triple): List { + return listOf( + convertKey1ToInternal(key.first), + convertKey2ToInternal(key.second), + convertKey3ToInternal(key.third) + ) + } + + private fun convertKeyFromInternal(componentHolder: ComponentHolder): Triple { + val first = key1FromInternalConverter?.invoke(componentHolder.component1()) ?: componentHolder.component1() as I1 + val second = key2FromInternalConverter?.invoke(componentHolder.component2()) ?: componentHolder.component2() as I2 + val third = key3FromInternalConverter?.invoke(componentHolder.component3()) ?: componentHolder.component3() as I3 + + return Triple(first, second, third) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/converters/AccountIdKey.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/converters/AccountIdKey.kt new file mode 100644 index 0000000..fd55f1d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/source/query/api/converters/AccountIdKey.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.runtime.storage.source.query.api.converters + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageKeyFromInternalBinder +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageKeyToInternalBinder + +val AccountIdKey.Companion.scaleEncoder: QueryableStorageKeyToInternalBinder + get() = AccountIdKey::value + +val AccountIdKey.Companion.scaleDecoder: QueryableStorageKeyFromInternalBinder + get() = ::bindAccountIdKey diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/storage/typed/SystemRuntimeApi.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/typed/SystemRuntimeApi.kt new file mode 100644 index 0000000..29cf626 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/storage/typed/SystemRuntimeApi.kt @@ -0,0 +1,42 @@ +package io.novafoundation.nova.runtime.storage.typed + +import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo +import io.novafoundation.nova.common.data.network.runtime.binding.EventRecord +import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountInfo +import io.novafoundation.nova.common.data.network.runtime.binding.bindBlockNumber +import io.novafoundation.nova.common.data.network.runtime.binding.bindEventRecords +import io.novafoundation.nova.common.utils.system +import io.novafoundation.nova.runtime.network.binding.PerDispatchClassWeight +import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry0 +import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1 +import io.novafoundation.nova.runtime.storage.source.query.api.storage0 +import io.novafoundation.nova.runtime.storage.source.query.api.storage1 +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import java.math.BigInteger + +@JvmInline +value class SystemRuntimeApi(override val module: Module) : QueryableModule + +context(StorageQueryContext) +val RuntimeMetadata.system: SystemRuntimeApi + get() = SystemRuntimeApi(system()) + +context(StorageQueryContext) +val SystemRuntimeApi.number: QueryableStorageEntry0 + get() = storage0("Number", binding = ::bindBlockNumber) + +context(StorageQueryContext) +val SystemRuntimeApi.account: QueryableStorageEntry1 + get() = storage1("Account", binding = { decoded, _ -> bindAccountInfo(decoded) }) + +context(StorageQueryContext) +val SystemRuntimeApi.events: QueryableStorageEntry0> + get() = storage0("Events", binding = ::bindEventRecords) + +context(StorageQueryContext) +val SystemRuntimeApi.blockWeight: QueryableStorageEntry0 + get() = storage0("BlockWeight", binding = PerDispatchClassWeight::bind) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/util/AccountLookup.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/util/AccountLookup.kt new file mode 100644 index 0000000..4469ea3 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/util/AccountLookup.kt @@ -0,0 +1,59 @@ +package io.novafoundation.nova.runtime.util + +import android.util.Log +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.RuntimeType +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.MULTI_ADDRESS_ID +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.FixedByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.skipAliases + +private const val TAG = "AccountLookup" + +fun RuntimeType<*, *>.constructAccountLookupInstance(accountId: AccountId): Any { + val resolvedType = skipAliases() + Log.d(TAG, "Type name: ${this.name}, resolved type: ${resolvedType?.javaClass?.simpleName}") + + return when (resolvedType) { + is DictEnum -> { + // MultiAddress type - wrap in the appropriate variant + // Standard chains use "Id", but Pezkuwi uses numeric variants like "0" + val variantNames = resolvedType.elements.values.map { it.name } + Log.d(TAG, "DictEnum variants: $variantNames") + + // Use "Id" if available (standard chains), otherwise use the first variant (index 0) + // which is always the AccountId variant in MultiAddress + val idVariantName = if (variantNames.contains(MULTI_ADDRESS_ID)) { + MULTI_ADDRESS_ID + } else { + // For chains like Pezkuwi that use numeric variant names + resolvedType.elements[0]?.name ?: MULTI_ADDRESS_ID + } + Log.d(TAG, "Using variant name: $idVariantName") + DictEnum.Entry(idVariantName, accountId) + } + is FixedByteArray -> { + // GenericAccountId or similar - return raw accountId + Log.d(TAG, "FixedByteArray type, returning raw accountId") + accountId + } + null -> { + // For Pezkuwi chains where alias might not resolve properly + // Check if the original type name suggests MultiAddress + Log.d(TAG, "Resolved type is null, checking original type name: ${this.name}") + if (this.name?.contains("MultiAddress") == true || this.name?.contains("multiaddress") == true) { + // For unresolved MultiAddress types, use "0" which is the standard first variant (AccountId) + Log.d(TAG, "Type name contains MultiAddress, using DictEnum.Entry with variant 0") + DictEnum.Entry("0", accountId) + } else { + Log.d(TAG, "Unknown type with null resolution, returning raw accountId") + accountId + } + } + else -> { + // Unknown type - for Pezkuwi compatibility, try raw accountId instead of throwing + Log.w(TAG, "Unknown address type: ${this.name} (${resolvedType.javaClass.simpleName}), trying raw accountId") + accountId + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/util/BlockDurationEstimator.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/util/BlockDurationEstimator.kt new file mode 100644 index 0000000..7c26386 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/util/BlockDurationEstimator.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.runtime.util + +import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber +import io.novafoundation.nova.common.utils.formatting.TimerValue +import io.novafoundation.nova.common.utils.formatting.toTimerValue +import java.math.BigInteger +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +interface BlockDurationEstimator { + + val currentBlock: BlockNumber + + fun durationUntil(block: BlockNumber): Duration + + fun durationOf(blocks: BlockNumber): Duration + + fun timestampOf(block: BlockNumber): Long + + fun blockInFuture(duration: Duration): BlockNumber + + fun durationToBlocks(duration: Duration): BlockNumber +} + +fun BlockDurationEstimator.blockInPast(duration: Duration): BlockNumber { + return blockInFuture(-duration) +} + +fun BlockDurationEstimator.timerUntil(block: BlockNumber): TimerValue { + return durationUntil(block).toTimerValue() +} + +fun BlockDurationEstimator.isBlockedPassed(block: BlockNumber): Boolean { + return currentBlock >= block +} + +fun BlockDurationEstimator(currentBlock: BlockNumber, blockTimeMillis: BigInteger): BlockDurationEstimator { + return RealBlockDurationEstimator(currentBlock, blockTimeMillis) +} + +internal class RealBlockDurationEstimator( + override val currentBlock: BlockNumber, + private val blockTimeMillis: BigInteger +) : BlockDurationEstimator { + + private val createdAt = System.currentTimeMillis() + + override fun durationUntil(block: BlockNumber): Duration { + val blocksInFuture = block - currentBlock + return (durationOf(blocksInFuture) - timePassedSinceCreated()).coerceAtLeast(Duration.ZERO) + } + + override fun durationOf(blocks: BlockNumber): Duration { + if (blocks < BigInteger.ZERO) return Duration.ZERO + + val millisInFuture = blocks * blockTimeMillis + + return millisInFuture.toLong().milliseconds + } + + override fun timestampOf(block: BlockNumber): Long { + val offsetInBlocks = block - currentBlock + val offsetInMillis = offsetInBlocks * blockTimeMillis + + return createdAt + offsetInMillis.toLong() + } + + override fun blockInFuture(duration: Duration): BlockNumber { + val totalInTheFuture = duration + timePassedSinceCreated() + val offsetInBlocks = durationToBlocks(totalInTheFuture) + + return currentBlock + offsetInBlocks + } + + override fun durationToBlocks(duration: Duration): BlockNumber { + return duration.inWholeMilliseconds.toBigInteger() / blockTimeMillis + } + + private fun timePassedSinceCreated(): Duration { + return (System.currentTimeMillis() - createdAt).milliseconds + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/util/ChainAddressFormatExtension.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/util/ChainAddressFormatExtension.kt new file mode 100644 index 0000000..df8b7cd --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/util/ChainAddressFormatExtension.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.runtime.util + +import io.novafoundation.nova.common.address.format.AddressFormat +import io.novafoundation.nova.common.address.format.AddressScheme +import io.novafoundation.nova.common.address.format.EthereumAddressFormat +import io.novafoundation.nova.common.address.format.SubstrateAddressFormat +import io.novafoundation.nova.runtime.ext.addressScheme +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +fun AddressFormat.Companion.forChain(chain: Chain): AddressFormat { + return when (chain.addressScheme) { + AddressScheme.EVM -> EthereumAddressFormat() + AddressScheme.SUBSTRATE -> SubstrateAddressFormat.forSS58rPrefix(chain.addressPrefix.toShort()) + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/util/ChainAssetParcel.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/util/ChainAssetParcel.kt new file mode 100644 index 0000000..a3f7f2f --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/util/ChainAssetParcel.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.runtime.util + +import android.os.Build +import android.os.Parcel +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ChainAssetParcel(val value: Chain.Asset) : Parcelable { + + constructor(parcel: Parcel) : this(readAsset(parcel)) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeSerializable(value) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ChainAssetParcel { + return ChainAssetParcel(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + + private fun readAsset(parcel: Parcel): Chain.Asset { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + parcel.readSerializable(null, Chain.Asset::class.java)!! + } else { + parcel.readSerializable() as Chain.Asset + } + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/util/ChainParcel.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/util/ChainParcel.kt new file mode 100644 index 0000000..98185d8 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/util/ChainParcel.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.runtime.util + +import android.os.Build +import android.os.Parcel +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain + +class ChainParcel(val chain: Chain) : Parcelable { + + constructor(parcel: Parcel) : this(readChain(parcel)) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeSerializable(chain) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): ChainParcel { + return ChainParcel(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + + private fun readChain(parcel: Parcel): Chain { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + parcel.readSerializable(null, Chain::class.java)!! + } else { + parcel.readSerializable() as Chain + } + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/util/FullAssetIdModel.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/util/FullAssetIdModel.kt new file mode 100644 index 0000000..a237d0d --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/util/FullAssetIdModel.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.runtime.util + +import android.os.Parcelable +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId +import kotlinx.parcelize.Parcelize + +@Parcelize +class FullAssetIdModel(val chainId: ChainId, val assetId: Int) : Parcelable + +fun FullChainAssetId.toModel(): FullAssetIdModel { + return FullAssetIdModel( + chainId = chainId, + assetId = assetId + ) +} + +fun FullAssetIdModel.toDomain(): FullChainAssetId { + return FullChainAssetId( + chainId = chainId, + assetId = assetId + ) +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/util/RuntimeSnapshotExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/util/RuntimeSnapshotExt.kt new file mode 100644 index 0000000..5b1250a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/util/RuntimeSnapshotExt.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.runtime.util + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.FixedByteArray +import io.novasama.substrate_sdk_android.runtime.definitions.types.skipAliases + +fun RuntimeSnapshot.isEthereumAddress(): Boolean { + // Try different address type names used by different chains + val addressType = typeRegistry["Address"] + ?: typeRegistry["MultiAddress"] + ?: typeRegistry["sp_runtime::multiaddress::MultiAddress"] + ?: typeRegistry["pezsp_runtime::multiaddress::MultiAddress"] + ?: return false // If no address type found, assume not Ethereum + + val resolvedType = addressType.skipAliases() ?: return false + + return resolvedType is FixedByteArray && resolvedType.length == 20 +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/util/SocketServiceExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/util/SocketServiceExt.kt new file mode 100644 index 0000000..a7dabc7 --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/util/SocketServiceExt.kt @@ -0,0 +1,35 @@ +package io.novafoundation.nova.runtime.util + +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.dynamic.DynamicTypeResolver +import io.novasama.substrate_sdk_android.runtime.definitions.dynamic.extentsions.GenericsExtension +import io.novasama.substrate_sdk_android.runtime.definitions.registry.TypeRegistry +import io.novasama.substrate_sdk_android.runtime.definitions.registry.v14Preset +import io.novasama.substrate_sdk_android.runtime.definitions.v14.TypesParserV14 +import io.novasama.substrate_sdk_android.runtime.metadata.GetMetadataRequest +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadataReader +import io.novasama.substrate_sdk_android.runtime.metadata.builder.VersionedRuntimeBuilder +import io.novasama.substrate_sdk_android.runtime.metadata.v14.RuntimeMetadataSchemaV14 +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.executeAsync +import io.novasama.substrate_sdk_android.wsrpc.mappers.nonNull +import io.novasama.substrate_sdk_android.wsrpc.mappers.pojo + +suspend fun SocketService.fetchRuntimeSnapshot(): RuntimeSnapshot { + val metadataHex = stateGetMetadata() + val metadataReader = RuntimeMetadataReader.read(metadataHex) + + val types = TypesParserV14.parse( + lookup = metadataReader.metadata[RuntimeMetadataSchemaV14.lookup], + typePreset = v14Preset(), + ) + + val typeRegistry = TypeRegistry(types, DynamicTypeResolver(DynamicTypeResolver.DEFAULT_COMPOUND_EXTENSIONS + GenericsExtension)) + val runtimeMetadata = VersionedRuntimeBuilder.buildMetadata(metadataReader, typeRegistry) + + return RuntimeSnapshot(typeRegistry, runtimeMetadata) +} + +suspend fun SocketService.stateGetMetadata(): String { + return executeAsync(GetMetadataRequest, mapper = pojo().nonNull()) +} diff --git a/runtime/src/test/java/android/util/Log.java b/runtime/src/test/java/android/util/Log.java new file mode 100644 index 0000000..dd394e3 --- /dev/null +++ b/runtime/src/test/java/android/util/Log.java @@ -0,0 +1,26 @@ +package android.util; + +// Replace not mockable Log.xxx functions +public class Log { + public static int d(String tag, String msg) { + System.out.println("DEBUG: " + tag + ": " + msg); + return 0; + } + + public static int i(String tag, String msg) { + System.out.println("INFO: " + tag + ": " + msg); + return 0; + } + + public static int w(String tag, String msg) { + System.out.println("WARN: " + tag + ": " + msg); + return 0; + } + + public static int e(String tag, String msg) { + System.out.println("ERROR: " + tag + ": " + msg); + return 0; + } + + // add other methods if required... +} \ No newline at end of file diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/BatchAllWalkTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/BatchAllWalkTest.kt new file mode 100644 index 0000000..fe5ce88 --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/BatchAllWalkTest.kt @@ -0,0 +1,234 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.impl + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.RealExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch.BatchAllNode +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProvider +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +internal class BatchAllWalkTest { + + @Mock + private lateinit var runtimeProvider: RuntimeProvider + + @Mock + private lateinit var chainRegistry: ChainRegistry + + @Mock + private lateinit var runtime: RuntimeSnapshot + + @Mock + private lateinit var metadata: RuntimeMetadata + + @Mock + private lateinit var utilityModule: Module + + private lateinit var extrinsicWalk: ExtrinsicWalk + + private val itemCompletedType = Event("ItemCompleted", index = 0 to 0, documentation = emptyList(), arguments = emptyList()) + private val batchCompletedType = Event("BatchCompleted", index = 0 to 1, documentation = emptyList(), arguments = emptyList()) + private val itemFailedType = Event("ItemFailed", index = 0 to 2, documentation = emptyList(), arguments = emptyList()) + + private val signer = byteArrayOf(0x00) + + private val testModuleMocker = TestModuleMocker() + private val testEvent = testModuleMocker.testEvent + private val testInnerCall = testModuleMocker.testInnerCall + + @Before + fun setup() = runBlocking { + whenever(utilityModule.events).thenReturn( + mapOf( + batchCompletedType.name to batchCompletedType, + itemCompletedType.name to itemCompletedType, + itemFailedType.name to itemFailedType + ) + ) + whenever(metadata.modules).thenReturn(mapOf("Utility" to utilityModule)) + whenever(runtime.metadata).thenReturn(metadata) + whenever(runtimeProvider.get()).thenReturn(runtime) + whenever(chainRegistry.getRuntimeProvider(any())).thenReturn(runtimeProvider) + + extrinsicWalk = RealExtrinsicWalk(chainRegistry, knownNodes = listOf(BatchAllNode())) + } + + @Test + fun shouldVisitSucceededSingleBatchedCall() = runBlocking { + val innerBatchEvents = listOf(testEvent) + val events = innerBatchEvents + listOf(itemCompleted(), batchCompleted(), extrinsicSuccess()) + + val extrinsic = createExtrinsic( + call = batchCall(testInnerCall), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(true, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerBatchEvents, visit.events) + } + + @Test + fun shouldVisitFailedSingleBatchedCall() = runBlocking { + val events = listOf(extrinsicFailed()) + + val extrinsic = createExtrinsic( + call = batchCall(testInnerCall), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(false, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(true, visit.events.isEmpty()) + } + + @Test + fun shouldVisitSucceededMultipleBatchedCalls() = runBlocking { + val innerBatchEvents = listOf(testEvent) + val events = buildList { + addAll(innerBatchEvents) + add(itemCompleted()) + + addAll(innerBatchEvents) + add(itemCompleted()) + + add(batchCompleted()) + add(extrinsicSuccess()) + } + + val extrinsic = createExtrinsic( + call = batchCall(testInnerCall, testInnerCall), + events = events + ) + + val visits = extrinsicWalk.walkMultipleIgnoringBranches(extrinsic, expectedSize = 2) + visits.forEach { visit -> + assertEquals(true, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerBatchEvents, visit.events) + } + } + + @Test + fun shouldVisitFailedMultipleBatchedCalls() = runBlocking { + val events = listOf(extrinsicFailed()) + + val extrinsic = createExtrinsic( + call = batchCall(testInnerCall, testInnerCall), + events = events + ) + + val visits = extrinsicWalk.walkMultipleIgnoringBranches(extrinsic, expectedSize = 2) + + visits.forEach { visit -> + assertEquals(false, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(true, visit.events.isEmpty()) + } + } + + @Test + fun shouldVisitNestedBatches() = runBlocking { + val innerBatchEvents = listOf(testEvent) + val events = buildList { + // first level batch starts + addAll(innerBatchEvents) + add(itemCompleted()) + + run { + addAll(innerBatchEvents) + add(itemCompleted()) + + run { + addAll(innerBatchEvents) + add(itemCompleted()) + + addAll(innerBatchEvents) + add(itemCompleted()) + + add(batchCompleted()) + } + add(itemCompleted()) + + add(batchCompleted()) + } + add(itemCompleted()) + + addAll(innerBatchEvents) + add(itemCompleted()) + + // first level batch ends + add(batchCompleted()) + add(extrinsicSuccess()) + } + + val extrinsic = createExtrinsic( + call = batchCall( + testInnerCall, + batchCall( + testInnerCall, + batchCall( + testInnerCall, + testInnerCall + ) + ), + testInnerCall + ), + events = events + ) + + val visits = extrinsicWalk.walkMultipleIgnoringBranches(extrinsic, expectedSize = 5) + visits.forEach { visit -> + assertEquals(true, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerBatchEvents, visit.events) + } + } + + private fun createExtrinsic( + call: GenericCall.Instance, + events: List + ) = createExtrinsic(signer, call, events) + + private fun itemCompleted(): GenericEvent.Instance { + return GenericEvent.Instance(utilityModule, itemCompletedType, arguments = emptyList()) + } + + private fun batchCompleted(): GenericEvent.Instance { + return GenericEvent.Instance(utilityModule, batchCompletedType, arguments = emptyList()) + } + + private fun batchCall(vararg innerCalls: GenericCall.Instance): GenericCall.Instance { + return mockCall( + moduleName = Modules.UTILITY, + callName = "batch_all", + arguments = mapOf( + "calls" to innerCalls.toList() + ) + ) + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/Common.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/Common.kt new file mode 100644 index 0000000..6808e9f --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/Common.kt @@ -0,0 +1,146 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.impl + +import io.novafoundation.nova.common.data.network.runtime.binding.MultiAddress +import io.novafoundation.nova.common.data.network.runtime.binding.bindMultiAddress +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.walkToList +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Extrinsic +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.metadata.call +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event +import io.novasama.substrate_sdk_android.runtime.metadata.module.MetadataFunction +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import org.junit.Assert +import org.mockito.Mockito +import java.math.BigInteger + +fun createTestModuleWithCall( + moduleName: String, + callName: String +): Module { + return Module( + name = moduleName, + storage = null, + calls = mapOf( + callName to MetadataFunction( + name = callName, + arguments = emptyList(), + documentation = emptyList(), + index = 0 to 0 + ) + ), + events = emptyMap(), + constants = emptyMap(), + errors = emptyMap(), + index = BigInteger.ZERO + ) +} + +fun createExtrinsic( + signer: AccountId, + call: GenericCall.Instance, + events: List +) = ExtrinsicWithEvents( + extrinsic = Extrinsic.Instance( + type = Extrinsic.ExtrinsicType.Signed( + accountIdentifier = bindMultiAddress(MultiAddress.Id(signer)), + signature = null, + signedExtras = emptyMap() + ), + call = call, + ), + extrinsicHash = "0x", + events = events +) + +fun extrinsicSuccess(): GenericEvent.Instance { + return mockEvent("System", "ExtrinsicSuccess") +} + +fun extrinsicFailed(): GenericEvent.Instance { + return mockEvent("System", "ExtrinsicFailed") +} + +fun mockEvent(moduleName: String, eventName: String, arguments: List = emptyList()): GenericEvent.Instance { + val module = Mockito.mock(Module::class.java) + whenever(module.name).thenReturn(moduleName) + + val event = Mockito.mock(Event::class.java) + whenever(event.name).thenReturn(eventName) + + return GenericEvent.Instance( + module = module, + event = event, + arguments = arguments + ) +} + +fun mockCall(moduleName: String, callName: String, arguments: Map = emptyMap()): GenericCall.Instance { + val module = Mockito.mock(Module::class.java) + whenever(module.name).thenReturn(moduleName) + + val function = Mockito.mock(MetadataFunction::class.java) + whenever(function.name).thenReturn(callName) + + return GenericCall.Instance( + module = module, + function = function, + arguments = arguments + ) +} + +class TestModuleMocker { + + val testModule = createTestModuleWithCall(moduleName = "Test", callName = "test") + + val testInnerCall = GenericCall.Instance( + module = testModule, + function = testModule.call("test"), + arguments = emptyMap() + ) + + val testEvent = mockEvent(testModule.name, "test") + + operator fun component1(): GenericCall.Instance { + return testInnerCall + } + + operator fun component2():GenericEvent.Instance { + return testEvent + } +} + +suspend fun ExtrinsicWalk.walkSingleIgnoringBranches(extrinsicWithEvents: ExtrinsicWithEvents): ExtrinsicVisit { + val visits = walkToList(extrinsicWithEvents, Chain.Geneses.POLKADOT).ignoreBranches() + Assert.assertEquals(1, visits.size) + + return visits.single() +} + +suspend fun ExtrinsicWalk.walkToList(extrinsicWithEvents: ExtrinsicWithEvents): List { + return walkToList(extrinsicWithEvents, Chain.Geneses.POLKADOT) +} + +suspend fun ExtrinsicWalk.walkEmpty(extrinsicWithEvents: ExtrinsicWithEvents) { + val visits = walkToList(extrinsicWithEvents, Chain.Geneses.POLKADOT).ignoreBranches() + Assert.assertTrue(visits.isEmpty()) +} + + +suspend fun ExtrinsicWalk.walkMultipleIgnoringBranches(extrinsicWithEvents: ExtrinsicWithEvents, expectedSize: Int): List { + val visits = walkToList(extrinsicWithEvents, Chain.Geneses.POLKADOT).ignoreBranches() + Assert.assertEquals(expectedSize, visits.size) + + return visits +} + +private fun List.ignoreBranches(): List { + return filterNot { it.hasRegisteredNode } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/ForceBatchWalkTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/ForceBatchWalkTest.kt new file mode 100644 index 0000000..5b8cea9 --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/ForceBatchWalkTest.kt @@ -0,0 +1,264 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.impl + +import io.novafoundation.nova.common.utils.Modules +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.RealExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch.ForceBatchNode +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProvider +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +internal class ForceBatchWalkTest { + + @Mock + private lateinit var runtimeProvider: RuntimeProvider + + @Mock + private lateinit var chainRegistry: ChainRegistry + + @Mock + private lateinit var runtime: RuntimeSnapshot + + @Mock + private lateinit var metadata: RuntimeMetadata + + @Mock + private lateinit var utilityModule: Module + + private lateinit var extrinsicWalk: ExtrinsicWalk + + private val itemCompletedType = Event("ItemCompleted", index = 0 to 0, documentation = emptyList(), arguments = emptyList()) + private val batchCompletedType = Event("BatchCompleted", index = 0 to 1, documentation = emptyList(), arguments = emptyList()) + private val itemFailedType = Event("ItemFailed", index = 0 to 2, documentation = emptyList(), arguments = emptyList()) + private val batchCompletedWithErrorsType = Event("BatchCompletedWithErrors", index = 0 to 3, documentation = emptyList(), arguments = emptyList()) + + private val signer = byteArrayOf(0x00) + + private val testModuleMocker = TestModuleMocker() + private val testEvent = testModuleMocker.testEvent + private val testInnerCall = testModuleMocker.testInnerCall + + @Before + fun setup() = runBlocking { + whenever(utilityModule.events).thenReturn( + mapOf( + batchCompletedType.name to batchCompletedType, + itemCompletedType.name to itemCompletedType, + itemFailedType.name to itemFailedType, + batchCompletedWithErrorsType.name to batchCompletedWithErrorsType + ) + ) + whenever(metadata.modules).thenReturn(mapOf("Utility" to utilityModule)) + whenever(runtime.metadata).thenReturn(metadata) + whenever(runtimeProvider.get()).thenReturn(runtime) + whenever(chainRegistry.getRuntimeProvider(any())).thenReturn(runtimeProvider) + + extrinsicWalk = RealExtrinsicWalk(chainRegistry, knownNodes = listOf(ForceBatchNode())) + } + + @Test + fun shouldVisitSucceededSingleBatchedCall() = runBlocking { + val innerBatchEvents = listOf(testEvent) + val events = innerBatchEvents + listOf(itemCompleted(), batchCompleted(), extrinsicSuccess()) + + val extrinsic = createExtrinsic( + call = forceBatchCall(testInnerCall), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(true, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerBatchEvents, visit.events) + } + + @Test + fun shouldVisitFailedSingleBatchedCall() = runBlocking { + val innerBatchEvents = listOf(testEvent) + val events = innerBatchEvents + listOf(itemFailed(), batchCompletedWithErrors(), extrinsicSuccess()) + + val extrinsic = createExtrinsic( + call = forceBatchCall(testInnerCall), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(false, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(true, visit.events.isEmpty()) + } + + @Test + fun shouldVisitSucceededMultipleBatchedCalls() = runBlocking { + val innerBatchEvents = listOf(testEvent) + val events = buildList { + addAll(innerBatchEvents) + add(itemCompleted()) + + addAll(innerBatchEvents) + add(itemCompleted()) + + add(batchCompleted()) + add(extrinsicSuccess()) + } + + val extrinsic = createExtrinsic( + call = forceBatchCall(testInnerCall, testInnerCall), + events = events + ) + + val visits = extrinsicWalk.walkMultipleIgnoringBranches(extrinsic, expectedSize = 2) + visits.forEach { visit -> + assertEquals(true, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerBatchEvents, visit.events) + } + } + + @Test + fun shouldVisitMixedMultipleBatchedCalls() = runBlocking { + val innerBatchEvents = listOf(testEvent) + val events = buildList { + addAll(innerBatchEvents) + add(itemCompleted()) + + add(itemFailed()) + + addAll(innerBatchEvents) + add(itemCompleted()) + + add(batchCompletedWithErrors()) + add(extrinsicSuccess()) + } + + val extrinsic = createExtrinsic( + call = forceBatchCall(testInnerCall, testInnerCall, testInnerCall), + events = events + ) + + val visits = extrinsicWalk.walkMultipleIgnoringBranches(extrinsic, expectedSize = 3) + + val expected: List>> = listOf( + true to innerBatchEvents, + false to emptyList(), + true to innerBatchEvents + ) + + visits.zip(expected).forEach { (visit, expected) -> + val (expectedSuccess, expectedEvents) = expected + + assertEquals(expectedSuccess, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(expectedEvents, visit.events) + } + } + + @Test + fun shouldVisitNestedBatches() = runBlocking { + val innerBatchEvents = listOf(testEvent) + val events = buildList { + addAll(innerBatchEvents) + add(itemCompleted()) + + run { + addAll(innerBatchEvents) + add(itemCompleted()) + + run { + addAll(innerBatchEvents) + add(itemCompleted()) + + addAll(innerBatchEvents) + add(itemCompleted()) + + add(batchCompleted()) + } + add(itemCompleted()) + + add(batchCompleted()) + } + add(itemCompleted()) + + addAll(innerBatchEvents) + add(itemCompleted()) + + // first level batch ends + add(batchCompleted()) + add(extrinsicSuccess()) + } + + val extrinsic = createExtrinsic( + call = forceBatchCall( + testInnerCall, + forceBatchCall( + testInnerCall, + forceBatchCall( + testInnerCall, + testInnerCall + ) + ), + testInnerCall + ), + events = events + ) + + val visits = extrinsicWalk.walkMultipleIgnoringBranches(extrinsic, expectedSize = 5) + visits.forEach { visit -> + assertEquals(true, visit.success) + assertArrayEquals(signer, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerBatchEvents, visit.events) + } + } + + private fun createExtrinsic( + call: GenericCall.Instance, + events: List + ) = createExtrinsic(signer, call, events) + + private fun itemCompleted(): GenericEvent.Instance { + return GenericEvent.Instance(utilityModule, itemCompletedType, arguments = emptyList()) + } + + private fun itemFailed(): GenericEvent.Instance { + return GenericEvent.Instance(utilityModule, itemFailedType, arguments = emptyList()) + } + + private fun batchCompleted(): GenericEvent.Instance { + return GenericEvent.Instance(utilityModule, batchCompletedType, arguments = emptyList()) + } + + private fun batchCompletedWithErrors(): GenericEvent.Instance { + return GenericEvent.Instance(utilityModule, batchCompletedWithErrorsType, arguments = emptyList()) + } + + private fun forceBatchCall(vararg innerCalls: GenericCall.Instance): GenericCall.Instance { + return mockCall( + moduleName = Modules.UTILITY, + callName = "force_batch", + arguments = mapOf( + "calls" to innerCalls.toList() + ) + ) + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/MultisigWalkTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/MultisigWalkTest.kt new file mode 100644 index 0000000..f930ef9 --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/MultisigWalkTest.kt @@ -0,0 +1,230 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.impl + +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.RealExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig.MultisigNode +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig.generateMultisigAddress +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProvider +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +internal class MultisigWalkTest { + + @Mock + private lateinit var runtimeProvider: RuntimeProvider + + @Mock + private lateinit var chainRegistry: ChainRegistry + + @Mock + private lateinit var runtime: RuntimeSnapshot + + @Mock + private lateinit var metadata: RuntimeMetadata + + @Mock + private lateinit var multisigModule: Module + + private lateinit var extrinsicWalk: ExtrinsicWalk + + private val multisigExecutedEvent = Event("MultisigExecuted", index = 0 to 0, documentation = emptyList(), arguments = emptyList()) + private val multisigApprovalEvent = Event("MultisigApproval", index = 0 to 1, documentation = emptyList(), arguments = emptyList()) + private val multisigNewMultisigEvent = Event("NewMultisig", index = 0 to 1, documentation = emptyList(), arguments = emptyList()) + + private val signatory = byteArrayOf(0x00) + private val otherSignatories = listOf(byteArrayOf(0x01)) + private val threshold = 2 + private val multisig = generateMultisigAddress( + signatory = signatory.intoKey(), + otherSignatories = otherSignatories.map { it.intoKey() }, + threshold = threshold + ).value + + private val testModuleMocker = TestModuleMocker() + private val testEvent = testModuleMocker.testEvent + private val testInnerCall = testModuleMocker.testInnerCall + + @Before + fun setup() = runBlocking { + // Explicitly using string literals instead of accessing name property as this would result in Unfinished stubbing exception + whenever(multisigModule.events).thenReturn( + mapOf( + "MultisigExecuted" to multisigExecutedEvent, + "MultisigApproval" to multisigApprovalEvent, + "NewMultisig" to multisigNewMultisigEvent + ) + ) + whenever(multisigModule.name).thenReturn("Multisig") + whenever(metadata.modules).thenReturn(mapOf("Multisig" to multisigModule)) + whenever(runtime.metadata).thenReturn(metadata) + whenever(runtimeProvider.get()).thenReturn(runtime) + whenever(chainRegistry.getRuntimeProvider(any())).thenReturn(runtimeProvider) + + extrinsicWalk = RealExtrinsicWalk(chainRegistry, knownNodes = listOf(MultisigNode())) + } + + @Test + fun shouldVisitSucceededSingleMultisigCall() = runBlocking { + val innerMultisigEvents = listOf(testEvent) + val events = innerMultisigEvents + listOf(multisigExecuted(success = true), extrinsicSuccess()) + + val extrinsic = createExtrinsic( + signer = signatory, + call = multisig_call( + innerCall = testInnerCall, + threshold = threshold, + otherSignatories = otherSignatories + ), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(true, visit.success) + assertArrayEquals(multisig, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerMultisigEvents, visit.events) + } + + @Test + fun shouldVisitFailedSingleMultisigCall() = runBlocking { + val innerMultisigEvents = emptyList() + val events = listOf(multisigExecuted(success = false), extrinsicSuccess()) + + val extrinsic = createExtrinsic( + signer = signatory, + call = multisig_call( + innerCall = testInnerCall, + threshold = threshold, + otherSignatories = otherSignatories + ), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(false, visit.success) + assertArrayEquals(multisig, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerMultisigEvents, visit.events) + } + + @Test + fun shouldVisitNewMultisigCall() = runBlocking { + val events = listOf(newMultisig(), extrinsicSuccess()) + + val extrinsic = createExtrinsic( + signer = signatory, + call = multisig_call( + innerCall = testInnerCall, + threshold = threshold, + otherSignatories = otherSignatories + ), + events = events + ) + + extrinsicWalk.walkEmpty(extrinsic) + } + + @Test + fun shouldVisitMultisigApprovalCall() = runBlocking { + val events = listOf(multisigApproval(), extrinsicSuccess()) + + val extrinsic = createExtrinsic( + signer = signatory, + call = multisig_call( + innerCall = testInnerCall, + threshold = threshold, + otherSignatories = otherSignatories + ), + events = events + ) + + extrinsicWalk.walkEmpty(extrinsic) + } + + @Test + fun shouldVisitSucceededNestedMultisigCalls() = runBlocking { + val events = listOf( + newMultisig(), + multisigExecuted(success = true), + extrinsicSuccess() + ) + + val otherSignatories2 = otherSignatories + val threshold2 = 1 + + val extrinsic = createExtrinsic( + signer = signatory, + call = multisig_call( + threshold = threshold, + otherSignatories = otherSignatories, + innerCall = multisig_call( + innerCall = testInnerCall, + threshold = threshold2, + otherSignatories = otherSignatories2 + ), + ), + events = events + ) + + val visit = extrinsicWalk.walkToList(extrinsic) + assertEquals(2, visit.size) + + val visit1 = visit[0] + assertEquals(true, visit1.success) + assertArrayEquals(signatory, visit1.origin) + + val visit2 = visit[1] + assertEquals(true, visit2.success) + assertArrayEquals(multisig, visit2.origin) + } + + private fun multisigExecuted(success: Boolean): GenericEvent.Instance { + val outcomeVariant = if (success) "Ok" else "Err" + val outcome = DictEnum.Entry(name = outcomeVariant, value = null) + + return GenericEvent.Instance(multisigModule, multisigExecutedEvent, arguments = listOf(null, null, null, null, outcome)) + } + + private fun newMultisig(): GenericEvent.Instance { + return GenericEvent.Instance(multisigModule, multisigNewMultisigEvent, arguments = emptyList()) + } + + private fun multisigApproval(): GenericEvent.Instance { + return GenericEvent.Instance(multisigModule, multisigApprovalEvent, arguments = emptyList()) + } + + private fun multisig_call( + innerCall: GenericCall.Instance, + threshold: Int, + otherSignatories: List + ): GenericCall.Instance { + return mockCall( + moduleName = "Multisig", + callName = "as_multi", + arguments = mapOf( + "threshold" to threshold.toBigInteger(), + "other_signatories" to otherSignatories, + "call" to innerCall, + // other args are not relevant + ) + ) + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/ProxyWalkTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/ProxyWalkTest.kt new file mode 100644 index 0000000..42ebc55 --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/ProxyWalkTest.kt @@ -0,0 +1,227 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.impl + +import io.novafoundation.nova.common.data.network.runtime.binding.MultiAddress +import io.novafoundation.nova.common.data.network.runtime.binding.bindMultiAddress +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.RealExtrinsicWalk +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.proxy.ProxyNode +import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry +import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProvider +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent +import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata +import io.novasama.substrate_sdk_android.runtime.metadata.module.Event +import io.novasama.substrate_sdk_android.runtime.metadata.module.Module +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +internal class ProxyWalkTest { + + @Mock + private lateinit var runtimeProvider: RuntimeProvider + + @Mock + private lateinit var chainRegistry: ChainRegistry + + @Mock + private lateinit var runtime: RuntimeSnapshot + + @Mock + private lateinit var metadata: RuntimeMetadata + + @Mock + private lateinit var proxyModule: Module + + private lateinit var extrinsicWalk: ExtrinsicWalk + + private val proxyExecutedType = Event("ProxyExecuted", index = 0 to 0, documentation = emptyList(), arguments = emptyList()) + + private val proxy = byteArrayOf(0x00) + private val proxied = byteArrayOf(0x01) + + private val testModuleMocker = TestModuleMocker() + private val testEvent = testModuleMocker.testEvent + private val testInnerCall = testModuleMocker.testInnerCall + + @Before + fun setup() = runBlocking { + whenever(proxyModule.events).thenReturn(mapOf("ProxyExecuted" to proxyExecutedType)) + whenever(metadata.modules).thenReturn(mapOf("Proxy" to proxyModule)) + whenever(runtime.metadata).thenReturn(metadata) + whenever(runtimeProvider.get()).thenReturn(runtime) + whenever(chainRegistry.getRuntimeProvider(any())).thenReturn(runtimeProvider) + + extrinsicWalk = RealExtrinsicWalk(chainRegistry, knownNodes = listOf(ProxyNode())) + } + + @Test + fun shouldVisitSucceededSimpleCall() = runBlocking { + val events = listOf(testEvent, extrinsicSuccess()) + + val extrinsic = createExtrinsic( + signer = proxied, + call = testInnerCall, + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(true, visit.success) + assertArrayEquals(proxied, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(events, visit.events) + } + + @Test + fun shouldVisitFailedSimpleCall() = runBlocking { + val events = listOf(extrinsicFailed()) + + val extrinsic = createExtrinsic( + signer = proxied, + call = testInnerCall, + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(false, visit.success) + assertArrayEquals(proxied, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(events, visit.events) + } + + @Test + fun shouldVisitSucceededSingleProxyCall() = runBlocking { + val innerProxyEvents = listOf(testEvent) + val events = innerProxyEvents + listOf(proxyExecuted(success = true), extrinsicSuccess()) + + val extrinsic = createExtrinsic( + signer = proxy, + call = proxyCall( + real = proxied, + innerCall = testInnerCall + ), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(true, visit.success) + assertArrayEquals(proxied, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerProxyEvents, visit.events) + } + + @Test + fun shouldVisitFailedSingleProxyCall() = runBlocking { + val innerProxyEvents = emptyList() + val events = listOf(proxyExecuted(success = false), extrinsicSuccess()) + + val extrinsic = createExtrinsic( + signer = proxy, + call = proxyCall( + real = proxied, + innerCall = testInnerCall + ), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(false, visit.success) + assertArrayEquals(proxied, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerProxyEvents, visit.events) + } + + @Test + fun shouldVisitSucceededMultipleProxyCalls() = runBlocking { + val innerProxyEvents = listOf(testEvent) + val events = innerProxyEvents + listOf(proxyExecuted(success = true), proxyExecuted(success = true), proxyExecuted(success = true), extrinsicSuccess()) + + val proxy1 = byteArrayOf(0x00) + val proxy2 = byteArrayOf(0x01) + val proxy3 = byteArrayOf(0x02) + val proxied = byteArrayOf(0x10) + + val extrinsic = createExtrinsic( + signer = proxy1, + call = proxyCall( + real = proxy2, + innerCall = proxyCall( + real = proxy3, + innerCall = proxyCall( + real = proxied, + innerCall = testInnerCall + ) + ) + ), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(true, visit.success) + assertArrayEquals(proxied, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerProxyEvents, visit.events) + } + + @Test + fun shouldVisitFailedMultipleProxyCalls() = runBlocking { + val innerProxyEvents = emptyList() + val events = listOf(proxyExecuted(success = false), proxyExecuted(success = true), extrinsicSuccess()) // only outer-most proxy succeeded + + val proxy1 = byteArrayOf(0x00) + val proxy2 = byteArrayOf(0x01) + val proxy3 = byteArrayOf(0x02) + val proxied = byteArrayOf(0x10) + + val extrinsic = createExtrinsic( + signer = proxy1, + call = proxyCall( + real = proxy2, + innerCall = proxyCall( + real = proxy3, + innerCall = proxyCall( + real = proxied, + innerCall = testInnerCall + ) + ) + ), + events = events + ) + + val visit = extrinsicWalk.walkSingleIgnoringBranches(extrinsic) + assertEquals(false, visit.success) + assertArrayEquals(proxied, visit.origin) + assertEquals(testInnerCall, visit.call) + assertEquals(innerProxyEvents, visit.events) + } + + private fun proxyExecuted(success: Boolean): GenericEvent.Instance { + val outcomeVariant = if (success) "Ok" else "Err" + val outcome = DictEnum.Entry(name = outcomeVariant, value = null) + + return GenericEvent.Instance(proxyModule, proxyExecutedType, arguments = listOf(outcome)) + } + + private fun proxyCall(real: AccountId, innerCall: GenericCall.Instance): GenericCall.Instance { + return mockCall( + moduleName = "Proxy", + callName = "proxy", + arguments = mapOf( + "real" to bindMultiAddress(MultiAddress.Id(real)), + "call" to innerCall, + // other args are not relevant + ) + ) + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/multisig/CommonsTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/multisig/CommonsTest.kt new file mode 100644 index 0000000..718f574 --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/extrinsic/visitor/impl/multisig/CommonsTest.kt @@ -0,0 +1,60 @@ +package io.novafoundation.nova.runtime.extrinsic.visitor.impl.multisig + +import io.novafoundation.nova.common.address.AccountIdKey +import io.novafoundation.nova.common.address.intoKey +import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig.generateMultisigAddress +import io.novasama.substrate_sdk_android.extensions.asEthereumAddress +import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import org.junit.Assert.assertArrayEquals +import org.junit.Test + + +class CommonsTest { + + @Test + fun shouldGenerateSs58MultisigAddress() { + shouldGenerateMultisigAddress( + signatories = listOf( + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y" + ), + threshold = 2, + expected = "5DjYJStmdZ2rcqXbXGX7TW85JsrW6uG4y9MUcLq2BoPMpRA7", + ) + } + + @Test + fun shouldGenerateEvmMultisigAddress() { + shouldGenerateMultisigAddress( + signatories = listOf( + "0xC60eFE26b9b92380D1b2c479472323eC35F0f0aB", + "0x61d8c5647f4181f2c35996c62a6272967f5739a8", + "0xaCCaCE4056A930745218328BF086369Fbd61c212" + ), + threshold =2, + expected = "0xb4e55b61678623fd5ece9c24e79d6c0532bee057", + ) + } + + private fun shouldGenerateMultisigAddress( + signatories: List, + threshold: Int, + expected: String + ) { + val accountIds = signatories.map { it.addressToAccountId() } + val expectedId = expected.addressToAccountId() + + val actual = generateMultisigAddress(accountIds, threshold) + assertArrayEquals(expectedId.value, actual.value) + } + + private fun String.addressToAccountId(): AccountIdKey { + return if (startsWith("0x")) { + asEthereumAddress().toAccountId().value + } else { + toAccountId() + }.intoKey() + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmErc20AssetSyncServiceTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmErc20AssetSyncServiceTest.kt new file mode 100644 index 0000000..c665755 --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmErc20AssetSyncServiceTest.kt @@ -0,0 +1,192 @@ +package io.novafoundation.nova.runtime.multiNetwork.asset + +import com.google.gson.Gson +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.runtime.multiNetwork.asset.remote.AssetFetcher +import io.novafoundation.nova.runtime.multiNetwork.asset.remote.model.EVMAssetRemote +import io.novafoundation.nova.runtime.multiNetwork.asset.remote.model.EVMInstanceRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.chainAssetIdOfErc20Token +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapEVMAssetRemoteToLocalAssets +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.test_shared.emptyDiff +import io.novafoundation.nova.test_shared.insertsElement +import io.novafoundation.nova.test_shared.removesElement +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.lenient +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class EvmErc20AssetSyncServiceTest { + + private val chainId = "chainId" + private val contractAddress = "0xc748673057861a797275CD8A068AbB95A902e8de" + private val assetId = chainAssetIdOfErc20Token(contractAddress) + private val buyProviders = mapOf("transak" to mapOf("network" to "ETHEREUM")) + private val sellProviders = mapOf("transak" to mapOf("network" to "ETHEREUM")) + + private val REMOTE_ASSET = EVMAssetRemote( + symbol = "USDT", + precision = 6, + priceId = "usd", + name = "USDT", + icon = "https://url.com", + instances = listOf( + EVMInstanceRemote(chainId, contractAddress, buyProviders, sellProviders) + ) + ) + + private val gson = Gson() + + private val LOCAL_ASSETS = createLocalCopy(REMOTE_ASSET) + + @Mock + lateinit var dao: ChainAssetDao + + @Mock + lateinit var chaindao: ChainDao + + @Mock + lateinit var assetFetcher: AssetFetcher + + lateinit var evmAssetSyncService: EvmAssetsSyncService + + @Before + fun setup() { + evmAssetSyncService = EvmAssetsSyncService(chaindao, dao, assetFetcher, gson) + } + + @Test + fun `should insert new asset`() { + runBlocking { + localHasChains(chainId) + localReturnsERC20(emptyList()) + remoteReturns(listOf(REMOTE_ASSET)) + + evmAssetSyncService.syncUp() + + verify(dao).updateAssets( + insertAsset(chainId, assetId), + ) + } + } + + @Test + fun `should not insert the same asset`() { + runBlocking { + localHasChains(chainId) + localReturnsERC20(LOCAL_ASSETS) + remoteReturns(listOf(REMOTE_ASSET)) + + evmAssetSyncService.syncUp() + + verify(dao).updateAssets(emptyDiff()) + } + } + + @Test + fun `should update assets's own params`() { + runBlocking { + localHasChains(chainId) + localReturnsERC20(LOCAL_ASSETS) + remoteReturns(listOf(REMOTE_ASSET.copy(name = "new name"))) + + evmAssetSyncService.syncUp() + + verify(dao).updateAssets( + insertAsset(chainId, assetId), + ) + } + } + + @Test + fun `should remove asset`() { + runBlocking { + localHasChains(chainId) + localReturnsERC20(LOCAL_ASSETS) + remoteReturns(emptyList()) + + evmAssetSyncService.syncUp() + + verify(dao).updateAssets( + removeAsset(chainId, assetId), + ) + } + } + + @Test + fun `should not overwrite enabled state`() { + runBlocking { + localHasChains(chainId) + localReturnsERC20(LOCAL_ASSETS.map { it.copy(enabled = false) }) + remoteReturns(listOf(REMOTE_ASSET)) + + evmAssetSyncService.syncUp() + + verify(dao).updateAssets( + emptyDiff(), + ) + } + } + + @Test + fun `should not modify manual assets`() { + runBlocking { + localHasChains(chainId) + localReturnsERC20(LOCAL_ASSETS) + localReturnsManual(emptyList()) + remoteReturns(listOf(REMOTE_ASSET)) + + evmAssetSyncService.syncUp() + + verify(dao).updateAssets( + emptyDiff(), + ) + } + } + + @Test + fun `should not insert assets for non-present chain`() { + runBlocking { + localHasChains(chainId) + localReturnsERC20(emptyList()) + remoteReturns(listOf(REMOTE_ASSET.copy(instances = listOf(EVMInstanceRemote("changedChainId", contractAddress, buyProviders, sellProviders))))) + + evmAssetSyncService.syncUp() + + verify(dao).updateAssets(emptyDiff()) + } + } + + private suspend fun remoteReturns(assets: List) { + `when`(assetFetcher.getEVMAssets()).thenReturn(assets) + } + + private suspend fun localReturnsERC20(assets: List) { + `when`(dao.getAssetsBySource(AssetSourceLocal.ERC20)).thenReturn(assets) + } + + private suspend fun localHasChains(vararg chainIds: ChainId) { + `when`(chaindao.getAllChainIds()).thenReturn(chainIds.toList()) + } + + private suspend fun localReturnsManual(assets: List) { + lenient().`when`(dao.getAssetsBySource(AssetSourceLocal.MANUAL)).thenReturn(assets) + } + + private fun insertAsset(chainId: String, id: Int) = insertsElement { it.chainId == chainId && it.id == id } + + private fun removeAsset(chainId: String, id: Int) = removesElement { it.chainId == chainId && it.id == id } + + private fun createLocalCopy(remote: EVMAssetRemote): List { + return mapEVMAssetRemoteToLocalAssets(remote, gson) + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt new file mode 100644 index 0000000..dc880dd --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncServiceTest.kt @@ -0,0 +1,353 @@ +package io.novafoundation.nova.runtime.multiNetwork.chain + +import com.google.gson.Gson +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal +import io.novafoundation.nova.core_db.model.chain.ChainExplorerLocal +import io.novafoundation.nova.core_db.model.chain.ChainExternalApiLocal +import io.novafoundation.nova.core_db.model.chain.ChainLocal +import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal +import io.novafoundation.nova.core_db.model.chain.JoinedChainInfo +import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapExternalApisToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteAssetToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteChainToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteExplorersToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteNodesToLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainAssetRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainExplorerRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainExternalApiRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainNodeRemote +import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemote +import io.novafoundation.nova.test_shared.emptyDiff +import io.novafoundation.nova.test_shared.insertsElement +import io.novafoundation.nova.test_shared.removesElement +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +class ChainSyncServiceTest { + + private val assetId = 0 + private val nodeUrl = "url" + private val explorerName = "explorer" + private val transferApiUrl = "url" + + private val REMOTE_CHAIN = ChainRemote( + chainId = "0x00", + name = "Test", + assets = listOf( + ChainAssetRemote( + assetId = assetId, + symbol = "TEST", + precision = 10, + name = "Test", + priceId = "test", + staking = listOf("test"), + type = null, + typeExtras = null, + icon = null, + buyProviders = emptyMap(), + sellProviders = emptyMap() + ) + ), + nodes = listOf( + ChainNodeRemote( + url = nodeUrl, + name = "test" + ) + ), + icon = "test", + addressPrefix = 0, + legacyAddressPrefix = null, + types = null, + options = emptySet(), + parentId = null, + externalApi = mapOf( + "history" to listOf( + ChainExternalApiRemote( + sourceType = "subquery", + url = transferApiUrl, + parameters = null // substrate history + ) + ) + ), + explorers = listOf( + ChainExplorerRemote( + explorerName, + "extrinsic", + "account", + "event" + ) + ), + additional = emptyMap(), + nodeSelectionStrategy = null + ) + + private val gson = Gson() + + private val LOCAL_CHAIN = createLocalCopy(REMOTE_CHAIN) + + @Mock + lateinit var dao: ChainDao + + @Mock + lateinit var chainAssetDao: ChainAssetDao + + @Mock + lateinit var chainFetcher: ChainFetcher + + lateinit var chainSyncService: ChainSyncService + + @Before + fun setup() { + chainSyncService = ChainSyncService(dao, chainFetcher, gson) + } + + @Test + fun `should insert new chain`() { + runBlocking { + localReturns(emptyList()) + remoteReturns(listOf(REMOTE_CHAIN)) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + chainDiff = insertsChainWithId(REMOTE_CHAIN.chainId), + assetsDiff = insertsAssetWithId(assetId), + nodesDiff = insertsNodeWithUrl(nodeUrl), + explorersDiff = insertsExplorerByName(explorerName), + externalApisDiff = insertsTransferApiByUrl(transferApiUrl), + nodeSelectionPreferencesDiff = insertsNodeSelectionPreferences(REMOTE_CHAIN.chainId) + ) + } + } + + @Test + fun `should not insert the same chain`() { + runBlocking { + localReturns(listOf(LOCAL_CHAIN)) + remoteReturns(listOf(REMOTE_CHAIN)) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + emptyDiff(), + emptyDiff(), + emptyDiff(), + emptyDiff(), + emptyDiff(), + emptyDiff() + ) + } + } + + @Test + fun `should update chain's own params`() { + runBlocking { + localReturns(listOf(LOCAL_CHAIN)) + remoteReturns(listOf(REMOTE_CHAIN.copy(name = "new name"))) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + chainDiff = insertsChainWithId(REMOTE_CHAIN.chainId), + assetsDiff = emptyDiff(), + nodesDiff = emptyDiff(), + explorersDiff = emptyDiff(), + externalApisDiff = emptyDiff(), + nodeSelectionPreferencesDiff = emptyDiff() + ) + } + } + + @Test + fun `should update chain's asset`() { + runBlocking { + localReturns(listOf(LOCAL_CHAIN)) + + remoteReturns( + listOf( + REMOTE_CHAIN.copy( + assets = listOf( + REMOTE_CHAIN.assets.first().copy(symbol = "NEW") + ) + ) + ) + ) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + chainDiff = emptyDiff(), + assetsDiff = insertsAssetWithId(assetId), + nodesDiff = emptyDiff(), + explorersDiff = emptyDiff(), + externalApisDiff = emptyDiff(), + nodeSelectionPreferencesDiff = emptyDiff() + ) + } + } + + @Test + fun `should update chain's node`() { + runBlocking { + localReturns(listOf(LOCAL_CHAIN)) + + remoteReturns( + listOf( + REMOTE_CHAIN.copy( + nodes = listOf( + REMOTE_CHAIN.nodes.first().copy(name = "NEW") + ) + ) + ) + ) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + chainDiff = emptyDiff(), + assetsDiff = emptyDiff(), + nodesDiff = insertsNodeWithUrl(nodeUrl), + explorersDiff = emptyDiff(), + externalApisDiff = emptyDiff(), + nodeSelectionPreferencesDiff = emptyDiff() + ) + } + } + + @Test + fun `should update chain's explorer`() { + runBlocking { + localReturns(listOf(LOCAL_CHAIN)) + + remoteReturns( + listOf( + REMOTE_CHAIN.copy( + explorers = listOf( + REMOTE_CHAIN.explorers!!.first().copy(extrinsic = "NEW") + ) + ) + ) + ) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + chainDiff = emptyDiff(), + assetsDiff = emptyDiff(), + nodesDiff = emptyDiff(), + explorersDiff = insertsExplorerByName(explorerName), + externalApisDiff = emptyDiff(), + nodeSelectionPreferencesDiff = emptyDiff() + ) + } + } + + @Test + fun `should update chain's transfer apis`() { + runBlocking { + localReturns(listOf(LOCAL_CHAIN)) + + val currentHistoryApi = REMOTE_CHAIN.externalApi!!.getValue("history").first() + val anotherUrl = "another url" + + remoteReturns( + listOf( + REMOTE_CHAIN.copy( + externalApi = mapOf( + "history" to listOf( + currentHistoryApi, + currentHistoryApi.copy(url = anotherUrl) + ) + ) + ) + ) + ) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + chainDiff = emptyDiff(), + assetsDiff = emptyDiff(), + nodesDiff = emptyDiff(), + explorersDiff = emptyDiff(), + externalApisDiff = insertsTransferApiByUrl(anotherUrl), + nodeSelectionPreferencesDiff = emptyDiff() + ) + } + } + + @Test + fun `should remove chain`() { + runBlocking { + localReturns(listOf(LOCAL_CHAIN)) + + remoteReturns(emptyList()) + + chainSyncService.syncUp() + + verify(dao).applyDiff( + chainDiff = removesChainWithId(REMOTE_CHAIN.chainId), + assetsDiff = removesAssetWithId(assetId), + nodesDiff = removesNodeWithUrl(nodeUrl), + explorersDiff = removesExplorerByName(explorerName), + externalApisDiff = removesTransferApiByUrl(transferApiUrl), + nodeSelectionPreferencesDiff = removesNodeSelectionPreferences(REMOTE_CHAIN.chainId) + ) + } + } + + private suspend fun remoteReturns(chains: List) { + `when`(chainFetcher.getChains()).thenReturn(chains) + } + + private suspend fun localReturns(chains: List) { + `when`(dao.getJoinChainInfo()).thenReturn(chains) + } + + private fun insertsChainWithId(id: String) = insertsElement { it.id == id } + private fun insertsAssetWithId(id: Int) = insertsElement { it.id == id } + private fun insertsNodeWithUrl(url: String) = insertsElement { it.url == url } + private fun insertsExplorerByName(name: String) = insertsElement { it.name == name } + private fun insertsTransferApiByUrl(url: String) = insertsElement { it.url == url } + private fun insertsNodeSelectionPreferences(id: String) = insertsElement { it.chainId == id } + + private fun removesChainWithId(id: String) = removesElement { it.id == id } + private fun removesAssetWithId(id: Int) = removesElement { it.id == id } + private fun removesNodeWithUrl(url: String) = removesElement { it.url == url } + private fun removesExplorerByName(name: String) = removesElement { it.name == name } + private fun removesTransferApiByUrl(url: String) = removesElement { it.url == url } + private fun removesNodeSelectionPreferences(chainId: String) = removesElement { it.chainId == chainId } + + private fun createLocalCopy(remote: ChainRemote): JoinedChainInfo { + val domain = mapRemoteChainToLocal(remote, oldChain = null, source = ChainLocal.Source.DEFAULT, gson) + val nodeSelectionPreferences = NodeSelectionPreferencesLocal( + chainId = remote.chainId, + autoBalanceEnabled = true, + selectedNodeUrl = null + ) + val assets = remote.assets.map { mapRemoteAssetToLocal(remote, it, gson, true) } + val nodes = mapRemoteNodesToLocal(remote) + val explorers = mapRemoteExplorersToLocal(remote) + val transferHistoryApis = mapExternalApisToLocal(remote) + + return JoinedChainInfo( + chain = domain, + nodeSelectionPreferences = nodeSelectionPreferences, + nodes = nodes, + assets = assets, + explorers = explorers, + externalApis = transferHistoryApis + ) + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/RoundRobinStrategyTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/RoundRobinStrategyTest.kt new file mode 100644 index 0000000..3c21fcf --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/connection/autobalance/strategy/RoundRobinStrategyTest.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.connection.NodeWithSaturatedUrl +import org.junit.Assert.assertEquals +import org.junit.Test + +class RoundRobinStrategyTest { + + + private val nodes = listOf( + createFakeNode("1"), + createFakeNode("2"), + createFakeNode("3") + ) + + private val strategy = RoundRobinGenerator(nodes) + + @Test + fun `collections should have the same sequence`() { + val iterator = strategy.generateNodeSequence() + .iterator() + + nodes.forEach { assertEquals(it, iterator.next()) } + } + + @Test + fun `sequence should be looped`() { + val iterator = strategy.generateNodeSequence() + .iterator() + + repeat(nodes.size) { iterator.next() } + + assertEquals(nodes.first(), iterator.next()) + } + + private fun createFakeNode(id: String) = NodeWithSaturatedUrl( + node = Chain.Node(unformattedUrl = id, name = id, chainId = "test", orderId = 0, isCustom = false), + saturatedUrl = id + ) +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/runtime/Mocks.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/runtime/Mocks.kt new file mode 100644 index 0000000..82fe439 --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/runtime/Mocks.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.test_shared.whenever +import org.mockito.Mockito + +object Mocks { + fun chain(id: String) : Chain { + val chain = Mockito.mock(Chain::class.java) + + whenever(chain.id).thenReturn(id) + + return chain + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeProviderTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeProviderTest.kt new file mode 100644 index 0000000..83a904e --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeProviderTest.kt @@ -0,0 +1,318 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.TypesUsage +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchronizer +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.eq +import io.novafoundation.nova.test_shared.thenThrowUnsafe +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@RunWith(MockitoJUnitRunner::class) +@Ignore("Not stable tests") // FIXME +class RuntimeProviderTest { + + lateinit var baseTypeSyncFlow: MutableSharedFlow + lateinit var chainSyncFlow: MutableSharedFlow + + lateinit var chain: Chain + + @Mock + lateinit var runtime: RuntimeSnapshot + + @Mock + lateinit var constructedRuntime: ConstructedRuntime + + @Mock + lateinit var runtimeSyncService: RuntimeSyncService + + @Mock + lateinit var runtimeCache: RuntimeFilesCache + + @Mock + lateinit var runtimeFactory: RuntimeFactory + + @Mock + lateinit var baseTypesSynchronizer: BaseTypeSynchronizer + + lateinit var runtimeProvider: RuntimeProvider + + @Before + fun setup() { + runBlocking { + chain = Mocks.chain(id = "1") + + baseTypeSyncFlow = MutableSharedFlow() + chainSyncFlow = MutableSharedFlow() + + whenever(constructedRuntime.runtime).thenReturn(runtime) + whenever(runtimeFactory.constructRuntime(any(), any())).thenReturn(constructedRuntime) + + whenever(baseTypesSynchronizer.syncStatusFlow).thenAnswer { baseTypeSyncFlow } + whenever(runtimeSyncService.syncResultFlow(eq(chain.id))).thenAnswer { chainSyncFlow } + } + } + + @Test(timeout = 500) + fun `should init from cache`() { + runBlocking { + initProvider() + + val returnedRuntime = runtimeProvider.get() + + verify(runtimeFactory, times(1)).constructRuntime(eq(chain.id), any()) + + assertEquals(returnedRuntime, runtime) + } + } + + @Test + fun `should not reconstruct runtime if base types has remains the same`() { + runBlocking { + initProvider() + + currentBaseTypesHash("Hash") + + baseTypeSyncFlow.emit("Hash") + + verifyReconstructionNotStarted() + } + } + + @Test + fun `should not reconstruct runtime on base types change if they are not used`() { + runBlocking { + initProvider(typesUsage = TypesUsage.OWN) + + baseTypeSyncFlow.emit("Hash") + + verifyReconstructionNotStarted() + } + } + + @Test + fun `should reconstruct runtime if base types changes`() { + runBlocking { + initProvider() + + currentBaseTypesHash("Hash") + + baseTypeSyncFlow.emit("Changed Hash") + + verifyReconstructionStarted() + } + } + + @Test + fun `should not reconstruct runtime if chain metadata or types did not change`() { + runBlocking { + initProvider() + + currentChainTypesHash("Hash") + currentMetadataHash("Hash") + + chainSyncFlow.emit(SyncResult(chain.id, metadataHash = "Hash", typesHash = "Hash")) + + verifyReconstructionNotStarted() + } + } + + @Test + fun `should reconstruct runtime if chain metadata or types changed`() { + runBlocking { + initProvider() + + currentChainTypesHash("Hash") + currentMetadataHash("Hash") + + chainSyncFlow.emit(SyncResult(chain.id, metadataHash = "Hash Changed", typesHash = "Hash")) + + verifyReconstructionAfterInit(1) + + chainSyncFlow.emit(SyncResult(chain.id, metadataHash = "Hash Changed", typesHash = "Hash Changed")) + + verifyReconstructionAfterInit(2) + } + } + + @Test + fun `should reconstruct runtime on chain info sync if cache init failed`() { + runBlocking { + withRuntimeFactoryFailing { + chainSyncFlow.emit(SyncResult(chain.id, metadataHash = "Hash Changed", typesHash = "Hash")) + + verifyReconstructionStarted() + } + } + } + + @Test + fun `should not reconstruct runtime if types and runtime were not synced`() { + runBlocking { + initProvider() + + currentChainTypesHash("Hash") + currentMetadataHash("Hash") + + chainSyncFlow.emit(SyncResult(chain.id, metadataHash = null, typesHash = null)) + + verifyReconstructionNotStarted() + } + } + + @Test + fun `should wait until current job is finished before consider reconstructing runtime on runtime sync event`() { + runBlocking { + whenever(runtimeFactory.constructRuntime(any(), any())).thenAnswer { + runBlocking { chainSyncFlow.first() } // ensure runtime wont be returned until chainSyncFlow event + + constructedRuntime + } + + initProvider() + + currentChainTypesHash("Hash") + currentMetadataHash("Hash") + + chainSyncFlow.emit(SyncResult(chain.id, metadataHash = null, typesHash = null)) + + verifyReconstructionNotStarted() + } + } + + @Test + fun `should wait until current job is finished before consider reconstructing runtime on types sync event`() { + runBlocking { + whenever(runtimeFactory.constructRuntime(any(), any())).thenAnswer { + runBlocking { baseTypeSyncFlow.first() } // ensure runtime wont be returned until baseTypeSyncFlow event + + constructedRuntime + } + + initProvider() + + currentChainTypesHash("Hash") + currentMetadataHash("Hash") + + baseTypeSyncFlow.emit("New hash") + + verifyReconstructionNotStarted() + } + } + + @Test + fun `should report missing cache for base types`() { + runBlocking { + withRuntimeFactoryFailing(BaseTypesNotInCacheException) { + verify(baseTypesSynchronizer, times(1)).cacheNotFound() + verify(runtimeSyncService, times(0)).cacheNotFound(any()) + } + } + } + + @Test + fun `should report missing cache for chain types or metadata`() { + runBlocking { + withRuntimeFactoryFailing(ChainInfoNotInCacheException) { + verify(runtimeSyncService, times(1)).cacheNotFound(eq(chain.id)) + verify(baseTypesSynchronizer, times(0)).cacheNotFound() + } + } + } + + @Test + fun `should construct runtime on base types sync if cache init failed`() { + runBlocking { + withRuntimeFactoryFailing { + baseTypeSyncFlow.emit("Hash") + + verifyReconstructionStarted() + } + } + } + + @Test + fun `should construct runtime on type usage change`() { + runBlocking { + initProvider(typesUsage = TypesUsage.BASE) + + runtimeProvider.considerUpdatingTypesUsage(TypesUsage.OWN) + + verifyReconstructionStarted() + } + } + + @Test + fun `should not construct runtime on same type usage`() { + runBlocking { + initProvider(typesUsage = TypesUsage.BASE) + + runtimeProvider.considerUpdatingTypesUsage(TypesUsage.BASE) + + verifyReconstructionNotStarted() + } + } + + private suspend fun verifyReconstructionNotStarted() { + verifyReconstructionAfterInit(0) + } + + private suspend fun verifyReconstructionStarted() { + verifyReconstructionAfterInit(1) + } + + private suspend fun withRuntimeFactoryFailing(exception: Exception = BaseTypesNotInCacheException, block: suspend () -> Unit) { + whenever(runtimeFactory.constructRuntime(any(), any())).thenThrowUnsafe(exception) + + initProvider() + + delay(10) + + block() + } + + private suspend fun verifyReconstructionAfterInit(times: Int) { + delay(10) + + // + 1 since it is called once in init (cache) + verify(runtimeFactory, times(times + 1)).constructRuntime(eq(chain.id), any()) + } + + private fun currentBaseTypesHash(hash: String?) { + whenever(constructedRuntime.baseTypesHash).thenReturn(hash) + } + + private fun currentMetadataHash(hash: String?) { + whenever(constructedRuntime.metadataHash).thenReturn(hash) + } + + private fun currentChainTypesHash(hash: String?) { + whenever(constructedRuntime.ownTypesHash).thenReturn(hash) + } + + private fun initProvider(typesUsage: TypesUsage? = null) { + val types = when (typesUsage) { + TypesUsage.OWN -> Chain.Types(url = "url", overridesCommon = true) + TypesUsage.BOTH -> Chain.Types(url = "url", overridesCommon = false) + else -> null + } + + whenever(chain.types).thenReturn(types) + + runtimeProvider = RuntimeProvider(runtimeFactory, runtimeSyncService, baseTypesSynchronizer, runtimeCache, chain) + } +} diff --git a/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeSyncServiceTest.kt b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeSyncServiceTest.kt new file mode 100644 index 0000000..8c03fea --- /dev/null +++ b/runtime/src/test/java/io/novafoundation/nova/runtime/multiNetwork/runtime/RuntimeSyncServiceTest.kt @@ -0,0 +1,381 @@ +package io.novafoundation.nova.runtime.multiNetwork.runtime + +import com.google.gson.Gson +import io.novafoundation.nova.common.utils.md5 +import io.novafoundation.nova.core_db.dao.ChainDao +import io.novafoundation.nova.core_db.model.chain.ChainRuntimeInfoLocal +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId +import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection +import io.novafoundation.nova.runtime.multiNetwork.runtime.types.TypesFetcher +import io.novafoundation.nova.test_shared.any +import io.novafoundation.nova.test_shared.eq +import io.novafoundation.nova.test_shared.whenever +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.request.runtime.RuntimeRequest +import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.Timeout +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + + +private const val TEST_TYPES = "Stub" + +@Ignore("Flaky tests due to concurrency issues") +@RunWith(MockitoJUnitRunner::class) +class RuntimeSyncServiceTest { + + private val testChain by lazy { + Mocks.chain(id = "1") + } + + @Mock + private lateinit var socket: SocketService + + @Mock + private lateinit var testConnection: ChainConnection + + @Mock + private lateinit var typesFetcher: TypesFetcher + + @Mock + private lateinit var chainDao: ChainDao + + @Mock + private lateinit var runtimeFilesCache: RuntimeFilesCache + + @Mock + private lateinit var runtimeMetadataFetcher: RuntimeMetadataFetcher + + @Mock + private lateinit var cacheMigrator: RuntimeCacheMigrator + + private lateinit var syncDispatcher: SyncChainSyncDispatcher + + private lateinit var service: RuntimeSyncService + + private lateinit var syncResultFlow: Flow + + @JvmField + @Rule + val globalTimeout: Timeout = Timeout.seconds(10) + + @Before + fun setup() = runBlocking { + whenever(testConnection.socketService).thenReturn(socket) + whenever(socket.jsonMapper).thenReturn(Gson()) + whenever(typesFetcher.getTypes(any())).thenReturn(TEST_TYPES) + + whenever(runtimeMetadataFetcher.fetchRawMetadata(any(), any())).thenReturn(RawRuntimeMetadata(metadataContent = byteArrayOf(0), isOpaque = false)) + + whenever(cacheMigrator.needsMetadataFetch(anyInt())).thenReturn(false) + + syncDispatcher = Mockito.spy(SyncChainSyncDispatcher()) + + service = RuntimeSyncService(typesFetcher, runtimeFilesCache, chainDao, runtimeMetadataFetcher, cacheMigrator, syncDispatcher) + + syncResultFlow = service.syncResultFlow(testChain.id) + .shareIn(GlobalScope, started = SharingStarted.Eagerly, replay = 1) + } + + @Test + fun `should not start syncing new chain`() { + service.registerChain(chain = testChain, connection = testConnection) + + assertNoSyncLaunched() + } + + @Test + fun `should start syncing on runtime version apply`() { + service.registerChain(chain = testChain, connection = testConnection) + + service.applyRuntimeVersion(testChain.id) + + assertSyncLaunchedOnce() + assertSyncCancelledTimes(1) + } + + @Test + fun `should not start syncing the same chain`() { + runBlocking { + chainDaoReturnsBiggerRuntimeVersion() + + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + assertSyncLaunchedOnce() + + service.registerChain(chain = testChain, connection = testConnection) + + // No new launches + assertSyncLaunchedOnce() + + assertFalse(service.isSyncing(testChain.id)) + } + } + + @Test + fun `should sync modified chain`() { + runBlocking { + chainDaoReturnsBiggerRuntimeVersion() + + val newChain = Mockito.mock(Chain::class.java) + whenever(newChain.id).thenAnswer { testChain.id } + whenever(newChain.types).thenReturn(Chain.Types(url = "Changed", overridesCommon = false)) + + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + assertSyncLaunchedOnce() + + chainDaoReturnsSameRuntimeInfo() + + service.registerChain(chain = newChain, connection = testConnection) + + assertSyncLaunchedTimes(2) + + val syncResult = syncResultFlow.first() + + assertNull("Metadata should not sync", syncResult.metadataHash) + } + } + + @Test + fun `should sync types when url is not null`() { + runBlocking { + chainDaoReturnsSameRuntimeInfo() + + whenever(testChain.types).thenReturn(Chain.Types("Stub", overridesCommon = false)) + + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + val result = syncResultFlow.first() + + assertNotNull(result.typesHash) + } + } + + @Test + fun `should not sync types when url is null`() { + runBlocking { + chainDaoReturnsBiggerRuntimeVersion() + + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + val result = syncResultFlow.first() + + assertNull(result.typesHash) + } + } + + @Test + fun `should cancel syncing when chain is unregistered`() { + runBlocking { + chainDaoReturnsBiggerRuntimeVersion() + + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + assertSyncCancelledTimes(1) + assertSyncLaunchedOnce() + + service.unregisterChain(testChain.id) + + assertSyncCancelledTimes(2) + } + } + + @Test + fun `should broadcast sync result`() { + runBlocking { + chainDaoReturnsBiggerRuntimeVersion() + + whenever(testChain.types).thenReturn(Chain.Types("testUrl", overridesCommon = false)) + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + val result = syncResultFlow.first() + + assertEquals(TEST_TYPES.md5(), result.typesHash) + } + } + + @Test + fun `should sync bigger version of metadata`() { + runBlocking { + chainDaoReturnsBiggerRuntimeVersion() + + whenever(testChain.types).thenReturn(Chain.Types("testUrl", overridesCommon = false)) + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + val syncResult = syncResultFlow.first() + + assertNotNull(syncResult.metadataHash) + } + } + + @Test + fun `should sync lower version of metadata`() { + runBlocking { + chainDaoReturnsLowerRuntimeInfo() + + whenever(testChain.types).thenReturn(Chain.Types("testUrl", overridesCommon = false)) + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + val syncResult = syncResultFlow.first() + + assertNotNull(syncResult.metadataHash) + } + } + + @Test + fun `should always sync chain info when cache is not found`() { + runBlocking { + chainDaoReturnsSameRuntimeInfo() + + whenever(testChain.types).thenReturn(Chain.Types("testUrl", overridesCommon = false)) + service.registerChain(chain = testChain, connection = testConnection) + + assertNoSyncLaunched() + + service.cacheNotFound(testChain.id) + + assertSyncLaunchedOnce() + + val syncResult = syncResultFlow.first() + assertNotNull(syncResult.metadataHash) + assertNotNull(syncResult.typesHash) + } + } + + @Test + fun `should not sync the same version of metadata`() { + runBlocking { + chainDaoReturnsSameRuntimeInfo() + + whenever(testChain.types).thenReturn(Chain.Types("testUrl", overridesCommon = false)) + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + val syncResult = syncResultFlow.first() + + assertNull(syncResult.metadataHash) + } + } + + @Test + fun `should sync the same version of metadata when local migration required`() { + runBlocking { + chainDaoReturnsSameRuntimeInfo() + requiresLocalMigration() + + whenever(testChain.types).thenReturn(Chain.Types("testUrl", overridesCommon = false)) + service.registerChain(chain = testChain, connection = testConnection) + service.applyRuntimeVersion(testChain.id) + + val syncResult = syncResultFlow.first() + + assertNotNull(syncResult.metadataHash) + } + } + + private suspend fun chainDaoReturnsBiggerRuntimeVersion() { + chainDaoReturnsRuntimeInfo(remoteVersion = 1, syncedVersion = 0) + } + + private suspend fun chainDaoReturnsSameRuntimeInfo() { + chainDaoReturnsRuntimeInfo(remoteVersion = 1, syncedVersion = 1) + } + + private suspend fun chainDaoReturnsLowerRuntimeInfo() { + chainDaoReturnsRuntimeInfo(remoteVersion = 0, syncedVersion = 1) + } + + private suspend fun chainDaoReturnsRuntimeInfo(remoteVersion: Int, syncedVersion: Int) { + whenever(chainDao.runtimeInfo(any())).thenReturn(ChainRuntimeInfoLocal("1", syncedVersion, remoteVersion, null, localMigratorVersion = 1)) + } + + private suspend fun RuntimeSyncService.latestSyncResult(chainId: String) = syncResultFlow(chainId).first() + + private suspend fun requiresLocalMigration() { + whenever(cacheMigrator.needsMetadataFetch(anyInt())).thenReturn(true) + } + + private fun socketAnswersRequest(request: RuntimeRequest, response: Any?) { + whenever(socket.executeRequest(eq(request), deliveryType = any(), callback = any())).thenAnswer { + (it.arguments[2] as SocketService.ResponseListener).onNext(RpcResponse(jsonrpc = "2.0", response, id = 1, error = null)) + + object : SocketService.Cancellable { + override fun cancel() { + // pass + } + } + } + } + + private fun assertNoSyncLaunched() { + verify(syncDispatcher, never()).launchSync(anyString(), any()) + } + + private fun assertSyncLaunchedOnce() { + verify(syncDispatcher, times(1)).launchSync(anyString(), any()) + } + + private fun assertSyncCancelledTimes(times: Int) { + verify(syncDispatcher, times(times)).cancelExistingSync(anyString()) + } + + private fun assertSyncLaunchedTimes(times: Int) { + verify(syncDispatcher, times(times)).launchSync(anyString(), any()) + } + + class SyncChainSyncDispatcher() : ChainSyncDispatcher { + + private val syncingChains = Collections.newSetFromMap(ConcurrentHashMap()) + + override fun isSyncing(chainId: String): Boolean { + return syncingChains.contains(chainId) + } + + override fun syncFinished(chainId: String) { + syncingChains.remove(chainId) + } + + override fun cancelExistingSync(chainId: String) { + syncingChains.remove(chainId) + } + + override fun launchSync(chainId: String, action: suspend () -> Unit) = runBlocking { + syncingChains.add(chainId) + + action() + } + } +} diff --git a/scripts/secrets.gradle b/scripts/secrets.gradle new file mode 100644 index 0000000..2a7670f --- /dev/null +++ b/scripts/secrets.gradle @@ -0,0 +1,36 @@ +Properties localProperties = new Properties() + +def localPropertiesFile = project.rootProject.file('local.properties') + +if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.newDataInputStream()) +} + +ext.readRawSecretOrNull = { secretName -> + def localPropSecret = localProperties.getProperty(secretName) + + def secret = (localPropSecret != null) ? localPropSecret : System.getenv(secretName) + + if (secret == null || secret.isEmpty()) return null + + return secret +} + +ext.readStringSecretOrNull = { secretName -> + def secret = readRawSecretOrNull(secretName) + if (secret == null) return null + + return maybeWrapInQuotes(secret) +} + +ext.readStringSecret = { secretName -> + return readStringSecretOrNull(secretName) ?: secretNotFound(secretName) +} + +private static def secretNotFound(secretName) { + throw new NoSuchElementException("${secretName} secret is not found in local.properties or environment variables") +} + +static def maybeWrapInQuotes(content) { + return content.startsWith("\"") ? content : "\"" + content + "\"" +} \ No newline at end of file diff --git a/scripts/versions.gradle b/scripts/versions.gradle new file mode 100644 index 0000000..0b9a2d7 --- /dev/null +++ b/scripts/versions.gradle @@ -0,0 +1,48 @@ +def getVersionCodeFromFile() { + def versionFile = new File(rootProject.projectDir, 'version.properties') + if (!versionFile.exists()) { + versionFile.text = 'VERSION_CODE=1' + } + def props = new Properties() + props.load(versionFile.newDataInputStream()) + return props.getProperty('VERSION_CODE', '1').toInteger() +} + +def incrementVersionCode() { + def versionFile = new File(rootProject.projectDir, 'version.properties') + def currentCode = getVersionCodeFromFile() + def newCode = currentCode + 1 + versionFile.text = "VERSION_CODE=${newCode}" + return newCode +} + +def computeVersionName() { + return "$rootProject.versionName" +} + +def computeVersionCode() { + if (System.env.CI_BUILD_ID) { + return Integer.valueOf(System.env.CI_BUILD_ID) + } + // Local build - auto increment + if (gradle.startParameter.taskNames.any { it.contains('assemble') || it.contains('install') }) { + return incrementVersionCode() + } + return getVersionCodeFromFile() +} + +def static releaseNotes() { + return System.getenv('CI_FIREBASE_RELEASENOTES') ?: '' +} + +def static firebaseGroup() { + return System.getenv('CI_FIREBASE_GROUP') ?: '' +} + + +ext { + computeVersionName = this.&computeVersionName + computeVersionCode = this.&computeVersionCode + releaseNotes = this.&releaseNotes + firebaseGroup = this.&firebaseGroup +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..50dba30 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,63 @@ +include ':feature-crowdloan-impl' +include ':feature-crowdloan-api' +include ':feature-staking-impl' +include ':feature-staking-api' +include ':feature-wallet-impl' +include ':feature-wallet-api' +include ':feature-onboarding-impl' +include ':feature-onboarding-api' +include ':app', ':test-shared', ':common', ':feature-splash', 'core-db', 'core-api' +include ':runtime' +include ':feature-account-api' +include ':feature-account-impl' +include ':feature-dapp-api' +include ':feature-dapp-impl' +include ':feature-assets' +include ':feature-nft-api' +include ':feature-nft-impl' +include ':feature-ledger-api' +include ':feature-ledger-impl' +include ':feature-currency-api' +include ':feature-currency-impl' +include ':feature-governance-api' +include ':feature-governance-impl' +include ':feature-vote' +include ':feature-versions-impl' +include ':feature-versions-api' +include ':web3names' +include ':caip' +include ':feature-external-sign-api' +include ':feature-external-sign-impl' +include ':feature-wallet-connect-api' +include ':feature-wallet-connect-impl' +include ':feature-settings-api' +include ':feature-settings-impl' +include ':feature-swap-api' +include ':feature-swap-impl' +include ':feature-buy-api' +include ':feature-buy-impl' +include ':bindings:hydra-dx-math' +include ':bindings:sr25519-bizinikiwi' +include ':feature-proxy-impl' +include ':feature-proxy-api' +include ':feature-push-notifications' +include ':feature-deep-linking' +include ':feature-cloud-backup-api' +include ':feature-cloud-backup-impl' +include ':feature-cloud-backup-test' +include ':bindings:metadata_shortener' +include ':feature-ledger-core' +include ':feature-swap-core' +include ':feature-swap-core:api' +include ':feature-xcm:api' +include ':feature-xcm:impl' +include ':feature-banners-api' +include ':feature-banners-impl' +include ':feature-account-migration' +include ':feature-multisig:operations' +include ':feature-ahm-api' +include ':feature-ahm-impl' +include ':feature-gift-api' +include ':feature-gift-impl' +include ':feature-bridge-api' +include ':feature-bridge-impl' diff --git a/test-shared/.gitignore b/test-shared/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/test-shared/.gitignore @@ -0,0 +1 @@ +/build diff --git a/test-shared/build.gradle b/test-shared/build.gradle new file mode 100644 index 0000000..774f349 --- /dev/null +++ b/test-shared/build.gradle @@ -0,0 +1,30 @@ + +android { + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + namespace 'io.novafoundation.nova.test_shared' +} + + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation kotlinDep + + implementation project(':common') + + api jUnitDep + api mockitoDep + + api wsDep + + api gsonDep + api substrateSdkDep + + api coroutinesTestDep +} \ No newline at end of file diff --git a/test-shared/src/main/AndroidManifest.xml b/test-shared/src/main/AndroidManifest.xml new file mode 100644 index 0000000..227314e --- /dev/null +++ b/test-shared/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test-shared/src/main/java/io/novafoundation/nova/test_shared/Assertions.kt b/test-shared/src/main/java/io/novafoundation/nova/test_shared/Assertions.kt new file mode 100644 index 0000000..a745765 --- /dev/null +++ b/test-shared/src/main/java/io/novafoundation/nova/test_shared/Assertions.kt @@ -0,0 +1,39 @@ +package io.novafoundation.nova.test_shared + +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals + +fun assertListEquals(expected: List, actual: List, compare: (T, T) -> Boolean = { a, b -> a == b }) { + if (expected.size != actual.size) { + throw AssertionError("Lists are not equal. Expected: $expected, actual: $actual") + } + for (i in expected.indices) { + if (!compare(expected[i], actual[i])) { + throw AssertionError("Lists are not equal. Expected: $expected, actual: $actual") + } + } +} + +fun assertHexEquals(expected: ByteArray, actual: ByteArray) { + assertEquals(expected.toHexString(), actual.toHexString()) +} + +fun assertSetEquals(expected: Set, actual: Set) { + if (expected != actual) { + throw AssertionError("Sets are not equal. Expected: $expected, actual: $actual") + } +} + +fun assertMapEquals(expected: Map, actual: Map) { + if (expected != actual) { + throw AssertionError("Maps are not equal. Expected: $expected, actual: $actual") + } +} + +fun assertAllItemsEquals(items: List) { + items.forEach { + if (it != items[0]) { + throw AssertionError("Items in list are not equal:\n$it\n${items[0]}") + } + } +} diff --git a/test-shared/src/main/java/io/novafoundation/nova/test_shared/CoroutineTest.kt b/test-shared/src/main/java/io/novafoundation/nova/test_shared/CoroutineTest.kt new file mode 100644 index 0000000..898fe55 --- /dev/null +++ b/test-shared/src/main/java/io/novafoundation/nova/test_shared/CoroutineTest.kt @@ -0,0 +1,18 @@ +package io.novafoundation.nova.test_shared + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest + +@OptIn(ExperimentalCoroutinesApi::class) +abstract class CoroutineTest { + + private val testDispatcher = TestCoroutineDispatcher() + private val testScope = TestCoroutineScope(testDispatcher) + + fun runCoroutineTest(test: suspend CoroutineScope.() -> Unit) = testScope.runBlockingTest { + test(testScope) + } +} diff --git a/test-shared/src/main/java/io/novafoundation/nova/test_shared/DiffHelpers.kt b/test-shared/src/main/java/io/novafoundation/nova/test_shared/DiffHelpers.kt new file mode 100644 index 0000000..e51a80a --- /dev/null +++ b/test-shared/src/main/java/io/novafoundation/nova/test_shared/DiffHelpers.kt @@ -0,0 +1,15 @@ +package io.novafoundation.nova.test_shared + +import io.novafoundation.nova.common.utils.CollectionDiffer + +fun removesElement(elementCheck: (T) -> Boolean) = argThat> { + it.newOrUpdated.isEmpty() && elementCheck(it.removed.single()) +} + +fun insertsElement(elementCheck: (T) -> Boolean) = argThat> { + it.removed.isEmpty() && elementCheck(it.newOrUpdated.single()) +} + +fun emptyDiff() = argThat> { + it.newOrUpdated.isEmpty() && it.removed.isEmpty() +} diff --git a/test-shared/src/main/java/io/novafoundation/nova/test_shared/HashMapEncryptedPreferences.kt b/test-shared/src/main/java/io/novafoundation/nova/test_shared/HashMapEncryptedPreferences.kt new file mode 100644 index 0000000..7b7e2d5 --- /dev/null +++ b/test-shared/src/main/java/io/novafoundation/nova/test_shared/HashMapEncryptedPreferences.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.test_shared + +import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences + +class HashMapEncryptedPreferences : EncryptedPreferences { + private val delegate = mutableMapOf() + + override fun putEncryptedString(field: String, value: String) { + delegate[field] = value + } + + override fun getDecryptedString(field: String): String? = delegate[field] + + override fun hasKey(field: String): Boolean = field in delegate + + override fun removeKey(field: String) { + delegate.remove(field) + } +} diff --git a/test-shared/src/main/java/io/novafoundation/nova/test_shared/LoggerHelpers.kt b/test-shared/src/main/java/io/novafoundation/nova/test_shared/LoggerHelpers.kt new file mode 100644 index 0000000..f33b577 --- /dev/null +++ b/test-shared/src/main/java/io/novafoundation/nova/test_shared/LoggerHelpers.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.test_shared + +import io.novasama.substrate_sdk_android.wsrpc.logging.Logger + +object StdoutLogger : Logger { + override fun log(message: String?) { + println(message) + } + + override fun log(throwable: Throwable?) { + throwable?.printStackTrace() + } +} + +object NoOpLogger : Logger { + override fun log(message: String?) { + // pass + } + + override fun log(throwable: Throwable?) { + // pass + } +} diff --git a/test-shared/src/main/java/io/novafoundation/nova/test_shared/MockitoHelpers.kt b/test-shared/src/main/java/io/novafoundation/nova/test_shared/MockitoHelpers.kt new file mode 100644 index 0000000..5eefd6e --- /dev/null +++ b/test-shared/src/main/java/io/novafoundation/nova/test_shared/MockitoHelpers.kt @@ -0,0 +1,34 @@ +package io.novafoundation.nova.test_shared + +import org.mockito.ArgumentMatcher +import org.mockito.Mockito +import org.mockito.stubbing.OngoingStubbing + +/** + * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun eq(obj: T): T = Mockito.eq(obj) + +/** + * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + */ +fun any(): T = Mockito.any() + +/** + * Returns Mockito.isA() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + */ +fun isA(classRef: Class): T = Mockito.isA(classRef) + +fun argThat(argumentMatcher: ArgumentMatcher): T = Mockito.argThat(argumentMatcher) + +fun whenever(methodCall: T): OngoingStubbing = + Mockito.`when`(methodCall) + +fun OngoingStubbing.thenThrowUnsafe(exception: Exception) = thenAnswer { + throw exception +} diff --git a/test-shared/src/main/java/io/novafoundation/nova/test_shared/SocketHelpers.kt b/test-shared/src/main/java/io/novafoundation/nova/test_shared/SocketHelpers.kt new file mode 100644 index 0000000..74a7aea --- /dev/null +++ b/test-shared/src/main/java/io/novafoundation/nova/test_shared/SocketHelpers.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.test_shared + +import com.google.gson.Gson +import com.neovisionaries.ws.client.WebSocketFactory +import io.novasama.substrate_sdk_android.wsrpc.SocketService +import io.novasama.substrate_sdk_android.wsrpc.recovery.Reconnector +import io.novasama.substrate_sdk_android.wsrpc.request.RequestExecutor + +fun createTestSocket() = SocketService(Gson(), NoOpLogger, WebSocketFactory(), Reconnector(), RequestExecutor()) diff --git a/tests.gradle b/tests.gradle new file mode 100644 index 0000000..597e88c --- /dev/null +++ b/tests.gradle @@ -0,0 +1,14 @@ +afterEvaluate { + task runModuleTests { + Task runTestsTask + if (project.tasks.findByName('testDevelopDebugUnitTest')) { + runTestsTask = project.tasks.findByName('testDevelopDebugUnitTest') + } else { + runTestsTask = project.tasks.findByName('testDebugUnitTest') + } + + dependsOn runTestsTask + + group "Verification" + } +} \ No newline at end of file diff --git a/web3names/.gitignore b/web3names/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/web3names/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/web3names/build.gradle b/web3names/build.gradle new file mode 100644 index 0000000..3b97d3f --- /dev/null +++ b/web3names/build.gradle @@ -0,0 +1,40 @@ + +android { + namespace 'io.novafoundation.nova.web3names' + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation project(":common") + implementation project(":core-db") + implementation project(":runtime") + implementation project(":core-api") + implementation project(":caip") + + implementation kotlinDep + + implementation coroutinesDep + + implementation retrofitDep + + implementation daggerDep + ksp daggerCompiler + + implementation multibaseDep + + implementation canonizationJsonDep + + testImplementation project(':test-shared') + + androidTestImplementation androidTestRunnerDep + androidTestImplementation androidTestRulesDep + androidTestImplementation androidJunitDep +} \ No newline at end of file diff --git a/web3names/consumer-rules.pro b/web3names/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/web3names/proguard-rules.pro b/web3names/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/web3names/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/web3names/src/main/AndroidManifest.xml b/web3names/src/main/AndroidManifest.xml new file mode 100644 index 0000000..10728cc --- /dev/null +++ b/web3names/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/endpoints/TransferRecipientsApi.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/endpoints/TransferRecipientsApi.kt new file mode 100644 index 0000000..eaa9bc3 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/endpoints/TransferRecipientsApi.kt @@ -0,0 +1,12 @@ +package io.novafoundation.nova.web3names.data.endpoints + +import retrofit2.http.GET +import retrofit2.http.Url + +interface TransferRecipientsApi { + + @GET + suspend fun getTransferRecipientsRaw( + @Url url: String + ): String +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/endpoints/model/TransferRecipientDetailsRemoteV1.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/endpoints/model/TransferRecipientDetailsRemoteV1.kt new file mode 100644 index 0000000..2d58d33 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/endpoints/model/TransferRecipientDetailsRemoteV1.kt @@ -0,0 +1,6 @@ +package io.novafoundation.nova.web3names.data.endpoints.model + +class TransferRecipientDetailsRemoteV1( + val account: String, + val description: String? +) diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/endpoints/model/TransferRecipientDetailsRemoteV2.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/endpoints/model/TransferRecipientDetailsRemoteV2.kt new file mode 100644 index 0000000..e2fa367 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/endpoints/model/TransferRecipientDetailsRemoteV2.kt @@ -0,0 +1,5 @@ +package io.novafoundation.nova.web3names.data.endpoints.model + +class TransferRecipientDetailsRemoteV2( + val description: String? +) diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/provider/Web3NamesServiceChainIdProvider.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/provider/Web3NamesServiceChainIdProvider.kt new file mode 100644 index 0000000..534db94 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/provider/Web3NamesServiceChainIdProvider.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.web3names.data.provider + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId + +interface Web3NamesServiceChainIdProvider { + fun getChainId(): ChainId +} + +class RealWeb3NamesServiceChainIdProvider(private val chainId: ChainId) : Web3NamesServiceChainIdProvider { + + override fun getChainId(): ChainId { + return chainId + } +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/repository/RealWeb3NamesRepository.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/repository/RealWeb3NamesRepository.kt new file mode 100644 index 0000000..b22b484 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/repository/RealWeb3NamesRepository.kt @@ -0,0 +1,117 @@ +package io.novafoundation.nova.web3names.data.repository + +import io.novafoundation.nova.caip.caip19.Caip19MatcherFactory +import io.novafoundation.nova.caip.caip19.Caip19Parser +import io.novafoundation.nova.caip.caip19.matchers.Caip19Matcher +import io.novafoundation.nova.common.data.network.runtime.binding.bindList +import io.novafoundation.nova.common.data.network.runtime.binding.bindString +import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct +import io.novafoundation.nova.runtime.ext.isValidAddress +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.web3names.data.provider.Web3NamesServiceChainIdProvider +import io.novafoundation.nova.web3names.data.serviceEndpoint.ServiceEndpoint +import io.novafoundation.nova.web3names.data.serviceEndpoint.W3NRecepient +import io.novafoundation.nova.web3names.data.serviceEndpoint.W3NServiceEndpointHandlerFactory +import io.novafoundation.nova.web3names.domain.exceptions.Web3NamesException.ChainProviderNotFoundException +import io.novafoundation.nova.web3names.domain.exceptions.Web3NamesException.UnsupportedAsset +import io.novafoundation.nova.web3names.domain.exceptions.Web3NamesException.ValidAccountNotFoundException +import io.novafoundation.nova.web3names.domain.models.Web3NameAccount +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.metadata.module +import io.novasama.substrate_sdk_android.runtime.metadata.storage + +class RealWeb3NamesRepository( + private val remoteStorageSource: StorageDataSource, + private val web3NamesServiceChainIdProvider: Web3NamesServiceChainIdProvider, + private val caip19MatcherFactory: Caip19MatcherFactory, + private val caip19Parser: Caip19Parser, + private val serviceEndpointHandlerFactory: W3NServiceEndpointHandlerFactory, +) : Web3NamesRepository { + + override suspend fun queryWeb3NameAccount(web3Name: String, chain: Chain, chainAsset: Chain.Asset): List { + val caip19Matcher = caip19MatcherFactory.getCaip19Matcher(chain, chainAsset) + if (caip19Matcher.isUnsupported()) throw UnsupportedAsset(web3Name, chainAsset) + + val owner = getWeb3NameAccountOwner(web3Name) ?: throw ChainProviderNotFoundException(web3Name) + val serviceEndpoints = getDidServiceEndpoints(owner) + val serviceEndpointHandler = serviceEndpointHandlerFactory.getHandler(serviceEndpoints) ?: throw ValidAccountNotFoundException(web3Name, chain.name) + + val recipients = serviceEndpointHandler.getRecipients(web3Name, chain) + + return findChainRecipients(recipients, web3Name, chain, caip19Matcher) + } + + private suspend fun getWeb3NameAccountOwner(web3Name: String): AccountId? { + return remoteStorageSource.query(web3NamesServiceChainIdProvider.getChainId()) { + runtime.metadata + .module("Web3Names") + .storage("Owner") + .query(web3Name.toByteArray(), binding = ::bindOwners) + } + } + + private suspend fun getDidServiceEndpoints(accountId: AccountId): List { + val serviceEndpoints = remoteStorageSource.query(web3NamesServiceChainIdProvider.getChainId()) { + runtime.metadata.module("Did") + .storage("ServiceEndpoints") + .entries( + accountId, + keyExtractor = { it }, + binding = { data, _ -> bindEndpoint(data) } + ) + } + + return serviceEndpoints.values.toList() + } + + private fun findChainRecipients( + recipients: List, + w3nIdentifier: String, + chain: Chain, + caip19Matcher: Caip19Matcher + ): List { + val matchingRecipients = recipients.groupBy { it.chainIdCaip19 } + .filterKeys { + val caip19Identifier = caip19Parser.parseCaip19(it).getOrNull() ?: return@filterKeys false + + caip19Matcher.match(caip19Identifier) + } + + if (matchingRecipients.isEmpty()) { + throw ValidAccountNotFoundException(w3nIdentifier, chain.name) + } + + val web3NameAccounts = matchingRecipients.flatMap { (_, chainRecipients) -> chainRecipients } + .map { + Web3NameAccount( + address = it.account, + description = it.description, + isValid = chain.isValidAddress(it.account) + ) + } + + if (web3NameAccounts.none(Web3NameAccount::isValid)) { + throw ValidAccountNotFoundException(w3nIdentifier, chain.name) + } + + return web3NameAccounts + } + + private fun bindOwners(data: Any?): AccountId? { + if (data == null) return null + + val ownerStruct = data.castToStruct() + return ownerStruct.get("owner") + } + + private fun bindEndpoint(data: Any?): ServiceEndpoint { + val endpointStruct = data.castToStruct() + + val serviceTypes = bindList(endpointStruct["serviceTypes"]) { bindString(it) } + val urls = bindList(endpointStruct["urls"]) { bindString(it) } + val id = bindString(endpointStruct["id"]) + + return ServiceEndpoint(id, serviceTypes, urls) + } +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/repository/Web3NamesRepository.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/repository/Web3NamesRepository.kt new file mode 100644 index 0000000..7e7617f --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/repository/Web3NamesRepository.kt @@ -0,0 +1,9 @@ +package io.novafoundation.nova.web3names.data.repository + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.web3names.domain.models.Web3NameAccount + +interface Web3NamesRepository { + + suspend fun queryWeb3NameAccount(web3Name: String, chain: Chain, chainAsset: Chain.Asset): List +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/ServiceEndpoint.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/ServiceEndpoint.kt new file mode 100644 index 0000000..3073d84 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/ServiceEndpoint.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.web3names.data.serviceEndpoint + +class ServiceEndpoint( + val id: String, + val serviceTypes: List, + val urls: List +) diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandler.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandler.kt new file mode 100644 index 0000000..77a6c76 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandler.kt @@ -0,0 +1,32 @@ +package io.novafoundation.nova.web3names.data.serviceEndpoint + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.web3names.data.endpoints.TransferRecipientsApi +import io.novafoundation.nova.web3names.domain.exceptions.Web3NamesException + +class W3NRecepient( + val chainIdCaip19: String, + val account: String, + val description: String? +) + +abstract class W3NServiceEndpointHandler( + private val endpoint: ServiceEndpoint, + private val transferRecipientApi: TransferRecipientsApi +) { + + suspend fun getRecipients(web3Name: String, chain: Chain): List { + val url = endpoint.urls.firstOrNull() ?: throw Web3NamesException.ValidAccountNotFoundException(web3Name, chain.id) + val recipientsContent = transferRecipientApi.getTransferRecipientsRaw(url) + + if (!verifyIntegrity(serviceEndpointId = endpoint.id, serviceEndpointContent = recipientsContent)) { + throw Web3NamesException.IntegrityCheckFailed(web3Name) + } + + return parseRecipients(recipientsContent) + } + + abstract fun verifyIntegrity(serviceEndpointId: String, serviceEndpointContent: String): Boolean + + protected abstract fun parseRecipients(content: String): List +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandlerFactory.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandlerFactory.kt new file mode 100644 index 0000000..a10add0 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandlerFactory.kt @@ -0,0 +1,27 @@ +package io.novafoundation.nova.web3names.data.serviceEndpoint + +import com.google.gson.Gson +import io.novafoundation.nova.web3names.data.endpoints.TransferRecipientsApi + +private const val TRANSFER_ASSETS_PROVIDER_DID_SERVICE_TYPE_V1 = "KiltTransferAssetRecipientV1" +private const val TRANSFER_ASSETS_PROVIDER_DID_SERVICE_TYPE_V2 = "KiltTransferAssetRecipientV2" + +class W3NServiceEndpointHandlerFactory( + private val transferRecipientsApi: TransferRecipientsApi, + private val gson: Gson +) { + + fun getHandler(serviceEndpoints: List): W3NServiceEndpointHandler? { + val v2ServiceEndpoint = serviceEndpoints.firstOrNull { TRANSFER_ASSETS_PROVIDER_DID_SERVICE_TYPE_V2 in it.serviceTypes } + if (v2ServiceEndpoint != null) { + return W3NServiceEndpointHandlerV2(v2ServiceEndpoint, transferRecipientsApi, gson) + } + + val v1ServiceEndpoint = serviceEndpoints.firstOrNull { TRANSFER_ASSETS_PROVIDER_DID_SERVICE_TYPE_V1 in it.serviceTypes } + if (v1ServiceEndpoint != null) { + return W3NServiceEndpointHandlerV1(v1ServiceEndpoint, transferRecipientsApi, gson) + } + + return null + } +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandlerV1.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandlerV1.kt new file mode 100644 index 0000000..6201393 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandlerV1.kt @@ -0,0 +1,38 @@ +package io.novafoundation.nova.web3names.data.serviceEndpoint + +import com.google.gson.Gson +import io.ipfs.multibase.Multibase +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.web3names.data.endpoints.TransferRecipientsApi +import io.novafoundation.nova.web3names.data.endpoints.model.TransferRecipientDetailsRemoteV1 +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 + +private typealias RecipientsByChainV1 = Map> + +class W3NServiceEndpointHandlerV1( + endpoint: ServiceEndpoint, + transferRecipientApi: TransferRecipientsApi, + private val gson: Gson +) : W3NServiceEndpointHandler(endpoint, transferRecipientApi) { + + override fun verifyIntegrity(serviceEndpointId: String, serviceEndpointContent: String): Boolean = runCatching { + val expectedHash = Multibase.decode(serviceEndpointId) + + val actualHash = serviceEndpointContent.encodeToByteArray().blake2b256() + + expectedHash.contentEquals(actualHash) + }.getOrDefault(false) + + override fun parseRecipients(content: String): List { + val recipients = gson.fromJson(content) + return recipients.flatMap { (chainId, recipients) -> + recipients.map { recipient -> + W3NRecepient( + chainIdCaip19 = chainId, + account = recipient.account, + description = recipient.description + ) + } + } + } +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandlerV2.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandlerV2.kt new file mode 100644 index 0000000..616d48d --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/data/serviceEndpoint/W3NServiceEndpointHandlerV2.kt @@ -0,0 +1,40 @@ +package io.novafoundation.nova.web3names.data.serviceEndpoint + +import com.google.gson.Gson +import io.ipfs.multibase.Multibase +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.web3names.data.endpoints.TransferRecipientsApi +import io.novafoundation.nova.web3names.data.endpoints.model.TransferRecipientDetailsRemoteV2 +import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256 +import org.erdtman.jcs.JsonCanonicalizer + +private typealias RecipientsByChainV2 = Map> + +class W3NServiceEndpointHandlerV2( + endpoint: ServiceEndpoint, + transferRecipientApi: TransferRecipientsApi, + private val gson: Gson +) : W3NServiceEndpointHandler(endpoint, transferRecipientApi) { + + override fun verifyIntegrity(serviceEndpointId: String, serviceEndpointContent: String): Boolean = runCatching { + val expectedHash = Multibase.decode(serviceEndpointId) + val canonizedJson = JsonCanonicalizer(serviceEndpointContent).encodedString + + val actualHash = canonizedJson.encodeToByteArray().blake2b256() + + expectedHash.contentEquals(actualHash) + }.getOrDefault(false) + + override fun parseRecipients(content: String): List { + val fromJson = gson.fromJson(content) + return fromJson.flatMap { (chainId, recipients) -> + recipients.map { recipient -> + W3NRecepient( + chainIdCaip19 = chainId, + account = recipient.key, + description = recipient.value.description + ) + } + } + } +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesApi.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesApi.kt new file mode 100644 index 0000000..3ddf286 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesApi.kt @@ -0,0 +1,8 @@ +package io.novafoundation.nova.web3names.di + +import io.novafoundation.nova.web3names.domain.networking.Web3NamesInteractor + +interface Web3NamesApi { + + val web3NamesInteractor: Web3NamesInteractor +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesDependencies.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesDependencies.kt new file mode 100644 index 0000000..915b423 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesDependencies.kt @@ -0,0 +1,23 @@ +package io.novafoundation.nova.web3names.di + +import com.google.gson.Gson +import io.novafoundation.nova.caip.caip19.Caip19MatcherFactory +import io.novafoundation.nova.caip.caip19.Caip19Parser +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import javax.inject.Named + +interface Web3NamesDependencies { + + val networkApiCreator: NetworkApiCreator + + val gson: Gson + + val caip19Parser: Caip19Parser + + val caip19MatcherFactory: Caip19MatcherFactory + + @Named(REMOTE_STORAGE_SOURCE) + fun remoteStorageSource(): StorageDataSource +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesFeatureComponent.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesFeatureComponent.kt new file mode 100644 index 0000000..b2ed141 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesFeatureComponent.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.web3names.di + +import dagger.Component +import io.novafoundation.nova.caip.di.CaipApi +import io.novafoundation.nova.common.di.CommonApi +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.runtime.di.RuntimeApi + +@Component( + modules = [ + Web3NamesModule::class + ], + dependencies = [ + Web3NamesDependencies::class + ] +) +@FeatureScope +abstract class Web3NamesFeatureComponent : Web3NamesApi { + + @Component( + dependencies = [ + CommonApi::class, + DbApi::class, + RuntimeApi::class, + CaipApi::class + ] + ) + interface Web3NamesDependenciesComponent : Web3NamesDependencies +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesHolder.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesHolder.kt new file mode 100644 index 0000000..83f11e0 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesHolder.kt @@ -0,0 +1,28 @@ +package io.novafoundation.nova.web3names.di + +import io.novafoundation.nova.caip.di.CaipApi +import io.novafoundation.nova.common.di.FeatureApiHolder +import io.novafoundation.nova.common.di.FeatureContainer +import io.novafoundation.nova.common.di.scope.ApplicationScope +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.runtime.di.RuntimeApi +import javax.inject.Inject + +@ApplicationScope +class Web3NamesHolder @Inject constructor( + featureContainer: FeatureContainer +) : FeatureApiHolder(featureContainer) { + + override fun initializeDependencies(): Any { + val dbDependencies = DaggerWeb3NamesFeatureComponent_Web3NamesDependenciesComponent.builder() + .commonApi(commonApi()) + .dbApi(getFeature(DbApi::class.java)) + .runtimeApi(getFeature(RuntimeApi::class.java)) + .caipApi(getFeature(CaipApi::class.java)) + .build() + + return DaggerWeb3NamesFeatureComponent.builder() + .web3NamesDependencies(dbDependencies) + .build() + } +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesModule.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesModule.kt new file mode 100644 index 0000000..41aa672 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/di/Web3NamesModule.kt @@ -0,0 +1,78 @@ +package io.novafoundation.nova.web3names.di + +import com.google.gson.Gson +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.caip.caip19.Caip19MatcherFactory +import io.novafoundation.nova.caip.caip19.Caip19Parser +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE +import io.novafoundation.nova.runtime.ext.Geneses +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.runtime.storage.source.StorageDataSource +import io.novafoundation.nova.web3names.data.endpoints.TransferRecipientsApi +import io.novafoundation.nova.web3names.data.provider.RealWeb3NamesServiceChainIdProvider +import io.novafoundation.nova.web3names.data.provider.Web3NamesServiceChainIdProvider +import io.novafoundation.nova.web3names.data.repository.RealWeb3NamesRepository +import io.novafoundation.nova.web3names.data.repository.Web3NamesRepository +import io.novafoundation.nova.web3names.data.serviceEndpoint.W3NServiceEndpointHandlerFactory +import io.novafoundation.nova.web3names.domain.networking.RealWeb3NamesInteractor +import io.novafoundation.nova.web3names.domain.networking.Web3NamesInteractor +import javax.inject.Named + +@Module +class Web3NamesModule { + + @Provides + @FeatureScope + fun provideWeb3NamesServiceChainIdProvider(): Web3NamesServiceChainIdProvider { + return RealWeb3NamesServiceChainIdProvider(Chain.Geneses.KILT) + } + + @Provides + @FeatureScope + fun provideTransferRecipientApi( + networkApiCreator: NetworkApiCreator + ): TransferRecipientsApi { + return networkApiCreator.create(TransferRecipientsApi::class.java) + } + + @Provides + @FeatureScope + fun provideW3NServiceEndpointHandlerFactory( + transferRecipientApi: TransferRecipientsApi, + gson: Gson + ) = W3NServiceEndpointHandlerFactory( + transferRecipientApi, + gson + ) + + @Provides + @FeatureScope + fun provideWeb3NamesRepository( + @Named(REMOTE_STORAGE_SOURCE) storageDataSource: StorageDataSource, + web3NamesServiceChainIdProvider: Web3NamesServiceChainIdProvider, + caip19MatcherFactory: Caip19MatcherFactory, + caip19Parser: Caip19Parser, + w3NServiceEndpointHandlerFactory: W3NServiceEndpointHandlerFactory + ): Web3NamesRepository { + return RealWeb3NamesRepository( + storageDataSource, + web3NamesServiceChainIdProvider, + caip19MatcherFactory, + caip19Parser, + w3NServiceEndpointHandlerFactory + ) + } + + @Provides + @FeatureScope + fun provideWeb3NamesInteractor( + web3NamesRepository: Web3NamesRepository + ): Web3NamesInteractor { + return RealWeb3NamesInteractor( + web3NamesRepository + ) + } +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/domain/exceptions/ParseWeb3NameException.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/domain/exceptions/ParseWeb3NameException.kt new file mode 100644 index 0000000..f2d3f7d --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/domain/exceptions/ParseWeb3NameException.kt @@ -0,0 +1,3 @@ +package io.novafoundation.nova.web3names.domain.exceptions + +class ParseWeb3NameException : Exception() diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/domain/exceptions/Web3NamesException.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/domain/exceptions/Web3NamesException.kt new file mode 100644 index 0000000..1eb873f --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/domain/exceptions/Web3NamesException.kt @@ -0,0 +1,19 @@ +package io.novafoundation.nova.web3names.domain.exceptions + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.requirePrefix + +sealed class Web3NamesException(identifier: String) : Exception() { + + val web3Name: String = identifier.requirePrefix("w3n:") + + class ChainProviderNotFoundException(identifier: String) : Web3NamesException(identifier) + + class IntegrityCheckFailed(identifier: String) : Web3NamesException(identifier) + + class ValidAccountNotFoundException(identifier: String, val chainName: String) : Web3NamesException(identifier) + + class UnknownException(web3NameInput: String, val chainName: String) : Web3NamesException(web3NameInput) + + class UnsupportedAsset(identifier: String, val chainAsset: Chain.Asset) : Web3NamesException(identifier) +} diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/domain/models/Web3NameAccount.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/domain/models/Web3NameAccount.kt new file mode 100644 index 0000000..d6ed0ed --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/domain/models/Web3NameAccount.kt @@ -0,0 +1,7 @@ +package io.novafoundation.nova.web3names.domain.models + +class Web3NameAccount( + val address: String, + val description: String?, + val isValid: Boolean, +) diff --git a/web3names/src/main/java/io/novafoundation/nova/web3names/domain/networking/Web3NamesInteractor.kt b/web3names/src/main/java/io/novafoundation/nova/web3names/domain/networking/Web3NamesInteractor.kt new file mode 100644 index 0000000..6befe43 --- /dev/null +++ b/web3names/src/main/java/io/novafoundation/nova/web3names/domain/networking/Web3NamesInteractor.kt @@ -0,0 +1,50 @@ +package io.novafoundation.nova.web3names.domain.networking + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novafoundation.nova.web3names.data.repository.Web3NamesRepository +import io.novafoundation.nova.web3names.domain.exceptions.ParseWeb3NameException +import io.novafoundation.nova.web3names.domain.models.Web3NameAccount + +interface Web3NamesInteractor { + + fun isValidWeb3Name(raw: String): Boolean + + suspend fun queryAccountsByWeb3Name(w3nIdentifier: String, chain: Chain, chainAsset: Chain.Asset): List + + fun removePrefix(w3nIdentifier: String): String +} + +class RealWeb3NamesInteractor( + private val web3NamesRepository: Web3NamesRepository +) : Web3NamesInteractor { + + override fun isValidWeb3Name(raw: String): Boolean { + return parseToWeb3Name(raw).isSuccess + } + + override suspend fun queryAccountsByWeb3Name(w3nIdentifier: String, chain: Chain, chainAsset: Chain.Asset): List { + require(isValidWeb3Name(w3nIdentifier)) + + val web3NameNoPrefix = parseToWeb3Name(w3nIdentifier).getOrThrow() + + return web3NamesRepository.queryWeb3NameAccount(web3NameNoPrefix, chain, chainAsset) + } + + override fun removePrefix(w3nIdentifier: String): String { + require(isValidWeb3Name(w3nIdentifier)) + + return parseToWeb3Name(w3nIdentifier).getOrThrow() + } + + private fun parseToWeb3Name(raw: String): Result { + return runCatching { + val (web3NameKey, web3NameValue) = raw.split(":", limit = 2) + + if (web3NameKey.trim() == "w3n") { + web3NameValue.trim() + } else { + throw ParseWeb3NameException() + } + } + } +} diff --git a/web3names/src/test/java/io/novafoundation/nova/web3names/We3NamesIntegrityVerifierTestV1.kt b/web3names/src/test/java/io/novafoundation/nova/web3names/We3NamesIntegrityVerifierTestV1.kt new file mode 100644 index 0000000..4f812a1 --- /dev/null +++ b/web3names/src/test/java/io/novafoundation/nova/web3names/We3NamesIntegrityVerifierTestV1.kt @@ -0,0 +1,82 @@ +package io.novafoundation.nova.web3names + +import com.google.gson.Gson +import io.novafoundation.nova.web3names.data.endpoints.TransferRecipientsApi +import io.novafoundation.nova.web3names.data.serviceEndpoint.ServiceEndpoint +import io.novafoundation.nova.web3names.data.serviceEndpoint.W3NServiceEndpointHandlerV1 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito.mock + + +class We3NamesIntegrityVerifierTestV1 { + + private val endpointHandler = W3NServiceEndpointHandlerV1( + endpoint = mock(ServiceEndpoint::class.java), + transferRecipientApi = mock(TransferRecipientsApi::class.java), + gson = Gson() + ) + + @Test + fun `should accept valid id matching resource hash`() = runTest( + endpointId = "Uc1JU0UF9iDfjaRkgHCFG2Rc5jki-cuhlgbEQcjN6-g0=", + expectedOutcome = true, + ) + + @Test + fun `should reject valid id not matching resource hash`() { + runTest( + endpointId = "Uinvalid-hash=", + expectedOutcome = false, + ) + + runTest( + endpointId = "Uc1JU0UF9iDfjaRkgHCFG2Rc5jki-cuhlgbEQcjN6-g0=", + resource = "changed resource", + expectedOutcome = false, + ) + } + + @Test + fun `should reject invalid id`() = runTest( + endpointId = "123", + expectedOutcome = false, + ) + + private fun runTest( + endpointId: String, + resource: String = testResource(), + expectedOutcome: Boolean + ) { + assertEquals(expectedOutcome, endpointHandler.verifyIntegrity(endpointId, resource)) + } + + private fun testResource(): String { + return """ +{ + "polkadot:411f057b9107718c9624d6aa4a3f23c1/slip44:2086": [ + { + "account": "4qBSZdEoUxPVnUqbX8fjXovgtQXcHK7ZvSf56527XcDZUukq", + "description": "Treasury proposals transfers" + }, + { + "account": "4oHvgA54py7SWFPpBCoubAajYrxj6xyc8yzHiAVryeAq574G", + "description": "Regular transfers" + }, + { + "account": "4taHgf8x9U5b8oJaiYoNEh61jaHpKs9caUdattxfBRkJMHvm" + } + ], + "eip:1/slip44:60": [ + { + "account": "0x8f8221AFBB33998D8584A2B05749BA73C37A938A", + "description": "NFT sales" + }, + { + "account": "0x6b175474e89094c44da98b954eedeac495271d0f" + } + ] +} + """.trimIndent() + } +} diff --git a/web3names/src/test/java/io/novafoundation/nova/web3names/We3NamesIntegrityVerifierTestV2.kt b/web3names/src/test/java/io/novafoundation/nova/web3names/We3NamesIntegrityVerifierTestV2.kt new file mode 100644 index 0000000..88c3827 --- /dev/null +++ b/web3names/src/test/java/io/novafoundation/nova/web3names/We3NamesIntegrityVerifierTestV2.kt @@ -0,0 +1,135 @@ +package io.novafoundation.nova.web3names + +import com.google.gson.Gson +import io.novafoundation.nova.web3names.data.endpoints.TransferRecipientsApi +import io.novafoundation.nova.web3names.data.serviceEndpoint.ServiceEndpoint +import io.novafoundation.nova.web3names.data.serviceEndpoint.W3NServiceEndpointHandlerV1 +import io.novafoundation.nova.web3names.data.serviceEndpoint.W3NServiceEndpointHandlerV2 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito.mock + + +class We3NamesIntegrityVerifierTestV2 { + + private val endpointHandler = W3NServiceEndpointHandlerV2( + endpoint = mock(ServiceEndpoint::class.java), + transferRecipientApi = mock(TransferRecipientsApi::class.java), + gson = Gson() + ) + + @Test + fun `should accept valid id matching resource hash`() = runTest( + endpointId = "UyLezITywEzmapmqriDu7sQM9xfXefYAqlmLxVoHFLpw=", + expectedOutcome = true, + ) + + @Test + fun `should reject valid id not matching resource hash`() { + runTest( + endpointId = "Uinvalid-hash=", + expectedOutcome = false, + ) + + runTest( + endpointId = "UyLezITywEzmapmqriDu7sQM9xfXefYAqlmLxVoHFLpw=", + resource = "changed resource", + expectedOutcome = false, + ) + } + + @Test + fun `should reject invalid id`() = runTest( + endpointId = "123", + expectedOutcome = false, + ) + + private fun runTest( + endpointId: String, + resource: String = testResource(), + expectedOutcome: Boolean + ) { + assertEquals(expectedOutcome, endpointHandler.verifyIntegrity(endpointId, resource)) + } + + /** + * Not canonized json + */ + private fun testResource(): String { + return """ +{ + "polkadot:91b171bb158e2d3848fa23a9f1c25182/slip44:354": { + "121eb2BdXbD2AbSno4qSpiGKL28TNGngzvfvLXQtN6qCmG7N": { + "description": "🏡 Day 7" + }, + "158rvBQ6sPTM7YMPrSvMMPpuQovELfMQdeFomXQkj63TjeNy": { + "description": "valid DOT Polkadot" + }, + "0x467E99717a0f54226454121e6e20Dd1549b30d0e": {}, + "0x3d89f11254b4eb4c251ED2593863b": { + "description": "Invalid Personal account" + } + }, + "polkadot:411f057b9107718c9624d6aa4a3f23c1/slip44:2086": { + "4q1yjt4UKaArQbLbb43AKHeFhjeEtQZ77j3MFEQDEEpsgYo1": { + "description": "Valid Kilt address" + }, + "4nvZhWv71x8reD9gq7BUGYQQVvTiThnLpTTanyru9XckaeWa": { + "description": "Council account" + } + }, + "polkadot:fc41b9bd8ef8fe53d58c7ea67c794c7e/slip44:787": { + "251TW6p8iJCcg8Q2umib8QV9sfMAtVecX3o7vMqmMdzLYif7": { + "description": "valid ACA Acala" + }, + "251TW6p8iJCcg8Q2umib8QV9sfMAtVecX3o7vMqmMdz": { + "description": "invalid Acala" + } + }, + "polkadot:fc41b9bd8ef8fe53d58c7ea67c794c7e/slip44:354": { + "251TW6p8iJCcg8Q2umib8QV9sfMAtVecX3o7vMqmMdzLYif7": { + "description": "valid DOT Acala" + }, + "251TW6p8iJCcg8Q2umib8QV9sfMAtVecX3o7vMqmMdz": { + "description": "invalid Acala" + } + }, + "polkadot:fe58ea77779b7abda7da4ec526d14db9/slip44:1284": { + "0x467E99717a0f54226454121e6e20Dd1549b30d0e": { + "description": "valid GLMR Moonbeam" + }, + "0x467E99717a0f54226454121e6e20Dd1549b": { + "description": "invalid Moonbeam" + } + }, + "polkadot:fe58ea77779b7abda7da4ec526d14db9/slip44:354": { + "0x467E99717a0f54226454121e6e20Dd1549b30d0e": { + "description": "valid DOT Moonbeam" + }, + "0x467E99717a0f54226454121e6e20Dd1549b": { + "description": "invalid Moonbeam" + } + }, + "polkadot:b0a8d493285c2df73290dfb7e61f870f/slip44:434": { + "Day71GSJAxUUiFic8bVaWoAczR3Ue3jNonBZthVHp2BKzyJ": { + "description": "🏡 Day 7" + } + }, + "eip155:1/slip44:60": { + "0x5173Dab1947CFD36fA7dCdBAE55C7E71A982a181": { + "description": "valid ETH Ethereum" + }, + "5173Dab1947CFD36fA7dCdBAE55C7E71A982a181": { + "description": "invalid ETH" + } + }, + "eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f": { + "0x5173Dab1947CFD36fA7dCdBAE55C7E71A982a181": { + "description": "valid DAI Ethereum" + }, + "0x6b175474e89094c44da98b954ee": {} + } +} + """.trimIndent() + } +}